@skyhook-io/radar-app 0.2.2 → 0.3.1

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 (177) hide show
  1. package/README.md +7 -1
  2. package/package.json +33 -25
  3. package/src/App.tsx +1449 -382
  4. package/src/RadarApp.tsx +132 -19
  5. package/src/api/apiResources.ts +1 -1
  6. package/src/api/client.argoResourceSync.test.ts +69 -0
  7. package/src/api/client.delta.test.ts +89 -0
  8. package/src/api/client.deltaSync.test.ts +216 -0
  9. package/src/api/client.metrics.test.ts +106 -0
  10. package/src/api/client.rightsizing.test.ts +32 -0
  11. package/src/api/client.ts +2730 -271
  12. package/src/api/client.yaml.test.ts +45 -0
  13. package/src/api/diagnose.ts +289 -0
  14. package/src/api/quotas.ts +16 -0
  15. package/src/api/rbac.ts +57 -0
  16. package/src/api/timelineSource.test.ts +217 -0
  17. package/src/api/timelineSource.ts +582 -0
  18. package/src/components/ConnectionErrorView.tsx +186 -70
  19. package/src/components/ContextSwitcher.tsx +63 -18
  20. package/src/components/DebugOverlay.tsx +5 -3
  21. package/src/components/NamespaceSwitcher.tsx +41 -0
  22. package/src/components/UserMenu.tsx +69 -21
  23. package/src/components/applications/ApplicationsView.tsx +936 -0
  24. package/src/components/audit/AuditSettingsDialog.tsx +79 -17
  25. package/src/components/audit/AuditView.tsx +65 -62
  26. package/src/components/compare/CompareViewRoute.tsx +124 -0
  27. package/src/components/compare/useCompareCandidates.ts +27 -0
  28. package/src/components/compare/useCompareLauncher.tsx +79 -0
  29. package/src/components/cost/ApplicationCostTab.test.ts +204 -0
  30. package/src/components/cost/ApplicationCostTab.tsx +571 -0
  31. package/src/components/cost/CostTrendChart.tsx +106 -75
  32. package/src/components/cost/CostView.test.ts +12 -0
  33. package/src/components/cost/CostView.tsx +507 -223
  34. package/src/components/cost/CostViewTabs.test.tsx +21 -0
  35. package/src/components/cost/CostViewTabs.tsx +40 -0
  36. package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
  37. package/src/components/cost/CurrentAllocationUse.tsx +126 -0
  38. package/src/components/cost/WorkloadCostTab.test.ts +153 -0
  39. package/src/components/cost/WorkloadCostTab.tsx +372 -0
  40. package/src/components/cost/cloud-console.test.ts +39 -0
  41. package/src/components/cost/cloud-console.ts +81 -0
  42. package/src/components/cost/errors.ts +8 -0
  43. package/src/components/cost/format.test.ts +27 -0
  44. package/src/components/cost/format.ts +46 -0
  45. package/src/components/cost/kinds.ts +5 -0
  46. package/src/components/curl/ServiceCurlButton.tsx +445 -0
  47. package/src/components/diagnose/AISettings.tsx +147 -0
  48. package/src/components/diagnose/DiagnoseContext.tsx +495 -0
  49. package/src/components/diagnose/DiagnoseSurface.tsx +394 -0
  50. package/src/components/diagnose/Home.tsx +163 -0
  51. package/src/components/diagnose/InvestigationView.tsx +622 -0
  52. package/src/components/diagnose/LocalDiagnoseAction.tsx +162 -0
  53. package/src/components/diagnose/launch.ts +65 -0
  54. package/src/components/diagnose/parts.tsx +1756 -0
  55. package/src/components/dock/BottomDock.tsx +2 -3
  56. package/src/components/dock/DockContext.tsx +1 -0
  57. package/src/components/dock/TerminalTab.tsx +1 -1
  58. package/src/components/dock/WorkloadLogsTab.tsx +21 -5
  59. package/src/components/dock/index.ts +1 -1
  60. package/src/components/execution/BatchExecutionView.test.ts +170 -0
  61. package/src/components/execution/BatchExecutionView.tsx +1329 -0
  62. package/src/components/execution/batch-run-actions.test.ts +48 -0
  63. package/src/components/execution/batch-run-actions.ts +24 -0
  64. package/src/components/execution/batch-timeline.test.ts +57 -0
  65. package/src/components/execution/batch-timeline.ts +46 -0
  66. package/src/components/execution/execution-definition.test.ts +208 -0
  67. package/src/components/execution/execution-definition.ts +245 -0
  68. package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
  69. package/src/components/gitops/GitOpsView.tsx +1042 -0
  70. package/src/components/gitops/RevisionMetaChip.tsx +63 -0
  71. package/src/components/helm/ChartBrowser.tsx +87 -31
  72. package/src/components/helm/HelmCompareRoute.tsx +1341 -0
  73. package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
  74. package/src/components/helm/HelmReleaseDrawer.tsx +1073 -102
  75. package/src/components/helm/HelmView.tsx +237 -96
  76. package/src/components/helm/InstallWizard.tsx +94 -38
  77. package/src/components/helm/ManifestDiffViewer.tsx +8 -27
  78. package/src/components/helm/OwnedResources.tsx +34 -59
  79. package/src/components/helm/RevisionHistory.tsx +52 -3
  80. package/src/components/helm/RoleGatedPanel.tsx +3 -3
  81. package/src/components/helm/TrackChartSourceDialog.tsx +185 -0
  82. package/src/components/helm/ValuesDiffPreview.tsx +17 -7
  83. package/src/components/helm/ValuesViewer.tsx +49 -53
  84. package/src/components/helm/helm-utils.ts +4 -0
  85. package/src/components/home/ActivitySummary.tsx +4 -1
  86. package/src/components/home/ClusterHealthCard.tsx +56 -42
  87. package/src/components/home/CostCard.tsx +21 -36
  88. package/src/components/home/GitOpsControllersCard.tsx +110 -0
  89. package/src/components/home/HelmSummary.tsx +3 -1
  90. package/src/components/home/HomeView.tsx +339 -105
  91. package/src/components/home/MCPSetupDialog.tsx +29 -87
  92. package/src/components/home/TrafficSummary.tsx +2 -2
  93. package/src/components/home/mcpToolCatalog.ts +333 -0
  94. package/src/components/issues/IssuesPane.tsx +151 -0
  95. package/src/components/logs/LogsViewer.tsx +4 -1
  96. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
  97. package/src/components/logs/WorkloadLogsViewer.tsx +4 -1
  98. package/src/components/nav/PrimaryNavRail.tsx +285 -0
  99. package/src/components/portforward/PortForwardButton.tsx +118 -47
  100. package/src/components/portforward/PortForwardManager.tsx +253 -131
  101. package/src/components/resource/HPACharts.tsx +237 -0
  102. package/src/components/resource/PVCUsageBar.tsx +59 -0
  103. package/src/components/resource/PrometheusCharts.tsx +160 -584
  104. package/src/components/resource/PrometheusChartsGrid.tsx +270 -0
  105. package/src/components/resource/RestartChart.tsx +133 -0
  106. package/src/components/resource/RightsizingStrip.test.ts +109 -0
  107. package/src/components/resource/RightsizingStrip.tsx +363 -0
  108. package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
  109. package/src/components/resources/CompositeRenderer.tsx +101 -0
  110. package/src/components/resources/ImageFilesystemModal.tsx +19 -12
  111. package/src/components/resources/PodFilesystemModal.tsx +6 -5
  112. package/src/components/resources/ResourceDetailDrawer.tsx +13 -3
  113. package/src/components/resources/ResourcesView.tsx +194 -17
  114. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
  115. package/src/components/resources/renderers/HPARenderer.tsx +20 -1
  116. package/src/components/resources/renderers/NamespaceRenderer.tsx +31 -0
  117. package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
  118. package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
  119. package/src/components/resources/renderers/PodRenderer.tsx +30 -6
  120. package/src/components/resources/renderers/RoleBindingRenderer.tsx +45 -1
  121. package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
  122. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
  123. package/src/components/resources/renderers/ServiceRenderer.tsx +81 -8
  124. package/src/components/resources/renderers/WorkloadRenderer.tsx +51 -4
  125. package/src/components/resources/renderers/index.ts +2 -0
  126. package/src/components/resources/resource-utils.ts +2 -1
  127. package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
  128. package/src/components/rightsizing/copy.test.ts +56 -0
  129. package/src/components/rightsizing/model.test.ts +227 -0
  130. package/src/components/rightsizing/model.ts +158 -0
  131. package/src/components/rightsizing/presentation.test.ts +104 -0
  132. package/src/components/rightsizing/presentation.ts +94 -0
  133. package/src/components/settings/MyPermissionsDialog.tsx +241 -0
  134. package/src/components/settings/SettingsDialog.tsx +1505 -165
  135. package/src/components/shared/CreateResourceDialog.tsx +9 -2
  136. package/src/components/shared/LargeClusterNamespacePicker.tsx +3 -3
  137. package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
  138. package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
  139. package/src/components/timeline/TimelineList.tsx +86 -13
  140. package/src/components/timeline/TimelineSwimlanes.tsx +9 -1299
  141. package/src/components/timeline/TimelineView.tsx +873 -24
  142. package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
  143. package/src/components/traffic/TrafficFilterSidebar.tsx +10 -45
  144. package/src/components/traffic/TrafficFlowList.tsx +29 -15
  145. package/src/components/traffic/TrafficGraph.tsx +42 -24
  146. package/src/components/traffic/TrafficView.tsx +32 -19
  147. package/src/components/ui/CommandPalette.tsx +8 -215
  148. package/src/components/ui/DiagnosticsOverlay.tsx +219 -9
  149. package/src/components/ui/Markdown.tsx +3 -3
  150. package/src/components/ui/Omnibar.tsx +602 -0
  151. package/src/components/ui/RadarOmnibar.tsx +52 -0
  152. package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
  153. package/src/components/ui/ShortcutHelpOverlay.tsx +3 -2
  154. package/src/components/ui/UpdateNotification.tsx +48 -36
  155. package/src/components/ui/command-items.ts +178 -0
  156. package/src/components/workload/WorkloadView.tsx +1342 -158
  157. package/src/context/ConnectionContext.tsx +146 -21
  158. package/src/context/DiagnoseCustomization.tsx +93 -0
  159. package/src/context/NavCustomization.tsx +75 -0
  160. package/src/context/TimelineSource.tsx +50 -0
  161. package/src/contexts/CapabilitiesContext.tsx +32 -8
  162. package/src/filter/FilterLocationBridge.tsx +30 -0
  163. package/src/hooks/useClusterLoadState.ts +73 -0
  164. package/src/hooks/useDocumentTitle.ts +25 -0
  165. package/src/hooks/useEventSource.ts +6 -0
  166. package/src/hooks/useKeyboardShortcuts.tsx +1 -0
  167. package/src/hooks/useMediaQuery.ts +21 -0
  168. package/src/hooks/useNavRailPinned.ts +46 -0
  169. package/src/hooks/useRecentResources.ts +49 -0
  170. package/src/index.css +162 -1
  171. package/src/index.ts +73 -1
  172. package/src/main.tsx +7 -5
  173. package/src/types/clusterLoadState.ts +33 -0
  174. package/src/types.ts +2 -0
  175. package/src/utils/auditBadges.ts +53 -0
  176. package/src/utils/navigation.ts +64 -1
  177. package/src/components/ui/NamespaceSelector.tsx +0 -436
@@ -0,0 +1,936 @@
1
+ import { useCallback, useEffect, useMemo, useState } from "react";
2
+ import { useNavigate, useSearchParams } from "react-router-dom";
3
+ import {
4
+ ApplicationsList,
5
+ ApplicationDetail,
6
+ CenteredEmpty,
7
+ PageHeader,
8
+ FreshnessControl,
9
+ IssueRow,
10
+ ISSUE_SEVERITY_RANK,
11
+ useToast,
12
+ orderEnvs,
13
+ matchWorkloadAcrossInstances,
14
+ workloadKey,
15
+ healthOf,
16
+ compareVersions,
17
+ gitOpsRouteForKind,
18
+ deploymentInventoryFromGitOps,
19
+ deploymentInventoryFromHelm,
20
+ buildAppMembershipIndex,
21
+ buildApplicationHistoryItems,
22
+ eventsForApplication,
23
+ memberRef,
24
+ subjectRef,
25
+ type AppRow,
26
+ type AppWorkload,
27
+ type AppIdentityInstance,
28
+ type ApplicationView,
29
+ type ApplicationHistoryRange,
30
+ type AppSourceRef,
31
+ type Issue,
32
+ type IssueResourceRef,
33
+ type SelectedAppWorkload,
34
+ type SelectedResource,
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
+ }
78
+
79
+ interface ApplicationsViewProps {
80
+ namespaces: string[];
81
+ onOpenResource: (resource: SelectedResource) => void;
82
+ }
83
+
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]);
91
+
92
+ const freshness = (
93
+ <FreshnessControl
94
+ mode="auto"
95
+ dataUpdatedAt={query.dataUpdatedAt}
96
+ onRefresh={() => query.refetch()}
97
+ connectionState={connection.state}
98
+ />
99
+ );
100
+
101
+ // Which app is open lives in the URL (?app=<key>) so the detail view is
102
+ // deep-linkable and the browser back button returns to the list. Opening or
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
+ );
110
+
111
+ const selectApp = useCallback(
112
+ (key: string | null) => {
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);
120
+ },
121
+ [searchParams, setSearchParams],
122
+ );
123
+
124
+ // A stale ?app= (uninstalled/renamed app, or a link from another cluster)
125
+ // would leave the URL lying under the list view — clear it once data is
126
+ // fresh. Never during load, so a slow fetch can't eject a valid deep link.
127
+ useEffect(() => {
128
+ if (selectedKey && !selected && query.isSuccess) {
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 });
135
+ }
136
+ }, [selectedKey, selected, query.isSuccess, searchParams, setSearchParams]);
137
+
138
+ if (selectedKey && selected) {
139
+ return (
140
+ <AppDetailRoute
141
+ app={selected}
142
+ apps={apps}
143
+ onBack={() => selectApp(null)}
144
+ onOpenResource={onOpenResource}
145
+ />
146
+ );
147
+ }
148
+
149
+ // The header + status + filters + table chassis lives inside ApplicationsList
150
+ // (mirroring GitOpsTableView), which renders only on the data path. To keep
151
+ // the page header from vanishing while loading / on error, the wrapper shows
152
+ // the same header bar above those states. (Keep title + description in sync
153
+ // with ApplicationsList's PageHeader.)
154
+ if (query.isLoading) {
155
+ return (
156
+ <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
157
+ <ApplicationsList
158
+ apps={[]}
159
+ onSelect={selectApp}
160
+ headerActions={freshness}
161
+ loading
162
+ />
163
+ </div>
164
+ );
165
+ }
166
+ if (query.error) {
167
+ return (
168
+ <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
169
+ <div className="shrink-0 border-b border-theme-border px-4 py-4">
170
+ <PageHeader
171
+ icon={Boxes}
172
+ title="Applications"
173
+ description="Deployable software in this cluster — your services, workers, and jobs, grouped by app/release evidence."
174
+ />
175
+ </div>
176
+ <CenteredEmpty
177
+ tone="filtered"
178
+ icon={Boxes}
179
+ headline="Failed to load applications"
180
+ body={(query.error as Error).message}
181
+ />
182
+ </div>
183
+ );
184
+ }
185
+
186
+ return (
187
+ <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
188
+ <ApplicationsList
189
+ apps={apps}
190
+ onSelect={selectApp}
191
+ headerActions={freshness}
192
+ />
193
+ </div>
194
+ );
195
+ }
196
+
197
+ // AppDetailRoute wires the OSS data hooks the shared ApplicationDetail can't:
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();
213
+ const appNamespaces = useMemo(
214
+ () =>
215
+ Array.from(
216
+ new Set((app.workloads ?? []).map((w) => w.namespace).filter(Boolean)),
217
+ ).sort(),
218
+ [app.workloads],
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
+ );
423
+ const selectWorkload = useCallback(
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);
463
+ },
464
+ [searchParams, setSearchParams],
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
+ );
528
+
529
+ // App identity switcher data: this instance's siblings (ladder-ordered
530
+ // digests). It switches between REAL instances — ?app= changes, deep links
531
+ // stay instance-keyed.
532
+ const { showToast } = useToast();
533
+ const identityInstances = useMemo<AppIdentityInstance[] | null>(() => {
534
+ const fam = app.identity;
535
+ if (!fam) return null;
536
+ const sibs = apps.filter((a) => a.identity?.key === fam.key);
537
+ if (sibs.length < 2) return null;
538
+ const newest = (a: AppRow) =>
539
+ (a.versions ?? []).reduce<string | undefined>(
540
+ (best, v) => (!best || compareVersions(v, best) === 1 ? v : best),
541
+ undefined,
542
+ ) ?? a.appVersion;
543
+ const order = orderEnvs(sibs.map((a) => a.identity!.env));
544
+ return [...sibs]
545
+ .sort(
546
+ (a, b) =>
547
+ order.indexOf(a.identity!.env) - order.indexOf(b.identity!.env) ||
548
+ a.name.localeCompare(b.name),
549
+ )
550
+ .map((a) => ({
551
+ appKey: a.key,
552
+ name: a.name,
553
+ env: a.identity!.env,
554
+ health: healthOf(a.health),
555
+ version: newest(a),
556
+ confidence: a.identity!.confidence,
557
+ evidence: a.identity!.evidence,
558
+ }));
559
+ }, [apps, app]);
560
+
561
+ // Position-preserving env switch: carry the selected workload + tab into the
562
+ // sibling when a matching workload exists there (exact kind+name, else the
563
+ // env-affix-stripped stem); otherwise land on the instance overview and say
564
+ // the workload wasn't found.
565
+ const switchInstance = useCallback(
566
+ (targetKey: string) => {
567
+ const target = apps.find((a) => a.key === targetKey);
568
+ const params = new URLSearchParams(searchParams);
569
+ params.set("app", targetKey);
570
+ params.delete("run");
571
+ const wk = params.get("workload");
572
+ let matched = false;
573
+ if (wk && target) {
574
+ // Stem matching strips this app group's own env tokens too, so
575
+ // discovered envs (loadtest, …) carry position like the trio does.
576
+ const identityEnvs = new Set(
577
+ (identityInstances ?? []).map((i) => i.env),
578
+ );
579
+ const m = matchWorkloadAcrossInstances(
580
+ wk,
581
+ target.workloads,
582
+ identityEnvs,
583
+ );
584
+ if (m) {
585
+ params.set("workload", workloadKey(m));
586
+ matched = true;
587
+ }
588
+ }
589
+ if (!matched && wk) {
590
+ // A workload WAS selected but has no counterpart — land on the target
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
+ }
603
+ if (target) {
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
+ );
613
+ }
614
+ }
615
+ setSearchParams(params);
616
+ },
617
+ [apps, identityInstances, searchParams, setSearchParams, showToast],
618
+ );
619
+
620
+ const discoveredEnvs = useMemo(
621
+ () =>
622
+ new Set(apps.map((a) => a.identity?.env).filter((e): e is string => !!e)),
623
+ [apps],
624
+ );
625
+
626
+ return (
627
+ <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
628
+ <ApplicationDetail
629
+ app={app}
630
+ onBack={onBack}
631
+ topology={topology}
632
+ topologyLoading={topologyLoading}
633
+ deploymentInventory={deploymentInventory}
634
+ identityInstances={identityInstances}
635
+ onSwitchInstance={switchInstance}
636
+ discoveredEnvs={discoveredEnvs}
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}
652
+ selectedWorkloadKey={selectedWorkloadKey}
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}
681
+ renderWorkload={(workload: SelectedAppWorkload) => (
682
+ <div className="h-full overflow-hidden">
683
+ <WorkloadView
684
+ kind={kindToPlural(workload.kind)}
685
+ group={workload.group}
686
+ namespace={workload.namespace}
687
+ name={workload.name}
688
+ onBack={() => selectWorkload(null)}
689
+ hideBackButton
690
+ compactHeader
691
+ pushTabHistory
692
+ onNavigateToResource={openWorkloadResource}
693
+ />
694
+ </div>
695
+ )}
696
+ />
697
+ </div>
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;
936
+ }