Skip to content

Commit 35c3c57

Browse files
committed
fix(web): properly handle backup progress
1 parent 4026bee commit 35c3c57

10 files changed

Lines changed: 231 additions & 169 deletions

File tree

apps/api/src/lib/backup.ts

Lines changed: 55 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,16 @@ const MAX_MONTHLY_BACKUPS = 12;
3535
const BACKUP_HOUR = 2; // 2 AM
3636
const BACKUP_MINUTE = 0;
3737

38+
/**
39+
* Format bytes to human readable string
40+
*/
41+
function formatBytes(bytes: number): string {
42+
if (bytes === 0) return "0 Bytes";
43+
if (bytes < 1024) return `${bytes} Bytes`;
44+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
45+
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
46+
}
47+
3848
export interface BackupMetadata {
3949
filename: string;
4050
filepath: string;
@@ -226,35 +236,59 @@ export async function createBackup(
226236

227237
// Track progress by monitoring file size
228238
let bytesWritten = 0;
229-
const progressInterval = setInterval(async () => {
230-
try {
231-
const stats = await fs.stat(backupPath);
232-
const newBytes = stats.size;
233-
if (newBytes > bytesWritten) {
234-
bytesWritten = newBytes;
235-
webSocketService.broadcast(WS_EVENTS.BACKUP_PROGRESS, {
236-
filename,
237-
bytesWritten: newBytes,
238-
status: "in_progress",
239-
});
239+
let progressInterval: NodeJS.Timeout | null = null;
240+
let progressTimeout: NodeJS.Timeout | null = null;
241+
let isProcessComplete = false;
242+
243+
const clearProgressMonitoring = () => {
244+
isProcessComplete = true;
245+
if (progressTimeout) clearTimeout(progressTimeout);
246+
if (progressInterval) clearInterval(progressInterval);
247+
};
248+
249+
// Wait a bit before starting progress monitoring to avoid showing tiny initial values
250+
progressTimeout = setTimeout(() => {
251+
if (isProcessComplete) return; // Don't start if already complete
252+
253+
progressInterval = setInterval(async () => {
254+
if (isProcessComplete) {
255+
if (progressInterval) clearInterval(progressInterval);
256+
return;
240257
}
241-
} catch {
242-
// File might not exist yet, ignore
243-
}
244-
}, 1000); // Update every second
258+
259+
try {
260+
const stats = await fs.stat(backupPath);
261+
const newBytes = stats.size;
262+
// Only broadcast if there's a meaningful change (at least 1KB difference)
263+
if (
264+
newBytes > bytesWritten &&
265+
(newBytes - bytesWritten >= 1024 || bytesWritten === 0)
266+
) {
267+
bytesWritten = newBytes;
268+
webSocketService.broadcast(WS_EVENTS.BACKUP_PROGRESS, {
269+
filename,
270+
sizeText: formatBytes(newBytes),
271+
status: "in_progress",
272+
});
273+
}
274+
} catch {
275+
// File might not exist yet, ignore
276+
}
277+
}, 1000); // Update every second
278+
}, 500); // Start monitoring after 500ms
245279

246280
let errorOutput = "";
247281
pgDump.stderr.on("data", (data) => {
248282
errorOutput += data.toString();
249283
});
250284

251285
pgDump.on("error", (error) => {
252-
clearInterval(progressInterval);
286+
clearProgressMonitoring();
253287
reject(new Error(`pg_dump process error: ${error.message}`));
254288
});
255289

256290
pgDump.on("close", (code) => {
257-
clearInterval(progressInterval);
291+
clearProgressMonitoring();
258292
if (code === 0) {
259293
resolve();
260294
} else {
@@ -274,12 +308,12 @@ export async function createBackup(
274308
});
275309

276310
destination.on("error", (error) => {
277-
clearInterval(progressInterval);
311+
clearProgressMonitoring();
278312
reject(error);
279313
});
280314

281315
gzip.on("error", (error) => {
282-
clearInterval(progressInterval);
316+
clearProgressMonitoring();
283317
reject(error);
284318
});
285319
});
@@ -301,13 +335,13 @@ export async function createBackup(
301335
};
302336

303337
logger.info(
304-
`Backup created successfully: ${filename} (${(stats.size / 1024 / 1024).toFixed(2)} MB)`
338+
`Backup created successfully: ${filename} (${formatBytes(stats.size)})`
305339
);
306340

307341
// Emit backup completed event
308342
webSocketService.broadcast(WS_EVENTS.BACKUP_COMPLETED, {
309343
filename,
310-
size: stats.size,
344+
sizeText: formatBytes(stats.size),
311345
type,
312346
verified: isValid,
313347
status: "completed",

apps/web/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,11 @@
2828
"clsx": "^2.1.1",
2929
"lucide-react": "^0.545.0",
3030
"motion": "^12.23.24",
31+
"next-themes": "^0.4.6",
3132
"react": "^19.1.1",
3233
"react-dom": "^19.1.1",
3334
"socket.io-client": "^4.8.1",
35+
"sonner": "^2.0.7",
3436
"tailwind-merge": "^3.3.1",
3537
"tailwindcss": "^4.1.14",
3638
"vaul": "^1.1.2",

apps/web/src/components/settings/setting-group.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,13 @@ export function SettingGroup({ group }: SettingGroupProps) {
2121
variant={group.headerAction.variant || "default"}
2222
size="sm"
2323
onClick={group.headerAction.onClick}
24+
disabled={group.headerAction.disabled}
2425
className="w-full md:w-auto"
2526
>
2627
{group.headerAction.icon && (
27-
<group.headerAction.icon className="w-4 h-4 mr-2" />
28+
<group.headerAction.icon
29+
className={`w-4 h-4 mr-2 ${group.headerAction.disabled ? "animate-spin" : ""}`}
30+
/>
2831
)}
2932
{group.headerAction.label}
3033
</Button>
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import {
2+
CircleCheckIcon,
3+
InfoIcon,
4+
Loader2Icon,
5+
OctagonXIcon,
6+
TriangleAlertIcon,
7+
} from "lucide-react"
8+
import { useTheme } from "next-themes"
9+
import { Toaster as Sonner, type ToasterProps } from "sonner"
10+
11+
const Toaster = ({ ...props }: ToasterProps) => {
12+
const { theme = "system" } = useTheme()
13+
14+
return (
15+
<Sonner
16+
theme={theme as ToasterProps["theme"]}
17+
className="toaster group"
18+
icons={{
19+
success: <CircleCheckIcon className="size-4" />,
20+
info: <InfoIcon className="size-4" />,
21+
warning: <TriangleAlertIcon className="size-4" />,
22+
error: <OctagonXIcon className="size-4" />,
23+
loading: <Loader2Icon className="size-4 animate-spin" />,
24+
}}
25+
style={
26+
{
27+
"--normal-bg": "var(--popover)",
28+
"--normal-text": "var(--popover-foreground)",
29+
"--normal-border": "var(--border)",
30+
"--border-radius": "var(--radius)",
31+
} as React.CSSProperties
32+
}
33+
{...props}
34+
/>
35+
)
36+
}
37+
38+
export { Toaster }

apps/web/src/config/system-settings-config.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@ import type {
55
ActiveAlertsResponse,
66
BackupsResponse,
77
} from "@/types/system";
8-
import { Download, RefreshCw, Trash2, Database } from "lucide-react";
8+
import { Download, RefreshCw, Trash2, Database, Loader2 } from "lucide-react";
99

1010
interface SystemSettingsConfigParams {
1111
healthData?: AdminHealthCheckResponse | null;
1212
performanceData?: PerformanceMetricsResponse | null;
1313
alertData?: ActiveAlertsResponse | null;
1414
backupData?: BackupsResponse | null;
15+
isBackupInProgress?: boolean;
1516
onRefreshHealth: () => void;
1617
onResetPerformanceMetrics: () => void;
1718
onCreateBackup: () => void;
@@ -28,6 +29,7 @@ export function systemSettingsConfig({
2829
performanceData,
2930
alertData,
3031
backupData,
32+
isBackupInProgress = false,
3133
onRefreshHealth,
3234
onResetPerformanceMetrics,
3335
onCreateBackup,
@@ -214,10 +216,11 @@ export function systemSettingsConfig({
214216
title: "Database Backups",
215217
description: "Create and manage database backups",
216218
headerAction: {
217-
label: "Create Backup",
218-
icon: Download,
219+
label: isBackupInProgress ? "Creating..." : "Create Backup",
220+
icon: isBackupInProgress ? Loader2 : Download,
219221
variant: "default",
220222
onClick: onCreateBackup,
223+
disabled: isBackupInProgress,
221224
},
222225
items: [
223226
...(backupData?.stats

apps/web/src/hooks/useBackupProgress.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,13 @@
55
*/
66

77
import { useState, useEffect } from "react";
8+
import { toast } from "sonner";
89
import { webSocketClient, WS_EVENTS } from "@/lib/websocket";
910

1011
export interface BackupProgress {
1112
filename: string;
1213
type?: string;
1314
status: "idle" | "starting" | "in_progress" | "completed" | "error";
14-
bytesWritten?: number;
15-
size?: number;
1615
verified?: boolean;
1716
error?: string;
1817
timestamp: string;
@@ -35,6 +34,7 @@ export function useBackupProgress() {
3534
data: { filename: string; type: string; status: string };
3635
};
3736
console.log("Backup started:", backupData);
37+
3838
setProgress({
3939
filename: backupData.data.filename,
4040
type: backupData.data.type,
@@ -46,13 +46,13 @@ export function useBackupProgress() {
4646
// Handler for backup progress
4747
const handleBackupProgress = (data: unknown) => {
4848
const backupData = data as {
49-
data: { filename: string; bytesWritten: number; status: string };
49+
data: { filename: string; sizeText: string; status: string };
5050
};
5151
console.log("Backup progress:", backupData);
52+
5253
setProgress((prev) => ({
5354
...prev,
5455
filename: backupData.data.filename,
55-
bytesWritten: backupData.data.bytesWritten,
5656
status: "in_progress",
5757
timestamp: new Date().toISOString(),
5858
}));
@@ -63,30 +63,34 @@ export function useBackupProgress() {
6363
const backupData = data as {
6464
data: {
6565
filename: string;
66-
size: number;
66+
sizeText: string;
6767
type: string;
6868
verified: boolean;
6969
status: string;
7070
};
7171
};
7272
console.log("Backup completed:", backupData);
73+
74+
toast.success("Backup created successfully", {
75+
description: `${backupData.data.sizeText}${backupData.data.verified ? "Verified" : "Unverified"}`,
76+
});
77+
7378
setProgress({
7479
filename: backupData.data.filename,
7580
type: backupData.data.type,
76-
size: backupData.data.size,
7781
verified: backupData.data.verified,
7882
status: "completed",
7983
timestamp: new Date().toISOString(),
8084
});
8185

82-
// Reset to idle after 5 seconds
86+
// Reset to idle after 3 seconds
8387
setTimeout(() => {
8488
setProgress({
8589
filename: "",
8690
status: "idle",
8791
timestamp: new Date().toISOString(),
8892
});
89-
}, 5000);
93+
}, 3000);
9094
};
9195

9296
// Handler for backup error
@@ -95,6 +99,12 @@ export function useBackupProgress() {
9599
data: { filename: string; error: string; status: string };
96100
};
97101
console.error("Backup error:", backupData);
102+
103+
toast.error("Backup failed", {
104+
description: backupData.data.error,
105+
duration: 5000,
106+
});
107+
98108
setProgress({
99109
filename: backupData.data.filename,
100110
error: backupData.data.error,

apps/web/src/hooks/useWebSocket.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import { useEffect, useCallback } from "react";
99
import { useQueryClient } from "@tanstack/react-query";
10+
import { toast } from "sonner";
1011
import { webSocketClient, WS_EVENTS } from "@/lib/websocket";
1112

1213
/**
@@ -23,12 +24,18 @@ export function useWebSocket() {
2324
// Setup event listeners for settings updates
2425
const handleSettingsUpdated = () => {
2526
console.log("Settings updated via WebSocket - invalidating cache");
27+
toast.success("Settings updated", {
28+
description: "Your changes have been saved",
29+
});
2630
queryClient.invalidateQueries({ queryKey: ["settings"] });
2731
queryClient.invalidateQueries({ queryKey: ["setup-status"] });
2832
};
2933

3034
const handleSetupCompleted = () => {
3135
console.log("Setup completed via WebSocket - invalidating cache");
36+
toast.success("Setup completed", {
37+
description: "Your application is ready to use",
38+
});
3239
queryClient.invalidateQueries({ queryKey: ["settings"] });
3340
queryClient.invalidateQueries({ queryKey: ["setup-status"] });
3441
};
@@ -43,11 +50,16 @@ export function useWebSocket() {
4350
// Setup event listeners for scan events
4451
const handleScanStarted = () => {
4552
console.log("Scan started via WebSocket");
46-
// Could show a notification or update UI
53+
toast.info("Media scan started", {
54+
description: "Scanning your library for new media...",
55+
});
4756
};
4857

4958
const handleScanCompleted = () => {
5059
console.log("Scan completed via WebSocket - invalidating media cache");
60+
toast.success("Media scan completed", {
61+
description: "Your library has been updated",
62+
});
5163
queryClient.invalidateQueries({ queryKey: ["media"] });
5264
queryClient.invalidateQueries({ queryKey: ["movies"] });
5365
queryClient.invalidateQueries({ queryKey: ["tv-shows"] });

0 commit comments

Comments
 (0)