265 lines
11 KiB
Plaintext
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);
|
|
}
|
|
}
|