From 3b055b01f522a6cd58f50b1240ca172998bbabb0 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Tue, 1 Sep 2026 16:53:54 +0200 Subject: [PATCH] feat(admin): dispatch allowlisted maintenance jobs --- .env.example | 5 ++ .gitea/workflows/maintenance.yml | 42 ++++++++++ .../AuthAndSystemControllerTests.cs | 11 ++- .../MaintenanceJobDispatcherTests.cs | 71 +++++++++++++++++ .../Controllers/AdminSystemController.cs | 26 ++++++- JobTrackerApi/Program.cs | 6 ++ .../Services/MaintenanceJobDispatcher.cs | 76 +++++++++++++++++++ docker-compose.yml | 5 ++ job-tracker-ui/src/admin-system-page.test.tsx | 26 ++++++- job-tracker-ui/src/i18n/translations.ts | 12 +++ job-tracker-ui/src/views/AdminSystemPage.tsx | 56 +++++++++++++- 11 files changed, 328 insertions(+), 8 deletions(-) create mode 100644 .gitea/workflows/maintenance.yml create mode 100644 JobTrackerApi.Tests/MaintenanceJobDispatcherTests.cs create mode 100644 JobTrackerApi/Services/MaintenanceJobDispatcher.cs diff --git a/.env.example b/.env.example index e2853bd..b39d939 100644 --- a/.env.example +++ b/.env.example @@ -93,6 +93,11 @@ WEB_PROXY_SUBNET=172.31.250.0/29 APP_VERSION= APP_COMMIT_SHA= APP_BUILD_STAMP= +# Optional. Use a narrowly scoped Gitea token that can dispatch Actions workflows. +MAINTENANCE_GITEA_BASE_URL=https://git.cesnimda.uk +MAINTENANCE_GITEA_OWNER=cesnimda +MAINTENANCE_GITEA_REPOSITORY=jobtrackingapp +MAINTENANCE_GITEA_TOKEN= EMAIL_ENABLED=false EMAIL_SMTP_HOST=smtp.gmail.com EMAIL_SMTP_PORT=587 diff --git a/.gitea/workflows/maintenance.yml b/.gitea/workflows/maintenance.yml new file mode 100644 index 0000000..8acae32 --- /dev/null +++ b/.gitea/workflows/maintenance.yml @@ -0,0 +1,42 @@ +name: Maintenance jobs + +on: + workflow_dispatch: + inputs: + job: + description: Allowlisted maintenance job + required: true + type: choice + options: + - cv-benchmark + +jobs: + cv-benchmark: + if: inputs.job == 'cv-benchmark' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.317 + + - name: Run CV corpus benchmark + env: + CV_BENCHMARK_OUTPUT_DIR: ${{ github.workspace }}/tmp/cv-benchmarks/latest + CV_BENCHMARK_APPROVED_DIR: ${{ github.workspace }}/tmp/cv-benchmarks/approved-fixtures + run: | + chmod +x scripts/run-cv-benchmark.sh + scripts/run-cv-benchmark.sh + test -s "$CV_BENCHMARK_OUTPUT_DIR/index.json" + test -s "$CV_BENCHMARK_OUTPUT_DIR/report.md" + + - name: Retain benchmark report and fixture candidates + uses: actions/upload-artifact@v4 + with: + name: cv-benchmark-${{ github.run_number }} + path: tmp/cv-benchmarks + if-no-files-found: error + retention-days: 30 diff --git a/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs b/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs index 773c610..d65affd 100644 --- a/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs +++ b/JobTrackerApi.Tests/AuthAndSystemControllerTests.cs @@ -1022,7 +1022,8 @@ public sealed class AuthAndSystemControllerTests null!, Mock.Of(), new FakeEnv(), - emailSettings.Object); + emailSettings.Object, + new MaintenanceJobDispatcher(cfg, Mock.Of())); var result = await controller.GetEmailSettings(CancellationToken.None); var ok = Assert.IsType(result.Result); @@ -1037,13 +1038,15 @@ public sealed class AuthAndSystemControllerTests var summarizer = new Mock(); summarizer.Setup(x => x.RunProbeAsync(It.IsAny())).Returns(Task.CompletedTask); + var cfg = BuildConfig(); var controller = new AdminSystemController( - BuildConfig(), - new AppPaths(BuildConfig(), new FakeHostEnv()), + cfg, + new AppPaths(cfg, new FakeHostEnv()), null!, summarizer.Object, new FakeEnv(), - Mock.Of()); + Mock.Of(), + new MaintenanceJobDispatcher(cfg, Mock.Of())); var result = await controller.RunSummarizerProbe(CancellationToken.None); diff --git a/JobTrackerApi.Tests/MaintenanceJobDispatcherTests.cs b/JobTrackerApi.Tests/MaintenanceJobDispatcherTests.cs new file mode 100644 index 0000000..0a59008 --- /dev/null +++ b/JobTrackerApi.Tests/MaintenanceJobDispatcherTests.cs @@ -0,0 +1,71 @@ +using System.Net; +using System.Text.Json; +using JobTrackerApi.Services; +using Microsoft.Extensions.Configuration; +using Moq; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed class MaintenanceJobDispatcherTests +{ + [Fact] + public void Job_is_unavailable_without_a_dispatch_token() + { + var dispatcher = new MaintenanceJobDispatcher(new ConfigurationBuilder().Build(), Mock.Of()); + + var job = Assert.Single(dispatcher.GetJobs()); + + Assert.Equal(MaintenanceJobDispatcher.CvBenchmarkJobId, job.Id); + Assert.False(job.Available); + Assert.Null(job.RunsUrl); + } + + [Fact] + public async Task Dispatch_sends_only_the_registered_workflow_and_job_id() + { + HttpRequestMessage? captured = null; + string? body = null; + var handler = new StubHandler(async request => + { + captured = request; + body = request.Content is null ? null : await request.Content.ReadAsStringAsync(); + return new HttpResponseMessage(HttpStatusCode.NoContent); + }); + var factory = new Mock(); + factory.Setup(x => x.CreateClient("maintenance-jobs")).Returns(new HttpClient(handler)); + var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["MaintenanceJobs:GiteaBaseUrl"] = "https://git.example.test/", + ["MaintenanceJobs:GiteaOwner"] = "owner", + ["MaintenanceJobs:GiteaRepository"] = "repo", + ["MaintenanceJobs:GiteaToken"] = "test-token", + }).Build(); + var dispatcher = new MaintenanceJobDispatcher(config, factory.Object); + + await dispatcher.DispatchAsync(MaintenanceJobDispatcher.CvBenchmarkJobId, CancellationToken.None); + + Assert.NotNull(captured); + Assert.Equal(HttpMethod.Post, captured!.Method); + Assert.Equal("https://git.example.test/api/v1/repos/owner/repo/actions/workflows/maintenance.yml/dispatches", captured.RequestUri!.ToString()); + Assert.Equal("token", captured.Headers.Authorization!.Scheme); + using var json = JsonDocument.Parse(body!); + Assert.Equal("main", json.RootElement.GetProperty("ref").GetString()); + Assert.Equal("cv-benchmark", json.RootElement.GetProperty("inputs").GetProperty("job").GetString()); + } + + [Fact] + public async Task Dispatch_rejects_any_unregistered_job_without_an_http_call() + { + var factory = new Mock(); + var dispatcher = new MaintenanceJobDispatcher(new ConfigurationBuilder().Build(), factory.Object); + + await Assert.ThrowsAsync(() => dispatcher.DispatchAsync("shell", CancellationToken.None)); + factory.Verify(x => x.CreateClient(It.IsAny()), Times.Never); + } + + private sealed class StubHandler(Func> send) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => send(request); + } +} diff --git a/JobTrackerApi/Controllers/AdminSystemController.cs b/JobTrackerApi/Controllers/AdminSystemController.cs index aec1f70..ff0fb3d 100644 --- a/JobTrackerApi/Controllers/AdminSystemController.cs +++ b/JobTrackerApi/Controllers/AdminSystemController.cs @@ -18,8 +18,9 @@ public sealed class AdminSystemController : ControllerBase private readonly ISummarizerService _summarizer; private readonly IWebHostEnvironment _env; private readonly IEmailSettingsResolver _emailSettings; + private readonly MaintenanceJobDispatcher _maintenanceJobs; - public AdminSystemController(IConfiguration cfg, AppPaths paths, JobTrackerContext db, ISummarizerService summarizer, IWebHostEnvironment env, IEmailSettingsResolver emailSettings) + public AdminSystemController(IConfiguration cfg, AppPaths paths, JobTrackerContext db, ISummarizerService summarizer, IWebHostEnvironment env, IEmailSettingsResolver emailSettings, MaintenanceJobDispatcher maintenanceJobs) { _cfg = cfg; _paths = paths; @@ -27,6 +28,7 @@ public sealed class AdminSystemController : ControllerBase _summarizer = summarizer; _env = env; _emailSettings = emailSettings; + _maintenanceJobs = maintenanceJobs; } public sealed record StorageStatusDto(string DataRoot, string DbPath, bool DbExists, long? DbSizeBytes, int CompanyCount, int JobCount, int DeletedCount); @@ -35,6 +37,7 @@ public sealed class AdminSystemController : ControllerBase public sealed record RuntimeStatusDto(string Framework, string OSDescription, string ProcessArchitecture, string? MachineName); public sealed record AuthStatusDto(bool Required, bool HasJwtKey, bool GoogleConfigured, bool GmailConfigured); public sealed record CvBenchmarkStatusDto(string? IndexJson, string? ReportMarkdown, string RootPath, DateTimeOffset? LastUpdatedAtUtc); + public sealed record MaintenanceJobQueuedDto(string JobId, string Status, string? RunsUrl); public sealed record SystemStatusDto( string Environment, string ContentRoot, @@ -141,6 +144,27 @@ public sealed class AdminSystemController : ControllerBase return Ok(new CvBenchmarkStatusDto(indexJson, reportMarkdown, _paths.CvBenchmarksRoot, lastUpdated == default ? null : new DateTimeOffset(DateTime.SpecifyKind(lastUpdated, DateTimeKind.Utc)))); } + [HttpGet("maintenance-jobs")] + public ActionResult> GetMaintenanceJobs() => Ok(_maintenanceJobs.GetJobs()); + + [HttpPost("maintenance-jobs/{jobId}/runs")] + public async Task> RunMaintenanceJob(string jobId, CancellationToken cancellationToken) + { + var job = _maintenanceJobs.GetJobs().SingleOrDefault(candidate => candidate.Id == jobId); + if (job is null) return NotFound("Unknown maintenance job."); + if (!job.Available) return Problem("Maintenance job dispatch is not configured.", statusCode: StatusCodes.Status503ServiceUnavailable); + + try + { + await _maintenanceJobs.DispatchAsync(jobId, cancellationToken); + return Accepted(new MaintenanceJobQueuedDto(jobId, "queued", job.RunsUrl)); + } + catch (HttpRequestException) + { + return Problem("The maintenance runner did not accept the job. Try again or inspect Gitea Actions.", statusCode: StatusCodes.Status502BadGateway); + } + } + [HttpGet] public async Task> Get(CancellationToken cancellationToken) { diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index bff14e7..659c0e4 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -46,6 +46,7 @@ builder.Services.AddScoped(sp => sp.GetRequiredService(); builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddScoped(); +builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -191,6 +192,11 @@ builder.Services.AddHttpClient("jobimport") AllowAutoRedirect = false }); +builder.Services.AddHttpClient("maintenance-jobs", client => +{ + client.Timeout = TimeSpan.FromSeconds(15); +}); + // Local AI service (FastAPI). Supports summarization and OCR/text extraction. // Every caller goes through this named client, so the shared-secret header is set once here. builder.Services.AddHttpClient("ai-service", client => diff --git a/JobTrackerApi/Services/MaintenanceJobDispatcher.cs b/JobTrackerApi/Services/MaintenanceJobDispatcher.cs new file mode 100644 index 0000000..3da83d1 --- /dev/null +++ b/JobTrackerApi/Services/MaintenanceJobDispatcher.cs @@ -0,0 +1,76 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; + +namespace JobTrackerApi.Services; + +public sealed record MaintenanceJobDefinition(string Id, string Title, string Description, bool Available, string? RunsUrl); + +public sealed class MaintenanceJobDispatcher(IConfiguration configuration, IHttpClientFactory httpClientFactory) +{ + public const string CvBenchmarkJobId = "cv-benchmark"; + + public IReadOnlyList GetJobs() + { + var settings = ReadSettings(); + return + [ + new( + CvBenchmarkJobId, + "CV extraction benchmark", + "Runs the CV corpus benchmark and retains the report and fixture candidates as a workflow artifact.", + settings.IsConfigured, + settings.RunsUrl) + ]; + } + + public async Task DispatchAsync(string jobId, CancellationToken cancellationToken) + { + if (!string.Equals(jobId, CvBenchmarkJobId, StringComparison.Ordinal)) + { + throw new KeyNotFoundException("Unknown maintenance job."); + } + + var settings = ReadSettings(); + if (!settings.IsConfigured) + { + throw new InvalidOperationException("Maintenance job dispatch is not configured."); + } + + using var request = new HttpRequestMessage( + HttpMethod.Post, + $"{settings.BaseUrl}/api/v1/repos/{Uri.EscapeDataString(settings.Owner)}/{Uri.EscapeDataString(settings.Repository)}/actions/workflows/maintenance.yml/dispatches"); + request.Headers.Authorization = new AuthenticationHeaderValue("token", settings.Token); + request.Content = JsonContent.Create(new + { + @ref = "main", + inputs = new Dictionary { ["job"] = jobId } + }); + + using var response = await httpClientFactory.CreateClient("maintenance-jobs") + .SendAsync(request, cancellationToken); + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException( + $"Gitea rejected the maintenance workflow dispatch with HTTP {(int)response.StatusCode}.", + null, + response.StatusCode); + } + } + + private Settings ReadSettings() + { + var baseUrl = (configuration["MaintenanceJobs:GiteaBaseUrl"] ?? "https://git.cesnimda.uk").Trim().TrimEnd('/'); + var owner = (configuration["MaintenanceJobs:GiteaOwner"] ?? "cesnimda").Trim(); + var repository = (configuration["MaintenanceJobs:GiteaRepository"] ?? "jobtrackingapp").Trim(); + var token = (configuration["MaintenanceJobs:GiteaToken"] ?? string.Empty).Trim(); + var configured = Uri.TryCreate(baseUrl, UriKind.Absolute, out var uri) + && uri.Scheme == Uri.UriSchemeHttps + && !string.IsNullOrWhiteSpace(owner) + && !string.IsNullOrWhiteSpace(repository) + && !string.IsNullOrWhiteSpace(token); + var runsUrl = configured ? $"{baseUrl}/{owner}/{repository}/actions" : null; + return new Settings(baseUrl, owner, repository, token, configured, runsUrl); + } + + private sealed record Settings(string BaseUrl, string Owner, string Repository, string Token, bool IsConfigured, string? RunsUrl); +} diff --git a/docker-compose.yml b/docker-compose.yml index 63ea1ec..daf7b41 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -63,6 +63,11 @@ services: - App__Version=${APP_VERSION} - App__CommitSha=${APP_COMMIT_SHA} - App__BuildStamp=${APP_BUILD_STAMP} + # Optional admin-triggered allowlisted maintenance workflows in Gitea Actions. + - MaintenanceJobs__GiteaBaseUrl=${MAINTENANCE_GITEA_BASE_URL:-https://git.cesnimda.uk} + - MaintenanceJobs__GiteaOwner=${MAINTENANCE_GITEA_OWNER:-cesnimda} + - MaintenanceJobs__GiteaRepository=${MAINTENANCE_GITEA_REPOSITORY:-jobtrackingapp} + - MaintenanceJobs__GiteaToken=${MAINTENANCE_GITEA_TOKEN:-} - Email__Enabled=${EMAIL_ENABLED} - Email__SmtpHost=${EMAIL_SMTP_HOST} - Email__SmtpPort=${EMAIL_SMTP_PORT} diff --git a/job-tracker-ui/src/admin-system-page.test.tsx b/job-tracker-ui/src/admin-system-page.test.tsx index d0ca18e..c14e2a6 100644 --- a/job-tracker-ui/src/admin-system-page.test.tsx +++ b/job-tracker-ui/src/admin-system-page.test.tsx @@ -133,6 +133,17 @@ describe('AdminSystemPage', () => { }, } as any); } + if (url === '/admin/system/maintenance-jobs') { + return Promise.resolve({ + data: [{ + id: 'cv-benchmark', + title: 'CV extraction benchmark', + description: 'Runs the CV corpus benchmark.', + available: true, + runsUrl: 'https://git.example.test/actions', + }], + } as any); + } return Promise.resolve({ data: {} } as any); }); mockedApi.put.mockResolvedValue({ @@ -201,12 +212,25 @@ describe('AdminSystemPage', () => { }); }); + it('dispatches the allowlisted CV benchmark from the system panel', async () => { + mockedApi.post.mockResolvedValueOnce({ data: { jobId: 'cv-benchmark', status: 'queued' } } as any); + render(); + + fireEvent.click(await screen.findByRole('button', { name: /run benchmark/i })); + + await waitFor(() => { + expect(mockedApi.post).toHaveBeenCalledWith('/admin/system/maintenance-jobs/cv-benchmark/runs'); + }); + expect(await screen.findByText(/queued in Gitea Actions/i)).toBeTruthy(); + expect(screen.getByRole('link', { name: /view runs/i }).getAttribute('href')).toBe('https://git.example.test/actions'); + }); + it('localizes benchmark diagnostics without changing runtime values', async () => { window.localStorage.setItem('uiLanguage', 'nb'); render(); expect(await screen.findByText('Gjennomgang av CV-referansetest')).toBeTruthy(); - expect(screen.getByText(/Mistenkelig kontaktsted: san francisco, hobbies/i)).toBeTruthy(); + expect(await screen.findByText(/Mistenkelig kontaktsted: san francisco, hobbies/i)).toBeTruthy(); expect(screen.getByText('Production')).toBeTruthy(); expect(screen.getByText(/Modell · qwen2.5:7b/i)).toBeTruthy(); fireEvent.click(screen.getByRole('tab', { name: /innstillinger/i })); diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 97b0fc9..70aad59 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -1569,6 +1569,12 @@ export const translations = { adminBenchmarkConsistencyValue: "Consistency {value}", adminBenchmarkLatestSummary: "Latest markdown summary", adminBenchmarkRunScript: "Run scripts/run-cv-benchmark.sh to generate the latest corpus report and fixture candidates.", + adminMaintenanceRunBenchmark: "Run benchmark", + adminMaintenanceStarting: "Starting…", + adminMaintenanceQueued: "CV benchmark queued in Gitea Actions. The report and fixture candidates will be retained with the workflow run.", + adminMaintenanceFailed: "The maintenance job could not be started.", + adminMaintenanceUnavailable: "Configure a scoped Gitea Actions token to run this job from the system panel.", + adminMaintenanceViewRuns: "View runs", adminBenchmarkSuspiciousLocation: "Suspicious contact location: {value}", adminBenchmarkEducationOvercaptured: "Education qualification looks over-captured.", adminBenchmarkWorkTitleOvercaptured: "Work title looks over-captured.", @@ -3998,6 +4004,12 @@ export const translations = { adminBenchmarkConsistencyValue: "Konsistens {value}", adminBenchmarkLatestSummary: "Siste markdown-sammendrag", adminBenchmarkRunScript: "Kjør scripts/run-cv-benchmark.sh for å generere den nyeste korpusrapporten og kandidater til testdata.", + adminMaintenanceRunBenchmark: "Kjør referansetest", + adminMaintenanceStarting: "Starter…", + adminMaintenanceQueued: "CV-referansetesten er satt i kø i Gitea Actions. Rapporten og kandidatene til testdata lagres med arbeidsflytkjøringen.", + adminMaintenanceFailed: "Vedlikeholdsjobben kunne ikke startes.", + adminMaintenanceUnavailable: "Konfigurer et avgrenset Gitea Actions-token for å kjøre denne jobben fra systempanelet.", + adminMaintenanceViewRuns: "Vis kjøringer", adminBenchmarkSuspiciousLocation: "Mistenkelig kontaktsted: {value}", adminBenchmarkEducationOvercaptured: "Utdanningskvalifikasjonen ser ut til å inneholde for mye tekst.", adminBenchmarkWorkTitleOvercaptured: "Jobbtittelen ser ut til å inneholde for mye tekst.", diff --git a/job-tracker-ui/src/views/AdminSystemPage.tsx b/job-tracker-ui/src/views/AdminSystemPage.tsx index 0594620..bb97bdc 100644 --- a/job-tracker-ui/src/views/AdminSystemPage.tsx +++ b/job-tracker-ui/src/views/AdminSystemPage.tsx @@ -109,6 +109,14 @@ type CvBenchmarkStatus = { lastUpdatedAtUtc?: string | null; }; +type MaintenanceJob = { + id: string; + title: string; + description: string; + available: boolean; + runsUrl?: string | null; +}; + type SystemStatus = { environment: string; contentRoot: string; @@ -211,6 +219,9 @@ export default function AdminSystemPage() { const [status, setStatus] = useState(null); const [emailSettings, setEmailSettings] = useState(null); const [benchmarkStatus, setBenchmarkStatus] = useState(null); + const [maintenanceJobs, setMaintenanceJobs] = useState([]); + const [runningMaintenanceJob, setRunningMaintenanceJob] = useState(null); + const [maintenanceMessage, setMaintenanceMessage] = useState(null); const [smtpPassword, setSmtpPassword] = useState(""); const [clearPassword, setClearPassword] = useState(false); const [loading, setLoading] = useState(false); @@ -226,14 +237,16 @@ export default function AdminSystemPage() { setLoading(true); setError(null); try { - const [statusRes, emailRes, benchmarkRes] = await Promise.all([ + const [statusRes, emailRes, benchmarkRes, maintenanceRes] = await Promise.all([ api.get("/admin/system"), api.get("/admin/system/email-settings"), api.get("/admin/system/cv-benchmark").catch(() => ({ data: null } as any)), + api.get("/admin/system/maintenance-jobs").catch(() => ({ data: [] } as any)), ]); setStatus(statusRes.data); setEmailSettings(emailRes.data); setBenchmarkStatus(benchmarkRes.data ?? null); + setMaintenanceJobs(maintenanceRes.data ?? []); setSmtpPassword(""); setClearPassword(false); } catch (e: any) { @@ -241,6 +254,7 @@ export default function AdminSystemPage() { setStatus(null); setEmailSettings(null); setBenchmarkStatus(null); + setMaintenanceJobs([]); } finally { setLoading(false); } @@ -265,6 +279,7 @@ export default function AdminSystemPage() { }, [status]); const benchmarkIndex = useMemo(() => parseBenchmarkIndex(benchmarkStatus?.indexJson), [benchmarkStatus?.indexJson]); + const cvBenchmarkJob = useMemo(() => maintenanceJobs.find((job) => job.id === "cv-benchmark"), [maintenanceJobs]); const weakestEntries = useMemo(() => { if (!benchmarkIndex) return [] as CvBenchmarkEntry[]; return [...benchmarkIndex.Entries] @@ -335,6 +350,20 @@ export default function AdminSystemPage() { } }; + const runMaintenanceJob = async (jobId: string) => { + setRunningMaintenanceJob(jobId); + setMaintenanceMessage(null); + setError(null); + try { + await api.post(`/admin/system/maintenance-jobs/${encodeURIComponent(jobId)}/runs`); + setMaintenanceMessage(t("adminMaintenanceQueued")); + } catch (e: any) { + setError(getApiErrorMessage(e, t("adminMaintenanceFailed"))); + } finally { + setRunningMaintenanceJob(null); + } + }; + return ( @@ -508,7 +537,30 @@ export default function AdminSystemPage() { - {t("adminBenchmarkTitle")} + + + {t("adminBenchmarkTitle")} + + {cvBenchmarkJob?.description || t("adminBenchmarkRunScript")} + + + + {cvBenchmarkJob?.runsUrl ? ( + + ) : null} + + + + {!cvBenchmarkJob?.available ? {t("adminMaintenanceUnavailable")} : null} + {maintenanceMessage ? {maintenanceMessage} : null}