@skyhook-io/radar-app 1.16.2 → 1.16.3

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 (42) hide show
  1. package/package.json +5 -5
  2. package/src/App.tsx +46 -5
  3. package/src/api/client.ts +53 -47
  4. package/src/api/cnpg.ts +95 -0
  5. package/src/api/timelineSource.ts +1 -1
  6. package/src/components/NoClusterAccessBanner.tsx +68 -0
  7. package/src/components/cnpg/CNPGClusterActivity.tsx +49 -0
  8. package/src/components/cnpg/CNPGClusterLogs.tsx +62 -0
  9. package/src/components/cnpg/CNPGDeclarations.tsx +311 -0
  10. package/src/components/cnpg/CNPGDetailPage.tsx +228 -0
  11. package/src/components/cnpg/CNPGDrawerTrail.tsx +37 -0
  12. package/src/components/cnpg/CNPGOperator.tsx +234 -0
  13. package/src/components/cnpg/CNPGOverview.tsx +307 -0
  14. package/src/components/cnpg/CNPGPooling.tsx +116 -0
  15. package/src/components/cnpg/CNPGProtection.tsx +320 -0
  16. package/src/components/cnpg/CNPGSummaryHost.tsx +143 -0
  17. package/src/components/cnpg/CNPGView.tsx +156 -0
  18. package/src/components/cnpg/paths.ts +13 -0
  19. package/src/components/cnpg/routes.test.ts +60 -0
  20. package/src/components/cnpg/routes.ts +112 -0
  21. package/src/components/cnpg/shared.tsx +290 -0
  22. package/src/components/cnpg/useCNPGSidebarWorkspace.ts +88 -0
  23. package/src/components/helm/ChartBrowser.tsx +6 -7
  24. package/src/components/helm/HelmCompareRoute.tsx +108 -115
  25. package/src/components/helm/HelmReleaseDrawer.tsx +26 -44
  26. package/src/components/helm/InstallWizard.tsx +5 -2
  27. package/src/components/helm/TrackChartSourceDialog.tsx +10 -2
  28. package/src/components/helm/ValuesViewer.tsx +1 -1
  29. package/src/components/resources/ResourceDetailDrawer.tsx +6 -0
  30. package/src/components/resources/ResourcesView.tsx +6 -38
  31. package/src/components/timeline/TimelineList.test.ts +47 -29
  32. package/src/components/timeline/TimelineList.tsx +57 -11
  33. package/src/components/timeline/TimelineView.tsx +9 -2
  34. package/src/components/timeline/TimelineView.urlparams.test.ts +6 -0
  35. package/src/components/whats-new/WhatsNew.test.ts +107 -28
  36. package/src/components/whats-new/WhatsNew.tsx +146 -86
  37. package/src/components/whats-new/WhatsNewDialog.test.tsx +132 -10
  38. package/src/components/whats-new/releaseNotes.ts +155 -24
  39. package/src/components/workload/WorkloadView.tsx +30 -4
  40. package/src/contexts/CapabilitiesContext.tsx +9 -1
  41. package/src/hooks/useResourceCounts.ts +49 -0
  42. package/src/components/helm/RoleGatedPanel.tsx +0 -47
@@ -1,38 +1,94 @@
1
1
  import type { LucideIcon } from 'lucide-react'
2
- import { Bot, GitCompareArrows, Layers, Network, Scale } from 'lucide-react'
2
+ import { Bot, GitCompareArrows, Layers, Network, RefreshCcw, Scale, ScrollText, SlidersHorizontal } from 'lucide-react'
3
3
  import { compareVersions } from '../../utils/version'
4
4
 
5
5
  export interface ReleaseHighlight {
6
6
  id: string
7
7
  icon: LucideIcon
8
8
  title: string
9
+ /**
10
+ * Shown on a grid card, so at most 150 characters. Read next to other
11
+ * releases' highlights, so it must stand on its own.
12
+ */
9
13
  description: string
14
+ /**
15
+ * Fuller copy for when this highlight leads the dialog. Only a release's
16
+ * top highlight can ever lead (every other one scores at or below it), so
17
+ * only that one may set it.
18
+ */
19
+ leadDescription?: string
20
+ /**
21
+ * 1-10, comparable across releases: it decides which highlights a user who
22
+ * skipped releases sees, and whether the dialog opens by itself.
23
+ * 9-10 a new capability that defines the release
24
+ * 7-8 a solid feature a typical user would go try now
25
+ * 4-6 a useful improvement
26
+ * 1-3 polish
27
+ * Most highlights are not 9s. When every card is a 7, every release
28
+ * interrupts the user, which is what the score exists to prevent.
29
+ */
30
+ importance: number
10
31
  /** In-app route the highlight's call to action opens. */
11
32
  path?: string
12
33
  cta?: string
13
- /** Icon tile color, so the cards read apart at a glance. The lead card always uses the accent. */
14
- tone?: HighlightTone
15
34
  }
16
35
 
17
- export type HighlightTone = 'violet' | 'teal' | 'emerald' | 'indigo'
18
-
19
36
  export interface ReleaseNotes {
20
37
  version: string
21
- /** The first highlight leads the dialog at full width. */
22
38
  highlights: ReleaseHighlight[]
23
39
  improvements: string[]
24
- releaseUrl: string
25
40
  }
26
41
 
27
- // One entry per release that has something to announce. A version without its
28
- // own entry shows the newest entry at or below it, so a patch upgrade (or one
29
- // that skips the release with notes) still gets them. Every minor and major
30
- // release must have its own entry: scripts/check-whats-new.sh refuses to
42
+ export const CHANGELOG_URL = 'https://radarhq.io/changelog'
43
+
44
+ // One entry per release that has something to announce. A user sees the
45
+ // entries for the releases they haven't seen yet, composed into one dialog, so
46
+ // a patch upgrade, or one that skips releases, still gets them. Every minor and
47
+ // major release must have its own entry: scripts/check-whats-new.sh refuses to
31
48
  // release one without it.
32
49
  export const RELEASE_NOTES: ReleaseNotes[] = [
50
+ {
51
+ version: 'v1.16.0',
52
+ highlights: [
53
+ {
54
+ id: 'restart-loop-issues',
55
+ icon: RefreshCcw,
56
+ title: 'One steady issue per crash loop',
57
+ description: 'A crash-looping container stays one issue until it recovers, with probe failures and exits as evidence. No more repeat alerts.',
58
+ leadDescription: 'A restarting container is one issue until it recovers, instead of a new one on every check, so no more repeat alerts and investigations. Probe failures, the last exit and how many replicas are affected sit on it as evidence, and severity follows impact.',
59
+ importance: 6,
60
+ path: '/issues',
61
+ cta: 'Open Issues',
62
+ },
63
+ {
64
+ id: 'log-viewer',
65
+ icon: ScrollText,
66
+ title: 'Log viewer enhancements',
67
+ description: 'Levels from the logger itself, stack traces kept with their errors, Hide matching, and a chip for lines without a level.',
68
+ importance: 5,
69
+ },
70
+ {
71
+ id: 'per-cluster-settings',
72
+ icon: SlidersHorizontal,
73
+ title: 'Separate settings for each cluster',
74
+ description: 'Running Radar locally, each kubeconfig context keeps its own Metrics, Argo CD and Cost connections.',
75
+ importance: 4,
76
+ },
77
+ ],
78
+ improvements: [
79
+ // The grid fills row by row: pair lines of similar length.
80
+ "A workload's Timeline shows only its own history",
81
+ "Timeline's Problems filter no longer hides crash loops",
82
+ 'Live Traffic figures corrected for Cilium and Istio',
83
+ 'Saved namespace picks survive a failing namespace list',
84
+ 'Terminal tabs show and keep the context they opened for',
85
+ 'YAML review flags a resource that changed after review',
86
+ 'Helm chart meets the Pod Security restricted profile',
87
+ 'Helm chart extraArgs passes through any Radar flag',
88
+ ],
89
+ },
33
90
  {
34
91
  version: 'v1.15.0',
35
- releaseUrl: 'https://github.com/skyhook-io/radar/releases/tag/v1.15.0',
36
92
  highlights: [
37
93
  {
38
94
  id: 'ai-investigations',
@@ -40,14 +96,16 @@ export const RELEASE_NOTES: ReleaseNotes[] = [
40
96
  title: 'AI investigations that show their work',
41
97
  // No link: the investigations page redirects home when this run
42
98
  // mode can't host local agents.
43
- description: 'A clear verdict and the story of what broke, with the charts, logs and config behind it placed where the agent cites them. Now also runs on OpenCode, including AWS Bedrock.',
99
+ description: 'A clear verdict and the story of what broke, with the evidence placed where the agent cites it. Runs on OpenCode too.',
100
+ leadDescription: 'A clear verdict and the story of what broke, with the charts, logs and config behind it placed where the agent cites them. Runs on OpenCode too, including AWS Bedrock.',
101
+ importance: 9,
44
102
  },
45
103
  {
46
104
  id: 'gitops-manifest-diff',
47
105
  icon: GitCompareArrows,
48
106
  title: 'Full manifest diffs for Argo CD resources',
49
107
  description: 'Compare the Git-rendered and live manifest of a drifted Argo CD resource, side by side or unified.',
50
- tone: 'violet',
108
+ importance: 8,
51
109
  path: '/gitops',
52
110
  cta: 'Open GitOps',
53
111
  },
@@ -56,7 +114,7 @@ export const RELEASE_NOTES: ReleaseNotes[] = [
56
114
  icon: Scale,
57
115
  title: 'Rightsizing and cost data in MCP',
58
116
  description: 'Agents get new optimized tools. Risks (OOM, limit conflicts and throttling) lead before cost-saving, in the UI as well.',
59
- tone: 'emerald',
117
+ importance: 7,
60
118
  path: '/cost/rightsizing',
61
119
  cta: 'Open Rightsizing',
62
120
  },
@@ -65,14 +123,14 @@ export const RELEASE_NOTES: ReleaseNotes[] = [
65
123
  icon: Layers,
66
124
  title: 'Deeper batch + AI/ML workloads support',
67
125
  description: 'Dedicated pages for Kueue, JobSet and Ray resources, and Job and JobSet details now show why a queued job has no Pods.',
68
- tone: 'teal',
126
+ importance: 7,
69
127
  },
70
128
  {
71
129
  id: 'rollout-traffic-topology',
72
130
  icon: Network,
73
131
  title: 'Canary and blue-green in Topology',
74
132
  description: 'See canary and stable Pods, which Services route to each side, and the canary traffic weight.',
75
- tone: 'indigo',
133
+ importance: 7,
76
134
  path: '/topology',
77
135
  cta: 'Open Topology',
78
136
  },
@@ -105,17 +163,90 @@ export function releaseLine(version: string): string {
105
163
  return version.replace(/^(v\d+\.\d+)\.\d+$/, '$1')
106
164
  }
107
165
 
108
- /** The newest notes for a release at or below `version` — what that version's user has access to. */
166
+ /** The entries at or below `version`, newest first: what that version's user has access to. */
167
+ function releasesUpTo(version: string, catalog: ReleaseNotes[]): ReleaseNotes[] {
168
+ return catalog
169
+ .filter(notes => (compareVersions(notes.version, version) ?? 1) <= 0)
170
+ .sort((a, b) => compareVersions(b.version, a.version) ?? 0)
171
+ }
172
+
173
+ /** The newest notes for a release at or below `version`. */
109
174
  export function latestReleaseNotesFor(
110
175
  version: string | undefined,
111
176
  catalog: ReleaseNotes[] = RELEASE_NOTES,
112
177
  ): ReleaseNotes | undefined {
113
178
  if (!version) return undefined
114
- let latest: ReleaseNotes | undefined
115
- for (const notes of catalog) {
116
- const atOrBelow = compareVersions(notes.version, version)
117
- if (atOrBelow === null || atOrBelow > 0) continue
118
- if (!latest || (compareVersions(notes.version, latest.version) ?? 0) > 0) latest = notes
179
+ return releasesUpTo(version, catalog)[0]
180
+ }
181
+
182
+ /**
183
+ * The entries a user hasn't seen, newest first: those after `lastSeen`, up to
184
+ * the running version. A fresh install has nothing to compare against and gets
185
+ * none; an install that predates the seen record, or whose record isn't a
186
+ * version, gets them all.
187
+ */
188
+ export function unreadReleases(
189
+ currentVersion: string,
190
+ lastSeen: string | null,
191
+ priorInstall: boolean,
192
+ catalog: ReleaseNotes[] = RELEASE_NOTES,
193
+ ): ReleaseNotes[] {
194
+ const available = releasesUpTo(currentVersion, catalog)
195
+ if (lastSeen === null) return priorInstall ? available : []
196
+ return available.filter(notes => (compareVersions(notes.version, lastSeen) ?? 1) > 0)
197
+ }
198
+
199
+ // How much a highlight counts by how far back its release is. A user who
200
+ // skipped releases sees the best of them, weighted toward the newest; older
201
+ // releases than this list covers are left to the changelog.
202
+ export const RELEASE_WEIGHTS = [1, 0.75, 0.5]
203
+ export const MAX_CARDS = 5
204
+ export const MAX_LINES = 8
205
+ // The dialog opens by itself only when the cards it shows add up to this.
206
+ // A release of mostly 4-6s stays behind the unread dot; one with a few solid
207
+ // features, or a skipped release with them, opens it.
208
+ export const AUTO_OPEN_SCORE = 25
209
+
210
+ export interface ComposedHighlight extends ReleaseHighlight {
211
+ version: string
212
+ score: number
213
+ }
214
+
215
+ export interface ComposedLine {
216
+ version: string
217
+ text: string
218
+ }
219
+
220
+ export interface ComposedNotes {
221
+ /** The releases shown, newest first. */
222
+ versions: string[]
223
+ /** The first one leads the dialog at full width. */
224
+ highlights: ComposedHighlight[]
225
+ lines: ComposedLine[]
226
+ /** Sum of the shown highlights' scores. */
227
+ score: number
228
+ /** False when older unseen releases were left out. */
229
+ complete: boolean
230
+ }
231
+
232
+ /** One dialog from releases ordered newest first. */
233
+ export function composeReleaseNotes(releases: ReleaseNotes[]): ComposedNotes {
234
+ const shown = releases.slice(0, RELEASE_WEIGHTS.length)
235
+ // Built newest release first, in authored order, so the stable sort breaks
236
+ // ties the same way.
237
+ const ranked = shown
238
+ .flatMap((notes, i) => notes.highlights.map(h => ({ ...h, version: notes.version, score: h.importance * RELEASE_WEIGHTS[i] })))
239
+ .sort((a, b) => b.score - a.score)
240
+ const highlights = ranked.slice(0, MAX_CARDS)
241
+ const lines: ComposedLine[] = [
242
+ ...ranked.slice(MAX_CARDS).map(h => ({ version: h.version, text: h.title })),
243
+ ...shown.flatMap(notes => notes.improvements.map(text => ({ version: notes.version, text }))),
244
+ ].slice(0, MAX_LINES)
245
+ return {
246
+ versions: shown.map(notes => notes.version),
247
+ highlights,
248
+ lines,
249
+ score: highlights.reduce((sum, h) => sum + h.score, 0),
250
+ complete: shown.length === releases.length,
119
251
  }
120
- return latest
121
252
  }
@@ -3,18 +3,20 @@ import { JobRenderer, JobSetRenderer } from '../resources/renderers/JobAdmission
3
3
  import { RayClusterRenderer } from '../resources/renderers/RayClusterRenderer'
4
4
  import { RayServiceRenderer } from '../resources/renderers/RayServiceRenderer'
5
5
  import { KueueWorkloadRenderer } from '../resources/renderers/KueueWorkloadRenderer'
6
- import { useMemo, useEffect, useCallback, useRef, useState } from 'react'
6
+ import { useMemo, useEffect, useCallback, useRef, useState, type ReactNode } from 'react'
7
7
  import { useQueries, useQueryClient } from '@tanstack/react-query'
8
- import { useNavigate, useLocation, useSearchParams } from 'react-router-dom'
8
+ import { Navigate, useNavigate, useLocation, useSearchParams } from 'react-router-dom'
9
9
  import { workloadPodAwaitsScheduling } from '../capacity/podDemandGate'
10
10
  import { clsx } from 'clsx'
11
11
  import { Terminal, Stethoscope } from 'lucide-react'
12
12
  import {
13
13
  WorkloadView as BaseWorkloadView,
14
+ isApiGroup,
14
15
  EditableYamlView,
15
16
  FetchResult,
16
17
  Section,
17
18
  type WorkloadTabType,
19
+ type WorkloadExtraTab,
18
20
  type RendererOverrides,
19
21
  type GitOpsOwnerRef,
20
22
  type GitOpsStatus,
@@ -148,6 +150,9 @@ import {
148
150
  CNPGSubscriptionRenderer,
149
151
  } from '../resources/renderers/CNPGDeclarativeRenderer'
150
152
  import { CreateResourceDialog } from '../shared/CreateResourceDialog'
153
+ import { renderCNPGSummary } from '../cnpg/CNPGSummaryHost'
154
+ import { CNPGClusterLogs } from '../cnpg/CNPGClusterLogs'
155
+ import { cnpgDetailKindFor, cnpgDetailPath } from '../cnpg/routes'
151
156
  import { cleanYamlForDuplicate } from '../../utils/skeleton-yaml'
152
157
  import { useDesktopDownload } from '../../hooks/useDesktopDownload'
153
158
  import { useCompareLauncher } from '../compare/useCompareLauncher'
@@ -277,6 +282,17 @@ export function WorkloadViewRoute({ onNavigateToResource }: WorkloadViewRoutePro
277
282
  )
278
283
  }
279
284
 
285
+ const cnpgPlural = cnpgDetailKindFor(kind, group)
286
+ if (cnpgPlural) {
287
+ const params = new URLSearchParams(searchParams)
288
+ params.delete('apiGroup')
289
+ const tab = params.get('tab')
290
+ if (cnpgPlural === 'clusters' && (tab === 'timeline' || tab === 'events')) params.set('tab', 'activity')
291
+ const base = cnpgDetailPath({ plural: cnpgPlural, namespace, name })
292
+ const qs = params.toString()
293
+ return <Navigate replace to={qs ? `${base}?${qs}` : base} state={location.state} />
294
+ }
295
+
280
296
  return (
281
297
  <WorkloadView
282
298
  kind={kind}
@@ -312,6 +328,8 @@ interface WorkloadViewProps {
312
328
  initialTab?: 'detail' | 'yaml'
313
329
  group?: string
314
330
  pushTabHistory?: boolean
331
+ breadcrumb?: ReactNode
332
+ extraTabs?: WorkloadExtraTab[]
315
333
  }
316
334
 
317
335
  interface ImageTargetOwnershipContext {
@@ -536,6 +554,7 @@ export function WorkloadView({
536
554
  ...rest
537
555
  }: WorkloadViewProps) {
538
556
  const [searchParams, setSearchParams] = useSearchParams()
557
+ const location = useLocation()
539
558
  const apiKind = kindToPluralWithGroup(kindProp, rest.group ?? '')
540
559
  const queryClient = useQueryClient()
541
560
  const [imageTargetOwnership, setImageTargetOwnership] =
@@ -561,9 +580,9 @@ export function WorkloadView({
561
580
  } else {
562
581
  params.set('tab', tab)
563
582
  }
564
- setSearchParams(params, { replace: opts?.replace ?? !pushTabHistory })
583
+ setSearchParams(params, { replace: opts?.replace ?? !pushTabHistory, state: location.state })
565
584
  },
566
- [pushTabHistory, searchParams, setSearchParams],
585
+ [pushTabHistory, searchParams, setSearchParams, location.state],
567
586
  )
568
587
 
569
588
  const selectedRunKey = searchParams.get('run') ?? ''
@@ -1264,6 +1283,9 @@ export function WorkloadView({
1264
1283
  onSelectRun={handleSelectedRunChange}
1265
1284
  />
1266
1285
  )}
1286
+ renderSummary={({ apiKind: ak, namespace: ns, name: n, resource: res, context, onNavigate }) =>
1287
+ renderCNPGSummary({ apiKind: ak, namespace: ns, name: n, group: effectiveGroup, resource: res, context, onNavigate })
1288
+ }
1267
1289
  renderExpandedOverview={({ kind: k, apiKind, namespace: ns, name: n, resource: res }) =>
1268
1290
  supportsBatchExecution(k, apiKind, effectiveGroup, res?.apiVersion) &&
1269
1291
  res ? (
@@ -1672,6 +1694,10 @@ function LogsTabContent({
1672
1694
  )
1673
1695
  }
1674
1696
 
1697
+ if (kind === 'Cluster' && isApiGroup(resource?.apiVersion, 'postgresql.cnpg.io')) {
1698
+ return <CNPGClusterLogs namespace={namespace} name={name} />
1699
+ }
1700
+
1675
1701
  // Workload kinds with stable pod selectors use the aggregated workload logs viewer
1676
1702
  if (WORKLOAD_LOG_KINDS.has(kind) && (kind !== 'Job' || isCoreBatchJob(apiKind, group))) {
1677
1703
  return (
@@ -114,6 +114,10 @@ export function useCanHelmWrite(): boolean {
114
114
  return useContext(CapabilitiesContext).helmWrite
115
115
  }
116
116
 
117
+ export function useIsAuthEnabled(): boolean {
118
+ return useContext(CapabilitiesContext).authEnabled === true
119
+ }
120
+
117
121
  export function useCanNodeWrite(): boolean {
118
122
  return useContext(CapabilitiesContext).nodeWrite
119
123
  }
@@ -158,7 +162,11 @@ export function useNamespacedCapabilities(namespace: string | undefined) {
158
162
  canExec: nsCaps?.exec ?? globalCaps.exec,
159
163
  canViewLogs: nsCaps?.logs ?? globalCaps.logs,
160
164
  canPortForward: nsCaps?.portForward ?? globalCaps.portForward,
165
+ canHelmWrite: nsCaps?.helmWrite ?? globalCaps.helmWrite,
161
166
  workloadWrites: nsCaps?.workloadWrites ?? globalCaps.workloadWrites,
162
167
  workloadWritesPending: Boolean(namespace && isPending),
163
- }), [globalCaps.exec, globalCaps.logs, globalCaps.portForward, globalCaps.workloadWrites, isPending, namespace, nsCaps])
168
+ // A failed refetch keeps the last successful namespace answer; with none,
169
+ // the global value is not an answer for this namespace.
170
+ helmWriteUnknown: Boolean(namespace && !nsCaps && (isPending || error)),
171
+ }), [globalCaps.exec, globalCaps.logs, globalCaps.portForward, globalCaps.helmWrite, globalCaps.workloadWrites, error, isPending, namespace, nsCaps])
164
172
  }
@@ -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
- }