@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.
Files changed (75) hide show
  1. package/README.md +7 -1
  2. package/package.json +11 -9
  3. package/src/App.tsx +221 -181
  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.ts +262 -34
  8. package/src/api/diagnose.ts +288 -0
  9. package/src/api/timelineSource.test.ts +217 -0
  10. package/src/api/timelineSource.ts +580 -0
  11. package/src/components/ConnectionErrorView.tsx +174 -70
  12. package/src/components/ContextSwitcher.tsx +13 -5
  13. package/src/components/applications/ApplicationsView.tsx +327 -26
  14. package/src/components/audit/AuditSettingsDialog.tsx +2 -2
  15. package/src/components/curl/ServiceCurlButton.tsx +2 -2
  16. package/src/components/diagnose/AISettings.tsx +121 -0
  17. package/src/components/diagnose/DiagnoseContext.tsx +491 -0
  18. package/src/components/diagnose/DiagnoseSurface.tsx +385 -0
  19. package/src/components/diagnose/Home.tsx +163 -0
  20. package/src/components/diagnose/InvestigationView.tsx +604 -0
  21. package/src/components/diagnose/LocalDiagnoseAction.tsx +150 -0
  22. package/src/components/diagnose/launch.ts +65 -0
  23. package/src/components/diagnose/parts.tsx +1689 -0
  24. package/src/components/dock/BottomDock.tsx +2 -3
  25. package/src/components/dock/WorkloadLogsTab.tsx +21 -5
  26. package/src/components/execution/BatchExecutionView.test.ts +170 -0
  27. package/src/components/execution/BatchExecutionView.tsx +1329 -0
  28. package/src/components/execution/batch-run-actions.test.ts +48 -0
  29. package/src/components/execution/batch-run-actions.ts +24 -0
  30. package/src/components/execution/batch-timeline.test.ts +57 -0
  31. package/src/components/execution/batch-timeline.ts +46 -0
  32. package/src/components/execution/execution-definition.test.ts +208 -0
  33. package/src/components/execution/execution-definition.ts +245 -0
  34. package/src/components/helm/ChartBrowser.tsx +2 -3
  35. package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
  36. package/src/components/helm/HelmReleaseDrawer.tsx +376 -43
  37. package/src/components/helm/HelmView.tsx +2 -3
  38. package/src/components/helm/InstallWizard.tsx +3 -5
  39. package/src/components/helm/TrackChartSourceDialog.tsx +48 -4
  40. package/src/components/helm/ValuesDiffPreview.tsx +15 -4
  41. package/src/components/home/HomeView.tsx +17 -15
  42. package/src/components/home/MCPSetupDialog.tsx +1 -1
  43. package/src/components/home/mcpToolCatalog.ts +2 -2
  44. package/src/components/issues/IssuesPane.tsx +9 -1
  45. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
  46. package/src/components/portforward/PortForwardButton.tsx +2 -2
  47. package/src/components/portforward/PortForwardManager.tsx +19 -14
  48. package/src/components/resource/PrometheusChartsGrid.tsx +6 -80
  49. package/src/components/resource/RightsizingStrip.tsx +0 -3
  50. package/src/components/resources/ImageFilesystemModal.tsx +2 -2
  51. package/src/components/resources/PodFilesystemModal.tsx +2 -3
  52. package/src/components/resources/ResourceDetailDrawer.tsx +2 -0
  53. package/src/components/resources/ResourcesView.tsx +13 -4
  54. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
  55. package/src/components/resources/renderers/index.ts +1 -0
  56. package/src/components/settings/SettingsDialog.tsx +80 -23
  57. package/src/components/shared/LargeClusterNamespacePicker.tsx +2 -2
  58. package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
  59. package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
  60. package/src/components/timeline/TimelineList.tsx +51 -10
  61. package/src/components/timeline/TimelineView.tsx +701 -15
  62. package/src/components/timeline/TimelineView.urlparams.test.ts +294 -0
  63. package/src/components/traffic/TrafficFilterSidebar.tsx +2 -2
  64. package/src/components/traffic/TrafficView.tsx +8 -4
  65. package/src/components/ui/CommandPalette.tsx +2 -2
  66. package/src/components/workload/WorkloadView.tsx +319 -35
  67. package/src/context/ConnectionContext.tsx +109 -11
  68. package/src/context/DiagnoseCustomization.tsx +42 -0
  69. package/src/context/TimelineSource.tsx +50 -0
  70. package/src/hooks/useClusterLoadState.ts +73 -0
  71. package/src/hooks/useEventSource.ts +6 -0
  72. package/src/index.css +157 -0
  73. package/src/index.ts +12 -0
  74. package/src/types/clusterLoadState.ts +33 -0
  75. 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 })
@@ -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 (for the app graph)
117
- // and the per-workload WorkloadView (which fetches its own topology for the
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 { 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
+ )
125
161
 
126
- // The selected workload (?workload=<key>) lives in the URL too: deep-linkable,
127
- // and back returns from a workload's runtime to the app graph. Clearing it
128
- // 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.
129
166
  const [searchParams, setSearchParams] = useSearchParams()
130
- 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
+ )
131
218
  const selectWorkload = useCallback(
132
219
  (key: string | null) => {
133
220
  const params = new URLSearchParams(searchParams)
134
- // Always drop the workload's tab: a fresh workload opens on its overview,
135
- // 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')
136
244
  params.delete('tab')
137
- if (key) params.set('workload', key)
138
- else params.delete('workload')
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 overview and say so. (With no workload selected the tab
193
- // 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.
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-auto">
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
- // "Back" returns to the app graph — meaningless for a
231
- // single-workload app, which has no graph to return to.
232
- hideBackButton={(app.workloads?.length ?? 0) <= 1}
396
+ hideBackButton
233
397
  compactHeader
234
- onNavigateToResource={onOpenResource}
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
- <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
+ }