@skyhook-io/radar-app 1.8.5 → 1.8.7
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 +11 -9
- package/src/App.tsx +229 -183
- package/src/RadarApp.tsx +91 -20
- 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.ts +377 -48
- package/src/api/diagnose.ts +288 -0
- package/src/api/timelineSource.test.ts +217 -0
- package/src/api/timelineSource.ts +580 -0
- package/src/components/ConnectionErrorView.tsx +174 -70
- package/src/components/ContextSwitcher.tsx +13 -5
- package/src/components/applications/ApplicationsView.tsx +336 -32
- package/src/components/audit/AuditSettingsDialog.tsx +2 -2
- package/src/components/curl/ServiceCurlButton.tsx +2 -2
- package/src/components/diagnose/AISettings.tsx +121 -0
- package/src/components/diagnose/DiagnoseContext.tsx +491 -0
- package/src/components/diagnose/DiagnoseSurface.tsx +385 -0
- package/src/components/diagnose/Home.tsx +163 -0
- package/src/components/diagnose/InvestigationView.tsx +604 -0
- package/src/components/diagnose/LocalDiagnoseAction.tsx +150 -0
- package/src/components/diagnose/launch.ts +65 -0
- package/src/components/diagnose/parts.tsx +1689 -0
- package/src/components/dock/BottomDock.tsx +2 -3
- package/src/components/dock/WorkloadLogsTab.tsx +21 -5
- package/src/components/execution/BatchExecutionView.test.ts +170 -0
- package/src/components/execution/BatchExecutionView.tsx +1329 -0
- package/src/components/execution/batch-run-actions.test.ts +48 -0
- package/src/components/execution/batch-run-actions.ts +24 -0
- package/src/components/execution/batch-timeline.test.ts +57 -0
- package/src/components/execution/batch-timeline.ts +46 -0
- package/src/components/execution/execution-definition.test.ts +208 -0
- package/src/components/execution/execution-definition.ts +245 -0
- package/src/components/helm/ChartBrowser.tsx +2 -3
- package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +376 -43
- package/src/components/helm/HelmView.tsx +2 -3
- package/src/components/helm/InstallWizard.tsx +3 -5
- package/src/components/helm/TrackChartSourceDialog.tsx +48 -4
- package/src/components/helm/ValuesDiffPreview.tsx +15 -4
- package/src/components/home/HomeView.tsx +22 -18
- package/src/components/home/MCPSetupDialog.tsx +1 -1
- package/src/components/home/mcpToolCatalog.ts +2 -2
- package/src/components/issues/IssuesPane.tsx +9 -1
- package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
- package/src/components/portforward/PortForwardButton.tsx +2 -2
- package/src/components/portforward/PortForwardManager.tsx +19 -14
- package/src/components/resource/PrometheusChartsGrid.tsx +6 -80
- package/src/components/resource/RightsizingStrip.tsx +0 -3
- package/src/components/resources/ImageFilesystemModal.tsx +2 -2
- package/src/components/resources/PodFilesystemModal.tsx +2 -3
- package/src/components/resources/ResourceDetailDrawer.tsx +2 -0
- package/src/components/resources/ResourcesView.tsx +13 -4
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
- package/src/components/resources/renderers/PodRenderer.tsx +10 -4
- package/src/components/resources/renderers/index.ts +1 -0
- package/src/components/settings/SettingsDialog.tsx +80 -23
- package/src/components/shared/LargeClusterNamespacePicker.tsx +2 -2
- package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
- package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
- package/src/components/timeline/TimelineList.tsx +51 -10
- package/src/components/timeline/TimelineView.tsx +701 -15
- package/src/components/timeline/TimelineView.urlparams.test.ts +294 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +2 -2
- package/src/components/traffic/TrafficView.tsx +8 -4
- package/src/components/ui/CommandPalette.tsx +2 -2
- package/src/components/workload/WorkloadView.tsx +319 -35
- package/src/context/ConnectionContext.tsx +109 -11
- package/src/context/DiagnoseCustomization.tsx +42 -0
- package/src/context/TimelineSource.tsx +50 -0
- package/src/hooks/useClusterLoadState.ts +73 -0
- package/src/hooks/useEventSource.ts +6 -0
- package/src/index.css +157 -0
- package/src/index.ts +12 -0
- package/src/types/clusterLoadState.ts +33 -0
- package/src/utils/navigation.ts +10 -0
|
@@ -1,28 +1,48 @@
|
|
|
1
|
-
import { useCallback, useEffect, useMemo } from 'react'
|
|
2
|
-
import { useSearchParams } from 'react-router-dom'
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
2
|
+
import { useNavigate, useSearchParams } from 'react-router-dom'
|
|
3
3
|
import {
|
|
4
4
|
ApplicationsList,
|
|
5
5
|
ApplicationDetail,
|
|
6
6
|
CenteredEmpty,
|
|
7
7
|
PageHeader,
|
|
8
8
|
FreshnessControl,
|
|
9
|
+
IssueRow,
|
|
10
|
+
ISSUE_SEVERITY_RANK,
|
|
9
11
|
useToast,
|
|
10
12
|
orderEnvs,
|
|
11
13
|
matchWorkloadAcrossInstances,
|
|
12
14
|
workloadKey,
|
|
13
15
|
healthOf,
|
|
14
16
|
compareVersions,
|
|
17
|
+
gitOpsRouteForKind,
|
|
18
|
+
deploymentInventoryFromGitOps,
|
|
19
|
+
deploymentInventoryFromHelm,
|
|
20
|
+
memberRef,
|
|
21
|
+
subjectRef,
|
|
15
22
|
type AppRow,
|
|
23
|
+
type AppWorkload,
|
|
16
24
|
type AppIdentityInstance,
|
|
25
|
+
type ApplicationView,
|
|
26
|
+
type AppSourceRef,
|
|
27
|
+
type Issue,
|
|
28
|
+
type IssueResourceRef,
|
|
17
29
|
type SelectedAppWorkload,
|
|
18
30
|
type SelectedResource,
|
|
19
31
|
} from '@skyhook-io/k8s-ui'
|
|
20
|
-
import { Boxes } from 'lucide-react'
|
|
21
|
-
import {
|
|
32
|
+
import { AlertTriangle, Boxes } from 'lucide-react'
|
|
33
|
+
import { SEVERITY_TEXT } from '@skyhook-io/k8s-ui/utils/badge-colors'
|
|
34
|
+
import { useApplicationHistory, useApplications, useGitOpsTree, useHelmRelease, useIssues, useTopology, type IssuesResponse } from '../../api/client'
|
|
22
35
|
import { useConnection } from '../../context/ConnectionContext'
|
|
23
|
-
import { kindToPlural } from '../../utils/navigation'
|
|
36
|
+
import { buildWorkloadPath, kindToPlural } from '../../utils/navigation'
|
|
24
37
|
import { WorkloadView } from '../workload/WorkloadView'
|
|
25
38
|
|
|
39
|
+
const APPLICATION_VIEWS: ReadonlySet<ApplicationView> = new Set<ApplicationView>(['overview', 'topology', 'history'])
|
|
40
|
+
|
|
41
|
+
function parseApplicationView(value: string | null): ApplicationView {
|
|
42
|
+
if (!value || !APPLICATION_VIEWS.has(value as ApplicationView)) return 'overview'
|
|
43
|
+
return value as ApplicationView
|
|
44
|
+
}
|
|
45
|
+
|
|
26
46
|
interface ApplicationsViewProps {
|
|
27
47
|
namespaces: string[]
|
|
28
48
|
onOpenResource: (resource: SelectedResource) => void
|
|
@@ -44,7 +64,7 @@ export function ApplicationsView({ namespaces, onOpenResource }: ApplicationsVie
|
|
|
44
64
|
|
|
45
65
|
// Which app is open lives in the URL (?app=<key>) so the detail view is
|
|
46
66
|
// deep-linkable and the browser back button returns to the list. Opening or
|
|
47
|
-
// closing an app also clears the per-app params (workload, tab).
|
|
67
|
+
// closing an app also clears the per-app params (view, workload, tab).
|
|
48
68
|
const [searchParams, setSearchParams] = useSearchParams()
|
|
49
69
|
const selectedKey = searchParams.get('app')
|
|
50
70
|
const selected = useMemo(() => apps.find((a) => a.key === selectedKey) ?? null, [apps, selectedKey])
|
|
@@ -54,6 +74,7 @@ export function ApplicationsView({ namespaces, onOpenResource }: ApplicationsVie
|
|
|
54
74
|
const params = new URLSearchParams(searchParams)
|
|
55
75
|
if (key) params.set('app', key)
|
|
56
76
|
else params.delete('app')
|
|
77
|
+
params.delete('view')
|
|
57
78
|
params.delete('workload')
|
|
58
79
|
params.delete('tab')
|
|
59
80
|
setSearchParams(params)
|
|
@@ -68,6 +89,7 @@ export function ApplicationsView({ namespaces, onOpenResource }: ApplicationsVie
|
|
|
68
89
|
if (selectedKey && !selected && query.isSuccess) {
|
|
69
90
|
const params = new URLSearchParams(searchParams)
|
|
70
91
|
params.delete('app')
|
|
92
|
+
params.delete('view')
|
|
71
93
|
params.delete('workload')
|
|
72
94
|
params.delete('tab')
|
|
73
95
|
setSearchParams(params, { replace: true })
|
|
@@ -83,7 +105,14 @@ export function ApplicationsView({ namespaces, onOpenResource }: ApplicationsVie
|
|
|
83
105
|
// the page header from vanishing while loading / on error, the wrapper shows
|
|
84
106
|
// the same header bar above those states. (Keep title + description in sync
|
|
85
107
|
// with ApplicationsList's PageHeader.)
|
|
86
|
-
if (query.isLoading
|
|
108
|
+
if (query.isLoading) {
|
|
109
|
+
return (
|
|
110
|
+
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
|
111
|
+
<ApplicationsList apps={[]} onSelect={selectApp} headerActions={freshness} loading />
|
|
112
|
+
</div>
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
if (query.error) {
|
|
87
116
|
return (
|
|
88
117
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
|
89
118
|
<div className="shrink-0 border-b border-theme-border px-4 py-4">
|
|
@@ -93,11 +122,7 @@ export function ApplicationsView({ namespaces, onOpenResource }: ApplicationsVie
|
|
|
93
122
|
description="Deployable software in this cluster — your services, workers, and jobs, grouped by app/release evidence."
|
|
94
123
|
/>
|
|
95
124
|
</div>
|
|
96
|
-
{query.
|
|
97
|
-
<CenteredEmpty icon={Boxes} headline="Loading applications…" />
|
|
98
|
-
) : (
|
|
99
|
-
<CenteredEmpty tone="filtered" icon={Boxes} headline="Failed to load applications" body={(query.error as Error).message} />
|
|
100
|
-
)}
|
|
125
|
+
<CenteredEmpty tone="filtered" icon={Boxes} headline="Failed to load applications" body={(query.error as Error).message} />
|
|
101
126
|
</div>
|
|
102
127
|
)
|
|
103
128
|
}
|
|
@@ -110,33 +135,151 @@ export function ApplicationsView({ namespaces, onOpenResource }: ApplicationsVie
|
|
|
110
135
|
}
|
|
111
136
|
|
|
112
137
|
// AppDetailRoute wires the OSS data hooks the shared ApplicationDetail can't:
|
|
113
|
-
// the resources-view topology over the app's namespaces
|
|
114
|
-
//
|
|
115
|
-
// Topology tab). Split out so useTopology runs unconditionally (Rules of Hooks).
|
|
138
|
+
// the resources-view topology over the app's namespaces and the per-workload
|
|
139
|
+
// WorkloadView. Split out so useTopology runs unconditionally (Rules of Hooks).
|
|
116
140
|
function AppDetailRoute({ app, apps, onBack, onOpenResource }: { app: AppRow; apps: AppRow[]; onBack: () => void; onOpenResource: (resource: SelectedResource) => void }) {
|
|
141
|
+
const navigate = useNavigate()
|
|
117
142
|
const appNamespaces = useMemo(
|
|
118
143
|
() => Array.from(new Set((app.workloads ?? []).map((w) => w.namespace).filter(Boolean))).sort(),
|
|
119
144
|
[app.workloads],
|
|
120
145
|
)
|
|
121
|
-
const
|
|
146
|
+
const appHistoryNamespaces = useMemo(() => {
|
|
147
|
+
const namespaces = new Set(appNamespaces)
|
|
148
|
+
if (app.sourceRef?.namespace) namespaces.add(app.sourceRef.namespace)
|
|
149
|
+
return Array.from(namespaces).sort()
|
|
150
|
+
}, [app.sourceRef?.namespace, appNamespaces])
|
|
151
|
+
const { data: topology, isLoading: topologyLoading } = useTopology(appNamespaces, 'resources', {
|
|
152
|
+
enabled: appNamespaces.length > 0,
|
|
153
|
+
includeReplicaSets: true,
|
|
154
|
+
refetchInterval: 10_000,
|
|
155
|
+
})
|
|
156
|
+
const issuesQuery = useIssues(appNamespaces)
|
|
157
|
+
const appIssues = useMemo(
|
|
158
|
+
() => appIssuesForWorkloads(issuesQuery.data?.issues ?? [], app.workloads ?? []),
|
|
159
|
+
[issuesQuery.data?.issues, app.workloads],
|
|
160
|
+
)
|
|
122
161
|
|
|
123
|
-
// The selected workload (?workload=<key>)
|
|
124
|
-
//
|
|
125
|
-
//
|
|
162
|
+
// The selected workload (?workload=<key>) is the scope switch and wins over
|
|
163
|
+
// ?view= when both are present. With neither param, use the product default:
|
|
164
|
+
// multi-workload apps open on app overview, single-workload apps open on the
|
|
165
|
+
// workload. A single-workload app does not expose app scope.
|
|
126
166
|
const [searchParams, setSearchParams] = useSearchParams()
|
|
127
|
-
const
|
|
167
|
+
const viewParam = searchParams.get('view')
|
|
168
|
+
const selectedView = parseApplicationView(viewParam)
|
|
169
|
+
const selectedWorkloadParam = searchParams.get('workload')
|
|
170
|
+
const appWorkloads = app.workloads ?? []
|
|
171
|
+
const singleWorkloadKey = appWorkloads.length === 1 ? workloadKey(appWorkloads[0]) : null
|
|
172
|
+
const selectedWorkloadKey = singleWorkloadKey ?? selectedWorkloadParam
|
|
173
|
+
const historyQuery = useApplicationHistory(app.key, appHistoryNamespaces, { enabled: !selectedWorkloadKey })
|
|
174
|
+
const sourceInventoryEnabled = !selectedWorkloadKey && selectedView === 'topology'
|
|
175
|
+
const gitOpsSource = app.sourceRef?.type === 'gitops' ? app.sourceRef : undefined
|
|
176
|
+
const deploymentTreeQuery = useGitOpsTree(
|
|
177
|
+
gitOpsSource?.kind ?? '',
|
|
178
|
+
gitOpsSource?.namespace ?? '',
|
|
179
|
+
gitOpsSource?.name ?? '',
|
|
180
|
+
gitOpsSource?.group,
|
|
181
|
+
appHistoryNamespaces,
|
|
182
|
+
{ enabled: sourceInventoryEnabled },
|
|
183
|
+
)
|
|
184
|
+
const helmSource = app.sourceRef?.type === 'helm' ? app.sourceRef : undefined
|
|
185
|
+
const helmReleaseQuery = useHelmRelease(helmSource?.namespace ?? '', helmSource?.name ?? '', { enabled: sourceInventoryEnabled })
|
|
186
|
+
const deploymentInventory = useMemo(
|
|
187
|
+
() => deploymentInventoryFromGitOps(deploymentTreeQuery.data) ?? deploymentInventoryFromHelm(helmReleaseQuery.data?.resources),
|
|
188
|
+
[deploymentTreeQuery.data, helmReleaseQuery.data?.resources],
|
|
189
|
+
)
|
|
190
|
+
useEffect(() => {
|
|
191
|
+
if (!singleWorkloadKey) return
|
|
192
|
+
if (selectedWorkloadParam === singleWorkloadKey && !viewParam) return
|
|
193
|
+
const params = new URLSearchParams(searchParams)
|
|
194
|
+
params.delete('view')
|
|
195
|
+
params.delete('run')
|
|
196
|
+
params.set('workload', singleWorkloadKey)
|
|
197
|
+
setSearchParams(params, { replace: true })
|
|
198
|
+
}, [searchParams, selectedWorkloadParam, setSearchParams, singleWorkloadKey, viewParam])
|
|
199
|
+
const selectView = useCallback(
|
|
200
|
+
(view: ApplicationView) => {
|
|
201
|
+
const params = new URLSearchParams(searchParams)
|
|
202
|
+
params.delete('tab')
|
|
203
|
+
params.delete('run')
|
|
204
|
+
if (singleWorkloadKey) {
|
|
205
|
+
params.delete('view')
|
|
206
|
+
params.set('workload', singleWorkloadKey)
|
|
207
|
+
} else if (view === 'overview') {
|
|
208
|
+
params.delete('view')
|
|
209
|
+
params.delete('workload')
|
|
210
|
+
} else {
|
|
211
|
+
params.set('view', view)
|
|
212
|
+
params.delete('workload')
|
|
213
|
+
}
|
|
214
|
+
setSearchParams(params)
|
|
215
|
+
},
|
|
216
|
+
[searchParams, setSearchParams, singleWorkloadKey],
|
|
217
|
+
)
|
|
128
218
|
const selectWorkload = useCallback(
|
|
129
219
|
(key: string | null) => {
|
|
130
220
|
const params = new URLSearchParams(searchParams)
|
|
131
|
-
|
|
132
|
-
|
|
221
|
+
params.delete('run')
|
|
222
|
+
if (key) {
|
|
223
|
+
const wasInWorkloadScope = !!selectedWorkloadKey
|
|
224
|
+
params.delete('view')
|
|
225
|
+
params.set('workload', key)
|
|
226
|
+
if (!wasInWorkloadScope) params.delete('tab')
|
|
227
|
+
} else if (singleWorkloadKey) {
|
|
228
|
+
params.delete('view')
|
|
229
|
+
params.set('workload', singleWorkloadKey)
|
|
230
|
+
} else {
|
|
231
|
+
params.delete('workload')
|
|
232
|
+
params.delete('tab')
|
|
233
|
+
params.delete('view')
|
|
234
|
+
}
|
|
235
|
+
setSearchParams(params)
|
|
236
|
+
},
|
|
237
|
+
[searchParams, selectedWorkloadKey, setSearchParams, singleWorkloadKey],
|
|
238
|
+
)
|
|
239
|
+
const selectWorkloadRun = useCallback(
|
|
240
|
+
(workload: SelectedAppWorkload, run: { kind: string; name: string; data: Record<string, unknown> }) => {
|
|
241
|
+
const params = new URLSearchParams(searchParams)
|
|
242
|
+
const runNamespace = typeof run.data?.namespace === 'string' ? run.data.namespace : workload.namespace
|
|
243
|
+
params.delete('view')
|
|
133
244
|
params.delete('tab')
|
|
134
|
-
|
|
135
|
-
|
|
245
|
+
params.set('workload', workloadKey(workload))
|
|
246
|
+
params.set('run', `${kindToPlural(run.kind)}/${runNamespace}/${run.name}`)
|
|
136
247
|
setSearchParams(params)
|
|
137
248
|
},
|
|
138
249
|
[searchParams, setSearchParams],
|
|
139
250
|
)
|
|
251
|
+
const openWorkloadResource = useCallback(
|
|
252
|
+
(resource: SelectedResource) => {
|
|
253
|
+
if (kindToPlural(resource.kind).toLowerCase() !== 'pods') {
|
|
254
|
+
onOpenResource(resource)
|
|
255
|
+
return
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const [pathname, rawSearch = ''] = buildWorkloadPath({ ...resource, kind: kindToPlural(resource.kind) }).split('?')
|
|
259
|
+
const params = new URLSearchParams(rawSearch)
|
|
260
|
+
const activeNamespaces = searchParams.get('namespaces')
|
|
261
|
+
if (activeNamespaces) params.set('namespaces', activeNamespaces)
|
|
262
|
+
navigate({ pathname, search: params.toString() })
|
|
263
|
+
},
|
|
264
|
+
[navigate, onOpenResource, searchParams],
|
|
265
|
+
)
|
|
266
|
+
const openSource = useCallback(
|
|
267
|
+
(source: AppSourceRef) => {
|
|
268
|
+
if (source.type === 'gitops') {
|
|
269
|
+
const path = gitOpsRouteForKind(source.kind, source.namespace, source.name)
|
|
270
|
+
if (path) navigate(path)
|
|
271
|
+
return
|
|
272
|
+
}
|
|
273
|
+
if (source.type === 'helm') {
|
|
274
|
+
const params = new URLSearchParams()
|
|
275
|
+
const activeNamespaces = searchParams.get('namespaces')
|
|
276
|
+
if (activeNamespaces) params.set('namespaces', activeNamespaces)
|
|
277
|
+
params.set('release', `${source.namespace}/${source.name}`)
|
|
278
|
+
navigate({ pathname: '/helm', search: params.toString() })
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
[navigate, searchParams],
|
|
282
|
+
)
|
|
140
283
|
|
|
141
284
|
// App identity switcher data: this instance's siblings (ladder-ordered
|
|
142
285
|
// digests). It switches between REAL instances — ?app= changes, deep links
|
|
@@ -172,6 +315,7 @@ function AppDetailRoute({ app, apps, onBack, onOpenResource }: { app: AppRow; ap
|
|
|
172
315
|
const target = apps.find((a) => a.key === targetKey);
|
|
173
316
|
const params = new URLSearchParams(searchParams);
|
|
174
317
|
params.set('app', targetKey);
|
|
318
|
+
params.delete('run');
|
|
175
319
|
const wk = params.get('workload');
|
|
176
320
|
let matched = false;
|
|
177
321
|
if (wk && target) {
|
|
@@ -186,12 +330,19 @@ function AppDetailRoute({ app, apps, onBack, onOpenResource }: { app: AppRow; ap
|
|
|
186
330
|
}
|
|
187
331
|
if (!matched && wk) {
|
|
188
332
|
// A workload WAS selected but has no counterpart — land on the target
|
|
189
|
-
// instance's
|
|
190
|
-
//
|
|
333
|
+
// instance's default scope and say so. Single-workload instances default
|
|
334
|
+
// to their workload; composed apps default to app overview.
|
|
191
335
|
params.delete('workload');
|
|
192
336
|
params.delete('tab');
|
|
337
|
+
const soleTargetWorkload = target?.workloads?.length === 1 ? target.workloads[0] : null;
|
|
338
|
+
if (soleTargetWorkload) {
|
|
339
|
+
params.delete('view');
|
|
340
|
+
params.set('workload', workloadKey(soleTargetWorkload));
|
|
341
|
+
} else {
|
|
342
|
+
params.delete('view');
|
|
343
|
+
}
|
|
193
344
|
if (target) {
|
|
194
|
-
showToast(`No matching workload in ${target.identity?.env ?? target.name}`, { detail: 'Showing the instance overview instead.', type: 'info' });
|
|
345
|
+
showToast(`No matching workload in ${target.identity?.env ?? target.name}`, { detail: soleTargetWorkload ? 'Showing the instance workload instead.' : 'Showing the instance overview instead.', type: 'info' });
|
|
195
346
|
}
|
|
196
347
|
}
|
|
197
348
|
setSearchParams(params);
|
|
@@ -205,30 +356,47 @@ function AppDetailRoute({ app, apps, onBack, onOpenResource }: { app: AppRow; ap
|
|
|
205
356
|
);
|
|
206
357
|
|
|
207
358
|
return (
|
|
208
|
-
<div className="flex-1 overflow-
|
|
359
|
+
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
|
209
360
|
<ApplicationDetail
|
|
210
361
|
app={app}
|
|
211
362
|
onBack={onBack}
|
|
212
363
|
topology={topology}
|
|
213
364
|
topologyLoading={topologyLoading}
|
|
365
|
+
deploymentInventory={deploymentInventory}
|
|
214
366
|
identityInstances={identityInstances}
|
|
215
367
|
onSwitchInstance={switchInstance}
|
|
216
368
|
discoveredEnvs={discoveredEnvs}
|
|
217
369
|
onNavigateToResource={onOpenResource}
|
|
370
|
+
onSelectWorkloadRun={selectWorkloadRun}
|
|
371
|
+
history={historyQuery.data}
|
|
372
|
+
historyLoading={historyQuery.isLoading}
|
|
373
|
+
onOpenSource={openSource}
|
|
218
374
|
selectedWorkloadKey={selectedWorkloadKey}
|
|
219
375
|
onSelectWorkload={selectWorkload}
|
|
376
|
+
selectedView={selectedView}
|
|
377
|
+
onSelectView={selectView}
|
|
378
|
+
renderOverviewIssues={() => (
|
|
379
|
+
<AppOverviewIssues
|
|
380
|
+
issues={appIssues}
|
|
381
|
+
error={issuesQuery.error}
|
|
382
|
+
hasData={Boolean(issuesQuery.data)}
|
|
383
|
+
visibility={issuesQuery.data?.visibility}
|
|
384
|
+
onOpenResource={onOpenResource}
|
|
385
|
+
/>
|
|
386
|
+
)}
|
|
387
|
+
hasOverviewIssues={appIssues.length > 0}
|
|
220
388
|
renderWorkload={(workload: SelectedAppWorkload) => (
|
|
221
389
|
<div className="h-full overflow-hidden">
|
|
222
390
|
<WorkloadView
|
|
223
391
|
kind={kindToPlural(workload.kind)}
|
|
392
|
+
group={workload.group}
|
|
224
393
|
namespace={workload.namespace}
|
|
225
394
|
name={workload.name}
|
|
226
395
|
onBack={() => selectWorkload(null)}
|
|
227
|
-
|
|
228
|
-
// single-workload app, which has no graph to return to.
|
|
229
|
-
hideBackButton={(app.workloads?.length ?? 0) <= 1}
|
|
396
|
+
hideBackButton
|
|
230
397
|
compactHeader
|
|
231
|
-
|
|
398
|
+
pushTabHistory
|
|
399
|
+
onNavigateToResource={openWorkloadResource}
|
|
232
400
|
/>
|
|
233
401
|
</div>
|
|
234
402
|
)}
|
|
@@ -236,3 +404,139 @@ function AppDetailRoute({ app, apps, onBack, onOpenResource }: { app: AppRow; ap
|
|
|
236
404
|
</div>
|
|
237
405
|
)
|
|
238
406
|
}
|
|
407
|
+
|
|
408
|
+
function AppOverviewIssues({ issues, error, hasData, visibility, onOpenResource }: { issues: Issue[]; error: unknown; hasData: boolean; visibility?: IssuesResponse['visibility']; onOpenResource: (resource: SelectedResource) => void }) {
|
|
409
|
+
const [openId, setOpenId] = useState<string | null>(null)
|
|
410
|
+
const sorted = useMemo(() => [...issues].sort(compareAppOverviewIssues), [issues])
|
|
411
|
+
|
|
412
|
+
if (error && !hasData) {
|
|
413
|
+
return <AppOverviewIssuesState headline="Operational issues unavailable" body={error instanceof Error ? error.message : 'Radar could not load issues for this application.'} />
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (error) {
|
|
417
|
+
return (
|
|
418
|
+
<section className="space-y-2">
|
|
419
|
+
<AppOverviewIssuesState headline="Operational issue refresh failed" body="Showing the last successful result for this application." />
|
|
420
|
+
{sorted.length > 0 && <AppOverviewIssueRows issues={sorted} openId={openId} setOpenId={setOpenId} onOpenResource={onOpenResource} />}
|
|
421
|
+
</section>
|
|
422
|
+
)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (visibility?.impact) {
|
|
426
|
+
return (
|
|
427
|
+
<section className="space-y-2">
|
|
428
|
+
<AppOverviewIssuesState headline={sorted.length === 0 ? 'No visible operational issues' : 'Operational issue visibility is limited'} body={`${visibility.impact} Results may be incomplete.`} />
|
|
429
|
+
{sorted.length > 0 && <AppOverviewIssueRows issues={sorted} openId={openId} setOpenId={setOpenId} onOpenResource={onOpenResource} />}
|
|
430
|
+
</section>
|
|
431
|
+
)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (sorted.length === 0) return null
|
|
435
|
+
|
|
436
|
+
return <AppOverviewIssueRows issues={sorted} openId={openId} setOpenId={setOpenId} onOpenResource={onOpenResource} />
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function AppOverviewIssuesState({ headline, body }: { headline: string; body: string }) {
|
|
440
|
+
return (
|
|
441
|
+
<section className="rounded-lg border border-theme-border bg-theme-surface px-4 py-3 shadow-theme-sm">
|
|
442
|
+
<div className="flex items-start gap-3">
|
|
443
|
+
<AlertTriangle className={`mt-0.5 h-4 w-4 shrink-0 ${SEVERITY_TEXT.warning}`} aria-hidden />
|
|
444
|
+
<div className="min-w-0">
|
|
445
|
+
<h2 className="text-sm font-semibold text-theme-text-primary">{headline}</h2>
|
|
446
|
+
<p className="mt-0.5 text-sm text-theme-text-secondary">{body}</p>
|
|
447
|
+
</div>
|
|
448
|
+
</div>
|
|
449
|
+
</section>
|
|
450
|
+
)
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function AppOverviewIssueRows({ issues: sorted, openId, setOpenId, onOpenResource }: { issues: Issue[]; openId: string | null; setOpenId: (value: string | null) => void; onOpenResource: (resource: SelectedResource) => void }) {
|
|
454
|
+
|
|
455
|
+
const navigate = (ref: IssueResourceRef) => {
|
|
456
|
+
onOpenResource({
|
|
457
|
+
kind: kindToPlural(ref.kind),
|
|
458
|
+
namespace: ref.namespace ?? '',
|
|
459
|
+
name: ref.name,
|
|
460
|
+
group: ref.group ?? '',
|
|
461
|
+
})
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return (
|
|
465
|
+
<section className="space-y-2">
|
|
466
|
+
<div className="flex items-center justify-between gap-3">
|
|
467
|
+
<div className="flex min-w-0 items-center gap-2 text-sm font-semibold text-theme-text-primary">
|
|
468
|
+
<AlertTriangle className="h-4 w-4 shrink-0 text-theme-text-secondary" aria-hidden />
|
|
469
|
+
<span>Operational Issues</span>
|
|
470
|
+
<span className="badge-sm text-[10px] text-theme-text-secondary">{sorted.length}</span>
|
|
471
|
+
</div>
|
|
472
|
+
<span className="text-xs text-theme-text-tertiary">Scoped to this application</span>
|
|
473
|
+
</div>
|
|
474
|
+
<ol className="flex flex-col gap-1.5">
|
|
475
|
+
{sorted.slice(0, 4).map((issue) => {
|
|
476
|
+
const rowKey = `${issue.cluster_id ?? ''}:${issue.id}`
|
|
477
|
+
return (
|
|
478
|
+
<IssueRow
|
|
479
|
+
key={rowKey}
|
|
480
|
+
issue={issue}
|
|
481
|
+
open={openId === rowKey}
|
|
482
|
+
onToggle={() => setOpenId(openId === rowKey ? null : rowKey)}
|
|
483
|
+
onResourceClick={navigate}
|
|
484
|
+
/>
|
|
485
|
+
)
|
|
486
|
+
})}
|
|
487
|
+
</ol>
|
|
488
|
+
{sorted.length > 4 ? (
|
|
489
|
+
<div className="text-xs text-theme-text-tertiary">
|
|
490
|
+
Showing 4 of {sorted.length} issues. Open Issues for the full queue.
|
|
491
|
+
</div>
|
|
492
|
+
) : null}
|
|
493
|
+
</section>
|
|
494
|
+
)
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function compareAppOverviewIssues(a: Issue, b: Issue): number {
|
|
498
|
+
const severity = ISSUE_SEVERITY_RANK[b.severity] - ISSUE_SEVERITY_RANK[a.severity]
|
|
499
|
+
if (severity !== 0) return severity
|
|
500
|
+
const fa = a.first_seen ?? ''
|
|
501
|
+
const fb = b.first_seen ?? ''
|
|
502
|
+
if (fa !== fb) return fb.localeCompare(fa)
|
|
503
|
+
const ns = (a.namespace ?? '').localeCompare(b.namespace ?? '')
|
|
504
|
+
if (ns !== 0) return ns
|
|
505
|
+
const name = a.name.localeCompare(b.name)
|
|
506
|
+
if (name !== 0) return name
|
|
507
|
+
return a.id.localeCompare(b.id)
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function appIssuesForWorkloads(issues: Issue[], workloads: AppWorkload[]): Issue[] {
|
|
511
|
+
if (issues.length === 0 || workloads.length === 0) return []
|
|
512
|
+
const workloadKeys = new Set(workloads.map(workloadIssueKey))
|
|
513
|
+
const out: Issue[] = []
|
|
514
|
+
const seen = new Set<string>()
|
|
515
|
+
for (const issue of issues) {
|
|
516
|
+
if (!issueRefs(issue).some((ref) => workloadKeys.has(issueRefKey(ref)))) continue
|
|
517
|
+
if (seen.has(issue.id)) continue
|
|
518
|
+
seen.add(issue.id)
|
|
519
|
+
out.push(issue)
|
|
520
|
+
}
|
|
521
|
+
return out
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function workloadIssueKey(workload: AppWorkload): string {
|
|
525
|
+
return `${workload.kind.toLowerCase()}|${workload.namespace}|${workload.name}`
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function issueRefKey(ref: IssueResourceRef): string {
|
|
529
|
+
return `${ref.kind.toLowerCase()}|${ref.namespace ?? ''}|${ref.name}`
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
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))
|
|
537
|
+
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)
|
|
540
|
+
}
|
|
541
|
+
return refs
|
|
542
|
+
}
|
|
@@ -5,6 +5,7 @@ import { useAuditSettings, useUpdateAuditSettings, useAudit, useCloudRole } from
|
|
|
5
5
|
import type { CheckMeta } from '@skyhook-io/k8s-ui'
|
|
6
6
|
import { validateRFC1123Label, type ValidationResult } from '@skyhook-io/k8s-ui/utils/validators'
|
|
7
7
|
import { Tooltip } from '../ui/Tooltip'
|
|
8
|
+
import { Input } from '@skyhook-io/k8s-ui'
|
|
8
9
|
|
|
9
10
|
interface AuditSettingsDialogProps {
|
|
10
11
|
namespaces: string[]
|
|
@@ -118,8 +119,7 @@ export function AuditSettingsDialog({ namespaces, onClose }: AuditSettingsDialog
|
|
|
118
119
|
</div>
|
|
119
120
|
|
|
120
121
|
<div className="flex gap-2">
|
|
121
|
-
<
|
|
122
|
-
type="text"
|
|
122
|
+
<Input
|
|
123
123
|
value={newNs}
|
|
124
124
|
onChange={e => setNewNs(e.target.value)}
|
|
125
125
|
onKeyDown={e => { if (e.key === 'Enter') addNamespace() }}
|
|
@@ -6,6 +6,7 @@ import { clsx } from 'clsx'
|
|
|
6
6
|
import { apiFetch } from '../../api/client'
|
|
7
7
|
import { apiUrl } from '../../api/config'
|
|
8
8
|
import { Tooltip } from '../ui/Tooltip'
|
|
9
|
+
import { Input } from '@skyhook-io/k8s-ui'
|
|
9
10
|
|
|
10
11
|
// A port is "curl-able" only if it plausibly speaks HTTP — probing a raw TCP
|
|
11
12
|
// port (Postgres, Redis) with a GET returns noise, so we don't offer it there
|
|
@@ -345,8 +346,7 @@ export function CurlPanel({
|
|
|
345
346
|
</select>
|
|
346
347
|
<div className="flex-1 min-w-0 flex items-center bg-theme-base border border-theme-border rounded px-2 focus-within:border-blue-500">
|
|
347
348
|
<span className="text-xs text-theme-text-tertiary font-mono select-none pointer-events-none">/</span>
|
|
348
|
-
<
|
|
349
|
-
type="text"
|
|
349
|
+
<Input
|
|
350
350
|
value={path}
|
|
351
351
|
onChange={(e) => setPath(e.target.value.replace(/^\/+/, ''))}
|
|
352
352
|
placeholder="healthz"
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// The "AI Diagnosis" section of the Settings dialog. Controlled by the dialog:
|
|
2
|
+
// it edits a STAGED draft and is committed on Save (like the rest of Settings),
|
|
3
|
+
// not on every keystroke. Renders nothing when no supported agent CLI is installed.
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
import { Trash2 } from "lucide-react";
|
|
6
|
+
import { clearHistory, type AgentInfo } from "../../api/diagnose";
|
|
7
|
+
import { AgentControls } from "./parts";
|
|
8
|
+
|
|
9
|
+
export interface AIDraft {
|
|
10
|
+
agent: string;
|
|
11
|
+
isolated: boolean;
|
|
12
|
+
model: string;
|
|
13
|
+
effort: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// ClearHistoryRow is an immediate action (not part of the staged draft): a
|
|
17
|
+
// two-step confirm button that wipes finished investigations from the local
|
|
18
|
+
// history DB. Live investigations survive.
|
|
19
|
+
function ClearHistoryRow({ onCleared }: { onCleared: () => void }) {
|
|
20
|
+
const [confirming, setConfirming] = useState(false);
|
|
21
|
+
const [state, setState] = useState<"idle" | "busy" | "done" | "error">(
|
|
22
|
+
"idle",
|
|
23
|
+
);
|
|
24
|
+
const run = () => {
|
|
25
|
+
setState("busy");
|
|
26
|
+
clearHistory()
|
|
27
|
+
.then(() => {
|
|
28
|
+
setState("done");
|
|
29
|
+
setConfirming(false);
|
|
30
|
+
onCleared();
|
|
31
|
+
})
|
|
32
|
+
.catch(() => setState("error"));
|
|
33
|
+
};
|
|
34
|
+
return (
|
|
35
|
+
<div className="mt-3 flex items-center justify-between gap-2 border-t border-theme-border/60 pt-3">
|
|
36
|
+
<p className="text-[11px] leading-snug text-theme-text-tertiary">
|
|
37
|
+
Investigation transcripts are kept on this machine (
|
|
38
|
+
<code className="font-mono">~/.radar</code>) so history survives
|
|
39
|
+
restarts.
|
|
40
|
+
{state === "done" && (
|
|
41
|
+
<span className="ml-1 font-medium text-theme-text-secondary">
|
|
42
|
+
History cleared.
|
|
43
|
+
</span>
|
|
44
|
+
)}
|
|
45
|
+
{state === "error" && (
|
|
46
|
+
<span className="ml-1 font-medium text-red-400">
|
|
47
|
+
Couldn't clear history.
|
|
48
|
+
</span>
|
|
49
|
+
)}
|
|
50
|
+
</p>
|
|
51
|
+
{confirming ? (
|
|
52
|
+
<div className="flex shrink-0 items-center gap-1.5">
|
|
53
|
+
<button
|
|
54
|
+
onClick={() => setConfirming(false)}
|
|
55
|
+
className="rounded-md border border-theme-border px-2 py-1 text-xs text-theme-text-secondary hover:bg-theme-hover"
|
|
56
|
+
>
|
|
57
|
+
Cancel
|
|
58
|
+
</button>
|
|
59
|
+
<button
|
|
60
|
+
onClick={run}
|
|
61
|
+
disabled={state === "busy"}
|
|
62
|
+
className="rounded-md border border-red-500/40 bg-red-500/10 px-2 py-1 text-xs font-medium text-red-400 hover:bg-red-500/20 disabled:opacity-50"
|
|
63
|
+
>
|
|
64
|
+
Clear all history
|
|
65
|
+
</button>
|
|
66
|
+
</div>
|
|
67
|
+
) : (
|
|
68
|
+
<button
|
|
69
|
+
onClick={() => {
|
|
70
|
+
setState("idle");
|
|
71
|
+
setConfirming(true);
|
|
72
|
+
}}
|
|
73
|
+
className="flex shrink-0 items-center gap-1 rounded-md border border-theme-border px-2 py-1 text-xs text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary"
|
|
74
|
+
>
|
|
75
|
+
<Trash2 className="h-3 w-3" />
|
|
76
|
+
Clear history…
|
|
77
|
+
</button>
|
|
78
|
+
)}
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function AISettingsSection({
|
|
84
|
+
available,
|
|
85
|
+
agents,
|
|
86
|
+
draft,
|
|
87
|
+
onChange,
|
|
88
|
+
onHistoryCleared,
|
|
89
|
+
}: {
|
|
90
|
+
available: boolean;
|
|
91
|
+
agents: AgentInfo[];
|
|
92
|
+
draft: AIDraft;
|
|
93
|
+
onChange: (patch: Partial<AIDraft>) => void;
|
|
94
|
+
onHistoryCleared: () => void;
|
|
95
|
+
}) {
|
|
96
|
+
if (!available || agents.length === 0) return null;
|
|
97
|
+
return (
|
|
98
|
+
<section className="mb-5 rounded-md border border-theme-border bg-theme-elevated/50 p-3">
|
|
99
|
+
<h3 className="mb-1 text-sm font-medium text-theme-text-primary">
|
|
100
|
+
AI Diagnosis
|
|
101
|
+
</h3>
|
|
102
|
+
<p className="mb-3 text-xs text-theme-text-tertiary">
|
|
103
|
+
Investigations run on your own machine via your installed agent CLI — no
|
|
104
|
+
Radar cloud, no API key. These preferences apply to new investigations.
|
|
105
|
+
</p>
|
|
106
|
+
<AgentControls
|
|
107
|
+
agents={agents}
|
|
108
|
+
selectedAgent={draft.agent}
|
|
109
|
+
// Model + effort are agent-specific; reset them when the agent changes.
|
|
110
|
+
onSelectAgent={(a) => onChange({ agent: a, model: "", effort: "" })}
|
|
111
|
+
isolated={draft.isolated}
|
|
112
|
+
onSetIsolated={(v) => onChange({ isolated: v })}
|
|
113
|
+
model={draft.model}
|
|
114
|
+
onSetModel={(v) => onChange({ model: v })}
|
|
115
|
+
effort={draft.effort}
|
|
116
|
+
onSetEffort={(v) => onChange({ effort: v })}
|
|
117
|
+
/>
|
|
118
|
+
<ClearHistoryRow onCleared={onHistoryCleared} />
|
|
119
|
+
</section>
|
|
120
|
+
);
|
|
121
|
+
}
|