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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user