@skyhook-io/radar-app 1.0.0 → 1.0.2
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 +5 -5
- package/src/App.tsx +64 -19
- package/src/api/client.ts +12 -3
- package/src/components/ContextSwitcher.tsx +49 -16
- package/src/components/helm/HelmReleaseDrawer.tsx +30 -13
- package/src/components/helm/HelmView.tsx +33 -7
- package/src/components/portforward/PortForwardManager.tsx +17 -2
- package/src/components/resources/ResourcesView.tsx +2 -2
- package/src/components/ui/NamespaceSelector.tsx +15 -5
- package/src/components/workload/WorkloadView.tsx +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyhook-io/radar-app",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
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",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"react-virtuoso": "^4.18.6",
|
|
36
36
|
"remark-gfm": "^4.0.1",
|
|
37
37
|
"shiki": "^4.0.1",
|
|
38
|
-
"yaml": "^2.8.
|
|
38
|
+
"yaml": "^2.8.4"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
41
41
|
"@skyhook-io/k8s-ui": ">=1.5.0",
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"@skyhook-io/k8s-ui": "*",
|
|
55
55
|
"@tailwindcss/typography": "^0.5.19",
|
|
56
56
|
"@tailwindcss/vite": "^4.2.4",
|
|
57
|
-
"@tanstack/react-query": "^5.100.
|
|
57
|
+
"@tanstack/react-query": "^5.100.9",
|
|
58
58
|
"@types/diff": "^8.0.0",
|
|
59
59
|
"@types/node": "^25.5.0",
|
|
60
60
|
"@types/react": "^19.2.14",
|
|
@@ -64,11 +64,11 @@
|
|
|
64
64
|
"clsx": "^2.1.1",
|
|
65
65
|
"elkjs": "^0.11.1",
|
|
66
66
|
"lucide-react": "^1.12.0",
|
|
67
|
-
"postcss": "^8.5.
|
|
67
|
+
"postcss": "^8.5.14",
|
|
68
68
|
"prettier": "^3.8.1",
|
|
69
69
|
"react": "^19.2.5",
|
|
70
70
|
"react-dom": "^19.2.5",
|
|
71
|
-
"react-router-dom": "^7.
|
|
71
|
+
"react-router-dom": "^7.15.0",
|
|
72
72
|
"tailwind-merge": "^3.5.0",
|
|
73
73
|
"tailwindcss": "^4.2.4",
|
|
74
74
|
"typescript": "^6.0.2",
|
package/src/App.tsx
CHANGED
|
@@ -28,7 +28,7 @@ import { ConnectionErrorView } from './components/ConnectionErrorView'
|
|
|
28
28
|
import { CapabilitiesProvider, useCapabilitiesContext } from './contexts/CapabilitiesContext'
|
|
29
29
|
import { UserMenu } from './components/UserMenu'
|
|
30
30
|
import { ErrorBoundary } from './components/ui/ErrorBoundary'
|
|
31
|
-
import { NamespaceSelector } from './components/ui/NamespaceSelector'
|
|
31
|
+
import { NamespaceSelector, type NamespaceSelectorHandle } from './components/ui/NamespaceSelector'
|
|
32
32
|
import { UpdateNotification } from './components/ui/UpdateNotification'
|
|
33
33
|
import { ShortcutHelpOverlay } from './components/ui/ShortcutHelpOverlay'
|
|
34
34
|
import { CommandPalette } from './components/ui/CommandPalette'
|
|
@@ -46,6 +46,7 @@ import { LargeClusterNamespacePicker } from './components/shared/LargeClusterNam
|
|
|
46
46
|
import { SettingsDialog } from './components/settings/SettingsDialog'
|
|
47
47
|
import type { TopologyNode, GroupingMode, MainView, SelectedResource, SelectedHelmRelease, NodeKind, TopologyMode, Topology, K8sEvent } from './types'
|
|
48
48
|
import { kindToPlural, openExternal } from './utils/navigation'
|
|
49
|
+
import type { ContextSwitcherHandle } from './components/ContextSwitcher'
|
|
49
50
|
|
|
50
51
|
// All possible node kinds (core + GitOps)
|
|
51
52
|
const ALL_NODE_KINDS: NodeKind[] = [
|
|
@@ -310,34 +311,51 @@ function AppInner() {
|
|
|
310
311
|
// Suppress the mainView-change clear effect during controlled expand/collapse transitions.
|
|
311
312
|
const suppressViewClearRef = useRef(false)
|
|
312
313
|
|
|
313
|
-
// Close resource drawer when
|
|
314
|
-
//
|
|
315
|
-
|
|
314
|
+
// Close resource drawer when the /resources route no longer matches the
|
|
315
|
+
// selected drawer resource. This covers both in-view kind switches and
|
|
316
|
+
// cross-kind navigations from expanded drawers (for example Node -> View Pods).
|
|
317
|
+
const prevResourcesKindKeyRef = useRef<string | null>(null)
|
|
318
|
+
const currentResourceKindSlug = normalizedResourcesKindSlug.toLowerCase()
|
|
319
|
+
const currentResourceGroup = searchParams.get('apiGroup') ?? ''
|
|
320
|
+
const selectedResourceKindSlug = selectedResource ? kindToPlural(selectedResource.kind).toLowerCase() : ''
|
|
321
|
+
const selectedResourceGroup = selectedResource?.group ?? ''
|
|
322
|
+
const selectedResourceRouteMismatch = mainView === 'resources' && !!selectedResource && (
|
|
323
|
+
selectedResourceKindSlug !== currentResourceKindSlug ||
|
|
324
|
+
selectedResourceGroup !== currentResourceGroup
|
|
325
|
+
)
|
|
326
|
+
const resourcesKindRouteChanged = mainView === 'resources' &&
|
|
327
|
+
prevResourcesKindKeyRef.current !== null &&
|
|
328
|
+
prevResourcesKindKeyRef.current !== `${currentResourceGroup}/${currentResourceKindSlug}`
|
|
329
|
+
const routeSelectedResource = resourcesKindRouteChanged && selectedResourceRouteMismatch
|
|
330
|
+
? null
|
|
331
|
+
: selectedResource
|
|
332
|
+
|
|
316
333
|
useEffect(() => {
|
|
317
334
|
if (mainView !== 'resources') {
|
|
318
|
-
|
|
335
|
+
prevResourcesKindKeyRef.current = null
|
|
319
336
|
return
|
|
320
337
|
}
|
|
321
|
-
const
|
|
322
|
-
const prev =
|
|
323
|
-
|
|
324
|
-
|
|
338
|
+
const key = `${currentResourceGroup}/${currentResourceKindSlug}`
|
|
339
|
+
const prev = prevResourcesKindKeyRef.current
|
|
340
|
+
prevResourcesKindKeyRef.current = key
|
|
341
|
+
|
|
342
|
+
if (prev !== null && prev !== key && selectedResourceRouteMismatch) {
|
|
325
343
|
setSelectedResource(null)
|
|
326
344
|
setDrawerExpanded(false)
|
|
327
345
|
}
|
|
328
|
-
}, [mainView,
|
|
346
|
+
}, [mainView, currentResourceKindSlug, currentResourceGroup, selectedResourceRouteMismatch])
|
|
329
347
|
|
|
330
348
|
// Animation hooks for smooth mount/unmount transitions
|
|
331
|
-
const resourceDrawer = useAnimatedUnmount(!!
|
|
349
|
+
const resourceDrawer = useAnimatedUnmount(!!routeSelectedResource, 300)
|
|
332
350
|
const helmDrawer = useAnimatedUnmount(!!(mainView === 'helm' && selectedHelmRelease), 300)
|
|
333
351
|
const helpOverlay = useAnimatedUnmount(showHelp, 300)
|
|
334
352
|
const commandPaletteAnim = useAnimatedUnmount(showCommandPalette, 300)
|
|
335
353
|
const diagnosticsOverlay = useAnimatedUnmount(showDiagnostics, 300)
|
|
336
354
|
|
|
337
355
|
// Hold last valid values so drawers can animate out before data disappears
|
|
338
|
-
const lastResourceRef = useRef(
|
|
339
|
-
if (
|
|
340
|
-
const drawerResource =
|
|
356
|
+
const lastResourceRef = useRef(routeSelectedResource)
|
|
357
|
+
if (routeSelectedResource) lastResourceRef.current = routeSelectedResource
|
|
358
|
+
const drawerResource = routeSelectedResource || lastResourceRef.current
|
|
341
359
|
|
|
342
360
|
const lastHelmReleaseRef = useRef(selectedHelmRelease)
|
|
343
361
|
if (selectedHelmRelease) lastHelmReleaseRef.current = selectedHelmRelease
|
|
@@ -371,6 +389,10 @@ function AppInner() {
|
|
|
371
389
|
// Context switching for command palette
|
|
372
390
|
const switchContext = useSwitchContext()
|
|
373
391
|
|
|
392
|
+
// Refs for dropdown components to trigger them via shortcuts
|
|
393
|
+
const namespaceSelectorRef = useRef<NamespaceSelectorHandle>(null)
|
|
394
|
+
const contextSwitcherRef = useRef<ContextSwitcherHandle>(null)
|
|
395
|
+
|
|
374
396
|
// View switching keyboard shortcuts
|
|
375
397
|
const views: ExtendedMainView[] = ['home', 'topology', 'resources', 'timeline', 'helm', 'traffic', 'cost', 'audit']
|
|
376
398
|
useRegisterShortcuts([
|
|
@@ -382,6 +404,22 @@ function AppInner() {
|
|
|
382
404
|
scope: 'global' as const,
|
|
383
405
|
handler: () => setMainView(view),
|
|
384
406
|
})),
|
|
407
|
+
{
|
|
408
|
+
id: 'switch-namespace',
|
|
409
|
+
keys: 'n',
|
|
410
|
+
description: 'Switch namespace',
|
|
411
|
+
category: 'Navigation' as const,
|
|
412
|
+
scope: 'global' as const,
|
|
413
|
+
handler: () => namespaceSelectorRef.current?.open(),
|
|
414
|
+
},
|
|
415
|
+
{
|
|
416
|
+
id: 'switch-context',
|
|
417
|
+
keys: 'c',
|
|
418
|
+
description: 'Switch context',
|
|
419
|
+
category: 'Navigation' as const,
|
|
420
|
+
scope: 'global' as const,
|
|
421
|
+
handler: () => contextSwitcherRef.current?.open(),
|
|
422
|
+
},
|
|
385
423
|
{
|
|
386
424
|
id: 'theme-toggle',
|
|
387
425
|
keys: 't',
|
|
@@ -676,7 +714,7 @@ function AppInner() {
|
|
|
676
714
|
if (slashIdx > 0) {
|
|
677
715
|
const ns = releaseParam.slice(0, slashIdx)
|
|
678
716
|
const name = releaseParam.slice(slashIdx + 1)
|
|
679
|
-
setSelectedHelmRelease({ namespace: ns, name })
|
|
717
|
+
setSelectedHelmRelease({ namespace: ns, name, storageNamespace: searchParams.get('releaseStorage') || undefined })
|
|
680
718
|
}
|
|
681
719
|
}
|
|
682
720
|
}, [searchParams])
|
|
@@ -798,7 +836,7 @@ function AppInner() {
|
|
|
798
836
|
{navCustomization.brandSlot ?? <Logo />}
|
|
799
837
|
|
|
800
838
|
<div className="flex items-center gap-2">
|
|
801
|
-
{navCustomization.contextSlot ?? <ContextSwitcher />}
|
|
839
|
+
{navCustomization.contextSlot ?? <ContextSwitcher ref={contextSwitcherRef} />}
|
|
802
840
|
{/* Connection status - next to cluster name */}
|
|
803
841
|
<div className="flex items-center gap-1.5 ml-1">
|
|
804
842
|
<Tooltip
|
|
@@ -893,6 +931,7 @@ function AppInner() {
|
|
|
893
931
|
<div className="flex items-center gap-3 shrink-0">
|
|
894
932
|
{/* Namespace selector with search */}
|
|
895
933
|
<NamespaceSelector
|
|
934
|
+
ref={namespaceSelectorRef}
|
|
896
935
|
value={namespaces}
|
|
897
936
|
onChange={setNamespaces}
|
|
898
937
|
namespaces={availableNamespaces}
|
|
@@ -1192,7 +1231,7 @@ function AppInner() {
|
|
|
1192
1231
|
{mainView === 'resources' && (
|
|
1193
1232
|
<ResourcesView
|
|
1194
1233
|
namespaces={namespaces}
|
|
1195
|
-
selectedResource={
|
|
1234
|
+
selectedResource={routeSelectedResource}
|
|
1196
1235
|
onResourceClick={(res) => res ? navigateToResource(res) : setSelectedResource(null)}
|
|
1197
1236
|
onResourceClickYaml={(res) => navigateToResource(res, 'yaml')}
|
|
1198
1237
|
onKindChange={() => setSelectedResource(null)}
|
|
@@ -1220,10 +1259,15 @@ function AppInner() {
|
|
|
1220
1259
|
<HelmView
|
|
1221
1260
|
namespace=""
|
|
1222
1261
|
selectedRelease={selectedHelmRelease}
|
|
1223
|
-
onReleaseClick={(ns, name) => {
|
|
1224
|
-
setSelectedHelmRelease({ namespace: ns, name })
|
|
1262
|
+
onReleaseClick={(ns, name, storageNamespace) => {
|
|
1263
|
+
setSelectedHelmRelease({ namespace: ns, name, storageNamespace })
|
|
1225
1264
|
const params = new URLSearchParams(window.location.search)
|
|
1226
1265
|
params.set('release', `${ns}/${name}`)
|
|
1266
|
+
if (storageNamespace) {
|
|
1267
|
+
params.set('releaseStorage', storageNamespace)
|
|
1268
|
+
} else {
|
|
1269
|
+
params.delete('releaseStorage')
|
|
1270
|
+
}
|
|
1227
1271
|
setSearchParams(params, { replace: true })
|
|
1228
1272
|
}}
|
|
1229
1273
|
/>
|
|
@@ -1305,6 +1349,7 @@ function AppInner() {
|
|
|
1305
1349
|
setSelectedHelmRelease(null)
|
|
1306
1350
|
const params = new URLSearchParams(window.location.search)
|
|
1307
1351
|
params.delete('release')
|
|
1352
|
+
params.delete('releaseStorage')
|
|
1308
1353
|
setSearchParams(params, { replace: true })
|
|
1309
1354
|
}}
|
|
1310
1355
|
onNavigateToResource={(resource) => {
|
package/src/api/client.ts
CHANGED
|
@@ -298,6 +298,7 @@ export function useAudit(namespaces: string[] = []) {
|
|
|
298
298
|
queryFn: () => fetchJSON(`/audit${params}`),
|
|
299
299
|
staleTime: 30000,
|
|
300
300
|
refetchInterval: 60000,
|
|
301
|
+
placeholderData: (prev) => prev,
|
|
301
302
|
})
|
|
302
303
|
}
|
|
303
304
|
|
|
@@ -1967,15 +1968,20 @@ function streamHelmProgress(
|
|
|
1967
1968
|
|
|
1968
1969
|
if (data.type === 'complete') {
|
|
1969
1970
|
resolve(data)
|
|
1971
|
+
return
|
|
1970
1972
|
} else if (data.type === 'error') {
|
|
1971
1973
|
reject(new Error(data.message || failureLabel))
|
|
1974
|
+
return
|
|
1972
1975
|
}
|
|
1973
|
-
} catch {
|
|
1974
|
-
|
|
1976
|
+
} catch (err) {
|
|
1977
|
+
reject(err instanceof Error ? err : new Error(`${failureLabel}: invalid progress event`))
|
|
1978
|
+
return
|
|
1975
1979
|
}
|
|
1976
1980
|
}
|
|
1977
1981
|
}
|
|
1978
1982
|
}
|
|
1983
|
+
|
|
1984
|
+
reject(new Error(`${failureLabel}: stream ended before completion`))
|
|
1979
1985
|
})
|
|
1980
1986
|
.catch(reject)
|
|
1981
1987
|
})
|
|
@@ -1986,10 +1992,13 @@ export function upgradeWithProgress(
|
|
|
1986
1992
|
namespace: string,
|
|
1987
1993
|
name: string,
|
|
1988
1994
|
version: string,
|
|
1995
|
+
repositoryName: string | undefined,
|
|
1989
1996
|
onProgress: (event: InstallProgressEvent) => void
|
|
1990
1997
|
): Promise<void> {
|
|
1998
|
+
const params = new URLSearchParams({ version })
|
|
1999
|
+
if (repositoryName) params.set('repository', repositoryName)
|
|
1991
2000
|
return streamHelmProgress(
|
|
1992
|
-
`${getApiBase()}/helm/releases/${namespace}/${name}/upgrade-stream
|
|
2001
|
+
`${getApiBase()}/helm/releases/${namespace}/${name}/upgrade-stream?${params.toString()}`,
|
|
1993
2002
|
{ method: 'POST' },
|
|
1994
2003
|
onProgress,
|
|
1995
2004
|
'Upgrade failed',
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useMemo, useState } from 'react'
|
|
1
|
+
import { useMemo, useState, forwardRef } from 'react'
|
|
2
2
|
import { AlertTriangle } from 'lucide-react'
|
|
3
3
|
import {
|
|
4
4
|
ClusterSwitcher,
|
|
@@ -16,11 +16,15 @@ interface ContextSwitcherProps {
|
|
|
16
16
|
className?: string
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
export interface ContextSwitcherHandle {
|
|
20
|
+
open: () => void
|
|
21
|
+
}
|
|
22
|
+
|
|
19
23
|
interface ParsedContext extends ParsedContextName {
|
|
20
24
|
context: ContextInfo
|
|
21
25
|
}
|
|
22
26
|
|
|
23
|
-
export
|
|
27
|
+
export const ContextSwitcher = forwardRef<ContextSwitcherHandle, ContextSwitcherProps>(({ className = '' }, ref) => {
|
|
24
28
|
const [showConfirm, setShowConfirm] = useState(false)
|
|
25
29
|
const [pendingSwitch, setPendingSwitch] = useState<ParsedContext | null>(null)
|
|
26
30
|
const [sessionCounts, setSessionCounts] = useState<SessionCounts | null>(null)
|
|
@@ -33,13 +37,37 @@ export function ContextSwitcher({ className = '' }: ContextSwitcherProps) {
|
|
|
33
37
|
const { tabs } = useDock()
|
|
34
38
|
|
|
35
39
|
// Parse contexts and decide whether to render group headers (multi-account only).
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
40
|
+
// hasMultipleSources gates the kubeconfig-source chip — only useful when 2+
|
|
41
|
+
// distinct kubeconfig files are in play. Single-source setups (the common
|
|
42
|
+
// case) skip the chip entirely so the dropdown stays clean.
|
|
43
|
+
const { parsedById, hasMultipleAccounts, hasMultipleSources } = useMemo(() => {
|
|
44
|
+
if (!contexts) return {
|
|
45
|
+
parsedById: new Map<string, ParsedContext>(),
|
|
46
|
+
hasMultipleAccounts: false,
|
|
47
|
+
hasMultipleSources: false,
|
|
48
|
+
}
|
|
49
|
+
// Strip the disambiguation suffix (" (<source>)" or " (<source> #N)")
|
|
50
|
+
// before parsing — qualified names won't match the GKE/EKS/AKS regexes
|
|
51
|
+
// otherwise, and the suffix is redundant with the source chip we
|
|
52
|
+
// render separately.
|
|
53
|
+
const stripSourceSuffix = (name: string, source?: string): string => {
|
|
54
|
+
if (!source) return name
|
|
55
|
+
const escaped = source.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
56
|
+
return name.replace(new RegExp(`\\s+\\(${escaped}(?:\\s+#\\d+)?\\)$`), '')
|
|
57
|
+
}
|
|
58
|
+
const parsed: ParsedContext[] = contexts.map(ctx => ({
|
|
59
|
+
context: ctx,
|
|
60
|
+
...parseContextName(stripSourceSuffix(ctx.name, ctx.source)),
|
|
61
|
+
}))
|
|
39
62
|
const accounts = new Set(parsed.map(p => `${p.provider}:${p.account}`))
|
|
63
|
+
const sources = new Set(contexts.map(c => c.source).filter(Boolean))
|
|
40
64
|
const byId = new Map<string, ParsedContext>()
|
|
41
65
|
for (const p of parsed) byId.set(p.context.name, p)
|
|
42
|
-
return {
|
|
66
|
+
return {
|
|
67
|
+
parsedById: byId,
|
|
68
|
+
hasMultipleAccounts: accounts.size > 1,
|
|
69
|
+
hasMultipleSources: sources.size > 1,
|
|
70
|
+
}
|
|
43
71
|
}, [contexts])
|
|
44
72
|
|
|
45
73
|
// Map parsed contexts → generic ClusterSwitcher items, sorted GKE/EKS/AKS/Other → account → name.
|
|
@@ -61,20 +89,16 @@ export function ContextSwitcher({ className = '' }: ContextSwitcherProps) {
|
|
|
61
89
|
: hasMultipleAccounts
|
|
62
90
|
? 'Other'
|
|
63
91
|
: undefined
|
|
64
|
-
// `name` is the raw context — ClusterSwitcher renders it through
|
|
65
|
-
// ClusterName, which collapses GKE/EKS/AKS shapes to the meaningful
|
|
66
|
-
// tail. `secondary` shows the original raw when we collapsed it,
|
|
67
|
-
// so users always see the full context at a glance (rather than
|
|
68
|
-
// having to hover to reveal it).
|
|
69
92
|
return {
|
|
70
93
|
id: p.context.name,
|
|
71
|
-
name: p.
|
|
94
|
+
name: p.raw,
|
|
72
95
|
secondary: p.provider ? p.raw : undefined,
|
|
73
96
|
badge: p.region || undefined,
|
|
97
|
+
sourceLabel: hasMultipleSources ? p.context.source : undefined,
|
|
74
98
|
group: { key: groupKey, label: groupLabel },
|
|
75
99
|
}
|
|
76
100
|
})
|
|
77
|
-
}, [parsedById, hasMultipleAccounts])
|
|
101
|
+
}, [parsedById, hasMultipleAccounts, hasMultipleSources])
|
|
78
102
|
|
|
79
103
|
const performSwitch = async (parsed: ParsedContext) => {
|
|
80
104
|
startSwitch({
|
|
@@ -152,15 +176,24 @@ export function ContextSwitcher({ className = '' }: ContextSwitcherProps) {
|
|
|
152
176
|
)
|
|
153
177
|
}
|
|
154
178
|
|
|
155
|
-
const
|
|
156
|
-
const currentId =
|
|
179
|
+
const currentCtx = contexts?.find(c => c.isCurrent)
|
|
180
|
+
const currentId = currentCtx?.name
|
|
181
|
+
// Use parsed.raw (the source-stripped form) for the trigger so the
|
|
182
|
+
// disambiguation suffix doesn't double up with the source chip.
|
|
183
|
+
// Fall back to clusterInfo.context for the very-early window before
|
|
184
|
+
// /api/contexts has resolved.
|
|
185
|
+
const currentParsed = currentId ? parsedById.get(currentId) : undefined
|
|
186
|
+
const currentRaw = currentParsed?.raw || clusterInfo?.context || currentCtx?.name || 'Unknown'
|
|
187
|
+
const currentSourceLabel = hasMultipleSources ? currentCtx?.source || undefined : undefined
|
|
157
188
|
|
|
158
189
|
return (
|
|
159
190
|
<>
|
|
160
191
|
<ClusterSwitcher
|
|
192
|
+
ref={ref}
|
|
161
193
|
className={className}
|
|
162
194
|
currentId={currentId}
|
|
163
195
|
currentName={currentRaw}
|
|
196
|
+
currentSourceLabel={currentSourceLabel}
|
|
164
197
|
items={items}
|
|
165
198
|
onSelect={handleSelect}
|
|
166
199
|
loading={switchContext.isPending}
|
|
@@ -222,4 +255,4 @@ export function ContextSwitcher({ className = '' }: ContextSwitcherProps) {
|
|
|
222
255
|
)}
|
|
223
256
|
</>
|
|
224
257
|
)
|
|
225
|
-
}
|
|
258
|
+
})
|
|
@@ -56,16 +56,17 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
56
56
|
// transient error state under the role-gated panel.
|
|
57
57
|
const { canAtLeast } = useCloudRole()
|
|
58
58
|
const canViewSensitive = canAtLeast('member')
|
|
59
|
+
const helmNamespace = release.storageNamespace || release.namespace
|
|
59
60
|
|
|
60
61
|
const { data: releaseDetail, isLoading, refetch: refetchRelease } = useHelmRelease(
|
|
61
|
-
|
|
62
|
+
helmNamespace,
|
|
62
63
|
release.name
|
|
63
64
|
)
|
|
64
65
|
const [refetch, isRefreshAnimating] = useRefreshAnimation(refetchRelease)
|
|
65
66
|
|
|
66
67
|
// Fetch manifest for selected revision (or latest)
|
|
67
68
|
const { data: manifest, isLoading: manifestLoading } = useHelmManifest(
|
|
68
|
-
|
|
69
|
+
helmNamespace,
|
|
69
70
|
release.name,
|
|
70
71
|
selectedRevision,
|
|
71
72
|
canViewSensitive,
|
|
@@ -73,7 +74,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
73
74
|
|
|
74
75
|
// Fetch values
|
|
75
76
|
const { data: values, isLoading: valuesLoading } = useHelmValues(
|
|
76
|
-
|
|
77
|
+
helmNamespace,
|
|
77
78
|
release.name,
|
|
78
79
|
showAllValues,
|
|
79
80
|
canViewSensitive,
|
|
@@ -81,7 +82,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
81
82
|
|
|
82
83
|
// Fetch diff if comparing revisions
|
|
83
84
|
const { data: diffData, isLoading: diffLoading } = useHelmManifestDiff(
|
|
84
|
-
|
|
85
|
+
helmNamespace,
|
|
85
86
|
release.name,
|
|
86
87
|
diffRevisions?.rev1 || 0,
|
|
87
88
|
diffRevisions?.rev2 || 0,
|
|
@@ -89,10 +90,11 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
89
90
|
)
|
|
90
91
|
|
|
91
92
|
// Lazy check for upgrade availability
|
|
92
|
-
const { data: upgradeInfo, isLoading: upgradeLoading } = useHelmUpgradeInfo(
|
|
93
|
-
|
|
93
|
+
const { data: upgradeInfo, isLoading: upgradeLoading, error: upgradeError } = useHelmUpgradeInfo(
|
|
94
|
+
helmNamespace,
|
|
94
95
|
release.name
|
|
95
96
|
)
|
|
97
|
+
const upgradeErrorMessage = upgradeError instanceof Error ? upgradeError.message : 'Upgrade check failed'
|
|
96
98
|
|
|
97
99
|
// Mutations for actions
|
|
98
100
|
const uninstallMutation = useHelmUninstall()
|
|
@@ -176,7 +178,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
176
178
|
|
|
177
179
|
try {
|
|
178
180
|
await rollbackWithProgress(
|
|
179
|
-
|
|
181
|
+
helmNamespace,
|
|
180
182
|
release.name,
|
|
181
183
|
rollbackRevision,
|
|
182
184
|
(event) => {
|
|
@@ -195,7 +197,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
195
197
|
}])
|
|
196
198
|
|
|
197
199
|
queryClient.invalidateQueries({ queryKey: ['helm-releases'] })
|
|
198
|
-
queryClient.invalidateQueries({ queryKey: ['helm-release',
|
|
200
|
+
queryClient.invalidateQueries({ queryKey: ['helm-release', helmNamespace, release.name] })
|
|
199
201
|
|
|
200
202
|
setTimeout(() => {
|
|
201
203
|
setRollbackRevision(null)
|
|
@@ -215,7 +217,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
215
217
|
|
|
216
218
|
const handleUninstallConfirm = () => {
|
|
217
219
|
uninstallMutation.mutate(
|
|
218
|
-
{ namespace:
|
|
220
|
+
{ namespace: helmNamespace, name: release.name },
|
|
219
221
|
{
|
|
220
222
|
onSuccess: () => {
|
|
221
223
|
setShowUninstallConfirm(false)
|
|
@@ -235,9 +237,10 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
235
237
|
|
|
236
238
|
try {
|
|
237
239
|
await upgradeWithProgress(
|
|
238
|
-
|
|
240
|
+
helmNamespace,
|
|
239
241
|
release.name,
|
|
240
242
|
upgradeInfo.latestVersion,
|
|
243
|
+
upgradeInfo.repositoryName,
|
|
241
244
|
(event) => {
|
|
242
245
|
if (event.type === 'progress' && event.message) {
|
|
243
246
|
setUpgradeProgress(prev => [...prev, {
|
|
@@ -255,8 +258,8 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
255
258
|
|
|
256
259
|
// Invalidate queries
|
|
257
260
|
queryClient.invalidateQueries({ queryKey: ['helm-releases'] })
|
|
258
|
-
queryClient.invalidateQueries({ queryKey: ['helm-release',
|
|
259
|
-
queryClient.invalidateQueries({ queryKey: ['helm-upgrade-info',
|
|
261
|
+
queryClient.invalidateQueries({ queryKey: ['helm-release', helmNamespace, release.name] })
|
|
262
|
+
queryClient.invalidateQueries({ queryKey: ['helm-upgrade-info', helmNamespace, release.name] })
|
|
260
263
|
queryClient.invalidateQueries({ queryKey: ['helm-batch-upgrade-info'] })
|
|
261
264
|
|
|
262
265
|
setTimeout(() => {
|
|
@@ -327,6 +330,13 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
327
330
|
<span className="badge bg-theme-hover/50 text-theme-text-secondary animate-pulse">
|
|
328
331
|
checking...
|
|
329
332
|
</span>
|
|
333
|
+
) : upgradeError ? (
|
|
334
|
+
<span
|
|
335
|
+
className="badge bg-theme-hover/50 text-theme-text-secondary"
|
|
336
|
+
title={upgradeErrorMessage}
|
|
337
|
+
>
|
|
338
|
+
upgrade check failed
|
|
339
|
+
</span>
|
|
330
340
|
) : upgradeInfo?.updateAvailable ? (
|
|
331
341
|
<button
|
|
332
342
|
onClick={() => setShowUpgradeConfirm(true)}
|
|
@@ -344,6 +354,13 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
344
354
|
<span className={clsx('badge', SEVERITY_BADGE.success)} title="Chart is up to date">
|
|
345
355
|
latest
|
|
346
356
|
</span>
|
|
357
|
+
) : upgradeInfo?.error ? (
|
|
358
|
+
<span
|
|
359
|
+
className="badge bg-theme-hover/50 text-theme-text-secondary"
|
|
360
|
+
title={upgradeInfo.error}
|
|
361
|
+
>
|
|
362
|
+
upstream unknown
|
|
363
|
+
</span>
|
|
347
364
|
) : null}
|
|
348
365
|
</div>
|
|
349
366
|
<div className="flex items-center gap-1">
|
|
@@ -450,7 +467,7 @@ export function HelmReleaseDrawer({ release, onClose, onNavigateToResource, isOp
|
|
|
450
467
|
onToggleAllValues={setShowAllValues}
|
|
451
468
|
onCopy={(text) => copyToClipboard(text, 'values')}
|
|
452
469
|
copied={copied === 'values'}
|
|
453
|
-
namespace={
|
|
470
|
+
namespace={helmNamespace}
|
|
454
471
|
name={release.name}
|
|
455
472
|
onApplySuccess={() => refetch()}
|
|
456
473
|
/>
|
|
@@ -17,7 +17,7 @@ type ViewTab = 'releases' | 'charts'
|
|
|
17
17
|
interface HelmViewProps {
|
|
18
18
|
namespace: string
|
|
19
19
|
selectedRelease?: SelectedHelmRelease | null
|
|
20
|
-
onReleaseClick?: (namespace: string, name: string) => void
|
|
20
|
+
onReleaseClick?: (namespace: string, name: string, storageNamespace?: string) => void
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
export function HelmView({ namespace, selectedRelease, onReleaseClick }: HelmViewProps) {
|
|
@@ -27,12 +27,14 @@ export function HelmView({ namespace, selectedRelease, onReleaseClick }: HelmVie
|
|
|
27
27
|
|
|
28
28
|
const { data: releases, isLoading, error: releasesError, refetch: refetchReleases } = useHelmReleases(namespace || undefined)
|
|
29
29
|
const isForbidden = isForbiddenError(releasesError)
|
|
30
|
+
const releasesErrorMessage = releasesError instanceof Error ? releasesError.message : 'Failed to load Helm releases'
|
|
30
31
|
|
|
31
32
|
// Lazy load upgrade info after releases are loaded
|
|
32
|
-
const { data: upgradeInfo, isLoading: upgradeLoading, refetch: refetchUpgradeInfo } = useHelmBatchUpgradeInfo(
|
|
33
|
+
const { data: upgradeInfo, isLoading: upgradeLoading, error: upgradeError, refetch: refetchUpgradeInfo } = useHelmBatchUpgradeInfo(
|
|
33
34
|
namespace || undefined,
|
|
34
35
|
Boolean(releases && releases.length > 0)
|
|
35
36
|
)
|
|
37
|
+
const upgradeErrorMessage = upgradeError instanceof Error ? upgradeError.message : 'Upgrade checks failed'
|
|
36
38
|
|
|
37
39
|
const [handleRefresh, isRefreshAnimating] = useRefreshAnimation(async () => {
|
|
38
40
|
await Promise.all([refetchReleases(), refetchUpgradeInfo()])
|
|
@@ -132,7 +134,7 @@ export function HelmView({ namespace, selectedRelease, onReleaseClick }: HelmVie
|
|
|
132
134
|
scope: 'helm',
|
|
133
135
|
handler: () => {
|
|
134
136
|
const release = getHighlightedRelease()
|
|
135
|
-
if (release) onReleaseClick?.(release.namespace, release.name)
|
|
137
|
+
if (release) onReleaseClick?.(release.namespace, release.name, release.storageNamespace)
|
|
136
138
|
},
|
|
137
139
|
enabled: highlightedIndex >= 0,
|
|
138
140
|
},
|
|
@@ -232,6 +234,11 @@ export function HelmView({ namespace, selectedRelease, onReleaseClick }: HelmVie
|
|
|
232
234
|
|
|
233
235
|
{/* Releases Table */}
|
|
234
236
|
<div className="flex-1 overflow-auto">
|
|
237
|
+
{upgradeError && (
|
|
238
|
+
<div className="m-4 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm text-amber-300">
|
|
239
|
+
Upgrade checks failed: {upgradeErrorMessage}
|
|
240
|
+
</div>
|
|
241
|
+
)}
|
|
235
242
|
{isLoading ? (
|
|
236
243
|
<PaneLoader className="h-full" />
|
|
237
244
|
) : isForbidden ? (
|
|
@@ -240,6 +247,20 @@ export function HelmView({ namespace, selectedRelease, onReleaseClick }: HelmVie
|
|
|
240
247
|
<p className="text-theme-text-secondary font-medium">Access Restricted</p>
|
|
241
248
|
<p className="text-sm mt-1">Insufficient permissions to list Helm releases</p>
|
|
242
249
|
</div>
|
|
250
|
+
) : releasesError ? (
|
|
251
|
+
<div className="flex flex-col items-center justify-center h-full text-theme-text-tertiary gap-3 px-6 text-center">
|
|
252
|
+
<Package className="w-10 h-10 text-amber-400" />
|
|
253
|
+
<div>
|
|
254
|
+
<p className="text-theme-text-secondary font-medium">Failed to load Helm releases</p>
|
|
255
|
+
<p className="text-sm mt-1 break-all">{releasesErrorMessage}</p>
|
|
256
|
+
</div>
|
|
257
|
+
<button
|
|
258
|
+
onClick={() => refetchReleases()}
|
|
259
|
+
className="px-3 py-1.5 text-sm text-theme-text-primary border border-theme-border rounded-lg hover:bg-theme-elevated transition-colors"
|
|
260
|
+
>
|
|
261
|
+
Retry
|
|
262
|
+
</button>
|
|
263
|
+
</div>
|
|
243
264
|
) : filteredReleases.length === 0 ? (
|
|
244
265
|
<div className="flex flex-col items-center justify-center h-full text-theme-text-tertiary gap-2">
|
|
245
266
|
<Package className="w-12 h-12 text-theme-text-disabled" />
|
|
@@ -291,16 +312,17 @@ export function HelmView({ namespace, selectedRelease, onReleaseClick }: HelmVie
|
|
|
291
312
|
<tbody className="table-divide-subtle">
|
|
292
313
|
{filteredReleases.map((release, index) => (
|
|
293
314
|
<ReleaseRow
|
|
294
|
-
key={
|
|
315
|
+
key={releaseIdentityKey(release)}
|
|
295
316
|
ref={index === highlightedIndex ? highlightedRowRef : null}
|
|
296
317
|
release={release}
|
|
297
|
-
upgradeInfo={upgradeInfo?.releases[
|
|
318
|
+
upgradeInfo={upgradeInfo?.releases[releaseIdentityKey(release)]}
|
|
298
319
|
isSelected={
|
|
299
320
|
selectedRelease?.namespace === release.namespace &&
|
|
300
|
-
selectedRelease?.name === release.name
|
|
321
|
+
selectedRelease?.name === release.name &&
|
|
322
|
+
(selectedRelease?.storageNamespace || selectedRelease?.namespace) === (release.storageNamespace || release.namespace)
|
|
301
323
|
}
|
|
302
324
|
isHighlighted={index === highlightedIndex}
|
|
303
|
-
onClick={() => onReleaseClick?.(release.namespace, release.name)}
|
|
325
|
+
onClick={() => onReleaseClick?.(release.namespace, release.name, release.storageNamespace)}
|
|
304
326
|
onMouseEnter={() => setHighlightedIndex(-1)}
|
|
305
327
|
/>
|
|
306
328
|
))}
|
|
@@ -329,6 +351,10 @@ export function HelmView({ namespace, selectedRelease, onReleaseClick }: HelmVie
|
|
|
329
351
|
)
|
|
330
352
|
}
|
|
331
353
|
|
|
354
|
+
function releaseIdentityKey(release: Pick<HelmRelease, 'namespace' | 'name' | 'storageNamespace'>): string {
|
|
355
|
+
return `${release.storageNamespace || release.namespace}/${release.name}`
|
|
356
|
+
}
|
|
357
|
+
|
|
332
358
|
interface ReleaseRowProps {
|
|
333
359
|
release: HelmRelease
|
|
334
360
|
upgradeInfo?: UpgradeInfo
|
|
@@ -139,10 +139,25 @@ export function PortForwardProvider({ children }: { children: ReactNode }) {
|
|
|
139
139
|
const measureAnchor = useCallback(() => {
|
|
140
140
|
if (!indicatorRef.current) return
|
|
141
141
|
const rect = indicatorRef.current.getBoundingClientRect()
|
|
142
|
+
// Align panel right edge with indicator right edge, but clamp so the
|
|
143
|
+
// panel's left edge never runs off the viewport's left edge — happens on
|
|
144
|
+
// narrow windows / split-screens where the indicator is closer to the
|
|
145
|
+
// left edge than the panel is wide. PANEL_WIDTH must match the panel's
|
|
146
|
+
// Tailwind w-80 (20rem = 320px).
|
|
147
|
+
const PANEL_WIDTH = 320
|
|
148
|
+
const MARGIN = 8
|
|
149
|
+
const desiredRight = Math.max(MARGIN, window.innerWidth - rect.right)
|
|
150
|
+
const maxRight = Math.max(MARGIN, window.innerWidth - PANEL_WIDTH - MARGIN)
|
|
151
|
+
const right = Math.min(desiredRight, maxRight)
|
|
152
|
+
// Keep the caret pointing at the indicator's horizontal center even after
|
|
153
|
+
// the panel has been clamped away from the indicator.
|
|
154
|
+
const panelRightX = window.innerWidth - right
|
|
155
|
+
const indicatorCenterX = rect.right - rect.width / 2
|
|
156
|
+
const caretRight = panelRightX - indicatorCenterX - 6
|
|
142
157
|
setAnchor({
|
|
143
158
|
top: rect.bottom + 10,
|
|
144
|
-
right
|
|
145
|
-
caretRight
|
|
159
|
+
right,
|
|
160
|
+
caretRight,
|
|
146
161
|
})
|
|
147
162
|
}, [])
|
|
148
163
|
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
} from '@skyhook-io/k8s-ui'
|
|
14
14
|
import type { ResourceQueryResult } from '@skyhook-io/k8s-ui'
|
|
15
15
|
import type { SelectedResource } from '../../types'
|
|
16
|
-
import type
|
|
16
|
+
import { kindToPlural, type NavigateToResource } from '../../utils/navigation'
|
|
17
17
|
import { CreateResourceDialog } from '../shared/CreateResourceDialog'
|
|
18
18
|
import { getSkeletonYaml } from '../../utils/skeleton-yaml'
|
|
19
19
|
|
|
@@ -204,7 +204,7 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
|
|
|
204
204
|
initialYaml={createDialogYaml}
|
|
205
205
|
title={createDialogTitle}
|
|
206
206
|
onCreated={(result) => {
|
|
207
|
-
onResourceClick?.({ kind: result.kind, namespace: result.namespace, name: result.name, group: '' })
|
|
207
|
+
onResourceClick?.({ kind: kindToPlural(result.kind), namespace: result.namespace, name: result.name, group: '' })
|
|
208
208
|
}}
|
|
209
209
|
/>
|
|
210
210
|
</>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
|
1
|
+
import { useState, useRef, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react'
|
|
2
2
|
import { createPortal } from 'react-dom'
|
|
3
3
|
import { clsx } from 'clsx'
|
|
4
4
|
import { ChevronDown, Search, X, Check, Shield } from 'lucide-react'
|
|
@@ -9,6 +9,10 @@ interface Namespace {
|
|
|
9
9
|
name: string
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
export interface NamespaceSelectorHandle {
|
|
13
|
+
open: () => void
|
|
14
|
+
}
|
|
15
|
+
|
|
12
16
|
interface NamespaceSelectorProps {
|
|
13
17
|
value: string[]
|
|
14
18
|
onChange: (value: string[]) => void
|
|
@@ -19,7 +23,7 @@ interface NamespaceSelectorProps {
|
|
|
19
23
|
disabledTooltip?: string
|
|
20
24
|
}
|
|
21
25
|
|
|
22
|
-
export
|
|
26
|
+
export const NamespaceSelector = forwardRef<NamespaceSelectorHandle, NamespaceSelectorProps>(({
|
|
23
27
|
value,
|
|
24
28
|
onChange,
|
|
25
29
|
namespaces,
|
|
@@ -27,7 +31,7 @@ export function NamespaceSelector({
|
|
|
27
31
|
className,
|
|
28
32
|
disabled,
|
|
29
33
|
disabledTooltip,
|
|
30
|
-
}
|
|
34
|
+
}, ref) => {
|
|
31
35
|
const [isOpen, setIsOpen] = useState(false)
|
|
32
36
|
const [search, setSearch] = useState('')
|
|
33
37
|
const [manualInput, setManualInput] = useState('')
|
|
@@ -73,11 +77,17 @@ export function NamespaceSelector({
|
|
|
73
77
|
|
|
74
78
|
// Open dropdown
|
|
75
79
|
const openDropdown = useCallback(() => {
|
|
80
|
+
if (disabled) return
|
|
76
81
|
setIsOpen(true)
|
|
77
82
|
setSearch('')
|
|
78
83
|
setHighlightedIndex(0)
|
|
79
84
|
updatePosition()
|
|
80
|
-
}, [updatePosition])
|
|
85
|
+
}, [disabled, updatePosition])
|
|
86
|
+
|
|
87
|
+
// Expose open method via ref
|
|
88
|
+
useImperativeHandle(ref, () => ({
|
|
89
|
+
open: openDropdown
|
|
90
|
+
}), [openDropdown])
|
|
81
91
|
|
|
82
92
|
// Close dropdown
|
|
83
93
|
const closeDropdown = useCallback(() => {
|
|
@@ -433,4 +443,4 @@ export function NamespaceSelector({
|
|
|
433
443
|
)}
|
|
434
444
|
</>
|
|
435
445
|
)
|
|
436
|
-
}
|
|
446
|
+
})
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
type RendererOverrides,
|
|
9
9
|
} from '@skyhook-io/k8s-ui'
|
|
10
10
|
import type { SelectedResource, ResourceRef, ResolvedEnvFrom } from '../../types'
|
|
11
|
-
import type
|
|
11
|
+
import { kindToPlural, type NavigateToResource } from '../../utils/navigation'
|
|
12
12
|
import {
|
|
13
13
|
useChanges, useResourceWithRelationships, usePodLogs, useTopology, useUpdateResource,
|
|
14
14
|
useDeleteResource, useTriggerCronJob, useSuspendCronJob, useResumeCronJob,
|
|
@@ -383,7 +383,7 @@ export function WorkloadView({
|
|
|
383
383
|
initialYaml={duplicateYaml}
|
|
384
384
|
title="Duplicate Resource"
|
|
385
385
|
onCreated={(result) => {
|
|
386
|
-
rest.onNavigateToResource?.({ kind: result.kind, namespace: result.namespace, name: result.name, group: '' })
|
|
386
|
+
rest.onNavigateToResource?.({ kind: kindToPlural(result.kind), namespace: result.namespace, name: result.name, group: '' })
|
|
387
387
|
}}
|
|
388
388
|
/>
|
|
389
389
|
</>
|