@skyhook-io/k8s-ui 1.8.9 → 1.8.10

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.
Files changed (31) hide show
  1. package/package.json +1 -1
  2. package/src/components/applications/ApplicationsList.tsx +4 -1
  3. package/src/components/applications/ApplicationsView.tsx +40 -9
  4. package/src/components/checks/ChecksView.tsx +80 -49
  5. package/src/components/checks/severity.ts +29 -39
  6. package/src/components/gitops/GitOpsTableView.tsx +11 -56
  7. package/src/components/issues/IssuesView.tsx +186 -123
  8. package/src/components/issues/ResourceIssuesSection.test.tsx +39 -0
  9. package/src/components/issues/ResourceIssuesSection.tsx +21 -3
  10. package/src/components/issues/index.ts +0 -2
  11. package/src/components/issues/issues.test.ts +45 -4
  12. package/src/components/issues/severity.ts +27 -32
  13. package/src/components/resources/ResourcesSidebar.test.tsx +36 -0
  14. package/src/components/resources/ResourcesSidebar.tsx +24 -6
  15. package/src/components/resources/ResourcesView.tsx +10 -2
  16. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +47 -0
  17. package/src/components/resources/renderers/NodeRenderer.test.tsx +103 -0
  18. package/src/components/resources/renderers/NodeRenderer.tsx +20 -12
  19. package/src/components/resources/renderers/PVCRenderer.test.tsx +23 -0
  20. package/src/components/resources/renderers/PVCRenderer.tsx +2 -2
  21. package/src/components/resources/renderers/PodRenderer.test.tsx +101 -0
  22. package/src/components/resources/renderers/PodRenderer.tsx +81 -70
  23. package/src/components/ui/BoardSkeleton.tsx +47 -0
  24. package/src/components/ui/CardSection.tsx +117 -0
  25. package/src/components/ui/Toast.tsx +1 -1
  26. package/src/components/ui/index.ts +2 -0
  27. package/src/components/ui/severity-tone.ts +56 -0
  28. package/src/theme/variables.css +14 -0
  29. package/src/utils/api-resources.test.ts +54 -0
  30. package/src/utils/api-resources.ts +68 -1
  31. package/tsconfig.json +2 -2
@@ -107,3 +107,104 @@ describe('PodRenderer issues banner', () => {
107
107
  expect(html).toContain('min-w-0 break-words')
108
108
  })
109
109
  })
110
+
111
+ describe('PodRenderer metrics', () => {
112
+ it('renders a calm unavailable state when metrics-server is absent', () => {
113
+ const html = renderToString(
114
+ <PodRenderer
115
+ data={pod}
116
+ onCopy={() => undefined}
117
+ copied={null}
118
+ metricsUnavailable
119
+ metricsHistory={{
120
+ containers: [],
121
+ metricsUnavailableReason: 'the server could not find the requested resource',
122
+ }}
123
+ />,
124
+ )
125
+
126
+ expect(html).toContain('Resource Usage')
127
+ expect(html).toContain('Metrics unavailable')
128
+ expect(html).toContain('Radar cannot read metrics.k8s.io')
129
+ expect(html).toContain('Metrics error details')
130
+ expect(html).not.toContain('Install or repair metrics-server and its APIService')
131
+ expect(html).not.toContain('Metrics collection error')
132
+ expect(html).not.toContain('Collecting metrics data')
133
+ })
134
+
135
+ it('does not let stale live metrics hide an unavailable state', () => {
136
+ const html = renderToString(
137
+ <PodRenderer
138
+ data={pod}
139
+ onCopy={() => undefined}
140
+ copied={null}
141
+ metricsUnavailable
142
+ metrics={{
143
+ containers: [{ name: 'api', usage: { cpu: '100m', memory: '256Mi' } }],
144
+ timestamp: '2026-06-30T00:00:00Z',
145
+ }}
146
+ />,
147
+ )
148
+
149
+ expect(html).toContain('Metrics unavailable')
150
+ expect(html).not.toContain('100m')
151
+ expect(html).not.toContain('Last updated')
152
+ })
153
+
154
+ it('keeps buffered historical charts visible under an unavailable notice', () => {
155
+ const html = renderToString(
156
+ <PodRenderer
157
+ data={pod}
158
+ onCopy={() => undefined}
159
+ copied={null}
160
+ metricsUnavailable
161
+ metricsHistory={{
162
+ containers: [{
163
+ name: 'api',
164
+ dataPoints: [{ timestamp: '2026-06-30T00:00:00Z', cpu: 100000000, memory: 268435456 }],
165
+ }],
166
+ }}
167
+ />,
168
+ )
169
+
170
+ expect(html).toContain('Metrics unavailable')
171
+ expect(html).toContain('CPU')
172
+ expect(html).toContain('Memory')
173
+ expect(html).not.toContain('Last updated')
174
+ })
175
+
176
+ it('keeps non-absence collection errors visible', () => {
177
+ const html = renderToString(
178
+ <PodRenderer
179
+ data={pod}
180
+ onCopy={() => undefined}
181
+ copied={null}
182
+ metricsHistory={{ containers: [], collectionError: 'forbidden: no access to pods.metrics.k8s.io' }}
183
+ />,
184
+ )
185
+
186
+ expect(html).toContain('Metrics collection error')
187
+ expect(html).toContain('forbidden: no access to pods.metrics.k8s.io')
188
+ })
189
+
190
+ it('warns about non-absence collection errors even when historical samples exist', () => {
191
+ const html = renderToString(
192
+ <PodRenderer
193
+ data={pod}
194
+ onCopy={() => undefined}
195
+ copied={null}
196
+ metricsHistory={{
197
+ collectionError: 'forbidden: no access to pods.metrics.k8s.io',
198
+ containers: [{
199
+ name: 'api',
200
+ dataPoints: [{ timestamp: '2026-06-30T00:00:00Z', cpu: 100000000, memory: 268435456 }],
201
+ }],
202
+ }}
203
+ />,
204
+ )
205
+
206
+ expect(html).toContain('Metrics collection error')
207
+ expect(html).toContain('forbidden: no access to pods.metrics.k8s.io')
208
+ expect(html).toContain('CPU')
209
+ })
210
+ })
@@ -15,6 +15,7 @@ import { RBACErrorSection, isRBACUnavailable } from './RBACErrorSection'
15
15
  import type { ResolvedEnvFrom, RBACSubjectResponse, RBACPolicyRule } from '../../../types'
16
16
  import { Tooltip } from '../../ui/Tooltip'
17
17
  import { MetricsChart } from '../../ui/MetricsChart'
18
+ import { MetricsUnavailableNotice } from './MetricsUnavailableNotice'
18
19
 
19
20
  function parseValidDate(dateStr: string): Date | null {
20
21
  const d = new Date(dateStr)
@@ -73,7 +74,8 @@ interface PodRendererProps {
73
74
  renderPortAction?: (props: { namespace: string; podName: string; port: number; protocol: string; disabled?: boolean }) => ReactNode
74
75
  // Metrics data injection
75
76
  metrics?: { containers?: any[]; timestamp?: string }
76
- metricsHistory?: { containers?: any[]; collectionError?: string }
77
+ metricsHistory?: { containers?: any[]; collectionError?: string; metricsUnavailableReason?: string; metricsUnavailableDiagnosis?: string }
78
+ metricsUnavailable?: boolean
77
79
  hideMetricsServer?: boolean
78
80
  // Filesystem browser render props
79
81
  renderImageBrowser?: (props: { image: string; namespace: string; podName: string; pullSecrets: string[]; onClose: () => void; onSwitchToPodFiles?: () => void }) => ReactNode
@@ -257,6 +259,7 @@ export function PodRenderer({
257
259
  renderPortAction,
258
260
  metrics,
259
261
  metricsHistory,
262
+ metricsUnavailable,
260
263
  hideMetricsServer,
261
264
  renderImageBrowser,
262
265
  renderPodBrowser,
@@ -280,6 +283,9 @@ export function PodRenderer({
280
283
  const operationalIssuesShown = useOperationalIssuesShown()
281
284
  const podProblems = getPodProblems(data)
282
285
  const hasProblems = podProblems.length > 0 && !operationalIssuesShown
286
+ const showMetricsUnavailable = !!metricsUnavailable && !metricsHistory?.collectionError
287
+ const hasMetricsHistory = !!metricsHistory?.containers?.length
288
+ const currentMetrics = metricsUnavailable ? undefined : metrics
283
289
 
284
290
  // Image filesystem modal state
285
291
  const [selectedImage, setSelectedImage] = useState<string | null>(null)
@@ -718,89 +724,94 @@ export function PodRenderer({
718
724
  )}
719
725
 
720
726
  {/* Resource Usage (from metrics-server) — hidden when Prometheus has CPU/memory data */}
721
- {!hideMetricsServer && !!(metrics?.containers?.length || metricsHistory?.containers?.length || metricsHistory?.collectionError) && (
727
+ {!hideMetricsServer && !!(currentMetrics?.containers?.length || hasMetricsHistory || metricsHistory?.collectionError || showMetricsUnavailable) && (
722
728
  <Section title="Resource Usage" icon={Activity} defaultExpanded>
723
- {metricsHistory?.collectionError && !metricsHistory?.containers?.length && (
729
+ {showMetricsUnavailable && (
730
+ <MetricsUnavailableNotice rawError={metricsHistory?.metricsUnavailableReason} diagnosis={metricsHistory?.metricsUnavailableDiagnosis} />
731
+ )}
732
+ {metricsHistory?.collectionError && (
724
733
  <div className="mb-3 rounded-lg border border-yellow-500/30 bg-yellow-500/5 px-3 py-2 text-xs text-yellow-400">
725
734
  <span className="font-medium">Metrics collection error:</span>{' '}
726
735
  <span className="break-all">{metricsHistory.collectionError}</span>
727
736
  </div>
728
737
  )}
729
- <div className="space-y-4">
730
- {(metricsHistory?.containers || metrics?.containers || []).map((historyContainer) => {
731
- // Find current metrics for this container
732
- const currentMetrics = metrics?.containers?.find(c => c.name === historyContainer.name)
733
- // Find the container spec to compare against limits
734
- const containerSpec = containers.find((c: any) => c.name === historyContainer.name)
735
- const limits = containerSpec?.resources?.limits
736
- const requests = containerSpec?.resources?.requests
737
-
738
- // Get historical data points (from history or empty)
739
- const dataPoints = 'dataPoints' in historyContainer ? historyContainer.dataPoints : []
738
+ {(hasMetricsHistory || !showMetricsUnavailable) && (
739
+ <div className="space-y-4">
740
+ {(metricsHistory?.containers || currentMetrics?.containers || []).map((historyContainer) => {
741
+ // Find current metrics for this container
742
+ const currentContainerMetrics = currentMetrics?.containers?.find(c => c.name === historyContainer.name)
743
+ // Find the container spec to compare against limits
744
+ const containerSpec = containers.find((c: any) => c.name === historyContainer.name)
745
+ const limits = containerSpec?.resources?.limits
746
+ const requests = containerSpec?.resources?.requests
740
747
 
741
- return (
742
- <div key={historyContainer.name} className="card-inner-lg">
743
- <div className="flex items-center justify-between mb-3">
744
- <span className="text-sm font-medium text-theme-text-primary">{historyContainer.name}</span>
745
- </div>
748
+ // Get historical data points (from history or empty)
749
+ const dataPoints = 'dataPoints' in historyContainer ? historyContainer.dataPoints : []
746
750
 
747
- {dataPoints && dataPoints.length > 0 ? (
748
- <div className="grid grid-cols-2 gap-6">
749
- <div>
750
- <div className="text-xs text-theme-text-tertiary mb-2">CPU</div>
751
- <MetricsChart
752
- dataPoints={dataPoints}
753
- type="cpu"
754
- height={80}
755
- showAxis={true}
756
- limit={limits?.cpu}
757
- request={requests?.cpu}
758
- />
759
- </div>
760
- <div>
761
- <div className="text-xs text-theme-text-tertiary mb-2">Memory</div>
762
- <MetricsChart
763
- dataPoints={dataPoints}
764
- type="memory"
765
- height={80}
766
- showAxis={true}
767
- limit={limits?.memory}
768
- request={requests?.memory}
769
- />
770
- </div>
751
+ return (
752
+ <div key={historyContainer.name} className="card-inner-lg">
753
+ <div className="flex items-center justify-between mb-3">
754
+ <span className="text-sm font-medium text-theme-text-primary">{historyContainer.name}</span>
771
755
  </div>
772
- ) : currentMetrics ? (
773
- /* Fallback to simple display if no history yet */
774
- <div className="grid grid-cols-2 gap-4 text-xs">
775
- <div>
776
- <div className="text-theme-text-tertiary mb-1">CPU</div>
777
- <div className="flex items-baseline gap-1">
778
- <span className="text-sm font-medium text-blue-400">{currentMetrics.usage.cpu}</span>
779
- {limits?.cpu && (
780
- <span className="text-theme-text-tertiary">/ {limits.cpu} limit</span>
781
- )}
756
+
757
+ {dataPoints && dataPoints.length > 0 ? (
758
+ <div className="grid grid-cols-2 gap-6">
759
+ <div>
760
+ <div className="text-xs text-theme-text-tertiary mb-2">CPU</div>
761
+ <MetricsChart
762
+ dataPoints={dataPoints}
763
+ type="cpu"
764
+ height={80}
765
+ showAxis={true}
766
+ limit={limits?.cpu}
767
+ request={requests?.cpu}
768
+ />
769
+ </div>
770
+ <div>
771
+ <div className="text-xs text-theme-text-tertiary mb-2">Memory</div>
772
+ <MetricsChart
773
+ dataPoints={dataPoints}
774
+ type="memory"
775
+ height={80}
776
+ showAxis={true}
777
+ limit={limits?.memory}
778
+ request={requests?.memory}
779
+ />
782
780
  </div>
783
781
  </div>
784
- <div>
785
- <div className="text-theme-text-tertiary mb-1">Memory</div>
786
- <div className="flex items-baseline gap-1">
787
- <span className="text-sm font-medium text-purple-400">{currentMetrics.usage.memory}</span>
788
- {limits?.memory && (
789
- <span className="text-theme-text-tertiary">/ {limits.memory} limit</span>
790
- )}
782
+ ) : currentContainerMetrics ? (
783
+ /* Fallback to simple display if no history yet */
784
+ <div className="grid grid-cols-2 gap-4 text-xs">
785
+ <div>
786
+ <div className="text-theme-text-tertiary mb-1">CPU</div>
787
+ <div className="flex items-baseline gap-1">
788
+ <span className="text-sm font-medium text-blue-400">{currentContainerMetrics.usage.cpu}</span>
789
+ {limits?.cpu && (
790
+ <span className="text-theme-text-tertiary">/ {limits.cpu} limit</span>
791
+ )}
792
+ </div>
793
+ </div>
794
+ <div>
795
+ <div className="text-theme-text-tertiary mb-1">Memory</div>
796
+ <div className="flex items-baseline gap-1">
797
+ <span className="text-sm font-medium text-purple-400">{currentContainerMetrics.usage.memory}</span>
798
+ {limits?.memory && (
799
+ <span className="text-theme-text-tertiary">/ {limits.memory} limit</span>
800
+ )}
801
+ </div>
791
802
  </div>
792
803
  </div>
793
- </div>
794
- ) : (
795
- <div className="text-xs text-theme-text-tertiary">Collecting metrics data...</div>
796
- )}
797
- </div>
798
- )
799
- })}
800
- </div>
801
- {metrics?.timestamp && (
804
+ ) : (
805
+ <div className="text-xs text-theme-text-tertiary">Collecting metrics data...</div>
806
+ )}
807
+ </div>
808
+ )
809
+ })}
810
+ </div>
811
+ )}
812
+ {!showMetricsUnavailable && currentMetrics?.timestamp && (
802
813
  <div className="mt-2 text-xs text-theme-text-tertiary">
803
- Last updated: {new Date(metrics.timestamp).toLocaleTimeString()}
814
+ Last updated: {new Date(currentMetrics.timestamp).toLocaleTimeString()}
804
815
  </div>
805
816
  )}
806
817
  </Section>
@@ -0,0 +1,47 @@
1
+ // Shape-stable loading stand-ins for the list-board chassis (GitOps,
2
+ // Applications). Both mirror the loaded anatomy so data resolves in place:
3
+ // rows appear inside the table frame, facets inside the rail — no half-height
4
+ // rail dangling next to a centered spinner, no layout jump when the response
5
+ // lands.
6
+
7
+ export function BoardTableSkeleton({ rows = 10 }: { rows?: number }) {
8
+ return (
9
+ <div aria-live="polite" aria-label="Loading…" className="divide-y divide-theme-border-light">
10
+ {Array.from({ length: rows }, (_, i) => (
11
+ <div key={i} className="flex items-center gap-6 px-4 py-3" style={{ opacity: 1 - i * 0.09 }}>
12
+ <div className="min-w-0 flex-[2] space-y-2">
13
+ <div className="h-4 w-2/3 animate-pulse rounded bg-theme-hover" />
14
+ <div className="h-3 w-1/2 animate-pulse rounded bg-theme-hover" />
15
+ </div>
16
+ <div className="hidden h-4 flex-1 animate-pulse rounded bg-theme-hover md:block" />
17
+ <div className="h-5 w-20 animate-pulse rounded-full bg-theme-hover" />
18
+ <div className="h-5 w-20 animate-pulse rounded-full bg-theme-hover" />
19
+ <div className="hidden h-4 flex-[1.5] animate-pulse rounded bg-theme-hover lg:block" />
20
+ </div>
21
+ ))}
22
+ </div>
23
+ )
24
+ }
25
+
26
+ // Section stubs sized like the loaded rail so it keeps its height while
27
+ // counts are unknown — real facet buttons with "0" counts would be false
28
+ // zeros. `sections` = [title, rowCount] pairs approximating the loaded rail.
29
+ export function BoardRailSkeleton({ sections }: { sections: Array<[string, number]> }) {
30
+ return (
31
+ <div aria-hidden>
32
+ {sections.map(([title, rows]) => (
33
+ <div key={title} className="border-b border-theme-border-light px-3 py-3">
34
+ <div className="mb-2 h-3 w-24 animate-pulse rounded bg-theme-hover" />
35
+ <div className="space-y-1.5">
36
+ {Array.from({ length: rows }, (_, i) => (
37
+ <div key={i} className="flex items-center justify-between px-1.5 py-1">
38
+ <div className="h-3.5 animate-pulse rounded bg-theme-hover" style={{ width: `${52 - i * 6}%` }} />
39
+ <div className="h-3.5 w-6 animate-pulse rounded bg-theme-hover" />
40
+ </div>
41
+ ))}
42
+ </div>
43
+ </div>
44
+ ))}
45
+ </div>
46
+ )
47
+ }
@@ -0,0 +1,117 @@
1
+ import { Fragment, type ComponentType, type ReactNode } from 'react'
2
+
3
+ // =============================================================================
4
+ // CARD SECTION PRIMITIVES — the shared "Sectioned + icons" vocabulary used by
5
+ // the Issue card (IssuesView) and the Check card (ChecksView) so both read as
6
+ // one product: ONE eyebrow style, an icon gutter per section, and a single body
7
+ // contrast floor (body text is never lighter than the secondary token, so it
8
+ // stays legible on the card surface). Severity is the only status color; section
9
+ // icons carry quiet role hues (what's-wrong = amber, fix = emerald, neutral =
10
+ // grey).
11
+ // =============================================================================
12
+
13
+ // Section role → eyebrow + icon hue. Body text always uses the secondary token
14
+ // (the contrast floor), regardless of tone.
15
+ export type CardSectionTone = 'neutral' | 'warn' | 'fix'
16
+
17
+ const SECTION_TONE_CLASS: Record<CardSectionTone, string> = {
18
+ // Eyebrow grey — the default, for neutral sections (Why it matters, Raw error,
19
+ // Affected resources, Context, Subject).
20
+ neutral: 'text-theme-text-tertiary',
21
+ // What's wrong — amber, the diagnosis beat.
22
+ warn: 'text-amber-700 dark:text-amber-300',
23
+ // Next step / How to fix — emerald, the action beat.
24
+ fix: 'text-emerald-700 dark:text-emerald-400',
25
+ }
26
+
27
+ // The single eyebrow style: 11px / 600 / uppercase, with an icon in a fixed
28
+ // gutter so every section's body aligns under the same left edge.
29
+ export function CardSection({
30
+ icon: Icon,
31
+ label,
32
+ tone = 'neutral',
33
+ labelExtra,
34
+ children,
35
+ }: {
36
+ icon: ComponentType<{ className?: string }>
37
+ label: string
38
+ tone?: CardSectionTone
39
+ /** Optional trailing eyebrow text (e.g. "· retried 5×"), rendered muted. */
40
+ labelExtra?: ReactNode
41
+ children: ReactNode
42
+ }) {
43
+ const toneClass = SECTION_TONE_CLASS[tone]
44
+ return (
45
+ <section className="flex items-start gap-2.5">
46
+ {/* Icon sits in a fixed gutter, anchored to the eyebrow line (top-aligned
47
+ with a nudge to its optical center) — sections like Affected resources
48
+ can be dozens of rows tall, and a section-centered glyph would float
49
+ detached mid-list. */}
50
+ <Icon className={`mt-[-1px] h-[18px] w-[18px] shrink-0 ${toneClass}`} aria-hidden />
51
+ <div className="flex min-w-0 flex-1 flex-col gap-1">
52
+ <h4 className={`text-[11px] font-semibold uppercase tracking-[0.06em] ${toneClass}`}>
53
+ {label}
54
+ {labelExtra ? <span className="font-medium normal-case tracking-normal text-theme-text-tertiary"> {labelExtra}</span> : null}
55
+ </h4>
56
+ {children}
57
+ </div>
58
+ </section>
59
+ )
60
+ }
61
+
62
+ // Body paragraph at the contrast floor — 14px, secondary token, never lighter.
63
+ export function CardBody({ children, className }: { children: ReactNode; className?: string }) {
64
+ return <p className={`text-sm leading-relaxed text-theme-text-secondary ${className ?? ''}`}>{children}</p>
65
+ }
66
+
67
+ // The dark terminal block for raw error output. Intentionally dark in BOTH
68
+ // themes (it's a console surface, not chrome), so it draws from the dedicated
69
+ // --terminal-* tokens rather than the theme surface tokens.
70
+ export function TerminalBlock({ label, children }: { label?: ReactNode; children: ReactNode }) {
71
+ return (
72
+ <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}
78
+ <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
+ {children}
80
+ </pre>
81
+ </div>
82
+ )
83
+ }
84
+
85
+ // The neutral category chip — drops the per-category hue so severity is the only
86
+ // status color. Self-contained (don't pair with badge-sm): 11px / 600, 2px×8px
87
+ // padding, no border, neutral grey.
88
+ export const NEUTRAL_CHIP_CLASS =
89
+ '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
+
91
+ // Mono kind chip — the grey tag for an all-caps resource Kind (the Issue row
92
+ // header + the Subject footer row). Mono and tighter than NEUTRAL_CHIP_CLASS,
93
+ // which carries the label/category chips.
94
+ export const KIND_CHIP_CLASS =
95
+ 'shrink-0 rounded bg-theme-elevated px-1.5 py-px font-mono text-[11px] uppercase text-theme-text-secondary'
96
+
97
+ // Render a prose string, turning markdown `inline-code` spans into mono chips
98
+ // via the shared .inline-code class. Plain-text passthrough when the string
99
+ // carries no backticks, so it's a no-op for today's plain catalog copy and
100
+ // lights up automatically once backtick markup is added.
101
+ export function renderProse(text: string | undefined | null): ReactNode {
102
+ if (!text) return null
103
+ if (!text.includes('`')) return text
104
+ const parts = text.split(/(`[^`]+`)/g)
105
+ return parts.map((part, i) => {
106
+ // length > 2 excludes a bare "``" (which the split leaves in a plain part)
107
+ // so it renders as literal text, not an empty chip.
108
+ if (part.length > 2 && part.startsWith('`') && part.endsWith('`')) {
109
+ return (
110
+ <code key={i} className="inline-code">
111
+ {part.slice(1, -1)}
112
+ </code>
113
+ )
114
+ }
115
+ return <Fragment key={i}>{part}</Fragment>
116
+ })
117
+ }
@@ -215,7 +215,7 @@ function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }
215
215
  <button
216
216
  onClick={toast.onDetailClick}
217
217
  className={clsx(
218
- 'mt-1.5 block text-xs font-mono break-all text-left rounded px-1.5 py-1 -ml-1.5 transition-colors',
218
+ 'mt-1.5 block text-xs font-mono break-words text-left rounded px-1.5 py-1 -ml-1.5 transition-colors',
219
219
  isError
220
220
  ? 'text-red-300 hover:bg-red-900/50'
221
221
  : isSuccess
@@ -38,3 +38,5 @@ export { DistributionBar, DistributionLegendChip } from './DistributionBar'
38
38
  export type { DistributionSegment } from './DistributionBar'
39
39
  export { SearchPillInput } from './SearchPillInput'
40
40
  export type { SearchModifier, SearchPillInputProps } from './SearchPillInput'
41
+ export { CardSection, CardBody, TerminalBlock, renderProse, NEUTRAL_CHIP_CLASS, KIND_CHIP_CLASS } from './CardSection'
42
+ export type { CardSectionTone } from './CardSection'
@@ -0,0 +1,56 @@
1
+ // Shared severity color vocabulary. The Issue card (2-tier: critical/warning)
2
+ // and the Check card (4-tier: critical/high/medium/low) are different severity
3
+ // axes, but every tier resolves to one of four hues — so the color strings live
4
+ // here once and each card maps its own severity enum onto a tone. A palette
5
+ // change lands in one place instead of drifting between the two cards.
6
+ //
7
+ // Literal class strings (no template interpolation) so Tailwind's scanner sees
8
+ // every utility.
9
+
10
+ export type SeverityTone = 'red' | 'orange' | 'amber' | 'slate'
11
+
12
+ // Text color — leading severity glyph + emphasis text.
13
+ export const TONE_TEXT_CLASS: Record<SeverityTone, string> = {
14
+ red: 'text-red-600 dark:text-red-400',
15
+ orange: 'text-orange-600 dark:text-orange-400',
16
+ amber: 'text-amber-600 dark:text-amber-400',
17
+ slate: 'text-slate-500 dark:text-slate-400',
18
+ }
19
+
20
+ // Solid fill — dots + the proportional distribution-bar segments.
21
+ export const TONE_FILL_CLASS: Record<SeverityTone, string> = {
22
+ red: 'bg-red-500',
23
+ orange: 'bg-orange-500',
24
+ amber: 'bg-amber-500',
25
+ slate: 'bg-slate-400',
26
+ }
27
+
28
+ // Left accent rail on a collapsed queue row — the scan-down severity cue + a
29
+ // faint hover tint. The expanded row swaps this for the header band below.
30
+ export const TONE_RAIL_CLASS: Record<SeverityTone, string> = {
31
+ red: 'border-l-red-500 hover:bg-red-50/40 dark:hover:bg-red-950/20',
32
+ orange: 'border-l-orange-500 hover:bg-orange-50/40 dark:hover:bg-orange-950/20',
33
+ amber: 'border-l-amber-500 hover:bg-amber-50/30 dark:hover:bg-amber-950/15',
34
+ slate: 'border-l-slate-300 dark:border-l-slate-600 hover:bg-theme-hover/40',
35
+ }
36
+
37
+ // Solid severity pill — the loud focus signal on an expanded card's header band
38
+ // (collapsed cards keep the soft BADGE pill so a long queue stays calm).
39
+ // The warm mid-tones (orange/amber) take dark text: white on orange-600/amber-500
40
+ // sits at ~3.5:1/~2.1:1, below WCAG for the 12px pill text.
41
+ export const TONE_SOLID_CLASS: Record<SeverityTone, string> = {
42
+ red: 'bg-red-600 text-white',
43
+ orange: 'bg-orange-500 text-orange-950',
44
+ amber: 'bg-amber-400 text-amber-950',
45
+ slate: 'bg-slate-500 text-white dark:bg-slate-600',
46
+ }
47
+
48
+ // Severity-tinted header band, shown only when a card is expanded. The left
49
+ // rail keeps its color so the queue's scan-down severity rhythm doesn't break
50
+ // at the focused row; no hover variant, so it doesn't lighten on hover.
51
+ export const TONE_HEADER_BAND_CLASS: Record<SeverityTone, string> = {
52
+ red: 'border-l-red-500 bg-red-50 dark:bg-red-950/40',
53
+ orange: 'border-l-orange-500 bg-orange-50 dark:bg-orange-950/30',
54
+ amber: 'border-l-amber-500 bg-amber-50 dark:bg-amber-950/30',
55
+ slate: 'border-l-slate-300 dark:border-l-slate-600 bg-slate-50 dark:bg-slate-900/40',
56
+ }
@@ -54,6 +54,10 @@
54
54
  --border-default: light-dark(#D8E0EE, #222840);
55
55
  --border-light: light-dark(#CDD6E8, #2C324A);
56
56
  --border-subtle: light-dark(rgba(74, 100, 180, 0.07), rgba(255, 255, 255, 0.04));
57
+ /* Emphasis edge for the focused/expanded card in a queue — must read clearly
58
+ against sibling cards' --border-default, unlike --border-light's one-notch
59
+ step. */
60
+ --border-emphasis: light-dark(#96A8CA, rgba(255, 255, 255, 0.22));
57
61
  --border-focus: var(--color-brand);
58
62
 
59
63
  /* ── Accent ── */
@@ -108,6 +112,16 @@
108
112
  --group-icon-app: light-dark(#059669, #34d399);
109
113
  --group-icon-label: light-dark(#d97706, #fbbf24);
110
114
 
115
+ /* ── Terminal / console surface (intentionally dark in BOTH themes — it's a
116
+ console, not chrome). The outer border darkens on a light page and lightens
117
+ on a dark one so the block stays defined; the inner divider and text stay
118
+ light because the surface is always dark. ── */
119
+ --terminal-bg: #0F172A;
120
+ --terminal-border: light-dark(#1E293B, rgba(255, 255, 255, 0.10));
121
+ --terminal-divider: rgba(255, 255, 255, 0.10);
122
+ --terminal-label: #94A3B8;
123
+ --terminal-text: #CBD5E1;
124
+
111
125
  /* ── ANSI terminal colors (theme-aware for log viewer) ── */
112
126
  --ansi-black: light-dark(#3b3b3b, #4c4c4c);
113
127
  --ansi-red: light-dark(#cd3131, #cd3131);
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { categorizeResources, formatGroupName, shortenGroupName } from './api-resources'
3
+
4
+ describe('formatGroupName', () => {
5
+ it('uses friendly names for common CRD groups seen in clusters', () => {
6
+ expect(formatGroupName('policies.kyverno.io')).toBe('Kyverno')
7
+ expect(formatGroupName('networking.gke.io')).toBe('GKE Networking')
8
+ expect(formatGroupName('apiregistration.k8s.io')).toBe('API Registration')
9
+ expect(formatGroupName('monitoring.googleapis.com')).toBe('Google Cloud Monitoring')
10
+ expect(formatGroupName('sql.cnrm.cloud.google.com')).toBe('Config Connector')
11
+ expect(formatGroupName('crd.k8s.amazonaws.com')).toBe('AWS VPC CNI')
12
+ expect(formatGroupName('acid.zalan.do')).toBe('Zalando Postgres')
13
+ })
14
+
15
+ it('formats unmapped API groups without exposing raw domain strings', () => {
16
+ expect(formatGroupName('widgets.example.io')).toBe('Example Widgets')
17
+ expect(formatGroupName('api.my-company.dev')).toBe('My Company API')
18
+ expect(formatGroupName('dns.gke.example.io')).toBe('Example DNS GKE')
19
+ expect(formatGroupName('widgets.example.io')).not.toMatch(/\./)
20
+ })
21
+
22
+ it('does not promote Kubernetes domain-family suffixes into visible labels', () => {
23
+ expect(formatGroupName('flowcontrol.apiserver.k8s.io')).toBe('Apiserver Flowcontrol')
24
+ expect(formatGroupName('addons.cluster.x-k8s.io')).toBe('Cluster API')
25
+ expect(formatGroupName('ipam.cluster.x-k8s.io')).toBe('Cluster API')
26
+ expect(formatGroupName('nfd.k8s-sigs.io')).toBe('NFD')
27
+ })
28
+
29
+ it('keeps unmapped CRD groups separate from core categories', () => {
30
+ expect(formatGroupName('networking.io')).toBe('Networking APIs')
31
+ expect(formatGroupName('storage.io')).toBe('Storage APIs')
32
+ })
33
+
34
+ it('keeps shortenGroupName as the legacy suffix-stripping helper', () => {
35
+ expect(shortenGroupName('networking.gke.io')).toBe('networking.gke')
36
+ expect(shortenGroupName('rbac.authorization.k8s.io')).toBe('rbac.authorization')
37
+ expect(shortenGroupName('widgets.example.dev')).toBe('widgets.example')
38
+ })
39
+ })
40
+
41
+ describe('categorizeResources', () => {
42
+ it('does not merge unmapped CRDs into matching core categories', () => {
43
+ const categories = categorizeResources([
44
+ { group: 'networking.io', version: 'v1', kind: 'WidgetRoute', name: 'widgetroutes', namespaced: true, isCrd: true, verbs: ['list'] },
45
+ ])
46
+
47
+ const networking = categories.find(c => c.name === 'Networking')
48
+ const networkingAPIs = categories.find(c => c.name === 'Networking APIs')
49
+
50
+ expect(networking?.resources.some(r => r.kind === 'Service')).toBe(true)
51
+ expect(networking?.resources.some(r => r.kind === 'WidgetRoute')).toBe(false)
52
+ expect(networkingAPIs?.resources.map(r => r.kind)).toEqual(['WidgetRoute'])
53
+ })
54
+ })