Converge remaining drawer detail rows

This commit is contained in:
rcourtman
2026-08-27 20:36:44 +01:00
parent b0c1eca4cd
commit 13ceeffe7d
13 changed files with 309 additions and 298 deletions
@@ -215,6 +215,7 @@ overflow.
1a. `frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts`
1b. `frontend-modern/src/components/Workloads/nodeDrawerModel.ts`
1c. `frontend-modern/src/features/docker/dockerHostDrawerModel.ts`
1d. `frontend-modern/src/components/Workloads/AvailabilityProbeSuggestionCard.tsx`
2. `frontend-modern/src/components/Settings/Settings.tsx`
3. `frontend-modern/src/components/Settings/SettingsDialogs.tsx`
4. `frontend-modern/src/components/Settings/SettingsPageShell.tsx`
@@ -1226,6 +1227,12 @@ not a replacement status card, CTA band, or page-local nested card.
otherwise the shared `lg` transition is canonical. Feature surfaces own the
labels, values, wrapping, and selection behavior, but must not restore
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.
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
@@ -6185,6 +6192,10 @@ Known platform resources use that same compact presentation when availability
is attached. `AvailabilityProbeStatusCard` is the shared detail primitive for
Workloads and Docker host drawers; it renders the complete target, protocol,
latest result, latency when relevant, evidence freshness, and last observation.
Its fact rows, and the matching service/probe/target rows in
`AvailabilityProbeSuggestionCard`, compose `InfoCardKeyValueRow` so phone
layouts remain condensed while wide cards keep each value adjacent to its
label.
Plural attached checks render as repeated bounded cards from
`availabilityChecks`, while the row keeps one compatibility summary. Expired
successful evidence must render an amber `Stale` state with no green
@@ -4899,6 +4899,7 @@
"frontend-modern/src/components/SetupWizard/SetupCompletionPreview.tsx",
"frontend-modern/src/components/SetupWizard/SetupWizard.tsx",
"frontend-modern/src/components/Toast/Toast.tsx",
"frontend-modern/src/components/Workloads/AvailabilityProbeSuggestionCard.tsx",
"frontend-modern/src/components/Workloads/nodeDrawerModel.ts",
"frontend-modern/src/features/docker/dockerHostDrawerModel.ts",
"frontend-modern/src/features/platformPage/platformEstateOverviewModel.ts",
@@ -5185,8 +5186,21 @@
"frontend-modern/src/utils/__tests__/reportingResourceTypes.test.ts"
]
},
{
"id": "workload-presentation-helpers",
{
"id": "compact-info-card-consumers",
"label": "compact information card consumer proof",
"match_prefixes": [],
"match_files": [
"frontend-modern/src/components/Workloads/AvailabilityProbeSuggestionCard.tsx"
],
"allow_same_subsystem_tests": false,
"test_prefixes": [],
"exact_files": [
"frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts"
]
},
{
"id": "workload-presentation-helpers",
"label": "workload presentation helper proof",
"match_prefixes": [],
"match_files": [
@@ -1329,6 +1329,11 @@ and temperature facts must also compose the frontend-primitives
`InfoCardKeyValueRow`. Mobile rows retain their condensed endpoint layout;
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.
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
+21 -19
View File
@@ -1,34 +1,36 @@
{
"version": 1,
"base_sha": "636367e96ee97ce56c7b1353635236a418a3922c",
"verified_at": "2026-08-27T19:10:47Z",
"base_sha": "b0c1eca4cdace3cb798a77b2fa0c01a220f76981",
"verified_at": "2026-08-27T19:35:10Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/features/alerts/AlertEscalationSection.tsx",
"frontend-modern/src/features/alerts/helpers.ts",
"frontend-modern/src/features/alerts/useAlertScheduleState.ts"
"frontend-modern/src/components/Infrastructure/AvailabilityProbeStatusCard.tsx",
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx",
"frontend-modern/src/components/Workloads/AvailabilityProbeSuggestionCard.tsx",
"frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx"
],
"content_sha256": {
"frontend-modern/src/features/alerts/AlertEscalationSection.tsx": "f80f94d529196fffb4e184288c5ce9369f673bc8cabc0eb2047ebc10b7dfe363",
"frontend-modern/src/features/alerts/helpers.ts": "27feef6ede9bdb6668af5c447c5b9256f019b8df5a291ae8b5d436d371363794",
"frontend-modern/src/features/alerts/useAlertScheduleState.ts": "84ec18253c926be70d941daf5b56bf045cf372ded5026033849d38f76c23e84f"
"frontend-modern/src/components/Infrastructure/AvailabilityProbeStatusCard.tsx": "2d991718c178866ba5bd7d1b89e7a1a9bec6fcd30133bc2fd549aa2fd3ccf4e5",
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx": "ac68e2e7f5d954ca5bd23b44f347817ce9a39414ab493d22ad52340ceb3296e2",
"frontend-modern/src/components/Workloads/AvailabilityProbeSuggestionCard.tsx": "a8c549b1284befee652c9ecc79c3c449602d1a102f3867acb81f4a5c9f5fd042",
"frontend-modern/src/features/docker/DockerHostDrawerOverview.tsx": "ab63fba8e1a06cb5951e0d3dc637d92ef371f2b5db5da2dfb80408cd477c0ea5"
},
"routes": ["/alerts/schedule"],
"routes": ["/standalone/availability", "/proxmox/backups/date", "/docker/overview"],
"viewports": [
{ "width": 1280, "height": 720 },
{ "width": 1920, "height": 800 },
{ "width": 390, "height": 844 }
],
"states": [
"alert escalation enabled with one level and selected disabled destinations visible",
"level delay normalized to the 5 minute lower bound",
"level delay normalized to the 180 minute upper bound",
"upper-bound level retained in the narrow layout without horizontal overflow"
"Solar inverter web panel expanded with availability facts visible",
"backup-vault expanded with Platform details and PBS service facts visible",
"auth-service-01 expanded on Manage with Container updates visible"
],
"interactions": [
"enabled alert escalation and added an escalation level",
"entered 0 minutes and blurred the field, confirming it normalized to 5",
"entered 999 minutes and blurred the field, confirming it normalized to 180",
"resized from desktop to 390px and confirmed zero document-level horizontal overflow",
"confirmed the browser console contained no warnings or errors"
"expanded the Solar inverter web panel and confirmed desktop availability values use an adjacent fixed label track",
"confirmed availability facts retain condensed right-aligned values and clean wrapping at 390px",
"expanded backup-vault, opened Platform details, and revealed the PBS service section",
"confirmed PBS service facts use adjacent desktop values and retain compact mobile alignment",
"expanded auth-service-01, selected Manage, and confirmed Container updates facts use adjacent desktop values",
"confirmed Docker management facts retain condensed right alignment at 390px"
]
}
@@ -466,6 +466,7 @@
{ "path": "src/components/shared/cards/RootDiskCard.tsx" },
{ "path": "src/components/shared/cards/SystemInfoCard.tsx" },
{ "path": "src/components/shared/cards/TemperaturesCard.tsx" },
{ "path": "src/components/Workloads/AvailabilityProbeSuggestionCard.tsx" },
{ "path": "src/components/Workloads/DrawerDiskListCard.tsx" },
{ "path": "src/features/docker/DockerHostDrawerOverview.tsx" },
{ "path": "src/features/storageBackups/detailPresentation.ts" }
@@ -2,7 +2,7 @@ import { For, Show, createMemo } from 'solid-js';
import { Activity, AlertCircle, Check } from 'lucide-solid';
import type { ResourceAvailabilityMeta } from '@/types/resource';
import { InfoCardFrame } from '@/components/shared/InfoCardFrame';
import { InfoCardFrame, InfoCardKeyValueRow } from '@/components/shared/InfoCardFrame';
import {
getAvailabilityProbeMethodLabel,
getAvailabilityProbeEndpointLabel,
@@ -129,130 +129,111 @@ export function AvailabilityProbeStatusCard(props: AvailabilityProbeStatusCardPr
</span>
</div>
<div class="space-y-1.5 text-[11px]">
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Latency</span>
<Show
when={isUp() && latency()}
fallback={<span class="text-red-600 dark:text-red-400 font-medium"></span>}
>
<span
class="font-medium"
classList={{
'text-emerald-600 dark:text-emerald-400': !isStale(),
'text-amber-600 dark:text-amber-300': isStale(),
}}
<InfoCardKeyValueRow
label="Latency"
value={
<Show
when={isUp() && latency()}
fallback={<span class="text-red-600 dark:text-red-400"></span>}
>
{latency()}
</span>
</Show>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Method</span>
<span class="font-medium text-base-content" title={targetAddr()}>
{method()}
</span>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Target</span>
<span class="font-medium text-base-content truncate ml-2" title={targetAddr()}>
{targetAddr()}
</span>
</div>
<span
classList={{
'text-emerald-600 dark:text-emerald-400': !isStale(),
'text-amber-600 dark:text-amber-300': isStale(),
}}
>
{latency()}
</span>
</Show>
}
/>
<InfoCardKeyValueRow label="Method" value={method()} valueTitle={targetAddr()} />
<InfoCardKeyValueRow
label="Target"
value={targetAddr()}
valueClass="truncate"
valueTitle={targetAddr()}
/>
<Show when={lastChecked()}>
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Checked</span>
<span class="text-base-content/70">{lastChecked()}</span>
</div>
<InfoCardKeyValueRow
label="Checked"
value={lastChecked()}
valueClass="text-base-content/70"
/>
</Show>
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Freshness</span>
<span
class="font-medium"
classList={{
'text-amber-600 dark:text-amber-300': presentation()?.freshnessLabel === 'stale',
'text-base-content': presentation()?.freshnessLabel !== 'stale',
}}
>
{presentation()?.freshnessLabel ?? 'freshness unknown'}
</span>
</div>
<InfoCardKeyValueRow
label="Freshness"
value={presentation()?.freshnessLabel ?? 'freshness unknown'}
valueClass={
presentation()?.freshnessLabel === 'stale' ? 'text-amber-600 dark:text-amber-300' : ''
}
/>
<Show when={presentation()?.correlationLabel}>
{(label) => (
<div class="flex items-start justify-between gap-2">
<span class="text-muted">Resource</span>
<span class="text-right text-amber-600 dark:text-amber-300">{label()}</span>
</div>
<InfoCardKeyValueRow
label="Resource"
value={label()}
valueClass="text-amber-600 dark:text-amber-300"
/>
)}
</Show>
<Show when={certificate()}>
{(cert) => (
<div class="mt-1.5 space-y-1.5 border-t border-base-200 pt-1.5">
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Certificate</span>
<span
class="font-medium"
classList={{
'text-emerald-600 dark:text-emerald-300': certificateTrust().tone === 'success',
'text-red-600 dark:text-red-300': certificateTrust().tone === 'danger',
'text-base-content': certificateTrust().tone === 'neutral',
}}
title={cert().trustError || undefined}
>
{certificateTrust().label}
</span>
</div>
<InfoCardKeyValueRow
label="Certificate"
value={certificateTrust().label}
valueClass={
certificateTrust().tone === 'success'
? 'text-emerald-600 dark:text-emerald-300'
: certificateTrust().tone === 'danger'
? 'text-red-600 dark:text-red-300'
: ''
}
valueTitle={cert().trustError || undefined}
/>
<Show when={cert().subject}>
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Subject</span>
<span class="ml-2 truncate font-medium text-base-content" title={cert().subject}>
{cert().subject}
</span>
</div>
<InfoCardKeyValueRow
label="Subject"
value={cert().subject}
valueClass="truncate"
valueTitle={cert().subject}
/>
</Show>
<Show when={certificateExpiry()}>
{(expiry) => (
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Expires</span>
<span
class="font-medium text-base-content"
title={`Warning window: ${props.availability.certificateExpiryWarningDays ?? 30} days`}
>
{expiry()}
</span>
</div>
<InfoCardKeyValueRow
label="Expires"
value={expiry()}
valueTitle={`Warning window: ${props.availability.certificateExpiryWarningDays ?? 30} days`}
/>
)}
</Show>
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Hostname</span>
<span
class="font-medium"
classList={{
'text-emerald-600 dark:text-emerald-300': cert().hostnameValid,
'text-red-600 dark:text-red-300': !cert().hostnameValid,
}}
>
{cert().hostnameValid ? 'Matches' : 'Mismatch'}
</span>
</div>
<InfoCardKeyValueRow
label="Hostname"
value={cert().hostnameValid ? 'Matches' : 'Mismatch'}
valueClass={
cert().hostnameValid
? 'text-emerald-600 dark:text-emerald-300'
: 'text-red-600 dark:text-red-300'
}
/>
<Show when={cert().issuer}>
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Issuer</span>
<span class="ml-2 truncate text-base-content" title={cert().issuer}>
{cert().issuer}
</span>
</div>
<InfoCardKeyValueRow
label="Issuer"
value={cert().issuer}
valueClass="truncate font-normal"
valueTitle={cert().issuer}
/>
</Show>
<Show when={cert().fingerprintSha256}>
{(fingerprint) => (
<div class="flex items-center justify-between gap-2">
<span class="text-muted">SHA-256</span>
<span
class="ml-2 truncate font-mono text-[10px] text-base-content"
title={fingerprint()}
>
{fingerprint().slice(0, 16)}
</span>
</div>
<InfoCardKeyValueRow
label="SHA-256"
value={`${fingerprint().slice(0, 16)}`}
valueClass="truncate font-mono text-[10px] font-normal"
valueTitle={fingerprint()}
/>
)}
</Show>
</div>
@@ -18,7 +18,7 @@ import { RaidCard } from '@/components/shared/cards/RaidCard';
import { DiscoveryTab } from '@/components/Discovery/DiscoveryTab';
import { DiscoveryLoadingFallback } from '@/components/shared/DiscoveryLoadingFallback';
import { FormSelect } from '@/components/shared/FormSelect';
import { InfoCardFrame } from '@/components/shared/InfoCardFrame';
import { InfoCardFrame, InfoCardKeyValueRow } from '@/components/shared/InfoCardFrame';
import { WebInterfaceUrlField } from '@/components/shared/WebInterfaceUrlField';
import {
WEB_INTERFACE_LINK_COLOR_CLASS,
@@ -575,31 +575,27 @@ export const ResourceDetailDrawerOverviewTab: Component<ResourceDetailDrawerOver
<span class="text-muted">{sourceTypePresentation.label}</span>
</div>
<div class="mt-1 space-y-1">
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Confidence</span>
<span class="font-medium text-base-content">
{formatConfidenceLabel(change.confidence)}
</span>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Adapter</span>
<span class="font-medium text-base-content">
{sourceAdapterPresentation?.label || '—'}
</span>
</div>
<InfoCardKeyValueRow
label="Confidence"
value={formatConfidenceLabel(change.confidence)}
/>
<InfoCardKeyValueRow
label="Adapter"
value={sourceAdapterPresentation?.label || '—'}
/>
<Show when={change.actor}>
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Actor</span>
<span class="font-medium text-base-content">{change.actor}</span>
</div>
<InfoCardKeyValueRow
label="Actor"
value={change.actor}
valueClass="break-words"
/>
</Show>
<Show when={change.from || change.to}>
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Transition</span>
<span class="font-medium text-base-content">
{change.from || '—'} {change.to || '—'}
</span>
</div>
<InfoCardKeyValueRow
label="Transition"
value={`${change.from || '—'}${change.to || '—'}`}
valueClass="break-words"
/>
</Show>
</div>
<Show when={change.reason}>
@@ -726,27 +722,24 @@ export const ResourceDetailDrawerOverviewTab: Component<ResourceDetailDrawerOver
</div>
<div class="space-y-1.5 text-[11px]">
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Containers</span>
<span class="font-medium text-base-content">
{formatInteger(drawer.dockerContainerCount())}
</span>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Updates</span>
<span
class={`font-medium ${drawer.dockerUpdatesAvailable() > 0 ? 'text-sky-700 dark:text-sky-300' : 'text-base-content'}`}
>
{formatInteger(drawer.dockerUpdatesAvailable())}
</span>
</div>
<InfoCardKeyValueRow
label="Containers"
value={formatInteger(drawer.dockerContainerCount())}
/>
<InfoCardKeyValueRow
label="Updates"
value={formatInteger(drawer.dockerUpdatesAvailable())}
valueClass={
drawer.dockerUpdatesAvailable() > 0
? 'text-sky-700 dark:text-sky-300'
: ''
}
/>
<Show when={drawer.dockerUpdatesCheckedRelative()}>
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Checked</span>
<span class="font-medium text-base-content">
{drawer.dockerUpdatesCheckedRelative()}
</span>
</div>
<InfoCardKeyValueRow
label="Checked"
value={drawer.dockerUpdatesCheckedRelative()}
/>
</Show>
<Show when={drawer.showDockerUpdateControls()}>
@@ -758,24 +751,24 @@ export const ResourceDetailDrawerOverviewTab: Component<ResourceDetailDrawerOver
}
>
<div class="rounded border border-sky-200 bg-surface px-2 py-1.5 text-[10px] dark:border-sky-700">
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Action</span>
<span class="font-medium text-base-content">
{formatIdentifierLabel(drawer.dockerHostCommand()?.type, {
fallback: 'command',
})}
</span>
</div>
<div class="mt-1 flex items-center justify-between gap-2">
<span class="text-muted">State</span>
<span
class={`font-medium ${drawer.dockerHostCommandActive() ? 'text-sky-700 dark:text-sky-300' : 'text-base-content'}`}
>
{formatIdentifierLabel(drawer.dockerHostCommand()?.status, {
fallback: 'unknown',
})}
</span>
</div>
<InfoCardKeyValueRow
label="Action"
value={formatIdentifierLabel(drawer.dockerHostCommand()?.type, {
fallback: 'command',
})}
/>
<InfoCardKeyValueRow
class="mt-1"
label="State"
value={formatIdentifierLabel(drawer.dockerHostCommand()?.status, {
fallback: 'unknown',
})}
valueClass={
drawer.dockerHostCommandActive()
? 'text-sky-700 dark:text-sky-300'
: ''
}
/>
<Show when={drawer.dockerHostCommand()?.message}>
<div
class="mt-1 text-muted truncate"
@@ -885,33 +878,26 @@ export const ResourceDetailDrawerOverviewTab: Component<ResourceDetailDrawerOver
</Show>
</div>
<div class="space-y-1.5 text-[11px]">
<div class="flex items-center justify-between gap-2">
<span class="text-muted">State</span>
<span class={`font-medium ${connection.text}`}>
{connection.label}
</span>
</div>
<InfoCardKeyValueRow
label="State"
value={connection.label}
valueClass={connection.text}
/>
<Show when={pbs().version}>
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Version</span>
<span class="font-medium text-base-content">{pbs().version}</span>
</div>
<InfoCardKeyValueRow label="Version" value={pbs().version} />
</Show>
<Show when={pbs().uptimeSeconds || resource.uptime}>
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Uptime</span>
<span class="font-medium text-base-content">
{formatUptime(pbs().uptimeSeconds ?? resource.uptime ?? 0)}
</span>
</div>
<InfoCardKeyValueRow
label="Uptime"
value={formatUptime(pbs().uptimeSeconds ?? resource.uptime ?? 0)}
/>
</Show>
<Show when={drawer.pbsActiveTaskCount() > 0}>
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Active tasks</span>
<span class="font-medium text-emerald-700 dark:text-emerald-300">
{formatInteger(drawer.pbsActiveTaskCount())}
</span>
</div>
<InfoCardKeyValueRow
label="Active tasks"
value={formatInteger(drawer.pbsActiveTaskCount())}
valueClass="text-emerald-700 dark:text-emerald-300"
/>
</Show>
<Show when={drawer.showPbsJobDetail()}>
<div class="space-y-1.5 border-t border-indigo-200 pt-2 dark:border-indigo-700">
@@ -1141,25 +1127,19 @@ export const ResourceDetailDrawerOverviewTab: Component<ResourceDetailDrawerOver
</Show>
</div>
<div class="space-y-1.5 text-[11px]">
<div class="flex items-center justify-between gap-2">
<span class="text-muted">State</span>
<span class={`font-medium ${connection.text}`}>
{connection.label}
</span>
</div>
<InfoCardKeyValueRow
label="State"
value={connection.label}
valueClass={connection.text}
/>
<Show when={pmg().version}>
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Version</span>
<span class="font-medium text-base-content">{pmg().version}</span>
</div>
<InfoCardKeyValueRow label="Version" value={pmg().version} />
</Show>
<Show when={pmg().uptimeSeconds || resource.uptime}>
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Uptime</span>
<span class="font-medium text-base-content">
{formatUptime(pmg().uptimeSeconds ?? resource.uptime ?? 0)}
</span>
</div>
<InfoCardKeyValueRow
label="Uptime"
value={formatUptime(pmg().uptimeSeconds ?? resource.uptime ?? 0)}
/>
</Show>
<Show when={drawer.showPmgMailFlowDetail()}>
<div class="space-y-1.5 border-t border-rose-200 pt-2 dark:border-rose-700">
@@ -1187,22 +1167,21 @@ export const ResourceDetailDrawerOverviewTab: Component<ResourceDetailDrawerOver
class="space-y-1.5 rounded border border-dashed border-rose-200 bg-surface px-2 py-1.5 text-[10px] dark:border-rose-700"
>
<Show when={pmg().nodeCount}>
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Nodes</span>
<span class="font-medium text-base-content">
{formatInteger(pmg().nodeCount)}
</span>
</div>
<InfoCardKeyValueRow
label="Nodes"
value={formatInteger(pmg().nodeCount)}
/>
</Show>
<Show when={drawer.pmgUpdatedRelative()}>
<div
class={`flex items-center justify-between gap-2 ${pmg().nodeCount ? 'border-t border-rose-200 pt-1.5 dark:border-rose-700' : ''}`}
>
<span class="text-muted">Updated</span>
<span class="font-medium text-base-content">
{drawer.pmgUpdatedRelative()}
</span>
</div>
<InfoCardKeyValueRow
class={
pmg().nodeCount
? 'border-t border-rose-200 pt-1.5 dark:border-rose-700'
: ''
}
label="Updated"
value={drawer.pmgUpdatedRelative()}
/>
</Show>
</div>
</Show>
@@ -1213,14 +1192,13 @@ export const ResourceDetailDrawerOverviewTab: Component<ResourceDetailDrawerOver
<div class="mt-2 space-y-1.5 border-t border-rose-200 pt-2 text-[10px] dark:border-rose-700">
<For each={drawer.pmgVisibleQueueBreakdown()}>
{(entry) => (
<div class="flex items-center justify-between gap-2 text-muted">
<span>{entry.label}</span>
<span
class={`font-medium ${entry.warn ? 'text-amber-600 dark:text-amber-400' : 'text-base-content'}`}
>
{formatInteger(entry.value)}
</span>
</div>
<InfoCardKeyValueRow
label={entry.label}
value={formatInteger(entry.value)}
valueClass={
entry.warn ? 'text-amber-600 dark:text-amber-400' : ''
}
/>
)}
</For>
</div>
@@ -1232,12 +1210,10 @@ export const ResourceDetailDrawerOverviewTab: Component<ResourceDetailDrawerOver
<div class="mt-2 space-y-1.5 border-t border-rose-200 pt-2 text-[10px] dark:border-rose-700">
<For each={drawer.pmgVisibleMailBreakdown()}>
{(entry) => (
<div class="flex items-center justify-between gap-2 text-muted">
<span>{entry.label}</span>
<span class="font-medium text-base-content">
{formatInteger(entry.value)}
</span>
</div>
<InfoCardKeyValueRow
label={entry.label}
value={formatInteger(entry.value)}
/>
)}
</For>
</div>
@@ -802,6 +802,12 @@ describe('ResourceDetailDrawer change history section', () => {
expect(panel.getByText('Routine restart requested')).toBeInTheDocument();
expect(panel.getByText('Confidence')).toBeInTheDocument();
expect(panel.getByText('Adapter')).toBeInTheDocument();
expect(panel.getByText('Confidence').parentElement).toHaveClass(
'justify-between',
'lg:grid',
'lg:grid-cols-[7rem_minmax(0,1fr)]',
);
expect(panel.getByText('High')).toHaveClass('text-right', 'lg:text-left');
expect(panel.getByText('Metadata')).toBeInTheDocument();
expect(panel.getByText(/"ticket": "INC-1234"/)).toBeInTheDocument();
expect(panel.queryByText('Capabilities')).toBeNull();
@@ -113,6 +113,12 @@ describe('ResourceDetailDrawer service cards', () => {
expect(serviceDetails.queryByText('Connection')).toBeNull();
expect(serviceDetails.getAllByText('State').length).toBeGreaterThan(0);
expect(serviceDetails.getAllByText('pbs-main.local').length).toBeGreaterThan(0);
expect(serviceDetails.getByText('State').parentElement).toHaveClass(
'justify-between',
'lg:grid',
'lg:grid-cols-[7rem_minmax(0,1fr)]',
);
expect(serviceDetails.getByText('Healthy')).toHaveClass('text-right', 'lg:text-left');
expect(queryByText('Backup summary')).toBeNull();
expect(queryByText('Job breakdown')).toBeNull();
expect(queryByText('Types')).toBeNull();
@@ -307,6 +313,12 @@ describe('ResourceDetailDrawer service cards', () => {
expect(serviceDetails.queryByText('Connection')).toBeNull();
expect(serviceDetails.getAllByText('State').length).toBeGreaterThan(0);
expect(serviceDetails.getAllByText('pmg-main.local').length).toBeGreaterThan(0);
expect(serviceDetails.getByText('State').parentElement).toHaveClass(
'justify-between',
'lg:grid',
'lg:grid-cols-[7rem_minmax(0,1fr)]',
);
expect(serviceDetails.getByText('Healthy')).toHaveClass('text-right', 'lg:text-left');
expect(queryByText('Mail flow summary')).toBeNull();
expect(queryByText('Queue breakdown')).toBeNull();
expect(queryByText('Mail processing')).toBeNull();
@@ -384,6 +396,11 @@ describe('ResourceDetailDrawer service cards', () => {
expect(getByText('18 containers · 4 updates')).toBeInTheDocument();
fireEvent.click(getByRole('button', { name: 'Show service' }));
expect(getByText('Docker runtime')).toBeInTheDocument();
expect(getByText('Containers').parentElement).toHaveClass(
'justify-between',
'lg:grid',
'lg:grid-cols-[7rem_minmax(0,1fr)]',
);
expect(queryByText('Container Updates')).toBeNull();
expect(queryByText('Check now')).toBeNull();
expect(queryByText('Show update controls')).toBeNull();
@@ -7,7 +7,7 @@ import {
type AvailabilityTarget,
} from '@/api/availabilityTargets';
import type { AvailabilityProbeSuggestion } from '@/types/discovery';
import { InfoCardFrame } from '@/components/shared/InfoCardFrame';
import { InfoCardFrame, InfoCardKeyValueRow } from '@/components/shared/InfoCardFrame';
interface AvailabilityProbeSuggestionCardProps {
suggestion: AvailabilityProbeSuggestion;
@@ -64,28 +64,19 @@ export function AvailabilityProbeSuggestionCard(props: AvailabilityProbeSuggesti
</div>
<div class="space-y-1.5 text-[11px]">
<Show when={state() !== 'created'}>
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Service</span>
<span
class="font-medium text-base-content truncate ml-2"
title={props.suggestion.service_name}
>
{props.suggestion.service_name}
</span>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Probe</span>
<span class="font-medium text-base-content">{protocolLabel()}</span>
</div>
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Target</span>
<span
class="font-medium text-base-content truncate ml-2"
title={props.suggestion.address}
>
{props.suggestion.address}
</span>
</div>
<InfoCardKeyValueRow
label="Service"
value={props.suggestion.service_name}
valueClass="truncate"
valueTitle={props.suggestion.service_name}
/>
<InfoCardKeyValueRow label="Probe" value={protocolLabel()} />
<InfoCardKeyValueRow
label="Target"
value={props.suggestion.address}
valueClass="truncate"
valueTitle={props.suggestion.address}
/>
<Show when={state() === 'error'}>
<div class="text-[10px] text-red-600 dark:text-red-400 mt-1">{error()}</div>
</Show>
@@ -21,6 +21,12 @@ describe('AvailabilityProbeStatusCard', () => {
expect(screen.getByText('Not checked')).toBeInTheDocument();
expect(screen.queryByText('Down')).not.toBeInTheDocument();
expect(screen.getByText('freshness unknown')).toBeInTheDocument();
expect(screen.getByText('Latency').parentElement).toHaveClass(
'justify-between',
'lg:grid',
'lg:grid-cols-[7rem_minmax(0,1fr)]',
);
expect(screen.getByText('freshness unknown')).toHaveClass('text-right', 'lg:text-left');
});
it('shows stale evidence and an unresolved canonical resource link', () => {
@@ -1171,6 +1171,7 @@ describe('shared primitive guardrails', () => {
'src/components/shared/cards/RootDiskCard.tsx',
'src/components/shared/cards/SystemInfoCard.tsx',
'src/components/shared/cards/TemperaturesCard.tsx',
'src/components/Workloads/AvailabilityProbeSuggestionCard.tsx',
'src/components/Workloads/DrawerDiskListCard.tsx',
'src/features/docker/DockerHostDrawerOverview.tsx',
'src/features/storageBackups/detailPresentation.ts',
@@ -1233,6 +1234,19 @@ describe('shared primitive guardrails', () => {
expect(source).toContain('InfoCardKeyValueRow');
expect(source).not.toContain('flex items-center justify-between');
}
for (const consumerPath of [
'src/components/Infrastructure/AvailabilityProbeStatusCard.tsx',
'src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx',
'src/components/Workloads/AvailabilityProbeSuggestionCard.tsx',
'src/features/docker/DockerHostDrawerOverview.tsx',
]) {
const source = readFrontendSource(consumerPath);
expect(source).toContain('InfoCardKeyValueRow');
expect(source).not.toMatch(
/class="flex items-(?:center|start) justify-between gap-2">\s*<span class="text-muted">/,
);
}
});
it('keeps shared subtabs as one primitive and leaves shell styling to owning surfaces', () => {
@@ -5,7 +5,7 @@ import {
type DrawerDiskListItem,
} from '@/components/Workloads/DrawerDiskListCard';
import { AvailabilityProbeStatusCards } from '@/components/Infrastructure/AvailabilityProbeStatusCard';
import { InfoCardFrame } from '@/components/shared/InfoCardFrame';
import { InfoCardFrame, InfoCardKeyValueRow } from '@/components/shared/InfoCardFrame';
import { TechnicalDetailsSection } from '@/components/shared/TechnicalDetailsDisclosure';
import { DrawerAttentionSection } from '@/components/shared/DrawerAttentionSection';
import {
@@ -99,32 +99,19 @@ export function DockerHostDrawerManagement(props: DockerHostDrawerOverviewProps)
Container updates
</h3>
<div class="space-y-1.5 text-[11px]">
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Available</span>
<span class="font-medium text-base-content">{updatesAvailable()}</span>
</div>
<InfoCardKeyValueRow label="Available" value={updatesAvailable()} />
<Show when={checkedAtMillis()} keyed>
{(timestamp) => (
<div class="flex items-center justify-between gap-2">
<span class="text-muted">Last checked</span>
<span class="font-medium text-base-content">{formatRelativeTime(timestamp)}</span>
</div>
<InfoCardKeyValueRow label="Last checked" value={formatRelativeTime(timestamp)} />
)}
</Show>
<Show when={hostCommand()?.type || hostCommand()?.status}>
<div class="flex items-center justify-between gap-2">
<span class="text-muted">
{titleCase(cleanText(hostCommand()?.type).replace(/_/g, ' ') || 'Command')}
</span>
<span
class={`truncate text-right font-medium ${
hostCommandActive() ? 'text-sky-700 dark:text-sky-300' : 'text-base-content'
}`}
title={hostCommand()?.failureReason || hostCommand()?.message || undefined}
>
{titleCase(cleanText(hostCommand()?.status).replace(/_/g, ' ') || 'unknown')}
</span>
</div>
<InfoCardKeyValueRow
label={titleCase(cleanText(hostCommand()?.type).replace(/_/g, ' ') || 'Command')}
value={titleCase(cleanText(hostCommand()?.status).replace(/_/g, ' ') || 'unknown')}
valueClass={`truncate ${hostCommandActive() ? 'text-sky-700 dark:text-sky-300' : ''}`}
valueTitle={hostCommand()?.failureReason || hostCommand()?.message || undefined}
/>
</Show>
<Show when={updateActions.dockerActionError()}>
<div class="rounded border border-red-200 bg-red-50 px-2 py-1.5 text-[10px] text-red-700 dark:border-red-700 dark:bg-red-900 dark:text-red-200">