@skyhook-io/radar-app 0.1.6 → 0.1.9

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.
@@ -1,5 +1,6 @@
1
1
  import { useState, useCallback, useEffect, useRef } from 'react'
2
2
  import { flushSync } from 'react-dom'
3
+ import { PaneLoader } from '@skyhook-io/k8s-ui'
3
4
  import { TRANSITION_DRAWER } from '../../utils/animation'
4
5
  import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
5
6
  import { X, Copy, Check, RefreshCw, Package, Code, History, FileText, Settings, Link2, Anchor, GitFork, BookOpen, ArrowUpCircle, Trash2 } from 'lucide-react'
@@ -12,7 +13,8 @@ import type { SelectedHelmRelease, HelmHook, ChartDependency } from '../../types
12
13
  import type { NavigateToResource } from '../../utils/navigation'
13
14
  import { formatDate } from './helm-utils'
14
15
  import { getHelmStatusColor, SEVERITY_BADGE, SEVERITY_TEXT } from '../../utils/badge-colors'
15
- import { useCanHelmWrite } from '../../contexts/CapabilitiesContext'
16
+ import { useCanHelmAct, useCloudRole } from '../../api/client'
17
+ import { RoleGatedPanel } from './RoleGatedPanel'
16
18
  import { RevisionHistory } from './RevisionHistory'
17
19
  import { ManifestViewer } from './ManifestViewer'
18
20
  import { ValuesViewer } from './ValuesViewer'
@@ -46,7 +48,13 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
46
48
  const [showUpgradeConfirm, setShowUpgradeConfirm] = useState(false)
47
49
  const resizeStartX = useRef(0)
48
50
  const resizeStartWidth = useRef(DEFAULT_WIDTH)
49
- const canHelmWrite = useCanHelmWrite()
51
+ const { allowed: canHelmWrite, reason: helmActReason } = useCanHelmAct()
52
+ // Cloud viewers can't view release manifests / values / diffs
53
+ // (backend gate at requireCloudRole('member')). Skip the queries
54
+ // when the role would 403 — saves a round-trip and avoids a
55
+ // transient error state under the role-gated panel.
56
+ const { canAtLeast } = useCloudRole()
57
+ const canViewSensitive = canAtLeast('member')
50
58
 
51
59
  const { data: releaseDetail, isLoading, refetch: refetchRelease } = useHelmRelease(
52
60
  release.namespace,
@@ -58,14 +66,16 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
58
66
  const { data: manifest, isLoading: manifestLoading } = useHelmManifest(
59
67
  release.namespace,
60
68
  release.name,
61
- selectedRevision
69
+ selectedRevision,
70
+ canViewSensitive,
62
71
  )
63
72
 
64
73
  // Fetch values
65
74
  const { data: values, isLoading: valuesLoading } = useHelmValues(
66
75
  release.namespace,
67
76
  release.name,
68
- showAllValues
77
+ showAllValues,
78
+ canViewSensitive,
69
79
  )
70
80
 
71
81
  // Fetch diff if comparing revisions
@@ -73,7 +83,8 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
73
83
  release.namespace,
74
84
  release.name,
75
85
  diffRevisions?.rev1 || 0,
76
- diffRevisions?.rev2 || 0
86
+ diffRevisions?.rev2 || 0,
87
+ canViewSensitive,
77
88
  )
78
89
 
79
90
  // Lazy check for upgrade availability
@@ -325,7 +336,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
325
336
  'badge transition-colors', SEVERITY_BADGE.warning,
326
337
  canHelmWrite ? 'hover:bg-amber-500/30 cursor-pointer' : 'opacity-50 cursor-not-allowed'
327
338
  )}
328
- title={canHelmWrite ? `Click to upgrade: ${upgradeInfo.currentVersion} → ${upgradeInfo.latestVersion}${upgradeInfo.repositoryName ? ` (${upgradeInfo.repositoryName})` : ''}` : 'Helm write permissions required (rbac.helm=true)'}
339
+ title={canHelmWrite ? `Click to upgrade: ${upgradeInfo.currentVersion} → ${upgradeInfo.latestVersion}${upgradeInfo.repositoryName ? ` (${upgradeInfo.repositoryName})` : ''}` : helmActReason}
329
340
  >
330
341
  <ArrowUpCircle className="w-3 h-3" />
331
342
  {upgradeInfo.latestVersion}
@@ -354,7 +365,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
354
365
  ? 'text-theme-text-secondary hover:text-red-400 hover:bg-red-500/10'
355
366
  : 'text-theme-text-disabled cursor-not-allowed'
356
367
  )}
357
- title={canHelmWrite ? 'Uninstall release' : 'Helm write permissions required (rbac.helm=true)'}
368
+ title={canHelmWrite ? 'Uninstall release' : helmActReason}
358
369
  >
359
370
  <Trash2 className="w-4 h-4" />
360
371
  </button>
@@ -403,7 +414,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
403
414
  {/* Content */}
404
415
  <div className="flex-1 overflow-y-auto" style={{ viewTransitionName: 'helm-drawer-content' }}>
405
416
  {isLoading ? (
406
- <div className="flex items-center justify-center h-32 text-theme-text-tertiary">Loading...</div>
417
+ <PaneLoader className="h-32" />
407
418
  ) : !releaseDetail ? (
408
419
  <div className="flex items-center justify-center h-32 text-theme-text-tertiary">Release not found</div>
409
420
  ) : (
@@ -421,26 +432,30 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
421
432
  />
422
433
  )}
423
434
  {activeTab === 'manifest' && (
424
- <ManifestViewer
425
- manifest={manifest || ''}
426
- isLoading={manifestLoading}
427
- revision={selectedRevision}
428
- onCopy={(text) => copyToClipboard(text, 'manifest')}
429
- copied={copied === 'manifest'}
430
- />
435
+ <RoleGatedPanel min="member" feature="release manifests">
436
+ <ManifestViewer
437
+ manifest={manifest || ''}
438
+ isLoading={manifestLoading}
439
+ revision={selectedRevision}
440
+ onCopy={(text) => copyToClipboard(text, 'manifest')}
441
+ copied={copied === 'manifest'}
442
+ />
443
+ </RoleGatedPanel>
431
444
  )}
432
445
  {activeTab === 'values' && (
433
- <ValuesViewer
434
- values={values}
435
- isLoading={valuesLoading}
436
- showAllValues={showAllValues}
437
- onToggleAllValues={setShowAllValues}
438
- onCopy={(text) => copyToClipboard(text, 'values')}
439
- copied={copied === 'values'}
440
- namespace={release.namespace}
441
- name={release.name}
442
- onApplySuccess={() => refetch()}
443
- />
446
+ <RoleGatedPanel min="member" feature="release values">
447
+ <ValuesViewer
448
+ values={values}
449
+ isLoading={valuesLoading}
450
+ showAllValues={showAllValues}
451
+ onToggleAllValues={setShowAllValues}
452
+ onCopy={(text) => copyToClipboard(text, 'values')}
453
+ copied={copied === 'values'}
454
+ namespace={release.namespace}
455
+ name={release.name}
456
+ onApplySuccess={() => refetch()}
457
+ />
458
+ </RoleGatedPanel>
444
459
  )}
445
460
  {activeTab === 'resources' && (
446
461
  <OwnedResources
@@ -452,16 +467,18 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
452
467
  <HooksTab hooks={releaseDetail.hooks || []} />
453
468
  )}
454
469
  {activeTab === 'diff' && diffRevisions && (
455
- <ManifestDiffViewer
456
- diff={diffData?.diff || ''}
457
- isLoading={diffLoading}
458
- revision1={diffRevisions.rev1}
459
- revision2={diffRevisions.rev2}
460
- onClose={() => {
461
- setDiffRevisions(null)
462
- setActiveTab('history')
463
- }}
464
- />
470
+ <RoleGatedPanel min="member" feature="release manifest diffs">
471
+ <ManifestDiffViewer
472
+ diff={diffData?.diff || ''}
473
+ isLoading={diffLoading}
474
+ revision1={diffRevisions.rev1}
475
+ revision2={diffRevisions.rev2}
476
+ onClose={() => {
477
+ setDiffRevisions(null)
478
+ setActiveTab('history')
479
+ }}
480
+ />
481
+ </RoleGatedPanel>
465
482
  )}
466
483
  </>
467
484
  )}
@@ -2,6 +2,7 @@ import { useState, useMemo, useRef, useEffect, useCallback, forwardRef } from 'r
2
2
  import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
3
3
  import { useRegisterShortcuts } from '../../hooks/useKeyboardShortcuts'
4
4
  import { Package, Search, RefreshCw, ArrowUpCircle, LayoutGrid, List, Shield } from 'lucide-react'
5
+ import { PaneLoader } from '@skyhook-io/k8s-ui'
5
6
  import { clsx } from 'clsx'
6
7
  import { useHelmReleases, useHelmBatchUpgradeInfo, isForbiddenError } from '../../api/client'
7
8
  import type { HelmRelease, SelectedHelmRelease, UpgradeInfo, ChartSource } from '../../types'
@@ -232,9 +233,7 @@ export function HelmView({ namespace, selectedRelease, onReleaseClick }: HelmVie
232
233
  {/* Releases Table */}
233
234
  <div className="flex-1 overflow-auto">
234
235
  {isLoading ? (
235
- <div className="flex items-center justify-center h-full text-theme-text-tertiary">
236
- Loading...
237
- </div>
236
+ <PaneLoader className="h-full" />
238
237
  ) : isForbidden ? (
239
238
  <div className="flex flex-col items-center justify-center h-full text-theme-text-tertiary">
240
239
  <Shield className="w-8 h-8 text-amber-400 mb-2" />
@@ -1,11 +1,12 @@
1
1
  import { useState, useCallback, useEffect, useMemo, useRef } from 'react'
2
2
  import { X, Package, ChevronRight, ChevronLeft, Play, Loader2, AlertTriangle, CheckCircle, User, BookOpen, Link as LinkIcon, Star, BadgeCheck, Shield, Globe, Building2, Plus, Minus, Terminal } from 'lucide-react'
3
+ import { PaneLoader } from '@skyhook-io/k8s-ui'
3
4
  import { clsx } from 'clsx'
4
5
  import yaml from 'yaml'
5
6
  import { createPatch } from 'diff'
6
7
  import { useQueryClient } from '@tanstack/react-query'
7
8
  import { useChartDetail, useNamespaces, useArtifactHubChart, installChartWithProgress, type InstallProgressEvent } from '../../api/client'
8
- import { useCanHelmWrite } from '../../contexts/CapabilitiesContext'
9
+ import { useCanHelmAct } from '../../api/client'
9
10
  import type { ChartSource, ChartDetail, ArtifactHubChartDetail } from '../../types'
10
11
  import { YamlEditor } from '../ui/YamlEditor'
11
12
  import { Tooltip } from '../ui/Tooltip'
@@ -64,7 +65,7 @@ export function InstallWizard({ repo, chartName, version, source, repoUrl, defau
64
65
  const progressEndRef = useRef<HTMLDivElement>(null)
65
66
 
66
67
  const queryClient = useQueryClient()
67
- const canHelmWrite = useCanHelmWrite()
68
+ const { allowed: canHelmWrite, reason: helmActReason } = useCanHelmAct()
68
69
 
69
70
  // Choose the right data based on source
70
71
  const isLocal = source === 'local'
@@ -277,10 +278,7 @@ export function InstallWizard({ repo, chartName, version, source, repoUrl, defau
277
278
  {/* Content */}
278
279
  <div className="flex-1 overflow-auto p-4">
279
280
  {chartLoading ? (
280
- <div className="flex items-center justify-center h-32 text-theme-text-tertiary">
281
- <Loader2 className="w-5 h-5 animate-spin mr-2" />
282
- Loading chart details...
283
- </div>
281
+ <PaneLoader label="Loading chart details…" className="h-32" />
284
282
  ) : (
285
283
  <>
286
284
  {step === 'info' && (
@@ -389,7 +387,7 @@ export function InstallWizard({ repo, chartName, version, source, repoUrl, defau
389
387
  onClick={handleInstall}
390
388
  disabled={!canInstall || isInstalling || !canHelmWrite}
391
389
  className="flex items-center gap-2 px-4 py-2 text-sm font-medium btn-brand rounded-lg disabled:cursor-not-allowed"
392
- title={!canHelmWrite ? 'Helm write permissions required (rbac.helm=true)' : undefined}
390
+ title={!canHelmWrite ? helmActReason : undefined}
393
391
  >
394
392
  {isInstalling ? (
395
393
  <Loader2 className="w-4 h-4 animate-spin" />
@@ -1,4 +1,5 @@
1
1
  import { X, GitCompare } from 'lucide-react'
2
+ import { PaneLoader } from '@skyhook-io/k8s-ui'
2
3
  import { clsx } from 'clsx'
3
4
 
4
5
  interface ManifestDiffViewerProps {
@@ -11,11 +12,7 @@ interface ManifestDiffViewerProps {
11
12
 
12
13
  export function ManifestDiffViewer({ diff, isLoading, revision1, revision2, onClose }: ManifestDiffViewerProps) {
13
14
  if (isLoading) {
14
- return (
15
- <div className="flex items-center justify-center h-32 text-theme-text-tertiary">
16
- Computing diff...
17
- </div>
18
- )
15
+ return <PaneLoader label="Computing diff…" className="h-32" />
19
16
  }
20
17
 
21
18
  if (!diff) {
@@ -1,4 +1,5 @@
1
1
  import { Copy, Check, Code } from 'lucide-react'
2
+ import { PaneLoader } from '@skyhook-io/k8s-ui'
2
3
  import { CodeViewer } from '../ui/CodeViewer'
3
4
 
4
5
  interface ManifestViewerProps {
@@ -11,11 +12,7 @@ interface ManifestViewerProps {
11
12
 
12
13
  export function ManifestViewer({ manifest, isLoading, revision, onCopy, copied }: ManifestViewerProps) {
13
14
  if (isLoading) {
14
- return (
15
- <div className="flex items-center justify-center h-32 text-theme-text-tertiary">
16
- Loading manifest...
17
- </div>
18
- )
15
+ return <PaneLoader label="Loading manifest…" className="h-32" />
19
16
  }
20
17
 
21
18
  if (!manifest) {
@@ -0,0 +1,47 @@
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 SPA 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="font-mono text-theme-text-primary">{role ?? 'viewer'}</span>.
42
+ This view requires <span className="font-mono text-theme-text-primary">{min}</span> or higher.
43
+ Ask a {min} or owner if you need access.
44
+ </div>
45
+ </div>
46
+ )
47
+ }
@@ -1,12 +1,13 @@
1
1
  import { useState, useCallback } from 'react'
2
2
  import { Copy, Check, Settings, Pencil, X, Eye, Play, Loader2 } from 'lucide-react'
3
+ import { PaneLoader } from '@skyhook-io/k8s-ui'
3
4
  import { clsx } from 'clsx'
4
5
  import yaml from 'yaml'
5
6
  import type { HelmValues, ValuesPreviewResponse } from '../../types'
6
7
  import { CodeViewer } from '../ui/CodeViewer'
7
8
  import { YamlEditor } from '../ui/YamlEditor'
8
9
  import { useHelmPreviewValues, useHelmApplyValues } from '../../api/client'
9
- import { useCanHelmWrite } from '../../contexts/CapabilitiesContext'
10
+ import { useCanHelmAct } from '../../api/client'
10
11
  import { ValuesDiffPreview } from './ValuesDiffPreview'
11
12
 
12
13
  interface ValuesViewerProps {
@@ -41,7 +42,7 @@ export function ValuesViewer({
41
42
 
42
43
  const previewMutation = useHelmPreviewValues()
43
44
  const applyMutation = useHelmApplyValues()
44
- const canHelmWrite = useCanHelmWrite()
45
+ const { allowed: canHelmWrite, reason: helmActReason } = useCanHelmAct()
45
46
 
46
47
  const canEdit = Boolean(namespace && name) && canHelmWrite
47
48
 
@@ -138,11 +139,7 @@ export function ValuesViewer({
138
139
  }, [previewData, namespace, name, applyMutation, handleCancelEdit, onApplySuccess])
139
140
 
140
141
  if (isLoading) {
141
- return (
142
- <div className="flex items-center justify-center h-32 text-theme-text-tertiary">
143
- Loading values...
144
- </div>
145
- )
142
+ return <PaneLoader label="Loading values…" className="h-32" />
146
143
  }
147
144
 
148
145
  if (isEmpty && !isEditing) {
@@ -234,7 +231,7 @@ export function ValuesViewer({
234
231
  onClick={handleApply}
235
232
  disabled={!!yamlError || applyMutation.isPending || !canHelmWrite}
236
233
  className="flex items-center gap-1 px-2.5 py-1 text-xs btn-brand rounded disabled:cursor-not-allowed"
237
- title={!canHelmWrite ? 'Helm write permissions required (rbac.helm=true)' : undefined}
234
+ title={!canHelmWrite ? helmActReason : undefined}
238
235
  >
239
236
  {applyMutation.isPending ? (
240
237
  <Loader2 className="w-3.5 h-3.5 animate-spin" />
@@ -82,6 +82,18 @@ export function HelmSummary({ data, onNavigate }: HelmSummaryProps) {
82
82
  <span className="text-xs font-medium text-theme-text-secondary">Access Restricted</span>
83
83
  <span className="text-[11px] mt-1">Insufficient permissions to list Helm releases</span>
84
84
  </div>
85
+ ) : data.error ? (
86
+ <div className="flex flex-col items-center justify-center h-full py-4 text-theme-text-tertiary">
87
+ <Shield className="w-8 h-8 text-amber-400 mb-2" />
88
+ <span className="text-xs font-medium text-theme-text-secondary">
89
+ {data.errorCode === 'unconfigured' ? 'Helm not configured' : 'Helm unavailable'}
90
+ </span>
91
+ <span className="text-[11px] mt-1 text-center px-2 truncate max-w-full" title={data.error}>
92
+ {data.errorCode === 'unconfigured'
93
+ ? 'Set rbac.helm=true in the Radar Helm chart values.'
94
+ : data.error}
95
+ </span>
96
+ </div>
85
97
  ) : !data.releases || data.releases.length === 0 ? (
86
98
  <div className="flex items-center justify-center h-full py-4 text-xs text-theme-text-tertiary">
87
99
  No Helm releases found
@@ -9,9 +9,9 @@ import { TrafficSummary } from './TrafficSummary'
9
9
  import { CertificateHealthCard } from './CertificateHealthCard'
10
10
  import { NetworkPolicyCoverageCard } from './NetworkPolicyCoverageCard'
11
11
  import { CostCard } from './CostCard'
12
- import { AuditCard, StatusDot, mapHealthToTone } from '@skyhook-io/k8s-ui'
12
+ import { AuditCard, PaneLoader, StatusDot, mapHealthToTone } from '@skyhook-io/k8s-ui'
13
13
  import { ClusterHealthCard } from './ClusterHealthCard'
14
- import { AlertTriangle, Loader2, Shield } from 'lucide-react'
14
+ import { AlertTriangle, Shield } from 'lucide-react'
15
15
  import { clsx } from 'clsx'
16
16
 
17
17
  interface HomeViewProps {
@@ -29,14 +29,7 @@ export function HomeView({ namespaces, topology, onNavigateToView, onNavigateToR
29
29
  const { data: helmData } = useDashboardHelm(namespaces)
30
30
 
31
31
  if (isLoading) {
32
- return (
33
- <div className="flex-1 flex items-center justify-center">
34
- <div className="flex flex-col items-center gap-3">
35
- <Loader2 className="w-6 h-6 animate-spin text-theme-text-tertiary" />
36
- <span className="text-sm text-theme-text-tertiary">Loading dashboard...</span>
37
- </div>
38
- </div>
39
- )
32
+ return <PaneLoader label="Loading dashboard…" className="flex-1" />
40
33
  }
41
34
 
42
35
  if (error || !data) {
@@ -1,6 +1,7 @@
1
1
  import { useState, useRef, useEffect, useMemo, useCallback } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
3
  import { X, Folder, File, Link2, ChevronRight, ChevronDown, AlertTriangle, Loader2, Search, Download, HardDrive, Shield, ShieldCheck, Terminal, Copy, Check, RefreshCw } from 'lucide-react'
4
+ import radarLoadingIcon from '@skyhook-io/k8s-ui/assets/radar/radar-icon-loading.svg'
4
5
  import { clsx } from 'clsx'
5
6
  import { useImageMetadata, ApiError } from '../../api/client'
6
7
  import type { FileNode, ImageFilesystem } from '../../types'
@@ -177,13 +178,13 @@ export function ImageFilesystemModal({
177
178
  <div className="flex-1 overflow-y-auto p-4">
178
179
  {/* Loading state */}
179
180
  {isLoading && (
180
- <div className="flex flex-col items-center justify-center h-64">
181
- <Loader2 className="w-8 h-8 text-blue-400 animate-spin" />
182
- <span className="mt-3 text-theme-text-secondary">
183
- {isLoadingMetadata ? 'Checking image...' : 'Downloading image layers...'}
181
+ <div className="flex flex-col items-center justify-center gap-3 h-64">
182
+ <img src={radarLoadingIcon} alt="" aria-hidden className="w-11 h-11" />
183
+ <span className="text-sm text-theme-text-secondary">
184
+ {isLoadingMetadata ? 'Checking image' : 'Downloading image layers'}
184
185
  </span>
185
186
  {isLoadingFilesystem && metadata && (
186
- <span className="mt-1 text-xs text-theme-text-tertiary">
187
+ <span className="text-xs text-theme-text-tertiary">
187
188
  This may take a moment for large images
188
189
  </span>
189
190
  )}
@@ -1,6 +1,7 @@
1
1
  import { useState, useRef, useEffect, useMemo, useCallback } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
3
  import { X, File, Link2, ChevronRight, AlertTriangle, Loader2, Search, Download, FolderOpen } from 'lucide-react'
4
+ import { PaneLoader } from '@skyhook-io/k8s-ui'
4
5
  import { clsx } from 'clsx'
5
6
  import type { FileNode } from '../../types'
6
7
  import { formatBytes } from '../../utils/format'
@@ -204,12 +205,7 @@ export function PodFilesystemModal({
204
205
  {/* Content */}
205
206
  <div className="flex-1 overflow-y-auto p-4">
206
207
  {/* Loading */}
207
- {isLoading && (
208
- <div className="flex flex-col items-center justify-center h-64">
209
- <Loader2 className="w-8 h-8 text-blue-400 animate-spin" />
210
- <span className="mt-3 text-theme-text-secondary">Loading files...</span>
211
- </div>
212
- )}
208
+ {isLoading && <PaneLoader label="Loading files…" className="h-64" />}
213
209
 
214
210
  {/* Error */}
215
211
  {error && !isLoading && (
@@ -27,7 +27,7 @@ import { useHasLimitedAccess } from '../../contexts/CapabilitiesContext'
27
27
  import type { TimelineEvent, Topology } from '../../types'
28
28
  import type { NavigateToResource } from '../../utils/navigation'
29
29
  import { kindToPlural } from '../../utils/navigation'
30
- import { pluralize } from '@skyhook-io/k8s-ui'
30
+ import { PaneLoader, pluralize } from '@skyhook-io/k8s-ui'
31
31
  import { isChangeEvent, isHistoricalEvent, isOperation, displayKind } from '../../types'
32
32
  import { DiffViewer } from './DiffViewer'
33
33
  import { getOperationColor, getHealthBadgeColor, getEventTypeColor } from '../../utils/badge-colors'
@@ -436,12 +436,7 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
436
436
  }, [])
437
437
 
438
438
  if (isLoading) {
439
- return (
440
- <div className="flex items-center justify-center h-full w-full text-theme-text-tertiary">
441
- <RefreshCw className="w-5 h-5 animate-spin mr-2" />
442
- Loading timeline...
443
- </div>
444
- )
439
+ return <PaneLoader label="Loading timeline…" className="h-full w-full" />
445
440
  }
446
441
 
447
442
  // Compute empty state info (but don't early return - we need the toolbar visible)
@@ -11,7 +11,7 @@ import { Loader2, RefreshCw, Filter, Plug, ChevronDown, List, Activity, AlertTri
11
11
  import { clsx } from 'clsx'
12
12
  import { useQueryClient } from '@tanstack/react-query'
13
13
  import { useDock } from '../dock'
14
- import { EmptyState } from '@skyhook-io/k8s-ui'
14
+ import { EmptyState, PaneLoader } from '@skyhook-io/k8s-ui'
15
15
 
16
16
  // Addon types for filtering
17
17
  export type AddonMode = 'show' | 'group' | 'hide'
@@ -1136,12 +1136,10 @@ export function TrafficView({ namespaces }: TrafficViewProps) {
1136
1136
  })()}
1137
1137
 
1138
1138
  {isConnecting || (flowsFetching && finalFlows.length === 0) ? (
1139
- <div className="absolute inset-0 flex items-center justify-center">
1140
- <div className="flex items-center gap-2 text-theme-text-secondary">
1141
- <Loader2 className="h-5 w-5 animate-spin" />
1142
- <span>{isConnecting ? 'Connecting to traffic source...' : 'Loading traffic data...'}</span>
1143
- </div>
1144
- </div>
1139
+ <PaneLoader
1140
+ label={isConnecting ? 'Connecting to traffic source…' : 'Loading traffic data…'}
1141
+ className="absolute inset-0"
1142
+ />
1145
1143
  ) : finalFlows.length > 0 ? (
1146
1144
  <TrafficGraph
1147
1145
  flows={finalFlows}
@@ -1,6 +1,8 @@
1
1
  import { useState, useEffect } from 'react'
2
2
  import type { TrafficSourcesResponse, TrafficWizardState } from '../../types'
3
- import { Loader2, CheckCircle2, XCircle, AlertTriangle, Copy, ExternalLink, ArrowRight, ArrowLeft, Package } from 'lucide-react'
3
+ import { CheckCircle2, XCircle, AlertTriangle, Copy, ExternalLink, ArrowRight, ArrowLeft, Package } from 'lucide-react'
4
+ import { PaneLoader } from '@skyhook-io/k8s-ui'
5
+ import radarLoadingIcon from '@skyhook-io/k8s-ui/assets/radar/radar-icon-loading.svg'
4
6
  import { InstallWizard } from '../helm/InstallWizard'
5
7
 
6
8
  interface TrafficWizardProps {
@@ -88,14 +90,7 @@ export function TrafficWizard({
88
90
 
89
91
  // Detecting state
90
92
  if (state === 'detecting' || sourcesLoading) {
91
- return (
92
- <div className="flex items-center justify-center h-full w-full">
93
- <div className="text-center space-y-4">
94
- <Loader2 className="h-8 w-8 animate-spin text-blue-400 mx-auto" />
95
- <p className="text-theme-text-secondary">Detecting traffic sources...</p>
96
- </div>
97
- </div>
98
- )
93
+ return <PaneLoader label="Detecting traffic sources…" className="h-full w-full" />
99
94
  }
100
95
 
101
96
  // Checking state (polling for newly enabled source)
@@ -104,9 +99,9 @@ export function TrafficWizard({
104
99
  <>
105
100
  <div className="flex items-center justify-center h-full w-full">
106
101
  <div className="max-w-md w-full p-6 space-y-6">
107
- <div className="text-center space-y-2">
108
- <Loader2 className="h-8 w-8 animate-spin text-blue-400 mx-auto" />
109
- <h2 className="text-lg font-medium text-theme-text-primary">Waiting for traffic source...</h2>
102
+ <div className="flex flex-col items-center gap-3">
103
+ <img src={radarLoadingIcon} alt="" aria-hidden className="h-11 w-11" />
104
+ <h2 className="text-lg font-medium text-theme-text-primary">Waiting for traffic source…</h2>
110
105
  <p className="text-sm text-theme-text-secondary">
111
106
  Checking for availability
112
107
  </p>
package/src/index.ts CHANGED
@@ -16,3 +16,9 @@ export {
16
16
  } from './api/config';
17
17
  export type { NavCustomization } from './context/NavCustomization';
18
18
  export { ShortcutHelpOverlay } from './components/ui/ShortcutHelpOverlay';
19
+
20
+ // Shared cluster-switcher primitive — re-exported from @skyhook-io/k8s-ui so
21
+ // embedders (Radar Hub) can render a switcher visually identical to OSS Radar's
22
+ // kubeconfig ContextSwitcher without taking a direct dep on k8s-ui internals.
23
+ export { ClusterSwitcher } from '@skyhook-io/k8s-ui';
24
+ export type { ClusterSwitcherProps, ClusterSwitcherItem } from '@skyhook-io/k8s-ui';