mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
Incorporate supplied upstream frontier for this candidate batch
Change-source: pulse-maintainer
This commit is contained in:
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
+147
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user