@skyhook-io/radar-app 1.1.1 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/App.tsx +81 -18
- package/src/api/client.ts +165 -11
- package/src/api/rbac.ts +57 -0
- package/src/components/compare/CompareViewRoute.tsx +116 -0
- package/src/components/compare/useCompareCandidates.ts +27 -0
- package/src/components/compare/useCompareLauncher.tsx +76 -0
- package/src/components/cost/CostView.tsx +1 -1
- package/src/components/gitops/GitOpsView.tsx +1 -1
- package/src/components/helm/InstallWizard.tsx +5 -5
- package/src/components/helm/ValuesViewer.tsx +3 -39
- package/src/components/home/HomeView.tsx +18 -2
- package/src/components/resource/HPACharts.tsx +232 -0
- package/src/components/resource/PVCUsageBar.tsx +59 -0
- package/src/components/resource/PrometheusCharts.tsx +151 -434
- package/src/components/resource/PrometheusChartsGrid.tsx +339 -0
- package/src/components/resource/RestartChart.tsx +124 -0
- package/src/components/resource/RightsizingStrip.tsx +167 -0
- package/src/components/resources/CompositeRenderer.tsx +101 -0
- package/src/components/resources/renderers/HPARenderer.tsx +17 -1
- package/src/components/resources/renderers/NamespaceRenderer.tsx +22 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +19 -1
- package/src/components/resources/renderers/PodRenderer.tsx +13 -0
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +43 -1
- package/src/components/resources/renderers/RoleRenderer.tsx +27 -1
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +28 -1
- package/src/components/resources/renderers/WorkloadRenderer.tsx +12 -0
- package/src/components/resources/renderers/index.ts +1 -0
- package/src/components/settings/MyPermissionsDialog.tsx +231 -0
- package/src/components/ui/DiagnosticsOverlay.tsx +1 -0
- package/src/components/workload/WorkloadView.tsx +107 -3
- package/src/context/NavCustomization.tsx +13 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|