mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Gate the Proxmox Replication tab on fetched jobs and restore Next sync
The tab was gated on replicationChanges, recentChanges keyword-matched
for replication/replica. No production path emits such a change: change
emission produces generic reasons, ChangeKind has no replication value,
and no replication alerts exist. So when 2315d8330 removed the
hasPveEstate clause the tab became unreachable on every estate,
including ones with active pvesr jobs (rc.6 shipped this way). The
backend was intact throughout: monitor_pve_replication.go collects and
/api/replication/jobs serves; only the gate was dead. The vitest that
covered the gate passed by fabricating a change reason ("Replication
job completed") that nothing in internal/ generates.
Replication deliberately bypasses the unified-resource pipeline, so the
tab is now gated on its canonical source instead: ProxmoxPageSurface
owns the jobs fetch, shows the tab when jobs exist, and feeds the same
data to a now-presentational ProxmoxReplicationTable.
Also restore v5's Next sync column (countdown with overdue/imminent
tones), which the v6 rebuild dropped while the payload kept carrying
nextSyncTime/nextSyncUnix, and type the *Time fields as the RFC3339
strings the Go model actually serializes. The page model keyword
machinery and its fabricated-change test are removed; tab visibility
tests pass the job count explicitly.
This commit is contained in:
@@ -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<ProxmoxPageTabId>(visibleTabs().map((tab) => tab.id)),
|
||||
);
|
||||
@@ -198,6 +209,9 @@ export function ProxmoxPageSurface() {
|
||||
</Show>
|
||||
<Show when={activeTab() === 'replication'}>
|
||||
<ProxmoxReplicationTable
|
||||
jobs={replicationJobs.error ? undefined : replicationJobs()}
|
||||
error={replicationJobs.error}
|
||||
onRetry={() => void refetchReplicationJobs()}
|
||||
emptyIcon={<ProxmoxIcon class="h-6 w-6 text-slate-400" />}
|
||||
emptyTitle="No replication jobs"
|
||||
emptyDescription="Replication jobs appear here once PVE is configured to replicate guests between nodes."
|
||||
|
||||
@@ -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<NextSyncTone, string> = {
|
||||
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<ReplicationJob[]> {
|
||||
export async function fetchReplicationJobs(): Promise<ReplicationJob[]> {
|
||||
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<ReplicationJob[]> {
|
||||
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<ReplicationJob[]>(fetchReplicationJobs);
|
||||
const [search, setSearch] = createSignal('');
|
||||
const [status, setStatus] = createSignal<ReplicationStatusFilter>('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 (
|
||||
<Show
|
||||
when={!jobs.error}
|
||||
when={!props.error}
|
||||
fallback={
|
||||
<Card padding="lg">
|
||||
<EmptyState
|
||||
icon={props.emptyIcon}
|
||||
title="Could not load replication jobs"
|
||||
description={(jobs.error as Error | undefined)?.message ?? 'Refresh to retry.'}
|
||||
description={(props.error as Error | undefined)?.message ?? 'Refresh to retry.'}
|
||||
actions={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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<{
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={jobs() !== undefined}
|
||||
when={props.jobs !== undefined}
|
||||
fallback={
|
||||
<Card padding="lg">
|
||||
<EmptyState
|
||||
@@ -233,7 +262,7 @@ export const ProxmoxReplicationTable: Component<{
|
||||
}
|
||||
>
|
||||
<TableCard class={PLATFORM_TABLE_CARD_CLASS}>
|
||||
<Table class="min-w-[1100px] text-xs">
|
||||
<Table class="min-w-[1200px] text-xs">
|
||||
<TableHeader>
|
||||
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
|
||||
<TableHead class={getPlatformTableHeadClassForKind('text')}>Status</TableHead>
|
||||
@@ -248,6 +277,9 @@ export const ProxmoxReplicationTable: Component<{
|
||||
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
|
||||
Last sync
|
||||
</TableHead>
|
||||
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
|
||||
Next sync
|
||||
</TableHead>
|
||||
<TableHead class={getPlatformTableHeadClassForKind('numeric-value')}>
|
||||
Duration
|
||||
</TableHead>
|
||||
@@ -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)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
|
||||
>
|
||||
<span class={NEXT_SYNC_TONE_CLASS[next.tone]}>{next.text}</span>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
|
||||
>
|
||||
|
||||
@@ -109,6 +109,7 @@ vi.mock('../ProxmoxNodesTable', () => ({
|
||||
|
||||
vi.mock('../ProxmoxReplicationTable', () => ({
|
||||
ProxmoxReplicationTable: () => <div data-testid="replication-table" />,
|
||||
fetchReplicationJobs: () => Promise.resolve([]),
|
||||
}));
|
||||
|
||||
describe('ProxmoxPageSurface contract', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<ResourceType>([
|
||||
'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<string, ProxmoxClusterGroup>();
|
||||
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<ProxmoxPageTabId>(['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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user