@skyhook-io/radar-app 1.9.0 → 1.9.2
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 +7 -7
- package/src/App.tsx +69 -16
- package/src/api/apiResources.test.ts +11 -0
- package/src/api/apiResources.ts +51 -12
- package/src/api/client.capacity.test.ts +92 -0
- package/src/api/client.ts +2905 -2081
- package/src/api/config.test.ts +47 -0
- package/src/api/config.ts +15 -0
- package/src/api/diagnose.ts +15 -15
- package/src/components/ConnectionErrorView.test.tsx +88 -0
- package/src/components/ConnectionErrorView.tsx +128 -22
- package/src/components/capacity/CapacityActivity.tsx +787 -0
- package/src/components/capacity/CapacityDemand.tsx +961 -0
- package/src/components/capacity/CapacityOverview.tsx +1529 -0
- package/src/components/capacity/CapacityPoolDetail.tsx +1626 -0
- package/src/components/capacity/CapacityView.test.tsx +2287 -0
- package/src/components/capacity/CapacityView.tsx +85 -0
- package/src/components/capacity/ClusterSchedulingCard.tsx +603 -0
- package/src/components/capacity/DemandNomination.test.tsx +151 -0
- package/src/components/capacity/certaintyGlyph.test.tsx +191 -0
- package/src/components/capacity/coverageCertainty.test.ts +162 -0
- package/src/components/capacity/podDemandGate.test.ts +47 -0
- package/src/components/capacity/podDemandGate.ts +22 -0
- package/src/components/capacity/schedulingBar.test.ts +244 -0
- package/src/components/capacity/shared.tsx +1841 -0
- package/src/components/diagnose/AISettings.tsx +21 -7
- package/src/components/diagnose/AgentSetupNotice.tsx +117 -0
- package/src/components/diagnose/DiagnoseContext.tsx +127 -57
- package/src/components/diagnose/DiagnoseSurface.tsx +33 -15
- package/src/components/diagnose/LocalDiagnoseAction.tsx +50 -27
- package/src/components/diagnose/agentCatalog.ts +30 -0
- package/src/components/diagnose/parts.test.tsx +125 -0
- package/src/components/diagnose/parts.tsx +166 -75
- package/src/components/home/CapacityCard.test.tsx +150 -0
- package/src/components/home/CapacityCard.tsx +125 -0
- package/src/components/home/HomeView.tsx +15 -1
- package/src/components/issues/IssuesPane.test.ts +142 -0
- package/src/components/issues/IssuesPane.tsx +142 -38
- package/src/components/nav/PrimaryNavRail.test.tsx +20 -0
- package/src/components/nav/PrimaryNavRail.tsx +191 -103
- package/src/components/resources/ResourcesView.tsx +9 -8
- package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +29 -1
- package/src/components/resources/renderers/PodRenderer.tsx +32 -3
- package/src/components/settings/SettingsDialog.tsx +31 -19
- package/src/components/timeline/TimelineView.tsx +17 -3
- package/src/components/ui/command-items.ts +222 -98
- package/src/components/workload/WorkloadView.tsx +16 -83
- package/src/context/ConnectionContext.test.ts +39 -0
- package/src/context/ConnectionContext.tsx +155 -51
- package/src/context/DiagnoseCustomization.tsx +1 -1
- package/src/utils/shell-safe.test.ts +55 -0
- package/src/utils/shell-safe.ts +21 -0
|
@@ -0,0 +1,787 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { useLocation, useNavigate } from "react-router-dom";
|
|
3
|
+
import {
|
|
4
|
+
Collapse,
|
|
5
|
+
CollapseChevron,
|
|
6
|
+
SearchBox,
|
|
7
|
+
WithTooltip,
|
|
8
|
+
formatDuration,
|
|
9
|
+
type CapacityActivityEpisode,
|
|
10
|
+
type CapacityActivityResponse,
|
|
11
|
+
type CapacityResourceIdentity,
|
|
12
|
+
FilterPill,
|
|
13
|
+
} from "@skyhook-io/k8s-ui";
|
|
14
|
+
import { Badge } from "@skyhook-io/k8s-ui/components/ui/Badge";
|
|
15
|
+
import {
|
|
16
|
+
isCapacityCursorInvalidError,
|
|
17
|
+
useCapacityActivity,
|
|
18
|
+
} from "../../api/client";
|
|
19
|
+
import type { SelectedResource } from "../../types";
|
|
20
|
+
import {
|
|
21
|
+
ActivityStateBadge,
|
|
22
|
+
CapacityFreshness,
|
|
23
|
+
InlineEmpty,
|
|
24
|
+
LinkButton,
|
|
25
|
+
Notice,
|
|
26
|
+
PageControls,
|
|
27
|
+
PoolSelector,
|
|
28
|
+
ROW_HOVER,
|
|
29
|
+
ScopeBadges,
|
|
30
|
+
ScrollableContent,
|
|
31
|
+
TABLE_HEAD,
|
|
32
|
+
TABLE_WRAP,
|
|
33
|
+
TBODY,
|
|
34
|
+
TD,
|
|
35
|
+
TH,
|
|
36
|
+
activityTypeLabel,
|
|
37
|
+
activityWindowPreset,
|
|
38
|
+
coverageHasObservations,
|
|
39
|
+
coverageIsLowerBound,
|
|
40
|
+
coverageMessage,
|
|
41
|
+
errorMessage,
|
|
42
|
+
formatTimestamp,
|
|
43
|
+
identityKey,
|
|
44
|
+
identityToSelectedResource,
|
|
45
|
+
integrationBlock,
|
|
46
|
+
relativeTime,
|
|
47
|
+
retentionLabel,
|
|
48
|
+
useCapacityCursorRecovery,
|
|
49
|
+
useCapacityPagination,
|
|
50
|
+
} from "./shared";
|
|
51
|
+
|
|
52
|
+
const WINDOW_PILLS: [number | undefined, string][] = [
|
|
53
|
+
[undefined, "Retained"],
|
|
54
|
+
[1, "Last hour"],
|
|
55
|
+
[6, "Last 6 hours"],
|
|
56
|
+
[24, "Last 24 hours"],
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
const TYPE_PILL_ORDER: CapacityActivityEpisode["type"][] = [
|
|
60
|
+
"provision",
|
|
61
|
+
"launch_failure",
|
|
62
|
+
"registration_failure",
|
|
63
|
+
"initialization_failure",
|
|
64
|
+
"disruption",
|
|
65
|
+
"interruption",
|
|
66
|
+
"termination",
|
|
67
|
+
"config_change",
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
export function CapacityActivity({
|
|
71
|
+
connectionState,
|
|
72
|
+
onOpenPool,
|
|
73
|
+
onOpenResource,
|
|
74
|
+
onNavigate,
|
|
75
|
+
}: {
|
|
76
|
+
connectionState: "connected" | "disconnected" | "connecting";
|
|
77
|
+
onOpenPool: (name: string) => void;
|
|
78
|
+
onOpenResource: (resource: SelectedResource) => void;
|
|
79
|
+
onNavigate: (path: string) => void;
|
|
80
|
+
}) {
|
|
81
|
+
const location = useLocation();
|
|
82
|
+
const navigate = useNavigate();
|
|
83
|
+
const search = new URLSearchParams(location.search);
|
|
84
|
+
const poolFilter = search.get("pool") || undefined;
|
|
85
|
+
const claimFilter = search.get("claim") || undefined;
|
|
86
|
+
const nodeFilter = search.get("node") || undefined;
|
|
87
|
+
const qParam = search.get("q") || undefined;
|
|
88
|
+
const typeFilter = search.get("type") || undefined;
|
|
89
|
+
const sinceFilter = search.get("since") || undefined;
|
|
90
|
+
const invalidSinceFilter = Boolean(
|
|
91
|
+
sinceFilter && !Number.isFinite(Date.parse(sinceFilter)),
|
|
92
|
+
);
|
|
93
|
+
const requestSinceFilter = invalidSinceFilter ? undefined : sinceFilter;
|
|
94
|
+
const selectedWindow = activityWindowPreset(requestSinceFilter);
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
const params = new URLSearchParams(location.search);
|
|
97
|
+
if (!params.has("workload")) return;
|
|
98
|
+
params.delete("workload");
|
|
99
|
+
navigate(
|
|
100
|
+
{
|
|
101
|
+
pathname: location.pathname,
|
|
102
|
+
search: params.toString() ? `?${params.toString()}` : "",
|
|
103
|
+
},
|
|
104
|
+
{ replace: true },
|
|
105
|
+
);
|
|
106
|
+
}, [location.pathname, location.search, navigate]);
|
|
107
|
+
const [searchInput, setSearchInput] = useState(qParam ?? "");
|
|
108
|
+
// Deferred writes (the debounced URL mirror of the search) must merge onto
|
|
109
|
+
// the CURRENT params, not a snapshot captured when the timer was armed —
|
|
110
|
+
// otherwise a window/pool change made mid-type would be dropped when the
|
|
111
|
+
// write lands.
|
|
112
|
+
const searchRef = useRef(location.search);
|
|
113
|
+
searchRef.current = location.search;
|
|
114
|
+
const pathRef = useRef(location.pathname);
|
|
115
|
+
pathRef.current = location.pathname;
|
|
116
|
+
const setParam = useCallback(
|
|
117
|
+
(key: "pool" | "q" | "since", value: string | undefined) => {
|
|
118
|
+
const params = new URLSearchParams(searchRef.current);
|
|
119
|
+
params.delete("workload");
|
|
120
|
+
const trimmed = value?.trim();
|
|
121
|
+
if (trimmed) params.set(key, trimmed);
|
|
122
|
+
else params.delete(key);
|
|
123
|
+
navigate(
|
|
124
|
+
{
|
|
125
|
+
pathname: pathRef.current,
|
|
126
|
+
search: params.toString() ? `?${params.toString()}` : "",
|
|
127
|
+
},
|
|
128
|
+
{ replace: true },
|
|
129
|
+
);
|
|
130
|
+
},
|
|
131
|
+
[navigate],
|
|
132
|
+
);
|
|
133
|
+
// The search filters the loaded episodes client-side, instantly. The URL
|
|
134
|
+
// `q` param is a shareability mirror only — it never drives a server fetch.
|
|
135
|
+
// Reflect external changes (Clear filters, back/forward) into the buffer;
|
|
136
|
+
// both guards compare trimmed so the mirror landing mid-type can never eat
|
|
137
|
+
// a trailing space the user is still extending.
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
setSearchInput((current) =>
|
|
140
|
+
current.trim() === (qParam ?? "") ? current : (qParam ?? ""),
|
|
141
|
+
);
|
|
142
|
+
}, [qParam]);
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
if (searchInput.trim() === (qParam ?? "")) return;
|
|
145
|
+
const timer = setTimeout(() => setParam("q", searchInput), 350);
|
|
146
|
+
return () => clearTimeout(timer);
|
|
147
|
+
}, [searchInput, qParam, setParam]);
|
|
148
|
+
// The URL mirror of the search must not reset pagination — only the
|
|
149
|
+
// server-side filters do.
|
|
150
|
+
const paginationParams = new URLSearchParams(location.search);
|
|
151
|
+
paginationParams.delete("q");
|
|
152
|
+
const pagination = useCapacityPagination<CapacityActivityResponse>(
|
|
153
|
+
paginationParams.toString(),
|
|
154
|
+
);
|
|
155
|
+
const query = useCapacityActivity({
|
|
156
|
+
limit: 50,
|
|
157
|
+
cursor: pagination.cursor,
|
|
158
|
+
pool: poolFilter,
|
|
159
|
+
claim: claimFilter,
|
|
160
|
+
node: nodeFilter,
|
|
161
|
+
type: typeFilter,
|
|
162
|
+
since: requestSinceFilter,
|
|
163
|
+
});
|
|
164
|
+
const recoveringCursor = useCapacityCursorRecovery(
|
|
165
|
+
query.error,
|
|
166
|
+
pagination.cursor,
|
|
167
|
+
pagination.recover,
|
|
168
|
+
);
|
|
169
|
+
const recoveredCursor = pagination.recovered || recoveringCursor;
|
|
170
|
+
const responseData =
|
|
171
|
+
query.data ?? (recoveredCursor ? pagination.retainedPage : undefined);
|
|
172
|
+
const blocked = integrationBlock(
|
|
173
|
+
responseData,
|
|
174
|
+
query.error,
|
|
175
|
+
query.isLoading,
|
|
176
|
+
"Loading capacity activity…",
|
|
177
|
+
);
|
|
178
|
+
if (blocked) return blocked;
|
|
179
|
+
const response = responseData as CapacityActivityResponse;
|
|
180
|
+
const changeTypeFilter = (type?: CapacityActivityEpisode["type"]) => {
|
|
181
|
+
const params = new URLSearchParams(location.search);
|
|
182
|
+
params.delete("workload");
|
|
183
|
+
if (type) params.set("type", type);
|
|
184
|
+
else params.delete("type");
|
|
185
|
+
navigate(
|
|
186
|
+
{
|
|
187
|
+
pathname: location.pathname,
|
|
188
|
+
search: params.toString() ? `?${params.toString()}` : "",
|
|
189
|
+
},
|
|
190
|
+
{ replace: true },
|
|
191
|
+
);
|
|
192
|
+
};
|
|
193
|
+
const clearFilters = () => {
|
|
194
|
+
// Reset the buffer directly — otherwise a pending debounce timer would
|
|
195
|
+
// rewrite `q` right after the URL was cleared.
|
|
196
|
+
setSearchInput("");
|
|
197
|
+
const params = new URLSearchParams(location.search);
|
|
198
|
+
["pool", "claim", "node", "workload", "q", "since", "type"].forEach((key) =>
|
|
199
|
+
params.delete(key),
|
|
200
|
+
);
|
|
201
|
+
navigate(
|
|
202
|
+
{
|
|
203
|
+
pathname: location.pathname,
|
|
204
|
+
search: params.toString() ? `?${params.toString()}` : "",
|
|
205
|
+
},
|
|
206
|
+
{ replace: true },
|
|
207
|
+
);
|
|
208
|
+
};
|
|
209
|
+
const removeFilter = (key: "claim" | "node" | "since") => {
|
|
210
|
+
const params = new URLSearchParams(location.search);
|
|
211
|
+
params.delete(key);
|
|
212
|
+
params.delete("workload");
|
|
213
|
+
navigate(
|
|
214
|
+
{
|
|
215
|
+
pathname: location.pathname,
|
|
216
|
+
search: params.toString() ? `?${params.toString()}` : "",
|
|
217
|
+
},
|
|
218
|
+
{ replace: true },
|
|
219
|
+
);
|
|
220
|
+
};
|
|
221
|
+
const setWindowHours = (hours: number | undefined, now: number) => {
|
|
222
|
+
setParam(
|
|
223
|
+
"since",
|
|
224
|
+
hours ? new Date(now - hours * 60 * 60 * 1000).toISOString() : undefined,
|
|
225
|
+
);
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const hasActiveFilters = Boolean(
|
|
229
|
+
poolFilter ||
|
|
230
|
+
claimFilter ||
|
|
231
|
+
nodeFilter ||
|
|
232
|
+
searchInput.trim() ||
|
|
233
|
+
sinceFilter ||
|
|
234
|
+
typeFilter,
|
|
235
|
+
);
|
|
236
|
+
const searchTerm = searchInput.trim().toLowerCase();
|
|
237
|
+
const visibleItems = searchTerm
|
|
238
|
+
? response.items.filter((episode) =>
|
|
239
|
+
episodeMatchesSearch(episode, searchTerm),
|
|
240
|
+
)
|
|
241
|
+
: response.items;
|
|
242
|
+
const aggregate = response.aggregate;
|
|
243
|
+
// Episodes are built from both the retained timeline and Karpenter's object
|
|
244
|
+
// events; either source being partial makes every rollup count a floor.
|
|
245
|
+
const aggregateIsLowerBound =
|
|
246
|
+
coverageIsLowerBound(response.coverage.timeline) ||
|
|
247
|
+
coverageIsLowerBound(response.coverage.karpenterObjectEvents);
|
|
248
|
+
const formatAggregateCount = (count: number) =>
|
|
249
|
+
`${aggregateIsLowerBound ? "≥" : ""}${count}`;
|
|
250
|
+
|
|
251
|
+
return (
|
|
252
|
+
<ScrollableContent>
|
|
253
|
+
<div className="flex flex-wrap items-start justify-between gap-4">
|
|
254
|
+
<div className="min-w-0">
|
|
255
|
+
<div className="flex flex-wrap items-baseline gap-3">
|
|
256
|
+
<LinkButton onClick={() => onNavigate("/capacity")}>
|
|
257
|
+
← Capacity
|
|
258
|
+
</LinkButton>
|
|
259
|
+
<h1 className="text-lg font-semibold text-theme-text-primary">
|
|
260
|
+
Activity
|
|
261
|
+
</h1>
|
|
262
|
+
<span className="text-xs text-theme-text-tertiary">
|
|
263
|
+
Recent Karpenter capacity events, retained for a bounded window.
|
|
264
|
+
</span>
|
|
265
|
+
</div>
|
|
266
|
+
<div className="mt-2">
|
|
267
|
+
<ScopeBadges
|
|
268
|
+
coverage={response.coverage}
|
|
269
|
+
source="karpenterObjectEvents"
|
|
270
|
+
/>
|
|
271
|
+
</div>
|
|
272
|
+
{coverageHasObservations(response.coverage.timeline) && (
|
|
273
|
+
<p className="mt-1.5 flex flex-wrap items-center gap-x-1.5 text-xs text-theme-text-tertiary">
|
|
274
|
+
<span>
|
|
275
|
+
Window {formatTimestamp(response.observation.startedAt)} →{" "}
|
|
276
|
+
{formatTimestamp(response.observation.endedAt)}
|
|
277
|
+
</span>
|
|
278
|
+
<WithTooltip
|
|
279
|
+
tip={`${
|
|
280
|
+
response.observation.sources.length > 0
|
|
281
|
+
? `Sources: ${response.observation.sources.join(", ")} · `
|
|
282
|
+
: ""
|
|
283
|
+
}${retentionLabel(response.observation.retention)}. An observation window, not a durable audit log. ${coverageMessage(response.coverage.timeline, "Retained activity")}`}
|
|
284
|
+
>
|
|
285
|
+
<span aria-label="About the observation window">ⓘ</span>
|
|
286
|
+
</WithTooltip>
|
|
287
|
+
</p>
|
|
288
|
+
)}
|
|
289
|
+
</div>
|
|
290
|
+
<div className="flex items-center gap-2">
|
|
291
|
+
{pagination.cursor && (
|
|
292
|
+
<button
|
|
293
|
+
type="button"
|
|
294
|
+
className="rounded-lg border border-theme-border px-3 py-1.5 text-xs font-medium text-theme-text-secondary hover:bg-theme-hover"
|
|
295
|
+
onClick={pagination.reset}
|
|
296
|
+
>
|
|
297
|
+
↑ Jump to newest
|
|
298
|
+
</button>
|
|
299
|
+
)}
|
|
300
|
+
<LinkButton
|
|
301
|
+
onClick={() =>
|
|
302
|
+
onNavigate(
|
|
303
|
+
poolFilter
|
|
304
|
+
? `/capacity/demand?pool=${encodeURIComponent(poolFilter)}`
|
|
305
|
+
: "/capacity/demand",
|
|
306
|
+
)
|
|
307
|
+
}
|
|
308
|
+
>
|
|
309
|
+
Pending demand →
|
|
310
|
+
</LinkButton>
|
|
311
|
+
<CapacityFreshness
|
|
312
|
+
meta={response}
|
|
313
|
+
query={query}
|
|
314
|
+
connectionState={connectionState}
|
|
315
|
+
/>
|
|
316
|
+
</div>
|
|
317
|
+
</div>
|
|
318
|
+
|
|
319
|
+
{recoveredCursor && (
|
|
320
|
+
<Notice>Capacity data changed; showing the latest results.</Notice>
|
|
321
|
+
)}
|
|
322
|
+
{query.error && !isCapacityCursorInvalidError(query.error) && (
|
|
323
|
+
<Notice>
|
|
324
|
+
Refresh failed; showing the last successful capacity snapshot.{" "}
|
|
325
|
+
{errorMessage(query.error)}
|
|
326
|
+
</Notice>
|
|
327
|
+
)}
|
|
328
|
+
{invalidSinceFilter && (
|
|
329
|
+
<Notice>
|
|
330
|
+
The activity window timestamp is invalid, so the retained window is
|
|
331
|
+
shown instead.{" "}
|
|
332
|
+
<LinkButton className="inline" onClick={() => removeFilter("since")}>
|
|
333
|
+
Clear invalid filter
|
|
334
|
+
</LinkButton>
|
|
335
|
+
</Notice>
|
|
336
|
+
)}
|
|
337
|
+
{response.cursorStatus !== "valid" && (
|
|
338
|
+
<Notice>
|
|
339
|
+
The retained activity boundary changed (
|
|
340
|
+
{response.cursorGap?.reason ?? response.cursorStatus}). The previous
|
|
341
|
+
cursor can no longer produce a continuous history.{" "}
|
|
342
|
+
<LinkButton className="inline" onClick={pagination.reset}>
|
|
343
|
+
Return to newest activity
|
|
344
|
+
</LinkButton>
|
|
345
|
+
</Notice>
|
|
346
|
+
)}
|
|
347
|
+
{coverageIsLowerBound(response.coverage.karpenterObjectEvents) && (
|
|
348
|
+
<Notice>
|
|
349
|
+
≥{" "}
|
|
350
|
+
{coverageMessage(
|
|
351
|
+
response.coverage.karpenterObjectEvents,
|
|
352
|
+
"Activity evidence",
|
|
353
|
+
)}
|
|
354
|
+
. Episodes may omit events outside this user’s authorized namespaces.
|
|
355
|
+
</Notice>
|
|
356
|
+
)}
|
|
357
|
+
|
|
358
|
+
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
|
359
|
+
<div className="flex flex-wrap items-center gap-1.5 text-xs text-theme-text-tertiary">
|
|
360
|
+
<span>Window</span>
|
|
361
|
+
{WINDOW_PILLS.map(([hours, label]) => (
|
|
362
|
+
<FilterPill
|
|
363
|
+
key={hours ?? "retained"}
|
|
364
|
+
label={label}
|
|
365
|
+
active={selectedWindow === hours}
|
|
366
|
+
onClick={() => setWindowHours(hours, Date.now())}
|
|
367
|
+
/>
|
|
368
|
+
))}
|
|
369
|
+
{sinceFilter && <span>Since {formatTimestamp(sinceFilter)}</span>}
|
|
370
|
+
</div>
|
|
371
|
+
<PoolSelector
|
|
372
|
+
key={response.clusterContext.contextName}
|
|
373
|
+
pool={poolFilter}
|
|
374
|
+
onChange={(pool) => setParam("pool", pool)}
|
|
375
|
+
label="NodePool"
|
|
376
|
+
emptyLabel="Any pool"
|
|
377
|
+
unavailableLabel="NodePool options unavailable; activity remains available."
|
|
378
|
+
/>
|
|
379
|
+
<SearchBox
|
|
380
|
+
value={searchInput}
|
|
381
|
+
onChange={setSearchInput}
|
|
382
|
+
scope="global"
|
|
383
|
+
shortcutId="capacity-activity-search"
|
|
384
|
+
placeholder="LaunchFailed, node name…"
|
|
385
|
+
className="w-60 2xl:w-72"
|
|
386
|
+
/>
|
|
387
|
+
{(claimFilter || nodeFilter) && (
|
|
388
|
+
<div
|
|
389
|
+
className="flex flex-wrap items-center gap-1.5"
|
|
390
|
+
aria-label="Active resource filters"
|
|
391
|
+
>
|
|
392
|
+
<span className="text-xs text-theme-text-tertiary">
|
|
393
|
+
Resource filters
|
|
394
|
+
</span>
|
|
395
|
+
{claimFilter && (
|
|
396
|
+
<ActivityFilterChip
|
|
397
|
+
label={`NodeClaim: ${claimFilter}`}
|
|
398
|
+
onRemove={() => removeFilter("claim")}
|
|
399
|
+
/>
|
|
400
|
+
)}
|
|
401
|
+
{nodeFilter && (
|
|
402
|
+
<ActivityFilterChip
|
|
403
|
+
label={`Node: ${nodeFilter}`}
|
|
404
|
+
onRemove={() => removeFilter("node")}
|
|
405
|
+
/>
|
|
406
|
+
)}
|
|
407
|
+
</div>
|
|
408
|
+
)}
|
|
409
|
+
{hasActiveFilters && (
|
|
410
|
+
<button
|
|
411
|
+
type="button"
|
|
412
|
+
className="rounded-lg border border-theme-border px-3 py-1.5 text-sm text-theme-text-secondary hover:bg-theme-hover"
|
|
413
|
+
onClick={clearFilters}
|
|
414
|
+
>
|
|
415
|
+
Clear filters
|
|
416
|
+
</button>
|
|
417
|
+
)}
|
|
418
|
+
</div>
|
|
419
|
+
|
|
420
|
+
{(aggregate !== undefined || typeFilter !== undefined) && (
|
|
421
|
+
<div
|
|
422
|
+
className="flex flex-wrap gap-1.5"
|
|
423
|
+
aria-label="Filter activity by type"
|
|
424
|
+
>
|
|
425
|
+
{/* Counts come from the whole-window rollup (stable across the active
|
|
426
|
+
type filter), not the current page. Pills without a rollup (cursor
|
|
427
|
+
pages) never show a fabricated count. */}
|
|
428
|
+
<FilterPill
|
|
429
|
+
label={
|
|
430
|
+
aggregate
|
|
431
|
+
? `All · ${formatAggregateCount(aggregate.total)}`
|
|
432
|
+
: "All"
|
|
433
|
+
}
|
|
434
|
+
active={typeFilter === undefined}
|
|
435
|
+
onClick={() => changeTypeFilter(undefined)}
|
|
436
|
+
/>
|
|
437
|
+
{TYPE_PILL_ORDER.filter(
|
|
438
|
+
(type) =>
|
|
439
|
+
(aggregate?.byType[type]?.total ?? 0) > 0 || type === typeFilter,
|
|
440
|
+
).map((type) => {
|
|
441
|
+
const counts = aggregate?.byType[type];
|
|
442
|
+
const failed = counts?.byState?.failed ?? 0;
|
|
443
|
+
return (
|
|
444
|
+
<FilterPill
|
|
445
|
+
key={type}
|
|
446
|
+
label={
|
|
447
|
+
counts
|
|
448
|
+
? `${activityTypeLabel(type)} · ${formatAggregateCount(counts.total)}${
|
|
449
|
+
failed > 0
|
|
450
|
+
? ` · ${formatAggregateCount(failed)} failed`
|
|
451
|
+
: ""
|
|
452
|
+
}`
|
|
453
|
+
: activityTypeLabel(type)
|
|
454
|
+
}
|
|
455
|
+
active={typeFilter === type}
|
|
456
|
+
onClick={() =>
|
|
457
|
+
changeTypeFilter(typeFilter === type ? undefined : type)
|
|
458
|
+
}
|
|
459
|
+
/>
|
|
460
|
+
);
|
|
461
|
+
})}
|
|
462
|
+
</div>
|
|
463
|
+
)}
|
|
464
|
+
|
|
465
|
+
{visibleItems.length > 0 ? (
|
|
466
|
+
<div className="space-y-3">
|
|
467
|
+
{visibleItems.map((episode, index) => (
|
|
468
|
+
<ActivityEpisodeCard
|
|
469
|
+
key={episode.id}
|
|
470
|
+
episode={episode}
|
|
471
|
+
defaultExpanded={index === 0 && !searchTerm}
|
|
472
|
+
onOpenPool={onOpenPool}
|
|
473
|
+
onOpenResource={onOpenResource}
|
|
474
|
+
/>
|
|
475
|
+
))}
|
|
476
|
+
</div>
|
|
477
|
+
) : response.items.length > 0 ? (
|
|
478
|
+
<InlineEmpty
|
|
479
|
+
title="No loaded episodes match this search"
|
|
480
|
+
detail="The search narrows the episodes loaded on this page. Matches may exist on other pages — page through, or clear the search."
|
|
481
|
+
/>
|
|
482
|
+
) : coverageHasObservations(response.coverage.timeline) ? (
|
|
483
|
+
<InlineEmpty
|
|
484
|
+
title="No episodes match these filters"
|
|
485
|
+
detail={
|
|
486
|
+
pagination.cursor
|
|
487
|
+
? "No activity episodes were retained on this page."
|
|
488
|
+
: "Within the retained window, nothing matches. Evidence outside the window is not retained — absence here is not proof nothing happened earlier."
|
|
489
|
+
}
|
|
490
|
+
/>
|
|
491
|
+
) : (
|
|
492
|
+
<InlineEmpty
|
|
493
|
+
title="Activity unavailable"
|
|
494
|
+
detail={coverageMessage(
|
|
495
|
+
response.coverage.timeline,
|
|
496
|
+
"Retained activity",
|
|
497
|
+
)}
|
|
498
|
+
/>
|
|
499
|
+
)}
|
|
500
|
+
|
|
501
|
+
{(pagination.history.length > 0 || response.page.hasMore) && (
|
|
502
|
+
<PageControls
|
|
503
|
+
page={pagination.history.length + 1}
|
|
504
|
+
hasPrevious={pagination.history.length > 0}
|
|
505
|
+
hasNext={response.page.hasMore && Boolean(response.page.nextCursor)}
|
|
506
|
+
busy={query.isFetching}
|
|
507
|
+
onPrevious={() => pagination.goBack(response)}
|
|
508
|
+
onNext={() =>
|
|
509
|
+
response.page.nextCursor &&
|
|
510
|
+
pagination.goNext(response.page.nextCursor, response)
|
|
511
|
+
}
|
|
512
|
+
/>
|
|
513
|
+
)}
|
|
514
|
+
</ScrollableContent>
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function episodeMatchesSearch(
|
|
519
|
+
episode: CapacityActivityEpisode,
|
|
520
|
+
term: string,
|
|
521
|
+
): boolean {
|
|
522
|
+
const haystacks = [
|
|
523
|
+
episode.summary,
|
|
524
|
+
activityTypeLabel(episode.type),
|
|
525
|
+
episode.state,
|
|
526
|
+
episode.primaryReasonCode,
|
|
527
|
+
...[episode.pool, episode.claim, episode.node]
|
|
528
|
+
.filter((identity): identity is CapacityResourceIdentity =>
|
|
529
|
+
Boolean(identity),
|
|
530
|
+
)
|
|
531
|
+
.flatMap((identity) => [identity.ref.kind, identity.ref.name]),
|
|
532
|
+
...episode.evidence.flatMap((evidence) => [
|
|
533
|
+
evidence.reasonCode,
|
|
534
|
+
evidence.rawReason,
|
|
535
|
+
evidence.rawMessage,
|
|
536
|
+
]),
|
|
537
|
+
];
|
|
538
|
+
return haystacks.some((value) => value?.toLowerCase().includes(term));
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function ActivityFilterChip({
|
|
542
|
+
label,
|
|
543
|
+
onRemove,
|
|
544
|
+
}: {
|
|
545
|
+
label: string;
|
|
546
|
+
onRemove: () => void;
|
|
547
|
+
}) {
|
|
548
|
+
return (
|
|
549
|
+
<button
|
|
550
|
+
type="button"
|
|
551
|
+
className="rounded-md border border-theme-border bg-theme-base px-2 py-1 text-xs text-theme-text-secondary hover:bg-theme-hover"
|
|
552
|
+
onClick={onRemove}
|
|
553
|
+
aria-label={`Remove ${label} filter`}
|
|
554
|
+
>
|
|
555
|
+
{label} <span aria-hidden="true">×</span>
|
|
556
|
+
</button>
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function ActivityEpisodeCard({
|
|
561
|
+
episode,
|
|
562
|
+
defaultExpanded,
|
|
563
|
+
onOpenPool,
|
|
564
|
+
onOpenResource,
|
|
565
|
+
}: {
|
|
566
|
+
episode: CapacityActivityEpisode;
|
|
567
|
+
defaultExpanded: boolean;
|
|
568
|
+
onOpenPool: (name: string) => void;
|
|
569
|
+
onOpenResource: (resource: SelectedResource) => void;
|
|
570
|
+
}) {
|
|
571
|
+
const [expanded, setExpanded] = useState(defaultExpanded);
|
|
572
|
+
const [showProvenance, setShowProvenance] = useState(false);
|
|
573
|
+
const subjects = [episode.pool, episode.claim, episode.node].filter(
|
|
574
|
+
(identity): identity is CapacityResourceIdentity => Boolean(identity),
|
|
575
|
+
);
|
|
576
|
+
const duration =
|
|
577
|
+
episode.durationSeconds !== undefined
|
|
578
|
+
? formatDuration(episode.durationSeconds * 1000, true)
|
|
579
|
+
: episode.state === "open"
|
|
580
|
+
? "in progress"
|
|
581
|
+
: episode.state === "observed"
|
|
582
|
+
? "point observation"
|
|
583
|
+
: "—";
|
|
584
|
+
return (
|
|
585
|
+
<article className="overflow-hidden rounded-xl border border-theme-border bg-theme-surface shadow-theme-sm">
|
|
586
|
+
<button
|
|
587
|
+
type="button"
|
|
588
|
+
aria-expanded={expanded}
|
|
589
|
+
className={`flex w-full items-start gap-2 px-4 py-3 text-left ${ROW_HOVER}`}
|
|
590
|
+
onClick={() => setExpanded((current) => !current)}
|
|
591
|
+
>
|
|
592
|
+
<CollapseChevron open={expanded} className="mt-0.5 h-4 w-4" />
|
|
593
|
+
<div className="min-w-0 flex-1">
|
|
594
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
595
|
+
<Badge tone="structural" size="sm">
|
|
596
|
+
{activityTypeLabel(episode.type)}
|
|
597
|
+
</Badge>
|
|
598
|
+
<ActivityStateBadge state={episode.state} />
|
|
599
|
+
{!episode.evidence.some(
|
|
600
|
+
(item) => item.relationship === "direct",
|
|
601
|
+
) && (
|
|
602
|
+
<WithTooltip tip="No controller-recorded cause — this episode was inferred by correlating event text. Expand for the raw evidence.">
|
|
603
|
+
<Badge severity="neutral" size="sm">
|
|
604
|
+
inferred
|
|
605
|
+
</Badge>
|
|
606
|
+
</WithTooltip>
|
|
607
|
+
)}
|
|
608
|
+
<span className="min-w-0 truncate font-medium text-theme-text-primary">
|
|
609
|
+
{episode.summary}
|
|
610
|
+
</span>
|
|
611
|
+
<span className="ml-auto font-mono text-xs text-theme-text-tertiary">
|
|
612
|
+
{relativeTime(episode.startedAt)}
|
|
613
|
+
</span>
|
|
614
|
+
<WithTooltip tip="Duration is shown only when start and end were both observed.">
|
|
615
|
+
<Badge tone="structural" size="sm">
|
|
616
|
+
{duration}
|
|
617
|
+
</Badge>
|
|
618
|
+
</WithTooltip>
|
|
619
|
+
</div>
|
|
620
|
+
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
|
621
|
+
{subjects.map((subject) => (
|
|
622
|
+
<span
|
|
623
|
+
key={identityKey(subject)}
|
|
624
|
+
role="link"
|
|
625
|
+
tabIndex={0}
|
|
626
|
+
className="cursor-pointer"
|
|
627
|
+
onClick={(event) => {
|
|
628
|
+
event.stopPropagation();
|
|
629
|
+
if (subject.ref.kind === "NodePool")
|
|
630
|
+
onOpenPool(subject.ref.name);
|
|
631
|
+
else onOpenResource(identityToSelectedResource(subject));
|
|
632
|
+
}}
|
|
633
|
+
onKeyDown={(event) => {
|
|
634
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
635
|
+
event.preventDefault();
|
|
636
|
+
event.stopPropagation();
|
|
637
|
+
if (subject.ref.kind === "NodePool")
|
|
638
|
+
onOpenPool(subject.ref.name);
|
|
639
|
+
else onOpenResource(identityToSelectedResource(subject));
|
|
640
|
+
}
|
|
641
|
+
}}
|
|
642
|
+
>
|
|
643
|
+
<Badge tone="structural" size="sm">
|
|
644
|
+
{subject.ref.kind.toLowerCase()}/{subject.ref.name}
|
|
645
|
+
</Badge>
|
|
646
|
+
</span>
|
|
647
|
+
))}
|
|
648
|
+
{episode.primaryReasonCode && (
|
|
649
|
+
<span className="font-mono text-[11px] text-theme-text-tertiary">
|
|
650
|
+
reason: {episode.primaryReasonCode}
|
|
651
|
+
</span>
|
|
652
|
+
)}
|
|
653
|
+
</div>
|
|
654
|
+
</div>
|
|
655
|
+
</button>
|
|
656
|
+
|
|
657
|
+
<Collapse open={expanded}>
|
|
658
|
+
<div className="border-t border-theme-border">
|
|
659
|
+
{episode.evidence.length > 0 ? (
|
|
660
|
+
<div className={TABLE_WRAP}>
|
|
661
|
+
<div className="flex justify-end px-4 pt-2">
|
|
662
|
+
<LinkButton
|
|
663
|
+
onClick={() => setShowProvenance((current) => !current)}
|
|
664
|
+
>
|
|
665
|
+
{showProvenance ? "Hide provenance ▴" : "Show provenance ▾"}
|
|
666
|
+
</LinkButton>
|
|
667
|
+
</div>
|
|
668
|
+
<table className="w-full text-left">
|
|
669
|
+
<thead className={TABLE_HEAD}>
|
|
670
|
+
<tr>
|
|
671
|
+
<th className={TH}>When</th>
|
|
672
|
+
<th className={TH}>Source</th>
|
|
673
|
+
{showProvenance && <th className={TH}>Normalized</th>}
|
|
674
|
+
<th className={TH}>Raw</th>
|
|
675
|
+
{showProvenance && <th className={TH}>Relationship</th>}
|
|
676
|
+
{showProvenance && <th className={TH}>Confidence</th>}
|
|
677
|
+
<th className={TH}>References</th>
|
|
678
|
+
</tr>
|
|
679
|
+
</thead>
|
|
680
|
+
<tbody className={TBODY}>
|
|
681
|
+
{episode.evidence.map((evidence, index) => (
|
|
682
|
+
<tr
|
|
683
|
+
key={`${evidence.at}-${evidence.reasonCode}-${index}`}
|
|
684
|
+
className={ROW_HOVER}
|
|
685
|
+
>
|
|
686
|
+
<td
|
|
687
|
+
className={`${TD} whitespace-nowrap font-mono text-theme-text-tertiary`}
|
|
688
|
+
>
|
|
689
|
+
{relativeTime(evidence.at)}
|
|
690
|
+
</td>
|
|
691
|
+
<td className={TD}>
|
|
692
|
+
<Badge tone="structural" size="sm">
|
|
693
|
+
{evidence.source.replace("_", " ")}
|
|
694
|
+
</Badge>
|
|
695
|
+
</td>
|
|
696
|
+
{showProvenance && (
|
|
697
|
+
<td className={`${TD} font-mono`}>
|
|
698
|
+
{evidence.reasonCode}
|
|
699
|
+
</td>
|
|
700
|
+
)}
|
|
701
|
+
<td className={`${TD} text-theme-text-secondary`}>
|
|
702
|
+
{evidence.rawReason || evidence.rawMessage ? (
|
|
703
|
+
<>
|
|
704
|
+
{evidence.rawReason
|
|
705
|
+
? `${evidence.rawReason}: `
|
|
706
|
+
: ""}
|
|
707
|
+
{evidence.rawMessage}
|
|
708
|
+
</>
|
|
709
|
+
) : (
|
|
710
|
+
"—"
|
|
711
|
+
)}
|
|
712
|
+
</td>
|
|
713
|
+
{showProvenance && (
|
|
714
|
+
<td className={`${TD} text-theme-text-secondary`}>
|
|
715
|
+
{evidence.relationship}
|
|
716
|
+
</td>
|
|
717
|
+
)}
|
|
718
|
+
{showProvenance && (
|
|
719
|
+
<td className={TD}>
|
|
720
|
+
<Badge tone="structural" size="sm">
|
|
721
|
+
{evidence.confidence}
|
|
722
|
+
</Badge>
|
|
723
|
+
</td>
|
|
724
|
+
)}
|
|
725
|
+
<td className={TD}>
|
|
726
|
+
{evidence.refs.length > 0 ? (
|
|
727
|
+
<div className="flex flex-wrap gap-1">
|
|
728
|
+
{evidence.refs.map((ref) => (
|
|
729
|
+
<span
|
|
730
|
+
key={identityKey(ref)}
|
|
731
|
+
role="link"
|
|
732
|
+
tabIndex={0}
|
|
733
|
+
className="cursor-pointer"
|
|
734
|
+
onClick={() =>
|
|
735
|
+
ref.ref.kind === "NodePool"
|
|
736
|
+
? onOpenPool(ref.ref.name)
|
|
737
|
+
: onOpenResource(
|
|
738
|
+
identityToSelectedResource(ref),
|
|
739
|
+
)
|
|
740
|
+
}
|
|
741
|
+
onKeyDown={(event) => {
|
|
742
|
+
if (
|
|
743
|
+
event.key === "Enter" ||
|
|
744
|
+
event.key === " "
|
|
745
|
+
) {
|
|
746
|
+
event.preventDefault();
|
|
747
|
+
if (ref.ref.kind === "NodePool") {
|
|
748
|
+
onOpenPool(ref.ref.name);
|
|
749
|
+
} else {
|
|
750
|
+
onOpenResource(
|
|
751
|
+
identityToSelectedResource(ref),
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}}
|
|
756
|
+
>
|
|
757
|
+
<Badge tone="structural" size="sm">
|
|
758
|
+
{ref.ref.kind.toLowerCase()}/{ref.ref.name}
|
|
759
|
+
</Badge>
|
|
760
|
+
</span>
|
|
761
|
+
))}
|
|
762
|
+
</div>
|
|
763
|
+
) : (
|
|
764
|
+
"—"
|
|
765
|
+
)}
|
|
766
|
+
</td>
|
|
767
|
+
</tr>
|
|
768
|
+
))}
|
|
769
|
+
</tbody>
|
|
770
|
+
</table>
|
|
771
|
+
</div>
|
|
772
|
+
) : (
|
|
773
|
+
<p className="px-4 py-3 text-sm text-theme-text-secondary">
|
|
774
|
+
No evidence records were retained for this episode.
|
|
775
|
+
</p>
|
|
776
|
+
)}
|
|
777
|
+
{episode.evidenceMeta.truncated && (
|
|
778
|
+
<p className="px-4 py-2 text-[11px] text-theme-text-tertiary">
|
|
779
|
+
Showing {episode.evidenceMeta.returned} of{" "}
|
|
780
|
+
{episode.evidenceMeta.total} evidence records.
|
|
781
|
+
</p>
|
|
782
|
+
)}
|
|
783
|
+
</div>
|
|
784
|
+
</Collapse>
|
|
785
|
+
</article>
|
|
786
|
+
);
|
|
787
|
+
}
|