feat: password rotation, PPSK management, VLAN/AP groups
- Add password rotation: RotatePasswords console command + migration + service updates - Add PPSK management: UnifiPpsk model, migration, SyncPpskSchedules console - Add VLAN groups and AP groups: VlanGroupController, ApGroupController, model, migration - Add RebootAllAps console command - Add in_alert column to device states - Wire new features through service provider, routes, and existing controllers/services Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
<?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_password_rotations', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name'); // display name (e.g. "Staff WiFi")
|
||||
$table->json('wlan_ids'); // array of WLAN IDs to rotate
|
||||
$table->text('wordlist')->nullable(); // one password per line
|
||||
$table->boolean('enabled')->default(true);
|
||||
$table->string('frequency')->default('weekly'); // daily | weekly
|
||||
$table->tinyInteger('day_of_week')->nullable(); // 0=Sun ... 6=Sat (weekly only)
|
||||
$table->tinyInteger('hour')->default(1);
|
||||
$table->tinyInteger('minute')->default(0);
|
||||
$table->timestamp('last_rotated_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('unifi_password_rotations');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
<?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_vlan_groups', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name'); // e.g. "Students"
|
||||
$table->unsignedSmallInteger('vlan_id'); // 1–4094
|
||||
$table->string('description')->nullable(); // optional note
|
||||
$table->unsignedSmallInteger('sort_order')->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('unifi_vlan_groups');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
<?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_device_states', function (Blueprint $table) {
|
||||
// Tracks whether an active device_offline alert has been sent for this device.
|
||||
// device_online ("resolved") only fires when in_alert = true.
|
||||
// This prevents orphan "back online" alerts and duplicate offline alerts.
|
||||
$table->boolean('in_alert')->default(false)->after('was_online');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('unifi_device_states', function (Blueprint $table) {
|
||||
$table->dropColumn('in_alert');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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_ppsks', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('wlan_id', 36)->index(); // UniFi WLAN _id
|
||||
$table->string('unifi_id', 36)->nullable()->index(); // null when held (not currently in UniFi)
|
||||
$table->string('name', 100);
|
||||
$table->string('x_passphrase', 63);
|
||||
$table->unsignedSmallInteger('vlan')->nullable();
|
||||
$table->enum('state', ['active', 'held'])->default('active')->index();
|
||||
$table->boolean('rotate_password')->default(false);
|
||||
$table->json('schedule')->nullable(); // 336 booleans [day*48+slot], null = unscheduled
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('unifi_ppsks');
|
||||
}
|
||||
};
|
||||
62
src/Console/RebootAllAps.php
Normal file
62
src/Console/RebootAllAps.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace Dashboard\Unifi\Console;
|
||||
|
||||
use Dashboard\Unifi\Services\UnifiApiClient;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class RebootAllAps extends Command
|
||||
{
|
||||
protected $signature = 'unifi:reboot-all-aps {--delay=5 : Seconds to wait between each reboot}';
|
||||
protected $description = 'Planned reboot of all access points — suppresses webhook offline/online alerts';
|
||||
|
||||
public function handle(UnifiApiClient $unifi): int
|
||||
{
|
||||
try {
|
||||
$aps = $unifi->getAccessPoints();
|
||||
} catch (\Throwable $e) {
|
||||
$this->error('Failed to fetch APs: ' . $e->getMessage());
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (empty($aps)) {
|
||||
$this->warn('No access points found.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$delay = max(0, (int) $this->option('delay'));
|
||||
|
||||
// Pre-mark all APs as planned reboots before sending any commands
|
||||
foreach ($aps as $ap) {
|
||||
$mac = strtolower($ap['mac']);
|
||||
Cache::put("unifi:planned_reboot:{$mac}", true, now()->addMinutes(20));
|
||||
$this->line("Marked planned reboot: {$ap['name']} ({$mac})");
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$ok = 0;
|
||||
$fail = 0;
|
||||
|
||||
foreach ($aps as $ap) {
|
||||
$mac = strtolower($ap['mac']);
|
||||
$name = $ap['name'] ?? $mac;
|
||||
try {
|
||||
$unifi->rebootDevice($mac);
|
||||
$this->info("Rebooted: {$name} ({$mac})");
|
||||
$ok++;
|
||||
} catch (\Throwable $e) {
|
||||
$this->error("Failed to reboot {$name}: {$e->getMessage()}");
|
||||
$fail++;
|
||||
}
|
||||
|
||||
if ($delay > 0 && $ok + $fail < count($aps)) {
|
||||
sleep($delay);
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("Done. {$ok} rebooted, {$fail} failed.");
|
||||
return $fail > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
}
|
||||
97
src/Console/RotatePasswords.php
Normal file
97
src/Console/RotatePasswords.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace Dashboard\Unifi\Console;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Dashboard\Unifi\Models\UnifiPpsk;
|
||||
use Dashboard\Unifi\Services\UnifiApiClient;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class RotatePasswords extends Command
|
||||
{
|
||||
protected $signature = 'unifi:rotate-passwords {--force : Run regardless of schedule}';
|
||||
protected $description = 'Rotate WiFi passwords for SSIDs configured with a wordlist schedule';
|
||||
|
||||
public function handle(UnifiApiClient $unifi): int
|
||||
{
|
||||
if (! Setting::get('unifi.password_rotation.enabled')) {
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$wlanIdsJson = Setting::get('unifi.password_rotation.wlan_ids', '[]');
|
||||
$wlanIds = json_decode($wlanIdsJson, true);
|
||||
|
||||
if (empty($wlanIds) || ! is_array($wlanIds)) {
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$wordlist = Setting::get('unifi.password_rotation.wordlist', '');
|
||||
$passwords = array_values(array_filter(array_map('trim', explode("\n", $wordlist))));
|
||||
|
||||
if (empty($passwords)) {
|
||||
$this->warn('Password rotation: no passwords in wordlist — skipped.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (! $this->option('force') && ! $this->isDue()) {
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$password = $passwords[array_rand($passwords)];
|
||||
$rotated = 0;
|
||||
|
||||
foreach ($wlanIds as $wlanId) {
|
||||
try {
|
||||
$unifi->updateWlan($wlanId, ['x_passphrase' => $password]);
|
||||
$rotated++;
|
||||
} catch (\Throwable $e) {
|
||||
$this->error("Failed to rotate wlan {$wlanId}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
if ($rotated > 0) {
|
||||
Setting::set('unifi.password_rotation.last_rotated_at', now()->toIso8601String());
|
||||
$this->info("Rotated password for {$rotated} SSID(s).");
|
||||
}
|
||||
|
||||
// ── Rotate PPSK passwords ────────────────────────────────────────────
|
||||
$rotatedPpsks = 0;
|
||||
foreach (UnifiPpsk::where('rotate_password', true)->where('state', 'active')->whereNotNull('unifi_id')->get() as $ppsk) {
|
||||
// Each PPSK gets its own independently-chosen password from the wordlist
|
||||
$newPass = $passwords[array_rand($passwords)];
|
||||
try {
|
||||
$unifi->updatePpsk($ppsk->unifi_id, ['x_passphrase' => $newPass]);
|
||||
$ppsk->update(['x_passphrase' => $newPass]);
|
||||
$rotatedPpsks++;
|
||||
} catch (\Throwable $e) {
|
||||
$this->error("Failed to rotate PPSK \"{$ppsk->name}\": {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
if ($rotatedPpsks > 0) {
|
||||
$this->info("Rotated password for {$rotatedPpsks} PPSK(s).");
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function isDue(): bool
|
||||
{
|
||||
$frequency = Setting::get('unifi.password_rotation.frequency', 'weekly');
|
||||
$hour = (int) Setting::get('unifi.password_rotation.hour', 2);
|
||||
$minute = (int) Setting::get('unifi.password_rotation.minute', 0);
|
||||
$dow = (int) Setting::get('unifi.password_rotation.day_of_week', 0);
|
||||
$tz = Setting::get('unifi.timezone', 'UTC');
|
||||
$now = now($tz);
|
||||
|
||||
if ($now->hour !== $hour || $now->minute !== $minute) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($frequency === 'weekly' && $now->dayOfWeek !== $dow) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
94
src/Console/SyncPpskSchedules.php
Normal file
94
src/Console/SyncPpskSchedules.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace Dashboard\Unifi\Console;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Dashboard\Unifi\Models\UnifiPpsk;
|
||||
use Dashboard\Unifi\Services\UnifiApiClient;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class SyncPpskSchedules extends Command
|
||||
{
|
||||
protected $signature = 'unifi:sync-ppsk-schedules {--force : Run even if PPSK scheduling is disabled}';
|
||||
protected $description = 'Enable or disable PPSKs based on their weekly half-hour schedule, kicking active clients when disabling';
|
||||
|
||||
public function handle(UnifiApiClient $unifi): int
|
||||
{
|
||||
if (! $this->option('force') && ! Setting::get('unifi.ppsk_scheduling.enabled')) {
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$tz = Setting::get('unifi.timezone', 'UTC');
|
||||
$now = now($tz);
|
||||
$day = $now->dayOfWeek; // 0=Sun … 6=Sat
|
||||
$slot = $now->hour * 2 + ($now->minute >= 30 ? 1 : 0); // 0–47
|
||||
|
||||
$ppsks = UnifiPpsk::whereNotNull('schedule')->get();
|
||||
|
||||
if ($ppsks->isEmpty()) {
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// Fetch network confs once so we can resolve vlan → networkconf_id on re-enable
|
||||
$networksByVlan = [];
|
||||
try {
|
||||
foreach ($unifi->getNetworkConfs() as $n) {
|
||||
if (isset($n['vlan'])) {
|
||||
$networksByVlan[(int) $n['vlan']] = $n;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->warn("Could not fetch network configs: {$e->getMessage()}");
|
||||
}
|
||||
|
||||
foreach ($ppsks as $ppsk) {
|
||||
$shouldBeOn = (bool) ($ppsk->schedule[$day * 48 + $slot] ?? true);
|
||||
|
||||
if ($shouldBeOn && $ppsk->state === 'held') {
|
||||
$this->enablePpsk($ppsk, $unifi, $networksByVlan);
|
||||
} elseif (! $shouldBeOn && $ppsk->state === 'active' && $ppsk->unifi_id) {
|
||||
$this->disablePpsk($ppsk, $unifi);
|
||||
}
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function enablePpsk(UnifiPpsk $ppsk, UnifiApiClient $unifi, array $networksByVlan): void
|
||||
{
|
||||
try {
|
||||
$data = [
|
||||
'name' => $ppsk->name,
|
||||
'x_passphrase' => $ppsk->x_passphrase,
|
||||
'wlan_id' => $ppsk->wlan_id,
|
||||
];
|
||||
|
||||
if ($ppsk->vlan && isset($networksByVlan[$ppsk->vlan])) {
|
||||
$data['networkconf_id'] = $networksByVlan[$ppsk->vlan]['_id'];
|
||||
}
|
||||
|
||||
$result = $unifi->createPpsk($data);
|
||||
$raw = $result[0] ?? $result;
|
||||
$newId = $raw['_id'] ?? null;
|
||||
|
||||
$ppsk->update(['state' => 'active', 'unifi_id' => $newId]);
|
||||
$this->info("Enabled: {$ppsk->name} (wlan {$ppsk->wlan_id})");
|
||||
} catch (\Throwable $e) {
|
||||
$this->error("Failed to enable PPSK \"{$ppsk->name}\": {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
private function disablePpsk(UnifiPpsk $ppsk, UnifiApiClient $unifi): void
|
||||
{
|
||||
try {
|
||||
$kicked = $unifi->kickClientsForPpsk($ppsk->unifi_id);
|
||||
$unifi->deletePpsk($ppsk->unifi_id);
|
||||
$ppsk->update(['state' => 'held', 'unifi_id' => null]);
|
||||
|
||||
$suffix = $kicked > 0 ? " — kicked {$kicked} client(s)" : '';
|
||||
$this->info("Disabled: {$ppsk->name}{$suffix}");
|
||||
} catch (\Throwable $e) {
|
||||
$this->error("Failed to disable PPSK \"{$ppsk->name}\": {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
88
src/Http/Controllers/ApGroupController.php
Normal file
88
src/Http/Controllers/ApGroupController.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace Dashboard\Unifi\Http\Controllers;
|
||||
|
||||
use Dashboard\Unifi\Services\UnifiApiClient;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class ApGroupController extends Controller
|
||||
{
|
||||
public function index(UnifiApiClient $unifi)
|
||||
{
|
||||
try {
|
||||
$groups = collect($unifi->getApGroups())->map(fn ($g) => [
|
||||
'id' => $g['_id'],
|
||||
'name' => $g['name'] ?? 'Unnamed',
|
||||
'device_macs' => $g['device_macs'] ?? [],
|
||||
'is_default' => $g['attr_no_delete'] ?? false,
|
||||
])->values();
|
||||
|
||||
$devices = collect($unifi->getAccessPoints())->map(fn ($d) => [
|
||||
'mac' => strtolower($d['mac']),
|
||||
'name' => $d['name'] ?? $d['model'] ?? $d['mac'],
|
||||
'model' => $d['model'] ?? '',
|
||||
'state' => $d['state'] ?? 0,
|
||||
])->values();
|
||||
|
||||
return Inertia::render('Unifi/ApGroups', [
|
||||
'groups' => $groups,
|
||||
'devices' => $devices,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return Inertia::render('Unifi/ApGroups', [
|
||||
'groups' => [], 'devices' => [], 'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function store(Request $request, UnifiApiClient $unifi)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'device_macs' => 'present|array',
|
||||
'device_macs.*' => 'string',
|
||||
]);
|
||||
|
||||
try {
|
||||
$result = $unifi->createApGroup([
|
||||
'name' => $data['name'],
|
||||
'device_macs' => array_values(array_map('strtolower', $data['device_macs'])),
|
||||
]);
|
||||
return back()->with('success', 'AP group created.');
|
||||
} catch (\Throwable $e) {
|
||||
return back()->withErrors(['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
public function update(Request $request, string $groupId, UnifiApiClient $unifi)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => 'sometimes|string|max:100',
|
||||
'device_macs' => 'sometimes|array',
|
||||
'device_macs.*' => 'string',
|
||||
]);
|
||||
|
||||
if (isset($data['device_macs'])) {
|
||||
$data['device_macs'] = array_values(array_map('strtolower', $data['device_macs']));
|
||||
}
|
||||
|
||||
try {
|
||||
$unifi->updateApGroup($groupId, $data);
|
||||
return back()->with('success', 'AP group updated.');
|
||||
} catch (\Throwable $e) {
|
||||
return back()->withErrors(['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
public function destroy(string $groupId, UnifiApiClient $unifi)
|
||||
{
|
||||
try {
|
||||
$unifi->deleteApGroup($groupId);
|
||||
return back()->with('success', 'AP group deleted.');
|
||||
} catch (\Throwable $e) {
|
||||
return back()->withErrors(['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace Dashboard\Unifi\Http\Controllers;
|
||||
|
||||
use Dashboard\Unifi\Models\KnownMac;
|
||||
use Dashboard\Unifi\Models\PortalSession;
|
||||
use Dashboard\Unifi\Models\VlanGroup;
|
||||
use Dashboard\Unifi\Services\UnifiApiClient;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
@@ -11,7 +12,7 @@ use Inertia\Inertia;
|
||||
|
||||
class ClientController extends Controller
|
||||
{
|
||||
public function index(UnifiApiClient $unifi)
|
||||
public function index(Request $request, UnifiApiClient $unifi)
|
||||
{
|
||||
try {
|
||||
$clients = collect($unifi->getActiveClients())->map(fn ($c) => [
|
||||
@@ -26,7 +27,10 @@ class ClientController extends Controller
|
||||
'is_wired' => $c['is_wired'] ?? false,
|
||||
'is_guest' => $c['is_guest'] ?? false,
|
||||
'ssid' => $c['essid'] ?? null,
|
||||
'network' => $c['network'] ?? null,
|
||||
'ap_mac' => $c['ap_mac'] ?? null,
|
||||
'sw_mac' => $c['sw_mac'] ?? null,
|
||||
'sw_port' => $c['sw_port'] ?? null,
|
||||
'rssi' => $c['rssi'] ?? null,
|
||||
'signal' => $c['signal'] ?? null,
|
||||
'channel' => $c['channel'] ?? null,
|
||||
@@ -34,12 +38,32 @@ class ClientController extends Controller
|
||||
'rx_bytes' => $c['rx_bytes'] ?? 0,
|
||||
'tx_rate' => $c['tx_rate'] ?? 0,
|
||||
'rx_rate' => $c['rx_rate'] ?? 0,
|
||||
'tx_rate_r' => $c['tx_bytes-r'] ?? 0,
|
||||
'rx_rate_r' => $c['rx_bytes-r'] ?? 0,
|
||||
'uptime' => $c['uptime'] ?? 0,
|
||||
'satisfaction' => $c['satisfaction'] ?? null,
|
||||
'vlan_id' => ($c['vlan_id'] ?? 0) ?: null,
|
||||
'dot1x' => $c['1x_identity'] ?? $c['dot1x_identity'] ?? null,
|
||||
'is_known' => KnownMac::where('mac_address', strtolower($c['mac']))->exists(),
|
||||
])->values();
|
||||
|
||||
return Inertia::render('Unifi/Clients', ['clients' => $clients]);
|
||||
// APs and switches for the device filter dropdown
|
||||
$devices = collect($unifi->getDevices())
|
||||
->filter(fn ($d) => in_array($d['type'] ?? '', ['uap', 'usw']))
|
||||
->map(fn ($d) => [
|
||||
'mac' => $d['mac'],
|
||||
'name' => $d['name'] ?? $d['model'] ?? $d['mac'],
|
||||
'type' => $d['type'],
|
||||
])
|
||||
->sortBy('name')
|
||||
->values();
|
||||
|
||||
return Inertia::render('Unifi/Clients', [
|
||||
'clients' => $clients,
|
||||
'vlanGroups' => VlanGroup::orderBy('sort_order')->get(),
|
||||
'devices' => $devices,
|
||||
'selectedDevice' => $request->query('device'),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return Inertia::render('Unifi/Clients', ['clients' => [], 'error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Dashboard\Unifi\Http\Controllers;
|
||||
use Dashboard\Unifi\Services\UnifiApiClient;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class DeviceController extends Controller
|
||||
@@ -12,29 +13,49 @@ 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();
|
||||
$devices = collect($unifi->getDevices())->map(function ($d) {
|
||||
// radio_table_stats has actual live channel + per-radio client counts + rates
|
||||
$radioStats = collect($d['radio_table_stats'] ?? [])->keyBy('name');
|
||||
|
||||
// Device-level throughput: prefer device field, fall back to sum of radio stats
|
||||
$txRate = ($d['tx_bytes-r'] ?? 0) ?: $radioStats->sum(fn ($r) => $r['tx_bytes-r'] ?? 0);
|
||||
$rxRate = ($d['rx_bytes-r'] ?? 0) ?: $radioStats->sum(fn ($r) => $r['rx_bytes-r'] ?? 0);
|
||||
|
||||
return [
|
||||
'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,
|
||||
'tx_rate' => $txRate,
|
||||
'rx_rate' => $rxRate,
|
||||
'cpu' => $d['system-stats']['cpu'] ?? null,
|
||||
'mem' => $d['system-stats']['mem'] ?? null,
|
||||
'satisfaction' => $d['satisfaction'] ?? null,
|
||||
// Use radio_table_stats for actual channel (not 'auto' from config),
|
||||
// per-radio client count, and per-radio rates.
|
||||
'channels' => collect($d['radio_table'] ?? [])->map(function ($r) use ($radioStats) {
|
||||
$stats = $radioStats->get($r['name'] ?? '');
|
||||
// stats['channel'] is the real channel in use; 0 = not broadcasting
|
||||
$channel = $stats ? (($stats['channel'] ?? 0) ?: null) : null;
|
||||
return [
|
||||
'radio' => $r['radio'] ?? '',
|
||||
'channel' => $channel,
|
||||
'num_sta' => $stats['num_sta'] ?? 0,
|
||||
'tx_rate' => $stats ? ($stats['tx_bytes-r'] ?? 0) : 0,
|
||||
'rx_rate' => $stats ? ($stats['rx_bytes-r'] ?? 0) : 0,
|
||||
];
|
||||
})->values(),
|
||||
];
|
||||
})->values();
|
||||
|
||||
return Inertia::render('Unifi/Devices', ['devices' => $devices]);
|
||||
} catch (\Throwable $e) {
|
||||
@@ -47,6 +68,9 @@ class DeviceController extends Controller
|
||||
$request->validate(['mac' => 'required|string|regex:/^([0-9a-f]{2}:){5}[0-9a-f]{2}$/i']);
|
||||
|
||||
try {
|
||||
// Suppress offline/online webhook alerts for planned reboots (15-minute window)
|
||||
Cache::put('unifi:planned_reboot:' . strtolower($request->mac), true, now()->addMinutes(15));
|
||||
|
||||
$unifi->rebootDevice($request->mac);
|
||||
return back()->with('success', 'Reboot command sent.');
|
||||
} catch (\Throwable $e) {
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace Dashboard\Unifi\Http\Controllers;
|
||||
|
||||
use Dashboard\Unifi\Models\KnownMac;
|
||||
use Dashboard\Unifi\Models\PortalSession;
|
||||
use Dashboard\Unifi\Models\VlanGroup;
|
||||
use Dashboard\Unifi\Models\VlanMapping;
|
||||
use Dashboard\Unifi\Services\UnifiApiClient;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -17,6 +18,7 @@ class PortalController extends Controller
|
||||
public function settings()
|
||||
{
|
||||
return Inertia::render('Unifi/Portal', [
|
||||
'vlanGroups' => VlanGroup::orderBy('sort_order')->get(),
|
||||
'vlanMappings' => VlanMapping::orderBy('sort_order')->get(),
|
||||
'knownMacs' => KnownMac::orderBy('device_name')->paginate(50),
|
||||
'activeSessions' => PortalSession::where('is_active', true)
|
||||
|
||||
@@ -8,6 +8,7 @@ use Dashboard\Unifi\Models\ClientSnapshot;
|
||||
use Dashboard\Unifi\Services\UnifiApiClient;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class StatsController extends Controller
|
||||
@@ -65,9 +66,9 @@ class StatsController extends Controller
|
||||
|
||||
try {
|
||||
$health = $unifi->getSiteHealth();
|
||||
$allAps = $unifi->getAccessPoints();
|
||||
$allAps = Cache::remember('unifi_access_points', 30, fn () => $unifi->getAccessPoints());
|
||||
$aps = empty($apFilter) ? $allAps : array_values(array_filter($allAps, fn ($a) => in_array($a['mac'], $apFilter)));
|
||||
$clients = $unifi->getActiveClients();
|
||||
$clients = Cache::remember('unifi_active_clients', 30, fn () => $unifi->getActiveClients());
|
||||
$ssidGroups = json_decode(Setting::get('unifi.ssid_groups', '{}'), true);
|
||||
if (! is_array($ssidGroups) || array_is_list($ssidGroups)) $ssidGroups = [];
|
||||
|
||||
@@ -173,9 +174,9 @@ class StatsController extends Controller
|
||||
}
|
||||
|
||||
try {
|
||||
// Current snapshot for categorical counts (device type / OS) — use live API
|
||||
$clients = collect($unifi->getActiveClients());
|
||||
$aps = collect($unifi->getAccessPoints());
|
||||
// Current snapshot for categorical counts (device type / OS) — use live/cached API
|
||||
$clients = collect(Cache::remember('unifi_active_clients', 30, fn () => $unifi->getActiveClients()));
|
||||
$aps = collect(Cache::remember('unifi_access_points', 30, fn () => $unifi->getAccessPoints()));
|
||||
|
||||
$devCatCounts = $clients->map(fn ($c) => ['name' => $this->classifyDeviceType($c)])
|
||||
->groupBy('name')
|
||||
@@ -208,6 +209,9 @@ class StatsController extends Controller
|
||||
'ap_mac' => $apMac,
|
||||
'ap_name' => $apNames[$apMac] ?? (($c['is_wired'] ?? false) ? 'Wired' : 'Unknown'),
|
||||
'is_wired' => (bool) ($c['is_wired'] ?? false),
|
||||
'ssid' => $c['essid'] ?? null,
|
||||
'vlan_id' => ($c['vlan_id'] ?? 0) ?: null,
|
||||
'dot1x' => $c['1x_identity'] ?? $c['dot1x_identity'] ?? null,
|
||||
'tx_rate' => (int) ($c['tx_rate'] ?? 0),
|
||||
'rx_rate' => (int) ($c['rx_rate'] ?? 0),
|
||||
'tx_bytes' => (int) ($c['tx_bytes'] ?? 0),
|
||||
@@ -215,7 +219,11 @@ class StatsController extends Controller
|
||||
];
|
||||
})->sortByDesc(fn ($c) => $c['rx_rate'] + $c['tx_rate'])->values();
|
||||
|
||||
$series = $this->buildClientSeries($startDt, $endDt);
|
||||
$rangeKey = $range === 'interval'
|
||||
? "interval_{$startDt->timestamp}_{$endDt->timestamp}"
|
||||
: $range;
|
||||
$series = Cache::remember("unifi_client_series_{$rangeKey}", 45,
|
||||
fn () => $this->buildClientSeries($startDt, $endDt));
|
||||
|
||||
return Inertia::render('Unifi/ClientDashboard', [
|
||||
'devCatCounts' => $devCatCounts,
|
||||
@@ -228,11 +236,10 @@ class StatsController extends Controller
|
||||
'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,
|
||||
'vlanGroups' => \Dashboard\Unifi\Models\VlanGroup::orderBy('sort_order')->get(),
|
||||
'range' => $range,
|
||||
'ranges' => array_keys(self::RANGE_MAP),
|
||||
'pollInterval' => (int) Setting::get('unifi.poll_interval', 30),
|
||||
@@ -246,6 +253,58 @@ class StatsController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX — Traffic time-series for clients currently on a given AP.
|
||||
* Reads from the cached series (built by clientDashboard), so it's fast.
|
||||
*/
|
||||
public function clientApTraffic(Request $request, UnifiApiClient $unifi): \Illuminate\Http\JsonResponse
|
||||
{
|
||||
$apMac = strtolower(trim($request->get('ap', '')));
|
||||
$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 {
|
||||
// Which client MACs are currently on this AP?
|
||||
$clients = collect(Cache::remember('unifi_active_clients', 30, fn () => $unifi->getActiveClients()));
|
||||
$macsOnAp = $clients
|
||||
->filter(fn ($c) => strtolower($c['ap_mac'] ?? '') === $apMac)
|
||||
->pluck('mac')
|
||||
->map(fn ($m) => strtolower($m))
|
||||
->flip() // flip to key => true for O(1) lookup
|
||||
->all();
|
||||
|
||||
// Fetch (or warm) the cached series
|
||||
$rangeKey = $range === 'interval'
|
||||
? "interval_{$startDt->timestamp}_{$endDt->timestamp}"
|
||||
: $range;
|
||||
$series = Cache::remember("unifi_client_series_{$rangeKey}", 45,
|
||||
fn () => $this->buildClientSeries($startDt, $endDt));
|
||||
|
||||
$rx = collect($series['traffic_rx'])
|
||||
->filter(fn ($s) => isset($macsOnAp[strtolower($s['mac'] ?? '')]))
|
||||
->values();
|
||||
$tx = collect($series['traffic_tx'])
|
||||
->filter(fn ($s) => isset($macsOnAp[strtolower($s['mac'] ?? '')]))
|
||||
->values();
|
||||
|
||||
return response()->json([
|
||||
'labels' => $series['labels'],
|
||||
'traffic_rx' => $rx,
|
||||
'traffic_tx' => $tx,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['error' => $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Device TYPE = manufacturer + model (when detectable).
|
||||
*
|
||||
@@ -438,7 +497,8 @@ class StatsController extends Controller
|
||||
|
||||
$labels = $times->map(fn ($t) => $t * 1000);
|
||||
|
||||
$snapshots = ClientSnapshot::whereIn(\Illuminate\Support\Facades\DB::raw('UNIX_TIMESTAMP(captured_at)'), $times->all())
|
||||
$snapshots = ClientSnapshot::whereBetween('captured_at', [$start, $end])
|
||||
->whereIn(\Illuminate\Support\Facades\DB::raw('UNIX_TIMESTAMP(captured_at)'), $times->all())
|
||||
->orderBy('captured_at')
|
||||
->get();
|
||||
|
||||
@@ -562,12 +622,12 @@ class StatsController extends Controller
|
||||
$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,
|
||||
'labels' => $labels->values()->all(),
|
||||
'traffic_rx' => $trafficRx->values()->all(),
|
||||
'traffic_tx' => $trafficTx->values()->all(),
|
||||
'satisfaction' => $satisfaction->values()->all(),
|
||||
'signal' => $signal->values()->all(),
|
||||
'download_mb' => $downloadMb->values()->all(),
|
||||
'total_download_bytes' => $totalDownload,
|
||||
'total_upload_bytes' => $totalUpload,
|
||||
'download_series' => $downloadSeries,
|
||||
|
||||
@@ -13,52 +13,79 @@ 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),
|
||||
'controllerUrl' => Setting::get('unifi.controller_url', ''),
|
||||
'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),
|
||||
'timezone' => Setting::get('unifi.timezone', 'UTC'),
|
||||
'autoRebootEnabled' => (bool) Setting::get('unifi.auto_reboot.enabled', false),
|
||||
'autoRebootFrequency' => Setting::get('unifi.auto_reboot.frequency', 'daily'),
|
||||
'autoRebootDow' => (int) Setting::get('unifi.auto_reboot.day_of_week', 0),
|
||||
'autoRebootHour' => (int) Setting::get('unifi.auto_reboot.hour', 2),
|
||||
'autoRebootMinute' => (int) Setting::get('unifi.auto_reboot.minute', 0),
|
||||
'rotationEnabled' => (bool) Setting::get('unifi.password_rotation.enabled', false),
|
||||
'rotationFrequency' => Setting::get('unifi.password_rotation.frequency', 'weekly'),
|
||||
'rotationDow' => (int) Setting::get('unifi.password_rotation.day_of_week', 0),
|
||||
'rotationHour' => (int) Setting::get('unifi.password_rotation.hour', 2),
|
||||
'rotationMinute' => (int) Setting::get('unifi.password_rotation.minute', 0),
|
||||
'rotationWordlist' => Setting::get('unifi.password_rotation.wordlist', ''),
|
||||
'rotationLastRotatedAt' => Setting::get('unifi.password_rotation.last_rotated_at', null),
|
||||
'ppskSchedulingEnabled' => (bool) Setting::get('unifi.ppsk_scheduling.enabled', false),
|
||||
]);
|
||||
}
|
||||
|
||||
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',
|
||||
'controller_url' => 'required|url|max:500',
|
||||
'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',
|
||||
'timezone' => 'nullable|string|timezone',
|
||||
'auto_reboot_enabled' => 'boolean',
|
||||
'auto_reboot_frequency' => 'in:daily,weekly',
|
||||
'auto_reboot_dow' => 'nullable|integer|min:0|max:6',
|
||||
'auto_reboot_hour' => 'nullable|integer|min:0|max:23',
|
||||
'auto_reboot_minute' => 'nullable|integer|min:0|max:59',
|
||||
'rotation_enabled' => 'boolean',
|
||||
'rotation_frequency' => 'in:daily,weekly',
|
||||
'rotation_dow' => 'nullable|integer|min:0|max:6',
|
||||
'rotation_hour' => 'nullable|integer|min:0|max:23',
|
||||
'rotation_minute' => 'nullable|integer|min:0|max:59',
|
||||
'rotation_wordlist' => 'nullable|string|max:20000',
|
||||
'ppsk_scheduling_enabled' => 'boolean',
|
||||
]);
|
||||
|
||||
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('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);
|
||||
if ($request->has('timezone')) Setting::set('unifi.timezone', $request->timezone ?? 'UTC');
|
||||
|
||||
Setting::set('unifi.auto_reboot.enabled', $request->boolean('auto_reboot_enabled') ? '1' : '');
|
||||
Setting::set('unifi.auto_reboot.frequency', $request->input('auto_reboot_frequency', 'daily'));
|
||||
Setting::set('unifi.auto_reboot.day_of_week',$request->input('auto_reboot_dow', 0));
|
||||
Setting::set('unifi.auto_reboot.hour', $request->input('auto_reboot_hour', 2));
|
||||
Setting::set('unifi.auto_reboot.minute', $request->input('auto_reboot_minute', 0));
|
||||
|
||||
Setting::set('unifi.password_rotation.enabled', $request->boolean('rotation_enabled') ? '1' : '');
|
||||
Setting::set('unifi.password_rotation.frequency', $request->input('rotation_frequency', 'weekly'));
|
||||
Setting::set('unifi.password_rotation.day_of_week', $request->input('rotation_dow', 0));
|
||||
Setting::set('unifi.password_rotation.hour', $request->input('rotation_hour', 2));
|
||||
Setting::set('unifi.password_rotation.minute', $request->input('rotation_minute', 0));
|
||||
Setting::set('unifi.password_rotation.wordlist', $request->input('rotation_wordlist', ''));
|
||||
Setting::set('unifi.ppsk_scheduling.enabled', $request->boolean('ppsk_scheduling_enabled') ? '1' : '');
|
||||
|
||||
// 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.');
|
||||
@@ -103,12 +130,10 @@ class UnifiSettingsController extends Controller
|
||||
$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.";
|
||||
} elseif (! $key) {
|
||||
$hint .= "Enter an API key above.";
|
||||
} 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.";
|
||||
$hint .= "Check that the API key is correct and the controller URL is reachable.";
|
||||
}
|
||||
|
||||
return response()->json(['ok' => false, 'error' => $e->getMessage(), 'hint' => $hint], 422);
|
||||
|
||||
39
src/Http/Controllers/VlanGroupController.php
Normal file
39
src/Http/Controllers/VlanGroupController.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace Dashboard\Unifi\Http\Controllers;
|
||||
|
||||
use Dashboard\Unifi\Models\VlanGroup;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
|
||||
class VlanGroupController extends Controller
|
||||
{
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'vlan_id' => 'required|integer|min:1|max:4094',
|
||||
'description' => 'nullable|string|max:255',
|
||||
]);
|
||||
$data['sort_order'] = VlanGroup::max('sort_order') + 1;
|
||||
VlanGroup::create($data);
|
||||
return back()->with('success', 'VLAN group added.');
|
||||
}
|
||||
|
||||
public function update(Request $request, VlanGroup $vlanGroup)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'vlan_id' => 'required|integer|min:1|max:4094',
|
||||
'description' => 'nullable|string|max:255',
|
||||
]);
|
||||
$vlanGroup->update($data);
|
||||
return back()->with('success', 'VLAN group updated.');
|
||||
}
|
||||
|
||||
public function destroy(VlanGroup $vlanGroup)
|
||||
{
|
||||
$vlanGroup->delete();
|
||||
return back()->with('success', 'VLAN group deleted.');
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace Dashboard\Unifi\Http\Controllers;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Dashboard\Unifi\Models\UnifiPpsk;
|
||||
use Dashboard\Unifi\Services\UnifiApiClient;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
@@ -13,56 +14,82 @@ 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();
|
||||
$wlans = collect($unifi->getWlans())->map(fn ($w) => $this->mapWlan($w))->values();
|
||||
|
||||
// Load saved groups: { "Staff": ["id1", "id2"], ... }
|
||||
$raw = Setting::get('unifi.ssid_groups', '{}');
|
||||
try {
|
||||
$apGroups = collect($unifi->getApGroups())->map(fn ($g) => [
|
||||
'id' => $g['_id'],
|
||||
'name' => $g['attr_no_delete'] ?? false ? 'Default' : ($g['name'] ?? 'Unnamed'),
|
||||
'device_macs' => $g['device_macs'] ?? [],
|
||||
'is_default' => $g['attr_no_delete'] ?? false,
|
||||
])->values();
|
||||
} catch (\Throwable $e) {
|
||||
$apGroups = collect(); // AP groups not supported by this controller
|
||||
}
|
||||
|
||||
$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;
|
||||
|
||||
$rotateWlanIds = json_decode(Setting::get('unifi.password_rotation.wlan_ids', '[]'), true) ?: [];
|
||||
|
||||
return Inertia::render('Unifi/Wifi', [
|
||||
'wlans' => $wlans,
|
||||
'groups' => $groups,
|
||||
'wlans' => $wlans,
|
||||
'groups' => $groups,
|
||||
'apGroups' => $apGroups,
|
||||
'rotateWlanIds' => $rotateWlanIds,
|
||||
'ppskSchedulingEnabled' => (bool) Setting::get('unifi.ppsk_scheduling.enabled', false),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return Inertia::render('Unifi/Wifi', ['wlans' => [], 'groups' => [], 'error' => $e->getMessage()]);
|
||||
return Inertia::render('Unifi/Wifi', [
|
||||
'wlans' => [], 'groups' => [], 'apGroups' => [], 'rotateWlanIds' => [], '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',
|
||||
'x_passphrase' => 'sometimes|string|min:8|max:63',
|
||||
'hide_ssid' => 'sometimes|boolean',
|
||||
'mac_filter_enabled' => 'sometimes|boolean',
|
||||
'mac_filter_policy' => 'sometimes|string|in:allow,deny',
|
||||
'rotate_password' => 'sometimes|boolean',
|
||||
]);
|
||||
|
||||
try {
|
||||
// If this WLAN is in a group, apply the same change to all grouped WLANs
|
||||
// Password/hide changes apply to all grouped WLANs
|
||||
$shared = array_filter($data, fn ($k) => in_array($k, ['x_passphrase', 'hide_ssid']), ARRAY_FILTER_USE_KEY);
|
||||
$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);
|
||||
if (! empty($shared)) {
|
||||
if ($groupedIds) {
|
||||
foreach ($groupedIds as $id) {
|
||||
$unifi->updateWlan($id, $shared);
|
||||
}
|
||||
} else {
|
||||
$unifi->updateWlan($wlanId, $shared);
|
||||
}
|
||||
} else {
|
||||
$unifi->updateWlan($wlanId, $data);
|
||||
}
|
||||
|
||||
// MAC filter changes apply to this WLAN only
|
||||
$perWlan = array_filter($data, fn ($k) => in_array($k, ['mac_filter_enabled', 'mac_filter_policy']), ARRAY_FILTER_USE_KEY);
|
||||
if (! empty($perWlan)) {
|
||||
$unifi->updateWlan($wlanId, $perWlan);
|
||||
}
|
||||
|
||||
// Toggle wlan_id in the rotation list
|
||||
if ($request->has('rotate_password')) {
|
||||
$rotateIds = json_decode(Setting::get('unifi.password_rotation.wlan_ids', '[]'), true) ?: [];
|
||||
$targetIds = $groupedIds ?: [$wlanId];
|
||||
if ($request->boolean('rotate_password')) {
|
||||
$rotateIds = array_values(array_unique(array_merge($rotateIds, $targetIds)));
|
||||
} else {
|
||||
$rotateIds = array_values(array_diff($rotateIds, $targetIds));
|
||||
}
|
||||
Setting::set('unifi.password_rotation.wlan_ids', json_encode($rotateIds));
|
||||
}
|
||||
|
||||
return back()->with('success', 'WiFi network updated.');
|
||||
@@ -71,14 +98,29 @@ class WifiController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update AP group assignments for a single WLAN (not synced to group siblings).
|
||||
*/
|
||||
public function updateApGroups(Request $request, string $wlanId, UnifiApiClient $unifi)
|
||||
{
|
||||
$request->validate(['ap_group_ids' => 'required|array']);
|
||||
|
||||
try {
|
||||
$unifi->updateWlan($wlanId, ['ap_group_ids' => $request->ap_group_ids]);
|
||||
return back()->with('success', 'AP groups 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) ?: [];
|
||||
$groups = json_decode(Setting::get('unifi.ssid_groups', '{}'), true) ?: [];
|
||||
$groupedIds = collect($groups)->first(fn ($ids) => in_array($wlanId, $ids));
|
||||
$enabled = $request->boolean('enabled');
|
||||
$enabled = $request->boolean('enabled');
|
||||
|
||||
if ($groupedIds) {
|
||||
foreach ($groupedIds as $id) {
|
||||
@@ -94,6 +136,256 @@ class WifiController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
// ── PPSK ─────────────────────────────────────────────────────────────────
|
||||
|
||||
public function ppskIndex(string $wlanId, UnifiApiClient $unifi)
|
||||
{
|
||||
try {
|
||||
$liveEntries = $unifi->getPpskEntries($wlanId);
|
||||
|
||||
// Network confs are best-effort — don't let a failure block PPSK display
|
||||
try {
|
||||
$networksRaw = $unifi->getNetworkConfs();
|
||||
} catch (\Throwable $e) {
|
||||
$networksRaw = [];
|
||||
}
|
||||
$networksById = collect($networksRaw)->keyBy('_id');
|
||||
|
||||
// ── Sync live entries into DB ────────────────────────────────────
|
||||
$liveIds = [];
|
||||
foreach ($liveEntries as $entry) {
|
||||
$pass = $entry['x_passphrase'] ?? $entry['password'] ?? null;
|
||||
$uid = $entry['_id'] ?? $entry['id'] ?? null;
|
||||
// wlan_embedded PPSKs have no _id — derive a stable synthetic ID from the passphrase
|
||||
if (! $uid && $pass) {
|
||||
$uid = 'emb_' . substr(hash('sha256', $wlanId . ':' . $pass), 0, 32);
|
||||
}
|
||||
if (! $uid) continue;
|
||||
$liveIds[] = $uid;
|
||||
|
||||
$nconfId = $entry['networkconf_id'] ?? null;
|
||||
$vlan = ($nconfId && isset($networksById[$nconfId]))
|
||||
? ($networksById[$nconfId]['vlan'] ?? null)
|
||||
: null;
|
||||
if ($vlan === null && ! empty($entry['vlan_id'])) {
|
||||
$vlan = (int) $entry['vlan_id'];
|
||||
}
|
||||
$name = $entry['name'] ?? $entry['label'] ?? $entry['username'] ?? null;
|
||||
// For anonymous PPSKs, use the associated network name as the default label
|
||||
if (! $name && $nconfId && isset($networksById[$nconfId])) {
|
||||
$name = $networksById[$nconfId]['name'] ?? null;
|
||||
}
|
||||
|
||||
// Match by unifi_id, or by passphrase for a held embedded record re-appearing
|
||||
$record = UnifiPpsk::where('unifi_id', $uid)->first()
|
||||
?? UnifiPpsk::where('wlan_id', $wlanId)
|
||||
->where('x_passphrase', $pass)
|
||||
->where('state', 'held')
|
||||
->first();
|
||||
|
||||
if ($record) {
|
||||
$upd = ['unifi_id' => $uid, 'state' => 'active'];
|
||||
if ($name) $upd['name'] = $name;
|
||||
if ($pass) $upd['x_passphrase'] = $pass;
|
||||
if ($vlan !== null) $upd['vlan'] = $vlan;
|
||||
$record->update($upd);
|
||||
} else {
|
||||
UnifiPpsk::create([
|
||||
'wlan_id' => $wlanId,
|
||||
'unifi_id' => $uid,
|
||||
'name' => $name ?? 'PPSK',
|
||||
'x_passphrase' => $pass ?? '',
|
||||
'vlan' => $vlan,
|
||||
'state' => 'active',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Only mark as held when we have confirmed live IDs —
|
||||
// never wipe on an empty API response (prevents false-holds on API failures)
|
||||
if (! empty($liveIds)) {
|
||||
UnifiPpsk::where('wlan_id', $wlanId)
|
||||
->where('state', 'active')
|
||||
->whereNotNull('unifi_id')
|
||||
->whereNotIn('unifi_id', $liveIds)
|
||||
->update(['state' => 'held', 'unifi_id' => null]);
|
||||
}
|
||||
|
||||
$dbRecords = UnifiPpsk::where('wlan_id', $wlanId)
|
||||
->orderByRaw("FIELD(state, 'active', 'held')")
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Fallback: if DB still empty but live entries exist, return live entries directly.
|
||||
// Applies the same synthetic-ID and networkconf logic so IDs are always non-null.
|
||||
if ($dbRecords->isEmpty() && ! empty($liveEntries)) {
|
||||
$entries = collect($liveEntries)->map(function ($e) use ($wlanId, $networksById) {
|
||||
$pass = $e['x_passphrase'] ?? $e['password'] ?? null;
|
||||
$uid = $e['_id'] ?? $e['id'] ?? null;
|
||||
if (! $uid && $pass) {
|
||||
$uid = 'emb_' . substr(hash('sha256', $wlanId . ':' . $pass), 0, 32);
|
||||
}
|
||||
$nconfId = $e['networkconf_id'] ?? null;
|
||||
$vlan = ($nconfId && isset($networksById[$nconfId]))
|
||||
? ($networksById[$nconfId]['vlan'] ?? null) : null;
|
||||
if ($vlan === null && ! empty($e['vlan_id'])) {
|
||||
$vlan = (int) $e['vlan_id'];
|
||||
}
|
||||
$name = $e['name'] ?? $e['label'] ?? $e['username'] ?? null;
|
||||
if (! $name && $nconfId && isset($networksById[$nconfId])) {
|
||||
$name = $networksById[$nconfId]['name'] ?? null;
|
||||
}
|
||||
return [
|
||||
'id' => $uid,
|
||||
'unifi_id' => $uid,
|
||||
'name' => $name ?? 'PPSK',
|
||||
'x_passphrase' => $pass,
|
||||
'vlan' => $vlan,
|
||||
'state' => 'active',
|
||||
'rotate_password' => false,
|
||||
'schedule' => null,
|
||||
];
|
||||
})->values();
|
||||
} else {
|
||||
$entries = $dbRecords->map(fn ($r) => $this->mapPpsk($r));
|
||||
}
|
||||
|
||||
$networks = $networksById->values()->map(fn ($n) => [
|
||||
'_id' => $n['_id'],
|
||||
'name' => $n['name'] ?? 'Unnamed',
|
||||
'vlan' => $n['vlan'] ?? null,
|
||||
]);
|
||||
|
||||
return response()->json(['ok' => true, 'entries' => $entries, 'networks' => $networks]);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['ok' => false, 'error' => $e->getMessage()], 422);
|
||||
}
|
||||
}
|
||||
|
||||
public function ppskStore(Request $request, string $wlanId, UnifiApiClient $unifi)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'x_passphrase' => 'required|string|min:8|max:63',
|
||||
'networkconf_id' => 'nullable|string',
|
||||
'vlan' => 'nullable|integer',
|
||||
]);
|
||||
|
||||
try {
|
||||
$pushData = [
|
||||
'name' => $data['name'],
|
||||
'x_passphrase' => $data['x_passphrase'],
|
||||
'wlan_id' => $wlanId,
|
||||
];
|
||||
if (! empty($data['networkconf_id'])) {
|
||||
$pushData['networkconf_id'] = $data['networkconf_id'];
|
||||
}
|
||||
|
||||
$result = $unifi->createPpsk($pushData);
|
||||
$raw = $result[0] ?? $result;
|
||||
$unifiId = $raw['_id'] ?? null;
|
||||
|
||||
$record = UnifiPpsk::create([
|
||||
'wlan_id' => $wlanId,
|
||||
'unifi_id' => $unifiId,
|
||||
'name' => $data['name'],
|
||||
'x_passphrase' => $data['x_passphrase'],
|
||||
'vlan' => $data['vlan'] ?? null,
|
||||
'state' => 'active',
|
||||
]);
|
||||
|
||||
return response()->json(['ok' => true, 'entry' => $this->mapPpsk($record)]);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['ok' => false, 'error' => $e->getMessage()], 422);
|
||||
}
|
||||
}
|
||||
|
||||
public function ppskUpdate(Request $request, string $wlanId, string $ppskId, UnifiApiClient $unifi)
|
||||
{
|
||||
$record = UnifiPpsk::findOrFail($ppskId);
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => 'sometimes|string|max:100',
|
||||
'x_passphrase' => 'sometimes|string|min:8|max:63',
|
||||
'networkconf_id' => 'nullable|string',
|
||||
'vlan' => 'nullable|integer',
|
||||
]);
|
||||
|
||||
try {
|
||||
if ($record->unifi_id && $record->state === 'active') {
|
||||
$unifiUpdate = array_filter(
|
||||
array_intersect_key($data, array_flip(['name', 'x_passphrase', 'networkconf_id'])),
|
||||
fn ($v) => $v !== null
|
||||
);
|
||||
if (! empty($unifiUpdate)) {
|
||||
$unifi->updatePpsk($record->unifi_id, $unifiUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
$dbUpdate = array_intersect_key($data, array_flip(['name', 'x_passphrase']));
|
||||
// vlan can be explicitly set to null
|
||||
if (array_key_exists('vlan', $data)) $dbUpdate['vlan'] = $data['vlan'];
|
||||
if (! empty($dbUpdate)) $record->update($dbUpdate);
|
||||
|
||||
return response()->json(['ok' => true, 'entry' => $this->mapPpsk($record->fresh())]);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['ok' => false, 'error' => $e->getMessage()], 422);
|
||||
}
|
||||
}
|
||||
|
||||
public function ppskDestroy(string $wlanId, string $ppskId, UnifiApiClient $unifi)
|
||||
{
|
||||
$record = UnifiPpsk::findOrFail($ppskId);
|
||||
|
||||
try {
|
||||
if ($record->unifi_id) {
|
||||
try { $unifi->kickClientsForPpsk($record->unifi_id); } catch (\Throwable) {}
|
||||
// Embedded PPSKs (synthetic emb_ IDs) aren't deletable via the PPSK REST endpoint;
|
||||
// skip the API call — the entry will disappear from UniFi when the WLAN is reconfigured.
|
||||
if (! str_starts_with($record->unifi_id, 'emb_')) {
|
||||
$unifi->deletePpsk($record->unifi_id);
|
||||
}
|
||||
}
|
||||
$record->delete();
|
||||
return response()->json(['ok' => true]);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['ok' => false, 'error' => $e->getMessage()], 422);
|
||||
}
|
||||
}
|
||||
|
||||
public function ppskSchedule(Request $request, string $wlanId, string $ppskId)
|
||||
{
|
||||
$record = UnifiPpsk::findOrFail($ppskId);
|
||||
$request->validate([
|
||||
'schedule' => 'nullable|array',
|
||||
'schedule.*' => 'boolean',
|
||||
]);
|
||||
$record->update(['schedule' => $request->schedule]);
|
||||
return response()->json(['ok' => true, 'entry' => $this->mapPpsk($record->fresh())]);
|
||||
}
|
||||
|
||||
public function ppskToggleRotation(Request $request, string $wlanId, string $ppskId)
|
||||
{
|
||||
$record = UnifiPpsk::findOrFail($ppskId);
|
||||
$request->validate(['rotate_password' => 'required|boolean']);
|
||||
$record->update(['rotate_password' => $request->boolean('rotate_password')]);
|
||||
return response()->json(['ok' => true, 'entry' => $this->mapPpsk($record)]);
|
||||
}
|
||||
|
||||
private function mapPpsk(UnifiPpsk $r): array
|
||||
{
|
||||
return [
|
||||
'id' => $r->id,
|
||||
'unifi_id' => $r->unifi_id,
|
||||
'name' => $r->name,
|
||||
'x_passphrase' => $r->x_passphrase,
|
||||
'vlan' => $r->vlan,
|
||||
'state' => $r->state,
|
||||
'rotate_password' => $r->rotate_password,
|
||||
'schedule' => $r->schedule,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Group/ungroup SSIDs. Groups are stored as { "GroupName": ["wlan_id_1", "wlan_id_2"] }
|
||||
*/
|
||||
@@ -106,16 +398,40 @@ class WifiController extends Controller
|
||||
return back()->with('success', 'SSID groups saved.');
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private function mapWlan(array $w): array
|
||||
{
|
||||
return [
|
||||
'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),
|
||||
'ap_group_ids' => $w['ap_group_ids'] ?? [],
|
||||
'mac_filter_enabled' => $w['mac_filter_enabled'] ?? false,
|
||||
'mac_filter_policy' => $w['mac_filter_policy'] ?? 'deny',
|
||||
'ppsk_enabled' => ($w['wpa3_ppsk'] ?? false)
|
||||
|| ($w['ppsk'] ?? false)
|
||||
|| ($w['private_preshared_keys_enabled'] ?? false)
|
||||
|| ! empty($w['private_preshared_keys']),
|
||||
];
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
@@ -8,6 +8,6 @@ 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'];
|
||||
protected $fillable = ['device_mac', 'device_name', 'was_online', 'in_alert', 'consecutive_count', 'last_seen_at', 'updated_at'];
|
||||
protected $casts = ['was_online' => 'boolean', 'in_alert' => 'boolean', 'last_seen_at' => 'datetime', 'updated_at' => 'datetime'];
|
||||
}
|
||||
|
||||
30
src/Models/UnifiPpsk.php
Normal file
30
src/Models/UnifiPpsk.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace Dashboard\Unifi\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class UnifiPpsk extends Model
|
||||
{
|
||||
protected $table = 'unifi_ppsks';
|
||||
protected $fillable = [
|
||||
'wlan_id', 'unifi_id', 'name', 'x_passphrase', 'vlan',
|
||||
'state', 'rotate_password', 'schedule',
|
||||
];
|
||||
protected $casts = [
|
||||
'vlan' => 'integer',
|
||||
'rotate_password' => 'boolean',
|
||||
'schedule' => 'array',
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns true if this PPSK should be active at the given day (0=Sun…6=Sat)
|
||||
* and half-hour slot (0=00:00, 47=23:30).
|
||||
* A null schedule means always-on.
|
||||
*/
|
||||
public function isScheduledOnAt(int $day, int $slot): bool
|
||||
{
|
||||
if (! $this->schedule) return true;
|
||||
return (bool) ($this->schedule[$day * 48 + $slot] ?? true);
|
||||
}
|
||||
}
|
||||
12
src/Models/VlanGroup.php
Normal file
12
src/Models/VlanGroup.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace Dashboard\Unifi\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class VlanGroup extends Model
|
||||
{
|
||||
protected $table = 'unifi_vlan_groups';
|
||||
protected $fillable = ['name', 'vlan_id', 'description', 'sort_order'];
|
||||
protected $casts = ['vlan_id' => 'integer', 'sort_order' => 'integer'];
|
||||
}
|
||||
@@ -286,6 +286,20 @@ class UnifiApiClient
|
||||
return $this->post('/cmd/stamgr', ['cmd' => 'unauthorize-guest', 'mac' => strtolower($mac)]);
|
||||
}
|
||||
|
||||
private function delete(string $path): void
|
||||
{
|
||||
$this->init();
|
||||
$url = $this->siteUrl($path);
|
||||
$response = $this->buildRequest()->delete($url);
|
||||
if ($response->status() === 401) {
|
||||
Cache::forget('unifi:session:' . md5($this->baseUrl . $this->username));
|
||||
$response = $this->buildRequest()->delete($url);
|
||||
}
|
||||
if (! $response->successful()) {
|
||||
throw new \RuntimeException("UniFi API error: HTTP {$response->status()}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── WiFi Networks / WLANs ─────────────────────────────────────────────────
|
||||
|
||||
public function getWlans(): array
|
||||
@@ -298,6 +312,252 @@ class UnifiApiClient
|
||||
return $this->put("/rest/wlanconf/{$wlanId}", $data);
|
||||
}
|
||||
|
||||
// ── AP Groups ─────────────────────────────────────────────────────────────
|
||||
|
||||
public function getApGroups(): array
|
||||
{
|
||||
return $this->get('/rest/apgroups');
|
||||
}
|
||||
|
||||
public function createApGroup(array $data): array
|
||||
{
|
||||
return $this->post('/rest/apgroups', $data);
|
||||
}
|
||||
|
||||
public function updateApGroup(string $groupId, array $data): array
|
||||
{
|
||||
return $this->put("/rest/apgroups/{$groupId}", $data);
|
||||
}
|
||||
|
||||
public function deleteApGroup(string $groupId): void
|
||||
{
|
||||
$this->delete("/rest/apgroups/{$groupId}");
|
||||
}
|
||||
|
||||
// ── PPSK ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a v2 API URL. UniFi OS consoles expose /proxy/network/v2/api/site/{site}/...
|
||||
* Standalone controllers may not have this path.
|
||||
*/
|
||||
private function v2SiteUrl(string $path): string
|
||||
{
|
||||
$this->init();
|
||||
$v2Base = str_contains($this->apiPath(), 'proxy') ? '/proxy/network' : '';
|
||||
return "{$this->baseUrl}{$v2Base}/v2/api/site/{$this->site}{$path}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether an HTTP response is a usable JSON response (not a 404 HTML page).
|
||||
*/
|
||||
private function isJsonResponse($response): bool
|
||||
{
|
||||
if (! $response->successful()) return false;
|
||||
$ct = $response->header('Content-Type', '');
|
||||
return str_contains($ct, 'json') || (! str_contains($ct, 'html'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize PPSK entries to a consistent shape regardless of API version.
|
||||
*
|
||||
* We merge the original raw entry with our normalized aliases so:
|
||||
* - All original fields survive (frontend can inspect them)
|
||||
* - Standard field names (_id, name, x_passphrase, wlan_id, vlan_id) are
|
||||
* always present, mapped from whatever variant the API used
|
||||
*/
|
||||
private function normalizePpsk(array $entries): array
|
||||
{
|
||||
return array_values(array_map(function ($e) {
|
||||
$normalized = [
|
||||
'_id' => $e['_id'] ?? $e['id'] ?? null,
|
||||
'name' => $e['name'] ?? $e['label'] ?? $e['username'] ?? $e['privatePskName'] ?? null,
|
||||
'x_passphrase' => $e['x_passphrase'] ?? $e['password'] ?? $e['passphrase'] ?? $e['psk'] ?? null,
|
||||
'wlan_id' => $e['wlan_id'] ?? $e['wlanId'] ?? null,
|
||||
'networkconf_id'=> $e['networkconf_id'] ?? $e['network_conf_id'] ?? $e['networkId'] ?? null,
|
||||
'vlan_id' => $e['vlan_id'] ?? $e['vlanId'] ?? $e['vlan'] ?? null,
|
||||
];
|
||||
return array_merge($e, array_filter($normalized, fn ($v) => $v !== null));
|
||||
}, $entries));
|
||||
}
|
||||
|
||||
public function getNetworkConfs(): array
|
||||
{
|
||||
return $this->get('/rest/networkconf');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch PPSK entries for a WLAN.
|
||||
*
|
||||
* Tries multiple endpoints in order of likelihood, because the correct path
|
||||
* varies significantly by controller version and firmware:
|
||||
*
|
||||
* 1. Classic REST with wlan_id filter (works on most standalone controllers)
|
||||
* 2. Classic REST fetch-all + local filter (when query param is ignored)
|
||||
* 3. v2 hotspot endpoint (UniFi Network App 7.x+)
|
||||
* 4. v2 wlan password endpoint (some UDM firmware variants)
|
||||
* 5. Embedded in the WLAN object itself (some controller versions)
|
||||
*/
|
||||
public function getPpskEntries(string $wlanId): array
|
||||
{
|
||||
// 1. Classic path with filter
|
||||
try {
|
||||
$results = $this->get("/rest/ppsk?wlan_id={$wlanId}");
|
||||
if (! empty($results)) {
|
||||
Log::info('unifi.ppsk_found', ['path' => 'classic_filter', 'count' => count($results)]);
|
||||
return $this->normalizePpsk($results);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::debug('unifi.ppsk_classic_filter_failed', ['error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
// 2. Classic path without filter — fetch all and filter locally
|
||||
try {
|
||||
$all = $this->get('/rest/ppsk');
|
||||
Log::debug('unifi.ppsk_classic_all', ['total' => count($all)]);
|
||||
if (! empty($all)) {
|
||||
$filtered = array_filter($all, fn ($e) => ($e['wlan_id'] ?? '') === $wlanId);
|
||||
if (! empty($filtered)) {
|
||||
Log::info('unifi.ppsk_found', ['path' => 'classic_all_filtered', 'count' => count($filtered)]);
|
||||
return $this->normalizePpsk(array_values($filtered));
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::debug('unifi.ppsk_classic_all_failed', ['error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
// 3–4. v2 API paths (try each, skip HTML error pages)
|
||||
$v2Paths = [
|
||||
"/hotspot/op/private-preshared-key?wlanId={$wlanId}",
|
||||
"/wlan/{$wlanId}/password",
|
||||
"/wlan/{$wlanId}/private-preshared-key",
|
||||
];
|
||||
|
||||
foreach ($v2Paths as $path) {
|
||||
try {
|
||||
$url = $this->v2SiteUrl($path);
|
||||
$response = $this->buildRequest()->get($url);
|
||||
Log::debug('unifi.ppsk_v2_probe', ['path' => $path, 'status' => $response->status(), 'ct' => $response->header('Content-Type')]);
|
||||
if (! $this->isJsonResponse($response)) continue;
|
||||
|
||||
$data = $response->json('data') ?? $response->json() ?? [];
|
||||
if (is_array($data) && ! empty($data)) {
|
||||
Log::info('unifi.ppsk_found', ['path' => $path, 'count' => count($data)]);
|
||||
return $this->normalizePpsk(array_values($data));
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::debug('unifi.ppsk_v2_path_failed', ['path' => $path, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Check if PPSKs are embedded in the WLAN object
|
||||
try {
|
||||
$wlan = $this->get("/rest/wlanconf/{$wlanId}");
|
||||
$w = $wlan[0] ?? $wlan;
|
||||
$embedded = $w['private_preshared_keys'] ?? [];
|
||||
if (! empty($embedded)) {
|
||||
Log::info('unifi.ppsk_found', ['path' => 'wlan_embedded', 'count' => count($embedded)]);
|
||||
return $this->normalizePpsk($embedded);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::debug('unifi.ppsk_wlan_embedded_failed', ['error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
Log::warning('unifi.ppsk_all_paths_empty', ['wlan_id' => $wlanId]);
|
||||
// All paths exhausted — return empty rather than erroring (network may just have no PPSKs yet)
|
||||
return [];
|
||||
}
|
||||
|
||||
public function createPpsk(array $data): array
|
||||
{
|
||||
$wlanId = $data['wlan_id'] ?? null;
|
||||
|
||||
// Try v2 hotspot endpoint first
|
||||
if ($wlanId) {
|
||||
try {
|
||||
$url = $this->v2SiteUrl('/hotspot/op/private-preshared-key');
|
||||
$response = $this->buildRequest()->asJson()->post($url, $data);
|
||||
if ($this->isJsonResponse($response)) {
|
||||
return $this->normalizePpsk([$response->json('data') ?? $response->json()]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::debug('unifi.ppsk_v2_hotspot_create_failed', ['error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
// Try v2 wlan password endpoint
|
||||
try {
|
||||
$url = $this->v2SiteUrl("/wlan/{$wlanId}/password");
|
||||
$response = $this->buildRequest()->asJson()->post($url, $data);
|
||||
if ($this->isJsonResponse($response)) {
|
||||
$result = $response->json('data') ?? $response->json();
|
||||
return $this->normalizePpsk(is_array($result) && isset($result[0]) ? $result : [$result]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::debug('unifi.ppsk_v2_wlan_create_failed', ['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to classic REST
|
||||
$result = $this->post('/rest/ppsk', $data);
|
||||
return $this->normalizePpsk(is_array($result) && isset($result[0]) ? $result : [$result]);
|
||||
}
|
||||
|
||||
public function updatePpsk(string $ppskId, array $data): array
|
||||
{
|
||||
// Try v2 hotspot endpoint first
|
||||
try {
|
||||
$url = $this->v2SiteUrl("/hotspot/op/private-preshared-key/{$ppskId}");
|
||||
$response = $this->buildRequest()->asJson()->put($url, $data);
|
||||
if ($this->isJsonResponse($response)) {
|
||||
return $this->normalizePpsk([$response->json('data') ?? $response->json()]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::debug('unifi.ppsk_v2_hotspot_update_failed', ['error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
$result = $this->put("/rest/ppsk/{$ppskId}", $data);
|
||||
return $this->normalizePpsk(is_array($result) && isset($result[0]) ? $result : [$result]);
|
||||
}
|
||||
|
||||
public function deletePpsk(string $ppskId): void
|
||||
{
|
||||
// Try v2 hotspot endpoint first
|
||||
try {
|
||||
$url = $this->v2SiteUrl("/hotspot/op/private-preshared-key/{$ppskId}");
|
||||
$response = $this->buildRequest()->delete($url);
|
||||
if ($response->successful()) return;
|
||||
} catch (\Throwable $e) {
|
||||
Log::debug('unifi.ppsk_v2_hotspot_delete_failed', ['error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
$this->delete("/rest/ppsk/{$ppskId}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Kick (deauth) every client currently connected via the given PPSK.
|
||||
* UniFi station records include a _psk_id field matching the PPSK's _id.
|
||||
* Returns the number of clients kicked.
|
||||
*/
|
||||
public function kickClientsForPpsk(string $ppskUnifiId): int
|
||||
{
|
||||
$kicked = 0;
|
||||
try {
|
||||
$clients = $this->get('/rest/sta');
|
||||
foreach ($clients as $client) {
|
||||
if (($client['_psk_id'] ?? null) !== $ppskUnifiId) continue;
|
||||
try {
|
||||
$this->post('/cmd/stamgr', ['cmd' => 'kick-sta', 'mac' => $client['mac']]);
|
||||
$kicked++;
|
||||
} catch (\Throwable $e) {
|
||||
// continue kicking remaining clients even if one fails
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// non-fatal: if we can't list clients, skip kicking
|
||||
Log::debug('unifi.ppsk_kick_clients_failed', ['ppsk_id' => $ppskUnifiId, 'error' => $e->getMessage()]);
|
||||
}
|
||||
return $kicked;
|
||||
}
|
||||
|
||||
// ── Health / Stats ────────────────────────────────────────────────────────
|
||||
|
||||
public function getSiteHealth(): array
|
||||
|
||||
@@ -5,29 +5,42 @@ 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)',
|
||||
];
|
||||
|
||||
/**
|
||||
* Available template variables per event type.
|
||||
*/
|
||||
public const TEMPLATE_VARS = [
|
||||
'device_offline' => ['{{device}}', '{{mac}}', '{{timestamp}}'],
|
||||
'device_online' => ['{{device}}', '{{mac}}', '{{timestamp}}'],
|
||||
@@ -36,9 +49,13 @@ class WebhookCheckService
|
||||
'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}}'],
|
||||
];
|
||||
@@ -51,9 +68,13 @@ class WebhookCheckService
|
||||
'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)',
|
||||
];
|
||||
@@ -71,15 +92,14 @@ class WebhookCheckService
|
||||
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']));
|
||||
$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');
|
||||
$wan = collect($health)->firstWhere('subsystem', 'wan');
|
||||
$aps = collect($devices)->where('type', 'uap');
|
||||
$fired = 0;
|
||||
|
||||
foreach ($configs as $config) {
|
||||
@@ -88,30 +108,45 @@ class WebhookCheckService
|
||||
$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),
|
||||
'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 => [],
|
||||
'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;
|
||||
// Apply message template
|
||||
|
||||
$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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,6 +157,60 @@ class WebhookCheckService
|
||||
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
|
||||
@@ -136,17 +225,13 @@ class WebhookCheckService
|
||||
$name = is_array($entry) ? ($entry['name'] ?? $mac) : $mac;
|
||||
if (! $mac) continue;
|
||||
|
||||
$prev = DeviceState::where('device_mac', $mac)->first();
|
||||
$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
|
||||
if ($comingOnline && $prev->was_online === false && $isOnline && $prev->consecutive_count >= 1) {
|
||||
$alerts[] = [
|
||||
'key' => $mac,
|
||||
'client_name' => $name,
|
||||
@@ -158,7 +243,7 @@ class WebhookCheckService
|
||||
'message' => "{$name} ({$mac}) connected",
|
||||
];
|
||||
}
|
||||
if (! $comingOnline && $prev->was_online === true && ! $isOnline && $prev->consecutive_count >= 2) { // 3rd consecutive poll offline
|
||||
if (! $comingOnline && $prev->was_online === true && ! $isOnline && $prev->consecutive_count >= 2) {
|
||||
$alerts[] = [
|
||||
'key' => $mac,
|
||||
'client_name' => $name,
|
||||
@@ -172,89 +257,7 @@ class WebhookCheckService
|
||||
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;
|
||||
}
|
||||
// ── WAN ──────────────────────────────────────────────────────────────────
|
||||
|
||||
private function checkWan(?array $wan, bool $comingUp): array
|
||||
{
|
||||
@@ -268,61 +271,222 @@ class WebhookCheckService
|
||||
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;
|
||||
}
|
||||
// ── Threshold checks (alert on entry, resolved on exit) ──────────────────
|
||||
|
||||
private function checkCu($aps, int $threshold, ?string $band, array $filter): array
|
||||
/**
|
||||
* 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;
|
||||
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"];
|
||||
$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;
|
||||
}
|
||||
|
||||
private function checkSatisfaction($aps, int $minSat, array $filter): array
|
||||
/**
|
||||
* 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;
|
||||
$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"];
|
||||
$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;
|
||||
}
|
||||
|
||||
private function checkErrorRate($aps, array $filter): array
|
||||
/**
|
||||
* 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;
|
||||
$retries = $ap['stat']['ap']['tx_retries'] ?? 0;
|
||||
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) {
|
||||
$name = $ap['name'] ?? $ap['mac'];
|
||||
$alerts[] = ['key' => $ap['mac'], 'device' => $name, 'mac' => $ap['mac'], 'retries' => $retries, 'message' => "{$name}: high retries ({$retries})"];
|
||||
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;
|
||||
@@ -334,8 +498,9 @@ class WebhookCheckService
|
||||
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"];
|
||||
$name = $dev['name'] ?? $dev['mac'];
|
||||
$alerts[] = ['key' => $dev['mac'], 'device' => $name, 'mac' => $dev['mac'],
|
||||
'version' => $dev['version'] ?? '', 'message' => "{$name}: firmware update available"];
|
||||
}
|
||||
}
|
||||
return $alerts;
|
||||
@@ -346,12 +511,14 @@ class WebhookCheckService
|
||||
$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"];
|
||||
$name = $ap['name'] ?? $ap['mac'];
|
||||
$alerts[] = ['key' => $ap['mac'], 'device' => $name, 'mac' => $ap['mac'],
|
||||
'uptime' => $uptime, 'message' => "{$name}: unexpected reboot"];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -390,7 +557,6 @@ class WebhookCheckService
|
||||
'data' => $data,
|
||||
];
|
||||
|
||||
// Format payload for the target platform
|
||||
$url = $config->url;
|
||||
$payload = $this->formatPayloadForPlatform($url, $message, $internalPayload);
|
||||
|
||||
@@ -413,46 +579,26 @@ class WebhookCheckService
|
||||
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
|
||||
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 with consecutive_count for debouncing.
|
||||
* Sync device states. Tracks consecutive_count and was_online.
|
||||
* in_alert is managed separately in checkDeviceTransition (updated after confirmed fire).
|
||||
*
|
||||
* 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.
|
||||
* 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)
|
||||
*
|
||||
* 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)
|
||||
* 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
|
||||
{
|
||||
@@ -463,9 +609,13 @@ class WebhookCheckService
|
||||
|
||||
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(),
|
||||
'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;
|
||||
}
|
||||
@@ -473,22 +623,67 @@ class WebhookCheckService
|
||||
$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]);
|
||||
// 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 {
|
||||
// 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]);
|
||||
'last_seen_at' => now(), 'updated_at' => now()]);
|
||||
} else {
|
||||
// Not yet confirmed — just bump counter, keep was_online unchanged
|
||||
$prev->update(['consecutive_count' => $count, 'last_seen_at' => now(), 'updated_at' => now()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@ class UnifiServiceProvider extends ServiceProvider
|
||||
Console\CheckWebhooks::class,
|
||||
Console\CaptureSnapshots::class,
|
||||
Console\CleanupSnapshots::class,
|
||||
Console\RebootAllAps::class,
|
||||
Console\RotatePasswords::class,
|
||||
Console\SyncPpskSchedules::class,
|
||||
]);
|
||||
$this->publishes([
|
||||
__DIR__ . '/../config/unifi.php' => config_path('unifi.php'),
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<?php
|
||||
|
||||
use Dashboard\Unifi\Http\Controllers\ApGroupController;
|
||||
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\VlanGroupController;
|
||||
use Dashboard\Unifi\Http\Controllers\WebhookController;
|
||||
use Dashboard\Unifi\Http\Controllers\WifiController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
@@ -21,15 +23,34 @@ Route::middleware(['web', 'auth', 'app.access:unifi'])
|
||||
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');
|
||||
Route::get('/client-dashboard', [StatsController::class, 'clientDashboard']) ->name('client.dashboard');
|
||||
Route::get('/client-ap-traffic', [StatsController::class, 'clientApTraffic']) ->name('client.ap-traffic');
|
||||
});
|
||||
|
||||
// ── 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');
|
||||
// WiFi networks
|
||||
Route::get('/wifi', [WifiController::class, 'index']) ->name('wifi');
|
||||
Route::put('/wifi/{wlanId}', [WifiController::class, 'update']) ->name('wifi.update');
|
||||
Route::put('/wifi/{wlanId}/ap-groups', [WifiController::class, 'updateApGroups']) ->name('wifi.ap-groups');
|
||||
Route::post('/wifi/{wlanId}/toggle', [WifiController::class, 'toggle']) ->name('wifi.toggle');
|
||||
Route::post('/wifi/groups', [WifiController::class, 'saveGroups']) ->name('wifi.groups');
|
||||
|
||||
// PPSK (per-WLAN pre-shared keys)
|
||||
Route::get('/wifi/{wlanId}/ppsk', [WifiController::class, 'ppskIndex']) ->name('wifi.ppsk.index');
|
||||
Route::post('/wifi/{wlanId}/ppsk', [WifiController::class, 'ppskStore']) ->name('wifi.ppsk.store');
|
||||
Route::put('/wifi/{wlanId}/ppsk/{ppskId}', [WifiController::class, 'ppskUpdate']) ->name('wifi.ppsk.update');
|
||||
Route::delete('/wifi/{wlanId}/ppsk/{ppskId}', [WifiController::class, 'ppskDestroy']) ->name('wifi.ppsk.destroy');
|
||||
Route::put('/wifi/{wlanId}/ppsk/{ppskId}/schedule', [WifiController::class, 'ppskSchedule']) ->name('wifi.ppsk.schedule');
|
||||
Route::patch('/wifi/{wlanId}/ppsk/{ppskId}/rotation',[WifiController::class, 'ppskToggleRotation'])->name('wifi.ppsk.rotation');
|
||||
|
||||
// AP Groups
|
||||
Route::get('/ap-groups', [ApGroupController::class, 'index']) ->name('ap-groups.index');
|
||||
Route::post('/ap-groups', [ApGroupController::class, 'store']) ->name('ap-groups.store');
|
||||
Route::put('/ap-groups/{groupId}', [ApGroupController::class, 'update']) ->name('ap-groups.update');
|
||||
Route::delete('/ap-groups/{groupId}', [ApGroupController::class, 'destroy']) ->name('ap-groups.destroy');
|
||||
|
||||
// Devices
|
||||
Route::post('/devices/reboot', [DeviceController::class, 'reboot']) ->name('devices.reboot');
|
||||
Route::post('/clients/kick', [ClientController::class, 'kick']) ->name('clients.kick');
|
||||
});
|
||||
@@ -43,13 +64,18 @@ Route::middleware(['web', 'auth', 'app.access:unifi'])
|
||||
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');
|
||||
|
||||
// VLAN Groups
|
||||
Route::post('/portal/vlan-groups', [VlanGroupController::class, 'store']) ->name('portal.vlan-groups.store');
|
||||
Route::put('/portal/vlan-groups/{vlanGroup}', [VlanGroupController::class, 'update']) ->name('portal.vlan-groups.update');
|
||||
Route::delete('/portal/vlan-groups/{vlanGroup}', [VlanGroupController::class, 'destroy'])->name('portal.vlan-groups.destroy');
|
||||
});
|
||||
|
||||
// ── 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/test', [UnifiSettingsController::class, 'testConnection'])->name('settings.test');
|
||||
Route::post('/settings/sites', [UnifiSettingsController::class, 'fetchSites']) ->name('settings.sites');
|
||||
|
||||
// Webhooks
|
||||
|
||||
Reference in New Issue
Block a user