@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.
- package/package.json +4 -4
- package/src/App.tsx +69 -61
- package/src/RadarApp.tsx +15 -1
- package/src/api/apiResources.ts +1 -1
- package/src/api/client.argoResourceSync.test.ts +69 -0
- package/src/api/client.rightsizing.test.ts +32 -0
- package/src/api/client.ts +1222 -244
- package/src/api/timelineSource.ts +4 -2
- package/src/components/applications/ApplicationsView.tsx +613 -219
- 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/diagnose/AISettings.tsx +7 -12
- package/src/components/diagnose/DiagnoseContext.tsx +1 -0
- package/src/components/diagnose/DiagnoseSurface.tsx +3 -0
- package/src/components/diagnose/InvestigationView.tsx +16 -3
- package/src/components/diagnose/parts.tsx +140 -73
- 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/HelmCompareRoute.tsx +1 -2
- package/src/components/helm/ManifestDiffViewer.tsx +1 -31
- package/src/components/helm/ValuesDiffPreview.tsx +2 -3
- package/src/components/home/CostCard.tsx +21 -36
- package/src/components/resource/RightsizingStrip.test.ts +109 -0
- package/src/components/resource/RightsizingStrip.tsx +319 -123
- 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 +1268 -318
- package/src/components/timeline/TimelineList.tsx +35 -8
- package/src/components/timeline/TimelineView.tsx +156 -26
- package/src/components/timeline/TimelineView.urlparams.test.ts +43 -2
- package/src/components/workload/WorkloadView.tsx +711 -328
- package/src/context/DiagnoseCustomization.tsx +36 -2
- package/src/index.css +5 -1
- package/src/index.ts +4 -1
- package/src/main.tsx +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { useCallback, useEffect, useMemo, useState } from
|
|
2
|
-
import { useNavigate, 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,
|
|
@@ -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
|
|
32
|
-
import { AlertTriangle, Boxes } from
|
|
33
|
-
import { SEVERITY_TEXT } from
|
|
34
|
-
import {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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({
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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(
|
|
70
|
-
const selected = useMemo(
|
|
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(
|
|
76
|
-
else params.delete(
|
|
77
|
-
params.delete(
|
|
78
|
-
params.delete(
|
|
79
|
-
params.delete(
|
|
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(
|
|
92
|
-
params.delete(
|
|
93
|
-
params.delete(
|
|
94
|
-
params.delete(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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({
|
|
141
|
-
|
|
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
|
-
() =>
|
|
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(
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
() =>
|
|
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(
|
|
168
|
-
const
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
const
|
|
172
|
-
const
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
const
|
|
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 ===
|
|
185
|
-
const helmReleaseQuery = useHelmRelease(
|
|
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
|
-
() =>
|
|
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 (
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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:
|
|
201
|
-
const params = new URLSearchParams(searchParams)
|
|
202
|
-
params.delete(
|
|
203
|
-
params.delete(
|
|
402
|
+
(view: ApplicationRouteView) => {
|
|
403
|
+
const params = new URLSearchParams(searchParams);
|
|
404
|
+
params.delete("tab");
|
|
405
|
+
params.delete("run");
|
|
204
406
|
if (singleWorkloadKey) {
|
|
205
|
-
params.delete(
|
|
206
|
-
params.set(
|
|
207
|
-
} else if (view ===
|
|
208
|
-
params.
|
|
209
|
-
params.delete(
|
|
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(
|
|
212
|
-
params.delete(
|
|
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(
|
|
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(
|
|
225
|
-
params.set(
|
|
226
|
-
if (
|
|
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(
|
|
229
|
-
params.set(
|
|
434
|
+
params.delete("view");
|
|
435
|
+
params.set("workload", singleWorkloadKey);
|
|
230
436
|
} else {
|
|
231
|
-
params.delete(
|
|
232
|
-
params.delete(
|
|
233
|
-
params.delete(
|
|
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
|
-
(
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
params
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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() !==
|
|
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 =
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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 ===
|
|
269
|
-
const path = gitOpsRouteForKind(
|
|
270
|
-
|
|
271
|
-
|
|
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 ===
|
|
274
|
-
const params = new URLSearchParams()
|
|
275
|
-
const activeNamespaces = searchParams.get(
|
|
276
|
-
if (activeNamespaces) params.set(
|
|
277
|
-
params.set(
|
|
278
|
-
navigate({ pathname:
|
|
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>(
|
|
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(
|
|
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(
|
|
318
|
-
params.delete(
|
|
319
|
-
const wk = params.get(
|
|
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(
|
|
325
|
-
|
|
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(
|
|
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(
|
|
336
|
-
params.delete(
|
|
337
|
-
const soleTargetWorkload =
|
|
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(
|
|
340
|
-
params.set(
|
|
598
|
+
params.delete("view");
|
|
599
|
+
params.set("workload", workloadKey(soleTargetWorkload));
|
|
341
600
|
} else {
|
|
342
|
-
params.delete(
|
|
601
|
+
params.delete("view");
|
|
343
602
|
}
|
|
344
603
|
if (target) {
|
|
345
|
-
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
|
+
);
|
|
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
|
-
() =>
|
|
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({
|
|
409
|
-
|
|
410
|
-
|
|
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
|
|
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
|
|
420
|
-
|
|
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
|
|
429
|
-
|
|
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
|
|
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({
|
|
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
|
|
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">
|
|
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({
|
|
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
|
|
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">
|
|
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">
|
|
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 ??
|
|
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 =
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
const
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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(
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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))))
|
|
517
|
-
|
|
518
|
-
seen.
|
|
519
|
-
|
|
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 ??
|
|
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
|
}
|