@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.
Files changed (78) hide show
  1. package/README.md +7 -1
  2. package/package.json +11 -9
  3. package/src/App.tsx +229 -183
  4. package/src/RadarApp.tsx +91 -20
  5. package/src/api/client.delta.test.ts +89 -0
  6. package/src/api/client.deltaSync.test.ts +216 -0
  7. package/src/api/client.metrics.test.ts +106 -0
  8. package/src/api/client.ts +377 -48
  9. package/src/api/diagnose.ts +288 -0
  10. package/src/api/timelineSource.test.ts +217 -0
  11. package/src/api/timelineSource.ts +580 -0
  12. package/src/components/ConnectionErrorView.tsx +174 -70
  13. package/src/components/ContextSwitcher.tsx +13 -5
  14. package/src/components/applications/ApplicationsView.tsx +336 -32
  15. package/src/components/audit/AuditSettingsDialog.tsx +2 -2
  16. package/src/components/curl/ServiceCurlButton.tsx +2 -2
  17. package/src/components/diagnose/AISettings.tsx +121 -0
  18. package/src/components/diagnose/DiagnoseContext.tsx +491 -0
  19. package/src/components/diagnose/DiagnoseSurface.tsx +385 -0
  20. package/src/components/diagnose/Home.tsx +163 -0
  21. package/src/components/diagnose/InvestigationView.tsx +604 -0
  22. package/src/components/diagnose/LocalDiagnoseAction.tsx +150 -0
  23. package/src/components/diagnose/launch.ts +65 -0
  24. package/src/components/diagnose/parts.tsx +1689 -0
  25. package/src/components/dock/BottomDock.tsx +2 -3
  26. package/src/components/dock/WorkloadLogsTab.tsx +21 -5
  27. package/src/components/execution/BatchExecutionView.test.ts +170 -0
  28. package/src/components/execution/BatchExecutionView.tsx +1329 -0
  29. package/src/components/execution/batch-run-actions.test.ts +48 -0
  30. package/src/components/execution/batch-run-actions.ts +24 -0
  31. package/src/components/execution/batch-timeline.test.ts +57 -0
  32. package/src/components/execution/batch-timeline.ts +46 -0
  33. package/src/components/execution/execution-definition.test.ts +208 -0
  34. package/src/components/execution/execution-definition.ts +245 -0
  35. package/src/components/helm/ChartBrowser.tsx +2 -3
  36. package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
  37. package/src/components/helm/HelmReleaseDrawer.tsx +376 -43
  38. package/src/components/helm/HelmView.tsx +2 -3
  39. package/src/components/helm/InstallWizard.tsx +3 -5
  40. package/src/components/helm/TrackChartSourceDialog.tsx +48 -4
  41. package/src/components/helm/ValuesDiffPreview.tsx +15 -4
  42. package/src/components/home/HomeView.tsx +22 -18
  43. package/src/components/home/MCPSetupDialog.tsx +1 -1
  44. package/src/components/home/mcpToolCatalog.ts +2 -2
  45. package/src/components/issues/IssuesPane.tsx +9 -1
  46. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
  47. package/src/components/portforward/PortForwardButton.tsx +2 -2
  48. package/src/components/portforward/PortForwardManager.tsx +19 -14
  49. package/src/components/resource/PrometheusChartsGrid.tsx +6 -80
  50. package/src/components/resource/RightsizingStrip.tsx +0 -3
  51. package/src/components/resources/ImageFilesystemModal.tsx +2 -2
  52. package/src/components/resources/PodFilesystemModal.tsx +2 -3
  53. package/src/components/resources/ResourceDetailDrawer.tsx +2 -0
  54. package/src/components/resources/ResourcesView.tsx +13 -4
  55. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
  56. package/src/components/resources/renderers/NodeRenderer.tsx +10 -4
  57. package/src/components/resources/renderers/PodRenderer.tsx +10 -4
  58. package/src/components/resources/renderers/index.ts +1 -0
  59. package/src/components/settings/SettingsDialog.tsx +80 -23
  60. package/src/components/shared/LargeClusterNamespacePicker.tsx +2 -2
  61. package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
  62. package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
  63. package/src/components/timeline/TimelineList.tsx +51 -10
  64. package/src/components/timeline/TimelineView.tsx +701 -15
  65. package/src/components/timeline/TimelineView.urlparams.test.ts +294 -0
  66. package/src/components/traffic/TrafficFilterSidebar.tsx +2 -2
  67. package/src/components/traffic/TrafficView.tsx +8 -4
  68. package/src/components/ui/CommandPalette.tsx +2 -2
  69. package/src/components/workload/WorkloadView.tsx +319 -35
  70. package/src/context/ConnectionContext.tsx +109 -11
  71. package/src/context/DiagnoseCustomization.tsx +42 -0
  72. package/src/context/TimelineSource.tsx +50 -0
  73. package/src/hooks/useClusterLoadState.ts +73 -0
  74. package/src/hooks/useEventSource.ts +6 -0
  75. package/src/index.css +157 -0
  76. package/src/index.ts +12 -0
  77. package/src/types/clusterLoadState.ts +33 -0
  78. 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 { useApplications, useTopology } from '../../api/client'
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 || query.error) {
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.isLoading ? (
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 (for the app graph)
114
- // and the per-workload WorkloadView (which fetches its own topology for the
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 { data: topology, isLoading: topologyLoading } = useTopology(appNamespaces, 'resources', { enabled: appNamespaces.length > 0 })
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>) lives in the URL too: deep-linkable,
124
- // and back returns from a workload's runtime to the app graph. Clearing it
125
- // also drops the workload's tab param.
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 selectedWorkloadKey = searchParams.get('workload')
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
- // Always drop the workload's tab: a fresh workload opens on its overview,
132
- // and clearing back to the graph leaves no stale tab on the route.
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
- if (key) params.set('workload', key)
135
- else params.delete('workload')
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 overview and say so. (With no workload selected the tab
190
- // rides along: it applies to the lone workload either side.)
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-auto">
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
- // "Back" returns to the app graph — meaningless for a
228
- // single-workload app, which has no graph to return to.
229
- hideBackButton={(app.workloads?.length ?? 0) <= 1}
396
+ hideBackButton
230
397
  compactHeader
231
- onNavigateToResource={onOpenResource}
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
- <input
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
- <input
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&apos;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
+ }