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..5ceb554a --- /dev/null +++ b/app/client/components/backup-summary-card.tsx @@ -0,0 +1,82 @@ +import { Card, CardContent } from "~/client/components/ui/card"; +import { ByteSize } from "~/client/components/bytes-size"; +import type { ResticSnapshotSummaryDto } from "~/schemas/restic-dto"; +import { formatDuration } from "~/utils/utils"; + +type Props = { + summary?: ResticSnapshotSummaryDto | 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..1e1ae264 100644 --- a/app/client/hooks/use-server-events.ts +++ b/app/client/hooks/use-server-events.ts @@ -1,5 +1,10 @@ import { useEffect, useRef } from "react"; import { useQueryClient } from "@tanstack/react-query"; +import type { + BackupCompletedEventDto, + BackupProgressEventDto, + BackupStartedEventDto, +} from "~/schemas/events-dto"; type ServerEventType = | "connected" @@ -16,26 +21,6 @@ type ServerEventType = | "doctor:completed" | "doctor:cancelled"; -export interface BackupEvent { - scheduleId: number; - volumeName: string; - repositoryName: string; - status?: "success" | "error"; -} - -export interface BackupProgressEvent { - scheduleId: number; - volumeName: string; - repositoryName: string; - seconds_elapsed: number; - percent_done: number; - total_files: number; - files_done: number; - total_bytes: number; - bytes_done: number; - current_files: string[]; -} - export interface VolumeEvent { volumeName: string; } @@ -87,7 +72,7 @@ export function useServerEvents() { eventSource.addEventListener("heartbeat", () => {}); eventSource.addEventListener("backup:started", (e) => { - const data = JSON.parse(e.data) as BackupEvent; + const data = JSON.parse(e.data) as BackupStartedEventDto; console.info("[SSE] Backup started:", data); handlersRef.current.get("backup:started")?.forEach((handler) => { @@ -96,7 +81,7 @@ export function useServerEvents() { }); eventSource.addEventListener("backup:progress", (e) => { - const data = JSON.parse(e.data) as BackupProgressEvent; + const data = JSON.parse(e.data) as BackupProgressEventDto; handlersRef.current.get("backup:progress")?.forEach((handler) => { handler(data); @@ -104,7 +89,7 @@ export function useServerEvents() { }); eventSource.addEventListener("backup:completed", (e) => { - const data = JSON.parse(e.data) as BackupEvent; + const data = JSON.parse(e.data) as BackupCompletedEventDto; console.info("[SSE] Backup completed:", data); void queryClient.invalidateQueries(); diff --git a/app/client/modules/backups/components/backup-progress-card.tsx b/app/client/modules/backups/components/backup-progress-card.tsx index f654798a..62cfb2d3 100644 --- a/app/client/modules/backups/components/backup-progress-card.tsx +++ b/app/client/modules/backups/components/backup-progress-card.tsx @@ -1,9 +1,11 @@ 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 { useServerEvents } from "~/client/hooks/use-server-events"; +import type { BackupProgressEventDto } from "~/schemas/events-dto"; import { formatDuration } from "~/utils/utils"; +import { formatBytes } from "~/utils/format-bytes"; type Props = { scheduleId: number; @@ -11,11 +13,11 @@ type Props = { export const BackupProgressCard = ({ scheduleId }: Props) => { const { addEventListener } = useServerEvents(); - const [progress, setProgress] = useState(null); + const [progress, setProgress] = useState(null); useEffect(() => { const unsubscribe = addEventListener("backup:progress", (data) => { - const progressData = data as BackupProgressEvent; + const progressData = data as BackupProgressEventDto; if (progressData.scheduleId === scheduleId) { setProgress(progressData); } @@ -69,7 +71,9 @@ 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/schemas/events-dto.ts b/app/schemas/events-dto.ts new file mode 100644 index 00000000..4ff9b02f --- /dev/null +++ b/app/schemas/events-dto.ts @@ -0,0 +1,39 @@ +import { type } from "arktype"; +import { resticBackupProgressMetricsSchema, resticBackupRunSummarySchema } from "~/schemas/restic-dto"; + +export const backupEventStatusSchema = type("'success' | 'error' | 'stopped' | 'warning'"); + +const backupEventBaseSchema = type({ + scheduleId: "number", + volumeName: "string", + repositoryName: "string", +}); + +const organizationScopedSchema = type({ + organizationId: "string", +}); + +export const backupStartedEventSchema = backupEventBaseSchema; + +export const backupProgressEventSchema = backupEventBaseSchema.and(resticBackupProgressMetricsSchema); + +export const backupCompletedEventSchema = backupEventBaseSchema.and( + type({ + status: backupEventStatusSchema, + summary: resticBackupRunSummarySchema.optional(), + }), +); + +export const serverBackupStartedEventSchema = organizationScopedSchema.and(backupStartedEventSchema); + +export const serverBackupProgressEventSchema = organizationScopedSchema.and(backupProgressEventSchema); + +export const serverBackupCompletedEventSchema = organizationScopedSchema.and(backupCompletedEventSchema); + +export type BackupEventStatusDto = typeof backupEventStatusSchema.infer; +export type BackupStartedEventDto = typeof backupStartedEventSchema.infer; +export type BackupProgressEventDto = typeof backupProgressEventSchema.infer; +export type BackupCompletedEventDto = typeof backupCompletedEventSchema.infer; +export type ServerBackupStartedEventDto = typeof serverBackupStartedEventSchema.infer; +export type ServerBackupProgressEventDto = typeof serverBackupProgressEventSchema.infer; +export type ServerBackupCompletedEventDto = typeof serverBackupCompletedEventSchema.infer; diff --git a/app/schemas/restic-dto.ts b/app/schemas/restic-dto.ts new file mode 100644 index 00000000..5abd2733 --- /dev/null +++ b/app/schemas/restic-dto.ts @@ -0,0 +1,70 @@ +import { type } from "arktype"; + +export const resticSummaryBaseSchema = type({ + 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", +}); + +export const resticSnapshotSummarySchema = resticSummaryBaseSchema.and( + type({ + backup_start: "string", + backup_end: "string", + }), +); + +export const resticBackupRunSummarySchema = resticSummaryBaseSchema.and( + type({ + total_duration: "number", + snapshot_id: "string", + }), +); + +export const resticBackupOutputSchema = resticBackupRunSummarySchema.and( + type({ + message_type: "'summary'", + }), +); + +export const resticBackupProgressMetricsSchema = type({ + seconds_elapsed: "number", + percent_done: "number", + total_files: "number", + files_done: "number", + total_bytes: "number", + bytes_done: "number", + current_files: "string[]", +}); + +export const resticBackupProgressSchema = resticBackupProgressMetricsSchema.and( + type({ + message_type: "'status'", + }), +); + +export const resticRestoreOutputSchema = type({ + message_type: "'summary'", + total_files: "number?", + files_restored: "number", + files_skipped: "number", + total_bytes: "number?", + bytes_restored: "number?", + bytes_skipped: "number", +}); + +export type ResticSnapshotSummaryDto = typeof resticSnapshotSummarySchema.infer; +export type ResticBackupRunSummaryDto = typeof resticBackupRunSummarySchema.infer; +export type ResticBackupOutputDto = typeof resticBackupOutputSchema.infer; +export type ResticBackupProgressMetricsDto = typeof resticBackupProgressMetricsSchema.infer; +export type ResticBackupProgressDto = typeof resticBackupProgressSchema.infer; + +export type ResticRestoreOutputDto = typeof resticRestoreOutputSchema.infer; diff --git a/app/server/core/events.ts b/app/server/core/events.ts index ec6776a9..45da39f6 100644 --- a/app/server/core/events.ts +++ b/app/server/core/events.ts @@ -1,32 +1,19 @@ import { EventEmitter } from "node:events"; import type { TypedEmitter } from "tiny-typed-emitter"; import type { DoctorResult } from "~/schemas/restic"; +import type { + ServerBackupCompletedEventDto, + ServerBackupProgressEventDto, + ServerBackupStartedEventDto, +} from "~/schemas/events-dto"; /** * Event payloads for the SSE system */ interface ServerEvents { - "backup:started": (data: { organizationId: string; scheduleId: number; volumeName: string; repositoryName: string }) => void; - "backup:progress": (data: { - organizationId: string; - scheduleId: number; - volumeName: string; - repositoryName: string; - seconds_elapsed: number; - percent_done: number; - total_files: number; - files_done: number; - total_bytes: number; - bytes_done: number; - current_files: string[]; - }) => void; - "backup:completed": (data: { - organizationId: string; - scheduleId: number; - volumeName: string; - repositoryName: string; - status: "success" | "error" | "stopped" | "warning"; - }) => void; + "backup:started": (data: ServerBackupStartedEventDto) => void; + "backup:progress": (data: ServerBackupProgressEventDto) => void; + "backup:completed": (data: ServerBackupCompletedEventDto) => void; "mirror:started": (data: { organizationId: string; scheduleId: number; diff --git a/app/server/modules/backups/backups.execution.ts b/app/server/modules/backups/backups.execution.ts index da639ea7..3651e3ef 100644 --- a/app/server/modules/backups/backups.execution.ts +++ b/app/server/modules/backups/backups.execution.ts @@ -11,6 +11,7 @@ import { repoMutex } from "../../core/repository-mutex"; import { getOrganizationId } from "~/server/core/request-context"; import { scheduleQueries, mirrorQueries, repositoryQueries } from "./backups.queries"; import { calculateNextRun, createBackupOptions } from "./backup.helpers"; +import type { ResticBackupOutputDto } from "~/schemas/restic-dto"; const runningBackups = new Map(); @@ -125,13 +126,18 @@ const runBackupOperation = async (ctx: BackupContext, signal: AbortSignal) => { }); }, }); - return result.exitCode; + return result; } finally { releaseBackupLock(); } }; -const finalizeSuccessfulBackup = async (ctx: BackupContext, scheduleId: number, exitCode: number) => { +const finalizeSuccessfulBackup = async ( + ctx: BackupContext, + scheduleId: number, + exitCode: number, + result: ResticBackupOutputDto | null, +) => { const finalStatus = exitCode === 0 ? "success" : "warning"; if (ctx.schedule.retentionPolicy) { @@ -171,6 +177,7 @@ const finalizeSuccessfulBackup = async (ctx: BackupContext, scheduleId: number, volumeName: ctx.volume.name, repositoryName: ctx.repository.name, status: finalStatus, + summary: result ?? undefined, }); notificationsService @@ -178,6 +185,7 @@ const finalizeSuccessfulBackup = async (ctx: BackupContext, scheduleId: number, volumeName: ctx.volume.name, repositoryName: ctx.repository.name, scheduleName: ctx.schedule.name, + summary: result ?? undefined, }) .catch((error) => { logger.error(`Failed to send backup success notification: ${toMessage(error)}`); @@ -259,8 +267,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..3d7a778b 100644 --- a/app/server/modules/events/events.controller.ts +++ b/app/server/modules/events/events.controller.ts @@ -4,6 +4,11 @@ import { logger } from "../../utils/logger"; import { serverEvents } from "../../core/events"; import { requireAuth } from "../auth/auth.middleware"; import type { DoctorResult } from "~/schemas/restic"; +import type { + ServerBackupCompletedEventDto, + ServerBackupProgressEventDto, + ServerBackupStartedEventDto, +} from "~/schemas/events-dto"; export const eventsController = new Hono().use(requireAuth).get("/", (c) => { logger.info("Client connected to SSE endpoint"); @@ -15,12 +20,7 @@ export const eventsController = new Hono().use(requireAuth).get("/", (c) => { event: "connected", }); - const onBackupStarted = async (data: { - organizationId: string; - scheduleId: number; - volumeName: string; - repositoryName: string; - }) => { + const onBackupStarted = async (data: ServerBackupStartedEventDto) => { if (data.organizationId !== organizationId) return; await stream.writeSSE({ data: JSON.stringify(data), @@ -28,19 +28,7 @@ export const eventsController = new Hono().use(requireAuth).get("/", (c) => { }); }; - const onBackupProgress = async (data: { - organizationId: string; - scheduleId: number; - volumeName: string; - repositoryName: string; - seconds_elapsed: number; - percent_done: number; - total_files: number; - files_done: number; - total_bytes: number; - bytes_done: number; - current_files: string[]; - }) => { + const onBackupProgress = async (data: ServerBackupProgressEventDto) => { if (data.organizationId !== organizationId) return; await stream.writeSSE({ data: JSON.stringify(data), @@ -48,13 +36,7 @@ export const eventsController = new Hono().use(requireAuth).get("/", (c) => { }); }; - const onBackupCompleted = async (data: { - organizationId: string; - scheduleId: number; - volumeName: string; - repositoryName: string; - status: "success" | "error" | "stopped" | "warning"; - }) => { + const onBackupCompleted = async (data: ServerBackupCompletedEventDto) => { if (data.organizationId !== organizationId) return; await stream.writeSSE({ data: JSON.stringify(data), diff --git a/app/server/modules/notifications/notifications.service.ts b/app/server/modules/notifications/notifications.service.ts index 09d7ad60..73d48a44 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 { formatDuration } from "~/utils/utils"; import { buildShoutrrrUrl } from "./builders"; import { notificationConfigSchema, type NotificationConfig, type NotificationEvent } from "~/schemas/notifications"; +import type { ResticBackupRunSummaryDto } from "~/schemas/restic-dto"; 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,70 @@ const updateScheduleNotifications = async ( return getScheduleNotifications(scheduleId); }; +const formatBytesText = (bytes: number) => { + const { text, unit } = formatBytes(bytes, { + base: 1024, + locale: "en-US", + fallback: "-", + }); + + return unit ? `${text} ${unit}` : text; +}; + +const buildBackupNotificationLines = (summary?: ResticBackupRunSummaryDto) => { + 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 hasDetailedStats = summary.files_new || summary.files_changed || summary.dirs_new || summary.data_blobs; + + if (!hasDetailedStats) { + const lines: (string | null)[] = []; + + if (summary.total_duration) { + lines.push(`Duration: ${Math.round(summary.total_duration)}s`); + } + if (summary.total_files_processed !== undefined) { + lines.push(`Files: ${summary.total_files_processed.toLocaleString()}`); + } + if (summary.total_bytes_processed !== undefined) { + lines.push(`Size: ${safeBytesText(summary.total_bytes_processed)}`); + } + if (summary.snapshot_id) { + lines.push(`Snapshot: ${summary.snapshot_id}`); + } + + return lines.filter((line): line is string => Boolean(line)); + } + + 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(Boolean); +}; + const sendBackupNotification = async ( scheduleId: number, event: NotificationEvent, @@ -323,10 +390,7 @@ const sendBackupNotification = async ( repositoryName: string; scheduleName?: string; error?: string; - duration?: number; - filesProcessed?: number; - bytesProcessed?: string; - snapshotId?: string; + summary?: ResticBackupRunSummaryDto; }, ) => { try { @@ -369,11 +433,7 @@ const sendBackupNotification = async ( const decryptedConfig = await decryptSensitiveFields(assignment.destination.config); const shoutrrrUrl = buildShoutrrrUrl(decryptedConfig); - const result = await sendNotification({ - shoutrrrUrl, - title, - body, - }); + const result = await sendNotification({ shoutrrrUrl, title, body }); if (result.success) { logger.info( @@ -402,13 +462,11 @@ function buildNotificationMessage( repositoryName: string; scheduleName?: string; error?: string; - duration?: number; - filesProcessed?: number; - bytesProcessed?: string; - snapshotId?: string; + summary?: ResticBackupRunSummaryDto; }, ) { const backupName = context.scheduleName ?? "backup"; + const notificationLines = buildBackupNotificationLines(context.summary); switch (event) { case "start": @@ -423,38 +481,34 @@ function buildNotificationMessage( .join("\n"), }; - case "success": + case "success": { + const bodyLines = [ + `Volume: ${context.volumeName}`, + `Repository: ${context.repositoryName}`, + context.scheduleName ? `Schedule: ${context.scheduleName}` : null, + ...notificationLines, + ]; + return { title: `Zerobyte ${backupName} completed successfully`, - body: [ - `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, - ] - .filter(Boolean) - .join("\n"), + body: bodyLines.filter(Boolean).join("\n"), }; + } + + case "warning": { + const bodyLines = [ + `Volume: ${context.volumeName}`, + `Repository: ${context.repositoryName}`, + context.scheduleName ? `Schedule: ${context.scheduleName}` : null, + context.error ? `Warning: ${context.error}` : null, + ...notificationLines, + ]; - case "warning": return { title: `Zerobyte ${backupName} completed with warnings`, - body: [ - `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, - context.error ? `Warning: ${context.error}` : null, - ] - .filter(Boolean) - .join("\n"), + body: bodyLines.filter(Boolean).join("\n"), }; + } case "failure": return { diff --git a/app/server/modules/repositories/repositories.controller.ts b/app/server/modules/repositories/repositories.controller.ts index 2f762cef..c4c1b8fd 100644 --- a/app/server/modules/repositories/repositories.controller.ts +++ b/app/server/modules/repositories/repositories.controller.ts @@ -49,6 +49,7 @@ import { getRcloneRemoteInfo, listRcloneRemotes } from "../../utils/rclone"; import { requireAuth, requireOrgAdmin } from "../auth/auth.middleware"; import { toMessage } from "~/server/utils/errors"; import { requireDevPanel } from "../auth/dev-panel.middleware"; +import { getSnapshotDuration } from "../../utils/snapshots"; export const repositoriesController = new Hono() .use(requireAuth) @@ -102,20 +103,17 @@ export const repositoriesController = new Hono() const snapshots = res.map((snapshot) => { const { summary } = snapshot; - let duration = 0; - if (summary) { - const { backup_start, backup_end } = summary; - duration = new Date(backup_end).getTime() - new Date(backup_start).getTime(); - } + const duration = getSnapshotDuration(summary); return { short_id: snapshot.short_id, duration, paths: snapshot.paths, tags: snapshot.tags ?? [], - size: summary?.total_bytes_processed || 0, + size: summary?.total_bytes_processed ?? 0, time: new Date(snapshot.time).getTime(), retentionCategories: retentionCategories.get(snapshot.short_id) ?? [], + summary: summary, }; }); @@ -131,11 +129,7 @@ export const repositoriesController = new Hono() const { id, snapshotId } = c.req.param(); const snapshot = await repositoriesService.getSnapshotDetails(id, snapshotId); - let duration = 0; - if (snapshot.summary) { - const { backup_start, backup_end } = snapshot.summary; - duration = new Date(backup_end).getTime() - new Date(backup_start).getTime(); - } + const duration = getSnapshotDuration(snapshot.summary); const response = { short_id: snapshot.short_id, @@ -143,7 +137,7 @@ export const repositoriesController = new Hono() time: new Date(snapshot.time).getTime(), paths: snapshot.paths, hostname: snapshot.hostname, - size: snapshot.summary?.total_bytes_processed || 0, + size: snapshot.summary?.total_bytes_processed ?? 0, tags: snapshot.tags ?? [], retentionCategories: [], summary: snapshot.summary, diff --git a/app/server/modules/repositories/repositories.dto.ts b/app/server/modules/repositories/repositories.dto.ts index 543337bc..dda6dbc7 100644 --- a/app/server/modules/repositories/repositories.dto.ts +++ b/app/server/modules/repositories/repositories.dto.ts @@ -8,6 +8,7 @@ import { repositoryConfigSchema, doctorResultSchema, } from "~/schemas/restic"; +import { resticSnapshotSummarySchema } from "~/schemas/restic-dto"; export const repositorySchema = type({ id: "string", @@ -180,6 +181,7 @@ export const snapshotSchema = type({ tags: "string[]", retentionCategories: "string[]", hostname: "string?", + summary: resticSnapshotSummarySchema.optional(), }); const listSnapshotsResponse = snapshotSchema.array(); diff --git a/app/server/utils/restic.ts b/app/server/utils/restic.ts index f0c1b76e..db4382d4 100644 --- a/app/server/utils/restic.ts +++ b/app/server/utils/restic.ts @@ -11,28 +11,19 @@ import { cryptoUtils } from "./crypto"; import type { RetentionPolicy } from "../modules/backups/backups.dto"; import { safeSpawn, exec } from "./spawn"; import type { CompressionMode, RepositoryConfig, OverwriteMode, BandwidthLimit } from "~/schemas/restic"; +import { + resticBackupOutputSchema, + resticBackupProgressSchema, + resticRestoreOutputSchema, + resticSnapshotSummarySchema, + type ResticBackupProgressDto, + type ResticRestoreOutputDto, + type ResticSnapshotSummaryDto, +} from "~/schemas/restic-dto"; import { ResticError } from "./errors"; import { db } from "../db/db"; import { safeJsonParse } from "./json"; -const backupOutputSchema = type({ - message_type: "'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", - total_files_processed: "number", - total_bytes_processed: "number", - total_duration: "number", - snapshot_id: "string", -}); -export type BackupOutput = typeof backupOutputSchema.infer; - const snapshotInfoSchema = type({ gid: "number?", hostname: "string", @@ -45,22 +36,7 @@ const snapshotInfoSchema = type({ uid: "number?", username: "string?", tags: "string[]?", - summary: type({ - backup_end: "string", - backup_start: "string", - data_added: "number", - data_added_packed: "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", - }).optional(), + summary: resticSnapshotSummarySchema.optional(), }); export const buildRepoUrl = (config: RepositoryConfig): string => { @@ -267,19 +243,6 @@ const init = async (config: RepositoryConfig, organizationId: string, options?: return { success: true, error: null }; }; -const backupProgressSchema = type({ - message_type: "'status'", - seconds_elapsed: "number", - percent_done: "number", - total_files: "number", - files_done: "number", - total_bytes: "number", - bytes_done: "number", - current_files: "string[]", -}); - -export type BackupProgress = typeof backupProgressSchema.infer; - const backup = async ( config: RepositoryConfig, source: string, @@ -292,7 +255,7 @@ const backup = async ( oneFileSystem?: boolean; compressionMode?: CompressionMode; signal?: AbortSignal; - onProgress?: (progress: BackupProgress) => void; + onProgress?: (progress: ResticBackupProgressDto) => void; }, ) => { const repoUrl = buildRepoUrl(config); @@ -356,7 +319,7 @@ const backup = async ( if (options?.onProgress) { try { const jsonData = JSON.parse(data); - const progress = backupProgressSchema(jsonData); + const progress = resticBackupProgressSchema(jsonData); if (!(progress instanceof type.errors)) { options.onProgress(progress); } @@ -411,7 +374,7 @@ const backup = async ( } logger.debug(`Restic backup output last line: ${summaryLine}`); - const result = backupOutputSchema(summaryLine); + const result = resticBackupOutputSchema(summaryLine); if (result instanceof type.errors) { logger.error(`Restic backup output validation failed: ${result.summary}`); @@ -421,16 +384,6 @@ const backup = async ( return { result, exitCode: res.exitCode }; }; -const restoreOutputSchema = type({ - message_type: "'summary'", - total_files: "number?", - files_restored: "number", - files_skipped: "number", - total_bytes: "number?", - bytes_restored: "number?", - bytes_skipped: "number", -}); - const restore = async ( config: RepositoryConfig, snapshotId: string, @@ -498,18 +451,20 @@ const restore = async ( } logger.debug(`Restic restore output last line: ${summaryLine}`); - const result = restoreOutputSchema(summaryLine); + const result = resticRestoreOutputSchema(summaryLine); if (result instanceof type.errors) { logger.warn(`Restic restore output validation failed: ${result.summary}`); logger.info(`Restic restore completed for snapshot ${snapshotId} to target ${target}`); - return { + const fallback: ResticRestoreOutputDto = { message_type: "summary" as const, total_files: 0, files_restored: 0, files_skipped: 0, bytes_skipped: 0, }; + + return fallback; } logger.info( @@ -576,28 +531,11 @@ export interface Snapshot { excludes?: string[] | null; tags?: string[] | null; program_version?: string; - summary?: SnapshotSummary; + summary?: ResticSnapshotSummaryDto; id: string; short_id: string; } -export interface SnapshotSummary { - 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; -} - export interface ForgetReason { snapshot: Snapshot; matches: string[]; diff --git a/app/server/utils/snapshots.ts b/app/server/utils/snapshots.ts new file mode 100644 index 00000000..e87a8ab6 --- /dev/null +++ b/app/server/utils/snapshots.ts @@ -0,0 +1,4 @@ +export function getSnapshotDuration(summary?: { backup_start: string; backup_end: string }): number { + if (!summary) return 0; + return new Date(summary.backup_end).getTime() - new Date(summary.backup_start).getTime(); +} 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, + }; +}