Files
dashboard-unifi/src/Http/Controllers/StatsController.php
jwed 0802ef35f3 feat: password rotation, PPSK management, VLAN/AP groups
- Add password rotation: RotatePasswords console command + migration + service updates
- Add PPSK management: UnifiPpsk model, migration, SyncPpskSchedules console
- Add VLAN groups and AP groups: VlanGroupController, ApGroupController, model, migration
- Add RebootAllAps console command
- Add in_alert column to device states
- Wire new features through service provider, routes, and existing controllers/services

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 17:54:24 -04:00

829 lines
42 KiB
PHP

<?php
namespace Dashboard\Unifi\Http\Controllers;
use App\Models\Setting;
use Dashboard\Unifi\Models\ApSnapshot;
use Dashboard\Unifi\Models\ClientSnapshot;
use Dashboard\Unifi\Services\UnifiApiClient;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Cache;
use Inertia\Inertia;
class StatsController extends Controller
{
private const RANGE_MAP = [
'5m' => 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<N>...` and `st<NN>` 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;
}
}