@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.14.7",
3
+ "version": "1.14.8",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -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
- <span className="truncate">{getSubtitle(node)}</span>
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
- function getSubtitle(node: GitOpsTreeNode): string {
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
- if (node.sync || node.health) {
768
- return [node.sync, node.health].filter(Boolean).join(' • ')
769
- }
770
- if (node.info?.[0]?.value) return node.info[0].value
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
- function servicePortsTooltip(ports: ServicePortEntry[]): React.ReactNode | null {
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'
@@ -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 > * + * {