@skyhook-io/radar-app 1.8.0 → 1.8.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.
Files changed (32) hide show
  1. package/package.json +2 -2
  2. package/src/App.tsx +95 -16
  3. package/src/api/client.ts +64 -3
  4. package/src/components/DebugOverlay.tsx +1 -1
  5. package/src/components/applications/ApplicationsView.tsx +1 -1
  6. package/src/components/compare/CompareViewRoute.tsx +13 -5
  7. package/src/components/cost/CostTrendChart.tsx +3 -3
  8. package/src/components/cost/CostView.tsx +1 -1
  9. package/src/components/helm/ChartBrowser.tsx +1 -1
  10. package/src/components/helm/HelmReleaseDrawer.tsx +233 -19
  11. package/src/components/helm/HelmView.tsx +85 -16
  12. package/src/components/helm/InstallWizard.tsx +1 -1
  13. package/src/components/helm/RevisionHistory.tsx +46 -2
  14. package/src/components/helm/TrackChartSourceDialog.tsx +141 -0
  15. package/src/components/home/ActivitySummary.tsx +4 -1
  16. package/src/components/home/TrafficSummary.tsx +2 -2
  17. package/src/components/home/mcpToolCatalog.ts +5 -5
  18. package/src/components/issues/IssuesPane.tsx +2 -2
  19. package/src/components/nav/PrimaryNavRail.tsx +1 -1
  20. package/src/components/portforward/PortForwardManager.tsx +70 -8
  21. package/src/components/resource/PrometheusChartsGrid.tsx +1 -1
  22. package/src/components/resources/ResourcesView.tsx +2 -0
  23. package/src/components/settings/MyPermissionsDialog.tsx +64 -4
  24. package/src/components/shared/LargeClusterNamespacePicker.tsx +1 -1
  25. package/src/components/traffic/TrafficGraph.tsx +29 -20
  26. package/src/components/traffic/TrafficView.tsx +3 -3
  27. package/src/components/ui/DiagnosticsOverlay.tsx +1 -1
  28. package/src/components/ui/ShortcutHelpOverlay.tsx +1 -1
  29. package/src/components/ui/command-items.ts +1 -1
  30. package/src/components/workload/WorkloadView.tsx +17 -17
  31. package/src/context/ConnectionContext.tsx +29 -2
  32. package/src/main.tsx +1 -1
@@ -0,0 +1,141 @@
1
+ import { useState } from 'react'
2
+ import { DialogPortal } from '@skyhook-io/k8s-ui/components/ui/DialogPortal'
3
+ import { X, Plus, Trash2, Link2, AlertTriangle } from 'lucide-react'
4
+ import { clsx } from 'clsx'
5
+ import { useHelmOCISources, useAddOCISource, useRemoveOCISource, useClusterInfo } from '../../api/client'
6
+
7
+ interface TrackChartSourceDialogProps {
8
+ open: boolean
9
+ onClose: () => void
10
+ /** Chart name of the release this was opened from, for the example prompt. */
11
+ chartName?: string
12
+ }
13
+
14
+ // TrackChartSourceDialog lets the user register an OCI chart-source prefix — the
15
+ // OCI analog of `helm repo add`. Helm doesn't persist the ref a release was
16
+ // installed from, so for charts published to an OCI registry (and not managed by
17
+ // GitOps) Radar can only track upgrades once the user declares where they live.
18
+ // Registering a registry/org prefix lets Radar probe "<prefix>/<chartName>".
19
+ export function TrackChartSourceDialog({ open, onClose, chartName }: TrackChartSourceDialogProps) {
20
+ const [value, setValue] = useState('')
21
+ const { data: sources } = useHelmOCISources()
22
+ const { data: clusterInfo } = useClusterInfo()
23
+ const addSource = useAddOCISource()
24
+ const removeSource = useRemoveOCISource()
25
+
26
+ // In-cluster Radar has no `helm registry login` store (the pod's HELM_CONFIG_HOME
27
+ // points at an empty /tmp), so private registries can't authenticate — only
28
+ // public charts can be tracked. Be honest about it rather than silently failing.
29
+ const inCluster = clusterInfo?.inCluster ?? false
30
+
31
+ const trimmed = value.trim()
32
+ const invalid = trimmed !== '' && !trimmed.startsWith('oci://')
33
+
34
+ const handleAdd = () => {
35
+ if (!trimmed || invalid) return
36
+ addSource.mutate(trimmed, { onSuccess: () => setValue('') })
37
+ }
38
+
39
+ return (
40
+ <DialogPortal open={open} onClose={onClose} className="max-w-lg w-full">
41
+ <div className="flex items-start gap-3 p-4 border-b border-theme-border">
42
+ <div className="flex items-center justify-center w-10 h-10 rounded-full shrink-0 bg-theme-hover">
43
+ <Link2 className="w-5 h-5 text-theme-text-secondary" />
44
+ </div>
45
+ <div className="flex-1 min-w-0">
46
+ <h3 className="text-lg font-semibold text-theme-text-primary">Track chart source</h3>
47
+ <p className="text-sm text-theme-text-secondary mt-1">
48
+ Helm doesn&apos;t record where a chart was installed from. Register your OCI
49
+ registry prefix and Radar will check it for newer versions of your charts.
50
+ </p>
51
+ </div>
52
+ <button
53
+ onClick={onClose}
54
+ className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
55
+ >
56
+ <X className="w-5 h-5" />
57
+ </button>
58
+ </div>
59
+
60
+ <div className="p-4 space-y-4">
61
+ <div>
62
+ <label className="block text-sm font-medium text-theme-text-secondary mb-2">
63
+ OCI registry prefix
64
+ </label>
65
+ <div className="flex gap-2">
66
+ <input
67
+ type="text"
68
+ value={value}
69
+ onChange={(e) => setValue(e.target.value)}
70
+ onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
71
+ placeholder="oci://ghcr.io/myorg/charts"
72
+ aria-invalid={invalid ? true : undefined}
73
+ className={clsx(
74
+ 'flex-1 px-3 py-2 bg-theme-elevated border rounded-lg text-sm text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2',
75
+ invalid ? 'border-red-500/60 focus:ring-red-500' : 'border-theme-border-light focus:ring-accent',
76
+ )}
77
+ />
78
+ <button
79
+ onClick={handleAdd}
80
+ disabled={!trimmed || invalid || addSource.isPending}
81
+ className="btn-brand px-3 py-2 text-sm inline-flex items-center gap-1 disabled:opacity-50 disabled:pointer-events-none"
82
+ >
83
+ <Plus className="w-4 h-4" />
84
+ Add
85
+ </button>
86
+ </div>
87
+ <p className="mt-1 text-xs text-theme-text-tertiary">
88
+ {invalid
89
+ ? 'Must be an oci:// reference.'
90
+ : chartName
91
+ ? `Radar will look for "${chartName}" under this prefix (and any others below).`
92
+ : 'Radar probes <prefix>/<chartName> for each untracked release.'}
93
+ </p>
94
+ </div>
95
+
96
+ {sources && sources.length > 0 && (
97
+ <div>
98
+ <p className="text-xs font-medium text-theme-text-tertiary uppercase tracking-wide mb-2">
99
+ Registered sources
100
+ </p>
101
+ <ul className="space-y-1">
102
+ {sources.map((src) => (
103
+ <li
104
+ key={src}
105
+ className="flex items-center justify-between gap-2 px-3 py-2 bg-theme-elevated rounded-lg"
106
+ >
107
+ <span className="text-sm text-theme-text-primary font-mono truncate">{src}</span>
108
+ <button
109
+ onClick={() => removeSource.mutate(src)}
110
+ disabled={removeSource.isPending}
111
+ className="p-1 text-theme-text-secondary hover:text-red-400 hover:bg-red-500/10 rounded disabled:opacity-50"
112
+ aria-label={`Remove ${src}`}
113
+ >
114
+ <Trash2 className="w-4 h-4" />
115
+ </button>
116
+ </li>
117
+ ))}
118
+ </ul>
119
+ </div>
120
+ )}
121
+
122
+ {inCluster ? (
123
+ <div className="flex items-start gap-2 rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-400">
124
+ <AlertTriangle className="w-4 h-4 shrink-0 mt-px" />
125
+ <span>
126
+ Radar is running in-cluster, where it has no{' '}
127
+ <span className="font-mono">helm registry login</span> credentials — only{' '}
128
+ <strong>public</strong> charts can be tracked. Private-registry support for in-cluster
129
+ Radar isn&apos;t available yet.
130
+ </span>
131
+ </div>
132
+ ) : (
133
+ <p className="text-xs text-theme-text-tertiary">
134
+ Credentials are reused from your <span className="font-mono">helm registry login</span>.
135
+ Radar stores no registry secrets.
136
+ </p>
137
+ )}
138
+ </div>
139
+ </DialogPortal>
140
+ )
141
+ }
@@ -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">
@@ -100,7 +100,7 @@ export function TrafficSummary({ data, onNavigate }: TrafficSummaryProps) {
100
100
  <div className="flex items-center justify-between px-5 py-3 border-b border-theme-border/50">
101
101
  <div className="flex items-center gap-2">
102
102
  <Activity className="w-4 h-4 text-theme-text-tertiary" />
103
- <span className="text-xs font-semibold uppercase tracking-wider text-theme-text-secondary">Traffic</span>
103
+ <span className="text-xs font-semibold uppercase tracking-wider text-theme-text-secondary">Live Traffic</span>
104
104
  </div>
105
105
  {hasFlows && (
106
106
  <span className="text-[11px] text-theme-text-tertiary">
@@ -145,7 +145,7 @@ export function TrafficSummary({ data, onNavigate }: TrafficSummaryProps) {
145
145
  </div>
146
146
 
147
147
  <div className="px-4 py-1.5 border-t border-theme-border/50 flex items-center justify-end gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-theme-text-secondary group-hover:text-theme-text-primary transition-colors">
148
- Open Traffic
148
+ Open Live Traffic
149
149
  <ArrowRight className="w-3.5 h-3.5 transition-transform group-hover:translate-x-0.5" />
150
150
  </div>
151
151
  </div>
@@ -126,7 +126,7 @@ export const MCP_TOOL_CATALOG: MCPToolInfo[] = [
126
126
  },
127
127
  {
128
128
  name: 'get_changes',
129
- desc: 'Recent resource creates, updates, and deletes from the cluster timeline. Use to investigate what changed before an incident.',
129
+ desc: 'Recent resource creates, updates, and deletes from the Kubernetes timeline. Helm release history is separate; use list_helm_releases or get_helm_release include=history,operations for failed upgrades and rollbacks.',
130
130
  params: [
131
131
  { arg: 'namespace', desc: 'filter to a specific namespace' },
132
132
  { arg: 'kind', desc: 'filter to a resource kind (e.g. Deployment)' },
@@ -147,16 +147,16 @@ export const MCP_TOOL_CATALOG: MCPToolInfo[] = [
147
147
  },
148
148
  {
149
149
  name: 'list_helm_releases',
150
- desc: 'All Helm releases in the cluster with status and resource health name, namespace, chart, version.',
150
+ desc: 'All Helm releases with status, resource health, storage namespace, Flux ownership, current lastOperation, and capped operation trails for failed upgrades, rollbacks, or stuck pending operations.',
151
151
  params: [{ arg: 'namespace', desc: 'filter to a specific namespace' }],
152
152
  },
153
153
  {
154
154
  name: 'get_helm_release',
155
- desc: 'Detailed Helm release info with owned resources and their status. Optionally include values, revision history, or a manifest diff between revisions.',
155
+ desc: 'Detailed Helm release info with owned resources, health, Flux ownership, and current lastOperation; include history and operations for the full revision trail.',
156
156
  params: [
157
- { arg: 'namespace', required: true, desc: 'release namespace' },
157
+ { arg: 'namespace', required: true, desc: 'Helm storage namespace; use storageNamespace from list_helm_releases when present' },
158
158
  { arg: 'name', required: true, desc: 'release name' },
159
- { arg: 'include', desc: 'values, history, diff' },
159
+ { arg: 'include', desc: 'values, history, operations, diff' },
160
160
  { arg: 'diff_revision_1', desc: 'first revision for diff' },
161
161
  { arg: 'diff_revision_2', desc: 'second revision for diff (defaults to current)' },
162
162
  ],
@@ -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
 
@@ -45,7 +45,7 @@ const NAV_ITEMS: NavItemDef[] = [
45
45
  { view: 'topology', icon: Network, label: 'Topology' },
46
46
  { view: 'applications', icon: Boxes, label: 'Applications' },
47
47
  { view: 'timeline', icon: Clock, label: 'Timeline' },
48
- { view: 'traffic', icon: Activity, label: 'Traffic' },
48
+ { view: 'traffic', icon: Activity, label: 'Live Traffic' },
49
49
  { view: 'helm', icon: Package, label: 'Helm' },
50
50
  { view: 'gitops', icon: GitBranch, label: 'GitOps' },
51
51
  { view: 'checks', icon: ShieldCheck, label: 'Checks' },
@@ -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>