@skyhook-io/radar-app 1.1.1 → 1.1.2
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 +81 -18
- package/src/api/client.ts +165 -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/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/HomeView.tsx +18 -2
- 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/ui/DiagnosticsOverlay.tsx +1 -0
- package/src/components/workload/WorkloadView.tsx +107 -3
- package/src/context/NavCustomization.tsx +13 -0
package/package.json
CHANGED
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(() => {
|
|
@@ -1146,6 +1160,18 @@ function AppInner() {
|
|
|
1146
1160
|
</button>
|
|
1147
1161
|
)}
|
|
1148
1162
|
|
|
1163
|
+
{/* My Permissions — what the current user can do in the cluster,
|
|
1164
|
+
computed live by the apiserver via SelfSubjectRulesReview.
|
|
1165
|
+
Available in embedded mode too — Radar Hub users still benefit
|
|
1166
|
+
from "why can't I do X" debugging. */}
|
|
1167
|
+
<button
|
|
1168
|
+
onClick={() => setShowMyPermissions(true)}
|
|
1169
|
+
className="p-1.5 rounded-md bg-theme-elevated hover:bg-theme-hover text-theme-text-secondary hover:text-theme-text-primary transition-colors"
|
|
1170
|
+
title="My permissions in this cluster"
|
|
1171
|
+
>
|
|
1172
|
+
<ShieldIcon className="w-4 h-4" />
|
|
1173
|
+
</button>
|
|
1174
|
+
|
|
1149
1175
|
{/* User menu (when auth enabled) — hidden in embedded mode;
|
|
1150
1176
|
host app typically provides its own via rightExtras. */}
|
|
1151
1177
|
{!navCustomization.embedded && <UserMenu />}
|
|
@@ -1173,13 +1199,35 @@ function AppInner() {
|
|
|
1173
1199
|
/>
|
|
1174
1200
|
)}
|
|
1175
1201
|
|
|
1176
|
-
{/* Connecting view
|
|
1202
|
+
{/* Connecting view — shown during initial connection or retry.
|
|
1203
|
+
Icon is viewport-anchored so its screen position matches the
|
|
1204
|
+
host hub splash across cross-document transitions. */}
|
|
1177
1205
|
{!isSwitching && !(authMe?.authEnabled && !authMe?.username) && connection.state === 'connecting' && (
|
|
1178
|
-
<div className="flex-1
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1206
|
+
<div className="flex-1 relative bg-theme-base">
|
|
1207
|
+
{/* Icon absolutely anchored to viewport-center. The label block
|
|
1208
|
+
sits at a fixed offset below — independent of label height
|
|
1209
|
+
so multi-line messages (context + progress) don't shift the
|
|
1210
|
+
icon's screen position. */}
|
|
1211
|
+
<div className="fixed inset-0 pointer-events-none">
|
|
1212
|
+
<img
|
|
1213
|
+
src={radarLoadingIcon}
|
|
1214
|
+
alt=""
|
|
1215
|
+
aria-hidden
|
|
1216
|
+
// Integer offset (vw/2 − 22) — avoids sub-pixel jitter from
|
|
1217
|
+
// `translate(-50%, -50%)` on odd-width viewports.
|
|
1218
|
+
className="absolute w-11 h-11"
|
|
1219
|
+
style={{ left: 'calc(50% - 22px)', top: 'calc(50% - 22px)' }}
|
|
1220
|
+
/>
|
|
1221
|
+
<div
|
|
1222
|
+
className="absolute left-1/2 -translate-x-1/2 text-center"
|
|
1223
|
+
style={{ top: 'calc(50% + 34px)' }}
|
|
1224
|
+
>
|
|
1225
|
+
{/* 17px semibold matches the other splash surfaces so font
|
|
1226
|
+
weight doesn't visibly swap during hub → cluster
|
|
1227
|
+
transitions. Subtitles below stay smaller/dimmer. */}
|
|
1228
|
+
<p className="whitespace-nowrap text-[17px] font-semibold tracking-tight text-theme-text-primary">
|
|
1229
|
+
Connecting to cluster
|
|
1230
|
+
</p>
|
|
1183
1231
|
{connection.context && (
|
|
1184
1232
|
<p className="text-sm text-theme-text-secondary mt-1">{connection.context}</p>
|
|
1185
1233
|
)}
|
|
@@ -1193,13 +1241,24 @@ function AppInner() {
|
|
|
1193
1241
|
</div>
|
|
1194
1242
|
)}
|
|
1195
1243
|
|
|
1196
|
-
{/* Context switching overlay */}
|
|
1244
|
+
{/* Context switching overlay — icon viewport-anchored, label below. */}
|
|
1197
1245
|
{isSwitching && (
|
|
1198
|
-
<div className="flex-1
|
|
1199
|
-
<div className="
|
|
1200
|
-
<img
|
|
1201
|
-
|
|
1202
|
-
|
|
1246
|
+
<div className="flex-1 relative bg-theme-base">
|
|
1247
|
+
<div className="fixed inset-0 pointer-events-none">
|
|
1248
|
+
<img
|
|
1249
|
+
src={radarLoadingIcon}
|
|
1250
|
+
alt=""
|
|
1251
|
+
aria-hidden
|
|
1252
|
+
// Integer offset (vw/2 − 22) — avoids sub-pixel jitter from
|
|
1253
|
+
// `translate(-50%, -50%)` on odd-width viewports.
|
|
1254
|
+
className="absolute w-11 h-11"
|
|
1255
|
+
style={{ left: 'calc(50% - 22px)', top: 'calc(50% - 22px)' }}
|
|
1256
|
+
/>
|
|
1257
|
+
<div
|
|
1258
|
+
className="absolute left-1/2 -translate-x-1/2 text-center"
|
|
1259
|
+
style={{ top: 'calc(50% + 34px)' }}
|
|
1260
|
+
>
|
|
1261
|
+
<div className="whitespace-nowrap text-[17px] font-semibold tracking-tight text-theme-text-primary">Switching context</div>
|
|
1203
1262
|
{targetContext && (
|
|
1204
1263
|
<div className="text-xs mt-2 text-theme-text-tertiary">
|
|
1205
1264
|
{targetContext.provider ? (
|
|
@@ -1476,6 +1535,9 @@ function AppInner() {
|
|
|
1476
1535
|
/>
|
|
1477
1536
|
)}
|
|
1478
1537
|
|
|
1538
|
+
{/* Compare two resources of the same kind side-by-side */}
|
|
1539
|
+
{mainView === 'compare' && <CompareViewRoute />}
|
|
1540
|
+
|
|
1479
1541
|
</ErrorBoundary>
|
|
1480
1542
|
</div>}
|
|
1481
1543
|
|
|
@@ -1584,6 +1646,7 @@ function AppInner() {
|
|
|
1584
1646
|
|
|
1585
1647
|
{/* Settings dialog */}
|
|
1586
1648
|
<SettingsDialog open={showSettings} onClose={() => setShowSettings(false)} />
|
|
1649
|
+
<MyPermissionsDialog open={showMyPermissions} onClose={() => setShowMyPermissions(false)} />
|
|
1587
1650
|
|
|
1588
1651
|
{/* Debug overlay - only in dev mode */}
|
|
1589
1652
|
{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'
|
|
@@ -890,7 +891,7 @@ export function useResourceWithRelationships<T>(kind: string, namespace: string,
|
|
|
890
891
|
}
|
|
891
892
|
|
|
892
893
|
// List resources - queryKey includes group for cache sharing with ResourcesView
|
|
893
|
-
export function useResources<T>(kind: string, namespace?: string, group?: string) {
|
|
894
|
+
export function useResources<T>(kind: string, namespace?: string, group?: string, options?: { enabled?: boolean }) {
|
|
894
895
|
const params = new URLSearchParams()
|
|
895
896
|
if (namespace) params.set('namespace', namespace)
|
|
896
897
|
if (group) params.set('group', group)
|
|
@@ -899,6 +900,7 @@ export function useResources<T>(kind: string, namespace?: string, group?: string
|
|
|
899
900
|
return useQuery<T[]>({
|
|
900
901
|
queryKey: ['resources', kind, group, namespace],
|
|
901
902
|
queryFn: () => fetchJSON(`/resources/${kind}${queryString ? `?${queryString}` : ''}`),
|
|
903
|
+
enabled: (options?.enabled ?? true) && Boolean(kind),
|
|
902
904
|
staleTime: 30000, // 30 seconds - matches refetchInterval in ResourcesView
|
|
903
905
|
})
|
|
904
906
|
}
|
|
@@ -1219,19 +1221,21 @@ export interface PrometheusStatus {
|
|
|
1219
1221
|
error?: string
|
|
1220
1222
|
}
|
|
1221
1223
|
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1224
|
+
// Time-series sample types live in @skyhook-io/k8s-ui (shared with library
|
|
1225
|
+
// consumers). Re-export here so radar-app callers keep their existing import
|
|
1226
|
+
// paths; the Prom-prefixed names are deprecated aliases.
|
|
1227
|
+
export type {
|
|
1228
|
+
TimeSeriesPoint,
|
|
1229
|
+
TimeSeries,
|
|
1230
|
+
PrometheusDataPoint,
|
|
1231
|
+
PrometheusSeries,
|
|
1232
|
+
} from '@skyhook-io/k8s-ui/components/charts'
|
|
1226
1233
|
|
|
1227
|
-
|
|
1228
|
-
labels: Record<string, string>
|
|
1229
|
-
dataPoints: PrometheusDataPoint[]
|
|
1230
|
-
}
|
|
1234
|
+
import type { TimeSeries as ChartTimeSeries } from '@skyhook-io/k8s-ui/components/charts'
|
|
1231
1235
|
|
|
1232
1236
|
export interface PrometheusQueryResult {
|
|
1233
1237
|
resultType: string
|
|
1234
|
-
series:
|
|
1238
|
+
series: ChartTimeSeries[]
|
|
1235
1239
|
}
|
|
1236
1240
|
|
|
1237
1241
|
export interface PrometheusResourceMetrics {
|
|
@@ -1246,9 +1250,44 @@ export interface PrometheusResourceMetrics {
|
|
|
1246
1250
|
hint?: string // Contextual hint when results are empty (e.g. cri-docker label issues)
|
|
1247
1251
|
}
|
|
1248
1252
|
|
|
1249
|
-
export type PrometheusMetricCategory = 'cpu' | 'memory' | 'network_rx' | 'network_tx' | 'filesystem'
|
|
1253
|
+
export type PrometheusMetricCategory = 'cpu' | 'memory' | 'network_rx' | 'network_tx' | 'filesystem' | 'restarts'
|
|
1250
1254
|
export type PrometheusTimeRange = '10m' | '30m' | '1h' | '3h' | '6h' | '12h' | '24h' | '48h' | '7d' | '14d'
|
|
1251
1255
|
|
|
1256
|
+
// PVC usage at a moment in time, derived from kubelet_volume_stats_*.
|
|
1257
|
+
// HasData=false silently indicates the CSI driver doesn't report or Prom
|
|
1258
|
+
// isn't scraping kubelet endpoints — UI should hide the gauge in that case.
|
|
1259
|
+
export interface PrometheusPVCUsage {
|
|
1260
|
+
namespace: string
|
|
1261
|
+
name: string
|
|
1262
|
+
used: number
|
|
1263
|
+
capacity: number
|
|
1264
|
+
ratio: number
|
|
1265
|
+
hasData: boolean
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
export type RightsizingTone = 'ok' | 'info' | 'warning' | 'alert' | 'critical'
|
|
1269
|
+
|
|
1270
|
+
export interface RightsizingRow {
|
|
1271
|
+
container: string
|
|
1272
|
+
resource: 'cpu' | 'memory'
|
|
1273
|
+
currentRequest?: string
|
|
1274
|
+
currentLimit?: string
|
|
1275
|
+
p95?: string
|
|
1276
|
+
recommendedRequest?: string
|
|
1277
|
+
tone: RightsizingTone
|
|
1278
|
+
message: string
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
export interface PrometheusRightsizing {
|
|
1282
|
+
kind: string
|
|
1283
|
+
namespace: string
|
|
1284
|
+
name: string
|
|
1285
|
+
window: string
|
|
1286
|
+
sampleAvailable: boolean
|
|
1287
|
+
rows: RightsizingRow[]
|
|
1288
|
+
reason?: string
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1252
1291
|
// Check Prometheus availability
|
|
1253
1292
|
export function usePrometheusStatus() {
|
|
1254
1293
|
return useQuery<PrometheusStatus>({
|
|
@@ -1281,6 +1320,86 @@ export function usePrometheusConnect() {
|
|
|
1281
1320
|
})
|
|
1282
1321
|
}
|
|
1283
1322
|
|
|
1323
|
+
// Auto-discover Prometheus on first mount of any Prom-backed view, and
|
|
1324
|
+
// auto-reconnect across radar restarts on subsequent mounts.
|
|
1325
|
+
//
|
|
1326
|
+
// Two paths through this hook, both running once per cluster context per
|
|
1327
|
+
// component-instance:
|
|
1328
|
+
// 1. Cached path — localStorage flag means "Prom was discovered before on
|
|
1329
|
+
// this context". Probe fires immediately on mount; the user sees
|
|
1330
|
+
// charts populate without manual interaction.
|
|
1331
|
+
// 2. First-time path — no flag yet. Probe fires after a small delay so the
|
|
1332
|
+
// initial workload-view render lands before we hit the cluster network.
|
|
1333
|
+
// Behavior matches Lens / Headlamp defaults; the trade-off is one
|
|
1334
|
+
// cluster probe per session per fresh kubeconfig context.
|
|
1335
|
+
//
|
|
1336
|
+
// On success either way we set the flag, so subsequent mounts take path 1.
|
|
1337
|
+
// On failure we clear the flag (path 1) or leave it cleared (path 2) and
|
|
1338
|
+
// reset attemptedRef, so the existing "Discover Prometheus" CTA renders
|
|
1339
|
+
// once status refreshes. Manual interaction stays available as the fallback.
|
|
1340
|
+
//
|
|
1341
|
+
// localStorage is the right surface: connection intent is browser-local,
|
|
1342
|
+
// not a server-side preference, and we want it to persist across radar
|
|
1343
|
+
// restarts on the same port.
|
|
1344
|
+
const PROM_AUTOCONNECT_PREFIX = 'radar.prometheus.autoConnect:'
|
|
1345
|
+
// First-mount delay before probing the cluster. Chosen short enough that the
|
|
1346
|
+
// CTA → charts transition feels prompt, long enough that the probe doesn't
|
|
1347
|
+
// race the initial workload-view render.
|
|
1348
|
+
const PROM_FIRSTLAUNCH_PROBE_DELAY_MS = 500
|
|
1349
|
+
|
|
1350
|
+
function promAutoConnectKey(contextName: string): string {
|
|
1351
|
+
return `${PROM_AUTOCONNECT_PREFIX}${contextName}`
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
export function useAutoPromConnect(): void {
|
|
1355
|
+
const queryClient = useQueryClient()
|
|
1356
|
+
const { data: clusterInfo } = useClusterInfo()
|
|
1357
|
+
const { data: status, isLoading: statusLoading } = usePrometheusStatus()
|
|
1358
|
+
const attemptedRef = useRef<string | null>(null)
|
|
1359
|
+
|
|
1360
|
+
useEffect(() => {
|
|
1361
|
+
if (typeof window === 'undefined') return
|
|
1362
|
+
const context = clusterInfo?.context
|
|
1363
|
+
if (!context || statusLoading) return
|
|
1364
|
+
|
|
1365
|
+
// Persist the "we've connected here before" signal once a connection lands.
|
|
1366
|
+
if (status?.connected) {
|
|
1367
|
+
try { window.localStorage.setItem(promAutoConnectKey(context), '1') } catch {
|
|
1368
|
+
// localStorage can throw in some restricted browser modes — fail open.
|
|
1369
|
+
}
|
|
1370
|
+
return
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
if (attemptedRef.current === context) return
|
|
1374
|
+
let cached: string | null = null
|
|
1375
|
+
try { cached = window.localStorage.getItem(promAutoConnectKey(context)) } catch {
|
|
1376
|
+
cached = null
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
attemptedRef.current = context
|
|
1380
|
+
|
|
1381
|
+
// Cached path probes immediately; first-time path defers briefly so the
|
|
1382
|
+
// initial UI render isn't competing with the cluster network call.
|
|
1383
|
+
const delay = cached === '1' ? 0 : PROM_FIRSTLAUNCH_PROBE_DELAY_MS
|
|
1384
|
+
const timeout = window.setTimeout(() => {
|
|
1385
|
+
// Direct apiFetch (not via the usePrometheusConnect mutation) so the
|
|
1386
|
+
// meta-driven toast handler stays silent — the user didn't click anything.
|
|
1387
|
+
apiFetch(`${getApiBase()}/prometheus/connect`, { method: 'POST' })
|
|
1388
|
+
.then(resp => {
|
|
1389
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
|
1390
|
+
queryClient.invalidateQueries({ queryKey: ['prometheus-status'] })
|
|
1391
|
+
})
|
|
1392
|
+
.catch(() => {
|
|
1393
|
+
try { window.localStorage.removeItem(promAutoConnectKey(context)) } catch {
|
|
1394
|
+
// ignore — manual CTA will render once status refreshes
|
|
1395
|
+
}
|
|
1396
|
+
attemptedRef.current = null
|
|
1397
|
+
})
|
|
1398
|
+
}, delay)
|
|
1399
|
+
return () => window.clearTimeout(timeout)
|
|
1400
|
+
}, [clusterInfo?.context, status?.connected, statusLoading, queryClient])
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1284
1403
|
// Fetch Prometheus metrics for a resource
|
|
1285
1404
|
export function usePrometheusResourceMetrics(
|
|
1286
1405
|
kind: string,
|
|
@@ -1337,6 +1456,40 @@ export function usePrometheusClusterMetrics(
|
|
|
1337
1456
|
})
|
|
1338
1457
|
}
|
|
1339
1458
|
|
|
1459
|
+
// Fetch PVC usage. hasData=false when no series — UI should hide the gauge.
|
|
1460
|
+
export function usePrometheusPVCUsage(namespace: string, name: string, enabled = true) {
|
|
1461
|
+
return useQuery<PrometheusPVCUsage>({
|
|
1462
|
+
queryKey: ['prometheus-pvc-usage', namespace, name],
|
|
1463
|
+
queryFn: () => fetchJSON(`/prometheus/pvc/${namespace}/${name}`),
|
|
1464
|
+
enabled: enabled && Boolean(namespace && name),
|
|
1465
|
+
staleTime: 60000,
|
|
1466
|
+
refetchInterval: 120000,
|
|
1467
|
+
})
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
// Fetch rightsizing recommendations for a workload (Deployment / StatefulSet / DaemonSet).
|
|
1471
|
+
export function usePrometheusRightsizing(kind: string, namespace: string, name: string, enabled = true) {
|
|
1472
|
+
return useQuery<PrometheusRightsizing>({
|
|
1473
|
+
queryKey: ['prometheus-rightsizing', kind, namespace, name],
|
|
1474
|
+
queryFn: () => fetchJSON(`/prometheus/rightsizing/${kind}/${namespace}/${name}`),
|
|
1475
|
+
enabled: enabled && Boolean(kind && namespace && name),
|
|
1476
|
+
staleTime: 5 * 60 * 1000, // P95 over 24h is slow to shift; cache aggressively
|
|
1477
|
+
refetchInterval: 10 * 60 * 1000,
|
|
1478
|
+
})
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
// Raw PromQL query (range). Used by HPA charts for status_current_replicas etc.
|
|
1482
|
+
export function usePromQLRange(query: string, range: PrometheusTimeRange = '1h', enabled = true) {
|
|
1483
|
+
return useQuery<PrometheusQueryResult>({
|
|
1484
|
+
queryKey: ['promql-range', query, range],
|
|
1485
|
+
queryFn: () =>
|
|
1486
|
+
fetchJSON(`/prometheus/query?query=${encodeURIComponent(query)}&range=${range}`),
|
|
1487
|
+
enabled: enabled && Boolean(query),
|
|
1488
|
+
staleTime: 30000,
|
|
1489
|
+
refetchInterval: 60000,
|
|
1490
|
+
})
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1340
1493
|
// ============================================================================
|
|
1341
1494
|
// Pod Logs
|
|
1342
1495
|
// ============================================================================
|
|
@@ -2998,6 +3151,7 @@ export interface DiagnosticsSnapshot {
|
|
|
2998
3151
|
debugEvents: boolean
|
|
2999
3152
|
mcpEnabled: boolean
|
|
3000
3153
|
hasPrometheusURL: boolean
|
|
3154
|
+
hasPrometheusHeaders: boolean
|
|
3001
3155
|
}
|
|
3002
3156
|
recentErrors?: DiagErrorEntry[]
|
|
3003
3157
|
totalErrorsRecorded?: number
|
package/src/api/rbac.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { useQuery } from '@tanstack/react-query'
|
|
2
|
+
import type {
|
|
3
|
+
RBACSubjectResponse,
|
|
4
|
+
RBACRoleResponse,
|
|
5
|
+
RBACWhoamiResponse,
|
|
6
|
+
RBACNamespaceResponse,
|
|
7
|
+
} from '@skyhook-io/k8s-ui'
|
|
8
|
+
import { fetchJSON } from './client'
|
|
9
|
+
|
|
10
|
+
// /api/rbac/subject/{kind}/{namespace}/{name} (ServiceAccount)
|
|
11
|
+
// /api/rbac/subject/{kind}/{name} (User/Group — no namespace)
|
|
12
|
+
export function useRBACSubject(kind: 'ServiceAccount' | 'User' | 'Group', namespace: string, name: string, enabled = true) {
|
|
13
|
+
// Subject lookups depend on cluster-wide RBAC. They don't change often,
|
|
14
|
+
// and operators bouncing between Pod/SA pages re-hit the same SA. Use a
|
|
15
|
+
// 15s stale window so cross-page navigation is instant.
|
|
16
|
+
const path =
|
|
17
|
+
kind === 'ServiceAccount'
|
|
18
|
+
? `/rbac/subject/${kind}/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}`
|
|
19
|
+
: `/rbac/subject/${kind}/${encodeURIComponent(name)}`
|
|
20
|
+
return useQuery<RBACSubjectResponse>({
|
|
21
|
+
queryKey: ['rbac', 'subject', kind, namespace, name],
|
|
22
|
+
queryFn: () => fetchJSON<RBACSubjectResponse>(path),
|
|
23
|
+
enabled: enabled && !!name && (kind !== 'ServiceAccount' || !!namespace),
|
|
24
|
+
staleTime: 15000,
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// /api/rbac/role/{kind}/{namespace}/{name} (use "_" for ClusterRole's empty namespace)
|
|
29
|
+
export function useRBACRole(kind: 'Role' | 'ClusterRole', namespace: string, name: string, enabled = true) {
|
|
30
|
+
const nsSegment = kind === 'ClusterRole' ? '_' : encodeURIComponent(namespace)
|
|
31
|
+
return useQuery<RBACRoleResponse>({
|
|
32
|
+
queryKey: ['rbac', 'role', kind, namespace, name],
|
|
33
|
+
queryFn: () => fetchJSON<RBACRoleResponse>(`/rbac/role/${kind}/${nsSegment}/${encodeURIComponent(name)}`),
|
|
34
|
+
enabled: enabled && !!name && (kind !== 'Role' || !!namespace),
|
|
35
|
+
staleTime: 15000,
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// /api/rbac/namespace/{namespace}
|
|
40
|
+
export function useRBACNamespace(namespace: string, enabled = true) {
|
|
41
|
+
return useQuery<RBACNamespaceResponse>({
|
|
42
|
+
queryKey: ['rbac', 'namespace', namespace],
|
|
43
|
+
queryFn: () => fetchJSON<RBACNamespaceResponse>(`/rbac/namespace/${encodeURIComponent(namespace)}`),
|
|
44
|
+
enabled: enabled && !!namespace,
|
|
45
|
+
staleTime: 15000,
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// /api/rbac/whoami?namespace=<ns>
|
|
50
|
+
export function useRBACWhoami(namespace: string, enabled = true) {
|
|
51
|
+
return useQuery<RBACWhoamiResponse>({
|
|
52
|
+
queryKey: ['rbac', 'whoami', namespace],
|
|
53
|
+
queryFn: () => fetchJSON<RBACWhoamiResponse>(`/rbac/whoami?namespace=${encodeURIComponent(namespace)}`),
|
|
54
|
+
enabled: enabled && !!namespace,
|
|
55
|
+
staleTime: 30000,
|
|
56
|
+
})
|
|
57
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { useCallback, useState } from 'react'
|
|
2
|
+
import { useNavigate, useSearchParams } from 'react-router-dom'
|
|
3
|
+
import {
|
|
4
|
+
ResourceCompareView,
|
|
5
|
+
CompareResourcePicker,
|
|
6
|
+
parseRef,
|
|
7
|
+
refToParam,
|
|
8
|
+
type CompareResourceRef,
|
|
9
|
+
type CompareSide,
|
|
10
|
+
type CompareSideError,
|
|
11
|
+
} from '@skyhook-io/k8s-ui'
|
|
12
|
+
import { useResource } from '../../api/client'
|
|
13
|
+
import { useTheme } from '../../context/ThemeContext'
|
|
14
|
+
import { useCompareCandidates } from './useCompareCandidates'
|
|
15
|
+
|
|
16
|
+
export function CompareViewRoute() {
|
|
17
|
+
const navigate = useNavigate()
|
|
18
|
+
const [searchParams, setSearchParams] = useSearchParams()
|
|
19
|
+
const { theme } = useTheme()
|
|
20
|
+
|
|
21
|
+
const kind = (searchParams.get('kind') ?? '').toLowerCase()
|
|
22
|
+
// Matches Radar's repo-wide URL convention. The bare `group` param is
|
|
23
|
+
// reserved for topology grouping mode and gets stripped by App.tsx's URL
|
|
24
|
+
// sync on every non-topology view.
|
|
25
|
+
const group = searchParams.get('apiGroup') ?? undefined
|
|
26
|
+
const aParsed = parseRef(searchParams.get('a'))
|
|
27
|
+
const bParsed = parseRef(searchParams.get('b'))
|
|
28
|
+
|
|
29
|
+
const [pickerOpen, setPickerOpen] = useState<CompareSide | null>(null)
|
|
30
|
+
|
|
31
|
+
const a: CompareResourceRef | null = aParsed ? { kind, namespace: aParsed.namespace, name: aParsed.name, group } : null
|
|
32
|
+
const b: CompareResourceRef | null = bParsed ? { kind, namespace: bParsed.namespace, name: bParsed.name, group } : null
|
|
33
|
+
|
|
34
|
+
const aQuery = useResource<unknown>(a?.kind ?? '', a?.namespace ?? '', a?.name ?? '', a?.group)
|
|
35
|
+
const bQuery = useResource<unknown>(b?.kind ?? '', b?.namespace ?? '', b?.name ?? '', b?.group)
|
|
36
|
+
|
|
37
|
+
const { candidates, isPending: candidatesPending, error: candidatesError } = useCompareCandidates(kind, group, !!pickerOpen)
|
|
38
|
+
|
|
39
|
+
const updateParam = useCallback(
|
|
40
|
+
(next: Record<string, string>) => {
|
|
41
|
+
const params = new URLSearchParams(searchParams)
|
|
42
|
+
for (const [k, v] of Object.entries(next)) params.set(k, v)
|
|
43
|
+
setSearchParams(params, { replace: true })
|
|
44
|
+
},
|
|
45
|
+
[searchParams, setSearchParams],
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
const handleSwap = useCallback(() => {
|
|
49
|
+
if (!a || !b) return
|
|
50
|
+
updateParam({ a: refToParam(b), b: refToParam(a) })
|
|
51
|
+
}, [a, b, updateParam])
|
|
52
|
+
|
|
53
|
+
const handleClose = useCallback(() => {
|
|
54
|
+
navigate(-1)
|
|
55
|
+
}, [navigate])
|
|
56
|
+
|
|
57
|
+
const handlePick = useCallback(
|
|
58
|
+
(picked: CompareResourceRef) => {
|
|
59
|
+
if (!pickerOpen) return
|
|
60
|
+
updateParam({ [pickerOpen]: refToParam({ namespace: picked.namespace, name: picked.name }) })
|
|
61
|
+
setPickerOpen(null)
|
|
62
|
+
},
|
|
63
|
+
[pickerOpen, updateParam],
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
if (!kind || !a || !b) {
|
|
67
|
+
return (
|
|
68
|
+
<div className="flex flex-col items-center justify-center h-full text-theme-text-secondary gap-3 p-8">
|
|
69
|
+
<p className="text-sm">This compare link is missing required parameters.</p>
|
|
70
|
+
<button
|
|
71
|
+
onClick={() => navigate('/resources')}
|
|
72
|
+
className="px-3 py-1.5 text-xs font-medium btn-brand rounded-lg"
|
|
73
|
+
>
|
|
74
|
+
Back to resources
|
|
75
|
+
</button>
|
|
76
|
+
</div>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// A refetch failure with cached data is not worth shouting about — show the
|
|
81
|
+
// stale data instead of blanking the side with a misleading "failed" banner.
|
|
82
|
+
const errors: CompareSideError[] = []
|
|
83
|
+
if (aQuery.error && !aQuery.data) errors.push({ side: 'a', message: aQuery.error instanceof Error ? aQuery.error.message : String(aQuery.error) })
|
|
84
|
+
if (bQuery.error && !bQuery.data) errors.push({ side: 'b', message: bQuery.error instanceof Error ? bQuery.error.message : String(bQuery.error) })
|
|
85
|
+
|
|
86
|
+
const source = pickerOpen === 'a' ? a : pickerOpen === 'b' ? b : null
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<>
|
|
90
|
+
<ResourceCompareView
|
|
91
|
+
a={a}
|
|
92
|
+
b={b}
|
|
93
|
+
aData={aQuery.data}
|
|
94
|
+
bData={bQuery.data}
|
|
95
|
+
errors={errors}
|
|
96
|
+
editorTheme={theme === 'dark' ? 'vs-dark' : 'vs'}
|
|
97
|
+
onSwap={handleSwap}
|
|
98
|
+
onClose={handleClose}
|
|
99
|
+
onChangeA={() => setPickerOpen('a')}
|
|
100
|
+
onChangeB={() => setPickerOpen('b')}
|
|
101
|
+
/>
|
|
102
|
+
{source && pickerOpen && (
|
|
103
|
+
<CompareResourcePicker
|
|
104
|
+
open={true}
|
|
105
|
+
onClose={() => setPickerOpen(null)}
|
|
106
|
+
source={source}
|
|
107
|
+
sourceSide={pickerOpen}
|
|
108
|
+
candidates={candidates}
|
|
109
|
+
loading={candidatesPending}
|
|
110
|
+
error={candidatesError}
|
|
111
|
+
onPick={handlePick}
|
|
112
|
+
/>
|
|
113
|
+
)}
|
|
114
|
+
</>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
import type { CompareResourceRef } from '@skyhook-io/k8s-ui'
|
|
3
|
+
import { useResources } from '../../api/client'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Fetch candidates for the compare picker — same kind as the source.
|
|
7
|
+
* Pass `enabled=false` when the picker is closed to avoid hitting the API.
|
|
8
|
+
*/
|
|
9
|
+
export function useCompareCandidates(kind: string, group: string | undefined, enabled: boolean) {
|
|
10
|
+
const query = useResources<{ metadata?: { name?: string; namespace?: string } }>(
|
|
11
|
+
enabled ? kind : '',
|
|
12
|
+
undefined,
|
|
13
|
+
group,
|
|
14
|
+
)
|
|
15
|
+
const candidates: CompareResourceRef[] = useMemo(() => {
|
|
16
|
+
if (!query.data) return []
|
|
17
|
+
return query.data
|
|
18
|
+
.filter(r => r?.metadata?.name)
|
|
19
|
+
.map(r => ({
|
|
20
|
+
kind,
|
|
21
|
+
namespace: r.metadata?.namespace ?? '',
|
|
22
|
+
name: r.metadata!.name!,
|
|
23
|
+
group,
|
|
24
|
+
}))
|
|
25
|
+
}, [query.data, kind, group])
|
|
26
|
+
return { candidates, isPending: query.isPending, error: query.error }
|
|
27
|
+
}
|