@skyhook-io/radar-app 1.1.1 → 1.2.0
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 +167 -64
- package/src/api/client.ts +197 -11
- package/src/api/rbac.ts +57 -0
- package/src/components/compare/CompareViewRoute.tsx +116 -0
- package/src/components/compare/useCompareCandidates.ts +27 -0
- package/src/components/compare/useCompareLauncher.tsx +76 -0
- package/src/components/cost/CostView.tsx +1 -1
- package/src/components/dock/TerminalTab.tsx +1 -1
- package/src/components/gitops/GitOpsView.tsx +1 -1
- package/src/components/helm/InstallWizard.tsx +5 -5
- package/src/components/helm/ValuesViewer.tsx +3 -39
- package/src/components/home/ClusterHealthCard.tsx +17 -13
- package/src/components/home/HomeView.tsx +18 -2
- package/src/components/home/MCPSetupDialog.tsx +5 -3
- package/src/components/resource/HPACharts.tsx +232 -0
- package/src/components/resource/PVCUsageBar.tsx +59 -0
- package/src/components/resource/PrometheusCharts.tsx +151 -434
- package/src/components/resource/PrometheusChartsGrid.tsx +339 -0
- package/src/components/resource/RestartChart.tsx +124 -0
- package/src/components/resource/RightsizingStrip.tsx +167 -0
- package/src/components/resources/CompositeRenderer.tsx +101 -0
- package/src/components/resources/renderers/HPARenderer.tsx +17 -1
- package/src/components/resources/renderers/NamespaceRenderer.tsx +22 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
- package/src/components/resources/renderers/PodRenderer.tsx +13 -0
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +43 -1
- package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
- package/src/components/resources/renderers/WorkloadRenderer.tsx +12 -0
- package/src/components/resources/renderers/index.ts +1 -0
- package/src/components/settings/MyPermissionsDialog.tsx +231 -0
- package/src/components/traffic/TrafficFlowList.tsx +16 -11
- package/src/components/traffic/TrafficGraph.tsx +5 -1
- package/src/components/ui/DiagnosticsOverlay.tsx +127 -8
- package/src/components/workload/WorkloadView.tsx +107 -3
- package/src/context/NavCustomization.tsx +13 -0
- package/src/main.tsx +1 -0
- package/src/monaco-deep.d.ts +8 -0
- package/src/monaco-setup.ts +26 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyhook-io/radar-app",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
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
|
@@ -12,6 +12,7 @@ import { ResourcesView } from './components/resources/ResourcesView'
|
|
|
12
12
|
import { serializeColumnFilters } from './components/resources/resource-utils'
|
|
13
13
|
import { ResourceDetailDrawer } from './components/resources/ResourceDetailDrawer'
|
|
14
14
|
import { WorkloadViewRoute } from './components/workload/WorkloadView'
|
|
15
|
+
import { CompareViewRoute } from './components/compare/CompareViewRoute'
|
|
15
16
|
import { HelmView } from './components/helm/HelmView'
|
|
16
17
|
import { TrafficView } from './components/traffic/TrafficView'
|
|
17
18
|
import { CostView } from './components/cost/CostView'
|
|
@@ -40,11 +41,12 @@ import { routePath, apiUrl, getAuthHeaders, getCredentialsMode } from './api/con
|
|
|
40
41
|
import { KeyboardShortcutProvider, useRegisterShortcut, useRegisterShortcuts } from './hooks/useKeyboardShortcuts'
|
|
41
42
|
import { useAnimatedUnmount } from './hooks/useAnimatedUnmount'
|
|
42
43
|
import radarLoadingIcon from '@skyhook-io/k8s-ui/assets/radar/radar-icon-loading.svg'
|
|
43
|
-
import { RefreshCw, Network, List, Clock, Package, Sun, Moon, Activity, Home, Star, Search, Bug, Settings, SquareTerminal, ShieldCheck, GitBranch } from 'lucide-react'
|
|
44
|
+
import { RefreshCw, Network, List, Clock, Package, Sun, Moon, Activity, Home, Star, Search, Bug, Settings, SquareTerminal, ShieldCheck, GitBranch, Shield as ShieldIcon } from 'lucide-react'
|
|
44
45
|
import { useTheme } from './context/ThemeContext'
|
|
45
46
|
import { Tooltip } from './components/ui/Tooltip'
|
|
46
47
|
import { LargeClusterNamespacePicker } from './components/shared/LargeClusterNamespacePicker'
|
|
47
48
|
import { SettingsDialog } from './components/settings/SettingsDialog'
|
|
49
|
+
import { MyPermissionsDialog } from './components/settings/MyPermissionsDialog'
|
|
48
50
|
import type { TopologyNode, GroupingMode, MainView, SelectedResource, SelectedHelmRelease, NodeKind, TopologyMode, Topology, K8sEvent } from './types'
|
|
49
51
|
import { kindToPlural, openExternal, apiVersionToGroup, buildWorkloadPath } from './utils/navigation'
|
|
50
52
|
import type { ContextSwitcherHandle } from './components/ContextSwitcher'
|
|
@@ -116,7 +118,7 @@ function apiResourceToNodeIdPrefix(apiResource: string): string {
|
|
|
116
118
|
}
|
|
117
119
|
|
|
118
120
|
// Extended MainView type that includes traffic and cost
|
|
119
|
-
type ExtendedMainView = MainView | 'traffic' | 'cost' | 'workload' | 'audit' | 'gitops'
|
|
121
|
+
type ExtendedMainView = MainView | 'traffic' | 'cost' | 'workload' | 'audit' | 'gitops' | 'compare'
|
|
120
122
|
|
|
121
123
|
// Extract view from URL path
|
|
122
124
|
function getViewFromPath(pathname: string): ExtendedMainView {
|
|
@@ -131,6 +133,7 @@ function getViewFromPath(pathname: string): ExtendedMainView {
|
|
|
131
133
|
if (path === 'workload') return 'workload'
|
|
132
134
|
if (path === 'audit') return 'audit'
|
|
133
135
|
if (path === 'gitops') return 'gitops'
|
|
136
|
+
if (path === 'compare') return 'compare'
|
|
134
137
|
return 'home'
|
|
135
138
|
}
|
|
136
139
|
|
|
@@ -143,10 +146,20 @@ function AuthBarrier({ authMode }: { authMode: string }) {
|
|
|
143
146
|
|
|
144
147
|
if (authMode === 'oidc') {
|
|
145
148
|
return (
|
|
146
|
-
<div className="flex-1
|
|
147
|
-
<div className="
|
|
148
|
-
<img
|
|
149
|
-
|
|
149
|
+
<div className="flex-1 relative bg-theme-base">
|
|
150
|
+
<div className="fixed inset-0 pointer-events-none">
|
|
151
|
+
<img
|
|
152
|
+
src={radarLoadingIcon}
|
|
153
|
+
alt=""
|
|
154
|
+
aria-hidden
|
|
155
|
+
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-11 h-11"
|
|
156
|
+
/>
|
|
157
|
+
<p
|
|
158
|
+
className="absolute left-1/2 -translate-x-1/2 whitespace-nowrap text-[17px] font-semibold tracking-tight text-theme-text-primary"
|
|
159
|
+
style={{ top: 'calc(50% + 34px)' }}
|
|
160
|
+
>
|
|
161
|
+
Redirecting to login…
|
|
162
|
+
</p>
|
|
150
163
|
</div>
|
|
151
164
|
</div>
|
|
152
165
|
)
|
|
@@ -288,6 +301,7 @@ function AppInner() {
|
|
|
288
301
|
|
|
289
302
|
// Settings dialog state
|
|
290
303
|
const [showSettings, setShowSettings] = useState(false)
|
|
304
|
+
const [showMyPermissions, setShowMyPermissions] = useState(false)
|
|
291
305
|
|
|
292
306
|
// Listen for desktop "open-settings" event from native menu
|
|
293
307
|
useEffect(() => {
|
|
@@ -509,58 +523,97 @@ function AppInner() {
|
|
|
509
523
|
// Query client for cache invalidation
|
|
510
524
|
const queryClient = useQueryClient()
|
|
511
525
|
|
|
512
|
-
// SSE-driven cache invalidation
|
|
513
|
-
//
|
|
514
|
-
//
|
|
515
|
-
//
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
|
545
|
+
timer: number | null
|
|
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
|
|
519
549
|
timer: number | null
|
|
520
|
-
}>({
|
|
550
|
+
}>({ updatedKinds: new Set(), timer: null })
|
|
521
551
|
|
|
522
552
|
const handleK8sEvent = useCallback((event: K8sEvent) => {
|
|
523
553
|
// Skip K8s Event kind — informational, not resource mutations
|
|
524
554
|
if (event.kind === 'Event') return
|
|
525
555
|
|
|
526
|
-
const
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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)
|
|
530
590
|
}
|
|
531
591
|
|
|
532
|
-
//
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
if (pending.kinds.has('secrets')) {
|
|
545
|
-
queryClient.invalidateQueries({ queryKey: ['secret-cert-expiry'] })
|
|
546
|
-
}
|
|
547
|
-
// GitOps tree + insights are derived views over the same informer
|
|
548
|
-
// cache that produced this SSE event — when *anything* changes, the
|
|
549
|
-
// managed-resource tree and the insights pipeline can have stale
|
|
550
|
-
// changes/events/drift. Invalidating broadly here is cheap (only the
|
|
551
|
-
// currently-mounted GitOps view re-fetches; other views have no
|
|
552
|
-
// matching keys) and is what makes the detail page actually live.
|
|
553
|
-
// Without this the failure card + topology lag behind the title chips
|
|
554
|
-
// until window focus or a manual refresh.
|
|
555
|
-
queryClient.invalidateQueries({ queryKey: ['gitops-tree'] })
|
|
556
|
-
queryClient.invalidateQueries({ queryKey: ['gitops-insights'] })
|
|
557
|
-
// Reset accumulator
|
|
558
|
-
pending.kinds = new Set()
|
|
559
|
-
pending.hasCountChange = false
|
|
560
|
-
pending.timer = null
|
|
561
|
-
}, 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
|
+
}
|
|
562
604
|
}, [queryClient])
|
|
563
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
|
+
|
|
564
617
|
// SSE connection for real-time updates — no namespace filter for small/medium clusters (frontend filters).
|
|
565
618
|
// forceNamespaceFilter is only set for large clusters that require server-side filtering.
|
|
566
619
|
// Fleet mode uses 'resources' topology on the backend — filtering is client-side
|
|
@@ -576,10 +629,10 @@ function AppInner() {
|
|
|
576
629
|
queryClient.invalidateQueries()
|
|
577
630
|
|
|
578
631
|
// Cancel any pending SSE-driven invalidation — old cluster's events are irrelevant
|
|
579
|
-
if (
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
}
|
|
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 }
|
|
583
636
|
|
|
584
637
|
// Close any open drawers/overlays — old cluster's resources don't exist on the new one
|
|
585
638
|
setSelectedResource(null)
|
|
@@ -943,6 +996,7 @@ function AppInner() {
|
|
|
943
996
|
})
|
|
944
997
|
|
|
945
998
|
return {
|
|
999
|
+
...displayedTopology,
|
|
946
1000
|
nodes: filteredNodes,
|
|
947
1001
|
edges: filteredEdges,
|
|
948
1002
|
}
|
|
@@ -1146,6 +1200,18 @@ function AppInner() {
|
|
|
1146
1200
|
</button>
|
|
1147
1201
|
)}
|
|
1148
1202
|
|
|
1203
|
+
{/* My Permissions — what the current user can do in the cluster,
|
|
1204
|
+
computed live by the apiserver via SelfSubjectRulesReview.
|
|
1205
|
+
Available in embedded mode too — Radar Hub users still benefit
|
|
1206
|
+
from "why can't I do X" debugging. */}
|
|
1207
|
+
<button
|
|
1208
|
+
onClick={() => setShowMyPermissions(true)}
|
|
1209
|
+
className="p-1.5 rounded-md bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors"
|
|
1210
|
+
title="My permissions in this cluster"
|
|
1211
|
+
>
|
|
1212
|
+
<ShieldIcon className="w-4 h-4" />
|
|
1213
|
+
</button>
|
|
1214
|
+
|
|
1149
1215
|
{/* User menu (when auth enabled) — hidden in embedded mode;
|
|
1150
1216
|
host app typically provides its own via rightExtras. */}
|
|
1151
1217
|
{!navCustomization.embedded && <UserMenu />}
|
|
@@ -1173,13 +1239,35 @@ function AppInner() {
|
|
|
1173
1239
|
/>
|
|
1174
1240
|
)}
|
|
1175
1241
|
|
|
1176
|
-
{/* Connecting view
|
|
1242
|
+
{/* Connecting view — shown during initial connection or retry.
|
|
1243
|
+
Icon is viewport-anchored so its screen position matches the
|
|
1244
|
+
host hub splash across cross-document transitions. */}
|
|
1177
1245
|
{!isSwitching && !(authMe?.authEnabled && !authMe?.username) && connection.state === 'connecting' && (
|
|
1178
|
-
<div className="flex-1
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1246
|
+
<div className="flex-1 relative bg-theme-base">
|
|
1247
|
+
{/* Icon absolutely anchored to viewport-center. The label block
|
|
1248
|
+
sits at a fixed offset below — independent of label height
|
|
1249
|
+
so multi-line messages (context + progress) don't shift the
|
|
1250
|
+
icon's screen position. */}
|
|
1251
|
+
<div className="fixed inset-0 pointer-events-none">
|
|
1252
|
+
<img
|
|
1253
|
+
src={radarLoadingIcon}
|
|
1254
|
+
alt=""
|
|
1255
|
+
aria-hidden
|
|
1256
|
+
// Integer offset (vw/2 − 22) — avoids sub-pixel jitter from
|
|
1257
|
+
// `translate(-50%, -50%)` on odd-width viewports.
|
|
1258
|
+
className="absolute w-11 h-11"
|
|
1259
|
+
style={{ left: 'calc(50% - 22px)', top: 'calc(50% - 22px)' }}
|
|
1260
|
+
/>
|
|
1261
|
+
<div
|
|
1262
|
+
className="absolute left-1/2 -translate-x-1/2 text-center"
|
|
1263
|
+
style={{ top: 'calc(50% + 34px)' }}
|
|
1264
|
+
>
|
|
1265
|
+
{/* 17px semibold matches the other splash surfaces so font
|
|
1266
|
+
weight doesn't visibly swap during hub → cluster
|
|
1267
|
+
transitions. Subtitles below stay smaller/dimmer. */}
|
|
1268
|
+
<p className="whitespace-nowrap text-[17px] font-semibold tracking-tight text-theme-text-primary">
|
|
1269
|
+
Connecting to cluster
|
|
1270
|
+
</p>
|
|
1183
1271
|
{connection.context && (
|
|
1184
1272
|
<p className="text-sm text-theme-text-secondary mt-1">{connection.context}</p>
|
|
1185
1273
|
)}
|
|
@@ -1193,13 +1281,24 @@ function AppInner() {
|
|
|
1193
1281
|
</div>
|
|
1194
1282
|
)}
|
|
1195
1283
|
|
|
1196
|
-
{/* Context switching overlay */}
|
|
1284
|
+
{/* Context switching overlay — icon viewport-anchored, label below. */}
|
|
1197
1285
|
{isSwitching && (
|
|
1198
|
-
<div className="flex-1
|
|
1199
|
-
<div className="
|
|
1200
|
-
<img
|
|
1201
|
-
|
|
1202
|
-
|
|
1286
|
+
<div className="flex-1 relative bg-theme-base">
|
|
1287
|
+
<div className="fixed inset-0 pointer-events-none">
|
|
1288
|
+
<img
|
|
1289
|
+
src={radarLoadingIcon}
|
|
1290
|
+
alt=""
|
|
1291
|
+
aria-hidden
|
|
1292
|
+
// Integer offset (vw/2 − 22) — avoids sub-pixel jitter from
|
|
1293
|
+
// `translate(-50%, -50%)` on odd-width viewports.
|
|
1294
|
+
className="absolute w-11 h-11"
|
|
1295
|
+
style={{ left: 'calc(50% - 22px)', top: 'calc(50% - 22px)' }}
|
|
1296
|
+
/>
|
|
1297
|
+
<div
|
|
1298
|
+
className="absolute left-1/2 -translate-x-1/2 text-center"
|
|
1299
|
+
style={{ top: 'calc(50% + 34px)' }}
|
|
1300
|
+
>
|
|
1301
|
+
<div className="whitespace-nowrap text-[17px] font-semibold tracking-tight text-theme-text-primary">Switching context</div>
|
|
1203
1302
|
{targetContext && (
|
|
1204
1303
|
<div className="text-xs mt-2 text-theme-text-tertiary">
|
|
1205
1304
|
{targetContext.provider ? (
|
|
@@ -1476,6 +1575,9 @@ function AppInner() {
|
|
|
1476
1575
|
/>
|
|
1477
1576
|
)}
|
|
1478
1577
|
|
|
1578
|
+
{/* Compare two resources of the same kind side-by-side */}
|
|
1579
|
+
{mainView === 'compare' && <CompareViewRoute />}
|
|
1580
|
+
|
|
1479
1581
|
</ErrorBoundary>
|
|
1480
1582
|
</div>}
|
|
1481
1583
|
|
|
@@ -1584,6 +1686,7 @@ function AppInner() {
|
|
|
1584
1686
|
|
|
1585
1687
|
{/* Settings dialog */}
|
|
1586
1688
|
<SettingsDialog open={showSettings} onClose={() => setShowSettings(false)} />
|
|
1689
|
+
<MyPermissionsDialog open={showMyPermissions} onClose={() => setShowMyPermissions(false)} />
|
|
1587
1690
|
|
|
1588
1691
|
{/* Debug overlay - only in dev mode */}
|
|
1589
1692
|
{import.meta.env.DEV && <DebugOverlay />}
|
package/src/api/client.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react'
|
|
1
2
|
import { useQuery, useMutation, useQueryClient, skipToken } from '@tanstack/react-query'
|
|
2
3
|
import { showApiError, showApiSuccess } from '../components/ui/Toast'
|
|
3
4
|
import { useCanHelmWrite } from '../contexts/CapabilitiesContext'
|
|
@@ -139,6 +140,9 @@ export interface WorkloadCount {
|
|
|
139
140
|
export interface DashboardMetrics {
|
|
140
141
|
cpu?: MetricSummary
|
|
141
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
|
|
142
146
|
}
|
|
143
147
|
|
|
144
148
|
export interface MetricSummary {
|
|
@@ -890,7 +894,7 @@ export function useResourceWithRelationships<T>(kind: string, namespace: string,
|
|
|
890
894
|
}
|
|
891
895
|
|
|
892
896
|
// List resources - queryKey includes group for cache sharing with ResourcesView
|
|
893
|
-
export function useResources<T>(kind: string, namespace?: string, group?: string) {
|
|
897
|
+
export function useResources<T>(kind: string, namespace?: string, group?: string, options?: { enabled?: boolean }) {
|
|
894
898
|
const params = new URLSearchParams()
|
|
895
899
|
if (namespace) params.set('namespace', namespace)
|
|
896
900
|
if (group) params.set('group', group)
|
|
@@ -899,6 +903,7 @@ export function useResources<T>(kind: string, namespace?: string, group?: string
|
|
|
899
903
|
return useQuery<T[]>({
|
|
900
904
|
queryKey: ['resources', kind, group, namespace],
|
|
901
905
|
queryFn: () => fetchJSON(`/resources/${kind}${queryString ? `?${queryString}` : ''}`),
|
|
906
|
+
enabled: (options?.enabled ?? true) && Boolean(kind),
|
|
902
907
|
staleTime: 30000, // 30 seconds - matches refetchInterval in ResourcesView
|
|
903
908
|
})
|
|
904
909
|
}
|
|
@@ -1219,19 +1224,21 @@ export interface PrometheusStatus {
|
|
|
1219
1224
|
error?: string
|
|
1220
1225
|
}
|
|
1221
1226
|
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1227
|
+
// Time-series sample types live in @skyhook-io/k8s-ui (shared with library
|
|
1228
|
+
// consumers). Re-export here so radar-app callers keep their existing import
|
|
1229
|
+
// paths; the Prom-prefixed names are deprecated aliases.
|
|
1230
|
+
export type {
|
|
1231
|
+
TimeSeriesPoint,
|
|
1232
|
+
TimeSeries,
|
|
1233
|
+
PrometheusDataPoint,
|
|
1234
|
+
PrometheusSeries,
|
|
1235
|
+
} from '@skyhook-io/k8s-ui/components/charts'
|
|
1226
1236
|
|
|
1227
|
-
|
|
1228
|
-
labels: Record<string, string>
|
|
1229
|
-
dataPoints: PrometheusDataPoint[]
|
|
1230
|
-
}
|
|
1237
|
+
import type { TimeSeries as ChartTimeSeries } from '@skyhook-io/k8s-ui/components/charts'
|
|
1231
1238
|
|
|
1232
1239
|
export interface PrometheusQueryResult {
|
|
1233
1240
|
resultType: string
|
|
1234
|
-
series:
|
|
1241
|
+
series: ChartTimeSeries[]
|
|
1235
1242
|
}
|
|
1236
1243
|
|
|
1237
1244
|
export interface PrometheusResourceMetrics {
|
|
@@ -1246,9 +1253,44 @@ export interface PrometheusResourceMetrics {
|
|
|
1246
1253
|
hint?: string // Contextual hint when results are empty (e.g. cri-docker label issues)
|
|
1247
1254
|
}
|
|
1248
1255
|
|
|
1249
|
-
export type PrometheusMetricCategory = 'cpu' | 'memory' | 'network_rx' | 'network_tx' | 'filesystem'
|
|
1256
|
+
export type PrometheusMetricCategory = 'cpu' | 'memory' | 'network_rx' | 'network_tx' | 'filesystem' | 'restarts'
|
|
1250
1257
|
export type PrometheusTimeRange = '10m' | '30m' | '1h' | '3h' | '6h' | '12h' | '24h' | '48h' | '7d' | '14d'
|
|
1251
1258
|
|
|
1259
|
+
// PVC usage at a moment in time, derived from kubelet_volume_stats_*.
|
|
1260
|
+
// HasData=false silently indicates the CSI driver doesn't report or Prom
|
|
1261
|
+
// isn't scraping kubelet endpoints — UI should hide the gauge in that case.
|
|
1262
|
+
export interface PrometheusPVCUsage {
|
|
1263
|
+
namespace: string
|
|
1264
|
+
name: string
|
|
1265
|
+
used: number
|
|
1266
|
+
capacity: number
|
|
1267
|
+
ratio: number
|
|
1268
|
+
hasData: boolean
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
export type RightsizingTone = 'ok' | 'info' | 'warning' | 'alert' | 'critical'
|
|
1272
|
+
|
|
1273
|
+
export interface RightsizingRow {
|
|
1274
|
+
container: string
|
|
1275
|
+
resource: 'cpu' | 'memory'
|
|
1276
|
+
currentRequest?: string
|
|
1277
|
+
currentLimit?: string
|
|
1278
|
+
p95?: string
|
|
1279
|
+
recommendedRequest?: string
|
|
1280
|
+
tone: RightsizingTone
|
|
1281
|
+
message: string
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
export interface PrometheusRightsizing {
|
|
1285
|
+
kind: string
|
|
1286
|
+
namespace: string
|
|
1287
|
+
name: string
|
|
1288
|
+
window: string
|
|
1289
|
+
sampleAvailable: boolean
|
|
1290
|
+
rows: RightsizingRow[]
|
|
1291
|
+
reason?: string
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1252
1294
|
// Check Prometheus availability
|
|
1253
1295
|
export function usePrometheusStatus() {
|
|
1254
1296
|
return useQuery<PrometheusStatus>({
|
|
@@ -1281,6 +1323,86 @@ export function usePrometheusConnect() {
|
|
|
1281
1323
|
})
|
|
1282
1324
|
}
|
|
1283
1325
|
|
|
1326
|
+
// Auto-discover Prometheus on first mount of any Prom-backed view, and
|
|
1327
|
+
// auto-reconnect across radar restarts on subsequent mounts.
|
|
1328
|
+
//
|
|
1329
|
+
// Two paths through this hook, both running once per cluster context per
|
|
1330
|
+
// component-instance:
|
|
1331
|
+
// 1. Cached path — localStorage flag means "Prom was discovered before on
|
|
1332
|
+
// this context". Probe fires immediately on mount; the user sees
|
|
1333
|
+
// charts populate without manual interaction.
|
|
1334
|
+
// 2. First-time path — no flag yet. Probe fires after a small delay so the
|
|
1335
|
+
// initial workload-view render lands before we hit the cluster network.
|
|
1336
|
+
// Behavior matches Lens / Headlamp defaults; the trade-off is one
|
|
1337
|
+
// cluster probe per session per fresh kubeconfig context.
|
|
1338
|
+
//
|
|
1339
|
+
// On success either way we set the flag, so subsequent mounts take path 1.
|
|
1340
|
+
// On failure we clear the flag (path 1) or leave it cleared (path 2) and
|
|
1341
|
+
// reset attemptedRef, so the existing "Discover Prometheus" CTA renders
|
|
1342
|
+
// once status refreshes. Manual interaction stays available as the fallback.
|
|
1343
|
+
//
|
|
1344
|
+
// localStorage is the right surface: connection intent is browser-local,
|
|
1345
|
+
// not a server-side preference, and we want it to persist across radar
|
|
1346
|
+
// restarts on the same port.
|
|
1347
|
+
const PROM_AUTOCONNECT_PREFIX = 'radar.prometheus.autoConnect:'
|
|
1348
|
+
// First-mount delay before probing the cluster. Chosen short enough that the
|
|
1349
|
+
// CTA → charts transition feels prompt, long enough that the probe doesn't
|
|
1350
|
+
// race the initial workload-view render.
|
|
1351
|
+
const PROM_FIRSTLAUNCH_PROBE_DELAY_MS = 500
|
|
1352
|
+
|
|
1353
|
+
function promAutoConnectKey(contextName: string): string {
|
|
1354
|
+
return `${PROM_AUTOCONNECT_PREFIX}${contextName}`
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
export function useAutoPromConnect(): void {
|
|
1358
|
+
const queryClient = useQueryClient()
|
|
1359
|
+
const { data: clusterInfo } = useClusterInfo()
|
|
1360
|
+
const { data: status, isLoading: statusLoading } = usePrometheusStatus()
|
|
1361
|
+
const attemptedRef = useRef<string | null>(null)
|
|
1362
|
+
|
|
1363
|
+
useEffect(() => {
|
|
1364
|
+
if (typeof window === 'undefined') return
|
|
1365
|
+
const context = clusterInfo?.context
|
|
1366
|
+
if (!context || statusLoading) return
|
|
1367
|
+
|
|
1368
|
+
// Persist the "we've connected here before" signal once a connection lands.
|
|
1369
|
+
if (status?.connected) {
|
|
1370
|
+
try { window.localStorage.setItem(promAutoConnectKey(context), '1') } catch {
|
|
1371
|
+
// localStorage can throw in some restricted browser modes — fail open.
|
|
1372
|
+
}
|
|
1373
|
+
return
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
if (attemptedRef.current === context) return
|
|
1377
|
+
let cached: string | null = null
|
|
1378
|
+
try { cached = window.localStorage.getItem(promAutoConnectKey(context)) } catch {
|
|
1379
|
+
cached = null
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
attemptedRef.current = context
|
|
1383
|
+
|
|
1384
|
+
// Cached path probes immediately; first-time path defers briefly so the
|
|
1385
|
+
// initial UI render isn't competing with the cluster network call.
|
|
1386
|
+
const delay = cached === '1' ? 0 : PROM_FIRSTLAUNCH_PROBE_DELAY_MS
|
|
1387
|
+
const timeout = window.setTimeout(() => {
|
|
1388
|
+
// Direct apiFetch (not via the usePrometheusConnect mutation) so the
|
|
1389
|
+
// meta-driven toast handler stays silent — the user didn't click anything.
|
|
1390
|
+
apiFetch(`${getApiBase()}/prometheus/connect`, { method: 'POST' })
|
|
1391
|
+
.then(resp => {
|
|
1392
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
|
1393
|
+
queryClient.invalidateQueries({ queryKey: ['prometheus-status'] })
|
|
1394
|
+
})
|
|
1395
|
+
.catch(() => {
|
|
1396
|
+
try { window.localStorage.removeItem(promAutoConnectKey(context)) } catch {
|
|
1397
|
+
// ignore — manual CTA will render once status refreshes
|
|
1398
|
+
}
|
|
1399
|
+
attemptedRef.current = null
|
|
1400
|
+
})
|
|
1401
|
+
}, delay)
|
|
1402
|
+
return () => window.clearTimeout(timeout)
|
|
1403
|
+
}, [clusterInfo?.context, status?.connected, statusLoading, queryClient])
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1284
1406
|
// Fetch Prometheus metrics for a resource
|
|
1285
1407
|
export function usePrometheusResourceMetrics(
|
|
1286
1408
|
kind: string,
|
|
@@ -1337,6 +1459,40 @@ export function usePrometheusClusterMetrics(
|
|
|
1337
1459
|
})
|
|
1338
1460
|
}
|
|
1339
1461
|
|
|
1462
|
+
// Fetch PVC usage. hasData=false when no series — UI should hide the gauge.
|
|
1463
|
+
export function usePrometheusPVCUsage(namespace: string, name: string, enabled = true) {
|
|
1464
|
+
return useQuery<PrometheusPVCUsage>({
|
|
1465
|
+
queryKey: ['prometheus-pvc-usage', namespace, name],
|
|
1466
|
+
queryFn: () => fetchJSON(`/prometheus/pvc/${namespace}/${name}`),
|
|
1467
|
+
enabled: enabled && Boolean(namespace && name),
|
|
1468
|
+
staleTime: 60000,
|
|
1469
|
+
refetchInterval: 120000,
|
|
1470
|
+
})
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
// Fetch rightsizing recommendations for a workload (Deployment / StatefulSet / DaemonSet).
|
|
1474
|
+
export function usePrometheusRightsizing(kind: string, namespace: string, name: string, enabled = true) {
|
|
1475
|
+
return useQuery<PrometheusRightsizing>({
|
|
1476
|
+
queryKey: ['prometheus-rightsizing', kind, namespace, name],
|
|
1477
|
+
queryFn: () => fetchJSON(`/prometheus/rightsizing/${kind}/${namespace}/${name}`),
|
|
1478
|
+
enabled: enabled && Boolean(kind && namespace && name),
|
|
1479
|
+
staleTime: 5 * 60 * 1000, // P95 over 24h is slow to shift; cache aggressively
|
|
1480
|
+
refetchInterval: 10 * 60 * 1000,
|
|
1481
|
+
})
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
// Raw PromQL query (range). Used by HPA charts for status_current_replicas etc.
|
|
1485
|
+
export function usePromQLRange(query: string, range: PrometheusTimeRange = '1h', enabled = true) {
|
|
1486
|
+
return useQuery<PrometheusQueryResult>({
|
|
1487
|
+
queryKey: ['promql-range', query, range],
|
|
1488
|
+
queryFn: () =>
|
|
1489
|
+
fetchJSON(`/prometheus/query?query=${encodeURIComponent(query)}&range=${range}`),
|
|
1490
|
+
enabled: enabled && Boolean(query),
|
|
1491
|
+
staleTime: 30000,
|
|
1492
|
+
refetchInterval: 60000,
|
|
1493
|
+
})
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1340
1496
|
// ============================================================================
|
|
1341
1497
|
// Pod Logs
|
|
1342
1498
|
// ============================================================================
|
|
@@ -2873,6 +3029,9 @@ export interface DiagInformerSyncStatus {
|
|
|
2873
3029
|
synced: boolean
|
|
2874
3030
|
syncedAt?: string
|
|
2875
3031
|
items: number
|
|
3032
|
+
lastError?: string
|
|
3033
|
+
lastErrorAt?: string
|
|
3034
|
+
forbiddenSeen?: boolean
|
|
2876
3035
|
}
|
|
2877
3036
|
|
|
2878
3037
|
export interface DiagCacheSyncStatus {
|
|
@@ -2889,6 +3048,31 @@ export interface DiagCacheSyncStatus {
|
|
|
2889
3048
|
promotedKinds?: string[]
|
|
2890
3049
|
}
|
|
2891
3050
|
|
|
3051
|
+
export interface DiagSampleWindow {
|
|
3052
|
+
count: number
|
|
3053
|
+
last: number
|
|
3054
|
+
min: number
|
|
3055
|
+
p50: number
|
|
3056
|
+
p95: number
|
|
3057
|
+
p99: number
|
|
3058
|
+
max: number
|
|
3059
|
+
}
|
|
3060
|
+
|
|
3061
|
+
export interface DiagPerfSnapshot {
|
|
3062
|
+
topology: {
|
|
3063
|
+
totalBuilds: number
|
|
3064
|
+
durationUs: DiagSampleWindow
|
|
3065
|
+
nodeCount: DiagSampleWindow
|
|
3066
|
+
edgeCount: DiagSampleWindow
|
|
3067
|
+
payloadBytes: DiagSampleWindow
|
|
3068
|
+
estimatedNodes: DiagSampleWindow
|
|
3069
|
+
}
|
|
3070
|
+
sse: {
|
|
3071
|
+
totalBroadcasts: number
|
|
3072
|
+
totalDrops: number
|
|
3073
|
+
}
|
|
3074
|
+
}
|
|
3075
|
+
|
|
2892
3076
|
export interface DiagnosticsSnapshot {
|
|
2893
3077
|
timestamp: string
|
|
2894
3078
|
radarVersion: string
|
|
@@ -2983,6 +3167,7 @@ export interface DiagnosticsSnapshot {
|
|
|
2983
3167
|
sse?: {
|
|
2984
3168
|
connectedClients: number
|
|
2985
3169
|
}
|
|
3170
|
+
perf?: DiagPerfSnapshot
|
|
2986
3171
|
runtime?: {
|
|
2987
3172
|
heapMB: number
|
|
2988
3173
|
heapObjectsK: number
|
|
@@ -2998,6 +3183,7 @@ export interface DiagnosticsSnapshot {
|
|
|
2998
3183
|
debugEvents: boolean
|
|
2999
3184
|
mcpEnabled: boolean
|
|
3000
3185
|
hasPrometheusURL: boolean
|
|
3186
|
+
hasPrometheusHeaders: boolean
|
|
3001
3187
|
}
|
|
3002
3188
|
recentErrors?: DiagErrorEntry[]
|
|
3003
3189
|
totalErrorsRecorded?: number
|