@skyhook-io/radar-app 1.8.1 → 1.8.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/App.tsx +167 -56
- package/src/RadarApp.tsx +18 -1
- package/src/api/client.ts +173 -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 +802 -44
- package/src/components/helm/HelmView.tsx +85 -16
- package/src/components/helm/ManifestDiffViewer.tsx +15 -4
- package/src/components/helm/OwnedResources.tsx +14 -50
- package/src/components/helm/RevisionHistory.tsx +50 -2
- package/src/components/helm/TrackChartSourceDialog.tsx +141 -0
- package/src/components/helm/ValuesViewer.tsx +41 -11
- package/src/components/home/TrafficSummary.tsx +2 -2
- package/src/components/home/mcpToolCatalog.ts +10 -10
- package/src/components/nav/PrimaryNavRail.tsx +1 -1
- 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/ShortcutHelpOverlay.tsx +1 -1
- package/src/components/ui/UpdateNotification.tsx +5 -10
- package/src/components/ui/command-items.ts +1 -1
- package/src/components/workload/WorkloadView.tsx +52 -7
- package/src/context/ConnectionContext.tsx +29 -2
- 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
|
@@ -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
|
|
@@ -91,7 +91,7 @@ const VIEW_ENTRIES: { view: MainView; label: string; icon: React.ComponentType<{
|
|
|
91
91
|
{ view: 'timeline', label: 'Timeline', icon: Clock, shortcut: 'g l' },
|
|
92
92
|
{ view: 'helm', label: 'Helm', icon: Package, shortcut: 'g m' },
|
|
93
93
|
{ view: 'gitops', label: 'GitOps', icon: GitBranch, shortcut: 'g o' },
|
|
94
|
-
{ view: 'traffic', label: 'Traffic', icon: Activity, shortcut: 'g f' },
|
|
94
|
+
{ view: 'traffic', label: 'Live Traffic', icon: Activity, shortcut: 'g f' },
|
|
95
95
|
{ view: 'checks', label: 'Checks', icon: ShieldCheck, shortcut: 'g u' },
|
|
96
96
|
{ view: 'cost', label: 'Cost', icon: DollarSign, shortcut: 'g c' },
|
|
97
97
|
]
|
|
@@ -35,11 +35,11 @@ import { PrometheusCharts, isPrometheusSupported } from '../resource/PrometheusC
|
|
|
35
35
|
import { PrometheusChartsGrid } from '../resource/PrometheusChartsGrid'
|
|
36
36
|
import { RestartEventLane } from '../resource/RestartChart'
|
|
37
37
|
import { RightsizingStrip } from '../resource/RightsizingStrip'
|
|
38
|
-
import { useResourceAudit, useResources } from '../../api/client'
|
|
39
|
-
import { AuditAlerts } from '@skyhook-io/k8s-ui'
|
|
38
|
+
import { useResourceAudit, useResourceIssues, useResources } from '../../api/client'
|
|
39
|
+
import { AuditAlerts, ResourceIssuesSection } from '@skyhook-io/k8s-ui'
|
|
40
40
|
import { WorkloadLogsViewer } from '../logs/WorkloadLogsViewer'
|
|
41
41
|
import { LogsViewer } from '../logs/LogsViewer'
|
|
42
|
-
import { useCanUpdateSecrets, useCanNodeWrite, useNamespacedCapabilities } from '../../contexts/CapabilitiesContext'
|
|
42
|
+
import { useCanUpdateSecrets, useCanNodeWrite, useNamespacedCapabilities, useIsLocalDeployment } from '../../contexts/CapabilitiesContext'
|
|
43
43
|
import { useOpenTerminal, useOpenLogs, useOpenWorkloadLogs, useOpenNodeTerminal } from '../dock'
|
|
44
44
|
import { PortForwardButton } from '../portforward/PortForwardButton'
|
|
45
45
|
import { useToast } from '../ui/Toast'
|
|
@@ -90,13 +90,27 @@ export function WorkloadViewRoute({ onNavigateToResource }: WorkloadViewRoutePro
|
|
|
90
90
|
// Parse /workload/:kind/:ns/:name from pathname. Segments are URL-encoded by
|
|
91
91
|
// buildWorkloadPath; names can also contain literal slashes (e.g. some CRD names),
|
|
92
92
|
// which survive encoding as %2F and reassemble correctly here.
|
|
93
|
+
//
|
|
94
|
+
// Cluster-scoped resources (Node, PersistentVolume, Namespace, …) have no
|
|
95
|
+
// namespace: buildWorkloadPath encodes the namespace segment as '_'. Decode
|
|
96
|
+
// that back to '' here, and tolerate a legacy empty segment ('//') and the
|
|
97
|
+
// collapsed three-segment form (/workload/:kind/:name) for older links.
|
|
93
98
|
const parts = location.pathname.replace(/^\//, '').split('/')
|
|
94
99
|
const decode = (s: string): string => {
|
|
95
100
|
try { return decodeURIComponent(s) } catch { return s }
|
|
96
101
|
}
|
|
97
102
|
const kind = decode(parts[1] ?? '')
|
|
98
|
-
|
|
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}
|
|
@@ -62,6 +62,22 @@ export function ConnectionProvider({ children }: { children: ReactNode }) {
|
|
|
62
62
|
// Track if SSE has started delivering connection_state events
|
|
63
63
|
// Once SSE is active, it becomes the authoritative source for connection state
|
|
64
64
|
const sseActiveRef = useRef(false)
|
|
65
|
+
// Track whether we've reached 'connected' at least once. Distinguishes the
|
|
66
|
+
// initial connect (bootstrap queries already fetched while 'connecting') from
|
|
67
|
+
// a reconnect after a drop (cache may be stale across the gap).
|
|
68
|
+
const hasConnectedRef = useRef(false)
|
|
69
|
+
// Whether the QueryClient already held data when this provider mounted. A host
|
|
70
|
+
// can share one client across cluster-scoped RadarApp mounts (see RadarApp's
|
|
71
|
+
// `queryClient` prop); that client may carry another cluster's data under
|
|
72
|
+
// identical keys, so a warm-at-mount cache must be fully refreshed on first
|
|
73
|
+
// connect. A cold cache (standalone, or a per-cluster remount) takes the cheap
|
|
74
|
+
// error-only path. Snapshot synchronously before this provider's own query
|
|
75
|
+
// registers — ConnectionProvider is the outermost provider, so a fresh client
|
|
76
|
+
// is genuinely empty here.
|
|
77
|
+
const cacheWarmAtMountRef = useRef<boolean | null>(null)
|
|
78
|
+
if (cacheWarmAtMountRef.current === null) {
|
|
79
|
+
cacheWarmAtMountRef.current = queryClient.getQueryCache().getAll().length > 0
|
|
80
|
+
}
|
|
65
81
|
|
|
66
82
|
// Fetch initial connection status
|
|
67
83
|
// Poll while connecting to get progress updates (SSE not established yet)
|
|
@@ -143,9 +159,20 @@ export function ConnectionProvider({ children }: { children: ReactNode }) {
|
|
|
143
159
|
return status
|
|
144
160
|
})
|
|
145
161
|
|
|
146
|
-
// If we just connected, invalidate queries to fetch fresh data
|
|
147
162
|
if (status.state === 'connected') {
|
|
148
|
-
|
|
163
|
+
const firstConnect = !hasConnectedRef.current
|
|
164
|
+
hasConnectedRef.current = true
|
|
165
|
+
// A reconnect after a drop (cache stale across the gap), or a first connect
|
|
166
|
+
// onto a client that already carried data at mount (shared across clusters),
|
|
167
|
+
// refreshes the whole cache. A clean first connect only needs to recover the
|
|
168
|
+
// bootstrap queries that 503'd while the cluster was still 'connecting'
|
|
169
|
+
// (status === 'error'); the rest already fetched fresh during 'connecting',
|
|
170
|
+
// so re-fetching the whole cache there would double-load every endpoint.
|
|
171
|
+
if (!firstConnect || cacheWarmAtMountRef.current) {
|
|
172
|
+
queryClient.invalidateQueries()
|
|
173
|
+
} else {
|
|
174
|
+
queryClient.invalidateQueries({ predicate: (q) => q.state.status === 'error' })
|
|
175
|
+
}
|
|
149
176
|
}
|
|
150
177
|
}, [queryClient])
|
|
151
178
|
|
|
@@ -91,6 +91,14 @@ export function useCanPortForward(): boolean {
|
|
|
91
91
|
return useContext(CapabilitiesContext).portForward
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
// True when Radar runs as a local binary (live port-forward is possible). When
|
|
95
|
+
// false (in-cluster / Radar Cloud) a live forward can't bind a usable local
|
|
96
|
+
// listener, so the UI offers a copy-paste `kubectl port-forward` command instead.
|
|
97
|
+
// Defaults to local during the capabilities-loading window (see defaultCapabilities).
|
|
98
|
+
export function useIsLocalDeployment(): boolean {
|
|
99
|
+
return useContext(CapabilitiesContext).deployment?.mode === 'local'
|
|
100
|
+
}
|
|
101
|
+
|
|
94
102
|
export function useCanViewSecrets(): boolean {
|
|
95
103
|
return useContext(CapabilitiesContext).secrets
|
|
96
104
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
|
|
3
|
+
// Restore-on-unmount matters for overlay/detail views: closing a resource drawer
|
|
4
|
+
// that opened over a list returns the list's title rather than stranding the
|
|
5
|
+
// resource's. document.title is global to the page, so embedders that don't own
|
|
6
|
+
// the whole tab pass a falsy `label` to opt out and keep their own title
|
|
7
|
+
// (AppInner only feeds a label when the host passed `manageDocumentTitle`).
|
|
8
|
+
//
|
|
9
|
+
// `suffix` is the full trailing string after the label, so a host can rebrand
|
|
10
|
+
// (' — My Cloud') or drop it entirely ('').
|
|
11
|
+
const DEFAULT_SUFFIX = ' · Radar';
|
|
12
|
+
|
|
13
|
+
export function useDocumentTitle(
|
|
14
|
+
label: string | null | undefined,
|
|
15
|
+
suffix: string = DEFAULT_SUFFIX,
|
|
16
|
+
): void {
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (!label) return;
|
|
19
|
+
const previous = document.title;
|
|
20
|
+
document.title = `${label}${suffix}`;
|
|
21
|
+
return () => {
|
|
22
|
+
document.title = previous;
|
|
23
|
+
};
|
|
24
|
+
}, [label, suffix]);
|
|
25
|
+
}
|
package/src/main.tsx
CHANGED
|
@@ -155,10 +155,12 @@ window.addEventListener('mouseup', (e: MouseEvent) => {
|
|
|
155
155
|
}, true)
|
|
156
156
|
|
|
157
157
|
|
|
158
|
-
// Standalone Radar binary: same-origin API, router at root.
|
|
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
|