@skyhook-io/radar-app 1.7.0 → 1.8.1

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 (47) hide show
  1. package/package.json +9 -3
  2. package/src/App.tsx +150 -44
  3. package/src/api/client.ts +3 -3
  4. package/src/components/ConnectionErrorView.tsx +5 -2
  5. package/src/components/DebugOverlay.tsx +5 -3
  6. package/src/components/UserMenu.tsx +5 -2
  7. package/src/components/applications/ApplicationsView.tsx +1 -1
  8. package/src/components/audit/AuditSettingsDialog.tsx +14 -10
  9. package/src/components/audit/AuditView.tsx +3 -1
  10. package/src/components/compare/CompareViewRoute.tsx +13 -5
  11. package/src/components/compare/useCompareLauncher.tsx +6 -3
  12. package/src/components/cost/CostTrendChart.tsx +3 -3
  13. package/src/components/cost/CostView.tsx +14 -5
  14. package/src/components/helm/ChartBrowser.tsx +7 -5
  15. package/src/components/helm/HelmReleaseDrawer.tsx +25 -13
  16. package/src/components/helm/HelmView.tsx +3 -2
  17. package/src/components/helm/InstallWizard.tsx +4 -3
  18. package/src/components/helm/OwnedResources.tsx +24 -13
  19. package/src/components/helm/RevisionHistory.tsx +2 -1
  20. package/src/components/helm/ValuesViewer.tsx +4 -2
  21. package/src/components/home/ActivitySummary.tsx +4 -1
  22. package/src/components/home/ClusterHealthCard.tsx +21 -18
  23. package/src/components/home/HelmSummary.tsx +3 -1
  24. package/src/components/home/MCPSetupDialog.tsx +10 -4
  25. package/src/components/issues/IssuesPane.tsx +2 -2
  26. package/src/components/nav/PrimaryNavRail.tsx +5 -2
  27. package/src/components/portforward/PortForwardButton.tsx +14 -8
  28. package/src/components/portforward/PortForwardManager.tsx +70 -8
  29. package/src/components/resource/PrometheusChartsGrid.tsx +10 -3
  30. package/src/components/resource/RestartChart.tsx +6 -5
  31. package/src/components/resource/RightsizingStrip.tsx +4 -1
  32. package/src/components/resource-drawer/ResourceDrawer.tsx +3 -1
  33. package/src/components/resources/ImageFilesystemModal.tsx +16 -8
  34. package/src/components/resources/PodFilesystemModal.tsx +4 -2
  35. package/src/components/resources/ResourcesView.tsx +2 -0
  36. package/src/components/settings/MyPermissionsDialog.tsx +64 -4
  37. package/src/components/settings/SettingsDialog.tsx +371 -141
  38. package/src/components/shared/LargeClusterNamespacePicker.tsx +1 -1
  39. package/src/components/traffic/TrafficFilterSidebar.tsx +8 -43
  40. package/src/components/traffic/TrafficFlowList.tsx +13 -4
  41. package/src/components/traffic/TrafficGraph.tsx +37 -23
  42. package/src/components/traffic/TrafficView.tsx +7 -5
  43. package/src/components/ui/DiagnosticsOverlay.tsx +1 -1
  44. package/src/components/ui/Omnibar.tsx +0 -1
  45. package/src/components/workload/WorkloadView.tsx +32 -27
  46. package/src/context/NavCustomization.tsx +19 -5
  47. package/src/main.tsx +1 -1
@@ -1,12 +1,13 @@
1
1
  import { useState, useEffect, useRef, useCallback, type ReactNode } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
- import { Settings, X, RotateCcw, Loader2, Copy, Check, Pin, Shield, Lock } from 'lucide-react'
3
+ import { Settings, X, RotateCcw, RotateCw, Loader2, Copy, Check, Pin, Shield, Lock, Plug, Plus } from 'lucide-react'
4
4
  import { clsx } from 'clsx'
5
5
  import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
6
6
  import { TRANSITION_BACKDROP, TRANSITION_PANEL } from '../../utils/animation'
7
7
  import { apiUrl, getAuthHeaders, getCredentialsMode } from '../../api/config'
8
8
  import { useCloudRole, useVersionCheck } from '../../api/client'
9
9
  import { useCapabilitiesContext } from '../../contexts/CapabilitiesContext'
10
+ import { Tooltip } from '../ui/Tooltip'
10
11
  import type { DeploymentMode } from '../../types'
11
12
 
12
13
  interface Config {
@@ -27,6 +28,7 @@ interface ConfigResponse {
27
28
  file: Config
28
29
  effective: Config
29
30
  isDesktop: boolean
31
+ prometheusHeaderKeys?: string[]
30
32
  }
31
33
 
32
34
  interface SettingsDialogProps {
@@ -156,7 +158,7 @@ export function SettingsDialog({ open, onClose, onShowMyPermissions }: SettingsD
156
158
  className={clsx(
157
159
  'relative bg-theme-surface border border-theme-border shadow-theme-lg w-full outline-none flex flex-col',
158
160
  'max-sm:inset-0 max-sm:absolute max-sm:rounded-none max-sm:max-h-full max-sm:border-0',
159
- 'sm:rounded-xl sm:max-w-xl sm:mx-4 sm:max-h-[85vh]',
161
+ 'sm:rounded-xl sm:max-w-2xl sm:mx-4 sm:max-h-[85vh]',
160
162
  TRANSITION_PANEL,
161
163
  isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95'
162
164
  )}
@@ -165,7 +167,13 @@ export function SettingsDialog({ open, onClose, onShowMyPermissions }: SettingsD
165
167
  <div className="flex items-center justify-between p-4 border-b border-theme-border shrink-0">
166
168
  <div className="flex items-center gap-2">
167
169
  <Settings className="w-5 h-5 text-theme-text-secondary" />
168
- <h2 className="text-lg font-semibold text-theme-text-primary">Settings</h2>
170
+ <div className="flex items-baseline gap-2">
171
+ <h2 className="text-lg font-semibold text-theme-text-primary">Settings</h2>
172
+ <span className="text-[11px] text-theme-text-tertiary">
173
+ Radar{versionInfo?.currentVersion ? ` v${versionInfo.currentVersion}` : ''}
174
+ <span className="text-theme-text-disabled"> · by Skyhook</span>
175
+ </span>
176
+ </div>
169
177
  </div>
170
178
  <button
171
179
  onClick={onClose}
@@ -212,6 +220,7 @@ export function SettingsDialog({ open, onClose, onShowMyPermissions }: SettingsD
212
220
  effectiveConfig={configData?.effective}
213
221
  isDesktop={isDesktop}
214
222
  deploymentMode={deploymentMode}
223
+ prometheusHeaderKeys={configData?.prometheusHeaderKeys ?? []}
215
224
  onChange={updateConfigField}
216
225
  />
217
226
  ) : (
@@ -233,17 +242,18 @@ export function SettingsDialog({ open, onClose, onShowMyPermissions }: SettingsD
233
242
  the save controls entirely for non-owners (personal sections save
234
243
  themselves). */}
235
244
  {canEditConfig && (
236
- <div className="flex items-center justify-between gap-3 p-4 border-t border-theme-border shrink-0">
245
+ <div className="flex items-center justify-between gap-3 px-4 py-2.5 border-t border-theme-border shrink-0">
237
246
  <div className="flex items-center gap-2">
247
+ <Tooltip content="Clear all fields — reverts to defaults when saved">
238
248
  <button
239
249
  onClick={resetConfig}
240
250
  disabled={saving}
241
- className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded-md transition-colors disabled:opacity-50"
242
- title="Reset all configuration to defaults"
251
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded-md transition-colors disabled:opacity-50 disabled:pointer-events-none"
243
252
  >
244
253
  <RotateCcw className="w-3.5 h-3.5" />
245
254
  Reset
246
255
  </button>
256
+ </Tooltip>
247
257
  {saveMessage && (
248
258
  <span className={clsx(
249
259
  'text-xs',
@@ -253,32 +263,22 @@ export function SettingsDialog({ open, onClose, onShowMyPermissions }: SettingsD
253
263
  </span>
254
264
  )}
255
265
  </div>
256
- <button
257
- onClick={saveConfig}
258
- disabled={saving || !configDirty}
259
- className="flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium btn-brand rounded-md"
260
- >
261
- {saving && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
262
- Save
263
- </button>
266
+ <div className="flex items-center gap-3">
267
+ <span className="hidden sm:flex items-center gap-1.5 text-[11px] text-theme-text-tertiary">
268
+ <RotateCw className="w-3 h-3" />
269
+ Applies on next launch
270
+ </span>
271
+ <button
272
+ onClick={saveConfig}
273
+ disabled={saving || !configDirty}
274
+ className="flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium btn-brand rounded-md"
275
+ >
276
+ {saving && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
277
+ Save
278
+ </button>
279
+ </div>
264
280
  </div>
265
281
  )}
266
-
267
- {/* About — muted version footer (canonical "Settings → About"). */}
268
- <div className="flex items-center justify-between gap-2 px-4 py-2 border-t border-theme-border/60 text-[11px] text-theme-text-tertiary shrink-0">
269
- <span>
270
- Radar{versionInfo?.currentVersion ? ` v${versionInfo.currentVersion}` : ''}
271
- <span className="text-theme-text-disabled"> · by Skyhook</span>
272
- </span>
273
- <a
274
- href="https://github.com/skyhook-io/radar"
275
- target="_blank"
276
- rel="noopener noreferrer"
277
- className="text-accent-text hover:underline"
278
- >
279
- GitHub
280
- </a>
281
- </div>
282
282
  </div>
283
283
  </div>,
284
284
  document.body
@@ -295,6 +295,19 @@ function SectionLabel({ children }: { children: ReactNode }) {
295
295
  )
296
296
  }
297
297
 
298
+ // A titled card grouping related config fields. Cards (vs thin dividers) give the
299
+ // sections clear visual separation in the scroll.
300
+ function ConfigSection({ title, children }: { title: string; children: ReactNode }) {
301
+ return (
302
+ <section className="rounded-lg border border-theme-border bg-theme-elevated/30 p-4">
303
+ <h4 className="text-xs font-medium text-theme-text-secondary uppercase tracking-wider mb-3">
304
+ {title}
305
+ </h4>
306
+ <div className="space-y-4">{children}</div>
307
+ </section>
308
+ )
309
+ }
310
+
298
311
  // -- Startup Configuration Tab ------------------------------------------------
299
312
 
300
313
  function StartupConfigTab({
@@ -302,133 +315,130 @@ function StartupConfigTab({
302
315
  effectiveConfig,
303
316
  isDesktop,
304
317
  deploymentMode,
318
+ prometheusHeaderKeys,
305
319
  onChange,
306
320
  }: {
307
321
  config: Config
308
322
  effectiveConfig?: Config
309
323
  isDesktop: boolean
310
324
  deploymentMode: DeploymentMode
325
+ prometheusHeaderKeys: string[]
311
326
  onChange: <K extends keyof Config>(field: K, value: Config[K]) => void
312
327
  }) {
313
328
  const showBrowserLaunchControls = !isDesktop && deploymentMode === 'local'
314
329
  return (
315
- <div className="space-y-4">
330
+ <div className="space-y-3">
316
331
  <p className="text-xs text-theme-text-tertiary">
317
- Changes require a restart to take effect.
332
+ Most changes require a restart to take effect.
318
333
  {isDesktop
319
334
  ? ' Quit and relaunch Radar to apply.'
320
335
  : ' Stop and restart the radar command to apply.'}
321
336
  </p>
322
337
 
323
- <ConfigField
324
- label="Kubeconfig"
325
- help="Path to kubeconfig file"
326
- value={config.kubeconfig ?? ''}
327
- effectiveValue={effectiveConfig?.kubeconfig}
328
- placeholder="~/.kube/config"
329
- onChange={(v) => onChange('kubeconfig', v || undefined)}
330
- />
331
-
332
- <ConfigArrayField
333
- label="Kubeconfig Directories"
334
- help="Comma-separated directories containing kubeconfig files"
335
- value={config.kubeconfigDirs}
336
- effectiveValue={effectiveConfig?.kubeconfigDirs}
337
- placeholder="/path/to/dir1, /path/to/dir2"
338
- onChange={(v) => onChange('kubeconfigDirs', v)}
339
- />
340
-
341
- <ConfigField
342
- label="Default Namespace"
343
- help="Initial namespace filter on startup"
344
- value={config.namespace ?? ''}
345
- effectiveValue={effectiveConfig?.namespace}
346
- placeholder="All namespaces"
347
- onChange={(v) => onChange('namespace', v || undefined)}
348
- />
349
-
350
- <ConfigNumberField
351
- label="Port"
352
- help={isDesktop
353
- ? 'Fixed server port (leave empty for random). Set this to keep a stable MCP endpoint.'
354
- : 'Server port'}
355
- value={config.port}
356
- effectiveValue={effectiveConfig?.port}
357
- placeholder={isDesktop ? 'Random' : '9280'}
358
- onChange={(v) => onChange('port', v)}
359
- />
338
+ <ConfigSection title="Cluster">
339
+ <ConfigField
340
+ label="Kubeconfig"
341
+ help="Path to kubeconfig file"
342
+ value={config.kubeconfig ?? ''}
343
+ effectiveValue={effectiveConfig?.kubeconfig}
344
+ placeholder="~/.kube/config"
345
+ onChange={(v) => onChange('kubeconfig', v || undefined)}
346
+ />
360
347
 
361
- {showBrowserLaunchControls && (
362
- <>
363
- <ConfigToggle
364
- label="Open browser on start"
365
- value={!(config.noBrowser ?? false)}
366
- onChange={(v) => onChange('noBrowser', !v ? true : undefined)}
367
- />
368
-
369
- <ConfigField
370
- label="Browser"
371
- help="Browser for automatic launch; macOS app names are supported"
372
- value={config.browser ?? ''}
373
- effectiveValue={effectiveConfig?.browser}
374
- placeholder="System default"
375
- onChange={(v) => onChange('browser', v || undefined)}
376
- />
377
- </>
378
- )}
348
+ <ConfigArrayField
349
+ label="Kubeconfig Directories"
350
+ help="Comma-separated directories containing kubeconfig files"
351
+ value={config.kubeconfigDirs}
352
+ effectiveValue={effectiveConfig?.kubeconfigDirs}
353
+ placeholder="/path/to/dir1, /path/to/dir2"
354
+ onChange={(v) => onChange('kubeconfigDirs', v)}
355
+ />
379
356
 
380
- <div className="border-t border-theme-border pt-4 mt-4">
381
- <h4 className="text-xs font-medium text-theme-text-secondary uppercase tracking-wider mb-3">Timeline</h4>
357
+ <ConfigField
358
+ label="Default Namespace"
359
+ help="Startup default only — change the active namespace live anytime from the header switcher"
360
+ value={config.namespace ?? ''}
361
+ effectiveValue={effectiveConfig?.namespace}
362
+ placeholder="All namespaces"
363
+ onChange={(v) => onChange('namespace', v || undefined)}
364
+ />
365
+ </ConfigSection>
366
+
367
+ <ConfigSection title="Server">
368
+ <ConfigNumberField
369
+ label="Port"
370
+ help={isDesktop
371
+ ? 'Fixed server port (leave empty for random). Set this to keep a stable MCP endpoint.'
372
+ : 'Server port'}
373
+ value={config.port}
374
+ effectiveValue={effectiveConfig?.port}
375
+ placeholder={isDesktop ? 'Random' : '9280'}
376
+ onChange={(v) => onChange('port', v)}
377
+ />
382
378
 
383
- <div className="space-y-4">
384
- <div>
385
- <label className="block text-sm font-medium text-theme-text-primary mb-1">
386
- Storage Backend
387
- </label>
388
- <select
389
- value={config.timelineStorage ?? 'memory'}
390
- onChange={(e) => onChange('timelineStorage', e.target.value === 'memory' ? undefined : e.target.value as 'sqlite')}
391
- className="w-full px-3 py-1.5 text-sm bg-theme-elevated border border-theme-border rounded-md text-theme-text-primary focus:outline-none focus:border-blue-500"
392
- >
393
- <option value="memory">Memory (default)</option>
394
- <option value="sqlite">SQLite (persistent)</option>
395
- </select>
396
- <EffectiveHint current={config.timelineStorage} effective={effectiveConfig?.timelineStorage} />
397
- </div>
379
+ {showBrowserLaunchControls && (
380
+ <>
381
+ <ConfigToggle
382
+ label="Open browser on start"
383
+ value={!(config.noBrowser ?? false)}
384
+ onChange={(v) => onChange('noBrowser', !v ? true : undefined)}
385
+ />
398
386
 
399
- <ConfigNumberField
400
- label="History Limit"
401
- help="Maximum events to retain"
402
- value={config.historyLimit}
403
- effectiveValue={effectiveConfig?.historyLimit}
404
- placeholder="10000"
405
- onChange={(v) => onChange('historyLimit', v)}
406
- />
387
+ <ConfigField
388
+ label="Browser"
389
+ help="Browser for automatic launch; macOS app names are supported"
390
+ value={config.browser ?? ''}
391
+ effectiveValue={effectiveConfig?.browser}
392
+ placeholder="System default"
393
+ onChange={(v) => onChange('browser', v || undefined)}
394
+ />
395
+ </>
396
+ )}
397
+ </ConfigSection>
398
+
399
+ <ConfigSection title="AI Tools">
400
+ <MCPSection
401
+ mcpEnabled={config.mcp ?? true}
402
+ onToggle={(v) => onChange('mcp', v)}
403
+ isDesktop={isDesktop}
404
+ portPinned={config.port != null && config.port > 0}
405
+ onPinPort={(port) => onChange('port', port)}
406
+ />
407
+ </ConfigSection>
408
+
409
+ <ConfigSection title="Timeline">
410
+ <div>
411
+ <label className="block text-sm font-medium text-theme-text-primary mb-1">
412
+ Storage Backend
413
+ </label>
414
+ <select
415
+ value={config.timelineStorage ?? 'memory'}
416
+ onChange={(e) => onChange('timelineStorage', e.target.value === 'memory' ? undefined : e.target.value as 'sqlite')}
417
+ className="w-full px-3 py-1.5 text-sm bg-theme-elevated border border-theme-border rounded-md text-theme-text-primary focus:outline-none focus:border-skyhook-500"
418
+ >
419
+ <option value="memory">Memory (default)</option>
420
+ <option value="sqlite">SQLite (persistent)</option>
421
+ </select>
422
+ <EffectiveHint current={config.timelineStorage} effective={effectiveConfig?.timelineStorage} />
407
423
  </div>
408
- </div>
409
424
 
410
- <div className="border-t border-theme-border pt-4 mt-4">
411
- <h4 className="text-xs font-medium text-theme-text-secondary uppercase tracking-wider mb-3">Integrations</h4>
412
-
413
- <div className="space-y-4">
414
- <ConfigField
415
- label="Prometheus URL"
416
- help="Manual Prometheus/VictoriaMetrics URL (skips auto-discovery)"
417
- value={config.prometheusUrl ?? ''}
418
- effectiveValue={effectiveConfig?.prometheusUrl}
419
- placeholder="http://prometheus-server.monitoring:9090"
420
- onChange={(v) => onChange('prometheusUrl', v || undefined)}
421
- />
422
-
423
- <MCPSection
424
- mcpEnabled={config.mcp ?? true}
425
- onToggle={(v) => onChange('mcp', v)}
426
- isDesktop={isDesktop}
427
- portPinned={config.port != null && config.port > 0}
428
- onPinPort={(port) => onChange('port', port)}
429
- />
430
- </div>
431
- </div>
425
+ <ConfigNumberField
426
+ label="History Limit"
427
+ help="Maximum events to retain"
428
+ value={config.historyLimit}
429
+ effectiveValue={effectiveConfig?.historyLimit}
430
+ placeholder="10000"
431
+ onChange={(v) => onChange('historyLimit', v)}
432
+ />
433
+ </ConfigSection>
434
+
435
+ <ConfigSection title="Integrations">
436
+ <PrometheusConfigField
437
+ value={config.prometheusUrl ?? ''}
438
+ configuredHeaderKeys={prometheusHeaderKeys}
439
+ onChange={(v) => onChange('prometheusUrl', v || undefined)}
440
+ />
441
+ </ConfigSection>
432
442
  </div>
433
443
  )
434
444
  }
@@ -466,7 +476,7 @@ function MCPSection({
466
476
  return (
467
477
  <div className="space-y-3">
468
478
  <ConfigToggle
469
- label="MCP Server (AI tools)"
479
+ label="MCP Server"
470
480
  value={mcpEnabled}
471
481
  onChange={onToggle}
472
482
  />
@@ -479,13 +489,14 @@ function MCPSection({
479
489
  <code className="flex-1 px-2.5 py-1.5 text-xs font-mono bg-theme-elevated border border-theme-border rounded-md text-theme-text-primary truncate">
480
490
  {mcpUrl}
481
491
  </code>
492
+ <Tooltip content="Copy MCP URL" wrapperClassName="shrink-0">
482
493
  <button
483
494
  onClick={handleCopy}
484
- className="shrink-0 p-1.5 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-elevated rounded-md transition-colors"
485
- title="Copy MCP URL"
495
+ className="p-1.5 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-elevated rounded-md transition-colors"
486
496
  >
487
497
  {copied ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
488
498
  </button>
499
+ </Tooltip>
489
500
  </div>
490
501
  </div>
491
502
 
@@ -515,6 +526,225 @@ function MCPSection({
515
526
  )
516
527
  }
517
528
 
529
+ // -- Prometheus (live-appliable) ----------------------------------------------
530
+
531
+ // Unlike the rest of this dialog, the Prometheus URL can be re-pointed without
532
+ // a restart — the metrics path reads it from a mutable global. "Apply now" hits
533
+ // PUT /integrations/prometheus, which persists the URL AND re-points the running
534
+ // client, then probes it so we can confirm reachability inline. The global Save
535
+ // still persists this field too (taking effect next launch, like everything
536
+ // else); Apply is the shortcut to "now". No EffectiveHint here — the per-field
537
+ // restart-diff hint would contradict the whole point of applying live.
538
+ type ApplyState =
539
+ | { status: 'idle' }
540
+ | { status: 'applying' }
541
+ | { status: 'connected'; address: string }
542
+ | { status: 'unreachable'; error: string } // persisted, but the probe failed
543
+ | { status: 'failed'; error: string } // request itself failed — nothing saved
544
+
545
+ type HeaderRow = { key: string; value: string }
546
+
547
+ function PrometheusConfigField({
548
+ value,
549
+ onChange,
550
+ configuredHeaderKeys,
551
+ }: {
552
+ value: string
553
+ onChange: (value: string) => void
554
+ configuredHeaderKeys: string[]
555
+ }) {
556
+ const [apply, setApply] = useState<ApplyState>({ status: 'idle' })
557
+ // null = not editing headers (preserve what's stored). A non-null array means
558
+ // the user opened the editor; on Apply we send it verbatim, replacing all
559
+ // stored headers (values are write-only, so the server never sends them back).
560
+ const [headerRows, setHeaderRows] = useState<HeaderRow[] | null>(null)
561
+ // Show the server's configured header keys, but let a successful apply override
562
+ // optimistically (config isn't refetched). Derived from the prop — not a
563
+ // mount-time snapshot — so it stays correct as config loads asynchronously.
564
+ const [appliedKeys, setAppliedKeys] = useState<string[] | null>(null)
565
+ const storedKeys = appliedKeys ?? configuredHeaderKeys
566
+
567
+ const clearStatus = () => {
568
+ if (apply.status !== 'applying') setApply({ status: 'idle' })
569
+ }
570
+
571
+ // Footer Reset (and any external edit) clears the URL field without a keystroke;
572
+ // drop a stale "Connected"/"Saved" status so it doesn't describe an emptied field.
573
+ useEffect(() => {
574
+ setApply((s) => (s.status === 'idle' || s.status === 'applying' ? s : { status: 'idle' }))
575
+ }, [value])
576
+
577
+ const handleApply = async () => {
578
+ setApply({ status: 'applying' })
579
+ // Decide what to do with headers. undefined = leave them untouched. Only send
580
+ // a replacement when the editor has real content, or {} when the user emptied
581
+ // every row (explicit clear) — blank in-progress rows must NOT wipe stored
582
+ // secrets just because the editor happens to be open for a URL-only change.
583
+ let editedHeaders: Record<string, string> | undefined
584
+ if (headerRows !== null) {
585
+ const entered = Object.fromEntries(
586
+ headerRows
587
+ .map((r) => [r.key.trim(), r.value] as const)
588
+ .filter(([k, v]) => k !== '' && v !== '')
589
+ )
590
+ if (Object.keys(entered).length > 0) editedHeaders = entered
591
+ else if (headerRows.length === 0) editedHeaders = {}
592
+ }
593
+ try {
594
+ const res = await fetch(apiUrl('/integrations/prometheus'), {
595
+ method: 'PUT',
596
+ credentials: getCredentialsMode(),
597
+ headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
598
+ body: JSON.stringify({
599
+ prometheusUrl: value.trim(),
600
+ ...(editedHeaders !== undefined ? { headers: editedHeaders } : {}),
601
+ }),
602
+ })
603
+ const data = await res.json().catch(() => null)
604
+ if (!res.ok) {
605
+ setApply({ status: 'failed', error: data?.error || res.statusText })
606
+ return
607
+ }
608
+ if (editedHeaders !== undefined) {
609
+ setAppliedKeys(Object.keys(editedHeaders).sort())
610
+ }
611
+ if (headerRows !== null) {
612
+ setHeaderRows(null)
613
+ }
614
+ if (data?.connected) {
615
+ setApply({ status: 'connected', address: data.address || value.trim() })
616
+ } else {
617
+ setApply({ status: 'unreachable', error: data?.error || 'not reachable' })
618
+ }
619
+ } catch (err) {
620
+ setApply({ status: 'failed', error: String(err) })
621
+ }
622
+ }
623
+
624
+ return (
625
+ <div>
626
+ <label className="block text-sm font-medium text-theme-text-primary mb-1">
627
+ Prometheus URL
628
+ </label>
629
+ <p className="text-xs text-theme-text-tertiary mb-1">
630
+ Manual Prometheus/VictoriaMetrics URL (skips auto-discovery)
631
+ </p>
632
+ <div className="flex items-center gap-2">
633
+ <input
634
+ type="text"
635
+ value={value}
636
+ onChange={(e) => onChange(e.target.value)}
637
+ placeholder="http://prometheus-server.monitoring:9090"
638
+ className="flex-1 min-w-0 px-3 py-1.5 text-sm bg-theme-elevated border border-theme-border rounded-md text-theme-text-primary placeholder:text-theme-text-tertiary focus:outline-none focus:border-skyhook-500"
639
+ />
640
+ <Tooltip content="Apply this URL to the running server now — no restart" wrapperClassName="shrink-0">
641
+ <button
642
+ onClick={handleApply}
643
+ disabled={apply.status === 'applying'}
644
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated border border-theme-border rounded-md transition-colors disabled:opacity-50"
645
+ >
646
+ {apply.status === 'applying'
647
+ ? <Loader2 className="w-3.5 h-3.5 animate-spin" />
648
+ : <Plug className="w-3.5 h-3.5" />}
649
+ Apply now
650
+ </button>
651
+ </Tooltip>
652
+ </div>
653
+ {apply.status === 'connected' ? (
654
+ <p className="mt-1 flex items-center gap-1 text-xs text-green-600 dark:text-green-400/80">
655
+ <Check className="w-3 h-3 shrink-0" />
656
+ Connected to {apply.address} — applied, no restart needed
657
+ </p>
658
+ ) : apply.status === 'unreachable' ? (
659
+ <p className="mt-1 text-xs text-amber-600 dark:text-amber-400/80">
660
+ Saved, but not reachable: {apply.error}
661
+ </p>
662
+ ) : apply.status === 'failed' ? (
663
+ <p className="mt-1 text-xs text-red-600 dark:text-red-400/80">
664
+ Couldn't apply: {apply.error}
665
+ </p>
666
+ ) : (
667
+ <p className="mt-1 text-xs text-theme-text-tertiary">
668
+ Applies immediately — no restart needed.
669
+ </p>
670
+ )}
671
+
672
+ {/* Auth headers — for token / multi-tenant backends (Bearer, X-Scope-OrgID). */}
673
+ <div className="mt-3">
674
+ {headerRows === null ? (
675
+ <div className="flex items-center justify-between gap-2">
676
+ <span className="text-xs text-theme-text-tertiary">
677
+ {storedKeys.length > 0
678
+ ? <>Auth headers: <span className="text-theme-text-secondary">{storedKeys.join(', ')}</span> <span className="text-theme-text-disabled">(values hidden)</span></>
679
+ : 'No auth headers'}
680
+ </span>
681
+ <button
682
+ onClick={() => { setHeaderRows([{ key: '', value: '' }]); clearStatus() }}
683
+ className="shrink-0 text-xs font-medium text-accent-text hover:underline"
684
+ >
685
+ {storedKeys.length > 0 ? 'Edit headers' : 'Add auth headers'}
686
+ </button>
687
+ </div>
688
+ ) : (
689
+ <div className="rounded-md border border-theme-border bg-theme-elevated/40 p-2.5 space-y-2">
690
+ {headerRows.map((row, i) => (
691
+ <div key={i} className="flex items-center gap-2">
692
+ <input
693
+ type="text"
694
+ value={row.key}
695
+ onChange={(e) => {
696
+ setHeaderRows((rows) => rows!.map((r, j) => j === i ? { ...r, key: e.target.value } : r))
697
+ clearStatus()
698
+ }}
699
+ placeholder="Header (e.g. Authorization)"
700
+ className="flex-1 min-w-0 px-2.5 py-1.5 text-xs bg-theme-base border border-theme-border rounded-md text-theme-text-primary placeholder:text-theme-text-tertiary focus:outline-none focus:border-skyhook-500"
701
+ />
702
+ <input
703
+ type="password"
704
+ value={row.value}
705
+ onChange={(e) => {
706
+ setHeaderRows((rows) => rows!.map((r, j) => j === i ? { ...r, value: e.target.value } : r))
707
+ clearStatus()
708
+ }}
709
+ placeholder="Value (e.g. Bearer …)"
710
+ className="flex-1 min-w-0 px-2.5 py-1.5 text-xs bg-theme-base border border-theme-border rounded-md text-theme-text-primary placeholder:text-theme-text-tertiary focus:outline-none focus:border-skyhook-500"
711
+ />
712
+ <Tooltip content="Remove header" wrapperClassName="shrink-0">
713
+ <button
714
+ onClick={() => setHeaderRows((rows) => rows!.filter((_, j) => j !== i))}
715
+ className="p-1 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-hover rounded"
716
+ >
717
+ <X className="w-3.5 h-3.5" />
718
+ </button>
719
+ </Tooltip>
720
+ </div>
721
+ ))}
722
+ <div className="flex items-center justify-between gap-2">
723
+ <button
724
+ onClick={() => setHeaderRows((rows) => [...rows!, { key: '', value: '' }])}
725
+ className="flex items-center gap-1 text-xs font-medium text-accent-text hover:underline"
726
+ >
727
+ <Plus className="w-3 h-3" /> Add header
728
+ </button>
729
+ <button
730
+ onClick={() => { setHeaderRows(null); clearStatus() }}
731
+ className="text-xs text-theme-text-tertiary hover:text-theme-text-primary"
732
+ >
733
+ Cancel
734
+ </button>
735
+ </div>
736
+ <p className="text-xs text-theme-text-tertiary">
737
+ Saved when you click Apply now. Entered headers replace all stored
738
+ ones — values are hidden, so re-enter any you want to keep. Leave
739
+ blank to keep existing headers unchanged.
740
+ </p>
741
+ </div>
742
+ )}
743
+ </div>
744
+ </div>
745
+ )
746
+ }
747
+
518
748
  // -- Shared Field Components --------------------------------------------------
519
749
 
520
750
  function ConfigField({
@@ -543,7 +773,7 @@ function ConfigField({
543
773
  value={value}
544
774
  onChange={(e) => onChange(e.target.value)}
545
775
  placeholder={placeholder}
546
- className="w-full px-3 py-1.5 text-sm bg-theme-elevated border border-theme-border rounded-md text-theme-text-primary placeholder:text-theme-text-tertiary focus:outline-none focus:border-blue-500"
776
+ className="w-full px-3 py-1.5 text-sm bg-theme-elevated border border-theme-border rounded-md text-theme-text-primary placeholder:text-theme-text-tertiary focus:outline-none focus:border-skyhook-500"
547
777
  />
548
778
  <EffectiveHint current={value || undefined} effective={effectiveValue} />
549
779
  </div>
@@ -603,7 +833,7 @@ function ConfigArrayField({
603
833
  commit(e.target.value)
604
834
  }}
605
835
  placeholder={placeholder}
606
- className="w-full px-3 py-1.5 text-sm bg-theme-elevated border border-theme-border rounded-md text-theme-text-primary placeholder:text-theme-text-tertiary focus:outline-none focus:border-blue-500"
836
+ className="w-full px-3 py-1.5 text-sm bg-theme-elevated border border-theme-border rounded-md text-theme-text-primary placeholder:text-theme-text-tertiary focus:outline-none focus:border-skyhook-500"
607
837
  />
608
838
  <EffectiveHint current={canonical(value) || undefined} effective={canonical(effectiveValue) || undefined} />
609
839
  </div>
@@ -636,7 +866,7 @@ function ConfigNumberField({
636
866
  value={value ?? ''}
637
867
  onChange={(e) => onChange(e.target.value ? parseInt(e.target.value, 10) || undefined : undefined)}
638
868
  placeholder={placeholder}
639
- className="w-full px-3 py-1.5 text-sm bg-theme-elevated border border-theme-border rounded-md text-theme-text-primary placeholder:text-theme-text-tertiary focus:outline-none focus:border-blue-500"
869
+ className="w-full px-3 py-1.5 text-sm bg-theme-elevated border border-theme-border rounded-md text-theme-text-primary placeholder:text-theme-text-tertiary focus:outline-none focus:border-skyhook-500"
640
870
  />
641
871
  <EffectiveHint current={value} effective={effectiveValue} />
642
872
  </div>
@@ -39,7 +39,7 @@ export function LargeClusterNamespacePicker({ namespaces, onSelect }: {
39
39
  <div className="max-h-[240px] overflow-y-auto rounded-lg border border-theme-border bg-theme-base">
40
40
  {!namespaces ? (
41
41
  <div className="px-3 py-6 text-center text-sm text-theme-text-tertiary">
42
- Loading namespaces...
42
+ Loading namespaces
43
43
  </div>
44
44
  ) : filtered.length === 0 ? (
45
45
  <div className="px-3 py-6 text-center text-sm text-theme-text-tertiary">