feat(admin): dispatch allowlisted maintenance jobs
This commit is contained in:
@@ -93,6 +93,11 @@ WEB_PROXY_SUBNET=172.31.250.0/29
|
|||||||
APP_VERSION=
|
APP_VERSION=
|
||||||
APP_COMMIT_SHA=
|
APP_COMMIT_SHA=
|
||||||
APP_BUILD_STAMP=
|
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_ENABLED=false
|
||||||
EMAIL_SMTP_HOST=smtp.gmail.com
|
EMAIL_SMTP_HOST=smtp.gmail.com
|
||||||
EMAIL_SMTP_PORT=587
|
EMAIL_SMTP_PORT=587
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1022,7 +1022,8 @@ public sealed class AuthAndSystemControllerTests
|
|||||||
null!,
|
null!,
|
||||||
Mock.Of<ISummarizerService>(),
|
Mock.Of<ISummarizerService>(),
|
||||||
new FakeEnv(),
|
new FakeEnv(),
|
||||||
emailSettings.Object);
|
emailSettings.Object,
|
||||||
|
new MaintenanceJobDispatcher(cfg, Mock.Of<IHttpClientFactory>()));
|
||||||
|
|
||||||
var result = await controller.GetEmailSettings(CancellationToken.None);
|
var result = await controller.GetEmailSettings(CancellationToken.None);
|
||||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||||
@@ -1037,13 +1038,15 @@ public sealed class AuthAndSystemControllerTests
|
|||||||
var summarizer = new Mock<ISummarizerService>();
|
var summarizer = new Mock<ISummarizerService>();
|
||||||
summarizer.Setup(x => x.RunProbeAsync(It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);
|
summarizer.Setup(x => x.RunProbeAsync(It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
var cfg = BuildConfig();
|
||||||
var controller = new AdminSystemController(
|
var controller = new AdminSystemController(
|
||||||
BuildConfig(),
|
cfg,
|
||||||
new AppPaths(BuildConfig(), new FakeHostEnv()),
|
new AppPaths(cfg, new FakeHostEnv()),
|
||||||
null!,
|
null!,
|
||||||
summarizer.Object,
|
summarizer.Object,
|
||||||
new FakeEnv(),
|
new FakeEnv(),
|
||||||
Mock.Of<IEmailSettingsResolver>());
|
Mock.Of<IEmailSettingsResolver>(),
|
||||||
|
new MaintenanceJobDispatcher(cfg, Mock.Of<IHttpClientFactory>()));
|
||||||
|
|
||||||
var result = await controller.RunSummarizerProbe(CancellationToken.None);
|
var result = await controller.RunSummarizerProbe(CancellationToken.None);
|
||||||
|
|
||||||
|
|||||||
@@ -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<IHttpClientFactory>());
|
||||||
|
|
||||||
|
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<IHttpClientFactory>();
|
||||||
|
factory.Setup(x => x.CreateClient("maintenance-jobs")).Returns(new HttpClient(handler));
|
||||||
|
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["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<IHttpClientFactory>();
|
||||||
|
var dispatcher = new MaintenanceJobDispatcher(new ConfigurationBuilder().Build(), factory.Object);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<KeyNotFoundException>(() => dispatcher.DispatchAsync("shell", CancellationToken.None));
|
||||||
|
factory.Verify(x => x.CreateClient(It.IsAny<string>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubHandler(Func<HttpRequestMessage, Task<HttpResponseMessage>> send) : HttpMessageHandler
|
||||||
|
{
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => send(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,8 +18,9 @@ public sealed class AdminSystemController : ControllerBase
|
|||||||
private readonly ISummarizerService _summarizer;
|
private readonly ISummarizerService _summarizer;
|
||||||
private readonly IWebHostEnvironment _env;
|
private readonly IWebHostEnvironment _env;
|
||||||
private readonly IEmailSettingsResolver _emailSettings;
|
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;
|
_cfg = cfg;
|
||||||
_paths = paths;
|
_paths = paths;
|
||||||
@@ -27,6 +28,7 @@ public sealed class AdminSystemController : ControllerBase
|
|||||||
_summarizer = summarizer;
|
_summarizer = summarizer;
|
||||||
_env = env;
|
_env = env;
|
||||||
_emailSettings = emailSettings;
|
_emailSettings = emailSettings;
|
||||||
|
_maintenanceJobs = maintenanceJobs;
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record StorageStatusDto(string DataRoot, string DbPath, bool DbExists, long? DbSizeBytes, int CompanyCount, int JobCount, int DeletedCount);
|
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 RuntimeStatusDto(string Framework, string OSDescription, string ProcessArchitecture, string? MachineName);
|
||||||
public sealed record AuthStatusDto(bool Required, bool HasJwtKey, bool GoogleConfigured, bool GmailConfigured);
|
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 CvBenchmarkStatusDto(string? IndexJson, string? ReportMarkdown, string RootPath, DateTimeOffset? LastUpdatedAtUtc);
|
||||||
|
public sealed record MaintenanceJobQueuedDto(string JobId, string Status, string? RunsUrl);
|
||||||
public sealed record SystemStatusDto(
|
public sealed record SystemStatusDto(
|
||||||
string Environment,
|
string Environment,
|
||||||
string ContentRoot,
|
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))));
|
return Ok(new CvBenchmarkStatusDto(indexJson, reportMarkdown, _paths.CvBenchmarksRoot, lastUpdated == default ? null : new DateTimeOffset(DateTime.SpecifyKind(lastUpdated, DateTimeKind.Utc))));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("maintenance-jobs")]
|
||||||
|
public ActionResult<IReadOnlyList<MaintenanceJobDefinition>> GetMaintenanceJobs() => Ok(_maintenanceJobs.GetJobs());
|
||||||
|
|
||||||
|
[HttpPost("maintenance-jobs/{jobId}/runs")]
|
||||||
|
public async Task<ActionResult<MaintenanceJobQueuedDto>> 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]
|
[HttpGet]
|
||||||
public async Task<ActionResult<SystemStatusDto>> Get(CancellationToken cancellationToken)
|
public async Task<ActionResult<SystemStatusDto>> Get(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ builder.Services.AddScoped<ICurrentUserService>(sp => sp.GetRequiredService<Curr
|
|||||||
builder.Services.AddSingleton<BackgroundTenantRunner>();
|
builder.Services.AddSingleton<BackgroundTenantRunner>();
|
||||||
builder.Services.AddSingleton(TimeProvider.System);
|
builder.Services.AddSingleton(TimeProvider.System);
|
||||||
builder.Services.AddScoped<UserOperationStore>();
|
builder.Services.AddScoped<UserOperationStore>();
|
||||||
|
builder.Services.AddSingleton<MaintenanceJobDispatcher>();
|
||||||
builder.Services.AddScoped<EmailSendAttemptStore>();
|
builder.Services.AddScoped<EmailSendAttemptStore>();
|
||||||
builder.Services.AddScoped<AccountOwnedFileInventory>();
|
builder.Services.AddScoped<AccountOwnedFileInventory>();
|
||||||
builder.Services.AddScoped<AccountDataExportService>();
|
builder.Services.AddScoped<AccountDataExportService>();
|
||||||
@@ -191,6 +192,11 @@ builder.Services.AddHttpClient("jobimport")
|
|||||||
AllowAutoRedirect = false
|
AllowAutoRedirect = false
|
||||||
});
|
});
|
||||||
|
|
||||||
|
builder.Services.AddHttpClient("maintenance-jobs", client =>
|
||||||
|
{
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(15);
|
||||||
|
});
|
||||||
|
|
||||||
// Local AI service (FastAPI). Supports summarization and OCR/text extraction.
|
// 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.
|
// Every caller goes through this named client, so the shared-secret header is set once here.
|
||||||
builder.Services.AddHttpClient("ai-service", client =>
|
builder.Services.AddHttpClient("ai-service", client =>
|
||||||
|
|||||||
@@ -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<MaintenanceJobDefinition> 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<string, string> { ["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);
|
||||||
|
}
|
||||||
@@ -63,6 +63,11 @@ services:
|
|||||||
- App__Version=${APP_VERSION}
|
- App__Version=${APP_VERSION}
|
||||||
- App__CommitSha=${APP_COMMIT_SHA}
|
- App__CommitSha=${APP_COMMIT_SHA}
|
||||||
- App__BuildStamp=${APP_BUILD_STAMP}
|
- 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__Enabled=${EMAIL_ENABLED}
|
||||||
- Email__SmtpHost=${EMAIL_SMTP_HOST}
|
- Email__SmtpHost=${EMAIL_SMTP_HOST}
|
||||||
- Email__SmtpPort=${EMAIL_SMTP_PORT}
|
- Email__SmtpPort=${EMAIL_SMTP_PORT}
|
||||||
|
|||||||
@@ -133,6 +133,17 @@ describe('AdminSystemPage', () => {
|
|||||||
},
|
},
|
||||||
} as any);
|
} 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);
|
return Promise.resolve({ data: {} } as any);
|
||||||
});
|
});
|
||||||
mockedApi.put.mockResolvedValue({
|
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(<I18nProvider><AdminSystemPage /></I18nProvider>);
|
||||||
|
|
||||||
|
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 () => {
|
it('localizes benchmark diagnostics without changing runtime values', async () => {
|
||||||
window.localStorage.setItem('uiLanguage', 'nb');
|
window.localStorage.setItem('uiLanguage', 'nb');
|
||||||
render(<I18nProvider><AdminSystemPage /></I18nProvider>);
|
render(<I18nProvider><AdminSystemPage /></I18nProvider>);
|
||||||
|
|
||||||
expect(await screen.findByText('Gjennomgang av CV-referansetest')).toBeTruthy();
|
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('Production')).toBeTruthy();
|
||||||
expect(screen.getByText(/Modell · qwen2.5:7b/i)).toBeTruthy();
|
expect(screen.getByText(/Modell · qwen2.5:7b/i)).toBeTruthy();
|
||||||
fireEvent.click(screen.getByRole('tab', { name: /innstillinger/i }));
|
fireEvent.click(screen.getByRole('tab', { name: /innstillinger/i }));
|
||||||
|
|||||||
@@ -1569,6 +1569,12 @@ export const translations = {
|
|||||||
adminBenchmarkConsistencyValue: "Consistency {value}",
|
adminBenchmarkConsistencyValue: "Consistency {value}",
|
||||||
adminBenchmarkLatestSummary: "Latest markdown summary",
|
adminBenchmarkLatestSummary: "Latest markdown summary",
|
||||||
adminBenchmarkRunScript: "Run scripts/run-cv-benchmark.sh to generate the latest corpus report and fixture candidates.",
|
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}",
|
adminBenchmarkSuspiciousLocation: "Suspicious contact location: {value}",
|
||||||
adminBenchmarkEducationOvercaptured: "Education qualification looks over-captured.",
|
adminBenchmarkEducationOvercaptured: "Education qualification looks over-captured.",
|
||||||
adminBenchmarkWorkTitleOvercaptured: "Work title looks over-captured.",
|
adminBenchmarkWorkTitleOvercaptured: "Work title looks over-captured.",
|
||||||
@@ -3998,6 +4004,12 @@ export const translations = {
|
|||||||
adminBenchmarkConsistencyValue: "Konsistens {value}",
|
adminBenchmarkConsistencyValue: "Konsistens {value}",
|
||||||
adminBenchmarkLatestSummary: "Siste markdown-sammendrag",
|
adminBenchmarkLatestSummary: "Siste markdown-sammendrag",
|
||||||
adminBenchmarkRunScript: "Kjør scripts/run-cv-benchmark.sh for å generere den nyeste korpusrapporten og kandidater til testdata.",
|
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}",
|
adminBenchmarkSuspiciousLocation: "Mistenkelig kontaktsted: {value}",
|
||||||
adminBenchmarkEducationOvercaptured: "Utdanningskvalifikasjonen ser ut til å inneholde for mye tekst.",
|
adminBenchmarkEducationOvercaptured: "Utdanningskvalifikasjonen ser ut til å inneholde for mye tekst.",
|
||||||
adminBenchmarkWorkTitleOvercaptured: "Jobbtittelen ser ut til å inneholde for mye tekst.",
|
adminBenchmarkWorkTitleOvercaptured: "Jobbtittelen ser ut til å inneholde for mye tekst.",
|
||||||
|
|||||||
@@ -109,6 +109,14 @@ type CvBenchmarkStatus = {
|
|||||||
lastUpdatedAtUtc?: string | null;
|
lastUpdatedAtUtc?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type MaintenanceJob = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
available: boolean;
|
||||||
|
runsUrl?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
type SystemStatus = {
|
type SystemStatus = {
|
||||||
environment: string;
|
environment: string;
|
||||||
contentRoot: string;
|
contentRoot: string;
|
||||||
@@ -211,6 +219,9 @@ export default function AdminSystemPage() {
|
|||||||
const [status, setStatus] = useState<SystemStatus | null>(null);
|
const [status, setStatus] = useState<SystemStatus | null>(null);
|
||||||
const [emailSettings, setEmailSettings] = useState<EditableEmailSettings | null>(null);
|
const [emailSettings, setEmailSettings] = useState<EditableEmailSettings | null>(null);
|
||||||
const [benchmarkStatus, setBenchmarkStatus] = useState<CvBenchmarkStatus | null>(null);
|
const [benchmarkStatus, setBenchmarkStatus] = useState<CvBenchmarkStatus | null>(null);
|
||||||
|
const [maintenanceJobs, setMaintenanceJobs] = useState<MaintenanceJob[]>([]);
|
||||||
|
const [runningMaintenanceJob, setRunningMaintenanceJob] = useState<string | null>(null);
|
||||||
|
const [maintenanceMessage, setMaintenanceMessage] = useState<string | null>(null);
|
||||||
const [smtpPassword, setSmtpPassword] = useState("");
|
const [smtpPassword, setSmtpPassword] = useState("");
|
||||||
const [clearPassword, setClearPassword] = useState(false);
|
const [clearPassword, setClearPassword] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -226,14 +237,16 @@ export default function AdminSystemPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const [statusRes, emailRes, benchmarkRes] = await Promise.all([
|
const [statusRes, emailRes, benchmarkRes, maintenanceRes] = await Promise.all([
|
||||||
api.get<SystemStatus>("/admin/system"),
|
api.get<SystemStatus>("/admin/system"),
|
||||||
api.get<EditableEmailSettings>("/admin/system/email-settings"),
|
api.get<EditableEmailSettings>("/admin/system/email-settings"),
|
||||||
api.get<CvBenchmarkStatus>("/admin/system/cv-benchmark").catch(() => ({ data: null } as any)),
|
api.get<CvBenchmarkStatus>("/admin/system/cv-benchmark").catch(() => ({ data: null } as any)),
|
||||||
|
api.get<MaintenanceJob[]>("/admin/system/maintenance-jobs").catch(() => ({ data: [] } as any)),
|
||||||
]);
|
]);
|
||||||
setStatus(statusRes.data);
|
setStatus(statusRes.data);
|
||||||
setEmailSettings(emailRes.data);
|
setEmailSettings(emailRes.data);
|
||||||
setBenchmarkStatus(benchmarkRes.data ?? null);
|
setBenchmarkStatus(benchmarkRes.data ?? null);
|
||||||
|
setMaintenanceJobs(maintenanceRes.data ?? []);
|
||||||
setSmtpPassword("");
|
setSmtpPassword("");
|
||||||
setClearPassword(false);
|
setClearPassword(false);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -241,6 +254,7 @@ export default function AdminSystemPage() {
|
|||||||
setStatus(null);
|
setStatus(null);
|
||||||
setEmailSettings(null);
|
setEmailSettings(null);
|
||||||
setBenchmarkStatus(null);
|
setBenchmarkStatus(null);
|
||||||
|
setMaintenanceJobs([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -265,6 +279,7 @@ export default function AdminSystemPage() {
|
|||||||
}, [status]);
|
}, [status]);
|
||||||
|
|
||||||
const benchmarkIndex = useMemo(() => parseBenchmarkIndex(benchmarkStatus?.indexJson), [benchmarkStatus?.indexJson]);
|
const benchmarkIndex = useMemo(() => parseBenchmarkIndex(benchmarkStatus?.indexJson), [benchmarkStatus?.indexJson]);
|
||||||
|
const cvBenchmarkJob = useMemo(() => maintenanceJobs.find((job) => job.id === "cv-benchmark"), [maintenanceJobs]);
|
||||||
const weakestEntries = useMemo(() => {
|
const weakestEntries = useMemo(() => {
|
||||||
if (!benchmarkIndex) return [] as CvBenchmarkEntry[];
|
if (!benchmarkIndex) return [] as CvBenchmarkEntry[];
|
||||||
return [...benchmarkIndex.Entries]
|
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 (
|
return (
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}>
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}>
|
||||||
@@ -508,7 +537,30 @@ export default function AdminSystemPage() {
|
|||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
<Paper sx={{ p: 2, borderRadius: 3 }}>
|
<Paper sx={{ p: 2, borderRadius: 3 }}>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>{t("adminBenchmarkTitle")}</Typography>
|
<Box sx={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 2, flexWrap: "wrap", mb: 1 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 900 }}>{t("adminBenchmarkTitle")}</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.25 }}>
|
||||||
|
{cvBenchmarkJob?.description || t("adminBenchmarkRunScript")}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap" }}>
|
||||||
|
{cvBenchmarkJob?.runsUrl ? (
|
||||||
|
<Button component="a" href={cvBenchmarkJob.runsUrl} target="_blank" rel="noreferrer" variant="outlined">
|
||||||
|
{t("adminMaintenanceViewRuns")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
disabled={!cvBenchmarkJob?.available || runningMaintenanceJob === "cv-benchmark"}
|
||||||
|
onClick={() => void runMaintenanceJob("cv-benchmark")}
|
||||||
|
>
|
||||||
|
{runningMaintenanceJob === "cv-benchmark" ? t("adminMaintenanceStarting") : t("adminMaintenanceRunBenchmark")}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
{!cvBenchmarkJob?.available ? <Alert severity="info" sx={{ mb: 1.5 }}>{t("adminMaintenanceUnavailable")}</Alert> : null}
|
||||||
|
{maintenanceMessage ? <Alert severity="success" sx={{ mb: 1.5 }}>{maintenanceMessage}</Alert> : null}
|
||||||
<Stack spacing={0.75}>
|
<Stack spacing={0.75}>
|
||||||
<DetailRow label={t("adminBenchmarkRoot")} value={benchmarkStatus?.rootPath || "-"} />
|
<DetailRow label={t("adminBenchmarkRoot")} value={benchmarkStatus?.rootPath || "-"} />
|
||||||
<DetailRow label={t("adminBenchmarkLastUpdate")} value={formatDate(benchmarkStatus?.lastUpdatedAtUtc, language === "nb" ? "nb-NO" : "en")} />
|
<DetailRow label={t("adminBenchmarkLastUpdate")} value={formatDate(benchmarkStatus?.lastUpdatedAtUtc, language === "nb" ? "nb-NO" : "en")} />
|
||||||
|
|||||||
Reference in New Issue
Block a user