feat(admin): add safe processing monitor and operation correlation
This commit is contained in:
@@ -1,12 +1,42 @@
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.MariaDb.Tests;
|
||||
|
||||
public sealed class UserOperationHistoryPersistenceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Administrator_metadata_grouping_paging_associations_and_string_cursors_use_real_MariaDB_SQL()
|
||||
{
|
||||
await using var database = await MariaDbDatabase.CreateAsync();
|
||||
await using var db = database.Context();
|
||||
db.Users.Add(new() { Id = "rd21-owner", UserName = "synthetic-history", Email = "history@example.test" });
|
||||
var artifact = new CvUploadArtifact { OwnerUserId = "rd21-owner", OriginalFileName = "synthetic.pdf", MimeType = "application/pdf", ByteSize = 400 };
|
||||
db.CvUploadArtifacts.Add(artifact);
|
||||
await db.SaveChangesAsync();
|
||||
var run = new CvExtractionRun { OwnerUserId = "rd21-owner", ArtifactId = artifact.Id, Trigger = "document_import", ParserVersion = "docling-v1", DocumentJson = "{\"PageCount\":2,\"Text\":\"SECRET_SOURCE\"}" };
|
||||
db.CvExtractionRuns.Add(run);
|
||||
await db.SaveChangesAsync();
|
||||
var operation = (await new UserOperationStore(db, TimeProvider.System).CreateAsync(Request("admin-query") with { SubjectType = CvProcessingQueue.SubjectType, SubjectId = run.Id.ToString() }, default)).Operation;
|
||||
var controller = new AdminProcessingController(db, new ConfigurationBuilder().Build(), TimeProvider.System);
|
||||
var listing = Assert.IsType<AdminProcessingController.PageDto>(Assert.IsType<OkObjectResult>((await controller.List(owner: "rd21-owner", pageSize: 1)).Result).Value);
|
||||
Assert.Equal(1, listing.Total);
|
||||
Assert.Equal(1, listing.Counts[OperationStatuses.Queued]);
|
||||
Assert.Equal("synthetic.pdf", Assert.Single(listing.Items).Document!.Name);
|
||||
var detail = Assert.IsType<AdminProcessingController.ItemDto>(Assert.IsType<OkObjectResult>((await controller.Detail(operation.Id, default)).Result).Value);
|
||||
Assert.Equal(2, detail.Document!.Pages);
|
||||
Assert.DoesNotContain("SECRET", System.Text.Json.JsonSerializer.Serialize(detail));
|
||||
var events = Assert.IsType<AdminProcessingController.EventPageDto>(Assert.IsType<OkObjectResult>((await controller.Events(operation.Id)).Result).Value);
|
||||
var item = Assert.Single(events.Items);
|
||||
Assert.True(long.TryParse(item.Id, out _));
|
||||
Assert.Equal(DateTimeKind.Utc, item.AtUtc.Kind);
|
||||
}
|
||||
|
||||
private static CreateUserOperation Request(string key) => new("cv.process", key, "authorized", "local_only");
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class AdminProcessingControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task User_authored_identity_labels_are_bounded_before_admin_projection()
|
||||
{
|
||||
await using var host = await Host.Start();
|
||||
using var client = host.Client("admin");
|
||||
var detail = await client.GetFromJsonAsync<JsonElement>($"/api/admin/processing/{host.OperationId}");
|
||||
var name = detail.GetProperty("owner").GetProperty("name").GetString()!;
|
||||
Assert.True(name.Length <= 161);
|
||||
Assert.EndsWith("…", name);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, HttpStatusCode.Unauthorized)]
|
||||
[InlineData("ordinary", HttpStatusCode.Forbidden)]
|
||||
[InlineData("admin", HttpStatusCode.OK)]
|
||||
public async Task Processing_routes_require_the_server_administrator_role(string? identity, HttpStatusCode expected)
|
||||
{
|
||||
await using var host = await Host.Start();
|
||||
using var client = host.Client(identity);
|
||||
foreach (var route in new[] { "/api/admin/processing", $"/api/admin/processing/{host.OperationId}", $"/api/admin/processing/{host.OperationId}/events" })
|
||||
Assert.Equal(expected, (await client.GetAsync(route)).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admin_projection_filters_owners_and_never_returns_raw_processing_contents_or_lease_data()
|
||||
{
|
||||
await using var host = await Host.Start();
|
||||
using var client = host.Client("admin");
|
||||
var listing = await client.GetFromJsonAsync<JsonElement>("/api/admin/processing?owner=owner&status=running&pageSize=1");
|
||||
Assert.Equal(1, listing.GetProperty("total").GetInt32());
|
||||
Assert.Single(listing.GetProperty("items").EnumerateArray());
|
||||
var detail = await client.GetFromJsonAsync<JsonElement>($"/api/admin/processing/{host.OperationId}");
|
||||
Assert.Contains("owner@example.test", detail.GetRawText());
|
||||
Assert.Contains("CV.pdf", detail.GetRawText());
|
||||
Assert.Equal(2, detail.GetProperty("document").GetProperty("pages").GetInt32());
|
||||
Assert.DoesNotContain("folder", detail.GetProperty("document").GetProperty("name").GetString());
|
||||
Assert.DoesNotContain("SECRET", detail.GetRawText());
|
||||
Assert.DoesNotContain("SECRET", listing.GetRawText());
|
||||
var foreign = await client.GetFromJsonAsync<JsonElement>($"/api/admin/processing/{host.ForeignSubjectOperationId}");
|
||||
Assert.Equal(JsonValueKind.Null, foreign.GetProperty("job").ValueKind);
|
||||
Assert.DoesNotContain("Owned job title", foreign.GetRawText());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Event_cursor_is_exact_ordered_bounded_retained_and_redacted()
|
||||
{
|
||||
await using var host = await Host.Start();
|
||||
using var client = host.Client("admin");
|
||||
var first = await client.GetFromJsonAsync<JsonElement>($"/api/admin/processing/{host.OperationId}/events?take=1");
|
||||
Assert.True(first.GetProperty("hasMore").GetBoolean());
|
||||
var item = Assert.Single(first.GetProperty("items").EnumerateArray());
|
||||
Assert.Equal("9007199254740994", item.GetProperty("id").GetString());
|
||||
Assert.DoesNotContain("SECRET", first.GetRawText());
|
||||
var after = await client.GetFromJsonAsync<JsonElement>($"/api/admin/processing/{host.OperationId}/events?after=9007199254740993");
|
||||
Assert.Single(after.GetProperty("items").EnumerateArray());
|
||||
var before = await client.GetFromJsonAsync<JsonElement>($"/api/admin/processing/{host.OperationId}/events?before=9007199254740994");
|
||||
Assert.Equal("9007199254740993", Assert.Single(before.GetProperty("items").EnumerateArray()).GetProperty("id").GetString());
|
||||
foreach (var query in new[] { "take=101", "after=-1", "after=1&before=2" })
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await client.GetAsync($"/api/admin/processing/{host.OperationId}/events?{query}")).StatusCode);
|
||||
}
|
||||
|
||||
private sealed class Host(WebApplication app, string directory, Guid operationId, Guid foreignSubjectId) : IAsyncDisposable
|
||||
{
|
||||
public Guid OperationId => operationId;
|
||||
public Guid ForeignSubjectOperationId => foreignSubjectId;
|
||||
public HttpClient Client(string? identity)
|
||||
{
|
||||
var client = new HttpClient { BaseAddress = new Uri(app.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.Single()) };
|
||||
if (identity is not null) client.DefaultRequestHeaders.Add("X-Test-Identity", identity);
|
||||
return client;
|
||||
}
|
||||
public static async Task<Host> Start()
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), "admin-processing-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(directory);
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.Logging.ClearProviders(); builder.WebHost.UseUrls("http://127.0.0.1:0");
|
||||
builder.Services.AddControllers().AddApplicationPart(typeof(AuthController).Assembly);
|
||||
builder.Services.AddAuthentication("test").AddScheme<AuthenticationSchemeOptions, TestAuthentication>("test", _ => { });
|
||||
builder.Services.AddAuthorization(); builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddDbContext<JobTrackerContext>(options => options.UseSqlite($"Data Source={Path.Combine(directory, "processing.db")};Pooling=False"));
|
||||
var app = builder.Build();
|
||||
app.UseAuthentication(); app.UseAuthorization(); app.MapControllers();
|
||||
var operationId = Guid.NewGuid(); var foreign = Guid.NewGuid();
|
||||
await using (var scope = app.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
db.Users.AddRange(new ApplicationUser { Id = "owner", Email = "owner@example.test", DisplayName = "Synthetic owner " + new string('n', 2000) }, new ApplicationUser { Id = "other", Email = "other@example.test" });
|
||||
var company = new Company { Id = 1, OwnerUserId = "owner", Name = "Synthetic company" };
|
||||
db.Companies.Add(company);
|
||||
db.JobApplications.Add(new() { Id = 2, OwnerUserId = "owner", CompanyId = 1, JobTitle = "Owned job title", Status = "Applied" });
|
||||
db.CvUploadArtifacts.Add(new() { Id = 1, OwnerUserId = "owner", OriginalFileName = "../folder/CV.pdf", MimeType = "application/pdf", ByteSize = 500, StoragePath = "SECRET_PATH" });
|
||||
db.CvExtractionRuns.Add(new() { Id = 1, ArtifactId = 1, OwnerUserId = "owner", Trigger = "document_import", ParserVersion = "docling-v1", DocumentJson = "{\"PageCount\":2,\"Text\":\"SECRET_CV_CONTENT\"}" });
|
||||
db.UserOperations.AddRange(new UserOperation { Id = operationId, OwnerUserId = "owner", IdempotencyKey = "one", TaskType = "cv.process", SubjectType = "cv_extraction_run", SubjectId = "1", Status = "running", CreatedAtUtc = DateTime.UtcNow, LeaseToken = "SECRET_TOKEN", FailureMessage = "SECRET_EXCEPTION", FailureCategory = "SECRET_CATEGORY", ResultReference = "SECRET_RESULT", ProgressStage = "SECRET_STAGE" },
|
||||
new UserOperation { Id = foreign, OwnerUserId = "other", IdempotencyKey = "two", TaskType = "strategy.snapshot", SubjectType = "job_strategy", SubjectId = "2|", Status = "queued", CreatedAtUtc = DateTime.UtcNow });
|
||||
db.UserOperationEvents.AddRange(
|
||||
new UserOperationEvent { Id = 100, OperationId = operationId, OwnerUserId = "owner", AtUtc = DateTime.UtcNow.AddDays(-31), Kind = "Created", Status = "queued" },
|
||||
new UserOperationEvent { Id = 9007199254740993, OperationId = operationId, OwnerUserId = "owner", AtUtc = DateTime.UtcNow, Kind = "Created", Status = "running" },
|
||||
new UserOperationEvent { Id = 9007199254740994, OperationId = operationId, OwnerUserId = "owner", AtUtc = DateTime.UtcNow, Kind = "SECRET_EVENT", Status = "SECRET_STATUS", Stage = "SECRET_STAGE", FailureCode = "SECRET_FAILURE" });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
await app.StartAsync(); return new(app, directory, operationId, foreign);
|
||||
}
|
||||
public async ValueTask DisposeAsync() { await app.DisposeAsync(); Directory.Delete(directory, true); }
|
||||
}
|
||||
private sealed class TestAuthentication(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
||||
{
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
if (!Request.Headers.TryGetValue("X-Test-Identity", out var identity)) return Task.FromResult(AuthenticateResult.NoResult());
|
||||
var claims = new List<Claim> { new(ClaimTypes.NameIdentifier, identity.ToString()) };
|
||||
if (identity == "admin") claims.Add(new(ClaimTypes.Role, "Admin"));
|
||||
return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(new ClaimsIdentity(claims, "test")), "test")));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,13 +106,37 @@ public sealed class AiPrivacyPolicyTests
|
||||
Assert.Equal("strategy.snapshot", capture.TaskType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Correlation_comes_only_from_the_actual_operation_scope_for_all_sidecar_paths()
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync(adminEnabled: false);
|
||||
var execution = new AiOperationExecutionScope();
|
||||
var capture = new CaptureHandler();
|
||||
using var client = new HttpClient(new AiPrivacyHeaderHandler(new HttpContextAccessor(), fixture.Policy, execution) { InnerHandler = capture });
|
||||
var id = Guid.NewGuid();
|
||||
var lease = new UserOperationLease(id, "user-1", "lease", "cv.process", "local_only", "cv_extraction_run", "1", 1, null);
|
||||
using (execution.Use(new(lease, "local_only")))
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "http://ai-service/cv-import/extract");
|
||||
request.Headers.Add("X-Operation-Id", "SECRET_untrusted_header");
|
||||
await client.SendAsync(request);
|
||||
Assert.Equal(id.ToString("D"), capture.Correlation);
|
||||
}
|
||||
using var ordinary = new HttpRequestMessage(HttpMethod.Get, "http://ai-service/health");
|
||||
ordinary.Headers.Add("X-Operation-Id", "SECRET_untrusted_header");
|
||||
await client.SendAsync(ordinary);
|
||||
Assert.Null(capture.Correlation);
|
||||
}
|
||||
|
||||
private sealed class CaptureHandler : HttpMessageHandler
|
||||
{
|
||||
public string? ExternalAllowed { get; private set; }
|
||||
public string? TaskType { get; private set; }
|
||||
public string? Correlation { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
Correlation = request.Headers.TryGetValues("X-Operation-Id", out var correlation) ? correlation.Single() : null;
|
||||
ExternalAllowed = request.Headers.TryGetValues(AiPrivacyPolicy.ExternalAllowedHeader, out var values)
|
||||
? values.Single()
|
||||
: null;
|
||||
|
||||
@@ -106,7 +106,7 @@ public sealed class ApiAuthorizationPostureTests
|
||||
[Fact]
|
||||
public void Admin_controllers_require_the_admin_role_not_merely_a_signed_in_user()
|
||||
{
|
||||
foreach (var name in new[] { "AdminAuditController", "AdminSystemController", "UsersController" })
|
||||
foreach (var name in new[] { "AdminAuditController", "AdminSystemController", "AdminProcessingController", "UsersController" })
|
||||
{
|
||||
var type = Controllers().Single(t => t.Name == name);
|
||||
var attribute = type.GetCustomAttribute<AuthorizeAttribute>(inherit: true);
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace JobTrackerApi.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/admin/processing")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public sealed class AdminProcessingController(JobTrackerContext db, IConfiguration configuration, TimeProvider clock) : ControllerBase
|
||||
{
|
||||
private static readonly HashSet<string> EventKinds = Enum.GetNames<OperationEventKind>().ToHashSet(StringComparer.Ordinal);
|
||||
public sealed record IdentityDto(string Id, string? Name, string? Email);
|
||||
public sealed record JobDto(int Id, string Title, string? Company);
|
||||
public sealed record DocumentDto(string Name, string? Type, long Bytes, int? Pages, string? State);
|
||||
public sealed record ItemDto(Guid Id, IdentityDto Owner, string TaskType, string Status, string? Stage, int AttemptCount,
|
||||
int MaxAttempts, DateTime CreatedAtUtc, DateTime? StartedAtUtc, DateTime? CompletedAtUtc, DateTime? LastHeartbeatAtUtc,
|
||||
double DurationSeconds, string Worker, bool WorkerEnabled, string? FailureCode, JobDto? Job, DocumentDto? Document);
|
||||
public sealed record WorkerDto(string Name, bool FlagEnabled, bool IndividualOperationsRecorded);
|
||||
public sealed record PageDto(IReadOnlyList<ItemDto> Items, int Total, int Page, int PageSize,
|
||||
IReadOnlyDictionary<string, int> Counts, IReadOnlyList<WorkerDto> Workers, string CvInterpretation);
|
||||
public sealed record EventDto(string Id, DateTime AtUtc, string Kind, string Status, string? Stage, int Attempt, string? FailureCode);
|
||||
public sealed record EventPageDto(IReadOnlyList<EventDto> Items, bool HasMore, int RetentionDays);
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<PageDto>> List([FromQuery] string? status = null, [FromQuery] string? taskType = null,
|
||||
[FromQuery] string? owner = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 25, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (page is < 1 or > 1000 || pageSize is < 1 or > 100 || owner?.Length > 255
|
||||
|| (status is not null && OperationEventLog.SafeStatus(status) == "unknown")
|
||||
|| (taskType is not null && taskType is not (CvProcessingQueue.TaskType or StrategySnapshotService.TaskType or "other"))) return BadRequest();
|
||||
var all = ReadOperations();
|
||||
var query = all;
|
||||
if (status is not null) query = query.Where(item => item.Status == status);
|
||||
if (!string.IsNullOrWhiteSpace(owner)) query = query.Where(item => item.OwnerUserId == owner);
|
||||
if (taskType == "other") query = query.Where(item => item.TaskType != CvProcessingQueue.TaskType && item.TaskType != StrategySnapshotService.TaskType);
|
||||
else if (taskType is not null) query = query.Where(item => item.TaskType == taskType);
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
var operations = await query.OrderByDescending(item => item.CreatedAtUtc).ThenByDescending(item => item.Id)
|
||||
.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync(cancellationToken);
|
||||
var grouped = await all.GroupBy(item => item.Status).Select(group => new { Status = group.Key, Count = group.Count() }).ToListAsync(cancellationToken);
|
||||
var counts = grouped.GroupBy(item => OperationEventLog.SafeStatus(item.Status)).ToDictionary(group => group.Key, group => group.Sum(item => item.Count));
|
||||
var workers = new[]
|
||||
{
|
||||
new WorkerDto("AiOperations", Enabled("AiOperations"), true),
|
||||
new WorkerDto("CvDocumentImports", Enabled("CvDocumentImports"), true),
|
||||
new WorkerDto("JobEnrichment", Enabled("JobEnrichment"), false),
|
||||
new WorkerDto("Rules", Enabled("Rules"), false),
|
||||
new WorkerDto("FollowUpReminders", Enabled("FollowUpReminders"), false),
|
||||
new WorkerDto("DailyExport", Enabled("DailyExport"), false),
|
||||
new WorkerDto("SavedJobSearchAlerts", configuration.GetValue("Workers:SavedJobSearchAlertsEnabled", true), false),
|
||||
};
|
||||
return Ok(new PageDto(await Project(operations, false, cancellationToken), total, page, pageSize, counts, workers, "deterministic"));
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<ActionResult<ItemDto>> Detail(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var operation = await ReadOperations().SingleOrDefaultAsync(item => item.Id == id, cancellationToken);
|
||||
if (operation is null) return NotFound();
|
||||
return Ok((await Project(new[] { operation }, true, cancellationToken)).Single());
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/events")]
|
||||
public async Task<ActionResult<EventPageDto>> Events(Guid id, [FromQuery] long? after = null, [FromQuery] long? before = null,
|
||||
[FromQuery] int take = 100, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (take is < 1 or > 100 || after < 0 || before < 0 || (after is not null && before is not null)) return BadRequest();
|
||||
var owner = await db.UserOperations.IgnoreQueryFilters().Where(item => item.Id == id).Select(item => item.OwnerUserId).SingleOrDefaultAsync(cancellationToken);
|
||||
if (owner is null) return NotFound();
|
||||
var cutoff = clock.GetUtcNow().UtcDateTime.AddDays(-OperationEventLog.RetentionDays);
|
||||
var query = db.UserOperationEvents.IgnoreQueryFilters().AsNoTracking().Where(item => item.OperationId == id && item.OwnerUserId == owner && item.AtUtc >= cutoff);
|
||||
if (after is not null) query = query.Where(item => item.Id > after);
|
||||
if (before is not null) query = query.Where(item => item.Id < before);
|
||||
var rows = await (after is not null ? query.OrderBy(item => item.Id) : query.OrderByDescending(item => item.Id)).Take(take + 1).ToListAsync(cancellationToken);
|
||||
var hasMore = rows.Count > take;
|
||||
rows = rows.Take(take).ToList();
|
||||
if (after is null) rows.Reverse();
|
||||
var items = rows.Select(item => new EventDto(item.Id.ToString(CultureInfo.InvariantCulture), Utc(item.AtUtc),
|
||||
EventKinds.Contains(item.Kind) ? item.Kind : "Unknown",
|
||||
OperationEventLog.SafeStatus(item.Status), OperationEventLog.SafeStage(item.Stage), Math.Clamp(item.Attempt, 0, 10), OperationEventLog.SafeFailure(item.FailureCode))).ToArray();
|
||||
return Ok(new EventPageDto(items, hasMore, OperationEventLog.RetentionDays));
|
||||
}
|
||||
|
||||
private bool Enabled(string worker) => configuration.GetValue($"Workers:{worker}Enabled", false);
|
||||
private IQueryable<UserOperation> ReadOperations() => db.UserOperations.IgnoreQueryFilters().AsNoTracking().Select(item => new UserOperation
|
||||
{
|
||||
Id = item.Id, OwnerUserId = item.OwnerUserId, TaskType = item.TaskType, SubjectType = item.SubjectType, SubjectId = item.SubjectId,
|
||||
Status = item.Status, ProgressStage = item.ProgressStage, AttemptCount = item.AttemptCount, MaxAttempts = item.MaxAttempts,
|
||||
CreatedAtUtc = item.CreatedAtUtc, StartedAtUtc = item.StartedAtUtc, CompletedAtUtc = item.CompletedAtUtc,
|
||||
LastHeartbeatAtUtc = item.LastHeartbeatAtUtc, FailureCategory = item.FailureCategory,
|
||||
});
|
||||
private static DateTime Utc(DateTime value) => DateTime.SpecifyKind(value, DateTimeKind.Utc);
|
||||
private static DateTime? Utc(DateTime? value) => value is null ? null : Utc(value.Value);
|
||||
private static string SafeTask(string value) => value is CvProcessingQueue.TaskType or StrategySnapshotService.TaskType ? value : "other";
|
||||
private static int? Subject(UserOperation operation)
|
||||
{
|
||||
if (operation.SubjectId?.Length > 128) return null;
|
||||
if (operation.TaskType == CvProcessingQueue.TaskType && operation.SubjectType == CvProcessingQueue.SubjectType)
|
||||
return int.TryParse(operation.SubjectId, NumberStyles.None, CultureInfo.InvariantCulture, out var id) && id > 0 ? id : null;
|
||||
if (operation.TaskType == StrategySnapshotService.TaskType && operation.SubjectType == "job_strategy")
|
||||
{
|
||||
try { return StrategySnapshotService.DecodeSubject(operation.SubjectId).JobId; }
|
||||
catch (AiOperationFailure) { return null; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
private static string FileName(string value) => new string(value.Split('/', '\\').Last().Where(character => !char.IsControl(character)).Take(120).ToArray());
|
||||
private static int? Pages(string? json)
|
||||
{
|
||||
if (json is null || json.Length > 2 * 1024 * 1024) return null;
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
if (document.RootElement.ValueKind == JsonValueKind.Object &&
|
||||
(document.RootElement.TryGetProperty("PageCount", out var field) || document.RootElement.TryGetProperty("pageCount", out field)) &&
|
||||
field.ValueKind == JsonValueKind.Number && field.TryGetInt32(out var count) && count is > 0 and <= 5000) return count;
|
||||
}
|
||||
catch (JsonException) { }
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<ItemDto>> Project(IReadOnlyList<UserOperation> operations, bool includePages, CancellationToken token)
|
||||
{
|
||||
var ownerIds = operations.Select(item => item.OwnerUserId).Distinct().ToArray();
|
||||
var users = await db.Users.AsNoTracking().Where(item => ownerIds.Contains(item.Id))
|
||||
.Select(item => new { item.Id, Name = item.DisplayName ?? item.UserName, item.Email })
|
||||
.Select(item => new IdentityDto(item.Id, item.Name != null && item.Name.Length > 160 ? item.Name.Substring(0, 160) + "…" : item.Name,
|
||||
item.Email != null && item.Email.Length > 254 ? item.Email.Substring(0, 254) + "…" : item.Email)).ToDictionaryAsync(item => item.Id, token);
|
||||
var cvIds = operations.Where(item => item.TaskType == CvProcessingQueue.TaskType).Select(Subject).OfType<int>().ToArray();
|
||||
var jobIds = operations.Where(item => item.TaskType == StrategySnapshotService.TaskType).Select(Subject).OfType<int>().ToArray();
|
||||
var runs = await db.CvExtractionRuns.IgnoreQueryFilters().AsNoTracking().Where(item => cvIds.Contains(item.Id))
|
||||
.Select(item => new { item.Id, item.OwnerUserId, item.ArtifactId, item.ParserVersion, item.Trigger, item.Status, DocumentJson = includePages ? item.DocumentJson : null }).ToDictionaryAsync(item => item.Id, token);
|
||||
var artifactIds = runs.Values.Select(item => item.ArtifactId).OfType<int>().ToArray();
|
||||
var artifacts = await db.CvUploadArtifacts.IgnoreQueryFilters().AsNoTracking().Where(item => artifactIds.Contains(item.Id))
|
||||
.Select(item => new { item.Id, item.OwnerUserId, item.OriginalFileName, item.MimeType, item.ByteSize }).ToDictionaryAsync(item => item.Id, token);
|
||||
var jobs = await db.JobApplications.IgnoreQueryFilters().AsNoTracking().Where(item => jobIds.Contains(item.Id))
|
||||
.Select(item => new { item.Id, item.OwnerUserId, JobTitle = item.JobTitle.Length > 200 ? item.JobTitle.Substring(0, 200) + "…" : item.JobTitle,
|
||||
Company = item.Company.OwnerUserId == item.OwnerUserId ? (item.Company.Name.Length > 160 ? item.Company.Name.Substring(0, 160) + "…" : item.Company.Name) : null }).ToDictionaryAsync(item => item.Id, token);
|
||||
return operations.Select(operation =>
|
||||
{
|
||||
var subject = Subject(operation);
|
||||
var run = operation.TaskType == CvProcessingQueue.TaskType && subject is int cvId && runs.TryGetValue(cvId, out var foundRun) && foundRun.OwnerUserId == operation.OwnerUserId ? foundRun : null;
|
||||
var job = operation.TaskType == StrategySnapshotService.TaskType && subject is int jobId && jobs.TryGetValue(jobId, out var foundJob) && foundJob.OwnerUserId == operation.OwnerUserId ? new JobDto(foundJob.Id, foundJob.JobTitle, foundJob.Company) : null;
|
||||
var artifact = run?.ArtifactId is int artifactId && artifacts.TryGetValue(artifactId, out var foundArtifact) && foundArtifact.OwnerUserId == operation.OwnerUserId ? foundArtifact : null;
|
||||
var document = artifact is null ? null : new DocumentDto(FileName(artifact.OriginalFileName),
|
||||
artifact.MimeType is "application/pdf" or "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ? artifact.MimeType : null,
|
||||
Math.Max(0, artifact.ByteSize), Pages(run?.DocumentJson), run?.Status is "pending_review" or "applied" or "expired" or "failed" or "cancelled" or "running" or "queued" ? run.Status : null);
|
||||
var worker = SafeTask(operation.TaskType) == "other" ? "untracked" : run is { ParserVersion: ProfileCvController.DocumentParserVersion, Trigger: "document_import" } ? "document" : "ai";
|
||||
var duration = operation.StartedAtUtc is DateTime started ? Math.Max(0, ((operation.CompletedAtUtc ?? clock.GetUtcNow().UtcDateTime) - started).TotalSeconds) : 0;
|
||||
return new ItemDto(operation.Id, users.GetValueOrDefault(operation.OwnerUserId) ?? new(operation.OwnerUserId, null, null), SafeTask(operation.TaskType),
|
||||
OperationEventLog.SafeStatus(operation.Status), OperationEventLog.SafeStage(operation.ProgressStage), Math.Clamp(operation.AttemptCount, 0, 10),
|
||||
Math.Clamp(operation.MaxAttempts, 1, 10), Utc(operation.CreatedAtUtc), Utc(operation.StartedAtUtc), Utc(operation.CompletedAtUtc), Utc(operation.LastHeartbeatAtUtc),
|
||||
duration, worker, worker != "untracked" && (Enabled("AiOperations") || (worker == "document" && Enabled("CvDocumentImports"))), OperationEventLog.SafeFailure(operation.FailureCategory), job, document);
|
||||
}).ToArray();
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,9 @@ public sealed class AiPrivacyHeaderHandler(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
request.Headers.Remove("X-Operation-Id");
|
||||
if (executionScope.Current?.Lease.OperationId is Guid id && id != Guid.Empty)
|
||||
request.Headers.Add("X-Operation-Id", id.ToString("D"));
|
||||
if (request.RequestUri?.AbsolutePath.StartsWith("/cv/", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
var operationContext = executionScope.Current;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# D: administrator processing monitor acceptance
|
||||
|
||||
28 October 2026. Conditional GO for cumulative development and integration QA. Production remains NO-GO. Source follows history foundation `1f87301ee9a38889c012c4fbe1c53a5b80fb9f6a` on `feature/admin-processing-monitor`; the publication commit containing this report identifies the qualified implementation.
|
||||
|
||||
## Delivered behaviour
|
||||
|
||||
The Admin-only Processing page shows actual durable operations, status counts, bounded owner/status/task filters, verified job associations and bounded source-file metadata. Detail shows UTC timestamps, elapsed duration, attempts, sanitized failure codes, a copyable operation GUID and ordered retained events. Worker configuration is explicitly configuration, not a health probe. Workers without individual durable operations are identified; historical events are not fabricated.
|
||||
|
||||
The existing operation store owns transactional history. Successful state changes and their events commit together; failed event insertion rolls back the transition. Same-stage heartbeats do not create events. Owner filtering, stale-lease checks, CAS recovery and terminal notifications remain intact. Events expire after 30 days, are excluded immediately at read time, are removed in bounded existing retention sweeps and participate in account export/deletion. The foundation checkpoint contains migration/startup/rollback evidence.
|
||||
|
||||
Read-only APIs require the server Admin role. Ordinary users retain owner-only operation/source access. Projections omit document text, raw result/failure/subject/lease values, paths, prompts, credentials and model information. Joined job/company/source ownership is checked. Names are bounded and filenames strip path/control characters. Event cursors remain decimal strings, including values above JavaScript's safe integer range. Correlation headers accept only an actual execution GUID; the sidecar validates and echoes that GUID after authentication without logging document content.
|
||||
|
||||
Polling is sequential, aborts on navigation or identity changes, uses a 15-second request timeout and failure backoff, and supports pause/resume, refresh and bounded older-event pages. Forbidden responses clear cached data and stop automatic retries. Errors cannot display arbitrary server content. EN/Bokmål, light/dark, keyboard focus, responsive tables and accessible status messages use existing application owners.
|
||||
|
||||
## Independent evidence
|
||||
|
||||
All paths below are relative to `.artifacts/pre-phase10-ux/`. Focused reruns are not added to full-suite totals.
|
||||
|
||||
| Lane | Result | Evidence |
|
||||
| --- | --- | --- |
|
||||
| Full .NET backend, final source | 1,424 passed, zero failed/skipped | `d-backend-final/d-backend.trx` |
|
||||
| Full frontend | 1,027 passed across 112 suites | `d-frontend-full-01.json` |
|
||||
| Focused final API/security/metadata bounds | 21 passed | `d-api-final/d-api-final.trx` |
|
||||
| Real MariaDB, including final bounded SQL projections | 89 passed, disposable schema cleanup confirmed | `d-mariadb-04/` |
|
||||
| Admin browser authentication/isolation/history/errors/pause, responsive themes/languages and axe | 2 passed | `d-browser-02.json`, `d-browser-02-results/` |
|
||||
| Real Docling browser lifecycle plus PDF/DOCX review/apply/profile/editor/PDF | 3 passed | `d-docling-browser-01.json` |
|
||||
| Offline sidecar including correlation validation | 81 passed; model loading disabled | `d-sidecar-offline-01.log` |
|
||||
| Production frontend build, TypeScript | exit 0 | `d-build-03.log` |
|
||||
| Full frontend lint | exit 0 | `d-lint-final-02.log` |
|
||||
|
||||
The Docling browser run uses real extraction, deterministic interpretation and synthetic documents. Its separate parser container has an internal-only network, bounded resources, read-only source/root and model loading disabled. A bounded fixed-upstream gateway provides host-loopback access; the gateway's own bridge is not an egress firewall. No production model or service was changed. The browser run preceded the final metadata-label clipping change; final API and MariaDB tests cover that change, and cumulative browser qualification remains required.
|
||||
|
||||
Initial failing checks remain evidence: invalid correlation headers, missing event storage, stale tracked retry, oversized identity labels, duplicate browser alert/region selectors and migration-version expectations were reproduced and corrected without weakening persistence/isolation assertions.
|
||||
|
||||
## Decisions and remaining qualification
|
||||
|
||||
History/transaction safety, bounded read-only Admin API, monitor UI, ordinary-user isolation and real deterministic import lifecycle: PASS in the recorded automated scopes. D overall: CONDITIONAL GO for integration QA. Formal security review and the complete cumulative regression/preservation/PDF lanes remain pending at this checkpoint. This does not replace the final acceptance report.
|
||||
|
||||
Human accessibility, physical devices, language review and manual printing remain open. B's revised large-name PDF coordinate-oracle failures remain unresolved and must be carried forward explicitly. Local synthetic model smoke tests do not qualify extraction accuracy or deployed-production model parity. Existing low-severity development-tool dependency advisories remain separate from production dependency results. No main merge, production deployment or Phase 10 cleanup is authorized by this decision.
|
||||
@@ -32,7 +32,7 @@ No main merge, production service/database/storage/model/cache/deployment operat
|
||||
- [x] C-00: branch from accepted B; pinned2.3.0/MIT, installed modern API/React19 pointer proof, measured+23,636 aggregate gzip bytes; schema2 and legacy RED/GREEN migration tests. Combined with C-01 source checkpoint.
|
||||
- [x] C-01: actual widgets drag/resize, compact catalogue and keyboard controls, bounded non-overlapping geometry, no automatic writes, stale-client/CAS protection. Backend1405/full frontend1019 plus final focused33/MariaDB86/browser12; C_ACCEPTANCE.md records conditional development acceptance and independent evidence. Commit/push/remote equality form the publication step of this checkpoint.
|
||||
- [x] D-00: cumulative branch from C0bd290e, safe history/API/identity contract pushed5f26108. Missing history and stale tracked retry reproduced RED. Event foundation: backend1413/final focused53/MariaDB88 with atomic rollback/redaction/retention/account rights; D_HISTORY_CHECKPOINT.md. Admin API/UI remains in D-01.
|
||||
- [ ] D-01: atomic structured events/correlation/retention and account rights, safe admin list/stats/detail/events, polling filters/reconnect/pause, EN/Bokmål and responsive UI; security/provider/browser validation; commit/push/verify.
|
||||
- [x] D-01: atomic structured events/correlation/retention and account rights, safe admin list/stats/detail/events, polling filters/reconnect/pause, EN/Bokmål and responsive UI. Backend1424/frontend1027 in112 suites/MariaDB89/admin browser2/real Docling3/offline Python81; D_ACCEPTANCE.md records conditional integration entry and outstanding formal security/full regression. Commit/push/remote equality form the publication step.
|
||||
- [ ] UX-INT-00: branch `test/pre-phase10-ux-acceptance` from accepted D. Fresh full backend/frontend/ordinary and added browsers/real MariaDB/RD21/Python/Docling/import/contracts/security/type/lint/build/Windows/Linux PDF/preservation; exact source/discovery/native exits, retain failures and fix regressions.
|
||||
- [ ] UX-INT-01: independent feature/overall/Phase10/production verdicts, all57/33 preservation carry-forward, accepted branch commit register, human/operator debt and cleanup-only handoff. Commit/push/remote equality/own-work clean; preserve unrelated primary checkout work; stop.
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { expect, test, type APIRequestContext, type TestInfo } from '@playwright/test';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { realpathSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { translations } from '../src/i18n/translations';
|
||||
|
||||
const api = 'http://localhost:5302/api';
|
||||
const password = 'E2ePassword123!';
|
||||
async function login(client: APIRequestContext, email: string) {
|
||||
expect((await client.post(`${api}/auth/login`, { data: { email, password } })).status()).toBe(200);
|
||||
const me = await (await client.get(`${api}/auth/me`)).json();
|
||||
return { id: me.id as string, email, headers: { 'X-CSRF-TOKEN': (await client.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value, 'X-Session-Context': me.sessionContext as string } };
|
||||
}
|
||||
function seed(info: TestInfo, owner: Awaited<ReturnType<typeof login>>) {
|
||||
const root = info.config.metadata.isolatedDataRoot;
|
||||
if (typeof root !== 'string' || process.env.PLAYWRIGHT_REUSE_SERVERS === 'true') throw new Error('Explicit isolated non-reused test data required.');
|
||||
const dataRoot = realpathSync(root);
|
||||
expect(path.dirname(dataRoot)).toBe(realpathSync(os.tmpdir()));
|
||||
const result = spawnSync(process.env.PLAYWRIGHT_PYTHON_PATH || (process.platform === 'win32' ? 'python' : 'python3'), ['-X', 'utf8', path.resolve('e2e/helpers/seed-operation.py')],
|
||||
{ input: JSON.stringify({ dataRoot, ownerId: owner.id, ownerEmail: owner.email }), encoding: 'utf8', timeout: 15000, maxBuffer: 1024 * 1024 });
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
return (JSON.parse(result.stdout) as { operationId: string }).operationId;
|
||||
}
|
||||
|
||||
test('[D] Real administrator/owner boundaries, actual cancellation history and responsive EN/NB themes', async ({ page, playwright }, info) => {
|
||||
test.setTimeout(180000);
|
||||
const admin = await playwright.request.newContext();
|
||||
const ownerClient = await playwright.request.newContext();
|
||||
const otherClient = await playwright.request.newContext();
|
||||
const anonymous = await playwright.request.newContext();
|
||||
try {
|
||||
const administrator = await login(admin, 'e2e@example.test');
|
||||
const email = `processing-owner-${Date.now()}@example.test`;
|
||||
const otherEmail = `processing-other-${Date.now()}@example.test`;
|
||||
for (const userEmail of [email, otherEmail]) expect((await admin.post(`${api}/users`, { headers: administrator.headers, data: { email: userEmail, password, roles: [] } })).status()).toBe(200);
|
||||
const owner = await login(ownerClient, email);
|
||||
const other = await login(otherClient, otherEmail);
|
||||
const id = seed(info, owner);
|
||||
for (const endpoint of ['/admin/processing', `/admin/processing/${id}`, `/admin/processing/${id}/events`]) {
|
||||
expect((await anonymous.get(`${api}${endpoint}`)).status()).toBe(401);
|
||||
expect((await ownerClient.get(`${api}${endpoint}`)).status()).toBe(403);
|
||||
}
|
||||
expect((await otherClient.get(`${api}/operations/${id}`)).status()).toBe(404);
|
||||
expect((await otherClient.post(`${api}/operations/${id}/cancel`, { headers: other.headers })).status()).toBe(404);
|
||||
const pageAdmin = await login(page.request, 'e2e@example.test');
|
||||
await page.context().route('**/*', route => ['localhost', '127.0.0.1'].includes(new URL(route.request().url()).hostname) ? route.continue() : route.abort());
|
||||
await page.goto('/admin/processing');
|
||||
await page.getByLabel('Owner ID', { exact: true }).fill(owner.id);
|
||||
await page.getByRole('button', { name: 'Apply filters', exact: true }).click();
|
||||
await page.getByRole('button', { name: `View processing details ${id}`, exact: true }).click();
|
||||
await expect(page.getByLabel('Correlation ID', { exact: true })).toHaveValue(id);
|
||||
await expect(page.getByText('No retained events. Older jobs may predate event recording.', { exact: true })).toBeVisible();
|
||||
const cancellation = await ownerClient.post(`${api}/operations/${id}/cancel`, { headers: owner.headers });
|
||||
expect(cancellation.status()).toBe(200);
|
||||
expect((await cancellation.json()).status).toBe('cancelled');
|
||||
await page.getByRole('button', { name: 'Refresh', exact: true }).click();
|
||||
await expect(page.getByRole('list').filter({ has: page.getByText(/^Processing cancelled/) })).toBeVisible();
|
||||
const history = await (await admin.get(`${api}/admin/processing/${id}/events`)).json();
|
||||
expect(history.items.map((item: { kind: string }) => item.kind)).toEqual(['Cancelled']);
|
||||
expect(history.items[0].atUtc).toMatch(/Z$/);
|
||||
const detail = await (await admin.get(`${api}/admin/processing/${id}`)).json();
|
||||
expect(detail.owner.id).toBe(owner.id);
|
||||
for (const field of ['leaseToken', 'resultReference', 'failureMessage', 'subjectId', 'idempotencyKey']) expect(detail).not.toHaveProperty(field);
|
||||
expect(detail.worker).toBe('untracked');
|
||||
for (const language of ['en', 'nb'] as const) for (const mode of ['light', 'dark'] as const) {
|
||||
const t = translations[language];
|
||||
expect((await page.request.put(`${api}/auth/preferences/language`, { headers: pageAdmin.headers, data: { language: language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204);
|
||||
await page.evaluate(({ language, mode }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', mode); }, { language, mode });
|
||||
await page.reload();
|
||||
await page.getByLabel(t.processingOwnerFilter, { exact: true }).fill(owner.id);
|
||||
await page.getByRole('button', { name: t.processingApplyFilters, exact: true }).click();
|
||||
await page.getByRole('button', { name: t.processingViewDetails.replace('{id}', id), exact: true }).click();
|
||||
await expect(page.getByLabel(t.processingCorrelation, { exact: true })).toHaveValue(id);
|
||||
for (const width of [1440, 768, 390]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
|
||||
await page.screenshot({ path: info.outputPath(`processing-${language}-${mode}-${width}.png`), fullPage: true });
|
||||
}
|
||||
const axe = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
|
||||
await info.attach(`processing-${language}-${mode}-axe`, { body: JSON.stringify({ violations: axe.violations, incomplete: axe.incomplete }), contentType: 'application/json' });
|
||||
expect(axe.violations).toEqual([]);
|
||||
}
|
||||
await info.attach('fixture-evidence-limit', { body: 'Guarded insertion of one inert unregistered task; actual native cancel creates its only event. No historical Created event, model job, inference, human accessibility or physical-device acceptance is fabricated.', contentType: 'text/plain' });
|
||||
} finally { await Promise.all([admin.dispose(), ownerClient.dispose(), otherClient.dispose(), anonymous.dispose()]); }
|
||||
});
|
||||
|
||||
test('[D] Controlled unavailable/forbidden reads recover without exposing raw failures or cached metadata', async ({ page }) => {
|
||||
const administrator = await login(page.request, 'e2e@example.test');
|
||||
expect((await page.request.put(`${api}/auth/preferences/language`, { headers: administrator.headers, data: { language: 'en' } })).status()).toBe(204);
|
||||
await page.addInitScript(() => localStorage.setItem('uiLanguage', 'en'));
|
||||
await page.context().route('**/*', route => ['localhost', '127.0.0.1'].includes(new URL(route.request().url()).hostname) ? route.continue() : route.abort());
|
||||
let failure: number | null = 503;
|
||||
await page.route('**/api/admin/processing?**', route => failure ? route.fulfill({ status: failure, json: { message: 'SECRET_EXCEPTION_DOCUMENT_CONTENT' } }) : route.continue());
|
||||
await page.goto('/admin/processing');
|
||||
await expect(page.getByRole('alert').filter({ hasText: 'Processing information could not be loaded.' })).toBeVisible();
|
||||
await expect(page.getByText(/SECRET_EXCEPTION/)).toHaveCount(0);
|
||||
failure = null;
|
||||
await page.getByRole('button', { name: 'Retry', exact: true }).click();
|
||||
await expect(page.getByRole('region', { name: 'Processing jobs', exact: true })).toBeVisible();
|
||||
let calls = 0; page.on('request', request => { if (new URL(request.url()).pathname === '/api/admin/processing') calls++; });
|
||||
await page.getByRole('button', { name: 'Pause updates', exact: true }).click();
|
||||
const paused = calls;
|
||||
await page.waitForTimeout(5500); // Negative assertion across one real polling interval.
|
||||
expect(calls).toBe(paused);
|
||||
failure = 403;
|
||||
await page.getByRole('button', { name: 'Resume updates', exact: true }).click();
|
||||
await expect(page.getByRole('alert').filter({ hasText: 'Administrator access is required.' })).toBeVisible();
|
||||
await expect(page.getByRole('region', { name: 'Processing jobs', exact: true })).toHaveCount(0);
|
||||
await expect(page.getByText(/SECRET_EXCEPTION/)).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { expect, test, type APIRequestContext } from '@playwright/test';
|
||||
import path from 'node:path';
|
||||
|
||||
const api = 'http://localhost:5302/api';
|
||||
const password = 'E2ePassword123!';
|
||||
async function login(client: APIRequestContext, email: string) {
|
||||
expect((await client.post(`${api}/auth/login`, { data: { email, password } })).status()).toBe(200);
|
||||
const me = await (await client.get(`${api}/auth/me`)).json();
|
||||
return { id: me.id as string, headers: { 'X-CSRF-TOKEN': (await client.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value, 'X-Session-Context': me.sessionContext as string } };
|
||||
}
|
||||
|
||||
test('real Docling produces owner-scoped administrator history without exposing source text', async ({ page, playwright }, info) => {
|
||||
await page.context().route('**/*', route => ['localhost', '127.0.0.1'].includes(new URL(route.request().url()).hostname) ? route.continue() : route.abort());
|
||||
const admin = await playwright.request.newContext();
|
||||
try {
|
||||
const administrator = await login(admin, 'e2e@example.test');
|
||||
const email = `processing-docling-${Date.now()}@example.test`;
|
||||
expect((await admin.post(`${api}/users`, { headers: administrator.headers, data: { email, password, roles: ['Premium'] } })).status()).toBe(200);
|
||||
const owner = await login(page.request, email);
|
||||
await page.goto('/career?section=import');
|
||||
await page.locator('input[type=file][accept*=".pdf"]').setInputFiles(path.resolve(process.env.CV_IMPORT_FIXTURE_ROOT || '../.artifacts/cv-import/corpus-02', 'dev-en-simple.pdf'));
|
||||
await expect.poll(async () => (await (await page.request.get(`${api}/profile-cv/runs`)).json())[0]?.operation?.status, { timeout: 90000 }).toBe('succeeded');
|
||||
const run = (await (await page.request.get(`${api}/profile-cv/runs`)).json())[0];
|
||||
const id = run.operation.id;
|
||||
expect(run.status).toBe('pending_review');
|
||||
expect((await admin.get(`${api}/profile-cv/runs/${run.id}/source`)).status()).toBe(404);
|
||||
const history = await (await admin.get(`${api}/admin/processing/${id}/events`)).json();
|
||||
expect(history.items.map((item: { kind: string }) => item.kind)).toEqual(['Created', 'Claimed', ...Array(7).fill('StageChanged'), 'Completed']);
|
||||
expect(history.items.filter((item: { kind: string }) => item.kind === 'StageChanged').map((item: { stage: string }) => item.stage)).toEqual(['cv_validating_file', 'cv_extracting', 'cv_identifying_sections', 'cv_analysing', 'cv_validating_details', 'cv_preparing_review', 'cv_review_ready']);
|
||||
const detail = await (await admin.get(`${api}/admin/processing/${id}`)).json();
|
||||
expect(detail.owner.id).toBe(owner.id);
|
||||
expect(detail.document.name).toBe('dev-en-simple.pdf');
|
||||
expect(detail.document.pages).toBeGreaterThan(0);
|
||||
expect(detail.worker).toBe('document');
|
||||
expect(detail.workerEnabled).toBe(true);
|
||||
for (const field of ['rawExtractedText', 'documentJson', 'structuredProfileJson', 'leaseToken', 'failureMessage', 'resultReference']) expect(detail).not.toHaveProperty(field);
|
||||
await login(page.request, 'e2e@example.test');
|
||||
await page.goto('/admin/processing');
|
||||
await page.getByLabel('Owner ID', { exact: true }).fill(owner.id);
|
||||
await page.getByRole('button', { name: 'Apply filters', exact: true }).click();
|
||||
await page.getByRole('button', { name: `View processing details ${id}`, exact: true }).click();
|
||||
await expect(page.getByText(/^Processing completed/)).toBeVisible();
|
||||
await expect(page.getByLabel('Correlation ID', { exact: true })).toHaveValue(id);
|
||||
await page.screenshot({ path: info.outputPath('real-docling-processing-history.png'), fullPage: true });
|
||||
await info.attach('real-parser-evidence-limit', { body: 'Actual pinned Docling conversion and deterministic interpretation; no generative model inference or model accuracy acceptance. Administrator source-download access remains denied by the ordinary ownership API.', contentType: 'text/plain' });
|
||||
} finally { await admin.dispose(); }
|
||||
});
|
||||
@@ -16,6 +16,7 @@ for (const scenario of [
|
||||
{ language: 'nb', mode: 'dark', width: 390, file: 'dev-nb-education.docx' },
|
||||
] as const) {
|
||||
test(`real Docling ${scenario.language}/${scenario.mode}: upload, refresh, review, correct, durable profile, editor and PDF`, async ({ page, context, playwright }, info) => {
|
||||
await context.route('**/*', route => ['localhost', '127.0.0.1'].includes(new URL(route.request().url()).hostname) ? route.continue() : route.abort());
|
||||
const t = translations[scenario.language];
|
||||
const admin = await playwright.request.newContext();
|
||||
const adminHeaders = await login(admin, 'e2e@example.test');
|
||||
|
||||
@@ -6,8 +6,8 @@ const servers = Array.isArray(ordinary.webServer) ? ordinary.webServer : [ordina
|
||||
export default defineConfig({
|
||||
...ordinary,
|
||||
testDir: './import-tests',
|
||||
testMatch: 'cv-import-docling.spec.ts',
|
||||
testMatch: ['cv-import-docling.spec.ts', 'admin-processing-docling.spec.ts'],
|
||||
timeout: 180_000,
|
||||
retries: 0,
|
||||
webServer: [{ ...servers[0], env: { ...servers[0].env, Workers__AiOperationsEnabled: 'false', Workers__CvDocumentImportsEnabled: 'true', Ai__BaseUrl: process.env.CV_IMPORT_SIDECAR_URL, Ai__ProbeEnabled: 'false', Email__Enabled: 'false' } }, ...servers.slice(1)],
|
||||
webServer: [{ ...servers[0], env: { ...servers[0].env, Workers__AiOperationsEnabled: 'false', Workers__CvDocumentImportsEnabled: 'true', Ai__BaseUrl: process.env.CV_IMPORT_SIDECAR_URL, Ai__ServiceToken: process.env.CV_IMPORT_SIDECAR_TOKEN ?? '', Ai__ProbeEnabled: 'false', Email__Enabled: 'false' } }, ...servers.slice(1)],
|
||||
});
|
||||
|
||||
@@ -64,6 +64,7 @@ const ConnectedAccountsPage = lazy(() => import("./views/ConnectedAccountsPage")
|
||||
const AdminAuditPage = lazy(() => import("./views/AdminAuditPage"));
|
||||
const AdminUsersPage = lazy(() => import("./views/AdminUsersPage"));
|
||||
const AdminSystemPage = lazy(() => import("./views/AdminSystemPage"));
|
||||
const AdminProcessingPage = lazy(() => import("./views/AdminProcessingPage"));
|
||||
const CorrespondenceInboxPage = lazy(() => import("./views/CorrespondenceInboxPage"));
|
||||
const OperationsPage = lazy(() => import("./views/OperationsPage"));
|
||||
const OfferComparisonPage = lazy(() => import("./views/OfferComparisonPage"));
|
||||
@@ -126,6 +127,7 @@ function breadcrumbsFor(path: string, t: (k: any) => string): string[] {
|
||||
if (path.startsWith("/admin/audit")) return [t("home"), t("admin"), t("auditLog")];
|
||||
if (path.startsWith("/admin/users")) return [t("home"), t("admin"), t("users")];
|
||||
if (path.startsWith("/admin/system")) return [t("home"), t("admin"), t("system")];
|
||||
if (path.startsWith("/admin/processing")) return [t("home"), t("admin"), t("processingTitle")];
|
||||
return [t("home")];
|
||||
}
|
||||
|
||||
@@ -152,6 +154,7 @@ function titleFor(path: string, t: (k: any) => string): string {
|
||||
if (path.startsWith("/admin/audit")) return t("auditLog");
|
||||
if (path.startsWith("/admin/users")) return t("users");
|
||||
if (path.startsWith("/admin/system")) return t("systemStatus");
|
||||
if (path.startsWith("/admin/processing")) return t("processingTitle");
|
||||
return t("notFoundTitle");
|
||||
}
|
||||
|
||||
@@ -344,6 +347,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
{ to: "/admin/audit", label: t("auditLog"), icon: <ShieldIcon fontSize="small" />, hidden: !isAdmin, section: t("admin") },
|
||||
{ to: "/admin/users", label: t("users"), icon: <AccountCircleIcon fontSize="small" />, hidden: !isAdmin, section: t("admin") },
|
||||
{ to: "/admin/system", label: t("system"), icon: <MemoryIcon fontSize="small" />, hidden: !isAdmin, section: t("admin") },
|
||||
{ to: "/admin/processing", label: t("processingTitle"), icon: <MemoryIcon fontSize="small" />, hidden: !isAdmin, section: t("admin") },
|
||||
{ to: "/settings", label: t("settings"), icon: <SettingsIcon fontSize="small" />, section: t("account"), children: [
|
||||
{ to: "/profile", label: t("profile"), icon: <AccountCircleIcon fontSize="small" /> },
|
||||
{ to: "/settings/connected-accounts", label: t("connectedAccounts"), icon: <MailOutlineIcon fontSize="small" /> },
|
||||
@@ -439,6 +443,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
<Route path="/admin/audit" element={<AdminAuditPage />} />
|
||||
<Route path="/admin/users" element={<AdminUsersPage />} />
|
||||
<Route path="/admin/system" element={<AdminSystemPage />} />
|
||||
<Route path="/admin/processing" element={<AdminProcessingPage />} />
|
||||
<Route path="/trash" element={<JobTable refreshToken={refreshToken} pageSize={jobPageSize} onPageSizeChange={setAndPersistPageSize} columns={jobColumns} onColumnsChange={setAndPersistColumns} mode="trash" />} />
|
||||
<Route path="/settings" element={<SettingsView pageSize={jobPageSize} onPageSizeChange={setAndPersistPageSize} columns={jobColumns} onColumnsChange={setAndPersistColumns} themeMode={themeMode} onThemeModeChange={onThemeModeChange} />} />
|
||||
<Route path="/settings/connected-accounts" element={<ConnectedAccountsPage />} />
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import React from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import AdminProcessingPage from './views/AdminProcessingPage';
|
||||
import { I18nProvider } from './i18n/I18nProvider';
|
||||
import { api } from './api';
|
||||
import { setAuthUserKey } from './auth';
|
||||
|
||||
const get = api.get as jest.Mock;
|
||||
const item = { id: '11111111-1111-4111-8111-111111111111', owner: { id: 'owner', name: 'Synthetic owner', email: 'owner@example.test' }, taskType: 'cv.process', status: 'running', stage: 'cv_extracting', attemptCount: 1, maxAttempts: 3, createdAtUtc: '2026-10-28T12:00:00Z', startedAtUtc: '2026-10-28T12:00:00Z', completedAtUtc: null, lastHeartbeatAtUtc: null, durationSeconds: 8, worker: 'document', workerEnabled: true, failureCode: null, job: null, document: { name: 'CV.pdf', type: 'application/pdf', bytes: 500, pages: 2, state: 'running' } };
|
||||
const listing = { items: [item], total: 1, page: 1, pageSize: 25, counts: { running: 1 }, workers: [{ name: 'AiOperations', flagEnabled: false, individualOperationsRecorded: true }], cvInterpretation: 'deterministic' };
|
||||
function open() { return render(<MemoryRouter><I18nProvider><AdminProcessingPage /></I18nProvider></MemoryRouter>); }
|
||||
beforeEach(() => {
|
||||
localStorage.clear(); setAuthUserKey('admin', false);
|
||||
get.mockReset();
|
||||
get.mockImplementation((url: string) => Promise.resolve({ data: url.endsWith('/events') || url.includes('/events?')
|
||||
? { items: [{ id: '9007199254740993', kind: 'Claimed', status: 'running', stage: null, attempt: 1, failureCode: null, atUtc: '2026-10-28T12:00:00Z' }], hasMore: false, retentionDays: 30 }
|
||||
: url.includes(item.id) ? item : listing }));
|
||||
});
|
||||
afterEach(() => jest.useRealTimers());
|
||||
|
||||
test('shows safe job metadata and real events with a copyable correlation ID', async () => {
|
||||
open();
|
||||
expect(await screen.findByText('Synthetic owner')).toBeVisible();
|
||||
fireEvent.click(screen.getByRole('button', { name: /View processing details/ }));
|
||||
expect(await screen.findByLabelText('Correlation ID')).toHaveValue(item.id);
|
||||
expect(await screen.findByText(/^Worker claimed job/)).toBeVisible();
|
||||
expect(screen.getAllByText('CV.pdf').length).toBeGreaterThan(0);
|
||||
await waitFor(() => expect(screen.getByRole('heading', { name: 'Processing details' })).toHaveFocus());
|
||||
expect(screen.queryByText(/SECRET/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('pause stops polling and resume uses an exact string event cursor', async () => {
|
||||
jest.useFakeTimers(); open();
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
fireEvent.click(screen.getByRole('button', { name: /View processing details/ }));
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
await act(async () => { jest.advanceTimersByTime(5100); });
|
||||
expect(get.mock.calls.some(([url]) => url.includes('after=9007199254740993'))).toBe(true);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Pause updates' }));
|
||||
const count = get.mock.calls.length;
|
||||
await act(async () => { jest.advanceTimersByTime(15000); });
|
||||
expect(get).toHaveBeenCalledTimes(count);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Resume updates' }));
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
expect(get.mock.calls.length).toBeGreaterThan(count);
|
||||
});
|
||||
|
||||
test('forbidden responses clear metadata and do not retry automatically', async () => {
|
||||
jest.useFakeTimers(); get.mockRejectedValue({ response: { status: 403, data: 'SECRET server error' } });
|
||||
open(); await act(async () => { await Promise.resolve(); });
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Administrator access is required.');
|
||||
const count = get.mock.calls.length;
|
||||
await act(async () => { jest.advanceTimersByTime(30000); });
|
||||
expect(get).toHaveBeenCalledTimes(count);
|
||||
expect(screen.queryByText('Synthetic owner')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/SECRET/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('malformed successful responses produce a recoverable error instead of rendering raw data', async () => {
|
||||
get.mockResolvedValue({ data: 'SECRET malformed body' });
|
||||
open();
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Processing information could not be loaded.');
|
||||
expect(screen.queryByText(/SECRET/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('identity changes abort pending reads and clear the previous administrator view', async () => {
|
||||
let finish!: (value: unknown) => void;
|
||||
get.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
|
||||
open(); await waitFor(() => expect(get).toHaveBeenCalled());
|
||||
const signal = get.mock.calls[0][1].signal;
|
||||
get.mockRejectedValue({ response: { status: 403 } });
|
||||
act(() => setAuthUserKey('ordinary'));
|
||||
await act(async () => { finish({ data: listing }); });
|
||||
expect(signal.aborted).toBe(true);
|
||||
expect(screen.queryByText('Synthetic owner')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('a failed cycle aborts sibling reads and reconnects with bounded backoff', async () => {
|
||||
jest.useFakeTimers();
|
||||
get.mockRejectedValueOnce(new Error('SECRET transport failure'));
|
||||
open(); await act(async () => { await Promise.resolve(); });
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Processing information could not be loaded.');
|
||||
expect(get.mock.calls[0][1].signal.aborted).toBe(true);
|
||||
const count = get.mock.calls.length;
|
||||
await act(async () => { jest.advanceTimersByTime(14999); });
|
||||
expect(get).toHaveBeenCalledTimes(count);
|
||||
await act(async () => { jest.advanceTimersByTime(1); });
|
||||
expect(screen.getByText('Synthetic owner')).toBeVisible();
|
||||
expect(screen.queryByText(/SECRET/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('older event pages pause updates and retain exact cursors until returning to latest', async () => {
|
||||
jest.useFakeTimers();
|
||||
get.mockImplementation((url: string) => Promise.resolve({ data: url.includes('/events')
|
||||
? { items: [{ id: url.includes('before=') ? '9007199254740992' : '9007199254740993', kind: url.includes('before=') ? 'Created' : 'Claimed', status: 'running', stage: null, attempt: 1, failureCode: null, atUtc: '2026-10-28T12:00:00Z' }], hasMore: true, retentionDays: 30 }
|
||||
: url.includes(item.id) ? item : listing }));
|
||||
open(); await act(async () => { await Promise.resolve(); });
|
||||
fireEvent.click(screen.getByRole('button', { name: /View processing details/ }));
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Older events' }));
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
expect(get.mock.calls.some(([url]) => url.includes('before=9007199254740993'))).toBe(true);
|
||||
expect(screen.getByText(/^Job created/)).toBeVisible();
|
||||
const count = get.mock.calls.length;
|
||||
await act(async () => { jest.advanceTimersByTime(30000); });
|
||||
expect(get).toHaveBeenCalledTimes(count);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Latest events' }));
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
expect(screen.getByText(/^Worker claimed job/)).toBeVisible();
|
||||
});
|
||||
@@ -2,6 +2,82 @@ export type UiLanguage = "en" | "nb";
|
||||
|
||||
export const translations = {
|
||||
en: {
|
||||
processingTitle: "Processing monitor",
|
||||
processingSubtitle: "Document and AI operations, their owners and safe processing history.",
|
||||
processingDetails: "Processing details",
|
||||
processingJobs: "Processing jobs",
|
||||
processingEvents: "Processing events",
|
||||
processingViewDetails: "View processing details {id}",
|
||||
processingPause: "Pause updates",
|
||||
processingResume: "Resume updates",
|
||||
processingConnected: "Automatic updates enabled",
|
||||
processingPaused: "Updates paused",
|
||||
processingReconnecting: "Reconnecting",
|
||||
processingDenied: "Administrator access is required.",
|
||||
processingLoadFailed: "Processing information could not be loaded. The last displayed information may be out of date. Retry or wait for reconnection.",
|
||||
processingNoJobs: "No processing jobs match these filters.",
|
||||
processingNoEvents: "No retained events. Older jobs may predate event recording.",
|
||||
processingPrevious: "Previous page",
|
||||
processingNext: "Next page",
|
||||
processingPage: "Page {page} of {total}",
|
||||
processingOwner: "Owner",
|
||||
processingStatus: "Status",
|
||||
processingTask: "Task",
|
||||
processingAll: "All",
|
||||
processingApplyFilters: "Apply filters",
|
||||
processingOwnerFilter: "Owner ID",
|
||||
processingWorker: "Worker",
|
||||
processingWorkerDocument: "Document processing",
|
||||
processingWorkerAi: "AI operations",
|
||||
processingWorkerUntracked: "Unregistered worker",
|
||||
processingWorkerEnrichment: "Job enrichment",
|
||||
processingWorkerRules: "Rules",
|
||||
processingWorkerReminders: "Follow-up reminders",
|
||||
processingWorkerExport: "Daily exports",
|
||||
processingWorkerSearch: "Saved search alerts",
|
||||
processingEnabled: "Enabled",
|
||||
processingDisabled: "Disabled",
|
||||
processingWorkerFlags: "Configured worker switches",
|
||||
processingWorkerFlagsHelp: "Additional feature settings may also be required. Only document and AI operations publish individual job history here; enabled does not confirm worker health.",
|
||||
processingAiDescription: "CV imports use deterministic interpretation. AI worker availability is shown above; model health and extraction quality are not checked by this view.",
|
||||
processingCorrelation: "Correlation ID",
|
||||
processingCopy: "Copy ID",
|
||||
processingCopied: "ID copied",
|
||||
processingCopyFailed: "Clipboard unavailable. Select and copy the ID field.",
|
||||
processingCreated: "Created",
|
||||
processingStarted: "Started",
|
||||
processingCompleted: "Completed",
|
||||
processingLastSeen: "Last heartbeat",
|
||||
processingDuration: "{count} seconds",
|
||||
processingAttempts: "Attempt {count} of {max}",
|
||||
processingNotAvailable: "Not recorded",
|
||||
processingSource: "Source document",
|
||||
processingBytes: "{count} bytes",
|
||||
processingPages: "{count} pages",
|
||||
processingSourceApplied: "Approved changes applied to Career Profile",
|
||||
processingSourceExpired: "Source document expired",
|
||||
processingOlder: "Older events",
|
||||
processingLatest: "Latest events",
|
||||
processingRetention: "This view keeps up to 200 events. History is retained for 30 days. Loading an older page pauses automatic updates.",
|
||||
processingUnknown: "Unknown",
|
||||
processingOther: "Other operation",
|
||||
processingEventCreated: "Job created",
|
||||
processingEventClaimed: "Worker claimed job",
|
||||
processingEventStageChanged: "Processing stage changed",
|
||||
processingEventCompleted: "Processing completed",
|
||||
processingEventRetryScheduled: "Retry scheduled",
|
||||
processingEventFailed: "Processing failed",
|
||||
processingEventCancellationRequested: "Cancellation requested",
|
||||
processingEventCancelled: "Processing cancelled",
|
||||
processingEventRetried: "Job retried",
|
||||
processingEventLeaseRecovered: "Stopped worker recovered",
|
||||
processingEventExpired: "Worker lease or deadline expired",
|
||||
processingFailureGeneric: "Processing failed. Check service configuration and availability.",
|
||||
processingFailureTimeout: "Processing timed out.",
|
||||
processingFailureLease: "The worker stopped responding before processing completed.",
|
||||
processingFailureDeadline: "The processing deadline elapsed.",
|
||||
processingFailureAccess: "AI access changed before processing began.",
|
||||
processingLastUpdated: "Updated {time}",
|
||||
cvImportReviewHelp: "Review every field. Nothing changes in your Career Profile until you apply your choices.",
|
||||
cvImportPanels: "Import source and review",
|
||||
cvImportSourcePanel: "Source document",
|
||||
@@ -3032,6 +3108,82 @@ export const translations = {
|
||||
coverAiReject: "Reject",
|
||||
},
|
||||
nb: {
|
||||
processingTitle: "Behandlingsovervåking",
|
||||
processingSubtitle: "Dokument- og KI-oppgaver, eiere og trygg behandlingshistorikk.",
|
||||
processingDetails: "Behandlingsdetaljer",
|
||||
processingJobs: "Behandlingsoppgaver",
|
||||
processingEvents: "Behandlingshendelser",
|
||||
processingViewDetails: "Vis behandlingsdetaljer {id}",
|
||||
processingPause: "Sett oppdateringer på pause",
|
||||
processingResume: "Fortsett oppdateringer",
|
||||
processingConnected: "Automatiske oppdateringer er aktivert",
|
||||
processingPaused: "Oppdateringer er satt på pause",
|
||||
processingReconnecting: "Kobler til på nytt",
|
||||
processingDenied: "Administratortilgang kreves.",
|
||||
processingLoadFailed: "Behandlingsinformasjonen kunne ikke lastes. Informasjonen som vises, kan være utdatert. Prøv igjen eller vent på ny tilkobling.",
|
||||
processingNoJobs: "Ingen behandlingsoppgaver samsvarer med filtrene.",
|
||||
processingNoEvents: "Ingen lagrede hendelser. Eldre oppgaver kan være fra før hendelsesregistrering ble innført.",
|
||||
processingPrevious: "Forrige side",
|
||||
processingNext: "Neste side",
|
||||
processingPage: "Side {page} av {total}",
|
||||
processingOwner: "Eier",
|
||||
processingStatus: "Status",
|
||||
processingTask: "Oppgave",
|
||||
processingAll: "Alle",
|
||||
processingApplyFilters: "Bruk filtre",
|
||||
processingOwnerFilter: "Eier-ID",
|
||||
processingWorker: "Bakgrunnsprosess",
|
||||
processingWorkerDocument: "Dokumentbehandling",
|
||||
processingWorkerAi: "KI-oppgaver",
|
||||
processingWorkerUntracked: "Uregistrert bakgrunnsprosess",
|
||||
processingWorkerEnrichment: "Jobbberikelse",
|
||||
processingWorkerRules: "Regler",
|
||||
processingWorkerReminders: "Oppfølgingspåminnelser",
|
||||
processingWorkerExport: "Daglige eksporter",
|
||||
processingWorkerSearch: "Varsler for lagrede søk",
|
||||
processingEnabled: "Aktivert",
|
||||
processingDisabled: "Deaktivert",
|
||||
processingWorkerFlags: "Konfigurerte brytere for bakgrunnsprosesser",
|
||||
processingWorkerFlagsHelp: "Andre funksjonsinnstillinger kan også være nødvendige. Bare dokument- og KI-oppgaver registrerer individuell oppgavehistorikk her. Aktivert bekrefter ikke at prosessen fungerer.",
|
||||
processingAiDescription: "CV-import bruker deterministisk tolkning. Tilgjengeligheten til KI-prosessen vises ovenfor. Denne visningen kontrollerer ikke modelltilstand eller uttrekkskvalitet.",
|
||||
processingCorrelation: "Korrelasjons-ID",
|
||||
processingCopy: "Kopier ID",
|
||||
processingCopied: "ID kopiert",
|
||||
processingCopyFailed: "Utklippstavlen er utilgjengelig. Merk og kopier ID-feltet.",
|
||||
processingCreated: "Opprettet",
|
||||
processingStarted: "Startet",
|
||||
processingCompleted: "Fullført",
|
||||
processingLastSeen: "Siste livstegn",
|
||||
processingDuration: "{count} sekunder",
|
||||
processingAttempts: "Forsøk {count} av {max}",
|
||||
processingNotAvailable: "Ikke registrert",
|
||||
processingSource: "Kildedokument",
|
||||
processingBytes: "{count} byte",
|
||||
processingPages: "{count} sider",
|
||||
processingSourceApplied: "Godkjente endringer er lagt til i karriereprofilen",
|
||||
processingSourceExpired: "Kildedokumentet er utløpt",
|
||||
processingOlder: "Eldre hendelser",
|
||||
processingLatest: "Nyeste hendelser",
|
||||
processingRetention: "Visningen beholder opptil 200 hendelser. Historikk lagres i 30 dager. Lasting av en eldre side setter automatiske oppdateringer på pause.",
|
||||
processingUnknown: "Ukjent",
|
||||
processingOther: "Annen oppgave",
|
||||
processingEventCreated: "Oppgave opprettet",
|
||||
processingEventClaimed: "Bakgrunnsprosessen hentet oppgaven",
|
||||
processingEventStageChanged: "Behandlingstrinn endret",
|
||||
processingEventCompleted: "Behandling fullført",
|
||||
processingEventRetryScheduled: "Nytt forsøk planlagt",
|
||||
processingEventFailed: "Behandling mislyktes",
|
||||
processingEventCancellationRequested: "Avbrudd forespurt",
|
||||
processingEventCancelled: "Behandling avbrutt",
|
||||
processingEventRetried: "Oppgave startet på nytt",
|
||||
processingEventLeaseRecovered: "Stanset bakgrunnsprosess gjenopprettet",
|
||||
processingEventExpired: "Arbeidsleie eller tidsfrist utløpt",
|
||||
processingFailureGeneric: "Behandlingen mislyktes. Kontroller tjenestekonfigurasjon og tilgjengelighet.",
|
||||
processingFailureTimeout: "Behandlingen tok for lang tid.",
|
||||
processingFailureLease: "Bakgrunnsprosessen sluttet å svare før behandlingen var fullført.",
|
||||
processingFailureDeadline: "Tidsfristen for behandlingen er utløpt.",
|
||||
processingFailureAccess: "KI-tilgangen ble endret før behandlingen startet.",
|
||||
processingLastUpdated: "Oppdatert {time}",
|
||||
cvImportReviewHelp: "Gå gjennom hvert felt. Karriereprofilen endres først når du bruker valgene dine.",
|
||||
cvImportPanels: "Kilde og gjennomgang av import",
|
||||
cvImportSourcePanel: "Kildedokument",
|
||||
|
||||
@@ -32,7 +32,7 @@ export function PageHeader({ title, description, actions, level = "h1", titleId,
|
||||
}
|
||||
|
||||
// Presentation only: domain components keep their loading, draft and mutation owners.
|
||||
export function PageSection({ title, description, actions, children, level = "h2", surface = false }: Omit<HeaderProps, "titleId" | "level" | "titleRef"> & {
|
||||
export function PageSection({ title, titleRef, description, actions, children, level = "h2", surface = false }: Omit<HeaderProps, "titleId" | "level"> & {
|
||||
children?: React.ReactNode;
|
||||
level?: "h2" | "h3";
|
||||
surface?: boolean;
|
||||
@@ -43,7 +43,7 @@ export function PageSection({ title, description, actions, children, level = "h2
|
||||
display: "grid", gap: 2, minWidth: 0, alignContent: "start",
|
||||
...(surface ? { p: "var(--app-card-padding)", bgcolor: "background.paper", border: "1px solid", borderColor: "divider", borderRadius: 1 } : {}),
|
||||
}}>
|
||||
<PageHeader title={title} titleId={titleId} description={description} actions={actions} level={level} />
|
||||
<PageHeader title={title} titleRef={titleRef} titleId={titleId} description={description} actions={actions} level={level} />
|
||||
{children ? <Box sx={{ minWidth: 0 }}>{children}</Box> : null}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Alert, Box, Button, Chip, Stack, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TextField, Typography } from '@mui/material';
|
||||
import { api } from '../api';
|
||||
import { AUTH_USER_CHANGED_EVENT, getAuthUserKey } from '../auth';
|
||||
import { isSessionInvalidated, SESSION_INVALIDATED_EVENT } from '../sessionBoundary';
|
||||
import { useI18n } from '../i18n/I18nProvider';
|
||||
import type { TranslationKey } from '../i18n/translations';
|
||||
import { PageHeader, PageSection } from '../layout/PageLayout';
|
||||
|
||||
type Item = {
|
||||
id: string; owner: { id: string; name: string | null; email: string | null }; taskType: string; status: string; stage: string | null;
|
||||
attemptCount: number; maxAttempts: number; createdAtUtc: string; startedAtUtc: string | null; completedAtUtc: string | null;
|
||||
lastHeartbeatAtUtc: string | null; durationSeconds: number; worker: string; workerEnabled: boolean; failureCode: string | null;
|
||||
job: { id: number; title: string; company: string | null } | null;
|
||||
document: { name: string; type: string | null; bytes: number; pages: number | null; state: string | null } | null;
|
||||
};
|
||||
type Listing = { items: Item[]; total: number; counts: Record<string, number>; workers: { name: string; flagEnabled: boolean; individualOperationsRecorded: boolean }[] };
|
||||
type Event = { id: string; atUtc: string; kind: string; status: string; stage: string | null; attempt: number; failureCode: string | null };
|
||||
type Events = { items: Event[]; hasMore: boolean; retentionDays: number };
|
||||
const statuses: Record<string, TranslationKey> = { queued: 'operationsStatusQueued', running: 'operationsStatusRunning', waiting_for_retry: 'operationsStatusWaitingForRetry', waiting_for_external_fallback: 'operationsStatusWaitingForFallback', succeeded: 'operationsStatusSucceeded', failed: 'operationsStatusFailed', cancelled: 'operationsStatusCancelled' };
|
||||
const tasks: Record<string, TranslationKey> = { 'cv.process': 'operationsTaskCv', 'strategy.snapshot': 'operationsTaskStrategy' };
|
||||
const stages: Record<string, TranslationKey> = { cv_validating_file: 'cvImportStageValidating', cv_extracting: 'cvImportStageExtracting', cv_identifying_sections: 'cvImportStageSections', cv_analysing: 'cvImportStageAnalysing', cv_validating_details: 'cvImportStageDetails', cv_preparing_review: 'cvImportStagePreparing', cv_review_ready: 'cvImportStageReady' };
|
||||
const kinds: Record<string, TranslationKey> = { Created: 'processingEventCreated', Claimed: 'processingEventClaimed', StageChanged: 'processingEventStageChanged', Completed: 'processingEventCompleted', RetryScheduled: 'processingEventRetryScheduled', Failed: 'processingEventFailed', CancellationRequested: 'processingEventCancellationRequested', Cancelled: 'processingEventCancelled', Retried: 'processingEventRetried', LeaseRecovered: 'processingEventLeaseRecovered', Expired: 'processingEventExpired' };
|
||||
const workers: Record<string, TranslationKey> = { AiOperations: 'processingWorkerAi', CvDocumentImports: 'processingWorkerDocument', JobEnrichment: 'processingWorkerEnrichment', Rules: 'processingWorkerRules', FollowUpReminders: 'processingWorkerReminders', DailyExport: 'processingWorkerExport', SavedJobSearchAlerts: 'processingWorkerSearch' };
|
||||
|
||||
export default function AdminProcessingPage() {
|
||||
const { t, language } = useI18n();
|
||||
const [listing, setListing] = useState<Listing | null>(null);
|
||||
const [selected, select] = useState<string | null>(null);
|
||||
const [detail, setDetail] = useState<Item | null>(null);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [hasOlder, setHasOlder] = useState(false);
|
||||
const [before, setBefore] = useState<string | null>(null);
|
||||
const [filters, setFilters] = useState({ status: '', taskType: '', owner: '' });
|
||||
const [inputs, setInputs] = useState(filters);
|
||||
const [page, setPage] = useState(1);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const pausedRef = useRef(false);
|
||||
const [refresh, setRefresh] = useState(0);
|
||||
const [identity, setIdentity] = useState(getAuthUserKey);
|
||||
const [busy, setBusy] = useState(true);
|
||||
const [error, setError] = useState<'denied' | 'unavailable' | null>(null);
|
||||
const [updated, setUpdated] = useState<string | null>(null);
|
||||
const [copy, setCopy] = useState<'copied' | 'failed' | null>(null);
|
||||
const detailHeading = useRef<HTMLHeadingElement>(null);
|
||||
const focusDetail = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const changed = () => {
|
||||
setListing(null); setDetail(null); setEvents([]); select(null); setBefore(null);
|
||||
setIdentity(getAuthUserKey()); setRefresh(value => value + 1);
|
||||
};
|
||||
window.addEventListener(AUTH_USER_CHANGED_EVENT, changed);
|
||||
window.addEventListener(SESSION_INVALIDATED_EVENT, changed);
|
||||
return () => { window.removeEventListener(AUTH_USER_CHANGED_EVENT, changed); window.removeEventListener(SESSION_INVALIDATED_EVENT, changed); };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSessionInvalidated()) return;
|
||||
let controller = new AbortController();
|
||||
let stopped = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let cursor: string | undefined;
|
||||
let eventWindow: Event[] = [];
|
||||
const current = () => !stopped && identity === getAuthUserKey() && !isSessionInvalidated();
|
||||
const load = async () => {
|
||||
if (!current()) return;
|
||||
const request = new AbortController();
|
||||
controller = request;
|
||||
const options = { signal: request.signal, timeout: 15000 };
|
||||
setBusy(true);
|
||||
let retry = true;
|
||||
let delay = 5000;
|
||||
try {
|
||||
const query = new URLSearchParams({ page: String(page), pageSize: '25' });
|
||||
for (const [key, value] of Object.entries(filters)) if (value) query.set(key, value);
|
||||
const eventQuery = new URLSearchParams({ take: '100' });
|
||||
if (before) eventQuery.set('before', before);
|
||||
else if (cursor) eventQuery.set('after', cursor);
|
||||
const [listResponse, detailResponse, eventResponse] = await Promise.all([
|
||||
api.get<Listing>(`/admin/processing?${query}`, options),
|
||||
selected ? api.get<Item>(`/admin/processing/${selected}`, options) : Promise.resolve(null),
|
||||
selected ? api.get<Events>(`/admin/processing/${selected}/events?${eventQuery}`, options) : Promise.resolve(null),
|
||||
]);
|
||||
if (!current() || request.signal.aborted) return;
|
||||
const list = listResponse.data;
|
||||
if (!list || !Array.isArray(list.items) || list.items.length > 100 || !Array.isArray(list.workers) || list.workers.length > 32
|
||||
|| !list.counts || typeof list.counts !== 'object' || !Number.isSafeInteger(list.total) || list.total < 0
|
||||
|| (detailResponse && (detailResponse.data?.id !== selected || typeof detailResponse.data?.owner?.id !== 'string'))
|
||||
|| (eventResponse && (!Array.isArray(eventResponse.data?.items) || eventResponse.data.items.length > 100))) throw new Error('Invalid processing response');
|
||||
setListing(listResponse.data); setDetail(detailResponse?.data ?? null);
|
||||
if (eventResponse) {
|
||||
const next = eventResponse.data.items;
|
||||
const append = Boolean(cursor) && !before;
|
||||
const combined = append ? [...new Map([...eventWindow, ...next].map(item => [item.id, item])).values()] : next;
|
||||
eventWindow = combined.slice(-200);
|
||||
setEvents(eventWindow);
|
||||
if (!append) setHasOlder(eventResponse.data.hasMore);
|
||||
else if (combined.length > 200) setHasOlder(true);
|
||||
if (next.length) cursor = next[next.length - 1].id;
|
||||
} else setEvents([]);
|
||||
setUpdated(new Date().toISOString()); setError(null);
|
||||
if (focusDetail.current && detailResponse) {
|
||||
focusDetail.current = false;
|
||||
requestAnimationFrame(() => { if (current()) detailHeading.current?.focus(); });
|
||||
}
|
||||
} catch (cause) {
|
||||
if (!current() || request.signal.aborted) return;
|
||||
request.abort();
|
||||
const status = (cause as { response?: { status?: number } }).response?.status;
|
||||
if (status === 401 || status === 403) {
|
||||
setListing(null); setDetail(null); setEvents([]); setError('denied'); retry = false;
|
||||
} else { setError('unavailable'); delay = 15000; }
|
||||
} finally {
|
||||
if (current()) {
|
||||
setBusy(false);
|
||||
if (retry && !pausedRef.current && !before) timer = setTimeout(() => { if (!pausedRef.current) void load(); }, delay);
|
||||
}
|
||||
}
|
||||
};
|
||||
void load();
|
||||
return () => { stopped = true; controller.abort(); clearTimeout(timer); };
|
||||
}, [before, filters, identity, page, refresh, selected]);
|
||||
|
||||
const date = (value: string | null) => value ? new Date(value).toLocaleString(language === 'nb' ? 'nb-NO' : 'en-GB') : t('processingNotAvailable');
|
||||
const state = (value: string) => t(statuses[value] ?? 'processingUnknown');
|
||||
const stage = (value: string | null) => value && stages[value] ? t(stages[value]) : t('processingNotAvailable');
|
||||
const failure = (value: string) => t(value === 'timeout' ? 'processingFailureTimeout' : value === 'lease_expired' ? 'processingFailureLease' : value === 'deadline_exceeded' ? 'processingFailureDeadline' : value === 'entitlement_changed' ? 'processingFailureAccess' : 'processingFailureGeneric');
|
||||
const worker = (value: string) => t(value === 'document' ? 'processingWorkerDocument' : value === 'ai' ? 'processingWorkerAi' : 'processingWorkerUntracked');
|
||||
const resetSelection = () => { select(null); setDetail(null); setEvents([]); setBefore(null); setCopy(null); };
|
||||
const resume = () => { pausedRef.current = false; setPaused(false); setBefore(null); setRefresh(value => value + 1); };
|
||||
const visibleListing = identity === getAuthUserKey() ? listing : null;
|
||||
return <Box sx={{ display: 'grid', gap: 3, minWidth: 0, '& p, & td, & th': { overflowWrap: 'anywhere' }, '& button': { minHeight: 44 } }}>
|
||||
<PageHeader level="h2" title={t('processingTitle')} description={t('processingSubtitle')} />
|
||||
<Stack direction="row" useFlexGap flexWrap="wrap" spacing={1} alignItems="center">
|
||||
<Button onClick={() => { if (paused) resume(); else { pausedRef.current = true; setPaused(true); } }}>{t(paused ? 'processingResume' : 'processingPause')}</Button>
|
||||
<Button onClick={() => setRefresh(value => value + 1)} disabled={busy}>{t('refresh')}</Button>
|
||||
<Typography role="status" aria-live="polite">{t(error === 'denied' ? 'processingDenied' : paused ? 'processingPaused' : error ? 'processingReconnecting' : 'processingConnected')}</Typography>
|
||||
{updated ? <Typography variant="caption">{t('processingLastUpdated', { time: date(updated) })}</Typography> : null}
|
||||
</Stack>
|
||||
{error ? <Alert severity="error" action={error === 'unavailable' ? <Button onClick={() => setRefresh(value => value + 1)}>{t('retry')}</Button> : undefined}>{t(error === 'denied' ? 'processingDenied' : 'processingLoadFailed')}</Alert> : null}
|
||||
{busy && !visibleListing ? <Typography role="status">{t('loading')}</Typography> : null}
|
||||
<Box component="form" onSubmit={event => { event.preventDefault(); resetSelection(); setPage(1); setFilters({ ...inputs, owner: inputs.owner.trim() }); }}>
|
||||
<Stack direction="row" useFlexGap flexWrap="wrap" spacing={2}>
|
||||
<TextField select label={t('processingStatus')} value={inputs.status} onChange={event => setInputs(value => ({ ...value, status: event.target.value }))} slotProps={{ select: { native: true } }}>
|
||||
<option value="">{t('processingAll')}</option>{Object.keys(statuses).map(value => <option key={value} value={value}>{state(value)}</option>)}
|
||||
</TextField>
|
||||
<TextField select label={t('processingTask')} value={inputs.taskType} onChange={event => setInputs(value => ({ ...value, taskType: event.target.value }))} slotProps={{ select: { native: true } }}>
|
||||
<option value="">{t('processingAll')}</option>{Object.keys(tasks).map(value => <option key={value} value={value}>{t(tasks[value])}</option>)}<option value="other">{t('processingOther')}</option>
|
||||
</TextField>
|
||||
<TextField label={t('processingOwnerFilter')} value={inputs.owner} onChange={event => setInputs(value => ({ ...value, owner: event.target.value }))} slotProps={{ htmlInput: { maxLength: 255 } }} sx={{ flex: '1 1 14rem', minWidth: 0 }} />
|
||||
<Button type="submit" variant="outlined">{t('processingApplyFilters')}</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
{visibleListing ? <>
|
||||
<Stack direction="row" useFlexGap flexWrap="wrap" spacing={1}>{Object.entries(visibleListing.counts).map(([key, count]) => <Chip key={key} label={`${state(key)}: ${count}`} />)}</Stack>
|
||||
<PageSection title={t('processingJobs')} surface>
|
||||
{!visibleListing.items.length ? <Typography>{t('processingNoJobs')}</Typography> : <TableContainer tabIndex={0} role="group" aria-label={t('processingJobs')} sx={{ overflowX: 'auto', maxWidth: '100%' }}>
|
||||
<Table aria-label={t('processingJobs')} sx={{ minWidth: 720 }}><TableHead><TableRow>{['processingTask', 'processingOwner', 'processingStatus', 'processingWorker', 'processingCreated'].map(key => <TableCell key={key}>{t(key as TranslationKey)}</TableCell>)}</TableRow></TableHead>
|
||||
<TableBody>{visibleListing.items.map(item => <TableRow key={item.id} selected={selected === item.id}>
|
||||
<TableCell component="th" scope="row"><Button aria-label={t('processingViewDetails', { id: item.id })} onClick={() => { setDetail(null); setEvents([]); setBefore(null); setCopy(null); focusDetail.current = true; select(item.id); }}>{t(tasks[item.taskType] ?? 'processingOther')}</Button><Typography variant="caption" display="block">{item.job?.title ?? item.document?.name ?? item.id.slice(0, 8)}</Typography></TableCell>
|
||||
<TableCell><Button onClick={() => { const next = { ...filters, owner: item.owner.id }; setInputs(next); setFilters(next); setPage(1); resetSelection(); }}>{item.owner.name ?? item.owner.email ?? item.owner.id}</Button><Typography variant="caption" display="block">{item.owner.email}</Typography></TableCell>
|
||||
<TableCell>{state(item.status)}<Typography variant="caption" display="block">{stage(item.stage)}</Typography></TableCell>
|
||||
<TableCell>{worker(item.worker)}<Typography variant="caption" display="block">{t(item.workerEnabled ? 'processingEnabled' : 'processingDisabled')}</Typography></TableCell>
|
||||
<TableCell><time dateTime={item.createdAtUtc}>{date(item.createdAtUtc)}</time></TableCell>
|
||||
</TableRow>)}</TableBody></Table>
|
||||
</TableContainer>}
|
||||
<Stack direction="row" spacing={1} useFlexGap flexWrap="wrap" alignItems="center" sx={{ mt: 2 }}>
|
||||
<Button disabled={page <= 1 || busy} onClick={() => { resetSelection(); setPage(value => value - 1); }}>{t('processingPrevious')}</Button>
|
||||
<Typography>{t('processingPage', { page, total: Math.max(1, Math.ceil(visibleListing.total / 25)) })}</Typography>
|
||||
<Button disabled={page * 25 >= visibleListing.total || busy} onClick={() => { resetSelection(); setPage(value => value + 1); }}>{t('processingNext')}</Button>
|
||||
</Stack>
|
||||
</PageSection>
|
||||
<Box component="details"><Box component="summary" sx={{ p: 1, cursor: 'pointer' }}>{t('processingWorkerFlags')}</Box>
|
||||
<Typography variant="body2">{t('processingWorkerFlagsHelp')}</Typography><Typography variant="body2">{t('processingAiDescription')}</Typography>
|
||||
<Stack direction="row" useFlexGap flexWrap="wrap" spacing={1} sx={{ mt: 1 }}>{visibleListing.workers.map(item => <Chip key={item.name} label={`${t(workers[item.name] ?? 'processingWorkerUntracked')}: ${t(item.flagEnabled ? 'processingEnabled' : 'processingDisabled')}`} />)}</Stack>
|
||||
</Box>
|
||||
</> : null}
|
||||
{detail && visibleListing ? <PageSection surface title={t('processingDetails')} titleRef={detailHeading}>
|
||||
<Stack spacing={2}>
|
||||
<Stack direction="row" useFlexGap flexWrap="wrap" spacing={1} alignItems="center">
|
||||
<TextField label={t('processingCorrelation')} value={detail.id} slotProps={{ input: { readOnly: true } }} sx={{ flex: '1 1 20rem', minWidth: 0 }} />
|
||||
<Button onClick={async () => { try { await navigator.clipboard.writeText(detail.id); setCopy('copied'); } catch { setCopy('failed'); } }}>{t('processingCopy')}</Button>
|
||||
{copy ? <Typography role="status">{t(copy === 'copied' ? 'processingCopied' : 'processingCopyFailed')}</Typography> : null}
|
||||
</Stack>
|
||||
<Typography>{detail.owner.name ?? detail.owner.id} · {detail.owner.email} · {state(detail.status)} · {stage(detail.stage)}</Typography>
|
||||
<Typography>{t('processingAttempts', { count: detail.attemptCount, max: detail.maxAttempts })} · {t('processingDuration', { count: Math.round(detail.durationSeconds) })}</Typography>
|
||||
<Box component="dl" sx={{ m: 0, display: 'grid', gridTemplateColumns: { xs: '1fr', sm: 'auto 1fr' }, gap: 1 }}>
|
||||
{([['processingCreated', detail.createdAtUtc], ['processingStarted', detail.startedAtUtc], ['processingCompleted', detail.completedAtUtc], ['processingLastSeen', detail.lastHeartbeatAtUtc]] as const).map(([key, value]) => <React.Fragment key={key}><Typography component="dt">{t(key)}</Typography><Typography component="dd" sx={{ m: 0 }}>{date(value)}</Typography></React.Fragment>)}
|
||||
</Box>
|
||||
{detail.failureCode ? <Alert severity="warning">{failure(detail.failureCode)}</Alert> : null}
|
||||
{detail.job ? <Typography>{detail.job.title} · {detail.job.company} · #{detail.job.id}</Typography> : null}
|
||||
{detail.document ? <Box><Typography>{t('processingSource')}: {detail.document.name}</Typography><Typography variant="body2">{detail.document.type} · {t('processingBytes', { count: detail.document.bytes })}{detail.document.pages ? ` · ${t('processingPages', { count: detail.document.pages })}` : ''}</Typography><Typography variant="body2">{detail.document.state === 'pending_review' ? t('cvImportStageReady') : detail.document.state === 'applied' ? t('processingSourceApplied') : detail.document.state === 'expired' ? t('processingSourceExpired') : state(detail.document.state ?? 'unknown')}</Typography></Box> : null}
|
||||
<Typography component="h3" variant="h6">{t('processingEvents')}</Typography>
|
||||
<Typography variant="body2">{t('processingRetention')}</Typography>
|
||||
{!events.length ? <Typography>{t('processingNoEvents')}</Typography> : <Box component="ol" sx={{ m: 0, pl: 3 }}>{events.map(item => <Box component="li" key={item.id} sx={{ mb: 2 }}><Typography>{t(kinds[item.kind] ?? 'processingUnknown')} · {state(item.status)}</Typography><Typography variant="body2"><time dateTime={item.atUtc}>{date(item.atUtc)}</time> · {stage(item.stage)} · {t('processingAttempts', { count: item.attempt, max: detail.maxAttempts })}</Typography>{item.failureCode ? <Typography variant="body2">{failure(item.failureCode)}</Typography> : null}</Box>)}</Box>}
|
||||
<Stack direction="row" spacing={1}><Button disabled={!hasOlder || busy || !events.length} onClick={() => { pausedRef.current = true; setPaused(true); setBefore(events[0].id); }}>{t('processingOlder')}</Button><Button disabled={busy} onClick={resume}>{t('processingLatest')}</Button></Stack>
|
||||
</Stack>
|
||||
</PageSection> : null}
|
||||
</Box>;
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import torch
|
||||
import threading
|
||||
import time
|
||||
import zipfile
|
||||
from uuid import UUID
|
||||
from urllib import request as urllib_request
|
||||
from urllib.error import URLError, HTTPError
|
||||
from contextvars import ContextVar
|
||||
@@ -66,6 +67,16 @@ async def require_service_token(request: Request, call_next):
|
||||
{"detail": "Invalid or missing service token."},
|
||||
status_code=401,
|
||||
)
|
||||
operation_id = None
|
||||
supplied_id = request.headers.get("X-Operation-Id", "")
|
||||
if len(supplied_id) == 36:
|
||||
try:
|
||||
parsed_id = UUID(supplied_id)
|
||||
if parsed_id.int:
|
||||
operation_id = str(parsed_id)
|
||||
except ValueError:
|
||||
pass
|
||||
request.state.operation_id = operation_id
|
||||
allowed = (
|
||||
EXTERNAL_AI_ENABLED
|
||||
and request.headers.get(EXTERNAL_AI_ALLOWED_HEADER, "").strip().lower() == "true"
|
||||
@@ -78,6 +89,8 @@ async def require_service_token(request: Request, call_next):
|
||||
state_token = _route_state.set(state)
|
||||
try:
|
||||
response = await call_next(request)
|
||||
if operation_id:
|
||||
response.headers["X-Operation-Id"] = operation_id
|
||||
if request.url.path.startswith("/cv/"):
|
||||
if state["provider"]:
|
||||
response.headers["X-Ai-Provider"] = state["provider"]
|
||||
|
||||
@@ -60,6 +60,24 @@ def load_app_module(
|
||||
return importlib.reload(module)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value,expected", [
|
||||
("11111111-1111-4111-8111-111111111111", "11111111-1111-4111-8111-111111111111"),
|
||||
("AAAAAAAA-AAAA-4AAA-8AAA-AAAAAAAAAAAA", "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"),
|
||||
("SECRET_document_content", None),
|
||||
("00000000-0000-0000-0000-000000000000", None),
|
||||
("x" * 128, None),
|
||||
])
|
||||
def test_operation_correlation_echoes_only_a_nonempty_bounded_uuid(monkeypatch, value, expected):
|
||||
module = load_app_module(monkeypatch, service_token="synthetic-token")
|
||||
client = TestClient(module.app)
|
||||
response = client.post("/cv/rewrite", json={}, headers={"X-Ai-Service-Token": "synthetic-token", "X-Operation-Id": value})
|
||||
assert response.status_code == 422 # Schema rejection exercises middleware without inference.
|
||||
assert response.headers.get("X-Operation-Id") == expected
|
||||
denied = client.post("/cv/rewrite", json={}, headers={"X-Operation-Id": value})
|
||||
assert denied.status_code == 401
|
||||
assert "X-Operation-Id" not in denied.headers
|
||||
|
||||
|
||||
def test_health_reports_runtime_without_ollama_and_without_forcing_model_load(monkeypatch):
|
||||
module = load_app_module(monkeypatch)
|
||||
client = TestClient(module.app)
|
||||
|
||||
Reference in New Issue
Block a user