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

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