@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.
- package/package.json +1 -1
- package/src/App.tsx +164 -54
- package/src/RadarApp.tsx +18 -1
- package/src/api/client.ts +112 -6
- package/src/components/NamespaceSwitcher.tsx +52 -30
- package/src/components/curl/ServiceCurlButton.tsx +445 -0
- package/src/components/gitops/GitOpsView.tsx +1 -10
- package/src/components/helm/HelmReleaseDrawer.tsx +575 -31
- package/src/components/helm/ManifestDiffViewer.tsx +15 -4
- 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/mcpToolCatalog.ts +8 -8
- package/src/components/portforward/PortForwardButton.tsx +69 -25
- package/src/components/portforward/PortForwardManager.tsx +18 -4
- package/src/components/resources/ResourcesView.tsx +42 -1
- package/src/components/resources/renderers/PodRenderer.tsx +7 -2
- package/src/components/resources/renderers/ServiceRenderer.tsx +54 -8
- package/src/components/ui/UpdateNotification.tsx +5 -10
- package/src/components/workload/WorkloadView.tsx +52 -7
- package/src/contexts/CapabilitiesContext.tsx +8 -0
- package/src/hooks/useDocumentTitle.ts +25 -0
- package/src/main.tsx +5 -3
- package/src/utils/auditBadges.ts +53 -0
- package/src/utils/navigation.ts +5 -3
|
@@ -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:
|
|
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={
|
|
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={({
|
|
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
|
}
|
|
@@ -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
|
|
@@ -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.
|
|
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
|