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 <noreply@anthropic.com>
This commit is contained in:
768
src/Http/Controllers/StatsController.php
Normal file
768
src/Http/Controllers/StatsController.php
Normal file
@@ -0,0 +1,768 @@
|
||||
<?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 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 = $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<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::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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user