fix(admin): complete processing diagnostics and failure evidence
This commit is contained in:
@@ -37,6 +37,47 @@ public sealed class UserOperationHistoryPersistenceTests
|
|||||||
Assert.Equal(DateTimeKind.Utc, item.AtUtc.Kind);
|
Assert.Equal(DateTimeKind.Utc, item.AtUtc.Kind);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Admin_diagnostic_filters_translate_and_preserve_owner_joins_in_MariaDB()
|
||||||
|
{
|
||||||
|
await using var database = await MariaDbDatabase.CreateAsync();
|
||||||
|
await using var db = database.Context();
|
||||||
|
db.Users.Add(new() { Id = "rd21-owner", UserName = "diagnostic-owner", Email = "diagnostic@example.test" });
|
||||||
|
db.Users.Add(new() { Id = "foreign", UserName = "diagnostic-foreign", Email = "foreign@example.test" });
|
||||||
|
var company = new Company { OwnerUserId = "rd21-owner", Name = "Diagnostic company" };
|
||||||
|
var job = new JobApplication { OwnerUserId = "rd21-owner", Company = company, JobTitle = "Diagnostic engineer", Status = "Applied" };
|
||||||
|
var artifact = new CvUploadArtifact { OwnerUserId = "rd21-owner", OriginalFileName = "synthetic.pdf", MimeType = "application/pdf", ByteSize = 400 };
|
||||||
|
db.JobApplications.Add(job); db.CvUploadArtifacts.Add(artifact);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var run = new CvExtractionRun { OwnerUserId = "rd21-owner", ArtifactId = artifact.Id, Trigger = "document_import", ParserVersion = ProfileCvController.DocumentParserVersion };
|
||||||
|
db.CvExtractionRuns.Add(run); await db.SaveChangesAsync();
|
||||||
|
var store = new UserOperationStore(db, TimeProvider.System);
|
||||||
|
await store.CreateAsync(Request("diagnostic-document") with { SubjectType = CvProcessingQueue.SubjectType, SubjectId = run.Id.ToString() }, default);
|
||||||
|
var strategy = (await store.CreateAsync(Request("diagnostic-strategy") with { TaskType = StrategySnapshotService.TaskType, SubjectType = "job_strategy", SubjectId = StrategySnapshotService.EncodeSubject(job.Id, []) }, default)).Operation;
|
||||||
|
strategy.CreatedAtUtc = new DateTime(2026, 10, 15, 12, 0, 0, DateTimeKind.Utc);
|
||||||
|
strategy.FailureCategory = "timeout";
|
||||||
|
var failedAt = DateTime.UtcNow.AddMinutes(-5);
|
||||||
|
db.UserOperationEvents.Add(new() { OperationId = strategy.Id, OwnerUserId = strategy.OwnerUserId, Kind = "RetryScheduled", Status = OperationStatuses.WaitingForRetry, AtUtc = failedAt, Stage = "cv_extracting", FailureCode = "timeout" });
|
||||||
|
db.UserOperations.Add(new() { Id = Guid.NewGuid(), OwnerUserId = "foreign", IdempotencyKey = "foreign-diagnostic", TaskType = StrategySnapshotService.TaskType, SubjectType = "job_strategy", SubjectId = StrategySnapshotService.EncodeSubject(job.Id, []), Status = OperationStatuses.Queued, CreatedAtUtc = DateTime.UtcNow });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var controller = new AdminProcessingController(db, new ConfigurationBuilder().Build(), TimeProvider.System);
|
||||||
|
var results = new[]
|
||||||
|
{
|
||||||
|
await controller.List(jobId: job.Id), await controller.List(q: "Diagnostic engineer"),
|
||||||
|
await controller.List(worker: "document"), await controller.List(failureCode: "timeout"),
|
||||||
|
await controller.List(createdBefore: new DateTimeOffset(2026, 10, 16, 0, 0, 0, TimeSpan.Zero)),
|
||||||
|
};
|
||||||
|
foreach (var result in results)
|
||||||
|
{
|
||||||
|
var page = Assert.IsType<AdminProcessingController.PageDto>(Assert.IsType<OkObjectResult>(result.Result).Value);
|
||||||
|
Assert.Equal(1, page.Total);
|
||||||
|
Assert.Equal("rd21-owner", Assert.Single(page.Items).Owner.Id);
|
||||||
|
}
|
||||||
|
var detail = Assert.IsType<AdminProcessingController.ItemDto>(Assert.IsType<OkObjectResult>((await controller.Detail(strategy.Id, default)).Result).Value);
|
||||||
|
Assert.Equal(failedAt, Assert.IsType<DateTime>(detail.LastFailureAtUtc), TimeSpan.FromMilliseconds(1));
|
||||||
|
Assert.Equal("cv_extracting", detail.LastFailureStage);
|
||||||
|
}
|
||||||
|
|
||||||
private static CreateUserOperation Request(string key) => new("cv.process", key, "authorized", "local_only");
|
private static CreateUserOperation Request(string key) => new("cv.process", key, "authorized", "local_only");
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -10,14 +10,132 @@ using JobTrackerApi.Services;
|
|||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.AspNetCore.Hosting.Server;
|
using Microsoft.AspNetCore.Hosting.Server;
|
||||||
using Microsoft.AspNetCore.Hosting.Server.Features;
|
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using System.Threading.RateLimiting;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace JobTrackerApi.Tests;
|
namespace JobTrackerApi.Tests;
|
||||||
|
|
||||||
public sealed class AdminProcessingControllerTests
|
public sealed class AdminProcessingControllerTests
|
||||||
{
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("jobId=9999")]
|
||||||
|
[InlineData("createdFrom=2099-01-01T00:00:00Z")]
|
||||||
|
[InlineData("createdBefore=2000-01-01T00:00:00Z")]
|
||||||
|
[InlineData("worker=untracked")]
|
||||||
|
[InlineData("q=SECRET_CV_CONTENT")]
|
||||||
|
public async Task Diagnostic_filters_are_applied_before_pagination(string query)
|
||||||
|
{
|
||||||
|
await using var host = await Host.Start();
|
||||||
|
using var client = host.Client("admin");
|
||||||
|
var result = await client.GetFromJsonAsync<JsonElement>("/api/admin/processing?" + query);
|
||||||
|
Assert.Equal(0, result.GetProperty("total").GetInt32());
|
||||||
|
Assert.Empty(result.GetProperty("items").EnumerateArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("jobId=-1")]
|
||||||
|
[InlineData("worker=SECRET")]
|
||||||
|
[InlineData("createdFrom=0001-01-01T00:00:00Z")]
|
||||||
|
[InlineData("failureCode=SECRET")]
|
||||||
|
[InlineData("createdFrom=2026-11-01T00:00:00Z&createdBefore=2026-10-01T00:00:00Z")]
|
||||||
|
public async Task Invalid_diagnostic_filters_fail_closed(string query)
|
||||||
|
{
|
||||||
|
await using var host = await Host.Start();
|
||||||
|
using var client = host.Client("admin");
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, (await client.GetAsync("/api/admin/processing?" + query)).StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("jobId=2", 1)]
|
||||||
|
[InlineData("q=Owned%20job%20title", 1)]
|
||||||
|
[InlineData("q=owner%40example.test", 2)]
|
||||||
|
[InlineData("worker=document", 1)]
|
||||||
|
[InlineData("worker=ai", 2)]
|
||||||
|
[InlineData("failureCode=timeout", 1)]
|
||||||
|
[InlineData("failureCode=processing_failed", 1)]
|
||||||
|
[InlineData("createdBefore=2026-10-16T00:00:00Z", 1)]
|
||||||
|
public async Task Diagnostic_filters_preserve_actual_owner_relationships(string query, int expected)
|
||||||
|
{
|
||||||
|
await using var host = await Host.Start();
|
||||||
|
using var client = host.Client("admin");
|
||||||
|
var result = await client.GetFromJsonAsync<JsonElement>("/api/admin/processing?" + query + "&pageSize=1");
|
||||||
|
Assert.Equal(expected, result.GetProperty("total").GetInt32());
|
||||||
|
Assert.Single(result.GetProperty("items").EnumerateArray());
|
||||||
|
Assert.DoesNotContain("SECRET", result.GetRawText());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Detail_exposes_import_identity_and_links_only_the_administrators_own_application()
|
||||||
|
{
|
||||||
|
await using var host = await Host.Start();
|
||||||
|
using var admin = host.Client("admin");
|
||||||
|
var document = await admin.GetFromJsonAsync<JsonElement>($"/api/admin/processing/{host.OperationId}");
|
||||||
|
Assert.Equal(1, document.GetProperty("document").GetProperty("importId").GetInt32());
|
||||||
|
var job = await admin.GetFromJsonAsync<JsonElement>($"/api/admin/processing/{host.JobOperationId}");
|
||||||
|
Assert.False(job.GetProperty("job").GetProperty("canOpen").GetBoolean());
|
||||||
|
using var ownerAdmin = host.Client("owner-admin");
|
||||||
|
job = await ownerAdmin.GetFromJsonAsync<JsonElement>($"/api/admin/processing/{host.JobOperationId}");
|
||||||
|
Assert.True(job.GetProperty("job").GetProperty("canOpen").GetBoolean());
|
||||||
|
var search = await admin.GetFromJsonAsync<JsonElement>($"/api/admin/processing?q={host.OperationId}");
|
||||||
|
Assert.Equal(1, search.GetProperty("total").GetInt32());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Processing_reads_share_a_bounded_account_rate_limit()
|
||||||
|
{
|
||||||
|
await using var host = await Host.Start(2);
|
||||||
|
using var admin = host.Client("admin");
|
||||||
|
Assert.Equal(HttpStatusCode.OK, (await admin.GetAsync("/api/admin/processing")).StatusCode);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, (await admin.GetAsync($"/api/admin/processing/{host.OperationId}")).StatusCode);
|
||||||
|
Assert.Equal(HttpStatusCode.TooManyRequests, (await admin.GetAsync($"/api/admin/processing/{host.OperationId}/events")).StatusCode);
|
||||||
|
using var other = host.Client("owner-admin");
|
||||||
|
Assert.Equal(HttpStatusCode.OK, (await other.GetAsync("/api/admin/processing")).StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("succeeded")]
|
||||||
|
[InlineData("waiting_for_retry")]
|
||||||
|
public async Task Failure_metadata_uses_the_recorded_failure_instead_of_later_success(string status)
|
||||||
|
{
|
||||||
|
await using var host = await Host.Start();
|
||||||
|
var failedAt = DateTime.UtcNow.AddMinutes(-2);
|
||||||
|
await using (var scope = host.Services.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
|
||||||
|
var operation = await db.UserOperations.IgnoreQueryFilters().SingleAsync(item => item.Id == host.OperationId);
|
||||||
|
operation.Status = status; operation.FailureCategory = "timeout";
|
||||||
|
operation.CompletedAtUtc = status == "succeeded" ? failedAt.AddMinutes(1) : null;
|
||||||
|
operation.ProgressStage = "cv_review_ready";
|
||||||
|
db.UserOperationEvents.Add(new() { Id = 9007199254740995, OperationId = operation.Id, OwnerUserId = "owner", Kind = "RetryScheduled", Status = "waiting_for_retry", AtUtc = failedAt, Stage = "cv_extracting", FailureCode = "timeout" });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
using var client = host.Client("admin");
|
||||||
|
var result = await client.GetFromJsonAsync<JsonElement>($"/api/admin/processing/{host.OperationId}");
|
||||||
|
Assert.Equal(status, result.GetProperty("status").GetString());
|
||||||
|
Assert.Equal(failedAt, result.GetProperty("lastFailureAtUtc").GetDateTime());
|
||||||
|
Assert.Equal("cv_extracting", result.GetProperty("lastFailureStage").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Missing_failure_history_does_not_turn_success_time_into_failure_evidence()
|
||||||
|
{
|
||||||
|
await using var host = await Host.Start();
|
||||||
|
await using (var scope = host.Services.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
|
||||||
|
var operation = await db.UserOperations.IgnoreQueryFilters().SingleAsync(item => item.Id == host.OperationId);
|
||||||
|
operation.Status = "succeeded"; operation.FailureCategory = "timeout"; operation.CompletedAtUtc = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
using var client = host.Client("admin");
|
||||||
|
var result = await client.GetFromJsonAsync<JsonElement>($"/api/admin/processing/{host.OperationId}");
|
||||||
|
Assert.Equal(JsonValueKind.Null, result.GetProperty("lastFailureAtUtc").ValueKind);
|
||||||
|
Assert.Equal(JsonValueKind.Null, result.GetProperty("lastFailureStage").ValueKind);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task User_authored_identity_labels_are_bounded_before_admin_projection()
|
public async Task User_authored_identity_labels_are_bounded_before_admin_projection()
|
||||||
{
|
{
|
||||||
@@ -81,15 +199,17 @@ public sealed class AdminProcessingControllerTests
|
|||||||
|
|
||||||
private sealed class Host(WebApplication app, string directory, Guid operationId, Guid foreignSubjectId) : IAsyncDisposable
|
private sealed class Host(WebApplication app, string directory, Guid operationId, Guid foreignSubjectId) : IAsyncDisposable
|
||||||
{
|
{
|
||||||
|
public IServiceProvider Services => app.Services;
|
||||||
public Guid OperationId => operationId;
|
public Guid OperationId => operationId;
|
||||||
public Guid ForeignSubjectOperationId => foreignSubjectId;
|
public Guid ForeignSubjectOperationId => foreignSubjectId;
|
||||||
|
public Guid JobOperationId => Guid.Parse("22222222-2222-4222-8222-222222222222");
|
||||||
public HttpClient Client(string? identity)
|
public HttpClient Client(string? identity)
|
||||||
{
|
{
|
||||||
var client = new HttpClient { BaseAddress = new Uri(app.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.Single()) };
|
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);
|
if (identity is not null) client.DefaultRequestHeaders.Add("X-Test-Identity", identity);
|
||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
public static async Task<Host> Start()
|
public static async Task<Host> Start(int permits = 120)
|
||||||
{
|
{
|
||||||
var directory = Path.Combine(Path.GetTempPath(), "admin-processing-" + Guid.NewGuid().ToString("N"));
|
var directory = Path.Combine(Path.GetTempPath(), "admin-processing-" + Guid.NewGuid().ToString("N"));
|
||||||
Directory.CreateDirectory(directory);
|
Directory.CreateDirectory(directory);
|
||||||
@@ -98,11 +218,18 @@ public sealed class AdminProcessingControllerTests
|
|||||||
builder.Services.AddControllers().AddApplicationPart(typeof(AuthController).Assembly);
|
builder.Services.AddControllers().AddApplicationPart(typeof(AuthController).Assembly);
|
||||||
builder.Services.AddAuthentication("test").AddScheme<AuthenticationSchemeOptions, TestAuthentication>("test", _ => { });
|
builder.Services.AddAuthentication("test").AddScheme<AuthenticationSchemeOptions, TestAuthentication>("test", _ => { });
|
||||||
builder.Services.AddAuthorization(); builder.Services.AddHttpContextAccessor();
|
builder.Services.AddAuthorization(); builder.Services.AddHttpContextAccessor();
|
||||||
|
builder.Services.AddRateLimiter(options =>
|
||||||
|
{
|
||||||
|
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||||
|
options.AddPolicy(AdminProcessingController.RateLimitPolicy, context => RateLimitPartition.GetFixedWindowLimiter(
|
||||||
|
RateLimitPartitionKeys.Account(context, AdminProcessingController.RateLimitPolicy),
|
||||||
|
_ => new FixedWindowRateLimiterOptions { PermitLimit = permits, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 }));
|
||||||
|
});
|
||||||
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
|
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
|
||||||
builder.Services.AddSingleton(TimeProvider.System);
|
builder.Services.AddSingleton(TimeProvider.System);
|
||||||
builder.Services.AddDbContext<JobTrackerContext>(options => options.UseSqlite($"Data Source={Path.Combine(directory, "processing.db")};Pooling=False"));
|
builder.Services.AddDbContext<JobTrackerContext>(options => options.UseSqlite($"Data Source={Path.Combine(directory, "processing.db")};Pooling=False"));
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
app.UseAuthentication(); app.UseAuthorization(); app.MapControllers();
|
app.UseAuthentication(); app.UseAuthorization(); app.UseRateLimiter(); app.MapControllers();
|
||||||
var operationId = Guid.NewGuid(); var foreign = Guid.NewGuid();
|
var operationId = Guid.NewGuid(); var foreign = Guid.NewGuid();
|
||||||
await using (var scope = app.Services.CreateAsyncScope())
|
await using (var scope = app.Services.CreateAsyncScope())
|
||||||
{
|
{
|
||||||
@@ -115,7 +242,8 @@ public sealed class AdminProcessingControllerTests
|
|||||||
db.CvUploadArtifacts.Add(new() { Id = 1, OwnerUserId = "owner", OriginalFileName = "../folder/CV.pdf", MimeType = "application/pdf", ByteSize = 500, StoragePath = "SECRET_PATH" });
|
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.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" },
|
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 });
|
new UserOperation { Id = foreign, OwnerUserId = "other", IdempotencyKey = "two", TaskType = "strategy.snapshot", SubjectType = "job_strategy", SubjectId = "2|", Status = "queued", CreatedAtUtc = DateTime.UtcNow },
|
||||||
|
new UserOperation { Id = Guid.Parse("22222222-2222-4222-8222-222222222222"), OwnerUserId = "owner", IdempotencyKey = "three", TaskType = "strategy.snapshot", SubjectType = "job_strategy", SubjectId = "2|", Status = "failed", FailureCategory = "timeout", CreatedAtUtc = new DateTime(2026, 10, 15, 12, 0, 0, DateTimeKind.Utc) });
|
||||||
db.UserOperationEvents.AddRange(
|
db.UserOperationEvents.AddRange(
|
||||||
new UserOperationEvent { Id = 100, OperationId = operationId, OwnerUserId = "owner", AtUtc = DateTime.UtcNow.AddDays(-31), Kind = "Created", Status = "queued" },
|
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 = 9007199254740993, OperationId = operationId, OwnerUserId = "owner", AtUtc = DateTime.UtcNow, Kind = "Created", Status = "running" },
|
||||||
@@ -131,8 +259,8 @@ public sealed class AdminProcessingControllerTests
|
|||||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||||
{
|
{
|
||||||
if (!Request.Headers.TryGetValue("X-Test-Identity", out var identity)) return Task.FromResult(AuthenticateResult.NoResult());
|
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()) };
|
var claims = new List<Claim> { new(ClaimTypes.NameIdentifier, identity == "owner-admin" ? "owner" : identity.ToString()) };
|
||||||
if (identity == "admin") claims.Add(new(ClaimTypes.Role, "Admin"));
|
if (identity == "admin" || identity == "owner-admin") claims.Add(new(ClaimTypes.Role, "Admin"));
|
||||||
return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(new ClaimsIdentity(claims, "test")), "test")));
|
return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(new ClaimsIdentity(claims, "test")), "test")));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using System.Security.Claims;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using JobTrackerApi.Data;
|
using JobTrackerApi.Data;
|
||||||
using JobTrackerApi.Models;
|
using JobTrackerApi.Models;
|
||||||
using JobTrackerApi.Services;
|
using JobTrackerApi.Services;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace JobTrackerApi.Controllers;
|
namespace JobTrackerApi.Controllers;
|
||||||
@@ -12,15 +14,18 @@ namespace JobTrackerApi.Controllers;
|
|||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/admin/processing")]
|
[Route("api/admin/processing")]
|
||||||
[Authorize(Roles = "Admin")]
|
[Authorize(Roles = "Admin")]
|
||||||
|
[EnableRateLimiting(RateLimitPolicy)]
|
||||||
public sealed class AdminProcessingController(JobTrackerContext db, IConfiguration configuration, TimeProvider clock) : ControllerBase
|
public sealed class AdminProcessingController(JobTrackerContext db, IConfiguration configuration, TimeProvider clock) : ControllerBase
|
||||||
{
|
{
|
||||||
|
public const string RateLimitPolicy = "admin-processing";
|
||||||
private static readonly HashSet<string> EventKinds = Enum.GetNames<OperationEventKind>().ToHashSet(StringComparer.Ordinal);
|
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 IdentityDto(string Id, string? Name, string? Email);
|
||||||
public sealed record JobDto(int Id, string Title, string? Company);
|
public sealed record JobDto(int Id, string Title, string? Company, bool CanOpen);
|
||||||
public sealed record DocumentDto(string Name, string? Type, long Bytes, int? Pages, string? State);
|
public sealed record DocumentDto(int ImportId, 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,
|
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,
|
int MaxAttempts, DateTime CreatedAtUtc, DateTime? StartedAtUtc, DateTime? CompletedAtUtc, DateTime? LastHeartbeatAtUtc,
|
||||||
double DurationSeconds, string Worker, bool WorkerEnabled, string? FailureCode, JobDto? Job, DocumentDto? Document);
|
double DurationSeconds, string Worker, bool WorkerEnabled, string? FailureCode, JobDto? Job, DocumentDto? Document,
|
||||||
|
DateTime? LastFailureAtUtc, string? LastFailureStage);
|
||||||
public sealed record WorkerDto(string Name, bool FlagEnabled, bool IndividualOperationsRecorded);
|
public sealed record WorkerDto(string Name, bool FlagEnabled, bool IndividualOperationsRecorded);
|
||||||
public sealed record PageDto(IReadOnlyList<ItemDto> Items, int Total, int Page, int PageSize,
|
public sealed record PageDto(IReadOnlyList<ItemDto> Items, int Total, int Page, int PageSize,
|
||||||
IReadOnlyDictionary<string, int> Counts, IReadOnlyList<WorkerDto> Workers, string CvInterpretation);
|
IReadOnlyDictionary<string, int> Counts, IReadOnlyList<WorkerDto> Workers, string CvInterpretation);
|
||||||
@@ -29,17 +34,64 @@ public sealed class AdminProcessingController(JobTrackerContext db, IConfigurati
|
|||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<ActionResult<PageDto>> List([FromQuery] string? status = null, [FromQuery] string? taskType = null,
|
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)
|
[FromQuery] string? owner = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 25,
|
||||||
|
[FromQuery] int? jobId = null, [FromQuery] string? worker = null, [FromQuery] string? failureCode = null,
|
||||||
|
[FromQuery] string? q = null, [FromQuery] DateTimeOffset? createdFrom = null, [FromQuery] DateTimeOffset? createdBefore = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (page is < 1 or > 1000 || pageSize is < 1 or > 100 || owner?.Length > 255
|
if (page is < 1 or > 1000 || pageSize is < 1 or > 100 || owner?.Length > 255
|
||||||
|| (status is not null && OperationEventLog.SafeStatus(status) == "unknown")
|
|| (status is not null && OperationEventLog.SafeStatus(status) == "unknown")
|
||||||
|| (taskType is not null && taskType is not (CvProcessingQueue.TaskType or StrategySnapshotService.TaskType or "other"))) return BadRequest();
|
|| (taskType is not null && taskType is not (CvProcessingQueue.TaskType or StrategySnapshotService.TaskType or "other"))
|
||||||
|
|| jobId <= 0 || q?.Length > 200 || (worker is not null && worker is not ("document" or "ai" or "untracked"))
|
||||||
|
|| (failureCode is not null && failureCode != "processing_failed" && OperationEventLog.SafeFailure(failureCode) != failureCode)
|
||||||
|
|| createdFrom?.UtcDateTime.Year < 1000 || createdBefore?.UtcDateTime.Year < 1000
|
||||||
|
|| (createdFrom is not null && createdBefore is not null && createdFrom >= createdBefore)) return BadRequest();
|
||||||
var all = ReadOperations();
|
var all = ReadOperations();
|
||||||
var query = all;
|
var query = all;
|
||||||
|
// Historical tables use different MariaDB collations; owner identifiers must compare exactly.
|
||||||
|
var identifiers = db.Database.IsMySql() ? "utf8mb4_bin" : "BINARY";
|
||||||
if (status is not null) query = query.Where(item => item.Status == status);
|
if (status is not null) query = query.Where(item => item.Status == status);
|
||||||
if (!string.IsNullOrWhiteSpace(owner)) query = query.Where(item => item.OwnerUserId == owner);
|
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);
|
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);
|
else if (taskType is not null) query = query.Where(item => item.TaskType == taskType);
|
||||||
|
if (createdFrom is not null) { var from = createdFrom.Value.UtcDateTime; query = query.Where(item => item.CreatedAtUtc >= from); }
|
||||||
|
if (createdBefore is not null) { var before = createdBefore.Value.UtcDateTime; query = query.Where(item => item.CreatedAtUtc < before); }
|
||||||
|
if (jobId is not null)
|
||||||
|
{
|
||||||
|
var prefix = jobId.Value.ToString(CultureInfo.InvariantCulture) + "|";
|
||||||
|
query = query.Where(item => item.TaskType == StrategySnapshotService.TaskType && item.SubjectType == "job_strategy"
|
||||||
|
&& item.SubjectId != null && item.SubjectId.StartsWith(prefix)
|
||||||
|
&& db.JobApplications.IgnoreQueryFilters().Any(job => job.Id == jobId && EF.Functions.Collate(job.OwnerUserId, identifiers) == item.OwnerUserId));
|
||||||
|
}
|
||||||
|
if (worker is not null)
|
||||||
|
{
|
||||||
|
var documents = db.CvExtractionRuns.IgnoreQueryFilters().Where(run => run.ParserVersion == ProfileCvController.DocumentParserVersion && run.Trigger == "document_import");
|
||||||
|
if (worker == "untracked") query = query.Where(item => item.TaskType != CvProcessingQueue.TaskType && item.TaskType != StrategySnapshotService.TaskType);
|
||||||
|
else if (worker == "document") query = query.Where(item => item.TaskType == CvProcessingQueue.TaskType && item.SubjectType == CvProcessingQueue.SubjectType
|
||||||
|
&& documents.Any(run => EF.Functions.Collate(run.OwnerUserId, identifiers) == item.OwnerUserId && run.Id.ToString() == item.SubjectId));
|
||||||
|
else query = query.Where(item => (item.TaskType == CvProcessingQueue.TaskType || item.TaskType == StrategySnapshotService.TaskType)
|
||||||
|
&& !(item.TaskType == CvProcessingQueue.TaskType && item.SubjectType == CvProcessingQueue.SubjectType
|
||||||
|
&& documents.Any(run => EF.Functions.Collate(run.OwnerUserId, identifiers) == item.OwnerUserId && run.Id.ToString() == item.SubjectId)));
|
||||||
|
}
|
||||||
|
if (failureCode is not null)
|
||||||
|
{
|
||||||
|
var known = OperationEventLog.FailureCodes.ToArray();
|
||||||
|
query = failureCode == "processing_failed"
|
||||||
|
? query.Where(item => item.FailureCategory != null && !known.Contains(item.FailureCategory))
|
||||||
|
: query.Where(item => item.FailureCategory == failureCode);
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrWhiteSpace(q))
|
||||||
|
{
|
||||||
|
q = q.Trim();
|
||||||
|
var users = db.Users.Where(user => user.Id.Contains(q) || (user.DisplayName != null && user.DisplayName.Contains(q))
|
||||||
|
|| (user.Email != null && user.Email.Contains(q))).Select(user => user.Id);
|
||||||
|
var jobs = db.JobApplications.IgnoreQueryFilters().Where(job => job.JobTitle.Contains(q)
|
||||||
|
|| (EF.Functions.Collate(job.Company.OwnerUserId, identifiers) == job.OwnerUserId && job.Company.Name.Contains(q)));
|
||||||
|
var isId = Guid.TryParse(q, out var operationId);
|
||||||
|
query = query.Where(item => users.Any(id => EF.Functions.Collate(id, identifiers) == item.OwnerUserId) || (isId && item.Id == operationId)
|
||||||
|
|| (item.TaskType == StrategySnapshotService.TaskType && item.SubjectType == "job_strategy" && item.SubjectId != null
|
||||||
|
&& jobs.Any(job => EF.Functions.Collate(job.OwnerUserId, identifiers) == item.OwnerUserId && item.SubjectId.StartsWith(job.Id.ToString() + "|"))));
|
||||||
|
}
|
||||||
var total = await query.CountAsync(cancellationToken);
|
var total = await query.CountAsync(cancellationToken);
|
||||||
var operations = await query.OrderByDescending(item => item.CreatedAtUtc).ThenByDescending(item => item.Id)
|
var operations = await query.OrderByDescending(item => item.CreatedAtUtc).ThenByDescending(item => item.Id)
|
||||||
.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync(cancellationToken);
|
.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync(cancellationToken);
|
||||||
@@ -127,6 +179,13 @@ public sealed class AdminProcessingController(JobTrackerContext db, IConfigurati
|
|||||||
|
|
||||||
private async Task<IReadOnlyList<ItemDto>> Project(IReadOnlyList<UserOperation> operations, bool includePages, CancellationToken token)
|
private async Task<IReadOnlyList<ItemDto>> Project(IReadOnlyList<UserOperation> operations, bool includePages, CancellationToken token)
|
||||||
{
|
{
|
||||||
|
var selected = includePages && operations.Count == 1 ? operations[0] : null;
|
||||||
|
var code = OperationEventLog.SafeFailure(selected?.FailureCategory);
|
||||||
|
var cutoff = clock.GetUtcNow().UtcDateTime.AddDays(-OperationEventLog.RetentionDays);
|
||||||
|
var lastFailure = selected is null || code is null ? null : await db.UserOperationEvents.IgnoreQueryFilters().AsNoTracking()
|
||||||
|
.Where(item => item.OperationId == selected.Id && item.OwnerUserId == selected.OwnerUserId && item.AtUtc >= cutoff && item.FailureCode == code
|
||||||
|
&& (item.Kind == "Failed" || item.Kind == "RetryScheduled" || item.Kind == "LeaseRecovered" || item.Kind == "Expired"))
|
||||||
|
.OrderByDescending(item => item.Id).Select(item => new { item.AtUtc, item.Stage }).FirstOrDefaultAsync(token);
|
||||||
var ownerIds = operations.Select(item => item.OwnerUserId).Distinct().ToArray();
|
var ownerIds = operations.Select(item => item.OwnerUserId).Distinct().ToArray();
|
||||||
var users = await db.Users.AsNoTracking().Where(item => ownerIds.Contains(item.Id))
|
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 { item.Id, Name = item.DisplayName ?? item.UserName, item.Email })
|
||||||
@@ -146,9 +205,9 @@ public sealed class AdminProcessingController(JobTrackerContext db, IConfigurati
|
|||||||
{
|
{
|
||||||
var subject = Subject(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 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 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, foundJob.OwnerUserId == HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier)) : null;
|
||||||
var artifact = run?.ArtifactId is int artifactId && artifacts.TryGetValue(artifactId, out var foundArtifact) && foundArtifact.OwnerUserId == operation.OwnerUserId ? foundArtifact : 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),
|
var document = artifact is null ? null : new DocumentDto(run!.Id, FileName(artifact.OriginalFileName),
|
||||||
artifact.MimeType is "application/pdf" or "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ? artifact.MimeType : null,
|
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);
|
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 worker = SafeTask(operation.TaskType) == "other" ? "untracked" : run is { ParserVersion: ProfileCvController.DocumentParserVersion, Trigger: "document_import" } ? "document" : "ai";
|
||||||
@@ -156,7 +215,9 @@ public sealed class AdminProcessingController(JobTrackerContext db, IConfigurati
|
|||||||
return new ItemDto(operation.Id, users.GetValueOrDefault(operation.OwnerUserId) ?? new(operation.OwnerUserId, null, null), SafeTask(operation.TaskType),
|
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),
|
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),
|
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);
|
duration, worker, worker != "untracked" && (Enabled("AiOperations") || (worker == "document" && Enabled("CvDocumentImports"))), OperationEventLog.SafeFailure(operation.FailureCategory), job, document,
|
||||||
|
Utc(lastFailure?.AtUtc ?? (operation.Status == OperationStatuses.Failed ? operation.CompletedAtUtc : null)),
|
||||||
|
OperationEventLog.SafeStage(lastFailure?.Stage ?? (operation.Status == OperationStatuses.Failed ? operation.ProgressStage : null)));
|
||||||
}).ToArray();
|
}).ToArray();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -460,6 +460,16 @@ builder.Services.AddRateLimiter(options =>
|
|||||||
QueueLimit = 0,
|
QueueLimit = 0,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
options.AddPolicy(AdminProcessingController.RateLimitPolicy, context =>
|
||||||
|
RateLimitPartition.GetFixedWindowLimiter(
|
||||||
|
partitionKey: RateLimitPartitionKeys.Account(context, AdminProcessingController.RateLimitPolicy),
|
||||||
|
factory: _ => new FixedWindowRateLimiterOptions
|
||||||
|
{
|
||||||
|
PermitLimit = 120,
|
||||||
|
Window = TimeSpan.FromMinutes(1),
|
||||||
|
QueueLimit = 0,
|
||||||
|
}));
|
||||||
|
|
||||||
options.AddPolicy("public-pdf", context =>
|
options.AddPolicy("public-pdf", context =>
|
||||||
RateLimitPartition.GetFixedWindowLimiter(
|
RateLimitPartition.GetFixedWindowLimiter(
|
||||||
partitionKey: RateLimitPartitionKeys.PublicPdf(context),
|
partitionKey: RateLimitPartitionKeys.PublicPdf(context),
|
||||||
|
|||||||
@@ -14,11 +14,13 @@ public static class OperationEventLog
|
|||||||
public static string? SafeStage(string? value) => value is "cv_validating_file" or "cv_extracting" or "cv_identifying_sections"
|
public static string? SafeStage(string? value) => value is "cv_validating_file" or "cv_extracting" or "cv_identifying_sections"
|
||||||
or "cv_analysing" or "cv_validating_details" or "cv_preparing_review" or "cv_review_ready" ? value : null;
|
or "cv_analysing" or "cv_validating_details" or "cv_preparing_review" or "cv_review_ready" ? value : null;
|
||||||
|
|
||||||
public static string? SafeFailure(string? value) => value is null ? null : value is
|
public static IReadOnlyList<string> FailureCodes { get; } = Array.AsReadOnly(new[]
|
||||||
"timeout" or "lease_expired" or "deadline_exceeded" or "entitlement_changed" or "document_unavailable"
|
{
|
||||||
or "sidecar_unavailable" or "upstream_unavailable" or "invalid_response" or "circuit_open" or "rate_limited"
|
"timeout", "lease_expired", "deadline_exceeded", "entitlement_changed", "document_unavailable",
|
||||||
or "permission_denied" or "cancelled" or "upload_rejected" or "parser_failed" or "malformed_output" or "provider_unavailable"
|
"sidecar_unavailable", "upstream_unavailable", "invalid_response", "circuit_open", "rate_limited",
|
||||||
? value : "processing_failed";
|
"permission_denied", "cancelled", "upload_rejected", "parser_failed", "malformed_output", "provider_unavailable",
|
||||||
|
});
|
||||||
|
public static string? SafeFailure(string? value) => value is null ? null : FailureCodes.Contains(value, StringComparer.Ordinal) ? value : "processing_failed";
|
||||||
|
|
||||||
public static UserOperationEvent Create(UserOperation operation, OperationEventKind kind, DateTime now,
|
public static UserOperationEvent Create(UserOperation operation, OperationEventKind kind, DateTime now,
|
||||||
string? status = null, string? stage = null, string? failure = null, int? attempt = null) => new()
|
string? status = null, string? stage = null, string? failure = null, int? attempt = null) => new()
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# D owner-addendum qualification
|
||||||
|
|
||||||
|
29 October 2026. This adds evidence to the preserved initial D acceptance; final cumulative acceptance remains pending.
|
||||||
|
|
||||||
|
## Completed requirements
|
||||||
|
|
||||||
|
The administrator list now filters before paging by UTC creation interval (inclusive start, exclusive end), actual owner-verified job, configured processing family, fixed failure code and bounded search. Search covers safe account identity, owned job title/company and exact operation GUID only. Invalid identifiers, intervals, worker names and failure codes return 400. The UI explains invalid filters and stops automatic retries until a correction. Dates earlier than MariaDB's supported range are rejected. Existing owner/status/task filters and bounded paging remain intact.
|
||||||
|
|
||||||
|
Details expose the internal owner identifier, actual extraction-run identifier and explicit absence of a linked job. Job navigation is offered only when the authenticated administrator also owns that job; administrative metadata access grants no new ordinary-user job-reading permission. The list displays elapsed duration and current failure state. A named account-partitioned read limit permits 120 requests/minute without queueing; normal polling uses approximately 36/minute. Existing authorization remains mandatory.
|
||||||
|
|
||||||
|
Failure metadata uses the latest retained, owner-bound typed failure event with a sanitized matching code. Successful/retrying operations label retained failures as previous failures. Missing historical timestamps stay unknown; a later success timestamp is never presented as the failure time. Only an actually failed terminal operation may fall back to its recorded completion time. No arbitrary messages, payloads or source text enter this projection.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Evidence paths are relative to `.artifacts/pre-phase10-ux/`. Focused reruns are not added to full-suite totals.
|
||||||
|
|
||||||
|
| Check | Result | Evidence |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Full backend | 1,447 passed, zero failed/skipped | `d-addendum-backend-final/backend.trx` |
|
||||||
|
| Full frontend | 1,031 passed across 112 suites, zero failed/pending | `d-addendum-frontend-full-02.json` |
|
||||||
|
| TypeScript, full lint, production frontend build | exit 0 each | `d-addendum-{typecheck,lint,build}-final.log` |
|
||||||
|
| Admin boundaries, filters, recovery, responsive EN/NB/themes | two passed | `d-addendum-browser-02.json` |
|
||||||
|
| Real browser zoom 100/200/400%, EN/light and NB/dark | two passed | same browser report; original geometry assertions retained |
|
||||||
|
| Real MariaDB, including final failure-event projection | 90 passed, zero failed/skipped; cleanup exit 0 | `d-addendum-mariadb-final-02/` |
|
||||||
|
| Follow-up static review | timestamp finding resolved; no further blocker | existing server review worker, 29 October |
|
||||||
|
|
||||||
|
The initial RED API/UI cases remain in `d-addendum-api-red/` and `d-addendum-ui-red.json`. The timestamp regression was independently reproduced with the former completion-time binding (`d-failure-time-red.json`) before restoring the corrected binding. The first provider attempt found an actual collation mismatch in the new cross-table ownership filters. Explicit provider-appropriate binary collation fixed those comparisons without rewriting data or schemas; `d-addendum-mariadb-02/` passed 90 tests before the final failure-time query was added. A subsequent final attempt could not start because Docker Desktop was stopped (`d-addendum-mariadb-final/`); it did not reach tests or MariaDB. This infrastructure failure is not a test pass.
|
||||||
|
|
||||||
|
The initial zoom recovery run passed both admin cases but was stopped while waiting for extension startup; retain `d-addendum-browser-final.log` as an incomplete attempt. Isolated diagnostic launches ruled out the long worktree path. The test context now enables its deliberate local extension and captures its service worker before navigation; the fresh four-case report passes. Application code, zoom percentages, timeout and geometry assertions were not relaxed.
|
||||||
|
|
||||||
|
## Scope and gates
|
||||||
|
|
||||||
|
The sealed security scan through `d52403d` does not include this later delta. Its bounded filters, rate policy, failure-event query and UI were reviewed separately; no new security blocker was identified. This is source review, not a new sealed scan or production acceptance.
|
||||||
|
|
||||||
|
Full cumulative ordinary/specialized browser, final Docling, PDF fidelity, source/runtime closure and local C/D activation remain pending. Historical PDF coordinate-oracle failures, human/device/language/print checks, real AI extraction qualification and production infrastructure debt remain open. No production operation, main merge, deployment or Phase 10 cleanup occurred.
|
||||||
@@ -1,5 +1,15 @@
|
|||||||
# Integrated qualification progress
|
# Integrated qualification progress
|
||||||
|
|
||||||
|
## 29 October addendum
|
||||||
|
|
||||||
|
The mandatory D owner-addendum audit identified missing diagnostic filters and identifiers in the initial checkpoint. `D_ADDENDUM_ACCEPTANCE.md` records the completed changes, reproduced failures and current independent evidence: backend 1,447, frontend 1,031 across 112 suites, TypeScript/lint/build exit zero, and four focused browser cases (including actual 100/200/400% zoom) passed. Final MariaDB is being rerun; the first final attempt could not start Docker and reached no tests. The source delta is separately reviewed and lies outside the original sealed security scan.
|
||||||
|
|
||||||
|
Fresh deterministic interpretation/scoring is now complete for the 36 source-bound structural documents: PDF typed precision 100%, recall 81.97%, F1 90.09%; DOCX typed precision/recall/F1 100%. Both formats recovered 134/134 expected literal fragments and correctly abstained in all six designated ambiguous cases. PDF provenance is 24/27 and DOCX 27/27. PDF unresolved-source classification remains limited (16 true positives, 27 false positives, two false negatives); text recovery alone does not establish record reconstruction. Evidence: `.artifacts/pre-phase10-ux/integration-scoring-02/`, including separate frozen-split results. No generative model was invoked; synthetic corpus results do not estimate population accuracy or qualify reviewed persistence/real AI.
|
||||||
|
|
||||||
|
The earlier rows below are preserved as historical evidence, not final-source results. Full ordinary/specialized browser, provider/PDF closure, local C/D activation and final handoff remain pending.
|
||||||
|
|
||||||
|
## 28 October checkpoint
|
||||||
|
|
||||||
28 October 2026. Cumulative branch `test/pre-phase10-ux-acceptance` contains A/B/C/D through source `d52403d`, test-fixture correction `4dcc07b` and security evidence `019d0aa`. This is an intermediate evidence checkpoint, not final acceptance or permission for Phase 10 implementation.
|
28 October 2026. Cumulative branch `test/pre-phase10-ux-acceptance` contains A/B/C/D through source `d52403d`, test-fixture correction `4dcc07b` and security evidence `019d0aa`. This is an intermediate evidence checkpoint, not final acceptance or permission for Phase 10 implementation.
|
||||||
|
|
||||||
The verified preceding CV-QA baseline is backend 1,336, frontend 995 in 109 suites, ordinary browser 364, specialized browser configurations 53, real MariaDB 80 and Python 76 offline plus 14 real parser tests. Historical evidence remains in `docs/cv-integration-qa/`; CV-UX commit `4a0af62` is an ancestor. The newer C/D focused evidence must not be added to overlapping full-suite counts.
|
The verified preceding CV-QA baseline is backend 1,336, frontend 995 in 109 suites, ordinary browser 364, specialized browser configurations 53, real MariaDB 80 and Python 76 offline plus 14 real parser tests. Historical evidence remains in `docs/cv-integration-qa/`; CV-UX commit `4a0af62` is an ancestor. The newer C/D focused evidence must not be added to overlapping full-suite counts.
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ No main merge, production service/database/storage/model/cache/deployment operat
|
|||||||
|
|
||||||
## Checkpoints
|
## Checkpoints
|
||||||
|
|
||||||
|
- [x] D-owner-addendum: actual pre-paging diagnostic filters, bounded read policy, owner/import identifiers, permitted job navigation and source-based failure times; RED/GREEN plus backend1447/frontend1031/MariaDB90/browser4/type/lint/build. See D_ADDENDUM_ACCEPTANCE.md. Final cumulative qualification remains below.
|
||||||
|
|
||||||
- [x] P10-UX-00: read owner specification, verify latest CV-QA ancestry/remote/entry, isolate managed worktree, audit four owners and choose compatibility-first design. Create feedback register and branch plan.
|
- [x] P10-UX-00: read owner specification, verify latest CV-QA ancestry/remote/entry, isolate managed worktree, audit four owners and choose compatibility-first design. Create feedback register and branch plan.
|
||||||
- [x] A-01: reproduce blank hydration/header/export and Strict Mode history failures in focused tests plus actual local development browser. Save RED evidence; no application fix before reproduction.
|
- [x] A-01: reproduce blank hydration/header/export and Strict Mode history failures in focused tests plus actual local development browser. Save RED evidence; no application fix before reproduction.
|
||||||
- [x] A-02: nullable explicit inclusion flags across resolver/editor/AI/matcher, deliberate creation/profile selection, empty export validation; pure immutable grouped session history/shortcuts/epoch guards; focused unit/provider/browser and RD21 checks, review, docs, commit/push/verify. Evidence: A_ACCEPTANCE.md, backend108/frontend98/browser4/MariaDB82, TypeScript/lint0; fresh review fixes have RED/GREEN tests. Commit is the source-bearing checkpoint immediately following5d4b8f2.
|
- [x] A-02: nullable explicit inclusion flags across resolver/editor/AI/matcher, deliberate creation/profile selection, empty export validation; pure immutable grouped session history/shortcuts/epoch guards; focused unit/provider/browser and RD21 checks, review, docs, commit/push/verify. Evidence: A_ACCEPTANCE.md, backend108/frontend98/browser4/MariaDB82, TypeScript/lint0; fresh review fixes have RED/GREEN tests. Commit is the source-bearing checkpoint immediately following5d4b8f2.
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Cumulative security qualification
|
# Cumulative security qualification
|
||||||
|
|
||||||
|
29 October addendum: the subsequent Admin diagnostic filters, account read-rate policy, identifiers, owner-permitted job navigation and retained failure-time projection were separately reviewed against the current source. The reviewer identified a completion-time/failure-time mix-up; retained typed owner-bound failure events now supply the actual timestamp and stage, with null for unknown recovered history. Regression reproduction plus backend 1,447, frontend 1,031, real MariaDB 90 and four focused browser cases passed. No further correctness/security blocker was identified by the follow-up review. This later delta is outside the sealed scan below; it is not represented as a second sealed scan. See `D_ADDENDUM_ACCEPTANCE.md`.
|
||||||
|
|
||||||
28 October 2026. Static frozen-diff review, not production security acceptance.
|
28 October 2026. Static frozen-diff review, not production security acceptance.
|
||||||
|
|
||||||
Codex Security scan `9772bf28-2e7a-4061-8e31-b016e1056f1b` completed and sealed for range `4a8c907d70cbed235728f7d690b5409e72b93abe..d52403dba873a17c83cbc18f0f87d544b72bd3d2`. All 136 changed files are accounted for: 58 server/test/provider paths, 48 frontend/test/configuration paths, and 30 local operator/documentation/Python/proof paths. The compact tool inventory contained 106 paths; the remaining Git changes were reviewed explicitly. No plausible new candidates or reportable findings remained. No application execution, production access or network fetch was performed by security reviewers.
|
Codex Security scan `9772bf28-2e7a-4061-8e31-b016e1056f1b` completed and sealed for range `4a8c907d70cbed235728f7d690b5409e72b93abe..d52403dba873a17c83cbc18f0f87d544b72bd3d2`. All 136 changed files are accounted for: 58 server/test/provider paths, 48 frontend/test/configuration paths, and 30 local operator/documentation/Python/proof paths. The compact tool inventory contained 106 paths; the remaining Git changes were reviewed explicitly. No plausible new candidates or reportable findings remained. No application execution, production access or network fetch was performed by security reviewers.
|
||||||
|
|||||||
@@ -48,9 +48,19 @@ test('[D] Real administrator/owner boundaries, actual cancellation history and r
|
|||||||
await page.context().route('**/*', route => ['localhost', '127.0.0.1'].includes(new URL(route.request().url()).hostname) ? route.continue() : route.abort());
|
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.goto('/admin/processing');
|
||||||
await page.getByLabel('Owner ID', { exact: true }).fill(owner.id);
|
await page.getByLabel('Owner ID', { exact: true }).fill(owner.id);
|
||||||
|
await page.getByLabel('Search users, jobs or task ID', { exact: true }).fill(id);
|
||||||
|
await page.getByLabel('Worker filter', { exact: true }).selectOption('untracked');
|
||||||
|
await page.getByLabel('Submitted from (UTC)', { exact: true }).fill('2020-01-01');
|
||||||
|
await page.getByLabel('Submitted before (UTC)', { exact: true }).fill('2100-01-01');
|
||||||
|
await page.getByLabel('Application ID', { exact: true }).fill('2147483647');
|
||||||
|
await page.getByRole('button', { name: 'Apply filters', exact: true }).click();
|
||||||
|
await expect(page.getByText('No processing jobs match these filters.', { exact: true })).toBeVisible();
|
||||||
|
await page.getByLabel('Application ID', { exact: true }).fill('');
|
||||||
await page.getByRole('button', { name: 'Apply filters', exact: true }).click();
|
await page.getByRole('button', { name: 'Apply filters', exact: true }).click();
|
||||||
await page.getByRole('button', { name: `View processing details ${id}`, 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.getByLabel('Correlation ID', { exact: true })).toHaveValue(id);
|
||||||
|
await expect(page.getByLabel('Submitting user ID', { exact: true })).toHaveValue(owner.id);
|
||||||
|
await expect(page.getByText('No linked job', { exact: true })).toBeVisible();
|
||||||
await expect(page.getByText('No retained events. Older jobs may predate event recording.', { exact: true })).toBeVisible();
|
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 });
|
const cancellation = await ownerClient.post(`${api}/operations/${id}/cancel`, { headers: owner.headers });
|
||||||
expect(cancellation.status()).toBe(200);
|
expect(cancellation.status()).toBe(200);
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ for (const [language, mode] of [['en', 'light'], ['nb', 'dark']] as const) {
|
|||||||
await fs.writeFile(`${extension}/background.js`, 'globalThis.auditZoomReady = true;');
|
await fs.writeFile(`${extension}/background.js`, 'globalThis.auditZoomReady = true;');
|
||||||
const context = await playwright.chromium.launchPersistentContext(info.outputPath('local-browser-profile'), {
|
const context = await playwright.chromium.launchPersistentContext(info.outputPath('local-browser-profile'), {
|
||||||
channel: 'chromium', headless: true, viewport: null, deviceScaleFactor: undefined,
|
channel: 'chromium', headless: true, viewport: null, deviceScaleFactor: undefined,
|
||||||
|
// The bundled browser otherwise disables the extension that owns real tab zoom.
|
||||||
|
ignoreDefaultArgs: ['--disable-extensions'],
|
||||||
args: ['--window-size=1280,900', `--disable-extensions-except=${extension}`, `--load-extension=${extension}`],
|
args: ['--window-size=1280,900', `--disable-extensions-except=${extension}`, `--load-extension=${extension}`],
|
||||||
});
|
});
|
||||||
const admin = await playwright.request.newContext();
|
const admin = await playwright.request.newContext();
|
||||||
@@ -65,6 +67,7 @@ for (const [language, mode] of [['en', 'light'], ['nb', 'dark']] as const) {
|
|||||||
let createdAdminId: string | undefined;
|
let createdAdminId: string | undefined;
|
||||||
const records: unknown[] = [];
|
const records: unknown[] = [];
|
||||||
try {
|
try {
|
||||||
|
const worker = context.serviceWorkers()[0] || await context.waitForEvent('serviceworker');
|
||||||
await context.route('**/*', route => {
|
await context.route('**/*', route => {
|
||||||
const url = new URL(route.request().url());
|
const url = new URL(route.request().url());
|
||||||
return ['localhost', '127.0.0.1'].includes(url.hostname) || ['data:', 'blob:', 'about:'].includes(url.protocol) ? route.continue() : route.abort();
|
return ['localhost', '127.0.0.1'].includes(url.hostname) || ['data:', 'blob:', 'about:'].includes(url.protocol) ? route.continue() : route.abort();
|
||||||
@@ -77,7 +80,6 @@ for (const [language, mode] of [['en', 'light'], ['nb', 'dark']] as const) {
|
|||||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||||
await page.goto('http://localhost:3300/login');
|
await page.goto('http://localhost:3300/login');
|
||||||
await expect(page.getByRole('heading', { name: translations[language].signInTitle, exact: true })).toBeVisible();
|
await expect(page.getByRole('heading', { name: translations[language].signInTitle, exact: true })).toBeVisible();
|
||||||
const worker = context.serviceWorkers()[0] || await context.waitForEvent('serviceworker');
|
|
||||||
const zoom = async (factor: number) => {
|
const zoom = async (factor: number) => {
|
||||||
const result = await worker.evaluate(async factor => {
|
const result = await worker.evaluate(async factor => {
|
||||||
// The extension changes real tab zoom, rather than emulating a narrow viewport or pinch.
|
// The extension changes real tab zoom, rather than emulating a narrow viewport or pinch.
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ test('real Docling produces owner-scoped administrator history without exposing
|
|||||||
const detail = await (await admin.get(`${api}/admin/processing/${id}`)).json();
|
const detail = await (await admin.get(`${api}/admin/processing/${id}`)).json();
|
||||||
expect(detail.owner.id).toBe(owner.id);
|
expect(detail.owner.id).toBe(owner.id);
|
||||||
expect(detail.document.name).toBe('dev-en-simple.pdf');
|
expect(detail.document.name).toBe('dev-en-simple.pdf');
|
||||||
|
expect(detail.document.importId).toBe(run.id);
|
||||||
expect(detail.document.pages).toBeGreaterThan(0);
|
expect(detail.document.pages).toBeGreaterThan(0);
|
||||||
expect(detail.worker).toBe('document');
|
expect(detail.worker).toBe('document');
|
||||||
expect(detail.workerEnabled).toBe(true);
|
expect(detail.workerEnabled).toBe(true);
|
||||||
@@ -41,6 +42,9 @@ test('real Docling produces owner-scoped administrator history without exposing
|
|||||||
await page.getByRole('button', { name: `View processing details ${id}`, 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.getByText(/^Processing completed/)).toBeVisible();
|
||||||
await expect(page.getByLabel('Correlation ID', { exact: true })).toHaveValue(id);
|
await expect(page.getByLabel('Correlation ID', { exact: true })).toHaveValue(id);
|
||||||
|
await expect(page.getByLabel('Submitting user ID', { exact: true })).toHaveValue(owner.id);
|
||||||
|
await expect(page.getByText(`Import ID: ${run.id}`, { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText('No linked job', { exact: true })).toBeVisible();
|
||||||
await page.screenshot({ path: info.outputPath('real-docling-processing-history.png'), fullPage: true });
|
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' });
|
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(); }
|
} finally { await admin.dispose(); }
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { api } from './api';
|
|||||||
import { setAuthUserKey } from './auth';
|
import { setAuthUserKey } from './auth';
|
||||||
|
|
||||||
const get = api.get as jest.Mock;
|
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 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: { importId: 7, 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' };
|
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>); }
|
function open() { return render(<MemoryRouter><I18nProvider><AdminProcessingPage /></I18nProvider></MemoryRouter>); }
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -31,6 +31,57 @@ test('shows safe job metadata and real events with a copyable correlation ID', a
|
|||||||
expect(screen.queryByText(/SECRET/)).not.toBeInTheDocument();
|
expect(screen.queryByText(/SECRET/)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('submits diagnostic filters and displays explicit owner, import and unrelated-job metadata', async () => {
|
||||||
|
open();
|
||||||
|
await screen.findByText('Synthetic owner');
|
||||||
|
fireEvent.change(screen.getByLabelText('Search users, jobs or task ID'), { target: { value: 'Owned job' } });
|
||||||
|
fireEvent.change(screen.getByLabelText('Application ID'), { target: { value: '2' } });
|
||||||
|
fireEvent.change(screen.getByLabelText('Submitted from (UTC)'), { target: { value: '2026-10-01' } });
|
||||||
|
fireEvent.change(screen.getByLabelText('Submitted before (UTC)'), { target: { value: '2026-11-01' } });
|
||||||
|
fireEvent.change(screen.getByLabelText('Worker filter'), { target: { value: 'document' } });
|
||||||
|
fireEvent.change(screen.getByLabelText('Failure code'), { target: { value: 'timeout' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Apply filters' }));
|
||||||
|
await waitFor(() => expect(get.mock.calls.some(([url]) => {
|
||||||
|
const query = new URLSearchParams(url.split('?')[1]);
|
||||||
|
return query.get('q') === 'Owned job' && query.get('jobId') === '2' && query.get('worker') === 'document'
|
||||||
|
&& query.get('failureCode') === 'timeout' && query.get('createdFrom') === '2026-10-01T00:00:00Z'
|
||||||
|
&& query.get('createdBefore') === '2026-11-01T00:00:00Z';
|
||||||
|
})).toBe(true));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /View processing details/ }));
|
||||||
|
expect(await screen.findByLabelText('Submitting user ID')).toHaveValue('owner');
|
||||||
|
expect(screen.getByText('No linked job')).toBeVisible();
|
||||||
|
expect(screen.getByText('Import ID: 7')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows stable failure evidence and links an application only when the server permits it', async () => {
|
||||||
|
const selected = { ...item, status: 'failed', failureCode: 'timeout', completedAtUtc: '2026-10-28T12:00:08Z', job: { id: 2, title: 'Owned job', company: 'Synthetic company', canOpen: false } };
|
||||||
|
get.mockImplementation((url: string) => Promise.resolve({ data: url.includes('/events') ? { items: [], hasMore: false, retentionDays: 30 }
|
||||||
|
: url.includes(item.id) ? selected : listing }));
|
||||||
|
open();
|
||||||
|
await screen.findByText('Synthetic owner');
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /View processing details/ }));
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent('Failure code: timeout');
|
||||||
|
expect(screen.getByRole('alert')).toHaveTextContent('Whether this failure is transient or permanent has not been established.');
|
||||||
|
expect(screen.queryByRole('link', { name: 'Owned job' })).not.toBeInTheDocument();
|
||||||
|
selected.job.canOpen = true;
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Refresh' }));
|
||||||
|
expect(await screen.findByRole('link', { name: 'Owned job' })).toHaveAttribute('href', '/jobs/2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a successful retry keeps prior failure evidence separate from its success timestamp', async () => {
|
||||||
|
const failedAt = '2026-10-28T12:01:00Z';
|
||||||
|
const completedAt = '2026-10-28T12:05:00Z';
|
||||||
|
const selected = { ...item, status: 'succeeded', failureCode: 'timeout', completedAtUtc: completedAt, lastFailureAtUtc: failedAt, lastFailureStage: 'cv_extracting' };
|
||||||
|
get.mockImplementation((url: string) => Promise.resolve({ data: url.includes('/events') ? { items: [], hasMore: false, retentionDays: 30 }
|
||||||
|
: url.includes(item.id) ? selected : listing }));
|
||||||
|
open(); await screen.findByText('Synthetic owner');
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /View processing details/ }));
|
||||||
|
const alert = await screen.findByRole('alert');
|
||||||
|
expect(alert).toHaveTextContent('Previous failure: Processing timed out.');
|
||||||
|
expect(alert).toHaveTextContent(new Date(failedAt).toLocaleString('en-GB'));
|
||||||
|
expect(alert).not.toHaveTextContent(new Date(completedAt).toLocaleString('en-GB'));
|
||||||
|
});
|
||||||
|
|
||||||
test('pause stops polling and resume uses an exact string event cursor', async () => {
|
test('pause stops polling and resume uses an exact string event cursor', async () => {
|
||||||
jest.useFakeTimers(); open();
|
jest.useFakeTimers(); open();
|
||||||
await act(async () => { await Promise.resolve(); });
|
await act(async () => { await Promise.resolve(); });
|
||||||
@@ -65,6 +116,16 @@ test('malformed successful responses produce a recoverable error instead of rend
|
|||||||
expect(screen.queryByText(/SECRET/)).not.toBeInTheDocument();
|
expect(screen.queryByText(/SECRET/)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('invalid filters explain the rejected request and stop automatic retries', async () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
get.mockRejectedValue({ response: { status: 400 } });
|
||||||
|
open(); await act(async () => { await Promise.resolve(); });
|
||||||
|
expect(screen.getByRole('alert')).toHaveTextContent('Check the filter values');
|
||||||
|
const count = get.mock.calls.length;
|
||||||
|
await act(async () => { jest.advanceTimersByTime(30000); });
|
||||||
|
expect(get).toHaveBeenCalledTimes(count);
|
||||||
|
});
|
||||||
|
|
||||||
test('identity changes abort pending reads and clear the previous administrator view', async () => {
|
test('identity changes abort pending reads and clear the previous administrator view', async () => {
|
||||||
let finish!: (value: unknown) => void;
|
let finish!: (value: unknown) => void;
|
||||||
get.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
|
get.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
|
||||||
|
|||||||
@@ -21,6 +21,21 @@ export const translations = {
|
|||||||
processingNext: "Next page",
|
processingNext: "Next page",
|
||||||
processingPage: "Page {page} of {total}",
|
processingPage: "Page {page} of {total}",
|
||||||
processingOwner: "Owner",
|
processingOwner: "Owner",
|
||||||
|
processingSearch: "Search users, jobs or task ID",
|
||||||
|
processingInvalidFilters: "Check the filter values and ensure the before date follows the from date.",
|
||||||
|
processingApplicationId: "Application ID",
|
||||||
|
processingCreatedFrom: "Submitted from (UTC)",
|
||||||
|
processingCreatedBefore: "Submitted before (UTC)",
|
||||||
|
processingWorkerFilter: "Worker filter",
|
||||||
|
processingFailureCode: "Failure code",
|
||||||
|
processingElapsed: "Duration",
|
||||||
|
processingSubmittingUserId: "Submitting user ID",
|
||||||
|
processingNoLinkedJob: "No linked job",
|
||||||
|
processingImportId: "Import ID",
|
||||||
|
processingFailureAt: "Failure timestamp",
|
||||||
|
processingPreviousFailure: "Previous failure",
|
||||||
|
processingRetryScheduled: "A retry is scheduled; see the status and timeline.",
|
||||||
|
processingRetryUnknown: "Whether this failure is transient or permanent has not been established.",
|
||||||
processingStatus: "Status",
|
processingStatus: "Status",
|
||||||
processingTask: "Task",
|
processingTask: "Task",
|
||||||
processingAll: "All",
|
processingAll: "All",
|
||||||
@@ -3127,6 +3142,21 @@ export const translations = {
|
|||||||
processingNext: "Neste side",
|
processingNext: "Neste side",
|
||||||
processingPage: "Side {page} av {total}",
|
processingPage: "Side {page} av {total}",
|
||||||
processingOwner: "Eier",
|
processingOwner: "Eier",
|
||||||
|
processingSearch: "Søk etter bruker, jobb eller oppgave-ID",
|
||||||
|
processingInvalidFilters: "Kontroller filterverdiene. Før-datoen må være etter fra-datoen.",
|
||||||
|
processingApplicationId: "Søknads-ID",
|
||||||
|
processingCreatedFrom: "Sendt inn fra (UTC)",
|
||||||
|
processingCreatedBefore: "Sendt inn før (UTC)",
|
||||||
|
processingWorkerFilter: "Prosessfilter",
|
||||||
|
processingFailureCode: "Feilkode",
|
||||||
|
processingElapsed: "Varighet",
|
||||||
|
processingSubmittingUserId: "Innsenderens bruker-ID",
|
||||||
|
processingNoLinkedJob: "Ingen tilknyttet jobb",
|
||||||
|
processingImportId: "Import-ID",
|
||||||
|
processingFailureAt: "Tidspunkt for feil",
|
||||||
|
processingPreviousFailure: "Tidligere feil",
|
||||||
|
processingRetryScheduled: "Et nytt forsøk er planlagt. Se status og tidslinje.",
|
||||||
|
processingRetryUnknown: "Det er ikke fastslått om feilen er midlertidig eller varig.",
|
||||||
processingStatus: "Status",
|
processingStatus: "Status",
|
||||||
processingTask: "Oppgave",
|
processingTask: "Oppgave",
|
||||||
processingAll: "Alle",
|
processingAll: "Alle",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
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 { Alert, Box, Button, Chip, Link, Stack, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TextField, Typography } from '@mui/material';
|
||||||
|
import { Link as RouterLink } from 'react-router-dom';
|
||||||
import { api } from '../api';
|
import { api } from '../api';
|
||||||
import { AUTH_USER_CHANGED_EVENT, getAuthUserKey } from '../auth';
|
import { AUTH_USER_CHANGED_EVENT, getAuthUserKey } from '../auth';
|
||||||
import { isSessionInvalidated, SESSION_INVALIDATED_EVENT } from '../sessionBoundary';
|
import { isSessionInvalidated, SESSION_INVALIDATED_EVENT } from '../sessionBoundary';
|
||||||
@@ -11,8 +12,9 @@ type Item = {
|
|||||||
id: string; owner: { id: string; name: string | null; email: string | null }; taskType: string; status: string; stage: string | null;
|
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;
|
attemptCount: number; maxAttempts: number; createdAtUtc: string; startedAtUtc: string | null; completedAtUtc: string | null;
|
||||||
lastHeartbeatAtUtc: string | null; durationSeconds: number; worker: string; workerEnabled: boolean; failureCode: string | null;
|
lastHeartbeatAtUtc: string | null; durationSeconds: number; worker: string; workerEnabled: boolean; failureCode: string | null;
|
||||||
job: { id: number; title: string; company: string | null } | null;
|
lastFailureAtUtc: string | null; lastFailureStage: string | null;
|
||||||
document: { name: string; type: string | null; bytes: number; pages: number | null; state: string | null } | null;
|
job: { id: number; title: string; company: string | null; canOpen: boolean } | null;
|
||||||
|
document: { importId: number; 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 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 Event = { id: string; atUtc: string; kind: string; status: string; stage: string | null; attempt: number; failureCode: string | null };
|
||||||
@@ -22,6 +24,7 @@ const tasks: Record<string, TranslationKey> = { 'cv.process': 'operationsTaskCv'
|
|||||||
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 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 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' };
|
const workers: Record<string, TranslationKey> = { AiOperations: 'processingWorkerAi', CvDocumentImports: 'processingWorkerDocument', JobEnrichment: 'processingWorkerEnrichment', Rules: 'processingWorkerRules', FollowUpReminders: 'processingWorkerReminders', DailyExport: 'processingWorkerExport', SavedJobSearchAlerts: 'processingWorkerSearch' };
|
||||||
|
const failureCodes = ['timeout', 'lease_expired', 'deadline_exceeded', 'entitlement_changed', 'document_unavailable', 'sidecar_unavailable', 'upstream_unavailable', 'invalid_response', 'circuit_open', 'rate_limited', 'permission_denied', 'cancelled', 'upload_rejected', 'parser_failed', 'malformed_output', 'provider_unavailable', 'processing_failed'];
|
||||||
|
|
||||||
export default function AdminProcessingPage() {
|
export default function AdminProcessingPage() {
|
||||||
const { t, language } = useI18n();
|
const { t, language } = useI18n();
|
||||||
@@ -31,7 +34,7 @@ export default function AdminProcessingPage() {
|
|||||||
const [events, setEvents] = useState<Event[]>([]);
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
const [hasOlder, setHasOlder] = useState(false);
|
const [hasOlder, setHasOlder] = useState(false);
|
||||||
const [before, setBefore] = useState<string | null>(null);
|
const [before, setBefore] = useState<string | null>(null);
|
||||||
const [filters, setFilters] = useState({ status: '', taskType: '', owner: '' });
|
const [filters, setFilters] = useState({ status: '', taskType: '', owner: '', q: '', jobId: '', worker: '', failureCode: '', createdFrom: '', createdBefore: '' });
|
||||||
const [inputs, setInputs] = useState(filters);
|
const [inputs, setInputs] = useState(filters);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [paused, setPaused] = useState(false);
|
const [paused, setPaused] = useState(false);
|
||||||
@@ -39,7 +42,7 @@ export default function AdminProcessingPage() {
|
|||||||
const [refresh, setRefresh] = useState(0);
|
const [refresh, setRefresh] = useState(0);
|
||||||
const [identity, setIdentity] = useState(getAuthUserKey);
|
const [identity, setIdentity] = useState(getAuthUserKey);
|
||||||
const [busy, setBusy] = useState(true);
|
const [busy, setBusy] = useState(true);
|
||||||
const [error, setError] = useState<'denied' | 'unavailable' | null>(null);
|
const [error, setError] = useState<'denied' | 'unavailable' | 'invalid' | null>(null);
|
||||||
const [updated, setUpdated] = useState<string | null>(null);
|
const [updated, setUpdated] = useState<string | null>(null);
|
||||||
const [copy, setCopy] = useState<'copied' | 'failed' | null>(null);
|
const [copy, setCopy] = useState<'copied' | 'failed' | null>(null);
|
||||||
const detailHeading = useRef<HTMLHeadingElement>(null);
|
const detailHeading = useRef<HTMLHeadingElement>(null);
|
||||||
@@ -73,7 +76,7 @@ export default function AdminProcessingPage() {
|
|||||||
let delay = 5000;
|
let delay = 5000;
|
||||||
try {
|
try {
|
||||||
const query = new URLSearchParams({ page: String(page), pageSize: '25' });
|
const query = new URLSearchParams({ page: String(page), pageSize: '25' });
|
||||||
for (const [key, value] of Object.entries(filters)) if (value) query.set(key, value);
|
for (const [key, value] of Object.entries(filters)) if (value) query.set(key, key === 'createdFrom' || key === 'createdBefore' ? `${value}T00:00:00Z` : value);
|
||||||
const eventQuery = new URLSearchParams({ take: '100' });
|
const eventQuery = new URLSearchParams({ take: '100' });
|
||||||
if (before) eventQuery.set('before', before);
|
if (before) eventQuery.set('before', before);
|
||||||
else if (cursor) eventQuery.set('after', cursor);
|
else if (cursor) eventQuery.set('after', cursor);
|
||||||
@@ -110,7 +113,8 @@ export default function AdminProcessingPage() {
|
|||||||
const status = (cause as { response?: { status?: number } }).response?.status;
|
const status = (cause as { response?: { status?: number } }).response?.status;
|
||||||
if (status === 401 || status === 403) {
|
if (status === 401 || status === 403) {
|
||||||
setListing(null); setDetail(null); setEvents([]); setError('denied'); retry = false;
|
setListing(null); setDetail(null); setEvents([]); setError('denied'); retry = false;
|
||||||
} else { setError('unavailable'); delay = 15000; }
|
} else if (status === 400) { setError('invalid'); retry = false; }
|
||||||
|
else { setError('unavailable'); delay = 15000; }
|
||||||
} finally {
|
} finally {
|
||||||
if (current()) {
|
if (current()) {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
@@ -135,10 +139,10 @@ export default function AdminProcessingPage() {
|
|||||||
<Stack direction="row" useFlexGap flexWrap="wrap" spacing={1} alignItems="center">
|
<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={() => { 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>
|
<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>
|
<Typography role="status" aria-live="polite">{t(error === 'denied' ? 'processingDenied' : error === 'invalid' ? 'processingInvalidFilters' : paused ? 'processingPaused' : error ? 'processingReconnecting' : 'processingConnected')}</Typography>
|
||||||
{updated ? <Typography variant="caption">{t('processingLastUpdated', { time: date(updated) })}</Typography> : null}
|
{updated ? <Typography variant="caption">{t('processingLastUpdated', { time: date(updated) })}</Typography> : null}
|
||||||
</Stack>
|
</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}
|
{error ? <Alert severity="error" action={error === 'unavailable' ? <Button onClick={() => setRefresh(value => value + 1)}>{t('retry')}</Button> : undefined}>{t(error === 'denied' ? 'processingDenied' : error === 'invalid' ? 'processingInvalidFilters' : 'processingLoadFailed')}</Alert> : null}
|
||||||
{busy && !visibleListing ? <Typography role="status">{t('loading')}</Typography> : 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() }); }}>
|
<Box component="form" onSubmit={event => { event.preventDefault(); resetSelection(); setPage(1); setFilters({ ...inputs, owner: inputs.owner.trim() }); }}>
|
||||||
<Stack direction="row" useFlexGap flexWrap="wrap" spacing={2}>
|
<Stack direction="row" useFlexGap flexWrap="wrap" spacing={2}>
|
||||||
@@ -149,6 +153,16 @@ export default function AdminProcessingPage() {
|
|||||||
<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>
|
<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>
|
||||||
<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 }} />
|
<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 }} />
|
||||||
|
<TextField label={t('processingSearch')} value={inputs.q} onChange={event => setInputs(value => ({ ...value, q: event.target.value }))} slotProps={{ htmlInput: { maxLength: 200 } }} />
|
||||||
|
<TextField type="number" label={t('processingApplicationId')} value={inputs.jobId} onChange={event => setInputs(value => ({ ...value, jobId: event.target.value }))} slotProps={{ htmlInput: { min: 1, max: 2147483647 } }} />
|
||||||
|
<TextField type="date" label={t('processingCreatedFrom')} value={inputs.createdFrom} onChange={event => setInputs(value => ({ ...value, createdFrom: event.target.value }))} slotProps={{ inputLabel: { shrink: true } }} />
|
||||||
|
<TextField type="date" label={t('processingCreatedBefore')} value={inputs.createdBefore} onChange={event => setInputs(value => ({ ...value, createdBefore: event.target.value }))} slotProps={{ inputLabel: { shrink: true } }} />
|
||||||
|
<TextField select label={t('processingWorkerFilter')} value={inputs.worker} onChange={event => setInputs(value => ({ ...value, worker: event.target.value }))} slotProps={{ select: { native: true } }}>
|
||||||
|
<option value="">{t('processingAll')}</option>{['document', 'ai', 'untracked'].map(value => <option key={value} value={value}>{worker(value)}</option>)}
|
||||||
|
</TextField>
|
||||||
|
<TextField select label={t('processingFailureCode')} value={inputs.failureCode} onChange={event => setInputs(value => ({ ...value, failureCode: event.target.value }))} slotProps={{ select: { native: true } }}>
|
||||||
|
<option value="">{t('processingAll')}</option>{failureCodes.map(value => <option key={value} value={value}>{value}</option>)}
|
||||||
|
</TextField>
|
||||||
<Button type="submit" variant="outlined">{t('processingApplyFilters')}</Button>
|
<Button type="submit" variant="outlined">{t('processingApplyFilters')}</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -156,19 +170,20 @@ export default function AdminProcessingPage() {
|
|||||||
<Stack direction="row" useFlexGap flexWrap="wrap" spacing={1}>{Object.entries(visibleListing.counts).map(([key, count]) => <Chip key={key} label={`${state(key)}: ${count}`} />)}</Stack>
|
<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>
|
<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%' }}>
|
{!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>
|
<Table aria-label={t('processingJobs')} sx={{ minWidth: 800 }}><TableHead><TableRow>{['processingTask', 'processingOwner', 'processingStatus', 'processingWorker', 'processingCreated', 'processingElapsed'].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}>
|
<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 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><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>{state(item.status)}<Typography variant="caption" display="block">{stage(item.stage)}</Typography>{item.failureCode && ['failed', 'waiting_for_retry', 'waiting_for_external_fallback'].includes(item.status) ? <Chip size="small" color="warning" label={failure(item.failureCode)} /> : null}</TableCell>
|
||||||
<TableCell>{worker(item.worker)}<Typography variant="caption" display="block">{t(item.workerEnabled ? 'processingEnabled' : 'processingDisabled')}</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>
|
<TableCell><time dateTime={item.createdAtUtc}>{date(item.createdAtUtc)}</time></TableCell>
|
||||||
|
<TableCell>{t('processingDuration', { count: Math.round(item.durationSeconds) })}</TableCell>
|
||||||
</TableRow>)}</TableBody></Table>
|
</TableRow>)}</TableBody></Table>
|
||||||
</TableContainer>}
|
</TableContainer>}
|
||||||
<Stack direction="row" spacing={1} useFlexGap flexWrap="wrap" alignItems="center" sx={{ mt: 2 }}>
|
<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>
|
<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>
|
<Typography>{t('processingPage', { page, total: Math.max(1, Math.min(1000, Math.ceil(visibleListing.total / 25))) })}</Typography>
|
||||||
<Button disabled={page * 25 >= visibleListing.total || busy} onClick={() => { resetSelection(); setPage(value => value + 1); }}>{t('processingNext')}</Button>
|
<Button disabled={page >= 1000 || page * 25 >= visibleListing.total || busy} onClick={() => { resetSelection(); setPage(value => value + 1); }}>{t('processingNext')}</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</PageSection>
|
</PageSection>
|
||||||
<Box component="details"><Box component="summary" sx={{ p: 1, cursor: 'pointer' }}>{t('processingWorkerFlags')}</Box>
|
<Box component="details"><Box component="summary" sx={{ p: 1, cursor: 'pointer' }}>{t('processingWorkerFlags')}</Box>
|
||||||
@@ -184,13 +199,14 @@ export default function AdminProcessingPage() {
|
|||||||
{copy ? <Typography role="status">{t(copy === 'copied' ? 'processingCopied' : 'processingCopyFailed')}</Typography> : null}
|
{copy ? <Typography role="status">{t(copy === 'copied' ? 'processingCopied' : 'processingCopyFailed')}</Typography> : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
<Typography>{detail.owner.name ?? detail.owner.id} · {detail.owner.email} · {state(detail.status)} · {stage(detail.stage)}</Typography>
|
<Typography>{detail.owner.name ?? detail.owner.id} · {detail.owner.email} · {state(detail.status)} · {stage(detail.stage)}</Typography>
|
||||||
|
<TextField label={t('processingSubmittingUserId')} value={detail.owner.id} slotProps={{ input: { readOnly: true } }} />
|
||||||
<Typography>{t('processingAttempts', { count: detail.attemptCount, max: detail.maxAttempts })} · {t('processingDuration', { count: Math.round(detail.durationSeconds) })}</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 }}>
|
<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>)}
|
{([['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>
|
</Box>
|
||||||
{detail.failureCode ? <Alert severity="warning">{failure(detail.failureCode)}</Alert> : null}
|
{detail.failureCode ? <Alert severity="warning"><Typography>{!['failed', 'waiting_for_retry', 'waiting_for_external_fallback'].includes(detail.status) ? `${t('processingPreviousFailure')}: ` : ''}{failure(detail.failureCode)}</Typography><Typography variant="body2">{t('processingFailureCode')}: {detail.failureCode} · {t('processingFailureAt')}: {date(detail.lastFailureAtUtc)} · {stage(detail.lastFailureStage)}</Typography><Typography variant="body2">{t(detail.status === 'waiting_for_retry' ? 'processingRetryScheduled' : 'processingRetryUnknown')}</Typography></Alert> : null}
|
||||||
{detail.job ? <Typography>{detail.job.title} · {detail.job.company} · #{detail.job.id}</Typography> : null}
|
{detail.job ? <Typography>{detail.job.canOpen ? <Link component={RouterLink} to={`/jobs/${detail.job.id}`}>{detail.job.title}</Link> : detail.job.title} · {detail.job.company} · #{detail.job.id}</Typography> : <Typography>{t('processingNoLinkedJob')}</Typography>}
|
||||||
{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}
|
{detail.document ? <Box><Typography>{t('processingImportId')}: {detail.document.importId}</Typography><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 component="h3" variant="h6">{t('processingEvents')}</Typography>
|
||||||
<Typography variant="body2">{t('processingRetention')}</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>}
|
{!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>}
|
||||||
|
|||||||
Reference in New Issue
Block a user