@skyhook-io/radar-app 1.8.1 → 1.8.3

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 (32) hide show
  1. package/package.json +1 -1
  2. package/src/App.tsx +167 -56
  3. package/src/RadarApp.tsx +18 -1
  4. package/src/api/client.ts +173 -6
  5. package/src/components/NamespaceSwitcher.tsx +52 -30
  6. package/src/components/curl/ServiceCurlButton.tsx +445 -0
  7. package/src/components/gitops/GitOpsView.tsx +1 -10
  8. package/src/components/helm/HelmReleaseDrawer.tsx +802 -44
  9. package/src/components/helm/HelmView.tsx +85 -16
  10. package/src/components/helm/ManifestDiffViewer.tsx +15 -4
  11. package/src/components/helm/OwnedResources.tsx +14 -50
  12. package/src/components/helm/RevisionHistory.tsx +50 -2
  13. package/src/components/helm/TrackChartSourceDialog.tsx +141 -0
  14. package/src/components/helm/ValuesViewer.tsx +41 -11
  15. package/src/components/home/TrafficSummary.tsx +2 -2
  16. package/src/components/home/mcpToolCatalog.ts +10 -10
  17. package/src/components/nav/PrimaryNavRail.tsx +1 -1
  18. package/src/components/portforward/PortForwardButton.tsx +69 -25
  19. package/src/components/portforward/PortForwardManager.tsx +18 -4
  20. package/src/components/resources/ResourcesView.tsx +42 -1
  21. package/src/components/resources/renderers/PodRenderer.tsx +7 -2
  22. package/src/components/resources/renderers/ServiceRenderer.tsx +54 -8
  23. package/src/components/ui/ShortcutHelpOverlay.tsx +1 -1
  24. package/src/components/ui/UpdateNotification.tsx +5 -10
  25. package/src/components/ui/command-items.ts +1 -1
  26. package/src/components/workload/WorkloadView.tsx +52 -7
  27. package/src/context/ConnectionContext.tsx +29 -2
  28. package/src/contexts/CapabilitiesContext.tsx +8 -0
  29. package/src/hooks/useDocumentTitle.ts +25 -0
  30. package/src/main.tsx +5 -3
  31. package/src/utils/auditBadges.ts +53 -0
  32. package/src/utils/navigation.ts +5 -3
@@ -1,7 +1,9 @@
1
- import { useMemo } from 'react'
1
+ import { useMemo, useState, useCallback } from 'react'
2
2
  import { ServiceRenderer as BaseServiceRenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/ServiceRenderer'
3
3
  import { PortForwardInlineButton } from '../../portforward/PortForwardButton'
4
+ import { CurlButton, CurlPanel, isHttpishPort, defaultScheme, defaultPathForPort } from '../../curl/ServiceCurlButton'
4
5
  import { useResources } from '../../../api/client'
6
+ import { useNamespacedCapabilities, useIsLocalDeployment } from '../../../contexts/CapabilitiesContext'
5
7
  import type { ResourceRef } from '../../../types'
6
8
 
7
9
  interface ServiceRendererProps {
@@ -14,6 +16,24 @@ interface ServiceRendererProps {
14
16
  export function ServiceRenderer({ data, onCopy, copied, onNavigate }: ServiceRendererProps) {
15
17
  const namespace = data.metadata?.namespace
16
18
  const serviceName = data.metadata?.name
19
+ const { canPortForward } = useNamespacedCapabilities(namespace)
20
+ const isLocal = useIsLocalDeployment()
21
+ // Offer the port-forward affordance when a live forward is possible (local +
22
+ // RBAC) OR when we're not local — in-cluster/Cloud can't bind a local listener,
23
+ // but we still surface a copy-paste `kubectl port-forward` command.
24
+ const showPortForward = canPortForward || !isLocal
25
+ // Curl dials the Service directly from in-cluster, so it's only available when
26
+ // Radar runs in-cluster/Cloud — locally you'd port-forward instead.
27
+ const showCurl = !isLocal
28
+ // Which port's inline curl panel is open (one at a time). `closing` keeps the
29
+ // panel mounted through its collapse animation before we drop it.
30
+ const [curl, setCurl] = useState<{ port: number; closing: boolean } | null>(null)
31
+ const closeCurl = useCallback(() => {
32
+ setCurl((p) => (p ? { ...p, closing: true } : null))
33
+ // Only drop the panel if it's still the one closing. Opening another port
34
+ // (which sets closing:false) before this fires must not clear the new panel.
35
+ window.setTimeout(() => setCurl((p) => (p?.closing ? null : p)), 220)
36
+ }, [])
17
37
  const spec = data.spec || {}
18
38
  const shouldLoadEndpointSlices = Boolean(
19
39
  namespace &&
@@ -40,14 +60,40 @@ export function ServiceRenderer({ data, onCopy, copied, onNavigate }: ServiceRen
40
60
  endpointSlices={matchingEndpointSlices}
41
61
  endpointSlicesLoading={endpointSlicesLoading}
42
62
  onNavigate={onNavigate}
43
- renderPortAction={({ namespace, serviceName, port, protocol }) => (
44
- <PortForwardInlineButton
45
- namespace={namespace}
46
- serviceName={serviceName}
47
- port={port}
48
- protocol={protocol}
49
- />
63
+ renderPortAction={({ port, name, appProtocol, protocol }) => (
64
+ <>
65
+ {showCurl && isHttpishPort(port, name, appProtocol, protocol) && (
66
+ <CurlButton
67
+ active={curl?.port === port && !curl.closing}
68
+ onClick={() => {
69
+ if (curl?.port === port && !curl.closing) closeCurl()
70
+ else setCurl({ port, closing: false })
71
+ }}
72
+ />
73
+ )}
74
+ {showPortForward && (
75
+ <PortForwardInlineButton
76
+ namespace={namespace}
77
+ serviceName={serviceName}
78
+ port={port}
79
+ protocol={protocol}
80
+ />
81
+ )}
82
+ </>
50
83
  )}
84
+ renderPortPanel={({ port, name, appProtocol }) =>
85
+ curl?.port === port ? (
86
+ <CurlPanel
87
+ namespace={namespace}
88
+ serviceName={serviceName}
89
+ port={port}
90
+ initialScheme={defaultScheme(port, name, appProtocol)}
91
+ initialPath={defaultPathForPort(port, name, appProtocol)}
92
+ open={!curl.closing}
93
+ onClose={closeCurl}
94
+ />
95
+ ) : null
96
+ }
51
97
  />
52
98
  )
53
99
  }
@@ -29,7 +29,7 @@ const VIEW_LABELS: Record<string, string> = {
29
29
  timeline: 'Timeline',
30
30
  helm: 'Helm',
31
31
  gitops: 'GitOps',
32
- traffic: 'Traffic',
32
+ traffic: 'Live Traffic',
33
33
  }
34
34
 
35
35
  type ShortcutEntry = { description: string; keys: string[] }
@@ -27,21 +27,16 @@ export function UpdateNotification() {
27
27
 
28
28
  const isDesktop = versionInfo?.installMethod === 'desktop'
29
29
 
30
- // Listen for "Check for Updates" menu item in desktop app (Wails runtime event).
31
- // Un-dismisses the notification and invalidates the version check cache.
30
+ // Listen for "Check for Updates" menu item in desktop app.
32
31
  useEffect(() => {
33
- const wailsRuntime = (window as unknown as Record<string, unknown>).runtime as
34
- | { EventsOn?: (event: string, callback: () => void) => () => void }
35
- | undefined
36
- if (!wailsRuntime?.EventsOn) return
37
-
38
- const cleanup = wailsRuntime.EventsOn('check-for-updates', () => {
32
+ const handler = () => {
39
33
  setDismissed(false)
40
34
  try { localStorage.removeItem(DISMISSED_KEY) } catch { /* ignore */ }
41
35
  queryClient.invalidateQueries({ queryKey: ['version-check'] })
42
- })
36
+ }
43
37
 
44
- return cleanup
38
+ window.addEventListener('radar:check-for-updates', handler)
39
+ return () => window.removeEventListener('radar:check-for-updates', handler)
45
40
  }, [queryClient])
46
41
 
47
42
  // Log version check errors for debugging
@@ -91,7 +91,7 @@ const VIEW_ENTRIES: { view: MainView; label: string; icon: React.ComponentType<{
91
91
  { view: 'timeline', label: 'Timeline', icon: Clock, shortcut: 'g l' },
92
92
  { view: 'helm', label: 'Helm', icon: Package, shortcut: 'g m' },
93
93
  { view: 'gitops', label: 'GitOps', icon: GitBranch, shortcut: 'g o' },
94
- { view: 'traffic', label: 'Traffic', icon: Activity, shortcut: 'g f' },
94
+ { view: 'traffic', label: 'Live Traffic', icon: Activity, shortcut: 'g f' },
95
95
  { view: 'checks', label: 'Checks', icon: ShieldCheck, shortcut: 'g u' },
96
96
  { view: 'cost', label: 'Cost', icon: DollarSign, shortcut: 'g c' },
97
97
  ]
@@ -35,11 +35,11 @@ import { PrometheusCharts, isPrometheusSupported } from '../resource/PrometheusC
35
35
  import { PrometheusChartsGrid } from '../resource/PrometheusChartsGrid'
36
36
  import { RestartEventLane } from '../resource/RestartChart'
37
37
  import { RightsizingStrip } from '../resource/RightsizingStrip'
38
- import { useResourceAudit, useResources } from '../../api/client'
39
- import { AuditAlerts } from '@skyhook-io/k8s-ui'
38
+ import { useResourceAudit, useResourceIssues, useResources } from '../../api/client'
39
+ import { AuditAlerts, ResourceIssuesSection } from '@skyhook-io/k8s-ui'
40
40
  import { WorkloadLogsViewer } from '../logs/WorkloadLogsViewer'
41
41
  import { LogsViewer } from '../logs/LogsViewer'
42
- import { useCanUpdateSecrets, useCanNodeWrite, useNamespacedCapabilities } from '../../contexts/CapabilitiesContext'
42
+ import { useCanUpdateSecrets, useCanNodeWrite, useNamespacedCapabilities, useIsLocalDeployment } from '../../contexts/CapabilitiesContext'
43
43
  import { useOpenTerminal, useOpenLogs, useOpenWorkloadLogs, useOpenNodeTerminal } from '../dock'
44
44
  import { PortForwardButton } from '../portforward/PortForwardButton'
45
45
  import { useToast } from '../ui/Toast'
@@ -90,13 +90,27 @@ export function WorkloadViewRoute({ onNavigateToResource }: WorkloadViewRoutePro
90
90
  // Parse /workload/:kind/:ns/:name from pathname. Segments are URL-encoded by
91
91
  // buildWorkloadPath; names can also contain literal slashes (e.g. some CRD names),
92
92
  // which survive encoding as %2F and reassemble correctly here.
93
+ //
94
+ // Cluster-scoped resources (Node, PersistentVolume, Namespace, …) have no
95
+ // namespace: buildWorkloadPath encodes the namespace segment as '_'. Decode
96
+ // that back to '' here, and tolerate a legacy empty segment ('//') and the
97
+ // collapsed three-segment form (/workload/:kind/:name) for older links.
93
98
  const parts = location.pathname.replace(/^\//, '').split('/')
94
99
  const decode = (s: string): string => {
95
100
  try { return decodeURIComponent(s) } catch { return s }
96
101
  }
97
102
  const kind = decode(parts[1] ?? '')
98
- const namespace = decode(parts[2] ?? '')
99
- const name = parts.slice(3).map(decode).join('/')
103
+ let namespace: string
104
+ let name: string
105
+ if (parts.length <= 3) {
106
+ // /workload/:kind/:name — cluster-scoped link with no namespace segment.
107
+ namespace = ''
108
+ name = decode(parts[2] ?? '')
109
+ } else {
110
+ const nsSegment = parts[2] ?? ''
111
+ namespace = nsSegment === '_' || nsSegment === '' ? '' : decode(nsSegment)
112
+ name = parts.slice(3).map(decode).join('/')
113
+ }
100
114
  const group = searchParams.get('apiGroup') || ''
101
115
 
102
116
  const handleBack = useCallback(() => {
@@ -112,7 +126,8 @@ export function WorkloadViewRoute({ onNavigateToResource }: WorkloadViewRoutePro
112
126
  }, [navigate])
113
127
 
114
128
  // Hooks must run unconditionally — the invalid-URL guard comes after them.
115
- if (!kind || !namespace || !name) {
129
+ // Namespace is empty for cluster-scoped resources, so only kind + name are required.
130
+ if (!kind || !name) {
116
131
  return (
117
132
  <div className="flex items-center justify-center h-full text-theme-text-tertiary">
118
133
  Invalid workload URL
@@ -159,6 +174,10 @@ function useActionsBarProps(kind: string, namespace: string, name: string) {
159
174
  const openWorkloadLogs = useOpenWorkloadLogs()
160
175
  const openNodeTerminal = useOpenNodeTerminal()
161
176
  const { canExec, canViewLogs, canPortForward } = useNamespacedCapabilities(namespace)
177
+ // Live forward when local+RBAC; otherwise (in-cluster/Cloud) still surface the
178
+ // copy-paste kubectl command. The button picks live vs. copy by deployment mode.
179
+ const isLocal = useIsLocalDeployment()
180
+ const showPortForward = canPortForward || !isLocal
162
181
 
163
182
  const deleteMutation = useDeleteResource()
164
183
  const restartWorkloadMutation = useRestartWorkload()
@@ -190,7 +209,7 @@ function useActionsBarProps(kind: string, namespace: string, name: string) {
190
209
  return {
191
210
  canExec,
192
211
  canViewLogs,
193
- canPortForward,
212
+ canPortForward: showPortForward,
194
213
  onOpenTerminal: openTerminal,
195
214
  onOpenLogs: openLogs,
196
215
  onOpenWorkloadLogs: openWorkloadLogs,
@@ -417,6 +436,15 @@ export function WorkloadView({
417
436
  () => (resource?.apiVersion ? apiVersionToGroup(resource.apiVersion) : undefined),
418
437
  [resource?.apiVersion],
419
438
  )
439
+ // Live Operational Issues for this resource. Fetched here (not inside the lead
440
+ // render-prop) so the count also gates `hasOperationalIssues` — which tells the
441
+ // renderers to suppress their own status-derived problems and avoid duplicates.
442
+ // Keyed on the STABLE prop kind+group (same inputs as the resource fetch above),
443
+ // NOT the manifest-derived ones: deriving kind/group from the loaded resource
444
+ // would flip the query key when the manifest arrives, drop liveIssues, and flash
445
+ // the renderer banners. The backend canonicalizes a plural kind via discovery,
446
+ // so passing the route's plural kindProp resolves correctly.
447
+ const { data: liveIssues } = useResourceIssues(kindProp, rest.group, namespace, name)
420
448
  const { onCompareTo, onCompareAcrossClusters, picker: comparePicker } = useCompareLauncher({
421
449
  kind: kindProp,
422
450
  namespace,
@@ -523,6 +551,23 @@ export function WorkloadView({
523
551
  <FluxSourceConsumersSection kind={k} namespace={ns} name={n} />
524
552
  </>
525
553
  )}
554
+ renderOverviewLead={() => (
555
+ <ResourceIssuesSection
556
+ issues={liveIssues}
557
+ onResourceClick={
558
+ rest.onNavigateToResource
559
+ ? (ref) =>
560
+ rest.onNavigateToResource?.({
561
+ kind: kindToPlural(ref.kind),
562
+ namespace: ref.namespace ?? '',
563
+ name: ref.name,
564
+ group: ref.group ?? '',
565
+ })
566
+ : undefined
567
+ }
568
+ />
569
+ )}
570
+ hasOperationalIssues={!!liveIssues?.length}
526
571
  onOpenGitOpsResource={gitopsOwnerQuery.data ? handleOpenGitOpsResource : undefined}
527
572
  resolvedGitOpsOwner={gitopsOwner}
528
573
  gitOpsOwnerVerified={gitOpsOwnerVerified}
@@ -62,6 +62,22 @@ export function ConnectionProvider({ children }: { children: ReactNode }) {
62
62
  // Track if SSE has started delivering connection_state events
63
63
  // Once SSE is active, it becomes the authoritative source for connection state
64
64
  const sseActiveRef = useRef(false)
65
+ // Track whether we've reached 'connected' at least once. Distinguishes the
66
+ // initial connect (bootstrap queries already fetched while 'connecting') from
67
+ // a reconnect after a drop (cache may be stale across the gap).
68
+ const hasConnectedRef = useRef(false)
69
+ // Whether the QueryClient already held data when this provider mounted. A host
70
+ // can share one client across cluster-scoped RadarApp mounts (see RadarApp's
71
+ // `queryClient` prop); that client may carry another cluster's data under
72
+ // identical keys, so a warm-at-mount cache must be fully refreshed on first
73
+ // connect. A cold cache (standalone, or a per-cluster remount) takes the cheap
74
+ // error-only path. Snapshot synchronously before this provider's own query
75
+ // registers — ConnectionProvider is the outermost provider, so a fresh client
76
+ // is genuinely empty here.
77
+ const cacheWarmAtMountRef = useRef<boolean | null>(null)
78
+ if (cacheWarmAtMountRef.current === null) {
79
+ cacheWarmAtMountRef.current = queryClient.getQueryCache().getAll().length > 0
80
+ }
65
81
 
66
82
  // Fetch initial connection status
67
83
  // Poll while connecting to get progress updates (SSE not established yet)
@@ -143,9 +159,20 @@ export function ConnectionProvider({ children }: { children: ReactNode }) {
143
159
  return status
144
160
  })
145
161
 
146
- // If we just connected, invalidate queries to fetch fresh data
147
162
  if (status.state === 'connected') {
148
- queryClient.invalidateQueries()
163
+ const firstConnect = !hasConnectedRef.current
164
+ hasConnectedRef.current = true
165
+ // A reconnect after a drop (cache stale across the gap), or a first connect
166
+ // onto a client that already carried data at mount (shared across clusters),
167
+ // refreshes the whole cache. A clean first connect only needs to recover the
168
+ // bootstrap queries that 503'd while the cluster was still 'connecting'
169
+ // (status === 'error'); the rest already fetched fresh during 'connecting',
170
+ // so re-fetching the whole cache there would double-load every endpoint.
171
+ if (!firstConnect || cacheWarmAtMountRef.current) {
172
+ queryClient.invalidateQueries()
173
+ } else {
174
+ queryClient.invalidateQueries({ predicate: (q) => q.state.status === 'error' })
175
+ }
149
176
  }
150
177
  }, [queryClient])
151
178
 
@@ -91,6 +91,14 @@ export function useCanPortForward(): boolean {
91
91
  return useContext(CapabilitiesContext).portForward
92
92
  }
93
93
 
94
+ // True when Radar runs as a local binary (live port-forward is possible). When
95
+ // false (in-cluster / Radar Cloud) a live forward can't bind a usable local
96
+ // listener, so the UI offers a copy-paste `kubectl port-forward` command instead.
97
+ // Defaults to local during the capabilities-loading window (see defaultCapabilities).
98
+ export function useIsLocalDeployment(): boolean {
99
+ return useContext(CapabilitiesContext).deployment?.mode === 'local'
100
+ }
101
+
94
102
  export function useCanViewSecrets(): boolean {
95
103
  return useContext(CapabilitiesContext).secrets
96
104
  }
@@ -0,0 +1,25 @@
1
+ import { useEffect } from 'react';
2
+
3
+ // Restore-on-unmount matters for overlay/detail views: closing a resource drawer
4
+ // that opened over a list returns the list's title rather than stranding the
5
+ // resource's. document.title is global to the page, so embedders that don't own
6
+ // the whole tab pass a falsy `label` to opt out and keep their own title
7
+ // (AppInner only feeds a label when the host passed `manageDocumentTitle`).
8
+ //
9
+ // `suffix` is the full trailing string after the label, so a host can rebrand
10
+ // (' — My Cloud') or drop it entirely ('').
11
+ const DEFAULT_SUFFIX = ' · Radar';
12
+
13
+ export function useDocumentTitle(
14
+ label: string | null | undefined,
15
+ suffix: string = DEFAULT_SUFFIX,
16
+ ): void {
17
+ useEffect(() => {
18
+ if (!label) return;
19
+ const previous = document.title;
20
+ document.title = `${label}${suffix}`;
21
+ return () => {
22
+ document.title = previous;
23
+ };
24
+ }, [label, suffix]);
25
+ }
package/src/main.tsx CHANGED
@@ -155,10 +155,12 @@ window.addEventListener('mouseup', (e: MouseEvent) => {
155
155
  }, true)
156
156
 
157
157
 
158
- // Standalone Radar binary: same-origin API, router at root. Library consumers
159
- // (e.g. radar-hub-web) render <RadarApp apiBase="..." basename="..." /> instead.
158
+ // Standalone Radar binary: same-origin API, router at root. It owns the whole
159
+ // tab, so it opts into per-view document.title. Library consumers (e.g.
160
+ // radar-hub-web) render <RadarApp apiBase="..." basename="..." /> WITHOUT this
161
+ // flag, keeping their own tab title.
160
162
  ReactDOM.createRoot(document.getElementById('root')!).render(
161
163
  <React.StrictMode>
162
- <RadarApp />
164
+ <RadarApp manageDocumentTitle />
163
165
  </React.StrictMode>
164
166
  )
@@ -0,0 +1,53 @@
1
+ import { resourceKey, type AuditFinding, type CheckMeta } from '@skyhook-io/k8s-ui'
2
+
3
+ export interface AuditBadgeMessage {
4
+ severity: string
5
+ message: string
6
+ }
7
+
8
+ export interface AuditSeverityCounts {
9
+ danger: number
10
+ warning: number
11
+ /** The finding messages behind the counts, danger-first, for inline tooltips.
12
+ * Lets a badge say WHAT is wrong on hover instead of just a count. */
13
+ messages: AuditBadgeMessage[]
14
+ }
15
+
16
+ /**
17
+ * isBadgeWorthy keeps per-resource badges high-signal: only findings whose check
18
+ * is flagged `badgeWorthy` in the registry (reference-integrity / lifecycle —
19
+ * "this resource is actually broken") count. Security-posture and best-practice
20
+ * checks fire on nearly every resource and would turn the badges into noise;
21
+ * they live in the Checks/Audit views. Unknown checks default to NOT badged.
22
+ */
23
+ export function isBadgeWorthy(
24
+ finding: AuditFinding,
25
+ checks: Record<string, CheckMeta> | undefined,
26
+ ): boolean {
27
+ return !!checks?.[finding.checkID]?.badgeWorthy
28
+ }
29
+
30
+ /**
31
+ * buildAuditSeverityMap keys badge-worthy findings by the same resource key the
32
+ * backend stamps onto topology nodes (`node.data.auditKey`): `group|Kind|ns|name`,
33
+ * group following the audit convention (built-ins → their group, CRDs → "").
34
+ */
35
+ export function buildAuditSeverityMap(
36
+ findings: AuditFinding[] | undefined,
37
+ checks: Record<string, CheckMeta> | undefined,
38
+ ): Map<string, AuditSeverityCounts> {
39
+ const map = new Map<string, AuditSeverityCounts>()
40
+ for (const f of findings ?? []) {
41
+ if (!isBadgeWorthy(f, checks)) continue
42
+ const key = resourceKey(f.group ?? '', f.kind, f.namespace ?? '', f.name)
43
+ const cur = map.get(key) ?? { danger: 0, warning: 0, messages: [] }
44
+ if (f.severity === 'danger') cur.danger++
45
+ else if (f.severity === 'warning') cur.warning++
46
+ cur.messages.push({ severity: f.severity, message: f.message })
47
+ map.set(key, cur)
48
+ }
49
+ for (const cur of map.values()) {
50
+ cur.messages.sort((a, b) => (a.severity === 'danger' ? 0 : 1) - (b.severity === 'danger' ? 0 : 1))
51
+ }
52
+ return map
53
+ }
@@ -20,12 +20,14 @@ export type { NavigateToResource } from '@skyhook-io/k8s-ui/utils/navigation'
20
20
  /**
21
21
  * Build a /workload/:kind/:namespace/:name URL, preserving the API group as a
22
22
  * query param so the WorkloadView can resolve CRDs with colliding kind names.
23
- * Namespaced workloads only WorkloadView requires a namespace. For arbitrary
24
- * kinds (including cluster-scoped) use resourcePath.
23
+ * Cluster-scoped resources (Node, PersistentVolume, Namespace, …) have no
24
+ * namespace; they're encoded with a '_' sentinel segment so the path stays
25
+ * positional and WorkloadViewRoute can parse it back. '_' is safe — it's not a
26
+ * valid DNS-1123 namespace label, so it can never collide with a real one.
25
27
  */
26
28
  export function buildWorkloadPath(resource: SelectedResource): string {
27
29
  const kind = encodeURIComponent(resource.kind)
28
- const namespace = encodeURIComponent(resource.namespace)
30
+ const namespace = encodeURIComponent(resource.namespace || '_')
29
31
  const name = encodeURIComponent(resource.name)
30
32
  const base = `/workload/${kind}/${namespace}/${name}`
31
33
  return resource.group ? `${base}?apiGroup=${encodeURIComponent(resource.group)}` : base