diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index 0492947e..bbd14aff 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -1650,6 +1650,22 @@ export type ListSnapshotsResponses = { tags: Array; time: number; hostname?: string; + summary?: { + backup_end: string; + backup_start: string; + data_added: number; + data_blobs: number; + dirs_changed: number; + dirs_new: number; + dirs_unmodified: number; + files_changed: number; + files_new: number; + files_unmodified: number; + total_bytes_processed: number; + total_files_processed: number; + tree_blobs: number; + data_added_packed?: number; + }; }>; }; @@ -1720,6 +1736,22 @@ export type GetSnapshotDetailsResponses = { tags: Array; time: number; hostname?: string; + summary?: { + backup_end: string; + backup_start: string; + data_added: number; + data_blobs: number; + dirs_changed: number; + dirs_new: number; + dirs_unmodified: number; + files_changed: number; + files_new: number; + files_unmodified: number; + total_bytes_processed: number; + total_files_processed: number; + tree_blobs: number; + data_added_packed?: number; + }; }; }; diff --git a/app/client/components/backup-summary-card.tsx b/app/client/components/backup-summary-card.tsx new file mode 100644 index 00000000..05027685 --- /dev/null +++ b/app/client/components/backup-summary-card.tsx @@ -0,0 +1,98 @@ +import { Card, CardContent } from "~/client/components/ui/card"; +import { ByteSize } from "~/client/components/bytes-size"; +import { formatDuration } from "~/utils/utils"; + +type BackupSummary = { + backup_start: string; + backup_end: string; + files_new: number; + files_changed: number; + files_unmodified: number; + dirs_new: number; + dirs_changed: number; + dirs_unmodified: number; + data_blobs: number; + tree_blobs: number; + data_added: number; + data_added_packed?: number | null; + total_files_processed: number; + total_bytes_processed: number; +}; + +type Props = { + summary?: BackupSummary | null; +}; + +const formatCount = (value: number) => value.toLocaleString(); + +const getDurationLabel = (start: string, end: string) => { + const startMs = new Date(start).getTime(); + const endMs = new Date(end).getTime(); + if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs < startMs) return "-"; + return formatDuration(Math.round((endMs - startMs) / 1000)); +}; + +export const BackupSummaryCard = ({ summary }: Props) => { + if (!summary) return null; + + const durationLabel = getDurationLabel(summary.backup_start, summary.backup_end); + + const topStats = [ + { + label: "Data added", + value: , + }, + { + label: "Data stored", + value: , + }, + { + label: "Files processed", + value: formatCount(summary.total_files_processed), + }, + { + label: "Bytes processed", + value: , + }, + { + label: "Duration", + value: durationLabel, + }, + ]; + + const detailStats = [ + { label: "New files", value: formatCount(summary.files_new) }, + { label: "Changed files", value: formatCount(summary.files_changed) }, + { label: "Unmodified files", value: formatCount(summary.files_unmodified) }, + { label: "New dirs", value: formatCount(summary.dirs_new) }, + { label: "Changed dirs", value: formatCount(summary.dirs_changed) }, + { label: "Unmodified dirs", value: formatCount(summary.dirs_unmodified) }, + { label: "Data blobs", value: formatCount(summary.data_blobs) }, + { label: "Tree blobs", value: formatCount(summary.tree_blobs) }, + ]; + + return ( + + +
+ {topStats.map((stat) => ( +
+ {stat.label} + {stat.value} +
+ ))} +
+
+
+ {detailStats.map((stat) => ( +
+ {stat.value} + {stat.label} +
+ ))} +
+
+
+
+ ); +}; diff --git a/app/client/components/bytes-size.tsx b/app/client/components/bytes-size.tsx index 979d1445..0ffa5fad 100644 --- a/app/client/components/bytes-size.tsx +++ b/app/client/components/bytes-size.tsx @@ -1,4 +1,5 @@ import type React from "react"; +import { formatBytes } from "~/utils/format-bytes"; type ByteSizeProps = { bytes: number; @@ -12,71 +13,6 @@ type ByteSizeProps = { fallback?: string; // shown if bytes is not a finite number (default: '—') }; -const SI_UNITS = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"] as const; -const IEC_UNITS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"] as const; - -type FormatBytesResult = { - text: string; - unit: string; - unitIndex: number; - numeric: number; // numeric value before formatting (with sign) -}; - -export function formatBytes( - bytes: number, - options?: { - base?: 1000 | 1024; - maximumFractionDigits?: number; - smartRounding?: boolean; - locale?: string | string[]; - }, -): FormatBytesResult { - const { base = 1000, maximumFractionDigits = 2, smartRounding = true, locale } = options ?? {}; - - if (!Number.isFinite(bytes)) { - return { - text: "—", - unit: "", - unitIndex: 0, - numeric: NaN, - }; - } - - const units = base === 1024 ? IEC_UNITS : SI_UNITS; - - const sign = Math.sign(bytes) || 1; - const abs = Math.abs(bytes); - - let idx = 0; - if (abs > 0) { - idx = Math.floor(Math.log(abs) / Math.log(base)); - if (!Number.isFinite(idx)) idx = 0; - idx = Math.max(0, Math.min(idx, units.length - 1)); - } - - const numeric = (abs / base ** idx) * sign; - - const maxFrac = (() => { - if (!smartRounding) return maximumFractionDigits; - const v = Math.abs(numeric); - if (v >= 100) return 0; - if (v >= 10) return Math.min(1, maximumFractionDigits); - return maximumFractionDigits; - })(); - - const text = new Intl.NumberFormat(locale, { - minimumFractionDigits: 0, - maximumFractionDigits: maxFrac, - }).format(numeric); - - return { - text, - unit: units[idx], - unitIndex: idx, - numeric, - }; -} - export function ByteSize(props: ByteSizeProps) { const { bytes, @@ -95,9 +31,10 @@ export function ByteSize(props: ByteSizeProps) { maximumFractionDigits, smartRounding, locale, + fallback, }); - if (text === "—") { + if (text === fallback) { return ( {fallback} diff --git a/app/client/components/file-tree.tsx b/app/client/components/file-tree.tsx index 9a39ca7b..1e5aa409 100644 --- a/app/client/components/file-tree.tsx +++ b/app/client/components/file-tree.tsx @@ -529,7 +529,7 @@ const File = memo(({ file, onFileSelect, selected, withCheckbox, checked, onChec {name} {typeof size === "number" && ( - + )} diff --git a/app/client/hooks/use-server-events.ts b/app/client/hooks/use-server-events.ts index 0330223f..688ec98c 100644 --- a/app/client/hooks/use-server-events.ts +++ b/app/client/hooks/use-server-events.ts @@ -21,6 +21,22 @@ export interface BackupEvent { volumeName: string; repositoryName: string; status?: "success" | "error"; + summary?: { + files_new: number; + files_changed: number; + files_unmodified: number; + dirs_new: number; + dirs_changed: number; + dirs_unmodified: number; + data_blobs: number; + tree_blobs: number; + data_added: number; + data_added_packed?: number; + total_files_processed: number; + total_bytes_processed: number; + total_duration: number; + snapshot_id: string; + }; } export interface BackupProgressEvent { diff --git a/app/client/modules/backups/components/backup-progress-card.tsx b/app/client/modules/backups/components/backup-progress-card.tsx index f654798a..50ea5f03 100644 --- a/app/client/modules/backups/components/backup-progress-card.tsx +++ b/app/client/modules/backups/components/backup-progress-card.tsx @@ -1,9 +1,10 @@ import { useEffect, useState } from "react"; -import { ByteSize, formatBytes } from "~/client/components/bytes-size"; +import { ByteSize } from "~/client/components/bytes-size"; import { Card } from "~/client/components/ui/card"; import { Progress } from "~/client/components/ui/progress"; import { type BackupProgressEvent, useServerEvents } from "~/client/hooks/use-server-events"; import { formatDuration } from "~/utils/utils"; +import { formatBytes } from "../../../../utils/format-bytes"; type Props = { scheduleId: number; @@ -69,7 +70,7 @@ export const BackupProgressCard = ({ scheduleId }: Props) => {

{progress ? ( <> - / + / ) : ( "—" diff --git a/app/client/modules/backups/components/snapshot-timeline.tsx b/app/client/modules/backups/components/snapshot-timeline.tsx index 02092a22..40786118 100644 --- a/app/client/modules/backups/components/snapshot-timeline.tsx +++ b/app/client/modules/backups/components/snapshot-timeline.tsx @@ -80,7 +80,7 @@ export const SnapshotTimeline = (props: Props) => {

{formatShortDate(date)}
{formatTime(date)}
- +
diff --git a/app/client/modules/backups/routes/backup-details.tsx b/app/client/modules/backups/routes/backup-details.tsx index e5bd557d..9e31ad3a 100644 --- a/app/client/modules/backups/routes/backup-details.tsx +++ b/app/client/modules/backups/routes/backup-details.tsx @@ -30,6 +30,7 @@ import { SnapshotFileBrowser } from "../components/snapshot-file-browser"; import { SnapshotTimeline } from "../components/snapshot-timeline"; import { ScheduleNotificationsConfig } from "../components/schedule-notifications-config"; import { ScheduleMirrorsConfig } from "../components/schedule-mirrors-config"; +import { BackupSummaryCard } from "~/client/components/backup-summary-card"; import { cn } from "~/client/lib/utils"; import type { BackupSchedule, @@ -251,6 +252,7 @@ export function ScheduleDetailsPage(props: Props) { error={failureReason?.message} onSnapshotSelect={setSelectedSnapshotId} /> + {selectedSnapshot && ( )} + {data && } ); } diff --git a/app/server/core/events.ts b/app/server/core/events.ts index ec6776a9..cd60a17e 100644 --- a/app/server/core/events.ts +++ b/app/server/core/events.ts @@ -26,6 +26,22 @@ interface ServerEvents { volumeName: string; repositoryName: string; status: "success" | "error" | "stopped" | "warning"; + summary?: { + files_new: number; + files_changed: number; + files_unmodified: number; + dirs_new: number; + dirs_changed: number; + dirs_unmodified: number; + data_blobs: number; + tree_blobs: number; + data_added: number; + data_added_packed?: number; + total_files_processed: number; + total_bytes_processed: number; + total_duration: number; + snapshot_id: string; + }; }) => void; "mirror:started": (data: { organizationId: string; diff --git a/app/server/modules/backups/backups.execution.ts b/app/server/modules/backups/backups.execution.ts index da639ea7..65f3bd0c 100644 --- a/app/server/modules/backups/backups.execution.ts +++ b/app/server/modules/backups/backups.execution.ts @@ -1,6 +1,6 @@ import { NotFoundError, BadRequestError, ConflictError } from "http-errors-enhanced"; import type { BackupSchedule, Volume, Repository } from "../../db/schema"; -import { restic } from "../../utils/restic"; +import { restic, type BackupOutput } from "../../utils/restic"; import { logger } from "../../utils/logger"; import { cache } from "../../utils/cache"; import { getVolumePath } from "../volumes/helpers"; @@ -125,14 +125,25 @@ const runBackupOperation = async (ctx: BackupContext, signal: AbortSignal) => { }); }, }); - return result.exitCode; + return result; } finally { releaseBackupLock(); } }; -const finalizeSuccessfulBackup = async (ctx: BackupContext, scheduleId: number, exitCode: number) => { +const buildBackupSummary = (result: BackupOutput | null | undefined) => { + if (!result) return undefined; + return result; +}; + +const finalizeSuccessfulBackup = async ( + ctx: BackupContext, + scheduleId: number, + exitCode: number, + result: BackupOutput | null, +) => { const finalStatus = exitCode === 0 ? "success" : "warning"; + const summary = buildBackupSummary(result); if (ctx.schedule.retentionPolicy) { void runForget(scheduleId).catch((error) => { @@ -171,6 +182,7 @@ const finalizeSuccessfulBackup = async (ctx: BackupContext, scheduleId: number, volumeName: ctx.volume.name, repositoryName: ctx.repository.name, status: finalStatus, + summary, }); notificationsService @@ -178,6 +190,7 @@ const finalizeSuccessfulBackup = async (ctx: BackupContext, scheduleId: number, volumeName: ctx.volume.name, repositoryName: ctx.repository.name, scheduleName: ctx.schedule.name, + summary, }) .catch((error) => { logger.error(`Failed to send backup success notification: ${toMessage(error)}`); @@ -259,8 +272,8 @@ const executeBackup = async (scheduleId: number, manual = false): Promise runningBackups.set(scheduleId, abortController); try { - const exitCode = await runBackupOperation(ctx, abortController.signal); - await finalizeSuccessfulBackup(ctx, scheduleId, exitCode); + const backupResult = await runBackupOperation(ctx, abortController.signal); + await finalizeSuccessfulBackup(ctx, scheduleId, backupResult.exitCode, backupResult.result); } catch (error) { await handleBackupFailure(scheduleId, ctx.organizationId, error, ctx); } finally { diff --git a/app/server/modules/events/events.controller.ts b/app/server/modules/events/events.controller.ts index 7e913d98..709ee4c3 100644 --- a/app/server/modules/events/events.controller.ts +++ b/app/server/modules/events/events.controller.ts @@ -54,6 +54,22 @@ export const eventsController = new Hono().use(requireAuth).get("/", (c) => { volumeName: string; repositoryName: string; status: "success" | "error" | "stopped" | "warning"; + summary?: { + files_new: number; + files_changed: number; + files_unmodified: number; + dirs_new: number; + dirs_changed: number; + dirs_unmodified: number; + data_blobs: number; + tree_blobs: number; + data_added: number; + data_added_packed?: number; + total_files_processed: number; + total_bytes_processed: number; + total_duration: number; + snapshot_id: string; + }; }) => { if (data.organizationId !== organizationId) return; await stream.writeSSE({ diff --git a/app/server/modules/notifications/notifications.service.ts b/app/server/modules/notifications/notifications.service.ts index 09d7ad60..a458d30e 100644 --- a/app/server/modules/notifications/notifications.service.ts +++ b/app/server/modules/notifications/notifications.service.ts @@ -9,11 +9,14 @@ import { import { cryptoUtils } from "../../utils/crypto"; import { logger } from "../../utils/logger"; import { sendNotification } from "../../utils/shoutrrr"; +import type { BackupOutput } from "../../utils/restic"; +import { formatDuration } from "~/utils/utils"; import { buildShoutrrrUrl } from "./builders"; import { notificationConfigSchema, type NotificationConfig, type NotificationEvent } from "~/schemas/notifications"; import { toMessage } from "../../utils/errors"; import { type } from "arktype"; import { getOrganizationId } from "~/server/core/request-context"; +import { formatBytes } from "~/utils/format-bytes"; const listDestinations = async () => { const organizationId = getOrganizationId(); @@ -315,6 +318,50 @@ const updateScheduleNotifications = async ( return getScheduleNotifications(scheduleId); }; +type BackupSummary = Omit; + +const formatBytesText = (bytes: number) => { + const { text, unit } = formatBytes(bytes, { + base: 1024, + locale: "en-US", + fallback: "-", + }); + + return unit ? `${text} ${unit}` : text; +}; + +const buildBackupSummaryLines = (summary?: BackupSummary) => { + if (!summary) return []; + + const safeNumber = (value: number | undefined) => (typeof value === "number" && Number.isFinite(value) ? value : 0); + const safeCountText = (value: number | undefined) => safeNumber(value).toLocaleString(); + const safeBytesText = (value: number | undefined) => formatBytesText(safeNumber(value)); + const safeDurationText = (value: number | undefined) => + typeof value === "number" && Number.isFinite(value) ? formatDuration(Math.round(value)) : "N/A"; + const snapshotText = summary.snapshot_id ?? "N/A"; + + const lines = [ + "Overview:", + `- Data added: ${safeBytesText(summary.data_added)}`, + summary.data_added_packed !== undefined ? `- Data stored: ${safeBytesText(summary.data_added_packed)}` : null, + `- Total files processed: ${safeCountText(summary.total_files_processed)}`, + `- Total bytes processed: ${safeBytesText(summary.total_bytes_processed)}`, + "Backup Statistics:", + `- Files new: ${safeCountText(summary.files_new)}`, + `- Files changed: ${safeCountText(summary.files_changed)}`, + `- Files unmodified: ${safeCountText(summary.files_unmodified)}`, + `- Dirs new: ${safeCountText(summary.dirs_new)}`, + `- Dirs changed: ${safeCountText(summary.dirs_changed)}`, + `- Dirs unmodified: ${safeCountText(summary.dirs_unmodified)}`, + `- Data blobs: ${safeCountText(summary.data_blobs)}`, + `- Tree blobs: ${safeCountText(summary.tree_blobs)}`, + `- Total duration: ${safeDurationText(summary.total_duration)}`, + `- Snapshot: ${snapshotText}`, + ]; + + return lines.filter((line): line is string => Boolean(line)); +}; + const sendBackupNotification = async ( scheduleId: number, event: NotificationEvent, @@ -327,6 +374,7 @@ const sendBackupNotification = async ( filesProcessed?: number; bytesProcessed?: string; snapshotId?: string; + summary?: BackupSummary; }, ) => { try { @@ -406,9 +454,17 @@ function buildNotificationMessage( filesProcessed?: number; bytesProcessed?: string; snapshotId?: string; + summary?: BackupSummary; }, ) { const backupName = context.scheduleName ?? "backup"; + const derivedDuration = + context.duration ?? (context.summary?.total_duration ? context.summary.total_duration * 1000 : undefined); + const derivedFilesProcessed = context.filesProcessed ?? context.summary?.total_files_processed; + const derivedBytesProcessed = + context.bytesProcessed ?? (context.summary ? formatBytesText(context.summary.total_bytes_processed) : undefined); + const derivedSnapshotId = context.snapshotId ?? context.summary?.snapshot_id; + const summaryLines = buildBackupSummaryLines(context.summary); switch (event) { case "start": @@ -430,10 +486,11 @@ function buildNotificationMessage( `Volume: ${context.volumeName}`, `Repository: ${context.repositoryName}`, context.scheduleName ? `Schedule: ${context.scheduleName}` : null, - context.duration ? `Duration: ${Math.round(context.duration / 1000)}s` : null, - context.filesProcessed !== undefined ? `Files: ${context.filesProcessed}` : null, - context.bytesProcessed ? `Size: ${context.bytesProcessed}` : null, - context.snapshotId ? `Snapshot: ${context.snapshotId}` : null, + derivedDuration ? `Duration: ${Math.round(derivedDuration / 1000)}s` : null, + derivedFilesProcessed !== undefined ? `Files: ${derivedFilesProcessed.toLocaleString()}` : null, + derivedBytesProcessed ? `Size: ${derivedBytesProcessed}` : null, + derivedSnapshotId ? `Snapshot: ${derivedSnapshotId}` : null, + ...summaryLines, ] .filter(Boolean) .join("\n"), @@ -446,11 +503,12 @@ function buildNotificationMessage( `Volume: ${context.volumeName}`, `Repository: ${context.repositoryName}`, context.scheduleName ? `Schedule: ${context.scheduleName}` : null, - context.duration ? `Duration: ${Math.round(context.duration / 1000)}s` : null, - context.filesProcessed !== undefined ? `Files: ${context.filesProcessed}` : null, - context.bytesProcessed ? `Size: ${context.bytesProcessed}` : null, - context.snapshotId ? `Snapshot: ${context.snapshotId}` : null, + derivedDuration ? `Duration: ${Math.round(derivedDuration / 1000)}s` : null, + derivedFilesProcessed !== undefined ? `Files: ${derivedFilesProcessed.toLocaleString()}` : null, + derivedBytesProcessed ? `Size: ${derivedBytesProcessed}` : null, + derivedSnapshotId ? `Snapshot: ${derivedSnapshotId}` : null, context.error ? `Warning: ${context.error}` : null, + ...summaryLines, ] .filter(Boolean) .join("\n"), diff --git a/app/server/modules/repositories/repositories.controller.ts b/app/server/modules/repositories/repositories.controller.ts index 2f762cef..0a0ee8eb 100644 --- a/app/server/modules/repositories/repositories.controller.ts +++ b/app/server/modules/repositories/repositories.controller.ts @@ -116,6 +116,7 @@ export const repositoriesController = new Hono() size: summary?.total_bytes_processed || 0, time: new Date(snapshot.time).getTime(), retentionCategories: retentionCategories.get(snapshot.short_id) ?? [], + summary: summary, }; }); diff --git a/app/server/modules/repositories/repositories.dto.ts b/app/server/modules/repositories/repositories.dto.ts index 543337bc..d7e234cf 100644 --- a/app/server/modules/repositories/repositories.dto.ts +++ b/app/server/modules/repositories/repositories.dto.ts @@ -180,6 +180,22 @@ export const snapshotSchema = type({ tags: "string[]", retentionCategories: "string[]", hostname: "string?", + summary: type({ + backup_start: "string", + backup_end: "string", + files_new: "number", + files_changed: "number", + files_unmodified: "number", + dirs_new: "number", + dirs_changed: "number", + dirs_unmodified: "number", + data_blobs: "number", + tree_blobs: "number", + data_added: "number", + data_added_packed: "number?", + total_files_processed: "number", + total_bytes_processed: "number", + }).optional(), }); const listSnapshotsResponse = snapshotSchema.array(); diff --git a/app/server/utils/restic.ts b/app/server/utils/restic.ts index f0c1b76e..43c10bb3 100644 --- a/app/server/utils/restic.ts +++ b/app/server/utils/restic.ts @@ -26,6 +26,7 @@ const backupOutputSchema = type({ data_blobs: "number", tree_blobs: "number", data_added: "number", + data_added_packed: "number?", total_files_processed: "number", total_bytes_processed: "number", total_duration: "number", diff --git a/app/utils/format-bytes.ts b/app/utils/format-bytes.ts new file mode 100644 index 00000000..01a03da9 --- /dev/null +++ b/app/utils/format-bytes.ts @@ -0,0 +1,68 @@ +const SI_UNITS = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"] as const; +const IEC_UNITS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"] as const; + +export type FormatBytesResult = { + text: string; + unit: string; + unitIndex: number; + numeric: number; +}; + +export type FormatBytesOptions = { + base?: 1000 | 1024; + maximumFractionDigits?: number; + smartRounding?: boolean; + locale?: string | string[]; + fallback?: string; +}; + +export function formatBytes(bytes: number, options?: FormatBytesOptions): FormatBytesResult { + const { + base = 1000, + maximumFractionDigits = 2, + smartRounding = true, + locale, + fallback = "—", + } = options ?? {}; + + if (!Number.isFinite(bytes)) { + return { + text: fallback, + unit: "", + unitIndex: 0, + numeric: NaN, + }; + } + + const units = base === 1024 ? IEC_UNITS : SI_UNITS; + const sign = Math.sign(bytes) || 1; + const abs = Math.abs(bytes); + + let idx = 0; + if (abs > 0) { + idx = Math.floor(Math.log(abs) / Math.log(base)); + if (!Number.isFinite(idx)) idx = 0; + idx = Math.max(0, Math.min(idx, units.length - 1)); + } + + const numeric = (abs / base ** idx) * sign; + const maxFrac = (() => { + if (!smartRounding) return maximumFractionDigits; + const value = Math.abs(numeric); + if (value >= 100) return 0; + if (value >= 10) return Math.min(1, maximumFractionDigits); + return maximumFractionDigits; + })(); + + const text = new Intl.NumberFormat(locale, { + minimumFractionDigits: 0, + maximumFractionDigits: maxFrac, + }).format(numeric); + + return { + text, + unit: units[idx], + unitIndex: idx, + numeric, + }; +}