diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 496f81cf0..1744f43ae 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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. diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index b59e685a1..69759735d 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -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 diff --git a/frontend-modern/src/components/Dashboard/DashboardWorkloadTable.tsx b/frontend-modern/src/components/Dashboard/DashboardWorkloadTable.tsx index 2a39d2627..2bd587a91 100644 --- a/frontend-modern/src/components/Dashboard/DashboardWorkloadTable.tsx +++ b/frontend-modern/src/components/Dashboard/DashboardWorkloadTable.tsx @@ -71,11 +71,7 @@ export function DashboardWorkloadTable(props: DashboardWorkloadTableProps) { /> { + const parsed = Number.parseFloat(presentation().barWidth); + if (!Number.isFinite(parsed)) return '0'; + return String(Math.max(0, Math.min(parsed, 100))); + }; return (
@@ -14,10 +19,22 @@ export function EnhancedCPUBar(props: EnhancedCPUBarProps) { onMouseEnter={state.handleMouseEnter} onMouseLeave={state.handleMouseLeave} > -
+ {presentation().displayUsage} diff --git a/frontend-modern/src/components/Dashboard/GuestRow.tsx b/frontend-modern/src/components/Dashboard/GuestRow.tsx index 5d6fc88c8..157db8b3c 100644 --- a/frontend-modern/src/components/Dashboard/GuestRow.tsx +++ b/frontend-modern/src/components/Dashboard/GuestRow.tsx @@ -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 ( <>
-
+
-
+
-
+ 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 (
@@ -19,34 +25,70 @@ export function StackedDiskBar(props: StackedDiskBarProps) { > {/* Stacked segments for multiple disks */} -
+
+ <> + sum + item.widthPercent, 0), + )} + y="0" + width={clampPercent(segment.widthPercent)} + height="100" + rx="3" + fill={segment.color} + /> + + 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" + /> + + )} -
+ {/* Single bar for aggregate or single disk */} -
+ {/* Label overlay */} @@ -83,26 +125,30 @@ export function StackedDiskBar(props: StackedDiskBarProps) { } >
-
+
{(disk) => ( -
+
{disk.label}
-
+
)} @@ -128,21 +174,33 @@ export function StackedDiskBar(props: StackedDiskBarProps) { classList={{ 'border-t border-border': idx() > 0 }} >
- - {item.label} + + + {item.label} {item.percent} ({item.used}/{item.total})
-
-
+
+
)} diff --git a/frontend-modern/src/components/Dashboard/StackedMemoryBar.test.tsx b/frontend-modern/src/components/Dashboard/StackedMemoryBar.test.tsx index 5d810837c..ee538c819 100644 --- a/frontend-modern/src/components/Dashboard/StackedMemoryBar.test.tsx +++ b/frontend-modern/src/components/Dashboard/StackedMemoryBar.test.tsx @@ -15,6 +15,16 @@ global.ResizeObserver = class ResizeObserver { unobserve = vi.fn(); }; +function getSegments(container: HTMLElement): SVGRectElement[] { + return Array.from( + container.querySelectorAll('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', () => { )); 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', () => { )); // 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(() => ( )); - 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(() => ( )); - 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(() => ( )); - 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(() => ( )); - 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(() => ); - 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(() => ); - 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(() => ( )); - 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(() => ( )); - 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(() => ( )); - 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(() => ( )); - 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 ---- diff --git a/frontend-modern/src/components/Dashboard/StackedMemoryBar.tsx b/frontend-modern/src/components/Dashboard/StackedMemoryBar.tsx index 182aab6ca..eb933b4f0 100644 --- a/frontend-modern/src/components/Dashboard/StackedMemoryBar.tsx +++ b/frontend-modern/src/components/Dashboard/StackedMemoryBar.tsx @@ -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 (
@@ -14,29 +17,49 @@ export function StackedMemoryBar(props: StackedMemoryBarProps) { onMouseEnter={state.handleMouseEnter} onMouseLeave={state.handleMouseLeave} > - - {(segment, idx) => ( -