@skyhook-io/k8s-ui 1.8.9 → 1.8.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/package.json +5 -4
  2. package/src/assets/gitops/argocd.png +0 -0
  3. package/src/assets/gitops/flux.svg +7 -0
  4. package/src/components/applications/AppChips.tsx +10 -0
  5. package/src/components/applications/ApplicationDetail.test.tsx +569 -0
  6. package/src/components/applications/ApplicationDetail.tsx +2517 -450
  7. package/src/components/applications/ApplicationsList.tsx +4 -1
  8. package/src/components/applications/ApplicationsView.tsx +101 -18
  9. package/src/components/applications/applications-view.test.tsx +47 -0
  10. package/src/components/applications/index.ts +1 -1
  11. package/src/components/audit/AuditAlerts.tsx +14 -10
  12. package/src/components/checks/ChecksView.tsx +81 -51
  13. package/src/components/checks/severity.ts +39 -45
  14. package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -2
  15. package/src/components/compare/CompareResourcePicker.tsx +2 -2
  16. package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
  17. package/src/components/gitops/GitOpsTableView.tsx +14 -59
  18. package/src/components/gitops/ManagedResourcesList.tsx +8 -11
  19. package/src/components/gitops/SyncCountdown.tsx +26 -17
  20. package/src/components/gitops/SyncOptionsDialog.tsx +2 -2
  21. package/src/components/gitops/insights/GitOpsInsightViews.tsx +277 -104
  22. package/src/components/gitops/insights/insights-helpers.test.ts +86 -1
  23. package/src/components/gitops/insights/insights-helpers.ts +69 -1
  24. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -2
  25. package/src/components/issues/IssuesView.tsx +219 -143
  26. package/src/components/issues/ResourceIssuesSection.test.tsx +40 -0
  27. package/src/components/issues/ResourceIssuesSection.tsx +28 -128
  28. package/src/components/issues/index.ts +3 -2
  29. package/src/components/issues/issue-timing.ts +53 -0
  30. package/src/components/issues/issues.test.ts +146 -4
  31. package/src/components/issues/severity.ts +29 -32
  32. package/src/components/logs/LogCore.tsx +14 -4
  33. package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
  34. package/src/components/logs/useLogStream.ts +8 -1
  35. package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
  36. package/src/components/resources/ResourcesSidebar.test.tsx +107 -0
  37. package/src/components/resources/ResourcesSidebar.tsx +70 -52
  38. package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
  39. package/src/components/resources/ResourcesView.tsx +243 -53
  40. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
  41. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
  42. package/src/components/resources/renderers/JobRenderer.tsx +3 -1
  43. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +47 -0
  44. package/src/components/resources/renderers/NodeRenderer.test.tsx +103 -0
  45. package/src/components/resources/renderers/NodeRenderer.tsx +20 -12
  46. package/src/components/resources/renderers/PVCRenderer.test.tsx +23 -0
  47. package/src/components/resources/renderers/PVCRenderer.tsx +2 -2
  48. package/src/components/resources/renderers/PodRenderer.test.tsx +101 -0
  49. package/src/components/resources/renderers/PodRenderer.tsx +81 -70
  50. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
  51. package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
  52. package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
  53. package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
  54. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
  55. package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
  56. package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
  57. package/src/components/resources/renderers/index.ts +1 -0
  58. package/src/components/resources/resource-utils-capi.ts +1 -1
  59. package/src/components/resources/resource-utils.ts +31 -0
  60. package/src/components/shared/DetailShell.tsx +2 -2
  61. package/src/components/shared/ResourceActionsBar.tsx +140 -108
  62. package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
  63. package/src/components/timeline/TimelineList.tsx +371 -354
  64. package/src/components/timeline/TimelineStrip.test.tsx +194 -0
  65. package/src/components/timeline/TimelineStrip.tsx +836 -0
  66. package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
  67. package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
  68. package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
  69. package/src/components/timeline/TimelineToolbar.tsx +759 -0
  70. package/src/components/timeline/TimelineToolbarState.test.tsx +64 -0
  71. package/src/components/timeline/index.ts +23 -0
  72. package/src/components/timeline/scrubber-math.test.ts +235 -0
  73. package/src/components/timeline/scrubber-math.ts +249 -0
  74. package/src/components/timeline/shared.test.tsx +219 -0
  75. package/src/components/timeline/shared.tsx +232 -447
  76. package/src/components/timeline/timeline-filters.test.ts +196 -0
  77. package/src/components/timeline/timeline-filters.ts +180 -0
  78. package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
  79. package/src/components/timeline/timeline-lane-sort.ts +77 -0
  80. package/src/components/timeline/timeline-live.test.ts +131 -0
  81. package/src/components/timeline/timeline-live.ts +77 -0
  82. package/src/components/topology/GroupNode.tsx +9 -2
  83. package/src/components/topology/K8sResourceNode.test.ts +11 -0
  84. package/src/components/topology/K8sResourceNode.tsx +387 -243
  85. package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
  86. package/src/components/topology/TopologyGraph.tsx +89 -16
  87. package/src/components/topology/TopologySearch.tsx +2 -2
  88. package/src/components/topology/layout.ts +10 -2
  89. package/src/components/topology/topology.css +0 -92
  90. package/src/components/ui/Badge.tsx +5 -0
  91. package/src/components/ui/BoardSkeleton.tsx +47 -0
  92. package/src/components/ui/CardSection.tsx +117 -0
  93. package/src/components/ui/CodeViewer.tsx +2 -2
  94. package/src/components/ui/Collapse.tsx +59 -0
  95. package/src/components/ui/ConfirmDialog.tsx +7 -3
  96. package/src/components/ui/DialogPortal.tsx +31 -9
  97. package/src/components/ui/Input.tsx +24 -0
  98. package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
  99. package/src/components/ui/MultiSelectPicker.tsx +167 -0
  100. package/src/components/ui/PaneLoader.tsx +10 -2
  101. package/src/components/ui/ResourceBar.tsx +68 -25
  102. package/src/components/ui/SearchBox.tsx +18 -4
  103. package/src/components/ui/SearchPillInput.tsx +2 -2
  104. package/src/components/ui/Toast.tsx +1 -1
  105. package/src/components/ui/drawer-components.tsx +39 -26
  106. package/src/components/ui/index.ts +6 -0
  107. package/src/components/ui/severity-tone.ts +63 -0
  108. package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
  109. package/src/components/workload/WorkloadView.tsx +2682 -472
  110. package/src/components/workload/index.ts +1 -1
  111. package/src/theme/variables.css +15 -1
  112. package/src/types/core.ts +57 -4
  113. package/src/utils/api-resources.test.ts +54 -0
  114. package/src/utils/api-resources.ts +68 -1
  115. package/src/utils/application-topology.test.ts +198 -0
  116. package/src/utils/application-topology.ts +261 -0
  117. package/src/utils/applications.test.ts +167 -1
  118. package/src/utils/applications.ts +1157 -393
  119. package/src/utils/index.ts +2 -0
  120. package/src/utils/navigation.test.ts +32 -1
  121. package/src/utils/navigation.ts +70 -0
  122. package/src/utils/resource-hierarchy.test.ts +990 -1
  123. package/src/utils/resource-hierarchy.ts +905 -267
  124. package/src/utils/resource-icons.ts +2 -0
  125. package/src/utils/topology-neighborhood.test.ts +202 -1
  126. package/src/utils/topology-neighborhood.ts +194 -13
  127. package/src/utils/workflow-execution.test.ts +556 -0
  128. package/src/utils/workflow-execution.ts +814 -0
  129. package/src/utils/workload-colors.ts +2 -3
  130. package/tsconfig.json +2 -2
@@ -1,17 +1,25 @@
1
1
  import { useMemo, useState, useCallback, useEffect, useRef, type ReactNode } from 'react'
2
- import { ArrowLeft, Boxes, Network, Layers, ChevronDown } from 'lucide-react'
2
+ import { AlertTriangle, ArrowLeft, Boxes, ChevronDown, ExternalLink, Layers, Radio, Search } from 'lucide-react'
3
3
  import { clsx } from 'clsx'
4
- import type { Topology, TopologyNode } from '../../types'
4
+ import type { ResourceRef, Topology, TopologyNode } from '../../types'
5
+ import argoCdLogo from '../../assets/gitops/argocd.png'
6
+ import fluxLogo from '../../assets/gitops/flux.svg'
5
7
  import { StatusDot, mapHealthToTone } from '../ui/status-tone'
6
8
  import { Tooltip } from '../ui/Tooltip'
9
+ import { EmptyState } from '../ui/EmptyState'
10
+ import { ResourceRefBadge } from '../ui/drawer-components'
7
11
  import { TopologyGraph } from '../topology/TopologyGraph'
8
12
  import { pluralize } from '../../utils/pluralize'
9
- import { kindToPlural, apiVersionToGroup } from '../../utils/navigation'
10
- import { tagWorkloadOwnership, seedNodeIds, ownershipOf, workloadKey, type NeighborhoodSeed } from '../../utils/topology-neighborhood'
13
+ import { kindToPlural, refToSelectedResource } from '../../utils/navigation'
14
+ import { batchRunParentNodes, tagWorkloadOwnership, seedNodeIds, ownershipOf, workloadKey, type NeighborhoodSeed } from '../../utils/topology-neighborhood'
11
15
  import { workloadHue, NEUTRAL_OWNER, type WorkloadFocus } from '../../utils/workload-colors'
16
+ import { getTopologyIcon } from '../../utils/resource-icons'
12
17
  import {
13
18
  type AppRow,
19
+ type AppHistory,
20
+ type AppSourceRef,
14
21
  type AppWorkload,
22
+ type AppBatchActivity,
15
23
  type AppHealth,
16
24
  CHIP,
17
25
  CHIP_TONE,
@@ -23,197 +31,321 @@ import {
23
31
  identityEnvInferred,
24
32
  workloadClassOf,
25
33
  classCompositionOf,
34
+ batchSignalForApp,
35
+ batchActivityForApp,
36
+ batchRuntimeForApp,
37
+ applicationDisplayHealth,
38
+ sourceReportedHealth,
39
+ sourceSyncHealth,
40
+ servingReadiness,
26
41
  worstHealth,
27
42
  appGroupLagMessage,
28
43
  compareVersions,
44
+ appSourceLabel,
45
+ overlayProvenance,
29
46
  } from '../../utils/applications'
30
47
  import { PaneLoader } from '../ui/PaneLoader'
31
48
  import { midTruncate } from '../../utils/format'
32
49
  import { VersionTooltip, AppIdentityTooltip } from './AppTooltips'
33
- import { ProvenanceBadge, ClassBadge, CategoryChip, VersionInfo } from './AppChips'
50
+ import { ProvenanceBadge, ClassBadge, CategoryChip, VersionInfo, BatchSignalChip } from './AppChips'
34
51
  import { ReadyBar } from './ReadyBar'
52
+ import { collapseStableReplicaSets, layerDeploymentInventory, topologyGroup, type DeploymentInventory, type DeploymentTopologyLayer } from '../../utils/application-topology'
35
53
 
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
54
+ // ApplicationDetail owns the application chrome and scope switcher. The selected
55
+ // scope decides the one tab row shown in the detail pane: app scope gets
56
+ // Overview/Topology/History; workload scope delegates to the host's WorkloadView.
57
+
58
+ export type SelectedAppWorkload = NeighborhoodSeed;
50
59
 
51
60
  /** One env instance in the identity switcher — a sibling app row's digest. */
52
61
  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
62
+ appKey: string;
63
+ name: string;
64
+ env: string;
65
+ health: AppHealth;
66
+ version?: string;
67
+ confidence: string;
68
+ evidence: string;
69
+ count?: number;
61
70
  }
62
71
 
63
72
  /** Workload selection is either fully controlled (key + callback, the host
64
73
  * 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. */
74
+ * only half silently freezes the selector, so the types forbid it. `null` means
75
+ * the application itself is selected; a key (see `workloadKey`) selects a
76
+ * workload scope. */
67
77
  type SelectionProps =
68
- | { selectedWorkloadKey: string | null; onSelectWorkload: (key: string | null) => void }
69
- | { selectedWorkloadKey?: undefined; onSelectWorkload?: undefined }
78
+ | {
79
+ selectedWorkloadKey: string | null;
80
+ onSelectWorkload: (key: string | null) => void;
81
+ }
82
+ | { selectedWorkloadKey?: undefined; onSelectWorkload?: undefined };
83
+
84
+ type CanonicalApplicationView = "overview" | "topology" | "history";
85
+
86
+ export type ApplicationView = CanonicalApplicationView;
87
+
88
+ const DEFAULT_APPLICATION_VIEW: CanonicalApplicationView = "overview";
89
+
90
+ const APPLICATION_VIEWS: Array<{
91
+ id: CanonicalApplicationView;
92
+ label: string;
93
+ }> = [
94
+ { id: "overview", label: "Overview" },
95
+ { id: "topology", label: "Topology" },
96
+ { id: "history", label: "History" },
97
+ ];
98
+
99
+ function canonicalApplicationView(
100
+ view?: ApplicationView,
101
+ ): CanonicalApplicationView {
102
+ return view ?? DEFAULT_APPLICATION_VIEW;
103
+ }
104
+
105
+ type ViewProps =
106
+ | {
107
+ selectedView: ApplicationView;
108
+ onSelectView: (view: ApplicationView) => void;
109
+ }
110
+ | { selectedView?: undefined; onSelectView?: undefined };
70
111
 
71
112
  export type ApplicationDetailProps = {
72
- app: AppRow
73
- onBack: () => void
113
+ app: AppRow;
114
+ onBack: () => void;
74
115
  /** 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
116
+ renderWorkload: (workload: SelectedAppWorkload) => ReactNode;
117
+ /** Render host-scoped operational issues for the app overview. */
118
+ renderOverviewIssues?: () => ReactNode;
119
+ /** Whether the host-scoped issue surface contains current issues. */
120
+ hasOverviewIssues?: boolean;
121
+ /** Resources-view topology spanning the app's namespaces. When present, it
122
+ * powers the application Topology view and workload hover focus. */
123
+ topology?: Topology;
79
124
  /** 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. */
125
+ * multi-workload app can briefly show an empty topology while topology loads. */
82
126
  topologyLoading?: boolean
127
+ /** Exact resources reported by the app's Argo CD, Flux, or Helm deployment source. */
128
+ deploymentInventory?: DeploymentInventory
83
129
  /** Open a related (non-workload) resource clicked in the app graph. */
84
- onNavigateToResource?: (resource: { kind: string; namespace: string; name: string; group?: string }) => void
130
+ onNavigateToResource?: (resource: {
131
+ kind: string;
132
+ namespace: string;
133
+ name: string;
134
+ group?: string;
135
+ }) => void;
136
+ /** Open a generated Job or Workflow in the run history of its parent workload. */
137
+ onSelectWorkloadRun?: (
138
+ workload: SelectedAppWorkload,
139
+ run: TopologyNode,
140
+ ) => void;
85
141
  /** App identity siblings (this instance included, ladder-ordered) — turns the
86
142
  * context strip's Environment fact into a switcher. Identity grouping is
87
143
  * classification, not an address: it switches between REAL instances, never
88
144
  * an aggregate page. */
89
- identityInstances?: AppIdentityInstance[] | null
145
+ identityInstances?: AppIdentityInstance[] | null;
90
146
  /** Switch to a sibling instance (host swaps ?app= and, when it can match
91
147
  * the current workload in the target, preserves ?workload= + ?tab=). */
92
- onSwitchInstance?: (appKey: string) => void
148
+ onSwitchInstance?: (appKey: string) => void;
93
149
  /** Env tokens the cluster proved (the list derives the same set) — keeps a
94
150
  * ungrouped app's Environment fact consistent between list and detail. */
95
- discoveredEnvs?: ReadonlySet<string>
151
+ discoveredEnvs?: ReadonlySet<string>;
96
152
  /** Which instance in `identityInstances` is active, by its `appKey`. Defaults
97
153
  * to `app.key` — correct for the OSS case, where each instance IS a distinct
98
154
  * app row keyed the same way. A host that keys instances on something other
99
155
  * than the app key (e.g. the Cloud fleet keys per cluster, while `app.key`
100
156
  * must stay the logical app key for provenance) passes the active instance's
101
157
  * key here so the switcher marks it. */
102
- activeInstanceKey?: string
103
- } & SelectionProps
158
+ activeInstanceKey?: string;
159
+ history?: AppHistory;
160
+ historyLoading?: boolean;
161
+ onOpenSource?: (source: AppSourceRef) => void;
162
+ } & SelectionProps &
163
+ ViewProps;
104
164
 
105
- function ContextFact({ label, children }: { label: string; children: ReactNode }) {
165
+ function ContextFact({
166
+ label,
167
+ children,
168
+ }: {
169
+ label: string;
170
+ children: ReactNode;
171
+ }) {
106
172
  return (
107
173
  <div className="flex min-w-0 items-baseline gap-1.5">
108
- <span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary">{label}</span>
109
- <span className="min-w-0 truncate text-xs text-theme-text-secondary">{children}</span>
174
+ <span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary">
175
+ {label}
176
+ </span>
177
+ <span className="min-w-0 truncate text-xs text-theme-text-secondary">
178
+ {children}
179
+ </span>
110
180
  </div>
111
- )
181
+ );
112
182
  }
113
183
 
114
- function collapseIdentityInstances(instances: AppIdentityInstance[], activeKey: string): AppIdentityInstance[] {
115
- const byEnv = new Map<string, AppIdentityInstance[]>()
184
+ function collapseIdentityInstances(
185
+ instances: AppIdentityInstance[],
186
+ activeKey: string,
187
+ ): AppIdentityInstance[] {
188
+ const byEnv = new Map<string, AppIdentityInstance[]>();
116
189
  for (const inst of instances) {
117
- byEnv.set(inst.env, [...(byEnv.get(inst.env) ?? []), inst])
190
+ byEnv.set(inst.env, [...(byEnv.get(inst.env) ?? []), inst]);
118
191
  }
119
192
  return Array.from(byEnv.values()).map((group) => {
120
- const active = group.find((i) => i.appKey === activeKey)
121
- const newest = group.reduce<AppIdentityInstance | undefined>((best, inst) => {
122
- if (!best) return inst
123
- return compareDefinedVersions(inst.version, best.version) === 1 ? inst : best
124
- }, undefined)
125
- const display = active ?? newest ?? group[0]
193
+ const active = group.find((i) => i.appKey === activeKey);
194
+ const newest = group.reduce<AppIdentityInstance | undefined>(
195
+ (best, inst) => {
196
+ if (!best) return inst;
197
+ return compareDefinedVersions(inst.version, best.version) === 1
198
+ ? inst
199
+ : best;
200
+ },
201
+ undefined,
202
+ );
203
+ const display = active ?? newest ?? group[0];
126
204
  return {
127
205
  ...display,
128
206
  health: worstHealth(group.map((inst) => inst.health)),
129
207
  version: newest?.version,
130
208
  count: group.length,
131
- }
132
- })
209
+ };
210
+ });
133
211
  }
134
212
 
135
- function compareDefinedVersions(a: string | undefined, b: string | undefined): number {
136
- if (!a && !b) return 0
137
- if (a && !b) return 1
138
- if (!a || !b) return -1
139
- return compareVersions(a, b) ?? 0
213
+ function compareDefinedVersions(
214
+ a: string | undefined,
215
+ b: string | undefined,
216
+ ): number {
217
+ if (!a && !b) return 0;
218
+ if (a && !b) return 1;
219
+ if (!a || !b) return -1;
220
+ return compareVersions(a, b) ?? 0;
140
221
  }
141
222
 
142
- export function ApplicationDetail({ app, onBack, renderWorkload, topology, topologyLoading, onNavigateToResource, identityInstances, onSwitchInstance, discoveredEnvs, activeInstanceKey, selectedWorkloadKey, onSelectWorkload }: ApplicationDetailProps) {
223
+ export function ApplicationDetail({ app, onBack, renderWorkload, renderOverviewIssues, hasOverviewIssues, topology, topologyLoading, deploymentInventory, onNavigateToResource, onSelectWorkloadRun, identityInstances, onSwitchInstance, discoveredEnvs, activeInstanceKey, history, historyLoading, onOpenSource, selectedWorkloadKey, onSelectWorkload, selectedView, onSelectView }: ApplicationDetailProps) {
143
224
  // Stable order regardless of API ordering: rail rows and the per-workload
144
225
  // color assignment both follow this array, so an order flap between
145
226
  // refetches must not reshuffle rows or reassign a workload's hue.
146
227
  const workloads = useMemo(
147
228
  () =>
148
229
  [...(app.workloads ?? [])].sort(
149
- (a, b) => a.name.localeCompare(b.name) || a.kind.localeCompare(b.kind) || a.namespace.localeCompare(b.namespace),
230
+ (a, b) =>
231
+ a.name.localeCompare(b.name) ||
232
+ a.kind.localeCompare(b.kind) ||
233
+ a.namespace.localeCompare(b.namespace),
150
234
  ),
151
235
  [app.workloads],
152
- )
153
- const overall = worstHealth([app.health, ...workloads.map((w) => w.health)])
154
- const verdictTone = HEALTH_META[overall].pill
155
- const verdictLabel = HEALTH_META[overall].label
156
- const workloadClass = workloadClassOf(app.workload_class)
157
- const versions = useMemo(() => Array.from(new Set((app.versions || []).filter(Boolean))), [app.versions])
158
- const ready = workloads.reduce((n, w) => n + (w.ready ?? 0), 0)
159
- const desired = workloads.reduce((n, w) => n + (w.desired ?? 0), 0)
160
- const restartSignal = restartWarning(workloads)
236
+ );
237
+ const workloadClass = workloadClassOf(app.workload_class);
238
+ const batchRuntime = batchRuntimeForApp(app);
239
+ const overall = applicationDisplayHealth(app);
240
+ const verdictTone = HEALTH_META[overall].pill;
241
+ const verdictLabel =
242
+ workloadClass === "job" && overall === batchRuntime.health
243
+ ? batchRuntime.label
244
+ : HEALTH_META[overall].label;
245
+ const versions = useMemo(
246
+ () => Array.from(new Set((app.versions || []).filter(Boolean))),
247
+ [app.versions],
248
+ );
249
+ const { ready, desired } = servingReadiness(workloads);
250
+ const restartSignal = restartWarning(workloads);
251
+ const batchSignal = workloadClass === "job" ? null : batchSignalForApp(app);
161
252
  // Resolve namespace the same way the list does (the workloads' shared
162
253
  // namespace) so env/namespace match across list and detail. Multi-namespace
163
254
  // apps get the count, never an arbitrary pick.
164
- const namespace = namespaceOf(app)
165
- const namespaces = namespacesOf(app)
166
- const resolvedEnv = resolveEnv(undefined, namespace, discoveredEnvs)
167
- const env = app.identity?.env ?? resolvedEnv.env
168
- const inferred = app.identity ? identityEnvInferred(app.identity) : resolvedEnv.inferred
169
-
170
- // The app graph is the landing for multi-workload apps when a topology was
171
- // injected. A single workload (or no topology) skips straight to runtime.
172
- const appGraphAvailable = !!topology && workloads.length > 1
173
-
174
- // `null` = the app graph (landing); a key = that workload's runtime. Initially
175
- // null so multi-workload apps land on the graph; single-workload apps ignore
176
- // this and render the lone workload directly. Controlled by the host (URL) when
177
- // `selectedWorkloadKey` is provided, otherwise internal.
178
- const [internalSelected, setInternalSelected] = useState<string | null>(null)
179
- const rawSelected = selectedWorkloadKey !== undefined ? selectedWorkloadKey : internalSelected
255
+ const namespace = namespaceOf(app);
256
+ const namespaces = namespacesOf(app);
257
+ const resolvedEnv = resolveEnv(undefined, namespace, discoveredEnvs);
258
+ const env = app.identity?.env ?? resolvedEnv.env;
259
+ const inferred = app.identity
260
+ ? identityEnvInferred(app.identity)
261
+ : resolvedEnv.inferred;
262
+ const [internalView, setInternalView] = useState<CanonicalApplicationView>(
263
+ DEFAULT_APPLICATION_VIEW,
264
+ );
265
+ const activeView = canonicalApplicationView(selectedView ?? internalView);
266
+ const setView = useCallback(
267
+ (view: CanonicalApplicationView) =>
268
+ onSelectView ? onSelectView(view) : setInternalView(view),
269
+ [onSelectView],
270
+ );
271
+ useEffect(() => {
272
+ if (selectedView === undefined) setInternalView(DEFAULT_APPLICATION_VIEW);
273
+ }, [app.key, selectedView]);
274
+
275
+ const [internalSelected, setInternalSelected] = useState<string | null>(null);
276
+ const implicitSingleWorkloadKey =
277
+ workloads.length === 1 ? workloadKey(workloads[0]) : null;
278
+ const rawSelected =
279
+ selectedWorkloadKey !== undefined
280
+ ? selectedWorkloadKey
281
+ : (internalSelected ?? implicitSingleWorkloadKey);
180
282
  const setSelected = useCallback(
181
- (key: string | null) => (onSelectWorkload ? onSelectWorkload(key) : setInternalSelected(key)),
283
+ (key: string | null) =>
284
+ onSelectWorkload ? onSelectWorkload(key) : setInternalSelected(key),
182
285
  [onSelectWorkload],
183
- )
184
- const selectedWorkload = rawSelected ? workloads.find((w) => workloadKey(w) === rawSelected) : undefined
185
- // A stale controlled key (a deleted workload still in the URL) falls back to
186
- // the app graph rather than silently rendering a different workload under a
187
- // URL that names the missing one.
188
- const selected = rawSelected !== null && !selectedWorkload ? null : rawSelected
286
+ );
287
+ const selectedWorkload = rawSelected
288
+ ? workloads.find((w) => workloadKey(w) === rawSelected)
289
+ : undefined;
290
+ const singleWorkloadScope = workloads.length === 1 && !!selectedWorkload;
189
291
  useEffect(() => {
190
- if (selectedWorkloadKey !== undefined && selectedWorkloadKey !== null && !selectedWorkload) {
191
- onSelectWorkload?.(null)
292
+ if (
293
+ selectedWorkloadKey !== undefined &&
294
+ selectedWorkloadKey !== null &&
295
+ !selectedWorkload
296
+ ) {
297
+ onSelectWorkload?.(null);
192
298
  }
193
- }, [selectedWorkloadKey, selectedWorkload, onSelectWorkload])
299
+ }, [selectedWorkloadKey, selectedWorkload, onSelectWorkload]);
194
300
  useEffect(() => {
195
- if (selectedWorkloadKey === undefined) setInternalSelected(null)
196
- }, [app.key, selectedWorkloadKey])
301
+ if (selectedWorkloadKey === undefined) setInternalSelected(null);
302
+ }, [app.key, selectedWorkloadKey]);
197
303
 
198
304
  // Hover-focus: the workload (or NEUTRAL_OWNER) whose nodes should stay lit
199
305
  // while the rest of the graph dims. Driven by the rail and, reciprocally, by
200
306
  // hovering a node.
201
307
  const [focusedOwnerId, setFocusedOwnerId] = useState<WorkloadFocus>(null)
308
+ const [expandedReplicaSetOwners, setExpandedReplicaSetOwners] = useState<Set<string>>(new Set())
309
+ useEffect(() => setExpandedReplicaSetOwners(new Set()), [app.key])
310
+ const toggleReplicaSets = useCallback((ownerID: string) => {
311
+ setExpandedReplicaSetOwners((current) => {
312
+ const next = new Set(current)
313
+ if (next.has(ownerID)) next.delete(ownerID)
314
+ else next.add(ownerID)
315
+ return next
316
+ })
317
+ }, [])
202
318
 
203
319
  const appSeeds = useMemo(
204
- () => workloads.map((w) => ({ kind: w.kind, namespace: w.namespace, name: w.name })),
320
+ () =>
321
+ workloads.map((w) => ({
322
+ kind: w.kind,
323
+ namespace: w.namespace,
324
+ name: w.name,
325
+ group: w.group,
326
+ })),
205
327
  [workloads],
206
- )
328
+ );
207
329
  // Neighborhood subgraph + per-workload color/ownership tagging in one pass.
330
+ const composedTopology = useMemo(
331
+ () => topology ? collapseStableReplicaSets(topology, expandedReplicaSetOwners) : null,
332
+ [topology, expandedReplicaSetOwners],
333
+ )
208
334
  const ownership = useMemo(
209
- () => (topology ? tagWorkloadOwnership(topology, appSeeds) : null),
210
- [topology, appSeeds],
335
+ () => (composedTopology ? tagWorkloadOwnership(composedTopology, appSeeds) : null),
336
+ [composedTopology, appSeeds],
211
337
  )
212
338
  const appGraph = ownership?.topology ?? null
339
+ const deploymentLayer = useMemo(
340
+ () => appGraph && deploymentInventory && app.sourceRef
341
+ ? layerDeploymentInventory(appGraph, deploymentInventory, sourceInventoryLabel(app.sourceRef))
342
+ : null,
343
+ [appGraph, deploymentInventory, app.sourceRef],
344
+ )
213
345
  const appGraphFocusId = useMemo(
214
346
  () => (topology ? seedNodeIds(topology, appSeeds)[0] : undefined),
215
347
  [topology, appSeeds],
216
- )
348
+ );
217
349
 
218
350
  // Hovering a node lights up its owning workload (and the rail row). An
219
351
  // unowned node related to exactly ONE workload (a GitOps manager over a
@@ -221,65 +353,128 @@ export function ApplicationDetail({ app, onBack, renderWorkload, topology, topol
221
353
  // focus. Truly shared nodes clear instead of dimming everything.
222
354
  const handleNodeHover = useCallback((node: TopologyNode | null) => {
223
355
  if (!node) {
224
- setFocusedOwnerId(null)
225
- return
356
+ setFocusedOwnerId(null);
357
+ return;
226
358
  }
227
- const stamp = ownershipOf(node.data)
228
- setFocusedOwnerId(stamp.ownerWorkloadId ?? (stamp.focusWorkloadIds.length === 1 ? stamp.focusWorkloadIds[0] : NEUTRAL_OWNER))
229
- }, [])
359
+ const stamp = ownershipOf(node.data);
360
+ setFocusedOwnerId(
361
+ stamp.ownerWorkloadId ??
362
+ (stamp.focusWorkloadIds.length === 1
363
+ ? stamp.focusWorkloadIds[0]
364
+ : NEUTRAL_OWNER),
365
+ );
366
+ }, []);
230
367
 
231
368
  // A node click in the app graph either drills into one of the app's workloads
232
369
  // (its runtime) or opens a related resource (Service/config/…) via the host.
233
370
  const handleAppNodeClick = useCallback(
234
371
  (node: TopologyNode) => {
372
+ if (node.data?.sourceInventoryGroup && app.sourceRef) {
373
+ onOpenSource?.(app.sourceRef)
374
+ return
375
+ }
235
376
  const ns = (node.data?.namespace as string) || ''
236
377
  const match = workloads.find((w) => w.kind === node.kind && w.name === node.name && w.namespace === ns)
237
378
  if (match) {
238
- setSelected(workloadKey(match))
239
- return
379
+ setSelected(workloadKey(match));
380
+ return;
381
+ }
382
+ const parents = appGraph ? batchRunParentNodes(appGraph, node) : [];
383
+ const parentWorkload = parents.flatMap((parent) => {
384
+ const parentNamespace = (parent.data?.namespace as string) || "";
385
+ const workload = workloads.find(
386
+ (candidate) =>
387
+ candidate.kind === parent.kind &&
388
+ candidate.name === parent.name &&
389
+ candidate.namespace === parentNamespace,
390
+ );
391
+ return workload ? [workload] : [];
392
+ })[0];
393
+ if (parentWorkload && onSelectWorkloadRun) {
394
+ onSelectWorkloadRun(parentWorkload, node);
395
+ return;
240
396
  }
241
397
  onNavigateToResource?.({
242
398
  kind: kindToPlural(node.kind),
243
399
  namespace: ns,
244
400
  name: node.name,
245
- group: apiVersionToGroup(node.data?.apiVersion as string | undefined),
401
+ group: topologyGroup(node),
246
402
  })
247
403
  },
248
- [workloads, onNavigateToResource, setSelected],
404
+ [app.sourceRef, appGraph, workloads, onNavigateToResource, onOpenSource, onSelectWorkloadRun, setSelected],
249
405
  )
250
406
 
407
+ const appTopology = deploymentLayer?.topology ?? appGraph ?? null
408
+ const appTopologyAvailable = !!appTopology && appTopology.nodes.length > 0
409
+ const colorByWorkload = ownership?.colorByWorkload ?? null
410
+
251
411
  return (
252
- <div className="flex w-full flex-col">
253
- {/* Title row */}
254
- <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">
255
- <button type="button" onClick={onBack} className="flex items-center gap-1.5 text-xs text-theme-text-tertiary hover:text-theme-text-primary">
412
+ <div className="flex min-h-0 w-full flex-1 flex-col bg-theme-base">
413
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-theme-border px-4 py-3 sm:px-6">
414
+ <button
415
+ type="button"
416
+ onClick={onBack}
417
+ className="flex shrink-0 items-center gap-1.5 text-xs text-theme-text-tertiary hover:text-theme-text-primary"
418
+ >
256
419
  <ArrowLeft className="h-3.5 w-3.5" aria-hidden /> Applications
257
420
  </button>
258
- <span className="h-6 w-px bg-theme-border" aria-hidden />
259
- <span className={`flex h-8 w-8 items-center justify-center rounded-md ring-1 ring-inset ${verdictTone}`}>
421
+ <span
422
+ className="hidden h-6 w-px bg-theme-border sm:block"
423
+ aria-hidden
424
+ />
425
+ <span
426
+ className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-md ring-1 ring-inset ${verdictTone}`}
427
+ >
260
428
  <Boxes className="h-4 w-4" aria-hidden />
261
429
  </span>
262
- <h1 className="text-2xl font-semibold text-theme-text-primary">{app.name}</h1>
263
- <ProvenanceBadge tier={app.tier} appKey={app.key} confidence={app.confidence} />
264
- <CategoryChip category={app.category} addonReason={app.addonReason} />
265
- <ClassBadge workloadClass={workloadClass} composition={classCompositionOf(app)} />
266
- <div className="ml-auto flex flex-wrap items-center justify-end gap-2">
267
- <span className={`inline-flex items-center gap-2 rounded-md px-2.5 py-1 ring-1 ring-inset ${verdictTone}`}>
430
+ <div className="flex min-w-0 flex-1 items-center gap-2">
431
+ <h1 className="min-w-0 truncate text-xl font-semibold text-theme-text-primary lg:text-2xl">
432
+ {app.name}
433
+ </h1>
434
+ {!singleWorkloadScope && (
435
+ <>
436
+ <span className="shrink-0 text-theme-text-tertiary">/</span>
437
+ <ApplicationScopeSelector
438
+ workloads={workloads}
439
+ selectedWorkload={selectedWorkload ?? null}
440
+ onSelect={setSelected}
441
+ onFocus={setFocusedOwnerId}
442
+ />
443
+ </>
444
+ )}
445
+ </div>
446
+ <div className="ml-auto flex shrink-0 flex-wrap items-center justify-end gap-2">
447
+ <span
448
+ className={`inline-flex items-center gap-2 rounded-md px-2.5 py-1 ring-1 ring-inset ${verdictTone}`}
449
+ >
268
450
  <StatusDot tone={mapHealthToTone(overall)} />
269
451
  <span className="text-sm font-semibold">{verdictLabel}</span>
270
452
  </span>
271
453
  {restartSignal && (
272
- <Tooltip content={`${restartSignal.workload} · ${pluralize(restartSignal.restarts, 'restart')}`} delay={150}>
273
- <span className={`inline-flex items-center rounded-md px-2 py-1 text-xs font-semibold ring-1 ring-inset ${CHIP_TONE.amber}`}>
274
- Pod warning: {restartSignal.reason || 'Restarts'} · {pluralize(restartSignal.restarts, 'restart')}
454
+ <Tooltip
455
+ content={`${restartSignal.workload} · ${pluralize(restartSignal.restarts, "restart")}`}
456
+ delay={150}
457
+ >
458
+ <span
459
+ className={`inline-flex items-center rounded-md px-2 py-1 text-xs font-semibold ring-1 ring-inset ${CHIP_TONE.amber}`}
460
+ >
461
+ Pod warning: {restartSignal.reason || "Restarts"} ·{" "}
462
+ {pluralize(restartSignal.restarts, "restart")}
275
463
  </span>
276
464
  </Tooltip>
277
465
  )}
278
466
  {/* Amber only on real skew (same image, different tags) — the context
279
467
  strip already covers the multi-image "N versions" case neutrally. */}
280
468
  {app.versionSkew && versions.length > 1 && (
281
- <Tooltip content={<VersionTooltip workloads={workloads} />} delay={150}>
282
- <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>
469
+ <Tooltip
470
+ content={<VersionTooltip workloads={workloads} />}
471
+ delay={150}
472
+ >
473
+ <span
474
+ className={`inline-flex items-center rounded-md px-2 py-1 font-mono text-xs ring-1 ring-inset ${CHIP_TONE.amber}`}
475
+ >
476
+ {versions.length} versions
477
+ </span>
283
478
  </Tooltip>
284
479
  )}
285
480
  </div>
@@ -287,18 +482,46 @@ export function ApplicationDetail({ app, onBack, renderWorkload, topology, topol
287
482
 
288
483
  {/* Context strip */}
289
484
  <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">
485
+ <ProvenanceBadge
486
+ tier={app.tier}
487
+ appKey={app.key}
488
+ confidence={app.confidence}
489
+ />
490
+ <CategoryChip category={app.category} addonReason={app.addonReason} />
491
+ <ClassBadge
492
+ workloadClass={workloadClass}
493
+ composition={classCompositionOf(app)}
494
+ />
495
+ <BatchSignalChip signal={batchSignal} />
496
+ {singleWorkloadScope && selectedWorkload.name !== app.name && (
497
+ <ContextFact label="Workload">
498
+ <span className="font-mono">
499
+ {selectedWorkload.kind}/{selectedWorkload.name}
500
+ </span>
501
+ </ContextFact>
502
+ )}
290
503
  {identityInstances && identityInstances.length > 1 ? (
291
504
  // The Environment fact IS the switcher when this app runs in several
292
505
  // envs — prominent, in existing header space, no extra row. Inline
293
506
  // pills for a handful; a picker beyond that (scales to ~any count).
294
507
  <div className="flex min-w-0 items-center gap-1.5">
295
- <span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary">Environment</span>
296
- <EnvSwitcher identityKey={app.identity?.key ?? ''} instances={identityInstances} activeKey={activeInstanceKey ?? app.key} onSwitch={onSwitchInstance} />
508
+ <span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary">
509
+ Environment
510
+ </span>
511
+ <EnvSwitcher
512
+ identityKey={app.identity?.key ?? ""}
513
+ instances={identityInstances}
514
+ activeKey={activeInstanceKey ?? app.key}
515
+ onSwitch={onSwitchInstance}
516
+ />
297
517
  </div>
298
518
  ) : env ? (
299
519
  <ContextFact label="Environment">
300
520
  {inferred ? (
301
- <Tooltip content={`Inferred from namespace "${namespace || env}" — confirm with an environment label.`} delay={150}>
521
+ <Tooltip
522
+ content={`Inferred from namespace "${namespace || env}" — confirm with an environment label.`}
523
+ delay={150}
524
+ >
302
525
  <span className="italic">~{env}</span>
303
526
  </Tooltip>
304
527
  ) : (
@@ -312,14 +535,18 @@ export function ApplicationDetail({ app, onBack, renderWorkload, topology, topol
312
535
  </ContextFact>
313
536
  ) : namespaces.length > 1 ? (
314
537
  <ContextFact label="Namespaces">
315
- <Tooltip content={namespaces.join(', ')} delay={150}>
538
+ <Tooltip content={namespaces.join(", ")} delay={150}>
316
539
  <span>{namespaces.length} namespaces</span>
317
540
  </Tooltip>
318
541
  </ContextFact>
319
542
  ) : null}
320
- <ContextFact label="Ready">
321
- <ReadyBar ready={ready} desired={desired} width="w-16" />
322
- </ContextFact>
543
+ {workloadClass === "job" ? (
544
+ <ContextFact label="Runtime">{batchRuntime.label}</ContextFact>
545
+ ) : desired > 0 ? (
546
+ <ContextFact label="Ready">
547
+ <ReadyBar ready={ready} desired={desired} width="w-16" />
548
+ </ContextFact>
549
+ ) : null}
323
550
  {(app.appVersion || versions.length > 0) && (
324
551
  <ContextFact label="Version">
325
552
  <VersionInfo app={app} variant="fact" />
@@ -327,352 +554,2192 @@ export function ApplicationDetail({ app, onBack, renderWorkload, topology, topol
327
554
  )}
328
555
  </div>
329
556
 
330
- {/* Body: for a multi-workload app, a workload rail (navigation + color
331
- legend + hover-focus) beside the app graph or the selected workload's
332
- runtime. A single-workload app skips straight to its runtime. */}
333
- {workloads.length > 1 ? (
334
- <div className="flex h-[calc(100vh-13rem)] min-h-[480px]">
335
- <WorkloadRail
557
+ <div className="min-h-0 flex-1 overflow-hidden bg-theme-base">
558
+ {selectedWorkload ? (
559
+ renderRuntime(selectedWorkload, renderWorkload)
560
+ ) : (
561
+ <ApplicationWorkspace
562
+ app={app}
563
+ activeView={activeView}
564
+ onViewChange={setView}
336
565
  workloads={workloads}
337
- colorByWorkload={ownership?.colorByWorkload ?? null}
338
- showAllEntry={appGraphAvailable}
339
- // Without an app graph the body always renders a workload (selected ??
340
- // workloads[0]); mirror that so the rail marks it active.
341
- selectedKey={appGraphAvailable ? selected : selected ?? (workloads[0] ? workloadKey(workloads[0]) : null)}
566
+ ready={ready}
567
+ desired={desired}
568
+ versions={versions}
569
+ topology={appTopology}
570
+ topologyLoading={topologyLoading}
571
+ deploymentLayer={deploymentLayer}
572
+ deploymentSource={app.sourceRef}
573
+ topologyAvailable={appTopologyAvailable}
574
+ focusNodeId={appGraphFocusId}
342
575
  focusedOwnerId={focusedOwnerId}
343
- onSelect={setSelected}
344
- onFocus={setFocusedOwnerId}
576
+ colorByWorkload={colorByWorkload}
577
+ onNodeClick={handleAppNodeClick}
578
+ onNodeHover={handleNodeHover}
579
+ onFocusWorkload={setFocusedOwnerId}
580
+ onSelectWorkload={(workload) => setSelected(workloadKey(workload))}
581
+ onNavigateToResource={onNavigateToResource}
582
+ history={history}
583
+ historyLoading={historyLoading}
584
+ onOpenSource={onOpenSource}
585
+ onToggleReplicaSets={toggleReplicaSets}
586
+ renderOverviewIssues={renderOverviewIssues}
587
+ hasOverviewIssues={hasOverviewIssues}
345
588
  />
346
- <div className="relative min-h-0 flex-1 overflow-hidden bg-theme-surface">
347
- {appGraphAvailable && selected === null ? (
348
- <TopologyGraph
349
- topology={appGraph}
350
- viewMode="resources"
351
- groupingMode="namespace"
352
- hideGroupHeader
353
- onNodeClick={handleAppNodeClick}
354
- showExportButton={false}
355
- focusNodeId={appGraphFocusId}
356
- focusedOwnerId={focusedOwnerId}
357
- onNodeHover={handleNodeHover}
358
- />
359
- ) : selected === null && topologyLoading ? (
360
- // The graph is this view's landing — hold a loader while the
361
- // topology fetch is in flight instead of flashing the first
362
- // workload's runtime (which would fire its data fetches too).
363
- <PaneLoader label="Loading topology…" className="absolute inset-0" />
364
- ) : (
365
- <div key={workloadKey(selectedWorkload ?? workloads[0])} className="h-full min-h-0">
366
- {renderWorkload(selectedWorkload ?? workloads[0])}
367
- </div>
368
- )}
369
- </div>
370
- </div>
371
- ) : (
372
- renderRuntime(workloads[0], renderWorkload)
373
- )}
589
+ )}
590
+ </div>
374
591
  </div>
375
- )
592
+ );
376
593
  }
377
594
 
378
- // WorkloadRail — the app's workloads as a left rail: color legend (each row's
379
- // swatch matches that workload's node tint in the graph), navigation (click →
380
- // that workload's runtime in place), and hover-focus (hover → that workload's
381
- // nodes stay lit, the rest dim). Reciprocal: hovering a node lights its row.
382
- function WorkloadRail({
595
+ function ApplicationWorkspace({
596
+ app,
597
+ activeView,
598
+ onViewChange,
383
599
  workloads,
384
- colorByWorkload,
385
- showAllEntry,
386
- selectedKey,
600
+ ready,
601
+ desired,
602
+ versions,
603
+ topology,
604
+ topologyLoading,
605
+ deploymentLayer,
606
+ deploymentSource,
607
+ topologyAvailable,
608
+ focusNodeId,
387
609
  focusedOwnerId,
388
- onSelect,
389
- onFocus,
610
+ colorByWorkload,
611
+ onNodeClick,
612
+ onNodeHover,
613
+ onFocusWorkload,
614
+ onSelectWorkload,
615
+ onNavigateToResource,
616
+ history,
617
+ historyLoading,
618
+ onOpenSource,
619
+ onToggleReplicaSets,
620
+ renderOverviewIssues,
621
+ hasOverviewIssues,
390
622
  }: {
623
+ app: AppRow
624
+ activeView: CanonicalApplicationView
625
+ onViewChange: (view: CanonicalApplicationView) => void
391
626
  workloads: AppWorkload[]
392
- colorByWorkload: Map<string, number> | null
393
- showAllEntry: boolean
394
- selectedKey: string | null
627
+ ready: number
628
+ desired: number
629
+ versions: string[]
630
+ topology: Topology | null
631
+ topologyLoading?: boolean
632
+ deploymentLayer: DeploymentTopologyLayer | null
633
+ deploymentSource?: AppSourceRef
634
+ topologyAvailable: boolean
635
+ focusNodeId?: string
395
636
  focusedOwnerId: WorkloadFocus
396
- onSelect: (key: string | null) => void
397
- onFocus: (owner: WorkloadFocus) => void
637
+ colorByWorkload: Map<string, number> | null
638
+ onNodeClick: (node: TopologyNode) => void
639
+ onNodeHover: (node: TopologyNode | null) => void
640
+ onFocusWorkload: (owner: WorkloadFocus) => void
641
+ onSelectWorkload: (workload: AppWorkload) => void
642
+ onNavigateToResource?: (resource: { kind: string; namespace: string; name: string; group?: string }) => void
643
+ history?: AppHistory
644
+ historyLoading?: boolean
645
+ onOpenSource?: (source: AppSourceRef) => void
646
+ onToggleReplicaSets: (ownerID: string) => void
647
+ renderOverviewIssues?: () => ReactNode
648
+ hasOverviewIssues?: boolean
398
649
  }) {
650
+ const historyCount =
651
+ (history?.anchors?.length ?? 0) +
652
+ (history?.incidents?.length ?? app.events?.length ?? 0);
399
653
  return (
400
- <div
401
- 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"
402
- onMouseLeave={() => onFocus(null)}
403
- >
404
- <div className="flex items-center justify-between px-1.5 pb-1 pt-0.5">
405
- <span className="text-[10px] font-medium uppercase tracking-wide text-theme-text-tertiary">Workloads</span>
406
- <span className="rounded-full bg-theme-hover px-1.5 text-[10px] font-medium text-theme-text-tertiary">{workloads.length}</span>
407
- </div>
408
-
409
- {showAllEntry && (
410
- <RailRow
411
- active={selectedKey === null}
412
- onClick={() => onSelect(null)}
413
- onMouseEnter={() => onFocus(null)}
414
- swatch={<Network className="h-3.5 w-3.5 text-theme-text-secondary" aria-hidden />}
415
- title="All workloads"
654
+ <div className="flex h-full min-h-0 flex-col">
655
+ <ApplicationViewTabs
656
+ activeView={activeView}
657
+ historyCount={historyCount}
658
+ onChange={onViewChange}
659
+ />
660
+ {activeView === "overview" && (
661
+ <ApplicationOverview
662
+ app={app}
663
+ workloads={workloads}
664
+ ready={ready}
665
+ desired={desired}
666
+ versions={versions}
667
+ onSelectWorkload={onSelectWorkload}
668
+ onNavigateToResource={onNavigateToResource}
669
+ history={history}
670
+ onSelectHistory={() => onViewChange("history")}
671
+ onOpenSource={onOpenSource}
672
+ renderOverviewIssues={renderOverviewIssues}
673
+ hasOverviewIssues={hasOverviewIssues}
416
674
  />
417
675
  )}
418
-
419
- {workloads.map((w) => {
420
- const key = workloadKey(w)
421
- const idx = colorByWorkload?.get(key)
422
- const hue = idx != null ? workloadHue(idx) : null
423
- return (
424
- <RailRow
425
- key={key}
426
- active={selectedKey === key}
427
- focused={focusedOwnerId === key}
428
- onClick={() => onSelect(key)}
429
- onMouseEnter={() => onFocus(key)}
430
- swatch={
431
- <span
432
- className="h-3 w-3 shrink-0 rounded-[3px] bg-theme-border ring-1 ring-inset ring-theme-border"
433
- style={hue ? { background: hue.swatch } : undefined}
434
- />
435
- }
436
- title={w.name}
437
- tooltip={`${w.name} · ${w.kind}`}
438
- subtitle={w.kind}
439
- trailing={<StatusDot tone={mapHealthToTone(healthOf(w.health))} />}
440
- />
441
- )
442
- })}
443
-
444
- {colorByWorkload && (
445
- <RailRow
446
- muted
447
- focused={focusedOwnerId === NEUTRAL_OWNER}
448
- onMouseEnter={() => onFocus(NEUTRAL_OWNER)}
449
- swatch={<span className="h-3 w-3 shrink-0 rounded-[3px] bg-theme-border" />}
450
- title="Shared / unscoped"
676
+ {activeView === "topology" && (
677
+ <ApplicationTopology
678
+ topology={topology}
679
+ loading={topologyLoading}
680
+ available={topologyAvailable}
681
+ workloads={workloads}
682
+ colorByWorkload={colorByWorkload}
683
+ focusNodeId={focusNodeId}
684
+ focusedOwnerId={focusedOwnerId}
685
+ onNodeClick={onNodeClick}
686
+ onNodeHover={onNodeHover}
687
+ onFocusWorkload={onFocusWorkload}
688
+ onSelectWorkload={onSelectWorkload}
689
+ deploymentLayer={deploymentLayer}
690
+ deploymentSource={deploymentSource}
691
+ onOpenSource={onOpenSource}
692
+ onToggleReplicaSets={onToggleReplicaSets}
693
+ />
694
+ )}
695
+ {activeView === "history" && (
696
+ <ApplicationHistoryView
697
+ history={history}
698
+ loading={historyLoading}
699
+ fallbackEvents={app.events ?? []}
700
+ workloads={workloads}
701
+ onSelectWorkload={onSelectWorkload}
702
+ onOpenSource={onOpenSource}
451
703
  />
452
704
  )}
453
705
  </div>
454
- )
706
+ );
455
707
  }
456
708
 
457
- function RailRow({
458
- active,
459
- focused,
460
- muted,
461
- onClick,
462
- onMouseEnter,
463
- swatch,
464
- title,
465
- tooltip,
466
- subtitle,
467
- trailing,
709
+ function ApplicationViewTabs({
710
+ activeView,
711
+ historyCount,
712
+ onChange,
468
713
  }: {
469
- active?: boolean
470
- focused?: boolean
471
- muted?: boolean
472
- onClick?: () => void
473
- onMouseEnter?: () => void
474
- swatch: ReactNode
475
- title: string
476
- /** Full label shown on hover — the row title truncates. */
477
- tooltip?: string
478
- subtitle?: string
479
- trailing?: ReactNode
714
+ activeView: CanonicalApplicationView;
715
+ historyCount: number;
716
+ onChange: (view: CanonicalApplicationView) => void;
480
717
  }) {
481
- const className = clsx(
482
- 'flex w-full items-center gap-2 rounded-md px-1.5 py-1.5 text-left transition-colors',
483
- active
484
- ? 'selection selection-ring'
485
- : focused
486
- ? 'bg-theme-hover'
487
- : onClick && 'hover:bg-theme-hover',
488
- !onClick && 'cursor-default',
489
- )
490
- const titleEl = <span className={clsx('block truncate text-xs', muted ? 'text-theme-text-tertiary' : 'font-medium text-theme-text-primary')}>{title}</span>
491
- const inner = (
492
- <>
493
- {swatch}
494
- <span className="min-w-0 flex-1">
495
- {tooltip ? <Tooltip content={tooltip} delay={300} position="right">{titleEl}</Tooltip> : titleEl}
496
- {subtitle && <span className="block truncate text-[10px] uppercase tracking-wide text-theme-text-tertiary">{subtitle}</span>}
497
- </span>
498
- {trailing}
499
- </>
500
- )
501
- return onClick ? (
502
- <button type="button" onClick={onClick} onMouseEnter={onMouseEnter} className={className}>{inner}</button>
503
- ) : (
504
- <div onMouseEnter={onMouseEnter} className={className}>{inner}</div>
505
- )
718
+ return (
719
+ <div
720
+ className="flex shrink-0 items-center border-b border-theme-border px-4 sm:px-6"
721
+ role="tablist"
722
+ aria-label="Application views"
723
+ >
724
+ <div className="flex min-w-0 gap-1 overflow-x-auto">
725
+ {APPLICATION_VIEWS.map((view) => {
726
+ const active = view.id === activeView;
727
+ const badge =
728
+ view.id === "history" && historyCount > 0 ? historyCount : null;
729
+ return (
730
+ <button
731
+ key={view.id}
732
+ type="button"
733
+ role="tab"
734
+ aria-selected={active}
735
+ onClick={() => onChange(view.id)}
736
+ className={clsx(
737
+ "flex items-center gap-1.5 whitespace-nowrap border-b-2 px-3 py-2 text-sm font-medium transition-colors",
738
+ active
739
+ ? "border-skyhook-500 text-theme-text-primary"
740
+ : "border-transparent text-theme-text-secondary hover:border-theme-border-light hover:text-theme-text-primary",
741
+ )}
742
+ >
743
+ {view.label}
744
+ {badge !== null && (
745
+ <span className="rounded bg-theme-hover px-1.5 py-0.5 text-[10px] font-semibold text-theme-text-tertiary">
746
+ {badge}
747
+ </span>
748
+ )}
749
+ </button>
750
+ );
751
+ })}
752
+ </div>
753
+ </div>
754
+ );
506
755
  }
507
756
 
508
- // renderRuntime shows the single-workload app's runtime (host-injected
509
- // WorkloadView). Multi-workload apps render runtimes beside the rail instead,
510
- // which also owns navigation back to the app graph.
511
- function renderRuntime(
512
- workload: SelectedAppWorkload | undefined,
513
- renderWorkload: (workload: SelectedAppWorkload) => ReactNode,
514
- ): ReactNode {
515
- if (!workload) {
516
- return (
517
- <div className="rounded-md border border-dashed border-theme-border p-8 text-center text-sm text-theme-text-tertiary">
518
- This application has no inspectable workloads.
519
- </div>
520
- )
521
- }
757
+ type AppIssueSeverity = "error" | "warning" | "info";
758
+
759
+ type AppIssue = {
760
+ key: string;
761
+ severity: AppIssueSeverity;
762
+ title: string;
763
+ detail?: string;
764
+ workload?: AppWorkload;
765
+ };
766
+
767
+ function ApplicationOverview({
768
+ app,
769
+ workloads,
770
+ ready,
771
+ desired,
772
+ versions,
773
+ onSelectWorkload,
774
+ onNavigateToResource,
775
+ history,
776
+ onSelectHistory,
777
+ onOpenSource,
778
+ renderOverviewIssues,
779
+ hasOverviewIssues,
780
+ }: {
781
+ app: AppRow
782
+ workloads: AppWorkload[]
783
+ ready: number
784
+ desired: number
785
+ versions: string[]
786
+ onSelectWorkload: (workload: AppWorkload) => void
787
+ onNavigateToResource?: (resource: { kind: string; namespace: string; name: string; group?: string }) => void
788
+ history?: AppHistory
789
+ onSelectHistory: () => void
790
+ onOpenSource?: (source: AppSourceRef) => void
791
+ renderOverviewIssues?: () => ReactNode
792
+ hasOverviewIssues?: boolean
793
+ }) {
794
+ const rel = app.relationships
795
+ const hasEntrypoints = Boolean(rel && (relationshipRefs(rel, 'service').length > 0 || relationshipRefs(rel, 'ingress').length > 0 || relationshipRefs(rel, 'route').length > 0))
796
+ const hasDependencies = Boolean(rel && (
797
+ relationshipRefs(rel, 'config').length > 0 ||
798
+ relationshipRefs(rel, 'scaler').length > 0 ||
799
+ relationshipRefs(rel, 'storage').length > 0 ||
800
+ relationshipRefs(rel, 'pdb').length > 0 ||
801
+ relationshipRefs(rel, 'networkPolicy').length > 0
802
+ ))
803
+ const composition = classCompositionOf(app)
804
+ .map(({ cls, count }) => `${count} ${cls}`)
805
+ .join(' / ')
806
+ const issues = useMemo(() => buildAppIssues(workloads, app.events ?? []), [workloads, app.events])
807
+ const currentIssuesPresent = renderOverviewIssues
808
+ ? hasOverviewIssues ?? issues.length > 0
809
+ : issues.length > 0
810
+ const batchActivity = useMemo(() => batchActivityForApp(app), [app])
811
+ const batchStats = batchOverviewStats(batchActivity)
812
+ const pureBatch = workloadClassOf(app.workload_class) === 'job'
813
+ const workloadComposition = workloadKindComposition(workloads)
814
+ const latestChange = history?.summary?.state === 'change' ? history.summary : undefined
815
+ const runtimeHealth = healthOf(app.runtimeHealth ?? worstHealth(workloads.map((workload) => workload.health)))
816
+ const hasDeliveryStatus = Boolean(app.sourceStatus?.sync || app.sourceStatus?.health)
817
+ const extraFactCount = Number(Boolean(latestChange)) + Number(hasDeliveryStatus)
818
+ const factGrid = pureBatch
819
+ ? extraFactCount === 2
820
+ ? '2xl:grid-cols-6'
821
+ : extraFactCount === 1
822
+ ? '2xl:grid-cols-5'
823
+ : '2xl:grid-cols-4'
824
+ : extraFactCount === 2
825
+ ? '2xl:grid-cols-5'
826
+ : extraFactCount === 1
827
+ ? '2xl:grid-cols-4'
828
+ : '2xl:grid-cols-3'
829
+
522
830
  return (
523
- // Concrete height (not flex-1 of an unsized column): the workload's
524
- // Topology tab positions ReactFlow absolutely and collapses to zero inside
525
- // a content-sized ancestor. Mirrors the multi-workload rail row's height.
526
- <div className="flex h-[calc(100vh-13rem)] min-h-[480px] flex-col">
527
- <div key={workloadKey(workload)} className="min-h-0 flex-1">{renderWorkload(workload)}</div>
831
+ <div className="min-h-0 flex-1 overflow-auto">
832
+ <div className="grid w-full max-w-[2400px] gap-4 p-4 sm:p-6 xl:grid-cols-[minmax(0,1fr)_minmax(320px,380px)]">
833
+ <div className="min-w-0 space-y-4">
834
+ {renderOverviewIssues ? renderOverviewIssues() : <ApplicationNow issues={issues} onSelectWorkload={onSelectWorkload} />}
835
+ <ApplicationLatestHistory
836
+ history={history}
837
+ sourceRef={app.sourceRef}
838
+ showIncidentPreview={!currentIssuesPresent}
839
+ onSelectHistory={onSelectHistory}
840
+ onOpenSource={onOpenSource}
841
+ />
842
+ <div className={clsx('grid gap-3 md:grid-cols-2', factGrid)}>
843
+ <ApplicationFact
844
+ label="Runtime"
845
+ value={pureBatch ? batchRuntimeForApp(app).label : HEALTH_META[runtimeHealth].label}
846
+ detail={pureBatch ? batchStats.activeDetail : desired > 0 ? `${ready}/${desired} ready` : 'No desired replicas'}
847
+ />
848
+ {hasDeliveryStatus && (
849
+ <ApplicationFact
850
+ label="Delivery"
851
+ value={<SourceDeliveryStatus status={app.sourceStatus!} />}
852
+ detail={app.sourceRef ? sourceObjectLabel(app.sourceRef) : undefined}
853
+ />
854
+ )}
855
+ {pureBatch ? (
856
+ <>
857
+ <ApplicationFact label="Batch resources" value={String(workloads.length)} detail={workloadComposition || 'No workloads'} />
858
+ <ApplicationFact label="Active runs" value={batchStats.activeValue} detail={batchStats.activeDetail} />
859
+ <ApplicationFact label="Retained runs" value={batchStats.retainedValue} detail="Kubernetes-retained history" />
860
+ </>
861
+ ) : (
862
+ <>
863
+ <ApplicationFact label="Workloads" value={String(workloads.length)} detail={composition || 'No workloads'} />
864
+ <ApplicationFact label="Version" value={app.appVersion || (versions.length === 1 ? versions[0] : versions.length > 1 ? `${versions.length} versions` : 'Unknown')} />
865
+ </>
866
+ )}
867
+ {latestChange && (
868
+ <ApplicationLatestChangeFact
869
+ summary={latestChange}
870
+ sourceRef={app.sourceRef ? undefined : history?.sourceRef}
871
+ onSelectHistory={onSelectHistory}
872
+ onOpenSource={onOpenSource}
873
+ />
874
+ )}
875
+ </div>
876
+ {hasEntrypoints && (
877
+ <ApplicationEntrypoints
878
+ relationships={rel}
879
+ onNavigateToResource={onNavigateToResource}
880
+ />
881
+ )}
882
+ <ApplicationPanel title="Workloads">
883
+ <WorkloadsMatrix
884
+ workloads={workloads}
885
+ onSelectWorkload={onSelectWorkload}
886
+ />
887
+ </ApplicationPanel>
888
+ {!pureBatch && <ApplicationBatchOverview activity={batchActivity} />}
889
+ </div>
890
+ <aside className={clsx('min-w-0 gap-4 xl:block xl:space-y-4', hasDependencies ? 'grid md:grid-cols-2' : 'max-w-xl xl:max-w-none')}>
891
+ <ApplicationSourceProvenance app={app} onOpenSource={onOpenSource} />
892
+ {hasDependencies && <ApplicationDependencies relationships={rel} onNavigateToResource={onNavigateToResource} />}
893
+ </aside>
894
+ </div>
528
895
  </div>
529
- )
896
+ );
530
897
  }
531
898
 
532
- function restartWarning(workloads: AppWorkload[]): { restarts: number; reason?: string; workload: string } | null {
533
- let worst: { restarts: number; reason?: string; workload: string } | null = null
534
- for (const w of workloads) {
535
- const r = w.restarts ?? 0
536
- if (r > 0 && (!worst || r > worst.restarts)) {
537
- worst = { restarts: r, reason: w.reason, workload: `${w.kind}/${w.name}` }
538
- }
899
+ function workloadKindComposition(workloads: AppWorkload[]): string {
900
+ const counts = new Map<string, number>();
901
+ for (const workload of workloads) {
902
+ if (!workload.kind) continue;
903
+ counts.set(workload.kind, (counts.get(workload.kind) ?? 0) + 1);
539
904
  }
540
- return worst
905
+ return [...counts.entries()]
906
+ .sort(
907
+ ([aKind, aCount], [bKind, bCount]) =>
908
+ bCount - aCount || aKind.localeCompare(bKind),
909
+ )
910
+ .map(([kind, count]) => pluralize(count, kind))
911
+ .join(" / ");
541
912
  }
542
913
 
543
- // EnvSwitcher — the Environment fact's interactive form when one app runs in
544
- // several environments. Up to MAX_INLINE_ENVS: inline pills (the at-a-glance
545
- // ladder). More: a picker popover with the full ladder-ordered list — same
546
- // affordance at 5 envs or 100. Always ends with the evidence chip
547
- // (AppIdentityTooltip) and, when a ranked lower env outruns a ranked higher one,
548
- // the amber lag chip.
549
- const MAX_INLINE_ENVS = 4
550
-
551
- function EnvSwitcher({
552
- identityKey,
553
- instances,
554
- activeKey,
555
- onSwitch,
914
+ function ApplicationSourceProvenance({
915
+ app,
916
+ onOpenSource,
556
917
  }: {
557
- identityKey: string
558
- instances: AppIdentityInstance[]
559
- activeKey: string
560
- onSwitch?: (appKey: string) => void
918
+ app: AppRow
919
+ onOpenSource?: (source: AppSourceRef) => void
561
920
  }) {
562
- const [open, setOpen] = useState(false)
563
- const rootRef = useRef<HTMLDivElement>(null)
564
- useEffect(() => {
565
- if (!open) return
566
- const onDown = (e: MouseEvent) => {
567
- if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false)
568
- }
569
- const onKey = (e: KeyboardEvent) => {
570
- if (e.key === 'Escape') setOpen(false)
571
- }
572
- document.addEventListener('mousedown', onDown)
573
- document.addEventListener('keydown', onKey)
574
- return () => {
575
- document.removeEventListener('mousedown', onDown)
576
- document.removeEventListener('keydown', onKey)
577
- }
578
- }, [open])
579
-
580
- const envInstances = useMemo(() => collapseIdentityInstances(instances, activeKey), [instances, activeKey])
581
- const lag = appGroupLagMessage(envInstances)
582
- const active = envInstances.find((i) => i.appKey === activeKey) ?? instances.find((i) => i.appKey === activeKey)
583
- const envCounts = useMemo(() => {
584
- const counts = new Map<string, number>()
585
- for (const inst of instances) counts.set(inst.env, (counts.get(inst.env) ?? 0) + 1)
586
- return counts
587
- }, [instances])
588
- const evidenceChip = (
589
- <Tooltip
590
- content={<AppIdentityTooltip identityKey={identityKey} members={instances.map((i) => ({ name: i.name, env: i.env, confidence: i.confidence, evidence: i.evidence }))} />}
591
- delay={150}
592
- >
593
- <span className="inline-flex cursor-default items-center rounded-sm bg-theme-hover px-1 py-px ring-1 ring-inset ring-theme-border">
594
- <Layers className="h-3 w-3 text-theme-text-tertiary" aria-hidden />
595
- </span>
596
- </Tooltip>
597
- )
598
- const lagChip = lag && <span className={`${CHIP} ${CHIP_TONE.amber}`}>{lag}</span>
599
-
600
- if (envInstances.length <= MAX_INLINE_ENVS) {
921
+ if (app.sourceRef) {
922
+ const sourceName = `${app.sourceRef.namespace}/${app.sourceRef.name}`
601
923
  return (
602
- <span className="flex flex-wrap items-center gap-1">
603
- {instances.map((inst) => {
604
- const isActive = inst.appKey === activeKey
605
- const duplicateEnv = (envCounts.get(inst.env) ?? 0) > 1
606
- return (
607
- <Tooltip key={inst.appKey} content={`${inst.name}${inst.version ? ` · ${inst.version}` : ''}`} delay={150}>
608
- <button
609
- type="button"
610
- disabled={isActive}
611
- onClick={() => !isActive && onSwitch?.(inst.appKey)}
612
- className={clsx(
613
- 'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs ring-1 ring-inset transition-colors',
614
- isActive ? 'selection selection-ring font-medium' : 'bg-theme-surface ring-theme-border hover:bg-theme-hover',
615
- )}
616
- >
617
- <StatusDot tone={mapHealthToTone(inst.health)} />
618
- {inst.env}
619
- {duplicateEnv ? <span className="max-w-24 truncate text-theme-text-tertiary">{inst.name}</span> : null}
620
- </button>
924
+ <ApplicationPanel title="Deployment source">
925
+ <div className="space-y-3">
926
+ <div>
927
+ <div className="text-xs font-semibold uppercase text-theme-text-tertiary">{sourceObjectLabel(app.sourceRef)}</div>
928
+ <Tooltip content={sourceName} delay={150}>
929
+ <div className="mt-1 truncate font-mono text-sm font-medium text-theme-text-primary">{sourceName}</div>
621
930
  </Tooltip>
622
- )
623
- })}
624
- {evidenceChip}
625
- {lagChip}
626
- </span>
931
+ </div>
932
+ {app.sourceStatus && <SourceDeliveryStatus status={app.sourceStatus} />}
933
+ {onOpenSource && (
934
+ <button
935
+ type="button"
936
+ onClick={() => onOpenSource(app.sourceRef!)}
937
+ className="inline-flex w-fit items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm font-medium text-accent-text hover:bg-theme-hover"
938
+ >
939
+ <ExternalLink className="h-3.5 w-3.5" aria-hidden />
940
+ {sourceLinkLabel(app.sourceRef)}
941
+ </button>
942
+ )}
943
+ </div>
944
+ </ApplicationPanel>
627
945
  )
628
946
  }
629
947
 
948
+ if (app.sourceConflict) {
949
+ return (
950
+ <ApplicationPanel title="Deployment source">
951
+ <div className="space-y-2">
952
+ <div className="flex items-center gap-2">
953
+ <AlertTriangle className="h-4 w-4 shrink-0 text-amber-500" aria-hidden />
954
+ <span className="text-sm font-semibold text-theme-text-primary">Multiple deployment sources detected</span>
955
+ </div>
956
+ <p className="text-sm text-theme-text-secondary">Workloads in this application do not share one deployment manager.</p>
957
+ </div>
958
+ </ApplicationPanel>
959
+ )
960
+ }
961
+
962
+ const inferred = app.identity?.confidence === 'medium'
963
+ const identityLabel = app.identity?.source ? appSourceLabel(app.identity.source) : undefined
964
+ const tierLabel = !app.identity && app.tier ? overlayProvenance(app.tier) : undefined
630
965
  return (
631
- <div ref={rootRef} className="relative flex items-center gap-1">
632
- <button
633
- type="button"
634
- onClick={() => setOpen((o) => !o)}
635
- aria-haspopup="listbox"
636
- aria-expanded={open}
637
- 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"
638
- >
639
- {active && <StatusDot tone={mapHealthToTone(active.health)} />}
640
- <span className="font-medium">{active?.env ?? '—'}</span>
641
- <span className="text-theme-text-tertiary">· {envInstances.length} environments</span>
642
- <ChevronDown className={clsx('h-3 w-3 text-theme-text-tertiary transition-transform', open && 'rotate-180')} aria-hidden />
643
- </button>
644
- {evidenceChip}
645
- {lagChip}
646
- {open && (
647
- <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">
648
- {instances.map((inst) => {
649
- const isActive = inst.appKey === activeKey
650
- return (
651
- <button
652
- key={inst.appKey}
653
- type="button"
654
- role="option"
655
- aria-selected={isActive}
656
- onClick={() => {
657
- setOpen(false)
658
- if (!isActive) onSwitch?.(inst.appKey)
659
- }}
660
- className={clsx(
661
- 'flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs',
662
- isActive ? 'selection selection-ring' : 'hover:bg-theme-hover',
663
- )}
664
- >
665
- <StatusDot tone={mapHealthToTone(inst.health)} />
666
- <span className="w-20 shrink-0 font-medium text-theme-text-primary">{inst.env}</span>
667
- <span className="min-w-0 flex-1 truncate text-theme-text-secondary">
668
- {inst.name}
669
- </span>
670
- {inst.version && <span className="font-mono text-[10px] text-theme-text-tertiary">{midTruncate(inst.version, 18)}</span>}
671
- </button>
672
- )
673
- })}
966
+ <ApplicationPanel title="Application identity">
967
+ <div className="space-y-2">
968
+ <div className="flex items-center gap-2">
969
+ {inferred && <AlertTriangle className="h-4 w-4 shrink-0 text-amber-500" aria-hidden />}
970
+ <span className="text-sm font-semibold text-theme-text-primary">
971
+ {identityLabel
972
+ ? inferred
973
+ ? 'Inferred application boundary'
974
+ : `Identified by ${identityLabel}`
975
+ : tierLabel
976
+ ? `Grouped by ${tierLabel} metadata`
977
+ : 'Grouped from Kubernetes ownership'}
978
+ </span>
674
979
  </div>
675
- )}
676
- </div>
677
- )
980
+ {inferred && identityLabel && <p className="text-sm text-theme-text-secondary">Matched using {identityLabel}.</p>}
981
+ {app.identity?.evidence && (
982
+ <Tooltip content={app.identity.evidence} delay={150}>
983
+ <div className="truncate font-mono text-xs text-theme-text-tertiary">{app.identity.evidence}</div>
984
+ </Tooltip>
985
+ )}
986
+ </div>
987
+ </ApplicationPanel>
988
+ );
989
+ }
990
+
991
+ function ApplicationEntrypoints({
992
+ relationships,
993
+ onNavigateToResource,
994
+ }: {
995
+ relationships: AppRow['relationships']
996
+ onNavigateToResource?: (resource: { kind: string; namespace: string; name: string; group?: string }) => void
997
+ }) {
998
+ const serviceRefs = relationshipRefs(relationships, 'service')
999
+ const ingressRefs = relationshipRefs(relationships, 'ingress')
1000
+ const routeRefs = relationshipRefs(relationships, 'route')
1001
+ const serviceCount = serviceRefs.length
1002
+ const ingressCount = ingressRefs.length
1003
+ const routeCount = routeRefs.length
1004
+ const hasExternal = ingressCount + routeCount > 0
1005
+ if (serviceCount + ingressCount + routeCount === 0) return null
1006
+
1007
+ return (
1008
+ <ApplicationPanel title="Entrypoints">
1009
+ <div className="space-y-3">
1010
+ <div>
1011
+ <div className="flex flex-wrap items-center gap-2">
1012
+ <h3 className="text-sm font-semibold text-theme-text-primary">
1013
+ {hasExternal
1014
+ ? "External entrypoints configured"
1015
+ : "Internal services configured"}
1016
+ </h3>
1017
+ <span
1018
+ className={`${CHIP} ${hasExternal ? CHIP_TONE.blue : CHIP_TONE.muted}`}
1019
+ >
1020
+ {hasExternal ? "External" : "Internal"}
1021
+ </span>
1022
+ </div>
1023
+ <p className="mt-1 text-sm text-theme-text-secondary">
1024
+ {hasExternal
1025
+ ? "Traffic can enter this application through ingress or route resources."
1026
+ : "Services target this application, but no ingress or route was detected."}
1027
+ </p>
1028
+ </div>
1029
+ <div className="grid gap-3 sm:grid-cols-3">
1030
+ <ApplicationFact
1031
+ variant="bare"
1032
+ label="Services"
1033
+ value={String(serviceCount)}
1034
+ />
1035
+ <ApplicationFact
1036
+ variant="bare"
1037
+ label="Ingresses"
1038
+ value={String(ingressCount)}
1039
+ />
1040
+ <ApplicationFact
1041
+ variant="bare"
1042
+ label="Routes"
1043
+ value={String(routeCount)}
1044
+ />
1045
+ </div>
1046
+ <div className="space-y-3 border-t border-theme-border pt-3">
1047
+ <ApplicationRelatedNameGroup
1048
+ label="Services"
1049
+ refs={serviceRefs}
1050
+ onNavigateToResource={onNavigateToResource}
1051
+ />
1052
+ <ApplicationRelatedNameGroup
1053
+ label="Ingresses"
1054
+ refs={ingressRefs}
1055
+ onNavigateToResource={onNavigateToResource}
1056
+ />
1057
+ <ApplicationRelatedNameGroup label="Routes" refs={routeRefs} onNavigateToResource={onNavigateToResource} />
1058
+ </div>
1059
+ </div>
1060
+ </ApplicationPanel>
1061
+ );
1062
+ }
1063
+
1064
+ function ApplicationDependencies({
1065
+ relationships,
1066
+ onNavigateToResource,
1067
+ }: {
1068
+ relationships: AppRow['relationships']
1069
+ onNavigateToResource?: (resource: { kind: string; namespace: string; name: string; group?: string }) => void
1070
+ }) {
1071
+ const configs = relationshipRefs(relationships, 'config')
1072
+ const scalers = relationshipRefs(relationships, 'scaler')
1073
+ const storage = relationshipRefs(relationships, 'storage')
1074
+ const pdbs = relationshipRefs(relationships, 'pdb')
1075
+ const networkPolicies = relationshipRefs(relationships, 'networkPolicy')
1076
+ if (configs.length + scalers.length + storage.length + pdbs.length + networkPolicies.length === 0) return null
1077
+
1078
+ return (
1079
+ <ApplicationPanel title="Dependencies">
1080
+ <div className="space-y-3">
1081
+ <ApplicationDependencyRow label="Configuration" refs={configs} totalCount={relationships?.configs} detail="ConfigMaps and Secrets referenced by app workloads." onNavigateToResource={onNavigateToResource} />
1082
+ <ApplicationDependencyRow label="Autoscaling" refs={scalers} totalCount={relationships?.scalers} detail="Autoscalers controlling app workloads." onNavigateToResource={onNavigateToResource} />
1083
+ <ApplicationDependencyRow label="Storage" refs={storage} totalCount={relationships?.storage} detail="PersistentVolumeClaims mounted by app workloads." onNavigateToResource={onNavigateToResource} />
1084
+ <ApplicationDependencyRow label="Availability policy" refs={pdbs} totalCount={relationships?.pdbs} detail="PodDisruptionBudgets protecting app workloads." onNavigateToResource={onNavigateToResource} />
1085
+ <ApplicationDependencyRow label="Network policy" refs={networkPolicies} totalCount={relationships?.networkPolicies} detail="Network policies selecting app workloads." onNavigateToResource={onNavigateToResource} />
1086
+ </div>
1087
+ </ApplicationPanel>
1088
+ );
1089
+ }
1090
+
1091
+ function ApplicationDependencyRow({
1092
+ label,
1093
+ refs,
1094
+ totalCount,
1095
+ detail,
1096
+ onNavigateToResource,
1097
+ }: {
1098
+ label: string
1099
+ refs: ResourceRef[]
1100
+ totalCount?: number
1101
+ detail: string
1102
+ onNavigateToResource?: (resource: { kind: string; namespace: string; name: string; group?: string }) => void
1103
+ }) {
1104
+ if (refs.length === 0) return null
1105
+ const count = Math.max(totalCount ?? refs.length, refs.length)
1106
+ return (
1107
+ <div className="min-w-0 border-b border-theme-border pb-3 last:border-b-0">
1108
+ <div className="flex items-baseline justify-between gap-3">
1109
+ <div className="text-sm font-semibold text-theme-text-primary">
1110
+ {label}
1111
+ </div>
1112
+ <span className={`${CHIP} ${CHIP_TONE.muted}`}>
1113
+ {pluralize(count, "resource")}
1114
+ </span>
1115
+ </div>
1116
+ <div className="mt-1 text-xs text-theme-text-tertiary">{detail}</div>
1117
+ <ApplicationRelatedNameGroup label={label} refs={refs} totalCount={count} onNavigateToResource={onNavigateToResource} hideLabel />
1118
+ </div>
1119
+ );
1120
+ }
1121
+
1122
+ function ApplicationRelatedNameGroup({
1123
+ label,
1124
+ refs,
1125
+ totalCount,
1126
+ onNavigateToResource,
1127
+ hideLabel,
1128
+ }: {
1129
+ label: string
1130
+ refs?: ResourceRef[]
1131
+ totalCount?: number
1132
+ onNavigateToResource?: (resource: { kind: string; namespace: string; name: string; group?: string }) => void
1133
+ hideLabel?: boolean
1134
+ }) {
1135
+ if (!refs || refs.length === 0) return null
1136
+
1137
+ const visible = refs.slice(0, 12)
1138
+ const count = Math.max(totalCount ?? refs.length, refs.length)
1139
+ const overflow = Math.max(0, count - visible.length)
1140
+ return (
1141
+ <div className={hideLabel ? 'mt-2' : undefined}>
1142
+ {!hideLabel && <div className="mb-1 text-xs font-medium text-theme-text-tertiary">{label}{count > 1 ? ` (${count})` : ''}</div>}
1143
+ <div className="flex flex-wrap gap-1.5">
1144
+ {visible.map((ref) => (
1145
+ <ResourceRefBadge
1146
+ key={`${ref.group || ''}/${ref.kind}/${ref.namespace}/${ref.name}`}
1147
+ resourceRef={ref}
1148
+ onClick={onNavigateToResource ? (clicked) => onNavigateToResource(refToSelectedResource(clicked)) : undefined}
1149
+ />
1150
+ ))}
1151
+ {overflow > 0 && <span className={`${CHIP} ${CHIP_TONE.muted}`}>+{overflow} more</span>}
1152
+ </div>
1153
+ </div>
1154
+ );
1155
+ }
1156
+
1157
+ type RelationshipGroup = 'service' | 'ingress' | 'route' | 'config' | 'scaler' | 'storage' | 'pdb' | 'networkPolicy'
1158
+
1159
+ function relationshipRefs(relationships: AppRow['relationships'] | undefined, group: RelationshipGroup): ResourceRef[] {
1160
+ if (!relationships) return []
1161
+ switch (group) {
1162
+ case 'service':
1163
+ return relationships.serviceRefs ?? []
1164
+ case 'ingress':
1165
+ return relationships.ingressRefs ?? []
1166
+ case 'route':
1167
+ return relationships.routeRefs ?? []
1168
+ case 'config':
1169
+ return relationships.configRefs ?? []
1170
+ case 'scaler':
1171
+ return relationships.scalerRefs ?? []
1172
+ case 'storage':
1173
+ return relationships.storageRefs ?? []
1174
+ case 'pdb':
1175
+ return relationships.pdbRefs ?? []
1176
+ case 'networkPolicy':
1177
+ return relationships.networkPolicyRefs ?? []
1178
+ }
1179
+ }
1180
+
1181
+ function ApplicationNow({
1182
+ issues,
1183
+ onSelectWorkload,
1184
+ }: {
1185
+ issues: AppIssue[]
1186
+ onSelectWorkload: (workload: AppWorkload) => void
1187
+ }) {
1188
+ if (issues.length === 0) return null
1189
+
1190
+ const top = issues[0];
1191
+ return (
1192
+ <section className="rounded-lg border border-theme-border bg-theme-surface shadow-theme-sm">
1193
+ <div className="flex flex-wrap items-start gap-3 border-b border-theme-border px-4 py-3">
1194
+ <span
1195
+ className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-md ring-1 ring-inset ${issueTone(top.severity)}`}
1196
+ >
1197
+ <AlertTriangle className="h-4 w-4" aria-hidden />
1198
+ </span>
1199
+ <div className="min-w-0 flex-1">
1200
+ <div className="flex flex-wrap items-center gap-2">
1201
+ <h2 className="text-sm font-semibold text-theme-text-primary">
1202
+ {top.title}
1203
+ </h2>
1204
+ <span className={`${CHIP} ${issueTone(top.severity)}`}>
1205
+ {top.severity === "error" ? "Needs attention" : "Warning"}
1206
+ </span>
1207
+ </div>
1208
+ {top.detail && (
1209
+ <p className="mt-0.5 text-sm text-theme-text-secondary">
1210
+ {top.detail}
1211
+ </p>
1212
+ )}
1213
+ </div>
1214
+ {top.workload && (
1215
+ <button
1216
+ type="button"
1217
+ onClick={() => onSelectWorkload(top.workload!)}
1218
+ className="rounded-md px-2.5 py-1.5 text-sm font-medium text-accent-text hover:bg-theme-hover"
1219
+ >
1220
+ Open workload
1221
+ </button>
1222
+ )}
1223
+ </div>
1224
+ {issues.length > 1 && (
1225
+ <div className="divide-y divide-theme-border px-4">
1226
+ {issues.slice(1, 5).map((issue) => (
1227
+ <div key={issue.key} className="flex items-start gap-3 py-3">
1228
+ <StatusDot
1229
+ tone={
1230
+ issue.severity === "error"
1231
+ ? "unhealthy"
1232
+ : issue.severity === "warning"
1233
+ ? "degraded"
1234
+ : "neutral"
1235
+ }
1236
+ className="mt-1"
1237
+ />
1238
+ <div className="min-w-0 flex-1">
1239
+ <div className="truncate text-sm font-medium text-theme-text-primary">
1240
+ {issue.title}
1241
+ </div>
1242
+ {issue.detail && (
1243
+ <div className="truncate text-sm text-theme-text-tertiary">
1244
+ {issue.detail}
1245
+ </div>
1246
+ )}
1247
+ </div>
1248
+ {issue.workload && (
1249
+ <button
1250
+ type="button"
1251
+ onClick={() => onSelectWorkload(issue.workload!)}
1252
+ className="shrink-0 text-sm font-medium text-accent-text hover:underline"
1253
+ >
1254
+ Open
1255
+ </button>
1256
+ )}
1257
+ </div>
1258
+ ))}
1259
+ </div>
1260
+ )}
1261
+ </section>
1262
+ );
1263
+ }
1264
+
1265
+ function ApplicationBatchOverview({
1266
+ activity,
1267
+ }: {
1268
+ activity: AppBatchActivity[];
1269
+ }) {
1270
+ if (activity.length === 0) return null;
1271
+
1272
+ const stats = batchOverviewStats(activity);
1273
+
1274
+ return (
1275
+ <ApplicationPanel title="Batch activity">
1276
+ <div className="grid gap-4">
1277
+ <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
1278
+ <ApplicationFact
1279
+ variant="bare"
1280
+ label="Latest run"
1281
+ value={stats.latestValue}
1282
+ detail={stats.latestDetail}
1283
+ />
1284
+ <ApplicationFact
1285
+ variant="bare"
1286
+ label="Retained runs"
1287
+ value={stats.retainedValue}
1288
+ detail="Kubernetes-retained history"
1289
+ />
1290
+ <ApplicationFact
1291
+ variant="bare"
1292
+ label="Active work"
1293
+ value={stats.activeValue}
1294
+ detail={stats.activeDetail}
1295
+ />
1296
+ <ApplicationFact
1297
+ variant="bare"
1298
+ label="Schedules"
1299
+ value={stats.scheduleValue}
1300
+ detail={stats.scheduleDetail}
1301
+ monoValue={stats.scheduleMono}
1302
+ />
1303
+ </div>
1304
+ </div>
1305
+ </ApplicationPanel>
1306
+ );
1307
+ }
1308
+
1309
+ function latestBatchRuns(activity: AppBatchActivity[]): AppBatchActivity[] {
1310
+ return [...activity].sort(
1311
+ (a, b) =>
1312
+ latestRunTime(b) - latestRunTime(a) ||
1313
+ a.workload.name.localeCompare(b.workload.name),
1314
+ );
1315
+ }
1316
+
1317
+ function latestRunTimestamp(item: AppBatchActivity): string | undefined {
1318
+ return (
1319
+ item.latestStartedAt ||
1320
+ item.latestFinishedAt ||
1321
+ item.lastScheduledAt ||
1322
+ item.lastSuccessfulAt
1323
+ );
1324
+ }
1325
+
1326
+ function latestRunTime(item: AppBatchActivity): number {
1327
+ const value = latestRunTimestamp(item);
1328
+ if (!value) return 0;
1329
+ const t = Date.parse(value);
1330
+ return Number.isNaN(t) ? 0 : t;
1331
+ }
1332
+
1333
+ function batchOverviewStats(activity: AppBatchActivity[]) {
1334
+ const latest = latestBatchRuns(activity)[0];
1335
+ const schedules = Array.from(
1336
+ new Set(
1337
+ activity
1338
+ .map((item) => item.schedule)
1339
+ .filter((s): s is string => Boolean(s)),
1340
+ ),
1341
+ );
1342
+ const retained = activity.reduce((n, item) => n + item.retainedRuns, 0);
1343
+ const succeeded = activity.reduce(
1344
+ (n, item) => n + (item.workload.batch?.succeededRuns ?? 0),
1345
+ 0,
1346
+ );
1347
+ const failed = activity.reduce((n, item) => n + item.failedRuns, 0);
1348
+ const active = activity.reduce((n, item) => n + item.activeRuns, 0);
1349
+
1350
+ return {
1351
+ latestValue: latest?.latestRunPhase || latest?.label || "None",
1352
+ latestDetail: latest
1353
+ ? `${latest.latestRunName || latest.workload.name}${latestRunTimestamp(latest) ? ` · ${relativeTime(latestRunTimestamp(latest)!)} ` : ""}`.trim()
1354
+ : "No retained runs",
1355
+ retainedValue:
1356
+ retained > 0 ? `${succeeded} succeeded / ${failed} failed` : "0 retained",
1357
+ activeValue: active > 0 ? `${active} running` : "None",
1358
+ activeDetail:
1359
+ active > 0 ? activeWorkloadNames(activity) : "No active batch work",
1360
+ scheduleValue:
1361
+ schedules.length === 0
1362
+ ? "None"
1363
+ : schedules.length === 1
1364
+ ? schedules[0]
1365
+ : `${schedules.length} schedules`,
1366
+ scheduleDetail:
1367
+ schedules.length === 1 && latest?.lastSuccessfulAt
1368
+ ? `last success ${relativeTime(latest.lastSuccessfulAt)}`
1369
+ : undefined,
1370
+ scheduleMono: schedules.length === 1,
1371
+ };
1372
+ }
1373
+
1374
+ function activeWorkloadNames(activity: AppBatchActivity[]): string {
1375
+ const active = activity
1376
+ .filter((item) => item.activeRuns > 0)
1377
+ .map((item) => item.workload.name);
1378
+ if (active.length <= 2) return active.join(", ");
1379
+ return `${active.slice(0, 2).join(", ")} +${active.length - 2} more`;
1380
+ }
1381
+
1382
+ function relativeTime(value: string): string {
1383
+ const t = Date.parse(value);
1384
+ if (Number.isNaN(t)) return value;
1385
+ const diff = Date.now() - t;
1386
+ if (diff < 60_000) return "just now";
1387
+ const minutes = Math.floor(diff / 60_000);
1388
+ if (minutes < 60) return `${minutes}m ago`;
1389
+ const hours = Math.floor(minutes / 60);
1390
+ if (hours < 48) return `${hours}h ago`;
1391
+ return `${Math.floor(hours / 24)}d ago`;
1392
+ }
1393
+
1394
+ function ApplicationLatestHistory({
1395
+ history,
1396
+ sourceRef,
1397
+ showIncidentPreview,
1398
+ onSelectHistory,
1399
+ onOpenSource,
1400
+ }: {
1401
+ history?: AppHistory
1402
+ sourceRef?: AppSourceRef
1403
+ showIncidentPreview?: boolean
1404
+ onSelectHistory: () => void
1405
+ onOpenSource?: (source: AppSourceRef) => void
1406
+ }) {
1407
+ const summary = history?.summary
1408
+ const showIncident = summary?.state === 'incident' && showIncidentPreview
1409
+ if (!summary || !showIncident) return null
1410
+ const resolvedSource = sourceRef ?? history?.sourceRef
1411
+ const tone = CHIP_TONE.amber
1412
+
1413
+ return (
1414
+ <section className="rounded-lg border border-theme-border bg-theme-surface px-4 py-3 shadow-theme-sm">
1415
+ <div className="flex flex-wrap items-start gap-3">
1416
+ <span className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-md ring-1 ring-inset ${tone}`}>
1417
+ <AlertTriangle className="h-4 w-4" aria-hidden />
1418
+ </span>
1419
+ <div className="min-w-0 flex-1">
1420
+ <div className="flex flex-wrap items-center gap-2">
1421
+ <h2 className="text-sm font-semibold text-theme-text-primary">{summary.title}</h2>
1422
+ <span className={`${CHIP} ${tone}`}>Latest incident</span>
1423
+ </div>
1424
+ {summary.detail && (
1425
+ <p className="mt-0.5 line-clamp-2 text-sm text-theme-text-secondary">
1426
+ {summary.detail}
1427
+ </p>
1428
+ )}
1429
+ {summary.timestamp && (
1430
+ <p className="mt-1 text-xs text-theme-text-tertiary">
1431
+ {formatAppEventTime(summary.timestamp)}
1432
+ </p>
1433
+ )}
1434
+ </div>
1435
+ <div className="flex shrink-0 items-center gap-2">
1436
+ {resolvedSource && !sourceRef && onOpenSource && (
1437
+ <button type="button" onClick={() => onOpenSource(resolvedSource)} className="rounded-md px-2.5 py-1.5 text-sm font-medium text-accent-text hover:bg-theme-hover">
1438
+ {sourceLinkLabel(resolvedSource)}
1439
+ </button>
1440
+ )}
1441
+ <button
1442
+ type="button"
1443
+ onClick={onSelectHistory}
1444
+ className="rounded-md px-2.5 py-1.5 text-sm font-medium text-accent-text hover:bg-theme-hover"
1445
+ >
1446
+ View history
1447
+ </button>
1448
+ </div>
1449
+ </div>
1450
+ </section>
1451
+ );
1452
+ }
1453
+
1454
+ function ApplicationLatestChangeFact({
1455
+ summary,
1456
+ sourceRef,
1457
+ onSelectHistory,
1458
+ onOpenSource,
1459
+ }: {
1460
+ summary: NonNullable<AppHistory['summary']>
1461
+ sourceRef?: AppSourceRef
1462
+ onSelectHistory: () => void
1463
+ onOpenSource?: (source: AppSourceRef) => void
1464
+ }) {
1465
+ return (
1466
+ <div className="min-w-0 rounded-lg border border-theme-border bg-theme-surface px-4 py-3 shadow-theme-sm md:col-span-2 2xl:col-span-1">
1467
+ <div className="flex items-center justify-between gap-2">
1468
+ <div className="text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">Latest change</div>
1469
+ <button type="button" onClick={onSelectHistory} className="shrink-0 text-xs font-medium text-accent-text hover:underline">History</button>
1470
+ </div>
1471
+ <div className="mt-1 truncate text-sm font-semibold text-theme-text-primary">{summary.title}</div>
1472
+ {summary.detail && <div className="mt-0.5 truncate font-mono text-xs text-theme-text-tertiary">{summary.detail}</div>}
1473
+ {sourceRef && onOpenSource && (
1474
+ <button type="button" onClick={() => onOpenSource(sourceRef)} className="mt-1 text-xs font-medium text-accent-text hover:underline">
1475
+ {sourceLinkLabel(sourceRef)}
1476
+ </button>
1477
+ )}
1478
+ </div>
1479
+ )
1480
+ }
1481
+
1482
+ function WorkloadsMatrix({ workloads, onSelectWorkload }: { workloads: AppWorkload[]; onSelectWorkload: (workload: AppWorkload) => void }) {
1483
+ if (workloads.length === 0) {
1484
+ return <EmptyState variant="inline" headline="No inspectable workloads." />;
1485
+ }
1486
+
1487
+ return (
1488
+ <div className="overflow-x-auto">
1489
+ <table className="w-full min-w-[760px] table-fixed text-sm">
1490
+ <thead>
1491
+ <tr className="border-b border-theme-border text-left text-[10px] uppercase tracking-wide text-theme-text-tertiary">
1492
+ <th className="w-[34%] px-2 py-2 font-semibold">Workload</th>
1493
+ <th className="w-[13%] px-2 py-2 font-semibold">Kind</th>
1494
+ <th className="w-[12%] px-2 py-2 font-semibold">Class</th>
1495
+ <th className="w-[14%] px-2 py-2 font-semibold">Status</th>
1496
+ <th className="w-[18%] px-2 py-2 font-semibold">Runtime</th>
1497
+ <th className="w-[16%] px-2 py-2 font-semibold">Version</th>
1498
+ </tr>
1499
+ </thead>
1500
+ <tbody>
1501
+ {workloads.map((w) => {
1502
+ const status = workloadRuntimeStatus(w)
1503
+ const reason = w.reason === 'Completed' && w.kind !== 'Job' ? undefined : w.reason
1504
+ return (
1505
+ <tr
1506
+ key={workloadKey(w)}
1507
+ className="border-b border-theme-border last:border-b-0 hover:bg-theme-hover"
1508
+ >
1509
+ <td className="truncate px-2 py-2">
1510
+ <button
1511
+ type="button"
1512
+ onClick={() => onSelectWorkload(w)}
1513
+ className="truncate font-medium text-theme-text-primary hover:text-accent-text hover:underline"
1514
+ >
1515
+ {w.name}
1516
+ </button>
1517
+ {reason && <div className="truncate text-xs text-theme-text-tertiary">{reason}</div>}
1518
+ </td>
1519
+ <td className="px-2 py-2 text-theme-text-secondary">{w.kind}</td>
1520
+ <td className="px-2 py-2 text-theme-text-secondary">{workloadClassOf(w.workload_class)}</td>
1521
+ <td className="px-2 py-2">
1522
+ <span className="inline-flex items-center gap-1.5 text-theme-text-secondary">
1523
+ <StatusDot tone={mapHealthToTone(status.health)} />
1524
+ {status.label}
1525
+ </span>
1526
+ </td>
1527
+ <td className="px-2 py-2 text-xs text-theme-text-secondary">
1528
+ {workloadRuntimeDetail(w)}
1529
+ </td>
1530
+ <td className="truncate px-2 py-2 font-mono text-xs text-theme-text-secondary">
1531
+ {w.version || w.appVersion || "-"}
1532
+ </td>
1533
+ </tr>
1534
+ );
1535
+ })}
1536
+ </tbody>
1537
+ </table>
1538
+ </div>
1539
+ );
1540
+ }
1541
+
1542
+ function workloadRuntimeStatus(workload: AppWorkload): {
1543
+ label: string;
1544
+ health: AppHealth;
1545
+ } {
1546
+ const batch = workload.batch;
1547
+ if (!batch) {
1548
+ const health = healthOf(workload.health);
1549
+ return { label: HEALTH_META[health].label, health };
1550
+ }
1551
+ if ((batch.activeRuns ?? 0) > 0 || batch.latestRunPhase === "Running")
1552
+ return { label: "Running", health: "neutral" };
1553
+ if (batch.latestRunPhase === "Failed" || batch.latestRunPhase === "Error")
1554
+ return { label: "Failed", health: "unhealthy" };
1555
+ if (batch.latestRunPhase === "Succeeded")
1556
+ return { label: "Succeeded", health: "healthy" };
1557
+ if (batch.suspended) return { label: "Suspended", health: "neutral" };
1558
+ return { label: "Idle", health: "neutral" };
1559
+ }
1560
+
1561
+ function workloadRuntimeDetail(workload: AppWorkload): string {
1562
+ const batch = workload.batch;
1563
+ if (!batch) return `${workload.ready}/${workload.desired} ready`;
1564
+ if ((batch.activeRuns ?? 0) > 0)
1565
+ return pluralize(batch.activeRuns ?? 0, "active run");
1566
+ const at =
1567
+ batch.latestStartedAt || batch.latestFinishedAt || batch.lastScheduledAt;
1568
+ if (batch.latestRunName)
1569
+ return `${midTruncate(batch.latestRunName, 28)}${at ? ` · ${relativeTime(at)}` : ""}`;
1570
+ if (batch.schedule) return `Schedule ${batch.schedule}`;
1571
+ return "No retained runs";
1572
+ }
1573
+
1574
+ function ApplicationTopology({
1575
+ topology,
1576
+ loading,
1577
+ available,
1578
+ workloads,
1579
+ colorByWorkload,
1580
+ focusNodeId,
1581
+ focusedOwnerId,
1582
+ onNodeClick,
1583
+ onNodeHover,
1584
+ onFocusWorkload,
1585
+ onSelectWorkload,
1586
+ deploymentLayer,
1587
+ deploymentSource,
1588
+ onOpenSource,
1589
+ onToggleReplicaSets,
1590
+ }: {
1591
+ topology: Topology | null
1592
+ loading?: boolean
1593
+ available: boolean
1594
+ workloads: AppWorkload[]
1595
+ colorByWorkload: Map<string, number> | null
1596
+ focusNodeId?: string
1597
+ focusedOwnerId: WorkloadFocus
1598
+ onNodeClick: (node: TopologyNode) => void
1599
+ onNodeHover: (node: TopologyNode | null) => void
1600
+ onFocusWorkload: (owner: WorkloadFocus) => void
1601
+ onSelectWorkload: (workload: AppWorkload) => void
1602
+ deploymentLayer: DeploymentTopologyLayer | null
1603
+ deploymentSource?: AppSourceRef
1604
+ onOpenSource?: (source: AppSourceRef) => void
1605
+ onToggleReplicaSets: (ownerID: string) => void
1606
+ }) {
1607
+ const hasSharedOrUnscopedNodes = useMemo(
1608
+ () => topology?.nodes.some((node) => {
1609
+ if (node.data?.deploymentMembership === 'source-only') return false
1610
+ const stamp = ownershipOf(node.data)
1611
+ return !stamp.ownerWorkloadId && stamp.focusWorkloadIds.length !== 1
1612
+ }) ?? false,
1613
+ [topology],
1614
+ );
1615
+
1616
+ return (
1617
+ <div className="relative min-h-0 flex-1 bg-theme-surface">
1618
+ {available && topology ? (
1619
+ <>
1620
+ <TopologyGraph
1621
+ topology={topology}
1622
+ viewMode="resources"
1623
+ groupingMode="namespace"
1624
+ hideGroupHeader
1625
+ onNodeClick={onNodeClick}
1626
+ showExportButton={false}
1627
+ focusNodeId={focusNodeId}
1628
+ focusedOwnerId={focusedOwnerId}
1629
+ onNodeHover={onNodeHover}
1630
+ onToggleReplicaSets={onToggleReplicaSets}
1631
+ fitViewPadding={{ top: 0.08, right: 0.05, bottom: 0.08, left: '360px' }}
1632
+ />
1633
+ <TopologyWorkloadLegend
1634
+ workloads={workloads}
1635
+ colorByWorkload={colorByWorkload}
1636
+ focusedOwnerId={focusedOwnerId}
1637
+ showSharedOrUnscoped={hasSharedOrUnscopedNodes}
1638
+ onFocus={onFocusWorkload}
1639
+ onSelectWorkload={onSelectWorkload}
1640
+ deploymentLayer={deploymentLayer}
1641
+ deploymentSource={deploymentSource}
1642
+ onOpenSource={onOpenSource}
1643
+ />
1644
+ </>
1645
+ ) : loading ? (
1646
+ <PaneLoader label="Loading topology..." className="absolute inset-0" />
1647
+ ) : (
1648
+ <div className="flex h-full items-center justify-center p-6">
1649
+ <EmptyState
1650
+ headline="No topology available"
1651
+ body="Radar could not build an application topology for this app."
1652
+ />
1653
+ </div>
1654
+ )}
1655
+ </div>
1656
+ );
1657
+ }
1658
+
1659
+ function ApplicationHistoryView({
1660
+ history,
1661
+ loading,
1662
+ fallbackEvents,
1663
+ workloads,
1664
+ onSelectWorkload,
1665
+ onOpenSource,
1666
+ }: {
1667
+ history?: AppHistory;
1668
+ loading?: boolean;
1669
+ fallbackEvents: NonNullable<AppRow["events"]>;
1670
+ workloads: AppWorkload[];
1671
+ onSelectWorkload: (workload: AppWorkload) => void;
1672
+ onOpenSource?: (source: AppSourceRef) => void;
1673
+ }) {
1674
+ const anchors = history?.anchors ?? [];
1675
+ const incidents =
1676
+ history?.incidents ??
1677
+ fallbackEvents.map((event) => ({
1678
+ severity: "warning",
1679
+ title: event.object ? `${event.reason} on ${event.object}` : event.reason,
1680
+ object: event.object,
1681
+ message: event.message,
1682
+ count: event.count,
1683
+ firstSeen: event.firstSeen,
1684
+ lastSeen: event.lastSeen,
1685
+ }));
1686
+ const empty = !loading && anchors.length === 0 && incidents.length === 0;
1687
+
1688
+ return (
1689
+ <div className="min-h-0 flex-1 overflow-auto">
1690
+ <div className="w-full max-w-[2400px] space-y-4 p-4 sm:p-6">
1691
+ {loading && (
1692
+ <ApplicationPanel title="History">
1693
+ <div className="text-sm text-theme-text-tertiary">
1694
+ Loading application history...
1695
+ </div>
1696
+ </ApplicationPanel>
1697
+ )}
1698
+ {history?.partialSources && history.partialSources.length > 0 && (
1699
+ <ApplicationPanel title="Partial history">
1700
+ <div className="space-y-1 text-sm text-theme-text-secondary">
1701
+ {history.partialSources.map((msg, idx) => (
1702
+ <div key={`${msg}-${idx}`}>{msg}</div>
1703
+ ))}
1704
+ </div>
1705
+ </ApplicationPanel>
1706
+ )}
1707
+ {incidents.length > 0 && (
1708
+ <ApplicationPanel title="Current incidents">
1709
+ <div className="divide-y divide-theme-border">
1710
+ {incidents.map((incident, idx) => {
1711
+ const workload = directWorkloadForEvent(
1712
+ {
1713
+ object: incident.object,
1714
+ reason: "",
1715
+ count: 1,
1716
+ type: "Warning",
1717
+ },
1718
+ workloads,
1719
+ );
1720
+ return (
1721
+ <HistoryIncidentLine
1722
+ key={`${incident.object}-${incident.title}-${idx}`}
1723
+ incident={incident}
1724
+ action={
1725
+ workload ? (
1726
+ <button
1727
+ type="button"
1728
+ onClick={() => onSelectWorkload(workload)}
1729
+ className="text-xs font-medium text-accent-text hover:underline"
1730
+ >
1731
+ Open workload
1732
+ </button>
1733
+ ) : undefined
1734
+ }
1735
+ />
1736
+ );
1737
+ })}
1738
+ </div>
1739
+ </ApplicationPanel>
1740
+ )}
1741
+ {anchors.length > 0 && (
1742
+ <ApplicationPanel
1743
+ title={
1744
+ <span className="flex items-center justify-between gap-3">
1745
+ <span>Deployment history</span>
1746
+ {history?.sourceRef && onOpenSource && (
1747
+ <button
1748
+ type="button"
1749
+ onClick={() => onOpenSource(history.sourceRef!)}
1750
+ className="text-xs font-medium text-accent-text hover:underline"
1751
+ >
1752
+ {sourceLinkLabel(history.sourceRef)}
1753
+ </button>
1754
+ )}
1755
+ </span>
1756
+ }
1757
+ >
1758
+ <div className="divide-y divide-theme-border">
1759
+ {anchors.map((anchor, idx) => (
1760
+ <HistoryAnchorLine
1761
+ key={`${anchor.type}-${anchor.timestamp}-${anchor.revision}-${idx}`}
1762
+ anchor={anchor}
1763
+ />
1764
+ ))}
1765
+ </div>
1766
+ </ApplicationPanel>
1767
+ )}
1768
+ {empty && (
1769
+ <ApplicationPanel title="History">
1770
+ <EmptyState
1771
+ variant="inline"
1772
+ headline="No retained deployment history."
1773
+ body="Current application state is still available in Overview."
1774
+ />
1775
+ </ApplicationPanel>
1776
+ )}
1777
+ </div>
1778
+ </div>
1779
+ );
1780
+ }
1781
+
1782
+ function HistoryAnchorLine({
1783
+ anchor,
1784
+ }: {
1785
+ anchor: NonNullable<AppHistory["anchors"]>[number];
1786
+ }) {
1787
+ return (
1788
+ <div className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 py-3 first:pt-0 last:pb-0">
1789
+ <div className="min-w-0">
1790
+ <div className="flex flex-wrap items-center gap-2">
1791
+ <span className="font-medium text-theme-text-primary">
1792
+ {anchor.title}
1793
+ </span>
1794
+ {anchor.status && (
1795
+ <span className={`${CHIP} ${historyStatusTone(anchor.status)}`}>
1796
+ {anchor.status}
1797
+ </span>
1798
+ )}
1799
+ </div>
1800
+ {anchor.revision && (
1801
+ <div className="truncate font-mono text-sm text-theme-text-secondary">
1802
+ {anchor.revision}
1803
+ </div>
1804
+ )}
1805
+ {anchor.message && (
1806
+ <div className="mt-1 line-clamp-2 text-sm text-theme-text-tertiary">
1807
+ {anchor.message}
1808
+ </div>
1809
+ )}
1810
+ {anchor.source && (
1811
+ <div className="mt-1 truncate text-xs text-theme-text-tertiary">
1812
+ {anchor.source}
1813
+ </div>
1814
+ )}
1815
+ </div>
1816
+ <div className="text-right text-xs text-theme-text-tertiary">
1817
+ {formatAppEventTime(anchor.timestamp)}
1818
+ {anchor.initiatedBy && <div>{anchor.initiatedBy}</div>}
1819
+ </div>
1820
+ </div>
1821
+ );
1822
+ }
1823
+
1824
+ function HistoryIncidentLine({
1825
+ incident,
1826
+ action,
1827
+ }: {
1828
+ incident: NonNullable<AppHistory["incidents"]>[number];
1829
+ action?: ReactNode;
1830
+ }) {
1831
+ return (
1832
+ <div className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 py-3 first:pt-0 last:pb-0">
1833
+ <div className="min-w-0">
1834
+ <div className="truncate font-medium text-theme-text-primary">
1835
+ {incident.title}
1836
+ </div>
1837
+ {incident.object && (
1838
+ <div className="truncate text-sm text-theme-text-secondary">
1839
+ {incident.object}
1840
+ </div>
1841
+ )}
1842
+ {incident.message && (
1843
+ <div className="mt-1 line-clamp-2 text-sm text-theme-text-tertiary">
1844
+ {incident.message}
1845
+ </div>
1846
+ )}
1847
+ </div>
1848
+ <div className="text-right text-xs text-theme-text-tertiary">
1849
+ {formatAppEventTime(incident.lastSeen)}
1850
+ {incident.count && incident.count > 1 && <div>{incident.count}x</div>}
1851
+ {action && <div className="mt-1">{action}</div>}
1852
+ </div>
1853
+ </div>
1854
+ );
1855
+ }
1856
+
1857
+ function buildAppIssues(
1858
+ workloads: AppWorkload[],
1859
+ events: NonNullable<AppRow["events"]>,
1860
+ ): AppIssue[] {
1861
+ const issues: AppIssue[] = [];
1862
+ const issueByWorkload = new Map<string, AppIssue>();
1863
+
1864
+ for (const workload of workloads) {
1865
+ const health = healthOf(workload.health);
1866
+ const notReady = workload.desired > 0 && workload.ready < workload.desired;
1867
+ const hasRestarts = (workload.restarts ?? 0) > 0;
1868
+ const hasReason = Boolean(workload.reason);
1869
+ const hasHealthProblem = health === "degraded" || health === "unhealthy";
1870
+ if (!hasHealthProblem && !notReady && !hasRestarts && !hasReason) continue;
1871
+
1872
+ const severity: AppIssueSeverity =
1873
+ health === "unhealthy" || (workload.desired > 0 && workload.ready === 0)
1874
+ ? "error"
1875
+ : "warning";
1876
+ const detail = [
1877
+ notReady ? `${workload.ready}/${workload.desired} ready` : undefined,
1878
+ hasRestarts ? pluralize(workload.restarts, "restart") : undefined,
1879
+ workload.reason,
1880
+ ]
1881
+ .filter(Boolean)
1882
+ .join(" · ");
1883
+ const issue: AppIssue = {
1884
+ key: `workload:${workloadKey(workload)}`,
1885
+ severity,
1886
+ title: `${workload.name} ${health === "unhealthy" ? "is down" : health === "degraded" || notReady ? "is degraded" : "needs attention"}`,
1887
+ detail,
1888
+ workload,
1889
+ };
1890
+ issueByWorkload.set(workloadKey(workload), issue);
1891
+ issues.push(issue);
1892
+ }
1893
+
1894
+ for (const event of events) {
1895
+ const workload = directWorkloadForEvent(event, workloads);
1896
+ if (workload) {
1897
+ const existing = issueByWorkload.get(workloadKey(workload));
1898
+ if (existing) {
1899
+ existing.detail = [existing.detail, event.reason]
1900
+ .filter(Boolean)
1901
+ .join(" · ");
1902
+ continue;
1903
+ }
1904
+ issues.push({
1905
+ key: `event:${event.object}:${event.reason}`,
1906
+ severity: "warning",
1907
+ title: `${event.reason} on ${workload.name}`,
1908
+ detail: event.message,
1909
+ workload,
1910
+ });
1911
+ continue;
1912
+ }
1913
+ issues.push({
1914
+ key: `event:${event.object}:${event.reason}`,
1915
+ severity: "warning",
1916
+ title: `${event.reason} on ${event.object}`,
1917
+ detail: event.message,
1918
+ });
1919
+ }
1920
+
1921
+ const rank: Record<AppIssueSeverity, number> = {
1922
+ error: 0,
1923
+ warning: 1,
1924
+ info: 2,
1925
+ };
1926
+ return issues.sort(
1927
+ (a, b) =>
1928
+ rank[a.severity] - rank[b.severity] || a.title.localeCompare(b.title),
1929
+ );
1930
+ }
1931
+
1932
+ function directWorkloadForEvent(
1933
+ event: NonNullable<AppRow["events"]>[number],
1934
+ workloads: AppWorkload[],
1935
+ ): AppWorkload | undefined {
1936
+ const parsed = parseEventObject(event.object);
1937
+ if (!parsed) return undefined;
1938
+ const matches = workloads.filter(
1939
+ (w) =>
1940
+ w.kind.toLowerCase() === parsed.kind.toLowerCase() &&
1941
+ w.name === parsed.name,
1942
+ );
1943
+ return matches.length === 1 ? matches[0] : undefined;
1944
+ }
1945
+
1946
+ function parseEventObject(
1947
+ object: string,
1948
+ ): { kind: string; name: string } | null {
1949
+ const slash = object.indexOf("/");
1950
+ if (slash <= 0 || slash === object.length - 1) return null;
1951
+ return { kind: object.slice(0, slash), name: object.slice(slash + 1) };
1952
+ }
1953
+
1954
+ function issueTone(severity: AppIssueSeverity): string {
1955
+ if (severity === "error") return CHIP_TONE.rose;
1956
+ if (severity === "warning") return CHIP_TONE.amber;
1957
+ return CHIP_TONE.blue;
1958
+ }
1959
+
1960
+ function historyStatusTone(status: string): string {
1961
+ const s = status.toLowerCase();
1962
+ if (s.includes("fail") || s.includes("error")) return CHIP_TONE.rose;
1963
+ if (s.includes("running") || s.includes("pending") || s.includes("progress"))
1964
+ return CHIP_TONE.amber;
1965
+ if (s.includes("succeed") || s.includes("deployed") || s.includes("ready"))
1966
+ return CHIP_TONE.emerald;
1967
+ return CHIP_TONE.muted;
1968
+ }
1969
+
1970
+ function sourceLinkLabel(source: AppSourceRef): string {
1971
+ return `View ${sourceObjectLabel(source)}`
1972
+ }
1973
+
1974
+ function sourceObjectLabel(source: AppSourceRef): string {
1975
+ if (source.type === 'helm') return 'Helm release'
1976
+ if (source.tool === 'argocd' || source.kind.toLowerCase() === 'application') return 'Argo CD application'
1977
+ if (source.kind.toLowerCase() === 'helmrelease') return 'Flux HelmRelease'
1978
+ if (source.kind.toLowerCase() === 'kustomization') return 'Flux Kustomization'
1979
+ return 'GitOps source'
1980
+ }
1981
+
1982
+ function sourceInventoryLabel(source: AppSourceRef): string {
1983
+ if (source.type === 'helm') return 'Helm'
1984
+ if (source.tool === 'argocd' || source.kind.toLowerCase() === 'application') return 'Argo CD'
1985
+ if (source.tool === 'fluxcd') return 'Flux'
1986
+ return 'GitOps'
1987
+ }
1988
+
1989
+ function SourceDeliveryStatus({ status }: { status: NonNullable<AppRow['sourceStatus']> }) {
1990
+ const values = [
1991
+ status.sync ? { label: status.sync, tone: sourceSyncHealth(status.sync) } : null,
1992
+ status.health ? { label: status.health, tone: sourceReportedHealth(status.health) } : null,
1993
+ ].filter((value): value is { label: string; tone: AppHealth } => value !== null)
1994
+
1995
+ return (
1996
+ <span className="inline-flex min-w-0 items-center gap-2">
1997
+ {values.map((value, index) => (
1998
+ <span key={`${value.label}-${index}`} className="inline-flex min-w-0 items-center gap-1.5">
1999
+ {index > 0 && <span className="text-theme-text-tertiary" aria-hidden>·</span>}
2000
+ <StatusDot tone={mapHealthToTone(value.tone)} />
2001
+ <span className="truncate">{value.label}</span>
2002
+ </span>
2003
+ ))}
2004
+ </span>
2005
+ )
2006
+ }
2007
+
2008
+ function formatAppEventTime(value?: string): string {
2009
+ if (!value || value.startsWith("0001-01-01T00:00:00")) return "";
2010
+ return value;
2011
+ }
2012
+
2013
+ function ApplicationPanel({
2014
+ title,
2015
+ children,
2016
+ }: {
2017
+ title: ReactNode;
2018
+ children: ReactNode;
2019
+ }) {
2020
+ return (
2021
+ <section className="rounded-lg border border-theme-border bg-theme-surface p-4 shadow-theme-sm">
2022
+ <h2 className="mb-3 text-sm font-semibold text-theme-text-primary">
2023
+ {title}
2024
+ </h2>
2025
+ {children}
2026
+ </section>
2027
+ );
2028
+ }
2029
+
2030
+ function ApplicationFact({
2031
+ label,
2032
+ value,
2033
+ detail,
2034
+ monoValue,
2035
+ monoDetail,
2036
+ variant = "card",
2037
+ }: {
2038
+ label: string
2039
+ value: ReactNode
2040
+ detail?: string
2041
+ monoValue?: boolean
2042
+ monoDetail?: boolean
2043
+ variant?: 'card' | 'row' | 'bare'
2044
+ }) {
2045
+ if (variant === "row") {
2046
+ return (
2047
+ <div className="min-w-0 border-b border-theme-border pb-3 last:border-b-0">
2048
+ <div className="text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
2049
+ {label}
2050
+ </div>
2051
+ <div
2052
+ className={clsx(
2053
+ "mt-1 truncate text-sm font-semibold text-theme-text-primary",
2054
+ monoValue && "font-mono",
2055
+ )}
2056
+ >
2057
+ {value}
2058
+ </div>
2059
+ {detail && (
2060
+ <div
2061
+ className={clsx(
2062
+ "mt-0.5 truncate text-xs text-theme-text-tertiary",
2063
+ monoDetail && "font-mono",
2064
+ )}
2065
+ >
2066
+ {detail}
2067
+ </div>
2068
+ )}
2069
+ </div>
2070
+ );
2071
+ }
2072
+
2073
+ if (variant === "bare") {
2074
+ return (
2075
+ <div className="min-w-0">
2076
+ <div className="text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
2077
+ {label}
2078
+ </div>
2079
+ <div
2080
+ className={clsx(
2081
+ "mt-1 truncate text-sm font-semibold text-theme-text-primary",
2082
+ monoValue && "font-mono",
2083
+ )}
2084
+ >
2085
+ {value}
2086
+ </div>
2087
+ {detail && (
2088
+ <div
2089
+ className={clsx(
2090
+ "mt-0.5 truncate text-xs text-theme-text-tertiary",
2091
+ monoDetail && "font-mono",
2092
+ )}
2093
+ >
2094
+ {detail}
2095
+ </div>
2096
+ )}
2097
+ </div>
2098
+ );
2099
+ }
2100
+
2101
+ return (
2102
+ <div className="min-w-0 rounded-lg border border-theme-border bg-theme-surface px-4 py-3 shadow-theme-sm">
2103
+ <div className="text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
2104
+ {label}
2105
+ </div>
2106
+ <div
2107
+ className={clsx(
2108
+ "mt-1 truncate text-sm font-semibold text-theme-text-primary",
2109
+ monoValue && "font-mono",
2110
+ )}
2111
+ >
2112
+ {value}
2113
+ </div>
2114
+ {detail && (
2115
+ <div
2116
+ className={clsx(
2117
+ "mt-0.5 truncate text-xs text-theme-text-tertiary",
2118
+ monoDetail && "font-mono",
2119
+ )}
2120
+ >
2121
+ {detail}
2122
+ </div>
2123
+ )}
2124
+ </div>
2125
+ );
2126
+ }
2127
+
2128
+ function ApplicationScopeSelector({
2129
+ workloads,
2130
+ selectedWorkload,
2131
+ onSelect,
2132
+ onFocus,
2133
+ }: {
2134
+ workloads: AppWorkload[];
2135
+ selectedWorkload: AppWorkload | null;
2136
+ onSelect: (key: string | null) => void;
2137
+ onFocus: (owner: WorkloadFocus) => void;
2138
+ }) {
2139
+ const [open, setOpen] = useState(false);
2140
+ const [query, setQuery] = useState("");
2141
+ const rootRef = useDismissablePopover<HTMLDivElement>(open, setOpen);
2142
+ const selectedKey = selectedWorkload ? workloadKey(selectedWorkload) : null;
2143
+ const filteredWorkloads = useMemo(() => {
2144
+ const q = query.trim().toLowerCase();
2145
+ if (!q) return workloads;
2146
+ return workloads.filter((w) =>
2147
+ `${w.kind} ${w.namespace} ${w.name}`.toLowerCase().includes(q),
2148
+ );
2149
+ }, [query, workloads]);
2150
+
2151
+ if (workloads.length <= 1) {
2152
+ return selectedWorkload ? (
2153
+ <StaticWorkloadScope workload={selectedWorkload} />
2154
+ ) : (
2155
+ <span className="inline-flex max-w-[min(48vw,34rem)] items-center gap-2 rounded-md bg-theme-base px-2.5 py-1.5 text-sm ring-1 ring-inset ring-theme-border">
2156
+ <Boxes
2157
+ className="h-4 w-4 shrink-0 text-theme-text-secondary"
2158
+ aria-hidden
2159
+ />
2160
+ <span className="min-w-0 truncate font-medium text-theme-text-primary">
2161
+ Application
2162
+ </span>
2163
+ </span>
2164
+ );
2165
+ }
2166
+
2167
+ return (
2168
+ <div ref={rootRef} className="relative min-w-0">
2169
+ <button
2170
+ type="button"
2171
+ onClick={() => setOpen((o) => !o)}
2172
+ aria-haspopup="listbox"
2173
+ aria-expanded={open}
2174
+ className="inline-flex max-w-[min(48vw,34rem)] items-center gap-2 rounded-md bg-theme-base px-2.5 py-1.5 text-sm ring-1 ring-inset ring-theme-border hover:bg-theme-hover"
2175
+ >
2176
+ {selectedWorkload ? (
2177
+ <WorkloadKindIcon workload={selectedWorkload} />
2178
+ ) : (
2179
+ <Boxes
2180
+ className="h-4 w-4 shrink-0 text-theme-text-secondary"
2181
+ aria-hidden
2182
+ />
2183
+ )}
2184
+ <span className="min-w-0 truncate font-medium text-theme-text-primary">
2185
+ {selectedWorkload ? selectedWorkload.name : "Application"}
2186
+ </span>
2187
+ {selectedWorkload && (
2188
+ <span className="hidden shrink-0 text-xs uppercase tracking-wide text-theme-text-tertiary md:inline">
2189
+ {selectedWorkload.kind}
2190
+ </span>
2191
+ )}
2192
+ <ChevronDown
2193
+ className={clsx(
2194
+ "h-3.5 w-3.5 shrink-0 text-theme-text-tertiary transition-transform",
2195
+ open && "rotate-180",
2196
+ )}
2197
+ aria-hidden
2198
+ />
2199
+ </button>
2200
+ {open && (
2201
+ <div
2202
+ role="listbox"
2203
+ className="absolute left-0 top-full z-50 mt-1 w-[min(32rem,calc(100vw-2rem))] overflow-hidden rounded-md border border-theme-border bg-theme-surface shadow-theme-md"
2204
+ onMouseLeave={() => onFocus(null)}
2205
+ >
2206
+ <div className="border-b border-theme-border p-1">
2207
+ <ScopeOption
2208
+ active={selectedKey === null}
2209
+ icon={
2210
+ <Boxes
2211
+ className="h-4 w-4 text-theme-text-secondary"
2212
+ aria-hidden
2213
+ />
2214
+ }
2215
+ title="Application"
2216
+ subtitle="App scope"
2217
+ onClick={() => {
2218
+ setOpen(false);
2219
+ onSelect(null);
2220
+ }}
2221
+ onMouseEnter={() => onFocus(null)}
2222
+ />
2223
+ </div>
2224
+ <div className="border-b border-theme-border px-2 py-1.5">
2225
+ <div className="flex items-center justify-between gap-3">
2226
+ <span className="text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
2227
+ Workloads
2228
+ </span>
2229
+ <span className="rounded-full bg-theme-hover px-1.5 text-[10px] font-medium text-theme-text-tertiary">
2230
+ {workloads.length}
2231
+ </span>
2232
+ </div>
2233
+ {workloads.length > 8 && (
2234
+ <label className="mt-2 flex items-center gap-2 rounded-md bg-theme-base px-2 py-1 ring-1 ring-inset ring-theme-border">
2235
+ <Search
2236
+ className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary"
2237
+ aria-hidden
2238
+ />
2239
+ <input
2240
+ value={query}
2241
+ onChange={(e) => setQuery(e.target.value)}
2242
+ placeholder="Filter workloads..."
2243
+ className="min-w-0 flex-1 bg-transparent text-sm text-theme-text-primary outline-none placeholder:text-theme-text-tertiary"
2244
+ />
2245
+ </label>
2246
+ )}
2247
+ </div>
2248
+ <div className="max-h-80 overflow-y-auto p-1">
2249
+ {filteredWorkloads.length === 0 ? (
2250
+ <div className="px-2 py-4 text-center text-sm text-theme-text-tertiary">
2251
+ No workloads match.
2252
+ </div>
2253
+ ) : (
2254
+ filteredWorkloads.map((w) => {
2255
+ const key = workloadKey(w);
2256
+ return (
2257
+ <ScopeOption
2258
+ key={key}
2259
+ active={selectedKey === key}
2260
+ icon={<WorkloadKindIcon workload={w} />}
2261
+ title={w.name}
2262
+ subtitle={`${w.kind} · ${w.ready}/${w.desired} ready${w.reason ? ` · ${w.reason}` : ""}`}
2263
+ onClick={() => {
2264
+ setOpen(false);
2265
+ onSelect(key);
2266
+ }}
2267
+ onMouseEnter={() => onFocus(key)}
2268
+ />
2269
+ );
2270
+ })
2271
+ )}
2272
+ </div>
2273
+ </div>
2274
+ )}
2275
+ </div>
2276
+ );
2277
+ }
2278
+
2279
+ function TopologyWorkloadLegend({
2280
+ workloads,
2281
+ colorByWorkload,
2282
+ focusedOwnerId,
2283
+ showSharedOrUnscoped,
2284
+ onFocus,
2285
+ onSelectWorkload,
2286
+ deploymentLayer,
2287
+ deploymentSource,
2288
+ onOpenSource,
2289
+ }: {
2290
+ workloads: AppWorkload[]
2291
+ colorByWorkload: Map<string, number> | null
2292
+ focusedOwnerId: WorkloadFocus
2293
+ showSharedOrUnscoped: boolean
2294
+ onFocus: (owner: WorkloadFocus) => void
2295
+ onSelectWorkload: (workload: AppWorkload) => void
2296
+ deploymentLayer: DeploymentTopologyLayer | null
2297
+ deploymentSource?: AppSourceRef
2298
+ onOpenSource?: (source: AppSourceRef) => void
2299
+ }) {
2300
+ const managedOnlyCount = deploymentLayer?.managedOnly.length ?? 0
2301
+ const runtimeOnlyCount = deploymentLayer?.runtimeOnlyCount ?? 0
2302
+ const showSourceDifferences = !!deploymentSource && managedOnlyCount + runtimeOnlyCount > 0
2303
+ const sourceLabel = deploymentSource ? sourceInventoryLabel(deploymentSource) : ''
2304
+
2305
+ return (
2306
+ <div className="absolute left-4 top-4 z-10 w-[min(18.5rem,calc(100%-2rem))] 2xl:w-[min(22rem,calc(100%-2rem))] overflow-hidden rounded-lg border border-theme-border bg-theme-surface/95 shadow-theme-md backdrop-blur">
2307
+ <div className="flex items-center justify-between gap-3 border-b border-theme-border bg-theme-base px-3 py-2">
2308
+ <span className="text-xs font-semibold text-theme-text-primary">
2309
+ Workloads
2310
+ </span>
2311
+ <span className="rounded-full bg-theme-hover px-1.5 text-[10px] font-medium text-theme-text-tertiary">
2312
+ {workloads.length}
2313
+ </span>
2314
+ </div>
2315
+ <div
2316
+ className="max-h-72 overflow-y-auto p-1"
2317
+ onMouseLeave={() => onFocus(null)}
2318
+ >
2319
+ {workloads.map((w) => {
2320
+ const key = workloadKey(w);
2321
+ const idx = colorByWorkload?.get(key);
2322
+ const hue = idx != null ? workloadHue(idx) : null;
2323
+ return (
2324
+ <ScopeOption
2325
+ key={key}
2326
+ compact
2327
+ active={focusedOwnerId === key}
2328
+ accentColor={hue?.swatch}
2329
+ icon={<WorkloadKindIcon workload={w} compact />}
2330
+ title={w.name}
2331
+ subtitle={`${w.kind} · ${w.ready}/${w.desired}`}
2332
+ onClick={() => onSelectWorkload(w)}
2333
+ onMouseEnter={() => onFocus(key)}
2334
+ />
2335
+ );
2336
+ })}
2337
+ {showSharedOrUnscoped && (
2338
+ <ScopeOption
2339
+ compact
2340
+ muted
2341
+ active={focusedOwnerId === NEUTRAL_OWNER}
2342
+ icon={<SharedScopeMarker />}
2343
+ title="Shared / unscoped"
2344
+ onMouseEnter={() => onFocus(NEUTRAL_OWNER)}
2345
+ />
2346
+ )}
2347
+ </div>
2348
+ {showSourceDifferences && deploymentSource && (
2349
+ <div className="border-t border-theme-border px-3 py-2.5">
2350
+ <div className="mb-1.5 text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">Source differences</div>
2351
+ <div className="space-y-1">
2352
+ {managedOnlyCount > 0 && (
2353
+ <div className="flex items-center gap-2 px-1.5 py-1 text-xs text-theme-text-secondary">
2354
+ <DeploymentSourceLogo source={deploymentSource} />
2355
+ <span className="min-w-0 flex-1">
2356
+ <span className="block">{managedOnlyCount} only in {sourceLabel}</span>
2357
+ {deploymentLayer?.managedOnlySummary && (
2358
+ <span className="mt-0.5 block text-[10px] text-theme-text-tertiary">{deploymentLayer.managedOnlySummary}</span>
2359
+ )}
2360
+ </span>
2361
+ {onOpenSource && (
2362
+ <Tooltip content={`Open ${sourceLabel} view`} delay={150}>
2363
+ <button type="button" onClick={() => onOpenSource(deploymentSource)} className="rounded p-0.5 text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary">
2364
+ <ExternalLink className="h-3 w-3" aria-hidden />
2365
+ </button>
2366
+ </Tooltip>
2367
+ )}
2368
+ </div>
2369
+ )}
2370
+ {runtimeOnlyCount > 0 && (
2371
+ <div className="flex items-center gap-2 px-1.5 py-1 text-xs text-theme-text-secondary">
2372
+ <Radio className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" aria-hidden />
2373
+ <span>{pluralize(runtimeOnlyCount, 'runtime-only resource')}</span>
2374
+ </div>
2375
+ )}
2376
+ </div>
2377
+ </div>
2378
+ )}
2379
+ </div>
2380
+ );
2381
+ }
2382
+
2383
+ function DeploymentSourceLogo({ source }: { source: AppSourceRef }) {
2384
+ const sourceLabel = sourceInventoryLabel(source)
2385
+ const logo = sourceLabel === 'Argo CD' ? argoCdLogo : sourceLabel === 'Flux' ? fluxLogo : null
2386
+ if (!logo) return <Layers className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" aria-hidden />
2387
+ return <img src={logo} alt="" className="h-4 w-4 shrink-0 object-contain" />
2388
+ }
2389
+
2390
+ function StaticWorkloadScope({ workload }: { workload: AppWorkload }) {
2391
+ return (
2392
+ <span className="inline-flex max-w-[min(48vw,34rem)] items-center gap-2 rounded-md bg-theme-base px-2.5 py-1.5 text-sm ring-1 ring-inset ring-theme-border">
2393
+ <WorkloadKindIcon workload={workload} />
2394
+ <span className="min-w-0 truncate font-medium text-theme-text-primary">
2395
+ {workload.name}
2396
+ </span>
2397
+ <span className="hidden shrink-0 text-xs uppercase tracking-wide text-theme-text-tertiary md:inline">
2398
+ {workload.kind}
2399
+ </span>
2400
+ </span>
2401
+ );
2402
+ }
2403
+
2404
+ function WorkloadKindIcon({
2405
+ workload,
2406
+ compact,
2407
+ }: {
2408
+ workload: AppWorkload;
2409
+ compact?: boolean;
2410
+ }) {
2411
+ const KindIcon = getTopologyIcon(workload.kind);
2412
+ return (
2413
+ <span
2414
+ className={clsx(
2415
+ "relative flex shrink-0 items-center justify-center",
2416
+ compact ? "h-4 w-4" : "h-5 w-5",
2417
+ )}
2418
+ aria-hidden
2419
+ >
2420
+ <KindIcon
2421
+ className={clsx(
2422
+ "text-theme-text-secondary",
2423
+ compact ? "h-3.5 w-3.5" : "h-4 w-4",
2424
+ )}
2425
+ />
2426
+ <StatusDot
2427
+ tone={mapHealthToTone(healthOf(workload.health))}
2428
+ size="xs"
2429
+ className="absolute -bottom-0.5 -right-0.5 ring-2 ring-theme-base"
2430
+ />
2431
+ </span>
2432
+ );
2433
+ }
2434
+
2435
+ function SharedScopeMarker() {
2436
+ return (
2437
+ <span className="flex w-5 shrink-0 items-center gap-1" aria-hidden>
2438
+ <span className="block h-4 w-1 rounded-full bg-theme-border" />
2439
+ <span className="h-1.5 w-1.5 rounded-full bg-theme-border" />
2440
+ </span>
2441
+ );
2442
+ }
2443
+
2444
+ function ScopeOption({
2445
+ active,
2446
+ muted,
2447
+ compact,
2448
+ onClick,
2449
+ onMouseEnter,
2450
+ icon,
2451
+ title,
2452
+ subtitle,
2453
+ accentColor,
2454
+ }: {
2455
+ active?: boolean;
2456
+ muted?: boolean;
2457
+ compact?: boolean;
2458
+ onClick?: () => void;
2459
+ onMouseEnter?: () => void;
2460
+ icon: ReactNode;
2461
+ title: string;
2462
+ subtitle?: string;
2463
+ accentColor?: string;
2464
+ }) {
2465
+ const className = clsx(
2466
+ "relative flex w-full items-center gap-2 rounded-md text-left transition-colors",
2467
+ compact ? "px-2 py-1.5" : "px-2 py-2",
2468
+ active ? "selection selection-ring" : onClick && "hover:bg-theme-hover",
2469
+ !onClick && "cursor-default",
2470
+ );
2471
+ const inner = (
2472
+ <>
2473
+ {accentColor && (
2474
+ <span
2475
+ className="absolute inset-y-2 left-0 w-1 rounded-full"
2476
+ style={{ background: accentColor }}
2477
+ aria-hidden
2478
+ />
2479
+ )}
2480
+ {icon}
2481
+ <span className="min-w-0 flex-1">
2482
+ <span
2483
+ className={clsx(
2484
+ "block truncate text-sm",
2485
+ muted
2486
+ ? "text-theme-text-tertiary"
2487
+ : "font-medium text-theme-text-primary",
2488
+ )}
2489
+ >
2490
+ {title}
2491
+ </span>
2492
+ {subtitle && (
2493
+ <span className="block truncate text-[10px] uppercase tracking-wide text-theme-text-tertiary">
2494
+ {subtitle}
2495
+ </span>
2496
+ )}
2497
+ </span>
2498
+ </>
2499
+ );
2500
+ return onClick ? (
2501
+ <button
2502
+ type="button"
2503
+ onClick={onClick}
2504
+ onMouseEnter={onMouseEnter}
2505
+ className={className}
2506
+ >
2507
+ {inner}
2508
+ </button>
2509
+ ) : (
2510
+ <div onMouseEnter={onMouseEnter} className={className}>
2511
+ {inner}
2512
+ </div>
2513
+ );
2514
+ }
2515
+
2516
+ function useDismissablePopover<T extends HTMLElement>(
2517
+ open: boolean,
2518
+ setOpen: (open: boolean) => void,
2519
+ ) {
2520
+ const rootRef = useRef<T>(null);
2521
+ useEffect(() => {
2522
+ if (!open) return;
2523
+ const onDown = (e: MouseEvent) => {
2524
+ if (rootRef.current && !rootRef.current.contains(e.target as Node))
2525
+ setOpen(false);
2526
+ };
2527
+ const onKey = (e: KeyboardEvent) => {
2528
+ if (e.key === "Escape") setOpen(false);
2529
+ };
2530
+ document.addEventListener("mousedown", onDown);
2531
+ document.addEventListener("keydown", onKey);
2532
+ return () => {
2533
+ document.removeEventListener("mousedown", onDown);
2534
+ document.removeEventListener("keydown", onKey);
2535
+ };
2536
+ }, [open, setOpen]);
2537
+ return rootRef;
2538
+ }
2539
+
2540
+ function renderRuntime(
2541
+ workload: SelectedAppWorkload | undefined,
2542
+ renderWorkload: (workload: SelectedAppWorkload) => ReactNode,
2543
+ ): ReactNode {
2544
+ if (!workload) {
2545
+ return (
2546
+ <div className="rounded-md border border-dashed border-theme-border p-8 text-center text-sm text-theme-text-tertiary">
2547
+ This application has no inspectable workloads.
2548
+ </div>
2549
+ );
2550
+ }
2551
+ return (
2552
+ <div className="flex h-full min-h-0 flex-col">
2553
+ <div key={workloadKey(workload)} className="min-h-0 flex-1">
2554
+ {renderWorkload(workload)}
2555
+ </div>
2556
+ </div>
2557
+ );
2558
+ }
2559
+
2560
+ function restartWarning(
2561
+ workloads: AppWorkload[],
2562
+ ): { restarts: number; reason?: string; workload: string } | null {
2563
+ let worst: { restarts: number; reason?: string; workload: string } | null =
2564
+ null;
2565
+ for (const w of workloads) {
2566
+ const r = w.restarts ?? 0;
2567
+ if (r > 0 && (!worst || r > worst.restarts)) {
2568
+ worst = {
2569
+ restarts: r,
2570
+ reason: w.reason,
2571
+ workload: `${w.kind}/${w.name}`,
2572
+ };
2573
+ }
2574
+ }
2575
+ return worst;
2576
+ }
2577
+
2578
+ // EnvSwitcher — the Environment fact's interactive form when one app runs in
2579
+ // several environments. Up to MAX_INLINE_ENVS: inline pills (the at-a-glance
2580
+ // ladder). More: a picker popover with the full ladder-ordered list — same
2581
+ // affordance at 5 envs or 100. Always ends with the evidence chip
2582
+ // (AppIdentityTooltip) and, when a ranked lower env outruns a ranked higher one,
2583
+ // the amber lag chip.
2584
+ const MAX_INLINE_ENVS = 4;
2585
+
2586
+ function EnvSwitcher({
2587
+ identityKey,
2588
+ instances,
2589
+ activeKey,
2590
+ onSwitch,
2591
+ }: {
2592
+ identityKey: string;
2593
+ instances: AppIdentityInstance[];
2594
+ activeKey: string;
2595
+ onSwitch?: (appKey: string) => void;
2596
+ }) {
2597
+ const [open, setOpen] = useState(false);
2598
+ const rootRef = useDismissablePopover<HTMLDivElement>(open, setOpen);
2599
+
2600
+ const envInstances = useMemo(
2601
+ () => collapseIdentityInstances(instances, activeKey),
2602
+ [instances, activeKey],
2603
+ );
2604
+ const lag = appGroupLagMessage(envInstances);
2605
+ const active =
2606
+ envInstances.find((i) => i.appKey === activeKey) ??
2607
+ instances.find((i) => i.appKey === activeKey);
2608
+ const envCounts = useMemo(() => {
2609
+ const counts = new Map<string, number>();
2610
+ for (const inst of instances)
2611
+ counts.set(inst.env, (counts.get(inst.env) ?? 0) + 1);
2612
+ return counts;
2613
+ }, [instances]);
2614
+ const evidenceChip = (
2615
+ <Tooltip
2616
+ content={
2617
+ <AppIdentityTooltip
2618
+ identityKey={identityKey}
2619
+ members={instances.map((i) => ({
2620
+ name: i.name,
2621
+ env: i.env,
2622
+ confidence: i.confidence,
2623
+ evidence: i.evidence,
2624
+ }))}
2625
+ />
2626
+ }
2627
+ delay={150}
2628
+ >
2629
+ <span className="inline-flex cursor-default items-center rounded-sm bg-theme-hover px-1 py-px ring-1 ring-inset ring-theme-border">
2630
+ <Layers className="h-3 w-3 text-theme-text-tertiary" aria-hidden />
2631
+ </span>
2632
+ </Tooltip>
2633
+ );
2634
+ const lagChip = lag && (
2635
+ <span className={`${CHIP} ${CHIP_TONE.amber}`}>{lag}</span>
2636
+ );
2637
+
2638
+ if (envInstances.length <= MAX_INLINE_ENVS) {
2639
+ return (
2640
+ <span className="flex flex-wrap items-center gap-1">
2641
+ {instances.map((inst) => {
2642
+ const isActive = inst.appKey === activeKey;
2643
+ const duplicateEnv = (envCounts.get(inst.env) ?? 0) > 1;
2644
+ return (
2645
+ <Tooltip
2646
+ key={inst.appKey}
2647
+ content={`${inst.name}${inst.version ? ` · ${inst.version}` : ""}`}
2648
+ delay={150}
2649
+ >
2650
+ <button
2651
+ type="button"
2652
+ disabled={isActive}
2653
+ onClick={() => !isActive && onSwitch?.(inst.appKey)}
2654
+ className={clsx(
2655
+ "inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs ring-1 ring-inset transition-colors",
2656
+ isActive
2657
+ ? "selection selection-ring font-medium"
2658
+ : "bg-theme-surface ring-theme-border hover:bg-theme-hover",
2659
+ )}
2660
+ >
2661
+ <StatusDot tone={mapHealthToTone(inst.health)} />
2662
+ {inst.env}
2663
+ {duplicateEnv ? (
2664
+ <span className="max-w-24 truncate text-theme-text-tertiary">
2665
+ {inst.name}
2666
+ </span>
2667
+ ) : null}
2668
+ </button>
2669
+ </Tooltip>
2670
+ );
2671
+ })}
2672
+ {evidenceChip}
2673
+ {lagChip}
2674
+ </span>
2675
+ );
2676
+ }
2677
+
2678
+ return (
2679
+ <div ref={rootRef} className="relative flex items-center gap-1">
2680
+ <button
2681
+ type="button"
2682
+ onClick={() => setOpen((o) => !o)}
2683
+ aria-haspopup="listbox"
2684
+ aria-expanded={open}
2685
+ 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"
2686
+ >
2687
+ {active && <StatusDot tone={mapHealthToTone(active.health)} />}
2688
+ <span className="font-medium">{active?.env ?? "—"}</span>
2689
+ <span className="text-theme-text-tertiary">
2690
+ · {envInstances.length} environments
2691
+ </span>
2692
+ <ChevronDown
2693
+ className={clsx(
2694
+ "h-3 w-3 text-theme-text-tertiary transition-transform",
2695
+ open && "rotate-180",
2696
+ )}
2697
+ aria-hidden
2698
+ />
2699
+ </button>
2700
+ {evidenceChip}
2701
+ {lagChip}
2702
+ {open && (
2703
+ <div
2704
+ role="listbox"
2705
+ 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"
2706
+ >
2707
+ {instances.map((inst) => {
2708
+ const isActive = inst.appKey === activeKey;
2709
+ return (
2710
+ <button
2711
+ key={inst.appKey}
2712
+ type="button"
2713
+ role="option"
2714
+ aria-selected={isActive}
2715
+ onClick={() => {
2716
+ setOpen(false);
2717
+ if (!isActive) onSwitch?.(inst.appKey);
2718
+ }}
2719
+ className={clsx(
2720
+ "flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs",
2721
+ isActive
2722
+ ? "selection selection-ring"
2723
+ : "hover:bg-theme-hover",
2724
+ )}
2725
+ >
2726
+ <StatusDot tone={mapHealthToTone(inst.health)} />
2727
+ <span className="w-20 shrink-0 font-medium text-theme-text-primary">
2728
+ {inst.env}
2729
+ </span>
2730
+ <span className="min-w-0 flex-1 truncate text-theme-text-secondary">
2731
+ {inst.name}
2732
+ </span>
2733
+ {inst.version && (
2734
+ <span className="font-mono text-[10px] text-theme-text-tertiary">
2735
+ {midTruncate(inst.version, 18)}
2736
+ </span>
2737
+ )}
2738
+ </button>
2739
+ );
2740
+ })}
2741
+ </div>
2742
+ )}
2743
+ </div>
2744
+ );
678
2745
  }