Finish drawer detail row convergence

This commit is contained in:
rcourtman
2026-08-27 21:12:12 +01:00
parent 4b9abd4eca
commit 3608854f7e
11 changed files with 148 additions and 101 deletions
@@ -1239,10 +1239,11 @@ not a replacement status card, CTA band, or page-local nested card.
drawer-width `justify-between` rows on desktop.
This contract also covers secondary drawer facts in availability status and
suggestion cards, resource change-history entries, Docker/PBS/PMG service
support panels, and Docker container-update management cards. Headers,
status summaries, actions, disk capacity summaries, and RAID state pairs may
retain intentional endpoint alignment; ordinary label/value facts in those
surfaces may not.
support panels, Docker container-update management cards, action history,
Discovery summaries, and the specialized Proxmox Mail Gateway drawer.
Headers, status summaries, actions, disk capacity summaries, and RAID state
pairs may retain intentional endpoint alignment; ordinary label/value facts
in those surfaces may not.
Read-only metadata chips belong to `MetadataBadge` and domain wrappers over
it. Organization role and share-status chips must use
`OrganizationRoleBadge` and `OrganizationShareStatusBadge`, so role/status
@@ -7616,6 +7616,7 @@
"frontend-modern/src/features/proxmox/ProxmoxCephTable.tsx",
"frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx",
"frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts",
"frontend-modern/src/features/proxmox/ProxmoxMailGatewayDrawer.tsx",
"frontend-modern/src/features/proxmox/ProxmoxMailGatewayTable.tsx",
"frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx",
"frontend-modern/src/features/proxmox/proxmoxPageModel.ts",
@@ -7815,6 +7816,7 @@
"frontend-modern/src/features/proxmox/ProxmoxCephTable.tsx",
"frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx",
"frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts",
"frontend-modern/src/features/proxmox/ProxmoxMailGatewayDrawer.tsx",
"frontend-modern/src/features/proxmox/ProxmoxMailGatewayTable.tsx",
"frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx",
"frontend-modern/src/features/proxmox/proxmoxPageModel.ts",
@@ -7889,6 +7891,7 @@
"frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupServersTable.test.ts",
"frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx",
"frontend-modern/src/features/proxmox/__tests__/ProxmoxCoverageTable.test.tsx",
"frontend-modern/src/features/proxmox/__tests__/ProxmoxMailGatewayTable.test.ts",
"frontend-modern/src/features/proxmox/__tests__/ProxmoxNodesTable.test.tsx",
"frontend-modern/src/features/proxmox/__tests__/proxmoxPageModel.test.ts",
"frontend-modern/src/features/proxmox/__tests__/ProxmoxPageSurface.contract.test.tsx",
@@ -268,6 +268,7 @@ about the same disk cannot diverge.
125. `frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx`
126. `frontend-modern/src/features/proxmox/ProxmoxCephTable.tsx`
127. `frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx`
127a. `frontend-modern/src/features/proxmox/ProxmoxMailGatewayDrawer.tsx`
128. `frontend-modern/src/features/proxmox/ProxmoxMailGatewayTable.tsx`
129. `frontend-modern/src/features/proxmox/ProxmoxRecoverableTable.tsx`
130. `frontend-modern/src/features/proxmox/ProxmoxReplicationTable.tsx`
@@ -1331,9 +1332,11 @@ desktop rows use the shared fixed label track so labels and values remain
visually adjacent instead of spanning the full drawer width.
The same boundary applies to availability facts, resource change-history
metadata, Docker/PBS/PMG service facts, nested PMG queue/mail breakdowns, and
Docker container-update management facts. Their headers, actions, and compact
status summaries remain feature-owned, while ordinary label/value rows compose
`InfoCardKeyValueRow` rather than restoring a drawer-width flex split.
Docker container-update management facts. It also covers action-history facts,
Discovery facts, and the specialized `ProxmoxMailGatewayDrawer.tsx` inbound
statistics. Their headers, actions, and compact status summaries remain
feature-owned, while ordinary label/value rows compose `InfoCardKeyValueRow`
rather than restoring a drawer-width flex split.
Curated technical inventory follows the shared compact-row contract instead
of the secondary-card contract. Docker-host drawers must project system,
runtime, memory, storage, and telemetry facts through
+17 -23
View File
@@ -1,38 +1,32 @@
{
"version": 1,
"base_sha": "478a39bdec4b3a20f309490a564bcaca8e423a34",
"verified_at": "2026-08-27T20:06:10Z",
"base_sha": "4b9abd4eca5a1399a2386814d98eeadded198e45",
"verified_at": "2026-08-27T20:10:44Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/features/alerts/AlertsConfigurationSurface.tsx",
"frontend-modern/src/features/alerts/alertsConfigurationModel.ts",
"frontend-modern/src/features/alerts/tabs/ThresholdsTab.tsx",
"frontend-modern/src/features/alerts/thresholds/types.ts",
"frontend-modern/src/features/alerts/useAlertsConfigurationSnapshotState.ts",
"frontend-modern/src/types/alerts.ts"
"frontend-modern/src/components/Discovery/DiscoveryTab.tsx",
"frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx",
"frontend-modern/src/features/proxmox/ProxmoxMailGatewayDrawer.tsx"
],
"content_sha256": {
"frontend-modern/src/features/alerts/AlertsConfigurationSurface.tsx": "6e52a34562926185ae32483add8e303b0a8fae5c3d5315f5b87865f960f54929",
"frontend-modern/src/features/alerts/alertsConfigurationModel.ts": "adb7c745b8fdee148edf56beea8f1b7a02f30e6495d6fc6d7e2eff36f228d10c",
"frontend-modern/src/features/alerts/tabs/ThresholdsTab.tsx": "00e8e50cd3892262c9195129b28fc834d4dbc87adb37aeb6f652957869367ad8",
"frontend-modern/src/features/alerts/thresholds/types.ts": "d87f4509648798b55c01ac9e701faa2b9a96de7e25bf6453bfc10fbc5176e1f3",
"frontend-modern/src/features/alerts/useAlertsConfigurationSnapshotState.ts": "34fff486ecf33457f42cefeb0b04bbd4b3e04dad24faca8bd99bfd86a75e70f7",
"frontend-modern/src/types/alerts.ts": "ef4e8322a38e8443393f762ee4ba6c512ffd4ff18b3f32ff5fd64f5d3754585d"
"frontend-modern/src/components/Discovery/DiscoveryTab.tsx": "50839ded7dc62cbc3a9fb11fc5fc575acd1997ec9a4b0d7331fba22f0afb0ab4",
"frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx": "241e5f93f444b3838b80c05f6b4f78c4788931c760d01dc63f54ee5d4ee5270a",
"frontend-modern/src/features/proxmox/ProxmoxMailGatewayDrawer.tsx": "26faeb83e422b1d928385ce836e86a701ce544aa936b8de5bb38caee845bcb4d"
},
"routes": ["/alerts/thresholds/proxmox"],
"routes": ["/proxmox/mail", "/standalone/machines", "/docker/overview", "/actions"],
"viewports": [
{ "width": 1440, "height": 900 },
{ "width": 1920, "height": 800 },
{ "width": 390, "height": 844 }
],
"states": [
"CPU evaluation window loaded with the global five-minute rule and inherited platform overrides",
"mobile platform overrides collapsed and expanded",
"global rule changed to one minute with inherited labels updating immediately"
"mail-gateway-eu expanded with inbound disposition statistics visible",
"Apollo-114 and auth-service-01 management drawers inspected for Discovery and action-audit availability",
"Actions open and history views inspected for seeded action records"
],
"interactions": [
"confirmed the desktop three-column profile grid is readable without displacing threshold navigation",
"confirmed the narrow layout keeps the global rule visible and collapses platform overrides",
"expanded mobile platform overrides and confirmed every selector remains accessible",
"changed the global CPU window to one minute and confirmed inherited labels and unsaved-change state update"
"expanded mail-gateway-eu and confirmed Greylist, RBL, and average-process values remain adjacent to their labels on desktop",
"confirmed the three inbound statistic rows span the mobile card cleanly at 390px without overlap",
"repeated the PMG desktop check after the mobile span correction",
"inspected machine and Docker host drawers plus Actions history; the demo does not seed resource-level Discovery facts or action-audit drawer rows, so those responsive states are covered by rendered component tests"
]
}
@@ -32,7 +32,7 @@ import {
} from '@/utils/resourceAnalysisPresentation';
import { ButtonLink, CopyValueButton } from '@/components/shared/Button';
import { CopyableCodeRow } from '@/components/shared/CopyableCodeRow';
import { InfoCardFrame } from '@/components/shared/InfoCardFrame';
import { InfoCardFrame, InfoCardKeyValueRow } from '@/components/shared/InfoCardFrame';
import { useDiscoveryTabState } from './useDiscoveryTabState';
import { orderFactsByActionability } from './factOrdering';
import { deriveCliCommand } from './cliCommand';
@@ -1020,22 +1020,32 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
<div class="space-y-1.5">
<For each={orderFactsByActionability(d().facts).slice(0, 8)}>
{(fact) => (
<div class="flex items-center justify-between gap-2 text-xs">
<span class="min-w-0 text-muted truncate">{fact.key}</span>
<div class="flex min-w-0 items-center gap-1.5">
<span class="truncate font-medium text-base-content" title={fact.value}>
{fact.value}
</span>
<CopyValueButton
value={fact.value}
copied={copiedDiscoveryValue() === fact.value.trim()}
onCopyValue={handleCopyDiscoveryValue}
label={`Copy ${fact.key}`}
variant="ghost"
size="xs"
/>
</div>
</div>
<InfoCardKeyValueRow
class="text-xs"
label={fact.key}
labelClass="truncate"
labelTitle={fact.key}
value={
<>
<span
class="truncate font-medium text-base-content"
title={fact.value}
>
{fact.value}
</span>
<CopyValueButton
value={fact.value}
copied={copiedDiscoveryValue() === fact.value.trim()}
onCopyValue={handleCopyDiscoveryValue}
label={`Copy ${fact.key}`}
variant="ghost"
size="xs"
/>
</>
}
valueClass="flex items-center gap-1.5"
valueTitle={fact.value}
/>
)}
</For>
</div>
@@ -226,7 +226,16 @@ describe('DiscoveryTab', () => {
service_version: '16.1',
category: 'database',
cli_access: 'psql',
facts: [],
facts: [
{
category: 'network',
key: 'Listen address',
value: '0.0.0.0:5432',
source: 'ss',
confidence: 0.95,
discovered_at: '2026-04-15T00:00:00Z',
},
],
config_paths: [],
data_paths: [],
log_paths: [],
@@ -255,6 +264,12 @@ describe('DiscoveryTab', () => {
));
const runButton = await screen.findByRole('button', { name: 'Run Discovery' });
const factRow = (await screen.findByText('Listen address')).parentElement;
expect(factRow).toHaveClass('justify-between', 'lg:grid', 'lg:grid-cols-[7rem_minmax(0,1fr)]');
expect(screen.getByText('0.0.0.0:5432').parentElement).toHaveClass(
'text-right',
'lg:text-left',
);
await waitFor(() => expect(runButton).not.toBeDisabled());
fireEvent.click(runButton);
@@ -1,6 +1,6 @@
import { For, Show } from 'solid-js';
import type { Component } from 'solid-js';
import { InfoCardFrame } from '@/components/shared/InfoCardFrame';
import { InfoCardFrame, InfoCardKeyValueRow } from '@/components/shared/InfoCardFrame';
import type { ActionAuditRecord } from '@/types/actionAudit';
import { formatRelativeTime } from '@/utils/format';
import {
@@ -55,27 +55,22 @@ const ActionHistoryRow: Component<{ audit: ActionAuditRecord }> = (props) => {
<Show when={props.audit.request?.reason}>
<div class="text-base-content">{props.audit.request.reason}</div>
</Show>
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Approval</span>
<span class="font-medium text-base-content">
{formatActionApprovalPolicyLabel(props.audit.plan?.approvalPolicy)}
</span>
</div>
<InfoCardKeyValueRow
label="Approval"
value={formatActionApprovalPolicyLabel(props.audit.plan?.approvalPolicy)}
/>
<Show when={preflight()}>
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Dry run</span>
<span class="font-medium text-base-content">
{preflight()?.dryRunAvailable ? 'Available' : 'Not available'}
</span>
</div>
<InfoCardKeyValueRow
label="Dry run"
value={preflight()?.dryRunAvailable ? 'Available' : 'Not available'}
/>
</Show>
<Show when={preflight()?.intendedChange}>
<div class="flex items-start justify-between gap-2">
<span class="text-muted">Intent</span>
<span class="max-w-[70%] text-right font-medium text-base-content">
{preflight()?.intendedChange}
</span>
</div>
<InfoCardKeyValueRow
label="Intent"
value={preflight()?.intendedChange}
valueClass="break-words"
/>
</Show>
<Show when={(preflight()?.safetyChecks || []).length > 0}>
<div class="space-y-1">
@@ -113,19 +108,20 @@ const ActionHistoryRow: Component<{ audit: ActionAuditRecord }> = (props) => {
})()}
</Show>
<Show when={apt()?.facts.length}>
<dl
<div
data-testid="resource-apt-action-facts"
class="grid gap-1 rounded border border-border bg-surface px-2 py-1.5 sm:grid-cols-2"
>
<For each={apt()?.facts ?? []}>
{(fact) => (
<div class="flex items-start justify-between gap-2">
<dt class="text-muted">{fact.label}</dt>
<dd class="text-right font-medium text-base-content">{fact.value}</dd>
</div>
<InfoCardKeyValueRow
label={fact.label}
value={fact.value}
valueClass="break-words"
/>
)}
</For>
</dl>
</div>
</Show>
<Show when={verificationOutcome()}>
{(() => {
@@ -70,6 +70,13 @@ describe('ResourceActionHistory verification rendering', () => {
);
const actionHistory = within(screen.getByTestId('resource-action-history-section'));
const approvalRow = actionHistory.getAllByText('Approval')[0]?.parentElement;
expect(approvalRow).toHaveClass(
'justify-between',
'lg:grid',
'lg:grid-cols-[7rem_minmax(0,1fr)]',
);
expect(approvalRow?.lastElementChild).toHaveClass('text-right', 'lg:text-left');
expect(actionHistory.getAllByText('Legacy check passed (source unclassified)')).toHaveLength(1);
expect(actionHistory.getByText("systemctl is-active 'nginx'")).toBeInTheDocument();
expect(actionHistory.queryByText('should not render')).toBeNull();
@@ -1247,6 +1247,18 @@ describe('shared primitive guardrails', () => {
/class="flex items-(?:center|start) justify-between gap-2">\s*<span class="text-muted">/,
);
}
for (const consumerPath of [
'src/components/Discovery/DiscoveryTab.tsx',
'src/components/Infrastructure/ResourceActionHistory.tsx',
'src/features/proxmox/ProxmoxMailGatewayDrawer.tsx',
]) {
const source = readFrontendSource(consumerPath);
expect(source).toContain('InfoCardKeyValueRow');
expect(source).not.toMatch(
/class="flex items-(?:baseline|center|start) justify-between[^\"]*">\s*<(?:span|dt) class="[^\"]*text-muted/,
);
}
});
it('keeps shared subtabs as one primitive and leaves shell styling to owning surfaces', () => {
@@ -1,6 +1,7 @@
import { For, Show, createMemo, createResource, type Component } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { DrawerSubjectHeading } from '@/components/shared/DrawerSubjectHeading';
import { InfoCardKeyValueRow } from '@/components/shared/InfoCardFrame';
import { ObjectDrawerHeader } from '@/components/shared/ObjectDrawerHeader';
import { StatusDot } from '@/components/shared/StatusDot';
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
@@ -396,34 +397,28 @@ export const ProxmoxMailGatewayDrawer: Component<{
outValue={stats()!.bytesOut ?? 0}
format={(v) => (v > 0 ? formatBytes(v) : '—')}
/>
<div class="space-y-1">
<div class="flex items-baseline justify-between text-[11px]">
<span class="text-muted">Greylist / Junk in</span>
<span class="font-mono text-[10px] text-base-content font-semibold">
{formatPlatformTableIntegerValue(stats()!.greylistCount)} /{' '}
{formatPlatformTableIntegerValue(stats()!.junkIn)}
</span>
</div>
</div>
<div class="space-y-1">
<div class="flex items-baseline justify-between text-[11px]">
<span class="text-muted">RBL / Pregreet rejects</span>
<span class="font-mono text-[10px] text-base-content font-semibold">
{formatPlatformTableIntegerValue(stats()!.rblRejects)} /{' '}
{formatPlatformTableIntegerValue(stats()!.pregreetRejects)}
</span>
</div>
</div>
<div class="space-y-1">
<div class="flex items-baseline justify-between text-[11px]">
<span class="text-muted">Avg process</span>
<span class="font-mono text-[10px] text-base-content font-semibold">
{stats()?.averageProcessTimeMs
? `${Math.round(stats()!.averageProcessTimeMs)}ms`
: '—'}
</span>
</div>
</div>
<InfoCardKeyValueRow
class="col-span-2 text-[11px] sm:col-span-1"
label="Greylist / Junk in"
value={`${formatPlatformTableIntegerValue(stats()!.greylistCount)} / ${formatPlatformTableIntegerValue(stats()!.junkIn)}`}
valueClass="font-mono text-[10px]"
/>
<InfoCardKeyValueRow
class="col-span-2 text-[11px] sm:col-span-1"
label="RBL / Pregreet rejects"
value={`${formatPlatformTableIntegerValue(stats()!.rblRejects)} / ${formatPlatformTableIntegerValue(stats()!.pregreetRejects)}`}
valueClass="font-mono text-[10px]"
/>
<InfoCardKeyValueRow
class="col-span-2 text-[11px] sm:col-span-1"
label="Avg process"
value={
stats()?.averageProcessTimeMs
? `${Math.round(stats()!.averageProcessTimeMs)}ms`
: '—'
}
valueClass="font-mono text-[10px]"
/>
</div>
</Show>
</Card>
@@ -6,8 +6,19 @@ import {
MAIL_GATEWAY_PHONE_COLUMNS,
MAIL_GATEWAY_PHONE_COLUMN_WIDTHS,
} from '../ProxmoxMailGatewayTable';
import mailGatewayDrawerSource from '../ProxmoxMailGatewayDrawer.tsx?raw';
describe('ProxmoxMailGatewayTable phone presentation', () => {
it('keeps compact drawer statistics on the shared responsive row contract', () => {
expect(mailGatewayDrawerSource).toContain('InfoCardKeyValueRow');
expect(mailGatewayDrawerSource).not.toMatch(
/class="flex items-baseline justify-between[^\"]*">\s*<span class="text-muted">/,
);
expect(
mailGatewayDrawerSource.match(/class="col-span-2 text-\[11px\] sm:col-span-1"/g),
).toHaveLength(3);
});
it('keeps fleet context and mail-flow counters visible without scrolling', () => {
expect(MAIL_GATEWAY_PHONE_COLUMNS).toEqual([
'instance',