Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b73b53dd6 | |||
| 75943fbe2b |
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "dashboard/unifi",
|
"name": "dashboard/unifi",
|
||||||
"description": "UniFi network management, WiFi stats, and captive portal authentication for the Dashboard platform",
|
"description": "UniFi network management, WiFi stats, and captive portal authentication for the Dashboard platform",
|
||||||
"version": "1.4.0",
|
"version": "1.5.1",
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"autoload": {
|
"autoload": {
|
||||||
@@ -28,7 +28,6 @@
|
|||||||
{ "label": "Clients", "route_name": "unifi.clients", "icon": "users", "permission": "unifi.stats", "sort_order": 4 },
|
{ "label": "Clients", "route_name": "unifi.clients", "icon": "users", "permission": "unifi.stats", "sort_order": 4 },
|
||||||
{ "label": "WiFi Networks", "route_name": "unifi.wifi", "icon": "wifi", "permission": "unifi.manage", "sort_order": 5 },
|
{ "label": "WiFi Networks", "route_name": "unifi.wifi", "icon": "wifi", "permission": "unifi.manage", "sort_order": 5 },
|
||||||
{ "label": "Portal", "route_name": "unifi.portal.settings", "icon": "shield-check", "permission": "unifi.auth", "sort_order": 6 },
|
{ "label": "Portal", "route_name": "unifi.portal.settings", "icon": "shield-check", "permission": "unifi.auth", "sort_order": 6 },
|
||||||
{ "label": "Webhooks", "route_name": "unifi.webhooks.index", "icon": "bell-alert", "permission": "unifi.settings", "sort_order": 7 },
|
|
||||||
{ "label": "Settings", "route_name": "unifi.settings", "icon": "cog-6-tooth", "permission": "unifi.settings", "sort_order": 99 }
|
{ "label": "Settings", "route_name": "unifi.settings", "icon": "cog-6-tooth", "permission": "unifi.settings", "sort_order": 99 }
|
||||||
],
|
],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -2,41 +2,40 @@
|
|||||||
|
|
||||||
namespace Dashboard\Unifi\Console;
|
namespace Dashboard\Unifi\Console;
|
||||||
|
|
||||||
|
use Dashboard\Unifi\Models\UnifiCronRun;
|
||||||
use Dashboard\Unifi\Services\UnifiApiClient;
|
use Dashboard\Unifi\Services\UnifiApiClient;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
class RebootAllAps extends Command
|
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';
|
protected $description = 'Planned reboot of all access points — suppresses webhook offline/online alerts';
|
||||||
|
|
||||||
public function handle(UnifiApiClient $unifi): int
|
public function handle(UnifiApiClient $unifi): int
|
||||||
{
|
{
|
||||||
try {
|
$run = UnifiCronRun::record(
|
||||||
|
'reboot-all-aps',
|
||||||
|
$this->option('triggered-by') ?: 'schedule',
|
||||||
|
null,
|
||||||
|
function () use ($unifi) {
|
||||||
$aps = $unifi->getAccessPoints();
|
$aps = $unifi->getAccessPoints();
|
||||||
} catch (\Throwable $e) {
|
|
||||||
$this->error('Failed to fetch APs: ' . $e->getMessage());
|
|
||||||
return self::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($aps)) {
|
if (empty($aps)) {
|
||||||
$this->warn('No access points found.');
|
$this->warn('No access points found.');
|
||||||
return self::SUCCESS;
|
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) {
|
foreach ($aps as $ap) {
|
||||||
$mac = strtolower($ap['mac']);
|
$mac = strtolower($ap['mac']);
|
||||||
Cache::put("unifi:planned_reboot:{$mac}", true, now()->addMinutes(20));
|
Cache::put("unifi:planned_reboot:{$mac}", true, now()->addMinutes(20));
|
||||||
$this->line("Marked planned reboot: {$ap['name']} ({$mac})");
|
$this->line("Marked planned reboot: {$ap['name']} ({$mac})");
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->newLine();
|
$this->newLine();
|
||||||
$ok = 0;
|
|
||||||
$fail = 0;
|
|
||||||
|
|
||||||
foreach ($aps as $ap) {
|
foreach ($aps as $ap) {
|
||||||
$mac = strtolower($ap['mac']);
|
$mac = strtolower($ap['mac']);
|
||||||
@@ -44,19 +43,27 @@ class RebootAllAps extends Command
|
|||||||
try {
|
try {
|
||||||
$unifi->rebootDevice($mac);
|
$unifi->rebootDevice($mac);
|
||||||
$this->info("Rebooted: {$name} ({$mac})");
|
$this->info("Rebooted: {$name} ({$mac})");
|
||||||
$ok++;
|
$rebooted[] = $name;
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$this->error("Failed to reboot {$name}: {$e->getMessage()}");
|
$this->error("Failed to reboot {$name}: {$e->getMessage()}");
|
||||||
$fail++;
|
$failed[] = ['name' => $name, 'error' => $e->getMessage()];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($delay > 0 && $ok + $fail < count($aps)) {
|
if ($delay > 0 && count($rebooted) + count($failed) < count($aps)) {
|
||||||
sleep($delay);
|
sleep($delay);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->newLine();
|
return [
|
||||||
$this->info("Done. {$ok} rebooted, {$fail} failed.");
|
'status' => count($failed) === 0 ? 'succeeded' : (count($rebooted) > 0 ? 'partial' : 'failed'),
|
||||||
return $fail > 0 ? self::FAILURE : self::SUCCESS;
|
'rebooted' => $rebooted,
|
||||||
|
'failed' => $failed,
|
||||||
|
'total' => count($aps),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->info("Done. Status: {$run->status}.");
|
||||||
|
return $run->status === 'failed' ? self::FAILURE : self::SUCCESS;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,27 +3,38 @@
|
|||||||
namespace Dashboard\Unifi\Console;
|
namespace Dashboard\Unifi\Console;
|
||||||
|
|
||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
|
use Dashboard\Unifi\Models\UnifiCronRun;
|
||||||
use Dashboard\Unifi\Models\UnifiPpsk;
|
use Dashboard\Unifi\Models\UnifiPpsk;
|
||||||
use Dashboard\Unifi\Services\UnifiApiClient;
|
use Dashboard\Unifi\Services\UnifiApiClient;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
|
|
||||||
class RotatePasswords extends Command
|
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';
|
protected $description = 'Rotate WiFi passwords for SSIDs configured with a wordlist schedule';
|
||||||
|
|
||||||
public function handle(UnifiApiClient $unifi): int
|
public function handle(UnifiApiClient $unifi): int
|
||||||
{
|
{
|
||||||
if (! Setting::get('unifi.password_rotation.enabled')) {
|
if (! Setting::get('unifi.password_rotation.enabled')) {
|
||||||
|
// Don't log anything — the scheduler runs this every minute
|
||||||
|
// and we'd flood the logs with "rotation disabled" rows.
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! $this->option('force') && ! $this->isDue()) {
|
||||||
|
// Same reasoning — only log when we actually do something.
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$force = $this->option('force');
|
||||||
|
$triggeredBy = $this->option('triggered-by') ?: 'schedule';
|
||||||
|
|
||||||
|
$run = UnifiCronRun::record('rotate-passwords', $triggeredBy, null, function () use ($unifi, $force) {
|
||||||
$wlanIdsJson = Setting::get('unifi.password_rotation.wlan_ids', '[]');
|
$wlanIdsJson = Setting::get('unifi.password_rotation.wlan_ids', '[]');
|
||||||
$wlanIds = json_decode($wlanIdsJson, true);
|
$wlanIds = json_decode($wlanIdsJson, true);
|
||||||
|
|
||||||
if (empty($wlanIds) || ! is_array($wlanIds)) {
|
if (empty($wlanIds) || ! is_array($wlanIds)) {
|
||||||
return self::SUCCESS;
|
return ['status' => 'skipped', 'reason' => 'no SSIDs configured for rotation'];
|
||||||
}
|
}
|
||||||
|
|
||||||
$wordlist = Setting::get('unifi.password_rotation.wordlist', '');
|
$wordlist = Setting::get('unifi.password_rotation.wordlist', '');
|
||||||
@@ -31,48 +42,55 @@ class RotatePasswords extends Command
|
|||||||
|
|
||||||
if (empty($passwords)) {
|
if (empty($passwords)) {
|
||||||
$this->warn('Password rotation: no passwords in wordlist — skipped.');
|
$this->warn('Password rotation: no passwords in wordlist — skipped.');
|
||||||
return self::SUCCESS;
|
return ['status' => 'skipped', 'reason' => 'empty wordlist'];
|
||||||
}
|
|
||||||
|
|
||||||
if (! $this->option('force') && ! $this->isDue()) {
|
|
||||||
return self::SUCCESS;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$password = $passwords[array_rand($passwords)];
|
$password = $passwords[array_rand($passwords)];
|
||||||
$rotated = 0;
|
$rotated = [];
|
||||||
|
$failedWlans = [];
|
||||||
|
|
||||||
foreach ($wlanIds as $wlanId) {
|
foreach ($wlanIds as $wlanId) {
|
||||||
try {
|
try {
|
||||||
$unifi->updateWlan($wlanId, ['x_passphrase' => $password]);
|
$unifi->updateWlan($wlanId, ['x_passphrase' => $password]);
|
||||||
$rotated++;
|
$rotated[] = $wlanId;
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$this->error("Failed to rotate wlan {$wlanId}: {$e->getMessage()}");
|
$this->error("Failed to rotate wlan {$wlanId}: {$e->getMessage()}");
|
||||||
|
$failedWlans[] = ['wlan_id' => $wlanId, 'error' => $e->getMessage()];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($rotated > 0) {
|
if ($rotated) {
|
||||||
Setting::set('unifi.password_rotation.last_rotated_at', now()->toIso8601String());
|
Setting::set('unifi.password_rotation.last_rotated_at', now()->toIso8601String());
|
||||||
$this->info("Rotated password for {$rotated} SSID(s).");
|
$this->info('Rotated password for ' . count($rotated) . ' SSID(s).');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Rotate PPSK passwords ────────────────────────────────────────────
|
$rotatedPpsks = [];
|
||||||
$rotatedPpsks = 0;
|
$failedPpsks = [];
|
||||||
foreach (UnifiPpsk::where('rotate_password', true)->where('state', 'active')->whereNotNull('unifi_id')->get() as $ppsk) {
|
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)];
|
$newPass = $passwords[array_rand($passwords)];
|
||||||
try {
|
try {
|
||||||
$unifi->updatePpsk($ppsk->unifi_id, ['x_passphrase' => $newPass]);
|
$unifi->updatePpsk($ppsk->unifi_id, ['x_passphrase' => $newPass]);
|
||||||
$ppsk->update(['x_passphrase' => $newPass]);
|
$ppsk->update(['x_passphrase' => $newPass]);
|
||||||
$rotatedPpsks++;
|
$rotatedPpsks[] = $ppsk->name;
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$this->error("Failed to rotate PPSK \"{$ppsk->name}\": {$e->getMessage()}");
|
$this->error("Failed to rotate PPSK \"{$ppsk->name}\": {$e->getMessage()}");
|
||||||
|
$failedPpsks[] = ['name' => $ppsk->name, 'error' => $e->getMessage()];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($rotatedPpsks > 0) {
|
|
||||||
$this->info("Rotated password for {$rotatedPpsks} PPSK(s).");
|
|
||||||
}
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
$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
|
private function isDue(): bool
|
||||||
|
|||||||
@@ -3,38 +3,33 @@
|
|||||||
namespace Dashboard\Unifi\Console;
|
namespace Dashboard\Unifi\Console;
|
||||||
|
|
||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
|
use Dashboard\Unifi\Models\UnifiCronRun;
|
||||||
use Dashboard\Unifi\Models\UnifiPpsk;
|
use Dashboard\Unifi\Models\UnifiPpsk;
|
||||||
use Dashboard\Unifi\Services\UnifiApiClient;
|
use Dashboard\Unifi\Services\UnifiApiClient;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
class SyncPpskSchedules extends 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';
|
protected $description = 'Enable or disable PPSKs based on their weekly half-hour schedule, kicking active clients when disabling';
|
||||||
|
|
||||||
public function handle(UnifiApiClient $unifi): int
|
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); // 0–47
|
|
||||||
|
|
||||||
$ppsks = UnifiPpsk::all();
|
$ppsks = UnifiPpsk::all();
|
||||||
|
|
||||||
if ($ppsks->isEmpty()) {
|
if ($ppsks->isEmpty()) {
|
||||||
|
// Don't bother logging — no work, no audit value.
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch network confs once so we can resolve vlan → networkconf_id on re-enable
|
$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 = [];
|
$networksByVlan = [];
|
||||||
try {
|
try {
|
||||||
foreach ($unifi->getNetworkConfs() as $n) {
|
foreach ($unifi->getNetworkConfs() as $n) {
|
||||||
@@ -46,22 +41,44 @@ class SyncPpskSchedules extends Command
|
|||||||
$this->warn("Could not fetch network configs: {$e->getMessage()}");
|
$this->warn("Could not fetch network configs: {$e->getMessage()}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$enabled = [];
|
||||||
|
$disabled = [];
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
foreach ($ppsks as $ppsk) {
|
foreach ($ppsks as $ppsk) {
|
||||||
// Default to "always on". Only consult the schedule if
|
|
||||||
// global scheduling is enabled AND this PPSK has one.
|
|
||||||
$shouldBeOn = true;
|
$shouldBeOn = true;
|
||||||
if ($globalEnabled && $ppsk->schedule) {
|
if ($globalEnabled && $ppsk->schedule) {
|
||||||
$shouldBeOn = (bool) ($ppsk->schedule[$day * 48 + $slot] ?? true);
|
$shouldBeOn = (bool) ($ppsk->schedule[$day * 48 + $slot] ?? true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
if ($shouldBeOn && $ppsk->state === 'held') {
|
if ($shouldBeOn && $ppsk->state === 'held') {
|
||||||
$this->enablePpsk($ppsk, $unifi, $networksByVlan);
|
$this->enablePpsk($ppsk, $unifi, $networksByVlan);
|
||||||
|
$enabled[] = $ppsk->name;
|
||||||
} elseif (! $shouldBeOn && $ppsk->state === 'active' && $ppsk->unifi_id) {
|
} elseif (! $shouldBeOn && $ppsk->state === 'active' && $ppsk->unifi_id) {
|
||||||
$this->disablePpsk($ppsk, $unifi);
|
$this->disablePpsk($ppsk, $unifi);
|
||||||
|
$disabled[] = $ppsk->name;
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$errors[] = ['ppsk' => $ppsk->name, 'error' => $e->getMessage()];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return self::SUCCESS;
|
$hasActions = count($enabled) + count($disabled) > 0;
|
||||||
|
$status = count($errors) > 0
|
||||||
|
? ($hasActions ? 'partial' : 'failed')
|
||||||
|
: ($hasActions ? 'succeeded' : 'skipped');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => $status,
|
||||||
|
'global_enabled' => $globalEnabled,
|
||||||
|
'enabled_ppsks' => $enabled,
|
||||||
|
'disabled_ppsks' => $disabled,
|
||||||
|
'errors' => $errors,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return $run->status === 'failed' ? self::FAILURE : self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function enablePpsk(UnifiPpsk $ppsk, UnifiApiClient $unifi, array $networksByVlan): void
|
private function enablePpsk(UnifiPpsk $ppsk, UnifiApiClient $unifi, array $networksByVlan): void
|
||||||
|
|||||||
43
src/Http/Controllers/UnifiCronLogsController.php
Normal file
43
src/Http/Controllers/UnifiCronLogsController.php
Normal 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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/Models/UnifiCronRun.php
Normal file
79
src/Models/UnifiCronRun.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ use Dashboard\Unifi\Http\Controllers\ClientController;
|
|||||||
use Dashboard\Unifi\Http\Controllers\DeviceController;
|
use Dashboard\Unifi\Http\Controllers\DeviceController;
|
||||||
use Dashboard\Unifi\Http\Controllers\PortalController;
|
use Dashboard\Unifi\Http\Controllers\PortalController;
|
||||||
use Dashboard\Unifi\Http\Controllers\StatsController;
|
use Dashboard\Unifi\Http\Controllers\StatsController;
|
||||||
|
use Dashboard\Unifi\Http\Controllers\UnifiCronLogsController;
|
||||||
use Dashboard\Unifi\Http\Controllers\UnifiPagesAccessController;
|
use Dashboard\Unifi\Http\Controllers\UnifiPagesAccessController;
|
||||||
use Dashboard\Unifi\Http\Controllers\UnifiSettingsController;
|
use Dashboard\Unifi\Http\Controllers\UnifiSettingsController;
|
||||||
use Dashboard\Unifi\Http\Controllers\VlanGroupController;
|
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');
|
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
|
// Webhooks
|
||||||
Route::get('/webhooks', [WebhookController::class, 'index']) ->name('webhooks.index');
|
Route::get('/webhooks', [WebhookController::class, 'index']) ->name('webhooks.index');
|
||||||
Route::post('/webhooks', [WebhookController::class, 'store']) ->name('webhooks.store');
|
Route::post('/webhooks', [WebhookController::class, 'store']) ->name('webhooks.store');
|
||||||
|
|||||||
Reference in New Issue
Block a user