@skyhook-io/radar-app 1.1.2 → 1.2.1
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 +2 -1
- package/src/App.tsx +101 -53
- package/src/api/client.ts +50 -2
- package/src/api/quotas.ts +16 -0
- package/src/components/dock/TerminalTab.tsx +1 -1
- package/src/components/home/ClusterHealthCard.tsx +17 -13
- package/src/components/home/MCPSetupDialog.tsx +5 -3
- package/src/components/resources/renderers/NamespaceRenderer.tsx +9 -0
- package/src/components/traffic/TrafficFlowList.tsx +16 -11
- package/src/components/traffic/TrafficGraph.tsx +5 -1
- package/src/components/ui/DiagnosticsOverlay.tsx +126 -8
- package/src/index.ts +9 -0
- package/src/main.tsx +1 -0
- package/src/monaco-deep.d.ts +8 -0
- package/src/monaco-setup.ts +26 -0
- package/src/utils/navigation.ts +27 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyhook-io/radar-app",
|
|
3
|
-
"version": "1.1
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"description": "Radar's full web UI as a reusable React component. Used by Radar's own binary and by external consumers like Radar Cloud.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"@fontsource/dm-mono": "^5.2.7",
|
|
32
32
|
"@monaco-editor/react": "^4.7.0",
|
|
33
33
|
"diff": "^9.0.0",
|
|
34
|
+
"monaco-editor": "^0.55.1",
|
|
34
35
|
"react-markdown": "^10.1.0",
|
|
35
36
|
"react-virtuoso": "^4.18.6",
|
|
36
37
|
"remark-gfm": "^4.0.1",
|
package/src/App.tsx
CHANGED
|
@@ -523,58 +523,97 @@ function AppInner() {
|
|
|
523
523
|
// Query client for cache invalidation
|
|
524
524
|
const queryClient = useQueryClient()
|
|
525
525
|
|
|
526
|
-
// SSE-driven cache invalidation
|
|
527
|
-
//
|
|
528
|
-
//
|
|
529
|
-
//
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
526
|
+
// SSE-driven cache invalidation, split into two cadences so constant status
|
|
527
|
+
// churn on large clusters doesn't force the *expensive* queries (big resource
|
|
528
|
+
// lists + dashboard) to refetch every 3s. The core distinction: add/delete
|
|
529
|
+
// changes what rows/counts exist (membership — keep fast); update is mostly
|
|
530
|
+
// status/restart/health noise that can fire constantly on a 10k-pod cluster
|
|
531
|
+
// and shouldn't drag a giant list onto a 3s cadence.
|
|
532
|
+
//
|
|
533
|
+
// FAST (3s): detail drawer for any change (one cheap mounted object), and
|
|
534
|
+
// on add/delete: the list, counts, and dashboard. GitOps + cert keep
|
|
535
|
+
// their existing every-batch behavior — Phase 2 makes GitOps relevance-aware.
|
|
536
|
+
// SLOW (15s): list + dashboard for kinds with update churn. A kind that also
|
|
537
|
+
// had an add/delete in the window gets refreshed by both tiers (an extra
|
|
538
|
+
// refetch per 15s at most) — that's fine and avoids a stale-list bug:
|
|
539
|
+
// deduping by "was structural this window" would wrongly suppress an
|
|
540
|
+
// update that arrived *after* the fast structural flush already ran.
|
|
541
|
+
const fastInvalidationRef = useRef<{
|
|
542
|
+
changedKinds: Set<string> // every changed kind (any op) → detail drawer
|
|
543
|
+
structuralKinds: Set<string> // add/delete kinds → list membership + counts + dashboard
|
|
544
|
+
secretsChanged: boolean
|
|
533
545
|
timer: number | null
|
|
534
|
-
}>({
|
|
546
|
+
}>({ changedKinds: new Set(), structuralKinds: new Set(), secretsChanged: false, timer: null })
|
|
547
|
+
const slowInvalidationRef = useRef<{
|
|
548
|
+
updatedKinds: Set<string> // update-only churn → throttled list + dashboard
|
|
549
|
+
timer: number | null
|
|
550
|
+
}>({ updatedKinds: new Set(), timer: null })
|
|
535
551
|
|
|
536
552
|
const handleK8sEvent = useCallback((event: K8sEvent) => {
|
|
537
553
|
// Skip K8s Event kind — informational, not resource mutations
|
|
538
554
|
if (event.kind === 'Event') return
|
|
539
555
|
|
|
540
|
-
const
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
556
|
+
const kind = kindToPlural(event.kind)
|
|
557
|
+
const structural = event.operation === 'add' || event.operation === 'delete'
|
|
558
|
+
|
|
559
|
+
const fast = fastInvalidationRef.current
|
|
560
|
+
fast.changedKinds.add(kind)
|
|
561
|
+
if (structural) fast.structuralKinds.add(kind)
|
|
562
|
+
if (kind === 'secrets') fast.secretsChanged = true
|
|
563
|
+
|
|
564
|
+
const slow = slowInvalidationRef.current
|
|
565
|
+
if (!structural) slow.updatedKinds.add(kind)
|
|
566
|
+
|
|
567
|
+
// FAST tier — membership-sensitive + cheap, bounded 3s latency.
|
|
568
|
+
if (fast.timer === null) {
|
|
569
|
+
fast.timer = window.setTimeout(() => {
|
|
570
|
+
const f = fastInvalidationRef.current
|
|
571
|
+
for (const k of f.changedKinds) {
|
|
572
|
+
queryClient.invalidateQueries({ queryKey: ['resource', k] }) // open detail drawer stays live
|
|
573
|
+
}
|
|
574
|
+
for (const k of f.structuralKinds) {
|
|
575
|
+
queryClient.invalidateQueries({ queryKey: ['resources', k] }) // list membership changed
|
|
576
|
+
}
|
|
577
|
+
if (f.structuralKinds.size > 0) {
|
|
578
|
+
queryClient.invalidateQueries({ queryKey: ['resource-counts'] })
|
|
579
|
+
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
|
580
|
+
}
|
|
581
|
+
if (f.secretsChanged) {
|
|
582
|
+
queryClient.invalidateQueries({ queryKey: ['secret-cert-expiry'] })
|
|
583
|
+
}
|
|
584
|
+
// GitOps behavior unchanged from before — refreshes every batch when a
|
|
585
|
+
// GitOps view is mounted (Phase 2 will make this relevance-aware).
|
|
586
|
+
queryClient.invalidateQueries({ queryKey: ['gitops-tree'] })
|
|
587
|
+
queryClient.invalidateQueries({ queryKey: ['gitops-insights'] })
|
|
588
|
+
fastInvalidationRef.current = { changedKinds: new Set(), structuralKinds: new Set(), secretsChanged: false, timer: null }
|
|
589
|
+
}, 3000)
|
|
544
590
|
}
|
|
545
591
|
|
|
546
|
-
//
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
if (pending.kinds.has('secrets')) {
|
|
559
|
-
queryClient.invalidateQueries({ queryKey: ['secret-cert-expiry'] })
|
|
560
|
-
}
|
|
561
|
-
// GitOps tree + insights are derived views over the same informer
|
|
562
|
-
// cache that produced this SSE event — when *anything* changes, the
|
|
563
|
-
// managed-resource tree and the insights pipeline can have stale
|
|
564
|
-
// changes/events/drift. Invalidating broadly here is cheap (only the
|
|
565
|
-
// currently-mounted GitOps view re-fetches; other views have no
|
|
566
|
-
// matching keys) and is what makes the detail page actually live.
|
|
567
|
-
// Without this the failure card + topology lag behind the title chips
|
|
568
|
-
// until window focus or a manual refresh.
|
|
569
|
-
queryClient.invalidateQueries({ queryKey: ['gitops-tree'] })
|
|
570
|
-
queryClient.invalidateQueries({ queryKey: ['gitops-insights'] })
|
|
571
|
-
// Reset accumulator
|
|
572
|
-
pending.kinds = new Set()
|
|
573
|
-
pending.hasCountChange = false
|
|
574
|
-
pending.timer = null
|
|
575
|
-
}, 3000)
|
|
592
|
+
// SLOW tier — throttle the expensive queries for status-only churn. Only
|
|
593
|
+
// updates schedule it; structural changes are fully handled by the fast tier.
|
|
594
|
+
if (!structural && slow.timer === null) {
|
|
595
|
+
slow.timer = window.setTimeout(() => {
|
|
596
|
+
const s = slowInvalidationRef.current
|
|
597
|
+
for (const k of s.updatedKinds) {
|
|
598
|
+
queryClient.invalidateQueries({ queryKey: ['resources', k] })
|
|
599
|
+
}
|
|
600
|
+
queryClient.invalidateQueries({ queryKey: ['dashboard'] }) // health reflects status updates
|
|
601
|
+
slowInvalidationRef.current = { updatedKinds: new Set(), timer: null }
|
|
602
|
+
}, 15000)
|
|
603
|
+
}
|
|
576
604
|
}, [queryClient])
|
|
577
605
|
|
|
606
|
+
// Clear pending invalidation timers on unmount. Reset the refs (not just
|
|
607
|
+
// clearTimeout) so a same-instance remount doesn't inherit a non-null timer
|
|
608
|
+
// id — handleK8sEvent only schedules when timer === null, so a stale id would
|
|
609
|
+
// silently wedge all further SSE-driven invalidation.
|
|
610
|
+
useEffect(() => () => {
|
|
611
|
+
if (fastInvalidationRef.current.timer !== null) clearTimeout(fastInvalidationRef.current.timer)
|
|
612
|
+
if (slowInvalidationRef.current.timer !== null) clearTimeout(slowInvalidationRef.current.timer)
|
|
613
|
+
fastInvalidationRef.current = { changedKinds: new Set(), structuralKinds: new Set(), secretsChanged: false, timer: null }
|
|
614
|
+
slowInvalidationRef.current = { updatedKinds: new Set(), timer: null }
|
|
615
|
+
}, [])
|
|
616
|
+
|
|
578
617
|
// SSE connection for real-time updates — no namespace filter for small/medium clusters (frontend filters).
|
|
579
618
|
// forceNamespaceFilter is only set for large clusters that require server-side filtering.
|
|
580
619
|
// Fleet mode uses 'resources' topology on the backend — filtering is client-side
|
|
@@ -590,10 +629,10 @@ function AppInner() {
|
|
|
590
629
|
queryClient.invalidateQueries()
|
|
591
630
|
|
|
592
631
|
// Cancel any pending SSE-driven invalidation — old cluster's events are irrelevant
|
|
593
|
-
if (
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
}
|
|
632
|
+
if (fastInvalidationRef.current.timer !== null) clearTimeout(fastInvalidationRef.current.timer)
|
|
633
|
+
if (slowInvalidationRef.current.timer !== null) clearTimeout(slowInvalidationRef.current.timer)
|
|
634
|
+
fastInvalidationRef.current = { changedKinds: new Set(), structuralKinds: new Set(), secretsChanged: false, timer: null }
|
|
635
|
+
slowInvalidationRef.current = { updatedKinds: new Set(), timer: null }
|
|
597
636
|
|
|
598
637
|
// Close any open drawers/overlays — old cluster's resources don't exist on the new one
|
|
599
638
|
setSelectedResource(null)
|
|
@@ -909,22 +948,30 @@ function AppInner() {
|
|
|
909
948
|
const navigatingToHelm = mainView === 'helm' && prevMainView.current !== 'helm'
|
|
910
949
|
prevMainView.current = mainView
|
|
911
950
|
|
|
912
|
-
//
|
|
913
|
-
|
|
951
|
+
// The URL is the source of truth for what's selected. A deep link
|
|
952
|
+
// (?resource=, ?release=) seeds the selection on mount; the effects that
|
|
953
|
+
// run during that same mount must not wipe a selection the URL still
|
|
954
|
+
// asserts. (On a real view switch the URL no longer carries the param, so
|
|
955
|
+
// the clear proceeds.) Without this, deep-linking straight to a Helm
|
|
956
|
+
// release lands on the release list with no drawer.
|
|
957
|
+
const params = new URLSearchParams(window.location.search)
|
|
958
|
+
if (!navigatingToResources && !params.has('resource')) {
|
|
914
959
|
setSelectedResource(null)
|
|
915
960
|
}
|
|
916
|
-
|
|
917
|
-
if (!navigatingToHelm) {
|
|
961
|
+
if (!navigatingToHelm && !params.has('release')) {
|
|
918
962
|
setSelectedHelmRelease(null)
|
|
919
963
|
}
|
|
920
964
|
setDrawerExpanded(false)
|
|
921
965
|
}, [mainView])
|
|
922
966
|
|
|
923
|
-
// Clear resource selection when namespaces change
|
|
967
|
+
// Clear resource selection when namespaces change — but keep a selection the
|
|
968
|
+
// URL still asserts (deep link, or a release/resource the user is viewing
|
|
969
|
+
// while they adjust the namespace scope filter).
|
|
924
970
|
useEffect(() => {
|
|
925
|
-
|
|
971
|
+
const params = new URLSearchParams(window.location.search)
|
|
972
|
+
if (!params.has('resource')) setSelectedResource(null)
|
|
973
|
+
if (!params.has('release')) setSelectedHelmRelease(null)
|
|
926
974
|
setDrawerExpanded(false)
|
|
927
|
-
setSelectedHelmRelease(null)
|
|
928
975
|
}, [namespacesKey])
|
|
929
976
|
|
|
930
977
|
// Filter topology based on visible kinds (uses displayedTopology which respects pause)
|
|
@@ -957,6 +1004,7 @@ function AppInner() {
|
|
|
957
1004
|
})
|
|
958
1005
|
|
|
959
1006
|
return {
|
|
1007
|
+
...displayedTopology,
|
|
960
1008
|
nodes: filteredNodes,
|
|
961
1009
|
edges: filteredEdges,
|
|
962
1010
|
}
|
package/src/api/client.ts
CHANGED
|
@@ -140,6 +140,9 @@ export interface WorkloadCount {
|
|
|
140
140
|
export interface DashboardMetrics {
|
|
141
141
|
cpu?: MetricSummary
|
|
142
142
|
memory?: MetricSummary
|
|
143
|
+
// When false, only requests/capacity are meaningful — live usage (from
|
|
144
|
+
// metrics-server) is unavailable and usage fields are zero.
|
|
145
|
+
usageAvailable: boolean
|
|
143
146
|
}
|
|
144
147
|
|
|
145
148
|
export interface MetricSummary {
|
|
@@ -1606,8 +1609,24 @@ export function useUpdateResource() {
|
|
|
1606
1609
|
errorMessage: 'Failed to update resource',
|
|
1607
1610
|
successMessage: 'Resource updated',
|
|
1608
1611
|
},
|
|
1609
|
-
onSuccess: (
|
|
1610
|
-
|
|
1612
|
+
onSuccess: (updated: any, variables) => {
|
|
1613
|
+
// The PUT goes straight to the apiserver and returns the authoritative
|
|
1614
|
+
// object, but the GET behind this query reads Radar's informer cache,
|
|
1615
|
+
// which lags a write by one watch round-trip. Seed the detail cache with
|
|
1616
|
+
// the PUT response so the edit shows immediately. Invalidating here
|
|
1617
|
+
// instead would trigger a refetch that races the seed and re-reads the
|
|
1618
|
+
// lagging cache — the change appears not to have taken effect.
|
|
1619
|
+
if (updated && typeof updated === 'object' && updated.metadata) {
|
|
1620
|
+
queryClient.setQueriesData(
|
|
1621
|
+
{ queryKey: ['resource', variables.kind, variables.namespace, variables.name] },
|
|
1622
|
+
(old: any) =>
|
|
1623
|
+
old && typeof old === 'object' && 'resource' in old
|
|
1624
|
+
? { ...old, resource: updated }
|
|
1625
|
+
: { resource: updated }
|
|
1626
|
+
)
|
|
1627
|
+
} else {
|
|
1628
|
+
queryClient.invalidateQueries({ queryKey: ['resource', variables.kind, variables.namespace, variables.name] })
|
|
1629
|
+
}
|
|
1611
1630
|
queryClient.invalidateQueries({ queryKey: ['resources', variables.kind] })
|
|
1612
1631
|
queryClient.invalidateQueries({ queryKey: ['topology'] })
|
|
1613
1632
|
},
|
|
@@ -3026,6 +3045,9 @@ export interface DiagInformerSyncStatus {
|
|
|
3026
3045
|
synced: boolean
|
|
3027
3046
|
syncedAt?: string
|
|
3028
3047
|
items: number
|
|
3048
|
+
lastError?: string
|
|
3049
|
+
lastErrorAt?: string
|
|
3050
|
+
forbiddenSeen?: boolean
|
|
3029
3051
|
}
|
|
3030
3052
|
|
|
3031
3053
|
export interface DiagCacheSyncStatus {
|
|
@@ -3042,6 +3064,31 @@ export interface DiagCacheSyncStatus {
|
|
|
3042
3064
|
promotedKinds?: string[]
|
|
3043
3065
|
}
|
|
3044
3066
|
|
|
3067
|
+
export interface DiagSampleWindow {
|
|
3068
|
+
count: number
|
|
3069
|
+
last: number
|
|
3070
|
+
min: number
|
|
3071
|
+
p50: number
|
|
3072
|
+
p95: number
|
|
3073
|
+
p99: number
|
|
3074
|
+
max: number
|
|
3075
|
+
}
|
|
3076
|
+
|
|
3077
|
+
export interface DiagPerfSnapshot {
|
|
3078
|
+
topology: {
|
|
3079
|
+
totalBuilds: number
|
|
3080
|
+
durationUs: DiagSampleWindow
|
|
3081
|
+
nodeCount: DiagSampleWindow
|
|
3082
|
+
edgeCount: DiagSampleWindow
|
|
3083
|
+
payloadBytes: DiagSampleWindow
|
|
3084
|
+
estimatedNodes: DiagSampleWindow
|
|
3085
|
+
}
|
|
3086
|
+
sse: {
|
|
3087
|
+
totalBroadcasts: number
|
|
3088
|
+
totalDrops: number
|
|
3089
|
+
}
|
|
3090
|
+
}
|
|
3091
|
+
|
|
3045
3092
|
export interface DiagnosticsSnapshot {
|
|
3046
3093
|
timestamp: string
|
|
3047
3094
|
radarVersion: string
|
|
@@ -3136,6 +3183,7 @@ export interface DiagnosticsSnapshot {
|
|
|
3136
3183
|
sse?: {
|
|
3137
3184
|
connectedClients: number
|
|
3138
3185
|
}
|
|
3186
|
+
perf?: DiagPerfSnapshot
|
|
3139
3187
|
runtime?: {
|
|
3140
3188
|
heapMB: number
|
|
3141
3189
|
heapObjectsK: number
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { useQuery } from '@tanstack/react-query'
|
|
2
|
+
import { fetchJSON } from './client'
|
|
3
|
+
|
|
4
|
+
// useNamespaceQuotas fetches a namespace's ResourceQuota objects via
|
|
5
|
+
// /api/resources/resourcequotas?namespace=<ns> (a bare array). Backs the
|
|
6
|
+
// NamespaceRenderer quota-usage section — quota saturation is otherwise
|
|
7
|
+
// surfaced nowhere in the UI, yet it's exactly why a namespace stops
|
|
8
|
+
// admitting new pods.
|
|
9
|
+
export function useNamespaceQuotas(namespace: string, enabled = true) {
|
|
10
|
+
return useQuery<any[]>({
|
|
11
|
+
queryKey: ['resourcequotas', namespace],
|
|
12
|
+
queryFn: () => fetchJSON<any[]>(`/resources/resourcequotas?namespace=${encodeURIComponent(namespace)}`),
|
|
13
|
+
enabled: enabled && !!namespace,
|
|
14
|
+
staleTime: 15000,
|
|
15
|
+
})
|
|
16
|
+
}
|
|
@@ -19,7 +19,7 @@ export function TerminalTab({ namespace, podName, containerName, containers, isA
|
|
|
19
19
|
const response = await fetch(apiUrl(`/pods/${namespace}/${podName}/debug`), {
|
|
20
20
|
method: 'POST',
|
|
21
21
|
headers: { 'Content-Type': 'application/json' },
|
|
22
|
-
body: JSON.stringify({ targetContainer
|
|
22
|
+
body: JSON.stringify({ targetContainer }),
|
|
23
23
|
})
|
|
24
24
|
if (!response.ok) {
|
|
25
25
|
const err = await response.json().catch(() => ({ error: 'Unknown error' }))
|
|
@@ -389,12 +389,14 @@ export function ClusterHealthCard({
|
|
|
389
389
|
<Cpu className="w-3.5 h-3.5 text-theme-text-tertiary" />
|
|
390
390
|
CPU
|
|
391
391
|
</div>
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
392
|
+
{metricsServerAvailable && (
|
|
393
|
+
<ResourceBar
|
|
394
|
+
label="Used"
|
|
395
|
+
used={formatCPUMillicores(metrics.cpu.usageMillis)}
|
|
396
|
+
total={formatCPUMillicores(metrics.cpu.capacityMillis)}
|
|
397
|
+
percent={metrics.cpu.usagePercent}
|
|
398
|
+
/>
|
|
399
|
+
)}
|
|
398
400
|
<ResourceBar
|
|
399
401
|
label="Requested"
|
|
400
402
|
used={formatCPUMillicores(metrics.cpu.requestsMillis)}
|
|
@@ -409,12 +411,14 @@ export function ClusterHealthCard({
|
|
|
409
411
|
<MemoryStick className="w-3.5 h-3.5 text-theme-text-tertiary" />
|
|
410
412
|
Memory
|
|
411
413
|
</div>
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
414
|
+
{metricsServerAvailable && (
|
|
415
|
+
<ResourceBar
|
|
416
|
+
label="Used"
|
|
417
|
+
used={formatMemoryMiB(metrics.memory.usageMillis)}
|
|
418
|
+
total={formatMemoryMiB(metrics.memory.capacityMillis)}
|
|
419
|
+
percent={metrics.memory.usagePercent}
|
|
420
|
+
/>
|
|
421
|
+
)}
|
|
418
422
|
<ResourceBar
|
|
419
423
|
label="Requested"
|
|
420
424
|
used={formatMemoryMiB(metrics.memory.requestsMillis)}
|
|
@@ -423,7 +427,7 @@ export function ClusterHealthCard({
|
|
|
423
427
|
/>
|
|
424
428
|
</div>
|
|
425
429
|
)}
|
|
426
|
-
{!
|
|
430
|
+
{!metricsServerAvailable && (
|
|
427
431
|
<MetricsUnavailableHint platform={cluster.platform} metricsServerAvailable={metricsServerAvailable} />
|
|
428
432
|
)}
|
|
429
433
|
</div>
|
|
@@ -306,11 +306,13 @@ export function MCPSetupDialog({ open, onClose, mcpUrl }: MCPSetupDialogProps) {
|
|
|
306
306
|
{ name: 'kind', required: true, desc: 'resource kind, e.g. pods, deployments, services' },
|
|
307
307
|
{ name: 'namespace', required: false, desc: 'filter to a specific namespace' },
|
|
308
308
|
]},
|
|
309
|
-
{ name: 'get_resource', desc: 'Get
|
|
309
|
+
{ name: 'get_resource', desc: 'Get a single Kubernetes resource: minified spec/status/metadata plus default-on resourceContext (managedBy, exposes, selectedBy, uses, runsOn, issue/audit rollups). Optionally include heavier sidecars (events, metrics, logs).', params: [
|
|
310
310
|
{ name: 'kind', required: true, desc: 'resource kind, e.g. pod, deployment, service' },
|
|
311
|
-
{ name: 'namespace', required:
|
|
311
|
+
{ name: 'namespace', required: false, desc: 'omit for cluster-scoped kinds (Node, ClusterRole, IngressClass, etc.)' },
|
|
312
312
|
{ name: 'name', required: true, desc: 'resource name' },
|
|
313
|
-
{ name: '
|
|
313
|
+
{ name: 'group', required: false, desc: 'API group when the kind is ambiguous (e.g. serving.knative.dev for Knative Service vs core Service)' },
|
|
314
|
+
{ name: 'include', required: false, desc: 'events, metrics, logs' },
|
|
315
|
+
{ name: 'context', required: false, desc: 'resourceContext tier: basic (default) or none (bare minified)' },
|
|
314
316
|
]},
|
|
315
317
|
{ name: 'get_topology', desc: 'Get the topology graph showing relationships between Kubernetes resources. Returns nodes and edges representing Deployments, Services, Ingresses, Pods, etc. Use \'traffic\' view for network flow or \'resources\' view for ownership hierarchy. Use \'summary\' format for LLM-friendly text descriptions.', params: [
|
|
316
318
|
{ name: 'namespace', required: false, desc: 'filter to a specific namespace' },
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { NamespaceRenderer as BaseNamespaceRenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/NamespaceRenderer'
|
|
2
2
|
import type { ResourceRef } from '@skyhook-io/k8s-ui'
|
|
3
3
|
import { useRBACNamespace } from '../../../api/rbac'
|
|
4
|
+
import { useNamespaceQuotas } from '../../../api/quotas'
|
|
5
|
+
import { isForbiddenError } from '../../../api/client'
|
|
4
6
|
|
|
5
7
|
interface NamespaceRendererProps {
|
|
6
8
|
data: any
|
|
@@ -10,12 +12,19 @@ interface NamespaceRendererProps {
|
|
|
10
12
|
export function NamespaceRenderer({ data, onNavigate }: NamespaceRendererProps) {
|
|
11
13
|
const name = data?.metadata?.name ?? ''
|
|
12
14
|
const { data: rbacData, isLoading, error } = useRBACNamespace(name, !!name)
|
|
15
|
+
const { data: quotaData, error: quotaError } = useNamespaceQuotas(name, !!name)
|
|
16
|
+
// 403 → the user can't see quotas; hide the section (same posture as the
|
|
17
|
+
// RBAC sections). Surface other errors (500/503) so a quota-constrained
|
|
18
|
+
// namespace doesn't silently render as quota-free.
|
|
19
|
+
const quotaErr = quotaError && !isForbiddenError(quotaError) ? (quotaError as Error) : null
|
|
13
20
|
return (
|
|
14
21
|
<BaseNamespaceRenderer
|
|
15
22
|
data={data}
|
|
16
23
|
rbacData={rbacData ?? null}
|
|
17
24
|
rbacLoading={isLoading}
|
|
18
25
|
rbacError={error as Error | null}
|
|
26
|
+
quotaData={quotaData}
|
|
27
|
+
quotaError={quotaErr}
|
|
19
28
|
onNavigate={onNavigate}
|
|
20
29
|
/>
|
|
21
30
|
)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useState, useMemo } from 'react'
|
|
2
|
+
import { Virtuoso } from 'react-virtuoso'
|
|
2
3
|
import type { TrafficFlow } from '../../types'
|
|
3
4
|
import { clsx } from 'clsx'
|
|
4
5
|
import { ChevronDown, ChevronUp, ShieldCheck } from 'lucide-react'
|
|
@@ -141,14 +142,18 @@ export function TrafficFlowList({ flows }: TrafficFlowListProps) {
|
|
|
141
142
|
<span className="text-right">Verdict</span>
|
|
142
143
|
</div>
|
|
143
144
|
|
|
144
|
-
{/* Flow rows
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
145
|
+
{/* Flow rows — virtualized so tens of thousands of Hubble/Cilium flows
|
|
146
|
+
don't all become DOM. Virtuoso measures variable row heights, so the
|
|
147
|
+
expand/collapse panel still works. */}
|
|
148
|
+
{sorted.length === 0 ? (
|
|
149
|
+
<div className="flex-1 flex items-center justify-center text-sm text-theme-text-tertiary">
|
|
150
|
+
{search ? 'No flows match the search' : 'No flows to display'}
|
|
151
|
+
</div>
|
|
152
|
+
) : (
|
|
153
|
+
<Virtuoso
|
|
154
|
+
className="flex-1"
|
|
155
|
+
data={sorted}
|
|
156
|
+
itemContent={(i, flow) => {
|
|
152
157
|
const isExpanded = expandedIdx === i
|
|
153
158
|
const isHTTP = flow.l7Protocol === 'HTTP'
|
|
154
159
|
const isDNS = flow.l7Protocol === 'DNS'
|
|
@@ -316,9 +321,9 @@ export function TrafficFlowList({ flows }: TrafficFlowListProps) {
|
|
|
316
321
|
)}
|
|
317
322
|
</div>
|
|
318
323
|
)
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
|
|
324
|
+
}}
|
|
325
|
+
/>
|
|
326
|
+
)}
|
|
322
327
|
|
|
323
328
|
{/* Footer */}
|
|
324
329
|
<div className="px-3 py-1.5 border-t border-theme-border text-[10px] text-theme-text-tertiary">
|
|
@@ -1264,9 +1264,13 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
|
|
|
1264
1264
|
try {
|
|
1265
1265
|
const layoutResult = await elk.layout(elkGraph)
|
|
1266
1266
|
|
|
1267
|
+
// Index ELK's positioned children by id once — a .find() per node here is
|
|
1268
|
+
// O(nodes²) and bites on dense traffic graphs.
|
|
1269
|
+
const elkPositions = new Map((layoutResult.children ?? []).map(n => [n.id, n]))
|
|
1270
|
+
|
|
1267
1271
|
// Apply positions from ELK to nodes
|
|
1268
1272
|
let positionedNodes = rawNodes.map(node => {
|
|
1269
|
-
const elkNode =
|
|
1273
|
+
const elkNode = elkPositions.get(node.id)
|
|
1270
1274
|
return {
|
|
1271
1275
|
...node,
|
|
1272
1276
|
position: {
|
|
@@ -4,7 +4,8 @@ import { clsx } from 'clsx'
|
|
|
4
4
|
import { TRANSITION_BACKDROP, TRANSITION_PANEL } from '../../utils/animation'
|
|
5
5
|
import { openExternal } from '../../utils/navigation'
|
|
6
6
|
import { useDiagnostics } from '../../api/client'
|
|
7
|
-
import type { DiagnosticsSnapshot, DiagMetricsSourceHealth, DiagDropRecord, DiagErrorEntry, DiagCacheSyncStatus, DiagInformerSyncStatus, DiagSyncPhase } from '../../api/client'
|
|
7
|
+
import type { DiagnosticsSnapshot, DiagMetricsSourceHealth, DiagDropRecord, DiagErrorEntry, DiagCacheSyncStatus, DiagInformerSyncStatus, DiagSyncPhase, DiagSampleWindow } from '../../api/client'
|
|
8
|
+
import { getK8sUIPerfSnapshot, type K8sUIPerfSnapshot } from '@skyhook-io/k8s-ui'
|
|
8
9
|
|
|
9
10
|
interface DiagnosticsOverlayProps {
|
|
10
11
|
onClose: () => void
|
|
@@ -31,9 +32,10 @@ export function DiagnosticsOverlay({ onClose, isOpen = true }: DiagnosticsOverla
|
|
|
31
32
|
|
|
32
33
|
const copyToClipboard = useCallback(async (type: 'json' | 'formatted') => {
|
|
33
34
|
if (!data) return
|
|
35
|
+
const frontendPerf = getK8sUIPerfSnapshot()
|
|
34
36
|
const text = type === 'json'
|
|
35
|
-
? JSON.stringify(data, null, 2)
|
|
36
|
-
: formatForGitHub(data)
|
|
37
|
+
? JSON.stringify({ ...data, frontendPerf }, null, 2)
|
|
38
|
+
: formatForGitHub(data, frontendPerf)
|
|
37
39
|
try {
|
|
38
40
|
await navigator.clipboard.writeText(text)
|
|
39
41
|
setCopied(type)
|
|
@@ -46,7 +48,7 @@ export function DiagnosticsOverlay({ onClose, isOpen = true }: DiagnosticsOverla
|
|
|
46
48
|
|
|
47
49
|
const openBugReport = useCallback(() => {
|
|
48
50
|
if (!data) return
|
|
49
|
-
const body = formatForBugReport(data)
|
|
51
|
+
const body = formatForBugReport(data, getK8sUIPerfSnapshot())
|
|
50
52
|
const url = `https://github.com/skyhook-io/radar/issues/new?labels=bug&body=${encodeURIComponent(body)}`
|
|
51
53
|
if (url.length > 8000) {
|
|
52
54
|
// URL too long for GitHub — copy diagnostics to clipboard and open blank issue
|
|
@@ -116,6 +118,7 @@ export function DiagnosticsOverlay({ onClose, isOpen = true }: DiagnosticsOverla
|
|
|
116
118
|
<TrafficSection data={data} />
|
|
117
119
|
<PermissionsSection data={data} />
|
|
118
120
|
<APIDiscoverySection data={data} />
|
|
121
|
+
<PerfSection data={data} />
|
|
119
122
|
<RuntimeSection data={data} />
|
|
120
123
|
<ConfigSection data={data} />
|
|
121
124
|
{data.errors && data.errors.length > 0 && (
|
|
@@ -459,6 +462,73 @@ function APIDiscoverySection({ data }: { data: DiagnosticsSnapshot }) {
|
|
|
459
462
|
)
|
|
460
463
|
}
|
|
461
464
|
|
|
465
|
+
function PerfSection({ data }: { data: DiagnosticsSnapshot }) {
|
|
466
|
+
const backend = data.perf
|
|
467
|
+
const frontend = getK8sUIPerfSnapshot()
|
|
468
|
+
if (!backend && frontend.totalLayouts === 0 && frontend.totalStructureKeyComputes === 0) return null
|
|
469
|
+
// Warn when SSE has dropped frames, the topology payload window's p95 exceeds
|
|
470
|
+
// 5 MB, or the frontend ELK layout p95 exceeds 1s — these are the load-bearing
|
|
471
|
+
// thresholds for "the tab is going to feel bad."
|
|
472
|
+
const warn =
|
|
473
|
+
(backend?.sse.totalDrops ?? 0) > 0 ||
|
|
474
|
+
(backend?.topology.payloadBytes.p95 ?? 0) > 5 * 1024 * 1024 ||
|
|
475
|
+
frontend.layoutMs.p95 > 1000
|
|
476
|
+
return (
|
|
477
|
+
<Section title="Performance" warn={warn}>
|
|
478
|
+
{backend && (
|
|
479
|
+
<>
|
|
480
|
+
<Row label="Topology Builds" value={backend.topology.totalBuilds.toLocaleString()} />
|
|
481
|
+
<Row label=" Duration" value={formatSampleDuration(backend.topology.durationUs)} />
|
|
482
|
+
<Row label=" Node Count" value={formatSampleCount(backend.topology.nodeCount)} />
|
|
483
|
+
<Row label=" Edge Count" value={formatSampleCount(backend.topology.edgeCount)} />
|
|
484
|
+
<Row label=" Payload" value={formatSampleBytes(backend.topology.payloadBytes)} warn={backend.topology.payloadBytes.p95 > 5 * 1024 * 1024} />
|
|
485
|
+
<Row label=" Estimated Nodes" value={formatSampleCount(backend.topology.estimatedNodes)} />
|
|
486
|
+
<Row label="SSE Broadcasts" value={backend.sse.totalBroadcasts.toLocaleString()} />
|
|
487
|
+
<Row label="SSE Drops" value={backend.sse.totalDrops.toLocaleString()} warn={backend.sse.totalDrops > 0} />
|
|
488
|
+
</>
|
|
489
|
+
)}
|
|
490
|
+
{(frontend.totalLayouts > 0 || frontend.totalStructureKeyComputes > 0) && (
|
|
491
|
+
<>
|
|
492
|
+
<Row label="Frontend Layouts" value={`${frontend.totalLayouts.toLocaleString()} (skipped ${frontend.totalLayoutsSkipped.toLocaleString()})`} />
|
|
493
|
+
<Row label=" ELK Duration" value={formatFrontendMs(frontend.layoutMs)} warn={frontend.layoutMs.p95 > 1000} />
|
|
494
|
+
<Row label=" Last Rendered" value={`${frontend.lastLayoutNodeCount.toLocaleString()} nodes / ${frontend.lastLayoutEdgeCount.toLocaleString()} edges`} />
|
|
495
|
+
<Row label="Frontend structureKey" value={`${frontend.totalStructureKeyComputes.toLocaleString()} computes`} />
|
|
496
|
+
<Row label=" Duration" value={formatFrontendUs(frontend.structureKeyUs)} />
|
|
497
|
+
</>
|
|
498
|
+
)}
|
|
499
|
+
</Section>
|
|
500
|
+
)
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function formatSampleDuration(w: DiagSampleWindow): string {
|
|
504
|
+
if (w.count === 0) return 'no samples'
|
|
505
|
+
const ms = (us: number) => (us / 1000).toFixed(us < 1000 ? 2 : 1)
|
|
506
|
+
return `last ${ms(w.last)}ms · p50 ${ms(w.p50)} · p95 ${ms(w.p95)} · max ${ms(w.max)}ms (n=${w.count})`
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function formatSampleCount(w: DiagSampleWindow): string {
|
|
510
|
+
if (w.count === 0) return 'no samples'
|
|
511
|
+
return `last ${w.last.toLocaleString()} · p50 ${w.p50.toLocaleString()} · p95 ${w.p95.toLocaleString()} · max ${w.max.toLocaleString()}`
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function formatSampleBytes(w: DiagSampleWindow): string {
|
|
515
|
+
if (w.count === 0) return 'no samples'
|
|
516
|
+
const kb = (b: number) => b < 1024 * 1024 ? `${(b / 1024).toFixed(1)}KB` : `${(b / 1024 / 1024).toFixed(2)}MB`
|
|
517
|
+
return `last ${kb(w.last)} · p50 ${kb(w.p50)} · p95 ${kb(w.p95)} · max ${kb(w.max)}`
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function formatFrontendMs(w: { count: number; last: number; p50: number; p95: number; max: number }): string {
|
|
521
|
+
if (w.count === 0) return 'no samples'
|
|
522
|
+
const fmt = (v: number) => v < 100 ? v.toFixed(1) : Math.round(v).toString()
|
|
523
|
+
return `last ${fmt(w.last)}ms · p50 ${fmt(w.p50)} · p95 ${fmt(w.p95)} · max ${fmt(w.max)}ms (n=${w.count})`
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function formatFrontendUs(w: { count: number; last: number; p50: number; p95: number; max: number }): string {
|
|
527
|
+
if (w.count === 0) return 'no samples'
|
|
528
|
+
const fmt = (v: number) => v < 1000 ? `${v.toFixed(0)}μs` : `${(v / 1000).toFixed(2)}ms`
|
|
529
|
+
return `last ${fmt(w.last)} · p50 ${fmt(w.p50)} · p95 ${fmt(w.p95)} · max ${fmt(w.max)} (n=${w.count})`
|
|
530
|
+
}
|
|
531
|
+
|
|
462
532
|
function RuntimeSection({ data }: { data: DiagnosticsSnapshot }) {
|
|
463
533
|
if (!data.runtime) return null
|
|
464
534
|
const rt = data.runtime
|
|
@@ -510,7 +580,7 @@ function CopyButton({ label, onClick, copied }: { label: string; onClick: () =>
|
|
|
510
580
|
|
|
511
581
|
// --- GitHub-friendly formatting ---
|
|
512
582
|
|
|
513
|
-
function formatForGitHub(data: DiagnosticsSnapshot, includeRawJson = true): string {
|
|
583
|
+
function formatForGitHub(data: DiagnosticsSnapshot, frontendPerf?: K8sUIPerfSnapshot, includeRawJson = true): string {
|
|
514
584
|
const lines: string[] = []
|
|
515
585
|
lines.push(`## Radar Diagnostics`)
|
|
516
586
|
lines.push(``)
|
|
@@ -600,9 +670,26 @@ function formatForGitHub(data: DiagnosticsSnapshot, includeRawJson = true): stri
|
|
|
600
670
|
}
|
|
601
671
|
const pending = getPendingInformers(sync)
|
|
602
672
|
if (pending.length > 0) {
|
|
603
|
-
const parts = pending.map((i) =>
|
|
673
|
+
const parts = pending.map((i) => {
|
|
674
|
+
const flags = [i.deferred ? 'deferred' : 'critical', `${i.items.toLocaleString()} items`]
|
|
675
|
+
if (i.forbiddenSeen) flags.push('forbidden')
|
|
676
|
+
if (i.lastError) flags.push(`err: ${i.lastError}`)
|
|
677
|
+
return `${i.kind}(${flags.join(', ')})`
|
|
678
|
+
})
|
|
604
679
|
lines.push(`- **Pending:** ${parts.join(', ')}`)
|
|
605
680
|
}
|
|
681
|
+
// Synced informers that have since hit a watch error or 403 — a count of 0
|
|
682
|
+
// from one of these is a stale/forbidden lister, not an empty cluster.
|
|
683
|
+
const errored = sync.informers.filter((i) => !pending.includes(i) && (i.lastError || i.forbiddenSeen))
|
|
684
|
+
if (errored.length > 0) {
|
|
685
|
+
const parts = errored.map((i) => {
|
|
686
|
+
const flags: string[] = []
|
|
687
|
+
if (i.forbiddenSeen) flags.push('forbidden')
|
|
688
|
+
if (i.lastError) flags.push(`err: ${i.lastError}`)
|
|
689
|
+
return `${i.kind}(${flags.join(', ')})`
|
|
690
|
+
})
|
|
691
|
+
lines.push(`- **Informer errors:** ${parts.join(', ')}`)
|
|
692
|
+
}
|
|
606
693
|
}
|
|
607
694
|
if (inf.watchedCRDs && inf.watchedCRDs.length > 0) {
|
|
608
695
|
lines.push(`- CRDs: ${inf.watchedCRDs.join(', ')}`)
|
|
@@ -640,6 +727,37 @@ function formatForGitHub(data: DiagnosticsSnapshot, includeRawJson = true): stri
|
|
|
640
727
|
lines.push(``)
|
|
641
728
|
}
|
|
642
729
|
|
|
730
|
+
if (data.perf || (frontendPerf && (frontendPerf.totalLayouts > 0 || frontendPerf.totalStructureKeyComputes > 0))) {
|
|
731
|
+
lines.push(`### Performance`)
|
|
732
|
+
if (data.perf) {
|
|
733
|
+
const p = data.perf
|
|
734
|
+
const fmtMs = (us: number) => (us / 1000).toFixed(us < 1000 ? 2 : 1)
|
|
735
|
+
const fmtKB = (b: number) => b < 1024 * 1024 ? `${(b / 1024).toFixed(1)}KB` : `${(b / 1024 / 1024).toFixed(2)}MB`
|
|
736
|
+
lines.push(`- Topology Builds: ${p.topology.totalBuilds.toLocaleString()}`)
|
|
737
|
+
if (p.topology.durationUs.count > 0) {
|
|
738
|
+
lines.push(` - Duration (ms): last ${fmtMs(p.topology.durationUs.last)} · p50 ${fmtMs(p.topology.durationUs.p50)} · p95 ${fmtMs(p.topology.durationUs.p95)} · max ${fmtMs(p.topology.durationUs.max)}`)
|
|
739
|
+
lines.push(` - Nodes: last ${p.topology.nodeCount.last} · p95 ${p.topology.nodeCount.p95} · max ${p.topology.nodeCount.max}`)
|
|
740
|
+
lines.push(` - Edges: last ${p.topology.edgeCount.last} · p95 ${p.topology.edgeCount.p95} · max ${p.topology.edgeCount.max}`)
|
|
741
|
+
lines.push(` - Payload: last ${fmtKB(p.topology.payloadBytes.last)} · p95 ${fmtKB(p.topology.payloadBytes.p95)} · max ${fmtKB(p.topology.payloadBytes.max)}`)
|
|
742
|
+
lines.push(` - Estimated Nodes: last ${p.topology.estimatedNodes.last} · p95 ${p.topology.estimatedNodes.p95}`)
|
|
743
|
+
}
|
|
744
|
+
lines.push(`- SSE: ${p.sse.totalBroadcasts.toLocaleString()} broadcasts, ${p.sse.totalDrops.toLocaleString()} drops`)
|
|
745
|
+
}
|
|
746
|
+
if (frontendPerf && (frontendPerf.totalLayouts > 0 || frontendPerf.totalStructureKeyComputes > 0)) {
|
|
747
|
+
const fmt = (v: number) => v < 100 ? v.toFixed(1) : Math.round(v).toString()
|
|
748
|
+
lines.push(`- Frontend Layouts: ${frontendPerf.totalLayouts.toLocaleString()} (${frontendPerf.totalLayoutsSkipped.toLocaleString()} skipped)`)
|
|
749
|
+
if (frontendPerf.layoutMs.count > 0) {
|
|
750
|
+
lines.push(` - ELK (ms): last ${fmt(frontendPerf.layoutMs.last)} · p50 ${fmt(frontendPerf.layoutMs.p50)} · p95 ${fmt(frontendPerf.layoutMs.p95)} · max ${fmt(frontendPerf.layoutMs.max)}`)
|
|
751
|
+
lines.push(` - Last rendered: ${frontendPerf.lastLayoutNodeCount.toLocaleString()} nodes / ${frontendPerf.lastLayoutEdgeCount.toLocaleString()} edges`)
|
|
752
|
+
}
|
|
753
|
+
if (frontendPerf.structureKeyUs.count > 0) {
|
|
754
|
+
const fmtUs = (v: number) => v < 1000 ? `${Math.round(v)}μs` : `${(v / 1000).toFixed(2)}ms`
|
|
755
|
+
lines.push(` - structureKey: ${frontendPerf.totalStructureKeyComputes.toLocaleString()} computes · p50 ${fmtUs(frontendPerf.structureKeyUs.p50)} · p95 ${fmtUs(frontendPerf.structureKeyUs.p95)} · max ${fmtUs(frontendPerf.structureKeyUs.max)}`)
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
lines.push(``)
|
|
759
|
+
}
|
|
760
|
+
|
|
643
761
|
if (data.runtime) {
|
|
644
762
|
const rt = data.runtime
|
|
645
763
|
lines.push(`### Runtime`)
|
|
@@ -683,8 +801,8 @@ function formatForGitHub(data: DiagnosticsSnapshot, includeRawJson = true): stri
|
|
|
683
801
|
return lines.join('\n')
|
|
684
802
|
}
|
|
685
803
|
|
|
686
|
-
function formatForBugReport(data: DiagnosticsSnapshot): string {
|
|
687
|
-
const diagnostics = formatForGitHub(data, false)
|
|
804
|
+
function formatForBugReport(data: DiagnosticsSnapshot, frontendPerf?: K8sUIPerfSnapshot): string {
|
|
805
|
+
const diagnostics = formatForGitHub(data, frontendPerf, false)
|
|
688
806
|
|
|
689
807
|
const lines: string[] = []
|
|
690
808
|
lines.push(`## Describe the bug`)
|
package/src/index.ts
CHANGED
|
@@ -22,3 +22,12 @@ export { ShortcutHelpOverlay } from './components/ui/ShortcutHelpOverlay';
|
|
|
22
22
|
// kubeconfig ContextSwitcher without taking a direct dep on k8s-ui internals.
|
|
23
23
|
export { ClusterSwitcher } from '@skyhook-io/k8s-ui';
|
|
24
24
|
export type { ClusterSwitcherProps, ClusterSwitcherItem } from '@skyhook-io/k8s-ui';
|
|
25
|
+
|
|
26
|
+
// Deep-link builders — so consumers (Radar Hub) construct deep links into a
|
|
27
|
+
// cluster view without hand-rolling Radar's internal URL format, which drifts
|
|
28
|
+
// silently when Radar re-routes. `resourcePath` opens the detail drawer for any
|
|
29
|
+
// kind incl. cluster-scoped; `buildWorkloadPath` is the namespaced-workload
|
|
30
|
+
// full-page view. Both return basename-relative paths; embedders prepend their
|
|
31
|
+
// cluster prefix (e.g. /c/:id).
|
|
32
|
+
export { resourcePath, buildWorkloadPath } from './utils/navigation';
|
|
33
|
+
export type { SelectedResource } from '@skyhook-io/k8s-ui/types/core';
|
package/src/main.tsx
CHANGED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// monaco-editor's package `exports` map ("./*": "./*") doesn't surface type
|
|
2
|
+
// declarations for deep ESM subpaths, so TS can't resolve these imports even
|
|
3
|
+
// though the .js/.d.ts files exist on disk. Re-export the root types for the
|
|
4
|
+
// editor API and declare the YAML grammar as a side-effect-only module.
|
|
5
|
+
declare module 'monaco-editor/esm/vs/editor/editor.api' {
|
|
6
|
+
export * from 'monaco-editor'
|
|
7
|
+
}
|
|
8
|
+
declare module 'monaco-editor/esm/vs/basic-languages/yaml/yaml.contribution'
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Load the Monaco editor from the bundled npm package instead of the default
|
|
2
|
+
// jsdelivr CDN. Without this, @monaco-editor/react fetches the editor at runtime
|
|
3
|
+
// over the network, so the YAML editor never loads in airgapped / offline
|
|
4
|
+
// deployments. Bundling makes the binary fully self-contained.
|
|
5
|
+
//
|
|
6
|
+
// Imported for side effects from main.tsx (Radar's binary entry) only — library
|
|
7
|
+
// consumers (e.g. Radar Hub) keep the default CDN loader unless they opt in.
|
|
8
|
+
//
|
|
9
|
+
// Import the editor API + YAML grammar directly rather than the `monaco-editor`
|
|
10
|
+
// barrel: the barrel pulls in the JSON/CSS/HTML/TypeScript language services,
|
|
11
|
+
// each of which bundles a heavy web worker (the TS one alone is ~7MB) that Radar
|
|
12
|
+
// never uses — it only ever edits YAML.
|
|
13
|
+
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api'
|
|
14
|
+
import 'monaco-editor/esm/vs/basic-languages/yaml/yaml.contribution'
|
|
15
|
+
import { loader } from '@monaco-editor/react'
|
|
16
|
+
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
|
|
17
|
+
|
|
18
|
+
// YAML has no dedicated Monaco language worker — the base editor worker covers
|
|
19
|
+
// everything we use, so route every label to it.
|
|
20
|
+
;(self as typeof self & { MonacoEnvironment?: { getWorker(): Worker } }).MonacoEnvironment = {
|
|
21
|
+
getWorker() {
|
|
22
|
+
return new EditorWorker()
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
loader.config({ monaco })
|
package/src/utils/navigation.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { apiUrl, getAuthHeaders, getCredentialsMode } from '../api/config'
|
|
2
|
+
import { kindToPlural } from '@skyhook-io/k8s-ui/utils/navigation'
|
|
2
3
|
import type { SelectedResource } from '@skyhook-io/k8s-ui/types/core'
|
|
3
4
|
|
|
4
5
|
// Re-export shared navigation utilities from @skyhook-io/k8s-ui.
|
|
@@ -8,6 +9,8 @@ export type { NavigateToResource } from '@skyhook-io/k8s-ui/utils/navigation'
|
|
|
8
9
|
/**
|
|
9
10
|
* Build a /workload/:kind/:namespace/:name URL, preserving the API group as a
|
|
10
11
|
* query param so the WorkloadView can resolve CRDs with colliding kind names.
|
|
12
|
+
* Namespaced workloads only — WorkloadView requires a namespace. For arbitrary
|
|
13
|
+
* kinds (including cluster-scoped) use resourcePath.
|
|
11
14
|
*/
|
|
12
15
|
export function buildWorkloadPath(resource: SelectedResource): string {
|
|
13
16
|
const kind = encodeURIComponent(resource.kind)
|
|
@@ -17,6 +20,30 @@ export function buildWorkloadPath(resource: SelectedResource): string {
|
|
|
17
20
|
return resource.group ? `${base}?apiGroup=${encodeURIComponent(resource.group)}` : base
|
|
18
21
|
}
|
|
19
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Build a /resources/:plural?resource=:namespace/:name URL — the deep link that
|
|
25
|
+
* opens a resource's detail drawer in the resources view. Cluster-scoped
|
|
26
|
+
* resources use ?resource=:name (no slash); the API group rides in ?apiGroup=
|
|
27
|
+
* to disambiguate CRD/core kind collisions. This is the exact form the
|
|
28
|
+
* ResourcesView mount effect parses (the `?resource=` reader in
|
|
29
|
+
* packages/k8s-ui/src/components/resources/ResourcesView.tsx) — keep the two in
|
|
30
|
+
* lockstep.
|
|
31
|
+
*
|
|
32
|
+
* Unlike buildWorkloadPath, this opens the detail drawer for ANY kind,
|
|
33
|
+
* including cluster-scoped resources. Returns a basename-relative path;
|
|
34
|
+
* embedders (Radar Hub) prepend their cluster prefix (e.g. /c/:id).
|
|
35
|
+
*/
|
|
36
|
+
export function resourcePath(resource: SelectedResource): string {
|
|
37
|
+
const params = new URLSearchParams()
|
|
38
|
+
// No name → nothing to open; the kind list is the sane fallback.
|
|
39
|
+
if (resource.name) {
|
|
40
|
+
params.set('resource', resource.namespace ? `${resource.namespace}/${resource.name}` : resource.name)
|
|
41
|
+
}
|
|
42
|
+
if (resource.group) params.set('apiGroup', resource.group)
|
|
43
|
+
const query = params.toString()
|
|
44
|
+
return `/resources/${kindToPlural(resource.kind)}${query ? `?${query}` : ''}`
|
|
45
|
+
}
|
|
46
|
+
|
|
20
47
|
// radar-specific: open URL in system browser (desktop app support)
|
|
21
48
|
export function openExternal(url: string): void {
|
|
22
49
|
fetch(apiUrl('/desktop/open-url'), {
|