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) }}