@skyhook-io/k8s-ui 1.4.0 → 1.4.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.
Files changed (55) hide show
  1. package/package.json +2 -2
  2. package/src/components/resources/ResourcesSidebar.tsx +152 -57
  3. package/src/components/resources/ResourcesView.tsx +141 -49
  4. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +190 -28
  5. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +22 -0
  6. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +156 -5
  7. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +28 -1
  8. package/src/components/resources/renderers/CertificateRenderer.tsx +47 -8
  9. package/src/components/resources/renderers/ChallengeRenderer.tsx +22 -3
  10. package/src/components/resources/renderers/CiliumNetworkPolicyRenderer.tsx +262 -45
  11. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +81 -9
  12. package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +10 -3
  13. package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +59 -1
  14. package/src/components/resources/renderers/ClusterNetworkPolicyRenderer.tsx +7 -21
  15. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +3 -5
  16. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +91 -3
  17. package/src/components/resources/renderers/GitRepositoryRenderer.tsx +3 -42
  18. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +3 -41
  19. package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +66 -2
  20. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +31 -1
  21. package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +66 -1
  22. package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +3 -0
  23. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +55 -1
  24. package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +23 -0
  25. package/src/components/resources/renderers/KustomizationRenderer.tsx +38 -3
  26. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +7 -56
  27. package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +3 -42
  28. package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +3 -8
  29. package/src/components/resources/renderers/PodMonitorRenderer.tsx +5 -7
  30. package/src/components/resources/renderers/PodRenderer.tsx +22 -74
  31. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +178 -20
  32. package/src/components/resources/renderers/ReplicaSetRenderer.tsx +2 -2
  33. package/src/components/resources/renderers/RolloutRenderer.tsx +105 -3
  34. package/src/components/resources/renderers/SbomReportRenderer.tsx +46 -5
  35. package/src/components/resources/renderers/SealedSecretRenderer.tsx +11 -2
  36. package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +5 -7
  37. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +11 -3
  38. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +11 -3
  39. package/src/components/resources/renderers/WebhookConfigRenderer.tsx +8 -16
  40. package/src/components/resources/renderers/WorkflowRenderer.tsx +71 -17
  41. package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +46 -1
  42. package/src/components/resources/renderers/karpenter-cells.tsx +18 -3
  43. package/src/components/resources/resource-utils-cnpg.ts +47 -0
  44. package/src/components/resources/resource-utils-karpenter.ts +6 -0
  45. package/src/components/resources/resource-utils-prometheus.ts +50 -6
  46. package/src/components/resources/resource-utils.ts +131 -33
  47. package/src/components/shared/EditableYamlView.tsx +38 -3
  48. package/src/components/shared/ResourceRendererDispatch.tsx +4 -4
  49. package/src/components/timeline/TimelineList.tsx +11 -4
  50. package/src/components/ui/Tooltip.tsx +16 -0
  51. package/src/components/ui/drawer-components.tsx +48 -1
  52. package/src/theme/variables.css +1 -1
  53. package/src/types/gitops.ts +8 -0
  54. package/src/utils/navigation.test.ts +29 -2
  55. package/src/utils/navigation.ts +45 -24
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -65,7 +65,7 @@
65
65
  "react": "^19.2.4",
66
66
  "react-dom": "^19.2.4",
67
67
  "typescript": "^6.0.2",
68
- "vitest": "^4.0.18",
68
+ "vitest": "^4.1.3",
69
69
  "yaml": "^2.8.3"
70
70
  }
71
71
  }
@@ -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
  })
@@ -31,6 +31,8 @@ import {
31
31
  getPodStatus,
32
32
  getPodRestarts,
33
33
  getPodProblems,
34
+ getWorkloadProblems,
35
+ workloadMatchesProblemCategory,
34
36
  getContainerSquareStates,
35
37
  getWorkloadImages,
36
38
  getWorkloadConditions,
@@ -116,6 +118,8 @@ import {
116
118
  getCellFilterValue,
117
119
  parseColumnFilters,
118
120
  serializeColumnFilters,
121
+ podMatchesProblemCategory,
122
+ SEVERITY_DOT_COLOR,
119
123
  } from './resource-utils'
120
124
  import { SEVERITY_BADGE, EVENT_TYPE_COLORS } from '../../utils/badge-colors'
121
125
  import { Tooltip } from '../ui/Tooltip'
@@ -140,7 +144,9 @@ import { ResourcesSidebar } from './ResourcesSidebar'
140
144
  import type { SelectedKindInfo } from './ResourcesSidebar'
141
145
 
142
146
  // Pod problem filter options (special multi-select, not a single column value)
143
- const POD_PROBLEMS = ['CrashLoopBackOff', 'ImagePullBackOff', 'OOMKilled', 'Unschedulable', 'Not Ready', 'High Restarts'] as const
147
+ const POD_PROBLEMS = ['CrashLoopBackOff', 'ImagePullBackOff', 'OOMKilled', 'Unschedulable', 'Not Ready', 'High Restarts', 'Init Failed', 'Exit Code Error', 'Failed', 'Other'] as const
148
+ const WORKLOAD_PROBLEMS = ['Unavailable', 'Rollout Stuck', 'Rollout In Progress'] as const
149
+ const WORKLOAD_KINDS = new Set(['deployments', 'statefulsets', 'daemonsets'])
144
150
 
145
151
  // Columns to skip for auto-detected filters (high cardinality, text-like, or non-filterable)
146
152
  const SKIP_FILTER_COLUMNS = new Set([
@@ -455,6 +461,8 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
455
461
  { key: 'namespace', label: 'Namespace', width: 'w-36' },
456
462
  { key: 'status', label: 'Status', width: 'w-24' },
457
463
  { key: 'instanceType', label: 'Instance Type', width: 'w-32' },
464
+ { key: 'capacityType', label: 'Capacity', width: 'w-24', tooltip: 'Spot or On-Demand' },
465
+ { key: 'zone', label: 'Zone', width: 'w-28', hideOnMobile: true },
458
466
  { key: 'nodePool', label: 'Node Pool', width: 'w-32' },
459
467
  { key: 'nodeName', label: 'Node', width: 'w-40', hideOnMobile: true },
460
468
  { key: 'age', label: 'Age', width: 'w-20' },
@@ -2476,27 +2484,8 @@ export function ResourcesView({
2476
2484
  const podMatchesProblemFilter = useCallback((pod: any, filters: string[]): boolean => {
2477
2485
  if (filters.length === 0) return true
2478
2486
  const problems = getPodProblems(pod)
2479
- const problemMessages = problems.map(p => p.message)
2480
2487
  const restarts = getPodRestarts(pod)
2481
-
2482
- return filters.some(filter => {
2483
- switch (filter) {
2484
- case 'CrashLoopBackOff':
2485
- return problemMessages.includes('CrashLoopBackOff')
2486
- case 'ImagePullBackOff':
2487
- return problemMessages.some(m => m.includes('ImagePull'))
2488
- case 'OOMKilled':
2489
- return problemMessages.includes('OOMKilled')
2490
- case 'Unschedulable':
2491
- return problemMessages.includes('Unschedulable')
2492
- case 'Not Ready':
2493
- return problemMessages.includes('Not Ready') || problemMessages.some(m => m.includes('Probe'))
2494
- case 'High Restarts':
2495
- return restarts > 5
2496
- default:
2497
- return false
2498
- }
2499
- })
2488
+ return filters.some(filter => podMatchesProblemCategory(problems, restarts, filter))
2500
2489
  }, [])
2501
2490
 
2502
2491
 
@@ -2526,9 +2515,17 @@ export function ResourcesView({
2526
2515
  )
2527
2516
  }
2528
2517
 
2529
- // Apply problem filters (pods only)
2530
- if (problemFilters.length > 0 && selectedKind.name.toLowerCase() === 'pods') {
2531
- result = result.filter((r: any) => podMatchesProblemFilter(r, problemFilters))
2518
+ // Apply problem filters
2519
+ if (problemFilters.length > 0) {
2520
+ const kindLower = normalizeKindToPlural(selectedKind.name, selectedKind.group)
2521
+ if (kindLower === 'pods') {
2522
+ result = result.filter((r: any) => podMatchesProblemFilter(r, problemFilters))
2523
+ } else if (WORKLOAD_KINDS.has(kindLower)) {
2524
+ result = result.filter((r: any) => {
2525
+ const problems = getWorkloadProblems(r, kindLower)
2526
+ return problemFilters.some(f => workloadMatchesProblemCategory(problems, f))
2527
+ })
2528
+ }
2532
2529
  }
2533
2530
 
2534
2531
  // Apply inactive ReplicaSet filter (default: hide inactive)
@@ -2802,15 +2799,12 @@ export function ResourcesView({
2802
2799
 
2803
2800
  for (const pod of resources) {
2804
2801
  const podProblems = getPodProblems(pod)
2805
- const msgs = podProblems.map(p => p.message)
2806
2802
  const restarts = getPodRestarts(pod)
2807
-
2808
- if (msgs.includes('CrashLoopBackOff')) problemCounts['CrashLoopBackOff']++
2809
- if (msgs.some(m => m.includes('ImagePull'))) problemCounts['ImagePullBackOff']++
2810
- if (msgs.includes('OOMKilled')) problemCounts['OOMKilled']++
2811
- if (msgs.includes('Unschedulable')) problemCounts['Unschedulable']++
2812
- if (msgs.includes('Not Ready') || msgs.some(m => m.includes('Probe'))) problemCounts['Not Ready']++
2813
- if (restarts > 5) problemCounts['High Restarts']++
2803
+ for (const category of POD_PROBLEMS) {
2804
+ if (podMatchesProblemCategory(podProblems, restarts, category)) {
2805
+ problemCounts[category]++
2806
+ }
2807
+ }
2814
2808
  }
2815
2809
 
2816
2810
  const activeProblems = POD_PROBLEMS
@@ -2821,6 +2815,28 @@ export function ResourcesView({
2821
2815
  }
2822
2816
  }
2823
2817
 
2818
+ // Workload-specific: compute problem counts
2819
+ if (WORKLOAD_KINDS.has(kindLower)) {
2820
+ const problemCounts: Record<string, number> = {}
2821
+ WORKLOAD_PROBLEMS.forEach(p => problemCounts[p] = 0)
2822
+
2823
+ for (const resource of resources) {
2824
+ const workloadProblems = getWorkloadProblems(resource, kindLower)
2825
+ for (const category of WORKLOAD_PROBLEMS) {
2826
+ if (workloadMatchesProblemCategory(workloadProblems, category)) {
2827
+ problemCounts[category]++
2828
+ }
2829
+ }
2830
+ }
2831
+
2832
+ const activeProblems = WORKLOAD_PROBLEMS
2833
+ .map(p => ({ value: p, count: problemCounts[p] }))
2834
+ .filter(p => p.count > 0)
2835
+ if (activeProblems.length > 0) {
2836
+ problems = activeProblems
2837
+ }
2838
+ }
2839
+
2824
2840
  // Compute available labels for label filtering
2825
2841
  const labelCounts: Record<string, number> = {}
2826
2842
  for (const r of resources) {
@@ -2922,6 +2938,11 @@ export function ResourcesView({
2922
2938
  pinned={pinned}
2923
2939
  togglePin={togglePin}
2924
2940
  isPinned={isPinned}
2941
+ onKindNavigated={() => {
2942
+ // After selecting a kind via keyboard, move focus to the table search
2943
+ // so the user can immediately filter within the selected kind.
2944
+ setTimeout(() => searchInputRef.current?.focus(), 50)
2945
+ }}
2925
2946
  />
2926
2947
  )}
2927
2948
 
@@ -2937,6 +2958,30 @@ export function ResourcesView({
2937
2958
  placeholder="Search... (press /)"
2938
2959
  value={searchTerm}
2939
2960
  onChange={(e) => setSearchTerm(e.target.value)}
2961
+ onKeyDown={(e) => {
2962
+ if (e.key === 'ArrowDown') {
2963
+ // Hand off to the table's keyboard navigation — blur the input
2964
+ // so the registered ArrowDown/j/k shortcuts take over, and
2965
+ // highlight the first row.
2966
+ e.preventDefault()
2967
+ searchInputRef.current?.blur()
2968
+ setHighlightedIndex(0)
2969
+ } else if (e.key === 'Enter' && filteredResourceCountRef.current > 0) {
2970
+ // Select the first (or currently highlighted) resource
2971
+ e.preventDefault()
2972
+ searchInputRef.current?.blur()
2973
+ if (highlightedIndex < 0) setHighlightedIndex(0)
2974
+ // Defer to next frame so the highlight renders before we open
2975
+ requestAnimationFrame(() => {
2976
+ const res = highlightedResourceRef.current ?? filteredResources[0]
2977
+ if (res?.metadata?.name) {
2978
+ onResourceClick?.({ kind: selectedKind.name, namespace: res.metadata.namespace || '', name: res.metadata.name, group: selectedKind.group })
2979
+ }
2980
+ })
2981
+ } else if (e.key === 'Escape') {
2982
+ searchInputRef.current?.blur()
2983
+ }
2984
+ }}
2940
2985
  className="w-full max-w-md pl-10 pr-4 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"
2941
2986
  />
2942
2987
  </div>
@@ -4021,12 +4066,21 @@ function PodCell({ resource, column }: { resource: any; column: string }) {
4021
4066
  const status = getPodStatus(resource)
4022
4067
  const problems = getPodProblems(resource)
4023
4068
  return (
4024
- <div className="flex items-center gap-2">
4025
- <span className={clsx('badge', status.color)}>
4069
+ <div className="flex items-center gap-2 min-w-0">
4070
+ <span className={clsx('badge truncate', status.color)} title={status.text}>
4026
4071
  {status.text}
4027
4072
  </span>
4028
- {problems.length > 0 && (
4029
- <Tooltip content={problems.map(p => p.message).join(', ')}>
4073
+ {problems.length > 1 && (
4074
+ <Tooltip content={
4075
+ <div className="space-y-0.5">
4076
+ {problems.map((p, i) => (
4077
+ <div key={i} className="flex items-center gap-1.5">
4078
+ <span className={clsx('w-1.5 h-1.5 rounded-full shrink-0', SEVERITY_DOT_COLOR[p.severity])} />
4079
+ <span>{p.message}</span>
4080
+ </div>
4081
+ ))}
4082
+ </div>
4083
+ }>
4030
4084
  <span className="text-red-400">
4031
4085
  <AlertTriangle className="w-3.5 h-3.5" />
4032
4086
  </span>
@@ -4101,7 +4155,7 @@ function PodCell({ resource, column }: { resource: any; column: string }) {
4101
4155
  }
4102
4156
  }
4103
4157
 
4104
- function WorkloadCell({ resource, column }: { resource: any; kind: string; column: string }) {
4158
+ function WorkloadCell({ resource, column, kind }: { resource: any; kind: string; column: string }) {
4105
4159
  const status = resource.status || {}
4106
4160
  const spec = resource.spec || {}
4107
4161
 
@@ -4110,13 +4164,32 @@ function WorkloadCell({ resource, column }: { resource: any; kind: string; colum
4110
4164
  const desired = spec.replicas ?? 0
4111
4165
  const ready = status.readyReplicas || 0
4112
4166
  const allReady = ready === desired && desired > 0
4167
+ const problems = getWorkloadProblems(resource, kind)
4113
4168
  return (
4114
- <span className={clsx(
4115
- 'text-sm font-medium',
4116
- desired === 0 ? 'text-theme-text-secondary' : allReady ? 'text-green-400' : ready > 0 ? 'text-yellow-400' : 'text-red-400'
4117
- )}>
4118
- {ready}/{desired}
4119
- </span>
4169
+ <div className="flex items-center gap-1.5">
4170
+ <span className={clsx(
4171
+ 'text-sm font-medium',
4172
+ desired === 0 ? 'text-theme-text-secondary' : allReady ? 'text-green-400' : ready > 0 ? 'text-yellow-400' : 'text-red-400'
4173
+ )}>
4174
+ {ready}/{desired}
4175
+ </span>
4176
+ {problems.length > 0 && (
4177
+ <Tooltip content={
4178
+ <div className="space-y-0.5">
4179
+ {problems.map((p, i) => (
4180
+ <div key={i} className="flex items-center gap-1.5">
4181
+ <span className={clsx('w-1.5 h-1.5 rounded-full shrink-0', SEVERITY_DOT_COLOR[p.severity])} />
4182
+ <span>{p.message}</span>
4183
+ </div>
4184
+ ))}
4185
+ </div>
4186
+ }>
4187
+ <span className="text-red-400">
4188
+ <AlertTriangle className="w-3.5 h-3.5" />
4189
+ </span>
4190
+ </Tooltip>
4191
+ )}
4192
+ </div>
4120
4193
  )
4121
4194
  }
4122
4195
  case 'upToDate':
@@ -4167,13 +4240,32 @@ function DaemonSetCell({ resource, column }: { resource: any; column: string })
4167
4240
  const desired = status.desiredNumberScheduled || 0
4168
4241
  const ready = status.numberReady || 0
4169
4242
  const allReady = ready === desired && desired > 0
4243
+ const problems = getWorkloadProblems(resource, 'daemonsets')
4170
4244
  return (
4171
- <span className={clsx(
4172
- 'text-sm font-medium',
4173
- allReady ? 'text-green-400' : ready > 0 ? 'text-yellow-400' : 'text-red-400'
4174
- )}>
4175
- {ready}
4176
- </span>
4245
+ <div className="flex items-center gap-1.5">
4246
+ <span className={clsx(
4247
+ 'text-sm font-medium',
4248
+ allReady ? 'text-green-400' : ready > 0 ? 'text-yellow-400' : 'text-red-400'
4249
+ )}>
4250
+ {ready}
4251
+ </span>
4252
+ {problems.length > 0 && (
4253
+ <Tooltip content={
4254
+ <div className="space-y-0.5">
4255
+ {problems.map((p, i) => (
4256
+ <div key={i} className="flex items-center gap-1.5">
4257
+ <span className={clsx('w-1.5 h-1.5 rounded-full shrink-0', SEVERITY_DOT_COLOR[p.severity])} />
4258
+ <span>{p.message}</span>
4259
+ </div>
4260
+ ))}
4261
+ </div>
4262
+ }>
4263
+ <span className="text-red-400">
4264
+ <AlertTriangle className="w-3.5 h-3.5" />
4265
+ </span>
4266
+ </Tooltip>
4267
+ )}
4268
+ </div>
4177
4269
  )
4178
4270
  }
4179
4271
  case 'upToDate':