@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.
- package/README.md +7 -1
- package/package.json +33 -25
- package/src/App.tsx +1449 -382
- package/src/RadarApp.tsx +132 -19
- package/src/api/apiResources.ts +1 -1
- 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.metrics.test.ts +106 -0
- package/src/api/client.rightsizing.test.ts +32 -0
- package/src/api/client.ts +2730 -271
- package/src/api/client.yaml.test.ts +45 -0
- package/src/api/diagnose.ts +289 -0
- package/src/api/quotas.ts +16 -0
- package/src/api/rbac.ts +57 -0
- package/src/api/timelineSource.test.ts +217 -0
- package/src/api/timelineSource.ts +582 -0
- package/src/components/ConnectionErrorView.tsx +186 -70
- package/src/components/ContextSwitcher.tsx +63 -18
- package/src/components/DebugOverlay.tsx +5 -3
- package/src/components/NamespaceSwitcher.tsx +41 -0
- package/src/components/UserMenu.tsx +69 -21
- package/src/components/applications/ApplicationsView.tsx +936 -0
- package/src/components/audit/AuditSettingsDialog.tsx +79 -17
- package/src/components/audit/AuditView.tsx +65 -62
- package/src/components/compare/CompareViewRoute.tsx +124 -0
- package/src/components/compare/useCompareCandidates.ts +27 -0
- package/src/components/compare/useCompareLauncher.tsx +79 -0
- package/src/components/cost/ApplicationCostTab.test.ts +204 -0
- package/src/components/cost/ApplicationCostTab.tsx +571 -0
- package/src/components/cost/CostTrendChart.tsx +106 -75
- package/src/components/cost/CostView.test.ts +12 -0
- package/src/components/cost/CostView.tsx +507 -223
- 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 +445 -0
- package/src/components/diagnose/AISettings.tsx +147 -0
- package/src/components/diagnose/DiagnoseContext.tsx +495 -0
- package/src/components/diagnose/DiagnoseSurface.tsx +394 -0
- package/src/components/diagnose/Home.tsx +163 -0
- package/src/components/diagnose/InvestigationView.tsx +622 -0
- package/src/components/diagnose/LocalDiagnoseAction.tsx +162 -0
- package/src/components/diagnose/launch.ts +65 -0
- package/src/components/diagnose/parts.tsx +1756 -0
- package/src/components/dock/BottomDock.tsx +2 -3
- package/src/components/dock/DockContext.tsx +1 -0
- package/src/components/dock/TerminalTab.tsx +1 -1
- package/src/components/dock/WorkloadLogsTab.tsx +21 -5
- package/src/components/dock/index.ts +1 -1
- 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 +1042 -0
- package/src/components/gitops/RevisionMetaChip.tsx +63 -0
- package/src/components/helm/ChartBrowser.tsx +87 -31
- package/src/components/helm/HelmCompareRoute.tsx +1341 -0
- package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +1073 -102
- package/src/components/helm/HelmView.tsx +237 -96
- package/src/components/helm/InstallWizard.tsx +94 -38
- package/src/components/helm/ManifestDiffViewer.tsx +8 -27
- package/src/components/helm/OwnedResources.tsx +34 -59
- package/src/components/helm/RevisionHistory.tsx +52 -3
- package/src/components/helm/RoleGatedPanel.tsx +3 -3
- package/src/components/helm/TrackChartSourceDialog.tsx +185 -0
- package/src/components/helm/ValuesDiffPreview.tsx +17 -7
- package/src/components/helm/ValuesViewer.tsx +49 -53
- package/src/components/helm/helm-utils.ts +4 -0
- package/src/components/home/ActivitySummary.tsx +4 -1
- package/src/components/home/ClusterHealthCard.tsx +56 -42
- package/src/components/home/CostCard.tsx +21 -36
- package/src/components/home/GitOpsControllersCard.tsx +110 -0
- package/src/components/home/HelmSummary.tsx +3 -1
- package/src/components/home/HomeView.tsx +339 -105
- package/src/components/home/MCPSetupDialog.tsx +29 -87
- package/src/components/home/TrafficSummary.tsx +2 -2
- package/src/components/home/mcpToolCatalog.ts +333 -0
- package/src/components/issues/IssuesPane.tsx +151 -0
- package/src/components/logs/LogsViewer.tsx +4 -1
- package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +4 -1
- package/src/components/nav/PrimaryNavRail.tsx +285 -0
- package/src/components/portforward/PortForwardButton.tsx +118 -47
- package/src/components/portforward/PortForwardManager.tsx +253 -131
- package/src/components/resource/HPACharts.tsx +237 -0
- package/src/components/resource/PVCUsageBar.tsx +59 -0
- package/src/components/resource/PrometheusCharts.tsx +160 -584
- package/src/components/resource/PrometheusChartsGrid.tsx +270 -0
- package/src/components/resource/RestartChart.tsx +133 -0
- package/src/components/resource/RightsizingStrip.test.ts +109 -0
- package/src/components/resource/RightsizingStrip.tsx +363 -0
- package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
- package/src/components/resources/CompositeRenderer.tsx +101 -0
- package/src/components/resources/ImageFilesystemModal.tsx +19 -12
- package/src/components/resources/PodFilesystemModal.tsx +6 -5
- package/src/components/resources/ResourceDetailDrawer.tsx +13 -3
- package/src/components/resources/ResourcesView.tsx +194 -17
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
- package/src/components/resources/renderers/HPARenderer.tsx +20 -1
- package/src/components/resources/renderers/NamespaceRenderer.tsx +31 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
- package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
- package/src/components/resources/renderers/PodRenderer.tsx +30 -6
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +45 -1
- package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
- package/src/components/resources/renderers/ServiceRenderer.tsx +81 -8
- package/src/components/resources/renderers/WorkloadRenderer.tsx +51 -4
- package/src/components/resources/renderers/index.ts +2 -0
- package/src/components/resources/resource-utils.ts +2 -1
- 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 +241 -0
- package/src/components/settings/SettingsDialog.tsx +1505 -165
- package/src/components/shared/CreateResourceDialog.tsx +9 -2
- package/src/components/shared/LargeClusterNamespacePicker.tsx +3 -3
- package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
- package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
- package/src/components/timeline/TimelineList.tsx +86 -13
- package/src/components/timeline/TimelineSwimlanes.tsx +9 -1299
- package/src/components/timeline/TimelineView.tsx +873 -24
- package/src/components/timeline/TimelineView.urlparams.test.ts +335 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +10 -45
- package/src/components/traffic/TrafficFlowList.tsx +29 -15
- package/src/components/traffic/TrafficGraph.tsx +42 -24
- package/src/components/traffic/TrafficView.tsx +32 -19
- package/src/components/ui/CommandPalette.tsx +8 -215
- package/src/components/ui/DiagnosticsOverlay.tsx +219 -9
- package/src/components/ui/Markdown.tsx +3 -3
- package/src/components/ui/Omnibar.tsx +602 -0
- package/src/components/ui/RadarOmnibar.tsx +52 -0
- package/src/components/ui/SearchSyntaxHelp.tsx +89 -0
- package/src/components/ui/ShortcutHelpOverlay.tsx +3 -2
- package/src/components/ui/UpdateNotification.tsx +48 -36
- package/src/components/ui/command-items.ts +178 -0
- package/src/components/workload/WorkloadView.tsx +1342 -158
- package/src/context/ConnectionContext.tsx +146 -21
- package/src/context/DiagnoseCustomization.tsx +93 -0
- package/src/context/NavCustomization.tsx +75 -0
- package/src/context/TimelineSource.tsx +50 -0
- package/src/contexts/CapabilitiesContext.tsx +32 -8
- package/src/filter/FilterLocationBridge.tsx +30 -0
- package/src/hooks/useClusterLoadState.ts +73 -0
- package/src/hooks/useDocumentTitle.ts +25 -0
- package/src/hooks/useEventSource.ts +6 -0
- package/src/hooks/useKeyboardShortcuts.tsx +1 -0
- package/src/hooks/useMediaQuery.ts +21 -0
- package/src/hooks/useNavRailPinned.ts +46 -0
- package/src/hooks/useRecentResources.ts +49 -0
- package/src/index.css +162 -1
- package/src/index.ts +73 -1
- package/src/main.tsx +7 -5
- package/src/types/clusterLoadState.ts +33 -0
- package/src/types.ts +2 -0
- package/src/utils/auditBadges.ts +53 -0
- package/src/utils/navigation.ts +64 -1
- 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
|
+
}
|