feat: repository used space (#551)
* feat: repository used space refactor: use a smarter cache key logic for easier invalidations * chore: pr feedbacks
This commit is contained in:
parent
182d39a887
commit
45fd9f9de1
16 changed files with 483 additions and 159 deletions
|
|
@ -33,6 +33,7 @@ import {
|
||||||
getNotificationDestination,
|
getNotificationDestination,
|
||||||
getRegistrationStatus,
|
getRegistrationStatus,
|
||||||
getRepository,
|
getRepository,
|
||||||
|
getRepositoryStats,
|
||||||
getScheduleMirrors,
|
getScheduleMirrors,
|
||||||
getScheduleNotifications,
|
getScheduleNotifications,
|
||||||
getSnapshotDetails,
|
getSnapshotDetails,
|
||||||
|
|
@ -117,6 +118,8 @@ import type {
|
||||||
GetRegistrationStatusResponse,
|
GetRegistrationStatusResponse,
|
||||||
GetRepositoryData,
|
GetRepositoryData,
|
||||||
GetRepositoryResponse,
|
GetRepositoryResponse,
|
||||||
|
GetRepositoryStatsData,
|
||||||
|
GetRepositoryStatsResponse,
|
||||||
GetScheduleMirrorsData,
|
GetScheduleMirrorsData,
|
||||||
GetScheduleMirrorsResponse,
|
GetScheduleMirrorsResponse,
|
||||||
GetScheduleNotificationsData,
|
GetScheduleNotificationsData,
|
||||||
|
|
@ -688,6 +691,31 @@ export const updateRepositoryMutation = (
|
||||||
return mutationOptions;
|
return mutationOptions;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getRepositoryStatsQueryKey = (options: Options<GetRepositoryStatsData>) =>
|
||||||
|
createQueryKey("getRepositoryStats", options);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get repository storage and compression statistics
|
||||||
|
*/
|
||||||
|
export const getRepositoryStatsOptions = (options: Options<GetRepositoryStatsData>) =>
|
||||||
|
queryOptions<
|
||||||
|
GetRepositoryStatsResponse,
|
||||||
|
DefaultError,
|
||||||
|
GetRepositoryStatsResponse,
|
||||||
|
ReturnType<typeof getRepositoryStatsQueryKey>
|
||||||
|
>({
|
||||||
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
|
const { data } = await getRepositoryStats({
|
||||||
|
...options,
|
||||||
|
...queryKey[0],
|
||||||
|
signal,
|
||||||
|
throwOnError: true,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
queryKey: getRepositoryStatsQueryKey(options),
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete multiple snapshots from a repository
|
* Delete multiple snapshots from a repository
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ export {
|
||||||
getNotificationDestination,
|
getNotificationDestination,
|
||||||
getRegistrationStatus,
|
getRegistrationStatus,
|
||||||
getRepository,
|
getRepository,
|
||||||
|
getRepositoryStats,
|
||||||
getScheduleMirrors,
|
getScheduleMirrors,
|
||||||
getScheduleNotifications,
|
getScheduleNotifications,
|
||||||
getSnapshotDetails,
|
getSnapshotDetails,
|
||||||
|
|
@ -135,6 +136,9 @@ export type {
|
||||||
GetRepositoryData,
|
GetRepositoryData,
|
||||||
GetRepositoryResponse,
|
GetRepositoryResponse,
|
||||||
GetRepositoryResponses,
|
GetRepositoryResponses,
|
||||||
|
GetRepositoryStatsData,
|
||||||
|
GetRepositoryStatsResponse,
|
||||||
|
GetRepositoryStatsResponses,
|
||||||
GetScheduleMirrorsData,
|
GetScheduleMirrorsData,
|
||||||
GetScheduleMirrorsResponse,
|
GetScheduleMirrorsResponse,
|
||||||
GetScheduleMirrorsResponses,
|
GetScheduleMirrorsResponses,
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,8 @@ import type {
|
||||||
GetRegistrationStatusResponses,
|
GetRegistrationStatusResponses,
|
||||||
GetRepositoryData,
|
GetRepositoryData,
|
||||||
GetRepositoryResponses,
|
GetRepositoryResponses,
|
||||||
|
GetRepositoryStatsData,
|
||||||
|
GetRepositoryStatsResponses,
|
||||||
GetScheduleMirrorsData,
|
GetScheduleMirrorsData,
|
||||||
GetScheduleMirrorsResponses,
|
GetScheduleMirrorsResponses,
|
||||||
GetScheduleNotificationsData,
|
GetScheduleNotificationsData,
|
||||||
|
|
@ -363,6 +365,17 @@ export const updateRepository = <ThrowOnError extends boolean = false>(
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get repository storage and compression statistics
|
||||||
|
*/
|
||||||
|
export const getRepositoryStats = <ThrowOnError extends boolean = false>(
|
||||||
|
options: Options<GetRepositoryStatsData, ThrowOnError>,
|
||||||
|
) =>
|
||||||
|
(options.client ?? client).get<GetRepositoryStatsResponses, unknown, ThrowOnError>({
|
||||||
|
url: "/api/v1/repositories/{shortId}/stats",
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete multiple snapshots from a repository
|
* Delete multiple snapshots from a repository
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1770,6 +1770,31 @@ export type UpdateRepositoryResponses = {
|
||||||
|
|
||||||
export type UpdateRepositoryResponse = UpdateRepositoryResponses[keyof UpdateRepositoryResponses];
|
export type UpdateRepositoryResponse = UpdateRepositoryResponses[keyof UpdateRepositoryResponses];
|
||||||
|
|
||||||
|
export type GetRepositoryStatsData = {
|
||||||
|
body?: never;
|
||||||
|
path: {
|
||||||
|
shortId: string;
|
||||||
|
};
|
||||||
|
query?: never;
|
||||||
|
url: "/api/v1/repositories/{shortId}/stats";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetRepositoryStatsResponses = {
|
||||||
|
/**
|
||||||
|
* Repository statistics
|
||||||
|
*/
|
||||||
|
200: {
|
||||||
|
compression_progress?: number;
|
||||||
|
compression_ratio?: number;
|
||||||
|
compression_space_saving?: number;
|
||||||
|
snapshots_count?: number;
|
||||||
|
total_size?: number;
|
||||||
|
total_uncompressed_size?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetRepositoryStatsResponse = GetRepositoryStatsResponses[keyof GetRepositoryStatsResponses];
|
||||||
|
|
||||||
export type DeleteSnapshotsData = {
|
export type DeleteSnapshotsData = {
|
||||||
body?: {
|
body?: {
|
||||||
snapshotIds: Array<string>;
|
snapshotIds: Array<string>;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,134 @@
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Archive } from "lucide-react";
|
||||||
|
import { getRepositoryStatsOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
|
import { ByteSize } from "~/client/components/bytes-size";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
repositoryShortId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toSafeNumber = (value: number | undefined) => {
|
||||||
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(0, value);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompressionStatsChart({ repositoryShortId }: Props) {
|
||||||
|
const {
|
||||||
|
data: stats,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
} = useQuery({
|
||||||
|
...getRepositoryStatsOptions({ path: { shortId: repositoryShortId } }),
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const storedSize = toSafeNumber(stats?.total_size);
|
||||||
|
const uncompressedSize = toSafeNumber(stats?.total_uncompressed_size);
|
||||||
|
const savedSize = uncompressedSize > storedSize ? uncompressedSize - storedSize : 0;
|
||||||
|
|
||||||
|
const compressionRatio = toSafeNumber(stats?.compression_ratio);
|
||||||
|
|
||||||
|
const rawCompressionProgress = toSafeNumber(stats?.compression_progress);
|
||||||
|
const compressionProgressPercent = Math.min(100, Math.max(0, rawCompressionProgress));
|
||||||
|
|
||||||
|
const spaceSavingPercent = toSafeNumber(stats?.compression_space_saving);
|
||||||
|
const snapshotsCount = Math.round(toSafeNumber(stats?.snapshots_count));
|
||||||
|
|
||||||
|
const hasStats = !!stats && (storedSize > 0 || uncompressedSize > 0 || snapshotsCount > 0);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card className="p-6">
|
||||||
|
<p className="text-sm text-muted-foreground">Loading compression statistics...</p>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className="p-6">
|
||||||
|
<p className="text-sm font-medium text-destructive">Failed to load compression statistics</p>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground wrap-break-word">{error.message}</p>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasStats) {
|
||||||
|
return (
|
||||||
|
<Card className="p-6">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No compression statistics available yet. Run a backup to populate repository stats.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="flex flex-col h-full">
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base font-semibold">
|
||||||
|
<Archive className="h-4 w-4" />
|
||||||
|
Compression Statistics
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-1">
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-y-6 gap-x-6">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider">Stored Size</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<ByteSize base={1024} bytes={storedSize} className="text-2xl font-bold font-mono text-foreground" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider">Uncompressed</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<ByteSize base={1024} bytes={uncompressedSize} className="text-2xl font-bold font-mono text-foreground" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider">Space Saved</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="text-2xl font-bold font-mono text-foreground">{spaceSavingPercent.toFixed(1)}%</span>
|
||||||
|
<ByteSize base={1024} bytes={savedSize} className="text-sm text-muted-foreground font-mono" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider">Ratio</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="text-2xl font-bold font-mono text-foreground">
|
||||||
|
{compressionRatio > 0 ? `${compressionRatio.toFixed(2)}x` : "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider">Snapshots</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="text-2xl font-bold font-mono text-foreground">{snapshotsCount.toLocaleString()}</span>
|
||||||
|
<span className="text-sm text-muted-foreground font-mono">
|
||||||
|
{compressionProgressPercent.toFixed(1)}% compressed
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -25,6 +25,7 @@ import type { RepositoryConfig } from "~/schemas/restic";
|
||||||
import { DoctorReport } from "../components/doctor-report";
|
import { DoctorReport } from "../components/doctor-report";
|
||||||
import { parseError } from "~/client/lib/errors";
|
import { parseError } from "~/client/lib/errors";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
import { CompressionStatsChart } from "../components/compression-stats-chart";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
repository: Repository;
|
repository: Repository;
|
||||||
|
|
@ -96,143 +97,148 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card className="p-6 @container">
|
<div className="grid gap-4">
|
||||||
<div className="flex flex-col @xl:flex-row items-start @xl:items-center justify-between gap-4">
|
<Card className="p-6 @container">
|
||||||
<div>
|
<div className="flex flex-col @xl:flex-row items-start @xl:items-center justify-between gap-4">
|
||||||
<span className="text-lg font-semibold">Repository Settings</span>
|
<div>
|
||||||
</div>
|
<span className="text-lg font-semibold">Repository Settings</span>
|
||||||
<div className="flex flex-col @xl:flex-row w-full @xl:w-auto gap-2">
|
</div>
|
||||||
<Button
|
<div className="flex flex-col @xl:flex-row w-full @xl:w-auto gap-2">
|
||||||
type="button"
|
<Button
|
||||||
variant="outline"
|
type="button"
|
||||||
onClick={() => navigate({ to: `/repositories/${repository.shortId}/edit` })}
|
variant="outline"
|
||||||
>
|
onClick={() => navigate({ to: `/repositories/${repository.shortId}/edit` })}
|
||||||
<Pencil className="h-4 w-4 mr-2" />
|
>
|
||||||
Edit
|
<Pencil className="h-4 w-4 mr-2" />
|
||||||
</Button>
|
Edit
|
||||||
{repository.status === "doctor" ? (
|
</Button>
|
||||||
|
{repository.status === "doctor" ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
loading={cancelDoctor.isPending}
|
||||||
|
onClick={() => cancelDoctor.mutate({ path: { shortId: repository.shortId } })}
|
||||||
|
>
|
||||||
|
<Square className="h-4 w-4 mr-2" />
|
||||||
|
<span>Cancel doctor</span>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => startDoctor.mutate({ path: { shortId: repository.shortId } })}
|
||||||
|
disabled={startDoctor.isPending}
|
||||||
|
>
|
||||||
|
<Stethoscope className="h-4 w-4 mr-2" />
|
||||||
|
Run doctor
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => unlockRepo.mutate({ path: { shortId: repository.shortId } })}
|
||||||
|
loading={unlockRepo.isPending}
|
||||||
|
>
|
||||||
|
<Unlock className="h-4 w-4 mr-2" />
|
||||||
|
Unlock
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
loading={cancelDoctor.isPending}
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
onClick={() => cancelDoctor.mutate({ path: { shortId: repository.shortId } })}
|
disabled={deleteRepo.isPending}
|
||||||
>
|
>
|
||||||
<Square className="h-4 w-4 mr-2" />
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
<span>Cancel doctor</span>
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={() => startDoctor.mutate({ path: { shortId: repository.shortId } })}
|
|
||||||
disabled={startDoctor.isPending}
|
|
||||||
>
|
|
||||||
<Stethoscope className="h-4 w-4 mr-2" />
|
|
||||||
Run doctor
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => unlockRepo.mutate({ path: { shortId: repository.shortId } })}
|
|
||||||
loading={unlockRepo.isPending}
|
|
||||||
>
|
|
||||||
<Unlock className="h-4 w-4 mr-2" />
|
|
||||||
Unlock
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => setShowDeleteConfirm(true)}
|
|
||||||
disabled={deleteRepo.isPending}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold mb-4">Current Configuration</h3>
|
|
||||||
<div className="grid grid-cols-1 @xl:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-muted-foreground">Name</div>
|
|
||||||
<p className="mt-1 text-sm">{repository.name}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-muted-foreground">Compression mode</div>
|
|
||||||
<p className="mt-1 text-sm">{repository.compressionMode || "off"}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold mb-4">Repository Information</h3>
|
|
||||||
<div className="grid grid-cols-1 @xl:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-muted-foreground">Backend</div>
|
|
||||||
<p className="mt-1 text-sm">{repository.type}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-muted-foreground">Status</div>
|
|
||||||
<p className="mt-1 text-sm">{repository.status || "unknown"}</p>
|
|
||||||
</div>
|
|
||||||
{effectiveLocalPath && (
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-muted-foreground">Local path</div>
|
|
||||||
<p className="mt-1 text-sm font-mono">{effectiveLocalPath}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-muted-foreground">Created at</div>
|
|
||||||
<p className="mt-1 text-sm">{formatDateTime(repository.createdAt)}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-muted-foreground">Last checked</div>
|
|
||||||
<p className="mt-1 text-sm">{formatTimeAgo(repository.lastChecked)}</p>
|
|
||||||
</div>
|
|
||||||
{config.cacert && (
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-muted-foreground">CA Certificate</div>
|
|
||||||
<p className="mt-1 text-sm">
|
|
||||||
<span className="text-green-500">configured</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{"insecureTls" in config && (
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-muted-foreground">TLS Certificate Validation</div>
|
|
||||||
<p className="mt-1 text-sm">
|
|
||||||
{config.insecureTls ? (
|
|
||||||
<span className="text-red-500">disabled</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-green-500">enabled</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{repository.lastError && (
|
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<h3 className="text-lg font-semibold mb-4">Current Configuration</h3>
|
||||||
<h3 className="text-lg font-semibold text-red-500">Last Error</h3>
|
<div className="grid grid-cols-1 @xl:grid-cols-2 gap-4">
|
||||||
</div>
|
<div>
|
||||||
<div className="bg-red-500/10 border border-red-500/20 rounded-md p-4">
|
<div className="text-sm font-medium text-muted-foreground">Name</div>
|
||||||
<p className="text-sm text-red-500 wrap-break-word">{repository.lastError}</p>
|
<p className="mt-1 text-sm">{repository.name}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">Compression mode</div>
|
||||||
|
<p className="mt-1 text-sm">{repository.compressionMode || "off"}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold mb-4">Configuration</h3>
|
<h3 className="text-lg font-semibold mb-4">Repository Information</h3>
|
||||||
<div className="bg-muted/50 rounded-md p-4">
|
<div className="grid grid-cols-1 @xl:grid-cols-2 gap-4">
|
||||||
<pre className="text-sm overflow-auto">{JSON.stringify(repository.config, null, 2)}</pre>
|
<div>
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">Backend</div>
|
||||||
|
<p className="mt-1 text-sm">{repository.type}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">Status</div>
|
||||||
|
<p className="mt-1 text-sm">{repository.status || "unknown"}</p>
|
||||||
|
</div>
|
||||||
|
{effectiveLocalPath && (
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">Local path</div>
|
||||||
|
<p className="mt-1 text-sm font-mono">{effectiveLocalPath}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">Created at</div>
|
||||||
|
<p className="mt-1 text-sm">{formatDateTime(repository.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">Last checked</div>
|
||||||
|
<p className="mt-1 text-sm">{formatTimeAgo(repository.lastChecked)}</p>
|
||||||
|
</div>
|
||||||
|
{config.cacert && (
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">CA Certificate</div>
|
||||||
|
<p className="mt-1 text-sm">
|
||||||
|
<span className="text-green-500">configured</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{"insecureTls" in config && (
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">TLS Certificate Validation</div>
|
||||||
|
<p className="mt-1 text-sm">
|
||||||
|
{config.insecureTls ? (
|
||||||
|
<span className="text-red-500">disabled</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-green-500">enabled</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<DoctorReport repositoryStatus={repository.status} result={repository.doctorResult} />
|
<div>
|
||||||
</Card>
|
{repository.lastError && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-red-500">Last Error</h3>
|
||||||
|
</div>
|
||||||
|
<div className="bg-red-500/10 border border-red-500/20 rounded-md p-4">
|
||||||
|
<p className="text-sm text-red-500 wrap-break-word">{repository.lastError}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Configuration</h3>
|
||||||
|
<div className="bg-muted/50 rounded-md p-4">
|
||||||
|
<pre className="text-sm overflow-auto">{JSON.stringify(repository.config, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DoctorReport repositoryStatus={repository.status} result={repository.doctorResult} />
|
||||||
|
</Card>
|
||||||
|
<CompressionStatsChart repositoryShortId={repository.shortId} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,15 @@ export const resticRestoreOutputSchema = type({
|
||||||
bytes_skipped: "number",
|
bytes_skipped: "number",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const resticStatsSchema = type({
|
||||||
|
total_size: "number = 0",
|
||||||
|
total_uncompressed_size: "number = 0",
|
||||||
|
compression_ratio: "number = 0",
|
||||||
|
compression_progress: "number = 0",
|
||||||
|
compression_space_saving: "number = 0",
|
||||||
|
snapshots_count: "number = 0",
|
||||||
|
});
|
||||||
|
|
||||||
export type ResticSnapshotSummaryDto = typeof resticSnapshotSummarySchema.infer;
|
export type ResticSnapshotSummaryDto = typeof resticSnapshotSummarySchema.infer;
|
||||||
export type ResticBackupRunSummaryDto = typeof resticBackupRunSummarySchema.infer;
|
export type ResticBackupRunSummaryDto = typeof resticBackupRunSummarySchema.infer;
|
||||||
export type ResticBackupOutputDto = typeof resticBackupOutputSchema.infer;
|
export type ResticBackupOutputDto = typeof resticBackupOutputSchema.infer;
|
||||||
|
|
@ -68,3 +77,4 @@ export type ResticBackupProgressMetricsDto = typeof resticBackupProgressMetricsS
|
||||||
export type ResticBackupProgressDto = typeof resticBackupProgressSchema.infer;
|
export type ResticBackupProgressDto = typeof resticBackupProgressSchema.infer;
|
||||||
|
|
||||||
export type ResticRestoreOutputDto = typeof resticRestoreOutputSchema.infer;
|
export type ResticRestoreOutputDto = typeof resticRestoreOutputSchema.infer;
|
||||||
|
export type ResticStatsDto = typeof resticStatsSchema.infer;
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { NotFoundError, BadRequestError, ConflictError } from "http-errors-enhan
|
||||||
import type { BackupSchedule, Volume, Repository } from "../../db/schema";
|
import type { BackupSchedule, Volume, Repository } from "../../db/schema";
|
||||||
import { restic } from "../../utils/restic";
|
import { restic } from "../../utils/restic";
|
||||||
import { logger } from "../../utils/logger";
|
import { logger } from "../../utils/logger";
|
||||||
import { cache } from "../../utils/cache";
|
import { cache, cacheKeys } from "../../utils/cache";
|
||||||
import { getVolumePath } from "../volumes/helpers";
|
import { getVolumePath } from "../volumes/helpers";
|
||||||
import { toMessage } from "../../utils/errors";
|
import { toMessage } from "../../utils/errors";
|
||||||
import { serverEvents } from "../../core/events";
|
import { serverEvents } from "../../core/events";
|
||||||
|
|
@ -150,8 +150,7 @@ const finalizeSuccessfulBackup = async (
|
||||||
logger.error(`Background mirror copy failed for schedule ${scheduleId}: ${toMessage(error)}`);
|
logger.error(`Background mirror copy failed for schedule ${scheduleId}: ${toMessage(error)}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
cache.delByPrefix(`snapshots:${ctx.repository.id}:`);
|
cache.delByPrefix(cacheKeys.repository.all(ctx.repository.id));
|
||||||
cache.del(`retention:${ctx.schedule.shortId}`);
|
|
||||||
|
|
||||||
const nextBackupAt = calculateNextRun(ctx.schedule.cronExpression);
|
const nextBackupAt = calculateNextRun(ctx.schedule.cronExpression);
|
||||||
await scheduleQueries.updateStatus(scheduleId, ctx.organizationId, {
|
await scheduleQueries.updateStatus(scheduleId, ctx.organizationId, {
|
||||||
|
|
@ -328,8 +327,7 @@ const runForget = async (scheduleId: number, repositoryId?: string) => {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await restic.forget(repository.config, schedule.retentionPolicy, { tag: schedule.shortId, organizationId });
|
await restic.forget(repository.config, schedule.retentionPolicy, { tag: schedule.shortId, organizationId });
|
||||||
cache.delByPrefix(`snapshots:${repository.id}:`);
|
cache.delByPrefix(cacheKeys.repository.all(repository.id));
|
||||||
cache.del(`retention:${schedule.shortId}`);
|
|
||||||
} finally {
|
} finally {
|
||||||
releaseLock();
|
releaseLock();
|
||||||
}
|
}
|
||||||
|
|
@ -393,7 +391,7 @@ const copyToSingleMirror = async (
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await restic.copy(sourceRepository.config, mirror.repository.config, { tag: schedule.shortId, organizationId });
|
await restic.copy(sourceRepository.config, mirror.repository.config, { tag: schedule.shortId, organizationId });
|
||||||
cache.delByPrefix(`snapshots:${mirror.repository.id}:`);
|
cache.delByPrefix(cacheKeys.repository.all(mirror.repository.id));
|
||||||
} finally {
|
} finally {
|
||||||
releaseSource();
|
releaseSource();
|
||||||
releaseMirror();
|
releaseMirror();
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ describe("repositories security", () => {
|
||||||
{ method: "POST", path: "/api/v1/repositories" },
|
{ method: "POST", path: "/api/v1/repositories" },
|
||||||
{ method: "GET", path: "/api/v1/repositories/rclone-remotes" },
|
{ method: "GET", path: "/api/v1/repositories/rclone-remotes" },
|
||||||
{ method: "GET", path: "/api/v1/repositories/test-repo" },
|
{ method: "GET", path: "/api/v1/repositories/test-repo" },
|
||||||
|
{ method: "GET", path: "/api/v1/repositories/test-repo/stats" },
|
||||||
{ method: "DELETE", path: "/api/v1/repositories/test-repo" },
|
{ method: "DELETE", path: "/api/v1/repositories/test-repo" },
|
||||||
{ method: "GET", path: "/api/v1/repositories/test-repo/snapshots" },
|
{ method: "GET", path: "/api/v1/repositories/test-repo/snapshots" },
|
||||||
{ method: "GET", path: "/api/v1/repositories/test-repo/snapshots/test-snapshot" },
|
{ method: "GET", path: "/api/v1/repositories/test-repo/snapshots/test-snapshot" },
|
||||||
|
|
@ -115,6 +116,17 @@ describe("repositories security", () => {
|
||||||
expect(body.message).toBe("Repository not found");
|
expect(body.message).toBe("Repository not found");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should return 404 for stats of non-existent repository", async () => {
|
||||||
|
const { token } = await createTestSession();
|
||||||
|
const res = await app.request("/api/v1/repositories/non-existent-repo/stats", {
|
||||||
|
headers: getAuthHeaders(token),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.message).toBe("Repository not found");
|
||||||
|
});
|
||||||
|
|
||||||
test("should return 400 for invalid payload on create", async () => {
|
test("should return 400 for invalid payload on create", async () => {
|
||||||
const { token } = await createTestSession();
|
const { token } = await createTestSession();
|
||||||
const res = await app.request("/api/v1/repositories", {
|
const res = await app.request("/api/v1/repositories", {
|
||||||
|
|
|
||||||
|
|
@ -244,7 +244,7 @@ describe("repositoriesService.dumpSnapshot", () => {
|
||||||
);
|
);
|
||||||
spyOn(restic, "snapshots").mockImplementation(snapshotsMock as typeof restic.snapshots);
|
spyOn(restic, "snapshots").mockImplementation(snapshotsMock as typeof restic.snapshots);
|
||||||
|
|
||||||
await expect(
|
expect(
|
||||||
withContext({ organizationId, userId: user.id }, () =>
|
withContext({ organizationId, userId: user.id }, () =>
|
||||||
repositoriesService.dumpSnapshot(shortId, "snapshot-no-kind", `${basePath}/documents/report.txt`),
|
repositoriesService.dumpSnapshot(shortId, "snapshot-no-kind", `${basePath}/documents/report.txt`),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
startDoctorDto,
|
startDoctorDto,
|
||||||
cancelDoctorDto,
|
cancelDoctorDto,
|
||||||
getRepositoryDto,
|
getRepositoryDto,
|
||||||
|
getRepositoryStatsDto,
|
||||||
getSnapshotDetailsDto,
|
getSnapshotDetailsDto,
|
||||||
refreshSnapshotsDto,
|
refreshSnapshotsDto,
|
||||||
listRcloneRemotesDto,
|
listRcloneRemotesDto,
|
||||||
|
|
@ -38,6 +39,7 @@ import {
|
||||||
type StartDoctorDto,
|
type StartDoctorDto,
|
||||||
type CancelDoctorDto,
|
type CancelDoctorDto,
|
||||||
type GetRepositoryDto,
|
type GetRepositoryDto,
|
||||||
|
type GetRepositoryStatsDto,
|
||||||
type GetSnapshotDetailsDto,
|
type GetSnapshotDetailsDto,
|
||||||
type RefreshSnapshotsDto,
|
type RefreshSnapshotsDto,
|
||||||
type ListRepositoriesDto,
|
type ListRepositoriesDto,
|
||||||
|
|
@ -89,6 +91,12 @@ export const repositoriesController = new Hono()
|
||||||
|
|
||||||
return c.json<GetRepositoryDto>(res.repository, 200);
|
return c.json<GetRepositoryDto>(res.repository, 200);
|
||||||
})
|
})
|
||||||
|
.get("/:shortId/stats", getRepositoryStatsDto, async (c) => {
|
||||||
|
const { shortId } = c.req.param();
|
||||||
|
const stats = await repositoriesService.getRepositoryStats(shortId);
|
||||||
|
|
||||||
|
return c.json<GetRepositoryStatsDto>(stats, 200);
|
||||||
|
})
|
||||||
.delete("/:shortId", deleteRepositoryDto, async (c) => {
|
.delete("/:shortId", deleteRepositoryDto, async (c) => {
|
||||||
const { shortId } = c.req.param();
|
const { shortId } = c.req.param();
|
||||||
await repositoriesService.deleteRepository(shortId);
|
await repositoriesService.deleteRepository(shortId);
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import {
|
||||||
repositoryConfigSchema,
|
repositoryConfigSchema,
|
||||||
doctorResultSchema,
|
doctorResultSchema,
|
||||||
} from "~/schemas/restic";
|
} from "~/schemas/restic";
|
||||||
import { resticSnapshotSummarySchema } from "~/schemas/restic-dto";
|
import { resticSnapshotSummarySchema, resticStatsSchema } from "~/schemas/restic-dto";
|
||||||
|
|
||||||
export const repositorySchema = type({
|
export const repositorySchema = type({
|
||||||
id: "string",
|
id: "string",
|
||||||
|
|
@ -109,6 +109,29 @@ export const getRepositoryDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get repository stats
|
||||||
|
*/
|
||||||
|
export const repositoryStatsSchema = resticStatsSchema;
|
||||||
|
export const getRepositoryStatsResponse = repositoryStatsSchema;
|
||||||
|
export type GetRepositoryStatsDto = typeof getRepositoryStatsResponse.infer;
|
||||||
|
|
||||||
|
export const getRepositoryStatsDto = describeRoute({
|
||||||
|
description: "Get repository storage and compression statistics",
|
||||||
|
tags: ["Repositories"],
|
||||||
|
operationId: "getRepositoryStats",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Repository statistics",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(getRepositoryStatsResponse),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a repository
|
* Delete a repository
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import { parseRetentionCategories, type RetentionCategory } from "~/server/utils
|
||||||
import { repoMutex } from "../../core/repository-mutex";
|
import { repoMutex } from "../../core/repository-mutex";
|
||||||
import { db } from "../../db/db";
|
import { db } from "../../db/db";
|
||||||
import { repositoriesTable } from "../../db/schema";
|
import { repositoriesTable } from "../../db/schema";
|
||||||
import { cache } from "../../utils/cache";
|
import { cache, cacheKeys } from "../../utils/cache";
|
||||||
import { cryptoUtils } from "../../utils/crypto";
|
import { cryptoUtils } from "../../utils/crypto";
|
||||||
import { toMessage } from "../../utils/errors";
|
import { toMessage } from "../../utils/errors";
|
||||||
import { generateShortId } from "../../utils/id";
|
import { generateShortId } from "../../utils/id";
|
||||||
|
|
@ -194,6 +194,30 @@ const getRepository = async (shortId: string) => {
|
||||||
return { repository };
|
return { repository };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getRepositoryStats = async (shortId: string) => {
|
||||||
|
const organizationId = getOrganizationId();
|
||||||
|
const repository = await findRepository(shortId);
|
||||||
|
|
||||||
|
if (!repository) {
|
||||||
|
throw new NotFoundError("Repository not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = cacheKeys.repository.stats(repository.id);
|
||||||
|
const cached = cache.get<Awaited<ReturnType<typeof restic.stats>>>(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const releaseLock = await repoMutex.acquireShared(repository.id, "stats");
|
||||||
|
try {
|
||||||
|
const stats = await restic.stats(repository.config, { organizationId });
|
||||||
|
cache.set(cacheKey, stats);
|
||||||
|
return stats;
|
||||||
|
} finally {
|
||||||
|
releaseLock();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const deleteRepository = async (shortId: string) => {
|
const deleteRepository = async (shortId: string) => {
|
||||||
const repository = await findRepository(shortId);
|
const repository = await findRepository(shortId);
|
||||||
|
|
||||||
|
|
@ -209,8 +233,7 @@ const deleteRepository = async (shortId: string) => {
|
||||||
and(eq(repositoriesTable.id, repository.id), eq(repositoriesTable.organizationId, repository.organizationId)),
|
and(eq(repositoriesTable.id, repository.id), eq(repositoriesTable.organizationId, repository.organizationId)),
|
||||||
);
|
);
|
||||||
|
|
||||||
cache.delByPrefix(`snapshots:${repository.id}:`);
|
cache.delByPrefix(cacheKeys.repository.all(repository.id));
|
||||||
cache.delByPrefix(`ls:${repository.id}:`);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -229,7 +252,7 @@ const listSnapshots = async (shortId: string, backupId?: string) => {
|
||||||
throw new NotFoundError("Repository not found");
|
throw new NotFoundError("Repository not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const cacheKey = `snapshots:${repository.id}:${backupId || "all"}`;
|
const cacheKey = cacheKeys.repository.snapshots(repository.id, backupId);
|
||||||
const cached = cache.get<Awaited<ReturnType<typeof restic.snapshots>>>(cacheKey);
|
const cached = cache.get<Awaited<ReturnType<typeof restic.snapshots>>>(cacheKey);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return cached;
|
return cached;
|
||||||
|
|
@ -269,7 +292,7 @@ const listSnapshotFiles = async (
|
||||||
const offset = options?.offset ?? 0;
|
const offset = options?.offset ?? 0;
|
||||||
const limit = options?.limit ?? 500;
|
const limit = options?.limit ?? 500;
|
||||||
|
|
||||||
const cacheKey = `ls:${repository.id}:${snapshotId}:${path || "root"}:${offset}:${limit}`;
|
const cacheKey = cacheKeys.repository.ls(repository.id, snapshotId, path, offset, limit);
|
||||||
type LsResult = {
|
type LsResult = {
|
||||||
snapshot: { id: string; short_id: string; time: string; hostname: string; paths: string[] } | null;
|
snapshot: { id: string; short_id: string; time: string; hostname: string; paths: string[] } | null;
|
||||||
nodes: { name: string; type: string; path: string; size?: number; mode?: number }[];
|
nodes: { name: string; type: string; path: string; size?: number; mode?: number }[];
|
||||||
|
|
@ -459,7 +482,7 @@ const getSnapshotDetails = async (shortId: string, snapshotId: string) => {
|
||||||
throw new NotFoundError("Repository not found");
|
throw new NotFoundError("Repository not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const cacheKey = `snapshots:${repository.id}:all`;
|
const cacheKey = cacheKeys.repository.snapshots(repository.id);
|
||||||
let snapshots = cache.get<Awaited<ReturnType<typeof restic.snapshots>>>(cacheKey);
|
let snapshots = cache.get<Awaited<ReturnType<typeof restic.snapshots>>>(cacheKey);
|
||||||
|
|
||||||
if (!snapshots) {
|
if (!snapshots) {
|
||||||
|
|
@ -589,8 +612,7 @@ const deleteSnapshot = async (shortId: string, snapshotId: string) => {
|
||||||
const releaseLock = await repoMutex.acquireExclusive(repository.id, `delete:${snapshotId}`);
|
const releaseLock = await repoMutex.acquireExclusive(repository.id, `delete:${snapshotId}`);
|
||||||
try {
|
try {
|
||||||
await restic.deleteSnapshot(repository.config, snapshotId, organizationId);
|
await restic.deleteSnapshot(repository.config, snapshotId, organizationId);
|
||||||
cache.delByPrefix(`snapshots:${repository.id}:`);
|
cache.delByPrefix(cacheKeys.repository.all(repository.id));
|
||||||
cache.delByPrefix(`ls:${repository.id}:${snapshotId}:`);
|
|
||||||
} finally {
|
} finally {
|
||||||
releaseLock();
|
releaseLock();
|
||||||
}
|
}
|
||||||
|
|
@ -607,10 +629,7 @@ const deleteSnapshots = async (shortId: string, snapshotIds: string[]) => {
|
||||||
const releaseLock = await repoMutex.acquireExclusive(repository.id, `delete:bulk`);
|
const releaseLock = await repoMutex.acquireExclusive(repository.id, `delete:bulk`);
|
||||||
try {
|
try {
|
||||||
await restic.deleteSnapshots(repository.config, snapshotIds, organizationId);
|
await restic.deleteSnapshots(repository.config, snapshotIds, organizationId);
|
||||||
cache.delByPrefix(`snapshots:${repository.id}:`);
|
cache.delByPrefix(cacheKeys.repository.all(repository.id));
|
||||||
for (const snapshotId of snapshotIds) {
|
|
||||||
cache.delByPrefix(`ls:${repository.id}:${snapshotId}:`);
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
releaseLock();
|
releaseLock();
|
||||||
}
|
}
|
||||||
|
|
@ -631,10 +650,7 @@ const tagSnapshots = async (
|
||||||
const releaseLock = await repoMutex.acquireExclusive(repository.id, `tag:bulk`);
|
const releaseLock = await repoMutex.acquireExclusive(repository.id, `tag:bulk`);
|
||||||
try {
|
try {
|
||||||
await restic.tagSnapshots(repository.config, snapshotIds, tags, organizationId);
|
await restic.tagSnapshots(repository.config, snapshotIds, tags, organizationId);
|
||||||
cache.delByPrefix(`snapshots:${repository.id}:`);
|
cache.delByPrefix(cacheKeys.repository.all(repository.id));
|
||||||
for (const snapshotId of snapshotIds) {
|
|
||||||
cache.delByPrefix(`ls:${repository.id}:${snapshotId}:`);
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
releaseLock();
|
releaseLock();
|
||||||
}
|
}
|
||||||
|
|
@ -648,13 +664,12 @@ const refreshSnapshots = async (shortId: string) => {
|
||||||
throw new NotFoundError("Repository not found");
|
throw new NotFoundError("Repository not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
cache.delByPrefix(`snapshots:${repository.id}:`);
|
cache.delByPrefix(cacheKeys.repository.all(repository.id));
|
||||||
cache.delByPrefix(`ls:${repository.id}:`);
|
|
||||||
|
|
||||||
const releaseLock = await repoMutex.acquireShared(repository.id, "refresh");
|
const releaseLock = await repoMutex.acquireShared(repository.id, "refresh");
|
||||||
try {
|
try {
|
||||||
const snapshots = await restic.snapshots(repository.config, { organizationId });
|
const snapshots = await restic.snapshots(repository.config, { organizationId });
|
||||||
const cacheKey = `snapshots:${repository.id}:all`;
|
const cacheKey = cacheKeys.repository.snapshots(repository.id);
|
||||||
cache.set(cacheKey, snapshots);
|
cache.set(cacheKey, snapshots);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -731,8 +746,7 @@ const updateRepository = async (shortId: string, updates: UpdateRepositoryBody)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (configChanged) {
|
if (configChanged) {
|
||||||
cache.delByPrefix(`snapshots:${existing.id}:`);
|
cache.delByPrefix(cacheKeys.repository.all(existing.id));
|
||||||
cache.delByPrefix(`ls:${existing.id}:`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { repository: updated };
|
return { repository: updated };
|
||||||
|
|
@ -791,7 +805,7 @@ const getRetentionCategories = async (repositoryId: string, scheduleId?: string)
|
||||||
return new Map<string, RetentionCategory[]>();
|
return new Map<string, RetentionCategory[]>();
|
||||||
}
|
}
|
||||||
|
|
||||||
const cacheKey = `retention:${scheduleId}`;
|
const cacheKey = cacheKeys.repository.retention(repositoryId, scheduleId);
|
||||||
const cached = cache.get<Record<string, RetentionCategory[]>>(cacheKey);
|
const cached = cache.get<Record<string, RetentionCategory[]>>(cacheKey);
|
||||||
|
|
||||||
if (cached && Object.keys(cached).length > 0) {
|
if (cached && Object.keys(cached).length > 0) {
|
||||||
|
|
@ -834,6 +848,7 @@ export const repositoriesService = {
|
||||||
listRepositories,
|
listRepositories,
|
||||||
createRepository,
|
createRepository,
|
||||||
getRepository,
|
getRepository,
|
||||||
|
getRepositoryStats,
|
||||||
deleteRepository,
|
deleteRepository,
|
||||||
updateRepository,
|
updateRepository,
|
||||||
listSnapshots,
|
listSnapshots,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { getCapabilities } from "../../core/capabilities";
|
||||||
import { config } from "../../core/config";
|
import { config } from "../../core/config";
|
||||||
import type { UpdateInfoDto } from "./system.dto";
|
import type { UpdateInfoDto } from "./system.dto";
|
||||||
import semver from "semver";
|
import semver from "semver";
|
||||||
import { cache } from "../../utils/cache";
|
import { cache, cacheKeys } from "../../utils/cache";
|
||||||
import { logger } from "~/server/utils/logger";
|
import { logger } from "~/server/utils/logger";
|
||||||
import { db } from "../../db/db";
|
import { db } from "../../db/db";
|
||||||
import { appMetadataTable } from "../../db/schema";
|
import { appMetadataTable } from "../../db/schema";
|
||||||
|
|
@ -24,7 +24,7 @@ interface GitHubRelease {
|
||||||
}
|
}
|
||||||
|
|
||||||
const getUpdates = async (): Promise<UpdateInfoDto> => {
|
const getUpdates = async (): Promise<UpdateInfoDto> => {
|
||||||
const CACHE_KEY = `system:updates:${config.appVersion}`;
|
const CACHE_KEY = cacheKeys.system.githubReleases(config.appVersion);
|
||||||
|
|
||||||
const cached = cache.get<UpdateInfoDto>(CACHE_KEY);
|
const cached = cache.get<UpdateInfoDto>(CACHE_KEY);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
|
|
|
||||||
|
|
@ -54,14 +54,20 @@ export const createCache = (options: CacheOptions = {}) => {
|
||||||
stmt.run(key);
|
stmt.run(key);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const escapeLikePattern = (pattern: string): string => {
|
||||||
|
return pattern.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
|
||||||
|
};
|
||||||
|
|
||||||
const delByPrefix = (prefix: string) => {
|
const delByPrefix = (prefix: string) => {
|
||||||
const stmt = db.prepare("DELETE FROM cache WHERE key LIKE ?");
|
const escapedPrefix = escapeLikePattern(prefix);
|
||||||
stmt.run(`${prefix}%`);
|
const stmt = db.prepare("DELETE FROM cache WHERE key LIKE ? ESCAPE '\\'");
|
||||||
|
stmt.run(`${escapedPrefix}%`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getByPrefix = <T>(prefix: string): { key: string; value: T }[] => {
|
const getByPrefix = <T>(prefix: string): { key: string; value: T }[] => {
|
||||||
const stmt = db.prepare("SELECT key, value, expiration FROM cache WHERE key LIKE ?");
|
const escapedPrefix = escapeLikePattern(prefix);
|
||||||
const rows = stmt.all(`${prefix}%`) as { key: string; value: string; expiration: number }[];
|
const stmt = db.prepare("SELECT key, value, expiration FROM cache WHERE key LIKE ? ESCAPE '\\'");
|
||||||
|
const rows = stmt.all(`${escapedPrefix}%`) as { key: string; value: string; expiration: number }[];
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const results: { key: string; value: T }[] = [];
|
const results: { key: string; value: T }[] = [];
|
||||||
|
|
@ -99,4 +105,18 @@ export const createCache = (options: CacheOptions = {}) => {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const cacheKeys = {
|
||||||
|
repository: {
|
||||||
|
all: (repositoryId: string) => `repo:${repositoryId}:`,
|
||||||
|
stats: (repositoryId: string) => `repo:${repositoryId}:stats`,
|
||||||
|
snapshots: (repositoryId: string, backupId = "all") => `repo:${repositoryId}:snapshots:${backupId}`,
|
||||||
|
ls: (repositoryId: string, snapshotId: string, path = "root", offset: number, limit: number) =>
|
||||||
|
`repo:${repositoryId}:ls:${snapshotId}:${path}:${offset}:${limit}`,
|
||||||
|
retention: (repositoryId: string, scheduleId: string) => `repo:${repositoryId}:retention:${scheduleId}`,
|
||||||
|
},
|
||||||
|
system: {
|
||||||
|
githubReleases: (version: string) => `system:updates:${version}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const cache = createCache();
|
export const cache = createCache();
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
resticBackupProgressSchema,
|
resticBackupProgressSchema,
|
||||||
resticRestoreOutputSchema,
|
resticRestoreOutputSchema,
|
||||||
resticSnapshotSummarySchema,
|
resticSnapshotSummarySchema,
|
||||||
|
resticStatsSchema,
|
||||||
} from "~/schemas/restic-dto";
|
} from "~/schemas/restic-dto";
|
||||||
import { config as appConfig } from "../core/config";
|
import { config as appConfig } from "../core/config";
|
||||||
import { DEFAULT_EXCLUDES, RESTIC_CACHE_DIR, RESTIC_PASS_FILE } from "../core/constants";
|
import { DEFAULT_EXCLUDES, RESTIC_CACHE_DIR, RESTIC_PASS_FILE } from "../core/constants";
|
||||||
|
|
@ -691,6 +692,32 @@ const snapshots = async (config: RepositoryConfig, options: { tags?: string[]; o
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const stats = async (config: RepositoryConfig, options: { organizationId: string }) => {
|
||||||
|
const repoUrl = buildRepoUrl(config);
|
||||||
|
const env = await buildEnv(config, options.organizationId);
|
||||||
|
|
||||||
|
const args = ["--repo", repoUrl, "stats", "--mode", "raw-data"];
|
||||||
|
addCommonArgs(args, env, config);
|
||||||
|
|
||||||
|
const res = await exec({ command: "restic", args, env });
|
||||||
|
await cleanupTemporaryKeys(env);
|
||||||
|
|
||||||
|
if (res.exitCode !== 0) {
|
||||||
|
logger.error(`Restic stats retrieval failed: ${res.stderr}`);
|
||||||
|
throw new ResticError(res.exitCode, res.stderr);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedJson = safeJsonParse<unknown>(res.stdout);
|
||||||
|
const result = resticStatsSchema(parsedJson);
|
||||||
|
|
||||||
|
if (result instanceof type.errors) {
|
||||||
|
logger.error(`Restic stats output validation failed: ${result.summary}`);
|
||||||
|
throw new Error(`Restic stats output validation failed: ${result.summary}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
export type ResticForgetResponse = ForgetGroup[];
|
export type ResticForgetResponse = ForgetGroup[];
|
||||||
|
|
||||||
export interface ForgetGroup {
|
export interface ForgetGroup {
|
||||||
|
|
@ -1230,6 +1257,7 @@ export const restic = {
|
||||||
restore,
|
restore,
|
||||||
dump,
|
dump,
|
||||||
snapshots,
|
snapshots,
|
||||||
|
stats,
|
||||||
forget,
|
forget,
|
||||||
deleteSnapshot,
|
deleteSnapshot,
|
||||||
deleteSnapshots,
|
deleteSnapshots,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue