@skyhook-io/k8s-ui 1.14.7 → 1.14.8
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 +1 -1
- package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +92 -0
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +36 -6
- package/src/components/topology/K8sResourceNode.tsx +4 -2
- package/src/components/ui/Collapse.test.tsx +0 -13
- package/src/components/ui/Disclosure.test.tsx +26 -0
- package/src/components/ui/Disclosure.tsx +69 -0
- package/src/components/ui/index.ts +1 -0
- package/src/theme/components.css +0 -22
package/package.json
CHANGED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { getSubtitle } from './GitOpsTreeGraph'
|
|
4
|
+
import type { GitOpsTreeNode } from '../../../types'
|
|
5
|
+
|
|
6
|
+
function node(extras: Partial<GitOpsTreeNode> = {}): GitOpsTreeNode {
|
|
7
|
+
return {
|
|
8
|
+
id: 'node-1',
|
|
9
|
+
ref: { kind: 'Service', name: 'podinfo', namespace: 'demo-flux' },
|
|
10
|
+
role: 'declared',
|
|
11
|
+
tool: 'fluxcd',
|
|
12
|
+
...extras,
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('getSubtitle', () => {
|
|
17
|
+
it('shows what a healthy resource exposes, not that it is healthy', () => {
|
|
18
|
+
// The status dot and the left border stripe already paint a healthy node
|
|
19
|
+
// green, so the word adds nothing and the ports are the only thing here the
|
|
20
|
+
// rest of the card can't say.
|
|
21
|
+
expect(getSubtitle(node({
|
|
22
|
+
sync: 'Synced',
|
|
23
|
+
health: 'Healthy',
|
|
24
|
+
info: [{ name: 'Service', value: 'ClusterIP :9898 +1 more' }],
|
|
25
|
+
}))).toBe('Synced • ClusterIP :9898 +1 more')
|
|
26
|
+
|
|
27
|
+
expect(getSubtitle(node({
|
|
28
|
+
ref: { kind: 'Pod', name: 'podinfo-6b4f8c9d7-xk2mv', namespace: 'demo-flux' },
|
|
29
|
+
role: 'generated',
|
|
30
|
+
health: 'Healthy',
|
|
31
|
+
info: [{ name: 'Phase', value: 'Running' }],
|
|
32
|
+
}))).toBe('Running')
|
|
33
|
+
|
|
34
|
+
expect(getSubtitle(node({
|
|
35
|
+
ref: { kind: 'Ingress', name: 'podinfo', namespace: 'demo-flux' },
|
|
36
|
+
sync: 'Synced',
|
|
37
|
+
health: 'Healthy',
|
|
38
|
+
info: [{ name: 'Host', value: 'podinfo.example.com' }],
|
|
39
|
+
}))).toBe('Synced • podinfo.example.com')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('keeps every health value the stripe colour cannot identify on its own', () => {
|
|
43
|
+
// Progressing and Suspended are both yellow, Degraded and Missing both red.
|
|
44
|
+
// Drop the word and the two become indistinguishable.
|
|
45
|
+
expect(getSubtitle(node({
|
|
46
|
+
sync: 'Synced',
|
|
47
|
+
health: 'Progressing',
|
|
48
|
+
info: [{ name: 'Service', value: 'LoadBalancer :80' }],
|
|
49
|
+
}))).toBe('Synced • Progressing • LoadBalancer :80')
|
|
50
|
+
|
|
51
|
+
expect(getSubtitle(node({
|
|
52
|
+
ref: { kind: 'Deployment', name: 'podinfo', namespace: 'demo-flux' },
|
|
53
|
+
sync: 'OutOfSync',
|
|
54
|
+
health: 'Degraded',
|
|
55
|
+
info: [{ name: 'Ready', value: '2/3' }],
|
|
56
|
+
}))).toBe('OutOfSync • Degraded • 2/3')
|
|
57
|
+
|
|
58
|
+
expect(getSubtitle(node({
|
|
59
|
+
health: 'Unknown',
|
|
60
|
+
info: [{ name: 'Service', value: 'ClusterIP :80' }],
|
|
61
|
+
}))).toBe('Unknown • ClusterIP :80')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('still reads as Healthy when there is nothing to say instead', () => {
|
|
65
|
+
// Kinds infoFromTopology doesn't cover, and remote Argo destinations, reach
|
|
66
|
+
// the tree with no info line at all. Those must not lose their status line.
|
|
67
|
+
expect(getSubtitle(node({ sync: 'Synced', health: 'Healthy' }))).toBe('Synced • Healthy')
|
|
68
|
+
expect(getSubtitle(node({ health: 'Healthy' }))).toBe('Healthy')
|
|
69
|
+
expect(getSubtitle(node({ sync: 'OutOfSync', health: 'Missing' }))).toBe('OutOfSync • Missing')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('lets lifecycle and grouping own the line outright', () => {
|
|
73
|
+
expect(getSubtitle(node({
|
|
74
|
+
role: 'group',
|
|
75
|
+
sync: 'Synced',
|
|
76
|
+
health: 'Healthy',
|
|
77
|
+
info: [{ name: 'Phase', value: 'Running' }],
|
|
78
|
+
}))).toBe('Click to expand')
|
|
79
|
+
|
|
80
|
+
expect(getSubtitle(node({
|
|
81
|
+
sync: 'Synced',
|
|
82
|
+
health: 'Healthy',
|
|
83
|
+
info: [{ name: 'Service', value: 'ClusterIP :9898 +1 more' }],
|
|
84
|
+
data: { deletionTimestamp: '2026-09-11T13:09:43Z' },
|
|
85
|
+
}))).toBe('Pending deletion')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('falls back to the namespace only when the node states nothing else', () => {
|
|
89
|
+
expect(getSubtitle(node())).toBe('demo-flux')
|
|
90
|
+
expect(getSubtitle(node({ ref: { kind: 'ClusterRole', name: 'podinfo', namespace: '' } }))).toBe('')
|
|
91
|
+
})
|
|
92
|
+
})
|
|
@@ -25,6 +25,7 @@ import { displayKind } from '../../../types'
|
|
|
25
25
|
import { healthToSeverity, SEVERITY_DOT } from '../../../utils/badge-colors'
|
|
26
26
|
import { formatCompactAge } from '../../../utils/format'
|
|
27
27
|
import { radarHealthNote } from '../health-provenance'
|
|
28
|
+
import { servicePortsTooltip, type ServicePortEntry } from '../../topology/K8sResourceNode'
|
|
28
29
|
import { getTopologyIcon } from '../../../utils/resource-icons'
|
|
29
30
|
import { Tooltip } from '../../ui/Tooltip'
|
|
30
31
|
import { hasGitOpsTreeFilters, matchesGitOpsTreeFilters, type GitOpsTreeFilters } from './tree-helpers'
|
|
@@ -592,6 +593,15 @@ const GitOpsResourceNode = memo(function GitOpsResourceNode({ data }: NodeProps<
|
|
|
592
593
|
// (ReplicaSets, Pods) are always Radar's read; marking each would be
|
|
593
594
|
// noise.
|
|
594
595
|
const radarNote = node.role === 'declared' ? radarHealthNote(node) : ''
|
|
596
|
+
// A Service's subtitle names its first port and counts the rest, so without a
|
|
597
|
+
// hover the ones behind "+1 more" can't be reached from the graph at all. Same
|
|
598
|
+
// affordance the topology graph's Service node carries, same renderer. Stood
|
|
599
|
+
// down when the card already explains an unhealthy verdict: that reason is
|
|
600
|
+
// what the hover should say, and only one tooltip is ever visible anyway.
|
|
601
|
+
const portsTooltip = kind === 'Service' && !cause
|
|
602
|
+
? servicePortsTooltip((node.data?.ports as ServicePortEntry[] | undefined) ?? [])
|
|
603
|
+
: null
|
|
604
|
+
const subtitle = getSubtitle(node)
|
|
595
605
|
|
|
596
606
|
const card = (
|
|
597
607
|
<div
|
|
@@ -654,7 +664,13 @@ const GitOpsResourceNode = memo(function GitOpsResourceNode({ data }: NodeProps<
|
|
|
654
664
|
own page". The subtitle text alone wasn't enough — users were
|
|
655
665
|
treating the count as an immutable fact rather than a button. */}
|
|
656
666
|
<div className="mt-0.5 flex items-center gap-1 text-xs text-theme-text-secondary">
|
|
657
|
-
|
|
667
|
+
{portsTooltip ? (
|
|
668
|
+
<Tooltip content={portsTooltip} position="bottom" wrapperClassName="min-w-0">
|
|
669
|
+
<span className="cursor-help truncate">{subtitle}</span>
|
|
670
|
+
</Tooltip>
|
|
671
|
+
) : (
|
|
672
|
+
<span className="truncate">{subtitle}</span>
|
|
673
|
+
)}
|
|
658
674
|
{(node.role === 'group' || gitopsTool) && <ChevronRight className="ml-auto h-3 w-3 shrink-0 text-theme-text-tertiary" />}
|
|
659
675
|
</div>
|
|
660
676
|
{chips.length > 0 && (
|
|
@@ -755,7 +771,21 @@ function buildChips(node: GitOpsTreeNode): Array<{ label?: string; value: string
|
|
|
755
771
|
return chips
|
|
756
772
|
}
|
|
757
773
|
|
|
758
|
-
|
|
774
|
+
// The subtitle is the node's one line of prose, and sync, health and the
|
|
775
|
+
// backend's info line all want it. It can't be won on precedence: the backend
|
|
776
|
+
// derives a health for every node it can build an info line for, so a rule that
|
|
777
|
+
// only reaches info when health is absent never reaches it at all, and a
|
|
778
|
+
// Service's ports, a Pod's phase and an Ingress's host stay invisible.
|
|
779
|
+
//
|
|
780
|
+
// So all three share the line, and "Healthy" is the part that yields when the
|
|
781
|
+
// space is contested. It is the one health value the node already states without
|
|
782
|
+
// words — healthToTopology maps it onto the green status dot and the green left
|
|
783
|
+
// stripe, one value to one colour. Progressing and Suspended are both yellow and
|
|
784
|
+
// Degraded and Missing are both red, so for those the word is the only thing
|
|
785
|
+
// that tells them apart and it keeps its place. "Healthy" gives up the line only
|
|
786
|
+
// when there is a concrete info line to spend it on, so a node with nothing to
|
|
787
|
+
// say instead still reads as Healthy.
|
|
788
|
+
export function getSubtitle(node: GitOpsTreeNode): string {
|
|
759
789
|
if (node.role === 'group') {
|
|
760
790
|
// Action-oriented copy invites the click; "collapsed" alone reads as
|
|
761
791
|
// a state description, not an affordance.
|
|
@@ -764,10 +794,10 @@ function getSubtitle(node: GitOpsTreeNode): string {
|
|
|
764
794
|
if (isNodeTerminating(node)) {
|
|
765
795
|
return 'Pending deletion'
|
|
766
796
|
}
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
if (
|
|
797
|
+
const info = node.info?.[0]?.value
|
|
798
|
+
const health = node.health === 'Healthy' && info ? undefined : node.health
|
|
799
|
+
const parts = [node.sync, health, info].filter(Boolean)
|
|
800
|
+
if (parts.length > 0) return parts.join(' • ')
|
|
771
801
|
return node.ref.namespace || ''
|
|
772
802
|
}
|
|
773
803
|
|
|
@@ -173,8 +173,10 @@ function getIssueTooltip(issue: string | undefined): React.ReactNode {
|
|
|
173
173
|
// a multi-port Service's other ports are visible without opening the detail
|
|
174
174
|
// page. Formatting (hide targetPort when it matches port) mirrors
|
|
175
175
|
// ServicePortCards in ServiceRenderer.tsx so a Service's ports read the same
|
|
176
|
-
// whether glanced at in the graph or opened in the full resource view.
|
|
177
|
-
|
|
176
|
+
// whether glanced at in the graph or opened in the full resource view. Exported
|
|
177
|
+
// because the GitOps resource tree's Service node summarises ports the same way
|
|
178
|
+
// and needs the same hover to reach the ones the summary drops.
|
|
179
|
+
export function servicePortsTooltip(ports: ServicePortEntry[]): React.ReactNode | null {
|
|
178
180
|
if (ports.length < 2) return null;
|
|
179
181
|
return (
|
|
180
182
|
<div className="max-w-xs space-y-0.5">
|
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
import { renderToString } from 'react-dom/server'
|
|
3
|
-
import { readFileSync } from 'fs'
|
|
4
|
-
import { join } from 'path'
|
|
5
3
|
import { Collapse, CollapseChevron, disclosurePanelId } from './Collapse'
|
|
6
4
|
import {
|
|
7
|
-
CSS_EASE,
|
|
8
5
|
DURATION_DISCLOSURE,
|
|
9
6
|
TRANSITION_CHEVRON,
|
|
10
7
|
TRANSITION_DISCLOSURE,
|
|
@@ -62,13 +59,3 @@ describe('CollapseChevron', () => {
|
|
|
62
59
|
expect(html).toContain(`duration-${DURATION_DISCLOSURE}`)
|
|
63
60
|
})
|
|
64
61
|
})
|
|
65
|
-
|
|
66
|
-
describe('motion tokens stay in step with the stylesheet', () => {
|
|
67
|
-
it('.issue-details-motion mirrors DURATION_DISCLOSURE and CSS_EASE', () => {
|
|
68
|
-
const css = readFileSync(join(__dirname, '../../theme/components.css'), 'utf8')
|
|
69
|
-
const m = css.match(/\.issue-details-motion \{[^}]*transition: grid-template-rows (\d+)ms ([^;]+);/)
|
|
70
|
-
expect(m, 'legacy class present').not.toBeNull()
|
|
71
|
-
expect(Number(m![1])).toBe(DURATION_DISCLOSURE)
|
|
72
|
-
expect(m![2].replace(/\s+/g, '')).toBe(CSS_EASE.replace(/\s+/g, ''))
|
|
73
|
-
})
|
|
74
|
-
})
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import { Disclosure } from './Disclosure'
|
|
4
|
+
|
|
5
|
+
describe('Disclosure', () => {
|
|
6
|
+
it('wires the header to the panel and keeps closed content mounted', () => {
|
|
7
|
+
const html = renderToString(<Disclosure summary="More">hidden-copy</Disclosure>)
|
|
8
|
+
expect(html).toContain('aria-expanded="false"')
|
|
9
|
+
const id = html.match(/aria-controls="([^"]+)"/)?.[1]
|
|
10
|
+
expect(id).toBeTruthy()
|
|
11
|
+
expect(html).toContain(`id="${id}"`)
|
|
12
|
+
expect(html).toContain('hidden-copy')
|
|
13
|
+
expect(html).toMatch(/inert=""|inert>/)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('honours defaultOpen and a controlled open', () => {
|
|
17
|
+
expect(renderToString(<Disclosure summary="s" defaultOpen>x</Disclosure>)).toContain('aria-expanded="true"')
|
|
18
|
+
expect(renderToString(<Disclosure summary="s" open onOpenChange={() => {}}>x</Disclosure>)).toContain('aria-expanded="true"')
|
|
19
|
+
expect(renderToString(<Disclosure summary="s" open={false} defaultOpen>x</Disclosure>)).toContain('aria-expanded="false"')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('caret follows the header color unless pinned', () => {
|
|
23
|
+
expect(renderToString(<Disclosure summary="s">x</Disclosure>)).not.toContain('text-theme-text-tertiary')
|
|
24
|
+
expect(renderToString(<Disclosure summary="s" inheritColor={false}>x</Disclosure>)).toContain('text-theme-text-tertiary')
|
|
25
|
+
})
|
|
26
|
+
})
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { useState, type ReactNode } from 'react'
|
|
2
|
+
import { clsx } from 'clsx'
|
|
3
|
+
import { Collapse, CollapseChevron, useDisclosure } from './Collapse'
|
|
4
|
+
|
|
5
|
+
// Disclosure — in-flow expand/collapse with the app-standard motion; the
|
|
6
|
+
// replacement for native <details>/<summary> on primary paths. Native details
|
|
7
|
+
// snaps open with no transition and its marker ignores the caret language;
|
|
8
|
+
// this pairs a button header (aria-expanded / aria-controls via useDisclosure)
|
|
9
|
+
// with CollapseChevron and an animated Collapse panel. Shared by Radar and
|
|
10
|
+
// Radar Cloud so both open alike.
|
|
11
|
+
//
|
|
12
|
+
// Content stays mounted while closed (Collapse's default), exactly like native
|
|
13
|
+
// details — server-rendered markup and in-page search still see the collapsed
|
|
14
|
+
// copy. Sites whose panel polls or holds an editor should use
|
|
15
|
+
// <Collapse unmountOnExit> directly instead.
|
|
16
|
+
//
|
|
17
|
+
// Uncontrolled by default (`defaultOpen`); pass `open` + `onOpenChange` to
|
|
18
|
+
// control it from above (a form that keys other state on the disclosure).
|
|
19
|
+
// The chevron takes currentColor from the header by default: a summary's
|
|
20
|
+
// caret should read as part of its text, including on hover.
|
|
21
|
+
export function Disclosure({
|
|
22
|
+
summary,
|
|
23
|
+
children,
|
|
24
|
+
className,
|
|
25
|
+
summaryClassName,
|
|
26
|
+
chevronClassName = 'h-3.5 w-3.5',
|
|
27
|
+
inheritColor = true,
|
|
28
|
+
defaultOpen = false,
|
|
29
|
+
open: controlledOpen,
|
|
30
|
+
onOpenChange,
|
|
31
|
+
}: {
|
|
32
|
+
summary: ReactNode
|
|
33
|
+
children: ReactNode
|
|
34
|
+
/** Classes for the wrapper around header and panel. */
|
|
35
|
+
className?: string
|
|
36
|
+
/** Classes for the header button (layout, size and color of the summary). */
|
|
37
|
+
summaryClassName?: string
|
|
38
|
+
chevronClassName?: string
|
|
39
|
+
/** False pins the caret to the tertiary grey regardless of the header's color. */
|
|
40
|
+
inheritColor?: boolean
|
|
41
|
+
defaultOpen?: boolean
|
|
42
|
+
open?: boolean
|
|
43
|
+
onOpenChange?: (open: boolean) => void
|
|
44
|
+
}) {
|
|
45
|
+
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen)
|
|
46
|
+
const open = controlledOpen ?? uncontrolledOpen
|
|
47
|
+
const disclosure = useDisclosure(open)
|
|
48
|
+
const toggle = () => {
|
|
49
|
+
const next = !open
|
|
50
|
+
if (controlledOpen === undefined) setUncontrolledOpen(next)
|
|
51
|
+
onOpenChange?.(next)
|
|
52
|
+
}
|
|
53
|
+
return (
|
|
54
|
+
<div className={className}>
|
|
55
|
+
<button
|
|
56
|
+
type="button"
|
|
57
|
+
{...disclosure.buttonProps}
|
|
58
|
+
onClick={toggle}
|
|
59
|
+
className={clsx('flex w-full cursor-pointer select-none items-center gap-1.5 text-left', summaryClassName)}
|
|
60
|
+
>
|
|
61
|
+
<CollapseChevron open={open} inheritColor={inheritColor} className={chevronClassName} />
|
|
62
|
+
{summary}
|
|
63
|
+
</button>
|
|
64
|
+
<Collapse open={open} id={disclosure.panelId}>
|
|
65
|
+
{children}
|
|
66
|
+
</Collapse>
|
|
67
|
+
</div>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
@@ -27,6 +27,7 @@ export { HealthRing } from './HealthRing'
|
|
|
27
27
|
export { MetricsChart, MetricsSparkline } from './MetricsChart'
|
|
28
28
|
export * from './drawer-components'
|
|
29
29
|
export { Collapse, CollapseChevron, useDisclosure, disclosurePanelId } from './Collapse'
|
|
30
|
+
export { Disclosure } from './Disclosure'
|
|
30
31
|
export { ResourceBar } from './ResourceBar'
|
|
31
32
|
export { ForceDeleteConfirmDialog } from './ForceDeleteConfirmDialog'
|
|
32
33
|
export { InClusterConsentDialog } from './InClusterConsentDialog'
|
package/src/theme/components.css
CHANGED
|
@@ -142,28 +142,6 @@
|
|
|
142
142
|
padding: 0.75rem;
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
-
/* ── ISSUE ROW DETAILS ── */
|
|
146
|
-
|
|
147
|
-
/* Legacy: prefer <Collapse unmountOnExit> (ui/Collapse.tsx). Kept for any
|
|
148
|
-
straggler; timing mirrors DURATION_DISCLOSURE / CSS_EASE in
|
|
149
|
-
utils/animation.ts — the source-hygiene test keeps them equal. */
|
|
150
|
-
.issue-details-motion {
|
|
151
|
-
display: grid;
|
|
152
|
-
grid-template-rows: 0fr;
|
|
153
|
-
overflow: hidden;
|
|
154
|
-
transition: grid-template-rows 300ms cubic-bezier(0.32, 0.72, 0, 1);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
.issue-details-motion-open {
|
|
158
|
-
grid-template-rows: 1fr;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
@starting-style {
|
|
162
|
-
.issue-details-motion-open {
|
|
163
|
-
grid-template-rows: 0fr;
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
145
|
/* ── SUBTLE BORDERS ── */
|
|
168
146
|
|
|
169
147
|
.table-divide-subtle > * + * {
|