mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 00:55:07 +00:00
cab9291d29
Failed queue jobs and read notifications both grow with use, and neither ever shrank on its own. The failed-jobs list waited for somebody to press "Delete all failed" — a fine tool for a backlog you are looking at, and the only thing that ever emptied it. Notifications had nothing at all: one row per recipient per event, kept for the life of the installation, on what is easily the fastest-growing table here. Both now have a retention window, set together on the Scheduler screen under Housekeeping, and a nightly purge that honours it. Thirty days for failed jobs and ninety for read notifications, and zero means keep everything — the explicit choice somebody makes when a failure is evidence rather than debris. Unread notifications are never deleted, whatever their age. A notification nobody has looked at is the one row in that table still doing its job, and somebody back from four months away should find their news rather than a clean slate. The activity log is untouched by any of this: it is an audit trail, and it is never pruned. Two things came out of building it. The API request log purge has been running nightly since it shipped without ever appearing on the Scheduler screen — so a failure of it was invisible on the screen that exists to make failures visible — and there is now a test asserting the screen's list and the schedule are the same list, because they had already drifted once and would again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
206 lines
8.3 KiB
PHP
206 lines
8.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Modules\Platform\Http\Controllers;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Modules\Platform\Scheduling\ScheduledTaskRun;
|
|
use App\Modules\Platform\Settings\Setting;
|
|
use App\Modules\Platform\Settings\Settings;
|
|
use App\Modules\Platform\Updates\LatestReleaseInfo;
|
|
use App\Support\Pagination;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Pagination\LengthAwarePaginator;
|
|
use Illuminate\Pagination\Paginator;
|
|
use Illuminate\Queue\Failed\FailedJobProviderInterface;
|
|
use Illuminate\Support\Facades\Artisan;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Inertia\Inertia;
|
|
use Inertia\Response;
|
|
|
|
/**
|
|
* Community-only (Capability::SchedulerMonitoring, enforced entirely by
|
|
* route middleware — see routes/settings.php, same all-or-nothing shape as
|
|
* ExternalStorageSettingsController): visibility into scheduled-command run
|
|
* history and failed queue jobs, since neither has any in-app surface
|
|
* otherwise — a failure only ever lands in the container's own logs.
|
|
*/
|
|
class SchedulerMonitoringController extends Controller
|
|
{
|
|
/**
|
|
* Every command this app schedules (routes/console.php) — a plain
|
|
* label map rather than a backed enum, since it'd just have to be kept
|
|
* in sync with 7 already-scattered `protected $signature` values
|
|
* either way.
|
|
*
|
|
* @var array<string, string>
|
|
*/
|
|
private const KNOWN_COMMANDS = [
|
|
'projectsend:purge-erasures' => 'Purge erased accounts',
|
|
'projectsend:purge-stale-uploads' => 'Purge stale chunked uploads',
|
|
'projectsend:purge-zip-downloads' => 'Purge zip downloads',
|
|
'projectsend:check-for-updates' => 'Check for updates',
|
|
'projectsend:fetch-news' => 'Fetch dashboard news',
|
|
'projectsend:purge-expired-files' => 'Purge expired files',
|
|
'projectsend:purge-orphan-files' => 'Purge orphan files',
|
|
'projectsend:purge-api-request-logs' => 'Purge API request logs',
|
|
'projectsend:purge-failed-jobs' => 'Purge failed jobs',
|
|
'projectsend:purge-notifications' => 'Purge read notifications',
|
|
];
|
|
|
|
private const FAILED_PER_PAGE = 20;
|
|
|
|
public function __construct(
|
|
private readonly FailedJobProviderInterface $failer,
|
|
private readonly LatestReleaseInfo $latestRelease,
|
|
private readonly Settings $settings,
|
|
) {}
|
|
|
|
public function index(Request $request): Response|RedirectResponse
|
|
{
|
|
$runs = ScheduledTaskRun::query()->get()->keyBy('command');
|
|
$details = $this->details();
|
|
|
|
$tasks = collect(self::KNOWN_COMMANDS)->map(function (string $label, string $command) use ($runs, $details): array {
|
|
$run = $runs->get($command);
|
|
|
|
return [
|
|
'command' => $command,
|
|
'label' => $label,
|
|
'status' => $run?->status->value,
|
|
'message' => $run?->message,
|
|
'detail' => $details[$command] ?? null,
|
|
'duration_ms' => $run?->duration_ms,
|
|
'ran_at' => $run?->ran_at?->toIso8601String(),
|
|
];
|
|
})->values()->all();
|
|
|
|
/** @var list<object{id: string, connection: string, queue: string, exception: string, failed_at: string}> $failedJobRows */
|
|
$failedJobRows = $this->failer->all();
|
|
|
|
// The provider hands back the whole list as an array (no LIMIT of its
|
|
// own), so page it in memory the same way the orphan-files repair
|
|
// tool does — a backlog big enough to matter is exactly when a single
|
|
// 100-row wall stops being usable.
|
|
$failed = collect($failedJobRows)
|
|
->sortByDesc('failed_at')
|
|
->map(fn (object $job): array => [
|
|
'id' => $job->id,
|
|
'connection' => $job->connection,
|
|
'queue' => $job->queue,
|
|
'exception' => strtok((string) $job->exception, "\n") ?: null,
|
|
'failed_at' => $job->failed_at,
|
|
])->values();
|
|
|
|
$page = Paginator::resolveCurrentPage();
|
|
|
|
$paginator = new LengthAwarePaginator(
|
|
$failed->forPage($page, self::FAILED_PER_PAGE)->values()->all(),
|
|
$failed->count(),
|
|
self::FAILED_PER_PAGE,
|
|
$page,
|
|
['path' => $request->url(), 'query' => $request->query()],
|
|
);
|
|
|
|
// A ?page= past the end (deleting a page's worth, or a stale bookmark)
|
|
// would render an empty list rather than the real last page.
|
|
if (Pagination::isPastLastPage($paginator, $page)) {
|
|
return redirect()->route('system-settings.scheduler.index', array_filter([
|
|
'tab' => 'failed',
|
|
'page' => Pagination::redirectPage($paginator),
|
|
]));
|
|
}
|
|
|
|
return Inertia::render('system/settings/scheduler', [
|
|
'tab' => $request->query('tab') === 'failed' ? 'failed' : 'tasks',
|
|
'tasks' => $tasks,
|
|
'failed_jobs' => $paginator->items(),
|
|
'failed_pagination' => Pagination::meta($paginator),
|
|
'failed_total' => $failed->count(),
|
|
'pending_jobs_count' => DB::table('jobs')->count(),
|
|
'retention' => [
|
|
'failed_jobs' => (int) $this->settings->get(Setting::FailedJobRetentionDays),
|
|
'notifications' => (int) $this->settings->get(Setting::NotificationRetentionDays),
|
|
],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* How long the two tables that grow on their own are kept.
|
|
*
|
|
* They live on this screen because this is where their purges are
|
|
* listed: the window and the job that honours it are one idea, and an
|
|
* administrator who has just seen "Purge failed jobs · never run" is
|
|
* the person asking how long anything is kept.
|
|
*/
|
|
public function updateRetention(Request $request): RedirectResponse
|
|
{
|
|
// Ten years is not a policy, it is a guard against a typo becoming
|
|
// a number nobody notices. 0 is the real "keep everything".
|
|
$validated = $request->validate([
|
|
'failed_jobs' => ['required', 'integer', 'min:0', 'max:3650'],
|
|
'notifications' => ['required', 'integer', 'min:0', 'max:3650'],
|
|
]);
|
|
|
|
$this->settings->set(Setting::FailedJobRetentionDays, $validated['failed_jobs']);
|
|
$this->settings->set(Setting::NotificationRetentionDays, $validated['notifications']);
|
|
|
|
return back()->with('success', __('Retention updated.'));
|
|
}
|
|
|
|
public function retryFailedJob(string $uuid): RedirectResponse
|
|
{
|
|
Artisan::call('queue:retry', ['id' => [$uuid]]);
|
|
|
|
return back()->with('success', __('Job queued for retry.'));
|
|
}
|
|
|
|
public function destroyFailedJob(string $uuid): RedirectResponse
|
|
{
|
|
$this->failer->forget($uuid);
|
|
|
|
return back()->with('success', __('Failed job deleted.'));
|
|
}
|
|
|
|
public function destroyAllFailedJobs(): RedirectResponse
|
|
{
|
|
// flush() with no argument clears every failed job regardless of age
|
|
// — the same as `queue:flush`. The list only shows the most recent
|
|
// 100, but this deletes all of them, which is the intent of a
|
|
// "clear the backlog" button.
|
|
$this->failer->flush();
|
|
|
|
return back()->with('success', __('All failed jobs deleted.'));
|
|
}
|
|
|
|
/**
|
|
* What a task actually found, for the tasks that find something.
|
|
*
|
|
* The run rows answer "did it work"; they cannot answer "and what did
|
|
* it say", because Laravel's ScheduledTaskFinished event fires after
|
|
* the command returns and carries no output — which is why a
|
|
* successful check for updates has always shown an empty Message.
|
|
* Joined here at render time instead, from what the command itself
|
|
* wrote to the settings, so the line stays true whether the daily job
|
|
* or somebody pressing the button did the work.
|
|
*
|
|
* @return array<string, string>
|
|
*/
|
|
private function details(): array
|
|
{
|
|
$release = $this->latestRelease->current();
|
|
$checkedAt = $this->settings->get(Setting::LatestVersionCheckedAt);
|
|
$everChecked = is_string($checkedAt) && $checkedAt !== '';
|
|
|
|
$updates = match (true) {
|
|
$release !== null => (string) __(':version is available', ['version' => $release['version']]),
|
|
$everChecked => (string) __('Up to date'),
|
|
default => null,
|
|
};
|
|
|
|
return array_filter(['projectsend:check-for-updates' => $updates]);
|
|
}
|
|
}
|