@skyhook-io/radar-app 1.14.5 → 1.14.6

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 (83) hide show
  1. package/README.md +2 -9
  2. package/package.json +6 -6
  3. package/src/App.tsx +91 -328
  4. package/src/RadarApp.tsx +3 -8
  5. package/src/api/client.jobset.test.ts +18 -0
  6. package/src/api/client.resource-identity.test.ts +44 -0
  7. package/src/api/client.rightsizing.test.ts +7 -3
  8. package/src/api/client.ts +233 -78
  9. package/src/api/preferences.test.ts +54 -0
  10. package/src/api/preferences.ts +38 -0
  11. package/src/components/CloudFunnelButton.test.tsx +155 -0
  12. package/src/components/CloudFunnelButton.tsx +14 -8
  13. package/src/components/ContextSwitcher.tsx +13 -132
  14. package/src/components/applications/ApplicationsView.identity.test.ts +39 -0
  15. package/src/components/applications/ApplicationsView.tsx +5 -3
  16. package/src/components/audit/AuditSettingsDialog.tsx +15 -11
  17. package/src/components/diagnose/AgentControls.tsx +26 -3
  18. package/src/components/diagnose/DiagnoseContext.tsx +1 -0
  19. package/src/components/diagnose/agentCatalog.ts +6 -0
  20. package/src/components/diagnose/launch.test.ts +23 -0
  21. package/src/components/diagnose/launch.ts +4 -0
  22. package/src/components/diagnose/parts.test.tsx +18 -0
  23. package/src/components/dock/NodeTerminalTab.test.tsx +34 -0
  24. package/src/components/dock/NodeTerminalTab.tsx +5 -3
  25. package/src/components/dock/WorkloadLogsTab.test.tsx +66 -0
  26. package/src/components/dock/WorkloadLogsTab.tsx +2 -1
  27. package/src/components/execution/BatchExecutionView.render.test.tsx +2 -1
  28. package/src/components/execution/BatchExecutionView.test.ts +52 -1
  29. package/src/components/execution/BatchExecutionView.tsx +324 -65
  30. package/src/components/execution/JobSetAdmission.test.tsx +42 -0
  31. package/src/components/execution/JobSetAdmission.tsx +17 -0
  32. package/src/components/execution/JobSetMemberComparison.test.tsx +30 -0
  33. package/src/components/execution/JobSetMemberComparison.tsx +88 -0
  34. package/src/components/execution/batch-run-actions.test.ts +1 -0
  35. package/src/components/execution/batch-timeline.test.ts +3 -0
  36. package/src/components/execution/execution-definition.test.ts +15 -1
  37. package/src/components/execution/execution-definition.ts +4 -0
  38. package/src/components/execution/member-collection.render.test.tsx +142 -0
  39. package/src/components/gitops/GitOpsView.tsx +33 -2
  40. package/src/components/gitops/destination-toast.test.ts +33 -0
  41. package/src/components/gitops/destination-toast.ts +46 -0
  42. package/src/components/gitops/useDestinationCluster.ts +30 -0
  43. package/src/components/helm/TrackChartSourceDialog.tsx +20 -11
  44. package/src/components/home/MCPSetupDialog.tsx +10 -0
  45. package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +75 -22
  46. package/src/components/logs/WorkloadLogsViewer.tsx +7 -4
  47. package/src/components/nav/PrimaryNavRail.tsx +2 -5
  48. package/src/components/resource/PVCUsageBar.render.test.tsx +168 -0
  49. package/src/components/resource/PVCUsageBar.tsx +62 -16
  50. package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -1
  51. package/src/components/resources/ResourcesView.tsx +3 -3
  52. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +8 -0
  53. package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +39 -0
  54. package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +37 -0
  55. package/src/components/resources/renderers/RayClusterRenderer.test.tsx +18 -0
  56. package/src/components/resources/renderers/RayClusterRenderer.tsx +17 -0
  57. package/src/components/resources/renderers/RayServiceRenderer.test.tsx +69 -0
  58. package/src/components/resources/renderers/RayServiceRenderer.tsx +38 -0
  59. package/src/components/rightsizing/RightsizingScanView.tsx +34 -22
  60. package/src/components/rightsizing/copy.test.ts +7 -0
  61. package/src/components/rightsizing/model.test.ts +2 -1
  62. package/src/components/rightsizing/notices.test.tsx +4 -3
  63. package/src/components/settings/OperatorManagedNotice.tsx +17 -0
  64. package/src/components/settings/SettingsDialog.tsx +157 -42
  65. package/src/components/settings/settings-state.test.ts +18 -0
  66. package/src/components/settings/settings-state.ts +18 -0
  67. package/src/components/timeline/TimelineView.tsx +9 -3
  68. package/src/components/timeline/TimelineView.urlparams.test.ts +18 -1
  69. package/src/components/ui/command-items.ts +0 -3
  70. package/src/components/useContextSwitchFlow.tsx +147 -0
  71. package/src/components/workload/WorkloadView.test.ts +17 -1
  72. package/src/components/workload/WorkloadView.tsx +36 -18
  73. package/src/context/ContextSwitchContext.tsx +18 -2
  74. package/src/context/NavCustomization.tsx +4 -58
  75. package/src/context/ThemeContext.tsx +3 -11
  76. package/src/hooks/useClusterLoadState.ts +2 -2
  77. package/src/hooks/useFavorites.ts +3 -5
  78. package/src/utils/auditBadges.ts +1 -1
  79. package/src/utils/navigation.test.ts +18 -1
  80. package/src/utils/navigation.ts +12 -5
  81. package/src/utils/topology-selection.test.ts +56 -0
  82. package/src/utils/topology-selection.ts +9 -9
  83. package/src/components/ui/CommandPalette.tsx +0 -261
@@ -30,16 +30,13 @@ import {
30
30
  costIntegrationUnavailableMessage,
31
31
  costSourceLabel,
32
32
  } from '../cost/source'
33
- import { costSourceApplyLabel, shouldOfferCostReview, shouldShowSettingsFooter } from './settings-state'
33
+ import { costSourceApplyLabel, prometheusHeadersFromRows, shouldOfferCostReview, shouldShowSettingsFooter } from './settings-state'
34
34
  import type { SettingsSectionId } from './settings-state'
35
+ import { OperatorManagedNotice } from './OperatorManagedNotice'
35
36
  export type { SettingsSectionId } from './settings-state'
36
37
 
37
- // The loopback URL an MCP client is told to connect to. Shared by the overview
38
- // row and the MCP section: both must carry the base path, or the URL they
39
- // advertise 404s under a subpath deployment.
40
- function mcpLoopbackUrl(): string {
41
- const port = Number(window.location.port) || 80
42
- return `http://localhost:${port}${routePath('/mcp')}`
38
+ function mcpEndpointUrl(): string {
39
+ return `${window.location.origin}${routePath('/mcp')}`
43
40
  }
44
41
 
45
42
  interface Config {
@@ -64,11 +61,15 @@ interface Config {
64
61
  }
65
62
 
66
63
  interface ConfigResponse {
64
+ management: 'local' | 'operator' | 'cloud'
67
65
  file: Config
68
66
  effective: Config
69
67
  isDesktop: boolean
70
68
  openCostCurrencyManaged?: boolean
71
69
  prometheusHeaderKeys?: string[]
70
+ prometheusServerManaged: boolean
71
+ prometheusHeadersManaged: boolean
72
+ prometheusUrlFromFlag: boolean
72
73
  kubecostApiKeySet?: boolean
73
74
  kubecostEnvManaged?: boolean
74
75
  kubecostEnvError?: string
@@ -137,16 +138,12 @@ export function SettingsDialog({
137
138
  const dialogRef = useRef<HTMLDivElement>(null)
138
139
  const { shouldRender, isOpen } = useAnimatedUnmount(open, overlayExitMs('dialog'))
139
140
  const { data: versionInfo } = useVersionCheck()
140
- // Radar configuration (kubeconfig, port, integrations…) is host-level and
141
- // affects every user of this instance, so it's gated to owners. Personal
142
- // sections (My permissions, AI investigations) stay usable by everyone. Non-Cloud
143
- // callers (OSS, OIDC, kubectl plugin) have no role and pass — single-user
144
- // laptops are never locked out of their own config. Backend enforces this too.
145
141
  const { canAtLeast } = useCloudRole()
146
142
  const capabilities = useCapabilitiesContext()
147
- const canEditConfig = canAtLeast('owner')
148
143
 
149
144
  const [configData, setConfigData] = useState<ConfigResponse | null>(null)
145
+ const operatorManaged = configData?.management === 'operator'
146
+ const canEditConfig = configData != null && !operatorManaged && canAtLeast('owner')
150
147
  const [editedConfig, setEditedConfig] = useState<Config>({})
151
148
  const [saving, setSaving] = useState(false)
152
149
  const [saveMessage, setSaveMessage] = useState<string | null>(null)
@@ -209,6 +206,7 @@ export function SettingsDialog({
209
206
  // actually accessible to the current identity.
210
207
  useEffect(() => {
211
208
  if (!open) return
209
+ setConfigData(null)
212
210
  setSaveMessage(null)
213
211
  setLoadError(null)
214
212
  setConfirmingClose(false)
@@ -229,7 +227,7 @@ export function SettingsDialog({
229
227
  })
230
228
  .then((data: ConfigResponse) => {
231
229
  setConfigData(data)
232
- setEditedConfig(data.file)
230
+ setEditedConfig({ ...data.file, prometheusUrl: data.effective.prometheusUrl })
233
231
  })
234
232
  .catch((err) => {
235
233
  console.warn('[settings] Failed to load config:', err)
@@ -361,7 +359,7 @@ export function SettingsDialog({
361
359
  // live integration fields, so restoring it drops drafts without touching
362
360
  // what's saved.
363
361
  if (!configData) return
364
- setEditedConfig(configData.file)
362
+ setEditedConfig({ ...configData.file, prometheusUrl: configData.effective.prometheusUrl })
365
363
  setCostCredentialDirty(false)
366
364
  setCostDraftReset((current) => current + 1)
367
365
  setSaveMessage(null)
@@ -535,7 +533,7 @@ export function SettingsDialog({
535
533
  key={i.id}
536
534
  item={i}
537
535
  active={section === i.id}
538
- disabled={i.ownerOnly && !canEditConfig}
536
+ disabled={i.ownerOnly && !operatorManaged && !canAtLeast('owner')}
539
537
  onSelect={() => setSection(i.id)}
540
538
  />
541
539
  ))}
@@ -549,7 +547,7 @@ export function SettingsDialog({
549
547
  item={i}
550
548
  horizontal
551
549
  active={section === i.id}
552
- disabled={i.ownerOnly && !canEditConfig}
550
+ disabled={i.ownerOnly && !operatorManaged && !canAtLeast('owner')}
553
551
  onSelect={() => setSection(i.id)}
554
552
  />
555
553
  ))}
@@ -564,12 +562,16 @@ export function SettingsDialog({
564
562
  </div>
565
563
  )}
566
564
 
565
+ {!configData && !['overview', 'perms', 'ai'].includes(section) ? (
566
+ <p className="text-sm text-theme-text-secondary">{loadError ? 'Configuration is unavailable. Close Settings and try again.' : 'Loading configuration…'}</p>
567
+ ) : <>
568
+ {operatorManaged && section !== 'perms' && section !== 'ai' && <div className="mb-4"><OperatorManagedNotice /></div>}
567
569
  {/* Overview — status at a glance; the landing section */}
568
570
  <div className={clsx(section !== 'overview' && 'hidden')} role="tabpanel" inert={section !== 'overview' || undefined}>
569
571
  <div className="mb-1">
570
572
  <h3 className="text-base font-semibold text-theme-text-primary">Overview</h3>
571
573
  <p className="mt-0.5 text-xs text-theme-text-tertiary">
572
- What this Radar is connected to right now — select a row to manage it.
574
+ What this Radar is connected to right now — select a row for details.
573
575
  </p>
574
576
  </div>
575
577
  <div className="mt-3">
@@ -596,6 +598,7 @@ export function SettingsDialog({
596
598
  id="connection"
597
599
  active={section}
598
600
  title="Connection"
601
+ managed={operatorManaged ? <OperatorSettingsSummary section="connection" config={configData} /> : undefined}
599
602
  caption="Takes effect on next launch."
600
603
  locked={!canEditConfig}
601
604
  >
@@ -625,7 +628,8 @@ export function SettingsDialog({
625
628
  id="prometheus"
626
629
  active={section}
627
630
  title="Metrics"
628
- caption="Applies immediately — no restart."
631
+ managed={operatorManaged ? <OperatorSettingsSummary section="prometheus" config={configData} /> : undefined}
632
+ caption="Connect and manage your metrics backend."
629
633
  live
630
634
  locked={!canEditConfig}
631
635
  >
@@ -633,10 +637,17 @@ export function SettingsDialog({
633
637
  local={deploymentMode === 'local'}
634
638
  value={editedConfig.prometheusUrl ?? ''}
635
639
  configuredHeaderKeys={configData?.prometheusHeaderKeys ?? []}
640
+ serverManaged={configData?.prometheusServerManaged === true}
641
+ headersManaged={configData?.prometheusHeadersManaged === true}
642
+ urlFromFlag={configData?.prometheusUrlFromFlag === true}
636
643
  onChange={(v) => updateConfigField('prometheusUrl', v || undefined)}
637
644
  onApplied={(url) =>
638
645
  setConfigData((prev) =>
639
- prev ? { ...prev, file: { ...prev.file, prometheusUrl: url || undefined } } : prev
646
+ prev ? {
647
+ ...prev,
648
+ file: { ...prev.file, prometheusUrl: url || undefined },
649
+ effective: { ...prev.effective, prometheusUrl: url || undefined },
650
+ } : prev
640
651
  )
641
652
  }
642
653
  />
@@ -646,6 +657,7 @@ export function SettingsDialog({
646
657
  id="cost"
647
658
  active={section}
648
659
  title="Cost"
660
+ managed={operatorManaged ? <OperatorSettingsSummary section="cost" config={configData} /> : undefined}
649
661
  caption="Choose where Radar gets cost data and how amounts are labeled."
650
662
  live
651
663
  locked={!canEditConfig}
@@ -690,6 +702,7 @@ export function SettingsDialog({
690
702
  id="argocd"
691
703
  active={section}
692
704
  title="Argo CD"
705
+ managed={operatorManaged ? <OperatorSettingsSummary section="argocd" config={configData} /> : undefined}
693
706
  caption="Applies immediately — no restart."
694
707
  live
695
708
  locked={!canEditConfig}
@@ -739,12 +752,14 @@ export function SettingsDialog({
739
752
  <div className="mb-4">
740
753
  <h3 className="text-base font-semibold text-theme-text-primary">AI investigations</h3>
741
754
  <p className="mt-0.5 text-xs text-theme-text-tertiary">
742
- {diag.hosted
755
+ {operatorManaged
756
+ ? 'AI investigations are available in local Radar and Radar Cloud.'
757
+ : diag.hosted
743
758
  ? `Investigate incidents with ${diag.agentLabel} — reading logs, events, and topology to understand what's happening.`
744
759
  : "Investigate incidents with an AI agent that runs on your own machine — reading logs, events, and topology to understand what's happening. No Radar cloud, no API key."}
745
760
  </p>
746
761
  </div>
747
- {aiAvailable ? (
762
+ {operatorManaged ? <p className="text-sm text-theme-text-secondary">Local AI investigations are unavailable in a shared installation. Use local Radar with an agent CLI, or Radar Cloud.</p> : aiAvailable ? (
748
763
  <div className="space-y-4">
749
764
  <AISettingsSection
750
765
  available={diag.available}
@@ -786,6 +801,7 @@ export function SettingsDialog({
786
801
  id="advanced"
787
802
  active={section}
788
803
  title="Advanced"
804
+ managed={operatorManaged ? <OperatorSettingsSummary section="advanced" config={configData} /> : undefined}
789
805
  caption="Takes effect on next launch."
790
806
  locked={!canEditConfig}
791
807
  >
@@ -808,6 +824,7 @@ export function SettingsDialog({
808
824
  />
809
825
  </div>
810
826
  </SectionPane>
827
+ </>}
811
828
  </div>
812
829
  </div>
813
830
 
@@ -1009,6 +1026,7 @@ function SectionPane({
1009
1026
  caption,
1010
1027
  live,
1011
1028
  locked,
1029
+ managed,
1012
1030
  children,
1013
1031
  }: {
1014
1032
  id: SettingsSectionId
@@ -1017,6 +1035,7 @@ function SectionPane({
1017
1035
  caption?: string
1018
1036
  live?: boolean
1019
1037
  locked?: boolean
1038
+ managed?: ReactNode
1020
1039
  children: ReactNode
1021
1040
  }) {
1022
1041
  return (
@@ -1025,7 +1044,7 @@ function SectionPane({
1025
1044
  <h3 className="text-base font-semibold text-theme-text-primary">{title}</h3>
1026
1045
  {!locked && caption && <SectionCaption live={live}>{caption}</SectionCaption>}
1027
1046
  </div>
1028
- {locked ? <LockWall /> : <div className="space-y-4">{children}</div>}
1047
+ {managed ?? (locked ? <LockWall /> : <div className="space-y-4">{children}</div>)}
1029
1048
  </div>
1030
1049
  )
1031
1050
  }
@@ -1044,6 +1063,70 @@ function SectionCaption({ children, live }: { children: ReactNode; live?: boolea
1044
1063
  )
1045
1064
  }
1046
1065
 
1066
+ function OperatorSettingsSummary({ section, config }: { section: SettingsSectionId; config: ConfigResponse }) {
1067
+ const { data: prom, error: promError } = usePrometheusStatus()
1068
+ const { data: argo, error: argoError } = useArgoStatus(section === 'argocd')
1069
+ const { data: cost, error: costError } = useOpenCostSummary()
1070
+ const { data: cluster } = useClusterInfo()
1071
+ const c = config.effective
1072
+ let rows: [string, ReactNode][] = []
1073
+ let detail: string | undefined
1074
+ switch (section) {
1075
+ case 'connection':
1076
+ rows = [['Cluster connection', cluster?.inCluster ? 'Pod service account' : cluster?.context ?? 'Connecting…'], ['Server port', c.port ?? '—']]
1077
+ detail = 'The operator selects the cluster and connection settings at startup.'
1078
+ break
1079
+ case 'prometheus':
1080
+ rows = [
1081
+ ['Status', promError ? 'Status unavailable' : !prom ? 'Checking…' : prom.connected ? 'Connected' : prom.discovering ? 'Discovering…' : 'Not connected'],
1082
+ ['Endpoint', prom?.address || c.prometheusUrl || 'Auto-discovery'],
1083
+ ['Headers', config.prometheusHeaderKeys?.join(', ') || 'None configured'],
1084
+ ]
1085
+ detail = prom?.error
1086
+ break
1087
+ case 'argocd':
1088
+ rows = [
1089
+ ['Status', argoError ? 'Status unavailable' : !argo ? 'Checking…' : argo.connected ? 'Connected' : 'Not connected'],
1090
+ ['Endpoint', argo?.address || c.argoCdUrl || 'Auto-discovery'],
1091
+ ['Token', config.argoCdTokenSet ? 'Configured' : 'None configured'],
1092
+ ['TLS verification', c.argoCdInsecureTls ? 'Disabled' : 'Enabled'],
1093
+ ]
1094
+ detail = config.argoCdEnvError || argo?.reason
1095
+ break
1096
+ case 'cost':
1097
+ rows = [
1098
+ ['Status', costError ? 'Status unavailable' : !cost ? 'Checking…' : cost.available ? 'Available' : 'Unavailable'],
1099
+ ['Source preference', c.costSource || 'Auto'],
1100
+ ['Kubecost endpoint', c.kubecostUrl || 'Auto-discovery'],
1101
+ ['Cluster ID', c.kubecostClusterId || 'Auto-detected when available'],
1102
+ ['API key', config.kubecostApiKeySet ? 'Configured' : 'None configured'],
1103
+ ['Currency override', c.opencostCurrency || 'Automatic'],
1104
+ ]
1105
+ detail = config.kubecostEnvError || (cost?.reason ? costIntegrationUnavailableMessage(cost.reason) ?? undefined : undefined)
1106
+ break
1107
+ case 'advanced':
1108
+ rows = [
1109
+ ['MCP', c.mcp ? 'Enabled' : 'Disabled'],
1110
+ ['Timeline storage', c.timelineStorage || 'Memory'],
1111
+ ['Event history limit', c.historyLimit ?? '—'],
1112
+ ]
1113
+ break
1114
+ }
1115
+ return (
1116
+ <div className="space-y-3">
1117
+ <dl className="divide-y divide-theme-border-subtle rounded-lg border border-theme-border px-3">
1118
+ {rows.map(([label, value]) => (
1119
+ <div key={label} className="grid grid-cols-[140px_minmax(0,1fr)] gap-3 py-3 text-sm">
1120
+ <dt className="text-theme-text-tertiary">{label}</dt>
1121
+ <dd className="min-w-0 break-words text-theme-text-primary">{value}</dd>
1122
+ </div>
1123
+ ))}
1124
+ </dl>
1125
+ {detail && <p className="text-sm text-theme-text-secondary break-words">{detail}</p>}
1126
+ </div>
1127
+ )
1128
+ }
1129
+
1047
1130
  function LockWall() {
1048
1131
  return (
1049
1132
  <div className="rounded-md border border-theme-border bg-theme-elevated/50 p-4 flex items-start gap-3">
@@ -1096,7 +1179,7 @@ function OverviewPanel({ active, onNavigate }: { active: boolean; onNavigate: (s
1096
1179
  const agentLabel =
1097
1180
  diag.agents.find((a) => a.name === diag.selectedAgent)?.label ?? diag.agents[0]?.label
1098
1181
  const mcpOn = capabilities.mcpEnabled
1099
- const mcpUrl = mcpLoopbackUrl()
1182
+ const mcpUrl = mcpEndpointUrl()
1100
1183
  const costMissing = cost?.reason === 'no_prometheus' || cost?.reason === 'no_cost_source' || cost?.reason === 'no_metrics'
1101
1184
  const costUnavailableDetail = cost?.reason === 'no_prometheus'
1102
1185
  ? 'Connect OpenCost metrics in Metrics.'
@@ -1240,9 +1323,10 @@ function AIUnavailableNotice() {
1240
1323
  <p className="text-sm font-medium text-theme-text-primary">No supported agent CLI found</p>
1241
1324
  <p className="mt-1 text-xs text-theme-text-tertiary">
1242
1325
  Install <span className="text-theme-text-secondary">Claude Code</span>,{' '}
1243
- <span className="text-theme-text-secondary">Codex</span>, or{' '}
1326
+ <span className="text-theme-text-secondary">Codex</span>,{' '}
1244
1327
  <span className="text-theme-text-secondary">Cursor</span> (
1245
- <span className="font-mono">cursor-agent</span>), then restart Radar — this tab
1328
+ <span className="font-mono">cursor-agent</span>), or{' '}
1329
+ <span className="text-theme-text-secondary">OpenCode</span>, then restart Radar — this tab
1246
1330
  will show the agent, model, and effort controls.
1247
1331
  </p>
1248
1332
  </div>
@@ -1812,7 +1896,7 @@ function MCPSection({
1812
1896
  const [copied, setCopied] = useState(false)
1813
1897
 
1814
1898
  const currentPort = Number(window.location.port) || 80
1815
- const mcpUrl = mcpLoopbackUrl()
1899
+ const mcpUrl = mcpEndpointUrl()
1816
1900
 
1817
1901
  const handleCopy = () => {
1818
1902
  navigator.clipboard.writeText(mcpUrl)
@@ -1915,12 +1999,18 @@ function PrometheusConfigField({
1915
1999
  value,
1916
2000
  onChange,
1917
2001
  configuredHeaderKeys,
2002
+ serverManaged,
2003
+ headersManaged,
2004
+ urlFromFlag,
1918
2005
  onApplied,
1919
2006
  }: {
1920
2007
  local: boolean
1921
2008
  value: string
1922
2009
  onChange: (value: string) => void
1923
2010
  configuredHeaderKeys: string[]
2011
+ serverManaged: boolean
2012
+ headersManaged: boolean
2013
+ urlFromFlag: boolean
1924
2014
  onApplied?: (url: string) => void
1925
2015
  }) {
1926
2016
  const [apply, setApply] = useState<ApplyState>({ status: 'idle' })
@@ -1950,17 +2040,8 @@ function PrometheusConfigField({
1950
2040
  // a replacement when the editor has real content, or {} when the user emptied
1951
2041
  // every row (explicit clear) — blank in-progress rows must NOT wipe stored
1952
2042
  // secrets just because the editor happens to be open for a URL-only change.
1953
- let editedHeaders: Record<string, string> | undefined
1954
- if (headerRows !== null) {
1955
- const entered = Object.fromEntries(
1956
- headerRows
1957
- .map((r) => [r.key.trim(), r.value] as const)
1958
- .filter(([k, v]) => k !== '' && v !== '')
1959
- )
1960
- if (Object.keys(entered).length > 0) editedHeaders = entered
1961
- else if (headerRows.length === 0) editedHeaders = {}
1962
- }
1963
2043
  try {
2044
+ const editedHeaders = prometheusHeadersFromRows(headerRows)
1964
2045
  const res = await fetch(apiUrl('/integrations/prometheus'), {
1965
2046
  method: 'PUT',
1966
2047
  credentials: getCredentialsMode(),
@@ -2007,11 +2088,12 @@ function PrometheusConfigField({
2007
2088
  <div className="flex items-center gap-2">
2008
2089
  <Input
2009
2090
  value={value}
2091
+ disabled={apply.status === 'applying'}
2010
2092
  onChange={(e) => onChange(e.target.value)}
2011
2093
  placeholder="http://prometheus-server.monitoring:9090"
2012
2094
  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"
2013
2095
  />
2014
- <Tooltip content="Apply the connection now — no restart. Changing the URL or headers clears any workload scope override and resumes automatic identity matching." wrapperClassName="shrink-0">
2096
+ <Tooltip content="Save and apply this connection, then check reachability. Applying clears any workload scope override and resumes automatic identity matching." wrapperClassName="shrink-0">
2015
2097
  <button
2016
2098
  onClick={handleApply}
2017
2099
  disabled={apply.status === 'applying'}
@@ -2039,15 +2121,26 @@ function PrometheusConfigField({
2039
2121
  </p>
2040
2122
  ) : (
2041
2123
  <p className="mt-1 text-xs text-theme-text-tertiary">
2042
- Applies immediately — no restart needed.
2124
+ Saves and applies before checking the connection.
2043
2125
  </p>
2044
2126
  )}
2045
2127
  <p className="mt-2 text-xs text-theme-text-tertiary">
2046
2128
  {local
2047
2129
  ? 'Saved URL and headers apply across all local cluster contexts.'
2048
2130
  : 'Changes affect this Radar installation. Use deployment settings for configuration that survives Pod replacement.'}
2049
- {' URL changes do not clear headers. Replace or clear credentials before switching backends.'}
2131
+ {' Changing servers requires replacing or clearing the saved headers.'}
2050
2132
  </p>
2133
+ {serverManaged && (
2134
+ <p className="mt-2 text-xs text-theme-text-secondary">
2135
+ Server controlled by startup configuration. To set or change the server,
2136
+ {local ? ' update the startup flags or environment references and restart Radar.' : ' update the deployment configuration (such as Helm values) and restart Radar.'}
2137
+ </p>
2138
+ )}
2139
+ {urlFromFlag && (
2140
+ <p className="mt-1 text-xs text-theme-text-secondary">
2141
+ Path edits apply until restart; the URL supplied at launch will then be restored.
2142
+ </p>
2143
+ )}
2051
2144
 
2052
2145
  {/* Auth headers — for token / multi-tenant backends (Bearer, X-Scope-OrgID). */}
2053
2146
  <div className="mt-3">
@@ -2058,12 +2151,13 @@ function PrometheusConfigField({
2058
2151
  ? <>Auth headers: <span className="text-theme-text-secondary">{storedKeys.join(', ')}</span> <span className="text-theme-text-disabled">(values hidden)</span></>
2059
2152
  : 'No auth headers'}
2060
2153
  </span>
2061
- <button
2154
+ {!headersManaged && <button
2062
2155
  onClick={() => { setHeaderRows([{ key: '', value: '' }]); clearStatus() }}
2156
+ disabled={apply.status === 'applying'}
2063
2157
  className="shrink-0 text-xs font-medium text-accent-text hover:underline"
2064
2158
  >
2065
2159
  {storedKeys.length > 0 ? 'Edit headers' : 'Add auth headers'}
2066
- </button>
2160
+ </button>}
2067
2161
  </div>
2068
2162
  ) : (
2069
2163
  <div className="rounded-md border border-theme-border bg-theme-elevated/40 p-2.5 space-y-2">
@@ -2071,6 +2165,7 @@ function PrometheusConfigField({
2071
2165
  <div key={i} className="flex items-center gap-2">
2072
2166
  <Input
2073
2167
  value={row.key}
2168
+ disabled={apply.status === 'applying'}
2074
2169
  onChange={(e) => {
2075
2170
  setHeaderRows((rows) => rows!.map((r, j) => j === i ? { ...r, key: e.target.value } : r))
2076
2171
  clearStatus()
@@ -2081,6 +2176,7 @@ function PrometheusConfigField({
2081
2176
  <input
2082
2177
  type="password"
2083
2178
  value={row.value}
2179
+ disabled={apply.status === 'applying'}
2084
2180
  onChange={(e) => {
2085
2181
  setHeaderRows((rows) => rows!.map((r, j) => j === i ? { ...r, value: e.target.value } : r))
2086
2182
  clearStatus()
@@ -2091,6 +2187,7 @@ function PrometheusConfigField({
2091
2187
  <Tooltip content="Remove header" wrapperClassName="shrink-0">
2092
2188
  <button
2093
2189
  onClick={() => setHeaderRows((rows) => rows!.filter((_, j) => j !== i))}
2190
+ disabled={apply.status === 'applying'}
2094
2191
  className="p-1 text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-hover rounded"
2095
2192
  >
2096
2193
  <X className="w-3.5 h-3.5" />
@@ -2101,12 +2198,14 @@ function PrometheusConfigField({
2101
2198
  <div className="flex items-center justify-between gap-2">
2102
2199
  <button
2103
2200
  onClick={() => setHeaderRows((rows) => [...rows!, { key: '', value: '' }])}
2201
+ disabled={apply.status === 'applying'}
2104
2202
  className="flex items-center gap-1 text-xs font-medium text-accent-text hover:underline"
2105
2203
  >
2106
2204
  <Plus className="w-3 h-3" /> Add header
2107
2205
  </button>
2108
2206
  <button
2109
2207
  onClick={() => { setHeaderRows(null); clearStatus() }}
2208
+ disabled={apply.status === 'applying'}
2110
2209
  className="text-xs text-theme-text-tertiary hover:text-theme-text-primary"
2111
2210
  >
2112
2211
  Cancel
@@ -2121,6 +2220,22 @@ function PrometheusConfigField({
2121
2220
  </div>
2122
2221
  )}
2123
2222
  </div>
2223
+ {headersManaged ? (
2224
+ <p className="mt-2 text-xs text-theme-text-secondary">
2225
+ Headers are controlled by startup configuration. Change them at their source and restart Radar.
2226
+ </p>
2227
+ ) : storedKeys.length > 0 && (
2228
+ <button
2229
+ onClick={() => { setHeaderRows([]); clearStatus() }}
2230
+ disabled={apply.status === 'applying'}
2231
+ className="mt-2 text-xs text-theme-text-secondary hover:underline"
2232
+ >
2233
+ Clear saved headers
2234
+ </button>
2235
+ )}
2236
+ {headerRows?.length === 0 && (
2237
+ <p className="mt-1 text-xs text-warning-text">Headers will be cleared when you click Apply now.</p>
2238
+ )}
2124
2239
  </div>
2125
2240
  )
2126
2241
  }
@@ -1,10 +1,28 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import {
3
3
  costSourceApplyLabel,
4
+ prometheusHeadersFromRows,
4
5
  shouldOfferCostReview,
5
6
  shouldShowSettingsFooter,
6
7
  } from "./settings-state";
7
8
 
9
+ describe("Prometheus header edits", () => {
10
+ it("distinguishes unchanged, replacement and explicit clear", () => {
11
+ expect(prometheusHeadersFromRows(null)).toBeUndefined();
12
+ expect(prometheusHeadersFromRows([{ key: "", value: "" }])).toBeUndefined();
13
+ expect(prometheusHeadersFromRows([])).toEqual({});
14
+ expect(prometheusHeadersFromRows([{ key: " Authorization ", value: "Bearer new" }])).toEqual({ Authorization: "Bearer new" });
15
+ });
16
+ it("does not silently discard incomplete or duplicate rows", () => {
17
+ expect(() => prometheusHeadersFromRows([{ key: "Authorization", value: "" }])).toThrow("both a name and value");
18
+ expect(() => prometheusHeadersFromRows([{ key: "", value: "secret" }])).toThrow("both a name and value");
19
+ expect(() => prometheusHeadersFromRows([{ key: "Authorization", value: "a" }, { key: "authorization", value: "b" }])).toThrow("more than once");
20
+ });
21
+ it("preserves valid names that coincide with object properties", () => {
22
+ expect(JSON.stringify(prometheusHeadersFromRows([{ key: "__proto__", value: "value" }]))).toBe('{"__proto__":"value"}');
23
+ });
24
+ });
25
+
8
26
  describe("Cost settings state", () => {
9
27
  it("keeps source drafts inline while the Cost section is open", () => {
10
28
  expect(shouldOfferCostReview(true, "cost")).toBe(false);
@@ -37,3 +37,21 @@ export function costSourceApplyLabel(
37
37
  ): string {
38
38
  return source === 'prometheus' ? 'Apply source' : 'Test & apply source'
39
39
  }
40
+
41
+ export function prometheusHeadersFromRows(
42
+ rows: { key: string; value: string }[] | null,
43
+ ): Record<string, string> | undefined {
44
+ if (rows === null) return undefined
45
+ if (rows.length === 0) return {}
46
+ const entries: [string, string][] = []
47
+ const seen = new Set<string>()
48
+ for (const row of rows) {
49
+ const key = row.key.trim()
50
+ if (!key && !row.value) continue
51
+ if (!key || !row.value) throw new Error('Enter both a name and value for each header, or remove the incomplete row.')
52
+ if (seen.has(key.toLowerCase())) throw new Error(`Header "${key}" is entered more than once.`)
53
+ seen.add(key.toLowerCase())
54
+ entries.push([key, row.value])
55
+ }
56
+ return entries.length ? Object.fromEntries(entries) : undefined
57
+ }
@@ -10,6 +10,7 @@ import {
10
10
  buildAppMembershipIndex,
11
11
  eventsForApplication,
12
12
  isPinnedLaneRef,
13
+ pinnedLaneRefMatches,
13
14
  LIVE_TICK_MS,
14
15
  SEVERITY_TEXT,
15
16
  type ScrubberRange,
@@ -50,6 +51,13 @@ function loadPinnedLanes(): PinnedLaneRef[] {
50
51
  }
51
52
  }
52
53
 
54
+ export function togglePinnedLaneRefs(previous: PinnedLaneRef[], ref: PinnedLaneRef): PinnedLaneRef[] {
55
+ const pinned = previous.some((stored) => pinnedLaneRefMatches(stored, ref))
56
+ return pinned
57
+ ? previous.filter((stored) => !pinnedLaneRefMatches(stored, ref))
58
+ : [...previous, ref]
59
+ }
60
+
53
61
  // Helper to check if topology has meaningfully changed
54
62
  function topologyContentEqual(a: Topology | undefined, b: Topology | undefined): boolean {
55
63
  if (a === b) return true
@@ -348,9 +356,7 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
348
356
  }
349
357
  }, [pinnedLanes])
350
358
  const togglePin = useCallback((ref: PinnedLaneRef) => {
351
- setPinnedLanes((prev) => (
352
- prev.some((p) => p.id === ref.id) ? prev.filter((p) => p.id !== ref.id) : [...prev, ref]
353
- ))
359
+ setPinnedLanes((prev) => togglePinnedLaneRefs(prev, ref))
354
360
  }, [])
355
361
  // pinnedOnly is meaningless with no pins: unpinning the last lane (or landing
356
362
  // on ?pinnedOnly=1 with no stored pins) would otherwise leave the filter stuck
@@ -1,16 +1,33 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import type { ActivityFilterKey, TimelineGrouping, TimelineSort } from '@skyhook-io/k8s-ui'
2
+ import type { ActivityFilterKey, PinnedLaneRef, TimelineGrouping, TimelineSort } from '@skyhook-io/k8s-ui'
3
3
  import {
4
4
  parseTimeMode,
5
5
  resolveApplicationTimelineScope,
6
6
  writeTimelineParams,
7
7
  onlyHighFreqDiffer,
8
8
  timeModeEqual,
9
+ togglePinnedLaneRefs,
9
10
  type TimelineMode,
10
11
  type PersistedTimelineState,
11
12
  type TimelineViewMode,
12
13
  } from './TimelineView'
13
14
 
15
+ describe('togglePinnedLaneRefs', () => {
16
+ it('unpins a migrated legacy CRD pin by its canonical live identity', () => {
17
+ const legacy: PinnedLaneRef = { id: 'Cluster/prod/main', kind: 'Cluster', namespace: 'prod', name: 'main' }
18
+ const live: PinnedLaneRef = { id: 'Cluster.postgresql.cnpg.io/prod/main', kind: 'Cluster', group: 'postgresql.cnpg.io', namespace: 'prod', name: 'main' }
19
+
20
+ expect(togglePinnedLaneRefs([legacy], live)).toEqual([])
21
+ })
22
+
23
+ it('does not unpin a different explicit API group', () => {
24
+ const cnpg: PinnedLaneRef = { id: 'Cluster.postgresql.cnpg.io/prod/main', kind: 'Cluster', group: 'postgresql.cnpg.io', namespace: 'prod', name: 'main' }
25
+ const capi: PinnedLaneRef = { id: 'Cluster.cluster.x-k8s.io/prod/main', kind: 'Cluster', group: 'cluster.x-k8s.io', namespace: 'prod', name: 'main' }
26
+
27
+ expect(togglePinnedLaneRefs([cnpg], capi)).toEqual([cnpg, capi])
28
+ })
29
+ })
30
+
14
31
  // These mirror the module-private defaults writeTimelineParams omits at (and
15
32
  // parseTimeMode falls back to). Kept in sync deliberately: the round-trip tests
16
33
  // below fail loudly if a module default drifts from what is asserted here.
@@ -146,9 +146,6 @@ const VIEW_ENTRIES: {
146
146
  { view: "cost", label: "Cost", icon: Coins, shortcut: "g c" },
147
147
  ];
148
148
 
149
- // The static command-palette items (Views, Resource Kinds, Contexts,
150
- // Namespaces, Actions) — shared by the centered modal (embedded) and the
151
- // standalone omnibar so the two never drift.
152
149
  export function useCommandItems(cb: CommandItemCallbacks): CommandItem[] {
153
150
  const { data: namespacesData } = useNamespaces();
154
151
  const { data: contexts } = useContexts();