From 0ddc30c1e150fa875c936e5e645d58fa195b17d8 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 13 Dec 2025 00:08:29 +0000 Subject: [PATCH] Redesign Ceph, Replication, and Mail Gateway pages for UI consistency - Ceph: Complete rework with summary cards (storage, clusters, services, pools), search with type-to-search, proper loading/disconnected/empty states, cluster overview table, pools table with filtering, health badges with tooltips - Replication: Add summary cards (total/healthy/issues/next sync), status filter buttons (All/Healthy/Warning/Error), search with type-to-search, countdown timers for next sync with overdue/imminent highlighting, row highlighting for problem jobs, error tooltips, loading state - Mail Gateway: Add aggregate summary cards across all instances (mail/spam/virus/ quarantine/queue), visual threat rate progress bars, status badges with health indicators, queue depth tooltips with breakdown, streamlined stats grid with color-coded threat categories, improved cluster nodes table All three pages now follow the same design patterns as Dashboard, Hosts, Docker, and Storage using Card, EmptyState, and consistent styling. --- frontend-modern/src/api/ai.ts | 6 +- frontend-modern/src/components/AI/AIChat.tsx | 3 + .../src/components/Dashboard/Dashboard.tsx | 4 +- .../src/components/Dashboard/GuestRow.tsx | 21 +- .../src/components/Hosts/HostsOverview.tsx | 22 +- .../Kubernetes/KubernetesClusters.tsx | 8 - .../src/components/PMG/MailGateway.tsx | 1084 +++++++++++------ .../components/Replication/Replication.tsx | 676 +++++++--- .../src/hooks/useColumnVisibility.ts | 3 +- frontend-modern/src/pages/Ceph.tsx | 849 ++++++++----- frontend-modern/src/stores/websocket.ts | 6 +- 11 files changed, 1826 insertions(+), 856 deletions(-) diff --git a/frontend-modern/src/api/ai.ts b/frontend-modern/src/api/ai.ts index d7060f2..5c3e690 100644 --- a/frontend-modern/src/api/ai.ts +++ b/frontend-modern/src/api/ai.ts @@ -210,7 +210,7 @@ export class AIAPI { let lastEventTime = Date.now(); try { - while (true) { + for (;;) { if (Date.now() - lastEventTime > STREAM_TIMEOUT_MS) { console.warn('[AI] Alert investigation stream timeout'); break; @@ -305,7 +305,7 @@ export class AIAPI { logger.debug('[AI SSE] Starting to read stream'); try { - while (true) { + for (;;) { // Check for stream timeout if (Date.now() - lastEventTime > STREAM_TIMEOUT_MS) { logger.warn('[AI SSE] Stream timeout', { seconds: STREAM_TIMEOUT_MS / 1000 }); @@ -397,7 +397,7 @@ export class AIAPI { if (data.type === 'complete') receivedComplete = true; if (data.type === 'done') receivedDone = true; } - } catch (e) { + } catch { logger.warn('[AI SSE] Could not parse remaining buffer', { preview: buffer.substring(0, 100) }); } } diff --git a/frontend-modern/src/components/AI/AIChat.tsx b/frontend-modern/src/components/AI/AIChat.tsx index 0b0857d..28b87f1 100644 --- a/frontend-modern/src/components/AI/AIChat.tsx +++ b/frontend-modern/src/components/AI/AIChat.tsx @@ -1156,8 +1156,10 @@ export const AIChat: Component = (props) => { {/* Content chunk - rendered as markdown */} + {/* DOMPurify sanitizes LLM output in renderMarkdown before HTML rendering. */}
@@ -1191,6 +1193,7 @@ export const AIChat: Component = (props) => {
diff --git a/frontend-modern/src/components/Dashboard/Dashboard.tsx b/frontend-modern/src/components/Dashboard/Dashboard.tsx index ac8215a..130f372 100644 --- a/frontend-modern/src/components/Dashboard/Dashboard.tsx +++ b/frontend-modern/src/components/Dashboard/Dashboard.tsx @@ -1122,9 +1122,9 @@ export function Dashboard(props: DashboardProps) { onClick={() => isSortable && handleSort(sortKeyForCol!)} title={col.icon ? col.label : undefined} > -
+
{col.icon ? ( - + {col.icon} ) : ( col.label )} diff --git a/frontend-modern/src/components/Dashboard/GuestRow.tsx b/frontend-modern/src/components/Dashboard/GuestRow.tsx index f4bd630..e59648e 100644 --- a/frontend-modern/src/components/Dashboard/GuestRow.tsx +++ b/frontend-modern/src/components/Dashboard/GuestRow.tsx @@ -1,4 +1,5 @@ import { createMemo, createSignal, createEffect, Show, For } from 'solid-js'; +import type { JSX } from 'solid-js'; import { Portal } from 'solid-js/web'; import type { VM, Container, GuestNetworkInterface } from '@/types/api'; import { formatBytes, formatUptime, formatSpeed, getBackupInfo, type BackupStatus, formatPercent } from '@/utils/format'; @@ -411,7 +412,7 @@ function BackupStatusCell(props: { lastBackup: string | number | null | undefine export interface GuestColumnDef { id: string; label: string; - icon?: string; // SVG icon markup for compact column headers + icon?: JSX.Element; // SVG icon for compact column headers priority: ColumnPriority; toggleable?: boolean; width?: string; // Fixed width for consistent column sizing @@ -433,20 +434,20 @@ export const GUEST_COLUMNS: GuestColumnDef[] = [ { id: 'disk', label: 'Disk', priority: 'essential', width: '140px', sortKey: 'disk' }, // Secondary - visible on md+ (768px), user toggleable - use icons - { id: 'ip', label: 'IP', icon: '', priority: 'secondary', width: '45px', toggleable: true }, - { id: 'uptime', label: 'Uptime', icon: '', priority: 'secondary', width: '60px', toggleable: true, sortKey: 'uptime' }, - { id: 'node', label: 'Node', icon: '', priority: 'secondary', width: '55px', toggleable: true, sortKey: 'node' }, + { id: 'ip', label: 'IP', icon: , priority: 'secondary', width: '45px', toggleable: true }, + { id: 'uptime', label: 'Uptime', icon: , priority: 'secondary', width: '60px', toggleable: true, sortKey: 'uptime' }, + { id: 'node', label: 'Node', icon: , priority: 'secondary', width: '55px', toggleable: true, sortKey: 'node' }, // Supplementary - visible on lg+ (1024px), user toggleable - { id: 'backup', label: 'Backup', icon: '', priority: 'supplementary', width: '50px', toggleable: true }, - { id: 'tags', label: 'Tags', icon: '', priority: 'supplementary', width: '60px', toggleable: true }, + { id: 'backup', label: 'Backup', icon: , priority: 'supplementary', width: '50px', toggleable: true }, + { id: 'tags', label: 'Tags', icon: , priority: 'supplementary', width: '60px', toggleable: true }, // Detailed - visible on xl+ (1280px), user toggleable { id: 'os', label: 'OS', priority: 'detailed', width: '45px', toggleable: true }, - { id: 'diskRead', label: 'D Read', icon: '', priority: 'detailed', width: '55px', toggleable: true, sortKey: 'diskRead' }, - { id: 'diskWrite', label: 'D Write', icon: '', priority: 'detailed', width: '55px', toggleable: true, sortKey: 'diskWrite' }, - { id: 'netIn', label: 'Net In', icon: '', priority: 'detailed', width: '55px', toggleable: true, sortKey: 'networkIn' }, - { id: 'netOut', label: 'Net Out', icon: '', priority: 'detailed', width: '55px', toggleable: true, sortKey: 'networkOut' }, + { id: 'diskRead', label: 'D Read', icon: , priority: 'detailed', width: '55px', toggleable: true, sortKey: 'diskRead' }, + { id: 'diskWrite', label: 'D Write', icon: , priority: 'detailed', width: '55px', toggleable: true, sortKey: 'diskWrite' }, + { id: 'netIn', label: 'Net In', icon: , priority: 'detailed', width: '55px', toggleable: true, sortKey: 'networkIn' }, + { id: 'netOut', label: 'Net Out', icon: , priority: 'detailed', width: '55px', toggleable: true, sortKey: 'networkOut' }, ]; interface GuestRowProps { diff --git a/frontend-modern/src/components/Hosts/HostsOverview.tsx b/frontend-modern/src/components/Hosts/HostsOverview.tsx index 844d4bf..74884dd 100644 --- a/frontend-modern/src/components/Hosts/HostsOverview.tsx +++ b/frontend-modern/src/components/Hosts/HostsOverview.tsx @@ -1,4 +1,4 @@ -import type { Component } from 'solid-js'; +import type { Component, JSX } from 'solid-js'; import { For, Show, createMemo, createSignal, createEffect, onMount, onCleanup } from 'solid-js'; import { Portal } from 'solid-js/web'; import { useNavigate } from '@solidjs/router'; @@ -26,7 +26,7 @@ import { logger } from '@/utils/logger'; export interface HostColumnDef { id: string; label: string; - icon?: string; + icon?: JSX.Element; priority: ColumnPriority; toggleable?: boolean; width?: string; @@ -43,13 +43,13 @@ export const HOST_COLUMNS: HostColumnDef[] = [ { id: 'disk', label: 'Disk', priority: 'essential', width: '140px', sortKey: 'disk' }, // Secondary - visible on md+, toggleable - { id: 'temp', label: 'Temp', icon: '', priority: 'secondary', width: '50px', toggleable: true }, - { id: 'uptime', label: 'Uptime', icon: '', priority: 'secondary', width: '65px', toggleable: true, sortKey: 'uptime' }, + { id: 'temp', label: 'Temp', icon: , priority: 'secondary', width: '50px', toggleable: true }, + { id: 'uptime', label: 'Uptime', icon: , priority: 'secondary', width: '65px', toggleable: true, sortKey: 'uptime' }, { id: 'agent', label: 'Agent', priority: 'secondary', width: '60px', toggleable: true }, // Supplementary - visible on lg+, toggleable // Note: CPU count and load average removed - they're shown in the EnhancedCPUBar tooltip - { id: 'ip', label: 'IP', icon: '', priority: 'supplementary', width: '50px', toggleable: true }, + { id: 'ip', label: 'IP', icon: , priority: 'supplementary', width: '50px', toggleable: true }, // Detailed - visible on xl+, toggleable { id: 'arch', label: 'Arch', priority: 'detailed', width: '55px', toggleable: true }, @@ -468,11 +468,7 @@ function HostRAIDStatusCell(props: HostRAIDStatusCellProps) { } type SortKey = 'name' | 'platform' | 'cpu' | 'memory' | 'disk' | 'uptime'; - -// eslint-disable-next-line @typescript-eslint/no-empty-interface -interface HostsOverviewProps { } - -export const HostsOverview: Component = () => { +export const HostsOverview: Component = () => { const navigate = useNavigate(); const wsContext = useWebSocket(); const [search, setSearch] = createSignal(''); @@ -821,13 +817,13 @@ export const HostsOverview: Component = () => { {/* Secondary columns */} - c.id === 'temp')?.icon} /> + {HOST_COLUMNS.find(c => c.id === 'temp')?.icon} handleSort('uptime')} title="Uptime"> - c.id === 'uptime')?.icon} /> + {HOST_COLUMNS.find(c => c.id === 'uptime')?.icon} {renderSortIndicator('uptime')} @@ -839,7 +835,7 @@ export const HostsOverview: Component = () => { {/* Supplementary columns */} - c.id === 'ip')?.icon} /> + {HOST_COLUMNS.find(c => c.id === 'ip')?.icon} diff --git a/frontend-modern/src/components/Kubernetes/KubernetesClusters.tsx b/frontend-modern/src/components/Kubernetes/KubernetesClusters.tsx index df63485..f768530 100644 --- a/frontend-modern/src/components/Kubernetes/KubernetesClusters.tsx +++ b/frontend-modern/src/components/Kubernetes/KubernetesClusters.tsx @@ -441,14 +441,6 @@ export const KubernetesClusters: Component = (props) => return (
- {/* Header */} -
-

Kubernetes

-

- Cluster health from the unified agent Kubernetes module. -

-
- {/* Filter Bar */}
diff --git a/frontend-modern/src/components/PMG/MailGateway.tsx b/frontend-modern/src/components/PMG/MailGateway.tsx index ea24f38..131de10 100644 --- a/frontend-modern/src/components/PMG/MailGateway.tsx +++ b/frontend-modern/src/components/PMG/MailGateway.tsx @@ -1,9 +1,18 @@ -import { Component, Show, For, createMemo } from 'solid-js'; +import { Component, Show, For, createMemo, createSignal, createEffect, onCleanup } from 'solid-js'; +import { Portal } from 'solid-js/web'; import { useWebSocket } from '@/App'; import { ProxmoxSectionNav } from '@/components/Proxmox/ProxmoxSectionNav'; import { Card } from '@/components/shared/Card'; import { EmptyState } from '@/components/shared/EmptyState'; -import { formatRelativeTime } from '@/utils/format'; +import { formatRelativeTime, formatBytes } from '@/utils/format'; + +// Format large numbers with K/M suffixes +const formatCompact = (value?: number | null): string => { + if (value === undefined || value === null || Number.isNaN(value)) return '—'; + if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`; + if (value >= 1000) return `${(value / 1000).toFixed(1)}K`; + return Math.round(value).toLocaleString(); +}; const formatNum = (value?: number | null) => { if (value === undefined || value === null || Number.isNaN(value)) return '—'; @@ -15,21 +24,280 @@ const formatDec = (value?: number | null, digits = 1) => { return value.toFixed(digits); }; +const formatPct = (value?: number | null) => { + if (value === undefined || value === null || Number.isNaN(value)) return '0%'; + return `${value.toFixed(1)}%`; +}; + const parseTimestamp = (value?: string) => { if (!value) return undefined; const time = Date.parse(value); return Number.isNaN(time) ? undefined : time; }; -const MailGateway: Component = () => { - const { state, connected, reconnecting, reconnect } = useWebSocket(); - const instances = createMemo(() => state.pmg ?? []); +// Threat rate bar component +const ThreatBar: Component<{ + percent: number; + color: 'spam' | 'virus' | 'quarantine'; + label: string; + count: number; +}> = (props) => { + const barColor = () => { + switch (props.color) { + case 'spam': return 'bg-orange-500'; + case 'virus': return 'bg-red-500'; + case 'quarantine': return 'bg-yellow-500'; + } + }; + + const textColor = () => { + switch (props.color) { + case 'spam': return 'text-orange-600 dark:text-orange-400'; + case 'virus': return 'text-red-600 dark:text-red-400'; + case 'quarantine': return 'text-yellow-600 dark:text-yellow-400'; + } + }; return ( -
+
+
+ {props.label} + + {formatCompact(props.count)} ({formatPct(props.percent)}) + +
+
+
+
+
+ ); +}; + +// Instance status badge +const StatusBadge: Component<{ status: string; health?: string }> = (props) => { + const statusInfo = createMemo(() => { + const status = (props.health || props.status || '').toLowerCase(); + if (status.includes('healthy') || status === 'online') { + return { bg: 'bg-green-100 dark:bg-green-900/30', text: 'text-green-700 dark:text-green-400', dot: 'bg-green-500', label: 'Healthy' }; + } + if (status.includes('degraded') || status.includes('warning')) { + return { bg: 'bg-yellow-100 dark:bg-yellow-900/30', text: 'text-yellow-700 dark:text-yellow-400', dot: 'bg-yellow-500', label: 'Degraded' }; + } + if (status.includes('error') || status === 'offline') { + return { bg: 'bg-red-100 dark:bg-red-900/30', text: 'text-red-700 dark:text-red-400', dot: 'bg-red-500', label: 'Offline' }; + } + return { bg: 'bg-gray-100 dark:bg-gray-800', text: 'text-gray-600 dark:text-gray-400', dot: 'bg-gray-400', label: status || 'Unknown' }; + }); + + return ( + + + {statusInfo().label} + + ); +}; + +// Queue depth indicator with tooltip +const QueueIndicator: Component<{ queue?: { total: number; active?: number; deferred?: number; hold?: number; incoming?: number; oldestAge?: number } }> = (props) => { + const [showTooltip, setShowTooltip] = createSignal(false); + const [tooltipPos, setTooltipPos] = createSignal({ x: 0, y: 0 }); + + const queueTotal = () => props.queue?.total || 0; + const hasQueue = () => queueTotal() > 0; + + const queueSeverity = () => { + const total = queueTotal(); + if (total > 100) return 'high'; + if (total > 20) return 'medium'; + return 'low'; + }; + + const handleMouseEnter = (e: MouseEvent) => { + if (!props.queue) return; + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + setTooltipPos({ x: rect.left + rect.width / 2, y: rect.top }); + setShowTooltip(true); + }; + + return ( + <> +
setShowTooltip(false)} + > + Empty + }> + + {formatNum(queueTotal())} msgs + + +
+ + + +
+
+
Queue Breakdown
+
+
Active{props.queue?.active || 0}
+
Deferred{props.queue?.deferred || 0}
+
Hold{props.queue?.hold || 0}
+
Incoming{props.queue?.incoming || 0}
+ 0}> +
+ Oldest + 1800 ? 'text-yellow-400' : ''}> + {Math.floor((props.queue?.oldestAge || 0) / 60)}m + +
+
+
+
+
+
+
+ + ); +}; + +const MailGateway: Component = () => { + const { state, connected, reconnecting, reconnect, initialDataReceived } = useWebSocket(); + + const [searchTerm, setSearchTerm] = createSignal(''); + let searchInputRef: HTMLInputElement | undefined; + + // Keyboard handler for type-to-search + createEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const target = e.target as HTMLElement; + const isInputField = + target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.tagName === 'SELECT' || + target.contentEditable === 'true'; + + if (e.key === 'Escape') { + if (searchTerm().trim()) { + setSearchTerm(''); + searchInputRef?.blur(); + } + } else if (!isInputField && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { + if (searchInputRef) { + searchInputRef.focus(); + } + } + }; + + document.addEventListener('keydown', handleKeyDown); + onCleanup(() => document.removeEventListener('keydown', handleKeyDown)); + }); + + const instances = createMemo(() => state.pmg ?? []); + const hasInstances = createMemo(() => instances().length > 0); + + // Filter instances by search + const filteredInstances = createMemo(() => { + const term = searchTerm().toLowerCase().trim(); + if (!term) return instances(); + return instances().filter(pmg => + pmg.name.toLowerCase().includes(term) || + (pmg.host || '').toLowerCase().includes(term) || + (pmg.version || '').toLowerCase().includes(term) + ); + }); + + // Aggregate stats across all instances + const aggregateStats = createMemo(() => { + const pmgs = instances(); + let totalMail = 0; + let totalSpam = 0; + let totalVirus = 0; + let totalQuarantine = 0; + let totalQueue = 0; + let healthyCount = 0; + let issueCount = 0; + + for (const pmg of pmgs) { + totalMail += pmg.mailStats?.countTotal || 0; + totalSpam += pmg.mailStats?.spamIn || 0; + totalVirus += pmg.mailStats?.virusIn || 0; + + const q = pmg.quarantine; + if (q) { + totalQuarantine += (q.spam || 0) + (q.virus || 0) + (q.attachment || 0) + (q.blacklisted || 0); + } + + // Sum queue across all nodes + if (pmg.nodes) { + for (const node of pmg.nodes) { + totalQueue += node.queueStatus?.total || 0; + } + } + + const status = (pmg.connectionHealth || pmg.status || '').toLowerCase(); + if (status.includes('healthy') || status === 'online') { + healthyCount++; + } else { + issueCount++; + } + } + + const spamRate = totalMail > 0 ? (totalSpam / totalMail) * 100 : 0; + const virusRate = totalMail > 0 ? (totalVirus / totalMail) * 100 : 0; + + return { + totalMail, + totalSpam, + totalVirus, + totalQuarantine, + totalQueue, + spamRate, + virusRate, + healthyCount, + issueCount, + instanceCount: pmgs.length, + }; + }); + + const isLoading = createMemo(() => connected() && !initialDataReceived()); + + return ( +
- + {/* Loading State */} + + + + + + + } + title="Loading mail gateway data..." + description="Connecting to the monitoring service." + /> + + + + {/* Disconnected State */} + { } title="Connection lost" - description={ - reconnecting() - ? 'Attempting to reconnect…' - : 'Unable to connect to the backend server' - } + description={reconnecting() ? 'Attempting to reconnect…' : 'Unable to connect to the backend server'} tone="danger" actions={ !reconnecting() ? ( @@ -58,430 +322,438 @@ const MailGateway: Component = () => { - - + + {/* No Instances Empty State */} + - + + } title="No Mail Gateways configured" - description="Add a Proxmox Mail Gateway via Settings → Nodes to start collecting mail analytics." + description="Add a Proxmox Mail Gateway via Settings → Nodes to start collecting mail analytics and security metrics." /> - - {(pmg) => { - const statusTone = (pmg.connectionHealth || pmg.status || '').toLowerCase(); - const isHealthy = statusTone.includes('healthy') || (pmg.status || '').toLowerCase() === 'online'; - const isWarning = statusTone.includes('degraded') || statusTone.includes('warning'); - const dotClass = isHealthy ? 'bg-green-500' : isWarning ? 'bg-amber-500' : 'bg-red-500'; - - const lastUpdated = () => { - const ts = parseTimestamp(pmg.mailStats?.updatedAt) ?? parseTimestamp(pmg.lastUpdated) ?? parseTimestamp(pmg.lastSeen); - return ts ? formatRelativeTime(ts) : '—'; - }; - - const total = pmg.mailStats?.countTotal || 0; - const inbound = pmg.mailStats?.countIn || 0; - const outbound = pmg.mailStats?.countOut || 0; - const spam = pmg.mailStats?.spamIn || 0; - const virus = pmg.mailStats?.virusIn || 0; - const bouncesIn = pmg.mailStats?.bouncesIn || 0; - const bouncesOut = pmg.mailStats?.bouncesOut || 0; - const bytesIn = pmg.mailStats?.bytesIn || 0; - const bytesOut = pmg.mailStats?.bytesOut || 0; - const rbl = pmg.mailStats?.rblRejects || 0; - const pregreet = pmg.mailStats?.pregreetRejects || 0; - const greylist = pmg.mailStats?.greylistCount || 0; - const junk = pmg.mailStats?.junkIn || 0; - - const qSpam = pmg.quarantine?.spam || 0; - const qVirus = pmg.quarantine?.virus || 0; - const qAttachment = pmg.quarantine?.attachment || 0; - const qBlacklist = pmg.quarantine?.blacklisted || 0; - const qTotal = qSpam + qVirus + qAttachment + qBlacklist; - - const spamPct = (spam / Math.max(total, 1)) * 100; - const virusPct = (virus / Math.max(total, 1)) * 100; - const inboundPct = (inbound / Math.max(total, 1)) * 100; - const outboundPct = (outbound / Math.max(total, 1)) * 100; - const rblPct = (rbl / Math.max(inbound, 1)) * 100; - - return ( - - {/* Instance Header Strip */} -
-
- - - {pmg.name} - - v{pmg.version || '?'} - - {pmg.status || 'unknown'} + {/* Has Instances - Show Content */} + + {/* Summary Cards */} +
+ {/* Total Mail */} + +
+
+
Mail (24h)
+
+ {formatCompact(aggregateStats().totalMail)}
- Updated {lastUpdated()} + ~{formatNum(Math.round(aggregateStats().totalMail / 24))}/hr
+
+ + + +
+
+
- {/* 3-Column Detail Grid */} -
-
- {/* LEFT COLUMN: Mail Flow + Traffic Volume */} -
- {/* Mail Flow Table */} -
-
-

- Mail Flow (24h) -

+ {/* Spam Rate */} + 50 ? 'warning' : 'glass'}> +
+
+
Spam
+
50 ? 'text-orange-600 dark:text-orange-400' : 'text-gray-900 dark:text-gray-100'}`}> + {formatPct(aggregateStats().spamRate)} +
+
+ {formatCompact(aggregateStats().totalSpam)} caught +
+
+
50 ? 'bg-orange-100 dark:bg-orange-900/30' : 'bg-orange-100 dark:bg-orange-900/30'}`}> + + + +
+
+
+ + {/* Virus Blocked */} + 0 ? 'danger' : 'glass'}> +
+
+
Viruses
+
0 ? 'text-red-600 dark:text-red-400' : 'text-gray-900 dark:text-gray-100'}`}> + {formatCompact(aggregateStats().totalVirus)} +
+
+ blocked today +
+
+
0 ? 'bg-red-100 dark:bg-red-900/30' : 'bg-gray-100 dark:bg-gray-800'}`}> + 0 ? 'text-red-600 dark:text-red-400' : 'text-gray-400'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"> + + +
+
+
+ + {/* Quarantine */} + +
+
+
Quarantine
+
+ {formatCompact(aggregateStats().totalQuarantine)} +
+
+ items held +
+
+
+ + + +
+
+
+ + {/* Queue Status */} + 50 ? 'warning' : 'glass'}> +
+
+
Queue
+
100 ? 'text-red-600 dark:text-red-400' : + aggregateStats().totalQueue > 20 ? 'text-yellow-600 dark:text-yellow-400' : + 'text-green-600 dark:text-green-400' + }`}> + {formatNum(aggregateStats().totalQueue)} +
+
+ {aggregateStats().totalQueue === 0 ? 'all clear' : 'pending'} +
+
+
50 ? 'bg-yellow-100 dark:bg-yellow-900/30' : 'bg-green-100 dark:bg-green-900/30' + }`}> + 50 ? 'text-yellow-600 dark:text-yellow-400' : 'text-green-600 dark:text-green-400' + }`} fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"> + + +
+
+
+
+ + {/* Search Bar (only show if multiple instances) */} + 1}> + +
+ (searchInputRef = el)} + type="text" + placeholder="Search gateways..." + value={searchTerm()} + onInput={(e) => setSearchTerm(e.currentTarget.value)} + class="w-full pl-9 pr-8 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg + bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500 + focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 outline-none transition-all" + /> + + + + + + +
+
+
+ + {/* Instance Cards */} + 0} + fallback={ + +
+ + + +
No gateways match "{searchTerm()}"
+ +
+
+ } + > +
+ + {(pmg) => { + const lastUpdated = () => { + const ts = parseTimestamp(pmg.mailStats?.updatedAt) ?? parseTimestamp(pmg.lastUpdated) ?? parseTimestamp(pmg.lastSeen); + return ts ? formatRelativeTime(ts) : '—'; + }; + + const stats = createMemo(() => { + const m = pmg.mailStats; + const total = m?.countTotal || 0; + const inbound = m?.countIn || 0; + const outbound = m?.countOut || 0; + const spam = m?.spamIn || 0; + const virus = m?.virusIn || 0; + const bytesIn = m?.bytesIn || 0; + const bytesOut = m?.bytesOut || 0; + const rbl = m?.rblRejects || 0; + const pregreet = m?.pregreetRejects || 0; + const greylist = m?.greylistCount || 0; + const bouncesIn = m?.bouncesIn || 0; + const bouncesOut = m?.bouncesOut || 0; + + const q = pmg.quarantine; + const qSpam = q?.spam || 0; + const qVirus = q?.virus || 0; + const qAttachment = q?.attachment || 0; + const qBlacklist = q?.blacklisted || 0; + const qTotal = qSpam + qVirus + qAttachment + qBlacklist; + + const spamPct = total > 0 ? (spam / total) * 100 : 0; + const virusPct = total > 0 ? (virus / total) * 100 : 0; + const quarantinePct = inbound > 0 ? (qTotal / inbound) * 100 : 0; + + return { + total, inbound, outbound, spam, virus, bytesIn, bytesOut, + rbl, pregreet, greylist, bouncesIn, bouncesOut, + qSpam, qVirus, qAttachment, qBlacklist, qTotal, + spamPct, virusPct, quarantinePct, + }; + }); + + // Get aggregate queue for this instance + const instanceQueue = createMemo(() => { + if (!pmg.nodes) return { total: 0 }; + let total = 0, active = 0, deferred = 0, hold = 0, incoming = 0, oldestAge = 0; + for (const node of pmg.nodes) { + const q = node.queueStatus; + if (q) { + total += q.total || 0; + active += q.active || 0; + deferred += q.deferred || 0; + hold += q.hold || 0; + incoming += q.incoming || 0; + if ((q.oldestAge || 0) > oldestAge) oldestAge = q.oldestAge || 0; + } + } + return { total, active, deferred, hold, incoming, oldestAge }; + }); + + return ( + + {/* Instance Header */} +
+
+ + {pmg.name} + + + + + v{pmg.version} + + +
+
+ + Updated {lastUpdated()}
- - - - - - - - - - - - - - - - - - - - - - - -
-
{formatNum(total)}
-
{formatDec(total / 24)}/hr
-
Total processed
-
{formatNum(inbound)}
-
- {formatDec(inbound / 24)}/hr · {formatDec(inboundPct)}% of total -
-
Inbound
-
{formatNum(outbound)}
-
- {formatDec(outbound / 24)}/hr · {formatDec(outboundPct)}% of total -
-
Outbound
-
{formatNum(spam)}
-
- {formatDec(spam / 24)}/hr · {formatDec(spamPct)}% rate -
-
Spam caught
-
{formatNum(virus)}
-
- {formatDec(virus / 24)}/hr · {formatDec(virusPct)}% rate -
-
Viruses blocked
- {/* Traffic Volume Table */} -
-
-

- Traffic Volume -

+ {/* Main Content */} +
+ {/* Threat Rate Bars */} +
+ + +
- - - - - - - - - - - -
-
{formatDec(bytesIn / 1024 / 1024)} MB
-
- {formatDec(bytesIn / 1024 / 1024 / 24)} MB/hr avg -
-
Inbound bytes
-
{formatDec(bytesOut / 1024 / 1024)} MB
-
- {formatDec(bytesOut / 1024 / 1024 / 24)} MB/hr avg -
-
Outbound bytes
-
-
- {/* CENTER COLUMN: Threat/Quarantine + Spam Distribution */} -
- {/* Threat/Quarantine Table */} -
-
-

- Threat & Quarantine -

+ {/* Stats Grid */} +
+ {/* Mail Flow */} +
+
Total (24h)
+
{formatCompact(stats().total)}
+
{formatNum(Math.round(stats().total / 24))}/hr
+
+
+
Inbound
+
{formatCompact(stats().inbound)}
+
{formatBytes(stats().bytesIn)}
+
+
+
Outbound
+
{formatCompact(stats().outbound)}
+
{formatBytes(stats().bytesOut)}
+
+ + {/* Threats */} +
+
Spam
+
{formatCompact(stats().spam)}
+
{formatPct(stats().spamPct)} rate
+
+
+
Viruses
+
{formatCompact(stats().virus)}
+
{formatPct(stats().virusPct)} rate
+
+
+
Quarantine
+
{formatCompact(stats().qTotal)}
+
{formatPct(stats().quarantinePct)} of inbound
+
- - - - - - - - - - - - - - - - - - - - - - - -
-
{formatNum(qSpam)}
-
- {formatDec((qSpam / Math.max(qTotal, 1)) * 100)}% of quarantine -
-
Spam quarantined
-
{formatNum(qVirus)}
-
- {formatDec((qVirus / Math.max(qTotal, 1)) * 100)}% of quarantine -
-
Virus quarantined
-
{formatNum(qAttachment)}
-
- {formatDec((qAttachment / Math.max(qTotal, 1)) * 100)}% of quarantine -
-
Attachments blocked
-
{formatNum(qBlacklist)}
-
- {formatDec((qBlacklist / Math.max(qTotal, 1)) * 100)}% of quarantine -
-
Blacklisted
-
{formatNum(qTotal)}
-
- {formatDec(qTotal / 24)}/hr · {formatDec((qTotal / Math.max(inbound, 1)) * 100)}% of inbound -
-
Total quarantined
-
- {/* Spam Score Distribution */} - 0}> - {(() => { - const totalSpamScored = pmg.spamDistribution?.reduce((sum, b) => sum + b.count, 0) || 1; - return ( -
-
-

- Spam Score Distribution -

+ {/* Delivery Health Row */} +
+
Delivery Health
+
+
+ RBL Rejects: + {formatNum(stats().rbl)} +
+
+ Pregreet: + {formatNum(stats().pregreet)} +
+
+ Greylisted: + {formatNum(stats().greylist)} +
+
+ Bounces In/Out: + {formatNum(stats().bouncesIn)}/{formatNum(stats().bouncesOut)} +
+ +
+ Avg Process: + {formatDec((pmg.mailStats?.averageProcessTimeMs || 0) / 1000, 2)}s
-
-
- - {(bucket) => { - const pct = (bucket.count / totalSpamScored) * 100; + +
+
+ + {/* Cluster Nodes */} + 0}> +
+
+ Cluster Nodes ({pmg.nodes?.length}) +
+
+ + + + + + + + + + + + + {(node) => { + const isOnline = (node.status || '').toLowerCase() === 'online'; + return ( -
-
{bucket.score}
-
{formatNum(bucket.count)}
-
{formatDec(pct)}%
-
+
+ + + + + + ); }} - - + +
NodeStatusUptimeLoadQueue
{node.name} + + + {node.status || 'unknown'} + + + + {Math.floor((node.uptime ?? 0) / 86400)}d {Math.floor(((node.uptime ?? 0) % 86400) / 3600)}h + + {node.loadAvg || '—'} + +
- ); - })()} - -
+
+ - {/* RIGHT COLUMN: Delivery Health */} -
-
-
-

- Delivery Health -

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
{formatNum(bouncesIn)}
-
- {formatDec(bouncesIn / 24)}/hr · {formatDec((bouncesIn / Math.max(inbound, 1)) * 100)}% of inbound -
-
Bounces inbound
-
{formatNum(bouncesOut)}
-
- {formatDec(bouncesOut / 24)}/hr · {formatDec((bouncesOut / Math.max(outbound, 1)) * 100)}% of outbound -
-
Bounces outbound
-
{formatNum(rbl)}
-
- {formatDec(rbl / 24)}/hr · {formatDec(rblPct)}% of inbound -
-
RBL rejects
-
{formatNum(pregreet)}
-
- {formatDec(pregreet / 24)}/hr · {formatDec((pregreet / Math.max(inbound, 1)) * 100)}% of inbound -
-
Pregreet rejects
-
{formatNum(greylist)}
-
- {formatDec(greylist / 24)}/hr · {formatDec((greylist / Math.max(inbound, 1)) * 100)}% of inbound -
-
Greylisted
-
{formatNum(junk)}
-
- {formatDec(junk / 24)}/hr · {formatDec((junk / Math.max(inbound, 1)) * 100)}% of inbound -
-
Junk mail
-
- {pmg.mailStats?.averageProcessTimeMs ? formatDec(pmg.mailStats.averageProcessTimeMs / 1000, 2) : '—'} s -
-
- ~{pmg.mailStats?.averageProcessTimeMs ? formatNum(60000 / pmg.mailStats.averageProcessTimeMs) : '—'} msg/min -
-
Avg process time
+ {/* Spam Distribution */} + 0}> +
+
Spam Score Distribution
+
+ + {(bucket) => { + const totalScored = pmg.spamDistribution?.reduce((sum, b) => sum + b.count, 0) || 1; + const pct = (bucket.count / totalScored) * 100; + return ( +
+
{bucket.score}
+
{formatCompact(bucket.count)}
+
{formatDec(pct)}%
+
+ ); + }} +
+
+
+
-
-
- - {/* FULL-WIDTH: Cluster Nodes */} - 0}> -
-
-

- Cluster Nodes -

-
-
- - - - - - - - - - - - - - - {(node) => { - const nodeStatusClass = (node.status || '').toLowerCase() === 'online' ? 'bg-green-500' : 'bg-red-500'; - const queue = node.queueStatus; - const queueTotal = queue?.total || 0; - const hasQueue = queueTotal > 0; - const queueClass = hasQueue && queueTotal > 50 ? 'text-amber-600 dark:text-amber-400' : 'text-gray-700 dark:text-gray-300'; - const oldestAge = queue?.oldestAge || 0; - const oldestMin = Math.floor(oldestAge / 60); - const oldestHr = Math.floor(oldestMin / 60); - const oldestClass = oldestAge > 1800 ? 'text-amber-600 dark:text-amber-400' : 'text-gray-700 dark:text-gray-300'; - - return ( - - - - - - - - - - ); - }} - - -
NameRoleStatusUptimeLoad AvgQueue DepthOldest Msg
{node.name}{node.role || '—'} -
- - {node.status || 'unknown'} -
-
- - {Math.floor((node.uptime ?? 0) / 86400)}d {Math.floor(((node.uptime ?? 0) % 86400) / 3600)}h - - {node.loadAvg || '—'} - —}> -
- {formatNum(queueTotal)} -
-
- A:{queue!.active} D:{queue!.deferred} H:{queue!.hold} I:{queue!.incoming} -
-
-
- 0} fallback={}> - - {oldestHr > 0 ? `${oldestHr}h ${oldestMin % 60}m` : `${oldestMin}m`} - - -
-
-
-
-
- - ); - }} - + + ); + }} + +
+
+
); diff --git a/frontend-modern/src/components/Replication/Replication.tsx b/frontend-modern/src/components/Replication/Replication.tsx index f5b69ad..4b29b9c 100644 --- a/frontend-modern/src/components/Replication/Replication.tsx +++ b/frontend-modern/src/components/Replication/Replication.tsx @@ -1,5 +1,6 @@ import type { Component } from 'solid-js'; -import { Show, For, createMemo } from 'solid-js'; +import { Show, For, createMemo, createSignal, createEffect, onCleanup } from 'solid-js'; +import { Portal } from 'solid-js/web'; import { ProxmoxSectionNav } from '@/components/Proxmox/ProxmoxSectionNav'; import { useWebSocket } from '@/App'; import { Card } from '@/components/shared/Card'; @@ -7,33 +8,139 @@ import { EmptyState } from '@/components/shared/EmptyState'; import { formatAbsoluteTime, formatRelativeTime } from '@/utils/format'; import { StatusDot } from '@/components/shared/StatusDot'; import { getReplicationJobStatusIndicator } from '@/utils/status'; +import type { ReplicationJob } from '@/types/api'; + +type StatusFilter = 'all' | 'healthy' | 'warning' | 'error'; function formatDuration(durationSeconds?: number, durationHuman?: string): string { if (durationHuman && durationHuman.trim()) return durationHuman; if (!durationSeconds || durationSeconds <= 0) return ''; - const hours = Math.floor(durationSeconds / 3600) - .toString() - .padStart(2, '0'); - const minutes = Math.floor((durationSeconds % 3600) / 60) - .toString() - .padStart(2, '0'); - const seconds = Math.floor(durationSeconds % 60) - .toString() - .padStart(2, '0'); + const hours = Math.floor(durationSeconds / 3600).toString().padStart(2, '0'); + const minutes = Math.floor((durationSeconds % 3600) / 60).toString().padStart(2, '0'); + const seconds = Math.floor(durationSeconds % 60).toString().padStart(2, '0'); return `${hours}:${minutes}:${seconds}`; } - - -function formatRate(limit?: number): string { - if (!limit || limit <= 0) return '—'; - return `${limit.toFixed(0)} MB/s`; +function coerceTimestamp(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string') { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return undefined; } +function normalizeTimestampMs(value: number): number { + return value > 1e12 ? value : value * 1000; +} + + + +// Countdown timer for next sync +function getTimeUntil(timestamp?: number): { text: string; isOverdue: boolean; isImminent: boolean } { + if (!timestamp) return { text: '—', isOverdue: false, isImminent: false }; + + const now = Date.now(); + // Handle both Unix timestamp (seconds) and JS timestamp (milliseconds) + const target = timestamp > 1e12 ? timestamp : timestamp * 1000; + const diff = target - now; + + if (diff < 0) { + // Overdue + const overdueMinutes = Math.abs(Math.floor(diff / 60000)); + if (overdueMinutes < 60) { + return { text: `${overdueMinutes}m overdue`, isOverdue: true, isImminent: false }; + } + const overdueHours = Math.floor(overdueMinutes / 60); + return { text: `${overdueHours}h overdue`, isOverdue: true, isImminent: false }; + } + + const minutes = Math.floor(diff / 60000); + if (minutes < 60) { + return { text: `in ${minutes}m`, isOverdue: false, isImminent: minutes < 5 }; + } + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return { text: `in ${hours}h ${remainingMinutes}m`, isOverdue: false, isImminent: false }; +} + +// Error tooltip component +const ErrorTooltip: Component<{ error: string }> = (props) => { + const [showTooltip, setShowTooltip] = createSignal(false); + const [tooltipPos, setTooltipPos] = createSignal({ x: 0, y: 0 }); + + const handleMouseEnter = (e: MouseEvent) => { + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + setTooltipPos({ x: rect.left + rect.width / 2, y: rect.top }); + setShowTooltip(true); + }; + + return ( + <> +
setShowTooltip(false)} + > + {props.error} +
+ + + +
+
+
Error Details
+
{props.error}
+
+
+
+
+ + ); +}; + const Replication: Component = () => { - const { state, connected, reconnecting, reconnect } = useWebSocket(); + const { state, connected, reconnecting, reconnect, initialDataReceived } = useWebSocket(); + + const [searchTerm, setSearchTerm] = createSignal(''); + const [statusFilter, setStatusFilter] = createSignal('all'); + let searchInputRef: HTMLInputElement | undefined; + + // Keyboard handler for type-to-search + createEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const target = e.target as HTMLElement; + const isInputField = + target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.tagName === 'SELECT' || + target.contentEditable === 'true'; + + if (e.key === 'Escape') { + if (searchTerm().trim() || statusFilter() !== 'all') { + setSearchTerm(''); + setStatusFilter('all'); + searchInputRef?.blur(); + } + } else if (!isInputField && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { + if (searchInputRef) { + searchInputRef.focus(); + } + } + }; + + document.addEventListener('keydown', handleKeyDown); + onCleanup(() => document.removeEventListener('keydown', handleKeyDown)); + }); const replicationJobs = createMemo(() => { const jobs = state.replicationJobs ?? []; @@ -46,11 +153,93 @@ const Replication: Component = () => { }); }); + // Get job status category for filtering + const getJobStatusCategory = (job: ReplicationJob): StatusFilter => { + const indicator = getReplicationJobStatusIndicator(job); + if (indicator.variant === 'success') return 'healthy'; + if (indicator.variant === 'warning') return 'warning'; + if (indicator.variant === 'danger') return 'error'; + return 'healthy'; + }; + + // Filtered jobs based on search and status + const filteredJobs = createMemo(() => { + let jobs = replicationJobs(); + + // Apply status filter + if (statusFilter() !== 'all') { + jobs = jobs.filter(job => getJobStatusCategory(job) === statusFilter()); + } + + // Apply search + const term = searchTerm().toLowerCase().trim(); + if (term) { + jobs = jobs.filter(job => + (job.guestName || '').toLowerCase().includes(term) || + (job.jobId || '').toLowerCase().includes(term) || + (job.sourceNode || '').toLowerCase().includes(term) || + (job.targetNode || '').toLowerCase().includes(term) || + (job.instance || '').toLowerCase().includes(term) || + String(job.guestId || '').includes(term) + ); + } + + return jobs; + }); + + // Summary stats + const stats = createMemo(() => { + const jobs = replicationJobs(); + let healthy = 0; + let warning = 0; + let error = 0; + let nextSync: { job: ReplicationJob | null; time: number | undefined } = { job: null, time: undefined }; + + for (const job of jobs) { + const category = getJobStatusCategory(job); + if (category === 'healthy') healthy++; + else if (category === 'warning') warning++; + else error++; + + // Track the soonest next sync + const nextSyncRaw = coerceTimestamp(job.nextSyncTime ?? job.nextSyncUnix); + if (typeof nextSyncRaw === 'number') { + const jobSyncMs = normalizeTimestampMs(nextSyncRaw); + const currentSyncMs = + typeof nextSync.time === 'number' ? normalizeTimestampMs(nextSync.time) : Infinity; + if (jobSyncMs < currentSyncMs) nextSync = { job, time: nextSyncRaw }; + } + } + + return { total: jobs.length, healthy, warning, error, nextSync }; + }); + + const isLoading = createMemo(() => connected() && !initialDataReceived()); + + const thClass = "px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400"; + return ( -
+
- + + + + + + } + title="Loading replication data..." + description="Connecting to the monitoring service." + /> + + + + {/* Disconnected State */} + { } title="Connection lost" - description={ - reconnecting() - ? 'Attempting to reconnect…' - : 'Unable to connect to the backend server' - } + description={reconnecting() ? 'Attempting to reconnect…' : 'Unable to connect to the backend server'} tone="danger" actions={ !reconnecting() ? ( @@ -77,126 +262,343 @@ const Replication: Component = () => { } /> - }> - 0} - fallback={ - - - + + + + {/* No Jobs Empty State */} + + + + + + } + title="No replication jobs detected" + description="Replication jobs will appear here once configured in Proxmox. Replication keeps your VMs synchronized across nodes for high availability." + /> + + + + {/* Has Jobs - Show Content */} + 0}> + {/* Summary Cards */} +
+ {/* Total Jobs */} + +
+
+
Total Jobs
+
+ {stats().total} +
+
+
+ + - } - title="No replication jobs detected" - description="Replication jobs will appear here once configured in Proxmox." - /> +
+
- } - > - -
- - - - - - - - - - - - - - - - {(job) => { - const indicator = getReplicationJobStatusIndicator(job); + + {/* Healthy */} + +
+
+
Healthy
+
+ {stats().healthy} +
+
+
+ + + +
+
+
+ + {/* Warning/Error Combined */} + 0 ? 'danger' : stats().warning > 0 ? 'warning' : 'glass'}> +
+
+
Issues
+
0 + ? 'text-red-600 dark:text-red-400' + : stats().warning > 0 + ? 'text-yellow-600 dark:text-yellow-400' + : 'text-gray-400' + }`}> + {stats().error + stats().warning} +
+
+
0 + ? 'bg-red-100 dark:bg-red-900/30' + : stats().warning > 0 + ? 'bg-yellow-100 dark:bg-yellow-900/30' + : 'bg-gray-100 dark:bg-gray-800' + }`}> + 0 || stats().warning > 0} fallback={ + + + + }> + 0 ? 'text-red-600 dark:text-red-400' : 'text-yellow-600 dark:text-yellow-400'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> + + + +
+
+
+ + {/* Next Sync */} + +
+
+
Next Sync
+
+ }> + {(() => { + const timeInfo = getTimeUntil(stats().nextSync.time); return ( -
- - - - - - - - - + <> +
+ {timeInfo.text} +
+
+ {stats().nextSync.job?.guestName || `VM ${stats().nextSync.job?.guestId}`} +
+ ); - }} - - -
GuestJobSource → TargetLast SyncNext SyncStatusFailuresRate
-
- {job.guestName || `VM ${job.guestId ?? ''}`} -
-
- {job.instance} · ID {job.guestId ?? '—'} · {job.guestNode || job.sourceNode || 'Unknown node'} -
-
-
{job.jobId || '—'}
-
- {job.type ? `${job.type.toUpperCase()} · ` : ''}{job.schedule || '*/15'} -
-
-
- {job.sourceNode || '—'} - - {job.targetNode || '—'} -
-
- {job.sourceStorage || 'local'} → {job.targetStorage || 'remote'} -
-
- -
{formatAbsoluteTime(job.lastSyncTime!)}
-
- {formatRelativeTime(job.lastSyncTime!)} - - · - {formatDuration(job.lastSyncDurationSeconds, job.lastSyncDurationHuman)} - -
-
- - Never - -
- -
{formatAbsoluteTime(job.nextSyncTime!)}
-
- {formatRelativeTime(job.nextSyncTime!)} -
-
- - - -
-
- - - {indicator.label} - -
- -
- {job.error} -
-
-
- {job.failCount ?? 0} - - {formatRate(job.rateLimitMbps)} -
+ })()} + +
+
+ + + +
+
+ +
+ + {/* Filter Bar */} + +
+ {/* Search */} +
+ (searchInputRef = el)} + type="text" + placeholder="Search by guest, job, or node..." + value={searchTerm()} + onInput={(e) => setSearchTerm(e.currentTarget.value)} + class="w-full pl-9 pr-8 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg + bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500 + focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 outline-none transition-all" + /> + + + + + + +
+ + {/* Status Filter Buttons */} +
+ Status: + + + + +
+ + {/* Jobs Table */} + + 0} + fallback={ +
+ + + +
+ No jobs match your search +
+ +
+ } + > +
+ + + + + + + + + + + + + + {(job) => { + const indicator = getReplicationJobStatusIndicator(job); + const nextSyncInfo = getTimeUntil(job.nextSyncTime); + const statusCategory = getJobStatusCategory(job); + + return ( + + + + + + + + + ); + }} + + +
GuestJobSource → TargetLast SyncNext SyncStatus
+
+ {job.guestName || `VM ${job.guestId ?? ''}`} +
+
+ + {job.instance} + + · ID {job.guestId ?? '—'} +
+
+
{job.jobId || '—'}
+
+ {job.schedule || '*/15'} +
+
+
+ {job.sourceNode || '—'} + + + + {job.targetNode || '—'} +
+
+ Never + }> +
+ {formatRelativeTime(job.lastSyncTime!)} +
+
+ {formatAbsoluteTime(job.lastSyncTime!)} + + ({formatDuration(job.lastSyncDurationSeconds, job.lastSyncDurationHuman)}) + +
+
+
+
+ {nextSyncInfo.text} +
+ +
+ {formatAbsoluteTime(job.nextSyncTime!)} +
+
+
+
+ + + {indicator.label} + +
+ + + + 0}> +
+ {job.failCount} failure{(job.failCount ?? 0) > 1 ? 's' : ''} +
+
+
+
+
+
diff --git a/frontend-modern/src/hooks/useColumnVisibility.ts b/frontend-modern/src/hooks/useColumnVisibility.ts index 0a23c78..a88811d 100644 --- a/frontend-modern/src/hooks/useColumnVisibility.ts +++ b/frontend-modern/src/hooks/useColumnVisibility.ts @@ -1,11 +1,12 @@ import { createMemo, Accessor } from 'solid-js'; +import type { JSX } from 'solid-js'; import { usePersistentSignal } from './usePersistentSignal'; import { useBreakpoint, type ColumnPriority, PRIORITY_BREAKPOINTS, type Breakpoint } from './useBreakpoint'; export interface ColumnDef { id: string; label: string; - icon?: string; // SVG icon markup for compact column headers + icon?: JSX.Element; // Optional icon for compact column headers priority: ColumnPriority; toggleable?: boolean; width?: string; // Fixed width for consistent column sizing diff --git a/frontend-modern/src/pages/Ceph.tsx b/frontend-modern/src/pages/Ceph.tsx index baffdfa..372bec1 100644 --- a/frontend-modern/src/pages/Ceph.tsx +++ b/frontend-modern/src/pages/Ceph.tsx @@ -1,287 +1,289 @@ -import { Component, For, Show, createMemo } from 'solid-js'; +import { Component, For, Show, createMemo, createSignal, createEffect, onCleanup } from 'solid-js'; +import { Portal } from 'solid-js/web'; import { useWebSocket } from '@/App'; import { ProxmoxSectionNav } from '@/components/Proxmox/ProxmoxSectionNav'; -import type { CephCluster, CephPool, CephServiceStatus } from '@/types/api'; +import { Card } from '@/components/shared/Card'; +import { EmptyState } from '@/components/shared/EmptyState'; +import type { CephPool, CephServiceStatus } from '@/types/api'; +import { formatBytes } from '@/utils/format'; -// Format bytes to human readable -const formatBytes = (bytes: number): string => { - if (bytes === 0) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; +// Service type icon component with proper styling +const ServiceIcon: Component<{ type: string; class?: string }> = (props) => { + const iconClass = () => `${props.class || 'w-4 h-4'}`; + + switch (props.type.toLowerCase()) { + case 'mon': + return ( + + + + + ); + case 'mgr': + return ( + + + + + ); + case 'osd': + return ( + + + + ); + case 'mds': + return ( + + + + ); + case 'rgw': + return ( + + + + ); + default: + return ( + + + + ); + } }; // Get health color classes -const getHealthClasses = (health: string) => { +const getHealthInfo = (health: string) => { const h = health?.toUpperCase() || ''; if (h === 'OK' || h === 'HEALTH_OK') { return { - bg: 'bg-green-100 dark:bg-green-900/30', - text: 'text-green-700 dark:text-green-400', - border: 'border-green-200 dark:border-green-800', - dot: 'bg-green-500', + bgClass: 'bg-green-100 dark:bg-green-900/30', + textClass: 'text-green-700 dark:text-green-400', + borderClass: 'border-green-200 dark:border-green-700', + dotClass: 'bg-green-500', + label: 'OK', }; } if (h === 'WARN' || h === 'HEALTH_WARN' || h === 'WARNING') { return { - bg: 'bg-yellow-100 dark:bg-yellow-900/30', - text: 'text-yellow-700 dark:text-yellow-400', - border: 'border-yellow-200 dark:border-yellow-800', - dot: 'bg-yellow-500', + bgClass: 'bg-yellow-100 dark:bg-yellow-900/30', + textClass: 'text-yellow-700 dark:text-yellow-400', + borderClass: 'border-yellow-200 dark:border-yellow-700', + dotClass: 'bg-yellow-500', + label: 'WARN', }; } if (h === 'ERR' || h === 'HEALTH_ERR' || h === 'ERROR' || h === 'CRITICAL') { return { - bg: 'bg-red-100 dark:bg-red-900/30', - text: 'text-red-700 dark:text-red-400', - border: 'border-red-200 dark:border-red-800', - dot: 'bg-red-500', + bgClass: 'bg-red-100 dark:bg-red-900/30', + textClass: 'text-red-700 dark:text-red-400', + borderClass: 'border-red-200 dark:border-red-700', + dotClass: 'bg-red-500', + label: 'ERROR', }; } return { - bg: 'bg-gray-100 dark:bg-gray-800', - text: 'text-gray-700 dark:text-gray-400', - border: 'border-gray-200 dark:border-gray-700', - dot: 'bg-gray-500', + bgClass: 'bg-gray-100 dark:bg-gray-800', + textClass: 'text-gray-600 dark:text-gray-400', + borderClass: 'border-gray-200 dark:border-gray-700', + dotClass: 'bg-gray-400', + label: 'UNKNOWN', }; }; -// Cluster Overview Card -const ClusterCard: Component<{ cluster: CephCluster }> = (props) => { - const healthClasses = createMemo(() => getHealthClasses(props.cluster.health)); +// Cluster health status badge with tooltip +const HealthBadge: Component<{ health: string; message?: string }> = (props) => { + const [showTooltip, setShowTooltip] = createSignal(false); + const [tooltipPos, setTooltipPos] = createSignal({ x: 0, y: 0 }); - const displayHealth = createMemo(() => { - const h = props.cluster.health?.toUpperCase() || 'UNKNOWN'; - return h.replace('HEALTH_', ''); - }); + const healthInfo = createMemo(() => getHealthInfo(props.health)); + + const handleMouseEnter = (e: MouseEvent) => { + if (!props.message) return; + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + setTooltipPos({ x: rect.left + rect.width / 2, y: rect.top }); + setShowTooltip(true); + }; return ( -
-
-
-

- {props.cluster.name || 'Ceph Cluster'} -

- -

- {props.cluster.fsid} -

-
-
-
- - {displayHealth()} -
-
+ <> + setShowTooltip(false)} + > + + {healthInfo().label} + - -

- {props.cluster.healthMessage} -

-
- - {/* Capacity Bar */} -
-
- Capacity - - {props.cluster.usagePercent?.toFixed(1) || 0}% used - -
-
+ +
85 - ? 'bg-red-500' - : (props.cluster.usagePercent || 0) > 70 - ? 'bg-yellow-500' - : 'bg-blue-500' - }`} - style={{ width: `${Math.min(props.cluster.usagePercent || 0, 100)}%` }} - /> -
-
- {formatBytes(props.cluster.usedBytes || 0)} used - {formatBytes(props.cluster.totalBytes || 0)} total -
-
- - {/* Service Stats Grid */} -
-
-
- {props.cluster.numMons || 0} + class="fixed z-[9999] pointer-events-none" + style={{ + left: `${tooltipPos().x}px`, + top: `${tooltipPos().y - 8}px`, + transform: 'translate(-50%, -100%)', + }} + > +
+ {props.message} +
-
Monitors
-
-
-
- {props.cluster.numMgrs || 0} -
-
Managers
-
-
-
- - {props.cluster.numOsdsUp || 0} - - /{props.cluster.numOsds || 0} -
-
OSDs Up
-
-
- - {/* PG Count */} - -
- {props.cluster.numPGs?.toLocaleString()} - {' '}Placement Groups -
+
-
+ ); }; -// Pool Table -const PoolsTable: Component<{ pools: CephPool[] }> = (props) => { - return ( -
-
-

- Pools ({props.pools.length}) -

-
-
- - - - - - - - - - - - - {(pool) => ( - - - - - - - - )} - - -
NameUsedAvailableObjectsUsage
- {pool.name} - - {formatBytes(pool.storedBytes || 0)} - - {formatBytes(pool.availableBytes || 0)} - - {(pool.objects || 0).toLocaleString()} - -
-
-
85 - ? 'bg-red-500' - : (pool.percentUsed || 0) > 70 - ? 'bg-yellow-500' - : 'bg-blue-500' - }`} - style={{ width: `${Math.min(pool.percentUsed || 0, 100)}%` }} - /> -
- - {(pool.percentUsed || 0).toFixed(1)}% - -
-
-
-
- ); -}; +// Service status cell with tooltip +const ServiceStatusCell: Component<{ services: CephServiceStatus[] }> = (props) => { + const [showTooltip, setShowTooltip] = createSignal(false); + const [tooltipPos, setTooltipPos] = createSignal({ x: 0, y: 0 }); -// Services Status -const ServicesStatus: Component<{ services: CephServiceStatus[] }> = (props) => { - const getServiceIcon = (type: string) => { - switch (type.toLowerCase()) { - case 'mon': - return '🔵'; - case 'mgr': - return '🟣'; - case 'osd': - return '🟢'; - case 'mds': - return '🟡'; - case 'rgw': - return '🟠'; - default: - return '⚪'; - } + const handleMouseEnter = (e: MouseEvent) => { + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + setTooltipPos({ x: rect.left + rect.width / 2, y: rect.top }); + setShowTooltip(true); + }; + + const getServiceStatus = (svc: CephServiceStatus) => { + if (svc.running === svc.total) return { color: 'text-green-600 dark:text-green-400', status: 'healthy' }; + if (svc.running > 0) return { color: 'text-yellow-600 dark:text-yellow-400', status: 'degraded' }; + return { color: 'text-red-600 dark:text-red-400', status: 'down' }; }; return ( -
-

Services

-
- - {(service) => { - const allRunning = service.running === service.total; + <> +
setShowTooltip(false)} + > + + {(svc) => { + const status = getServiceStatus(svc); return ( -
0 - ? 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800' - : 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800' - }`} - > -
- {getServiceIcon(service.type)} - - {service.type} - -
-
0 - ? 'text-yellow-700 dark:text-yellow-400' - : 'text-red-700 dark:text-red-400' - }`}> - {service.running}/{service.total} -
- -
- {service.message} -
-
-
+ + + {svc.running}/{svc.total} + ); }}
+ 4}> + +{props.services.length - 4} +
+ + 0}> + +
+
+
+ Ceph Services +
+
+ + {(svc) => { + const status = getServiceStatus(svc); + return ( +
+ + + {svc.type} + + + {svc.running}/{svc.total} + +
+ ); + }} +
+
+
+
+
+
+ + ); +}; + +// Usage bar component +const UsageBar: Component<{ percent: number; size?: 'sm' | 'md' }> = (props) => { + const barHeight = () => props.size === 'sm' ? 'h-1.5' : 'h-2'; + const barColor = () => { + const p = props.percent || 0; + if (p > 90) return 'bg-red-500'; + if (p > 75) return 'bg-yellow-500'; + return 'bg-blue-500'; + }; + + return ( +
+
); }; -// Main Ceph Page +// Main Ceph Page Component const Ceph: Component = () => { - const { state } = useWebSocket(); + const { state, connected, initialDataReceived, reconnecting, reconnect } = useWebSocket(); + + const [searchTerm, setSearchTerm] = createSignal(''); + let searchInputRef: HTMLInputElement | undefined; + + // Keyboard handler for type-to-search + createEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const target = e.target as HTMLElement; + const isInputField = + target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.tagName === 'SELECT' || + target.contentEditable === 'true'; + + if (e.key === 'Escape') { + if (searchTerm().trim()) { + setSearchTerm(''); + searchInputRef?.blur(); + } + } else if (!isInputField && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { + if (searchInputRef) { + searchInputRef.focus(); + } + } + }; + + document.addEventListener('keydown', handleKeyDown); + onCleanup(() => document.removeEventListener('keydown', handleKeyDown)); + }); const clusters = createMemo(() => state.cephClusters || []); const hasClusters = createMemo(() => clusters().length > 0); // Aggregate all pools from all clusters const allPools = createMemo(() => { - const pools: CephPool[] = []; + const pools: (CephPool & { clusterName: string })[] = []; for (const cluster of clusters()) { if (cluster.pools) { - pools.push(...cluster.pools); + for (const pool of cluster.pools) { + pools.push({ + ...pool, + clusterName: cluster.name || 'Ceph Cluster', + }); + } } } return pools; @@ -308,55 +310,358 @@ const Ceph: Component = () => { })); }); + // Filter pools by search term + const filteredPools = createMemo(() => { + const term = searchTerm().toLowerCase().trim(); + if (!term) return allPools(); + return allPools().filter( + pool => + pool.name.toLowerCase().includes(term) || + pool.clusterName.toLowerCase().includes(term) + ); + }); + + // Calculate total storage stats + const totalStats = createMemo(() => { + let totalBytes = 0; + let usedBytes = 0; + for (const cluster of clusters()) { + totalBytes += cluster.totalBytes || 0; + usedBytes += cluster.usedBytes || 0; + } + const usagePercent = totalBytes > 0 ? (usedBytes / totalBytes) * 100 : 0; + return { totalBytes, usedBytes, usagePercent }; + }); + + const isLoading = createMemo(() => connected() && !initialDataReceived()); + + const thClass = "px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600/50 whitespace-nowrap transition-colors"; + return ( -
- {/* Header */} -
-
-

- Ceph Storage -

-

- Distributed storage cluster status and pools -

-
- -
+
+ {/* Navigation */} + - -
- - + {/* Loading State */} + + + + + -
-

- No Ceph Clusters Detected -

-

- Ceph cluster data will appear here when detected via the Pulse agent on your Proxmox nodes. - Install the agent on a node with Ceph configured. -

-
- } - > - {/* Cluster Cards */} -
- - {(cluster) => } - -
+ } + title="Loading Ceph data..." + description="Connecting to the monitoring service." + /> + + - {/* Services Overview */} - 0}> - + {/* Disconnected State */} + + + + + + } + title="Connection lost" + description={reconnecting() ? 'Attempting to reconnect…' : 'Unable to connect to the backend server'} + tone="danger" + actions={ + !reconnecting() ? ( + + ) : undefined + } + /> + + + + + {/* No Clusters Empty State */} + + + + + + } + title="No Ceph Clusters Detected" + description="Ceph cluster data will appear here when detected via the Pulse agent on your Proxmox nodes. Install the agent on a node with Ceph configured." + /> + - {/* Pools Table */} - 0}> - + {/* Clusters Found - Show Content */} + + {/* Summary Cards */} +
+ {/* Total Storage Card */} + +
+ Total Storage + + + +
+
+ {formatBytes(totalStats().totalBytes)} +
+
+ +
+ {formatBytes(totalStats().usedBytes)} used + {totalStats().usagePercent.toFixed(1)}% +
+
+
+ + {/* Clusters Card */} + +
+ Clusters + + + +
+
+ {clusters().length} +
+
+ + {(cluster) => ( + + )} + +
+
+ + {/* Services Card */} + +
+ Services + + + +
+
+ {allServices().reduce((acc, svc) => acc + svc.running, 0)} + + /{allServices().reduce((acc, svc) => acc + svc.total, 0)} + +
+
+ +
+
+ + {/* Pools Card */} + +
+ Pools + + + +
+
+ {allPools().length} +
+
+ {allPools().reduce((acc, pool) => acc + (pool.objects || 0), 0).toLocaleString()} objects +
+
+
+ + {/* Cluster Details Table */} + 0}> + +
+

+ Cluster Overview +

+
+
+ + + + + + + + + + + + + + + {(cluster) => ( + + + + + + + + + + )} + + +
ClusterHealthMonitorsManagersOSDsPGsCapacity
+
+ {cluster.name || 'Ceph Cluster'} +
+ +
+ {cluster.fsid} +
+
+
+ + + + + {cluster.numMons || 0} + + + + + {cluster.numMgrs || 0} + + + + + + {cluster.numOsdsUp || 0} + + + /{cluster.numOsds || 0} + + + + + {(cluster.numPGs || 0).toLocaleString()} + + +
+ +
+ {formatBytes(cluster.usedBytes || 0)} + {(cluster.usagePercent || 0).toFixed(1)}% +
+
+
+
+
+
+ + {/* Pools Table */} + 0}> + +
+

+ Storage Pools ({filteredPools().length}) +

+ {/* Search Input */} +
+ (searchInputRef = el)} + type="text" + placeholder="Search pools..." + value={searchTerm()} + onInput={(e) => setSearchTerm(e.currentTarget.value)} + class="w-full pl-8 pr-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg + bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500 + focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 outline-none transition-all" + /> + + + + + + +
+
+ + 0} + fallback={ +
+ No pools match "{searchTerm()}" +
+ } + > +
+ + + + + + + + + + + + + + {(pool) => ( + + + + + + + + + )} + + +
PoolClusterUsedAvailableObjectsUsage
+ {pool.name} + + {pool.clusterName} + + {formatBytes(pool.storedBytes || 0)} + + {formatBytes(pool.availableBytes || 0)} + + {(pool.objects || 0).toLocaleString()} + +
+
+ +
+ + {(pool.percentUsed || 0).toFixed(1)}% + +
+
+
+
+
+
diff --git a/frontend-modern/src/stores/websocket.ts b/frontend-modern/src/stores/websocket.ts index 5bbd9b0..e420a91 100644 --- a/frontend-modern/src/stores/websocket.ts +++ b/frontend-modern/src/stores/websocket.ts @@ -340,8 +340,7 @@ export function createWebSocketStore(url: string) { ) { // Update state properties individually, but batch the whole payload to // reduce reactive recomputations and UI thrash on large updates. - if (message.data) { - batch(() => { + if (message.data) batch(() => { // Mark that we've received usable data (initial payload or raw update) if (!initialDataReceived()) { setInitialDataReceived(true); @@ -648,8 +647,7 @@ export function createWebSocketStore(url: string) { // Updated recentlyResolved } setState('lastUpdate', message.data.lastUpdate || new Date().toISOString()); - }); - } + }); logger.debug('message', { type: message.type, hasData: !!message.data,