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 75943fbe2b
8 changed files with 325 additions and 125 deletions

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();
}
}