* The Test URL button was POSTing a generic {event, timestamp, data}
envelope to every endpoint. Google Chat / Slack / Discord / Teams
reject anything that isn't their specific shape — so a successful
Laravel request still got a 400 back from the platform, making the
test look broken. The real webhook events already handle this via
WebhookCheckService::formatPayloadForPlatform; that helper is now
exposed as a public static (buildPlatformPayload) and the test
endpoint uses the same code path, so the test exercises the same
format real events will.
* unifi_device_states was missing a consecutive_count column the
WebhookCheckService inserts on every snapshot capture. The scheduler
was throwing "Unknown column 'consecutive_count'" once a minute.
Added an idempotent migration.
v1.6.1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
702 lines
34 KiB
PHP
702 lines
34 KiB
PHP
<?php
|
|
|
|
namespace Dashboard\Unifi\Services;
|
|
|
|
use Dashboard\Unifi\Models\DeviceState;
|
|
use Dashboard\Unifi\Models\WebhookConfig;
|
|
use Dashboard\Unifi\Models\WebhookLog;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class WebhookCheckService
|
|
{
|
|
public const EVENTS = [
|
|
// ── Device presence ──────────────────────────────────────────────────────
|
|
'device_offline' => 'A UniFi device goes offline',
|
|
'device_online' => 'A UniFi device comes back online',
|
|
|
|
// ── Client tracking ──────────────────────────────────────────────────────
|
|
'client_offline' => 'A tracked client (by MAC) disconnects',
|
|
'client_online' => 'A tracked client (by MAC) connects',
|
|
|
|
// ── WAN ──────────────────────────────────────────────────────────────────
|
|
'wan_down' => 'Internet / WAN goes down',
|
|
'wan_up' => 'Internet / WAN comes back up',
|
|
|
|
// ── Threshold alerts (fire on entry into alert state) ────────────────────
|
|
'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',
|
|
|
|
// ── Threshold resolved (fire on exit from alert state) ───────────────────
|
|
'client_count_normal' => 'AP client count returns to normal',
|
|
'cu_normal' => 'Channel utilization returns to normal',
|
|
'satisfaction_normal' => 'WiFi experience returns to normal',
|
|
'error_rate_normal' => 'Error rate returns to normal',
|
|
|
|
// ── Informational ────────────────────────────────────────────────────────
|
|
'firmware_available' => 'AP firmware update available',
|
|
'ap_unexpected_reboot' => 'AP rebooted unexpectedly (uptime reset)',
|
|
];
|
|
|
|
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}}'],
|
|
'client_count_normal' => ['{{device}}', '{{mac}}', '{{clients}}', '{{threshold}}', '{{timestamp}}'],
|
|
'cu_high' => ['{{device}}', '{{mac}}', '{{radio}}', '{{cu}}', '{{threshold}}', '{{timestamp}}'],
|
|
'cu_normal' => ['{{device}}', '{{mac}}', '{{radio}}', '{{cu}}', '{{threshold}}', '{{timestamp}}'],
|
|
'satisfaction_low' => ['{{device}}', '{{mac}}', '{{satisfaction}}', '{{threshold}}', '{{timestamp}}'],
|
|
'satisfaction_normal' => ['{{device}}', '{{mac}}', '{{satisfaction}}', '{{threshold}}', '{{timestamp}}'],
|
|
'high_error_rate' => ['{{device}}', '{{mac}}', '{{retries}}', '{{timestamp}}'],
|
|
'error_rate_normal' => ['{{device}}', '{{mac}}', '{{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}})',
|
|
'client_count_normal' => '✅ {{device}}: client count returned to normal ({{clients}} clients)',
|
|
'cu_high' => '⚠️ {{device}} ({{radio}}): {{cu}}% channel utilization (threshold: {{threshold}}%)',
|
|
'cu_normal' => '✅ {{device}} ({{radio}}): channel utilization returned to normal ({{cu}}%)',
|
|
'satisfaction_low' => '⚠️ {{device}}: WiFi experience {{satisfaction}}% (threshold: {{threshold}}%)',
|
|
'satisfaction_normal' => '✅ {{device}}: WiFi experience returned to normal ({{satisfaction}}%)',
|
|
'high_error_rate' => '⚠️ {{device}}: high retry count ({{retries}})',
|
|
'error_rate_normal' => '✅ {{device}}: error rate returned to normal',
|
|
'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;
|
|
}
|
|
|
|
$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 ?? [];
|
|
$cid = $config->id;
|
|
|
|
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, $cid),
|
|
'client_count_normal' => $this->checkClientCountResolved($aps, $thresholds['client_count'] ?? 50, $filter, $cid),
|
|
'cu_high' => $this->checkCu($aps, $thresholds['cu_threshold'] ?? 80, $thresholds['cu_band'] ?? null, $filter, $cid, (int) ($thresholds['cu_sustain'] ?? 3)),
|
|
'cu_normal' => $this->checkCuResolved($aps, $thresholds['cu_threshold'] ?? 80, $thresholds['cu_band'] ?? null, $filter, $cid),
|
|
'satisfaction_low' => $this->checkSatisfaction($aps, $thresholds['satisfaction_min'] ?? 50, $filter, $cid),
|
|
'satisfaction_normal' => $this->checkSatisfactionResolved($aps, $thresholds['satisfaction_min'] ?? 50, $filter, $cid),
|
|
'high_error_rate' => $this->checkErrorRate($aps, $filter, $cid),
|
|
'error_rate_normal' => $this->checkErrorRateResolved($aps, $filter, $cid),
|
|
'firmware_available' => $this->checkFirmware($devices, $filter),
|
|
'ap_unexpected_reboot'=> $this->checkReboot($aps, $filter),
|
|
default => [],
|
|
};
|
|
|
|
foreach ($alerts as $alert) {
|
|
// Extract internal metadata before cooldown/fire
|
|
$deviceStateUpdate = $alert['_device_state_update'] ?? null;
|
|
unset($alert['_device_state_update']);
|
|
|
|
if ($this->isInCooldown($config, $event, $alert['key'] ?? '')) continue;
|
|
|
|
$alert['message'] = $this->applyTemplate($event, $alert, $templates[$event] ?? null);
|
|
$this->fire($config, $event, $alert);
|
|
$fired++;
|
|
|
|
// Update device in_alert state — only AFTER confirmed firing (not if suppressed by cooldown)
|
|
if ($deviceStateUpdate !== null) {
|
|
[$stateModel, $inAlert] = $deviceStateUpdate;
|
|
$stateModel->update(['in_alert' => $inAlert]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
$this->syncDeviceStates($devices);
|
|
if ($activeClients !== null) $this->syncClientStates($activeClients);
|
|
|
|
return $fired;
|
|
}
|
|
|
|
// ── Device transitions ────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Checks device online/offline transitions.
|
|
*
|
|
* Offline (2-poll grace): fires when a device has been offline for 2 consecutive polls
|
|
* AND no active offline alert is already outstanding (in_alert = false).
|
|
* Setting in_alert=true is deferred until after the alert is confirmed not suppressed by cooldown.
|
|
*
|
|
* Online ("resolved"): fires when a device has been online for 2 consecutive polls
|
|
* AND an active offline alert was previously sent (in_alert = true).
|
|
* This prevents orphan "back online" notifications with no preceding "offline".
|
|
*/
|
|
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;
|
|
|
|
// Skip planned reboots — these are intentional, not alerts
|
|
if (Cache::has('unifi:planned_reboot:' . strtolower($mac))) continue;
|
|
|
|
if ($comingOnline) {
|
|
// Online: 2nd consecutive online poll, and we previously sent an offline alert
|
|
if ($prev->was_online === false && $isOnline && $prev->consecutive_count >= 1 && $prev->in_alert) {
|
|
$alerts[] = [
|
|
'key' => $mac,
|
|
'device' => $name,
|
|
'mac' => $mac,
|
|
'message' => "{$name} is back online",
|
|
'_device_state_update' => [$prev, false], // clear in_alert after fire
|
|
];
|
|
}
|
|
} else {
|
|
// Offline: 2nd consecutive offline poll, no active alert already outstanding
|
|
if ($prev->was_online === true && ! $isOnline && $prev->consecutive_count >= 1 && ! $prev->in_alert) {
|
|
$alerts[] = [
|
|
'key' => $mac,
|
|
'device' => $name,
|
|
'mac' => $mac,
|
|
'message' => "{$name} has gone offline",
|
|
'_device_state_update' => [$prev, true], // set in_alert after fire
|
|
];
|
|
}
|
|
}
|
|
}
|
|
return $alerts;
|
|
}
|
|
|
|
// ── 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);
|
|
|
|
if ($comingOnline && $prev->was_online === false && $isOnline && $prev->consecutive_count >= 1) {
|
|
$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) {
|
|
$alerts[] = [
|
|
'key' => $mac,
|
|
'client_name' => $name,
|
|
'mac' => $mac,
|
|
'last_ap' => $prev->device_name ?? '',
|
|
'last_ssid' => '',
|
|
'message' => "{$name} ({$mac}) disconnected",
|
|
];
|
|
}
|
|
}
|
|
return $alerts;
|
|
}
|
|
|
|
// ── WAN ──────────────────────────────────────────────────────────────────
|
|
|
|
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 [];
|
|
}
|
|
|
|
// ── Threshold checks (alert on entry, resolved on exit) ──────────────────
|
|
|
|
/**
|
|
* Client count — alert when exceeding threshold (only once per alert state entry).
|
|
*/
|
|
private function checkClientCount($aps, int $threshold, array $filter, int $configId): array
|
|
{
|
|
$alerts = [];
|
|
foreach ($aps as $ap) {
|
|
if (! empty($filter) && ! in_array($ap['mac'], $filter)) continue;
|
|
$count = $ap['num_sta'] ?? 0;
|
|
$cacheKey = "unifi:alert:{$configId}:client_count:{$ap['mac']}";
|
|
if ($count >= $threshold) {
|
|
if (! Cache::has($cacheKey)) {
|
|
Cache::put($cacheKey, $threshold, now()->addHours(4));
|
|
$name = $ap['name'] ?? $ap['mac'];
|
|
$alerts[] = ['key' => $ap['mac'], 'device' => $name, 'mac' => $ap['mac'],
|
|
'clients' => $count, 'threshold' => $threshold, 'message' => "{$name}: {$count} clients"];
|
|
} else {
|
|
Cache::put($cacheKey, $threshold, now()->addHours(4)); // refresh TTL
|
|
}
|
|
}
|
|
}
|
|
return $alerts;
|
|
}
|
|
|
|
/**
|
|
* Client count — resolved when dropping back below threshold.
|
|
* Only fires if the cached threshold matches the current config (prevents spurious
|
|
* resolved alerts after the threshold is raised).
|
|
*/
|
|
private function checkClientCountResolved($aps, int $threshold, array $filter, int $configId): array
|
|
{
|
|
$alerts = [];
|
|
foreach ($aps as $ap) {
|
|
if (! empty($filter) && ! in_array($ap['mac'], $filter)) continue;
|
|
$count = $ap['num_sta'] ?? 0;
|
|
$cacheKey = "unifi:alert:{$configId}:client_count:{$ap['mac']}";
|
|
$cached = Cache::get($cacheKey);
|
|
if ($count < $threshold && $cached !== null) {
|
|
Cache::forget($cacheKey);
|
|
if ((int) $cached === $threshold) {
|
|
// Only alert if this is the same threshold that originally triggered
|
|
$name = $ap['name'] ?? $ap['mac'];
|
|
$alerts[] = ['key' => $ap['mac'], 'device' => $name, 'mac' => $ap['mac'],
|
|
'clients' => $count, 'threshold' => $threshold, 'message' => "{$name}: client count returned to normal ({$count})"];
|
|
}
|
|
}
|
|
}
|
|
return $alerts;
|
|
}
|
|
|
|
/**
|
|
* Channel utilization — alert on entry, with sustain guard.
|
|
*
|
|
* CU must stay above threshold for $sustainPolls consecutive poll cycles before
|
|
* the alert fires. A pending counter cache key tracks progress toward that threshold.
|
|
* The counter is cleared as soon as CU drops back below threshold.
|
|
*/
|
|
private function checkCu($aps, int $threshold, ?string $band, array $filter, int $configId, int $sustainPolls = 3): 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;
|
|
$rName = $radio['radio'] ?? '?';
|
|
$alertKey = "unifi:alert:{$configId}:cu:{$ap['mac']}:{$rName}";
|
|
$pendingKey = "unifi:alert:{$configId}:cu_pending:{$ap['mac']}:{$rName}";
|
|
|
|
if ($cu >= $threshold) {
|
|
if (Cache::has($alertKey)) {
|
|
Cache::put($alertKey, $threshold, now()->addHours(4)); // refresh TTL while still in alert
|
|
} else {
|
|
$count = (int) Cache::get($pendingKey, 0) + 1;
|
|
Cache::put($pendingKey, $count, now()->addHours(1));
|
|
|
|
if ($count >= $sustainPolls) {
|
|
Cache::put($alertKey, $threshold, now()->addHours(4));
|
|
Cache::forget($pendingKey);
|
|
$name = $ap['name'] ?? $ap['mac'];
|
|
$alerts[] = ['key' => $ap['mac'].':'.$rName, 'device' => $name, 'mac' => $ap['mac'],
|
|
'radio' => $rName, 'cu' => $cu, 'threshold' => $threshold, 'message' => "{$name} ({$rName}): {$cu}% CU"];
|
|
}
|
|
}
|
|
} else {
|
|
Cache::forget($pendingKey); // reset sustain counter when CU drops below threshold
|
|
}
|
|
}
|
|
}
|
|
return $alerts;
|
|
}
|
|
|
|
/**
|
|
* Channel utilization — resolved on exit.
|
|
* Also clears the pending sustain counter so it doesn't carry over to the next alert cycle.
|
|
* Only fires the resolved alert when the cached threshold matches the current config —
|
|
* this prevents a spurious "back to normal" after the threshold has been raised.
|
|
*/
|
|
private function checkCuResolved($aps, int $threshold, ?string $band, array $filter, int $configId): 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;
|
|
$rName = $radio['radio'] ?? '?';
|
|
$alertKey = "unifi:alert:{$configId}:cu:{$ap['mac']}:{$rName}";
|
|
$pendingKey = "unifi:alert:{$configId}:cu_pending:{$ap['mac']}:{$rName}";
|
|
if ($cu < $threshold) {
|
|
Cache::forget($pendingKey); // always reset sustain counter when below threshold
|
|
$cached = Cache::get($alertKey);
|
|
if ($cached !== null) {
|
|
Cache::forget($alertKey);
|
|
if ((int) $cached === $threshold) {
|
|
// Only alert if this is the same threshold that originally triggered
|
|
$name = $ap['name'] ?? $ap['mac'];
|
|
$alerts[] = ['key' => $ap['mac'].':'.$rName, 'device' => $name, 'mac' => $ap['mac'],
|
|
'radio' => $rName, 'cu' => $cu, 'threshold' => $threshold, 'message' => "{$name} ({$rName}): CU returned to normal ({$cu}%)"];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return $alerts;
|
|
}
|
|
|
|
/**
|
|
* WiFi satisfaction — alert on entry.
|
|
*/
|
|
private function checkSatisfaction($aps, int $minSat, array $filter, int $configId): array
|
|
{
|
|
$alerts = [];
|
|
foreach ($aps as $ap) {
|
|
if (! empty($filter) && ! in_array($ap['mac'], $filter)) continue;
|
|
$sat = $ap['satisfaction'] ?? null;
|
|
$cacheKey = "unifi:alert:{$configId}:satisfaction:{$ap['mac']}";
|
|
if ($sat !== null && $sat >= 0 && $sat < $minSat) {
|
|
if (! Cache::has($cacheKey)) {
|
|
Cache::put($cacheKey, $minSat, now()->addHours(4));
|
|
$name = $ap['name'] ?? $ap['mac'];
|
|
$alerts[] = ['key' => $ap['mac'], 'device' => $name, 'mac' => $ap['mac'],
|
|
'satisfaction' => $sat, 'threshold' => $minSat, 'message' => "{$name}: {$sat}% experience"];
|
|
} else {
|
|
Cache::put($cacheKey, $minSat, now()->addHours(4)); // refresh TTL
|
|
}
|
|
}
|
|
}
|
|
return $alerts;
|
|
}
|
|
|
|
/**
|
|
* WiFi satisfaction — resolved on exit.
|
|
* Only fires if the cached threshold matches current config.
|
|
*/
|
|
private function checkSatisfactionResolved($aps, int $minSat, array $filter, int $configId): array
|
|
{
|
|
$alerts = [];
|
|
foreach ($aps as $ap) {
|
|
if (! empty($filter) && ! in_array($ap['mac'], $filter)) continue;
|
|
$sat = $ap['satisfaction'] ?? null;
|
|
$cacheKey = "unifi:alert:{$configId}:satisfaction:{$ap['mac']}";
|
|
$cached = Cache::get($cacheKey);
|
|
if ($sat !== null && $sat >= $minSat && $cached !== null) {
|
|
Cache::forget($cacheKey);
|
|
if ((int) $cached === $minSat) {
|
|
$name = $ap['name'] ?? $ap['mac'];
|
|
$alerts[] = ['key' => $ap['mac'], 'device' => $name, 'mac' => $ap['mac'],
|
|
'satisfaction' => $sat, 'threshold' => $minSat, 'message' => "{$name}: experience returned to normal ({$sat}%)"];
|
|
}
|
|
}
|
|
}
|
|
return $alerts;
|
|
}
|
|
|
|
/**
|
|
* Error rate — alert on entry.
|
|
*/
|
|
private function checkErrorRate($aps, array $filter, int $configId): array
|
|
{
|
|
$alerts = [];
|
|
foreach ($aps as $ap) {
|
|
if (! empty($filter) && ! in_array($ap['mac'], $filter)) continue;
|
|
$retries = $ap['stat']['ap']['tx_retries'] ?? 0;
|
|
$cacheKey = "unifi:alert:{$configId}:error_rate:{$ap['mac']}";
|
|
if ($retries > 1000) {
|
|
if (! Cache::has($cacheKey)) {
|
|
Cache::put($cacheKey, 1000, now()->addHours(4));
|
|
$name = $ap['name'] ?? $ap['mac'];
|
|
$alerts[] = ['key' => $ap['mac'], 'device' => $name, 'mac' => $ap['mac'],
|
|
'retries' => $retries, 'message' => "{$name}: high retries ({$retries})"];
|
|
} else {
|
|
Cache::put($cacheKey, 1000, now()->addHours(4)); // refresh TTL
|
|
}
|
|
}
|
|
}
|
|
return $alerts;
|
|
}
|
|
|
|
/**
|
|
* Error rate — resolved on exit.
|
|
*/
|
|
private function checkErrorRateResolved($aps, array $filter, int $configId): array
|
|
{
|
|
$alerts = [];
|
|
foreach ($aps as $ap) {
|
|
if (! empty($filter) && ! in_array($ap['mac'], $filter)) continue;
|
|
$retries = $ap['stat']['ap']['tx_retries'] ?? 0;
|
|
$cacheKey = "unifi:alert:{$configId}:error_rate:{$ap['mac']}";
|
|
if ($retries <= 1000 && Cache::get($cacheKey) !== null) {
|
|
Cache::forget($cacheKey);
|
|
$name = $ap['name'] ?? $ap['mac'];
|
|
$alerts[] = ['key' => $ap['mac'], 'device' => $name, 'mac' => $ap['mac'],
|
|
'message' => "{$name}: error rate returned to normal"];
|
|
}
|
|
}
|
|
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;
|
|
if (Cache::has('unifi:planned_reboot:' . strtolower($ap['mac']))) 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,
|
|
];
|
|
|
|
$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);
|
|
}
|
|
|
|
private function formatPayloadForPlatform(string $url, string $message, array $fullPayload): array
|
|
{
|
|
return self::buildPlatformPayload($url, $message, $fullPayload);
|
|
}
|
|
|
|
/**
|
|
* Public/static helper so the test-webhook endpoint produces the
|
|
* same per-platform payload shape that real events do.
|
|
*/
|
|
public static function buildPlatformPayload(string $url, string $message, array $fullPayload): array
|
|
{
|
|
if (str_contains($url, 'chat.googleapis.com')) return ['text' => $message];
|
|
if (str_contains($url, 'hooks.slack.com')) return ['text' => $message];
|
|
if (str_contains($url, 'discord.com/api/webhooks')) return ['content' => $message];
|
|
if (str_contains($url, 'webhook.office.com') || str_contains($url, 'workflows.office.com')) return ['text' => $message];
|
|
return $fullPayload;
|
|
}
|
|
|
|
// ── State sync ────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Sync device states. Tracks consecutive_count and was_online.
|
|
* in_alert is managed separately in checkDeviceTransition (updated after confirmed fire).
|
|
*
|
|
* Offline grace: 2 consecutive offline polls (count reaches 2 → flip was_online=false)
|
|
* Online grace: 2 consecutive online polls (count reaches 2 → flip was_online=true)
|
|
*
|
|
* checkDeviceTransition fires one poll before the flip (at count >= 1), which is correct:
|
|
* the alert fires on the 2nd consecutive poll, state flips on the same run's sync call.
|
|
*/
|
|
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,
|
|
'in_alert' => false,
|
|
'consecutive_count' => 0,
|
|
'last_seen_at' => now(),
|
|
'updated_at' => now(),
|
|
]);
|
|
continue;
|
|
}
|
|
|
|
$prevState = $prev->was_online;
|
|
|
|
if ($prevState === $isOnline) {
|
|
// Stable — reset counter
|
|
$prev->update([
|
|
'consecutive_count' => 0,
|
|
'last_seen_at' => now(),
|
|
'updated_at' => now(),
|
|
'device_name' => $dev['name'] ?? $dev['model'] ?? $prev->device_name,
|
|
]);
|
|
} else {
|
|
// Approaching a state change
|
|
$count = $prev->consecutive_count + 1;
|
|
$grace = 2; // 2 consecutive polls for both directions
|
|
if ($count >= $grace) {
|
|
// Confirmed — flip was_online, reset counter
|
|
$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 {
|
|
$prev->update([
|
|
'consecutive_count' => $count,
|
|
'last_seen_at' => now(),
|
|
'updated_at' => now(),
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private function syncClientStates(array $clients): void
|
|
{
|
|
$connectedMacs = collect($clients)->pluck('mac')->map(fn ($m) => strtolower($m))->all();
|
|
|
|
$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, 'in_alert' => false,
|
|
'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) {
|
|
$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()]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|