@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.
- package/README.md +2 -9
- package/package.json +6 -6
- package/src/App.tsx +91 -328
- package/src/RadarApp.tsx +3 -8
- package/src/api/client.jobset.test.ts +18 -0
- package/src/api/client.resource-identity.test.ts +44 -0
- package/src/api/client.rightsizing.test.ts +7 -3
- package/src/api/client.ts +233 -78
- package/src/api/preferences.test.ts +54 -0
- package/src/api/preferences.ts +38 -0
- package/src/components/CloudFunnelButton.test.tsx +155 -0
- package/src/components/CloudFunnelButton.tsx +14 -8
- package/src/components/ContextSwitcher.tsx +13 -132
- package/src/components/applications/ApplicationsView.identity.test.ts +39 -0
- package/src/components/applications/ApplicationsView.tsx +5 -3
- package/src/components/audit/AuditSettingsDialog.tsx +15 -11
- package/src/components/diagnose/AgentControls.tsx +26 -3
- package/src/components/diagnose/DiagnoseContext.tsx +1 -0
- package/src/components/diagnose/agentCatalog.ts +6 -0
- package/src/components/diagnose/launch.test.ts +23 -0
- package/src/components/diagnose/launch.ts +4 -0
- package/src/components/diagnose/parts.test.tsx +18 -0
- package/src/components/dock/NodeTerminalTab.test.tsx +34 -0
- package/src/components/dock/NodeTerminalTab.tsx +5 -3
- package/src/components/dock/WorkloadLogsTab.test.tsx +66 -0
- package/src/components/dock/WorkloadLogsTab.tsx +2 -1
- package/src/components/execution/BatchExecutionView.render.test.tsx +2 -1
- package/src/components/execution/BatchExecutionView.test.ts +52 -1
- package/src/components/execution/BatchExecutionView.tsx +324 -65
- package/src/components/execution/JobSetAdmission.test.tsx +42 -0
- package/src/components/execution/JobSetAdmission.tsx +17 -0
- package/src/components/execution/JobSetMemberComparison.test.tsx +30 -0
- package/src/components/execution/JobSetMemberComparison.tsx +88 -0
- package/src/components/execution/batch-run-actions.test.ts +1 -0
- package/src/components/execution/batch-timeline.test.ts +3 -0
- package/src/components/execution/execution-definition.test.ts +15 -1
- package/src/components/execution/execution-definition.ts +4 -0
- package/src/components/execution/member-collection.render.test.tsx +142 -0
- package/src/components/gitops/GitOpsView.tsx +33 -2
- package/src/components/gitops/destination-toast.test.ts +33 -0
- package/src/components/gitops/destination-toast.ts +46 -0
- package/src/components/gitops/useDestinationCluster.ts +30 -0
- package/src/components/helm/TrackChartSourceDialog.tsx +20 -11
- package/src/components/home/MCPSetupDialog.tsx +10 -0
- package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +75 -22
- package/src/components/logs/WorkloadLogsViewer.tsx +7 -4
- package/src/components/nav/PrimaryNavRail.tsx +2 -5
- package/src/components/resource/PVCUsageBar.render.test.tsx +168 -0
- package/src/components/resource/PVCUsageBar.tsx +62 -16
- package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -1
- package/src/components/resources/ResourcesView.tsx +3 -3
- package/src/components/resources/renderers/CAPIClusterRenderer.tsx +8 -0
- package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +39 -0
- package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +37 -0
- package/src/components/resources/renderers/RayClusterRenderer.test.tsx +18 -0
- package/src/components/resources/renderers/RayClusterRenderer.tsx +17 -0
- package/src/components/resources/renderers/RayServiceRenderer.test.tsx +69 -0
- package/src/components/resources/renderers/RayServiceRenderer.tsx +38 -0
- package/src/components/rightsizing/RightsizingScanView.tsx +34 -22
- package/src/components/rightsizing/copy.test.ts +7 -0
- package/src/components/rightsizing/model.test.ts +2 -1
- package/src/components/rightsizing/notices.test.tsx +4 -3
- package/src/components/settings/OperatorManagedNotice.tsx +17 -0
- package/src/components/settings/SettingsDialog.tsx +157 -42
- package/src/components/settings/settings-state.test.ts +18 -0
- package/src/components/settings/settings-state.ts +18 -0
- package/src/components/timeline/TimelineView.tsx +9 -3
- package/src/components/timeline/TimelineView.urlparams.test.ts +18 -1
- package/src/components/ui/command-items.ts +0 -3
- package/src/components/useContextSwitchFlow.tsx +147 -0
- package/src/components/workload/WorkloadView.test.ts +17 -1
- package/src/components/workload/WorkloadView.tsx +36 -18
- package/src/context/ContextSwitchContext.tsx +18 -2
- package/src/context/NavCustomization.tsx +4 -58
- package/src/context/ThemeContext.tsx +3 -11
- package/src/hooks/useClusterLoadState.ts +2 -2
- package/src/hooks/useFavorites.ts +3 -5
- package/src/utils/auditBadges.ts +1 -1
- package/src/utils/navigation.test.ts +18 -1
- package/src/utils/navigation.ts +12 -5
- package/src/utils/topology-selection.test.ts +56 -0
- package/src/utils/topology-selection.ts +9 -9
- 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
|
-
|
|
38
|
-
|
|
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 && !
|
|
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 && !
|
|
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
|
|
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
|
-
|
|
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 ? {
|
|
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
|
-
{
|
|
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 =
|
|
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>,
|
|
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>),
|
|
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 =
|
|
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="
|
|
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
|
-
|
|
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
|
-
{'
|
|
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();
|