@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/radar-app",
3
- "version": "1.0.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.3"
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.6",
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.12",
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.14.2",
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 switching workload kind in URL (/resources/pods → /resources/deployments).
314
- // Keeps stale Pod drawer from masking the table after sidebar navigation (Radar Hub / app.radarhq.io).
315
- const prevResourcesKindSlugRef = useRef<string | null>(null)
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
- prevResourcesKindSlugRef.current = null
335
+ prevResourcesKindKeyRef.current = null
319
336
  return
320
337
  }
321
- const slug = normalizedResourcesKindSlug
322
- const prev = prevResourcesKindSlugRef.current
323
- prevResourcesKindSlugRef.current = slug
324
- if (prev !== null && prev !== slug) {
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, normalizedResourcesKindSlug])
346
+ }, [mainView, currentResourceKindSlug, currentResourceGroup, selectedResourceRouteMismatch])
329
347
 
330
348
  // Animation hooks for smooth mount/unmount transitions
331
- const resourceDrawer = useAnimatedUnmount(!!selectedResource, 300)
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(selectedResource)
339
- if (selectedResource) lastResourceRef.current = selectedResource
340
- const drawerResource = selectedResource || lastResourceRef.current
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={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
- // Ignore parse errors
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?version=${encodeURIComponent(version)}`,
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 function ContextSwitcher({ className = '' }: ContextSwitcherProps) {
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
- const { parsedById, hasMultipleAccounts } = useMemo(() => {
37
- if (!contexts) return { parsedById: new Map<string, ParsedContext>(), hasMultipleAccounts: false }
38
- const parsed: ParsedContext[] = contexts.map(ctx => ({ context: ctx, ...parseContextName(ctx.name) }))
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 { parsedById: byId, hasMultipleAccounts: accounts.size > 1 }
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.context.name,
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 currentRaw = clusterInfo?.context || contexts?.find(c => c.isCurrent)?.name || 'Unknown'
156
- const currentId = contexts?.find(c => c.isCurrent)?.name
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
- release.namespace,
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
- release.namespace,
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
- release.namespace,
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
- release.namespace,
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
- release.namespace,
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
- release.namespace,
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', release.namespace, release.name] })
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: release.namespace, name: release.name },
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
- release.namespace,
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', release.namespace, release.name] })
259
- queryClient.invalidateQueries({ queryKey: ['helm-upgrade-info', release.namespace, release.name] })
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={release.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={`${release.namespace}-${release.name}`}
315
+ key={releaseIdentityKey(release)}
295
316
  ref={index === highlightedIndex ? highlightedRowRef : null}
296
317
  release={release}
297
- upgradeInfo={upgradeInfo?.releases[`${release.namespace}/${release.name}`]}
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: Math.max(16, window.innerWidth - rect.right),
145
- caretRight: rect.width / 2 - 6,
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 { NavigateToResource } from '../../utils/navigation'
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 function NamespaceSelector({
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
- }: NamespaceSelectorProps) {
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 { NavigateToResource } from '../../utils/navigation'
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
  </>