@skyhook-io/radar-app 1.8.2 → 1.8.5

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 (43) hide show
  1. package/package.json +5 -5
  2. package/src/App.tsx +412 -146
  3. package/src/RadarApp.tsx +21 -1
  4. package/src/api/client.ts +144 -12
  5. package/src/components/ConnectionErrorView.tsx +1 -1
  6. package/src/components/ContextSwitcher.tsx +5 -1
  7. package/src/components/NamespaceSwitcher.tsx +21 -278
  8. package/src/components/applications/ApplicationsView.tsx +13 -1
  9. package/src/components/audit/AuditView.tsx +11 -2
  10. package/src/components/cost/CostView.tsx +12 -2
  11. package/src/components/curl/ServiceCurlButton.tsx +445 -0
  12. package/src/components/gitops/GitOpsView.tsx +23 -17
  13. package/src/components/helm/HelmCompareRoute.tsx +1342 -0
  14. package/src/components/helm/HelmReleaseDrawer.tsx +448 -67
  15. package/src/components/helm/HelmView.tsx +79 -62
  16. package/src/components/helm/ManifestDiffViewer.tsx +18 -7
  17. package/src/components/helm/OwnedResources.tsx +14 -50
  18. package/src/components/helm/RevisionHistory.tsx +9 -5
  19. package/src/components/helm/ValuesViewer.tsx +41 -11
  20. package/src/components/home/ClusterHealthCard.tsx +6 -1
  21. package/src/components/home/HomeView.tsx +12 -1
  22. package/src/components/home/mcpToolCatalog.ts +8 -8
  23. package/src/components/issues/IssuesPane.tsx +29 -18
  24. package/src/components/portforward/PortForwardButton.tsx +69 -25
  25. package/src/components/portforward/PortForwardManager.tsx +18 -4
  26. package/src/components/resources/ResourceDetailDrawer.tsx +8 -3
  27. package/src/components/resources/ResourcesView.tsx +45 -1
  28. package/src/components/resources/renderers/PodRenderer.tsx +7 -2
  29. package/src/components/resources/renderers/ServiceRenderer.tsx +54 -8
  30. package/src/components/timeline/TimelineView.tsx +26 -2
  31. package/src/components/traffic/TrafficView.tsx +17 -10
  32. package/src/components/ui/Markdown.tsx +2 -2
  33. package/src/components/ui/Omnibar.tsx +1 -1
  34. package/src/components/ui/UpdateNotification.tsx +5 -10
  35. package/src/components/workload/WorkloadView.tsx +57 -8
  36. package/src/contexts/CapabilitiesContext.tsx +8 -0
  37. package/src/filter/FilterLocationBridge.tsx +30 -0
  38. package/src/hooks/useDocumentTitle.ts +25 -0
  39. package/src/hooks/useKeyboardShortcuts.tsx +1 -0
  40. package/src/index.ts +15 -0
  41. package/src/main.tsx +5 -3
  42. package/src/utils/auditBadges.ts +53 -0
  43. 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
  }
@@ -1,5 +1,5 @@
1
1
  import { useState, useMemo, useRef } from 'react'
2
- import { Network } from 'lucide-react'
2
+ import { Network, AlertTriangle, RefreshCw } from 'lucide-react'
3
3
  import { TimelineList } from './TimelineList'
4
4
  import { TimelineSwimlanes } from './TimelineSwimlanes'
5
5
  import { useChanges, useTopology } from '../../api/client'
@@ -52,7 +52,7 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
52
52
 
53
53
  // Fetch all activity - zoom controls what's visible in the UI
54
54
  // Only fetch heavy 10k dataset for swimlanes; list view fetches its own 500
55
- const { data: activity, isLoading } = useChanges({
55
+ const { data: activity, isLoading, isError, refetch } = useChanges({
56
56
  namespaces,
57
57
  timeRange: 'all',
58
58
  includeK8sEvents: true,
@@ -115,6 +115,30 @@ export function TimelineView({ namespaces, onResourceClick, initialViewMode, ini
115
115
  )
116
116
  }
117
117
 
118
+ // A failed fetch must not render as the swimlane "No events yet" empty state —
119
+ // that reads as a quiet cluster rather than a load failure.
120
+ if (isError) {
121
+ return (
122
+ <div className="flex-1 flex flex-col">
123
+ <div className="flex items-center justify-between px-4 py-2 border-b border-theme-border">
124
+ <div />
125
+ <ViewModeToggle viewMode={viewMode} onViewModeChange={setViewMode} />
126
+ </div>
127
+ <div className="flex-1 flex flex-col items-center justify-center text-theme-text-tertiary gap-3">
128
+ <AlertTriangle className="w-10 h-10 text-amber-400/70" />
129
+ <p className="text-base">Failed to load timeline data</p>
130
+ <button
131
+ onClick={() => refetch()}
132
+ className="flex items-center gap-2 px-3 py-1.5 text-sm bg-theme-elevated border border-theme-border-light rounded-lg hover:bg-theme-hover transition-colors"
133
+ >
134
+ <RefreshCw className="w-3.5 h-3.5" />
135
+ Try again
136
+ </button>
137
+ </div>
138
+ </div>
139
+ )
140
+ }
141
+
118
142
  return (
119
143
  <TimelineSwimlanes
120
144
  events={events}
@@ -1,5 +1,4 @@
1
1
  import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
2
- import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
3
2
  import { useTrafficSources, useTrafficFlows, useTrafficConnect, useSetTrafficSource } from '../../api/traffic'
4
3
  import { useClusterInfo } from '../../api/client'
5
4
  import type { TrafficWizardState, AggregatedFlow } from '../../types'
@@ -7,11 +6,12 @@ import { TrafficWizard } from './TrafficWizard'
7
6
  import { TrafficGraph, type TrafficGraphSelection } from './TrafficGraph'
8
7
  import { TrafficFilterSidebar } from './TrafficFilterSidebar'
9
8
  import { TrafficFlowListProvider } from './TrafficFlowListContext'
10
- import { Loader2, RefreshCw, Filter, Plug, ChevronDown, List, Activity, AlertTriangle } from 'lucide-react'
9
+ import { Loader2, Filter, Plug, ChevronDown, List, Activity, AlertTriangle } from 'lucide-react'
11
10
  import { clsx } from 'clsx'
12
11
  import { useQueryClient } from '@tanstack/react-query'
13
12
  import { useDock } from '../dock'
14
- import { EmptyState, PaneLoader } from '@skyhook-io/k8s-ui'
13
+ import { EmptyState, PaneLoader, FreshnessControl } from '@skyhook-io/k8s-ui'
14
+ import { useConnection } from '../../context/ConnectionContext'
15
15
  import { Tooltip } from '../ui/Tooltip'
16
16
 
17
17
  // Addon types for filtering
@@ -339,6 +339,7 @@ interface TrafficViewProps {
339
339
  }
340
340
 
341
341
  export function TrafficView({ namespaces }: TrafficViewProps) {
342
+ const { connection } = useConnection()
342
343
  const [wizardState, setWizardState] = useState<TrafficWizardState>('detecting')
343
344
  const [timeRange, setTimeRange] = useState<string>('5m')
344
345
  const [hideSystem, setHideSystem] = useState(true)
@@ -421,8 +422,8 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
421
422
 
422
423
  const {
423
424
  data: flowsData,
424
- isLoading: flowsLoading,
425
425
  isFetching: flowsFetching,
426
+ dataUpdatedAt: flowsUpdatedAt,
426
427
  refetch: refetchFlowsRaw,
427
428
  } = useTrafficFlows({
428
429
  namespaces,
@@ -430,7 +431,6 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
430
431
  // Only fetch flows when connected (not connecting and no connection error)
431
432
  enabled: wizardState === 'ready' && !isConnecting && !connectionError,
432
433
  })
433
- const [refetchFlows, isRefreshAnimating] = useRefreshAnimation(refetchFlowsRaw)
434
434
 
435
435
  // Auto-retry when flows return with warning but no data (e.g., port-forward not ready yet)
436
436
  useEffect(() => {
@@ -1125,12 +1125,19 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
1125
1125
  </button>
1126
1126
  </Tooltip>
1127
1127
  )}
1128
- <div className="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-theme-surface/90 backdrop-blur border border-theme-border text-[10px] text-theme-text-tertiary">
1128
+ <div className="flex items-center px-2 py-1 rounded-lg bg-theme-surface/90 backdrop-blur border border-theme-border text-[10px] text-theme-text-tertiary tabular-nums">
1129
1129
  {flowStats.shown}/{flowStats.total}
1130
- <button onClick={refetchFlows} disabled={flowsLoading || isRefreshAnimating}
1131
- className={clsx('p-0.5 rounded hover:text-theme-text-primary transition-colors', (flowsLoading || isRefreshAnimating) && 'opacity-50')}>
1132
- {flowsLoading ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className={clsx('h-3 w-3', isRefreshAnimating && 'animate-spin')} />}
1133
- </button>
1130
+ </div>
1131
+ {/* Flows are a REST snapshot (no poll, no stream), so this is
1132
+ an honest "Updated N ago" + manual refresh not "live". */}
1133
+ <div className="flex items-center rounded-lg bg-theme-surface/90 backdrop-blur border border-theme-border px-1.5 py-0.5">
1134
+ <FreshnessControl
1135
+ mode="snapshot"
1136
+ dataUpdatedAt={flowsUpdatedAt}
1137
+ isFetching={flowsFetching}
1138
+ onRefresh={() => refetchFlowsRaw()}
1139
+ connectionState={connection.state}
1140
+ />
1134
1141
  </div>
1135
1142
  </div>
1136
1143
  </>
@@ -39,10 +39,10 @@ export function Markdown({ children, className }: MarkdownProps) {
39
39
  </a>
40
40
  ),
41
41
  ul: ({ children }) => (
42
- <ul className="list-disc list-inside my-2 space-y-1 text-theme-text-secondary">{children}</ul>
42
+ <ul className="list-disc list-outside pl-4 my-2 space-y-1 text-theme-text-secondary">{children}</ul>
43
43
  ),
44
44
  ol: ({ children }) => (
45
- <ol className="list-decimal list-inside my-2 space-y-1 text-theme-text-secondary">{children}</ol>
45
+ <ol className="list-decimal list-outside pl-4 my-2 space-y-1 text-theme-text-secondary">{children}</ol>
46
46
  ),
47
47
  li: ({ children }) => (
48
48
  <li className="leading-relaxed">{children}</li>
@@ -414,7 +414,7 @@ export const Omnibar = forwardRef<OmnibarHandle, OmnibarProps>(function Omnibar(
414
414
  return (
415
415
  <div
416
416
  ref={containerRef}
417
- className={clsx('relative w-full', hero ? 'max-w-3xl' : 'max-w-xl', open && hero && 'z-[16]')}
417
+ className={clsx('relative w-full', hero ? 'max-w-3xl' : 'max-w-lg', open && hero && 'z-[16]')}
418
418
  // Open on click even when the field is already focused — onFocus alone
419
419
  // never fires again, so an autofocused hero (Home) wouldn't reveal the
420
420
  // launcher on a click.
@@ -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
@@ -146,8 +161,12 @@ interface WorkloadViewProps {
146
161
  onNavigateToResource?: NavigateToResource
147
162
  onCollapseToDrawer?: () => void
148
163
  expanded?: boolean
164
+ /** false on the outgoing layer during an expand/collapse crossfade (default true) */
165
+ active?: boolean
149
166
  onClose?: () => void
150
- onExpand?: () => void
167
+ onExpand?: (opts?: { yaml?: boolean }) => void
168
+ onExpandIntent?: () => void
169
+ onCancelExpandIntent?: () => void
151
170
  initialTab?: 'detail' | 'yaml'
152
171
  group?: string
153
172
  }
@@ -159,6 +178,10 @@ function useActionsBarProps(kind: string, namespace: string, name: string) {
159
178
  const openWorkloadLogs = useOpenWorkloadLogs()
160
179
  const openNodeTerminal = useOpenNodeTerminal()
161
180
  const { canExec, canViewLogs, canPortForward } = useNamespacedCapabilities(namespace)
181
+ // Live forward when local+RBAC; otherwise (in-cluster/Cloud) still surface the
182
+ // copy-paste kubectl command. The button picks live vs. copy by deployment mode.
183
+ const isLocal = useIsLocalDeployment()
184
+ const showPortForward = canPortForward || !isLocal
162
185
 
163
186
  const deleteMutation = useDeleteResource()
164
187
  const restartWorkloadMutation = useRestartWorkload()
@@ -190,7 +213,7 @@ function useActionsBarProps(kind: string, namespace: string, name: string) {
190
213
  return {
191
214
  canExec,
192
215
  canViewLogs,
193
- canPortForward,
216
+ canPortForward: showPortForward,
194
217
  onOpenTerminal: openTerminal,
195
218
  onOpenLogs: openLogs,
196
219
  onOpenWorkloadLogs: openWorkloadLogs,
@@ -417,6 +440,15 @@ export function WorkloadView({
417
440
  () => (resource?.apiVersion ? apiVersionToGroup(resource.apiVersion) : undefined),
418
441
  [resource?.apiVersion],
419
442
  )
443
+ // Live Operational Issues for this resource. Fetched here (not inside the lead
444
+ // render-prop) so the count also gates `hasOperationalIssues` — which tells the
445
+ // renderers to suppress their own status-derived problems and avoid duplicates.
446
+ // Keyed on the STABLE prop kind+group (same inputs as the resource fetch above),
447
+ // NOT the manifest-derived ones: deriving kind/group from the loaded resource
448
+ // would flip the query key when the manifest arrives, drop liveIssues, and flash
449
+ // the renderer banners. The backend canonicalizes a plural kind via discovery,
450
+ // so passing the route's plural kindProp resolves correctly.
451
+ const { data: liveIssues } = useResourceIssues(kindProp, rest.group, namespace, name)
420
452
  const { onCompareTo, onCompareAcrossClusters, picker: comparePicker } = useCompareLauncher({
421
453
  kind: kindProp,
422
454
  namespace,
@@ -523,6 +555,23 @@ export function WorkloadView({
523
555
  <FluxSourceConsumersSection kind={k} namespace={ns} name={n} />
524
556
  </>
525
557
  )}
558
+ renderOverviewLead={() => (
559
+ <ResourceIssuesSection
560
+ issues={liveIssues}
561
+ onResourceClick={
562
+ rest.onNavigateToResource
563
+ ? (ref) =>
564
+ rest.onNavigateToResource?.({
565
+ kind: kindToPlural(ref.kind),
566
+ namespace: ref.namespace ?? '',
567
+ name: ref.name,
568
+ group: ref.group ?? '',
569
+ })
570
+ : undefined
571
+ }
572
+ />
573
+ )}
574
+ hasOperationalIssues={!!liveIssues?.length}
526
575
  onOpenGitOpsResource={gitopsOwnerQuery.data ? handleOpenGitOpsResource : undefined}
527
576
  resolvedGitOpsOwner={gitopsOwner}
528
577
  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,30 @@
1
+ import { useCallback, useMemo, useRef, type ReactNode } from 'react';
2
+ import { useSearchParams } from 'react-router-dom';
3
+ import { FilterLocationProvider, type FilterLocation } from '@skyhook-io/k8s-ui';
4
+
5
+ // Adapts OSS Radar's react-router search params to the app-agnostic
6
+ // FilterLocation seam that @skyhook-io/k8s-ui's useFilterState reads. Mounted
7
+ // once inside the router; every list view's shared filter state flows through
8
+ // it, keeping the URL the single source of truth. (Radar Hub provides its own
9
+ // bridge over its router — k8s-ui itself never depends on react-router.)
10
+ export function FilterLocationBridge({ children }: { children: ReactNode }) {
11
+ const [searchParams, setSearchParams] = useSearchParams();
12
+
13
+ // React Router's functional updater is NOT state-queued: two updates in one
14
+ // tick can both read the same params and clobber. Advance a ref synchronously
15
+ // so successive filter changes (e.g. toggling two facets fast) compose.
16
+ const latest = useRef(searchParams);
17
+ latest.current = searchParams;
18
+
19
+ const update = useCallback<FilterLocation['update']>(
20
+ (updater, opts) => {
21
+ const next = updater(new URLSearchParams(latest.current));
22
+ latest.current = next;
23
+ setSearchParams(next, opts);
24
+ },
25
+ [setSearchParams],
26
+ );
27
+
28
+ const value = useMemo<FilterLocation>(() => ({ searchParams, update }), [searchParams, update]);
29
+ return <FilterLocationProvider value={value}>{children}</FilterLocationProvider>;
30
+ }
@@ -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
+ }
@@ -3,5 +3,6 @@ export {
3
3
  useRegisterShortcut,
4
4
  useRegisterShortcuts,
5
5
  useActiveShortcuts,
6
+ useSuppressBaseShortcuts,
6
7
  } from '@skyhook-io/k8s-ui'
7
8
  export type { KeyboardShortcut, ShortcutCategory } from '@skyhook-io/k8s-ui'
package/src/index.ts CHANGED
@@ -23,6 +23,21 @@ export { ShortcutHelpOverlay } from './components/ui/ShortcutHelpOverlay';
23
23
  export { ClusterSwitcher } from '@skyhook-io/k8s-ui';
24
24
  export type { ClusterSwitcherProps, ClusterSwitcherItem } from '@skyhook-io/k8s-ui';
25
25
 
26
+ // Shared namespace-scope picker primitive — re-exported so embedders (Radar
27
+ // Hub) can render a namespace filter visually identical to OSS Radar's, driving
28
+ // their own per-cluster scope (Hub via ?namespaces= on the embedded RadarApp).
29
+ export { NamespacePicker } from '@skyhook-io/k8s-ui';
30
+ export type {
31
+ NamespacePickerProps,
32
+ NamespacePickerHandle,
33
+ NamespaceScopeView,
34
+ } from '@skyhook-io/k8s-ui';
35
+
36
+ // Shared bordered shell that groups the cluster + namespace segments into one
37
+ // pill — so Radar Hub's cluster top bar matches OSS Radar's header exactly.
38
+ export { ScopePill } from '@skyhook-io/k8s-ui';
39
+ export type { ScopePillProps } from '@skyhook-io/k8s-ui';
40
+
26
41
  // Deep-link builders — so consumers (Radar Hub) construct deep links into a
27
42
  // cluster view without hand-rolling Radar's internal URL format, which drifts
28
43
  // silently when Radar re-routes. `resourcePath` opens the detail drawer for any
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