commit ce3217d8f4f20e95c0b3daae359fc4eaf21cfb8f Author: Joel Wedemire Date: Sun Apr 12 23:00:05 2026 -0700 feat: initial commit — UniFi snap-in package Full UniFi dashboard snap-in including: - WiFi/client/device stats with time-series snapshots - Client Dashboard with traffic, satisfaction, signal, download charts - Webhook alerting with debounced offline/online detection - AP snapshot collection, client snapshot collection - Device classification (type and OS) from OUI/hostname heuristics - Webhook cooldown, templates, and multi-platform delivery Co-Authored-By: Claude Sonnet 4.6 diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..82bcb43 --- /dev/null +++ b/composer.json @@ -0,0 +1,47 @@ +{ + "name": "dashboard/unifi", + "description": "UniFi network management, WiFi stats, and captive portal authentication for the Dashboard platform", + "version": "1.0.0", + "type": "library", + "license": "MIT", + "autoload": { + "psr-4": { + "Dashboard\\Unifi\\": "src/" + } + }, + "extra": { + "laravel": { + "providers": [ + "Dashboard\\Unifi\\UnifiServiceProvider" + ] + }, + "dashboard": { + "nav_folder": { + "label": "Unifi Network", + "icon": "wifi", + "sort_order": 40 + }, + "pages": [ + { "label": "WiFi Dashboard", "route_name": "unifi.dashboard", "icon": "chart-bar-square", "permission": "unifi.stats", "sort_order": 1 }, + { "label": "Client Dashboard", "route_name": "unifi.client.dashboard", "icon": "chart-pie", "permission": "unifi.stats", "sort_order": 2 }, + { "label": "Devices", "route_name": "unifi.devices", "icon": "cpu-chip", "permission": "unifi.stats", "sort_order": 3 }, + { "label": "Clients", "route_name": "unifi.clients", "icon": "users", "permission": "unifi.stats", "sort_order": 4 }, + { "label": "WiFi Networks", "route_name": "unifi.wifi", "icon": "wifi", "permission": "unifi.manage", "sort_order": 5 }, + { "label": "Portal", "route_name": "unifi.portal.settings", "icon": "shield-check", "permission": "unifi.auth", "sort_order": 6 }, + { "label": "Webhooks", "route_name": "unifi.webhooks.index", "icon": "bell-alert", "permission": "unifi.settings", "sort_order": 7 }, + { "label": "Settings", "route_name": "unifi.settings", "icon": "cog-6-tooth", "permission": "unifi.settings", "sort_order": 99 } + ], + "permissions": [ + { "key": "unifi.stats", "label": "View Network Stats", "description": "View WiFi dashboards, AP stats, and client lists" }, + { "key": "unifi.manage", "label": "Manage Network", "description": "Reboot APs, manage SSIDs, change WiFi passwords" }, + { "key": "unifi.auth", "label": "Manage Portal Auth", "description": "Configure captive portal, VLAN mappings, MAC allowlist" }, + { "key": "unifi.settings", "label": "Network Settings", "description": "Configure UniFi controller connection" } + ] + } + }, + "require": { + "php": "^8.2", + "illuminate/support": "^11.0|^12.0|^13.0", + "guzzlehttp/guzzle": "^7.0" + } +} diff --git a/config/unifi.php b/config/unifi.php new file mode 100644 index 0000000..50d6b84 --- /dev/null +++ b/config/unifi.php @@ -0,0 +1,12 @@ + 30, + 'cache_ttl_clients' => 15, + 'cache_ttl_health' => 10, + 'cache_ttl_events' => 30, + + // Maximum portal session duration defaults (overridden per-group in DB) + 'portal_session_default_minutes' => 720, // 12 hours +]; diff --git a/database/migrations/2026_04_12_000001_create_unifi_tables.php b/database/migrations/2026_04_12_000001_create_unifi_tables.php new file mode 100644 index 0000000..8824f4e --- /dev/null +++ b/database/migrations/2026_04_12_000001_create_unifi_tables.php @@ -0,0 +1,67 @@ +id(); + $table->string('group_name', 255); // Google OU or group name + $table->string('match_type', 20) // 'ou', 'group', 'email_domain', 'default' + ->default('group'); + $table->string('match_value', 255); // the value to match against + $table->unsignedSmallInteger('vlan_id'); // VLAN to assign + $table->unsignedTinyInteger('max_devices') // max concurrent devices + ->default(1); + $table->unsignedInteger('session_minutes') // portal session duration + ->default(720); + $table->unsignedSmallInteger('sort_order') + ->default(0); + $table->timestamps(); + }); + + // Known MAC addresses → direct VLAN assignment (no portal needed) + Schema::create('unifi_known_macs', function (Blueprint $table) { + $table->id(); + $table->string('mac_address', 17)->unique(); // aa:bb:cc:dd:ee:ff + $table->string('device_name', 255)->nullable(); + $table->string('device_type', 100)->nullable(); // chromebook, printer, phone, etc. + $table->string('owner', 255)->nullable(); // who it belongs to + $table->unsignedSmallInteger('vlan_id'); + $table->text('notes')->nullable(); + $table->timestamps(); + }); + + // Active portal sessions (tracks who's connected via captive portal) + Schema::create('unifi_portal_sessions', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('mac_address', 17); + $table->string('device_hostname', 255)->nullable(); + $table->string('device_os', 100)->nullable(); // detected OS + $table->string('device_type', 100)->nullable(); // detected device category + $table->string('ssid', 100)->nullable(); + $table->unsignedSmallInteger('vlan_id')->nullable(); + $table->string('ap_mac', 17)->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamp('authorized_at'); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + + $table->index(['user_id', 'is_active']); + $table->index('mac_address'); + }); + } + + public function down(): void + { + Schema::dropIfExists('unifi_portal_sessions'); + Schema::dropIfExists('unifi_known_macs'); + Schema::dropIfExists('unifi_vlan_mappings'); + } +}; diff --git a/database/migrations/2026_04_12_000002_create_unifi_webhooks_table.php b/database/migrations/2026_04_12_000002_create_unifi_webhooks_table.php new file mode 100644 index 0000000..5322b6f --- /dev/null +++ b/database/migrations/2026_04_12_000002_create_unifi_webhooks_table.php @@ -0,0 +1,51 @@ +id(); + $table->string('name'); + $table->string('url', 500); + $table->string('secret', 255)->nullable(); + $table->boolean('is_active')->default(true); + $table->json('events'); // ['device_offline','wan_down',...] + $table->json('thresholds')->nullable(); // {"client_count":50,"cu_threshold":80,...} + $table->json('device_filter')->nullable(); // ["aa:bb:cc:dd:ee:ff",...] + $table->unsignedInteger('cooldown_minutes')->default(15); + $table->timestamps(); + }); + + Schema::create('unifi_webhook_logs', function (Blueprint $table) { + $table->id(); + $table->foreignId('webhook_config_id')->constrained('unifi_webhook_configs')->cascadeOnDelete(); + $table->string('event_type', 100); + $table->json('payload'); + $table->unsignedSmallInteger('response_code')->nullable(); + $table->text('response_body')->nullable(); + $table->timestamp('fired_at'); + $table->index(['webhook_config_id', 'event_type', 'fired_at']); + }); + + Schema::create('unifi_device_states', function (Blueprint $table) { + $table->id(); + $table->string('device_mac', 17)->unique(); + $table->string('device_name', 255)->nullable(); + $table->boolean('was_online')->default(true); + $table->timestamp('last_seen_at')->nullable(); + $table->timestamp('updated_at')->nullable(); + }); + } + + public function down(): void + { + Schema::dropIfExists('unifi_webhook_logs'); + Schema::dropIfExists('unifi_webhook_configs'); + Schema::dropIfExists('unifi_device_states'); + } +}; diff --git a/database/migrations/2026_04_12_000003_create_unifi_snapshots_table.php b/database/migrations/2026_04_12_000003_create_unifi_snapshots_table.php new file mode 100644 index 0000000..2753171 --- /dev/null +++ b/database/migrations/2026_04_12_000003_create_unifi_snapshots_table.php @@ -0,0 +1,35 @@ +id(); + $table->string('ap_mac', 17)->index(); + $table->string('ap_name', 255)->nullable(); + $table->unsignedSmallInteger('num_sta')->default(0); + $table->unsignedBigInteger('rx_bytes')->default(0); + $table->unsignedBigInteger('tx_bytes')->default(0); + $table->unsignedInteger('tx_retries')->default(0); + $table->unsignedInteger('tx_dropped')->default(0); + $table->unsignedInteger('rx_dropped')->default(0); + $table->unsignedInteger('tx_errors')->default(0); + $table->unsignedInteger('rx_errors')->default(0); + $table->unsignedTinyInteger('satisfaction')->nullable(); + $table->unsignedTinyInteger('cu_2g')->nullable(); + $table->unsignedTinyInteger('cu_5g')->nullable(); + $table->unsignedTinyInteger('cu_6g')->nullable(); + $table->timestamp('captured_at')->index(); + }); + } + + public function down(): void + { + Schema::dropIfExists('unifi_ap_snapshots'); + } +}; diff --git a/database/migrations/2026_04_12_000004_create_unifi_client_snapshots_table.php b/database/migrations/2026_04_12_000004_create_unifi_client_snapshots_table.php new file mode 100644 index 0000000..4de9002 --- /dev/null +++ b/database/migrations/2026_04_12_000004_create_unifi_client_snapshots_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('mac', 17)->index(); + $table->string('name', 255)->nullable(); + $table->string('dev_cat', 64)->nullable()->index(); + $table->string('os_name', 64)->nullable()->index(); + $table->boolean('is_wired')->default(false); + $table->unsignedBigInteger('rx_bytes')->default(0); + $table->unsignedBigInteger('tx_bytes')->default(0); + $table->unsignedTinyInteger('satisfaction')->nullable(); + $table->timestamp('captured_at')->index(); + }); + } + + public function down(): void + { + Schema::dropIfExists('unifi_client_snapshots'); + } +}; diff --git a/database/migrations/2026_04_12_000005_add_signal_to_unifi_client_snapshots.php b/database/migrations/2026_04_12_000005_add_signal_to_unifi_client_snapshots.php new file mode 100644 index 0000000..d23282b --- /dev/null +++ b/database/migrations/2026_04_12_000005_add_signal_to_unifi_client_snapshots.php @@ -0,0 +1,24 @@ +smallInteger('signal')->nullable()->after('satisfaction'); + }); + } + + public function down(): void + { + Schema::table('unifi_client_snapshots', function (Blueprint $table) { + $table->dropColumn('signal'); + }); + } +}; diff --git a/src/Console/CaptureSnapshots.php b/src/Console/CaptureSnapshots.php new file mode 100644 index 0000000..6dd2abd --- /dev/null +++ b/src/Console/CaptureSnapshots.php @@ -0,0 +1,97 @@ +getAccessPoints(); + } catch (\Throwable $e) { + $this->error('Failed to fetch APs: ' . $e->getMessage()); + return self::FAILURE; + } + + // ── 1. Store snapshot ───────────────────────────────────────────── + $now = now(); + $rows = []; + + foreach ($aps as $ap) { + $rows[] = [ + 'ap_mac' => $ap['mac'], + 'ap_name' => $ap['name'] ?? $ap['model'] ?? null, + 'num_sta' => $ap['num_sta'] ?? 0, + 'rx_bytes' => $ap['rx_bytes'] ?? 0, + 'tx_bytes' => $ap['tx_bytes'] ?? 0, + 'tx_retries' => $ap['stat']['ap']['tx_retries'] ?? 0, + 'tx_dropped' => $ap['stat']['ap']['tx_dropped'] ?? 0, + 'rx_dropped' => $ap['stat']['ap']['rx_dropped'] ?? 0, + 'tx_errors' => $ap['stat']['ap']['tx_errors'] ?? 0, + 'rx_errors' => $ap['stat']['ap']['rx_errors'] ?? 0, + 'satisfaction' => ($ap['satisfaction'] ?? -1) >= 0 ? $ap['satisfaction'] : null, + 'cu_2g' => $this->getRadioStat($ap, 'ng'), + 'cu_5g' => $this->getRadioStat($ap, 'na'), + 'cu_6g' => $this->getRadioStat($ap, 'a6'), + 'captured_at' => $now, + ]; + } + + if (! empty($rows)) { + ApSnapshot::insert($rows); + } + + // ── 1b. Store per-client snapshot ───────────────────────────────── + try { + $clients = $unifi->getActiveClients(); + $clientRows = []; + foreach ($clients as $c) { + if (empty($c['mac'])) continue; + $clientRows[] = [ + 'mac' => strtolower($c['mac']), + 'name' => $c['hostname'] ?? $c['name'] ?? null, + 'dev_cat' => $c['dev_cat'] ?? null, + 'os_name' => $c['os_name'] ?? null, + 'is_wired' => (bool) ($c['is_wired'] ?? false), + 'rx_bytes' => $c['rx_bytes'] ?? 0, + 'tx_bytes' => $c['tx_bytes'] ?? 0, + 'satisfaction' => ($c['satisfaction'] ?? -1) >= 0 ? $c['satisfaction'] : null, + 'signal' => isset($c['signal']) ? (int) $c['signal'] : null, + 'captured_at' => $now, + ]; + } + if (! empty($clientRows)) { + ClientSnapshot::insert($clientRows); + } + } catch (\Throwable $e) { + $this->warn('Client snapshot failed: ' . $e->getMessage()); + } + + // ── 2. Check webhook alerts with the same data ──────────────────── + $fired = $webhooks->checkAll($unifi); + if ($fired > 0) { + $this->info("Fired {$fired} webhook(s)."); + } + + return self::SUCCESS; + } + + private function getRadioStat(array $ap, string $radio): ?int + { + foreach ($ap['radio_table_stats'] ?? [] as $stat) { + if (($stat['radio'] ?? '') === $radio && isset($stat['cu_total'])) { + return (int) $stat['cu_total']; + } + } + return null; + } +} diff --git a/src/Console/CheckWebhooks.php b/src/Console/CheckWebhooks.php new file mode 100644 index 0000000..d272a51 --- /dev/null +++ b/src/Console/CheckWebhooks.php @@ -0,0 +1,22 @@ +checkAll($unifi); + if ($fired > 0) { + $this->info("Fired {$fired} webhook(s)."); + } + return self::SUCCESS; + } +} diff --git a/src/Console/CleanupSnapshots.php b/src/Console/CleanupSnapshots.php new file mode 100644 index 0000000..b396a27 --- /dev/null +++ b/src/Console/CleanupSnapshots.php @@ -0,0 +1,30 @@ +subDays($days); + + $snapshots = ApSnapshot::where('captured_at', '<', $cutoff)->delete(); + $clientSnapshots = ClientSnapshot::where('captured_at', '<', $cutoff)->delete(); + $webhookLogs = WebhookLog::where('fired_at', '<', $cutoff)->delete(); + + $this->info("Cleaned up: {$snapshots} AP snapshots, {$clientSnapshots} client snapshots, {$webhookLogs} webhook logs older than {$days} days."); + + return self::SUCCESS; + } +} diff --git a/src/Http/Controllers/ClientController.php b/src/Http/Controllers/ClientController.php new file mode 100644 index 0000000..d33de3c --- /dev/null +++ b/src/Http/Controllers/ClientController.php @@ -0,0 +1,64 @@ +getActiveClients())->map(fn ($c) => [ + 'mac' => $c['mac'], + 'hostname' => $c['hostname'] ?? $c['name'] ?? '', + 'ip' => $c['ip'] ?? '', + 'oui' => $c['oui'] ?? '', + 'os' => $c['os_name'] ?? null, + 'dev_cat' => $c['dev_cat'] ?? null, + 'dev_family' => $c['dev_family'] ?? null, + 'dev_vendor' => $c['dev_vendor'] ?? null, + 'is_wired' => $c['is_wired'] ?? false, + 'is_guest' => $c['is_guest'] ?? false, + 'ssid' => $c['essid'] ?? null, + 'ap_mac' => $c['ap_mac'] ?? null, + 'rssi' => $c['rssi'] ?? null, + 'signal' => $c['signal'] ?? null, + 'channel' => $c['channel'] ?? null, + 'tx_bytes' => $c['tx_bytes'] ?? 0, + 'rx_bytes' => $c['rx_bytes'] ?? 0, + 'tx_rate' => $c['tx_rate'] ?? 0, + 'rx_rate' => $c['rx_rate'] ?? 0, + 'uptime' => $c['uptime'] ?? 0, + 'satisfaction' => $c['satisfaction'] ?? null, + 'is_known' => KnownMac::where('mac_address', strtolower($c['mac']))->exists(), + ])->values(); + + return Inertia::render('Unifi/Clients', ['clients' => $clients]); + } catch (\Throwable $e) { + return Inertia::render('Unifi/Clients', ['clients' => [], 'error' => $e->getMessage()]); + } + } + + public function kick(Request $request, UnifiApiClient $unifi) + { + $request->validate(['mac' => 'required|string']); + + try { + $unifi->kickClient($request->mac); + // Deactivate portal session if there is one + PortalSession::where('mac_address', strtolower($request->mac)) + ->where('is_active', true) + ->update(['is_active' => false]); + + return back()->with('success', 'Client disconnected.'); + } catch (\Throwable $e) { + return back()->withErrors(['error' => $e->getMessage()]); + } + } +} diff --git a/src/Http/Controllers/DeviceController.php b/src/Http/Controllers/DeviceController.php new file mode 100644 index 0000000..d0d0b80 --- /dev/null +++ b/src/Http/Controllers/DeviceController.php @@ -0,0 +1,56 @@ +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(); + + return Inertia::render('Unifi/Devices', ['devices' => $devices]); + } catch (\Throwable $e) { + return Inertia::render('Unifi/Devices', ['devices' => [], 'error' => $e->getMessage()]); + } + } + + public function reboot(Request $request, UnifiApiClient $unifi) + { + $request->validate(['mac' => 'required|string|regex:/^([0-9a-f]{2}:){5}[0-9a-f]{2}$/i']); + + try { + $unifi->rebootDevice($request->mac); + return back()->with('success', 'Reboot command sent.'); + } catch (\Throwable $e) { + return back()->withErrors(['error' => $e->getMessage()]); + } + } +} diff --git a/src/Http/Controllers/PortalController.php b/src/Http/Controllers/PortalController.php new file mode 100644 index 0000000..dddebeb --- /dev/null +++ b/src/Http/Controllers/PortalController.php @@ -0,0 +1,202 @@ + VlanMapping::orderBy('sort_order')->get(), + 'knownMacs' => KnownMac::orderBy('device_name')->paginate(50), + 'activeSessions' => PortalSession::where('is_active', true) + ->with('user:id,name,email') + ->latest('authorized_at') + ->get(), + ]); + } + + public function storeMapping(Request $request) + { + $data = $request->validate([ + 'group_name' => 'required|string|max:255', + 'match_type' => 'required|in:ou,group,email_domain,default', + 'match_value' => 'required|string|max:255', + 'vlan_id' => 'required|integer|min:1|max:4094', + 'max_devices' => 'required|integer|min:1|max:50', + 'session_minutes' => 'required|integer|min:1', + ]); + $data['sort_order'] = VlanMapping::max('sort_order') + 1; + VlanMapping::create($data); + return back()->with('success', 'VLAN mapping created.'); + } + + public function updateMapping(Request $request, VlanMapping $mapping) + { + $data = $request->validate([ + 'group_name' => 'required|string|max:255', + 'match_type' => 'required|in:ou,group,email_domain,default', + 'match_value' => 'required|string|max:255', + 'vlan_id' => 'required|integer|min:1|max:4094', + 'max_devices' => 'required|integer|min:1|max:50', + 'session_minutes' => 'required|integer|min:1', + ]); + $mapping->update($data); + return back()->with('success', 'Mapping updated.'); + } + + public function destroyMapping(VlanMapping $mapping) + { + $mapping->delete(); + return back()->with('success', 'Mapping deleted.'); + } + + // ── Known MACs ──────────────────────────────────────────────────────────── + + public function storeMac(Request $request) + { + $data = $request->validate([ + 'mac_address' => 'required|string|regex:/^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$/|unique:unifi_known_macs,mac_address', + 'device_name' => 'nullable|string|max:255', + 'device_type' => 'nullable|string|max:100', + 'owner' => 'nullable|string|max:255', + 'vlan_id' => 'required|integer|min:1|max:4094', + 'notes' => 'nullable|string|max:1000', + ]); + KnownMac::create($data); + return back()->with('success', 'MAC address added.'); + } + + public function destroyMac(KnownMac $mac) + { + $mac->delete(); + return back()->with('success', 'MAC address removed.'); + } + + // ── Captive portal callback (called by UniFi external portal redirect) ─── + + public function captiveCallback(Request $request, UnifiApiClient $unifi) + { + // UniFi redirects guest to this URL with ?id=&ap=&t=&url=&ssid= + // At this point the user has already authenticated via Google OAuth (handled by the shell's auth system) + $user = $request->user(); + if (! $user) { + return redirect()->route('login'); + } + + $clientMac = strtolower($request->input('mac', $request->input('id', ''))); + $ssid = $request->input('ssid', ''); + $apMac = $request->input('ap', ''); + + if (! $clientMac) { + return response('Missing MAC address', 400); + } + + // Find the VLAN mapping for this user's group/OU + $mapping = $this->resolveMapping($user); + if (! $mapping) { + return Inertia::render('Unifi/PortalDenied', ['reason' => 'No WiFi access configured for your account.']); + } + + // Check device limit + $activeSessions = PortalSession::where('user_id', $user->id) + ->where('is_active', true) + ->get(); + + foreach ($activeSessions as $session) { + if ($session->mac_address === $clientMac) { + // Same device reconnecting — refresh + $session->update(['is_active' => false]); + continue; + } + // Check if other device is still actually connected + if (! $unifi->isClientConnected($session->mac_address)) { + $session->update(['is_active' => false]); // stale session + } + } + + $activeCount = PortalSession::where('user_id', $user->id)->where('is_active', true)->count(); + if ($activeCount >= $mapping->max_devices) { + return Inertia::render('Unifi/PortalDenied', [ + 'reason' => "You already have {$activeCount} device(s) connected (limit: {$mapping->max_devices}).", + 'sessions' => $activeSessions->map(fn ($s) => [ + 'id' => $s->id, + 'mac' => $s->mac_address, + 'hostname' => $s->device_hostname, + 'os' => $s->device_os, + 'since' => $s->authorized_at->toISOString(), + ]), + 'can_disconnect' => true, + ]); + } + + // Authorize the guest + $minutes = $mapping->session_minutes; + $unifi->authorizeGuest($clientMac, $minutes); + + // Record the session + PortalSession::create([ + 'user_id' => $user->id, + 'mac_address' => $clientMac, + 'device_hostname' => $request->input('hostname'), + 'device_os' => $request->input('os'), + 'device_type' => $request->input('dev_cat'), + 'ssid' => $ssid, + 'vlan_id' => $mapping->vlan_id, + 'ap_mac' => $apMac, + 'is_active' => true, + 'authorized_at' => now(), + 'expires_at' => now()->addMinutes($minutes), + ]); + + // Redirect to the original URL the user was trying to reach + $redirectUrl = $request->input('url', '/'); + return redirect($redirectUrl); + } + + public function disconnectSession(Request $request, PortalSession $session, UnifiApiClient $unifi) + { + // Allow users to disconnect their own sessions + abort_if($session->user_id !== auth()->id() && ! auth()->user()->can('unifi.auth'), 403); + + try { + $unifi->kickClient($session->mac_address); + $unifi->unauthorizeGuest($session->mac_address); + } catch (\Throwable) {} + + $session->update(['is_active' => false]); + + return back()->with('success', 'Device disconnected.'); + } + + // ── Private helpers ─────────────────────────────────────────────────────── + + private function resolveMapping($user): ?VlanMapping + { + $mappings = VlanMapping::orderBy('sort_order')->get(); + + // Try to match by email domain, group, OU, etc. + foreach ($mappings as $mapping) { + $matched = match ($mapping->match_type) { + 'email_domain' => str_ends_with($user->email ?? '', '@' . $mapping->match_value), + 'group' => $user->groups?->contains('name', $mapping->match_value) ?? false, + 'ou' => str_contains($user->ou ?? '', $mapping->match_value), + 'default' => true, + default => false, + }; + if ($matched) return $mapping; + } + + return null; + } +} diff --git a/src/Http/Controllers/StatsController.php b/src/Http/Controllers/StatsController.php new file mode 100644 index 0000000..527ca19 --- /dev/null +++ b/src/Http/Controllers/StatsController.php @@ -0,0 +1,768 @@ + 5, + '10m' => 10, + '15m' => 15, + '30m' => 30, + '1h' => 60, + '2h' => 120, + '4h' => 240, + '8h' => 480, + '12h' => 720, + '24h' => 1440, + ]; + + public function wanStatus(UnifiApiClient $unifi) + { + try { + $health = $unifi->getSiteHealth(); + $wan = collect($health)->firstWhere('subsystem', 'wan'); + $gw = $unifi->getGateway(); + $wanIp = $gw['wan1']['ip'] ?? $gw['ip'] ?? null; + if ($wanIp && str_starts_with($wanIp, '127.')) $wanIp = $gw['connect_request_ip'] ?? null; + + return response()->json([ + 'status' => $wan['status'] ?? 'unknown', + 'tx_rate' => $wan['tx_bytes-r'] ?? 0, + 'rx_rate' => $wan['rx_bytes-r'] ?? 0, + 'isp' => $gw['geo_info']['ISP'] ?? null, + 'wan_ip' => $wanIp, + 'latency' => $wan['latency'] ?? $gw['wan1']['latency'] ?? null, + ]); + } catch (\Throwable $e) { + return response()->json(['status' => 'error'], 500); + } + } + + public function dashboard(Request $request, UnifiApiClient $unifi) + { + $range = $request->get('range', '4h'); + $fullscreen = $request->route()->defaults['fullscreen'] ?? false; + $apFilter = $request->get('aps') ? explode(',', $request->get('aps')) : []; + + // Determine time window + 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 { + $health = $unifi->getSiteHealth(); + $allAps = $unifi->getAccessPoints(); + $aps = empty($apFilter) ? $allAps : array_values(array_filter($allAps, fn ($a) => in_array($a['mac'], $apFilter))); + $clients = $unifi->getActiveClients(); + $ssidGroups = json_decode(Setting::get('unifi.ssid_groups', '{}'), true); + if (! is_array($ssidGroups) || array_is_list($ssidGroups)) $ssidGroups = []; + + try { $events = $unifi->getEvents(50); } catch (\Throwable) { $events = []; } + + // WAN + $wan = collect($health)->firstWhere('subsystem', 'wan'); + $gw = $unifi->getGateway(); + $wanIp = $gw['wan1']['ip'] ?? $gw['ip'] ?? null; + if ($wanIp && str_starts_with($wanIp, '127.')) $wanIp = $gw['connect_request_ip'] ?? null; + + $wanInfo = [ + 'status' => $wan['status'] ?? 'unknown', 'tx_rate' => $wan['tx_bytes-r'] ?? 0, + 'rx_rate' => $wan['rx_bytes-r'] ?? 0, 'isp' => $gw['geo_info']['ISP'] ?? null, + 'wan_ip' => $wanIp, 'latency' => $wan['latency'] ?? $gw['wan1']['latency'] ?? null, + ]; + + // SSIDs — filter to selected APs if filtered + $wifiClients = collect($clients)->where('is_wired', false); + if (! empty($apFilter)) { + $apMacSet = collect($apFilter)->map(fn ($m) => strtolower($m)); + $wifiClients = $wifiClients->filter(fn ($c) => $apMacSet->contains(strtolower($c['ap_mac'] ?? ''))); + } + $ssidStats = $wifiClients->groupBy('essid')->map(fn ($g, $ssid) => [ + 'ssid' => $ssid ?: '(hidden)', 'client_count' => $g->count(), + ])->values(); + if (! empty($ssidGroups)) $ssidStats = $this->applySSIDGroups($ssidStats, $ssidGroups, $unifi); + + // Per-AP live + $apCurrent = collect($aps)->map(fn ($ap) => [ + 'mac' => $ap['mac'], 'name' => $ap['name'] ?? $ap['model'] ?? 'Unknown', + 'num_clients' => $ap['num_sta'] ?? 0, 'satisfaction' => $ap['satisfaction'] ?? null, + 'cu_2g' => $this->getRadioStat($ap, 'ng', 'cu_total'), + 'cu_5g' => $this->getRadioStat($ap, 'na', 'cu_total'), + 'cu_6g' => $this->getRadioStat($ap, 'a6', 'cu_total'), + 'ch_2g' => $this->getRadioStat($ap, 'ng', 'channel'), + 'ch_5g' => $this->getRadioStat($ap, 'na', 'channel'), + 'ch_6g' => $this->getRadioStat($ap, 'a6', 'channel'), + ])->values(); + + // Build time series from LOCAL snapshots + $apTimeSeries = $this->buildFromSnapshots($startDt, $endDt, $aps, $apFilter); + + // Errors + // WiFi errors: disconnects, lost contact + $errorEvents = collect($events)->filter(fn ($e) => + str_contains($e['key'] ?? '', 'Lost_Contact') + || str_contains($e['key'] ?? '', 'Disconnected') + )->take(20)->values(); + + // Infrastructure events: auth failures, SFP loss, RADIUS, firmware, etc. + $infraEvents = collect($events)->filter(fn ($e) => + str_contains($e['key'] ?? '', 'AUTH_FAIL') + || str_contains($e['key'] ?? '', 'SFP') + || str_contains($e['key'] ?? '', 'RADIUS') + || str_contains($e['key'] ?? '', 'IPS') + || str_contains($e['key'] ?? '', 'FW_UPDATE') + || str_contains($e['key'] ?? '', 'SpeedTest') + || str_contains($e['key'] ?? '', 'WANTransition') + || str_contains($e['key'] ?? '', 'LAN') + || str_contains($e['key'] ?? '', 'ALARM') + )->take(30)->values(); + + return Inertia::render($fullscreen ? 'Unifi/DashboardFullscreen' : 'Unifi/Dashboard', [ + 'wanInfo' => $wanInfo, 'ssidStats' => $ssidStats, 'apCurrent' => $apCurrent, + 'apTimeSeries' => $apTimeSeries, 'errorEvents' => $errorEvents, 'infraEvents' => $infraEvents, + 'wifiClients' => $wifiClients->count(), + 'pollInterval' => (int) Setting::get('unifi.poll_interval', 30), + 'ssidGroups' => $ssidGroups, 'range' => $range, + 'ranges' => array_keys(self::RANGE_MAP), 'fullscreen' => $fullscreen, + 'apList' => collect($allAps)->map(fn ($a) => ['mac' => $a['mac'], 'name' => $a['name'] ?? $a['model'] ?? $a['mac']])->sortBy('name')->values(), + 'apFilter' => $apFilter, + 'retentionDays' => (int) Setting::get('unifi.retention_days', 30), + ]); + } catch (\Throwable $e) { + return Inertia::render($fullscreen ? 'Unifi/DashboardFullscreen' : 'Unifi/Dashboard', [ + 'error' => $e->getMessage(), 'range' => $range, 'ranges' => array_keys(self::RANGE_MAP), + ]); + } + } + + /** + * Client Dashboard — mirrors the AP dashboard but per-client. + * + * Returns: + * - devCatCounts: # devices per dev_cat (pie/bar) + * - osCounts: # devices per os_name + * - clientTraffic: per-client time-series for rx/tx Mbps (like Traffic per AP) + * - clientSatisfaction: per-client inverted WiFi experience (like WiFi Experience per AP) + * - totalDownloaded: total bytes downloaded (rx) across all clients over the interval + */ + public function clientDashboard(Request $request, UnifiApiClient $unifi) + { + $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 { + // Current snapshot for categorical counts (device type / OS) — use live API + $clients = collect($unifi->getActiveClients()); + $aps = collect($unifi->getAccessPoints()); + + $devCatCounts = $clients->map(fn ($c) => ['name' => $this->classifyDeviceType($c)]) + ->groupBy('name') + ->map(fn ($g, $k) => ['name' => $k, 'count' => $g->count()]) + ->sortByDesc('count') + ->values(); + + $osCounts = $clients->map(fn ($c) => ['name' => $this->classifyOsFamily($c)]) + ->groupBy('name') + ->map(fn ($g, $k) => ['name' => $k, 'count' => $g->count()]) + ->sortByDesc('count') + ->values(); + + // AP lookup: mac → friendly name (for the per-client-per-AP table) + $apNames = $aps->mapWithKeys(fn ($a) => [ + strtolower($a['mac'] ?? '') => $a['name'] ?? $a['model'] ?? ($a['mac'] ?? 'AP') + ])->all(); + + $apList = $aps->map(fn ($a) => [ + 'mac' => strtolower($a['mac'] ?? ''), + 'name' => $a['name'] ?? $a['model'] ?? ($a['mac'] ?? 'AP'), + ])->sortBy('name')->values(); + + // Per-client current traffic (for the per-client-per-AP table) + $clientList = $clients->map(function ($c) use ($apNames) { + $apMac = strtolower($c['ap_mac'] ?? ''); + return [ + 'mac' => strtolower($c['mac'] ?? ''), + 'name' => $c['hostname'] ?? $c['name'] ?? substr($c['mac'] ?? '', -8), + 'ap_mac' => $apMac, + 'ap_name' => $apNames[$apMac] ?? (($c['is_wired'] ?? false) ? 'Wired' : 'Unknown'), + 'is_wired' => (bool) ($c['is_wired'] ?? false), + 'tx_rate' => (int) ($c['tx_rate'] ?? 0), + 'rx_rate' => (int) ($c['rx_rate'] ?? 0), + 'tx_bytes' => (int) ($c['tx_bytes'] ?? 0), + 'rx_bytes' => (int) ($c['rx_bytes'] ?? 0), + ]; + })->sortByDesc(fn ($c) => $c['rx_rate'] + $c['tx_rate'])->values(); + + $series = $this->buildClientSeries($startDt, $endDt); + + return Inertia::render('Unifi/ClientDashboard', [ + 'devCatCounts' => $devCatCounts, + 'osCounts' => $osCounts, + 'labels' => $series['labels'], + 'clientTrafficRx' => $series['traffic_rx'], + 'clientTrafficTx' => $series['traffic_tx'], + 'clientSatisfaction' => $series['satisfaction'], + 'clientSignal' => $series['signal'], + '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, + 'range' => $range, + 'ranges' => array_keys(self::RANGE_MAP), + 'pollInterval' => (int) Setting::get('unifi.poll_interval', 30), + ]); + } catch (\Throwable $e) { + return Inertia::render('Unifi/ClientDashboard', [ + 'error' => $e->getMessage(), + 'range' => $range, + 'ranges' => array_keys(self::RANGE_MAP), + ]); + } + } + + /** + * Device TYPE = manufacturer + model (when detectable). + * + * Unifi's dev_cat / dev_family / os_name are numeric fingerprint IDs that + * only their controller can resolve (private fingerprint DB). The fields + * Unifi exposes as reliable strings are `oui` (vendor) and `hostname`. + * We combine them: cleaned manufacturer name + a model hint sniffed from + * the hostname when possible. + */ + private function classifyDeviceType(array $c): string + { + $mfr = $this->cleanVendor((string) ($c['oui'] ?? '')); + $host = strtolower((string) ($c['hostname'] ?? $c['name'] ?? '')); + $wired = (bool) ($c['is_wired'] ?? false); + + // Products with a canonical brand — always render as "Brand Model" regardless of OUI. + // Stops duplicate buckets like "Apple iPad" vs "iPad". + $branded = [ + 'Apple iPhone' => ['iphone'], + 'Apple iPad' => ['ipad'], + 'Apple MacBook' => ['macbook'], + 'Apple iMac' => ['imac'], + 'Apple TV' => ['appletv', 'apple-tv'], + 'Apple Watch' => ['applewatch', 'apple-watch'], + 'Apple HomePod' => ['homepod'], + 'Google Pixel' => ['pixel'], + 'Samsung Galaxy' => ['galaxy'], + 'Google Chromecast' => ['chromecast'], + 'Google Chromebook' => ['chromebook'], + 'Google Nest' => ['nest-', 'nest_'], + 'Microsoft Xbox' => ['xbox'], + 'Sony PlayStation' => ['playstation', 'ps5', 'ps4'], + 'Nintendo Switch' => ['nintendo', 'switch-'], + 'Amazon Fire TV' => ['firetv', 'fire-tv'], + 'Amazon Echo' => ['echo-', 'echo_'], + 'Roku' => ['roku'], + 'Sonos' => ['sonos'], + 'Ring' => ['ring-'], + 'Raspberry Pi' => ['raspberry', 'raspberrypi'], + 'Printer' => ['printer', 'envy', 'officejet', 'laserjet'], + 'UniFi Camera' => ['g2-', 'g3-', 'g4-', 'g5-', 'uvc-'], + ]; + foreach ($branded as $label => $needles) { + foreach ($needles as $n) { + if (str_contains($host, $n)) return $label; + } + } + + // Hostname-prefix heuristics — facility naming patterns + // `int...` and `st` are common intercom / paging endpoints + // (observed on-site: empty OUI, hostnames like int1kitchen, int2staffw, st22). + if ($host !== '' && preg_match('/^(int\d|st\d{2,})/i', $host)) return 'Intercom / Paging'; + + if ($mfr) return $mfr; + return $wired ? 'Wired Device' : 'Unknown'; + } + + private function cleanVendor(string $oui): string + { + $v = trim($oui); + if ($v === '') return ''; + + // Map common vendor OUIs to friendlier role-based labels. + // These cover devices Unifi can't OS-classify (wired appliances). + $role = $this->vendorRole($v); + if ($role !== null) return $role; + + // Strip trailing corporate suffixes + $v = preg_replace('/(,?\s*(inc|ltd|llc|gmbh|co|corp|corporation|technology|technologies|electronics|digital|limited|international|industries|connect|communications|solutions|server)\.?)+$/i', '', $v); + $v = rtrim(trim($v), '.,'); + // Collapse "Apple, Inc." → "Apple" + return $v; + } + + /** + * Map well-known vendor OUI substrings to a role-based label + * (used when Unifi's fingerprint DB can't help — typical for wired gear). + */ + private function vendorRole(string $oui): ?string + { + $o = strtolower($oui); + if (str_contains($o, 'seiko epson')) return 'Epson Projector'; + if (str_contains($o, 'brother industries') || str_contains($o, 'canon inc')) return 'Printer'; + if (str_contains($o, 'hewlett packard') && (str_contains($o, 'printer') || str_contains($o, 'imaging'))) return 'HP Printer'; + if (str_contains($o, 'hikvision') || str_contains($o, 'amcrest')) return 'HikVision Camera'; + if (str_contains($o, 'dahua') || str_contains($o, 'axis communications')) return 'IP Camera'; + if (str_contains($o, 'panasonic connect') || str_contains($o, 'panasonic communications')) return 'Panasonic Phone'; + if (str_contains($o, 'panasonic')) return 'Panasonic Device'; + if (str_contains($o, 'proxmox')) return 'Proxmox VM'; + if (str_contains($o, 'lanner electronics')) return 'Network Appliance'; + if (str_contains($o, 'juniper networks')) return 'Juniper Network Device'; + if (str_contains($o, 'ubiquiti')) return 'Ubiquiti Device'; + if (str_contains($o, 'cisco systems') || str_contains($o, 'cisco-linksys')) return 'Cisco Device'; + if (str_contains($o, 'polycom')) return 'Polycom Phone'; + if (str_contains($o, 'yealink') || str_contains($o, 'grandstream')) return 'VoIP Phone'; + if (str_contains($o, 'raspberry pi')) return 'Raspberry Pi'; + if (str_contains($o, 'espressif') || str_contains($o, 'shelly') || str_contains($o, 'tuya') || str_contains($o, 'sonoff')) return 'IoT Device'; + if (str_contains($o, 'sonos')) return 'Sonos Speaker'; + if (str_contains($o, 'google ')) return 'Google Device'; + if (str_contains($o, 'nest labs')) return 'Nest Device'; + if (str_contains($o, 'ring ')) return 'Ring Device'; + return null; + } + + /** + * OS family — derived from hostname + oui since Unifi's os_name is numeric. + * Produces "iOS", "macOS", "Android", "Windows", "Linux", "ChromeOS", etc. + */ + private function classifyOsFamily(array $c): string + { + $oui = strtolower((string) ($c['oui'] ?? '')); + $host = strtolower((string) ($c['hostname'] ?? $c['name'] ?? '')); + + if (str_contains($host, 'iphone') || str_contains($host, 'ipad')) return 'iOS'; + if (str_contains($host, 'macbook') || str_contains($host, 'imac') || str_contains($host, 'mac-')) return 'macOS'; + if (str_contains($host, 'appletv') || str_contains($host, 'apple-tv')) return 'tvOS'; + if (str_contains($host, 'android') || str_contains($host, 'galaxy') || str_contains($host, 'pixel')) return 'Android'; + if (str_contains($host, 'chromebook') || str_contains($host, 'chromecast')) return 'ChromeOS'; + if (str_contains($host, 'windows') || str_contains($host, 'desktop-') || str_contains($host, 'laptop-')) return 'Windows'; + if (str_contains($host, 'ubuntu') || str_contains($host, 'debian') || str_contains($host, 'linux') || str_contains($host, 'raspberry')) return 'Linux'; + if (str_contains($host, 'xbox')) return 'Xbox OS'; + if (str_contains($host, 'playstation') || str_contains($host, 'ps5')) return 'PlayStation OS'; + + if ($oui) { + if (str_contains($oui, 'apple')) return 'Apple (OS unknown)'; + if (str_contains($oui, 'google')) return 'Android'; + if (str_contains($oui, 'samsung') || str_contains($oui, 'motorola') || str_contains($oui, 'oneplus')) return 'Android'; + if (str_contains($oui, 'microsoft')) return 'Windows'; + if (str_contains($oui, 'intel') || str_contains($oui, 'dell') || str_contains($oui, 'lenovo') || str_contains($oui, 'asus') || str_contains($oui, 'hp ') || str_contains($oui, 'hewlett')) return 'Windows (likely)'; + if (str_contains($oui, 'raspberry')) return 'Linux'; + if (str_contains($oui, 'espressif') || str_contains($oui, 'shelly') || str_contains($oui, 'tuya')) return 'Embedded/IoT'; + if (str_contains($oui, 'ubiquiti')) return 'Ubiquiti Device'; + if (str_contains($oui, 'hikvision') || str_contains($oui, 'amcrest')) return 'HikVision Camera'; + if (str_contains($oui, 'dahua') || str_contains($oui, 'axis communications')) return 'IP Camera'; + if (str_contains($oui, 'panasonic')) return 'Panasonic Phone'; + if (str_contains($oui, 'seiko epson')) return 'Epson Projector'; + if (str_contains($oui, 'proxmox')) return 'Virtual Machine'; + } + + // Last resort: fall back to the device type label when it's more specific than "Unknown". + // This catches Cisco, Juniper, VoIP phones, IoT devices, printers, cameras, etc. + // whose OS isn't a conventional named OS but whose hardware type is known. + $deviceType = $this->classifyDeviceType($c); + if (! in_array($deviceType, ['Unknown', 'Wired Device'])) { + return $deviceType; + } + + return 'Unknown'; + } + + /** + * Build per-client time-series from ClientSnapshot. + * Traffic uses DELTAS (cumulative counter handling), satisfaction is point-in-time. + */ + private function buildClientSeries(\Carbon\Carbon $start, \Carbon\Carbon $end): array + { + $empty = [ + 'labels' => [], 'traffic_rx' => [], 'traffic_tx' => [], 'satisfaction' => [], + 'signal' => [], 'download_mb' => [], + 'total_download_bytes' => 0, 'total_upload_bytes' => 0, + 'download_series' => [], 'upload_series' => [], + ]; + + $startTs = $start->timestamp; + $endTs = $end->timestamp; + $rangeMinutes = max(1, $start->diffInMinutes($end)); + + $allTimestamps = ClientSnapshot::whereBetween('captured_at', [$start, $end]) + ->selectRaw('DISTINCT UNIX_TIMESTAMP(captured_at) as ts') + ->orderBy('captured_at') + ->pluck('ts'); + + if ($allTimestamps->isEmpty()) { + $emptyLabels = collect(); + $step = max(1, (int) (($endTs - $startTs) / 9)); + for ($t = $startTs; $t <= $endTs; $t += $step) $emptyLabels->push($t * 1000); + return array_merge($empty, ['labels' => $emptyLabels->values()]); + } + + // Downsample to ~10 points for charts + if ($allTimestamps->count() <= 12 || $rangeMinutes <= 15) { + $selectedTs = $allTimestamps; + } else { + $step = max(1, (int) floor($allTimestamps->count() / 10)); + $selectedTs = $allTimestamps->filter(fn ($v, $i) => $i % $step === 0 || $i === $allTimestamps->count() - 1)->values(); + } + if ($selectedTs->first() - $startTs > 60) $selectedTs->prepend($startTs); + if ($endTs - $selectedTs->last() > 60) $selectedTs->push($endTs); + $times = $selectedTs->unique()->sort()->values(); + + $labels = $times->map(fn ($t) => $t * 1000); + + $snapshots = ClientSnapshot::whereIn(\Illuminate\Support\Facades\DB::raw('UNIX_TIMESTAMP(captured_at)'), $times->all()) + ->orderBy('captured_at') + ->get(); + + if ($snapshots->isEmpty()) return array_merge($empty, ['labels' => $labels]); + + $byClient = $snapshots->groupBy('mac'); + + // ── Compute per-client name, and delta series ──────────────────── + $clientDeltas = []; + $clientNames = []; + $totalDownload = 0; + $totalUpload = 0; + foreach ($byClient as $mac => $rows) { + $sorted = $rows->sortBy(fn ($r) => $r->captured_at->timestamp)->values(); + $clientNames[$mac] = $sorted->last()->name ?: substr($mac, -8); + + $deltas = []; + for ($i = 1; $i < $sorted->count(); $i++) { + $prev = $sorted[$i - 1]; + $curr = $sorted[$i]; + $dt = max(1, $curr->captured_at->timestamp - $prev->captured_at->timestamp); + + $dRx = max(0, $curr->rx_bytes - $prev->rx_bytes); + $dTx = max(0, $curr->tx_bytes - $prev->tx_bytes); + + $totalDownload += $dRx; + $totalUpload += $dTx; + + $deltas[$curr->captured_at->timestamp] = [ + 'rx_mbps' => round(($dRx / $dt) * 8 / 1_000_000, 3), + 'tx_mbps' => round(($dTx / $dt) * 8 / 1_000_000, 3), + 'rx_bytes' => $dRx, + 'total_bytes' => $dRx + $dTx, + ]; + } + $clientDeltas[$mac] = $deltas; + } + + // ── Helpers ────────────────────────────────────────────────────── + $deltaSeries = function ($mac, $times, string $key) use ($clientDeltas) { + $d = $clientDeltas[$mac] ?? []; + return $times->map(fn ($t) => $d[$t][$key] ?? 0)->values()->all(); + }; + $pointSeries = function ($mac, $times, $field) use ($byClient) { + $byTime = []; + foreach ($byClient[$mac] ?? [] as $r) { $byTime[$r->captured_at->timestamp] = $r; } + return $times->map(function ($t) use ($byTime, $field) { + $row = $byTime[$t] ?? null; + return $row ? (is_callable($field) ? $field($row) : ($row->$field ?? 0)) : 0; + })->values()->all(); + }; + + // ── Rank clients by total delta traffic ────────────────────────── + $ranked = collect($clientDeltas)->map(fn ($d, $mac) => [ + 'mac' => $mac, + 'name' => $clientNames[$mac], + 'total_traffic' => collect($d)->sum('total_bytes'), + 'satisfaction_inv' => optional($byClient[$mac] ?? null) + ->whereNotNull('satisfaction') + ->avg(fn ($r) => $r->satisfaction * -1 + 100) ?? 0, + ]); + + $trafficRx = $ranked->sortByDesc('total_traffic')->map(fn ($info) => [ + 'mac' => $info['mac'], + 'name' => $info['name'], + 'data' => $deltaSeries($info['mac'], $times, 'rx_mbps'), + ])->values(); + $trafficTx = $ranked->sortByDesc('total_traffic')->map(fn ($info) => [ + 'mac' => $info['mac'], + 'name' => $info['name'], + 'data' => collect($deltaSeries($info['mac'], $times, 'tx_mbps'))->map(fn ($v) => -$v)->all(), + ])->values(); + + $satisfaction = $ranked->sortByDesc('satisfaction_inv')->map(fn ($info) => [ + 'mac' => $info['mac'], + 'name' => $info['name'], + 'data' => $pointSeries($info['mac'], $times, fn ($r) => + $r->satisfaction !== null ? round($r->satisfaction * -1 + 100, 1) : 0), + ])->values(); + + // Inverted signal strength: Unifi reports signal as negative dBm + // (closer to 0 = stronger). We plot |signal| so worse signal is higher + // on the Y axis (top) and better signal is lower (bottom). Missing + // values render as 0 (skipped by the tooltip filter). + $signal = $ranked->map(fn ($info) => [ + 'mac' => $info['mac'], + 'name' => $info['name'], + 'data' => $pointSeries($info['mac'], $times, fn ($r) => + $r->signal !== null ? abs((int) $r->signal) : 0), + ])->values(); + + // ── Per-client cumulative download in MB (running byte total from interval start) ── + $clientCumMb = []; + foreach ($clientDeltas as $mac => $deltasByTs) { + $cumBytes = 0; + foreach ($times as $t) { + $cumBytes += $deltasByTs[$t]['rx_bytes'] ?? 0; + $clientCumMb[$mac][$t] = round($cumBytes / 1_048_576, 3); + } + } + $downloadMb = $ranked->sortByDesc(fn ($info) => $clientCumMb[$info['mac']][$times->last()] ?? 0) + ->map(fn ($info) => [ + 'mac' => $info['mac'], + 'name' => $info['name'], + 'data' => $times->map(fn ($t) => $clientCumMb[$info['mac']][$t] ?? 0)->values()->all(), + ])->values(); + + // ── Aggregate download/upload series (sum of raw delta bytes per bucket) ── + $rxByTs = []; $txByTs = []; + foreach ($byClient as $mac => $rows) { + $sorted = $rows->sortBy(fn ($r) => $r->captured_at->timestamp)->values(); + for ($i = 1; $i < $sorted->count(); $i++) { + $prev = $sorted[$i - 1]; + $curr = $sorted[$i]; + $ts = $curr->captured_at->timestamp; + $rxByTs[$ts] = ($rxByTs[$ts] ?? 0) + max(0, $curr->rx_bytes - $prev->rx_bytes); + $txByTs[$ts] = ($txByTs[$ts] ?? 0) + max(0, $curr->tx_bytes - $prev->tx_bytes); + } + } + $downloadSeries = $times->map(fn ($t) => (int) ($rxByTs[$t] ?? 0))->values()->all(); + $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, + 'total_download_bytes' => $totalDownload, + 'total_upload_bytes' => $totalUpload, + 'download_series' => $downloadSeries, + 'upload_series' => $uploadSeries, + ]; + } + + /** + * Build time-series from local snapshots. + * Traffic and errors use DELTAS between consecutive snapshots (cumulative counters). + * Clients, satisfaction, CU use point-in-time values. + * X-axis always spans the full requested range, with nulls where no data exists. + */ + private function buildFromSnapshots(\Carbon\Carbon $start, \Carbon\Carbon $end, array $aps, array $apFilter): array + { + $empty = ['labels'=>[],'clients'=>[],'traffic_rx'=>[],'traffic_tx'=>[],'retries'=>[],'satisfaction'=>[],'cu_2g'=>[],'cu_5g'=>[],'cu_6g'=>[]]; + + $rangeMinutes = max(1, $start->diffInMinutes($end)); + $startTs = $start->timestamp; + $endTs = $end->timestamp; + + // Step 1: Get the distinct timestamps available in range (lightweight query) + $allTimestamps = ApSnapshot::whereBetween('captured_at', [$start, $end]) + ->selectRaw('DISTINCT UNIX_TIMESTAMP(captured_at) as ts') + ->orderBy('captured_at') + ->pluck('ts'); + + if ($allTimestamps->isEmpty()) { + // Generate empty labels spanning the range + $emptyLabels = collect(); + $step = max(1, (int) (($endTs - $startTs) / 9)); + for ($t = $startTs; $t <= $endTs; $t += $step) $emptyLabels->push($t * 1000); + return array_merge($empty, ['labels' => $emptyLabels->values()]); + } + + // Step 2: Pick ~10 timestamps (all for short ranges) + if ($allTimestamps->count() <= 12 || $rangeMinutes <= 15) { + $selectedTs = $allTimestamps; + } else { + $step = max(1, (int) floor($allTimestamps->count() / 10)); + $selectedTs = $allTimestamps->filter(fn ($v, $i) => $i % $step === 0 || $i === $allTimestamps->count() - 1)->values(); + } + + // Add boundaries if needed + if ($selectedTs->first() - $startTs > 60) $selectedTs->prepend($startTs); + if ($endTs - $selectedTs->last() > 60) $selectedTs->push($endTs); + $times = $selectedTs->unique()->sort()->values(); + + $labels = $times->map(fn ($t) => $t * 1000); + + // Step 3: Load ONLY the snapshots at the selected timestamps (much smaller query) + $query = ApSnapshot::whereIn(\Illuminate\Support\Facades\DB::raw('UNIX_TIMESTAMP(captured_at)'), $times->all()) + ->orderBy('captured_at'); + if (! empty($apFilter)) $query->whereIn('ap_mac', $apFilter); + + $snapshots = $query->get(); + if ($snapshots->isEmpty()) return array_merge($empty, ['labels' => $labels]); + + $apNames = collect($aps)->pluck('name', 'mac')->map(fn ($n, $m) => $n ?? substr($m, -8)); + $byAp = $snapshots->groupBy('ap_mac'); + + // ── Pre-compute per-AP delta series for cumulative counters ─────── + // For each AP, build arrays indexed by timestamp with delta values + $apDeltas = []; + foreach ($byAp as $mac => $rows) { + $sorted = $rows->sortBy(fn ($r) => $r->captured_at->timestamp)->values(); + $deltas = []; + for ($i = 1; $i < $sorted->count(); $i++) { + $prev = $sorted[$i - 1]; + $curr = $sorted[$i]; + $dt = max(1, $curr->captured_at->timestamp - $prev->captured_at->timestamp); + + // Delta bytes (handle counter reset — if delta is negative, AP rebooted) + $dRx = max(0, $curr->rx_bytes - $prev->rx_bytes); + $dTx = max(0, $curr->tx_bytes - $prev->tx_bytes); + $dErr = max(0, ($curr->tx_retries - $prev->tx_retries)) + + max(0, ($curr->tx_dropped - $prev->tx_dropped)) + + max(0, ($curr->rx_dropped - $prev->rx_dropped)) + + max(0, ($curr->tx_errors - $prev->tx_errors)) + + max(0, ($curr->rx_errors - $prev->rx_errors)); + + $deltas[$curr->captured_at->timestamp] = [ + 'rx_mbps' => round(($dRx / $dt) * 8 / 1_000_000, 3), + 'tx_mbps' => round(($dTx / $dt) * 8 / 1_000_000, 3), + 'err_rate' => round($dErr / $dt, 2), + 'total_bytes' => $dRx + $dTx, + ]; + } + $apDeltas[$mac] = $deltas; + } + + // ── Rank APs by total delta traffic ────────────────────────────── + $apTotals = collect($apDeltas)->map(function ($deltas, $mac) use ($apNames, $byAp) { + $rows = $byAp[$mac]; + return [ + 'mac' => $mac, + 'name' => $apNames[$mac] ?? substr($mac, -8), + 'total_traffic' => collect($deltas)->sum('total_bytes'), + 'total_errors' => collect($deltas)->sum('err_rate'), + 'clients' => $rows->avg('num_sta'), + 'satisfaction_inv' => $rows->avg(fn ($r) => $r->satisfaction !== null ? ($r->satisfaction * -1 + 100) : null), + ]; + }); + + // ── Helper to extract point-in-time series ─────────────────────── + $pointSeries = function ($mac, $times, $field, $transform = null) use ($byAp) { + $byTime = []; + foreach ($byAp[$mac] ?? [] as $r) { $byTime[$r->captured_at->timestamp] = $r; } + return $times->map(function ($t) use ($byTime, $field, $transform) { + $row = $byTime[$t] ?? null; + $val = $row ? (is_callable($field) ? $field($row) : ($row->$field ?? 0)) : 0; + return $transform ? $transform($val) : $val; + })->values()->all(); + }; + + // ── Helper to extract delta series ─────────────────────────────── + $deltaSeries = function ($mac, $times, string $key) use ($apDeltas) { + $deltas = $apDeltas[$mac] ?? []; + return $times->map(fn ($t) => $deltas[$t][$key] ?? 0)->values()->all(); + }; + + // ── Clients (point-in-time) ────────────────────────────────────── + $clients = $apTotals->sortByDesc('clients')->map(fn ($info) => [ + 'name' => $info['name'], + 'data' => $pointSeries($info['mac'], $times, 'num_sta'), + ])->values(); + + // ── Traffic (delta-based Mbps) — return ALL APs, frontend filters by threshold + $trafficAll = $apTotals->sortByDesc('total_traffic'); + $trafficRx = $trafficAll->map(fn ($info) => [ + 'name' => $info['name'], + 'data' => $deltaSeries($info['mac'], $times, 'rx_mbps'), + ])->values(); + $trafficTx = $trafficAll->map(fn ($info) => [ + 'name' => $info['name'], + 'data' => collect($deltaSeries($info['mac'], $times, 'tx_mbps'))->map(fn ($v) => -$v)->all(), + ])->values(); + + // ── Errors (delta-based rate/sec) ──────────────────────────────── + $retries = $apTotals->sortByDesc('total_errors')->map(fn ($info) => [ + 'name' => $info['name'], + 'data' => $deltaSeries($info['mac'], $times, 'err_rate'), + ])->values(); + + // ── Satisfaction (point-in-time, inverted) ─────────────────────── + $satisfaction = $apTotals->sortByDesc('satisfaction_inv')->map(fn ($info) => [ + 'name' => $info['name'], + 'data' => $pointSeries($info['mac'], $times, fn ($r) => + $r->satisfaction !== null ? round($r->satisfaction * -1 + 100, 1) : 0), + ])->values(); + + // ── Channel utilization (point-in-time) ────────────────────────── + $buildCu = fn ($field) => $apTotals->sortByDesc('clients')->map(fn ($info) => [ + 'name' => $info['name'], + 'data' => $pointSeries($info['mac'], $times, $field), + ])->values(); + + return [ + 'labels' => $labels, 'clients' => $clients, + 'traffic_rx' => $trafficRx, 'traffic_tx' => $trafficTx, + 'retries' => $retries, 'satisfaction' => $satisfaction, + 'cu_2g' => $buildCu('cu_2g'), 'cu_5g' => $buildCu('cu_5g'), 'cu_6g' => $buildCu('cu_6g'), + ]; + } + + private function applySSIDGroups($ssidStats, array $groups, UnifiApiClient $unifi): \Illuminate\Support\Collection + { + try { $wlans = collect($unifi->getWlans())->keyBy('_id'); } catch (\Throwable) { return $ssidStats; } + $ssidToGroup = []; + foreach ($groups as $groupName => $wlanIds) { + foreach ($wlanIds as $wlanId) { + $ssidName = $wlans[$wlanId]['name'] ?? null; + if ($ssidName) $ssidToGroup[$ssidName] = $groupName; + } + } + $merged = []; + foreach ($ssidStats as $stat) { + $displayName = $ssidToGroup[$stat['ssid']] ?? $stat['ssid']; + if (! isset($merged[$displayName])) $merged[$displayName] = ['ssid' => $displayName, 'client_count' => 0, 'sub_ssids' => []]; + $merged[$displayName]['client_count'] += $stat['client_count']; + if ($stat['ssid'] !== $displayName) $merged[$displayName]['sub_ssids'][] = $stat['ssid']; + } + return collect(array_values($merged)); + } + + private function getRadioStat(array $ap, string $radio, string $key): ?int + { + // Check radio_table_stats first (for cu_total, etc.) + foreach ($ap['radio_table_stats'] ?? [] as $stat) { + if (($stat['radio'] ?? '') === $radio && isset($stat[$key])) return (int) $stat[$key]; + } + // Fallback to radio_table (for channel, ht, etc.) + foreach ($ap['radio_table'] ?? [] as $entry) { + if (($entry['radio'] ?? '') === $radio && isset($entry[$key])) return (int) $entry[$key]; + } + return null; + } +} diff --git a/src/Http/Controllers/UnifiSettingsController.php b/src/Http/Controllers/UnifiSettingsController.php new file mode 100644 index 0000000..8729bfc --- /dev/null +++ b/src/Http/Controllers/UnifiSettingsController.php @@ -0,0 +1,117 @@ + 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), + ]); + } + + 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', + ]); + + 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('retention_days')) Setting::set('unifi.retention_days', $request->retention_days ?? 30); + + // 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.'); + } + + public function testConnection(UnifiApiClient $unifi) + { + try { + $info = $unifi->testConnection(); + $version = $info[0]['version'] ?? 'unknown'; + return response()->json(['ok' => true, 'version' => $version]); + } catch (\Throwable $e) { + return response()->json(['ok' => false, 'error' => $e->getMessage()], 422); + } + } + + public function fetchSites(Request $request, UnifiApiClient $unifi) + { + $request->validate([ + 'controller_url' => 'required|url', + ]); + + $url = rtrim($request->controller_url, '/'); + $user = $request->input('username', ''); + $pass = $request->input('password', ''); + $key = $request->input('api_key', ''); + + // Use saved credentials if placeholders sent + if ($pass === '••••••••') $pass = Setting::get('unifi.password', ''); + if ($key === '••••••••') $key = Setting::get('unifi.api_key', ''); + + try { + $sites = $unifi->getSites($url, $user ?: null, $pass ?: null, $key ?: null); + return response()->json([ + 'ok' => true, + 'sites' => collect($sites)->map(fn ($s) => [ + 'name' => $s['name'] ?? 'default', + 'desc' => $s['desc'] ?? $s['name'] ?? 'Default', + ])->values(), + ]); + } catch (\Throwable $e) { + $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."; + } 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."; + } + + return response()->json(['ok' => false, 'error' => $e->getMessage(), 'hint' => $hint], 422); + } + } +} diff --git a/src/Http/Controllers/WebhookController.php b/src/Http/Controllers/WebhookController.php new file mode 100644 index 0000000..22dba74 --- /dev/null +++ b/src/Http/Controllers/WebhookController.php @@ -0,0 +1,89 @@ + WebhookConfig::latest()->get(), + 'recentLogs' => \Dashboard\Unifi\Models\WebhookLog::with('config:id,name') + ->latest('fired_at')->take(50)->get(), + 'eventTypes' => WebhookCheckService::EVENTS, + 'templateVars' => WebhookCheckService::TEMPLATE_VARS, + 'defaultTemplates' => WebhookCheckService::DEFAULT_TEMPLATES, + ]); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'name' => 'required|string|max:255', + 'url' => 'required|url|max:500', + 'secret' => 'nullable|string|max:255', + 'is_active' => 'boolean', + 'events' => 'required|array|min:1', + 'events.*' => 'string|in:' . implode(',', array_keys(WebhookCheckService::EVENTS)), + 'thresholds' => 'nullable|array', + 'device_filter' => 'nullable|array', + 'tracked_clients' => 'nullable|array', + 'templates' => 'nullable|array', + 'cooldown_minutes' => 'integer|min:1|max:1440', + ]); + WebhookConfig::create($data); + return back()->with('success', 'Webhook created.'); + } + + public function update(Request $request, WebhookConfig $webhook) + { + $data = $request->validate([ + 'name' => 'required|string|max:255', + 'url' => 'required|url|max:500', + 'secret' => 'nullable|string|max:255', + 'is_active' => 'boolean', + 'events' => 'required|array|min:1', + 'events.*' => 'string|in:' . implode(',', array_keys(WebhookCheckService::EVENTS)), + 'thresholds' => 'nullable|array', + 'device_filter' => 'nullable|array', + 'tracked_clients' => 'nullable|array', + 'templates' => 'nullable|array', + 'cooldown_minutes' => 'integer|min:1|max:1440', + ]); + $webhook->update($data); + return back()->with('success', 'Webhook updated.'); + } + + public function destroy(WebhookConfig $webhook) + { + $webhook->delete(); + return back()->with('success', 'Webhook deleted.'); + } + + public function test(WebhookConfig $webhook) + { + $payload = [ + 'event' => 'test', + 'timestamp' => now()->toIso8601String(), + 'data' => ['message' => 'This is a test webhook from ' . config('app.name')], + ]; + + $headers = ['Content-Type' => 'application/json']; + if ($webhook->secret) { + $headers['X-Webhook-Signature'] = hash_hmac('sha256', json_encode($payload), $webhook->secret); + } + + try { + $response = \Illuminate\Support\Facades\Http::withHeaders($headers)->timeout(10)->post($webhook->url, $payload); + return response()->json(['ok' => true, 'status' => $response->status()]); + } catch (\Throwable $e) { + return response()->json(['ok' => false, 'error' => $e->getMessage()], 422); + } + } +} diff --git a/src/Http/Controllers/WifiController.php b/src/Http/Controllers/WifiController.php new file mode 100644 index 0000000..de3577b --- /dev/null +++ b/src/Http/Controllers/WifiController.php @@ -0,0 +1,126 @@ +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(); + + // Load saved groups: { "Staff": ["id1", "id2"], ... } + $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; + + return Inertia::render('Unifi/Wifi', [ + 'wlans' => $wlans, + 'groups' => $groups, + ]); + } catch (\Throwable $e) { + return Inertia::render('Unifi/Wifi', ['wlans' => [], 'groups' => [], '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', + ]); + + try { + // If this WLAN is in a group, apply the same change to all grouped WLANs + $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); + } + } else { + $unifi->updateWlan($wlanId, $data); + } + + return back()->with('success', 'WiFi network 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) ?: []; + $groupedIds = collect($groups)->first(fn ($ids) => in_array($wlanId, $ids)); + $enabled = $request->boolean('enabled'); + + if ($groupedIds) { + foreach ($groupedIds as $id) { + $unifi->updateWlan($id, ['enabled' => $enabled]); + } + } else { + $unifi->updateWlan($wlanId, ['enabled' => $enabled]); + } + + return back()->with('success', 'WiFi network ' . ($enabled ? 'enabled' : 'disabled') . '.'); + } catch (\Throwable $e) { + return back()->withErrors(['error' => $e->getMessage()]); + } + } + + /** + * Group/ungroup SSIDs. Groups are stored as { "GroupName": ["wlan_id_1", "wlan_id_2"] } + */ + public function saveGroups(Request $request) + { + $request->validate(['groups' => 'present']); + $groups = $request->input('groups', []); + if (is_array($groups) && array_is_list($groups)) $groups = (object) []; + Setting::set('unifi.ssid_groups', json_encode($groups ?: (object) [])); + return back()->with('success', 'SSID groups saved.'); + } + + 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'; + if (preg_match('/6\s*g/i', $name)) return '6 GHz'; + + return 'All bands'; + } +} diff --git a/src/Models/ApSnapshot.php b/src/Models/ApSnapshot.php new file mode 100644 index 0000000..880af24 --- /dev/null +++ b/src/Models/ApSnapshot.php @@ -0,0 +1,17 @@ + 'datetime']; +} diff --git a/src/Models/ClientSnapshot.php b/src/Models/ClientSnapshot.php new file mode 100644 index 0000000..1f20157 --- /dev/null +++ b/src/Models/ClientSnapshot.php @@ -0,0 +1,19 @@ + 'datetime', + 'is_wired' => 'bool', + ]; +} diff --git a/src/Models/DeviceState.php b/src/Models/DeviceState.php new file mode 100644 index 0000000..384c53c --- /dev/null +++ b/src/Models/DeviceState.php @@ -0,0 +1,13 @@ + 'boolean', 'last_seen_at' => 'datetime', 'updated_at' => 'datetime']; +} diff --git a/src/Models/KnownMac.php b/src/Models/KnownMac.php new file mode 100644 index 0000000..67516fd --- /dev/null +++ b/src/Models/KnownMac.php @@ -0,0 +1,16 @@ +attributes['mac_address'] = strtolower($value); + } +} diff --git a/src/Models/PortalSession.php b/src/Models/PortalSession.php new file mode 100644 index 0000000..7daafc0 --- /dev/null +++ b/src/Models/PortalSession.php @@ -0,0 +1,30 @@ + 'boolean', + 'authorized_at' => 'datetime', + 'expires_at' => 'datetime', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(\App\Models\User::class); + } + + public function setMacAddressAttribute($value) + { + $this->attributes['mac_address'] = strtolower($value); + } +} diff --git a/src/Models/VlanMapping.php b/src/Models/VlanMapping.php new file mode 100644 index 0000000..70da80e --- /dev/null +++ b/src/Models/VlanMapping.php @@ -0,0 +1,11 @@ + 'boolean', 'events' => 'array', 'thresholds' => 'array', 'device_filter' => 'array', 'tracked_clients' => 'array', 'templates' => 'array']; + + public function logs() { return $this->hasMany(WebhookLog::class, 'webhook_config_id'); } +} diff --git a/src/Models/WebhookLog.php b/src/Models/WebhookLog.php new file mode 100644 index 0000000..586e58d --- /dev/null +++ b/src/Models/WebhookLog.php @@ -0,0 +1,15 @@ + 'array', 'fired_at' => 'datetime']; + + public function config() { return $this->belongsTo(WebhookConfig::class, 'webhook_config_id'); } +} diff --git a/src/Services/UnifiApiClient.php b/src/Services/UnifiApiClient.php new file mode 100644 index 0000000..e92bf58 --- /dev/null +++ b/src/Services/UnifiApiClient.php @@ -0,0 +1,403 @@ +baseUrl) return; + + $this->baseUrl = rtrim(Setting::get('unifi.controller_url', ''), '/'); + $this->site = Setting::get('unifi.site', 'default'); + $this->username = Setting::get('unifi.username', ''); + $this->password = Setting::get('unifi.password', ''); + $this->apiKey = Setting::get('unifi.api_key', ''); + + if (! $this->baseUrl) { + throw new \RuntimeException('UniFi controller not configured. Go to Unifi Network → Settings.'); + } + if (! $this->username && ! $this->apiKey) { + throw new \RuntimeException('No credentials configured. Set either local account credentials or an API key in Unifi Network → Settings.'); + } + } + + /** + * Get session cookies, logging in if needed. Cached for 30 minutes. + */ + private function getSessionCookies(?string $overrideUrl = null, ?string $overrideUser = null, ?string $overridePass = null): array + { + $base = $overrideUrl ?? $this->baseUrl; + $user = $overrideUser ?? $this->username; + $pass = $overridePass ?? $this->password; + + $cacheKey = 'unifi:session:' . md5($base . $user); + + if (! $overrideUrl) { + $cached = Cache::get($cacheKey); + if ($cached) return $cached; + } + + $loginUrl = "{$base}/api/auth/login"; + + $response = Http::withHeaders([ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ])->withoutVerifying()->post($loginUrl, [ + 'username' => $user, + 'password' => $pass, + ]); + + if (! $response->successful()) { + $msg = $response->json('message') ?? "HTTP {$response->status()}"; + throw new \RuntimeException("UniFi login failed: {$msg}"); + } + + // Extract cookies from Set-Cookie headers + $cookies = []; + foreach ($response->cookies() as $cookie) { + $cookies[$cookie->getName()] = $cookie->getValue(); + } + + // Also capture the CSRF token + $csrf = $cookies['csrf_token'] ?? $response->header('X-CSRF-Token') ?? ''; + $cookieData = ['cookies' => $cookies, 'csrf' => $csrf]; + + if (! $overrideUrl) { + Cache::put($cacheKey, $cookieData, 1800); // 30 minutes + } + + return $cookieData; + } + + private function buildRequest() + { + // Prefer cookie-based auth (local account) for full access + if ($this->username && $this->password) { + $session = $this->getSessionCookies(); + $cookieStr = collect($session['cookies'])->map(fn ($v, $k) => "{$k}={$v}")->implode('; '); + + return Http::withHeaders([ + 'Accept' => 'application/json', + 'Cookie' => $cookieStr, + 'X-CSRF-Token' => $session['csrf'], + ])->withoutVerifying(); + } + + // Fallback to API key (read-only stats) + if ($this->apiKey) { + return Http::withHeaders([ + 'X-API-Key' => $this->apiKey, + 'Accept' => 'application/json', + ])->withoutVerifying(); + } + + throw new \RuntimeException('No credentials configured.'); + } + + /** + * Determine the API base path. UniFi OS consoles use /proxy/network/api/s/{site}, + * standalone controllers use /api/s/{site}. + */ + private function apiPath(): string + { + $cacheKey = 'unifi:api_prefix:' . md5($this->baseUrl); + + // Only cache if detection actually succeeds — don't cache a guess + $cached = Cache::get($cacheKey); + if ($cached) return $cached; + + $paths = ['/proxy/network/api', '/api']; + + foreach ($paths as $prefix) { + try { + $testUrl = "{$this->baseUrl}{$prefix}/s/{$this->site}/stat/sysinfo"; + $response = $this->buildRequest()->timeout(10)->get($testUrl); + $ct = $response->header('Content-Type', ''); + + if ($response->successful() && str_contains($ct, 'json')) { + Log::debug('unifi.api_prefix_detected', ['prefix' => $prefix]); + Cache::put($cacheKey, $prefix, 3600); + return $prefix; + } + } catch (\Throwable $e) { + Log::debug('unifi.api_prefix_probe', ['prefix' => $prefix, 'error' => $e->getMessage()]); + } + } + + // Default for modern UniFi OS hardware — but don't cache it so we retry next time + return '/proxy/network/api'; + } + + private function siteUrl(string $path): string + { + $this->init(); + return "{$this->baseUrl}{$this->apiPath()}/s/{$this->site}{$path}"; + } + + private function get(string $path): array + { + $this->init(); + $url = $this->siteUrl($path); + + $response = $this->buildRequest()->get($url); + + if ($response->status() === 401) { + // Session expired — clear cache and retry once + Cache::forget('unifi:session:' . md5($this->baseUrl . $this->username)); + $response = $this->buildRequest()->get($url); + } + + if (! $response->successful()) { + Log::warning('unifi.api_error', ['url' => $url, 'status' => $response->status()]); + throw new \RuntimeException("UniFi API error: HTTP {$response->status()}"); + } + + return $response->json('data', []); + } + + private function post(string $path, array $body = []): array + { + $this->init(); + $url = $this->siteUrl($path); + + $response = $this->buildRequest()->asJson()->post($url, $body); + + if ($response->status() === 401) { + Cache::forget('unifi:session:' . md5($this->baseUrl . $this->username)); + $response = $this->buildRequest()->asJson()->post($url, $body); + } + + if (! $response->successful()) { + Log::warning('unifi.api_error', ['url' => $url, 'status' => $response->status(), 'body' => $body]); + throw new \RuntimeException("UniFi API error: HTTP {$response->status()}"); + } + + return $response->json('data', []); + } + + private function put(string $path, array $body = []): array + { + $this->init(); + $url = $this->siteUrl($path); + + $response = $this->buildRequest()->asJson()->put($url, $body); + + if ($response->status() === 401) { + Cache::forget('unifi:session:' . md5($this->baseUrl . $this->username)); + $response = $this->buildRequest()->asJson()->put($url, $body); + } + + if (! $response->successful()) { + throw new \RuntimeException("UniFi API error: HTTP {$response->status()}"); + } + + return $response->json('data', []); + } + + // ── Devices / APs ───────────────────────────────────────────────────────── + + public function getDevices(): array + { + return Cache::remember('unifi:devices', (int) \App\Models\Setting::get('unifi.cache_ttl', 30), fn () => + $this->get('/stat/device') + ); + } + + public function getAccessPoints(): array + { + return collect($this->getDevices())->where('type', 'uap')->values()->all(); + } + + public function getGateway(): ?array + { + return collect($this->getDevices())->firstWhere('type', 'ugw') + ?? collect($this->getDevices())->firstWhere('type', 'udm') + ?? null; + } + + public function rebootDevice(string $mac): array + { + Cache::forget('unifi:devices'); + return $this->post('/cmd/devmgr', ['cmd' => 'restart', 'mac' => strtolower($mac)]); + } + + // ── Clients ─────────────────────────────────────────────────────────────── + + public function getActiveClients(): array + { + return Cache::remember('unifi:clients', (int) \App\Models\Setting::get('unifi.cache_ttl', 30), fn () => + $this->get('/stat/sta') + ); + } + + public function isClientConnected(string $mac): bool + { + $data = $this->get('/stat/sta/' . strtolower($mac)); + return ! empty($data); + } + + public function kickClient(string $mac): array + { + Cache::forget('unifi:clients'); + return $this->post('/cmd/stamgr', ['cmd' => 'kick-sta', 'mac' => strtolower($mac)]); + } + + public function blockClient(string $mac): array + { + return $this->post('/cmd/stamgr', ['cmd' => 'block-sta', 'mac' => strtolower($mac)]); + } + + public function unblockClient(string $mac): array + { + return $this->post('/cmd/stamgr', ['cmd' => 'unblock-sta', 'mac' => strtolower($mac)]); + } + + // ── Guest Portal ────────────────────────────────────────────────────────── + + public function authorizeGuest(string $mac, int $minutes = 720, ?int $upKbps = null, ?int $downKbps = null): array + { + $body = ['cmd' => 'authorize-guest', 'mac' => strtolower($mac), 'minutes' => $minutes]; + if ($upKbps) $body['up'] = $upKbps; + if ($downKbps) $body['down'] = $downKbps; + + Cache::forget('unifi:clients'); + return $this->post('/cmd/stamgr', $body); + } + + public function unauthorizeGuest(string $mac): array + { + return $this->post('/cmd/stamgr', ['cmd' => 'unauthorize-guest', 'mac' => strtolower($mac)]); + } + + // ── WiFi Networks / WLANs ───────────────────────────────────────────────── + + public function getWlans(): array + { + return $this->get('/rest/wlanconf'); + } + + public function updateWlan(string $wlanId, array $data): array + { + return $this->put("/rest/wlanconf/{$wlanId}", $data); + } + + // ── Health / Stats ──────────────────────────────────────────────────────── + + public function getSiteHealth(): array + { + return Cache::remember('unifi:health', (int) \App\Models\Setting::get('unifi.cache_ttl', 30), fn () => + $this->get('/stat/health') + ); + } + + public function getEvents(int $limit = 100): array + { + return Cache::remember('unifi:events', (int) \App\Models\Setting::get('unifi.cache_ttl', 30), fn () => + $this->get("/stat/event?start=0&limit={$limit}") + ); + } + + public function getAlarms(): array + { + return $this->get('/stat/alarm'); + } + + public function getHistoricalStats(string $type, int $startEpochMs, int $endEpochMs, array $attrs, ?string $mac = null): array + { + $body = ['attrs' => $attrs, 'start' => $startEpochMs, 'end' => $endEpochMs]; + if ($mac) $body['mac'] = strtolower($mac); + + return $this->post("/stat/report/{$type}", $body); + } + + // ── Connection Test ─────────────────────────────────────────────────────── + + public function testConnection(): array + { + return $this->get('/stat/sysinfo'); + } + + /** + * List all sites. Supports both cookie-based and API-key auth. + * Accepts override credentials for the settings page (before they're saved). + */ + public function getSites(?string $overrideUrl = null, ?string $overrideUser = null, ?string $overridePass = null, ?string $overrideKey = null): array + { + $base = $overrideUrl ? rtrim($overrideUrl, '/') : null; + + if (! $base) { + $this->init(); + $base = $this->baseUrl; + } + + // Build the HTTP client with appropriate auth + if ($overrideUser && $overridePass) { + $session = $this->getSessionCookies($base, $overrideUser, $overridePass); + $cookieStr = collect($session['cookies'])->map(fn ($v, $k) => "{$k}={$v}")->implode('; '); + $http = Http::withHeaders([ + 'Accept' => 'application/json', + 'Cookie' => $cookieStr, + 'X-CSRF-Token' => $session['csrf'], + ])->withoutVerifying(); + } elseif ($overrideKey) { + $http = Http::withHeaders([ + 'X-API-Key' => $overrideKey, + 'Accept' => 'application/json', + ])->withoutVerifying(); + } else { + $http = $this->buildRequest(); + } + + // Try multiple URL patterns + $paths = [ + '/proxy/network/api/self/sites', + '/api/self/sites', + ]; + + $lastError = ''; + foreach ($paths as $path) { + $url = "{$base}{$path}"; + try { + $response = $http->timeout(10)->get($url); + + if ($response->successful()) { + $ct = $response->header('Content-Type', ''); + if (str_contains($ct, 'text/html')) { + $lastError = "Got HTML on {$path} — not an API endpoint"; + continue; + } + + $data = $response->json('data', $response->json()); + if (is_array($data) && ! empty($data) && isset($data[0]['name'])) { + Log::debug('unifi.sites_found', ['path' => $path, 'count' => count($data)]); + return $data; + } + $lastError = "No sites in response from {$path}"; + } else { + $lastError = "HTTP {$response->status()} on {$path}"; + } + } catch (\Throwable $e) { + $lastError = "{$path}: {$e->getMessage()}"; + } + } + + throw new \RuntimeException("Could not list sites. {$lastError}"); + } +} diff --git a/src/Services/WebhookCheckService.php b/src/Services/WebhookCheckService.php new file mode 100644 index 0000000..c4804fb --- /dev/null +++ b/src/Services/WebhookCheckService.php @@ -0,0 +1,497 @@ + 'A UniFi device goes offline', + 'device_online' => 'A UniFi device comes back online', + 'client_offline' => 'A tracked client (by MAC) disconnects', + 'client_online' => 'A tracked client (by MAC) connects', + 'wan_down' => 'Internet / WAN goes down', + 'wan_up' => 'Internet / WAN comes back up', + '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', + '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}}'], + 'client_offline' => ['{{client_name}}', '{{mac}}', '{{last_ap}}', '{{last_ssid}}', '{{timestamp}}'], + 'client_online' => ['{{client_name}}', '{{mac}}', '{{ap}}', '{{ssid}}', '{{ip}}', '{{os}}', '{{timestamp}}'], + 'wan_down' => ['{{timestamp}}'], + 'wan_up' => ['{{timestamp}}'], + 'client_count_high' => ['{{device}}', '{{mac}}', '{{clients}}', '{{threshold}}', '{{timestamp}}'], + 'cu_high' => ['{{device}}', '{{mac}}', '{{radio}}', '{{cu}}', '{{threshold}}', '{{timestamp}}'], + 'satisfaction_low' => ['{{device}}', '{{mac}}', '{{satisfaction}}', '{{threshold}}', '{{timestamp}}'], + 'high_error_rate' => ['{{device}}', '{{mac}}', '{{retries}}', '{{timestamp}}'], + 'firmware_available' => ['{{device}}', '{{mac}}', '{{version}}', '{{timestamp}}'], + 'ap_unexpected_reboot' => ['{{device}}', '{{mac}}', '{{uptime}}', '{{timestamp}}'], + ]; + + public const DEFAULT_TEMPLATES = [ + 'device_offline' => '🔴 {{device}} ({{mac}}) has gone offline', + 'device_online' => '🟢 {{device}} ({{mac}}) is back online', + 'client_offline' => '📴 Client {{client_name}} ({{mac}}) disconnected from {{last_ap}} / {{last_ssid}}', + 'client_online' => '📱 Client {{client_name}} ({{mac}}) connected to {{ap}} / {{ssid}} — IP: {{ip}}, OS: {{os}}', + 'wan_down' => '🔴 Internet connection is DOWN', + 'wan_up' => '🟢 Internet connection restored', + 'client_count_high' => '⚠️ {{device}}: {{clients}} clients connected (threshold: {{threshold}})', + 'cu_high' => '⚠️ {{device}} ({{radio}}): {{cu}}% channel utilization (threshold: {{threshold}}%)', + 'satisfaction_low' => '⚠️ {{device}}: WiFi experience {{satisfaction}}% (threshold: {{threshold}}%)', + 'high_error_rate' => '⚠️ {{device}}: high retry count ({{retries}})', + 'firmware_available' => '🔄 {{device}}: firmware update available (current: {{version}})', + 'ap_unexpected_reboot' => '🔄 {{device}}: unexpected reboot (uptime: {{uptime}}s)', + ]; + + public function checkAll(UnifiApiClient $unifi): int + { + $configs = WebhookConfig::where('is_active', true)->get(); + if ($configs->isEmpty()) return 0; + + try { + $devices = $unifi->getDevices(); + $health = $unifi->getSiteHealth(); + } catch (\Throwable $e) { + Log::warning('unifi.webhook_check_failed', ['error' => $e->getMessage()]); + 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'])); + if ($needsClients) { + try { $activeClients = $unifi->getActiveClients(); } catch (\Throwable) { $activeClients = []; } + } + + $wan = collect($health)->firstWhere('subsystem', 'wan'); + $aps = collect($devices)->where('type', 'uap'); + $fired = 0; + + foreach ($configs as $config) { + $events = $config->events ?? []; + $thresholds = $config->thresholds ?? []; + $filter = $config->device_filter ?? []; + $templates = $config->templates ?? []; + $clientMacs = $config->tracked_clients ?? []; + + 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 => [], + }; + + foreach ($alerts as $alert) { + 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++; + } + } + } + + $this->syncDeviceStates($devices); + if ($activeClients !== null) $this->syncClientStates($activeClients); + + return $fired; + } + + // ── Client tracking ─────────────────────────────────────────────────────── + + private function checkClientTransition(?array $clients, array $trackedMacs, bool $comingOnline): array + { + if (empty($trackedMacs) || $clients === null) return []; + + $connectedMacs = collect($clients)->pluck('mac')->map(fn ($m) => strtolower($m))->all(); + $alerts = []; + + foreach ($trackedMacs as $entry) { + $mac = strtolower(is_array($entry) ? ($entry['mac'] ?? '') : $entry); + $name = is_array($entry) ? ($entry['name'] ?? $mac) : $mac; + if (! $mac) continue; + + $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 + $alerts[] = [ + 'key' => $mac, + 'client_name' => $name, + 'mac' => $mac, + 'ap' => $clientInfo['ap_mac'] ?? '', + 'ssid' => $clientInfo['essid'] ?? '', + 'ip' => $clientInfo['ip'] ?? '', + 'os' => $clientInfo['os_name'] ?? '', + 'message' => "{$name} ({$mac}) connected", + ]; + } + if (! $comingOnline && $prev->was_online === true && ! $isOnline && $prev->consecutive_count >= 2) { // 3rd consecutive poll offline + $alerts[] = [ + 'key' => $mac, + 'client_name' => $name, + 'mac' => $mac, + 'last_ap' => $prev->device_name ?? '', + 'last_ssid' => '', + 'message' => "{$name} ({$mac}) disconnected", + ]; + } + } + 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; + } + + private function checkWan(?array $wan, bool $comingUp): array + { + $status = $wan['status'] ?? 'unknown'; + $prevKey = 'unifi:wan_was_ok'; + $wasOk = cache()->get($prevKey, true); + $isOk = $status === 'ok'; + cache()->put($prevKey, $isOk, 3600); + if ($comingUp && ! $wasOk && $isOk) return [['key' => 'wan', 'message' => 'Internet connection restored']]; + if (! $comingUp && $wasOk && ! $isOk) return [['key' => 'wan', 'message' => 'Internet connection is down']]; + 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; + } + + private function checkCu($aps, int $threshold, ?string $band, array $filter): 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"]; + } + } + } + return $alerts; + } + + private function checkSatisfaction($aps, int $minSat, array $filter): 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"]; + } + } + return $alerts; + } + + private function checkErrorRate($aps, array $filter): array + { + $alerts = []; + foreach ($aps as $ap) { + if (! empty($filter) && ! in_array($ap['mac'], $filter)) continue; + $retries = $ap['stat']['ap']['tx_retries'] ?? 0; + if ($retries > 1000) { + $name = $ap['name'] ?? $ap['mac']; + $alerts[] = ['key' => $ap['mac'], 'device' => $name, 'mac' => $ap['mac'], 'retries' => $retries, 'message' => "{$name}: high retries ({$retries})"]; + } + } + return $alerts; + } + + private function checkFirmware(array $devices, array $filter): array + { + $alerts = []; + 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"]; + } + } + return $alerts; + } + + private function checkReboot($aps, array $filter): array + { + $alerts = []; + foreach ($aps as $ap) { + if (! empty($filter) && ! in_array($ap['mac'], $filter)) 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"]; + } + } + } + return $alerts; + } + + // ── Template + Cooldown + Firing ────────────────────────────────────────── + + private function applyTemplate(string $event, array $data, ?string $customTemplate): string + { + $template = $customTemplate ?: (self::DEFAULT_TEMPLATES[$event] ?? '{{message}}'); + $data['timestamp'] = now()->toIso8601String(); + + return preg_replace_callback('/\{\{(\w+)\}\}/', function ($matches) use ($data) { + return $data[$matches[1]] ?? $matches[0]; + }, $template); + } + + private function isInCooldown(WebhookConfig $config, string $event, string $key): bool + { + return WebhookLog::where('webhook_config_id', $config->id) + ->where('event_type', $event) + ->where('fired_at', '>=', now()->subMinutes($config->cooldown_minutes)) + ->whereJsonContains('payload->data->key', $key) + ->exists(); + } + + private function fire(WebhookConfig $config, string $event, array $data): void + { + $message = $data['message'] ?? $event; + + $internalPayload = [ + 'event' => $event, + 'timestamp' => now()->toIso8601String(), + 'message' => $message, + 'data' => $data, + ]; + + // Format payload for the target platform + $url = $config->url; + $payload = $this->formatPayloadForPlatform($url, $message, $internalPayload); + + $headers = ['Content-Type' => 'application/json']; + if ($config->secret) { + $headers['X-Webhook-Signature'] = hash_hmac('sha256', json_encode($internalPayload), $config->secret); + } + + $log = ['webhook_config_id' => $config->id, 'event_type' => $event, 'payload' => $internalPayload, 'fired_at' => now()]; + + try { + $response = Http::withHeaders($headers)->timeout(10)->post($url, $payload); + $log['response_code'] = $response->status(); + $log['response_body'] = substr($response->body(), 0, 1000); + } catch (\Throwable $e) { + $log['response_code'] = 0; + $log['response_body'] = $e->getMessage(); + } + + 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 + return $fullPayload; + } + + /** + * Sync device states with consecutive_count for debouncing. + * + * 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. + * + * 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) + */ + private function syncDeviceStates(array $devices): void + { + foreach ($devices as $dev) { + $mac = $dev['mac']; + $isOnline = ($dev['state'] ?? 0) == 1; + $prev = DeviceState::where('device_mac', $mac)->first(); + + 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(), + ]); + continue; + } + + $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]); + } 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]); + } 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 new file mode 100644 index 0000000..fea9e81 --- /dev/null +++ b/src/UnifiServiceProvider.php @@ -0,0 +1,34 @@ +mergeConfigFrom(__DIR__ . '/../config/unifi.php', 'unifi'); + + $this->app->singleton(Services\UnifiApiClient::class, function ($app) { + return new Services\UnifiApiClient(); + }); + } + + public function boot(): void + { + $this->loadRoutesFrom(__DIR__ . '/routes/unifi.php'); + $this->loadMigrationsFrom(__DIR__ . '/../database/migrations'); + + if ($this->app->runningInConsole()) { + $this->commands([ + Console\CheckWebhooks::class, + Console\CaptureSnapshots::class, + Console\CleanupSnapshots::class, + ]); + $this->publishes([ + __DIR__ . '/../config/unifi.php' => config_path('unifi.php'), + ], 'unifi-config'); + } + } +} diff --git a/src/routes/unifi.php b/src/routes/unifi.php new file mode 100644 index 0000000..d21fc2b --- /dev/null +++ b/src/routes/unifi.php @@ -0,0 +1,67 @@ +prefix('app/network') + ->name('unifi.') + ->group(function () { + + // ── Stats (read-only) ──────────────────────────────────────────────── + Route::middleware('permission:unifi.stats')->group(function () { + Route::get('/', [StatsController::class, 'dashboard'])->name('dashboard'); + Route::get('/fullscreen', [StatsController::class, 'dashboard'])->name('dashboard.fullscreen')->defaults('fullscreen', true); + Route::get('/wan-status', [StatsController::class, 'wanStatus'])->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'); + }); + + // ── 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'); + Route::post('/devices/reboot', [DeviceController::class, 'reboot']) ->name('devices.reboot'); + Route::post('/clients/kick', [ClientController::class, 'kick']) ->name('clients.kick'); + }); + + // ── Portal auth ────────────────────────────────────────────────────── + Route::middleware('permission:unifi.auth')->group(function () { + Route::get('/portal', [PortalController::class, 'settings']) ->name('portal.settings'); + Route::post('/portal/mappings', [PortalController::class, 'storeMapping']) ->name('portal.mappings.store'); + Route::put('/portal/mappings/{mapping}', [PortalController::class, 'updateMapping']) ->name('portal.mappings.update'); + Route::delete('/portal/mappings/{mapping}', [PortalController::class, 'destroyMapping'])->name('portal.mappings.destroy'); + 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'); + }); + + // ── 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/sites', [UnifiSettingsController::class, 'fetchSites']) ->name('settings.sites'); + + // Webhooks + Route::get('/webhooks', [WebhookController::class, 'index']) ->name('webhooks.index'); + Route::post('/webhooks', [WebhookController::class, 'store']) ->name('webhooks.store'); + Route::put('/webhooks/{webhook}', [WebhookController::class, 'update']) ->name('webhooks.update'); + Route::delete('/webhooks/{webhook}', [WebhookController::class, 'destroy'])->name('webhooks.destroy'); + Route::post('/webhooks/{webhook}/test', [WebhookController::class, 'test']) ->name('webhooks.test'); + }); + }); + +// ── Captive portal callback (public — user redirected here by UniFi) ───── +Route::middleware(['web', 'auth']) + ->get('/portal/wifi/callback', [PortalController::class, 'captiveCallback']) + ->name('unifi.portal.callback');