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"}
+
+
+
+
+
)}
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,
+ };
};