feat(logs): structured cron run history + read endpoint

Adds unifi_cron_runs table (one row per scheduled-task execution) and
UnifiCronRun::record() wrapper that captures start/finish/status and
exceptions. The three scheduled commands now write through it:

  - reboot-all-aps    → rebooted/failed AP names per run
  - rotate-passwords  → rotated SSIDs + PPSKs, failures (when actually
                        rotating; the "is it due" early-return is silent
                        so we don't flood the log with no-op rows every
                        minute)
  - sync-ppsk-schedules → enabled/disabled PPSKs (silent when there's
                          no work)

UnifiCronLogsController returns the most-recent 200 runs as JSON,
filterable by command + status. Behind permission:unifi.settings; no
super-admin required — read-only history is fine for any operator
who can see settings.

v1.5.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-24 16:05:36 -04:00
parent a33f2885ff
commit 89b09cf096
8 changed files with 325 additions and 125 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "dashboard/unifi",
"description": "UniFi network management, WiFi stats, and captive portal authentication for the Dashboard platform",
"version": "1.4.0",
"version": "1.5.0",
"type": "library",
"license": "MIT",
"autoload": {

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Structured log of every unifi scheduled-task execution: AP reboots,
* password rotations, PPSK schedule syncs. One row per run.
* Surfaced in the Logs tab of the Unifi settings page.
*/
public function up(): void
{
Schema::create('unifi_cron_runs', function (Blueprint $table) {
$table->id();
$table->string('command', 64)->index(); // 'reboot-all-aps' | 'rotate-passwords' | 'sync-ppsk-schedules'
$table->enum('triggered_by', ['schedule', 'manual']);
$table->foreignId('triggered_by_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->timestamp('started_at')->index();
$table->timestamp('finished_at')->nullable();
$table->string('status', 16); // 'running' | 'succeeded' | 'partial' | 'failed' | 'skipped'
$table->longText('details')->nullable(); // JSON: counts, per-item actions, error summary
});
}
public function down(): void
{
Schema::dropIfExists('unifi_cron_runs');
}
};

View File

@@ -2,61 +2,68 @@
namespace Dashboard\Unifi\Console;
use Dashboard\Unifi\Models\UnifiCronRun;
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 $signature = 'unifi:reboot-all-aps {--delay=5 : Seconds to wait between each reboot} {--triggered-by=schedule}';
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;
}
$run = UnifiCronRun::record(
'reboot-all-aps',
$this->option('triggered-by') ?: 'schedule',
null,
function () use ($unifi) {
$aps = $unifi->getAccessPoints();
if (empty($aps)) {
$this->warn('No access points found.');
return self::SUCCESS;
}
if (empty($aps)) {
$this->warn('No access points found.');
return ['status' => 'skipped', 'reason' => 'no APs found'];
}
$delay = max(0, (int) $this->option('delay'));
$delay = max(0, (int) $this->option('delay'));
$rebooted = [];
$failed = [];
// 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})");
}
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();
$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})");
$rebooted[] = $name;
} catch (\Throwable $e) {
$this->error("Failed to reboot {$name}: {$e->getMessage()}");
$failed[] = ['name' => $name, 'error' => $e->getMessage()];
}
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 && count($rebooted) + count($failed) < count($aps)) {
sleep($delay);
}
}
return [
'status' => count($failed) === 0 ? 'succeeded' : (count($rebooted) > 0 ? 'partial' : 'failed'),
'rebooted' => $rebooted,
'failed' => $failed,
'total' => count($aps),
];
}
);
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;
$this->info("Done. Status: {$run->status}.");
return $run->status === 'failed' ? self::FAILURE : self::SUCCESS;
}
}

View File

@@ -3,76 +3,94 @@
namespace Dashboard\Unifi\Console;
use App\Models\Setting;
use Dashboard\Unifi\Models\UnifiCronRun;
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 $signature = 'unifi:rotate-passwords {--force : Run regardless of schedule} {--triggered-by=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.');
// Don't log anything — the scheduler runs this every minute
// and we'd flood the logs with "rotation disabled" rows.
return self::SUCCESS;
}
if (! $this->option('force') && ! $this->isDue()) {
// Same reasoning — only log when we actually do something.
return self::SUCCESS;
}
$password = $passwords[array_rand($passwords)];
$rotated = 0;
$force = $this->option('force');
$triggeredBy = $this->option('triggered-by') ?: 'schedule';
foreach ($wlanIds as $wlanId) {
try {
$unifi->updateWlan($wlanId, ['x_passphrase' => $password]);
$rotated++;
} catch (\Throwable $e) {
$this->error("Failed to rotate wlan {$wlanId}: {$e->getMessage()}");
$run = UnifiCronRun::record('rotate-passwords', $triggeredBy, null, function () use ($unifi, $force) {
$wlanIdsJson = Setting::get('unifi.password_rotation.wlan_ids', '[]');
$wlanIds = json_decode($wlanIdsJson, true);
if (empty($wlanIds) || ! is_array($wlanIds)) {
return ['status' => 'skipped', 'reason' => 'no SSIDs configured for rotation'];
}
}
if ($rotated > 0) {
Setting::set('unifi.password_rotation.last_rotated_at', now()->toIso8601String());
$this->info("Rotated password for {$rotated} SSID(s).");
}
$wordlist = Setting::get('unifi.password_rotation.wordlist', '');
$passwords = array_values(array_filter(array_map('trim', explode("\n", $wordlist))));
// ── 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 (empty($passwords)) {
$this->warn('Password rotation: no passwords in wordlist — skipped.');
return ['status' => 'skipped', 'reason' => 'empty wordlist'];
}
}
if ($rotatedPpsks > 0) {
$this->info("Rotated password for {$rotatedPpsks} PPSK(s).");
}
return self::SUCCESS;
$password = $passwords[array_rand($passwords)];
$rotated = [];
$failedWlans = [];
foreach ($wlanIds as $wlanId) {
try {
$unifi->updateWlan($wlanId, ['x_passphrase' => $password]);
$rotated[] = $wlanId;
} catch (\Throwable $e) {
$this->error("Failed to rotate wlan {$wlanId}: {$e->getMessage()}");
$failedWlans[] = ['wlan_id' => $wlanId, 'error' => $e->getMessage()];
}
}
if ($rotated) {
Setting::set('unifi.password_rotation.last_rotated_at', now()->toIso8601String());
$this->info('Rotated password for ' . count($rotated) . ' SSID(s).');
}
$rotatedPpsks = [];
$failedPpsks = [];
foreach (UnifiPpsk::where('rotate_password', true)->where('state', 'active')->whereNotNull('unifi_id')->get() as $ppsk) {
$newPass = $passwords[array_rand($passwords)];
try {
$unifi->updatePpsk($ppsk->unifi_id, ['x_passphrase' => $newPass]);
$ppsk->update(['x_passphrase' => $newPass]);
$rotatedPpsks[] = $ppsk->name;
} catch (\Throwable $e) {
$this->error("Failed to rotate PPSK \"{$ppsk->name}\": {$e->getMessage()}");
$failedPpsks[] = ['name' => $ppsk->name, 'error' => $e->getMessage()];
}
}
$hasFailures = count($failedWlans) + count($failedPpsks) > 0;
$hasSuccess = count($rotated) + count($rotatedPpsks) > 0;
return [
'status' => $hasFailures ? ($hasSuccess ? 'partial' : 'failed') : 'succeeded',
'rotated_wlans' => $rotated,
'failed_wlans' => $failedWlans,
'rotated_ppsks' => $rotatedPpsks,
'failed_ppsks' => $failedPpsks,
];
});
return $run->status === 'failed' ? self::FAILURE : self::SUCCESS;
}
private function isDue(): bool

View File

@@ -3,65 +3,82 @@
namespace Dashboard\Unifi\Console;
use App\Models\Setting;
use Dashboard\Unifi\Models\UnifiCronRun;
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 $signature = 'unifi:sync-ppsk-schedules {--force : Run even if PPSK scheduling is disabled} {--triggered-by=schedule}';
protected $description = 'Enable or disable PPSKs based on their weekly half-hour schedule, kicking active clients when disabling';
public function handle(UnifiApiClient $unifi): int
{
// Always run, even when global ppsk_scheduling is disabled — in
// that case the target state for every PPSK is "active" (always
// on). That way disabling the global setting actually restores
// any held PPSKs to active without operators having to do
// anything else, and null-schedule PPSKs always end up active.
// Schedules in the DB are preserved regardless of toggle state,
// so re-enabling resumes the per-PPSK schedule.
$globalEnabled = (bool) Setting::get('unifi.ppsk_scheduling.enabled');
$tz = \App\Support\Timezone::current();
$now = now($tz);
$day = $now->dayOfWeek; // 0=Sun … 6=Sat
$slot = $now->hour * 2 + ($now->minute >= 30 ? 1 : 0); // 047
$ppsks = UnifiPpsk::all();
if ($ppsks->isEmpty()) {
// Don't bother logging — no work, no audit value.
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;
$triggeredBy = $this->option('triggered-by') ?: 'schedule';
$run = UnifiCronRun::record('sync-ppsk-schedules', $triggeredBy, null, function () use ($unifi, $ppsks) {
$globalEnabled = (bool) Setting::get('unifi.ppsk_scheduling.enabled');
$tz = \App\Support\Timezone::current();
$now = now($tz);
$day = $now->dayOfWeek;
$slot = $now->hour * 2 + ($now->minute >= 30 ? 1 : 0);
$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()}");
}
$enabled = [];
$disabled = [];
$errors = [];
foreach ($ppsks as $ppsk) {
$shouldBeOn = true;
if ($globalEnabled && $ppsk->schedule) {
$shouldBeOn = (bool) ($ppsk->schedule[$day * 48 + $slot] ?? true);
}
try {
if ($shouldBeOn && $ppsk->state === 'held') {
$this->enablePpsk($ppsk, $unifi, $networksByVlan);
$enabled[] = $ppsk->name;
} elseif (! $shouldBeOn && $ppsk->state === 'active' && $ppsk->unifi_id) {
$this->disablePpsk($ppsk, $unifi);
$disabled[] = $ppsk->name;
}
} catch (\Throwable $e) {
$errors[] = ['ppsk' => $ppsk->name, 'error' => $e->getMessage()];
}
}
} catch (\Throwable $e) {
$this->warn("Could not fetch network configs: {$e->getMessage()}");
}
foreach ($ppsks as $ppsk) {
// Default to "always on". Only consult the schedule if
// global scheduling is enabled AND this PPSK has one.
$shouldBeOn = true;
if ($globalEnabled && $ppsk->schedule) {
$shouldBeOn = (bool) ($ppsk->schedule[$day * 48 + $slot] ?? true);
}
$hasActions = count($enabled) + count($disabled) > 0;
$status = count($errors) > 0
? ($hasActions ? 'partial' : 'failed')
: ($hasActions ? 'succeeded' : 'skipped');
if ($shouldBeOn && $ppsk->state === 'held') {
$this->enablePpsk($ppsk, $unifi, $networksByVlan);
} elseif (! $shouldBeOn && $ppsk->state === 'active' && $ppsk->unifi_id) {
$this->disablePpsk($ppsk, $unifi);
}
}
return [
'status' => $status,
'global_enabled' => $globalEnabled,
'enabled_ppsks' => $enabled,
'disabled_ppsks' => $disabled,
'errors' => $errors,
];
});
return self::SUCCESS;
return $run->status === 'failed' ? self::FAILURE : self::SUCCESS;
}
private function enablePpsk(UnifiPpsk $ppsk, UnifiApiClient $unifi, array $networksByVlan): void

View File

@@ -0,0 +1,43 @@
<?php
namespace Dashboard\Unifi\Http\Controllers;
use Dashboard\Unifi\Models\UnifiCronRun;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
class UnifiCronLogsController extends Controller
{
public function index(Request $request)
{
$filters = $request->only(['command', 'status']);
$runs = UnifiCronRun::query()
->with('triggeredByUser:id,name,email')
->when($filters['command'] ?? null, fn ($q, $c) => $q->where('command', $c))
->when($filters['status'] ?? null, fn ($q, $s) => $q->where('status', $s))
->orderByDesc('started_at')
->limit(200)
->get();
return response()->json([
'runs' => $runs->map(fn ($r) => [
'id' => $r->id,
'command' => $r->command,
'triggered_by' => $r->triggered_by,
'triggered_user' => $r->triggeredByUser ? [
'id' => $r->triggeredByUser->id,
'name' => $r->triggeredByUser->name,
'email' => $r->triggeredByUser->email,
] : null,
'started_at' => $r->started_at?->toIso8601String(),
'finished_at' => $r->finished_at?->toIso8601String(),
'duration_ms' => $r->finished_at && $r->started_at
? (int) $r->finished_at->diffInMilliseconds($r->started_at)
: null,
'status' => $r->status,
'details' => $r->details,
])->values(),
]);
}
}

View File

@@ -0,0 +1,79 @@
<?php
namespace Dashboard\Unifi\Models;
use Illuminate\Database\Eloquent\Model;
class UnifiCronRun extends Model
{
protected $table = 'unifi_cron_runs';
public $timestamps = false;
protected $fillable = [
'command',
'triggered_by',
'triggered_by_user_id',
'started_at',
'finished_at',
'status',
'details',
];
protected $casts = [
'started_at' => 'datetime',
'finished_at' => 'datetime',
'details' => 'array',
];
public function triggeredByUser()
{
return $this->belongsTo(\App\Models\User::class, 'triggered_by_user_id');
}
/**
* Wraps a unit of cron work, recording start/finish/status and any
* exception. Returns whatever the work returns; the resulting
* UnifiCronRun row is returned via the $run reference param.
*/
public static function record(string $command, string $triggeredBy, ?int $userId, callable $work): self
{
$run = static::create([
'command' => $command,
'triggered_by' => $triggeredBy,
'triggered_by_user_id' => $userId,
'started_at' => now(),
'status' => 'running',
]);
try {
$details = $work($run);
// Caller can return a status string ("skipped", "partial",
// etc.) by sticking it under the 'status' key in details.
// Default = succeeded.
$status = is_array($details) && isset($details['status'])
? $details['status']
: 'succeeded';
$run->update([
'finished_at' => now(),
'status' => $status,
'details' => is_array($details) ? array_diff_key($details, ['status' => null]) : null,
]);
} catch (\Throwable $e) {
$run->update([
'finished_at' => now(),
'status' => 'failed',
'details' => [
'error' => $e->getMessage(),
'class' => $e::class,
'file' => $e->getFile() . ':' . $e->getLine(),
],
]);
throw $e;
}
return $run->refresh();
}
}

View File

@@ -4,6 +4,7 @@ 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\UnifiCronLogsController;
use Dashboard\Unifi\Http\Controllers\UnifiPagesAccessController;
use Dashboard\Unifi\Http\Controllers\UnifiSettingsController;
use Dashboard\Unifi\Http\Controllers\VlanGroupController;
@@ -78,6 +79,9 @@ Route::middleware(['web', 'auth', 'app.access:unifi'])
Route::put('/settings/pages-access/{navItem}', [UnifiPagesAccessController::class, 'update']) ->name('settings.pages-access.update');
});
// Cron logs — read-only history of scheduled-task runs.
Route::get('/settings/cron-logs', [UnifiCronLogsController::class, 'index'])->name('settings.cron-logs.index');
// Webhooks
Route::get('/webhooks', [WebhookController::class, 'index']) ->name('webhooks.index');
Route::post('/webhooks', [WebhookController::class, 'store']) ->name('webhooks.store');