@skyhook-io/radar-app 1.8.6 → 1.8.8

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 (109) hide show
  1. package/README.md +7 -1
  2. package/package.json +12 -10
  3. package/src/App.tsx +278 -230
  4. package/src/RadarApp.tsx +91 -20
  5. package/src/api/client.argoResourceSync.test.ts +69 -0
  6. package/src/api/client.delta.test.ts +89 -0
  7. package/src/api/client.deltaSync.test.ts +216 -0
  8. package/src/api/client.rightsizing.test.ts +32 -0
  9. package/src/api/client.ts +1462 -246
  10. package/src/api/diagnose.ts +288 -0
  11. package/src/api/timelineSource.test.ts +217 -0
  12. package/src/api/timelineSource.ts +582 -0
  13. package/src/components/ConnectionErrorView.tsx +174 -70
  14. package/src/components/ContextSwitcher.tsx +13 -5
  15. package/src/components/applications/ApplicationsView.tsx +775 -80
  16. package/src/components/audit/AuditSettingsDialog.tsx +2 -2
  17. package/src/components/cost/ApplicationCostTab.test.ts +204 -0
  18. package/src/components/cost/ApplicationCostTab.tsx +571 -0
  19. package/src/components/cost/CostTrendChart.tsx +103 -72
  20. package/src/components/cost/CostView.test.ts +12 -0
  21. package/src/components/cost/CostView.tsx +494 -229
  22. package/src/components/cost/CostViewTabs.test.tsx +21 -0
  23. package/src/components/cost/CostViewTabs.tsx +40 -0
  24. package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
  25. package/src/components/cost/CurrentAllocationUse.tsx +126 -0
  26. package/src/components/cost/WorkloadCostTab.test.ts +153 -0
  27. package/src/components/cost/WorkloadCostTab.tsx +372 -0
  28. package/src/components/cost/cloud-console.test.ts +39 -0
  29. package/src/components/cost/cloud-console.ts +81 -0
  30. package/src/components/cost/errors.ts +8 -0
  31. package/src/components/cost/format.test.ts +27 -0
  32. package/src/components/cost/format.ts +46 -0
  33. package/src/components/cost/kinds.ts +5 -0
  34. package/src/components/curl/ServiceCurlButton.tsx +2 -2
  35. package/src/components/diagnose/AISettings.tsx +116 -0
  36. package/src/components/diagnose/DiagnoseContext.tsx +491 -0
  37. package/src/components/diagnose/DiagnoseSurface.tsx +385 -0
  38. package/src/components/diagnose/Home.tsx +163 -0
  39. package/src/components/diagnose/InvestigationView.tsx +604 -0
  40. package/src/components/diagnose/LocalDiagnoseAction.tsx +150 -0
  41. package/src/components/diagnose/launch.ts +65 -0
  42. package/src/components/diagnose/parts.tsx +1689 -0
  43. package/src/components/dock/BottomDock.tsx +2 -3
  44. package/src/components/dock/WorkloadLogsTab.tsx +21 -5
  45. package/src/components/execution/BatchExecutionView.test.ts +170 -0
  46. package/src/components/execution/BatchExecutionView.tsx +1329 -0
  47. package/src/components/execution/batch-run-actions.test.ts +48 -0
  48. package/src/components/execution/batch-run-actions.ts +24 -0
  49. package/src/components/execution/batch-timeline.test.ts +57 -0
  50. package/src/components/execution/batch-timeline.ts +46 -0
  51. package/src/components/execution/execution-definition.test.ts +208 -0
  52. package/src/components/execution/execution-definition.ts +245 -0
  53. package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
  54. package/src/components/gitops/GitOpsView.tsx +81 -14
  55. package/src/components/gitops/RevisionMetaChip.tsx +63 -0
  56. package/src/components/helm/ChartBrowser.tsx +2 -3
  57. package/src/components/helm/HelmCompareRoute.tsx +1 -2
  58. package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
  59. package/src/components/helm/HelmReleaseDrawer.tsx +376 -43
  60. package/src/components/helm/HelmView.tsx +2 -3
  61. package/src/components/helm/InstallWizard.tsx +3 -5
  62. package/src/components/helm/ManifestDiffViewer.tsx +1 -31
  63. package/src/components/helm/TrackChartSourceDialog.tsx +48 -4
  64. package/src/components/helm/ValuesDiffPreview.tsx +17 -7
  65. package/src/components/home/CostCard.tsx +21 -36
  66. package/src/components/home/HomeView.tsx +17 -15
  67. package/src/components/home/MCPSetupDialog.tsx +1 -1
  68. package/src/components/home/mcpToolCatalog.ts +2 -2
  69. package/src/components/issues/IssuesPane.tsx +9 -1
  70. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
  71. package/src/components/portforward/PortForwardButton.tsx +2 -2
  72. package/src/components/portforward/PortForwardManager.tsx +19 -14
  73. package/src/components/resource/PrometheusChartsGrid.tsx +6 -80
  74. package/src/components/resource/RightsizingStrip.test.ts +109 -0
  75. package/src/components/resource/RightsizingStrip.tsx +320 -127
  76. package/src/components/resources/ImageFilesystemModal.tsx +2 -2
  77. package/src/components/resources/PodFilesystemModal.tsx +2 -3
  78. package/src/components/resources/ResourceDetailDrawer.tsx +2 -0
  79. package/src/components/resources/ResourcesView.tsx +13 -4
  80. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
  81. package/src/components/resources/renderers/index.ts +1 -0
  82. package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
  83. package/src/components/rightsizing/copy.test.ts +56 -0
  84. package/src/components/rightsizing/model.test.ts +227 -0
  85. package/src/components/rightsizing/model.ts +158 -0
  86. package/src/components/rightsizing/presentation.test.ts +104 -0
  87. package/src/components/rightsizing/presentation.ts +94 -0
  88. package/src/components/settings/MyPermissionsDialog.tsx +66 -116
  89. package/src/components/settings/SettingsDialog.tsx +1290 -283
  90. package/src/components/shared/LargeClusterNamespacePicker.tsx +2 -2
  91. package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
  92. package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
  93. package/src/components/timeline/TimelineList.tsx +81 -13
  94. package/src/components/timeline/TimelineView.tsx +842 -26
  95. package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
  96. package/src/components/traffic/TrafficFilterSidebar.tsx +2 -2
  97. package/src/components/traffic/TrafficView.tsx +8 -4
  98. package/src/components/ui/CommandPalette.tsx +2 -2
  99. package/src/components/workload/WorkloadView.tsx +912 -245
  100. package/src/context/ConnectionContext.tsx +109 -11
  101. package/src/context/DiagnoseCustomization.tsx +42 -0
  102. package/src/context/TimelineSource.tsx +50 -0
  103. package/src/hooks/useClusterLoadState.ts +73 -0
  104. package/src/hooks/useEventSource.ts +6 -0
  105. package/src/index.css +162 -1
  106. package/src/index.ts +12 -0
  107. package/src/main.tsx +1 -1
  108. package/src/types/clusterLoadState.ts +33 -0
  109. package/src/utils/navigation.ts +10 -0
@@ -1,37 +1,93 @@
1
- import { useCallback, useEffect, useMemo } from 'react'
2
- import { useSearchParams } from 'react-router-dom'
1
+ import { useCallback, useEffect, useMemo, useState } from "react";
2
+ import { useNavigate, useSearchParams } from "react-router-dom";
3
3
  import {
4
4
  ApplicationsList,
5
5
  ApplicationDetail,
6
6
  CenteredEmpty,
7
7
  PageHeader,
8
8
  FreshnessControl,
9
+ IssueRow,
10
+ ISSUE_SEVERITY_RANK,
9
11
  useToast,
10
12
  orderEnvs,
11
13
  matchWorkloadAcrossInstances,
12
14
  workloadKey,
13
15
  healthOf,
14
16
  compareVersions,
17
+ gitOpsRouteForKind,
18
+ deploymentInventoryFromGitOps,
19
+ deploymentInventoryFromHelm,
20
+ buildAppMembershipIndex,
21
+ buildApplicationHistoryItems,
22
+ eventsForApplication,
23
+ memberRef,
24
+ subjectRef,
15
25
  type AppRow,
26
+ type AppWorkload,
16
27
  type AppIdentityInstance,
28
+ type ApplicationView,
29
+ type ApplicationHistoryRange,
30
+ type AppSourceRef,
31
+ type Issue,
32
+ type IssueResourceRef,
17
33
  type SelectedAppWorkload,
18
34
  type SelectedResource,
19
- } from '@skyhook-io/k8s-ui'
20
- import { Boxes } from 'lucide-react'
21
- import { useApplications, useTopology } from '../../api/client'
22
- import { useConnection } from '../../context/ConnectionContext'
23
- import { kindToPlural } from '../../utils/navigation'
24
- import { WorkloadView } from '../workload/WorkloadView'
35
+ } from "@skyhook-io/k8s-ui";
36
+ import { AlertTriangle, Boxes } from "lucide-react";
37
+ import { SEVERITY_TEXT } from "@skyhook-io/k8s-ui/utils/badge-colors";
38
+ import {
39
+ useApplicationHistory,
40
+ useApplications,
41
+ useGitOpsTree,
42
+ useHelmRelease,
43
+ useIssues,
44
+ useTopology,
45
+ type IssuesResponse,
46
+ } from "../../api/client";
47
+ import { useConnection } from "../../context/ConnectionContext";
48
+ import { useTimelineSource } from "../../context/TimelineSource";
49
+ import { buildWorkloadPath, kindToPlural } from "../../utils/navigation";
50
+ import { WorkloadView } from "../workload/WorkloadView";
51
+ import { ApplicationCostTab } from "../cost/ApplicationCostTab";
52
+ import { isOpenCostWorkloadKind } from "../cost/kinds";
53
+
54
+ type ApplicationRouteView = ApplicationView | "cost";
55
+
56
+ const APPLICATION_HISTORY_WINDOW: Record<
57
+ ApplicationHistoryRange,
58
+ number | "all"
59
+ > = {
60
+ "24h": 24 * 60 * 60 * 1000,
61
+ "7d": 7 * 24 * 60 * 60 * 1000,
62
+ "30d": 30 * 24 * 60 * 60 * 1000,
63
+ all: "all",
64
+ };
65
+ const APPLICATION_HISTORY_EVENT_LIMIT = 10_000;
66
+ const DEFAULT_RETAINED_HISTORY_DAYS = 7;
67
+ const APPLICATION_TIMELINE_FOCUS_MS = 60 * 60 * 1000;
68
+ const APPLICATION_TIMELINE_AFTER_MS = 45 * 60 * 1000;
69
+
70
+ const APPLICATION_VIEWS: ReadonlySet<ApplicationRouteView> =
71
+ new Set<ApplicationRouteView>(["overview", "topology", "history", "cost"]);
72
+
73
+ function parseApplicationView(value: string | null): ApplicationRouteView {
74
+ if (!value || !APPLICATION_VIEWS.has(value as ApplicationRouteView))
75
+ return "overview";
76
+ return value as ApplicationRouteView;
77
+ }
25
78
 
26
79
  interface ApplicationsViewProps {
27
- namespaces: string[]
28
- onOpenResource: (resource: SelectedResource) => void
80
+ namespaces: string[];
81
+ onOpenResource: (resource: SelectedResource) => void;
29
82
  }
30
83
 
31
- export function ApplicationsView({ namespaces, onOpenResource }: ApplicationsViewProps) {
32
- const query = useApplications(namespaces)
33
- const { connection } = useConnection()
34
- const apps = useMemo(() => query.data?.applications ?? [], [query.data])
84
+ export function ApplicationsView({
85
+ namespaces,
86
+ onOpenResource,
87
+ }: ApplicationsViewProps) {
88
+ const query = useApplications(namespaces);
89
+ const { connection } = useConnection();
90
+ const apps = useMemo(() => query.data?.applications ?? [], [query.data]);
35
91
 
36
92
  const freshness = (
37
93
  <FreshnessControl
@@ -40,42 +96,54 @@ export function ApplicationsView({ namespaces, onOpenResource }: ApplicationsVie
40
96
  onRefresh={() => query.refetch()}
41
97
  connectionState={connection.state}
42
98
  />
43
- )
99
+ );
44
100
 
45
101
  // Which app is open lives in the URL (?app=<key>) so the detail view is
46
102
  // deep-linkable and the browser back button returns to the list. Opening or
47
- // closing an app also clears the per-app params (workload, tab).
48
- const [searchParams, setSearchParams] = useSearchParams()
49
- const selectedKey = searchParams.get('app')
50
- const selected = useMemo(() => apps.find((a) => a.key === selectedKey) ?? null, [apps, selectedKey])
103
+ // closing an app also clears the per-app params (view, workload, tab).
104
+ const [searchParams, setSearchParams] = useSearchParams();
105
+ const selectedKey = searchParams.get("app");
106
+ const selected = useMemo(
107
+ () => apps.find((a) => a.key === selectedKey) ?? null,
108
+ [apps, selectedKey],
109
+ );
51
110
 
52
111
  const selectApp = useCallback(
53
112
  (key: string | null) => {
54
- const params = new URLSearchParams(searchParams)
55
- if (key) params.set('app', key)
56
- else params.delete('app')
57
- params.delete('workload')
58
- params.delete('tab')
59
- setSearchParams(params)
113
+ const params = new URLSearchParams(searchParams);
114
+ if (key) params.set("app", key);
115
+ else params.delete("app");
116
+ params.delete("view");
117
+ params.delete("workload");
118
+ params.delete("tab");
119
+ setSearchParams(params);
60
120
  },
61
121
  [searchParams, setSearchParams],
62
- )
122
+ );
63
123
 
64
124
  // A stale ?app= (uninstalled/renamed app, or a link from another cluster)
65
125
  // would leave the URL lying under the list view — clear it once data is
66
126
  // fresh. Never during load, so a slow fetch can't eject a valid deep link.
67
127
  useEffect(() => {
68
128
  if (selectedKey && !selected && query.isSuccess) {
69
- const params = new URLSearchParams(searchParams)
70
- params.delete('app')
71
- params.delete('workload')
72
- params.delete('tab')
73
- setSearchParams(params, { replace: true })
129
+ const params = new URLSearchParams(searchParams);
130
+ params.delete("app");
131
+ params.delete("view");
132
+ params.delete("workload");
133
+ params.delete("tab");
134
+ setSearchParams(params, { replace: true });
74
135
  }
75
- }, [selectedKey, selected, query.isSuccess, searchParams, setSearchParams])
136
+ }, [selectedKey, selected, query.isSuccess, searchParams, setSearchParams]);
76
137
 
77
138
  if (selectedKey && selected) {
78
- return <AppDetailRoute app={selected} apps={apps} onBack={() => selectApp(null)} onOpenResource={onOpenResource} />
139
+ return (
140
+ <AppDetailRoute
141
+ app={selected}
142
+ apps={apps}
143
+ onBack={() => selectApp(null)}
144
+ onOpenResource={onOpenResource}
145
+ />
146
+ );
79
147
  }
80
148
 
81
149
  // The header + status + filters + table chassis lives inside ApplicationsList
@@ -86,9 +154,14 @@ export function ApplicationsView({ namespaces, onOpenResource }: ApplicationsVie
86
154
  if (query.isLoading) {
87
155
  return (
88
156
  <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
89
- <ApplicationsList apps={[]} onSelect={selectApp} headerActions={freshness} loading />
157
+ <ApplicationsList
158
+ apps={[]}
159
+ onSelect={selectApp}
160
+ headerActions={freshness}
161
+ loading
162
+ />
90
163
  </div>
91
- )
164
+ );
92
165
  }
93
166
  if (query.error) {
94
167
  return (
@@ -100,46 +173,358 @@ export function ApplicationsView({ namespaces, onOpenResource }: ApplicationsVie
100
173
  description="Deployable software in this cluster — your services, workers, and jobs, grouped by app/release evidence."
101
174
  />
102
175
  </div>
103
- <CenteredEmpty tone="filtered" icon={Boxes} headline="Failed to load applications" body={(query.error as Error).message} />
176
+ <CenteredEmpty
177
+ tone="filtered"
178
+ icon={Boxes}
179
+ headline="Failed to load applications"
180
+ body={(query.error as Error).message}
181
+ />
104
182
  </div>
105
- )
183
+ );
106
184
  }
107
185
 
108
186
  return (
109
187
  <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
110
- <ApplicationsList apps={apps} onSelect={selectApp} headerActions={freshness} />
188
+ <ApplicationsList
189
+ apps={apps}
190
+ onSelect={selectApp}
191
+ headerActions={freshness}
192
+ />
111
193
  </div>
112
- )
194
+ );
113
195
  }
114
196
 
115
197
  // AppDetailRoute wires the OSS data hooks the shared ApplicationDetail can't:
116
- // the resources-view topology over the app's namespaces (for the app graph)
117
- // and the per-workload WorkloadView (which fetches its own topology for the
118
- // Topology tab). Split out so useTopology runs unconditionally (Rules of Hooks).
119
- function AppDetailRoute({ app, apps, onBack, onOpenResource }: { app: AppRow; apps: AppRow[]; onBack: () => void; onOpenResource: (resource: SelectedResource) => void }) {
198
+ // the resources-view topology over the app's namespaces and the per-workload
199
+ // WorkloadView. Split out so useTopology runs unconditionally (Rules of Hooks).
200
+ function AppDetailRoute({
201
+ app,
202
+ apps,
203
+ onBack,
204
+ onOpenResource,
205
+ }: {
206
+ app: AppRow;
207
+ apps: AppRow[];
208
+ onBack: () => void;
209
+ onOpenResource: (resource: SelectedResource) => void;
210
+ }) {
211
+ const navigate = useNavigate();
212
+ const timelineSource = useTimelineSource();
120
213
  const appNamespaces = useMemo(
121
- () => Array.from(new Set((app.workloads ?? []).map((w) => w.namespace).filter(Boolean))).sort(),
214
+ () =>
215
+ Array.from(
216
+ new Set((app.workloads ?? []).map((w) => w.namespace).filter(Boolean)),
217
+ ).sort(),
122
218
  [app.workloads],
123
- )
124
- const { data: topology, isLoading: topologyLoading } = useTopology(appNamespaces, 'resources', { enabled: appNamespaces.length > 0 })
125
-
126
- // The selected workload (?workload=<key>) lives in the URL too: deep-linkable,
127
- // and back returns from a workload's runtime to the app graph. Clearing it
128
- // also drops the workload's tab param.
129
- const [searchParams, setSearchParams] = useSearchParams()
130
- const selectedWorkloadKey = searchParams.get('workload')
219
+ );
220
+ const appHistoryNamespaces = useMemo(() => {
221
+ const namespaces = new Set(appNamespaces);
222
+ if (app.sourceRef?.namespace) namespaces.add(app.sourceRef.namespace);
223
+ return Array.from(namespaces).sort();
224
+ }, [app.sourceRef?.namespace, appNamespaces]);
225
+ const { data: topology, isLoading: topologyLoading } = useTopology(
226
+ appNamespaces,
227
+ "resources",
228
+ {
229
+ enabled: appNamespaces.length > 0,
230
+ includeReplicaSets: true,
231
+ refetchInterval: 10_000,
232
+ },
233
+ );
234
+ const issuesQuery = useIssues(appNamespaces);
235
+ const appIssues = useMemo(
236
+ () =>
237
+ appIssuesForWorkloads(
238
+ issuesQuery.data?.issues ?? [],
239
+ app.workloads ?? [],
240
+ ),
241
+ [issuesQuery.data?.issues, app.workloads],
242
+ );
243
+
244
+ // The selected workload (?workload=<key>) is the scope switch and wins over
245
+ // ?view= when both are present. With neither param, use the product default:
246
+ // multi-workload apps open on app overview, single-workload apps open on the
247
+ // workload. A single-workload app does not expose app scope.
248
+ const [searchParams, setSearchParams] = useSearchParams();
249
+ const viewParam = searchParams.get("view");
250
+ const selectedRouteView = parseApplicationView(viewParam);
251
+ const selectedView: ApplicationView =
252
+ selectedRouteView === "cost" ? "overview" : selectedRouteView;
253
+ const selectedWorkloadParam = searchParams.get("workload");
254
+ const appWorkloads = app.workloads ?? [];
255
+ const singleWorkloadKey =
256
+ appWorkloads.length === 1 ? workloadKey(appWorkloads[0]) : null;
257
+ const selectedWorkloadKey = singleWorkloadKey ?? selectedWorkloadParam;
258
+ const applicationCostAvailable =
259
+ !singleWorkloadKey &&
260
+ appWorkloads.some((workload) => isOpenCostWorkloadKind(workload.kind));
261
+ const applicationCostSelected =
262
+ selectedRouteView === "cost" && applicationCostAvailable;
263
+ const historyQuery = useApplicationHistory(app.key, appHistoryNamespaces, {
264
+ enabled: !selectedWorkloadKey,
265
+ });
266
+ const [retainedHistoryRange, setRetainedHistoryRange] =
267
+ useState<ApplicationHistoryRange>("7d");
268
+ const historyRangeOptions = useMemo(
269
+ () =>
270
+ applicationHistoryRangeOptions(
271
+ timelineSource.capabilities.mode,
272
+ timelineSource.capabilities.maxRangeDays,
273
+ ),
274
+ [
275
+ timelineSource.capabilities.maxRangeDays,
276
+ timelineSource.capabilities.mode,
277
+ ],
278
+ );
279
+ const historyRange =
280
+ timelineSource.capabilities.mode === "local"
281
+ ? "all"
282
+ : historyRangeOptions.some(
283
+ (option) => option.value === retainedHistoryRange,
284
+ )
285
+ ? retainedHistoryRange
286
+ : historyRangeOptions[0].value;
287
+ const historyTimelineEnabled =
288
+ !selectedWorkloadKey && selectedView === "history";
289
+ const historyTimelineQuery = timelineSource.useEvents({
290
+ namespaces: appHistoryNamespaces,
291
+ timeRange: historyRange,
292
+ filter: "all",
293
+ includeK8sEvents: true,
294
+ includeManaged: true,
295
+ includeDeleted: true,
296
+ limit: APPLICATION_HISTORY_EVENT_LIMIT,
297
+ enabled: historyTimelineEnabled,
298
+ });
299
+ const historyRuntimeLimited = useMemo(() => {
300
+ if (timelineSource.capabilities.mode !== "retained") return false;
301
+ const events = historyTimelineQuery.data;
302
+ if (!events || events.length < APPLICATION_HISTORY_EVENT_LIMIT)
303
+ return false;
304
+ const oldestTimestamp = events.reduce((oldest, event) => {
305
+ const timestamp = new Date(event.timestamp).getTime();
306
+ return Number.isFinite(timestamp) ? Math.min(oldest, timestamp) : oldest;
307
+ }, Number.POSITIVE_INFINITY);
308
+ if (!Number.isFinite(oldestTimestamp)) return false;
309
+ const configuredWindow = APPLICATION_HISTORY_WINDOW[historyRange];
310
+ const rangeMs =
311
+ configuredWindow === "all"
312
+ ? (timelineSource.capabilities.maxRangeDays ??
313
+ DEFAULT_RETAINED_HISTORY_DAYS) *
314
+ 24 *
315
+ 60 *
316
+ 60 *
317
+ 1000
318
+ : configuredWindow;
319
+ return oldestTimestamp > Date.now() - rangeMs + 60_000;
320
+ }, [
321
+ historyRange,
322
+ historyTimelineQuery.data,
323
+ timelineSource.capabilities.maxRangeDays,
324
+ timelineSource.capabilities.mode,
325
+ ]);
326
+ const historyMembership = useMemo(
327
+ () => buildAppMembershipIndex([app]),
328
+ [app],
329
+ );
330
+ const applicationEvents = useMemo(
331
+ () =>
332
+ eventsForApplication(
333
+ historyTimelineQuery.data ?? [],
334
+ topology,
335
+ historyMembership,
336
+ ),
337
+ [historyMembership, historyTimelineQuery.data, topology],
338
+ );
339
+ const historyItems = useMemo(
340
+ () => buildApplicationHistoryItems(historyQuery.data, applicationEvents),
341
+ [applicationEvents, historyQuery.data],
342
+ );
343
+ const sourceInventoryEnabled =
344
+ !selectedWorkloadKey && selectedView === "topology";
345
+ const gitOpsSource =
346
+ app.sourceRef?.type === "gitops" ? app.sourceRef : undefined;
347
+ const deploymentTreeQuery = useGitOpsTree(
348
+ gitOpsSource?.kind ?? "",
349
+ gitOpsSource?.namespace ?? "",
350
+ gitOpsSource?.name ?? "",
351
+ gitOpsSource?.group,
352
+ appHistoryNamespaces,
353
+ { enabled: sourceInventoryEnabled },
354
+ );
355
+ const helmSource = app.sourceRef?.type === "helm" ? app.sourceRef : undefined;
356
+ const helmReleaseQuery = useHelmRelease(
357
+ helmSource?.namespace ?? "",
358
+ helmSource?.name ?? "",
359
+ { enabled: sourceInventoryEnabled },
360
+ );
361
+ const deploymentInventory = useMemo(
362
+ () =>
363
+ deploymentInventoryFromGitOps(deploymentTreeQuery.data) ??
364
+ deploymentInventoryFromHelm(helmReleaseQuery.data?.resources),
365
+ [deploymentTreeQuery.data, helmReleaseQuery.data?.resources],
366
+ );
367
+ useEffect(() => {
368
+ if (!singleWorkloadKey) return;
369
+ if (selectedWorkloadParam === singleWorkloadKey && !viewParam) return;
370
+ const params = new URLSearchParams(searchParams);
371
+ params.delete("view");
372
+ params.delete("run");
373
+ params.set("workload", singleWorkloadKey);
374
+ if (selectedRouteView === "cost") params.set("tab", "cost");
375
+ setSearchParams(params, { replace: true });
376
+ }, [
377
+ searchParams,
378
+ selectedRouteView,
379
+ selectedWorkloadParam,
380
+ setSearchParams,
381
+ singleWorkloadKey,
382
+ viewParam,
383
+ ]);
384
+ useEffect(() => {
385
+ if (
386
+ singleWorkloadKey ||
387
+ selectedRouteView !== "cost" ||
388
+ applicationCostAvailable
389
+ )
390
+ return;
391
+ const params = new URLSearchParams(searchParams);
392
+ params.delete("view");
393
+ setSearchParams(params, { replace: true });
394
+ }, [
395
+ applicationCostAvailable,
396
+ searchParams,
397
+ selectedRouteView,
398
+ setSearchParams,
399
+ singleWorkloadKey,
400
+ ]);
401
+ const selectView = useCallback(
402
+ (view: ApplicationRouteView) => {
403
+ const params = new URLSearchParams(searchParams);
404
+ params.delete("tab");
405
+ params.delete("run");
406
+ if (singleWorkloadKey) {
407
+ params.delete("view");
408
+ params.set("workload", singleWorkloadKey);
409
+ } else if (view === "cost") {
410
+ params.set("view", "cost");
411
+ params.delete("workload");
412
+ } else if (view === "overview") {
413
+ params.delete("view");
414
+ params.delete("workload");
415
+ } else {
416
+ params.set("view", view);
417
+ params.delete("workload");
418
+ }
419
+ setSearchParams(params);
420
+ },
421
+ [searchParams, setSearchParams, singleWorkloadKey],
422
+ );
131
423
  const selectWorkload = useCallback(
132
- (key: string | null) => {
133
- const params = new URLSearchParams(searchParams)
134
- // Always drop the workload's tab: a fresh workload opens on its overview,
135
- // and clearing back to the graph leaves no stale tab on the route.
136
- params.delete('tab')
137
- if (key) params.set('workload', key)
138
- else params.delete('workload')
139
- setSearchParams(params)
424
+ (key: string | null, options?: { tab?: string }) => {
425
+ const params = new URLSearchParams(searchParams);
426
+ params.delete("run");
427
+ if (key) {
428
+ const wasInWorkloadScope = !!selectedWorkloadKey;
429
+ params.delete("view");
430
+ params.set("workload", key);
431
+ if (options?.tab) params.set("tab", options.tab);
432
+ else if (!wasInWorkloadScope) params.delete("tab");
433
+ } else if (singleWorkloadKey) {
434
+ params.delete("view");
435
+ params.set("workload", singleWorkloadKey);
436
+ } else {
437
+ params.delete("workload");
438
+ params.delete("tab");
439
+ params.delete("view");
440
+ }
441
+ setSearchParams(params);
442
+ },
443
+ [searchParams, selectedWorkloadKey, setSearchParams, singleWorkloadKey],
444
+ );
445
+ const selectWorkloadRun = useCallback(
446
+ (
447
+ workload: SelectedAppWorkload,
448
+ run: { kind: string; name: string; data: Record<string, unknown> },
449
+ ) => {
450
+ const params = new URLSearchParams(searchParams);
451
+ const runNamespace =
452
+ typeof run.data?.namespace === "string"
453
+ ? run.data.namespace
454
+ : workload.namespace;
455
+ params.delete("view");
456
+ params.delete("tab");
457
+ params.set("workload", workloadKey(workload));
458
+ params.set(
459
+ "run",
460
+ `${kindToPlural(run.kind)}/${runNamespace}/${run.name}`,
461
+ );
462
+ setSearchParams(params);
140
463
  },
141
464
  [searchParams, setSearchParams],
142
- )
465
+ );
466
+ const openWorkloadResource = useCallback(
467
+ (resource: SelectedResource) => {
468
+ if (kindToPlural(resource.kind).toLowerCase() !== "pods") {
469
+ onOpenResource(resource);
470
+ return;
471
+ }
472
+
473
+ const [pathname, rawSearch = ""] = buildWorkloadPath({
474
+ ...resource,
475
+ kind: kindToPlural(resource.kind),
476
+ }).split("?");
477
+ const params = new URLSearchParams(rawSearch);
478
+ const activeNamespaces = searchParams.get("namespaces");
479
+ if (activeNamespaces) params.set("namespaces", activeNamespaces);
480
+ navigate({ pathname, search: params.toString() });
481
+ },
482
+ [navigate, onOpenResource, searchParams],
483
+ );
484
+ const openSource = useCallback(
485
+ (source: AppSourceRef) => {
486
+ if (source.type === "gitops") {
487
+ const path = gitOpsRouteForKind(
488
+ source.kind,
489
+ source.namespace,
490
+ source.name,
491
+ );
492
+ if (path) navigate(path);
493
+ return;
494
+ }
495
+ if (source.type === "helm") {
496
+ const params = new URLSearchParams();
497
+ const activeNamespaces = searchParams.get("namespaces");
498
+ if (activeNamespaces) params.set("namespaces", activeNamespaces);
499
+ params.set("release", `${source.namespace}/${source.name}`);
500
+ navigate({ pathname: "/helm", search: params.toString() });
501
+ }
502
+ },
503
+ [navigate, searchParams],
504
+ );
505
+ const openApplicationTimeline = useCallback(
506
+ (timestamp?: string) => {
507
+ const params = new URLSearchParams();
508
+ params.set("app", app.key);
509
+ params.set("scopeNamespaces", appHistoryNamespaces.join(","));
510
+ params.set("grouping", "app");
511
+ const latestTimestamp = timestamp
512
+ ? new Date(timestamp).getTime()
513
+ : Number.NaN;
514
+ if (Number.isFinite(latestTimestamp)) {
515
+ const to = Math.min(
516
+ Date.now(),
517
+ latestTimestamp + APPLICATION_TIMELINE_AFTER_MS,
518
+ );
519
+ params.set("from", String(to - APPLICATION_TIMELINE_FOCUS_MS));
520
+ params.set("to", String(to));
521
+ } else {
522
+ params.set("window", String(APPLICATION_HISTORY_WINDOW[historyRange]));
523
+ }
524
+ navigate({ pathname: "/timeline", search: params.toString() });
525
+ },
526
+ [app.key, appHistoryNamespaces, historyRange, navigate],
527
+ );
143
528
 
144
529
  // App identity switcher data: this instance's siblings (ladder-ordered
145
530
  // digests). It switches between REAL instances — ?app= changes, deep links
@@ -151,10 +536,17 @@ function AppDetailRoute({ app, apps, onBack, onOpenResource }: { app: AppRow; ap
151
536
  const sibs = apps.filter((a) => a.identity?.key === fam.key);
152
537
  if (sibs.length < 2) return null;
153
538
  const newest = (a: AppRow) =>
154
- (a.versions ?? []).reduce<string | undefined>((best, v) => (!best || compareVersions(v, best) === 1 ? v : best), undefined) ?? a.appVersion;
539
+ (a.versions ?? []).reduce<string | undefined>(
540
+ (best, v) => (!best || compareVersions(v, best) === 1 ? v : best),
541
+ undefined,
542
+ ) ?? a.appVersion;
155
543
  const order = orderEnvs(sibs.map((a) => a.identity!.env));
156
544
  return [...sibs]
157
- .sort((a, b) => order.indexOf(a.identity!.env) - order.indexOf(b.identity!.env) || a.name.localeCompare(b.name))
545
+ .sort(
546
+ (a, b) =>
547
+ order.indexOf(a.identity!.env) - order.indexOf(b.identity!.env) ||
548
+ a.name.localeCompare(b.name),
549
+ )
158
550
  .map((a) => ({
159
551
  appKey: a.key,
160
552
  name: a.name,
@@ -174,27 +566,50 @@ function AppDetailRoute({ app, apps, onBack, onOpenResource }: { app: AppRow; ap
174
566
  (targetKey: string) => {
175
567
  const target = apps.find((a) => a.key === targetKey);
176
568
  const params = new URLSearchParams(searchParams);
177
- params.set('app', targetKey);
178
- const wk = params.get('workload');
569
+ params.set("app", targetKey);
570
+ params.delete("run");
571
+ const wk = params.get("workload");
179
572
  let matched = false;
180
573
  if (wk && target) {
181
574
  // Stem matching strips this app group's own env tokens too, so
182
575
  // discovered envs (loadtest, …) carry position like the trio does.
183
- const identityEnvs = new Set((identityInstances ?? []).map((i) => i.env));
184
- const m = matchWorkloadAcrossInstances(wk, target.workloads, identityEnvs);
576
+ const identityEnvs = new Set(
577
+ (identityInstances ?? []).map((i) => i.env),
578
+ );
579
+ const m = matchWorkloadAcrossInstances(
580
+ wk,
581
+ target.workloads,
582
+ identityEnvs,
583
+ );
185
584
  if (m) {
186
- params.set('workload', workloadKey(m));
585
+ params.set("workload", workloadKey(m));
187
586
  matched = true;
188
587
  }
189
588
  }
190
589
  if (!matched && wk) {
191
590
  // A workload WAS selected but has no counterpart — land on the target
192
- // instance's overview and say so. (With no workload selected the tab
193
- // rides along: it applies to the lone workload either side.)
194
- params.delete('workload');
195
- params.delete('tab');
591
+ // instance's default scope and say so. Single-workload instances default
592
+ // to their workload; composed apps default to app overview.
593
+ params.delete("workload");
594
+ params.delete("tab");
595
+ const soleTargetWorkload =
596
+ target?.workloads?.length === 1 ? target.workloads[0] : null;
597
+ if (soleTargetWorkload) {
598
+ params.delete("view");
599
+ params.set("workload", workloadKey(soleTargetWorkload));
600
+ } else {
601
+ params.delete("view");
602
+ }
196
603
  if (target) {
197
- showToast(`No matching workload in ${target.identity?.env ?? target.name}`, { detail: 'Showing the instance overview instead.', type: 'info' });
604
+ showToast(
605
+ `No matching workload in ${target.identity?.env ?? target.name}`,
606
+ {
607
+ detail: soleTargetWorkload
608
+ ? "Showing the instance workload instead."
609
+ : "Showing the instance overview instead.",
610
+ type: "info",
611
+ },
612
+ );
198
613
  }
199
614
  }
200
615
  setSearchParams(params);
@@ -203,39 +618,319 @@ function AppDetailRoute({ app, apps, onBack, onOpenResource }: { app: AppRow; ap
203
618
  );
204
619
 
205
620
  const discoveredEnvs = useMemo(
206
- () => new Set(apps.map((a) => a.identity?.env).filter((e): e is string => !!e)),
621
+ () =>
622
+ new Set(apps.map((a) => a.identity?.env).filter((e): e is string => !!e)),
207
623
  [apps],
208
624
  );
209
625
 
210
626
  return (
211
- <div className="flex-1 overflow-auto">
627
+ <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
212
628
  <ApplicationDetail
213
629
  app={app}
214
630
  onBack={onBack}
215
631
  topology={topology}
216
632
  topologyLoading={topologyLoading}
633
+ deploymentInventory={deploymentInventory}
217
634
  identityInstances={identityInstances}
218
635
  onSwitchInstance={switchInstance}
219
636
  discoveredEnvs={discoveredEnvs}
220
637
  onNavigateToResource={onOpenResource}
638
+ onSelectWorkloadRun={selectWorkloadRun}
639
+ history={historyQuery.data}
640
+ historyLoading={historyQuery.isLoading}
641
+ historyItems={historyItems}
642
+ historyRuntimeLoading={historyTimelineQuery.isFetching}
643
+ historyRuntimeError={historyTimelineQuery.isError}
644
+ historyMode={timelineSource.capabilities.mode}
645
+ historyRange={historyRange}
646
+ historyRangeOptions={historyRangeOptions}
647
+ historyCoverageRecordCount={historyTimelineQuery.coverage?.length ?? 0}
648
+ historyRuntimeLimited={historyRuntimeLimited}
649
+ onHistoryRangeChange={setRetainedHistoryRange}
650
+ onOpenTimeline={openApplicationTimeline}
651
+ onOpenSource={openSource}
221
652
  selectedWorkloadKey={selectedWorkloadKey}
222
653
  onSelectWorkload={selectWorkload}
654
+ selectedView={selectedView}
655
+ onSelectView={selectView}
656
+ costViewSelected={applicationCostSelected}
657
+ onSelectCostView={() => selectView("cost")}
658
+ renderCostView={
659
+ applicationCostAvailable
660
+ ? ({ app, workloads, onSelectWorkload }) => (
661
+ <ApplicationCostTab
662
+ app={app}
663
+ workloads={workloads}
664
+ onSelectWorkloadCost={(workload) =>
665
+ onSelectWorkload(workload, { tab: "cost" })
666
+ }
667
+ />
668
+ )
669
+ : undefined
670
+ }
671
+ renderOverviewIssues={() => (
672
+ <AppOverviewIssues
673
+ issues={appIssues}
674
+ error={issuesQuery.error}
675
+ hasData={Boolean(issuesQuery.data)}
676
+ visibility={issuesQuery.data?.visibility}
677
+ onOpenResource={onOpenResource}
678
+ />
679
+ )}
680
+ hasOverviewIssues={appIssues.length > 0}
223
681
  renderWorkload={(workload: SelectedAppWorkload) => (
224
682
  <div className="h-full overflow-hidden">
225
683
  <WorkloadView
226
684
  kind={kindToPlural(workload.kind)}
685
+ group={workload.group}
227
686
  namespace={workload.namespace}
228
687
  name={workload.name}
229
688
  onBack={() => selectWorkload(null)}
230
- // "Back" returns to the app graph — meaningless for a
231
- // single-workload app, which has no graph to return to.
232
- hideBackButton={(app.workloads?.length ?? 0) <= 1}
689
+ hideBackButton
233
690
  compactHeader
234
- onNavigateToResource={onOpenResource}
691
+ pushTabHistory
692
+ onNavigateToResource={openWorkloadResource}
235
693
  />
236
694
  </div>
237
695
  )}
238
696
  />
239
697
  </div>
240
- )
698
+ );
699
+ }
700
+
701
+ function applicationHistoryRangeOptions(
702
+ mode: "local" | "retained",
703
+ maxRangeDays?: number,
704
+ ): Array<{ value: ApplicationHistoryRange; label: string }> {
705
+ if (mode === "local") return [{ value: "all", label: "All observed" }];
706
+ const maxDays = maxRangeDays ?? DEFAULT_RETAINED_HISTORY_DAYS;
707
+ const options: Array<{ value: ApplicationHistoryRange; label: string }> = [];
708
+ if (maxDays >= 1) options.push({ value: "24h", label: "24 hours" });
709
+ if (maxDays >= 7) options.push({ value: "7d", label: "7 days" });
710
+ if (maxDays >= 30) options.push({ value: "30d", label: "30 days" });
711
+ options.push({ value: "all", label: "All available" });
712
+ return options;
713
+ }
714
+
715
+ function AppOverviewIssues({
716
+ issues,
717
+ error,
718
+ hasData,
719
+ visibility,
720
+ onOpenResource,
721
+ }: {
722
+ issues: Issue[];
723
+ error: unknown;
724
+ hasData: boolean;
725
+ visibility?: IssuesResponse["visibility"];
726
+ onOpenResource: (resource: SelectedResource) => void;
727
+ }) {
728
+ const [openId, setOpenId] = useState<string | null>(null);
729
+ const sorted = useMemo(
730
+ () => [...issues].sort(compareAppOverviewIssues),
731
+ [issues],
732
+ );
733
+
734
+ if (error && !hasData) {
735
+ return (
736
+ <AppOverviewIssuesState
737
+ headline="Operational issues unavailable"
738
+ body={
739
+ error instanceof Error
740
+ ? error.message
741
+ : "Radar could not load issues for this application."
742
+ }
743
+ />
744
+ );
745
+ }
746
+
747
+ if (error) {
748
+ return (
749
+ <section className="space-y-2">
750
+ <AppOverviewIssuesState
751
+ headline="Operational issue refresh failed"
752
+ body="Showing the last successful result for this application."
753
+ />
754
+ {sorted.length > 0 && (
755
+ <AppOverviewIssueRows
756
+ issues={sorted}
757
+ openId={openId}
758
+ setOpenId={setOpenId}
759
+ onOpenResource={onOpenResource}
760
+ />
761
+ )}
762
+ </section>
763
+ );
764
+ }
765
+
766
+ if (visibility?.impact) {
767
+ return (
768
+ <section className="space-y-2">
769
+ <AppOverviewIssuesState
770
+ headline={
771
+ sorted.length === 0
772
+ ? "No visible operational issues"
773
+ : "Operational issue visibility is limited"
774
+ }
775
+ body={`${visibility.impact} Results may be incomplete.`}
776
+ />
777
+ {sorted.length > 0 && (
778
+ <AppOverviewIssueRows
779
+ issues={sorted}
780
+ openId={openId}
781
+ setOpenId={setOpenId}
782
+ onOpenResource={onOpenResource}
783
+ />
784
+ )}
785
+ </section>
786
+ );
787
+ }
788
+
789
+ if (sorted.length === 0) return null;
790
+
791
+ return (
792
+ <AppOverviewIssueRows
793
+ issues={sorted}
794
+ openId={openId}
795
+ setOpenId={setOpenId}
796
+ onOpenResource={onOpenResource}
797
+ />
798
+ );
799
+ }
800
+
801
+ function AppOverviewIssuesState({
802
+ headline,
803
+ body,
804
+ }: {
805
+ headline: string;
806
+ body: string;
807
+ }) {
808
+ return (
809
+ <section className="rounded-lg border border-theme-border bg-theme-surface px-4 py-3 shadow-theme-sm">
810
+ <div className="flex items-start gap-3">
811
+ <AlertTriangle
812
+ className={`mt-0.5 h-4 w-4 shrink-0 ${SEVERITY_TEXT.warning}`}
813
+ aria-hidden
814
+ />
815
+ <div className="min-w-0">
816
+ <h2 className="text-sm font-semibold text-theme-text-primary">
817
+ {headline}
818
+ </h2>
819
+ <p className="mt-0.5 text-sm text-theme-text-secondary">{body}</p>
820
+ </div>
821
+ </div>
822
+ </section>
823
+ );
824
+ }
825
+
826
+ function AppOverviewIssueRows({
827
+ issues: sorted,
828
+ openId,
829
+ setOpenId,
830
+ onOpenResource,
831
+ }: {
832
+ issues: Issue[];
833
+ openId: string | null;
834
+ setOpenId: (value: string | null) => void;
835
+ onOpenResource: (resource: SelectedResource) => void;
836
+ }) {
837
+ const navigate = (ref: IssueResourceRef) => {
838
+ onOpenResource({
839
+ kind: kindToPlural(ref.kind),
840
+ namespace: ref.namespace ?? "",
841
+ name: ref.name,
842
+ group: ref.group ?? "",
843
+ });
844
+ };
845
+
846
+ return (
847
+ <section className="space-y-2">
848
+ <div className="flex items-center justify-between gap-3">
849
+ <div className="flex min-w-0 items-center gap-2 text-sm font-semibold text-theme-text-primary">
850
+ <AlertTriangle
851
+ className="h-4 w-4 shrink-0 text-theme-text-secondary"
852
+ aria-hidden
853
+ />
854
+ <span>Operational Issues</span>
855
+ <span className="badge-sm text-[10px] text-theme-text-secondary">
856
+ {sorted.length}
857
+ </span>
858
+ </div>
859
+ <span className="text-xs text-theme-text-tertiary">
860
+ Scoped to this application
861
+ </span>
862
+ </div>
863
+ <ol className="flex flex-col gap-1.5">
864
+ {sorted.slice(0, 4).map((issue) => {
865
+ const rowKey = `${issue.cluster_id ?? ""}:${issue.id}`;
866
+ return (
867
+ <IssueRow
868
+ key={rowKey}
869
+ issue={issue}
870
+ open={openId === rowKey}
871
+ onToggle={() => setOpenId(openId === rowKey ? null : rowKey)}
872
+ onResourceClick={navigate}
873
+ />
874
+ );
875
+ })}
876
+ </ol>
877
+ {sorted.length > 4 ? (
878
+ <div className="text-xs text-theme-text-tertiary">
879
+ Showing 4 of {sorted.length} issues. Open Issues for the full queue.
880
+ </div>
881
+ ) : null}
882
+ </section>
883
+ );
884
+ }
885
+
886
+ function compareAppOverviewIssues(a: Issue, b: Issue): number {
887
+ const severity =
888
+ ISSUE_SEVERITY_RANK[b.severity] - ISSUE_SEVERITY_RANK[a.severity];
889
+ if (severity !== 0) return severity;
890
+ const fa = a.first_seen ?? "";
891
+ const fb = b.first_seen ?? "";
892
+ if (fa !== fb) return fb.localeCompare(fa);
893
+ const ns = (a.namespace ?? "").localeCompare(b.namespace ?? "");
894
+ if (ns !== 0) return ns;
895
+ const name = a.name.localeCompare(b.name);
896
+ if (name !== 0) return name;
897
+ return a.id.localeCompare(b.id);
898
+ }
899
+
900
+ function appIssuesForWorkloads(
901
+ issues: Issue[],
902
+ workloads: AppWorkload[],
903
+ ): Issue[] {
904
+ if (issues.length === 0 || workloads.length === 0) return [];
905
+ const workloadKeys = new Set(workloads.map(workloadIssueKey));
906
+ const out: Issue[] = [];
907
+ const seen = new Set<string>();
908
+ for (const issue of issues) {
909
+ if (!issueRefs(issue).some((ref) => workloadKeys.has(issueRefKey(ref))))
910
+ continue;
911
+ if (seen.has(issue.id)) continue;
912
+ seen.add(issue.id);
913
+ out.push(issue);
914
+ }
915
+ return out;
916
+ }
917
+
918
+ function workloadIssueKey(workload: AppWorkload): string {
919
+ return `${workload.kind.toLowerCase()}|${workload.namespace}|${workload.name}`;
920
+ }
921
+
922
+ function issueRefKey(ref: IssueResourceRef): string {
923
+ return `${ref.kind.toLowerCase()}|${ref.namespace ?? ""}|${ref.name}`;
924
+ }
925
+
926
+ function issueRefs(issue: Issue): IssueResourceRef[] {
927
+ const refs: IssueResourceRef[] = [subjectRef(issue)];
928
+ if (issue.owner) refs.push(issue.owner);
929
+ if (issue.incident_parent?.ref) refs.push(issue.incident_parent.ref);
930
+ for (const member of issue.members ?? []) refs.push(memberRef(issue, member));
931
+ for (const fact of issue.diagnostic_context?.facts ?? []) {
932
+ refs.push(...(fact.refs ?? []));
933
+ for (const related of fact.related_issues ?? []) refs.push(related.ref);
934
+ }
935
+ return refs;
241
936
  }