@skyhook-io/k8s-ui 1.7.11 → 1.7.13

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 (70) hide show
  1. package/package.json +1 -1
  2. package/src/components/applications/AppChips.tsx +109 -0
  3. package/src/components/applications/AppTooltips.tsx +199 -0
  4. package/src/components/applications/ApplicationDetail.tsx +671 -0
  5. package/src/components/applications/ApplicationsList.tsx +569 -0
  6. package/src/components/applications/ReadyBar.tsx +22 -0
  7. package/src/components/applications/index.ts +8 -0
  8. package/src/components/audit/AuditFindingsTable.tsx +3 -25
  9. package/src/components/dock/TerminalTab.tsx +4 -2
  10. package/src/components/gitops/insights/GitOpsInsightViews.tsx +1 -0
  11. package/src/components/issues/IssuesView.tsx +64 -17
  12. package/src/components/issues/index.ts +1 -1
  13. package/src/components/issues/issues.test.ts +4 -4
  14. package/src/components/issues/severity.ts +5 -0
  15. package/src/components/issues/types.ts +43 -0
  16. package/src/components/logs/LogCore.tsx +13 -2
  17. package/src/components/logs/LogToolbarSelects.tsx +6 -2
  18. package/src/components/logs/LogsViewer.tsx +66 -10
  19. package/src/components/logs/WorkloadLogsViewer.tsx +68 -13
  20. package/src/components/logs/useLogStream.ts +41 -3
  21. package/src/components/resources/ResourcesView.tsx +550 -52
  22. package/src/components/resources/column-filter-serialization.test.ts +26 -0
  23. package/src/components/resources/get-default-container-name.test.ts +31 -0
  24. package/src/components/resources/renderers/DeviceClassRenderer.tsx +49 -0
  25. package/src/components/resources/renderers/NodeRenderer.tsx +7 -0
  26. package/src/components/resources/renderers/NvidiaClusterPolicyRenderer.tsx +57 -0
  27. package/src/components/resources/renderers/NvidiaDriverRenderer.tsx +47 -0
  28. package/src/components/resources/renderers/PodRenderer.tsx +2 -2
  29. package/src/components/resources/renderers/ResourceClaimRenderer.tsx +118 -0
  30. package/src/components/resources/renderers/ResourceClaimTemplateRenderer.tsx +39 -0
  31. package/src/components/resources/renderers/ResourceSliceRenderer.tsx +72 -0
  32. package/src/components/resources/renderers/WorkloadRenderer.tsx +5 -4
  33. package/src/components/resources/renderers/dra-cells.tsx +80 -0
  34. package/src/components/resources/renderers/index.ts +8 -0
  35. package/src/components/resources/renderers/nvidia-cells.tsx +43 -0
  36. package/src/components/resources/resource-utils-dra.ts +90 -0
  37. package/src/components/resources/resource-utils-nvidia.ts +63 -0
  38. package/src/components/resources/resource-utils.ts +62 -4
  39. package/src/components/shared/DetailShell.tsx +14 -7
  40. package/src/components/shared/EditableYamlView.tsx +37 -17
  41. package/src/components/shared/ResourceActionsBar.tsx +5 -4
  42. package/src/components/shared/ResourceRendererDispatch.test.tsx +103 -0
  43. package/src/components/shared/ResourceRendererDispatch.tsx +27 -2
  44. package/src/components/timeline/TimelineList.tsx +3 -32
  45. package/src/components/timeline/TimelineSwimlanes.tsx +3 -31
  46. package/src/components/topology/K8sResourceNode.tsx +26 -5
  47. package/src/components/topology/TopologyGraph.tsx +102 -3
  48. package/src/components/topology/layout.ts +36 -11
  49. package/src/components/ui/CenteredEmpty.tsx +27 -0
  50. package/src/components/ui/ConfirmDialog.tsx +1 -1
  51. package/src/components/ui/SearchBox.tsx +85 -0
  52. package/src/components/ui/drawer-components.tsx +23 -1
  53. package/src/components/ui/index.ts +1 -0
  54. package/src/components/workload/WorkloadView.tsx +167 -33
  55. package/src/components/workload/index.ts +1 -1
  56. package/src/hooks/useKeyboardShortcuts.tsx +3 -1
  57. package/src/index.ts +4 -0
  58. package/src/types/core.ts +1 -0
  59. package/src/utils/api-resources.ts +21 -0
  60. package/src/utils/applications.test.ts +207 -0
  61. package/src/utils/applications.ts +674 -0
  62. package/src/utils/custom-columns.test.ts +111 -0
  63. package/src/utils/custom-columns.ts +49 -0
  64. package/src/utils/extended-resources.test.ts +152 -0
  65. package/src/utils/extended-resources.ts +121 -0
  66. package/src/utils/format.ts +11 -0
  67. package/src/utils/index.ts +3 -0
  68. package/src/utils/topology-neighborhood.test.ts +185 -0
  69. package/src/utils/topology-neighborhood.ts +262 -0
  70. package/src/utils/workload-colors.ts +36 -0
@@ -0,0 +1,671 @@
1
+ import { useMemo, useState, useCallback, useEffect, useRef, type ReactNode } from 'react'
2
+ import { ArrowLeft, Boxes, Network, Layers, ChevronDown } from 'lucide-react'
3
+ import { clsx } from 'clsx'
4
+ import type { Topology, TopologyNode } from '../../types'
5
+ import { StatusDot, mapHealthToTone } from '../ui/status-tone'
6
+ import { Tooltip } from '../ui/Tooltip'
7
+ import { TopologyGraph } from '../topology/TopologyGraph'
8
+ import { pluralize } from '../../utils/pluralize'
9
+ import { kindToPlural, apiVersionToGroup } from '../../utils/navigation'
10
+ import { tagWorkloadOwnership, seedNodeIds, ownershipOf, workloadKey, type NeighborhoodSeed } from '../../utils/topology-neighborhood'
11
+ import { workloadHue, NEUTRAL_OWNER, type WorkloadFocus } from '../../utils/workload-colors'
12
+ import {
13
+ type AppRow,
14
+ type AppWorkload,
15
+ type AppHealth,
16
+ CHIP,
17
+ CHIP_TONE,
18
+ HEALTH_META,
19
+ healthOf,
20
+ namespaceOf,
21
+ namespacesOf,
22
+ resolveEnv,
23
+ identityEnvInferred,
24
+ workloadClassOf,
25
+ classCompositionOf,
26
+ worstHealth,
27
+ appGroupLagMessage,
28
+ compareVersions,
29
+ } from '../../utils/applications'
30
+ import { PaneLoader } from '../ui/PaneLoader'
31
+ import { midTruncate } from '../../utils/format'
32
+ import { VersionTooltip, AppIdentityTooltip } from './AppTooltips'
33
+ import { ProvenanceBadge, ClassBadge, CategoryChip, VersionInfo } from './AppChips'
34
+ import { ReadyBar } from './ReadyBar'
35
+
36
+ // ApplicationDetail — pure single-cluster detail shell. Owns the title row
37
+ // (icon, name, provenance/ungrouped chip, add-on/mixed chip, class badge,
38
+ // health pill) + a context strip (Environment · Namespace · Ready · Version) +
39
+ // a workload selector (only when >1 workload). The embedded WorkloadView is
40
+ // injected by the host via the `renderWorkload` render-prop, keyed to the
41
+ // selected workload — the shell never touches data hooks.
42
+ //
43
+ // Count-adaptive landing: a multi-workload app with a topology lands on the
44
+ // app-level graph (the whole app's neighborhood) and drills into a workload's
45
+ // runtime on node/pill click. A single-workload app (or no topology) goes
46
+ // straight to the workload runtime — a graph of one Deployment + a Service
47
+ // adds nothing.
48
+
49
+ export type SelectedAppWorkload = NeighborhoodSeed
50
+
51
+ /** One env instance in the identity switcher — a sibling app row's digest. */
52
+ export interface AppIdentityInstance {
53
+ appKey: string
54
+ name: string
55
+ env: string
56
+ health: AppHealth
57
+ version?: string
58
+ confidence: string
59
+ evidence: string
60
+ count?: number
61
+ }
62
+
63
+ /** Workload selection is either fully controlled (key + callback, the host
64
+ * wires it to the URL so back/forward works) or fully internal — providing
65
+ * only half silently freezes the rail, so the types forbid it. `null` = the
66
+ * app graph landing; a key (see `workloadKey`) = that workload's runtime. */
67
+ type SelectionProps =
68
+ | { selectedWorkloadKey: string | null; onSelectWorkload: (key: string | null) => void }
69
+ | { selectedWorkloadKey?: undefined; onSelectWorkload?: undefined }
70
+
71
+ export type ApplicationDetailProps = {
72
+ app: AppRow
73
+ onBack: () => void
74
+ /** Render the host's WorkloadView for the chosen workload. */
75
+ renderWorkload: (workload: SelectedAppWorkload) => ReactNode
76
+ /** Resources-view topology spanning the app's namespaces. When present and the
77
+ * app has >1 workload, the detail lands on the app graph. */
78
+ topology?: Topology
79
+ /** True while the host's topology fetch is in flight. Without it, a
80
+ * multi-workload landing briefly mounts the first workload's runtime (and
81
+ * fires its data fetches) before jumping to the graph. */
82
+ topologyLoading?: boolean
83
+ /** Open a related (non-workload) resource clicked in the app graph. */
84
+ onNavigateToResource?: (resource: { kind: string; namespace: string; name: string; group?: string }) => void
85
+ /** App identity siblings (this instance included, ladder-ordered) — turns the
86
+ * context strip's Environment fact into a switcher. Identity grouping is
87
+ * classification, not an address: it switches between REAL instances, never
88
+ * an aggregate page. */
89
+ identityInstances?: AppIdentityInstance[] | null
90
+ /** Switch to a sibling instance (host swaps ?app= and, when it can match
91
+ * the current workload in the target, preserves ?workload= + ?tab=). */
92
+ onSwitchInstance?: (appKey: string) => void
93
+ /** Env tokens the cluster proved (the list derives the same set) — keeps a
94
+ * ungrouped app's Environment fact consistent between list and detail. */
95
+ discoveredEnvs?: ReadonlySet<string>
96
+ } & SelectionProps
97
+
98
+ function ContextFact({ label, children }: { label: string; children: ReactNode }) {
99
+ return (
100
+ <div className="flex min-w-0 items-baseline gap-1.5">
101
+ <span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary">{label}</span>
102
+ <span className="min-w-0 truncate text-xs text-theme-text-secondary">{children}</span>
103
+ </div>
104
+ )
105
+ }
106
+
107
+ function collapseIdentityInstances(instances: AppIdentityInstance[], activeKey: string): AppIdentityInstance[] {
108
+ const byEnv = new Map<string, AppIdentityInstance[]>()
109
+ for (const inst of instances) {
110
+ byEnv.set(inst.env, [...(byEnv.get(inst.env) ?? []), inst])
111
+ }
112
+ return Array.from(byEnv.values()).map((group) => {
113
+ const active = group.find((i) => i.appKey === activeKey)
114
+ const newest = group.reduce<AppIdentityInstance | undefined>((best, inst) => {
115
+ if (!best) return inst
116
+ return compareDefinedVersions(inst.version, best.version) === 1 ? inst : best
117
+ }, undefined)
118
+ const display = active ?? newest ?? group[0]
119
+ return {
120
+ ...display,
121
+ health: worstHealth(group.map((inst) => inst.health)),
122
+ version: newest?.version,
123
+ count: group.length,
124
+ }
125
+ })
126
+ }
127
+
128
+ function compareDefinedVersions(a: string | undefined, b: string | undefined): number {
129
+ if (!a && !b) return 0
130
+ if (a && !b) return 1
131
+ if (!a || !b) return -1
132
+ return compareVersions(a, b) ?? 0
133
+ }
134
+
135
+ export function ApplicationDetail({ app, onBack, renderWorkload, topology, topologyLoading, onNavigateToResource, identityInstances, onSwitchInstance, discoveredEnvs, selectedWorkloadKey, onSelectWorkload }: ApplicationDetailProps) {
136
+ // Stable order regardless of API ordering: rail rows and the per-workload
137
+ // color assignment both follow this array, so an order flap between
138
+ // refetches must not reshuffle rows or reassign a workload's hue.
139
+ const workloads = useMemo(
140
+ () =>
141
+ [...(app.workloads ?? [])].sort(
142
+ (a, b) => a.name.localeCompare(b.name) || a.kind.localeCompare(b.kind) || a.namespace.localeCompare(b.namespace),
143
+ ),
144
+ [app.workloads],
145
+ )
146
+ const overall = worstHealth([app.health, ...workloads.map((w) => w.health)])
147
+ const verdictTone = HEALTH_META[overall].pill
148
+ const verdictLabel = HEALTH_META[overall].label
149
+ const workloadClass = workloadClassOf(app.workload_class)
150
+ const versions = useMemo(() => Array.from(new Set((app.versions || []).filter(Boolean))), [app.versions])
151
+ const ready = workloads.reduce((n, w) => n + (w.ready ?? 0), 0)
152
+ const desired = workloads.reduce((n, w) => n + (w.desired ?? 0), 0)
153
+ const restartSignal = restartWarning(workloads)
154
+ // Resolve namespace the same way the list does (the workloads' shared
155
+ // namespace) so env/namespace match across list and detail. Multi-namespace
156
+ // apps get the count, never an arbitrary pick.
157
+ const namespace = namespaceOf(app)
158
+ const namespaces = namespacesOf(app)
159
+ const resolvedEnv = resolveEnv(undefined, namespace, discoveredEnvs)
160
+ const env = app.identity?.env ?? resolvedEnv.env
161
+ const inferred = app.identity ? identityEnvInferred(app.identity) : resolvedEnv.inferred
162
+
163
+ // The app graph is the landing for multi-workload apps when a topology was
164
+ // injected. A single workload (or no topology) skips straight to runtime.
165
+ const appGraphAvailable = !!topology && workloads.length > 1
166
+
167
+ // `null` = the app graph (landing); a key = that workload's runtime. Initially
168
+ // null so multi-workload apps land on the graph; single-workload apps ignore
169
+ // this and render the lone workload directly. Controlled by the host (URL) when
170
+ // `selectedWorkloadKey` is provided, otherwise internal.
171
+ const [internalSelected, setInternalSelected] = useState<string | null>(null)
172
+ const rawSelected = selectedWorkloadKey !== undefined ? selectedWorkloadKey : internalSelected
173
+ const setSelected = useCallback(
174
+ (key: string | null) => (onSelectWorkload ? onSelectWorkload(key) : setInternalSelected(key)),
175
+ [onSelectWorkload],
176
+ )
177
+ const selectedWorkload = rawSelected ? workloads.find((w) => workloadKey(w) === rawSelected) : undefined
178
+ // A stale controlled key (a deleted workload still in the URL) falls back to
179
+ // the app graph rather than silently rendering a different workload under a
180
+ // URL that names the missing one.
181
+ const selected = rawSelected !== null && !selectedWorkload ? null : rawSelected
182
+ useEffect(() => {
183
+ if (selectedWorkloadKey !== undefined && selectedWorkloadKey !== null && !selectedWorkload) {
184
+ onSelectWorkload?.(null)
185
+ }
186
+ }, [selectedWorkloadKey, selectedWorkload, onSelectWorkload])
187
+ useEffect(() => {
188
+ if (selectedWorkloadKey === undefined) setInternalSelected(null)
189
+ }, [app.key, selectedWorkloadKey])
190
+
191
+ // Hover-focus: the workload (or NEUTRAL_OWNER) whose nodes should stay lit
192
+ // while the rest of the graph dims. Driven by the rail and, reciprocally, by
193
+ // hovering a node.
194
+ const [focusedOwnerId, setFocusedOwnerId] = useState<WorkloadFocus>(null)
195
+
196
+ const appSeeds = useMemo(
197
+ () => workloads.map((w) => ({ kind: w.kind, namespace: w.namespace, name: w.name })),
198
+ [workloads],
199
+ )
200
+ // Neighborhood subgraph + per-workload color/ownership tagging in one pass.
201
+ const ownership = useMemo(
202
+ () => (topology ? tagWorkloadOwnership(topology, appSeeds) : null),
203
+ [topology, appSeeds],
204
+ )
205
+ const appGraph = ownership?.topology ?? null
206
+ const appGraphFocusId = useMemo(
207
+ () => (topology ? seedNodeIds(topology, appSeeds)[0] : undefined),
208
+ [topology, appSeeds],
209
+ )
210
+
211
+ // Hovering a node lights up its owning workload (and the rail row). An
212
+ // unowned node related to exactly ONE workload (a GitOps manager over a
213
+ // single workload here) still focuses that workload, mirroring rail-driven
214
+ // focus. Truly shared nodes clear instead of dimming everything.
215
+ const handleNodeHover = useCallback((node: TopologyNode | null) => {
216
+ if (!node) {
217
+ setFocusedOwnerId(null)
218
+ return
219
+ }
220
+ const stamp = ownershipOf(node.data)
221
+ setFocusedOwnerId(stamp.ownerWorkloadId ?? (stamp.focusWorkloadIds.length === 1 ? stamp.focusWorkloadIds[0] : NEUTRAL_OWNER))
222
+ }, [])
223
+
224
+ // A node click in the app graph either drills into one of the app's workloads
225
+ // (its runtime) or opens a related resource (Service/config/…) via the host.
226
+ const handleAppNodeClick = useCallback(
227
+ (node: TopologyNode) => {
228
+ const ns = (node.data?.namespace as string) || ''
229
+ const match = workloads.find((w) => w.kind === node.kind && w.name === node.name && w.namespace === ns)
230
+ if (match) {
231
+ setSelected(workloadKey(match))
232
+ return
233
+ }
234
+ onNavigateToResource?.({
235
+ kind: kindToPlural(node.kind),
236
+ namespace: ns,
237
+ name: node.name,
238
+ group: apiVersionToGroup(node.data?.apiVersion as string | undefined),
239
+ })
240
+ },
241
+ [workloads, onNavigateToResource, setSelected],
242
+ )
243
+
244
+ return (
245
+ <div className="flex w-full flex-col">
246
+ {/* Title row */}
247
+ <div className="flex flex-wrap items-center gap-x-4 gap-y-2 border-b border-theme-border px-4 py-3 sm:px-6">
248
+ <button type="button" onClick={onBack} className="flex items-center gap-1.5 text-xs text-theme-text-tertiary hover:text-theme-text-primary">
249
+ <ArrowLeft className="h-3.5 w-3.5" aria-hidden /> Applications
250
+ </button>
251
+ <span className="h-6 w-px bg-theme-border" aria-hidden />
252
+ <span className={`flex h-8 w-8 items-center justify-center rounded-md ring-1 ring-inset ${verdictTone}`}>
253
+ <Boxes className="h-4 w-4" aria-hidden />
254
+ </span>
255
+ <h1 className="text-2xl font-semibold text-theme-text-primary">{app.name}</h1>
256
+ <ProvenanceBadge tier={app.tier} appKey={app.key} confidence={app.confidence} />
257
+ <CategoryChip category={app.category} addonReason={app.addonReason} />
258
+ <ClassBadge workloadClass={workloadClass} composition={classCompositionOf(app)} />
259
+ <div className="ml-auto flex flex-wrap items-center justify-end gap-2">
260
+ <span className={`inline-flex items-center gap-2 rounded-md px-2.5 py-1 ring-1 ring-inset ${verdictTone}`}>
261
+ <StatusDot tone={mapHealthToTone(overall)} />
262
+ <span className="text-sm font-semibold">{verdictLabel}</span>
263
+ </span>
264
+ {restartSignal && (
265
+ <Tooltip content={`${restartSignal.workload} · ${pluralize(restartSignal.restarts, 'restart')}`} delay={150}>
266
+ <span className={`inline-flex items-center rounded-md px-2 py-1 text-xs font-semibold ring-1 ring-inset ${CHIP_TONE.amber}`}>
267
+ Pod warning: {restartSignal.reason || 'Restarts'} · {pluralize(restartSignal.restarts, 'restart')}
268
+ </span>
269
+ </Tooltip>
270
+ )}
271
+ {/* Amber only on real skew (same image, different tags) — the context
272
+ strip already covers the multi-image "N versions" case neutrally. */}
273
+ {app.versionSkew && versions.length > 1 && (
274
+ <Tooltip content={<VersionTooltip workloads={workloads} />} delay={150}>
275
+ <span className={`inline-flex items-center rounded-md px-2 py-1 font-mono text-xs ring-1 ring-inset ${CHIP_TONE.amber}`}>{versions.length} versions</span>
276
+ </Tooltip>
277
+ )}
278
+ </div>
279
+ </div>
280
+
281
+ {/* Context strip */}
282
+ <div className="flex flex-wrap items-center gap-x-5 gap-y-2 border-b border-theme-border px-4 py-2 sm:px-6">
283
+ {identityInstances && identityInstances.length > 1 ? (
284
+ // The Environment fact IS the switcher when this app runs in several
285
+ // envs — prominent, in existing header space, no extra row. Inline
286
+ // pills for a handful; a picker beyond that (scales to ~any count).
287
+ <div className="flex min-w-0 items-center gap-1.5">
288
+ <span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary">Environment</span>
289
+ <EnvSwitcher identityKey={app.identity?.key ?? ''} instances={identityInstances} activeKey={app.key} onSwitch={onSwitchInstance} />
290
+ </div>
291
+ ) : env ? (
292
+ <ContextFact label="Environment">
293
+ {inferred ? (
294
+ <Tooltip content={`Inferred from namespace "${namespace || env}" — confirm with an environment label.`} delay={150}>
295
+ <span className="italic">~{env}</span>
296
+ </Tooltip>
297
+ ) : (
298
+ env
299
+ )}
300
+ </ContextFact>
301
+ ) : null}
302
+ {namespace ? (
303
+ <ContextFact label="Namespace">
304
+ <span className="font-mono">{namespace}</span>
305
+ </ContextFact>
306
+ ) : namespaces.length > 1 ? (
307
+ <ContextFact label="Namespaces">
308
+ <Tooltip content={namespaces.join(', ')} delay={150}>
309
+ <span>{namespaces.length} namespaces</span>
310
+ </Tooltip>
311
+ </ContextFact>
312
+ ) : null}
313
+ <ContextFact label="Ready">
314
+ <ReadyBar ready={ready} desired={desired} width="w-16" />
315
+ </ContextFact>
316
+ {(app.appVersion || versions.length > 0) && (
317
+ <ContextFact label="Version">
318
+ <VersionInfo app={app} variant="fact" />
319
+ </ContextFact>
320
+ )}
321
+ </div>
322
+
323
+ {/* Body: for a multi-workload app, a workload rail (navigation + color
324
+ legend + hover-focus) beside the app graph or the selected workload's
325
+ runtime. A single-workload app skips straight to its runtime. */}
326
+ {workloads.length > 1 ? (
327
+ <div className="flex h-[calc(100vh-13rem)] min-h-[480px]">
328
+ <WorkloadRail
329
+ workloads={workloads}
330
+ colorByWorkload={ownership?.colorByWorkload ?? null}
331
+ showAllEntry={appGraphAvailable}
332
+ // Without an app graph the body always renders a workload (selected ??
333
+ // workloads[0]); mirror that so the rail marks it active.
334
+ selectedKey={appGraphAvailable ? selected : selected ?? (workloads[0] ? workloadKey(workloads[0]) : null)}
335
+ focusedOwnerId={focusedOwnerId}
336
+ onSelect={setSelected}
337
+ onFocus={setFocusedOwnerId}
338
+ />
339
+ <div className="relative min-h-0 flex-1 overflow-hidden bg-theme-surface">
340
+ {appGraphAvailable && selected === null ? (
341
+ <TopologyGraph
342
+ topology={appGraph}
343
+ viewMode="resources"
344
+ groupingMode="namespace"
345
+ hideGroupHeader
346
+ onNodeClick={handleAppNodeClick}
347
+ showExportButton={false}
348
+ focusNodeId={appGraphFocusId}
349
+ focusedOwnerId={focusedOwnerId}
350
+ onNodeHover={handleNodeHover}
351
+ />
352
+ ) : selected === null && topologyLoading ? (
353
+ // The graph is this view's landing — hold a loader while the
354
+ // topology fetch is in flight instead of flashing the first
355
+ // workload's runtime (which would fire its data fetches too).
356
+ <PaneLoader label="Loading topology…" className="absolute inset-0" />
357
+ ) : (
358
+ <div key={workloadKey(selectedWorkload ?? workloads[0])} className="h-full min-h-0">
359
+ {renderWorkload(selectedWorkload ?? workloads[0])}
360
+ </div>
361
+ )}
362
+ </div>
363
+ </div>
364
+ ) : (
365
+ renderRuntime(workloads[0], renderWorkload)
366
+ )}
367
+ </div>
368
+ )
369
+ }
370
+
371
+ // WorkloadRail — the app's workloads as a left rail: color legend (each row's
372
+ // swatch matches that workload's node tint in the graph), navigation (click →
373
+ // that workload's runtime in place), and hover-focus (hover → that workload's
374
+ // nodes stay lit, the rest dim). Reciprocal: hovering a node lights its row.
375
+ function WorkloadRail({
376
+ workloads,
377
+ colorByWorkload,
378
+ showAllEntry,
379
+ selectedKey,
380
+ focusedOwnerId,
381
+ onSelect,
382
+ onFocus,
383
+ }: {
384
+ workloads: AppWorkload[]
385
+ colorByWorkload: Map<string, number> | null
386
+ showAllEntry: boolean
387
+ selectedKey: string | null
388
+ focusedOwnerId: WorkloadFocus
389
+ onSelect: (key: string | null) => void
390
+ onFocus: (owner: WorkloadFocus) => void
391
+ }) {
392
+ return (
393
+ <div
394
+ className="flex w-56 shrink-0 flex-col gap-0.5 overflow-y-auto border-r border-theme-border bg-theme-base px-2 py-2"
395
+ onMouseLeave={() => onFocus(null)}
396
+ >
397
+ <div className="flex items-center justify-between px-1.5 pb-1 pt-0.5">
398
+ <span className="text-[10px] font-medium uppercase tracking-wide text-theme-text-tertiary">Workloads</span>
399
+ <span className="rounded-full bg-theme-hover px-1.5 text-[10px] font-medium text-theme-text-tertiary">{workloads.length}</span>
400
+ </div>
401
+
402
+ {showAllEntry && (
403
+ <RailRow
404
+ active={selectedKey === null}
405
+ onClick={() => onSelect(null)}
406
+ onMouseEnter={() => onFocus(null)}
407
+ swatch={<Network className="h-3.5 w-3.5 text-theme-text-secondary" aria-hidden />}
408
+ title="All workloads"
409
+ />
410
+ )}
411
+
412
+ {workloads.map((w) => {
413
+ const key = workloadKey(w)
414
+ const idx = colorByWorkload?.get(key)
415
+ const hue = idx != null ? workloadHue(idx) : null
416
+ return (
417
+ <RailRow
418
+ key={key}
419
+ active={selectedKey === key}
420
+ focused={focusedOwnerId === key}
421
+ onClick={() => onSelect(key)}
422
+ onMouseEnter={() => onFocus(key)}
423
+ swatch={
424
+ <span
425
+ className="h-3 w-3 shrink-0 rounded-[3px] bg-theme-border ring-1 ring-inset ring-theme-border"
426
+ style={hue ? { background: hue.swatch } : undefined}
427
+ />
428
+ }
429
+ title={w.name}
430
+ tooltip={`${w.name} · ${w.kind}`}
431
+ subtitle={w.kind}
432
+ trailing={<StatusDot tone={mapHealthToTone(healthOf(w.health))} />}
433
+ />
434
+ )
435
+ })}
436
+
437
+ {colorByWorkload && (
438
+ <RailRow
439
+ muted
440
+ focused={focusedOwnerId === NEUTRAL_OWNER}
441
+ onMouseEnter={() => onFocus(NEUTRAL_OWNER)}
442
+ swatch={<span className="h-3 w-3 shrink-0 rounded-[3px] bg-theme-border" />}
443
+ title="Shared / unscoped"
444
+ />
445
+ )}
446
+ </div>
447
+ )
448
+ }
449
+
450
+ function RailRow({
451
+ active,
452
+ focused,
453
+ muted,
454
+ onClick,
455
+ onMouseEnter,
456
+ swatch,
457
+ title,
458
+ tooltip,
459
+ subtitle,
460
+ trailing,
461
+ }: {
462
+ active?: boolean
463
+ focused?: boolean
464
+ muted?: boolean
465
+ onClick?: () => void
466
+ onMouseEnter?: () => void
467
+ swatch: ReactNode
468
+ title: string
469
+ /** Full label shown on hover — the row title truncates. */
470
+ tooltip?: string
471
+ subtitle?: string
472
+ trailing?: ReactNode
473
+ }) {
474
+ const className = clsx(
475
+ 'flex w-full items-center gap-2 rounded-md px-1.5 py-1.5 text-left transition-colors',
476
+ active
477
+ ? 'selection selection-ring'
478
+ : focused
479
+ ? 'bg-theme-hover'
480
+ : onClick && 'hover:bg-theme-hover',
481
+ !onClick && 'cursor-default',
482
+ )
483
+ const titleEl = <span className={clsx('block truncate text-xs', muted ? 'text-theme-text-tertiary' : 'font-medium text-theme-text-primary')}>{title}</span>
484
+ const inner = (
485
+ <>
486
+ {swatch}
487
+ <span className="min-w-0 flex-1">
488
+ {tooltip ? <Tooltip content={tooltip} delay={300} position="right">{titleEl}</Tooltip> : titleEl}
489
+ {subtitle && <span className="block truncate text-[10px] uppercase tracking-wide text-theme-text-tertiary">{subtitle}</span>}
490
+ </span>
491
+ {trailing}
492
+ </>
493
+ )
494
+ return onClick ? (
495
+ <button type="button" onClick={onClick} onMouseEnter={onMouseEnter} className={className}>{inner}</button>
496
+ ) : (
497
+ <div onMouseEnter={onMouseEnter} className={className}>{inner}</div>
498
+ )
499
+ }
500
+
501
+ // renderRuntime shows the single-workload app's runtime (host-injected
502
+ // WorkloadView). Multi-workload apps render runtimes beside the rail instead,
503
+ // which also owns navigation back to the app graph.
504
+ function renderRuntime(
505
+ workload: SelectedAppWorkload | undefined,
506
+ renderWorkload: (workload: SelectedAppWorkload) => ReactNode,
507
+ ): ReactNode {
508
+ if (!workload) {
509
+ return (
510
+ <div className="rounded-md border border-dashed border-theme-border p-8 text-center text-sm text-theme-text-tertiary">
511
+ This application has no inspectable workloads.
512
+ </div>
513
+ )
514
+ }
515
+ return (
516
+ // Concrete height (not flex-1 of an unsized column): the workload's
517
+ // Topology tab positions ReactFlow absolutely and collapses to zero inside
518
+ // a content-sized ancestor. Mirrors the multi-workload rail row's height.
519
+ <div className="flex h-[calc(100vh-13rem)] min-h-[480px] flex-col">
520
+ <div key={workloadKey(workload)} className="min-h-0 flex-1">{renderWorkload(workload)}</div>
521
+ </div>
522
+ )
523
+ }
524
+
525
+ function restartWarning(workloads: AppWorkload[]): { restarts: number; reason?: string; workload: string } | null {
526
+ let worst: { restarts: number; reason?: string; workload: string } | null = null
527
+ for (const w of workloads) {
528
+ const r = w.restarts ?? 0
529
+ if (r > 0 && (!worst || r > worst.restarts)) {
530
+ worst = { restarts: r, reason: w.reason, workload: `${w.kind}/${w.name}` }
531
+ }
532
+ }
533
+ return worst
534
+ }
535
+
536
+ // EnvSwitcher — the Environment fact's interactive form when one app runs in
537
+ // several environments. Up to MAX_INLINE_ENVS: inline pills (the at-a-glance
538
+ // ladder). More: a picker popover with the full ladder-ordered list — same
539
+ // affordance at 5 envs or 100. Always ends with the evidence chip
540
+ // (AppIdentityTooltip) and, when a ranked lower env outruns a ranked higher one,
541
+ // the amber lag chip.
542
+ const MAX_INLINE_ENVS = 4
543
+
544
+ function EnvSwitcher({
545
+ identityKey,
546
+ instances,
547
+ activeKey,
548
+ onSwitch,
549
+ }: {
550
+ identityKey: string
551
+ instances: AppIdentityInstance[]
552
+ activeKey: string
553
+ onSwitch?: (appKey: string) => void
554
+ }) {
555
+ const [open, setOpen] = useState(false)
556
+ const rootRef = useRef<HTMLDivElement>(null)
557
+ useEffect(() => {
558
+ if (!open) return
559
+ const onDown = (e: MouseEvent) => {
560
+ if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false)
561
+ }
562
+ const onKey = (e: KeyboardEvent) => {
563
+ if (e.key === 'Escape') setOpen(false)
564
+ }
565
+ document.addEventListener('mousedown', onDown)
566
+ document.addEventListener('keydown', onKey)
567
+ return () => {
568
+ document.removeEventListener('mousedown', onDown)
569
+ document.removeEventListener('keydown', onKey)
570
+ }
571
+ }, [open])
572
+
573
+ const envInstances = useMemo(() => collapseIdentityInstances(instances, activeKey), [instances, activeKey])
574
+ const lag = appGroupLagMessage(envInstances)
575
+ const active = envInstances.find((i) => i.appKey === activeKey) ?? instances.find((i) => i.appKey === activeKey)
576
+ const envCounts = useMemo(() => {
577
+ const counts = new Map<string, number>()
578
+ for (const inst of instances) counts.set(inst.env, (counts.get(inst.env) ?? 0) + 1)
579
+ return counts
580
+ }, [instances])
581
+ const evidenceChip = (
582
+ <Tooltip
583
+ content={<AppIdentityTooltip identityKey={identityKey} members={instances.map((i) => ({ name: i.name, env: i.env, confidence: i.confidence, evidence: i.evidence }))} />}
584
+ delay={150}
585
+ >
586
+ <span className="inline-flex cursor-default items-center rounded-sm bg-theme-hover px-1 py-px ring-1 ring-inset ring-theme-border">
587
+ <Layers className="h-3 w-3 text-theme-text-tertiary" aria-hidden />
588
+ </span>
589
+ </Tooltip>
590
+ )
591
+ const lagChip = lag && <span className={`${CHIP} ${CHIP_TONE.amber}`}>{lag}</span>
592
+
593
+ if (envInstances.length <= MAX_INLINE_ENVS) {
594
+ return (
595
+ <span className="flex flex-wrap items-center gap-1">
596
+ {instances.map((inst) => {
597
+ const isActive = inst.appKey === activeKey
598
+ const duplicateEnv = (envCounts.get(inst.env) ?? 0) > 1
599
+ return (
600
+ <Tooltip key={inst.appKey} content={`${inst.name}${inst.version ? ` · ${inst.version}` : ''}`} delay={150}>
601
+ <button
602
+ type="button"
603
+ disabled={isActive}
604
+ onClick={() => !isActive && onSwitch?.(inst.appKey)}
605
+ className={clsx(
606
+ 'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs ring-1 ring-inset transition-colors',
607
+ isActive ? 'selection selection-ring font-medium' : 'bg-theme-surface ring-theme-border hover:bg-theme-hover',
608
+ )}
609
+ >
610
+ <StatusDot tone={mapHealthToTone(inst.health)} />
611
+ {inst.env}
612
+ {duplicateEnv ? <span className="max-w-24 truncate text-theme-text-tertiary">{inst.name}</span> : null}
613
+ </button>
614
+ </Tooltip>
615
+ )
616
+ })}
617
+ {evidenceChip}
618
+ {lagChip}
619
+ </span>
620
+ )
621
+ }
622
+
623
+ return (
624
+ <div ref={rootRef} className="relative flex items-center gap-1">
625
+ <button
626
+ type="button"
627
+ onClick={() => setOpen((o) => !o)}
628
+ aria-haspopup="listbox"
629
+ aria-expanded={open}
630
+ className="inline-flex items-center gap-1.5 rounded-md bg-theme-surface px-2 py-0.5 text-xs ring-1 ring-inset ring-theme-border hover:bg-theme-hover"
631
+ >
632
+ {active && <StatusDot tone={mapHealthToTone(active.health)} />}
633
+ <span className="font-medium">{active?.env ?? '—'}</span>
634
+ <span className="text-theme-text-tertiary">· {envInstances.length} environments</span>
635
+ <ChevronDown className={clsx('h-3 w-3 text-theme-text-tertiary transition-transform', open && 'rotate-180')} aria-hidden />
636
+ </button>
637
+ {evidenceChip}
638
+ {lagChip}
639
+ {open && (
640
+ <div role="listbox" className="absolute left-0 top-full z-50 mt-1 max-h-80 w-80 overflow-y-auto rounded-md border border-theme-border bg-theme-surface p-1 shadow-theme-md">
641
+ {instances.map((inst) => {
642
+ const isActive = inst.appKey === activeKey
643
+ return (
644
+ <button
645
+ key={inst.appKey}
646
+ type="button"
647
+ role="option"
648
+ aria-selected={isActive}
649
+ onClick={() => {
650
+ setOpen(false)
651
+ if (!isActive) onSwitch?.(inst.appKey)
652
+ }}
653
+ className={clsx(
654
+ 'flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs',
655
+ isActive ? 'selection selection-ring' : 'hover:bg-theme-hover',
656
+ )}
657
+ >
658
+ <StatusDot tone={mapHealthToTone(inst.health)} />
659
+ <span className="w-20 shrink-0 font-medium text-theme-text-primary">{inst.env}</span>
660
+ <span className="min-w-0 flex-1 truncate text-theme-text-secondary">
661
+ {inst.name}
662
+ </span>
663
+ {inst.version && <span className="font-mono text-[10px] text-theme-text-tertiary">{midTruncate(inst.version, 18)}</span>}
664
+ </button>
665
+ )
666
+ })}
667
+ </div>
668
+ )}
669
+ </div>
670
+ )
671
+ }