@skyhook-io/radar-app 1.15.0 → 1.16.0

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 (89) hide show
  1. package/README.md +2 -0
  2. package/package.json +6 -5
  3. package/src/App.tsx +14 -11
  4. package/src/RadarApp.tsx +51 -21
  5. package/src/api/apiResources.test.ts +19 -1
  6. package/src/api/apiResources.ts +18 -3
  7. package/src/api/client.capacity.test.ts +15 -0
  8. package/src/api/client.trace.test.ts +9 -1
  9. package/src/api/client.ts +232 -201
  10. package/src/api/diagnose.test.ts +102 -1
  11. package/src/api/diagnose.ts +42 -11
  12. package/src/api/drain-plan.test.ts +0 -10
  13. package/src/api/httpErrors.test.ts +95 -0
  14. package/src/api/httpErrors.ts +101 -0
  15. package/src/api/policy.ts +33 -5
  16. package/src/api/radarFeatures.render.test.tsx +190 -0
  17. package/src/api/radarFeatures.test.ts +133 -0
  18. package/src/api/radarFeatures.ts +147 -0
  19. package/src/api/timelineSource.test.ts +2 -10
  20. package/src/api/timelineSource.ts +3 -2
  21. package/src/api/traffic.ts +4 -3
  22. package/src/components/CloudFunnelButton.tsx +9 -9
  23. package/src/components/NamespaceSwitcher.test.tsx +47 -0
  24. package/src/components/NamespaceSwitcher.tsx +17 -1
  25. package/src/components/SelfManagedStart.tsx +5 -6
  26. package/src/components/applications/ApplicationsView.tsx +20 -6
  27. package/src/components/audit/UpgradeReadinessView.test.ts +14 -6
  28. package/src/components/audit/UpgradeReadinessView.tsx +9 -13
  29. package/src/components/capacity/CapacityView.test.tsx +13 -4
  30. package/src/components/capacity/shared.tsx +14 -13
  31. package/src/components/cost/ApplicationCostTab.tsx +9 -1
  32. package/src/components/cost/CostConnectionAction.tsx +30 -0
  33. package/src/components/cost/CostTrendChart.tsx +2 -0
  34. package/src/components/cost/CostView.tsx +4 -24
  35. package/src/components/cost/WorkloadCostTab.tsx +7 -1
  36. package/src/components/cost/source.test.ts +30 -3
  37. package/src/components/cost/source.ts +37 -2
  38. package/src/components/curl/ServiceCurlButton.tsx +7 -3
  39. package/src/components/dock/NodeTerminalTab.tsx +2 -1
  40. package/src/components/dock/TerminalTab.tsx +2 -1
  41. package/src/components/dock/TrafficFlowListTab.tsx +2 -2
  42. package/src/components/gitops/GitOpsView.tsx +12 -0
  43. package/src/components/home/HomeView.tsx +1 -1
  44. package/src/components/home/MCPSetupDialog.tsx +37 -37
  45. package/src/components/resource/PVCUsageBar.render.test.tsx +16 -0
  46. package/src/components/resource/PVCUsageBar.tsx +4 -1
  47. package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -0
  48. package/src/components/resource/PrometheusChartsGrid.tsx +4 -0
  49. package/src/components/resources/ImageFilesystemModal.tsx +2 -1
  50. package/src/components/resources/PodFilePreview.tsx +4 -1
  51. package/src/components/resources/PodFilesystemModal.tsx +3 -2
  52. package/src/components/resources/ResourcesView.tsx +2 -1
  53. package/src/components/rightsizing/RightsizingScanView.tsx +4 -1
  54. package/src/components/settings/ConnectionHeadersEditor.tsx +109 -0
  55. package/src/components/settings/FormSaveActions.tsx +76 -0
  56. package/src/components/settings/IntegrationConnectionForms.tsx +391 -0
  57. package/src/components/settings/LocalConfigurationDetails.tsx +389 -0
  58. package/src/components/settings/LocalConnectionSettings.test.tsx +189 -0
  59. package/src/components/settings/LocalConnectionSettings.tsx +1015 -0
  60. package/src/components/settings/LocalIntegrationStatus.tsx +50 -0
  61. package/src/components/settings/PrometheusConfigField.tsx +30 -0
  62. package/src/components/settings/PrometheusConnectionForm.tsx +295 -0
  63. package/src/components/settings/SettingsDialog.tsx +382 -521
  64. package/src/components/settings/currency-options.test.ts +1 -1
  65. package/src/components/settings/currency-options.ts +1 -1
  66. package/src/components/settings/settings-state.test.ts +17 -10
  67. package/src/components/settings/settings-state.ts +20 -12
  68. package/src/components/timeline/TimelineList.test.ts +56 -0
  69. package/src/components/timeline/TimelineList.tsx +7 -1
  70. package/src/components/traffic/TrafficFlowList.tsx +5 -21
  71. package/src/components/traffic/TrafficFlowListContext.tsx +6 -4
  72. package/src/components/traffic/TrafficGraph.tsx +79 -48
  73. package/src/components/traffic/TrafficView.tsx +61 -37
  74. package/src/components/traffic/TrafficWizard.tsx +4 -2
  75. package/src/components/traffic/trafficFilters.test.ts +154 -1
  76. package/src/components/traffic/trafficFilters.ts +141 -0
  77. package/src/components/ui/Omnibar.tsx +1 -1
  78. package/src/components/ui/UpdateNotification.tsx +57 -12
  79. package/src/components/workload/WorkloadView.tsx +36 -13
  80. package/src/components/workload/historyPaging.test.ts +72 -0
  81. package/src/components/workload/historyPaging.ts +147 -0
  82. package/src/components/workload/useHistoryPaging.test.tsx +81 -0
  83. package/src/context/ConnectionContext.tsx +2 -1
  84. package/src/context/RadarUpgrade.tsx +30 -0
  85. package/src/context/RadarUpgradeHost.tsx +23 -0
  86. package/src/hooks/usePreviousIntegrationSettings.render.test.tsx +62 -0
  87. package/src/hooks/usePreviousIntegrationSettings.test.ts +62 -0
  88. package/src/hooks/usePreviousIntegrationSettings.ts +89 -0
  89. package/src/index.ts +1 -0
@@ -3,7 +3,7 @@ import { TrafficFlowList } from '../traffic/TrafficFlowList'
3
3
  import { List } from 'lucide-react'
4
4
 
5
5
  export function TrafficFlowListTab() {
6
- const { flows } = useTrafficFlowList()
6
+ const { flows, responsesCallerOriented } = useTrafficFlowList()
7
7
 
8
8
  if (flows.length === 0) {
9
9
  return (
@@ -14,5 +14,5 @@ export function TrafficFlowListTab() {
14
14
  )
15
15
  }
16
16
 
17
- return <TrafficFlowList flows={flows} />
17
+ return <TrafficFlowList flows={flows} responsesCallerOriented={responsesCallerOriented} />
18
18
  }
@@ -2,6 +2,8 @@ import { useEffect, useMemo, useRef, useState } from 'react'
2
2
  import { useLocation, useNavigate } from 'react-router-dom'
3
3
  import { useQuery } from '@tanstack/react-query'
4
4
  import yaml from 'yaml'
5
+ import { hasHealthSettingsHint } from '@skyhook-io/k8s-ui/components/gitops/GitOpsHealthSourceNotice'
6
+ import { previousSettingsNote, usePreviousIntegrationSettings } from '../../hooks/usePreviousIntegrationSettings'
5
7
  import {
6
8
  GitOpsActivityInsightView,
7
9
  GitOpsChangesView,
@@ -412,6 +414,14 @@ function GitOpsDetailView({ namespaces, onOpenResource, onOpenSettings }: GitOps
412
414
  const terminatingChipTooltip = terminatingDescriptions.chipTooltip
413
415
  const terminatingActionTooltip = terminatingDescriptions.actionDisabledTooltip
414
416
  const [appView, setAppView] = useState<GitOpsDetailTab>('topology')
417
+ const insight = insightsQ.data
418
+ const showDiffSettingsHint = appView === 'changes' && insight?.summary.tool === 'argocd' && insight.summary.kind === 'Application'
419
+ && !!insight.changes?.length && !insight.capabilities?.argoDiffAvailable
420
+ const previousSettings = usePreviousIntegrationSettings(!!onOpenSettings && (
421
+ hasHealthSettingsHint(insight?.summary, insight?.changes)
422
+ || showDiffSettingsHint
423
+ ))
424
+ const settingsNote = previousSettings.argocd ? previousSettingsNote('argocd', previousSettings.argocd) : undefined
415
425
  // When the user clicks an actionable issue alert ("OutOfSync — NodePool
416
426
  // default is out of sync · View →"), we navigate to Changes and focus
417
427
  // that resource. The ref is stringified to a stable key so GitOpsChangesView
@@ -670,6 +680,7 @@ function GitOpsDetailView({ namespaces, onOpenResource, onOpenSettings }: GitOps
670
680
  healthDocsUrl={GITOPS_HEALTH_DOCS_URL}
671
681
  remoteDestinationHint={<RemoteDestinationCloudHint />}
672
682
  onOpenSettings={onOpenSettings}
683
+ settingsNote={showDiffSettingsHint ? undefined : settingsNote}
673
684
  renderRevisionMeta={
674
685
  isArgoApp && insightsQ.data?.capabilities?.revisionMetadataAvailable
675
686
  ? (revision) => (
@@ -809,6 +820,7 @@ function GitOpsDetailView({ namespaces, onOpenResource, onOpenSettings }: GitOps
809
820
  <ArgoResourceDiffLoader appNamespace={namespace} appName={name} resourceRef={ref} />
810
821
  ) : undefined}
811
822
  onOpenSettings={onOpenSettings}
823
+ settingsNote={settingsNote}
812
824
  />
813
825
  )
814
826
  }
@@ -351,7 +351,7 @@ function ProblemsPanel({
351
351
  <div className="flex items-center gap-2">
352
352
  <button
353
353
  type="button"
354
- className="rounded-md px-2 py-1 text-xs font-medium text-accent-text transition-colors hover:bg-accent-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-radar-accent)]/40"
354
+ className="rounded-md px-2 py-1 text-xs font-medium text-accent-text transition-colors hover:bg-accent-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-radar-accent,var(--accent))]/40"
355
355
  onClick={onNavigateToIssues}
356
356
  >
357
357
  View all
@@ -116,71 +116,67 @@ export function MCPSetupDialog({ open, onClose, mcpUrl }: MCPSetupDialogProps) {
116
116
 
117
117
  const currentPort = Number(window.location.port) || 80
118
118
 
119
- const claudeDesktopConfig = JSON.stringify({
119
+ const mcpServersUrlConfig = (key: 'url' | 'serverUrl') => JSON.stringify({
120
120
  mcpServers: {
121
121
  radar: {
122
- type: "http",
123
- url: mcpUrl,
122
+ [key]: mcpUrl,
124
123
  }
125
124
  }
126
125
  }, null, 2)
127
126
 
128
- const cursorConfig = JSON.stringify({
129
- mcpServers: {
127
+ const vsCodeConfig = JSON.stringify({
128
+ servers: {
130
129
  radar: {
130
+ type: "http",
131
131
  url: mcpUrl,
132
132
  }
133
133
  }
134
134
  }, null, 2)
135
135
 
136
- const windsurfConfig = JSON.stringify({
137
- mcpServers: {
138
- radar: {
139
- serverUrl: mcpUrl,
140
- }
141
- }
142
- }, null, 2)
143
-
144
- const vsCodeConfig = JSON.stringify({
145
- servers: {
136
+ const opencodeConfig = JSON.stringify({
137
+ mcp: {
146
138
  radar: {
147
- type: "http",
139
+ type: "remote",
148
140
  url: mcpUrl,
141
+ enabled: true,
149
142
  }
150
143
  }
151
144
  }, null, 2)
152
145
 
153
- const geminiConfig = JSON.stringify({
146
+ const clineConfig = JSON.stringify({
154
147
  mcpServers: {
155
148
  radar: {
156
- httpUrl: mcpUrl,
149
+ type: "streamableHttp",
150
+ url: mcpUrl,
157
151
  }
158
152
  }
159
153
  }, null, 2)
160
154
 
161
- const codexConfig = `[mcp_servers.radar]\nurl = "${mcpUrl}"`
162
-
163
- const clineConfig = JSON.stringify({
155
+ // mcp-remote rejects plain http:// unless the host is localhost/127.0.0.1 or --allow-http is passed.
156
+ const parsedMcpUrl = new URL(mcpUrl)
157
+ const mcpRemoteNeedsAllowHttp = parsedMcpUrl.protocol === 'http:' && parsedMcpUrl.hostname !== 'localhost' && parsedMcpUrl.hostname !== '127.0.0.1'
158
+ const mcpRemoteArgs = ['-y', 'mcp-remote', mcpUrl, ...(mcpRemoteNeedsAllowHttp ? ['--allow-http'] : [])]
159
+ const claudeDesktopConfig = JSON.stringify({
164
160
  mcpServers: {
165
161
  radar: {
166
- url: mcpUrl,
162
+ command: "npx",
163
+ args: mcpRemoteArgs,
167
164
  }
168
165
  }
169
166
  }, null, 2)
170
167
 
171
- const jetbrainsConfig = JSON.stringify({
172
- mcpServers: {
168
+ const zedConfig = JSON.stringify({
169
+ context_servers: {
173
170
  radar: {
174
171
  url: mcpUrl,
175
172
  }
176
173
  }
177
174
  }, null, 2)
178
175
 
179
- const opencodeConfig = JSON.stringify({
180
- mcp: {
176
+ const geminiConfig = JSON.stringify({
177
+ mcpServers: {
181
178
  radar: {
182
- type: "remote",
183
- url: mcpUrl,
179
+ httpUrl: mcpUrl,
184
180
  }
185
181
  }
186
182
  }, null, 2)
@@ -285,16 +281,20 @@ export function MCPSetupDialog({ open, onClose, mcpUrl }: MCPSetupDialogProps) {
285
281
  <h4 className="text-sm font-semibold text-theme-text-primary">Connect your AI tool</h4>
286
282
 
287
283
  {[
288
- { icon: Terminal, name: 'Claude Code', path: '', config: `claude mcp add radar --transport http ${mcpUrl}` },
289
- { icon: MessageSquare, name: 'Claude Desktop', path: '~/Library/Application Support/Claude/claude_desktop_config.json', config: claudeDesktopConfig },
290
- { icon: Code2, name: 'Cursor', path: '~/.cursor/mcp.json', config: cursorConfig },
291
- { icon: Code2, name: 'Windsurf', path: '~/.codeium/windsurf/mcp_config.json', config: windsurfConfig },
292
- { icon: Code2, name: 'VS Code Copilot', path: '.vscode/mcp.json', config: vsCodeConfig },
293
- { icon: Code2, name: 'Cline', path: 'Cline MCP settings (via UI)', config: clineConfig },
294
- { icon: Code2, name: 'JetBrains AI', path: 'Settings → Tools → AI Assistant → MCP', config: jetbrainsConfig },
284
+ { icon: Terminal, name: 'Claude Code', path: '', config: `claude mcp add --transport http radar ${mcpUrl}` },
285
+ { icon: Terminal, name: 'Codex', path: '', config: `codex mcp add radar --url ${mcpUrl}` },
286
+ { icon: Code2, name: 'Cursor', path: '~/.cursor/mcp.json', config: mcpServersUrlConfig('url') },
287
+ { icon: Code2, name: 'VS Code (GitHub Copilot)', path: '.vscode/mcp.json', config: vsCodeConfig },
288
+ { icon: Terminal, name: 'GitHub Copilot CLI', path: '', config: `copilot mcp add --transport http radar ${mcpUrl}` },
295
289
  { icon: Terminal, name: 'OpenCode', path: '~/.config/opencode/opencode.json', config: opencodeConfig },
296
- { icon: Terminal, name: 'OpenAI Codex', path: '~/.codex/config.toml', config: codexConfig },
297
- { icon: Terminal, name: 'Gemini CLI', path: '~/.gemini/settings.json', config: geminiConfig },
290
+ { icon: Code2, name: 'Google Antigravity', path: '~/.gemini/config/mcp_config.json', config: mcpServersUrlConfig('serverUrl') },
291
+ { icon: Code2, name: 'Kiro', path: '~/.kiro/settings/mcp.json', config: mcpServersUrlConfig('url') },
292
+ { icon: Code2, name: 'JetBrains AI Assistant / Junie', path: 'AI Assistant settings or ~/.junie/mcp/mcp.json', config: mcpServersUrlConfig('url') },
293
+ { icon: Code2, name: 'Cline', path: 'Cline MCP settings (via UI)', config: clineConfig },
294
+ { icon: Code2, name: 'Devin Desktop (formerly Windsurf)', path: '~/.config/devin/mcp_config.json', config: mcpServersUrlConfig('url') },
295
+ { icon: MessageSquare, name: 'Claude Desktop', path: '~/Library/Application Support/Claude/claude_desktop_config.json', config: claudeDesktopConfig },
296
+ { icon: Code2, name: 'Zed', path: 'Zed settings.json', config: zedConfig },
297
+ { icon: Terminal, name: 'Gemini CLI (Code Assist Standard/Enterprise)', path: '~/.gemini/settings.json', config: geminiConfig },
298
298
  ].map((agent) => (
299
299
  <Disclosure
300
300
  key={agent.name}
@@ -5,6 +5,7 @@ import { ApiError, type PrometheusPVCUsage } from '../../api/client'
5
5
 
6
6
  let canConfigure = true
7
7
  let roleLoading = false
8
+ let previousMetrics = false
8
9
  let statusResult: { data?: { connected: boolean; discovering?: boolean }; error?: Error }
9
10
  let usageResult: { data?: PrometheusPVCUsage; error?: Error }
10
11
  vi.mock('../../api/client', async (importActual) => ({
@@ -15,13 +16,28 @@ vi.mock('../../api/client', async (importActual) => ({
15
16
  useCloudRole: () => ({ canAtLeast: () => canConfigure, isLoading: roleLoading }),
16
17
  }))
17
18
  const { PVCUsageBar } = await import('./PVCUsageBar')
19
+ vi.mock('../../hooks/usePreviousIntegrationSettings', () => ({
20
+ usePreviousIntegrationSettings: (relevant: boolean) => ({ metrics: relevant && previousMetrics }),
21
+ previousSettingsNote: () => 'You can copy your previous settings in Settings → Metrics.',
22
+ }))
18
23
  const measured: PrometheusPVCUsage = { namespace: 'demo', name: 'disk', used: 0, capacity: 1024, ratio: 0, hasData: true, status: 'available' }
19
24
  const render = () => renderToString(<PVCUsageBar namespace="demo" name="disk" />)
20
25
 
21
26
  describe('PVC usage availability', () => {
27
+ it('offers previous settings only for a missing connection, not missing series or denied access', () => {
28
+ previousMetrics = true
29
+ statusResult = { data: { connected: false } }
30
+ expect(render()).toContain('copy your previous settings')
31
+ statusResult = { data: { connected: true } }
32
+ usageResult = { data: { ...measured, hasData: false, status: 'no_series' } }
33
+ expect(render()).not.toContain('copy your previous settings')
34
+ statusResult = { error: new ApiError('denied', 403) }
35
+ expect(render()).not.toContain('copy your previous settings')
36
+ })
22
37
  beforeEach(() => {
23
38
  canConfigure = true
24
39
  roleLoading = false
40
+ previousMetrics = false
25
41
  statusResult = { data: { connected: true } }
26
42
  usageResult = {}
27
43
  })
@@ -1,6 +1,7 @@
1
1
  import { formatMemoryBytes } from '@skyhook-io/k8s-ui/utils/format'
2
2
  import { isForbiddenError, useAutoPromConnect, useCloudRole, usePrometheusPVCUsage, usePrometheusStatus } from '../../api/client'
3
3
  import { useNavCustomization } from '../../context/NavCustomization'
4
+ import { previousSettingsNote, usePreviousIntegrationSettings } from '../../hooks/usePreviousIntegrationSettings'
4
5
 
5
6
  export function PVCUsageBar({ namespace, name }: { namespace: string; name: string }) {
6
7
  // PVC detail can be the first Prometheus-backed surface a user opens; without
@@ -40,6 +41,8 @@ export function PVCUsageBar({ namespace, name }: { namespace: string; name: stri
40
41
 
41
42
  const denied = isForbiddenError(error) || isForbiddenError(statusError)
42
43
  const waiting = !denied && !statusError && (!status || status.discovering || (isConnected && !usage && !error))
44
+ const offers = usePreviousIntegrationSettings(canConfigure && !waiting && !denied && !statusError && !isConnected)
45
+ const previousNote = offers.metrics ? previousSettingsNote('metrics', offers.metrics) : undefined
43
46
  const guidance = denied
44
47
  ? 'Ask your operator to review your metrics access.'
45
48
  : !canConfigure && !roleLoading
@@ -53,6 +56,7 @@ export function PVCUsageBar({ namespace, name }: { namespace: string; name: stri
53
56
  <section aria-label="PVC usage" className="rounded-lg border border-theme-border bg-theme-surface/30 p-3">
54
57
  <div className="text-xs font-medium text-theme-text-secondary uppercase tracking-wide mb-1">Usage</div>
55
58
  <p className="text-sm text-theme-text-tertiary">{unavailable}</p>
59
+ {previousNote && <p className="mt-2 text-xs text-theme-text-secondary">{previousNote}</p>}
56
60
  {!waiting && (
57
61
  canConfigure && !denied ? (
58
62
  <button
@@ -102,4 +106,3 @@ export function PVCUsageBar({ namespace, name }: { namespace: string; name: stri
102
106
  </section>
103
107
  )
104
108
  }
105
-
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
2
  import { renderToString } from 'react-dom/server'
3
3
  import { MemoryRouter } from 'react-router-dom'
4
4
  import { NavCustomizationProvider } from '../../context/NavCustomization'
5
+ vi.mock('../../hooks/usePreviousIntegrationSettings', () => ({ usePreviousIntegrationSettings: () => ({ metrics: false }) }))
5
6
  import type { WorkloadMetrics } from '../../api/workloadMetrics'
6
7
 
7
8
  let connected = false
@@ -35,6 +35,7 @@ import { Tooltip } from "../ui/Tooltip";
35
35
  import { WorkloadMetricsSection } from "./WorkloadMetricsSection";
36
36
  import { RightsizingStrip } from "./RightsizingStrip";
37
37
  import { useNavCustomization } from "../../context/NavCustomization";
38
+ import { previousSettingsNote, usePreviousIntegrationSettings } from '../../hooks/usePreviousIntegrationSettings';
38
39
 
39
40
  // Used when MetricsTabContent is in expanded (full-screen) mode. Drawer mode
40
41
  // uses the single-chart tabbed `PrometheusCharts` instead — drawer width
@@ -73,6 +74,8 @@ export function PrometheusChartsGrid({
73
74
  const showRestartLane = isSupported && kind !== "Node";
74
75
 
75
76
  const settingsAvailable = !useNavCustomization().embedded;
77
+ const offers = usePreviousIntegrationSettings(isSupported && !statusLoading && !!status && !isConnected && !status.discovering);
78
+ const previousNote = offers.metrics ? previousSettingsNote('metrics', offers.metrics) : undefined;
76
79
  const [searchParams, setSearchParams] = useSearchParams();
77
80
  const timeRange = TIME_RANGES.find((range) => range.value === searchParams.get("metricsRange"))?.value ?? "1h";
78
81
 
@@ -142,6 +145,7 @@ export function PrometheusChartsGrid({
142
145
  onClick={() => window.dispatchEvent(new CustomEvent('radar:open-settings', { detail: { section: 'prometheus' } }))}
143
146
  >Configure metrics</button>}
144
147
  </div>
148
+ {previousNote && <p className="mt-3 text-xs text-theme-text-secondary">{previousNote}</p>}
145
149
  {!settingsAvailable && <p className="mt-3 text-xs text-theme-text-tertiary">Ask your operator to configure the metrics connection for this cluster.</p>}
146
150
  {isWorkload && <a className="mt-3 inline-block text-xs text-accent hover:underline" href="https://github.com/skyhook-io/radar/blob/main/docs/workload-metrics.md#what-each-chart-needs" target="_blank" rel="noopener noreferrer">What each chart needs</a>}
147
151
  </div>
@@ -10,6 +10,7 @@ import { formatBytes } from '../../utils/format'
10
10
  import { downloadBlob, filterTree } from './file-browser-utils'
11
11
  import { Tooltip } from '../ui/Tooltip'
12
12
  import { apiUrl, getAuthHeaders, getCredentialsMode } from '../../api/config'
13
+ import { readErrorBody } from '../../api/httpErrors'
13
14
  import { Input } from '@skyhook-io/k8s-ui'
14
15
 
15
16
  const radarLoadingIconUrl = assetUrl(radarLoadingIcon)
@@ -32,7 +33,7 @@ async function fetchImageFilesystem(
32
33
  headers: getAuthHeaders(),
33
34
  })
34
35
  if (!response.ok) {
35
- const error = await response.json().catch(() => ({ error: 'Request failed' }))
36
+ const error = await readErrorBody(response)
36
37
  throw new Error(error.error || `HTTP ${response.status}`)
37
38
  }
38
39
  return response.json()
@@ -5,6 +5,7 @@ import { PaneLoader, ensureMonacoRuntime } from '@skyhook-io/k8s-ui'
5
5
  import { formatBytes } from '../../utils/format'
6
6
  import { Disclosure } from '@skyhook-io/k8s-ui/components/ui/Disclosure'
7
7
  import { apiUrl, getAuthHeaders, getCredentialsMode } from '../../api/config'
8
+ import { nonJsonErrorMessage } from '../../api/httpErrors'
8
9
 
9
10
  // A curated inline viewer for text files inside a pod container, rendered in
10
11
  // place of the file listing by PodFilesystemModal — never as its own dialog.
@@ -81,7 +82,9 @@ async function fetchPodFilePreview(
81
82
  return {
82
83
  ok: false,
83
84
  code: 'network_error',
84
- message: `Unexpected non-JSON response (HTTP ${response.status}).`,
85
+ message: response.ok
86
+ ? `Unexpected non-JSON response (HTTP ${response.status}).`
87
+ : nonJsonErrorMessage(response, raw),
85
88
  }
86
89
  }
87
90
 
@@ -7,6 +7,7 @@ import type { FileNode } from '../../types'
7
7
  import { formatBytes } from '../../utils/format'
8
8
  import { downloadBlob, filterTree } from './file-browser-utils'
9
9
  import { apiUrl, getAuthHeaders, getCredentialsMode } from '../../api/config'
10
+ import { readErrorBody } from '../../api/httpErrors'
10
11
  import { isDesktopApp } from '../../utils/desktop-download'
11
12
  import { openFile, openFolder } from '../../utils/desktop-open-folder'
12
13
  import { useToast } from '../ui/Toast'
@@ -33,7 +34,7 @@ async function fetchPodFiles(
33
34
  headers: getAuthHeaders(),
34
35
  })
35
36
  if (!response.ok) {
36
- const error = await response.json().catch(() => ({ error: 'Request failed' }))
37
+ const error = await readErrorBody(response)
37
38
  throw new Error(error.error || `HTTP ${response.status}`)
38
39
  }
39
40
  return response.json()
@@ -105,7 +106,7 @@ async function downloadPodFile(
105
106
  headers: getAuthHeaders(),
106
107
  })
107
108
  if (!response.ok) {
108
- const err = await response.json().catch(() => ({ error: 'Download failed' }))
109
+ const err = await readErrorBody(response)
109
110
  throw new Error(err.error || `HTTP ${response.status}`)
110
111
  }
111
112
 
@@ -5,6 +5,7 @@ import { ApiError, debugNamespaceLog, fetchJSON, isForbiddenError, isKindSyncFai
5
5
  import { isBadgeWorthy } from '../../utils/auditBadges'
6
6
  import type { AuditBadgeMessage } from '@skyhook-io/k8s-ui'
7
7
  import { apiUrl, getAuthHeaders, getCredentialsMode, stripBasename } from '../../api/config'
8
+ import { readErrorBody } from '../../api/httpErrors'
8
9
  import { useAPIResources } from '../../api/apiResources'
9
10
  import { useConnection } from '../../context/ConnectionContext'
10
11
  import { initNavigationMap, getSecretStoreProviderType } from '@skyhook-io/k8s-ui'
@@ -296,7 +297,7 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
296
297
  durationMs: Math.round(performance.now() - startedAt),
297
298
  })
298
299
  if (!res.ok) {
299
- const errorData = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
300
+ const errorData = await readErrorBody(res)
300
301
  throw new ApiError(errorData.error || `Failed to fetch ${selectedKind.name}`, res.status, errorData)
301
302
  }
302
303
  const body = await res.json()
@@ -36,6 +36,7 @@ import {
36
36
  type RightsizingActionTone,
37
37
  } from './presentation'
38
38
  import { useNavCustomization } from '../../context/NavCustomization'
39
+ import { previousSettingsNote, usePreviousIntegrationSettings } from '../../hooks/usePreviousIntegrationSettings'
39
40
 
40
41
  export const RIGHTSIZING_SCAN_DESCRIPTION =
41
42
  'Find CPU and memory requests to increase, reduce, or review. Radar never changes them.'
@@ -117,6 +118,8 @@ export function RightsizingScanView({ namespaces }: RightsizingScanViewProps) {
117
118
  isLoading: statusLoading,
118
119
  refetch: retryPrometheus,
119
120
  } = usePrometheusStatus()
121
+ const offers = usePreviousIntegrationSettings(!!promStatus && !promStatus.connected && !promStatus.discovering && !statusLoading)
122
+ const previousNote = offers.metrics ? previousSettingsNote('metrics', offers.metrics) : undefined
120
123
  const scan = useRightsizingScan(namespaces, clusterInfo?.context)
121
124
  const result = scan.data
122
125
  const [openRow, setOpenRow] = useState<string | null>(null)
@@ -257,7 +260,7 @@ export function RightsizingScanView({ namespaces }: RightsizingScanViewProps) {
257
260
  <CenteredState
258
261
  title={RIGHTSIZING_METRICS_REQUIRED_TITLE}
259
262
  body={settingsAvailable
260
- ? RIGHTSIZING_METRICS_REQUIRED_BODY
263
+ ? `${RIGHTSIZING_METRICS_REQUIRED_BODY}${previousNote ? `\n${previousNote}` : ''}`
261
264
  : RIGHTSIZING_EMBEDDED_METRICS_REQUIRED_BODY}
262
265
  action={
263
266
  <div className="flex flex-wrap items-center justify-center gap-3">
@@ -0,0 +1,109 @@
1
+ import { Input } from '@skyhook-io/k8s-ui'
2
+
3
+ export interface HeaderOperation {
4
+ key: string
5
+ action: 'keep' | 'set' | 'clear'
6
+ value?: string
7
+ }
8
+
9
+ export function ConnectionHeadersEditor({
10
+ keys,
11
+ environmentKeys,
12
+ value,
13
+ onChange
14
+ }: {
15
+ keys: string[]
16
+ environmentKeys: string[]
17
+ value: HeaderOperation[]
18
+ onChange: (value: HeaderOperation[]) => void
19
+ }) {
20
+ const rows = value.length
21
+ ? value
22
+ : keys.map((key): HeaderOperation => ({ key, action: 'keep' }))
23
+ const update = (index: number, patch: Partial<HeaderOperation>) =>
24
+ onChange(rows.map((row, i) => (i === index ? { ...row, ...patch } : row)))
25
+ return (
26
+ <section className="mt-4 space-y-2">
27
+ <h4 className="text-sm font-medium text-theme-text-primary">
28
+ Authentication headers
29
+ </h4>
30
+ <p className="text-xs text-theme-text-tertiary">
31
+ Optional. Saved values stay unchanged unless you replace or remove them.
32
+ </p>
33
+ {rows.map((row, index) => {
34
+ const saved = index < keys.length
35
+ const environment = saved && environmentKeys.some(
36
+ (key) => key.toLowerCase() === row.key.toLowerCase()
37
+ )
38
+ return (
39
+ <div key={index} className="space-y-1">
40
+ <div className="flex items-center gap-2">
41
+ <Input
42
+ aria-label={`Header ${index + 1} name`}
43
+ value={row.key}
44
+ disabled={saved}
45
+ onChange={(e) => update(index, { key: e.target.value })}
46
+ placeholder="Header name"
47
+ className="min-w-0 flex-1 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"
48
+ />
49
+ <Input
50
+ aria-label={`${row.key || 'New header'} value`}
51
+ type="password"
52
+ autoComplete="new-password"
53
+ disabled={environment || row.action === 'clear'}
54
+ value={row.value ?? ''}
55
+ placeholder={
56
+ row.action === 'clear'
57
+ ? 'Will be removed'
58
+ : environment
59
+ ? 'From environment'
60
+ : saved
61
+ ? 'Saved value'
62
+ : 'Value'
63
+ }
64
+ onChange={(e) =>
65
+ update(index, {
66
+ action: saved && !e.target.value ? 'keep' : 'set',
67
+ value: e.target.value || undefined
68
+ })
69
+ }
70
+ className="min-w-0 flex-1 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"
71
+ />
72
+ {!environment && (
73
+ <button
74
+ type="button"
75
+ className="text-xs text-accent-text hover:underline shrink-0"
76
+ aria-label={`${row.action === 'clear' ? 'Undo removal of' : 'Remove'} ${row.key || 'new header'}`}
77
+ onClick={() =>
78
+ saved
79
+ ? update(index, {
80
+ action: row.action === 'clear' ? 'keep' : 'clear',
81
+ value: undefined
82
+ })
83
+ : onChange(rows.filter((_, i) => i !== index))
84
+ }
85
+ >
86
+ {row.action === 'clear' ? 'Undo' : 'Remove'}
87
+ </button>
88
+ )}
89
+ </div>
90
+ {environment && (
91
+ <p className="text-xs text-theme-text-tertiary">
92
+ Environment reference · edit this header in clusters.json.
93
+ </p>
94
+ )}
95
+ </div>
96
+ )
97
+ })}
98
+ <button
99
+ type="button"
100
+ className="text-xs text-accent-text hover:underline"
101
+ onClick={() =>
102
+ onChange([...rows, { key: '', action: 'set', value: '' }])
103
+ }
104
+ >
105
+ Add header
106
+ </button>
107
+ </section>
108
+ )
109
+ }
@@ -0,0 +1,76 @@
1
+ import { Collapse } from "@skyhook-io/k8s-ui";
2
+ import { Check } from "lucide-react";
3
+
4
+ export interface FormFeedback {
5
+ message: string;
6
+ tone: "success" | "warning" | "info";
7
+ }
8
+
9
+ export function FormSaveActions({
10
+ dirty,
11
+ busy,
12
+ onSave,
13
+ onDiscard,
14
+ feedback,
15
+ error,
16
+ }: {
17
+ dirty: boolean;
18
+ busy: boolean;
19
+ onSave: () => void;
20
+ onDiscard: () => void;
21
+ feedback?: FormFeedback;
22
+ error?: string;
23
+ }) {
24
+ const success = !dirty && !busy && !error && feedback?.tone === "success";
25
+ const detail =
26
+ !busy && (error || (feedback?.tone !== "success" && feedback?.message));
27
+ return (
28
+ <div className="mt-4 border-t border-theme-border pt-4">
29
+ <div className="flex min-h-8 flex-wrap items-center gap-3">
30
+ <button
31
+ type="button"
32
+ disabled={!dirty || busy}
33
+ onClick={onSave}
34
+ className="btn-brand min-w-28 px-3 py-2 text-xs"
35
+ >
36
+ {busy ? "Saving…" : "Save changes"}
37
+ </button>
38
+ {success && (
39
+ <span
40
+ role="status"
41
+ className="flex items-center gap-1.5 text-xs text-theme-text-secondary"
42
+ >
43
+ <Check
44
+ aria-hidden="true"
45
+ className="h-3.5 w-3.5 text-[var(--color-success-dark)] dark:text-[var(--color-success-light)]"
46
+ />
47
+ {feedback.message}
48
+ </span>
49
+ )}
50
+ {dirty && (
51
+ <>
52
+ <button
53
+ type="button"
54
+ disabled={busy}
55
+ onClick={onDiscard}
56
+ className="text-xs text-theme-text-secondary hover:underline"
57
+ >
58
+ Discard
59
+ </button>
60
+ <span className="text-xs text-theme-text-tertiary">
61
+ Unsaved changes
62
+ </span>
63
+ </>
64
+ )}
65
+ </div>
66
+ <Collapse open={!!detail}>
67
+ <p
68
+ role={error ? "alert" : "status"}
69
+ className={`pt-2 text-xs ${error ? "text-semantic-error" : feedback?.tone === "warning" ? "text-warning-text" : "text-theme-text-secondary"}`}
70
+ >
71
+ {detail}
72
+ </p>
73
+ </Collapse>
74
+ </div>
75
+ );
76
+ }