@skyhook-io/k8s-ui 1.14.5 → 1.14.7
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 -5
- package/src/components/applications/ApplicationDetail.tsx +36 -4
- package/src/components/applications/ApplicationsView.tsx +2 -1
- package/src/components/audit/AuditAlerts.tsx +35 -37
- package/src/components/audit/AuditCard.tsx +18 -7
- package/src/components/audit/AuditCoveragePresentation.test.tsx +45 -0
- package/src/components/audit/AuditFindingsTable.tsx +23 -27
- package/src/components/charts/AreaChart.test.tsx +13 -0
- package/src/components/charts/AreaChart.tsx +28 -25
- package/src/components/charts/PrometheusChartsView.tsx +41 -10
- package/src/components/charts/axis.test.ts +16 -0
- package/src/components/charts/axis.ts +3 -1
- package/src/components/charts/format.ts +4 -0
- package/src/components/charts/nearestSample.test.ts +31 -0
- package/src/components/charts/nearestSample.ts +15 -0
- package/src/components/checks/ChecksView.tsx +76 -28
- package/src/components/gitops/GitOpsDetailLayout.tsx +25 -9
- package/src/components/gitops/GitOpsHealthSourceNotice.tsx +141 -0
- package/src/components/gitops/health-provenance.test.ts +54 -0
- package/src/components/gitops/health-provenance.ts +24 -0
- package/src/components/gitops/index.ts +2 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +40 -21
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +59 -8
- package/src/components/gitops/tree/merge.test.ts +34 -0
- package/src/components/gitops/tree/merge.ts +7 -0
- package/src/components/issues/IssuesView.tsx +35 -54
- package/src/components/logs/LogCore.tsx +40 -9
- package/src/components/resources/ResourcesSidebar.tsx +66 -78
- package/src/components/resources/ResourcesView.tsx +67 -2
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/renderers/AnalysisTemplateRenderer.tsx +150 -0
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +21 -15
- package/src/components/resources/renderers/CompositionRenderer.tsx +30 -10
- package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +16 -12
- package/src/components/resources/renderers/ConfigMapRenderer.tsx +8 -1
- package/src/components/resources/renderers/ExperimentRenderer.tsx +179 -0
- package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +10 -4
- package/src/components/resources/renderers/GenericRenderer.tsx +13 -7
- package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +6 -4
- package/src/components/resources/renderers/LimitRangeRenderer.test.tsx +156 -0
- package/src/components/resources/renderers/LimitRangeRenderer.tsx +277 -0
- package/src/components/resources/renderers/NamespaceRenderer.tsx +24 -1
- package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +9 -16
- package/src/components/resources/renderers/NetworkPolicyRenderer.test.tsx +75 -0
- package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +50 -36
- package/src/components/resources/renderers/PodRenderer.tsx +11 -0
- package/src/components/resources/renderers/PolicyCoverageSection.tsx +7 -4
- package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +13 -15
- package/src/components/resources/renderers/ReflectorSection.test.tsx +281 -0
- package/src/components/resources/renderers/ReflectorSection.tsx +296 -0
- package/src/components/resources/renderers/RolloutRenderer.test.tsx +117 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +219 -56
- package/src/components/resources/renderers/SbomReportRenderer.tsx +10 -4
- package/src/components/resources/renderers/SecretRenderer.tsx +10 -3
- package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +10 -4
- package/src/components/resources/renderers/WorkloadRenderer.tsx +11 -1
- package/src/components/resources/renderers/index.ts +3 -0
- package/src/components/resources/renderers/rollout/CanaryStepTimeline.tsx +199 -0
- package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +123 -0
- package/src/components/resources/resource-utils.ts +18 -9
- package/src/components/shared/CreateResourceDialog.tsx +237 -14
- package/src/components/shared/DrainPlanDialog.test.tsx +154 -0
- package/src/components/shared/DrainPlanDialog.tsx +297 -0
- package/src/components/shared/ResourceActionsBar.tsx +53 -26
- package/src/components/shared/ResourceRendererDispatch.test.tsx +45 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +27 -6
- package/src/components/shared/index.ts +4 -0
- package/src/components/timeline/TimelineList.tsx +14 -11
- package/src/components/timeline/TimelineStrip.tsx +26 -4
- package/src/components/timeline/TimelineSwimlanes.tsx +3 -5
- package/src/components/timeline/TimelineToolbar.tsx +43 -10
- package/src/components/topology/TopologyControls.tsx +27 -8
- package/src/components/topology/TopologyGraph.tsx +14 -5
- package/src/components/trace/ReachabilityView.tsx +17 -16
- package/src/components/ui/CardSection.tsx +79 -35
- package/src/components/ui/Collapse.test.tsx +74 -0
- package/src/components/ui/Collapse.tsx +115 -29
- package/src/components/ui/DialogPortal.tsx +7 -2
- package/src/components/ui/FetchResult.test.tsx +20 -2
- package/src/components/ui/FetchResult.tsx +33 -3
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +7 -4
- package/src/components/ui/FreshnessControl.tsx +8 -2
- package/src/components/ui/RestrictedState.tsx +7 -4
- package/src/components/ui/RowActionMenu.tsx +20 -8
- package/src/components/ui/SelectMenu.tsx +18 -6
- package/src/components/ui/YamlEditor.tsx +7 -9
- package/src/components/ui/drawer-components.tsx +14 -21
- package/src/components/ui/index.ts +30 -22
- package/src/components/workload/WorkloadView.tsx +8 -12
- package/src/hooks/useAnimatedUnmount.ts +21 -3
- package/src/theme/components.css +20 -1
- package/src/types/core.ts +15 -1
- package/src/types/gitops-insights.ts +23 -0
- package/src/types/gitops-tree.ts +29 -0
- package/src/utils/animation.ts +93 -13
- package/src/utils/index.ts +1 -0
- package/src/utils/navigation.test.ts +20 -1
- package/src/utils/navigation.ts +25 -0
- package/src/utils/network-policy.test.ts +42 -0
- package/src/utils/network-policy.ts +59 -0
- package/src/utils/reflector.ts +139 -0
- package/src/utils/yaml-file-import.test.ts +189 -0
- package/src/utils/yaml-file-import.ts +95 -0
- package/src/components/logs/JsonLogLine.tsx +0 -93
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Fragment, type ComponentType, type ReactNode } from
|
|
1
|
+
import { Fragment, type ComponentType, type ReactNode } from "react";
|
|
2
2
|
|
|
3
3
|
// =============================================================================
|
|
4
4
|
// CARD SECTION PRIMITIVES — the shared "Sectioned + icons" vocabulary used by
|
|
@@ -12,106 +12,150 @@ import { Fragment, type ComponentType, type ReactNode } from 'react'
|
|
|
12
12
|
|
|
13
13
|
// Section role → eyebrow + icon hue. Body text always uses the secondary token
|
|
14
14
|
// (the contrast floor), regardless of tone.
|
|
15
|
-
export type CardSectionTone =
|
|
15
|
+
export type CardSectionTone = "neutral" | "warn" | "fix";
|
|
16
16
|
|
|
17
17
|
const SECTION_TONE_CLASS: Record<CardSectionTone, string> = {
|
|
18
18
|
// Eyebrow grey — the default, for neutral sections (Why it matters, Raw error,
|
|
19
19
|
// Affected resources, Context, Subject).
|
|
20
|
-
neutral:
|
|
20
|
+
neutral: "text-theme-text-tertiary",
|
|
21
21
|
// What's wrong — amber, the diagnosis beat.
|
|
22
|
-
warn:
|
|
22
|
+
warn: "text-amber-700 dark:text-amber-300",
|
|
23
23
|
// Next step / How to fix — emerald, the action beat.
|
|
24
|
-
fix:
|
|
25
|
-
}
|
|
24
|
+
fix: "text-emerald-700 dark:text-emerald-400",
|
|
25
|
+
};
|
|
26
26
|
|
|
27
27
|
// The single eyebrow style: 11px / 600 / uppercase, with an icon in a fixed
|
|
28
28
|
// gutter so every section's body aligns under the same left edge.
|
|
29
29
|
export function CardSection({
|
|
30
30
|
icon: Icon,
|
|
31
31
|
label,
|
|
32
|
-
tone =
|
|
32
|
+
tone = "neutral",
|
|
33
33
|
labelExtra,
|
|
34
34
|
children,
|
|
35
35
|
}: {
|
|
36
|
-
icon: ComponentType<{ className?: string }
|
|
37
|
-
label: string
|
|
38
|
-
tone?: CardSectionTone
|
|
36
|
+
icon: ComponentType<{ className?: string }>;
|
|
37
|
+
label: string;
|
|
38
|
+
tone?: CardSectionTone;
|
|
39
39
|
/** Optional trailing eyebrow text (e.g. "· retried 5×"), rendered muted. */
|
|
40
|
-
labelExtra?: ReactNode
|
|
41
|
-
children: ReactNode
|
|
40
|
+
labelExtra?: ReactNode;
|
|
41
|
+
children: ReactNode;
|
|
42
42
|
}) {
|
|
43
|
-
const toneClass = SECTION_TONE_CLASS[tone]
|
|
43
|
+
const toneClass = SECTION_TONE_CLASS[tone];
|
|
44
44
|
return (
|
|
45
45
|
<section className="flex items-start gap-2.5">
|
|
46
46
|
{/* Icon sits in a fixed gutter, anchored to the eyebrow line (top-aligned
|
|
47
47
|
with a nudge to its optical center) — sections like Affected resources
|
|
48
48
|
can be dozens of rows tall, and a section-centered glyph would float
|
|
49
49
|
detached mid-list. */}
|
|
50
|
-
<Icon
|
|
50
|
+
<Icon
|
|
51
|
+
className={`mt-[-1px] h-[18px] w-[18px] shrink-0 ${toneClass}`}
|
|
52
|
+
aria-hidden
|
|
53
|
+
/>
|
|
51
54
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
|
52
|
-
<h4
|
|
55
|
+
<h4
|
|
56
|
+
className={`text-[11px] font-semibold uppercase tracking-[0.06em] ${toneClass}`}
|
|
57
|
+
>
|
|
53
58
|
{label}
|
|
54
|
-
{labelExtra ?
|
|
59
|
+
{labelExtra ? (
|
|
60
|
+
<span className="font-medium normal-case tracking-normal text-theme-text-tertiary">
|
|
61
|
+
{" "}
|
|
62
|
+
{labelExtra}
|
|
63
|
+
</span>
|
|
64
|
+
) : null}
|
|
55
65
|
</h4>
|
|
56
66
|
{children}
|
|
57
67
|
</div>
|
|
58
68
|
</section>
|
|
59
|
-
)
|
|
69
|
+
);
|
|
60
70
|
}
|
|
61
71
|
|
|
62
72
|
// Body paragraph at the contrast floor — 14px, secondary token, never lighter.
|
|
63
|
-
export function CardBody({
|
|
64
|
-
|
|
73
|
+
export function CardBody({
|
|
74
|
+
children,
|
|
75
|
+
className,
|
|
76
|
+
}: {
|
|
77
|
+
children: ReactNode;
|
|
78
|
+
className?: string;
|
|
79
|
+
}) {
|
|
80
|
+
return (
|
|
81
|
+
<p
|
|
82
|
+
className={`text-sm leading-relaxed text-theme-text-secondary ${className ?? ""}`}
|
|
83
|
+
>
|
|
84
|
+
{children}
|
|
85
|
+
</p>
|
|
86
|
+
);
|
|
65
87
|
}
|
|
66
88
|
|
|
67
89
|
// The dark terminal block for raw error output. Intentionally dark in BOTH
|
|
68
90
|
// themes (it's a console surface, not chrome), so it draws from the dedicated
|
|
69
91
|
// --terminal-* tokens rather than the theme surface tokens.
|
|
70
|
-
export function
|
|
92
|
+
export function TerminalBlockLabel({
|
|
93
|
+
children,
|
|
94
|
+
divider,
|
|
95
|
+
}: {
|
|
96
|
+
children: ReactNode;
|
|
97
|
+
divider?: boolean;
|
|
98
|
+
}) {
|
|
99
|
+
return (
|
|
100
|
+
<div
|
|
101
|
+
className={`${divider ? "border-t" : "border-b"} border-[var(--terminal-divider)] px-3 py-1.5 text-[10px] font-semibold uppercase tracking-[0.06em] text-[var(--terminal-label)]`}
|
|
102
|
+
>
|
|
103
|
+
{children}
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function TerminalBlock({
|
|
109
|
+
label,
|
|
110
|
+
children,
|
|
111
|
+
footer,
|
|
112
|
+
}: {
|
|
113
|
+
label?: ReactNode;
|
|
114
|
+
children: ReactNode;
|
|
115
|
+
/** Rendered under the output, inside the same block: a folded tail, a divider. */
|
|
116
|
+
footer?: ReactNode;
|
|
117
|
+
}) {
|
|
71
118
|
return (
|
|
72
119
|
<div className="overflow-hidden rounded-lg border border-[var(--terminal-border)] bg-[var(--terminal-bg)]">
|
|
73
|
-
{label ?
|
|
74
|
-
<div className="border-b border-[var(--terminal-divider)] px-3 py-1.5 text-[10px] font-semibold uppercase tracking-[0.06em] text-[var(--terminal-label)]">
|
|
75
|
-
{label}
|
|
76
|
-
</div>
|
|
77
|
-
) : null}
|
|
120
|
+
{label ? <TerminalBlockLabel>{label}</TerminalBlockLabel> : null}
|
|
78
121
|
<pre className="overflow-x-auto whitespace-pre-wrap break-words px-3 py-2.5 font-mono text-xs leading-relaxed text-[var(--terminal-text)]">
|
|
79
122
|
{children}
|
|
80
123
|
</pre>
|
|
124
|
+
{footer}
|
|
81
125
|
</div>
|
|
82
|
-
)
|
|
126
|
+
);
|
|
83
127
|
}
|
|
84
128
|
|
|
85
129
|
// The neutral category chip — drops the per-category hue so severity is the only
|
|
86
130
|
// status color. Self-contained (don't pair with badge-sm): 11px / 600, 2px×8px
|
|
87
131
|
// padding, no border, neutral grey.
|
|
88
132
|
export const NEUTRAL_CHIP_CLASS =
|
|
89
|
-
|
|
133
|
+
"inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-semibold leading-tight bg-theme-elevated text-theme-text-secondary";
|
|
90
134
|
|
|
91
135
|
// Mono kind chip — the grey tag for an all-caps resource Kind (the Issue row
|
|
92
136
|
// header + the Subject footer row). Mono and tighter than NEUTRAL_CHIP_CLASS,
|
|
93
137
|
// which carries the label/category chips.
|
|
94
138
|
export const KIND_CHIP_CLASS =
|
|
95
|
-
|
|
139
|
+
"shrink-0 rounded bg-theme-elevated px-1.5 py-px font-mono text-[11px] uppercase text-theme-text-secondary";
|
|
96
140
|
|
|
97
141
|
// Render a prose string, turning markdown `inline-code` spans into mono chips
|
|
98
142
|
// via the shared .inline-code class. Plain-text passthrough when the string
|
|
99
143
|
// carries no backticks, so it's a no-op for today's plain catalog copy and
|
|
100
144
|
// lights up automatically once backtick markup is added.
|
|
101
145
|
export function renderProse(text: string | undefined | null): ReactNode {
|
|
102
|
-
if (!text) return null
|
|
103
|
-
if (!text.includes(
|
|
104
|
-
const parts = text.split(/(`[^`]+`)/g)
|
|
146
|
+
if (!text) return null;
|
|
147
|
+
if (!text.includes("`")) return text;
|
|
148
|
+
const parts = text.split(/(`[^`]+`)/g);
|
|
105
149
|
return parts.map((part, i) => {
|
|
106
150
|
// length > 2 excludes a bare "``" (which the split leaves in a plain part)
|
|
107
151
|
// so it renders as literal text, not an empty chip.
|
|
108
|
-
if (part.length > 2 && part.startsWith(
|
|
152
|
+
if (part.length > 2 && part.startsWith("`") && part.endsWith("`")) {
|
|
109
153
|
return (
|
|
110
154
|
<code key={i} className="inline-code">
|
|
111
155
|
{part.slice(1, -1)}
|
|
112
156
|
</code>
|
|
113
|
-
)
|
|
157
|
+
);
|
|
114
158
|
}
|
|
115
|
-
return <Fragment key={i}>{part}</Fragment
|
|
116
|
-
})
|
|
159
|
+
return <Fragment key={i}>{part}</Fragment>;
|
|
160
|
+
});
|
|
117
161
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { renderToString } from 'react-dom/server'
|
|
3
|
+
import { readFileSync } from 'fs'
|
|
4
|
+
import { join } from 'path'
|
|
5
|
+
import { Collapse, CollapseChevron, disclosurePanelId } from './Collapse'
|
|
6
|
+
import {
|
|
7
|
+
CSS_EASE,
|
|
8
|
+
DURATION_DISCLOSURE,
|
|
9
|
+
TRANSITION_CHEVRON,
|
|
10
|
+
TRANSITION_DISCLOSURE,
|
|
11
|
+
} from '../../utils/animation'
|
|
12
|
+
|
|
13
|
+
// The primitive is the app-wide standard for expand/collapse; these pin the
|
|
14
|
+
// contract other components rely on rather than the visuals.
|
|
15
|
+
describe('Collapse', () => {
|
|
16
|
+
it('animates grid-template-rows with the shared disclosure timing', () => {
|
|
17
|
+
const html = renderToString(<Collapse open={false}>x</Collapse>)
|
|
18
|
+
expect(html).toContain('grid-template-rows:0fr')
|
|
19
|
+
expect(html).toContain(`duration-${DURATION_DISCLOSURE}`)
|
|
20
|
+
expect(TRANSITION_DISCLOSURE).toContain(`duration-${DURATION_DISCLOSURE}`)
|
|
21
|
+
expect(TRANSITION_DISCLOSURE).toContain('motion-reduce:transition-none')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('keeps closed content mounted but inert, and clipped', () => {
|
|
25
|
+
const html = renderToString(<Collapse open={false}>hidden-content</Collapse>)
|
|
26
|
+
expect(html).toContain('hidden-content')
|
|
27
|
+
expect(html).toMatch(/inert=""|inert>/)
|
|
28
|
+
expect(html).toContain('overflow-hidden')
|
|
29
|
+
const opened = renderToString(<Collapse open>shown</Collapse>)
|
|
30
|
+
expect(opened).toContain('grid-template-rows:1fr')
|
|
31
|
+
expect(opened).not.toMatch(/inert=""|inert>/)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('mountLazily renders nothing until first open', () => {
|
|
35
|
+
expect(renderToString(<Collapse open={false} mountLazily>lazy</Collapse>)).not.toContain('lazy')
|
|
36
|
+
expect(renderToString(<Collapse open mountLazily>lazy</Collapse>)).toContain('lazy')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('unmountOnExit renders nothing when initially closed', () => {
|
|
40
|
+
// Initial-closed mount never had a transition to wait for.
|
|
41
|
+
expect(renderToString(<Collapse open={false} unmountOnExit>gone</Collapse>)).not.toContain('gone')
|
|
42
|
+
expect(renderToString(<Collapse open unmountOnExit>here</Collapse>)).toContain('here')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('exposes the panel id for aria-controls', () => {
|
|
46
|
+
expect(renderToString(<Collapse open id="panel-1">x</Collapse>)).toContain('id="panel-1"')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('disclosurePanelId never yields whitespace (aria-controls is a space-separated list)', () => {
|
|
50
|
+
expect(disclosurePanelId(':r1:', 'Access Control')).toBe(':r1:-Access%20Control')
|
|
51
|
+
expect(disclosurePanelId(':r1:', 7)).toBe(':r1:-7')
|
|
52
|
+
// Injective: keys that only differ by the separator stay distinct.
|
|
53
|
+
expect(disclosurePanelId('p', 'a b')).not.toBe(disclosurePanelId('p', 'a_b'))
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe('CollapseChevron', () => {
|
|
58
|
+
it('runs the same clock as the panel', () => {
|
|
59
|
+
expect(TRANSITION_CHEVRON).toContain(`duration-${DURATION_DISCLOSURE}`)
|
|
60
|
+
const html = renderToString(<CollapseChevron open />)
|
|
61
|
+
expect(html).toContain('rotate-90')
|
|
62
|
+
expect(html).toContain(`duration-${DURATION_DISCLOSURE}`)
|
|
63
|
+
})
|
|
64
|
+
})
|
|
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
|
+
})
|
|
@@ -1,47 +1,93 @@
|
|
|
1
|
-
import { useState, type ReactNode } from 'react'
|
|
1
|
+
import { useEffect, useId, useState, type ReactNode, type TransitionEvent } from 'react'
|
|
2
2
|
import { ChevronRight } from 'lucide-react'
|
|
3
3
|
import { clsx } from 'clsx'
|
|
4
|
+
import { DURATION_DISCLOSURE, TRANSITION_CHEVRON, TRANSITION_DISCLOSURE, prefersReducedMotion } from '../../utils/animation'
|
|
4
5
|
|
|
5
6
|
// Animated show/hide using the grid-template-rows 0fr→1fr technique — the
|
|
6
|
-
// app-wide standard for expand/collapse (
|
|
7
|
-
// sidebar,
|
|
8
|
-
// it clips the content while
|
|
9
|
-
//
|
|
7
|
+
// app-wide standard for expand/collapse (drawer sections, checks, audit,
|
|
8
|
+
// resources sidebar, issue rows, and every disclosure in Radar Cloud). The
|
|
9
|
+
// inner overflow-hidden wrapper is load-bearing: it clips the content while
|
|
10
|
+
// the grid row animates between 0 and its natural height, so nothing spills
|
|
11
|
+
// before the row is fully open.
|
|
10
12
|
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// land on invisible rows (matching ChecksView's collapse).
|
|
13
|
+
// Timing lives in utils/animation (DURATION_DISCLOSURE, one curve). The caret
|
|
14
|
+
// (CollapseChevron) runs the same clock, so an interrupted toggle reverses
|
|
15
|
+
// panel and caret together.
|
|
15
16
|
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
17
|
+
// Mount policy — pick per site, it is not cosmetic:
|
|
18
|
+
// default content stays mounted while closed, clipped to zero height
|
|
19
|
+
// and `inert` so keyboard/screen-reader users can't land on
|
|
20
|
+
// invisible rows. Right for static or cheap content.
|
|
21
|
+
// mountLazily defers the first render until the first open, then keeps
|
|
22
|
+
// it. For many instances with heavy collapsed content
|
|
23
|
+
// (hundreds of rows each with a drift panel).
|
|
24
|
+
// unmountOnExit unmounts the children once the close transition has
|
|
25
|
+
// finished (transitionend on this wrapper, with a timer
|
|
26
|
+
// fallback so disposal is guaranteed under reduced motion,
|
|
27
|
+
// for empty content, or if the event never fires). For
|
|
28
|
+
// subtrees that poll, subscribe, or should reset on close.
|
|
29
|
+
// Note: work stops when the subtree unmounts, i.e. after the
|
|
30
|
+
// exit; a query that must stop at logical close should also
|
|
31
|
+
// take `enabled: open`.
|
|
22
32
|
export function Collapse({
|
|
23
33
|
open,
|
|
24
34
|
children,
|
|
25
35
|
className,
|
|
26
36
|
mountLazily = false,
|
|
37
|
+
unmountOnExit = false,
|
|
38
|
+
id,
|
|
27
39
|
}: {
|
|
28
40
|
open: boolean
|
|
29
41
|
children: ReactNode
|
|
30
42
|
className?: string
|
|
31
43
|
mountLazily?: boolean
|
|
44
|
+
unmountOnExit?: boolean
|
|
45
|
+
/** Panel id for the header's aria-controls (see useDisclosure). */
|
|
46
|
+
id?: string
|
|
32
47
|
}) {
|
|
33
48
|
// Latch: once opened, stay mounted. Conditional setState-in-render is the
|
|
34
49
|
// supported pattern for deriving state from props without an extra commit.
|
|
35
50
|
const [hasOpened, setHasOpened] = useState(open)
|
|
36
51
|
if (mountLazily && open && !hasOpened) setHasOpened(true)
|
|
37
|
-
|
|
52
|
+
|
|
53
|
+
// unmountOnExit: keep rendering through the close transition, then stop.
|
|
54
|
+
// `exiting` must flip in the SAME render that sees `open` go false — doing
|
|
55
|
+
// it in an effect would commit one frame with the children already gone
|
|
56
|
+
// (render = open || exiting, both false) before the effect put them back.
|
|
57
|
+
// Same setState-in-render pattern as the latch above.
|
|
58
|
+
const [exiting, setExiting] = useState(false)
|
|
59
|
+
const [prevOpen, setPrevOpen] = useState(open)
|
|
60
|
+
if (open !== prevOpen) {
|
|
61
|
+
setPrevOpen(open)
|
|
62
|
+
// Reopen before disposal cancels the pending unmount; under reduced motion
|
|
63
|
+
// the stylesheet has zeroed the transition, so dispose at logical close.
|
|
64
|
+
setExiting(!open && unmountOnExit && !prefersReducedMotion())
|
|
65
|
+
}
|
|
66
|
+
// Guarantee disposal even if transitionend never arrives (empty content
|
|
67
|
+
// transitions instantly; a cancelled transition fires no end). Any change to
|
|
68
|
+
// `exiting` (reopen, transitionend) clears the pending timer via cleanup.
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (!exiting) return
|
|
71
|
+
const t = window.setTimeout(() => setExiting(false), DURATION_DISCLOSURE + 50)
|
|
72
|
+
return () => window.clearTimeout(t)
|
|
73
|
+
}, [exiting])
|
|
74
|
+
|
|
75
|
+
const onTransitionEnd = (event: TransitionEvent<HTMLDivElement>) => {
|
|
76
|
+
if (!exiting) return
|
|
77
|
+
// Only this wrapper's own row transition — descendants' transitions
|
|
78
|
+
// bubble here too.
|
|
79
|
+
if (event.target !== event.currentTarget) return
|
|
80
|
+
if (event.propertyName !== 'grid-template-rows') return
|
|
81
|
+
setExiting(false)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const render = unmountOnExit ? open || exiting : !mountLazily || hasOpened
|
|
38
85
|
return (
|
|
39
86
|
<div
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
className,
|
|
43
|
-
)}
|
|
87
|
+
id={id}
|
|
88
|
+
className={clsx('grid', TRANSITION_DISCLOSURE, className)}
|
|
44
89
|
style={{ gridTemplateRows: open ? '1fr' : '0fr' }}
|
|
90
|
+
onTransitionEnd={onTransitionEnd}
|
|
45
91
|
>
|
|
46
92
|
{/* `relative` is load-bearing. Collapsed content is still laid out at full
|
|
47
93
|
height and only clipped by `overflow-hidden` — but an absolutely-positioned
|
|
@@ -51,24 +97,64 @@ export function Collapse({
|
|
|
51
97
|
and extends the enclosing scroll container's scrollable overflow: the drawer
|
|
52
98
|
scrolls past its own content into blank space. Anchoring here puts those
|
|
53
99
|
boxes back inside the clip, so a collapsed section contributes nothing. */}
|
|
54
|
-
<div className="relative overflow-hidden" inert={!open || undefined}>
|
|
100
|
+
<div className="relative overflow-hidden" inert={!open || undefined}>
|
|
101
|
+
{render ? children : null}
|
|
102
|
+
</div>
|
|
55
103
|
</div>
|
|
56
104
|
)
|
|
57
105
|
}
|
|
58
106
|
|
|
59
107
|
// CollapseChevron is the disclosure caret that pairs with <Collapse>: a single
|
|
60
108
|
// ChevronRight that rotates 90° when open, rather than swapping between two
|
|
61
|
-
// icons.
|
|
62
|
-
//
|
|
63
|
-
|
|
109
|
+
// icons. Same duration and curve as the panel, so every collapsible surface
|
|
110
|
+
// animates the same way and an interrupted toggle keeps caret and panel in
|
|
111
|
+
// step. Menu triggers are a different thing — they keep ChevronDown/Up
|
|
112
|
+
// flipping toward the menu.
|
|
113
|
+
//
|
|
114
|
+
// The caret is tertiary grey by default. Inside a tinted header (an amber
|
|
115
|
+
// warning, a red error banner, a button whose hover recolors its text) pass
|
|
116
|
+
// `inheritColor` so it takes currentColor from the header instead — a color
|
|
117
|
+
// utility in `className` would race the default in the cascade.
|
|
118
|
+
export function CollapseChevron({
|
|
119
|
+
open,
|
|
120
|
+
className,
|
|
121
|
+
inheritColor = false,
|
|
122
|
+
}: {
|
|
123
|
+
open: boolean
|
|
124
|
+
className?: string
|
|
125
|
+
inheritColor?: boolean
|
|
126
|
+
}) {
|
|
64
127
|
return (
|
|
65
128
|
<ChevronRight
|
|
66
129
|
aria-hidden="true"
|
|
67
|
-
className={clsx(
|
|
68
|
-
'shrink-0 text-theme-text-tertiary transition-transform duration-200 motion-reduce:transition-none',
|
|
69
|
-
open && 'rotate-90',
|
|
70
|
-
className,
|
|
71
|
-
)}
|
|
130
|
+
className={clsx('shrink-0', !inheritColor && 'text-theme-text-tertiary', TRANSITION_CHEVRON, open && 'rotate-90', className)}
|
|
72
131
|
/>
|
|
73
132
|
)
|
|
74
133
|
}
|
|
134
|
+
|
|
135
|
+
// useDisclosure wires the header ↔ panel relationship: the header gets
|
|
136
|
+
// aria-expanded + aria-controls, the panel gets a stable id. Works with any
|
|
137
|
+
// header markup (a plain button, or a compound row with its own actions), so
|
|
138
|
+
// it does not force a <button> wrapper around things that already contain
|
|
139
|
+
// buttons.
|
|
140
|
+
//
|
|
141
|
+
// const d = useDisclosure(open)
|
|
142
|
+
// <button {...d.buttonProps} onClick={toggle}>…<CollapseChevron open={open} /></button>
|
|
143
|
+
// <Collapse open={open} id={d.panelId}>…</Collapse>
|
|
144
|
+
// Rows mapped inline can't each call useDisclosure; they build ids from one
|
|
145
|
+
// useId() prefix plus a row key. The key is display data (a category name, a
|
|
146
|
+
// cluster id) and may contain whitespace, which would split `aria-controls`
|
|
147
|
+
// into two references — so it is percent-encoded here (injective, so two
|
|
148
|
+
// distinct keys can't land on one id).
|
|
149
|
+
export function disclosurePanelId(base: string, key: string | number): string {
|
|
150
|
+
return `${base}-${encodeURIComponent(String(key))}`
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function useDisclosure(open: boolean, explicitId?: string) {
|
|
154
|
+
const generated = useId()
|
|
155
|
+
const panelId = explicitId ?? `disclosure-${generated}`
|
|
156
|
+
return {
|
|
157
|
+
panelId,
|
|
158
|
+
buttonProps: { 'aria-expanded': open, 'aria-controls': panelId } as const,
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -2,7 +2,7 @@ import { useEffect, useRef, type KeyboardEvent as ReactKeyboardEvent, type React
|
|
|
2
2
|
import { createPortal } from 'react-dom'
|
|
3
3
|
import { clsx } from 'clsx'
|
|
4
4
|
import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
|
|
5
|
-
import { TRANSITION_BACKDROP, TRANSITION_PANEL } from '../../utils/animation'
|
|
5
|
+
import { TRANSITION_BACKDROP, TRANSITION_PANEL, overlayExitMs, overlayTransitionStyle } from '../../utils/animation'
|
|
6
6
|
|
|
7
7
|
interface DialogPortalProps {
|
|
8
8
|
open: boolean
|
|
@@ -27,7 +27,10 @@ interface DialogPortalProps {
|
|
|
27
27
|
*/
|
|
28
28
|
export function DialogPortal({ open, onClose, children, className, closable = true }: DialogPortalProps) {
|
|
29
29
|
const dialogRef = useRef<HTMLDivElement>(null)
|
|
30
|
-
|
|
30
|
+
// The unmount window is the exit duration: shorter than the entrance, and
|
|
31
|
+
// exactly what the panel transition below runs — a mismatch here cut the
|
|
32
|
+
// close at two-thirds (200ms window under a 300ms panel).
|
|
33
|
+
const { shouldRender, isOpen } = useAnimatedUnmount(open, overlayExitMs('dialog'))
|
|
31
34
|
|
|
32
35
|
// Bubble phase lets nested editors and menus consume Escape before the dialog closes.
|
|
33
36
|
const handleDialogKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
|
|
@@ -81,6 +84,7 @@ export function DialogPortal({ open, onClose, children, className, closable = tr
|
|
|
81
84
|
TRANSITION_BACKDROP,
|
|
82
85
|
isOpen ? 'opacity-100' : 'opacity-0',
|
|
83
86
|
)}
|
|
87
|
+
style={overlayTransitionStyle(isOpen, 'dialog')}
|
|
84
88
|
onClick={closable ? onClose : undefined}
|
|
85
89
|
/>
|
|
86
90
|
<div
|
|
@@ -95,6 +99,7 @@ export function DialogPortal({ open, onClose, children, className, closable = tr
|
|
|
95
99
|
isOpen ? 'opacity-100 scale-100' : 'opacity-0 scale-95',
|
|
96
100
|
className,
|
|
97
101
|
)}
|
|
102
|
+
style={overlayTransitionStyle(isOpen, 'dialog')}
|
|
98
103
|
>
|
|
99
104
|
{children}
|
|
100
105
|
</div>
|
|
@@ -2,8 +2,8 @@ import { describe, expect, it } from 'vitest'
|
|
|
2
2
|
import { renderToString } from 'react-dom/server'
|
|
3
3
|
import { FetchResult } from './FetchResult'
|
|
4
4
|
|
|
5
|
-
function shaped(message: string, status: number) {
|
|
6
|
-
return Object.assign(new Error(message), { status })
|
|
5
|
+
function shaped(message: string, status: number, data?: Record<string, unknown>) {
|
|
6
|
+
return Object.assign(new Error(message), { status, data })
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
describe('FetchResult', () => {
|
|
@@ -54,6 +54,24 @@ describe('FetchResult', () => {
|
|
|
54
54
|
expect(html).toContain('Resource cache not available')
|
|
55
55
|
})
|
|
56
56
|
|
|
57
|
+
it('does not blame the cluster for a kind_sync_failed 503', () => {
|
|
58
|
+
const html = renderToString(
|
|
59
|
+
<FetchResult
|
|
60
|
+
loading={false}
|
|
61
|
+
error={shaped('pods failed to load within the sync deadline', 503, { error_code: 'kind_sync_failed' })}
|
|
62
|
+
/>,
|
|
63
|
+
)
|
|
64
|
+
expect(html).not.toContain('Cluster unavailable')
|
|
65
|
+
expect(html).toContain('Couldn')
|
|
66
|
+
expect(html).toContain('pods failed to load within the sync deadline')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('renders a Retry button only when onRetry is given', () => {
|
|
70
|
+
const err = shaped('internal server error', 500)
|
|
71
|
+
expect(renderToString(<FetchResult loading={false} error={err} onRetry={() => {}} />)).toContain('Retry')
|
|
72
|
+
expect(renderToString(<FetchResult loading={false} error={err} />)).not.toContain('Retry')
|
|
73
|
+
})
|
|
74
|
+
|
|
57
75
|
it('renders "Sign-in required" on 401', () => {
|
|
58
76
|
const html = renderToString(
|
|
59
77
|
<FetchResult loading={false} error={shaped('Unauthorized', 401)} />,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useState } from 'react'
|
|
2
|
-
import { ShieldOff, AlertTriangle, ServerCrash, LogIn, Copy, Check, type LucideIcon } from 'lucide-react'
|
|
2
|
+
import { ShieldOff, AlertTriangle, ServerCrash, LogIn, Copy, Check, RefreshCw, type LucideIcon } from 'lucide-react'
|
|
3
3
|
import { clsx } from 'clsx'
|
|
4
4
|
import { PaneLoader } from './PaneLoader'
|
|
5
5
|
import { isFetchError } from '../../types/fetch-error'
|
|
@@ -14,6 +14,7 @@ import { isFetchError } from '../../types/fetch-error'
|
|
|
14
14
|
// loading → <PaneLoader/>
|
|
15
15
|
// error (403) → "Access denied" + error.message
|
|
16
16
|
// error (404) → notFoundMessage + error.message
|
|
17
|
+
// error (503 sync failed) → "Couldn't load this view" + error.message
|
|
17
18
|
// error (503) → "Cluster unavailable" + error.message
|
|
18
19
|
// error (401) → "Sign-in required" (apiFetch redirects; fallback)
|
|
19
20
|
// error (other / no shape) → "Couldn't load this view" + error.message
|
|
@@ -34,6 +35,8 @@ interface FetchResultProps {
|
|
|
34
35
|
notFoundMessage?: string
|
|
35
36
|
/** Pin to parent height. Existing call sites use "h-32" or "h-full". */
|
|
36
37
|
className?: string
|
|
38
|
+
/** Renders a Retry button on the error surface. */
|
|
39
|
+
onRetry?: () => void
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
export function FetchResult({
|
|
@@ -41,6 +44,7 @@ export function FetchResult({
|
|
|
41
44
|
error,
|
|
42
45
|
notFoundMessage = 'Resource not found',
|
|
43
46
|
className = 'h-32',
|
|
47
|
+
onRetry,
|
|
44
48
|
}: FetchResultProps) {
|
|
45
49
|
if (loading) {
|
|
46
50
|
return <PaneLoader className={className} />
|
|
@@ -55,16 +59,17 @@ export function FetchResult({
|
|
|
55
59
|
</div>
|
|
56
60
|
)
|
|
57
61
|
}
|
|
58
|
-
return <ErrorSurface error={error} notFoundMessage={notFoundMessage} className={className} />
|
|
62
|
+
return <ErrorSurface error={error} notFoundMessage={notFoundMessage} className={className} onRetry={onRetry} />
|
|
59
63
|
}
|
|
60
64
|
|
|
61
65
|
interface ErrorSurfaceProps {
|
|
62
66
|
error: unknown
|
|
63
67
|
notFoundMessage: string
|
|
64
68
|
className: string
|
|
69
|
+
onRetry?: () => void
|
|
65
70
|
}
|
|
66
71
|
|
|
67
|
-
function ErrorSurface({ error, notFoundMessage, className }: ErrorSurfaceProps) {
|
|
72
|
+
function ErrorSurface({ error, notFoundMessage, className, onRetry }: ErrorSurfaceProps) {
|
|
68
73
|
const classified = classify(error, notFoundMessage)
|
|
69
74
|
const Icon = classified.icon
|
|
70
75
|
|
|
@@ -81,6 +86,16 @@ function ErrorSurface({ error, notFoundMessage, className }: ErrorSurfaceProps)
|
|
|
81
86
|
<CopyErrorButton text={classified.detail} />
|
|
82
87
|
</div>
|
|
83
88
|
)}
|
|
89
|
+
{onRetry && (
|
|
90
|
+
<button
|
|
91
|
+
type="button"
|
|
92
|
+
onClick={onRetry}
|
|
93
|
+
className="mt-1 flex items-center gap-1.5 rounded-md border border-theme-border bg-theme-elevated px-3 py-1.5 text-xs text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary"
|
|
94
|
+
>
|
|
95
|
+
<RefreshCw className="h-3.5 w-3.5" aria-hidden />
|
|
96
|
+
Retry
|
|
97
|
+
</button>
|
|
98
|
+
)}
|
|
84
99
|
</div>
|
|
85
100
|
)
|
|
86
101
|
}
|
|
@@ -101,6 +116,17 @@ function classify(error: unknown, notFoundMessage: string): Classified {
|
|
|
101
116
|
case 401:
|
|
102
117
|
return { headline: 'Sign-in required', detail: error.message, icon: LogIn }
|
|
103
118
|
case 503:
|
|
119
|
+
// The cluster is connected; one kind's informer missed its sync
|
|
120
|
+
// deadline, so "Cluster unavailable" would send the reader the wrong way.
|
|
121
|
+
if (errorCodeOf(error) === 'kind_sync_failed') {
|
|
122
|
+
return { headline: "Couldn't load this view", detail: error.message, icon: AlertTriangle }
|
|
123
|
+
}
|
|
124
|
+
// The cluster is healthy and still loading — the retry policies keep
|
|
125
|
+
// these surfaces polling, but a caller without one must not tell the
|
|
126
|
+
// user a connecting cluster is down.
|
|
127
|
+
if (errorCodeOf(error) === 'cluster_connecting' || errorCodeOf(error) === 'kind_sync_pending') {
|
|
128
|
+
return { headline: 'Cluster is still loading', detail: error.message, icon: AlertTriangle }
|
|
129
|
+
}
|
|
104
130
|
return { headline: 'Cluster unavailable', detail: error.message, icon: ServerCrash }
|
|
105
131
|
default:
|
|
106
132
|
return { headline: "Couldn't load this view", detail: error.message, icon: AlertTriangle }
|
|
@@ -110,6 +136,10 @@ function classify(error: unknown, notFoundMessage: string): Classified {
|
|
|
110
136
|
return { headline: "Couldn't load this view", detail: errorMessageOf(error), icon: AlertTriangle }
|
|
111
137
|
}
|
|
112
138
|
|
|
139
|
+
function errorCodeOf(error: unknown): unknown {
|
|
140
|
+
return (error as { data?: { error_code?: unknown } }).data?.error_code
|
|
141
|
+
}
|
|
142
|
+
|
|
113
143
|
function errorMessageOf(error: unknown): string | null {
|
|
114
144
|
if (error instanceof Error && error.message) return error.message
|
|
115
145
|
if (typeof error === 'string') return error
|