diff --git a/frontend-modern/src/features/proxmox/BackupActivityChart.tsx b/frontend-modern/src/features/proxmox/BackupActivityChart.tsx deleted file mode 100644 index b59d10a3a..000000000 --- a/frontend-modern/src/features/proxmox/BackupActivityChart.tsx +++ /dev/null @@ -1,371 +0,0 @@ -import { createSignal, For, Show } from 'solid-js'; -import type { Accessor, Component } from 'solid-js'; - -import { TooltipPortal } from '@/components/shared/TooltipPortal'; -import { - SUMMARY_CHART_PLOT_AREA_CLASS, - SUMMARY_CHART_SLOT_COMPACT_CLASS, -} from '@/components/shared/summaryChartLayout'; -import { - getRecoveryCompactAxisLabel, - getRecoveryPrettyDateLabel, -} from '@/utils/recoveryDatePresentation'; -import { - getRecoveryTimelineAxisLabelClass, - getRecoveryTimelineAxisTicks, - getRecoveryTimelineChartGapPx, - getRecoveryTimelineChartMinWidthPx, - RECOVERY_TIMELINE_LEGEND_ITEM_CLASS, -} from '@/utils/recoveryTimelineChartPresentation'; -import { - getRecoveryTimelineBarMarkerClass, - getRecoveryTimelineColumnButtonClass, - getRecoveryTimelineEmptyMarkerClass, -} from '@/utils/recoveryTimelinePresentation'; - -import { - BACKUP_ACTIVITY_RANGE_DAYS, - getBackupActivityAxisLabel, - getBackupActivityColumnAriaLabel, - getBackupActivityDayFilterStateLabel, - getBackupActivityPointTotalLabel, - getBackupActivitySegmentPresentation, - getBackupActivityTooltipRows, - type BackupActivityMetricMode, - type BackupActivityNoun, - type BackupActivityPoint, - type BackupActivityRangeDays, - type BackupActivitySegmentKind, - type BackupActivityTimeline, -} from './proxmoxBackupActivityPresentation'; - -interface TooltipState { - dateLabel: string; - point: BackupActivityPoint; - x: number; - y: number; -} - -export interface BackupActivityChartMetricToggle { - mode: Accessor; - onChange: (mode: BackupActivityMetricMode) => void; -} - -interface BackupActivityChartProps { - title: string; - noun: BackupActivityNoun; - segmentKinds: readonly BackupActivitySegmentKind[]; - range: Accessor; - onRangeChange: (days: BackupActivityRangeDays) => void; - timeline: Accessor; - selectedDateKey: Accessor; - onToggleDay: (key: string) => void; - metricMode?: Accessor; - metricToggle?: BackupActivityChartMetricToggle; -} - -export const BackupActivityChart: Component = (props) => { - const [tooltip, setTooltip] = createSignal(null); - - const points = () => props.timeline().points; - const axisMax = () => props.timeline().axisMax; - const hasSelection = () => props.selectedDateKey() !== null; - const metricMode = (): BackupActivityMetricMode => - props.metricMode?.() ?? props.metricToggle?.mode() ?? 'count'; - - const axisTicks = () => - getRecoveryTimelineAxisTicks(points().length, false, props.timeline().labelEvery); - - const chartMinWidthStyle = () => { - const minWidth = getRecoveryTimelineChartMinWidthPx(false, props.range(), points().length); - return minWidth > 0 ? `${minWidth}px` : '100%'; - }; - - const chartGapStyle = () => `${getRecoveryTimelineChartGapPx(props.range())}px`; - - const showTooltip = (target: HTMLElement, point: BackupActivityPoint, dateLabel: string) => { - const rect = target.getBoundingClientRect(); - setTooltip({ - dateLabel, - point, - x: rect.left + rect.width / 2, - y: rect.top, - }); - }; - const hideTooltip = () => setTooltip(null); - - return ( -
-
-
-
{props.title}
- 1}> - - {(kind) => { - const presentation = getBackupActivitySegmentPresentation(kind); - return ( - - - {presentation.label} - - ); - }} - - -
-
- -
- - {(mode) => { - const selected = () => props.metricToggle?.mode() === mode; - return ( - - ); - }} - -
-
-
- - {(days) => { - const selected = () => props.range() === days; - return ( - - ); - }} - -
-
-
- - 0} - fallback={ -
- No activity in the selected window. -
- } - > -
-
-
- - {(step) => { - const value = () => (axisMax() * (2 - step)) / 2; - return {getBackupActivityAxisLabel(value(), metricMode())}; - }} - -
- -
-
-
- - {(point) => { - const total = () => point.total; - const heightPct = () => - axisMax() > 0 ? Math.min(100, (total() / axisMax()) * 100) : 0; - const isSelected = () => props.selectedDateKey() === point.key; - const dateLabel = getRecoveryPrettyDateLabel(point.key); - - return ( -
- -
- ); - }} -
-
- -
- - {(tick) => { - const point = points()[tick.index]; - const isSelected = () => - point ? props.selectedDateKey() === point.key : false; - const alignmentClass = - tick.align === 'start' - ? 'left-0 text-left' - : tick.align === 'end' - ? 'left-full -translate-x-full text-right' - : '-translate-x-1/2 text-center'; - return ( - - - {getRecoveryCompactAxisLabel(point!.key, props.range())} - - - ); - }} - -
-
-
-
- - - - {(t) => ( -
-
-
-
- {t().dateLabel} -
-
- {getBackupActivityPointTotalLabel( - t().point.total, - props.noun, - metricMode(), - )} -
-
-
- {getBackupActivityDayFilterStateLabel( - props.selectedDateKey() === t().point.key, - hasSelection(), - )} -
-
-
    - - {(row) => ( -
  • - - - {row.label} - - {row.value} -
  • - )} -
    -
-
- )} -
-
-
-
-
- ); -}; - -export default BackupActivityChart; diff --git a/frontend-modern/src/features/proxmox/ProxmoxArchivesTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxArchivesTable.tsx deleted file mode 100644 index c5906a7de..000000000 --- a/frontend-modern/src/features/proxmox/ProxmoxArchivesTable.tsx +++ /dev/null @@ -1,273 +0,0 @@ -import { For, Show, type Accessor, type JSX } from 'solid-js'; - -import { Card } from '@/components/shared/Card'; -import { EmptyState } from '@/components/shared/EmptyState'; -import { - Table, - TableBody, - TableCell, - TableHeader, - TableRow, -} from '@/components/shared/Table'; -import { TableCard } from '@/components/shared/TableCard'; -import { formatBytes, formatRelativeTime } from '@/utils/format'; -import { - PLATFORM_TABLE_BODY_CLASS, - PLATFORM_TABLE_CARD_CLASS, - PLATFORM_TABLE_HEADER_ROW_CLASS, - getPlatformTableCellClassForKind, - getPlatformTableHeadClassForKind, -} from '@/features/platformPage/sharedPlatformPage'; -import type { StorageBackup } from '@/types/api'; - -import { classifyArchiveRowAge } from './proxmoxBackupSummaryPresentation'; -import { guestLabel, type ArchiveSortKey } from './proxmoxBackupsTableModel'; -import { RowMetricBar, SortableHead } from './proxmoxBackupsTableShared'; - -// "Source details > Archives" table: vzdump / storage backup files with -// volume, guest, storage, age swatch, size, and (when any archive carries PBS -// metadata) protection + verification columns. Presentational — the parent -// owns the filtered+sorted memo, shared filters, and the size/now scales. -// table-fixed + a weighted colgroup keeps the columns from ballooning on wide -// viewports, accounting for the conditional PBS columns. -export function ProxmoxArchivesTable(props: { - archives: StorageBackup[]; - hasAnyArchives: boolean; - emptyIcon: JSX.Element; - emptyTitle: string; - emptyDescription: string; - sortKey: Accessor; - sortDirection: Accessor<'asc' | 'desc'>; - onSort: (key: ArchiveSortKey) => void; - showPBSColumns: boolean; - sizeMaxBytes: number; - nowMs: number; -}) { - const visibleColumns = () => [ - { id: 'volume', weight: 18 }, - { id: 'guest', weight: 9 }, - { id: 'storage', weight: 11 }, - { id: 'node', weight: 10 }, - { id: 'format', weight: 8 }, - { id: 'created', weight: 12 }, - { id: 'size', weight: 14 }, - ...(props.showPBSColumns - ? [ - { id: 'protected', weight: 9 }, - { id: 'verified', weight: 9 }, - ] - : []), - ]; - const totalColumnWeight = () => - visibleColumns().reduce((sum, column) => sum + column.weight, 0); - - return ( - 0} - fallback={ - - - - } - > - - - - - {(column) => ( - - )} - - - - - - - - - - - - - - - - - - - - {(arc) => ( - - - - {arc.volid} - - - - {guestLabel(arc.type, arc.vmid)} - - - {arc.storage || '—'} - - - {arc.node || '—'} - - - {arc.format || '—'} - - -
- {(() => { - const age = classifyArchiveRowAge(arc.time, props.nowMs); - return ( -
-
- - 0 ? (arc.size / props.sizeMaxBytes) * 100 : 0} - fillClass="bg-blue-500/40 dark:bg-blue-500/40" - label={formatBytes(arc.size)} - tooltip={`${formatBytes(arc.size)} (relative to largest file in view)`} - /> - - - - Unprotected} - > - - Protected - - - - - n/a}> - Pending - - } - > - - Verified - - - - -
- )} -
-
-
-
-
- ); -} diff --git a/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx new file mode 100644 index 000000000..a938410cd --- /dev/null +++ b/frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx @@ -0,0 +1,214 @@ +import { For, Show, type JSX } from 'solid-js'; + +import { StatusDot } from '@/components/shared/StatusDot'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/shared/Table'; +import { TableCard } from '@/components/shared/TableCard'; +import { formatBytes } from '@/utils/format'; +import { + PLATFORM_TABLE_BODY_CLASS, + PLATFORM_TABLE_CARD_CLASS, + PLATFORM_TABLE_HEADER_ROW_CLASS, + getPlatformTableCellClassForKind, + getPlatformTableHeadClassForKind, +} from '@/features/platformPage/sharedPlatformPage'; +import type { Resource, ResourcePBSDatastore } from '@/types/resource'; +import type { StatusIndicatorVariant } from '@/utils/status'; + +// "Backup servers" answers the two questions the coverage table can't: is my +// PBS reachable, and is its datastore about to fill? Datastore fill is the +// headline backup risk — a full datastore silently fails every future backup — +// so it lives here on the Backups page, not buried on the platform Storage tab +// where the rows read as generic "PVE" storage. One row per datastore, labelled +// by its server; a server with no datastore data still gets a reachability row. + +interface BackupServerRow { + key: string; + serverName: string; + online: boolean; + connectionLabel: string; + version?: string; + datastore?: ResourcePBSDatastore; +} + +function serverIsOnline(resource: Resource): boolean { + const status = (resource.status ?? '').toLowerCase(); + const conn = (resource.pbs?.connectionHealth ?? '').toLowerCase(); + if (conn) return conn === 'healthy' || conn === 'ok'; + return status === 'online' || status === 'running'; +} + +function connectionLabel(resource: Resource): string { + const conn = resource.pbs?.connectionHealth?.trim(); + if (conn) return conn.charAt(0).toUpperCase() + conn.slice(1); + return serverIsOnline(resource) ? 'Online' : 'Offline'; +} + +function usagePercent(datastore: ResourcePBSDatastore): number | undefined { + if (typeof datastore.usagePercent === 'number') return datastore.usagePercent; + if (datastore.total > 0) return (datastore.used / datastore.total) * 100; + return undefined; +} + +// >=90% is the silent-backup-failure danger zone; >=75% is the early warning. +function usageVariant(pct: number | undefined): StatusIndicatorVariant { + if (pct === undefined) return 'muted'; + if (pct >= 90) return 'danger'; + if (pct >= 75) return 'warning'; + return 'success'; +} + +function usageToneClass(pct: number | undefined): string { + if (pct === undefined) return 'text-muted'; + if (pct >= 90) return 'text-red-600 dark:text-red-300'; + if (pct >= 75) return 'text-amber-600 dark:text-amber-300'; + return 'text-base-content'; +} + +export function buildBackupServerRows(servers: readonly Resource[]): BackupServerRow[] { + const rows: BackupServerRow[] = []; + // `model().pbs` is scope-filtered (proxmox-pbs), which also catches PBS + // *datastore* storage resources (type 'storage', sources ['pbs']). This table + // is about the server, so keep only actual PBS server instances — otherwise a + // datastore renders as a phantom offline "server" row. + for (const server of servers.filter((resource) => resource.type === 'pbs')) { + const datastores = server.pbs?.datastores ?? []; + if (datastores.length === 0) { + rows.push({ + key: server.id, + serverName: server.name, + online: serverIsOnline(server), + connectionLabel: connectionLabel(server), + version: server.pbs?.version, + }); + continue; + } + for (const datastore of datastores) { + rows.push({ + key: `${server.id}:${datastore.name}`, + serverName: server.name, + online: serverIsOnline(server), + connectionLabel: connectionLabel(server), + version: server.pbs?.version, + datastore, + }); + } + } + return rows; +} + +export function ProxmoxBackupServersTable(props: { + servers: readonly Resource[]; + emptyIcon?: JSX.Element; +}) { + const rows = () => buildBackupServerRows(props.servers); + + return ( + 0}> + + + + + + + + + + + + + Backup server + Status + Version + Datastore + Used + Dedup + + + + + {(row) => { + const pct = () => (row.datastore ? usagePercent(row.datastore) : undefined); + return ( + + + {row.serverName} + + +
+ + + {row.connectionLabel} + +
+
+ + {row.version || '—'} + + + {row.datastore?.name ?? '—'} + + + No datastore data} + > + {(datastore) => ( +
+ + + + {Math.round(pct() ?? 0)}% + + + + {formatBytes(datastore().used)} / {formatBytes(datastore().total)} + +
+ )} +
+
+ + —} + > + {(factor) => <>{factor().toFixed(1)}×} + + +
+ ); + }} +
+
+
+
+
+ ); +} + +export default ProxmoxBackupServersTable; diff --git a/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx index 15af72799..49fa2f646 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxBackupsTable.tsx @@ -1,30 +1,18 @@ import { - For, Show, - createEffect, createMemo, createResource, createSignal, type Component, type JSX, } from 'solid-js'; -import ArchiveIcon from 'lucide-solid/icons/archive'; -import CameraIcon from 'lucide-solid/icons/camera'; -import ActivityIcon from 'lucide-solid/icons/activity'; -import DatabaseIcon from 'lucide-solid/icons/database'; -import ServerIcon from 'lucide-solid/icons/server'; -import ShieldCheckIcon from 'lucide-solid/icons/shield-check'; +import ChevronRightIcon from 'lucide-solid/icons/chevron-right'; import { Card } from '@/components/shared/Card'; import { EmptyState } from '@/components/shared/EmptyState'; import { useSearchParams } from '@solidjs/router'; import { FilterBar, type FilterDef, type FilterSelectOption } from '@/components/shared/FilterBar'; import { useBreakpoint } from '@/hooks/useBreakpoint'; import { apiFetch } from '@/utils/apiClient'; -import { formatBytes } from '@/utils/format'; -import { - recoveryDateKeyFromTimestamp, - getRecoveryFilterDateLabel, -} from '@/utils/recoveryDatePresentation'; import type { BackupTask, GuestSnapshot, @@ -36,164 +24,37 @@ import type { StorageBackup, } from '@/types/api'; import type { Resource } from '@/types/resource'; -import { BackupActivityChart } from './BackupActivityChart'; -import { - buildBackupActivityTimeline, - type BackupActivityMetricMode, - type BackupActivityRangeDays, - type BackupActivitySegmentKind, -} from './proxmoxBackupActivityPresentation'; -import { - buildArchiveCoverageSummary, - buildSnapshotCoverageSummary, - buildTaskOutcomeSummary, - computeMedianTaskDurationSeconds, - getBackupAgeBucketPresentation, - guestKey, - taskDurationSeconds, -} from './proxmoxBackupSummaryPresentation'; import { buildProxmoxBackupRecoveryModel, coverageRowMatchesSearch, isCoverageAttention, - recoverableArtifactMatchesSearch, - type RecoverableArtifact, } from './proxmoxBackupRecoveryModel'; import { - ARCHIVE_SORT_DEFAULT_DIRECTION, COVERAGE_SORT_DEFAULT_DIRECTION, - PBS_SORT_DEFAULT_DIRECTION, - RECOVERABLE_SORT_DEFAULT_DIRECTION, - SNAPSHOT_SORT_DEFAULT_DIRECTION, - TASK_SORT_DEFAULT_DIRECTION, - classifyTaskStatus, - cmpBool, cmpNumber, cmpString, - formatDurationFromSeconds, - guestLabel, - pbsRepositoryLabel, - pbsWorkloadLabel, - type ArchiveSortKey, - type BackupTabId, type CoverageFilterValue, type CoverageSortKey, - type PBSSortKey, - type RecoverableFilterValue, - type RecoverableSortKey, - type SnapshotFilterValue, - type SnapshotGuestRow, - type SnapshotSortKey, - type SourceDetailTabId, - type TaskSortKey, } from './proxmoxBackupsTableModel'; -import { - ARCHIVE_STATUS_FILTERS, - COVERAGE_FILTERS, - PBS_STATUS_FILTERS, - RECOVERABLE_FILTERS, - SNAPSHOT_FILTERS, - TASK_STATUS_FILTERS, - artifactStateLabel, -} from './proxmoxBackupsTableShared'; -import { ProxmoxArchivesTable } from './ProxmoxArchivesTable'; +import { COVERAGE_FILTERS } from './proxmoxBackupsTableShared'; import { ProxmoxBackupsCoverageStrip } from './ProxmoxBackupsCoverageStrip'; +import { ProxmoxBackupServersTable } from './ProxmoxBackupServersTable'; import { ProxmoxCoverageTable } from './ProxmoxCoverageTable'; -import { ProxmoxPbsTable } from './ProxmoxPbsTable'; -import { ProxmoxRecoverableTable } from './ProxmoxRecoverableTable'; -import { ProxmoxSnapshotsTable } from './ProxmoxSnapshotsTable'; -import { ProxmoxTasksTable } from './ProxmoxTasksTable'; -// Proxmox backups are intentionally organized around operator questions, not -// storage-source mechanics: -// - Workload coverage answers "does this workload have a backup?" by default. -// - Restore points answers "what exactly can I restore?" across every source. -// - Source details keeps PBS/PVE evidence available without making those -// implementation-specific tables equal-weight primary destinations. -// - Job history shows whether backup jobs are actually running successfully. +// The Proxmox backups surface answers one operator question: "is every guest +// backed up, recently, and did the last job work?" That question IS the whole +// surface — one row per workload, a posture dot, the latest restore point, and +// the per-source evidence (PBS snapshots, PVE archives, guest snapshots) tucked +// into each row's expansion for anyone who wants to drill in. +// +// The earlier per-source browsers (PBS artifacts / Snapshots / Backup files), +// the "Restore points" and "Job history" tabs, and the per-day activity charts +// were removed deliberately. A monitor is not a console: the live PBS and PVE +// web UIs already own the forensic per-artifact view, and reproducing it +// read-only here only buried the one question a backup monitor exists to +// answer. Nothing was deleted that a power user needs — the evidence lives in +// the row expansion (demote, not delete). -interface BackupTabSpec { - id: BackupTabId; - label: string; - icon: () => JSX.Element; - emptyTitle: string; - emptyDescription: string; -} - -const TABS: BackupTabSpec[] = [ - { - id: 'coverage', - label: 'Workload coverage', - icon: () =>