@skyhook-io/k8s-ui 1.8.9 → 1.8.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -4
- package/src/assets/gitops/argocd.png +0 -0
- package/src/assets/gitops/flux.svg +7 -0
- package/src/components/applications/AppChips.tsx +10 -0
- package/src/components/applications/ApplicationDetail.test.tsx +569 -0
- package/src/components/applications/ApplicationDetail.tsx +2517 -450
- package/src/components/applications/ApplicationsList.tsx +4 -1
- package/src/components/applications/ApplicationsView.tsx +101 -18
- package/src/components/applications/applications-view.test.tsx +47 -0
- package/src/components/applications/index.ts +1 -1
- package/src/components/audit/AuditAlerts.tsx +14 -10
- package/src/components/checks/ChecksView.tsx +81 -51
- package/src/components/checks/severity.ts +39 -45
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -2
- package/src/components/compare/CompareResourcePicker.tsx +2 -2
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
- package/src/components/gitops/GitOpsTableView.tsx +14 -59
- package/src/components/gitops/ManagedResourcesList.tsx +8 -11
- package/src/components/gitops/SyncCountdown.tsx +26 -17
- package/src/components/gitops/SyncOptionsDialog.tsx +2 -2
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +277 -104
- package/src/components/gitops/insights/insights-helpers.test.ts +86 -1
- package/src/components/gitops/insights/insights-helpers.ts +69 -1
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -2
- package/src/components/issues/IssuesView.tsx +219 -143
- package/src/components/issues/ResourceIssuesSection.test.tsx +40 -0
- package/src/components/issues/ResourceIssuesSection.tsx +28 -128
- package/src/components/issues/index.ts +3 -2
- package/src/components/issues/issue-timing.ts +53 -0
- package/src/components/issues/issues.test.ts +146 -4
- package/src/components/issues/severity.ts +29 -32
- package/src/components/logs/LogCore.tsx +14 -4
- package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
- package/src/components/logs/useLogStream.ts +8 -1
- package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
- package/src/components/resources/ResourcesSidebar.test.tsx +107 -0
- package/src/components/resources/ResourcesSidebar.tsx +70 -52
- package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
- package/src/components/resources/ResourcesView.tsx +243 -53
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
- package/src/components/resources/renderers/JobRenderer.tsx +3 -1
- package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +47 -0
- package/src/components/resources/renderers/NodeRenderer.test.tsx +103 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +20 -12
- package/src/components/resources/renderers/PVCRenderer.test.tsx +23 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +2 -2
- package/src/components/resources/renderers/PodRenderer.test.tsx +101 -0
- package/src/components/resources/renderers/PodRenderer.tsx +81 -70
- package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
- package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
- package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
- package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
- package/src/components/resources/renderers/index.ts +1 -0
- package/src/components/resources/resource-utils-capi.ts +1 -1
- package/src/components/resources/resource-utils.ts +31 -0
- package/src/components/shared/DetailShell.tsx +2 -2
- package/src/components/shared/ResourceActionsBar.tsx +140 -108
- package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
- package/src/components/timeline/TimelineList.tsx +371 -354
- package/src/components/timeline/TimelineStrip.test.tsx +194 -0
- package/src/components/timeline/TimelineStrip.tsx +836 -0
- package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
- package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
- package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
- package/src/components/timeline/TimelineToolbar.tsx +759 -0
- package/src/components/timeline/TimelineToolbarState.test.tsx +64 -0
- package/src/components/timeline/index.ts +23 -0
- package/src/components/timeline/scrubber-math.test.ts +235 -0
- package/src/components/timeline/scrubber-math.ts +249 -0
- package/src/components/timeline/shared.test.tsx +219 -0
- package/src/components/timeline/shared.tsx +232 -447
- package/src/components/timeline/timeline-filters.test.ts +196 -0
- package/src/components/timeline/timeline-filters.ts +180 -0
- package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
- package/src/components/timeline/timeline-lane-sort.ts +77 -0
- package/src/components/timeline/timeline-live.test.ts +131 -0
- package/src/components/timeline/timeline-live.ts +77 -0
- package/src/components/topology/GroupNode.tsx +9 -2
- package/src/components/topology/K8sResourceNode.test.ts +11 -0
- package/src/components/topology/K8sResourceNode.tsx +387 -243
- package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
- package/src/components/topology/TopologyGraph.tsx +89 -16
- package/src/components/topology/TopologySearch.tsx +2 -2
- package/src/components/topology/layout.ts +10 -2
- package/src/components/topology/topology.css +0 -92
- package/src/components/ui/Badge.tsx +5 -0
- package/src/components/ui/BoardSkeleton.tsx +47 -0
- package/src/components/ui/CardSection.tsx +117 -0
- package/src/components/ui/CodeViewer.tsx +2 -2
- package/src/components/ui/Collapse.tsx +59 -0
- package/src/components/ui/ConfirmDialog.tsx +7 -3
- package/src/components/ui/DialogPortal.tsx +31 -9
- package/src/components/ui/Input.tsx +24 -0
- package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
- package/src/components/ui/MultiSelectPicker.tsx +167 -0
- package/src/components/ui/PaneLoader.tsx +10 -2
- package/src/components/ui/ResourceBar.tsx +68 -25
- package/src/components/ui/SearchBox.tsx +18 -4
- package/src/components/ui/SearchPillInput.tsx +2 -2
- package/src/components/ui/Toast.tsx +1 -1
- package/src/components/ui/drawer-components.tsx +39 -26
- package/src/components/ui/index.ts +6 -0
- package/src/components/ui/severity-tone.ts +63 -0
- package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
- package/src/components/workload/WorkloadView.tsx +2682 -472
- package/src/components/workload/index.ts +1 -1
- package/src/theme/variables.css +15 -1
- package/src/types/core.ts +57 -4
- package/src/utils/api-resources.test.ts +54 -0
- package/src/utils/api-resources.ts +68 -1
- package/src/utils/application-topology.test.ts +198 -0
- package/src/utils/application-topology.ts +261 -0
- package/src/utils/applications.test.ts +167 -1
- package/src/utils/applications.ts +1157 -393
- package/src/utils/index.ts +2 -0
- package/src/utils/navigation.test.ts +32 -1
- package/src/utils/navigation.ts +70 -0
- package/src/utils/resource-hierarchy.test.ts +990 -1
- package/src/utils/resource-hierarchy.ts +905 -267
- package/src/utils/resource-icons.ts +2 -0
- package/src/utils/topology-neighborhood.test.ts +202 -1
- package/src/utils/topology-neighborhood.ts +194 -13
- package/src/utils/workflow-execution.test.ts +556 -0
- package/src/utils/workflow-execution.ts +814 -0
- package/src/utils/workload-colors.ts +2 -3
- package/tsconfig.json +2 -2
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { useState, type ReactNode } from 'react'
|
|
2
|
+
import { ChevronRight } from 'lucide-react'
|
|
3
|
+
import { clsx } from 'clsx'
|
|
4
|
+
|
|
5
|
+
// Animated show/hide using the grid-template-rows 0fr→1fr technique — the
|
|
6
|
+
// app-wide standard for expand/collapse (drawers, checks, audit, resources
|
|
7
|
+
// sidebar, port-forward). The inner overflow-hidden wrapper is load-bearing:
|
|
8
|
+
// it clips the content while the grid row animates between 0 and its natural
|
|
9
|
+
// height, so nothing spills before the row is fully open.
|
|
10
|
+
//
|
|
11
|
+
// Content stays mounted while closed (clipped to zero height) — that's what
|
|
12
|
+
// lets it animate. `inert` when closed keeps that clipped content out of the
|
|
13
|
+
// tab order and the accessibility tree, so keyboard/screen-reader users don't
|
|
14
|
+
// land on invisible rows (matching ChecksView's collapse).
|
|
15
|
+
//
|
|
16
|
+
// `mountLazily` defers rendering the children until the first open, then keeps
|
|
17
|
+
// them mounted so both the open and close animations still run. Use it when the
|
|
18
|
+
// collapsed content is heavy and there are many instances (e.g. hundreds of
|
|
19
|
+
// resource rows, each with a drift panel) — otherwise every collapsed instance
|
|
20
|
+
// pays its render cost up front. Off by default so the common single-instance
|
|
21
|
+
// case keeps the simplest behavior.
|
|
22
|
+
export function Collapse({
|
|
23
|
+
open,
|
|
24
|
+
children,
|
|
25
|
+
className,
|
|
26
|
+
mountLazily = false,
|
|
27
|
+
}: {
|
|
28
|
+
open: boolean
|
|
29
|
+
children: ReactNode
|
|
30
|
+
className?: string
|
|
31
|
+
mountLazily?: boolean
|
|
32
|
+
}) {
|
|
33
|
+
// Latch: once opened, stay mounted. Conditional setState-in-render is the
|
|
34
|
+
// supported pattern for deriving state from props without an extra commit.
|
|
35
|
+
const [hasOpened, setHasOpened] = useState(open)
|
|
36
|
+
if (mountLazily && open && !hasOpened) setHasOpened(true)
|
|
37
|
+
const render = !mountLazily || hasOpened
|
|
38
|
+
return (
|
|
39
|
+
<div
|
|
40
|
+
className={clsx('grid transition-[grid-template-rows] duration-200 ease-out', className)}
|
|
41
|
+
style={{ gridTemplateRows: open ? '1fr' : '0fr' }}
|
|
42
|
+
>
|
|
43
|
+
<div className="overflow-hidden" inert={!open || undefined}>{render ? children : null}</div>
|
|
44
|
+
</div>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// CollapseChevron is the disclosure caret that pairs with <Collapse>: a single
|
|
49
|
+
// ChevronRight that rotates 90° when open, rather than swapping between two
|
|
50
|
+
// icons. Matches the drawer Section / ExpandableSection affordance so every
|
|
51
|
+
// collapsible surface animates the same way.
|
|
52
|
+
export function CollapseChevron({ open, className }: { open: boolean; className?: string }) {
|
|
53
|
+
return (
|
|
54
|
+
<ChevronRight
|
|
55
|
+
aria-hidden="true"
|
|
56
|
+
className={clsx('shrink-0 text-theme-text-tertiary transition-transform duration-200', open && 'rotate-90', className)}
|
|
57
|
+
/>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
@@ -17,6 +17,8 @@ interface ConfirmDialogProps {
|
|
|
17
17
|
variant?: 'danger' | 'warning'
|
|
18
18
|
isLoading?: boolean
|
|
19
19
|
isClosable?: boolean // Allow closing even when isLoading (e.g., for long-running ops the user can dismiss)
|
|
20
|
+
confirmDisabled?: boolean // Block the confirm action while custom content is invalid (e.g., bad YAML)
|
|
21
|
+
className?: string
|
|
20
22
|
children?: ReactNode // Optional custom content (e.g., checkboxes)
|
|
21
23
|
}
|
|
22
24
|
|
|
@@ -32,6 +34,8 @@ export function ConfirmDialog({
|
|
|
32
34
|
variant = 'danger',
|
|
33
35
|
isLoading = false,
|
|
34
36
|
isClosable = false,
|
|
37
|
+
confirmDisabled = false,
|
|
38
|
+
className,
|
|
35
39
|
children,
|
|
36
40
|
}: ConfirmDialogProps) {
|
|
37
41
|
const canClose = !isLoading || isClosable
|
|
@@ -39,7 +43,7 @@ export function ConfirmDialog({
|
|
|
39
43
|
const severity = isDanger ? 'error' : 'warning'
|
|
40
44
|
|
|
41
45
|
return (
|
|
42
|
-
<DialogPortal open={open} onClose={onClose} closable={canClose} className=
|
|
46
|
+
<DialogPortal open={open} onClose={onClose} closable={canClose} className={clsx('w-full', className ?? 'max-w-md')}>
|
|
43
47
|
{/* Header */}
|
|
44
48
|
<div className="flex items-start gap-3 p-4 border-b border-theme-border">
|
|
45
49
|
<div
|
|
@@ -109,9 +113,9 @@ export function ConfirmDialog({
|
|
|
109
113
|
</button>
|
|
110
114
|
<button
|
|
111
115
|
onClick={onConfirm}
|
|
112
|
-
disabled={isLoading}
|
|
116
|
+
disabled={isLoading || confirmDisabled}
|
|
113
117
|
className={clsx(
|
|
114
|
-
'px-4 py-2 text-sm font-medium rounded-lg transition-colors disabled:opacity-50 flex items-center gap-2',
|
|
118
|
+
'px-4 py-2 text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2',
|
|
115
119
|
isDanger
|
|
116
120
|
? 'bg-red-600 hover:bg-red-700 text-theme-text-primary'
|
|
117
121
|
: 'bg-amber-600 hover:bg-amber-700 text-theme-text-primary'
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useRef, type ReactNode } from 'react'
|
|
1
|
+
import { useEffect, useRef, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from 'react'
|
|
2
2
|
import { createPortal } from 'react-dom'
|
|
3
3
|
import { clsx } from 'clsx'
|
|
4
4
|
import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
|
|
@@ -29,19 +29,40 @@ export function DialogPortal({ open, onClose, children, className, closable = tr
|
|
|
29
29
|
const dialogRef = useRef<HTMLDivElement>(null)
|
|
30
30
|
const { shouldRender, isOpen } = useAnimatedUnmount(open, 200)
|
|
31
31
|
|
|
32
|
-
//
|
|
32
|
+
// Bubble phase lets nested editors and menus consume Escape before the dialog closes.
|
|
33
|
+
const handleDialogKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
|
|
34
|
+
if (e.key !== 'Escape') return
|
|
35
|
+
|
|
36
|
+
e.stopPropagation()
|
|
37
|
+
e.preventDefault()
|
|
38
|
+
|
|
39
|
+
if (closable) {
|
|
40
|
+
onClose()
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
33
44
|
useEffect(() => {
|
|
34
45
|
if (!open) return
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
46
|
+
|
|
47
|
+
const handleDocumentKeyDown = (e: KeyboardEvent) => {
|
|
48
|
+
if (e.key !== 'Escape') return
|
|
49
|
+
const modalDialogs = Array.from(document.querySelectorAll<HTMLElement>('[role="dialog"][aria-modal="true"]'))
|
|
50
|
+
const topDialog = modalDialogs[modalDialogs.length - 1]
|
|
51
|
+
if (topDialog !== dialogRef.current) return
|
|
52
|
+
|
|
53
|
+
if (dialogRef.current?.contains(e.target as Node)) return
|
|
54
|
+
|
|
55
|
+
e.stopPropagation()
|
|
56
|
+
e.preventDefault()
|
|
57
|
+
|
|
58
|
+
if (closable) {
|
|
39
59
|
onClose()
|
|
40
60
|
}
|
|
41
61
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
62
|
+
|
|
63
|
+
document.addEventListener('keydown', handleDocumentKeyDown, true)
|
|
64
|
+
return () => document.removeEventListener('keydown', handleDocumentKeyDown, true)
|
|
65
|
+
}, [open, closable, onClose])
|
|
45
66
|
|
|
46
67
|
// Move focus into the dialog for accessibility and tab navigation
|
|
47
68
|
useEffect(() => {
|
|
@@ -67,6 +88,7 @@ export function DialogPortal({ open, onClose, children, className, closable = tr
|
|
|
67
88
|
role="dialog"
|
|
68
89
|
aria-modal="true"
|
|
69
90
|
tabIndex={-1}
|
|
91
|
+
onKeyDown={handleDialogKeyDown}
|
|
70
92
|
className={clsx(
|
|
71
93
|
'relative bg-theme-surface border border-theme-border rounded-lg shadow-2xl mx-4 outline-none',
|
|
72
94
|
TRANSITION_PANEL,
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { forwardRef } from 'react'
|
|
2
|
+
|
|
3
|
+
/** The shared text-input primitive. A minimal pass-through over a native
|
|
4
|
+
* `<input>` that bakes in autocorrect/autocapitalize OFF — in a Kubernetes
|
|
5
|
+
* tool every text field is an identifier, selector, or number, never prose,
|
|
6
|
+
* so browser text-mangling (e.g. macOS Safari turning `aws-node` into
|
|
7
|
+
* `Was-node`) only ever corrupts input. Spellcheck is deliberately left
|
|
8
|
+
* untouched — its red squiggle is a passive hint, not a mutation, and stays
|
|
9
|
+
* useful. Carries no styling of its own: callers keep their own `className`.
|
|
10
|
+
* Non-text inputs (checkbox, number, password) stay raw `<input>` — this
|
|
11
|
+
* defaults to `type="text"`. */
|
|
12
|
+
export const Input = forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
|
13
|
+
function Input({ type = 'text', ...props }, ref) {
|
|
14
|
+
return (
|
|
15
|
+
<input
|
|
16
|
+
ref={ref}
|
|
17
|
+
type={type}
|
|
18
|
+
autoCorrect="off"
|
|
19
|
+
autoCapitalize="off"
|
|
20
|
+
{...props}
|
|
21
|
+
/>
|
|
22
|
+
)
|
|
23
|
+
},
|
|
24
|
+
)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import { MultiSelectPicker } from './MultiSelectPicker'
|
|
4
|
+
|
|
5
|
+
// 8 items so the list clears the default search threshold (6) and the filter
|
|
6
|
+
// input renders.
|
|
7
|
+
const KINDS = ['ConfigMap', 'Deployment', 'Ingress', 'Job', 'Pod', 'ReplicaSet', 'Service', 'StatefulSet']
|
|
8
|
+
|
|
9
|
+
function render(props: Partial<Parameters<typeof MultiSelectPicker>[0]> = {}) {
|
|
10
|
+
return renderToString(
|
|
11
|
+
<MultiSelectPicker
|
|
12
|
+
items={KINDS}
|
|
13
|
+
selected={new Set()}
|
|
14
|
+
onSelectionChange={() => {}}
|
|
15
|
+
onClearAll={() => {}}
|
|
16
|
+
onDone={() => {}}
|
|
17
|
+
search=""
|
|
18
|
+
onSearchChange={() => {}}
|
|
19
|
+
searchPlaceholder="Filter kinds"
|
|
20
|
+
summaryEmptyLabel="All kinds"
|
|
21
|
+
noItemsLabel="No kinds available."
|
|
22
|
+
clearAllDisabled
|
|
23
|
+
clearAllAriaLabel="Clear kind selection"
|
|
24
|
+
{...props}
|
|
25
|
+
/>,
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('MultiSelectPicker', () => {
|
|
30
|
+
it('renders each item as a checkbox row with Clear all / Select all and Done', () => {
|
|
31
|
+
const html = render()
|
|
32
|
+
expect(html).toContain('type="checkbox"')
|
|
33
|
+
for (const kind of KINDS) expect(html).toContain(`>${kind}</span>`)
|
|
34
|
+
expect(html).toContain('Clear all')
|
|
35
|
+
expect(html).toContain('Select all')
|
|
36
|
+
expect(html).toContain('>Done</button>')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('shows the filter input above the threshold and hides it below', () => {
|
|
40
|
+
const shown = render()
|
|
41
|
+
expect(shown).toContain('placeholder="Filter kinds"')
|
|
42
|
+
|
|
43
|
+
const hidden = render({ items: ['Pod', 'Service'] })
|
|
44
|
+
expect(hidden).not.toContain('placeholder="Filter kinds"')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('filters the list by the controlled search term', () => {
|
|
48
|
+
const html = render({ search: 'dep' })
|
|
49
|
+
expect(html).toContain('>Deployment</span>')
|
|
50
|
+
expect(html).not.toContain('>Pod</span>')
|
|
51
|
+
expect(html).not.toContain('>Service</span>')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('shows a no-matches message when the search excludes everything', () => {
|
|
55
|
+
const html = render({ search: 'zzz' })
|
|
56
|
+
expect(html).toContain('No matches.')
|
|
57
|
+
expect(html).not.toContain('>Pod</span>')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('shows the empty-items label when there are no items and no search', () => {
|
|
61
|
+
const html = render({ items: [], search: '' })
|
|
62
|
+
expect(html).toContain('No kinds available.')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('summarises the empty selection as the empty label, else "N selected"', () => {
|
|
66
|
+
expect(render({ selected: new Set() })).toContain('All kinds')
|
|
67
|
+
|
|
68
|
+
const some = render({ selected: new Set(['Pod', 'Service']), clearAllDisabled: false })
|
|
69
|
+
expect(some).toContain('2 selected')
|
|
70
|
+
expect(some).not.toContain('All kinds')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('reflects the selected items via checked checkboxes', () => {
|
|
74
|
+
const html = render({ selected: new Set(['Pod']), clearAllDisabled: false })
|
|
75
|
+
// The checked checkbox renders with the `checked` attribute in SSR markup.
|
|
76
|
+
expect(html).toContain('checked=""')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('disables Clear all when clearAllDisabled and enables it otherwise', () => {
|
|
80
|
+
expect(render({ clearAllDisabled: true })).toContain('aria-label="Clear kind selection"')
|
|
81
|
+
// Disabled state present when nothing is selected.
|
|
82
|
+
expect(render({ clearAllDisabled: true })).toMatch(/Clear all[\s\S]*?disabled|disabled[\s\S]*?Clear all/)
|
|
83
|
+
|
|
84
|
+
const enabled = render({ selected: new Set(['Pod']), clearAllDisabled: false })
|
|
85
|
+
// The bulk row's Clear all button is not disabled here (the list checkboxes carry no disabled attr either).
|
|
86
|
+
expect(enabled).toContain('aria-label="Clear kind selection"')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('labels the visible-bulk button by search + selection state', () => {
|
|
90
|
+
// No search, nothing selected -> plain "Select all".
|
|
91
|
+
expect(render({ search: '' })).toContain('Select all')
|
|
92
|
+
|
|
93
|
+
// Search active, nothing of the visible set selected -> "Select N visible".
|
|
94
|
+
const searching = render({ search: 'e' })
|
|
95
|
+
expect(searching).toMatch(/Select \d+ visible/)
|
|
96
|
+
|
|
97
|
+
// All visible selected -> "Clear N visible".
|
|
98
|
+
const allSelected = render({ selected: new Set(KINDS), clearAllDisabled: false })
|
|
99
|
+
expect(allSelected).toMatch(/Clear \d+ visible/)
|
|
100
|
+
})
|
|
101
|
+
})
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
import { Search, X } from 'lucide-react'
|
|
4
|
+
|
|
5
|
+
export interface MultiSelectPickerProps {
|
|
6
|
+
/** Full option list. The host sorts/orders it; the picker filters by search only. */
|
|
7
|
+
items: string[]
|
|
8
|
+
/** Currently-selected values. Empty = no filter (summary shows {@link summaryEmptyLabel}). */
|
|
9
|
+
selected: ReadonlySet<string>
|
|
10
|
+
/** Called with the next selection whenever a row or the visible-bulk button toggles. */
|
|
11
|
+
onSelectionChange: (next: Set<string>) => void
|
|
12
|
+
/**
|
|
13
|
+
* The left "Clear all" button. Separate from {@link onSelectionChange} so the
|
|
14
|
+
* host can attach side effects (e.g. commit + close) rather than only clearing.
|
|
15
|
+
*/
|
|
16
|
+
onClearAll: () => void
|
|
17
|
+
/** Footer "Done" button. */
|
|
18
|
+
onDone: () => void
|
|
19
|
+
|
|
20
|
+
/** Controlled search term (kept in the host so the body stays SSR-testable). */
|
|
21
|
+
search: string
|
|
22
|
+
onSearchChange: (value: string) => void
|
|
23
|
+
|
|
24
|
+
/** Placeholder for the filter input, e.g. "Filter kinds". */
|
|
25
|
+
searchPlaceholder: string
|
|
26
|
+
/** Footer summary when nothing is selected, e.g. "All kinds". */
|
|
27
|
+
summaryEmptyLabel: string
|
|
28
|
+
/** List message when there are no items at all, e.g. "No kinds available." */
|
|
29
|
+
noItemsLabel: string
|
|
30
|
+
|
|
31
|
+
/** Disables the left "Clear all" button (nothing to clear / not permitted). */
|
|
32
|
+
clearAllDisabled?: boolean
|
|
33
|
+
clearAllAriaLabel?: string
|
|
34
|
+
/** Trailing per-item content (e.g. a "kubeconfig" tag). */
|
|
35
|
+
renderItemMeta?: (item: string) => ReactNode
|
|
36
|
+
/** Show the filter input only once the list is longer than this. Default 6. */
|
|
37
|
+
searchThreshold?: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Presentational multi-select picker body: filter input, Clear all / Select all
|
|
42
|
+
* row, a scrollable checkbox list, and a summary + Done footer. The shared core
|
|
43
|
+
* behind the namespace scope picker and the timeline Kinds filter — controlled
|
|
44
|
+
* (selection + search injected) so both hosts drive it their own way and it
|
|
45
|
+
* stays SSR-testable.
|
|
46
|
+
*/
|
|
47
|
+
export function MultiSelectPicker({
|
|
48
|
+
items,
|
|
49
|
+
selected,
|
|
50
|
+
onSelectionChange,
|
|
51
|
+
onClearAll,
|
|
52
|
+
onDone,
|
|
53
|
+
search,
|
|
54
|
+
onSearchChange,
|
|
55
|
+
searchPlaceholder,
|
|
56
|
+
summaryEmptyLabel,
|
|
57
|
+
noItemsLabel,
|
|
58
|
+
clearAllDisabled = false,
|
|
59
|
+
clearAllAriaLabel = 'Clear selection',
|
|
60
|
+
renderItemMeta,
|
|
61
|
+
searchThreshold = 6,
|
|
62
|
+
}: MultiSelectPickerProps) {
|
|
63
|
+
const filteredItems = useMemo(() => {
|
|
64
|
+
const q = search.trim().toLowerCase()
|
|
65
|
+
if (!q) return items
|
|
66
|
+
return items.filter(item => item.toLowerCase().includes(q))
|
|
67
|
+
}, [items, search])
|
|
68
|
+
|
|
69
|
+
const toggle = (item: string) => {
|
|
70
|
+
const next = new Set(selected)
|
|
71
|
+
if (next.has(item)) next.delete(item)
|
|
72
|
+
else next.add(item)
|
|
73
|
+
onSelectionChange(next)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const selectVisible = () => {
|
|
77
|
+
const next = new Set(selected)
|
|
78
|
+
for (const item of filteredItems) next.add(item)
|
|
79
|
+
onSelectionChange(next)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const clearVisible = () => {
|
|
83
|
+
const next = new Set(selected)
|
|
84
|
+
for (const item of filteredItems) next.delete(item)
|
|
85
|
+
onSelectionChange(next)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Counts computed against the visible (filtered) set so the bulk-action label
|
|
89
|
+
// matches what the button will affect.
|
|
90
|
+
const visibleSelectedCount = filteredItems.reduce((n, item) => n + (selected.has(item) ? 1 : 0), 0)
|
|
91
|
+
const allVisibleSelected = filteredItems.length > 0 && visibleSelectedCount === filteredItems.length
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<>
|
|
95
|
+
{items.length > searchThreshold && (
|
|
96
|
+
<div className="flex items-center gap-2 px-2 py-1.5 border-b border-theme-border">
|
|
97
|
+
<Search className="w-3.5 h-3.5 text-theme-text-tertiary" />
|
|
98
|
+
<input
|
|
99
|
+
autoFocus
|
|
100
|
+
value={search}
|
|
101
|
+
onChange={e => onSearchChange(e.target.value)}
|
|
102
|
+
placeholder={searchPlaceholder}
|
|
103
|
+
className="flex-1 bg-transparent text-sm outline-none text-theme-text-primary placeholder:text-theme-text-tertiary"
|
|
104
|
+
/>
|
|
105
|
+
</div>
|
|
106
|
+
)}
|
|
107
|
+
|
|
108
|
+
<div className="flex items-center justify-between px-2 py-1.5 border-b border-theme-border text-xs text-theme-text-secondary">
|
|
109
|
+
<button
|
|
110
|
+
onClick={clearAllDisabled ? undefined : onClearAll}
|
|
111
|
+
disabled={clearAllDisabled}
|
|
112
|
+
className="flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-theme-hover disabled:opacity-50 disabled:hover:bg-transparent"
|
|
113
|
+
aria-label={clearAllAriaLabel}
|
|
114
|
+
>
|
|
115
|
+
<X className="w-3 h-3" />
|
|
116
|
+
Clear all
|
|
117
|
+
</button>
|
|
118
|
+
<button
|
|
119
|
+
onClick={allVisibleSelected ? clearVisible : selectVisible}
|
|
120
|
+
disabled={filteredItems.length === 0}
|
|
121
|
+
className="px-1.5 py-0.5 rounded hover:bg-theme-hover disabled:opacity-50 disabled:hover:bg-transparent"
|
|
122
|
+
>
|
|
123
|
+
{allVisibleSelected
|
|
124
|
+
? `Clear ${filteredItems.length} visible`
|
|
125
|
+
: search.trim()
|
|
126
|
+
? `Select ${filteredItems.length} visible`
|
|
127
|
+
: 'Select all'}
|
|
128
|
+
</button>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
<ul className="max-h-80 overflow-y-auto py-1">
|
|
132
|
+
{filteredItems.length === 0 && (
|
|
133
|
+
<li className="px-3 py-2 text-xs text-theme-text-tertiary">
|
|
134
|
+
{search ? 'No matches.' : noItemsLabel}
|
|
135
|
+
</li>
|
|
136
|
+
)}
|
|
137
|
+
|
|
138
|
+
{filteredItems.map(item => (
|
|
139
|
+
<li key={item}>
|
|
140
|
+
<label className="w-full flex items-center justify-between px-3 py-1.5 text-sm hover:bg-theme-hover text-left text-theme-text-primary cursor-pointer">
|
|
141
|
+
<span className="flex items-center gap-2 min-w-0">
|
|
142
|
+
<input
|
|
143
|
+
type="checkbox"
|
|
144
|
+
checked={selected.has(item)}
|
|
145
|
+
onChange={() => toggle(item)}
|
|
146
|
+
className="shrink-0 accent-current"
|
|
147
|
+
/>
|
|
148
|
+
<span className="truncate">{item}</span>
|
|
149
|
+
{renderItemMeta?.(item)}
|
|
150
|
+
</span>
|
|
151
|
+
</label>
|
|
152
|
+
</li>
|
|
153
|
+
))}
|
|
154
|
+
</ul>
|
|
155
|
+
|
|
156
|
+
<div className="flex items-center justify-between px-3 py-1.5 border-t border-theme-border text-[11px] text-theme-text-tertiary">
|
|
157
|
+
<span>{selected.size === 0 ? summaryEmptyLabel : `${selected.size} selected`}</span>
|
|
158
|
+
<button
|
|
159
|
+
onClick={onDone}
|
|
160
|
+
className="px-2 py-0.5 rounded bg-theme-elevated hover:bg-theme-hover text-theme-text-primary"
|
|
161
|
+
>
|
|
162
|
+
Done
|
|
163
|
+
</button>
|
|
164
|
+
</div>
|
|
165
|
+
</>
|
|
166
|
+
)
|
|
167
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { assetUrl } from '../../utils/asset-url'
|
|
2
2
|
import radarLoadingIconAsset from '../../assets/radar/radar-icon-loading.svg'
|
|
3
|
+
import type { ReactNode } from 'react'
|
|
3
4
|
|
|
4
5
|
// assetUrl normalizes the bundler-specific asset-import type (string under Vite,
|
|
5
6
|
// StaticImageData under webpack/Next) to a URL string usable in `<img src>`.
|
|
@@ -18,9 +19,11 @@ const radarLoadingIcon = assetUrl(radarLoadingIconAsset)
|
|
|
18
19
|
export function PaneLoader({
|
|
19
20
|
label = 'Loading…',
|
|
20
21
|
className = '',
|
|
22
|
+
children,
|
|
21
23
|
}: {
|
|
22
24
|
label?: string
|
|
23
25
|
className?: string
|
|
26
|
+
children?: ReactNode
|
|
24
27
|
}) {
|
|
25
28
|
// No `relative` on the root: the label anchors to the inner `relative` span
|
|
26
29
|
// below, and callers may pass a positioning class (e.g. `absolute inset-0`,
|
|
@@ -35,8 +38,13 @@ export function PaneLoader({
|
|
|
35
38
|
primary) so the whole loading family — boot splash, connect splash,
|
|
36
39
|
PaneLoader — reads as one continuous state, not a font change at the
|
|
37
40
|
hand-off. */}
|
|
38
|
-
<span className="absolute left-1/2 top-full mt-3 -translate-x-1/2
|
|
39
|
-
{label}
|
|
41
|
+
<span className="absolute left-1/2 top-full mt-3 w-[min(24rem,calc(100vw-2rem))] -translate-x-1/2 text-center text-[17px] font-semibold tracking-tight text-theme-text-primary">
|
|
42
|
+
<span className="block whitespace-nowrap">{label}</span>
|
|
43
|
+
{children && (
|
|
44
|
+
<span className="block whitespace-normal">
|
|
45
|
+
{children}
|
|
46
|
+
</span>
|
|
47
|
+
)}
|
|
40
48
|
</span>
|
|
41
49
|
</span>
|
|
42
50
|
</div>
|
|
@@ -2,7 +2,7 @@ import { clsx } from 'clsx'
|
|
|
2
2
|
import { type ReactNode } from 'react'
|
|
3
3
|
import { Tooltip } from './Tooltip'
|
|
4
4
|
|
|
5
|
-
type ColorScheme = 'utilization' | 'count'
|
|
5
|
+
type ColorScheme = 'utilization' | 'count' | 'quiet'
|
|
6
6
|
|
|
7
7
|
interface ResourceBarProps {
|
|
8
8
|
/** Formatted usage string (e.g., "847m", "2.1Gi", "17") */
|
|
@@ -11,50 +11,93 @@ interface ResourceBarProps {
|
|
|
11
11
|
total: string
|
|
12
12
|
/** Usage percentage (0–100) */
|
|
13
13
|
percent: number
|
|
14
|
-
/** Color scheme:
|
|
14
|
+
/** Color scheme:
|
|
15
|
+
* - "utilization": green/yellow/red — node/workload detail surfaces
|
|
16
|
+
* - "count": blue/yellow
|
|
17
|
+
* - "quiet": dashboard-calibrated — brand fill, amber only at genuine
|
|
18
|
+
* scheduling-pressure levels (≥90%), never red. High utilization is a
|
|
19
|
+
* bin-packing goal, not an incident; alarming it trains amber-blindness. */
|
|
15
20
|
colorScheme?: ColorScheme
|
|
16
|
-
/** Optional marker line position (0–100), e.g.,
|
|
21
|
+
/** Optional marker line position (0–100), e.g., requests as % of allocatable.
|
|
22
|
+
* Under "quiet" the marker itself turns amber at ≥95% — a cluster whose
|
|
23
|
+
* requests are at capacity is full to the scheduler even when usage is low. */
|
|
17
24
|
markerPercent?: number
|
|
18
25
|
/** Optional tooltip content shown on hover */
|
|
19
26
|
tooltip?: ReactNode
|
|
27
|
+
/** Layout: "stacked" (default — numbers row above the track) or "inline"
|
|
28
|
+
* (one row: label · track · percent) for dense cards. Inline requires
|
|
29
|
+
* `label` and omits the used/total numbers row — put them in `tooltip`. */
|
|
30
|
+
layout?: 'stacked' | 'inline'
|
|
31
|
+
/** Short leading label for the inline layout (e.g. "CPU", "MEM"). */
|
|
32
|
+
label?: string
|
|
20
33
|
}
|
|
21
34
|
|
|
22
35
|
function getBarColor(percent: number, scheme: ColorScheme): string {
|
|
23
36
|
if (scheme === 'count') {
|
|
24
37
|
return percent > 90 ? 'bg-yellow-500' : 'bg-blue-500'
|
|
25
38
|
}
|
|
39
|
+
if (scheme === 'quiet') {
|
|
40
|
+
return percent >= 90 ? 'bg-amber-500' : 'bg-emerald-400'
|
|
41
|
+
}
|
|
26
42
|
if (percent > 85) return 'bg-red-500'
|
|
27
43
|
if (percent > 60) return 'bg-yellow-500'
|
|
28
44
|
return 'bg-green-500'
|
|
29
45
|
}
|
|
30
46
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
47
|
+
function getMarkerColor(markerPercent: number, scheme: ColorScheme): string {
|
|
48
|
+
if (scheme === 'quiet' && markerPercent >= 95) return 'bg-amber-500'
|
|
49
|
+
return 'bg-theme-text-primary/70'
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function ResourceBar({
|
|
53
|
+
used,
|
|
54
|
+
total,
|
|
55
|
+
percent,
|
|
56
|
+
colorScheme = 'utilization',
|
|
57
|
+
markerPercent,
|
|
58
|
+
tooltip,
|
|
59
|
+
layout = 'stacked',
|
|
60
|
+
label,
|
|
61
|
+
}: ResourceBarProps) {
|
|
62
|
+
const track = (
|
|
63
|
+
<div className={clsx('relative', layout === 'inline' && 'min-w-0 flex-1')}>
|
|
64
|
+
<div className={clsx('rounded-full border border-theme-border bg-theme-elevated overflow-hidden', layout === 'inline' ? 'h-1' : 'h-1.5')}>
|
|
65
|
+
<div
|
|
66
|
+
className={clsx('h-full rounded-full transition-[width] duration-300 ease-out', getBarColor(percent, colorScheme))}
|
|
67
|
+
style={{ width: `${Math.min(percent, 100)}%` }}
|
|
68
|
+
/>
|
|
69
|
+
</div>
|
|
70
|
+
{markerPercent != null && markerPercent > 0 && markerPercent <= 100 && (
|
|
71
|
+
<div
|
|
72
|
+
className={clsx('absolute -top-[1px] h-[calc(100%+2px)] w-[2px]', getMarkerColor(markerPercent, colorScheme))}
|
|
73
|
+
style={{ left: `${markerPercent}%` }}
|
|
74
|
+
/>
|
|
75
|
+
)}
|
|
76
|
+
</div>
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
const bar =
|
|
80
|
+
layout === 'inline' ? (
|
|
81
|
+
<div className="flex items-center gap-1.5 min-w-0">
|
|
82
|
+
<span className="w-7 shrink-0 text-[10px] font-medium uppercase tracking-wide text-theme-text-tertiary">{label}</span>
|
|
83
|
+
{track}
|
|
84
|
+
<span className="w-8 shrink-0 text-right text-[10.5px] font-mono tabular-nums text-theme-text-secondary">
|
|
39
85
|
{Math.round(percent)}%
|
|
40
86
|
</span>
|
|
41
87
|
</div>
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
88
|
+
) : (
|
|
89
|
+
<div className="flex flex-col gap-0.5 min-w-0">
|
|
90
|
+
<div className="flex items-baseline justify-between gap-1">
|
|
91
|
+
<span className="text-xs font-mono text-theme-text-secondary truncate">
|
|
92
|
+
{used} / {total}
|
|
93
|
+
</span>
|
|
94
|
+
<span className="text-[10px] font-mono text-theme-text-tertiary shrink-0">
|
|
95
|
+
{Math.round(percent)}%
|
|
96
|
+
</span>
|
|
48
97
|
</div>
|
|
49
|
-
{
|
|
50
|
-
<div
|
|
51
|
-
className="absolute -top-[1px] h-[calc(100%+2px)] w-[2px] bg-theme-text-primary/70"
|
|
52
|
-
style={{ left: `${markerPercent}%` }}
|
|
53
|
-
/>
|
|
54
|
-
)}
|
|
98
|
+
{track}
|
|
55
99
|
</div>
|
|
56
|
-
|
|
57
|
-
)
|
|
100
|
+
)
|
|
58
101
|
|
|
59
102
|
if (tooltip) {
|
|
60
103
|
return (
|