mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
Show a silent agent's retained disk usage as last known
Since #2634 a host agent past its reporting lease reads offline, and the machine drawer marks its Thermals rows "(last known)". The Disks card in the same Machine details kept presenting the agent's retained filesystem usage as current: on a mock stack the silent host's root mount read 93% in red with a red aggregate bar, beside Thermals reading "45°C (last known)". DisksCard now takes a lastKnownReason. With one, the total and each mount keep their figures, because a disk that filled before the machine went quiet is evidence, but read "Last known N%" in muted text titled with the reason, without threshold colour or usage bars, as the guest drawer's retained filesystem rows already do. The drawer derives one agentReadingsLastKnownReason from agent.stale for Thermals and Disks alike, so a Proxmox node that stays online with a silent linked agent gets the same treatment. Mount rows may now wrap. On a phone the row's label keeps its full width, so a long mountpoint squeezed the value into a sliver and clipped its byte figures; the label now takes its own line with the value below.
This commit is contained in:
@@ -526,6 +526,22 @@ SharedPrimitives.guardrails.test.ts protects this composition; component tests
|
||||
preserve mount counts and totals. The disk-mounts qualification fixture checks
|
||||
short/long lists, themes and keyboard reachability with production CSS.
|
||||
|
||||
### Retained disk usage
|
||||
|
||||
DisksCard takes an optional `lastKnownReason` for usage figures that are
|
||||
retained rather than current. The machine drawer passes the host-agent
|
||||
stopped-reporting reason when `agent.stale` is set, the same signal that marks
|
||||
its Thermals rows "(last known)". The card keeps every figure, because a disk
|
||||
that filled before the machine went quiet is evidence. The total and each mount
|
||||
read "Last known N%" with used and total bytes, in muted text titled "Last known
|
||||
reading, not current: <reason>". Threshold text colour, the aggregate
|
||||
`StackedDiskBar` and the per-mount bars are dropped, as the guest drawer does
|
||||
for retained filesystem rows. Mount rows may wrap, with the label capped at the
|
||||
row width, so on a phone a long mountpoint takes its own line instead of
|
||||
squeezing the figures out of the card. `DisksCard.test.tsx` covers both
|
||||
readings and SharedPrimitives.guardrails.test.ts pins the branch and the drawer
|
||||
wiring.
|
||||
|
||||
### Ollama credential editing
|
||||
The provider panel exposes the existing Basic Auth configuration. Saved passwords
|
||||
are represented by presence text, never a placeholder secret or input value.
|
||||
|
||||
@@ -2288,7 +2288,11 @@ A standalone host agent past its reporting lease reaches the Machines table as
|
||||
offline metric fallback blanks its CPU, memory, disk, network, disk I/O, uptime
|
||||
and temperature cells. The machine drawer drops a silent agent's uptime and
|
||||
marks its non-disk Thermals rows "(last known)" with the reason as title,
|
||||
keyed on `agent.stale` because those rows are that agent's own sensors. For SMART disk
|
||||
keyed on `agent.stale` because those rows are that agent's own sensors.
|
||||
Its Disks card reads the agent's retained filesystem usage as last known on the
|
||||
same signal (`agentReadingsLastKnownReason`): each figure stays, muted and
|
||||
titled with the reason, without threshold colour or usage bars.
|
||||
For SMART disk
|
||||
temperatures on rows that still render, the provenance travels on
|
||||
`agent.sensors.smart[].collection`: the Machines temperature cell
|
||||
(`AgentsMachinesTable.tsx`, `agentMachineTableModel.ts`), its tooltip and the
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "a21e0ef8f819c404338661f76b82b5bea09aedfd",
|
||||
"verified_at": "2026-10-07T09:20:59Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx",
|
||||
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts",
|
||||
"frontend-modern/src/components/shared/cards/DisksCard.tsx"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx": "d1c98c016ddceb095a429680939398c15dfaf1e0c9521fd6984f433f76f04cf6",
|
||||
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts": "6ff3a895c4ba3c0c758bdb7e5c4f1b512f3ae3e2de1b8d03b7f03d4a7f249d51",
|
||||
"frontend-modern/src/components/shared/cards/DisksCard.tsx": "2b80761565c92409919fe18f0fdf4229abbb397114deca69ec7cfcdb1dec88f1"
|
||||
},
|
||||
"routes": [
|
||||
"/standalone/machines"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Private mock stack built from a21e0ef8f8 with a local-only go build -overlay of internal/mock/generator.go that holds host-linux-1 (Apollo-114) offline past its lease: LastSeen frozen 14 minutes back, readings retained, root mount filled to 93% and a long /var/lib/docker/volumes/postgres-primary-data mount at 88%. /api/resources reports status offline with agent.stale true",
|
||||
"Baseline on main (bea0cec8b9, before the rebase): the Machines row blanks every reading and Thermals reads 45°C (last known), but the Disks card shows / at 93% in red with a red aggregate StackedDiskBar and green/red per-mount bars, as if current",
|
||||
"Patched, silent host: Total Usage reads Last known 75% · 2.29 TB / 3.07 TB, each mount reads Last known N% · used / total, all in text-muted (rgb 71,85,105 light, 148,163,184 dark) with title Last known reading, not current: host agent stopped reporting; no StackedDiskBar and no per-mount bars render",
|
||||
"Patched, live control host Spectrum-709: unchanged, aggregate bar amber, /backup 80% amber text and bar, other mounts green bars, no Last known text",
|
||||
"390 wide: before the wrap fix the long mountpoint label kept its full width and squeezed the value into a sliver (Last known / 88% over three lines, byte figures clipped by the card); with the mount row wrapping, the long label takes its own line and the full value sits below it, short mounts stay on one line, no horizontal page scroll at either width",
|
||||
"Dark scheme at both widths for the silent host: muted values readable, no threshold colour",
|
||||
"Re-run on the rebased tree after #2607 and #2637 landed; same readings at both widths"
|
||||
],
|
||||
"interactions": [
|
||||
"Open /standalone/machines, click the Apollo-114 row (phone: tap its second visible cell) to open the drawer",
|
||||
"Expand Platform details, then Machine Show details, scroll to the Disks card and read text, computed colours, titles and bar fills",
|
||||
"Repeat for the live Spectrum-709 row as a control"
|
||||
]
|
||||
}
|
||||
@@ -179,7 +179,10 @@ const HostDetailsDisclosure: Component<{
|
||||
<SystemInfoCard variant="agent" agent={agent()} />
|
||||
<HardwareCard variant="agent" agent={agent()} />
|
||||
<NetworkInterfacesCard interfaces={agent().networkInterfaces} />
|
||||
<DisksCard disks={agent().disks} />
|
||||
<DisksCard
|
||||
disks={agent().disks}
|
||||
lastKnownReason={props.drawer.agentReadingsLastKnownReason()}
|
||||
/>
|
||||
<RaidCard arrays={props.drawer.agentMeta()?.raid} />
|
||||
<TemperaturesCard rows={props.drawer.temperatureRows()} title="Thermals" />
|
||||
<TemperaturesCard rows={props.drawer.customSensorRows()} title="Custom Metrics" />
|
||||
|
||||
+43
@@ -354,8 +354,51 @@ describe('ResourceDetailDrawer runtime and identity cards', () => {
|
||||
expect(getByText('Network')).toBeInTheDocument();
|
||||
expect(getByText('Disks')).toBeInTheDocument();
|
||||
expect(getByText('eth0')).toBeInTheDocument();
|
||||
expect(getByTestId('disks-card-total')).not.toHaveTextContent('Last known');
|
||||
});
|
||||
|
||||
// A Proxmox node keeps its row online while its linked agent is silent.
|
||||
it.each(['offline', 'online'] as const)(
|
||||
"shows a silent agent's retained disk usage and sensors as last known on a %s row",
|
||||
async (status) => {
|
||||
const GiB = 1024 * 1024 * 1024;
|
||||
const resource = baseResource({
|
||||
status,
|
||||
platformData: { sources: ['agent'] },
|
||||
agent: {
|
||||
agentId: 'agent-1',
|
||||
agentVersion: '1.2.3',
|
||||
hostname: 'host-1',
|
||||
platform: 'linux',
|
||||
stale: true,
|
||||
disks: [
|
||||
{ mountpoint: '/', total: 100 * GiB, used: 93 * GiB },
|
||||
{ mountpoint: '/data', total: 100 * GiB, used: 41 * GiB },
|
||||
],
|
||||
sensors: { temperatureCelsius: { cpu_package: 45 } },
|
||||
},
|
||||
});
|
||||
|
||||
const { getByRole, getByTestId, getByText, queryAllByTestId } = render(() => (
|
||||
<ResourceDetailDrawer resource={resource} />
|
||||
));
|
||||
fireEvent.click(getByRole('button', { name: 'Show details' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('disks-card-total')).toBeInTheDocument();
|
||||
});
|
||||
expect(getByTestId('disks-card-total')).toHaveTextContent('Last known 67% · 134 GB / 200 GB');
|
||||
const root = getByText('Last known 93%');
|
||||
expect(root).toHaveClass('text-muted');
|
||||
expect(root.closest('[title]')).toHaveAttribute(
|
||||
'title',
|
||||
'Last known reading, not current: host agent stopped reporting',
|
||||
);
|
||||
expect(queryAllByTestId('disks-card-mount-bar')).toHaveLength(0);
|
||||
expect(getByText('45°C (last known)')).toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it('shows PVE API network inventory when no linked agent supplies interfaces', async () => {
|
||||
const resource = baseResource({
|
||||
type: 'agent',
|
||||
|
||||
+7
-3
@@ -202,11 +202,14 @@ export const useResourceDetailDrawerDerivedState = (
|
||||
[],
|
||||
);
|
||||
const agentInfo = createMemo(() => toAgentFromResource(resource(), agentMeta()));
|
||||
// A silent agent's sensors are its last report, even on a row another
|
||||
// source keeps online.
|
||||
// A silent agent's sensors and disk usage are its last report, even on a
|
||||
// row another source keeps online.
|
||||
const agentReadingsLastKnownReason = createMemo(() =>
|
||||
resource().agent?.stale ? HOST_AGENT_STOPPED_REPORTING_REASON : undefined,
|
||||
);
|
||||
const temperatureRows = createMemo(() =>
|
||||
buildTemperatureRows(agentInfo()?.sensors, {
|
||||
lastKnownReason: resource().agent?.stale ? HOST_AGENT_STOPPED_REPORTING_REASON : undefined,
|
||||
lastKnownReason: agentReadingsLastKnownReason(),
|
||||
}),
|
||||
);
|
||||
const customSensorRows = createMemo(() => buildCustomSensorRows(agentInfo()?.sensors));
|
||||
@@ -475,6 +478,7 @@ export const useResourceDetailDrawerDerivedState = (
|
||||
proxmoxNode,
|
||||
proxmoxNetworkInterfaces,
|
||||
agentInfo,
|
||||
agentReadingsLastKnownReason,
|
||||
temperatureRows,
|
||||
customSensorRows,
|
||||
dockerHostData,
|
||||
|
||||
@@ -348,6 +348,19 @@ describe('shared primitive guardrails', () => {
|
||||
expect(source).toContain('<StackedDiskBar');
|
||||
});
|
||||
|
||||
it("keeps a silent agent's retained disk usage out of threshold colour and usage bars", () => {
|
||||
const source = readFrontendSource('src/components/shared/cards/DisksCard.tsx');
|
||||
expect(source).toContain('lastKnownReason?: string;');
|
||||
expect(source).toContain('`Last known reading, not current: ${reason}`');
|
||||
expect(source).toMatch(/<Show\s+when=\{!lastKnownTitle\(\)\}[\s\S]*?<StackedDiskBar/);
|
||||
expect(source).toMatch(
|
||||
/<Show when=\{!lastKnownTitle\(\)\}>\s*<div[^>]*data-testid="disks-card-mount-bar"/,
|
||||
);
|
||||
expect(
|
||||
readFrontendSource('src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx'),
|
||||
).toContain('lastKnownReason={props.drawer.agentReadingsLastKnownReason()}');
|
||||
});
|
||||
|
||||
it('keeps one canonical agent-host metric history group catalog', () => {
|
||||
expect(HOST_METRICS_HISTORY_GROUPS.map((group) => group.id)).toEqual([
|
||||
'utilization',
|
||||
|
||||
@@ -7,11 +7,22 @@ import { StackedDiskBar } from '@/components/Workloads/StackedDiskBar';
|
||||
|
||||
interface DisksCardProps {
|
||||
disks?: Disk[];
|
||||
/**
|
||||
* Why the usage figures are retained rather than current, such as a host
|
||||
* agent past its reporting lease. The card keeps every figure, since a
|
||||
* disk that filled before the machine went quiet is evidence, but reads
|
||||
* them as last known, without threshold colour or usage bars.
|
||||
*/
|
||||
lastKnownReason?: string;
|
||||
}
|
||||
|
||||
export const DisksCard: Component<DisksCardProps> = (props) => {
|
||||
if (!props.disks || props.disks.length === 0) return null;
|
||||
|
||||
const lastKnownTitle = () => {
|
||||
const reason = props.lastKnownReason?.trim();
|
||||
return reason ? `Last known reading, not current: ${reason}` : undefined;
|
||||
};
|
||||
const aggregateDisk = (): NormalizedDisk | null => {
|
||||
const total = props.disks?.reduce((sum, disk) => sum + (disk.total || 0), 0) ?? 0;
|
||||
const used = props.disks?.reduce((sum, disk) => sum + (disk.used || 0), 0) ?? 0;
|
||||
@@ -37,13 +48,28 @@ export const DisksCard: Component<DisksCardProps> = (props) => {
|
||||
<Show when={aggregateDisk()}>
|
||||
{(aggregate) => (
|
||||
<div class="mb-3 space-y-1.5 border-b border-border pb-3" data-testid="disks-card-total">
|
||||
<InfoCardKeyValueRow
|
||||
class="text-[10px]"
|
||||
label="Total Usage"
|
||||
value={`${formatBytes(aggregate().used)} / ${formatBytes(aggregate().total)}`}
|
||||
valueClass={getMetricTextColorClass(aggregateUsagePercent(), 'disk')}
|
||||
/>
|
||||
<StackedDiskBar disks={props.disks} aggregateDisk={aggregate()} mode="aggregate" />
|
||||
<Show
|
||||
when={!lastKnownTitle()}
|
||||
fallback={
|
||||
<InfoCardKeyValueRow
|
||||
class="text-[10px]"
|
||||
label="Total Usage"
|
||||
value={`Last known ${aggregateUsagePercent().toFixed(0)}% · ${formatBytes(
|
||||
aggregate().used,
|
||||
)} / ${formatBytes(aggregate().total)}`}
|
||||
valueClass="text-muted"
|
||||
valueTitle={lastKnownTitle()}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<InfoCardKeyValueRow
|
||||
class="text-[10px]"
|
||||
label="Total Usage"
|
||||
value={`${formatBytes(aggregate().used)} / ${formatBytes(aggregate().total)}`}
|
||||
valueClass={getMetricTextColorClass(aggregateUsagePercent(), 'disk')}
|
||||
/>
|
||||
<StackedDiskBar disks={props.disks} aggregateDisk={aggregate()} mode="aggregate" />
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
@@ -58,14 +84,20 @@ export const DisksCard: Component<DisksCardProps> = (props) => {
|
||||
const textColor = getMetricTextColorClass(usagePercent, 'disk');
|
||||
return (
|
||||
<div class="text-[10px]">
|
||||
{/* A long mountpoint takes its own line on a phone rather
|
||||
than squeezing the usage figures out of the card. */}
|
||||
<InfoCardKeyValueRow
|
||||
class="mb-0.5 text-[10px]"
|
||||
class="mb-0.5 flex-wrap gap-y-0.5 text-[10px]"
|
||||
label={disk.mountpoint}
|
||||
labelClass="truncate"
|
||||
labelClass="max-w-full truncate"
|
||||
labelTitle={disk.mountpoint}
|
||||
valueTitle={lastKnownTitle()}
|
||||
value={
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span class={`font-medium ${textColor}`}>{usagePercent.toFixed(0)}%</span>
|
||||
<span class={`font-medium ${lastKnownTitle() ? 'text-muted' : textColor}`}>
|
||||
{lastKnownTitle() ? 'Last known ' : ''}
|
||||
{usagePercent.toFixed(0)}%
|
||||
</span>
|
||||
<span class="text-muted">·</span>
|
||||
<span class="text-muted">
|
||||
{formatBytes(used)} / {formatBytes(total)}
|
||||
@@ -73,15 +105,20 @@ export const DisksCard: Component<DisksCardProps> = (props) => {
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<div class="h-1.5 w-full rounded-full bg-surface-hover overflow-hidden">
|
||||
<Show when={!lastKnownTitle()}>
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${Math.min(100, Math.max(0, usagePercent))}%`,
|
||||
'background-color': barColor,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
class="h-1.5 w-full rounded-full bg-surface-hover overflow-hidden"
|
||||
data-testid="disks-card-mount-bar"
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${Math.min(100, Math.max(0, usagePercent))}%`,
|
||||
'background-color': barColor,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cleanup, render, screen } from '@solidjs/testing-library';
|
||||
import { cleanup, render, screen, within } from '@solidjs/testing-library';
|
||||
import { createSignal } from 'solid-js';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DisksCard } from '../DisksCard';
|
||||
|
||||
@@ -70,6 +71,64 @@ describe('DisksCard', () => {
|
||||
expect(screen.getByTitle(`/mnt/disk-${count - 1}`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const GiB = 1024 ** 3;
|
||||
const fullRootDisks = () => [
|
||||
{ mountpoint: '/', total: 100 * GiB, used: 93 * GiB, free: 7 * GiB, usage: 0.93 },
|
||||
{ mountpoint: '/data', total: 100 * GiB, used: 41 * GiB, free: 59 * GiB, usage: 0.41 },
|
||||
];
|
||||
|
||||
it('colours current usage by the disk thresholds and draws usage bars', () => {
|
||||
render(() => <DisksCard disks={fullRootDisks()} />);
|
||||
|
||||
expect(screen.getByTestId('stacked-disk-bar')).toBeInTheDocument();
|
||||
expect(screen.getByText('93%')).toHaveClass('text-red-600');
|
||||
expect(screen.getByText('41%')).toHaveClass('text-muted');
|
||||
expect(screen.getAllByTestId('disks-card-mount-bar')).toHaveLength(2);
|
||||
expect(screen.getByTestId('disks-card-mounts')).not.toHaveTextContent('Last known');
|
||||
});
|
||||
|
||||
it('keeps retained usage figures but reads them as last known, without threshold colour or bars', () => {
|
||||
render(() => (
|
||||
<DisksCard disks={fullRootDisks()} lastKnownReason="host agent stopped reporting" />
|
||||
));
|
||||
const title = 'Last known reading, not current: host agent stopped reporting';
|
||||
|
||||
const total = screen.getByTestId('disks-card-total');
|
||||
expect(total).toHaveTextContent('Total Usage');
|
||||
expect(total).toHaveTextContent('Last known 67% · 134 GB / 200 GB');
|
||||
expect(within(total).getByTitle(title)).toHaveClass('text-muted');
|
||||
expect(screen.queryByTestId('stacked-disk-bar')).toBeNull();
|
||||
|
||||
const mounts = screen.getByTestId('disks-card-mounts');
|
||||
expect(mounts.children).toHaveLength(2);
|
||||
const root = screen.getByText('Last known 93%');
|
||||
expect(root).toHaveClass('text-muted');
|
||||
expect(root).not.toHaveClass('text-red-600');
|
||||
expect(root.closest('[title]')).toHaveAttribute('title', title);
|
||||
expect(mounts).toHaveTextContent('93.0 GB / 100 GB');
|
||||
expect(screen.getByText('Last known 41%')).toHaveClass('text-muted');
|
||||
expect(screen.queryAllByTestId('disks-card-mount-bar')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('follows the reason when an agent stops and resumes reporting', () => {
|
||||
const [reason, setReason] = createSignal<string | undefined>();
|
||||
render(() => <DisksCard disks={fullRootDisks()} lastKnownReason={reason()} />);
|
||||
|
||||
expect(screen.getByText('93%')).toHaveClass('text-red-600');
|
||||
expect(screen.getAllByTestId('disks-card-mount-bar')).toHaveLength(2);
|
||||
|
||||
setReason('host agent stopped reporting');
|
||||
expect(screen.getByText('Last known 93%')).toHaveClass('text-muted');
|
||||
expect(screen.queryByTestId('stacked-disk-bar')).toBeNull();
|
||||
expect(screen.queryAllByTestId('disks-card-mount-bar')).toHaveLength(0);
|
||||
|
||||
setReason(undefined);
|
||||
expect(screen.getByText('93%')).toHaveClass('text-red-600');
|
||||
expect(screen.queryByText(/Last known/)).toBeNull();
|
||||
expect(screen.getByTestId('stacked-disk-bar')).toBeInTheDocument();
|
||||
expect(screen.getAllByTestId('disks-card-mount-bar')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('renders nothing when no disks are available', () => {
|
||||
const { container } = render(() => <DisksCard disks={[]} />);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user