Compare commits

...

1 commit

Author SHA1 Message Date
Nicolas Meienberger
7ae5f73783 fix: run repo stats in background
Closes #614
2026-03-05 18:57:28 +01:00
17 changed files with 2588 additions and 102 deletions

View file

@ -4,8 +4,8 @@
import { type DefaultError, type InfiniteData, infiniteQueryOptions, queryOptions, type UseMutationOptions } from '@tanstack/react-query'; import { type DefaultError, type InfiniteData, infiniteQueryOptions, queryOptions, type UseMutationOptions } from '@tanstack/react-query';
import { client } from '../client.gen'; import { client } from '../client.gen';
import { browseFilesystem, cancelDoctor, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteSnapshots, deleteSsoInvitation, deleteSsoProvider, deleteUserAccount, deleteVolume, downloadResticPassword, dumpSnapshot, getAdminUsers, getBackupProgress, getBackupSchedule, getBackupScheduleForVolume, getDevPanel, getMirrorCompatibility, getNotificationDestination, getOrgMembers, getPublicSsoProviders, getRegistrationStatus, getRepository, getRepositoryStats, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getSsoSettings, getStatus, getSystemInfo, getUpdates, getUserDeletionImpact, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, mountVolume, type Options, refreshSnapshots, removeOrgMember, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, setRegistrationStatus, startDoctor, stopBackup, tagSnapshots, testConnection, testNotificationDestination, unlockRepository, unmountVolume, updateBackupSchedule, updateMemberRole, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateSsoProviderAutoLinking, updateVolume } from '../sdk.gen'; import { browseFilesystem, cancelDoctor, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteSnapshots, deleteSsoInvitation, deleteSsoProvider, deleteUserAccount, deleteVolume, downloadResticPassword, dumpSnapshot, getAdminUsers, getBackupProgress, getBackupSchedule, getBackupScheduleForVolume, getDevPanel, getMirrorCompatibility, getNotificationDestination, getOrgMembers, getPublicSsoProviders, getRegistrationStatus, getRepository, getRepositoryStats, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getSsoSettings, getStatus, getSystemInfo, getUpdates, getUserDeletionImpact, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, mountVolume, type Options, refreshRepositoryStats, refreshSnapshots, removeOrgMember, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, setRegistrationStatus, startDoctor, stopBackup, tagSnapshots, testConnection, testNotificationDestination, unlockRepository, unmountVolume, updateBackupSchedule, updateMemberRole, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateSsoProviderAutoLinking, updateVolume } from '../sdk.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponse, CancelDoctorData, CancelDoctorResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteSnapshotsData, DeleteSnapshotsResponse, DeleteSsoInvitationData, DeleteSsoProviderData, DeleteUserAccountData, DeleteVolumeData, DeleteVolumeResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, DumpSnapshotData, DumpSnapshotResponse, GetAdminUsersData, GetAdminUsersResponse, GetBackupProgressData, GetBackupProgressResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetDevPanelData, GetDevPanelResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetOrgMembersData, GetOrgMembersResponse, GetPublicSsoProvidersData, GetPublicSsoProvidersResponse, GetRegistrationStatusData, GetRegistrationStatusResponse, GetRepositoryData, GetRepositoryResponse, GetRepositoryStatsData, GetRepositoryStatsResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetSsoSettingsData, GetSsoSettingsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetUpdatesData, GetUpdatesResponse, GetUserDeletionImpactData, GetUserDeletionImpactResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, MountVolumeData, MountVolumeResponse, RefreshSnapshotsData, RefreshSnapshotsResponse, RemoveOrgMemberData, ReorderBackupSchedulesData, ReorderBackupSchedulesResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, SetRegistrationStatusData, SetRegistrationStatusResponse, StartDoctorData, StartDoctorResponse, StopBackupData, StopBackupResponse, TagSnapshotsData, TagSnapshotsResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnlockRepositoryData, UnlockRepositoryResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateMemberRoleData, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateSsoProviderAutoLinkingData, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen'; import type { BrowseFilesystemData, BrowseFilesystemResponse, CancelDoctorData, CancelDoctorResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteSnapshotsData, DeleteSnapshotsResponse, DeleteSsoInvitationData, DeleteSsoProviderData, DeleteUserAccountData, DeleteVolumeData, DeleteVolumeResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, DumpSnapshotData, DumpSnapshotResponse, GetAdminUsersData, GetAdminUsersResponse, GetBackupProgressData, GetBackupProgressResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetDevPanelData, GetDevPanelResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetOrgMembersData, GetOrgMembersResponse, GetPublicSsoProvidersData, GetPublicSsoProvidersResponse, GetRegistrationStatusData, GetRegistrationStatusResponse, GetRepositoryData, GetRepositoryResponse, GetRepositoryStatsData, GetRepositoryStatsResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetSsoSettingsData, GetSsoSettingsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetUpdatesData, GetUpdatesResponse, GetUserDeletionImpactData, GetUserDeletionImpactResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, MountVolumeData, MountVolumeResponse, RefreshRepositoryStatsData, RefreshRepositoryStatsResponse, RefreshSnapshotsData, RefreshSnapshotsResponse, RemoveOrgMemberData, ReorderBackupSchedulesData, ReorderBackupSchedulesResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, SetRegistrationStatusData, SetRegistrationStatusResponse, StartDoctorData, StartDoctorResponse, StopBackupData, StopBackupResponse, TagSnapshotsData, TagSnapshotsResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnlockRepositoryData, UnlockRepositoryResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateMemberRoleData, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateSsoProviderAutoLinkingData, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen';
export type QueryKey<TOptions extends Options> = [ export type QueryKey<TOptions extends Options> = [
Pick<TOptions, 'baseUrl' | 'body' | 'headers' | 'path' | 'query'> & { Pick<TOptions, 'baseUrl' | 'body' | 'headers' | 'path' | 'query'> & {
@ -620,6 +620,23 @@ export const getRepositoryStatsOptions = (options: Options<GetRepositoryStatsDat
queryKey: getRepositoryStatsQueryKey(options) queryKey: getRepositoryStatsQueryKey(options)
}); });
/**
* Refresh repository storage and compression statistics
*/
export const refreshRepositoryStatsMutation = (options?: Partial<Options<RefreshRepositoryStatsData>>): UseMutationOptions<RefreshRepositoryStatsResponse, DefaultError, Options<RefreshRepositoryStatsData>> => {
const mutationOptions: UseMutationOptions<RefreshRepositoryStatsResponse, DefaultError, Options<RefreshRepositoryStatsData>> = {
mutationFn: async (fnOptions) => {
const { data } = await refreshRepositoryStats({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
/** /**
* Delete multiple snapshots from a repository * Delete multiple snapshots from a repository
*/ */

View file

@ -52,6 +52,7 @@ export {
listVolumes, listVolumes,
mountVolume, mountVolume,
type Options, type Options,
refreshRepositoryStats,
refreshSnapshots, refreshSnapshots,
removeOrgMember, removeOrgMember,
reorderBackupSchedules, reorderBackupSchedules,
@ -230,6 +231,9 @@ export type {
MountVolumeData, MountVolumeData,
MountVolumeResponse, MountVolumeResponse,
MountVolumeResponses, MountVolumeResponses,
RefreshRepositoryStatsData,
RefreshRepositoryStatsResponse,
RefreshRepositoryStatsResponses,
RefreshSnapshotsData, RefreshSnapshotsData,
RefreshSnapshotsResponse, RefreshSnapshotsResponse,
RefreshSnapshotsResponses, RefreshSnapshotsResponses,

View file

@ -3,7 +3,7 @@
import type { Client, Options as Options2, TDataShape } from './client'; import type { Client, Options as Options2, TDataShape } from './client';
import { client } from './client.gen'; import { client } from './client.gen';
import type { BrowseFilesystemData, BrowseFilesystemResponses, CancelDoctorData, CancelDoctorErrors, CancelDoctorResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteSnapshotsData, DeleteSnapshotsResponses, DeleteSsoInvitationData, DeleteSsoInvitationErrors, DeleteSsoInvitationResponses, DeleteSsoProviderData, DeleteSsoProviderErrors, DeleteSsoProviderResponses, DeleteUserAccountData, DeleteUserAccountErrors, DeleteUserAccountResponses, DeleteVolumeData, DeleteVolumeResponses, DevPanelExecData, DevPanelExecErrors, DevPanelExecResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, DumpSnapshotData, DumpSnapshotResponses, GetAdminUsersData, GetAdminUsersResponses, GetBackupProgressData, GetBackupProgressResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetDevPanelData, GetDevPanelResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetOrgMembersData, GetOrgMembersResponses, GetPublicSsoProvidersData, GetPublicSsoProvidersResponses, GetRegistrationStatusData, GetRegistrationStatusResponses, GetRepositoryData, GetRepositoryResponses, GetRepositoryStatsData, GetRepositoryStatsResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetSsoSettingsData, GetSsoSettingsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetUpdatesData, GetUpdatesResponses, GetUserDeletionImpactData, GetUserDeletionImpactResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, MountVolumeData, MountVolumeResponses, RefreshSnapshotsData, RefreshSnapshotsResponses, RemoveOrgMemberData, RemoveOrgMemberErrors, RemoveOrgMemberResponses, ReorderBackupSchedulesData, ReorderBackupSchedulesResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, SetRegistrationStatusData, SetRegistrationStatusResponses, StartDoctorData, StartDoctorErrors, StartDoctorResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TagSnapshotsData, TagSnapshotsResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnlockRepositoryData, UnlockRepositoryResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateMemberRoleData, UpdateMemberRoleErrors, UpdateMemberRoleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateSsoProviderAutoLinkingData, UpdateSsoProviderAutoLinkingErrors, UpdateSsoProviderAutoLinkingResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen'; import type { BrowseFilesystemData, BrowseFilesystemResponses, CancelDoctorData, CancelDoctorErrors, CancelDoctorResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteSnapshotsData, DeleteSnapshotsResponses, DeleteSsoInvitationData, DeleteSsoInvitationErrors, DeleteSsoInvitationResponses, DeleteSsoProviderData, DeleteSsoProviderErrors, DeleteSsoProviderResponses, DeleteUserAccountData, DeleteUserAccountErrors, DeleteUserAccountResponses, DeleteVolumeData, DeleteVolumeResponses, DevPanelExecData, DevPanelExecErrors, DevPanelExecResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, DumpSnapshotData, DumpSnapshotResponses, GetAdminUsersData, GetAdminUsersResponses, GetBackupProgressData, GetBackupProgressResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetDevPanelData, GetDevPanelResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetOrgMembersData, GetOrgMembersResponses, GetPublicSsoProvidersData, GetPublicSsoProvidersResponses, GetRegistrationStatusData, GetRegistrationStatusResponses, GetRepositoryData, GetRepositoryResponses, GetRepositoryStatsData, GetRepositoryStatsResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetSsoSettingsData, GetSsoSettingsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetUpdatesData, GetUpdatesResponses, GetUserDeletionImpactData, GetUserDeletionImpactResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, MountVolumeData, MountVolumeResponses, RefreshRepositoryStatsData, RefreshRepositoryStatsResponses, RefreshSnapshotsData, RefreshSnapshotsResponses, RemoveOrgMemberData, RemoveOrgMemberErrors, RemoveOrgMemberResponses, ReorderBackupSchedulesData, ReorderBackupSchedulesResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, SetRegistrationStatusData, SetRegistrationStatusResponses, StartDoctorData, StartDoctorErrors, StartDoctorResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TagSnapshotsData, TagSnapshotsResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnlockRepositoryData, UnlockRepositoryResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateMemberRoleData, UpdateMemberRoleErrors, UpdateMemberRoleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateSsoProviderAutoLinkingData, UpdateSsoProviderAutoLinkingErrors, UpdateSsoProviderAutoLinkingResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & { export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
/** /**
@ -218,6 +218,11 @@ export const updateRepository = <ThrowOnError extends boolean = false>(options:
*/ */
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 }); 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 });
/**
* Refresh repository storage and compression statistics
*/
export const refreshRepositoryStats = <ThrowOnError extends boolean = false>(options: Options<RefreshRepositoryStatsData, ThrowOnError>) => (options.client ?? client).post<RefreshRepositoryStatsResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{shortId}/stats/refresh', ...options });
/** /**
* Delete multiple snapshots from a repository * Delete multiple snapshots from a repository
*/ */

View file

@ -1985,6 +1985,31 @@ export type GetRepositoryStatsResponses = {
export type GetRepositoryStatsResponse = GetRepositoryStatsResponses[keyof GetRepositoryStatsResponses]; export type GetRepositoryStatsResponse = GetRepositoryStatsResponses[keyof GetRepositoryStatsResponses];
export type RefreshRepositoryStatsData = {
body?: never;
path: {
shortId: string;
};
query?: never;
url: '/api/v1/repositories/{shortId}/stats/refresh';
};
export type RefreshRepositoryStatsResponses = {
/**
* Refreshed 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 RefreshRepositoryStatsResponse = RefreshRepositoryStatsResponses[keyof RefreshRepositoryStatsResponses];
export type DeleteSnapshotsData = { export type DeleteSnapshotsData = {
body: { body: {
snapshotIds: Array<string>; snapshotIds: Array<string>;

View file

@ -1,9 +1,16 @@
import { useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { Archive } from "lucide-react"; import { Archive, RefreshCw } from "lucide-react";
import { getRepositoryStatsOptions } from "~/client/api-client/@tanstack/react-query.gen"; import {
import { ByteSize } from "~/client/components/bytes-size"; getRepositoryStatsOptions,
import { Card, CardContent, CardTitle } from "~/client/components/ui/card"; refreshRepositoryStatsMutation,
} from "~/client/api-client/@tanstack/react-query.gen";
import type { GetRepositoryStatsResponse } from "~/client/api-client/types.gen"; import type { GetRepositoryStatsResponse } from "~/client/api-client/types.gen";
import { ByteSize } from "~/client/components/bytes-size";
import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardTitle } from "~/client/components/ui/card";
import { parseError } from "~/client/lib/errors";
import { cn } from "~/client/lib/utils";
import { toast } from "sonner";
type Props = { type Props = {
repositoryShortId: string; repositoryShortId: string;
@ -19,6 +26,18 @@ const toSafeNumber = (value: number | undefined) => {
}; };
export function CompressionStatsChart({ repositoryShortId, initialStats }: Props) { export function CompressionStatsChart({ repositoryShortId, initialStats }: Props) {
const refreshStats = useMutation({
...refreshRepositoryStatsMutation(),
onSuccess: () => {
toast.success("Repository stats refreshed");
},
onError: (mutationError) => {
toast.error("Failed to refresh repository stats", {
description: parseError(mutationError)?.message,
});
},
});
const { const {
data: stats, data: stats,
isPending, isPending,
@ -43,89 +62,84 @@ export function CompressionStatsChart({ repositoryShortId, initialStats }: Props
const hasStats = !!stats && (storedSize > 0 || uncompressedSize > 0 || snapshotsCount > 0); const hasStats = !!stats && (storedSize > 0 || uncompressedSize > 0 || snapshotsCount > 0);
if (isPending) {
return (
<Card className="p-6">
<p className="text-sm text-muted-foreground">Loading compression statistics...</p>
</Card>
);
}
if (error) {
return (
<Card className="p-6 border-red-500/20 bg-red-500/5">
<p className="text-sm font-medium text-red-500">Failed to load compression statistics</p>
<p className="mt-2 text-sm text-red-500/80 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 ( return (
<Card className="flex flex-col px-6 py-6"> <Card className="flex flex-col px-6 py-6">
<div className="pb-4"> <div className="flex items-start justify-between gap-3 pb-4">
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2 w-full">
<Archive className="h-5 w-5 text-muted-foreground" /> <div className="flex items-center gap-2 flex-1">
Compression Statistics <Archive className="h-5 w-5 text-muted-foreground" />
Compression Statistics
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => refreshStats.mutate({ path: { shortId: repositoryShortId } })}
disabled={refreshStats.isPending}
title="Refresh statistics"
>
<RefreshCw className={refreshStats.isPending ? "h-4 w-4 animate-spin" : "h-4 w-4"} />
</Button>
</CardTitle> </CardTitle>
</div> </div>
<div> <p className={cn("text-sm text-muted-foreground", { hidden: !isPending })}>Loading compression statistics...</p>
<CardContent className="grid grid-cols-2 lg:grid-cols-3 gap-y-6 gap-x-4 px-0"> <div className={cn("space-y-2", { hidden: !error || isPending })}>
<div className="flex flex-col gap-1.5"> <p className="text-sm font-medium text-destructive">Failed to load compression statistics</p>
<div className="text-sm font-medium text-muted-foreground">Stored Size</div> <p className="text-sm text-muted-foreground wrap-break-word">{error?.message}</p>
<div className="flex items-baseline gap-2">
<ByteSize base={1024} bytes={storedSize} className="text-xl font-semibold text-foreground font-mono" />
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="text-sm font-medium text-muted-foreground">Uncompressed</div>
<div className="flex items-baseline gap-2">
<ByteSize
base={1024}
bytes={uncompressedSize}
className="text-xl font-semibold text-foreground font-mono"
/>
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="text-sm font-medium text-muted-foreground">Space Saved</div>
<div className="flex items-baseline gap-2">
<span className="text-xl font-semibold text-foreground font-mono">{spaceSavingPercent.toFixed(1)}%</span>
<ByteSize base={1024} bytes={savedSize} className="text-xs text-muted-foreground font-mono" />
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="text-sm font-medium text-muted-foreground">Ratio</div>
<div className="flex items-baseline gap-2">
<span className="text-xl font-semibold text-foreground font-mono">
{compressionRatio > 0 ? `${compressionRatio.toFixed(2)}x` : "—"}
</span>
</div>
</div>
<div className="flex flex-col gap-1.5 lg:col-span-2">
<div className="text-sm font-medium text-muted-foreground">Snapshots</div>
<div className="flex items-baseline gap-2">
<span className="text-xl font-semibold text-foreground font-mono">{snapshotsCount.toLocaleString()}</span>
<span className="text-xs text-muted-foreground font-mono">
{compressionProgressPercent.toFixed(1)}% compressed
</span>
</div>
</div>
</CardContent>
</div> </div>
<p className={cn("text-sm text-muted-foreground", { hidden: isPending || !!error || hasStats })}>
Stats will be populated after your first backup. You can also refresh them manually.
</p>
<CardContent
className={cn("grid grid-cols-2 lg:grid-cols-3 gap-y-6 gap-x-4 px-0", {
hidden: isPending || !!error || !hasStats,
})}
>
<div className="flex flex-col gap-1.5">
<div className="text-sm font-medium text-muted-foreground">Stored Size</div>
<div className="flex items-baseline gap-2">
<ByteSize base={1024} bytes={storedSize} className="text-xl font-semibold text-foreground font-mono" />
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="text-sm font-medium text-muted-foreground">Uncompressed</div>
<div className="flex items-baseline gap-2">
<ByteSize
base={1024}
bytes={uncompressedSize}
className="text-xl font-semibold text-foreground font-mono"
/>
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="text-sm font-medium text-muted-foreground">Space Saved</div>
<div className="flex items-baseline gap-2">
<span className="text-xl font-semibold text-foreground font-mono">{spaceSavingPercent.toFixed(1)}%</span>
<ByteSize base={1024} bytes={savedSize} className="text-xs text-muted-foreground font-mono" />
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="text-sm font-medium text-muted-foreground">Ratio</div>
<div className="flex items-baseline gap-2">
<span className="text-xl font-semibold text-foreground font-mono">
{compressionRatio > 0 ? `${compressionRatio.toFixed(2)}x` : "-"}
</span>
</div>
</div>
<div className="flex flex-col gap-1.5 lg:col-span-2">
<div className="text-sm font-medium text-muted-foreground">Snapshots</div>
<div className="flex items-baseline gap-2">
<span className="text-xl font-semibold text-foreground font-mono">{snapshotsCount.toLocaleString()}</span>
<span className="text-xs text-muted-foreground font-mono">
{compressionProgressPercent.toFixed(1)}% compressed
</span>
</div>
</div>
</CardContent>
</Card> </Card>
); );
} }

View file

@ -0,0 +1,2 @@
ALTER TABLE `repositories_table` ADD `stats` text;--> statement-breakpoint
ALTER TABLE `repositories_table` ADD `stats_updated_at` integer;

File diff suppressed because it is too large Load diff

View file

@ -8,6 +8,7 @@ import type {
BandwidthUnit, BandwidthUnit,
DoctorResult, DoctorResult,
} from "~/schemas/restic"; } from "~/schemas/restic";
import type { ResticStatsDto } from "~/schemas/restic-dto";
import type { BackendStatus, BackendType, volumeConfigSchema } from "~/schemas/volumes"; import type { BackendStatus, BackendType, volumeConfigSchema } from "~/schemas/volumes";
import type { NotificationType, notificationConfigSchema } from "~/schemas/notifications"; import type { NotificationType, notificationConfigSchema } from "~/schemas/notifications";
import type { ShortId } from "~/server/utils/branded"; import type { ShortId } from "~/server/utils/branded";
@ -241,6 +242,8 @@ export const repositoriesTable = sqliteTable("repositories_table", {
lastChecked: int("last_checked", { mode: "number" }), lastChecked: int("last_checked", { mode: "number" }),
lastError: text("last_error"), lastError: text("last_error"),
doctorResult: text("doctor_result", { mode: "json" }).$type<DoctorResult>(), doctorResult: text("doctor_result", { mode: "json" }).$type<DoctorResult>(),
stats: text("stats", { mode: "json" }).$type<ResticStatsDto | null>(),
statsUpdatedAt: int("stats_updated_at", { mode: "number" }),
uploadLimitEnabled: int("upload_limit_enabled", { mode: "boolean" }).notNull().default(false), uploadLimitEnabled: int("upload_limit_enabled", { mode: "boolean" }).notNull().default(false),
uploadLimitValue: real("upload_limit_value").notNull().default(1), uploadLimitValue: real("upload_limit_value").notNull().default(1),
uploadLimitUnit: text("upload_limit_unit").$type<BandwidthUnit>().notNull().default("Mbps"), uploadLimitUnit: text("upload_limit_unit").$type<BandwidthUnit>().notNull().default("Mbps"),

View file

@ -14,21 +14,34 @@ import { restic } from "~/server/utils/restic";
import { NotFoundError, BadRequestError } from "http-errors-enhanced"; import { NotFoundError, BadRequestError } from "http-errors-enhanced";
import { scheduleQueries } from "../backups.queries"; import { scheduleQueries } from "../backups.queries";
import { fromAny } from "@total-typescript/shoehorn"; import { fromAny } from "@total-typescript/shoehorn";
import { repositoriesService } from "~/server/modules/repositories/repositories.service";
const setup = () => { const setup = () => {
const resticBackupMock = mock(() => Promise.resolve({ exitCode: 0, summary: generateBackupOutput(), error: "" })); const resticBackupMock = mock(() => Promise.resolve({ exitCode: 0, summary: generateBackupOutput(), error: "" }));
const resticForgetMock = mock(() => Promise.resolve({ success: true, data: null })); const resticForgetMock = mock(() => Promise.resolve({ success: true, data: null }));
const resticCopyMock = mock(() => Promise.resolve({ success: true, output: "" })); const resticCopyMock = mock(() => Promise.resolve({ success: true, output: "" }));
const refreshStatsMock = mock(() =>
Promise.resolve({
total_size: 0,
total_uncompressed_size: 0,
compression_ratio: 0,
compression_progress: 0,
compression_space_saving: 0,
snapshots_count: 0,
}),
);
spyOn(spawnModule, "safeSpawn").mockImplementation(resticBackupMock); spyOn(spawnModule, "safeSpawn").mockImplementation(resticBackupMock);
spyOn(restic, "forget").mockImplementation(resticForgetMock); spyOn(restic, "forget").mockImplementation(resticForgetMock);
spyOn(restic, "copy").mockImplementation(resticCopyMock); spyOn(restic, "copy").mockImplementation(resticCopyMock);
spyOn(repositoriesService, "refreshRepositoryStats").mockImplementation(refreshStatsMock);
spyOn(context, "getOrganizationId").mockReturnValue(TEST_ORG_ID); spyOn(context, "getOrganizationId").mockReturnValue(TEST_ORG_ID);
return { return {
resticBackupMock, resticBackupMock,
resticForgetMock, resticForgetMock,
resticCopyMock, resticCopyMock,
refreshStatsMock,
}; };
}; };

View file

@ -13,14 +13,27 @@ import { backupScheduleMirrorsTable, repositoriesTable, volumesTable } from "~/s
import { TEST_ORG_ID } from "~/test/helpers/organization"; import { TEST_ORG_ID } from "~/test/helpers/organization";
import * as context from "~/server/core/request-context"; import * as context from "~/server/core/request-context";
import { backupsExecutionService } from "../backups.execution"; import { backupsExecutionService } from "../backups.execution";
import { repositoriesService } from "~/server/modules/repositories/repositories.service";
const setup = () => { const setup = () => {
const resticBackupMock = mock(() => Promise.resolve({ exitCode: 0, summary: "", error: "" })); const resticBackupMock = mock(() => Promise.resolve({ exitCode: 0, summary: "", error: "" }));
const refreshStatsMock = mock(() =>
Promise.resolve({
total_size: 0,
total_uncompressed_size: 0,
compression_ratio: 0,
compression_progress: 0,
compression_space_saving: 0,
snapshots_count: 0,
}),
);
spyOn(spawnModule, "safeSpawn").mockImplementation(resticBackupMock); spyOn(spawnModule, "safeSpawn").mockImplementation(resticBackupMock);
spyOn(repositoriesService, "refreshRepositoryStats").mockImplementation(refreshStatsMock);
spyOn(context, "getOrganizationId").mockReturnValue(TEST_ORG_ID); spyOn(context, "getOrganizationId").mockReturnValue(TEST_ORG_ID);
return { return {
resticBackupMock, resticBackupMock,
refreshStatsMock,
}; };
}; };

View file

@ -8,6 +8,7 @@ import { toMessage } from "../../utils/errors";
import { serverEvents } from "../../core/events"; import { serverEvents } from "../../core/events";
import { notificationsService } from "../notifications/notifications.service"; import { notificationsService } from "../notifications/notifications.service";
import { repoMutex } from "../../core/repository-mutex"; import { repoMutex } from "../../core/repository-mutex";
import { repositoriesService } from "../repositories/repositories.service";
import { getOrganizationId } from "~/server/core/request-context"; import { getOrganizationId } from "~/server/core/request-context";
import { scheduleQueries, mirrorQueries, repositoryQueries } from "./backups.queries"; import { scheduleQueries, mirrorQueries, repositoryQueries } from "./backups.queries";
import { calculateNextRun, createBackupOptions } from "./backup.helpers"; import { calculateNextRun, createBackupOptions } from "./backup.helpers";
@ -163,6 +164,12 @@ const finalizeSuccessfulBackup = async (
cache.delByPrefix(cacheKeys.repository.all(ctx.repository.id)); cache.delByPrefix(cacheKeys.repository.all(ctx.repository.id));
void repositoriesService.refreshRepositoryStats(ctx.repository.shortId).catch((error) => {
logger.error(
`Background repository stats refresh failed for schedule ${scheduleId} (${ctx.repository.shortId}): ${toMessage(error)}`,
);
});
const nextBackupAt = calculateNextRun(ctx.schedule.cronExpression); const nextBackupAt = calculateNextRun(ctx.schedule.cronExpression);
await scheduleQueries.updateStatus(scheduleId, ctx.organizationId, { await scheduleQueries.updateStatus(scheduleId, ctx.organizationId, {
lastBackupAt: Date.now(), lastBackupAt: Date.now(),

View file

@ -73,6 +73,7 @@ describe("repositories security", () => {
{ 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: "GET", path: "/api/v1/repositories/test-repo/stats" },
{ method: "POST", path: "/api/v1/repositories/test-repo/stats/refresh" },
{ 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: "POST", path: "/api/v1/repositories/test-repo/snapshots/refresh" }, { method: "POST", path: "/api/v1/repositories/test-repo/snapshots/refresh" },
@ -146,6 +147,18 @@ describe("repositories security", () => {
expect(body.message).toBe("Repository not found"); expect(body.message).toBe("Repository not found");
}); });
test("should return 404 for stats refresh of non-existent repository", async () => {
const { headers } = await createTestSession();
const res = await app.request("/api/v1/repositories/non-existent-repo/stats/refresh", {
method: "POST",
headers,
});
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 { headers } = await createTestSession(); const { headers } = await createTestSession();
const res = await app.request("/api/v1/repositories", { const res = await app.request("/api/v1/repositories", {

View file

@ -135,6 +135,63 @@ describe("repositoriesService.createRepository", () => {
}); });
}); });
describe("repositoriesService repository stats", () => {
afterEach(() => {
mock.restore();
});
test("returns empty stats when repository has not been populated yet", async () => {
const { organizationId, user } = await createTestSession();
const repository = await createTestRepository(organizationId);
const stats = await withContext({ organizationId, userId: user.id }, () =>
repositoriesService.getRepositoryStats(repository.shortId),
);
expect(stats).toEqual({
total_size: 0,
total_uncompressed_size: 0,
compression_ratio: 0,
compression_progress: 0,
compression_space_saving: 0,
snapshots_count: 0,
});
});
test("refreshes and persists repository stats", async () => {
const { organizationId, user } = await createTestSession();
const repository = await createTestRepository(organizationId);
const expectedStats = {
total_size: 1024,
total_uncompressed_size: 2048,
compression_ratio: 2,
compression_progress: 50,
compression_space_saving: 50,
snapshots_count: 3,
};
const statsSpy = spyOn(restic, "stats").mockResolvedValue(expectedStats);
const refreshed = await withContext({ organizationId, userId: user.id }, () =>
repositoriesService.refreshRepositoryStats(repository.shortId),
);
expect(refreshed).toEqual(expectedStats);
expect(statsSpy).toHaveBeenCalledTimes(1);
const persistedRepository = await db.query.repositoriesTable.findFirst({ where: { id: repository.id } });
expect(persistedRepository?.stats).toEqual(expectedStats);
expect(typeof persistedRepository?.statsUpdatedAt).toBe("number");
const loaded = await withContext({ organizationId, userId: user.id }, () =>
repositoriesService.getRepositoryStats(repository.shortId),
);
expect(loaded).toEqual(expectedStats);
expect(statsSpy).toHaveBeenCalledTimes(1);
});
});
describe("repositoriesService.dumpSnapshot", () => { describe("repositoriesService.dumpSnapshot", () => {
afterEach(() => { afterEach(() => {
mock.restore(); mock.restore();
@ -337,6 +394,34 @@ describe("repositoriesService.deleteSnapshot", () => {
mock.restore(); mock.restore();
}); });
test("refreshes repository stats in background after successful deletion", async () => {
const { organizationId, user } = await createTestSession();
const repository = await createTestRepository(organizationId);
const expectedStats = {
total_size: 128,
total_uncompressed_size: 256,
compression_ratio: 2,
compression_progress: 50,
compression_space_saving: 50,
snapshots_count: 1,
};
spyOn(restic, "deleteSnapshot").mockResolvedValue({ success: true });
const statsSpy = spyOn(restic, "stats").mockResolvedValue(expectedStats);
await withContext({ organizationId, userId: user.id }, () =>
repositoriesService.deleteSnapshot(repository.shortId, "snap-1"),
);
await new Promise((resolve) => setTimeout(resolve, 20));
expect(statsSpy).toHaveBeenCalledTimes(1);
const updatedRepository = await db.query.repositoriesTable.findFirst({ where: { id: repository.id } });
expect(updatedRepository?.stats).toEqual(expectedStats);
expect(typeof updatedRepository?.statsUpdatedAt).toBe("number");
});
test("should throw original error when restic deleteSnapshot fails", async () => { test("should throw original error when restic deleteSnapshot fails", async () => {
const { organizationId, user } = await createTestSession(); const { organizationId, user } = await createTestSession();
const repository = await createTestRepository(organizationId); const repository = await createTestRepository(organizationId);

View file

@ -14,6 +14,7 @@ import {
cancelDoctorDto, cancelDoctorDto,
getRepositoryDto, getRepositoryDto,
getRepositoryStatsDto, getRepositoryStatsDto,
refreshRepositoryStatsDto,
getSnapshotDetailsDto, getSnapshotDetailsDto,
refreshSnapshotsDto, refreshSnapshotsDto,
listRcloneRemotesDto, listRcloneRemotesDto,
@ -40,6 +41,7 @@ import {
type CancelDoctorDto, type CancelDoctorDto,
type GetRepositoryDto, type GetRepositoryDto,
type GetRepositoryStatsDto, type GetRepositoryStatsDto,
type RefreshRepositoryStatsDto,
type GetSnapshotDetailsDto, type GetSnapshotDetailsDto,
type RefreshSnapshotsDto, type RefreshSnapshotsDto,
type ListRepositoriesDto, type ListRepositoriesDto,
@ -98,6 +100,12 @@ export const repositoriesController = new Hono()
return c.json<GetRepositoryStatsDto>(stats, 200); return c.json<GetRepositoryStatsDto>(stats, 200);
}) })
.post("/:shortId/stats/refresh", refreshRepositoryStatsDto, async (c) => {
const shortId = asShortId(c.req.param("shortId"));
const stats = await repositoriesService.refreshRepositoryStats(shortId);
return c.json<RefreshRepositoryStatsDto>(stats, 200);
})
.delete("/:shortId", deleteRepositoryDto, async (c) => { .delete("/:shortId", deleteRepositoryDto, async (c) => {
const shortId = asShortId(c.req.param("shortId")); const shortId = asShortId(c.req.param("shortId"));
await repositoriesService.deleteRepository(shortId); await repositoriesService.deleteRepository(shortId);

View file

@ -132,6 +132,25 @@ export const getRepositoryStatsDto = describeRoute({
}, },
}); });
export const refreshRepositoryStatsResponse = repositoryStatsSchema;
export type RefreshRepositoryStatsDto = typeof refreshRepositoryStatsResponse.infer;
export const refreshRepositoryStatsDto = describeRoute({
description: "Refresh repository storage and compression statistics",
tags: ["Repositories"],
operationId: "refreshRepositoryStats",
responses: {
200: {
description: "Refreshed repository statistics",
content: {
"application/json": {
schema: resolver(refreshRepositoryStatsResponse),
},
},
},
},
});
/** /**
* Delete a repository * Delete a repository
*/ */

View file

@ -9,6 +9,7 @@ import {
type RepositoryConfig, type RepositoryConfig,
repositoryConfigSchema, repositoryConfigSchema,
} from "~/schemas/restic"; } from "~/schemas/restic";
import type { ResticStatsDto } from "~/schemas/restic-dto";
import { config as appConfig } from "~/server/core/config"; import { config as appConfig } from "~/server/core/config";
import { serverEvents } from "~/server/core/events"; import { serverEvents } from "~/server/core/events";
import { getOrganizationId } from "~/server/core/request-context"; import { getOrganizationId } from "~/server/core/request-context";
@ -16,7 +17,7 @@ import { logger } from "~/server/utils/logger";
import { parseRetentionCategories, type RetentionCategory } from "~/server/utils/retention-categories"; import { parseRetentionCategories, type RetentionCategory } from "~/server/utils/retention-categories";
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, type Repository } from "../../db/schema";
import { cache, cacheKeys } 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";
@ -32,6 +33,15 @@ import type { ShortId } from "~/server/utils/branded";
const runningDoctors = new Map<string, AbortController>(); const runningDoctors = new Map<string, AbortController>();
const emptyRepositoryStats: ResticStatsDto = {
total_size: 0,
total_uncompressed_size: 0,
compression_ratio: 0,
compression_progress: 0,
compression_space_saving: 0,
snapshots_count: 0,
};
const findRepository = async (shortId: ShortId) => { const findRepository = async (shortId: ShortId) => {
const organizationId = getOrganizationId(); const organizationId = getOrganizationId();
return await db.query.repositoriesTable.findFirst({ return await db.query.repositoriesTable.findFirst({
@ -197,28 +207,42 @@ const getRepository = async (shortId: ShortId) => {
return { repository }; return { repository };
}; };
const getRepositoryStats = async (shortId: ShortId) => { const runAndStoreRepositoryStats = async (repository: Repository): Promise<ResticStatsDto> => {
const organizationId = getOrganizationId(); const releaseLock = await repoMutex.acquireShared(repository.id, "stats");
try {
const stats = await restic.stats(repository.config, { organizationId: repository.organizationId });
await db
.update(repositoriesTable)
.set({ stats, statsUpdatedAt: Date.now() })
.where(
and(eq(repositoriesTable.id, repository.id), eq(repositoriesTable.organizationId, repository.organizationId)),
);
return stats;
} finally {
releaseLock();
}
};
const refreshRepositoryStats = async (shortId: ShortId) => {
const repository = await findRepository(shortId); const repository = await findRepository(shortId);
if (!repository) { if (!repository) {
throw new NotFoundError("Repository not found"); throw new NotFoundError("Repository not found");
} }
const cacheKey = cacheKeys.repository.stats(repository.id); return runAndStoreRepositoryStats(repository);
const cached = cache.get<Awaited<ReturnType<typeof restic.stats>>>(cacheKey); };
if (cached) {
return cached; const getRepositoryStats = async (shortId: ShortId) => {
const repository = await findRepository(shortId);
if (!repository) {
throw new NotFoundError("Repository not found");
} }
const releaseLock = await repoMutex.acquireShared(repository.id, "stats"); return repository.stats ?? { ...emptyRepositoryStats };
try {
const stats = await restic.stats(repository.config, { organizationId });
cache.set(cacheKey, stats);
return stats;
} finally {
releaseLock();
}
}; };
const deleteRepository = async (shortId: ShortId) => { const deleteRepository = async (shortId: ShortId) => {
@ -616,6 +640,11 @@ const deleteSnapshot = async (shortId: ShortId, snapshotId: string) => {
try { try {
await restic.deleteSnapshot(repository.config, snapshotId, organizationId); await restic.deleteSnapshot(repository.config, snapshotId, organizationId);
cache.delByPrefix(cacheKeys.repository.all(repository.id)); cache.delByPrefix(cacheKeys.repository.all(repository.id));
void runAndStoreRepositoryStats(repository).catch((error) => {
logger.error(
`Failed to refresh repository stats after snapshot deletion for ${repository.shortId}: ${toMessage(error)}`,
);
});
} finally { } finally {
releaseLock(); releaseLock();
} }
@ -629,13 +658,25 @@ const deleteSnapshots = async (shortId: ShortId, snapshotIds: string[]) => {
throw new NotFoundError("Repository not found"); throw new NotFoundError("Repository not found");
} }
let shouldRefreshStats = false;
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(cacheKeys.repository.all(repository.id)); cache.delByPrefix(cacheKeys.repository.all(repository.id));
shouldRefreshStats = true;
} finally { } finally {
releaseLock(); releaseLock();
} }
if (!shouldRefreshStats) {
return;
}
void runAndStoreRepositoryStats(repository).catch((error) => {
logger.error(
`Failed to refresh repository stats after snapshot deletion for ${repository.shortId}: ${toMessage(error)}`,
);
});
}; };
const tagSnapshots = async ( const tagSnapshots = async (
@ -734,6 +775,8 @@ const updateRepository = async (shortId: ShortId, updates: UpdateRepositoryBody)
lastChecked: null, lastChecked: null,
lastError: null, lastError: null,
doctorResult: null, doctorResult: null,
stats: null,
statsUpdatedAt: null,
} }
: {}), : {}),
}; };
@ -852,6 +895,7 @@ export const repositoriesService = {
createRepository, createRepository,
getRepository, getRepository,
getRepositoryStats, getRepositoryStats,
refreshRepositoryStats,
deleteRepository, deleteRepository,
updateRepository, updateRepository,
listSnapshots, listSnapshots,

View file

@ -108,7 +108,6 @@ export const createCache = (options: CacheOptions = {}) => {
export const cacheKeys = { export const cacheKeys = {
repository: { repository: {
all: (repositoryId: string) => `repo:${repositoryId}:`, all: (repositoryId: string) => `repo:${repositoryId}:`,
stats: (repositoryId: string) => `repo:${repositoryId}:stats`,
snapshots: (repositoryId: string, backupId = "all") => `repo:${repositoryId}:snapshots:${backupId}`, snapshots: (repositoryId: string, backupId = "all") => `repo:${repositoryId}:snapshots:${backupId}`,
ls: (repositoryId: string, snapshotId: string, path = "root", offset: number, limit: number) => ls: (repositoryId: string, snapshotId: string, path = "root", offset: number, limit: number) =>
`repo:${repositoryId}:ls:${snapshotId}:${path}:${offset}:${limit}`, `repo:${repositoryId}:ls:${snapshotId}:${path}:${offset}:${limit}`,