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:
64
src/Http/Controllers/ClientController.php
Normal file
64
src/Http/Controllers/ClientController.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace Dashboard\Unifi\Http\Controllers;
|
||||
|
||||
use Dashboard\Unifi\Models\KnownMac;
|
||||
use Dashboard\Unifi\Models\PortalSession;
|
||||
use Dashboard\Unifi\Services\UnifiApiClient;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class ClientController extends Controller
|
||||
{
|
||||
public function index(UnifiApiClient $unifi)
|
||||
{
|
||||
try {
|
||||
$clients = collect($unifi->getActiveClients())->map(fn ($c) => [
|
||||
'mac' => $c['mac'],
|
||||
'hostname' => $c['hostname'] ?? $c['name'] ?? '',
|
||||
'ip' => $c['ip'] ?? '',
|
||||
'oui' => $c['oui'] ?? '',
|
||||
'os' => $c['os_name'] ?? null,
|
||||
'dev_cat' => $c['dev_cat'] ?? null,
|
||||
'dev_family' => $c['dev_family'] ?? null,
|
||||
'dev_vendor' => $c['dev_vendor'] ?? null,
|
||||
'is_wired' => $c['is_wired'] ?? false,
|
||||
'is_guest' => $c['is_guest'] ?? false,
|
||||
'ssid' => $c['essid'] ?? null,
|
||||
'ap_mac' => $c['ap_mac'] ?? null,
|
||||
'rssi' => $c['rssi'] ?? null,
|
||||
'signal' => $c['signal'] ?? null,
|
||||
'channel' => $c['channel'] ?? null,
|
||||
'tx_bytes' => $c['tx_bytes'] ?? 0,
|
||||
'rx_bytes' => $c['rx_bytes'] ?? 0,
|
||||
'tx_rate' => $c['tx_rate'] ?? 0,
|
||||
'rx_rate' => $c['rx_rate'] ?? 0,
|
||||
'uptime' => $c['uptime'] ?? 0,
|
||||
'satisfaction' => $c['satisfaction'] ?? null,
|
||||
'is_known' => KnownMac::where('mac_address', strtolower($c['mac']))->exists(),
|
||||
])->values();
|
||||
|
||||
return Inertia::render('Unifi/Clients', ['clients' => $clients]);
|
||||
} catch (\Throwable $e) {
|
||||
return Inertia::render('Unifi/Clients', ['clients' => [], 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
public function kick(Request $request, UnifiApiClient $unifi)
|
||||
{
|
||||
$request->validate(['mac' => 'required|string']);
|
||||
|
||||
try {
|
||||
$unifi->kickClient($request->mac);
|
||||
// Deactivate portal session if there is one
|
||||
PortalSession::where('mac_address', strtolower($request->mac))
|
||||
->where('is_active', true)
|
||||
->update(['is_active' => false]);
|
||||
|
||||
return back()->with('success', 'Client disconnected.');
|
||||
} catch (\Throwable $e) {
|
||||
return back()->withErrors(['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
56
src/Http/Controllers/DeviceController.php
Normal file
56
src/Http/Controllers/DeviceController.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace Dashboard\Unifi\Http\Controllers;
|
||||
|
||||
use Dashboard\Unifi\Services\UnifiApiClient;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class DeviceController extends Controller
|
||||
{
|
||||
public function index(UnifiApiClient $unifi)
|
||||
{
|
||||
try {
|
||||
$devices = collect($unifi->getDevices())->map(fn ($d) => [
|
||||
'mac' => $d['mac'],
|
||||
'name' => $d['name'] ?? $d['model'] ?? 'Unknown',
|
||||
'model' => $d['model'] ?? '',
|
||||
'type' => $d['type'] ?? '',
|
||||
'ip' => $d['ip'] ?? '',
|
||||
'version' => $d['version'] ?? '',
|
||||
'state' => $d['state'] ?? 0,
|
||||
'adopted' => $d['adopted'] ?? false,
|
||||
'upgradable' => $d['upgradable'] ?? false,
|
||||
'uptime' => $d['uptime'] ?? 0,
|
||||
'num_sta' => $d['num_sta'] ?? 0,
|
||||
'tx_bytes' => $d['tx_bytes'] ?? 0,
|
||||
'rx_bytes' => $d['rx_bytes'] ?? 0,
|
||||
'cpu' => $d['system-stats']['cpu'] ?? null,
|
||||
'mem' => $d['system-stats']['mem'] ?? null,
|
||||
'satisfaction' => $d['satisfaction'] ?? null,
|
||||
'channels' => collect($d['radio_table'] ?? [])->map(fn ($r) => [
|
||||
'radio' => $r['radio'] ?? '',
|
||||
'channel' => $r['channel'] ?? null,
|
||||
'ht' => $r['ht'] ?? '',
|
||||
])->values(),
|
||||
])->values();
|
||||
|
||||
return Inertia::render('Unifi/Devices', ['devices' => $devices]);
|
||||
} catch (\Throwable $e) {
|
||||
return Inertia::render('Unifi/Devices', ['devices' => [], 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
public function reboot(Request $request, UnifiApiClient $unifi)
|
||||
{
|
||||
$request->validate(['mac' => 'required|string|regex:/^([0-9a-f]{2}:){5}[0-9a-f]{2}$/i']);
|
||||
|
||||
try {
|
||||
$unifi->rebootDevice($request->mac);
|
||||
return back()->with('success', 'Reboot command sent.');
|
||||
} catch (\Throwable $e) {
|
||||
return back()->withErrors(['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
202
src/Http/Controllers/PortalController.php
Normal file
202
src/Http/Controllers/PortalController.php
Normal file
@@ -0,0 +1,202 @@
|
||||
<?php
|
||||
|
||||
namespace Dashboard\Unifi\Http\Controllers;
|
||||
|
||||
use Dashboard\Unifi\Models\KnownMac;
|
||||
use Dashboard\Unifi\Models\PortalSession;
|
||||
use Dashboard\Unifi\Models\VlanMapping;
|
||||
use Dashboard\Unifi\Services\UnifiApiClient;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class PortalController extends Controller
|
||||
{
|
||||
// ── Settings page (VLAN mappings + known MACs) ────────────────────────────
|
||||
|
||||
public function settings()
|
||||
{
|
||||
return Inertia::render('Unifi/Portal', [
|
||||
'vlanMappings' => VlanMapping::orderBy('sort_order')->get(),
|
||||
'knownMacs' => KnownMac::orderBy('device_name')->paginate(50),
|
||||
'activeSessions' => PortalSession::where('is_active', true)
|
||||
->with('user:id,name,email')
|
||||
->latest('authorized_at')
|
||||
->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function storeMapping(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'group_name' => 'required|string|max:255',
|
||||
'match_type' => 'required|in:ou,group,email_domain,default',
|
||||
'match_value' => 'required|string|max:255',
|
||||
'vlan_id' => 'required|integer|min:1|max:4094',
|
||||
'max_devices' => 'required|integer|min:1|max:50',
|
||||
'session_minutes' => 'required|integer|min:1',
|
||||
]);
|
||||
$data['sort_order'] = VlanMapping::max('sort_order') + 1;
|
||||
VlanMapping::create($data);
|
||||
return back()->with('success', 'VLAN mapping created.');
|
||||
}
|
||||
|
||||
public function updateMapping(Request $request, VlanMapping $mapping)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'group_name' => 'required|string|max:255',
|
||||
'match_type' => 'required|in:ou,group,email_domain,default',
|
||||
'match_value' => 'required|string|max:255',
|
||||
'vlan_id' => 'required|integer|min:1|max:4094',
|
||||
'max_devices' => 'required|integer|min:1|max:50',
|
||||
'session_minutes' => 'required|integer|min:1',
|
||||
]);
|
||||
$mapping->update($data);
|
||||
return back()->with('success', 'Mapping updated.');
|
||||
}
|
||||
|
||||
public function destroyMapping(VlanMapping $mapping)
|
||||
{
|
||||
$mapping->delete();
|
||||
return back()->with('success', 'Mapping deleted.');
|
||||
}
|
||||
|
||||
// ── Known MACs ────────────────────────────────────────────────────────────
|
||||
|
||||
public function storeMac(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'mac_address' => 'required|string|regex:/^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$/|unique:unifi_known_macs,mac_address',
|
||||
'device_name' => 'nullable|string|max:255',
|
||||
'device_type' => 'nullable|string|max:100',
|
||||
'owner' => 'nullable|string|max:255',
|
||||
'vlan_id' => 'required|integer|min:1|max:4094',
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
]);
|
||||
KnownMac::create($data);
|
||||
return back()->with('success', 'MAC address added.');
|
||||
}
|
||||
|
||||
public function destroyMac(KnownMac $mac)
|
||||
{
|
||||
$mac->delete();
|
||||
return back()->with('success', 'MAC address removed.');
|
||||
}
|
||||
|
||||
// ── Captive portal callback (called by UniFi external portal redirect) ───
|
||||
|
||||
public function captiveCallback(Request $request, UnifiApiClient $unifi)
|
||||
{
|
||||
// UniFi redirects guest to this URL with ?id=<ap_mac>&ap=<ap_mac>&t=<timestamp>&url=<original_url>&ssid=<ssid>
|
||||
// At this point the user has already authenticated via Google OAuth (handled by the shell's auth system)
|
||||
$user = $request->user();
|
||||
if (! $user) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
$clientMac = strtolower($request->input('mac', $request->input('id', '')));
|
||||
$ssid = $request->input('ssid', '');
|
||||
$apMac = $request->input('ap', '');
|
||||
|
||||
if (! $clientMac) {
|
||||
return response('Missing MAC address', 400);
|
||||
}
|
||||
|
||||
// Find the VLAN mapping for this user's group/OU
|
||||
$mapping = $this->resolveMapping($user);
|
||||
if (! $mapping) {
|
||||
return Inertia::render('Unifi/PortalDenied', ['reason' => 'No WiFi access configured for your account.']);
|
||||
}
|
||||
|
||||
// Check device limit
|
||||
$activeSessions = PortalSession::where('user_id', $user->id)
|
||||
->where('is_active', true)
|
||||
->get();
|
||||
|
||||
foreach ($activeSessions as $session) {
|
||||
if ($session->mac_address === $clientMac) {
|
||||
// Same device reconnecting — refresh
|
||||
$session->update(['is_active' => false]);
|
||||
continue;
|
||||
}
|
||||
// Check if other device is still actually connected
|
||||
if (! $unifi->isClientConnected($session->mac_address)) {
|
||||
$session->update(['is_active' => false]); // stale session
|
||||
}
|
||||
}
|
||||
|
||||
$activeCount = PortalSession::where('user_id', $user->id)->where('is_active', true)->count();
|
||||
if ($activeCount >= $mapping->max_devices) {
|
||||
return Inertia::render('Unifi/PortalDenied', [
|
||||
'reason' => "You already have {$activeCount} device(s) connected (limit: {$mapping->max_devices}).",
|
||||
'sessions' => $activeSessions->map(fn ($s) => [
|
||||
'id' => $s->id,
|
||||
'mac' => $s->mac_address,
|
||||
'hostname' => $s->device_hostname,
|
||||
'os' => $s->device_os,
|
||||
'since' => $s->authorized_at->toISOString(),
|
||||
]),
|
||||
'can_disconnect' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
// Authorize the guest
|
||||
$minutes = $mapping->session_minutes;
|
||||
$unifi->authorizeGuest($clientMac, $minutes);
|
||||
|
||||
// Record the session
|
||||
PortalSession::create([
|
||||
'user_id' => $user->id,
|
||||
'mac_address' => $clientMac,
|
||||
'device_hostname' => $request->input('hostname'),
|
||||
'device_os' => $request->input('os'),
|
||||
'device_type' => $request->input('dev_cat'),
|
||||
'ssid' => $ssid,
|
||||
'vlan_id' => $mapping->vlan_id,
|
||||
'ap_mac' => $apMac,
|
||||
'is_active' => true,
|
||||
'authorized_at' => now(),
|
||||
'expires_at' => now()->addMinutes($minutes),
|
||||
]);
|
||||
|
||||
// Redirect to the original URL the user was trying to reach
|
||||
$redirectUrl = $request->input('url', '/');
|
||||
return redirect($redirectUrl);
|
||||
}
|
||||
|
||||
public function disconnectSession(Request $request, PortalSession $session, UnifiApiClient $unifi)
|
||||
{
|
||||
// Allow users to disconnect their own sessions
|
||||
abort_if($session->user_id !== auth()->id() && ! auth()->user()->can('unifi.auth'), 403);
|
||||
|
||||
try {
|
||||
$unifi->kickClient($session->mac_address);
|
||||
$unifi->unauthorizeGuest($session->mac_address);
|
||||
} catch (\Throwable) {}
|
||||
|
||||
$session->update(['is_active' => false]);
|
||||
|
||||
return back()->with('success', 'Device disconnected.');
|
||||
}
|
||||
|
||||
// ── Private helpers ───────────────────────────────────────────────────────
|
||||
|
||||
private function resolveMapping($user): ?VlanMapping
|
||||
{
|
||||
$mappings = VlanMapping::orderBy('sort_order')->get();
|
||||
|
||||
// Try to match by email domain, group, OU, etc.
|
||||
foreach ($mappings as $mapping) {
|
||||
$matched = match ($mapping->match_type) {
|
||||
'email_domain' => str_ends_with($user->email ?? '', '@' . $mapping->match_value),
|
||||
'group' => $user->groups?->contains('name', $mapping->match_value) ?? false,
|
||||
'ou' => str_contains($user->ou ?? '', $mapping->match_value),
|
||||
'default' => true,
|
||||
default => false,
|
||||
};
|
||||
if ($matched) return $mapping;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
117
src/Http/Controllers/UnifiSettingsController.php
Normal file
117
src/Http/Controllers/UnifiSettingsController.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace Dashboard\Unifi\Http\Controllers;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Dashboard\Unifi\Services\UnifiApiClient;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class UnifiSettingsController extends Controller
|
||||
{
|
||||
public function edit()
|
||||
{
|
||||
return Inertia::render('Unifi/Settings', [
|
||||
'controllerUrl' => Setting::get('unifi.controller_url', ''),
|
||||
'username' => Setting::get('unifi.username', ''),
|
||||
'hasPassword' => (bool) Setting::get('unifi.password'),
|
||||
'hasApiKey' => (bool) Setting::get('unifi.api_key'),
|
||||
'site' => Setting::get('unifi.site', 'default'),
|
||||
'pollInterval' => (int) Setting::get('unifi.poll_interval', 30),
|
||||
'cacheTtl' => (int) Setting::get('unifi.cache_ttl', 30),
|
||||
'retentionDays' => (int) Setting::get('unifi.retention_days', 30),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'controller_url' => 'required|url|max:500',
|
||||
'username' => 'nullable|string|max:255',
|
||||
'password' => 'nullable|string|max:255',
|
||||
'api_key' => 'nullable|string|max:500',
|
||||
'site' => 'required|string|max:100',
|
||||
'poll_interval' => 'nullable|integer|min:5|max:300',
|
||||
'cache_ttl' => 'nullable|integer|min:5|max:300',
|
||||
'retention_days' => 'nullable|integer|min:1|max:365',
|
||||
]);
|
||||
|
||||
Setting::set('unifi.controller_url', rtrim($request->controller_url, '/'));
|
||||
Setting::set('unifi.site', $request->site);
|
||||
|
||||
// Save the chosen auth method and clear the other
|
||||
Setting::set('unifi.username', $request->username ?? '');
|
||||
if ($request->password && $request->password !== '••••••••') {
|
||||
Setting::set('unifi.password', $request->password);
|
||||
} elseif (! $request->username) {
|
||||
Setting::set('unifi.password', ''); // clear password when switching to API key mode
|
||||
}
|
||||
if ($request->api_key && $request->api_key !== '••••••••') {
|
||||
Setting::set('unifi.api_key', $request->api_key);
|
||||
} elseif ($request->username) {
|
||||
Setting::set('unifi.api_key', ''); // clear API key when switching to local account mode
|
||||
}
|
||||
|
||||
if ($request->has('poll_interval')) Setting::set('unifi.poll_interval', $request->poll_interval ?? 30);
|
||||
if ($request->has('cache_ttl')) Setting::set('unifi.cache_ttl', $request->cache_ttl ?? 30);
|
||||
if ($request->has('retention_days')) Setting::set('unifi.retention_days', $request->retention_days ?? 30);
|
||||
|
||||
// Clear cached sessions so new credentials take effect
|
||||
\Illuminate\Support\Facades\Cache::forget('unifi:session:' . md5(rtrim($request->controller_url, '/') . $request->username));
|
||||
\Illuminate\Support\Facades\Cache::forget('unifi:api_prefix:' . md5(rtrim($request->controller_url, '/')));
|
||||
|
||||
return back()->with('success', 'UniFi settings saved.');
|
||||
}
|
||||
|
||||
public function testConnection(UnifiApiClient $unifi)
|
||||
{
|
||||
try {
|
||||
$info = $unifi->testConnection();
|
||||
$version = $info[0]['version'] ?? 'unknown';
|
||||
return response()->json(['ok' => true, 'version' => $version]);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['ok' => false, 'error' => $e->getMessage()], 422);
|
||||
}
|
||||
}
|
||||
|
||||
public function fetchSites(Request $request, UnifiApiClient $unifi)
|
||||
{
|
||||
$request->validate([
|
||||
'controller_url' => 'required|url',
|
||||
]);
|
||||
|
||||
$url = rtrim($request->controller_url, '/');
|
||||
$user = $request->input('username', '');
|
||||
$pass = $request->input('password', '');
|
||||
$key = $request->input('api_key', '');
|
||||
|
||||
// Use saved credentials if placeholders sent
|
||||
if ($pass === '••••••••') $pass = Setting::get('unifi.password', '');
|
||||
if ($key === '••••••••') $key = Setting::get('unifi.api_key', '');
|
||||
|
||||
try {
|
||||
$sites = $unifi->getSites($url, $user ?: null, $pass ?: null, $key ?: null);
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
'sites' => collect($sites)->map(fn ($s) => [
|
||||
'name' => $s['name'] ?? 'default',
|
||||
'desc' => $s['desc'] ?? $s['name'] ?? 'Default',
|
||||
])->values(),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
$hint = "Tried URL: {$url}. ";
|
||||
if (str_contains($url, 'unifi.ui.com')) {
|
||||
$hint .= "Use your console's direct URL (*.id.ui.direct or local IP) instead of unifi.ui.com.";
|
||||
} elseif (! $user && ! $key) {
|
||||
$hint .= "Enter either a local account username/password or an API key.";
|
||||
} else {
|
||||
$hint .= $user
|
||||
? "Check that the local account credentials are correct."
|
||||
: "The API key may be read-only. Try using a local admin account instead.";
|
||||
}
|
||||
|
||||
return response()->json(['ok' => false, 'error' => $e->getMessage(), 'hint' => $hint], 422);
|
||||
}
|
||||
}
|
||||
}
|
||||
89
src/Http/Controllers/WebhookController.php
Normal file
89
src/Http/Controllers/WebhookController.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace Dashboard\Unifi\Http\Controllers;
|
||||
|
||||
use Dashboard\Unifi\Models\WebhookConfig;
|
||||
use Dashboard\Unifi\Services\WebhookCheckService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class WebhookController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
return Inertia::render('Unifi/Webhooks', [
|
||||
'webhooks' => WebhookConfig::latest()->get(),
|
||||
'recentLogs' => \Dashboard\Unifi\Models\WebhookLog::with('config:id,name')
|
||||
->latest('fired_at')->take(50)->get(),
|
||||
'eventTypes' => WebhookCheckService::EVENTS,
|
||||
'templateVars' => WebhookCheckService::TEMPLATE_VARS,
|
||||
'defaultTemplates' => WebhookCheckService::DEFAULT_TEMPLATES,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'url' => 'required|url|max:500',
|
||||
'secret' => 'nullable|string|max:255',
|
||||
'is_active' => 'boolean',
|
||||
'events' => 'required|array|min:1',
|
||||
'events.*' => 'string|in:' . implode(',', array_keys(WebhookCheckService::EVENTS)),
|
||||
'thresholds' => 'nullable|array',
|
||||
'device_filter' => 'nullable|array',
|
||||
'tracked_clients' => 'nullable|array',
|
||||
'templates' => 'nullable|array',
|
||||
'cooldown_minutes' => 'integer|min:1|max:1440',
|
||||
]);
|
||||
WebhookConfig::create($data);
|
||||
return back()->with('success', 'Webhook created.');
|
||||
}
|
||||
|
||||
public function update(Request $request, WebhookConfig $webhook)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'url' => 'required|url|max:500',
|
||||
'secret' => 'nullable|string|max:255',
|
||||
'is_active' => 'boolean',
|
||||
'events' => 'required|array|min:1',
|
||||
'events.*' => 'string|in:' . implode(',', array_keys(WebhookCheckService::EVENTS)),
|
||||
'thresholds' => 'nullable|array',
|
||||
'device_filter' => 'nullable|array',
|
||||
'tracked_clients' => 'nullable|array',
|
||||
'templates' => 'nullable|array',
|
||||
'cooldown_minutes' => 'integer|min:1|max:1440',
|
||||
]);
|
||||
$webhook->update($data);
|
||||
return back()->with('success', 'Webhook updated.');
|
||||
}
|
||||
|
||||
public function destroy(WebhookConfig $webhook)
|
||||
{
|
||||
$webhook->delete();
|
||||
return back()->with('success', 'Webhook deleted.');
|
||||
}
|
||||
|
||||
public function test(WebhookConfig $webhook)
|
||||
{
|
||||
$payload = [
|
||||
'event' => 'test',
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
'data' => ['message' => 'This is a test webhook from ' . config('app.name')],
|
||||
];
|
||||
|
||||
$headers = ['Content-Type' => 'application/json'];
|
||||
if ($webhook->secret) {
|
||||
$headers['X-Webhook-Signature'] = hash_hmac('sha256', json_encode($payload), $webhook->secret);
|
||||
}
|
||||
|
||||
try {
|
||||
$response = \Illuminate\Support\Facades\Http::withHeaders($headers)->timeout(10)->post($webhook->url, $payload);
|
||||
return response()->json(['ok' => true, 'status' => $response->status()]);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['ok' => false, 'error' => $e->getMessage()], 422);
|
||||
}
|
||||
}
|
||||
}
|
||||
126
src/Http/Controllers/WifiController.php
Normal file
126
src/Http/Controllers/WifiController.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace Dashboard\Unifi\Http\Controllers;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Dashboard\Unifi\Services\UnifiApiClient;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class WifiController extends Controller
|
||||
{
|
||||
public function index(UnifiApiClient $unifi)
|
||||
{
|
||||
try {
|
||||
$wlans = collect($unifi->getWlans())->map(fn ($w) => [
|
||||
'id' => $w['_id'],
|
||||
'name' => $w['name'],
|
||||
'enabled' => $w['enabled'] ?? true,
|
||||
'security' => $w['security'] ?? 'open',
|
||||
'wpa_mode' => $w['wpa_mode'] ?? '',
|
||||
'is_guest' => $w['is_guest'] ?? false,
|
||||
'vlan_enabled' => $w['vlan_enabled'] ?? false,
|
||||
'vlan' => $w['vlan'] ?? null,
|
||||
'hide_ssid' => $w['hide_ssid'] ?? false,
|
||||
'passphrase' => $w['x_passphrase'] ?? '',
|
||||
'band' => $this->detectBand($w),
|
||||
])->values();
|
||||
|
||||
// Load saved groups: { "Staff": ["id1", "id2"], ... }
|
||||
$raw = Setting::get('unifi.ssid_groups', '{}');
|
||||
$groups = json_decode($raw, true);
|
||||
if (! is_array($groups) || array_is_list($groups)) $groups = [];
|
||||
// Force object cast so Vue gets {} not []
|
||||
$groups = (object) $groups;
|
||||
|
||||
return Inertia::render('Unifi/Wifi', [
|
||||
'wlans' => $wlans,
|
||||
'groups' => $groups,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return Inertia::render('Unifi/Wifi', ['wlans' => [], 'groups' => [], 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
public function update(Request $request, string $wlanId, UnifiApiClient $unifi)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => 'sometimes|string|max:255',
|
||||
'enabled' => 'sometimes|boolean',
|
||||
'x_passphrase' => 'sometimes|string|min:8|max:63',
|
||||
'hide_ssid' => 'sometimes|boolean',
|
||||
]);
|
||||
|
||||
try {
|
||||
// If this WLAN is in a group, apply the same change to all grouped WLANs
|
||||
$groups = json_decode(Setting::get('unifi.ssid_groups', '{}'), true) ?: [];
|
||||
$groupedIds = collect($groups)->first(fn ($ids) => in_array($wlanId, $ids));
|
||||
|
||||
if ($groupedIds) {
|
||||
foreach ($groupedIds as $id) {
|
||||
$unifi->updateWlan($id, $data);
|
||||
}
|
||||
} else {
|
||||
$unifi->updateWlan($wlanId, $data);
|
||||
}
|
||||
|
||||
return back()->with('success', 'WiFi network updated.');
|
||||
} catch (\Throwable $e) {
|
||||
return back()->withErrors(['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
public function toggle(Request $request, string $wlanId, UnifiApiClient $unifi)
|
||||
{
|
||||
$request->validate(['enabled' => 'required|boolean']);
|
||||
|
||||
try {
|
||||
$groups = json_decode(Setting::get('unifi.ssid_groups', '{}'), true) ?: [];
|
||||
$groupedIds = collect($groups)->first(fn ($ids) => in_array($wlanId, $ids));
|
||||
$enabled = $request->boolean('enabled');
|
||||
|
||||
if ($groupedIds) {
|
||||
foreach ($groupedIds as $id) {
|
||||
$unifi->updateWlan($id, ['enabled' => $enabled]);
|
||||
}
|
||||
} else {
|
||||
$unifi->updateWlan($wlanId, ['enabled' => $enabled]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'WiFi network ' . ($enabled ? 'enabled' : 'disabled') . '.');
|
||||
} catch (\Throwable $e) {
|
||||
return back()->withErrors(['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Group/ungroup SSIDs. Groups are stored as { "GroupName": ["wlan_id_1", "wlan_id_2"] }
|
||||
*/
|
||||
public function saveGroups(Request $request)
|
||||
{
|
||||
$request->validate(['groups' => 'present']);
|
||||
$groups = $request->input('groups', []);
|
||||
if (is_array($groups) && array_is_list($groups)) $groups = (object) [];
|
||||
Setting::set('unifi.ssid_groups', json_encode($groups ?: (object) []));
|
||||
return back()->with('success', 'SSID groups saved.');
|
||||
}
|
||||
|
||||
private function detectBand(array $w): string
|
||||
{
|
||||
// UniFi stores band info in wlan_band or in the radio settings
|
||||
$band = $w['wlan_band'] ?? null;
|
||||
if ($band === 'ng' || $band === '2g') return '2.4 GHz';
|
||||
if ($band === 'na' || $band === '5g') return '5 GHz';
|
||||
if ($band === 'a6' || $band === '6g' || $band === '6e') return '6 GHz';
|
||||
if ($band === 'both' || $band === null) return 'All bands';
|
||||
|
||||
// Try to detect from SSID name as fallback
|
||||
$name = strtolower($w['name'] ?? '');
|
||||
if (preg_match('/2\.?4\s*g/i', $name)) return '2.4 GHz';
|
||||
if (preg_match('/5\s*g/i', $name)) return '5 GHz';
|
||||
if (preg_match('/6\s*g/i', $name)) return '6 GHz';
|
||||
|
||||
return 'All bands';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user