@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.
- package/README.md +2 -0
- package/package.json +6 -5
- package/src/App.tsx +65 -17
- package/src/RadarApp.tsx +51 -21
- package/src/api/apiResources.test.ts +19 -1
- package/src/api/apiResources.ts +18 -3
- package/src/api/client.admission.test.ts +34 -0
- package/src/api/client.capacity.test.ts +15 -0
- package/src/api/client.trace.test.ts +9 -1
- package/src/api/client.ts +269 -204
- package/src/api/diagnose.test.ts +102 -1
- package/src/api/diagnose.ts +42 -11
- package/src/api/drain-plan.test.ts +0 -10
- package/src/api/httpErrors.test.ts +95 -0
- package/src/api/httpErrors.ts +101 -0
- package/src/api/policy.ts +33 -5
- package/src/api/radarFeatures.render.test.tsx +190 -0
- package/src/api/radarFeatures.test.ts +133 -0
- package/src/api/radarFeatures.ts +147 -0
- package/src/api/timelineSource.test.ts +2 -10
- package/src/api/timelineSource.ts +3 -2
- package/src/api/traffic.ts +4 -3
- package/src/api/usage-data.fixtures.ts +33 -0
- package/src/api/usage-data.ts +165 -0
- package/src/components/CloudFunnelButton.test.tsx +2 -2
- package/src/components/CloudFunnelButton.tsx +99 -55
- package/src/components/NamespaceSwitcher.test.tsx +47 -0
- package/src/components/NamespaceSwitcher.tsx +17 -1
- package/src/components/SelfManagedStart.tsx +95 -0
- package/src/components/applications/ApplicationsView.tsx +20 -6
- package/src/components/audit/UpgradeReadinessView.test.ts +14 -6
- package/src/components/audit/UpgradeReadinessView.tsx +9 -13
- package/src/components/capacity/CapacityView.test.tsx +13 -4
- package/src/components/capacity/shared.tsx +14 -13
- package/src/components/cloudConnectHandoff.test.ts +1 -1
- package/src/components/cloudConnectHandoff.ts +1 -1
- package/src/components/cost/ApplicationCostTab.tsx +9 -1
- package/src/components/cost/CostConnectionAction.tsx +30 -0
- package/src/components/cost/CostTrendChart.tsx +2 -0
- package/src/components/cost/CostView.tsx +4 -24
- package/src/components/cost/WorkloadCostTab.tsx +7 -1
- package/src/components/cost/source.test.ts +30 -3
- package/src/components/cost/source.ts +37 -2
- package/src/components/curl/ServiceCurlButton.tsx +7 -3
- package/src/components/diagnose/ActivityTurn.tsx +3 -2
- package/src/components/diagnose/ApplyDialog.tsx +4 -2
- package/src/components/dialog-names.test.ts +69 -0
- package/src/components/dock/NodeTerminalTab.tsx +2 -1
- package/src/components/dock/TerminalTab.tsx +2 -1
- package/src/components/dock/TrafficFlowListTab.tsx +2 -2
- package/src/components/execution/BatchExecutionView.tsx +1 -1
- package/src/components/execution/JobSetAdmission.test.tsx +160 -37
- package/src/components/execution/JobSetAdmission.tsx +16 -7
- package/src/components/gitops/GitOpsView.tsx +12 -0
- package/src/components/helm/TrackChartSourceDialog.tsx +5 -3
- package/src/components/home/HomeView.tsx +7 -3
- package/src/components/home/MCPSetupDialog.tsx +37 -37
- package/src/components/home/RadarVersionLine.test.tsx +14 -0
- package/src/components/home/RadarVersionLine.tsx +28 -3
- package/src/components/nav/PrimaryNavRail.test.tsx +10 -1
- package/src/components/nav/PrimaryNavRail.tsx +25 -3
- package/src/components/resource/PVCUsageBar.render.test.tsx +16 -0
- package/src/components/resource/PVCUsageBar.tsx +4 -1
- package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -0
- package/src/components/resource/PrometheusChartsGrid.tsx +4 -0
- package/src/components/resources/ImageFilesystemModal.tsx +2 -1
- package/src/components/resources/PodFilePreview.tsx +4 -1
- package/src/components/resources/PodFilesystemModal.tsx +3 -2
- package/src/components/resources/ResourcesView.tsx +2 -1
- package/src/components/resources/renderers/JobAdmissionRenderers.test.tsx +18 -0
- package/src/components/resources/renderers/JobAdmissionRenderers.tsx +18 -0
- package/src/components/resources/renderers/RayJobRenderer.test.tsx +40 -0
- package/src/components/resources/renderers/RayJobRenderer.tsx +45 -0
- package/src/components/rightsizing/RightsizingScanView.tsx +4 -1
- package/src/components/settings/ConnectionHeadersEditor.tsx +109 -0
- package/src/components/settings/FormSaveActions.tsx +76 -0
- package/src/components/settings/IntegrationConnectionForms.tsx +391 -0
- package/src/components/settings/LocalConfigurationDetails.tsx +389 -0
- package/src/components/settings/LocalConnectionSettings.test.tsx +189 -0
- package/src/components/settings/LocalConnectionSettings.tsx +1015 -0
- package/src/components/settings/LocalIntegrationStatus.tsx +50 -0
- package/src/components/settings/PrivacySection.test.tsx +84 -0
- package/src/components/settings/PrivacySection.tsx +140 -0
- package/src/components/settings/PrometheusConfigField.tsx +30 -0
- package/src/components/settings/PrometheusConnectionForm.tsx +295 -0
- package/src/components/settings/SettingsDialog.tsx +393 -571
- package/src/components/settings/controls.tsx +55 -0
- package/src/components/settings/currency-options.test.ts +1 -1
- package/src/components/settings/currency-options.ts +1 -1
- package/src/components/settings/settings-state.test.ts +17 -10
- package/src/components/settings/settings-state.ts +21 -12
- package/src/components/timeline/TimelineList.test.ts +56 -0
- package/src/components/timeline/TimelineList.tsx +7 -1
- package/src/components/traffic/TrafficFlowList.tsx +5 -21
- package/src/components/traffic/TrafficFlowListContext.tsx +6 -4
- package/src/components/traffic/TrafficGraph.tsx +79 -48
- package/src/components/traffic/TrafficView.tsx +61 -37
- package/src/components/traffic/TrafficWizard.tsx +4 -2
- package/src/components/traffic/trafficFilters.test.ts +154 -1
- package/src/components/traffic/trafficFilters.ts +141 -0
- package/src/components/ui/ErrorBoundary.tsx +5 -0
- package/src/components/ui/Omnibar.tsx +17 -1
- package/src/components/ui/UpdateNotification.tsx +70 -21
- package/src/components/ui/command-items.ts +14 -0
- package/src/components/usage-data/UsageDataAsk.test.tsx +41 -0
- package/src/components/usage-data/UsageDataAsk.tsx +106 -0
- package/src/components/usage-data/UsageDataPrompt.test.ts +53 -0
- package/src/components/usage-data/UsageDataPrompt.tsx +148 -0
- package/src/components/whats-new/WhatsNew.test.ts +110 -0
- package/src/components/whats-new/WhatsNew.tsx +423 -0
- package/src/components/whats-new/WhatsNewDialog.test.tsx +229 -0
- package/src/components/whats-new/check-whats-new.test.ts +65 -0
- package/src/components/whats-new/releaseNotes.ts +121 -0
- package/src/components/workload/WorkloadView.tsx +47 -14
- package/src/components/workload/historyPaging.test.ts +72 -0
- package/src/components/workload/historyPaging.ts +147 -0
- package/src/components/workload/useHistoryPaging.test.tsx +81 -0
- package/src/context/ConnectionContext.tsx +2 -1
- package/src/context/RadarUpgrade.tsx +30 -0
- package/src/context/RadarUpgradeHost.tsx +23 -0
- package/src/hooks/usePreviousIntegrationSettings.render.test.tsx +62 -0
- package/src/hooks/usePreviousIntegrationSettings.test.ts +62 -0
- package/src/hooks/usePreviousIntegrationSettings.ts +89 -0
- package/src/index.ts +1 -0
- package/src/k8s-ui-exports.test.ts +73 -0
- package/src/utils/navigation.test.ts +17 -1
- package/src/utils/navigation.ts +16 -0
- 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
|
|
119
|
+
const mcpServersUrlConfig = (key: 'url' | 'serverUrl') => JSON.stringify({
|
|
120
120
|
mcpServers: {
|
|
121
121
|
radar: {
|
|
122
|
-
|
|
123
|
-
url: mcpUrl,
|
|
122
|
+
[key]: mcpUrl,
|
|
124
123
|
}
|
|
125
124
|
}
|
|
126
125
|
}, null, 2)
|
|
127
126
|
|
|
128
|
-
const
|
|
129
|
-
|
|
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
|
|
137
|
-
|
|
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: "
|
|
139
|
+
type: "remote",
|
|
148
140
|
url: mcpUrl,
|
|
141
|
+
enabled: true,
|
|
149
142
|
}
|
|
150
143
|
}
|
|
151
144
|
}, null, 2)
|
|
152
145
|
|
|
153
|
-
const
|
|
146
|
+
const clineConfig = JSON.stringify({
|
|
154
147
|
mcpServers: {
|
|
155
148
|
radar: {
|
|
156
|
-
|
|
149
|
+
type: "streamableHttp",
|
|
150
|
+
url: mcpUrl,
|
|
157
151
|
}
|
|
158
152
|
}
|
|
159
153
|
}, null, 2)
|
|
160
154
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
const
|
|
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
|
-
|
|
162
|
+
command: "npx",
|
|
163
|
+
args: mcpRemoteArgs,
|
|
167
164
|
}
|
|
168
165
|
}
|
|
169
166
|
}, null, 2)
|
|
170
167
|
|
|
171
|
-
const
|
|
172
|
-
|
|
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
|
|
180
|
-
|
|
176
|
+
const geminiConfig = JSON.stringify({
|
|
177
|
+
mcpServers: {
|
|
181
178
|
radar: {
|
|
182
|
-
|
|
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
|
|
289
|
-
{ icon:
|
|
290
|
-
{ icon: Code2, name: 'Cursor', path: '~/.cursor/mcp.json', config:
|
|
291
|
-
{ icon: Code2, name: '
|
|
292
|
-
{ icon:
|
|
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:
|
|
297
|
-
{ icon:
|
|
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's new')
|
|
149
|
+
expect(renderToString(<RadarVersionLine version={upToDate} onShowWhatsNew={() => {}} />)).toContain('What's new')
|
|
150
|
+
expect(renderToString(<RadarVersionLine version={version} onShowWhatsNew={() => {}} />)).toContain('What'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'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
|
|
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's new");
|
|
24
|
+
const read = renderRail({ unread: false, onOpen: () => {} });
|
|
25
|
+
expect(read).toContain("What'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=
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|