Merge branch 'main' into config-export-feature
This commit is contained in:
commit
630b34ee10
50 changed files with 1606 additions and 297 deletions
|
|
@ -407,7 +407,7 @@ export const deleteRepositoryMutation = (options?: Partial<Options<DeleteReposit
|
|||
export const getRepositoryQueryKey = (options: Options<GetRepositoryData>) => createQueryKey('getRepository', options);
|
||||
|
||||
/**
|
||||
* Get a single repository by name
|
||||
* Get a single repository by ID
|
||||
*/
|
||||
export const getRepositoryOptions = (options: Options<GetRepositoryData>) => queryOptions<GetRepositoryResponse, DefaultError, GetRepositoryResponse, ReturnType<typeof getRepositoryQueryKey>>({
|
||||
queryFn: async ({ queryKey, signal }) => {
|
||||
|
|
|
|||
|
|
@ -170,18 +170,18 @@ export const listRcloneRemotes = <ThrowOnError extends boolean = false>(options?
|
|||
/**
|
||||
* Delete a repository
|
||||
*/
|
||||
export const deleteRepository = <ThrowOnError extends boolean = false>(options: Options<DeleteRepositoryData, ThrowOnError>) => (options.client ?? client).delete<DeleteRepositoryResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{name}', ...options });
|
||||
export const deleteRepository = <ThrowOnError extends boolean = false>(options: Options<DeleteRepositoryData, ThrowOnError>) => (options.client ?? client).delete<DeleteRepositoryResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{id}', ...options });
|
||||
|
||||
/**
|
||||
* Get a single repository by name
|
||||
* Get a single repository by ID
|
||||
*/
|
||||
export const getRepository = <ThrowOnError extends boolean = false>(options: Options<GetRepositoryData, ThrowOnError>) => (options.client ?? client).get<GetRepositoryResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{name}', ...options });
|
||||
export const getRepository = <ThrowOnError extends boolean = false>(options: Options<GetRepositoryData, ThrowOnError>) => (options.client ?? client).get<GetRepositoryResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{id}', ...options });
|
||||
|
||||
/**
|
||||
* Update a repository's name or settings
|
||||
*/
|
||||
export const updateRepository = <ThrowOnError extends boolean = false>(options: Options<UpdateRepositoryData, ThrowOnError>) => (options.client ?? client).patch<UpdateRepositoryResponses, UpdateRepositoryErrors, ThrowOnError>({
|
||||
url: '/api/v1/repositories/{name}',
|
||||
url: '/api/v1/repositories/{id}',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
@ -192,28 +192,28 @@ export const updateRepository = <ThrowOnError extends boolean = false>(options:
|
|||
/**
|
||||
* List all snapshots in a repository
|
||||
*/
|
||||
export const listSnapshots = <ThrowOnError extends boolean = false>(options: Options<ListSnapshotsData, ThrowOnError>) => (options.client ?? client).get<ListSnapshotsResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{name}/snapshots', ...options });
|
||||
export const listSnapshots = <ThrowOnError extends boolean = false>(options: Options<ListSnapshotsData, ThrowOnError>) => (options.client ?? client).get<ListSnapshotsResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{id}/snapshots', ...options });
|
||||
|
||||
/**
|
||||
* Delete a specific snapshot from a repository
|
||||
*/
|
||||
export const deleteSnapshot = <ThrowOnError extends boolean = false>(options: Options<DeleteSnapshotData, ThrowOnError>) => (options.client ?? client).delete<DeleteSnapshotResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{name}/snapshots/{snapshotId}', ...options });
|
||||
export const deleteSnapshot = <ThrowOnError extends boolean = false>(options: Options<DeleteSnapshotData, ThrowOnError>) => (options.client ?? client).delete<DeleteSnapshotResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{id}/snapshots/{snapshotId}', ...options });
|
||||
|
||||
/**
|
||||
* Get details of a specific snapshot
|
||||
*/
|
||||
export const getSnapshotDetails = <ThrowOnError extends boolean = false>(options: Options<GetSnapshotDetailsData, ThrowOnError>) => (options.client ?? client).get<GetSnapshotDetailsResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{name}/snapshots/{snapshotId}', ...options });
|
||||
export const getSnapshotDetails = <ThrowOnError extends boolean = false>(options: Options<GetSnapshotDetailsData, ThrowOnError>) => (options.client ?? client).get<GetSnapshotDetailsResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{id}/snapshots/{snapshotId}', ...options });
|
||||
|
||||
/**
|
||||
* List files and directories in a snapshot
|
||||
*/
|
||||
export const listSnapshotFiles = <ThrowOnError extends boolean = false>(options: Options<ListSnapshotFilesData, ThrowOnError>) => (options.client ?? client).get<ListSnapshotFilesResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{name}/snapshots/{snapshotId}/files', ...options });
|
||||
export const listSnapshotFiles = <ThrowOnError extends boolean = false>(options: Options<ListSnapshotFilesData, ThrowOnError>) => (options.client ?? client).get<ListSnapshotFilesResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{id}/snapshots/{snapshotId}/files', ...options });
|
||||
|
||||
/**
|
||||
* Restore a snapshot to a target path on the filesystem
|
||||
*/
|
||||
export const restoreSnapshot = <ThrowOnError extends boolean = false>(options: Options<RestoreSnapshotData, ThrowOnError>) => (options.client ?? client).post<RestoreSnapshotResponses, unknown, ThrowOnError>({
|
||||
url: '/api/v1/repositories/{name}/restore',
|
||||
url: '/api/v1/repositories/{id}/restore',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
@ -224,7 +224,7 @@ export const restoreSnapshot = <ThrowOnError extends boolean = false>(options: O
|
|||
/**
|
||||
* Run doctor operations on a repository to fix common issues (unlock, check, repair index). Use this when the repository is locked or has errors.
|
||||
*/
|
||||
export const doctorRepository = <ThrowOnError extends boolean = false>(options: Options<DoctorRepositoryData, ThrowOnError>) => (options.client ?? client).post<DoctorRepositoryResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{name}/doctor', ...options });
|
||||
export const doctorRepository = <ThrowOnError extends boolean = false>(options: Options<DoctorRepositoryData, ThrowOnError>) => (options.client ?? client).post<DoctorRepositoryResponses, unknown, ThrowOnError>({ url: '/api/v1/repositories/{id}/doctor', ...options });
|
||||
|
||||
/**
|
||||
* List all backup schedules
|
||||
|
|
|
|||
|
|
@ -870,6 +870,7 @@ export type CreateRepositoryResponses = {
|
|||
repository: {
|
||||
id: string;
|
||||
name: string;
|
||||
shortId: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
@ -898,10 +899,10 @@ export type ListRcloneRemotesResponse = ListRcloneRemotesResponses[keyof ListRcl
|
|||
export type DeleteRepositoryData = {
|
||||
body?: never;
|
||||
path: {
|
||||
name: string;
|
||||
id: string;
|
||||
};
|
||||
query?: never;
|
||||
url: '/api/v1/repositories/{name}';
|
||||
url: '/api/v1/repositories/{id}';
|
||||
};
|
||||
|
||||
export type DeleteRepositoryResponses = {
|
||||
|
|
@ -918,10 +919,10 @@ export type DeleteRepositoryResponse = DeleteRepositoryResponses[keyof DeleteRep
|
|||
export type GetRepositoryData = {
|
||||
body?: never;
|
||||
path: {
|
||||
name: string;
|
||||
id: string;
|
||||
};
|
||||
query?: never;
|
||||
url: '/api/v1/repositories/{name}';
|
||||
url: '/api/v1/repositories/{id}';
|
||||
};
|
||||
|
||||
export type GetRepositoryResponses = {
|
||||
|
|
@ -1011,10 +1012,10 @@ export type UpdateRepositoryData = {
|
|||
name?: string;
|
||||
};
|
||||
path: {
|
||||
name: string;
|
||||
id: string;
|
||||
};
|
||||
query?: never;
|
||||
url: '/api/v1/repositories/{name}';
|
||||
url: '/api/v1/repositories/{id}';
|
||||
};
|
||||
|
||||
export type UpdateRepositoryErrors = {
|
||||
|
|
@ -1112,12 +1113,12 @@ export type UpdateRepositoryResponse = UpdateRepositoryResponses[keyof UpdateRep
|
|||
export type ListSnapshotsData = {
|
||||
body?: never;
|
||||
path: {
|
||||
name: string;
|
||||
id: string;
|
||||
};
|
||||
query?: {
|
||||
backupId?: string;
|
||||
};
|
||||
url: '/api/v1/repositories/{name}/snapshots';
|
||||
url: '/api/v1/repositories/{id}/snapshots';
|
||||
};
|
||||
|
||||
export type ListSnapshotsResponses = {
|
||||
|
|
@ -1139,11 +1140,11 @@ export type ListSnapshotsResponse = ListSnapshotsResponses[keyof ListSnapshotsRe
|
|||
export type DeleteSnapshotData = {
|
||||
body?: never;
|
||||
path: {
|
||||
name: string;
|
||||
id: string;
|
||||
snapshotId: string;
|
||||
};
|
||||
query?: never;
|
||||
url: '/api/v1/repositories/{name}/snapshots/{snapshotId}';
|
||||
url: '/api/v1/repositories/{id}/snapshots/{snapshotId}';
|
||||
};
|
||||
|
||||
export type DeleteSnapshotResponses = {
|
||||
|
|
@ -1160,11 +1161,11 @@ export type DeleteSnapshotResponse = DeleteSnapshotResponses[keyof DeleteSnapsho
|
|||
export type GetSnapshotDetailsData = {
|
||||
body?: never;
|
||||
path: {
|
||||
name: string;
|
||||
id: string;
|
||||
snapshotId: string;
|
||||
};
|
||||
query?: never;
|
||||
url: '/api/v1/repositories/{name}/snapshots/{snapshotId}';
|
||||
url: '/api/v1/repositories/{id}/snapshots/{snapshotId}';
|
||||
};
|
||||
|
||||
export type GetSnapshotDetailsResponses = {
|
||||
|
|
@ -1186,13 +1187,13 @@ export type GetSnapshotDetailsResponse = GetSnapshotDetailsResponses[keyof GetSn
|
|||
export type ListSnapshotFilesData = {
|
||||
body?: never;
|
||||
path: {
|
||||
name: string;
|
||||
id: string;
|
||||
snapshotId: string;
|
||||
};
|
||||
query?: {
|
||||
path?: string;
|
||||
};
|
||||
url: '/api/v1/repositories/{name}/snapshots/{snapshotId}/files';
|
||||
url: '/api/v1/repositories/{id}/snapshots/{snapshotId}/files';
|
||||
};
|
||||
|
||||
export type ListSnapshotFilesResponses = {
|
||||
|
|
@ -1235,10 +1236,10 @@ export type RestoreSnapshotData = {
|
|||
targetPath?: string;
|
||||
};
|
||||
path: {
|
||||
name: string;
|
||||
id: string;
|
||||
};
|
||||
query?: never;
|
||||
url: '/api/v1/repositories/{name}/restore';
|
||||
url: '/api/v1/repositories/{id}/restore';
|
||||
};
|
||||
|
||||
export type RestoreSnapshotResponses = {
|
||||
|
|
@ -1258,10 +1259,10 @@ export type RestoreSnapshotResponse = RestoreSnapshotResponses[keyof RestoreSnap
|
|||
export type DoctorRepositoryData = {
|
||||
body?: never;
|
||||
path: {
|
||||
name: string;
|
||||
id: string;
|
||||
};
|
||||
query?: never;
|
||||
url: '/api/v1/repositories/{name}/doctor';
|
||||
url: '/api/v1/repositories/{id}/doctor';
|
||||
};
|
||||
|
||||
export type DoctorRepositoryResponses = {
|
||||
|
|
|
|||
89
app/client/components/cron-input.tsx
Normal file
89
app/client/components/cron-input.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { CronExpressionParser } from "cron-parser";
|
||||
import { format } from "date-fns";
|
||||
import { AlertCircle, CheckCircle2 } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
|
||||
import { Input } from "~/client/components/ui/input";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
|
||||
interface CronInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function CronInput({ value, onChange, error }: CronInputProps) {
|
||||
const { isValid, nextRuns, parseError } = useMemo(() => {
|
||||
if (!value) {
|
||||
return { isValid: false, nextRuns: [], parseError: null };
|
||||
}
|
||||
|
||||
const parts = value.trim().split(/\s+/);
|
||||
|
||||
if (parts.length !== 5) {
|
||||
return {
|
||||
isValid: false,
|
||||
nextRuns: [],
|
||||
parseError: "Expression must have exactly 5 fields (minute, hour, day, month, day-of-week)",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const interval = CronExpressionParser.parse(value);
|
||||
const runs: Date[] = [];
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
runs.push(interval.next().toDate());
|
||||
}
|
||||
|
||||
return { isValid: true, nextRuns: runs, parseError: null };
|
||||
} catch (e) {
|
||||
return { isValid: false, nextRuns: [], parseError: (e as Error).message };
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<FormItem className="md:col-span-2">
|
||||
<FormLabel>Cron expression</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Input
|
||||
placeholder="* * * * *"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={cn("font-mono", { "border-destructive": error || (value && !isValid) })}
|
||||
/>
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
{value && (
|
||||
<div>
|
||||
{isValid ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<AlertCircle className="h-4 w-4 text-destructive" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Standard cron format: <code className="bg-muted px-1 rounded">minute hour day month day-of-week</code>.
|
||||
</FormDescription>
|
||||
{value && !isValid && parseError && <p className="text-xs text-destructive mt-1">{parseError}</p>}
|
||||
{isValid && nextRuns.length > 0 && (
|
||||
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
|
||||
<p className="text-xs font-medium mb-2 text-muted-foreground uppercase tracking-wider">Next 5 executions:</p>
|
||||
<ul className="space-y-1">
|
||||
{nextRuns.map((date, i) => (
|
||||
<li key={date.toISOString()} className="text-xs font-mono flex items-center gap-2">
|
||||
<span className="text-muted-foreground w-4">{i + 1}.</span>
|
||||
{format(date, "PPP p")}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import { ChevronDown, ChevronRight, File as FileIcon, Folder as FolderIcon, Fold
|
|||
import { memo, type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
import { Checkbox } from "~/client/components/ui/checkbox";
|
||||
import { ByteSize } from "~/client/components/bytes-size";
|
||||
|
||||
const NODE_PADDING_LEFT = 12;
|
||||
|
||||
|
|
@ -431,7 +432,7 @@ interface FileProps {
|
|||
}
|
||||
|
||||
const File = memo(({ file, onFileSelect, selected, withCheckbox, checked, onCheckboxChange }: FileProps) => {
|
||||
const { depth, name, fullPath } = file;
|
||||
const { depth, name, fullPath, size } = file;
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
onFileSelect(fullPath);
|
||||
|
|
@ -458,6 +459,11 @@ const File = memo(({ file, onFileSelect, selected, withCheckbox, checked, onChec
|
|||
<Checkbox checked={checked} onCheckedChange={handleCheckboxChange} onClick={(e) => e.stopPropagation()} />
|
||||
)}
|
||||
<span className="truncate">{name}</span>
|
||||
{typeof size === "number" && (
|
||||
<span className="ml-auto shrink-0 text-xs text-muted-foreground">
|
||||
<ByteSize bytes={size} />
|
||||
</span>
|
||||
)}
|
||||
</NodeButton>
|
||||
);
|
||||
});
|
||||
|
|
@ -499,6 +505,7 @@ interface BaseNode {
|
|||
|
||||
interface FileNode extends BaseNode {
|
||||
kind: "file";
|
||||
size?: number;
|
||||
}
|
||||
|
||||
interface FolderNode extends BaseNode {
|
||||
|
|
@ -518,12 +525,14 @@ function buildFileList(files: FileEntry[], foldersOnly = false): Node[] {
|
|||
const name = segments[segments.length - 1];
|
||||
|
||||
if (!fileMap.has(file.path)) {
|
||||
const isFile = file.type === "file";
|
||||
fileMap.set(file.path, {
|
||||
kind: file.type === "file" ? "file" : "folder",
|
||||
kind: isFile ? "file" : "folder",
|
||||
id: fileMap.size,
|
||||
name,
|
||||
fullPath: file.path,
|
||||
depth,
|
||||
size: file.size,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,18 +14,18 @@ import { FileTree } from "~/client/components/file-tree";
|
|||
import { listSnapshotFilesOptions, restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { useFileBrowser } from "~/client/hooks/use-file-browser";
|
||||
import { OVERWRITE_MODES, type OverwriteMode } from "~/schemas/restic";
|
||||
import type { Snapshot } from "~/client/lib/types";
|
||||
import type { Repository, Snapshot } from "~/client/lib/types";
|
||||
|
||||
type RestoreLocation = "original" | "custom";
|
||||
|
||||
interface RestoreFormProps {
|
||||
snapshot: Snapshot;
|
||||
repositoryName: string;
|
||||
repository: Repository;
|
||||
snapshotId: string;
|
||||
returnPath: string;
|
||||
}
|
||||
|
||||
export function RestoreForm({ snapshot, repositoryName, snapshotId, returnPath }: RestoreFormProps) {
|
||||
export function RestoreForm({ snapshot, repository, snapshotId, returnPath }: RestoreFormProps) {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
|
@ -42,10 +42,9 @@ export function RestoreForm({ snapshot, repositoryName, snapshotId, returnPath }
|
|||
|
||||
const { data: filesData, isLoading: filesLoading } = useQuery({
|
||||
...listSnapshotFilesOptions({
|
||||
path: { name: repositoryName, snapshotId },
|
||||
path: { id: repository.id, snapshotId },
|
||||
query: { path: volumeBasePath },
|
||||
}),
|
||||
enabled: !!repositoryName && !!snapshotId,
|
||||
});
|
||||
|
||||
const stripBasePath = useCallback(
|
||||
|
|
@ -78,7 +77,7 @@ export function RestoreForm({ snapshot, repositoryName, snapshotId, returnPath }
|
|||
fetchFolder: async (path) => {
|
||||
return await queryClient.ensureQueryData(
|
||||
listSnapshotFilesOptions({
|
||||
path: { name: repositoryName, snapshotId },
|
||||
path: { id: repository.id, snapshotId },
|
||||
query: { path },
|
||||
}),
|
||||
);
|
||||
|
|
@ -86,7 +85,7 @@ export function RestoreForm({ snapshot, repositoryName, snapshotId, returnPath }
|
|||
prefetchFolder: (path) => {
|
||||
queryClient.prefetchQuery(
|
||||
listSnapshotFilesOptions({
|
||||
path: { name: repositoryName, snapshotId },
|
||||
path: { id: repository.id, snapshotId },
|
||||
query: { path },
|
||||
}),
|
||||
);
|
||||
|
|
@ -111,8 +110,6 @@ export function RestoreForm({ snapshot, repositoryName, snapshotId, returnPath }
|
|||
});
|
||||
|
||||
const handleRestore = useCallback(() => {
|
||||
if (!repositoryName || !snapshotId) return;
|
||||
|
||||
const excludeXattrArray = excludeXattr
|
||||
?.split(",")
|
||||
.map((s) => s.trim())
|
||||
|
|
@ -125,7 +122,7 @@ export function RestoreForm({ snapshot, repositoryName, snapshotId, returnPath }
|
|||
const includePaths = pathsArray.map((path) => addBasePath(path));
|
||||
|
||||
restoreSnapshot({
|
||||
path: { name: repositoryName },
|
||||
path: { id: repository.id },
|
||||
body: {
|
||||
snapshotId,
|
||||
include: includePaths.length > 0 ? includePaths : undefined,
|
||||
|
|
@ -136,7 +133,7 @@ export function RestoreForm({ snapshot, repositoryName, snapshotId, returnPath }
|
|||
},
|
||||
});
|
||||
}, [
|
||||
repositoryName,
|
||||
repository.id,
|
||||
snapshotId,
|
||||
excludeXattr,
|
||||
restoreLocation,
|
||||
|
|
@ -156,7 +153,7 @@ export function RestoreForm({ snapshot, repositoryName, snapshotId, returnPath }
|
|||
<div>
|
||||
<h1 className="text-2xl font-bold">Restore Snapshot</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{repositoryName} / {snapshotId}
|
||||
{repository.name} / {snapshotId}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Calendar, Clock, Database, FolderTree, HardDrive, Trash2 } from "lucide-react";
|
||||
import { Calendar, Clock, Database, HardDrive, Server, Trash2 } from "lucide-react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { ByteSize } from "~/client/components/bytes-size";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "~/client/components/ui/table";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
|
|
@ -21,14 +20,15 @@ import { formatDuration } from "~/utils/utils";
|
|||
import { deleteSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { parseError } from "~/client/lib/errors";
|
||||
import type { BackupSchedule, Snapshot } from "../lib/types";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
type Props = {
|
||||
snapshots: Snapshot[];
|
||||
backups: BackupSchedule[];
|
||||
repositoryName: string;
|
||||
repositoryId: string;
|
||||
};
|
||||
|
||||
export const SnapshotsTable = ({ snapshots, repositoryName, backups }: Props) => {
|
||||
export const SnapshotsTable = ({ snapshots, repositoryId, backups }: Props) => {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
|
@ -53,7 +53,7 @@ export const SnapshotsTable = ({ snapshots, repositoryName, backups }: Props) =>
|
|||
if (snapshotToDelete) {
|
||||
toast.promise(
|
||||
deleteSnapshot.mutateAsync({
|
||||
path: { name: repositoryName, snapshotId: snapshotToDelete },
|
||||
path: { id: repositoryId, snapshotId: snapshotToDelete },
|
||||
}),
|
||||
{
|
||||
loading: "Deleting snapshot...",
|
||||
|
|
@ -65,7 +65,7 @@ export const SnapshotsTable = ({ snapshots, repositoryName, backups }: Props) =>
|
|||
};
|
||||
|
||||
const handleRowClick = (snapshotId: string) => {
|
||||
navigate(`/repositories/${repositoryName}/${snapshotId}`);
|
||||
navigate(`/repositories/${repositoryId}/${snapshotId}`);
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -79,7 +79,7 @@ export const SnapshotsTable = ({ snapshots, repositoryName, backups }: Props) =>
|
|||
<TableHead className="uppercase">Date & Time</TableHead>
|
||||
<TableHead className="uppercase">Size</TableHead>
|
||||
<TableHead className="uppercase hidden md:table-cell text-right">Duration</TableHead>
|
||||
<TableHead className="uppercase hidden text-right lg:table-cell">Paths</TableHead>
|
||||
<TableHead className="uppercase hidden text-right lg:table-cell">Volume</TableHead>
|
||||
<TableHead className="uppercase text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
|
@ -108,7 +108,7 @@ export const SnapshotsTable = ({ snapshots, repositoryName, backups }: Props) =>
|
|||
onClick={(e) => e.stopPropagation()}
|
||||
className="hover:underline"
|
||||
>
|
||||
<span className="text-sm">{backup ? backup.id : "-"}</span>
|
||||
<span className="text-sm">{backup ? backup.name : "-"}</span>
|
||||
</Link>
|
||||
<span hidden={!!backup} className="text-sm text-muted-foreground">
|
||||
-
|
||||
|
|
@ -137,23 +137,18 @@ export const SnapshotsTable = ({ snapshots, repositoryName, backups }: Props) =>
|
|||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<FolderTree className="h-4 w-4 text-muted-foreground" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-xs bg-primary/10 text-primary rounded-md px-2 py-1 cursor-help">
|
||||
{snapshot.paths.length} {snapshot.paths.length === 1 ? "path" : "paths"}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-md">
|
||||
<div className="flex flex-col gap-1">
|
||||
{snapshot.paths.map((path) => (
|
||||
<div key={`${snapshot.short_id}-${path}`} className="text-xs font-mono">
|
||||
{path}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Server className={cn("h-4 w-4 text-muted-foreground", { hidden: !backup })} />
|
||||
<Link
|
||||
hidden={!backup}
|
||||
to={backup ? `/volumes/${backup.volume.name}` : "#"}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="hover:underline"
|
||||
>
|
||||
<span className="text-sm">{backup ? backup.volume.name : "-"}</span>
|
||||
</Link>
|
||||
<span hidden={!!backup} className="text-sm text-muted-foreground">
|
||||
-
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
|
|
|
|||
|
|
@ -1,16 +1,19 @@
|
|||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { type } from "arktype";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { AuthLayout } from "~/client/components/auth-layout";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "~/client/components/ui/dialog";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
|
||||
import { Input } from "~/client/components/ui/input";
|
||||
import { authMiddleware } from "~/middleware/auth";
|
||||
import type { Route } from "./+types/login";
|
||||
import { loginMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { copyToClipboard } from "~/utils/clipboard";
|
||||
|
||||
export const clientMiddleware = [authMiddleware];
|
||||
|
||||
|
|
@ -33,6 +36,7 @@ type LoginFormValues = typeof loginSchema.inferIn;
|
|||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const [showResetDialog, setShowResetDialog] = useState(false);
|
||||
|
||||
const form = useForm<LoginFormValues>({
|
||||
resolver: arktypeResolver(loginSchema),
|
||||
|
|
@ -93,7 +97,7 @@ export default function LoginPage() {
|
|||
<button
|
||||
type="button"
|
||||
className="text-xs text-muted-foreground hover:underline"
|
||||
onClick={() => toast.info("Password reset not implemented")}
|
||||
onClick={() => setShowResetDialog(true)}
|
||||
>
|
||||
Forgot your password?
|
||||
</button>
|
||||
|
|
@ -110,6 +114,39 @@ export default function LoginPage() {
|
|||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<ResetPasswordDialog open={showResetDialog} onOpenChange={setShowResetDialog} />
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const RESET_PASSWORD_COMMAND = "docker exec -it zerobyte bun run cli reset-password";
|
||||
|
||||
function ResetPasswordDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||
const handleCopy = async () => {
|
||||
await copyToClipboard(RESET_PASSWORD_COMMAND);
|
||||
toast.success("Command copied to clipboard");
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reset your password</DialogTitle>
|
||||
<DialogDescription>
|
||||
To reset your password, run the following command on the server where Zerobyte is installed.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md bg-muted p-4 font-mono text-sm break-all">{RESET_PASSWORD_COMMAND}</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This command will start an interactive session where you can enter a new password for your account.
|
||||
</p>
|
||||
<Button onClick={handleCopy} variant="outline" className="w-full">
|
||||
Copy Command
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~
|
|||
import { Button } from "~/client/components/ui/button";
|
||||
import { Textarea } from "~/client/components/ui/textarea";
|
||||
import { VolumeFileBrowser } from "~/client/components/volume-file-browser";
|
||||
import { CronInput } from "~/client/components/cron-input";
|
||||
import { cronToFormValues } from "../lib/cron-utils";
|
||||
import type { BackupSchedule, Volume } from "~/client/lib/types";
|
||||
import { deepClean } from "~/utils/object";
|
||||
|
||||
|
|
@ -36,6 +38,7 @@ const internalFormSchema = type({
|
|||
dailyTime: "string?",
|
||||
weeklyDay: "string?",
|
||||
monthlyDays: "string[]?",
|
||||
cronExpression: "string?",
|
||||
keepLast: "number?",
|
||||
keepHourly: "number?",
|
||||
keepDaily: "number?",
|
||||
|
|
@ -80,17 +83,7 @@ const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalFormValu
|
|||
return undefined;
|
||||
}
|
||||
|
||||
const parts = schedule.cronExpression.split(" ");
|
||||
const [minutePart, hourPart, dayOfMonthPart, , dayOfWeekPart] = parts;
|
||||
|
||||
const isHourly = hourPart === "*";
|
||||
const isMonthly = !isHourly && dayOfMonthPart !== "*" && dayOfWeekPart === "*";
|
||||
const isDaily = !isHourly && dayOfMonthPart === "*" && dayOfWeekPart === "*";
|
||||
|
||||
const frequency = isHourly ? "hourly" : isMonthly ? "monthly" : isDaily ? "daily" : "weekly";
|
||||
const dailyTime = isHourly ? undefined : `${hourPart.padStart(2, "0")}:${minutePart.padStart(2, "0")}`;
|
||||
const weeklyDay = frequency === "weekly" ? dayOfWeekPart : undefined;
|
||||
const monthlyDays = isMonthly ? dayOfMonthPart.split(",") : undefined;
|
||||
const cronValues = cronToFormValues(schedule.cronExpression ?? "0 * * * *");
|
||||
|
||||
const patterns = schedule.includePatterns || [];
|
||||
const isGlobPattern = (p: string) => /[*?[\]]/.test(p);
|
||||
|
|
@ -100,15 +93,12 @@ const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalFormValu
|
|||
return {
|
||||
name: schedule.name,
|
||||
repositoryId: schedule.repositoryId,
|
||||
frequency,
|
||||
monthlyDays,
|
||||
dailyTime,
|
||||
weeklyDay,
|
||||
includePatterns: fileBrowserPaths.length > 0 ? fileBrowserPaths : undefined,
|
||||
includePatternsText: textPatterns.length > 0 ? textPatterns.join("\n") : undefined,
|
||||
excludePatternsText: schedule.excludePatterns?.join("\n") || undefined,
|
||||
excludeIfPresentText: schedule.excludeIfPresent?.join("\n") || undefined,
|
||||
oneFileSystem: schedule.oneFileSystem ?? false,
|
||||
...cronValues,
|
||||
...schedule.retentionPolicy,
|
||||
};
|
||||
};
|
||||
|
|
@ -126,6 +116,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
|||
excludeIfPresentText,
|
||||
includePatternsText,
|
||||
includePatterns: fileBrowserPatterns,
|
||||
cronExpression,
|
||||
...rest
|
||||
} = data;
|
||||
const excludePatterns = excludePatternsText
|
||||
|
|
@ -152,6 +143,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
|||
|
||||
onSubmit({
|
||||
...rest,
|
||||
cronExpression,
|
||||
includePatterns: includePatterns.length > 0 ? includePatterns : [],
|
||||
excludePatterns,
|
||||
excludeIfPresent,
|
||||
|
|
@ -255,6 +247,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
|||
<SelectItem value="daily">Daily</SelectItem>
|
||||
<SelectItem value="weekly">Weekly</SelectItem>
|
||||
<SelectItem value="monthly">Specific days</SelectItem>
|
||||
<SelectItem value="cron">Custom (Cron)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
|
@ -264,7 +257,17 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
|||
)}
|
||||
/>
|
||||
|
||||
{frequency !== "hourly" && (
|
||||
{frequency === "cron" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="cronExpression"
|
||||
render={({ field, fieldState }) => (
|
||||
<CronInput value={field.value || ""} onChange={field.onChange} error={fieldState.error?.message} />
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{frequency !== "hourly" && frequency !== "cron" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dailyTime"
|
||||
|
|
|
|||
|
|
@ -285,7 +285,7 @@ export const ScheduleMirrorsConfig = ({ scheduleId, primaryRepositoryId, reposit
|
|||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
to={`/repositories/${repository.name}`}
|
||||
to={`/repositories/${repository.shortId}`}
|
||||
className="hover:underline flex items-center gap-2"
|
||||
>
|
||||
<RepositoryIcon backend={repository.type} className="h-4 w-4" />
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ export const ScheduleSummary = (props: Props) => {
|
|||
<span>{schedule.volume.name}</span>
|
||||
</Link>
|
||||
<span className="mx-2">→</span>
|
||||
<Link to={`/repositories/${schedule.repository.name}`} className="hover:underline">
|
||||
<Link to={`/repositories/${schedule.repository.shortId}`} className="hover:underline">
|
||||
<Database className="inline h-4 w-4 mr-2 text-strong-accent" />
|
||||
<span className="text-strong-accent">{schedule.repository.name}</span>
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -8,17 +8,18 @@ import { Button, buttonVariants } from "~/client/components/ui/button";
|
|||
import type { Snapshot } from "~/client/lib/types";
|
||||
import { listSnapshotFilesOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { useFileBrowser } from "~/client/hooks/use-file-browser";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
|
||||
interface Props {
|
||||
snapshot: Snapshot;
|
||||
repositoryName: string;
|
||||
repositoryId: string;
|
||||
backupId?: string;
|
||||
onDeleteSnapshot?: (snapshotId: string) => void;
|
||||
isDeletingSnapshot?: boolean;
|
||||
}
|
||||
|
||||
export const SnapshotFileBrowser = (props: Props) => {
|
||||
const { snapshot, repositoryName, backupId, onDeleteSnapshot, isDeletingSnapshot } = props;
|
||||
const { snapshot, repositoryId, backupId, onDeleteSnapshot, isDeletingSnapshot } = props;
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
|
@ -26,7 +27,7 @@ export const SnapshotFileBrowser = (props: Props) => {
|
|||
|
||||
const { data: filesData, isLoading: filesLoading } = useQuery({
|
||||
...listSnapshotFilesOptions({
|
||||
path: { name: repositoryName, snapshotId: snapshot.short_id },
|
||||
path: { id: repositoryId, snapshotId: snapshot.short_id },
|
||||
query: { path: volumeBasePath },
|
||||
}),
|
||||
});
|
||||
|
|
@ -61,7 +62,7 @@ export const SnapshotFileBrowser = (props: Props) => {
|
|||
fetchFolder: async (path) => {
|
||||
return await queryClient.ensureQueryData(
|
||||
listSnapshotFilesOptions({
|
||||
path: { name: repositoryName, snapshotId: snapshot.short_id },
|
||||
path: { id: repositoryId, snapshotId: snapshot.short_id },
|
||||
query: { path },
|
||||
}),
|
||||
);
|
||||
|
|
@ -69,7 +70,7 @@ export const SnapshotFileBrowser = (props: Props) => {
|
|||
prefetchFolder: (path) => {
|
||||
queryClient.prefetchQuery(
|
||||
listSnapshotFilesOptions({
|
||||
path: { name: repositoryName, snapshotId: snapshot.short_id },
|
||||
path: { id: repositoryId, snapshotId: snapshot.short_id },
|
||||
query: { path },
|
||||
}),
|
||||
);
|
||||
|
|
@ -82,19 +83,21 @@ export const SnapshotFileBrowser = (props: Props) => {
|
|||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card className="h-[600px] flex flex-col">
|
||||
<Card className="h-150 flex flex-col">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle>File Browser</CardTitle>
|
||||
<CardDescription>{`Viewing snapshot from ${new Date(snapshot?.time ?? 0).toLocaleString()}`}</CardDescription>
|
||||
<CardDescription
|
||||
className={cn({ hidden: !snapshot.time })}
|
||||
>{`Viewing snapshot from ${new Date(snapshot?.time ?? 0).toLocaleString()}`}</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Link
|
||||
to={
|
||||
backupId
|
||||
? `/backups/${backupId}/${snapshot.short_id}/restore`
|
||||
: `/repositories/${repositoryName}/${snapshot.short_id}/restore`
|
||||
: `/repositories/${repositoryId}/${snapshot.short_id}/restore`
|
||||
}
|
||||
className={buttonVariants({ variant: "primary", size: "sm" })}
|
||||
>
|
||||
|
|
|
|||
96
app/client/modules/backups/lib/cron-utils.ts
Normal file
96
app/client/modules/backups/lib/cron-utils.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
export type CronFormValues = {
|
||||
frequency: string;
|
||||
dailyTime?: string;
|
||||
weeklyDay?: string;
|
||||
monthlyDays?: string[];
|
||||
cronExpression?: string;
|
||||
};
|
||||
|
||||
const isSimpleNumber = (s: string) => /^\d+$/.test(s);
|
||||
const isWildcard = (s: string) => s === "*";
|
||||
|
||||
type Matcher = (parts: string[]) => CronFormValues | null;
|
||||
|
||||
const matchers: Matcher[] = [
|
||||
// Hourly: 0 * * * *
|
||||
(parts) => {
|
||||
if (parts.length === 5 && parts[0] === "0" && parts.slice(1).every(isWildcard)) {
|
||||
return { frequency: "hourly" };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
// Daily: mm hh * * *
|
||||
(parts) => {
|
||||
if (
|
||||
parts.length === 5 &&
|
||||
isSimpleNumber(parts[0]) &&
|
||||
isSimpleNumber(parts[1]) &&
|
||||
parts.slice(2).every(isWildcard)
|
||||
) {
|
||||
return {
|
||||
frequency: "daily",
|
||||
dailyTime: `${parts[1].padStart(2, "0")}:${parts[0].padStart(2, "0")}`,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
// Weekly: mm hh * * d
|
||||
(parts) => {
|
||||
if (
|
||||
parts.length === 5 &&
|
||||
isSimpleNumber(parts[0]) &&
|
||||
isSimpleNumber(parts[1]) &&
|
||||
isWildcard(parts[2]) &&
|
||||
isWildcard(parts[3]) &&
|
||||
isSimpleNumber(parts[4])
|
||||
) {
|
||||
return {
|
||||
frequency: "weekly",
|
||||
dailyTime: `${parts[1].padStart(2, "0")}:${parts[0].padStart(2, "0")}`,
|
||||
weeklyDay: parts[4],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
// Monthly: mm hh dd * *
|
||||
(parts) => {
|
||||
if (
|
||||
parts.length === 5 &&
|
||||
isSimpleNumber(parts[0]) &&
|
||||
isSimpleNumber(parts[1]) &&
|
||||
parts[2] !== "*" &&
|
||||
parts[2].split(",").every(isSimpleNumber) &&
|
||||
isWildcard(parts[3]) &&
|
||||
isWildcard(parts[4])
|
||||
) {
|
||||
return {
|
||||
frequency: "monthly",
|
||||
dailyTime: `${parts[1].padStart(2, "0")}:${parts[0].padStart(2, "0")}`,
|
||||
monthlyDays: parts[2].split(","),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
];
|
||||
|
||||
export const cronToFormValues = (cronExpression: string): CronFormValues => {
|
||||
if (!cronExpression) {
|
||||
return { frequency: "hourly" };
|
||||
}
|
||||
|
||||
const normalized = cronExpression.trim().replace(/\s+/g, " ");
|
||||
const parts = normalized.split(" ");
|
||||
|
||||
for (const matcher of matchers) {
|
||||
const result = matcher(parts);
|
||||
if (result) return result;
|
||||
}
|
||||
|
||||
return {
|
||||
frequency: "cron",
|
||||
cronExpression: normalized,
|
||||
};
|
||||
};
|
||||
|
|
@ -80,7 +80,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
|||
isLoading,
|
||||
failureReason,
|
||||
} = useQuery({
|
||||
...listSnapshotsOptions({ path: { name: schedule.repository.name }, query: { backupId: schedule.id.toString() } }),
|
||||
...listSnapshotsOptions({ path: { id: schedule.repository.id }, query: { backupId: schedule.id.toString() } }),
|
||||
});
|
||||
|
||||
const updateSchedule = useMutation({
|
||||
|
|
@ -146,6 +146,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
|||
formValues.dailyTime,
|
||||
formValues.weeklyDay,
|
||||
formValues.monthlyDays,
|
||||
formValues.cronExpression,
|
||||
);
|
||||
|
||||
const retentionPolicy: Record<string, number> = {};
|
||||
|
|
@ -197,7 +198,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
|||
if (snapshotToDelete) {
|
||||
toast.promise(
|
||||
deleteSnapshot.mutateAsync({
|
||||
path: { name: schedule.repository.name, snapshotId: snapshotToDelete },
|
||||
path: { id: schedule.repository.shortId, snapshotId: snapshotToDelete },
|
||||
}),
|
||||
{
|
||||
loading: "Deleting snapshot...",
|
||||
|
|
@ -259,7 +260,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon
|
|||
<SnapshotFileBrowser
|
||||
key={selectedSnapshot?.short_id}
|
||||
snapshot={selectedSnapshot}
|
||||
repositoryName={schedule.repository.name}
|
||||
repositoryId={schedule.repository.shortId}
|
||||
backupId={schedule.id.toString()}
|
||||
onDeleteSnapshot={handleDeleteSnapshot}
|
||||
isDeletingSnapshot={deleteSnapshot.isPending}
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ export default function CreateBackup({ loaderData }: Route.ComponentProps) {
|
|||
formValues.dailyTime,
|
||||
formValues.weeklyDay,
|
||||
formValues.monthlyDays,
|
||||
formValues.cronExpression,
|
||||
);
|
||||
|
||||
const retentionPolicy: Record<string, number> = {};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { redirect } from "react-router";
|
||||
import { getBackupSchedule, getSnapshotDetails } from "~/client/api-client";
|
||||
import { getBackupSchedule, getRepository, getSnapshotDetails } from "~/client/api-client";
|
||||
import { RestoreForm } from "~/client/components/restore-form";
|
||||
import type { Route } from "./+types/restore-snapshot";
|
||||
|
||||
|
|
@ -26,27 +26,30 @@ export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
|
|||
const schedule = await getBackupSchedule({ path: { scheduleId: params.id } });
|
||||
if (!schedule.data) return redirect("/backups");
|
||||
|
||||
const repositoryName = schedule.data.repository.name;
|
||||
const repositoryId = schedule.data.repository.id;
|
||||
const snapshot = await getSnapshotDetails({
|
||||
path: { name: repositoryName, snapshotId: params.snapshotId },
|
||||
path: { id: repositoryId, snapshotId: params.snapshotId },
|
||||
});
|
||||
if (!snapshot.data) return redirect(`/backups/${params.id}`);
|
||||
|
||||
const repository = await getRepository({ path: { id: repositoryId } });
|
||||
if (!repository.data) return redirect(`/backups/${params.id}`);
|
||||
|
||||
return {
|
||||
snapshot: snapshot.data,
|
||||
repositoryName,
|
||||
repository: repository.data,
|
||||
snapshotId: params.snapshotId,
|
||||
backupId: params.id,
|
||||
};
|
||||
};
|
||||
|
||||
export default function RestoreSnapshotFromBackupPage({ loaderData }: Route.ComponentProps) {
|
||||
const { snapshot, repositoryName, snapshotId, backupId } = loaderData;
|
||||
const { snapshot, repository, snapshotId, backupId } = loaderData;
|
||||
|
||||
return (
|
||||
<RestoreForm
|
||||
snapshot={snapshot}
|
||||
repositoryName={repositoryName}
|
||||
repository={repository}
|
||||
snapshotId={snapshotId}
|
||||
returnPath={`/backups/${backupId}`}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { type } from "arktype";
|
|||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Save } from "lucide-react";
|
||||
import { cn, slugify } from "~/client/lib/utils";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
import { deepClean } from "~/utils/object";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
|
|
@ -109,7 +109,7 @@ export const CreateRepositoryForm = ({
|
|||
<Input
|
||||
{...field}
|
||||
placeholder="Repository name"
|
||||
onChange={(e) => field.onChange(slugify(e.target.value))}
|
||||
onChange={(e) => field.onChange(e.target.value)}
|
||||
maxLength={32}
|
||||
minLength={2}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ export default function CreateRepository() {
|
|||
...createRepositoryMutation(),
|
||||
onSuccess: (data) => {
|
||||
toast.success("Repository created successfully");
|
||||
navigate(`/repositories/${data.repository.name}`);
|
||||
navigate(`/repositories/${data.repository.shortId}`);
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -148,9 +148,9 @@ export default function Repositories({ loaderData }: Route.ComponentProps) {
|
|||
) : (
|
||||
filteredRepositories.map((repository) => (
|
||||
<TableRow
|
||||
key={repository.name}
|
||||
key={repository.id}
|
||||
className="hover:bg-accent/50 hover:cursor-pointer"
|
||||
onClick={() => navigate(`/repositories/${repository.name}`)}
|
||||
onClick={() => navigate(`/repositories/${repository.shortId}`)}
|
||||
>
|
||||
<TableCell className="font-medium text-strong-accent">{repository.name}</TableCell>
|
||||
<TableCell>
|
||||
|
|
|
|||
|
|
@ -30,13 +30,13 @@ import { Loader2, Stethoscope, Trash2, X } from "lucide-react";
|
|||
export const handle = {
|
||||
breadcrumb: (match: Route.MetaArgs) => [
|
||||
{ label: "Repositories", href: "/repositories" },
|
||||
{ label: match.params.name },
|
||||
{ label: match.loaderData?.name || match.params.id },
|
||||
],
|
||||
};
|
||||
|
||||
export function meta({ params }: Route.MetaArgs) {
|
||||
export function meta({ params, loaderData }: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: `Zerobyte - ${params.name}` },
|
||||
{ title: `Zerobyte - ${loaderData?.name || params.id}` },
|
||||
{
|
||||
name: "description",
|
||||
content: "View repository configuration, status, and snapshots.",
|
||||
|
|
@ -45,7 +45,7 @@ export function meta({ params }: Route.MetaArgs) {
|
|||
}
|
||||
|
||||
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
|
||||
const repository = await getRepository({ path: { name: params.name ?? "" } });
|
||||
const repository = await getRepository({ path: { id: params.id ?? "" } });
|
||||
if (repository.data) return repository.data;
|
||||
|
||||
return redirect("/repositories");
|
||||
|
|
@ -62,13 +62,13 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
|
|||
const activeTab = searchParams.get("tab") || "info";
|
||||
|
||||
const { data } = useQuery({
|
||||
...getRepositoryOptions({ path: { name: loaderData.name } }),
|
||||
...getRepositoryOptions({ path: { id: loaderData.id } }),
|
||||
initialData: loaderData,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
queryClient.prefetchQuery(listSnapshotsOptions({ path: { name: data.name } }));
|
||||
}, [queryClient, data.name]);
|
||||
queryClient.prefetchQuery(listSnapshotsOptions({ path: { id: data.id } }));
|
||||
}, [queryClient, data.id]);
|
||||
|
||||
const deleteRepo = useMutation({
|
||||
...deleteRepositoryMutation(),
|
||||
|
|
@ -108,7 +108,7 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
|
|||
|
||||
const handleConfirmDelete = () => {
|
||||
setShowDeleteConfirm(false);
|
||||
deleteRepo.mutate({ path: { name: data.name } });
|
||||
deleteRepo.mutate({ path: { id: data.id } });
|
||||
};
|
||||
|
||||
const getStepLabel = (step: string) => {
|
||||
|
|
@ -142,7 +142,7 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
|
|||
</div>
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
onClick={() => doctorMutation.mutate({ path: { name: data.name } })}
|
||||
onClick={() => doctorMutation.mutate({ path: { id: data.id } })}
|
||||
disabled={doctorMutation.isPending}
|
||||
variant={"outline"}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { redirect } from "react-router";
|
||||
import { getSnapshotDetails } from "~/client/api-client";
|
||||
import { getRepository, getSnapshotDetails } from "~/client/api-client";
|
||||
import { RestoreForm } from "~/client/components/restore-form";
|
||||
import type { Route } from "./+types/restore-snapshot";
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: (match: Route.MetaArgs) => [
|
||||
{ label: "Repositories", href: "/repositories" },
|
||||
{ label: match.params.name, href: `/repositories/${match.params.name}` },
|
||||
{ label: match.params.snapshotId, href: `/repositories/${match.params.name}/${match.params.snapshotId}` },
|
||||
{ label: match.loaderData?.repository.name || match.params.id, href: `/repositories/${match.params.id}` },
|
||||
{ label: match.params.snapshotId, href: `/repositories/${match.params.id}/${match.params.snapshotId}` },
|
||||
{ label: "Restore" },
|
||||
],
|
||||
};
|
||||
|
|
@ -24,22 +24,25 @@ export function meta({ params }: Route.MetaArgs) {
|
|||
|
||||
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
|
||||
const snapshot = await getSnapshotDetails({
|
||||
path: { name: params.name, snapshotId: params.snapshotId },
|
||||
path: { id: params.id, snapshotId: params.snapshotId },
|
||||
});
|
||||
if (snapshot.data) return { snapshot: snapshot.data, name: params.name, snapshotId: params.snapshotId };
|
||||
if (!snapshot.data) return redirect("/repositories");
|
||||
|
||||
return redirect("/repositories");
|
||||
const repository = await getRepository({ path: { id: params.id } });
|
||||
if (!repository.data) return redirect(`/repositories`);
|
||||
|
||||
return { snapshot: snapshot.data, id: params.id, repository: repository.data, snapshotId: params.snapshotId };
|
||||
};
|
||||
|
||||
export default function RestoreSnapshotPage({ loaderData }: Route.ComponentProps) {
|
||||
const { snapshot, name, snapshotId } = loaderData;
|
||||
const { snapshot, id, snapshotId, repository } = loaderData;
|
||||
|
||||
return (
|
||||
<RestoreForm
|
||||
snapshot={snapshot}
|
||||
repositoryName={name}
|
||||
repository={repository}
|
||||
snapshotId={snapshotId}
|
||||
returnPath={`/repositories/${name}/${snapshotId}`}
|
||||
returnPath={`/repositories/${id}/${snapshotId}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { redirect, useParams } from "react-router";
|
||||
import { listSnapshotFilesOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { redirect, useParams, Link, Await } from "react-router";
|
||||
import { listBackupSchedulesOptions, listSnapshotFilesOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
import { SnapshotFileBrowser } from "~/client/modules/backups/components/snapshot-file-browser";
|
||||
import { getSnapshotDetails } from "~/client/api-client";
|
||||
import { getRepository, getSnapshotDetails } from "~/client/api-client";
|
||||
import type { Route } from "./+types/snapshot-details";
|
||||
import { Suspense } from "react";
|
||||
|
||||
export const handle = {
|
||||
breadcrumb: (match: Route.MetaArgs) => [
|
||||
{ label: "Repositories", href: "/repositories" },
|
||||
{ label: match.params.name, href: `/repositories/${match.params.name}` },
|
||||
{ label: match.loaderData?.repository.name || match.params.id, href: `/repositories/${match.params.id}` },
|
||||
{ label: match.params.snapshotId },
|
||||
],
|
||||
};
|
||||
|
|
@ -25,29 +26,35 @@ export function meta({ params }: Route.MetaArgs) {
|
|||
}
|
||||
|
||||
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
|
||||
const snapshot = await getSnapshotDetails({
|
||||
path: { name: params.name, snapshotId: params.snapshotId },
|
||||
const snapshot = getSnapshotDetails({
|
||||
path: { id: params.id, snapshotId: params.snapshotId },
|
||||
});
|
||||
if (snapshot.data) return snapshot.data;
|
||||
|
||||
return redirect("/repositories");
|
||||
const repository = await getRepository({ path: { id: params.id } });
|
||||
if (!repository.data) return redirect("/repositories");
|
||||
|
||||
return { snapshot: snapshot, repository: repository.data };
|
||||
};
|
||||
|
||||
export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps) {
|
||||
const { name, snapshotId } = useParams<{
|
||||
name: string;
|
||||
const { id, snapshotId } = useParams<{
|
||||
id: string;
|
||||
snapshotId: string;
|
||||
}>();
|
||||
|
||||
const { data } = useQuery({
|
||||
...listSnapshotFilesOptions({
|
||||
path: { name: name ?? "", snapshotId: snapshotId ?? "" },
|
||||
path: { id: id ?? "", snapshotId: snapshotId ?? "" },
|
||||
query: { path: "/" },
|
||||
}),
|
||||
enabled: !!name && !!snapshotId,
|
||||
enabled: !!id && !!snapshotId,
|
||||
});
|
||||
|
||||
if (!name || !snapshotId) {
|
||||
const schedules = useQuery({
|
||||
...listBackupSchedulesOptions(),
|
||||
});
|
||||
|
||||
if (!id || !snapshotId) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-destructive">Invalid snapshot reference</p>
|
||||
|
|
@ -59,12 +66,27 @@ export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps
|
|||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{name}</h1>
|
||||
<h1 className="text-2xl font-bold">{loaderData.repository.name}</h1>
|
||||
<p className="text-sm text-muted-foreground">Snapshot: {snapshotId}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SnapshotFileBrowser repositoryName={name} snapshot={loaderData} />
|
||||
<Suspense
|
||||
fallback={
|
||||
<SnapshotFileBrowser
|
||||
repositoryId={id}
|
||||
snapshot={{ duration: 0, paths: [], short_id: "", size: 0, tags: [], time: 0 }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Await resolve={loaderData.snapshot}>
|
||||
{(value) => {
|
||||
if (!value.data) return <div className="text-destructive">Snapshot data not found.</div>;
|
||||
|
||||
return <SnapshotFileBrowser repositoryId={id} snapshot={value.data} />;
|
||||
}}
|
||||
</Await>
|
||||
</Suspense>
|
||||
|
||||
{data?.snapshot && (
|
||||
<Card>
|
||||
|
|
@ -89,6 +111,41 @@ export default function SnapshotDetailsPage({ loaderData }: Route.ComponentProps
|
|||
<span className="text-muted-foreground">Time:</span>
|
||||
<p>{new Date(data.snapshot.time).toLocaleString()}</p>
|
||||
</div>
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<Await resolve={loaderData.snapshot}>
|
||||
{(value) => {
|
||||
if (!value.data) return null;
|
||||
|
||||
const backupIds = value.data.tags.map(Number).filter((tag) => !Number.isNaN(tag));
|
||||
const backupSchedule = schedules.data?.find((s) => backupIds.includes(s.id));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Backup Schedule:</span>
|
||||
<p>
|
||||
<Link to={`/backups/${backupSchedule?.id}`} className="text-primary hover:underline">
|
||||
{backupSchedule?.name}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Volume:</span>
|
||||
<p>
|
||||
<Link
|
||||
to={`/volumes/${backupSchedule?.volume.name}`}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{backupSchedule?.volume.name}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Await>
|
||||
</Suspense>
|
||||
|
||||
<div className="col-span-2">
|
||||
<span className="text-muted-foreground">Paths:</span>
|
||||
<div className="space-y-1 mt-1">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useNavigate } from "react-router";
|
||||
import { Check, Save } from "lucide-react";
|
||||
import { Card } from "~/client/components/ui/card";
|
||||
import { Button } from "~/client/components/ui/button";
|
||||
|
|
@ -19,9 +18,7 @@ import {
|
|||
AlertDialogTitle,
|
||||
} from "~/client/components/ui/alert-dialog";
|
||||
import type { Repository } from "~/client/lib/types";
|
||||
import { slugify } from "~/client/lib/utils";
|
||||
import { updateRepositoryMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import type { UpdateRepositoryResponse } from "~/client/api-client/types.gen";
|
||||
import type { CompressionMode } from "~/schemas/restic";
|
||||
|
||||
type Props = {
|
||||
|
|
@ -29,22 +26,19 @@ type Props = {
|
|||
};
|
||||
|
||||
export const RepositoryInfoTabContent = ({ repository }: Props) => {
|
||||
const navigate = useNavigate();
|
||||
const [name, setName] = useState(repository.name);
|
||||
const [compressionMode, setCompressionMode] = useState<CompressionMode>(
|
||||
(repository.compressionMode as CompressionMode) || "off",
|
||||
);
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
|
||||
const isImportedLocal = repository.type === "local" && repository.config.isExistingRepository;
|
||||
|
||||
const updateMutation = useMutation({
|
||||
...updateRepositoryMutation(),
|
||||
onSuccess: (data: UpdateRepositoryResponse) => {
|
||||
onSuccess: () => {
|
||||
toast.success("Repository updated successfully");
|
||||
setShowConfirmDialog(false);
|
||||
|
||||
if (data.name !== repository.name) {
|
||||
navigate(`/repositories/${data.name}`);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to update repository", { description: error.message, richColors: true });
|
||||
|
|
@ -59,7 +53,7 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => {
|
|||
|
||||
const confirmUpdate = () => {
|
||||
updateMutation.mutate({
|
||||
path: { name: repository.name },
|
||||
path: { id: repository.id },
|
||||
body: { name, compressionMode },
|
||||
});
|
||||
};
|
||||
|
|
@ -79,12 +73,17 @@ export const RepositoryInfoTabContent = ({ repository }: Props) => {
|
|||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(slugify(e.target.value))}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Repository name"
|
||||
maxLength={32}
|
||||
minLength={2}
|
||||
disabled={isImportedLocal}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">Unique identifier for the repository.</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isImportedLocal
|
||||
? "Imported local repositories cannot be renamed."
|
||||
: "Unique identifier for the repository."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="compressionMode">Compression mode</Label>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
|
|||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const { data, isFetching, failureReason } = useQuery({
|
||||
...listSnapshotsOptions({ path: { name: repository.name } }),
|
||||
...listSnapshotsOptions({ path: { id: repository.id } }),
|
||||
initialData: [],
|
||||
});
|
||||
|
||||
|
|
@ -28,9 +28,15 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
|
|||
const filteredSnapshots = data.filter((snapshot: Snapshot) => {
|
||||
if (!searchQuery) return true;
|
||||
const searchLower = searchQuery.toLowerCase();
|
||||
|
||||
const backupIds = snapshot.tags.map(Number).filter((tag) => !Number.isNaN(tag));
|
||||
const backup = schedules.data?.find((b) => backupIds.includes(b.id));
|
||||
|
||||
return (
|
||||
snapshot.short_id.toLowerCase().includes(searchLower) ||
|
||||
snapshot.paths.some((path) => path.toLowerCase().includes(searchLower))
|
||||
snapshot.paths.some((path) => path.toLowerCase().includes(searchLower)) ||
|
||||
backup?.name?.toLowerCase().includes(searchLower) ||
|
||||
backup?.volume?.name?.toLowerCase().includes(searchLower)
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -137,7 +143,11 @@ export const RepositorySnapshotsTabContent = ({ repository }: Props) => {
|
|||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<SnapshotsTable snapshots={filteredSnapshots} repositoryName={repository.name} backups={schedules.data ?? []} />
|
||||
<SnapshotsTable
|
||||
snapshots={filteredSnapshots}
|
||||
repositoryId={repository.shortId}
|
||||
backups={schedules.data ?? []}
|
||||
/>
|
||||
)}
|
||||
<div className="px-4 py-2 text-sm text-muted-foreground bg-card-header flex justify-between border-t">
|
||||
<span>
|
||||
|
|
|
|||
|
|
@ -44,8 +44,8 @@ const defaultValuesForType = {
|
|||
directory: { backend: "directory" as const, path: "/" },
|
||||
nfs: { backend: "nfs" as const, port: 2049, version: "4.1" as const },
|
||||
smb: { backend: "smb" as const, port: 445, vers: "3.0" as const },
|
||||
webdav: { backend: "webdav" as const, port: 80, ssl: false },
|
||||
rclone: { backend: "rclone" as const },
|
||||
webdav: { backend: "webdav" as const, port: 80, ssl: false, path: "/webdav" },
|
||||
rclone: { backend: "rclone" as const, path: "/" },
|
||||
};
|
||||
|
||||
export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId, loading, className }: Props) => {
|
||||
|
|
|
|||
1
app/drizzle/0023_chemical_deathbird.sql
Normal file
1
app/drizzle/0023_chemical_deathbird.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
DROP INDEX `repositories_table_name_unique`;
|
||||
1
app/drizzle/0025_remarkable_pete_wisdom.sql
Normal file
1
app/drizzle/0025_remarkable_pete_wisdom.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
DROP INDEX IF EXISTS `repositories_table_name_unique`;
|
||||
832
app/drizzle/meta/0025_snapshot.json
Normal file
832
app/drizzle/meta/0025_snapshot.json
Normal file
|
|
@ -0,0 +1,832 @@
|
|||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "ca46a423-51ca-45ae-9470-f82172a67bd3",
|
||||
"prevId": "f19cb32f-2280-42dd-a86a-aba7c0409d9f",
|
||||
"tables": {
|
||||
"app_metadata": {
|
||||
"name": "app_metadata",
|
||||
"columns": {
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"backup_schedule_mirrors_table": {
|
||||
"name": "backup_schedule_mirrors_table",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"schedule_id": {
|
||||
"name": "schedule_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"repository_id": {
|
||||
"name": "repository_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"last_copy_at": {
|
||||
"name": "last_copy_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_copy_status": {
|
||||
"name": "last_copy_status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_copy_error": {
|
||||
"name": "last_copy_error",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"backup_schedule_mirrors_table_schedule_id_repository_id_unique": {
|
||||
"name": "backup_schedule_mirrors_table_schedule_id_repository_id_unique",
|
||||
"columns": [
|
||||
"schedule_id",
|
||||
"repository_id"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"backup_schedule_mirrors_table_schedule_id_backup_schedules_table_id_fk": {
|
||||
"name": "backup_schedule_mirrors_table_schedule_id_backup_schedules_table_id_fk",
|
||||
"tableFrom": "backup_schedule_mirrors_table",
|
||||
"tableTo": "backup_schedules_table",
|
||||
"columnsFrom": [
|
||||
"schedule_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"backup_schedule_mirrors_table_repository_id_repositories_table_id_fk": {
|
||||
"name": "backup_schedule_mirrors_table_repository_id_repositories_table_id_fk",
|
||||
"tableFrom": "backup_schedule_mirrors_table",
|
||||
"tableTo": "repositories_table",
|
||||
"columnsFrom": [
|
||||
"repository_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"backup_schedule_notifications_table": {
|
||||
"name": "backup_schedule_notifications_table",
|
||||
"columns": {
|
||||
"schedule_id": {
|
||||
"name": "schedule_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"destination_id": {
|
||||
"name": "destination_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"notify_on_start": {
|
||||
"name": "notify_on_start",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"notify_on_success": {
|
||||
"name": "notify_on_success",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"notify_on_warning": {
|
||||
"name": "notify_on_warning",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"notify_on_failure": {
|
||||
"name": "notify_on_failure",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"backup_schedule_notifications_table_schedule_id_backup_schedules_table_id_fk": {
|
||||
"name": "backup_schedule_notifications_table_schedule_id_backup_schedules_table_id_fk",
|
||||
"tableFrom": "backup_schedule_notifications_table",
|
||||
"tableTo": "backup_schedules_table",
|
||||
"columnsFrom": [
|
||||
"schedule_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"backup_schedule_notifications_table_destination_id_notification_destinations_table_id_fk": {
|
||||
"name": "backup_schedule_notifications_table_destination_id_notification_destinations_table_id_fk",
|
||||
"tableFrom": "backup_schedule_notifications_table",
|
||||
"tableTo": "notification_destinations_table",
|
||||
"columnsFrom": [
|
||||
"destination_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"backup_schedule_notifications_table_schedule_id_destination_id_pk": {
|
||||
"columns": [
|
||||
"schedule_id",
|
||||
"destination_id"
|
||||
],
|
||||
"name": "backup_schedule_notifications_table_schedule_id_destination_id_pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"backup_schedules_table": {
|
||||
"name": "backup_schedules_table",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"volume_id": {
|
||||
"name": "volume_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"repository_id": {
|
||||
"name": "repository_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"cron_expression": {
|
||||
"name": "cron_expression",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"retention_policy": {
|
||||
"name": "retention_policy",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"exclude_patterns": {
|
||||
"name": "exclude_patterns",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'[]'"
|
||||
},
|
||||
"exclude_if_present": {
|
||||
"name": "exclude_if_present",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'[]'"
|
||||
},
|
||||
"include_patterns": {
|
||||
"name": "include_patterns",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'[]'"
|
||||
},
|
||||
"last_backup_at": {
|
||||
"name": "last_backup_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_backup_status": {
|
||||
"name": "last_backup_status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_backup_error": {
|
||||
"name": "last_backup_error",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"next_backup_at": {
|
||||
"name": "next_backup_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"one_file_system": {
|
||||
"name": "one_file_system",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"sort_order": {
|
||||
"name": "sort_order",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"backup_schedules_table_name_unique": {
|
||||
"name": "backup_schedules_table_name_unique",
|
||||
"columns": [
|
||||
"name"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"backup_schedules_table_volume_id_volumes_table_id_fk": {
|
||||
"name": "backup_schedules_table_volume_id_volumes_table_id_fk",
|
||||
"tableFrom": "backup_schedules_table",
|
||||
"tableTo": "volumes_table",
|
||||
"columnsFrom": [
|
||||
"volume_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"backup_schedules_table_repository_id_repositories_table_id_fk": {
|
||||
"name": "backup_schedules_table_repository_id_repositories_table_id_fk",
|
||||
"tableFrom": "backup_schedules_table",
|
||||
"tableTo": "repositories_table",
|
||||
"columnsFrom": [
|
||||
"repository_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"notification_destinations_table": {
|
||||
"name": "notification_destinations_table",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"config": {
|
||||
"name": "config",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"notification_destinations_table_name_unique": {
|
||||
"name": "notification_destinations_table_name_unique",
|
||||
"columns": [
|
||||
"name"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"repositories_table": {
|
||||
"name": "repositories_table",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"short_id": {
|
||||
"name": "short_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"config": {
|
||||
"name": "config",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"compression_mode": {
|
||||
"name": "compression_mode",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'auto'"
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'unknown'"
|
||||
},
|
||||
"last_checked": {
|
||||
"name": "last_checked",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_error": {
|
||||
"name": "last_error",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"repositories_table_short_id_unique": {
|
||||
"name": "repositories_table_short_id_unique",
|
||||
"columns": [
|
||||
"short_id"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"sessions_table": {
|
||||
"name": "sessions_table",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"sessions_table_user_id_users_table_id_fk": {
|
||||
"name": "sessions_table_user_id_users_table_id_fk",
|
||||
"tableFrom": "sessions_table",
|
||||
"tableTo": "users_table",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"users_table": {
|
||||
"name": "users_table",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"has_downloaded_restic_password": {
|
||||
"name": "has_downloaded_restic_password",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"users_table_username_unique": {
|
||||
"name": "users_table_username_unique",
|
||||
"columns": [
|
||||
"username"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"volumes_table": {
|
||||
"name": "volumes_table",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"short_id": {
|
||||
"name": "short_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'unmounted'"
|
||||
},
|
||||
"last_error": {
|
||||
"name": "last_error",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_health_check": {
|
||||
"name": "last_health_check",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(unixepoch() * 1000)"
|
||||
},
|
||||
"config": {
|
||||
"name": "config",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"auto_remount": {
|
||||
"name": "auto_remount",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"volumes_table_short_id_unique": {
|
||||
"name": "volumes_table_short_id_unique",
|
||||
"columns": [
|
||||
"short_id"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"volumes_table_name_unique": {
|
||||
"name": "volumes_table_name_unique",
|
||||
"columns": [
|
||||
"name"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -176,6 +176,13 @@
|
|||
"when": 1766325504548,
|
||||
"tag": "0024_schedules-one-fs",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 25,
|
||||
"version": "6",
|
||||
"when": 1766431021321,
|
||||
"tag": "0025_remarkable_pete_wisdom",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -15,9 +15,9 @@ export default [
|
|||
route("backups/:id/:snapshotId/restore", "./client/modules/backups/routes/restore-snapshot.tsx"),
|
||||
route("repositories", "./client/modules/repositories/routes/repositories.tsx"),
|
||||
route("repositories/create", "./client/modules/repositories/routes/create-repository.tsx"),
|
||||
route("repositories/:name", "./client/modules/repositories/routes/repository-details.tsx"),
|
||||
route("repositories/:name/:snapshotId", "./client/modules/repositories/routes/snapshot-details.tsx"),
|
||||
route("repositories/:name/:snapshotId/restore", "./client/modules/repositories/routes/restore-snapshot.tsx"),
|
||||
route("repositories/:id", "./client/modules/repositories/routes/repository-details.tsx"),
|
||||
route("repositories/:id/:snapshotId", "./client/modules/repositories/routes/snapshot-details.tsx"),
|
||||
route("repositories/:id/:snapshotId/restore", "./client/modules/repositories/routes/restore-snapshot.tsx"),
|
||||
route("notifications", "./client/modules/notifications/routes/notifications.tsx"),
|
||||
route("notifications/create", "./client/modules/notifications/routes/create-notification.tsx"),
|
||||
route("notifications/:id", "./client/modules/notifications/routes/notification-details.tsx"),
|
||||
|
|
|
|||
|
|
@ -56,13 +56,6 @@ export const createApp = () => {
|
|||
)
|
||||
.get("healthcheck", (c) => c.json({ status: "ok" }))
|
||||
.route("/api/v1/auth", authController)
|
||||
.use("/api/v1/volumes/*", requireAuth)
|
||||
.use("/api/v1/repositories/*", requireAuth)
|
||||
.use("/api/v1/backups/*", requireAuth)
|
||||
.use("/api/v1/notifications/*", requireAuth)
|
||||
.use("/api/v1/system/*", requireAuth)
|
||||
.use("/api/v1/events/*", requireAuth)
|
||||
.use("/api/v1/config/*", requireAuth)
|
||||
.route("/api/v1/volumes", volumeController)
|
||||
.route("/api/v1/repositories", repositoriesController)
|
||||
.route("/api/v1/backups", backupScheduleController)
|
||||
|
|
|
|||
91
app/server/cli/commands/reset-password.ts
Normal file
91
app/server/cli/commands/reset-password.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { Command } from "commander";
|
||||
import { password, select } from "@inquirer/prompts";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../../db/db";
|
||||
import { sessionsTable, usersTable } from "../../db/schema";
|
||||
|
||||
const listUsers = () => {
|
||||
return db.select({ id: usersTable.id, username: usersTable.username }).from(usersTable);
|
||||
};
|
||||
|
||||
const resetPassword = async (username: string, newPassword: string) => {
|
||||
const [user] = await db.select().from(usersTable).where(eq(usersTable.username, username));
|
||||
|
||||
if (!user) {
|
||||
throw new Error(`User "${username}" not found`);
|
||||
}
|
||||
|
||||
const newPasswordHash = await Bun.password.hash(newPassword, {
|
||||
algorithm: "argon2id",
|
||||
memoryCost: 19456,
|
||||
timeCost: 2,
|
||||
});
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.update(usersTable).set({ passwordHash: newPasswordHash }).where(eq(usersTable.id, user.id));
|
||||
await tx.delete(sessionsTable).where(eq(sessionsTable.userId, user.id));
|
||||
});
|
||||
};
|
||||
|
||||
export const resetPasswordCommand = new Command("reset-password")
|
||||
.description("Reset password for a user")
|
||||
.option("-u, --username <username>", "Username of the account")
|
||||
.option("-p, --password <password>", "New password for the account")
|
||||
.action(async (options) => {
|
||||
console.log("\n🔐 Zerobyte Password Reset\n");
|
||||
|
||||
let username = options.username;
|
||||
let newPassword = options.password;
|
||||
|
||||
if (!username) {
|
||||
const users = await listUsers();
|
||||
|
||||
if (users.length === 0) {
|
||||
console.error("❌ No users found in the database.");
|
||||
console.log(" Please create a user first by starting the application.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
username = await select({
|
||||
message: "Select user to reset password for:",
|
||||
choices: users.map((u) => ({ name: u.username, value: u.username })),
|
||||
});
|
||||
}
|
||||
|
||||
if (!newPassword) {
|
||||
newPassword = await password({
|
||||
message: "Enter new password:",
|
||||
mask: "*",
|
||||
validate: (value) => {
|
||||
if (value.length < 8) {
|
||||
return "Password must be at least 8 characters long";
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
const confirmPassword = await password({
|
||||
message: "Confirm new password:",
|
||||
mask: "*",
|
||||
});
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
console.error("\n❌ Passwords do not match.");
|
||||
process.exit(1);
|
||||
}
|
||||
} else if (newPassword.length < 8) {
|
||||
console.error("\n❌ Password must be at least 8 characters long.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
await resetPassword(username, newPassword);
|
||||
console.log(`\n✅ Password for user "${username}" has been reset successfully.`);
|
||||
console.log(" All existing sessions have been invalidated.");
|
||||
} catch (error) {
|
||||
console.error(`\n❌ Failed to reset password: ${error instanceof Error ? error.message : "Unknown error"}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
});
|
||||
21
app/server/cli/index.ts
Normal file
21
app/server/cli/index.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { Command } from "commander";
|
||||
import { resetPasswordCommand } from "./commands/reset-password";
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program.name("zerobyte").description("Zerobyte CLI - Backup automation tool built on top of Restic").version("1.0.0");
|
||||
program.addCommand(resetPasswordCommand);
|
||||
|
||||
export async function runCLI(argv: string[]): Promise<boolean> {
|
||||
const args = argv.slice(2);
|
||||
const hasCommand = args.length > 0 && !args[0].startsWith("-");
|
||||
|
||||
if (!hasCommand) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await program.parseAsync(argv);
|
||||
return true;
|
||||
}
|
||||
|
||||
export { program };
|
||||
5
app/server/cli/main.ts
Normal file
5
app/server/cli/main.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
#!/usr/bin/env bun
|
||||
|
||||
import { program } from "./index";
|
||||
|
||||
await program.parseAsync(process.argv);
|
||||
|
|
@ -4,17 +4,20 @@ import "dotenv/config";
|
|||
const envSchema = type({
|
||||
NODE_ENV: type.enumerated("development", "production", "test").default("production"),
|
||||
SERVER_IP: 'string = "localhost"',
|
||||
SERVER_IDLE_TIMEOUT: 'string.integer.parse = "60"',
|
||||
}).pipe((s) => ({
|
||||
__prod__: s.NODE_ENV === "production",
|
||||
environment: s.NODE_ENV,
|
||||
serverIp: s.SERVER_IP,
|
||||
serverIdleTimeout: s.SERVER_IDLE_TIMEOUT,
|
||||
}));
|
||||
|
||||
const parseConfig = (env: unknown) => {
|
||||
const result = envSchema(env);
|
||||
|
||||
if (result instanceof type.errors) {
|
||||
throw new Error(`Invalid environment variables: ${result.toString()}`);
|
||||
console.error(`Environment variable validation failed: ${result.toString()}`);
|
||||
throw new Error("Invalid environment variables");
|
||||
}
|
||||
|
||||
return result;
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ export type Session = typeof sessionsTable.$inferSelect;
|
|||
export const repositoriesTable = sqliteTable("repositories_table", {
|
||||
id: text().primaryKey(),
|
||||
shortId: text("short_id").notNull().unique(),
|
||||
name: text().notNull().unique(),
|
||||
name: text().notNull(),
|
||||
type: text().$type<RepositoryBackend>().notNull(),
|
||||
config: text("config", { mode: "json" }).$type<typeof repositoryConfigSchema.inferOut>().notNull(),
|
||||
compressionMode: text("compression_mode").$type<CompressionMode>().default("auto"),
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { shutdown } from "./modules/lifecycle/shutdown";
|
|||
import { REQUIRED_MIGRATIONS } from "./core/constants";
|
||||
import { validateRequiredMigrations } from "./modules/lifecycle/checkpoint";
|
||||
import { createApp } from "./app";
|
||||
import { config } from "./core/config";
|
||||
|
||||
const app = createApp();
|
||||
|
||||
|
|
@ -33,4 +34,13 @@ process.on("SIGINT", async () => {
|
|||
process.exit(0);
|
||||
});
|
||||
|
||||
export default await createHonoServer({ app, port: 4096 });
|
||||
export default await createHonoServer({
|
||||
app,
|
||||
port: 4096,
|
||||
customBunServer: {
|
||||
idleTimeout: config.serverIdleTimeout,
|
||||
error(err) {
|
||||
logger.error(`[Bun.serve] Server error: ${err.message}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -41,8 +41,10 @@ import {
|
|||
type UpdateScheduleNotificationsDto,
|
||||
} from "../notifications/notifications.dto";
|
||||
import { notificationsService } from "../notifications/notifications.service";
|
||||
import { requireAuth } from "../auth/auth.middleware";
|
||||
|
||||
export const backupScheduleController = new Hono()
|
||||
.use(requireAuth)
|
||||
.get("/", listBackupSchedulesDto, async (c) => {
|
||||
const schedules = await backupsService.listSchedules();
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@ import { Hono } from "hono";
|
|||
import { streamSSE } from "hono/streaming";
|
||||
import { logger } from "../../utils/logger";
|
||||
import { serverEvents } from "../../core/events";
|
||||
import { requireAuth } from "../auth/auth.middleware";
|
||||
|
||||
export const eventsController = new Hono().get("/", (c) => {
|
||||
export const eventsController = new Hono().use(requireAuth).get("/", (c) => {
|
||||
logger.info("Client connected to SSE endpoint");
|
||||
|
||||
return streamSSE(c, async (stream) => {
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ const ensureLatestConfigurationSchema = async () => {
|
|||
const repositories = await db.query.repositoriesTable.findMany({});
|
||||
|
||||
for (const repo of repositories) {
|
||||
await repositoriesService.updateRepository(repo.name, {}).catch((err) => {
|
||||
await repositoriesService.updateRepository(repo.id, {}).catch((err) => {
|
||||
logger.error(`Failed to update repository ${repo.name}: ${err}`);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,8 +17,10 @@ import {
|
|||
type UpdateDestinationDto,
|
||||
} from "./notifications.dto";
|
||||
import { notificationsService } from "./notifications.service";
|
||||
import { requireAuth } from "../auth/auth.middleware";
|
||||
|
||||
export const notificationsController = new Hono()
|
||||
.use(requireAuth)
|
||||
.get("/destinations", listDestinationsDto, async (c) => {
|
||||
const destinations = await notificationsService.listDestinations();
|
||||
return c.json<ListDestinationsDto>(destinations, 200);
|
||||
|
|
|
|||
|
|
@ -31,8 +31,10 @@ import {
|
|||
} from "./repositories.dto";
|
||||
import { repositoriesService } from "./repositories.service";
|
||||
import { getRcloneRemoteInfo, listRcloneRemotes } from "../../utils/rclone";
|
||||
import { requireAuth } from "../auth/auth.middleware";
|
||||
|
||||
export const repositoriesController = new Hono()
|
||||
.use(requireAuth)
|
||||
.get("/", listRepositoriesDto, async (c) => {
|
||||
const repositories = await repositoriesService.listRepositories();
|
||||
|
||||
|
|
@ -59,23 +61,23 @@ export const repositoriesController = new Hono()
|
|||
|
||||
return c.json(remotes);
|
||||
})
|
||||
.get("/:name", getRepositoryDto, async (c) => {
|
||||
const { name } = c.req.param();
|
||||
const res = await repositoriesService.getRepository(name);
|
||||
.get("/:id", getRepositoryDto, async (c) => {
|
||||
const { id } = c.req.param();
|
||||
const res = await repositoriesService.getRepository(id);
|
||||
|
||||
return c.json<GetRepositoryDto>(res.repository, 200);
|
||||
})
|
||||
.delete("/:name", deleteRepositoryDto, async (c) => {
|
||||
const { name } = c.req.param();
|
||||
await repositoriesService.deleteRepository(name);
|
||||
.delete("/:id", deleteRepositoryDto, async (c) => {
|
||||
const { id } = c.req.param();
|
||||
await repositoriesService.deleteRepository(id);
|
||||
|
||||
return c.json<DeleteRepositoryDto>({ message: "Repository deleted" }, 200);
|
||||
})
|
||||
.get("/:name/snapshots", listSnapshotsDto, validator("query", listSnapshotsFilters), async (c) => {
|
||||
const { name } = c.req.param();
|
||||
.get("/:id/snapshots", listSnapshotsDto, validator("query", listSnapshotsFilters), async (c) => {
|
||||
const { id } = c.req.param();
|
||||
const { backupId } = c.req.valid("query");
|
||||
|
||||
const res = await repositoriesService.listSnapshots(name, backupId);
|
||||
const res = await repositoriesService.listSnapshots(id, backupId);
|
||||
|
||||
const snapshots = res.map((snapshot) => {
|
||||
const { summary } = snapshot;
|
||||
|
|
@ -98,9 +100,9 @@ export const repositoriesController = new Hono()
|
|||
|
||||
return c.json<ListSnapshotsDto>(snapshots, 200);
|
||||
})
|
||||
.get("/:name/snapshots/:snapshotId", getSnapshotDetailsDto, async (c) => {
|
||||
const { name, snapshotId } = c.req.param();
|
||||
const snapshot = await repositoriesService.getSnapshotDetails(name, snapshotId);
|
||||
.get("/:id/snapshots/:snapshotId", getSnapshotDetailsDto, async (c) => {
|
||||
const { id, snapshotId } = c.req.param();
|
||||
const snapshot = await repositoriesService.getSnapshotDetails(id, snapshotId);
|
||||
|
||||
let duration = 0;
|
||||
if (snapshot.summary) {
|
||||
|
|
@ -121,48 +123,48 @@ export const repositoriesController = new Hono()
|
|||
return c.json<GetSnapshotDetailsDto>(response, 200);
|
||||
})
|
||||
.get(
|
||||
"/:name/snapshots/:snapshotId/files",
|
||||
"/:id/snapshots/:snapshotId/files",
|
||||
listSnapshotFilesDto,
|
||||
validator("query", listSnapshotFilesQuery),
|
||||
async (c) => {
|
||||
const { name, snapshotId } = c.req.param();
|
||||
const { id, snapshotId } = c.req.param();
|
||||
const { path } = c.req.valid("query");
|
||||
|
||||
const decodedPath = path ? decodeURIComponent(path) : undefined;
|
||||
const result = await repositoriesService.listSnapshotFiles(name, snapshotId, decodedPath);
|
||||
const result = await repositoriesService.listSnapshotFiles(id, snapshotId, decodedPath);
|
||||
|
||||
c.header("Cache-Control", "max-age=300, stale-while-revalidate=600");
|
||||
|
||||
return c.json<ListSnapshotFilesDto>(result, 200);
|
||||
},
|
||||
)
|
||||
.post("/:name/restore", restoreSnapshotDto, validator("json", restoreSnapshotBody), async (c) => {
|
||||
const { name } = c.req.param();
|
||||
.post("/:id/restore", restoreSnapshotDto, validator("json", restoreSnapshotBody), async (c) => {
|
||||
const { id } = c.req.param();
|
||||
const { snapshotId, ...options } = c.req.valid("json");
|
||||
|
||||
const result = await repositoriesService.restoreSnapshot(name, snapshotId, options);
|
||||
const result = await repositoriesService.restoreSnapshot(id, snapshotId, options);
|
||||
|
||||
return c.json<RestoreSnapshotDto>(result, 200);
|
||||
})
|
||||
.post("/:name/doctor", doctorRepositoryDto, async (c) => {
|
||||
const { name } = c.req.param();
|
||||
.post("/:id/doctor", doctorRepositoryDto, async (c) => {
|
||||
const { id } = c.req.param();
|
||||
|
||||
const result = await repositoriesService.doctorRepository(name);
|
||||
const result = await repositoriesService.doctorRepository(id);
|
||||
|
||||
return c.json<DoctorRepositoryDto>(result, 200);
|
||||
})
|
||||
.delete("/:name/snapshots/:snapshotId", deleteSnapshotDto, async (c) => {
|
||||
const { name, snapshotId } = c.req.param();
|
||||
.delete("/:id/snapshots/:snapshotId", deleteSnapshotDto, async (c) => {
|
||||
const { id, snapshotId } = c.req.param();
|
||||
|
||||
await repositoriesService.deleteSnapshot(name, snapshotId);
|
||||
await repositoriesService.deleteSnapshot(id, snapshotId);
|
||||
|
||||
return c.json<DeleteSnapshotDto>({ message: "Snapshot deleted" }, 200);
|
||||
})
|
||||
.patch("/:name", updateRepositoryDto, validator("json", updateRepositoryBody), async (c) => {
|
||||
const { name } = c.req.param();
|
||||
.patch("/:id", updateRepositoryDto, validator("json", updateRepositoryBody), async (c) => {
|
||||
const { id } = c.req.param();
|
||||
const body = c.req.valid("json");
|
||||
|
||||
const res = await repositoriesService.updateRepository(name, body);
|
||||
const res = await repositoriesService.updateRepository(id, body);
|
||||
|
||||
return c.json<UpdateRepositoryDto>(res.repository, 200);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ export const createRepositoryResponse = type({
|
|||
message: "string",
|
||||
repository: type({
|
||||
id: "string",
|
||||
shortId: "string",
|
||||
name: "string",
|
||||
}),
|
||||
});
|
||||
|
|
@ -90,7 +91,7 @@ export const getRepositoryResponse = repositorySchema;
|
|||
export type GetRepositoryDto = typeof getRepositoryResponse.infer;
|
||||
|
||||
export const getRepositoryDto = describeRoute({
|
||||
description: "Get a single repository by name",
|
||||
description: "Get a single repository by ID",
|
||||
tags: ["Repositories"],
|
||||
operationId: "getRepository",
|
||||
responses: {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import crypto from "node:crypto";
|
||||
import { and, eq, ne } from "drizzle-orm";
|
||||
import { eq, or } from "drizzle-orm";
|
||||
import { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced";
|
||||
import slugify from "slugify";
|
||||
import { db } from "../../db/db";
|
||||
import { repositoriesTable } from "../../db/schema";
|
||||
import { toMessage } from "../../utils/errors";
|
||||
|
|
@ -17,6 +16,12 @@ import {
|
|||
} from "~/schemas/restic";
|
||||
import { type } from "arktype";
|
||||
|
||||
const findRepository = async (idOrShortId: string) => {
|
||||
return await db.query.repositoriesTable.findFirst({
|
||||
where: or(eq(repositoriesTable.id, idOrShortId), eq(repositoriesTable.shortId, idOrShortId)),
|
||||
});
|
||||
};
|
||||
|
||||
const listRepositories = async () => {
|
||||
const repositories = await db.query.repositoriesTable.findMany({});
|
||||
return repositories;
|
||||
|
|
@ -58,21 +63,11 @@ const encryptConfig = async (config: RepositoryConfig): Promise<RepositoryConfig
|
|||
};
|
||||
|
||||
const createRepository = async (name: string, config: RepositoryConfig, compressionMode?: CompressionMode) => {
|
||||
const slug = slugify(name, { lower: true, strict: true });
|
||||
|
||||
const existing = await db.query.repositoriesTable.findFirst({
|
||||
where: eq(repositoriesTable.name, slug),
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictError("Repository with this name already exists");
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const shortId = generateShortId();
|
||||
|
||||
let processedConfig = config;
|
||||
if (config.backend === "local") {
|
||||
if (config.backend === "local" && !config.isExistingRepository) {
|
||||
processedConfig = { ...config, name: shortId };
|
||||
}
|
||||
|
||||
|
|
@ -83,7 +78,7 @@ const createRepository = async (name: string, config: RepositoryConfig, compress
|
|||
.values({
|
||||
id,
|
||||
shortId,
|
||||
name: slug,
|
||||
name: name.trim(),
|
||||
type: config.backend,
|
||||
config: encryptedConfig,
|
||||
compressionMode: compressionMode ?? "auto",
|
||||
|
|
@ -124,10 +119,8 @@ const createRepository = async (name: string, config: RepositoryConfig, compress
|
|||
throw new InternalServerError(`Failed to initialize repository: ${errorMessage}`);
|
||||
};
|
||||
|
||||
const getRepository = async (name: string) => {
|
||||
const repository = await db.query.repositoriesTable.findFirst({
|
||||
where: eq(repositoriesTable.name, name),
|
||||
});
|
||||
const getRepository = async (id: string) => {
|
||||
const repository = await findRepository(id);
|
||||
|
||||
if (!repository) {
|
||||
throw new NotFoundError("Repository not found");
|
||||
|
|
@ -136,10 +129,8 @@ const getRepository = async (name: string) => {
|
|||
return { repository };
|
||||
};
|
||||
|
||||
const deleteRepository = async (name: string) => {
|
||||
const repository = await db.query.repositoriesTable.findFirst({
|
||||
where: eq(repositoriesTable.name, name),
|
||||
});
|
||||
const deleteRepository = async (id: string) => {
|
||||
const repository = await findRepository(id);
|
||||
|
||||
if (!repository) {
|
||||
throw new NotFoundError("Repository not found");
|
||||
|
|
@ -147,21 +138,19 @@ const deleteRepository = async (name: string) => {
|
|||
|
||||
// TODO: Add cleanup logic for the actual restic repository files
|
||||
|
||||
await db.delete(repositoriesTable).where(eq(repositoriesTable.name, name));
|
||||
await db.delete(repositoriesTable).where(eq(repositoriesTable.id, repository.id));
|
||||
};
|
||||
|
||||
/**
|
||||
* List snapshots for a given repository
|
||||
* If backupId is provided, filter snapshots by that backup ID (tag)
|
||||
* @param name Repository name
|
||||
* @param id Repository ID
|
||||
* @param backupId Optional backup ID to filter snapshots for a specific backup schedule
|
||||
*
|
||||
* @returns List of snapshots
|
||||
*/
|
||||
const listSnapshots = async (name: string, backupId?: string) => {
|
||||
const repository = await db.query.repositoriesTable.findFirst({
|
||||
where: eq(repositoriesTable.name, name),
|
||||
});
|
||||
const listSnapshots = async (id: string, backupId?: string) => {
|
||||
const repository = await findRepository(id);
|
||||
|
||||
if (!repository) {
|
||||
throw new NotFoundError("Repository not found");
|
||||
|
|
@ -183,10 +172,8 @@ const listSnapshots = async (name: string, backupId?: string) => {
|
|||
}
|
||||
};
|
||||
|
||||
const listSnapshotFiles = async (name: string, snapshotId: string, path?: string) => {
|
||||
const repository = await db.query.repositoriesTable.findFirst({
|
||||
where: eq(repositoriesTable.name, name),
|
||||
});
|
||||
const listSnapshotFiles = async (id: string, snapshotId: string, path?: string) => {
|
||||
const repository = await findRepository(id);
|
||||
|
||||
if (!repository) {
|
||||
throw new NotFoundError("Repository not found");
|
||||
|
|
@ -216,7 +203,7 @@ const listSnapshotFiles = async (name: string, snapshotId: string, path?: string
|
|||
};
|
||||
|
||||
const restoreSnapshot = async (
|
||||
name: string,
|
||||
id: string,
|
||||
snapshotId: string,
|
||||
options?: {
|
||||
include?: string[];
|
||||
|
|
@ -227,9 +214,7 @@ const restoreSnapshot = async (
|
|||
overwrite?: OverwriteMode;
|
||||
},
|
||||
) => {
|
||||
const repository = await db.query.repositoriesTable.findFirst({
|
||||
where: eq(repositoriesTable.name, name),
|
||||
});
|
||||
const repository = await findRepository(id);
|
||||
|
||||
if (!repository) {
|
||||
throw new NotFoundError("Repository not found");
|
||||
|
|
@ -252,10 +237,8 @@ const restoreSnapshot = async (
|
|||
}
|
||||
};
|
||||
|
||||
const getSnapshotDetails = async (name: string, snapshotId: string) => {
|
||||
const repository = await db.query.repositoriesTable.findFirst({
|
||||
where: eq(repositoriesTable.name, name),
|
||||
});
|
||||
const getSnapshotDetails = async (id: string, snapshotId: string) => {
|
||||
const repository = await findRepository(id);
|
||||
|
||||
if (!repository) {
|
||||
throw new NotFoundError("Repository not found");
|
||||
|
|
@ -277,9 +260,7 @@ const getSnapshotDetails = async (name: string, snapshotId: string) => {
|
|||
};
|
||||
|
||||
const checkHealth = async (repositoryId: string) => {
|
||||
const repository = await db.query.repositoriesTable.findFirst({
|
||||
where: eq(repositoriesTable.id, repositoryId),
|
||||
});
|
||||
const repository = await findRepository(repositoryId);
|
||||
|
||||
if (!repository) {
|
||||
throw new NotFoundError("Repository not found");
|
||||
|
|
@ -304,10 +285,8 @@ const checkHealth = async (repositoryId: string) => {
|
|||
}
|
||||
};
|
||||
|
||||
const doctorRepository = async (name: string) => {
|
||||
const repository = await db.query.repositoriesTable.findFirst({
|
||||
where: eq(repositoriesTable.name, name),
|
||||
});
|
||||
const doctorRepository = async (id: string) => {
|
||||
const repository = await findRepository(id);
|
||||
|
||||
if (!repository) {
|
||||
throw new NotFoundError("Repository not found");
|
||||
|
|
@ -395,10 +374,8 @@ const doctorRepository = async (name: string) => {
|
|||
};
|
||||
};
|
||||
|
||||
const deleteSnapshot = async (name: string, snapshotId: string) => {
|
||||
const repository = await db.query.repositoriesTable.findFirst({
|
||||
where: eq(repositoriesTable.name, name),
|
||||
});
|
||||
const deleteSnapshot = async (id: string, snapshotId: string) => {
|
||||
const repository = await findRepository(id);
|
||||
|
||||
if (!repository) {
|
||||
throw new NotFoundError("Repository not found");
|
||||
|
|
@ -412,10 +389,8 @@ const deleteSnapshot = async (name: string, snapshotId: string) => {
|
|||
}
|
||||
};
|
||||
|
||||
const updateRepository = async (name: string, updates: { name?: string; compressionMode?: CompressionMode }) => {
|
||||
const existing = await db.query.repositoriesTable.findFirst({
|
||||
where: eq(repositoriesTable.name, name),
|
||||
});
|
||||
const updateRepository = async (id: string, updates: { name?: string; compressionMode?: CompressionMode }) => {
|
||||
const existing = await findRepository(id);
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundError("Repository not found");
|
||||
|
|
@ -439,17 +414,7 @@ const updateRepository = async (name: string, updates: { name?: string; compress
|
|||
|
||||
let newName = existing.name;
|
||||
if (updates.name !== undefined && updates.name !== existing.name) {
|
||||
const newSlug = slugify(updates.name, { lower: true, strict: true });
|
||||
|
||||
const conflict = await db.query.repositoriesTable.findFirst({
|
||||
where: and(eq(repositoriesTable.name, newSlug), ne(repositoriesTable.id, existing.id)),
|
||||
});
|
||||
|
||||
if (conflict) {
|
||||
throw new ConflictError("A repository with this name already exists");
|
||||
}
|
||||
|
||||
newName = newSlug;
|
||||
newName = updates.name.trim();
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { usersTable } from "../../db/schema";
|
|||
import { eq } from "drizzle-orm";
|
||||
|
||||
export const systemController = new Hono()
|
||||
.use(requireAuth)
|
||||
.get("/info", systemInfoDto, async (c) => {
|
||||
const info = await systemService.getSystemInfo();
|
||||
|
||||
|
|
@ -22,7 +23,6 @@ export const systemController = new Hono()
|
|||
.post(
|
||||
"/restic-password",
|
||||
downloadResticPasswordDto,
|
||||
requireAuth,
|
||||
validator("json", downloadResticPasswordBodySchema),
|
||||
async (c) => {
|
||||
const user = c.get("user");
|
||||
|
|
|
|||
|
|
@ -24,8 +24,10 @@ import {
|
|||
} from "./volume.dto";
|
||||
import { volumeService } from "./volume.service";
|
||||
import { getVolumePath } from "./helpers";
|
||||
import { requireAuth } from "../auth/auth.middleware";
|
||||
|
||||
export const volumeController = new Hono()
|
||||
.use(requireAuth)
|
||||
.get("/", listVolumesDto, async (c) => {
|
||||
const volumes = await volumeService.listVolumes();
|
||||
|
||||
|
|
|
|||
|
|
@ -2,5 +2,5 @@ import crypto from "node:crypto";
|
|||
|
||||
export const generateShortId = (length = 5): string => {
|
||||
const bytesNeeded = Math.ceil((length * 3) / 4);
|
||||
return crypto.randomBytes(bytesNeeded).toString("base64url").slice(0, length);
|
||||
return crypto.randomBytes(bytesNeeded).toString("base64url").slice(0, length).toLowerCase();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,7 +5,12 @@ export const getCronExpression = (
|
|||
dailyTime?: string,
|
||||
weeklyDay?: string,
|
||||
monthlyDays?: string[],
|
||||
cronExpression?: string,
|
||||
): string => {
|
||||
if (frequency === "cron" && cronExpression) {
|
||||
return cronExpression;
|
||||
}
|
||||
|
||||
if (frequency === "hourly") {
|
||||
return "0 * * * *";
|
||||
}
|
||||
|
|
|
|||
112
bun.lock
112
bun.lock
|
|
@ -10,6 +10,7 @@
|
|||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hono/standard-validator": "^0.2.0",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@inquirer/prompts": "^8.0.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
|
|
@ -29,6 +30,7 @@
|
|||
"arktype": "^2.1.28",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"commander": "^14.0.2",
|
||||
"cron-parser": "^5.4.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"dither-plugin": "^1.1.1",
|
||||
|
|
@ -258,6 +260,38 @@
|
|||
|
||||
"@hookform/resolvers": ["@hookform/resolvers@5.2.2", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="],
|
||||
|
||||
"@inquirer/ansi": ["@inquirer/ansi@2.0.2", "", {}, "sha512-SYLX05PwJVnW+WVegZt1T4Ip1qba1ik+pNJPDiqvk6zS5Y/i8PhRzLpGEtVd7sW0G8cMtkD8t4AZYhQwm8vnww=="],
|
||||
|
||||
"@inquirer/checkbox": ["@inquirer/checkbox@5.0.3", "", { "dependencies": { "@inquirer/ansi": "^2.0.2", "@inquirer/core": "^11.1.0", "@inquirer/figures": "^2.0.2", "@inquirer/type": "^4.0.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-xtQP2eXMFlOcAhZ4ReKP2KZvDIBb1AnCfZ81wWXG3DXLVH0f0g4obE0XDPH+ukAEMRcZT0kdX2AS1jrWGXbpxw=="],
|
||||
|
||||
"@inquirer/confirm": ["@inquirer/confirm@6.0.3", "", { "dependencies": { "@inquirer/core": "^11.1.0", "@inquirer/type": "^4.0.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-lyEvibDFL+NA5R4xl8FUmNhmu81B+LDL9L/MpKkZlQDJZXzG8InxiqYxiAlQYa9cqLLhYqKLQwZqXmSTqCLjyw=="],
|
||||
|
||||
"@inquirer/core": ["@inquirer/core@11.1.0", "", { "dependencies": { "@inquirer/ansi": "^2.0.2", "@inquirer/figures": "^2.0.2", "@inquirer/type": "^4.0.2", "cli-width": "^4.1.0", "mute-stream": "^3.0.0", "signal-exit": "^4.1.0", "wrap-ansi": "^9.0.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-+jD/34T1pK8M5QmZD/ENhOfXdl9Zr+BrQAUc5h2anWgi7gggRq15ZbiBeLoObj0TLbdgW7TAIQRU2boMc9uOKQ=="],
|
||||
|
||||
"@inquirer/editor": ["@inquirer/editor@5.0.3", "", { "dependencies": { "@inquirer/core": "^11.1.0", "@inquirer/external-editor": "^2.0.2", "@inquirer/type": "^4.0.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-wYyQo96TsAqIciP/r5D3cFeV8h4WqKQ/YOvTg5yOfP2sqEbVVpbxPpfV3LM5D0EP4zUI3EZVHyIUIllnoIa8OQ=="],
|
||||
|
||||
"@inquirer/expand": ["@inquirer/expand@5.0.3", "", { "dependencies": { "@inquirer/core": "^11.1.0", "@inquirer/type": "^4.0.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-2oINvuL27ujjxd95f6K2K909uZOU2x1WiAl7Wb1X/xOtL8CgQ1kSxzykIr7u4xTkXkXOAkCuF45T588/YKee7w=="],
|
||||
|
||||
"@inquirer/external-editor": ["@inquirer/external-editor@2.0.2", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-X/fMXK7vXomRWEex1j8mnj7s1mpnTeP4CO/h2gysJhHLT2WjBnLv4ZQEGpm/kcYI8QfLZ2fgW+9kTKD+jeopLg=="],
|
||||
|
||||
"@inquirer/figures": ["@inquirer/figures@2.0.2", "", {}, "sha512-qXm6EVvQx/FmnSrCWCIGtMHwqeLgxABP8XgcaAoywsL0NFga9gD5kfG0gXiv80GjK9Hsoz4pgGwF/+CjygyV9A=="],
|
||||
|
||||
"@inquirer/input": ["@inquirer/input@5.0.3", "", { "dependencies": { "@inquirer/core": "^11.1.0", "@inquirer/type": "^4.0.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-4R0TdWl53dtp79Vs6Df2OHAtA2FVNqya1hND1f5wjHWxZJxwDMSNB1X5ADZJSsQKYAJ5JHCTO+GpJZ42mK0Otw=="],
|
||||
|
||||
"@inquirer/number": ["@inquirer/number@4.0.3", "", { "dependencies": { "@inquirer/core": "^11.1.0", "@inquirer/type": "^4.0.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-TjQLe93GGo5snRlu83JxE38ZPqj5ZVggL+QqqAF2oBA5JOJoxx25GG3EGH/XN/Os5WOmKfO8iLVdCXQxXRZIMQ=="],
|
||||
|
||||
"@inquirer/password": ["@inquirer/password@5.0.3", "", { "dependencies": { "@inquirer/ansi": "^2.0.2", "@inquirer/core": "^11.1.0", "@inquirer/type": "^4.0.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-rCozGbUMAHedTeYWEN8sgZH4lRCdgG/WinFkit6ZPsp8JaNg2T0g3QslPBS5XbpORyKP/I+xyBO81kFEvhBmjA=="],
|
||||
|
||||
"@inquirer/prompts": ["@inquirer/prompts@8.1.0", "", { "dependencies": { "@inquirer/checkbox": "^5.0.3", "@inquirer/confirm": "^6.0.3", "@inquirer/editor": "^5.0.3", "@inquirer/expand": "^5.0.3", "@inquirer/input": "^5.0.3", "@inquirer/number": "^4.0.3", "@inquirer/password": "^5.0.3", "@inquirer/rawlist": "^5.1.0", "@inquirer/search": "^4.0.3", "@inquirer/select": "^5.0.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-LsZMdKcmRNF5LyTRuZE5nWeOjganzmN3zwbtNfcs6GPh3I2TsTtF1UYZlbxVfhxd+EuUqLGs/Lm3Xt4v6Az1wA=="],
|
||||
|
||||
"@inquirer/rawlist": ["@inquirer/rawlist@5.1.0", "", { "dependencies": { "@inquirer/core": "^11.1.0", "@inquirer/type": "^4.0.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-yUCuVh0jW026Gr2tZlG3kHignxcrLKDR3KBp+eUgNz+BAdSeZk0e18yt2gyBr+giYhj/WSIHCmPDOgp1mT2niQ=="],
|
||||
|
||||
"@inquirer/search": ["@inquirer/search@4.0.3", "", { "dependencies": { "@inquirer/core": "^11.1.0", "@inquirer/figures": "^2.0.2", "@inquirer/type": "^4.0.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-lzqVw0YwuKYetk5VwJ81Ba+dyVlhseHPx9YnRKQgwXdFS0kEavCz2gngnNhnMIxg8+j1N/rUl1t5s1npwa7bqg=="],
|
||||
|
||||
"@inquirer/select": ["@inquirer/select@5.0.3", "", { "dependencies": { "@inquirer/ansi": "^2.0.2", "@inquirer/core": "^11.1.0", "@inquirer/figures": "^2.0.2", "@inquirer/type": "^4.0.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-M+ynbwS0ecQFDYMFrQrybA0qL8DV0snpc4kKevCCNaTpfghsRowRY7SlQBeIYNzHqXtiiz4RG9vTOeb/udew7w=="],
|
||||
|
||||
"@inquirer/type": ["@inquirer/type@4.0.2", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-cae7mzluplsjSdgFA6ACLygb5jC8alO0UUnFPyu0E7tNRPrL+q/f8VcSXp+cjZQ7l5CMpDpi2G1+IQvkOiL1Lw=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
|
@ -360,49 +394,49 @@
|
|||
|
||||
"@remix-run/node-fetch-server": ["@remix-run/node-fetch-server@0.9.0", "", {}, "sha512-SoLMv7dbH+njWzXnOY6fI08dFMI5+/dQ+vY3n8RnnbdG7MdJEgiP28Xj/xWlnRnED/aB6SFw56Zop+LbmaaKqA=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.53.5", "", { "os": "android", "cpu": "arm" }, "sha512-iDGS/h7D8t7tvZ1t6+WPK04KD0MwzLZrG0se1hzBjSi5fyxlsiggoJHwh18PCFNn7tG43OWb6pdZ6Y+rMlmyNQ=="],
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.54.0", "", { "os": "android", "cpu": "arm" }, "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.53.5", "", { "os": "android", "cpu": "arm64" }, "sha512-wrSAViWvZHBMMlWk6EJhvg8/rjxzyEhEdgfMMjREHEq11EtJ6IP6yfcCH57YAEca2Oe3FNCE9DSTgU70EIGmVw=="],
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.54.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.53.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-S87zZPBmRO6u1YXQLwpveZm4JfPpAa6oHBX7/ghSiGH3rz/KDgAu1rKdGutV+WUI6tKDMbaBJomhnT30Y2t4VQ=="],
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.54.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.53.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-YTbnsAaHo6VrAczISxgpTva8EkfQus0VPEVJCEaboHtZRIb6h6j0BNxRBOwnDciFTZLDPW5r+ZBmhL/+YpTZgA=="],
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.54.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.53.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-1T8eY2J8rKJWzaznV7zedfdhD1BqVs1iqILhmHDq/bqCUZsrMt+j8VCTHhP0vdfbHK3e1IQ7VYx3jlKqwlf+vw=="],
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.54.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.53.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-sHTiuXyBJApxRn+VFMaw1U+Qsz4kcNlxQ742snICYPrY+DDL8/ZbaC4DVIB7vgZmp3jiDaKA0WpBdP0aqPJoBQ=="],
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.54.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.53.5", "", { "os": "linux", "cpu": "arm" }, "sha512-dV3T9MyAf0w8zPVLVBptVlzaXxka6xg1f16VAQmjg+4KMSTWDvhimI/Y6mp8oHwNrmnmVl9XxJ/w/mO4uIQONA=="],
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.54.0", "", { "os": "linux", "cpu": "arm" }, "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.53.5", "", { "os": "linux", "cpu": "arm" }, "sha512-wIGYC1x/hyjP+KAu9+ewDI+fi5XSNiUi9Bvg6KGAh2TsNMA3tSEs+Sh6jJ/r4BV/bx/CyWu2ue9kDnIdRyafcQ=="],
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.54.0", "", { "os": "linux", "cpu": "arm" }, "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.53.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y+qVA0D9d0y2FRNiG9oM3Hut/DgODZbU9I8pLLPwAsU0tUKZ49cyV1tzmB/qRbSzGvY8lpgGkJuMyuhH7Ma+Vg=="],
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.54.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.53.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-juaC4bEgJsyFVfqhtGLz8mbopaWD+WeSOYr5E16y+1of6KQjc0BpwZLuxkClqY1i8sco+MdyoXPNiCkQou09+g=="],
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.54.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.53.5", "", { "os": "linux", "cpu": "none" }, "sha512-rIEC0hZ17A42iXtHX+EPJVL/CakHo+tT7W0pbzdAGuWOt2jxDFh7A/lRhsNHBcqL4T36+UiAgwO8pbmn3dE8wA=="],
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.54.0", "", { "os": "linux", "cpu": "none" }, "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.53.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-T7l409NhUE552RcAOcmJHj3xyZ2h7vMWzcwQI0hvn5tqHh3oSoclf9WgTl+0QqffWFG8MEVZZP1/OBglKZx52Q=="],
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.54.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.53.5", "", { "os": "linux", "cpu": "none" }, "sha512-7OK5/GhxbnrMcxIFoYfhV/TkknarkYC1hqUw1wU2xUN3TVRLNT5FmBv4KkheSG2xZ6IEbRAhTooTV2+R5Tk0lQ=="],
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.54.0", "", { "os": "linux", "cpu": "none" }, "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.53.5", "", { "os": "linux", "cpu": "none" }, "sha512-GwuDBE/PsXaTa76lO5eLJTyr2k8QkPipAyOrs4V/KJufHCZBJ495VCGJol35grx9xryk4V+2zd3Ri+3v7NPh+w=="],
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.54.0", "", { "os": "linux", "cpu": "none" }, "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.53.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-IAE1Ziyr1qNfnmiQLHBURAD+eh/zH1pIeJjeShleII7Vj8kyEm2PF77o+lf3WTHDpNJcu4IXJxNO0Zluro8bOw=="],
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.54.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.53.5", "", { "os": "linux", "cpu": "x64" }, "sha512-Pg6E+oP7GvZ4XwgRJBuSXZjcqpIW3yCBhK4BcsANvb47qMvAbCjR6E+1a/U2WXz1JJxp9/4Dno3/iSJLcm5auw=="],
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.54.0", "", { "os": "linux", "cpu": "x64" }, "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.53.5", "", { "os": "linux", "cpu": "x64" }, "sha512-txGtluxDKTxaMDzUduGP0wdfng24y1rygUMnmlUJ88fzCCULCLn7oE5kb2+tRB+MWq1QDZT6ObT5RrR8HFRKqg=="],
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.54.0", "", { "os": "linux", "cpu": "x64" }, "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw=="],
|
||||
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.53.5", "", { "os": "none", "cpu": "arm64" }, "sha512-3DFiLPnTxiOQV993fMc+KO8zXHTcIjgaInrqlG8zDp1TlhYl6WgrOHuJkJQ6M8zHEcntSJsUp1XFZSY8C1DYbg=="],
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.54.0", "", { "os": "none", "cpu": "arm64" }, "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.53.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-nggc/wPpNTgjGg75hu+Q/3i32R00Lq1B6N1DO7MCU340MRKL3WZJMjA9U4K4gzy3dkZPXm9E1Nc81FItBVGRlA=="],
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.54.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.53.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-U/54pTbdQpPLBdEzCT6NBCFAfSZMvmjr0twhnD9f4EIvlm9wy3jjQ38yQj1AGznrNO65EWQMgm/QUjuIVrYF9w=="],
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.54.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.53.5", "", { "os": "win32", "cpu": "x64" }, "sha512-2NqKgZSuLH9SXBBV2dWNRCZmocgSOx8OJSdpRaEcRlIfX8YrKxUT6z0F1NpvDVhOsl190UFTRh2F2WDWWCYp3A=="],
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.54.0", "", { "os": "win32", "cpu": "x64" }, "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.53.5", "", { "os": "win32", "cpu": "x64" }, "sha512-JRpZUhCfhZ4keB5v0fe02gQJy05GqboPOaxvjugW04RLSYYoB/9t2lx2u/tMs/Na/1NXfY8QYjgRljRpN+MjTQ=="],
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.54.0", "", { "os": "win32", "cpu": "x64" }, "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg=="],
|
||||
|
||||
"@scalar/core": ["@scalar/core@0.3.28", "", { "dependencies": { "@scalar/types": "0.5.4" } }, "sha512-Ka+g5P3Fe4f9lsJcBxfI+XAgwMYeZRgzIBWw1/HBrDoRmH3rV/N//410MBKEYXUw7pWpS+dZPJANZRvU5jtxhw=="],
|
||||
|
||||
|
|
@ -498,6 +532,10 @@
|
|||
|
||||
"ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||
|
||||
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
|
||||
|
||||
"arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
|
||||
|
|
@ -546,12 +584,16 @@
|
|||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001761", "", {}, "sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g=="],
|
||||
|
||||
"chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="],
|
||||
|
||||
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||
|
||||
"citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="],
|
||||
|
||||
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||
|
||||
"cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"color": ["color@5.0.3", "", { "dependencies": { "color-convert": "^3.1.3", "color-string": "^2.1.3" } }, "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA=="],
|
||||
|
|
@ -660,6 +702,8 @@
|
|||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.267", "", {}, "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
||||
|
||||
"enabled": ["enabled@2.0.0", "", {}, "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ=="],
|
||||
|
||||
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
||||
|
|
@ -712,6 +756,8 @@
|
|||
|
||||
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
|
||||
"get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
|
||||
|
|
@ -746,7 +792,7 @@
|
|||
|
||||
"http-errors-enhanced": ["http-errors-enhanced@4.0.2", "", {}, "sha512-5EXN1gmhJVvuWpNfz+RclWvLnnENEXNMPfww3gm30H9mQzPF4QSBj/MD5FRkVDxGIUhO/cR2GSLCd/6C6xpBcw=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
|
||||
"iconv-lite": ["iconv-lite@0.7.1", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw=="],
|
||||
|
||||
"immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
|
||||
|
||||
|
|
@ -842,6 +888,8 @@
|
|||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"mute-stream": ["mute-stream@3.0.0", "", {}, "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"negotiator": ["negotiator@0.6.4", "", {}, "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w=="],
|
||||
|
|
@ -918,7 +966,7 @@
|
|||
|
||||
"react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="],
|
||||
|
||||
"react-hook-form": ["react-hook-form@7.68.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-oNN3fjrZ/Xo40SWlHf1yCjlMK417JxoSJVUXQjGdvdRCU07NTFei1i1f8ApUAts+IVh14e4EdakeLEA+BEAs/Q=="],
|
||||
"react-hook-form": ["react-hook-form@7.69.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-yt6ZGME9f4F6WHwevrvpAjh42HMvocuSnSIHUGycBqXIJdhqGSPQzTpGF+1NLREk/58IdPxEMfPcFCjlMhclGw=="],
|
||||
|
||||
"react-is": ["react-is@19.2.3", "", {}, "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA=="],
|
||||
|
||||
|
|
@ -950,7 +998,7 @@
|
|||
|
||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||
|
||||
"rollup": ["rollup@4.53.5", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.53.5", "@rollup/rollup-android-arm64": "4.53.5", "@rollup/rollup-darwin-arm64": "4.53.5", "@rollup/rollup-darwin-x64": "4.53.5", "@rollup/rollup-freebsd-arm64": "4.53.5", "@rollup/rollup-freebsd-x64": "4.53.5", "@rollup/rollup-linux-arm-gnueabihf": "4.53.5", "@rollup/rollup-linux-arm-musleabihf": "4.53.5", "@rollup/rollup-linux-arm64-gnu": "4.53.5", "@rollup/rollup-linux-arm64-musl": "4.53.5", "@rollup/rollup-linux-loong64-gnu": "4.53.5", "@rollup/rollup-linux-ppc64-gnu": "4.53.5", "@rollup/rollup-linux-riscv64-gnu": "4.53.5", "@rollup/rollup-linux-riscv64-musl": "4.53.5", "@rollup/rollup-linux-s390x-gnu": "4.53.5", "@rollup/rollup-linux-x64-gnu": "4.53.5", "@rollup/rollup-linux-x64-musl": "4.53.5", "@rollup/rollup-openharmony-arm64": "4.53.5", "@rollup/rollup-win32-arm64-msvc": "4.53.5", "@rollup/rollup-win32-ia32-msvc": "4.53.5", "@rollup/rollup-win32-x64-gnu": "4.53.5", "@rollup/rollup-win32-x64-msvc": "4.53.5", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-iTNAbFSlRpcHeeWu73ywU/8KuU/LZmNCSxp6fjQkJBD3ivUb8tpDrXhIxEzA05HlYMEwmtaUnb3RP+YNv162OQ=="],
|
||||
"rollup": ["rollup@4.54.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.54.0", "@rollup/rollup-android-arm64": "4.54.0", "@rollup/rollup-darwin-arm64": "4.54.0", "@rollup/rollup-darwin-x64": "4.54.0", "@rollup/rollup-freebsd-arm64": "4.54.0", "@rollup/rollup-freebsd-x64": "4.54.0", "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", "@rollup/rollup-linux-arm-musleabihf": "4.54.0", "@rollup/rollup-linux-arm64-gnu": "4.54.0", "@rollup/rollup-linux-arm64-musl": "4.54.0", "@rollup/rollup-linux-loong64-gnu": "4.54.0", "@rollup/rollup-linux-ppc64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-musl": "4.54.0", "@rollup/rollup-linux-s390x-gnu": "4.54.0", "@rollup/rollup-linux-x64-gnu": "4.54.0", "@rollup/rollup-linux-x64-musl": "4.54.0", "@rollup/rollup-openharmony-arm64": "4.54.0", "@rollup/rollup-win32-arm64-msvc": "4.54.0", "@rollup/rollup-win32-ia32-msvc": "4.54.0", "@rollup/rollup-win32-x64-gnu": "4.54.0", "@rollup/rollup-win32-x64-msvc": "4.54.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw=="],
|
||||
|
||||
"run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="],
|
||||
|
||||
|
|
@ -984,6 +1032,8 @@
|
|||
|
||||
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||
|
||||
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
|
||||
"slugify": ["slugify@1.6.6", "", {}, "sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw=="],
|
||||
|
||||
"sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
|
||||
|
|
@ -998,8 +1048,12 @@
|
|||
|
||||
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||
|
||||
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
|
||||
|
||||
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
|
||||
|
||||
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
|
||||
|
||||
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
|
||||
|
|
@ -1076,6 +1130,8 @@
|
|||
|
||||
"winston-transport": ["winston-transport@4.9.0", "", { "dependencies": { "logform": "^2.7.0", "readable-stream": "^3.6.2", "triple-beam": "^1.3.0" } }, "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A=="],
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
|
||||
|
||||
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
|
||||
|
||||
"wsl-utils": ["wsl-utils@0.3.0", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-3sFIGLiaDP7rTO4xh3g+b3AzhYDIUGGywE/WsmqzJWDxus5aJXVnPTNC/6L+r2WzrwXqVOdD262OaO+cEyPMSQ=="],
|
||||
|
|
@ -1114,7 +1170,7 @@
|
|||
|
||||
"@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@reduxjs/toolkit/immer": ["immer@11.0.1", "", {}, "sha512-naDCyggtcBWANtIrjQEajhhBEuL9b0Zg4zmlWK2CzS6xCWSE39/vvf4LqnMjUAWHBhot4m9MHCM/Z+mfWhUkiA=="],
|
||||
"@reduxjs/toolkit/immer": ["immer@11.1.0", "", {}, "sha512-dlzb07f5LDY+tzs+iLCSXV2yuhaYfezqyZQc+n6baLECWkOMEWxkECAOnXL0ba7lsA25fM9b2jtzpu/uxo1a7g=="],
|
||||
|
||||
"@scalar/types/nanoid": ["nanoid@5.1.5", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw=="],
|
||||
|
||||
|
|
@ -1138,7 +1194,7 @@
|
|||
|
||||
"body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
|
||||
"bun-types/@types/node": ["@types/node@24.10.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg=="],
|
||||
"body-parser/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
|
||||
|
||||
"c12/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
|
|
@ -1164,6 +1220,8 @@
|
|||
|
||||
"pkg-types/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"raw-body/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
|
||||
|
||||
"react-router-hono-server/@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
|
||||
|
||||
"send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
"build": "react-router build",
|
||||
"dev": "bunx --bun vite",
|
||||
"start": "bun ./dist/server/index.js",
|
||||
"cli": "bun run app/server/cli/main.ts",
|
||||
"tsc": "react-router typegen && tsc",
|
||||
"lint": "biome check .",
|
||||
"lint:ci": "biome ci . --changed --error-on-warnings --no-errors-on-unmatched",
|
||||
|
|
@ -26,6 +27,7 @@
|
|||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hono/standard-validator": "^0.2.0",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@inquirer/prompts": "^8.0.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
|
|
@ -45,6 +47,7 @@
|
|||
"arktype": "^2.1.28",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"commander": "^14.0.2",
|
||||
"cron-parser": "^5.4.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"dither-plugin": "^1.1.1",
|
||||
|
|
|
|||
Loading…
Reference in a new issue