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 */}