Flatten connected-systems settings table to single-line rows
Some checks are pending
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Frontend & Backend (push) Waiting to run

The connected-systems table on /settings/infrastructure stacked three
lines into the System cell (name, OS/identity subtitle, source badge +
host) and let Coverage and the status problem line wrap, so every row
ran 57-75px and was hard to scan. Every other top-level Pulse table
(Proxmox overview, backups) is single-line, one datum per column.

Give each datum its own column: System | Method | Host | Coverage |
Status | Actions. The OS/version (or cluster identity) subtitle folds
into the name's hover title, matching how the discovery rows already
fold version in. The operational problem line and last-error box stay
as a subtle full-width sub-row, so only rows with an actual issue get a
second line. The row-level install-agent shortcut becomes an icon
button so the Actions cell stays single-line.

This also aligns the configured and cluster-member rows with the
6-column grid the discovery rows already assumed, removing a latent
column-count mismatch. Rows now render at ~29-39px.
This commit is contained in:
rcourtman 2026-05-29 23:17:19 +01:00
parent 1e1aebd174
commit dbf7572f67

View file

@ -26,6 +26,7 @@ import {
connectionAgentVersionPresentation,
infrastructureSourcePresentation,
surfaceLabel,
type InfrastructureSystemMemberRow,
type InfrastructureSystemRow,
} from './connectionsTableModel';
import type { DiscoveredServer, DiscoveryScanStatus } from './infrastructureSettingsModel';
@ -72,7 +73,18 @@ const workspaceSecondaryButtonClass =
'inline-flex min-h-9 items-center justify-center gap-2 rounded-md border border-border bg-surface px-3 py-2 text-sm font-medium text-base-content transition-colors hover:bg-surface-hover disabled:cursor-not-allowed disabled:opacity-60';
const discoveryRowClass =
'border-b border-border-subtle bg-blue-50/30 hover:bg-blue-50/40 dark:bg-blue-950/10 dark:hover:bg-blue-950/20';
const wrappedFieldClass = 'whitespace-normal break-words leading-4';
// The system column shows just the name on a single line. The OS / version
// (standalone) or cluster identity (cluster) descriptor that used to sit on a
// second line moves into the name's hover title, so every row stays one line
// like the platform overview tables. Discovery rows already fold version into
// the name title the same way.
const rowSystemTitle = (row: InfrastructureSystemRow): string => {
const detail = row.isCluster ? row.subtitle : row.identitySubtitle;
return detail ? `${row.name} · ${detail}` : row.name;
};
const memberSystemTitle = (member: InfrastructureSystemMemberRow): string =>
member.subtitle ? `${member.name} · ${member.subtitle}` : member.name;
// The connected-systems table sets `min-w-[820px]`; switch to the card
// layout whenever the measured container can't fit that, otherwise the
// table renders but overflows horizontally inside the settings panel.
@ -660,13 +672,19 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
<Table class="w-full min-w-[820px] table-fixed text-sm">
<TableHeader class="bg-surface-alt/60">
<TableRow>
<TableHead class="w-[36%] py-1.5 pl-3 pr-3 text-left text-[11px] font-medium text-muted whitespace-nowrap xl:w-[34%]">
<TableHead class="w-[20%] py-1.5 pl-3 pr-3 text-left text-[11px] font-medium text-muted whitespace-nowrap">
System
</TableHead>
<TableHead class="w-[32%] px-3 py-1.5 text-left text-[11px] font-medium text-muted whitespace-nowrap xl:w-[34%]">
<TableHead class="w-[10%] px-3 py-1.5 text-left text-[11px] font-medium text-muted whitespace-nowrap">
Method
</TableHead>
<TableHead class="w-[16%] px-3 py-1.5 text-left text-[11px] font-medium text-muted whitespace-nowrap">
Host
</TableHead>
<TableHead class="w-[16%] px-3 py-1.5 text-left text-[11px] font-medium text-muted whitespace-nowrap">
Coverage
</TableHead>
<TableHead class="w-[24%] px-3 py-1.5 text-left text-[11px] font-medium text-muted whitespace-nowrap xl:w-[22%]">
<TableHead class="w-[22%] px-3 py-1.5 text-left text-[11px] font-medium text-muted whitespace-nowrap">
Status
</TableHead>
<Show when={actionColumnVisible()}>
@ -702,7 +720,7 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
when={actionColumnVisible()}
fallback={
<TableRow class={groupRowClass()}>
<TableCell colspan={3} class={getGroupedTableRowCellClass()}>
<TableCell colspan={5} class={getGroupedTableRowCellClass()}>
<div class="flex min-w-0 items-center gap-2">
<span>{group.label}</span>
</div>
@ -711,7 +729,7 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
}
>
<TableRow class={groupRowClass()}>
<TableCell colspan={4} class={getGroupedTableRowCellClass()}>
<TableCell colspan={6} class={getGroupedTableRowCellClass()}>
<div class="flex items-center justify-between gap-2 whitespace-nowrap">
<span>{group.label}</span>
<Show
@ -745,27 +763,20 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
row.isCluster ? 'bg-surface-alt/40' : ''
}`}
>
<TableCell class="py-1 pl-3 pr-3 align-top">
<TableCell class="py-1.5 pl-3 pr-3 align-middle">
<div
class={`text-[13px] ${
class={`truncate text-[13px] ${
row.isCluster
? 'font-medium text-base-content'
: 'text-base-content/80'
} ${wrappedFieldClass}`}
title={row.name}
}`}
title={rowSystemTitle(row)}
>
{row.name}
</div>
<Show when={row.isCluster && row.subtitle}>
<div class="mt-0.5 text-[11px] text-muted">
{row.subtitle}
</div>
</Show>
<Show when={!row.isCluster && row.identitySubtitle}>
<div class="mt-0.5 text-[11px] text-muted">
{row.identitySubtitle}
</div>
</Show>
</TableCell>
<TableCell class="px-3 py-1.5 align-middle">
{(() => {
const presentation = infrastructureSourcePresentation(
row.source,
@ -773,27 +784,35 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
const title =
agentMethodTitleFor(row) ?? presentation.title;
return (
<div class="mt-1 flex min-w-0 items-center gap-1.5">
<span
class={`${presentation.badgeClassName} flex-shrink-0 whitespace-nowrap`}
title={title}
>
{presentation.label}
</span>
<Show when={row.host}>
<span
class="min-w-0 truncate text-[11px] text-muted"
title={row.host}
>
{row.host}
</span>
</Show>
</div>
<span
class={`${presentation.badgeClassName} whitespace-nowrap`}
title={title}
>
{presentation.label}
</span>
);
})()}
</TableCell>
<TableCell class="px-3 py-1 align-top">
<TableCell class="px-3 py-1.5 align-middle">
<Show
when={row.host}
fallback={
<span class="text-xs text-muted" aria-hidden="true">
</span>
}
>
<div
class="truncate text-[12px] text-muted"
title={row.host}
>
{row.host}
</div>
</Show>
</TableCell>
<TableCell class="px-3 py-1.5 align-middle">
<Show
when={row.coverageLabels.length > 0}
fallback={
@ -803,7 +822,7 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
}
>
<div
class="whitespace-normal break-words text-[12px] leading-4 text-muted"
class="truncate text-[12px] text-muted"
title={row.coverageLabels.join(', ')}
>
{row.coverageLabels.join(', ')}
@ -811,8 +830,8 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
</Show>
</TableCell>
<TableCell class="px-3 py-1 align-top">
<div class="flex flex-wrap items-center gap-x-1.5 gap-y-1">
<TableCell class="px-3 py-1.5 align-middle">
<div class="flex items-center gap-1.5 overflow-hidden whitespace-nowrap">
<span
class={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium whitespace-nowrap ${row.statusClassName}`}
>
@ -822,11 +841,11 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
<span class="inline-flex items-center rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-medium whitespace-nowrap text-amber-800 dark:bg-amber-900 dark:text-amber-200">
{row.agentUpdateCount === 1
? 'Agent update'
: `${row.agentUpdateCount} agent updates`}
: `${row.agentUpdateCount} updates`}
</span>
</Show>
<span
class="whitespace-nowrap text-[12px] text-muted/90"
class="truncate text-[12px] text-muted/90"
title={
row.isCluster
? 'Oldest activity across cluster API and member agents'
@ -836,38 +855,26 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
{row.lastActivityText}
</span>
</div>
<Show when={row.problem}>
{(problem) => (
<div
class={`mt-1 text-[11px] italic ${
problem().tone === 'critical'
? 'text-rose-700 dark:text-rose-300'
: 'text-amber-700 dark:text-amber-300'
}`}
title={problem().detail}
>
{problem().label}
</div>
)}
</Show>
</TableCell>
<Show when={actionColumnVisible()}>
<TableCell class="px-3 py-1 align-top text-right">
<TableCell class="px-3 py-1.5 align-middle text-right">
<Show
when={rowInteractive(row)}
fallback={
<span class="text-xs text-muted">Read only</span>
}
>
<div class="flex flex-col items-end gap-1">
<div class="flex items-center justify-end gap-1">
{/* Row-level install-agent shortcut, scoped to
Proxmox VE: the agent adds node-local
telemetry (temps, SMART, host identity)
that the PVE API doesn't expose. PBS and
other API-only sources are fully covered
without an agent, so we don't nudge for
one there. */}
one there. Rendered icon-only so the row
stays a single line; the full guided flow
lives behind Manage. */}
<Show
when={
row.ownerType === 'pve' &&
@ -881,11 +888,11 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
<button
type="button"
onClick={handleInstallAgentShortcut}
class="inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs font-medium text-blue-700 transition-colors hover:bg-blue-50 dark:text-blue-300 dark:hover:bg-blue-950/30"
class="inline-flex items-center justify-center rounded p-1 text-blue-700 transition-colors hover:bg-blue-50 dark:text-blue-300 dark:hover:bg-blue-950/30"
aria-label="Install Pulse Agent on this system"
title="Install Pulse Agent on this system to add node-local telemetry (temperatures, SMART, host identity)."
>
<Plus class="h-3 w-3" />
Install agent
<Plus class="h-3.5 w-3.5" />
</button>
</Show>
<button
@ -901,18 +908,34 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
</Show>
</TableRow>
<Show when={row.lastErrorMessage}>
<Show when={row.problem || row.lastErrorMessage}>
<TableRow class="border-b border-border-subtle">
<TableCell
colspan={actionColumnVisible() ? 4 : 3}
colspan={actionColumnVisible() ? 6 : 5}
class="bg-surface px-3 pb-1.5 pt-0"
>
<div
role="alert"
class="rounded-md border border-rose-300 bg-rose-50 px-3 py-2 text-xs text-rose-800 dark:border-rose-900 dark:bg-rose-950 dark:text-rose-200"
>
{row.lastErrorMessage}
</div>
<Show when={row.problem}>
{(problem) => (
<div
class={`text-[11px] italic ${
problem().tone === 'critical'
? 'text-rose-700 dark:text-rose-300'
: 'text-amber-700 dark:text-amber-300'
}`}
title={problem().detail}
>
{problem().label}
</div>
)}
</Show>
<Show when={row.lastErrorMessage}>
<div
role="alert"
class="mt-1 rounded-md border border-rose-300 bg-rose-50 px-3 py-2 text-xs text-rose-800 dark:border-rose-900 dark:bg-rose-950 dark:text-rose-200"
>
{row.lastErrorMessage}
</div>
</Show>
</TableCell>
</TableRow>
</Show>
@ -926,99 +949,118 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
memberMethodTitleFor(row, memberIndex()) ??
memberPresentation.title;
return (
<TableRow class="border-b border-border-subtle bg-surface-alt/30">
<TableCell class="py-1 pl-3 pr-3 align-top">
<div class="flex min-w-0 items-start gap-2 pl-4">
<span class="mt-1.5 h-1.5 w-1.5 flex-shrink-0 rounded-full bg-border" />
<div class="min-w-0">
<>
<TableRow class="border-b border-border-subtle bg-surface-alt/30">
<TableCell class="py-1.5 pl-3 pr-3 align-middle">
<div class="flex min-w-0 items-center gap-2 pl-4">
<span class="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-border" />
<div
class={`text-[13px] text-base-content/85 ${wrappedFieldClass}`}
title={member.name}
class="truncate text-[13px] text-base-content/85"
title={memberSystemTitle(member)}
>
{member.name}
</div>
<div class="mt-0.5 text-[11px] text-muted">
{member.subtitle}
</div>
<div class="mt-1 flex min-w-0 items-center gap-1.5">
<span
class={`${memberPresentation.badgeClassName} flex-shrink-0 whitespace-nowrap`}
title={memberSourceTitle}
>
{memberPresentation.label}
</span>
<Show when={member.host}>
<span
class="min-w-0 truncate text-[11px] text-muted"
title={member.host}
>
{member.host}
</span>
</Show>
</div>
</div>
</div>
</TableCell>
</TableCell>
<TableCell class="px-3 py-1 align-top">
<Show
when={member.coverageLabels.length > 0}
fallback={
<TableCell class="px-3 py-1.5 align-middle">
<span
class={`${memberPresentation.badgeClassName} whitespace-nowrap`}
title={memberSourceTitle}
>
{memberPresentation.label}
</span>
</TableCell>
<TableCell class="px-3 py-1.5 align-middle">
<Show
when={member.host}
fallback={
<span
class="text-xs text-muted"
aria-hidden="true"
>
</span>
}
>
<div
class="truncate text-[12px] text-muted"
title={member.host}
>
{member.host}
</div>
</Show>
</TableCell>
<TableCell class="px-3 py-1.5 align-middle">
<Show
when={member.coverageLabels.length > 0}
fallback={
<span
class="text-xs text-muted"
aria-hidden="true"
>
</span>
}
>
<div
class="truncate text-[12px] text-muted"
title={member.coverageLabels.join(', ')}
>
{member.coverageLabels.join(', ')}
</div>
</Show>
</TableCell>
<TableCell class="px-3 py-1.5 align-middle">
<div class="flex items-center gap-1.5 overflow-hidden whitespace-nowrap">
<span
class={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium whitespace-nowrap ${member.statusClassName}`}
>
{member.statusLabel}
</span>
<span class="truncate text-[12px] text-muted/90">
{member.lastActivityText}
</span>
</div>
</TableCell>
<Show when={actionColumnVisible()}>
<TableCell class="px-3 py-1.5 align-middle text-right">
<span
class="text-xs text-muted"
aria-hidden="true"
>
</span>
}
>
<div
class="whitespace-normal break-words text-[12px] leading-4 text-muted"
title={member.coverageLabels.join(', ')}
>
{member.coverageLabels.join(', ')}
</div>
</TableCell>
</Show>
</TableCell>
</TableRow>
<TableCell class="px-3 py-1 align-top">
<div class="flex flex-wrap items-center gap-x-1.5 gap-y-1">
<span
class={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium whitespace-nowrap ${member.statusClassName}`}
>
{member.statusLabel}
</span>
<span class="whitespace-nowrap text-[12px] text-muted/90">
{member.lastActivityText}
</span>
</div>
<Show when={member.problem}>
{(problem) => (
<div
class={`mt-1 text-[11px] italic ${
problem().tone === 'critical'
? 'text-rose-700 dark:text-rose-300'
: 'text-amber-700 dark:text-amber-300'
}`}
title={problem().detail}
<Show when={member.problem}>
{(problem) => (
<TableRow class="border-b border-border-subtle bg-surface-alt/30">
<TableCell
colspan={actionColumnVisible() ? 6 : 5}
class="pb-1.5 pl-9 pr-3 pt-0"
>
{problem().label}
</div>
)}
</Show>
</TableCell>
<Show when={actionColumnVisible()}>
<TableCell class="px-3 py-1 align-top text-right">
<span
class="text-xs text-muted"
aria-hidden="true"
>
</span>
</TableCell>
<div
class={`text-[11px] italic ${
problem().tone === 'critical'
? 'text-rose-700 dark:text-rose-300'
: 'text-amber-700 dark:text-amber-300'
}`}
title={problem().detail}
>
{problem().label}
</div>
</TableCell>
</TableRow>
)}
</Show>
</TableRow>
</>
);
}}
</For>
@ -1033,16 +1075,16 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
{(server) => {
return (
<TableRow class={discoveryRowClass}>
<TableCell class="py-1 pl-3 pr-3 align-top">
<TableCell class="py-1.5 pl-3 pr-3 align-middle">
<div
class={`text-[13px] text-base-content/85 ${wrappedFieldClass}`}
class="truncate text-[13px] text-base-content/85"
title={`${discoveredServerName(server)}${server.version ? ` · ${server.version}` : ''}`}
>
{discoveredServerName(server)}
</div>
</TableCell>
<TableCell class="px-3 py-1 align-top">
<TableCell class="px-3 py-1.5 align-middle">
<span
class="inline-flex items-center rounded-full border border-dashed border-border bg-surface-alt px-2 py-0.5 text-[11px] font-medium text-muted whitespace-nowrap"
title="Discovery candidate — review to attach a source"
@ -1051,7 +1093,7 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
</span>
</TableCell>
<TableCell class="px-3 py-1 align-top">
<TableCell class="px-3 py-1.5 align-middle">
<div
class="truncate whitespace-nowrap text-[12px] text-muted"
title={discoveredServerEndpoint(server)}
@ -1060,28 +1102,28 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
</div>
</TableCell>
<TableCell class="px-3 py-1 align-top">
<TableCell class="px-3 py-1.5 align-middle">
<div
class="whitespace-normal break-words text-[12px] leading-4 text-muted"
class="truncate whitespace-nowrap text-[12px] text-muted"
title={discoveredCoverageText(server)}
>
{discoveredCoverageText(server)}
</div>
</TableCell>
<TableCell class="px-3 py-1 align-top">
<div class="flex flex-wrap items-center gap-x-1.5 gap-y-1">
<TableCell class="px-3 py-1.5 align-middle">
<div class="flex items-center gap-1.5 overflow-hidden whitespace-nowrap">
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-[11px] font-medium whitespace-nowrap text-blue-800 dark:bg-blue-950/40 dark:text-blue-200">
Discovered
</span>
<span class="whitespace-nowrap text-[12px] text-muted/90">
<span class="truncate text-[12px] text-muted/90">
{lastDiscoveryResultText() ?? 'Waiting for scan'}
</span>
</div>
</TableCell>
<Show when={actionColumnVisible()}>
<TableCell class="px-3 py-1 align-top text-right">
<TableCell class="px-3 py-1.5 align-middle text-right">
<Show
when={props.onReviewDiscoveredSource}
fallback={
@ -1116,7 +1158,7 @@ export const InfrastructureSourceManager: Component<InfrastructureSourceManagerP
<Show when={!hasAnyConfigured() && !hasAnyDiscovered()}>
<TableRow>
<TableCell
colspan={actionColumnVisible() ? 4 : 3}
colspan={actionColumnVisible() ? 6 : 5}
class="px-4 py-8 text-center text-sm text-muted"
>
{emptyStateContent()}