feat: add JobRow, JobChildrenSummary, JobSection, JobTree components

This commit is contained in:
2026-07-06 15:17:54 +02:00
parent 0f505575aa
commit e0508c2a22
4 changed files with 262 additions and 0 deletions

View File

@@ -0,0 +1,52 @@
@using Butter.Dtos.Jobs
@using Butter.Types
@* Collapsed summary of child jobs with status counts and mini progress bar *@
<div class="@($"d-flex align-items-center gap-2 py-1 px-1 small text-muted cursor-pointer rounded {ExtraClass}")"
style="padding-left: @((Level + 1) * 24 + 8)px"
@onclick="OnToggle" role="button">
<span class="flex-shrink-0"><i class="bi bi-diagram-2"></i></span>
<span>@Children.Count sub-jobs</span>
@if (Counts is not null) {
<span class="text-muted">
@(string.Join(", ", Counts.Where(c => c.Value > 0).Select(c => $"{c.Value} {c.Key}")))
</span>
}
<span class="flex-shrink-0 ms-auto" style="width: 90px;">
<ProgressBar Value="@OverallProgress" Color="@OverallColor" />
</span>
<span class="flex-shrink-0 small @(Expanded ? "bi-chevron-up" : "bi-chevron-down")"></span>
</div>
@code {
[Parameter] public required List<JobStatusDto> Children { get; set; }
[Parameter] public int Level { get; set; }
[Parameter] public EventCallback<bool> ExpandedChanged { get; set; }
[Parameter] public bool Expanded { get; set; }
[Parameter] public string? ExtraClass { get; set; }
Dictionary<string, int>? Counts => Children?
.GroupBy(j => j.Status.ToString())
.ToDictionary(g => g.Key, g => g.Count());
float OverallProgress => Children is { Count: > 0 }
? Children.Average(j => j.Progress)
: 0;
string OverallColor {
get {
if (Children is null || Children.Count == 0) return "secondary";
var anyFailed = Children.Any(j => j.Status == EJobStatus.Failed);
var anyRunning = Children.Any(j => j.Status is EJobStatus.Running or EJobStatus.Waiting);
var anyQueued = Children.Any(j => j.Status == EJobStatus.Queued);
if (anyFailed) return "danger";
if (anyRunning || anyQueued) return "primary";
return "success";
}
}
async Task OnToggle() {
Expanded = !Expanded;
await ExpandedChanged.InvokeAsync(Expanded);
}
}

View File

@@ -0,0 +1,131 @@
@using Butter.Dtos.Jobs
@using Butter.Types
@using MilkStream.Client.Services
@inject JobsService JobsService
<div class="@($"job-row {RowClass}")" style="padding-left: @(Level * 24 + 8)px">
<div class="d-flex align-items-center gap-2 py-1 px-1 job-row-main rounded" @onclick="Toggle" role="button">
<span class="flex-shrink-0">@((MarkupString)Icon)</span>
<span class="text-truncate flex-grow-1 small" title="@Job.Name">@Job.Name</span>
<span class="flex-shrink-0" title="@Job.Status.ToString()">
<span class="d-inline-block rounded-circle" style="width: 8px; height: 8px; background: @DotColor"></span>
</span>
<span class="flex-shrink-0" style="width: 90px;">
<ProgressBar Value="@Job.Progress" Color="@StatusColor" />
</span>
<span class="flex-shrink-0 text-muted small" style="width: 60px; text-align: right;">@Elapsed</span>
@if (CanCancel) {
<button class="btn btn-sm p-0 border-0 text-danger flex-shrink-0 lh-1" @onclick:stopPropagation @onclick="Cancel" title="Cancel">
<i class="bi bi-x-lg" style="font-size: 0.75rem;"></i>
</button>
}
<span class="flex-shrink-0 text-muted small @(Expanded ? "bi-chevron-up" : "bi-chevron-down")"></span>
</div>
@if (Expanded) {
<div class="job-row-details small text-muted px-2 py-1" style="border-left: 2px solid @DotColor; margin-left: 16px;">
<div class="d-flex flex-wrap gap-x-3 gap-y-0">
<span>Created: @Job.Created.ToLocalTime().ToString("g")</span>
@if (Job.Started.HasValue) {
<span>Started: @Job.Started.Value.ToLocalTime().ToString("g")</span>
}
@if (Job.Finished.HasValue) {
<span>Finished: @Job.Finished.Value.ToLocalTime().ToString("g")</span>
}
@if (Duration is not null) {
<span>Duration: @Duration.Value.ToString(@"h\:mm\:ss")</span>
}
</div>
@if (!string.IsNullOrEmpty(Job.Message)) {
<div class="mt-1" style="white-space: pre-wrap; word-break: break-word;">@Job.Message</div>
}
</div>
}
</div>
@code {
[Parameter] public required JobStatusDto Job { get; set; }
[Parameter] public int Level { get; set; }
bool Expanded;
string Icon => Job.JobType switch {
EJobType.FileSystemScan => "<i class=\"bi bi-folder2-open\"></i>",
EJobType.ThumbnailGeneration => "<i class=\"bi bi-pip\"></i>",
EJobType.PreviewGeneration => "<i class=\"bi bi-file-image\"></i>",
EJobType.MetadataExtraction => "<i class=\"bi bi-file-earmark-break\"></i>",
EJobType.IntegrityCheck => "<i class=\"bi bi-file-earmark-check\"></i>",
EJobType.PHashGeneration => "<i class=\"bi bi-hash\"></i>",
_ => "<i class=\"bi bi-question-circle\"></i>"
};
string RowClass => Job.Status switch {
EJobStatus.Completed => "",
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"
};
string StatusColor => Job.Status switch {
EJobStatus.Queued => "secondary",
EJobStatus.Running => "primary",
EJobStatus.Waiting => "info",
EJobStatus.Completed => "success",
EJobStatus.Failed => "danger",
EJobStatus.Canceled => "secondary",
_ => "secondary"
};
bool CanCancel => Job.Status is EJobStatus.Queued or EJobStatus.Running or EJobStatus.Waiting;
string? Elapsed {
get {
var now = DateTime.Now;
TimeSpan? ts = Job.Status switch {
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
=> Job.Finished.Value.ToLocalTime() - Job.Started.Value.ToLocalTime(),
EJobStatus.Completed or EJobStatus.Failed or EJobStatus.Canceled when Job.Finished.HasValue
=> Job.Finished.Value.ToLocalTime() - Job.Created.ToLocalTime(),
_ => null
};
return ts switch {
null => null,
{ TotalMinutes: < 1 } => $"{(int)ts.Value.TotalSeconds}s",
{ TotalHours: < 1 } => $"{(int)ts.Value.TotalMinutes}m",
_ => $"{(int)ts.Value.TotalHours}h {ts.Value.Minutes}m"
};
}
}
TimeSpan? Duration {
get {
if (Job.Started is null) return null;
var end = Job.Finished ?? DateTime.UtcNow;
return end.ToLocalTime() - Job.Started.Value.ToLocalTime();
}
}
async Task Toggle() {
Expanded = !Expanded;
}
async Task Cancel() {
try {
await JobsService.CancelJob(Job);
} catch {
// Silently handle — next poll will reflect actual state
}
}
}

View File

@@ -0,0 +1,19 @@
@* Section wrapper for a group of jobs with title and count *@
<div class="mb-3">
<div class="d-flex align-items-center mb-1">
<h6 class="mb-0 text-muted text-uppercase small">@Title @(Count is not null ? $"({Count})" : "")</h6>
@if (Extra is not null) {
<span class="ms-auto">@Extra</span>
}
</div>
<div class="border rounded">
@ChildContent
</div>
</div>
@code {
[Parameter] public required string Title { get; set; }
[Parameter] public int? Count { get; set; }
[Parameter] public RenderFragment? Extra { get; set; }
[Parameter] public required RenderFragment ChildContent { get; set; }
}

View File

@@ -0,0 +1,60 @@
@using Butter.Dtos.Jobs
@using Butter.Types
@* Organizes a flat job list into a parent-child hierarchy with auto-collapse for large child sets *@
@if (Roots is null || Roots.Count == 0) {
<div class="text-muted small p-2 text-center">@EmptyText</div>
} else {
@foreach (var root in Roots) {
var children = ChildrenOf(root.Id);
var hasChildren = children.Count > 0;
@* Render the parent row *@
<JobRow @key="root.Id" Job="root" Level="0" />
@* Render children: auto-collapsed summary or full list *@
if (hasChildren) {
var shouldAutoCollapse = AutoCollapseThreshold > 0 && children.Count > AutoCollapseThreshold;
if (shouldAutoCollapse) {
var expanded = ExpandedParents.Contains(root.Id);
<JobChildrenSummary Children="children"
Level="0"
Expanded="expanded"
ExpandedChanged="(bool e) => ToggleParent(root.Id, e)" />
if (expanded) {
@foreach (var child in children) {
<JobRow @key="child.Id" Job="child" Level="1" />
}
}
} else {
@foreach (var child in children) {
<JobRow @key="child.Id" Job="child" Level="1" />
}
}
}
}
}
@code {
[Parameter] public required List<JobStatusDto> Jobs { get; set; }
[Parameter] public string EmptyText { get; set; } = "No jobs";
[Parameter] public int AutoCollapseThreshold { get; set; } = 5;
List<JobStatusDto> Roots => Jobs?
.Where(j => j.ParentJobId is null)
.OrderBy(j => j.Created)
.ToList() ?? [];
HashSet<Guid> ExpandedParents { get; } = [];
List<JobStatusDto> ChildrenOf(Guid parentId) =>
Jobs?
.Where(j => j.ParentJobId == parentId)
.OrderBy(j => j.Created)
.ToList() ?? [];
void ToggleParent(Guid id, bool expanded) {
if (expanded) ExpandedParents.Add(id);
else ExpandedParents.Remove(id);
}
}