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 = Cache::remember('unifi_access_points', 30, fn () => $unifi->getAccessPoints()); $aps = empty($apFilter) ? $allAps : array_values(array_filter($allAps, fn ($a) => in_array($a['mac'], $apFilter))); $clients = Cache::remember('unifi_active_clients', 30, fn () => $unifi->getActiveClients()); $ssidGroups = json_decode(Setting::get('unifi.ssid_groups', '{}'), true); if (! is_array($ssidGroups) || array_is_list($ssidGroups)) $ssidGroups = []; 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/cached API $clients = collect(Cache::remember('unifi_active_clients', 30, fn () => $unifi->getActiveClients())); $aps = collect(Cache::remember('unifi_access_points', 30, fn () => $unifi->getAccessPoints())); $devCatCounts = $clients->map(fn ($c) => ['name' => $this->classifyDeviceType($c)]) ->groupBy('name') ->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), 'ssid' => $c['essid'] ?? null, 'vlan_id' => ($c['vlan_id'] ?? 0) ?: null, 'dot1x' => $c['1x_identity'] ?? $c['dot1x_identity'] ?? null, 'tx_rate' => (int) ($c['tx_rate'] ?? 0), 'rx_rate' => (int) ($c['rx_rate'] ?? 0), 'tx_bytes' => (int) ($c['tx_bytes'] ?? 0), 'rx_bytes' => (int) ($c['rx_bytes'] ?? 0), ]; })->sortByDesc(fn ($c) => $c['rx_rate'] + $c['tx_rate'])->values(); $rangeKey = $range === 'interval' ? "interval_{$startDt->timestamp}_{$endDt->timestamp}" : $range; $series = Cache::remember("unifi_client_series_{$rangeKey}", 45, fn () => $this->buildClientSeries($startDt, $endDt)); return Inertia::render('Unifi/ClientDashboard', [ 'devCatCounts' => $devCatCounts, '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'], 'activeClientCount' => $clients->count(), 'apList' => $apList, 'clientList' => $clientList, 'vlanGroups' => \Dashboard\Unifi\Models\VlanGroup::orderBy('sort_order')->get(), 'range' => $range, 'ranges' => array_keys(self::RANGE_MAP), 'pollInterval' => (int) Setting::get('unifi.poll_interval', 30), ]); } catch (\Throwable $e) { return Inertia::render('Unifi/ClientDashboard', [ 'error' => $e->getMessage(), 'range' => $range, 'ranges' => array_keys(self::RANGE_MAP), ]); } } /** * AJAX — Traffic time-series for clients currently on a given AP. * Reads from the cached series (built by clientDashboard), so it's fast. */ public function clientApTraffic(Request $request, UnifiApiClient $unifi): \Illuminate\Http\JsonResponse { $apMac = strtolower(trim($request->get('ap', ''))); $range = $request->get('range', '4h'); if ($range === 'interval' && $request->get('start') && $request->get('end')) { $startDt = \Carbon\Carbon::parse($request->get('start')); $endDt = \Carbon\Carbon::parse($request->get('end')); } else { if (! isset(self::RANGE_MAP[$range])) $range = '4h'; $endDt = now(); $startDt = $endDt->copy()->subMinutes(self::RANGE_MAP[$range]); } try { // Which client MACs are currently on this AP? $clients = collect(Cache::remember('unifi_active_clients', 30, fn () => $unifi->getActiveClients())); $macsOnAp = $clients ->filter(fn ($c) => strtolower($c['ap_mac'] ?? '') === $apMac) ->pluck('mac') ->map(fn ($m) => strtolower($m)) ->flip() // flip to key => true for O(1) lookup ->all(); // Fetch (or warm) the cached series $rangeKey = $range === 'interval' ? "interval_{$startDt->timestamp}_{$endDt->timestamp}" : $range; $series = Cache::remember("unifi_client_series_{$rangeKey}", 45, fn () => $this->buildClientSeries($startDt, $endDt)); $rx = collect($series['traffic_rx']) ->filter(fn ($s) => isset($macsOnAp[strtolower($s['mac'] ?? '')])) ->values(); $tx = collect($series['traffic_tx']) ->filter(fn ($s) => isset($macsOnAp[strtolower($s['mac'] ?? '')])) ->values(); return response()->json([ 'labels' => $series['labels'], 'traffic_rx' => $rx, 'traffic_tx' => $tx, ]); } catch (\Throwable $e) { return response()->json(['error' => $e->getMessage()], 500); } } /** * Device TYPE = manufacturer + model (when detectable). * * 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::whereBetween('captured_at', [$start, $end]) ->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->values()->all(), 'traffic_rx' => $trafficRx->values()->all(), 'traffic_tx' => $trafficTx->values()->all(), 'satisfaction' => $satisfaction->values()->all(), 'signal' => $signal->values()->all(), 'download_mb' => $downloadMb->values()->all(), 'total_download_bytes' => $totalDownload, 'total_upload_bytes' => $totalUpload, 'download_series' => $downloadSeries, '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; } }