mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
Mark the alerting Mail Gateway number, keep rows single-line (#2496)
An earlier change (#2490) named the alert type on a second line under the gateway name. The shared platform-table contract keeps summary rows on one line, with context in an operational column, a tooltip or the drawer. Each gateway alert is about one number in the row, so that number now carries it. The message shows on hover and for screen readers. - Queue, hold and message-age alerts colour Queue. - Deferred alerts colour Deferred. - Quarantine alerts colour Quarantine. - Inbound spam and virus anomalies colour the Spam and Virus columns. Alerts with no column of their own (offline, outbound anomalies) stay on the status dot, a screen-reader summary, the row tint and the drawer. Verified at 1440 and 390, light and dark, including the phone drawer.
This commit is contained in:
@@ -4832,7 +4832,9 @@ and alert metric beside that text rather than collapsing them into generic VM
|
||||
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
|
||||
`whitespace-normal` because platform table cells do not wrap. The row itself
|
||||
marks the metric an alert is about instead of stacking a reason under the
|
||||
name, so it keeps the shared single-line rhythm. 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
|
||||
|
||||
@@ -1148,9 +1148,12 @@ 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
|
||||
threshold, a backlog) renders and filters as needing attention, colours the
|
||||
number the alert is about (queue, deferred, quarantine, spam or virus) with
|
||||
the alert message on hover and for screen readers, and its expansion leads
|
||||
with the shared drawer attention section carrying the exact alert message. The
|
||||
row stays single-line: an alert with no column of its own, such as offline,
|
||||
rides on the status dot, a screen-reader summary, and the drawer. 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
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "94ac386abfe13a8eb0109ac1eea4c087390f9717",
|
||||
"verified_at": "2026-10-05T05:31:34Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/features/proxmox/ProxmoxMailGatewayTable.tsx"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/features/proxmox/ProxmoxMailGatewayTable.tsx": "137179821488ebf769125cc3eb76b58a48c28a8a5769efd12ac6b8487d873aca"
|
||||
},
|
||||
"routes": [
|
||||
"/proxmox/mail"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Mail Gateway at 1440: both rows are single-line again. mail-gateway-eu has an open message-age alert, so its Queue number (17) reads amber and bold with the alert message on hover, where before the row grew a second line reading Message Age under the name",
|
||||
"The alerting row keeps its tint and amber dot, mail-gateway-us is unmarked",
|
||||
"Phone at 390: the five-track projection stays single-line, the Q value carries the amber mark",
|
||||
"Tapping mail-gateway-eu on the phone opens the drawer with Needs attention and the queued-messages alert text",
|
||||
"Dark mode at 1440: the amber Queue number stays legible"
|
||||
],
|
||||
"interactions": [
|
||||
"Tapped the alerting gateway on a 390 phone and read the drawer"
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { For, Show, createMemo, type Component } from 'solid-js';
|
||||
import { For, Show, createMemo, type Component, type JSX } from 'solid-js';
|
||||
import { InlineDetailTableRow } from '@/components/shared/InlineDetailTableRow';
|
||||
import { StatusDot } from '@/components/shared/StatusDot';
|
||||
import { TableCell, TableHead, TableRow } from '@/components/shared/Table';
|
||||
@@ -38,6 +38,56 @@ import {
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { ProxmoxMailGatewayDrawer } from './ProxmoxMailGatewayDrawer';
|
||||
|
||||
type MailGatewayAlertColumn = 'queue' | 'deferred' | 'quarantine' | 'spam' | 'virus';
|
||||
|
||||
// Each mail gateway alert is about one number in the row, so that number
|
||||
// carries it: coloured, with the alert on hover and for screen readers. The
|
||||
// row keeps its single line (the shared platform-table rhythm), and an alert
|
||||
// with no column of its own (offline) rides on the status dot and drawer.
|
||||
export const mailGatewayAlertColumn = (type: string | undefined): MailGatewayAlertColumn | null => {
|
||||
const normalized = (type ?? '').toLowerCase();
|
||||
if (normalized === 'queue-deferred') return 'deferred';
|
||||
if (
|
||||
normalized === 'queue-total' ||
|
||||
normalized === 'queue-depth' ||
|
||||
normalized === 'queue' ||
|
||||
normalized === 'queue-hold' ||
|
||||
normalized === 'message-age'
|
||||
) {
|
||||
return 'queue';
|
||||
}
|
||||
if (normalized.startsWith('quarantine-')) return 'quarantine';
|
||||
// The Spam and Virus columns count inbound mail, so only the inbound
|
||||
// anomalies (anomaly-spamIn, anomaly-virusIn in internal/alerts/pmg.go) mark
|
||||
// them. Outbound anomalies have no column and stay on the dot and drawer.
|
||||
if (normalized === 'anomaly-spamin') return 'spam';
|
||||
if (normalized === 'anomaly-virusin') return 'virus';
|
||||
return null;
|
||||
};
|
||||
|
||||
const AlertedMetric: Component<{
|
||||
alerts: Alert[];
|
||||
column: MailGatewayAlertColumn;
|
||||
children: JSX.Element;
|
||||
}> = (props) => (
|
||||
<Show when={props.alerts.length > 0} fallback={props.children}>
|
||||
<span
|
||||
class={`font-semibold ${
|
||||
props.alerts.some((alert) => alert.level === 'critical')
|
||||
? 'text-red-600 dark:text-red-300'
|
||||
: 'text-amber-700 dark:text-amber-300'
|
||||
}`}
|
||||
title={props.alerts.map((alert) => alert.message).join('\n')}
|
||||
data-mail-gateway-alert-column={props.column}
|
||||
>
|
||||
{props.children}
|
||||
<span class="sr-only">
|
||||
{`. ${props.alerts.map((alert) => `${alertTypeDisplayLabel(alert.type)}: ${alert.message}`).join('. ')}`}
|
||||
</span>
|
||||
</span>
|
||||
</Show>
|
||||
);
|
||||
|
||||
export type MailGatewayPhoneColumn =
|
||||
'instance' | 'nodes' | 'uptime' | 'mail' | 'queue' | 'deferred';
|
||||
|
||||
@@ -320,6 +370,8 @@ export const ProxmoxMailGatewayTable: Component<{
|
||||
const name = () => asTrimmedString(instance.name) || instance.id;
|
||||
const version = () => asTrimmedString(pmg()?.version) || '—';
|
||||
const rowAlerts = createMemo(() => openAlertsFor(instance));
|
||||
const columnAlerts = (column: MailGatewayAlertColumn) =>
|
||||
rowAlerts().filter((alert) => mailGatewayAlertColumn(alert.type) === column);
|
||||
const indicator = () => indicatorFor(instance);
|
||||
// Same tint as the other platform tables, from the cached alerts.
|
||||
const rowAlertBg = () => {
|
||||
@@ -354,32 +406,19 @@ export const ProxmoxMailGatewayTable: Component<{
|
||||
title={rowAlerts()[0]?.message || instance.status || 'unknown'}
|
||||
ariaHidden
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<span
|
||||
class="block font-semibold text-base-content truncate"
|
||||
title={name()}
|
||||
>
|
||||
{name()}
|
||||
<span
|
||||
class="min-w-0 truncate font-semibold text-base-content"
|
||||
title={name()}
|
||||
>
|
||||
{name()}
|
||||
</span>
|
||||
<Show when={rowAlerts().length > 0}>
|
||||
<span class="sr-only" data-mail-gateway-alert-summary>
|
||||
{`Needs attention: ${rowAlerts()
|
||||
.map((alert) => alert.message)
|
||||
.join('. ')}`}
|
||||
</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>
|
||||
</Show>
|
||||
</div>
|
||||
</TableCell>
|
||||
<Show when={showVersion()}>
|
||||
@@ -420,47 +459,60 @@ export const ProxmoxMailGatewayTable: Component<{
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
|
||||
>
|
||||
<PlatformTableNumberValue
|
||||
value={pmg()?.spamIn}
|
||||
format={formatPlatformTableIntegerValue}
|
||||
/>
|
||||
<AlertedMetric alerts={columnAlerts('spam')} column="spam">
|
||||
<PlatformTableNumberValue
|
||||
value={pmg()?.spamIn}
|
||||
format={formatPlatformTableIntegerValue}
|
||||
/>
|
||||
</AlertedMetric>
|
||||
</TableCell>
|
||||
</Show>
|
||||
<Show when={showVirus()}>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
|
||||
>
|
||||
<PlatformTableNumberValue
|
||||
value={pmg()?.virusIn}
|
||||
format={formatPlatformTableIntegerValue}
|
||||
/>
|
||||
<AlertedMetric alerts={columnAlerts('virus')} column="virus">
|
||||
<PlatformTableNumberValue
|
||||
value={pmg()?.virusIn}
|
||||
format={formatPlatformTableIntegerValue}
|
||||
/>
|
||||
</AlertedMetric>
|
||||
</TableCell>
|
||||
</Show>
|
||||
<Show when={showOperational()}>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
|
||||
>
|
||||
<PlatformTableNumberValue
|
||||
value={pmg()?.quarantine}
|
||||
format={formatPlatformTableIntegerValue}
|
||||
/>
|
||||
<AlertedMetric
|
||||
alerts={columnAlerts('quarantine')}
|
||||
column="quarantine"
|
||||
>
|
||||
<PlatformTableNumberValue
|
||||
value={pmg()?.quarantine}
|
||||
format={formatPlatformTableIntegerValue}
|
||||
/>
|
||||
</AlertedMetric>
|
||||
</TableCell>
|
||||
</Show>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
|
||||
>
|
||||
<PlatformTableNumberValue
|
||||
value={pmg()?.queueTotal ?? pmg()?.queueActive}
|
||||
format={formatPlatformTableIntegerValue}
|
||||
/>
|
||||
<AlertedMetric alerts={columnAlerts('queue')} column="queue">
|
||||
<PlatformTableNumberValue
|
||||
value={pmg()?.queueTotal ?? pmg()?.queueActive}
|
||||
format={formatPlatformTableIntegerValue}
|
||||
/>
|
||||
</AlertedMetric>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
|
||||
>
|
||||
<PlatformTableNumberValue
|
||||
value={pmg()?.queueDeferred}
|
||||
format={formatPlatformTableIntegerValue}
|
||||
/>
|
||||
<AlertedMetric alerts={columnAlerts('deferred')} column="deferred">
|
||||
<PlatformTableNumberValue
|
||||
value={pmg()?.queueDeferred}
|
||||
format={formatPlatformTableIntegerValue}
|
||||
/>
|
||||
</AlertedMetric>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<Show when={isOpen()}>
|
||||
|
||||
+100
-14
@@ -2,7 +2,7 @@ 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';
|
||||
import { ProxmoxMailGatewayTable, mailGatewayAlertColumn } from '../ProxmoxMailGatewayTable';
|
||||
|
||||
const activeAlerts: Record<string, Alert> = {};
|
||||
|
||||
@@ -38,7 +38,7 @@ describe('ProxmoxMailGatewayTable open alerts', () => {
|
||||
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 () => {
|
||||
it('marks the alerting number on an online gateway, keyed by its instance id', async () => {
|
||||
activeAlerts['pmg-main::oldest'] = {
|
||||
id: 'pmg-main::oldest',
|
||||
type: 'message-age',
|
||||
@@ -61,15 +61,23 @@ describe('ProxmoxMailGatewayTable open alerts', () => {
|
||||
/>
|
||||
));
|
||||
|
||||
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);
|
||||
// A message-age alert is about the queue, so the Queue number carries it
|
||||
// and the row stays single-line.
|
||||
const queue = document.querySelector('[data-mail-gateway-alert-column="queue"]');
|
||||
expect(queue?.getAttribute('title')).toContain('queued for 41 minutes');
|
||||
expect(queue?.className).toContain('text-amber-700');
|
||||
expect(queue?.querySelector('.sr-only')?.textContent).toContain('Message Age:');
|
||||
expect(document.querySelectorAll('[data-mail-gateway-alert-column]')).toHaveLength(1);
|
||||
const nameCell = screen.getByText('mail-gateway-eu').closest('td');
|
||||
expect(nameCell?.querySelector('[data-mail-gateway-alert-summary]')).toHaveClass('sr-only');
|
||||
expect(nameCell).not.toHaveTextContent('Message Age');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /mail-gateway-eu/ }));
|
||||
expect(
|
||||
await screen.findByText(/queued for 41 minutes \(threshold: 30 minutes\)/),
|
||||
).toBeVisible();
|
||||
const drawerMatches = (
|
||||
await screen.findAllByText(/queued for 41 minutes \(threshold: 30 minutes\)/)
|
||||
).filter((element) => !element.closest('.sr-only'));
|
||||
expect(drawerMatches.length).toBeGreaterThan(0);
|
||||
expect(drawerMatches[0]).toBeVisible();
|
||||
expect(screen.getByText('Needs attention')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -93,7 +101,8 @@ describe('ProxmoxMailGatewayTable open alerts', () => {
|
||||
/>
|
||||
));
|
||||
|
||||
expect(document.querySelector('[data-mail-gateway-alert-reason]')).toBeNull();
|
||||
expect(document.querySelector('[data-mail-gateway-alert-column]')).toBeNull();
|
||||
expect(document.querySelector('[data-mail-gateway-alert-summary]')).toBeNull();
|
||||
});
|
||||
|
||||
it("does not pin another gateway's node alert on a row that shares its node name", () => {
|
||||
@@ -116,7 +125,7 @@ describe('ProxmoxMailGatewayTable open alerts', () => {
|
||||
/>
|
||||
));
|
||||
|
||||
expect(document.querySelector('[data-mail-gateway-alert-reason]')).toBeNull();
|
||||
expect(document.querySelector('[data-mail-gateway-alert-column]')).toBeNull();
|
||||
});
|
||||
|
||||
it('reads red for a critical open alert', () => {
|
||||
@@ -139,9 +148,86 @@ describe('ProxmoxMailGatewayTable open alerts', () => {
|
||||
/>
|
||||
));
|
||||
|
||||
const reason = document.querySelector('[data-mail-gateway-alert-reason]');
|
||||
expect(reason?.className).toContain('text-red-600');
|
||||
const row = reason?.closest('tr');
|
||||
const queue = document.querySelector('[data-mail-gateway-alert-column="queue"]');
|
||||
expect(queue?.className).toContain('text-red-600');
|
||||
const row = queue?.closest('tr');
|
||||
expect(row?.querySelector('[class*="bg-red"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('puts a deferred-queue alert on the Deferred number', () => {
|
||||
activeAlerts['pmg-main::deferred'] = {
|
||||
id: 'pmg-main::deferred',
|
||||
type: 'queue-deferred',
|
||||
level: 'warning',
|
||||
resourceId: 'pmg-main',
|
||||
resourceName: 'mail-gateway-eu',
|
||||
node: 'https://pmg.example:8006',
|
||||
message: 'Deferred queue above warning',
|
||||
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 deferred = document.querySelector('[data-mail-gateway-alert-column="deferred"]');
|
||||
expect(deferred?.getAttribute('title')).toBe('Deferred queue above warning');
|
||||
expect(document.querySelector('[data-mail-gateway-alert-column="queue"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('marks inbound spam anomalies on Spam but leaves outbound ones to the dot and drawer', () => {
|
||||
activeAlerts['pmg-main-anomaly-spamIn'] = {
|
||||
id: 'pmg-main-anomaly-spamIn',
|
||||
type: 'anomaly-spamIn',
|
||||
level: 'warning',
|
||||
resourceId: 'pmg-main',
|
||||
resourceName: 'mail-gateway-eu',
|
||||
message: 'Inbound spam is 4x its usual rate',
|
||||
acknowledged: false,
|
||||
} as unknown as Alert;
|
||||
activeAlerts['pmg-main-anomaly-spamOut'] = {
|
||||
id: 'pmg-main-anomaly-spamOut',
|
||||
type: 'anomaly-spamOut',
|
||||
level: 'warning',
|
||||
resourceId: 'pmg-main',
|
||||
resourceName: 'mail-gateway-eu',
|
||||
message: 'Outbound spam is 6x its usual rate',
|
||||
acknowledged: false,
|
||||
} as unknown as Alert;
|
||||
|
||||
render(() => (
|
||||
<ProxmoxMailGatewayTable
|
||||
resources={[gateway('pmg-unified-eu', 'mail-gateway-eu', 'pmg-main')]}
|
||||
emptyTitle="No gateways"
|
||||
emptyDescription="No gateways"
|
||||
/>
|
||||
));
|
||||
|
||||
// jsdom renders the compact layout, where Spam is hidden, so the column
|
||||
// mapping itself is asserted below and the row keeps the full summary.
|
||||
expect(document.querySelector('[data-mail-gateway-alert-summary]')?.textContent).toContain(
|
||||
'Outbound spam',
|
||||
);
|
||||
});
|
||||
|
||||
it('maps every mail gateway alert type to the number it is about', () => {
|
||||
expect(mailGatewayAlertColumn('queue-total')).toBe('queue');
|
||||
expect(mailGatewayAlertColumn('queue-depth')).toBe('queue');
|
||||
expect(mailGatewayAlertColumn('queue')).toBe('queue');
|
||||
expect(mailGatewayAlertColumn('queue-hold')).toBe('queue');
|
||||
expect(mailGatewayAlertColumn('message-age')).toBe('queue');
|
||||
expect(mailGatewayAlertColumn('queue-deferred')).toBe('deferred');
|
||||
expect(mailGatewayAlertColumn('quarantine-spam')).toBe('quarantine');
|
||||
expect(mailGatewayAlertColumn('quarantine-virus')).toBe('quarantine');
|
||||
expect(mailGatewayAlertColumn('anomaly-spamIn')).toBe('spam');
|
||||
expect(mailGatewayAlertColumn('anomaly-virusIn')).toBe('virus');
|
||||
// Outbound anomalies and offline have no column of their own.
|
||||
expect(mailGatewayAlertColumn('anomaly-spamOut')).toBeNull();
|
||||
expect(mailGatewayAlertColumn('anomaly-virusOut')).toBeNull();
|
||||
expect(mailGatewayAlertColumn('offline')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,4 +77,11 @@ describe('ProxmoxMailGatewayTable phone presentation', () => {
|
||||
);
|
||||
expect(mailGatewayDrawerSource).toContain('<DrawerAttentionSection');
|
||||
});
|
||||
|
||||
it('keeps alerting rows single-line by marking the metric the alert is about', () => {
|
||||
// Platform table rows stay on one line, so an alert colours its own number
|
||||
// rather than stacking a reason under the gateway name.
|
||||
expect(mailGatewayTableSource).toContain('<AlertedMetric alerts={columnAlerts(');
|
||||
expect(mailGatewayTableSource).not.toContain('data-mail-gateway-alert-reason');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user