@skyhook-io/radar-app 1.8.0 → 1.8.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 -2
- package/src/App.tsx +92 -14
- package/src/api/client.ts +3 -3
- package/src/components/DebugOverlay.tsx +1 -1
- package/src/components/applications/ApplicationsView.tsx +1 -1
- package/src/components/compare/CompareViewRoute.tsx +13 -5
- package/src/components/cost/CostTrendChart.tsx +3 -3
- package/src/components/cost/CostView.tsx +1 -1
- package/src/components/helm/ChartBrowser.tsx +1 -1
- package/src/components/helm/InstallWizard.tsx +1 -1
- package/src/components/home/ActivitySummary.tsx +4 -1
- package/src/components/issues/IssuesPane.tsx +2 -2
- package/src/components/portforward/PortForwardManager.tsx +70 -8
- package/src/components/resource/PrometheusChartsGrid.tsx +1 -1
- package/src/components/resources/ResourcesView.tsx +2 -0
- package/src/components/settings/MyPermissionsDialog.tsx +64 -4
- package/src/components/shared/LargeClusterNamespacePicker.tsx +1 -1
- package/src/components/traffic/TrafficGraph.tsx +29 -20
- package/src/components/traffic/TrafficView.tsx +3 -3
- package/src/components/ui/DiagnosticsOverlay.tsx +1 -1
- package/src/components/workload/WorkloadView.tsx +17 -17
- package/src/main.tsx +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyhook-io/radar-app",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.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",
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"@tailwindcss/typography": "^0.5.20",
|
|
58
58
|
"@tailwindcss/vite": "^4.3.1",
|
|
59
59
|
"@tanstack/react-query": "^5.100.14",
|
|
60
|
-
"@types/node": "^
|
|
60
|
+
"@types/node": "^26.0.0",
|
|
61
61
|
"@types/react": "^19.2.17",
|
|
62
62
|
"@types/react-dom": "^19.2.3",
|
|
63
63
|
"@vitejs/plugin-react": "^6.0.2",
|
package/src/App.tsx
CHANGED
|
@@ -138,14 +138,19 @@ function AuthBarrier({ authMode }: { authMode: string }) {
|
|
|
138
138
|
src={radarLoadingIcon}
|
|
139
139
|
alt=""
|
|
140
140
|
aria-hidden
|
|
141
|
-
|
|
141
|
+
// Integer offset (vw/2 − 22) — matches the Connecting/Opening splashes;
|
|
142
|
+
// avoids sub-pixel jitter from translate(-50%) on odd-width viewports.
|
|
143
|
+
className="absolute w-11 h-11"
|
|
144
|
+
style={{ left: 'calc(50% - 22px)', top: 'calc(50% - 22px)' }}
|
|
142
145
|
/>
|
|
143
|
-
<
|
|
144
|
-
className="absolute left-1/2 -translate-x-1/2
|
|
146
|
+
<div
|
|
147
|
+
className="absolute left-1/2 -translate-x-1/2 text-center"
|
|
145
148
|
style={{ top: 'calc(50% + 34px)' }}
|
|
146
149
|
>
|
|
147
|
-
|
|
148
|
-
|
|
150
|
+
<p className="whitespace-nowrap text-[17px] font-semibold tracking-tight text-theme-text-primary">
|
|
151
|
+
Redirecting to login…
|
|
152
|
+
</p>
|
|
153
|
+
</div>
|
|
149
154
|
</div>
|
|
150
155
|
</div>
|
|
151
156
|
)
|
|
@@ -171,6 +176,15 @@ function AuthBarrier({ authMode }: { authMode: string }) {
|
|
|
171
176
|
)
|
|
172
177
|
}
|
|
173
178
|
|
|
179
|
+
// Identity of the "page" a non-URL-backed peek drawer belongs to. Pathname alone
|
|
180
|
+
// is not enough: Applications keeps the list and an app's detail on the same
|
|
181
|
+
// `/applications` pathname and distinguishes them with `?app=`, so a Back from
|
|
182
|
+
// detail to list would otherwise leave the peek orphaned. Only `app` is included
|
|
183
|
+
// (not the whole query) so filter/tab/namespace churn doesn't close the peek.
|
|
184
|
+
function peekOwnerKey(pathname: string, search: string): string {
|
|
185
|
+
return `${pathname}${new URLSearchParams(search).get('app') ?? ''}`
|
|
186
|
+
}
|
|
187
|
+
|
|
174
188
|
function AppInner() {
|
|
175
189
|
const navigate = useNavigate()
|
|
176
190
|
const location = useLocation()
|
|
@@ -410,6 +424,9 @@ function AppInner() {
|
|
|
410
424
|
// selected drawer resource. This covers both in-view kind switches and
|
|
411
425
|
// cross-kind navigations from expanded drawers (for example Node -> View Pods).
|
|
412
426
|
const prevResourcesKindKeyRef = useRef<string | null>(null)
|
|
427
|
+
// Owner-key (pathname + ?app) a non-URL-backed peek was opened on; see
|
|
428
|
+
// navigateToResource and peekOwnerKey.
|
|
429
|
+
const peekOwnerKeyRef = useRef<string | null>(null)
|
|
413
430
|
const currentResourceKindSlug = normalizedResourcesKindSlug.toLowerCase()
|
|
414
431
|
const currentResourceGroup = searchParams.get('apiGroup') ?? ''
|
|
415
432
|
const selectedResourceKindSlug = selectedResource ? kindToPlural(selectedResource.kind).toLowerCase() : ''
|
|
@@ -421,9 +438,28 @@ function AppInner() {
|
|
|
421
438
|
const resourcesKindRouteChanged = mainView === 'resources' &&
|
|
422
439
|
prevResourcesKindKeyRef.current !== null &&
|
|
423
440
|
prevResourcesKindKeyRef.current !== `${currentResourceGroup}/${currentResourceKindSlug}`
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
441
|
+
|
|
442
|
+
// A peek opened outside /resources (topology, GitOps, Applications) carries no
|
|
443
|
+
// URL backing, so the only signal that the page beneath it has navigated is
|
|
444
|
+
// that its owner-key (pathname + ?app) no longer matches where it was opened.
|
|
445
|
+
// Hiding it here, at render time, closes the orphan on Back without adding
|
|
446
|
+
// another clearing effect that would race the `suppressViewClearRef` lifecycle.
|
|
447
|
+
// The /resources case is URL-backed and handled above; an expanded drawer
|
|
448
|
+
// (drawerExpanded) legitimately lives at /workload and must stay open there.
|
|
449
|
+
const peekRouteOrphaned = !!selectedResource && !drawerExpanded && mainView !== 'resources' &&
|
|
450
|
+
peekOwnerKeyRef.current !== null &&
|
|
451
|
+
peekOwnerKeyRef.current !== peekOwnerKey(location.pathname, location.search)
|
|
452
|
+
|
|
453
|
+
// In Applications the inline WorkloadView (?workload) and the peek drawer are
|
|
454
|
+
// mutually exclusive — never two detail surfaces at once. ?workload is the
|
|
455
|
+
// single source of truth: while it's set the peek yields to the inline view.
|
|
456
|
+
// (Opening a child peek from Applications clears ?workload, see onOpenResource.)
|
|
457
|
+
const appsInlineWorkloadActive = mainView === 'applications' && searchParams.has('workload')
|
|
458
|
+
|
|
459
|
+
const routeSelectedResource =
|
|
460
|
+
(resourcesKindRouteChanged && selectedResourceRouteMismatch) || peekRouteOrphaned || appsInlineWorkloadActive
|
|
461
|
+
? null
|
|
462
|
+
: selectedResource
|
|
427
463
|
|
|
428
464
|
useEffect(() => {
|
|
429
465
|
if (mainView !== 'resources') {
|
|
@@ -458,6 +494,13 @@ function AppInner() {
|
|
|
458
494
|
|
|
459
495
|
// Navigate to a resource — uses View Transitions cross-fade when drawer is already open
|
|
460
496
|
const navigateToResource = useCallback((res: SelectedResource, tab: 'detail' | 'yaml' = 'detail') => {
|
|
497
|
+
// Record the page this peek was opened on. Outside /resources the drawer is
|
|
498
|
+
// not URL-backed, so this ref is what lets the render-time gate below close
|
|
499
|
+
// the peek when the page under it changes (e.g. browser Back off a GitOps
|
|
500
|
+
// detail page, or Applications detail → list via ?app). window.location is
|
|
501
|
+
// read (not the `location` closure) so the value is always current
|
|
502
|
+
// regardless of this callback's memoization.
|
|
503
|
+
peekOwnerKeyRef.current = peekOwnerKey(window.location.pathname, window.location.search)
|
|
461
504
|
const update = () => { setDrawerInitialTab(tab); setSelectedResource(res) }
|
|
462
505
|
// Skip the cross-fade animation entirely on first open (no
|
|
463
506
|
// `selectedResource`); otherwise route through
|
|
@@ -920,7 +963,7 @@ function AppInner() {
|
|
|
920
963
|
name: node.name,
|
|
921
964
|
group: apiVersionToGroup(node.data.apiVersion as string | undefined),
|
|
922
965
|
})
|
|
923
|
-
}, [navigate])
|
|
966
|
+
}, [navigate, navigateToResource])
|
|
924
967
|
|
|
925
968
|
// Serialize namespaces for stable dependency tracking
|
|
926
969
|
const namespacesKey = namespaces.join(',')
|
|
@@ -1136,6 +1179,11 @@ function AppInner() {
|
|
|
1136
1179
|
// Switching to specific namespaces - disable namespace grouping
|
|
1137
1180
|
setGroupingMode('none')
|
|
1138
1181
|
}
|
|
1182
|
+
// Intentionally runs ONLY when the namespace selection changes. It reads the
|
|
1183
|
+
// current groupingMode but must not re-run when grouping changes, or it would
|
|
1184
|
+
// immediately revert a manual/fleet grouping choice. namespacesKey is the
|
|
1185
|
+
// manual dependency standing in for the namespaces array.
|
|
1186
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1139
1187
|
}, [namespacesKey])
|
|
1140
1188
|
|
|
1141
1189
|
// Clear resource selection when changing views or namespaces
|
|
@@ -1829,7 +1877,10 @@ function AppInner() {
|
|
|
1829
1877
|
<GitOpsView
|
|
1830
1878
|
namespaces={namespaces}
|
|
1831
1879
|
onOpenResource={(resource) => {
|
|
1832
|
-
|
|
1880
|
+
// Route through navigateToResource so the peek records the page it
|
|
1881
|
+
// opened on — that's what lets Back off the GitOps detail page close
|
|
1882
|
+
// the drawer instead of orphaning it on the list.
|
|
1883
|
+
navigateToResource(resource)
|
|
1833
1884
|
}}
|
|
1834
1885
|
onClearNamespaces={clearAllNamespaces}
|
|
1835
1886
|
/>
|
|
@@ -1840,7 +1891,17 @@ function AppInner() {
|
|
|
1840
1891
|
<ApplicationsView
|
|
1841
1892
|
namespaces={namespaces}
|
|
1842
1893
|
onOpenResource={(resource) => {
|
|
1843
|
-
|
|
1894
|
+
// The peek and the inline WorkloadView are mutually exclusive: drop
|
|
1895
|
+
// the inline workload selection so the app graph (not a second
|
|
1896
|
+
// detail panel) sits behind the peek. Search-only change keeps the
|
|
1897
|
+
// pathname — and thus the peek's owner-path — intact.
|
|
1898
|
+
const params = new URLSearchParams(window.location.search)
|
|
1899
|
+
if (params.has('workload') || params.has('tab')) {
|
|
1900
|
+
params.delete('workload')
|
|
1901
|
+
params.delete('tab')
|
|
1902
|
+
navigate({ pathname: window.location.pathname, search: params.toString() }, { replace: true })
|
|
1903
|
+
}
|
|
1904
|
+
navigateToResource(resource)
|
|
1844
1905
|
}}
|
|
1845
1906
|
/>
|
|
1846
1907
|
)}
|
|
@@ -1861,9 +1922,26 @@ function AppInner() {
|
|
|
1861
1922
|
own fetches) while the cross-document nav lands. Covers checks /
|
|
1862
1923
|
issues / gitops with one block since only one view is active. */}
|
|
1863
1924
|
{viewTakeoverHref && (
|
|
1864
|
-
<div className="flex-1
|
|
1865
|
-
|
|
1866
|
-
|
|
1925
|
+
<div className="flex-1 relative bg-theme-base">
|
|
1926
|
+
{/* Viewport-anchored, 17px — identical to the "Connecting" splash so
|
|
1927
|
+
the mark doesn't move or resize across the takeover hand-off. */}
|
|
1928
|
+
<div className="fixed inset-0 pointer-events-none">
|
|
1929
|
+
<img
|
|
1930
|
+
src={radarLoadingIcon}
|
|
1931
|
+
alt=""
|
|
1932
|
+
aria-hidden
|
|
1933
|
+
className="absolute w-11 h-11"
|
|
1934
|
+
style={{ left: 'calc(50% - 22px)', top: 'calc(50% - 22px)' }}
|
|
1935
|
+
/>
|
|
1936
|
+
<div
|
|
1937
|
+
className="absolute left-1/2 -translate-x-1/2 text-center"
|
|
1938
|
+
style={{ top: 'calc(50% + 34px)' }}
|
|
1939
|
+
>
|
|
1940
|
+
<p className="whitespace-nowrap text-[17px] font-semibold tracking-tight text-theme-text-primary">
|
|
1941
|
+
Opening…
|
|
1942
|
+
</p>
|
|
1943
|
+
</div>
|
|
1944
|
+
</div>
|
|
1867
1945
|
</div>
|
|
1868
1946
|
)}
|
|
1869
1947
|
|
package/src/api/client.ts
CHANGED
|
@@ -1509,7 +1509,7 @@ export function useAutoPromConnect(): void {
|
|
|
1509
1509
|
if (attemptedRef.current === context) return
|
|
1510
1510
|
let cached: string | null = null
|
|
1511
1511
|
try { cached = window.localStorage.getItem(promAutoConnectKey(context)) } catch {
|
|
1512
|
-
|
|
1512
|
+
// keep the null fallback
|
|
1513
1513
|
}
|
|
1514
1514
|
|
|
1515
1515
|
attemptedRef.current = context
|
|
@@ -3048,7 +3048,7 @@ export function useSwitchContext() {
|
|
|
3048
3048
|
} catch (error) {
|
|
3049
3049
|
clearTimeout(timeoutId)
|
|
3050
3050
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
3051
|
-
throw new Error('Context switch timed out. The cluster may be unreachable.')
|
|
3051
|
+
throw new Error('Context switch timed out. The cluster may be unreachable.', { cause: error })
|
|
3052
3052
|
}
|
|
3053
3053
|
throw error
|
|
3054
3054
|
}
|
|
@@ -3151,7 +3151,7 @@ export function useSetActiveNamespace() {
|
|
|
3151
3151
|
error: error instanceof Error ? error.message : String(error),
|
|
3152
3152
|
})
|
|
3153
3153
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
3154
|
-
throw new Error('Namespace switch timed out. The cluster may be unreachable.')
|
|
3154
|
+
throw new Error('Namespace switch timed out. The cluster may be unreachable.', { cause: error })
|
|
3155
3155
|
}
|
|
3156
3156
|
throw error
|
|
3157
3157
|
}
|
|
@@ -28,7 +28,7 @@ interface ApplicationsViewProps {
|
|
|
28
28
|
|
|
29
29
|
export function ApplicationsView({ namespaces, onOpenResource }: ApplicationsViewProps) {
|
|
30
30
|
const query = useApplications(namespaces)
|
|
31
|
-
const apps = query.data?.applications ?? []
|
|
31
|
+
const apps = useMemo(() => query.data?.applications ?? [], [query.data])
|
|
32
32
|
|
|
33
33
|
// Which app is open lives in the URL (?app=<key>) so the detail view is
|
|
34
34
|
// deep-linkable and the browser back button returns to the list. Opening or
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useState } from 'react'
|
|
1
|
+
import { useCallback, useMemo, useState } from 'react'
|
|
2
2
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
|
3
3
|
import {
|
|
4
4
|
ResourceCompareView,
|
|
@@ -23,13 +23,21 @@ export function CompareViewRoute() {
|
|
|
23
23
|
// reserved for topology grouping mode and gets stripped by App.tsx's URL
|
|
24
24
|
// sync on every non-topology view.
|
|
25
25
|
const group = searchParams.get('apiGroup') ?? undefined
|
|
26
|
-
const
|
|
27
|
-
const
|
|
26
|
+
const aRaw = searchParams.get('a')
|
|
27
|
+
const bRaw = searchParams.get('b')
|
|
28
28
|
|
|
29
29
|
const [pickerOpen, setPickerOpen] = useState<CompareSide | null>(null)
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
// Memoized so the refs keep a stable identity across renders — otherwise the
|
|
32
|
+
// useCallbacks below (which depend on a/b) would be rebuilt every render.
|
|
33
|
+
const a: CompareResourceRef | null = useMemo(() => {
|
|
34
|
+
const p = parseRef(aRaw)
|
|
35
|
+
return p ? { kind, namespace: p.namespace, name: p.name, group } : null
|
|
36
|
+
}, [aRaw, kind, group])
|
|
37
|
+
const b: CompareResourceRef | null = useMemo(() => {
|
|
38
|
+
const p = parseRef(bRaw)
|
|
39
|
+
return p ? { kind, namespace: p.namespace, name: p.name, group } : null
|
|
40
|
+
}, [bRaw, kind, group])
|
|
33
41
|
|
|
34
42
|
const aQuery = useResource<unknown>(a?.kind ?? '', a?.namespace ?? '', a?.name ?? '', a?.group)
|
|
35
43
|
const bQuery = useResource<unknown>(b?.kind ?? '', b?.namespace ?? '', b?.name ?? '', b?.group)
|
|
@@ -34,7 +34,7 @@ export function CostTrendChart() {
|
|
|
34
34
|
<div className="rounded-lg border border-theme-border bg-theme-surface/50 p-4">
|
|
35
35
|
<div className="flex items-center justify-center h-[200px] text-theme-text-tertiary">
|
|
36
36
|
<Loader2 className="w-5 h-5 animate-spin mr-2" />
|
|
37
|
-
Loading cost trend
|
|
37
|
+
Loading cost trend…
|
|
38
38
|
</div>
|
|
39
39
|
</div>
|
|
40
40
|
)
|
|
@@ -164,7 +164,7 @@ function StackedAreaChart({ series }: { series: OpenCostTrendSeries[] }) {
|
|
|
164
164
|
})
|
|
165
165
|
|
|
166
166
|
return { timestamps, stacked, minTs, maxTs, yMax, seriesLookups, toX, toY, yTicks, xTicks, paths }
|
|
167
|
-
}, [series])
|
|
167
|
+
}, [series, plotHeight, plotWidth])
|
|
168
168
|
|
|
169
169
|
// Hover data — depends on hoverX + chartData, must be a separate hook (called unconditionally)
|
|
170
170
|
const hoverData = useMemo(() => {
|
|
@@ -193,7 +193,7 @@ function StackedAreaChart({ series }: { series: OpenCostTrendSeries[] }) {
|
|
|
193
193
|
})
|
|
194
194
|
|
|
195
195
|
return { ts: closestTs, x: toX(closestTs), total, points }
|
|
196
|
-
}, [hoverX, chartData, series])
|
|
196
|
+
}, [hoverX, chartData, series, plotWidth])
|
|
197
197
|
|
|
198
198
|
const handleMouseMove = useCallback((e: React.MouseEvent<SVGRectElement>) => {
|
|
199
199
|
const svg = svgRef.current
|
|
@@ -282,7 +282,7 @@ function WorkloadRows({ namespace }: { namespace: string }) {
|
|
|
282
282
|
return (
|
|
283
283
|
<div className="px-4 py-3 flex items-center gap-2 text-xs text-theme-text-tertiary bg-theme-elevated/30">
|
|
284
284
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
285
|
-
Loading workloads
|
|
285
|
+
Loading workloads…
|
|
286
286
|
</div>
|
|
287
287
|
)
|
|
288
288
|
}
|
|
@@ -190,7 +190,7 @@ export function ChartBrowser({ onChartSelect }: ChartBrowserProps) {
|
|
|
190
190
|
</button>
|
|
191
191
|
<div className="border-t border-theme-border my-1" />
|
|
192
192
|
{reposLoading ? (
|
|
193
|
-
<div className="px-3 py-2 text-sm text-theme-text-tertiary">Loading
|
|
193
|
+
<div className="px-3 py-2 text-sm text-theme-text-tertiary">Loading…</div>
|
|
194
194
|
) : repositories?.length === 0 ? (
|
|
195
195
|
<div className="px-3 py-2 text-sm text-theme-text-tertiary">No repositories configured</div>
|
|
196
196
|
) : (
|
|
@@ -191,7 +191,7 @@ export function InstallWizard({ repo, chartName, version, source, repoUrl, defau
|
|
|
191
191
|
} finally {
|
|
192
192
|
setIsInstalling(false)
|
|
193
193
|
}
|
|
194
|
-
}, [releaseName, namespace, chartName, version, repo, valuesYaml, createNamespace, onSuccess, isLocal, artifactHubDetail, queryClient])
|
|
194
|
+
}, [releaseName, namespace, chartName, version, repo, repoUrl, valuesYaml, createNamespace, onSuccess, isLocal, artifactHubDetail, queryClient])
|
|
195
195
|
|
|
196
196
|
// Validate release name + namespace before letting the user
|
|
197
197
|
// advance. Without this, a name like "Invalid Name With Spaces!"
|
|
@@ -82,6 +82,9 @@ export function ActivitySummary({ namespaces, topology, onNavigate }: ActivitySu
|
|
|
82
82
|
limit: 1000,
|
|
83
83
|
})
|
|
84
84
|
|
|
85
|
+
// Intentionally re-sample 'now' only when events refresh (not every render),
|
|
86
|
+
// so the timeline window stays stable between data updates.
|
|
87
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
85
88
|
const now = useMemo(() => Date.now(), [events])
|
|
86
89
|
const spanMs = SPAN_MINUTES * 60 * 1000
|
|
87
90
|
const startTime = now - spanMs
|
|
@@ -125,7 +128,7 @@ export function ActivitySummary({ namespaces, topology, onNavigate }: ActivitySu
|
|
|
125
128
|
<div className="flex-1 min-h-0 overflow-hidden px-4 py-1.5">
|
|
126
129
|
{isLoading ? (
|
|
127
130
|
<div className="flex items-center justify-center h-full py-4 text-xs text-theme-text-tertiary">
|
|
128
|
-
Loading
|
|
131
|
+
Loading…
|
|
129
132
|
</div>
|
|
130
133
|
) : error ? (
|
|
131
134
|
<div className="flex items-center justify-center h-full py-4 text-xs text-theme-text-tertiary">
|
|
@@ -33,7 +33,7 @@ export function IssuesPane({ namespaces, onNavigateToResource }: IssuesPaneProps
|
|
|
33
33
|
const { data, isLoading, error } = useIssues(namespaces)
|
|
34
34
|
const [severityFilter, setSeverityFilter] = useState<Set<IssueSeverity>>(new Set())
|
|
35
35
|
|
|
36
|
-
const allIssues = data?.issues ?? []
|
|
36
|
+
const allIssues = useMemo(() => data?.issues ?? [], [data])
|
|
37
37
|
const totals = useMemo(() => {
|
|
38
38
|
const t: Record<IssueSeverity, number> = { critical: 0, warning: 0 }
|
|
39
39
|
for (const i of allIssues) t[i.severity] = (t[i.severity] ?? 0) + 1
|
|
@@ -44,7 +44,7 @@ export function IssuesPane({ namespaces, onNavigateToResource }: IssuesPaneProps
|
|
|
44
44
|
const toggleSeverity = (s: IssueSeverity) =>
|
|
45
45
|
setSeverityFilter((prev) => {
|
|
46
46
|
const next = new Set(prev)
|
|
47
|
-
next.has(s)
|
|
47
|
+
if (next.has(s)) next.delete(s); else next.add(s)
|
|
48
48
|
return next
|
|
49
49
|
})
|
|
50
50
|
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
Globe,
|
|
21
21
|
Monitor,
|
|
22
22
|
PenLine,
|
|
23
|
+
RotateCw,
|
|
23
24
|
} from 'lucide-react'
|
|
24
25
|
import { clsx } from 'clsx'
|
|
25
26
|
// CSS_EASE (the shared spring curve) is intentionally NOT used for this panel —
|
|
@@ -30,6 +31,7 @@ import { Tooltip } from '../ui/Tooltip'
|
|
|
30
31
|
import { useToast } from '../ui/Toast'
|
|
31
32
|
import { openExternal } from '../../utils/navigation'
|
|
32
33
|
import { apiUrl } from '../../api/config'
|
|
34
|
+
import { apiFetch } from '../../api/client'
|
|
33
35
|
import { pluralize } from '@skyhook-io/k8s-ui'
|
|
34
36
|
|
|
35
37
|
// --- Types -------------------------------------------------------------------
|
|
@@ -86,7 +88,7 @@ function usePortForwardQuery() {
|
|
|
86
88
|
return useQuery<PortForwardSession[]>({
|
|
87
89
|
queryKey: ['portforwards'],
|
|
88
90
|
queryFn: async () => {
|
|
89
|
-
const res = await
|
|
91
|
+
const res = await apiFetch(apiUrl('/portforwards'))
|
|
90
92
|
if (!res.ok) throw new Error('Failed to fetch port forwards')
|
|
91
93
|
return res.json()
|
|
92
94
|
},
|
|
@@ -390,6 +392,9 @@ export function PortForwardPanel() {
|
|
|
390
392
|
// without disabling all stop buttons (the old shared-mutation approach blocked
|
|
391
393
|
// every row when any single stop was in-flight).
|
|
392
394
|
const [stoppingIds, setStoppingIds] = useState<Set<string>>(() => new Set())
|
|
395
|
+
// Per-session retry tracking — same rationale as stoppingIds: multiple failed
|
|
396
|
+
// forwards can be retried independently without disabling every retry button.
|
|
397
|
+
const [retryingIds, setRetryingIds] = useState<Set<string>>(() => new Set())
|
|
393
398
|
const queryClient = useQueryClient()
|
|
394
399
|
const { showSuccess, showError } = useToast()
|
|
395
400
|
|
|
@@ -424,7 +429,7 @@ export function PortForwardPanel() {
|
|
|
424
429
|
const stopPortForward = useCallback(async (id: string) => {
|
|
425
430
|
setStoppingIds(prev => new Set(prev).add(id))
|
|
426
431
|
try {
|
|
427
|
-
const res = await
|
|
432
|
+
const res = await apiFetch(apiUrl(`/portforwards/${id}`), { method: 'DELETE' })
|
|
428
433
|
if (!res.ok) {
|
|
429
434
|
const body = await res.json().catch(() => ({}))
|
|
430
435
|
throw new Error(body.error || `Failed to stop port forward (HTTP ${res.status})`)
|
|
@@ -444,6 +449,48 @@ export function PortForwardPanel() {
|
|
|
444
449
|
}
|
|
445
450
|
}, [queryClient, showError])
|
|
446
451
|
|
|
452
|
+
// Recreate a failed forward. The errored session is already dead — there's no live
|
|
453
|
+
// forward to lose — so we drop the stale row FIRST, then recreate. Delete-first keeps
|
|
454
|
+
// the panel at exactly one row in every outcome (success → one running row; failure →
|
|
455
|
+
// one errored row), avoiding the orphaned-duplicate the reverse order would leave when
|
|
456
|
+
// the backend keeps a failed-start session in its map. A 404 means it was already
|
|
457
|
+
// cleared (e.g. context switch) — benign, proceed. Service-resolved sessions re-route
|
|
458
|
+
// through the service path via buildRecreateBody, so a retry after the backing pod was
|
|
459
|
+
// replaced re-resolves to a currently-running pod.
|
|
460
|
+
const retryPortForward = useCallback(async (session: PortForwardSession) => {
|
|
461
|
+
commitInteraction()
|
|
462
|
+
setRetryingIds(prev => new Set(prev).add(session.id))
|
|
463
|
+
try {
|
|
464
|
+
const delRes = await apiFetch(apiUrl(`/portforwards/${session.id}`), { method: 'DELETE' })
|
|
465
|
+
if (!delRes.ok && delRes.status !== 404) {
|
|
466
|
+
const body = await delRes.json().catch(() => ({}))
|
|
467
|
+
throw new Error(body.error || `Failed to clear failed port forward (HTTP ${delRes.status})`)
|
|
468
|
+
}
|
|
469
|
+
const res = await apiFetch(apiUrl('/portforwards'), {
|
|
470
|
+
method: 'POST',
|
|
471
|
+
headers: { 'Content-Type': 'application/json' },
|
|
472
|
+
body: JSON.stringify(buildRecreateBody(session, { localPort: session.localPort, listenAddress: session.listenAddress })),
|
|
473
|
+
})
|
|
474
|
+
if (!res.ok) {
|
|
475
|
+
const body = await res.json().catch(() => ({}))
|
|
476
|
+
throw new Error(body.error || `Failed to retry port forward (HTTP ${res.status})`)
|
|
477
|
+
}
|
|
478
|
+
queryClient.invalidateQueries({ queryKey: ['portforwards'] })
|
|
479
|
+
showSuccess('Port forward restarted', `Now listening on localhost:${session.localPort}`)
|
|
480
|
+
} catch (err) {
|
|
481
|
+
queryClient.invalidateQueries({ queryKey: ['portforwards'] })
|
|
482
|
+
const msg = err instanceof Error ? err.message : 'Failed to retry port forward'
|
|
483
|
+
showError('Failed to retry port forward', msg)
|
|
484
|
+
console.error('Failed to retry port forward:', err)
|
|
485
|
+
} finally {
|
|
486
|
+
setRetryingIds(prev => {
|
|
487
|
+
const next = new Set(prev)
|
|
488
|
+
next.delete(session.id)
|
|
489
|
+
return next
|
|
490
|
+
})
|
|
491
|
+
}
|
|
492
|
+
}, [commitInteraction, queryClient, showSuccess, showError])
|
|
493
|
+
|
|
447
494
|
const toggleListenAddress = async (session: PortForwardSession) => {
|
|
448
495
|
commitInteraction()
|
|
449
496
|
const newAddress = session.listenAddress === '0.0.0.0' ? '127.0.0.1' : '0.0.0.0'
|
|
@@ -454,13 +501,13 @@ export function PortForwardPanel() {
|
|
|
454
501
|
// apart from "original gone and recreate failed = data loss."
|
|
455
502
|
let deleted = false
|
|
456
503
|
try {
|
|
457
|
-
const delRes = await
|
|
504
|
+
const delRes = await apiFetch(apiUrl(`/portforwards/${session.id}`), { method: 'DELETE' })
|
|
458
505
|
if (!delRes.ok) {
|
|
459
506
|
const body = await delRes.json().catch(() => ({}))
|
|
460
507
|
throw new Error(body.error || `Failed to stop existing port forward (HTTP ${delRes.status})`)
|
|
461
508
|
}
|
|
462
509
|
deleted = true
|
|
463
|
-
const res = await
|
|
510
|
+
const res = await apiFetch(apiUrl('/portforwards'), {
|
|
464
511
|
method: 'POST',
|
|
465
512
|
headers: { 'Content-Type': 'application/json' },
|
|
466
513
|
body: JSON.stringify(buildRecreateBody(session, { localPort: session.localPort, listenAddress: newAddress })),
|
|
@@ -501,13 +548,13 @@ export function PortForwardPanel() {
|
|
|
501
548
|
// apart from "original gone and recreate failed = data loss."
|
|
502
549
|
let deleted = false
|
|
503
550
|
try {
|
|
504
|
-
const delRes = await
|
|
551
|
+
const delRes = await apiFetch(apiUrl(`/portforwards/${session.id}`), { method: 'DELETE' })
|
|
505
552
|
if (!delRes.ok) {
|
|
506
553
|
const body = await delRes.json().catch(() => ({}))
|
|
507
554
|
throw new Error(body.error || `Failed to stop existing port forward (HTTP ${delRes.status})`)
|
|
508
555
|
}
|
|
509
556
|
deleted = true
|
|
510
|
-
const res = await
|
|
557
|
+
const res = await apiFetch(apiUrl('/portforwards'), {
|
|
511
558
|
method: 'POST',
|
|
512
559
|
headers: { 'Content-Type': 'application/json' },
|
|
513
560
|
body: JSON.stringify(buildRecreateBody(session, { localPort: newPort, listenAddress: session.listenAddress })),
|
|
@@ -716,13 +763,28 @@ export function PortForwardPanel() {
|
|
|
716
763
|
</button>
|
|
717
764
|
</Tooltip>
|
|
718
765
|
)}
|
|
766
|
+
{session.status === 'error' && (
|
|
767
|
+
<Tooltip content="Retry" delay={300} position="bottom" disabled={!isPanelOpen}>
|
|
768
|
+
<button
|
|
769
|
+
onClick={() => retryPortForward(session)}
|
|
770
|
+
disabled={retryingIds.has(session.id) || stoppingIds.has(session.id)}
|
|
771
|
+
className="p-1.5 text-theme-text-tertiary hover:text-green-400 hover:bg-theme-hover rounded disabled:opacity-50"
|
|
772
|
+
>
|
|
773
|
+
{retryingIds.has(session.id) ? (
|
|
774
|
+
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
775
|
+
) : (
|
|
776
|
+
<RotateCw className="w-3.5 h-3.5" />
|
|
777
|
+
)}
|
|
778
|
+
</button>
|
|
779
|
+
</Tooltip>
|
|
780
|
+
)}
|
|
719
781
|
<Tooltip content={session.status === 'error' ? 'Dismiss' : 'Stop'} delay={300} position="bottom" disabled={!isPanelOpen}>
|
|
720
782
|
<button
|
|
721
783
|
onClick={() => {
|
|
722
784
|
commitInteraction()
|
|
723
785
|
stopPortForward(session.id)
|
|
724
786
|
}}
|
|
725
|
-
disabled={stoppingIds.has(session.id)}
|
|
787
|
+
disabled={stoppingIds.has(session.id) || retryingIds.has(session.id)}
|
|
726
788
|
className="p-1.5 text-theme-text-tertiary hover:text-red-400 hover:bg-theme-hover rounded disabled:opacity-50"
|
|
727
789
|
>
|
|
728
790
|
<Trash2 className="w-3.5 h-3.5" />
|
|
@@ -882,7 +944,7 @@ export function useStartPortForward() {
|
|
|
882
944
|
localPort?: number
|
|
883
945
|
listenAddress?: string // "127.0.0.1" (default) or "0.0.0.0"
|
|
884
946
|
}) => {
|
|
885
|
-
const res = await
|
|
947
|
+
const res = await apiFetch(apiUrl('/portforwards'), {
|
|
886
948
|
method: 'POST',
|
|
887
949
|
headers: { 'Content-Type': 'application/json' },
|
|
888
950
|
body: JSON.stringify(req),
|
|
@@ -316,7 +316,7 @@ function PanelLoading() {
|
|
|
316
316
|
return (
|
|
317
317
|
<div className="flex items-center justify-center h-full min-h-[160px] text-theme-text-tertiary text-xs">
|
|
318
318
|
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
|
319
|
-
Loading
|
|
319
|
+
Loading…
|
|
320
320
|
</div>
|
|
321
321
|
)
|
|
322
322
|
}
|
|
@@ -23,6 +23,7 @@ import { getSkeletonYaml } from '../../utils/skeleton-yaml'
|
|
|
23
23
|
interface ResourceCountsResponse {
|
|
24
24
|
counts: Record<string, number>
|
|
25
25
|
forbidden?: string[]
|
|
26
|
+
reasons?: Record<string, string>
|
|
26
27
|
unavailable?: string[]
|
|
27
28
|
}
|
|
28
29
|
|
|
@@ -284,6 +285,7 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
|
|
|
284
285
|
// Lightweight counts for sidebar (replaces 233 parallel queries)
|
|
285
286
|
resourceCounts={countsData?.counts}
|
|
286
287
|
resourceForbidden={countsData?.forbidden}
|
|
288
|
+
resourceReasons={countsData?.reasons}
|
|
287
289
|
resourceUnavailable={countsData?.unavailable}
|
|
288
290
|
selectedKindQuery={selectedKindQueryResult}
|
|
289
291
|
largeListGuard={largeListGuard}
|
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
import { useState, useEffect, useRef } from 'react'
|
|
2
2
|
import { createPortal } from 'react-dom'
|
|
3
|
-
import { Shield, X, Loader2 } from 'lucide-react'
|
|
3
|
+
import { Shield, X, Loader2, Lock, ExternalLink } from 'lucide-react'
|
|
4
4
|
import { clsx } from 'clsx'
|
|
5
|
+
import { useQuery } from '@tanstack/react-query'
|
|
5
6
|
import {
|
|
6
7
|
rbacVerbBadgeClass,
|
|
7
8
|
rbacResourceBadgeClass,
|
|
8
9
|
rbacApiGroupBadgeClass,
|
|
9
10
|
rbacResourceNameBadgeClass,
|
|
10
11
|
rbacNonResourceUrlBadgeClass,
|
|
12
|
+
type RBACWhoamiResponse,
|
|
11
13
|
} from '@skyhook-io/k8s-ui'
|
|
12
14
|
import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
|
|
13
15
|
import { TRANSITION_BACKDROP, TRANSITION_PANEL } from '../../utils/animation'
|
|
14
|
-
import { useNamespaces, useAuthMe } from '../../api/client'
|
|
16
|
+
import { useNamespaces, useAuthMe, fetchJSON } from '../../api/client'
|
|
15
17
|
import { useRBACWhoami } from '../../api/rbac'
|
|
16
18
|
|
|
17
19
|
interface MyPermissionsDialogProps {
|
|
@@ -71,7 +73,7 @@ export function MyPermissionsDialog({ open, onClose }: MyPermissionsDialogProps)
|
|
|
71
73
|
<div className="flex items-center justify-between p-4 border-b border-theme-border shrink-0">
|
|
72
74
|
<div className="flex items-center gap-2">
|
|
73
75
|
<Shield className="w-5 h-5 text-theme-text-secondary" />
|
|
74
|
-
<h2 className="text-lg font-semibold text-theme-text-primary">
|
|
76
|
+
<h2 className="text-lg font-semibold text-theme-text-primary">Your access on this cluster</h2>
|
|
75
77
|
</div>
|
|
76
78
|
<button
|
|
77
79
|
onClick={onClose}
|
|
@@ -134,6 +136,8 @@ export function MyPermissionsDialog({ open, onClose }: MyPermissionsDialogProps)
|
|
|
134
136
|
) : whoami ? (
|
|
135
137
|
<PermissionsTable whoami={whoami} />
|
|
136
138
|
) : null}
|
|
139
|
+
|
|
140
|
+
<RestrictedResources enabled={open} />
|
|
137
141
|
</div>
|
|
138
142
|
</div>
|
|
139
143
|
</div>,
|
|
@@ -141,7 +145,7 @@ export function MyPermissionsDialog({ open, onClose }: MyPermissionsDialogProps)
|
|
|
141
145
|
)
|
|
142
146
|
}
|
|
143
147
|
|
|
144
|
-
function PermissionsTable({ whoami }: { whoami:
|
|
148
|
+
function PermissionsTable({ whoami }: { whoami: RBACWhoamiResponse }) {
|
|
145
149
|
const resourceRules = whoami.resourceRules ?? []
|
|
146
150
|
const nonResourceRules = whoami.nonResourceRules ?? []
|
|
147
151
|
|
|
@@ -229,3 +233,59 @@ function ResourceRuleRow({ rule }: { rule: { verbs?: string[]; apiGroups?: strin
|
|
|
229
233
|
</div>
|
|
230
234
|
)
|
|
231
235
|
}
|
|
236
|
+
|
|
237
|
+
// displayKind strips the API group from a resource-counts key ("group/Kind" →
|
|
238
|
+
// "Kind"; core kinds have no prefix).
|
|
239
|
+
function displayKind(countKey: string): string {
|
|
240
|
+
const i = countKey.indexOf('/')
|
|
241
|
+
return i === -1 ? countKey : countKey.slice(i + 1)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// RestrictedResources surfaces the kinds Radar isn't showing the user (the
|
|
245
|
+
// resource-counts `forbidden` set) — the "what's hidden from me" half of access,
|
|
246
|
+
// alongside the SelfSubjectRulesReview rules above. That set mixes RBAC denials
|
|
247
|
+
// with not-installed/not-watched kinds, so the copy says "usually RBAC" rather
|
|
248
|
+
// than asserting a cause, and links to the docs that carry the unblock RBAC.
|
|
249
|
+
function RestrictedResources({ enabled }: { enabled: boolean }) {
|
|
250
|
+
const { data } = useQuery<{ forbidden?: string[] }>({
|
|
251
|
+
queryKey: ['resource-counts', 'your-access'],
|
|
252
|
+
queryFn: () => fetchJSON('/resource-counts'),
|
|
253
|
+
enabled,
|
|
254
|
+
staleTime: 10000,
|
|
255
|
+
})
|
|
256
|
+
const forbidden = data?.forbidden ?? []
|
|
257
|
+
if (forbidden.length === 0) return null
|
|
258
|
+
|
|
259
|
+
return (
|
|
260
|
+
<div>
|
|
261
|
+
<div className="text-xs font-medium text-theme-text-secondary uppercase tracking-wider mb-2 flex items-center gap-1.5">
|
|
262
|
+
<Lock className="w-3.5 h-3.5 text-amber-400" />
|
|
263
|
+
Restricted or unavailable ({forbidden.length})
|
|
264
|
+
</div>
|
|
265
|
+
<p className="text-xs text-theme-text-tertiary mb-2">
|
|
266
|
+
Resource types Radar isn't showing you — usually because your RBAC doesn't allow listing
|
|
267
|
+
them, sometimes because the type isn't installed or watched on this cluster. Either way,
|
|
268
|
+
not an empty cluster.
|
|
269
|
+
</p>
|
|
270
|
+
<div className="flex flex-wrap gap-1.5">
|
|
271
|
+
{forbidden.map((k) => (
|
|
272
|
+
<span
|
|
273
|
+
key={k}
|
|
274
|
+
className="inline-flex items-center px-2 py-0.5 text-xs rounded border border-theme-border bg-theme-elevated text-theme-text-secondary"
|
|
275
|
+
>
|
|
276
|
+
{displayKind(k)}
|
|
277
|
+
</span>
|
|
278
|
+
))}
|
|
279
|
+
</div>
|
|
280
|
+
<a
|
|
281
|
+
href="https://radarhq.io/docs/cloud/rbac"
|
|
282
|
+
target="_blank"
|
|
283
|
+
rel="noreferrer"
|
|
284
|
+
className="inline-flex items-center gap-1 text-xs text-accent-text hover:underline mt-2"
|
|
285
|
+
>
|
|
286
|
+
How to get access
|
|
287
|
+
<ExternalLink className="w-3 h-3" />
|
|
288
|
+
</a>
|
|
289
|
+
</div>
|
|
290
|
+
)
|
|
291
|
+
}
|
|
@@ -39,7 +39,7 @@ export function LargeClusterNamespacePicker({ namespaces, onSelect }: {
|
|
|
39
39
|
<div className="max-h-[240px] overflow-y-auto rounded-lg border border-theme-border bg-theme-base">
|
|
40
40
|
{!namespaces ? (
|
|
41
41
|
<div className="px-3 py-6 text-center text-sm text-theme-text-tertiary">
|
|
42
|
-
Loading namespaces
|
|
42
|
+
Loading namespaces…
|
|
43
43
|
</div>
|
|
44
44
|
) : filtered.length === 0 ? (
|
|
45
45
|
<div className="px-3 py-6 text-center text-sm text-theme-text-tertiary">
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useMemo, useEffect, useState, useCallback, useRef } from 'react'
|
|
1
|
+
import { useMemo, useEffect, useState, useCallback, useRef, type MutableRefObject } from 'react'
|
|
2
2
|
import {
|
|
3
3
|
ReactFlow,
|
|
4
4
|
Background,
|
|
@@ -971,6 +971,33 @@ const nodeTypes = {
|
|
|
971
971
|
addonGroup: AddonGroupNode,
|
|
972
972
|
}
|
|
973
973
|
|
|
974
|
+
// Fits the view once nodes have been laid out. Module-scope (not defined inside
|
|
975
|
+
// TrafficGraph's render) so it keeps a stable identity — otherwise it remounts
|
|
976
|
+
// every render and its effect churns. Must render inside <ReactFlow> for the
|
|
977
|
+
// useReactFlow() context; the trigger state is passed in as props.
|
|
978
|
+
function FitViewOnChange({
|
|
979
|
+
shouldFitViewRef,
|
|
980
|
+
layoutedNodes,
|
|
981
|
+
}: {
|
|
982
|
+
shouldFitViewRef: MutableRefObject<boolean>
|
|
983
|
+
layoutedNodes: Node<TrafficNodeData>[]
|
|
984
|
+
}) {
|
|
985
|
+
const { fitView } = useReactFlow()
|
|
986
|
+
|
|
987
|
+
useEffect(() => {
|
|
988
|
+
if (shouldFitViewRef.current && layoutedNodes.length > 0) {
|
|
989
|
+
// Small delay to ensure nodes are rendered
|
|
990
|
+
const timer = setTimeout(() => {
|
|
991
|
+
fitView({ padding: 0.2, duration: 200 })
|
|
992
|
+
shouldFitViewRef.current = false
|
|
993
|
+
}, 50)
|
|
994
|
+
return () => clearTimeout(timer)
|
|
995
|
+
}
|
|
996
|
+
}, [fitView, layoutedNodes, shouldFitViewRef])
|
|
997
|
+
|
|
998
|
+
return null
|
|
999
|
+
}
|
|
1000
|
+
|
|
974
1001
|
export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups = false, serviceCategories, addonMode = 'show', trafficSource = '', onSelectionChange }: TrafficGraphProps) {
|
|
975
1002
|
const isIstio = trafficSource === 'istio'
|
|
976
1003
|
const connLabel = isIstio ? 'req/s' : 'conn'
|
|
@@ -1492,24 +1519,6 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
|
|
|
1492
1519
|
onSelectionChange?.(null)
|
|
1493
1520
|
}, [onSelectionChange])
|
|
1494
1521
|
|
|
1495
|
-
// FitView handler component - must be inside ReactFlow
|
|
1496
|
-
const FitViewOnChange = () => {
|
|
1497
|
-
const { fitView } = useReactFlow()
|
|
1498
|
-
|
|
1499
|
-
useEffect(() => {
|
|
1500
|
-
if (shouldFitViewRef.current && layoutedNodes.length > 0) {
|
|
1501
|
-
// Small delay to ensure nodes are rendered
|
|
1502
|
-
const timer = setTimeout(() => {
|
|
1503
|
-
fitView({ padding: 0.2, duration: 200 })
|
|
1504
|
-
shouldFitViewRef.current = false
|
|
1505
|
-
}, 50)
|
|
1506
|
-
return () => clearTimeout(timer)
|
|
1507
|
-
}
|
|
1508
|
-
}, [fitView, layoutedNodes])
|
|
1509
|
-
|
|
1510
|
-
return null
|
|
1511
|
-
}
|
|
1512
|
-
|
|
1513
1522
|
return (
|
|
1514
1523
|
<div className="w-full h-full relative">
|
|
1515
1524
|
<ReactFlow
|
|
@@ -1535,7 +1544,7 @@ export function TrafficGraph({ flows, hotPathThreshold = 0, showNamespaceGroups
|
|
|
1535
1544
|
>
|
|
1536
1545
|
<Background />
|
|
1537
1546
|
<Controls />
|
|
1538
|
-
<FitViewOnChange />
|
|
1547
|
+
<FitViewOnChange shouldFitViewRef={shouldFitViewRef} layoutedNodes={layoutedNodes} />
|
|
1539
1548
|
</ReactFlow>
|
|
1540
1549
|
|
|
1541
1550
|
{/* Legend */}
|
|
@@ -620,13 +620,13 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
|
|
|
620
620
|
|
|
621
621
|
// Toggle L7 filter helpers
|
|
622
622
|
const toggleL7Method = useCallback((method: string) => {
|
|
623
|
-
setL7Methods(prev => { const next = new Set(prev); next.has(method)
|
|
623
|
+
setL7Methods(prev => { const next = new Set(prev); if (next.has(method)) next.delete(method); else next.add(method); return next })
|
|
624
624
|
}, [])
|
|
625
625
|
const toggleL7StatusRange = useCallback((range: string) => {
|
|
626
|
-
setL7StatusRanges(prev => { const next = new Set(prev); next.has(range)
|
|
626
|
+
setL7StatusRanges(prev => { const next = new Set(prev); if (next.has(range)) next.delete(range); else next.add(range); return next })
|
|
627
627
|
}, [])
|
|
628
628
|
const toggleL7Verdict = useCallback((verdict: string) => {
|
|
629
|
-
setL7Verdicts(prev => { const next = new Set(prev); next.has(verdict)
|
|
629
|
+
setL7Verdicts(prev => { const next = new Set(prev); if (next.has(verdict)) next.delete(verdict); else next.add(verdict); return next })
|
|
630
630
|
}, [])
|
|
631
631
|
|
|
632
632
|
// Toggle namespace visibility
|
|
@@ -99,7 +99,7 @@ export function DiagnosticsOverlay({ onClose, isOpen = true }: DiagnosticsOverla
|
|
|
99
99
|
{/* Content */}
|
|
100
100
|
<div className="overflow-y-auto flex-1 px-5 py-4 space-y-4">
|
|
101
101
|
{isLoading && (
|
|
102
|
-
<div className="text-sm text-theme-text-tertiary text-center py-8">Loading diagnostics
|
|
102
|
+
<div className="text-sm text-theme-text-tertiary text-center py-8">Loading diagnostics…</div>
|
|
103
103
|
)}
|
|
104
104
|
{error && (
|
|
105
105
|
<div className="text-sm text-red-400 text-center py-8">Failed to load diagnostics: {(error as Error).message}</div>
|
|
@@ -199,45 +199,45 @@ function useActionsBarProps(kind: string, namespace: string, name: string) {
|
|
|
199
199
|
renderPortForward: ({ type, namespace: ns, name: n, className }: { type: 'pod' | 'service'; namespace: string; name: string; className?: string }) => (
|
|
200
200
|
<PortForwardButton type={type} namespace={ns} name={n} className={className} />
|
|
201
201
|
),
|
|
202
|
-
onDelete: (params:
|
|
202
|
+
onDelete: (params: Parameters<typeof deleteMutation.mutate>[0], callbacks?: { onSuccess?: () => void }) => deleteMutation.mutate(params, { onSuccess: callbacks?.onSuccess }),
|
|
203
203
|
isDeleting: deleteMutation.isPending,
|
|
204
204
|
cascadeDependents: cascadePreview?.dependents,
|
|
205
205
|
cascadeLoading,
|
|
206
|
-
onRestart: (params:
|
|
206
|
+
onRestart: (params: Parameters<typeof restartWorkloadMutation.mutate>[0]) => restartWorkloadMutation.mutate(params),
|
|
207
207
|
isRestarting: restartWorkloadMutation.isPending,
|
|
208
208
|
revisions: revisionsList,
|
|
209
209
|
revisionsLoading,
|
|
210
210
|
revisionsError: revisionsError ?? null,
|
|
211
|
-
onRollback: (params:
|
|
211
|
+
onRollback: (params: Parameters<typeof rollbackMutation.mutate>[0], callbacks?: { onSuccess?: () => void }) => rollbackMutation.mutate(params, { onSuccess: callbacks?.onSuccess }),
|
|
212
212
|
isRollingBack: rollbackMutation.isPending,
|
|
213
|
-
onTriggerCronJob: (params:
|
|
213
|
+
onTriggerCronJob: (params: Parameters<typeof triggerCronJobMutation.mutate>[0]) => triggerCronJobMutation.mutate(params),
|
|
214
214
|
isTriggeringCronJob: triggerCronJobMutation.isPending,
|
|
215
|
-
onSuspendCronJob: (params:
|
|
215
|
+
onSuspendCronJob: (params: Parameters<typeof suspendCronJobMutation.mutate>[0]) => suspendCronJobMutation.mutate(params),
|
|
216
216
|
isSuspendingCronJob: suspendCronJobMutation.isPending,
|
|
217
|
-
onResumeCronJob: (params:
|
|
217
|
+
onResumeCronJob: (params: Parameters<typeof resumeCronJobMutation.mutate>[0]) => resumeCronJobMutation.mutate(params),
|
|
218
218
|
isResumingCronJob: resumeCronJobMutation.isPending,
|
|
219
|
-
onFluxReconcile: (params:
|
|
219
|
+
onFluxReconcile: (params: Parameters<typeof fluxReconcileMutation.mutate>[0]) => fluxReconcileMutation.mutate(params),
|
|
220
220
|
isFluxReconciling: fluxReconcileMutation.isPending,
|
|
221
|
-
onFluxSyncWithSource: (params:
|
|
221
|
+
onFluxSyncWithSource: (params: Parameters<typeof fluxSyncWithSourceMutation.mutate>[0]) => fluxSyncWithSourceMutation.mutate(params),
|
|
222
222
|
isFluxSyncing: fluxSyncWithSourceMutation.isPending,
|
|
223
|
-
onFluxSuspend: (params:
|
|
223
|
+
onFluxSuspend: (params: Parameters<typeof fluxSuspendMutation.mutate>[0]) => fluxSuspendMutation.mutate(params),
|
|
224
224
|
isFluxSuspending: fluxSuspendMutation.isPending,
|
|
225
|
-
onFluxResume: (params:
|
|
225
|
+
onFluxResume: (params: Parameters<typeof fluxResumeMutation.mutate>[0]) => fluxResumeMutation.mutate(params),
|
|
226
226
|
isFluxResuming: fluxResumeMutation.isPending,
|
|
227
|
-
onArgoSync: (params:
|
|
227
|
+
onArgoSync: (params: Parameters<typeof argoSyncMutation.mutate>[0]) => argoSyncMutation.mutate(params),
|
|
228
228
|
isArgoSyncing: argoSyncMutation.isPending,
|
|
229
|
-
onArgoRefresh: (params:
|
|
229
|
+
onArgoRefresh: (params: Parameters<typeof argoRefreshMutation.mutate>[0]) => argoRefreshMutation.mutate(params),
|
|
230
230
|
isArgoRefreshing: argoRefreshMutation.isPending,
|
|
231
|
-
onArgoSuspend: (params:
|
|
231
|
+
onArgoSuspend: (params: Parameters<typeof argoSuspendMutation.mutate>[0]) => argoSuspendMutation.mutate(params),
|
|
232
232
|
isArgoSuspending: argoSuspendMutation.isPending,
|
|
233
|
-
onArgoResume: (params:
|
|
233
|
+
onArgoResume: (params: Parameters<typeof argoResumeMutation.mutate>[0]) => argoResumeMutation.mutate(params),
|
|
234
234
|
isArgoResuming: argoResumeMutation.isPending,
|
|
235
235
|
canNodeWrite,
|
|
236
|
-
onCordonNode: (params:
|
|
236
|
+
onCordonNode: (params: Parameters<typeof cordonMutation.mutate>[0]) => cordonMutation.mutate(params),
|
|
237
237
|
isCordoningNode: cordonMutation.isPending,
|
|
238
|
-
onUncordonNode: (params:
|
|
238
|
+
onUncordonNode: (params: Parameters<typeof uncordonMutation.mutate>[0]) => uncordonMutation.mutate(params),
|
|
239
239
|
isUncordoningNode: uncordonMutation.isPending,
|
|
240
|
-
onDrainNode: (params:
|
|
240
|
+
onDrainNode: (params: Parameters<typeof drainMutation.mutate>[0]) => drainMutation.mutate(params),
|
|
241
241
|
isDrainingNode: drainMutation.isPending,
|
|
242
242
|
}
|
|
243
243
|
}
|
package/src/main.tsx
CHANGED
|
@@ -130,7 +130,7 @@ document.execCommand = function (command: string, showUI?: boolean, value?: stri
|
|
|
130
130
|
dt.setData('text/plain', text)
|
|
131
131
|
const ev = new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })
|
|
132
132
|
if (!el.dispatchEvent(ev)) return
|
|
133
|
-
} catch
|
|
133
|
+
} catch { /* ClipboardEvent dispatch failed, fall back to insertText */ }
|
|
134
134
|
_origExecCommand('insertText', false, text)
|
|
135
135
|
}).catch((err) => { console.warn('[Radar] Paste failed:', err) })
|
|
136
136
|
return true
|