diff --git a/app/client/modules/backups/components/backup-progress-card.tsx b/app/client/modules/backups/components/backup-progress-card.tsx index 45ae948d..62cfb2d3 100644 --- a/app/client/modules/backups/components/backup-progress-card.tsx +++ b/app/client/modules/backups/components/backup-progress-card.tsx @@ -5,7 +5,7 @@ import { Progress } from "~/client/components/ui/progress"; 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"; +import { formatBytes } from "~/utils/format-bytes"; type Props = { scheduleId: number; @@ -71,7 +71,9 @@ export const BackupProgressCard = ({ scheduleId }: Props) => {

{progress ? ( <> - / + +  /  + ) : ( "—" diff --git a/app/server/modules/backups/backups.execution.ts b/app/server/modules/backups/backups.execution.ts index 98f3c991..3651e3ef 100644 --- a/app/server/modules/backups/backups.execution.ts +++ b/app/server/modules/backups/backups.execution.ts @@ -132,11 +132,6 @@ const runBackupOperation = async (ctx: BackupContext, signal: AbortSignal) => { } }; -const buildBackupSummary = (result: ResticBackupOutputDto | null | undefined) => { - if (!result) return undefined; - return result; -}; - const finalizeSuccessfulBackup = async ( ctx: BackupContext, scheduleId: number, @@ -144,7 +139,6 @@ const finalizeSuccessfulBackup = async ( result: ResticBackupOutputDto | null, ) => { const finalStatus = exitCode === 0 ? "success" : "warning"; - const summary = buildBackupSummary(result); if (ctx.schedule.retentionPolicy) { void runForget(scheduleId).catch((error) => { @@ -183,7 +177,7 @@ const finalizeSuccessfulBackup = async ( volumeName: ctx.volume.name, repositoryName: ctx.repository.name, status: finalStatus, - summary, + summary: result ?? undefined, }); notificationsService @@ -191,7 +185,7 @@ const finalizeSuccessfulBackup = async ( volumeName: ctx.volume.name, repositoryName: ctx.repository.name, scheduleName: ctx.schedule.name, - summary, + summary: result ?? undefined, }) .catch((error) => { logger.error(`Failed to send backup success notification: ${toMessage(error)}`); diff --git a/app/server/modules/notifications/notifications.service.ts b/app/server/modules/notifications/notifications.service.ts index 4f548eed..73d48a44 100644 --- a/app/server/modules/notifications/notifications.service.ts +++ b/app/server/modules/notifications/notifications.service.ts @@ -328,7 +328,7 @@ const formatBytesText = (bytes: number) => { return unit ? `${text} ${unit}` : text; }; -const buildBackupSummaryLines = (summary?: ResticBackupRunSummaryDto) => { +const buildBackupNotificationLines = (summary?: ResticBackupRunSummaryDto) => { if (!summary) return []; const safeNumber = (value: number | undefined) => (typeof value === "number" && Number.isFinite(value) ? value : 0); @@ -336,6 +336,28 @@ const buildBackupSummaryLines = (summary?: ResticBackupRunSummaryDto) => { 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 = [ @@ -357,7 +379,7 @@ const buildBackupSummaryLines = (summary?: ResticBackupRunSummaryDto) => { `- Snapshot: ${snapshotText}`, ]; - return lines.filter((line): line is string => Boolean(line)); + return lines.filter(Boolean); }; const sendBackupNotification = async ( @@ -368,10 +390,6 @@ const sendBackupNotification = async ( repositoryName: string; scheduleName?: string; error?: string; - duration?: number; - filesProcessed?: number; - bytesProcessed?: string; - snapshotId?: string; summary?: ResticBackupRunSummaryDto; }, ) => { @@ -415,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( @@ -448,21 +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 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); + const notificationLines = buildBackupNotificationLines(context.summary); switch (event) { case "start": @@ -477,40 +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, - 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"), + 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, - 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"), + 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 0a0ee8eb..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,18 +103,14 @@ 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, @@ -132,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, @@ -144,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/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(); +}