- 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.
608 lines
28 KiB
TypeScript
608 lines
28 KiB
TypeScript
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 (
|
|
<>
|
|
<div
|
|
class="mt-1 text-xs text-red-500 dark:text-red-400 line-clamp-1 cursor-help"
|
|
onMouseEnter={handleMouseEnter}
|
|
onMouseLeave={() => setShowTooltip(false)}
|
|
>
|
|
{props.error}
|
|
</div>
|
|
|
|
<Show when={showTooltip()}>
|
|
<Portal mount={document.body}>
|
|
<div
|
|
class="fixed z-[9999] pointer-events-none"
|
|
style={{
|
|
left: `${tooltipPos().x}px`,
|
|
top: `${tooltipPos().y - 8}px`,
|
|
transform: 'translate(-50%, -100%)',
|
|
}}
|
|
>
|
|
<div class="bg-gray-900 dark:bg-gray-800 text-white text-xs rounded-md shadow-lg px-3 py-2 max-w-[400px] border border-gray-700">
|
|
<div class="font-medium text-red-400 mb-1">Error Details</div>
|
|
<div class="text-gray-300 whitespace-pre-wrap">{props.error}</div>
|
|
</div>
|
|
</div>
|
|
</Portal>
|
|
</Show>
|
|
</>
|
|
);
|
|
};
|
|
|
|
const Replication: Component = () => {
|
|
const { state, connected, reconnecting, reconnect, initialDataReceived } = useWebSocket();
|
|
|
|
const [searchTerm, setSearchTerm] = createSignal('');
|
|
const [statusFilter, setStatusFilter] = createSignal<StatusFilter>('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 (
|
|
<div class="space-y-4">
|
|
<ProxmoxSectionNav current="replication" />
|
|
|
|
{/* Loading State */}
|
|
<Show when={isLoading()}>
|
|
<Card padding="lg">
|
|
<EmptyState
|
|
icon={
|
|
<svg class="h-12 w-12 animate-spin text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
|
</svg>
|
|
}
|
|
title="Loading replication data..."
|
|
description="Connecting to the monitoring service."
|
|
/>
|
|
</Card>
|
|
</Show>
|
|
|
|
{/* Disconnected State */}
|
|
<Show when={!connected() && !isLoading()}>
|
|
<Card padding="lg" tone="danger">
|
|
<EmptyState
|
|
icon={
|
|
<svg class="h-12 w-12 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
}
|
|
title="Connection lost"
|
|
description={reconnecting() ? 'Attempting to reconnect…' : 'Unable to connect to the backend server'}
|
|
tone="danger"
|
|
actions={
|
|
!reconnecting() ? (
|
|
<button
|
|
onClick={() => reconnect()}
|
|
class="mt-2 inline-flex items-center px-4 py-2 text-xs font-medium rounded bg-red-600 text-white hover:bg-red-700 transition-colors"
|
|
>
|
|
Reconnect now
|
|
</button>
|
|
) : undefined
|
|
}
|
|
/>
|
|
</Card>
|
|
</Show>
|
|
|
|
<Show when={connected() && initialDataReceived()}>
|
|
{/* No Jobs Empty State */}
|
|
<Show when={replicationJobs().length === 0}>
|
|
<Card padding="lg">
|
|
<EmptyState
|
|
icon={
|
|
<svg class="h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
|
|
</svg>
|
|
}
|
|
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."
|
|
/>
|
|
</Card>
|
|
</Show>
|
|
|
|
{/* Has Jobs - Show Content */}
|
|
<Show when={replicationJobs().length > 0}>
|
|
{/* Summary Cards */}
|
|
<div class="grid gap-3 grid-cols-2 lg:grid-cols-4">
|
|
{/* Total Jobs */}
|
|
<Card padding="sm" tone="glass">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<div class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Total Jobs</div>
|
|
<div class="text-2xl font-bold text-gray-900 dark:text-gray-100 mt-1">
|
|
{stats().total}
|
|
</div>
|
|
</div>
|
|
<div class="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
|
|
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Healthy */}
|
|
<Card padding="sm" tone="glass">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<div class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Healthy</div>
|
|
<div class="text-2xl font-bold text-green-600 dark:text-green-400 mt-1">
|
|
{stats().healthy}
|
|
</div>
|
|
</div>
|
|
<div class="p-2 bg-green-100 dark:bg-green-900/30 rounded-lg">
|
|
<svg class="w-5 h-5 text-green-600 dark:text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Warning/Error Combined */}
|
|
<Card padding="sm" tone={stats().error > 0 ? 'danger' : stats().warning > 0 ? 'warning' : 'glass'}>
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<div class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Issues</div>
|
|
<div class={`text-2xl font-bold mt-1 ${stats().error > 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}
|
|
</div>
|
|
</div>
|
|
<div class={`p-2 rounded-lg ${stats().error > 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'
|
|
}`}>
|
|
<Show when={stats().error > 0 || stats().warning > 0} fallback={
|
|
<svg class="w-5 h-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
}>
|
|
<svg class={`w-5 h-5 ${stats().error > 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">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
|
</svg>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Next Sync */}
|
|
<Card padding="sm" tone="glass">
|
|
<div class="flex items-center justify-between">
|
|
<div class="min-w-0 flex-1">
|
|
<div class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Next Sync</div>
|
|
<Show when={stats().nextSync.job} fallback={
|
|
<div class="text-sm text-gray-400 mt-1">—</div>
|
|
}>
|
|
{(() => {
|
|
const timeInfo = getTimeUntil(stats().nextSync.time);
|
|
return (
|
|
<>
|
|
<div class={`text-lg font-bold mt-1 ${timeInfo.isOverdue
|
|
? 'text-red-600 dark:text-red-400'
|
|
: timeInfo.isImminent
|
|
? 'text-blue-600 dark:text-blue-400'
|
|
: 'text-gray-900 dark:text-gray-100'
|
|
}`}>
|
|
{timeInfo.text}
|
|
</div>
|
|
<div class="text-xs text-gray-500 dark:text-gray-400 truncate">
|
|
{stats().nextSync.job?.guestName || `VM ${stats().nextSync.job?.guestId}`}
|
|
</div>
|
|
</>
|
|
);
|
|
})()}
|
|
</Show>
|
|
</div>
|
|
<div class="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
|
|
<svg class="w-5 h-5 text-purple-600 dark:text-purple-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Filter Bar */}
|
|
<Card padding="sm" tone="glass">
|
|
<div class="flex flex-col sm:flex-row gap-3 sm:items-center sm:justify-between">
|
|
{/* Search */}
|
|
<div class="relative flex-1 max-w-md">
|
|
<input
|
|
ref={(el) => (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"
|
|
/>
|
|
<svg class="absolute left-3 top-2 h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
</svg>
|
|
<Show when={searchTerm()}>
|
|
<button
|
|
onClick={() => setSearchTerm('')}
|
|
class="absolute right-2.5 top-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
|
>
|
|
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</Show>
|
|
</div>
|
|
|
|
{/* Status Filter Buttons */}
|
|
<div class="flex items-center gap-1.5">
|
|
<span class="text-xs text-gray-500 dark:text-gray-400 mr-1">Status:</span>
|
|
<button
|
|
onClick={() => setStatusFilter('all')}
|
|
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${statusFilter() === 'all'
|
|
? 'bg-gray-800 dark:bg-gray-200 text-white dark:text-gray-900'
|
|
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
|
|
}`}
|
|
>
|
|
All
|
|
</button>
|
|
<button
|
|
onClick={() => setStatusFilter('healthy')}
|
|
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${statusFilter() === 'healthy'
|
|
? 'bg-green-600 text-white'
|
|
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
|
|
}`}
|
|
>
|
|
Healthy
|
|
</button>
|
|
<button
|
|
onClick={() => setStatusFilter('warning')}
|
|
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${statusFilter() === 'warning'
|
|
? 'bg-yellow-500 text-white'
|
|
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
|
|
}`}
|
|
>
|
|
Warning
|
|
</button>
|
|
<button
|
|
onClick={() => setStatusFilter('error')}
|
|
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${statusFilter() === 'error'
|
|
? 'bg-red-600 text-white'
|
|
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700'
|
|
}`}
|
|
>
|
|
Error
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Jobs Table */}
|
|
<Card padding="none" tone="glass" class="overflow-hidden">
|
|
<Show
|
|
when={filteredJobs().length > 0}
|
|
fallback={
|
|
<div class="p-8 text-center">
|
|
<svg class="w-10 h-10 text-gray-300 dark:text-gray-600 mx-auto mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
|
</svg>
|
|
<div class="text-gray-500 dark:text-gray-400 text-sm">
|
|
No jobs match your search
|
|
</div>
|
|
<button
|
|
onClick={() => { setSearchTerm(''); setStatusFilter('all'); }}
|
|
class="mt-2 text-xs text-blue-600 dark:text-blue-400 hover:underline"
|
|
>
|
|
Clear filters
|
|
</button>
|
|
</div>
|
|
}
|
|
>
|
|
<div class="overflow-x-auto">
|
|
<table class="w-full min-w-[900px]">
|
|
<thead class="bg-gray-50 dark:bg-gray-800/60 border-b border-gray-200 dark:border-gray-700">
|
|
<tr>
|
|
<th class={`${thClass} pl-4`}>Guest</th>
|
|
<th class={thClass}>Job</th>
|
|
<th class={thClass}>Source → Target</th>
|
|
<th class={thClass}>Last Sync</th>
|
|
<th class={thClass}>Next Sync</th>
|
|
<th class={thClass}>Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-gray-100 dark:divide-gray-700/50">
|
|
<For each={filteredJobs()}>
|
|
{(job) => {
|
|
const indicator = getReplicationJobStatusIndicator(job);
|
|
const nextSyncInfo = getTimeUntil(job.nextSyncTime);
|
|
const statusCategory = getJobStatusCategory(job);
|
|
|
|
return (
|
|
<tr class={`transition-colors ${statusCategory === 'error'
|
|
? 'bg-red-50/50 dark:bg-red-900/10 hover:bg-red-50 dark:hover:bg-red-900/20'
|
|
: statusCategory === 'warning'
|
|
? 'bg-yellow-50/30 dark:bg-yellow-900/10 hover:bg-yellow-50/50 dark:hover:bg-yellow-900/20'
|
|
: 'hover:bg-gray-50/80 dark:hover:bg-gray-800/50'
|
|
}`}>
|
|
<td class="px-3 py-3 pl-4">
|
|
<div class="font-medium text-sm text-gray-900 dark:text-gray-100">
|
|
{job.guestName || `VM ${job.guestId ?? ''}`}
|
|
</div>
|
|
<div class="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
|
|
<span class="inline-flex items-center px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 font-mono">
|
|
{job.instance}
|
|
</span>
|
|
<span>· ID {job.guestId ?? '—'}</span>
|
|
</div>
|
|
</td>
|
|
<td class="px-3 py-3">
|
|
<div class="font-mono text-sm text-gray-700 dark:text-gray-300">{job.jobId || '—'}</div>
|
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
|
{job.schedule || '*/15'}
|
|
</div>
|
|
</td>
|
|
<td class="px-3 py-3">
|
|
<div class="flex items-center gap-2 text-sm">
|
|
<span class="text-gray-700 dark:text-gray-300">{job.sourceNode || '—'}</span>
|
|
<svg class="w-4 h-4 text-gray-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
|
</svg>
|
|
<span class="text-gray-700 dark:text-gray-300">{job.targetNode || '—'}</span>
|
|
</div>
|
|
</td>
|
|
<td class="px-3 py-3">
|
|
<Show when={job.lastSyncTime} fallback={
|
|
<span class="text-gray-400 text-sm">Never</span>
|
|
}>
|
|
<div class="text-sm text-gray-900 dark:text-gray-100">
|
|
{formatRelativeTime(job.lastSyncTime!)}
|
|
</div>
|
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
|
{formatAbsoluteTime(job.lastSyncTime!)}
|
|
<Show when={job.lastSyncDurationSeconds || job.lastSyncDurationHuman}>
|
|
<span class="ml-1">({formatDuration(job.lastSyncDurationSeconds, job.lastSyncDurationHuman)})</span>
|
|
</Show>
|
|
</div>
|
|
</Show>
|
|
</td>
|
|
<td class="px-3 py-3">
|
|
<div class={`text-sm font-medium ${nextSyncInfo.isOverdue
|
|
? 'text-red-600 dark:text-red-400'
|
|
: nextSyncInfo.isImminent
|
|
? 'text-blue-600 dark:text-blue-400'
|
|
: 'text-gray-700 dark:text-gray-300'
|
|
}`}>
|
|
{nextSyncInfo.text}
|
|
</div>
|
|
<Show when={job.nextSyncTime}>
|
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
|
{formatAbsoluteTime(job.nextSyncTime!)}
|
|
</div>
|
|
</Show>
|
|
</td>
|
|
<td class="px-3 py-3">
|
|
<div class="flex items-center gap-2">
|
|
<StatusDot
|
|
variant={indicator.variant}
|
|
title={indicator.label}
|
|
ariaLabel={indicator.label}
|
|
size="sm"
|
|
/>
|
|
<span class={`text-sm font-medium ${statusCategory === 'error'
|
|
? 'text-red-700 dark:text-red-400'
|
|
: statusCategory === 'warning'
|
|
? 'text-yellow-700 dark:text-yellow-400'
|
|
: 'text-gray-700 dark:text-gray-300'
|
|
}`}>
|
|
{indicator.label}
|
|
</span>
|
|
</div>
|
|
<Show when={job.error}>
|
|
<ErrorTooltip error={job.error!} />
|
|
</Show>
|
|
<Show when={(job.failCount ?? 0) > 0}>
|
|
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
|
{job.failCount} failure{(job.failCount ?? 0) > 1 ? 's' : ''}
|
|
</div>
|
|
</Show>
|
|
</td>
|
|
</tr>
|
|
);
|
|
}}
|
|
</For>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Show>
|
|
</Card>
|
|
</Show>
|
|
</Show>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Replication;
|