@skyhook-io/radar-app 1.1.1 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/package.json +1 -1
  2. package/src/App.tsx +81 -18
  3. package/src/api/client.ts +165 -11
  4. package/src/api/rbac.ts +57 -0
  5. package/src/components/compare/CompareViewRoute.tsx +116 -0
  6. package/src/components/compare/useCompareCandidates.ts +27 -0
  7. package/src/components/compare/useCompareLauncher.tsx +76 -0
  8. package/src/components/cost/CostView.tsx +1 -1
  9. package/src/components/gitops/GitOpsView.tsx +1 -1
  10. package/src/components/helm/InstallWizard.tsx +5 -5
  11. package/src/components/helm/ValuesViewer.tsx +3 -39
  12. package/src/components/home/HomeView.tsx +18 -2
  13. package/src/components/resource/HPACharts.tsx +232 -0
  14. package/src/components/resource/PVCUsageBar.tsx +59 -0
  15. package/src/components/resource/PrometheusCharts.tsx +151 -434
  16. package/src/components/resource/PrometheusChartsGrid.tsx +339 -0
  17. package/src/components/resource/RestartChart.tsx +124 -0
  18. package/src/components/resource/RightsizingStrip.tsx +167 -0
  19. package/src/components/resources/CompositeRenderer.tsx +101 -0
  20. package/src/components/resources/renderers/HPARenderer.tsx +17 -1
  21. package/src/components/resources/renderers/NamespaceRenderer.tsx +22 -0
  22. package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
  23. package/src/components/resources/renderers/PodRenderer.tsx +13 -0
  24. package/src/components/resources/renderers/RoleBindingRenderer.tsx +43 -1
  25. package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
  26. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
  27. package/src/components/resources/renderers/WorkloadRenderer.tsx +12 -0
  28. package/src/components/resources/renderers/index.ts +1 -0
  29. package/src/components/settings/MyPermissionsDialog.tsx +231 -0
  30. package/src/components/ui/DiagnosticsOverlay.tsx +1 -0
  31. package/src/components/workload/WorkloadView.tsx +107 -3
  32. package/src/context/NavCustomization.tsx +13 -0
@@ -0,0 +1,101 @@
1
+ import { useMemo } from 'react'
2
+ import { useQueries } from '@tanstack/react-query'
3
+ import {
4
+ CompositeRenderer as BaseCompositeRenderer,
5
+ type ComposedRefStatus,
6
+ } from '@skyhook-io/k8s-ui/components/resources/renderers/CompositeRenderer'
7
+ import {
8
+ getCrossplaneResourceRefs,
9
+ type CrossplaneResourceRef,
10
+ } from '@skyhook-io/k8s-ui/components/resources/resource-utils-crossplane'
11
+ import { getResourceStatus } from '@skyhook-io/k8s-ui'
12
+ import { kindToPlural } from '@skyhook-io/k8s-ui/utils/navigation'
13
+ import type { ResourceRef as NavRef } from '../../types'
14
+ import { fetchJSON, ApiError } from '../../api/client'
15
+
16
+ interface CompositeRendererProps {
17
+ data: any
18
+ onNavigate?: (ref: NavRef) => void
19
+ }
20
+
21
+ function makeRefKey(ref: CrossplaneResourceRef): string {
22
+ return `${ref.apiVersion}/${ref.kind}/${ref.namespace ?? ''}/${ref.name}`
23
+ }
24
+
25
+ // Crossplane refs carry full apiVersion (e.g. "s3.aws.upbound.io/v1beta1")
26
+ // because plural collisions across provider groups are real — two providers
27
+ // can ship CRDs with the same kind. Drop the version, keep the group.
28
+ function groupFromApiVersion(apiVersion: string | undefined): string {
29
+ if (!apiVersion) return ''
30
+ const slash = apiVersion.indexOf('/')
31
+ return slash < 0 ? '' : apiVersion.slice(0, slash)
32
+ }
33
+
34
+ /**
35
+ * Host wrapper for the package's CompositeRenderer: fans out a React Query
36
+ * lookup per composed resource ref so each row in the composed-resources
37
+ * list can show a live status badge.
38
+ *
39
+ * Errors are folded into the per-ref status entry rather than thrown — a
40
+ * missing composed resource (e.g. 404 because reconciliation hasn't created
41
+ * it yet) is a normal state for a freshly-applied Composite, not a failure.
42
+ */
43
+ export function CompositeRenderer({ data, onNavigate }: CompositeRendererProps) {
44
+ const refs = useMemo<CrossplaneResourceRef[]>(() => getCrossplaneResourceRefs(data), [data])
45
+
46
+ const queries = useQueries({
47
+ queries: refs.map(ref => {
48
+ const group = groupFromApiVersion(ref.apiVersion)
49
+ return {
50
+ queryKey: ['composed-ref', group, ref.kind, ref.namespace ?? '', ref.name],
51
+ queryFn: async () => {
52
+ const ns = ref.namespace || '_'
53
+ const plural = kindToPlural(ref.kind)
54
+ const query = group ? `?group=${encodeURIComponent(group)}` : ''
55
+ return fetchJSON<{ resource: any }>(`/resources/${plural}/${ns}/${ref.name}${query}`)
56
+ },
57
+ staleTime: 30000,
58
+ retry: false,
59
+ enabled: Boolean(ref.kind && ref.name),
60
+ }
61
+ }),
62
+ })
63
+
64
+ const composedRefStatuses = useMemo<Map<string, ComposedRefStatus>>(() => {
65
+ const map = new Map<string, ComposedRefStatus>()
66
+ refs.forEach((ref, i) => {
67
+ const q = queries[i]
68
+ const key = makeRefKey(ref)
69
+ if (q.isLoading) {
70
+ map.set(key, { ref, loading: true })
71
+ return
72
+ }
73
+ if (q.isError) {
74
+ // 404 = the composed resource genuinely doesn't exist yet (normal
75
+ // during reconciliation). Any other failure (401/403/500/network)
76
+ // means we can't tell the operator the resource's status — render
77
+ // it distinctly so they don't read "missing" and think the resource
78
+ // is absent when they've actually just lost permission to read it.
79
+ if (q.error instanceof ApiError && q.error.status === 404) {
80
+ map.set(key, { ref, missing: true })
81
+ } else {
82
+ const message = q.error instanceof Error ? q.error.message : 'Failed to fetch composed resource'
83
+ map.set(key, { ref, error: true, errorMessage: message })
84
+ }
85
+ return
86
+ }
87
+ if (!q.data) {
88
+ map.set(key, { ref, missing: true })
89
+ return
90
+ }
91
+ const status = getResourceStatus(kindToPlural(ref.kind), q.data.resource) ?? undefined
92
+ map.set(key, {
93
+ ref,
94
+ status: status ? { ...status, level: (status as any).level ?? 'unknown' } : undefined,
95
+ })
96
+ })
97
+ return map
98
+ }, [refs, queries])
99
+
100
+ return <BaseCompositeRenderer data={data} onNavigate={onNavigate} composedRefStatuses={composedRefStatuses} />
101
+ }
@@ -1 +1,17 @@
1
- export * from '@skyhook-io/k8s-ui/components/resources/renderers/HPARenderer'
1
+ import { HPARenderer as BaseHPARenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/HPARenderer'
2
+ import { HPACharts } from '../../resource/HPACharts'
3
+
4
+ interface HPARendererProps {
5
+ data: any
6
+ onNavigate?: (ref: { kind: string; namespace: string; name: string }) => void
7
+ }
8
+
9
+ export function HPARenderer({ data, onNavigate }: HPARendererProps) {
10
+ return (
11
+ <BaseHPARenderer
12
+ data={data}
13
+ onNavigate={onNavigate}
14
+ extraSections={<HPACharts data={data} />}
15
+ />
16
+ )
17
+ }
@@ -0,0 +1,22 @@
1
+ import { NamespaceRenderer as BaseNamespaceRenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/NamespaceRenderer'
2
+ import type { ResourceRef } from '@skyhook-io/k8s-ui'
3
+ import { useRBACNamespace } from '../../../api/rbac'
4
+
5
+ interface NamespaceRendererProps {
6
+ data: any
7
+ onNavigate?: (ref: ResourceRef) => void
8
+ }
9
+
10
+ export function NamespaceRenderer({ data, onNavigate }: NamespaceRendererProps) {
11
+ const name = data?.metadata?.name ?? ''
12
+ const { data: rbacData, isLoading, error } = useRBACNamespace(name, !!name)
13
+ return (
14
+ <BaseNamespaceRenderer
15
+ data={data}
16
+ rbacData={rbacData ?? null}
17
+ rbacLoading={isLoading}
18
+ rbacError={error as Error | null}
19
+ onNavigate={onNavigate}
20
+ />
21
+ )
22
+ }
@@ -1 +1,19 @@
1
- export * from '@skyhook-io/k8s-ui/components/resources/renderers/PVCRenderer'
1
+ import { PVCRenderer as BasePVCRenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/PVCRenderer'
2
+ import { PVCUsageBar } from '../../resource/PVCUsageBar'
3
+
4
+ interface PVCRendererProps {
5
+ data: any
6
+ onNavigate?: (ref: { kind: string; namespace: string; name: string }) => void
7
+ }
8
+
9
+ export function PVCRenderer({ data, onNavigate }: PVCRendererProps) {
10
+ const namespace = data?.metadata?.namespace ?? ''
11
+ const name = data?.metadata?.name ?? ''
12
+ return (
13
+ <BasePVCRenderer
14
+ data={data}
15
+ onNavigate={onNavigate}
16
+ extraSections={namespace && name ? <PVCUsageBar namespace={namespace} name={name} /> : undefined}
17
+ />
18
+ )
19
+ }
@@ -4,6 +4,7 @@ import type { ResolvedEnvFrom } from '@skyhook-io/k8s-ui'
4
4
  import { useOpenTerminal, useOpenLogs } from '../../dock'
5
5
  import { useNamespacedCapabilities } from '../../../contexts/CapabilitiesContext'
6
6
  import { usePodMetrics, usePodMetricsHistory, usePrometheusResourceMetrics, usePrometheusStatus } from '../../../api/client'
7
+ import { useRBACSubject } from '../../../api/rbac'
7
8
  import { PortForwardInlineButton } from '../../portforward/PortForwardButton'
8
9
  import { ImageFilesystemModal } from '../ImageFilesystemModal'
9
10
  import { PodFilesystemModal } from '../PodFilesystemModal'
@@ -42,6 +43,15 @@ export function PodRenderer({ data, onCopy, copied, onNavigate, onOpenLogs, reso
42
43
  ) ?? false)
43
44
  const hideMetricsServer = prometheusHasCPU || (prometheusConnected && prometheusCPULoading)
44
45
 
46
+ // RBAC reverse-lookup for the Pod's ServiceAccount. Defaults to "default" —
47
+ // that's the SA every Pod uses when spec.serviceAccountName is unset, which
48
+ // is itself a useful signal (operators often don't realize "default" still
49
+ // has whatever permissions the namespace's defaults grant).
50
+ const saName = data.spec?.serviceAccountName || 'default'
51
+ const { data: rbacData, isLoading: rbacLoading, error: rbacError } = useRBACSubject(
52
+ 'ServiceAccount', namespace ?? '', saName, !!namespace,
53
+ )
54
+
45
55
  return (
46
56
  <BasePodRenderer
47
57
  data={data}
@@ -50,6 +60,9 @@ export function PodRenderer({ data, onCopy, copied, onNavigate, onOpenLogs, reso
50
60
  onNavigate={onNavigate}
51
61
  onOpenLogs={onOpenLogs}
52
62
  resolvedEnvFrom={resolvedEnvFrom}
63
+ rbacData={rbacData ?? null}
64
+ rbacLoading={rbacLoading}
65
+ rbacError={rbacError as Error | null}
53
66
  canExec={canExec}
54
67
  canViewLogs={canViewLogs}
55
68
  canPortForward={canPortForward}
@@ -1 +1,43 @@
1
- export * from '@skyhook-io/k8s-ui/components/resources/renderers/RoleBindingRenderer'
1
+ import { RoleBindingRenderer as BaseRoleBindingRenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/RoleBindingRenderer'
2
+ import type { ResourceRef } from '@skyhook-io/k8s-ui'
3
+ import { useResource } from '../../../api/client'
4
+
5
+ interface RoleBindingRendererProps {
6
+ data: any
7
+ onNavigate?: (ref: ResourceRef) => void
8
+ }
9
+
10
+ // Reuses the generic resource endpoint to fetch the referenced Role/ClusterRole
11
+ // so the base renderer can show an inline rules preview. A dedicated /api/rbac
12
+ // "rules-only" endpoint would be tighter, but the generic one is already
13
+ // cached and respects per-user RBAC the same way.
14
+ export function RoleBindingRenderer({ data, onNavigate }: RoleBindingRendererProps) {
15
+ const roleRef = data?.roleRef ?? {}
16
+ const isClusterRole = roleRef.kind === 'ClusterRole'
17
+ const kind = isClusterRole ? 'clusterroles' : 'roles'
18
+ // For ClusterRole the namespace is cluster-scoped (empty); for Role the
19
+ // binding's namespace is the role's namespace (Roles are namespaced and
20
+ // RoleBindings can only reference Roles in their own namespace).
21
+ const namespace = isClusterRole ? '' : (data?.metadata?.namespace ?? '')
22
+ const name = roleRef.name ?? ''
23
+
24
+ const { data: role, isLoading } = useResource<any>(kind, namespace, name)
25
+ // `role` is undefined while loading, then the resource, then potentially
26
+ // null on 404. Pass [] for "loaded but no rules" so the base renderer's
27
+ // `rules === null` branch fires only on outright fetch failure (orphan).
28
+ const rules =
29
+ isLoading
30
+ ? undefined
31
+ : role
32
+ ? (role.rules ?? [])
33
+ : null
34
+
35
+ return (
36
+ <BaseRoleBindingRenderer
37
+ data={data}
38
+ onNavigate={onNavigate}
39
+ roleRules={rules ?? null}
40
+ roleRulesLoading={isLoading}
41
+ />
42
+ )
43
+ }
@@ -1 +1,27 @@
1
- export * from '@skyhook-io/k8s-ui/components/resources/renderers/RoleRenderer'
1
+ import { RoleRenderer as BaseRoleRenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/RoleRenderer'
2
+ import type { ResourceRef } from '@skyhook-io/k8s-ui'
3
+ import { useRBACRole } from '../../../api/rbac'
4
+
5
+ interface RoleRendererProps {
6
+ data: any
7
+ onNavigate?: (ref: ResourceRef) => void
8
+ }
9
+
10
+ export function RoleRenderer({ data, onNavigate }: RoleRendererProps) {
11
+ // ClusterRole has no namespace; Role does. The kind in the manifest is
12
+ // authoritative — RoleRenderer is shared between both kinds via the
13
+ // dispatch, so checking it here is the only way to know which we are.
14
+ const kind: 'Role' | 'ClusterRole' = data?.kind === 'ClusterRole' ? 'ClusterRole' : 'Role'
15
+ const namespace = data?.metadata?.namespace ?? ''
16
+ const name = data?.metadata?.name ?? ''
17
+ const { data: rbacRoleData, isLoading, error } = useRBACRole(kind, namespace, name, !!name)
18
+ return (
19
+ <BaseRoleRenderer
20
+ data={data}
21
+ rbacRoleData={rbacRoleData ?? null}
22
+ rbacRoleLoading={isLoading}
23
+ rbacRoleError={error as Error | null}
24
+ onNavigate={onNavigate}
25
+ />
26
+ )
27
+ }
@@ -1 +1,28 @@
1
- export * from '@skyhook-io/k8s-ui/components/resources/renderers/ServiceAccountRenderer'
1
+ import { ServiceAccountRenderer as BaseServiceAccountRenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/ServiceAccountRenderer'
2
+ import type { ResourceRef } from '@skyhook-io/k8s-ui'
3
+ import { useRBACSubject } from '../../../api/rbac'
4
+
5
+ interface ServiceAccountRendererProps {
6
+ data: any
7
+ onNavigate?: (ref: ResourceRef) => void
8
+ }
9
+
10
+ export function ServiceAccountRenderer({ data, onNavigate }: ServiceAccountRendererProps) {
11
+ const namespace = data?.metadata?.namespace ?? ''
12
+ const name = data?.metadata?.name ?? ''
13
+ const { data: rbacData, isLoading, error } = useRBACSubject(
14
+ 'ServiceAccount',
15
+ namespace,
16
+ name,
17
+ !!name,
18
+ )
19
+ return (
20
+ <BaseServiceAccountRenderer
21
+ data={data}
22
+ rbacData={rbacData ?? null}
23
+ rbacLoading={isLoading}
24
+ rbacError={error as Error | null}
25
+ onNavigate={onNavigate}
26
+ />
27
+ )
28
+ }
@@ -1,6 +1,7 @@
1
1
  import { WorkloadRenderer as BaseWorkloadRenderer } from '@skyhook-io/k8s-ui/components/resources/renderers/WorkloadRenderer'
2
2
  import { useNavigate } from 'react-router-dom'
3
3
  import { useScaleWorkload } from '../../../api/client'
4
+ import { useRBACSubject } from '../../../api/rbac'
4
5
  import { useQueryClient } from '@tanstack/react-query'
5
6
 
6
7
  // Map plural lowercase kind to singular PascalCase for ownerReferences matching
@@ -29,12 +30,23 @@ export function WorkloadRenderer({ kind, data, onNavigate }: WorkloadRendererPro
29
30
  const metadata = data.metadata || {}
30
31
  const viewPodsUrl = `/resources/pods?ownerKind=${encodeURIComponent(getOwnerKind(kind))}&ownerName=${encodeURIComponent(metadata.name || '')}&namespace=${encodeURIComponent(metadata.namespace || '')}`
31
32
 
33
+ // SA reverse-lookup for the workload's pod template. "default" when unset
34
+ // (matches PodRenderer's semantics — the SA every Pod uses by default).
35
+ const saName = data?.spec?.template?.spec?.serviceAccountName || 'default'
36
+ const namespace = metadata.namespace ?? ''
37
+ const { data: rbacData, isLoading: rbacLoading, error: rbacError } = useRBACSubject(
38
+ 'ServiceAccount', namespace, saName, !!namespace,
39
+ )
40
+
32
41
  return (
33
42
  <BaseWorkloadRenderer
34
43
  kind={kind}
35
44
  data={data}
36
45
  onNavigate={onNavigate}
37
46
  onViewPods={() => navigate(viewPodsUrl)}
47
+ rbacData={rbacData ?? null}
48
+ rbacLoading={rbacLoading}
49
+ rbacError={rbacError as Error | null}
38
50
  onScale={async (replicas) => {
39
51
  await scaleMutation.mutateAsync({
40
52
  kind,
@@ -28,6 +28,7 @@ export { WorkflowTemplateRenderer } from './WorkflowTemplateRenderer'
28
28
  export { NetworkPolicyRenderer } from './NetworkPolicyRenderer'
29
29
  export { PodDisruptionBudgetRenderer } from './PodDisruptionBudgetRenderer'
30
30
  export { ServiceAccountRenderer } from './ServiceAccountRenderer'
31
+ export { NamespaceRenderer } from './NamespaceRenderer'
31
32
  export { RoleRenderer } from './RoleRenderer'
32
33
  export { RoleBindingRenderer } from './RoleBindingRenderer'
33
34
  export { EventRenderer } from './EventRenderer'
@@ -0,0 +1,231 @@
1
+ import { useState, useEffect, useRef } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { Shield, X, Loader2 } from 'lucide-react'
4
+ import { clsx } from 'clsx'
5
+ import {
6
+ rbacVerbBadgeClass,
7
+ rbacResourceBadgeClass,
8
+ rbacApiGroupBadgeClass,
9
+ rbacResourceNameBadgeClass,
10
+ rbacNonResourceUrlBadgeClass,
11
+ } from '@skyhook-io/k8s-ui'
12
+ import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
13
+ import { TRANSITION_BACKDROP, TRANSITION_PANEL } from '../../utils/animation'
14
+ import { useNamespaces, useAuthMe } from '../../api/client'
15
+ import { useRBACWhoami } from '../../api/rbac'
16
+
17
+ interface MyPermissionsDialogProps {
18
+ open: boolean
19
+ onClose: () => void
20
+ }
21
+
22
+ export function MyPermissionsDialog({ open, onClose }: MyPermissionsDialogProps) {
23
+ const dialogRef = useRef<HTMLDivElement>(null)
24
+ const { shouldRender, isOpen } = useAnimatedUnmount(open, 200)
25
+ const [namespace, setNamespace] = useState('default')
26
+
27
+ const { data: authMe } = useAuthMe()
28
+ const { data: namespaces } = useNamespaces()
29
+ const { data: whoami, isLoading, error } = useRBACWhoami(namespace, open)
30
+
31
+ // ESC + focus
32
+ useEffect(() => {
33
+ if (!open) return
34
+ const handler = (e: KeyboardEvent) => {
35
+ if (e.key === 'Escape') {
36
+ e.stopPropagation()
37
+ onClose()
38
+ }
39
+ }
40
+ document.addEventListener('keydown', handler, true)
41
+ return () => document.removeEventListener('keydown', handler, true)
42
+ }, [open, onClose])
43
+ useEffect(() => {
44
+ if (open && dialogRef.current) dialogRef.current.focus()
45
+ }, [open])
46
+
47
+ if (!shouldRender) return null
48
+
49
+ return createPortal(
50
+ <div className="fixed inset-0 z-50 flex items-center justify-center">
51
+ <div
52
+ className={clsx(
53
+ 'absolute inset-0 bg-black/60 backdrop-blur-sm',
54
+ TRANSITION_BACKDROP,
55
+ isOpen ? 'opacity-100' : 'opacity-0',
56
+ )}
57
+ onClick={onClose}
58
+ />
59
+ <div
60
+ ref={dialogRef}
61
+ tabIndex={-1}
62
+ className={clsx(
63
+ 'relative bg-theme-surface border border-theme-border shadow-theme-lg w-full outline-none flex flex-col',
64
+ 'max-sm:inset-0 max-sm:absolute max-sm:rounded-none max-sm:max-h-full max-sm:border-0',
65
+ 'sm:rounded-xl sm:max-w-3xl sm:mx-4 sm:max-h-[85vh]',
66
+ TRANSITION_PANEL,
67
+ isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95',
68
+ )}
69
+ >
70
+ {/* Header */}
71
+ <div className="flex items-center justify-between p-4 border-b border-theme-border shrink-0">
72
+ <div className="flex items-center gap-2">
73
+ <Shield className="w-5 h-5 text-theme-text-secondary" />
74
+ <h2 className="text-lg font-semibold text-theme-text-primary">My Permissions</h2>
75
+ </div>
76
+ <button
77
+ onClick={onClose}
78
+ className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded"
79
+ >
80
+ <X className="w-5 h-5" />
81
+ </button>
82
+ </div>
83
+
84
+ <div className="overflow-y-auto p-4 flex-1 space-y-4">
85
+ {/* Identity + namespace selector */}
86
+ <div className="flex flex-wrap items-end gap-3">
87
+ <div className="flex-1 min-w-[180px]">
88
+ <label className="block text-xs text-theme-text-tertiary mb-1">Identity</label>
89
+ <div className="px-2 py-1.5 text-sm bg-theme-elevated rounded border border-theme-border text-theme-text-primary truncate">
90
+ {authMe?.username || '(current kubeconfig user)'}
91
+ </div>
92
+ </div>
93
+ <div className="flex-1 min-w-[180px]">
94
+ <label className="block text-xs text-theme-text-tertiary mb-1">Namespace</label>
95
+ <select
96
+ value={namespace}
97
+ onChange={(e) => setNamespace(e.target.value)}
98
+ className="w-full px-2 py-1.5 text-sm bg-theme-elevated border border-theme-border rounded text-theme-text-primary focus:outline-none focus:border-blue-500"
99
+ >
100
+ {namespaces?.map((ns) => (
101
+ <option key={ns.name} value={ns.name}>{ns.name}</option>
102
+ ))}
103
+ {!namespaces?.some((ns) => ns.name === namespace) && (
104
+ <option value={namespace}>{namespace}</option>
105
+ )}
106
+ </select>
107
+ </div>
108
+ </div>
109
+
110
+ <p className="text-xs text-theme-text-tertiary">
111
+ Computed by the Kubernetes API via{' '}
112
+ <code className="bg-theme-elevated px-1 rounded">SelfSubjectRulesReview</code>.
113
+ Shows what you can do in <span className="text-theme-text-secondary">{namespace}</span>,
114
+ plus any cluster-scoped rules that apply everywhere.
115
+ </p>
116
+
117
+ {whoami?.incomplete && (
118
+ <div className="px-2.5 py-1.5 text-xs bg-amber-500/10 border border-amber-500/20 rounded">
119
+ The apiserver returned an incomplete rule set
120
+ {whoami.evaluationError ? ` (${whoami.evaluationError})` : ''}.
121
+ The list below may understate your actual permissions.
122
+ </div>
123
+ )}
124
+
125
+ {isLoading ? (
126
+ <div className="flex items-center gap-2 text-sm text-theme-text-tertiary">
127
+ <Loader2 className="w-3.5 h-3.5 animate-spin" />
128
+ Querying apiserver…
129
+ </div>
130
+ ) : error ? (
131
+ <div className="text-sm text-red-400">
132
+ Failed to load: {(error as Error).message}
133
+ </div>
134
+ ) : whoami ? (
135
+ <PermissionsTable whoami={whoami} />
136
+ ) : null}
137
+ </div>
138
+ </div>
139
+ </div>,
140
+ document.body,
141
+ )
142
+ }
143
+
144
+ function PermissionsTable({ whoami }: { whoami: { resourceRules: any[]; nonResourceRules: any[] } }) {
145
+ const resourceRules = whoami.resourceRules ?? []
146
+ const nonResourceRules = whoami.nonResourceRules ?? []
147
+
148
+ return (
149
+ <div className="space-y-4">
150
+ <div>
151
+ <div className="text-xs font-medium text-theme-text-secondary uppercase tracking-wider mb-2">
152
+ Resource permissions ({resourceRules.length})
153
+ </div>
154
+ {resourceRules.length === 0 ? (
155
+ <div className="text-sm text-theme-text-tertiary">
156
+ No resource permissions in this namespace.
157
+ </div>
158
+ ) : (
159
+ <div className="space-y-2">
160
+ {resourceRules.map((r, i) => (
161
+ <ResourceRuleRow key={i} rule={r} />
162
+ ))}
163
+ </div>
164
+ )}
165
+ </div>
166
+
167
+ {nonResourceRules.length > 0 && (
168
+ <div>
169
+ <div className="text-xs font-medium text-theme-text-secondary uppercase tracking-wider mb-2">
170
+ Non-resource permissions ({nonResourceRules.length})
171
+ </div>
172
+ <div className="space-y-1 text-xs">
173
+ {nonResourceRules.map((r, i) => (
174
+ <div key={i} className="flex items-center gap-1 flex-wrap">
175
+ {(r.verbs ?? []).map((v: string) => (
176
+ <span key={v} className={clsx('badge', rbacVerbBadgeClass(v))}>{v}</span>
177
+ ))}
178
+ <span className="text-theme-text-secondary">on</span>
179
+ {(r.nonResourceURLs ?? []).map((u: string) => (
180
+ <span key={u} className={clsx('badge', rbacNonResourceUrlBadgeClass)}>{u}</span>
181
+ ))}
182
+ </div>
183
+ ))}
184
+ </div>
185
+ </div>
186
+ )}
187
+ </div>
188
+ )
189
+ }
190
+
191
+ function ResourceRuleRow({ rule }: { rule: { verbs?: string[]; apiGroups?: string[]; resources?: string[]; resourceNames?: string[] } }) {
192
+ const verbs = rule.verbs ?? []
193
+ const resources = rule.resources ?? []
194
+ const groups = rule.apiGroups ?? []
195
+ const resourceNames = rule.resourceNames ?? []
196
+ return (
197
+ <div className="card-inner text-xs flex items-center gap-1 flex-wrap">
198
+ {verbs.map((v) => (
199
+ <span key={v} className={clsx('badge', rbacVerbBadgeClass(v))}>{v}</span>
200
+ ))}
201
+ <span className="text-theme-text-secondary">on</span>
202
+ {resources.length === 0 ? (
203
+ <span className="text-theme-text-secondary italic">(no resources)</span>
204
+ ) : (
205
+ resources.map((r) => (
206
+ <span key={r} className={clsx('badge', rbacResourceBadgeClass)}>
207
+ {r === '*' ? '*' : r}
208
+ </span>
209
+ ))
210
+ )}
211
+ {groups.length > 0 && groups.some((g) => g !== '') && (
212
+ <>
213
+ <span className="text-theme-text-secondary">in</span>
214
+ {groups.map((g) => (
215
+ <span key={g} className={clsx('badge', rbacApiGroupBadgeClass)}>
216
+ {g === '' ? 'core' : g}
217
+ </span>
218
+ ))}
219
+ </>
220
+ )}
221
+ {resourceNames.length > 0 && (
222
+ <>
223
+ <span className="text-theme-text-secondary">named</span>
224
+ {resourceNames.map((n) => (
225
+ <span key={n} className={clsx('badge', rbacResourceNameBadgeClass)}>{n}</span>
226
+ ))}
227
+ </>
228
+ )}
229
+ </div>
230
+ )
231
+ }
@@ -484,6 +484,7 @@ function ConfigSection({ data }: { data: DiagnosticsSnapshot }) {
484
484
  <Row label="History Limit" value={cfg.historyLimit.toLocaleString()} />
485
485
  <Row label="MCP Enabled" value={cfg.mcpEnabled ? 'Yes' : 'No'} />
486
486
  <Row label="Prometheus URL" value={cfg.hasPrometheusURL ? 'Set' : 'Auto-discover'} />
487
+ <Row label="Prometheus Headers" value={cfg.hasPrometheusHeaders ? 'Set' : 'None'} />
487
488
  </Section>
488
489
  )
489
490
  }