Files
MilkyShots/MilkStream.Client/Components/JobTree.razor
T

265 lines
11 KiB
Plaintext

@using Butter.Dtos.Jobs
@using Butter.Types
@implements IDisposable
@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 hasFetched = _childrenCache.TryGetValue(root.Id, out var children);
var hasChildren = hasFetched ? children!.Count > 0 : true;
<JobRow @key="root.Id"
Job="root"
Level="@Level"
ChildSummaryText="@ChildSummary(root, hasFetched, children)"
Children="@(hasFetched ? children : null)"
IsExpanded="@(_expandedParents.Contains(root.Id) && hasFetched)"
ExpandedChanged="(bool e) => OnExpanded(root, e)" />
@if (_expandedParents.Contains(root.Id) && hasFetched && hasChildren) {
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 @key="groupKey" class="job-tree-group ms-@((Level + 1) * 2)">
<div class="small fw-semibold py-0 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")" style="font-size: 0.7rem;"></i>
<span class="d-inline-block rounded-circle flex-shrink-0"
style="width: 6px; height: 6px; background: @(StatusColor(group.Key))"></span>
<span class="small">@(StatusLabel(group.Key))</span>
<span class="text-muted small">@group.Count()</span>
</div>
@if (isGroupExpanded) {
<JobTree @key='@(groupKey + "-tree")'
Jobs="group.ToList()"
Level="@(Level + 2)"
FetchChildren="@FetchChildren"
EmptyText=""
PollInterval="PollInterval" />
}
</div>
}
}
}
}
@code {
[Parameter] public required List<JobStatusDto> Jobs { get; set; }
[Parameter] public int Level { get; set; }
[Parameter] public string EmptyText { get; set; } = "No jobs";
[Parameter] public Func<Guid, DateTime?, Task<(List<JobStatusDto> Children, DateTime? Since)>>? FetchChildren { get; set; }
[Parameter] public TimeSpan PollInterval { get; set; } = TimeSpan.FromSeconds(1);
List<JobStatusDto> Roots => Level == 0
? Jobs?.Where(j => j.ParentJobId is null).OrderByDescending(j => j.Finished ?? j.Created).ToList() ?? []
: Jobs?.OrderBy(j => j.Created).ToList() ?? [];
readonly HashSet<Guid> _expandedParents = [];
readonly HashSet<string> _expandedGroups = [];
readonly Dictionary<Guid, List<JobStatusDto>?> _childrenCache = [];
readonly Dictionary<Guid, DateTime> _sinceTimestamps = [];
CancellationTokenSource? _childRefreshCts;
protected override async Task OnParametersSetAsync() {
if (Level == 0 && FetchChildren is not null) {
await EagerFetchMissing();
StateHasChanged();
}
}
async Task EagerFetchMissing() {
foreach (var root in Roots) {
if (_childrenCache.ContainsKey(root.Id) && _sinceTimestamps.ContainsKey(root.Id)) continue;
try {
var (children, since) = await FetchChildren!(root.Id, null);
_childrenCache[root.Id] = children;
_sinceTimestamps[root.Id] = since ?? DateTime.UtcNow;
} catch {
// refresh loop will retry
}
}
}
string? ChildSummary(JobStatusDto job, bool hasFetched, List<JobStatusDto>? children) {
if (FetchChildren is null) return null;
if (!hasFetched) return null;
if (children!.Count == 0) return null;
return $"{children.Count} sub-jobs · {BuildChildSummaryIcons(children!)}";
}
static string BuildChildSummaryIcons(List<JobStatusDto> children) {
var counts = children
.GroupBy(j => j.Status)
.Select(g => (g.Key, Count: g.Count()))
.Where(c => c.Count > 0)
.OrderBy(c => (int)c.Key)
.Select(c => $"{c.Count}<i class=\"{StatusIcon(c.Key)} {StatusColorClass(c.Key)} ms-1\"></i>");
return string.Join(" ", counts);
}
static string StatusIcon(EJobStatus status) => status switch {
EJobStatus.Queued => "bi bi-hourglass",
EJobStatus.Running => "bi bi-arrow-repeat",
EJobStatus.Waiting => "bi bi-pause",
EJobStatus.Completed => "bi bi-check",
EJobStatus.CompletedWithErrors => "bi bi-exclamation",
EJobStatus.Failed => "bi bi-x",
EJobStatus.Canceled => "bi bi-slash",
_ => "bi bi-question"
};
static string StatusColorClass(EJobStatus status) => status switch {
EJobStatus.Queued => "text-secondary",
EJobStatus.Running => "text-primary",
EJobStatus.Waiting => "text-info",
EJobStatus.Completed => "text-success",
EJobStatus.CompletedWithErrors => "text-warning",
EJobStatus.Failed => "text-danger",
EJobStatus.Canceled => "text-secondary",
_ => "text-secondary"
};
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(JobStatusDto job, bool expanded) {
if (expanded) {
_expandedParents.Add(job.Id);
bool shouldFetch = FetchChildren is not null && (
!_childrenCache.ContainsKey(job.Id) ||
job.Status is EJobStatus.Queued or EJobStatus.Running or EJobStatus.Waiting
);
if (shouldFetch) {
var (children, since) = await FetchChildren!(job.Id, null);
_childrenCache[job.Id] = children;
_sinceTimestamps[job.Id] = since ?? DateTime.UtcNow;
}
StartChildRefresh();
} else {
_expandedParents.Remove(job.Id);
if (_expandedParents.Count == 0 && !HasActiveRoots) {
StopChildRefresh();
}
}
}
bool HasActiveRoots => Level == 0 && Roots.Any(j => j.Status is EJobStatus.Queued or EJobStatus.Running or EJobStatus.Waiting);
void StartChildRefresh() {
if (_childRefreshCts is not null) return;
bool hasActive = HasActiveRoots;
if (!hasActive) {
hasActive = _expandedParents.Any(id => {
var job = Roots?.FirstOrDefault(j => j.Id == id);
return job is not null && job.Status is EJobStatus.Queued or EJobStatus.Running or EJobStatus.Waiting;
});
}
if (!hasActive) return;
_childRefreshCts = new CancellationTokenSource();
_ = ChildRefreshLoop(_childRefreshCts.Token);
}
void StopChildRefresh() {
_childRefreshCts?.Cancel();
_childRefreshCts?.Dispose();
_childRefreshCts = null;
}
async Task ChildRefreshLoop(CancellationToken ct) {
while (!ct.IsCancellationRequested) {
try {
if (PollInterval <= TimeSpan.Zero) { StopChildRefresh(); return; }
await Task.Delay(PollInterval, ct);
} catch (OperationCanceledException) { return; }
if (ct.IsCancellationRequested) return;
bool hasActive = false;
var visited = new HashSet<Guid>();
// Level 0: poll children for all active roots (segment progress update)
if (Level == 0 && FetchChildren is not null) {
foreach (var root in Roots!) {
if (root.Status is EJobStatus.Queued or EJobStatus.Running or EJobStatus.Waiting) {
hasActive = true;
visited.Add(root.Id);
PollChildren(root.Id);
}
}
}
// Expanded parents (any level): poll children for tree display
foreach (var id in _expandedParents.ToArray()) {
if (visited.Contains(id)) continue;
var job = Roots?.FirstOrDefault(j => j.Id == id);
if (job is null) continue;
if (job.Status is EJobStatus.Queued or EJobStatus.Running or EJobStatus.Waiting) {
hasActive = true;
PollChildren(id);
}
}
if (!hasActive) {
StopChildRefresh();
return;
}
StateHasChanged();
}
}
async void PollChildren(Guid parentId) {
if (FetchChildren is null) return;
try {
DateTime? since = _sinceTimestamps.TryGetValue(parentId, out var ts) ? ts : null;
var (children, newSince) = await FetchChildren(parentId, since);
_sinceTimestamps[parentId] = newSince ?? DateTime.UtcNow;
if (children.Count > 0) {
MergeChildren(parentId, children);
}
} catch {
// next poll will retry
}
}
void MergeChildren(Guid parentId, List<JobStatusDto> incoming) {
if (!_childrenCache.TryGetValue(parentId, out var existing) || existing is null) {
_childrenCache[parentId] = incoming;
return;
}
var dict = existing.ToDictionary(c => c.Id);
foreach (var child in incoming) {
dict[child.Id] = child;
}
_childrenCache[parentId] = dict.Values.ToList();
}
public void Dispose() => StopChildRefresh();
void ToggleGroup(string groupKey) {
if (!_expandedGroups.Remove(groupKey))
_expandedGroups.Add(groupKey);
}
}