mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-05 23:36:37 +00:00
Make workloads table renderers CSP-safe
This commit is contained in:
parent
aae9be2441
commit
5add970dbd
20 changed files with 450 additions and 229 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 ----
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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' ? ' ▲' : ' ▼')}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 || ''}`}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue