@skyhook-io/k8s-ui 1.8.8 → 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.
- package/package.json +1 -1
- package/src/components/applications/ApplicationsList.tsx +4 -1
- package/src/components/applications/ApplicationsView.tsx +73 -38
- package/src/components/checks/ChecksView.tsx +105 -63
- package/src/components/checks/severity.ts +29 -39
- package/src/components/gitops/GitOpsTableView.tsx +72 -43
- package/src/components/issues/IssuesView.tsx +186 -123
- package/src/components/issues/ResourceIssuesSection.test.tsx +39 -0
- package/src/components/issues/ResourceIssuesSection.tsx +21 -3
- package/src/components/issues/index.ts +0 -2
- package/src/components/issues/issues.test.ts +45 -4
- package/src/components/issues/severity.ts +27 -32
- package/src/components/resources/ResourcesSidebar.test.tsx +36 -0
- package/src/components/resources/ResourcesSidebar.tsx +24 -6
- package/src/components/resources/ResourcesView.tsx +10 -2
- package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +47 -0
- package/src/components/resources/renderers/NodeRenderer.test.tsx +103 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +20 -12
- package/src/components/resources/renderers/PVCRenderer.test.tsx +23 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +2 -2
- package/src/components/resources/renderers/PodRenderer.test.tsx +101 -0
- package/src/components/resources/renderers/PodRenderer.tsx +81 -70
- package/src/components/ui/BoardSkeleton.tsx +47 -0
- package/src/components/ui/CardSection.tsx +117 -0
- package/src/components/ui/SummaryTile.tsx +9 -1
- package/src/components/ui/Toast.tsx +1 -1
- package/src/components/ui/index.ts +2 -0
- package/src/components/ui/severity-tone.ts +56 -0
- package/src/filter-state/filter-state-core.test.ts +98 -0
- package/src/filter-state/filter-state-core.ts +138 -0
- package/src/filter-state/filter-state.tsx +127 -0
- package/src/filter-state/index.ts +16 -0
- package/src/index.ts +4 -0
- package/src/theme/variables.css +14 -0
- package/src/utils/api-resources.test.ts +54 -0
- package/src/utils/api-resources.ts +68 -1
- 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 && !!(
|
|
727
|
+
{!hideMetricsServer && !!(currentMetrics?.containers?.length || hasMetricsHistory || metricsHistory?.collectionError || showMetricsUnavailable) && (
|
|
722
728
|
<Section title="Resource Usage" icon={Activity} defaultExpanded>
|
|
723
|
-
{
|
|
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
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
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
|
-
|
|
742
|
-
|
|
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
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
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
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
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
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
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
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
)
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
{
|
|
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(
|
|
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
|
+
}
|
|
@@ -12,12 +12,16 @@ export function SummaryTile({
|
|
|
12
12
|
tone = 'neutral',
|
|
13
13
|
onClick,
|
|
14
14
|
active = false,
|
|
15
|
+
loading = false,
|
|
15
16
|
}: {
|
|
16
17
|
label: string
|
|
17
18
|
value: number
|
|
18
19
|
tone?: SummaryTone
|
|
19
20
|
onClick?: () => void
|
|
20
21
|
active?: boolean
|
|
22
|
+
/** First fetch in flight — render a pulse instead of the value. A tile
|
|
23
|
+
* that reads "0" while loading is a false zero, not a count. */
|
|
24
|
+
loading?: boolean
|
|
21
25
|
}) {
|
|
22
26
|
const toneClass = {
|
|
23
27
|
neutral: 'text-theme-text-primary',
|
|
@@ -33,7 +37,11 @@ export function SummaryTile({
|
|
|
33
37
|
error: 'border-red-500',
|
|
34
38
|
info: 'border-sky-500',
|
|
35
39
|
}[tone]
|
|
36
|
-
const value$ =
|
|
40
|
+
const value$ = loading ? (
|
|
41
|
+
<div className="my-1 h-3.5 w-8 animate-pulse rounded bg-theme-hover" aria-hidden />
|
|
42
|
+
) : (
|
|
43
|
+
<div className={`text-sm font-semibold ${toneClass}`}>{value}</div>
|
|
44
|
+
)
|
|
37
45
|
const label$ = <div className="text-xs text-theme-text-tertiary">{label}</div>
|
|
38
46
|
if (!onClick) {
|
|
39
47
|
return (
|
|
@@ -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-
|
|
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
|
+
}
|