@skyhook-io/radar-app 1.8.7 → 1.8.9

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 (56) hide show
  1. package/package.json +4 -4
  2. package/src/App.tsx +69 -61
  3. package/src/RadarApp.tsx +15 -1
  4. package/src/api/apiResources.ts +1 -1
  5. package/src/api/client.argoResourceSync.test.ts +69 -0
  6. package/src/api/client.rightsizing.test.ts +32 -0
  7. package/src/api/client.ts +1222 -244
  8. package/src/api/timelineSource.ts +4 -2
  9. package/src/components/applications/ApplicationsView.tsx +613 -219
  10. package/src/components/cost/ApplicationCostTab.test.ts +204 -0
  11. package/src/components/cost/ApplicationCostTab.tsx +571 -0
  12. package/src/components/cost/CostTrendChart.tsx +103 -72
  13. package/src/components/cost/CostView.test.ts +12 -0
  14. package/src/components/cost/CostView.tsx +494 -229
  15. package/src/components/cost/CostViewTabs.test.tsx +21 -0
  16. package/src/components/cost/CostViewTabs.tsx +40 -0
  17. package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
  18. package/src/components/cost/CurrentAllocationUse.tsx +126 -0
  19. package/src/components/cost/WorkloadCostTab.test.ts +153 -0
  20. package/src/components/cost/WorkloadCostTab.tsx +372 -0
  21. package/src/components/cost/cloud-console.test.ts +39 -0
  22. package/src/components/cost/cloud-console.ts +81 -0
  23. package/src/components/cost/errors.ts +8 -0
  24. package/src/components/cost/format.test.ts +27 -0
  25. package/src/components/cost/format.ts +46 -0
  26. package/src/components/cost/kinds.ts +5 -0
  27. package/src/components/diagnose/AISettings.tsx +7 -12
  28. package/src/components/diagnose/DiagnoseContext.tsx +1 -0
  29. package/src/components/diagnose/DiagnoseSurface.tsx +3 -0
  30. package/src/components/diagnose/InvestigationView.tsx +16 -3
  31. package/src/components/diagnose/parts.tsx +140 -73
  32. package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
  33. package/src/components/gitops/GitOpsView.tsx +81 -14
  34. package/src/components/gitops/RevisionMetaChip.tsx +63 -0
  35. package/src/components/helm/HelmCompareRoute.tsx +1 -2
  36. package/src/components/helm/ManifestDiffViewer.tsx +1 -31
  37. package/src/components/helm/ValuesDiffPreview.tsx +2 -3
  38. package/src/components/home/CostCard.tsx +21 -36
  39. package/src/components/resource/RightsizingStrip.test.ts +109 -0
  40. package/src/components/resource/RightsizingStrip.tsx +319 -123
  41. package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
  42. package/src/components/rightsizing/copy.test.ts +56 -0
  43. package/src/components/rightsizing/model.test.ts +227 -0
  44. package/src/components/rightsizing/model.ts +158 -0
  45. package/src/components/rightsizing/presentation.test.ts +104 -0
  46. package/src/components/rightsizing/presentation.ts +94 -0
  47. package/src/components/settings/MyPermissionsDialog.tsx +66 -116
  48. package/src/components/settings/SettingsDialog.tsx +1268 -318
  49. package/src/components/timeline/TimelineList.tsx +35 -8
  50. package/src/components/timeline/TimelineView.tsx +156 -26
  51. package/src/components/timeline/TimelineView.urlparams.test.ts +43 -2
  52. package/src/components/workload/WorkloadView.tsx +711 -328
  53. package/src/context/DiagnoseCustomization.tsx +36 -2
  54. package/src/index.css +5 -1
  55. package/src/index.ts +4 -1
  56. package/src/main.tsx +1 -1
@@ -1,5 +1,5 @@
1
- import { useCallback, useEffect, useMemo, useState } from 'react'
2
- import { useNavigate, 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,
@@ -17,41 +17,77 @@ import {
17
17
  gitOpsRouteForKind,
18
18
  deploymentInventoryFromGitOps,
19
19
  deploymentInventoryFromHelm,
20
+ buildAppMembershipIndex,
21
+ buildApplicationHistoryItems,
22
+ eventsForApplication,
20
23
  memberRef,
21
24
  subjectRef,
22
25
  type AppRow,
23
26
  type AppWorkload,
24
27
  type AppIdentityInstance,
25
28
  type ApplicationView,
29
+ type ApplicationHistoryRange,
26
30
  type AppSourceRef,
27
31
  type Issue,
28
32
  type IssueResourceRef,
29
33
  type SelectedAppWorkload,
30
34
  type SelectedResource,
31
- } from '@skyhook-io/k8s-ui'
32
- import { AlertTriangle, Boxes } from 'lucide-react'
33
- import { SEVERITY_TEXT } from '@skyhook-io/k8s-ui/utils/badge-colors'
34
- import { useApplicationHistory, useApplications, useGitOpsTree, useHelmRelease, useIssues, useTopology, type IssuesResponse } from '../../api/client'
35
- import { useConnection } from '../../context/ConnectionContext'
36
- import { buildWorkloadPath, kindToPlural } from '../../utils/navigation'
37
- import { WorkloadView } from '../workload/WorkloadView'
38
-
39
- const APPLICATION_VIEWS: ReadonlySet<ApplicationView> = new Set<ApplicationView>(['overview', 'topology', 'history'])
40
-
41
- function parseApplicationView(value: string | null): ApplicationView {
42
- if (!value || !APPLICATION_VIEWS.has(value as ApplicationView)) return 'overview'
43
- return value as ApplicationView
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;
44
77
  }
45
78
 
46
79
  interface ApplicationsViewProps {
47
- namespaces: string[]
48
- onOpenResource: (resource: SelectedResource) => void
80
+ namespaces: string[];
81
+ onOpenResource: (resource: SelectedResource) => void;
49
82
  }
50
83
 
51
- export function ApplicationsView({ namespaces, onOpenResource }: ApplicationsViewProps) {
52
- const query = useApplications(namespaces)
53
- const { connection } = useConnection()
54
- 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]);
55
91
 
56
92
  const freshness = (
57
93
  <FreshnessControl
@@ -60,44 +96,54 @@ export function ApplicationsView({ namespaces, onOpenResource }: ApplicationsVie
60
96
  onRefresh={() => query.refetch()}
61
97
  connectionState={connection.state}
62
98
  />
63
- )
99
+ );
64
100
 
65
101
  // Which app is open lives in the URL (?app=<key>) so the detail view is
66
102
  // deep-linkable and the browser back button returns to the list. Opening or
67
103
  // closing an app also clears the per-app params (view, workload, tab).
68
- const [searchParams, setSearchParams] = useSearchParams()
69
- const selectedKey = searchParams.get('app')
70
- const selected = useMemo(() => apps.find((a) => a.key === selectedKey) ?? null, [apps, selectedKey])
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
+ );
71
110
 
72
111
  const selectApp = useCallback(
73
112
  (key: string | null) => {
74
- const params = new URLSearchParams(searchParams)
75
- if (key) params.set('app', key)
76
- else params.delete('app')
77
- params.delete('view')
78
- params.delete('workload')
79
- params.delete('tab')
80
- 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);
81
120
  },
82
121
  [searchParams, setSearchParams],
83
- )
122
+ );
84
123
 
85
124
  // A stale ?app= (uninstalled/renamed app, or a link from another cluster)
86
125
  // would leave the URL lying under the list view — clear it once data is
87
126
  // fresh. Never during load, so a slow fetch can't eject a valid deep link.
88
127
  useEffect(() => {
89
128
  if (selectedKey && !selected && query.isSuccess) {
90
- const params = new URLSearchParams(searchParams)
91
- params.delete('app')
92
- params.delete('view')
93
- params.delete('workload')
94
- params.delete('tab')
95
- 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 });
96
135
  }
97
- }, [selectedKey, selected, query.isSuccess, searchParams, setSearchParams])
136
+ }, [selectedKey, selected, query.isSuccess, searchParams, setSearchParams]);
98
137
 
99
138
  if (selectedKey && selected) {
100
- 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
+ );
101
147
  }
102
148
 
103
149
  // The header + status + filters + table chassis lives inside ApplicationsList
@@ -108,9 +154,14 @@ export function ApplicationsView({ namespaces, onOpenResource }: ApplicationsVie
108
154
  if (query.isLoading) {
109
155
  return (
110
156
  <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
111
- <ApplicationsList apps={[]} onSelect={selectApp} headerActions={freshness} loading />
157
+ <ApplicationsList
158
+ apps={[]}
159
+ onSelect={selectApp}
160
+ headerActions={freshness}
161
+ loading
162
+ />
112
163
  </div>
113
- )
164
+ );
114
165
  }
115
166
  if (query.error) {
116
167
  return (
@@ -122,164 +173,358 @@ export function ApplicationsView({ namespaces, onOpenResource }: ApplicationsVie
122
173
  description="Deployable software in this cluster — your services, workers, and jobs, grouped by app/release evidence."
123
174
  />
124
175
  </div>
125
- <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
+ />
126
182
  </div>
127
- )
183
+ );
128
184
  }
129
185
 
130
186
  return (
131
187
  <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
132
- <ApplicationsList apps={apps} onSelect={selectApp} headerActions={freshness} />
188
+ <ApplicationsList
189
+ apps={apps}
190
+ onSelect={selectApp}
191
+ headerActions={freshness}
192
+ />
133
193
  </div>
134
- )
194
+ );
135
195
  }
136
196
 
137
197
  // AppDetailRoute wires the OSS data hooks the shared ApplicationDetail can't:
138
198
  // the resources-view topology over the app's namespaces and the per-workload
139
199
  // WorkloadView. Split out so useTopology runs unconditionally (Rules of Hooks).
140
- function AppDetailRoute({ app, apps, onBack, onOpenResource }: { app: AppRow; apps: AppRow[]; onBack: () => void; onOpenResource: (resource: SelectedResource) => void }) {
141
- const navigate = useNavigate()
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();
142
213
  const appNamespaces = useMemo(
143
- () => 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(),
144
218
  [app.workloads],
145
- )
219
+ );
146
220
  const appHistoryNamespaces = useMemo(() => {
147
- const namespaces = new Set(appNamespaces)
148
- if (app.sourceRef?.namespace) namespaces.add(app.sourceRef.namespace)
149
- return Array.from(namespaces).sort()
150
- }, [app.sourceRef?.namespace, appNamespaces])
151
- const { data: topology, isLoading: topologyLoading } = useTopology(appNamespaces, 'resources', {
152
- enabled: appNamespaces.length > 0,
153
- includeReplicaSets: true,
154
- refetchInterval: 10_000,
155
- })
156
- const issuesQuery = useIssues(appNamespaces)
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);
157
235
  const appIssues = useMemo(
158
- () => appIssuesForWorkloads(issuesQuery.data?.issues ?? [], app.workloads ?? []),
236
+ () =>
237
+ appIssuesForWorkloads(
238
+ issuesQuery.data?.issues ?? [],
239
+ app.workloads ?? [],
240
+ ),
159
241
  [issuesQuery.data?.issues, app.workloads],
160
- )
242
+ );
161
243
 
162
244
  // The selected workload (?workload=<key>) is the scope switch and wins over
163
245
  // ?view= when both are present. With neither param, use the product default:
164
246
  // multi-workload apps open on app overview, single-workload apps open on the
165
247
  // workload. A single-workload app does not expose app scope.
166
- const [searchParams, setSearchParams] = useSearchParams()
167
- const viewParam = searchParams.get('view')
168
- const selectedView = parseApplicationView(viewParam)
169
- const selectedWorkloadParam = searchParams.get('workload')
170
- const appWorkloads = app.workloads ?? []
171
- const singleWorkloadKey = appWorkloads.length === 1 ? workloadKey(appWorkloads[0]) : null
172
- const selectedWorkloadKey = singleWorkloadKey ?? selectedWorkloadParam
173
- const historyQuery = useApplicationHistory(app.key, appHistoryNamespaces, { enabled: !selectedWorkloadKey })
174
- const sourceInventoryEnabled = !selectedWorkloadKey && selectedView === 'topology'
175
- const gitOpsSource = app.sourceRef?.type === 'gitops' ? app.sourceRef : undefined
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;
176
347
  const deploymentTreeQuery = useGitOpsTree(
177
- gitOpsSource?.kind ?? '',
178
- gitOpsSource?.namespace ?? '',
179
- gitOpsSource?.name ?? '',
348
+ gitOpsSource?.kind ?? "",
349
+ gitOpsSource?.namespace ?? "",
350
+ gitOpsSource?.name ?? "",
180
351
  gitOpsSource?.group,
181
352
  appHistoryNamespaces,
182
353
  { enabled: sourceInventoryEnabled },
183
- )
184
- const helmSource = app.sourceRef?.type === 'helm' ? app.sourceRef : undefined
185
- const helmReleaseQuery = useHelmRelease(helmSource?.namespace ?? '', helmSource?.name ?? '', { 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
+ );
186
361
  const deploymentInventory = useMemo(
187
- () => deploymentInventoryFromGitOps(deploymentTreeQuery.data) ?? deploymentInventoryFromHelm(helmReleaseQuery.data?.resources),
362
+ () =>
363
+ deploymentInventoryFromGitOps(deploymentTreeQuery.data) ??
364
+ deploymentInventoryFromHelm(helmReleaseQuery.data?.resources),
188
365
  [deploymentTreeQuery.data, helmReleaseQuery.data?.resources],
189
- )
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
+ ]);
190
384
  useEffect(() => {
191
- if (!singleWorkloadKey) return
192
- if (selectedWorkloadParam === singleWorkloadKey && !viewParam) return
193
- const params = new URLSearchParams(searchParams)
194
- params.delete('view')
195
- params.delete('run')
196
- params.set('workload', singleWorkloadKey)
197
- setSearchParams(params, { replace: true })
198
- }, [searchParams, selectedWorkloadParam, setSearchParams, singleWorkloadKey, viewParam])
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
+ ]);
199
401
  const selectView = useCallback(
200
- (view: ApplicationView) => {
201
- const params = new URLSearchParams(searchParams)
202
- params.delete('tab')
203
- params.delete('run')
402
+ (view: ApplicationRouteView) => {
403
+ const params = new URLSearchParams(searchParams);
404
+ params.delete("tab");
405
+ params.delete("run");
204
406
  if (singleWorkloadKey) {
205
- params.delete('view')
206
- params.set('workload', singleWorkloadKey)
207
- } else if (view === 'overview') {
208
- params.delete('view')
209
- params.delete('workload')
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");
210
415
  } else {
211
- params.set('view', view)
212
- params.delete('workload')
416
+ params.set("view", view);
417
+ params.delete("workload");
213
418
  }
214
- setSearchParams(params)
419
+ setSearchParams(params);
215
420
  },
216
421
  [searchParams, setSearchParams, singleWorkloadKey],
217
- )
422
+ );
218
423
  const selectWorkload = useCallback(
219
- (key: string | null) => {
220
- const params = new URLSearchParams(searchParams)
221
- params.delete('run')
424
+ (key: string | null, options?: { tab?: string }) => {
425
+ const params = new URLSearchParams(searchParams);
426
+ params.delete("run");
222
427
  if (key) {
223
- const wasInWorkloadScope = !!selectedWorkloadKey
224
- params.delete('view')
225
- params.set('workload', key)
226
- if (!wasInWorkloadScope) params.delete('tab')
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");
227
433
  } else if (singleWorkloadKey) {
228
- params.delete('view')
229
- params.set('workload', singleWorkloadKey)
434
+ params.delete("view");
435
+ params.set("workload", singleWorkloadKey);
230
436
  } else {
231
- params.delete('workload')
232
- params.delete('tab')
233
- params.delete('view')
437
+ params.delete("workload");
438
+ params.delete("tab");
439
+ params.delete("view");
234
440
  }
235
- setSearchParams(params)
441
+ setSearchParams(params);
236
442
  },
237
443
  [searchParams, selectedWorkloadKey, setSearchParams, singleWorkloadKey],
238
- )
444
+ );
239
445
  const selectWorkloadRun = useCallback(
240
- (workload: SelectedAppWorkload, run: { kind: string; name: string; data: Record<string, unknown> }) => {
241
- const params = new URLSearchParams(searchParams)
242
- const runNamespace = typeof run.data?.namespace === 'string' ? run.data.namespace : workload.namespace
243
- params.delete('view')
244
- params.delete('tab')
245
- params.set('workload', workloadKey(workload))
246
- params.set('run', `${kindToPlural(run.kind)}/${runNamespace}/${run.name}`)
247
- setSearchParams(params)
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);
248
463
  },
249
464
  [searchParams, setSearchParams],
250
- )
465
+ );
251
466
  const openWorkloadResource = useCallback(
252
467
  (resource: SelectedResource) => {
253
- if (kindToPlural(resource.kind).toLowerCase() !== 'pods') {
254
- onOpenResource(resource)
255
- return
468
+ if (kindToPlural(resource.kind).toLowerCase() !== "pods") {
469
+ onOpenResource(resource);
470
+ return;
256
471
  }
257
472
 
258
- const [pathname, rawSearch = ''] = buildWorkloadPath({ ...resource, kind: kindToPlural(resource.kind) }).split('?')
259
- const params = new URLSearchParams(rawSearch)
260
- const activeNamespaces = searchParams.get('namespaces')
261
- if (activeNamespaces) params.set('namespaces', activeNamespaces)
262
- navigate({ pathname, search: params.toString() })
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() });
263
481
  },
264
482
  [navigate, onOpenResource, searchParams],
265
- )
483
+ );
266
484
  const openSource = useCallback(
267
485
  (source: AppSourceRef) => {
268
- if (source.type === 'gitops') {
269
- const path = gitOpsRouteForKind(source.kind, source.namespace, source.name)
270
- if (path) navigate(path)
271
- return
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;
272
494
  }
273
- if (source.type === 'helm') {
274
- const params = new URLSearchParams()
275
- const activeNamespaces = searchParams.get('namespaces')
276
- if (activeNamespaces) params.set('namespaces', activeNamespaces)
277
- params.set('release', `${source.namespace}/${source.name}`)
278
- navigate({ pathname: '/helm', search: params.toString() })
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() });
279
501
  }
280
502
  },
281
503
  [navigate, searchParams],
282
- )
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
+ );
283
528
 
284
529
  // App identity switcher data: this instance's siblings (ladder-ordered
285
530
  // digests). It switches between REAL instances — ?app= changes, deep links
@@ -291,10 +536,17 @@ function AppDetailRoute({ app, apps, onBack, onOpenResource }: { app: AppRow; ap
291
536
  const sibs = apps.filter((a) => a.identity?.key === fam.key);
292
537
  if (sibs.length < 2) return null;
293
538
  const newest = (a: AppRow) =>
294
- (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;
295
543
  const order = orderEnvs(sibs.map((a) => a.identity!.env));
296
544
  return [...sibs]
297
- .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
+ )
298
550
  .map((a) => ({
299
551
  appKey: a.key,
300
552
  name: a.name,
@@ -314,17 +566,23 @@ function AppDetailRoute({ app, apps, onBack, onOpenResource }: { app: AppRow; ap
314
566
  (targetKey: string) => {
315
567
  const target = apps.find((a) => a.key === targetKey);
316
568
  const params = new URLSearchParams(searchParams);
317
- params.set('app', targetKey);
318
- params.delete('run');
319
- const wk = params.get('workload');
569
+ params.set("app", targetKey);
570
+ params.delete("run");
571
+ const wk = params.get("workload");
320
572
  let matched = false;
321
573
  if (wk && target) {
322
574
  // Stem matching strips this app group's own env tokens too, so
323
575
  // discovered envs (loadtest, …) carry position like the trio does.
324
- const identityEnvs = new Set((identityInstances ?? []).map((i) => i.env));
325
- 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
+ );
326
584
  if (m) {
327
- params.set('workload', workloadKey(m));
585
+ params.set("workload", workloadKey(m));
328
586
  matched = true;
329
587
  }
330
588
  }
@@ -332,17 +590,26 @@ function AppDetailRoute({ app, apps, onBack, onOpenResource }: { app: AppRow; ap
332
590
  // A workload WAS selected but has no counterpart — land on the target
333
591
  // instance's default scope and say so. Single-workload instances default
334
592
  // to their workload; composed apps default to app overview.
335
- params.delete('workload');
336
- params.delete('tab');
337
- const soleTargetWorkload = target?.workloads?.length === 1 ? target.workloads[0] : null;
593
+ params.delete("workload");
594
+ params.delete("tab");
595
+ const soleTargetWorkload =
596
+ target?.workloads?.length === 1 ? target.workloads[0] : null;
338
597
  if (soleTargetWorkload) {
339
- params.delete('view');
340
- params.set('workload', workloadKey(soleTargetWorkload));
598
+ params.delete("view");
599
+ params.set("workload", workloadKey(soleTargetWorkload));
341
600
  } else {
342
- params.delete('view');
601
+ params.delete("view");
343
602
  }
344
603
  if (target) {
345
- showToast(`No matching workload in ${target.identity?.env ?? target.name}`, { detail: soleTargetWorkload ? 'Showing the instance workload instead.' : '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
+ );
346
613
  }
347
614
  }
348
615
  setSearchParams(params);
@@ -351,7 +618,8 @@ function AppDetailRoute({ app, apps, onBack, onOpenResource }: { app: AppRow; ap
351
618
  );
352
619
 
353
620
  const discoveredEnvs = useMemo(
354
- () => 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)),
355
623
  [apps],
356
624
  );
357
625
 
@@ -370,11 +638,36 @@ function AppDetailRoute({ app, apps, onBack, onOpenResource }: { app: AppRow; ap
370
638
  onSelectWorkloadRun={selectWorkloadRun}
371
639
  history={historyQuery.data}
372
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}
373
651
  onOpenSource={openSource}
374
652
  selectedWorkloadKey={selectedWorkloadKey}
375
653
  onSelectWorkload={selectWorkload}
376
654
  selectedView={selectedView}
377
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
+ }
378
671
  renderOverviewIssues={() => (
379
672
  <AppOverviewIssues
380
673
  issues={appIssues}
@@ -402,78 +695,174 @@ function AppDetailRoute({ app, apps, onBack, onOpenResource }: { app: AppRow; ap
402
695
  )}
403
696
  />
404
697
  </div>
405
- )
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;
406
713
  }
407
714
 
408
- function AppOverviewIssues({ issues, error, hasData, visibility, onOpenResource }: { issues: Issue[]; error: unknown; hasData: boolean; visibility?: IssuesResponse['visibility']; onOpenResource: (resource: SelectedResource) => void }) {
409
- const [openId, setOpenId] = useState<string | null>(null)
410
- const sorted = useMemo(() => [...issues].sort(compareAppOverviewIssues), [issues])
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
+ );
411
733
 
412
734
  if (error && !hasData) {
413
- return <AppOverviewIssuesState headline="Operational issues unavailable" body={error instanceof Error ? error.message : 'Radar could not load issues for this application.'} />
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
+ );
414
745
  }
415
746
 
416
747
  if (error) {
417
748
  return (
418
749
  <section className="space-y-2">
419
- <AppOverviewIssuesState headline="Operational issue refresh failed" body="Showing the last successful result for this application." />
420
- {sorted.length > 0 && <AppOverviewIssueRows issues={sorted} openId={openId} setOpenId={setOpenId} onOpenResource={onOpenResource} />}
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
+ )}
421
762
  </section>
422
- )
763
+ );
423
764
  }
424
765
 
425
766
  if (visibility?.impact) {
426
767
  return (
427
768
  <section className="space-y-2">
428
- <AppOverviewIssuesState headline={sorted.length === 0 ? 'No visible operational issues' : 'Operational issue visibility is limited'} body={`${visibility.impact} Results may be incomplete.`} />
429
- {sorted.length > 0 && <AppOverviewIssueRows issues={sorted} openId={openId} setOpenId={setOpenId} onOpenResource={onOpenResource} />}
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
+ )}
430
785
  </section>
431
- )
786
+ );
432
787
  }
433
788
 
434
- if (sorted.length === 0) return null
789
+ if (sorted.length === 0) return null;
435
790
 
436
- return <AppOverviewIssueRows issues={sorted} openId={openId} setOpenId={setOpenId} onOpenResource={onOpenResource} />
791
+ return (
792
+ <AppOverviewIssueRows
793
+ issues={sorted}
794
+ openId={openId}
795
+ setOpenId={setOpenId}
796
+ onOpenResource={onOpenResource}
797
+ />
798
+ );
437
799
  }
438
800
 
439
- function AppOverviewIssuesState({ headline, body }: { headline: string; body: string }) {
801
+ function AppOverviewIssuesState({
802
+ headline,
803
+ body,
804
+ }: {
805
+ headline: string;
806
+ body: string;
807
+ }) {
440
808
  return (
441
809
  <section className="rounded-lg border border-theme-border bg-theme-surface px-4 py-3 shadow-theme-sm">
442
810
  <div className="flex items-start gap-3">
443
- <AlertTriangle className={`mt-0.5 h-4 w-4 shrink-0 ${SEVERITY_TEXT.warning}`} aria-hidden />
811
+ <AlertTriangle
812
+ className={`mt-0.5 h-4 w-4 shrink-0 ${SEVERITY_TEXT.warning}`}
813
+ aria-hidden
814
+ />
444
815
  <div className="min-w-0">
445
- <h2 className="text-sm font-semibold text-theme-text-primary">{headline}</h2>
816
+ <h2 className="text-sm font-semibold text-theme-text-primary">
817
+ {headline}
818
+ </h2>
446
819
  <p className="mt-0.5 text-sm text-theme-text-secondary">{body}</p>
447
820
  </div>
448
821
  </div>
449
822
  </section>
450
- )
823
+ );
451
824
  }
452
825
 
453
- function AppOverviewIssueRows({ issues: sorted, openId, setOpenId, onOpenResource }: { issues: Issue[]; openId: string | null; setOpenId: (value: string | null) => void; onOpenResource: (resource: SelectedResource) => void }) {
454
-
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
+ }) {
455
837
  const navigate = (ref: IssueResourceRef) => {
456
838
  onOpenResource({
457
839
  kind: kindToPlural(ref.kind),
458
- namespace: ref.namespace ?? '',
840
+ namespace: ref.namespace ?? "",
459
841
  name: ref.name,
460
- group: ref.group ?? '',
461
- })
462
- }
842
+ group: ref.group ?? "",
843
+ });
844
+ };
463
845
 
464
846
  return (
465
847
  <section className="space-y-2">
466
848
  <div className="flex items-center justify-between gap-3">
467
849
  <div className="flex min-w-0 items-center gap-2 text-sm font-semibold text-theme-text-primary">
468
- <AlertTriangle className="h-4 w-4 shrink-0 text-theme-text-secondary" aria-hidden />
850
+ <AlertTriangle
851
+ className="h-4 w-4 shrink-0 text-theme-text-secondary"
852
+ aria-hidden
853
+ />
469
854
  <span>Operational Issues</span>
470
- <span className="badge-sm text-[10px] text-theme-text-secondary">{sorted.length}</span>
855
+ <span className="badge-sm text-[10px] text-theme-text-secondary">
856
+ {sorted.length}
857
+ </span>
471
858
  </div>
472
- <span className="text-xs text-theme-text-tertiary">Scoped to this application</span>
859
+ <span className="text-xs text-theme-text-tertiary">
860
+ Scoped to this application
861
+ </span>
473
862
  </div>
474
863
  <ol className="flex flex-col gap-1.5">
475
864
  {sorted.slice(0, 4).map((issue) => {
476
- const rowKey = `${issue.cluster_id ?? ''}:${issue.id}`
865
+ const rowKey = `${issue.cluster_id ?? ""}:${issue.id}`;
477
866
  return (
478
867
  <IssueRow
479
868
  key={rowKey}
@@ -482,7 +871,7 @@ function AppOverviewIssueRows({ issues: sorted, openId, setOpenId, onOpenResourc
482
871
  onToggle={() => setOpenId(openId === rowKey ? null : rowKey)}
483
872
  onResourceClick={navigate}
484
873
  />
485
- )
874
+ );
486
875
  })}
487
876
  </ol>
488
877
  {sorted.length > 4 ? (
@@ -491,52 +880,57 @@ function AppOverviewIssueRows({ issues: sorted, openId, setOpenId, onOpenResourc
491
880
  </div>
492
881
  ) : null}
493
882
  </section>
494
- )
883
+ );
495
884
  }
496
885
 
497
886
  function compareAppOverviewIssues(a: Issue, b: Issue): number {
498
- const severity = ISSUE_SEVERITY_RANK[b.severity] - ISSUE_SEVERITY_RANK[a.severity]
499
- if (severity !== 0) return severity
500
- const fa = a.first_seen ?? ''
501
- const fb = b.first_seen ?? ''
502
- if (fa !== fb) return fb.localeCompare(fa)
503
- const ns = (a.namespace ?? '').localeCompare(b.namespace ?? '')
504
- if (ns !== 0) return ns
505
- const name = a.name.localeCompare(b.name)
506
- if (name !== 0) return name
507
- return a.id.localeCompare(b.id)
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);
508
898
  }
509
899
 
510
- function appIssuesForWorkloads(issues: Issue[], workloads: AppWorkload[]): Issue[] {
511
- if (issues.length === 0 || workloads.length === 0) return []
512
- const workloadKeys = new Set(workloads.map(workloadIssueKey))
513
- const out: Issue[] = []
514
- const seen = new Set<string>()
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>();
515
908
  for (const issue of issues) {
516
- if (!issueRefs(issue).some((ref) => workloadKeys.has(issueRefKey(ref)))) continue
517
- if (seen.has(issue.id)) continue
518
- seen.add(issue.id)
519
- out.push(issue)
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);
520
914
  }
521
- return out
915
+ return out;
522
916
  }
523
917
 
524
918
  function workloadIssueKey(workload: AppWorkload): string {
525
- return `${workload.kind.toLowerCase()}|${workload.namespace}|${workload.name}`
919
+ return `${workload.kind.toLowerCase()}|${workload.namespace}|${workload.name}`;
526
920
  }
527
921
 
528
922
  function issueRefKey(ref: IssueResourceRef): string {
529
- return `${ref.kind.toLowerCase()}|${ref.namespace ?? ''}|${ref.name}`
923
+ return `${ref.kind.toLowerCase()}|${ref.namespace ?? ""}|${ref.name}`;
530
924
  }
531
925
 
532
926
  function issueRefs(issue: Issue): IssueResourceRef[] {
533
- const refs: IssueResourceRef[] = [subjectRef(issue)]
534
- if (issue.owner) refs.push(issue.owner)
535
- if (issue.incident_parent?.ref) refs.push(issue.incident_parent.ref)
536
- for (const member of issue.members ?? []) refs.push(memberRef(issue, member))
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));
537
931
  for (const fact of issue.diagnostic_context?.facts ?? []) {
538
- refs.push(...(fact.refs ?? []))
539
- for (const related of fact.related_issues ?? []) refs.push(related.ref)
932
+ refs.push(...(fact.refs ?? []));
933
+ for (const related of fact.related_issues ?? []) refs.push(related.ref);
540
934
  }
541
- return refs
935
+ return refs;
542
936
  }