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=<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).
This commit is contained in:
rcourtman 2026-05-16 16:47:20 +01:00
parent 21e679e45c
commit a26dad0607
4 changed files with 646 additions and 42 deletions

View file

@ -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<PMGInstance | null> {
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 (
<div class="min-w-0 rounded-sm border border-border bg-surface-alt px-3 py-2">
<div class="truncate text-[10px] uppercase tracking-wide text-muted">{props.label}</div>
<div class="mt-0.5 flex items-baseline gap-1 truncate text-base font-semibold text-base-content tabular-nums">
<span class="truncate">{props.value}</span>
<Show when={props.sub}>
<span class="truncate text-[10px] font-normal text-muted">{props.sub}</span>
</Show>
</div>
</div>
);
}
function QueueDot(props: { count: number }) {
const tone =
props.count === 0
? 'muted'
: props.count > 50
? 'danger'
: props.count > 10
? 'warning'
: 'success';
return <StatusDot size="xs" variant={tone as StatusIndicatorVariant} ariaHidden />;
}
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<PMGInstance | null, string>(
id,
fetchPMGInstance,
);
const stats = createMemo(() => instance()?.mailStats);
const quarantine = createMemo(() => instance()?.quarantine);
const nodes = createMemo<PMGNodeStatus[]>(() => 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 (
<div class="space-y-4">
<header class="flex items-start justify-between gap-3">
<div class="min-w-0 space-y-1">
<div class="flex items-center gap-2 min-w-0">
<StatusDot size="md" variant={health().variant} title={health().label} ariaHidden />
<h3 class="truncate text-sm font-semibold text-base-content">{name()}</h3>
<span class="shrink-0 text-[10px] font-mono text-muted">{version()}</span>
</div>
<Show when={hostname()}>
<div class="font-mono text-[10px] text-muted break-all">{hostname()}</div>
</Show>
</div>
<button
type="button"
onClick={props.onClose}
class="shrink-0 inline-flex h-7 w-7 items-center justify-center rounded-sm text-muted hover:bg-surface-hover hover:text-base-content"
aria-label="Close mail gateway drawer"
>
<XIcon class="h-4 w-4" />
</button>
</header>
<Show
when={!instance.error}
fallback={
<Card padding="md">
<p class="text-xs text-red-600 dark:text-red-300">
Failed to load Mail Gateway detail.
</p>
<button
type="button"
onClick={() => void refetch()}
class="mt-2 inline-flex min-h-9 items-center rounded-md border border-border px-3 text-sm font-medium hover:bg-surface-hover"
>
Retry
</button>
</Card>
}
>
<Show
when={instance() !== undefined}
fallback={<p class="text-xs text-muted">Loading Mail Gateway detail</p>}
>
<Show
when={instance()}
fallback={<p class="text-xs text-muted">No detail available for this instance.</p>}
>
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
<StatTile label="Nodes" value={nodes().length} />
<StatTile
label="Mail in (24h)"
value={formatNumber(stats()?.countIn)}
/>
<StatTile label="Mail out (24h)" value={formatNumber(stats()?.countOut)} />
<StatTile label="Spam in" value={formatNumber(stats()?.spamIn)} />
<StatTile label="Virus in" value={formatNumber(stats()?.virusIn)} />
<StatTile label="Queue total" value={totalQueue()} />
</div>
<div class="grid gap-3 lg:grid-cols-2">
<Card padding="md">
<div class="mb-2 flex items-baseline justify-between">
<h4 class="text-xs font-semibold uppercase tracking-wide text-muted">
Cluster nodes
</h4>
<span class="text-[10px] text-muted tabular-nums">
{nodes().length} node{nodes().length === 1 ? '' : 's'}
</span>
</div>
<Show
when={nodes().length > 0}
fallback={<p class="text-xs text-muted">No cluster nodes reported.</p>}
>
<table class="w-full text-xs">
<thead class="text-[10px] uppercase tracking-wide text-muted">
<tr>
<th class="pb-2 text-left font-medium">Node</th>
<th class="pb-2 text-left font-medium">Role</th>
<th class="pb-2 text-right font-medium">Uptime</th>
<th class="pb-2 text-right font-medium">Load</th>
<th class="pb-2 text-right font-medium">Queue</th>
<th class="pb-2 text-right font-medium">Oldest</th>
</tr>
</thead>
<tbody class="divide-y divide-border-subtle">
<For each={nodes()}>
{(node) => {
const cls = classifyNode(node);
const queue = queueCell(node.queueStatus);
return (
<tr>
<td class="py-2">
<div class="flex items-center gap-2">
<StatusDot
size="sm"
variant={cls.variant}
title={cls.label}
ariaHidden
/>
<span class="font-mono text-[11px] font-semibold text-base-content">
{node.name || '—'}
</span>
</div>
</td>
<td class="py-2 text-base-content text-[11px]">
{asTrimmedString(node.role) || '—'}
</td>
<td class="py-2 text-right text-base-content">
{formatUptime(node.uptime)}
</td>
<td class="py-2 text-right text-base-content tabular-nums text-[11px]">
{asTrimmedString(node.loadAvg) || '—'}
</td>
<td class="py-2 text-right">
<div class="inline-flex items-center justify-end gap-1.5 tabular-nums">
<QueueDot count={queue.count} />
<span class="text-base-content font-semibold">{queue.count}</span>
<span
class="text-muted text-[10px] font-mono"
title="active/deferred/hold/incoming"
>
{queue.label}
</span>
</div>
</td>
<td class="py-2 text-right text-base-content">
{node.queueStatus?.oldestAge
? formatAge(node.queueStatus.oldestAge)
: '—'}
</td>
</tr>
);
}}
</For>
</tbody>
</table>
</Show>
</Card>
<Card padding="md">
<div class="mb-2 flex items-baseline justify-between">
<h4 class="text-xs font-semibold uppercase tracking-wide text-muted">
Quarantine
</h4>
<span class="text-[10px] text-muted tabular-nums">
{formatNumber(
(quarantine()?.spam ?? 0) +
(quarantine()?.virus ?? 0) +
(quarantine()?.attachment ?? 0) +
(quarantine()?.blacklisted ?? 0),
)}{' '}
total
</span>
</div>
<Show
when={quarantine()}
fallback={<p class="text-xs text-muted">No quarantine data reported.</p>}
>
<div class="grid grid-cols-2 gap-2">
<StatTile label="Spam" value={formatNumber(quarantine()?.spam)} />
<StatTile label="Virus" value={formatNumber(quarantine()?.virus)} />
<StatTile label="Attachment" value={formatNumber(quarantine()?.attachment)} />
<StatTile
label="Blacklisted"
value={formatNumber(quarantine()?.blacklisted)}
/>
</div>
</Show>
<div class="mt-3">
<h5 class="mb-1 text-[10px] uppercase tracking-wide text-muted">
Spam score distribution
</h5>
<Show
when={spamBuckets().length > 0}
fallback={<p class="text-xs text-muted">No spam score data.</p>}
>
<div class="flex flex-wrap gap-1">
<For each={spamBuckets()}>
{(bucket) => (
<span class="inline-flex items-center gap-1 rounded-sm bg-surface-alt px-1.5 py-0.5 text-[10px] font-mono tabular-nums">
<span class="text-muted">{bucket.score}</span>
<span class="text-base-content font-semibold">
{formatNumber(bucket.count)}
</span>
</span>
)}
</For>
</div>
</Show>
</div>
</Card>
</div>
<div class="grid gap-3 lg:grid-cols-2">
<Card padding="md">
<div class="mb-2 flex items-baseline justify-between">
<h4 class="text-xs font-semibold uppercase tracking-wide text-muted">
Top domains
</h4>
<span class="text-[10px] text-muted tabular-nums">
top {topDomains().length}
</span>
</div>
<Show
when={topDomains().length > 0}
fallback={<p class="text-xs text-muted">No domain stats reported.</p>}
>
<table class="w-full text-xs">
<thead class="text-[10px] uppercase tracking-wide text-muted">
<tr>
<th class="pb-2 text-left font-medium">Domain</th>
<th class="pb-2 text-right font-medium">Mail</th>
<th class="pb-2 text-right font-medium">Spam</th>
<th class="pb-2 text-right font-medium">Virus</th>
<th class="pb-2 text-right font-medium">Bytes</th>
</tr>
</thead>
<tbody class="divide-y divide-border-subtle">
<For each={topDomains()}>
{(domain) => (
<tr>
<td class="py-2 font-mono text-[11px] text-base-content truncate max-w-[14rem]" title={domain.domain}>
{domain.domain || '—'}
</td>
<td class="py-2 text-right text-base-content tabular-nums">
{formatNumber(domain.mailCount)}
</td>
<td class="py-2 text-right text-base-content tabular-nums">
{formatNumber(domain.spamCount)}
</td>
<td class="py-2 text-right text-base-content tabular-nums">
{formatNumber(domain.virusCount)}
</td>
<td class="py-2 text-right text-muted tabular-nums">
{domain.bytes && domain.bytes > 0
? formatBytes(domain.bytes)
: '—'}
</td>
</tr>
)}
</For>
</tbody>
</table>
</Show>
</Card>
<Card padding="md">
<div class="mb-2 flex items-baseline justify-between">
<h4 class="text-xs font-semibold uppercase tracking-wide text-muted">
Mail flow detail
</h4>
<Show when={stats()?.timeframe}>
<span class="text-[10px] text-muted">{stats()?.timeframe}</span>
</Show>
</div>
<Show
when={stats()}
fallback={<p class="text-xs text-muted">No mail stats reported.</p>}
>
<div class="grid grid-cols-2 gap-2 text-xs sm:grid-cols-3">
<StatTile label="Bounces in" value={formatNumber(stats()?.bouncesIn)} />
<StatTile label="Bounces out" value={formatNumber(stats()?.bouncesOut)} />
<StatTile label="Greylist" value={formatNumber(stats()?.greylistCount)} />
<StatTile label="Junk in" value={formatNumber(stats()?.junkIn)} />
<StatTile label="RBL rejects" value={formatNumber(stats()?.rblRejects)} />
<StatTile
label="Pregreet rejects"
value={formatNumber(stats()?.pregreetRejects)}
/>
<StatTile
label="Bytes in"
value={
stats()?.bytesIn && stats()!.bytesIn > 0
? formatBytes(stats()!.bytesIn)
: '—'
}
/>
<StatTile
label="Bytes out"
value={
stats()?.bytesOut && stats()!.bytesOut > 0
? formatBytes(stats()!.bytesOut)
: '—'
}
/>
<StatTile
label="Avg process"
value={
stats()?.averageProcessTimeMs
? `${Math.round(stats()!.averageProcessTimeMs)}ms`
: '—'
}
/>
</div>
</Show>
</Card>
</div>
<Show when={relayDomains().length > 0}>
<Card padding="md">
<h4 class="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">
Relay domains
</h4>
<div class="flex flex-wrap gap-1">
<For each={relayDomains()}>
{(rd) => (
<span
class="inline-flex items-center rounded-sm bg-surface-alt px-1.5 py-0.5 text-[10px] font-mono text-base-content"
title={rd.comment || rd.domain}
>
{rd.domain}
</span>
)}
</For>
</div>
</Card>
</Show>
</Show>
</Show>
</Show>
</div>
);
};
export default ProxmoxMailGatewayDrawer;

View file

@ -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<PlatformResourceStatusFilter>('all');
const [selectedId, setSelectedId] = createSignal<string | null>(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 (
<TableRow class="hover:bg-surface-hover">
<TableCell class="px-3 py-2">
<div class="flex items-center gap-2 min-w-0">
<StatusDot
size="sm"
variant={indicator().variant}
title={instance.status || 'unknown'}
ariaHidden
/>
<span class="font-semibold text-base-content truncate" title={name()}>
{name()}
</span>
</div>
</TableCell>
<TableCell class="px-3 py-2 text-base-content font-mono text-[11px]">
{version()}
</TableCell>
<TableCell class="px-3 py-2 text-right text-base-content tabular-nums">
{countCell(pmg()?.nodeCount)}
</TableCell>
<TableCell class="px-3 py-2 text-right text-base-content">
{formatUptime(instance.uptime ?? pmg()?.uptimeSeconds)}
</TableCell>
<TableCell class="px-3 py-2 text-right text-base-content">
{countCell(pmg()?.mailCountTotal)}
</TableCell>
<TableCell class="px-3 py-2 text-right text-base-content">
{countCell(pmg()?.spamIn)}
</TableCell>
<TableCell class="px-3 py-2 text-right text-base-content">
{countCell(pmg()?.virusIn)}
</TableCell>
<TableCell class="px-3 py-2 text-right text-base-content">
{countCell(pmg()?.quarantine)}
</TableCell>
<TableCell class="px-3 py-2 text-right text-base-content">
{countCell(pmg()?.queueTotal ?? pmg()?.queueActive)}
</TableCell>
<TableCell class="px-3 py-2 text-right text-base-content">
{countCell(pmg()?.queueDeferred)}
</TableCell>
</TableRow>
<>
<TableRow
class={`cursor-pointer hover:bg-surface-hover ${
isOpen() ? 'bg-surface-hover' : ''
}`}
onClick={() => toggleSelected(instance.id)}
aria-expanded={isOpen()}
>
<TableCell class="px-3 py-2">
<div class="flex items-center gap-2 min-w-0">
<StatusDot
size="sm"
variant={indicator().variant}
title={instance.status || 'unknown'}
ariaHidden
/>
<span class="font-semibold text-base-content truncate" title={name()}>
{name()}
</span>
</div>
</TableCell>
<TableCell class="px-3 py-2 text-base-content font-mono text-[11px]">
{version()}
</TableCell>
<TableCell class="px-3 py-2 text-right text-base-content tabular-nums">
{countCell(pmg()?.nodeCount)}
</TableCell>
<TableCell class="px-3 py-2 text-right text-base-content">
{formatUptime(instance.uptime ?? pmg()?.uptimeSeconds)}
</TableCell>
<TableCell class="px-3 py-2 text-right text-base-content">
{countCell(pmg()?.mailCountTotal)}
</TableCell>
<TableCell class="px-3 py-2 text-right text-base-content">
{countCell(pmg()?.spamIn)}
</TableCell>
<TableCell class="px-3 py-2 text-right text-base-content">
{countCell(pmg()?.virusIn)}
</TableCell>
<TableCell class="px-3 py-2 text-right text-base-content">
{countCell(pmg()?.quarantine)}
</TableCell>
<TableCell class="px-3 py-2 text-right text-base-content">
{countCell(pmg()?.queueTotal ?? pmg()?.queueActive)}
</TableCell>
<TableCell class="px-3 py-2 text-right text-base-content">
{countCell(pmg()?.queueDeferred)}
</TableCell>
</TableRow>
<Show when={isOpen()}>
<TableRow data-inline-detail-for={instance.id}>
<TableCell
colspan={10}
class="p-0 border-b border-border bg-surface-alt"
>
<div
class="px-4 py-4"
onClick={(event) => event.stopPropagation()}
>
<ProxmoxMailGatewayDrawer
instanceRow={instance}
onClose={() => setSelectedId(null)}
/>
</div>
</TableCell>
</TableRow>
</Show>
</>
);
}}
</For>

88
internal/api/pmg.go Normal file
View file

@ -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)))
}

View file

@ -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)))