@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 +1 -1
- package/src/App.tsx +93 -27
- package/src/api/client.ts +168 -24
- package/src/components/ContextSwitcher.tsx +100 -357
- package/src/components/audit/AuditView.tsx +3 -10
- package/src/components/cost/CostView.tsx +2 -8
- package/src/components/helm/ChartBrowser.tsx +16 -6
- package/src/components/helm/HelmReleaseDrawer.tsx +53 -36
- package/src/components/helm/HelmView.tsx +2 -3
- package/src/components/helm/InstallWizard.tsx +5 -7
- package/src/components/helm/ManifestDiffViewer.tsx +2 -5
- package/src/components/helm/ManifestViewer.tsx +2 -5
- package/src/components/helm/RoleGatedPanel.tsx +47 -0
- package/src/components/helm/ValuesViewer.tsx +5 -8
- package/src/components/home/HelmSummary.tsx +12 -0
- package/src/components/home/HomeView.tsx +3 -10
- package/src/components/resources/ImageFilesystemModal.tsx +6 -5
- package/src/components/resources/PodFilesystemModal.tsx +2 -6
- package/src/components/resources/ResourcesView.tsx +1 -0
- package/src/components/timeline/TimelineSwimlanes.tsx +2 -7
- package/src/components/traffic/TrafficView.tsx +5 -7
- package/src/components/traffic/TrafficWizard.tsx +7 -12
- package/src/components/workload/WorkloadView.tsx +16 -0
- package/src/index.ts +6 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +0 -31
package/package.json
CHANGED
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
|
|
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
|
-
<
|
|
145
|
-
<p className="text-sm text-theme-text-secondary">Redirecting to login
|
|
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
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
-
//
|
|
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
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
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
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
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
|
-
|
|
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
|
-
|
|
834
|
-
|
|
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
|
|
841
|
-
refetchInterval: 15000,
|
|
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
|
-
|
|
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
|
}
|