Merge branch 'feature/thumbnailInvalidation' into develop

This commit is contained in:
2026-07-06 20:32:06 +02:00
19 changed files with 742 additions and 72 deletions

View File

@@ -12,6 +12,8 @@ public enum EJobStatus {
Waiting,
/// <summary>Successfully finished.</summary>
Completed,
/// <summary>Finished, but some sub-jobs or steps had errors.</summary>
CompletedWithErrors,
/// <summary>Finished with an error.</summary>
Failed,
/// <summary>Canceled by user or system.</summary>

View File

@@ -21,4 +21,5 @@ RUN dotnet publish "Lactose.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:U
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
RUN mkdir -p thumbnails previews
ENTRYPOINT ["dotnet", "Lactose.dll"]

View File

@@ -52,6 +52,7 @@ public abstract class Job {
case EJobStatus.Running: // If still running or waiting, mark as completed
case EJobStatus.Waiting: //somebody forgot to update the status and the method returned.
case EJobStatus.Completed:
case EJobStatus.CompletedWithErrors:
Completed?.Invoke(this, JobStatus);
break;
case EJobStatus.Queued: // This should never happen

View File

@@ -132,6 +132,7 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> lo
break;
case EJobStatus.Queued:
case EJobStatus.Completed:
case EJobStatus.CompletedWithErrors:
case EJobStatus.Canceled:
case EJobStatus.Failed:
logger.LogInformation($"Job with ID {id} has been removed from the job manager.");

View File

@@ -88,6 +88,17 @@ public class JobStatus(Job job) {
if (message != null) Message = message;
}
/// <summary>
/// Marks the job as completed, but some sub-jobs or steps had errors.
/// </summary>
/// <param name="message">Optional completion message.</param>
public void CompleteWithErrors(string? message = null) {
Status = EJobStatus.CompletedWithErrors;
Finished = DateTime.UtcNow;
Progress = 1;
if (message != null) Message = message;
}
/// <summary>
/// Marks the job as failed.
/// </summary>

View File

@@ -1,4 +1,5 @@
using Butter.Settings;
using Butter.Types;
using Lactose.Models;
using Lactose.Repositories;
using SixLabors.ImageSharp;
@@ -8,15 +9,17 @@ using SixLabors.ImageSharp.Processing;
namespace Lactose.Jobs;
/// <summary>
/// Generates JPEG thumbnails for assets missing them. Creates a master job that spawns sub-jobs per asset.
/// Generates JPEG thumbnails for assets missing them or with wrong dimensions.
/// Creates a master job that spawns sub-jobs per asset.
/// </summary>
public class ThumbnailJob : Job {
ThumbnailJob? ParentJob;
List<ThumbnailJob>? subJobs;
Asset? Asset;
string? ThumbnailPath;
int processedAssets = 0;
int totalAssets = 1; // Avoid division by zero
int processedAssets = 0;
int failedAssets = 0;
int totalAssets = 1; // Avoid division by zero
int ThumbnailSize;
ILogger<ThumbnailJob> Logger;
ISettingsRepository SettingsRepository;
@@ -100,6 +103,13 @@ public class ThumbnailJob : Job {
return;
}
// Remove stale thumbnail if it exists (wrong size or orphaned)
if (!string.IsNullOrEmpty(Asset.ThumbnailPath) && File.Exists(Asset.ThumbnailPath)) {
Logger.LogInformation("Removing stale thumbnail for asset ID {Id} at {Path}.", Asset.Id, Asset.ThumbnailPath);
File.Delete(Asset.ThumbnailPath);
Asset.ThumbnailPath = "";
}
using var image = Image.Load(Asset.OriginalPath);
image.Mutate(x => x.Resize(
@@ -132,6 +142,7 @@ public class ThumbnailJob : Job {
);
Asset.ThumbnailPath = path;
Asset.ThumbnailSize = ThumbnailSize;
AssetRepository.Update(Asset);
AssetRepository.Save();
} catch (Exception ex) {
@@ -140,7 +151,7 @@ public class ThumbnailJob : Job {
return;
}
Logger.LogInformation($"Thumbnail generated for asset ID {Asset.Id} at {Asset.ThumbnailPath}");
Logger.LogInformation($"Thumbnail generated for asset ID {Asset.Id} at {Asset.ThumbnailPath} (size: {ThumbnailSize}px)");
JobStatus.Complete($"Thumbnail generated for asset ID {Asset.Id} at {Asset.ThumbnailPath}");
}
@@ -153,21 +164,27 @@ public class ThumbnailJob : Job {
JobStatus.Start();
Logger.LogInformation("Starting master Thumbnail job for all assets.");
var thumbPath = SettingsRepository.Get(Settings.ThumbnailPath.AsString());
//TODO: Add thumbnail size setting to the system
if (thumbPath == null) {
var thumbPathSetting = SettingsRepository.Get(Settings.ThumbnailPath.AsString());
var thumbSizeSetting = SettingsRepository.Get(Settings.ThumbnailSize.AsString());
if (thumbPathSetting == null) {
Logger.LogError("Thumbnail path not found. Cannot proceed with thumbnail generation.");
JobStatus.Fail("Thumbnail path not found. Cannot proceed with thumbnail generation.");
return;
}
var missingThumbnail = AssetRepository.GetAssetsMissingThumbnail(out totalAssets);
Logger.LogInformation($"Found {totalAssets} assets missing thumbnails.");
if (!int.TryParse(thumbSizeSetting?.Value, out var thumbnailSize)) {
thumbnailSize = 256;
Logger.LogWarning("Thumbnail Size setting not found or invalid. Defaulting to {Size}.", thumbnailSize);
}
var assetsNeedingThumbnails = AssetRepository.GetAssetsMissingOrWrongThumbnail(thumbnailSize, out totalAssets);
Logger.LogInformation($"Found {totalAssets} assets needing thumbnails (missing or wrong size).");
subJobs = new List<ThumbnailJob>();
foreach (var asset in missingThumbnail) {
foreach (var asset in assetsNeedingThumbnails) {
if (token.IsCancellationRequested) {
lock (subJobs) subJobs.ForEach(j => j.Cancel());
JobStatus.Cancel("Cancellation requested from user.");
@@ -175,11 +192,13 @@ public class ThumbnailJob : Job {
}
// Create a sub-job for each asset
var job = JobManager.CreateJob<ThumbnailJob>(this, asset, 256, thumbPath.Value!);
var job = JobManager.CreateJob<ThumbnailJob>(this, asset, thumbnailSize, thumbPathSetting.Value!);
job.Done += (o, _) => {
var sub = (ThumbnailJob)o!;
if (sub.JobStatus.Status == EJobStatus.Failed) failedAssets++;
IncrementProcessedAssets();
lock (subJobs) subJobs.Remove((o as ThumbnailJob)!);
lock (subJobs) subJobs.Remove(sub);
};
lock (subJobs) subJobs.Add(job);
@@ -197,7 +216,10 @@ public class ThumbnailJob : Job {
}
}
JobStatus.Complete("All thumbnails generated successfully.");
if (failedAssets > 0)
JobStatus.CompleteWithErrors($"{failedAssets} of {totalAssets} thumbnails failed.");
else
JobStatus.Complete("All thumbnails generated successfully.");
}
void IncrementProcessedAssets() {

View File

@@ -0,0 +1,513 @@
// <auto-generated />
using System;
using System.Collections;
using Lactose.Context;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Lactose.Migrations
{
[DbContext(typeof(LactoseDbContext))]
[Migration("20260706175732_AddThumbnailSizeToAssets")]
partial class AddThumbnailSizeToAssets
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.15")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("AlbumAsset", b =>
{
b.Property<Guid>("AlbumsId")
.HasColumnType("uuid");
b.Property<Guid>("AssetsId")
.HasColumnType("uuid");
b.HasKey("AlbumsId", "AssetsId");
b.HasIndex("AssetsId");
b.ToTable("AlbumAsset");
});
modelBuilder.Entity("AssetTag", b =>
{
b.Property<Guid>("AssetsId")
.HasColumnType("uuid");
b.Property<Guid>("TagsId")
.HasColumnType("uuid");
b.HasKey("AssetsId", "TagsId");
b.HasIndex("TagsId");
b.ToTable("AssetTag");
});
modelBuilder.Entity("Lactose.Models.Album", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime?>("CreatedAt")
.IsRequired()
.HasColumnType("timestamp without time zone");
b.Property<Guid?>("PersonOwnerId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp without time zone");
b.Property<Guid?>("UserOwnerId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("PersonOwnerId");
b.HasIndex("UserOwnerId");
b.ToTable("Albums");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp without time zone");
b.Property<float?>("Duration")
.HasColumnType("real");
b.Property<long>("FileSize")
.HasColumnType("bigint");
b.Property<Guid?>("FolderId")
.HasColumnType("uuid");
b.Property<float?>("FrameRate")
.HasColumnType("real");
b.Property<BitArray>("Hash")
.IsRequired()
.HasColumnType("bit(64)");
b.Property<bool>("IsPubliclyShared")
.HasColumnType("boolean");
b.Property<string>("MimeType")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("OriginalFilename")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("OriginalPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<Guid?>("OwnerId")
.HasColumnType("uuid");
b.Property<string>("PreviewPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<int>("ResolutionHeight")
.HasColumnType("integer");
b.Property<int>("ResolutionWidth")
.HasColumnType("integer");
b.Property<string>("ThumbnailPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<int>("ThumbnailSize")
.HasColumnType("integer");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp without time zone");
b.HasKey("Id");
b.HasIndex("FolderId");
b.HasIndex("OriginalPath")
.IsUnique();
b.HasIndex("OwnerId");
b.ToTable("Assets");
});
modelBuilder.Entity("Lactose.Models.Face", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AssetId")
.HasColumnType("uuid");
b.Property<int>("BoundingBoxX1")
.HasColumnType("integer");
b.Property<int>("BoundingBoxX2")
.HasColumnType("integer");
b.Property<int>("BoundingBoxY1")
.HasColumnType("integer");
b.Property<int>("BoundingBoxY2")
.HasColumnType("integer");
b.Property<int>("ImageHeight")
.HasColumnType("integer");
b.Property<int>("ImageWidth")
.HasColumnType("integer");
b.Property<Guid?>("PersonId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("AssetId");
b.HasIndex("PersonId");
b.ToTable("Faces");
});
modelBuilder.Entity("Lactose.Models.Folder", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("Active")
.HasColumnType("boolean");
b.Property<string>("BasePath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.HasKey("Id");
b.ToTable("Folders");
});
modelBuilder.Entity("Lactose.Models.JobRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("Created")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("Finished")
.HasColumnType("timestamp without time zone");
b.Property<int>("JobType")
.HasColumnType("integer");
b.Property<string>("Message")
.HasColumnType("VARCHAR(2048)");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(256)");
b.Property<Guid?>("ParentJobId")
.HasColumnType("uuid");
b.Property<float>("Progress")
.HasColumnType("real");
b.Property<DateTime?>("Started")
.HasColumnType("timestamp without time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("JobRecords");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp without time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp without time zone");
b.HasKey("Id");
b.ToTable("People");
});
modelBuilder.Entity("Lactose.Models.Setting", b =>
{
b.Property<string>("Name")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int>("DisplayType")
.HasColumnType("integer");
b.Property<string[]>("Options")
.HasColumnType("text[]");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<string>("Value")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.HasKey("Name");
b.ToTable("Settings");
});
modelBuilder.Entity("Lactose.Models.Tag", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ParentId");
b.ToTable("Tags");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AccessLevel")
.HasColumnType("integer");
b.Property<Guid?>("AssetId")
.HasColumnType("uuid");
b.Property<DateTime?>("BannedAt")
.HasColumnType("timestamp without time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp without time zone");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("VARCHAR(128)");
b.Property<DateTime?>("LastLogin")
.HasColumnType("timestamp without time zone");
b.Property<string>("Password")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("RefreshToken")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("RefreshTokenExpires")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp without time zone");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("VARCHAR(64)");
b.HasKey("Id");
b.HasIndex("AssetId");
b.ToTable("Users");
});
modelBuilder.Entity("AlbumAsset", b =>
{
b.HasOne("Lactose.Models.Album", null)
.WithMany()
.HasForeignKey("AlbumsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("AssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("AssetTag", b =>
{
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("AssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.Tag", null)
.WithMany()
.HasForeignKey("TagsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Lactose.Models.Album", b =>
{
b.HasOne("Lactose.Models.Person", "PersonOwner")
.WithMany("Albums")
.HasForeignKey("PersonOwnerId");
b.HasOne("Lactose.Models.User", "UserOwner")
.WithMany("OwnedAlbums")
.HasForeignKey("UserOwnerId");
b.Navigation("PersonOwner");
b.Navigation("UserOwner");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.HasOne("Lactose.Models.Folder", "Folder")
.WithMany("Assets")
.HasForeignKey("FolderId");
b.HasOne("Lactose.Models.User", "Owner")
.WithMany("OwnedAssets")
.HasForeignKey("OwnerId");
b.Navigation("Folder");
b.Navigation("Owner");
});
modelBuilder.Entity("Lactose.Models.Face", b =>
{
b.HasOne("Lactose.Models.Asset", "Asset")
.WithMany("Faces")
.HasForeignKey("AssetId");
b.HasOne("Lactose.Models.Person", "Person")
.WithMany("Faces")
.HasForeignKey("PersonId");
b.Navigation("Asset");
b.Navigation("Person");
});
modelBuilder.Entity("Lactose.Models.Tag", b =>
{
b.HasOne("Lactose.Models.Tag", "Parent")
.WithMany()
.HasForeignKey("ParentId");
b.Navigation("Parent");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.HasOne("Lactose.Models.Asset", null)
.WithMany("SharedWith")
.HasForeignKey("AssetId");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Navigation("Faces");
b.Navigation("SharedWith");
});
modelBuilder.Entity("Lactose.Models.Folder", b =>
{
b.Navigation("Assets");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.Navigation("Albums");
b.Navigation("Faces");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.Navigation("OwnedAlbums");
b.Navigation("OwnedAssets");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Lactose.Migrations
{
/// <inheritdoc />
public partial class AddThumbnailSizeToAssets : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "ThumbnailSize",
table: "Assets",
type: "integer",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ThumbnailSize",
table: "Assets");
}
}
}

View File

@@ -146,6 +146,9 @@ namespace Lactose.Migrations
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<int>("ThumbnailSize")
.HasColumnType("integer");
b.Property<int>("Type")
.HasColumnType("integer");

View File

@@ -37,6 +37,12 @@ public class Asset {
[Column(TypeName = "VARCHAR(2048)")]
public string ThumbnailPath { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the longest-side size in pixels of the generated thumbnail.
/// Zero means no thumbnail has been generated.
/// </summary>
public int ThumbnailSize { get; set; }
/// <summary>
/// Gets or sets the preview path of the asset.
/// </summary>

View File

@@ -89,6 +89,12 @@ public class AssetRepository(LactoseDbContext context) : IAssetRepository {
count = context.Assets.Count(a => a.ThumbnailPath == null || a.ThumbnailPath == "");
return context.Assets.Where(a => a.ThumbnailPath == null || a.ThumbnailPath == "");
}
/// <inheritdoc />
public IEnumerable<Asset> GetAssetsMissingOrWrongThumbnail(int thumbnailSize, out int count) {
count = context.Assets.Count(a => a.ThumbnailPath == null || a.ThumbnailPath == "" || a.ThumbnailSize != thumbnailSize);
return context.Assets.Where(a => a.ThumbnailPath == null || a.ThumbnailPath == "" || a.ThumbnailSize != thumbnailSize);
}
/// <inheritdoc />
public IEnumerable<Asset> GetAssetsMissingMetadata(out int count) {

View File

@@ -96,6 +96,14 @@ public interface IAssetRepository : IDisposable {
/// <returns>Assets that do not have a thumbnail path set.</returns>
IEnumerable<Asset> GetAssetsMissingThumbnail(out int totalAssets);
/// <summary>
/// Finds all assets that need thumbnails — either missing entirely, or with wrong dimensions.
/// </summary>
/// <param name="thumbnailSize">The expected longest-side size in pixels.</param>
/// <param name="totalAssets">The total number of assets needing thumbnails.</param>
/// <returns>Assets that need thumbnails regenerated.</returns>
IEnumerable<Asset> GetAssetsMissingOrWrongThumbnail(int thumbnailSize, out int totalAssets);
/// <summary>
/// Finds all image assets that are missing resolution metadata (ResolutionWidth/ResolutionHeight = 0).
/// </summary>

View File

@@ -22,7 +22,7 @@ public class JobRecordRepository(LactoseDbContext context) : IJobRecordRepositor
.OrderByDescending(j => j.Finished ?? j.Created)
.ToList();
static readonly EJobStatus[] PastStatuses = [EJobStatus.Completed, EJobStatus.Failed, EJobStatus.Canceled];
static readonly EJobStatus[] PastStatuses = [EJobStatus.Completed, EJobStatus.CompletedWithErrors, EJobStatus.Failed, EJobStatus.Canceled];
/// <inheritdoc />
public List<JobRecord> GetPastRootJobs(int page, int pageSize, out int total) {

View File

@@ -1,6 +1,37 @@
<Router AppAssembly="typeof(Program).Assembly">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)"/>
<FocusOnNavigate RouteData="routeData" Selector="h1"/>
</Found>
</Router>
@inject ILocalStorageService localStorage
@inject LoginService loginService
@inject UserService userService
@if (_initialized) {
<Router AppAssembly="typeof(Program).Assembly">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)"/>
<FocusOnNavigate RouteData="routeData" Selector="h1"/>
</Found>
</Router>
} else {
<div class="d-flex justify-content-center align-items-center vh-100 text-muted">
<div class="spinner-border me-2" role="status"></div>
<span>Loading...</span>
</div>
}
@code {
bool _initialized;
protected override async Task OnInitializedAsync() {
AuthInfo? auth = null;
try {
auth = await localStorage.GetItemAsync<AuthInfo>("auth");
} catch {
try { await localStorage.RemoveItemAsync("auth"); } catch { }
}
if (auth != null) {
loginService.AuthInfo = auth;
loginService.LoggedUser = await userService.GetUserAsync();
}
_initialized = true;
}
}

View File

@@ -63,30 +63,33 @@
};
string RowClass => Job.Status switch {
EJobStatus.Completed => "",
EJobStatus.Failed => "border-start border-2 border-danger",
EJobStatus.Canceled => "",
_ => ""
EJobStatus.Completed => "",
EJobStatus.CompletedWithErrors => "border-start border-2 border-warning",
EJobStatus.Failed => "border-start border-2 border-danger",
EJobStatus.Canceled => "",
_ => ""
};
string DotColor => Job.Status switch {
EJobStatus.Queued => "#6c757d",
EJobStatus.Running => "#0d6efd",
EJobStatus.Waiting => "#0dcaf0",
EJobStatus.Completed => "#198754",
EJobStatus.Failed => "#dc3545",
EJobStatus.Canceled => "#6c757d",
_ => "#6c757d"
EJobStatus.Queued => "#6c757d",
EJobStatus.Running => "#0d6efd",
EJobStatus.Waiting => "#0dcaf0",
EJobStatus.Completed => "#198754",
EJobStatus.CompletedWithErrors => "#fd7e14",
EJobStatus.Failed => "#dc3545",
EJobStatus.Canceled => "#6c757d",
_ => "#6c757d"
};
string StatusColor => Job.Status switch {
EJobStatus.Queued => "secondary",
EJobStatus.Running => "primary",
EJobStatus.Waiting => "info",
EJobStatus.Completed => "success",
EJobStatus.Failed => "danger",
EJobStatus.Canceled => "secondary",
_ => "secondary"
EJobStatus.Queued => "secondary",
EJobStatus.Running => "primary",
EJobStatus.Waiting => "info",
EJobStatus.Completed => "success",
EJobStatus.CompletedWithErrors => "warning",
EJobStatus.Failed => "danger",
EJobStatus.Canceled => "secondary",
_ => "secondary"
};
bool CanCancel => Job.Status is EJobStatus.Queued or EJobStatus.Running or EJobStatus.Waiting;
@@ -98,9 +101,9 @@
EJobStatus.Queued => now - Job.Created.ToLocalTime(),
EJobStatus.Running or EJobStatus.Waiting when Job.Started.HasValue
=> now - Job.Started.Value.ToLocalTime(),
EJobStatus.Completed or EJobStatus.Failed or EJobStatus.Canceled when Job.Finished.HasValue && Job.Started.HasValue
EJobStatus.Completed or EJobStatus.CompletedWithErrors or EJobStatus.Failed or EJobStatus.Canceled when Job.Finished.HasValue && Job.Started.HasValue
=> Job.Finished.Value.ToLocalTime() - Job.Started.Value.ToLocalTime(),
EJobStatus.Completed or EJobStatus.Failed or EJobStatus.Canceled when Job.Finished.HasValue
EJobStatus.Completed or EJobStatus.CompletedWithErrors or EJobStatus.Failed or EJobStatus.Canceled when Job.Finished.HasValue
=> Job.Finished.Value.ToLocalTime() - Job.Created.ToLocalTime(),
_ => null
};

View File

@@ -15,10 +15,27 @@
ExpandedChanged="(bool e) => OnExpanded(root.Id, e)" />
@if (_expandedParents.Contains(root.Id) && hasFetched && hasChildren) {
<JobTree Jobs="children!"
Level="@(Level + 1)"
FetchChildren="@FetchChildren"
EmptyText="" />
var groups = children!.GroupBy(j => j.Status).OrderBy(g => (int)g.Key);
@foreach (var group in groups) {
var groupKey = $"{root.Id}-{(int)group.Key}";
var isGroupExpanded = _expandedGroups.Contains(groupKey);
<div class="ms-@((Level + 1) * 3)">
<div class="small fw-semibold py-1 d-flex align-items-center gap-2 user-select-none"
@onclick="() => ToggleGroup(groupKey)" role="button">
<i class="bi @(isGroupExpanded ? "bi-chevron-down" : "bi-chevron-right")"></i>
<span class="d-inline-block rounded-circle flex-shrink-0"
style="width: 8px; height: 8px; background: @(StatusColor(group.Key))"></span>
<span>@(StatusLabel(group.Key))</span>
<span class="text-muted">@group.Count()</span>
</div>
@if (isGroupExpanded) {
<JobTree Jobs="group.ToList()"
Level="@(Level + 2)"
FetchChildren="@FetchChildren"
EmptyText="" />
}
</div>
}
}
}
}
@@ -29,12 +46,12 @@
[Parameter] public string EmptyText { get; set; } = "No jobs";
[Parameter] public Func<Guid, Task<List<JobStatusDto>>>? FetchChildren { get; set; }
List<JobStatusDto> Roots => Jobs?
.Where(j => j.ParentJobId is null)
.OrderBy(j => j.Created)
.ToList() ?? [];
List<JobStatusDto> Roots => Level == 0
? Jobs?.Where(j => j.ParentJobId is null).OrderBy(j => j.Created).ToList() ?? []
: Jobs?.OrderBy(j => j.Created).ToList() ?? [];
readonly HashSet<Guid> _expandedParents = [];
readonly HashSet<string> _expandedGroups = [];
readonly Dictionary<Guid, List<JobStatusDto>?> _childrenCache = [];
string? ChildSummary(JobStatusDto job, bool hasFetched, List<JobStatusDto>? children) {
@@ -56,6 +73,28 @@
return string.Join(" · ", parts);
}
static string StatusColor(EJobStatus status) => status switch {
EJobStatus.Queued => "#6c757d",
EJobStatus.Running => "#0d6efd",
EJobStatus.Waiting => "#0dcaf0",
EJobStatus.Completed => "#198754",
EJobStatus.CompletedWithErrors => "#fd7e14",
EJobStatus.Failed => "#dc3545",
EJobStatus.Canceled => "#6c757d",
_ => "#6c757d"
};
static string StatusLabel(EJobStatus status) => status switch {
EJobStatus.Completed => "Succeeded",
EJobStatus.CompletedWithErrors => "Completed with errors",
EJobStatus.Failed => "Failed",
EJobStatus.Canceled => "Canceled",
EJobStatus.Queued => "Queued",
EJobStatus.Running => "Running",
EJobStatus.Waiting => "Waiting",
_ => status.ToString()
};
async Task OnExpanded(Guid id, bool expanded) {
if (expanded) {
_expandedParents.Add(id);
@@ -66,4 +105,9 @@
_expandedParents.Remove(id);
}
}
void ToggleGroup(string groupKey) {
if (!_expandedGroups.Remove(groupKey))
_expandedGroups.Add(groupKey);
}
}

View File

@@ -3,7 +3,6 @@
@inherits LayoutComponentBase
@inject NavigationManager navigation
@inject UserService userService
@inject LoginService loginService
@inject ILocalStorageService localStorage
@@ -70,21 +69,7 @@
bool LoggedIn => loginService.IsLoggedIn;
bool Admin => loginService.LoggedUser?.AccessLevel == EAccessLevel.Admin;
protected override async Task OnInitializedAsync() {
AuthInfo? auth = null;
try {
auth = await localStorage.GetItemAsync<AuthInfo>("auth");
} catch (Exception) {
// The stored value may be a stale DPAPI-encrypted blob from the old Blazor Server
// app. It is not valid JSON so deserialization will throw. Clear the bad entry and
// continue as a logged-out user; the user can log in again normally.
await localStorage.RemoveItemAsync("auth");
}
if (auth != null) {
loginService.AuthInfo = auth;
loginService.LoggedUser = await userService.GetUserAsync();
}
protected override void OnInitialized() {
loginService.LoggedUserChanged += (_, _) => StateHasChanged();
}

View File

@@ -3,8 +3,9 @@
@using Butter.Dtos.Jobs
@using Butter.Types
@using MilkStream.Client.Services
@inject JobsService JobsService
@inject LoginService LoginService
@inject JobsService JobsService
@inject LoginService LoginService
@inject NavigationManager NavigationManager
<div class="d-flex align-items-center justify-content-between mb-2">
<h3 class="mb-0">Jobs</h3>
@@ -81,16 +82,18 @@
TimeSpan pollInterval = TimeSpan.FromSeconds(1);
protected override async Task OnInitializedAsync() {
if (!LoginService.IsLoggedIn) {
NavigationManager.NavigateTo("/login");
return;
}
LoginService.LoggedUserChanged += async (_, _) => {
await RefreshActiveRoots();
await LoadPastJobs(1);
};
if (LoginService.IsLoggedIn) {
await RefreshActiveRoots();
await LoadPastJobs(1);
StartPolling();
StartPastRefresh();
}
await RefreshActiveRoots();
await LoadPastJobs(1);
StartPolling();
StartPastRefresh();
}
// ── Active root polling ──

View File

@@ -8,3 +8,4 @@
@using Microsoft.JSInterop
@using MilkStream.Client
@using MilkStream.Client.Components
@using MilkStream.Client.Services