diff --git a/database/migrations/2026_04_12_000006_create_unifi_password_rotations_table.php b/database/migrations/2026_04_12_000006_create_unifi_password_rotations_table.php new file mode 100644 index 0000000..bc0daa5 --- /dev/null +++ b/database/migrations/2026_04_12_000006_create_unifi_password_rotations_table.php @@ -0,0 +1,30 @@ +id(); + $table->string('name'); // display name (e.g. "Staff WiFi") + $table->json('wlan_ids'); // array of WLAN IDs to rotate + $table->text('wordlist')->nullable(); // one password per line + $table->boolean('enabled')->default(true); + $table->string('frequency')->default('weekly'); // daily | weekly + $table->tinyInteger('day_of_week')->nullable(); // 0=Sun ... 6=Sat (weekly only) + $table->tinyInteger('hour')->default(1); + $table->tinyInteger('minute')->default(0); + $table->timestamp('last_rotated_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('unifi_password_rotations'); + } +}; diff --git a/database/migrations/2026_04_12_000007_create_unifi_vlan_groups_table.php b/database/migrations/2026_04_12_000007_create_unifi_vlan_groups_table.php new file mode 100644 index 0000000..c1871ee --- /dev/null +++ b/database/migrations/2026_04_12_000007_create_unifi_vlan_groups_table.php @@ -0,0 +1,25 @@ +id(); + $table->string('name'); // e.g. "Students" + $table->unsignedSmallInteger('vlan_id'); // 1–4094 + $table->string('description')->nullable(); // optional note + $table->unsignedSmallInteger('sort_order')->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('unifi_vlan_groups'); + } +}; diff --git a/database/migrations/2026_04_13_000008_add_in_alert_to_device_states.php b/database/migrations/2026_04_13_000008_add_in_alert_to_device_states.php new file mode 100644 index 0000000..a7e7be5 --- /dev/null +++ b/database/migrations/2026_04_13_000008_add_in_alert_to_device_states.php @@ -0,0 +1,25 @@ +boolean('in_alert')->default(false)->after('was_online'); + }); + } + + public function down(): void + { + Schema::table('unifi_device_states', function (Blueprint $table) { + $table->dropColumn('in_alert'); + }); + } +}; diff --git a/database/migrations/2026_04_16_000009_create_unifi_ppsks_table.php b/database/migrations/2026_04_16_000009_create_unifi_ppsks_table.php new file mode 100644 index 0000000..e3d5a91 --- /dev/null +++ b/database/migrations/2026_04_16_000009_create_unifi_ppsks_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('wlan_id', 36)->index(); // UniFi WLAN _id + $table->string('unifi_id', 36)->nullable()->index(); // null when held (not currently in UniFi) + $table->string('name', 100); + $table->string('x_passphrase', 63); + $table->unsignedSmallInteger('vlan')->nullable(); + $table->enum('state', ['active', 'held'])->default('active')->index(); + $table->boolean('rotate_password')->default(false); + $table->json('schedule')->nullable(); // 336 booleans [day*48+slot], null = unscheduled + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('unifi_ppsks'); + } +}; diff --git a/src/Console/RebootAllAps.php b/src/Console/RebootAllAps.php new file mode 100644 index 0000000..1e476dc --- /dev/null +++ b/src/Console/RebootAllAps.php @@ -0,0 +1,62 @@ +getAccessPoints(); + } catch (\Throwable $e) { + $this->error('Failed to fetch APs: ' . $e->getMessage()); + return self::FAILURE; + } + + if (empty($aps)) { + $this->warn('No access points found.'); + return self::SUCCESS; + } + + $delay = max(0, (int) $this->option('delay')); + + // Pre-mark all APs as planned reboots before sending any commands + foreach ($aps as $ap) { + $mac = strtolower($ap['mac']); + Cache::put("unifi:planned_reboot:{$mac}", true, now()->addMinutes(20)); + $this->line("Marked planned reboot: {$ap['name']} ({$mac})"); + } + + $this->newLine(); + $ok = 0; + $fail = 0; + + foreach ($aps as $ap) { + $mac = strtolower($ap['mac']); + $name = $ap['name'] ?? $mac; + try { + $unifi->rebootDevice($mac); + $this->info("Rebooted: {$name} ({$mac})"); + $ok++; + } catch (\Throwable $e) { + $this->error("Failed to reboot {$name}: {$e->getMessage()}"); + $fail++; + } + + if ($delay > 0 && $ok + $fail < count($aps)) { + sleep($delay); + } + } + + $this->newLine(); + $this->info("Done. {$ok} rebooted, {$fail} failed."); + return $fail > 0 ? self::FAILURE : self::SUCCESS; + } +} diff --git a/src/Console/RotatePasswords.php b/src/Console/RotatePasswords.php new file mode 100644 index 0000000..bdc1577 --- /dev/null +++ b/src/Console/RotatePasswords.php @@ -0,0 +1,97 @@ +warn('Password rotation: no passwords in wordlist — skipped.'); + return self::SUCCESS; + } + + if (! $this->option('force') && ! $this->isDue()) { + return self::SUCCESS; + } + + $password = $passwords[array_rand($passwords)]; + $rotated = 0; + + foreach ($wlanIds as $wlanId) { + try { + $unifi->updateWlan($wlanId, ['x_passphrase' => $password]); + $rotated++; + } catch (\Throwable $e) { + $this->error("Failed to rotate wlan {$wlanId}: {$e->getMessage()}"); + } + } + + if ($rotated > 0) { + Setting::set('unifi.password_rotation.last_rotated_at', now()->toIso8601String()); + $this->info("Rotated password for {$rotated} SSID(s)."); + } + + // ── Rotate PPSK passwords ──────────────────────────────────────────── + $rotatedPpsks = 0; + foreach (UnifiPpsk::where('rotate_password', true)->where('state', 'active')->whereNotNull('unifi_id')->get() as $ppsk) { + // Each PPSK gets its own independently-chosen password from the wordlist + $newPass = $passwords[array_rand($passwords)]; + try { + $unifi->updatePpsk($ppsk->unifi_id, ['x_passphrase' => $newPass]); + $ppsk->update(['x_passphrase' => $newPass]); + $rotatedPpsks++; + } catch (\Throwable $e) { + $this->error("Failed to rotate PPSK \"{$ppsk->name}\": {$e->getMessage()}"); + } + } + if ($rotatedPpsks > 0) { + $this->info("Rotated password for {$rotatedPpsks} PPSK(s)."); + } + + return self::SUCCESS; + } + + private function isDue(): bool + { + $frequency = Setting::get('unifi.password_rotation.frequency', 'weekly'); + $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'); + $now = now($tz); + + if ($now->hour !== $hour || $now->minute !== $minute) { + return false; + } + + if ($frequency === 'weekly' && $now->dayOfWeek !== $dow) { + return false; + } + + return true; + } +} diff --git a/src/Console/SyncPpskSchedules.php b/src/Console/SyncPpskSchedules.php new file mode 100644 index 0000000..c8c7bca --- /dev/null +++ b/src/Console/SyncPpskSchedules.php @@ -0,0 +1,94 @@ +option('force') && ! Setting::get('unifi.ppsk_scheduling.enabled')) { + return self::SUCCESS; + } + + $tz = Setting::get('unifi.timezone', 'UTC'); + $now = now($tz); + $day = $now->dayOfWeek; // 0=Sun … 6=Sat + $slot = $now->hour * 2 + ($now->minute >= 30 ? 1 : 0); // 0–47 + + $ppsks = UnifiPpsk::whereNotNull('schedule')->get(); + + if ($ppsks->isEmpty()) { + return self::SUCCESS; + } + + // Fetch network confs once so we can resolve vlan → networkconf_id on re-enable + $networksByVlan = []; + try { + foreach ($unifi->getNetworkConfs() as $n) { + if (isset($n['vlan'])) { + $networksByVlan[(int) $n['vlan']] = $n; + } + } + } catch (\Throwable $e) { + $this->warn("Could not fetch network configs: {$e->getMessage()}"); + } + + foreach ($ppsks as $ppsk) { + $shouldBeOn = (bool) ($ppsk->schedule[$day * 48 + $slot] ?? true); + + if ($shouldBeOn && $ppsk->state === 'held') { + $this->enablePpsk($ppsk, $unifi, $networksByVlan); + } elseif (! $shouldBeOn && $ppsk->state === 'active' && $ppsk->unifi_id) { + $this->disablePpsk($ppsk, $unifi); + } + } + + return self::SUCCESS; + } + + private function enablePpsk(UnifiPpsk $ppsk, UnifiApiClient $unifi, array $networksByVlan): void + { + try { + $data = [ + 'name' => $ppsk->name, + 'x_passphrase' => $ppsk->x_passphrase, + 'wlan_id' => $ppsk->wlan_id, + ]; + + if ($ppsk->vlan && isset($networksByVlan[$ppsk->vlan])) { + $data['networkconf_id'] = $networksByVlan[$ppsk->vlan]['_id']; + } + + $result = $unifi->createPpsk($data); + $raw = $result[0] ?? $result; + $newId = $raw['_id'] ?? null; + + $ppsk->update(['state' => 'active', 'unifi_id' => $newId]); + $this->info("Enabled: {$ppsk->name} (wlan {$ppsk->wlan_id})"); + } catch (\Throwable $e) { + $this->error("Failed to enable PPSK \"{$ppsk->name}\": {$e->getMessage()}"); + } + } + + private function disablePpsk(UnifiPpsk $ppsk, UnifiApiClient $unifi): void + { + try { + $kicked = $unifi->kickClientsForPpsk($ppsk->unifi_id); + $unifi->deletePpsk($ppsk->unifi_id); + $ppsk->update(['state' => 'held', 'unifi_id' => null]); + + $suffix = $kicked > 0 ? " — kicked {$kicked} client(s)" : ''; + $this->info("Disabled: {$ppsk->name}{$suffix}"); + } catch (\Throwable $e) { + $this->error("Failed to disable PPSK \"{$ppsk->name}\": {$e->getMessage()}"); + } + } +} diff --git a/src/Http/Controllers/ApGroupController.php b/src/Http/Controllers/ApGroupController.php new file mode 100644 index 0000000..81609b1 --- /dev/null +++ b/src/Http/Controllers/ApGroupController.php @@ -0,0 +1,88 @@ +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()]); + } + } +} diff --git a/src/Http/Controllers/ClientController.php b/src/Http/Controllers/ClientController.php index d33de3c..7307075 100644 --- a/src/Http/Controllers/ClientController.php +++ b/src/Http/Controllers/ClientController.php @@ -4,6 +4,7 @@ namespace Dashboard\Unifi\Http\Controllers; use Dashboard\Unifi\Models\KnownMac; use Dashboard\Unifi\Models\PortalSession; +use Dashboard\Unifi\Models\VlanGroup; use Dashboard\Unifi\Services\UnifiApiClient; use Illuminate\Http\Request; use Illuminate\Routing\Controller; @@ -11,7 +12,7 @@ use Inertia\Inertia; class ClientController extends Controller { - public function index(UnifiApiClient $unifi) + public function index(Request $request, UnifiApiClient $unifi) { try { $clients = collect($unifi->getActiveClients())->map(fn ($c) => [ @@ -26,7 +27,10 @@ class ClientController extends Controller 'is_wired' => $c['is_wired'] ?? false, 'is_guest' => $c['is_guest'] ?? false, 'ssid' => $c['essid'] ?? null, + 'network' => $c['network'] ?? null, 'ap_mac' => $c['ap_mac'] ?? null, + 'sw_mac' => $c['sw_mac'] ?? null, + 'sw_port' => $c['sw_port'] ?? null, 'rssi' => $c['rssi'] ?? null, 'signal' => $c['signal'] ?? null, 'channel' => $c['channel'] ?? null, @@ -34,12 +38,32 @@ class ClientController extends Controller 'rx_bytes' => $c['rx_bytes'] ?? 0, 'tx_rate' => $c['tx_rate'] ?? 0, 'rx_rate' => $c['rx_rate'] ?? 0, + 'tx_rate_r' => $c['tx_bytes-r'] ?? 0, + 'rx_rate_r' => $c['rx_bytes-r'] ?? 0, 'uptime' => $c['uptime'] ?? 0, 'satisfaction' => $c['satisfaction'] ?? null, + 'vlan_id' => ($c['vlan_id'] ?? 0) ?: null, + 'dot1x' => $c['1x_identity'] ?? $c['dot1x_identity'] ?? null, 'is_known' => KnownMac::where('mac_address', strtolower($c['mac']))->exists(), ])->values(); - return Inertia::render('Unifi/Clients', ['clients' => $clients]); + // APs and switches for the device filter dropdown + $devices = collect($unifi->getDevices()) + ->filter(fn ($d) => in_array($d['type'] ?? '', ['uap', 'usw'])) + ->map(fn ($d) => [ + 'mac' => $d['mac'], + 'name' => $d['name'] ?? $d['model'] ?? $d['mac'], + 'type' => $d['type'], + ]) + ->sortBy('name') + ->values(); + + return Inertia::render('Unifi/Clients', [ + 'clients' => $clients, + 'vlanGroups' => VlanGroup::orderBy('sort_order')->get(), + 'devices' => $devices, + 'selectedDevice' => $request->query('device'), + ]); } catch (\Throwable $e) { return Inertia::render('Unifi/Clients', ['clients' => [], 'error' => $e->getMessage()]); } diff --git a/src/Http/Controllers/DeviceController.php b/src/Http/Controllers/DeviceController.php index d0d0b80..a1d65e5 100644 --- a/src/Http/Controllers/DeviceController.php +++ b/src/Http/Controllers/DeviceController.php @@ -5,6 +5,7 @@ namespace Dashboard\Unifi\Http\Controllers; use Dashboard\Unifi\Services\UnifiApiClient; use Illuminate\Http\Request; use Illuminate\Routing\Controller; +use Illuminate\Support\Facades\Cache; use Inertia\Inertia; class DeviceController extends Controller @@ -12,29 +13,49 @@ class DeviceController extends Controller public function index(UnifiApiClient $unifi) { try { - $devices = collect($unifi->getDevices())->map(fn ($d) => [ - 'mac' => $d['mac'], - 'name' => $d['name'] ?? $d['model'] ?? 'Unknown', - 'model' => $d['model'] ?? '', - 'type' => $d['type'] ?? '', - 'ip' => $d['ip'] ?? '', - 'version' => $d['version'] ?? '', - 'state' => $d['state'] ?? 0, - 'adopted' => $d['adopted'] ?? false, - 'upgradable' => $d['upgradable'] ?? false, - 'uptime' => $d['uptime'] ?? 0, - 'num_sta' => $d['num_sta'] ?? 0, - 'tx_bytes' => $d['tx_bytes'] ?? 0, - 'rx_bytes' => $d['rx_bytes'] ?? 0, - 'cpu' => $d['system-stats']['cpu'] ?? null, - 'mem' => $d['system-stats']['mem'] ?? null, - 'satisfaction' => $d['satisfaction'] ?? null, - 'channels' => collect($d['radio_table'] ?? [])->map(fn ($r) => [ - 'radio' => $r['radio'] ?? '', - 'channel' => $r['channel'] ?? null, - 'ht' => $r['ht'] ?? '', - ])->values(), - ])->values(); + $devices = collect($unifi->getDevices())->map(function ($d) { + // radio_table_stats has actual live channel + per-radio client counts + rates + $radioStats = collect($d['radio_table_stats'] ?? [])->keyBy('name'); + + // Device-level throughput: prefer device field, fall back to sum of radio stats + $txRate = ($d['tx_bytes-r'] ?? 0) ?: $radioStats->sum(fn ($r) => $r['tx_bytes-r'] ?? 0); + $rxRate = ($d['rx_bytes-r'] ?? 0) ?: $radioStats->sum(fn ($r) => $r['rx_bytes-r'] ?? 0); + + return [ + 'mac' => $d['mac'], + 'name' => $d['name'] ?? $d['model'] ?? 'Unknown', + 'model' => $d['model'] ?? '', + 'type' => $d['type'] ?? '', + 'ip' => $d['ip'] ?? '', + 'version' => $d['version'] ?? '', + 'state' => $d['state'] ?? 0, + 'adopted' => $d['adopted'] ?? false, + 'upgradable' => $d['upgradable'] ?? false, + 'uptime' => $d['uptime'] ?? 0, + 'num_sta' => $d['num_sta'] ?? 0, + 'tx_bytes' => $d['tx_bytes'] ?? 0, + 'rx_bytes' => $d['rx_bytes'] ?? 0, + 'tx_rate' => $txRate, + 'rx_rate' => $rxRate, + 'cpu' => $d['system-stats']['cpu'] ?? null, + 'mem' => $d['system-stats']['mem'] ?? null, + 'satisfaction' => $d['satisfaction'] ?? null, + // Use radio_table_stats for actual channel (not 'auto' from config), + // per-radio client count, and per-radio rates. + 'channels' => collect($d['radio_table'] ?? [])->map(function ($r) use ($radioStats) { + $stats = $radioStats->get($r['name'] ?? ''); + // stats['channel'] is the real channel in use; 0 = not broadcasting + $channel = $stats ? (($stats['channel'] ?? 0) ?: null) : null; + return [ + 'radio' => $r['radio'] ?? '', + 'channel' => $channel, + 'num_sta' => $stats['num_sta'] ?? 0, + 'tx_rate' => $stats ? ($stats['tx_bytes-r'] ?? 0) : 0, + 'rx_rate' => $stats ? ($stats['rx_bytes-r'] ?? 0) : 0, + ]; + })->values(), + ]; + })->values(); return Inertia::render('Unifi/Devices', ['devices' => $devices]); } catch (\Throwable $e) { @@ -47,6 +68,9 @@ class DeviceController extends Controller $request->validate(['mac' => 'required|string|regex:/^([0-9a-f]{2}:){5}[0-9a-f]{2}$/i']); try { + // Suppress offline/online webhook alerts for planned reboots (15-minute window) + Cache::put('unifi:planned_reboot:' . strtolower($request->mac), true, now()->addMinutes(15)); + $unifi->rebootDevice($request->mac); return back()->with('success', 'Reboot command sent.'); } catch (\Throwable $e) { diff --git a/src/Http/Controllers/PortalController.php b/src/Http/Controllers/PortalController.php index dddebeb..aeb7e61 100644 --- a/src/Http/Controllers/PortalController.php +++ b/src/Http/Controllers/PortalController.php @@ -4,6 +4,7 @@ namespace Dashboard\Unifi\Http\Controllers; use Dashboard\Unifi\Models\KnownMac; use Dashboard\Unifi\Models\PortalSession; +use Dashboard\Unifi\Models\VlanGroup; use Dashboard\Unifi\Models\VlanMapping; use Dashboard\Unifi\Services\UnifiApiClient; use Illuminate\Http\Request; @@ -17,6 +18,7 @@ class PortalController extends Controller public function settings() { return Inertia::render('Unifi/Portal', [ + 'vlanGroups' => VlanGroup::orderBy('sort_order')->get(), 'vlanMappings' => VlanMapping::orderBy('sort_order')->get(), 'knownMacs' => KnownMac::orderBy('device_name')->paginate(50), 'activeSessions' => PortalSession::where('is_active', true) diff --git a/src/Http/Controllers/StatsController.php b/src/Http/Controllers/StatsController.php index 527ca19..ec0e70a 100644 --- a/src/Http/Controllers/StatsController.php +++ b/src/Http/Controllers/StatsController.php @@ -8,6 +8,7 @@ use Dashboard\Unifi\Models\ClientSnapshot; use Dashboard\Unifi\Services\UnifiApiClient; use Illuminate\Http\Request; use Illuminate\Routing\Controller; +use Illuminate\Support\Facades\Cache; use Inertia\Inertia; class StatsController extends Controller @@ -65,9 +66,9 @@ class StatsController extends Controller try { $health = $unifi->getSiteHealth(); - $allAps = $unifi->getAccessPoints(); + $allAps = Cache::remember('unifi_access_points', 30, fn () => $unifi->getAccessPoints()); $aps = empty($apFilter) ? $allAps : array_values(array_filter($allAps, fn ($a) => in_array($a['mac'], $apFilter))); - $clients = $unifi->getActiveClients(); + $clients = Cache::remember('unifi_active_clients', 30, fn () => $unifi->getActiveClients()); $ssidGroups = json_decode(Setting::get('unifi.ssid_groups', '{}'), true); if (! is_array($ssidGroups) || array_is_list($ssidGroups)) $ssidGroups = []; @@ -173,9 +174,9 @@ class StatsController extends Controller } try { - // Current snapshot for categorical counts (device type / OS) — use live API - $clients = collect($unifi->getActiveClients()); - $aps = collect($unifi->getAccessPoints()); + // Current snapshot for categorical counts (device type / OS) — use live/cached API + $clients = collect(Cache::remember('unifi_active_clients', 30, fn () => $unifi->getActiveClients())); + $aps = collect(Cache::remember('unifi_access_points', 30, fn () => $unifi->getAccessPoints())); $devCatCounts = $clients->map(fn ($c) => ['name' => $this->classifyDeviceType($c)]) ->groupBy('name') @@ -208,6 +209,9 @@ class StatsController extends Controller 'ap_mac' => $apMac, 'ap_name' => $apNames[$apMac] ?? (($c['is_wired'] ?? false) ? 'Wired' : 'Unknown'), 'is_wired' => (bool) ($c['is_wired'] ?? false), + 'ssid' => $c['essid'] ?? null, + 'vlan_id' => ($c['vlan_id'] ?? 0) ?: null, + 'dot1x' => $c['1x_identity'] ?? $c['dot1x_identity'] ?? null, 'tx_rate' => (int) ($c['tx_rate'] ?? 0), 'rx_rate' => (int) ($c['rx_rate'] ?? 0), 'tx_bytes' => (int) ($c['tx_bytes'] ?? 0), @@ -215,7 +219,11 @@ class StatsController extends Controller ]; })->sortByDesc(fn ($c) => $c['rx_rate'] + $c['tx_rate'])->values(); - $series = $this->buildClientSeries($startDt, $endDt); + $rangeKey = $range === 'interval' + ? "interval_{$startDt->timestamp}_{$endDt->timestamp}" + : $range; + $series = Cache::remember("unifi_client_series_{$rangeKey}", 45, + fn () => $this->buildClientSeries($startDt, $endDt)); return Inertia::render('Unifi/ClientDashboard', [ 'devCatCounts' => $devCatCounts, @@ -228,11 +236,10 @@ class StatsController extends Controller 'clientDownloadMb' => $series['download_mb'], 'totalDownloadBytes' => $series['total_download_bytes'], 'totalUploadBytes' => $series['total_upload_bytes'], - 'downloadSeries' => $series['download_series'], - 'uploadSeries' => $series['upload_series'], 'activeClientCount' => $clients->count(), 'apList' => $apList, 'clientList' => $clientList, + 'vlanGroups' => \Dashboard\Unifi\Models\VlanGroup::orderBy('sort_order')->get(), 'range' => $range, 'ranges' => array_keys(self::RANGE_MAP), 'pollInterval' => (int) Setting::get('unifi.poll_interval', 30), @@ -246,6 +253,58 @@ class StatsController extends Controller } } + /** + * AJAX — Traffic time-series for clients currently on a given AP. + * Reads from the cached series (built by clientDashboard), so it's fast. + */ + public function clientApTraffic(Request $request, UnifiApiClient $unifi): \Illuminate\Http\JsonResponse + { + $apMac = strtolower(trim($request->get('ap', ''))); + $range = $request->get('range', '4h'); + + if ($range === 'interval' && $request->get('start') && $request->get('end')) { + $startDt = \Carbon\Carbon::parse($request->get('start')); + $endDt = \Carbon\Carbon::parse($request->get('end')); + } else { + if (! isset(self::RANGE_MAP[$range])) $range = '4h'; + $endDt = now(); + $startDt = $endDt->copy()->subMinutes(self::RANGE_MAP[$range]); + } + + try { + // Which client MACs are currently on this AP? + $clients = collect(Cache::remember('unifi_active_clients', 30, fn () => $unifi->getActiveClients())); + $macsOnAp = $clients + ->filter(fn ($c) => strtolower($c['ap_mac'] ?? '') === $apMac) + ->pluck('mac') + ->map(fn ($m) => strtolower($m)) + ->flip() // flip to key => true for O(1) lookup + ->all(); + + // Fetch (or warm) the cached series + $rangeKey = $range === 'interval' + ? "interval_{$startDt->timestamp}_{$endDt->timestamp}" + : $range; + $series = Cache::remember("unifi_client_series_{$rangeKey}", 45, + fn () => $this->buildClientSeries($startDt, $endDt)); + + $rx = collect($series['traffic_rx']) + ->filter(fn ($s) => isset($macsOnAp[strtolower($s['mac'] ?? '')])) + ->values(); + $tx = collect($series['traffic_tx']) + ->filter(fn ($s) => isset($macsOnAp[strtolower($s['mac'] ?? '')])) + ->values(); + + return response()->json([ + 'labels' => $series['labels'], + 'traffic_rx' => $rx, + 'traffic_tx' => $tx, + ]); + } catch (\Throwable $e) { + return response()->json(['error' => $e->getMessage()], 500); + } + } + /** * Device TYPE = manufacturer + model (when detectable). * @@ -438,7 +497,8 @@ class StatsController extends Controller $labels = $times->map(fn ($t) => $t * 1000); - $snapshots = ClientSnapshot::whereIn(\Illuminate\Support\Facades\DB::raw('UNIX_TIMESTAMP(captured_at)'), $times->all()) + $snapshots = ClientSnapshot::whereBetween('captured_at', [$start, $end]) + ->whereIn(\Illuminate\Support\Facades\DB::raw('UNIX_TIMESTAMP(captured_at)'), $times->all()) ->orderBy('captured_at') ->get(); @@ -562,12 +622,12 @@ class StatsController extends Controller $uploadSeries = $times->map(fn ($t) => (int) ($txByTs[$t] ?? 0))->values()->all(); return [ - 'labels' => $labels, - 'traffic_rx' => $trafficRx, - 'traffic_tx' => $trafficTx, - 'satisfaction' => $satisfaction, - 'signal' => $signal, - 'download_mb' => $downloadMb, + 'labels' => $labels->values()->all(), + 'traffic_rx' => $trafficRx->values()->all(), + 'traffic_tx' => $trafficTx->values()->all(), + 'satisfaction' => $satisfaction->values()->all(), + 'signal' => $signal->values()->all(), + 'download_mb' => $downloadMb->values()->all(), 'total_download_bytes' => $totalDownload, 'total_upload_bytes' => $totalUpload, 'download_series' => $downloadSeries, diff --git a/src/Http/Controllers/UnifiSettingsController.php b/src/Http/Controllers/UnifiSettingsController.php index 8729bfc..979bea7 100644 --- a/src/Http/Controllers/UnifiSettingsController.php +++ b/src/Http/Controllers/UnifiSettingsController.php @@ -13,52 +13,79 @@ class UnifiSettingsController extends Controller public function edit() { return Inertia::render('Unifi/Settings', [ - 'controllerUrl' => Setting::get('unifi.controller_url', ''), - 'username' => Setting::get('unifi.username', ''), - 'hasPassword' => (bool) Setting::get('unifi.password'), - 'hasApiKey' => (bool) Setting::get('unifi.api_key'), - 'site' => Setting::get('unifi.site', 'default'), - 'pollInterval' => (int) Setting::get('unifi.poll_interval', 30), - 'cacheTtl' => (int) Setting::get('unifi.cache_ttl', 30), - 'retentionDays' => (int) Setting::get('unifi.retention_days', 30), + 'controllerUrl' => Setting::get('unifi.controller_url', ''), + 'hasApiKey' => (bool) Setting::get('unifi.api_key'), + 'site' => Setting::get('unifi.site', 'default'), + '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), + 'autoRebootHour' => (int) Setting::get('unifi.auto_reboot.hour', 2), + 'autoRebootMinute' => (int) Setting::get('unifi.auto_reboot.minute', 0), + 'rotationEnabled' => (bool) Setting::get('unifi.password_rotation.enabled', false), + 'rotationFrequency' => Setting::get('unifi.password_rotation.frequency', 'weekly'), + 'rotationDow' => (int) Setting::get('unifi.password_rotation.day_of_week', 0), + 'rotationHour' => (int) Setting::get('unifi.password_rotation.hour', 2), + 'rotationMinute' => (int) Setting::get('unifi.password_rotation.minute', 0), + 'rotationWordlist' => Setting::get('unifi.password_rotation.wordlist', ''), + 'rotationLastRotatedAt' => Setting::get('unifi.password_rotation.last_rotated_at', null), + 'ppskSchedulingEnabled' => (bool) Setting::get('unifi.ppsk_scheduling.enabled', false), ]); } public function update(Request $request) { $request->validate([ - 'controller_url' => 'required|url|max:500', - 'username' => 'nullable|string|max:255', - 'password' => 'nullable|string|max:255', - 'api_key' => 'nullable|string|max:500', - 'site' => 'required|string|max:100', - 'poll_interval' => 'nullable|integer|min:5|max:300', - 'cache_ttl' => 'nullable|integer|min:5|max:300', - 'retention_days' => 'nullable|integer|min:1|max:365', + 'controller_url' => 'required|url|max:500', + 'api_key' => 'nullable|string|max:500', + 'site' => 'required|string|max:100', + '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', + 'auto_reboot_hour' => 'nullable|integer|min:0|max:23', + 'auto_reboot_minute' => 'nullable|integer|min:0|max:59', + 'rotation_enabled' => 'boolean', + 'rotation_frequency' => 'in:daily,weekly', + 'rotation_dow' => 'nullable|integer|min:0|max:6', + 'rotation_hour' => 'nullable|integer|min:0|max:23', + 'rotation_minute' => 'nullable|integer|min:0|max:59', + 'rotation_wordlist' => 'nullable|string|max:20000', + 'ppsk_scheduling_enabled' => 'boolean', ]); Setting::set('unifi.controller_url', rtrim($request->controller_url, '/')); Setting::set('unifi.site', $request->site); - // Save the chosen auth method and clear the other - Setting::set('unifi.username', $request->username ?? ''); - if ($request->password && $request->password !== '••••••••') { - Setting::set('unifi.password', $request->password); - } elseif (! $request->username) { - Setting::set('unifi.password', ''); // clear password when switching to API key mode - } if ($request->api_key && $request->api_key !== '••••••••') { Setting::set('unifi.api_key', $request->api_key); - } elseif ($request->username) { - Setting::set('unifi.api_key', ''); // clear API key when switching to local account mode } 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('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')); + Setting::set('unifi.auto_reboot.day_of_week',$request->input('auto_reboot_dow', 0)); + Setting::set('unifi.auto_reboot.hour', $request->input('auto_reboot_hour', 2)); + Setting::set('unifi.auto_reboot.minute', $request->input('auto_reboot_minute', 0)); + + Setting::set('unifi.password_rotation.enabled', $request->boolean('rotation_enabled') ? '1' : ''); + Setting::set('unifi.password_rotation.frequency', $request->input('rotation_frequency', 'weekly')); + Setting::set('unifi.password_rotation.day_of_week', $request->input('rotation_dow', 0)); + Setting::set('unifi.password_rotation.hour', $request->input('rotation_hour', 2)); + Setting::set('unifi.password_rotation.minute', $request->input('rotation_minute', 0)); + Setting::set('unifi.password_rotation.wordlist', $request->input('rotation_wordlist', '')); + Setting::set('unifi.ppsk_scheduling.enabled', $request->boolean('ppsk_scheduling_enabled') ? '1' : ''); - // Clear cached sessions so new credentials take effect - \Illuminate\Support\Facades\Cache::forget('unifi:session:' . md5(rtrim($request->controller_url, '/') . $request->username)); \Illuminate\Support\Facades\Cache::forget('unifi:api_prefix:' . md5(rtrim($request->controller_url, '/'))); return back()->with('success', 'UniFi settings saved.'); @@ -103,12 +130,10 @@ class UnifiSettingsController extends Controller $hint = "Tried URL: {$url}. "; if (str_contains($url, 'unifi.ui.com')) { $hint .= "Use your console's direct URL (*.id.ui.direct or local IP) instead of unifi.ui.com."; - } elseif (! $user && ! $key) { - $hint .= "Enter either a local account username/password or an API key."; + } elseif (! $key) { + $hint .= "Enter an API key above."; } else { - $hint .= $user - ? "Check that the local account credentials are correct." - : "The API key may be read-only. Try using a local admin account instead."; + $hint .= "Check that the API key is correct and the controller URL is reachable."; } return response()->json(['ok' => false, 'error' => $e->getMessage(), 'hint' => $hint], 422); diff --git a/src/Http/Controllers/VlanGroupController.php b/src/Http/Controllers/VlanGroupController.php new file mode 100644 index 0000000..dcead7d --- /dev/null +++ b/src/Http/Controllers/VlanGroupController.php @@ -0,0 +1,39 @@ +validate([ + 'name' => 'required|string|max:100', + 'vlan_id' => 'required|integer|min:1|max:4094', + 'description' => 'nullable|string|max:255', + ]); + $data['sort_order'] = VlanGroup::max('sort_order') + 1; + VlanGroup::create($data); + return back()->with('success', 'VLAN group added.'); + } + + public function update(Request $request, VlanGroup $vlanGroup) + { + $data = $request->validate([ + 'name' => 'required|string|max:100', + 'vlan_id' => 'required|integer|min:1|max:4094', + 'description' => 'nullable|string|max:255', + ]); + $vlanGroup->update($data); + return back()->with('success', 'VLAN group updated.'); + } + + public function destroy(VlanGroup $vlanGroup) + { + $vlanGroup->delete(); + return back()->with('success', 'VLAN group deleted.'); + } +} diff --git a/src/Http/Controllers/WifiController.php b/src/Http/Controllers/WifiController.php index de3577b..7af6f31 100644 --- a/src/Http/Controllers/WifiController.php +++ b/src/Http/Controllers/WifiController.php @@ -3,6 +3,7 @@ namespace Dashboard\Unifi\Http\Controllers; use App\Models\Setting; +use Dashboard\Unifi\Models\UnifiPpsk; use Dashboard\Unifi\Services\UnifiApiClient; use Illuminate\Http\Request; use Illuminate\Routing\Controller; @@ -13,56 +14,82 @@ class WifiController extends Controller public function index(UnifiApiClient $unifi) { try { - $wlans = collect($unifi->getWlans())->map(fn ($w) => [ - 'id' => $w['_id'], - 'name' => $w['name'], - 'enabled' => $w['enabled'] ?? true, - 'security' => $w['security'] ?? 'open', - 'wpa_mode' => $w['wpa_mode'] ?? '', - 'is_guest' => $w['is_guest'] ?? false, - 'vlan_enabled' => $w['vlan_enabled'] ?? false, - 'vlan' => $w['vlan'] ?? null, - 'hide_ssid' => $w['hide_ssid'] ?? false, - 'passphrase' => $w['x_passphrase'] ?? '', - 'band' => $this->detectBand($w), - ])->values(); + $wlans = collect($unifi->getWlans())->map(fn ($w) => $this->mapWlan($w))->values(); - // Load saved groups: { "Staff": ["id1", "id2"], ... } - $raw = Setting::get('unifi.ssid_groups', '{}'); + 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 = []; - // Force object cast so Vue gets {} not [] $groups = (object) $groups; + $rotateWlanIds = json_decode(Setting::get('unifi.password_rotation.wlan_ids', '[]'), true) ?: []; + return Inertia::render('Unifi/Wifi', [ - 'wlans' => $wlans, - 'groups' => $groups, + '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' => [], 'error' => $e->getMessage()]); + return Inertia::render('Unifi/Wifi', [ + 'wlans' => [], 'groups' => [], 'apGroups' => [], 'rotateWlanIds' => [], 'error' => $e->getMessage(), + ]); } } public function update(Request $request, string $wlanId, UnifiApiClient $unifi) { $data = $request->validate([ - 'name' => 'sometimes|string|max:255', - 'enabled' => 'sometimes|boolean', - 'x_passphrase' => 'sometimes|string|min:8|max:63', - 'hide_ssid' => 'sometimes|boolean', + 'x_passphrase' => 'sometimes|string|min:8|max:63', + 'hide_ssid' => 'sometimes|boolean', + 'mac_filter_enabled' => 'sometimes|boolean', + 'mac_filter_policy' => 'sometimes|string|in:allow,deny', + 'rotate_password' => 'sometimes|boolean', ]); try { - // If this WLAN is in a group, apply the same change to all grouped WLANs + // Password/hide changes apply to all grouped WLANs + $shared = array_filter($data, fn ($k) => in_array($k, ['x_passphrase', 'hide_ssid']), ARRAY_FILTER_USE_KEY); $groups = json_decode(Setting::get('unifi.ssid_groups', '{}'), true) ?: []; $groupedIds = collect($groups)->first(fn ($ids) => in_array($wlanId, $ids)); - if ($groupedIds) { - foreach ($groupedIds as $id) { - $unifi->updateWlan($id, $data); + if (! empty($shared)) { + if ($groupedIds) { + foreach ($groupedIds as $id) { + $unifi->updateWlan($id, $shared); + } + } else { + $unifi->updateWlan($wlanId, $shared); } - } else { - $unifi->updateWlan($wlanId, $data); + } + + // MAC filter changes apply to this WLAN only + $perWlan = array_filter($data, fn ($k) => in_array($k, ['mac_filter_enabled', 'mac_filter_policy']), ARRAY_FILTER_USE_KEY); + if (! empty($perWlan)) { + $unifi->updateWlan($wlanId, $perWlan); + } + + // Toggle wlan_id in the rotation list + if ($request->has('rotate_password')) { + $rotateIds = json_decode(Setting::get('unifi.password_rotation.wlan_ids', '[]'), true) ?: []; + $targetIds = $groupedIds ?: [$wlanId]; + if ($request->boolean('rotate_password')) { + $rotateIds = array_values(array_unique(array_merge($rotateIds, $targetIds))); + } else { + $rotateIds = array_values(array_diff($rotateIds, $targetIds)); + } + Setting::set('unifi.password_rotation.wlan_ids', json_encode($rotateIds)); } return back()->with('success', 'WiFi network updated.'); @@ -71,14 +98,29 @@ 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']); try { - $groups = json_decode(Setting::get('unifi.ssid_groups', '{}'), true) ?: []; + $groups = json_decode(Setting::get('unifi.ssid_groups', '{}'), true) ?: []; $groupedIds = collect($groups)->first(fn ($ids) => in_array($wlanId, $ids)); - $enabled = $request->boolean('enabled'); + $enabled = $request->boolean('enabled'); if ($groupedIds) { foreach ($groupedIds as $id) { @@ -94,6 +136,256 @@ class WifiController extends Controller } } + // ── PPSK ───────────────────────────────────────────────────────────────── + + public function ppskIndex(string $wlanId, UnifiApiClient $unifi) + { + try { + $liveEntries = $unifi->getPpskEntries($wlanId); + + // Network confs are best-effort — don't let a failure block PPSK display + try { + $networksRaw = $unifi->getNetworkConfs(); + } catch (\Throwable $e) { + $networksRaw = []; + } + $networksById = collect($networksRaw)->keyBy('_id'); + + // ── Sync live entries into DB ──────────────────────────────────── + $liveIds = []; + foreach ($liveEntries as $entry) { + $pass = $entry['x_passphrase'] ?? $entry['password'] ?? null; + $uid = $entry['_id'] ?? $entry['id'] ?? null; + // wlan_embedded PPSKs have no _id — derive a stable synthetic ID from the passphrase + if (! $uid && $pass) { + $uid = 'emb_' . substr(hash('sha256', $wlanId . ':' . $pass), 0, 32); + } + if (! $uid) continue; + $liveIds[] = $uid; + + $nconfId = $entry['networkconf_id'] ?? null; + $vlan = ($nconfId && isset($networksById[$nconfId])) + ? ($networksById[$nconfId]['vlan'] ?? null) + : null; + if ($vlan === null && ! empty($entry['vlan_id'])) { + $vlan = (int) $entry['vlan_id']; + } + $name = $entry['name'] ?? $entry['label'] ?? $entry['username'] ?? null; + // For anonymous PPSKs, use the associated network name as the default label + if (! $name && $nconfId && isset($networksById[$nconfId])) { + $name = $networksById[$nconfId]['name'] ?? null; + } + + // Match by unifi_id, or by passphrase for a held embedded record re-appearing + $record = UnifiPpsk::where('unifi_id', $uid)->first() + ?? UnifiPpsk::where('wlan_id', $wlanId) + ->where('x_passphrase', $pass) + ->where('state', 'held') + ->first(); + + if ($record) { + $upd = ['unifi_id' => $uid, 'state' => 'active']; + if ($name) $upd['name'] = $name; + if ($pass) $upd['x_passphrase'] = $pass; + if ($vlan !== null) $upd['vlan'] = $vlan; + $record->update($upd); + } else { + UnifiPpsk::create([ + 'wlan_id' => $wlanId, + 'unifi_id' => $uid, + 'name' => $name ?? 'PPSK', + 'x_passphrase' => $pass ?? '', + 'vlan' => $vlan, + 'state' => 'active', + ]); + } + } + + // Only mark as held when we have confirmed live IDs — + // never wipe on an empty API response (prevents false-holds on API failures) + if (! empty($liveIds)) { + UnifiPpsk::where('wlan_id', $wlanId) + ->where('state', 'active') + ->whereNotNull('unifi_id') + ->whereNotIn('unifi_id', $liveIds) + ->update(['state' => 'held', 'unifi_id' => null]); + } + + $dbRecords = UnifiPpsk::where('wlan_id', $wlanId) + ->orderByRaw("FIELD(state, 'active', 'held')") + ->orderBy('name') + ->get(); + + // Fallback: if DB still empty but live entries exist, return live entries directly. + // Applies the same synthetic-ID and networkconf logic so IDs are always non-null. + if ($dbRecords->isEmpty() && ! empty($liveEntries)) { + $entries = collect($liveEntries)->map(function ($e) use ($wlanId, $networksById) { + $pass = $e['x_passphrase'] ?? $e['password'] ?? null; + $uid = $e['_id'] ?? $e['id'] ?? null; + if (! $uid && $pass) { + $uid = 'emb_' . substr(hash('sha256', $wlanId . ':' . $pass), 0, 32); + } + $nconfId = $e['networkconf_id'] ?? null; + $vlan = ($nconfId && isset($networksById[$nconfId])) + ? ($networksById[$nconfId]['vlan'] ?? null) : null; + if ($vlan === null && ! empty($e['vlan_id'])) { + $vlan = (int) $e['vlan_id']; + } + $name = $e['name'] ?? $e['label'] ?? $e['username'] ?? null; + if (! $name && $nconfId && isset($networksById[$nconfId])) { + $name = $networksById[$nconfId]['name'] ?? null; + } + return [ + 'id' => $uid, + 'unifi_id' => $uid, + 'name' => $name ?? 'PPSK', + 'x_passphrase' => $pass, + 'vlan' => $vlan, + 'state' => 'active', + 'rotate_password' => false, + 'schedule' => null, + ]; + })->values(); + } else { + $entries = $dbRecords->map(fn ($r) => $this->mapPpsk($r)); + } + + $networks = $networksById->values()->map(fn ($n) => [ + '_id' => $n['_id'], + 'name' => $n['name'] ?? 'Unnamed', + 'vlan' => $n['vlan'] ?? null, + ]); + + return response()->json(['ok' => true, 'entries' => $entries, 'networks' => $networks]); + } catch (\Throwable $e) { + return response()->json(['ok' => false, 'error' => $e->getMessage()], 422); + } + } + + public function ppskStore(Request $request, string $wlanId, UnifiApiClient $unifi) + { + $data = $request->validate([ + 'name' => 'required|string|max:100', + 'x_passphrase' => 'required|string|min:8|max:63', + 'networkconf_id' => 'nullable|string', + 'vlan' => 'nullable|integer', + ]); + + try { + $pushData = [ + 'name' => $data['name'], + 'x_passphrase' => $data['x_passphrase'], + 'wlan_id' => $wlanId, + ]; + if (! empty($data['networkconf_id'])) { + $pushData['networkconf_id'] = $data['networkconf_id']; + } + + $result = $unifi->createPpsk($pushData); + $raw = $result[0] ?? $result; + $unifiId = $raw['_id'] ?? null; + + $record = UnifiPpsk::create([ + 'wlan_id' => $wlanId, + 'unifi_id' => $unifiId, + 'name' => $data['name'], + 'x_passphrase' => $data['x_passphrase'], + 'vlan' => $data['vlan'] ?? null, + 'state' => 'active', + ]); + + return response()->json(['ok' => true, 'entry' => $this->mapPpsk($record)]); + } catch (\Throwable $e) { + return response()->json(['ok' => false, 'error' => $e->getMessage()], 422); + } + } + + public function ppskUpdate(Request $request, string $wlanId, string $ppskId, UnifiApiClient $unifi) + { + $record = UnifiPpsk::findOrFail($ppskId); + + $data = $request->validate([ + 'name' => 'sometimes|string|max:100', + 'x_passphrase' => 'sometimes|string|min:8|max:63', + 'networkconf_id' => 'nullable|string', + 'vlan' => 'nullable|integer', + ]); + + try { + if ($record->unifi_id && $record->state === 'active') { + $unifiUpdate = array_filter( + array_intersect_key($data, array_flip(['name', 'x_passphrase', 'networkconf_id'])), + fn ($v) => $v !== null + ); + if (! empty($unifiUpdate)) { + $unifi->updatePpsk($record->unifi_id, $unifiUpdate); + } + } + + $dbUpdate = array_intersect_key($data, array_flip(['name', 'x_passphrase'])); + // vlan can be explicitly set to null + if (array_key_exists('vlan', $data)) $dbUpdate['vlan'] = $data['vlan']; + if (! empty($dbUpdate)) $record->update($dbUpdate); + + return response()->json(['ok' => true, 'entry' => $this->mapPpsk($record->fresh())]); + } catch (\Throwable $e) { + return response()->json(['ok' => false, 'error' => $e->getMessage()], 422); + } + } + + public function ppskDestroy(string $wlanId, string $ppskId, UnifiApiClient $unifi) + { + $record = UnifiPpsk::findOrFail($ppskId); + + try { + if ($record->unifi_id) { + try { $unifi->kickClientsForPpsk($record->unifi_id); } catch (\Throwable) {} + // Embedded PPSKs (synthetic emb_ IDs) aren't deletable via the PPSK REST endpoint; + // skip the API call — the entry will disappear from UniFi when the WLAN is reconfigured. + if (! str_starts_with($record->unifi_id, 'emb_')) { + $unifi->deletePpsk($record->unifi_id); + } + } + $record->delete(); + return response()->json(['ok' => true]); + } catch (\Throwable $e) { + return response()->json(['ok' => false, 'error' => $e->getMessage()], 422); + } + } + + public function ppskSchedule(Request $request, string $wlanId, string $ppskId) + { + $record = UnifiPpsk::findOrFail($ppskId); + $request->validate([ + 'schedule' => 'nullable|array', + 'schedule.*' => 'boolean', + ]); + $record->update(['schedule' => $request->schedule]); + return response()->json(['ok' => true, 'entry' => $this->mapPpsk($record->fresh())]); + } + + public function ppskToggleRotation(Request $request, string $wlanId, string $ppskId) + { + $record = UnifiPpsk::findOrFail($ppskId); + $request->validate(['rotate_password' => 'required|boolean']); + $record->update(['rotate_password' => $request->boolean('rotate_password')]); + return response()->json(['ok' => true, 'entry' => $this->mapPpsk($record)]); + } + + private function mapPpsk(UnifiPpsk $r): array + { + return [ + 'id' => $r->id, + 'unifi_id' => $r->unifi_id, + 'name' => $r->name, + 'x_passphrase' => $r->x_passphrase, + 'vlan' => $r->vlan, + 'state' => $r->state, + 'rotate_password' => $r->rotate_password, + 'schedule' => $r->schedule, + ]; + } + /** * Group/ungroup SSIDs. Groups are stored as { "GroupName": ["wlan_id_1", "wlan_id_2"] } */ @@ -106,16 +398,40 @@ class WifiController extends Controller return back()->with('success', 'SSID groups saved.'); } + // ── Helpers ─────────────────────────────────────────────────────────────── + + private function mapWlan(array $w): array + { + return [ + 'id' => $w['_id'], + 'name' => $w['name'], + 'enabled' => $w['enabled'] ?? true, + 'security' => $w['security'] ?? 'open', + 'wpa_mode' => $w['wpa_mode'] ?? '', + 'is_guest' => $w['is_guest'] ?? false, + 'vlan_enabled' => $w['vlan_enabled'] ?? false, + 'vlan' => $w['vlan'] ?? null, + '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) + || ($w['ppsk'] ?? false) + || ($w['private_preshared_keys_enabled'] ?? false) + || ! empty($w['private_preshared_keys']), + ]; + } + private function detectBand(array $w): string { - // UniFi stores band info in wlan_band or in the radio settings $band = $w['wlan_band'] ?? null; if ($band === 'ng' || $band === '2g') return '2.4 GHz'; if ($band === 'na' || $band === '5g') return '5 GHz'; if ($band === 'a6' || $band === '6g' || $band === '6e') return '6 GHz'; if ($band === 'both' || $band === null) return 'All bands'; - // Try to detect from SSID name as fallback $name = strtolower($w['name'] ?? ''); if (preg_match('/2\.?4\s*g/i', $name)) return '2.4 GHz'; if (preg_match('/5\s*g/i', $name)) return '5 GHz'; diff --git a/src/Models/DeviceState.php b/src/Models/DeviceState.php index 384c53c..adc691f 100644 --- a/src/Models/DeviceState.php +++ b/src/Models/DeviceState.php @@ -8,6 +8,6 @@ class DeviceState extends Model { public $timestamps = false; protected $table = 'unifi_device_states'; - protected $fillable = ['device_mac', 'device_name', 'was_online', 'consecutive_count', 'last_seen_at', 'updated_at']; - protected $casts = ['was_online' => 'boolean', 'last_seen_at' => 'datetime', 'updated_at' => 'datetime']; + protected $fillable = ['device_mac', 'device_name', 'was_online', 'in_alert', 'consecutive_count', 'last_seen_at', 'updated_at']; + protected $casts = ['was_online' => 'boolean', 'in_alert' => 'boolean', 'last_seen_at' => 'datetime', 'updated_at' => 'datetime']; } diff --git a/src/Models/UnifiPpsk.php b/src/Models/UnifiPpsk.php new file mode 100644 index 0000000..fd01e58 --- /dev/null +++ b/src/Models/UnifiPpsk.php @@ -0,0 +1,30 @@ + 'integer', + 'rotate_password' => 'boolean', + 'schedule' => 'array', + ]; + + /** + * Returns true if this PPSK should be active at the given day (0=Sun…6=Sat) + * and half-hour slot (0=00:00, 47=23:30). + * A null schedule means always-on. + */ + public function isScheduledOnAt(int $day, int $slot): bool + { + if (! $this->schedule) return true; + return (bool) ($this->schedule[$day * 48 + $slot] ?? true); + } +} diff --git a/src/Models/VlanGroup.php b/src/Models/VlanGroup.php new file mode 100644 index 0000000..cff7fd9 --- /dev/null +++ b/src/Models/VlanGroup.php @@ -0,0 +1,12 @@ + 'integer', 'sort_order' => 'integer']; +} diff --git a/src/Services/UnifiApiClient.php b/src/Services/UnifiApiClient.php index e92bf58..af7a34f 100644 --- a/src/Services/UnifiApiClient.php +++ b/src/Services/UnifiApiClient.php @@ -286,6 +286,20 @@ class UnifiApiClient return $this->post('/cmd/stamgr', ['cmd' => 'unauthorize-guest', 'mac' => strtolower($mac)]); } + private function delete(string $path): void + { + $this->init(); + $url = $this->siteUrl($path); + $response = $this->buildRequest()->delete($url); + if ($response->status() === 401) { + Cache::forget('unifi:session:' . md5($this->baseUrl . $this->username)); + $response = $this->buildRequest()->delete($url); + } + if (! $response->successful()) { + throw new \RuntimeException("UniFi API error: HTTP {$response->status()}"); + } + } + // ── WiFi Networks / WLANs ───────────────────────────────────────────────── public function getWlans(): array @@ -298,6 +312,252 @@ 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 ───────────────────────────────────────────────────────────────── + + /** + * Build a v2 API URL. UniFi OS consoles expose /proxy/network/v2/api/site/{site}/... + * Standalone controllers may not have this path. + */ + private function v2SiteUrl(string $path): string + { + $this->init(); + $v2Base = str_contains($this->apiPath(), 'proxy') ? '/proxy/network' : ''; + return "{$this->baseUrl}{$v2Base}/v2/api/site/{$this->site}{$path}"; + } + + /** + * Check whether an HTTP response is a usable JSON response (not a 404 HTML page). + */ + private function isJsonResponse($response): bool + { + if (! $response->successful()) return false; + $ct = $response->header('Content-Type', ''); + return str_contains($ct, 'json') || (! str_contains($ct, 'html')); + } + + /** + * Normalize PPSK entries to a consistent shape regardless of API version. + * + * We merge the original raw entry with our normalized aliases so: + * - All original fields survive (frontend can inspect them) + * - Standard field names (_id, name, x_passphrase, wlan_id, vlan_id) are + * always present, mapped from whatever variant the API used + */ + private function normalizePpsk(array $entries): array + { + return array_values(array_map(function ($e) { + $normalized = [ + '_id' => $e['_id'] ?? $e['id'] ?? null, + 'name' => $e['name'] ?? $e['label'] ?? $e['username'] ?? $e['privatePskName'] ?? null, + 'x_passphrase' => $e['x_passphrase'] ?? $e['password'] ?? $e['passphrase'] ?? $e['psk'] ?? null, + 'wlan_id' => $e['wlan_id'] ?? $e['wlanId'] ?? null, + 'networkconf_id'=> $e['networkconf_id'] ?? $e['network_conf_id'] ?? $e['networkId'] ?? null, + 'vlan_id' => $e['vlan_id'] ?? $e['vlanId'] ?? $e['vlan'] ?? null, + ]; + return array_merge($e, array_filter($normalized, fn ($v) => $v !== null)); + }, $entries)); + } + + public function getNetworkConfs(): array + { + return $this->get('/rest/networkconf'); + } + + /** + * Fetch PPSK entries for a WLAN. + * + * Tries multiple endpoints in order of likelihood, because the correct path + * varies significantly by controller version and firmware: + * + * 1. Classic REST with wlan_id filter (works on most standalone controllers) + * 2. Classic REST fetch-all + local filter (when query param is ignored) + * 3. v2 hotspot endpoint (UniFi Network App 7.x+) + * 4. v2 wlan password endpoint (some UDM firmware variants) + * 5. Embedded in the WLAN object itself (some controller versions) + */ + public function getPpskEntries(string $wlanId): array + { + // 1. Classic path with filter + try { + $results = $this->get("/rest/ppsk?wlan_id={$wlanId}"); + if (! empty($results)) { + Log::info('unifi.ppsk_found', ['path' => 'classic_filter', 'count' => count($results)]); + return $this->normalizePpsk($results); + } + } catch (\Throwable $e) { + Log::debug('unifi.ppsk_classic_filter_failed', ['error' => $e->getMessage()]); + } + + // 2. Classic path without filter — fetch all and filter locally + try { + $all = $this->get('/rest/ppsk'); + Log::debug('unifi.ppsk_classic_all', ['total' => count($all)]); + if (! empty($all)) { + $filtered = array_filter($all, fn ($e) => ($e['wlan_id'] ?? '') === $wlanId); + if (! empty($filtered)) { + Log::info('unifi.ppsk_found', ['path' => 'classic_all_filtered', 'count' => count($filtered)]); + return $this->normalizePpsk(array_values($filtered)); + } + } + } catch (\Throwable $e) { + Log::debug('unifi.ppsk_classic_all_failed', ['error' => $e->getMessage()]); + } + + // 3–4. v2 API paths (try each, skip HTML error pages) + $v2Paths = [ + "/hotspot/op/private-preshared-key?wlanId={$wlanId}", + "/wlan/{$wlanId}/password", + "/wlan/{$wlanId}/private-preshared-key", + ]; + + foreach ($v2Paths as $path) { + try { + $url = $this->v2SiteUrl($path); + $response = $this->buildRequest()->get($url); + Log::debug('unifi.ppsk_v2_probe', ['path' => $path, 'status' => $response->status(), 'ct' => $response->header('Content-Type')]); + if (! $this->isJsonResponse($response)) continue; + + $data = $response->json('data') ?? $response->json() ?? []; + if (is_array($data) && ! empty($data)) { + Log::info('unifi.ppsk_found', ['path' => $path, 'count' => count($data)]); + return $this->normalizePpsk(array_values($data)); + } + } catch (\Throwable $e) { + Log::debug('unifi.ppsk_v2_path_failed', ['path' => $path, 'error' => $e->getMessage()]); + } + } + + // 5. Check if PPSKs are embedded in the WLAN object + try { + $wlan = $this->get("/rest/wlanconf/{$wlanId}"); + $w = $wlan[0] ?? $wlan; + $embedded = $w['private_preshared_keys'] ?? []; + if (! empty($embedded)) { + Log::info('unifi.ppsk_found', ['path' => 'wlan_embedded', 'count' => count($embedded)]); + return $this->normalizePpsk($embedded); + } + } catch (\Throwable $e) { + Log::debug('unifi.ppsk_wlan_embedded_failed', ['error' => $e->getMessage()]); + } + + Log::warning('unifi.ppsk_all_paths_empty', ['wlan_id' => $wlanId]); + // All paths exhausted — return empty rather than erroring (network may just have no PPSKs yet) + return []; + } + + public function createPpsk(array $data): array + { + $wlanId = $data['wlan_id'] ?? null; + + // Try v2 hotspot endpoint first + if ($wlanId) { + try { + $url = $this->v2SiteUrl('/hotspot/op/private-preshared-key'); + $response = $this->buildRequest()->asJson()->post($url, $data); + if ($this->isJsonResponse($response)) { + return $this->normalizePpsk([$response->json('data') ?? $response->json()]); + } + } catch (\Throwable $e) { + Log::debug('unifi.ppsk_v2_hotspot_create_failed', ['error' => $e->getMessage()]); + } + + // Try v2 wlan password endpoint + try { + $url = $this->v2SiteUrl("/wlan/{$wlanId}/password"); + $response = $this->buildRequest()->asJson()->post($url, $data); + if ($this->isJsonResponse($response)) { + $result = $response->json('data') ?? $response->json(); + return $this->normalizePpsk(is_array($result) && isset($result[0]) ? $result : [$result]); + } + } catch (\Throwable $e) { + Log::debug('unifi.ppsk_v2_wlan_create_failed', ['error' => $e->getMessage()]); + } + } + + // Fall back to classic REST + $result = $this->post('/rest/ppsk', $data); + return $this->normalizePpsk(is_array($result) && isset($result[0]) ? $result : [$result]); + } + + public function updatePpsk(string $ppskId, array $data): array + { + // Try v2 hotspot endpoint first + try { + $url = $this->v2SiteUrl("/hotspot/op/private-preshared-key/{$ppskId}"); + $response = $this->buildRequest()->asJson()->put($url, $data); + if ($this->isJsonResponse($response)) { + return $this->normalizePpsk([$response->json('data') ?? $response->json()]); + } + } catch (\Throwable $e) { + Log::debug('unifi.ppsk_v2_hotspot_update_failed', ['error' => $e->getMessage()]); + } + + $result = $this->put("/rest/ppsk/{$ppskId}", $data); + return $this->normalizePpsk(is_array($result) && isset($result[0]) ? $result : [$result]); + } + + public function deletePpsk(string $ppskId): void + { + // Try v2 hotspot endpoint first + try { + $url = $this->v2SiteUrl("/hotspot/op/private-preshared-key/{$ppskId}"); + $response = $this->buildRequest()->delete($url); + if ($response->successful()) return; + } catch (\Throwable $e) { + Log::debug('unifi.ppsk_v2_hotspot_delete_failed', ['error' => $e->getMessage()]); + } + + $this->delete("/rest/ppsk/{$ppskId}"); + } + + /** + * Kick (deauth) every client currently connected via the given PPSK. + * UniFi station records include a _psk_id field matching the PPSK's _id. + * Returns the number of clients kicked. + */ + public function kickClientsForPpsk(string $ppskUnifiId): int + { + $kicked = 0; + try { + $clients = $this->get('/rest/sta'); + foreach ($clients as $client) { + if (($client['_psk_id'] ?? null) !== $ppskUnifiId) continue; + try { + $this->post('/cmd/stamgr', ['cmd' => 'kick-sta', 'mac' => $client['mac']]); + $kicked++; + } catch (\Throwable $e) { + // continue kicking remaining clients even if one fails + } + } + } catch (\Throwable $e) { + // non-fatal: if we can't list clients, skip kicking + Log::debug('unifi.ppsk_kick_clients_failed', ['ppsk_id' => $ppskUnifiId, 'error' => $e->getMessage()]); + } + return $kicked; + } + // ── Health / Stats ──────────────────────────────────────────────────────── public function getSiteHealth(): array diff --git a/src/Services/WebhookCheckService.php b/src/Services/WebhookCheckService.php index c4804fb..7093bc3 100644 --- a/src/Services/WebhookCheckService.php +++ b/src/Services/WebhookCheckService.php @@ -5,29 +5,42 @@ namespace Dashboard\Unifi\Services; use Dashboard\Unifi\Models\DeviceState; use Dashboard\Unifi\Models\WebhookConfig; use Dashboard\Unifi\Models\WebhookLog; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; class WebhookCheckService { public const EVENTS = [ + // ── Device presence ────────────────────────────────────────────────────── 'device_offline' => 'A UniFi device goes offline', 'device_online' => 'A UniFi device comes back online', + + // ── Client tracking ────────────────────────────────────────────────────── 'client_offline' => 'A tracked client (by MAC) disconnects', 'client_online' => 'A tracked client (by MAC) connects', + + // ── WAN ────────────────────────────────────────────────────────────────── 'wan_down' => 'Internet / WAN goes down', 'wan_up' => 'Internet / WAN comes back up', + + // ── Threshold alerts (fire on entry into alert state) ──────────────────── 'client_count_high' => 'AP client count exceeds threshold', 'cu_high' => 'Channel utilization exceeds threshold', 'satisfaction_low' => 'WiFi experience drops below threshold', 'high_error_rate' => 'High retry/drop rate on an AP', + + // ── Threshold resolved (fire on exit from alert state) ─────────────────── + 'client_count_normal' => 'AP client count returns to normal', + 'cu_normal' => 'Channel utilization returns to normal', + 'satisfaction_normal' => 'WiFi experience returns to normal', + 'error_rate_normal' => 'Error rate returns to normal', + + // ── Informational ──────────────────────────────────────────────────────── 'firmware_available' => 'AP firmware update available', 'ap_unexpected_reboot' => 'AP rebooted unexpectedly (uptime reset)', ]; - /** - * Available template variables per event type. - */ public const TEMPLATE_VARS = [ 'device_offline' => ['{{device}}', '{{mac}}', '{{timestamp}}'], 'device_online' => ['{{device}}', '{{mac}}', '{{timestamp}}'], @@ -36,9 +49,13 @@ class WebhookCheckService 'wan_down' => ['{{timestamp}}'], 'wan_up' => ['{{timestamp}}'], 'client_count_high' => ['{{device}}', '{{mac}}', '{{clients}}', '{{threshold}}', '{{timestamp}}'], + 'client_count_normal' => ['{{device}}', '{{mac}}', '{{clients}}', '{{threshold}}', '{{timestamp}}'], 'cu_high' => ['{{device}}', '{{mac}}', '{{radio}}', '{{cu}}', '{{threshold}}', '{{timestamp}}'], + 'cu_normal' => ['{{device}}', '{{mac}}', '{{radio}}', '{{cu}}', '{{threshold}}', '{{timestamp}}'], 'satisfaction_low' => ['{{device}}', '{{mac}}', '{{satisfaction}}', '{{threshold}}', '{{timestamp}}'], + 'satisfaction_normal' => ['{{device}}', '{{mac}}', '{{satisfaction}}', '{{threshold}}', '{{timestamp}}'], 'high_error_rate' => ['{{device}}', '{{mac}}', '{{retries}}', '{{timestamp}}'], + 'error_rate_normal' => ['{{device}}', '{{mac}}', '{{timestamp}}'], 'firmware_available' => ['{{device}}', '{{mac}}', '{{version}}', '{{timestamp}}'], 'ap_unexpected_reboot' => ['{{device}}', '{{mac}}', '{{uptime}}', '{{timestamp}}'], ]; @@ -51,9 +68,13 @@ class WebhookCheckService 'wan_down' => '🔴 Internet connection is DOWN', 'wan_up' => '🟢 Internet connection restored', 'client_count_high' => '⚠️ {{device}}: {{clients}} clients connected (threshold: {{threshold}})', + 'client_count_normal' => '✅ {{device}}: client count returned to normal ({{clients}} clients)', 'cu_high' => '⚠️ {{device}} ({{radio}}): {{cu}}% channel utilization (threshold: {{threshold}}%)', + 'cu_normal' => '✅ {{device}} ({{radio}}): channel utilization returned to normal ({{cu}}%)', 'satisfaction_low' => '⚠️ {{device}}: WiFi experience {{satisfaction}}% (threshold: {{threshold}}%)', + 'satisfaction_normal' => '✅ {{device}}: WiFi experience returned to normal ({{satisfaction}}%)', 'high_error_rate' => '⚠️ {{device}}: high retry count ({{retries}})', + 'error_rate_normal' => '✅ {{device}}: error rate returned to normal', 'firmware_available' => '🔄 {{device}}: firmware update available (current: {{version}})', 'ap_unexpected_reboot' => '🔄 {{device}}: unexpected reboot (uptime: {{uptime}}s)', ]; @@ -71,15 +92,14 @@ class WebhookCheckService return 0; } - // Fetch active clients only if any config tracks client events $activeClients = null; - $needsClients = $configs->contains(fn ($c) => array_intersect($c->events ?? [], ['client_offline', 'client_online'])); + $needsClients = $configs->contains(fn ($c) => array_intersect($c->events ?? [], ['client_offline', 'client_online'])); if ($needsClients) { try { $activeClients = $unifi->getActiveClients(); } catch (\Throwable) { $activeClients = []; } } - $wan = collect($health)->firstWhere('subsystem', 'wan'); - $aps = collect($devices)->where('type', 'uap'); + $wan = collect($health)->firstWhere('subsystem', 'wan'); + $aps = collect($devices)->where('type', 'uap'); $fired = 0; foreach ($configs as $config) { @@ -88,30 +108,45 @@ class WebhookCheckService $filter = $config->device_filter ?? []; $templates = $config->templates ?? []; $clientMacs = $config->tracked_clients ?? []; + $cid = $config->id; foreach ($events as $event) { $alerts = match ($event) { - 'device_offline' => $this->checkDeviceTransition($devices, $filter, false), - 'device_online' => $this->checkDeviceTransition($devices, $filter, true), - 'client_offline' => $this->checkClientTransition($activeClients ?? [], $clientMacs, false), - 'client_online' => $this->checkClientTransition($activeClients ?? [], $clientMacs, true), - 'wan_down' => $this->checkWan($wan, false), - 'wan_up' => $this->checkWan($wan, true), - 'client_count_high' => $this->checkClientCount($aps, $thresholds['client_count'] ?? 50, $filter), - 'cu_high' => $this->checkCu($aps, $thresholds['cu_threshold'] ?? 80, $thresholds['cu_band'] ?? null, $filter), - 'satisfaction_low' => $this->checkSatisfaction($aps, $thresholds['satisfaction_min'] ?? 50, $filter), - 'high_error_rate' => $this->checkErrorRate($aps, $filter), - 'firmware_available' => $this->checkFirmware($devices, $filter), - 'ap_unexpected_reboot' => $this->checkReboot($aps, $filter), - default => [], + 'device_offline' => $this->checkDeviceTransition($devices, $filter, false), + 'device_online' => $this->checkDeviceTransition($devices, $filter, true), + 'client_offline' => $this->checkClientTransition($activeClients ?? [], $clientMacs, false), + 'client_online' => $this->checkClientTransition($activeClients ?? [], $clientMacs, true), + 'wan_down' => $this->checkWan($wan, false), + 'wan_up' => $this->checkWan($wan, true), + 'client_count_high' => $this->checkClientCount($aps, $thresholds['client_count'] ?? 50, $filter, $cid), + 'client_count_normal' => $this->checkClientCountResolved($aps, $thresholds['client_count'] ?? 50, $filter, $cid), + 'cu_high' => $this->checkCu($aps, $thresholds['cu_threshold'] ?? 80, $thresholds['cu_band'] ?? null, $filter, $cid, (int) ($thresholds['cu_sustain'] ?? 3)), + 'cu_normal' => $this->checkCuResolved($aps, $thresholds['cu_threshold'] ?? 80, $thresholds['cu_band'] ?? null, $filter, $cid), + 'satisfaction_low' => $this->checkSatisfaction($aps, $thresholds['satisfaction_min'] ?? 50, $filter, $cid), + 'satisfaction_normal' => $this->checkSatisfactionResolved($aps, $thresholds['satisfaction_min'] ?? 50, $filter, $cid), + 'high_error_rate' => $this->checkErrorRate($aps, $filter, $cid), + 'error_rate_normal' => $this->checkErrorRateResolved($aps, $filter, $cid), + 'firmware_available' => $this->checkFirmware($devices, $filter), + 'ap_unexpected_reboot'=> $this->checkReboot($aps, $filter), + default => [], }; foreach ($alerts as $alert) { + // Extract internal metadata before cooldown/fire + $deviceStateUpdate = $alert['_device_state_update'] ?? null; + unset($alert['_device_state_update']); + if ($this->isInCooldown($config, $event, $alert['key'] ?? '')) continue; - // Apply message template + $alert['message'] = $this->applyTemplate($event, $alert, $templates[$event] ?? null); $this->fire($config, $event, $alert); $fired++; + + // Update device in_alert state — only AFTER confirmed firing (not if suppressed by cooldown) + if ($deviceStateUpdate !== null) { + [$stateModel, $inAlert] = $deviceStateUpdate; + $stateModel->update(['in_alert' => $inAlert]); + } } } } @@ -122,6 +157,60 @@ class WebhookCheckService return $fired; } + // ── Device transitions ──────────────────────────────────────────────────── + + /** + * Checks device online/offline transitions. + * + * Offline (2-poll grace): fires when a device has been offline for 2 consecutive polls + * AND no active offline alert is already outstanding (in_alert = false). + * Setting in_alert=true is deferred until after the alert is confirmed not suppressed by cooldown. + * + * Online ("resolved"): fires when a device has been online for 2 consecutive polls + * AND an active offline alert was previously sent (in_alert = true). + * This prevents orphan "back online" notifications with no preceding "offline". + */ + private function checkDeviceTransition(array $devices, array $filter, bool $comingOnline): array + { + $alerts = []; + foreach ($devices as $dev) { + $mac = $dev['mac']; + if (! empty($filter) && ! in_array($mac, $filter)) continue; + $name = $dev['name'] ?? $dev['model'] ?? $mac; + $isOnline = ($dev['state'] ?? 0) == 1; + $prev = DeviceState::where('device_mac', $mac)->first(); + if (! $prev) continue; + + // Skip planned reboots — these are intentional, not alerts + if (Cache::has('unifi:planned_reboot:' . strtolower($mac))) continue; + + if ($comingOnline) { + // Online: 2nd consecutive online poll, and we previously sent an offline alert + if ($prev->was_online === false && $isOnline && $prev->consecutive_count >= 1 && $prev->in_alert) { + $alerts[] = [ + 'key' => $mac, + 'device' => $name, + 'mac' => $mac, + 'message' => "{$name} is back online", + '_device_state_update' => [$prev, false], // clear in_alert after fire + ]; + } + } else { + // Offline: 2nd consecutive offline poll, no active alert already outstanding + if ($prev->was_online === true && ! $isOnline && $prev->consecutive_count >= 1 && ! $prev->in_alert) { + $alerts[] = [ + 'key' => $mac, + 'device' => $name, + 'mac' => $mac, + 'message' => "{$name} has gone offline", + '_device_state_update' => [$prev, true], // set in_alert after fire + ]; + } + } + } + return $alerts; + } + // ── Client tracking ─────────────────────────────────────────────────────── private function checkClientTransition(?array $clients, array $trackedMacs, bool $comingOnline): array @@ -136,17 +225,13 @@ class WebhookCheckService $name = is_array($entry) ? ($entry['name'] ?? $mac) : $mac; if (! $mac) continue; - $prev = DeviceState::where('device_mac', $mac)->first(); + $prev = DeviceState::where('device_mac', $mac)->first(); $isOnline = in_array($mac, $connectedMacs); - if (! $prev) continue; $clientInfo = collect($clients)->firstWhere('mac', $mac); - // Fire on the 2nd consecutive observation of the new state. - // Check runs BEFORE sync — when count == 1 here, this poll is the 2nd consecutive - // miss/hit, and sync will flip was_online after we fire. - if ($comingOnline && $prev->was_online === false && $isOnline && $prev->consecutive_count >= 1) { // 2nd consecutive poll online + if ($comingOnline && $prev->was_online === false && $isOnline && $prev->consecutive_count >= 1) { $alerts[] = [ 'key' => $mac, 'client_name' => $name, @@ -158,7 +243,7 @@ class WebhookCheckService 'message' => "{$name} ({$mac}) connected", ]; } - if (! $comingOnline && $prev->was_online === true && ! $isOnline && $prev->consecutive_count >= 2) { // 3rd consecutive poll offline + if (! $comingOnline && $prev->was_online === true && ! $isOnline && $prev->consecutive_count >= 2) { $alerts[] = [ 'key' => $mac, 'client_name' => $name, @@ -172,89 +257,7 @@ class WebhookCheckService return $alerts; } - private function syncClientStates(array $clients): void - { - $connectedMacs = collect($clients)->pluck('mac')->map(fn ($m) => strtolower($m))->all(); - - // Update tracked client states - $tracked = DeviceState::whereNotNull('device_mac') - ->where('device_mac', 'NOT LIKE', '%:%:%:%:%:%') // skip MAC format check — just update all - ->get(); - - // Actually, we store client MACs in the same table. Let's just upsert for all connected clients - // that are in tracked_clients lists - $allTracked = WebhookConfig::where('is_active', true) - ->whereJsonLength('tracked_clients', '>', 0) - ->get() - ->flatMap(fn ($c) => collect($c->tracked_clients)->map(fn ($e) => strtolower(is_array($e) ? ($e['mac'] ?? '') : $e))) - ->unique() - ->filter(); - - foreach ($allTracked as $mac) { - $isOnline = in_array($mac, $connectedMacs); - $prev = DeviceState::where('device_mac', $mac)->first(); - - if (! $prev) { - DeviceState::create(['device_mac' => $mac, 'was_online' => $isOnline, 'consecutive_count' => 1, - 'last_seen_at' => now(), 'updated_at' => now()]); - continue; - } - - if ($prev->was_online === $isOnline) { - $prev->update(['consecutive_count' => 0, 'last_seen_at' => now(), 'updated_at' => now()]); - } else { - $count = $prev->consecutive_count + 1; - $grace = $isOnline ? 2 : 3; - if ($count >= $grace) { - // Confirmed — flip and reset counter - $prev->update(['was_online' => $isOnline, 'consecutive_count' => 0, - 'last_seen_at' => now(), 'updated_at' => now()]); - } else { - $prev->update(['consecutive_count' => $count, 'last_seen_at' => now(), 'updated_at' => now()]); - } - } - } - } - - // ── Device checks (existing) ────────────────────────────────────────────── - - /** - * Check device online/offline transitions. - * Runs BEFORE syncDeviceStates — reads state from the previous poll's sync. - * - * Offline: requires 3 consecutive offline polls (count >= 2) before firing. - * Online: requires 2 consecutive online polls (count >= 1) and was_online must - * already be false (i.e., offline was confirmed) — prevents "back online" - * alerts for devices that blipped and recovered within the offline grace window. - * - * After a confirmed transition, syncDeviceStates resets consecutive_count to 0, - * so the reverse direction must also accumulate from scratch. - */ - private function checkDeviceTransition(array $devices, array $filter, bool $comingOnline): array - { - $alerts = []; - foreach ($devices as $dev) { - $mac = $dev['mac']; - if (! empty($filter) && ! in_array($mac, $filter)) continue; - $name = $dev['name'] ?? $dev['model'] ?? $mac; - $isOnline = ($dev['state'] ?? 0) == 1; - $prev = DeviceState::where('device_mac', $mac)->first(); - if (! $prev) continue; - - // Online: fires on the 2nd consecutive poll back (count >= 1). - // Only fires if was_online=false, which only happens after offline was confirmed — - // so this naturally prevents spurious "back online" alerts for brief blips. - if ($comingOnline && $prev->was_online === false && $isOnline && $prev->consecutive_count >= 1) - $alerts[] = ['key' => $mac, 'device' => $name, 'mac' => $mac, 'message' => "{$name} is back online"]; - - // Offline: fires on the 3rd consecutive offline poll (count >= 2). - // Requires 3 consecutive misses (~90s at 30s interval) to avoid false positives - // from transient controller communication gaps (UPS, cameras, etc.). - if (! $comingOnline && $prev->was_online === true && ! $isOnline && $prev->consecutive_count >= 2) - $alerts[] = ['key' => $mac, 'device' => $name, 'mac' => $mac, 'message' => "{$name} has gone offline"]; - } - return $alerts; - } + // ── WAN ────────────────────────────────────────────────────────────────── private function checkWan(?array $wan, bool $comingUp): array { @@ -268,61 +271,222 @@ class WebhookCheckService return []; } - private function checkClientCount($aps, int $threshold, array $filter): array - { - $alerts = []; - foreach ($aps as $ap) { - if (! empty($filter) && ! in_array($ap['mac'], $filter)) continue; - $count = $ap['num_sta'] ?? 0; - if ($count >= $threshold) { - $name = $ap['name'] ?? $ap['mac']; - $alerts[] = ['key' => $ap['mac'], 'device' => $name, 'mac' => $ap['mac'], 'clients' => $count, 'threshold' => $threshold, 'message' => "{$name}: {$count} clients"]; - } - } - return $alerts; - } + // ── Threshold checks (alert on entry, resolved on exit) ────────────────── - private function checkCu($aps, int $threshold, ?string $band, array $filter): array + /** + * Client count — alert when exceeding threshold (only once per alert state entry). + */ + private function checkClientCount($aps, int $threshold, array $filter, int $configId): array { $alerts = []; foreach ($aps as $ap) { if (! empty($filter) && ! in_array($ap['mac'], $filter)) continue; - foreach ($ap['radio_table_stats'] ?? [] as $radio) { - if ($band && ($radio['radio'] ?? '') !== $band) continue; - $cu = $radio['cu_total'] ?? 0; - if ($cu >= $threshold) { - $name = $ap['name'] ?? $ap['mac']; - $rName = $radio['radio'] ?? '?'; - $alerts[] = ['key' => $ap['mac'].':'.$rName, 'device' => $name, 'mac' => $ap['mac'], 'radio' => $rName, 'cu' => $cu, 'threshold' => $threshold, 'message' => "{$name} ({$rName}): {$cu}% CU"]; + $count = $ap['num_sta'] ?? 0; + $cacheKey = "unifi:alert:{$configId}:client_count:{$ap['mac']}"; + if ($count >= $threshold) { + if (! Cache::has($cacheKey)) { + Cache::put($cacheKey, $threshold, now()->addHours(4)); + $name = $ap['name'] ?? $ap['mac']; + $alerts[] = ['key' => $ap['mac'], 'device' => $name, 'mac' => $ap['mac'], + 'clients' => $count, 'threshold' => $threshold, 'message' => "{$name}: {$count} clients"]; + } else { + Cache::put($cacheKey, $threshold, now()->addHours(4)); // refresh TTL } } } return $alerts; } - private function checkSatisfaction($aps, int $minSat, array $filter): array + /** + * Client count — resolved when dropping back below threshold. + * Only fires if the cached threshold matches the current config (prevents spurious + * resolved alerts after the threshold is raised). + */ + private function checkClientCountResolved($aps, int $threshold, array $filter, int $configId): array { $alerts = []; foreach ($aps as $ap) { if (! empty($filter) && ! in_array($ap['mac'], $filter)) continue; - $sat = $ap['satisfaction'] ?? null; - if ($sat !== null && $sat >= 0 && $sat < $minSat) { - $name = $ap['name'] ?? $ap['mac']; - $alerts[] = ['key' => $ap['mac'], 'device' => $name, 'mac' => $ap['mac'], 'satisfaction' => $sat, 'threshold' => $minSat, 'message' => "{$name}: {$sat}% experience"]; + $count = $ap['num_sta'] ?? 0; + $cacheKey = "unifi:alert:{$configId}:client_count:{$ap['mac']}"; + $cached = Cache::get($cacheKey); + if ($count < $threshold && $cached !== null) { + Cache::forget($cacheKey); + if ((int) $cached === $threshold) { + // Only alert if this is the same threshold that originally triggered + $name = $ap['name'] ?? $ap['mac']; + $alerts[] = ['key' => $ap['mac'], 'device' => $name, 'mac' => $ap['mac'], + 'clients' => $count, 'threshold' => $threshold, 'message' => "{$name}: client count returned to normal ({$count})"]; + } } } return $alerts; } - private function checkErrorRate($aps, array $filter): array + /** + * Channel utilization — alert on entry, with sustain guard. + * + * CU must stay above threshold for $sustainPolls consecutive poll cycles before + * the alert fires. A pending counter cache key tracks progress toward that threshold. + * The counter is cleared as soon as CU drops back below threshold. + */ + private function checkCu($aps, int $threshold, ?string $band, array $filter, int $configId, int $sustainPolls = 3): array { $alerts = []; foreach ($aps as $ap) { if (! empty($filter) && ! in_array($ap['mac'], $filter)) continue; - $retries = $ap['stat']['ap']['tx_retries'] ?? 0; + foreach ($ap['radio_table_stats'] ?? [] as $radio) { + if ($band && ($radio['radio'] ?? '') !== $band) continue; + $cu = $radio['cu_total'] ?? 0; + $rName = $radio['radio'] ?? '?'; + $alertKey = "unifi:alert:{$configId}:cu:{$ap['mac']}:{$rName}"; + $pendingKey = "unifi:alert:{$configId}:cu_pending:{$ap['mac']}:{$rName}"; + + if ($cu >= $threshold) { + if (Cache::has($alertKey)) { + Cache::put($alertKey, $threshold, now()->addHours(4)); // refresh TTL while still in alert + } else { + $count = (int) Cache::get($pendingKey, 0) + 1; + Cache::put($pendingKey, $count, now()->addHours(1)); + + if ($count >= $sustainPolls) { + Cache::put($alertKey, $threshold, now()->addHours(4)); + Cache::forget($pendingKey); + $name = $ap['name'] ?? $ap['mac']; + $alerts[] = ['key' => $ap['mac'].':'.$rName, 'device' => $name, 'mac' => $ap['mac'], + 'radio' => $rName, 'cu' => $cu, 'threshold' => $threshold, 'message' => "{$name} ({$rName}): {$cu}% CU"]; + } + } + } else { + Cache::forget($pendingKey); // reset sustain counter when CU drops below threshold + } + } + } + return $alerts; + } + + /** + * Channel utilization — resolved on exit. + * Also clears the pending sustain counter so it doesn't carry over to the next alert cycle. + * Only fires the resolved alert when the cached threshold matches the current config — + * this prevents a spurious "back to normal" after the threshold has been raised. + */ + private function checkCuResolved($aps, int $threshold, ?string $band, array $filter, int $configId): array + { + $alerts = []; + foreach ($aps as $ap) { + if (! empty($filter) && ! in_array($ap['mac'], $filter)) continue; + foreach ($ap['radio_table_stats'] ?? [] as $radio) { + if ($band && ($radio['radio'] ?? '') !== $band) continue; + $cu = $radio['cu_total'] ?? 0; + $rName = $radio['radio'] ?? '?'; + $alertKey = "unifi:alert:{$configId}:cu:{$ap['mac']}:{$rName}"; + $pendingKey = "unifi:alert:{$configId}:cu_pending:{$ap['mac']}:{$rName}"; + if ($cu < $threshold) { + Cache::forget($pendingKey); // always reset sustain counter when below threshold + $cached = Cache::get($alertKey); + if ($cached !== null) { + Cache::forget($alertKey); + if ((int) $cached === $threshold) { + // Only alert if this is the same threshold that originally triggered + $name = $ap['name'] ?? $ap['mac']; + $alerts[] = ['key' => $ap['mac'].':'.$rName, 'device' => $name, 'mac' => $ap['mac'], + 'radio' => $rName, 'cu' => $cu, 'threshold' => $threshold, 'message' => "{$name} ({$rName}): CU returned to normal ({$cu}%)"]; + } + } + } + } + } + return $alerts; + } + + /** + * WiFi satisfaction — alert on entry. + */ + private function checkSatisfaction($aps, int $minSat, array $filter, int $configId): array + { + $alerts = []; + foreach ($aps as $ap) { + if (! empty($filter) && ! in_array($ap['mac'], $filter)) continue; + $sat = $ap['satisfaction'] ?? null; + $cacheKey = "unifi:alert:{$configId}:satisfaction:{$ap['mac']}"; + if ($sat !== null && $sat >= 0 && $sat < $minSat) { + if (! Cache::has($cacheKey)) { + Cache::put($cacheKey, $minSat, now()->addHours(4)); + $name = $ap['name'] ?? $ap['mac']; + $alerts[] = ['key' => $ap['mac'], 'device' => $name, 'mac' => $ap['mac'], + 'satisfaction' => $sat, 'threshold' => $minSat, 'message' => "{$name}: {$sat}% experience"]; + } else { + Cache::put($cacheKey, $minSat, now()->addHours(4)); // refresh TTL + } + } + } + return $alerts; + } + + /** + * WiFi satisfaction — resolved on exit. + * Only fires if the cached threshold matches current config. + */ + private function checkSatisfactionResolved($aps, int $minSat, array $filter, int $configId): array + { + $alerts = []; + foreach ($aps as $ap) { + if (! empty($filter) && ! in_array($ap['mac'], $filter)) continue; + $sat = $ap['satisfaction'] ?? null; + $cacheKey = "unifi:alert:{$configId}:satisfaction:{$ap['mac']}"; + $cached = Cache::get($cacheKey); + if ($sat !== null && $sat >= $minSat && $cached !== null) { + Cache::forget($cacheKey); + if ((int) $cached === $minSat) { + $name = $ap['name'] ?? $ap['mac']; + $alerts[] = ['key' => $ap['mac'], 'device' => $name, 'mac' => $ap['mac'], + 'satisfaction' => $sat, 'threshold' => $minSat, 'message' => "{$name}: experience returned to normal ({$sat}%)"]; + } + } + } + return $alerts; + } + + /** + * Error rate — alert on entry. + */ + private function checkErrorRate($aps, array $filter, int $configId): array + { + $alerts = []; + foreach ($aps as $ap) { + if (! empty($filter) && ! in_array($ap['mac'], $filter)) continue; + $retries = $ap['stat']['ap']['tx_retries'] ?? 0; + $cacheKey = "unifi:alert:{$configId}:error_rate:{$ap['mac']}"; if ($retries > 1000) { - $name = $ap['name'] ?? $ap['mac']; - $alerts[] = ['key' => $ap['mac'], 'device' => $name, 'mac' => $ap['mac'], 'retries' => $retries, 'message' => "{$name}: high retries ({$retries})"]; + if (! Cache::has($cacheKey)) { + Cache::put($cacheKey, 1000, now()->addHours(4)); + $name = $ap['name'] ?? $ap['mac']; + $alerts[] = ['key' => $ap['mac'], 'device' => $name, 'mac' => $ap['mac'], + 'retries' => $retries, 'message' => "{$name}: high retries ({$retries})"]; + } else { + Cache::put($cacheKey, 1000, now()->addHours(4)); // refresh TTL + } + } + } + return $alerts; + } + + /** + * Error rate — resolved on exit. + */ + private function checkErrorRateResolved($aps, array $filter, int $configId): array + { + $alerts = []; + foreach ($aps as $ap) { + if (! empty($filter) && ! in_array($ap['mac'], $filter)) continue; + $retries = $ap['stat']['ap']['tx_retries'] ?? 0; + $cacheKey = "unifi:alert:{$configId}:error_rate:{$ap['mac']}"; + if ($retries <= 1000 && Cache::get($cacheKey) !== null) { + Cache::forget($cacheKey); + $name = $ap['name'] ?? $ap['mac']; + $alerts[] = ['key' => $ap['mac'], 'device' => $name, 'mac' => $ap['mac'], + 'message' => "{$name}: error rate returned to normal"]; } } return $alerts; @@ -334,8 +498,9 @@ class WebhookCheckService foreach ($devices as $dev) { if (! empty($filter) && ! in_array($dev['mac'], $filter)) continue; if ($dev['upgradable'] ?? false) { - $name = $dev['name'] ?? $dev['mac']; - $alerts[] = ['key' => $dev['mac'], 'device' => $name, 'mac' => $dev['mac'], 'version' => $dev['version'] ?? '', 'message' => "{$name}: firmware update available"]; + $name = $dev['name'] ?? $dev['mac']; + $alerts[] = ['key' => $dev['mac'], 'device' => $name, 'mac' => $dev['mac'], + 'version' => $dev['version'] ?? '', 'message' => "{$name}: firmware update available"]; } } return $alerts; @@ -346,12 +511,14 @@ class WebhookCheckService $alerts = []; foreach ($aps as $ap) { if (! empty($filter) && ! in_array($ap['mac'], $filter)) continue; + if (Cache::has('unifi:planned_reboot:' . strtolower($ap['mac']))) continue; $uptime = $ap['uptime'] ?? 0; if ($uptime > 0 && $uptime < 300) { $prev = DeviceState::where('device_mac', $ap['mac'])->first(); if ($prev && $prev->was_online) { - $name = $ap['name'] ?? $ap['mac']; - $alerts[] = ['key' => $ap['mac'], 'device' => $name, 'mac' => $ap['mac'], 'uptime' => $uptime, 'message' => "{$name}: unexpected reboot"]; + $name = $ap['name'] ?? $ap['mac']; + $alerts[] = ['key' => $ap['mac'], 'device' => $name, 'mac' => $ap['mac'], + 'uptime' => $uptime, 'message' => "{$name}: unexpected reboot"]; } } } @@ -390,7 +557,6 @@ class WebhookCheckService 'data' => $data, ]; - // Format payload for the target platform $url = $config->url; $payload = $this->formatPayloadForPlatform($url, $message, $internalPayload); @@ -413,46 +579,26 @@ class WebhookCheckService WebhookLog::create($log); } - /** - * Detect the webhook platform from the URL and format the payload accordingly. - */ private function formatPayloadForPlatform(string $url, string $message, array $fullPayload): array { - // Google Chat - if (str_contains($url, 'chat.googleapis.com')) { - return ['text' => $message]; - } - - // Slack - if (str_contains($url, 'hooks.slack.com')) { - return ['text' => $message]; - } - - // Discord - if (str_contains($url, 'discord.com/api/webhooks')) { - return ['content' => $message]; - } - - // Microsoft Teams - if (str_contains($url, 'webhook.office.com') || str_contains($url, 'workflows.office.com')) { - return ['text' => $message]; - } - - // Generic / custom — send the full structured payload + if (str_contains($url, 'chat.googleapis.com')) return ['text' => $message]; + if (str_contains($url, 'hooks.slack.com')) return ['text' => $message]; + if (str_contains($url, 'discord.com/api/webhooks')) return ['content' => $message]; + if (str_contains($url, 'webhook.office.com') || str_contains($url, 'workflows.office.com')) return ['text' => $message]; return $fullPayload; } + // ── State sync ──────────────────────────────────────────────────────────── + /** - * Sync device states with consecutive_count for debouncing. + * Sync device states. Tracks consecutive_count and was_online. + * in_alert is managed separately in checkDeviceTransition (updated after confirmed fire). * - * Same state → reset counter to 0 (stable, no change pending) - * Different state → increment counter; flip was_online only once the grace threshold - * is reached, then RESET counter to 0 so the opposite direction must - * also accumulate from scratch. + * Offline grace: 2 consecutive offline polls (count reaches 2 → flip was_online=false) + * Online grace: 2 consecutive online polls (count reaches 2 → flip was_online=true) * - * Thresholds (must match checkDeviceTransition, which fires one poll before the flip): - * Going offline: grace = 3 polls (check fires at count >= 2, flip at count+1 >= 3) - * Coming online: grace = 2 polls (check fires at count >= 1, flip at count+1 >= 2) + * checkDeviceTransition fires one poll before the flip (at count >= 1), which is correct: + * the alert fires on the 2nd consecutive poll, state flips on the same run's sync call. */ private function syncDeviceStates(array $devices): void { @@ -463,9 +609,13 @@ class WebhookCheckService if (! $prev) { DeviceState::create([ - 'device_mac' => $mac, 'device_name' => $dev['name'] ?? $dev['model'] ?? null, - 'was_online' => $isOnline, 'consecutive_count' => 0, - 'last_seen_at' => now(), 'updated_at' => now(), + 'device_mac' => $mac, + 'device_name' => $dev['name'] ?? $dev['model'] ?? null, + 'was_online' => $isOnline, + 'in_alert' => false, + 'consecutive_count' => 0, + 'last_seen_at' => now(), + 'updated_at' => now(), ]); continue; } @@ -473,22 +623,67 @@ class WebhookCheckService $prevState = $prev->was_online; if ($prevState === $isOnline) { - // Same as confirmed state — reset counter (no pending transition) - $prev->update(['consecutive_count' => 0, 'last_seen_at' => now(), 'updated_at' => now(), - 'device_name' => $dev['name'] ?? $dev['model'] ?? $prev->device_name]); + // Stable — reset counter + $prev->update([ + 'consecutive_count' => 0, + 'last_seen_at' => now(), + 'updated_at' => now(), + 'device_name' => $dev['name'] ?? $dev['model'] ?? $prev->device_name, + ]); + } else { + // Approaching a state change + $count = $prev->consecutive_count + 1; + $grace = 2; // 2 consecutive polls for both directions + if ($count >= $grace) { + // Confirmed — flip was_online, reset counter + $prev->update([ + 'was_online' => $isOnline, + 'consecutive_count' => 0, + 'last_seen_at' => now(), + 'updated_at' => now(), + 'device_name' => $dev['name'] ?? $dev['model'] ?? $prev->device_name, + ]); + } else { + $prev->update([ + 'consecutive_count' => $count, + 'last_seen_at' => now(), + 'updated_at' => now(), + ]); + } + } + } + } + + private function syncClientStates(array $clients): void + { + $connectedMacs = collect($clients)->pluck('mac')->map(fn ($m) => strtolower($m))->all(); + + $allTracked = WebhookConfig::where('is_active', true) + ->whereJsonLength('tracked_clients', '>', 0) + ->get() + ->flatMap(fn ($c) => collect($c->tracked_clients)->map(fn ($e) => strtolower(is_array($e) ? ($e['mac'] ?? '') : $e))) + ->unique() + ->filter(); + + foreach ($allTracked as $mac) { + $isOnline = in_array($mac, $connectedMacs); + $prev = DeviceState::where('device_mac', $mac)->first(); + + if (! $prev) { + DeviceState::create(['device_mac' => $mac, 'was_online' => $isOnline, 'in_alert' => false, + 'consecutive_count' => 1, 'last_seen_at' => now(), 'updated_at' => now()]); + continue; + } + + if ($prev->was_online === $isOnline) { + $prev->update(['consecutive_count' => 0, 'last_seen_at' => now(), 'updated_at' => now()]); } else { - // Moving toward a state change — accumulate consecutive count $count = $prev->consecutive_count + 1; - // Grace threshold: 3 polls to confirm going offline, 2 to confirm coming online $grace = $isOnline ? 2 : 3; if ($count >= $grace) { - // Confirmed — flip was_online and RESET counter so the reverse direction - // must also accumulate from zero (prevents immediate back-and-forth firing) $prev->update(['was_online' => $isOnline, 'consecutive_count' => 0, - 'last_seen_at' => now(), 'updated_at' => now(), - 'device_name' => $dev['name'] ?? $dev['model'] ?? $prev->device_name]); + 'last_seen_at' => now(), 'updated_at' => now()]); } else { - // Not yet confirmed — just bump counter, keep was_online unchanged $prev->update(['consecutive_count' => $count, 'last_seen_at' => now(), 'updated_at' => now()]); } } diff --git a/src/UnifiServiceProvider.php b/src/UnifiServiceProvider.php index fea9e81..623896a 100644 --- a/src/UnifiServiceProvider.php +++ b/src/UnifiServiceProvider.php @@ -25,6 +25,9 @@ class UnifiServiceProvider extends ServiceProvider Console\CheckWebhooks::class, Console\CaptureSnapshots::class, Console\CleanupSnapshots::class, + Console\RebootAllAps::class, + Console\RotatePasswords::class, + Console\SyncPpskSchedules::class, ]); $this->publishes([ __DIR__ . '/../config/unifi.php' => config_path('unifi.php'), diff --git a/src/routes/unifi.php b/src/routes/unifi.php index d21fc2b..e9c91fe 100644 --- a/src/routes/unifi.php +++ b/src/routes/unifi.php @@ -1,10 +1,12 @@ name('wan.status'); Route::get('/devices', [DeviceController::class, 'index']) ->name('devices'); Route::get('/clients', [ClientController::class, 'index']) ->name('clients'); - Route::get('/client-dashboard',[StatsController::class, 'clientDashboard'])->name('client.dashboard'); + Route::get('/client-dashboard', [StatsController::class, 'clientDashboard']) ->name('client.dashboard'); + Route::get('/client-ap-traffic', [StatsController::class, 'clientApTraffic']) ->name('client.ap-traffic'); }); // ── Management (write access) ──────────────────────────────────────── Route::middleware('permission:unifi.manage')->group(function () { - Route::get('/wifi', [WifiController::class, 'index']) ->name('wifi'); - Route::put('/wifi/{wlanId}', [WifiController::class, 'update']) ->name('wifi.update'); - Route::post('/wifi/{wlanId}/toggle', [WifiController::class, 'toggle'])->name('wifi.toggle'); - Route::post('/wifi/groups', [WifiController::class, 'saveGroups'])->name('wifi.groups'); + // 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'); + + // PPSK (per-WLAN pre-shared keys) + Route::get('/wifi/{wlanId}/ppsk', [WifiController::class, 'ppskIndex']) ->name('wifi.ppsk.index'); + Route::post('/wifi/{wlanId}/ppsk', [WifiController::class, 'ppskStore']) ->name('wifi.ppsk.store'); + Route::put('/wifi/{wlanId}/ppsk/{ppskId}', [WifiController::class, 'ppskUpdate']) ->name('wifi.ppsk.update'); + Route::delete('/wifi/{wlanId}/ppsk/{ppskId}', [WifiController::class, 'ppskDestroy']) ->name('wifi.ppsk.destroy'); + 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'); }); @@ -43,13 +64,18 @@ Route::middleware(['web', 'auth', 'app.access:unifi']) Route::post('/portal/macs', [PortalController::class, 'storeMac']) ->name('portal.macs.store'); Route::delete('/portal/macs/{mac}', [PortalController::class, 'destroyMac']) ->name('portal.macs.destroy'); Route::post('/portal/sessions/{session}/disconnect', [PortalController::class, 'disconnectSession'])->name('portal.sessions.disconnect'); + + // VLAN Groups + Route::post('/portal/vlan-groups', [VlanGroupController::class, 'store']) ->name('portal.vlan-groups.store'); + Route::put('/portal/vlan-groups/{vlanGroup}', [VlanGroupController::class, 'update']) ->name('portal.vlan-groups.update'); + Route::delete('/portal/vlan-groups/{vlanGroup}', [VlanGroupController::class, 'destroy'])->name('portal.vlan-groups.destroy'); }); // ── Settings ───────────────────────────────────────────────────────── Route::middleware('permission:unifi.settings')->group(function () { Route::get('/settings', [UnifiSettingsController::class, 'edit']) ->name('settings'); Route::post('/settings', [UnifiSettingsController::class, 'update']) ->name('settings.update'); - Route::post('/settings/test', [UnifiSettingsController::class, 'testConnection'])->name('settings.test'); + Route::post('/settings/test', [UnifiSettingsController::class, 'testConnection'])->name('settings.test'); Route::post('/settings/sites', [UnifiSettingsController::class, 'fetchSites']) ->name('settings.sites'); // Webhooks