import type { Component } 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'; 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'); return `${hours}:${minutes}:${seconds}`; } 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, 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 ?? []; return [...jobs].sort((a, b) => { if (a.instance !== b.instance) return a.instance.localeCompare(b.instance); if ((a.guestName || '') !== (b.guestName || '')) { return (a.guestName || '').localeCompare(b.guestName || ''); } return (a.jobId || '').localeCompare(b.jobId || ''); }); }); // 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 (
{/* Loading State */} } 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'} tone="danger" actions={ !reconnecting() ? ( ) : undefined } /> {/* 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}
{/* 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}`}
); })()}
{/* 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 ( ); }}
Guest Job Source → Target Last Sync Next Sync Status
{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' : ''}
); }; export default Replication;