From a9c00954e66c337af9686ef9221af69da6aa0a4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enes=20Ya=C4=9F=C4=B1z?= Date: Tue, 20 Oct 2026 01:49:50 +0300 Subject: [PATCH] feat(admin): show job type, target and trigger in the jobs panel The jobs panel now names each Hangfire job in plain words, shows the package or record it works on, and records what enqueued it: a schedule, an HTTP request, or another job. Co-Authored-By: Claude Opus 5.5 --- .../HostingServiceCollectionExtensions.cs | 5 +- .../PkgDiff.API/Filters/JobTriggerFilter.cs | 50 ++++++++++ .../Jobs/JobDescriptions.cs | 80 ++++++++++++++++ .../PkgDiff.Application/Jobs/JobTarget.cs | 8 ++ .../Services/AdminJobService.cs | 73 +++++++++------ .../PkgDiff.Shared/DTOs/AdminModels.cs | 8 ++ .../app/features/admin/jobs/admin-jobs.html | 93 ++++++++++++++----- .../src/app/features/admin/jobs/admin-jobs.ts | 24 ++++- 8 files changed, 285 insertions(+), 56 deletions(-) create mode 100644 src/backend/PkgDiff.API/Filters/JobTriggerFilter.cs create mode 100644 src/backend/PkgDiff.Application/Jobs/JobDescriptions.cs create mode 100644 src/backend/PkgDiff.Application/Jobs/JobTarget.cs diff --git a/src/backend/PkgDiff.API/Extensions/HostingServiceCollectionExtensions.cs b/src/backend/PkgDiff.API/Extensions/HostingServiceCollectionExtensions.cs index 951ebeb..a7b0a04 100644 --- a/src/backend/PkgDiff.API/Extensions/HostingServiceCollectionExtensions.cs +++ b/src/backend/PkgDiff.API/Extensions/HostingServiceCollectionExtensions.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; +using PkgDiff.API.Filters; using PkgDiff.API.Jobs; using PkgDiff.API.Middleware; using PkgDiff.API.Options; @@ -84,10 +85,12 @@ public static IServiceCollection AddPkgDiffPersistence(this IServiceCollection s public static IServiceCollection AddPkgDiffBackgroundJobs(this IServiceCollection services, IConfiguration configuration) { - services.AddHangfire(config => config + services.AddHttpContextAccessor(); + services.AddHangfire((provider, config) => config .SetDataCompatibilityLevel(CompatibilityLevel.Version_180) .UseSimpleAssemblyNameTypeSerializer() .UseRecommendedSerializerSettings() + .UseFilter(new JobTriggerFilter(provider.GetRequiredService())) .UsePostgreSqlStorage(options => options.UseNpgsqlConnection(configuration.GetConnectionString("DefaultConnection")))); diff --git a/src/backend/PkgDiff.API/Filters/JobTriggerFilter.cs b/src/backend/PkgDiff.API/Filters/JobTriggerFilter.cs new file mode 100644 index 0000000..cc14697 --- /dev/null +++ b/src/backend/PkgDiff.API/Filters/JobTriggerFilter.cs @@ -0,0 +1,50 @@ +using Hangfire.Client; +using Hangfire.Server; +using PkgDiff.Application.Jobs; + +namespace PkgDiff.API.Filters; + +public class JobTriggerFilter : IClientFilter, IServerFilter +{ + private static readonly AsyncLocal CurrentJob = new(); + private readonly IHttpContextAccessor _httpContextAccessor; + + public JobTriggerFilter(IHttpContextAccessor httpContextAccessor) + { + _httpContextAccessor = httpContextAccessor; + } + + public void OnCreating(CreatingContext context) + { + var trigger = CurrentJob.Value ?? DescribeRequest(); + if (trigger != null) + context.SetJobParameter(JobDescriptions.TriggerParameter, trigger); + } + + public void OnCreated(CreatedContext context) + { + } + + public void OnPerforming(PerformingContext context) + { + CurrentJob.Value = $"Job #{context.BackgroundJob.Id} · {JobDescriptions.Label(context.BackgroundJob.Job)}"; + } + + public void OnPerformed(PerformedContext context) + { + CurrentJob.Value = null; + } + + private string? DescribeRequest() + { + try + { + var request = _httpContextAccessor.HttpContext?.Request; + return request == null ? null : $"Request · {request.Method} {request.Path}"; + } + catch (ObjectDisposedException) + { + return null; + } + } +} diff --git a/src/backend/PkgDiff.Application/Jobs/JobDescriptions.cs b/src/backend/PkgDiff.Application/Jobs/JobDescriptions.cs new file mode 100644 index 0000000..482f955 --- /dev/null +++ b/src/backend/PkgDiff.Application/Jobs/JobDescriptions.cs @@ -0,0 +1,80 @@ +using System.Text.RegularExpressions; +using Hangfire.Common; +using PkgDiff.Shared.DTOs; + +namespace PkgDiff.Application.Jobs; + +public static class JobDescriptions +{ + public const string TriggerParameter = "TriggeredBy"; + + private static readonly Dictionary Labels = new() + { + ["PackageAnalysisJob.ExecuteAsync"] = "Cache refresh sweep", + ["PackageRefreshJob.RefreshAsync"] = "Package cache refresh", + ["IPackageTrustService.RefreshAsync"] = "Security scan", + ["IPackageTrustService.RefreshExpiredAsync"] = "Expired security re-scan", + ["IPackageCacheService.GetOrFetchMetadataAsync"] = "Metadata refresh", + ["IPackageAnalysisService.ScrapePackageReleaseNotesAsync"] = "Release notes scrape", + ["IDependencyAuditService.RunAsync"] = "Dependency audit", + ["IDependencyAuditService.DeleteExpiredAsync"] = "Audit cleanup", + ["DatabaseCleanupJob.CleanupOldRequestLogsAsync"] = "Request log cleanup", + ["IAutoPilotService.RunAutoPilotJobAsync"] = "AutoPilot blog generation", + ["BlogSocialPostJob.ExecuteAsync"] = "Blog social post", + ["IContributorService.BackfillFromExistingReleaseNotesAsync"] = "Contributor backfill" + }; + + public static string TechnicalName(Job? job) => + job == null ? "Unknown" : $"{job.Type.Name}.{job.Method.Name}"; + + public static string Label(Job? job) + { + if (job == null) return "Unknown job"; + return Labels.TryGetValue(TechnicalName(job), out var label) ? label : Humanize(job.Method.Name); + } + + public static JobTarget Target(Job? job) + { + var target = new JobTarget(); + if (job?.Args == null) return target; + + var parameters = job.Method.GetParameters(); + var details = new List(); + + for (var i = 0; i < parameters.Length && i < job.Args.Count; i++) + { + var arg = job.Args[i]; + switch (arg) + { + case null or bool: + break; + case AnalyzeRequest request: + target.Registry = request.Registry; + target.PackageId = request.PackageId; + if (!string.IsNullOrEmpty(request.CurrentVersion) || !string.IsNullOrEmpty(request.TargetVersion)) + details.Add($"{request.CurrentVersion} → {request.TargetVersion}"); + break; + case Guid id: + details.Add(id.ToString("N")[..8]); + break; + default: + var value = arg.ToString(); + if (string.IsNullOrWhiteSpace(value)) break; + if (parameters[i].Name == "registry") target.Registry = value; + else if (parameters[i].Name == "packageId") target.PackageId = value; + else details.Add($"{Humanize(parameters[i].Name ?? string.Empty)} {value}"); + break; + } + } + + target.Detail = details.Count > 0 ? string.Join(", ", details) : null; + return target; + } + + private static string Humanize(string name) + { + var trimmed = name.EndsWith("Async") ? name[..^5] : name; + var spaced = Regex.Replace(trimmed, "(?<=[a-z0-9])(?=[A-Z])", " ").ToLowerInvariant(); + return spaced.Length == 0 ? name : char.ToUpperInvariant(spaced[0]) + spaced[1..]; + } +} diff --git a/src/backend/PkgDiff.Application/Jobs/JobTarget.cs b/src/backend/PkgDiff.Application/Jobs/JobTarget.cs new file mode 100644 index 0000000..b2655d4 --- /dev/null +++ b/src/backend/PkgDiff.Application/Jobs/JobTarget.cs @@ -0,0 +1,8 @@ +namespace PkgDiff.Application.Jobs; + +public class JobTarget +{ + public string? Registry { get; set; } + public string? PackageId { get; set; } + public string? Detail { get; set; } +} diff --git a/src/backend/PkgDiff.Application/Services/AdminJobService.cs b/src/backend/PkgDiff.Application/Services/AdminJobService.cs index a344418..285894a 100644 --- a/src/backend/PkgDiff.Application/Services/AdminJobService.cs +++ b/src/backend/PkgDiff.Application/Services/AdminJobService.cs @@ -1,10 +1,9 @@ using PkgDiff.Application.Interfaces; -using PkgDiff.Domain.Entities; -using PkgDiff.Domain.Interfaces; +using PkgDiff.Application.Jobs; using PkgDiff.Shared.DTOs; using Hangfire; using Hangfire.Storage; -using Microsoft.AspNetCore.Identity; +using Hangfire.Storage.Monitoring; namespace PkgDiff.Application.Services; @@ -35,30 +34,40 @@ public Task GetJobsOverviewAsync() foreach (var job in succeeded) { var details = monitor.JobDetails(job.Key); + var target = JobDescriptions.Target(job.Value.Job); allJobs.Add(new RecentJobInfo { JobId = job.Key, - JobName = GetJobDisplayName(job.Value.Job), + JobName = JobDescriptions.TechnicalName(job.Value.Job), + JobType = JobDescriptions.Label(job.Value.Job), State = "Succeeded", CreatedAt = details?.CreatedAt, CompletedAt = job.Value.SucceededAt, Duration = job.Value.TotalDuration, - PackageId = ExtractPackageId(job.Value.Job) + PackageId = target.PackageId, + Registry = target.Registry, + Detail = target.Detail, + TriggeredBy = DescribeTrigger(details) }); } foreach (var job in failed) { var details = monitor.JobDetails(job.Key); + var target = JobDescriptions.Target(job.Value.Job); allJobs.Add(new RecentJobInfo { JobId = job.Key, - JobName = GetJobDisplayName(job.Value.Job), + JobName = JobDescriptions.TechnicalName(job.Value.Job), + JobType = JobDescriptions.Label(job.Value.Job), State = "Failed", CreatedAt = details?.CreatedAt, CompletedAt = job.Value.FailedAt, ExceptionMessage = job.Value.ExceptionMessage, - PackageId = ExtractPackageId(job.Value.Job) + PackageId = target.PackageId, + Registry = target.Registry, + Detail = target.Detail, + TriggeredBy = DescribeTrigger(details) }); } @@ -104,13 +113,18 @@ public Task GetJobsOverviewAsync() var enqueuedJobs = monitor.EnqueuedJobs(queue.Name, 0, 50); foreach (var job in enqueuedJobs) { + var target = JobDescriptions.Target(job.Value.Job); queuedJobs.Add(new QueuedJobInfo { JobId = job.Key, - JobName = GetJobDisplayName(job.Value.Job), + JobName = JobDescriptions.TechnicalName(job.Value.Job), + JobType = JobDescriptions.Label(job.Value.Job), State = "Enqueued", EnqueuedAt = job.Value.EnqueuedAt, - PackageId = ExtractPackageId(job.Value.Job) + PackageId = target.PackageId, + Registry = target.Registry, + Detail = target.Detail, + TriggeredBy = DescribeTrigger(monitor.JobDetails(job.Key)) }); } } @@ -118,14 +132,19 @@ public Task GetJobsOverviewAsync() var processingJobs = monitor.ProcessingJobs(0, 50); foreach (var job in processingJobs) { + var target = JobDescriptions.Target(job.Value.Job); queuedJobs.Add(new QueuedJobInfo { JobId = job.Key, - JobName = GetJobDisplayName(job.Value.Job), + JobName = JobDescriptions.TechnicalName(job.Value.Job), + JobType = JobDescriptions.Label(job.Value.Job), State = "Processing", StartedAt = job.Value.StartedAt, ServerId = job.Value.ServerId, - PackageId = ExtractPackageId(job.Value.Job) + PackageId = target.PackageId, + Registry = target.Registry, + Detail = target.Detail, + TriggeredBy = DescribeTrigger(monitor.JobDetails(job.Key)) }); } @@ -142,35 +161,31 @@ public Task GetJobsOverviewAsync() }); } - private static string GetJobDisplayName(Hangfire.Common.Job? job) + private static string? DescribeTrigger(JobDetailsDto? details) { - if (job == null) return "Unknown"; - return $"{job.Type.Name}.{job.Method.Name}"; + if (details?.Properties == null) return null; + + if (details.Properties.TryGetValue(JobDescriptions.TriggerParameter, out var trigger)) + return ReadParameter(trigger); + + if (details.Properties.TryGetValue("RecurringJobId", out var recurringJobId)) + return $"Schedule · {ReadParameter(recurringJobId)}"; + + return null; } - private static string? ExtractPackageId(Hangfire.Common.Job? job) + private static string? ReadParameter(string? value) { - if (job?.Args == null || job.Args.Count == 0) return null; - - if (job.Args[0] is AnalyzeRequest request) - return request.PackageId; + if (string.IsNullOrEmpty(value)) return null; try { - var json = job.Args[0]?.ToString(); - if (json != null && json.Contains("PackageId")) - { - using var doc = System.Text.Json.JsonDocument.Parse(json); - if (doc.RootElement.TryGetProperty("PackageId", out var prop)) - return prop.GetString(); - } + return System.Text.Json.JsonSerializer.Deserialize(value); } catch (System.Text.Json.JsonException) { - // The argument is not JSON: this job has no package to show + return value.Trim('"'); } - - return null; } public Task TriggerJobAsync(string jobId) diff --git a/src/backend/PkgDiff.Shared/DTOs/AdminModels.cs b/src/backend/PkgDiff.Shared/DTOs/AdminModels.cs index a2ef107..4def2af 100644 --- a/src/backend/PkgDiff.Shared/DTOs/AdminModels.cs +++ b/src/backend/PkgDiff.Shared/DTOs/AdminModels.cs @@ -129,12 +129,16 @@ public class RecentJobInfo { public string JobId { get; set; } = string.Empty; public string JobName { get; set; } = string.Empty; + public string JobType { get; set; } = string.Empty; public string State { get; set; } = string.Empty; public DateTime? CreatedAt { get; set; } public DateTime? CompletedAt { get; set; } public long? Duration { get; set; } public string? ExceptionMessage { get; set; } public string? PackageId { get; set; } + public string? Registry { get; set; } + public string? Detail { get; set; } + public string? TriggeredBy { get; set; } public List ChildJobs { get; set; } = new(); } @@ -142,11 +146,15 @@ public class QueuedJobInfo { public string JobId { get; set; } = string.Empty; public string JobName { get; set; } = string.Empty; + public string JobType { get; set; } = string.Empty; public string State { get; set; } = string.Empty; public DateTime? EnqueuedAt { get; set; } public DateTime? StartedAt { get; set; } public string? ServerId { get; set; } public string? PackageId { get; set; } + public string? Registry { get; set; } + public string? Detail { get; set; } + public string? TriggeredBy { get; set; } } public class SystemSettingDto diff --git a/src/frontend/src/app/features/admin/jobs/admin-jobs.html b/src/frontend/src/app/features/admin/jobs/admin-jobs.html index 6e6c394..2461bfe 100644 --- a/src/frontend/src/app/features/admin/jobs/admin-jobs.html +++ b/src/frontend/src/app/features/admin/jobs/admin-jobs.html @@ -64,8 +64,9 @@

Active Queue Job ID - Package - + Type + Target + Triggered by State Time Server @@ -78,14 +79,13 @@

Active Queue#{{ job.jobId }} - @if (job.packageId) { -
- - {{ job.packageId }} -
- } @else { - {{ job.jobName }} - } + + + + + + + Active Queue {{ formatDate(job.state === 'Processing' ? job.startedAt : job.enqueuedAt) }} - {{ job.serverId || '-' }} + {{ shortServer(job.serverId) }}