Incorporate supplied upstream frontier for this candidate batch

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-05 05:35:30 +01:00
15 changed files with 535 additions and 65 deletions
@@ -4840,7 +4840,10 @@ acknowledged severity, and reveals overflow through an in-place accessible
disclosure rather than an inert hidden-count row. Aggregate drawers that include
alerts from child resources must also preserve the affected resource identity
and alert metric beside that text rather than collapsing them into generic VM
or host labels. The remaining Overview rows are additive
or host labels. The Mail Gateway drawer receives its row's open alerts, matched
across the gateway's identity aliases, and its header status follows them, so
it never shows "Healthy" beside an active warning; its inline detail row sets
`whitespace-normal` because platform table cells do not wrap. The remaining Overview rows are additive
operator context that the parent table row cannot carry, such as OS/runtime,
Pulse observation or action coverage, primary reachability, protection gaps,
pending updates, or an identified service. Routine health, placement, and
@@ -7464,6 +7467,10 @@ presentation. The compact row remains actionable: plain-language rationale and
provider history/permission limitations live one disclosure deeper beside
bounded restore evidence. A posture fetch failure must keep the evidence
inspectable and show unknown, never a locally inferred healthy fallback.
The table renders the server explanation verbatim and keeps its own fallbacks
(checking, not rated, unknown) and the snapshot-only age tooltip in the same
plain voice, without model terms such as canonical identity or provider
evidence.
The bounded restore evidence keeps the newest point and the newest completed
point of each source before filling with the newest overall, so a run of guest
snapshots or a running job cannot push the latest usable backup out of the
@@ -3808,6 +3808,16 @@ expected. Stale, failed, partial, or unverified evidence is attention;
unprotected requires complete evidence of no qualifying backup; unavailable
identity, history, collection, or permission evidence fails to unknown.
Snapshots remain distinct and never prove independent recovery by themselves.
The posture `explanation` is user-facing copy (the Coverage row tooltip and
expansion render it verbatim), so it is written in plain words rather than the
model's vocabulary: "recent backup", "full backup history", "one of its backup
sources has reported a failure", "verification is missing or overdue", and
the stale case names the policy window ("the newest confirmed backup is older
than 7 days") through `formatPostureWindow`, which never rounds a sub-hour or
fractional window up. Copy only claims a backup exists on branches that have
one. Terms such as
"subject-linked", "provider history" and "freshness window" stay in code and
contracts, not in the explanation.
The per-tenant recovery store persists provider observations and a materialized
posture index, refreshes it after writes, reconciliation, migration, identity
@@ -1187,6 +1187,16 @@ the daemon tally as width shrinks, keeps one five-value phone projection
(cluster, health, OSDs, pools, capacity) whose values each fit whole, and
leaves quorum membership and the FSID to the cluster disclosure, where the
FSID stays searchable and is shown in full.
The Mail Gateway row reflects its open alerts, not only the provider status:
a reachable gateway with an unacknowledged alert (mail queued past its age
threshold, a backlog) renders and filters as needing attention, names the alert
type under the gateway name, and its expansion leads with the shared drawer
attention section carrying the exact alert message. Alerts are matched across
the row's identity aliases, because they are keyed by the PMG instance id while
the row carries the unified id. Its phone projection is the five-track
instance, uptime, mail, queue and deferred set with a 40 percent identity, so
gateways that share a name prefix stay distinguishable; node count lives in the
expansion.
The Proxmox host table drops temperature from its phone projection
(container widths under 440px) so the node name, expansion toggle, and
status dot share the same five-column shape as the guest table beneath it,
@@ -3353,7 +3363,10 @@ metadata.
### Protection posture identity consumer
`ProxmoxCoverageTable` remains a unified-resource identity consumer while
storage/recovery owns protection truth. Dropping the per-source age columns
storage/recovery owns protection truth. Its explanation copy for rows without a
canonical workload identity says so in user terms ("does not match a guest
Pulse currently monitors, so it is not rated"); the identity rule itself is
unchanged. Dropping the per-source age columns
from its rows changes no identity input: rows still key posture by the exact
canonical `Resource.id`. Live VM/LXC rows carry the exact
canonical `Resource.id` into one bounded posture batch; the table must not parse
@@ -226,7 +226,7 @@ const { chromium, webkit } = require('playwright');
await scopedAge.getByText('None', { exact: true }).waitFor();
assert.ok(
(await scopedAge.locator('span[title]').getAttribute('title')).includes(
'Guest snapshots alone do not prove independent recovery.',
'A snapshot is not a separate backup, so it does not count.',
),
);
checks.push(
@@ -0,0 +1,36 @@
{
"version": 1,
"base_sha": "a82a8e04747263f5c49083f4c537e03040ba3c62",
"verified_at": "2026-10-05T04:01:52Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx",
"frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts"
],
"content_sha256": {
"frontend-modern/src/features/proxmox/ProxmoxCoverageTable.tsx": "ffdd41704d33d02194119521d60f2cfcfa1b2a10f069e95e80eaacee7f63d40f",
"frontend-modern/src/features/proxmox/proxmoxBackupRecoveryModel.ts": "9073910e7ccb6806424e3a324424942afef939d102fc40e94db8323100dd78a6"
},
"routes": [
"/proxmox/backups/coverage"
],
"viewports": [
{
"width": 1440,
"height": 1000
},
{
"width": 390,
"height": 844
}
],
"states": [
"Coverage rows on a rebuilt backend: protected, attention (failed source, stale beyond 7 days), unprotected (no backup, snapshots only), unknown and not-evaluated explanations all read in plain words",
"Failed-source guest expansion: Needs attention: A backup exists, but one of its backup sources has reported a failure.",
"Snapshot-only age tooltip: No PBS snapshot or PVE backup file. A snapshot is not a separate backup, so it does not count."
],
"interactions": [
"Read every posture tooltip on the coverage table",
"Expanded the failed-source guest row"
]
}
@@ -0,0 +1,38 @@
{
"version": 1,
"base_sha": "27f020f033119d589150ae90f4570a7d8a579132",
"verified_at": "2026-10-05T03:34:23Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/features/proxmox/ProxmoxMailGatewayDrawer.tsx",
"frontend-modern/src/features/proxmox/ProxmoxMailGatewayTable.tsx"
],
"content_sha256": {
"frontend-modern/src/features/proxmox/ProxmoxMailGatewayDrawer.tsx": "0b57910805f48b86ab1823e61e7fee52e5e514632cf53fcdb1490f39c17eed83",
"frontend-modern/src/features/proxmox/ProxmoxMailGatewayTable.tsx": "8032d6deec8859124834cf66bec7e12dbbeb430b9f25710ba64f5ca71811feb9"
},
"routes": [
"/proxmox/mail"
],
"viewports": [
{
"width": 1440,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"mail-gateway-eu online with an open warning alert (messages queued 41 minutes, threshold 30): amber dot, row tint, Message Age under its name, filter counts Healthy 0 / Degraded 2",
"Before: the same gateway rendered green and counted Healthy",
"Phone 390 and 340: Instance, Age, In, Q, Def with whole, distinguishable gateway names; before both read mail-gatew...",
"Expanded drawer at 1440 and 390: amber header dot, Needs attention section first with the full alert message wrapping, statistics and cluster nodes below",
"No console errors"
],
"interactions": [
"Expanded mail-gateway-eu at desktop and phone widths",
"Compared rendered state against the active alert list from /api/alerts/active"
]
}
@@ -53,7 +53,7 @@ const COVERAGE_EMPTY_LABEL = 'None';
const COVERAGE_NO_BACKUP_TITLE = 'No PBS snapshot or PVE backup file';
// Matches the posture engine's own wording for a snapshot-only subject.
const COVERAGE_SNAPSHOT_ONLY_TITLE =
'No PBS snapshot or PVE backup file. Guest snapshots alone do not prove independent recovery.';
'No PBS snapshot or PVE backup file. A snapshot is not a separate backup, so it does not count.';
function CoverageAgeFallback(props: { unknown?: boolean; emptyTitle: string; compact?: boolean }) {
const title = () =>
@@ -129,11 +129,11 @@ const evidenceQualityLabel = (value: string): string => {
const postureExplanation = (row: WorkloadCoverageRow): string => {
if (row.protectionPosture?.explanation) return row.protectionPosture.explanation;
if (row.posture === 'checking') return 'Pulse is checking provider protection evidence.';
if (row.posture === 'checking') return "Pulse is checking this guest's backups.";
if (row.posture === 'not-evaluated') {
return 'This backup target has no current canonical workload identity, so Pulse does not evaluate workload protection for it.';
return 'This backup does not match a guest Pulse currently monitors, so it is not rated.';
}
return 'Pulse does not have enough provider evidence to determine protection.';
return 'Pulse cannot tell yet whether this guest is backed up.';
};
// "Workload coverage" table: one row per workload answering "does this have a
@@ -1,4 +1,7 @@
import { For, Show, createMemo, createResource, type Component } from 'solid-js';
import { DrawerAttentionSection } from '@/components/shared/DrawerAttentionSection';
import { alertTypeDisplayLabel } from '@/features/alerts/helpers';
import type { Alert } from '@/types/api';
import { Card } from '@/components/shared/Card';
import { DrawerSubjectHeading } from '@/components/shared/DrawerSubjectHeading';
import { InfoCardKeyValueRow } from '@/components/shared/InfoCardFrame';
@@ -217,6 +220,8 @@ function InOutBar(props: {
export const ProxmoxMailGatewayDrawer: Component<{
instanceRow: Resource;
onClose?: () => void;
/** Open alerts for this gateway, matched across its identity aliases by the table. */
alerts?: Alert[];
}> = (props) => {
const id = () => {
const meta = props.instanceRow.pmg;
@@ -236,7 +241,19 @@ export const ProxmoxMailGatewayDrawer: Component<{
);
const relayDomains = createMemo(() => instance()?.relayDomains ?? []);
const health = () => classifyHealth(instance()?.status ?? props.instanceRow.status);
// A reachable gateway with open alerts reads as needing attention, matching
// its table row, rather than a green "Healthy" beside the alert below.
const health = () => {
const base = classifyHealth(instance()?.status ?? props.instanceRow.status);
const open = (props.alerts ?? []).filter((alert) => !alert.acknowledged);
if (open.length === 0 || base.variant === 'danger') return base;
return {
variant: open.some((alert) => alert.level === 'critical')
? ('danger' as const)
: ('warning' as const),
label: 'Needs attention',
};
};
const name = () =>
asTrimmedString(instance()?.name) ||
asTrimmedString(props.instanceRow.name) ||
@@ -330,6 +347,18 @@ export const ProxmoxMailGatewayDrawer: Component<{
)}
</Show>
{/* The exact problem first: a phone cannot hover the row's short reason. */}
<DrawerAttentionSection
items={(props.alerts ?? []).map((alert) => ({
id: alert.id,
message: alert.message,
subject: name(),
metric: alertTypeDisplayLabel(alert.type),
severity: alert.level,
acknowledged: alert.acknowledged,
}))}
/>
<Show
when={!instance.error}
fallback={
@@ -3,6 +3,12 @@ import { InlineDetailTableRow } from '@/components/shared/InlineDetailTableRow';
import { StatusDot } from '@/components/shared/StatusDot';
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
import { getSimpleStatusIndicator } from '@/utils/status';
import { getAlertsForResource } from '@/utils/alerts';
import { getResourceIdentityAliases } from '@/utils/resourceIdentity';
import { alertTypeDisplayLabel } from '@/features/alerts/helpers';
import { useWebSocket } from '@/contexts/appRuntime';
import { useAlertsActivation } from '@/stores/alertsActivation';
import type { Alert } from '@/types/api';
import { asTrimmedString } from '@/utils/stringUtils';
import {
PLATFORM_HEALTH_FILTER_OPTIONS,
@@ -38,9 +44,11 @@ export type MailGatewayPhoneColumn =
export type MailGatewayColumn =
MailGatewayPhoneColumn | 'version' | 'spam' | 'virus' | 'quarantine';
// Phones carry five tracks. With node count as a sixth, gateway names that
// share a prefix ("mail-gateway-eu", "mail-gateway-us") truncated to the same
// text; the count stays in the row expansion.
export const MAIL_GATEWAY_PHONE_COLUMNS: readonly MailGatewayPhoneColumn[] = [
'instance',
'nodes',
'uptime',
'mail',
'queue',
@@ -56,12 +64,12 @@ export const MAIL_GATEWAY_NARROW_PHONE_COLUMNS: readonly MailGatewayPhoneColumn[
];
export const MAIL_GATEWAY_PHONE_COLUMN_WIDTHS: Readonly<Record<MailGatewayPhoneColumn, number>> = {
instance: 30,
nodes: 12,
uptime: 16,
mail: 14,
queue: 14,
deferred: 14,
instance: 40,
nodes: 0,
uptime: 15,
mail: 15,
queue: 15,
deferred: 15,
};
export const MAIL_GATEWAY_NARROW_PHONE_COLUMN_WIDTHS: Readonly<
@@ -104,10 +112,59 @@ export const ProxmoxMailGatewayTable: Component<{
emptyTitle: string;
emptyDescription: string;
}> = (props) => {
// A gateway the provider reports online can still be failing its job (mail
// held in the queue past the configured age, a backlog). Its open alerts
// decide the row's state, the status filter counts and the reason shown
// under its name, so "is mail flowing" is answered here, not only on Alerts.
const { activeAlerts } = useWebSocket();
const alertsActivation = useAlertsActivation();
// Alerts are keyed by the PMG instance id while the row carries the unified
// id, so match across the resource's identity aliases (alerts contract).
// There is deliberately no node-name fallback: PMG node alerts carry node
// names, and matching on them could pin one gateway's alert on another.
const alertIdsFor = (resource: Resource): string[] => [
resource.id,
...getResourceIdentityAliases(resource),
];
// One scan of the active-alert map per gateway, shared by the filter, the
// status counts, the row and its drawer.
const openAlertsById = createMemo(() => {
const byId = new Map<string, Alert[]>();
for (const resource of props.resources) {
byId.set(
resource.id,
getAlertsForResource(
alertIdsFor(resource),
activeAlerts,
alertsActivation.detectionEnabled(),
)
.filter((alert) => !alert.acknowledged)
.sort((a, b) => Number(b.level === 'critical') - Number(a.level === 'critical')),
);
}
return byId;
});
const openAlertsFor = (resource: Resource): Alert[] => openAlertsById().get(resource.id) ?? [];
// Filtering keeps the provider's buckets: an alerted, reachable gateway
// counts under attention (degraded), an unreachable one stays offline.
const effectiveStatus = (resource: Resource): string | undefined => {
if (openAlertsFor(resource).length === 0) return resource.status;
return getSimpleStatusIndicator(resource.status).variant === 'danger'
? resource.status
: 'warning';
};
// The dot follows the most severe open alert, so a critical one reads red.
const indicatorFor = (resource: Resource) => {
const base = getSimpleStatusIndicator(effectiveStatus(resource));
return openAlertsFor(resource)[0]?.level === 'critical'
? { ...base, variant: 'danger' as const }
: base;
};
const tableState = createPlatformTableFilterState({
resources: () => props.resources,
initialStatus: 'all' as PlatformResourceStatusFilter,
filter: filterPlatformResources,
filter: (resources, search, status) =>
filterPlatformResources(resources, search, status, effectiveStatus),
});
const detail = createPlatformResourceDetailState({ idPrefix: 'proxmox-mail-gateway-detail' });
const observedWidth = useObservedElementWidth();
@@ -118,9 +175,9 @@ export const ProxmoxMailGatewayTable: Component<{
const width = observedWidth.width();
return typeof width === 'number' && width > 0 && width < 360;
});
// Uptime and mail-flow counters remain in the narrow projection; node count
// moves into the existing instance detail expansion below 360px.
const showNodes = createMemo(() => !isNarrowPhone());
// Uptime and mail-flow counters remain in the phone projection; node count
// moves into the existing instance detail expansion on phones.
const showNodes = createMemo(() => layout() !== 'compact');
const showUptime = createMemo(() => true);
const showOperational = createMemo(() => ['operational', 'expanded', 'full'].includes(layout()));
const showVirus = createMemo(() => ['expanded', 'full'].includes(layout()));
@@ -262,7 +319,16 @@ export const ProxmoxMailGatewayTable: Component<{
const pmg = () => instance.pmg;
const name = () => asTrimmedString(instance.name) || instance.id;
const version = () => asTrimmedString(pmg()?.version) || '—';
const indicator = () => getSimpleStatusIndicator(instance.status);
const rowAlerts = createMemo(() => openAlertsFor(instance));
const indicator = () => indicatorFor(instance);
// Same tint as the other platform tables, from the cached alerts.
const rowAlertBg = () => {
const top = rowAlerts()[0];
if (!top) return '';
return top.level === 'critical'
? 'bg-red-50 dark:bg-red-950/25'
: 'bg-yellow-50 dark:bg-yellow-950/25';
};
const isOpen = () => detail.isExpanded(instance);
const detailRowId = () => detail.detailRowId(instance);
return (
@@ -271,6 +337,7 @@ export const ProxmoxMailGatewayTable: Component<{
{...getPlatformResourceDetailRowInteractionProps({
expanded: isOpen(),
onToggle: () => detail.toggle(instance),
class: rowAlertBg(),
})}
>
<TableCell class={getPlatformTableCellClassForKind('name')}>
@@ -284,12 +351,35 @@ export const ProxmoxMailGatewayTable: Component<{
<StatusDot
size="sm"
variant={indicator().variant}
title={instance.status || 'unknown'}
title={rowAlerts()[0]?.message || instance.status || 'unknown'}
ariaHidden
/>
<span class="font-semibold text-base-content truncate" title={name()}>
{name()}
</span>
<div class="min-w-0">
<span
class="block font-semibold text-base-content truncate"
title={name()}
>
{name()}
</span>
<Show when={rowAlerts()[0]}>
{(alert) => (
<span
class={`block truncate text-[11px] font-medium ${
alert().level === 'critical'
? 'text-red-600 dark:text-red-300'
: 'text-amber-700 dark:text-amber-300'
}`}
title={rowAlerts()
.map((open) => open.message)
.join('\n')}
data-mail-gateway-alert-reason
>
{alertTypeDisplayLabel(alert().type)}
{rowAlerts().length > 1 ? ` +${rowAlerts().length - 1}` : ''}
</span>
)}
</Show>
</div>
</div>
</TableCell>
<Show when={showVersion()}>
@@ -377,12 +467,14 @@ export const ProxmoxMailGatewayTable: Component<{
<InlineDetailTableRow
cellId={detailRowId()}
colspan={visibleColumnCount()}
contentClass="px-4 py-4"
cellClass="whitespace-normal"
contentClass="min-w-0 whitespace-normal px-4 py-4"
data-inline-detail-for={instance.id}
>
<ProxmoxMailGatewayDrawer
instanceRow={instance}
onClose={() => detail.close(instance)}
alerts={rowAlerts()}
/>
</InlineDetailTableRow>
</Show>
@@ -225,4 +225,37 @@ describe('ProxmoxCoverageTable column visibility', () => {
expect(empty(title)).toHaveTextContent(/^None$/);
}
});
it('explains snapshot-only and unrated rows in plain words', () => {
render(() => (
<ProxmoxCoverageTable
rows={[
{ ...row, key: 'snapshot-only', snapshotCount: 2 },
{ ...row, key: 'unrated', posture: 'not-evaluated', protectionPosture: undefined },
]}
hasAnyRows
emptyIcon={<span />}
emptyTitle=""
emptyDescription=""
sortKey={(() => 'posture') as Accessor<CoverageSortKey>}
sortDirection={() => 'asc'}
onSort={() => {}}
expandedKeys={new Set<string>()}
onToggleExpand={() => {}}
showTaskColumn={true}
layoutWidth={() => 1_200}
/>
));
const titles = [...document.querySelectorAll('td span[title]')].map((span) =>
span.getAttribute('title'),
);
expect(titles).toContain(
'No PBS snapshot or PVE backup file. A snapshot is not a separate backup, so it does not count.',
);
expect(titles).toContain(
'This backup does not match a guest Pulse currently monitors, so it is not rated.',
);
expect(titles.join(' ')).not.toMatch(/canonical|provider evidence|independent recovery/);
});
});
@@ -0,0 +1,147 @@
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { Alert } from '@/types/api';
import type { Resource } from '@/types/resource';
import { ProxmoxMailGatewayTable } from '../ProxmoxMailGatewayTable';
const activeAlerts: Record<string, Alert> = {};
vi.mock('@/contexts/appRuntime', () => ({
useWebSocket: () => ({ activeAlerts }),
}));
vi.mock('@/stores/alertsActivation', () => ({
useAlertsActivation: () => ({ detectionEnabled: () => true }),
}));
vi.mock('@/utils/apiClient', () => ({
apiFetch: vi.fn(async () => new Response(JSON.stringify(null), { status: 200 })),
}));
const gateway = (id: string, name: string, instanceId: string): Resource =>
({
id,
type: 'pmg',
name,
displayName: name,
platformType: 'proxmox-pmg',
sourceType: 'api',
status: 'online',
lastSeen: Date.now(),
canonicalIdentity: { primaryId: `agent:${instanceId}`, aliases: [instanceId, name] },
pmg: { instanceId, nodeCount: 1, mailCountTotal: 10, queueTotal: 3, queueDeferred: 1 },
}) as unknown as Resource;
describe('ProxmoxMailGatewayTable open alerts', () => {
afterEach(() => {
cleanup();
for (const key of Object.keys(activeAlerts)) delete activeAlerts[key];
});
it('marks an online gateway with an open alert and says why, keyed by its instance id', async () => {
activeAlerts['pmg-main::oldest'] = {
id: 'pmg-main::oldest',
type: 'message-age',
level: 'warning',
resourceId: 'pmg-main',
resourceName: 'mail-gateway-eu',
node: 'https://pmg.example:8006',
message: 'PMG mail-gateway-eu has messages queued for 41 minutes (threshold: 30 minutes)',
acknowledged: false,
} as unknown as Alert;
render(() => (
<ProxmoxMailGatewayTable
resources={[
gateway('pmg-unified-eu', 'mail-gateway-eu', 'pmg-main'),
gateway('pmg-unified-us', 'mail-gateway-us', 'pmg-edge'),
]}
emptyTitle="No gateways"
emptyDescription="No gateways"
/>
));
const reason = document.querySelector('[data-mail-gateway-alert-reason]');
expect(reason?.textContent).toBe('Message Age');
expect(reason?.getAttribute('title')).toContain('queued for 41 minutes');
expect(document.querySelectorAll('[data-mail-gateway-alert-reason]')).toHaveLength(1);
fireEvent.click(screen.getByRole('button', { name: /mail-gateway-eu/ }));
expect(
await screen.findByText(/queued for 41 minutes \(threshold: 30 minutes\)/),
).toBeVisible();
expect(screen.getByText('Needs attention')).toBeInTheDocument();
});
it('ignores acknowledged alerts', () => {
activeAlerts['pmg-main::oldest'] = {
id: 'pmg-main::oldest',
type: 'message-age',
level: 'warning',
resourceId: 'pmg-main',
resourceName: 'mail-gateway-eu',
node: 'https://pmg.example:8006',
message: 'queued',
acknowledged: true,
} as unknown as Alert;
render(() => (
<ProxmoxMailGatewayTable
resources={[gateway('pmg-unified-eu', 'mail-gateway-eu', 'pmg-main')]}
emptyTitle="No gateways"
emptyDescription="No gateways"
/>
));
expect(document.querySelector('[data-mail-gateway-alert-reason]')).toBeNull();
});
it("does not pin another gateway's node alert on a row that shares its node name", () => {
activeAlerts['pmg-edge::node'] = {
id: 'pmg-edge::node',
type: 'message-age',
level: 'warning',
resourceId: 'pmg-edge',
resourceName: 'mail-gateway-us',
node: 'mail-gateway-eu',
message: 'queued on edge',
acknowledged: false,
} as unknown as Alert;
render(() => (
<ProxmoxMailGatewayTable
resources={[gateway('pmg-unified-eu', 'mail-gateway-eu', 'pmg-main')]}
emptyTitle="No gateways"
emptyDescription="No gateways"
/>
));
expect(document.querySelector('[data-mail-gateway-alert-reason]')).toBeNull();
});
it('reads red for a critical open alert', () => {
activeAlerts['pmg-main::queue'] = {
id: 'pmg-main::queue',
type: 'queue-total',
level: 'critical',
resourceId: 'pmg-main',
resourceName: 'mail-gateway-eu',
node: 'https://pmg.example:8006',
message: 'Queue above critical',
acknowledged: false,
} as unknown as Alert;
render(() => (
<ProxmoxMailGatewayTable
resources={[gateway('pmg-unified-eu', 'mail-gateway-eu', 'pmg-main')]}
emptyTitle="No gateways"
emptyDescription="No gateways"
/>
));
const reason = document.querySelector('[data-mail-gateway-alert-reason]');
expect(reason?.className).toContain('text-red-600');
const row = reason?.closest('tr');
expect(row?.querySelector('[class*="bg-red"]')).not.toBeNull();
});
});
@@ -10,6 +10,7 @@ import {
MAIL_GATEWAY_PHONE_COLUMN_WIDTHS,
} from '../ProxmoxMailGatewayTable';
import mailGatewayDrawerSource from '../ProxmoxMailGatewayDrawer.tsx?raw';
import mailGatewayTableSource from '../ProxmoxMailGatewayTable.tsx?raw';
describe('ProxmoxMailGatewayTable phone presentation', () => {
it('keeps compact drawer statistics on the shared responsive row contract', () => {
@@ -22,23 +23,17 @@ describe('ProxmoxMailGatewayTable phone presentation', () => {
).toHaveLength(3);
});
it('keeps fleet context and mail-flow counters visible without scrolling', () => {
expect(MAIL_GATEWAY_PHONE_COLUMNS).toEqual([
'instance',
'nodes',
'uptime',
'mail',
'queue',
'deferred',
]);
expect(MAIL_GATEWAY_PHONE_COLUMN_WIDTHS).toEqual({
instance: 30,
nodes: 12,
uptime: 16,
mail: 14,
queue: 14,
deferred: 14,
});
it('keeps gateway names distinguishable with five phone tracks', () => {
// A sixth track (node count) truncated "mail-gateway-eu" and
// "mail-gateway-us" to the same text; the count lives in the expansion.
expect(MAIL_GATEWAY_PHONE_COLUMNS).toEqual(['instance', 'uptime', 'mail', 'queue', 'deferred']);
expect(MAIL_GATEWAY_PHONE_COLUMN_WIDTHS.instance).toBe(40);
expect(
MAIL_GATEWAY_PHONE_COLUMNS.reduce(
(total, column) => total + MAIL_GATEWAY_PHONE_COLUMN_WIDTHS[column],
0,
),
).toBe(100);
});
it('demotes node count below 360px while keeping five mail-flow fields', () => {
@@ -73,4 +68,13 @@ describe('ProxmoxMailGatewayTable phone presentation', () => {
expect(Math.min(...widths)).toBeGreaterThan(7);
expect(widths.reduce((total, width) => total + width, 0)).toBeCloseTo(100, 1);
});
it('matches gateway alerts across identity aliases and shows the problem in the drawer', () => {
// Alerts are keyed by the PMG instance id, rows by the unified id.
expect(mailGatewayTableSource).toContain('getResourceIdentityAliases(resource)');
expect(mailGatewayTableSource).toContain(
'filterPlatformResources(resources, search, status, effectiveStatus)',
);
expect(mailGatewayDrawerSource).toContain('<DrawerAttentionSection');
});
});
@@ -149,7 +149,7 @@ export function getRecoveryAgeBand(
// A guest snapshot shares the guest's storage, so it is listed as restore
// evidence but never counted as a backup. Mirrors the server posture rule that
// "snapshots alone do not prove independent recovery".
// "a snapshot is not a separate backup, so it does not count".
export function isBackupArtifact(artifact: RecoverableArtifact): boolean {
return artifact.sourceKind !== 'snapshot';
}
+39 -12
View File
@@ -390,7 +390,7 @@ func DeriveProtectionPostureAt(
ProviderStates: []ProtectionProviderState{},
RepositoryResourceIDs: []string{},
EvidenceIDs: []string{},
Explanation: "Pulse has no complete provider history linked to this resource, so protection is unknown.",
Explanation: "Pulse has no readable backup history for this resource, so it cannot say whether it is protected.",
EvaluatedAt: now,
}
@@ -561,37 +561,41 @@ func DeriveProtectionPostureAt(
(!stateVerificationExpected || posture.Verification == ProtectionVerificationVerified):
posture.State = ProtectionStateProtected
if posture.Verification == ProtectionVerificationVerified {
posture.Explanation = "A current subject-linked backup is available and has recent verification evidence."
posture.Explanation = "A recent backup is available and it was verified recently."
} else {
posture.Explanation = "A current subject-linked backup is available from complete provider history."
posture.Explanation = "A recent backup is available, and Pulse can see the full backup history."
}
if hasUnknownBlock {
posture.Explanation += " Another linked provider has unavailable history, but it does not invalidate the confirmed recovery point."
posture.Explanation += " Another backup source could not be read, but that does not change this backup."
}
case hasSupportedQualifyingSuccess:
posture.State = ProtectionStateAttention
switch {
case hasInvalidatingFailure:
posture.Explanation = "A backup exists, but a newer provider failure needs attention before Pulse can call this resource protected."
posture.Explanation = "A backup exists, but one of its backup sources has reported a failure."
case posture.Freshness == ProtectionFreshnessStale:
posture.Explanation = "The strongest subject-linked backup is older than the configured freshness window."
posture.Explanation = fmt.Sprintf("The newest confirmed backup is older than %s.", formatPostureWindow(policy.FreshnessWindow))
case stateVerificationExpected && posture.Verification != ProtectionVerificationVerified:
posture.Explanation = "A current backup exists, but its verification evidence is missing or stale."
posture.Explanation = "A recent backup exists, but its verification is missing or overdue."
default:
posture.Explanation = "Recovery evidence exists, but provider history or permissions are incomplete."
posture.Explanation = "A backup exists, but Pulse can see only part of the backup history or cannot read all of it."
}
case hasUnknownBlock:
posture.State = ProtectionStateUnknown
posture.Explanation = "Provider history or permissions are unavailable, so Pulse cannot make a stronger protection claim."
posture.Explanation = "Pulse cannot read the backup history (it is unavailable or Pulse lacks permission), so it cannot say whether this is protected."
case hasQualifyingSuccess || hasPartial:
posture.State = ProtectionStateAttention
posture.Explanation = "Recovery evidence exists, but provider history or permissions are incomplete."
if hasQualifyingSuccess {
posture.Explanation = "A backup exists, but Pulse can see only part of the backup history or cannot read all of it."
} else {
posture.Explanation = "Pulse can see only part of the backup history, so it cannot confirm a backup."
}
case hasCompleteHistory:
posture.State = ProtectionStateUnprotected
if hasSnapshotsOnly {
posture.Explanation = "Provider history is complete, but only snapshots are present; snapshots alone do not prove independent recovery."
posture.Explanation = "Only snapshots were found. A snapshot is not a separate backup, so it does not count."
} else {
posture.Explanation = "Provider history is complete, but no qualifying subject-linked backup exists."
posture.Explanation = "Pulse can see the full backup history and found no backup for this resource."
}
default:
posture.State = ProtectionStateUnknown
@@ -684,3 +688,26 @@ func validProtectionPermissions(value operationaltrust.EvidencePermissions) bool
return false
}
}
// formatPostureWindow renders the freshness window the way the explanation
// reads it: whole days, then whole hours, otherwise minutes, so a sub-hour or
// fractional window is never rounded into a claim it did not make.
func formatPostureWindow(window time.Duration) string {
plural := func(n int, unit string) string {
if n == 1 {
return fmt.Sprintf("1 %s", unit)
}
return fmt.Sprintf("%d %ss", n, unit)
}
switch {
case window >= 24*time.Hour && window%(24*time.Hour) == 0:
return plural(int(window/(24*time.Hour)), "day")
case window >= time.Hour && window%time.Hour == 0:
return plural(int(window/time.Hour), "hour")
case window > 0:
minutes := int((window + time.Minute - 1) / time.Minute)
return plural(minutes, "minute")
default:
return "0 minutes"
}
}
+44 -10
View File
@@ -55,7 +55,7 @@ func TestDeriveProtectionPostureTruthTable(t *testing.T) {
wantFresh: ProtectionFreshnessCurrent,
wantVerify: ProtectionVerificationVerified,
wantCoverage: ProtectionCoverageComplete,
wantText: "current subject-linked backup",
wantText: "A recent backup is available",
},
{
name: "stale success needs attention",
@@ -66,7 +66,7 @@ func TestDeriveProtectionPostureTruthTable(t *testing.T) {
wantFresh: ProtectionFreshnessStale,
wantVerify: ProtectionVerificationStale,
wantCoverage: ProtectionCoverageComplete,
wantText: "older than",
wantText: "newest confirmed backup is older than 7 days",
},
{
name: "missing expected verification needs attention",
@@ -79,7 +79,7 @@ func TestDeriveProtectionPostureTruthTable(t *testing.T) {
wantFresh: ProtectionFreshnessCurrent,
wantVerify: ProtectionVerificationUnverified,
wantCoverage: ProtectionCoverageComplete,
wantText: "verification evidence",
wantText: "verification is missing or overdue",
},
{
name: "newer provider failure invalidates protected claim",
@@ -94,7 +94,7 @@ func TestDeriveProtectionPostureTruthTable(t *testing.T) {
wantFresh: ProtectionFreshnessCurrent,
wantVerify: ProtectionVerificationVerified,
wantCoverage: ProtectionCoverageComplete,
wantText: "newer provider failure",
wantText: "one of its backup sources has reported a failure",
},
{
name: "complete snapshot-only history is unprotected",
@@ -114,7 +114,7 @@ func TestDeriveProtectionPostureTruthTable(t *testing.T) {
wantFresh: ProtectionFreshnessUnknown,
wantVerify: ProtectionVerificationUnknown,
wantCoverage: ProtectionCoverageNone,
wantText: "snapshots alone",
wantText: "A snapshot is not a separate backup",
},
{
name: "partial provider history is attention when a backup exists",
@@ -128,7 +128,21 @@ func TestDeriveProtectionPostureTruthTable(t *testing.T) {
wantFresh: ProtectionFreshnessCurrent,
wantVerify: ProtectionVerificationVerified,
wantCoverage: ProtectionCoveragePartial,
wantText: "incomplete",
wantText: "only part of the backup history",
},
{
name: "partial history without any backup does not claim one exists",
summaries: func() []ProtectionProviderSummary {
summary := protectionTestSummary(now, nil)
summary.HistoryCompleteness = ProtectionHistoryPartial
summary.BackupPointCount = 0
return []ProtectionProviderSummary{summary}
}(),
wantState: ProtectionStateAttention,
wantFresh: ProtectionFreshnessUnknown,
wantVerify: ProtectionVerificationUnknown,
wantCoverage: ProtectionCoveragePartial,
wantText: "cannot confirm a backup",
},
{
name: "permission denied is unknown",
@@ -142,7 +156,7 @@ func TestDeriveProtectionPostureTruthTable(t *testing.T) {
wantFresh: ProtectionFreshnessCurrent,
wantVerify: ProtectionVerificationVerified,
wantCoverage: ProtectionCoverageUnknown,
wantText: "permissions are unavailable",
wantText: "cannot read the backup history",
},
{
name: "confirmed PBS recovery is not invalidated by an unknown legacy provider",
@@ -166,7 +180,7 @@ func TestDeriveProtectionPostureTruthTable(t *testing.T) {
wantFresh: ProtectionFreshnessCurrent,
wantVerify: ProtectionVerificationVerified,
wantCoverage: ProtectionCoverageUnknown,
wantText: "does not invalidate",
wantText: "does not change this backup",
},
{
name: "no provider evidence is unknown",
@@ -175,7 +189,7 @@ func TestDeriveProtectionPostureTruthTable(t *testing.T) {
wantFresh: ProtectionFreshnessUnknown,
wantVerify: ProtectionVerificationUnknown,
wantCoverage: ProtectionCoverageUnknown,
wantText: "no complete provider history",
wantText: "no readable backup history",
},
}
@@ -338,7 +352,7 @@ func TestBuildProtectionPostureDoesNotTreatSuccessfulTaskAsAvailableBackup(t *te
if got.LastSuccessfulPointAt != nil {
t.Fatalf("last successful point = %v, want nil for task-only evidence", got.LastSuccessfulPointAt)
}
if !strings.Contains(got.Explanation, "no qualifying subject-linked backup") {
if !strings.Contains(got.Explanation, "found no backup") {
t.Fatalf("explanation = %q, want no qualifying backup", got.Explanation)
}
}
@@ -436,3 +450,23 @@ func TestDeriveProtectionPostureFailsClosedToDefaultPolicy(t *testing.T) {
t.Fatalf("freshness = %q, want current after default-policy fallback", posture.Freshness)
}
}
func TestFormatPostureWindowReadsAsDaysOrHours(t *testing.T) {
for _, tc := range []struct {
window time.Duration
want string
}{
{7 * 24 * time.Hour, "7 days"},
{24 * time.Hour, "1 day"},
{36 * time.Hour, "36 hours"},
{time.Hour, "1 hour"},
{30 * time.Minute, "30 minutes"},
{90 * time.Minute, "90 minutes"},
{time.Minute, "1 minute"},
{0, "0 minutes"},
} {
if got := formatPostureWindow(tc.window); got != tc.want {
t.Fatalf("formatPostureWindow(%s) = %q, want %q", tc.window, got, tc.want)
}
}
}