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:
2026-05-19 17:54:24 -04:00
parent ce3217d8f4
commit 0802ef35f3
22 changed files with 1771 additions and 305 deletions

View File

@@ -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,