feat: extend snapshot details with more info

Closes #385
This commit is contained in:
Nicolas Meienberger 2026-02-12 17:34:03 +01:00
parent 07aa2bf768
commit 25bb351aaf
17 changed files with 360 additions and 83 deletions

View file

@ -1650,6 +1650,22 @@ export type ListSnapshotsResponses = {
tags: Array<string>; tags: Array<string>;
time: number; time: number;
hostname?: string; hostname?: string;
summary?: {
backup_end: string;
backup_start: string;
data_added: number;
data_blobs: number;
dirs_changed: number;
dirs_new: number;
dirs_unmodified: number;
files_changed: number;
files_new: number;
files_unmodified: number;
total_bytes_processed: number;
total_files_processed: number;
tree_blobs: number;
data_added_packed?: number;
};
}>; }>;
}; };
@ -1720,6 +1736,22 @@ export type GetSnapshotDetailsResponses = {
tags: Array<string>; tags: Array<string>;
time: number; time: number;
hostname?: string; hostname?: string;
summary?: {
backup_end: string;
backup_start: string;
data_added: number;
data_blobs: number;
dirs_changed: number;
dirs_new: number;
dirs_unmodified: number;
files_changed: number;
files_new: number;
files_unmodified: number;
total_bytes_processed: number;
total_files_processed: number;
tree_blobs: number;
data_added_packed?: number;
};
}; };
}; };

View file

@ -0,0 +1,98 @@
import { Card, CardContent } from "~/client/components/ui/card";
import { ByteSize } from "~/client/components/bytes-size";
import { formatDuration } from "~/utils/utils";
type BackupSummary = {
backup_start: string;
backup_end: string;
files_new: number;
files_changed: number;
files_unmodified: number;
dirs_new: number;
dirs_changed: number;
dirs_unmodified: number;
data_blobs: number;
tree_blobs: number;
data_added: number;
data_added_packed?: number | null;
total_files_processed: number;
total_bytes_processed: number;
};
type Props = {
summary?: BackupSummary | null;
};
const formatCount = (value: number) => value.toLocaleString();
const getDurationLabel = (start: string, end: string) => {
const startMs = new Date(start).getTime();
const endMs = new Date(end).getTime();
if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs < startMs) return "-";
return formatDuration(Math.round((endMs - startMs) / 1000));
};
export const BackupSummaryCard = ({ summary }: Props) => {
if (!summary) return null;
const durationLabel = getDurationLabel(summary.backup_start, summary.backup_end);
const topStats = [
{
label: "Data added",
value: <ByteSize bytes={summary.data_added} base={1024} />,
},
{
label: "Data stored",
value: <ByteSize bytes={summary.data_added_packed ?? 0} base={1024} />,
},
{
label: "Files processed",
value: formatCount(summary.total_files_processed),
},
{
label: "Bytes processed",
value: <ByteSize bytes={summary.total_bytes_processed} base={1024} />,
},
{
label: "Duration",
value: durationLabel,
},
];
const detailStats = [
{ label: "New files", value: formatCount(summary.files_new) },
{ label: "Changed files", value: formatCount(summary.files_changed) },
{ label: "Unmodified files", value: formatCount(summary.files_unmodified) },
{ label: "New dirs", value: formatCount(summary.dirs_new) },
{ label: "Changed dirs", value: formatCount(summary.dirs_changed) },
{ label: "Unmodified dirs", value: formatCount(summary.dirs_unmodified) },
{ label: "Data blobs", value: formatCount(summary.data_blobs) },
{ label: "Tree blobs", value: formatCount(summary.tree_blobs) },
];
return (
<Card className="p-4">
<CardContent className="px-4">
<div className="grid gap-6 grid-cols-2 lg:grid-cols-5">
{topStats.map((stat) => (
<div key={stat.label} className="flex flex-col gap-1">
<span className="text-[11px] uppercase tracking-wide text-muted-foreground">{stat.label}</span>
<span className="text-sm font-semibold text-foreground">{stat.value}</span>
</div>
))}
</div>
<div className="mt-4 border-t border-border/60 pt-3">
<div className="grid gap-x-6 gap-y-2 grid-cols-2 lg:grid-cols-4">
{detailStats.map((stat) => (
<div key={stat.label} className="flex items-center justify-start text-xs gap-2">
<span className="font-semibold text-foreground">{stat.value}</span>
<span className="text-muted-foreground">{stat.label}</span>
</div>
))}
</div>
</div>
</CardContent>
</Card>
);
};

View file

@ -1,4 +1,5 @@
import type React from "react"; import type React from "react";
import { formatBytes } from "~/utils/format-bytes";
type ByteSizeProps = { type ByteSizeProps = {
bytes: number; bytes: number;
@ -12,71 +13,6 @@ type ByteSizeProps = {
fallback?: string; // shown if bytes is not a finite number (default: '—') fallback?: string; // shown if bytes is not a finite number (default: '—')
}; };
const SI_UNITS = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"] as const;
const IEC_UNITS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"] as const;
type FormatBytesResult = {
text: string;
unit: string;
unitIndex: number;
numeric: number; // numeric value before formatting (with sign)
};
export function formatBytes(
bytes: number,
options?: {
base?: 1000 | 1024;
maximumFractionDigits?: number;
smartRounding?: boolean;
locale?: string | string[];
},
): FormatBytesResult {
const { base = 1000, maximumFractionDigits = 2, smartRounding = true, locale } = options ?? {};
if (!Number.isFinite(bytes)) {
return {
text: "—",
unit: "",
unitIndex: 0,
numeric: NaN,
};
}
const units = base === 1024 ? IEC_UNITS : SI_UNITS;
const sign = Math.sign(bytes) || 1;
const abs = Math.abs(bytes);
let idx = 0;
if (abs > 0) {
idx = Math.floor(Math.log(abs) / Math.log(base));
if (!Number.isFinite(idx)) idx = 0;
idx = Math.max(0, Math.min(idx, units.length - 1));
}
const numeric = (abs / base ** idx) * sign;
const maxFrac = (() => {
if (!smartRounding) return maximumFractionDigits;
const v = Math.abs(numeric);
if (v >= 100) return 0;
if (v >= 10) return Math.min(1, maximumFractionDigits);
return maximumFractionDigits;
})();
const text = new Intl.NumberFormat(locale, {
minimumFractionDigits: 0,
maximumFractionDigits: maxFrac,
}).format(numeric);
return {
text,
unit: units[idx],
unitIndex: idx,
numeric,
};
}
export function ByteSize(props: ByteSizeProps) { export function ByteSize(props: ByteSizeProps) {
const { const {
bytes, bytes,
@ -95,9 +31,10 @@ export function ByteSize(props: ByteSizeProps) {
maximumFractionDigits, maximumFractionDigits,
smartRounding, smartRounding,
locale, locale,
fallback,
}); });
if (text === "—") { if (text === fallback) {
return ( return (
<span className={className} style={style}> <span className={className} style={style}>
{fallback} {fallback}

View file

@ -529,7 +529,7 @@ const File = memo(({ file, onFileSelect, selected, withCheckbox, checked, onChec
<span className="truncate">{name}</span> <span className="truncate">{name}</span>
{typeof size === "number" && ( {typeof size === "number" && (
<span className="ml-auto shrink-0 text-xs text-muted-foreground"> <span className="ml-auto shrink-0 text-xs text-muted-foreground">
<ByteSize bytes={size} /> <ByteSize bytes={size} base={1024} />
</span> </span>
)} )}
</NodeButton> </NodeButton>

View file

@ -21,6 +21,22 @@ export interface BackupEvent {
volumeName: string; volumeName: string;
repositoryName: string; repositoryName: string;
status?: "success" | "error"; status?: "success" | "error";
summary?: {
files_new: number;
files_changed: number;
files_unmodified: number;
dirs_new: number;
dirs_changed: number;
dirs_unmodified: number;
data_blobs: number;
tree_blobs: number;
data_added: number;
data_added_packed?: number;
total_files_processed: number;
total_bytes_processed: number;
total_duration: number;
snapshot_id: string;
};
} }
export interface BackupProgressEvent { export interface BackupProgressEvent {

View file

@ -1,9 +1,10 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ByteSize, formatBytes } from "~/client/components/bytes-size"; import { ByteSize } from "~/client/components/bytes-size";
import { Card } from "~/client/components/ui/card"; import { Card } from "~/client/components/ui/card";
import { Progress } from "~/client/components/ui/progress"; import { Progress } from "~/client/components/ui/progress";
import { type BackupProgressEvent, useServerEvents } from "~/client/hooks/use-server-events"; import { type BackupProgressEvent, useServerEvents } from "~/client/hooks/use-server-events";
import { formatDuration } from "~/utils/utils"; import { formatDuration } from "~/utils/utils";
import { formatBytes } from "../../../../utils/format-bytes";
type Props = { type Props = {
scheduleId: number; scheduleId: number;
@ -69,7 +70,7 @@ export const BackupProgressCard = ({ scheduleId }: Props) => {
<p className="font-medium"> <p className="font-medium">
{progress ? ( {progress ? (
<> <>
<ByteSize bytes={progress.bytes_done} /> / <ByteSize bytes={progress.total_bytes} /> <ByteSize bytes={progress.bytes_done} base={1024} /> / <ByteSize bytes={progress.total_bytes} base={1024} />
</> </>
) : ( ) : (
"—" "—"

View file

@ -80,7 +80,7 @@ export const SnapshotTimeline = (props: Props) => {
<div className="text-xs font-semibold text-foreground">{formatShortDate(date)}</div> <div className="text-xs font-semibold text-foreground">{formatShortDate(date)}</div>
<div className="text-xs text-muted-foreground">{formatTime(date)}</div> <div className="text-xs text-muted-foreground">{formatTime(date)}</div>
<div className="text-xs text-muted-foreground opacity-75"> <div className="text-xs text-muted-foreground opacity-75">
<ByteSize bytes={snapshot.size} /> <ByteSize bytes={snapshot.size} base={1024} />
</div> </div>
<RetentionCategoryBadges categories={snapshot.retentionCategories} className="mt-1" /> <RetentionCategoryBadges categories={snapshot.retentionCategories} className="mt-1" />
</button> </button>

View file

@ -30,6 +30,7 @@ import { SnapshotFileBrowser } from "../components/snapshot-file-browser";
import { SnapshotTimeline } from "../components/snapshot-timeline"; import { SnapshotTimeline } from "../components/snapshot-timeline";
import { ScheduleNotificationsConfig } from "../components/schedule-notifications-config"; import { ScheduleNotificationsConfig } from "../components/schedule-notifications-config";
import { ScheduleMirrorsConfig } from "../components/schedule-mirrors-config"; import { ScheduleMirrorsConfig } from "../components/schedule-mirrors-config";
import { BackupSummaryCard } from "~/client/components/backup-summary-card";
import { cn } from "~/client/lib/utils"; import { cn } from "~/client/lib/utils";
import type { import type {
BackupSchedule, BackupSchedule,
@ -251,6 +252,7 @@ export function ScheduleDetailsPage(props: Props) {
error={failureReason?.message} error={failureReason?.message}
onSnapshotSelect={setSelectedSnapshotId} onSnapshotSelect={setSelectedSnapshotId}
/> />
<BackupSummaryCard summary={selectedSnapshot?.summary} />
{selectedSnapshot && ( {selectedSnapshot && (
<SnapshotFileBrowser <SnapshotFileBrowser
key={selectedSnapshot?.short_id} key={selectedSnapshot?.short_id}

View file

@ -8,6 +8,7 @@ import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
import { SnapshotFileBrowser } from "~/client/modules/backups/components/snapshot-file-browser"; import { SnapshotFileBrowser } from "~/client/modules/backups/components/snapshot-file-browser";
import { formatDateTime } from "~/client/lib/datetime"; import { formatDateTime } from "~/client/lib/datetime";
import { BackupSummaryCard } from "~/client/components/backup-summary-card";
import { useState } from "react"; import { useState } from "react";
import { Database } from "lucide-react"; import { Database } from "lucide-react";
import { Link, useParams } from "@tanstack/react-router"; import { Link, useParams } from "@tanstack/react-router";
@ -170,6 +171,7 @@ export function SnapshotDetailsPage({ repositoryId, snapshotId }: { repositoryId
</CardContent> </CardContent>
</Card> </Card>
)} )}
{data && <BackupSummaryCard summary={data.summary} />}
</div> </div>
); );
} }

View file

@ -26,6 +26,22 @@ interface ServerEvents {
volumeName: string; volumeName: string;
repositoryName: string; repositoryName: string;
status: "success" | "error" | "stopped" | "warning"; status: "success" | "error" | "stopped" | "warning";
summary?: {
files_new: number;
files_changed: number;
files_unmodified: number;
dirs_new: number;
dirs_changed: number;
dirs_unmodified: number;
data_blobs: number;
tree_blobs: number;
data_added: number;
data_added_packed?: number;
total_files_processed: number;
total_bytes_processed: number;
total_duration: number;
snapshot_id: string;
};
}) => void; }) => void;
"mirror:started": (data: { "mirror:started": (data: {
organizationId: string; organizationId: string;

View file

@ -1,6 +1,6 @@
import { NotFoundError, BadRequestError, ConflictError } from "http-errors-enhanced"; import { NotFoundError, BadRequestError, ConflictError } from "http-errors-enhanced";
import type { BackupSchedule, Volume, Repository } from "../../db/schema"; import type { BackupSchedule, Volume, Repository } from "../../db/schema";
import { restic } from "../../utils/restic"; import { restic, type BackupOutput } from "../../utils/restic";
import { logger } from "../../utils/logger"; import { logger } from "../../utils/logger";
import { cache } from "../../utils/cache"; import { cache } from "../../utils/cache";
import { getVolumePath } from "../volumes/helpers"; import { getVolumePath } from "../volumes/helpers";
@ -125,14 +125,25 @@ const runBackupOperation = async (ctx: BackupContext, signal: AbortSignal) => {
}); });
}, },
}); });
return result.exitCode; return result;
} finally { } finally {
releaseBackupLock(); releaseBackupLock();
} }
}; };
const finalizeSuccessfulBackup = async (ctx: BackupContext, scheduleId: number, exitCode: number) => { const buildBackupSummary = (result: BackupOutput | null | undefined) => {
if (!result) return undefined;
return result;
};
const finalizeSuccessfulBackup = async (
ctx: BackupContext,
scheduleId: number,
exitCode: number,
result: BackupOutput | null,
) => {
const finalStatus = exitCode === 0 ? "success" : "warning"; const finalStatus = exitCode === 0 ? "success" : "warning";
const summary = buildBackupSummary(result);
if (ctx.schedule.retentionPolicy) { if (ctx.schedule.retentionPolicy) {
void runForget(scheduleId).catch((error) => { void runForget(scheduleId).catch((error) => {
@ -171,6 +182,7 @@ const finalizeSuccessfulBackup = async (ctx: BackupContext, scheduleId: number,
volumeName: ctx.volume.name, volumeName: ctx.volume.name,
repositoryName: ctx.repository.name, repositoryName: ctx.repository.name,
status: finalStatus, status: finalStatus,
summary,
}); });
notificationsService notificationsService
@ -178,6 +190,7 @@ const finalizeSuccessfulBackup = async (ctx: BackupContext, scheduleId: number,
volumeName: ctx.volume.name, volumeName: ctx.volume.name,
repositoryName: ctx.repository.name, repositoryName: ctx.repository.name,
scheduleName: ctx.schedule.name, scheduleName: ctx.schedule.name,
summary,
}) })
.catch((error) => { .catch((error) => {
logger.error(`Failed to send backup success notification: ${toMessage(error)}`); logger.error(`Failed to send backup success notification: ${toMessage(error)}`);
@ -259,8 +272,8 @@ const executeBackup = async (scheduleId: number, manual = false): Promise<void>
runningBackups.set(scheduleId, abortController); runningBackups.set(scheduleId, abortController);
try { try {
const exitCode = await runBackupOperation(ctx, abortController.signal); const backupResult = await runBackupOperation(ctx, abortController.signal);
await finalizeSuccessfulBackup(ctx, scheduleId, exitCode); await finalizeSuccessfulBackup(ctx, scheduleId, backupResult.exitCode, backupResult.result);
} catch (error) { } catch (error) {
await handleBackupFailure(scheduleId, ctx.organizationId, error, ctx); await handleBackupFailure(scheduleId, ctx.organizationId, error, ctx);
} finally { } finally {

View file

@ -54,6 +54,22 @@ export const eventsController = new Hono().use(requireAuth).get("/", (c) => {
volumeName: string; volumeName: string;
repositoryName: string; repositoryName: string;
status: "success" | "error" | "stopped" | "warning"; status: "success" | "error" | "stopped" | "warning";
summary?: {
files_new: number;
files_changed: number;
files_unmodified: number;
dirs_new: number;
dirs_changed: number;
dirs_unmodified: number;
data_blobs: number;
tree_blobs: number;
data_added: number;
data_added_packed?: number;
total_files_processed: number;
total_bytes_processed: number;
total_duration: number;
snapshot_id: string;
};
}) => { }) => {
if (data.organizationId !== organizationId) return; if (data.organizationId !== organizationId) return;
await stream.writeSSE({ await stream.writeSSE({

View file

@ -9,11 +9,14 @@ import {
import { cryptoUtils } from "../../utils/crypto"; import { cryptoUtils } from "../../utils/crypto";
import { logger } from "../../utils/logger"; import { logger } from "../../utils/logger";
import { sendNotification } from "../../utils/shoutrrr"; import { sendNotification } from "../../utils/shoutrrr";
import type { BackupOutput } from "../../utils/restic";
import { formatDuration } from "~/utils/utils";
import { buildShoutrrrUrl } from "./builders"; import { buildShoutrrrUrl } from "./builders";
import { notificationConfigSchema, type NotificationConfig, type NotificationEvent } from "~/schemas/notifications"; import { notificationConfigSchema, type NotificationConfig, type NotificationEvent } from "~/schemas/notifications";
import { toMessage } from "../../utils/errors"; import { toMessage } from "../../utils/errors";
import { type } from "arktype"; import { type } from "arktype";
import { getOrganizationId } from "~/server/core/request-context"; import { getOrganizationId } from "~/server/core/request-context";
import { formatBytes } from "~/utils/format-bytes";
const listDestinations = async () => { const listDestinations = async () => {
const organizationId = getOrganizationId(); const organizationId = getOrganizationId();
@ -315,6 +318,50 @@ const updateScheduleNotifications = async (
return getScheduleNotifications(scheduleId); return getScheduleNotifications(scheduleId);
}; };
type BackupSummary = Omit<BackupOutput, "message_type">;
const formatBytesText = (bytes: number) => {
const { text, unit } = formatBytes(bytes, {
base: 1024,
locale: "en-US",
fallback: "-",
});
return unit ? `${text} ${unit}` : text;
};
const buildBackupSummaryLines = (summary?: BackupSummary) => {
if (!summary) return [];
const safeNumber = (value: number | undefined) => (typeof value === "number" && Number.isFinite(value) ? value : 0);
const safeCountText = (value: number | undefined) => safeNumber(value).toLocaleString();
const safeBytesText = (value: number | undefined) => formatBytesText(safeNumber(value));
const safeDurationText = (value: number | undefined) =>
typeof value === "number" && Number.isFinite(value) ? formatDuration(Math.round(value)) : "N/A";
const snapshotText = summary.snapshot_id ?? "N/A";
const lines = [
"Overview:",
`- Data added: ${safeBytesText(summary.data_added)}`,
summary.data_added_packed !== undefined ? `- Data stored: ${safeBytesText(summary.data_added_packed)}` : null,
`- Total files processed: ${safeCountText(summary.total_files_processed)}`,
`- Total bytes processed: ${safeBytesText(summary.total_bytes_processed)}`,
"Backup Statistics:",
`- Files new: ${safeCountText(summary.files_new)}`,
`- Files changed: ${safeCountText(summary.files_changed)}`,
`- Files unmodified: ${safeCountText(summary.files_unmodified)}`,
`- Dirs new: ${safeCountText(summary.dirs_new)}`,
`- Dirs changed: ${safeCountText(summary.dirs_changed)}`,
`- Dirs unmodified: ${safeCountText(summary.dirs_unmodified)}`,
`- Data blobs: ${safeCountText(summary.data_blobs)}`,
`- Tree blobs: ${safeCountText(summary.tree_blobs)}`,
`- Total duration: ${safeDurationText(summary.total_duration)}`,
`- Snapshot: ${snapshotText}`,
];
return lines.filter((line): line is string => Boolean(line));
};
const sendBackupNotification = async ( const sendBackupNotification = async (
scheduleId: number, scheduleId: number,
event: NotificationEvent, event: NotificationEvent,
@ -327,6 +374,7 @@ const sendBackupNotification = async (
filesProcessed?: number; filesProcessed?: number;
bytesProcessed?: string; bytesProcessed?: string;
snapshotId?: string; snapshotId?: string;
summary?: BackupSummary;
}, },
) => { ) => {
try { try {
@ -406,9 +454,17 @@ function buildNotificationMessage(
filesProcessed?: number; filesProcessed?: number;
bytesProcessed?: string; bytesProcessed?: string;
snapshotId?: string; snapshotId?: string;
summary?: BackupSummary;
}, },
) { ) {
const backupName = context.scheduleName ?? "backup"; const backupName = context.scheduleName ?? "backup";
const derivedDuration =
context.duration ?? (context.summary?.total_duration ? context.summary.total_duration * 1000 : undefined);
const derivedFilesProcessed = context.filesProcessed ?? context.summary?.total_files_processed;
const derivedBytesProcessed =
context.bytesProcessed ?? (context.summary ? formatBytesText(context.summary.total_bytes_processed) : undefined);
const derivedSnapshotId = context.snapshotId ?? context.summary?.snapshot_id;
const summaryLines = buildBackupSummaryLines(context.summary);
switch (event) { switch (event) {
case "start": case "start":
@ -430,10 +486,11 @@ function buildNotificationMessage(
`Volume: ${context.volumeName}`, `Volume: ${context.volumeName}`,
`Repository: ${context.repositoryName}`, `Repository: ${context.repositoryName}`,
context.scheduleName ? `Schedule: ${context.scheduleName}` : null, context.scheduleName ? `Schedule: ${context.scheduleName}` : null,
context.duration ? `Duration: ${Math.round(context.duration / 1000)}s` : null, derivedDuration ? `Duration: ${Math.round(derivedDuration / 1000)}s` : null,
context.filesProcessed !== undefined ? `Files: ${context.filesProcessed}` : null, derivedFilesProcessed !== undefined ? `Files: ${derivedFilesProcessed.toLocaleString()}` : null,
context.bytesProcessed ? `Size: ${context.bytesProcessed}` : null, derivedBytesProcessed ? `Size: ${derivedBytesProcessed}` : null,
context.snapshotId ? `Snapshot: ${context.snapshotId}` : null, derivedSnapshotId ? `Snapshot: ${derivedSnapshotId}` : null,
...summaryLines,
] ]
.filter(Boolean) .filter(Boolean)
.join("\n"), .join("\n"),
@ -446,11 +503,12 @@ function buildNotificationMessage(
`Volume: ${context.volumeName}`, `Volume: ${context.volumeName}`,
`Repository: ${context.repositoryName}`, `Repository: ${context.repositoryName}`,
context.scheduleName ? `Schedule: ${context.scheduleName}` : null, context.scheduleName ? `Schedule: ${context.scheduleName}` : null,
context.duration ? `Duration: ${Math.round(context.duration / 1000)}s` : null, derivedDuration ? `Duration: ${Math.round(derivedDuration / 1000)}s` : null,
context.filesProcessed !== undefined ? `Files: ${context.filesProcessed}` : null, derivedFilesProcessed !== undefined ? `Files: ${derivedFilesProcessed.toLocaleString()}` : null,
context.bytesProcessed ? `Size: ${context.bytesProcessed}` : null, derivedBytesProcessed ? `Size: ${derivedBytesProcessed}` : null,
context.snapshotId ? `Snapshot: ${context.snapshotId}` : null, derivedSnapshotId ? `Snapshot: ${derivedSnapshotId}` : null,
context.error ? `Warning: ${context.error}` : null, context.error ? `Warning: ${context.error}` : null,
...summaryLines,
] ]
.filter(Boolean) .filter(Boolean)
.join("\n"), .join("\n"),

View file

@ -116,6 +116,7 @@ export const repositoriesController = new Hono()
size: summary?.total_bytes_processed || 0, size: summary?.total_bytes_processed || 0,
time: new Date(snapshot.time).getTime(), time: new Date(snapshot.time).getTime(),
retentionCategories: retentionCategories.get(snapshot.short_id) ?? [], retentionCategories: retentionCategories.get(snapshot.short_id) ?? [],
summary: summary,
}; };
}); });

View file

@ -180,6 +180,22 @@ export const snapshotSchema = type({
tags: "string[]", tags: "string[]",
retentionCategories: "string[]", retentionCategories: "string[]",
hostname: "string?", hostname: "string?",
summary: type({
backup_start: "string",
backup_end: "string",
files_new: "number",
files_changed: "number",
files_unmodified: "number",
dirs_new: "number",
dirs_changed: "number",
dirs_unmodified: "number",
data_blobs: "number",
tree_blobs: "number",
data_added: "number",
data_added_packed: "number?",
total_files_processed: "number",
total_bytes_processed: "number",
}).optional(),
}); });
const listSnapshotsResponse = snapshotSchema.array(); const listSnapshotsResponse = snapshotSchema.array();

View file

@ -26,6 +26,7 @@ const backupOutputSchema = type({
data_blobs: "number", data_blobs: "number",
tree_blobs: "number", tree_blobs: "number",
data_added: "number", data_added: "number",
data_added_packed: "number?",
total_files_processed: "number", total_files_processed: "number",
total_bytes_processed: "number", total_bytes_processed: "number",
total_duration: "number", total_duration: "number",

68
app/utils/format-bytes.ts Normal file
View file

@ -0,0 +1,68 @@
const SI_UNITS = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"] as const;
const IEC_UNITS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"] as const;
export type FormatBytesResult = {
text: string;
unit: string;
unitIndex: number;
numeric: number;
};
export type FormatBytesOptions = {
base?: 1000 | 1024;
maximumFractionDigits?: number;
smartRounding?: boolean;
locale?: string | string[];
fallback?: string;
};
export function formatBytes(bytes: number, options?: FormatBytesOptions): FormatBytesResult {
const {
base = 1000,
maximumFractionDigits = 2,
smartRounding = true,
locale,
fallback = "—",
} = options ?? {};
if (!Number.isFinite(bytes)) {
return {
text: fallback,
unit: "",
unitIndex: 0,
numeric: NaN,
};
}
const units = base === 1024 ? IEC_UNITS : SI_UNITS;
const sign = Math.sign(bytes) || 1;
const abs = Math.abs(bytes);
let idx = 0;
if (abs > 0) {
idx = Math.floor(Math.log(abs) / Math.log(base));
if (!Number.isFinite(idx)) idx = 0;
idx = Math.max(0, Math.min(idx, units.length - 1));
}
const numeric = (abs / base ** idx) * sign;
const maxFrac = (() => {
if (!smartRounding) return maximumFractionDigits;
const value = Math.abs(numeric);
if (value >= 100) return 0;
if (value >= 10) return Math.min(1, maximumFractionDigits);
return maximumFractionDigits;
})();
const text = new Intl.NumberFormat(locale, {
minimumFractionDigits: 0,
maximumFractionDigits: maxFrac,
}).format(numeric);
return {
text,
unit: units[idx],
unitIndex: idx,
numeric,
};
}