Make workloads table renderers CSP-safe

This commit is contained in:
rcourtman 2026-04-11 20:46:40 +01:00
parent aae9be2441
commit 5add970dbd
20 changed files with 450 additions and 229 deletions

View file

@ -190,6 +190,11 @@ work extends shared components instead of creating new local variants.
tooltip positioning, but it must not write inline `style=` attributes for
tick labels, tooltip placement, or per-series transitions on the public
shell.
The same shared presentation boundary also owns reusable scroll containers:
`frontend-modern/src/components/shared/Table.tsx` must keep touch-scroll
behavior on classes and shared CSS in `frontend-modern/src/index.css`
instead of reintroducing inline `style=` attributes for overflow or mobile
scroll behavior.
3. Add feature-specific presentation only when no shared primitive should own it
4. Add guardrail tests when a new shared pattern is introduced
5. Keep shared platform-connections shell state on the reusable settings boundary: `frontend-modern/src/components/Settings/useSettingsInfrastructurePanelProps.ts`, `frontend-modern/src/components/Settings/InfrastructurePlatformConnectionsSummaryCard.tsx`, and `frontend-modern/src/components/Settings/PlatformConnectionsWorkspace.tsx` must continue to derive provider counts, availability, and shared subtab copy from one infrastructure-settings source instead of creating provider-local summary fetches or VMware-only shell vocabulary.

View file

@ -250,6 +250,17 @@ regression protection.
second dashboard data load, widen suspense ownership, or force dashboard
summaries back through full-resource fetch paths just to satisfy page
chrome.
35. Keep the dashboard workloads table CSP-safe on the hot path. The renderers
in `frontend-modern/src/components/Dashboard/DashboardWorkloadTable.tsx`,
`frontend-modern/src/components/Dashboard/WorkloadTableHeader.tsx`,
`frontend-modern/src/components/Dashboard/GuestRow.tsx`,
`frontend-modern/src/components/Dashboard/EnhancedCPUBar.tsx`,
`frontend-modern/src/components/Dashboard/StackedMemoryBar.tsx`, and
`frontend-modern/src/components/Dashboard/StackedDiskBar.tsx` may still
use shared models plus SVG or HTML attributes for widths, offsets, and
colors, but they must not fall back to inline `style=` attributes on the
public shell just to express virtualization spacers, alert accents, or
workload metric bars.
## Forbidden Paths

View file

@ -71,11 +71,7 @@ export function DashboardWorkloadTable(props: DashboardWorkloadTableProps) {
/>
<Table
wrapperRef={props.setTableWrapperRef}
class="whitespace-nowrap min-w-[max-content]"
style={{
'table-layout': 'fixed',
'min-width': props.isMobile() ? '100%' : 'max-content',
}}
class={`workload-table ${props.isMobile() ? 'workload-table--mobile' : 'workload-table--desktop'}`}
>
<WorkloadTableHeader
handleSort={props.handleSort}

View file

@ -6,6 +6,11 @@ import { useEnhancedCPUBarState } from './useEnhancedCPUBarState';
export function EnhancedCPUBar(props: EnhancedCPUBarProps) {
const state = useEnhancedCPUBarState(props);
const presentation = state.presentation;
const barPercent = () => {
const parsed = Number.parseFloat(presentation().barWidth);
if (!Number.isFinite(parsed)) return '0';
return String(Math.max(0, Math.min(parsed, 100)));
};
return (
<div class="metric-text w-full h-4 flex items-center justify-center">
@ -14,10 +19,22 @@ export function EnhancedCPUBar(props: EnhancedCPUBarProps) {
onMouseEnter={state.handleMouseEnter}
onMouseLeave={state.handleMouseLeave}
>
<div
class={`absolute top-0 left-0 h-full transition-all duration-300 ${presentation().barClass}`}
style={{ width: presentation().barWidth }}
/>
<svg
aria-hidden="true"
class="absolute inset-0 h-full w-full"
viewBox="0 0 100 100"
preserveAspectRatio="none"
>
<rect
data-enhanced-cpu-fill="true"
x="0"
y="0"
width={barPercent()}
height="100"
rx="3"
fill={presentation().barFill}
/>
</svg>
<span class="absolute inset-0 flex items-center justify-center text-[10px] font-semibold text-base-content leading-none pointer-events-none">
{presentation().displayUsage}

View file

@ -79,8 +79,8 @@ export function GuestRow(props: GuestRowProps) {
ociImage,
osName,
osVersion,
alertAccentTone,
rowClass,
rowStyle,
supportsBackup,
typeInfo,
workloadType,
@ -106,9 +106,9 @@ export function GuestRow(props: GuestRowProps) {
return (
<>
<tr
class={`${rowClass()} ${props.onClick ? 'cursor-pointer group' : ''}`.trim()}
style={rowStyle()}
class={`${rowClass()} workload-row ${props.onClick ? 'cursor-pointer group' : ''}`.trim()}
data-guest-id={guestId()}
data-workload-alert-accent={alertAccentTone()}
data-summary-series-id={guestId()}
data-summary-group-member-active={
props.summaryGroupMemberState && props.summaryGroupMemberState !== 'default'
@ -211,14 +211,7 @@ export function GuestRow(props: GuestRowProps) {
{/* CPU */}
<Show when={isColVisible('cpu')}>
<td
class="px-1.5 sm:px-2 py-0.5 align-middle"
style={
isMobile()
? { 'min-width': '60px' }
: { width: '140px', 'min-width': '140px', 'max-width': '140px' }
}
>
<td class="px-1.5 sm:px-2 py-0.5 align-middle" data-workload-col="cpu">
<div class="h-4">
<EnhancedCPUBar
usage={cpuPercent()}
@ -232,14 +225,7 @@ export function GuestRow(props: GuestRowProps) {
{/* Memory */}
<Show when={isColVisible('memory')}>
<td
class="px-1.5 sm:px-2 py-0.5 align-middle"
style={
isMobile()
? { 'min-width': '60px' }
: { width: '140px', 'min-width': '140px', 'max-width': '140px' }
}
>
<td class="px-1.5 sm:px-2 py-0.5 align-middle" data-workload-col="memory">
<div title={memoryTooltip() ?? undefined}>
<StackedMemoryBar
used={props.guest.memory?.used || 0}
@ -257,14 +243,7 @@ export function GuestRow(props: GuestRowProps) {
{/* Disk */}
<Show when={isColVisible('disk')}>
<td
class="px-1.5 sm:px-2 py-0.5 align-middle"
style={
isMobile()
? { 'min-width': '60px' }
: { width: '140px', 'min-width': '140px', 'max-width': '140px' }
}
>
<td class="px-1.5 sm:px-2 py-0.5 align-middle" data-workload-col="disk">
<Show
when={hasDiskUsage()}
fallback={

View file

@ -6,6 +6,12 @@ import { useStackedDiskBarState } from './useStackedDiskBarState';
export function StackedDiskBar(props: StackedDiskBarProps) {
const state = useStackedDiskBarState(props);
const presentation = state.presentation;
const clampPercent = (value: number) => String(Math.max(0, Math.min(value, 100)));
const parsePercent = (value: string) => {
const parsed = Number.parseFloat(value);
if (!Number.isFinite(parsed)) return '0';
return String(Math.max(0, Math.min(parsed, 100)));
};
return (
<div ref={state.setContainerRef} class={presentation().containerClass}>
@ -19,34 +25,70 @@ export function StackedDiskBar(props: StackedDiskBarProps) {
>
{/* Stacked segments for multiple disks */}
<Show when={presentation().useStackedSegments}>
<div class="absolute top-0 left-0 h-full w-full flex">
<svg
aria-hidden="true"
class="absolute inset-0 h-full w-full"
viewBox="0 0 100 100"
preserveAspectRatio="none"
>
<For each={presentation().segments}>
{(segment, idx) => (
<div
class="h-full"
style={{
width: `${segment.widthPercent}%`,
'background-color': segment.color,
'border-right':
idx() < presentation().segments.length - 1
? '1px solid rgba(255,255,255,0.3)'
: 'none',
}}
/>
<>
<rect
data-stacked-disk-fill="segment"
x={String(
presentation()
.segments.slice(0, idx())
.reduce((sum, item) => sum + item.widthPercent, 0),
)}
y="0"
width={clampPercent(segment.widthPercent)}
height="100"
rx="3"
fill={segment.color}
/>
<Show when={idx() < presentation().segments.length - 1}>
<line
x1={String(
presentation()
.segments.slice(0, idx() + 1)
.reduce((sum, item) => sum + item.widthPercent, 0),
)}
x2={String(
presentation()
.segments.slice(0, idx() + 1)
.reduce((sum, item) => sum + item.widthPercent, 0),
)}
y1="0"
y2="100"
stroke="rgba(255,255,255,0.3)"
stroke-width="1"
/>
</Show>
</>
)}
</For>
</div>
</svg>
</Show>
{/* Single bar for aggregate or single disk */}
<Show when={!presentation().useStackedSegments}>
<div
class="absolute top-0 left-0 h-full"
style={{
width: `${presentation().barPercent}%`,
'background-color': presentation().barColor,
}}
/>
<svg
aria-hidden="true"
class="absolute inset-0 h-full w-full"
viewBox="0 0 100 100"
preserveAspectRatio="none"
>
<rect
data-stacked-disk-fill="single"
x="0"
y="0"
width={clampPercent(presentation().barPercent)}
height="100"
rx="3"
fill={presentation().barColor}
/>
</svg>
</Show>
{/* Label overlay */}
@ -83,26 +125,30 @@ export function StackedDiskBar(props: StackedDiskBarProps) {
}
>
<div class="w-full" onMouseEnter={state.handleMouseEnter} onMouseLeave={state.handleMouseLeave}>
<div
class="grid gap-1"
style={{
'grid-template-columns': `repeat(${presentation().miniDisks.length}, minmax(0, 1fr))`,
}}
>
<div class="flex items-stretch gap-1">
<For each={presentation().miniDisks}>
{(disk) => (
<div class="flex flex-col items-stretch gap-0.5">
<div class="flex min-w-0 flex-1 flex-col items-stretch gap-0.5">
<span class="text-[8px] text-muted truncate" title={disk.label}>
{disk.label}
</span>
<div class="relative h-2.5 rounded-sm bg-surface-alt overflow-hidden">
<div
class="h-full"
style={{
width: `${Math.min(disk.percent, 100)}%`,
'background-color': disk.color,
}}
/>
<svg
aria-hidden="true"
class="absolute inset-0 h-full w-full"
viewBox="0 0 100 100"
preserveAspectRatio="none"
>
<rect
data-stacked-disk-fill="mini"
x="0"
y="0"
width={clampPercent(disk.percent)}
height="100"
rx="2"
fill={disk.color}
/>
</svg>
</div>
</div>
)}
@ -128,21 +174,33 @@ export function StackedDiskBar(props: StackedDiskBarProps) {
classList={{ 'border-t border-border': idx() > 0 }}
>
<div class="flex justify-between gap-3">
<span class="truncate max-w-[100px]" style={{ color: item.color }}>
{item.label}
<span class="flex max-w-[100px] items-center gap-1 truncate text-slate-300">
<svg aria-hidden="true" class="h-2 w-2 shrink-0" viewBox="0 0 8 8">
<circle cx="4" cy="4" r="4" fill={item.color} />
</svg>
<span class="truncate">{item.label}</span>
</span>
<span class="whitespace-nowrap text-slate-300">
{item.percent} ({item.used}/{item.total})
</span>
</div>
<div class="h-1.5 w-full rounded bg-surface-hover overflow-hidden">
<div
class="h-full"
style={{
width: item.percent,
'background-color': item.color,
}}
/>
<div class="relative h-1.5 w-full overflow-hidden rounded bg-surface-hover">
<svg
aria-hidden="true"
class="absolute inset-0 h-full w-full"
viewBox="0 0 100 100"
preserveAspectRatio="none"
>
<rect
data-stacked-disk-fill="tooltip"
x="0"
y="0"
width={parsePercent(item.percent)}
height="100"
rx="2"
fill={item.color}
/>
</svg>
</div>
</div>
)}

View file

@ -15,6 +15,16 @@ global.ResizeObserver = class ResizeObserver {
unobserve = vi.fn();
};
function getSegments(container: HTMLElement): SVGRectElement[] {
return Array.from(
container.querySelectorAll<SVGRectElement>('rect[data-stacked-memory-segment="true"]'),
);
}
function getSwapBar(container: HTMLElement): SVGRectElement | null {
return container.querySelector('rect[data-stacked-memory-swap="true"]');
}
describe('StackedMemoryBar', () => {
beforeEach(() => {
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, value: 200 });
@ -65,9 +75,9 @@ describe('StackedMemoryBar', () => {
<StackedMemoryBar used={10 * 1024 ** 3} total={8 * 1024 ** 3} />
));
expect(screen.getByText('125%')).toBeInTheDocument();
// Segment width also exceeds 100% (CSS overflow:hidden on parent clips visually)
const segment = container.querySelector('.absolute.top-0.h-full') as HTMLElement;
expect(segment.style.width).toBe('125%');
// Segment width also exceeds 100% (the SVG viewBox clips visually)
const segment = getSegments(container)[0];
expect(segment).toHaveAttribute('width', '125');
});
// ---- Segments ----
@ -77,10 +87,10 @@ describe('StackedMemoryBar', () => {
<StackedMemoryBar used={2 * 1024 ** 3} total={8 * 1024 ** 3} />
));
// The bar fills 25%
const segments = container.querySelectorAll('.absolute.top-0.h-full');
const segments = getSegments(container);
expect(segments.length).toBe(1);
expect((segments[0] as HTMLElement).style.width).toBe('25%');
expect((segments[0] as HTMLElement).style.left).toBe('0%');
expect(segments[0]).toHaveAttribute('width', '25');
expect(segments[0]).toHaveAttribute('x', '0');
});
it('renders balloon segment when active ballooning is in effect', () => {
@ -88,14 +98,14 @@ describe('StackedMemoryBar', () => {
const { container } = render(() => (
<StackedMemoryBar used={2 * 1024 ** 3} total={8 * 1024 ** 3} balloon={4 * 1024 ** 3} />
));
const segments = container.querySelectorAll('.absolute.top-0.h-full');
const segments = getSegments(container);
// Active segment + Balloon segment
expect(segments.length).toBe(2);
// Active: 2/8 = 25%
expect((segments[0] as HTMLElement).style.width).toBe('25%');
expect(segments[0]).toHaveAttribute('width', '25');
// Balloon: (4/8)*100 - 25 = 25%
expect((segments[1] as HTMLElement).style.width).toBe('25%');
expect((segments[1] as HTMLElement).style.left).toBe('25%');
expect(segments[1]).toHaveAttribute('width', '25');
expect(segments[1]).toHaveAttribute('x', '25');
});
it('does not render balloon segment when balloon equals total', () => {
@ -103,7 +113,7 @@ describe('StackedMemoryBar', () => {
const { container } = render(() => (
<StackedMemoryBar used={2 * 1024 ** 3} total={8 * 1024 ** 3} balloon={8 * 1024 ** 3} />
));
const segments = container.querySelectorAll('.absolute.top-0.h-full');
const segments = getSegments(container);
expect(segments.length).toBe(1);
});
@ -111,7 +121,7 @@ describe('StackedMemoryBar', () => {
const { container } = render(() => (
<StackedMemoryBar used={2 * 1024 ** 3} total={8 * 1024 ** 3} balloon={0} />
));
const segments = container.querySelectorAll('.absolute.top-0.h-full');
const segments = getSegments(container);
expect(segments.length).toBe(1);
});
@ -120,23 +130,23 @@ describe('StackedMemoryBar', () => {
const { container } = render(() => (
<StackedMemoryBar used={5 * 1024 ** 3} total={8 * 1024 ** 3} balloon={4 * 1024 ** 3} />
));
const segments = container.querySelectorAll('.absolute.top-0.h-full');
const segments = getSegments(container);
// Only active segment (balloon filtered out because used > balloon)
expect(segments.length).toBe(1);
});
it('renders no segments when used is 0 and total > 0', () => {
const { container } = render(() => <StackedMemoryBar used={0} total={8 * 1024 ** 3} />);
const segments = container.querySelectorAll('.absolute.top-0.h-full');
const segments = getSegments(container);
// bytes=0 is filtered out
expect(segments.length).toBe(0);
});
it('renders percent-only segment (no bytes) when total is 0 but percentOnly > 0', () => {
const { container } = render(() => <StackedMemoryBar used={0} total={0} percentOnly={60} />);
const segments = container.querySelectorAll('.absolute.top-0.h-full');
const segments = getSegments(container);
expect(segments.length).toBe(1);
expect((segments[0] as HTMLElement).style.width).toBe('60%');
expect(segments[0]).toHaveAttribute('width', '60');
});
// ---- Swap ----
@ -151,10 +161,10 @@ describe('StackedMemoryBar', () => {
/>
));
// Swap indicator is the 3px bar at the bottom
const swapBar = container.querySelector('.h-\\[3px\\]');
const swapBar = getSwapBar(container);
expect(swapBar).toBeInTheDocument();
// 1/2 = 50%
expect((swapBar as HTMLElement).style.width).toBe('50%');
expect(swapBar).toHaveAttribute('width', '50');
});
it('does not render swap indicator when swapUsed is 0', () => {
@ -166,7 +176,7 @@ describe('StackedMemoryBar', () => {
swapTotal={2 * 1024 ** 3}
/>
));
const swapBar = container.querySelector('.h-\\[3px\\]');
const swapBar = getSwapBar(container);
expect(swapBar).not.toBeInTheDocument();
});
@ -174,7 +184,7 @@ describe('StackedMemoryBar', () => {
const { container } = render(() => (
<StackedMemoryBar used={4 * 1024 ** 3} total={8 * 1024 ** 3} swapTotal={0} />
));
const swapBar = container.querySelector('.h-\\[3px\\]');
const swapBar = getSwapBar(container);
expect(swapBar).not.toBeInTheDocument();
});
@ -187,10 +197,10 @@ describe('StackedMemoryBar', () => {
swapTotal={2 * 1024 ** 3}
/>
));
const swapBar = container.querySelector('.h-\\[3px\\]');
const swapBar = getSwapBar(container);
expect(swapBar).toBeInTheDocument();
// Math.min(150, 100) = 100%
expect((swapBar as HTMLElement).style.width).toBe('100%');
expect(swapBar).toHaveAttribute('width', '100');
});
// ---- Sublabel (bytes display) ----
@ -281,27 +291,27 @@ describe('StackedMemoryBar', () => {
const { container } = render(() => (
<StackedMemoryBar used={2 * 1024 ** 3} total={8 * 1024 ** 3} />
));
const segment = container.querySelector('.absolute.top-0.h-full') as HTMLElement;
const segment = getSegments(container)[0];
// 25% → normal → green
expect(segment.style.backgroundColor).toContain('34, 197, 94');
expect(segment.getAttribute('fill')).toContain('34, 197, 94');
});
it('uses yellow color for warning-level memory usage', () => {
const { container } = render(() => (
<StackedMemoryBar used={6 * 1024 ** 3} total={8 * 1024 ** 3} />
));
const segment = container.querySelector('.absolute.top-0.h-full') as HTMLElement;
const segment = getSegments(container)[0];
// 75% → warning → yellow
expect(segment.style.backgroundColor).toContain('234, 179, 8');
expect(segment.getAttribute('fill')).toContain('234, 179, 8');
});
it('uses red color for critical memory usage', () => {
const { container } = render(() => (
<StackedMemoryBar used={7 * 1024 ** 3} total={8 * 1024 ** 3} />
));
const segment = container.querySelector('.absolute.top-0.h-full') as HTMLElement;
const segment = getSegments(container)[0];
// 87.5% → critical → red
expect(segment.style.backgroundColor).toContain('239, 68, 68');
expect(segment.getAttribute('fill')).toContain('239, 68, 68');
});
// ---- ResizeObserver lifecycle ----

View file

@ -6,6 +6,9 @@ import { useStackedMemoryBarState } from './useStackedMemoryBarState';
export function StackedMemoryBar(props: StackedMemoryBarProps) {
const state = useStackedMemoryBarState(props);
const presentation = state.presentation;
const swapBarWidth = () => String(Math.max(0, Math.min(presentation().swapBarPercent, 100)));
const segmentEdge = (leftPercent: number, widthPercent: number) =>
String(Math.max(0, Math.min(leftPercent + widthPercent, 100)));
return (
<div ref={state.setContainerRef} class="metric-text w-full h-4 flex items-center justify-center">
@ -14,29 +17,49 @@ export function StackedMemoryBar(props: StackedMemoryBarProps) {
onMouseEnter={state.handleMouseEnter}
onMouseLeave={state.handleMouseLeave}
>
<For each={presentation().segments}>
{(segment, idx) => (
<div
class="absolute top-0 h-full transition-all duration-300"
style={{
left: `${segment.leftPercent}%`,
width: `${segment.widthPercent}%`,
'background-color': segment.color,
'border-right':
idx() < presentation().segments.length - 1
? '1px solid rgba(255,255,255,0.3)'
: 'none',
}}
<svg
aria-hidden="true"
class="absolute inset-0 h-full w-full"
viewBox="0 0 100 100"
preserveAspectRatio="none"
>
<For each={presentation().segments}>
{(segment, idx) => (
<>
<rect
data-stacked-memory-segment="true"
x={String(Math.max(0, Math.min(segment.leftPercent, 100)))}
y="0"
width={String(Math.max(0, segment.widthPercent))}
height="100"
rx="3"
fill={segment.color}
/>
<Show when={idx() < presentation().segments.length - 1}>
<line
x1={segmentEdge(segment.leftPercent, segment.widthPercent)}
x2={segmentEdge(segment.leftPercent, segment.widthPercent)}
y1="0"
y2="100"
stroke="rgba(255,255,255,0.3)"
stroke-width="1"
/>
</Show>
</>
)}
</For>
<Show when={presentation().showSwapBar}>
<rect
data-stacked-memory-swap="true"
x="0"
y="82"
width={swapBarWidth()}
height="18"
rx="2"
fill="rgb(168 85 247)"
/>
)}
</For>
<Show when={presentation().showSwapBar}>
<div
class="absolute bottom-0 left-0 h-[3px] w-full bg-purple-500"
style={{ width: `${presentation().swapBarPercent}%` }}
/>
</Show>
</Show>
</svg>
<span class="absolute inset-0 flex items-center justify-center text-[10px] font-semibold text-base-content leading-none pointer-events-none min-w-0 overflow-hidden">
<span class="max-w-full min-w-0 whitespace-nowrap overflow-hidden text-ellipsis px-0.5 text-center">

View file

@ -59,10 +59,14 @@ export function WorkloadPanel(props: WorkloadPanelProps) {
<TableBody ref={props.setTableBodyRef} class="divide-y divide-border">
<Show when={props.groupedWindowing.isWindowed() && props.topSpacerHeight() > 0}>
<TableRow aria-hidden="true">
<TableCell
colspan={props.totalColumns()}
style={{ height: `${props.topSpacerHeight()}px`, padding: '0', border: '0' }}
/>
<TableCell colspan={props.totalColumns()} class="p-0 border-0">
<svg
aria-hidden="true"
width="1"
height={String(props.topSpacerHeight())}
class="block w-px pointer-events-none"
/>
</TableCell>
</TableRow>
</Show>
<Index each={props.visibleGroupKeys()} fallback={<></>}>
@ -226,10 +230,14 @@ export function WorkloadPanel(props: WorkloadPanelProps) {
</Index>
<Show when={props.groupedWindowing.isWindowed() && props.bottomSpacerHeight() > 0}>
<TableRow aria-hidden="true">
<TableCell
colspan={props.totalColumns()}
style={{ height: `${props.bottomSpacerHeight()}px`, padding: '0', border: '0' }}
/>
<TableCell colspan={props.totalColumns()} class="p-0 border-0">
<svg
aria-hidden="true"
width="1"
height={String(props.bottomSpacerHeight())}
class="block w-px pointer-events-none"
/>
</TableCell>
</TableRow>
</Show>
</TableBody>

View file

@ -28,26 +28,14 @@ export function WorkloadTableHeader(props: WorkloadTableHeaderProps) {
return (
<TableHead
class={`py-0.5 text-[11px] sm:text-xs font-medium uppercase tracking-wider whitespace-nowrap
${isFirst() ? 'pl-2 sm:pl-3 pr-1.5 sm:pr-2 text-left' : 'px-1.5 sm:px-2 text-center'}
${isFirst() ? 'pl-2 sm:pl-3 pr-1.5 sm:pr-2 text-left' : 'px-1.5 sm:px-2 text-center'} align-middle
${isSortable ? 'cursor-pointer hover:bg-surface-hover' : ''}`}
style={{
...(['cpu', 'memory', 'disk'].includes(col.id)
? { width: props.isMobile() ? '70px' : '140px' }
: ['netIo', 'diskIo'].includes(col.id)
? { width: '170px' }
: props.isMobile() && col.id === 'name'
? { width: '100%', 'min-width': '120px' }
: col.width
? { width: col.width }
: {}),
'vertical-align': 'middle',
}}
data-workload-col={col.id}
onClick={() => isSortable && props.handleSort(sortKeyForCol!)}
title={col.icon ? col.label : undefined}
>
<div
class={`flex items-center gap-0.5 ${isFirst() ? 'justify-start' : 'justify-center'}`}
style={{ 'min-height': '14px' }}
class={`flex min-h-[14px] items-center gap-0.5 ${isFirst() ? 'justify-start' : 'justify-center'}`}
>
{col.icon ? <span class="flex items-center">{col.icon}</span> : col.label}
{isSorted() && (props.sortDirection() === 'asc' ? ' ▲' : ' ▼')}

View file

@ -836,6 +836,8 @@ describe('Dashboard performance contract', () => {
it('keeps stacked disk bar runtime and derivations in canonical owners', () => {
expect(stackedDiskBarSource).toContain('useStackedDiskBarState');
expect(stackedDiskBarSource).not.toContain('style={{');
expect(stackedDiskBarSource).not.toContain('style={');
expect(stackedDiskBarSource).not.toContain('const [containerWidth, setContainerWidth] =');
expect(stackedDiskBarSource).not.toContain('const tooltipContent = createMemo(() => {');
expect(stackedDiskBarSource).not.toContain('const SEGMENT_COLORS =');
@ -848,6 +850,8 @@ describe('Dashboard performance contract', () => {
it('keeps stacked memory bar runtime and derivations in canonical owners', () => {
expect(stackedMemoryBarSource).toContain('useStackedMemoryBarState');
expect(stackedMemoryBarSource).not.toContain('style={{');
expect(stackedMemoryBarSource).not.toContain('style={');
expect(stackedMemoryBarSource).not.toContain('const [containerWidth, setContainerWidth] =');
expect(stackedMemoryBarSource).not.toContain('const segments = createMemo(() => {');
expect(stackedMemoryBarSource).not.toContain('const MEMORY_COLORS =');
@ -873,6 +877,8 @@ describe('Dashboard performance contract', () => {
it('keeps enhanced CPU bar runtime and derivations in canonical owners', () => {
expect(enhancedCpuBarSource).toContain('useEnhancedCPUBarState');
expect(enhancedCpuBarSource).not.toContain('style={{');
expect(enhancedCpuBarSource).not.toContain('style={');
expect(enhancedCpuBarSource).not.toContain('const tip = useTooltip()');
expect(enhancedCpuBarSource).not.toContain('const barColor = createMemo(() =>');
expect(enhancedCpuBarSource).not.toContain('const anomalyRatio = createMemo(() =>');
@ -887,6 +893,8 @@ describe('Dashboard performance contract', () => {
it('keeps guest row contract and hot-path state in canonical row owners', () => {
expect(guestRowSource).toContain('useGuestRowState');
expect(guestRowSource).toContain("from './GuestRowCells'");
expect(guestRowSource).not.toContain('style={{');
expect(guestRowSource).not.toContain('style={');
expect(guestRowSource).not.toContain('export const GUEST_COLUMNS');
expect(guestRowSource).not.toContain('const guestId = createMemo(');
expect(guestRowSource).not.toContain('function NetworkInfoCell(');
@ -899,6 +907,8 @@ describe('Dashboard performance contract', () => {
expect(guestRowStateSource).toContain("from '@/routing/resourceLinks'");
expect(guestRowStateSource).toContain("from './workloadTopology'");
expect(guestRowStateSource).not.toContain("./infrastructureLink");
expect(guestRowStateSource).not.toContain('rowStyle');
expect(guestRowStateSource).not.toContain('box-shadow');
expect(guestRowStateSource).toContain('getWorkloadTypeBadge');
expect(guestRowCellsSource).toContain('export { BackupIndicator');
expect(guestRowCellsSource).toContain('function NetworkInfoCell(');
@ -941,17 +951,23 @@ describe('Dashboard performance contract', () => {
expect(dashboardSource).not.toContain('NodeGroupHeader');
expect(dashboardWorkloadTableSource).toContain('WorkloadTableHeader');
expect(dashboardWorkloadTableSource).toContain('WorkloadPanel');
expect(dashboardWorkloadTableSource).not.toContain('style={{');
expect(dashboardWorkloadTableSource).not.toContain('style={');
expect(dashboardWorkloadTableSource).not.toContain('<TableHead');
expect(dashboardWorkloadTableSource).not.toContain('NodeGroupHeader');
expect(dashboardWorkloadTableSource).not.toContain('GuestDrawer');
expect(workloadTableHeaderSource).toContain('TableHead');
expect(workloadTableHeaderSource).toContain("col.sortKey as WorkloadSortKey");
expect(workloadTableHeaderSource).not.toContain('style={{');
expect(workloadTableHeaderSource).not.toContain('style={');
expect(workloadTableHeaderSource).not.toContain('NodeGroupHeader');
expect(workloadPanelSource).toContain('NodeGroupHeader');
expect(workloadPanelSource).toContain('GuestDrawer');
expect(workloadPanelSource).toContain('createMemo(() => getCanonicalWorkloadId(guest()))');
expect(workloadPanelSource).toContain('createSummaryInteractiveRowPreviewHandlers');
expect(workloadPanelSource).toContain('resolveSummaryGroupMemberInteractionState');
expect(workloadPanelSource).not.toContain('style={{');
expect(workloadPanelSource).not.toContain('style={');
expect(workloadPanelSource).not.toContain('kind="scope"');
expect(workloadPanelSource).not.toContain('leadingAction={');
expect(dashboardSelectionStateSource).toContain('activeSummaryWorkloadGroupScope');

View file

@ -19,9 +19,9 @@ function makeAnomaly(overrides: Partial<AnomalyReport> = {}): AnomalyReport {
};
}
/** Select the bar fill element (absolutely-positioned div with inline width style). */
function getBarFill(container: HTMLElement): HTMLElement | null {
return container.querySelector('.absolute.top-0.left-0[style]');
/** Select the bar fill element. */
function getBarFill(container: HTMLElement): SVGRectElement | null {
return container.querySelector('rect[data-enhanced-cpu-fill="true"]');
}
/** Find the tooltip bar trigger element within the render container. */
@ -77,19 +77,19 @@ describe('EnhancedCPUBar', () => {
it('sets bar width to usage percentage', () => {
const { container } = render(() => <EnhancedCPUBar usage={65} />);
const bar = getBarFill(container);
expect(bar).toHaveStyle({ width: '65%' });
expect(bar).toHaveAttribute('width', '65');
});
it('caps bar width at 100% when usage exceeds 100', () => {
const { container } = render(() => <EnhancedCPUBar usage={150} />);
const bar = getBarFill(container);
expect(bar).toHaveStyle({ width: '100%' });
expect(bar).toHaveAttribute('width', '100');
});
it('sets bar width to 0% for zero usage', () => {
const { container } = render(() => <EnhancedCPUBar usage={0} />);
const bar = getBarFill(container);
expect(bar).toHaveStyle({ width: '0%' });
expect(bar).toHaveAttribute('width', '0');
});
// ── Bar color classes (exact boundary tests: cpu warning=80, critical=90) ──
@ -97,31 +97,31 @@ describe('EnhancedCPUBar', () => {
it('uses normal color class for usage below warning threshold', () => {
const { container } = render(() => <EnhancedCPUBar usage={79} />);
const bar = getBarFill(container);
expect(bar?.className).toContain('bg-metric-normal-bg');
expect(bar?.getAttribute('fill')).toContain('34, 197, 94');
});
it('uses warning color class at exactly the warning threshold (80)', () => {
const { container } = render(() => <EnhancedCPUBar usage={80} />);
const bar = getBarFill(container);
expect(bar?.className).toContain('bg-metric-warning-bg');
expect(bar?.getAttribute('fill')).toContain('234, 179, 8');
});
it('uses warning color class between warning and critical thresholds', () => {
const { container } = render(() => <EnhancedCPUBar usage={89} />);
const bar = getBarFill(container);
expect(bar?.className).toContain('bg-metric-warning-bg');
expect(bar?.getAttribute('fill')).toContain('234, 179, 8');
});
it('uses critical color class at exactly the critical threshold (90)', () => {
const { container } = render(() => <EnhancedCPUBar usage={90} />);
const bar = getBarFill(container);
expect(bar?.className).toContain('bg-metric-critical-bg');
expect(bar?.getAttribute('fill')).toContain('239, 68, 68');
});
it('uses critical color class well above critical threshold', () => {
const { container } = render(() => <EnhancedCPUBar usage={99} />);
const bar = getBarFill(container);
expect(bar?.className).toContain('bg-metric-critical-bg');
expect(bar?.getAttribute('fill')).toContain('239, 68, 68');
});
// ── Cores display ────────────────────────────────────────────────

View file

@ -392,7 +392,7 @@ describe('GuestRow', () => {
expect(tr?.className).toContain('bg-yellow-50');
});
it('applies box-shadow for alert accent on unacknowledged alerts', () => {
it('marks unacknowledged critical alerts with the canonical alert accent tone', () => {
const { container } = renderGuestRow({
guest: makeGuest(),
alertStyles: {
@ -406,12 +406,11 @@ describe('GuestRow', () => {
},
});
const tr = container.querySelector('tr');
const style = tr?.getAttribute('style') ?? '';
expect(style).toContain('box-shadow');
expect(style).toContain('#ef4444');
expect(tr).toHaveAttribute('data-workload-alert-accent', 'critical');
expect(tr?.getAttribute('style')).toBeNull();
});
it('applies grey accent for acknowledged-only alerts', () => {
it('marks acknowledged-only alerts with the canonical alert accent tone', () => {
const { container } = renderGuestRow({
guest: makeGuest(),
alertStyles: {
@ -425,8 +424,8 @@ describe('GuestRow', () => {
},
});
const tr = container.querySelector('tr');
const style = tr?.getAttribute('style') ?? '';
expect(style).toContain('#9ca3af');
expect(tr).toHaveAttribute('data-workload-alert-accent', 'acknowledged');
expect(tr?.getAttribute('style')).toBeNull();
});
});

View file

@ -53,23 +53,15 @@ function getBarTrigger(container: HTMLElement): HTMLElement {
}
/** Get single-bar fill elements (non-stacked mode). */
function getSingleBarFill(container: HTMLElement): HTMLElement | null {
// Single bar is .absolute.top-0.left-0.h-full that is a direct child (not inside a flex container)
const fills = container.querySelectorAll<HTMLElement>('.absolute.top-0.left-0.h-full');
// In non-stacked mode, the single bar is not inside a .flex container
for (const fill of fills) {
if (!fill.parentElement?.classList.contains('flex')) {
return fill;
}
}
return fills[0] ?? null;
function getSingleBarFill(container: HTMLElement): SVGRectElement | null {
return container.querySelector('rect[data-stacked-disk-fill="single"]');
}
/** Get stacked segment elements. */
function getStackedSegments(container: HTMLElement): HTMLElement[] {
const flexContainer = container.querySelector('.absolute.top-0.left-0.h-full.w-full.flex');
if (!flexContainer) return [];
return Array.from(flexContainer.children) as HTMLElement[];
function getStackedSegments(container: HTMLElement): SVGRectElement[] {
return Array.from(
container.querySelectorAll<SVGRectElement>('rect[data-stacked-disk-fill="segment"]'),
);
}
afterEach(() => {
@ -105,7 +97,7 @@ describe('StackedDiskBar', () => {
const { container } = render(() => <StackedDiskBar disks={[disk]} />);
const bar = getSingleBarFill(container);
expect(bar).toBeInTheDocument();
expect(bar).toHaveStyle({ width: '50%' });
expect(bar).toHaveAttribute('width', '50');
});
it('does not show stacked segments for a single disk', () => {
@ -179,18 +171,15 @@ describe('StackedDiskBar', () => {
// Total capacity = 150 GiB
// disk1 used = 20 GiB → 20/150 ≈ 13.33%
// disk2 used = 50 GiB → 50/150 ≈ 33.33%
const width1 = parseFloat(segments[0].style.width);
const width2 = parseFloat(segments[1].style.width);
const width1 = parseFloat(segments[0].getAttribute('width') ?? '0');
const width2 = parseFloat(segments[1].getAttribute('width') ?? '0');
expect(width1).toBeCloseTo(13.33, 0);
expect(width2).toBeCloseTo(33.33, 0);
});
it('adds border-right separator between segments', () => {
it('adds separators between stacked segments', () => {
const { container } = render(() => <StackedDiskBar disks={[disk1, disk2]} />);
const segments = getStackedSegments(container);
expect(segments[0].style.borderRight).toContain('1px solid');
// Last segment has border-right: 'none' in source, which jsdom omits from style
expect(segments[1].style.borderRight).not.toContain('1px solid');
expect(container.querySelectorAll('line')).toHaveLength(1);
});
});
@ -240,26 +229,23 @@ describe('StackedDiskBar', () => {
expect(screen.getByText('Disk 1')).toBeInTheDocument();
});
it('renders grid layout with correct column count', () => {
it('renders one equal-width mini column per disk', () => {
const disk1 = makeDisk({ mountpoint: '/boot' });
const disk2 = makeDisk({ mountpoint: '/data' });
const disk3 = makeDisk({ mountpoint: '/var' });
const { container } = render(() => (
<StackedDiskBar disks={[disk1, disk2, disk3]} mode="mini" />
));
const grid = container.querySelector('.grid');
expect(grid).toBeInTheDocument();
expect(grid).toHaveStyle({
'grid-template-columns': 'repeat(3, minmax(0, 1fr))',
});
const columns = container.querySelectorAll('.flex.min-w-0.flex-1.flex-col.items-stretch.gap-0\\.5');
expect(columns).toHaveLength(3);
});
it('clamps mini bar fill width to exactly 100% when used exceeds total', () => {
const disk = makeDisk({ used: 200000000000, total: 107374182400, mountpoint: '/full' });
const { container } = render(() => <StackedDiskBar disks={[disk]} mode="mini" />);
const miniBars = container.querySelectorAll('.h-full');
const barFill = miniBars[miniBars.length - 1] as HTMLElement;
expect(barFill.style.width).toBe('100%');
const miniBars = container.querySelectorAll<SVGRectElement>('rect[data-stacked-disk-fill="mini"]');
const barFill = miniBars[miniBars.length - 1];
expect(barFill).toHaveAttribute('width', '100');
});
});
@ -325,7 +311,7 @@ describe('StackedDiskBar', () => {
const bar = getSingleBarFill(container);
expect(bar).toBeInTheDocument();
// Normal green: rgba(34, 197, 94, 0.6)
expect(bar!.style.backgroundColor).toContain('34, 197, 94');
expect(bar?.getAttribute('fill')).toContain('34, 197, 94');
});
it('uses warning color for disk at 80-89%', () => {
@ -337,7 +323,7 @@ describe('StackedDiskBar', () => {
const bar = getSingleBarFill(container);
expect(bar).toBeInTheDocument();
// Warning yellow: rgba(234, 179, 8, 0.6)
expect(bar!.style.backgroundColor).toContain('234, 179, 8');
expect(bar?.getAttribute('fill')).toContain('234, 179, 8');
});
it('uses critical color for disk at 90%+', () => {
@ -349,7 +335,7 @@ describe('StackedDiskBar', () => {
const bar = getSingleBarFill(container);
expect(bar).toBeInTheDocument();
// Critical red: rgba(239, 68, 68, 0.6)
expect(bar!.style.backgroundColor).toContain('239, 68, 68');
expect(bar?.getAttribute('fill')).toContain('239, 68, 68');
});
it('uses warning color for stacked segment at 80-89%', () => {
@ -367,9 +353,9 @@ describe('StackedDiskBar', () => {
const segments = getStackedSegments(container);
expect(segments.length).toBe(2);
// First segment: normal → palette color (green)
expect(segments[0].style.backgroundColor).toContain('34, 197, 94');
expect(segments[0].getAttribute('fill')).toContain('34, 197, 94');
// Second segment: warning → yellow
expect(segments[1].style.backgroundColor).toContain('234, 179, 8');
expect(segments[1].getAttribute('fill')).toContain('234, 179, 8');
});
it('uses critical color for stacked segment at 90%+', () => {
@ -387,9 +373,9 @@ describe('StackedDiskBar', () => {
const segments = getStackedSegments(container);
expect(segments.length).toBe(2);
// First segment: normal → palette color
expect(segments[0].style.backgroundColor).not.toContain('239, 68, 68');
expect(segments[0].getAttribute('fill')).not.toContain('239, 68, 68');
// Second segment: critical → red
expect(segments[1].style.backgroundColor).toContain('239, 68, 68');
expect(segments[1].getAttribute('fill')).toContain('239, 68, 68');
});
});
@ -404,7 +390,7 @@ describe('StackedDiskBar', () => {
const { container } = render(() => <StackedDiskBar disks={[disk]} />);
const bar = getSingleBarFill(container);
expect(bar).toBeInTheDocument();
expect(bar!.style.width).toBe('100%');
expect(bar).toHaveAttribute('width', '100');
});
});
@ -493,7 +479,7 @@ describe('StackedDiskBar', () => {
const bar = getSingleBarFill(container);
expect(bar).toBeInTheDocument();
// Bar color should be critical (based on max disk at 92%)
expect(bar!.style.backgroundColor).toContain('239, 68, 68');
expect(bar?.getAttribute('fill')).toContain('239, 68, 68');
});
});
});

View file

@ -1,6 +1,6 @@
import type { AnomalyReport } from '@/types/aiIntelligence';
import { ANOMALY_SEVERITY_CLASS, formatAnomalyRatio, formatPercent } from '@/utils/format';
import { getMetricColorClass } from '@/utils/metricThresholds';
import { getMetricColorClass, getMetricColorRgba } from '@/utils/metricThresholds';
export interface EnhancedCPUBarProps {
usage: number;
@ -16,6 +16,7 @@ export interface EnhancedCPUBarPresentation {
anomalyDescription?: string;
anomalyRatio: string;
barClass: string;
barFill: string;
barWidth: string;
displayLoadAverage?: string;
displayUsage: string;
@ -35,6 +36,7 @@ export function buildEnhancedCPUBarPresentation(
anomalyDescription: props.anomaly?.description,
anomalyRatio,
barClass: getMetricColorClass(props.usage, 'cpu'),
barFill: getMetricColorRgba(props.usage, 'cpu'),
barWidth: `${Math.min(props.usage, 100)}%`,
displayLoadAverage:
props.loadAverage !== undefined ? props.loadAverage.toFixed(2) : undefined,

View file

@ -216,12 +216,12 @@ export function useGuestRowState(props: GuestRowProps) {
() => hasUnacknowledgedAlert() || hasAcknowledgedOnlyAlert(),
);
const alertAccentColor = createMemo(() => {
const alertAccentTone = createMemo<'critical' | 'warning' | 'acknowledged' | undefined>(() => {
if (!showAlertHighlight()) return undefined;
if (hasUnacknowledgedAlert()) {
return props.alertStyles?.severity === 'critical' ? '#ef4444' : '#eab308';
return props.alertStyles?.severity === 'critical' ? 'critical' : 'warning';
}
return '#9ca3af';
return 'acknowledged';
});
const rowClass = createMemo(() => {
@ -243,17 +243,6 @@ export function useGuestRowState(props: GuestRowProps) {
return [base, hover, defaultHover, alertBg, stoppedDimming].filter(Boolean).join(' ');
});
const rowStyle = createMemo(() => {
const styles: Record<string, string> = { 'min-height': '32px' };
if (showAlertHighlight()) {
const color = alertAccentColor();
if (color) {
styles['box-shadow'] = `inset 4px 0 0 0 ${color}`;
}
}
return styles;
});
const firstCellIndent = createMemo(() =>
props.isGroupedView ? GROUPED_FIRST_CELL_INDENT : DEFAULT_FIRST_CELL_INDENT,
);
@ -301,6 +290,7 @@ export function useGuestRowState(props: GuestRowProps) {
ociImage,
osName,
osVersion,
alertAccentTone,
supportsBackup,
typeInfo,
workloadType,
@ -308,6 +298,5 @@ export function useGuestRowState(props: GuestRowProps) {
clusterName,
agentVersion,
rowClass,
rowStyle,
};
}

View file

@ -18,8 +18,7 @@ export function Table(props: TableProps) {
<div
{...local.wrapperProps}
ref={local.wrapperRef}
class={`w-full overflow-x-auto ${local.wrapperClass || ''}`}
style={{ '-webkit-overflow-scrolling': 'touch' }}
class={`w-full overflow-x-auto touch-scroll ${local.wrapperClass || ''}`}
>
<table
class={`w-full border-collapse text-left whitespace-nowrap ${local.class || ''}`}

View file

@ -35,6 +35,9 @@ describe('PulseDataGrid', () => {
expect(pulseDataGridModelSource).toContain('target.closest(');
expect(tableSource).toContain('customDividePattern');
expect(tableSource).toContain('customBorderPattern');
expect(tableSource).toContain('touch-scroll');
expect(tableSource).not.toContain('style={{');
expect(tableSource).not.toContain('style={');
});
it('triggers the row handler when a non-interactive cell is clicked', () => {

View file

@ -1,9 +1,16 @@
import { render, screen } from '@solidjs/testing-library';
import { describe, expect, it } from 'vitest';
import tableSource from '@/components/shared/Table.tsx?raw';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/shared/Table';
describe('TableBody', () => {
it('keeps the shared table wrapper CSP-safe', () => {
expect(tableSource).toContain('touch-scroll');
expect(tableSource).not.toContain('style={{');
expect(tableSource).not.toContain('style={');
});
it('keeps default dividers when no custom divider classes are provided', () => {
render(() => (
<Table>

View file

@ -173,6 +173,131 @@
@apply border-b border-border hover:bg-surface-hover;
}
.workload-table {
table-layout: fixed;
min-width: max-content;
}
.workload-table--mobile {
min-width: 100%;
}
.workload-table [data-workload-col='name'] {
width: 200px;
min-width: 200px;
max-width: 200px;
}
.workload-table [data-workload-col='info'] {
width: 100px;
min-width: 100px;
max-width: 100px;
}
.workload-table [data-workload-col='vmid'],
.workload-table [data-workload-col='ip'],
.workload-table [data-workload-col='os'] {
width: 45px;
min-width: 45px;
max-width: 45px;
}
.workload-table [data-workload-col='cpu'],
.workload-table [data-workload-col='memory'],
.workload-table [data-workload-col='disk'] {
width: 140px;
min-width: 140px;
max-width: 140px;
}
.workload-table [data-workload-col='uptime'],
.workload-table [data-workload-col='tags'],
.workload-table [data-workload-col='update'] {
width: 60px;
min-width: 60px;
max-width: 60px;
}
.workload-table [data-workload-col='node'] {
width: 70px;
min-width: 70px;
max-width: 70px;
}
.workload-table [data-workload-col='image'] {
width: 140px;
min-width: 120px;
max-width: 140px;
}
.workload-table [data-workload-col='namespace'] {
width: 110px;
min-width: 90px;
max-width: 110px;
}
.workload-table [data-workload-col='context'] {
width: 120px;
min-width: 100px;
max-width: 120px;
}
.workload-table [data-workload-col='backup'] {
width: 50px;
min-width: 50px;
max-width: 50px;
}
.workload-table [data-workload-col='netIo'],
.workload-table [data-workload-col='diskIo'] {
width: 130px;
min-width: 120px;
max-width: 130px;
}
.workload-table [data-workload-col='link'] {
width: 28px;
min-width: 28px;
max-width: 28px;
}
.workload-table--mobile [data-workload-col='name'] {
width: auto;
min-width: 120px;
max-width: none;
}
.workload-table--mobile [data-workload-col='cpu'],
.workload-table--mobile [data-workload-col='memory'],
.workload-table--mobile [data-workload-col='disk'] {
width: 70px;
min-width: 60px;
max-width: 70px;
}
.workload-table--mobile [data-workload-col='netIo'],
.workload-table--mobile [data-workload-col='diskIo'] {
width: 170px;
min-width: 170px;
max-width: 170px;
}
.workload-row {
min-height: 32px;
}
.workload-row[data-workload-alert-accent='critical'] > td:first-child {
box-shadow: inset 4px 0 0 0 #ef4444;
}
.workload-row[data-workload-alert-accent='warning'] > td:first-child {
box-shadow: inset 4px 0 0 0 #eab308;
}
.workload-row[data-workload-alert-accent='acknowledged'] > td:first-child {
box-shadow: inset 4px 0 0 0 #9ca3af;
}
tr[data-summary-group-member-active='preview'] > td,
tr[data-summary-group-member-active='preview'] > th {
background-color: var(--color-summary-group-member-preview-bg);