@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.
Files changed (66) hide show
  1. package/package.json +10 -10
  2. package/src/App.tsx +5 -8
  3. package/src/api/client.ts +174 -12
  4. package/src/api/policy.test.ts +38 -0
  5. package/src/api/policy.ts +166 -2
  6. package/src/components/ConnectionErrorView.test.tsx +53 -0
  7. package/src/components/ConnectionErrorView.tsx +15 -12
  8. package/src/components/ContextSwitcher.tsx +10 -13
  9. package/src/components/audit/UpgradeReadinessView.tsx +3 -3
  10. package/src/components/capacity/ClusterSchedulingCard.tsx +8 -9
  11. package/src/components/capacity/schedulingBar.test.ts +10 -0
  12. package/src/components/cost/ApplicationCostTab.test.ts +6 -0
  13. package/src/components/cost/ApplicationCostTab.tsx +28 -16
  14. package/src/components/cost/CostTrendChart.tsx +20 -10
  15. package/src/components/cost/CostView.tsx +79 -28
  16. package/src/components/cost/CurrentAllocationUse.tsx +6 -4
  17. package/src/components/cost/WorkloadCostTab.test.ts +10 -0
  18. package/src/components/cost/WorkloadCostTab.tsx +24 -12
  19. package/src/components/cost/format.test.ts +27 -8
  20. package/src/components/cost/format.ts +78 -27
  21. package/src/components/home/ClusterHealthCard.tsx +3 -0
  22. package/src/components/home/CostCard.tsx +12 -7
  23. package/src/components/home/HomeView.tsx +15 -3
  24. package/src/components/home/NetworkPolicyCoverageCard.test.tsx +81 -0
  25. package/src/components/home/NetworkPolicyCoverageCard.tsx +50 -7
  26. package/src/components/home/TopologyPreview.tsx +57 -11
  27. package/src/components/home/mcpToolCatalog.ts +27 -5
  28. package/src/components/nav/PrimaryNavRail.tsx +2 -2
  29. package/src/components/resources/ResourcesView.tsx +15 -3
  30. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +116 -1
  31. package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +227 -0
  32. package/src/components/resources/renderers/CNPGImageCatalogRenderer.tsx +123 -0
  33. package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +152 -0
  34. package/src/components/resources/renderers/KyvernoPolicyCoverage.tsx +65 -0
  35. package/src/components/resources/renderers/KyvernoPolicyQueued.render.test.tsx +59 -0
  36. package/src/components/resources/renderers/KyvernoPolicyQueued.test.ts +99 -0
  37. package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +184 -0
  38. package/src/components/resources/renderers/RolloutRenderer.tsx +24 -1
  39. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +44 -1
  40. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +75 -1
  41. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +35 -1
  42. package/src/components/resources/renderers/index.ts +1 -0
  43. package/src/components/rightsizing/RightsizingScanView.tsx +2 -2
  44. package/src/components/settings/SettingsDialog.tsx +160 -36
  45. package/src/components/settings/currency-options.test.ts +49 -0
  46. package/src/components/settings/currency-options.ts +38 -0
  47. package/src/components/traffic/TrafficFilterSidebar.tsx +37 -20
  48. package/src/components/traffic/TrafficFlowList.tsx +16 -2
  49. package/src/components/traffic/TrafficGraph.tsx +150 -58
  50. package/src/components/traffic/TrafficView.tsx +168 -52
  51. package/src/components/traffic/TrafficWizard.tsx +13 -1
  52. package/src/components/traffic/trafficFilters.test.ts +103 -0
  53. package/src/components/traffic/trafficFilters.ts +117 -0
  54. package/src/components/ui/DiagnosticsOverlay.test.ts +115 -0
  55. package/src/components/ui/DiagnosticsOverlay.tsx +65 -8
  56. package/src/components/ui/command-items.ts +4 -14
  57. package/src/components/workload/WorkloadView.tsx +57 -8
  58. package/src/main.tsx +4 -114
  59. package/src/utils/context-name.test.ts +63 -0
  60. package/src/utils/context-name.ts +22 -0
  61. package/src/utils/navigation.ts +44 -2
  62. package/src/utils/network-policy-navigation.test.ts +68 -0
  63. package/src/utils/topology-selection.test.ts +40 -0
  64. package/src/utils/topology-selection.ts +39 -0
  65. package/src/utils/wails-clipboard.test.ts +109 -0
  66. 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-baseline justify-between gap-4 text-xs">
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 truncate',
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
- <Row label="Contexts (post-merge)" value={k.contextCount} />
225
- <Row label="Enriched From Shell" value={k.enrichedFromShell ? 'Yes' : 'No'} />
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
- lines.push(`- Mode: \`${k.mode || '(not initialized)'}\` | Files: ${k.fileCount} | Contexts (post-merge): ${k.contextCount} | Enriched From Shell: ${k.enrichedFromShell ? 'Yes' : 'No'}`)
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
- DollarSign,
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: DollarSign, shortcut: "g c" },
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 = parseContextName(
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: (params: Parameters<typeof restartWorkloadMutation.mutate>[0]) =>
343
- restartWorkloadMutation.mutate(params),
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
- params: Parameters<typeof rollbackMutation.mutate>[0],
350
- callbacks?: { onSuccess?: () => void },
351
- ) => rollbackMutation.mutate(params, { onSuccess: callbacks?.onSuccess }),
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?.cluster)) runInClusterTest(path)
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
- // === Wails Desktop Clipboard ===
21
- //
22
- // Background: The desktop app uses a RedirectHandler that navigates the Wails
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
+ }