@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.
- package/package.json +2 -2
- package/src/components/resources/ResourcesSidebar.tsx +152 -57
- package/src/components/resources/ResourcesView.tsx +141 -49
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +190 -28
- package/src/components/resources/renderers/CNPGBackupRenderer.tsx +22 -0
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +156 -5
- package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +28 -1
- package/src/components/resources/renderers/CertificateRenderer.tsx +47 -8
- package/src/components/resources/renderers/ChallengeRenderer.tsx +22 -3
- package/src/components/resources/renderers/CiliumNetworkPolicyRenderer.tsx +262 -45
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +81 -9
- package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +10 -3
- package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +59 -1
- package/src/components/resources/renderers/ClusterNetworkPolicyRenderer.tsx +7 -21
- package/src/components/resources/renderers/ExternalSecretRenderer.tsx +3 -5
- package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +91 -3
- package/src/components/resources/renderers/GitRepositoryRenderer.tsx +3 -42
- package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +3 -41
- package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +66 -2
- package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +31 -1
- package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +66 -1
- package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +3 -0
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +55 -1
- package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +23 -0
- package/src/components/resources/renderers/KustomizationRenderer.tsx +38 -3
- package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +7 -56
- package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +3 -42
- package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +3 -8
- package/src/components/resources/renderers/PodMonitorRenderer.tsx +5 -7
- package/src/components/resources/renderers/PodRenderer.tsx +22 -74
- package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +178 -20
- package/src/components/resources/renderers/ReplicaSetRenderer.tsx +2 -2
- package/src/components/resources/renderers/RolloutRenderer.tsx +105 -3
- package/src/components/resources/renderers/SbomReportRenderer.tsx +46 -5
- package/src/components/resources/renderers/SealedSecretRenderer.tsx +11 -2
- package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +5 -7
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +11 -3
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +11 -3
- package/src/components/resources/renderers/WebhookConfigRenderer.tsx +8 -16
- package/src/components/resources/renderers/WorkflowRenderer.tsx +71 -17
- package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +46 -1
- package/src/components/resources/renderers/karpenter-cells.tsx +18 -3
- package/src/components/resources/resource-utils-cnpg.ts +47 -0
- package/src/components/resources/resource-utils-karpenter.ts +6 -0
- package/src/components/resources/resource-utils-prometheus.ts +50 -6
- package/src/components/resources/resource-utils.ts +131 -33
- package/src/components/shared/EditableYamlView.tsx +38 -3
- package/src/components/shared/ResourceRendererDispatch.tsx +4 -4
- package/src/components/timeline/TimelineList.tsx +11 -4
- package/src/components/ui/Tooltip.tsx +16 -0
- package/src/components/ui/drawer-components.tsx +48 -1
- package/src/theme/variables.css +1 -1
- package/src/types/gitops.ts +8 -0
- package/src/utils/navigation.test.ts +29 -2
- 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.
|
|
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.
|
|
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
|
-
:
|
|
114
|
-
? '
|
|
115
|
-
:
|
|
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={
|
|
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
|
-
{
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
{
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
|
2530
|
-
if (problemFilters.length > 0
|
|
2531
|
-
|
|
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
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
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 >
|
|
4029
|
-
<Tooltip content={
|
|
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
|
-
<
|
|
4115
|
-
|
|
4116
|
-
|
|
4117
|
-
|
|
4118
|
-
|
|
4119
|
-
|
|
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
|
-
<
|
|
4172
|
-
|
|
4173
|
-
|
|
4174
|
-
|
|
4175
|
-
|
|
4176
|
-
|
|
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':
|