@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.
Files changed (52) hide show
  1. package/package.json +7 -7
  2. package/src/App.tsx +69 -16
  3. package/src/api/apiResources.test.ts +11 -0
  4. package/src/api/apiResources.ts +51 -12
  5. package/src/api/client.capacity.test.ts +92 -0
  6. package/src/api/client.ts +2905 -2081
  7. package/src/api/config.test.ts +47 -0
  8. package/src/api/config.ts +15 -0
  9. package/src/api/diagnose.ts +15 -15
  10. package/src/components/ConnectionErrorView.test.tsx +88 -0
  11. package/src/components/ConnectionErrorView.tsx +128 -22
  12. package/src/components/capacity/CapacityActivity.tsx +787 -0
  13. package/src/components/capacity/CapacityDemand.tsx +961 -0
  14. package/src/components/capacity/CapacityOverview.tsx +1529 -0
  15. package/src/components/capacity/CapacityPoolDetail.tsx +1626 -0
  16. package/src/components/capacity/CapacityView.test.tsx +2287 -0
  17. package/src/components/capacity/CapacityView.tsx +85 -0
  18. package/src/components/capacity/ClusterSchedulingCard.tsx +603 -0
  19. package/src/components/capacity/DemandNomination.test.tsx +151 -0
  20. package/src/components/capacity/certaintyGlyph.test.tsx +191 -0
  21. package/src/components/capacity/coverageCertainty.test.ts +162 -0
  22. package/src/components/capacity/podDemandGate.test.ts +47 -0
  23. package/src/components/capacity/podDemandGate.ts +22 -0
  24. package/src/components/capacity/schedulingBar.test.ts +244 -0
  25. package/src/components/capacity/shared.tsx +1841 -0
  26. package/src/components/diagnose/AISettings.tsx +21 -7
  27. package/src/components/diagnose/AgentSetupNotice.tsx +117 -0
  28. package/src/components/diagnose/DiagnoseContext.tsx +127 -57
  29. package/src/components/diagnose/DiagnoseSurface.tsx +33 -15
  30. package/src/components/diagnose/LocalDiagnoseAction.tsx +50 -27
  31. package/src/components/diagnose/agentCatalog.ts +30 -0
  32. package/src/components/diagnose/parts.test.tsx +125 -0
  33. package/src/components/diagnose/parts.tsx +166 -75
  34. package/src/components/home/CapacityCard.test.tsx +150 -0
  35. package/src/components/home/CapacityCard.tsx +125 -0
  36. package/src/components/home/HomeView.tsx +15 -1
  37. package/src/components/issues/IssuesPane.test.ts +142 -0
  38. package/src/components/issues/IssuesPane.tsx +142 -38
  39. package/src/components/nav/PrimaryNavRail.test.tsx +20 -0
  40. package/src/components/nav/PrimaryNavRail.tsx +191 -103
  41. package/src/components/resources/ResourcesView.tsx +9 -8
  42. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +29 -1
  43. package/src/components/resources/renderers/PodRenderer.tsx +32 -3
  44. package/src/components/settings/SettingsDialog.tsx +31 -19
  45. package/src/components/timeline/TimelineView.tsx +17 -3
  46. package/src/components/ui/command-items.ts +222 -98
  47. package/src/components/workload/WorkloadView.tsx +16 -83
  48. package/src/context/ConnectionContext.test.ts +39 -0
  49. package/src/context/ConnectionContext.tsx +155 -51
  50. package/src/context/DiagnoseCustomization.tsx +1 -1
  51. package/src/utils/shell-safe.test.ts +55 -0
  52. 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
+ }