@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,5 +1,5 @@
1
1
  import { useMemo, useState } from 'react'
2
- import { BarChart3, ChevronDown, ChevronRight, Loader2, Wifi, WifiOff } from 'lucide-react'
2
+ import { Loader2, Wifi, WifiOff } from 'lucide-react'
3
3
  import {
4
4
  AreaChart,
5
5
  SeriesLegend,
@@ -11,11 +11,9 @@ import {
11
11
  usePrometheusStatus,
12
12
  usePrometheusConnect,
13
13
  usePrometheusResourceMetrics,
14
- usePrometheusRightsizing,
15
14
  useAutoPromConnect,
16
15
  type PrometheusMetricCategory,
17
16
  type PrometheusTimeRange,
18
- type RightsizingTone,
19
17
  } from '../../api/client'
20
18
  import {
21
19
  MetricsSummary,
@@ -57,7 +55,6 @@ export function PrometheusChartsGrid({
57
55
  const showRestartLane = isSupported && kind !== 'Node'
58
56
 
59
57
  const [timeRange, setTimeRange] = useState<PrometheusTimeRange>('1h')
60
- const [diskExpanded, setDiskExpanded] = useState(false)
61
58
 
62
59
  const categories = kind === 'Node' ? NODE_CATEGORIES : WORKLOAD_CATEGORIES
63
60
 
@@ -112,7 +109,6 @@ export function PrometheusChartsGrid({
112
109
  const findCategory = (key: PrometheusMetricCategory): CategoryDef | undefined =>
113
110
  categories.find(c => c.key === key)
114
111
 
115
- // Disk I/O is collapsed by default — niche metric.
116
112
  const primaryCats: { def: CategoryDef; refLines?: ReferenceLine[] }[] = []
117
113
  const cpu = findCategory('cpu')
118
114
  if (cpu) primaryCats.push({ def: cpu, refLines: cpuRefLines })
@@ -125,19 +121,16 @@ export function PrometheusChartsGrid({
125
121
  if (tx) primaryCats.push({ def: tx })
126
122
  }
127
123
  const disk = findCategory('filesystem')
124
+ const chartPanels = disk ? [...primaryCats, { def: disk }] : primaryCats
128
125
 
129
126
  return (
130
127
  <div className="flex flex-col h-full overflow-auto">
131
- <div className="shrink-0 flex items-center justify-between px-4 py-2.5 border-b border-theme-border bg-theme-surface/50">
132
- <div className="flex items-center gap-2 text-sm font-medium text-theme-text-secondary">
133
- <BarChart3 className="w-4 h-4 text-theme-text-tertiary" />
134
- Metrics
135
- <WorkloadHealthBadge kind={kind} namespace={namespace} name={name} />
136
- </div>
128
+ <div className="flex shrink-0 justify-end px-4 pt-3">
137
129
  <select
130
+ aria-label="Metrics time range"
138
131
  value={timeRange}
139
132
  onChange={e => setTimeRange(e.target.value as PrometheusTimeRange)}
140
- className="px-2 py-1 text-xs rounded-md bg-theme-elevated border border-theme-border text-theme-text-secondary focus:outline-none focus:ring-1 focus:ring-blue-500/50"
133
+ className="rounded-md border border-theme-border bg-theme-elevated px-2 py-1 text-xs text-theme-text-secondary shadow-theme-sm focus:outline-none focus:ring-1 focus:ring-accent/50"
141
134
  >
142
135
  {TIME_RANGES.map(tr => (
143
136
  <option key={tr.value} value={tr.value}>{tr.label}</option>
@@ -154,7 +147,7 @@ export function PrometheusChartsGrid({
154
147
  )}
155
148
 
156
149
  <div className="grid grid-cols-1 md:grid-cols-2 gap-3 p-4">
157
- {primaryCats.map(({ def, refLines }) => (
150
+ {chartPanels.map(({ def, refLines }) => (
158
151
  <MetricsPanel
159
152
  key={def.key}
160
153
  category={def}
@@ -166,30 +159,6 @@ export function PrometheusChartsGrid({
166
159
  />
167
160
  ))}
168
161
  </div>
169
-
170
- {disk && (
171
- <div className="px-4 pb-4">
172
- <button
173
- type="button"
174
- onClick={() => setDiskExpanded(v => !v)}
175
- className="flex items-center gap-1.5 text-xs font-medium text-theme-text-tertiary hover:text-theme-text-secondary py-1"
176
- >
177
- {diskExpanded ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
178
- Disk I/O
179
- </button>
180
- {diskExpanded && (
181
- <div className="mt-2">
182
- <MetricsPanel
183
- category={disk}
184
- kind={kind}
185
- namespace={namespace}
186
- name={name}
187
- timeRange={timeRange}
188
- />
189
- </div>
190
- )}
191
- </div>
192
- )}
193
162
  </div>
194
163
  )
195
164
  }
@@ -269,49 +238,6 @@ function SaturationChip({ ratio, against }: { ratio: number; against: 'limit' |
269
238
  return <span className={`badge badge-sm ${SEVERITY_BADGE[tone]}`}>{label}</span>
270
239
  }
271
240
 
272
- // Severity ordering for rightsizing tones. Aligned with `Tone` constants in
273
- // `internal/prometheus/rightsizing.go` — adding a new tone there triggers a
274
- // TypeScript exhaustiveness error on the Record key set here.
275
- const TONE_RANK: Record<RightsizingTone, number> = {
276
- ok: 0,
277
- info: 1,
278
- warning: 2,
279
- alert: 3,
280
- critical: 4,
281
- }
282
-
283
- function WorkloadHealthBadge({ kind, namespace, name }: { kind: string; namespace: string; name: string }) {
284
- const supported = kind === 'Deployment' || kind === 'StatefulSet' || kind === 'DaemonSet'
285
- const { data, error } = usePrometheusRightsizing(kind, namespace, name, supported)
286
- if (!supported) return null
287
- // Surface a neutral "Health unknown" pill when the rightsizing endpoint
288
- // errors — otherwise an actually-throttled or OOM-risk workload would
289
- // silently render as fine while we have no signal to display.
290
- if (error && !data) {
291
- const msg = error instanceof Error ? error.message : String(error)
292
- return (
293
- <Tooltip content={`Health check failed: ${msg}`}>
294
- <span className={`badge badge-sm ${SEVERITY_BADGE.neutral}`}>Health unknown</span>
295
- </Tooltip>
296
- )
297
- }
298
- if (!data?.sampleAvailable || data.rows.length === 0) return null
299
-
300
- const worst = data.rows.reduce<RightsizingTone>(
301
- (acc, r) => (TONE_RANK[r.tone] > TONE_RANK[acc] ? r.tone : acc),
302
- 'ok',
303
- )
304
- // Skip the chip for the steady-state tones to avoid badge-blindness — we
305
- // only want to draw the eye when there's something to address.
306
- if (worst === 'ok' || worst === 'info') return null
307
-
308
- const { label, severity }: { label: string; severity: Severity } =
309
- worst === 'critical' ? { label: 'OOM risk', severity: 'error' } :
310
- worst === 'alert' ? { label: 'CPU throttling', severity: 'alert' } :
311
- /* warning */ { label: 'Needs review', severity: 'warning' }
312
- return <span className={`badge badge-sm ${SEVERITY_BADGE[severity]}`}>{label}</span>
313
- }
314
-
315
241
  function PanelLoading() {
316
242
  return (
317
243
  <div className="flex items-center justify-center h-full min-h-[160px] text-theme-text-tertiary text-xs">
@@ -29,9 +29,6 @@ export function RightsizingStrip({ kind, namespace, name }: {
29
29
  const { data, error, isLoading } = usePrometheusRightsizing(kind, namespace, name, isConnected && supported)
30
30
 
31
31
  if (!supported || !isConnected || isLoading) return null
32
- // Stay consistent with WorkloadHealthBadge — when the rightsizing query
33
- // fails, surface a small inline note so the absence of recommendations
34
- // doesn't read as "everything is fine."
35
32
  if (error && !data) {
36
33
  const msg = error instanceof Error ? error.message : String(error)
37
34
  return (
@@ -9,6 +9,7 @@ import { formatBytes } from '../../utils/format'
9
9
  import { downloadBlob, filterTree } from './file-browser-utils'
10
10
  import { Tooltip } from '../ui/Tooltip'
11
11
  import { apiUrl, getAuthHeaders, getCredentialsMode } from '../../api/config'
12
+ import { Input } from '@skyhook-io/k8s-ui'
12
13
 
13
14
  // Manual fetch function for filesystem (not a hook - gives us full control)
14
15
  async function fetchImageFilesystem(
@@ -166,8 +167,7 @@ export function ImageFilesystemModal({
166
167
  <div className="p-3 border-b border-theme-border shrink-0">
167
168
  <div className="relative">
168
169
  <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-theme-text-tertiary" />
169
- <input
170
- type="text"
170
+ <Input
171
171
  placeholder="Search files..."
172
172
  value={searchQuery}
173
173
  onChange={(e) => setSearchQuery(e.target.value)}
@@ -1,7 +1,7 @@
1
1
  import { useState, useRef, useEffect, useMemo, useCallback } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
3
  import { X, File, Link2, ChevronRight, AlertTriangle, Loader2, Search, Download, FolderOpen } from 'lucide-react'
4
- import { PaneLoader } from '@skyhook-io/k8s-ui'
4
+ import { PaneLoader, Input } from '@skyhook-io/k8s-ui'
5
5
  import { clsx } from 'clsx'
6
6
  import type { FileNode } from '../../types'
7
7
  import { formatBytes } from '../../utils/format'
@@ -192,8 +192,7 @@ export function PodFilesystemModal({
192
192
  {showFilesystem && (
193
193
  <div className="relative flex-1 min-w-[200px]">
194
194
  <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-theme-text-tertiary" />
195
- <input
196
- type="text"
195
+ <Input
197
196
  placeholder="Filter files..."
198
197
  value={searchQuery}
199
198
  onChange={(e) => setSearchQuery(e.target.value)}
@@ -23,6 +23,8 @@ interface ResourceDetailDrawerProps {
23
23
  /** Top offset for the drawer (px). Defaults to Radar's 49px header height;
24
24
  * pass 0 in chromeless embeds where there's no Radar header above it. */
25
25
  headerHeight?: number
26
+ /** Right inset in px so the drawer sits beside a docked side panel (AI), not under it. */
27
+ rightInset?: number
26
28
  }
27
29
 
28
30
  export function ResourceDetailDrawer(props: ResourceDetailDrawerProps) {
@@ -41,6 +41,7 @@ interface ResourcesViewProps {
41
41
 
42
42
  type SelectedKindInfo = { name: string; kind: string; group: string } | null
43
43
 
44
+ const EMPTY_RESOURCE_COUNTS: Record<string, number> = {}
44
45
  const LARGE_RESOURCE_LIST_LIMIT = 25000
45
46
  const LARGE_RESOURCE_LIST_GUARD_KEYS = new Set([
46
47
  'Pod',
@@ -60,6 +61,10 @@ function resourceCountKey(kind: NonNullable<SelectedKindInfo>): string {
60
61
  return kind.group ? `${kind.group}/${kind.kind}` : kind.kind
61
62
  }
62
63
 
64
+ function hasResourceCount(counts: Record<string, number> | undefined, key: string): boolean {
65
+ return Object.prototype.hasOwnProperty.call(counts ?? {}, key)
66
+ }
67
+
63
68
  export function ResourcesView({ namespaces, selectedResource, onResourceClick, onResourceClickYaml, onKindChange, onClearNamespaces }: ResourcesViewProps) {
64
69
  const location = useLocation()
65
70
  const navigate = useNavigate()
@@ -181,14 +186,16 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
181
186
 
182
187
  const selectedCountKey = selectedKind ? resourceCountKey(selectedKind) : ''
183
188
  const selectedCount = selectedCountKey ? countsData?.counts[selectedCountKey] : undefined
189
+ const selectedCountKnown = selectedCountKey ? hasResourceCount(countsData?.counts, selectedCountKey) : false
184
190
  const selectedCountUnavailable = selectedCountKey ? countsData?.unavailable?.includes(selectedCountKey) ?? false : false
185
191
  const isSelectedKindGuarded = selectedCountKey !== '' && LARGE_RESOURCE_LIST_GUARD_KEYS.has(selectedCountKey)
186
192
  const waitingForGuardCount = isSelectedKindGuarded && !countsData && !countsIsError
187
- const largeListBlocked = isSelectedKindGuarded && countsData != null && (selectedCountUnavailable || (selectedCount ?? 0) > LARGE_RESOURCE_LIST_LIMIT)
193
+ const largeListBlocked = isSelectedKindGuarded && countsData != null && (selectedCountUnavailable || (selectedCountKnown && (selectedCount ?? 0) > LARGE_RESOURCE_LIST_LIMIT))
188
194
  const selectedKindQueryBlocked = waitingForGuardCount || largeListBlocked
189
195
  const podCount = countsData?.counts.Pod
196
+ const podCountKnown = hasResourceCount(countsData?.counts, 'Pod')
190
197
  const podCountUnavailable = countsData?.unavailable?.includes('Pod') ?? false
191
- const podCountAllowsBulkMetrics = countsData != null && !podCountUnavailable && (podCount ?? 0) <= LARGE_RESOURCE_LIST_LIMIT
198
+ const podCountAllowsBulkMetrics = countsData != null && podCountKnown && !podCountUnavailable && (podCount ?? 0) <= LARGE_RESOURCE_LIST_LIMIT
192
199
  const selectedKindName = selectedKind?.name.toLowerCase() ?? ''
193
200
  const topPodMetricsEnabled = selectedKindName === 'pods' && podCountAllowsBulkMetrics
194
201
  const topNodeMetricsEnabled = selectedKindName === 'nodes' && namespaces.length === 0 && podCountAllowsBulkMetrics
@@ -248,6 +255,8 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
248
255
  const selectedKindQueryResult: ResourceQueryResult | undefined = useMemo(() => {
249
256
  if (!selectedKind) return undefined
250
257
  return {
258
+ resourceName: selectedKind.name,
259
+ group: selectedKind.group,
251
260
  data: selectedKindQueryBlocked ? [] : selectedKindQuery.data as any[] | undefined,
252
261
  isLoading: waitingForGuardCount || selectedKindQuery.isLoading,
253
262
  error: selectedKindQueryBlocked ? undefined : selectedKindQuery.error,
@@ -315,7 +324,7 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
315
324
  return (
316
325
  <>
317
326
  <BaseResourcesView
318
- key={location.pathname}
327
+ key={connection.context || 'default'}
319
328
  namespaces={namespaces}
320
329
  selectedResource={selectedResource}
321
330
  onResourceClick={onResourceClick}
@@ -325,7 +334,7 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
325
334
  // Injected data
326
335
  apiResources={apiResources}
327
336
  // Lightweight counts for sidebar (replaces 233 parallel queries)
328
- resourceCounts={countsData?.counts}
337
+ resourceCounts={countsData?.counts ?? EMPTY_RESOURCE_COUNTS}
329
338
  resourceForbidden={countsData?.forbidden}
330
339
  resourceReasons={countsData?.reasons}
331
340
  resourceUnavailable={countsData?.unavailable}
@@ -0,0 +1 @@
1
+ export * from '@skyhook-io/k8s-ui/components/resources/renderers/CronWorkflowRenderer'
@@ -7,6 +7,7 @@ export { ConfigMapRenderer } from './ConfigMapRenderer'
7
7
  export { SecretRenderer } from './SecretRenderer'
8
8
  export { JobRenderer } from './JobRenderer'
9
9
  export { CronJobRenderer } from './CronJobRenderer'
10
+ export { CronWorkflowRenderer } from './CronWorkflowRenderer'
10
11
  export { HPARenderer } from './HPARenderer'
11
12
  export { NodeRenderer } from './NodeRenderer'
12
13
  export { PVCRenderer } from './PVCRenderer'
@@ -9,6 +9,9 @@ import { useCloudRole, useVersionCheck } from '../../api/client'
9
9
  import { useCapabilitiesContext } from '../../contexts/CapabilitiesContext'
10
10
  import { Tooltip } from '../ui/Tooltip'
11
11
  import type { DeploymentMode } from '../../types'
12
+ import { Input } from '@skyhook-io/k8s-ui'
13
+ import { AISettingsSection, type AIDraft } from '../diagnose/AISettings'
14
+ import { useDiagnose } from '../diagnose/DiagnoseContext'
12
15
 
13
16
  interface Config {
14
17
  kubeconfig?: string
@@ -56,12 +59,35 @@ export function SettingsDialog({ open, onClose, onShowMyPermissions }: SettingsD
56
59
  const [configDirty, setConfigDirty] = useState(false)
57
60
  const [loadError, setLoadError] = useState<string | null>(null)
58
61
 
62
+ // AI Diagnosis prefs are client-side (localStorage) but, like the rest of this
63
+ // dialog, are STAGED and committed on Save — not on every change. The draft
64
+ // mirrors the committed values (from DiagnoseContext) and is snapshotted on open.
65
+ const diag = useDiagnose()
66
+ const [aiDraft, setAiDraft] = useState<AIDraft>({
67
+ agent: diag.selectedAgent,
68
+ isolated: diag.isolated,
69
+ model: diag.model,
70
+ effort: diag.effort,
71
+ })
72
+ const aiDirty =
73
+ aiDraft.agent !== diag.selectedAgent ||
74
+ aiDraft.isolated !== diag.isolated ||
75
+ aiDraft.model !== diag.model ||
76
+ aiDraft.effort !== diag.effort
77
+
59
78
  // Load config on open
60
79
  useEffect(() => {
61
80
  if (!open) return
62
81
  setSaveMessage(null)
63
82
  setConfigDirty(false)
64
83
  setLoadError(null)
84
+ // Snapshot the committed AI prefs into the draft so edits stage cleanly.
85
+ setAiDraft({
86
+ agent: diag.selectedAgent,
87
+ isolated: diag.isolated,
88
+ model: diag.model,
89
+ effort: diag.effort,
90
+ })
65
91
 
66
92
  fetch(apiUrl('/config'), { credentials: getCredentialsMode(), headers: getAuthHeaders() })
67
93
  .then((res) => {
@@ -76,6 +102,8 @@ export function SettingsDialog({ open, onClose, onShowMyPermissions }: SettingsD
76
102
  console.warn('[settings] Failed to load config:', err)
77
103
  setLoadError('Failed to load configuration.')
78
104
  })
105
+ // Snapshot-on-open only; we don't want late diag updates to wipe staged edits.
106
+ // eslint-disable-next-line react-hooks/exhaustive-deps
79
107
  }, [open])
80
108
 
81
109
  // ESC key
@@ -107,32 +135,52 @@ export function SettingsDialog({ open, onClose, onShowMyPermissions }: SettingsD
107
135
  const saveConfig = useCallback(async () => {
108
136
  setSaving(true)
109
137
  setSaveMessage(null)
138
+ // AI prefs are client-side (localStorage) — commit the staged draft now.
139
+ // setSelectedAgent clears model/effort (they're agent-specific), so set the
140
+ // agent first, then restore the draft's model/effort.
141
+ if (aiDirty) {
142
+ diag.setSelectedAgent(aiDraft.agent)
143
+ diag.setIsolated(aiDraft.isolated)
144
+ diag.setModel(aiDraft.model)
145
+ diag.setEffort(aiDraft.effort)
146
+ }
110
147
  try {
111
- const res = await fetch(apiUrl('/config'), {
112
- method: 'PUT',
113
- credentials: getCredentialsMode(),
114
- headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
115
- body: JSON.stringify(editedConfig),
116
- })
117
- if (!res.ok) {
118
- const data = await res.json().catch(() => null)
119
- setSaveMessage(`Error: ${data?.error || res.statusText}`)
120
- } else {
148
+ if (configDirty) {
149
+ const res = await fetch(apiUrl('/config'), {
150
+ method: 'PUT',
151
+ credentials: getCredentialsMode(),
152
+ headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
153
+ body: JSON.stringify(editedConfig),
154
+ })
155
+ if (!res.ok) {
156
+ const data = await res.json().catch(() => null)
157
+ setSaveMessage(`Error: ${data?.error || res.statusText}`)
158
+ return
159
+ }
121
160
  setConfigDirty(false)
122
- setSaveMessage('Saved. Changes take effect on next launch.')
161
+ setSaveMessage('Saved. Configuration changes take effect on next launch.')
162
+ } else {
163
+ setSaveMessage('Saved.')
123
164
  }
124
165
  } catch (err) {
125
166
  setSaveMessage(`Error: ${err}`)
126
167
  } finally {
127
168
  setSaving(false)
128
169
  }
129
- }, [editedConfig])
170
+ }, [editedConfig, configDirty, aiDirty, aiDraft, diag])
130
171
 
131
172
  const resetConfig = useCallback(() => {
132
173
  setEditedConfig({})
133
174
  setConfigDirty(true)
175
+ // Revert staged AI edits back to the committed prefs.
176
+ setAiDraft({
177
+ agent: diag.selectedAgent,
178
+ isolated: diag.isolated,
179
+ model: diag.model,
180
+ effort: diag.effort,
181
+ })
134
182
  setSaveMessage('All fields cleared. Press Save to apply.')
135
- }, [])
183
+ }, [diag])
136
184
 
137
185
  if (!shouldRender) return null
138
186
 
@@ -213,6 +261,17 @@ export function SettingsDialog({ open, onClose, onShowMyPermissions }: SettingsD
213
261
  </div>
214
262
  )}
215
263
 
264
+ <AISettingsSection
265
+ available={diag.available}
266
+ agents={diag.agents}
267
+ draft={aiDraft}
268
+ onChange={(patch) => {
269
+ setAiDraft((d) => ({ ...d, ...patch }))
270
+ setSaveMessage(null)
271
+ }}
272
+ onHistoryCleared={diag.refreshRuns}
273
+ />
274
+
216
275
  <SectionLabel>Radar configuration</SectionLabel>
217
276
  {canEditConfig ? (
218
277
  <StartupConfigTab
@@ -266,11 +325,13 @@ export function SettingsDialog({ open, onClose, onShowMyPermissions }: SettingsD
266
325
  <div className="flex items-center gap-3">
267
326
  <span className="hidden sm:flex items-center gap-1.5 text-[11px] text-theme-text-tertiary">
268
327
  <RotateCw className="w-3 h-3" />
269
- Applies on next launch
328
+ {aiDirty && !configDirty
329
+ ? 'Applies to new investigations'
330
+ : 'Applies on next launch'}
270
331
  </span>
271
332
  <button
272
333
  onClick={saveConfig}
273
- disabled={saving || !configDirty}
334
+ disabled={saving || (!configDirty && !aiDirty)}
274
335
  className="flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium btn-brand rounded-md"
275
336
  >
276
337
  {saving && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
@@ -630,8 +691,7 @@ function PrometheusConfigField({
630
691
  Manual Prometheus/VictoriaMetrics URL (skips auto-discovery)
631
692
  </p>
632
693
  <div className="flex items-center gap-2">
633
- <input
634
- type="text"
694
+ <Input
635
695
  value={value}
636
696
  onChange={(e) => onChange(e.target.value)}
637
697
  placeholder="http://prometheus-server.monitoring:9090"
@@ -689,8 +749,7 @@ function PrometheusConfigField({
689
749
  <div className="rounded-md border border-theme-border bg-theme-elevated/40 p-2.5 space-y-2">
690
750
  {headerRows.map((row, i) => (
691
751
  <div key={i} className="flex items-center gap-2">
692
- <input
693
- type="text"
752
+ <Input
694
753
  value={row.key}
695
754
  onChange={(e) => {
696
755
  setHeaderRows((rows) => rows!.map((r, j) => j === i ? { ...r, key: e.target.value } : r))
@@ -768,8 +827,7 @@ function ConfigField({
768
827
  {label}
769
828
  </label>
770
829
  {help && <p className="text-xs text-theme-text-tertiary mb-1">{help}</p>}
771
- <input
772
- type="text"
830
+ <Input
773
831
  value={value}
774
832
  onChange={(e) => onChange(e.target.value)}
775
833
  placeholder={placeholder}
@@ -820,8 +878,7 @@ function ConfigArrayField({
820
878
  {label}
821
879
  </label>
822
880
  {help && <p className="text-xs text-theme-text-tertiary mb-1">{help}</p>}
823
- <input
824
- type="text"
881
+ <Input
825
882
  value={text}
826
883
  onFocus={() => { focusedRef.current = true }}
827
884
  onBlur={() => {
@@ -1,5 +1,6 @@
1
1
  import { useState, useRef, useEffect, useMemo } from 'react'
2
2
  import { Search } from 'lucide-react'
3
+ import { Input } from '@skyhook-io/k8s-ui'
3
4
 
4
5
  export function LargeClusterNamespacePicker({ namespaces, onSelect }: {
5
6
  namespaces: { name: string }[] | undefined
@@ -27,9 +28,8 @@ export function LargeClusterNamespacePicker({ namespaces, onSelect }: {
27
28
  <div className="text-left">
28
29
  <div className="relative mb-2">
29
30
  <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-theme-text-tertiary" />
30
- <input
31
+ <Input
31
32
  ref={inputRef}
32
- type="text"
33
33
  value={search}
34
34
  onChange={(e) => setSearch(e.target.value)}
35
35
  placeholder="Search namespaces..."