diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 30fe40df19..a6980c3f68 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -4828,7 +4828,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 diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 737fdef6f7..b07a294ee8 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -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 diff --git a/frontend-modern/browser-verification/215c42485b49f95e.json b/frontend-modern/browser-verification/215c42485b49f95e.json new file mode 100644 index 0000000000..95e8a4b947 --- /dev/null +++ b/frontend-modern/browser-verification/215c42485b49f95e.json @@ -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" + ] +} diff --git a/frontend-modern/src/features/proxmox/ProxmoxMailGatewayTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxMailGatewayTable.tsx index 080ea99489..e7507dac16 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxMailGatewayTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxMailGatewayTable.tsx @@ -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) => ( + 0} fallback={props.children}> + 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} + + {`. ${props.alerts.map((alert) => `${alertTypeDisplayLabel(alert.type)}: ${alert.message}`).join('. ')}`} + + + +); + 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 /> -
- - {name()} + + {name()} + + 0}> + + {`Needs attention: ${rowAlerts() + .map((alert) => alert.message) + .join('. ')}`} - - {(alert) => ( - open.message) - .join('\n')} - data-mail-gateway-alert-reason - > - {alertTypeDisplayLabel(alert().type)} - {rowAlerts().length > 1 ? ` +${rowAlerts().length - 1}` : ''} - - )} - -
+ @@ -420,47 +459,60 @@ export const ProxmoxMailGatewayTable: Component<{ - + + + - + + + - + + + - + + + - + + + diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxMailGatewayTable.alerts.test.tsx b/frontend-modern/src/features/proxmox/__tests__/ProxmoxMailGatewayTable.alerts.test.tsx index b8c8e9b791..ef9592e4ae 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxMailGatewayTable.alerts.test.tsx +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxMailGatewayTable.alerts.test.tsx @@ -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 = {}; @@ -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(() => ( + + )); + + 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(() => ( + + )); + + // 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(); + }); }); diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxMailGatewayTable.test.ts b/frontend-modern/src/features/proxmox/__tests__/ProxmoxMailGatewayTable.test.ts index 3018142336..b41669c681 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxMailGatewayTable.test.ts +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxMailGatewayTable.test.ts @@ -77,4 +77,11 @@ describe('ProxmoxMailGatewayTable phone presentation', () => { ); expect(mailGatewayDrawerSource).toContain(' { + // 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('