@skyhook-io/radar-app 1.8.2 → 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.
@@ -31,7 +31,7 @@ import { Tooltip } from '../ui/Tooltip'
31
31
  import { useToast } from '../ui/Toast'
32
32
  import { openExternal } from '../../utils/navigation'
33
33
  import { apiUrl } from '../../api/config'
34
- import { apiFetch } from '../../api/client'
34
+ import { apiFetch, useCapabilities } from '../../api/client'
35
35
  import { pluralize } from '@skyhook-io/k8s-ui'
36
36
 
37
37
  // --- Types -------------------------------------------------------------------
@@ -84,7 +84,7 @@ function buildRecreateBody(session: PortForwardSession, overrides: { localPort:
84
84
 
85
85
  // --- Shared query ------------------------------------------------------------
86
86
 
87
- function usePortForwardQuery() {
87
+ function usePortForwardQuery(enabled: boolean) {
88
88
  return useQuery<PortForwardSession[]>({
89
89
  queryKey: ['portforwards'],
90
90
  queryFn: async () => {
@@ -92,6 +92,10 @@ function usePortForwardQuery() {
92
92
  if (!res.ok) throw new Error('Failed to fetch port forwards')
93
93
  return res.json()
94
94
  },
95
+ // Port-forward is a local-binary feature; in-cluster (Radar Cloud) the
96
+ // capability is false, so don't poll an endpoint that can never return a
97
+ // usable session. Also covers RBAC-denied users.
98
+ enabled,
95
99
  // 30s fallback poll — user mutations invalidate immediately, but out-of-band
96
100
  // session death (pod restart, OOM kill, server-side cleanup) only surfaces on
97
101
  // the next tick.
@@ -144,12 +148,21 @@ interface PortForwardContextValue {
144
148
  const PortForwardContext = createContext<PortForwardContextValue | null>(null)
145
149
 
146
150
  export function PortForwardProvider({ children }: { children: ReactNode }) {
151
+ // Gate the session-list poll on runtime mode, not the RBAC capability: port-forward
152
+ // only works when radar runs as a local binary, so in-cluster (Radar Cloud) we never
153
+ // poll /portforwards. We deliberately do NOT gate on `portForward` (RBAC) — a local
154
+ // user with portforward rights in only some namespaces must still see/stop the
155
+ // sessions they start (the start buttons gate per-namespace separately). Using the
156
+ // resolved value (undefined while capabilities load → no poll) keeps Cloud silent on
157
+ // first paint.
158
+ const { data: caps } = useCapabilities()
159
+ const canPortForward = caps?.deployment?.mode === 'local'
147
160
  const {
148
161
  data: sessions = [],
149
162
  isLoading,
150
163
  isError: isQueryError,
151
164
  error: queryError,
152
- } = usePortForwardQuery()
165
+ } = usePortForwardQuery(canPortForward)
153
166
  const activeSessions = sessions.filter((s) => s.status !== 'stopped')
154
167
  const errorSessions = sessions.filter((s) => s.status === 'error')
155
168
  const count = activeSessions.length
@@ -970,6 +983,7 @@ export function useStartPortForward() {
970
983
 
971
984
  // Backwards-compat: existing consumers that just want a count number.
972
985
  export function usePortForwardCount() {
973
- const { data: sessions = [] } = usePortForwardQuery()
986
+ const { data: caps } = useCapabilities()
987
+ const { data: sessions = [] } = usePortForwardQuery(caps?.deployment?.mode === 'local')
974
988
  return sessions.filter((s) => s.status !== 'stopped').length
975
989
  }
@@ -1,7 +1,9 @@
1
1
  import { useState, useMemo, useCallback, useEffect } from 'react'
2
2
  import { useLocation, useNavigate } from 'react-router-dom'
3
3
  import { useQuery } from '@tanstack/react-query'
4
- import { ApiError, debugNamespaceLog, fetchJSON, isForbiddenError, useCapabilities, useNamespaceCapabilities, useSecretCertExpiry, useTopPodMetrics, useTopNodeMetrics, useBulkDeleteResources, useBulkRestartWorkloads, useBulkScaleWorkloads } from '../../api/client'
4
+ import { ApiError, debugNamespaceLog, fetchJSON, isForbiddenError, useCapabilities, useNamespaceCapabilities, useSecretCertExpiry, useTopPodMetrics, useTopNodeMetrics, useBulkDeleteResources, useBulkRestartWorkloads, useBulkScaleWorkloads, useAudit } from '../../api/client'
5
+ import { isBadgeWorthy } from '../../utils/auditBadges'
6
+ import type { AuditBadgeMessage } from '@skyhook-io/k8s-ui'
5
7
  import { apiUrl, getAuthHeaders, getCredentialsMode, getBasename } from '../../api/config'
6
8
  import { useAPIResources } from '../../api/apiResources'
7
9
  import { initNavigationMap } from '@skyhook-io/k8s-ui'
@@ -137,6 +139,44 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
137
139
  return match?.isCrd ?? (!!selectedKind.group) // default: has group = likely CRD
138
140
  }, [selectedKind, apiResources])
139
141
 
142
+ // The canonical Kind for the selected resource. selectedKind.kind is the plural
143
+ // URL segment for CRDs/grouped kinds (e.g. "ingressroutes", "ingresses") — only
144
+ // core no-group kinds resolve to the real Kind there — so resolve it via
145
+ // discovery to match audit findings, which key by the real Kind ("IngressRoute").
146
+ const selectedKindCanonical = useMemo(() => {
147
+ if (!selectedKind) return undefined
148
+ const match = apiResources?.find(r => r.name === selectedKind.name && r.group === selectedKind.group)
149
+ ?? CORE_RESOURCES.find(r => r.name === selectedKind.name && r.group === selectedKind.group)
150
+ return match?.kind ?? selectedKind.kind
151
+ }, [selectedKind, apiResources])
152
+
153
+ // Cluster Audit findings for the selected kind, keyed by "namespace/name" for
154
+ // the resource list. The list shows ONE kind at a time, so ns/name is enough;
155
+ // we still match the finding's group (built-ins → real group, CRDs → "") so a
156
+ // kind shared across groups doesn't bleed findings across the two lists. Only
157
+ // "badge-worthy" findings count (reference-integrity / lifecycle) — posture
158
+ // and best-practice nags fire near-universally and would just be noise.
159
+ const audit = useAudit(namespaces)
160
+ const auditBadges = useMemo(() => {
161
+ if (!selectedKind || !audit.data?.findings) return undefined
162
+ const wantGroup = isSelectedCrd ? '' : selectedKind.group
163
+ const map: Record<string, { danger: number; warning: number; messages: AuditBadgeMessage[] }> = {}
164
+ for (const f of audit.data.findings) {
165
+ if (f.kind !== selectedKindCanonical || (f.group ?? '') !== wantGroup) continue
166
+ if (!isBadgeWorthy(f, audit.data.checks)) continue
167
+ const k = `${f.namespace || ''}/${f.name}`
168
+ const cur = map[k] ?? { danger: 0, warning: 0, messages: [] }
169
+ if (f.severity === 'danger') cur.danger++
170
+ else if (f.severity === 'warning') cur.warning++
171
+ cur.messages.push({ severity: f.severity, message: f.message })
172
+ map[k] = cur
173
+ }
174
+ for (const cur of Object.values(map)) {
175
+ cur.messages.sort((a, b) => (a.severity === 'danger' ? 0 : 1) - (b.severity === 'danger' ? 0 : 1))
176
+ }
177
+ return map
178
+ }, [audit.data?.findings, audit.data?.checks, selectedKind, selectedKindCanonical, isSelectedCrd])
179
+
140
180
  const selectedCountKey = selectedKind ? resourceCountKey(selectedKind) : ''
141
181
  const selectedCount = selectedCountKey ? countsData?.counts[selectedCountKey] : undefined
142
182
  const selectedCountUnavailable = selectedCountKey ? countsData?.unavailable?.includes(selectedCountKey) ?? false : false
@@ -294,6 +334,7 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
294
334
  topNodeMetrics={topNodeMetrics}
295
335
  certExpiry={certExpiry}
296
336
  certExpiryError={certExpiryError}
337
+ auditBadges={auditBadges}
297
338
  // Pinned kinds
298
339
  pinned={pinned}
299
340
  togglePin={togglePin}
@@ -2,7 +2,7 @@ import { PodRenderer as BasePodRenderer } from '@skyhook-io/k8s-ui/components/re
2
2
  import type { CopyHandler } from '@skyhook-io/k8s-ui/components/ui/drawer-components'
3
3
  import type { ResolvedEnvFrom } from '@skyhook-io/k8s-ui'
4
4
  import { useOpenTerminal, useOpenLogs } from '../../dock'
5
- import { useNamespacedCapabilities } from '../../../contexts/CapabilitiesContext'
5
+ import { useNamespacedCapabilities, useIsLocalDeployment } from '../../../contexts/CapabilitiesContext'
6
6
  import { usePodMetrics, usePodMetricsHistory, usePrometheusResourceMetrics, usePrometheusStatus } from '../../../api/client'
7
7
  import { useRBACSubject } from '../../../api/rbac'
8
8
  import { PortForwardInlineButton } from '../../portforward/PortForwardButton'
@@ -27,6 +27,11 @@ export function PodRenderer({ data, onCopy, copied, onNavigate, onOpenLogs, reso
27
27
 
28
28
  // Capabilities (namespace-scoped: re-checks RBAC if globally denied)
29
29
  const { canExec, canViewLogs, canPortForward } = useNamespacedCapabilities(namespace)
30
+ // Show the port-forward affordance for a live forward (local + RBAC) OR when
31
+ // not local — in-cluster/Cloud surfaces a copy-paste kubectl command instead.
32
+ // The button itself picks live vs. copy-command based on deployment mode.
33
+ const isLocal = useIsLocalDeployment()
34
+ const showPortForward = canPortForward || !isLocal
30
35
 
31
36
  // Metrics
32
37
  const { data: metrics } = usePodMetrics(namespace, podName)
@@ -65,7 +70,7 @@ export function PodRenderer({ data, onCopy, copied, onNavigate, onOpenLogs, reso
65
70
  rbacError={rbacError as Error | null}
66
71
  canExec={canExec}
67
72
  canViewLogs={canViewLogs}
68
- canPortForward={canPortForward}
73
+ canPortForward={showPortForward}
69
74
  onOpenTerminal={(params) => openTerminal(params)}
70
75
  onOpenLogsPanel={(params) => openLogsPanel(params)}
71
76
  renderPortAction={({ namespace: ns, podName: pod, port, protocol, disabled }) => (
@@ -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
  }
@@ -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
@@ -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}
@@ -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