diff --git a/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx b/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx index e32ae4755..255f82c67 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx @@ -1,5 +1,5 @@ import { useLocation } from '@solidjs/router'; -import { Show, createMemo, type Accessor } from 'solid-js'; +import { Show, createMemo, createResource, type Accessor } from 'solid-js'; import StorageSurface from '@/components/Storage/Storage'; import { WorkloadsFilter } from '@/components/Workloads/WorkloadsFilter'; import { WorkloadsSurface } from '@/components/Workloads/WorkloadsSurface'; @@ -38,7 +38,7 @@ import { ProxmoxBackupsTable } from './ProxmoxBackupsTable'; import { ProxmoxCephTable } from './ProxmoxCephTable'; import { ProxmoxMailGatewayTable } from './ProxmoxMailGatewayTable'; import { ProxmoxNodesTable } from './ProxmoxNodesTable'; -import { ProxmoxReplicationTable } from './ProxmoxReplicationTable'; +import { ProxmoxReplicationTable, fetchReplicationJobs } from './ProxmoxReplicationTable'; import { useUnifiedResources } from '@/hooks/useUnifiedResources'; import { updateStore } from '@/stores/updates'; import { @@ -81,7 +81,18 @@ export function ProxmoxPageSurface() { initialHydration: 'prefer-ws-then-rest', }); const model = createMemo(() => buildProxmoxPageModel(resources())); - const visibleTabs = createMemo(() => buildVisibleProxmoxTabSpecs(model())); + // Replication jobs come straight from /api/replication/jobs (they bypass + // the unified-resource pipeline), so the surface owns the fetch: the job + // count gates the Replication tab and the same data feeds the table. + // Reading an errored resource throws, hence the `.error` guards. + const [replicationJobs, { refetch: refetchReplicationJobs }] = + createResource(fetchReplicationJobs); + const replicationJobCount = createMemo(() => + replicationJobs.error ? 0 : (replicationJobs() ?? []).length, + ); + const visibleTabs = createMemo(() => + buildVisibleProxmoxTabSpecs(model(), replicationJobCount()), + ); const visibleTabIds = createMemo( () => new Set(visibleTabs().map((tab) => tab.id)), ); @@ -198,6 +209,9 @@ export function ProxmoxPageSurface() { void refetchReplicationJobs()} emptyIcon={} emptyTitle="No replication jobs" emptyDescription="Replication jobs appear here once PVE is configured to replicate guests between nodes." diff --git a/frontend-modern/src/features/proxmox/ProxmoxReplicationTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxReplicationTable.tsx index 41679838b..b7fc93767 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxReplicationTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxReplicationTable.tsx @@ -1,12 +1,4 @@ -import { - For, - Show, - createMemo, - createResource, - createSignal, - type Component, - type JSX, -} from 'solid-js'; +import { For, Show, createMemo, createSignal, type Component, type JSX } from 'solid-js'; import ArrowRightIcon from 'lucide-solid/icons/arrow-right'; import { Card } from '@/components/shared/Card'; import { EmptyState } from '@/components/shared/EmptyState'; @@ -101,11 +93,44 @@ function formatSyncTime(job: ReplicationJob): string { if (job.lastSyncUnix && job.lastSyncUnix > 0) { return formatRelativeTime(job.lastSyncUnix * 1000, { compact: true }); } - const raw = job.lastSyncTime as number | string | undefined; - if (raw) return formatRelativeTime(raw, { compact: true }); + if (job.lastSyncTime) return formatRelativeTime(job.lastSyncTime, { compact: true }); return '—'; } +type NextSyncTone = 'overdue' | 'imminent' | 'normal' | 'muted'; + +const NEXT_SYNC_TONE_CLASS: Record = { + overdue: 'text-red-600 dark:text-red-300 font-semibold', + imminent: 'text-amber-600 dark:text-amber-300', + normal: '', + muted: 'text-muted', +}; + +// An overdue next-sync is the one signal that catches a stalled pvesr +// scheduler even while the last sync still reports ok, so it gets its own +// column instead of folding into the status pill (which mirrors PVE's own +// job state). +function nextSyncFor(job: ReplicationJob): { text: string; tone: NextSyncTone } { + if (!job.enabled) return { text: '—', tone: 'muted' }; + let target = 0; + if (job.nextSyncUnix && job.nextSyncUnix > 0) { + target = job.nextSyncUnix * 1000; + } else if (job.nextSyncTime) { + const raw = job.nextSyncTime; + const parsed = typeof raw === 'number' ? (raw > 1e12 ? raw : raw * 1000) : Date.parse(raw); + if (Number.isFinite(parsed)) target = parsed; + } + if (!target) return { text: '—', tone: 'muted' }; + const minutes = Math.floor((target - Date.now()) / 60_000); + if (minutes < 0) { + const overdue = Math.abs(minutes); + const text = overdue < 60 ? `${overdue}m overdue` : `${Math.floor(overdue / 60)}h overdue`; + return { text, tone: 'overdue' }; + } + if (minutes < 60) return { text: `in ${minutes}m`, tone: minutes < 5 ? 'imminent' : 'normal' }; + return { text: `in ${Math.floor(minutes / 60)}h ${minutes % 60}m`, tone: 'normal' }; +} + function formatDuration(seconds: number | undefined, human: string | undefined): string { const explicit = (human ?? '').trim(); if (explicit) return explicit; @@ -117,7 +142,7 @@ function formatDuration(seconds: number | undefined, human: string | undefined): return `${h}h ${m}m`; } -async function fetchReplicationJobs(): Promise { +export async function fetchReplicationJobs(): Promise { const response = await apiFetch('/api/replication/jobs?platform=proxmox-pve'); if (!response.ok) { throw new Error(`Failed to load replication jobs (${response.status})`); @@ -126,19 +151,23 @@ async function fetchReplicationJobs(): Promise { return Array.isArray(payload?.data) ? payload.data : []; } +// The jobs resource lives in ProxmoxPageSurface (it also gates the +// Replication tab's visibility), so this table is purely presentational. export const ProxmoxReplicationTable: Component<{ + jobs: ReplicationJob[] | undefined; + error: unknown; + onRetry: () => void; emptyIcon: JSX.Element; emptyTitle: string; emptyDescription: string; }> = (props) => { - const [jobs, { refetch }] = createResource(fetchReplicationJobs); const [search, setSearch] = createSignal(''); const [status, setStatus] = createSignal('all'); const filtered = createMemo(() => { const term = search().trim().toLowerCase(); const want = status(); - return (jobs() ?? []).filter((job) => { + return (props.jobs ?? []).filter((job) => { if (want !== 'all' && classifyJob(job) !== want) return false; if (!term) return true; const haystack = [ @@ -158,22 +187,22 @@ export const ProxmoxReplicationTable: Component<{ }); }); - const total = createMemo(() => (jobs() ?? []).length); + const total = createMemo(() => (props.jobs ?? []).length); const visible = createMemo(() => filtered().length); return ( void refetch()} + onClick={() => props.onRetry()} class="inline-flex min-h-10 items-center rounded-md border border-border px-3 py-2 text-sm font-medium hover:bg-surface-hover" > Refresh @@ -184,7 +213,7 @@ export const ProxmoxReplicationTable: Component<{ } > - +
Status @@ -248,6 +277,9 @@ export const ProxmoxReplicationTable: Component<{ Last sync + + Next sync + Duration @@ -262,6 +294,7 @@ export const ProxmoxReplicationTable: Component<{ {(job) => { const classification = classifyJob(job); const ind = indicatorFor(classification); + const next = nextSyncFor(job); const sourceNode = (job.sourceNode ?? '').trim() || '—'; const targetNode = (job.targetNode ?? '').trim() || '—'; return ( @@ -308,6 +341,11 @@ export const ProxmoxReplicationTable: Component<{ > {formatSyncTime(job)} + + {next.text} + diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxPageSurface.contract.test.tsx b/frontend-modern/src/features/proxmox/__tests__/ProxmoxPageSurface.contract.test.tsx index 81e61a85f..d8d7f578a 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxPageSurface.contract.test.tsx +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxPageSurface.contract.test.tsx @@ -109,6 +109,7 @@ vi.mock('../ProxmoxNodesTable', () => ({ vi.mock('../ProxmoxReplicationTable', () => ({ ProxmoxReplicationTable: () =>
, + fetchReplicationJobs: () => Promise.resolve([]), })); describe('ProxmoxPageSurface contract', () => { diff --git a/frontend-modern/src/features/proxmox/__tests__/proxmoxPageModel.test.ts b/frontend-modern/src/features/proxmox/__tests__/proxmoxPageModel.test.ts index 5f2fd436d..86f76ec6f 100644 --- a/frontend-modern/src/features/proxmox/__tests__/proxmoxPageModel.test.ts +++ b/frontend-modern/src/features/proxmox/__tests__/proxmoxPageModel.test.ts @@ -51,10 +51,7 @@ describe('proxmoxPageModel', () => { }); it('returns every node when the search term is empty', () => { - expect(filterProxmoxNodesForSearch([minipc, delly], [debianGo], '')).toEqual([ - minipc, - delly, - ]); + expect(filterProxmoxNodesForSearch([minipc, delly], [debianGo], '')).toEqual([minipc, delly]); }); it('keeps the host node of a matching guest so a guest search does not empty the nodes table', () => { @@ -88,18 +85,6 @@ describe('proxmoxPageModel', () => { platformData: { proxmox: { lastBackup: 1_700_000_000_000 }, }, - recentChanges: [ - { - id: 'replication-1', - observedAt: '2026-05-15T08:00:00.000Z', - resourceId: 'vm-101', - kind: 'activity', - sourceType: 'platform_event', - sourceAdapter: 'proxmox_adapter', - confidence: 'high', - reason: 'Replication job completed', - }, - ], }), makeResource({ id: 'local-zfs', @@ -153,13 +138,10 @@ describe('proxmoxPageModel', () => { guests: [expect.objectContaining({ id: 'vm-101' })], storage: [expect.objectContaining({ id: 'local-zfs' })], }); - expect(model.replicationChanges).toHaveLength(1); - expect(model.replicationChanges[0]).toMatchObject({ - resource: expect.objectContaining({ id: 'vm-101' }), - change: expect.objectContaining({ id: 'replication-1' }), - }); expect(model.resources.map((resource) => resource.id)).not.toContain('docker-host'); - expect(buildVisibleProxmoxTabSpecs(model).map((tab) => tab.id)).toEqual([ + // Replication is gated on the fetched job count, not on anything in the + // resource model: the jobs bypass the unified-resource pipeline. + expect(buildVisibleProxmoxTabSpecs(model, 1).map((tab) => tab.id)).toEqual([ 'overview', 'storage', 'replication', @@ -169,7 +151,7 @@ describe('proxmoxPageModel', () => { ]); }); - it('hides Replication for a PVE estate without replication signals', () => { + it('hides Replication for a PVE estate without replication jobs', () => { const model = buildProxmoxPageModel([ makeResource({ id: 'pve-node-1', @@ -187,7 +169,7 @@ describe('proxmoxPageModel', () => { }), ]); - expect(buildVisibleProxmoxTabSpecs(model).map((tab) => tab.id)).toEqual(['overview']); + expect(buildVisibleProxmoxTabSpecs(model, 0).map((tab) => tab.id)).toEqual(['overview']); }); it('resolves Proxmox suite scope from canonical platform hints', () => { diff --git a/frontend-modern/src/features/proxmox/proxmoxPageModel.ts b/frontend-modern/src/features/proxmox/proxmoxPageModel.ts index 5212d109a..855d23f70 100644 --- a/frontend-modern/src/features/proxmox/proxmoxPageModel.ts +++ b/frontend-modern/src/features/proxmox/proxmoxPageModel.ts @@ -1,5 +1,4 @@ import type { Resource, ResourceMetric, ResourceType } from '@/types/resource'; -import type { ResourceChange } from '@/types/resource'; import { formatProxmoxVersion } from '@/utils/proxmoxVersion'; import { resourceMatchesSearch } from '@/utils/resourceSearchMatch'; @@ -43,16 +42,10 @@ export type ProxmoxPageModel = { pmg: Resource[]; ceph: Resource[]; physicalDisks: Resource[]; - replicationChanges: ProxmoxReplicationChange[]; clusterGroups: ProxmoxClusterGroup[]; summary: ProxmoxPageSummary; }; -export type ProxmoxReplicationChange = { - resource: Resource; - change: ResourceChange; -}; - const PROXMOX_RESOURCE_TYPES = new Set([ 'agent', 'vm', @@ -197,8 +190,7 @@ export function filterProxmoxNodesForSearch( ); return nodes.filter( (node) => - resourceMatchesSearch(node, term) || - matchingGuestNodeNames.has(getResourceNodeName(node)), + resourceMatchesSearch(node, term) || matchingGuestNodeNames.has(getResourceNodeName(node)), ); } @@ -221,25 +213,6 @@ export function getResourceLastBackup(resource: Resource): string | number | nul return typeof value === 'string' || typeof value === 'number' ? value : null; } -const hasReplicationSignal = (change: ResourceChange): boolean => { - const haystack = [ - change.id, - change.resourceId, - change.kind, - change.sourceType, - change.sourceAdapter, - change.reason, - change.from, - change.to, - ...(change.relatedResources ?? []), - ] - .filter((value): value is string => typeof value === 'string') - .join(' ') - .toLowerCase(); - - return haystack.includes('replication') || haystack.includes('replica'); -}; - const hasBackupSignal = (resource: Resource): boolean => { if (getResourceLastBackup(resource) !== null) return true; const haystack = [ @@ -264,21 +237,6 @@ const hasBackupSignal = (resource: Resource): boolean => { ); }; -function buildReplicationChanges(resources: Resource[]): ProxmoxReplicationChange[] { - return resources - .flatMap((resource) => - (resource.recentChanges ?? []) - .filter(hasReplicationSignal) - .map((change) => ({ resource, change })), - ) - .sort((left, right) => { - const observedDelta = - new Date(right.change.observedAt).getTime() - new Date(left.change.observedAt).getTime(); - if (observedDelta !== 0) return observedDelta; - return right.change.id.localeCompare(left.change.id); - }); -} - export function getResourceVersion(resource: Resource): string { const pveVersion = formatProxmoxVersion(resource.proxmox?.pveVersion); if (pveVersion) return pveVersion; @@ -319,7 +277,6 @@ export function buildProxmoxPageModel(resources: Resource[]): ProxmoxPageModel { isRecord(getPlatformData(resource).ceph), ); const physicalDisks = proxmoxResources.filter((resource) => resource.type === 'physical_disk'); - const replicationChanges = buildReplicationChanges(proxmoxResources); const groupsById = new Map(); const ensureGroup = (label: string): ProxmoxClusterGroup => { @@ -377,7 +334,6 @@ export function buildProxmoxPageModel(resources: Resource[]): ProxmoxPageModel { pmg, ceph, physicalDisks, - replicationChanges, clusterGroups, summary: { clusterCount: clusterGroups.filter((group) => group.id !== '__standalone__').length, @@ -398,13 +354,20 @@ export function buildProxmoxPageModel(resources: Resource[]): ProxmoxPageModel { }; } -export function buildVisibleProxmoxTabSpecs(model: ProxmoxPageModel): ProxmoxTabSpec[] { +// Replication deliberately bypasses the unified-resource pipeline (it is +// projected straight from Monitor.ReplicationJobsSnapshot via +// /api/replication/jobs), so the tab is gated on the fetched job count +// rather than on anything derivable from `model`. +export function buildVisibleProxmoxTabSpecs( + model: ProxmoxPageModel, + replicationJobCount: number, +): ProxmoxTabSpec[] { const visible = new Set(['overview']); if (model.storage.length > 0 || model.physicalDisks.length > 0) { visible.add('storage'); } - if (model.replicationChanges.length > 0) { + if (replicationJobCount > 0) { visible.add('replication'); } if (model.resources.some(hasBackupSignal) || model.pbs.length > 0) { diff --git a/frontend-modern/src/types/api.ts b/frontend-modern/src/types/api.ts index f9e273d5e..60de38976 100644 --- a/frontend-modern/src/types/api.ts +++ b/frontend-modern/src/types/api.ts @@ -560,11 +560,13 @@ export interface ReplicationJob { state?: string; status?: string; lastSyncStatus?: string; - lastSyncTime?: number; + // The Go model serializes *time.Time fields as RFC3339 strings; the + // *Unix companions carry the same instants as epoch seconds. + lastSyncTime?: number | string; lastSyncUnix?: number; lastSyncDurationSeconds?: number; lastSyncDurationHuman?: string; - nextSyncTime?: number; + nextSyncTime?: number | string; nextSyncUnix?: number; durationSeconds?: number; durationHuman?: string;