@skyhook-io/radar-app 0.1.6 → 0.2.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.
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.2.0",
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
  )
@@ -217,6 +217,23 @@ function AppInner() {
217
217
  // Get mainView from URL path
218
218
  const mainView = getViewFromPath(location.pathname)
219
219
 
220
+ // Workload slug after `/resources/` (defaults to `pods`). Bare `/resources` redirects to `/resources/pods`.
221
+ const normalizedResourcesKindSlug = useMemo(() => {
222
+ const m = location.pathname.match(/^\/resources(?:\/([^/]+))?/)
223
+ const slug = m?.[1] ?? ''
224
+ return slug || 'pods'
225
+ }, [location.pathname])
226
+
227
+ // Canonical URL — `/resources` is not stable for bookmarks/sharing; normalize to `/resources/pods`.
228
+ useEffect(() => {
229
+ const path = location.pathname.replace(/\/+$/, '') || '/'
230
+ if (path !== '/resources') return
231
+ navigate(
232
+ { pathname: '/resources/pods', search: location.search, hash: location.hash },
233
+ { replace: true },
234
+ )
235
+ }, [location.pathname, location.search, location.hash, navigate])
236
+
220
237
  // Set mainView by navigating to the path
221
238
  const setMainView = useCallback((view: ExtendedMainView, params?: Record<string, string>) => {
222
239
  const path = view === 'home' ? '/' : `/${view}`
@@ -292,6 +309,23 @@ function AppInner() {
292
309
  // Suppress the mainView-change clear effect during controlled expand/collapse transitions.
293
310
  const suppressViewClearRef = useRef(false)
294
311
 
312
+ // Close resource drawer when switching workload kind in URL (/resources/pods → /resources/deployments).
313
+ // Keeps stale Pod drawer from masking the table after sidebar navigation (Radar Hub / app.radarhq.io).
314
+ const prevResourcesKindSlugRef = useRef<string | null>(null)
315
+ useEffect(() => {
316
+ if (mainView !== 'resources') {
317
+ prevResourcesKindSlugRef.current = null
318
+ return
319
+ }
320
+ const slug = normalizedResourcesKindSlug
321
+ const prev = prevResourcesKindSlugRef.current
322
+ prevResourcesKindSlugRef.current = slug
323
+ if (prev !== null && prev !== slug) {
324
+ setSelectedResource(null)
325
+ setDrawerExpanded(false)
326
+ }
327
+ }, [mainView, normalizedResourcesKindSlug])
328
+
295
329
  // Animation hooks for smooth mount/unmount transitions
296
330
  const resourceDrawer = useAnimatedUnmount(!!selectedResource, 300)
297
331
  const helmDrawer = useAnimatedUnmount(!!(mainView === 'helm' && selectedHelmRelease), 300)
@@ -755,12 +789,7 @@ function AppInner() {
755
789
  <header className="relative z-50 flex items-center justify-between px-4 py-2 bg-theme-base/90 backdrop-blur-sm border-b border-theme-border/50">
756
790
  {/* Left: Logo + Cluster info */}
757
791
  <div className="flex items-center gap-4 shrink-0">
758
- {navCustomization.brandSlot ?? (
759
- <div className="flex items-center gap-2.5">
760
- <Logo />
761
- <span className="text-xl text-theme-text-primary leading-none -translate-y-0.5" style={{ fontFamily: "'DM Sans', sans-serif", fontWeight: 520 }}>radar</span>
762
- </div>
763
- )}
792
+ {navCustomization.brandSlot ?? <Logo />}
764
793
 
765
794
  <div className="flex items-center gap-2">
766
795
  {navCustomization.contextSlot ?? <ContextSwitcher />}
@@ -787,13 +816,17 @@ function AppInner() {
787
816
  }`}
788
817
  />
789
818
  </Tooltip>
790
- <span className="text-xs text-theme-text-tertiary hidden xl:inline">
791
- {!connected
792
- ? 'Disconnected'
793
- : crdDiscoveryStatus === 'discovering'
794
- ? 'Discovering Custom Resources...'
795
- : 'Connected'}
796
- </span>
819
+ {/* Inline label only for non-steady states where the user
820
+ might need to act or wait. The healthy "Connected" case
821
+ is the dot alone; the dot's tooltip discloses it. Keeping
822
+ "Connected" text here would expand the left section and
823
+ collide with the absolute-centered nav block at xl, which
824
+ is the same breakpoint where nav labels appear. */}
825
+ {(!connected || crdDiscoveryStatus === 'discovering') && (
826
+ <span className="text-xs text-theme-text-tertiary hidden xl:inline">
827
+ {!connected ? 'Disconnected' : 'Discovering Custom Resources...'}
828
+ </span>
829
+ )}
797
830
  {!connected && (
798
831
  <button
799
832
  onClick={reconnect}
@@ -834,7 +867,17 @@ function AppInner() {
834
867
  }`}
835
868
  >
836
869
  <Icon className="w-4 h-4" />
837
- <span className="hidden lg:inline">{label}</span>
870
+ {/* Labels appear only when the absolute-centered nav has
871
+ enough horizontal room past the left section. Right-side
872
+ chrome that adds further pressure (Connected text, star
873
+ count) is intentionally pushed to the next tier (xl) so
874
+ label rendering and right-side expansion stay decoupled.
875
+ Per-button Tooltip discloses labels on hover when the
876
+ icon-only viewport is in effect. The 1440 anchor is an
877
+ off-system breakpoint chosen by measurement at the time
878
+ of this PR — recompute if the cluster switcher cap or
879
+ other left-section chrome changes appreciably. */}
880
+ <span className="hidden min-[1440px]:inline">{label}</span>
838
881
  </button>
839
882
  </Tooltip>
840
883
  ))}
@@ -942,10 +985,12 @@ function AppInner() {
942
985
  {!isSwitching && !(authMe?.authEnabled && !authMe?.username) && connection.state === 'connecting' && (
943
986
  <div className="flex-1 flex items-center justify-center bg-theme-base">
944
987
  <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" />
988
+ <img src={radarLoadingIcon} alt="" aria-hidden className="w-11 h-11" />
946
989
  <div className="text-center">
947
990
  <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>
991
+ {connection.context && (
992
+ <p className="text-sm text-theme-text-secondary mt-1">{connection.context}</p>
993
+ )}
949
994
  {connection.progressMessage && (
950
995
  <p className="text-xs text-theme-text-tertiary animate-pulse mt-3">
951
996
  {connection.progressMessage}
@@ -960,7 +1005,7 @@ function AppInner() {
960
1005
  {isSwitching && (
961
1006
  <div className="flex-1 flex items-center justify-center bg-theme-base">
962
1007
  <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" />
1008
+ <img src={radarLoadingIcon} alt="" aria-hidden className="w-11 h-11" />
964
1009
  <div className="text-center">
965
1010
  <div className="text-sm font-medium text-theme-text-primary">Switching context</div>
966
1011
  {targetContext && (
@@ -1379,14 +1424,35 @@ function App() {
1379
1424
  )
1380
1425
  }
1381
1426
 
1382
- // Skyhook logo that switches based on theme
1427
+ // Header brand: emerald-square radar icon + stacked "Radar" / "by Skyhook"
1428
+ // wordmark. Shares its visual shape with the radar-hub-web shell so the
1429
+ // standalone OSS app and the embedded Cloud experience read as the same
1430
+ // product, and is narrow enough to leave room for the cluster switcher
1431
+ // and nav block on standard laptop viewports.
1383
1432
  function Logo() {
1384
- const { theme } = useTheme()
1385
- const logoSrc = theme === 'dark'
1386
- ? '/assets/skyhook/logotype-white-color.svg'
1387
- : '/assets/skyhook/logotype-dark-color.svg'
1388
-
1389
- return <img src={logoSrc} alt="Skyhook" className="h-5 w-auto" />
1433
+ return (
1434
+ <div className="flex items-center gap-2.5">
1435
+ <div className="relative w-7 h-7 rounded-lg overflow-hidden flex-shrink-0 bg-emerald-500/10 border border-emerald-500/20">
1436
+ <img
1437
+ src="/images/radar/radar-icon.svg"
1438
+ alt=""
1439
+ aria-hidden
1440
+ className="w-full h-full p-0.5"
1441
+ // Fail loud on a missing/blocked asset rather than rendering an
1442
+ // empty emerald square next to the wordmark — the latter reads
1443
+ // as broken chrome with no diagnostics. Most likely cause is a
1444
+ // build/deploy path mismatch.
1445
+ onError={(e) =>
1446
+ console.error('Radar logo asset failed to load:', (e.currentTarget as HTMLImageElement).src)
1447
+ }
1448
+ />
1449
+ </div>
1450
+ <div className="flex flex-col leading-none">
1451
+ <span className="font-semibold text-[15px] tracking-tight text-theme-text-primary">Radar</span>
1452
+ <span className="text-[9px] mt-0.5 tracking-wide uppercase text-theme-text-tertiary">by Skyhook</span>
1453
+ </div>
1454
+ </div>
1455
+ )
1390
1456
  }
1391
1457
 
1392
1458
  // GitHub star button with live star count + programmatic starring via gh CLI
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,
@@ -25,6 +26,7 @@ import type {
25
26
  } from '../types'
26
27
  import type { GitOpsOperationResponse } from '../types/gitops'
27
28
  import { getApiBase, getAuthHeaders, getCredentialsMode, getBasename, routePath } from './config'
29
+ import { pluralToKind } from '../utils/navigation'
28
30
 
29
31
  // Wrapper around fetch that always includes credentials (for session cookies)
30
32
  // and handles 401 responses globally. Merges caller-provided headers with
@@ -212,7 +214,13 @@ export interface DashboardHelmRelease {
212
214
  export interface DashboardHelmSummary {
213
215
  total: number
214
216
  releases: DashboardHelmRelease[]
215
- restricted?: boolean // True when user lacks permissions to list Helm releases
217
+ restricted?: boolean // True when user lacks permissions to list Helm releases (RBAC-denied)
218
+ // error + errorCode populated when the Helm read failed for a non-RBAC
219
+ // reason (client not initialized, unconfigured, network). Surfaced
220
+ // via the dashboard widget so empty results aren't mistaken for
221
+ // "this cluster has zero releases."
222
+ error?: string
223
+ errorCode?: string
216
224
  }
217
225
 
218
226
  export interface DashboardCRDCount {
@@ -264,6 +272,7 @@ export interface DashboardResponse {
264
272
  audit: DashboardAudit | null
265
273
  nodeVersionSkew: { versions: Record<string, string[]>; minVersion: string; maxVersion: string } | null
266
274
  deferredLoading?: boolean // True while deferred informers (secrets, events, etc.) are still syncing
275
+ partialData?: string[] // Resource kinds still loading after first paint (slow-cluster fallback)
267
276
  accessRestricted?: boolean // True when user has no namespace access (RBAC)
268
277
  }
269
278
 
@@ -654,11 +663,16 @@ export function useNamespaceCapabilities(namespace: string | undefined, globalCa
654
663
  }
655
664
 
656
665
  // Auth
666
+ export type CloudRole = 'owner' | 'member' | 'viewer'
667
+
657
668
  export interface AuthMe {
658
669
  authEnabled: boolean
659
670
  authMode?: string
660
671
  username?: string
661
672
  groups?: string[]
673
+ /** Pre-computed Cloud tier from `cloud:<tier>` group prefix.
674
+ * Absent when not running under Cloud (OSS, OIDC, no role group). */
675
+ cloudRole?: CloudRole
662
676
  }
663
677
 
664
678
  export function useAuthMe() {
@@ -669,6 +683,81 @@ export function useAuthMe() {
669
683
  })
670
684
  }
671
685
 
686
+ // Tier ordering for Cloud-role gates. Mirrors radar OSS pkg/auth
687
+ // CloudRole.AtLeast — the SPA must agree with the backend on what
688
+ // "member-or-higher" means; otherwise we'd hide a button the
689
+ // backend would happily honor (or vice versa).
690
+ const CLOUD_ROLE_RANK: Record<string, number> = { viewer: 1, member: 2, owner: 3 }
691
+
692
+ /**
693
+ * useCloudRole returns the caller's Cloud tier (`owner` / `member` /
694
+ * `viewer`) and a `canAtLeast(min)` gate. When no Cloud role is
695
+ * present (OSS, OIDC, no role group, OR auth/me is still loading),
696
+ * `canAtLeast` returns true — the gate is strictly additive for
697
+ * Cloud-attributed users, mirroring the backend's `requireCloudRole`
698
+ * semantics. Use for passive content gating (panels, sections); use
699
+ * `useCanHelmAct` (or similar) for *click-prone* surfaces where you
700
+ * need fail-closed behavior during the auth/me round-trip to prevent
701
+ * a viewer from clicking through during the loading window.
702
+ *
703
+ * Why optimistic during load: the gated empty state ("Your role can't
704
+ * view…") rendered briefly to OSS / kubectl-plugin users before
705
+ * auth/me resolves is a worse regression than a Cloud viewer seeing
706
+ * a content tab populate for a tick before being gated out. Click-
707
+ * prevention belongs in the action-button hook, not here.
708
+ */
709
+ export function useCloudRole() {
710
+ const { data, isLoading } = useAuthMe()
711
+ const role = data?.cloudRole
712
+ return {
713
+ role,
714
+ isLoading,
715
+ isCloudUser: !!role,
716
+ canAtLeast: (min: CloudRole) => {
717
+ if (!role) return true // not Cloud-attributed (incl. still-loading) → no gate
718
+ return (CLOUD_ROLE_RANK[role] ?? 0) >= (CLOUD_ROLE_RANK[min] ?? 0)
719
+ },
720
+ }
721
+ }
722
+
723
+ /**
724
+ * useCanHelmAct combines the K8s capability gate (rbac.helm=true) and
725
+ * the Cloud role gate (member+) into a single answer for any Helm
726
+ * write or sensitive-read button. Returns { allowed, reason } so the
727
+ * tooltip can explain which gate failed.
728
+ *
729
+ * Cloud role check runs FIRST so the message is actionable for Cloud
730
+ * users — telling them "Helm write permissions required" is wrong if
731
+ * the chart is fine and the actual gate is their viewer role.
732
+ */
733
+ export function useCanHelmAct(): { allowed: boolean; reason?: string } {
734
+ const helmWrite = useCanHelmWrite()
735
+ const { role, canAtLeast, isLoading } = useCloudRole()
736
+ // Fail-closed for action buttons during the auth/me round-trip:
737
+ // a Cloud viewer who clicks during loading would otherwise fire a
738
+ // real request that gets 403'd. For OSS / kubectl-plugin the
739
+ // round-trip is sub-ms so this is imperceptible; for Cloud it
740
+ // prevents the click-through window. Distinct from useCloudRole's
741
+ // canAtLeast (which is optimistic during loading) because passive
742
+ // content gates don't have a click-handler to misfire.
743
+ if (isLoading) {
744
+ return { allowed: false, reason: 'Loading permissions…' }
745
+ }
746
+ if (!canAtLeast('member')) {
747
+ return {
748
+ allowed: false,
749
+ reason: `Your Radar Cloud role (${role ?? 'unknown'}) cannot run Helm operations. Ask a member or owner.`,
750
+ }
751
+ }
752
+ if (!helmWrite) {
753
+ return {
754
+ allowed: false,
755
+ reason: 'Helm write permissions required. Set rbac.helm=true in the Radar Helm chart values.',
756
+ }
757
+ }
758
+ return { allowed: true }
759
+ }
760
+
672
761
  // Namespaces
673
762
  export function useNamespaces() {
674
763
  return useQuery<Namespace[]>({
@@ -819,27 +908,78 @@ export function useResourceChildren(kind: string, namespace: string, name: strin
819
908
  })
820
909
  }
821
910
 
822
- // Resource-specific events (filtered by resource name)
823
- export function useResourceEvents(kind: string, namespace: string, name: string) {
824
- const params = new URLSearchParams()
825
- params.set('namespace', namespace)
826
- params.set('kind', kind)
827
- params.set('limit', '50')
911
+ export interface ResourceEventsResult {
912
+ k8sEvents: TimelineEvent[]
913
+ updates: TimelineEvent[]
914
+ isLoading: boolean
915
+ // Per-stream errors are surfaced separately so the UI can distinguish
916
+ // "this stream failed" from "no data" — silent fallback to [] would
917
+ // reproduce the exact failure mode #547 is about.
918
+ k8sError: Error | null
919
+ updatesError: Error | null
920
+ }
921
+
922
+ // K8s events and resource updates are fetched separately so a high-frequency
923
+ // informer update stream (e.g. a CrashLoop status field flapping every few
924
+ // seconds) can never starve out user-meaningful K8s events under a shared limit.
925
+ export function useResourceEvents(kind: string, namespace: string, name: string): ResourceEventsResult {
926
+ // The timeline store keys events by their K8s Kind (singular PascalCase, e.g. "Pod"),
927
+ // but callers pass the URL-form kind ("pods").
928
+ const singularKind = pluralToKind(kind)
929
+ const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()
930
+
931
+ // Include managed resources — when viewing a specific resource (e.g. a Pod owned
932
+ // by a ReplicaSet, or a K8s Event whose involvedObject is the Pod itself), the
933
+ // default preset's IsManaged() filter would otherwise drop everything.
934
+ const baseParams = () => {
935
+ const p = new URLSearchParams()
936
+ p.set('namespace', namespace)
937
+ p.set('kind', singularKind)
938
+ p.set('include_managed', 'true')
939
+ p.set('since', since)
940
+ return p
941
+ }
828
942
 
829
- // Get events from last 24 hours
830
- const since = new Date(Date.now() - 24 * 60 * 60 * 1000)
831
- params.set('since', since.toISOString())
943
+ const enabled = Boolean(kind && namespace && name)
832
944
 
833
- return useQuery<TimelineEvent[]>({
834
- queryKey: ['resource-events', kind, namespace, name],
945
+ // K8s events: high limit so the full set is always returned. The number of
946
+ // distinct K8s events per resource is naturally bounded — kubelet/controllers
947
+ // dedupe via Reason+InvolvedObject and bump count.
948
+ const k8sQuery = useQuery<TimelineEvent[]>({
949
+ queryKey: ['resource-events', 'k8s', singularKind, namespace, name],
835
950
  queryFn: async () => {
951
+ const params = baseParams()
952
+ params.set('sources', 'k8s_event')
953
+ params.set('limit', '500')
836
954
  const events = await fetchJSON<TimelineEvent[]>(`/changes?${params.toString()}`)
837
- // Filter to only events for this specific resource
838
955
  return events.filter(e => e.name === name)
839
956
  },
840
- enabled: Boolean(kind && namespace && name),
841
- refetchInterval: 15000, // Refresh every 15 seconds
957
+ enabled,
958
+ refetchInterval: 15000,
959
+ })
960
+
961
+ // Resource updates (informer diffs + historical): bounded so a flapping
962
+ // resource doesn't return an unbounded payload.
963
+ const updatesQuery = useQuery<TimelineEvent[]>({
964
+ queryKey: ['resource-events', 'updates', singularKind, namespace, name],
965
+ queryFn: async () => {
966
+ const params = baseParams()
967
+ params.set('sources', 'informer,historical')
968
+ params.set('limit', '50')
969
+ const events = await fetchJSON<TimelineEvent[]>(`/changes?${params.toString()}`)
970
+ return events.filter(e => e.name === name)
971
+ },
972
+ enabled,
973
+ refetchInterval: 15000,
842
974
  })
975
+
976
+ return {
977
+ k8sEvents: k8sQuery.data ?? [],
978
+ updates: updatesQuery.data ?? [],
979
+ isLoading: k8sQuery.isLoading || updatesQuery.isLoading,
980
+ k8sError: (k8sQuery.error as Error | null) ?? null,
981
+ updatesError: (updatesQuery.error as Error | null) ?? null,
982
+ }
843
983
  }
844
984
 
845
985
  // ============================================================================
@@ -1652,8 +1792,11 @@ export function useHelmRelease(namespace: string, name: string) {
1652
1792
  })
1653
1793
  }
1654
1794
 
1655
- // Get manifest for a Helm release (optionally at a specific revision)
1656
- export function useHelmManifest(namespace: string, name: string, revision?: number) {
1795
+ // Get manifest for a Helm release (optionally at a specific revision).
1796
+ // `enabled` lets callers skip the query when the user's Cloud role
1797
+ // would 403 the read — saves a round-trip and avoids a transient
1798
+ // "error" state that the role-gated empty panel doesn't need.
1799
+ export function useHelmManifest(namespace: string, name: string, revision?: number, enabled = true) {
1657
1800
  const params = revision ? `?revision=${revision}` : ''
1658
1801
  return useQuery<string>({
1659
1802
  queryKey: ['helm-manifest', namespace, name, revision],
@@ -1665,34 +1808,35 @@ export function useHelmManifest(namespace: string, name: string, revision?: numb
1665
1808
  }
1666
1809
  return response.text()
1667
1810
  },
1668
- enabled: Boolean(namespace && name),
1811
+ enabled: Boolean(namespace && name && enabled),
1669
1812
  staleTime: 60000, // 1 minute
1670
1813
  })
1671
1814
  }
1672
1815
 
1673
- // Get values for a Helm release
1674
- export function useHelmValues(namespace: string, name: string, allValues?: boolean) {
1816
+ // Get values for a Helm release. `enabled` see useHelmManifest.
1817
+ export function useHelmValues(namespace: string, name: string, allValues?: boolean, enabled = true) {
1675
1818
  const params = allValues ? '?all=true' : ''
1676
1819
  return useQuery<HelmValues>({
1677
1820
  queryKey: ['helm-values', namespace, name, allValues],
1678
1821
  queryFn: () => fetchJSON(`/helm/releases/${namespace}/${name}/values${params}`),
1679
- enabled: Boolean(namespace && name),
1822
+ enabled: Boolean(namespace && name && enabled),
1680
1823
  staleTime: 60000,
1681
1824
  })
1682
1825
  }
1683
1826
 
1684
- // Get diff between two revisions
1827
+ // Get diff between two revisions. `enabled` see useHelmManifest.
1685
1828
  export function useHelmManifestDiff(
1686
1829
  namespace: string,
1687
1830
  name: string,
1688
1831
  revision1: number,
1689
- revision2: number
1832
+ revision2: number,
1833
+ enabled = true,
1690
1834
  ) {
1691
1835
  return useQuery<ManifestDiff>({
1692
1836
  queryKey: ['helm-diff', namespace, name, revision1, revision2],
1693
1837
  queryFn: () =>
1694
1838
  fetchJSON(`/helm/releases/${namespace}/${name}/diff?revision1=${revision1}&revision2=${revision2}`),
1695
- enabled: Boolean(namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2),
1839
+ enabled: Boolean(namespace && name && revision1 > 0 && revision2 > 0 && revision1 !== revision2 && enabled),
1696
1840
  staleTime: 60000,
1697
1841
  })
1698
1842
  }