@skyhook-io/k8s-ui 1.4.1 → 1.4.3

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.
Files changed (47) hide show
  1. package/package.json +1 -1
  2. package/src/components/resources/ResourcesSidebar.tsx +152 -57
  3. package/src/components/resources/ResourcesView.tsx +296 -14
  4. package/src/components/resources/renderers/AWSMachineRenderer.tsx +96 -0
  5. package/src/components/resources/renderers/AWSMachineTemplateRenderer.tsx +39 -0
  6. package/src/components/resources/renderers/AWSManagedClusterRenderer.tsx +49 -0
  7. package/src/components/resources/renderers/AWSManagedControlPlaneRenderer.tsx +174 -0
  8. package/src/components/resources/renderers/AWSManagedMachinePoolRenderer.tsx +89 -0
  9. package/src/components/resources/renderers/AzureMachineRenderer.tsx +41 -0
  10. package/src/components/resources/renderers/AzureManagedControlPlaneRenderer.tsx +71 -0
  11. package/src/components/resources/renderers/AzureManagedMachinePoolRenderer.tsx +106 -0
  12. package/src/components/resources/renderers/CAPIClusterClassRenderer.tsx +156 -0
  13. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +240 -0
  14. package/src/components/resources/renderers/CAPIKubeadmConfigRenderer.tsx +97 -0
  15. package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +124 -0
  16. package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +128 -0
  17. package/src/components/resources/renderers/CAPIMachineDrainRuleRenderer.tsx +38 -0
  18. package/src/components/resources/renderers/CAPIMachineHealthCheckRenderer.tsx +146 -0
  19. package/src/components/resources/renderers/CAPIMachinePoolRenderer.tsx +92 -0
  20. package/src/components/resources/renderers/CAPIMachineRenderer.tsx +170 -0
  21. package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +101 -0
  22. package/src/components/resources/renderers/GCPMachineRenderer.tsx +53 -0
  23. package/src/components/resources/renderers/GCPManagedControlPlaneRenderer.tsx +75 -0
  24. package/src/components/resources/renderers/GCPManagedMachinePoolRenderer.tsx +102 -0
  25. package/src/components/resources/renderers/aws-capi-cells.tsx +117 -0
  26. package/src/components/resources/renderers/azure-capi-cells.tsx +83 -0
  27. package/src/components/resources/renderers/capi-cells.tsx +158 -0
  28. package/src/components/resources/renderers/gcp-capi-cells.tsx +68 -0
  29. package/src/components/resources/renderers/index.ts +25 -0
  30. package/src/components/resources/resource-utils-aws-capi.ts +211 -0
  31. package/src/components/resources/resource-utils-azure-capi.ts +123 -0
  32. package/src/components/resources/resource-utils-capi.ts +305 -0
  33. package/src/components/resources/resource-utils-gcp-capi.ts +130 -0
  34. package/src/components/shared/EditableYamlView.tsx +38 -3
  35. package/src/components/shared/ResourceRendererDispatch.tsx +97 -2
  36. package/src/components/topology/K8sResourceNode.tsx +8 -0
  37. package/src/components/topology/TopologyControls.tsx +16 -0
  38. package/src/components/topology/TopologyFilterSidebar.tsx +15 -0
  39. package/src/components/topology/TopologyGraph.tsx +2 -1
  40. package/src/components/topology/layout.ts +11 -0
  41. package/src/components/topology/topology.css +29 -0
  42. package/src/components/ui/Badge.tsx +10 -0
  43. package/src/components/ui/Tooltip.tsx +16 -0
  44. package/src/components/ui/drawer-components.tsx +54 -17
  45. package/src/types/core.ts +18 -2
  46. package/src/utils/api-resources.ts +4 -0
  47. package/src/utils/resource-icons.ts +35 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.4.1",
3
+ "version": "1.4.3",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -1,4 +1,4 @@
1
- import { useState, useMemo, useEffect, useRef, forwardRef } from 'react'
1
+ import { useState, useMemo, useEffect, useRef, useCallback, forwardRef } from 'react'
2
2
  import {
3
3
  Search,
4
4
  ChevronDown,
@@ -44,6 +44,9 @@ export interface ResourcesSidebarProps {
44
44
  onNavigate?: (path: string) => void
45
45
  /** Base path for generating navigation URLs (e.g., '/org/clusters/id/k8s-resources') */
46
46
  basePath?: string
47
+ /** Called when a kind is selected via keyboard (Enter in the filter). Parent uses this
48
+ * to move focus to the next UI level (e.g., the table search input). */
49
+ onKindNavigated?: () => void
47
50
  }
48
51
 
49
52
  // Persisted across remounts so collapsed categories survive tab switches
@@ -93,6 +96,8 @@ interface ResourceTypeButtonProps {
93
96
  resource: APIResource
94
97
  count: number
95
98
  isSelected: boolean
99
+ /** Keyboard-highlight state (arrow nav in the filter input). */
100
+ isHighlighted?: boolean
96
101
  isForbidden?: boolean
97
102
  isPinned?: boolean
98
103
  onTogglePin?: () => void
@@ -100,7 +105,7 @@ interface ResourceTypeButtonProps {
100
105
  }
101
106
 
102
107
  const ResourceTypeButton = forwardRef<HTMLButtonElement, ResourceTypeButtonProps>(
103
- function ResourceTypeButton({ resource, count, isSelected, isForbidden: forbidden, isPinned, onTogglePin, onClick }, ref) {
108
+ function ResourceTypeButton({ resource, count, isSelected, isHighlighted, isForbidden: forbidden, isPinned, onTogglePin, onClick }, ref) {
104
109
  const Icon = getResourceIcon(resource.kind)
105
110
  return (
106
111
  <button
@@ -110,9 +115,11 @@ const ResourceTypeButton = forwardRef<HTMLButtonElement, ResourceTypeButtonProps
110
115
  'w-full flex items-center gap-2 px-2 xl:px-3 py-1.5 rounded-lg text-sm transition-colors group/kind min-w-0',
111
116
  isSelected
112
117
  ? 'selection-strong selection-text'
113
- : forbidden
114
- ? 'text-theme-text-disabled hover:bg-theme-elevated hover:text-theme-text-secondary'
115
- : 'text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary'
118
+ : isHighlighted
119
+ ? 'bg-theme-hover text-theme-text-primary'
120
+ : forbidden
121
+ ? 'text-theme-text-disabled hover:bg-theme-elevated hover:text-theme-text-secondary'
122
+ : 'text-theme-text-secondary hover:bg-theme-elevated hover:text-theme-text-primary'
116
123
  )}
117
124
  >
118
125
  <Icon className="w-4 h-4 shrink-0" />
@@ -172,6 +179,7 @@ export function ResourcesSidebar({
172
179
  className,
173
180
  onNavigate,
174
181
  basePath,
182
+ onKindNavigated,
175
183
  }: ResourcesSidebarProps) {
176
184
  // Wraps kind selection to also navigate when basePath/onNavigate are provided
177
185
  const selectKind = (kind: SelectedKindInfo) => {
@@ -195,6 +203,15 @@ export function ResourcesSidebar({
195
203
  // Ref to selected sidebar item for scrolling into view on deeplink
196
204
  const selectedSidebarRef = useRef<HTMLButtonElement>(null)
197
205
 
206
+ // Ref to kind search input — auto-focused on mount so users can type a kind name immediately
207
+ const kindSearchRef = useRef<HTMLInputElement>(null)
208
+ useEffect(() => {
209
+ // Only focus on fine-pointer devices to avoid popping up the virtual keyboard on touch
210
+ if (typeof window !== 'undefined' && window.matchMedia('(pointer: fine)').matches) {
211
+ kindSearchRef.current?.focus()
212
+ }
213
+ }, [])
214
+
198
215
  // Effective selected kind — fall back to a safe default
199
216
  const effectiveSelectedKind = selectedKind ?? { name: 'pods', kind: 'Pod', group: '' }
200
217
 
@@ -335,6 +352,69 @@ export function ResourcesSidebar({
335
352
  })
336
353
  }
337
354
 
355
+ // --- Keyboard navigation ---
356
+ // Flat list of all navigable kinds in the order they appear in the sidebar.
357
+ const flatVisibleKinds = useMemo<SelectedKindInfo[]>(() => {
358
+ const kinds: SelectedKindInfo[] = []
359
+ if (favoritesExpanded) {
360
+ for (const p of pinned) {
361
+ kinds.push({ name: p.name, kind: p.kind, group: p.group })
362
+ }
363
+ }
364
+ if (filteredCategories) {
365
+ for (const cat of filteredCategories) {
366
+ if (effectiveExpandedCategories.has(cat.name)) {
367
+ for (const r of cat.visibleResources) {
368
+ kinds.push({ name: r.name, kind: r.kind, group: r.group })
369
+ }
370
+ }
371
+ }
372
+ }
373
+ return kinds
374
+ }, [favoritesExpanded, pinned, filteredCategories, effectiveExpandedCategories])
375
+
376
+ const [highlightedIndex, setHighlightedIndex] = useState(-1)
377
+ // Reset highlight when the filter or kind list changes
378
+ useEffect(() => {
379
+ setHighlightedIndex(kindFilter ? 0 : -1)
380
+ }, [kindFilter]) // eslint-disable-line react-hooks/exhaustive-deps
381
+
382
+ const highlightedKind = highlightedIndex >= 0 && highlightedIndex < flatVisibleKinds.length
383
+ ? flatVisibleKinds[highlightedIndex]
384
+ : null
385
+
386
+ // Scroll the highlighted kind button into view
387
+ const highlightedRef = useRef<HTMLButtonElement>(null)
388
+ useEffect(() => {
389
+ if (highlightedRef.current) {
390
+ highlightedRef.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
391
+ }
392
+ }, [highlightedIndex])
393
+
394
+ const handleSearchKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
395
+ if (e.key === 'Escape') {
396
+ setKindFilter('')
397
+ setHighlightedIndex(-1)
398
+ ;(e.target as HTMLInputElement).blur()
399
+ } else if (e.key === 'ArrowDown') {
400
+ e.preventDefault()
401
+ setHighlightedIndex(prev => Math.min(prev + 1, flatVisibleKinds.length - 1))
402
+ } else if (e.key === 'ArrowUp') {
403
+ e.preventDefault()
404
+ setHighlightedIndex(prev => Math.max(prev - 1, 0))
405
+ } else if (e.key === 'Enter' && highlightedKind) {
406
+ e.preventDefault()
407
+ selectKind(highlightedKind)
408
+ setHighlightedIndex(-1)
409
+ setKindFilter('')
410
+ onKindNavigated?.()
411
+ }
412
+ }, [flatVisibleKinds.length, highlightedKind, selectKind, onKindNavigated]) // eslint-disable-line react-hooks/exhaustive-deps
413
+
414
+ const isKindHighlighted = useCallback((name: string, group: string) => {
415
+ return highlightedKind?.name === name && highlightedKind?.group === group
416
+ }, [highlightedKind])
417
+
338
418
  // Scroll sidebar to show selected kind on mount (deep linking) and on kind changes (keyboard nav)
339
419
  const lastScrolledKind = useRef<string | null>(null)
340
420
  const isInitialScroll = useRef(true)
@@ -362,11 +442,12 @@ export function ResourcesSidebar({
362
442
  <div className="relative">
363
443
  <Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-theme-text-tertiary" />
364
444
  <input
445
+ ref={kindSearchRef}
365
446
  type="text"
366
447
  placeholder="Filter resources..."
367
448
  value={kindFilter}
368
449
  onChange={(e) => setKindFilter(e.target.value)}
369
- onKeyDown={(e) => { if (e.key === 'Escape') { setKindFilter(''); (e.target as HTMLInputElement).blur() } }}
450
+ onKeyDown={handleSearchKeyDown}
370
451
  className="w-full pl-7 pr-7 py-2 bg-theme-elevated border border-theme-border-light rounded-lg text-sm text-theme-text-primary placeholder-theme-text-disabled focus:outline-none focus:ring-2 focus:ring-skyhook-500"
371
452
  />
372
453
  {kindFilter && (
@@ -398,34 +479,41 @@ export function ResourcesSidebar({
398
479
  </span>
399
480
  )}
400
481
  </button>
401
- {favoritesExpanded && (
402
- <div className="space-y-0.5">
403
- {pinned.length === 0 ? (
404
- <div className="px-3 py-2 text-xs text-theme-text-disabled">
405
- No pinned resources. Click <Pin className="w-3 h-3 inline" /> on any resource type to pin it here.
406
- </div>
407
- ) : (
408
- pinned.map((p) => {
409
- const isResourceSelected =
410
- (effectiveSelectedKind.name === p.name && effectiveSelectedKind.group === p.group) ||
411
- (effectiveSelectedKind.kind.toLowerCase() === p.kind.toLowerCase() && effectiveSelectedKind.group === p.group)
412
- return (
413
- <ResourceTypeButton
414
- key={`${p.name}-${p.group}`}
415
- ref={isResourceSelected ? selectedSidebarRef : null}
416
- resource={{ name: p.name, kind: p.kind, group: p.group, version: '', namespaced: true, isCrd: false, verbs: [] }}
417
- count={counts?.[(p.group ? `${p.group}/${p.kind}` : p.kind)] ?? 0}
418
- isSelected={isResourceSelected}
419
- isForbidden={forbiddenKinds.has(p.group ? `${p.group}/${p.kind}` : p.kind)}
420
- isPinned={true}
421
- onTogglePin={() => togglePin(p)}
422
- onClick={() => selectKind({ name: p.name, kind: p.kind, group: p.group })}
423
- />
424
- )
425
- })
426
- )}
482
+ <div className={clsx(
483
+ 'grid transition-[grid-template-rows] duration-200',
484
+ favoritesExpanded ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'
485
+ )} style={{ transitionTimingFunction: 'cubic-bezier(0.16, 1, 0.3, 1)' }}>
486
+ <div className="overflow-hidden">
487
+ <div className="space-y-0.5">
488
+ {pinned.length === 0 ? (
489
+ <div className="px-3 py-2 text-xs text-theme-text-disabled">
490
+ No pinned resources. Click <Pin className="w-3 h-3 inline" /> on any resource type to pin it here.
491
+ </div>
492
+ ) : (
493
+ pinned.map((p) => {
494
+ const isResourceSelected =
495
+ (effectiveSelectedKind.name === p.name && effectiveSelectedKind.group === p.group) ||
496
+ (effectiveSelectedKind.kind.toLowerCase() === p.kind.toLowerCase() && effectiveSelectedKind.group === p.group)
497
+ const highlighted = isKindHighlighted(p.name, p.group)
498
+ return (
499
+ <ResourceTypeButton
500
+ key={`${p.name}-${p.group}`}
501
+ ref={highlighted ? highlightedRef : (isResourceSelected ? selectedSidebarRef : null)}
502
+ resource={{ name: p.name, kind: p.kind, group: p.group, version: '', namespaced: true, isCrd: false, verbs: [] }}
503
+ count={counts?.[(p.group ? `${p.group}/${p.kind}` : p.kind)] ?? 0}
504
+ isSelected={isResourceSelected}
505
+ isHighlighted={highlighted}
506
+ isForbidden={forbiddenKinds.has(p.group ? `${p.group}/${p.kind}` : p.kind)}
507
+ isPinned={true}
508
+ onTogglePin={() => togglePin(p)}
509
+ onClick={() => selectKind({ name: p.name, kind: p.kind, group: p.group })}
510
+ />
511
+ )
512
+ })
513
+ )}
514
+ </div>
427
515
  </div>
428
- )}
516
+ </div>
429
517
  </div>
430
518
  {filteredCategories ? (
431
519
  // Dynamic categories from API
@@ -449,31 +537,38 @@ export function ResourcesSidebar({
449
537
  </span>
450
538
  )}
451
539
  </button>
452
- {isExpanded && (
453
- <div className="space-y-0.5">
454
- {category.visibleResources.map((resource) => {
455
- const resourceIsPinned = isPinned(resource.name, resource.group)
456
- const isResourceSelected =
457
- (effectiveSelectedKind.name === resource.name && effectiveSelectedKind.group === resource.group) ||
458
- (effectiveSelectedKind.kind.toLowerCase() === resource.kind.toLowerCase() && effectiveSelectedKind.group === resource.group)
459
- // If the resource is pinned, let the Favorites section own the highlight
460
- const showSelected = isResourceSelected && !resourceIsPinned
461
- return (
462
- <ResourceTypeButton
463
- key={resource.name}
464
- ref={isResourceSelected ? selectedSidebarRef : null}
465
- resource={resource}
466
- count={counts?.[(resource.group ? `${resource.group}/${resource.kind}` : resource.kind)] ?? 0}
467
- isSelected={showSelected}
468
- isForbidden={forbiddenKinds.has(resource.group ? `${resource.group}/${resource.kind}` : resource.kind)}
469
- isPinned={resourceIsPinned}
470
- onTogglePin={() => togglePin({ name: resource.name, kind: resource.kind, group: resource.group })}
471
- onClick={() => selectKind({ name: resource.name, kind: resource.kind, group: resource.group })}
472
- />
473
- )
474
- })}
540
+ <div className={clsx(
541
+ 'grid transition-[grid-template-rows] duration-200',
542
+ isExpanded ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'
543
+ )} style={{ transitionTimingFunction: 'cubic-bezier(0.16, 1, 0.3, 1)' }}>
544
+ <div className="overflow-hidden">
545
+ <div className="space-y-0.5">
546
+ {category.visibleResources.map((resource) => {
547
+ const resourceIsPinned = isPinned(resource.name, resource.group)
548
+ const isResourceSelected =
549
+ (effectiveSelectedKind.name === resource.name && effectiveSelectedKind.group === resource.group) ||
550
+ (effectiveSelectedKind.kind.toLowerCase() === resource.kind.toLowerCase() && effectiveSelectedKind.group === resource.group)
551
+ // If the resource is pinned, let the Favorites section own the highlight
552
+ const showSelected = isResourceSelected && !resourceIsPinned
553
+ const highlighted = isKindHighlighted(resource.name, resource.group)
554
+ return (
555
+ <ResourceTypeButton
556
+ key={resource.name}
557
+ ref={highlighted ? highlightedRef : (isResourceSelected ? selectedSidebarRef : null)}
558
+ resource={resource}
559
+ count={counts?.[(resource.group ? `${resource.group}/${resource.kind}` : resource.kind)] ?? 0}
560
+ isSelected={showSelected}
561
+ isHighlighted={highlighted}
562
+ isForbidden={forbiddenKinds.has(resource.group ? `${resource.group}/${resource.kind}` : resource.kind)}
563
+ isPinned={resourceIsPinned}
564
+ onTogglePin={() => togglePin({ name: resource.name, kind: resource.kind, group: resource.group })}
565
+ onClick={() => selectKind({ name: resource.name, kind: resource.kind, group: resource.group })}
566
+ />
567
+ )
568
+ })}
569
+ </div>
475
570
  </div>
476
- )}
571
+ </div>
477
572
  </div>
478
573
  )
479
574
  })