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:
Joel Wedemire
2026-04-12 23:00:05 -07:00
commit ce3217d8f4
29 changed files with 2972 additions and 0 deletions

47
composer.json Normal file
View File

@@ -0,0 +1,47 @@
{
"name": "dashboard/unifi",
"description": "UniFi network management, WiFi stats, and captive portal authentication for the Dashboard platform",
"version": "1.0.0",
"type": "library",
"license": "MIT",
"autoload": {
"psr-4": {
"Dashboard\\Unifi\\": "src/"
}
},
"extra": {
"laravel": {
"providers": [
"Dashboard\\Unifi\\UnifiServiceProvider"
]
},
"dashboard": {
"nav_folder": {
"label": "Unifi Network",
"icon": "wifi",
"sort_order": 40
},
"pages": [
{ "label": "WiFi Dashboard", "route_name": "unifi.dashboard", "icon": "chart-bar-square", "permission": "unifi.stats", "sort_order": 1 },
{ "label": "Client Dashboard", "route_name": "unifi.client.dashboard", "icon": "chart-pie", "permission": "unifi.stats", "sort_order": 2 },
{ "label": "Devices", "route_name": "unifi.devices", "icon": "cpu-chip", "permission": "unifi.stats", "sort_order": 3 },
{ "label": "Clients", "route_name": "unifi.clients", "icon": "users", "permission": "unifi.stats", "sort_order": 4 },
{ "label": "WiFi Networks", "route_name": "unifi.wifi", "icon": "wifi", "permission": "unifi.manage", "sort_order": 5 },
{ "label": "Portal", "route_name": "unifi.portal.settings", "icon": "shield-check", "permission": "unifi.auth", "sort_order": 6 },
{ "label": "Webhooks", "route_name": "unifi.webhooks.index", "icon": "bell-alert", "permission": "unifi.settings", "sort_order": 7 },
{ "label": "Settings", "route_name": "unifi.settings", "icon": "cog-6-tooth", "permission": "unifi.settings", "sort_order": 99 }
],
"permissions": [
{ "key": "unifi.stats", "label": "View Network Stats", "description": "View WiFi dashboards, AP stats, and client lists" },
{ "key": "unifi.manage", "label": "Manage Network", "description": "Reboot APs, manage SSIDs, change WiFi passwords" },
{ "key": "unifi.auth", "label": "Manage Portal Auth", "description": "Configure captive portal, VLAN mappings, MAC allowlist" },
{ "key": "unifi.settings", "label": "Network Settings", "description": "Configure UniFi controller connection" }
]
}
},
"require": {
"php": "^8.2",
"illuminate/support": "^11.0|^12.0|^13.0",
"guzzlehttp/guzzle": "^7.0"
}
}

12
config/unifi.php Normal file
View File

@@ -0,0 +1,12 @@
<?php
return [
// Polling intervals (seconds) for the stats cache
'cache_ttl_devices' => 30,
'cache_ttl_clients' => 15,
'cache_ttl_health' => 10,
'cache_ttl_events' => 30,
// Maximum portal session duration defaults (overridden per-group in DB)
'portal_session_default_minutes' => 720, // 12 hours
];

View File

@@ -0,0 +1,67 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// VLAN mapping rules: OU/group → VLAN + device limit + session duration
Schema::create('unifi_vlan_mappings', function (Blueprint $table) {
$table->id();
$table->string('group_name', 255); // Google OU or group name
$table->string('match_type', 20) // 'ou', 'group', 'email_domain', 'default'
->default('group');
$table->string('match_value', 255); // the value to match against
$table->unsignedSmallInteger('vlan_id'); // VLAN to assign
$table->unsignedTinyInteger('max_devices') // max concurrent devices
->default(1);
$table->unsignedInteger('session_minutes') // portal session duration
->default(720);
$table->unsignedSmallInteger('sort_order')
->default(0);
$table->timestamps();
});
// Known MAC addresses → direct VLAN assignment (no portal needed)
Schema::create('unifi_known_macs', function (Blueprint $table) {
$table->id();
$table->string('mac_address', 17)->unique(); // aa:bb:cc:dd:ee:ff
$table->string('device_name', 255)->nullable();
$table->string('device_type', 100)->nullable(); // chromebook, printer, phone, etc.
$table->string('owner', 255)->nullable(); // who it belongs to
$table->unsignedSmallInteger('vlan_id');
$table->text('notes')->nullable();
$table->timestamps();
});
// Active portal sessions (tracks who's connected via captive portal)
Schema::create('unifi_portal_sessions', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('mac_address', 17);
$table->string('device_hostname', 255)->nullable();
$table->string('device_os', 100)->nullable(); // detected OS
$table->string('device_type', 100)->nullable(); // detected device category
$table->string('ssid', 100)->nullable();
$table->unsignedSmallInteger('vlan_id')->nullable();
$table->string('ap_mac', 17)->nullable();
$table->boolean('is_active')->default(true);
$table->timestamp('authorized_at');
$table->timestamp('expires_at')->nullable();
$table->timestamps();
$table->index(['user_id', 'is_active']);
$table->index('mac_address');
});
}
public function down(): void
{
Schema::dropIfExists('unifi_portal_sessions');
Schema::dropIfExists('unifi_known_macs');
Schema::dropIfExists('unifi_vlan_mappings');
}
};

View File

@@ -0,0 +1,51 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('unifi_webhook_configs', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('url', 500);
$table->string('secret', 255)->nullable();
$table->boolean('is_active')->default(true);
$table->json('events'); // ['device_offline','wan_down',...]
$table->json('thresholds')->nullable(); // {"client_count":50,"cu_threshold":80,...}
$table->json('device_filter')->nullable(); // ["aa:bb:cc:dd:ee:ff",...]
$table->unsignedInteger('cooldown_minutes')->default(15);
$table->timestamps();
});
Schema::create('unifi_webhook_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('webhook_config_id')->constrained('unifi_webhook_configs')->cascadeOnDelete();
$table->string('event_type', 100);
$table->json('payload');
$table->unsignedSmallInteger('response_code')->nullable();
$table->text('response_body')->nullable();
$table->timestamp('fired_at');
$table->index(['webhook_config_id', 'event_type', 'fired_at']);
});
Schema::create('unifi_device_states', function (Blueprint $table) {
$table->id();
$table->string('device_mac', 17)->unique();
$table->string('device_name', 255)->nullable();
$table->boolean('was_online')->default(true);
$table->timestamp('last_seen_at')->nullable();
$table->timestamp('updated_at')->nullable();
});
}
public function down(): void
{
Schema::dropIfExists('unifi_webhook_logs');
Schema::dropIfExists('unifi_webhook_configs');
Schema::dropIfExists('unifi_device_states');
}
};

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('unifi_ap_snapshots', function (Blueprint $table) {
$table->id();
$table->string('ap_mac', 17)->index();
$table->string('ap_name', 255)->nullable();
$table->unsignedSmallInteger('num_sta')->default(0);
$table->unsignedBigInteger('rx_bytes')->default(0);
$table->unsignedBigInteger('tx_bytes')->default(0);
$table->unsignedInteger('tx_retries')->default(0);
$table->unsignedInteger('tx_dropped')->default(0);
$table->unsignedInteger('rx_dropped')->default(0);
$table->unsignedInteger('tx_errors')->default(0);
$table->unsignedInteger('rx_errors')->default(0);
$table->unsignedTinyInteger('satisfaction')->nullable();
$table->unsignedTinyInteger('cu_2g')->nullable();
$table->unsignedTinyInteger('cu_5g')->nullable();
$table->unsignedTinyInteger('cu_6g')->nullable();
$table->timestamp('captured_at')->index();
});
}
public function down(): void
{
Schema::dropIfExists('unifi_ap_snapshots');
}
};

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('unifi_client_snapshots', function (Blueprint $table) {
$table->id();
$table->string('mac', 17)->index();
$table->string('name', 255)->nullable();
$table->string('dev_cat', 64)->nullable()->index();
$table->string('os_name', 64)->nullable()->index();
$table->boolean('is_wired')->default(false);
$table->unsignedBigInteger('rx_bytes')->default(0);
$table->unsignedBigInteger('tx_bytes')->default(0);
$table->unsignedTinyInteger('satisfaction')->nullable();
$table->timestamp('captured_at')->index();
});
}
public function down(): void
{
Schema::dropIfExists('unifi_client_snapshots');
}
};

View File

@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('unifi_client_snapshots', function (Blueprint $table) {
// Unifi reports signal strength in dBm (typically -30 to -95, negative).
// SMALLINT so a nullable signed value fits comfortably.
$table->smallInteger('signal')->nullable()->after('satisfaction');
});
}
public function down(): void
{
Schema::table('unifi_client_snapshots', function (Blueprint $table) {
$table->dropColumn('signal');
});
}
};

View File

@@ -0,0 +1,97 @@
<?php
namespace Dashboard\Unifi\Console;
use Dashboard\Unifi\Models\ApSnapshot;
use Dashboard\Unifi\Models\ClientSnapshot;
use Dashboard\Unifi\Services\UnifiApiClient;
use Dashboard\Unifi\Services\WebhookCheckService;
use Illuminate\Console\Command;
class CaptureSnapshots extends Command
{
protected $signature = 'unifi:capture-snapshots';
protected $description = 'Capture AP stats snapshot and evaluate webhook alerts using the same data';
public function handle(UnifiApiClient $unifi, WebhookCheckService $webhooks): int
{
try {
$aps = $unifi->getAccessPoints();
} catch (\Throwable $e) {
$this->error('Failed to fetch APs: ' . $e->getMessage());
return self::FAILURE;
}
// ── 1. Store snapshot ─────────────────────────────────────────────
$now = now();
$rows = [];
foreach ($aps as $ap) {
$rows[] = [
'ap_mac' => $ap['mac'],
'ap_name' => $ap['name'] ?? $ap['model'] ?? null,
'num_sta' => $ap['num_sta'] ?? 0,
'rx_bytes' => $ap['rx_bytes'] ?? 0,
'tx_bytes' => $ap['tx_bytes'] ?? 0,
'tx_retries' => $ap['stat']['ap']['tx_retries'] ?? 0,
'tx_dropped' => $ap['stat']['ap']['tx_dropped'] ?? 0,
'rx_dropped' => $ap['stat']['ap']['rx_dropped'] ?? 0,
'tx_errors' => $ap['stat']['ap']['tx_errors'] ?? 0,
'rx_errors' => $ap['stat']['ap']['rx_errors'] ?? 0,
'satisfaction' => ($ap['satisfaction'] ?? -1) >= 0 ? $ap['satisfaction'] : null,
'cu_2g' => $this->getRadioStat($ap, 'ng'),
'cu_5g' => $this->getRadioStat($ap, 'na'),
'cu_6g' => $this->getRadioStat($ap, 'a6'),
'captured_at' => $now,
];
}
if (! empty($rows)) {
ApSnapshot::insert($rows);
}
// ── 1b. Store per-client snapshot ─────────────────────────────────
try {
$clients = $unifi->getActiveClients();
$clientRows = [];
foreach ($clients as $c) {
if (empty($c['mac'])) continue;
$clientRows[] = [
'mac' => strtolower($c['mac']),
'name' => $c['hostname'] ?? $c['name'] ?? null,
'dev_cat' => $c['dev_cat'] ?? null,
'os_name' => $c['os_name'] ?? null,
'is_wired' => (bool) ($c['is_wired'] ?? false),
'rx_bytes' => $c['rx_bytes'] ?? 0,
'tx_bytes' => $c['tx_bytes'] ?? 0,
'satisfaction' => ($c['satisfaction'] ?? -1) >= 0 ? $c['satisfaction'] : null,
'signal' => isset($c['signal']) ? (int) $c['signal'] : null,
'captured_at' => $now,
];
}
if (! empty($clientRows)) {
ClientSnapshot::insert($clientRows);
}
} catch (\Throwable $e) {
$this->warn('Client snapshot failed: ' . $e->getMessage());
}
// ── 2. Check webhook alerts with the same data ────────────────────
$fired = $webhooks->checkAll($unifi);
if ($fired > 0) {
$this->info("Fired {$fired} webhook(s).");
}
return self::SUCCESS;
}
private function getRadioStat(array $ap, string $radio): ?int
{
foreach ($ap['radio_table_stats'] ?? [] as $stat) {
if (($stat['radio'] ?? '') === $radio && isset($stat['cu_total'])) {
return (int) $stat['cu_total'];
}
}
return null;
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace Dashboard\Unifi\Console;
use Dashboard\Unifi\Services\UnifiApiClient;
use Dashboard\Unifi\Services\WebhookCheckService;
use Illuminate\Console\Command;
class CheckWebhooks extends Command
{
protected $signature = 'unifi:check-webhooks';
protected $description = 'Evaluate UniFi webhook conditions and fire alerts';
public function handle(WebhookCheckService $service, UnifiApiClient $unifi): int
{
$fired = $service->checkAll($unifi);
if ($fired > 0) {
$this->info("Fired {$fired} webhook(s).");
}
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace Dashboard\Unifi\Console;
use App\Models\Setting;
use Dashboard\Unifi\Models\ApSnapshot;
use Dashboard\Unifi\Models\ClientSnapshot;
use Dashboard\Unifi\Models\WebhookLog;
use Illuminate\Console\Command;
class CleanupSnapshots extends Command
{
protected $signature = 'unifi:cleanup';
protected $description = 'Remove old AP snapshots and webhook logs based on retention setting';
public function handle(): int
{
$days = (int) Setting::get('unifi.retention_days', 30);
$cutoff = now()->subDays($days);
$snapshots = ApSnapshot::where('captured_at', '<', $cutoff)->delete();
$clientSnapshots = ClientSnapshot::where('captured_at', '<', $cutoff)->delete();
$webhookLogs = WebhookLog::where('fired_at', '<', $cutoff)->delete();
$this->info("Cleaned up: {$snapshots} AP snapshots, {$clientSnapshots} client snapshots, {$webhookLogs} webhook logs older than {$days} days.");
return self::SUCCESS;
}
}

View 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()]);
}
}
}

View 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()]);
}
}
}

View 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;
}
}

View 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;
}
}

View 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);
}
}
}

View 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);
}
}
}

View 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';
}
}

17
src/Models/ApSnapshot.php Normal file
View File

@@ -0,0 +1,17 @@
<?php
namespace Dashboard\Unifi\Models;
use Illuminate\Database\Eloquent\Model;
class ApSnapshot extends Model
{
public $timestamps = false;
protected $table = 'unifi_ap_snapshots';
protected $fillable = [
'ap_mac', 'ap_name', 'num_sta', 'rx_bytes', 'tx_bytes',
'tx_retries', 'tx_dropped', 'rx_dropped', 'tx_errors', 'rx_errors',
'satisfaction', 'cu_2g', 'cu_5g', 'cu_6g', 'captured_at',
];
protected $casts = ['captured_at' => 'datetime'];
}

View File

@@ -0,0 +1,19 @@
<?php
namespace Dashboard\Unifi\Models;
use Illuminate\Database\Eloquent\Model;
class ClientSnapshot extends Model
{
public $timestamps = false;
protected $table = 'unifi_client_snapshots';
protected $fillable = [
'mac', 'name', 'dev_cat', 'os_name', 'is_wired',
'rx_bytes', 'tx_bytes', 'satisfaction', 'signal', 'captured_at',
];
protected $casts = [
'captured_at' => 'datetime',
'is_wired' => 'bool',
];
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Dashboard\Unifi\Models;
use Illuminate\Database\Eloquent\Model;
class DeviceState extends Model
{
public $timestamps = false;
protected $table = 'unifi_device_states';
protected $fillable = ['device_mac', 'device_name', 'was_online', 'consecutive_count', 'last_seen_at', 'updated_at'];
protected $casts = ['was_online' => 'boolean', 'last_seen_at' => 'datetime', 'updated_at' => 'datetime'];
}

16
src/Models/KnownMac.php Normal file
View File

@@ -0,0 +1,16 @@
<?php
namespace Dashboard\Unifi\Models;
use Illuminate\Database\Eloquent\Model;
class KnownMac extends Model
{
protected $table = 'unifi_known_macs';
protected $fillable = ['mac_address', 'device_name', 'device_type', 'owner', 'vlan_id', 'notes'];
public function setMacAddressAttribute($value)
{
$this->attributes['mac_address'] = strtolower($value);
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace Dashboard\Unifi\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PortalSession extends Model
{
protected $table = 'unifi_portal_sessions';
protected $fillable = [
'user_id', 'mac_address', 'device_hostname', 'device_os', 'device_type',
'ssid', 'vlan_id', 'ap_mac', 'is_active', 'authorized_at', 'expires_at',
];
protected $casts = [
'is_active' => 'boolean',
'authorized_at' => 'datetime',
'expires_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(\App\Models\User::class);
}
public function setMacAddressAttribute($value)
{
$this->attributes['mac_address'] = strtolower($value);
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace Dashboard\Unifi\Models;
use Illuminate\Database\Eloquent\Model;
class VlanMapping extends Model
{
protected $table = 'unifi_vlan_mappings';
protected $fillable = ['group_name', 'match_type', 'match_value', 'vlan_id', 'max_devices', 'session_minutes', 'sort_order'];
}

View File

@@ -0,0 +1,14 @@
<?php
namespace Dashboard\Unifi\Models;
use Illuminate\Database\Eloquent\Model;
class WebhookConfig extends Model
{
protected $table = 'unifi_webhook_configs';
protected $fillable = ['name', 'url', 'secret', 'is_active', 'events', 'thresholds', 'device_filter', 'tracked_clients', 'templates', 'cooldown_minutes'];
protected $casts = ['is_active' => 'boolean', 'events' => 'array', 'thresholds' => 'array', 'device_filter' => 'array', 'tracked_clients' => 'array', 'templates' => 'array'];
public function logs() { return $this->hasMany(WebhookLog::class, 'webhook_config_id'); }
}

15
src/Models/WebhookLog.php Normal file
View File

@@ -0,0 +1,15 @@
<?php
namespace Dashboard\Unifi\Models;
use Illuminate\Database\Eloquent\Model;
class WebhookLog extends Model
{
public $timestamps = false;
protected $table = 'unifi_webhook_logs';
protected $fillable = ['webhook_config_id', 'event_type', 'payload', 'response_code', 'response_body', 'fired_at'];
protected $casts = ['payload' => 'array', 'fired_at' => 'datetime'];
public function config() { return $this->belongsTo(WebhookConfig::class, 'webhook_config_id'); }
}

View File

@@ -0,0 +1,403 @@
<?php
namespace Dashboard\Unifi\Services;
use App\Models\Setting;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class UnifiApiClient
{
private ?string $baseUrl = null;
private ?string $site = null;
private ?string $username = null;
private ?string $password = null;
private ?string $apiKey = null;
// Cached session cookies for cookie-based auth
private ?array $cookies = null;
// ── Connection ────────────────────────────────────────────────────────────
private function init(): void
{
if ($this->baseUrl) return;
$this->baseUrl = rtrim(Setting::get('unifi.controller_url', ''), '/');
$this->site = Setting::get('unifi.site', 'default');
$this->username = Setting::get('unifi.username', '');
$this->password = Setting::get('unifi.password', '');
$this->apiKey = Setting::get('unifi.api_key', '');
if (! $this->baseUrl) {
throw new \RuntimeException('UniFi controller not configured. Go to Unifi Network → Settings.');
}
if (! $this->username && ! $this->apiKey) {
throw new \RuntimeException('No credentials configured. Set either local account credentials or an API key in Unifi Network → Settings.');
}
}
/**
* Get session cookies, logging in if needed. Cached for 30 minutes.
*/
private function getSessionCookies(?string $overrideUrl = null, ?string $overrideUser = null, ?string $overridePass = null): array
{
$base = $overrideUrl ?? $this->baseUrl;
$user = $overrideUser ?? $this->username;
$pass = $overridePass ?? $this->password;
$cacheKey = 'unifi:session:' . md5($base . $user);
if (! $overrideUrl) {
$cached = Cache::get($cacheKey);
if ($cached) return $cached;
}
$loginUrl = "{$base}/api/auth/login";
$response = Http::withHeaders([
'Content-Type' => 'application/json',
'Accept' => 'application/json',
])->withoutVerifying()->post($loginUrl, [
'username' => $user,
'password' => $pass,
]);
if (! $response->successful()) {
$msg = $response->json('message') ?? "HTTP {$response->status()}";
throw new \RuntimeException("UniFi login failed: {$msg}");
}
// Extract cookies from Set-Cookie headers
$cookies = [];
foreach ($response->cookies() as $cookie) {
$cookies[$cookie->getName()] = $cookie->getValue();
}
// Also capture the CSRF token
$csrf = $cookies['csrf_token'] ?? $response->header('X-CSRF-Token') ?? '';
$cookieData = ['cookies' => $cookies, 'csrf' => $csrf];
if (! $overrideUrl) {
Cache::put($cacheKey, $cookieData, 1800); // 30 minutes
}
return $cookieData;
}
private function buildRequest()
{
// Prefer cookie-based auth (local account) for full access
if ($this->username && $this->password) {
$session = $this->getSessionCookies();
$cookieStr = collect($session['cookies'])->map(fn ($v, $k) => "{$k}={$v}")->implode('; ');
return Http::withHeaders([
'Accept' => 'application/json',
'Cookie' => $cookieStr,
'X-CSRF-Token' => $session['csrf'],
])->withoutVerifying();
}
// Fallback to API key (read-only stats)
if ($this->apiKey) {
return Http::withHeaders([
'X-API-Key' => $this->apiKey,
'Accept' => 'application/json',
])->withoutVerifying();
}
throw new \RuntimeException('No credentials configured.');
}
/**
* Determine the API base path. UniFi OS consoles use /proxy/network/api/s/{site},
* standalone controllers use /api/s/{site}.
*/
private function apiPath(): string
{
$cacheKey = 'unifi:api_prefix:' . md5($this->baseUrl);
// Only cache if detection actually succeeds — don't cache a guess
$cached = Cache::get($cacheKey);
if ($cached) return $cached;
$paths = ['/proxy/network/api', '/api'];
foreach ($paths as $prefix) {
try {
$testUrl = "{$this->baseUrl}{$prefix}/s/{$this->site}/stat/sysinfo";
$response = $this->buildRequest()->timeout(10)->get($testUrl);
$ct = $response->header('Content-Type', '');
if ($response->successful() && str_contains($ct, 'json')) {
Log::debug('unifi.api_prefix_detected', ['prefix' => $prefix]);
Cache::put($cacheKey, $prefix, 3600);
return $prefix;
}
} catch (\Throwable $e) {
Log::debug('unifi.api_prefix_probe', ['prefix' => $prefix, 'error' => $e->getMessage()]);
}
}
// Default for modern UniFi OS hardware — but don't cache it so we retry next time
return '/proxy/network/api';
}
private function siteUrl(string $path): string
{
$this->init();
return "{$this->baseUrl}{$this->apiPath()}/s/{$this->site}{$path}";
}
private function get(string $path): array
{
$this->init();
$url = $this->siteUrl($path);
$response = $this->buildRequest()->get($url);
if ($response->status() === 401) {
// Session expired — clear cache and retry once
Cache::forget('unifi:session:' . md5($this->baseUrl . $this->username));
$response = $this->buildRequest()->get($url);
}
if (! $response->successful()) {
Log::warning('unifi.api_error', ['url' => $url, 'status' => $response->status()]);
throw new \RuntimeException("UniFi API error: HTTP {$response->status()}");
}
return $response->json('data', []);
}
private function post(string $path, array $body = []): array
{
$this->init();
$url = $this->siteUrl($path);
$response = $this->buildRequest()->asJson()->post($url, $body);
if ($response->status() === 401) {
Cache::forget('unifi:session:' . md5($this->baseUrl . $this->username));
$response = $this->buildRequest()->asJson()->post($url, $body);
}
if (! $response->successful()) {
Log::warning('unifi.api_error', ['url' => $url, 'status' => $response->status(), 'body' => $body]);
throw new \RuntimeException("UniFi API error: HTTP {$response->status()}");
}
return $response->json('data', []);
}
private function put(string $path, array $body = []): array
{
$this->init();
$url = $this->siteUrl($path);
$response = $this->buildRequest()->asJson()->put($url, $body);
if ($response->status() === 401) {
Cache::forget('unifi:session:' . md5($this->baseUrl . $this->username));
$response = $this->buildRequest()->asJson()->put($url, $body);
}
if (! $response->successful()) {
throw new \RuntimeException("UniFi API error: HTTP {$response->status()}");
}
return $response->json('data', []);
}
// ── Devices / APs ─────────────────────────────────────────────────────────
public function getDevices(): array
{
return Cache::remember('unifi:devices', (int) \App\Models\Setting::get('unifi.cache_ttl', 30), fn () =>
$this->get('/stat/device')
);
}
public function getAccessPoints(): array
{
return collect($this->getDevices())->where('type', 'uap')->values()->all();
}
public function getGateway(): ?array
{
return collect($this->getDevices())->firstWhere('type', 'ugw')
?? collect($this->getDevices())->firstWhere('type', 'udm')
?? null;
}
public function rebootDevice(string $mac): array
{
Cache::forget('unifi:devices');
return $this->post('/cmd/devmgr', ['cmd' => 'restart', 'mac' => strtolower($mac)]);
}
// ── Clients ───────────────────────────────────────────────────────────────
public function getActiveClients(): array
{
return Cache::remember('unifi:clients', (int) \App\Models\Setting::get('unifi.cache_ttl', 30), fn () =>
$this->get('/stat/sta')
);
}
public function isClientConnected(string $mac): bool
{
$data = $this->get('/stat/sta/' . strtolower($mac));
return ! empty($data);
}
public function kickClient(string $mac): array
{
Cache::forget('unifi:clients');
return $this->post('/cmd/stamgr', ['cmd' => 'kick-sta', 'mac' => strtolower($mac)]);
}
public function blockClient(string $mac): array
{
return $this->post('/cmd/stamgr', ['cmd' => 'block-sta', 'mac' => strtolower($mac)]);
}
public function unblockClient(string $mac): array
{
return $this->post('/cmd/stamgr', ['cmd' => 'unblock-sta', 'mac' => strtolower($mac)]);
}
// ── Guest Portal ──────────────────────────────────────────────────────────
public function authorizeGuest(string $mac, int $minutes = 720, ?int $upKbps = null, ?int $downKbps = null): array
{
$body = ['cmd' => 'authorize-guest', 'mac' => strtolower($mac), 'minutes' => $minutes];
if ($upKbps) $body['up'] = $upKbps;
if ($downKbps) $body['down'] = $downKbps;
Cache::forget('unifi:clients');
return $this->post('/cmd/stamgr', $body);
}
public function unauthorizeGuest(string $mac): array
{
return $this->post('/cmd/stamgr', ['cmd' => 'unauthorize-guest', 'mac' => strtolower($mac)]);
}
// ── WiFi Networks / WLANs ─────────────────────────────────────────────────
public function getWlans(): array
{
return $this->get('/rest/wlanconf');
}
public function updateWlan(string $wlanId, array $data): array
{
return $this->put("/rest/wlanconf/{$wlanId}", $data);
}
// ── Health / Stats ────────────────────────────────────────────────────────
public function getSiteHealth(): array
{
return Cache::remember('unifi:health', (int) \App\Models\Setting::get('unifi.cache_ttl', 30), fn () =>
$this->get('/stat/health')
);
}
public function getEvents(int $limit = 100): array
{
return Cache::remember('unifi:events', (int) \App\Models\Setting::get('unifi.cache_ttl', 30), fn () =>
$this->get("/stat/event?start=0&limit={$limit}")
);
}
public function getAlarms(): array
{
return $this->get('/stat/alarm');
}
public function getHistoricalStats(string $type, int $startEpochMs, int $endEpochMs, array $attrs, ?string $mac = null): array
{
$body = ['attrs' => $attrs, 'start' => $startEpochMs, 'end' => $endEpochMs];
if ($mac) $body['mac'] = strtolower($mac);
return $this->post("/stat/report/{$type}", $body);
}
// ── Connection Test ───────────────────────────────────────────────────────
public function testConnection(): array
{
return $this->get('/stat/sysinfo');
}
/**
* List all sites. Supports both cookie-based and API-key auth.
* Accepts override credentials for the settings page (before they're saved).
*/
public function getSites(?string $overrideUrl = null, ?string $overrideUser = null, ?string $overridePass = null, ?string $overrideKey = null): array
{
$base = $overrideUrl ? rtrim($overrideUrl, '/') : null;
if (! $base) {
$this->init();
$base = $this->baseUrl;
}
// Build the HTTP client with appropriate auth
if ($overrideUser && $overridePass) {
$session = $this->getSessionCookies($base, $overrideUser, $overridePass);
$cookieStr = collect($session['cookies'])->map(fn ($v, $k) => "{$k}={$v}")->implode('; ');
$http = Http::withHeaders([
'Accept' => 'application/json',
'Cookie' => $cookieStr,
'X-CSRF-Token' => $session['csrf'],
])->withoutVerifying();
} elseif ($overrideKey) {
$http = Http::withHeaders([
'X-API-Key' => $overrideKey,
'Accept' => 'application/json',
])->withoutVerifying();
} else {
$http = $this->buildRequest();
}
// Try multiple URL patterns
$paths = [
'/proxy/network/api/self/sites',
'/api/self/sites',
];
$lastError = '';
foreach ($paths as $path) {
$url = "{$base}{$path}";
try {
$response = $http->timeout(10)->get($url);
if ($response->successful()) {
$ct = $response->header('Content-Type', '');
if (str_contains($ct, 'text/html')) {
$lastError = "Got HTML on {$path} — not an API endpoint";
continue;
}
$data = $response->json('data', $response->json());
if (is_array($data) && ! empty($data) && isset($data[0]['name'])) {
Log::debug('unifi.sites_found', ['path' => $path, 'count' => count($data)]);
return $data;
}
$lastError = "No sites in response from {$path}";
} else {
$lastError = "HTTP {$response->status()} on {$path}";
}
} catch (\Throwable $e) {
$lastError = "{$path}: {$e->getMessage()}";
}
}
throw new \RuntimeException("Could not list sites. {$lastError}");
}
}

View File

@@ -0,0 +1,497 @@
<?php
namespace Dashboard\Unifi\Services;
use Dashboard\Unifi\Models\DeviceState;
use Dashboard\Unifi\Models\WebhookConfig;
use Dashboard\Unifi\Models\WebhookLog;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class WebhookCheckService
{
public const EVENTS = [
'device_offline' => 'A UniFi device goes offline',
'device_online' => 'A UniFi device comes back online',
'client_offline' => 'A tracked client (by MAC) disconnects',
'client_online' => 'A tracked client (by MAC) connects',
'wan_down' => 'Internet / WAN goes down',
'wan_up' => 'Internet / WAN comes back up',
'client_count_high' => 'AP client count exceeds threshold',
'cu_high' => 'Channel utilization exceeds threshold',
'satisfaction_low' => 'WiFi experience drops below threshold',
'high_error_rate' => 'High retry/drop rate on an AP',
'firmware_available' => 'AP firmware update available',
'ap_unexpected_reboot' => 'AP rebooted unexpectedly (uptime reset)',
];
/**
* Available template variables per event type.
*/
public const TEMPLATE_VARS = [
'device_offline' => ['{{device}}', '{{mac}}', '{{timestamp}}'],
'device_online' => ['{{device}}', '{{mac}}', '{{timestamp}}'],
'client_offline' => ['{{client_name}}', '{{mac}}', '{{last_ap}}', '{{last_ssid}}', '{{timestamp}}'],
'client_online' => ['{{client_name}}', '{{mac}}', '{{ap}}', '{{ssid}}', '{{ip}}', '{{os}}', '{{timestamp}}'],
'wan_down' => ['{{timestamp}}'],
'wan_up' => ['{{timestamp}}'],
'client_count_high' => ['{{device}}', '{{mac}}', '{{clients}}', '{{threshold}}', '{{timestamp}}'],
'cu_high' => ['{{device}}', '{{mac}}', '{{radio}}', '{{cu}}', '{{threshold}}', '{{timestamp}}'],
'satisfaction_low' => ['{{device}}', '{{mac}}', '{{satisfaction}}', '{{threshold}}', '{{timestamp}}'],
'high_error_rate' => ['{{device}}', '{{mac}}', '{{retries}}', '{{timestamp}}'],
'firmware_available' => ['{{device}}', '{{mac}}', '{{version}}', '{{timestamp}}'],
'ap_unexpected_reboot' => ['{{device}}', '{{mac}}', '{{uptime}}', '{{timestamp}}'],
];
public const DEFAULT_TEMPLATES = [
'device_offline' => '🔴 {{device}} ({{mac}}) has gone offline',
'device_online' => '🟢 {{device}} ({{mac}}) is back online',
'client_offline' => '📴 Client {{client_name}} ({{mac}}) disconnected from {{last_ap}} / {{last_ssid}}',
'client_online' => '📱 Client {{client_name}} ({{mac}}) connected to {{ap}} / {{ssid}} — IP: {{ip}}, OS: {{os}}',
'wan_down' => '🔴 Internet connection is DOWN',
'wan_up' => '🟢 Internet connection restored',
'client_count_high' => '⚠️ {{device}}: {{clients}} clients connected (threshold: {{threshold}})',
'cu_high' => '⚠️ {{device}} ({{radio}}): {{cu}}% channel utilization (threshold: {{threshold}}%)',
'satisfaction_low' => '⚠️ {{device}}: WiFi experience {{satisfaction}}% (threshold: {{threshold}}%)',
'high_error_rate' => '⚠️ {{device}}: high retry count ({{retries}})',
'firmware_available' => '🔄 {{device}}: firmware update available (current: {{version}})',
'ap_unexpected_reboot' => '🔄 {{device}}: unexpected reboot (uptime: {{uptime}}s)',
];
public function checkAll(UnifiApiClient $unifi): int
{
$configs = WebhookConfig::where('is_active', true)->get();
if ($configs->isEmpty()) return 0;
try {
$devices = $unifi->getDevices();
$health = $unifi->getSiteHealth();
} catch (\Throwable $e) {
Log::warning('unifi.webhook_check_failed', ['error' => $e->getMessage()]);
return 0;
}
// Fetch active clients only if any config tracks client events
$activeClients = null;
$needsClients = $configs->contains(fn ($c) => array_intersect($c->events ?? [], ['client_offline', 'client_online']));
if ($needsClients) {
try { $activeClients = $unifi->getActiveClients(); } catch (\Throwable) { $activeClients = []; }
}
$wan = collect($health)->firstWhere('subsystem', 'wan');
$aps = collect($devices)->where('type', 'uap');
$fired = 0;
foreach ($configs as $config) {
$events = $config->events ?? [];
$thresholds = $config->thresholds ?? [];
$filter = $config->device_filter ?? [];
$templates = $config->templates ?? [];
$clientMacs = $config->tracked_clients ?? [];
foreach ($events as $event) {
$alerts = match ($event) {
'device_offline' => $this->checkDeviceTransition($devices, $filter, false),
'device_online' => $this->checkDeviceTransition($devices, $filter, true),
'client_offline' => $this->checkClientTransition($activeClients ?? [], $clientMacs, false),
'client_online' => $this->checkClientTransition($activeClients ?? [], $clientMacs, true),
'wan_down' => $this->checkWan($wan, false),
'wan_up' => $this->checkWan($wan, true),
'client_count_high' => $this->checkClientCount($aps, $thresholds['client_count'] ?? 50, $filter),
'cu_high' => $this->checkCu($aps, $thresholds['cu_threshold'] ?? 80, $thresholds['cu_band'] ?? null, $filter),
'satisfaction_low' => $this->checkSatisfaction($aps, $thresholds['satisfaction_min'] ?? 50, $filter),
'high_error_rate' => $this->checkErrorRate($aps, $filter),
'firmware_available' => $this->checkFirmware($devices, $filter),
'ap_unexpected_reboot' => $this->checkReboot($aps, $filter),
default => [],
};
foreach ($alerts as $alert) {
if ($this->isInCooldown($config, $event, $alert['key'] ?? '')) continue;
// Apply message template
$alert['message'] = $this->applyTemplate($event, $alert, $templates[$event] ?? null);
$this->fire($config, $event, $alert);
$fired++;
}
}
}
$this->syncDeviceStates($devices);
if ($activeClients !== null) $this->syncClientStates($activeClients);
return $fired;
}
// ── Client tracking ───────────────────────────────────────────────────────
private function checkClientTransition(?array $clients, array $trackedMacs, bool $comingOnline): array
{
if (empty($trackedMacs) || $clients === null) return [];
$connectedMacs = collect($clients)->pluck('mac')->map(fn ($m) => strtolower($m))->all();
$alerts = [];
foreach ($trackedMacs as $entry) {
$mac = strtolower(is_array($entry) ? ($entry['mac'] ?? '') : $entry);
$name = is_array($entry) ? ($entry['name'] ?? $mac) : $mac;
if (! $mac) continue;
$prev = DeviceState::where('device_mac', $mac)->first();
$isOnline = in_array($mac, $connectedMacs);
if (! $prev) continue;
$clientInfo = collect($clients)->firstWhere('mac', $mac);
// Fire on the 2nd consecutive observation of the new state.
// Check runs BEFORE sync — when count == 1 here, this poll is the 2nd consecutive
// miss/hit, and sync will flip was_online after we fire.
if ($comingOnline && $prev->was_online === false && $isOnline && $prev->consecutive_count >= 1) { // 2nd consecutive poll online
$alerts[] = [
'key' => $mac,
'client_name' => $name,
'mac' => $mac,
'ap' => $clientInfo['ap_mac'] ?? '',
'ssid' => $clientInfo['essid'] ?? '',
'ip' => $clientInfo['ip'] ?? '',
'os' => $clientInfo['os_name'] ?? '',
'message' => "{$name} ({$mac}) connected",
];
}
if (! $comingOnline && $prev->was_online === true && ! $isOnline && $prev->consecutive_count >= 2) { // 3rd consecutive poll offline
$alerts[] = [
'key' => $mac,
'client_name' => $name,
'mac' => $mac,
'last_ap' => $prev->device_name ?? '',
'last_ssid' => '',
'message' => "{$name} ({$mac}) disconnected",
];
}
}
return $alerts;
}
private function syncClientStates(array $clients): void
{
$connectedMacs = collect($clients)->pluck('mac')->map(fn ($m) => strtolower($m))->all();
// Update tracked client states
$tracked = DeviceState::whereNotNull('device_mac')
->where('device_mac', 'NOT LIKE', '%:%:%:%:%:%') // skip MAC format check — just update all
->get();
// Actually, we store client MACs in the same table. Let's just upsert for all connected clients
// that are in tracked_clients lists
$allTracked = WebhookConfig::where('is_active', true)
->whereJsonLength('tracked_clients', '>', 0)
->get()
->flatMap(fn ($c) => collect($c->tracked_clients)->map(fn ($e) => strtolower(is_array($e) ? ($e['mac'] ?? '') : $e)))
->unique()
->filter();
foreach ($allTracked as $mac) {
$isOnline = in_array($mac, $connectedMacs);
$prev = DeviceState::where('device_mac', $mac)->first();
if (! $prev) {
DeviceState::create(['device_mac' => $mac, 'was_online' => $isOnline, 'consecutive_count' => 1,
'last_seen_at' => now(), 'updated_at' => now()]);
continue;
}
if ($prev->was_online === $isOnline) {
$prev->update(['consecutive_count' => 0, 'last_seen_at' => now(), 'updated_at' => now()]);
} else {
$count = $prev->consecutive_count + 1;
$grace = $isOnline ? 2 : 3;
if ($count >= $grace) {
// Confirmed — flip and reset counter
$prev->update(['was_online' => $isOnline, 'consecutive_count' => 0,
'last_seen_at' => now(), 'updated_at' => now()]);
} else {
$prev->update(['consecutive_count' => $count, 'last_seen_at' => now(), 'updated_at' => now()]);
}
}
}
}
// ── Device checks (existing) ──────────────────────────────────────────────
/**
* Check device online/offline transitions.
* Runs BEFORE syncDeviceStates — reads state from the previous poll's sync.
*
* Offline: requires 3 consecutive offline polls (count >= 2) before firing.
* Online: requires 2 consecutive online polls (count >= 1) and was_online must
* already be false (i.e., offline was confirmed) — prevents "back online"
* alerts for devices that blipped and recovered within the offline grace window.
*
* After a confirmed transition, syncDeviceStates resets consecutive_count to 0,
* so the reverse direction must also accumulate from scratch.
*/
private function checkDeviceTransition(array $devices, array $filter, bool $comingOnline): array
{
$alerts = [];
foreach ($devices as $dev) {
$mac = $dev['mac'];
if (! empty($filter) && ! in_array($mac, $filter)) continue;
$name = $dev['name'] ?? $dev['model'] ?? $mac;
$isOnline = ($dev['state'] ?? 0) == 1;
$prev = DeviceState::where('device_mac', $mac)->first();
if (! $prev) continue;
// Online: fires on the 2nd consecutive poll back (count >= 1).
// Only fires if was_online=false, which only happens after offline was confirmed —
// so this naturally prevents spurious "back online" alerts for brief blips.
if ($comingOnline && $prev->was_online === false && $isOnline && $prev->consecutive_count >= 1)
$alerts[] = ['key' => $mac, 'device' => $name, 'mac' => $mac, 'message' => "{$name} is back online"];
// Offline: fires on the 3rd consecutive offline poll (count >= 2).
// Requires 3 consecutive misses (~90s at 30s interval) to avoid false positives
// from transient controller communication gaps (UPS, cameras, etc.).
if (! $comingOnline && $prev->was_online === true && ! $isOnline && $prev->consecutive_count >= 2)
$alerts[] = ['key' => $mac, 'device' => $name, 'mac' => $mac, 'message' => "{$name} has gone offline"];
}
return $alerts;
}
private function checkWan(?array $wan, bool $comingUp): array
{
$status = $wan['status'] ?? 'unknown';
$prevKey = 'unifi:wan_was_ok';
$wasOk = cache()->get($prevKey, true);
$isOk = $status === 'ok';
cache()->put($prevKey, $isOk, 3600);
if ($comingUp && ! $wasOk && $isOk) return [['key' => 'wan', 'message' => 'Internet connection restored']];
if (! $comingUp && $wasOk && ! $isOk) return [['key' => 'wan', 'message' => 'Internet connection is down']];
return [];
}
private function checkClientCount($aps, int $threshold, array $filter): array
{
$alerts = [];
foreach ($aps as $ap) {
if (! empty($filter) && ! in_array($ap['mac'], $filter)) continue;
$count = $ap['num_sta'] ?? 0;
if ($count >= $threshold) {
$name = $ap['name'] ?? $ap['mac'];
$alerts[] = ['key' => $ap['mac'], 'device' => $name, 'mac' => $ap['mac'], 'clients' => $count, 'threshold' => $threshold, 'message' => "{$name}: {$count} clients"];
}
}
return $alerts;
}
private function checkCu($aps, int $threshold, ?string $band, array $filter): array
{
$alerts = [];
foreach ($aps as $ap) {
if (! empty($filter) && ! in_array($ap['mac'], $filter)) continue;
foreach ($ap['radio_table_stats'] ?? [] as $radio) {
if ($band && ($radio['radio'] ?? '') !== $band) continue;
$cu = $radio['cu_total'] ?? 0;
if ($cu >= $threshold) {
$name = $ap['name'] ?? $ap['mac'];
$rName = $radio['radio'] ?? '?';
$alerts[] = ['key' => $ap['mac'].':'.$rName, 'device' => $name, 'mac' => $ap['mac'], 'radio' => $rName, 'cu' => $cu, 'threshold' => $threshold, 'message' => "{$name} ({$rName}): {$cu}% CU"];
}
}
}
return $alerts;
}
private function checkSatisfaction($aps, int $minSat, array $filter): array
{
$alerts = [];
foreach ($aps as $ap) {
if (! empty($filter) && ! in_array($ap['mac'], $filter)) continue;
$sat = $ap['satisfaction'] ?? null;
if ($sat !== null && $sat >= 0 && $sat < $minSat) {
$name = $ap['name'] ?? $ap['mac'];
$alerts[] = ['key' => $ap['mac'], 'device' => $name, 'mac' => $ap['mac'], 'satisfaction' => $sat, 'threshold' => $minSat, 'message' => "{$name}: {$sat}% experience"];
}
}
return $alerts;
}
private function checkErrorRate($aps, array $filter): array
{
$alerts = [];
foreach ($aps as $ap) {
if (! empty($filter) && ! in_array($ap['mac'], $filter)) continue;
$retries = $ap['stat']['ap']['tx_retries'] ?? 0;
if ($retries > 1000) {
$name = $ap['name'] ?? $ap['mac'];
$alerts[] = ['key' => $ap['mac'], 'device' => $name, 'mac' => $ap['mac'], 'retries' => $retries, 'message' => "{$name}: high retries ({$retries})"];
}
}
return $alerts;
}
private function checkFirmware(array $devices, array $filter): array
{
$alerts = [];
foreach ($devices as $dev) {
if (! empty($filter) && ! in_array($dev['mac'], $filter)) continue;
if ($dev['upgradable'] ?? false) {
$name = $dev['name'] ?? $dev['mac'];
$alerts[] = ['key' => $dev['mac'], 'device' => $name, 'mac' => $dev['mac'], 'version' => $dev['version'] ?? '', 'message' => "{$name}: firmware update available"];
}
}
return $alerts;
}
private function checkReboot($aps, array $filter): array
{
$alerts = [];
foreach ($aps as $ap) {
if (! empty($filter) && ! in_array($ap['mac'], $filter)) continue;
$uptime = $ap['uptime'] ?? 0;
if ($uptime > 0 && $uptime < 300) {
$prev = DeviceState::where('device_mac', $ap['mac'])->first();
if ($prev && $prev->was_online) {
$name = $ap['name'] ?? $ap['mac'];
$alerts[] = ['key' => $ap['mac'], 'device' => $name, 'mac' => $ap['mac'], 'uptime' => $uptime, 'message' => "{$name}: unexpected reboot"];
}
}
}
return $alerts;
}
// ── Template + Cooldown + Firing ──────────────────────────────────────────
private function applyTemplate(string $event, array $data, ?string $customTemplate): string
{
$template = $customTemplate ?: (self::DEFAULT_TEMPLATES[$event] ?? '{{message}}');
$data['timestamp'] = now()->toIso8601String();
return preg_replace_callback('/\{\{(\w+)\}\}/', function ($matches) use ($data) {
return $data[$matches[1]] ?? $matches[0];
}, $template);
}
private function isInCooldown(WebhookConfig $config, string $event, string $key): bool
{
return WebhookLog::where('webhook_config_id', $config->id)
->where('event_type', $event)
->where('fired_at', '>=', now()->subMinutes($config->cooldown_minutes))
->whereJsonContains('payload->data->key', $key)
->exists();
}
private function fire(WebhookConfig $config, string $event, array $data): void
{
$message = $data['message'] ?? $event;
$internalPayload = [
'event' => $event,
'timestamp' => now()->toIso8601String(),
'message' => $message,
'data' => $data,
];
// Format payload for the target platform
$url = $config->url;
$payload = $this->formatPayloadForPlatform($url, $message, $internalPayload);
$headers = ['Content-Type' => 'application/json'];
if ($config->secret) {
$headers['X-Webhook-Signature'] = hash_hmac('sha256', json_encode($internalPayload), $config->secret);
}
$log = ['webhook_config_id' => $config->id, 'event_type' => $event, 'payload' => $internalPayload, 'fired_at' => now()];
try {
$response = Http::withHeaders($headers)->timeout(10)->post($url, $payload);
$log['response_code'] = $response->status();
$log['response_body'] = substr($response->body(), 0, 1000);
} catch (\Throwable $e) {
$log['response_code'] = 0;
$log['response_body'] = $e->getMessage();
}
WebhookLog::create($log);
}
/**
* Detect the webhook platform from the URL and format the payload accordingly.
*/
private function formatPayloadForPlatform(string $url, string $message, array $fullPayload): array
{
// Google Chat
if (str_contains($url, 'chat.googleapis.com')) {
return ['text' => $message];
}
// Slack
if (str_contains($url, 'hooks.slack.com')) {
return ['text' => $message];
}
// Discord
if (str_contains($url, 'discord.com/api/webhooks')) {
return ['content' => $message];
}
// Microsoft Teams
if (str_contains($url, 'webhook.office.com') || str_contains($url, 'workflows.office.com')) {
return ['text' => $message];
}
// Generic / custom — send the full structured payload
return $fullPayload;
}
/**
* Sync device states with consecutive_count for debouncing.
*
* Same state → reset counter to 0 (stable, no change pending)
* Different state → increment counter; flip was_online only once the grace threshold
* is reached, then RESET counter to 0 so the opposite direction must
* also accumulate from scratch.
*
* Thresholds (must match checkDeviceTransition, which fires one poll before the flip):
* Going offline: grace = 3 polls (check fires at count >= 2, flip at count+1 >= 3)
* Coming online: grace = 2 polls (check fires at count >= 1, flip at count+1 >= 2)
*/
private function syncDeviceStates(array $devices): void
{
foreach ($devices as $dev) {
$mac = $dev['mac'];
$isOnline = ($dev['state'] ?? 0) == 1;
$prev = DeviceState::where('device_mac', $mac)->first();
if (! $prev) {
DeviceState::create([
'device_mac' => $mac, 'device_name' => $dev['name'] ?? $dev['model'] ?? null,
'was_online' => $isOnline, 'consecutive_count' => 0,
'last_seen_at' => now(), 'updated_at' => now(),
]);
continue;
}
$prevState = $prev->was_online;
if ($prevState === $isOnline) {
// Same as confirmed state — reset counter (no pending transition)
$prev->update(['consecutive_count' => 0, 'last_seen_at' => now(), 'updated_at' => now(),
'device_name' => $dev['name'] ?? $dev['model'] ?? $prev->device_name]);
} else {
// Moving toward a state change — accumulate consecutive count
$count = $prev->consecutive_count + 1;
// Grace threshold: 3 polls to confirm going offline, 2 to confirm coming online
$grace = $isOnline ? 2 : 3;
if ($count >= $grace) {
// Confirmed — flip was_online and RESET counter so the reverse direction
// must also accumulate from zero (prevents immediate back-and-forth firing)
$prev->update(['was_online' => $isOnline, 'consecutive_count' => 0,
'last_seen_at' => now(), 'updated_at' => now(),
'device_name' => $dev['name'] ?? $dev['model'] ?? $prev->device_name]);
} else {
// Not yet confirmed — just bump counter, keep was_online unchanged
$prev->update(['consecutive_count' => $count, 'last_seen_at' => now(), 'updated_at' => now()]);
}
}
}
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace Dashboard\Unifi;
use Illuminate\Support\ServiceProvider;
class UnifiServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->mergeConfigFrom(__DIR__ . '/../config/unifi.php', 'unifi');
$this->app->singleton(Services\UnifiApiClient::class, function ($app) {
return new Services\UnifiApiClient();
});
}
public function boot(): void
{
$this->loadRoutesFrom(__DIR__ . '/routes/unifi.php');
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
if ($this->app->runningInConsole()) {
$this->commands([
Console\CheckWebhooks::class,
Console\CaptureSnapshots::class,
Console\CleanupSnapshots::class,
]);
$this->publishes([
__DIR__ . '/../config/unifi.php' => config_path('unifi.php'),
], 'unifi-config');
}
}
}

67
src/routes/unifi.php Normal file
View File

@@ -0,0 +1,67 @@
<?php
use Dashboard\Unifi\Http\Controllers\ClientController;
use Dashboard\Unifi\Http\Controllers\DeviceController;
use Dashboard\Unifi\Http\Controllers\PortalController;
use Dashboard\Unifi\Http\Controllers\StatsController;
use Dashboard\Unifi\Http\Controllers\UnifiSettingsController;
use Dashboard\Unifi\Http\Controllers\WebhookController;
use Dashboard\Unifi\Http\Controllers\WifiController;
use Illuminate\Support\Facades\Route;
Route::middleware(['web', 'auth', 'app.access:unifi'])
->prefix('app/network')
->name('unifi.')
->group(function () {
// ── Stats (read-only) ────────────────────────────────────────────────
Route::middleware('permission:unifi.stats')->group(function () {
Route::get('/', [StatsController::class, 'dashboard'])->name('dashboard');
Route::get('/fullscreen', [StatsController::class, 'dashboard'])->name('dashboard.fullscreen')->defaults('fullscreen', true);
Route::get('/wan-status', [StatsController::class, 'wanStatus'])->name('wan.status');
Route::get('/devices', [DeviceController::class, 'index']) ->name('devices');
Route::get('/clients', [ClientController::class, 'index']) ->name('clients');
Route::get('/client-dashboard',[StatsController::class, 'clientDashboard'])->name('client.dashboard');
});
// ── Management (write access) ────────────────────────────────────────
Route::middleware('permission:unifi.manage')->group(function () {
Route::get('/wifi', [WifiController::class, 'index']) ->name('wifi');
Route::put('/wifi/{wlanId}', [WifiController::class, 'update']) ->name('wifi.update');
Route::post('/wifi/{wlanId}/toggle', [WifiController::class, 'toggle'])->name('wifi.toggle');
Route::post('/wifi/groups', [WifiController::class, 'saveGroups'])->name('wifi.groups');
Route::post('/devices/reboot', [DeviceController::class, 'reboot']) ->name('devices.reboot');
Route::post('/clients/kick', [ClientController::class, 'kick']) ->name('clients.kick');
});
// ── Portal auth ──────────────────────────────────────────────────────
Route::middleware('permission:unifi.auth')->group(function () {
Route::get('/portal', [PortalController::class, 'settings']) ->name('portal.settings');
Route::post('/portal/mappings', [PortalController::class, 'storeMapping']) ->name('portal.mappings.store');
Route::put('/portal/mappings/{mapping}', [PortalController::class, 'updateMapping']) ->name('portal.mappings.update');
Route::delete('/portal/mappings/{mapping}', [PortalController::class, 'destroyMapping'])->name('portal.mappings.destroy');
Route::post('/portal/macs', [PortalController::class, 'storeMac']) ->name('portal.macs.store');
Route::delete('/portal/macs/{mac}', [PortalController::class, 'destroyMac']) ->name('portal.macs.destroy');
Route::post('/portal/sessions/{session}/disconnect', [PortalController::class, 'disconnectSession'])->name('portal.sessions.disconnect');
});
// ── Settings ─────────────────────────────────────────────────────────
Route::middleware('permission:unifi.settings')->group(function () {
Route::get('/settings', [UnifiSettingsController::class, 'edit']) ->name('settings');
Route::post('/settings', [UnifiSettingsController::class, 'update']) ->name('settings.update');
Route::post('/settings/test', [UnifiSettingsController::class, 'testConnection'])->name('settings.test');
Route::post('/settings/sites', [UnifiSettingsController::class, 'fetchSites']) ->name('settings.sites');
// Webhooks
Route::get('/webhooks', [WebhookController::class, 'index']) ->name('webhooks.index');
Route::post('/webhooks', [WebhookController::class, 'store']) ->name('webhooks.store');
Route::put('/webhooks/{webhook}', [WebhookController::class, 'update']) ->name('webhooks.update');
Route::delete('/webhooks/{webhook}', [WebhookController::class, 'destroy'])->name('webhooks.destroy');
Route::post('/webhooks/{webhook}/test', [WebhookController::class, 'test']) ->name('webhooks.test');
});
});
// ── Captive portal callback (public — user redirected here by UniFi) ─────
Route::middleware(['web', 'auth'])
->get('/portal/wifi/callback', [PortalController::class, 'captiveCallback'])
->name('unifi.portal.callback');