4 Commits

Author SHA1 Message Date
a4397c5178 chore: remove AP Groups surfaces (legacy API auth incompatible)
UniFi's /rest/apgroup endpoints (and per-SSID ap_group_ids writes via
/rest/wlanconf) require session-cookie auth — they don't accept the
X-API-Key header. The Integration API doesn't expose AP groups at all.
So with the current deployment running on API-key auth, every AP-group
operation returned 400 api.err.InvalidObject. Removing the dead code
rather than carrying a feature that can't function.

* Deleted ApGroupController, ApGroups.vue, the /ap-groups/* routes,
  and getApGroups/createApGroup/updateApGroup/deleteApGroup from
  UnifiApiClient.
* Removed the per-SSID AP-group assignment from Wifi.vue + the
  updateApGroups action + /wifi/{wlanId}/ap-groups route + the
  ap_group_ids field from the mapWlan output.
* Removed the AP Groups nav entry from composer.json.

If a future deploy adds local-admin username+password auth, AP groups
can be reintroduced — the UnifiApiClient::buildRequest() session-cookie
path is intact.

v1.3.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 16:35:32 -04:00
fc4f5370ae fix(ppsk): null schedule = always on; disabled global toggle restores all
Previously SyncPpskSchedules returned early when the global setting
was disabled, leaving any PPSK that had been held by a prior sync
stuck in 'held' state. It also only iterated whereNotNull(schedule),
so null-schedule PPSKs ("always on") were never drift-corrected back
to active either.

Now the command always runs and computes a per-PPSK target state:
  - global ppsk_scheduling disabled  → target = active (always)
  - global enabled  + null schedule  → target = active (always)
  - global enabled  + has schedule   → follow the schedule's slot

PPSKs that drift from the target get enabled/disabled accordingly.
Schedules in unifi_ppsks.schedule are preserved across global toggles
either way — disabling the setting doesn't touch them, so re-enabling
resumes the operator's per-PPSK schedules.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 16:09:40 -04:00
e59f193ffc feat(nav): surface AP Groups page; always-fresh device data on edit pages
The AP Groups page (ApGroups.vue + ApGroupController + UnifiApiClient
CRUD methods) has been built but never declared in composer.json's
pages list, so it was hidden from the menu. Added it at sort_order=6,
between WiFi Networks and Portal.

The WiFi Networks page already has per-SSID AP-group assignment via
the existing updateApGroups route — that wires into UniFi's standard
ap_group_ids field on wlanconf. (UniFi doesn't expose per-AP-only
assignment separately; the convention is "make a one-AP group for
this AP and assign the SSID to it.")

For the "always pull from UniFi on load" guarantee:
- getWlans() and getApGroups() are already uncached — fresh on every
  page load
- getDevices() (feeds the AP picker for group membership) is cached
  for unifi.cache_ttl seconds; both ApGroupController::index and
  WifiController::index now Cache::forget('unifi:devices') before
  reading so the device list is always fresh

v1.3.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 16:06:49 -04:00
f7672771e0 refactor: read timezone from shell-level site_timezone
Drops unifi.timezone from the settings form (now lives in
Admin → Settings on the shell). Schedulers (PPSK sync, password
rotation) now read \App\Support\Timezone::current() — same fallback
chain as the rest of the platform.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 15:26:50 -04:00
8 changed files with 19 additions and 158 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "dashboard/unifi",
"description": "UniFi network management, WiFi stats, and captive portal authentication for the Dashboard platform",
"version": "1.0.0",
"version": "1.3.1",
"type": "library",
"license": "MIT",
"autoload": {

View File

@@ -81,7 +81,7 @@ class RotatePasswords extends Command
$hour = (int) Setting::get('unifi.password_rotation.hour', 2);
$minute = (int) Setting::get('unifi.password_rotation.minute', 0);
$dow = (int) Setting::get('unifi.password_rotation.day_of_week', 0);
$tz = Setting::get('unifi.timezone', 'UTC');
$tz = \App\Support\Timezone::current();
$now = now($tz);
if ($now->hour !== $hour || $now->minute !== $minute) {

View File

@@ -14,16 +14,21 @@ class SyncPpskSchedules extends Command
public function handle(UnifiApiClient $unifi): int
{
if (! $this->option('force') && ! Setting::get('unifi.ppsk_scheduling.enabled')) {
return self::SUCCESS;
}
// Always run, even when global ppsk_scheduling is disabled — in
// that case the target state for every PPSK is "active" (always
// on). That way disabling the global setting actually restores
// any held PPSKs to active without operators having to do
// anything else, and null-schedule PPSKs always end up active.
// Schedules in the DB are preserved regardless of toggle state,
// so re-enabling resumes the per-PPSK schedule.
$globalEnabled = (bool) Setting::get('unifi.ppsk_scheduling.enabled');
$tz = Setting::get('unifi.timezone', 'UTC');
$tz = \App\Support\Timezone::current();
$now = now($tz);
$day = $now->dayOfWeek; // 0=Sun … 6=Sat
$slot = $now->hour * 2 + ($now->minute >= 30 ? 1 : 0); // 047
$ppsks = UnifiPpsk::whereNotNull('schedule')->get();
$ppsks = UnifiPpsk::all();
if ($ppsks->isEmpty()) {
return self::SUCCESS;
@@ -42,7 +47,12 @@ class SyncPpskSchedules extends Command
}
foreach ($ppsks as $ppsk) {
// Default to "always on". Only consult the schedule if
// global scheduling is enabled AND this PPSK has one.
$shouldBeOn = true;
if ($globalEnabled && $ppsk->schedule) {
$shouldBeOn = (bool) ($ppsk->schedule[$day * 48 + $slot] ?? true);
}
if ($shouldBeOn && $ppsk->state === 'held') {
$this->enablePpsk($ppsk, $unifi, $networksByVlan);

View File

@@ -1,88 +0,0 @@
<?php
namespace Dashboard\Unifi\Http\Controllers;
use Dashboard\Unifi\Services\UnifiApiClient;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Inertia\Inertia;
class ApGroupController extends Controller
{
public function index(UnifiApiClient $unifi)
{
try {
$groups = collect($unifi->getApGroups())->map(fn ($g) => [
'id' => $g['_id'],
'name' => $g['name'] ?? 'Unnamed',
'device_macs' => $g['device_macs'] ?? [],
'is_default' => $g['attr_no_delete'] ?? false,
])->values();
$devices = collect($unifi->getAccessPoints())->map(fn ($d) => [
'mac' => strtolower($d['mac']),
'name' => $d['name'] ?? $d['model'] ?? $d['mac'],
'model' => $d['model'] ?? '',
'state' => $d['state'] ?? 0,
])->values();
return Inertia::render('Unifi/ApGroups', [
'groups' => $groups,
'devices' => $devices,
]);
} catch (\Throwable $e) {
return Inertia::render('Unifi/ApGroups', [
'groups' => [], 'devices' => [], 'error' => $e->getMessage(),
]);
}
}
public function store(Request $request, UnifiApiClient $unifi)
{
$data = $request->validate([
'name' => 'required|string|max:100',
'device_macs' => 'present|array',
'device_macs.*' => 'string',
]);
try {
$result = $unifi->createApGroup([
'name' => $data['name'],
'device_macs' => array_values(array_map('strtolower', $data['device_macs'])),
]);
return back()->with('success', 'AP group created.');
} catch (\Throwable $e) {
return back()->withErrors(['error' => $e->getMessage()]);
}
}
public function update(Request $request, string $groupId, UnifiApiClient $unifi)
{
$data = $request->validate([
'name' => 'sometimes|string|max:100',
'device_macs' => 'sometimes|array',
'device_macs.*' => 'string',
]);
if (isset($data['device_macs'])) {
$data['device_macs'] = array_values(array_map('strtolower', $data['device_macs']));
}
try {
$unifi->updateApGroup($groupId, $data);
return back()->with('success', 'AP group updated.');
} catch (\Throwable $e) {
return back()->withErrors(['error' => $e->getMessage()]);
}
}
public function destroy(string $groupId, UnifiApiClient $unifi)
{
try {
$unifi->deleteApGroup($groupId);
return back()->with('success', 'AP group deleted.');
} catch (\Throwable $e) {
return back()->withErrors(['error' => $e->getMessage()]);
}
}
}

View File

@@ -19,7 +19,6 @@ class UnifiSettingsController extends Controller
'pollInterval' => (int) Setting::get('unifi.poll_interval', 30),
'cacheTtl' => (int) Setting::get('unifi.cache_ttl', 30),
'retentionDays' => (int) Setting::get('unifi.retention_days', 30),
'timezone' => Setting::get('unifi.timezone', 'UTC'),
'autoRebootEnabled' => (bool) Setting::get('unifi.auto_reboot.enabled', false),
'autoRebootFrequency' => Setting::get('unifi.auto_reboot.frequency', 'daily'),
'autoRebootDow' => (int) Setting::get('unifi.auto_reboot.day_of_week', 0),
@@ -45,7 +44,6 @@ class UnifiSettingsController extends Controller
'poll_interval' => 'nullable|integer|min:5|max:300',
'cache_ttl' => 'nullable|integer|min:5|max:300',
'retention_days' => 'nullable|integer|min:1|max:365',
'timezone' => 'nullable|string|timezone',
'auto_reboot_enabled' => 'boolean',
'auto_reboot_frequency' => 'in:daily,weekly',
'auto_reboot_dow' => 'nullable|integer|min:0|max:6',
@@ -70,7 +68,6 @@ class UnifiSettingsController extends Controller
if ($request->has('poll_interval')) Setting::set('unifi.poll_interval', $request->poll_interval ?? 30);
if ($request->has('cache_ttl')) Setting::set('unifi.cache_ttl', $request->cache_ttl ?? 30);
if ($request->has('retention_days')) Setting::set('unifi.retention_days', $request->retention_days ?? 30);
if ($request->has('timezone')) Setting::set('unifi.timezone', $request->timezone ?? 'UTC');
Setting::set('unifi.auto_reboot.enabled', $request->boolean('auto_reboot_enabled') ? '1' : '');
Setting::set('unifi.auto_reboot.frequency', $request->input('auto_reboot_frequency', 'daily'));

View File

@@ -16,17 +16,6 @@ class WifiController extends Controller
try {
$wlans = collect($unifi->getWlans())->map(fn ($w) => $this->mapWlan($w))->values();
try {
$apGroups = collect($unifi->getApGroups())->map(fn ($g) => [
'id' => $g['_id'],
'name' => $g['attr_no_delete'] ?? false ? 'Default' : ($g['name'] ?? 'Unnamed'),
'device_macs' => $g['device_macs'] ?? [],
'is_default' => $g['attr_no_delete'] ?? false,
])->values();
} catch (\Throwable $e) {
$apGroups = collect(); // AP groups not supported by this controller
}
$raw = Setting::get('unifi.ssid_groups', '{}');
$groups = json_decode($raw, true);
if (! is_array($groups) || array_is_list($groups)) $groups = [];
@@ -37,13 +26,12 @@ class WifiController extends Controller
return Inertia::render('Unifi/Wifi', [
'wlans' => $wlans,
'groups' => $groups,
'apGroups' => $apGroups,
'rotateWlanIds' => $rotateWlanIds,
'ppskSchedulingEnabled' => (bool) Setting::get('unifi.ppsk_scheduling.enabled', false),
]);
} catch (\Throwable $e) {
return Inertia::render('Unifi/Wifi', [
'wlans' => [], 'groups' => [], 'apGroups' => [], 'rotateWlanIds' => [], 'error' => $e->getMessage(),
'wlans' => [], 'groups' => [], 'rotateWlanIds' => [], 'error' => $e->getMessage(),
]);
}
}
@@ -98,21 +86,6 @@ class WifiController extends Controller
}
}
/**
* Update AP group assignments for a single WLAN (not synced to group siblings).
*/
public function updateApGroups(Request $request, string $wlanId, UnifiApiClient $unifi)
{
$request->validate(['ap_group_ids' => 'required|array']);
try {
$unifi->updateWlan($wlanId, ['ap_group_ids' => $request->ap_group_ids]);
return back()->with('success', 'AP groups updated.');
} catch (\Throwable $e) {
return back()->withErrors(['error' => $e->getMessage()]);
}
}
public function toggle(Request $request, string $wlanId, UnifiApiClient $unifi)
{
$request->validate(['enabled' => 'required|boolean']);
@@ -414,7 +387,6 @@ class WifiController extends Controller
'hide_ssid' => $w['hide_ssid'] ?? false,
'passphrase' => $w['x_passphrase'] ?? '',
'band' => $this->detectBand($w),
'ap_group_ids' => $w['ap_group_ids'] ?? [],
'mac_filter_enabled' => $w['mac_filter_enabled'] ?? false,
'mac_filter_policy' => $w['mac_filter_policy'] ?? 'deny',
'ppsk_enabled' => ($w['wpa3_ppsk'] ?? false)

View File

@@ -312,28 +312,6 @@ class UnifiApiClient
return $this->put("/rest/wlanconf/{$wlanId}", $data);
}
// ── AP Groups ─────────────────────────────────────────────────────────────
public function getApGroups(): array
{
return $this->get('/rest/apgroups');
}
public function createApGroup(array $data): array
{
return $this->post('/rest/apgroups', $data);
}
public function updateApGroup(string $groupId, array $data): array
{
return $this->put("/rest/apgroups/{$groupId}", $data);
}
public function deleteApGroup(string $groupId): void
{
$this->delete("/rest/apgroups/{$groupId}");
}
// ── PPSK ─────────────────────────────────────────────────────────────────
/**

View File

@@ -1,6 +1,5 @@
<?php
use Dashboard\Unifi\Http\Controllers\ApGroupController;
use Dashboard\Unifi\Http\Controllers\ClientController;
use Dashboard\Unifi\Http\Controllers\DeviceController;
use Dashboard\Unifi\Http\Controllers\PortalController;
@@ -32,7 +31,6 @@ Route::middleware(['web', 'auth', 'app.access:unifi'])
// WiFi networks
Route::get('/wifi', [WifiController::class, 'index']) ->name('wifi');
Route::put('/wifi/{wlanId}', [WifiController::class, 'update']) ->name('wifi.update');
Route::put('/wifi/{wlanId}/ap-groups', [WifiController::class, 'updateApGroups']) ->name('wifi.ap-groups');
Route::post('/wifi/{wlanId}/toggle', [WifiController::class, 'toggle']) ->name('wifi.toggle');
Route::post('/wifi/groups', [WifiController::class, 'saveGroups']) ->name('wifi.groups');
@@ -44,12 +42,6 @@ Route::middleware(['web', 'auth', 'app.access:unifi'])
Route::put('/wifi/{wlanId}/ppsk/{ppskId}/schedule', [WifiController::class, 'ppskSchedule']) ->name('wifi.ppsk.schedule');
Route::patch('/wifi/{wlanId}/ppsk/{ppskId}/rotation',[WifiController::class, 'ppskToggleRotation'])->name('wifi.ppsk.rotation');
// AP Groups
Route::get('/ap-groups', [ApGroupController::class, 'index']) ->name('ap-groups.index');
Route::post('/ap-groups', [ApGroupController::class, 'store']) ->name('ap-groups.store');
Route::put('/ap-groups/{groupId}', [ApGroupController::class, 'update']) ->name('ap-groups.update');
Route::delete('/ap-groups/{groupId}', [ApGroupController::class, 'destroy']) ->name('ap-groups.destroy');
// Devices
Route::post('/devices/reboot', [DeviceController::class, 'reboot']) ->name('devices.reboot');
Route::post('/clients/kick', [ClientController::class, 'kick']) ->name('clients.kick');