@skyhook-io/radar-app 1.10.0 → 1.12.2
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/package.json +10 -10
- package/src/App.tsx +5 -8
- package/src/api/client.ts +174 -12
- package/src/api/policy.test.ts +38 -0
- package/src/api/policy.ts +166 -2
- package/src/components/ConnectionErrorView.test.tsx +53 -0
- package/src/components/ConnectionErrorView.tsx +15 -12
- package/src/components/ContextSwitcher.tsx +10 -13
- package/src/components/audit/UpgradeReadinessView.tsx +3 -3
- package/src/components/capacity/ClusterSchedulingCard.tsx +8 -9
- package/src/components/capacity/schedulingBar.test.ts +10 -0
- package/src/components/cost/ApplicationCostTab.test.ts +6 -0
- package/src/components/cost/ApplicationCostTab.tsx +28 -16
- package/src/components/cost/CostTrendChart.tsx +20 -10
- package/src/components/cost/CostView.tsx +79 -28
- package/src/components/cost/CurrentAllocationUse.tsx +6 -4
- package/src/components/cost/WorkloadCostTab.test.ts +10 -0
- package/src/components/cost/WorkloadCostTab.tsx +24 -12
- package/src/components/cost/format.test.ts +27 -8
- package/src/components/cost/format.ts +78 -27
- package/src/components/home/ClusterHealthCard.tsx +3 -0
- package/src/components/home/CostCard.tsx +12 -7
- package/src/components/home/HomeView.tsx +15 -3
- package/src/components/home/NetworkPolicyCoverageCard.test.tsx +81 -0
- package/src/components/home/NetworkPolicyCoverageCard.tsx +50 -7
- package/src/components/home/TopologyPreview.tsx +57 -11
- package/src/components/home/mcpToolCatalog.ts +27 -5
- package/src/components/nav/PrimaryNavRail.tsx +2 -2
- package/src/components/resources/ResourcesView.tsx +15 -3
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +116 -1
- package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +227 -0
- package/src/components/resources/renderers/CNPGImageCatalogRenderer.tsx +123 -0
- package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +152 -0
- package/src/components/resources/renderers/KyvernoPolicyCoverage.tsx +65 -0
- package/src/components/resources/renderers/KyvernoPolicyQueued.render.test.tsx +59 -0
- package/src/components/resources/renderers/KyvernoPolicyQueued.test.ts +99 -0
- package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +184 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +24 -1
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +44 -1
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +75 -1
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +35 -1
- package/src/components/resources/renderers/index.ts +1 -0
- package/src/components/rightsizing/RightsizingScanView.tsx +2 -2
- package/src/components/settings/SettingsDialog.tsx +160 -36
- package/src/components/settings/currency-options.test.ts +49 -0
- package/src/components/settings/currency-options.ts +38 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +37 -20
- package/src/components/traffic/TrafficFlowList.tsx +16 -2
- package/src/components/traffic/TrafficGraph.tsx +150 -58
- package/src/components/traffic/TrafficView.tsx +168 -52
- package/src/components/traffic/TrafficWizard.tsx +13 -1
- package/src/components/traffic/trafficFilters.test.ts +103 -0
- package/src/components/traffic/trafficFilters.ts +117 -0
- package/src/components/ui/DiagnosticsOverlay.test.ts +115 -0
- package/src/components/ui/DiagnosticsOverlay.tsx +65 -8
- package/src/components/ui/command-items.ts +4 -14
- package/src/components/workload/WorkloadView.tsx +57 -8
- package/src/main.tsx +4 -114
- package/src/utils/context-name.test.ts +63 -0
- package/src/utils/context-name.ts +22 -0
- package/src/utils/navigation.ts +44 -2
- package/src/utils/network-policy-navigation.test.ts +68 -0
- package/src/utils/topology-selection.test.ts +40 -0
- package/src/utils/topology-selection.ts +39 -0
- package/src/utils/wails-clipboard.test.ts +109 -0
- package/src/utils/wails-clipboard.ts +127 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { formatEnvValue, formatForGitHub } from './DiagnosticsOverlay'
|
|
4
|
+
import type { DiagnosticsSnapshot } from '../../api/client'
|
|
5
|
+
|
|
6
|
+
const baseSnapshot: DiagnosticsSnapshot = {
|
|
7
|
+
timestamp: '2026-08-19T10:00:00Z',
|
|
8
|
+
radarVersion: '1.9.0',
|
|
9
|
+
goVersion: 'go1.26.5',
|
|
10
|
+
goos: 'linux',
|
|
11
|
+
goarch: 'amd64',
|
|
12
|
+
uptime: '11s',
|
|
13
|
+
uptimeSec: 11,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const desktop: NonNullable<DiagnosticsSnapshot['desktop']> = {
|
|
17
|
+
sessionType: 'wayland',
|
|
18
|
+
desktopEnvironment: 'ubuntu:GNOME',
|
|
19
|
+
displayServer: 'wayland+x11',
|
|
20
|
+
renderOverrides: [
|
|
21
|
+
{ key: 'GDK_BACKEND', value: '', set: false },
|
|
22
|
+
{ key: 'WEBKIT_DISABLE_DMABUF_RENDERER', value: '1', set: true },
|
|
23
|
+
{ key: 'WEBKIT_DISABLE_COMPOSITING_MODE', value: '', set: true },
|
|
24
|
+
],
|
|
25
|
+
sandbox: [{ key: 'SNAP', value: '/snap/radar-desktop/42', set: true }],
|
|
26
|
+
webkitLibrary: 'libwebkit2gtk-4.1.so.0.13.6',
|
|
27
|
+
gpuPolicy: 'on-demand',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('formatEnvValue', () => {
|
|
31
|
+
it('separates a variable that was never set from one set to empty', () => {
|
|
32
|
+
// Only the second suppresses the desktop app's own WebKit defaults, so
|
|
33
|
+
// collapsing them would hide the cause of a rendering failure.
|
|
34
|
+
expect(formatEnvValue({ key: 'GDK_BACKEND', value: '', set: false })).toBe('(unset)')
|
|
35
|
+
expect(formatEnvValue({ key: 'GDK_BACKEND', value: '', set: true })).toBe('(empty)')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('passes a real value through unchanged', () => {
|
|
39
|
+
expect(formatEnvValue({ key: 'GTK_THEME', value: 'Adwaita:dark', set: true })).toBe('Adwaita:dark')
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('formatForGitHub desktop section', () => {
|
|
44
|
+
it('omits the section entirely when the backend reports no desktop data', () => {
|
|
45
|
+
expect(formatForGitHub(baseSnapshot, undefined, false)).not.toContain('### Desktop')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('reports the host rendering environment', () => {
|
|
49
|
+
const md = formatForGitHub({ ...baseSnapshot, desktop }, undefined, false)
|
|
50
|
+
|
|
51
|
+
expect(md).toContain('### Desktop')
|
|
52
|
+
expect(md).toContain('Display Server: `wayland+x11`')
|
|
53
|
+
expect(md).toContain('Session Type: `wayland`')
|
|
54
|
+
expect(md).toContain('Desktop: `ubuntu:GNOME`')
|
|
55
|
+
expect(md).toContain('Webview Library: `libwebkit2gtk-4.1.so.0.13.6`')
|
|
56
|
+
expect(md).toContain('Webview GPU Policy: `on-demand`')
|
|
57
|
+
expect(md).toContain('Sandbox: `SNAP=/snap/radar-desktop/42`')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('renders each override state distinguishably', () => {
|
|
61
|
+
const md = formatForGitHub({ ...baseSnapshot, desktop }, undefined, false)
|
|
62
|
+
|
|
63
|
+
expect(md).toContain('`GDK_BACKEND=(unset)`')
|
|
64
|
+
expect(md).toContain('`WEBKIT_DISABLE_DMABUF_RENDERER=1`')
|
|
65
|
+
expect(md).toContain('`WEBKIT_DISABLE_COMPOSITING_MODE=(empty)`')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('degrades to placeholders rather than blanks when the host reports nothing', () => {
|
|
69
|
+
const md = formatForGitHub({ ...baseSnapshot, desktop: {} }, undefined, false)
|
|
70
|
+
|
|
71
|
+
expect(md).toContain('Display Server: `(none)`')
|
|
72
|
+
expect(md).toContain('Session Type: `(unset)`')
|
|
73
|
+
expect(md).toContain('Desktop: `(unset)`')
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('formatForGitHub kubeconfig section', () => {
|
|
78
|
+
it('reports combined source counts and ignored ambient configuration', () => {
|
|
79
|
+
const md = formatForGitHub({
|
|
80
|
+
...baseSnapshot,
|
|
81
|
+
kubeconfig: {
|
|
82
|
+
mode: 'multi-source',
|
|
83
|
+
fileCount: 3,
|
|
84
|
+
directoryFileCount: 2,
|
|
85
|
+
contextCount: 7,
|
|
86
|
+
enrichedFromShell: true,
|
|
87
|
+
kubeconfigEnvIgnored: true,
|
|
88
|
+
kubeconfigEnvIgnoredReason: 'directories-only configuration',
|
|
89
|
+
currentContextUsesExec: false,
|
|
90
|
+
},
|
|
91
|
+
}, undefined, false)
|
|
92
|
+
|
|
93
|
+
expect(md).toContain('Mode: `multi-source` | Files: 3 | Directory Files: 2 | Contexts (after source resolution): 7')
|
|
94
|
+
expect(md).toContain('KUBECONFIG Captured From Shell: Yes | Ignored: Yes — directories-only configuration')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('omits directory counts for environment path lists', () => {
|
|
98
|
+
const md = formatForGitHub({
|
|
99
|
+
...baseSnapshot,
|
|
100
|
+
kubeconfig: {
|
|
101
|
+
mode: 'multi-env',
|
|
102
|
+
fileCount: 2,
|
|
103
|
+
directoryFileCount: 0,
|
|
104
|
+
contextCount: 4,
|
|
105
|
+
enrichedFromShell: false,
|
|
106
|
+
kubeconfigEnvIgnored: false,
|
|
107
|
+
kubeconfigEnvIgnoredReason: '',
|
|
108
|
+
currentContextUsesExec: true,
|
|
109
|
+
},
|
|
110
|
+
}, undefined, false)
|
|
111
|
+
|
|
112
|
+
expect(md).toContain('Mode: `multi-env` | Files: 2 | Contexts (after source resolution): 4')
|
|
113
|
+
expect(md).not.toContain('Directory Files:')
|
|
114
|
+
})
|
|
115
|
+
})
|
|
@@ -4,7 +4,7 @@ import { clsx } from 'clsx'
|
|
|
4
4
|
import { TRANSITION_BACKDROP, TRANSITION_PANEL } from '../../utils/animation'
|
|
5
5
|
import { openExternal } from '../../utils/navigation'
|
|
6
6
|
import { useDiagnostics } from '../../api/client'
|
|
7
|
-
import type { DiagnosticsSnapshot, DiagMetricsSourceHealth, DiagDropRecord, DiagErrorEntry, DiagCacheSyncStatus, DiagInformerSyncStatus, DiagSyncPhase, DiagSampleWindow } from '../../api/client'
|
|
7
|
+
import type { DiagnosticsSnapshot, DiagEnvVar, DiagMetricsSourceHealth, DiagDropRecord, DiagErrorEntry, DiagCacheSyncStatus, DiagInformerSyncStatus, DiagSyncPhase, DiagSampleWindow } from '../../api/client'
|
|
8
8
|
import { getK8sUIPerfSnapshot, type K8sUIPerfSnapshot } from '@skyhook-io/k8s-ui'
|
|
9
9
|
|
|
10
10
|
interface DiagnosticsOverlayProps {
|
|
@@ -119,6 +119,7 @@ export function DiagnosticsOverlay({ onClose, isOpen = true }: DiagnosticsOverla
|
|
|
119
119
|
<PermissionsSection data={data} />
|
|
120
120
|
<APIDiscoverySection data={data} />
|
|
121
121
|
<PerfSection data={data} />
|
|
122
|
+
<DesktopSection data={data} />
|
|
122
123
|
<RuntimeSection data={data} />
|
|
123
124
|
<ConfigSection data={data} />
|
|
124
125
|
{data.errors && data.errors.length > 0 && (
|
|
@@ -170,10 +171,10 @@ function Section({ title, children, warn }: { title: string; children: React.Rea
|
|
|
170
171
|
|
|
171
172
|
function Row({ label, value, warn }: { label: string; value: React.ReactNode; warn?: boolean }) {
|
|
172
173
|
return (
|
|
173
|
-
<div className="flex items-
|
|
174
|
+
<div className="flex items-start justify-between gap-4 text-xs">
|
|
174
175
|
<span className="text-theme-text-secondary shrink-0">{label}</span>
|
|
175
176
|
<span className={clsx(
|
|
176
|
-
'text-right
|
|
177
|
+
'min-w-0 text-right break-words',
|
|
177
178
|
warn ? 'text-yellow-400' : 'text-theme-text-primary'
|
|
178
179
|
)}>{value}</span>
|
|
179
180
|
</div>
|
|
@@ -218,11 +219,19 @@ function KubeconfigSection({ data }: { data: DiagnosticsSnapshot }) {
|
|
|
218
219
|
const present = k.execPluginsPresent ?? []
|
|
219
220
|
const hasMissing = missing.length > 0
|
|
220
221
|
return (
|
|
221
|
-
<Section title="Kubeconfig" warn={hasMissing}>
|
|
222
|
+
<Section title="Kubeconfig" warn={hasMissing || k.kubeconfigEnvIgnored}>
|
|
222
223
|
<Row label="Mode" value={k.mode || '(not initialized)'} />
|
|
223
224
|
<Row label="Files Loaded" value={k.fileCount} />
|
|
224
|
-
|
|
225
|
-
|
|
225
|
+
{(k.mode === 'multi-dir' || k.mode === 'multi-source') && (
|
|
226
|
+
<Row label="Directory Files Loaded" value={k.directoryFileCount} />
|
|
227
|
+
)}
|
|
228
|
+
<Row label="Contexts (after source resolution)" value={k.contextCount} />
|
|
229
|
+
<Row label="KUBECONFIG Captured From Shell" value={k.enrichedFromShell ? 'Yes' : 'No'} />
|
|
230
|
+
<Row
|
|
231
|
+
label="KUBECONFIG Ignored"
|
|
232
|
+
value={k.kubeconfigEnvIgnored ? `Yes — ${k.kubeconfigEnvIgnoredReason}` : 'No'}
|
|
233
|
+
warn={k.kubeconfigEnvIgnored}
|
|
234
|
+
/>
|
|
226
235
|
<Row
|
|
227
236
|
label="Current Context Uses Exec"
|
|
228
237
|
value={k.currentContextUsesExec ? 'Yes' : 'No'}
|
|
@@ -529,6 +538,35 @@ function formatFrontendUs(w: { count: number; last: number; p50: number; p95: nu
|
|
|
529
538
|
return `last ${fmt(w.last)} · p50 ${fmt(w.p50)} · p95 ${fmt(w.p95)} · max ${fmt(w.max)} (n=${w.count})`
|
|
530
539
|
}
|
|
531
540
|
|
|
541
|
+
// An override present but empty is not the same as one that was never set —
|
|
542
|
+
// merely existing is enough to suppress the desktop app's WebKit defaults.
|
|
543
|
+
export function formatEnvValue(v: DiagEnvVar): string {
|
|
544
|
+
if (!v.set) return '(unset)'
|
|
545
|
+
return v.value === '' ? '(empty)' : v.value
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function DesktopSection({ data }: { data: DiagnosticsSnapshot }) {
|
|
549
|
+
if (!data.desktop) return null
|
|
550
|
+
const d = data.desktop
|
|
551
|
+
const overrides = d.renderOverrides ?? []
|
|
552
|
+
const sandbox = d.sandbox ?? []
|
|
553
|
+
return (
|
|
554
|
+
<Section title="Desktop">
|
|
555
|
+
{d.displayServer && <Row label="Display Server" value={d.displayServer} />}
|
|
556
|
+
<Row label="Session Type" value={d.sessionType || '(unset)'} />
|
|
557
|
+
<Row label="Desktop Environment" value={d.desktopEnvironment || '(unset)'} />
|
|
558
|
+
{d.webkitLibrary && <Row label="Webview Library" value={d.webkitLibrary} />}
|
|
559
|
+
{d.gpuPolicy && <Row label="Webview GPU Policy" value={d.gpuPolicy} />}
|
|
560
|
+
{overrides.map((v) => (
|
|
561
|
+
<Row key={v.key} label={v.key} value={formatEnvValue(v)} />
|
|
562
|
+
))}
|
|
563
|
+
{sandbox.map((v) => (
|
|
564
|
+
<Row key={v.key} label={`Sandbox: ${v.key}`} value={v.value} />
|
|
565
|
+
))}
|
|
566
|
+
</Section>
|
|
567
|
+
)
|
|
568
|
+
}
|
|
569
|
+
|
|
532
570
|
function RuntimeSection({ data }: { data: DiagnosticsSnapshot }) {
|
|
533
571
|
if (!data.runtime) return null
|
|
534
572
|
const rt = data.runtime
|
|
@@ -580,7 +618,7 @@ function CopyButton({ label, onClick, copied }: { label: string; onClick: () =>
|
|
|
580
618
|
|
|
581
619
|
// --- GitHub-friendly formatting ---
|
|
582
620
|
|
|
583
|
-
function formatForGitHub(data: DiagnosticsSnapshot, frontendPerf?: K8sUIPerfSnapshot, includeRawJson = true): string {
|
|
621
|
+
export function formatForGitHub(data: DiagnosticsSnapshot, frontendPerf?: K8sUIPerfSnapshot, includeRawJson = true): string {
|
|
584
622
|
const lines: string[] = []
|
|
585
623
|
lines.push(`## Radar Diagnostics`)
|
|
586
624
|
lines.push(``)
|
|
@@ -601,7 +639,11 @@ function formatForGitHub(data: DiagnosticsSnapshot, frontendPerf?: K8sUIPerfSnap
|
|
|
601
639
|
if (data.kubeconfig) {
|
|
602
640
|
const k = data.kubeconfig
|
|
603
641
|
lines.push(`### Kubeconfig`)
|
|
604
|
-
|
|
642
|
+
const directoryFiles = k.mode === 'multi-dir' || k.mode === 'multi-source'
|
|
643
|
+
? ` | Directory Files: ${k.directoryFileCount}`
|
|
644
|
+
: ''
|
|
645
|
+
lines.push(`- Mode: \`${k.mode || '(not initialized)'}\` | Files: ${k.fileCount}${directoryFiles} | Contexts (after source resolution): ${k.contextCount}`)
|
|
646
|
+
lines.push(`- KUBECONFIG Captured From Shell: ${k.enrichedFromShell ? 'Yes' : 'No'} | Ignored: ${k.kubeconfigEnvIgnored ? `Yes — ${k.kubeconfigEnvIgnoredReason}` : 'No'}`)
|
|
605
647
|
lines.push(`- Current Context Uses Exec: ${k.currentContextUsesExec ? 'Yes' : 'No'}`)
|
|
606
648
|
if (k.execPluginsPresent && k.execPluginsPresent.length > 0) {
|
|
607
649
|
lines.push(`- Exec Plugins on PATH: \`${k.execPluginsPresent.join('`, `')}\``)
|
|
@@ -758,6 +800,21 @@ function formatForGitHub(data: DiagnosticsSnapshot, frontendPerf?: K8sUIPerfSnap
|
|
|
758
800
|
lines.push(``)
|
|
759
801
|
}
|
|
760
802
|
|
|
803
|
+
if (data.desktop) {
|
|
804
|
+
const d = data.desktop
|
|
805
|
+
lines.push(`### Desktop`)
|
|
806
|
+
lines.push(`- Display Server: \`${d.displayServer || '(none)'}\` | Session Type: \`${d.sessionType || '(unset)'}\` | Desktop: \`${d.desktopEnvironment || '(unset)'}\``)
|
|
807
|
+
if (d.webkitLibrary) lines.push(`- Webview Library: \`${d.webkitLibrary}\``)
|
|
808
|
+
if (d.gpuPolicy) lines.push(`- Webview GPU Policy: \`${d.gpuPolicy}\``)
|
|
809
|
+
if (d.renderOverrides && d.renderOverrides.length > 0) {
|
|
810
|
+
lines.push(`- Render Overrides: ${d.renderOverrides.map((v) => `\`${v.key}=${formatEnvValue(v)}\``).join(' ')}`)
|
|
811
|
+
}
|
|
812
|
+
if (d.sandbox && d.sandbox.length > 0) {
|
|
813
|
+
lines.push(`- Sandbox: ${d.sandbox.map((v) => `\`${v.key}=${v.value}\``).join(' ')}`)
|
|
814
|
+
}
|
|
815
|
+
lines.push(``)
|
|
816
|
+
}
|
|
817
|
+
|
|
761
818
|
if (data.runtime) {
|
|
762
819
|
const rt = data.runtime
|
|
763
820
|
lines.push(`### Runtime`)
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
Activity,
|
|
9
9
|
Sun,
|
|
10
10
|
Stethoscope,
|
|
11
|
-
|
|
11
|
+
Coins,
|
|
12
12
|
Gauge,
|
|
13
13
|
ShieldCheck,
|
|
14
14
|
GitBranch,
|
|
@@ -19,15 +19,7 @@ import {
|
|
|
19
19
|
import { useNamespaces, useContexts } from "../../api/client";
|
|
20
20
|
import { CORE_RESOURCES, useAPIResources } from "../../api/apiResources";
|
|
21
21
|
import { getResourceIcon } from "../../utils/resource-icons";
|
|
22
|
-
import { parseContextName } from "../../utils/context-name";
|
|
23
|
-
|
|
24
|
-
// Drop the disambiguating " (source)" suffix the context list appends, so the
|
|
25
|
-
// GKE/EKS/AKS parser sees the bare context name (mirrors the cluster picker).
|
|
26
|
-
function stripSourceSuffix(name: string, source?: string): string {
|
|
27
|
-
if (!source) return name;
|
|
28
|
-
const escaped = source.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
29
|
-
return name.replace(new RegExp(`\\s+\\(${escaped}(?:\\s+#\\d+)?\\)$`), "");
|
|
30
|
-
}
|
|
22
|
+
import { parseContextForSwitcher, parseContextName } from "../../utils/context-name";
|
|
31
23
|
|
|
32
24
|
export type MainView =
|
|
33
25
|
| "home"
|
|
@@ -151,7 +143,7 @@ const VIEW_ENTRIES: {
|
|
|
151
143
|
{ view: "traffic", label: "Live Traffic", icon: Activity, shortcut: "g f" },
|
|
152
144
|
{ view: "checks", label: "Checks", icon: ShieldCheck, shortcut: "g u" },
|
|
153
145
|
{ view: "capacity", label: "Capacity", icon: Gauge, shortcut: "g p" },
|
|
154
|
-
{ view: "cost", label: "Cost", icon:
|
|
146
|
+
{ view: "cost", label: "Cost", icon: Coins, shortcut: "g c" },
|
|
155
147
|
];
|
|
156
148
|
|
|
157
149
|
// The static command-palette items (Views, Resource Kinds, Contexts,
|
|
@@ -204,9 +196,7 @@ export function useCommandItems(cb: CommandItemCallbacks): CommandItem[] {
|
|
|
204
196
|
// it. Count display names so genuine duplicates (same cluster name from
|
|
205
197
|
// different kubeconfig sources) stay distinguishable; unique ones stay clean.
|
|
206
198
|
const parsedCtx = contexts.map((ctx) => {
|
|
207
|
-
const parsed =
|
|
208
|
-
stripSourceSuffix(ctx.name, ctx.source),
|
|
209
|
-
);
|
|
199
|
+
const parsed = parseContextForSwitcher(ctx);
|
|
210
200
|
const fromCluster = ctx.cluster ? parseContextName(ctx.cluster) : null;
|
|
211
201
|
const meta = [
|
|
212
202
|
parsed.provider ?? fromCluster?.provider,
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
gitOpsOwnerFromRelationships,
|
|
23
23
|
getGitOpsResourceStatus,
|
|
24
24
|
isDiagnoseKind,
|
|
25
|
+
isRolloutKind,
|
|
25
26
|
} from '@skyhook-io/k8s-ui'
|
|
26
27
|
import type { ServicePortRenderProps } from '@skyhook-io/k8s-ui/components/resources/renderers/ServiceRenderer'
|
|
27
28
|
import type { SelectedResource, ResourceRef, Relationships } from '../../types'
|
|
@@ -46,6 +47,8 @@ import {
|
|
|
46
47
|
useRestartWorkload,
|
|
47
48
|
useWorkloadRevisions,
|
|
48
49
|
useRollbackWorkload,
|
|
50
|
+
useRolloutAction,
|
|
51
|
+
useRolloutCapabilities,
|
|
49
52
|
useWorkloadPods,
|
|
50
53
|
useFluxReconcile,
|
|
51
54
|
useFluxSyncWithSource,
|
|
@@ -109,6 +112,20 @@ import { RoleBindingRenderer } from '../resources/renderers/RoleBindingRenderer'
|
|
|
109
112
|
import { NamespaceRenderer } from '../resources/renderers/NamespaceRenderer'
|
|
110
113
|
import { HPARenderer } from '../resources/renderers/HPARenderer'
|
|
111
114
|
import { PVCRenderer } from '../resources/renderers/PVCRenderer'
|
|
115
|
+
import { RolloutRenderer } from '../resources/renderers/RolloutRenderer'
|
|
116
|
+
import { KyvernoPolicyCoverage } from '../resources/renderers/KyvernoPolicyCoverage'
|
|
117
|
+
import { KyvernoPolicyQueued } from '../resources/renderers/KyvernoPolicyQueued'
|
|
118
|
+
import { CNPGObjectStoreRenderer } from '../resources/renderers/CNPGObjectStoreRenderer'
|
|
119
|
+
import { VeleroBSLRenderer } from '../resources/renderers/VeleroBSLRenderer'
|
|
120
|
+
import { VeleroBackupRenderer } from '../resources/renderers/VeleroBackupRenderer'
|
|
121
|
+
import { VeleroRestoreRenderer } from '../resources/renderers/VeleroRestoreRenderer'
|
|
122
|
+
import { CNPGClusterRenderer } from '../resources/renderers/CNPGClusterRenderer'
|
|
123
|
+
import { CNPGImageCatalogRenderer } from '../resources/renderers/CNPGImageCatalogRenderer'
|
|
124
|
+
import {
|
|
125
|
+
CNPGDatabaseRenderer,
|
|
126
|
+
CNPGPublicationRenderer,
|
|
127
|
+
CNPGSubscriptionRenderer,
|
|
128
|
+
} from '../resources/renderers/CNPGDeclarativeRenderer'
|
|
112
129
|
import { CreateResourceDialog } from '../shared/CreateResourceDialog'
|
|
113
130
|
import { cleanYamlForDuplicate } from '../../utils/skeleton-yaml'
|
|
114
131
|
import { useDesktopDownload } from '../../hooks/useDesktopDownload'
|
|
@@ -141,6 +158,18 @@ const rendererOverrides: RendererOverrides = {
|
|
|
141
158
|
NamespaceRenderer,
|
|
142
159
|
HPARenderer,
|
|
143
160
|
PVCRenderer,
|
|
161
|
+
RolloutRenderer,
|
|
162
|
+
KyvernoPolicyCoverage,
|
|
163
|
+
KyvernoPolicyQueued,
|
|
164
|
+
CNPGObjectStoreRenderer,
|
|
165
|
+
VeleroBSLRenderer,
|
|
166
|
+
VeleroBackupRenderer,
|
|
167
|
+
VeleroRestoreRenderer,
|
|
168
|
+
CNPGClusterRenderer,
|
|
169
|
+
CNPGDatabaseRenderer,
|
|
170
|
+
CNPGPublicationRenderer,
|
|
171
|
+
CNPGSubscriptionRenderer,
|
|
172
|
+
CNPGImageCatalogRenderer,
|
|
144
173
|
}
|
|
145
174
|
|
|
146
175
|
// ============================================================================
|
|
@@ -269,11 +298,19 @@ function useActionsBarProps(
|
|
|
269
298
|
const deleteMutation = useDeleteResource()
|
|
270
299
|
const restartWorkloadMutation = useRestartWorkload()
|
|
271
300
|
const rollbackMutation = useRollbackWorkload()
|
|
301
|
+
const rolloutActionMutation = useRolloutAction()
|
|
272
302
|
const triggerCronJobMutation = useTriggerCronJob()
|
|
273
303
|
const suspendCronJobMutation = useSuspendCronJob()
|
|
274
304
|
const resumeCronJobMutation = useResumeCronJob()
|
|
275
305
|
|
|
276
|
-
const isRollbackKind = ['deployments', 'statefulsets', 'daemonsets'].includes(kind.toLowerCase())
|
|
306
|
+
const isRollbackKind = ['deployments', 'statefulsets', 'daemonsets', 'rollouts'].includes(kind.toLowerCase())
|
|
307
|
+
const isRollout = isRolloutKind(kind)
|
|
308
|
+
const { data: rolloutCapabilities } = useRolloutCapabilities(namespace, name, isRollout)
|
|
309
|
+
// Restart and Rollback are the generic workload buttons, so a Rollout has to
|
|
310
|
+
// withhold the callback the way promote-full does. Permissive until the probe
|
|
311
|
+
// answers — withholding while it loads would flicker the shared buttons.
|
|
312
|
+
const rolloutAllows = (verb: 'restart' | 'rollback') =>
|
|
313
|
+
!isRollout || !rolloutCapabilities || rolloutCapabilities[verb]
|
|
277
314
|
const {
|
|
278
315
|
data: revisionsList,
|
|
279
316
|
isLoading: revisionsLoading,
|
|
@@ -339,17 +376,28 @@ function useActionsBarProps(
|
|
|
339
376
|
cascadeDependents: cascadePreview?.dependents,
|
|
340
377
|
cascadeLoading,
|
|
341
378
|
cascadeRootResolved: cascadeError ? false : cascadePreview?.rootResolved,
|
|
342
|
-
onRestart: (
|
|
343
|
-
restartWorkloadMutation.mutate
|
|
379
|
+
onRestart: rolloutAllows('restart')
|
|
380
|
+
? (params: Parameters<typeof restartWorkloadMutation.mutate>[0]) =>
|
|
381
|
+
restartWorkloadMutation.mutate(params)
|
|
382
|
+
: undefined,
|
|
344
383
|
isRestarting: restartWorkloadMutation.isPending,
|
|
345
384
|
revisions: revisionsList,
|
|
346
385
|
revisionsLoading,
|
|
347
386
|
revisionsError: revisionsError ?? null,
|
|
348
|
-
onRollback: (
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
387
|
+
onRollback: rolloutAllows('rollback')
|
|
388
|
+
? (
|
|
389
|
+
params: Parameters<typeof rollbackMutation.mutate>[0],
|
|
390
|
+
callbacks?: { onSuccess?: () => void },
|
|
391
|
+
) => rollbackMutation.mutate(params, { onSuccess: callbacks?.onSuccess })
|
|
392
|
+
: undefined,
|
|
352
393
|
isRollingBack: rollbackMutation.isPending,
|
|
394
|
+
// Absent when promote-full is denied — the dialog reads the callback's
|
|
395
|
+
// presence as the permission signal and hides the option.
|
|
396
|
+
// mutateAsync, not mutate: the revision dialog awaits this before closing.
|
|
397
|
+
onRolloutPromoteFull: rolloutCapabilities?.promoteFull
|
|
398
|
+
? (params: { namespace: string; name: string }) =>
|
|
399
|
+
rolloutActionMutation.mutateAsync({ action: 'promote-full', ...params })
|
|
400
|
+
: undefined,
|
|
353
401
|
onTriggerCronJob: (params: Parameters<typeof triggerCronJobMutation.mutate>[0]) =>
|
|
354
402
|
triggerCronJobMutation.mutate(params),
|
|
355
403
|
isTriggeringCronJob: triggerCronJobMutation.isPending,
|
|
@@ -1821,7 +1869,7 @@ function DiagnoseTabContent({
|
|
|
1821
1869
|
// this is a safety confirm, not an authz check.
|
|
1822
1870
|
const [pendingRunPath, setPendingRunPath] = useState<string | null>(null)
|
|
1823
1871
|
const requestInClusterRun = useCallback((path: string) => {
|
|
1824
|
-
if (inClusterConsentGiven(inClusterCap?.
|
|
1872
|
+
if (inClusterConsentGiven(inClusterCap?.clusterKey)) runInClusterTest(path)
|
|
1825
1873
|
else setPendingRunPath(path)
|
|
1826
1874
|
}, [inClusterCap, runInClusterTest])
|
|
1827
1875
|
const confirmInClusterRun = useCallback(() => {
|
|
@@ -1943,6 +1991,7 @@ function DiagnoseTabContent({
|
|
|
1943
1991
|
<InClusterConsentDialog
|
|
1944
1992
|
open={pendingRunPath !== null}
|
|
1945
1993
|
cluster={inClusterCap?.cluster}
|
|
1994
|
+
clusterKey={inClusterCap?.clusterKey}
|
|
1946
1995
|
namespace={inClusterCap?.namespace ?? namespace}
|
|
1947
1996
|
requests={consentRequests}
|
|
1948
1997
|
untestedCount={consentUntestedCount}
|
package/src/main.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import React from 'react'
|
|
|
2
2
|
import ReactDOM from 'react-dom/client'
|
|
3
3
|
import { RadarApp } from './RadarApp'
|
|
4
4
|
import { openExternal } from './utils/navigation'
|
|
5
|
+
import { installWailsClipboardShim } from './utils/wails-clipboard'
|
|
5
6
|
import './index.css'
|
|
6
7
|
|
|
7
8
|
// Intercept external link clicks in the Wails desktop app.
|
|
@@ -17,120 +18,9 @@ window.addEventListener('click', (e: MouseEvent) => {
|
|
|
17
18
|
openExternal(href)
|
|
18
19
|
})
|
|
19
20
|
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
// webview from wails:// to http://localhost:<port>. After the redirect,
|
|
24
|
-
// window.runtime (Wails JS API) is no longer available. Clipboard operations
|
|
25
|
-
// must use navigator.clipboard and DOM events instead.
|
|
26
|
-
//
|
|
27
|
-
// What works and why:
|
|
28
|
-
// Cmd+C / Cmd+X: Handled in keydown listener below. The Edit menu registers
|
|
29
|
-
// these accelerators with nil callbacks (native responder chain), but WKWebView
|
|
30
|
-
// does NOT dispatch a DOM copy/cut event from the native copy: selector.
|
|
31
|
-
// The keydown event DOES reach JS, so we intercept it here.
|
|
32
|
-
// Cmd+V: Handled by menu.go's explicit WindowExecJS callback which reads
|
|
33
|
-
// navigator.clipboard.readText() and dispatches a synthetic paste event.
|
|
34
|
-
// Right-click Copy/Cut (Monaco): Monaco calls document.execCommand('copy'/'cut'),
|
|
35
|
-
// intercepted by the monkey-patch below.
|
|
36
|
-
// Right-click Paste (Monaco): Not supported — Monaco calls navigator.clipboard
|
|
37
|
-
// .readText() directly (not execCommand), and WKWebView blocks readText() from
|
|
38
|
-
// page JS context. Use Cmd+V instead.
|
|
39
|
-
|
|
40
|
-
// Read selected text from Monaco if it has focus. Monaco uses virtual selection
|
|
41
|
-
// (not DOM selection), so window.getSelection() doesn't work — we access the
|
|
42
|
-
// editor instance exposed by YamlEditor.tsx.
|
|
43
|
-
function getMonacoSelection(): { text: string; editor: any } | null {
|
|
44
|
-
const editor = (window as any).__radarMonacoEditor
|
|
45
|
-
if (!editor?.hasTextFocus?.()) return null
|
|
46
|
-
const sel = editor.getSelection()
|
|
47
|
-
const model = editor.getModel()
|
|
48
|
-
if (!sel || !model) return null
|
|
49
|
-
const text = model.getValueInRange(sel)
|
|
50
|
-
if (!text) return null
|
|
51
|
-
return { text, editor }
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function getSelectedText(): { text: string; monaco: { text: string; editor: any } | null } {
|
|
55
|
-
const monaco = getMonacoSelection()
|
|
56
|
-
if (monaco) return { text: monaco.text, monaco }
|
|
57
|
-
const sel = window.getSelection()
|
|
58
|
-
const text = sel ? sel.toString() : ''
|
|
59
|
-
return { text, monaco: null }
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function deleteMonacoSelection(editor: any): void {
|
|
63
|
-
editor.pushUndoStop()
|
|
64
|
-
editor.executeEdits('cut', [{ range: editor.getSelection(), text: '' }])
|
|
65
|
-
editor.pushUndoStop()
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function handleCopyOrCut(isCut: boolean): void {
|
|
69
|
-
const { text, monaco } = getSelectedText()
|
|
70
|
-
if (!text) return
|
|
71
|
-
navigator.clipboard.writeText(text).catch((err) => { console.warn('[Radar] Clipboard write failed:', err) })
|
|
72
|
-
if (isCut) {
|
|
73
|
-
if (monaco) {
|
|
74
|
-
deleteMonacoSelection(monaco.editor)
|
|
75
|
-
} else {
|
|
76
|
-
_origExecCommand('delete')
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// Cmd+C/X: the menu's nil callback does NOT dispatch a DOM copy event.
|
|
82
|
-
document.addEventListener('keydown', (e) => {
|
|
83
|
-
if (!(e.metaKey || e.ctrlKey)) return
|
|
84
|
-
if (e.key !== 'c' && e.key !== 'x') return
|
|
85
|
-
handleCopyOrCut(e.key === 'x')
|
|
86
|
-
}, true)
|
|
87
|
-
|
|
88
|
-
// Intercept copy/cut DOM events to handle Monaco's virtual selection.
|
|
89
|
-
// These fire from right-click -> Copy in some contexts. When a real
|
|
90
|
-
// ClipboardEvent is available, we write directly to e.clipboardData
|
|
91
|
-
// (synchronous, more reliable than the async clipboard API).
|
|
92
|
-
document.addEventListener('copy', (e: ClipboardEvent) => {
|
|
93
|
-
const result = getMonacoSelection()
|
|
94
|
-
if (result && e.clipboardData) {
|
|
95
|
-
e.preventDefault()
|
|
96
|
-
e.clipboardData.setData('text/plain', result.text)
|
|
97
|
-
}
|
|
98
|
-
}, true)
|
|
99
|
-
|
|
100
|
-
document.addEventListener('cut', (e: ClipboardEvent) => {
|
|
101
|
-
const result = getMonacoSelection()
|
|
102
|
-
if (result && e.clipboardData) {
|
|
103
|
-
e.preventDefault()
|
|
104
|
-
e.clipboardData.setData('text/plain', result.text)
|
|
105
|
-
deleteMonacoSelection(result.editor)
|
|
106
|
-
}
|
|
107
|
-
}, true)
|
|
108
|
-
|
|
109
|
-
// Monkey-patch document.execCommand for Wails WebView compatibility.
|
|
110
|
-
// Handles copy/cut from Monaco's right-click context menu, and paste from
|
|
111
|
-
// any context that calls execCommand('paste').
|
|
112
|
-
const _origExecCommand = document.execCommand.bind(document)
|
|
113
|
-
document.execCommand = function (command: string, showUI?: boolean, value?: string) {
|
|
114
|
-
if (command === 'copy' || command === 'cut') {
|
|
115
|
-
handleCopyOrCut(command === 'cut')
|
|
116
|
-
return true
|
|
117
|
-
}
|
|
118
|
-
if (command === 'paste') {
|
|
119
|
-
navigator.clipboard.readText().then((text) => {
|
|
120
|
-
if (!text) return
|
|
121
|
-
const el = document.activeElement || document.body
|
|
122
|
-
try {
|
|
123
|
-
const dt = new DataTransfer()
|
|
124
|
-
dt.setData('text/plain', text)
|
|
125
|
-
const ev = new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })
|
|
126
|
-
if (!el.dispatchEvent(ev)) return
|
|
127
|
-
} catch { /* ClipboardEvent dispatch failed, fall back to insertText */ }
|
|
128
|
-
_origExecCommand('insertText', false, text)
|
|
129
|
-
}).catch((err) => { console.warn('[Radar] Paste failed:', err) })
|
|
130
|
-
return true
|
|
131
|
-
}
|
|
132
|
-
return _origExecCommand(command, showUI, value)
|
|
133
|
-
} as typeof document.execCommand
|
|
21
|
+
// Wails desktop clipboard shim — see web/src/utils/wails-clipboard.ts for the
|
|
22
|
+
// full WKWebView background and what each interception exists for.
|
|
23
|
+
installWailsClipboardShim()
|
|
134
24
|
|
|
135
25
|
// Mouse back/forward button navigation (button 3 = back, button 4 = forward).
|
|
136
26
|
// Uses 'mouseup' in capture phase to intercept before the browser's native handler.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import type { ContextInfo } from '../types'
|
|
4
|
+
import { parseContextForSwitcher, visibleContextQualifier } from './context-name'
|
|
5
|
+
|
|
6
|
+
function context(name: string, originalName?: string): ContextInfo {
|
|
7
|
+
return {
|
|
8
|
+
name,
|
|
9
|
+
originalName,
|
|
10
|
+
cluster: 'cluster',
|
|
11
|
+
user: 'user',
|
|
12
|
+
namespace: '',
|
|
13
|
+
isCurrent: false,
|
|
14
|
+
source: 'eks',
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('parseContextForSwitcher', () => {
|
|
19
|
+
it('preserves provider parsing while separating a backend qualifier', () => {
|
|
20
|
+
const originalName = 'arn:aws:eks:us-east-1:123456789012:cluster/prod'
|
|
21
|
+
const parsed = parseContextForSwitcher(context(`${originalName} (eks)`, originalName))
|
|
22
|
+
|
|
23
|
+
expect(parsed.provider).toBe('EKS')
|
|
24
|
+
expect(parsed.clusterName).toBe('prod')
|
|
25
|
+
expect(parsed.region).toBe('us-east-1')
|
|
26
|
+
expect(parsed.nameQualifier).toBe('(eks)')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('keeps a qualifier after its colliding sibling disappears', () => {
|
|
30
|
+
const parsed = parseContextForSwitcher(context('prod (secondary)', 'prod'))
|
|
31
|
+
|
|
32
|
+
expect(parsed.raw).toBe('prod')
|
|
33
|
+
expect(parsed.nameQualifier).toBe('(secondary)')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('does not reinterpret a natural parenthesized name', () => {
|
|
37
|
+
const parsed = parseContextForSwitcher(context('prod (config)', 'prod (config)'))
|
|
38
|
+
|
|
39
|
+
expect(parsed.raw).toBe('prod (config)')
|
|
40
|
+
expect(parsed.nameQualifier).toBeUndefined()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('uses the visible name for direct single-file contexts', () => {
|
|
44
|
+
const parsed = parseContextForSwitcher(context('local'))
|
|
45
|
+
|
|
46
|
+
expect(parsed.raw).toBe('local')
|
|
47
|
+
expect(parsed.nameQualifier).toBeUndefined()
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('visibleContextQualifier', () => {
|
|
52
|
+
it('suppresses a qualifier repeated by the visible source label', () => {
|
|
53
|
+
expect(visibleContextQualifier('(secondary)', 'secondary', true)).toBeUndefined()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('retains qualifiers that distinguish identical source labels', () => {
|
|
57
|
+
expect(visibleContextQualifier('(secondary #2)', 'secondary', true)).toBe('(secondary #2)')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('retains the qualifier when the source label is hidden', () => {
|
|
61
|
+
expect(visibleContextQualifier('(secondary)', 'secondary', false)).toBe('(secondary)')
|
|
62
|
+
})
|
|
63
|
+
})
|
|
@@ -1,2 +1,24 @@
|
|
|
1
|
+
import { parseContextName } from '@skyhook-io/k8s-ui/utils/context-name'
|
|
2
|
+
import type { ContextInfo } from '../types'
|
|
3
|
+
|
|
1
4
|
// Re-export from the shared @skyhook-io/k8s-ui package.
|
|
2
5
|
export * from '@skyhook-io/k8s-ui/utils/context-name'
|
|
6
|
+
|
|
7
|
+
export function parseContextForSwitcher(context: ContextInfo) {
|
|
8
|
+
const raw = context.originalName || context.name
|
|
9
|
+
const nameQualifier = context.originalName && context.name !== context.originalName
|
|
10
|
+
? context.name.slice(context.originalName.length).trim() || undefined
|
|
11
|
+
: undefined
|
|
12
|
+
return {
|
|
13
|
+
...parseContextName(raw),
|
|
14
|
+
nameQualifier,
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function visibleContextQualifier(
|
|
19
|
+
qualifier: string | undefined,
|
|
20
|
+
source: string | undefined,
|
|
21
|
+
sourceLabelVisible: boolean,
|
|
22
|
+
) {
|
|
23
|
+
return sourceLabelVisible && qualifier === `(${source})` ? undefined : qualifier
|
|
24
|
+
}
|