@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/radar-app",
3
- "version": "1.8.0",
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": "^25.9.3",
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
- className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-11 h-11"
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
- <p
144
- className="absolute left-1/2 -translate-x-1/2 whitespace-nowrap text-[17px] font-semibold tracking-tight text-theme-text-primary"
146
+ <div
147
+ className="absolute left-1/2 -translate-x-1/2 text-center"
145
148
  style={{ top: 'calc(50% + 34px)' }}
146
149
  >
147
- Redirecting to login…
148
- </p>
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
- const routeSelectedResource = resourcesKindRouteChanged && selectedResourceRouteMismatch
425
- ? null
426
- : selectedResource
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
- setSelectedResource(resource)
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
- setSelectedResource(resource)
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 flex flex-col items-center justify-center gap-3 bg-theme-base">
1865
- <img src={radarLoadingIcon} alt="" aria-hidden className="w-11 h-11" />
1866
- <p className="text-sm text-theme-text-secondary">Opening…</p>
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
- cached = null
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
  }
@@ -88,7 +88,7 @@ export function DebugOverlay() {
88
88
  )}
89
89
  </>
90
90
  ) : (
91
- <span className="text-theme-text-tertiary">Loading...</span>
91
+ <span className="text-theme-text-tertiary">Loading…</span>
92
92
  )}
93
93
  </div>
94
94
  </div>
@@ -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 aParsed = parseRef(searchParams.get('a'))
27
- const bParsed = parseRef(searchParams.get('b'))
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
- 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
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...</div>
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) ? next.delete(s) : next.add(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 fetch(apiUrl('/portforwards'))
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 fetch(apiUrl(`/portforwards/${id}`), { method: 'DELETE' })
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 fetch(apiUrl(`/portforwards/${session.id}`), { method: 'DELETE' })
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 fetch(apiUrl('/portforwards'), {
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 fetch(apiUrl(`/portforwards/${session.id}`), { method: 'DELETE' })
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 fetch(apiUrl('/portforwards'), {
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 fetch(apiUrl('/portforwards'), {
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">My Permissions</h2>
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: { resourceRules: any[]; nonResourceRules: any[] } }) {
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) ? next.delete(method) : next.add(method); return next })
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) ? next.delete(range) : next.add(range); return next })
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) ? next.delete(verdict) : next.add(verdict); return next })
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...</div>
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: any, callbacks?: any) => deleteMutation.mutate(params, { onSuccess: callbacks?.onSuccess }),
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: any) => restartWorkloadMutation.mutate(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: any, callbacks?: any) => rollbackMutation.mutate(params, { onSuccess: callbacks?.onSuccess }),
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: any) => triggerCronJobMutation.mutate(params),
213
+ onTriggerCronJob: (params: Parameters<typeof triggerCronJobMutation.mutate>[0]) => triggerCronJobMutation.mutate(params),
214
214
  isTriggeringCronJob: triggerCronJobMutation.isPending,
215
- onSuspendCronJob: (params: any) => suspendCronJobMutation.mutate(params),
215
+ onSuspendCronJob: (params: Parameters<typeof suspendCronJobMutation.mutate>[0]) => suspendCronJobMutation.mutate(params),
216
216
  isSuspendingCronJob: suspendCronJobMutation.isPending,
217
- onResumeCronJob: (params: any) => resumeCronJobMutation.mutate(params),
217
+ onResumeCronJob: (params: Parameters<typeof resumeCronJobMutation.mutate>[0]) => resumeCronJobMutation.mutate(params),
218
218
  isResumingCronJob: resumeCronJobMutation.isPending,
219
- onFluxReconcile: (params: any) => fluxReconcileMutation.mutate(params),
219
+ onFluxReconcile: (params: Parameters<typeof fluxReconcileMutation.mutate>[0]) => fluxReconcileMutation.mutate(params),
220
220
  isFluxReconciling: fluxReconcileMutation.isPending,
221
- onFluxSyncWithSource: (params: any) => fluxSyncWithSourceMutation.mutate(params),
221
+ onFluxSyncWithSource: (params: Parameters<typeof fluxSyncWithSourceMutation.mutate>[0]) => fluxSyncWithSourceMutation.mutate(params),
222
222
  isFluxSyncing: fluxSyncWithSourceMutation.isPending,
223
- onFluxSuspend: (params: any) => fluxSuspendMutation.mutate(params),
223
+ onFluxSuspend: (params: Parameters<typeof fluxSuspendMutation.mutate>[0]) => fluxSuspendMutation.mutate(params),
224
224
  isFluxSuspending: fluxSuspendMutation.isPending,
225
- onFluxResume: (params: any) => fluxResumeMutation.mutate(params),
225
+ onFluxResume: (params: Parameters<typeof fluxResumeMutation.mutate>[0]) => fluxResumeMutation.mutate(params),
226
226
  isFluxResuming: fluxResumeMutation.isPending,
227
- onArgoSync: (params: any) => argoSyncMutation.mutate(params),
227
+ onArgoSync: (params: Parameters<typeof argoSyncMutation.mutate>[0]) => argoSyncMutation.mutate(params),
228
228
  isArgoSyncing: argoSyncMutation.isPending,
229
- onArgoRefresh: (params: any) => argoRefreshMutation.mutate(params),
229
+ onArgoRefresh: (params: Parameters<typeof argoRefreshMutation.mutate>[0]) => argoRefreshMutation.mutate(params),
230
230
  isArgoRefreshing: argoRefreshMutation.isPending,
231
- onArgoSuspend: (params: any) => argoSuspendMutation.mutate(params),
231
+ onArgoSuspend: (params: Parameters<typeof argoSuspendMutation.mutate>[0]) => argoSuspendMutation.mutate(params),
232
232
  isArgoSuspending: argoSuspendMutation.isPending,
233
- onArgoResume: (params: any) => argoResumeMutation.mutate(params),
233
+ onArgoResume: (params: Parameters<typeof argoResumeMutation.mutate>[0]) => argoResumeMutation.mutate(params),
234
234
  isArgoResuming: argoResumeMutation.isPending,
235
235
  canNodeWrite,
236
- onCordonNode: (params: any) => cordonMutation.mutate(params),
236
+ onCordonNode: (params: Parameters<typeof cordonMutation.mutate>[0]) => cordonMutation.mutate(params),
237
237
  isCordoningNode: cordonMutation.isPending,
238
- onUncordonNode: (params: any) => uncordonMutation.mutate(params),
238
+ onUncordonNode: (params: Parameters<typeof uncordonMutation.mutate>[0]) => uncordonMutation.mutate(params),
239
239
  isUncordoningNode: uncordonMutation.isPending,
240
- onDrainNode: (params: any) => drainMutation.mutate(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 (_e) { /* ClipboardEvent dispatch failed, fall back to insertText */ }
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