@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/radar-app",
3
- "version": "0.1.6",
3
+ "version": "0.1.9",
4
4
  "description": "Radar's full web UI as a reusable React component. Used by Radar's own binary and by external consumers like Radar Cloud.",
5
5
  "repository": {
6
6
  "type": "git",
package/src/App.tsx CHANGED
@@ -37,7 +37,7 @@ import { useNamespaces, useSwitchContext, useAuthMe } from './api/client'
37
37
  import { routePath, apiUrl, getAuthHeaders, getCredentialsMode } from './api/config'
38
38
  import { KeyboardShortcutProvider, useRegisterShortcut, useRegisterShortcuts } from './hooks/useKeyboardShortcuts'
39
39
  import { useAnimatedUnmount } from './hooks/useAnimatedUnmount'
40
- import { Loader2 } from 'lucide-react'
40
+ import radarLoadingIcon from '@skyhook-io/k8s-ui/assets/radar/radar-icon-loading.svg'
41
41
  import { RefreshCw, Network, List, Clock, Package, Sun, Moon, Activity, Home, Star, Search, Bug, Settings, SquareTerminal, ShieldCheck } from 'lucide-react'
42
42
  import { useTheme } from './context/ThemeContext'
43
43
  import { Tooltip } from './components/ui/Tooltip'
@@ -141,8 +141,8 @@ function AuthBarrier({ authMode }: { authMode: string }) {
141
141
  return (
142
142
  <div className="flex-1 flex items-center justify-center bg-theme-base">
143
143
  <div className="flex flex-col items-center gap-4">
144
- <Loader2 className="w-8 h-8 animate-spin text-blue-400" />
145
- <p className="text-sm text-theme-text-secondary">Redirecting to login...</p>
144
+ <img src={radarLoadingIcon} alt="" aria-hidden className="w-11 h-11" />
145
+ <p className="text-sm text-theme-text-secondary">Redirecting to login…</p>
146
146
  </div>
147
147
  </div>
148
148
  )
@@ -942,10 +942,10 @@ function AppInner() {
942
942
  {!isSwitching && !(authMe?.authEnabled && !authMe?.username) && connection.state === 'connecting' && (
943
943
  <div className="flex-1 flex items-center justify-center bg-theme-base">
944
944
  <div className="flex flex-col items-center gap-4 text-theme-text-secondary">
945
- <Loader2 className="w-8 h-8 animate-spin text-blue-400" />
945
+ <img src={radarLoadingIcon} alt="" aria-hidden className="w-11 h-11" />
946
946
  <div className="text-center">
947
947
  <p className="font-medium text-theme-text-primary">Connecting to cluster</p>
948
- <p className="text-sm text-theme-text-secondary mt-1">{connection.context || 'Loading...'}</p>
948
+ <p className="text-sm text-theme-text-secondary mt-1">{connection.context || 'Loading'}</p>
949
949
  {connection.progressMessage && (
950
950
  <p className="text-xs text-theme-text-tertiary animate-pulse mt-3">
951
951
  {connection.progressMessage}
@@ -960,7 +960,7 @@ function AppInner() {
960
960
  {isSwitching && (
961
961
  <div className="flex-1 flex items-center justify-center bg-theme-base">
962
962
  <div className="flex flex-col items-center gap-4 text-theme-text-secondary">
963
- <Loader2 className="w-8 h-8 animate-spin text-blue-400" />
963
+ <img src={radarLoadingIcon} alt="" aria-hidden className="w-11 h-11" />
964
964
  <div className="text-center">
965
965
  <div className="text-sm font-medium text-theme-text-primary">Switching context</div>
966
966
  {targetContext && (
package/src/api/client.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { useQuery, useMutation, useQueryClient, skipToken } from '@tanstack/react-query'
2
2
  import { showApiError, showApiSuccess } from '../components/ui/Toast'
3
+ import { useCanHelmWrite } from '../contexts/CapabilitiesContext'
3
4
  import type {
4
5
  Topology,
5
6
  ClusterInfo,
@@ -212,7 +213,13 @@ export interface DashboardHelmRelease {
212
213
  export interface DashboardHelmSummary {
213
214
  total: number
214
215
  releases: DashboardHelmRelease[]
215
- restricted?: boolean // True when user lacks permissions to list Helm releases
216
+ restricted?: boolean // True when user lacks permissions to list Helm releases (RBAC-denied)
217
+ // error + errorCode populated when the Helm read failed for a non-RBAC
218
+ // reason (client not initialized, unconfigured, network). Surfaced
219
+ // via the dashboard widget so empty results aren't mistaken for
220
+ // "this cluster has zero releases."
221
+ error?: string
222
+ errorCode?: string
216
223
  }
217
224
 
218
225
  export interface DashboardCRDCount {
@@ -264,6 +271,7 @@ export interface DashboardResponse {
264
271
  audit: DashboardAudit | null
265
272
  nodeVersionSkew: { versions: Record<string, string[]>; minVersion: string; maxVersion: string } | null
266
273
  deferredLoading?: boolean // True while deferred informers (secrets, events, etc.) are still syncing
274
+ partialData?: string[] // Resource kinds still loading after first paint (slow-cluster fallback)
267
275
  accessRestricted?: boolean // True when user has no namespace access (RBAC)
268
276
  }
269
277
 
@@ -654,11 +662,16 @@ export function useNamespaceCapabilities(namespace: string | undefined, globalCa
654
662
  }
655
663
 
656
664
  // Auth
665
+ export type CloudRole = 'owner' | 'member' | 'viewer'
666
+
657
667
  export interface AuthMe {
658
668
  authEnabled: boolean
659
669
  authMode?: string
660
670
  username?: string
661
671
  groups?: string[]
672
+ /** Pre-computed Cloud tier from `cloud:<tier>` group prefix.
673
+ * Absent when not running under Cloud (OSS, OIDC, no role group). */
674
+ cloudRole?: CloudRole
662
675
  }
663
676
 
664
677
  export function useAuthMe() {
@@ -669,6 +682,81 @@ export function useAuthMe() {
669
682
  })
670
683
  }
671
684
 
685
+ // Tier ordering for Cloud-role gates. Mirrors radar OSS pkg/auth
686
+ // CloudRole.AtLeast — the SPA must agree with the backend on what
687
+ // "member-or-higher" means; otherwise we'd hide a button the
688
+ // backend would happily honor (or vice versa).
689
+ const CLOUD_ROLE_RANK: Record<string, number> = { viewer: 1, member: 2, owner: 3 }
690
+
691
+ /**
692
+ * useCloudRole returns the caller's Cloud tier (`owner` / `member` /
693
+ * `viewer`) and a `canAtLeast(min)` gate. When no Cloud role is
694
+ * present (OSS, OIDC, no role group, OR auth/me is still loading),
695
+ * `canAtLeast` returns true — the gate is strictly additive for
696
+ * Cloud-attributed users, mirroring the backend's `requireCloudRole`
697
+ * semantics. Use for passive content gating (panels, sections); use
698
+ * `useCanHelmAct` (or similar) for *click-prone* surfaces where you
699
+ * need fail-closed behavior during the auth/me round-trip to prevent
700
+ * a viewer from clicking through during the loading window.
701
+ *
702
+ * Why optimistic during load: the gated empty state ("Your role can't
703
+ * view…") rendered briefly to OSS / kubectl-plugin users before
704
+ * auth/me resolves is a worse regression than a Cloud viewer seeing
705
+ * a content tab populate for a tick before being gated out. Click-
706
+ * prevention belongs in the action-button hook, not here.
707
+ */
708
+ export function useCloudRole() {
709
+ const { data, isLoading } = useAuthMe()
710
+ const role = data?.cloudRole
711
+ return {
712
+ role,
713
+ isLoading,
714
+ isCloudUser: !!role,
715
+ canAtLeast: (min: CloudRole) => {
716
+ if (!role) return true // not Cloud-attributed (incl. still-loading) → no gate
717
+ return (CLOUD_ROLE_RANK[role] ?? 0) >= (CLOUD_ROLE_RANK[min] ?? 0)
718
+ },
719
+ }
720
+ }
721
+
722
+ /**
723
+ * useCanHelmAct combines the K8s capability gate (rbac.helm=true) and
724
+ * the Cloud role gate (member+) into a single answer for any Helm
725
+ * write or sensitive-read button. Returns { allowed, reason } so the
726
+ * tooltip can explain which gate failed.
727
+ *
728
+ * Cloud role check runs FIRST so the message is actionable for Cloud
729
+ * users — telling them "Helm write permissions required" is wrong if
730
+ * the chart is fine and the actual gate is their viewer role.
731
+ */
732
+ export function useCanHelmAct(): { allowed: boolean; reason?: string } {
733
+ const helmWrite = useCanHelmWrite()
734
+ const { role, canAtLeast, isLoading } = useCloudRole()
735
+ // Fail-closed for action buttons during the auth/me round-trip:
736
+ // a Cloud viewer who clicks during loading would otherwise fire a
737
+ // real request that gets 403'd. For OSS / kubectl-plugin the
738
+ // round-trip is sub-ms so this is imperceptible; for Cloud it
739
+ // prevents the click-through window. Distinct from useCloudRole's
740
+ // canAtLeast (which is optimistic during loading) because passive
741
+ // content gates don't have a click-handler to misfire.
742
+ if (isLoading) {
743
+ return { allowed: false, reason: 'Loading permissions…' }
744
+ }
745
+ if (!canAtLeast('member')) {
746
+ return {
747
+ allowed: false,
748
+ reason: `Your Radar Cloud role (${role ?? 'unknown'}) cannot run Helm operations. Ask a member or owner.`,
749
+ }
750
+ }
751
+ if (!helmWrite) {
752
+ return {
753
+ allowed: false,
754
+ reason: 'Helm write permissions required. Set rbac.helm=true in the Radar Helm chart values.',
755
+ }
756
+ }
757
+ return { allowed: true }
758
+ }
759
+
672
760
  // Namespaces
673
761
  export function useNamespaces() {
674
762
  return useQuery<Namespace[]>({
@@ -1652,8 +1740,11 @@ export function useHelmRelease(namespace: string, name: string) {
1652
1740
  })
1653
1741
  }
1654
1742
 
1655
- // Get manifest for a Helm release (optionally at a specific revision)
1656
- export function useHelmManifest(namespace: string, name: string, revision?: number) {
1743
+ // Get manifest for a Helm release (optionally at a specific revision).
1744
+ // `enabled` lets callers skip the query when the user's Cloud role
1745
+ // would 403 the read — saves a round-trip and avoids a transient
1746
+ // "error" state that the role-gated empty panel doesn't need.
1747
+ export function useHelmManifest(namespace: string, name: string, revision?: number, enabled = true) {
1657
1748
  const params = revision ? `?revision=${revision}` : ''
1658
1749
  return useQuery<string>({
1659
1750
  queryKey: ['helm-manifest', namespace, name, revision],
@@ -1665,34 +1756,35 @@ export function useHelmManifest(namespace: string, name: string, revision?: numb
1665
1756
  }
1666
1757
  return response.text()
1667
1758
  },
1668
- enabled: Boolean(namespace && name),
1759
+ enabled: Boolean(namespace && name && enabled),
1669
1760
  staleTime: 60000, // 1 minute
1670
1761
  })
1671
1762
  }
1672
1763
 
1673
- // Get values for a Helm release
1674
- export function useHelmValues(namespace: string, name: string, allValues?: boolean) {
1764
+ // Get values for a Helm release. `enabled` see useHelmManifest.
1765
+ export function useHelmValues(namespace: string, name: string, allValues?: boolean, enabled = true) {
1675
1766
  const params = allValues ? '?all=true' : ''
1676
1767
  return useQuery<HelmValues>({
1677
1768
  queryKey: ['helm-values', namespace, name, allValues],
1678
1769
  queryFn: () => fetchJSON(`/helm/releases/${namespace}/${name}/values${params}`),
1679
- enabled: Boolean(namespace && name),
1770
+ enabled: Boolean(namespace && name && enabled),
1680
1771
  staleTime: 60000,
1681
1772
  })
1682
1773
  }
1683
1774
 
1684
- // Get diff between two revisions
1775
+ // Get diff between two revisions. `enabled` see useHelmManifest.
1685
1776
  export function useHelmManifestDiff(
1686
1777
  namespace: string,
1687
1778
  name: string,
1688
1779
  revision1: number,
1689
- revision2: number
1780
+ revision2: number,
1781
+ enabled = true,
1690
1782
  ) {
1691
1783
  return useQuery<ManifestDiff>({
1692
1784
  queryKey: ['helm-diff', namespace, name, revision1, revision2],
1693
1785
  queryFn: () =>
1694
1786
  fetchJSON(`/helm/releases/${namespace}/${name}/diff?revision1=${revision1}&revision2=${revision2}`),
1695
- enabled: Boolean(namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2),
1787
+ enabled: Boolean(namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled),
1696
1788
  staleTime: 60000,
1697
1789
  })
1698
1790
  }