From 1d6e7dcf65f508b5b3dc56515c0f1d3a3dcd64d6 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Wed, 18 Mar 2026 19:12:58 +0100 Subject: [PATCH] fix: show back warnings logs and surface in UI #544 --- .../backups/components/schedule-summary.tsx | 38 +++++++++++-------- .../__tests__/backups.execution.test.ts | 27 +++++++++++++ .../modules/backups/backups.execution.ts | 11 +++++- packages/core/src/restic/commands/backup.ts | 22 +++++++++-- 4 files changed, 78 insertions(+), 20 deletions(-) diff --git a/app/client/modules/backups/components/schedule-summary.tsx b/app/client/modules/backups/components/schedule-summary.tsx index f978d3c3..58cb3ad6 100644 --- a/app/client/modules/backups/components/schedule-summary.tsx +++ b/app/client/modules/backups/components/schedule-summary.tsx @@ -20,6 +20,7 @@ import { toast } from "sonner"; import { handleRepositoryError } from "~/client/lib/errors"; import { formatShortDateTime, formatTimeAgo } from "~/client/lib/datetime"; import { Link } from "@tanstack/react-router"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "~/client/components/ui/collapsible"; type Props = { schedule: BackupSchedule; @@ -89,6 +90,11 @@ export const ScheduleSummary = (props: Props) => { handleStopBackup(); }; + const backupDetails = + schedule.lastBackupStatus === "warning" + ? (schedule.lastBackupError ?? "Last backup completed with warnings. Check your container logs for more details.") + : schedule.lastBackupError; + return (
@@ -198,22 +204,24 @@ export const ScheduleSummary = (props: Props) => {

- {schedule.lastBackupStatus === "warning" && ( + {backupDetails && (schedule.lastBackupStatus === "warning" || schedule.lastBackupStatus === "error") && (
-

Warning Details

-

- {schedule.lastBackupError ?? - "Last backup completed with warnings. Check your container logs for more details."} -

-
- )} - - {schedule.lastBackupError && schedule.lastBackupStatus === "error" && ( -
-

Error details

-

- {schedule.lastBackupError} -

+ + + {schedule.lastBackupStatus === "warning" ? "Warning details" : "Error details"} + + +
+

+ {backupDetails} +

+
+
+
)} diff --git a/app/server/modules/backups/__tests__/backups.execution.test.ts b/app/server/modules/backups/__tests__/backups.execution.test.ts index fb563469..8d83436e 100644 --- a/app/server/modules/backups/__tests__/backups.execution.test.ts +++ b/app/server/modules/backups/__tests__/backups.execution.test.ts @@ -10,6 +10,7 @@ import { generateBackupOutput } from "~/test/helpers/restic"; import { TEST_ORG_ID } from "~/test/helpers/organization"; import * as context from "~/server/core/request-context"; import * as spawnModule from "@zerobyte/core/node"; +import type { SafeSpawnParams } from "@zerobyte/core/node"; import { restic } from "~/server/core/restic"; import { NotFoundError, BadRequestError } from "http-errors-enhanced"; import { scheduleQueries } from "../backups.queries"; @@ -148,6 +149,32 @@ describe("backup execution - validation failures", () => { }); describe("stop backup", () => { + test("should keep restic warning details when backup completes with read errors", async () => { + const { resticBackupMock } = setup(); + const volume = await createTestVolume(); + const repository = await createTestRepository(); + const schedule = await createTestBackupSchedule({ + volumeId: volume.id, + repositoryId: repository.id, + }); + + resticBackupMock.mockImplementationOnce((params: SafeSpawnParams) => { + params.onStderr?.("error: open /mnt/data/private.db: permission denied"); + + return Promise.resolve({ + exitCode: 3, + summary: generateBackupOutput(), + error: "Warning: at least one source file could not be read", + }); + }); + + await backupsExecutionService.executeBackup(schedule.id); + + const updatedSchedule = await backupsService.getScheduleById(schedule.id); + expect(updatedSchedule.lastBackupStatus).toBe("warning"); + expect(updatedSchedule.lastBackupError).toBe("error: open /mnt/data/private.db: permission denied"); + }); + test("should stop a running backup", async () => { // arrange const { resticBackupMock } = setup(); diff --git a/app/server/modules/backups/backups.execution.ts b/app/server/modules/backups/backups.execution.ts index e9389334..f446514a 100644 --- a/app/server/modules/backups/backups.execution.ts +++ b/app/server/modules/backups/backups.execution.ts @@ -149,6 +149,7 @@ const finalizeSuccessfulBackup = async ( scheduleId: number, exitCode: number, result: ResticBackupOutputDto | null, + warningDetails: string | null, ) => { const finalStatus = exitCode === 0 ? "success" : "warning"; @@ -174,7 +175,7 @@ const finalizeSuccessfulBackup = async ( await scheduleQueries.updateStatus(scheduleId, ctx.organizationId, { lastBackupAt: Date.now(), lastBackupStatus: finalStatus, - lastBackupError: null, + lastBackupError: finalStatus === "warning" ? warningDetails : null, nextBackupAt, }); @@ -285,7 +286,13 @@ const executeBackup = async (scheduleId: number, manual = false): Promise try { const backupResult = await runBackupOperation(ctx, abortController.signal); - await finalizeSuccessfulBackup(ctx, scheduleId, backupResult.exitCode, backupResult.result); + await finalizeSuccessfulBackup( + ctx, + scheduleId, + backupResult.exitCode, + backupResult.result, + backupResult.warningDetails, + ); } catch (error) { await handleBackupFailure(scheduleId, ctx.organizationId, error, ctx); } finally { diff --git a/packages/core/src/restic/commands/backup.ts b/packages/core/src/restic/commands/backup.ts index aa27aa98..b0022db8 100644 --- a/packages/core/src/restic/commands/backup.ts +++ b/packages/core/src/restic/commands/backup.ts @@ -101,6 +101,7 @@ export const backup = async ( const logData = throttle((data: string) => { logger.info(data.trim()); }, 5000); + const stderrLines: string[] = []; const streamProgress = throttle((data: string) => { if (options.onProgress) { @@ -134,6 +135,13 @@ export const backup = async ( streamProgress(data); } }, + onStderr: (error) => { + const line = error.trim(); + if (line.length > 0) { + stderrLines.push(line); + logger.error(`restic stderr: ${line}`); + } + }, }); if (includeFile) { @@ -146,7 +154,7 @@ export const backup = async ( if (options.signal?.aborted) { logger.warn("Restic backup was aborted by signal."); - return { result: null, exitCode: res.exitCode }; + return { result: null, exitCode: res.exitCode, warningDetails: null }; } if (res.exitCode === 3) { @@ -174,8 +182,16 @@ export const backup = async ( if (!result.success) { logger.error(`Restic backup output validation failed: ${result.error.message}`); - return { result: null, exitCode: res.exitCode }; + return { + result: null, + exitCode: res.exitCode, + warningDetails: stderrLines.length > 0 ? stderrLines.join("\n") : null, + }; } - return { result: result.data, exitCode: res.exitCode }; + return { + result: result.data, + exitCode: res.exitCode, + warningDetails: stderrLines.length > 0 ? stderrLines.join("\n") : null, + }; };