refactor: show progress card even if no data is streamed (#440)
This commit is contained in:
parent
d4864099a6
commit
b8ad1ae41a
8 changed files with 271 additions and 39 deletions
|
|
@ -68,7 +68,7 @@ RUN bun install --frozen-lockfile --ignore-scripts
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
EXPOSE 4096
|
EXPOSE 3000
|
||||||
|
|
||||||
CMD ["bun", "run", "dev"]
|
CMD ["bun", "run", "dev"]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,21 +34,10 @@ export const BackupProgressCard = ({ scheduleId }: Props) => {
|
||||||
};
|
};
|
||||||
}, [addEventListener, scheduleId]);
|
}, [addEventListener, scheduleId]);
|
||||||
|
|
||||||
if (!progress) {
|
const percentDone = progress ? Math.round(progress.percent_done * 100) : 0;
|
||||||
return (
|
const currentFile = progress?.current_files[0] || "";
|
||||||
<Card className="p-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
|
||||||
<span className="font-medium">Backup in progress</span>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const percentDone = Math.round(progress.percent_done * 100);
|
|
||||||
const currentFile = progress.current_files[0] || "";
|
|
||||||
const fileName = currentFile.split("/").pop() || currentFile;
|
const fileName = currentFile.split("/").pop() || currentFile;
|
||||||
const speed = formatBytes(progress.bytes_done / progress.seconds_elapsed);
|
const speed = progress ? formatBytes(progress.bytes_done / progress.seconds_elapsed) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
|
|
@ -57,7 +46,7 @@ export const BackupProgressCard = ({ scheduleId }: Props) => {
|
||||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||||
<span className="font-medium">Backup in progress</span>
|
<span className="font-medium">Backup in progress</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium text-primary">{percentDone}%</span>
|
<span className="text-sm font-medium text-primary">{progress ? `${percentDone}%` : "—"}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Progress value={percentDone} className="h-2" />
|
<Progress value={percentDone} className="h-2" />
|
||||||
|
|
@ -66,35 +55,45 @@ export const BackupProgressCard = ({ scheduleId }: Props) => {
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs uppercase text-muted-foreground">Files</p>
|
<p className="text-xs uppercase text-muted-foreground">Files</p>
|
||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
{progress.files_done.toLocaleString()} / {progress.total_files.toLocaleString()}
|
{progress ? (
|
||||||
|
<>
|
||||||
|
{progress.files_done.toLocaleString()} / {progress.total_files.toLocaleString()}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs uppercase text-muted-foreground">Data</p>
|
<p className="text-xs uppercase text-muted-foreground">Data</p>
|
||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
<ByteSize bytes={progress.bytes_done} /> / <ByteSize bytes={progress.total_bytes} />
|
{progress ? (
|
||||||
|
<>
|
||||||
|
<ByteSize bytes={progress.bytes_done} /> / <ByteSize bytes={progress.total_bytes} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs uppercase text-muted-foreground">Elapsed</p>
|
<p className="text-xs uppercase text-muted-foreground">Elapsed</p>
|
||||||
<p className="font-medium">{formatDuration(progress.seconds_elapsed)}</p>
|
<p className="font-medium">{progress ? formatDuration(progress.seconds_elapsed) : "—"}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs uppercase text-muted-foreground">Speed</p>
|
<p className="text-xs uppercase text-muted-foreground">Speed</p>
|
||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
{progress.seconds_elapsed > 0 ? `${speed.text} ${speed.unit}/s` : "Calculating..."}
|
{progress ? (progress.seconds_elapsed > 0 ? `${speed?.text} ${speed?.unit}/s` : "Calculating...") : "—"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{fileName && (
|
<div className="pt-2 border-t border-border">
|
||||||
<div className="pt-2 border-t border-border">
|
<p className="text-xs uppercase text-muted-foreground mb-1">Current file</p>
|
||||||
<p className="text-xs uppercase text-muted-foreground mb-1">Current file</p>
|
<p className="text-xs font-mono text-muted-foreground truncate" title={currentFile || undefined}>
|
||||||
<p className="text-xs font-mono text-muted-foreground truncate" title={currentFile}>
|
{fileName || "—"}
|
||||||
{fileName}
|
</p>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -46,9 +46,6 @@ export const createApp = () => {
|
||||||
|
|
||||||
if (config.environment === "production") {
|
if (config.environment === "production") {
|
||||||
app.use(secureHeaders());
|
app.use(secureHeaders());
|
||||||
}
|
|
||||||
|
|
||||||
if (config.environment !== "test") {
|
|
||||||
app.use(honoLogger());
|
app.use(honoLogger());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ process.on("SIGINT", async () => {
|
||||||
export default await createHonoServer({
|
export default await createHonoServer({
|
||||||
app,
|
app,
|
||||||
port: config.port,
|
port: config.port,
|
||||||
|
defaultLogger: false,
|
||||||
customBunServer: {
|
customBunServer: {
|
||||||
idleTimeout: config.serverIdleTimeout,
|
idleTimeout: config.serverIdleTimeout,
|
||||||
error(err) {
|
error(err) {
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ import { getOrganizationId } from "~/server/core/request-context";
|
||||||
|
|
||||||
const runningBackups = new Map<number, AbortController>();
|
const runningBackups = new Map<number, AbortController>();
|
||||||
|
|
||||||
const calculateNextRun = (cronExpression: string): number => {
|
const calculateNextRun = (cronExpression: string) => {
|
||||||
try {
|
try {
|
||||||
const interval = CronExpressionParser.parse(cronExpression, {
|
const interval = CronExpressionParser.parse(cronExpression, {
|
||||||
currentDate: new Date(),
|
currentDate: new Date(),
|
||||||
|
|
@ -36,7 +36,7 @@ const calculateNextRun = (cronExpression: string): number => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const processPattern = (pattern: string, volumePath: string): string => {
|
const processPattern = (pattern: string, volumePath: string) => {
|
||||||
let isNegated = false;
|
let isNegated = false;
|
||||||
let p = pattern;
|
let p = pattern;
|
||||||
|
|
||||||
|
|
@ -161,7 +161,11 @@ const updateSchedule = async (scheduleId: number, data: UpdateBackupScheduleBody
|
||||||
|
|
||||||
if (data.name) {
|
if (data.name) {
|
||||||
const existingName = await db.query.backupSchedulesTable.findFirst({
|
const existingName = await db.query.backupSchedulesTable.findFirst({
|
||||||
where: and(eq(backupSchedulesTable.name, data.name), ne(backupSchedulesTable.id, scheduleId), eq(backupSchedulesTable.organizationId, organizationId)),
|
where: and(
|
||||||
|
eq(backupSchedulesTable.name, data.name),
|
||||||
|
ne(backupSchedulesTable.id, scheduleId),
|
||||||
|
eq(backupSchedulesTable.organizationId, organizationId),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existingName) {
|
if (existingName) {
|
||||||
|
|
@ -310,7 +314,11 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
||||||
backupOptions.include = schedule.includePatterns.map((p) => processPattern(p, volumePath));
|
backupOptions.include = schedule.includePatterns.map((p) => processPattern(p, volumePath));
|
||||||
}
|
}
|
||||||
|
|
||||||
const releaseBackupLock = await repoMutex.acquireShared(repository.id, `backup:${volume.name}`, abortController.signal);
|
const releaseBackupLock = await repoMutex.acquireShared(
|
||||||
|
repository.id,
|
||||||
|
`backup:${volume.name}`,
|
||||||
|
abortController.signal,
|
||||||
|
);
|
||||||
let exitCode: number;
|
let exitCode: number;
|
||||||
try {
|
try {
|
||||||
const result = await restic.backup(repository.config, volumePath, {
|
const result = await restic.backup(repository.config, volumePath, {
|
||||||
|
|
@ -318,13 +326,14 @@ const executeBackup = async (scheduleId: number, manual = false) => {
|
||||||
compressionMode: repository.compressionMode ?? "auto",
|
compressionMode: repository.compressionMode ?? "auto",
|
||||||
organizationId,
|
organizationId,
|
||||||
onProgress: (progress) => {
|
onProgress: (progress) => {
|
||||||
serverEvents.emit("backup:progress", {
|
const progressData = {
|
||||||
organizationId,
|
organizationId,
|
||||||
scheduleId,
|
scheduleId,
|
||||||
volumeName: volume.name,
|
volumeName: volume.name,
|
||||||
repositoryName: repository.name,
|
repositoryName: repository.name,
|
||||||
...progress,
|
...progress,
|
||||||
});
|
};
|
||||||
|
serverEvents.emit("backup:progress", progressData);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
exitCode = result.exitCode;
|
exitCode = result.exitCode;
|
||||||
|
|
@ -501,7 +510,10 @@ const runForget = async (scheduleId: number, repositoryId?: string) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const repository = await db.query.repositoriesTable.findFirst({
|
const repository = await db.query.repositoriesTable.findFirst({
|
||||||
where: and(eq(repositoriesTable.id, repositoryId ?? schedule.repositoryId), eq(repositoriesTable.organizationId, organizationId)),
|
where: and(
|
||||||
|
eq(repositoriesTable.id, repositoryId ?? schedule.repositoryId),
|
||||||
|
eq(repositoriesTable.organizationId, organizationId),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!repository) {
|
if (!repository) {
|
||||||
|
|
|
||||||
|
|
@ -945,7 +945,8 @@ const formatBandwidthLimit = (limit?: BandwidthLimit): string => {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${Math.floor(kibibytesPerSecond)}`;
|
const limitValue = Math.max(1, Math.floor(kibibytesPerSecond));
|
||||||
|
return `${limitValue}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const addCommonArgs = (
|
export const addCommonArgs = (
|
||||||
|
|
|
||||||
49
scripts/README.md
Normal file
49
scripts/README.md
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
# Scripts
|
||||||
|
|
||||||
|
Utility scripts for Zerobyte development and testing.
|
||||||
|
|
||||||
|
## create-test-files.ts
|
||||||
|
|
||||||
|
Generates temporary test files with random content for testing Zerobyte backup functionality.
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun scripts/create-test-files.ts [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Options
|
||||||
|
|
||||||
|
| Option | Description | Default |
|
||||||
|
|--------|-------------|---------|
|
||||||
|
| `-c, --count <num>` | Number of files to create | 10 |
|
||||||
|
| `--min-size <size>` | Minimum file size | 1K |
|
||||||
|
| `--max-size <size>` | Maximum file size | 1M |
|
||||||
|
| `-o, --out <dir>` | Output directory | ./tmp/test-files |
|
||||||
|
| `-n, --nested` | Create files in nested subdirectories | false |
|
||||||
|
| `-h, --help` | Show help message | - |
|
||||||
|
|
||||||
|
### Size Format
|
||||||
|
|
||||||
|
Sizes can be specified as: `<number>[K|M|G|T][B]`
|
||||||
|
|
||||||
|
- `100` = 100 bytes
|
||||||
|
- `10K` = 10 kilobytes
|
||||||
|
- `5M` = 5 megabytes
|
||||||
|
- `1G` = 1 gigabyte
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create 10 test files (default)
|
||||||
|
bun scripts/create-test-files.ts
|
||||||
|
|
||||||
|
# Create 50 files, 10K to 100K, with nested directories
|
||||||
|
bun scripts/create-test-files.ts -c 50 --min-size 10K --max-size 100K -n
|
||||||
|
|
||||||
|
# Create 5 files, 1MB to 10MB
|
||||||
|
bun scripts/create-test-files.ts -c 5 --min-size 1M --max-size 10M -o ./data/test-backup
|
||||||
|
|
||||||
|
# Create 100 small files in nested structure
|
||||||
|
bun scripts/create-test-files.ts -c 100 --min-size 100B --max-size 1K -n
|
||||||
|
```
|
||||||
173
scripts/create-test-files.ts
Executable file
173
scripts/create-test-files.ts
Executable file
|
|
@ -0,0 +1,173 @@
|
||||||
|
#!/usr/bin/env bun
|
||||||
|
/**
|
||||||
|
* Creates temporary files for testing Zerobyte backup functionality.
|
||||||
|
* Generates files with various sizes and content patterns.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { mkdir, writeFile } from "fs/promises";
|
||||||
|
import { join } from "path";
|
||||||
|
|
||||||
|
interface TestFile {
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
content?: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
count: number;
|
||||||
|
minSize: number;
|
||||||
|
maxSize: number;
|
||||||
|
outDir: string;
|
||||||
|
nested: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(): Options {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const options: Options = {
|
||||||
|
count: 10,
|
||||||
|
minSize: 1024,
|
||||||
|
maxSize: 1024 * 1024,
|
||||||
|
outDir: "./tmp/test-files",
|
||||||
|
nested: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
const arg = args[i];
|
||||||
|
switch (arg) {
|
||||||
|
case "--count":
|
||||||
|
case "-c":
|
||||||
|
options.count = parseInt(args[++i], 10);
|
||||||
|
break;
|
||||||
|
case "--min-size":
|
||||||
|
case "--minsize":
|
||||||
|
options.minSize = parseSize(args[++i]);
|
||||||
|
break;
|
||||||
|
case "--max-size":
|
||||||
|
case "--maxsize":
|
||||||
|
options.maxSize = parseSize(args[++i]);
|
||||||
|
break;
|
||||||
|
case "--out":
|
||||||
|
case "-o":
|
||||||
|
options.outDir = args[++i];
|
||||||
|
break;
|
||||||
|
case "--nested":
|
||||||
|
case "-n":
|
||||||
|
options.nested = true;
|
||||||
|
break;
|
||||||
|
case "--help":
|
||||||
|
case "-h":
|
||||||
|
printHelp();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSize(size: string): number {
|
||||||
|
const match = size.match(/^(\d+)([kmgt]?)b?$/i);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(`Invalid size format: ${size}`);
|
||||||
|
}
|
||||||
|
const num = parseInt(match[1], 10);
|
||||||
|
const unit = match[2].toLowerCase();
|
||||||
|
const multipliers: Record<string, number> = {
|
||||||
|
"": 1,
|
||||||
|
k: 1024,
|
||||||
|
m: 1024 * 1024,
|
||||||
|
g: 1024 * 1024 * 1024,
|
||||||
|
t: 1024 * 1024 * 1024 * 1024,
|
||||||
|
};
|
||||||
|
return num * (multipliers[unit] || 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function printHelp(): void {
|
||||||
|
console.info(`
|
||||||
|
Usage: bun create-test-files.ts [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-c, --count <num> Number of files to create (default: 10)
|
||||||
|
--min-size <size> Minimum file size (default: 1K)
|
||||||
|
--max-size <size> Maximum file size (default: 1M)
|
||||||
|
-o, --out <dir> Output directory (default: ./tmp/test-files)
|
||||||
|
-n, --nested Create files in nested subdirectories
|
||||||
|
-h, --help Show this help message
|
||||||
|
|
||||||
|
Size format: <number>[K|M|G|T][B] (e.g., 100K, 5M, 1G)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomInt(min: number, max: number): number {
|
||||||
|
return Math.floor(Math.random() * (max - min + 1)) + min;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateContent(size: number): Buffer {
|
||||||
|
const content = Buffer.alloc(size);
|
||||||
|
for (let i = 0; i < size; i++) {
|
||||||
|
content[i] = randomInt(32, 126);
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createFiles(options: Options): Promise<void> {
|
||||||
|
console.info(`Creating ${options.count} test files...`);
|
||||||
|
console.info(` Output directory: ${options.outDir}`);
|
||||||
|
console.info(` Size range: ${formatSize(options.minSize)} - ${formatSize(options.maxSize)}`);
|
||||||
|
console.info(` Nested: ${options.nested}`);
|
||||||
|
|
||||||
|
await mkdir(options.outDir, { recursive: true });
|
||||||
|
|
||||||
|
const files: TestFile[] = [];
|
||||||
|
for (let i = 0; i < options.count; i++) {
|
||||||
|
const size = randomInt(options.minSize, options.maxSize);
|
||||||
|
const fileNum = i + 1;
|
||||||
|
|
||||||
|
let dir = options.outDir;
|
||||||
|
if (options.nested) {
|
||||||
|
const depth = randomInt(1, 3);
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (let d = 0; d < depth; d++) {
|
||||||
|
parts.push(`level${d + 1}`);
|
||||||
|
}
|
||||||
|
dir = join(options.outDir, ...parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = join(dir, `test-file-${fileNum.toString().padStart(4, "0")}.txt`);
|
||||||
|
files.push({ name, size });
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalSize = 0;
|
||||||
|
for (const file of files) {
|
||||||
|
await mkdir(file.name.substring(0, file.name.lastIndexOf("/")), { recursive: true });
|
||||||
|
const content = generateContent(file.size);
|
||||||
|
await writeFile(file.name, content);
|
||||||
|
totalSize += file.size;
|
||||||
|
process.stdout.write(`\r Created ${files.indexOf(file) + 1}/${options.count} files`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info(`\nDone! Created ${options.count} files totaling ${formatSize(totalSize)}`);
|
||||||
|
console.info(`Location: ${options.outDir}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||||
|
let size = bytes;
|
||||||
|
let unitIndex = 0;
|
||||||
|
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||||
|
size /= 1024;
|
||||||
|
unitIndex++;
|
||||||
|
}
|
||||||
|
return `${size.toFixed(2)} ${units[unitIndex]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const options = parseArgs();
|
||||||
|
await createFiles(options);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error:", error instanceof Error ? error.message : error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
Loading…
Reference in a new issue