@skyhook-io/radar-app 1.14.5 → 1.15.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 -9
- package/package.json +6 -6
- package/src/App.tsx +142 -334
- package/src/RadarApp.tsx +3 -8
- package/src/api/client.admission.test.ts +34 -0
- package/src/api/client.jobset.test.ts +18 -0
- package/src/api/client.resource-identity.test.ts +44 -0
- package/src/api/client.rightsizing.test.ts +7 -3
- package/src/api/client.ts +267 -78
- package/src/api/preferences.test.ts +54 -0
- package/src/api/preferences.ts +38 -0
- package/src/api/usage-data.fixtures.ts +33 -0
- package/src/api/usage-data.ts +165 -0
- package/src/components/CloudFunnelButton.test.tsx +155 -0
- package/src/components/CloudFunnelButton.tsx +113 -63
- package/src/components/ContextSwitcher.tsx +13 -132
- package/src/components/SelfManagedStart.tsx +96 -0
- package/src/components/applications/ApplicationsView.identity.test.ts +39 -0
- package/src/components/applications/ApplicationsView.tsx +5 -3
- package/src/components/audit/AuditSettingsDialog.tsx +15 -11
- package/src/components/cloudConnectHandoff.test.ts +1 -1
- package/src/components/cloudConnectHandoff.ts +1 -1
- package/src/components/diagnose/ActivityTurn.tsx +3 -2
- package/src/components/diagnose/AgentControls.tsx +26 -3
- package/src/components/diagnose/ApplyDialog.tsx +4 -2
- package/src/components/diagnose/DiagnoseContext.tsx +1 -0
- package/src/components/diagnose/agentCatalog.ts +6 -0
- package/src/components/diagnose/launch.test.ts +23 -0
- package/src/components/diagnose/launch.ts +4 -0
- package/src/components/diagnose/parts.test.tsx +18 -0
- package/src/components/dialog-names.test.ts +69 -0
- package/src/components/dock/NodeTerminalTab.test.tsx +34 -0
- package/src/components/dock/NodeTerminalTab.tsx +5 -3
- package/src/components/dock/WorkloadLogsTab.test.tsx +66 -0
- package/src/components/dock/WorkloadLogsTab.tsx +2 -1
- package/src/components/execution/BatchExecutionView.render.test.tsx +2 -1
- package/src/components/execution/BatchExecutionView.test.ts +52 -1
- package/src/components/execution/BatchExecutionView.tsx +324 -65
- package/src/components/execution/JobSetAdmission.test.tsx +165 -0
- package/src/components/execution/JobSetAdmission.tsx +26 -0
- package/src/components/execution/JobSetMemberComparison.test.tsx +30 -0
- package/src/components/execution/JobSetMemberComparison.tsx +88 -0
- package/src/components/execution/batch-run-actions.test.ts +1 -0
- package/src/components/execution/batch-timeline.test.ts +3 -0
- package/src/components/execution/execution-definition.test.ts +15 -1
- package/src/components/execution/execution-definition.ts +4 -0
- package/src/components/execution/member-collection.render.test.tsx +142 -0
- package/src/components/gitops/GitOpsView.tsx +33 -2
- package/src/components/gitops/destination-toast.test.ts +33 -0
- package/src/components/gitops/destination-toast.ts +46 -0
- package/src/components/gitops/useDestinationCluster.ts +30 -0
- package/src/components/helm/TrackChartSourceDialog.tsx +25 -14
- package/src/components/home/HomeView.tsx +6 -2
- package/src/components/home/MCPSetupDialog.tsx +10 -0
- package/src/components/home/RadarVersionLine.test.tsx +14 -0
- package/src/components/home/RadarVersionLine.tsx +28 -3
- package/src/components/logs/ScheduledWorkloadLogsViewer.tsx +75 -22
- package/src/components/logs/WorkloadLogsViewer.tsx +7 -4
- package/src/components/nav/PrimaryNavRail.test.tsx +10 -1
- package/src/components/nav/PrimaryNavRail.tsx +27 -8
- package/src/components/resource/PVCUsageBar.render.test.tsx +168 -0
- package/src/components/resource/PVCUsageBar.tsx +62 -16
- package/src/components/resource/PrometheusChartsGrid.render.test.tsx +1 -1
- package/src/components/resources/ResourcesView.tsx +3 -3
- package/src/components/resources/renderers/CAPIClusterRenderer.tsx +8 -0
- package/src/components/resources/renderers/JobAdmissionRenderers.test.tsx +18 -0
- package/src/components/resources/renderers/JobAdmissionRenderers.tsx +18 -0
- package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +39 -0
- package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +37 -0
- package/src/components/resources/renderers/RayClusterRenderer.test.tsx +18 -0
- package/src/components/resources/renderers/RayClusterRenderer.tsx +17 -0
- package/src/components/resources/renderers/RayJobRenderer.test.tsx +40 -0
- package/src/components/resources/renderers/RayJobRenderer.tsx +45 -0
- package/src/components/resources/renderers/RayServiceRenderer.test.tsx +69 -0
- package/src/components/resources/renderers/RayServiceRenderer.tsx +38 -0
- package/src/components/rightsizing/RightsizingScanView.tsx +34 -22
- package/src/components/rightsizing/copy.test.ts +7 -0
- package/src/components/rightsizing/model.test.ts +2 -1
- package/src/components/rightsizing/notices.test.tsx +4 -3
- package/src/components/settings/OperatorManagedNotice.tsx +17 -0
- package/src/components/settings/PrivacySection.test.tsx +84 -0
- package/src/components/settings/PrivacySection.tsx +140 -0
- package/src/components/settings/SettingsDialog.tsx +168 -92
- package/src/components/settings/controls.tsx +55 -0
- package/src/components/settings/settings-state.test.ts +18 -0
- package/src/components/settings/settings-state.ts +19 -0
- package/src/components/timeline/TimelineView.tsx +9 -3
- package/src/components/timeline/TimelineView.urlparams.test.ts +18 -1
- package/src/components/ui/ErrorBoundary.tsx +5 -0
- package/src/components/ui/Omnibar.tsx +16 -0
- package/src/components/ui/UpdateNotification.tsx +13 -9
- package/src/components/ui/command-items.ts +14 -3
- 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/useContextSwitchFlow.tsx +147 -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.test.ts +17 -1
- package/src/components/workload/WorkloadView.tsx +47 -19
- package/src/context/ContextSwitchContext.tsx +18 -2
- package/src/context/NavCustomization.tsx +4 -58
- package/src/context/ThemeContext.tsx +3 -11
- package/src/hooks/useClusterLoadState.ts +2 -2
- package/src/hooks/useFavorites.ts +3 -5
- package/src/k8s-ui-exports.test.ts +73 -0
- package/src/utils/auditBadges.ts +1 -1
- package/src/utils/navigation.test.ts +34 -1
- package/src/utils/navigation.ts +28 -5
- package/src/utils/topology-selection.test.ts +56 -0
- package/src/utils/topology-selection.ts +9 -9
- package/src/utils/version.ts +11 -0
- package/src/components/ui/CommandPalette.tsx +0 -261
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { ArrowLeft, ArrowUpRight } from 'lucide-react'
|
|
2
|
+
import { SELF_HOSTED_DOCS_URL, signupUrlFor } from './cloudConnectHandoff'
|
|
3
|
+
|
|
4
|
+
// What the Cloud dialog shows after "Run Radar Cloud yourself…". Running your
|
|
5
|
+
// own control plane starts with a Radar Cloud account, because the Hub signs
|
|
6
|
+
// the Self-Managed license, so this view names that as step 1 of 3 before the
|
|
7
|
+
// click instead of letting a sign-up page surprise someone who came to host
|
|
8
|
+
// it themselves. The link carries intent=self-hosting: the Hub's sign-in page
|
|
9
|
+
// repeats the steps and, after sign-up or sign-in, continues on its
|
|
10
|
+
// Self-hosting page, where the trial, the license and the install command are.
|
|
11
|
+
export function SelfManagedStart({ appUrl, onBack }: { appUrl: string; onBack: () => void }) {
|
|
12
|
+
const signupUrl = `${signupUrlFor(appUrl, 'self-managed-signup')}&intent=self-hosting`
|
|
13
|
+
const steps = [
|
|
14
|
+
{
|
|
15
|
+
title: 'Create your Radar Cloud account',
|
|
16
|
+
body: 'Your license is issued from it. An existing account works too.',
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
title: 'Generate your install command',
|
|
20
|
+
body: 'Self-Managed is part of Enterprise, with a 14-day free trial.',
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
title: 'Install the control plane, then connect each cluster',
|
|
24
|
+
body: 'One Helm command installs it. Then one command per cluster to connect it.',
|
|
25
|
+
},
|
|
26
|
+
]
|
|
27
|
+
return (
|
|
28
|
+
<>
|
|
29
|
+
<div className="min-h-0 overflow-y-auto">
|
|
30
|
+
<div className="px-8 pb-5">
|
|
31
|
+
<h3 className="text-[20px] font-semibold leading-tight tracking-tight text-theme-text-primary mb-1.5">
|
|
32
|
+
Run Radar Cloud yourself
|
|
33
|
+
</h3>
|
|
34
|
+
<p className="text-[13px] leading-relaxed text-theme-text-secondary mb-5">
|
|
35
|
+
Run the Radar Cloud control plane in your own infrastructure: the same fleet view, alerts, history
|
|
36
|
+
and team access, and your cluster data never leaves your network.
|
|
37
|
+
</p>
|
|
38
|
+
<ol className="space-y-3.5">
|
|
39
|
+
{steps.map((step, i) => (
|
|
40
|
+
<li key={step.title} className="flex gap-3">
|
|
41
|
+
<span
|
|
42
|
+
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[10.5px] font-semibold ${
|
|
43
|
+
i === 0 ? 'bg-emerald-500 text-emerald-950' : 'border border-theme-border text-theme-text-tertiary'
|
|
44
|
+
}`}
|
|
45
|
+
>
|
|
46
|
+
{i + 1}
|
|
47
|
+
</span>
|
|
48
|
+
<div className="min-w-0">
|
|
49
|
+
<p className={`text-[13px] font-medium ${i === 0 ? 'text-theme-text-primary' : 'text-theme-text-secondary'}`}>
|
|
50
|
+
{step.title}
|
|
51
|
+
</p>
|
|
52
|
+
<p className="text-[12px] leading-relaxed text-theme-text-tertiary">{step.body}</p>
|
|
53
|
+
</div>
|
|
54
|
+
</li>
|
|
55
|
+
))}
|
|
56
|
+
</ol>
|
|
57
|
+
{/* What a platform engineer has to bring, split by stage: a trial
|
|
58
|
+
needs only an address clusters can reach (a cloud load balancer
|
|
59
|
+
is the install page's default; Postgres and a built-in admin come
|
|
60
|
+
bundled); production adds their own Postgres and IdP. */}
|
|
61
|
+
<div className="mt-5 space-y-1 text-[12px] leading-relaxed text-theme-text-tertiary">
|
|
62
|
+
<p>
|
|
63
|
+
<b className="font-medium text-theme-text-secondary">For a trial</b>, you just need a hostname your
|
|
64
|
+
clusters can reach the hub on.
|
|
65
|
+
</p>
|
|
66
|
+
<p>
|
|
67
|
+
<b className="font-medium text-theme-text-secondary">For production</b>, connect your own Postgres and
|
|
68
|
+
your IdP to manage users.
|
|
69
|
+
</p>
|
|
70
|
+
<a href={SELF_HOSTED_DOCS_URL} target="_blank" rel="noopener noreferrer" className="inline-block underline underline-offset-2 hover:text-theme-text-primary">
|
|
71
|
+
Read the Self-Managed guide →
|
|
72
|
+
</a>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
<div className="shrink-0 px-8 py-4 bg-theme-base border-t border-theme-border flex flex-wrap items-center gap-x-3 gap-y-2.5">
|
|
77
|
+
<button
|
|
78
|
+
onClick={onBack}
|
|
79
|
+
className="inline-flex items-center gap-1.5 whitespace-nowrap text-[12.5px] text-theme-text-secondary hover:text-theme-text-primary transition-colors"
|
|
80
|
+
>
|
|
81
|
+
<ArrowLeft className="w-3.5 h-3.5" />
|
|
82
|
+
Back to Radar Cloud
|
|
83
|
+
</button>
|
|
84
|
+
<a
|
|
85
|
+
href={signupUrl}
|
|
86
|
+
target="_blank"
|
|
87
|
+
rel="noopener noreferrer"
|
|
88
|
+
className="ml-auto inline-flex items-center gap-1.5 whitespace-nowrap px-5 py-2 rounded-[10px] bg-emerald-500 hover:bg-emerald-400 text-emerald-950 text-[13.5px] font-bold shadow-[0_0_22px_rgba(16,185,129,0.35)] hover:shadow-[0_0_30px_rgba(16,185,129,0.5)] transition-all"
|
|
89
|
+
>
|
|
90
|
+
Create your Radar Cloud account
|
|
91
|
+
<ArrowUpRight className="w-4 h-4" />
|
|
92
|
+
</a>
|
|
93
|
+
</div>
|
|
94
|
+
</>
|
|
95
|
+
)
|
|
96
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import type { AppWorkload, Issue } from '@skyhook-io/k8s-ui'
|
|
4
|
+
import { appIssuesForWorkloads } from './ApplicationsView'
|
|
5
|
+
|
|
6
|
+
function workload(group: string): AppWorkload {
|
|
7
|
+
return { kind: 'Job', group, namespace: 'ml', name: 'train', health: 'healthy', ready: 1, desired: 1, restarts: 0 }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function issue(id: string, group?: string): Issue {
|
|
11
|
+
return {
|
|
12
|
+
id,
|
|
13
|
+
severity: 'warning',
|
|
14
|
+
source: 'condition',
|
|
15
|
+
category: 'job_failed',
|
|
16
|
+
category_group: 'runtime',
|
|
17
|
+
grouping_scope: 'workload',
|
|
18
|
+
kind: 'Job',
|
|
19
|
+
group,
|
|
20
|
+
namespace: 'ml',
|
|
21
|
+
name: 'train',
|
|
22
|
+
reason: id,
|
|
23
|
+
message: id,
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('appIssuesForWorkloads identity', () => {
|
|
28
|
+
it('does not share an issue between same-named core and Volcano Jobs', () => {
|
|
29
|
+
const issues = [issue('core', 'batch'), issue('volcano', 'batch.volcano.sh')]
|
|
30
|
+
|
|
31
|
+
expect(appIssuesForWorkloads(issues, [workload('batch')]).map(item => item.id)).toEqual(['core'])
|
|
32
|
+
expect(appIssuesForWorkloads(issues, [workload('batch.volcano.sh')]).map(item => item.id)).toEqual(['volcano'])
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('treats an omitted built-in group as the canonical built-in identity', () => {
|
|
36
|
+
expect(appIssuesForWorkloads([issue('legacy')], [workload('batch')]).map(item => item.id)).toEqual(['legacy'])
|
|
37
|
+
expect(appIssuesForWorkloads([issue('legacy')], [workload('batch.volcano.sh')])).toEqual([])
|
|
38
|
+
})
|
|
39
|
+
})
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
memberRef,
|
|
24
24
|
subjectRef,
|
|
25
25
|
compareIssueSortAnchors,
|
|
26
|
+
canonicalResourceGroup,
|
|
26
27
|
type AppRow,
|
|
27
28
|
type AppWorkload,
|
|
28
29
|
type AppIdentityInstance,
|
|
@@ -490,6 +491,7 @@ function AppDetailRoute({
|
|
|
490
491
|
source.kind,
|
|
491
492
|
source.namespace,
|
|
492
493
|
source.name,
|
|
494
|
+
source.group,
|
|
493
495
|
);
|
|
494
496
|
if (path) navigate(path);
|
|
495
497
|
return;
|
|
@@ -899,7 +901,7 @@ function compareAppOverviewIssues(a: Issue, b: Issue): number {
|
|
|
899
901
|
return a.id.localeCompare(b.id);
|
|
900
902
|
}
|
|
901
903
|
|
|
902
|
-
function appIssuesForWorkloads(
|
|
904
|
+
export function appIssuesForWorkloads(
|
|
903
905
|
issues: Issue[],
|
|
904
906
|
workloads: AppWorkload[],
|
|
905
907
|
): Issue[] {
|
|
@@ -918,11 +920,11 @@ function appIssuesForWorkloads(
|
|
|
918
920
|
}
|
|
919
921
|
|
|
920
922
|
function workloadIssueKey(workload: AppWorkload): string {
|
|
921
|
-
return `${workload.kind.toLowerCase()}|${workload.namespace}|${workload.name}`;
|
|
923
|
+
return `${canonicalResourceGroup(workload.kind, workload.group) ?? "?"}|${workload.kind.toLowerCase()}|${workload.namespace}|${workload.name}`;
|
|
922
924
|
}
|
|
923
925
|
|
|
924
926
|
function issueRefKey(ref: IssueResourceRef): string {
|
|
925
|
-
return `${ref.kind.toLowerCase()}|${ref.namespace ?? ""}|${ref.name}`;
|
|
927
|
+
return `${canonicalResourceGroup(ref.kind, ref.group) ?? "?"}|${ref.kind.toLowerCase()}|${ref.namespace ?? ""}|${ref.name}`;
|
|
926
928
|
}
|
|
927
929
|
|
|
928
930
|
function issueRefs(issue: Issue): IssueResourceRef[] {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { useState, useEffect, useMemo } from 'react'
|
|
2
2
|
import { X, Plus, Trash2, Lock } from 'lucide-react'
|
|
3
3
|
import { clsx } from 'clsx'
|
|
4
|
-
import { useAuditSettings, useUpdateAuditSettings, useAudit, useCloudRole } from '../../api/client'
|
|
4
|
+
import { useAuditSettings, useUpdateAuditSettings, useAudit, useCloudRole, useCapabilities } from '../../api/client'
|
|
5
|
+
import { OperatorManagedNotice } from '../settings/OperatorManagedNotice'
|
|
5
6
|
import type { CheckMeta } from '@skyhook-io/k8s-ui'
|
|
6
7
|
import { validateRFC1123Label, type ValidationResult } from '@skyhook-io/k8s-ui/utils/validators'
|
|
7
8
|
import { Tooltip } from '../ui/Tooltip'
|
|
@@ -20,7 +21,9 @@ export function AuditSettingsDialog({ namespaces, onClose }: AuditSettingsDialog
|
|
|
20
21
|
// server-side too). Non-owners get a read-only view. Non-Cloud callers
|
|
21
22
|
// have no role and pass.
|
|
22
23
|
const { canAtLeast } = useCloudRole()
|
|
23
|
-
const
|
|
24
|
+
const { data: capabilities } = useCapabilities()
|
|
25
|
+
const operatorManaged = capabilities?.configManagement === 'operator'
|
|
26
|
+
const canEdit = capabilities != null && !operatorManaged && canAtLeast('owner')
|
|
24
27
|
const [ignoredNs, setIgnoredNs] = useState<string[]>([])
|
|
25
28
|
const [disabledChecks, setDisabledChecks] = useState<string[]>([])
|
|
26
29
|
const [newNs, setNewNs] = useState('')
|
|
@@ -82,7 +85,8 @@ export function AuditSettingsDialog({ namespaces, onClose }: AuditSettingsDialog
|
|
|
82
85
|
</div>
|
|
83
86
|
|
|
84
87
|
<div className="px-5 py-4 overflow-y-auto flex-1">
|
|
85
|
-
{
|
|
88
|
+
{operatorManaged && <div className="mb-4"><OperatorManagedNotice helmValue="audit" /></div>}
|
|
89
|
+
{capabilities && !operatorManaged && !canEdit && (
|
|
86
90
|
<div className="mb-4 rounded-lg border border-theme-border bg-theme-elevated/50 p-3 flex items-start gap-2.5">
|
|
87
91
|
<Lock className="w-3.5 h-3.5 mt-0.5 shrink-0 text-theme-text-tertiary" />
|
|
88
92
|
<p className="text-xs text-theme-text-tertiary">
|
|
@@ -104,13 +108,13 @@ export function AuditSettingsDialog({ namespaces, onClose }: AuditSettingsDialog
|
|
|
104
108
|
{ignoredNs.map(ns => (
|
|
105
109
|
<div key={ns} className="flex items-center justify-between px-3 py-1.5 bg-theme-elevated rounded-lg">
|
|
106
110
|
<span className="text-sm text-theme-text-primary">{ns}</span>
|
|
107
|
-
<button
|
|
111
|
+
{!operatorManaged && <button
|
|
108
112
|
onClick={() => setIgnoredNs(ignoredNs.filter(n => n !== ns))}
|
|
109
113
|
disabled={!canEdit}
|
|
110
114
|
className="p-1 rounded hover:bg-theme-hover text-theme-text-tertiary hover:text-red-400 transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:text-theme-text-tertiary"
|
|
111
115
|
>
|
|
112
116
|
<Trash2 className="w-3.5 h-3.5" />
|
|
113
|
-
</button>
|
|
117
|
+
</button>}
|
|
114
118
|
</div>
|
|
115
119
|
))}
|
|
116
120
|
{ignoredNs.length === 0 && (
|
|
@@ -118,7 +122,7 @@ export function AuditSettingsDialog({ namespaces, onClose }: AuditSettingsDialog
|
|
|
118
122
|
)}
|
|
119
123
|
</div>
|
|
120
124
|
|
|
121
|
-
<div className="flex gap-2">
|
|
125
|
+
{!operatorManaged && <div className="flex gap-2">
|
|
122
126
|
<Input
|
|
123
127
|
value={newNs}
|
|
124
128
|
onChange={e => setNewNs(e.target.value)}
|
|
@@ -141,7 +145,7 @@ export function AuditSettingsDialog({ namespaces, onClose }: AuditSettingsDialog
|
|
|
141
145
|
>
|
|
142
146
|
<Plus className="w-4 h-4" />
|
|
143
147
|
</button>
|
|
144
|
-
</div>
|
|
148
|
+
</div>}
|
|
145
149
|
{(newNsError || newNsDuplicate) && (
|
|
146
150
|
<p id="new-ns-help" className="mt-1.5 text-xs text-red-400">
|
|
147
151
|
{newNsDuplicate
|
|
@@ -157,7 +161,7 @@ export function AuditSettingsDialog({ namespaces, onClose }: AuditSettingsDialog
|
|
|
157
161
|
Enabled Checks
|
|
158
162
|
</label>
|
|
159
163
|
<p className="text-xs text-theme-text-tertiary mt-1 mb-3">
|
|
160
|
-
Uncheck to disable specific checks globally across all views.
|
|
164
|
+
{operatorManaged ? 'Checks enabled by the installation’s audit policy.' : 'Uncheck to disable specific checks globally across all views.'}
|
|
161
165
|
</p>
|
|
162
166
|
|
|
163
167
|
<div className="flex flex-col gap-0.5">
|
|
@@ -191,9 +195,9 @@ export function AuditSettingsDialog({ namespaces, onClose }: AuditSettingsDialog
|
|
|
191
195
|
onClick={onClose}
|
|
192
196
|
className="px-4 py-1.5 text-sm text-theme-text-secondary hover:text-theme-text-primary bg-theme-elevated hover:bg-theme-hover border border-theme-border rounded-lg transition-colors"
|
|
193
197
|
>
|
|
194
|
-
Cancel
|
|
198
|
+
{operatorManaged ? 'Close' : 'Cancel'}
|
|
195
199
|
</button>
|
|
196
|
-
<Tooltip
|
|
200
|
+
{!operatorManaged && <Tooltip
|
|
197
201
|
content={
|
|
198
202
|
!canEdit
|
|
199
203
|
? 'Audit settings can only be changed by owners'
|
|
@@ -216,7 +220,7 @@ export function AuditSettingsDialog({ namespaces, onClose }: AuditSettingsDialog
|
|
|
216
220
|
>
|
|
217
221
|
{updateSettings.isPending ? 'Saving...' : 'Save'}
|
|
218
222
|
</button>
|
|
219
|
-
</Tooltip>
|
|
223
|
+
</Tooltip>}
|
|
220
224
|
</div>
|
|
221
225
|
</div>
|
|
222
226
|
</div>
|
|
@@ -171,7 +171,7 @@ describe('composeAdminNote — an ask, then the link, then the evidence', () =>
|
|
|
171
171
|
expect(note).toContain('Please confirm nothing is already installed before a fresh install.')
|
|
172
172
|
// The object of the request and the answer to "no SaaS", before the evidence.
|
|
173
173
|
expect(note.indexOf('What it is: Radar, the open-source Kubernetes tool')).toBeLessThan(note.indexOf('Details:'))
|
|
174
|
-
expect(note).toContain(`can
|
|
174
|
+
expect(note).toContain(`can run in-house as Radar Cloud Self-Managed: ${SELF_HOSTED_DOCS_URL}`)
|
|
175
175
|
// Never the card's second person.
|
|
176
176
|
expect(note).not.toMatch(/\byour\b/i)
|
|
177
177
|
})
|
|
@@ -144,7 +144,7 @@ export interface AdminNoteContext {
|
|
|
144
144
|
export const SELF_HOSTED_DOCS_URL = 'https://radarhq.io/docs/cloud/self-hosted/'
|
|
145
145
|
const WHAT_IT_IS = [
|
|
146
146
|
'What it is: Radar, the open-source Kubernetes tool, installed in the cluster as a Helm chart. It opens an outbound connection to a hosted control plane (nothing listens inbound); Radar Cloud users reach this Radar only through that connection, with the permissions of the ServiceAccount it runs as.',
|
|
147
|
-
`If a hosted service is not an option, the same control plane can
|
|
147
|
+
`If a hosted service is not an option, the same control plane can run in-house as Radar Cloud Self-Managed: ${SELF_HOSTED_DOCS_URL}`,
|
|
148
148
|
].join(' ')
|
|
149
149
|
|
|
150
150
|
export function composeAdminNote(blocked: CloudInstallBlocked, exit: BlockedExit, where: AdminNoteContext = {}): string {
|
|
@@ -1099,6 +1099,7 @@ function ToolResultDialog({
|
|
|
1099
1099
|
text: string;
|
|
1100
1100
|
truncated?: boolean;
|
|
1101
1101
|
}) {
|
|
1102
|
+
const titleId = useId();
|
|
1102
1103
|
const { theme } = useTheme();
|
|
1103
1104
|
const [fmt, setFmt] = useState<"yaml" | "json">("yaml");
|
|
1104
1105
|
const parsed = useMemo<{ ok: boolean; value?: unknown }>(() => {
|
|
@@ -1136,10 +1137,10 @@ function ToolResultDialog({
|
|
|
1136
1137
|
};
|
|
1137
1138
|
}, [open, display, language, theme]);
|
|
1138
1139
|
return (
|
|
1139
|
-
<DialogPortal open={open} onClose={onClose} className="w-[min(90vw,820px)]">
|
|
1140
|
+
<DialogPortal ariaLabelledBy={titleId} open={open} onClose={onClose} className="w-[min(90vw,820px)]">
|
|
1140
1141
|
<div className="flex items-center justify-between gap-3 border-b border-theme-border p-3">
|
|
1141
1142
|
<div className="min-w-0">
|
|
1142
|
-
<div className="truncate font-mono text-sm text-theme-text-primary">
|
|
1143
|
+
<div id={titleId} className="truncate font-mono text-sm text-theme-text-primary">
|
|
1143
1144
|
{title}
|
|
1144
1145
|
</div>
|
|
1145
1146
|
{truncated && (
|
|
@@ -9,6 +9,9 @@ import { useAnimatedUnmount } from "../../hooks/useAnimatedUnmount";
|
|
|
9
9
|
import type { DiagnoseConsentCopy } from "../../context/DiagnoseCustomization";
|
|
10
10
|
import type { AgentInfo, ExecutionProfile } from "../../api/diagnose";
|
|
11
11
|
|
|
12
|
+
const OPENCODE_FULL_LOCAL_WARNING =
|
|
13
|
+
"Radar runs OpenCode with --auto, which automatically approves actions that your configuration would normally ask about, including built-in tools and configured MCP servers. Explicit denials still apply. Radar does not enforce a CLI sandbox.";
|
|
14
|
+
|
|
12
15
|
const CURSOR_FULL_LOCAL_WARNING =
|
|
13
16
|
"Radar passes Cursor --force, which auto-approves its built-in tools and every MCP server it loads, including your global servers. Cursor’s sandbox does not reliably confine those tools to Radar’s temporary workspace.";
|
|
14
17
|
|
|
@@ -245,6 +248,7 @@ export function AgentControls({
|
|
|
245
248
|
const isCodex = selectedAgent === "codex";
|
|
246
249
|
const isClaude = selectedAgent === "claude";
|
|
247
250
|
const isCursor = selectedAgent === "cursor-agent";
|
|
251
|
+
const isOpenCode = selectedAgent === "opencode";
|
|
248
252
|
const selectedAgentInfo = agents.find((a) => a.name === selectedAgent);
|
|
249
253
|
const selectedAgentLabel =
|
|
250
254
|
selectedAgentInfo?.label || selectedAgent || "agent";
|
|
@@ -302,7 +306,9 @@ export function AgentControls({
|
|
|
302
306
|
? CURSOR_FULL_LOCAL_WARNING
|
|
303
307
|
: isClaude
|
|
304
308
|
? "Claude uses the permissions from your setup; Radar does not override them."
|
|
305
|
-
:
|
|
309
|
+
: isOpenCode
|
|
310
|
+
? OPENCODE_FULL_LOCAL_WARNING
|
|
311
|
+
: "Radar still enables the agent CLI’s own sandbox, but that sandbox does not constrain external MCP servers."}{" "}
|
|
306
312
|
Choose this only when you need that setup.
|
|
307
313
|
</span>
|
|
308
314
|
</div>
|
|
@@ -330,6 +336,13 @@ export function AgentControls({
|
|
|
330
336
|
cluster.{" "}
|
|
331
337
|
{isCursor ? (
|
|
332
338
|
CURSOR_FULL_LOCAL_WARNING
|
|
339
|
+
) : isClaude ? (
|
|
340
|
+
<>
|
|
341
|
+
Claude uses the permissions from your setup; Radar does not
|
|
342
|
+
override them.
|
|
343
|
+
</>
|
|
344
|
+
) : isOpenCode ? (
|
|
345
|
+
OPENCODE_FULL_LOCAL_WARNING
|
|
333
346
|
) : (
|
|
334
347
|
<>
|
|
335
348
|
Radar still enables the agent CLI's own sandbox, but
|
|
@@ -371,9 +384,17 @@ export function AgentControls({
|
|
|
371
384
|
<TextField
|
|
372
385
|
label="Model"
|
|
373
386
|
value={model}
|
|
374
|
-
placeholder=
|
|
387
|
+
placeholder={
|
|
388
|
+
selectedAgent === "opencode"
|
|
389
|
+
? "Default (provider/model)"
|
|
390
|
+
: "Default"
|
|
391
|
+
}
|
|
375
392
|
onChange={onSetModel}
|
|
376
|
-
hint=
|
|
393
|
+
hint={
|
|
394
|
+
selectedAgent === "opencode"
|
|
395
|
+
? "Leave empty for OpenCode's default, or enter a provider/model from opencode models."
|
|
396
|
+
: "Leave empty for the agent's default, or enter a model identifier it supports."
|
|
397
|
+
}
|
|
377
398
|
/>
|
|
378
399
|
)}
|
|
379
400
|
{isCodex && (
|
|
@@ -590,6 +611,8 @@ export function ConsentCard({
|
|
|
590
611
|
Claude uses the permissions from your setup; Radar does not
|
|
591
612
|
override them.
|
|
592
613
|
</>
|
|
614
|
+
) : agent === "opencode" ? (
|
|
615
|
+
OPENCODE_FULL_LOCAL_WARNING
|
|
593
616
|
) : (
|
|
594
617
|
<>
|
|
595
618
|
Radar still enables the agent CLI's own sandbox, but that
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useState } from "react";
|
|
1
|
+
import { useEffect, useState, useId } from "react";
|
|
2
2
|
import {
|
|
3
3
|
CheckCircle2,
|
|
4
4
|
AlertTriangle,
|
|
@@ -41,6 +41,7 @@ export function ApplyDialog({
|
|
|
41
41
|
managedBy?: string; // GitOps/Helm owner of the resource, if any
|
|
42
42
|
confidence?: number;
|
|
43
43
|
}) {
|
|
44
|
+
const titleId = useId();
|
|
44
45
|
const fixText = fix?.trim();
|
|
45
46
|
const lowConfidence = confidence != null && confidence < 0.5;
|
|
46
47
|
// A GitOps/Helm-managed resource needs an explicit acknowledgment before applying
|
|
@@ -56,6 +57,7 @@ export function ApplyDialog({
|
|
|
56
57
|
const applyBlocked = !!managedBy && !acked;
|
|
57
58
|
return (
|
|
58
59
|
<DialogPortal
|
|
60
|
+
ariaLabelledBy={titleId}
|
|
59
61
|
open={open}
|
|
60
62
|
onClose={onClose}
|
|
61
63
|
className="flex max-h-[calc(100dvh-2rem)] w-full max-w-2xl flex-col overflow-hidden"
|
|
@@ -65,7 +67,7 @@ export function ApplyDialog({
|
|
|
65
67
|
<AlertTriangle className="h-5 w-5 text-amber-500" />
|
|
66
68
|
</div>
|
|
67
69
|
<div className="min-w-0 flex-1">
|
|
68
|
-
<h3 className="text-lg font-semibold text-theme-text-primary">
|
|
70
|
+
<h3 id={titleId} className="text-lg font-semibold text-theme-text-primary">
|
|
69
71
|
Apply this fix?
|
|
70
72
|
</h3>
|
|
71
73
|
<p className="mt-1 text-sm text-theme-text-secondary">
|
|
@@ -27,4 +27,10 @@ export const SUPPORTED_AGENTS: AgentInstall[] = [
|
|
|
27
27
|
install: "curl https://cursor.com/install -fsS | bash",
|
|
28
28
|
docs: "https://docs.cursor.com/en/cli/overview",
|
|
29
29
|
},
|
|
30
|
+
{
|
|
31
|
+
name: "opencode",
|
|
32
|
+
label: "OpenCode",
|
|
33
|
+
install: "npm install -g opencode-ai",
|
|
34
|
+
docs: "https://opencode.ai/docs/",
|
|
35
|
+
},
|
|
30
36
|
];
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { RunSummary } from "../../api/diagnose";
|
|
3
|
+
import { buildLaunchCommand, launchAgentLabel } from "./launch";
|
|
4
|
+
|
|
5
|
+
describe("OpenCode terminal handoff", () => {
|
|
6
|
+
it("uses the correct label without launching a different agent", () => {
|
|
7
|
+
const run: RunSummary = {
|
|
8
|
+
id: "run-test",
|
|
9
|
+
kind: "Deployment",
|
|
10
|
+
group: "apps",
|
|
11
|
+
namespace: "default",
|
|
12
|
+
name: "demo",
|
|
13
|
+
context: "test",
|
|
14
|
+
agent: "opencode",
|
|
15
|
+
status: "done",
|
|
16
|
+
sessionId: "ses_opencode",
|
|
17
|
+
createdAt: "2026-09-21T00:00:00Z",
|
|
18
|
+
updatedAt: "2026-09-21T00:00:00Z",
|
|
19
|
+
};
|
|
20
|
+
expect(launchAgentLabel(run)).toBe("OpenCode");
|
|
21
|
+
expect(buildLaunchCommand(run, "http://localhost:9280/mcp")).toBeNull();
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -15,6 +15,7 @@ function sq(s: string): string {
|
|
|
15
15
|
|
|
16
16
|
export function launchAgentLabel(run: RunSummary): string {
|
|
17
17
|
if (run.agent === "codex") return "Codex";
|
|
18
|
+
if (run.agent === "opencode") return "OpenCode";
|
|
18
19
|
if (run.agent === "cursor-agent") return "Cursor";
|
|
19
20
|
return "Claude Code";
|
|
20
21
|
}
|
|
@@ -42,6 +43,9 @@ export function buildLaunchCommand(
|
|
|
42
43
|
): string | null {
|
|
43
44
|
if (!run.sessionId || run.status === "stale") return null;
|
|
44
45
|
|
|
46
|
+
// OpenCode terminal handoff needs a persistent MCP configuration outside the run workspace.
|
|
47
|
+
if (run.agent === "opencode") return null;
|
|
48
|
+
|
|
45
49
|
if (run.agent === "cursor-agent") {
|
|
46
50
|
// Cursor's --resume is workspace-scoped, and Radar runs each investigation in a
|
|
47
51
|
// throwaway per-run workspace the user doesn't have — no command can reattach
|
|
@@ -1031,6 +1031,24 @@ describe("ConsentCard execution profile treatment", () => {
|
|
|
1031
1031
|
expect(html).toContain("Radar does not override them");
|
|
1032
1032
|
expect(html).not.toContain("Radar still enables the agent CLI");
|
|
1033
1033
|
});
|
|
1034
|
+
|
|
1035
|
+
it("does not claim Radar enables a sandbox for OpenCode's normal setup", () => {
|
|
1036
|
+
const html = renderToStaticMarkup(
|
|
1037
|
+
<ConsentCard
|
|
1038
|
+
agentName="OpenCode"
|
|
1039
|
+
agent="opencode"
|
|
1040
|
+
profile="full-local"
|
|
1041
|
+
onApprove={noop}
|
|
1042
|
+
onCancel={noop}
|
|
1043
|
+
/>,
|
|
1044
|
+
);
|
|
1045
|
+
expect(html).toContain("Radar runs OpenCode with --auto");
|
|
1046
|
+
expect(html).toContain("automatically approves actions");
|
|
1047
|
+
expect(html).toContain("built-in tools and configured MCP servers");
|
|
1048
|
+
expect(html).toContain("Explicit denials still apply");
|
|
1049
|
+
expect(html).toContain("Radar does not enforce a CLI sandbox");
|
|
1050
|
+
expect(html).not.toContain("Radar still enables the agent CLI");
|
|
1051
|
+
});
|
|
1034
1052
|
});
|
|
1035
1053
|
|
|
1036
1054
|
// The consent card's error box is the ONLY place a refused approval is
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
|
2
|
+
import { join, relative } from 'node:path'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
|
|
5
|
+
// Screen readers announce a dialog by its name; an unnamed one is read as just
|
|
6
|
+
// "dialog". Every DialogPortal must pass ariaLabelledBy (its visible title) or
|
|
7
|
+
// ariaLabel.
|
|
8
|
+
const repoRoot = join(__dirname, '..', '..', '..')
|
|
9
|
+
const roots = ['web/src', 'packages/k8s-ui/src'].map(r => join(repoRoot, r))
|
|
10
|
+
|
|
11
|
+
function sourceFiles(dir: string): string[] {
|
|
12
|
+
return readdirSync(dir).flatMap(name => {
|
|
13
|
+
const path = join(dir, name)
|
|
14
|
+
if (statSync(path).isDirectory()) return name === 'node_modules' ? [] : sourceFiles(path)
|
|
15
|
+
return /\.tsx$/.test(name) && !/\.test\.tsx$/.test(name) ? [path] : []
|
|
16
|
+
})
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// The opening tag ends at the first '>' outside a {…} expression or a string.
|
|
20
|
+
function openingTags(source: string, component: string): string[] {
|
|
21
|
+
const tags: string[] = []
|
|
22
|
+
let from = 0
|
|
23
|
+
for (;;) {
|
|
24
|
+
const start = source.indexOf(`<${component}`, from)
|
|
25
|
+
if (start < 0) return tags
|
|
26
|
+
let depth = 0
|
|
27
|
+
let i = start + component.length + 1
|
|
28
|
+
for (; i < source.length; i++) {
|
|
29
|
+
const c = source[i]
|
|
30
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
31
|
+
i++
|
|
32
|
+
while (i < source.length && source[i] !== c) i += source[i] === '\\' ? 2 : 1
|
|
33
|
+
} else if (c === '{') depth++
|
|
34
|
+
else if (c === '}') depth--
|
|
35
|
+
else if (c === '>' && depth === 0) break
|
|
36
|
+
}
|
|
37
|
+
tags.push(source.slice(start, i + 1))
|
|
38
|
+
from = i
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isNamed(tag: string): boolean {
|
|
43
|
+
const withoutStrings = tag.replace(/(["'`])(?:\\.|(?!\1)[^\\])*\1/gs, '""')
|
|
44
|
+
return /\saria(Label|LabelledBy)\s*=/.test(withoutStrings)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('dialogs', () => {
|
|
48
|
+
it('every DialogPortal has an accessible name', () => {
|
|
49
|
+
const unnamed = roots.flatMap(sourceFiles).flatMap(file => {
|
|
50
|
+
if (file.endsWith('DialogPortal.tsx')) return []
|
|
51
|
+
return openingTags(readFileSync(file, 'utf8'), 'DialogPortal')
|
|
52
|
+
.filter(tag => /^<DialogPortal[\s>]/.test(tag) && !isNamed(tag))
|
|
53
|
+
.map(() => relative(repoRoot, file))
|
|
54
|
+
})
|
|
55
|
+
expect(unnamed).toEqual([])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('reads attributes, not text that looks like them', () => {
|
|
59
|
+
const [unnamed, spaced, braceInString] = openingTags(
|
|
60
|
+
`<DialogPortal onClose={() => log("ariaLabel=unused")}>` +
|
|
61
|
+
`<DialogPortal ariaLabel = "Named">` +
|
|
62
|
+
`<DialogPortal title={"}"} open>`,
|
|
63
|
+
'DialogPortal',
|
|
64
|
+
)
|
|
65
|
+
expect(isNamed(unnamed)).toBe(false)
|
|
66
|
+
expect(isNamed(spaced)).toBe(true)
|
|
67
|
+
expect(braceInString).toBe('<DialogPortal title={"}"} open>')
|
|
68
|
+
})
|
|
69
|
+
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { renderToString } from 'react-dom/server'
|
|
2
|
+
import { afterEach, expect, it, vi } from 'vitest'
|
|
3
|
+
import type { NodeTerminalTabProps } from '@skyhook-io/k8s-ui'
|
|
4
|
+
import { NodeTerminalTab } from './NodeTerminalTab'
|
|
5
|
+
|
|
6
|
+
const captured = vi.hoisted(() => ({ props: null as NodeTerminalTabProps | null }))
|
|
7
|
+
vi.mock('@skyhook-io/k8s-ui', () => ({
|
|
8
|
+
NodeTerminalTab: (props: NodeTerminalTabProps) => { captured.props = props; return null },
|
|
9
|
+
}))
|
|
10
|
+
vi.mock('../../api/config', () => ({
|
|
11
|
+
apiUrl: (path: string) => `/api${path}`,
|
|
12
|
+
getWsUrl: (path: string) => path,
|
|
13
|
+
getAuthHeaders: () => ({ Authorization: 'Bearer test' }),
|
|
14
|
+
getCredentialsMode: () => 'include',
|
|
15
|
+
}))
|
|
16
|
+
afterEach(() => vi.unstubAllGlobals())
|
|
17
|
+
|
|
18
|
+
it('passes the created UID to exact-pod cleanup query parameters with keepalive', async () => {
|
|
19
|
+
const pod = { namespace: 'default', podName: 'debug-a', uid: 'uid/a+b', containerName: 'debug' }
|
|
20
|
+
const fetch = vi.fn().mockResolvedValueOnce(new Response(JSON.stringify(pod)))
|
|
21
|
+
.mockResolvedValueOnce(new Response('{}'))
|
|
22
|
+
vi.stubGlobal('fetch', fetch)
|
|
23
|
+
renderToString(<NodeTerminalTab nodeName="node/a" />)
|
|
24
|
+
const props = captured.props!
|
|
25
|
+
const result = await props.createNodeDebugPod('node/a')
|
|
26
|
+
expect(result).toEqual(pod)
|
|
27
|
+
await props.cleanupNodeDebugPod('node/a', result)
|
|
28
|
+
const [url, options] = fetch.mock.calls[1]
|
|
29
|
+
const parsed = new URL(url, 'http://localhost')
|
|
30
|
+
expect(parsed.pathname).toBe('/api/nodes/node%2Fa/debug')
|
|
31
|
+
expect(Object.fromEntries(parsed.searchParams)).toEqual({ namespace: 'default', podName: 'debug-a', uid: 'uid/a+b' })
|
|
32
|
+
expect(options).toMatchObject({ method: 'DELETE', keepalive: true, credentials: 'include', headers: { Authorization: 'Bearer test' } })
|
|
33
|
+
expect(options).not.toHaveProperty('body')
|
|
34
|
+
})
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { NodeTerminalTab as SharedNodeTerminalTab } from '@skyhook-io/k8s-ui'
|
|
1
|
+
import { NodeTerminalTab as SharedNodeTerminalTab, type NodeTerminalTabProps as SharedNodeTerminalTabProps } from '@skyhook-io/k8s-ui'
|
|
2
2
|
import { apiUrl, getWsUrl, getAuthHeaders, getCredentialsMode } from '../../api/config'
|
|
3
3
|
|
|
4
4
|
interface NodeTerminalTabProps {
|
|
@@ -21,14 +21,16 @@ export function NodeTerminalTab({ nodeName, isActive }: NodeTerminalTabProps) {
|
|
|
21
21
|
return response.json()
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
const cleanupNodeDebugPod = async (name
|
|
24
|
+
const cleanupNodeDebugPod: SharedNodeTerminalTabProps['cleanupNodeDebugPod'] = async (name, pod) => {
|
|
25
|
+
const query = new URLSearchParams({ namespace: pod.namespace, podName: pod.podName, uid: pod.uid })
|
|
25
26
|
try {
|
|
26
|
-
await fetch(apiUrl(`/nodes/${encodeURIComponent(name)}/debug`), {
|
|
27
|
+
const response = await fetch(apiUrl(`/nodes/${encodeURIComponent(name)}/debug?${query}`), {
|
|
27
28
|
method: 'DELETE',
|
|
28
29
|
credentials: getCredentialsMode(),
|
|
29
30
|
headers: getAuthHeaders(),
|
|
30
31
|
keepalive: true,
|
|
31
32
|
})
|
|
33
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
32
34
|
} catch (err) {
|
|
33
35
|
console.warn(`[NodeTerminal] Failed to cleanup debug pod for node ${name}:`, err)
|
|
34
36
|
}
|