Canonicalize product table frames

This commit is contained in:
rcourtman 2026-04-28 21:17:18 +01:00
parent c3b50001e0
commit ef3d30e868
19 changed files with 337 additions and 238 deletions

View file

@ -168,6 +168,12 @@ work extends shared components instead of creating new local variants.
## Extension Points
1. Add shared primitives in `frontend-modern/src/components/shared/`
Framed product table surfaces must consume the shared
`TableCard` primitive instead of composing page-local `Card` border,
background, and overflow classes. Feature owners may own the table data,
filters, columns, and row behavior, but the outer product-table frame and
its light/dark border treatment belong to frontend primitives so
Infrastructure, Workloads, Storage, and Recovery do not drift visually.
Shared monitored-system warning primitives under that path must stay compact
app-shell pointers into the owned Pulse Pro billing surface. The shared
banner may announce posture and route to the relevant billing tab, but

View file

@ -224,6 +224,11 @@ regression protection.
the shared `.grouped-table-row` CSS contract in `frontend-modern/src/index.css`,
rather than local `bg-surface-alt` or hover-fill variants that drift between
pages.
Framed product-table cards on the same hot-path surfaces must consume the
frontend-primitives-owned `TableCard` wrapper so Workloads,
Infrastructure, Storage, and Recovery keep one border/background/overflow
frame instead of introducing page-local table shells with different
light/dark contrast.
Infrastructure table responsive behavior belongs to that same hot-path
owner. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts`
must derive column visibility from the measured table surface width, with

View file

@ -68,6 +68,11 @@ querying, and the operator-facing storage health presentation layer.
1. Add or change recovery-point persistence, rollups, or series derivation through `internal/recovery/`
2. Add or change recovery page UX through `frontend-modern/src/components/Recovery/` and keep canonical route/query/filter state ownership in `frontend-modern/src/features/recovery/useRecoverySurfaceState.ts`
Recovery table surfaces must consume the frontend-primitives-owned
`TableCard` frame for protected-item, recovery-event, and adjacent
table-fallback chrome. Storage/recovery may own table content, filters,
columns, and rows, but it must not fork border/background/overflow table
shells or reintroduce lighter open-sided Recovery table frames.
Recovery inventory protection posture and recovery-event outcome filtering
must stay separate in that owner: protected inventory uses the route-backed
`state` query for health, stale, failed, warning, running, unknown, and

View file

@ -337,7 +337,11 @@ AI-only summary payloads, or page-local heuristics.
plus `frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts`
must consume that scope through the shared page/group/entity interaction
contract rather than inventing infrastructure-local summary filters or
route-backed cluster hover state. Deliberate cluster focus must also stay
route-backed cluster hover state. Host and service infrastructure table
card frames must consume the frontend-primitives-owned `TableCard` wrapper;
unified-resource ownership remains on resource identity, grouping, and row
semantics rather than forking a table border/background shell. Deliberate
cluster focus must also stay
on the canonical infrastructure route through the shared `summaryGroup`
query state, so pinned scope is shareable, reversible, and owned by the
same route-backed summary contract as row focus. Infrastructure must stay

View file

@ -122,6 +122,9 @@ describe('App architecture', () => {
);
expect(appStylesSource).toContain('tr.grouped-table-row > td');
expect(appStylesSource).toContain('--color-grouped-table-row-bg');
expect(appStylesSource).toContain('--color-grouped-table-row-bg: rgba(226, 232, 240, 0.72);');
expect(appStylesSource).toContain('--color-grouped-table-row-bg: rgba(51, 65, 85, 0.58);');
expect(appStylesSource).not.toContain('--color-grouped-table-row-bg: theme(');
expect(appStylesSource).not.toContain('@keyframes pulse-brand-wordmark');
expect(appStylesSource).not.toContain('text-shadow');
expect(appLayoutSource).toContain("props.versionInfo()?.channel === 'rc'");

View file

@ -1,9 +1,9 @@
import { For } from 'solid-js';
import { ComponentErrorBoundary } from '@/components/ErrorBoundary';
import { Card } from '@/components/shared/Card';
import { SummaryTableCardHeader } from '@/components/shared/SummaryTableCardHeader';
import { Table } from '@/components/shared/Table';
import { TableCard } from '@/components/shared/TableCard';
import { getGuestColumnWidthStyle } from './guestRowModel';
import type { DashboardState } from './useDashboardState';
@ -60,10 +60,8 @@ export function DashboardWorkloadTable(props: DashboardWorkloadTableProps) {
return (
<ComponentErrorBoundary name="Guest Table">
<Card
<TableCard
ref={props.setTableRootRef}
padding="none"
tone="card"
class="mb-4 rounded-md"
data-summary-clear-surface
data-testid="workloads-table-surface"
@ -136,7 +134,7 @@ export function DashboardWorkloadTable(props: DashboardWorkloadTableProps) {
workloadTableVisibleColumnIds={props.workloadTableVisibleColumnIds}
/>
</Table>
</Card>
</TableCard>
</ComponentErrorBoundary>
);
}

View file

@ -794,6 +794,7 @@ describe('Dashboard performance contract', () => {
expect(dashboardSource).toContain('clearPinnedSummaryScope={state.clearPinnedSummaryScope}');
expect(dashboardWorkloadTableSource).toContain('data-summary-clear-surface');
expect(dashboardWorkloadTableSource).toContain('data-testid="workloads-table-surface"');
expect(dashboardWorkloadTableSource).toContain('TableCard');
expect(dashboardWorkloadTableSource).toContain('SummaryTableCardHeader');
expect(dashboardWorkloadTableSource).toContain('showClearSelection');
expect(dashboardWorkloadTableSource).toContain('clearPinnedSummaryScope');

View file

@ -3,9 +3,9 @@ import type { Component } from 'solid-js';
import type { Disk } from '@/types/api';
import { formatBytes, formatSpeed, formatUptime, normalizeDiskArray } from '@/utils/format';
import { formatTemperature } from '@/utils/temperature';
import { Card } from '@/components/shared/Card';
import { getInteractiveGroupedTableRowClass } from '@/components/shared/groupedTableRowPresentation';
import { SummaryTableCardHeader } from '@/components/shared/SummaryTableCardHeader';
import { TableCard } from '@/components/shared/TableCard';
import {
Table,
TableBody,
@ -63,7 +63,7 @@ export const UnifiedResourceHostTableCard: Component<UnifiedResourceHostTableCar
return (
<Show when={table.showHostTable()}>
<Card padding="none" tone="card" class="mb-0 overflow-hidden">
<TableCard class="mb-0">
<SummaryTableCardHeader
title="Agent Infrastructure"
showClearAction={table.showHostClearAction()}
@ -676,7 +676,7 @@ export const UnifiedResourceHostTableCard: Component<UnifiedResourceHostTableCar
</TableBody>
</Table>
</div>
</Card>
</TableCard>
</Show>
);
};

View file

@ -1,7 +1,7 @@
import { Show } from 'solid-js';
import type { Component } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { SummaryTableCardHeader } from '@/components/shared/SummaryTableCardHeader';
import { TableCard } from '@/components/shared/TableCard';
import {
type UnifiedResourceTableProps,
type UnifiedResourceTableState,
@ -21,7 +21,7 @@ export const UnifiedResourceServiceInfrastructureCard: Component<
return (
<Show when={table.sortedPBSResources().length > 0 || table.sortedPMGResources().length > 0}>
<Card padding="none" tone="card" class="mb-0 overflow-hidden">
<TableCard class="mb-0">
<SummaryTableCardHeader
title="Service Infrastructure"
showClearAction={table.showServiceClearAction()}
@ -29,7 +29,7 @@ export const UnifiedResourceServiceInfrastructureCard: Component<
/>
<UnifiedResourcePBSTableSection tableProps={tableProps} table={table} />
<UnifiedResourcePMGTableSection tableProps={tableProps} table={table} />
</Card>
</TableCard>
</Show>
);
};

View file

@ -517,10 +517,12 @@ describe('UnifiedResourceTable performance contract', () => {
expect(unifiedResourceHostTableCardSource).not.toContain('kind="scope"');
expect(unifiedResourceHostTableCardSource).toContain('getInteractiveGroupedTableRowClass');
expect(unifiedResourceHostTableCardSource).not.toContain('cursor-pointer bg-surface-alt');
expect(unifiedResourceHostTableCardSource).toContain('TableCard');
expect(unifiedResourceHostTableCardSource).toContain('SummaryTableCardHeader');
expect(unifiedResourceHostTableCardSource).toContain(
'onClear={tableProps.clearPinnedSummaryScope}',
);
expect(unifiedResourceServiceInfrastructureCardSource).toContain('TableCard');
expect(unifiedResourceServiceInfrastructureCardSource).toContain('SummaryTableCardHeader');
expect(unifiedResourceServiceInfrastructureCardSource).toContain('showClearAction');
expect(unifiedResourceServiceInfrastructureCardSource).toContain(

View file

@ -5,10 +5,10 @@ import { RecoveryActivitySection } from '@/components/Recovery/RecoveryActivityS
import { RecoveryHistorySection } from '@/components/Recovery/RecoveryHistorySection';
import { RecoveryProtectedInventorySection } from '@/components/Recovery/RecoveryProtectedInventorySection';
import { RecoverySummary } from '@/components/Recovery/RecoverySummary';
import { Card } from '@/components/shared/Card';
import { EmptyState } from '@/components/shared/EmptyState';
import { PageHeader } from '@/components/shared/PageHeader';
import { Subtabs } from '@/components/shared/Subtabs';
import { TableCard } from '@/components/shared/TableCard';
import { useRecoverySurfaceState } from '@/features/recovery/useRecoverySurfaceState';
import type { ProtectedStateFilter } from '@/features/recovery/useRecoverySurfaceState';
import { useBreakpoint } from '@/hooks/useBreakpoint';
@ -669,11 +669,7 @@ const Recovery: Component = () => {
{eventsActivity()}
<Show when={!recoveryPointsLoading() && recoveryPoints.response.error}>
<Card
padding="none"
tone="card"
class="overflow-hidden border-border-subtle bg-surface"
>
<TableCard>
<div class="p-6">
<EmptyState
title={getRecoveryPointsFailureState().title}
@ -683,7 +679,7 @@ const Recovery: Component = () => {
)}
/>
</div>
</Card>
</TableCard>
</Show>
<Show when={!recoveryPoints.response.error}>

View file

@ -12,6 +12,7 @@ import {
} from '@/components/shared/FilterToolbar';
import { PageControls } from '@/components/shared/PageControls';
import { SearchInput } from '@/components/shared/SearchInput';
import { TableCard } from '@/components/shared/TableCard';
import type { ColumnDef } from '@/hooks/useColumnVisibility';
import { STORAGE_KEYS } from '@/utils/localStorage';
import type { RecoveryOutcome, RecoveryPoint } from '@/types/recovery';
@ -469,7 +470,7 @@ export const RecoveryHistorySection: Component<RecoveryHistorySectionProps> = (p
</Card>
</Show>
<Card padding="none" tone="card" class="overflow-hidden border-border-subtle bg-surface">
<TableCard>
<RecoveryHistoryTable
clearSelectedPoint={clearSelectedPoint}
currentPage={props.currentPage}
@ -488,7 +489,7 @@ export const RecoveryHistorySection: Component<RecoveryHistorySectionProps> = (p
toggleSelectedPoint={toggleSelectedPoint}
totalPages={props.totalPages}
/>
</Card>
</TableCard>
</div>
);
};

View file

@ -8,7 +8,15 @@ import { PageControls } from '@/components/shared/PageControls';
import { SearchInput } from '@/components/shared/SearchInput';
import { StatusDot } from '@/components/shared/StatusDot';
import { getSourcePlatformBadge } from '@/components/shared/sourcePlatformBadges';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/shared/Table';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/shared/Table';
import { TableCard } from '@/components/shared/TableCard';
import { STORAGE_KEYS } from '@/utils/localStorage';
import { formatAbsoluteTime, formatRelativeTime } from '@/utils/format';
import type { ProtectionRollup } from '@/types/recovery';
@ -122,7 +130,8 @@ const getProtectionInsight = (
if (inventoryStatus === 'failed') return 'Latest protection event failed; open event details';
if (inventoryStatus === 'warning') return 'Latest event completed with warnings; review details';
if (inventoryStatus === 'running') return 'Protection event in progress; check events for completion';
if (inventoryStatus === 'running')
return 'Protection event in progress; check events for completion';
return '';
};
@ -174,8 +183,12 @@ export const RecoveryProtectedInventorySection: Component<
return multiplier * leftLabel.localeCompare(rightLabel);
}
case 'type': {
const leftType = getRecoveryItemTypePresentation(getRecoveryRollupItemTypeKey(left))?.label.toLowerCase();
const rightType = getRecoveryItemTypePresentation(getRecoveryRollupItemTypeKey(right))?.label.toLowerCase();
const leftType = getRecoveryItemTypePresentation(
getRecoveryRollupItemTypeKey(left),
)?.label.toLowerCase();
const rightType = getRecoveryItemTypePresentation(
getRecoveryRollupItemTypeKey(right),
)?.label.toLowerCase();
return multiplier * (leftType || '').localeCompare(rightType || '');
}
case 'platform': {
@ -204,9 +217,7 @@ export const RecoveryProtectedInventorySection: Component<
const leftTimestamp = getRecoveryRollupTimestampMs(left);
const rightTimestamp = getRecoveryRollupTimestampMs(right);
const naturalTieBreak =
leftPriority === 4
? leftTimestamp - rightTimestamp
: rightTimestamp - leftTimestamp;
leftPriority === 4 ? leftTimestamp - rightTimestamp : rightTimestamp - leftTimestamp;
if (naturalTieBreak !== 0) return multiplier * naturalTieBreak;
const leftOutcome = normalizeRecoveryOutcome(left.lastOutcome);
@ -337,9 +348,7 @@ export const RecoveryProtectedInventorySection: Component<
<For each={availableProtectionStates}>
{(state) => (
<option value={state}>
{state === 'all'
? 'Any state'
: getRecoveryRollupInventoryStatusLabel(state)}
{state === 'all' ? 'Any state' : getRecoveryRollupInventoryStatusLabel(state)}
</option>
)}
</For>
@ -349,7 +358,7 @@ export const RecoveryProtectedInventorySection: Component<
</Card>
</Show>
<Card padding="none" tone="card" class="overflow-hidden border-border-subtle bg-surface">
<TableCard>
<Show when={props.loading() && props.filteredRollups().length === 0}>
<div
data-testid="recovery-protected-loading"
@ -400,213 +409,216 @@ export const RecoveryProtectedInventorySection: Component<
<Show when={props.filteredRollups().length > 0}>
<div class="overflow-x-auto bg-surface">
<Table
class={`w-full border-collapse whitespace-nowrap table-fixed ${
props.isMobile ? 'min-w-full' : 'min-w-[640px]'
}`}
>
<TableHeader>
<TableRow class="bg-surface-alt/95 text-muted">
{(
[
['item', getRecoveryArtifactColumnLabel('item', 'Item')],
['type', 'Item Type'],
['platform', getRecoveryArtifactColumnLabel('platform', 'Platform')],
['lastBackup', 'Latest Point'],
['outcome', 'Protection State'],
] as const
).map(([column, label]) => (
<TableHead
class={`sticky top-0 z-[1] bg-surface-alt/95 px-3 py-2 whitespace-nowrap text-left text-[11px] font-medium cursor-pointer select-none hover:text-base-content transition-colors${
column === 'type'
? ' hidden md:table-cell w-[96px]'
: column === 'platform'
? ' hidden lg:table-cell w-[110px]'
: column === 'lastBackup'
? ' w-[120px]'
: column === 'outcome'
? ' w-[116px]'
: ''
}`}
onClick={() => toggleProtectedSort(column)}
>
<span class="inline-flex items-center gap-1">
{label}
<Show when={protectedSortCol() === column}>
<svg class="h-3 w-3" viewBox="0 0 12 12" fill="currentColor">
{protectedSortDir() === 'asc' ? (
<path d="M6 3l3.5 5h-7z" />
) : (
<path d="M6 9l3.5-5h-7z" />
)}
</svg>
</Show>
</span>
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
<For each={sortedRollups()}>
{(rollup) => {
const resourceIndex = props.resourcesById();
const label = getRecoveryRollupItemLabel(rollup, resourceIndex);
const secondaryLabel = getRecoveryRollupItemSecondaryLabel(rollup);
const attemptMs = rollup.lastAttemptAt ? Date.parse(rollup.lastAttemptAt) : 0;
const successMs = rollup.lastSuccessAt ? Date.parse(rollup.lastSuccessAt) : 0;
const inventoryStatus = getRecoveryRollupInventoryStatus(rollup);
const inventoryStatusLabel = getRecoveryRollupInventoryStatusLabel(inventoryStatus);
const platforms = getRecoveryRollupPlatforms(rollup)
.map((platform) => String(platform || '').trim())
.filter(Boolean)
.sort((left, right) =>
getSourcePlatformLabel(left).localeCompare(getSourcePlatformLabel(right)),
);
const itemTypePresentation =
getRecoveryItemTypePresentation(getRecoveryRollupItemTypeKey(rollup)) || null;
const nowMs = Date.now();
const neverSucceeded = inventoryStatus === 'never-succeeded';
const protectionInsight = getProtectionInsight(rollup, inventoryStatus, nowMs);
return (
<TableRow
class="cursor-pointer odd:bg-surface even:bg-surface-alt/35 transition-colors hover:bg-surface-hover/95"
onClick={() => props.onSelectRollup(rollup.rollupId)}
<Table
class={`w-full border-collapse whitespace-nowrap table-fixed ${
props.isMobile ? 'min-w-full' : 'min-w-[640px]'
}`}
>
<TableHeader>
<TableRow class="bg-surface-alt/95 text-muted">
{(
[
['item', getRecoveryArtifactColumnLabel('item', 'Item')],
['type', 'Item Type'],
['platform', getRecoveryArtifactColumnLabel('platform', 'Platform')],
['lastBackup', 'Latest Point'],
['outcome', 'Protection State'],
] as const
).map(([column, label]) => (
<TableHead
class={`sticky top-0 z-[1] bg-surface-alt/95 px-3 py-2 whitespace-nowrap text-left text-[11px] font-medium cursor-pointer select-none hover:text-base-content transition-colors${
column === 'type'
? ' hidden md:table-cell w-[96px]'
: column === 'platform'
? ' hidden lg:table-cell w-[110px]'
: column === 'lastBackup'
? ' w-[120px]'
: column === 'outcome'
? ' w-[116px]'
: ''
}`}
onClick={() => toggleProtectedSort(column)}
>
<TableCell
class="max-w-[420px] px-3 py-1.5 text-base-content"
title={label}
<span class="inline-flex items-center gap-1">
{label}
<Show when={protectedSortCol() === column}>
<svg class="h-3 w-3" viewBox="0 0 12 12" fill="currentColor">
{protectedSortDir() === 'asc' ? (
<path d="M6 3l3.5 5h-7z" />
) : (
<path d="M6 9l3.5-5h-7z" />
)}
</svg>
</Show>
</span>
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
<For each={sortedRollups()}>
{(rollup) => {
const resourceIndex = props.resourcesById();
const label = getRecoveryRollupItemLabel(rollup, resourceIndex);
const secondaryLabel = getRecoveryRollupItemSecondaryLabel(rollup);
const attemptMs = rollup.lastAttemptAt ? Date.parse(rollup.lastAttemptAt) : 0;
const successMs = rollup.lastSuccessAt ? Date.parse(rollup.lastSuccessAt) : 0;
const inventoryStatus = getRecoveryRollupInventoryStatus(rollup);
const inventoryStatusLabel =
getRecoveryRollupInventoryStatusLabel(inventoryStatus);
const platforms = getRecoveryRollupPlatforms(rollup)
.map((platform) => String(platform || '').trim())
.filter(Boolean)
.sort((left, right) =>
getSourcePlatformLabel(left).localeCompare(getSourcePlatformLabel(right)),
);
const itemTypePresentation =
getRecoveryItemTypePresentation(getRecoveryRollupItemTypeKey(rollup)) || null;
const nowMs = Date.now();
const neverSucceeded = inventoryStatus === 'never-succeeded';
const protectionInsight = getProtectionInsight(rollup, inventoryStatus, nowMs);
return (
<TableRow
class="cursor-pointer odd:bg-surface even:bg-surface-alt/35 transition-colors hover:bg-surface-hover/95"
onClick={() => props.onSelectRollup(rollup.rollupId)}
>
<div class="flex min-w-0 flex-col gap-1">
<div class="flex min-w-0 items-center gap-2">
<StatusDot
variant={getRecoveryRollupInventoryStatusVariant(inventoryStatus)}
size="xs"
pulse={inventoryStatus === 'running'}
title={inventoryStatusLabel}
ariaLabel={inventoryStatusLabel}
/>
<div class="flex min-w-0 items-baseline gap-1.5">
<span class="truncate text-[13px] font-medium">{label}</span>
<Show when={secondaryLabel}>
<span class="shrink-0 text-[10px] font-mono tabular-nums text-muted">
{secondaryLabel}
<TableCell
class="max-w-[420px] px-3 py-1.5 text-base-content"
title={label}
>
<div class="flex min-w-0 flex-col gap-1">
<div class="flex min-w-0 items-center gap-2">
<StatusDot
variant={getRecoveryRollupInventoryStatusVariant(inventoryStatus)}
size="xs"
pulse={inventoryStatus === 'running'}
title={inventoryStatusLabel}
ariaLabel={inventoryStatusLabel}
/>
<div class="flex min-w-0 items-baseline gap-1.5">
<span class="truncate text-[13px] font-medium">{label}</span>
<Show when={secondaryLabel}>
<span class="shrink-0 text-[10px] font-mono tabular-nums text-muted">
{secondaryLabel}
</span>
</Show>
</div>
</div>
<Show when={protectionInsight}>
<div class="pl-4 text-[10px] leading-4 text-muted">
{protectionInsight}
</div>
</Show>
<div class="flex flex-wrap items-center gap-1.5 text-[10px] md:hidden">
<Show when={itemTypePresentation?.label}>
<span class={itemTypePresentation?.tableBadgeClasses}>
{itemTypePresentation?.label}
</span>
</Show>
<Show when={platforms.length > 0}>
<For each={platforms.slice(0, 2)}>
{(platform) => {
const badge = getSourcePlatformBadge(platform);
return (
<span class={`${badge?.classes || ''} lg:hidden`}>
{badge?.label || getSourcePlatformLabel(platform)}
</span>
);
}}
</For>
</Show>
</div>
</div>
<Show when={protectionInsight}>
<div class="pl-4 text-[10px] leading-4 text-muted">
{protectionInsight}
</div>
</Show>
<div class="flex flex-wrap items-center gap-1.5 text-[10px] md:hidden">
<Show when={itemTypePresentation?.label}>
<span class={itemTypePresentation?.tableBadgeClasses}>
{itemTypePresentation?.label}
</span>
</Show>
<Show when={platforms.length > 0}>
<For each={platforms.slice(0, 2)}>
{(platform) => {
const badge = getSourcePlatformBadge(platform);
return (
<span class={`${badge?.classes || ''} lg:hidden`}>
{badge?.label || getSourcePlatformLabel(platform)}
</span>
);
}}
</For>
</Show>
</div>
</div>
</TableCell>
</TableCell>
<TableCell class="hidden md:table-cell whitespace-nowrap px-3 py-1.5">
<Show
when={itemTypePresentation}
fallback={<span class="text-muted"></span>}
>
<span class={itemTypePresentation?.tableBadgeClasses}>
{itemTypePresentation?.label}
</span>
</Show>
</TableCell>
<TableCell class="hidden lg:table-cell whitespace-nowrap px-3 py-1.5">
<div class="flex flex-wrap gap-1.5">
<For each={platforms}>
{(platform) => {
const badge = getSourcePlatformBadge(platform);
return (
<span class={badge?.classes || ''}>
{badge?.label || getSourcePlatformLabel(platform)}
</span>
);
}}
</For>
</div>
</TableCell>
<TableCell
class={`whitespace-nowrap px-3 py-1.5 ${getRecoveryRollupAgeTextClass(
rollup,
nowMs,
)}`}
title={
successMs > 0
? formatAbsoluteTime(successMs)
: attemptMs > 0
? formatAbsoluteTime(attemptMs)
: undefined
}
>
<div class="flex flex-col">
<span>
{successMs > 0 ? (
formatRelativeTime(successMs)
) : neverSucceeded ? (
<span class={getRecoverySpecialOutcomeTextClass('never')}>never</span>
) : (
'—'
)}
</span>
<Show when={inventoryStatus !== 'healthy' && attemptMs > 0}>
<span class="text-[10px] font-normal text-muted">
Attempt {formatRelativeTime(attemptMs)}
<TableCell class="hidden md:table-cell whitespace-nowrap px-3 py-1.5">
<Show
when={itemTypePresentation}
fallback={<span class="text-muted"></span>}
>
<span class={itemTypePresentation?.tableBadgeClasses}>
{itemTypePresentation?.label}
</span>
</Show>
</div>
</TableCell>
</TableCell>
<TableCell class="whitespace-nowrap px-3 py-1.5">
<span
class={`text-[11px] font-medium ${getRecoveryRollupInventoryStatusTextClass(
inventoryStatus,
<TableCell class="hidden lg:table-cell whitespace-nowrap px-3 py-1.5">
<div class="flex flex-wrap gap-1.5">
<For each={platforms}>
{(platform) => {
const badge = getSourcePlatformBadge(platform);
return (
<span class={badge?.classes || ''}>
{badge?.label || getSourcePlatformLabel(platform)}
</span>
);
}}
</For>
</div>
</TableCell>
<TableCell
class={`whitespace-nowrap px-3 py-1.5 ${getRecoveryRollupAgeTextClass(
rollup,
nowMs,
)}`}
title={
successMs > 0
? formatAbsoluteTime(successMs)
: attemptMs > 0
? formatAbsoluteTime(attemptMs)
: undefined
}
>
{inventoryStatusLabel}
</span>
</TableCell>
</TableRow>
);
}}
</For>
</TableBody>
</Table>
</div>
<div class="flex items-center justify-between gap-2 border-t border-border bg-surface px-4 py-3 text-xs text-muted">
<div>
<Show
when={sortedRollups().length > 0}
fallback={<span>Showing 0 of 0 protected items</span>}
>
<span>Showing {sortedRollups().length} protected items</span>
</Show>
<div class="flex flex-col">
<span>
{successMs > 0 ? (
formatRelativeTime(successMs)
) : neverSucceeded ? (
<span class={getRecoverySpecialOutcomeTextClass('never')}>
never
</span>
) : (
'—'
)}
</span>
<Show when={inventoryStatus !== 'healthy' && attemptMs > 0}>
<span class="text-[10px] font-normal text-muted">
Attempt {formatRelativeTime(attemptMs)}
</span>
</Show>
</div>
</TableCell>
<TableCell class="whitespace-nowrap px-3 py-1.5">
<span
class={`text-[11px] font-medium ${getRecoveryRollupInventoryStatusTextClass(
inventoryStatus,
)}`}
>
{inventoryStatusLabel}
</span>
</TableCell>
</TableRow>
);
}}
</For>
</TableBody>
</Table>
</div>
<div class="flex items-center justify-between gap-2 border-t border-border bg-surface px-4 py-3 text-xs text-muted">
<div>
<Show
when={sortedRollups().length > 0}
fallback={<span>Showing 0 of 0 protected items</span>}
>
<span>Showing {sortedRollups().length} protected items</span>
</Show>
</div>
</div>
</div>
</Show>
</Card>
</TableCard>
</div>
);
};

View file

@ -1,6 +1,6 @@
import { Component, Show } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { SummaryTableCardHeader } from '@/components/shared/SummaryTableCardHeader';
import { TableCard } from '@/components/shared/TableCard';
import { DiskList } from '@/components/Storage/DiskList';
import StoragePoolsTable from '@/components/Storage/StoragePoolsTable';
import { STORAGE_CONTENT_CARD_BODY_CLASS } from '@/features/storageBackups/storagePagePresentation';
@ -59,11 +59,8 @@ export const StorageContentCard: Component<StorageContentCardProps> = (props) =>
Boolean(props.focusedSummaryGroupId() || props.expandedPoolId() || props.selectedDiskId());
return (
<Card
<TableCard
ref={props.setTableRootRef}
padding="none"
tone="card"
class="overflow-hidden"
data-summary-clear-surface
data-testid="storage-content-surface"
>
@ -115,7 +112,7 @@ export const StorageContentCard: Component<StorageContentCardProps> = (props) =>
onHoverChange={props.setHoveredStorageResourceId}
/>
</Show>
</Card>
</TableCard>
);
};

View file

@ -3,7 +3,7 @@ import { JSX, splitProps, mergeProps } from 'solid-js';
type Tone = 'default' | 'muted' | 'info' | 'success' | 'warning' | 'danger' | 'card' | 'glass';
type Padding = 'none' | 'sm' | 'md' | 'lg';
type CardProps = {
export type CardProps = {
tone?: Tone;
padding?: Padding;
hoverable?: boolean;

View file

@ -64,6 +64,7 @@ import summaryRowActionButtonSource from '@/components/shared/SummaryRowActionBu
import summaryInteractionA11ySource from '@/components/shared/summaryInteractionA11y.ts?raw';
import summaryTableCardHeaderSource from '@/components/shared/SummaryTableCardHeader.tsx?raw';
import summaryTableFocusSource from '@/components/shared/summaryTableFocus.ts?raw';
import tableCardSource from '@/components/shared/TableCard.tsx?raw';
import groupedTableRowPresentationSource from '@/components/shared/groupedTableRowPresentation.ts?raw';
import infrastructureSummaryTableSource from '@/components/shared/InfrastructureSummaryTable.tsx?raw';
import infrastructureSummaryTableRowSource from '@/components/shared/InfrastructureSummaryTableRow.tsx?raw';
@ -118,15 +119,20 @@ import dashboardSelectionStateSource from '@/components/Dashboard/useDashboardSe
import infrastructureSummarySource from '@/components/Infrastructure/InfrastructureSummary.tsx?raw';
import infrastructureSummaryStateSource from '@/components/Infrastructure/useInfrastructureSummaryState.ts?raw';
import unifiedResourceHostTableCardSource from '@/components/Infrastructure/UnifiedResourceHostTableCard.tsx?raw';
import unifiedResourceServiceInfrastructureCardSource from '@/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx?raw';
import unifiedResourcePBSTableSectionSource from '@/components/Infrastructure/UnifiedResourcePBSTableSection.tsx?raw';
import unifiedResourcePMGTableSectionSource from '@/components/Infrastructure/UnifiedResourcePMGTableSection.tsx?raw';
import nodeGroupHeaderSource from '@/components/shared/NodeGroupHeader.tsx?raw';
import storageGroupRowSource from '@/components/Storage/StorageGroupRow.tsx?raw';
import storageGroupPresentationSource from '@/features/storageBackups/groupPresentation.ts?raw';
import storagePoolRowSource from '@/components/Storage/StoragePoolRow.tsx?raw';
import storageContentCardSource from '@/components/Storage/StorageContentCard.tsx?raw';
import diskListSource from '@/components/Storage/DiskList.tsx?raw';
import storageSummarySource from '@/components/Storage/StorageSummary.tsx?raw';
import workloadsSummarySource from '@/components/Workloads/WorkloadsSummary.tsx?raw';
import recoveryComponentSource from '@/components/Recovery/Recovery.tsx?raw';
import recoveryHistorySectionSource from '@/components/Recovery/RecoveryHistorySection.tsx?raw';
import recoveryProtectedInventorySectionSource from '@/components/Recovery/RecoveryProtectedInventorySection.tsx?raw';
import recoveryTablePresentationSource from '@/utils/recoveryTablePresentation.ts?raw';
import resourceDetailDrawerOverviewSource from '@/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx?raw';
import aiSettingsDialogsSource from '@/components/Settings/AISettingsDialogs.tsx?raw';
@ -278,6 +284,28 @@ describe('shared primitive guardrails', () => {
expect(storagePoolRowSource).not.toContain('Clear selection');
});
it('keeps framed product table surfaces on the shared TableCard owner', () => {
expect(tableCardSource).toContain("export const TABLE_CARD_FRAME_CLASS = 'overflow-hidden'");
expect(tableCardSource).toContain("Omit<CardProps, 'border' | 'padding' | 'tone'>");
expect(tableCardSource).toContain('border={true}');
expect(tableCardSource).toContain('padding="none"');
expect(tableCardSource).toContain('tone="card"');
expect(tableCardSource).toContain('<Card');
for (const source of [
dashboardWorkloadTableSource,
unifiedResourceHostTableCardSource,
unifiedResourceServiceInfrastructureCardSource,
storageContentCardSource,
recoveryComponentSource,
recoveryHistorySectionSource,
recoveryProtectedInventorySectionSource,
]) {
expect(source).toContain('TableCard');
expect(source).not.toContain('overflow-hidden border-border-subtle bg-surface');
}
});
it('keeps shared subtabs as one primitive and leaves shell styling to owning surfaces', () => {
expect(subtabsSource).not.toContain("variant?: 'default' | 'control'");
expect(subtabsSource).not.toContain('subtabsControlShellClass');
@ -387,6 +415,13 @@ describe('shared primitive guardrails', () => {
expect(frontendIndexCssSource).toContain('--color-summary-group-member-pinned-accent');
expect(frontendIndexCssSource).toContain('tr.grouped-table-row > td');
expect(frontendIndexCssSource).toContain('--color-grouped-table-row-bg');
expect(frontendIndexCssSource).toContain(
'--color-grouped-table-row-bg: rgba(226, 232, 240, 0.72);',
);
expect(frontendIndexCssSource).toContain(
'--color-grouped-table-row-bg: rgba(51, 65, 85, 0.58);',
);
expect(frontendIndexCssSource).not.toContain('--color-grouped-table-row-bg: theme(');
expect(groupedTableRowPresentationSource).toContain('GROUPED_TABLE_ROW_CLASS');
expect(groupedTableRowPresentationSource).not.toContain('GROUPED_TABLE_ROW_DIVIDER_CLASS');

View file

@ -0,0 +1,23 @@
import { splitProps } from 'solid-js';
import { Card, type CardProps } from '@/components/shared/Card';
export type TableCardProps = Omit<CardProps, 'border' | 'padding' | 'tone'>;
export const TABLE_CARD_FRAME_CLASS = 'overflow-hidden';
export function TableCard(props: TableCardProps) {
const [local, rest] = splitProps(props, ['class']);
return (
<Card
{...rest}
border={true}
padding="none"
tone="card"
class={`${TABLE_CARD_FRAME_CLASS} ${local.class ?? ''}`.trim()}
/>
);
}
export default TableCard;

View file

@ -85,9 +85,10 @@
--color-summary-group-member-pinned-bg: rgba(14, 165, 233, 0.04);
--color-summary-group-member-pinned-bg-hover: rgba(14, 165, 233, 0.055);
--color-summary-group-member-pinned-accent: rgba(14, 165, 233, 0.22);
--color-grouped-table-row-bg: theme('colors.slate.200');
--color-grouped-table-row-bg-hover: theme('colors.slate.300');
--color-grouped-table-row-border: theme('colors.slate.300');
/* Shared product-table subgroup band. It should read as body segmentation, not table chrome. */
--color-grouped-table-row-bg: rgba(226, 232, 240, 0.72);
--color-grouped-table-row-bg-hover: rgba(203, 213, 225, 0.82);
--color-grouped-table-row-border: rgba(203, 213, 225, 0.9);
}
.dark {
@ -110,9 +111,9 @@
--color-summary-group-member-pinned-bg: rgba(56, 189, 248, 0.07);
--color-summary-group-member-pinned-bg-hover: rgba(56, 189, 248, 0.09);
--color-summary-group-member-pinned-accent: rgba(56, 189, 248, 0.28);
--color-grouped-table-row-bg: theme('colors.slate.900');
--color-grouped-table-row-bg-hover: theme('colors.slate.800');
--color-grouped-table-row-border: theme('colors.slate.700');
--color-grouped-table-row-bg: rgba(51, 65, 85, 0.58);
--color-grouped-table-row-bg-hover: rgba(51, 65, 85, 0.82);
--color-grouped-table-row-border: rgba(71, 85, 105, 0.85);
}
body {

View file

@ -1107,8 +1107,17 @@ describe('frontend resource type boundaries', () => {
"import { useRecoverySurfaceState } from '@/features/recovery/useRecoverySurfaceState';",
);
expect(recoveryComponentSource).toContain('useRecoverySurfaceState');
expect(recoveryComponentSource).toContain('TableCard');
expect(recoveryHistorySectionSource).toContain('useRecoveryHistorySectionState');
expect(recoveryHistorySectionSource).toContain('RecoveryHistoryTable');
expect(recoveryHistorySectionSource).toContain('TableCard');
expect(recoveryHistorySectionSource).not.toContain(
'overflow-hidden border-border-subtle bg-surface',
);
expect(recoveryProtectedInventorySectionSource).toContain('TableCard');
expect(recoveryProtectedInventorySectionSource).not.toContain(
'overflow-hidden border-border-subtle bg-surface',
);
expect(recoveryHistorySectionStateSource).toContain(
'export function useRecoveryHistorySectionState',
);
@ -2050,6 +2059,7 @@ describe('frontend resource type boundaries', () => {
expect(storageContentCardSource).toContain('useStorageContentCardModel');
expect(storageContentCardSource).toContain('DiskList');
expect(storageContentCardSource).toContain('StoragePoolsTable');
expect(storageContentCardSource).toContain('TableCard');
expect(storageContentCardSource).toContain('SummaryTableCardHeader');
expect(storageContentCardSource).toContain('STORAGE_CONTENT_CARD_BODY_CLASS');
expect(storageContentCardSource).not.toContain(