@skyhook-io/radar-app 1.8.6 → 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 +221 -181
- 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.ts +262 -34
- 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 +327 -26
- 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 +17 -15
- package/src/components/home/MCPSetupDialog.tsx +1 -1
- package/src/components/home/mcpToolCatalog.ts +2 -2
- package/src/components/issues/IssuesPane.tsx +9 -1
- package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
- package/src/components/portforward/PortForwardButton.tsx +2 -2
- package/src/components/portforward/PortForwardManager.tsx +19 -14
- package/src/components/resource/PrometheusChartsGrid.tsx +6 -80
- package/src/components/resource/RightsizingStrip.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/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 })
|
|
@@ -113,33 +135,151 @@ export function ApplicationsView({ namespaces, onOpenResource }: ApplicationsVie
|
|
|
113
135
|
}
|
|
114
136
|
|
|
115
137
|
// AppDetailRoute wires the OSS data hooks the shared ApplicationDetail can't:
|
|
116
|
-
// the resources-view topology over the app's namespaces
|
|
117
|
-
//
|
|
118
|
-
// 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).
|
|
119
140
|
function AppDetailRoute({ app, apps, onBack, onOpenResource }: { app: AppRow; apps: AppRow[]; onBack: () => void; onOpenResource: (resource: SelectedResource) => void }) {
|
|
141
|
+
const navigate = useNavigate()
|
|
120
142
|
const appNamespaces = useMemo(
|
|
121
143
|
() => Array.from(new Set((app.workloads ?? []).map((w) => w.namespace).filter(Boolean))).sort(),
|
|
122
144
|
[app.workloads],
|
|
123
145
|
)
|
|
124
|
-
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
|
+
)
|
|
125
161
|
|
|
126
|
-
// The selected workload (?workload=<key>)
|
|
127
|
-
//
|
|
128
|
-
//
|
|
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.
|
|
129
166
|
const [searchParams, setSearchParams] = useSearchParams()
|
|
130
|
-
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
|
+
)
|
|
131
218
|
const selectWorkload = useCallback(
|
|
132
219
|
(key: string | null) => {
|
|
133
220
|
const params = new URLSearchParams(searchParams)
|
|
134
|
-
|
|
135
|
-
|
|
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')
|
|
136
244
|
params.delete('tab')
|
|
137
|
-
|
|
138
|
-
|
|
245
|
+
params.set('workload', workloadKey(workload))
|
|
246
|
+
params.set('run', `${kindToPlural(run.kind)}/${runNamespace}/${run.name}`)
|
|
139
247
|
setSearchParams(params)
|
|
140
248
|
},
|
|
141
249
|
[searchParams, setSearchParams],
|
|
142
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
|
+
)
|
|
143
283
|
|
|
144
284
|
// App identity switcher data: this instance's siblings (ladder-ordered
|
|
145
285
|
// digests). It switches between REAL instances — ?app= changes, deep links
|
|
@@ -175,6 +315,7 @@ function AppDetailRoute({ app, apps, onBack, onOpenResource }: { app: AppRow; ap
|
|
|
175
315
|
const target = apps.find((a) => a.key === targetKey);
|
|
176
316
|
const params = new URLSearchParams(searchParams);
|
|
177
317
|
params.set('app', targetKey);
|
|
318
|
+
params.delete('run');
|
|
178
319
|
const wk = params.get('workload');
|
|
179
320
|
let matched = false;
|
|
180
321
|
if (wk && target) {
|
|
@@ -189,12 +330,19 @@ function AppDetailRoute({ app, apps, onBack, onOpenResource }: { app: AppRow; ap
|
|
|
189
330
|
}
|
|
190
331
|
if (!matched && wk) {
|
|
191
332
|
// A workload WAS selected but has no counterpart — land on the target
|
|
192
|
-
// instance's
|
|
193
|
-
//
|
|
333
|
+
// instance's default scope and say so. Single-workload instances default
|
|
334
|
+
// to their workload; composed apps default to app overview.
|
|
194
335
|
params.delete('workload');
|
|
195
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
|
+
}
|
|
196
344
|
if (target) {
|
|
197
|
-
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' });
|
|
198
346
|
}
|
|
199
347
|
}
|
|
200
348
|
setSearchParams(params);
|
|
@@ -208,30 +356,47 @@ function AppDetailRoute({ app, apps, onBack, onOpenResource }: { app: AppRow; ap
|
|
|
208
356
|
);
|
|
209
357
|
|
|
210
358
|
return (
|
|
211
|
-
<div className="flex-1 overflow-
|
|
359
|
+
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
|
212
360
|
<ApplicationDetail
|
|
213
361
|
app={app}
|
|
214
362
|
onBack={onBack}
|
|
215
363
|
topology={topology}
|
|
216
364
|
topologyLoading={topologyLoading}
|
|
365
|
+
deploymentInventory={deploymentInventory}
|
|
217
366
|
identityInstances={identityInstances}
|
|
218
367
|
onSwitchInstance={switchInstance}
|
|
219
368
|
discoveredEnvs={discoveredEnvs}
|
|
220
369
|
onNavigateToResource={onOpenResource}
|
|
370
|
+
onSelectWorkloadRun={selectWorkloadRun}
|
|
371
|
+
history={historyQuery.data}
|
|
372
|
+
historyLoading={historyQuery.isLoading}
|
|
373
|
+
onOpenSource={openSource}
|
|
221
374
|
selectedWorkloadKey={selectedWorkloadKey}
|
|
222
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}
|
|
223
388
|
renderWorkload={(workload: SelectedAppWorkload) => (
|
|
224
389
|
<div className="h-full overflow-hidden">
|
|
225
390
|
<WorkloadView
|
|
226
391
|
kind={kindToPlural(workload.kind)}
|
|
392
|
+
group={workload.group}
|
|
227
393
|
namespace={workload.namespace}
|
|
228
394
|
name={workload.name}
|
|
229
395
|
onBack={() => selectWorkload(null)}
|
|
230
|
-
|
|
231
|
-
// single-workload app, which has no graph to return to.
|
|
232
|
-
hideBackButton={(app.workloads?.length ?? 0) <= 1}
|
|
396
|
+
hideBackButton
|
|
233
397
|
compactHeader
|
|
234
|
-
|
|
398
|
+
pushTabHistory
|
|
399
|
+
onNavigateToResource={openWorkloadResource}
|
|
235
400
|
/>
|
|
236
401
|
</div>
|
|
237
402
|
)}
|
|
@@ -239,3 +404,139 @@ function AppDetailRoute({ app, apps, onBack, onOpenResource }: { app: AppRow; ap
|
|
|
239
404
|
</div>
|
|
240
405
|
)
|
|
241
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
|
+
}
|