@skyhook-io/radar-app 1.14.6 → 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 (128) hide show
  1. package/README.md +2 -0
  2. package/package.json +6 -5
  3. package/src/App.tsx +65 -17
  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.admission.test.ts +34 -0
  8. package/src/api/client.capacity.test.ts +15 -0
  9. package/src/api/client.trace.test.ts +9 -1
  10. package/src/api/client.ts +269 -204
  11. package/src/api/diagnose.test.ts +102 -1
  12. package/src/api/diagnose.ts +42 -11
  13. package/src/api/drain-plan.test.ts +0 -10
  14. package/src/api/httpErrors.test.ts +95 -0
  15. package/src/api/httpErrors.ts +101 -0
  16. package/src/api/policy.ts +33 -5
  17. package/src/api/radarFeatures.render.test.tsx +190 -0
  18. package/src/api/radarFeatures.test.ts +133 -0
  19. package/src/api/radarFeatures.ts +147 -0
  20. package/src/api/timelineSource.test.ts +2 -10
  21. package/src/api/timelineSource.ts +3 -2
  22. package/src/api/traffic.ts +4 -3
  23. package/src/api/usage-data.fixtures.ts +33 -0
  24. package/src/api/usage-data.ts +165 -0
  25. package/src/components/CloudFunnelButton.test.tsx +2 -2
  26. package/src/components/CloudFunnelButton.tsx +99 -55
  27. package/src/components/NamespaceSwitcher.test.tsx +47 -0
  28. package/src/components/NamespaceSwitcher.tsx +17 -1
  29. package/src/components/SelfManagedStart.tsx +95 -0
  30. package/src/components/applications/ApplicationsView.tsx +20 -6
  31. package/src/components/audit/UpgradeReadinessView.test.ts +14 -6
  32. package/src/components/audit/UpgradeReadinessView.tsx +9 -13
  33. package/src/components/capacity/CapacityView.test.tsx +13 -4
  34. package/src/components/capacity/shared.tsx +14 -13
  35. package/src/components/cloudConnectHandoff.test.ts +1 -1
  36. package/src/components/cloudConnectHandoff.ts +1 -1
  37. package/src/components/cost/ApplicationCostTab.tsx +9 -1
  38. package/src/components/cost/CostConnectionAction.tsx +30 -0
  39. package/src/components/cost/CostTrendChart.tsx +2 -0
  40. package/src/components/cost/CostView.tsx +4 -24
  41. package/src/components/cost/WorkloadCostTab.tsx +7 -1
  42. package/src/components/cost/source.test.ts +30 -3
  43. package/src/components/cost/source.ts +37 -2
  44. package/src/components/curl/ServiceCurlButton.tsx +7 -3
  45. package/src/components/diagnose/ActivityTurn.tsx +3 -2
  46. package/src/components/diagnose/ApplyDialog.tsx +4 -2
  47. package/src/components/dialog-names.test.ts +69 -0
  48. package/src/components/dock/NodeTerminalTab.tsx +2 -1
  49. package/src/components/dock/TerminalTab.tsx +2 -1
  50. package/src/components/dock/TrafficFlowListTab.tsx +2 -2
  51. package/src/components/execution/BatchExecutionView.tsx +1 -1
  52. package/src/components/execution/JobSetAdmission.test.tsx +160 -37
  53. package/src/components/execution/JobSetAdmission.tsx +16 -7
  54. package/src/components/gitops/GitOpsView.tsx +12 -0
  55. package/src/components/helm/TrackChartSourceDialog.tsx +5 -3
  56. package/src/components/home/HomeView.tsx +7 -3
  57. package/src/components/home/MCPSetupDialog.tsx +37 -37
  58. package/src/components/home/RadarVersionLine.test.tsx +14 -0
  59. package/src/components/home/RadarVersionLine.tsx +28 -3
  60. package/src/components/nav/PrimaryNavRail.test.tsx +10 -1
  61. package/src/components/nav/PrimaryNavRail.tsx +25 -3
  62. package/src/components/resource/PVCUsageBar.render.test.tsx +16 -0
  63. package/src/components/resource/PVCUsageBar.tsx +4 -1
  64. package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -0
  65. package/src/components/resource/PrometheusChartsGrid.tsx +4 -0
  66. package/src/components/resources/ImageFilesystemModal.tsx +2 -1
  67. package/src/components/resources/PodFilePreview.tsx +4 -1
  68. package/src/components/resources/PodFilesystemModal.tsx +3 -2
  69. package/src/components/resources/ResourcesView.tsx +2 -1
  70. package/src/components/resources/renderers/JobAdmissionRenderers.test.tsx +18 -0
  71. package/src/components/resources/renderers/JobAdmissionRenderers.tsx +18 -0
  72. package/src/components/resources/renderers/RayJobRenderer.test.tsx +40 -0
  73. package/src/components/resources/renderers/RayJobRenderer.tsx +45 -0
  74. package/src/components/rightsizing/RightsizingScanView.tsx +4 -1
  75. package/src/components/settings/ConnectionHeadersEditor.tsx +109 -0
  76. package/src/components/settings/FormSaveActions.tsx +76 -0
  77. package/src/components/settings/IntegrationConnectionForms.tsx +391 -0
  78. package/src/components/settings/LocalConfigurationDetails.tsx +389 -0
  79. package/src/components/settings/LocalConnectionSettings.test.tsx +189 -0
  80. package/src/components/settings/LocalConnectionSettings.tsx +1015 -0
  81. package/src/components/settings/LocalIntegrationStatus.tsx +50 -0
  82. package/src/components/settings/PrivacySection.test.tsx +84 -0
  83. package/src/components/settings/PrivacySection.tsx +140 -0
  84. package/src/components/settings/PrometheusConfigField.tsx +30 -0
  85. package/src/components/settings/PrometheusConnectionForm.tsx +295 -0
  86. package/src/components/settings/SettingsDialog.tsx +393 -571
  87. package/src/components/settings/controls.tsx +55 -0
  88. package/src/components/settings/currency-options.test.ts +1 -1
  89. package/src/components/settings/currency-options.ts +1 -1
  90. package/src/components/settings/settings-state.test.ts +17 -10
  91. package/src/components/settings/settings-state.ts +21 -12
  92. package/src/components/timeline/TimelineList.test.ts +56 -0
  93. package/src/components/timeline/TimelineList.tsx +7 -1
  94. package/src/components/traffic/TrafficFlowList.tsx +5 -21
  95. package/src/components/traffic/TrafficFlowListContext.tsx +6 -4
  96. package/src/components/traffic/TrafficGraph.tsx +79 -48
  97. package/src/components/traffic/TrafficView.tsx +61 -37
  98. package/src/components/traffic/TrafficWizard.tsx +4 -2
  99. package/src/components/traffic/trafficFilters.test.ts +154 -1
  100. package/src/components/traffic/trafficFilters.ts +141 -0
  101. package/src/components/ui/ErrorBoundary.tsx +5 -0
  102. package/src/components/ui/Omnibar.tsx +17 -1
  103. package/src/components/ui/UpdateNotification.tsx +70 -21
  104. package/src/components/ui/command-items.ts +14 -0
  105. package/src/components/usage-data/UsageDataAsk.test.tsx +41 -0
  106. package/src/components/usage-data/UsageDataAsk.tsx +106 -0
  107. package/src/components/usage-data/UsageDataPrompt.test.ts +53 -0
  108. package/src/components/usage-data/UsageDataPrompt.tsx +148 -0
  109. package/src/components/whats-new/WhatsNew.test.ts +110 -0
  110. package/src/components/whats-new/WhatsNew.tsx +423 -0
  111. package/src/components/whats-new/WhatsNewDialog.test.tsx +229 -0
  112. package/src/components/whats-new/check-whats-new.test.ts +65 -0
  113. package/src/components/whats-new/releaseNotes.ts +121 -0
  114. package/src/components/workload/WorkloadView.tsx +47 -14
  115. package/src/components/workload/historyPaging.test.ts +72 -0
  116. package/src/components/workload/historyPaging.ts +147 -0
  117. package/src/components/workload/useHistoryPaging.test.tsx +81 -0
  118. package/src/context/ConnectionContext.tsx +2 -1
  119. package/src/context/RadarUpgrade.tsx +30 -0
  120. package/src/context/RadarUpgradeHost.tsx +23 -0
  121. package/src/hooks/usePreviousIntegrationSettings.render.test.tsx +62 -0
  122. package/src/hooks/usePreviousIntegrationSettings.test.ts +62 -0
  123. package/src/hooks/usePreviousIntegrationSettings.ts +89 -0
  124. package/src/index.ts +1 -0
  125. package/src/k8s-ui-exports.test.ts +73 -0
  126. package/src/utils/navigation.test.ts +17 -1
  127. package/src/utils/navigation.ts +16 -0
  128. package/src/utils/version.ts +11 -0
@@ -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}
@@ -142,4 +142,18 @@ describe('RadarVersionLine', () => {
142
142
  expect(html).toContain('Open the in-cluster upgrade instructions and apply the change through GitOps')
143
143
  expect(html).not.toContain('Open it to upgrade through GitOps')
144
144
  })
145
+
146
+ it('offers What\'s new only when the host can open it', () => {
147
+ const upToDate = { ...version, latestVersion: '1.2.3', updateAvailable: false }
148
+ expect(renderToString(<RadarVersionLine version={upToDate} />)).not.toContain('What&#x27;s new')
149
+ expect(renderToString(<RadarVersionLine version={upToDate} onShowWhatsNew={() => {}} />)).toContain('What&#x27;s new')
150
+ expect(renderToString(<RadarVersionLine version={version} onShowWhatsNew={() => {}} />)).toContain('What&#x27;s new')
151
+ })
152
+
153
+ it('hides upgrade guidance when the host handles upgrades elsewhere', () => {
154
+ const html = renderToString(<RadarVersionLine version={version} showUpgrade={false} onShowWhatsNew={() => {}} />)
155
+ expect(html).toContain('v1.2.3')
156
+ expect(html).not.toContain('available')
157
+ expect(html).toContain('What&#x27;s new')
158
+ })
145
159
  })
@@ -1,4 +1,4 @@
1
- import { ArrowUpCircle } from 'lucide-react'
1
+ import { ArrowUpCircle, Megaphone } from 'lucide-react'
2
2
  import { gitOpsRouteForResource } from '@skyhook-io/k8s-ui'
3
3
  import type { CloudConnectSelf, VersionInfo } from '../../api/client'
4
4
  import {
@@ -14,6 +14,10 @@ interface RadarVersionLineProps {
14
14
  managerLoading?: boolean
15
15
  onNavigateToHelmRelease?: (namespace: string, release: string) => void
16
16
  onNavigateToGitOps?: (path: string) => void
17
+ // Local installs upgrade through the update notification, and this line's
18
+ // upgrade link points at in-cluster instructions.
19
+ showUpgrade?: boolean
20
+ onShowWhatsNew?: () => void
17
21
  }
18
22
 
19
23
  function displayVersion(version: string): string {
@@ -26,13 +30,33 @@ export function RadarVersionLine({
26
30
  managerLoading = false,
27
31
  onNavigateToHelmRelease,
28
32
  onNavigateToGitOps,
33
+ showUpgrade: upgradeAllowed = true,
34
+ onShowWhatsNew,
29
35
  }: RadarVersionLineProps) {
30
36
  const latestVersion = version.latestVersion
31
37
  const updateStatus = getVersionUpdateStatus(version.currentVersion, latestVersion)
32
- const showUpgrade = version.updateAvailable && !!latestVersion && updateStatus.tier !== 'none'
38
+ const showUpgrade = upgradeAllowed && version.updateAvailable && !!latestVersion && updateStatus.tier !== 'none'
39
+ const whatsNew = onShowWhatsNew && (
40
+ <span className="inline-flex items-center gap-1">
41
+ <span aria-hidden>·</span>
42
+ <button
43
+ type="button"
44
+ onClick={onShowWhatsNew}
45
+ className="inline-flex items-center gap-1 text-accent-text hover:text-accent transition-colors"
46
+ >
47
+ <Megaphone className="h-3.5 w-3.5 shrink-0" aria-hidden />
48
+ What's new
49
+ </button>
50
+ </span>
51
+ )
33
52
 
34
53
  if (!showUpgrade) {
35
- return <span>Radar <span className="font-mono">{displayVersion(version.currentVersion)}</span></span>
54
+ return (
55
+ <span className="inline-flex flex-wrap items-center gap-x-1">
56
+ <span>Radar <span className="font-mono">{displayVersion(version.currentVersion)}</span></span>
57
+ {whatsNew}
58
+ </span>
59
+ )
36
60
  }
37
61
 
38
62
  const controller = manager?.controllerRef
@@ -111,6 +135,7 @@ export function RadarVersionLine({
111
135
  {action}
112
136
  </Tooltip>
113
137
  </span>
138
+ {whatsNew}
114
139
  </span>
115
140
  )
116
141
  }
@@ -2,13 +2,14 @@ import { describe, expect, it } from "vitest";
2
2
  import { renderToString } from "react-dom/server";
3
3
  import { PrimaryNavRail } from "./PrimaryNavRail";
4
4
 
5
- function renderRail() {
5
+ function renderRail(whatsNew?: { unread: boolean; onOpen: () => void }) {
6
6
  return renderToString(
7
7
  <PrimaryNavRail
8
8
  activeView="home"
9
9
  onNavigate={() => {}}
10
10
  pinned
11
11
  onTogglePinned={() => {}}
12
+ whatsNew={whatsNew}
12
13
  />,
13
14
  );
14
15
  }
@@ -17,4 +18,12 @@ describe("PrimaryNavRail", () => {
17
18
  it("always surfaces Capacity — the view reads cluster capacity across every node manager, not just Karpenter", () => {
18
19
  expect(renderRail()).toContain("Capacity");
19
20
  });
21
+
22
+ it("shows What's new only when the host has notes, and marks it while unread", () => {
23
+ expect(renderRail()).not.toContain("What&#x27;s new");
24
+ const read = renderRail({ unread: false, onOpen: () => {} });
25
+ expect(read).toContain("What&#x27;s new");
26
+ expect(read).not.toContain("(unread)");
27
+ expect(renderRail({ unread: true, onOpen: () => {} })).toContain("(unread)");
28
+ });
20
29
  });
@@ -14,6 +14,7 @@ import {
14
14
  Gauge,
15
15
  ShieldCheck,
16
16
  Settings,
17
+ Megaphone,
17
18
  PanelLeftClose,
18
19
  PanelLeftOpen,
19
20
  } from "lucide-react";
@@ -97,6 +98,8 @@ interface PrimaryNavRailProps {
97
98
  // which self-nulls without auth so the row vanishes in no-auth OSS).
98
99
  onOpenSettings?: () => void;
99
100
  accountSlot?: ReactNode;
101
+ // Present only when release notes exist for this version; unread adds a dot.
102
+ whatsNew?: { unread: boolean; onOpen: () => void };
100
103
  }
101
104
 
102
105
  export function PrimaryNavRail({
@@ -107,6 +110,7 @@ export function PrimaryNavRail({
107
110
  showPinToggle = true,
108
111
  onOpenSettings,
109
112
  accountSlot,
113
+ whatsNew,
110
114
  }: PrimaryNavRailProps) {
111
115
  return (
112
116
  <aside
@@ -157,6 +161,15 @@ export function PrimaryNavRail({
157
161
  accountSlot self-nulls without auth, so no-auth OSS shows only Settings. */}
158
162
  <nav className="flex flex-col gap-0.5 px-2 pt-1 border-t border-theme-border/50">
159
163
  {accountSlot}
164
+ {whatsNew && (
165
+ <RailActionRow
166
+ icon={Megaphone}
167
+ label="What's new"
168
+ pinned={pinned}
169
+ onClick={whatsNew.onOpen}
170
+ unread={whatsNew.unread}
171
+ />
172
+ )}
160
173
  {onOpenSettings && (
161
174
  <RailActionRow
162
175
  icon={Settings}
@@ -337,11 +350,13 @@ export function RailActionRow({
337
350
  label,
338
351
  pinned,
339
352
  onClick,
353
+ unread = false,
340
354
  }: {
341
355
  icon: ComponentType<{ className?: string }>;
342
356
  label: string;
343
357
  pinned: boolean;
344
358
  onClick: () => void;
359
+ unread?: boolean;
345
360
  }) {
346
361
  return (
347
362
  <div className={clsx("group/item relative", !pinned && "w-10")}>
@@ -353,11 +368,18 @@ export function RailActionRow({
353
368
  !pinned && "max-w-10 overflow-hidden",
354
369
  )}
355
370
  >
356
- <span className="flex w-10 shrink-0 items-center justify-center">
357
- <Icon className="w-[18px] h-[18px] text-theme-text-tertiary group-hover/item:text-theme-text-secondary" />
371
+ <span className="relative flex w-10 shrink-0 items-center justify-center">
372
+ <Icon className={clsx(
373
+ "w-[18px] h-[18px] group-hover/item:text-theme-text-secondary",
374
+ unread ? "text-accent" : "text-theme-text-tertiary",
375
+ )} />
376
+ {unread && (
377
+ <span aria-hidden className="absolute top-1.5 right-2 h-2 w-2 rounded-full bg-accent ring-2 ring-theme-sidebar" />
378
+ )}
358
379
  </span>
359
- <span className={clsx("pr-3 truncate", !pinned && "opacity-0")}>
380
+ <span className={clsx("pr-3 truncate", !pinned && "opacity-0", unread && "text-theme-text-primary")}>
360
381
  {label}
382
+ {unread && <span className="sr-only"> (unread)</span>}
361
383
  </span>
362
384
  </button>
363
385
  {!pinned && (
@@ -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()
@@ -0,0 +1,18 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import { JobRenderer, JobSetRenderer } from './JobAdmissionRenderers'
4
+ vi.mock('../../execution/JobSetAdmission', () => ({ KueueAdmission: ({ presentation }: { presentation?: string }) => <div data-presentation={presentation}>admission evidence</div> }))
5
+ const job = { apiVersion: 'batch/v1', kind: 'Job', metadata: { name: 'training', namespace: 'ml' }, status: { conditions: [{ type: 'Failed', status: 'True', reason: 'BackoffLimitExceeded' }] } }
6
+ describe('admission renderer composition', () => {
7
+ it('keeps Job failure above admission evidence', () => {
8
+ const html = renderToStaticMarkup(<JobRenderer data={job} />)
9
+ expect(html).toContain('data-presentation="drawer"')
10
+ expect(html.indexOf('Job Issues')).toBeLessThan(html.indexOf('admission evidence'))
11
+ })
12
+ it('does not mount batch admission for a colliding Job kind', () => {
13
+ expect(renderToStaticMarkup(<JobRenderer data={{...job, apiVersion:'batch.volcano.sh/v1alpha1'}} />)).not.toContain('admission evidence')
14
+ })
15
+ it('mounts admission in the JobSet drawer', () => {
16
+ expect(renderToStaticMarkup(<JobSetRenderer data={{...job, apiVersion:'jobset.x-k8s.io/v1alpha2',kind:'JobSet'}} />)).toContain('admission evidence')
17
+ })
18
+ })
@@ -0,0 +1,18 @@
1
+ import { JobRenderer as BaseJobRenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/JobRenderer'
2
+ import { JobSetRenderer as BaseJobSetRenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/JobSetRenderer'
3
+ import type { ResourceRef } from '@skyhook-io/k8s-ui'
4
+ import { KueueAdmission } from '../../execution/JobSetAdmission'
5
+
6
+ type Props = { data: any; onNavigate?: (ref: ResourceRef) => void }
7
+
8
+ export function JobRenderer({ data, onNavigate }: Props) {
9
+ return <BaseJobRenderer data={data} admissionContent={
10
+ data.apiVersion === 'batch/v1' && data.kind === 'Job' && <KueueAdmission presentation="drawer" resource={data} namespace={data.metadata.namespace} name={data.metadata.name} onNavigate={onNavigate} />
11
+ } />
12
+ }
13
+
14
+ export function JobSetRenderer({ data, onNavigate }: Props) {
15
+ return <BaseJobSetRenderer data={data} admissionContent={
16
+ <KueueAdmission presentation="drawer" resource={data} namespace={data.metadata.namespace} name={data.metadata.name} onNavigate={onNavigate} />
17
+ } />
18
+ }
@@ -0,0 +1,40 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { RayJobRenderer } from './RayJobRenderer'
4
+ import { ApiError } from '../../../api/client'
5
+ const mock = vi.hoisted(() => ({ query: {} as any, reads: [] as any[] }))
6
+ vi.mock('../../../api/client', () => ({ ApiError: class extends Error { constructor(message: string, public status: number) { super(message) } }, useResource: (...args: any[]) => { mock.reads.push(args); return mock.query } }))
7
+ vi.mock('../../execution/JobSetAdmission', () => ({ KueueAdmission: () => null }))
8
+ const root = { apiVersion: 'ray.io/v1', kind: 'RayJob', metadata: { name: 'train', namespace: 'ml', uid: 'root' }, spec: {} }
9
+ const child = { apiVersion: 'batch/v1', kind: 'Job', metadata: { name: 'train', namespace: 'ml', ownerReferences: [{ apiVersion: 'ray.io/v1', kind: 'RayJob', name: 'train', uid: 'root', controller: true }] }, spec: {}, status: {} }
10
+ const render = (data: any = root) => renderToStaticMarkup(<RayJobRenderer data={data} onNavigate={() => {}} />)
11
+ describe('RayJob child reads', () => {
12
+ beforeEach(() => { mock.query = {}; mock.reads = [] })
13
+ it('reads the exact submitter candidate and accepts only the controller incarnation', () => {
14
+ mock.query = { data: child }
15
+ expect(render()).toContain('Open this Job for submitter Pods')
16
+ expect(mock.reads).toEqual([['jobs', 'ml', 'train', 'batch']])
17
+ expect(render({ ...root, metadata: { ...root.metadata, uid: 'recreated' } })).toContain('ownership mismatch')
18
+ })
19
+ it.each([
20
+ { ...child, apiVersion: 'batch.volcano.sh/v1alpha1' },
21
+ { ...child, metadata: { ...child.metadata, namespace: 'other' } },
22
+ { ...child, metadata: { ...child.metadata, ownerReferences: [{ ...child.metadata.ownerReferences[0], controller: false }] } },
23
+ ])('rejects wrong identity', data => { mock.query = { data }; expect(render()).toContain('ownership mismatch') })
24
+ it.each(['HTTPMode', 'SidecarMode', 'InteractiveMode', 'FutureMode'])('does not read a submitter in %s', submissionMode => { render({ ...root, spec: { submissionMode } }); expect(mock.reads).toEqual([]) })
25
+ it('does not read remote children, including selected clusters', () => { render({ ...root, spec: { managedBy: 'remote', clusterSelector: { 'ray.io/cluster': 'shared' } } }); expect(mock.reads).toEqual([]) })
26
+ it('keeps missing and denied observations distinct, even with cached data', () => {
27
+ mock.query = { data: child, error: new ApiError('not found', 404) }
28
+ expect(render()).toContain('No submitter Job observed')
29
+ mock.query.error = new ApiError('denied', 403)
30
+ expect(render()).toContain('Submitter evidence unavailable')
31
+ expect(render()).not.toContain('Open this Job')
32
+ })
33
+ it('keeps deleting submitter logs reachable without declaring a current attempt', () => {
34
+ mock.query = { data: { ...child, metadata: { ...child.metadata, deletionTimestamp: '2026-09-27T00:00:00Z' } } }
35
+ const html = render({ ...root, status: { jobDeploymentStatus: 'Retrying' } })
36
+ expect(html).toContain('Deleting')
37
+ expect(html).toContain('attempt being cleaned up')
38
+ expect(html).toContain('Open this Job')
39
+ })
40
+ })
@@ -0,0 +1,45 @@
1
+ import { Tooltip } from '@skyhook-io/k8s-ui/components/ui/Tooltip'
2
+ import { Badge } from '@skyhook-io/k8s-ui/components/ui/Badge'
3
+ import type { ResourceRef } from '@skyhook-io/k8s-ui'
4
+ import { RayJobRenderer as BaseRenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/RayJobRenderer'
5
+ import { rayJobIsLocal, rayJobSubmissionMode } from '@skyhook-io/k8s-ui/components/resources/resource-utils-ray'
6
+ import { getJobStatus } from '@skyhook-io/k8s-ui/components/resources/resource-utils'
7
+ import { apiVersionToGroup } from '@skyhook-io/k8s-ui/utils/navigation'
8
+ import { formatRelativeAgeTime } from '@skyhook-io/k8s-ui/utils/format'
9
+ import { AlertBanner, ResourceLink } from '@skyhook-io/k8s-ui/components/ui/drawer-components'
10
+ import { ApiError, useResource } from '../../../api/client'
11
+ import { KueueAdmission } from '../../execution/JobSetAdmission'
12
+
13
+ function Submitter({ root, onNavigate }: { root: any; onNavigate?: (ref: ResourceRef) => void }) {
14
+ const query = useResource<any>('jobs', root.metadata.namespace, root.metadata.name, 'batch')
15
+ if (query.error) return query.error instanceof ApiError && query.error.status === 404
16
+ ? <p className="text-theme-text-secondary">No submitter Job observed. It may not have been created yet or may have been cleaned up.</p>
17
+ : <AlertBanner variant="warning" title="Submitter evidence unavailable" message={query.error.message} />
18
+ if (!query.data) return <p className="text-theme-text-secondary">Reading submitter Job…</p>
19
+ const job = query.data
20
+ const owned = job.apiVersion === 'batch/v1' && job.kind === 'Job' && job.metadata?.name === root.metadata.name && job.metadata?.namespace === root.metadata.namespace && job.metadata.ownerReferences?.some((owner: any) => owner.controller === true && apiVersionToGroup(owner.apiVersion) === 'ray.io' && owner.kind === 'RayJob' && owner.name === root.metadata.name && owner.uid === root.metadata.uid)
21
+ if (!owned) return <AlertBanner variant="warning" title="Submitter ownership mismatch" message="The same-name Job does not report this RayJob incarnation as its controller. Its status is not attributed to this RayJob." />
22
+ const status = getJobStatus(job)
23
+ return <div className="space-y-2">
24
+ <div className="flex flex-wrap items-center gap-2"><ResourceLink kind="jobs" group="batch" namespace={job.metadata.namespace} name={job.metadata.name} onNavigate={onNavigate} />{job.metadata.deletionTimestamp ? <Badge severity="alert">Deleting</Badge> : <span className={`badge ${status.color}`}>{status.text}</span>}</div>
25
+ {job.metadata.creationTimestamp && <p className="text-xs text-theme-text-secondary">Created <Tooltip content={job.metadata.creationTimestamp}><time dateTime={job.metadata.creationTimestamp}>{formatRelativeAgeTime(job.metadata.creationTimestamp)}</time></Tooltip></p>}
26
+ <p className="text-xs text-theme-text-secondary">Open this Job for submitter Pods and logs. Ownership identifies the RayJob, not a particular attempt.</p>
27
+ {(job.metadata.deletionTimestamp || ['Retrying', 'Suspending'].includes(root.status?.jobDeploymentStatus)) && <p className="text-xs text-theme-text-secondary">The submitter may be part of the attempt being cleaned up.</p>}
28
+ </div>
29
+ }
30
+
31
+ function SelectedCluster({ namespace, name }: { namespace: string; name: string }) {
32
+ const query = useResource<any>('rayclusters', namespace, name, 'ray.io')
33
+ if (query.error) return <AlertBanner variant="warning" title="Selected cluster unavailable" message={query.error instanceof ApiError && query.error.status === 404 ? 'The selected existing RayCluster was not found. RayJob initialization requires that cluster.' : query.error.message} />
34
+ if (!query.data) return <p className="text-xs text-theme-text-secondary">Checking selected RayCluster…</p>
35
+ return <p className="text-xs text-theme-text-secondary">Selected RayCluster is present. It may be shared; its health is separate from this application.</p>
36
+ }
37
+
38
+ export function RayJobRenderer({ data, onNavigate }: { data: any; onNavigate?: (ref: ResourceRef) => void }) {
39
+ const local = rayJobIsLocal(data)
40
+ const selected = data.spec?.clusterSelector?.['ray.io/cluster']
41
+ return <BaseRenderer data={data} onNavigate={onNavigate}
42
+ admissionContent={<KueueAdmission resource={data} namespace={data.metadata.namespace} name={data.metadata.name} onNavigate={onNavigate} presentation="card" />}
43
+ submitterEvidence={local && rayJobSubmissionMode(data) === 'K8sJobMode' && data.metadata.uid ? <Submitter root={data} onNavigate={onNavigate} /> : undefined}
44
+ selectedClusterEvidence={local && selected ? <SelectedCluster namespace={data.metadata.namespace} name={selected} /> : undefined} />
45
+ }