Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<IHttpContextAccessor>()))
.UsePostgreSqlStorage(options =>
options.UseNpgsqlConnection(configuration.GetConnectionString("DefaultConnection"))));

Expand Down
50 changes: 50 additions & 0 deletions src/backend/PkgDiff.API/Filters/JobTriggerFilter.cs
Original file line number Diff line number Diff line change
@@ -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<string?> 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;
}
}
}
80 changes: 80 additions & 0 deletions src/backend/PkgDiff.Application/Jobs/JobDescriptions.cs
Original file line number Diff line number Diff line change
@@ -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<string, string> 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<string>();

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..];
}
}
8 changes: 8 additions & 0 deletions src/backend/PkgDiff.Application/Jobs/JobTarget.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
73 changes: 44 additions & 29 deletions src/backend/PkgDiff.Application/Services/AdminJobService.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -35,30 +34,40 @@ public Task<JobsOverview> 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)
});
}

Expand Down Expand Up @@ -104,28 +113,38 @@ public Task<JobsOverview> 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))
});
}
}

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

Expand All @@ -142,35 +161,31 @@ public Task<JobsOverview> 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<string>(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)
Expand Down
8 changes: 8 additions & 0 deletions src/backend/PkgDiff.Shared/DTOs/AdminModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -129,24 +129,32 @@ 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<RecentJobInfo> ChildJobs { get; set; } = new();
}

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
Expand Down
Loading