@skyhook-io/radar-app 1.16.2 → 1.17.0

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 (93) hide show
  1. package/package.json +5 -5
  2. package/src/App.tsx +60 -12
  3. package/src/RadarApp.tsx +27 -17
  4. package/src/api/client.goneResource.test.ts +49 -0
  5. package/src/api/client.ts +168 -55
  6. package/src/api/cnpg.ts +95 -0
  7. package/src/api/diagnose.test.ts +40 -0
  8. package/src/api/diagnose.ts +12 -1
  9. package/src/api/radarFeatures.render.test.tsx +19 -1
  10. package/src/api/radarFeatures.ts +3 -0
  11. package/src/api/timelineSource.ts +1 -1
  12. package/src/api/traffic.sources.test.ts +55 -0
  13. package/src/api/traffic.ts +173 -14
  14. package/src/components/NoClusterAccessBanner.tsx +68 -0
  15. package/src/components/applications/ApplicationsView.tsx +3 -0
  16. package/src/components/capacity/CapacityPoolDetail.tsx +18 -7
  17. package/src/components/capacity/CapacityView.test.tsx +31 -1
  18. package/src/components/capacity/shared.tsx +12 -21
  19. package/src/components/cnpg/CNPGClusterActivity.tsx +49 -0
  20. package/src/components/cnpg/CNPGClusterLogs.tsx +62 -0
  21. package/src/components/cnpg/CNPGDeclarations.tsx +311 -0
  22. package/src/components/cnpg/CNPGDetailPage.tsx +228 -0
  23. package/src/components/cnpg/CNPGDrawerTrail.tsx +37 -0
  24. package/src/components/cnpg/CNPGOperator.tsx +234 -0
  25. package/src/components/cnpg/CNPGOverview.tsx +307 -0
  26. package/src/components/cnpg/CNPGPooling.tsx +116 -0
  27. package/src/components/cnpg/CNPGProtection.tsx +320 -0
  28. package/src/components/cnpg/CNPGSummaryHost.tsx +143 -0
  29. package/src/components/cnpg/CNPGView.tsx +156 -0
  30. package/src/components/cnpg/paths.ts +13 -0
  31. package/src/components/cnpg/routes.test.ts +60 -0
  32. package/src/components/cnpg/routes.ts +112 -0
  33. package/src/components/cnpg/shared.tsx +290 -0
  34. package/src/components/cnpg/useCNPGSidebarWorkspace.ts +88 -0
  35. package/src/components/diagnose/AgentSetupNotice.test.tsx +58 -0
  36. package/src/components/diagnose/AgentSetupNotice.tsx +142 -51
  37. package/src/components/diagnose/ApplyDialog.ack.test.tsx +47 -0
  38. package/src/components/diagnose/ApplyDialog.test.tsx +59 -6
  39. package/src/components/diagnose/ApplyDialog.tsx +26 -28
  40. package/src/components/diagnose/DiagnoseContext.test.ts +43 -0
  41. package/src/components/diagnose/DiagnoseContext.tsx +136 -54
  42. package/src/components/diagnose/DiagnoseSurface.tsx +16 -3
  43. package/src/components/diagnose/InvestigationView.tsx +39 -2
  44. package/src/components/diagnose/LocalDiagnoseAction.tsx +40 -20
  45. package/src/components/diagnose/agentCatalog.ts +2 -2
  46. package/src/components/diagnose/diagnoseEvidenceTypes.ts +2 -0
  47. package/src/components/dock/TrafficFlowListTab.tsx +27 -6
  48. package/src/components/execution/BatchExecutionView.tsx +11 -11
  49. package/src/components/helm/ChartBrowser.tsx +9 -10
  50. package/src/components/helm/HelmCompareRoute.tsx +108 -115
  51. package/src/components/helm/HelmReleaseDrawer.tsx +26 -44
  52. package/src/components/helm/InstallWizard.tsx +5 -2
  53. package/src/components/helm/RevisionHistory.tsx +3 -2
  54. package/src/components/helm/TrackChartSourceDialog.tsx +10 -2
  55. package/src/components/helm/ValuesViewer.tsx +1 -1
  56. package/src/components/home/ClusterHealthCard.tsx +7 -0
  57. package/src/components/home/mcpToolCatalog.ts +1 -1
  58. package/src/components/resources/ResourceDetailDrawer.tsx +6 -0
  59. package/src/components/resources/ResourcesView.tsx +6 -38
  60. package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +2 -1
  61. package/src/components/resources/renderers/NodeRenderer.tsx +7 -1
  62. package/src/components/resources/renderers/ServiceRenderer.test.tsx +81 -0
  63. package/src/components/resources/renderers/ServiceRenderer.tsx +19 -14
  64. package/src/components/settings/SettingsDialog.tsx +122 -19
  65. package/src/components/shared/LargeClusterNamespacePicker.tsx +11 -5
  66. package/src/components/timeline/TimelineList.test.ts +47 -29
  67. package/src/components/timeline/TimelineList.tsx +60 -11
  68. package/src/components/timeline/TimelineRetentionNote.test.tsx +49 -0
  69. package/src/components/timeline/TimelineRetentionNote.tsx +20 -0
  70. package/src/components/timeline/TimelineView.tsx +16 -2
  71. package/src/components/timeline/TimelineView.urlparams.test.ts +6 -0
  72. package/src/components/traffic/TrafficConnectionsTable.tsx +144 -0
  73. package/src/components/traffic/TrafficFilterSidebar.tsx +22 -1
  74. package/src/components/traffic/TrafficFlowListContext.tsx +12 -4
  75. package/src/components/traffic/TrafficFocusSearch.tsx +172 -0
  76. package/src/components/traffic/TrafficGraph.tsx +109 -26
  77. package/src/components/traffic/TrafficGraphTooLarge.tsx +150 -0
  78. package/src/components/traffic/TrafficView.tsx +582 -149
  79. package/src/components/traffic/trafficFilters.test.ts +398 -1
  80. package/src/components/traffic/trafficFilters.ts +408 -0
  81. package/src/components/whats-new/WhatsNew.test.ts +107 -28
  82. package/src/components/whats-new/WhatsNew.tsx +146 -86
  83. package/src/components/whats-new/WhatsNewDialog.test.tsx +132 -10
  84. package/src/components/whats-new/releaseNotes.ts +155 -24
  85. package/src/components/workload/WorkloadView.test.ts +2 -1
  86. package/src/components/workload/WorkloadView.tsx +165 -294
  87. package/src/context/TimelineCustomization.tsx +15 -0
  88. package/src/contexts/CapabilitiesContext.tsx +9 -1
  89. package/src/hooks/useGitOpsWriteGuard.test.tsx +457 -0
  90. package/src/hooks/useGitOpsWriteGuard.ts +193 -0
  91. package/src/hooks/useResolvedGitOpsOwner.ts +256 -0
  92. package/src/hooks/useResourceCounts.ts +49 -0
  93. package/src/components/helm/RoleGatedPanel.tsx +0 -47
@@ -0,0 +1,256 @@
1
+ import { useMemo } from 'react'
2
+ import {
3
+ getGitOpsResourceStatus,
4
+ gitOpsOwnerFromRelationships,
5
+ type GitOpsOwnerRef,
6
+ type GitOpsStatus,
7
+ type HelmOwnerRef,
8
+ } from '@skyhook-io/k8s-ui'
9
+ import type { Relationships, ResourceRef } from '../types'
10
+ import { kindToPluralWithGroup } from '../utils/navigation'
11
+ import { useResource, useResourceWithRelationships, useResources } from '../api/client'
12
+
13
+ export interface ResolvedGitOpsOwner {
14
+ /** GitOps owner, with an Argo Application's namespace filled in when the
15
+ * tracking label omitted it and exactly one Application matches. */
16
+ owner: GitOpsOwnerRef | null
17
+ /** Owner as reported by relationships, before namespace resolution. */
18
+ rawOwner: GitOpsOwnerRef | null
19
+ /** The owner CR (Application / Kustomization / HelmRelease). */
20
+ ownerObject: any
21
+ ownerPending: boolean
22
+ ownerVerified: boolean
23
+ /** The Argo CD tracking label named no namespace and Radar filled it in
24
+ * from a single Application of that name. */
25
+ ownerMatchedByName: boolean
26
+ ownerStatus: GitOpsStatus | null
27
+ /** The label/annotation that ties the object to its owner. */
28
+ ownerSource: string | null
29
+ helmOwner: HelmOwnerRef | null
30
+ helmSource: string | null
31
+ /** A parent (e.g. the Deployment of a ReplicaSet) consulted when the
32
+ * object carries no GitOps ownership itself. */
33
+ inheritedLookupRef: ResourceRef | null
34
+ lookupPending: boolean
35
+ lookupError: boolean
36
+ /** The parent was read but the server sent no relationships for it (its
37
+ * topology isn't built yet), so an inherited owner is unknown, not absent. */
38
+ relationshipsUnavailable: boolean
39
+ }
40
+
41
+ // Resolves who manages a resource: its own GitOps/Helm tracking metadata or,
42
+ // failing that, the metadata of the workload that owns it.
43
+ export function useResolvedGitOpsOwner({
44
+ kind,
45
+ group,
46
+ namespace,
47
+ name,
48
+ relationships,
49
+ resource,
50
+ enabled = true,
51
+ cacheScope,
52
+ }: {
53
+ kind: string
54
+ group?: string
55
+ namespace: string
56
+ name: string
57
+ relationships: Relationships | undefined
58
+ resource: any
59
+ enabled?: boolean
60
+ /** Keeps these reads apart from the shared resource cache (see useResource). */
61
+ cacheScope?: string
62
+ }): ResolvedGitOpsOwner {
63
+ const active = enabled && Boolean(relationships)
64
+ const relationshipGitopsOwner = useMemo(
65
+ () => (active ? gitOpsOwnerFromRelationships(relationships) : null),
66
+ [active, relationships],
67
+ )
68
+ const inheritedLookupRef = useMemo(
69
+ () =>
70
+ active
71
+ ? findInheritedGitOpsLookupRef(relationships, relationshipGitopsOwner, { kind, namespace, name, group })
72
+ : null,
73
+ [active, relationships, relationshipGitopsOwner, kind, namespace, name, group],
74
+ )
75
+ const inherited = useResourceWithRelationships<any>(
76
+ inheritedLookupRef ? kindToPluralWithGroup(inheritedLookupRef.kind, inheritedLookupRef.group ?? '') : '',
77
+ inheritedLookupRef?.namespace ?? '',
78
+ inheritedLookupRef?.name ?? '',
79
+ inheritedLookupRef?.group,
80
+ { cacheScope },
81
+ )
82
+ const inheritedGitopsOwner = useMemo(
83
+ () => (inheritedLookupRef ? gitOpsOwnerFromRelationships(inherited.data?.relationships) : null),
84
+ [inheritedLookupRef, inherited.data?.relationships],
85
+ )
86
+ const relationshipHelmOwner = useMemo(
87
+ () => (active ? nativeHelmOwnerFromRelationships(relationships, resource?.metadata?.namespace ?? namespace) : null),
88
+ [active, relationships, resource?.metadata?.namespace, namespace],
89
+ )
90
+ const inheritedHelmOwner = useMemo(
91
+ () =>
92
+ inheritedLookupRef
93
+ ? nativeHelmOwnerFromRelationships(
94
+ inherited.data?.relationships,
95
+ inherited.data?.resource?.metadata?.namespace ?? namespace,
96
+ )
97
+ : null,
98
+ [inheritedLookupRef, inherited.data?.relationships, inherited.data?.resource?.metadata?.namespace, namespace],
99
+ )
100
+
101
+ const rawOwner = relationshipGitopsOwner ?? inheritedGitopsOwner
102
+ const gitOpsSourceResource = relationshipGitopsOwner ? resource : inherited.data?.resource
103
+ const helmOwner = relationshipHelmOwner ?? inheritedHelmOwner
104
+ const helmSourceResource = relationshipHelmOwner ? resource : inherited.data?.resource
105
+
106
+ const shouldResolveArgoNamespace = rawOwner?.tool === 'argocd' && !rawOwner.namespace
107
+ const argoQuery = useResources<any>('applications', undefined, 'argoproj.io', {
108
+ enabled: shouldResolveArgoNamespace,
109
+ cacheScope,
110
+ })
111
+ const argoApplications = argoQuery.data
112
+ const owner = useMemo(() => resolveGitOpsOwner(rawOwner, argoApplications), [rawOwner, argoApplications])
113
+
114
+ const ownerGroup = owner ? gitOpsOwnerGroup(owner) : ''
115
+ const shouldFetchOwner = Boolean(owner?.namespace)
116
+ const ownerQuery = useResource<any>(
117
+ shouldFetchOwner ? owner!.kind : '',
118
+ owner?.namespace ?? '',
119
+ owner?.name ?? '',
120
+ ownerGroup,
121
+ { enabled: shouldFetchOwner, cacheScope },
122
+ )
123
+ const ownerObject = shouldFetchOwner ? ownerQuery.data : undefined
124
+
125
+ const ownerStatus = useMemo(() => deriveGitOpsOwnerStatus(owner, ownerObject), [owner, ownerObject])
126
+ const ownerSource = useMemo(
127
+ () => describeGitOpsOwnerSource(rawOwner, gitOpsSourceResource),
128
+ [rawOwner, gitOpsSourceResource],
129
+ )
130
+ const helmSource = useMemo(() => describeHelmOwnerSource(helmOwner, helmSourceResource), [helmOwner, helmSourceResource])
131
+
132
+ return {
133
+ owner,
134
+ rawOwner,
135
+ ownerObject,
136
+ ownerPending: Boolean(shouldFetchOwner && ownerQuery.isLoading && !ownerQuery.data),
137
+ ownerVerified: Boolean(shouldFetchOwner && ownerQuery.data),
138
+ ownerMatchedByName: Boolean(shouldResolveArgoNamespace && owner?.namespace),
139
+ ownerStatus,
140
+ ownerSource,
141
+ helmOwner,
142
+ helmSource,
143
+ inheritedLookupRef,
144
+ lookupPending: Boolean((inheritedLookupRef && inherited.isPending) || (shouldResolveArgoNamespace && argoQuery.isPending)),
145
+ lookupError: Boolean(inheritedLookupRef && inherited.isError),
146
+ relationshipsUnavailable: Boolean(inheritedLookupRef && inherited.data && !inherited.data.relationships),
147
+ }
148
+ }
149
+
150
+ export function gitOpsOwnerGroup(owner: GitOpsOwnerRef): string {
151
+ if (owner.tool === 'argocd') return 'argoproj.io'
152
+ if (owner.kind === 'kustomizations') return 'kustomize.toolkit.fluxcd.io'
153
+ return 'helm.toolkit.fluxcd.io'
154
+ }
155
+
156
+ /** The owner as the singular Kind + group the server's APIs take. */
157
+ export function gitOpsOwnerKindRef(owner: GitOpsOwnerRef): { kind: string; group: string } {
158
+ if (owner.tool === 'argocd') return { kind: 'Application', group: 'argoproj.io' }
159
+ if (owner.kind === 'kustomizations') return { kind: 'Kustomization', group: 'kustomize.toolkit.fluxcd.io' }
160
+ return { kind: 'HelmRelease', group: 'helm.toolkit.fluxcd.io' }
161
+ }
162
+
163
+ function resolveGitOpsOwner(owner: GitOpsOwnerRef | null, argoApplications: any[] | undefined): GitOpsOwnerRef | null {
164
+ if (!owner || owner.namespace || owner.tool !== 'argocd') return owner
165
+ const matches = (argoApplications ?? []).filter((app) => app?.metadata?.name === owner.name)
166
+ if (matches.length !== 1) return owner
167
+ const namespace = matches[0]?.metadata?.namespace
168
+ return namespace ? { ...owner, namespace } : owner
169
+ }
170
+
171
+ export function findInheritedGitOpsLookupRef(
172
+ relationships: Relationships | undefined,
173
+ directOwner: GitOpsOwnerRef | null,
174
+ current: ResourceRef,
175
+ ): ResourceRef | null {
176
+ if (directOwner) return null
177
+ const inheritedManagerRefs = (relationships?.managedBy ?? []).filter(
178
+ (ref) => !gitOpsOwnerFromRelationships({ managedBy: [ref] }) && !isNativeHelmManager(ref),
179
+ )
180
+ const candidates = [relationships?.deployment, ...inheritedManagerRefs, relationships?.owner].filter(
181
+ Boolean,
182
+ ) as ResourceRef[]
183
+
184
+ return candidates.find((ref) => !isCurrentResource(ref, current)) ?? null
185
+ }
186
+
187
+ export function nativeHelmOwnerFromRelationships(
188
+ relationships: Relationships | undefined,
189
+ fallbackNamespace: string,
190
+ ): HelmOwnerRef | null {
191
+ const ref = relationships?.managedBy?.[0]
192
+ if (!ref || !isNativeHelmManager(ref)) return null
193
+ return {
194
+ namespace: ref.namespace || fallbackNamespace,
195
+ name: ref.name,
196
+ }
197
+ }
198
+
199
+ function isCurrentResource(ref: ResourceRef, current: ResourceRef): boolean {
200
+ return (
201
+ kindToPluralWithGroup(ref.kind, ref.group ?? '') === kindToPluralWithGroup(current.kind, current.group ?? '') &&
202
+ ref.namespace === current.namespace &&
203
+ ref.name === current.name &&
204
+ (ref.group ?? '') === (current.group ?? '')
205
+ )
206
+ }
207
+
208
+ function isNativeHelmManager(ref: ResourceRef): boolean {
209
+ return ref.kind === 'HelmRelease' && ref.group !== 'helm.toolkit.fluxcd.io'
210
+ }
211
+
212
+ function describeGitOpsOwnerSource(owner: GitOpsOwnerRef | null, resource: any): string | null {
213
+ if (!owner || !resource) return null
214
+ const labels = resource.metadata?.labels ?? {}
215
+ const annotations = resource.metadata?.annotations ?? {}
216
+
217
+ if (owner.tool === 'fluxcd') {
218
+ const nameKey = owner.kind === 'helmreleases' ? 'helm.toolkit.fluxcd.io/name' : 'kustomize.toolkit.fluxcd.io/name'
219
+ const nsKey =
220
+ owner.kind === 'helmreleases' ? 'helm.toolkit.fluxcd.io/namespace' : 'kustomize.toolkit.fluxcd.io/namespace'
221
+ if (labels[nameKey] || labels[nsKey]) {
222
+ return `${nameKey}=${labels[nameKey] ?? ''}, ${nsKey}=${labels[nsKey] ?? ''}`
223
+ }
224
+ }
225
+
226
+ const trackingID = annotations['argocd.argoproj.io/tracking-id']
227
+ if (trackingID) return `argocd.argoproj.io/tracking-id=${trackingID}`
228
+ const argoInstance = labels['argocd.argoproj.io/instance']
229
+ if (argoInstance) return `argocd.argoproj.io/instance=${argoInstance}`
230
+ return null
231
+ }
232
+
233
+ function describeHelmOwnerSource(owner: HelmOwnerRef | null, resource: any): string | null {
234
+ if (!owner || !resource) return null
235
+ const annotations = resource.metadata?.annotations ?? {}
236
+ const releaseName = annotations['meta.helm.sh/release-name']
237
+ const releaseNamespace = annotations['meta.helm.sh/release-namespace']
238
+ if (releaseName || releaseNamespace) {
239
+ return `meta.helm.sh/release-name=${releaseName ?? ''}, meta.helm.sh/release-namespace=${releaseNamespace ?? ''}`
240
+ }
241
+ return null
242
+ }
243
+
244
+ function deriveGitOpsOwnerStatus(owner: GitOpsOwnerRef | null, resource: any): GitOpsStatus | null {
245
+ if (!owner || !resource || !hasGitOpsStatusPayload(owner, resource)) return null
246
+ return getGitOpsResourceStatus(owner.kind, resource)
247
+ }
248
+
249
+ function hasGitOpsStatusPayload(owner: GitOpsOwnerRef, resource: any): boolean {
250
+ if (owner.kind === 'applications') {
251
+ const status = resource.status ?? {}
252
+ return Boolean(status.sync?.status || status.health?.status || status.operationState?.phase)
253
+ }
254
+ if (resource.spec?.suspend === true) return true
255
+ return Array.isArray(resource.status?.conditions) && resource.status.conditions.length > 0
256
+ }
@@ -0,0 +1,49 @@
1
+ import { useQuery } from '@tanstack/react-query'
2
+ import { debugNamespaceLog, fetchJSON, isStillLoadingError } from '../api/client'
3
+ import { useConnection } from '../context/ConnectionContext'
4
+
5
+ export interface ResourceCountsResponse {
6
+ counts: Record<string, number>
7
+ forbidden?: string[]
8
+ reasons?: Record<string, string>
9
+ unavailable?: string[]
10
+ }
11
+
12
+ // Lightweight per-kind counts for the Resources sidebar badges. Shared by the
13
+ // Resources view and any surface that renders the same sidebar standalone, so
14
+ // both read one cache entry.
15
+ export function useResourceCounts(namespaces: string[]) {
16
+ const { connection } = useConnection()
17
+ const namespacesParam = namespaces.join(',')
18
+ return useQuery({
19
+ queryKey: ['resource-counts', namespacesParam],
20
+ queryFn: async () => {
21
+ const params = new URLSearchParams()
22
+ if (namespaces.length > 0) params.set('namespaces', namespacesParam)
23
+ const startedAt = performance.now()
24
+ debugNamespaceLog('resources:counts-fetch-start', { namespaces, params: params.toString() })
25
+ try {
26
+ return await fetchJSON<ResourceCountsResponse>(`/resource-counts?${params}`)
27
+ } finally {
28
+ debugNamespaceLog('resources:counts-fetch-end', {
29
+ namespaces,
30
+ params: params.toString(),
31
+ durationMs: Math.round(performance.now() - startedAt),
32
+ })
33
+ }
34
+ },
35
+ staleTime: 10000,
36
+ // SSE invalidation isn't running while connecting, and mid-sync counts
37
+ // are what unlatch guarded kinds as their informers finish — poll fast
38
+ // during the shell, settle to the safety net once connected.
39
+ refetchInterval: connection.state === 'connecting' ? 3000 : 60000,
40
+ // During the first seconds of the progressive shell the endpoint 503s
41
+ // (cluster_connecting) until the mid-sync cache handle exists; keep the
42
+ // query pending rather than parking it in error state, which would
43
+ // unlatch the large-list guard at the connected flip.
44
+ retry: (failureCount: number, error: Error) =>
45
+ isStillLoadingError(error) ? true : failureCount < 3,
46
+ retryDelay: (failureCount: number, error: Error) =>
47
+ isStillLoadingError(error) ? 2000 : Math.min(1000 * 2 ** failureCount, 30000),
48
+ })
49
+ }
@@ -1,47 +0,0 @@
1
- import { Lock } from 'lucide-react'
2
- import { useCloudRole, type CloudRole } from '../../api/client'
3
-
4
- interface RoleGatedPanelProps {
5
- /** Minimum Cloud tier required to view the children. */
6
- min: CloudRole
7
- /** Human-readable name of the gated content, e.g. "release manifests". */
8
- feature: string
9
- children: React.ReactNode
10
- }
11
-
12
- /**
13
- * RoleGatedPanel wraps tab-content surfaces that require a Cloud role
14
- * (member+) to view. Renders the children when the gate passes; renders
15
- * an explanatory empty state when the caller's role is insufficient.
16
- *
17
- * Why a per-tab gate rather than hiding tabs from the list: viewers
18
- * should learn what the platform offers and what their tier can't do,
19
- * not have features silently vanish. This mirrors how the action
20
- * buttons (Uninstall, Upgrade, etc.) are rendered visible-but-disabled
21
- * with a role-aware tooltip.
22
- *
23
- * Bypasses for non-Cloud users (OSS, OIDC, etc.) — `canAtLeast` returns
24
- * true when no Cloud role is present. The backend gate has the same
25
- * shape, so the frontend stays in lockstep.
26
- */
27
- export function RoleGatedPanel({ min, feature, children }: RoleGatedPanelProps) {
28
- const { role, canAtLeast } = useCloudRole()
29
- if (canAtLeast(min)) {
30
- return <>{children}</>
31
- }
32
- return (
33
- <div className="flex flex-col items-center justify-center h-full py-12 px-6 text-center">
34
- <div className="rounded-full bg-theme-surface p-3 mb-3">
35
- <Lock className="w-5 h-5 text-theme-text-tertiary" aria-hidden />
36
- </div>
37
- <div className="text-sm font-medium text-theme-text-primary">
38
- Your role can't view {feature}
39
- </div>
40
- <div className="mt-1 max-w-md text-xs text-theme-text-secondary">
41
- You're signed in as <span className="inline-code">{role ?? 'viewer'}</span>.
42
- This view requires <span className="inline-code">{min}</span> or higher.
43
- Ask a {min} or owner if you need access.
44
- </div>
45
- </div>
46
- )
47
- }