@skyhook-io/k8s-ui 1.8.10 → 1.8.12

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 (168) hide show
  1. package/package.json +7 -6
  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 +697 -0
  6. package/src/components/applications/ApplicationDetail.tsx +3269 -474
  7. package/src/components/applications/ApplicationsView.tsx +62 -10
  8. package/src/components/applications/applications-view.test.tsx +47 -0
  9. package/src/components/applications/index.ts +1 -1
  10. package/src/components/audit/AuditAlerts.tsx +14 -10
  11. package/src/components/checks/ChecksView.tsx +2 -3
  12. package/src/components/checks/severity.ts +14 -10
  13. package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -2
  14. package/src/components/compare/CompareResourcePicker.tsx +2 -2
  15. package/src/components/gitops/GitOpsDetailLayout.tsx +13 -3
  16. package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
  17. package/src/components/gitops/GitOpsTableView.tsx +13 -6
  18. package/src/components/gitops/ManagedResourcesList.tsx +8 -11
  19. package/src/components/gitops/SyncCountdown.tsx +26 -17
  20. package/src/components/gitops/SyncOptionsDialog.test.ts +48 -0
  21. package/src/components/gitops/SyncOptionsDialog.tsx +190 -46
  22. package/src/components/gitops/insights/ArgoResourceDiff.tsx +172 -0
  23. package/src/components/gitops/insights/GitOpsInsightViews.tsx +474 -121
  24. package/src/components/gitops/insights/index.ts +1 -0
  25. package/src/components/gitops/insights/insights-helpers.test.ts +102 -1
  26. package/src/components/gitops/insights/insights-helpers.ts +74 -1
  27. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +10 -2
  28. package/src/components/issues/IssuesView.tsx +91 -52
  29. package/src/components/issues/ResourceIssuesSection.test.tsx +39 -6
  30. package/src/components/issues/ResourceIssuesSection.tsx +46 -144
  31. package/src/components/issues/index.ts +3 -0
  32. package/src/components/issues/issue-timing.ts +53 -0
  33. package/src/components/issues/issues.test.ts +102 -1
  34. package/src/components/issues/severity.ts +3 -0
  35. package/src/components/logs/LogCore.tsx +14 -4
  36. package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
  37. package/src/components/logs/useLogStream.ts +8 -1
  38. package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
  39. package/src/components/resources/ResourcesSidebar.test.tsx +71 -0
  40. package/src/components/resources/ResourcesSidebar.tsx +46 -46
  41. package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
  42. package/src/components/resources/ResourcesView.tsx +233 -51
  43. package/src/components/resources/renderers/AWSMachineRenderer.tsx +3 -2
  44. package/src/components/resources/renderers/AWSManagedClusterRenderer.tsx +3 -2
  45. package/src/components/resources/renderers/AWSManagedControlPlaneRenderer.tsx +3 -2
  46. package/src/components/resources/renderers/AWSManagedMachinePoolRenderer.tsx +3 -2
  47. package/src/components/resources/renderers/AzureMachineRenderer.tsx +3 -2
  48. package/src/components/resources/renderers/AzureManagedControlPlaneRenderer.tsx +3 -2
  49. package/src/components/resources/renderers/AzureManagedMachinePoolRenderer.tsx +3 -2
  50. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
  51. package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +3 -2
  52. package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +3 -2
  53. package/src/components/resources/renderers/CAPIMachineHealthCheckRenderer.tsx +3 -2
  54. package/src/components/resources/renderers/CAPIMachinePoolRenderer.tsx +3 -2
  55. package/src/components/resources/renderers/CAPIMachineRenderer.tsx +3 -2
  56. package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +3 -2
  57. package/src/components/resources/renderers/CertificateRenderer.tsx +3 -2
  58. package/src/components/resources/renderers/CertificateRequestRenderer.tsx +3 -2
  59. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
  60. package/src/components/resources/renderers/CompositeRenderer.tsx +3 -2
  61. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
  62. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +3 -2
  63. package/src/components/resources/renderers/GCPMachineRenderer.tsx +3 -2
  64. package/src/components/resources/renderers/GCPManagedControlPlaneRenderer.tsx +3 -2
  65. package/src/components/resources/renderers/GCPManagedMachinePoolRenderer.tsx +3 -2
  66. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +7 -3
  67. package/src/components/resources/renderers/GatewayClassRenderer.tsx +3 -2
  68. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +7 -3
  69. package/src/components/resources/renderers/JobRenderer.tsx +3 -1
  70. package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +3 -2
  71. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +3 -2
  72. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +3 -2
  73. package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +3 -2
  74. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +3 -2
  75. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +3 -2
  76. package/src/components/resources/renderers/PVCRenderer.tsx +3 -2
  77. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
  78. package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
  79. package/src/components/resources/renderers/SealedSecretRenderer.tsx +3 -2
  80. package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
  81. package/src/components/resources/renderers/SecretStoreRenderer.tsx +3 -2
  82. package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
  83. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +7 -3
  84. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
  85. package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
  86. package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
  87. package/src/components/resources/renderers/index.ts +1 -0
  88. package/src/components/resources/resource-utils-argo.test.ts +18 -1
  89. package/src/components/resources/resource-utils-argo.ts +8 -0
  90. package/src/components/resources/resource-utils-capi.ts +1 -1
  91. package/src/components/resources/resource-utils.ts +31 -0
  92. package/src/components/shared/DetailShell.tsx +2 -2
  93. package/src/components/shared/ResourceActionsBar.tsx +142 -111
  94. package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
  95. package/src/components/shared/UnifiedDiff.tsx +51 -0
  96. package/src/components/shared/index.ts +1 -0
  97. package/src/components/timeline/TimelineList.tsx +375 -354
  98. package/src/components/timeline/TimelineStrip.test.tsx +194 -0
  99. package/src/components/timeline/TimelineStrip.tsx +836 -0
  100. package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
  101. package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
  102. package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
  103. package/src/components/timeline/TimelineToolbar.tsx +759 -0
  104. package/src/components/timeline/TimelineToolbarState.test.tsx +88 -0
  105. package/src/components/timeline/index.ts +23 -0
  106. package/src/components/timeline/scrubber-math.test.ts +235 -0
  107. package/src/components/timeline/scrubber-math.ts +249 -0
  108. package/src/components/timeline/shared.test.tsx +219 -0
  109. package/src/components/timeline/shared.tsx +232 -447
  110. package/src/components/timeline/timeline-filters.test.ts +196 -0
  111. package/src/components/timeline/timeline-filters.ts +180 -0
  112. package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
  113. package/src/components/timeline/timeline-lane-sort.ts +77 -0
  114. package/src/components/timeline/timeline-live.test.ts +131 -0
  115. package/src/components/timeline/timeline-live.ts +77 -0
  116. package/src/components/topology/GroupNode.tsx +9 -2
  117. package/src/components/topology/K8sResourceNode.test.ts +11 -0
  118. package/src/components/topology/K8sResourceNode.tsx +387 -243
  119. package/src/components/topology/TopologyBreadcrumb.tsx +30 -0
  120. package/src/components/topology/TopologyControls.tsx +3 -1
  121. package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
  122. package/src/components/topology/TopologyGraph.tsx +149 -90
  123. package/src/components/topology/TopologyOverlayBar.tsx +30 -0
  124. package/src/components/topology/TopologySearch.tsx +25 -17
  125. package/src/components/topology/index.ts +2 -0
  126. package/src/components/topology/layout.ts +10 -2
  127. package/src/components/topology/topology.css +0 -92
  128. package/src/components/ui/Badge.tsx +6 -1
  129. package/src/components/ui/CodeViewer.tsx +2 -2
  130. package/src/components/ui/Collapse.tsx +59 -0
  131. package/src/components/ui/ConfirmDialog.tsx +7 -3
  132. package/src/components/ui/DialogPortal.tsx +31 -9
  133. package/src/components/ui/Input.tsx +24 -0
  134. package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
  135. package/src/components/ui/MultiSelectPicker.tsx +167 -0
  136. package/src/components/ui/PaneLoader.tsx +10 -2
  137. package/src/components/ui/ResourceBar.tsx +68 -25
  138. package/src/components/ui/SearchBox.tsx +18 -4
  139. package/src/components/ui/SearchPillInput.tsx +2 -2
  140. package/src/components/ui/SelectMenu.tsx +81 -0
  141. package/src/components/ui/drawer-components.tsx +48 -32
  142. package/src/components/ui/index.ts +6 -0
  143. package/src/components/ui/severity-tone.ts +11 -4
  144. package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
  145. package/src/components/workload/WorkloadView.tsx +2706 -475
  146. package/src/components/workload/index.ts +1 -1
  147. package/src/theme/tailwind-theme.css +1 -0
  148. package/src/theme/variables.css +2 -1
  149. package/src/types/core.ts +57 -4
  150. package/src/types/gitops-insights.ts +69 -1
  151. package/src/types/gitops.ts +3 -3
  152. package/src/utils/application-history.test.ts +538 -0
  153. package/src/utils/application-history.ts +257 -0
  154. package/src/utils/application-topology.test.ts +198 -0
  155. package/src/utils/application-topology.ts +261 -0
  156. package/src/utils/applications.test.ts +201 -1
  157. package/src/utils/applications.ts +1182 -393
  158. package/src/utils/index.ts +3 -0
  159. package/src/utils/navigation.test.ts +32 -1
  160. package/src/utils/navigation.ts +70 -0
  161. package/src/utils/resource-hierarchy.test.ts +1024 -1
  162. package/src/utils/resource-hierarchy.ts +925 -267
  163. package/src/utils/resource-icons.ts +2 -0
  164. package/src/utils/topology-neighborhood.test.ts +202 -1
  165. package/src/utils/topology-neighborhood.ts +194 -13
  166. package/src/utils/workflow-execution.test.ts +556 -0
  167. package/src/utils/workflow-execution.ts +814 -0
  168. package/src/utils/workload-colors.ts +2 -3
@@ -1,17 +1,58 @@
1
- import { useMemo, useState, useCallback, useEffect, useRef, type ReactNode } from 'react'
2
- import { ArrowLeft, Boxes, Network, Layers, ChevronDown } from 'lucide-react'
3
- import { clsx } from 'clsx'
4
- import type { Topology, TopologyNode } from '../../types'
5
- import { StatusDot, mapHealthToTone } from '../ui/status-tone'
6
- import { Tooltip } from '../ui/Tooltip'
7
- import { TopologyGraph } from '../topology/TopologyGraph'
8
- import { pluralize } from '../../utils/pluralize'
9
- import { kindToPlural, apiVersionToGroup } from '../../utils/navigation'
10
- import { tagWorkloadOwnership, seedNodeIds, ownershipOf, workloadKey, type NeighborhoodSeed } from '../../utils/topology-neighborhood'
11
- import { workloadHue, NEUTRAL_OWNER, type WorkloadFocus } from '../../utils/workload-colors'
1
+ import {
2
+ useMemo,
3
+ useState,
4
+ useCallback,
5
+ useEffect,
6
+ useRef,
7
+ type ReactNode,
8
+ } from "react";
9
+ import {
10
+ AlertTriangle,
11
+ Activity,
12
+ ArrowLeft,
13
+ Boxes,
14
+ ChevronDown,
15
+ Clock3,
16
+ DollarSign,
17
+ ExternalLink,
18
+ GitCommit,
19
+ Layers,
20
+ Radio,
21
+ RefreshCw,
22
+ Search,
23
+ } from "lucide-react";
24
+ import { clsx } from "clsx";
25
+ import type { ResourceRef, Topology, TopologyNode } from "../../types";
26
+ import argoCdLogo from "../../assets/gitops/argocd.png";
27
+ import fluxLogo from "../../assets/gitops/flux.svg";
28
+ import { StatusDot, mapHealthToTone } from "../ui/status-tone";
29
+ import { Tooltip } from "../ui/Tooltip";
30
+ import { EmptyState } from "../ui/EmptyState";
31
+ import { ResourceRefBadge } from "../ui/drawer-components";
32
+ import { TopologyGraph } from "../topology/TopologyGraph";
33
+ import { pluralize } from "../../utils/pluralize";
34
+ import { kindToPlural, refToSelectedResource } from "../../utils/navigation";
35
+ import {
36
+ batchRunParentNodes,
37
+ tagWorkloadOwnership,
38
+ seedNodeIds,
39
+ ownershipOf,
40
+ workloadKey,
41
+ type NeighborhoodSeed,
42
+ } from "../../utils/topology-neighborhood";
43
+ import {
44
+ workloadHue,
45
+ NEUTRAL_OWNER,
46
+ type WorkloadFocus,
47
+ } from "../../utils/workload-colors";
48
+ import { getTopologyIcon } from "../../utils/resource-icons";
49
+ import { getKindLabel } from "../../utils/api-resources";
12
50
  import {
13
51
  type AppRow,
52
+ type AppHistory,
53
+ type AppSourceRef,
14
54
  type AppWorkload,
55
+ type AppBatchActivity,
15
56
  type AppHealth,
16
57
  CHIP,
17
58
  CHIP_TONE,
@@ -23,197 +64,433 @@ import {
23
64
  identityEnvInferred,
24
65
  workloadClassOf,
25
66
  classCompositionOf,
67
+ batchSignalForApp,
68
+ batchActivityForApp,
69
+ batchRuntimeForApp,
70
+ applicationDisplayHealth,
71
+ sourceReportedHealth,
72
+ sourceSyncHealth,
73
+ servingReadiness,
26
74
  worstHealth,
27
75
  appGroupLagMessage,
28
76
  compareVersions,
29
- } from '../../utils/applications'
30
- import { PaneLoader } from '../ui/PaneLoader'
31
- import { midTruncate } from '../../utils/format'
32
- import { VersionTooltip, AppIdentityTooltip } from './AppTooltips'
33
- import { ProvenanceBadge, ClassBadge, CategoryChip, VersionInfo } from './AppChips'
34
- import { ReadyBar } from './ReadyBar'
35
-
36
- // ApplicationDetail — pure single-cluster detail shell. Owns the title row
37
- // (icon, name, provenance/ungrouped chip, add-on/mixed chip, class badge,
38
- // health pill) + a context strip (Environment · Namespace · Ready · Version) +
39
- // a workload selector (only when >1 workload). The embedded WorkloadView is
40
- // injected by the host via the `renderWorkload` render-prop, keyed to the
41
- // selected workload — the shell never touches data hooks.
42
- //
43
- // Count-adaptive landing: a multi-workload app with a topology lands on the
44
- // app-level graph (the whole app's neighborhood) and drills into a workload's
45
- // runtime on node/pill click. A single-workload app (or no topology) goes
46
- // straight to the workload runtime — a graph of one Deployment + a Service
47
- // adds nothing.
48
-
49
- export type SelectedAppWorkload = NeighborhoodSeed
77
+ appSourceLabel,
78
+ overlayProvenance,
79
+ } from "../../utils/applications";
80
+ import { PaneLoader } from "../ui/PaneLoader";
81
+ import { midTruncate } from "../../utils/format";
82
+ import { VersionTooltip, AppIdentityTooltip } from "./AppTooltips";
83
+ import {
84
+ ProvenanceBadge,
85
+ ClassBadge,
86
+ CategoryChip,
87
+ VersionInfo,
88
+ BatchSignalChip,
89
+ } from "./AppChips";
90
+ import { ReadyBar } from "./ReadyBar";
91
+ import {
92
+ collapseStableReplicaSets,
93
+ layerDeploymentInventory,
94
+ topologyGroup,
95
+ type DeploymentInventory,
96
+ type DeploymentTopologyLayer,
97
+ } from "../../utils/application-topology";
98
+ import {
99
+ buildApplicationHistoryItems,
100
+ type ApplicationHistoryCategory,
101
+ type ApplicationHistoryItem,
102
+ type ApplicationHistoryRange,
103
+ } from "../../utils/application-history";
104
+
105
+ // ApplicationDetail owns the application chrome and scope switcher. The selected
106
+ // scope decides the one tab row shown in the detail pane: app scope gets
107
+ // Overview/Topology/History; workload scope delegates to the host's WorkloadView.
108
+
109
+ export type SelectedAppWorkload = NeighborhoodSeed;
50
110
 
51
111
  /** One env instance in the identity switcher — a sibling app row's digest. */
52
112
  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
113
+ appKey: string;
114
+ name: string;
115
+ env: string;
116
+ health: AppHealth;
117
+ version?: string;
118
+ confidence: string;
119
+ evidence: string;
120
+ count?: number;
61
121
  }
62
122
 
63
123
  /** Workload selection is either fully controlled (key + callback, the host
64
124
  * 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. */
125
+ * only half silently freezes the selector, so the types forbid it. `null` means
126
+ * the application itself is selected; a key (see `workloadKey`) selects a
127
+ * workload scope. */
67
128
  type SelectionProps =
68
- | { selectedWorkloadKey: string | null; onSelectWorkload: (key: string | null) => void }
69
- | { selectedWorkloadKey?: undefined; onSelectWorkload?: undefined }
129
+ | {
130
+ selectedWorkloadKey: string | null;
131
+ onSelectWorkload: (
132
+ key: string | null,
133
+ options?: AppWorkloadSelectionOptions,
134
+ ) => void;
135
+ }
136
+ | { selectedWorkloadKey?: undefined; onSelectWorkload?: undefined };
137
+
138
+ export interface AppWorkloadSelectionOptions {
139
+ tab?: string;
140
+ }
141
+
142
+ type CanonicalApplicationView = "overview" | "topology" | "history";
143
+
144
+ export type ApplicationView = CanonicalApplicationView;
145
+
146
+ const DEFAULT_APPLICATION_VIEW: CanonicalApplicationView = "overview";
147
+
148
+ const APPLICATION_VIEWS: Array<{
149
+ id: CanonicalApplicationView;
150
+ label: string;
151
+ }> = [
152
+ { id: "overview", label: "Overview" },
153
+ { id: "topology", label: "Topology" },
154
+ { id: "history", label: "History" },
155
+ ];
156
+
157
+ function canonicalApplicationView(
158
+ view?: ApplicationView,
159
+ ): CanonicalApplicationView {
160
+ return view ?? DEFAULT_APPLICATION_VIEW;
161
+ }
162
+
163
+ type ViewProps =
164
+ | {
165
+ selectedView: ApplicationView;
166
+ onSelectView: (view: ApplicationView) => void;
167
+ }
168
+ | { selectedView?: undefined; onSelectView?: undefined };
70
169
 
71
170
  export type ApplicationDetailProps = {
72
- app: AppRow
73
- onBack: () => void
171
+ app: AppRow;
172
+ onBack: () => void;
74
173
  /** 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
174
+ renderWorkload: (workload: SelectedAppWorkload) => ReactNode;
175
+ /** Optional host-rendered app-scope Cost view. When absent, no Cost tab is shown. */
176
+ renderCostView?: (props: {
177
+ app: AppRow;
178
+ workloads: AppWorkload[];
179
+ onSelectWorkload: (
180
+ workload: AppWorkload,
181
+ options?: AppWorkloadSelectionOptions,
182
+ ) => void;
183
+ }) => ReactNode;
184
+ costViewSelected?: boolean;
185
+ onSelectCostView?: () => void;
186
+ /** Render host-scoped operational issues for the app overview. */
187
+ renderOverviewIssues?: () => ReactNode;
188
+ /** Whether the host-scoped issue surface contains current issues. */
189
+ hasOverviewIssues?: boolean;
190
+ /** Resources-view topology spanning the app's namespaces. When present, it
191
+ * powers the application Topology view and workload hover focus. */
192
+ topology?: Topology;
79
193
  /** True while the host's topology fetch is in flight. Without it, a
80
- * multi-workload landing briefly mounts the first workload's runtime (and
81
- * fires its data fetches) before jumping to the graph. */
82
- topologyLoading?: boolean
194
+ * multi-workload app can briefly show an empty topology while topology loads. */
195
+ topologyLoading?: boolean;
196
+ /** Exact resources reported by the app's Argo CD, Flux, or Helm deployment source. */
197
+ deploymentInventory?: DeploymentInventory;
83
198
  /** Open a related (non-workload) resource clicked in the app graph. */
84
- onNavigateToResource?: (resource: { kind: string; namespace: string; name: string; group?: string }) => void
199
+ onNavigateToResource?: (resource: {
200
+ kind: string;
201
+ namespace: string;
202
+ name: string;
203
+ group?: string;
204
+ }) => void;
205
+ /** Open a generated Job or Workflow in the run history of its parent workload. */
206
+ onSelectWorkloadRun?: (
207
+ workload: SelectedAppWorkload,
208
+ run: TopologyNode,
209
+ ) => void;
85
210
  /** App identity siblings (this instance included, ladder-ordered) — turns the
86
211
  * context strip's Environment fact into a switcher. Identity grouping is
87
212
  * classification, not an address: it switches between REAL instances, never
88
213
  * an aggregate page. */
89
- identityInstances?: AppIdentityInstance[] | null
214
+ identityInstances?: AppIdentityInstance[] | null;
90
215
  /** Switch to a sibling instance (host swaps ?app= and, when it can match
91
216
  * the current workload in the target, preserves ?workload= + ?tab=). */
92
- onSwitchInstance?: (appKey: string) => void
217
+ onSwitchInstance?: (appKey: string) => void;
93
218
  /** Env tokens the cluster proved (the list derives the same set) — keeps a
94
219
  * ungrouped app's Environment fact consistent between list and detail. */
95
- discoveredEnvs?: ReadonlySet<string>
220
+ discoveredEnvs?: ReadonlySet<string>;
96
221
  /** Which instance in `identityInstances` is active, by its `appKey`. Defaults
97
222
  * to `app.key` — correct for the OSS case, where each instance IS a distinct
98
223
  * app row keyed the same way. A host that keys instances on something other
99
224
  * than the app key (e.g. the Cloud fleet keys per cluster, while `app.key`
100
225
  * must stay the logical app key for provenance) passes the active instance's
101
226
  * key here so the switcher marks it. */
102
- activeInstanceKey?: string
103
- } & SelectionProps
227
+ activeInstanceKey?: string;
228
+ history?: AppHistory;
229
+ historyLoading?: boolean;
230
+ historyItems?: ApplicationHistoryItem[];
231
+ historyRuntimeLoading?: boolean;
232
+ historyRuntimeError?: boolean;
233
+ historyMode?: "local" | "retained";
234
+ historyRange?: ApplicationHistoryRange;
235
+ historyRangeOptions?: Array<{
236
+ value: ApplicationHistoryRange;
237
+ label: string;
238
+ }>;
239
+ historyCoverageRecordCount?: number;
240
+ historyRuntimeLimited?: boolean;
241
+ onHistoryRangeChange?: (range: ApplicationHistoryRange) => void;
242
+ onOpenTimeline?: (timestamp?: string) => void;
243
+ onOpenSource?: (source: AppSourceRef) => void;
244
+ } & SelectionProps &
245
+ ViewProps;
104
246
 
105
- function ContextFact({ label, children }: { label: string; children: ReactNode }) {
247
+ function ContextFact({
248
+ label,
249
+ children,
250
+ }: {
251
+ label: string;
252
+ children: ReactNode;
253
+ }) {
106
254
  return (
107
255
  <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>
256
+ <span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary">
257
+ {label}
258
+ </span>
259
+ <span className="min-w-0 truncate text-xs text-theme-text-secondary">
260
+ {children}
261
+ </span>
110
262
  </div>
111
- )
263
+ );
112
264
  }
113
265
 
114
- function collapseIdentityInstances(instances: AppIdentityInstance[], activeKey: string): AppIdentityInstance[] {
115
- const byEnv = new Map<string, AppIdentityInstance[]>()
266
+ function collapseIdentityInstances(
267
+ instances: AppIdentityInstance[],
268
+ activeKey: string,
269
+ ): AppIdentityInstance[] {
270
+ const byEnv = new Map<string, AppIdentityInstance[]>();
116
271
  for (const inst of instances) {
117
- byEnv.set(inst.env, [...(byEnv.get(inst.env) ?? []), inst])
272
+ byEnv.set(inst.env, [...(byEnv.get(inst.env) ?? []), inst]);
118
273
  }
119
274
  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]
275
+ const active = group.find((i) => i.appKey === activeKey);
276
+ const newest = group.reduce<AppIdentityInstance | undefined>(
277
+ (best, inst) => {
278
+ if (!best) return inst;
279
+ return compareDefinedVersions(inst.version, best.version) === 1
280
+ ? inst
281
+ : best;
282
+ },
283
+ undefined,
284
+ );
285
+ const display = active ?? newest ?? group[0];
126
286
  return {
127
287
  ...display,
128
288
  health: worstHealth(group.map((inst) => inst.health)),
129
289
  version: newest?.version,
130
290
  count: group.length,
131
- }
132
- })
291
+ };
292
+ });
133
293
  }
134
294
 
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
295
+ function compareDefinedVersions(
296
+ a: string | undefined,
297
+ b: string | undefined,
298
+ ): number {
299
+ if (!a && !b) return 0;
300
+ if (a && !b) return 1;
301
+ if (!a || !b) return -1;
302
+ return compareVersions(a, b) ?? 0;
140
303
  }
141
304
 
142
- export function ApplicationDetail({ app, onBack, renderWorkload, topology, topologyLoading, onNavigateToResource, identityInstances, onSwitchInstance, discoveredEnvs, activeInstanceKey, selectedWorkloadKey, onSelectWorkload }: ApplicationDetailProps) {
305
+ export function ApplicationDetail({
306
+ app,
307
+ onBack,
308
+ renderWorkload,
309
+ renderCostView,
310
+ costViewSelected,
311
+ onSelectCostView,
312
+ renderOverviewIssues,
313
+ hasOverviewIssues,
314
+ topology,
315
+ topologyLoading,
316
+ deploymentInventory,
317
+ onNavigateToResource,
318
+ onSelectWorkloadRun,
319
+ identityInstances,
320
+ onSwitchInstance,
321
+ discoveredEnvs,
322
+ activeInstanceKey,
323
+ history,
324
+ historyLoading,
325
+ historyItems,
326
+ historyRuntimeLoading,
327
+ historyRuntimeError,
328
+ historyMode,
329
+ historyRange,
330
+ historyRangeOptions,
331
+ historyCoverageRecordCount,
332
+ historyRuntimeLimited,
333
+ onHistoryRangeChange,
334
+ onOpenTimeline,
335
+ onOpenSource,
336
+ selectedWorkloadKey,
337
+ onSelectWorkload,
338
+ selectedView,
339
+ onSelectView,
340
+ }: ApplicationDetailProps) {
143
341
  // Stable order regardless of API ordering: rail rows and the per-workload
144
342
  // color assignment both follow this array, so an order flap between
145
343
  // refetches must not reshuffle rows or reassign a workload's hue.
146
344
  const workloads = useMemo(
147
345
  () =>
148
346
  [...(app.workloads ?? [])].sort(
149
- (a, b) => a.name.localeCompare(b.name) || a.kind.localeCompare(b.kind) || a.namespace.localeCompare(b.namespace),
347
+ (a, b) =>
348
+ a.name.localeCompare(b.name) ||
349
+ a.kind.localeCompare(b.kind) ||
350
+ a.namespace.localeCompare(b.namespace),
150
351
  ),
151
352
  [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)
353
+ );
354
+ const workloadClass = workloadClassOf(app.workload_class);
355
+ const batchRuntime = batchRuntimeForApp(app);
356
+ const overall = applicationDisplayHealth(app);
357
+ const verdictTone = HEALTH_META[overall].pill;
358
+ const verdictLabel =
359
+ workloadClass === "job" && overall === batchRuntime.health
360
+ ? batchRuntime.label
361
+ : HEALTH_META[overall].label;
362
+ const versions = useMemo(
363
+ () => Array.from(new Set((app.versions || []).filter(Boolean))),
364
+ [app.versions],
365
+ );
366
+ const { ready, desired } = servingReadiness(workloads);
367
+ const restartSignal = restartWarning(workloads);
368
+ const batchSignal = workloadClass === "job" ? null : batchSignalForApp(app);
161
369
  // Resolve namespace the same way the list does (the workloads' shared
162
370
  // namespace) so env/namespace match across list and detail. Multi-namespace
163
371
  // 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
372
+ const namespace = namespaceOf(app);
373
+ const namespaces = namespacesOf(app);
374
+ const resolvedEnv = resolveEnv(undefined, namespace, discoveredEnvs);
375
+ const env = app.identity?.env ?? resolvedEnv.env;
376
+ const inferred = app.identity
377
+ ? identityEnvInferred(app.identity)
378
+ : resolvedEnv.inferred;
379
+ const [internalView, setInternalView] = useState<CanonicalApplicationView>(
380
+ DEFAULT_APPLICATION_VIEW,
381
+ );
382
+ const [internalCostSelected, setInternalCostSelected] = useState(false);
383
+ const activeView = canonicalApplicationView(selectedView ?? internalView);
384
+ const costSelected = Boolean(
385
+ renderCostView && (costViewSelected ?? internalCostSelected),
386
+ );
387
+ const setView = useCallback(
388
+ (view: CanonicalApplicationView) => {
389
+ setInternalCostSelected(false);
390
+ if (onSelectView) onSelectView(view);
391
+ else setInternalView(view);
392
+ },
393
+ [onSelectView],
394
+ );
395
+ const selectCostView = useCallback(() => {
396
+ if (!renderCostView) return;
397
+ if (onSelectCostView) onSelectCostView();
398
+ else setInternalCostSelected(true);
399
+ }, [onSelectCostView, renderCostView]);
400
+ useEffect(() => {
401
+ if (selectedView === undefined) setInternalView(DEFAULT_APPLICATION_VIEW);
402
+ setInternalCostSelected(false);
403
+ }, [app.key, selectedView]);
404
+
405
+ const [internalSelected, setInternalSelected] = useState<string | null>(null);
406
+ const implicitSingleWorkloadKey =
407
+ workloads.length === 1 ? workloadKey(workloads[0]) : null;
408
+ const rawSelected =
409
+ selectedWorkloadKey !== undefined
410
+ ? selectedWorkloadKey
411
+ : (internalSelected ?? implicitSingleWorkloadKey);
180
412
  const setSelected = useCallback(
181
- (key: string | null) => (onSelectWorkload ? onSelectWorkload(key) : setInternalSelected(key)),
413
+ (key: string | null, options?: AppWorkloadSelectionOptions) =>
414
+ onSelectWorkload
415
+ ? onSelectWorkload(key, options)
416
+ : setInternalSelected(key),
182
417
  [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
418
+ );
419
+ const selectedWorkload = rawSelected
420
+ ? workloads.find((w) => workloadKey(w) === rawSelected)
421
+ : undefined;
422
+ const singleWorkloadScope = workloads.length === 1 && !!selectedWorkload;
189
423
  useEffect(() => {
190
- if (selectedWorkloadKey !== undefined && selectedWorkloadKey !== null && !selectedWorkload) {
191
- onSelectWorkload?.(null)
424
+ if (
425
+ selectedWorkloadKey !== undefined &&
426
+ selectedWorkloadKey !== null &&
427
+ !selectedWorkload
428
+ ) {
429
+ onSelectWorkload?.(null);
192
430
  }
193
- }, [selectedWorkloadKey, selectedWorkload, onSelectWorkload])
431
+ }, [selectedWorkloadKey, selectedWorkload, onSelectWorkload]);
194
432
  useEffect(() => {
195
- if (selectedWorkloadKey === undefined) setInternalSelected(null)
196
- }, [app.key, selectedWorkloadKey])
433
+ if (selectedWorkloadKey === undefined) setInternalSelected(null);
434
+ }, [app.key, selectedWorkloadKey]);
197
435
 
198
436
  // Hover-focus: the workload (or NEUTRAL_OWNER) whose nodes should stay lit
199
437
  // while the rest of the graph dims. Driven by the rail and, reciprocally, by
200
438
  // hovering a node.
201
- const [focusedOwnerId, setFocusedOwnerId] = useState<WorkloadFocus>(null)
439
+ const [focusedOwnerId, setFocusedOwnerId] = useState<WorkloadFocus>(null);
440
+ const [expandedReplicaSetOwners, setExpandedReplicaSetOwners] = useState<
441
+ Set<string>
442
+ >(new Set());
443
+ useEffect(() => setExpandedReplicaSetOwners(new Set()), [app.key]);
444
+ const toggleReplicaSets = useCallback((ownerID: string) => {
445
+ setExpandedReplicaSetOwners((current) => {
446
+ const next = new Set(current);
447
+ if (next.has(ownerID)) next.delete(ownerID);
448
+ else next.add(ownerID);
449
+ return next;
450
+ });
451
+ }, []);
202
452
 
203
453
  const appSeeds = useMemo(
204
- () => workloads.map((w) => ({ kind: w.kind, namespace: w.namespace, name: w.name })),
454
+ () =>
455
+ workloads.map((w) => ({
456
+ kind: w.kind,
457
+ namespace: w.namespace,
458
+ name: w.name,
459
+ group: w.group,
460
+ })),
205
461
  [workloads],
206
- )
462
+ );
207
463
  // Neighborhood subgraph + per-workload color/ownership tagging in one pass.
464
+ const composedTopology = useMemo(
465
+ () =>
466
+ topology
467
+ ? collapseStableReplicaSets(topology, expandedReplicaSetOwners)
468
+ : null,
469
+ [topology, expandedReplicaSetOwners],
470
+ );
208
471
  const ownership = useMemo(
209
- () => (topology ? tagWorkloadOwnership(topology, appSeeds) : null),
210
- [topology, appSeeds],
211
- )
212
- const appGraph = ownership?.topology ?? null
472
+ () =>
473
+ composedTopology
474
+ ? tagWorkloadOwnership(composedTopology, appSeeds)
475
+ : null,
476
+ [composedTopology, appSeeds],
477
+ );
478
+ const appGraph = ownership?.topology ?? null;
479
+ const deploymentLayer = useMemo(
480
+ () =>
481
+ appGraph && deploymentInventory && app.sourceRef
482
+ ? layerDeploymentInventory(
483
+ appGraph,
484
+ deploymentInventory,
485
+ sourceInventoryLabel(app.sourceRef),
486
+ )
487
+ : null,
488
+ [appGraph, deploymentInventory, app.sourceRef],
489
+ );
213
490
  const appGraphFocusId = useMemo(
214
491
  () => (topology ? seedNodeIds(topology, appSeeds)[0] : undefined),
215
492
  [topology, appSeeds],
216
- )
493
+ );
217
494
 
218
495
  // Hovering a node lights up its owning workload (and the rail row). An
219
496
  // unowned node related to exactly ONE workload (a GitOps manager over a
@@ -221,65 +498,139 @@ export function ApplicationDetail({ app, onBack, renderWorkload, topology, topol
221
498
  // focus. Truly shared nodes clear instead of dimming everything.
222
499
  const handleNodeHover = useCallback((node: TopologyNode | null) => {
223
500
  if (!node) {
224
- setFocusedOwnerId(null)
225
- return
501
+ setFocusedOwnerId(null);
502
+ return;
226
503
  }
227
- const stamp = ownershipOf(node.data)
228
- setFocusedOwnerId(stamp.ownerWorkloadId ?? (stamp.focusWorkloadIds.length === 1 ? stamp.focusWorkloadIds[0] : NEUTRAL_OWNER))
229
- }, [])
504
+ const stamp = ownershipOf(node.data);
505
+ setFocusedOwnerId(
506
+ stamp.ownerWorkloadId ??
507
+ (stamp.focusWorkloadIds.length === 1
508
+ ? stamp.focusWorkloadIds[0]
509
+ : NEUTRAL_OWNER),
510
+ );
511
+ }, []);
230
512
 
231
513
  // A node click in the app graph either drills into one of the app's workloads
232
514
  // (its runtime) or opens a related resource (Service/config/…) via the host.
233
515
  const handleAppNodeClick = useCallback(
234
516
  (node: TopologyNode) => {
235
- const ns = (node.data?.namespace as string) || ''
236
- const match = workloads.find((w) => w.kind === node.kind && w.name === node.name && w.namespace === ns)
517
+ if (node.data?.sourceInventoryGroup && app.sourceRef) {
518
+ onOpenSource?.(app.sourceRef);
519
+ return;
520
+ }
521
+ const ns = (node.data?.namespace as string) || "";
522
+ const match = workloads.find(
523
+ (w) =>
524
+ w.kind === node.kind && w.name === node.name && w.namespace === ns,
525
+ );
237
526
  if (match) {
238
- setSelected(workloadKey(match))
239
- return
527
+ setSelected(workloadKey(match));
528
+ return;
529
+ }
530
+ const parents = appGraph ? batchRunParentNodes(appGraph, node) : [];
531
+ const parentWorkload = parents.flatMap((parent) => {
532
+ const parentNamespace = (parent.data?.namespace as string) || "";
533
+ const workload = workloads.find(
534
+ (candidate) =>
535
+ candidate.kind === parent.kind &&
536
+ candidate.name === parent.name &&
537
+ candidate.namespace === parentNamespace,
538
+ );
539
+ return workload ? [workload] : [];
540
+ })[0];
541
+ if (parentWorkload && onSelectWorkloadRun) {
542
+ onSelectWorkloadRun(parentWorkload, node);
543
+ return;
240
544
  }
241
545
  onNavigateToResource?.({
242
546
  kind: kindToPlural(node.kind),
243
547
  namespace: ns,
244
548
  name: node.name,
245
- group: apiVersionToGroup(node.data?.apiVersion as string | undefined),
246
- })
549
+ group: topologyGroup(node),
550
+ });
247
551
  },
248
- [workloads, onNavigateToResource, setSelected],
249
- )
552
+ [
553
+ app.sourceRef,
554
+ appGraph,
555
+ workloads,
556
+ onNavigateToResource,
557
+ onOpenSource,
558
+ onSelectWorkloadRun,
559
+ setSelected,
560
+ ],
561
+ );
562
+
563
+ const appTopology = deploymentLayer?.topology ?? appGraph ?? null;
564
+ const appTopologyAvailable = !!appTopology && appTopology.nodes.length > 0;
565
+ const colorByWorkload = ownership?.colorByWorkload ?? null;
250
566
 
251
567
  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">
568
+ <div className="flex min-h-0 w-full flex-1 flex-col bg-theme-base">
569
+ <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">
570
+ <button
571
+ type="button"
572
+ onClick={onBack}
573
+ className="flex shrink-0 items-center gap-1.5 text-xs text-theme-text-tertiary hover:text-theme-text-primary"
574
+ >
256
575
  <ArrowLeft className="h-3.5 w-3.5" aria-hidden /> Applications
257
576
  </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}`}>
577
+ <span
578
+ className="hidden h-6 w-px bg-theme-border sm:block"
579
+ aria-hidden
580
+ />
581
+ <span
582
+ className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-md ring-1 ring-inset ${verdictTone}`}
583
+ >
260
584
  <Boxes className="h-4 w-4" aria-hidden />
261
585
  </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}`}>
586
+ <div className="flex min-w-0 flex-1 items-center gap-2">
587
+ <h1 className="min-w-0 truncate text-xl font-semibold text-theme-text-primary lg:text-2xl">
588
+ {app.name}
589
+ </h1>
590
+ {!singleWorkloadScope && (
591
+ <>
592
+ <span className="shrink-0 text-theme-text-tertiary">/</span>
593
+ <ApplicationScopeSelector
594
+ workloads={workloads}
595
+ selectedWorkload={selectedWorkload ?? null}
596
+ onSelect={setSelected}
597
+ onFocus={setFocusedOwnerId}
598
+ />
599
+ </>
600
+ )}
601
+ </div>
602
+ <div className="ml-auto flex shrink-0 flex-wrap items-center justify-end gap-2">
603
+ <span
604
+ className={`inline-flex items-center gap-2 rounded-md px-2.5 py-1 ring-1 ring-inset ${verdictTone}`}
605
+ >
268
606
  <StatusDot tone={mapHealthToTone(overall)} />
269
607
  <span className="text-sm font-semibold">{verdictLabel}</span>
270
608
  </span>
271
609
  {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')}
610
+ <Tooltip
611
+ content={`${restartSignal.workload} · ${pluralize(restartSignal.restarts, "restart")}`}
612
+ delay={150}
613
+ >
614
+ <span
615
+ className={`inline-flex items-center rounded-md px-2 py-1 text-xs font-semibold ring-1 ring-inset ${CHIP_TONE.amber}`}
616
+ >
617
+ Pod warning: {restartSignal.reason || "Restarts"} ·{" "}
618
+ {pluralize(restartSignal.restarts, "restart")}
275
619
  </span>
276
620
  </Tooltip>
277
621
  )}
278
622
  {/* Amber only on real skew (same image, different tags) — the context
279
623
  strip already covers the multi-image "N versions" case neutrally. */}
280
624
  {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>
625
+ <Tooltip
626
+ content={<VersionTooltip workloads={workloads} />}
627
+ delay={150}
628
+ >
629
+ <span
630
+ className={`inline-flex items-center rounded-md px-2 py-1 font-mono text-xs ring-1 ring-inset ${CHIP_TONE.amber}`}
631
+ >
632
+ {versions.length} versions
633
+ </span>
283
634
  </Tooltip>
284
635
  )}
285
636
  </div>
@@ -287,18 +638,46 @@ export function ApplicationDetail({ app, onBack, renderWorkload, topology, topol
287
638
 
288
639
  {/* Context strip */}
289
640
  <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">
641
+ <ProvenanceBadge
642
+ tier={app.tier}
643
+ appKey={app.key}
644
+ confidence={app.confidence}
645
+ />
646
+ <CategoryChip category={app.category} addonReason={app.addonReason} />
647
+ <ClassBadge
648
+ workloadClass={workloadClass}
649
+ composition={classCompositionOf(app)}
650
+ />
651
+ <BatchSignalChip signal={batchSignal} />
652
+ {singleWorkloadScope && selectedWorkload.name !== app.name && (
653
+ <ContextFact label="Workload">
654
+ <span className="font-mono">
655
+ {selectedWorkload.kind}/{selectedWorkload.name}
656
+ </span>
657
+ </ContextFact>
658
+ )}
290
659
  {identityInstances && identityInstances.length > 1 ? (
291
660
  // The Environment fact IS the switcher when this app runs in several
292
661
  // envs — prominent, in existing header space, no extra row. Inline
293
662
  // pills for a handful; a picker beyond that (scales to ~any count).
294
663
  <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} />
664
+ <span className="text-[10px] uppercase tracking-wide text-theme-text-tertiary">
665
+ Environment
666
+ </span>
667
+ <EnvSwitcher
668
+ identityKey={app.identity?.key ?? ""}
669
+ instances={identityInstances}
670
+ activeKey={activeInstanceKey ?? app.key}
671
+ onSwitch={onSwitchInstance}
672
+ />
297
673
  </div>
298
674
  ) : env ? (
299
675
  <ContextFact label="Environment">
300
676
  {inferred ? (
301
- <Tooltip content={`Inferred from namespace "${namespace || env}" — confirm with an environment label.`} delay={150}>
677
+ <Tooltip
678
+ content={`Inferred from namespace "${namespace || env}" — confirm with an environment label.`}
679
+ delay={150}
680
+ >
302
681
  <span className="italic">~{env}</span>
303
682
  </Tooltip>
304
683
  ) : (
@@ -312,14 +691,18 @@ export function ApplicationDetail({ app, onBack, renderWorkload, topology, topol
312
691
  </ContextFact>
313
692
  ) : namespaces.length > 1 ? (
314
693
  <ContextFact label="Namespaces">
315
- <Tooltip content={namespaces.join(', ')} delay={150}>
694
+ <Tooltip content={namespaces.join(", ")} delay={150}>
316
695
  <span>{namespaces.length} namespaces</span>
317
696
  </Tooltip>
318
697
  </ContextFact>
319
698
  ) : null}
320
- <ContextFact label="Ready">
321
- <ReadyBar ready={ready} desired={desired} width="w-16" />
322
- </ContextFact>
699
+ {workloadClass === "job" ? (
700
+ <ContextFact label="Runtime">{batchRuntime.label}</ContextFact>
701
+ ) : desired > 0 ? (
702
+ <ContextFact label="Ready">
703
+ <ReadyBar ready={ready} desired={desired} width="w-16" />
704
+ </ContextFact>
705
+ ) : null}
323
706
  {(app.appVersion || versions.length > 0) && (
324
707
  <ContextFact label="Version">
325
708
  <VersionInfo app={app} variant="fact" />
@@ -327,352 +710,2764 @@ export function ApplicationDetail({ app, onBack, renderWorkload, topology, topol
327
710
  )}
328
711
  </div>
329
712
 
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
713
+ <div className="min-h-0 flex-1 overflow-hidden bg-theme-base">
714
+ {selectedWorkload ? (
715
+ renderRuntime(selectedWorkload, renderWorkload)
716
+ ) : (
717
+ <ApplicationWorkspace
718
+ app={app}
719
+ activeView={activeView}
720
+ costSelected={costSelected}
721
+ onViewChange={setView}
722
+ onCostViewChange={selectCostView}
723
+ renderCostView={renderCostView}
336
724
  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)}
725
+ ready={ready}
726
+ desired={desired}
727
+ versions={versions}
728
+ topology={appTopology}
729
+ topologyLoading={topologyLoading}
730
+ deploymentLayer={deploymentLayer}
731
+ deploymentSource={app.sourceRef}
732
+ topologyAvailable={appTopologyAvailable}
733
+ focusNodeId={appGraphFocusId}
342
734
  focusedOwnerId={focusedOwnerId}
343
- onSelect={setSelected}
344
- onFocus={setFocusedOwnerId}
735
+ colorByWorkload={colorByWorkload}
736
+ onNodeClick={handleAppNodeClick}
737
+ onNodeHover={handleNodeHover}
738
+ onFocusWorkload={setFocusedOwnerId}
739
+ onSelectWorkload={(workload, options) =>
740
+ setSelected(workloadKey(workload), options)
741
+ }
742
+ onNavigateToResource={onNavigateToResource}
743
+ history={history}
744
+ historyLoading={historyLoading}
745
+ historyItems={historyItems}
746
+ historyRuntimeLoading={historyRuntimeLoading}
747
+ historyRuntimeError={historyRuntimeError}
748
+ historyMode={historyMode}
749
+ historyRange={historyRange}
750
+ historyRangeOptions={historyRangeOptions}
751
+ historyCoverageRecordCount={historyCoverageRecordCount}
752
+ historyRuntimeLimited={historyRuntimeLimited}
753
+ onHistoryRangeChange={onHistoryRangeChange}
754
+ onOpenTimeline={onOpenTimeline}
755
+ onOpenSource={onOpenSource}
756
+ onToggleReplicaSets={toggleReplicaSets}
757
+ renderOverviewIssues={renderOverviewIssues}
758
+ hasOverviewIssues={hasOverviewIssues}
345
759
  />
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
- )}
760
+ )}
761
+ </div>
374
762
  </div>
375
- )
763
+ );
376
764
  }
377
765
 
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({
766
+ function ApplicationWorkspace({
767
+ app,
768
+ activeView,
769
+ costSelected,
770
+ onViewChange,
771
+ onCostViewChange,
772
+ renderCostView,
383
773
  workloads,
384
- colorByWorkload,
385
- showAllEntry,
386
- selectedKey,
774
+ ready,
775
+ desired,
776
+ versions,
777
+ topology,
778
+ topologyLoading,
779
+ deploymentLayer,
780
+ deploymentSource,
781
+ topologyAvailable,
782
+ focusNodeId,
387
783
  focusedOwnerId,
388
- onSelect,
389
- onFocus,
784
+ colorByWorkload,
785
+ onNodeClick,
786
+ onNodeHover,
787
+ onFocusWorkload,
788
+ onSelectWorkload,
789
+ onNavigateToResource,
790
+ history,
791
+ historyLoading,
792
+ historyItems,
793
+ historyRuntimeLoading,
794
+ historyRuntimeError,
795
+ historyMode,
796
+ historyRange,
797
+ historyRangeOptions,
798
+ historyCoverageRecordCount,
799
+ historyRuntimeLimited,
800
+ onHistoryRangeChange,
801
+ onOpenTimeline,
802
+ onOpenSource,
803
+ onToggleReplicaSets,
804
+ renderOverviewIssues,
805
+ hasOverviewIssues,
390
806
  }: {
391
- workloads: AppWorkload[]
392
- colorByWorkload: Map<string, number> | null
393
- showAllEntry: boolean
394
- selectedKey: string | null
395
- focusedOwnerId: WorkloadFocus
396
- onSelect: (key: string | null) => void
397
- onFocus: (owner: WorkloadFocus) => void
807
+ app: AppRow;
808
+ activeView: CanonicalApplicationView;
809
+ costSelected: boolean;
810
+ onViewChange: (view: CanonicalApplicationView) => void;
811
+ onCostViewChange: () => void;
812
+ renderCostView?: ApplicationDetailProps["renderCostView"];
813
+ workloads: AppWorkload[];
814
+ ready: number;
815
+ desired: number;
816
+ versions: string[];
817
+ topology: Topology | null;
818
+ topologyLoading?: boolean;
819
+ deploymentLayer: DeploymentTopologyLayer | null;
820
+ deploymentSource?: AppSourceRef;
821
+ topologyAvailable: boolean;
822
+ focusNodeId?: string;
823
+ focusedOwnerId: WorkloadFocus;
824
+ colorByWorkload: Map<string, number> | null;
825
+ onNodeClick: (node: TopologyNode) => void;
826
+ onNodeHover: (node: TopologyNode | null) => void;
827
+ onFocusWorkload: (owner: WorkloadFocus) => void;
828
+ onSelectWorkload: (
829
+ workload: AppWorkload,
830
+ options?: AppWorkloadSelectionOptions,
831
+ ) => void;
832
+ onNavigateToResource?: (resource: {
833
+ kind: string;
834
+ namespace: string;
835
+ name: string;
836
+ group?: string;
837
+ }) => void;
838
+ history?: AppHistory;
839
+ historyLoading?: boolean;
840
+ historyItems?: ApplicationHistoryItem[];
841
+ historyRuntimeLoading?: boolean;
842
+ historyRuntimeError?: boolean;
843
+ historyMode?: "local" | "retained";
844
+ historyRange?: ApplicationHistoryRange;
845
+ historyRangeOptions?: Array<{
846
+ value: ApplicationHistoryRange;
847
+ label: string;
848
+ }>;
849
+ historyCoverageRecordCount?: number;
850
+ historyRuntimeLimited?: boolean;
851
+ onHistoryRangeChange?: (range: ApplicationHistoryRange) => void;
852
+ onOpenTimeline?: (timestamp?: string) => void;
853
+ onOpenSource?: (source: AppSourceRef) => void;
854
+ onToggleReplicaSets: (ownerID: string) => void;
855
+ renderOverviewIssues?: () => ReactNode;
856
+ hasOverviewIssues?: boolean;
398
857
  }) {
399
858
  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"
859
+ <div className="flex h-full min-h-0 flex-col">
860
+ <ApplicationViewTabs
861
+ activeView={activeView}
862
+ costSelected={costSelected}
863
+ costAvailable={Boolean(renderCostView)}
864
+ onChange={onViewChange}
865
+ onCostChange={onCostViewChange}
866
+ />
867
+ {costSelected && renderCostView && (
868
+ <div className="min-h-0 flex-1 overflow-y-auto px-4 py-4 sm:px-6">
869
+ {renderCostView({ app, workloads, onSelectWorkload })}
870
+ </div>
871
+ )}
872
+ {!costSelected && activeView === "overview" && (
873
+ <ApplicationOverview
874
+ app={app}
875
+ workloads={workloads}
876
+ ready={ready}
877
+ desired={desired}
878
+ versions={versions}
879
+ onSelectWorkload={onSelectWorkload}
880
+ onNavigateToResource={onNavigateToResource}
881
+ history={history}
882
+ onSelectHistory={() => onViewChange("history")}
883
+ onOpenSource={onOpenSource}
884
+ renderOverviewIssues={renderOverviewIssues}
885
+ hasOverviewIssues={hasOverviewIssues}
416
886
  />
417
887
  )}
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"
888
+ {!costSelected && activeView === "topology" && (
889
+ <ApplicationTopology
890
+ topology={topology}
891
+ loading={topologyLoading}
892
+ available={topologyAvailable}
893
+ workloads={workloads}
894
+ colorByWorkload={colorByWorkload}
895
+ focusNodeId={focusNodeId}
896
+ focusedOwnerId={focusedOwnerId}
897
+ onNodeClick={onNodeClick}
898
+ onNodeHover={onNodeHover}
899
+ onFocusWorkload={onFocusWorkload}
900
+ onSelectWorkload={onSelectWorkload}
901
+ deploymentLayer={deploymentLayer}
902
+ deploymentSource={deploymentSource}
903
+ onOpenSource={onOpenSource}
904
+ onToggleReplicaSets={onToggleReplicaSets}
905
+ />
906
+ )}
907
+ {!costSelected && activeView === "history" && (
908
+ <ApplicationHistoryView
909
+ history={history}
910
+ sourceLoading={historyLoading}
911
+ items={historyItems}
912
+ runtimeLoading={historyRuntimeLoading}
913
+ runtimeError={historyRuntimeError}
914
+ mode={historyMode}
915
+ range={historyRange}
916
+ rangeOptions={historyRangeOptions}
917
+ coverageRecordCount={historyCoverageRecordCount}
918
+ runtimeLimited={historyRuntimeLimited}
919
+ workloads={workloads}
920
+ onSelectWorkload={onSelectWorkload}
921
+ onNavigateToResource={onNavigateToResource}
922
+ onRangeChange={onHistoryRangeChange}
923
+ onOpenTimeline={onOpenTimeline}
924
+ onOpenSource={onOpenSource}
451
925
  />
452
926
  )}
453
927
  </div>
454
- )
928
+ );
455
929
  }
456
930
 
457
- function RailRow({
458
- active,
459
- focused,
460
- muted,
461
- onClick,
462
- onMouseEnter,
463
- swatch,
464
- title,
465
- tooltip,
466
- subtitle,
467
- trailing,
931
+ function ApplicationViewTabs({
932
+ activeView,
933
+ costSelected,
934
+ costAvailable,
935
+ onChange,
936
+ onCostChange,
468
937
  }: {
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
938
+ activeView: CanonicalApplicationView;
939
+ costSelected: boolean;
940
+ costAvailable: boolean;
941
+ onChange: (view: CanonicalApplicationView) => void;
942
+ onCostChange: () => void;
480
943
  }) {
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
- )
944
+ return (
945
+ <div
946
+ className="flex shrink-0 items-center border-b border-theme-border px-4 sm:px-6"
947
+ role="tablist"
948
+ aria-label="Application views"
949
+ >
950
+ <div className="flex min-w-0 gap-1 overflow-x-auto">
951
+ {APPLICATION_VIEWS.map((view) => {
952
+ const active = !costSelected && view.id === activeView;
953
+ return (
954
+ <button
955
+ key={view.id}
956
+ type="button"
957
+ role="tab"
958
+ aria-selected={active}
959
+ onClick={() => onChange(view.id)}
960
+ className={clsx(
961
+ "flex items-center gap-1.5 whitespace-nowrap border-b-2 px-3 py-2 text-sm font-medium transition-colors",
962
+ active
963
+ ? "border-skyhook-500 text-theme-text-primary"
964
+ : "border-transparent text-theme-text-secondary hover:border-theme-border-light hover:text-theme-text-primary",
965
+ )}
966
+ >
967
+ {view.label}
968
+ </button>
969
+ );
970
+ })}
971
+ {costAvailable && (
972
+ <button
973
+ type="button"
974
+ role="tab"
975
+ aria-selected={costSelected}
976
+ onClick={onCostChange}
977
+ className={clsx(
978
+ "flex items-center gap-1.5 whitespace-nowrap border-b-2 px-3 py-2 text-sm font-medium transition-colors",
979
+ costSelected
980
+ ? "border-skyhook-500 text-theme-text-primary"
981
+ : "border-transparent text-theme-text-secondary hover:border-theme-border-light hover:text-theme-text-primary",
982
+ )}
983
+ >
984
+ <DollarSign className="h-4 w-4" />
985
+ Cost
986
+ </button>
987
+ )}
988
+ </div>
989
+ </div>
990
+ );
506
991
  }
507
992
 
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
- }
993
+ type AppIssueSeverity = "error" | "warning" | "info";
994
+
995
+ type AppIssue = {
996
+ key: string;
997
+ severity: AppIssueSeverity;
998
+ title: string;
999
+ detail?: string;
1000
+ workload?: AppWorkload;
1001
+ };
1002
+
1003
+ function ApplicationOverview({
1004
+ app,
1005
+ workloads,
1006
+ ready,
1007
+ desired,
1008
+ versions,
1009
+ onSelectWorkload,
1010
+ onNavigateToResource,
1011
+ history,
1012
+ onSelectHistory,
1013
+ onOpenSource,
1014
+ renderOverviewIssues,
1015
+ hasOverviewIssues,
1016
+ }: {
1017
+ app: AppRow;
1018
+ workloads: AppWorkload[];
1019
+ ready: number;
1020
+ desired: number;
1021
+ versions: string[];
1022
+ onSelectWorkload: (workload: AppWorkload) => void;
1023
+ onNavigateToResource?: (resource: {
1024
+ kind: string;
1025
+ namespace: string;
1026
+ name: string;
1027
+ group?: string;
1028
+ }) => void;
1029
+ history?: AppHistory;
1030
+ onSelectHistory: () => void;
1031
+ onOpenSource?: (source: AppSourceRef) => void;
1032
+ renderOverviewIssues?: () => ReactNode;
1033
+ hasOverviewIssues?: boolean;
1034
+ }) {
1035
+ const rel = app.relationships;
1036
+ const hasEntrypoints = Boolean(
1037
+ rel &&
1038
+ (relationshipRefs(rel, "service").length > 0 ||
1039
+ relationshipRefs(rel, "ingress").length > 0 ||
1040
+ relationshipRefs(rel, "route").length > 0),
1041
+ );
1042
+ const hasDependencies = Boolean(
1043
+ rel &&
1044
+ (relationshipRefs(rel, "config").length > 0 ||
1045
+ relationshipRefs(rel, "scaler").length > 0 ||
1046
+ relationshipRefs(rel, "storage").length > 0 ||
1047
+ relationshipRefs(rel, "pdb").length > 0 ||
1048
+ relationshipRefs(rel, "networkPolicy").length > 0),
1049
+ );
1050
+ const composition = classCompositionOf(app)
1051
+ .map(({ cls, count }) => `${count} ${cls}`)
1052
+ .join(" / ");
1053
+ const issues = useMemo(
1054
+ () => buildAppIssues(workloads, app.events ?? []),
1055
+ [workloads, app.events],
1056
+ );
1057
+ const currentIssuesPresent = renderOverviewIssues
1058
+ ? (hasOverviewIssues ?? issues.length > 0)
1059
+ : issues.length > 0;
1060
+ const batchActivity = useMemo(() => batchActivityForApp(app), [app]);
1061
+ const batchStats = batchOverviewStats(batchActivity);
1062
+ const pureBatch = workloadClassOf(app.workload_class) === "job";
1063
+ const workloadComposition = workloadKindComposition(workloads);
1064
+ const latestChange =
1065
+ history?.summary?.state === "change" ? history.summary : undefined;
1066
+ const runtimeHealth = healthOf(
1067
+ app.runtimeHealth ??
1068
+ worstHealth(workloads.map((workload) => workload.health)),
1069
+ );
1070
+ const hasDeliveryStatus = Boolean(
1071
+ app.sourceStatus?.sync || app.sourceStatus?.health,
1072
+ );
1073
+ const extraFactCount =
1074
+ Number(Boolean(latestChange)) + Number(hasDeliveryStatus);
1075
+ const factGrid = pureBatch
1076
+ ? extraFactCount === 2
1077
+ ? "2xl:grid-cols-6"
1078
+ : extraFactCount === 1
1079
+ ? "2xl:grid-cols-5"
1080
+ : "2xl:grid-cols-4"
1081
+ : extraFactCount === 2
1082
+ ? "2xl:grid-cols-5"
1083
+ : extraFactCount === 1
1084
+ ? "2xl:grid-cols-4"
1085
+ : "2xl:grid-cols-3";
1086
+
522
1087
  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>
1088
+ <div className="min-h-0 flex-1 overflow-auto">
1089
+ <div className="grid w-full max-w-[2400px] gap-4 p-4 sm:p-6 xl:grid-cols-[minmax(0,1fr)_minmax(320px,380px)]">
1090
+ <div className="min-w-0 space-y-4">
1091
+ {renderOverviewIssues ? (
1092
+ renderOverviewIssues()
1093
+ ) : (
1094
+ <ApplicationNow
1095
+ issues={issues}
1096
+ onSelectWorkload={onSelectWorkload}
1097
+ />
1098
+ )}
1099
+ <ApplicationLatestHistory
1100
+ history={history}
1101
+ sourceRef={app.sourceRef}
1102
+ showIncidentPreview={!currentIssuesPresent}
1103
+ onSelectHistory={onSelectHistory}
1104
+ onOpenSource={onOpenSource}
1105
+ />
1106
+ <div className={clsx("grid gap-3 md:grid-cols-2", factGrid)}>
1107
+ <ApplicationFact
1108
+ label="Runtime"
1109
+ value={
1110
+ pureBatch
1111
+ ? batchRuntimeForApp(app).label
1112
+ : HEALTH_META[runtimeHealth].label
1113
+ }
1114
+ detail={
1115
+ pureBatch
1116
+ ? batchStats.activeDetail
1117
+ : desired > 0
1118
+ ? `${ready}/${desired} ready`
1119
+ : "No desired replicas"
1120
+ }
1121
+ />
1122
+ {hasDeliveryStatus && (
1123
+ <ApplicationFact
1124
+ label="Delivery"
1125
+ value={<SourceDeliveryStatus status={app.sourceStatus!} />}
1126
+ detail={
1127
+ app.sourceRef ? sourceObjectLabel(app.sourceRef) : undefined
1128
+ }
1129
+ />
1130
+ )}
1131
+ {pureBatch ? (
1132
+ <>
1133
+ <ApplicationFact
1134
+ label="Batch resources"
1135
+ value={String(workloads.length)}
1136
+ detail={workloadComposition || "No workloads"}
1137
+ />
1138
+ <ApplicationFact
1139
+ label="Active runs"
1140
+ value={batchStats.activeValue}
1141
+ detail={batchStats.activeDetail}
1142
+ />
1143
+ <ApplicationFact
1144
+ label="Retained runs"
1145
+ value={batchStats.retainedValue}
1146
+ detail="Kubernetes-retained history"
1147
+ />
1148
+ </>
1149
+ ) : (
1150
+ <>
1151
+ <ApplicationFact
1152
+ label="Workloads"
1153
+ value={String(workloads.length)}
1154
+ detail={composition || "No workloads"}
1155
+ />
1156
+ <ApplicationFact
1157
+ label="Version"
1158
+ value={
1159
+ app.appVersion ||
1160
+ (versions.length === 1
1161
+ ? versions[0]
1162
+ : versions.length > 1
1163
+ ? `${versions.length} versions`
1164
+ : "Unknown")
1165
+ }
1166
+ />
1167
+ </>
1168
+ )}
1169
+ {latestChange && (
1170
+ <ApplicationLatestChangeFact
1171
+ summary={latestChange}
1172
+ sourceRef={app.sourceRef ? undefined : history?.sourceRef}
1173
+ onSelectHistory={onSelectHistory}
1174
+ onOpenSource={onOpenSource}
1175
+ />
1176
+ )}
1177
+ </div>
1178
+ {hasEntrypoints && (
1179
+ <ApplicationEntrypoints
1180
+ relationships={rel}
1181
+ onNavigateToResource={onNavigateToResource}
1182
+ />
1183
+ )}
1184
+ <ApplicationPanel title="Workloads">
1185
+ <WorkloadsMatrix
1186
+ workloads={workloads}
1187
+ onSelectWorkload={onSelectWorkload}
1188
+ />
1189
+ </ApplicationPanel>
1190
+ {!pureBatch && <ApplicationBatchOverview activity={batchActivity} />}
1191
+ </div>
1192
+ <aside
1193
+ className={clsx(
1194
+ "min-w-0 gap-4 xl:block xl:space-y-4",
1195
+ hasDependencies ? "grid md:grid-cols-2" : "max-w-xl xl:max-w-none",
1196
+ )}
1197
+ >
1198
+ <ApplicationSourceProvenance app={app} onOpenSource={onOpenSource} />
1199
+ {hasDependencies && (
1200
+ <ApplicationDependencies
1201
+ relationships={rel}
1202
+ onNavigateToResource={onNavigateToResource}
1203
+ />
1204
+ )}
1205
+ </aside>
1206
+ </div>
528
1207
  </div>
529
- )
1208
+ );
530
1209
  }
531
1210
 
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
- }
1211
+ function workloadKindComposition(workloads: AppWorkload[]): string {
1212
+ const counts = new Map<string, number>();
1213
+ for (const workload of workloads) {
1214
+ if (!workload.kind) continue;
1215
+ counts.set(workload.kind, (counts.get(workload.kind) ?? 0) + 1);
539
1216
  }
540
- return worst
1217
+ return [...counts.entries()]
1218
+ .sort(
1219
+ ([aKind, aCount], [bKind, bCount]) =>
1220
+ bCount - aCount || aKind.localeCompare(bKind),
1221
+ )
1222
+ .map(([kind, count]) => pluralize(count, kind))
1223
+ .join(" / ");
541
1224
  }
542
1225
 
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,
1226
+ function ApplicationSourceProvenance({
1227
+ app,
1228
+ onOpenSource,
556
1229
  }: {
557
- identityKey: string
558
- instances: AppIdentityInstance[]
559
- activeKey: string
560
- onSwitch?: (appKey: string) => void
1230
+ app: AppRow;
1231
+ onOpenSource?: (source: AppSourceRef) => void;
561
1232
  }) {
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) {
1233
+ if (app.sourceRef) {
1234
+ const sourceName = `${app.sourceRef.namespace}/${app.sourceRef.name}`;
601
1235
  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>
1236
+ <ApplicationPanel title="Deployment source">
1237
+ <div className="space-y-3">
1238
+ <div>
1239
+ <div className="text-xs font-semibold uppercase text-theme-text-tertiary">
1240
+ {sourceObjectLabel(app.sourceRef)}
1241
+ </div>
1242
+ <Tooltip content={sourceName} delay={150}>
1243
+ <div className="mt-1 truncate font-mono text-sm font-medium text-theme-text-primary">
1244
+ {sourceName}
1245
+ </div>
621
1246
  </Tooltip>
622
- )
623
- })}
624
- {evidenceChip}
625
- {lagChip}
626
- </span>
627
- )
1247
+ </div>
1248
+ {app.sourceStatus && (
1249
+ <SourceDeliveryStatus status={app.sourceStatus} />
1250
+ )}
1251
+ {onOpenSource && (
1252
+ <button
1253
+ type="button"
1254
+ onClick={() => onOpenSource(app.sourceRef!)}
1255
+ 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"
1256
+ >
1257
+ <ExternalLink className="h-3.5 w-3.5" aria-hidden />
1258
+ {sourceLinkLabel(app.sourceRef)}
1259
+ </button>
1260
+ )}
1261
+ </div>
1262
+ </ApplicationPanel>
1263
+ );
1264
+ }
1265
+
1266
+ if (app.sourceConflict) {
1267
+ return (
1268
+ <ApplicationPanel title="Deployment source">
1269
+ <div className="space-y-2">
1270
+ <div className="flex items-center gap-2">
1271
+ <AlertTriangle
1272
+ className="h-4 w-4 shrink-0 text-amber-500"
1273
+ aria-hidden
1274
+ />
1275
+ <span className="text-sm font-semibold text-theme-text-primary">
1276
+ Multiple deployment sources detected
1277
+ </span>
1278
+ </div>
1279
+ <p className="text-sm text-theme-text-secondary">
1280
+ Workloads in this application do not share one deployment manager.
1281
+ </p>
1282
+ </div>
1283
+ </ApplicationPanel>
1284
+ );
628
1285
  }
629
1286
 
1287
+ const inferred = app.identity?.confidence === "medium";
1288
+ const identityLabel = app.identity?.source
1289
+ ? appSourceLabel(app.identity.source)
1290
+ : undefined;
1291
+ const tierLabel =
1292
+ !app.identity && app.tier ? overlayProvenance(app.tier) : undefined;
630
1293
  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
- })}
1294
+ <ApplicationPanel title="Application identity">
1295
+ <div className="space-y-2">
1296
+ <div className="flex items-center gap-2">
1297
+ {inferred && (
1298
+ <AlertTriangle
1299
+ className="h-4 w-4 shrink-0 text-amber-500"
1300
+ aria-hidden
1301
+ />
1302
+ )}
1303
+ <span className="text-sm font-semibold text-theme-text-primary">
1304
+ {identityLabel
1305
+ ? inferred
1306
+ ? "Inferred application boundary"
1307
+ : `Identified by ${identityLabel}`
1308
+ : tierLabel
1309
+ ? `Grouped by ${tierLabel} metadata`
1310
+ : "Grouped from Kubernetes ownership"}
1311
+ </span>
674
1312
  </div>
675
- )}
676
- </div>
677
- )
1313
+ {inferred && identityLabel && (
1314
+ <p className="text-sm text-theme-text-secondary">
1315
+ Matched using {identityLabel}.
1316
+ </p>
1317
+ )}
1318
+ {app.identity?.evidence && (
1319
+ <Tooltip content={app.identity.evidence} delay={150}>
1320
+ <div className="truncate font-mono text-xs text-theme-text-tertiary">
1321
+ {app.identity.evidence}
1322
+ </div>
1323
+ </Tooltip>
1324
+ )}
1325
+ </div>
1326
+ </ApplicationPanel>
1327
+ );
1328
+ }
1329
+
1330
+ function ApplicationEntrypoints({
1331
+ relationships,
1332
+ onNavigateToResource,
1333
+ }: {
1334
+ relationships: AppRow["relationships"];
1335
+ onNavigateToResource?: (resource: {
1336
+ kind: string;
1337
+ namespace: string;
1338
+ name: string;
1339
+ group?: string;
1340
+ }) => void;
1341
+ }) {
1342
+ const serviceRefs = relationshipRefs(relationships, "service");
1343
+ const ingressRefs = relationshipRefs(relationships, "ingress");
1344
+ const routeRefs = relationshipRefs(relationships, "route");
1345
+ const serviceCount = serviceRefs.length;
1346
+ const ingressCount = ingressRefs.length;
1347
+ const routeCount = routeRefs.length;
1348
+ const hasExternal = ingressCount + routeCount > 0;
1349
+ if (serviceCount + ingressCount + routeCount === 0) return null;
1350
+
1351
+ return (
1352
+ <ApplicationPanel title="Entrypoints">
1353
+ <div className="space-y-3">
1354
+ <div>
1355
+ <div className="flex flex-wrap items-center gap-2">
1356
+ <h3 className="text-sm font-semibold text-theme-text-primary">
1357
+ {hasExternal
1358
+ ? "External entrypoints configured"
1359
+ : "Internal services configured"}
1360
+ </h3>
1361
+ <span
1362
+ className={`${CHIP} ${hasExternal ? CHIP_TONE.blue : CHIP_TONE.muted}`}
1363
+ >
1364
+ {hasExternal ? "External" : "Internal"}
1365
+ </span>
1366
+ </div>
1367
+ <p className="mt-1 text-sm text-theme-text-secondary">
1368
+ {hasExternal
1369
+ ? "Traffic can enter this application through ingress or route resources."
1370
+ : "Services target this application, but no ingress or route was detected."}
1371
+ </p>
1372
+ </div>
1373
+ <div className="grid gap-3 sm:grid-cols-3">
1374
+ <ApplicationFact
1375
+ variant="bare"
1376
+ label="Services"
1377
+ value={String(serviceCount)}
1378
+ />
1379
+ <ApplicationFact
1380
+ variant="bare"
1381
+ label="Ingresses"
1382
+ value={String(ingressCount)}
1383
+ />
1384
+ <ApplicationFact
1385
+ variant="bare"
1386
+ label="Routes"
1387
+ value={String(routeCount)}
1388
+ />
1389
+ </div>
1390
+ <div className="space-y-3 border-t border-theme-border pt-3">
1391
+ <ApplicationRelatedNameGroup
1392
+ label="Services"
1393
+ refs={serviceRefs}
1394
+ onNavigateToResource={onNavigateToResource}
1395
+ />
1396
+ <ApplicationRelatedNameGroup
1397
+ label="Ingresses"
1398
+ refs={ingressRefs}
1399
+ onNavigateToResource={onNavigateToResource}
1400
+ />
1401
+ <ApplicationRelatedNameGroup
1402
+ label="Routes"
1403
+ refs={routeRefs}
1404
+ onNavigateToResource={onNavigateToResource}
1405
+ />
1406
+ </div>
1407
+ </div>
1408
+ </ApplicationPanel>
1409
+ );
1410
+ }
1411
+
1412
+ function ApplicationDependencies({
1413
+ relationships,
1414
+ onNavigateToResource,
1415
+ }: {
1416
+ relationships: AppRow["relationships"];
1417
+ onNavigateToResource?: (resource: {
1418
+ kind: string;
1419
+ namespace: string;
1420
+ name: string;
1421
+ group?: string;
1422
+ }) => void;
1423
+ }) {
1424
+ const configs = relationshipRefs(relationships, "config");
1425
+ const scalers = relationshipRefs(relationships, "scaler");
1426
+ const storage = relationshipRefs(relationships, "storage");
1427
+ const pdbs = relationshipRefs(relationships, "pdb");
1428
+ const networkPolicies = relationshipRefs(relationships, "networkPolicy");
1429
+ if (
1430
+ configs.length +
1431
+ scalers.length +
1432
+ storage.length +
1433
+ pdbs.length +
1434
+ networkPolicies.length ===
1435
+ 0
1436
+ )
1437
+ return null;
1438
+
1439
+ return (
1440
+ <ApplicationPanel title="Dependencies">
1441
+ <div className="space-y-3">
1442
+ <ApplicationDependencyRow
1443
+ label="Configuration"
1444
+ refs={configs}
1445
+ totalCount={relationships?.configs}
1446
+ detail="ConfigMaps and Secrets referenced by app workloads."
1447
+ onNavigateToResource={onNavigateToResource}
1448
+ />
1449
+ <ApplicationDependencyRow
1450
+ label="Autoscaling"
1451
+ refs={scalers}
1452
+ totalCount={relationships?.scalers}
1453
+ detail="Autoscalers controlling app workloads."
1454
+ onNavigateToResource={onNavigateToResource}
1455
+ />
1456
+ <ApplicationDependencyRow
1457
+ label="Storage"
1458
+ refs={storage}
1459
+ totalCount={relationships?.storage}
1460
+ detail="PersistentVolumeClaims mounted by app workloads."
1461
+ onNavigateToResource={onNavigateToResource}
1462
+ />
1463
+ <ApplicationDependencyRow
1464
+ label="Availability policy"
1465
+ refs={pdbs}
1466
+ totalCount={relationships?.pdbs}
1467
+ detail="PodDisruptionBudgets protecting app workloads."
1468
+ onNavigateToResource={onNavigateToResource}
1469
+ />
1470
+ <ApplicationDependencyRow
1471
+ label="Network policy"
1472
+ refs={networkPolicies}
1473
+ totalCount={relationships?.networkPolicies}
1474
+ detail="Network policies selecting app workloads."
1475
+ onNavigateToResource={onNavigateToResource}
1476
+ />
1477
+ </div>
1478
+ </ApplicationPanel>
1479
+ );
1480
+ }
1481
+
1482
+ function ApplicationDependencyRow({
1483
+ label,
1484
+ refs,
1485
+ totalCount,
1486
+ detail,
1487
+ onNavigateToResource,
1488
+ }: {
1489
+ label: string;
1490
+ refs: ResourceRef[];
1491
+ totalCount?: number;
1492
+ detail: string;
1493
+ onNavigateToResource?: (resource: {
1494
+ kind: string;
1495
+ namespace: string;
1496
+ name: string;
1497
+ group?: string;
1498
+ }) => void;
1499
+ }) {
1500
+ if (refs.length === 0) return null;
1501
+ const count = Math.max(totalCount ?? refs.length, refs.length);
1502
+ return (
1503
+ <div className="min-w-0 border-b border-theme-border pb-3 last:border-b-0">
1504
+ <div className="flex items-baseline justify-between gap-3">
1505
+ <div className="text-sm font-semibold text-theme-text-primary">
1506
+ {label}
1507
+ </div>
1508
+ <span className={`${CHIP} ${CHIP_TONE.muted}`}>
1509
+ {pluralize(count, "resource")}
1510
+ </span>
1511
+ </div>
1512
+ <div className="mt-1 text-xs text-theme-text-tertiary">{detail}</div>
1513
+ <ApplicationRelatedNameGroup
1514
+ label={label}
1515
+ refs={refs}
1516
+ totalCount={count}
1517
+ onNavigateToResource={onNavigateToResource}
1518
+ hideLabel
1519
+ />
1520
+ </div>
1521
+ );
1522
+ }
1523
+
1524
+ function ApplicationRelatedNameGroup({
1525
+ label,
1526
+ refs,
1527
+ totalCount,
1528
+ onNavigateToResource,
1529
+ hideLabel,
1530
+ }: {
1531
+ label: string;
1532
+ refs?: ResourceRef[];
1533
+ totalCount?: number;
1534
+ onNavigateToResource?: (resource: {
1535
+ kind: string;
1536
+ namespace: string;
1537
+ name: string;
1538
+ group?: string;
1539
+ }) => void;
1540
+ hideLabel?: boolean;
1541
+ }) {
1542
+ if (!refs || refs.length === 0) return null;
1543
+
1544
+ const visible = refs.slice(0, 12);
1545
+ const count = Math.max(totalCount ?? refs.length, refs.length);
1546
+ const overflow = Math.max(0, count - visible.length);
1547
+ return (
1548
+ <div className={hideLabel ? "mt-2" : undefined}>
1549
+ {!hideLabel && (
1550
+ <div className="mb-1 text-xs font-medium text-theme-text-tertiary">
1551
+ {label}
1552
+ {count > 1 ? ` (${count})` : ""}
1553
+ </div>
1554
+ )}
1555
+ <div className="flex flex-wrap gap-1.5">
1556
+ {visible.map((ref) => (
1557
+ <ResourceRefBadge
1558
+ key={`${ref.group || ""}/${ref.kind}/${ref.namespace}/${ref.name}`}
1559
+ resourceRef={ref}
1560
+ onClick={
1561
+ onNavigateToResource
1562
+ ? (clicked) =>
1563
+ onNavigateToResource(refToSelectedResource(clicked))
1564
+ : undefined
1565
+ }
1566
+ />
1567
+ ))}
1568
+ {overflow > 0 && (
1569
+ <span className={`${CHIP} ${CHIP_TONE.muted}`}>+{overflow} more</span>
1570
+ )}
1571
+ </div>
1572
+ </div>
1573
+ );
1574
+ }
1575
+
1576
+ type RelationshipGroup =
1577
+ | "service"
1578
+ | "ingress"
1579
+ | "route"
1580
+ | "config"
1581
+ | "scaler"
1582
+ | "storage"
1583
+ | "pdb"
1584
+ | "networkPolicy";
1585
+
1586
+ function relationshipRefs(
1587
+ relationships: AppRow["relationships"] | undefined,
1588
+ group: RelationshipGroup,
1589
+ ): ResourceRef[] {
1590
+ if (!relationships) return [];
1591
+ switch (group) {
1592
+ case "service":
1593
+ return relationships.serviceRefs ?? [];
1594
+ case "ingress":
1595
+ return relationships.ingressRefs ?? [];
1596
+ case "route":
1597
+ return relationships.routeRefs ?? [];
1598
+ case "config":
1599
+ return relationships.configRefs ?? [];
1600
+ case "scaler":
1601
+ return relationships.scalerRefs ?? [];
1602
+ case "storage":
1603
+ return relationships.storageRefs ?? [];
1604
+ case "pdb":
1605
+ return relationships.pdbRefs ?? [];
1606
+ case "networkPolicy":
1607
+ return relationships.networkPolicyRefs ?? [];
1608
+ }
1609
+ }
1610
+
1611
+ function ApplicationNow({
1612
+ issues,
1613
+ onSelectWorkload,
1614
+ }: {
1615
+ issues: AppIssue[];
1616
+ onSelectWorkload: (workload: AppWorkload) => void;
1617
+ }) {
1618
+ if (issues.length === 0) return null;
1619
+
1620
+ const top = issues[0];
1621
+ return (
1622
+ <section className="rounded-lg border border-theme-border bg-theme-surface shadow-theme-sm">
1623
+ <div className="flex flex-wrap items-start gap-3 border-b border-theme-border px-4 py-3">
1624
+ <span
1625
+ className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-md ring-1 ring-inset ${issueTone(top.severity)}`}
1626
+ >
1627
+ <AlertTriangle className="h-4 w-4" aria-hidden />
1628
+ </span>
1629
+ <div className="min-w-0 flex-1">
1630
+ <div className="flex flex-wrap items-center gap-2">
1631
+ <h2 className="text-sm font-semibold text-theme-text-primary">
1632
+ {top.title}
1633
+ </h2>
1634
+ <span className={`${CHIP} ${issueTone(top.severity)}`}>
1635
+ {top.severity === "error" ? "Needs attention" : "Warning"}
1636
+ </span>
1637
+ </div>
1638
+ {top.detail && (
1639
+ <p className="mt-0.5 text-sm text-theme-text-secondary">
1640
+ {top.detail}
1641
+ </p>
1642
+ )}
1643
+ </div>
1644
+ {top.workload && (
1645
+ <button
1646
+ type="button"
1647
+ onClick={() => onSelectWorkload(top.workload!)}
1648
+ className="rounded-md px-2.5 py-1.5 text-sm font-medium text-accent-text hover:bg-theme-hover"
1649
+ >
1650
+ Open workload
1651
+ </button>
1652
+ )}
1653
+ </div>
1654
+ {issues.length > 1 && (
1655
+ <div className="divide-y divide-theme-border px-4">
1656
+ {issues.slice(1, 5).map((issue) => (
1657
+ <div key={issue.key} className="flex items-start gap-3 py-3">
1658
+ <StatusDot
1659
+ tone={
1660
+ issue.severity === "error"
1661
+ ? "unhealthy"
1662
+ : issue.severity === "warning"
1663
+ ? "degraded"
1664
+ : "neutral"
1665
+ }
1666
+ className="mt-1"
1667
+ />
1668
+ <div className="min-w-0 flex-1">
1669
+ <div className="truncate text-sm font-medium text-theme-text-primary">
1670
+ {issue.title}
1671
+ </div>
1672
+ {issue.detail && (
1673
+ <div className="truncate text-sm text-theme-text-tertiary">
1674
+ {issue.detail}
1675
+ </div>
1676
+ )}
1677
+ </div>
1678
+ {issue.workload && (
1679
+ <button
1680
+ type="button"
1681
+ onClick={() => onSelectWorkload(issue.workload!)}
1682
+ className="shrink-0 text-sm font-medium text-accent-text hover:underline"
1683
+ >
1684
+ Open
1685
+ </button>
1686
+ )}
1687
+ </div>
1688
+ ))}
1689
+ </div>
1690
+ )}
1691
+ </section>
1692
+ );
1693
+ }
1694
+
1695
+ function ApplicationBatchOverview({
1696
+ activity,
1697
+ }: {
1698
+ activity: AppBatchActivity[];
1699
+ }) {
1700
+ if (activity.length === 0) return null;
1701
+
1702
+ const stats = batchOverviewStats(activity);
1703
+
1704
+ return (
1705
+ <ApplicationPanel title="Batch activity">
1706
+ <div className="grid gap-4">
1707
+ <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
1708
+ <ApplicationFact
1709
+ variant="bare"
1710
+ label="Latest run"
1711
+ value={stats.latestValue}
1712
+ detail={stats.latestDetail}
1713
+ />
1714
+ <ApplicationFact
1715
+ variant="bare"
1716
+ label="Retained runs"
1717
+ value={stats.retainedValue}
1718
+ detail="Kubernetes-retained history"
1719
+ />
1720
+ <ApplicationFact
1721
+ variant="bare"
1722
+ label="Active work"
1723
+ value={stats.activeValue}
1724
+ detail={stats.activeDetail}
1725
+ />
1726
+ <ApplicationFact
1727
+ variant="bare"
1728
+ label="Schedules"
1729
+ value={stats.scheduleValue}
1730
+ detail={stats.scheduleDetail}
1731
+ monoValue={stats.scheduleMono}
1732
+ />
1733
+ </div>
1734
+ </div>
1735
+ </ApplicationPanel>
1736
+ );
1737
+ }
1738
+
1739
+ function latestBatchRuns(activity: AppBatchActivity[]): AppBatchActivity[] {
1740
+ return [...activity].sort(
1741
+ (a, b) =>
1742
+ latestRunTime(b) - latestRunTime(a) ||
1743
+ a.workload.name.localeCompare(b.workload.name),
1744
+ );
1745
+ }
1746
+
1747
+ function latestRunTimestamp(item: AppBatchActivity): string | undefined {
1748
+ return (
1749
+ item.latestStartedAt ||
1750
+ item.latestFinishedAt ||
1751
+ item.lastScheduledAt ||
1752
+ item.lastSuccessfulAt
1753
+ );
1754
+ }
1755
+
1756
+ function latestRunTime(item: AppBatchActivity): number {
1757
+ const value = latestRunTimestamp(item);
1758
+ if (!value) return 0;
1759
+ const t = Date.parse(value);
1760
+ return Number.isNaN(t) ? 0 : t;
1761
+ }
1762
+
1763
+ function batchOverviewStats(activity: AppBatchActivity[]) {
1764
+ const latest = latestBatchRuns(activity)[0];
1765
+ const schedules = Array.from(
1766
+ new Set(
1767
+ activity
1768
+ .map((item) => item.schedule)
1769
+ .filter((s): s is string => Boolean(s)),
1770
+ ),
1771
+ );
1772
+ const retained = activity.reduce((n, item) => n + item.retainedRuns, 0);
1773
+ const succeeded = activity.reduce(
1774
+ (n, item) => n + (item.workload.batch?.succeededRuns ?? 0),
1775
+ 0,
1776
+ );
1777
+ const failed = activity.reduce((n, item) => n + item.failedRuns, 0);
1778
+ const active = activity.reduce((n, item) => n + item.activeRuns, 0);
1779
+
1780
+ return {
1781
+ latestValue: latest?.latestRunPhase || latest?.label || "None",
1782
+ latestDetail: latest
1783
+ ? `${latest.latestRunName || latest.workload.name}${latestRunTimestamp(latest) ? ` · ${relativeTime(latestRunTimestamp(latest)!)} ` : ""}`.trim()
1784
+ : "No retained runs",
1785
+ retainedValue:
1786
+ retained > 0 ? `${succeeded} succeeded / ${failed} failed` : "0 retained",
1787
+ activeValue: active > 0 ? `${active} running` : "None",
1788
+ activeDetail:
1789
+ active > 0 ? activeWorkloadNames(activity) : "No active batch work",
1790
+ scheduleValue:
1791
+ schedules.length === 0
1792
+ ? "None"
1793
+ : schedules.length === 1
1794
+ ? schedules[0]
1795
+ : `${schedules.length} schedules`,
1796
+ scheduleDetail:
1797
+ schedules.length === 1 && latest?.lastSuccessfulAt
1798
+ ? `last success ${relativeTime(latest.lastSuccessfulAt)}`
1799
+ : undefined,
1800
+ scheduleMono: schedules.length === 1,
1801
+ };
1802
+ }
1803
+
1804
+ function activeWorkloadNames(activity: AppBatchActivity[]): string {
1805
+ const active = activity
1806
+ .filter((item) => item.activeRuns > 0)
1807
+ .map((item) => item.workload.name);
1808
+ if (active.length <= 2) return active.join(", ");
1809
+ return `${active.slice(0, 2).join(", ")} +${active.length - 2} more`;
1810
+ }
1811
+
1812
+ function relativeTime(value: string): string {
1813
+ const t = Date.parse(value);
1814
+ if (Number.isNaN(t)) return value;
1815
+ const diff = Date.now() - t;
1816
+ if (diff < 60_000) return "just now";
1817
+ const minutes = Math.floor(diff / 60_000);
1818
+ if (minutes < 60) return `${minutes}m ago`;
1819
+ const hours = Math.floor(minutes / 60);
1820
+ if (hours < 48) return `${hours}h ago`;
1821
+ return `${Math.floor(hours / 24)}d ago`;
1822
+ }
1823
+
1824
+ function ApplicationLatestHistory({
1825
+ history,
1826
+ sourceRef,
1827
+ showIncidentPreview,
1828
+ onSelectHistory,
1829
+ onOpenSource,
1830
+ }: {
1831
+ history?: AppHistory;
1832
+ sourceRef?: AppSourceRef;
1833
+ showIncidentPreview?: boolean;
1834
+ onSelectHistory: () => void;
1835
+ onOpenSource?: (source: AppSourceRef) => void;
1836
+ }) {
1837
+ const summary = history?.summary;
1838
+ const showIncident = summary?.state === "incident" && showIncidentPreview;
1839
+ if (!summary || !showIncident) return null;
1840
+ const resolvedSource = sourceRef ?? history?.sourceRef;
1841
+ const tone = CHIP_TONE.amber;
1842
+
1843
+ return (
1844
+ <section className="rounded-lg border border-theme-border bg-theme-surface px-4 py-3 shadow-theme-sm">
1845
+ <div className="flex flex-wrap items-start gap-3">
1846
+ <span
1847
+ className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-md ring-1 ring-inset ${tone}`}
1848
+ >
1849
+ <AlertTriangle className="h-4 w-4" aria-hidden />
1850
+ </span>
1851
+ <div className="min-w-0 flex-1">
1852
+ <div className="flex flex-wrap items-center gap-2">
1853
+ <h2 className="text-sm font-semibold text-theme-text-primary">
1854
+ {summary.title}
1855
+ </h2>
1856
+ <span className={`${CHIP} ${tone}`}>Latest incident</span>
1857
+ </div>
1858
+ {summary.detail && (
1859
+ <p className="mt-0.5 line-clamp-2 text-sm text-theme-text-secondary">
1860
+ {summary.detail}
1861
+ </p>
1862
+ )}
1863
+ {summary.timestamp && (
1864
+ <p className="mt-1 text-xs text-theme-text-tertiary">
1865
+ {formatAppEventTime(summary.timestamp)}
1866
+ </p>
1867
+ )}
1868
+ </div>
1869
+ <div className="flex shrink-0 items-center gap-2">
1870
+ {resolvedSource && !sourceRef && onOpenSource && (
1871
+ <button
1872
+ type="button"
1873
+ onClick={() => onOpenSource(resolvedSource)}
1874
+ className="rounded-md px-2.5 py-1.5 text-sm font-medium text-accent-text hover:bg-theme-hover"
1875
+ >
1876
+ {sourceLinkLabel(resolvedSource)}
1877
+ </button>
1878
+ )}
1879
+ <button
1880
+ type="button"
1881
+ onClick={onSelectHistory}
1882
+ className="rounded-md px-2.5 py-1.5 text-sm font-medium text-accent-text hover:bg-theme-hover"
1883
+ >
1884
+ View history
1885
+ </button>
1886
+ </div>
1887
+ </div>
1888
+ </section>
1889
+ );
1890
+ }
1891
+
1892
+ function ApplicationLatestChangeFact({
1893
+ summary,
1894
+ sourceRef,
1895
+ onSelectHistory,
1896
+ onOpenSource,
1897
+ }: {
1898
+ summary: NonNullable<AppHistory["summary"]>;
1899
+ sourceRef?: AppSourceRef;
1900
+ onSelectHistory: () => void;
1901
+ onOpenSource?: (source: AppSourceRef) => void;
1902
+ }) {
1903
+ return (
1904
+ <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">
1905
+ <div className="flex items-center justify-between gap-2">
1906
+ <div className="text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
1907
+ Latest change
1908
+ </div>
1909
+ <button
1910
+ type="button"
1911
+ onClick={onSelectHistory}
1912
+ className="shrink-0 text-xs font-medium text-accent-text hover:underline"
1913
+ >
1914
+ History
1915
+ </button>
1916
+ </div>
1917
+ <div className="mt-1 truncate text-sm font-semibold text-theme-text-primary">
1918
+ {summary.title}
1919
+ </div>
1920
+ {summary.detail && (
1921
+ <div className="mt-0.5 truncate font-mono text-xs text-theme-text-tertiary">
1922
+ {summary.detail}
1923
+ </div>
1924
+ )}
1925
+ {sourceRef && onOpenSource && (
1926
+ <button
1927
+ type="button"
1928
+ onClick={() => onOpenSource(sourceRef)}
1929
+ className="mt-1 text-xs font-medium text-accent-text hover:underline"
1930
+ >
1931
+ {sourceLinkLabel(sourceRef)}
1932
+ </button>
1933
+ )}
1934
+ </div>
1935
+ );
1936
+ }
1937
+
1938
+ function WorkloadsMatrix({
1939
+ workloads,
1940
+ onSelectWorkload,
1941
+ }: {
1942
+ workloads: AppWorkload[];
1943
+ onSelectWorkload: (workload: AppWorkload) => void;
1944
+ }) {
1945
+ if (workloads.length === 0) {
1946
+ return <EmptyState variant="inline" headline="No inspectable workloads." />;
1947
+ }
1948
+
1949
+ return (
1950
+ <div className="overflow-x-auto">
1951
+ <table className="w-full min-w-[760px] table-fixed text-sm">
1952
+ <thead>
1953
+ <tr className="border-b border-theme-border text-left text-[10px] uppercase tracking-wide text-theme-text-tertiary">
1954
+ <th className="w-[34%] px-2 py-2 font-semibold">Workload</th>
1955
+ <th className="w-[13%] px-2 py-2 font-semibold">Kind</th>
1956
+ <th className="w-[12%] px-2 py-2 font-semibold">Class</th>
1957
+ <th className="w-[14%] px-2 py-2 font-semibold">Status</th>
1958
+ <th className="w-[18%] px-2 py-2 font-semibold">Runtime</th>
1959
+ <th className="w-[16%] px-2 py-2 font-semibold">Version</th>
1960
+ </tr>
1961
+ </thead>
1962
+ <tbody>
1963
+ {workloads.map((w) => {
1964
+ const status = workloadRuntimeStatus(w);
1965
+ const reason =
1966
+ w.reason === "Completed" && w.kind !== "Job"
1967
+ ? undefined
1968
+ : w.reason;
1969
+ return (
1970
+ <tr
1971
+ key={workloadKey(w)}
1972
+ className="border-b border-theme-border last:border-b-0 hover:bg-theme-hover"
1973
+ >
1974
+ <td className="truncate px-2 py-2">
1975
+ <button
1976
+ type="button"
1977
+ onClick={() => onSelectWorkload(w)}
1978
+ className="truncate font-medium text-theme-text-primary hover:text-accent-text hover:underline"
1979
+ >
1980
+ {w.name}
1981
+ </button>
1982
+ {reason && (
1983
+ <div className="truncate text-xs text-theme-text-tertiary">
1984
+ {reason}
1985
+ </div>
1986
+ )}
1987
+ </td>
1988
+ <td className="px-2 py-2 text-theme-text-secondary">
1989
+ {w.kind}
1990
+ </td>
1991
+ <td className="px-2 py-2 text-theme-text-secondary">
1992
+ {workloadClassOf(w.workload_class)}
1993
+ </td>
1994
+ <td className="px-2 py-2">
1995
+ <span className="inline-flex items-center gap-1.5 text-theme-text-secondary">
1996
+ <StatusDot tone={mapHealthToTone(status.health)} />
1997
+ {status.label}
1998
+ </span>
1999
+ </td>
2000
+ <td className="px-2 py-2 text-xs text-theme-text-secondary">
2001
+ {workloadRuntimeDetail(w)}
2002
+ </td>
2003
+ <td className="truncate px-2 py-2 font-mono text-xs text-theme-text-secondary">
2004
+ {w.version || w.appVersion || "-"}
2005
+ </td>
2006
+ </tr>
2007
+ );
2008
+ })}
2009
+ </tbody>
2010
+ </table>
2011
+ </div>
2012
+ );
2013
+ }
2014
+
2015
+ function workloadRuntimeStatus(workload: AppWorkload): {
2016
+ label: string;
2017
+ health: AppHealth;
2018
+ } {
2019
+ const batch = workload.batch;
2020
+ if (!batch) {
2021
+ const health = healthOf(workload.health);
2022
+ return { label: HEALTH_META[health].label, health };
2023
+ }
2024
+ if ((batch.activeRuns ?? 0) > 0 || batch.latestRunPhase === "Running")
2025
+ return { label: "Running", health: "neutral" };
2026
+ if (batch.latestRunPhase === "Failed" || batch.latestRunPhase === "Error")
2027
+ return { label: "Failed", health: "unhealthy" };
2028
+ if (batch.latestRunPhase === "Succeeded")
2029
+ return { label: "Succeeded", health: "healthy" };
2030
+ if (batch.suspended) return { label: "Suspended", health: "neutral" };
2031
+ return { label: "Idle", health: "neutral" };
2032
+ }
2033
+
2034
+ function workloadRuntimeDetail(workload: AppWorkload): string {
2035
+ const batch = workload.batch;
2036
+ if (!batch) return `${workload.ready}/${workload.desired} ready`;
2037
+ if ((batch.activeRuns ?? 0) > 0)
2038
+ return pluralize(batch.activeRuns ?? 0, "active run");
2039
+ const at =
2040
+ batch.latestStartedAt || batch.latestFinishedAt || batch.lastScheduledAt;
2041
+ if (batch.latestRunName)
2042
+ return `${midTruncate(batch.latestRunName, 28)}${at ? ` · ${relativeTime(at)}` : ""}`;
2043
+ if (batch.schedule) return `Schedule ${batch.schedule}`;
2044
+ return "No retained runs";
2045
+ }
2046
+
2047
+ function ApplicationTopology({
2048
+ topology,
2049
+ loading,
2050
+ available,
2051
+ workloads,
2052
+ colorByWorkload,
2053
+ focusNodeId,
2054
+ focusedOwnerId,
2055
+ onNodeClick,
2056
+ onNodeHover,
2057
+ onFocusWorkload,
2058
+ onSelectWorkload,
2059
+ deploymentLayer,
2060
+ deploymentSource,
2061
+ onOpenSource,
2062
+ onToggleReplicaSets,
2063
+ }: {
2064
+ topology: Topology | null;
2065
+ loading?: boolean;
2066
+ available: boolean;
2067
+ workloads: AppWorkload[];
2068
+ colorByWorkload: Map<string, number> | null;
2069
+ focusNodeId?: string;
2070
+ focusedOwnerId: WorkloadFocus;
2071
+ onNodeClick: (node: TopologyNode) => void;
2072
+ onNodeHover: (node: TopologyNode | null) => void;
2073
+ onFocusWorkload: (owner: WorkloadFocus) => void;
2074
+ onSelectWorkload: (workload: AppWorkload) => void;
2075
+ deploymentLayer: DeploymentTopologyLayer | null;
2076
+ deploymentSource?: AppSourceRef;
2077
+ onOpenSource?: (source: AppSourceRef) => void;
2078
+ onToggleReplicaSets: (ownerID: string) => void;
2079
+ }) {
2080
+ const hasSharedOrUnscopedNodes = useMemo(
2081
+ () =>
2082
+ topology?.nodes.some((node) => {
2083
+ if (node.data?.deploymentMembership === "source-only") return false;
2084
+ const stamp = ownershipOf(node.data);
2085
+ return !stamp.ownerWorkloadId && stamp.focusWorkloadIds.length !== 1;
2086
+ }) ?? false,
2087
+ [topology],
2088
+ );
2089
+
2090
+ return (
2091
+ <div className="relative min-h-0 flex-1 bg-theme-surface">
2092
+ {available && topology ? (
2093
+ <>
2094
+ <TopologyGraph
2095
+ topology={topology}
2096
+ viewMode="resources"
2097
+ groupingMode="namespace"
2098
+ hideGroupHeader
2099
+ onNodeClick={onNodeClick}
2100
+ showExportButton={false}
2101
+ focusNodeId={focusNodeId}
2102
+ focusedOwnerId={focusedOwnerId}
2103
+ onNodeHover={onNodeHover}
2104
+ onToggleReplicaSets={onToggleReplicaSets}
2105
+ fitViewPadding={{
2106
+ top: 0.08,
2107
+ right: 0.05,
2108
+ bottom: 0.08,
2109
+ left: "360px",
2110
+ }}
2111
+ />
2112
+ <TopologyWorkloadLegend
2113
+ workloads={workloads}
2114
+ colorByWorkload={colorByWorkload}
2115
+ focusedOwnerId={focusedOwnerId}
2116
+ showSharedOrUnscoped={hasSharedOrUnscopedNodes}
2117
+ onFocus={onFocusWorkload}
2118
+ onSelectWorkload={onSelectWorkload}
2119
+ deploymentLayer={deploymentLayer}
2120
+ deploymentSource={deploymentSource}
2121
+ onOpenSource={onOpenSource}
2122
+ />
2123
+ </>
2124
+ ) : loading ? (
2125
+ <PaneLoader label="Loading topology..." className="absolute inset-0" />
2126
+ ) : (
2127
+ <div className="flex h-full items-center justify-center p-6">
2128
+ <EmptyState
2129
+ headline="No topology available"
2130
+ body="Radar could not build an application topology for this app."
2131
+ />
2132
+ </div>
2133
+ )}
2134
+ </div>
2135
+ );
2136
+ }
2137
+
2138
+ function ApplicationHistoryView({
2139
+ history,
2140
+ sourceLoading,
2141
+ items: suppliedItems,
2142
+ runtimeLoading,
2143
+ runtimeError,
2144
+ mode,
2145
+ range,
2146
+ rangeOptions,
2147
+ coverageRecordCount,
2148
+ runtimeLimited,
2149
+ workloads,
2150
+ onSelectWorkload,
2151
+ onNavigateToResource,
2152
+ onRangeChange,
2153
+ onOpenTimeline,
2154
+ onOpenSource,
2155
+ }: {
2156
+ history?: AppHistory;
2157
+ sourceLoading?: boolean;
2158
+ items?: ApplicationHistoryItem[];
2159
+ runtimeLoading?: boolean;
2160
+ runtimeError?: boolean;
2161
+ mode?: "local" | "retained";
2162
+ range?: ApplicationHistoryRange;
2163
+ rangeOptions?: Array<{ value: ApplicationHistoryRange; label: string }>;
2164
+ coverageRecordCount?: number;
2165
+ runtimeLimited?: boolean;
2166
+ workloads: AppWorkload[];
2167
+ onSelectWorkload: (workload: AppWorkload) => void;
2168
+ onNavigateToResource?: (resource: ResourceRef) => void;
2169
+ onRangeChange?: (range: ApplicationHistoryRange) => void;
2170
+ onOpenTimeline?: (timestamp?: string) => void;
2171
+ onOpenSource?: (source: AppSourceRef) => void;
2172
+ }) {
2173
+ const [category, setCategory] = useState<ApplicationHistoryCategory | "all">(
2174
+ "all",
2175
+ );
2176
+ const items = useMemo(
2177
+ () => suppliedItems ?? buildApplicationHistoryItems(history, []),
2178
+ [history, suppliedItems],
2179
+ );
2180
+ const availableCategories = useMemo(
2181
+ () => Array.from(new Set(items.map((item) => item.category))),
2182
+ [items],
2183
+ );
2184
+ useEffect(() => {
2185
+ if (category !== "all" && !availableCategories.includes(category))
2186
+ setCategory("all");
2187
+ }, [availableCategories, category]);
2188
+ const visibleItems = useMemo(
2189
+ () =>
2190
+ category === "all"
2191
+ ? items
2192
+ : items.filter((item) => item.category === category),
2193
+ [category, items],
2194
+ );
2195
+ const groups = useMemo(() => {
2196
+ const byDate = new Map<string, ApplicationHistoryItem[]>();
2197
+ for (const item of visibleItems) {
2198
+ const label = applicationHistoryDateLabel(item.timestamp);
2199
+ byDate.set(label, [...(byDate.get(label) ?? []), item]);
2200
+ }
2201
+ return [...byDate.entries()];
2202
+ }, [visibleItems]);
2203
+ const loading = sourceLoading || runtimeLoading;
2204
+ const empty = !loading && !runtimeError && items.length === 0;
2205
+ const hasRuntimeItems = items.some((item) => item.category !== "deployment");
2206
+
2207
+ return (
2208
+ <div className="min-h-0 flex-1 overflow-auto">
2209
+ <div className="w-full max-w-[2400px] p-4 sm:p-6">
2210
+ <section className="overflow-hidden rounded-lg border border-theme-border bg-theme-surface shadow-theme-sm">
2211
+ <div className="flex flex-wrap items-start justify-between gap-3 border-b border-theme-border px-4 py-3">
2212
+ <div className="min-w-0">
2213
+ <div className="flex items-center gap-2">
2214
+ <Clock3 className="h-4 w-4 text-theme-text-secondary" />
2215
+ <h2 className="text-sm font-semibold text-theme-text-primary">
2216
+ Application history
2217
+ </h2>
2218
+ {loading && items.length > 0 && (
2219
+ <RefreshCw
2220
+ className="h-3.5 w-3.5 animate-spin text-theme-text-tertiary"
2221
+ aria-label="Refreshing history"
2222
+ />
2223
+ )}
2224
+ </div>
2225
+ <p className="mt-1 text-xs text-theme-text-tertiary">
2226
+ Deployments, configuration changes, and problems attributed to
2227
+ this application.
2228
+ </p>
2229
+ </div>
2230
+ <div className="flex flex-wrap items-center justify-end gap-2">
2231
+ {range &&
2232
+ rangeOptions &&
2233
+ rangeOptions.length > 1 &&
2234
+ onRangeChange && (
2235
+ <select
2236
+ aria-label="Application history range"
2237
+ value={range}
2238
+ onChange={(event) =>
2239
+ onRangeChange(
2240
+ event.target.value as ApplicationHistoryRange,
2241
+ )
2242
+ }
2243
+ className="h-8 rounded-md border border-theme-border bg-theme-base px-2 text-xs text-theme-text-primary"
2244
+ >
2245
+ {rangeOptions.map((option) => (
2246
+ <option key={option.value} value={option.value}>
2247
+ {option.label}
2248
+ </option>
2249
+ ))}
2250
+ </select>
2251
+ )}
2252
+ {onOpenTimeline && (
2253
+ <button
2254
+ type="button"
2255
+ onClick={() => onOpenTimeline(visibleItems[0]?.timestamp)}
2256
+ className="inline-flex h-8 items-center gap-1.5 px-2 text-xs font-medium text-accent-text hover:underline"
2257
+ >
2258
+ Open full Timeline
2259
+ <ExternalLink className="h-3.5 w-3.5" />
2260
+ </button>
2261
+ )}
2262
+ </div>
2263
+ </div>
2264
+
2265
+ {availableCategories.length > 1 && (
2266
+ <div
2267
+ className="flex flex-wrap gap-1 border-b border-theme-border px-4 py-2"
2268
+ role="group"
2269
+ aria-label="Application history categories"
2270
+ >
2271
+ {(["all", "deployment", "change", "problem"] as const).map(
2272
+ (value) => {
2273
+ if (value !== "all" && !availableCategories.includes(value))
2274
+ return null;
2275
+ return (
2276
+ <button
2277
+ key={value}
2278
+ type="button"
2279
+ aria-pressed={category === value}
2280
+ onClick={() => setCategory(value)}
2281
+ className={clsx(
2282
+ "rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
2283
+ category === value
2284
+ ? "bg-accent-subtle text-accent-text"
2285
+ : "text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary",
2286
+ )}
2287
+ >
2288
+ {applicationHistoryCategoryLabel(value)}
2289
+ </button>
2290
+ );
2291
+ },
2292
+ )}
2293
+ </div>
2294
+ )}
2295
+
2296
+ {(runtimeError || (history?.partialSources?.length ?? 0) > 0) && (
2297
+ <div
2298
+ className={clsx(
2299
+ "border-b border-theme-border bg-theme-elevated px-4 py-2 text-xs",
2300
+ HEALTH_META.degraded.text,
2301
+ )}
2302
+ >
2303
+ {runtimeError && (
2304
+ <div>
2305
+ {hasRuntimeItems
2306
+ ? "Application changes could not be refreshed. Previously loaded changes may be stale."
2307
+ : "Application changes could not be loaded."}
2308
+ {!hasRuntimeItems &&
2309
+ items.some((item) => item.category === "deployment") &&
2310
+ " Deployment-source history is still shown."}
2311
+ </div>
2312
+ )}
2313
+ {history?.partialSources?.map((message, index) => (
2314
+ <div key={`${message}-${index}`}>{message}</div>
2315
+ ))}
2316
+ </div>
2317
+ )}
2318
+
2319
+ {loading && items.length === 0 && (
2320
+ <div className="flex items-center gap-2 px-4 py-8 text-sm text-theme-text-tertiary">
2321
+ <RefreshCw className="h-4 w-4 animate-spin" />
2322
+ Loading application history...
2323
+ </div>
2324
+ )}
2325
+
2326
+ {groups.map(([label, dateItems]) => (
2327
+ <div key={label}>
2328
+ <div className="border-b border-theme-border bg-theme-base px-4 py-2 text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
2329
+ {label}
2330
+ </div>
2331
+ <div className="divide-y divide-theme-border">
2332
+ {dateItems.map((item) => (
2333
+ <ApplicationHistoryLine
2334
+ key={item.id}
2335
+ item={item}
2336
+ workloads={workloads}
2337
+ onSelectWorkload={onSelectWorkload}
2338
+ onNavigateToResource={onNavigateToResource}
2339
+ onOpenSource={onOpenSource}
2340
+ />
2341
+ ))}
2342
+ </div>
2343
+ </div>
2344
+ ))}
2345
+
2346
+ {empty && (
2347
+ <div className="px-4 py-8">
2348
+ <EmptyState
2349
+ variant="inline"
2350
+ headline="No application history in this range."
2351
+ body="Overview still shows the current application state."
2352
+ />
2353
+ </div>
2354
+ )}
2355
+
2356
+ <div className="space-y-1 border-t border-theme-border px-4 py-2 text-xs text-theme-text-tertiary">
2357
+ {mode === "local" && (
2358
+ <div>
2359
+ Changes and problems reflect events retained by this Radar
2360
+ instance. Deployment-source history may cover a different
2361
+ period.
2362
+ </div>
2363
+ )}
2364
+ {mode === "retained" && (coverageRecordCount ?? 0) > 0 && (
2365
+ <div>
2366
+ Recording coverage is incomplete for part of this range.
2367
+ </div>
2368
+ )}
2369
+ {mode === "retained" && runtimeLimited && (
2370
+ <div>
2371
+ Showing the 10,000 most recent events in these namespaces. Older
2372
+ application activity in this range may not be included.
2373
+ </div>
2374
+ )}
2375
+ </div>
2376
+ </section>
2377
+ </div>
2378
+ </div>
2379
+ );
2380
+ }
2381
+
2382
+ function ApplicationHistoryLine({
2383
+ item,
2384
+ workloads,
2385
+ onSelectWorkload,
2386
+ onNavigateToResource,
2387
+ onOpenSource,
2388
+ }: {
2389
+ item: ApplicationHistoryItem;
2390
+ workloads: AppWorkload[];
2391
+ onSelectWorkload: (workload: AppWorkload) => void;
2392
+ onNavigateToResource?: (resource: ResourceRef) => void;
2393
+ onOpenSource?: (source: AppSourceRef) => void;
2394
+ }) {
2395
+ const workload = item.resource
2396
+ ? workloads.find(
2397
+ (candidate) =>
2398
+ candidate.kind.toLowerCase() === item.resource!.kind.toLowerCase() &&
2399
+ candidate.namespace === item.resource!.namespace &&
2400
+ candidate.name === item.resource!.name,
2401
+ )
2402
+ : undefined;
2403
+ const Icon =
2404
+ item.category === "deployment"
2405
+ ? GitCommit
2406
+ : item.category === "problem"
2407
+ ? AlertTriangle
2408
+ : item.resource
2409
+ ? getTopologyIcon(item.resource.kind)
2410
+ : Activity;
2411
+ const action =
2412
+ item.sourceRef && onOpenSource
2413
+ ? {
2414
+ label: sourceLinkLabel(item.sourceRef),
2415
+ run: () => onOpenSource(item.sourceRef!),
2416
+ }
2417
+ : workload
2418
+ ? { label: "Open workload", run: () => onSelectWorkload(workload) }
2419
+ : item.resource && onNavigateToResource
2420
+ ? {
2421
+ label: "Open resource",
2422
+ run: () => onNavigateToResource(item.resource!),
2423
+ }
2424
+ : null;
2425
+ const revisionAlreadyInTitle = Boolean(
2426
+ item.revision &&
2427
+ item.title.toLowerCase().startsWith("helm revision ") &&
2428
+ item.title.endsWith(item.revision),
2429
+ );
2430
+
2431
+ return (
2432
+ <div className="grid grid-cols-[auto_minmax(0,1fr)_auto] gap-3 px-4 py-3">
2433
+ <div
2434
+ className={clsx(
2435
+ "flex h-8 w-8 shrink-0 items-center justify-center rounded-md border",
2436
+ item.category === "problem"
2437
+ ? clsx(
2438
+ "border-theme-border bg-theme-elevated",
2439
+ HEALTH_META.degraded.text,
2440
+ )
2441
+ : "border-theme-border bg-theme-elevated text-theme-text-secondary",
2442
+ )}
2443
+ >
2444
+ <Icon className="h-4 w-4" />
2445
+ </div>
2446
+ <div className="min-w-0">
2447
+ <div className="flex flex-wrap items-center gap-2">
2448
+ <span className="font-medium text-theme-text-primary">
2449
+ {item.title}
2450
+ </span>
2451
+ {item.status && (
2452
+ <span className={`${CHIP} ${historyStatusTone(item.status)}`}>
2453
+ {item.status}
2454
+ </span>
2455
+ )}
2456
+ {item.count && item.count > 1 && (
2457
+ <span className={`${CHIP} ${CHIP_TONE.muted}`}>{item.count}x</span>
2458
+ )}
2459
+ </div>
2460
+ {item.revision && !revisionAlreadyInTitle && (
2461
+ <div className="truncate font-mono text-sm text-theme-text-secondary">
2462
+ {item.revision}
2463
+ </div>
2464
+ )}
2465
+ {item.detail && (
2466
+ <div className="mt-1 line-clamp-2 text-sm text-theme-text-tertiary">
2467
+ {item.detail}
2468
+ </div>
2469
+ )}
2470
+ {item.resource && (
2471
+ <button
2472
+ type="button"
2473
+ onClick={action?.run}
2474
+ disabled={!action}
2475
+ className={clsx(
2476
+ "mt-1 truncate text-xs",
2477
+ action
2478
+ ? "text-accent-text hover:underline"
2479
+ : "text-theme-text-tertiary",
2480
+ )}
2481
+ >
2482
+ {getKindLabel(item.resource.kind)} · {item.resource.namespace}/
2483
+ {item.resource.name}
2484
+ </button>
2485
+ )}
2486
+ </div>
2487
+ <div className="text-right text-xs text-theme-text-tertiary">
2488
+ <div>{applicationHistoryTimeLabel(item.timestamp)}</div>
2489
+ {item.initiatedBy && (
2490
+ <div className="mt-1 max-w-40 truncate">{item.initiatedBy}</div>
2491
+ )}
2492
+ {action && item.sourceRef && (
2493
+ <button
2494
+ type="button"
2495
+ onClick={action.run}
2496
+ className="mt-1 font-medium text-accent-text hover:underline"
2497
+ >
2498
+ {action.label}
2499
+ </button>
2500
+ )}
2501
+ </div>
2502
+ </div>
2503
+ );
2504
+ }
2505
+
2506
+ function applicationHistoryCategoryLabel(
2507
+ category: ApplicationHistoryCategory | "all",
2508
+ ): string {
2509
+ if (category === "all") return "All";
2510
+ if (category === "deployment") return "Deployments";
2511
+ if (category === "problem") return "Problems";
2512
+ return "Changes";
2513
+ }
2514
+
2515
+ function applicationHistoryDateLabel(timestamp: string): string {
2516
+ const date = new Date(timestamp);
2517
+ const today = new Date();
2518
+ const yesterday = new Date(today);
2519
+ yesterday.setDate(today.getDate() - 1);
2520
+ if (date.toDateString() === today.toDateString()) return "Today";
2521
+ if (date.toDateString() === yesterday.toDateString()) return "Yesterday";
2522
+ return new Intl.DateTimeFormat(undefined, {
2523
+ month: "short",
2524
+ day: "numeric",
2525
+ year: date.getFullYear() === today.getFullYear() ? undefined : "numeric",
2526
+ }).format(date);
2527
+ }
2528
+
2529
+ function applicationHistoryTimeLabel(timestamp: string): string {
2530
+ return new Intl.DateTimeFormat(undefined, {
2531
+ hour: "2-digit",
2532
+ minute: "2-digit",
2533
+ }).format(new Date(timestamp));
2534
+ }
2535
+
2536
+ function buildAppIssues(
2537
+ workloads: AppWorkload[],
2538
+ events: NonNullable<AppRow["events"]>,
2539
+ ): AppIssue[] {
2540
+ const issues: AppIssue[] = [];
2541
+ const issueByWorkload = new Map<string, AppIssue>();
2542
+
2543
+ for (const workload of workloads) {
2544
+ const health = healthOf(workload.health);
2545
+ const notReady = workload.desired > 0 && workload.ready < workload.desired;
2546
+ const hasRestarts = (workload.restarts ?? 0) > 0;
2547
+ const hasReason = Boolean(workload.reason);
2548
+ const hasHealthProblem = health === "degraded" || health === "unhealthy";
2549
+ if (!hasHealthProblem && !notReady && !hasRestarts && !hasReason) continue;
2550
+
2551
+ const severity: AppIssueSeverity =
2552
+ health === "unhealthy" || (workload.desired > 0 && workload.ready === 0)
2553
+ ? "error"
2554
+ : "warning";
2555
+ const detail = [
2556
+ notReady ? `${workload.ready}/${workload.desired} ready` : undefined,
2557
+ hasRestarts ? pluralize(workload.restarts, "restart") : undefined,
2558
+ workload.reason,
2559
+ ]
2560
+ .filter(Boolean)
2561
+ .join(" · ");
2562
+ const issue: AppIssue = {
2563
+ key: `workload:${workloadKey(workload)}`,
2564
+ severity,
2565
+ title: `${workload.name} ${health === "unhealthy" ? "is down" : health === "degraded" || notReady ? "is degraded" : "needs attention"}`,
2566
+ detail,
2567
+ workload,
2568
+ };
2569
+ issueByWorkload.set(workloadKey(workload), issue);
2570
+ issues.push(issue);
2571
+ }
2572
+
2573
+ for (const event of events) {
2574
+ const workload = directWorkloadForEvent(event, workloads);
2575
+ if (workload) {
2576
+ const existing = issueByWorkload.get(workloadKey(workload));
2577
+ if (existing) {
2578
+ existing.detail = [existing.detail, event.reason]
2579
+ .filter(Boolean)
2580
+ .join(" · ");
2581
+ continue;
2582
+ }
2583
+ issues.push({
2584
+ key: `event:${event.object}:${event.reason}`,
2585
+ severity: "warning",
2586
+ title: `${event.reason} on ${workload.name}`,
2587
+ detail: event.message,
2588
+ workload,
2589
+ });
2590
+ continue;
2591
+ }
2592
+ issues.push({
2593
+ key: `event:${event.object}:${event.reason}`,
2594
+ severity: "warning",
2595
+ title: `${event.reason} on ${event.object}`,
2596
+ detail: event.message,
2597
+ });
2598
+ }
2599
+
2600
+ const rank: Record<AppIssueSeverity, number> = {
2601
+ error: 0,
2602
+ warning: 1,
2603
+ info: 2,
2604
+ };
2605
+ return issues.sort(
2606
+ (a, b) =>
2607
+ rank[a.severity] - rank[b.severity] || a.title.localeCompare(b.title),
2608
+ );
2609
+ }
2610
+
2611
+ function directWorkloadForEvent(
2612
+ event: NonNullable<AppRow["events"]>[number],
2613
+ workloads: AppWorkload[],
2614
+ ): AppWorkload | undefined {
2615
+ const parsed = parseEventObject(event.object);
2616
+ if (!parsed) return undefined;
2617
+ const matches = workloads.filter(
2618
+ (w) =>
2619
+ w.kind.toLowerCase() === parsed.kind.toLowerCase() &&
2620
+ w.name === parsed.name,
2621
+ );
2622
+ return matches.length === 1 ? matches[0] : undefined;
2623
+ }
2624
+
2625
+ function parseEventObject(
2626
+ object: string,
2627
+ ): { kind: string; name: string } | null {
2628
+ const slash = object.indexOf("/");
2629
+ if (slash <= 0 || slash === object.length - 1) return null;
2630
+ return { kind: object.slice(0, slash), name: object.slice(slash + 1) };
2631
+ }
2632
+
2633
+ function issueTone(severity: AppIssueSeverity): string {
2634
+ if (severity === "error") return CHIP_TONE.rose;
2635
+ if (severity === "warning") return CHIP_TONE.amber;
2636
+ return CHIP_TONE.blue;
2637
+ }
2638
+
2639
+ function historyStatusTone(status: string): string {
2640
+ const s = status.toLowerCase();
2641
+ if (s.includes("fail") || s.includes("error")) return CHIP_TONE.rose;
2642
+ if (s.includes("running") || s.includes("pending") || s.includes("progress"))
2643
+ return CHIP_TONE.amber;
2644
+ if (s.includes("succeed") || s.includes("deployed") || s.includes("ready"))
2645
+ return CHIP_TONE.emerald;
2646
+ return CHIP_TONE.muted;
2647
+ }
2648
+
2649
+ function sourceLinkLabel(source: AppSourceRef): string {
2650
+ return `View ${sourceObjectLabel(source)}`;
2651
+ }
2652
+
2653
+ function sourceObjectLabel(source: AppSourceRef): string {
2654
+ if (source.type === "helm") return "Helm release";
2655
+ if (source.tool === "argocd" || source.kind.toLowerCase() === "application")
2656
+ return "Argo CD application";
2657
+ if (source.kind.toLowerCase() === "helmrelease") return "Flux HelmRelease";
2658
+ if (source.kind.toLowerCase() === "kustomization")
2659
+ return "Flux Kustomization";
2660
+ return "GitOps source";
2661
+ }
2662
+
2663
+ function sourceInventoryLabel(source: AppSourceRef): string {
2664
+ if (source.type === "helm") return "Helm";
2665
+ if (source.tool === "argocd" || source.kind.toLowerCase() === "application")
2666
+ return "Argo CD";
2667
+ if (source.tool === "fluxcd") return "Flux";
2668
+ return "GitOps";
2669
+ }
2670
+
2671
+ function SourceDeliveryStatus({
2672
+ status,
2673
+ }: {
2674
+ status: NonNullable<AppRow["sourceStatus"]>;
2675
+ }) {
2676
+ const values = [
2677
+ status.sync
2678
+ ? { label: status.sync, tone: sourceSyncHealth(status.sync) }
2679
+ : null,
2680
+ status.health
2681
+ ? { label: status.health, tone: sourceReportedHealth(status.health) }
2682
+ : null,
2683
+ ].filter(
2684
+ (value): value is { label: string; tone: AppHealth } => value !== null,
2685
+ );
2686
+
2687
+ return (
2688
+ <span className="inline-flex min-w-0 items-center gap-2">
2689
+ {values.map((value, index) => (
2690
+ <span
2691
+ key={`${value.label}-${index}`}
2692
+ className="inline-flex min-w-0 items-center gap-1.5"
2693
+ >
2694
+ {index > 0 && (
2695
+ <span className="text-theme-text-tertiary" aria-hidden>
2696
+ ·
2697
+ </span>
2698
+ )}
2699
+ <StatusDot tone={mapHealthToTone(value.tone)} />
2700
+ <span className="truncate">{value.label}</span>
2701
+ </span>
2702
+ ))}
2703
+ </span>
2704
+ );
2705
+ }
2706
+
2707
+ function formatAppEventTime(value?: string): string {
2708
+ if (!value || value.startsWith("0001-01-01T00:00:00")) return "";
2709
+ return value;
2710
+ }
2711
+
2712
+ function ApplicationPanel({
2713
+ title,
2714
+ children,
2715
+ }: {
2716
+ title: ReactNode;
2717
+ children: ReactNode;
2718
+ }) {
2719
+ return (
2720
+ <section className="rounded-lg border border-theme-border bg-theme-surface p-4 shadow-theme-sm">
2721
+ <h2 className="mb-3 text-sm font-semibold text-theme-text-primary">
2722
+ {title}
2723
+ </h2>
2724
+ {children}
2725
+ </section>
2726
+ );
2727
+ }
2728
+
2729
+ function ApplicationFact({
2730
+ label,
2731
+ value,
2732
+ detail,
2733
+ monoValue,
2734
+ monoDetail,
2735
+ variant = "card",
2736
+ }: {
2737
+ label: string;
2738
+ value: ReactNode;
2739
+ detail?: string;
2740
+ monoValue?: boolean;
2741
+ monoDetail?: boolean;
2742
+ variant?: "card" | "row" | "bare";
2743
+ }) {
2744
+ if (variant === "row") {
2745
+ return (
2746
+ <div className="min-w-0 border-b border-theme-border pb-3 last:border-b-0">
2747
+ <div className="text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
2748
+ {label}
2749
+ </div>
2750
+ <div
2751
+ className={clsx(
2752
+ "mt-1 truncate text-sm font-semibold text-theme-text-primary",
2753
+ monoValue && "font-mono",
2754
+ )}
2755
+ >
2756
+ {value}
2757
+ </div>
2758
+ {detail && (
2759
+ <div
2760
+ className={clsx(
2761
+ "mt-0.5 truncate text-xs text-theme-text-tertiary",
2762
+ monoDetail && "font-mono",
2763
+ )}
2764
+ >
2765
+ {detail}
2766
+ </div>
2767
+ )}
2768
+ </div>
2769
+ );
2770
+ }
2771
+
2772
+ if (variant === "bare") {
2773
+ return (
2774
+ <div className="min-w-0">
2775
+ <div className="text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
2776
+ {label}
2777
+ </div>
2778
+ <div
2779
+ className={clsx(
2780
+ "mt-1 truncate text-sm font-semibold text-theme-text-primary",
2781
+ monoValue && "font-mono",
2782
+ )}
2783
+ >
2784
+ {value}
2785
+ </div>
2786
+ {detail && (
2787
+ <div
2788
+ className={clsx(
2789
+ "mt-0.5 truncate text-xs text-theme-text-tertiary",
2790
+ monoDetail && "font-mono",
2791
+ )}
2792
+ >
2793
+ {detail}
2794
+ </div>
2795
+ )}
2796
+ </div>
2797
+ );
2798
+ }
2799
+
2800
+ return (
2801
+ <div className="min-w-0 rounded-lg border border-theme-border bg-theme-surface px-4 py-3 shadow-theme-sm">
2802
+ <div className="text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
2803
+ {label}
2804
+ </div>
2805
+ <div
2806
+ className={clsx(
2807
+ "mt-1 truncate text-sm font-semibold text-theme-text-primary",
2808
+ monoValue && "font-mono",
2809
+ )}
2810
+ >
2811
+ {value}
2812
+ </div>
2813
+ {detail && (
2814
+ <div
2815
+ className={clsx(
2816
+ "mt-0.5 truncate text-xs text-theme-text-tertiary",
2817
+ monoDetail && "font-mono",
2818
+ )}
2819
+ >
2820
+ {detail}
2821
+ </div>
2822
+ )}
2823
+ </div>
2824
+ );
2825
+ }
2826
+
2827
+ function ApplicationScopeSelector({
2828
+ workloads,
2829
+ selectedWorkload,
2830
+ onSelect,
2831
+ onFocus,
2832
+ }: {
2833
+ workloads: AppWorkload[];
2834
+ selectedWorkload: AppWorkload | null;
2835
+ onSelect: (key: string | null) => void;
2836
+ onFocus: (owner: WorkloadFocus) => void;
2837
+ }) {
2838
+ const [open, setOpen] = useState(false);
2839
+ const [query, setQuery] = useState("");
2840
+ const rootRef = useDismissablePopover<HTMLDivElement>(open, setOpen);
2841
+ const selectedKey = selectedWorkload ? workloadKey(selectedWorkload) : null;
2842
+ const filteredWorkloads = useMemo(() => {
2843
+ const q = query.trim().toLowerCase();
2844
+ if (!q) return workloads;
2845
+ return workloads.filter((w) =>
2846
+ `${w.kind} ${w.namespace} ${w.name}`.toLowerCase().includes(q),
2847
+ );
2848
+ }, [query, workloads]);
2849
+
2850
+ if (workloads.length <= 1) {
2851
+ return selectedWorkload ? (
2852
+ <StaticWorkloadScope workload={selectedWorkload} />
2853
+ ) : (
2854
+ <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">
2855
+ <Boxes
2856
+ className="h-4 w-4 shrink-0 text-theme-text-secondary"
2857
+ aria-hidden
2858
+ />
2859
+ <span className="min-w-0 truncate font-medium text-theme-text-primary">
2860
+ Application
2861
+ </span>
2862
+ </span>
2863
+ );
2864
+ }
2865
+
2866
+ return (
2867
+ <div ref={rootRef} className="relative min-w-0">
2868
+ <button
2869
+ type="button"
2870
+ onClick={() => setOpen((o) => !o)}
2871
+ aria-haspopup="listbox"
2872
+ aria-expanded={open}
2873
+ 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"
2874
+ >
2875
+ {selectedWorkload ? (
2876
+ <WorkloadKindIcon workload={selectedWorkload} />
2877
+ ) : (
2878
+ <Boxes
2879
+ className="h-4 w-4 shrink-0 text-theme-text-secondary"
2880
+ aria-hidden
2881
+ />
2882
+ )}
2883
+ <span className="min-w-0 truncate font-medium text-theme-text-primary">
2884
+ {selectedWorkload ? selectedWorkload.name : "Application"}
2885
+ </span>
2886
+ {selectedWorkload && (
2887
+ <span className="hidden shrink-0 text-xs uppercase tracking-wide text-theme-text-tertiary md:inline">
2888
+ {selectedWorkload.kind}
2889
+ </span>
2890
+ )}
2891
+ <ChevronDown
2892
+ className={clsx(
2893
+ "h-3.5 w-3.5 shrink-0 text-theme-text-tertiary transition-transform",
2894
+ open && "rotate-180",
2895
+ )}
2896
+ aria-hidden
2897
+ />
2898
+ </button>
2899
+ {open && (
2900
+ <div
2901
+ role="listbox"
2902
+ 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"
2903
+ onMouseLeave={() => onFocus(null)}
2904
+ >
2905
+ <div className="border-b border-theme-border p-1">
2906
+ <ScopeOption
2907
+ active={selectedKey === null}
2908
+ icon={
2909
+ <Boxes
2910
+ className="h-4 w-4 text-theme-text-secondary"
2911
+ aria-hidden
2912
+ />
2913
+ }
2914
+ title="Application"
2915
+ subtitle="App scope"
2916
+ onClick={() => {
2917
+ setOpen(false);
2918
+ onSelect(null);
2919
+ }}
2920
+ onMouseEnter={() => onFocus(null)}
2921
+ />
2922
+ </div>
2923
+ <div className="border-b border-theme-border px-2 py-1.5">
2924
+ <div className="flex items-center justify-between gap-3">
2925
+ <span className="text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
2926
+ Workloads
2927
+ </span>
2928
+ <span className="rounded-full bg-theme-hover px-1.5 text-[10px] font-medium text-theme-text-tertiary">
2929
+ {workloads.length}
2930
+ </span>
2931
+ </div>
2932
+ {workloads.length > 8 && (
2933
+ <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">
2934
+ <Search
2935
+ className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary"
2936
+ aria-hidden
2937
+ />
2938
+ <input
2939
+ value={query}
2940
+ onChange={(e) => setQuery(e.target.value)}
2941
+ placeholder="Filter workloads..."
2942
+ className="min-w-0 flex-1 bg-transparent text-sm text-theme-text-primary outline-none placeholder:text-theme-text-tertiary"
2943
+ />
2944
+ </label>
2945
+ )}
2946
+ </div>
2947
+ <div className="max-h-80 overflow-y-auto p-1">
2948
+ {filteredWorkloads.length === 0 ? (
2949
+ <div className="px-2 py-4 text-center text-sm text-theme-text-tertiary">
2950
+ No workloads match.
2951
+ </div>
2952
+ ) : (
2953
+ filteredWorkloads.map((w) => {
2954
+ const key = workloadKey(w);
2955
+ return (
2956
+ <ScopeOption
2957
+ key={key}
2958
+ active={selectedKey === key}
2959
+ icon={<WorkloadKindIcon workload={w} />}
2960
+ title={w.name}
2961
+ subtitle={`${w.kind} · ${w.ready}/${w.desired} ready${w.reason ? ` · ${w.reason}` : ""}`}
2962
+ onClick={() => {
2963
+ setOpen(false);
2964
+ onSelect(key);
2965
+ }}
2966
+ onMouseEnter={() => onFocus(key)}
2967
+ />
2968
+ );
2969
+ })
2970
+ )}
2971
+ </div>
2972
+ </div>
2973
+ )}
2974
+ </div>
2975
+ );
2976
+ }
2977
+
2978
+ function TopologyWorkloadLegend({
2979
+ workloads,
2980
+ colorByWorkload,
2981
+ focusedOwnerId,
2982
+ showSharedOrUnscoped,
2983
+ onFocus,
2984
+ onSelectWorkload,
2985
+ deploymentLayer,
2986
+ deploymentSource,
2987
+ onOpenSource,
2988
+ }: {
2989
+ workloads: AppWorkload[];
2990
+ colorByWorkload: Map<string, number> | null;
2991
+ focusedOwnerId: WorkloadFocus;
2992
+ showSharedOrUnscoped: boolean;
2993
+ onFocus: (owner: WorkloadFocus) => void;
2994
+ onSelectWorkload: (workload: AppWorkload) => void;
2995
+ deploymentLayer: DeploymentTopologyLayer | null;
2996
+ deploymentSource?: AppSourceRef;
2997
+ onOpenSource?: (source: AppSourceRef) => void;
2998
+ }) {
2999
+ const managedOnlyCount = deploymentLayer?.managedOnly.length ?? 0;
3000
+ const runtimeOnlyCount = deploymentLayer?.runtimeOnlyCount ?? 0;
3001
+ const showSourceDifferences =
3002
+ !!deploymentSource && managedOnlyCount + runtimeOnlyCount > 0;
3003
+ const sourceLabel = deploymentSource
3004
+ ? sourceInventoryLabel(deploymentSource)
3005
+ : "";
3006
+
3007
+ return (
3008
+ <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">
3009
+ <div className="flex items-center justify-between gap-3 border-b border-theme-border bg-theme-base px-3 py-2">
3010
+ <span className="text-xs font-semibold text-theme-text-primary">
3011
+ Workloads
3012
+ </span>
3013
+ <span className="rounded-full bg-theme-hover px-1.5 text-[10px] font-medium text-theme-text-tertiary">
3014
+ {workloads.length}
3015
+ </span>
3016
+ </div>
3017
+ <div
3018
+ className="max-h-72 overflow-y-auto p-1"
3019
+ onMouseLeave={() => onFocus(null)}
3020
+ >
3021
+ {workloads.map((w) => {
3022
+ const key = workloadKey(w);
3023
+ const idx = colorByWorkload?.get(key);
3024
+ const hue = idx != null ? workloadHue(idx) : null;
3025
+ return (
3026
+ <ScopeOption
3027
+ key={key}
3028
+ compact
3029
+ active={focusedOwnerId === key}
3030
+ accentColor={hue?.swatch}
3031
+ icon={<WorkloadKindIcon workload={w} compact />}
3032
+ title={w.name}
3033
+ subtitle={`${w.kind} · ${w.ready}/${w.desired}`}
3034
+ onClick={() => onSelectWorkload(w)}
3035
+ onMouseEnter={() => onFocus(key)}
3036
+ />
3037
+ );
3038
+ })}
3039
+ {showSharedOrUnscoped && (
3040
+ <ScopeOption
3041
+ compact
3042
+ muted
3043
+ active={focusedOwnerId === NEUTRAL_OWNER}
3044
+ icon={<SharedScopeMarker />}
3045
+ title="Shared / unscoped"
3046
+ onMouseEnter={() => onFocus(NEUTRAL_OWNER)}
3047
+ />
3048
+ )}
3049
+ </div>
3050
+ {showSourceDifferences && deploymentSource && (
3051
+ <div className="border-t border-theme-border px-3 py-2.5">
3052
+ <div className="mb-1.5 text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
3053
+ Source differences
3054
+ </div>
3055
+ <div className="space-y-1">
3056
+ {managedOnlyCount > 0 && (
3057
+ <div className="flex items-center gap-2 px-1.5 py-1 text-xs text-theme-text-secondary">
3058
+ <DeploymentSourceLogo source={deploymentSource} />
3059
+ <span className="min-w-0 flex-1">
3060
+ <span className="block">
3061
+ {managedOnlyCount} only in {sourceLabel}
3062
+ </span>
3063
+ {deploymentLayer?.managedOnlySummary && (
3064
+ <span className="mt-0.5 block text-[10px] text-theme-text-tertiary">
3065
+ {deploymentLayer.managedOnlySummary}
3066
+ </span>
3067
+ )}
3068
+ </span>
3069
+ {onOpenSource && (
3070
+ <Tooltip content={`Open ${sourceLabel} view`} delay={150}>
3071
+ <button
3072
+ type="button"
3073
+ onClick={() => onOpenSource(deploymentSource)}
3074
+ className="rounded p-0.5 text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary"
3075
+ >
3076
+ <ExternalLink className="h-3 w-3" aria-hidden />
3077
+ </button>
3078
+ </Tooltip>
3079
+ )}
3080
+ </div>
3081
+ )}
3082
+ {runtimeOnlyCount > 0 && (
3083
+ <div className="flex items-center gap-2 px-1.5 py-1 text-xs text-theme-text-secondary">
3084
+ <Radio
3085
+ className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary"
3086
+ aria-hidden
3087
+ />
3088
+ <span>
3089
+ {pluralize(runtimeOnlyCount, "runtime-only resource")}
3090
+ </span>
3091
+ </div>
3092
+ )}
3093
+ </div>
3094
+ </div>
3095
+ )}
3096
+ </div>
3097
+ );
3098
+ }
3099
+
3100
+ function DeploymentSourceLogo({ source }: { source: AppSourceRef }) {
3101
+ const sourceLabel = sourceInventoryLabel(source);
3102
+ const logo =
3103
+ sourceLabel === "Argo CD"
3104
+ ? argoCdLogo
3105
+ : sourceLabel === "Flux"
3106
+ ? fluxLogo
3107
+ : null;
3108
+ if (!logo)
3109
+ return (
3110
+ <Layers
3111
+ className="h-3.5 w-3.5 shrink-0 text-theme-text-tertiary"
3112
+ aria-hidden
3113
+ />
3114
+ );
3115
+ return <img src={logo} alt="" className="h-4 w-4 shrink-0 object-contain" />;
3116
+ }
3117
+
3118
+ function StaticWorkloadScope({ workload }: { workload: AppWorkload }) {
3119
+ return (
3120
+ <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">
3121
+ <WorkloadKindIcon workload={workload} />
3122
+ <span className="min-w-0 truncate font-medium text-theme-text-primary">
3123
+ {workload.name}
3124
+ </span>
3125
+ <span className="hidden shrink-0 text-xs uppercase tracking-wide text-theme-text-tertiary md:inline">
3126
+ {workload.kind}
3127
+ </span>
3128
+ </span>
3129
+ );
3130
+ }
3131
+
3132
+ function WorkloadKindIcon({
3133
+ workload,
3134
+ compact,
3135
+ }: {
3136
+ workload: AppWorkload;
3137
+ compact?: boolean;
3138
+ }) {
3139
+ const KindIcon = getTopologyIcon(workload.kind);
3140
+ return (
3141
+ <span
3142
+ className={clsx(
3143
+ "relative flex shrink-0 items-center justify-center",
3144
+ compact ? "h-4 w-4" : "h-5 w-5",
3145
+ )}
3146
+ aria-hidden
3147
+ >
3148
+ <KindIcon
3149
+ className={clsx(
3150
+ "text-theme-text-secondary",
3151
+ compact ? "h-3.5 w-3.5" : "h-4 w-4",
3152
+ )}
3153
+ />
3154
+ <StatusDot
3155
+ tone={mapHealthToTone(healthOf(workload.health))}
3156
+ size="xs"
3157
+ className="absolute -bottom-0.5 -right-0.5 ring-2 ring-theme-base"
3158
+ />
3159
+ </span>
3160
+ );
3161
+ }
3162
+
3163
+ function SharedScopeMarker() {
3164
+ return (
3165
+ <span className="flex w-5 shrink-0 items-center gap-1" aria-hidden>
3166
+ <span className="block h-4 w-1 rounded-full bg-theme-border" />
3167
+ <span className="h-1.5 w-1.5 rounded-full bg-theme-border" />
3168
+ </span>
3169
+ );
3170
+ }
3171
+
3172
+ function ScopeOption({
3173
+ active,
3174
+ muted,
3175
+ compact,
3176
+ onClick,
3177
+ onMouseEnter,
3178
+ icon,
3179
+ title,
3180
+ subtitle,
3181
+ accentColor,
3182
+ }: {
3183
+ active?: boolean;
3184
+ muted?: boolean;
3185
+ compact?: boolean;
3186
+ onClick?: () => void;
3187
+ onMouseEnter?: () => void;
3188
+ icon: ReactNode;
3189
+ title: string;
3190
+ subtitle?: string;
3191
+ accentColor?: string;
3192
+ }) {
3193
+ const className = clsx(
3194
+ "relative flex w-full items-center gap-2 rounded-md text-left transition-colors",
3195
+ compact ? "px-2 py-1.5" : "px-2 py-2",
3196
+ active ? "selection selection-ring" : onClick && "hover:bg-theme-hover",
3197
+ !onClick && "cursor-default",
3198
+ );
3199
+ const inner = (
3200
+ <>
3201
+ {accentColor && (
3202
+ <span
3203
+ className="absolute inset-y-2 left-0 w-1 rounded-full"
3204
+ style={{ background: accentColor }}
3205
+ aria-hidden
3206
+ />
3207
+ )}
3208
+ {icon}
3209
+ <span className="min-w-0 flex-1">
3210
+ <span
3211
+ className={clsx(
3212
+ "block truncate text-sm",
3213
+ muted
3214
+ ? "text-theme-text-tertiary"
3215
+ : "font-medium text-theme-text-primary",
3216
+ )}
3217
+ >
3218
+ {title}
3219
+ </span>
3220
+ {subtitle && (
3221
+ <span className="block truncate text-[10px] uppercase tracking-wide text-theme-text-tertiary">
3222
+ {subtitle}
3223
+ </span>
3224
+ )}
3225
+ </span>
3226
+ </>
3227
+ );
3228
+ return onClick ? (
3229
+ <button
3230
+ type="button"
3231
+ onClick={onClick}
3232
+ onMouseEnter={onMouseEnter}
3233
+ className={className}
3234
+ >
3235
+ {inner}
3236
+ </button>
3237
+ ) : (
3238
+ <div onMouseEnter={onMouseEnter} className={className}>
3239
+ {inner}
3240
+ </div>
3241
+ );
3242
+ }
3243
+
3244
+ function useDismissablePopover<T extends HTMLElement>(
3245
+ open: boolean,
3246
+ setOpen: (open: boolean) => void,
3247
+ ) {
3248
+ const rootRef = useRef<T>(null);
3249
+ useEffect(() => {
3250
+ if (!open) return;
3251
+ const onDown = (e: MouseEvent) => {
3252
+ if (rootRef.current && !rootRef.current.contains(e.target as Node))
3253
+ setOpen(false);
3254
+ };
3255
+ const onKey = (e: KeyboardEvent) => {
3256
+ if (e.key === "Escape") setOpen(false);
3257
+ };
3258
+ document.addEventListener("mousedown", onDown);
3259
+ document.addEventListener("keydown", onKey);
3260
+ return () => {
3261
+ document.removeEventListener("mousedown", onDown);
3262
+ document.removeEventListener("keydown", onKey);
3263
+ };
3264
+ }, [open, setOpen]);
3265
+ return rootRef;
3266
+ }
3267
+
3268
+ function renderRuntime(
3269
+ workload: SelectedAppWorkload | undefined,
3270
+ renderWorkload: (workload: SelectedAppWorkload) => ReactNode,
3271
+ ): ReactNode {
3272
+ if (!workload) {
3273
+ return (
3274
+ <div className="rounded-md border border-dashed border-theme-border p-8 text-center text-sm text-theme-text-tertiary">
3275
+ This application has no inspectable workloads.
3276
+ </div>
3277
+ );
3278
+ }
3279
+ return (
3280
+ <div className="flex h-full min-h-0 flex-col">
3281
+ <div key={workloadKey(workload)} className="min-h-0 flex-1">
3282
+ {renderWorkload(workload)}
3283
+ </div>
3284
+ </div>
3285
+ );
3286
+ }
3287
+
3288
+ function restartWarning(
3289
+ workloads: AppWorkload[],
3290
+ ): { restarts: number; reason?: string; workload: string } | null {
3291
+ let worst: { restarts: number; reason?: string; workload: string } | null =
3292
+ null;
3293
+ for (const w of workloads) {
3294
+ const r = w.restarts ?? 0;
3295
+ if (r > 0 && (!worst || r > worst.restarts)) {
3296
+ worst = {
3297
+ restarts: r,
3298
+ reason: w.reason,
3299
+ workload: `${w.kind}/${w.name}`,
3300
+ };
3301
+ }
3302
+ }
3303
+ return worst;
3304
+ }
3305
+
3306
+ // EnvSwitcher — the Environment fact's interactive form when one app runs in
3307
+ // several environments. Up to MAX_INLINE_ENVS: inline pills (the at-a-glance
3308
+ // ladder). More: a picker popover with the full ladder-ordered list — same
3309
+ // affordance at 5 envs or 100. Always ends with the evidence chip
3310
+ // (AppIdentityTooltip) and, when a ranked lower env outruns a ranked higher one,
3311
+ // the amber lag chip.
3312
+ const MAX_INLINE_ENVS = 4;
3313
+
3314
+ function EnvSwitcher({
3315
+ identityKey,
3316
+ instances,
3317
+ activeKey,
3318
+ onSwitch,
3319
+ }: {
3320
+ identityKey: string;
3321
+ instances: AppIdentityInstance[];
3322
+ activeKey: string;
3323
+ onSwitch?: (appKey: string) => void;
3324
+ }) {
3325
+ const [open, setOpen] = useState(false);
3326
+ const rootRef = useDismissablePopover<HTMLDivElement>(open, setOpen);
3327
+
3328
+ const envInstances = useMemo(
3329
+ () => collapseIdentityInstances(instances, activeKey),
3330
+ [instances, activeKey],
3331
+ );
3332
+ const lag = appGroupLagMessage(envInstances);
3333
+ const active =
3334
+ envInstances.find((i) => i.appKey === activeKey) ??
3335
+ instances.find((i) => i.appKey === activeKey);
3336
+ const envCounts = useMemo(() => {
3337
+ const counts = new Map<string, number>();
3338
+ for (const inst of instances)
3339
+ counts.set(inst.env, (counts.get(inst.env) ?? 0) + 1);
3340
+ return counts;
3341
+ }, [instances]);
3342
+ const evidenceChip = (
3343
+ <Tooltip
3344
+ content={
3345
+ <AppIdentityTooltip
3346
+ identityKey={identityKey}
3347
+ members={instances.map((i) => ({
3348
+ name: i.name,
3349
+ env: i.env,
3350
+ confidence: i.confidence,
3351
+ evidence: i.evidence,
3352
+ }))}
3353
+ />
3354
+ }
3355
+ delay={150}
3356
+ >
3357
+ <span className="inline-flex cursor-default items-center rounded-sm bg-theme-hover px-1 py-px ring-1 ring-inset ring-theme-border">
3358
+ <Layers className="h-3 w-3 text-theme-text-tertiary" aria-hidden />
3359
+ </span>
3360
+ </Tooltip>
3361
+ );
3362
+ const lagChip = lag && (
3363
+ <span className={`${CHIP} ${CHIP_TONE.amber}`}>{lag}</span>
3364
+ );
3365
+
3366
+ if (envInstances.length <= MAX_INLINE_ENVS) {
3367
+ return (
3368
+ <span className="flex flex-wrap items-center gap-1">
3369
+ {instances.map((inst) => {
3370
+ const isActive = inst.appKey === activeKey;
3371
+ const duplicateEnv = (envCounts.get(inst.env) ?? 0) > 1;
3372
+ return (
3373
+ <Tooltip
3374
+ key={inst.appKey}
3375
+ content={`${inst.name}${inst.version ? ` · ${inst.version}` : ""}`}
3376
+ delay={150}
3377
+ >
3378
+ <button
3379
+ type="button"
3380
+ disabled={isActive}
3381
+ onClick={() => !isActive && onSwitch?.(inst.appKey)}
3382
+ className={clsx(
3383
+ "inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs ring-1 ring-inset transition-colors",
3384
+ isActive
3385
+ ? "selection selection-ring font-medium"
3386
+ : "bg-theme-surface ring-theme-border hover:bg-theme-hover",
3387
+ )}
3388
+ >
3389
+ <StatusDot tone={mapHealthToTone(inst.health)} />
3390
+ {inst.env}
3391
+ {duplicateEnv ? (
3392
+ <span className="max-w-24 truncate text-theme-text-tertiary">
3393
+ {inst.name}
3394
+ </span>
3395
+ ) : null}
3396
+ </button>
3397
+ </Tooltip>
3398
+ );
3399
+ })}
3400
+ {evidenceChip}
3401
+ {lagChip}
3402
+ </span>
3403
+ );
3404
+ }
3405
+
3406
+ return (
3407
+ <div ref={rootRef} className="relative flex items-center gap-1">
3408
+ <button
3409
+ type="button"
3410
+ onClick={() => setOpen((o) => !o)}
3411
+ aria-haspopup="listbox"
3412
+ aria-expanded={open}
3413
+ 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"
3414
+ >
3415
+ {active && <StatusDot tone={mapHealthToTone(active.health)} />}
3416
+ <span className="font-medium">{active?.env ?? "—"}</span>
3417
+ <span className="text-theme-text-tertiary">
3418
+ · {envInstances.length} environments
3419
+ </span>
3420
+ <ChevronDown
3421
+ className={clsx(
3422
+ "h-3 w-3 text-theme-text-tertiary transition-transform",
3423
+ open && "rotate-180",
3424
+ )}
3425
+ aria-hidden
3426
+ />
3427
+ </button>
3428
+ {evidenceChip}
3429
+ {lagChip}
3430
+ {open && (
3431
+ <div
3432
+ role="listbox"
3433
+ 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"
3434
+ >
3435
+ {instances.map((inst) => {
3436
+ const isActive = inst.appKey === activeKey;
3437
+ return (
3438
+ <button
3439
+ key={inst.appKey}
3440
+ type="button"
3441
+ role="option"
3442
+ aria-selected={isActive}
3443
+ onClick={() => {
3444
+ setOpen(false);
3445
+ if (!isActive) onSwitch?.(inst.appKey);
3446
+ }}
3447
+ className={clsx(
3448
+ "flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs",
3449
+ isActive
3450
+ ? "selection selection-ring"
3451
+ : "hover:bg-theme-hover",
3452
+ )}
3453
+ >
3454
+ <StatusDot tone={mapHealthToTone(inst.health)} />
3455
+ <span className="w-20 shrink-0 font-medium text-theme-text-primary">
3456
+ {inst.env}
3457
+ </span>
3458
+ <span className="min-w-0 flex-1 truncate text-theme-text-secondary">
3459
+ {inst.name}
3460
+ </span>
3461
+ {inst.version && (
3462
+ <span className="font-mono text-[10px] text-theme-text-tertiary">
3463
+ {midTruncate(inst.version, 18)}
3464
+ </span>
3465
+ )}
3466
+ </button>
3467
+ );
3468
+ })}
3469
+ </div>
3470
+ )}
3471
+ </div>
3472
+ );
678
3473
  }