@skyhook-io/radar-app 1.8.6 → 1.8.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/package.json +11 -9
- package/src/App.tsx +221 -181
- package/src/RadarApp.tsx +91 -20
- package/src/api/client.delta.test.ts +89 -0
- package/src/api/client.deltaSync.test.ts +216 -0
- package/src/api/client.ts +262 -34
- package/src/api/diagnose.ts +288 -0
- package/src/api/timelineSource.test.ts +217 -0
- package/src/api/timelineSource.ts +580 -0
- package/src/components/ConnectionErrorView.tsx +174 -70
- package/src/components/ContextSwitcher.tsx +13 -5
- package/src/components/applications/ApplicationsView.tsx +327 -26
- package/src/components/audit/AuditSettingsDialog.tsx +2 -2
- package/src/components/curl/ServiceCurlButton.tsx +2 -2
- package/src/components/diagnose/AISettings.tsx +121 -0
- package/src/components/diagnose/DiagnoseContext.tsx +491 -0
- package/src/components/diagnose/DiagnoseSurface.tsx +385 -0
- package/src/components/diagnose/Home.tsx +163 -0
- package/src/components/diagnose/InvestigationView.tsx +604 -0
- package/src/components/diagnose/LocalDiagnoseAction.tsx +150 -0
- package/src/components/diagnose/launch.ts +65 -0
- package/src/components/diagnose/parts.tsx +1689 -0
- package/src/components/dock/BottomDock.tsx +2 -3
- package/src/components/dock/WorkloadLogsTab.tsx +21 -5
- package/src/components/execution/BatchExecutionView.test.ts +170 -0
- package/src/components/execution/BatchExecutionView.tsx +1329 -0
- package/src/components/execution/batch-run-actions.test.ts +48 -0
- package/src/components/execution/batch-run-actions.ts +24 -0
- package/src/components/execution/batch-timeline.test.ts +57 -0
- package/src/components/execution/batch-timeline.ts +46 -0
- package/src/components/execution/execution-definition.test.ts +208 -0
- package/src/components/execution/execution-definition.ts +245 -0
- package/src/components/helm/ChartBrowser.tsx +2 -3
- package/src/components/helm/HelmReleaseDrawer.test.ts +17 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +376 -43
- package/src/components/helm/HelmView.tsx +2 -3
- package/src/components/helm/InstallWizard.tsx +3 -5
- package/src/components/helm/TrackChartSourceDialog.tsx +48 -4
- package/src/components/helm/ValuesDiffPreview.tsx +15 -4
- package/src/components/home/HomeView.tsx +17 -15
- package/src/components/home/MCPSetupDialog.tsx +1 -1
- package/src/components/home/mcpToolCatalog.ts +2 -2
- package/src/components/issues/IssuesPane.tsx +9 -1
- package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +135 -0
- package/src/components/portforward/PortForwardButton.tsx +2 -2
- package/src/components/portforward/PortForwardManager.tsx +19 -14
- package/src/components/resource/PrometheusChartsGrid.tsx +6 -80
- package/src/components/resource/RightsizingStrip.tsx +0 -3
- package/src/components/resources/ImageFilesystemModal.tsx +2 -2
- package/src/components/resources/PodFilesystemModal.tsx +2 -3
- package/src/components/resources/ResourceDetailDrawer.tsx +2 -0
- package/src/components/resources/ResourcesView.tsx +13 -4
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +1 -0
- package/src/components/resources/renderers/index.ts +1 -0
- package/src/components/settings/SettingsDialog.tsx +80 -23
- package/src/components/shared/LargeClusterNamespacePicker.tsx +2 -2
- package/src/components/timeline/LocalTimelineScrubber.tsx +212 -0
- package/src/components/timeline/RetainedTimelineScrubber.tsx +311 -0
- package/src/components/timeline/TimelineList.tsx +51 -10
- package/src/components/timeline/TimelineView.tsx +701 -15
- package/src/components/timeline/TimelineView.urlparams.test.ts +294 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +2 -2
- package/src/components/traffic/TrafficView.tsx +8 -4
- package/src/components/ui/CommandPalette.tsx +2 -2
- package/src/components/workload/WorkloadView.tsx +319 -35
- package/src/context/ConnectionContext.tsx +109 -11
- package/src/context/DiagnoseCustomization.tsx +42 -0
- package/src/context/TimelineSource.tsx +50 -0
- package/src/hooks/useClusterLoadState.ts +73 -0
- package/src/hooks/useEventSource.ts +6 -0
- package/src/index.css +157 -0
- package/src/index.ts +12 -0
- package/src/types/clusterLoadState.ts +33 -0
- package/src/utils/navigation.ts +10 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useMemo, useState } from 'react'
|
|
2
|
-
import {
|
|
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
|
|
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="
|
|
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
|
-
{
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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={
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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..."
|