feat: add JobRow, JobChildrenSummary, JobSection, JobTree components
This commit is contained in:
52
MilkStream.Client/Components/JobChildrenSummary.razor
Normal file
52
MilkStream.Client/Components/JobChildrenSummary.razor
Normal 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);
|
||||
}
|
||||
}
|
||||
131
MilkStream.Client/Components/JobRow.razor
Normal file
131
MilkStream.Client/Components/JobRow.razor
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
19
MilkStream.Client/Components/JobSection.razor
Normal file
19
MilkStream.Client/Components/JobSection.razor
Normal 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; }
|
||||
}
|
||||
60
MilkStream.Client/Components/JobTree.razor
Normal file
60
MilkStream.Client/Components/JobTree.razor
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user