@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.
- package/README.md +7 -1
- package/package.json +12 -10
- package/src/App.tsx +278 -230
- package/src/RadarApp.tsx +91 -20
- package/src/api/client.argoResourceSync.test.ts +69 -0
- package/src/api/client.delta.test.ts +89 -0
- package/src/api/client.deltaSync.test.ts +216 -0
- package/src/api/client.rightsizing.test.ts +32 -0
- package/src/api/client.ts +1462 -246
- package/src/api/diagnose.ts +288 -0
- package/src/api/timelineSource.test.ts +217 -0
- package/src/api/timelineSource.ts +582 -0
- package/src/components/ConnectionErrorView.tsx +174 -70
- package/src/components/ContextSwitcher.tsx +13 -5
- package/src/components/applications/ApplicationsView.tsx +775 -80
- package/src/components/audit/AuditSettingsDialog.tsx +2 -2
- package/src/components/cost/ApplicationCostTab.test.ts +204 -0
- package/src/components/cost/ApplicationCostTab.tsx +571 -0
- package/src/components/cost/CostTrendChart.tsx +103 -72
- package/src/components/cost/CostView.test.ts +12 -0
- package/src/components/cost/CostView.tsx +494 -229
- package/src/components/cost/CostViewTabs.test.tsx +21 -0
- package/src/components/cost/CostViewTabs.tsx +40 -0
- package/src/components/cost/CurrentAllocationUse.test.ts +21 -0
- package/src/components/cost/CurrentAllocationUse.tsx +126 -0
- package/src/components/cost/WorkloadCostTab.test.ts +153 -0
- package/src/components/cost/WorkloadCostTab.tsx +372 -0
- package/src/components/cost/cloud-console.test.ts +39 -0
- package/src/components/cost/cloud-console.ts +81 -0
- package/src/components/cost/errors.ts +8 -0
- package/src/components/cost/format.test.ts +27 -0
- package/src/components/cost/format.ts +46 -0
- package/src/components/cost/kinds.ts +5 -0
- package/src/components/curl/ServiceCurlButton.tsx +2 -2
- package/src/components/diagnose/AISettings.tsx +116 -0
- package/src/components/diagnose/DiagnoseContext.tsx +491 -0
- package/src/components/diagnose/DiagnoseSurface.tsx +385 -0
- package/src/components/diagnose/Home.tsx +163 -0
- package/src/components/diagnose/InvestigationView.tsx +604 -0
- package/src/components/diagnose/LocalDiagnoseAction.tsx +150 -0
- package/src/components/diagnose/launch.ts +65 -0
- package/src/components/diagnose/parts.tsx +1689 -0
- package/src/components/dock/BottomDock.tsx +2 -3
- package/src/components/dock/WorkloadLogsTab.tsx +21 -5
- package/src/components/execution/BatchExecutionView.test.ts +170 -0
- package/src/components/execution/BatchExecutionView.tsx +1329 -0
- package/src/components/execution/batch-run-actions.test.ts +48 -0
- package/src/components/execution/batch-run-actions.ts +24 -0
- package/src/components/execution/batch-timeline.test.ts +57 -0
- package/src/components/execution/batch-timeline.ts +46 -0
- package/src/components/execution/execution-definition.test.ts +208 -0
- package/src/components/execution/execution-definition.ts +245 -0
- package/src/components/gitops/ArgoResourceDiffLoader.tsx +23 -0
- package/src/components/gitops/GitOpsView.tsx +81 -14
- package/src/components/gitops/RevisionMetaChip.tsx +63 -0
- package/src/components/helm/ChartBrowser.tsx +2 -3
- package/src/components/helm/HelmCompareRoute.tsx +1 -2
- package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +376 -43
- package/src/components/helm/HelmView.tsx +2 -3
- package/src/components/helm/InstallWizard.tsx +3 -5
- package/src/components/helm/ManifestDiffViewer.tsx +1 -31
- package/src/components/helm/TrackChartSourceDialog.tsx +48 -4
- package/src/components/helm/ValuesDiffPreview.tsx +17 -7
- package/src/components/home/CostCard.tsx +21 -36
- package/src/components/home/HomeView.tsx +17 -15
- package/src/components/home/MCPSetupDialog.tsx +1 -1
- package/src/components/home/mcpToolCatalog.ts +2 -2
- package/src/components/issues/IssuesPane.tsx +9 -1
- package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
- package/src/components/portforward/PortForwardButton.tsx +2 -2
- package/src/components/portforward/PortForwardManager.tsx +19 -14
- package/src/components/resource/PrometheusChartsGrid.tsx +6 -80
- package/src/components/resource/RightsizingStrip.test.ts +109 -0
- package/src/components/resource/RightsizingStrip.tsx +320 -127
- package/src/components/resources/ImageFilesystemModal.tsx +2 -2
- package/src/components/resources/PodFilesystemModal.tsx +2 -3
- package/src/components/resources/ResourceDetailDrawer.tsx +2 -0
- package/src/components/resources/ResourcesView.tsx +13 -4
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
- package/src/components/resources/renderers/index.ts +1 -0
- package/src/components/rightsizing/RightsizingScanView.tsx +938 -0
- package/src/components/rightsizing/copy.test.ts +56 -0
- package/src/components/rightsizing/model.test.ts +227 -0
- package/src/components/rightsizing/model.ts +158 -0
- package/src/components/rightsizing/presentation.test.ts +104 -0
- package/src/components/rightsizing/presentation.ts +94 -0
- package/src/components/settings/MyPermissionsDialog.tsx +66 -116
- package/src/components/settings/SettingsDialog.tsx +1290 -283
- package/src/components/shared/LargeClusterNamespacePicker.tsx +2 -2
- package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
- package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
- package/src/components/timeline/TimelineList.tsx +81 -13
- package/src/components/timeline/TimelineView.tsx +842 -26
- package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +2 -2
- package/src/components/traffic/TrafficView.tsx +8 -4
- package/src/components/ui/CommandPalette.tsx +2 -2
- package/src/components/workload/WorkloadView.tsx +912 -245
- package/src/context/ConnectionContext.tsx +109 -11
- package/src/context/DiagnoseCustomization.tsx +42 -0
- package/src/context/TimelineSource.tsx +50 -0
- package/src/hooks/useClusterLoadState.ts +73 -0
- package/src/hooks/useEventSource.ts +6 -0
- package/src/index.css +162 -1
- package/src/index.ts +12 -0
- package/src/main.tsx +1 -1
- package/src/types/clusterLoadState.ts +33 -0
- package/src/utils/navigation.ts +10 -0
|
@@ -1,37 +1,93 @@
|
|
|
1
|
-
import { useCallback, useEffect, useMemo } from
|
|
2
|
-
import { useSearchParams } from
|
|
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
|
|
20
|
-
import { Boxes } from
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
|
|
24
|
-
|
|
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({
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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(
|
|
50
|
-
const selected = useMemo(
|
|
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(
|
|
56
|
-
else params.delete(
|
|
57
|
-
params.delete(
|
|
58
|
-
params.delete(
|
|
59
|
-
|
|
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(
|
|
71
|
-
params.delete(
|
|
72
|
-
params.delete(
|
|
73
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
() =>
|
|
214
|
+
() =>
|
|
215
|
+
Array.from(
|
|
216
|
+
new Set((app.workloads ?? []).map((w) => w.namespace).filter(Boolean)),
|
|
217
|
+
).sort(),
|
|
122
218
|
[app.workloads],
|
|
123
|
-
)
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const
|
|
130
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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>(
|
|
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(
|
|
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(
|
|
178
|
-
|
|
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(
|
|
184
|
-
|
|
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(
|
|
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
|
|
193
|
-
//
|
|
194
|
-
params.delete(
|
|
195
|
-
params.delete(
|
|
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(
|
|
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
|
-
() =>
|
|
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-
|
|
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
|
-
|
|
231
|
-
// single-workload app, which has no graph to return to.
|
|
232
|
-
hideBackButton={(app.workloads?.length ?? 0) <= 1}
|
|
689
|
+
hideBackButton
|
|
233
690
|
compactHeader
|
|
234
|
-
|
|
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
|
}
|