From a26dad060701e390d75c8d3327fe090fbb4595d9 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 16 May 2026 16:47:20 +0100 Subject: [PATCH] proxmox(mail): bespoke drawer with cluster nodes + queues + stats Mail Gateway rows now expand inline to show the detail the slim ResourcePMGMeta projection couldn't carry: every cluster node with status / role / uptime / loadavg / postfix queue (active/deferred/ hold/incoming counts + oldest message age), quarantine breakdown (spam / virus / attachment / blacklisted), spam-score distribution, top 8 domains by mail volume, and the mail-flow detail strip (bounces in/out, greylist, junk, RBL + pregreet rejects, bytes in/out, average process time). Drawer fetches /api/pmg/instances?id= on first open so the row keeps its slim payload; the new endpoint projects Monitor.GetState().PMGInstances directly under ScopeMonitoringRead with optional id / name filters. Same inline expand-row pattern as the ceph and workloads drawers (TableRow inserted below, colspan all columns). --- .../proxmox/ProxmoxMailGatewayDrawer.tsx | 484 ++++++++++++++++++ .../proxmox/ProxmoxMailGatewayTable.tsx | 115 +++-- internal/api/pmg.go | 88 ++++ internal/api/router_routes_monitoring.go | 1 + 4 files changed, 646 insertions(+), 42 deletions(-) create mode 100644 frontend-modern/src/features/proxmox/ProxmoxMailGatewayDrawer.tsx create mode 100644 internal/api/pmg.go diff --git a/frontend-modern/src/features/proxmox/ProxmoxMailGatewayDrawer.tsx b/frontend-modern/src/features/proxmox/ProxmoxMailGatewayDrawer.tsx new file mode 100644 index 000000000..5e921bb8e --- /dev/null +++ b/frontend-modern/src/features/proxmox/ProxmoxMailGatewayDrawer.tsx @@ -0,0 +1,484 @@ +import { + For, + Show, + createMemo, + createResource, + type Component, +} from 'solid-js'; +import XIcon from 'lucide-solid/icons/x'; +import { Card } from '@/components/shared/Card'; +import { StatusDot } from '@/components/shared/StatusDot'; +import type { StatusIndicatorVariant } from '@/utils/status'; +import { formatBytes } from '@/utils/format'; +import { asTrimmedString } from '@/utils/stringUtils'; +import { apiFetch } from '@/utils/apiClient'; +import type { + PMGInstance, + PMGNodeStatus, + PMGQueueStatus, +} from '@/types/api'; +import type { Resource } from '@/types/resource'; + +// Inline drawer for a single Proxmox Mail Gateway instance. The row +// table only exposes the slim ResourcePMGMeta projection (totals +// only), but the backend State carries per-node cluster status with +// individual postfix queue detail, full mail stats (in/out, bytes, +// bounces, RBL/pregreet), quarantine-by-category, spam score +// distribution, top-domain stats, and configured relay domains. Fetch +// the full PMGInstance on first open from /api/pmg/instances so the +// row stays cheap and the drawer is rich. + +interface PMGInstancesResponse { + data: PMGInstance[]; + meta: { total: number }; +} + +async function fetchPMGInstance(id: string): Promise { + const response = await apiFetch(`/api/pmg/instances?id=${encodeURIComponent(id)}`); + if (!response.ok) { + throw new Error(`Failed to load PMG instance (${response.status})`); + } + const payload = (await response.json()) as PMGInstancesResponse; + return payload?.data?.[0] ?? null; +} + +function classifyHealth(status: string | undefined): { + variant: StatusIndicatorVariant; + label: string; +} { + const raw = (status ?? '').toLowerCase(); + if (raw === 'online' || raw === 'ok' || raw === 'healthy') { + return { variant: 'success', label: 'Healthy' }; + } + if (raw === 'degraded' || raw === 'warning') return { variant: 'warning', label: 'Degraded' }; + if (raw === 'offline' || raw === 'error' || raw === 'critical') { + return { variant: 'danger', label: 'Offline' }; + } + return { variant: 'muted', label: raw || 'Unknown' }; +} + +function classifyNode(node: PMGNodeStatus): { variant: StatusIndicatorVariant; label: string } { + const raw = (node.status ?? '').toLowerCase(); + if (raw === 'online' || raw === 'ok') return { variant: 'success', label: 'Online' }; + if (raw === 'degraded' || raw === 'warning') return { variant: 'warning', label: 'Degraded' }; + if (raw === 'offline' || raw === 'down') return { variant: 'danger', label: 'Offline' }; + return { variant: 'muted', label: raw || '—' }; +} + +function formatUptime(seconds: number | undefined): string { + if (!seconds || seconds <= 0) return '—'; + const days = Math.floor(seconds / 86_400); + if (days > 0) return `${days}d`; + const hours = Math.floor(seconds / 3_600); + if (hours > 0) return `${hours}h`; + const mins = Math.floor(seconds / 60); + return `${mins}m`; +} + +function formatAge(seconds: number): string { + if (!seconds || seconds <= 0) return '—'; + if (seconds < 60) return `${seconds}s`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; + if (seconds < 86_400) return `${Math.floor(seconds / 3600)}h`; + return `${Math.floor(seconds / 86_400)}d`; +} + +function formatNumber(value: number | undefined): string { + if (typeof value !== 'number' || !Number.isFinite(value)) return '—'; + return Math.round(value).toLocaleString(); +} + +function StatTile(props: { label: string; value: string | number; sub?: string }) { + return ( +
+
{props.label}
+
+ {props.value} + + {props.sub} + +
+
+ ); +} + +function QueueDot(props: { count: number }) { + const tone = + props.count === 0 + ? 'muted' + : props.count > 50 + ? 'danger' + : props.count > 10 + ? 'warning' + : 'success'; + return ; +} + +function queueCell(queue: PMGQueueStatus | undefined): { count: number; label: string } { + if (!queue) return { count: 0, label: '—' }; + return { + count: queue.total, + label: `${queue.active}/${queue.deferred}/${queue.hold}/${queue.incoming}`, + }; +} + +export const ProxmoxMailGatewayDrawer: Component<{ + instanceRow: Resource; + onClose: () => void; +}> = (props) => { + const id = () => { + const meta = props.instanceRow.pmg; + return asTrimmedString(meta?.instanceId) || props.instanceRow.id; + }; + const [instance, { refetch }] = createResource( + id, + fetchPMGInstance, + ); + + const stats = createMemo(() => instance()?.mailStats); + const quarantine = createMemo(() => instance()?.quarantine); + const nodes = createMemo(() => instance()?.nodes ?? []); + const spamBuckets = createMemo(() => instance()?.spamDistribution ?? []); + const topDomains = createMemo(() => + (instance()?.domainStats ?? []) + .slice() + .sort((a, b) => (b.mailCount ?? 0) - (a.mailCount ?? 0)) + .slice(0, 8), + ); + const relayDomains = createMemo(() => instance()?.relayDomains ?? []); + + const health = () => classifyHealth(instance()?.status ?? props.instanceRow.status); + const name = () => + asTrimmedString(instance()?.name) || asTrimmedString(props.instanceRow.name) || props.instanceRow.id; + const version = () => asTrimmedString(instance()?.version) || asTrimmedString(props.instanceRow.pmg?.version) || '—'; + const hostname = () => asTrimmedString(instance()?.host); + const totalQueue = createMemo(() => + nodes().reduce((sum, n) => sum + (n.queueStatus?.total ?? 0), 0), + ); + + return ( +
+
+
+
+ +

{name()}

+ {version()} +
+ +
{hostname()}
+
+
+ +
+ + +

+ Failed to load Mail Gateway detail. +

+ + + } + > + Loading Mail Gateway detail…

} + > + No detail available for this instance.

} + > +
+ + + + + + +
+ +
+ +
+

+ Cluster nodes +

+ + {nodes().length} node{nodes().length === 1 ? '' : 's'} + +
+ 0} + fallback={

No cluster nodes reported.

} + > + + + + + + + + + + + + + + {(node) => { + const cls = classifyNode(node); + const queue = queueCell(node.queueStatus); + return ( + + + + + + + + + ); + }} + + +
NodeRoleUptimeLoadQueueOldest
+
+ + + {node.name || '—'} + +
+
+ {asTrimmedString(node.role) || '—'} + + {formatUptime(node.uptime)} + + {asTrimmedString(node.loadAvg) || '—'} + +
+ + {queue.count} + + {queue.label} + +
+
+ {node.queueStatus?.oldestAge + ? formatAge(node.queueStatus.oldestAge) + : '—'} +
+
+
+ + +
+

+ Quarantine +

+ + {formatNumber( + (quarantine()?.spam ?? 0) + + (quarantine()?.virus ?? 0) + + (quarantine()?.attachment ?? 0) + + (quarantine()?.blacklisted ?? 0), + )}{' '} + total + +
+ No quarantine data reported.

} + > +
+ + + + +
+
+
+
+ Spam score distribution +
+ 0} + fallback={

No spam score data.

} + > +
+ + {(bucket) => ( + + {bucket.score} + + {formatNumber(bucket.count)} + + + )} + +
+
+
+
+
+ +
+ +
+

+ Top domains +

+ + top {topDomains().length} + +
+ 0} + fallback={

No domain stats reported.

} + > + + + + + + + + + + + + + {(domain) => ( + + + + + + + + )} + + +
DomainMailSpamVirusBytes
+ {domain.domain || '—'} + + {formatNumber(domain.mailCount)} + + {formatNumber(domain.spamCount)} + + {formatNumber(domain.virusCount)} + + {domain.bytes && domain.bytes > 0 + ? formatBytes(domain.bytes) + : '—'} +
+
+
+ + +
+

+ Mail flow detail +

+ + {stats()?.timeframe} + +
+ No mail stats reported.

} + > +
+ + + + + + + 0 + ? formatBytes(stats()!.bytesIn) + : '—' + } + /> + 0 + ? formatBytes(stats()!.bytesOut) + : '—' + } + /> + +
+
+
+
+ + 0}> + +

+ Relay domains +

+
+ + {(rd) => ( + + {rd.domain} + + )} + +
+
+
+
+
+
+
+ ); +}; + +export default ProxmoxMailGatewayDrawer; diff --git a/frontend-modern/src/features/proxmox/ProxmoxMailGatewayTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxMailGatewayTable.tsx index 248ba8aed..f76d67ba1 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxMailGatewayTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxMailGatewayTable.tsx @@ -19,6 +19,7 @@ import { type PlatformResourceStatusFilter, } from '@/features/platformPage/sharedPlatformPage'; import type { Resource } from '@/types/resource'; +import { ProxmoxMailGatewayDrawer } from './ProxmoxMailGatewayDrawer'; // Proxmox Mail Gateway instances are mail-flow / quarantine appliances. // The generic infrastructure table renders dashes for Disk I/O / Uptime @@ -55,6 +56,9 @@ export const ProxmoxMailGatewayTable: Component<{ }> = (props) => { const [search, setSearch] = createSignal(''); const [status, setStatus] = createSignal('all'); + const [selectedId, setSelectedId] = createSignal(null); + const toggleSelected = (id: string) => + setSelectedId((current) => (current === id ? null : id)); const filtered = createMemo(() => filterPlatformResources(props.resources, search(), status())); const visible = createMemo(() => filtered().length); @@ -124,49 +128,76 @@ export const ProxmoxMailGatewayTable: Component<{ const name = () => asTrimmedString(instance.name) || instance.id; const version = () => asTrimmedString(pmg()?.version) || '—'; const indicator = () => getSimpleStatusIndicator(instance.status); + const isOpen = () => selectedId() === instance.id; return ( - - -
- - - {name()} - -
-
- - {version()} - - - {countCell(pmg()?.nodeCount)} - - - {formatUptime(instance.uptime ?? pmg()?.uptimeSeconds)} - - - {countCell(pmg()?.mailCountTotal)} - - - {countCell(pmg()?.spamIn)} - - - {countCell(pmg()?.virusIn)} - - - {countCell(pmg()?.quarantine)} - - - {countCell(pmg()?.queueTotal ?? pmg()?.queueActive)} - - - {countCell(pmg()?.queueDeferred)} - -
+ <> + toggleSelected(instance.id)} + aria-expanded={isOpen()} + > + +
+ + + {name()} + +
+
+ + {version()} + + + {countCell(pmg()?.nodeCount)} + + + {formatUptime(instance.uptime ?? pmg()?.uptimeSeconds)} + + + {countCell(pmg()?.mailCountTotal)} + + + {countCell(pmg()?.spamIn)} + + + {countCell(pmg()?.virusIn)} + + + {countCell(pmg()?.quarantine)} + + + {countCell(pmg()?.queueTotal ?? pmg()?.queueActive)} + + + {countCell(pmg()?.queueDeferred)} + +
+ + + +
event.stopPropagation()} + > + setSelectedId(null)} + /> +
+
+
+
+ ); }} diff --git a/internal/api/pmg.go b/internal/api/pmg.go new file mode 100644 index 000000000..b79a9df4d --- /dev/null +++ b/internal/api/pmg.go @@ -0,0 +1,88 @@ +package api + +import ( + "net/http" + "strings" + + "github.com/rs/zerolog/log" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/models" + "github.com/rcourtman/pulse-go-rewrite/internal/utils" +) + +// PMGInstancesResponse is the canonical payload for +// `GET /api/pmg/instances`. It exposes the full Proxmox Mail Gateway +// instance snapshot — per-node cluster status with postfix queue +// detail, full mail stats (in/out, bytes, bounces, RBL/pregreet), +// quarantine breakdown by category, spam score distribution, top +// domain stats, and relay-domain configuration. The Mail Gateway +// platform-page drawer is the first consumer; the row table keeps +// reading the slim ResourcePMGMeta projection through /api/resources. +type PMGInstancesResponse struct { + Data []models.PMGInstance `json:"data"` + Meta PMGInstancesMeta `json:"meta"` +} + +type PMGInstancesMeta struct { + Total int `json:"total"` +} + +func (r *Router) handleListPMGInstances(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodGet { + writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", + "Only GET method is allowed", nil) + return + } + + monitor := r.getTenantMonitor(req.Context()) + if monitor == nil { + writeErrorResponse(w, http.StatusInternalServerError, "no_monitor", + "Monitor not available", nil) + return + } + + state := monitor.GetState() + instances := filterPMGInstances(state.PMGInstances, req) + + response := PMGInstancesResponse{ + Data: instances, + Meta: PMGInstancesMeta{Total: len(instances)}, + } + + if err := utils.WriteJSONResponse(w, response); err != nil { + log.Error().Err(err).Msg("Failed to encode PMG instances response") + writeErrorResponse(w, http.StatusInternalServerError, "encoding_error", + "Failed to encode PMG instances", nil) + } +} + +// filterPMGInstances narrows the snapshot by optional `id=` or +// `name=` query parameters. With neither, returns the full list. +func filterPMGInstances(instances []models.PMGInstance, req *http.Request) []models.PMGInstance { + query := req.URL.Query() + idFilter := strings.TrimSpace(query.Get("id")) + nameFilter := strings.TrimSpace(query.Get("name")) + + if idFilter == "" && nameFilter == "" { + out := make([]models.PMGInstance, len(instances)) + copy(out, instances) + return out + } + + out := make([]models.PMGInstance, 0, len(instances)) + for _, inst := range instances { + if idFilter != "" && !strings.EqualFold(strings.TrimSpace(inst.ID), idFilter) { + continue + } + if nameFilter != "" && !strings.EqualFold(strings.TrimSpace(inst.Name), nameFilter) { + continue + } + out = append(out, inst) + } + return out +} + +func (r *Router) registerPMGRoutes() { + r.mux.HandleFunc("/api/pmg/instances", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleListPMGInstances))) +} diff --git a/internal/api/router_routes_monitoring.go b/internal/api/router_routes_monitoring.go index c861662f2..83f03f3f3 100644 --- a/internal/api/router_routes_monitoring.go +++ b/internal/api/router_routes_monitoring.go @@ -29,6 +29,7 @@ func (r *Router) registerMonitoringResourceRoutes( r.mux.HandleFunc("/api/recovery/rollups", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.recoveryHandlers.HandleListRollups))) r.registerReplicationRoutes() r.registerPVEBackupsRoutes() + r.registerPMGRoutes() // Unified resources API r.mux.HandleFunc("/api/resources", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.resourceHandlers.HandleListResources)))