From 429b69ec92a36946c8b794c5275febba7b837e6b Mon Sep 17 00:00:00 2001 From: Nico <47644445+nicotsx@users.noreply.github.com> Date: Wed, 18 Mar 2026 20:21:14 +0100 Subject: [PATCH] fix: show back warnings logs and surface in UI (#677) fix: show back warnings logs and surface in UI #544 chore: fix dev login issue --- app/client/components/status-dot.tsx | 2 +- .../backups/components/backup-card.tsx | 3 +- .../backups/components/backup-status-dot.tsx | 7 ++- .../backups/components/schedule-summary.tsx | 51 +++++++++++++------ app/routes/api.$.ts | 31 +++++++---- .../__tests__/backups.execution.test.ts | 31 ++++++++++- .../modules/backups/backups.execution.ts | 11 +++- packages/core/src/restic/commands/backup.ts | 22 ++++++-- 8 files changed, 123 insertions(+), 35 deletions(-) diff --git a/app/client/components/status-dot.tsx b/app/client/components/status-dot.tsx index 3f550bc4..8a2097b5 100644 --- a/app/client/components/status-dot.tsx +++ b/app/client/components/status-dot.tsx @@ -40,7 +40,7 @@ export const StatusDot = ({ variant, label, animated }: StatusDotProps) => { return ( - + {statusMapping?.animated && ( { diff --git a/app/client/modules/backups/components/backup-status-dot.tsx b/app/client/modules/backups/components/backup-status-dot.tsx index 6d30224d..f099a68e 100644 --- a/app/client/modules/backups/components/backup-status-dot.tsx +++ b/app/client/modules/backups/components/backup-status-dot.tsx @@ -3,13 +3,15 @@ import { StatusDot } from "~/client/components/status-dot"; export const BackupStatusDot = ({ enabled, hasError, + hasWarning, isInProgress, }: { enabled: boolean; hasError?: boolean; + hasWarning?: boolean; isInProgress?: boolean; }) => { - let variant: "success" | "neutral" | "error" | "info"; + let variant: "success" | "neutral" | "error" | "warning" | "info"; let label: string; if (isInProgress) { @@ -18,6 +20,9 @@ export const BackupStatusDot = ({ } else if (hasError) { variant = "error"; label = "Error"; + } else if (hasWarning) { + variant = "warning"; + label = "Warning"; } else if (enabled) { variant = "success"; label = "Active"; diff --git a/app/client/modules/backups/components/schedule-summary.tsx b/app/client/modules/backups/components/schedule-summary.tsx index f978d3c3..089b6c5f 100644 --- a/app/client/modules/backups/components/schedule-summary.tsx +++ b/app/client/modules/backups/components/schedule-summary.tsx @@ -20,6 +20,8 @@ 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"; +import { cn } from "~/client/lib/utils"; type Props = { schedule: BackupSchedule; @@ -198,22 +200,41 @@ export const ScheduleSummary = (props: Props) => {

- {schedule.lastBackupStatus === "warning" && ( + {(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"} + + +
+

+ {schedule.lastBackupError ?? + "No additional details available. check your container logs for more information."} +

+
+
+
)} diff --git a/app/routes/api.$.ts b/app/routes/api.$.ts index 80bdbb0e..f36dfa84 100644 --- a/app/routes/api.$.ts +++ b/app/routes/api.$.ts @@ -13,26 +13,35 @@ type NodeRuntimeRequest = Request & { }; }; -export const prepareApiRequest = (request: Request, timeoutMs: number) => { - const nodeRequest = request as NodeRuntimeRequest; - nodeRequest.runtime?.node?.res?.setTimeout(timeoutMs); +type RequestInitWithDuplex = RequestInit & { + duplex?: "half"; +}; + +export const prepareApiRequest = (request: NodeRuntimeRequest, timeoutMs: number) => { + request.runtime?.node?.res?.setTimeout(timeoutMs); if (config.trustProxy && request.headers.has("x-forwarded-for")) { return request.clone(); } - const remoteAddress = nodeRequest.ip; - if (remoteAddress) { - const headers = new Headers(request.headers); - headers.set("x-forwarded-for", remoteAddress); + const remoteAddress = request.ip; + const headers = new Headers(request.headers); - return new Request(request, { headers }); + if (remoteAddress) { + headers.set("x-forwarded-for", remoteAddress); + } else { + headers.delete("x-forwarded-for"); } - const headers = new Headers(request.headers); - headers.delete("x-forwarded-for"); + const init: RequestInitWithDuplex = { + method: request.method, + headers, + body: request.body, + signal: request.signal, + duplex: request.body ? "half" : undefined, + }; - return new Request(request, { headers }); + return new Request(request.url, init); }; const handle = ({ request }: { request: Request }) => diff --git a/app/server/modules/backups/__tests__/backups.execution.test.ts b/app/server/modules/backups/__tests__/backups.execution.test.ts index fb563469..bc984c58 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"; @@ -17,7 +18,9 @@ import { fromAny } from "@total-typescript/shoehorn"; import { repositoriesService } from "~/server/modules/repositories/repositories.service"; const setup = () => { - const resticBackupMock = mock(() => Promise.resolve({ exitCode: 0, summary: generateBackupOutput(), error: "" })); + const resticBackupMock = mock((_: SafeSpawnParams) => + Promise.resolve({ exitCode: 0, summary: generateBackupOutput(), error: "" }), + ); const resticForgetMock = mock(() => Promise.resolve({ success: true, data: null })); const resticCopyMock = mock(() => Promise.resolve({ success: true, output: "" })); const refreshStatsMock = mock(() => @@ -148,6 +151,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, + }; };