@skyhook-io/k8s-ui 1.8.9 → 1.8.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -4
- package/src/assets/gitops/argocd.png +0 -0
- package/src/assets/gitops/flux.svg +7 -0
- package/src/components/applications/AppChips.tsx +10 -0
- package/src/components/applications/ApplicationDetail.test.tsx +569 -0
- package/src/components/applications/ApplicationDetail.tsx +2517 -450
- package/src/components/applications/ApplicationsList.tsx +4 -1
- package/src/components/applications/ApplicationsView.tsx +101 -18
- package/src/components/applications/applications-view.test.tsx +47 -0
- package/src/components/applications/index.ts +1 -1
- package/src/components/audit/AuditAlerts.tsx +14 -10
- package/src/components/checks/ChecksView.tsx +81 -51
- package/src/components/checks/severity.ts +39 -45
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -2
- package/src/components/compare/CompareResourcePicker.tsx +2 -2
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
- package/src/components/gitops/GitOpsTableView.tsx +14 -59
- package/src/components/gitops/ManagedResourcesList.tsx +8 -11
- package/src/components/gitops/SyncCountdown.tsx +26 -17
- package/src/components/gitops/SyncOptionsDialog.tsx +2 -2
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +277 -104
- package/src/components/gitops/insights/insights-helpers.test.ts +86 -1
- package/src/components/gitops/insights/insights-helpers.ts +69 -1
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -2
- package/src/components/issues/IssuesView.tsx +219 -143
- package/src/components/issues/ResourceIssuesSection.test.tsx +40 -0
- package/src/components/issues/ResourceIssuesSection.tsx +28 -128
- package/src/components/issues/index.ts +3 -2
- package/src/components/issues/issue-timing.ts +53 -0
- package/src/components/issues/issues.test.ts +146 -4
- package/src/components/issues/severity.ts +29 -32
- package/src/components/logs/LogCore.tsx +14 -4
- package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
- package/src/components/logs/useLogStream.ts +8 -1
- package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
- package/src/components/resources/ResourcesSidebar.test.tsx +107 -0
- package/src/components/resources/ResourcesSidebar.tsx +70 -52
- package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
- package/src/components/resources/ResourcesView.tsx +243 -53
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
- package/src/components/resources/renderers/JobRenderer.tsx +3 -1
- package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +47 -0
- package/src/components/resources/renderers/NodeRenderer.test.tsx +103 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +20 -12
- package/src/components/resources/renderers/PVCRenderer.test.tsx +23 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +2 -2
- package/src/components/resources/renderers/PodRenderer.test.tsx +101 -0
- package/src/components/resources/renderers/PodRenderer.tsx +81 -70
- package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
- package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
- package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
- package/src/components/resources/renderers/index.ts +1 -0
- package/src/components/resources/resource-utils-capi.ts +1 -1
- package/src/components/resources/resource-utils.ts +31 -0
- package/src/components/shared/DetailShell.tsx +2 -2
- package/src/components/shared/ResourceActionsBar.tsx +140 -108
- package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
- package/src/components/timeline/TimelineList.tsx +371 -354
- package/src/components/timeline/TimelineStrip.test.tsx +194 -0
- package/src/components/timeline/TimelineStrip.tsx +836 -0
- package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
- package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
- package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
- package/src/components/timeline/TimelineToolbar.tsx +759 -0
- package/src/components/timeline/TimelineToolbarState.test.tsx +64 -0
- package/src/components/timeline/index.ts +23 -0
- package/src/components/timeline/scrubber-math.test.ts +235 -0
- package/src/components/timeline/scrubber-math.ts +249 -0
- package/src/components/timeline/shared.test.tsx +219 -0
- package/src/components/timeline/shared.tsx +232 -447
- package/src/components/timeline/timeline-filters.test.ts +196 -0
- package/src/components/timeline/timeline-filters.ts +180 -0
- package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
- package/src/components/timeline/timeline-lane-sort.ts +77 -0
- package/src/components/timeline/timeline-live.test.ts +131 -0
- package/src/components/timeline/timeline-live.ts +77 -0
- package/src/components/topology/GroupNode.tsx +9 -2
- package/src/components/topology/K8sResourceNode.test.ts +11 -0
- package/src/components/topology/K8sResourceNode.tsx +387 -243
- package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
- package/src/components/topology/TopologyGraph.tsx +89 -16
- package/src/components/topology/TopologySearch.tsx +2 -2
- package/src/components/topology/layout.ts +10 -2
- package/src/components/topology/topology.css +0 -92
- package/src/components/ui/Badge.tsx +5 -0
- package/src/components/ui/BoardSkeleton.tsx +47 -0
- package/src/components/ui/CardSection.tsx +117 -0
- package/src/components/ui/CodeViewer.tsx +2 -2
- package/src/components/ui/Collapse.tsx +59 -0
- package/src/components/ui/ConfirmDialog.tsx +7 -3
- package/src/components/ui/DialogPortal.tsx +31 -9
- package/src/components/ui/Input.tsx +24 -0
- package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
- package/src/components/ui/MultiSelectPicker.tsx +167 -0
- package/src/components/ui/PaneLoader.tsx +10 -2
- package/src/components/ui/ResourceBar.tsx +68 -25
- package/src/components/ui/SearchBox.tsx +18 -4
- package/src/components/ui/SearchPillInput.tsx +2 -2
- package/src/components/ui/Toast.tsx +1 -1
- package/src/components/ui/drawer-components.tsx +39 -26
- package/src/components/ui/index.ts +6 -0
- package/src/components/ui/severity-tone.ts +63 -0
- package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
- package/src/components/workload/WorkloadView.tsx +2682 -472
- package/src/components/workload/index.ts +1 -1
- package/src/theme/variables.css +15 -1
- package/src/types/core.ts +57 -4
- package/src/utils/api-resources.test.ts +54 -0
- package/src/utils/api-resources.ts +68 -1
- package/src/utils/application-topology.test.ts +198 -0
- package/src/utils/application-topology.ts +261 -0
- package/src/utils/applications.test.ts +167 -1
- package/src/utils/applications.ts +1157 -393
- package/src/utils/index.ts +2 -0
- package/src/utils/navigation.test.ts +32 -1
- package/src/utils/navigation.ts +70 -0
- package/src/utils/resource-hierarchy.test.ts +990 -1
- package/src/utils/resource-hierarchy.ts +905 -267
- package/src/utils/resource-icons.ts +2 -0
- package/src/utils/topology-neighborhood.test.ts +202 -1
- package/src/utils/topology-neighborhood.ts +194 -13
- package/src/utils/workflow-execution.test.ts +556 -0
- package/src/utils/workflow-execution.ts +814 -0
- package/src/utils/workload-colors.ts +2 -3
- package/tsconfig.json +2 -2
|
@@ -0,0 +1,814 @@
|
|
|
1
|
+
export type WorkflowExecutionTone =
|
|
2
|
+
"success" | "danger" | "warning" | "info" | "muted";
|
|
3
|
+
|
|
4
|
+
export interface WorkflowExecutionNode {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
displayName: string;
|
|
8
|
+
displayLabel: string;
|
|
9
|
+
displayType: string;
|
|
10
|
+
type: string;
|
|
11
|
+
phase: string;
|
|
12
|
+
templateName?: string;
|
|
13
|
+
templateScope?: string;
|
|
14
|
+
boundaryId?: string;
|
|
15
|
+
podName?: string;
|
|
16
|
+
templateRef?: WorkflowTemplateReference;
|
|
17
|
+
message?: string;
|
|
18
|
+
startedAt?: string;
|
|
19
|
+
finishedAt?: string;
|
|
20
|
+
parentIds: string[];
|
|
21
|
+
childIds: string[];
|
|
22
|
+
hierarchyChildIds: string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface WorkflowExecutionEdge {
|
|
26
|
+
source: string;
|
|
27
|
+
target: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface WorkflowExecutionCounts {
|
|
31
|
+
podTotal: number;
|
|
32
|
+
podSucceeded: number;
|
|
33
|
+
podFailed: number;
|
|
34
|
+
podRunning: number;
|
|
35
|
+
podPending: number;
|
|
36
|
+
nodeTotal: number;
|
|
37
|
+
nodeSucceeded: number;
|
|
38
|
+
nodeFailed: number;
|
|
39
|
+
nodeRunning: number;
|
|
40
|
+
nodeSkipped: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface WorkflowExecutionPath {
|
|
44
|
+
terminal: WorkflowExecutionNode;
|
|
45
|
+
nodes: WorkflowExecutionNode[];
|
|
46
|
+
tone: WorkflowExecutionTone;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface WorkflowExecutionActivity {
|
|
50
|
+
id: string;
|
|
51
|
+
at: string;
|
|
52
|
+
label: string;
|
|
53
|
+
detail?: string;
|
|
54
|
+
tone: WorkflowExecutionTone;
|
|
55
|
+
nodeId?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface WorkflowTemplateReference {
|
|
59
|
+
name: string;
|
|
60
|
+
kind: "WorkflowTemplate" | "ClusterWorkflowTemplate";
|
|
61
|
+
resourceKind: "workflowtemplates" | "clusterworkflowtemplates";
|
|
62
|
+
namespace: string;
|
|
63
|
+
clusterScope: boolean;
|
|
64
|
+
source: "workflow" | "task";
|
|
65
|
+
template?: string;
|
|
66
|
+
taskName?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface WorkflowExecutionModel {
|
|
70
|
+
nodes: WorkflowExecutionNode[];
|
|
71
|
+
edges: WorkflowExecutionEdge[];
|
|
72
|
+
roots: WorkflowExecutionNode[];
|
|
73
|
+
executionNodes: WorkflowExecutionNode[];
|
|
74
|
+
executionRoots: WorkflowExecutionNode[];
|
|
75
|
+
visibleSteps: WorkflowExecutionNode[];
|
|
76
|
+
focusPaths: WorkflowExecutionPath[];
|
|
77
|
+
activity: WorkflowExecutionActivity[];
|
|
78
|
+
counts: WorkflowExecutionCounts;
|
|
79
|
+
templateRefs: WorkflowTemplateReference[];
|
|
80
|
+
resourcesDuration?: Record<string, number>;
|
|
81
|
+
isLarge: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface WorkflowExecutionRow {
|
|
85
|
+
node: WorkflowExecutionNode;
|
|
86
|
+
depth: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const LEAF_NODE_TYPES = new Set(["Pod", "Suspend", "Skipped"]);
|
|
90
|
+
const ACTIVITY_NODE_TYPES = new Set([
|
|
91
|
+
"Pod",
|
|
92
|
+
"HTTP",
|
|
93
|
+
"Plugin",
|
|
94
|
+
"Suspend",
|
|
95
|
+
"Skipped",
|
|
96
|
+
]);
|
|
97
|
+
const LARGE_WORKFLOW_NODE_COUNT = 80;
|
|
98
|
+
|
|
99
|
+
export function buildWorkflowExecutionModel(
|
|
100
|
+
workflow: any,
|
|
101
|
+
): WorkflowExecutionModel {
|
|
102
|
+
const rawNodes = asRecord(workflow?.status?.nodes);
|
|
103
|
+
const nodeById = new Map<string, WorkflowExecutionNode>();
|
|
104
|
+
|
|
105
|
+
for (const [id, raw] of Object.entries(rawNodes)) {
|
|
106
|
+
const node = asRecord(raw);
|
|
107
|
+
const displayName = asString(node.displayName) || asString(node.name) || id;
|
|
108
|
+
const templateRef = templateReferenceFromObject(
|
|
109
|
+
node.templateRef,
|
|
110
|
+
asString(workflow?.metadata?.namespace),
|
|
111
|
+
"task",
|
|
112
|
+
asString(node.templateName),
|
|
113
|
+
displayName,
|
|
114
|
+
);
|
|
115
|
+
nodeById.set(id, {
|
|
116
|
+
id,
|
|
117
|
+
name: asString(node.name) || displayName,
|
|
118
|
+
displayName,
|
|
119
|
+
displayLabel: displayName,
|
|
120
|
+
displayType: workflowNodeTypeLabel(asString(node.type) || "Unknown"),
|
|
121
|
+
type: asString(node.type) || "Unknown",
|
|
122
|
+
phase:
|
|
123
|
+
asString(node.phase) ||
|
|
124
|
+
(asString(node.type) === "Skipped" ? "Skipped" : "Pending"),
|
|
125
|
+
templateName: asString(node.templateName),
|
|
126
|
+
templateScope: asString(node.templateScope),
|
|
127
|
+
boundaryId: asString(node.boundaryID),
|
|
128
|
+
podName: asString(node.podName),
|
|
129
|
+
templateRef: templateRef ?? undefined,
|
|
130
|
+
message: asString(node.message),
|
|
131
|
+
startedAt: asString(node.startedAt),
|
|
132
|
+
finishedAt: asString(node.finishedAt),
|
|
133
|
+
parentIds: [],
|
|
134
|
+
childIds: [],
|
|
135
|
+
hierarchyChildIds: [],
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const edgeKeys = new Set<string>();
|
|
140
|
+
for (const [id, raw] of Object.entries(rawNodes)) {
|
|
141
|
+
const node = nodeById.get(id);
|
|
142
|
+
if (!node) continue;
|
|
143
|
+
const rawNode = asRecord(raw);
|
|
144
|
+
const children = asStringArray(rawNode.children);
|
|
145
|
+
node.hierarchyChildIds = (
|
|
146
|
+
children.length > 0 ? children : asStringArray(rawNode.outboundNodes)
|
|
147
|
+
).filter((childID) => nodeById.has(childID));
|
|
148
|
+
const childIds = [...children, ...asStringArray(rawNode.outboundNodes)];
|
|
149
|
+
for (const childId of childIds) {
|
|
150
|
+
if (!nodeById.has(childId)) continue;
|
|
151
|
+
addWorkflowEdge(nodeById, edgeKeys, id, childId);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
for (const node of nodeById.values()) {
|
|
156
|
+
if (
|
|
157
|
+
node.parentIds.length > 0 ||
|
|
158
|
+
!node.boundaryId ||
|
|
159
|
+
node.boundaryId === node.id ||
|
|
160
|
+
!nodeById.has(node.boundaryId)
|
|
161
|
+
)
|
|
162
|
+
continue;
|
|
163
|
+
addWorkflowEdge(nodeById, edgeKeys, node.boundaryId, node.id);
|
|
164
|
+
const boundary = nodeById.get(node.boundaryId);
|
|
165
|
+
if (boundary && !boundary.hierarchyChildIds.includes(node.id))
|
|
166
|
+
boundary.hierarchyChildIds = [...boundary.hierarchyChildIds, node.id];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const nodes = [...nodeById.values()].sort(compareExecutionNodes);
|
|
170
|
+
const roots = nodes.filter((node) => node.parentIds.length === 0);
|
|
171
|
+
const { nodes: executionNodes, roots: executionRoots } =
|
|
172
|
+
buildPresentationGraph(nodes, asString(workflow?.metadata?.name));
|
|
173
|
+
const visibleSteps = executionNodes;
|
|
174
|
+
const counts = countWorkflowExecution(executionNodes);
|
|
175
|
+
const focusNodes = pickFocusNodes(executionNodes);
|
|
176
|
+
const executionNodeById = new Map(
|
|
177
|
+
executionNodes.map((node) => [node.id, node]),
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
nodes,
|
|
182
|
+
edges: [...edgeKeys].map((key) => {
|
|
183
|
+
const [source, target] = key.split("\u0000");
|
|
184
|
+
return { source, target };
|
|
185
|
+
}),
|
|
186
|
+
roots,
|
|
187
|
+
executionNodes,
|
|
188
|
+
executionRoots,
|
|
189
|
+
visibleSteps,
|
|
190
|
+
focusPaths: focusNodes.map((node) => ({
|
|
191
|
+
terminal: node,
|
|
192
|
+
nodes: lineagePath(executionNodeById, node),
|
|
193
|
+
tone: phaseTone(node.phase),
|
|
194
|
+
})),
|
|
195
|
+
activity: workflowActivity(workflow, executionNodes),
|
|
196
|
+
counts,
|
|
197
|
+
templateRefs: collectWorkflowTemplateRefs(workflow),
|
|
198
|
+
resourcesDuration: asNumberRecord(workflow?.status?.resourcesDuration),
|
|
199
|
+
isLarge: executionNodes.length > LARGE_WORKFLOW_NODE_COUNT,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function flattenWorkflowExecution(
|
|
204
|
+
model: WorkflowExecutionModel,
|
|
205
|
+
): WorkflowExecutionRow[] {
|
|
206
|
+
const byID = new Map(model.executionNodes.map((node) => [node.id, node]));
|
|
207
|
+
const seen = new Set<string>();
|
|
208
|
+
const rows: WorkflowExecutionRow[] = [];
|
|
209
|
+
const visit = (node: WorkflowExecutionNode, depth: number) => {
|
|
210
|
+
if (seen.has(node.id)) return;
|
|
211
|
+
seen.add(node.id);
|
|
212
|
+
rows.push({ node, depth });
|
|
213
|
+
for (const childID of node.childIds) {
|
|
214
|
+
const child = byID.get(childID);
|
|
215
|
+
if (child) visit(child, depth + 1);
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
for (const root of model.executionRoots) visit(root, 0);
|
|
219
|
+
for (const node of model.executionNodes) visit(node, 0);
|
|
220
|
+
return rows;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function buildPresentationGraph(
|
|
224
|
+
rawNodes: WorkflowExecutionNode[],
|
|
225
|
+
workflowName: string,
|
|
226
|
+
): { nodes: WorkflowExecutionNode[]; roots: WorkflowExecutionNode[] } {
|
|
227
|
+
const rawByID = new Map(rawNodes.map((node) => [node.id, node]));
|
|
228
|
+
const elided = new Set<string>();
|
|
229
|
+
for (const node of rawNodes) {
|
|
230
|
+
if (node.type !== "StepGroup") continue;
|
|
231
|
+
if (
|
|
232
|
+
node.childIds.length === 1 ||
|
|
233
|
+
(node.childIds.length === 0 && !isWorkflowProblemPhase(node.phase))
|
|
234
|
+
)
|
|
235
|
+
elided.add(node.id);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const presentationByID = new Map<string, WorkflowExecutionNode>();
|
|
239
|
+
for (const rawNode of rawNodes) {
|
|
240
|
+
if (elided.has(rawNode.id)) continue;
|
|
241
|
+
presentationByID.set(rawNode.id, {
|
|
242
|
+
...rawNode,
|
|
243
|
+
parentIds: [],
|
|
244
|
+
childIds: [],
|
|
245
|
+
displayLabel: presentationLabel(rawNode, workflowName),
|
|
246
|
+
displayType: presentationType(rawNode),
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const nearestVisibleDescendants = (
|
|
251
|
+
id: string,
|
|
252
|
+
path = new Set<string>(),
|
|
253
|
+
): string[] => {
|
|
254
|
+
if (path.has(id)) return [];
|
|
255
|
+
if (!elided.has(id)) return presentationByID.has(id) ? [id] : [];
|
|
256
|
+
const node = rawByID.get(id);
|
|
257
|
+
if (!node) return [];
|
|
258
|
+
const nextPath = new Set(path).add(id);
|
|
259
|
+
return dedupeStrings(
|
|
260
|
+
node.hierarchyChildIds.flatMap((childID) =>
|
|
261
|
+
nearestVisibleDescendants(childID, nextPath),
|
|
262
|
+
),
|
|
263
|
+
);
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
for (const node of presentationByID.values()) {
|
|
267
|
+
for (const childID of dedupeStrings(
|
|
268
|
+
rawByID.get(node.id)?.hierarchyChildIds ?? [],
|
|
269
|
+
)) {
|
|
270
|
+
for (const visibleChildID of nearestVisibleDescendants(childID))
|
|
271
|
+
addPresentationEdge(presentationByID, node.id, visibleChildID);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
for (const hiddenID of elided) {
|
|
276
|
+
const hidden = rawByID.get(hiddenID);
|
|
277
|
+
if (!hidden || hidden.hierarchyChildIds.length !== 1) continue;
|
|
278
|
+
const stepNumber = stepGroupNumber(hidden);
|
|
279
|
+
if (!stepNumber) continue;
|
|
280
|
+
for (const childID of nearestVisibleDescendants(
|
|
281
|
+
hidden.hierarchyChildIds[0],
|
|
282
|
+
)) {
|
|
283
|
+
const child = presentationByID.get(childID);
|
|
284
|
+
if (child)
|
|
285
|
+
child.displayType = `Step ${stepNumber} · ${child.displayType}`;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
for (const node of presentationByID.values())
|
|
290
|
+
normalizeGroupedChildren(node, presentationByID);
|
|
291
|
+
|
|
292
|
+
const nodes = [...presentationByID.values()].sort(compareExecutionNodes);
|
|
293
|
+
return { nodes, roots: nodes.filter((node) => node.parentIds.length === 0) };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function presentationLabel(
|
|
297
|
+
node: WorkflowExecutionNode,
|
|
298
|
+
workflowName: string,
|
|
299
|
+
): string {
|
|
300
|
+
if (isExitHandlerNode(node, workflowName)) return "Exit handler";
|
|
301
|
+
if (
|
|
302
|
+
node.parentIds.length === 0 &&
|
|
303
|
+
node.displayName === workflowName &&
|
|
304
|
+
node.templateName
|
|
305
|
+
)
|
|
306
|
+
return node.templateName;
|
|
307
|
+
if (node.type !== "StepGroup") return node.displayName;
|
|
308
|
+
const stepNumber = stepGroupNumber(node);
|
|
309
|
+
return stepNumber
|
|
310
|
+
? `Step ${stepNumber}`
|
|
311
|
+
: node.childIds.length > 1
|
|
312
|
+
? "Parallel steps"
|
|
313
|
+
: "Workflow step";
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function presentationType(node: WorkflowExecutionNode): string {
|
|
317
|
+
if (node.type === "StepGroup") {
|
|
318
|
+
if (node.childIds.length > 1)
|
|
319
|
+
return `Parallel · ${node.childIds.length} steps`;
|
|
320
|
+
return "Step group";
|
|
321
|
+
}
|
|
322
|
+
if (node.type === "TaskGroup")
|
|
323
|
+
return `Fan-out · ${node.childIds.length} runs`;
|
|
324
|
+
return workflowNodeTypeLabel(node.type);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function normalizeGroupedChildren(
|
|
328
|
+
parent: WorkflowExecutionNode,
|
|
329
|
+
nodes: Map<string, WorkflowExecutionNode>,
|
|
330
|
+
) {
|
|
331
|
+
if (parent.type === "Retry") {
|
|
332
|
+
for (const childID of parent.childIds) {
|
|
333
|
+
const child = nodes.get(childID);
|
|
334
|
+
if (!child) continue;
|
|
335
|
+
const iteration = parseIterationName(child.displayName);
|
|
336
|
+
if (
|
|
337
|
+
!iteration ||
|
|
338
|
+
iteration.base !== parent.displayName ||
|
|
339
|
+
iteration.value !== undefined
|
|
340
|
+
)
|
|
341
|
+
continue;
|
|
342
|
+
child.displayLabel = `Attempt ${iteration.index + 1}`;
|
|
343
|
+
}
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (parent.type !== "TaskGroup" && parent.type !== "StepGroup") return;
|
|
347
|
+
const group = iterationGroupInfo(parent, nodes);
|
|
348
|
+
if (!group) return;
|
|
349
|
+
parent.displayLabel = group.base;
|
|
350
|
+
const stepNumber =
|
|
351
|
+
parent.type === "StepGroup" ? stepGroupNumber(parent) : null;
|
|
352
|
+
parent.displayType = `${stepNumber ? `Step ${stepNumber} · ` : ""}Fan-out · ${parent.childIds.length} runs`;
|
|
353
|
+
for (const childID of parent.childIds) {
|
|
354
|
+
const child = nodes.get(childID);
|
|
355
|
+
if (!child) continue;
|
|
356
|
+
const iteration = parseIterationName(child.displayName);
|
|
357
|
+
if (!iteration) continue;
|
|
358
|
+
child.displayLabel = iteration.value || `Run ${iteration.index + 1}`;
|
|
359
|
+
child.displayType = `Run ${iteration.index + 1} · ${workflowNodeTypeLabel(child.type)}`;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function iterationGroupInfo(
|
|
364
|
+
parent: WorkflowExecutionNode,
|
|
365
|
+
nodes?: Map<string, WorkflowExecutionNode>,
|
|
366
|
+
): { base: string } | null {
|
|
367
|
+
if (parent.childIds.length < 2) return null;
|
|
368
|
+
if (!nodes) return null;
|
|
369
|
+
const iterations = parent.childIds
|
|
370
|
+
.map((id) => nodes.get(id))
|
|
371
|
+
.map((node) => (node ? parseIterationName(node.displayName) : null));
|
|
372
|
+
if (iterations.some((iteration) => !iteration)) return null;
|
|
373
|
+
const base = iterations[0]!.base;
|
|
374
|
+
return iterations.every((iteration) => iteration!.base === base)
|
|
375
|
+
? { base }
|
|
376
|
+
: null;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function parseIterationName(
|
|
380
|
+
displayName: string,
|
|
381
|
+
): { base: string; index: number; value?: string } | null {
|
|
382
|
+
const match = displayName.match(/^(.+)\((\d+)(?::(.+))?\)$/);
|
|
383
|
+
if (!match) return null;
|
|
384
|
+
return { base: match[1], index: Number(match[2]), value: match[3] };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function stepGroupNumber(node: WorkflowExecutionNode): number | null {
|
|
388
|
+
const match = node.name.match(/\[(\d+)\]$/);
|
|
389
|
+
return match ? Number(match[1]) + 1 : null;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function isExitHandlerNode(
|
|
393
|
+
node: WorkflowExecutionNode,
|
|
394
|
+
workflowName: string,
|
|
395
|
+
): boolean {
|
|
396
|
+
return Boolean(
|
|
397
|
+
workflowName &&
|
|
398
|
+
(node.name === `${workflowName}.onExit` ||
|
|
399
|
+
node.displayName === `${workflowName}.onExit`),
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function workflowNodeTypeLabel(type: string): string {
|
|
404
|
+
switch (type) {
|
|
405
|
+
case "Steps":
|
|
406
|
+
return "Steps template";
|
|
407
|
+
case "StepGroup":
|
|
408
|
+
return "Step group";
|
|
409
|
+
case "TaskGroup":
|
|
410
|
+
return "Fan-out";
|
|
411
|
+
case "Retry":
|
|
412
|
+
return "Retry strategy";
|
|
413
|
+
case "Skipped":
|
|
414
|
+
return "Skipped step";
|
|
415
|
+
default:
|
|
416
|
+
return type;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function addPresentationEdge(
|
|
421
|
+
nodes: Map<string, WorkflowExecutionNode>,
|
|
422
|
+
source: string,
|
|
423
|
+
target: string,
|
|
424
|
+
) {
|
|
425
|
+
if (source === target) return;
|
|
426
|
+
const parent = nodes.get(source);
|
|
427
|
+
const child = nodes.get(target);
|
|
428
|
+
if (!parent || !child || parent.childIds.includes(target)) return;
|
|
429
|
+
parent.childIds = [...parent.childIds, target];
|
|
430
|
+
child.parentIds = [...child.parentIds, source];
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function dedupeStrings(values: string[]): string[] {
|
|
434
|
+
return [...new Set(values)];
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function collectWorkflowTemplateRefs(
|
|
438
|
+
workflow: any,
|
|
439
|
+
): WorkflowTemplateReference[] {
|
|
440
|
+
const namespace = asString(workflow?.metadata?.namespace);
|
|
441
|
+
const refs: WorkflowTemplateReference[] = [];
|
|
442
|
+
const workflowRef = templateReferenceFromObject(
|
|
443
|
+
workflow?.spec?.workflowTemplateRef,
|
|
444
|
+
namespace,
|
|
445
|
+
"workflow",
|
|
446
|
+
);
|
|
447
|
+
if (workflowRef) refs.push(workflowRef);
|
|
448
|
+
|
|
449
|
+
const effectiveSpec = effectiveWorkflowSpec(workflow);
|
|
450
|
+
for (const template of asArray(effectiveSpec?.templates)) {
|
|
451
|
+
const templateMap = asRecord(template);
|
|
452
|
+
const templateName = asString(templateMap.name);
|
|
453
|
+
for (const task of taskLikeObjects(templateMap)) {
|
|
454
|
+
const taskMap = asRecord(task);
|
|
455
|
+
const ref = templateReferenceFromObject(
|
|
456
|
+
taskMap.templateRef,
|
|
457
|
+
namespace,
|
|
458
|
+
"task",
|
|
459
|
+
templateName,
|
|
460
|
+
asString(taskMap.name),
|
|
461
|
+
);
|
|
462
|
+
if (ref) refs.push(ref);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
for (const raw of Object.values(asRecord(workflow?.status?.nodes))) {
|
|
467
|
+
const node = asRecord(raw);
|
|
468
|
+
const ref = templateReferenceFromObject(
|
|
469
|
+
node.templateRef,
|
|
470
|
+
namespace,
|
|
471
|
+
"task",
|
|
472
|
+
asString(node.templateName),
|
|
473
|
+
asString(node.displayName) || asString(node.name),
|
|
474
|
+
);
|
|
475
|
+
if (ref) refs.push(ref);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return dedupeTemplateRefs(refs);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function effectiveWorkflowSpec(workflow: any): Record<string, any> {
|
|
482
|
+
const stored = asRecord(workflow?.status?.storedWorkflowTemplateSpec);
|
|
483
|
+
return Object.keys(stored).length > 0 ? stored : asRecord(workflow?.spec);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function phaseTone(phase: string): WorkflowExecutionTone {
|
|
487
|
+
switch (phase) {
|
|
488
|
+
case "Succeeded":
|
|
489
|
+
return "success";
|
|
490
|
+
case "Failed":
|
|
491
|
+
case "Error":
|
|
492
|
+
return "danger";
|
|
493
|
+
case "Running":
|
|
494
|
+
case "Pending":
|
|
495
|
+
return "warning";
|
|
496
|
+
case "Skipped":
|
|
497
|
+
case "Omitted":
|
|
498
|
+
return "muted";
|
|
499
|
+
default:
|
|
500
|
+
return "info";
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function isWorkflowProblemPhase(phase: string): boolean {
|
|
505
|
+
return phase === "Failed" || phase === "Error";
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function addWorkflowEdge(
|
|
509
|
+
nodeById: Map<string, WorkflowExecutionNode>,
|
|
510
|
+
edgeKeys: Set<string>,
|
|
511
|
+
source: string,
|
|
512
|
+
target: string,
|
|
513
|
+
) {
|
|
514
|
+
const key = `${source}\u0000${target}`;
|
|
515
|
+
if (edgeKeys.has(key)) return;
|
|
516
|
+
const parent = nodeById.get(source);
|
|
517
|
+
const child = nodeById.get(target);
|
|
518
|
+
if (!parent || !child) return;
|
|
519
|
+
edgeKeys.add(key);
|
|
520
|
+
parent.childIds = [...parent.childIds, target];
|
|
521
|
+
child.parentIds = [...child.parentIds, source];
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function countWorkflowExecution(
|
|
525
|
+
nodes: WorkflowExecutionNode[],
|
|
526
|
+
): WorkflowExecutionCounts {
|
|
527
|
+
const counts: WorkflowExecutionCounts = {
|
|
528
|
+
podTotal: 0,
|
|
529
|
+
podSucceeded: 0,
|
|
530
|
+
podFailed: 0,
|
|
531
|
+
podRunning: 0,
|
|
532
|
+
podPending: 0,
|
|
533
|
+
nodeTotal: 0,
|
|
534
|
+
nodeSucceeded: 0,
|
|
535
|
+
nodeFailed: 0,
|
|
536
|
+
nodeRunning: 0,
|
|
537
|
+
nodeSkipped: 0,
|
|
538
|
+
};
|
|
539
|
+
for (const node of nodes) {
|
|
540
|
+
if (node.type === "Pod") {
|
|
541
|
+
counts.podTotal++;
|
|
542
|
+
if (node.phase === "Succeeded") counts.podSucceeded++;
|
|
543
|
+
else if (isWorkflowProblemPhase(node.phase)) counts.podFailed++;
|
|
544
|
+
else if (node.phase === "Running") counts.podRunning++;
|
|
545
|
+
else if (node.phase === "Pending") counts.podPending++;
|
|
546
|
+
}
|
|
547
|
+
counts.nodeTotal++;
|
|
548
|
+
if (node.phase === "Succeeded") counts.nodeSucceeded++;
|
|
549
|
+
else if (isWorkflowProblemPhase(node.phase)) counts.nodeFailed++;
|
|
550
|
+
else if (node.phase === "Running") counts.nodeRunning++;
|
|
551
|
+
else if (node.phase === "Skipped" || node.phase === "Omitted")
|
|
552
|
+
counts.nodeSkipped++;
|
|
553
|
+
}
|
|
554
|
+
return counts;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function pickFocusNodes(
|
|
558
|
+
nodes: WorkflowExecutionNode[],
|
|
559
|
+
): WorkflowExecutionNode[] {
|
|
560
|
+
const failed = nodes.filter(
|
|
561
|
+
(node) =>
|
|
562
|
+
isWorkflowProblemPhase(node.phase) &&
|
|
563
|
+
(LEAF_NODE_TYPES.has(node.type) ||
|
|
564
|
+
node.type === "Container" ||
|
|
565
|
+
node.message),
|
|
566
|
+
);
|
|
567
|
+
if (failed.length > 0) return failed.slice(0, 12);
|
|
568
|
+
const active = nodes.filter(
|
|
569
|
+
(node) => node.phase === "Running" || node.phase === "Pending",
|
|
570
|
+
);
|
|
571
|
+
return active.slice(0, 12);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function lineagePath(
|
|
575
|
+
nodeById: Map<string, WorkflowExecutionNode>,
|
|
576
|
+
terminal: WorkflowExecutionNode,
|
|
577
|
+
): WorkflowExecutionNode[] {
|
|
578
|
+
const path: WorkflowExecutionNode[] = [];
|
|
579
|
+
const seen = new Set<string>();
|
|
580
|
+
let current: WorkflowExecutionNode | undefined = terminal;
|
|
581
|
+
while (current && !seen.has(current.id)) {
|
|
582
|
+
seen.add(current.id);
|
|
583
|
+
path.unshift(current);
|
|
584
|
+
const parentId: string | undefined = [...current.parentIds].sort((a, b) =>
|
|
585
|
+
compareExecutionNodes(nodeById.get(a), nodeById.get(b)),
|
|
586
|
+
)[0];
|
|
587
|
+
current = parentId ? nodeById.get(parentId) : undefined;
|
|
588
|
+
}
|
|
589
|
+
return path;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function workflowActivity(
|
|
593
|
+
workflow: any,
|
|
594
|
+
nodes: WorkflowExecutionNode[],
|
|
595
|
+
): WorkflowExecutionActivity[] {
|
|
596
|
+
const items: WorkflowExecutionActivity[] = [];
|
|
597
|
+
const scheduledAt = asString(
|
|
598
|
+
workflow?.metadata?.annotations?.["workflows.argoproj.io/scheduled-time"],
|
|
599
|
+
);
|
|
600
|
+
if (scheduledAt) {
|
|
601
|
+
items.push({
|
|
602
|
+
id: "workflow-scheduled",
|
|
603
|
+
at: scheduledAt,
|
|
604
|
+
label: "Scheduled",
|
|
605
|
+
tone: "info",
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
const startedAt = asString(workflow?.status?.startedAt);
|
|
609
|
+
if (startedAt) {
|
|
610
|
+
items.push({
|
|
611
|
+
id: "workflow-started",
|
|
612
|
+
at: startedAt,
|
|
613
|
+
label: "Workflow started",
|
|
614
|
+
tone: "info",
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
for (const node of activityNodes(nodes)) {
|
|
618
|
+
if (node.startedAt) {
|
|
619
|
+
items.push({
|
|
620
|
+
id: `${node.id}-started`,
|
|
621
|
+
at: node.startedAt,
|
|
622
|
+
label: `${node.displayLabel} started`,
|
|
623
|
+
detail: node.displayType,
|
|
624
|
+
tone: phaseTone(node.phase),
|
|
625
|
+
nodeId: node.id,
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
if (node.finishedAt) {
|
|
629
|
+
items.push({
|
|
630
|
+
id: `${node.id}-finished`,
|
|
631
|
+
at: node.finishedAt,
|
|
632
|
+
label: `${node.displayLabel} ${activityVerb(node.phase)}`,
|
|
633
|
+
detail: node.message || node.displayType,
|
|
634
|
+
tone: phaseTone(node.phase),
|
|
635
|
+
nodeId: node.id,
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
const finishedAt = asString(workflow?.status?.finishedAt);
|
|
640
|
+
if (finishedAt) {
|
|
641
|
+
items.push({
|
|
642
|
+
id: "workflow-finished",
|
|
643
|
+
at: finishedAt,
|
|
644
|
+
label: `Workflow ${activityVerb(asString(workflow?.status?.phase) || "finished")}`,
|
|
645
|
+
tone: phaseTone(asString(workflow?.status?.phase)),
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
return items.sort((a, b) => Date.parse(a.at) - Date.parse(b.at));
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function activityNodes(
|
|
652
|
+
nodes: WorkflowExecutionNode[],
|
|
653
|
+
): WorkflowExecutionNode[] {
|
|
654
|
+
const byID = new Map(nodes.map((node) => [node.id, node]));
|
|
655
|
+
const failedExecutableDescendant = (node: WorkflowExecutionNode): boolean => {
|
|
656
|
+
const queue = [...node.childIds];
|
|
657
|
+
const seen = new Set<string>();
|
|
658
|
+
while (queue.length > 0) {
|
|
659
|
+
const id = queue.shift()!;
|
|
660
|
+
if (seen.has(id)) continue;
|
|
661
|
+
seen.add(id);
|
|
662
|
+
const child = byID.get(id);
|
|
663
|
+
if (!child) continue;
|
|
664
|
+
if (
|
|
665
|
+
ACTIVITY_NODE_TYPES.has(child.type) &&
|
|
666
|
+
isWorkflowProblemPhase(child.phase)
|
|
667
|
+
)
|
|
668
|
+
return true;
|
|
669
|
+
queue.push(...child.childIds);
|
|
670
|
+
}
|
|
671
|
+
return false;
|
|
672
|
+
};
|
|
673
|
+
const executableMessages = new Set(
|
|
674
|
+
nodes
|
|
675
|
+
.filter(
|
|
676
|
+
(node) =>
|
|
677
|
+
ACTIVITY_NODE_TYPES.has(node.type) &&
|
|
678
|
+
isWorkflowProblemPhase(node.phase),
|
|
679
|
+
)
|
|
680
|
+
.map((node) => node.message)
|
|
681
|
+
.filter(Boolean),
|
|
682
|
+
);
|
|
683
|
+
return nodes.filter((node) => {
|
|
684
|
+
if (ACTIVITY_NODE_TYPES.has(node.type)) return true;
|
|
685
|
+
if (node.type === "Container")
|
|
686
|
+
return (
|
|
687
|
+
isWorkflowProblemPhase(node.phase) &&
|
|
688
|
+
Boolean(node.message) &&
|
|
689
|
+
!executableMessages.has(node.message)
|
|
690
|
+
);
|
|
691
|
+
return (
|
|
692
|
+
isWorkflowProblemPhase(node.phase) &&
|
|
693
|
+
Boolean(node.message) &&
|
|
694
|
+
!failedExecutableDescendant(node)
|
|
695
|
+
);
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function activityVerb(phase: string): string {
|
|
700
|
+
switch (phase) {
|
|
701
|
+
case "Succeeded":
|
|
702
|
+
return "succeeded";
|
|
703
|
+
case "Failed":
|
|
704
|
+
return "failed";
|
|
705
|
+
case "Error":
|
|
706
|
+
return "errored";
|
|
707
|
+
case "Skipped":
|
|
708
|
+
case "Omitted":
|
|
709
|
+
return "skipped";
|
|
710
|
+
default:
|
|
711
|
+
return "finished";
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function templateReferenceFromObject(
|
|
716
|
+
raw: any,
|
|
717
|
+
namespace: string,
|
|
718
|
+
source: "workflow" | "task",
|
|
719
|
+
template?: string,
|
|
720
|
+
taskName?: string,
|
|
721
|
+
): WorkflowTemplateReference | null {
|
|
722
|
+
const ref = asRecord(raw);
|
|
723
|
+
const name = asString(ref.name);
|
|
724
|
+
if (!name) return null;
|
|
725
|
+
const clusterScope = ref.clusterScope === true;
|
|
726
|
+
return {
|
|
727
|
+
name,
|
|
728
|
+
kind: clusterScope ? "ClusterWorkflowTemplate" : "WorkflowTemplate",
|
|
729
|
+
resourceKind: clusterScope
|
|
730
|
+
? "clusterworkflowtemplates"
|
|
731
|
+
: "workflowtemplates",
|
|
732
|
+
namespace: clusterScope ? "" : namespace,
|
|
733
|
+
clusterScope,
|
|
734
|
+
source,
|
|
735
|
+
template: asString(ref.template) || template,
|
|
736
|
+
taskName,
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function taskLikeObjects(template: Record<string, any>): any[] {
|
|
741
|
+
const tasks: any[] = [];
|
|
742
|
+
for (const task of asArray(template?.dag?.tasks)) tasks.push(task);
|
|
743
|
+
for (const group of asArray(template?.steps)) {
|
|
744
|
+
for (const step of asArray(group)) tasks.push(step);
|
|
745
|
+
}
|
|
746
|
+
return tasks;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function dedupeTemplateRefs(
|
|
750
|
+
refs: WorkflowTemplateReference[],
|
|
751
|
+
): WorkflowTemplateReference[] {
|
|
752
|
+
const seen = new Set<string>();
|
|
753
|
+
const out: WorkflowTemplateReference[] = [];
|
|
754
|
+
for (const ref of refs) {
|
|
755
|
+
const key = [
|
|
756
|
+
ref.resourceKind,
|
|
757
|
+
ref.namespace,
|
|
758
|
+
ref.name,
|
|
759
|
+
ref.source,
|
|
760
|
+
ref.template || "",
|
|
761
|
+
ref.taskName || "",
|
|
762
|
+
].join("\u0000");
|
|
763
|
+
if (seen.has(key)) continue;
|
|
764
|
+
seen.add(key);
|
|
765
|
+
out.push(ref);
|
|
766
|
+
}
|
|
767
|
+
return out;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function compareExecutionNodes(
|
|
771
|
+
a?: WorkflowExecutionNode,
|
|
772
|
+
b?: WorkflowExecutionNode,
|
|
773
|
+
): number {
|
|
774
|
+
if (!a && !b) return 0;
|
|
775
|
+
if (!a) return 1;
|
|
776
|
+
if (!b) return -1;
|
|
777
|
+
const aTime = a.startedAt
|
|
778
|
+
? Date.parse(a.startedAt)
|
|
779
|
+
: Number.POSITIVE_INFINITY;
|
|
780
|
+
const bTime = b.startedAt
|
|
781
|
+
? Date.parse(b.startedAt)
|
|
782
|
+
: Number.POSITIVE_INFINITY;
|
|
783
|
+
if (aTime !== bTime) return aTime - bTime;
|
|
784
|
+
return a.displayName.localeCompare(b.displayName) || a.id.localeCompare(b.id);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function asRecord(value: any): Record<string, any> {
|
|
788
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
789
|
+
? value
|
|
790
|
+
: {};
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function asArray(value: any): any[] {
|
|
794
|
+
return Array.isArray(value) ? value : [];
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function asString(value: any): string {
|
|
798
|
+
return typeof value === "string" ? value : "";
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function asStringArray(value: any): string[] {
|
|
802
|
+
return Array.isArray(value)
|
|
803
|
+
? value.filter((item): item is string => typeof item === "string")
|
|
804
|
+
: [];
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function asNumberRecord(value: any): Record<string, number> | undefined {
|
|
808
|
+
const record = asRecord(value);
|
|
809
|
+
const out: Record<string, number> = {};
|
|
810
|
+
for (const [key, raw] of Object.entries(record)) {
|
|
811
|
+
if (typeof raw === "number") out[key] = raw;
|
|
812
|
+
}
|
|
813
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
814
|
+
}
|