@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.
- package/package.json +5 -5
- package/src/App.tsx +412 -146
- package/src/RadarApp.tsx +21 -1
- package/src/api/client.ts +144 -12
- package/src/components/ConnectionErrorView.tsx +1 -1
- package/src/components/ContextSwitcher.tsx +5 -1
- package/src/components/NamespaceSwitcher.tsx +21 -278
- package/src/components/applications/ApplicationsView.tsx +13 -1
- package/src/components/audit/AuditView.tsx +11 -2
- package/src/components/cost/CostView.tsx +12 -2
- package/src/components/curl/ServiceCurlButton.tsx +445 -0
- package/src/components/gitops/GitOpsView.tsx +23 -17
- package/src/components/helm/HelmCompareRoute.tsx +1342 -0
- package/src/components/helm/HelmReleaseDrawer.tsx +448 -67
- package/src/components/helm/HelmView.tsx +79 -62
- package/src/components/helm/ManifestDiffViewer.tsx +18 -7
- package/src/components/helm/OwnedResources.tsx +14 -50
- package/src/components/helm/RevisionHistory.tsx +9 -5
- package/src/components/helm/ValuesViewer.tsx +41 -11
- package/src/components/home/ClusterHealthCard.tsx +6 -1
- package/src/components/home/HomeView.tsx +12 -1
- package/src/components/home/mcpToolCatalog.ts +8 -8
- package/src/components/issues/IssuesPane.tsx +29 -18
- package/src/components/portforward/PortForwardButton.tsx +69 -25
- package/src/components/portforward/PortForwardManager.tsx +18 -4
- package/src/components/resources/ResourceDetailDrawer.tsx +8 -3
- package/src/components/resources/ResourcesView.tsx +45 -1
- package/src/components/resources/renderers/PodRenderer.tsx +7 -2
- package/src/components/resources/renderers/ServiceRenderer.tsx +54 -8
- package/src/components/timeline/TimelineView.tsx +26 -2
- package/src/components/traffic/TrafficView.tsx +17 -10
- package/src/components/ui/Markdown.tsx +2 -2
- package/src/components/ui/Omnibar.tsx +1 -1
- package/src/components/ui/UpdateNotification.tsx +5 -10
- package/src/components/workload/WorkloadView.tsx +57 -8
- package/src/contexts/CapabilitiesContext.tsx +8 -0
- package/src/filter/FilterLocationBridge.tsx +30 -0
- package/src/hooks/useDocumentTitle.ts +25 -0
- package/src/hooks/useKeyboardShortcuts.tsx +1 -0
- package/src/index.ts +15 -0
- package/src/main.tsx +5 -3
- package/src/utils/auditBadges.ts +53 -0
- 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={({
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
+
}
|
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.
|
|
159
|
-
//
|
|
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
|
+
}
|
package/src/utils/navigation.ts
CHANGED
|
@@ -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
|
-
*
|
|
24
|
-
*
|
|
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
|