parent
b35a20f43d
commit
55dbcf0eb3
58 changed files with 3776 additions and 3797 deletions
|
|
@ -50,6 +50,7 @@
|
||||||
"no-unused-vars": [
|
"no-unused-vars": [
|
||||||
"warn",
|
"warn",
|
||||||
{
|
{
|
||||||
|
"varsIgnorePattern": "^_",
|
||||||
"caughtErrorsIgnorePattern": "^_",
|
"caughtErrorsIgnorePattern": "^_",
|
||||||
"argsIgnorePattern": "^_"
|
"argsIgnorePattern": "^_"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -457,7 +457,7 @@ export const listFilesInfiniteQueryKey = (options: Options<ListFilesData>): Quer
|
||||||
/**
|
/**
|
||||||
* List files in a volume directory
|
* List files in a volume directory
|
||||||
*/
|
*/
|
||||||
export const listFilesInfiniteOptions = (options: Options<ListFilesData>) => infiniteQueryOptions<ListFilesResponse, DefaultError, InfiniteData<ListFilesResponse>, QueryKey<Options<ListFilesData>>, string | Pick<QueryKey<Options<ListFilesData>>[0], 'body' | 'headers' | 'path' | 'query'>>(
|
export const listFilesInfiniteOptions = (options: Options<ListFilesData>) => infiniteQueryOptions<ListFilesResponse, DefaultError, InfiniteData<ListFilesResponse>, QueryKey<Options<ListFilesData>>, number | Pick<QueryKey<Options<ListFilesData>>[0], 'body' | 'headers' | 'path' | 'query'>>(
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
{
|
{
|
||||||
queryFn: async ({ pageParam, queryKey, signal }) => {
|
queryFn: async ({ pageParam, queryKey, signal }) => {
|
||||||
|
|
@ -747,7 +747,7 @@ export const listSnapshotFilesInfiniteQueryKey = (options: Options<ListSnapshotF
|
||||||
/**
|
/**
|
||||||
* List files and directories in a snapshot
|
* List files and directories in a snapshot
|
||||||
*/
|
*/
|
||||||
export const listSnapshotFilesInfiniteOptions = (options: Options<ListSnapshotFilesData>) => infiniteQueryOptions<ListSnapshotFilesResponse, DefaultError, InfiniteData<ListSnapshotFilesResponse>, QueryKey<Options<ListSnapshotFilesData>>, string | Pick<QueryKey<Options<ListSnapshotFilesData>>[0], 'body' | 'headers' | 'path' | 'query'>>(
|
export const listSnapshotFilesInfiniteOptions = (options: Options<ListSnapshotFilesData>) => infiniteQueryOptions<ListSnapshotFilesResponse, DefaultError, InfiniteData<ListSnapshotFilesResponse>, QueryKey<Options<ListSnapshotFilesData>>, number | Pick<QueryKey<Options<ListSnapshotFilesData>>[0], 'body' | 'headers' | 'path' | 'query'>>(
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
{
|
{
|
||||||
queryFn: async ({ pageParam, queryKey, signal }) => {
|
queryFn: async ({ pageParam, queryKey, signal }) => {
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ export const createClient = (config: Config = {}): Client => {
|
||||||
...options,
|
...options,
|
||||||
fetch: options.fetch ?? _config.fetch ?? globalThis.fetch,
|
fetch: options.fetch ?? _config.fetch ?? globalThis.fetch,
|
||||||
headers: mergeHeaders(_config.headers, options.headers),
|
headers: mergeHeaders(_config.headers, options.headers),
|
||||||
serializedBody: undefined,
|
serializedBody: undefined as string | undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (opts.security) {
|
if (opts.security) {
|
||||||
|
|
@ -53,7 +53,7 @@ export const createClient = (config: Config = {}): Client => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts.body !== undefined && opts.bodySerializer) {
|
if (opts.body !== undefined && opts.bodySerializer) {
|
||||||
opts.serializedBody = opts.bodySerializer(opts.body);
|
opts.serializedBody = opts.bodySerializer(opts.body) as string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// remove Content-Type header if body is empty to avoid sending invalid requests
|
// remove Content-Type header if body is empty to avoid sending invalid requests
|
||||||
|
|
@ -259,8 +259,10 @@ export const createClient = (config: Config = {}): Client => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const _buildUrl: Client['buildUrl'] = (options) => buildUrl({ ..._config, ...options });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
buildUrl,
|
buildUrl: _buildUrl,
|
||||||
connect: makeMethodFn('CONNECT'),
|
connect: makeMethodFn('CONNECT'),
|
||||||
delete: makeMethodFn('DELETE'),
|
delete: makeMethodFn('DELETE'),
|
||||||
get: makeMethodFn('GET'),
|
get: makeMethodFn('GET'),
|
||||||
|
|
|
||||||
|
|
@ -191,7 +191,7 @@ export type Client = CoreClient<RequestFn, Config, MethodFn, BuildUrlFn, SseFn>
|
||||||
*/
|
*/
|
||||||
export type CreateClientConfig<T extends ClientOptions = ClientOptions> = (
|
export type CreateClientConfig<T extends ClientOptions = ClientOptions> = (
|
||||||
override?: Config<ClientOptions & T>,
|
override?: Config<ClientOptions & T>,
|
||||||
) => Config<Required<ClientOptions> & T> | Promise<Config<Required<ClientOptions> & T>>;
|
) => Config<Required<ClientOptions> & T>;
|
||||||
|
|
||||||
export interface TDataShape {
|
export interface TDataShape {
|
||||||
body?: unknown;
|
body?: unknown;
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import type { ArrayStyle, ObjectStyle, SerializerOptions } from './pathSerialize
|
||||||
|
|
||||||
export type QuerySerializer = (query: Record<string, unknown>) => string;
|
export type QuerySerializer = (query: Record<string, unknown>) => string;
|
||||||
|
|
||||||
export type BodySerializer = (body: any) => any;
|
export type BodySerializer = (body: unknown) => unknown;
|
||||||
|
|
||||||
type QuerySerializerOptionsObject = {
|
type QuerySerializerOptionsObject = {
|
||||||
allowReserved?: boolean;
|
allowReserved?: boolean;
|
||||||
|
|
@ -40,12 +40,10 @@ const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value:
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formDataBodySerializer = {
|
export const formDataBodySerializer = {
|
||||||
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(
|
bodySerializer: (body: unknown): FormData => {
|
||||||
body: T,
|
|
||||||
): FormData => {
|
|
||||||
const data = new FormData();
|
const data = new FormData();
|
||||||
|
|
||||||
Object.entries(body).forEach(([key, value]) => {
|
Object.entries(body as Record<string, unknown>).forEach(([key, value]) => {
|
||||||
if (value === undefined || value === null) {
|
if (value === undefined || value === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -61,15 +59,15 @@ export const formDataBodySerializer = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const jsonBodySerializer = {
|
export const jsonBodySerializer = {
|
||||||
bodySerializer: <T>(body: T): string =>
|
bodySerializer: (body: unknown): string =>
|
||||||
JSON.stringify(body, (_key, value) => (typeof value === 'bigint' ? value.toString() : value)),
|
JSON.stringify(body, (_key, value) => (typeof value === 'bigint' ? value.toString() : value)),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const urlSearchParamsBodySerializer = {
|
export const urlSearchParamsBodySerializer = {
|
||||||
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(body: T): string => {
|
bodySerializer: (body: unknown): string => {
|
||||||
const data = new URLSearchParams();
|
const data = new URLSearchParams();
|
||||||
|
|
||||||
Object.entries(body).forEach(([key, value]) => {
|
Object.entries(body as Record<string, unknown>).forEach(([key, value]) => {
|
||||||
if (value === undefined || value === null) {
|
if (value === undefined || value === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -74,11 +74,7 @@ export const SnapshotTreeBrowser = ({
|
||||||
return await queryClient.ensureQueryData(
|
return await queryClient.ensureQueryData(
|
||||||
listSnapshotFilesOptions({
|
listSnapshotFilesOptions({
|
||||||
path: { shortId: repositoryId, snapshotId },
|
path: { shortId: repositoryId, snapshotId },
|
||||||
query: {
|
query: { path, offset: offset, limit: pageSize },
|
||||||
path,
|
|
||||||
offset: offset.toString(),
|
|
||||||
limit: pageSize.toString(),
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -86,11 +82,7 @@ export const SnapshotTreeBrowser = ({
|
||||||
void queryClient.prefetchQuery(
|
void queryClient.prefetchQuery(
|
||||||
listSnapshotFilesOptions({
|
listSnapshotFilesOptions({
|
||||||
path: { shortId: repositoryId, snapshotId },
|
path: { shortId: repositoryId, snapshotId },
|
||||||
query: {
|
query: { path, offset: 0, limit: pageSize },
|
||||||
path,
|
|
||||||
offset: "0",
|
|
||||||
limit: pageSize.toString(),
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ export const VolumeFileBrowser = ({ volumeId, enabled = true, ...uiProps }: Volu
|
||||||
return await queryClient.ensureQueryData(
|
return await queryClient.ensureQueryData(
|
||||||
listFilesOptions({
|
listFilesOptions({
|
||||||
path: { shortId: volumeId },
|
path: { shortId: volumeId },
|
||||||
query: { path, offset: offset?.toString() },
|
query: { path, offset: offset },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { type } from "arktype";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
@ -16,13 +15,17 @@ import { useNavigate } from "@tanstack/react-router";
|
||||||
import { normalizeUsername } from "~/lib/username";
|
import { normalizeUsername } from "~/lib/username";
|
||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
import { SsoLoginSection } from "~/client/modules/sso/components/sso-login-section";
|
import { SsoLoginSection } from "~/client/modules/sso/components/sso-login-section";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
const loginSchema = type({
|
const loginSchema = z.object({
|
||||||
username: "2<=string<=50",
|
username: z
|
||||||
password: "string>=1",
|
.string()
|
||||||
|
.min(2, "Username must be at least 2 characters")
|
||||||
|
.max(50, "Username must be at most 50 characters"),
|
||||||
|
password: z.string().min(1, "Password is required"),
|
||||||
});
|
});
|
||||||
|
|
||||||
type LoginFormValues = typeof loginSchema.inferIn;
|
type LoginFormValues = z.input<typeof loginSchema>;
|
||||||
|
|
||||||
type LoginPageProps = {
|
type LoginPageProps = {
|
||||||
error?: string;
|
error?: string;
|
||||||
|
|
@ -40,7 +43,7 @@ export function LoginPage({ error }: LoginPageProps = {}) {
|
||||||
const errorDescription = errorCode ? getLoginErrorDescription(errorCode) : null;
|
const errorDescription = errorCode ? getLoginErrorDescription(errorCode) : null;
|
||||||
|
|
||||||
const form = useForm<LoginFormValues>({
|
const form = useForm<LoginFormValues>({
|
||||||
resolver: arktypeResolver(loginSchema),
|
resolver: zodResolver(loginSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
username: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { type } from "arktype";
|
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
|
|
@ -19,24 +18,28 @@ import { authClient } from "~/client/lib/auth-client";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import { normalizeUsername } from "~/lib/username";
|
import { normalizeUsername } from "~/lib/username";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
export const clientMiddleware = [authMiddleware];
|
export const clientMiddleware = [authMiddleware];
|
||||||
|
|
||||||
const onboardingSchema = type({
|
const onboardingSchema = z.object({
|
||||||
username: type("2<=string<=30").pipe(normalizeUsername),
|
username: z.string().min(2).max(30).transform(normalizeUsername),
|
||||||
email: type("string.email").pipe((str) => str.trim().toLowerCase()),
|
email: z
|
||||||
password: "string>=8",
|
.string()
|
||||||
confirmPassword: "string>=1",
|
.email()
|
||||||
|
.transform((str) => str.trim().toLowerCase()),
|
||||||
|
password: z.string().min(8),
|
||||||
|
confirmPassword: z.string().min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
type OnboardingFormValues = typeof onboardingSchema.inferIn;
|
type OnboardingFormValues = z.input<typeof onboardingSchema>;
|
||||||
|
|
||||||
export function OnboardingPage() {
|
export function OnboardingPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
const form = useForm<OnboardingFormValues>({
|
const form = useForm<OnboardingFormValues>({
|
||||||
resolver: arktypeResolver(onboardingSchema),
|
resolver: zodResolver(onboardingSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
username: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error";
|
import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error";
|
||||||
|
|
@ -13,7 +13,7 @@ import { FrequencySection } from "./frequency-section";
|
||||||
import { PathsSection } from "./paths-section";
|
import { PathsSection } from "./paths-section";
|
||||||
import { RetentionSection } from "./retention-section";
|
import { RetentionSection } from "./retention-section";
|
||||||
import { SummarySection } from "./summary-section";
|
import { SummarySection } from "./summary-section";
|
||||||
import { cleanSchema, type BackupScheduleFormValues, type InternalFormValues } from "./types";
|
import { internalFormSchema, type BackupScheduleFormValues, type InternalFormValues } from "./types";
|
||||||
import { backupScheduleToFormValues } from "./utils";
|
import { backupScheduleToFormValues } from "./utils";
|
||||||
|
|
||||||
export type { BackupScheduleFormValues };
|
export type { BackupScheduleFormValues };
|
||||||
|
|
@ -29,7 +29,7 @@ type Props = {
|
||||||
|
|
||||||
export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: Props) => {
|
export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: Props) => {
|
||||||
const form = useForm<InternalFormValues>({
|
const form = useForm<InternalFormValues>({
|
||||||
resolver: arktypeResolver(cleanSchema as unknown as typeof import("./types").internalFormSchema),
|
resolver: zodResolver(internalFormSchema),
|
||||||
defaultValues: backupScheduleToFormValues(initialValues),
|
defaultValues: backupScheduleToFormValues(initialValues),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ export const RetentionSection = ({ form }: RetentionSectionProps) => {
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
placeholder="Optional"
|
placeholder="Optional"
|
||||||
onChange={(v) => field.onChange(Number(v.target.value))}
|
onChange={(v) => field.onChange(v.target.value === "" ? undefined : Number(v.target.value))}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>Keep the N most recent snapshots.</FormDescription>
|
<FormDescription>Keep the N most recent snapshots.</FormDescription>
|
||||||
|
|
@ -43,7 +43,7 @@ export const RetentionSection = ({ form }: RetentionSectionProps) => {
|
||||||
min={0}
|
min={0}
|
||||||
placeholder="Optional"
|
placeholder="Optional"
|
||||||
{...field}
|
{...field}
|
||||||
onChange={(v) => field.onChange(Number(v.target.value))}
|
onChange={(v) => field.onChange(v.target.value === "" ? undefined : Number(v.target.value))}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>Keep the last N hourly snapshots.</FormDescription>
|
<FormDescription>Keep the last N hourly snapshots.</FormDescription>
|
||||||
|
|
@ -64,7 +64,7 @@ export const RetentionSection = ({ form }: RetentionSectionProps) => {
|
||||||
min={0}
|
min={0}
|
||||||
placeholder="e.g., 7"
|
placeholder="e.g., 7"
|
||||||
{...field}
|
{...field}
|
||||||
onChange={(v) => field.onChange(Number(v.target.value))}
|
onChange={(v) => field.onChange(v.target.value === "" ? undefined : Number(v.target.value))}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>Keep the last N daily snapshots.</FormDescription>
|
<FormDescription>Keep the last N daily snapshots.</FormDescription>
|
||||||
|
|
@ -85,7 +85,7 @@ export const RetentionSection = ({ form }: RetentionSectionProps) => {
|
||||||
min={0}
|
min={0}
|
||||||
placeholder="e.g., 4"
|
placeholder="e.g., 4"
|
||||||
{...field}
|
{...field}
|
||||||
onChange={(v) => field.onChange(Number(v.target.value))}
|
onChange={(v) => field.onChange(v.target.value === "" ? undefined : Number(v.target.value))}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>Keep the last N weekly snapshots.</FormDescription>
|
<FormDescription>Keep the last N weekly snapshots.</FormDescription>
|
||||||
|
|
@ -106,7 +106,7 @@ export const RetentionSection = ({ form }: RetentionSectionProps) => {
|
||||||
min={0}
|
min={0}
|
||||||
placeholder="e.g., 6"
|
placeholder="e.g., 6"
|
||||||
{...field}
|
{...field}
|
||||||
onChange={(v) => field.onChange(Number(v.target.value))}
|
onChange={(v) => field.onChange(v.target.value === "" ? undefined : Number(v.target.value))}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>Keep the last N monthly snapshots.</FormDescription>
|
<FormDescription>Keep the last N monthly snapshots.</FormDescription>
|
||||||
|
|
@ -127,7 +127,7 @@ export const RetentionSection = ({ form }: RetentionSectionProps) => {
|
||||||
min={0}
|
min={0}
|
||||||
placeholder="Optional"
|
placeholder="Optional"
|
||||||
{...field}
|
{...field}
|
||||||
onChange={(v) => field.onChange(Number(v.target.value))}
|
onChange={(v) => field.onChange(v.target.value === "" ? undefined : Number(v.target.value))}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>Keep the last N yearly snapshots.</FormDescription>
|
<FormDescription>Keep the last N yearly snapshots.</FormDescription>
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,27 @@
|
||||||
import { type } from "arktype";
|
import { z } from "zod";
|
||||||
import { deepClean } from "~/utils/object";
|
|
||||||
|
|
||||||
export const internalFormSchema = type({
|
export const internalFormSchema = z.object({
|
||||||
name: "1 <= string <= 128",
|
name: z.string().min(1).max(128),
|
||||||
repositoryId: "string",
|
repositoryId: z.string(),
|
||||||
excludePatternsText: "string?",
|
excludePatternsText: z.string().optional(),
|
||||||
excludeIfPresentText: "string?",
|
excludeIfPresentText: z.string().optional(),
|
||||||
includePatternsText: "string?",
|
includePatternsText: z.string().optional(),
|
||||||
includePatterns: "string[]?",
|
includePatterns: z.array(z.string()).optional(),
|
||||||
frequency: "string",
|
frequency: z.string(),
|
||||||
dailyTime: "string?",
|
dailyTime: z.string().optional(),
|
||||||
weeklyDay: "string?",
|
weeklyDay: z.string().optional(),
|
||||||
monthlyDays: "string[]?",
|
monthlyDays: z.array(z.string()).optional(),
|
||||||
cronExpression: "string?",
|
cronExpression: z.string().optional(),
|
||||||
keepLast: "number?",
|
keepLast: z.number().optional(),
|
||||||
keepHourly: "number?",
|
keepHourly: z.number().optional(),
|
||||||
keepDaily: "number?",
|
keepDaily: z.number().optional(),
|
||||||
keepWeekly: "number?",
|
keepWeekly: z.number().optional(),
|
||||||
keepMonthly: "number?",
|
keepMonthly: z.number().optional(),
|
||||||
keepYearly: "number?",
|
keepYearly: z.number().optional(),
|
||||||
oneFileSystem: "boolean?",
|
oneFileSystem: z.boolean().optional(),
|
||||||
customResticParamsText: "string?",
|
customResticParamsText: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const cleanSchema = type.pipe((d) => internalFormSchema(deepClean(d)));
|
|
||||||
|
|
||||||
export const weeklyDays = [
|
export const weeklyDays = [
|
||||||
{ label: "Monday", value: "1" },
|
{ label: "Monday", value: "1" },
|
||||||
{ label: "Tuesday", value: "2" },
|
{ label: "Tuesday", value: "2" },
|
||||||
|
|
@ -35,7 +32,7 @@ export const weeklyDays = [
|
||||||
{ label: "Sunday", value: "0" },
|
{ label: "Sunday", value: "0" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export type InternalFormValues = typeof internalFormSchema.infer;
|
export type InternalFormValues = z.infer<typeof internalFormSchema>;
|
||||||
|
|
||||||
export type BackupScheduleFormValues = Omit<
|
export type BackupScheduleFormValues = Omit<
|
||||||
InternalFormValues,
|
InternalFormValues,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { type } from "arktype";
|
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
import { deepClean } from "~/utils/object";
|
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
|
|
@ -14,7 +13,17 @@ import {
|
||||||
} from "~/client/components/ui/form";
|
} from "~/client/components/ui/form";
|
||||||
import { Input } from "~/client/components/ui/input";
|
import { Input } from "~/client/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
||||||
import { notificationConfigSchemaBase } from "~/schemas/notifications";
|
import {
|
||||||
|
customNotificationConfigSchema,
|
||||||
|
discordNotificationConfigSchema,
|
||||||
|
emailNotificationConfigSchema,
|
||||||
|
genericNotificationConfigSchema,
|
||||||
|
gotifyNotificationConfigSchema,
|
||||||
|
ntfyNotificationConfigSchema,
|
||||||
|
pushoverNotificationConfigSchema,
|
||||||
|
slackNotificationConfigSchema,
|
||||||
|
telegramNotificationConfigSchema,
|
||||||
|
} from "~/schemas/notifications";
|
||||||
import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error";
|
import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error";
|
||||||
import {
|
import {
|
||||||
CustomForm,
|
CustomForm,
|
||||||
|
|
@ -28,12 +37,21 @@ import {
|
||||||
TelegramForm,
|
TelegramForm,
|
||||||
} from "./notification-forms";
|
} from "./notification-forms";
|
||||||
|
|
||||||
export const formSchema = type({
|
const baseFields = { name: z.string().min(2).max(32) };
|
||||||
name: "2<=string<=32",
|
|
||||||
}).and(notificationConfigSchemaBase);
|
|
||||||
const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
|
|
||||||
|
|
||||||
export type NotificationFormValues = typeof formSchema.inferIn;
|
export const formSchema = z.discriminatedUnion("type", [
|
||||||
|
emailNotificationConfigSchema.extend(baseFields),
|
||||||
|
slackNotificationConfigSchema.extend(baseFields),
|
||||||
|
discordNotificationConfigSchema.extend(baseFields),
|
||||||
|
gotifyNotificationConfigSchema.extend(baseFields),
|
||||||
|
ntfyNotificationConfigSchema.extend(baseFields),
|
||||||
|
pushoverNotificationConfigSchema.extend(baseFields),
|
||||||
|
telegramNotificationConfigSchema.extend(baseFields),
|
||||||
|
genericNotificationConfigSchema.extend(baseFields),
|
||||||
|
customNotificationConfigSchema.extend(baseFields),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type NotificationFormValues = z.input<typeof formSchema>;
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onSubmit: (values: NotificationFormValues) => void;
|
onSubmit: (values: NotificationFormValues) => void;
|
||||||
|
|
@ -106,7 +124,7 @@ const defaultValuesForType = {
|
||||||
|
|
||||||
export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValues, formId, className }: Props) => {
|
export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValues, formId, className }: Props) => {
|
||||||
const form = useForm<NotificationFormValues>({
|
const form = useForm<NotificationFormValues>({
|
||||||
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema),
|
resolver: zodResolver(formSchema, undefined, { raw: true }),
|
||||||
defaultValues: initialValues || {
|
defaultValues: initialValues || {
|
||||||
name: "",
|
name: "",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { type } from "arktype";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { Save } from "lucide-react";
|
import { Save } from "lucide-react";
|
||||||
|
import { z } from "zod";
|
||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
import { deepClean } from "~/utils/object";
|
|
||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
|
|
@ -21,7 +20,17 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ".
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
|
||||||
import { useSystemInfo } from "~/client/hooks/use-system-info";
|
import { useSystemInfo } from "~/client/hooks/use-system-info";
|
||||||
import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error";
|
import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error";
|
||||||
import { COMPRESSION_MODES, repositoryConfigSchemaBase } from "~/schemas/restic";
|
import {
|
||||||
|
COMPRESSION_MODES,
|
||||||
|
azureRepositoryConfigSchema,
|
||||||
|
gcsRepositoryConfigSchema,
|
||||||
|
localRepositoryConfigSchema,
|
||||||
|
r2RepositoryConfigSchema,
|
||||||
|
rcloneRepositoryConfigSchema,
|
||||||
|
restRepositoryConfigSchema,
|
||||||
|
s3RepositoryConfigSchema,
|
||||||
|
sftpRepositoryConfigSchema,
|
||||||
|
} from "~/schemas/restic";
|
||||||
import { Checkbox } from "../../../components/ui/checkbox";
|
import { Checkbox } from "../../../components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
LocalRepositoryForm,
|
LocalRepositoryForm,
|
||||||
|
|
@ -38,13 +47,23 @@ import { useServerFn } from "@tanstack/react-start";
|
||||||
import { getServerConstants } from "~/server/lib/functions/server-constants";
|
import { getServerConstants } from "~/server/lib/functions/server-constants";
|
||||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
export const formSchema = type({
|
const formBaseFields = {
|
||||||
name: "2<=string<=32",
|
name: z.string().min(2).max(32),
|
||||||
compressionMode: type.valueOf(COMPRESSION_MODES).optional(),
|
compressionMode: z.enum(COMPRESSION_MODES).optional(),
|
||||||
}).and(repositoryConfigSchemaBase);
|
};
|
||||||
const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
|
|
||||||
|
|
||||||
export type RepositoryFormValues = typeof formSchema.inferIn;
|
export const formSchema = z.discriminatedUnion("backend", [
|
||||||
|
localRepositoryConfigSchema.extend(formBaseFields),
|
||||||
|
s3RepositoryConfigSchema.extend(formBaseFields),
|
||||||
|
r2RepositoryConfigSchema.extend(formBaseFields),
|
||||||
|
gcsRepositoryConfigSchema.extend(formBaseFields),
|
||||||
|
azureRepositoryConfigSchema.extend(formBaseFields),
|
||||||
|
rcloneRepositoryConfigSchema.extend(formBaseFields),
|
||||||
|
restRepositoryConfigSchema.extend(formBaseFields),
|
||||||
|
sftpRepositoryConfigSchema.extend(formBaseFields),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type RepositoryFormValues = z.input<typeof formSchema>;
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onSubmit: (values: RepositoryFormValues) => void;
|
onSubmit: (values: RepositoryFormValues) => void;
|
||||||
|
|
@ -81,7 +100,7 @@ export const CreateRepositoryForm = ({
|
||||||
});
|
});
|
||||||
|
|
||||||
const form = useForm<RepositoryFormValues>({
|
const form = useForm<RepositoryFormValues>({
|
||||||
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema),
|
resolver: zodResolver(formSchema, undefined, { raw: true }),
|
||||||
defaultValues: initialValues,
|
defaultValues: initialValues,
|
||||||
resetOptions: {
|
resetOptions: {
|
||||||
keepDefaultValues: true,
|
keepDefaultValues: true,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { toast } from "sonner";
|
||||||
import { createRepositoryMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
import { createRepositoryMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
import {
|
import {
|
||||||
CreateRepositoryForm,
|
CreateRepositoryForm,
|
||||||
|
formSchema,
|
||||||
type RepositoryFormValues,
|
type RepositoryFormValues,
|
||||||
} from "~/client/modules/repositories/components/create-repository-form";
|
} from "~/client/modules/repositories/components/create-repository-form";
|
||||||
import { Button } from "~/client/components/ui/button";
|
import { Button } from "~/client/components/ui/button";
|
||||||
|
|
@ -26,11 +27,13 @@ export function CreateRepositoryPage() {
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = (values: RepositoryFormValues) => {
|
const handleSubmit = (values: RepositoryFormValues) => {
|
||||||
|
const { name, compressionMode, ...config } = formSchema.parse(values);
|
||||||
|
|
||||||
createRepository.mutate({
|
createRepository.mutate({
|
||||||
body: {
|
body: {
|
||||||
config: values,
|
config,
|
||||||
name: values.name,
|
name,
|
||||||
compressionMode: values.compressionMode,
|
compressionMode,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { toast } from "sonner";
|
||||||
import { getRepositoryOptions, updateRepositoryMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
import { getRepositoryOptions, updateRepositoryMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
import {
|
import {
|
||||||
CreateRepositoryForm,
|
CreateRepositoryForm,
|
||||||
|
formSchema,
|
||||||
type RepositoryFormValues,
|
type RepositoryFormValues,
|
||||||
} from "~/client/modules/repositories/components/create-repository-form";
|
} from "~/client/modules/repositories/components/create-repository-form";
|
||||||
import {
|
import {
|
||||||
|
|
@ -35,11 +36,11 @@ const riskyLocationFieldsByBackend = {
|
||||||
rclone: ["remote", "path"],
|
rclone: ["remote", "path"],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const hasRiskyLocationChange = (initialConfig: RepositoryConfig, nextConfig: RepositoryFormValues): boolean => {
|
const hasRiskyLocationChange = (initialConfig: RepositoryConfig, nextConfig: RepositoryConfig): boolean => {
|
||||||
const fields = riskyLocationFieldsByBackend[initialConfig.backend] ?? [];
|
const fields = riskyLocationFieldsByBackend[initialConfig.backend] ?? [];
|
||||||
|
|
||||||
return fields.some(
|
return fields.some(
|
||||||
(field) => initialConfig[field as keyof RepositoryConfig] !== nextConfig[field as keyof RepositoryFormValues],
|
(field) => initialConfig[field as keyof RepositoryConfig] !== nextConfig[field as keyof RepositoryConfig],
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -75,18 +76,20 @@ export function EditRepositoryPage({ repositoryId }: { repositoryId: string }) {
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitUpdate = (values: RepositoryFormValues) => {
|
const submitUpdate = (values: RepositoryFormValues) => {
|
||||||
|
const { name, compressionMode, ...config } = formSchema.parse(values);
|
||||||
|
|
||||||
updateRepository.mutate({
|
updateRepository.mutate({
|
||||||
path: { shortId: repositoryId },
|
path: { shortId: repositoryId },
|
||||||
body: {
|
body: {
|
||||||
name: values.name,
|
name,
|
||||||
compressionMode: values.compressionMode,
|
compressionMode,
|
||||||
config: values,
|
config,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = (values: RepositoryFormValues) => {
|
const handleSubmit = (values: RepositoryFormValues) => {
|
||||||
const nextConfig = values;
|
const { name: _name, compressionMode: _compressionMode, ...nextConfig } = formSchema.parse(values);
|
||||||
|
|
||||||
if (hasRiskyLocationChange(initialConfig, nextConfig)) {
|
if (hasRiskyLocationChange(initialConfig, nextConfig)) {
|
||||||
setPendingValues(values);
|
setPendingValues(values);
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { type } from "arktype";
|
|
||||||
import { Plus, UserPlus } from "lucide-react";
|
import { Plus, UserPlus } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
import { Button } from "~/client/components/ui/button";
|
import { Button } from "~/client/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -20,15 +20,15 @@ import { Input } from "~/client/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
||||||
import { authClient } from "~/client/lib/auth-client";
|
import { authClient } from "~/client/lib/auth-client";
|
||||||
|
|
||||||
const createUserSchema = type({
|
const createUserSchema = z.object({
|
||||||
name: "string>=1",
|
name: z.string().min(1),
|
||||||
username: "string>=1",
|
username: z.string().min(1),
|
||||||
email: "string",
|
email: z.string().email(),
|
||||||
password: "string>=8",
|
password: z.string().min(8),
|
||||||
role: "'user'|'admin'",
|
role: z.enum(["user", "admin"]),
|
||||||
});
|
});
|
||||||
|
|
||||||
type CreateUserFormValues = typeof createUserSchema.infer;
|
type CreateUserFormValues = z.infer<typeof createUserSchema>;
|
||||||
|
|
||||||
interface CreateUserDialogProps {
|
interface CreateUserDialogProps {
|
||||||
onUserCreated?: () => void;
|
onUserCreated?: () => void;
|
||||||
|
|
@ -38,7 +38,7 @@ export function CreateUserDialog({ onUserCreated }: CreateUserDialogProps) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
const form = useForm<CreateUserFormValues>({
|
const form = useForm<CreateUserFormValues>({
|
||||||
resolver: arktypeResolver(createUserSchema),
|
resolver: zodResolver(createUserSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: "",
|
name: "",
|
||||||
username: "",
|
username: "",
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import { type } from "arktype";
|
|
||||||
import { ShieldCheck, Plus } from "lucide-react";
|
import { ShieldCheck, Plus } from "lucide-react";
|
||||||
import { useId } from "react";
|
import { useId } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
import { updateSsoProviderAutoLinkingMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
import { updateSsoProviderAutoLinkingMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
import { Button } from "~/client/components/ui/button";
|
import { Button } from "~/client/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||||
|
|
@ -24,17 +24,17 @@ import { authClient } from "~/client/lib/auth-client";
|
||||||
import { parseError } from "~/client/lib/errors";
|
import { parseError } from "~/client/lib/errors";
|
||||||
import { useOrganizationContext } from "~/client/hooks/use-org-context";
|
import { useOrganizationContext } from "~/client/hooks/use-org-context";
|
||||||
|
|
||||||
const ssoProviderSchema = type({
|
const ssoProviderSchema = z.object({
|
||||||
providerId: "string>=1",
|
providerId: z.string().min(1),
|
||||||
issuer: "string>=1",
|
issuer: z.string().min(1),
|
||||||
domain: "string>=1",
|
domain: z.string().min(1),
|
||||||
clientId: "string>=1",
|
clientId: z.string().min(1),
|
||||||
clientSecret: "string>=1",
|
clientSecret: z.string().min(1),
|
||||||
discoveryEndpoint: "string>=1",
|
discoveryEndpoint: z.string().min(1),
|
||||||
linkMatchingEmails: "boolean",
|
linkMatchingEmails: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type ProviderForm = typeof ssoProviderSchema.infer;
|
type ProviderForm = z.infer<typeof ssoProviderSchema>;
|
||||||
|
|
||||||
export function CreateSsoProviderPage() {
|
export function CreateSsoProviderPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
@ -42,7 +42,7 @@ export function CreateSsoProviderPage() {
|
||||||
const { activeOrganization } = useOrganizationContext();
|
const { activeOrganization } = useOrganizationContext();
|
||||||
|
|
||||||
const form = useForm<ProviderForm>({
|
const form = useForm<ProviderForm>({
|
||||||
resolver: arktypeResolver(ssoProviderSchema),
|
resolver: zodResolver(ssoProviderSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
providerId: "",
|
providerId: "",
|
||||||
issuer: "",
|
issuer: "",
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
import { arktypeResolver } from "@hookform/resolvers/arktype";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { type } from "arktype";
|
|
||||||
import { CheckCircle, Loader2, Plug, Save, XCircle } from "lucide-react";
|
import { CheckCircle, Loader2, Plug, Save, XCircle } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
import { cn } from "~/client/lib/utils";
|
import { cn } from "~/client/lib/utils";
|
||||||
import { deepClean } from "~/utils/object";
|
|
||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
|
|
@ -18,19 +17,31 @@ import {
|
||||||
} from "../../../components/ui/form";
|
} from "../../../components/ui/form";
|
||||||
import { Input } from "../../../components/ui/input";
|
import { Input } from "../../../components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../components/ui/select";
|
||||||
import { volumeConfigSchemaBase } from "~/schemas/volumes";
|
import {
|
||||||
|
directoryConfigSchema,
|
||||||
|
nfsConfigSchema,
|
||||||
|
rcloneConfigSchema,
|
||||||
|
sftpConfigSchema,
|
||||||
|
smbConfigSchema,
|
||||||
|
volumeConfigSchema,
|
||||||
|
webdavConfigSchema,
|
||||||
|
} from "~/schemas/volumes";
|
||||||
import { testConnectionMutation } from "../../../api-client/@tanstack/react-query.gen";
|
import { testConnectionMutation } from "../../../api-client/@tanstack/react-query.gen";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
|
||||||
import { useSystemInfo } from "~/client/hooks/use-system-info";
|
import { useSystemInfo } from "~/client/hooks/use-system-info";
|
||||||
import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error";
|
import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error";
|
||||||
import { DirectoryForm, NFSForm, SMBForm, WebDAVForm, RcloneForm, SFTPForm } from "./volume-forms";
|
import { DirectoryForm, NFSForm, SMBForm, WebDAVForm, RcloneForm, SFTPForm } from "./volume-forms";
|
||||||
|
|
||||||
export const formSchema = type({
|
export const formSchema = z.discriminatedUnion("backend", [
|
||||||
name: "2<=string<=32",
|
directoryConfigSchema.extend({ name: z.string().min(2).max(32) }),
|
||||||
}).and(volumeConfigSchemaBase);
|
nfsConfigSchema.extend({ name: z.string().min(2).max(32) }),
|
||||||
const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
|
smbConfigSchema.extend({ name: z.string().min(2).max(32) }),
|
||||||
|
webdavConfigSchema.extend({ name: z.string().min(2).max(32) }),
|
||||||
|
rcloneConfigSchema.extend({ name: z.string().min(2).max(32) }),
|
||||||
|
sftpConfigSchema.extend({ name: z.string().min(2).max(32) }),
|
||||||
|
]);
|
||||||
|
|
||||||
export type FormValues = typeof formSchema.inferIn;
|
export type FormValues = z.input<typeof formSchema>;
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onSubmit: (values: FormValues) => void;
|
onSubmit: (values: FormValues) => void;
|
||||||
|
|
@ -52,7 +63,7 @@ const defaultValuesForType = {
|
||||||
|
|
||||||
export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId, loading, className }: Props) => {
|
export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, formId, loading, className }: Props) => {
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
resolver: arktypeResolver(cleanSchema as unknown as typeof formSchema),
|
resolver: zodResolver(formSchema, undefined, { raw: true }),
|
||||||
defaultValues: initialValues || {
|
defaultValues: initialValues || {
|
||||||
name: "",
|
name: "",
|
||||||
backend: "directory",
|
backend: "directory",
|
||||||
|
|
@ -89,15 +100,22 @@ export const CreateVolumeForm = ({ onSubmit, mode = "create", initialValues, for
|
||||||
|
|
||||||
const handleTestConnection = async () => {
|
const handleTestConnection = async () => {
|
||||||
const formValues = getValues();
|
const formValues = getValues();
|
||||||
|
const { name: _, ...configCandidate } = formValues;
|
||||||
|
const parsedConfig = volumeConfigSchema.safeParse(configCandidate);
|
||||||
|
|
||||||
|
if (!parsedConfig.success) {
|
||||||
|
setTestMessage({ success: false, message: "Please fix validation errors before testing the connection." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
formValues.backend === "nfs" ||
|
parsedConfig.data.backend === "nfs" ||
|
||||||
formValues.backend === "smb" ||
|
parsedConfig.data.backend === "smb" ||
|
||||||
formValues.backend === "webdav" ||
|
parsedConfig.data.backend === "webdav" ||
|
||||||
formValues.backend === "sftp"
|
parsedConfig.data.backend === "sftp"
|
||||||
) {
|
) {
|
||||||
testBackendConnection.mutate({
|
testBackendConnection.mutate({
|
||||||
body: { config: formValues },
|
body: { config: parsedConfig.data },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { HardDrive, Plus } from "lucide-react";
|
||||||
import { useId } from "react";
|
import { useId } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { createVolumeMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
import { createVolumeMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
import { CreateVolumeForm, type FormValues } from "~/client/modules/volumes/components/create-volume-form";
|
import { CreateVolumeForm, formSchema, type FormValues } from "~/client/modules/volumes/components/create-volume-form";
|
||||||
import { Button } from "~/client/components/ui/button";
|
import { Button } from "~/client/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||||
import { parseError } from "~/client/lib/errors";
|
import { parseError } from "~/client/lib/errors";
|
||||||
|
|
@ -23,10 +23,12 @@ export function CreateVolumePage() {
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = (values: FormValues) => {
|
const handleSubmit = (values: FormValues) => {
|
||||||
|
const { name, ...config } = formSchema.parse(values);
|
||||||
|
|
||||||
createVolume.mutate({
|
createVolume.mutate({
|
||||||
body: {
|
body: {
|
||||||
config: values,
|
config,
|
||||||
name: values.name,
|
name,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useMutation } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Check, Plug, Trash2, Unplug } from "lucide-react";
|
import { Check, Plug, Trash2, Unplug } from "lucide-react";
|
||||||
import { CreateVolumeForm, type FormValues } from "~/client/modules/volumes/components/create-volume-form";
|
import { CreateVolumeForm, formSchema, type FormValues } from "~/client/modules/volumes/components/create-volume-form";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
|
|
@ -102,9 +102,11 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
|
||||||
|
|
||||||
const confirmUpdate = () => {
|
const confirmUpdate = () => {
|
||||||
if (pendingValues) {
|
if (pendingValues) {
|
||||||
|
const { name, ...config } = formSchema.parse(pendingValues);
|
||||||
|
|
||||||
updateMutation.mutate({
|
updateMutation.mutate({
|
||||||
path: { shortId: volume.shortId },
|
path: { shortId: volume.shortId },
|
||||||
body: { name: pendingValues.name, config: pendingValues },
|
body: { name, config },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { type } from "arktype";
|
import { z } from "zod";
|
||||||
import { LoginPage } from "~/client/modules/auth/routes/login";
|
import { LoginPage } from "~/client/modules/auth/routes/login";
|
||||||
|
|
||||||
export const Route = createFileRoute("/(auth)/login/error")({
|
export const Route = createFileRoute("/(auth)/login/error")({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
errorComponent: () => <div>Failed to load login error</div>,
|
errorComponent: () => <div>Failed to load login error</div>,
|
||||||
validateSearch: type({ error: "string?" }),
|
validateSearch: z.object({ error: z.string().optional() }),
|
||||||
head: () => ({
|
head: () => ({
|
||||||
meta: [
|
meta: [
|
||||||
{ title: "Zerobyte - Login Error" },
|
{ title: "Zerobyte - Login Error" },
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { Outlet, createFileRoute, useRouterState } from "@tanstack/react-router";
|
import { Outlet, createFileRoute, useRouterState } from "@tanstack/react-router";
|
||||||
import { type } from "arktype";
|
import { z } from "zod";
|
||||||
import { LoginPage } from "~/client/modules/auth/routes/login";
|
import { LoginPage } from "~/client/modules/auth/routes/login";
|
||||||
|
|
||||||
export const Route = createFileRoute("/(auth)/login")({
|
export const Route = createFileRoute("/(auth)/login")({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
errorComponent: () => <div>Failed to load login</div>,
|
errorComponent: () => <div>Failed to load login</div>,
|
||||||
validateSearch: type({ error: "string?" }),
|
validateSearch: z.object({ error: z.string().optional() }),
|
||||||
head: () => ({
|
head: () => ({
|
||||||
meta: [
|
meta: [
|
||||||
{ title: "Zerobyte - Login" },
|
{ title: "Zerobyte - Login" },
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||||
import { type } from "arktype";
|
import { z } from "zod";
|
||||||
import { fetchUser } from "../route";
|
import { fetchUser } from "../route";
|
||||||
import type { AppContext } from "~/context";
|
import type { AppContext } from "~/context";
|
||||||
import { AdminPage } from "~/client/modules/admin/routes/admin-page";
|
import { AdminPage } from "~/client/modules/admin/routes/admin-page";
|
||||||
import { getAdminUsersOptions, getRegistrationStatusOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
import { getAdminUsersOptions, getRegistrationStatusOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
|
|
||||||
export const Route = createFileRoute("/(dashboard)/admin/")({
|
export const Route = createFileRoute("/(dashboard)/admin/")({
|
||||||
validateSearch: type({ tab: "string?" }),
|
validateSearch: z.object({ tab: z.string().optional() }),
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
errorComponent: () => <div>Failed to load admin</div>,
|
errorComponent: () => <div>Failed to load admin</div>,
|
||||||
loader: async ({ context }) => {
|
loader: async ({ context }) => {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { type } from "arktype";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
getBackupProgressOptions,
|
getBackupProgressOptions,
|
||||||
getBackupScheduleOptions,
|
getBackupScheduleOptions,
|
||||||
|
|
@ -15,7 +15,7 @@ import { prefetchOrSkip } from "~/utils/prefetch";
|
||||||
export const Route = createFileRoute("/(dashboard)/backups/$backupId/")({
|
export const Route = createFileRoute("/(dashboard)/backups/$backupId/")({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
errorComponent: () => <div>Failed to load backup</div>,
|
errorComponent: () => <div>Failed to load backup</div>,
|
||||||
validateSearch: type({ snapshot: "string?" }),
|
validateSearch: z.object({ snapshot: z.string().optional() }),
|
||||||
loader: async ({ params, context }) => {
|
loader: async ({ params, context }) => {
|
||||||
const { backupId } = params;
|
const { backupId } = params;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { type } from "arktype";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
getRepositoryOptions,
|
getRepositoryOptions,
|
||||||
getRepositoryStatsOptions,
|
getRepositoryStatsOptions,
|
||||||
|
|
@ -30,7 +30,7 @@ export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/")
|
||||||
stats: context.queryClient.getQueryData(statsOptions.queryKey),
|
stats: context.queryClient.getQueryData(statsOptions.queryKey),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
validateSearch: type({ tab: "string?" }),
|
validateSearch: z.object({ tab: z.string().optional() }),
|
||||||
staticData: {
|
staticData: {
|
||||||
breadcrumb: (match) => [
|
breadcrumb: (match) => [
|
||||||
{ label: "Repositories", href: "/repositories" },
|
{ label: "Repositories", href: "/repositories" },
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,14 @@ import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { fetchUser } from "../route";
|
import { fetchUser } from "../route";
|
||||||
import type { AppContext } from "~/context";
|
import type { AppContext } from "~/context";
|
||||||
import { SettingsPage } from "~/client/modules/settings/routes/settings";
|
import { SettingsPage } from "~/client/modules/settings/routes/settings";
|
||||||
import { type } from "arktype";
|
import { z } from "zod";
|
||||||
|
import { getOrganizationContext } from "~/server/lib/functions/organization-context";
|
||||||
import { getOrgMembersOptions, getSsoSettingsOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
import { getOrgMembersOptions, getSsoSettingsOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
import { getOrigin } from "~/client/functions/get-origin";
|
import { getOrigin } from "~/client/functions/get-origin";
|
||||||
import { getOrganizationContext } from "~/server/lib/functions/organization-context";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/(dashboard)/settings/")({
|
export const Route = createFileRoute("/(dashboard)/settings/")({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
validateSearch: type({ tab: "string?" }),
|
validateSearch: z.object({ tab: z.string().optional() }),
|
||||||
errorComponent: () => <div>Failed to load settings</div>,
|
errorComponent: () => <div>Failed to load settings</div>,
|
||||||
loader: async ({ context }) => {
|
loader: async ({ context }) => {
|
||||||
const [authContext, orgContext] = await Promise.all([
|
const [authContext, orgContext] = await Promise.all([
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { type } from "arktype";
|
import { z } from "zod";
|
||||||
import { getVolumeOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
import { getVolumeOptions } from "~/client/api-client/@tanstack/react-query.gen";
|
||||||
import { VolumeDetails } from "~/client/modules/volumes/routes/volume-details";
|
import { VolumeDetails } from "~/client/modules/volumes/routes/volume-details";
|
||||||
|
|
||||||
|
|
@ -13,7 +13,7 @@ export const Route = createFileRoute("/(dashboard)/volumes/$volumeId")({
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
},
|
},
|
||||||
validateSearch: type({ tab: "string?" }),
|
validateSearch: z.object({ tab: z.string().optional() }),
|
||||||
staticData: {
|
staticData: {
|
||||||
breadcrumb: (match) => [
|
breadcrumb: (match) => [
|
||||||
{ label: "Volumes", href: "/volumes" },
|
{ label: "Volumes", href: "/volumes" },
|
||||||
|
|
|
||||||
|
|
@ -1,85 +1,84 @@
|
||||||
import { type } from "arktype";
|
import { z } from "zod";
|
||||||
import { resticBackupProgressMetricsSchema, resticBackupRunSummarySchema } from "~/schemas/restic-dto";
|
import { resticBackupProgressMetricsSchema, resticBackupRunSummarySchema } from "~/schemas/restic-dto";
|
||||||
|
|
||||||
export const backupEventStatusSchema = type("'success' | 'error' | 'stopped' | 'warning'");
|
export const backupEventStatusSchema = z.enum(["success", "error", "stopped", "warning"]);
|
||||||
export const restoreEventStatusSchema = type("'success' | 'error'");
|
export const restoreEventStatusSchema = z.enum(["success", "error"]);
|
||||||
|
|
||||||
const backupEventBaseSchema = type({
|
const backupEventBaseSchema = z.object({
|
||||||
scheduleId: "string",
|
scheduleId: z.string(),
|
||||||
volumeName: "string",
|
volumeName: z.string(),
|
||||||
repositoryName: "string",
|
repositoryName: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const organizationScopedSchema = type({
|
const organizationScopedSchema = z.object({
|
||||||
organizationId: "string",
|
organizationId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const restoreEventBaseSchema = type({
|
const restoreEventBaseSchema = z.object({
|
||||||
repositoryId: "string",
|
repositoryId: z.string(),
|
||||||
snapshotId: "string",
|
snapshotId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const dumpStartedEventSchema = type({
|
const dumpStartedEventSchema = z.object({
|
||||||
repositoryId: "string",
|
repositoryId: z.string(),
|
||||||
snapshotId: "string",
|
snapshotId: z.string(),
|
||||||
path: "string",
|
path: z.string(),
|
||||||
filename: "string",
|
filename: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const restoreProgressMetricsSchema = type({
|
const restoreProgressMetricsSchema = z.object({
|
||||||
seconds_elapsed: "number",
|
seconds_elapsed: z.number(),
|
||||||
percent_done: "number",
|
percent_done: z.number(),
|
||||||
total_files: "number",
|
total_files: z.number(),
|
||||||
files_restored: "number",
|
files_restored: z.number(),
|
||||||
total_bytes: "number",
|
total_bytes: z.number(),
|
||||||
bytes_restored: "number",
|
bytes_restored: z.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const backupStartedEventSchema = backupEventBaseSchema;
|
export const backupStartedEventSchema = backupEventBaseSchema;
|
||||||
|
|
||||||
export const backupProgressEventSchema = backupEventBaseSchema.and(resticBackupProgressMetricsSchema);
|
export const backupProgressEventSchema = backupEventBaseSchema.extend(resticBackupProgressMetricsSchema.shape);
|
||||||
|
|
||||||
export const backupCompletedEventSchema = backupEventBaseSchema.and(
|
export const backupCompletedEventSchema = backupEventBaseSchema.extend({
|
||||||
type({
|
status: backupEventStatusSchema,
|
||||||
status: backupEventStatusSchema,
|
summary: resticBackupRunSummarySchema.optional(),
|
||||||
summary: resticBackupRunSummarySchema.optional(),
|
});
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const restoreStartedEventSchema = restoreEventBaseSchema;
|
export const restoreStartedEventSchema = restoreEventBaseSchema;
|
||||||
|
|
||||||
export const restoreProgressEventSchema = restoreEventBaseSchema.and(restoreProgressMetricsSchema);
|
export const restoreProgressEventSchema = restoreEventBaseSchema.extend(restoreProgressMetricsSchema.shape);
|
||||||
|
|
||||||
export const restoreCompletedEventSchema = restoreEventBaseSchema.and(
|
export const restoreCompletedEventSchema = restoreEventBaseSchema.extend({
|
||||||
type({ status: restoreEventStatusSchema, error: "string?" }),
|
status: restoreEventStatusSchema,
|
||||||
);
|
error: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
export const serverBackupStartedEventSchema = organizationScopedSchema.and(backupStartedEventSchema);
|
export const serverBackupStartedEventSchema = organizationScopedSchema.extend(backupStartedEventSchema.shape);
|
||||||
|
|
||||||
export const serverBackupProgressEventSchema = organizationScopedSchema.and(backupProgressEventSchema);
|
export const serverBackupProgressEventSchema = organizationScopedSchema.extend(backupProgressEventSchema.shape);
|
||||||
|
|
||||||
export const serverBackupCompletedEventSchema = organizationScopedSchema.and(backupCompletedEventSchema);
|
export const serverBackupCompletedEventSchema = organizationScopedSchema.extend(backupCompletedEventSchema.shape);
|
||||||
|
|
||||||
export const serverRestoreStartedEventSchema = organizationScopedSchema.and(restoreStartedEventSchema);
|
export const serverRestoreStartedEventSchema = organizationScopedSchema.extend(restoreStartedEventSchema.shape);
|
||||||
|
|
||||||
export const serverRestoreProgressEventSchema = organizationScopedSchema.and(restoreProgressEventSchema);
|
export const serverRestoreProgressEventSchema = organizationScopedSchema.extend(restoreProgressEventSchema.shape);
|
||||||
|
|
||||||
export const serverRestoreCompletedEventSchema = organizationScopedSchema.and(restoreCompletedEventSchema);
|
export const serverRestoreCompletedEventSchema = organizationScopedSchema.extend(restoreCompletedEventSchema.shape);
|
||||||
|
|
||||||
export const serverDumpStartedEventSchema = organizationScopedSchema.and(dumpStartedEventSchema);
|
export const serverDumpStartedEventSchema = organizationScopedSchema.extend(dumpStartedEventSchema.shape);
|
||||||
|
|
||||||
export type BackupEventStatusDto = typeof backupEventStatusSchema.infer;
|
export type BackupEventStatusDto = z.infer<typeof backupEventStatusSchema>;
|
||||||
export type BackupStartedEventDto = typeof backupStartedEventSchema.infer;
|
export type BackupStartedEventDto = z.infer<typeof backupStartedEventSchema>;
|
||||||
export type BackupProgressEventDto = typeof backupProgressEventSchema.infer;
|
export type BackupProgressEventDto = z.infer<typeof backupProgressEventSchema>;
|
||||||
export type BackupCompletedEventDto = typeof backupCompletedEventSchema.infer;
|
export type BackupCompletedEventDto = z.infer<typeof backupCompletedEventSchema>;
|
||||||
export type RestoreStartedEventDto = typeof restoreStartedEventSchema.infer;
|
export type RestoreStartedEventDto = z.infer<typeof restoreStartedEventSchema>;
|
||||||
export type RestoreProgressEventDto = typeof restoreProgressEventSchema.infer;
|
export type RestoreProgressEventDto = z.infer<typeof restoreProgressEventSchema>;
|
||||||
export type RestoreCompletedEventDto = typeof restoreCompletedEventSchema.infer;
|
export type RestoreCompletedEventDto = z.infer<typeof restoreCompletedEventSchema>;
|
||||||
export type DumpStartedEventDto = typeof dumpStartedEventSchema.infer;
|
export type DumpStartedEventDto = z.infer<typeof dumpStartedEventSchema>;
|
||||||
export type ServerBackupStartedEventDto = typeof serverBackupStartedEventSchema.infer;
|
export type ServerBackupStartedEventDto = z.infer<typeof serverBackupStartedEventSchema>;
|
||||||
export type ServerBackupProgressEventDto = typeof serverBackupProgressEventSchema.infer;
|
export type ServerBackupProgressEventDto = z.infer<typeof serverBackupProgressEventSchema>;
|
||||||
export type ServerBackupCompletedEventDto = typeof serverBackupCompletedEventSchema.infer;
|
export type ServerBackupCompletedEventDto = z.infer<typeof serverBackupCompletedEventSchema>;
|
||||||
export type ServerRestoreStartedEventDto = typeof serverRestoreStartedEventSchema.infer;
|
export type ServerRestoreStartedEventDto = z.infer<typeof serverRestoreStartedEventSchema>;
|
||||||
export type ServerRestoreProgressEventDto = typeof serverRestoreProgressEventSchema.infer;
|
export type ServerRestoreProgressEventDto = z.infer<typeof serverRestoreProgressEventSchema>;
|
||||||
export type ServerRestoreCompletedEventDto = typeof serverRestoreCompletedEventSchema.infer;
|
export type ServerRestoreCompletedEventDto = z.infer<typeof serverRestoreCompletedEventSchema>;
|
||||||
export type ServerDumpStartedEventDto = typeof serverDumpStartedEventSchema.infer;
|
export type ServerDumpStartedEventDto = z.infer<typeof serverDumpStartedEventSchema>;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { type } from "arktype";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const NOTIFICATION_TYPES = {
|
export const NOTIFICATION_TYPES = {
|
||||||
email: "email",
|
email: "email",
|
||||||
|
|
@ -14,96 +14,98 @@ export const NOTIFICATION_TYPES = {
|
||||||
|
|
||||||
export type NotificationType = keyof typeof NOTIFICATION_TYPES;
|
export type NotificationType = keyof typeof NOTIFICATION_TYPES;
|
||||||
|
|
||||||
export const emailNotificationConfigSchema = type({
|
export const emailNotificationConfigSchema = z.object({
|
||||||
type: "'email'",
|
type: z.literal("email"),
|
||||||
smtpHost: "string",
|
smtpHost: z.string().min(1),
|
||||||
smtpPort: "1 <= number <= 65535",
|
smtpPort: z.number().int().min(1).max(65535),
|
||||||
username: "string?",
|
username: z.string().optional(),
|
||||||
password: "string?",
|
password: z.string().optional(),
|
||||||
from: "string",
|
from: z.string().min(1),
|
||||||
fromName: "string?",
|
fromName: z.string().optional(),
|
||||||
to: "string[]",
|
to: z.array(z.string()),
|
||||||
useTLS: "boolean",
|
useTLS: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const slackNotificationConfigSchema = type({
|
export const slackNotificationConfigSchema = z.object({
|
||||||
type: "'slack'",
|
type: z.literal("slack"),
|
||||||
webhookUrl: "string",
|
webhookUrl: z.string().min(1),
|
||||||
channel: "string?",
|
channel: z.string().optional(),
|
||||||
username: "string?",
|
username: z.string().optional(),
|
||||||
iconEmoji: "string?",
|
iconEmoji: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const discordNotificationConfigSchema = type({
|
export const discordNotificationConfigSchema = z.object({
|
||||||
type: "'discord'",
|
type: z.literal("discord"),
|
||||||
webhookUrl: "string",
|
webhookUrl: z.string().min(1),
|
||||||
username: "string?",
|
username: z.string().optional(),
|
||||||
avatarUrl: "string?",
|
avatarUrl: z.string().optional(),
|
||||||
threadId: "string?",
|
threadId: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const gotifyNotificationConfigSchema = type({
|
export const gotifyNotificationConfigSchema = z.object({
|
||||||
type: "'gotify'",
|
type: z.literal("gotify"),
|
||||||
serverUrl: "string",
|
serverUrl: z.string().min(1),
|
||||||
token: "string",
|
token: z.string().min(1),
|
||||||
path: "string?",
|
path: z.string().optional(),
|
||||||
priority: "0 <= number <= 10",
|
priority: z.number().min(0).max(10),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ntfyNotificationConfigSchema = type({
|
export const ntfyNotificationConfigSchema = z.object({
|
||||||
type: "'ntfy'",
|
type: z.literal("ntfy"),
|
||||||
serverUrl: "string?",
|
serverUrl: z.string().optional(),
|
||||||
topic: "string",
|
topic: z.string().min(1),
|
||||||
priority: "'max' | 'high' | 'default' | 'low' | 'min'",
|
priority: z.enum(["max", "high", "default", "low", "min"]),
|
||||||
username: "string?",
|
username: z.string().optional(),
|
||||||
password: "string?",
|
password: z.string().optional(),
|
||||||
accessToken: "string?",
|
accessToken: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const pushoverNotificationConfigSchema = type({
|
export const pushoverNotificationConfigSchema = z.object({
|
||||||
type: "'pushover'",
|
type: z.literal("pushover"),
|
||||||
userKey: "string",
|
userKey: z.string().min(1),
|
||||||
apiToken: "string",
|
apiToken: z.string().min(1),
|
||||||
devices: "string?",
|
devices: z.string().optional(),
|
||||||
priority: "-1 | 0 | 1",
|
priority: z.union([z.literal(-1), z.literal(0), z.literal(1)]),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const telegramNotificationConfigSchema = type({
|
export const telegramNotificationConfigSchema = z.object({
|
||||||
type: "'telegram'",
|
type: z.literal("telegram"),
|
||||||
botToken: "string",
|
botToken: z.string().min(1),
|
||||||
chatId: "string",
|
chatId: z.string().min(1),
|
||||||
threadId: "string?",
|
threadId: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const genericNotificationConfigSchema = type({
|
export const genericNotificationConfigSchema = z.object({
|
||||||
type: "'generic'",
|
type: z.literal("generic"),
|
||||||
url: "string",
|
url: z.string().min(1),
|
||||||
method: "'GET' | 'POST'",
|
method: z.enum(["GET", "POST"]),
|
||||||
contentType: "string?",
|
contentType: z.string().optional(),
|
||||||
headers: "string[]?",
|
headers: z.array(z.string()).optional(),
|
||||||
useJson: "boolean?",
|
useJson: z.boolean().optional(),
|
||||||
titleKey: "string?",
|
titleKey: z.string().optional(),
|
||||||
messageKey: "string?",
|
messageKey: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const customNotificationConfigSchema = type({
|
export const customNotificationConfigSchema = z.object({
|
||||||
type: "'custom'",
|
type: z.literal("custom"),
|
||||||
shoutrrrUrl: "string",
|
shoutrrrUrl: z.string().min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const notificationConfigSchemaBase = emailNotificationConfigSchema
|
export const notificationConfigSchemaBase = z.discriminatedUnion("type", [
|
||||||
.or(slackNotificationConfigSchema)
|
emailNotificationConfigSchema,
|
||||||
.or(discordNotificationConfigSchema)
|
slackNotificationConfigSchema,
|
||||||
.or(gotifyNotificationConfigSchema)
|
discordNotificationConfigSchema,
|
||||||
.or(ntfyNotificationConfigSchema)
|
gotifyNotificationConfigSchema,
|
||||||
.or(pushoverNotificationConfigSchema)
|
ntfyNotificationConfigSchema,
|
||||||
.or(telegramNotificationConfigSchema)
|
pushoverNotificationConfigSchema,
|
||||||
.or(genericNotificationConfigSchema)
|
telegramNotificationConfigSchema,
|
||||||
.or(customNotificationConfigSchema);
|
genericNotificationConfigSchema,
|
||||||
|
customNotificationConfigSchema,
|
||||||
|
]);
|
||||||
|
|
||||||
export const notificationConfigSchema = notificationConfigSchemaBase.onUndeclaredKey("delete");
|
export const notificationConfigSchema = notificationConfigSchemaBase;
|
||||||
|
|
||||||
export type NotificationConfig = typeof notificationConfigSchema.infer;
|
export type NotificationConfig = z.infer<typeof notificationConfigSchema>;
|
||||||
|
|
||||||
export const NOTIFICATION_EVENTS = {
|
export const NOTIFICATION_EVENTS = {
|
||||||
start: "start",
|
start: "start",
|
||||||
|
|
|
||||||
|
|
@ -1,83 +1,72 @@
|
||||||
import { type } from "arktype";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const resticSummaryBaseSchema = type({
|
export const resticSummaryBaseSchema = z.object({
|
||||||
files_new: "number",
|
files_new: z.number(),
|
||||||
files_changed: "number",
|
files_changed: z.number(),
|
||||||
files_unmodified: "number",
|
files_unmodified: z.number(),
|
||||||
dirs_new: "number",
|
dirs_new: z.number(),
|
||||||
dirs_changed: "number",
|
dirs_changed: z.number(),
|
||||||
dirs_unmodified: "number",
|
dirs_unmodified: z.number(),
|
||||||
data_blobs: "number",
|
data_blobs: z.number(),
|
||||||
tree_blobs: "number",
|
tree_blobs: z.number(),
|
||||||
data_added: "number",
|
data_added: z.number(),
|
||||||
data_added_packed: "number?",
|
data_added_packed: z.number().optional(),
|
||||||
total_files_processed: "number",
|
total_files_processed: z.number(),
|
||||||
total_bytes_processed: "number",
|
total_bytes_processed: z.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const resticSnapshotSummarySchema = resticSummaryBaseSchema.and(
|
export const resticSnapshotSummarySchema = resticSummaryBaseSchema.extend({
|
||||||
type({
|
backup_start: z.string(),
|
||||||
backup_start: "string",
|
backup_end: z.string(),
|
||||||
backup_end: "string",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const resticBackupRunSummarySchema = resticSummaryBaseSchema.and(
|
|
||||||
type({
|
|
||||||
total_duration: "number",
|
|
||||||
snapshot_id: "string",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const resticBackupOutputSchema = resticBackupRunSummarySchema.and(
|
|
||||||
type({
|
|
||||||
message_type: "'summary'",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const resticBackupProgressMetricsSchema = type({
|
|
||||||
seconds_elapsed: "number",
|
|
||||||
seconds_remaining: "number = 0",
|
|
||||||
percent_done: "number",
|
|
||||||
total_files: "number",
|
|
||||||
files_done: "number",
|
|
||||||
total_bytes: "number",
|
|
||||||
bytes_done: "number",
|
|
||||||
current_files: type("string")
|
|
||||||
.array()
|
|
||||||
.default(() => []),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const resticBackupProgressSchema = resticBackupProgressMetricsSchema.and(
|
export const resticBackupRunSummarySchema = resticSummaryBaseSchema.extend({
|
||||||
type({
|
total_duration: z.number(),
|
||||||
message_type: "'status'",
|
snapshot_id: z.string(),
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const resticRestoreOutputSchema = type({
|
|
||||||
message_type: "'summary'",
|
|
||||||
total_files: "number?",
|
|
||||||
files_restored: "number",
|
|
||||||
files_skipped: "number",
|
|
||||||
total_bytes: "number?",
|
|
||||||
bytes_restored: "number?",
|
|
||||||
bytes_skipped: "number",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const resticStatsSchema = type({
|
export const resticBackupOutputSchema = resticBackupRunSummarySchema.extend({
|
||||||
total_size: "number = 0",
|
message_type: z.literal("summary"),
|
||||||
total_uncompressed_size: "number = 0",
|
|
||||||
compression_ratio: "number = 0",
|
|
||||||
compression_progress: "number = 0",
|
|
||||||
compression_space_saving: "number = 0",
|
|
||||||
snapshots_count: "number = 0",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ResticSnapshotSummaryDto = typeof resticSnapshotSummarySchema.infer;
|
export const resticBackupProgressMetricsSchema = z.object({
|
||||||
export type ResticBackupRunSummaryDto = typeof resticBackupRunSummarySchema.infer;
|
seconds_elapsed: z.number(),
|
||||||
export type ResticBackupOutputDto = typeof resticBackupOutputSchema.infer;
|
percent_done: z.number(),
|
||||||
export type ResticBackupProgressMetricsDto = typeof resticBackupProgressMetricsSchema.infer;
|
total_files: z.number(),
|
||||||
export type ResticBackupProgressDto = typeof resticBackupProgressSchema.infer;
|
files_done: z.number(),
|
||||||
|
total_bytes: z.number(),
|
||||||
|
bytes_done: z.number(),
|
||||||
|
current_files: z.array(z.string()).default([]),
|
||||||
|
});
|
||||||
|
|
||||||
export type ResticRestoreOutputDto = typeof resticRestoreOutputSchema.infer;
|
export const resticBackupProgressSchema = resticBackupProgressMetricsSchema.extend({
|
||||||
export type ResticStatsDto = typeof resticStatsSchema.infer;
|
message_type: z.literal("status"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const resticRestoreOutputSchema = z.object({
|
||||||
|
message_type: z.literal("summary"),
|
||||||
|
total_files: z.number().optional(),
|
||||||
|
files_restored: z.number(),
|
||||||
|
files_skipped: z.number(),
|
||||||
|
total_bytes: z.number().optional(),
|
||||||
|
bytes_restored: z.number().optional(),
|
||||||
|
bytes_skipped: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const resticStatsSchema = z.object({
|
||||||
|
total_size: z.number().default(0),
|
||||||
|
total_uncompressed_size: z.number().default(0),
|
||||||
|
compression_ratio: z.number().default(0),
|
||||||
|
compression_progress: z.number().default(0),
|
||||||
|
compression_space_saving: z.number().default(0),
|
||||||
|
snapshots_count: z.number().default(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ResticSnapshotSummaryDto = z.infer<typeof resticSnapshotSummarySchema>;
|
||||||
|
export type ResticBackupRunSummaryDto = z.infer<typeof resticBackupRunSummarySchema>;
|
||||||
|
export type ResticBackupOutputDto = z.infer<typeof resticBackupOutputSchema>;
|
||||||
|
export type ResticBackupProgressMetricsDto = z.infer<typeof resticBackupProgressMetricsSchema>;
|
||||||
|
export type ResticBackupProgressDto = z.infer<typeof resticBackupProgressSchema>;
|
||||||
|
|
||||||
|
export type ResticRestoreOutputDto = z.infer<typeof resticRestoreOutputSchema>;
|
||||||
|
export type ResticStatsDto = z.infer<typeof resticStatsSchema>;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { type } from "arktype";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const REPOSITORY_BACKENDS = {
|
export const REPOSITORY_BACKENDS = {
|
||||||
local: "local",
|
local: "local",
|
||||||
|
|
@ -21,98 +21,120 @@ export const BANDWIDTH_UNITS = {
|
||||||
|
|
||||||
export type BandwidthUnit = keyof typeof BANDWIDTH_UNITS;
|
export type BandwidthUnit = keyof typeof BANDWIDTH_UNITS;
|
||||||
|
|
||||||
export const bandwidthLimitSchema = type({
|
const bandwidthUnitSchema = z.enum(["Kbps", "Mbps", "Gbps"]);
|
||||||
enabled: "boolean = false",
|
|
||||||
value: "number > 0 = 1",
|
export const bandwidthLimitSchema = z.object({
|
||||||
unit: type.valueOf(BANDWIDTH_UNITS).default("Mbps"),
|
enabled: z.boolean().default(false),
|
||||||
|
value: z.number().positive().default(1),
|
||||||
|
unit: bandwidthUnitSchema.default("Mbps"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type BandwidthLimit = typeof bandwidthLimitSchema.infer;
|
export type BandwidthLimit = z.infer<typeof bandwidthLimitSchema>;
|
||||||
|
|
||||||
// Common fields for all repository configs
|
const baseRepositoryConfigSchema = z.object({
|
||||||
const baseRepositoryConfigSchema = type({
|
isExistingRepository: z.boolean().optional(),
|
||||||
isExistingRepository: "boolean?",
|
customPassword: z.string().optional(),
|
||||||
customPassword: "string?",
|
cacert: z.string().optional(),
|
||||||
cacert: "string?",
|
insecureTls: z.boolean().optional(),
|
||||||
insecureTls: "boolean?",
|
|
||||||
// Bandwidth controls
|
|
||||||
uploadLimit: bandwidthLimitSchema.optional(),
|
uploadLimit: bandwidthLimitSchema.optional(),
|
||||||
downloadLimit: bandwidthLimitSchema.optional(),
|
downloadLimit: bandwidthLimitSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const s3RepositoryConfigSchema = type({
|
export const s3RepositoryConfigSchema = z
|
||||||
backend: "'s3'",
|
.object({
|
||||||
endpoint: "string",
|
backend: z.literal("s3"),
|
||||||
bucket: "string",
|
endpoint: z.string().min(1),
|
||||||
accessKeyId: "string",
|
bucket: z.string().min(1),
|
||||||
secretAccessKey: "string",
|
accessKeyId: z.string().min(1),
|
||||||
}).and(baseRepositoryConfigSchema);
|
secretAccessKey: z.string().min(1),
|
||||||
|
})
|
||||||
|
.extend(baseRepositoryConfigSchema.shape);
|
||||||
|
|
||||||
export const r2RepositoryConfigSchema = type({
|
export const r2RepositoryConfigSchema = z
|
||||||
backend: "'r2'",
|
.object({
|
||||||
endpoint: "string",
|
backend: z.literal("r2"),
|
||||||
bucket: "string",
|
endpoint: z.string().min(1),
|
||||||
accessKeyId: "string",
|
bucket: z.string().min(1),
|
||||||
secretAccessKey: "string",
|
accessKeyId: z.string().min(1),
|
||||||
}).and(baseRepositoryConfigSchema);
|
secretAccessKey: z.string().min(1),
|
||||||
|
})
|
||||||
|
.extend(baseRepositoryConfigSchema.shape);
|
||||||
|
|
||||||
export const localRepositoryConfigSchema = type({
|
export const localRepositoryConfigSchema = z
|
||||||
backend: "'local'",
|
.object({
|
||||||
path: "string",
|
backend: z.literal("local"),
|
||||||
}).and(baseRepositoryConfigSchema);
|
path: z.string().min(1),
|
||||||
|
})
|
||||||
|
.extend(baseRepositoryConfigSchema.shape);
|
||||||
|
|
||||||
export const gcsRepositoryConfigSchema = type({
|
export const gcsRepositoryConfigSchema = z
|
||||||
backend: "'gcs'",
|
.object({
|
||||||
bucket: "string",
|
backend: z.literal("gcs"),
|
||||||
projectId: "string",
|
bucket: z.string().min(1),
|
||||||
credentialsJson: "string",
|
projectId: z.string().min(1),
|
||||||
}).and(baseRepositoryConfigSchema);
|
credentialsJson: z.string().min(1),
|
||||||
|
})
|
||||||
|
.extend(baseRepositoryConfigSchema.shape);
|
||||||
|
|
||||||
export const azureRepositoryConfigSchema = type({
|
export const azureRepositoryConfigSchema = z
|
||||||
backend: "'azure'",
|
.object({
|
||||||
container: "string",
|
backend: z.literal("azure"),
|
||||||
accountName: "string",
|
container: z.string().min(1),
|
||||||
accountKey: "string",
|
accountName: z.string().min(1),
|
||||||
endpointSuffix: "string?",
|
accountKey: z.string().min(1),
|
||||||
}).and(baseRepositoryConfigSchema);
|
endpointSuffix: z.string().optional(),
|
||||||
|
})
|
||||||
|
.extend(baseRepositoryConfigSchema.shape);
|
||||||
|
|
||||||
export const rcloneRepositoryConfigSchema = type({
|
export const rcloneRepositoryConfigSchema = z
|
||||||
backend: "'rclone'",
|
.object({
|
||||||
remote: "string",
|
backend: z.literal("rclone"),
|
||||||
path: "string",
|
remote: z.string().min(1),
|
||||||
}).and(baseRepositoryConfigSchema);
|
path: z.string().min(1),
|
||||||
|
})
|
||||||
|
.extend(baseRepositoryConfigSchema.shape);
|
||||||
|
|
||||||
export const restRepositoryConfigSchema = type({
|
export const restRepositoryConfigSchema = z
|
||||||
backend: "'rest'",
|
.object({
|
||||||
url: "string",
|
backend: z.literal("rest"),
|
||||||
username: "string?",
|
url: z.string().min(1),
|
||||||
password: "string?",
|
username: z.string().optional(),
|
||||||
path: "string?",
|
password: z.string().optional(),
|
||||||
}).and(baseRepositoryConfigSchema);
|
path: z.string().optional(),
|
||||||
|
})
|
||||||
|
.extend(baseRepositoryConfigSchema.shape);
|
||||||
|
|
||||||
export const sftpRepositoryConfigSchema = type({
|
export const sftpRepositoryConfigSchema = z
|
||||||
backend: "'sftp'",
|
.object({
|
||||||
host: "string",
|
backend: z.literal("sftp"),
|
||||||
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(22),
|
host: z.string().min(1),
|
||||||
user: "string",
|
port: z
|
||||||
path: "string",
|
.union([z.string(), z.number()])
|
||||||
privateKey: "string",
|
.transform((value) => (typeof value === "string" ? Number.parseInt(value, 10) : value))
|
||||||
skipHostKeyCheck: "boolean = true",
|
.pipe(z.number().int().min(1).max(65535))
|
||||||
knownHosts: "string?",
|
.default(22),
|
||||||
}).and(baseRepositoryConfigSchema);
|
user: z.string().min(1),
|
||||||
|
path: z.string().min(1),
|
||||||
|
privateKey: z.string().min(1),
|
||||||
|
skipHostKeyCheck: z.boolean().default(true),
|
||||||
|
knownHosts: z.string().optional(),
|
||||||
|
})
|
||||||
|
.extend(baseRepositoryConfigSchema.shape);
|
||||||
|
|
||||||
export const repositoryConfigSchemaBase = s3RepositoryConfigSchema
|
export const repositoryConfigSchemaBase = z.discriminatedUnion("backend", [
|
||||||
.or(r2RepositoryConfigSchema)
|
s3RepositoryConfigSchema,
|
||||||
.or(localRepositoryConfigSchema)
|
r2RepositoryConfigSchema,
|
||||||
.or(gcsRepositoryConfigSchema)
|
localRepositoryConfigSchema,
|
||||||
.or(azureRepositoryConfigSchema)
|
gcsRepositoryConfigSchema,
|
||||||
.or(rcloneRepositoryConfigSchema)
|
azureRepositoryConfigSchema,
|
||||||
.or(restRepositoryConfigSchema)
|
rcloneRepositoryConfigSchema,
|
||||||
.or(sftpRepositoryConfigSchema);
|
restRepositoryConfigSchema,
|
||||||
|
sftpRepositoryConfigSchema,
|
||||||
|
]);
|
||||||
|
|
||||||
export const repositoryConfigSchema = repositoryConfigSchemaBase.onUndeclaredKey("delete");
|
export const repositoryConfigSchema = repositoryConfigSchemaBase;
|
||||||
|
|
||||||
export type RepositoryConfig = typeof repositoryConfigSchema.infer;
|
export type RepositoryConfig = z.infer<typeof repositoryConfigSchema>;
|
||||||
|
|
||||||
export const COMPRESSION_MODES = {
|
export const COMPRESSION_MODES = {
|
||||||
off: "off",
|
off: "off",
|
||||||
|
|
@ -132,22 +154,22 @@ export const REPOSITORY_STATUS = {
|
||||||
|
|
||||||
export type RepositoryStatus = keyof typeof REPOSITORY_STATUS;
|
export type RepositoryStatus = keyof typeof REPOSITORY_STATUS;
|
||||||
|
|
||||||
export const doctorStepSchema = type({
|
export const doctorStepSchema = z.object({
|
||||||
step: "string",
|
step: z.string(),
|
||||||
success: "boolean",
|
success: z.boolean(),
|
||||||
output: "string | null",
|
output: z.string().nullable(),
|
||||||
error: "string | null",
|
error: z.string().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type DoctorStep = typeof doctorStepSchema.infer;
|
export type DoctorStep = z.infer<typeof doctorStepSchema>;
|
||||||
|
|
||||||
export const doctorResultSchema = type({
|
export const doctorResultSchema = z.object({
|
||||||
success: "boolean",
|
success: z.boolean(),
|
||||||
steps: doctorStepSchema.array(),
|
steps: doctorStepSchema.array(),
|
||||||
completedAt: "number",
|
completedAt: z.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type DoctorResult = typeof doctorResultSchema.infer;
|
export type DoctorResult = z.infer<typeof doctorResultSchema>;
|
||||||
|
|
||||||
export const OVERWRITE_MODES = {
|
export const OVERWRITE_MODES = {
|
||||||
always: "always",
|
always: "always",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { type } from "arktype";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const BACKEND_TYPES = {
|
export const BACKEND_TYPES = {
|
||||||
nfs: "nfs",
|
nfs: "nfs",
|
||||||
|
|
@ -11,75 +11,93 @@ export const BACKEND_TYPES = {
|
||||||
|
|
||||||
export type BackendType = keyof typeof BACKEND_TYPES;
|
export type BackendType = keyof typeof BACKEND_TYPES;
|
||||||
|
|
||||||
export const nfsConfigSchema = type({
|
export const nfsConfigSchema = z.object({
|
||||||
backend: "'nfs'",
|
backend: z.literal("nfs"),
|
||||||
server: "string",
|
server: z.string().min(1),
|
||||||
exportPath: "string",
|
exportPath: z.string().min(1),
|
||||||
port: type("string.integer").or(type("number")).to("1 <= number <= 65536").default(2049),
|
port: z
|
||||||
version: "'3' | '4' | '4.1'",
|
.union([z.string(), z.number()])
|
||||||
readOnly: "boolean?",
|
.transform((value) => (typeof value === "string" ? Number.parseInt(value, 10) : value))
|
||||||
|
.pipe(z.number().int().min(1).max(65535))
|
||||||
|
.default(2049),
|
||||||
|
version: z.enum(["3", "4", "4.1"]),
|
||||||
|
readOnly: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const smbConfigSchema = type({
|
export const smbConfigSchema = z.object({
|
||||||
backend: "'smb'",
|
backend: z.literal("smb"),
|
||||||
server: "string",
|
server: z.string().min(1),
|
||||||
share: "string",
|
share: z.string().min(1),
|
||||||
username: "string?",
|
username: z.string().optional(),
|
||||||
password: "string?",
|
password: z.string().optional(),
|
||||||
guest: "boolean?",
|
guest: z.boolean().optional(),
|
||||||
vers: type("'1.0' | '2.0' | '2.1' | '3.0' | 'auto'").default("auto"),
|
vers: z.enum(["1.0", "2.0", "2.1", "3.0", "auto"]).default("auto"),
|
||||||
domain: "string?",
|
domain: z.string().optional(),
|
||||||
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(445),
|
port: z
|
||||||
readOnly: "boolean?",
|
.union([z.string(), z.number()])
|
||||||
|
.transform((value) => (typeof value === "string" ? Number.parseInt(value, 10) : value))
|
||||||
|
.pipe(z.number().int().min(1).max(65535))
|
||||||
|
.default(445),
|
||||||
|
readOnly: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const directoryConfigSchema = type({
|
export const directoryConfigSchema = z.object({
|
||||||
backend: "'directory'",
|
backend: z.literal("directory"),
|
||||||
path: "string",
|
path: z.string().min(1),
|
||||||
readOnly: "false?",
|
readOnly: z.literal(false).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const webdavConfigSchema = type({
|
export const webdavConfigSchema = z.object({
|
||||||
backend: "'webdav'",
|
backend: z.literal("webdav"),
|
||||||
server: "string",
|
server: z.string().min(1),
|
||||||
path: "string",
|
path: z.string().min(1),
|
||||||
username: "string?",
|
username: z.string().optional(),
|
||||||
password: "string?",
|
password: z.string().optional(),
|
||||||
port: type("string.integer").or(type("number")).to("1 <= number <= 65536").default(80),
|
port: z
|
||||||
readOnly: "boolean?",
|
.union([z.string(), z.number()])
|
||||||
ssl: "boolean?",
|
.transform((value) => (typeof value === "string" ? Number.parseInt(value, 10) : value))
|
||||||
|
.pipe(z.number().int().min(1).max(65535))
|
||||||
|
.default(80),
|
||||||
|
readOnly: z.boolean().optional(),
|
||||||
|
ssl: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const rcloneConfigSchema = type({
|
export const rcloneConfigSchema = z.object({
|
||||||
backend: "'rclone'",
|
backend: z.literal("rclone"),
|
||||||
remote: "string",
|
remote: z.string().min(1),
|
||||||
path: "string",
|
path: z.string().min(1),
|
||||||
readOnly: "boolean?",
|
readOnly: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const sftpConfigSchema = type({
|
export const sftpConfigSchema = z.object({
|
||||||
backend: "'sftp'",
|
backend: z.literal("sftp"),
|
||||||
host: "string",
|
host: z.string().min(1),
|
||||||
port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(22),
|
port: z
|
||||||
username: "string",
|
.union([z.string(), z.number()])
|
||||||
password: "string?",
|
.transform((value) => (typeof value === "string" ? Number.parseInt(value, 10) : value))
|
||||||
privateKey: "string?",
|
.pipe(z.number().int().min(1).max(65535))
|
||||||
path: "string",
|
.default(22),
|
||||||
readOnly: "boolean?",
|
username: z.string().min(1),
|
||||||
skipHostKeyCheck: "boolean = true",
|
password: z.string().optional(),
|
||||||
knownHosts: "string?",
|
privateKey: z.string().optional(),
|
||||||
|
path: z.string().min(1),
|
||||||
|
readOnly: z.boolean().optional(),
|
||||||
|
skipHostKeyCheck: z.boolean().default(true),
|
||||||
|
knownHosts: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const volumeConfigSchemaBase = nfsConfigSchema
|
export const volumeConfigSchemaBase = z.discriminatedUnion("backend", [
|
||||||
.or(smbConfigSchema)
|
nfsConfigSchema,
|
||||||
.or(webdavConfigSchema)
|
smbConfigSchema,
|
||||||
.or(directoryConfigSchema)
|
webdavConfigSchema,
|
||||||
.or(rcloneConfigSchema)
|
directoryConfigSchema,
|
||||||
.or(sftpConfigSchema);
|
rcloneConfigSchema,
|
||||||
|
sftpConfigSchema,
|
||||||
|
]);
|
||||||
|
|
||||||
export const volumeConfigSchema = volumeConfigSchemaBase.onUndeclaredKey("delete");
|
export const volumeConfigSchema = volumeConfigSchemaBase;
|
||||||
|
|
||||||
export type BackendConfig = typeof volumeConfigSchema.infer;
|
export type BackendConfig = z.infer<typeof volumeConfigSchema>;
|
||||||
|
|
||||||
export const BACKEND_STATUS = {
|
export const BACKEND_STATUS = {
|
||||||
mounted: "mounted",
|
mounted: "mounted",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import { type } from "arktype";
|
import { z } from "zod";
|
||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
|
|
||||||
const getResticHostname = () => {
|
const getResticHostname = () => {
|
||||||
|
|
@ -24,43 +24,45 @@ const getResticHostname = () => {
|
||||||
return "zerobyte";
|
return "zerobyte";
|
||||||
};
|
};
|
||||||
|
|
||||||
const envSchema = type({
|
const envSchema = z
|
||||||
NODE_ENV: type.enumerated("development", "production", "test").default("production"),
|
.object({
|
||||||
SERVER_IP: 'string = "localhost"',
|
NODE_ENV: z.enum(["development", "production", "test"]).default("production"),
|
||||||
SERVER_IDLE_TIMEOUT: 'string.integer.parse = "60"',
|
SERVER_IP: z.string().default("localhost"),
|
||||||
RESTIC_HOSTNAME: "string?",
|
SERVER_IDLE_TIMEOUT: z.coerce.number().int().default(60),
|
||||||
PORT: 'string.integer.parse = "4096"',
|
RESTIC_HOSTNAME: z.string().optional(),
|
||||||
MIGRATIONS_PATH: "string?",
|
PORT: z.coerce.number().int().default(4096),
|
||||||
APP_VERSION: "string = 'dev'",
|
MIGRATIONS_PATH: z.string().optional(),
|
||||||
TRUSTED_ORIGINS: "string?",
|
APP_VERSION: z.string().default("dev"),
|
||||||
DISABLE_RATE_LIMITING: 'string = "false"',
|
TRUSTED_ORIGINS: z.string().optional(),
|
||||||
APP_SECRET: "32 <= string <= 256",
|
DISABLE_RATE_LIMITING: z.string().default("false"),
|
||||||
BASE_URL: "string",
|
APP_SECRET: z.string().min(32).max(256),
|
||||||
ENABLE_DEV_PANEL: 'string = "false"',
|
BASE_URL: z.string(),
|
||||||
}).pipe((s) => ({
|
ENABLE_DEV_PANEL: z.string().default("false"),
|
||||||
__prod__: s.NODE_ENV === "production",
|
})
|
||||||
environment: s.NODE_ENV,
|
.transform((s) => ({
|
||||||
serverIp: s.SERVER_IP,
|
__prod__: s.NODE_ENV === "production",
|
||||||
serverIdleTimeout: s.SERVER_IDLE_TIMEOUT,
|
environment: s.NODE_ENV,
|
||||||
resticHostname: s.RESTIC_HOSTNAME || getResticHostname(),
|
serverIp: s.SERVER_IP,
|
||||||
port: s.PORT,
|
serverIdleTimeout: s.SERVER_IDLE_TIMEOUT,
|
||||||
migrationsPath: s.MIGRATIONS_PATH,
|
resticHostname: s.RESTIC_HOSTNAME || getResticHostname(),
|
||||||
appVersion: s.APP_VERSION,
|
port: s.PORT,
|
||||||
trustedOrigins: s.TRUSTED_ORIGINS?.split(",")
|
migrationsPath: s.MIGRATIONS_PATH,
|
||||||
.map((origin) => origin.trim())
|
appVersion: s.APP_VERSION,
|
||||||
.filter(Boolean)
|
trustedOrigins: s.TRUSTED_ORIGINS?.split(",")
|
||||||
.concat(s.BASE_URL) ?? [s.BASE_URL],
|
.map((origin) => origin.trim())
|
||||||
disableRateLimiting: s.DISABLE_RATE_LIMITING === "true",
|
.filter(Boolean)
|
||||||
appSecret: s.APP_SECRET,
|
.concat(s.BASE_URL) ?? [s.BASE_URL],
|
||||||
baseUrl: s.BASE_URL,
|
disableRateLimiting: s.DISABLE_RATE_LIMITING === "true",
|
||||||
isSecure: s.BASE_URL?.startsWith("https://") ?? false,
|
appSecret: s.APP_SECRET,
|
||||||
enableDevPanel: s.ENABLE_DEV_PANEL === "true",
|
baseUrl: s.BASE_URL,
|
||||||
}));
|
isSecure: s.BASE_URL?.startsWith("https://") ?? false,
|
||||||
|
enableDevPanel: s.ENABLE_DEV_PANEL === "true",
|
||||||
|
}));
|
||||||
|
|
||||||
const parseConfig = (env: unknown) => {
|
const parseConfig = (env: unknown) => {
|
||||||
const result = envSchema(env);
|
const result = envSchema.safeParse(env);
|
||||||
|
|
||||||
if (result instanceof type.errors) {
|
if (!result.success) {
|
||||||
if (!process.env.APP_SECRET) {
|
if (!process.env.APP_SECRET) {
|
||||||
const errorMessage = [
|
const errorMessage = [
|
||||||
"",
|
"",
|
||||||
|
|
@ -82,11 +84,12 @@ const parseConfig = (env: unknown) => {
|
||||||
|
|
||||||
console.error(errorMessage);
|
console.error(errorMessage);
|
||||||
}
|
}
|
||||||
console.error(`Environment variable validation failed: ${result.summary}`);
|
|
||||||
|
console.error(`Environment variable validation failed: ${result.error.message}`);
|
||||||
throw new Error("Invalid environment variables");
|
throw new Error("Invalid environment variables");
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const config = parseConfig(process.env);
|
export const config = parseConfig(process.env);
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,14 @@ import { index, int, integer, sqliteTable, text, real, primaryKey, unique, uniqu
|
||||||
import type {
|
import type {
|
||||||
CompressionMode,
|
CompressionMode,
|
||||||
RepositoryBackend,
|
RepositoryBackend,
|
||||||
repositoryConfigSchema,
|
RepositoryConfig,
|
||||||
RepositoryStatus,
|
RepositoryStatus,
|
||||||
BandwidthUnit,
|
BandwidthUnit,
|
||||||
DoctorResult,
|
DoctorResult,
|
||||||
} from "~/schemas/restic";
|
} from "~/schemas/restic";
|
||||||
import type { ResticStatsDto } from "~/schemas/restic-dto";
|
import type { ResticStatsDto } from "~/schemas/restic-dto";
|
||||||
import type { BackendStatus, BackendType, volumeConfigSchema } from "~/schemas/volumes";
|
import type { BackendConfig, BackendStatus, BackendType } from "~/schemas/volumes";
|
||||||
import type { NotificationType, notificationConfigSchema } from "~/schemas/notifications";
|
import type { NotificationConfig, NotificationType } from "~/schemas/notifications";
|
||||||
import type { ShortId } from "~/server/utils/branded";
|
import type { ShortId } from "~/server/utils/branded";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -217,7 +217,7 @@ export const volumesTable = sqliteTable(
|
||||||
.notNull()
|
.notNull()
|
||||||
.$onUpdate(() => Date.now())
|
.$onUpdate(() => Date.now())
|
||||||
.default(sql`(unixepoch() * 1000)`),
|
.default(sql`(unixepoch() * 1000)`),
|
||||||
config: text("config", { mode: "json" }).$type<typeof volumeConfigSchema.inferOut>().notNull(),
|
config: text("config", { mode: "json" }).$type<BackendConfig>().notNull(),
|
||||||
autoRemount: int("auto_remount", { mode: "boolean" }).notNull().default(true),
|
autoRemount: int("auto_remount", { mode: "boolean" }).notNull().default(true),
|
||||||
organizationId: text("organization_id")
|
organizationId: text("organization_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
|
|
@ -236,7 +236,7 @@ export const repositoriesTable = sqliteTable("repositories_table", {
|
||||||
shortId: text("short_id").$type<ShortId>().notNull().unique(),
|
shortId: text("short_id").$type<ShortId>().notNull().unique(),
|
||||||
name: text().notNull(),
|
name: text().notNull(),
|
||||||
type: text().$type<RepositoryBackend>().notNull(),
|
type: text().$type<RepositoryBackend>().notNull(),
|
||||||
config: text("config", { mode: "json" }).$type<typeof repositoryConfigSchema.inferOut>().notNull(),
|
config: text("config", { mode: "json" }).$type<RepositoryConfig>().notNull(),
|
||||||
compressionMode: text("compression_mode").$type<CompressionMode>().default("auto"),
|
compressionMode: text("compression_mode").$type<CompressionMode>().default("auto"),
|
||||||
status: text().$type<RepositoryStatus>().default("unknown"),
|
status: text().$type<RepositoryStatus>().default("unknown"),
|
||||||
lastChecked: int("last_checked", { mode: "number" }),
|
lastChecked: int("last_checked", { mode: "number" }),
|
||||||
|
|
@ -319,7 +319,7 @@ export const notificationDestinationsTable = sqliteTable("notification_destinati
|
||||||
name: text().notNull(),
|
name: text().notNull(),
|
||||||
enabled: int("enabled", { mode: "boolean" }).notNull().default(true),
|
enabled: int("enabled", { mode: "boolean" }).notNull().default(true),
|
||||||
type: text().$type<NotificationType>().notNull(),
|
type: text().$type<NotificationType>().notNull(),
|
||||||
config: text("config", { mode: "json" }).$type<typeof notificationConfigSchema.inferOut>().notNull(),
|
config: text("config", { mode: "json" }).$type<NotificationConfig>().notNull(),
|
||||||
createdAt: int("created_at", { mode: "number" })
|
createdAt: int("created_at", { mode: "number" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`(unixepoch() * 1000)`),
|
.default(sql`(unixepoch() * 1000)`),
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,30 @@
|
||||||
import { type } from "arktype";
|
import { z } from "zod";
|
||||||
import { describeRoute, resolver } from "hono-openapi";
|
import { describeRoute, resolver } from "hono-openapi";
|
||||||
|
|
||||||
const statusResponseSchema = type({
|
const statusResponseSchema = z.object({
|
||||||
hasUsers: "boolean",
|
hasUsers: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const adminUsersResponse = type({
|
export const adminUsersResponse = z.object({
|
||||||
users: type({
|
users: z
|
||||||
id: "string",
|
.object({
|
||||||
name: "string | null",
|
id: z.string(),
|
||||||
email: "string",
|
name: z.string().nullable(),
|
||||||
role: "string",
|
email: z.string(),
|
||||||
banned: "boolean",
|
role: z.string(),
|
||||||
accounts: type({
|
banned: z.boolean(),
|
||||||
id: "string",
|
accounts: z
|
||||||
providerId: "string",
|
.object({
|
||||||
}).array(),
|
id: z.string(),
|
||||||
}).array(),
|
providerId: z.string(),
|
||||||
total: "number",
|
})
|
||||||
|
.array(),
|
||||||
|
})
|
||||||
|
.array(),
|
||||||
|
total: z.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AdminUsersDto = typeof adminUsersResponse.infer;
|
export type AdminUsersDto = z.infer<typeof adminUsersResponse>;
|
||||||
|
|
||||||
export const getAdminUsersDto = describeRoute({
|
export const getAdminUsersDto = describeRoute({
|
||||||
description: "List admin users for settings management",
|
description: "List admin users for settings management",
|
||||||
|
|
@ -54,21 +58,23 @@ export const getStatusDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export type GetStatusDto = typeof statusResponseSchema.infer;
|
export type GetStatusDto = z.infer<typeof statusResponseSchema>;
|
||||||
|
|
||||||
export const userDeletionImpactDto = type({
|
export const userDeletionImpactDto = z.object({
|
||||||
organizations: type({
|
organizations: z
|
||||||
id: "string",
|
.object({
|
||||||
name: "string",
|
id: z.string(),
|
||||||
resources: {
|
name: z.string(),
|
||||||
volumesCount: "number",
|
resources: z.object({
|
||||||
repositoriesCount: "number",
|
volumesCount: z.number(),
|
||||||
backupSchedulesCount: "number",
|
repositoriesCount: z.number(),
|
||||||
},
|
backupSchedulesCount: z.number(),
|
||||||
}).array(),
|
}),
|
||||||
|
})
|
||||||
|
.array(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type UserDeletionImpactDto = typeof userDeletionImpactDto.infer;
|
export type UserDeletionImpactDto = z.infer<typeof userDeletionImpactDto>;
|
||||||
|
|
||||||
export const getUserDeletionImpactDto = describeRoute({
|
export const getUserDeletionImpactDto = describeRoute({
|
||||||
description: "Get impact of deleting a user",
|
description: "Get impact of deleting a user",
|
||||||
|
|
@ -103,20 +109,22 @@ export const deleteUserAccountDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const orgMembersResponse = type({
|
export const orgMembersResponse = z.object({
|
||||||
members: type({
|
members: z
|
||||||
id: "string",
|
.object({
|
||||||
userId: "string",
|
id: z.string(),
|
||||||
role: "string",
|
userId: z.string(),
|
||||||
createdAt: "string",
|
role: z.string(),
|
||||||
user: {
|
createdAt: z.string(),
|
||||||
name: "string | null",
|
user: z.object({
|
||||||
email: "string",
|
name: z.string().nullable(),
|
||||||
},
|
email: z.string(),
|
||||||
}).array(),
|
}),
|
||||||
|
})
|
||||||
|
.array(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type OrgMembersDto = typeof orgMembersResponse.infer;
|
export type OrgMembersDto = z.infer<typeof orgMembersResponse>;
|
||||||
|
|
||||||
export const getOrgMembersDto = describeRoute({
|
export const getOrgMembersDto = describeRoute({
|
||||||
description: "Get members of the active organization",
|
description: "Get members of the active organization",
|
||||||
|
|
@ -134,8 +142,8 @@ export const getOrgMembersDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updateMemberRoleBody = type({
|
export const updateMemberRoleBody = z.object({
|
||||||
role: "'member' | 'admin'",
|
role: z.enum(["member", "admin"]),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updateMemberRoleDto = describeRoute({
|
export const updateMemberRoleDto = describeRoute({
|
||||||
|
|
|
||||||
|
|
@ -41,5 +41,8 @@ export const createVolumeBackend = (volume: Volume): VolumeBackend => {
|
||||||
case "sftp": {
|
case "sftp": {
|
||||||
return makeSftpBackend(volume.config, path);
|
return makeSftpBackend(volume.config, path);
|
||||||
}
|
}
|
||||||
|
default: {
|
||||||
|
throw new Error("Unsupported backend");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,67 +1,61 @@
|
||||||
import { type } from "arktype";
|
import { z } from "zod";
|
||||||
import { describeRoute, resolver } from "hono-openapi";
|
import { describeRoute, resolver } from "hono-openapi";
|
||||||
import { volumeSchema } from "../volumes/volume.dto";
|
import { volumeSchema } from "../volumes/volume.dto";
|
||||||
import { repositorySchema } from "../repositories/repositories.dto";
|
import { repositorySchema } from "../repositories/repositories.dto";
|
||||||
import { backupProgressEventSchema } from "~/schemas/events-dto";
|
import { backupProgressEventSchema } from "~/schemas/events-dto";
|
||||||
|
|
||||||
const retentionPolicySchema = type({
|
const retentionPolicySchema = z.object({
|
||||||
keepLast: "number?",
|
keepLast: z.number().optional(),
|
||||||
keepHourly: "number?",
|
keepHourly: z.number().optional(),
|
||||||
keepDaily: "number?",
|
keepDaily: z.number().optional(),
|
||||||
keepWeekly: "number?",
|
keepWeekly: z.number().optional(),
|
||||||
keepMonthly: "number?",
|
keepMonthly: z.number().optional(),
|
||||||
keepYearly: "number?",
|
keepYearly: z.number().optional(),
|
||||||
keepWithinDuration: "string?",
|
keepWithinDuration: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type RetentionPolicy = typeof retentionPolicySchema.infer;
|
export type RetentionPolicy = z.infer<typeof retentionPolicySchema>;
|
||||||
|
|
||||||
const backupScheduleSchema = type({
|
const backupScheduleSchema = z.object({
|
||||||
id: "number",
|
id: z.number(),
|
||||||
shortId: "string",
|
shortId: z.string(),
|
||||||
name: "string",
|
name: z.string(),
|
||||||
volumeId: "number",
|
volumeId: z.number(),
|
||||||
repositoryId: "string",
|
repositoryId: z.string(),
|
||||||
enabled: "boolean",
|
enabled: z.boolean(),
|
||||||
cronExpression: "string",
|
cronExpression: z.string(),
|
||||||
retentionPolicy: retentionPolicySchema.or("null"),
|
retentionPolicy: retentionPolicySchema.nullable(),
|
||||||
excludePatterns: "string[] | null",
|
excludePatterns: z.array(z.string()).nullable(),
|
||||||
excludeIfPresent: "string[] | null",
|
excludeIfPresent: z.array(z.string()).nullable(),
|
||||||
includePatterns: "string[] | null",
|
includePatterns: z.array(z.string()).nullable(),
|
||||||
oneFileSystem: "boolean",
|
oneFileSystem: z.boolean(),
|
||||||
customResticParams: "string[] | null",
|
customResticParams: z.array(z.string()).nullable(),
|
||||||
lastBackupAt: "number | null",
|
lastBackupAt: z.number().nullable(),
|
||||||
lastBackupStatus: "'success' | 'error' | 'in_progress' | 'warning' | null",
|
lastBackupStatus: z.enum(["success", "error", "in_progress", "warning"]).nullable(),
|
||||||
lastBackupError: "string | null",
|
lastBackupError: z.string().nullable(),
|
||||||
nextBackupAt: "number | null",
|
nextBackupAt: z.number().nullable(),
|
||||||
createdAt: "number",
|
createdAt: z.number(),
|
||||||
updatedAt: "number",
|
updatedAt: z.number(),
|
||||||
}).and(
|
volume: volumeSchema,
|
||||||
type({
|
|
||||||
volume: volumeSchema,
|
|
||||||
repository: repositorySchema,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const scheduleMirrorSchema = type({
|
|
||||||
scheduleId: "string",
|
|
||||||
repositoryId: "string",
|
|
||||||
enabled: "boolean",
|
|
||||||
lastCopyAt: "number | null",
|
|
||||||
lastCopyStatus: "'success' | 'error' | 'in_progress' | null",
|
|
||||||
lastCopyError: "string | null",
|
|
||||||
createdAt: "number",
|
|
||||||
repository: repositorySchema,
|
repository: repositorySchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ScheduleMirrorDto = typeof scheduleMirrorSchema.infer;
|
const scheduleMirrorSchema = z.object({
|
||||||
|
scheduleId: z.string(),
|
||||||
|
repositoryId: z.string(),
|
||||||
|
enabled: z.boolean(),
|
||||||
|
lastCopyAt: z.number().nullable(),
|
||||||
|
lastCopyStatus: z.enum(["success", "error", "in_progress"]).nullable(),
|
||||||
|
lastCopyError: z.string().nullable(),
|
||||||
|
createdAt: z.number(),
|
||||||
|
repository: repositorySchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ScheduleMirrorDto = z.infer<typeof scheduleMirrorSchema>;
|
||||||
|
|
||||||
/**
|
|
||||||
* List all backup schedules
|
|
||||||
*/
|
|
||||||
export const listBackupSchedulesResponse = backupScheduleSchema.array();
|
export const listBackupSchedulesResponse = backupScheduleSchema.array();
|
||||||
|
|
||||||
export type ListBackupSchedulesResponseDto = typeof listBackupSchedulesResponse.infer;
|
export type ListBackupSchedulesResponseDto = z.infer<typeof listBackupSchedulesResponse>;
|
||||||
|
|
||||||
export const listBackupSchedulesDto = describeRoute({
|
export const listBackupSchedulesDto = describeRoute({
|
||||||
description: "List all backup schedules",
|
description: "List all backup schedules",
|
||||||
|
|
@ -79,12 +73,9 @@ export const listBackupSchedulesDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a single backup schedule
|
|
||||||
*/
|
|
||||||
export const getBackupScheduleResponse = backupScheduleSchema;
|
export const getBackupScheduleResponse = backupScheduleSchema;
|
||||||
|
|
||||||
export type GetBackupScheduleDto = typeof getBackupScheduleResponse.infer;
|
export type GetBackupScheduleDto = z.infer<typeof getBackupScheduleResponse>;
|
||||||
|
|
||||||
export const getBackupScheduleDto = describeRoute({
|
export const getBackupScheduleDto = describeRoute({
|
||||||
description: "Get a backup schedule by ID",
|
description: "Get a backup schedule by ID",
|
||||||
|
|
@ -102,9 +93,9 @@ export const getBackupScheduleDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getBackupScheduleForVolumeResponse = backupScheduleSchema.or("null");
|
export const getBackupScheduleForVolumeResponse = backupScheduleSchema.nullable();
|
||||||
|
|
||||||
export type GetBackupScheduleForVolumeResponseDto = typeof getBackupScheduleForVolumeResponse.infer;
|
export type GetBackupScheduleForVolumeResponseDto = z.infer<typeof getBackupScheduleForVolumeResponse>;
|
||||||
|
|
||||||
export const getBackupScheduleForVolumeDto = describeRoute({
|
export const getBackupScheduleForVolumeDto = describeRoute({
|
||||||
description: "Get a backup schedule for a specific volume",
|
description: "Get a backup schedule for a specific volume",
|
||||||
|
|
@ -122,29 +113,26 @@ export const getBackupScheduleForVolumeDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
export const createBackupScheduleBody = z.object({
|
||||||
* Create a new backup schedule
|
name: z.string().min(1).max(128),
|
||||||
*/
|
volumeId: z.union([z.string(), z.number()]),
|
||||||
export const createBackupScheduleBody = type({
|
repositoryId: z.string(),
|
||||||
name: "1 <= string <= 128",
|
enabled: z.boolean(),
|
||||||
volumeId: "string | number",
|
cronExpression: z.string(),
|
||||||
repositoryId: "string",
|
|
||||||
enabled: "boolean",
|
|
||||||
cronExpression: "string",
|
|
||||||
retentionPolicy: retentionPolicySchema.optional(),
|
retentionPolicy: retentionPolicySchema.optional(),
|
||||||
excludePatterns: "string[]?",
|
excludePatterns: z.array(z.string()).optional(),
|
||||||
excludeIfPresent: "string[]?",
|
excludeIfPresent: z.array(z.string()).optional(),
|
||||||
includePatterns: "string[]?",
|
includePatterns: z.array(z.string()).optional(),
|
||||||
oneFileSystem: "boolean?",
|
oneFileSystem: z.boolean().optional(),
|
||||||
tags: "string[]?",
|
tags: z.array(z.string()).optional(),
|
||||||
customResticParams: "string[]?",
|
customResticParams: z.array(z.string()).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type CreateBackupScheduleBody = typeof createBackupScheduleBody.infer;
|
export type CreateBackupScheduleBody = z.infer<typeof createBackupScheduleBody>;
|
||||||
|
|
||||||
export const createBackupScheduleResponse = backupScheduleSchema.omit("volume", "repository");
|
export const createBackupScheduleResponse = backupScheduleSchema.omit({ volume: true, repository: true });
|
||||||
|
|
||||||
export type CreateBackupScheduleDto = typeof createBackupScheduleResponse.infer;
|
export type CreateBackupScheduleDto = z.infer<typeof createBackupScheduleResponse>;
|
||||||
|
|
||||||
export const createBackupScheduleDto = describeRoute({
|
export const createBackupScheduleDto = describeRoute({
|
||||||
description: "Create a new backup schedule for a volume",
|
description: "Create a new backup schedule for a volume",
|
||||||
|
|
@ -162,28 +150,25 @@ export const createBackupScheduleDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
export const updateBackupScheduleBody = z.object({
|
||||||
* Update a backup schedule
|
name: z.string().min(1).max(128).optional(),
|
||||||
*/
|
repositoryId: z.string(),
|
||||||
export const updateBackupScheduleBody = type({
|
enabled: z.boolean().optional(),
|
||||||
name: "(1 <= string <= 128)?",
|
cronExpression: z.string(),
|
||||||
repositoryId: "string",
|
|
||||||
enabled: "boolean?",
|
|
||||||
cronExpression: "string",
|
|
||||||
retentionPolicy: retentionPolicySchema.optional(),
|
retentionPolicy: retentionPolicySchema.optional(),
|
||||||
excludePatterns: "string[]?",
|
excludePatterns: z.array(z.string()).optional(),
|
||||||
excludeIfPresent: "string[]?",
|
excludeIfPresent: z.array(z.string()).optional(),
|
||||||
includePatterns: "string[]?",
|
includePatterns: z.array(z.string()).optional(),
|
||||||
oneFileSystem: "boolean?",
|
oneFileSystem: z.boolean().optional(),
|
||||||
tags: "string[]?",
|
tags: z.array(z.string()).optional(),
|
||||||
customResticParams: "string[]?",
|
customResticParams: z.array(z.string()).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type UpdateBackupScheduleBody = typeof updateBackupScheduleBody.infer;
|
export type UpdateBackupScheduleBody = z.infer<typeof updateBackupScheduleBody>;
|
||||||
|
|
||||||
export const updateBackupScheduleResponse = backupScheduleSchema.omit("volume", "repository");
|
export const updateBackupScheduleResponse = backupScheduleSchema.omit({ volume: true, repository: true });
|
||||||
|
|
||||||
export type UpdateBackupScheduleDto = typeof updateBackupScheduleResponse.infer;
|
export type UpdateBackupScheduleDto = z.infer<typeof updateBackupScheduleResponse>;
|
||||||
|
|
||||||
export const updateBackupScheduleDto = describeRoute({
|
export const updateBackupScheduleDto = describeRoute({
|
||||||
description: "Update a backup schedule",
|
description: "Update a backup schedule",
|
||||||
|
|
@ -201,14 +186,11 @@ export const updateBackupScheduleDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
export const deleteBackupScheduleResponse = z.object({
|
||||||
* Delete a backup schedule
|
success: z.boolean(),
|
||||||
*/
|
|
||||||
export const deleteBackupScheduleResponse = type({
|
|
||||||
success: "boolean",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type DeleteBackupScheduleDto = typeof deleteBackupScheduleResponse.infer;
|
export type DeleteBackupScheduleDto = z.infer<typeof deleteBackupScheduleResponse>;
|
||||||
|
|
||||||
export const deleteBackupScheduleDto = describeRoute({
|
export const deleteBackupScheduleDto = describeRoute({
|
||||||
description: "Delete a backup schedule",
|
description: "Delete a backup schedule",
|
||||||
|
|
@ -226,14 +208,11 @@ export const deleteBackupScheduleDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
export const runBackupNowResponse = z.object({
|
||||||
* Run a backup immediately
|
success: z.boolean(),
|
||||||
*/
|
|
||||||
export const runBackupNowResponse = type({
|
|
||||||
success: "boolean",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type RunBackupNowDto = typeof runBackupNowResponse.infer;
|
export type RunBackupNowDto = z.infer<typeof runBackupNowResponse>;
|
||||||
|
|
||||||
export const runBackupNowDto = describeRoute({
|
export const runBackupNowDto = describeRoute({
|
||||||
description: "Trigger a backup immediately for a schedule",
|
description: "Trigger a backup immediately for a schedule",
|
||||||
|
|
@ -251,14 +230,11 @@ export const runBackupNowDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
export const stopBackupResponse = z.object({
|
||||||
* Stop a running backup
|
success: z.boolean(),
|
||||||
*/
|
|
||||||
export const stopBackupResponse = type({
|
|
||||||
success: "boolean",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type StopBackupDto = typeof stopBackupResponse.infer;
|
export type StopBackupDto = z.infer<typeof stopBackupResponse>;
|
||||||
|
|
||||||
export const stopBackupDto = describeRoute({
|
export const stopBackupDto = describeRoute({
|
||||||
description: "Stop a backup that is currently in progress",
|
description: "Stop a backup that is currently in progress",
|
||||||
|
|
@ -279,14 +255,11 @@ export const stopBackupDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
export const runForgetResponse = z.object({
|
||||||
* Run retention policy (forget) manually
|
success: z.boolean(),
|
||||||
*/
|
|
||||||
export const runForgetResponse = type({
|
|
||||||
success: "boolean",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type RunForgetDto = typeof runForgetResponse.infer;
|
export type RunForgetDto = z.infer<typeof runForgetResponse>;
|
||||||
|
|
||||||
export const runForgetDto = describeRoute({
|
export const runForgetDto = describeRoute({
|
||||||
description: "Manually apply retention policy to clean up old snapshots",
|
description: "Manually apply retention policy to clean up old snapshots",
|
||||||
|
|
@ -305,7 +278,7 @@ export const runForgetDto = describeRoute({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getScheduleMirrorsResponse = scheduleMirrorSchema.array();
|
export const getScheduleMirrorsResponse = scheduleMirrorSchema.array();
|
||||||
export type GetScheduleMirrorsDto = typeof getScheduleMirrorsResponse.infer;
|
export type GetScheduleMirrorsDto = z.infer<typeof getScheduleMirrorsResponse>;
|
||||||
|
|
||||||
export const getScheduleMirrorsDto = describeRoute({
|
export const getScheduleMirrorsDto = describeRoute({
|
||||||
description: "Get mirror repository assignments for a backup schedule",
|
description: "Get mirror repository assignments for a backup schedule",
|
||||||
|
|
@ -323,17 +296,19 @@ export const getScheduleMirrorsDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updateScheduleMirrorsBody = type({
|
export const updateScheduleMirrorsBody = z.object({
|
||||||
mirrors: type({
|
mirrors: z
|
||||||
repositoryId: "string",
|
.object({
|
||||||
enabled: "boolean",
|
repositoryId: z.string(),
|
||||||
}).array(),
|
enabled: z.boolean(),
|
||||||
|
})
|
||||||
|
.array(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type UpdateScheduleMirrorsBody = typeof updateScheduleMirrorsBody.infer;
|
export type UpdateScheduleMirrorsBody = z.infer<typeof updateScheduleMirrorsBody>;
|
||||||
|
|
||||||
export const updateScheduleMirrorsResponse = scheduleMirrorSchema.array();
|
export const updateScheduleMirrorsResponse = scheduleMirrorSchema.array();
|
||||||
export type UpdateScheduleMirrorsDto = typeof updateScheduleMirrorsResponse.infer;
|
export type UpdateScheduleMirrorsDto = z.infer<typeof updateScheduleMirrorsResponse>;
|
||||||
|
|
||||||
export const updateScheduleMirrorsDto = describeRoute({
|
export const updateScheduleMirrorsDto = describeRoute({
|
||||||
description: "Update mirror repository assignments for a backup schedule",
|
description: "Update mirror repository assignments for a backup schedule",
|
||||||
|
|
@ -351,14 +326,14 @@ export const updateScheduleMirrorsDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const mirrorCompatibilitySchema = type({
|
const mirrorCompatibilitySchema = z.object({
|
||||||
repositoryId: "string",
|
repositoryId: z.string(),
|
||||||
compatible: "boolean",
|
compatible: z.boolean(),
|
||||||
reason: "string | null",
|
reason: z.string().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getMirrorCompatibilityResponse = mirrorCompatibilitySchema.array();
|
export const getMirrorCompatibilityResponse = mirrorCompatibilitySchema.array();
|
||||||
export type GetMirrorCompatibilityDto = typeof getMirrorCompatibilityResponse.infer;
|
export type GetMirrorCompatibilityDto = z.infer<typeof getMirrorCompatibilityResponse>;
|
||||||
|
|
||||||
export const getMirrorCompatibilityDto = describeRoute({
|
export const getMirrorCompatibilityDto = describeRoute({
|
||||||
description: "Get mirror compatibility info for all repositories relative to a backup schedule's primary repository",
|
description: "Get mirror compatibility info for all repositories relative to a backup schedule's primary repository",
|
||||||
|
|
@ -376,20 +351,17 @@ export const getMirrorCompatibilityDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
export const reorderBackupSchedulesBody = z.object({
|
||||||
* Reorder backup schedules
|
scheduleShortIds: z.array(z.string()),
|
||||||
*/
|
|
||||||
export const reorderBackupSchedulesBody = type({
|
|
||||||
scheduleShortIds: "string[]",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ReorderBackupSchedulesBody = typeof reorderBackupSchedulesBody.infer;
|
export type ReorderBackupSchedulesBody = z.infer<typeof reorderBackupSchedulesBody>;
|
||||||
|
|
||||||
export const reorderBackupSchedulesResponse = type({
|
export const reorderBackupSchedulesResponse = z.object({
|
||||||
success: "boolean",
|
success: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ReorderBackupSchedulesDto = typeof reorderBackupSchedulesResponse.infer;
|
export type ReorderBackupSchedulesDto = z.infer<typeof reorderBackupSchedulesResponse>;
|
||||||
|
|
||||||
export const reorderBackupSchedulesDto = describeRoute({
|
export const reorderBackupSchedulesDto = describeRoute({
|
||||||
description: "Reorder backup schedules by providing an array of schedule short IDs in the desired order",
|
description: "Reorder backup schedules by providing an array of schedule short IDs in the desired order",
|
||||||
|
|
@ -407,11 +379,8 @@ export const reorderBackupSchedulesDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
const getBackupProgressResponse = backupProgressEventSchema.nullable();
|
||||||
* Get current backup progress for a running backup
|
export type GetBackupProgressDto = z.infer<typeof getBackupProgressResponse>;
|
||||||
*/
|
|
||||||
const getBackupProgressResponse = backupProgressEventSchema.or("null");
|
|
||||||
export type GetBackupProgressDto = typeof getBackupProgressResponse.infer;
|
|
||||||
|
|
||||||
export const getBackupProgressDto = describeRoute({
|
export const getBackupProgressDto = describeRoute({
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import { logger } from "../../../utils/logger";
|
||||||
import { toMessage } from "~/server/utils/errors";
|
import { toMessage } from "~/server/utils/errors";
|
||||||
import { REPOSITORY_BASE } from "~/server/core/constants";
|
import { REPOSITORY_BASE } from "~/server/core/constants";
|
||||||
import { repositoryConfigSchema } from "~/schemas/restic";
|
import { repositoryConfigSchema } from "~/schemas/restic";
|
||||||
import { type } from "arktype";
|
|
||||||
|
|
||||||
type MigrationError = { name: string; error: string };
|
type MigrationError = { name: string; error: string };
|
||||||
|
|
||||||
|
|
@ -68,14 +67,15 @@ const execute = async () => {
|
||||||
|
|
||||||
config.path = buildPath(currentPath, localRepositoryName);
|
config.path = buildPath(currentPath, localRepositoryName);
|
||||||
|
|
||||||
const newConfig = repositoryConfigSchema(config);
|
const newConfigResult = repositoryConfigSchema.safeParse(config);
|
||||||
if (newConfig instanceof type.errors) {
|
if (!newConfigResult.success) {
|
||||||
errors.push({
|
errors.push({
|
||||||
name: `repository:${repository.id}`,
|
name: `repository:${repository.id}`,
|
||||||
error: `Validation failed for updated repository config: ${newConfig.summary}`,
|
error: `Validation failed for updated repository config: ${newConfigResult.error.message}`,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
const newConfig = newConfigResult.data;
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(repositoriesTable)
|
.update(repositoriesTable)
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,21 @@
|
||||||
import { type } from "arktype";
|
import { z } from "zod";
|
||||||
import { describeRoute, resolver } from "hono-openapi";
|
import { describeRoute, resolver } from "hono-openapi";
|
||||||
import { NOTIFICATION_TYPES, notificationConfigSchema } from "~/schemas/notifications";
|
import { NOTIFICATION_TYPES, notificationConfigSchema } from "~/schemas/notifications";
|
||||||
|
|
||||||
/**
|
export const notificationDestinationSchema = z.object({
|
||||||
* Notification Destination Schema
|
id: z.number(),
|
||||||
*/
|
name: z.string(),
|
||||||
export const notificationDestinationSchema = type({
|
enabled: z.boolean(),
|
||||||
id: "number",
|
type: z.enum(NOTIFICATION_TYPES),
|
||||||
name: "string",
|
|
||||||
enabled: "boolean",
|
|
||||||
type: type.valueOf(NOTIFICATION_TYPES),
|
|
||||||
config: notificationConfigSchema,
|
config: notificationConfigSchema,
|
||||||
createdAt: "number",
|
createdAt: z.number(),
|
||||||
updatedAt: "number",
|
updatedAt: z.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type NotificationDestinationDto = typeof notificationDestinationSchema.infer;
|
export type NotificationDestinationDto = z.infer<typeof notificationDestinationSchema>;
|
||||||
|
|
||||||
/**
|
|
||||||
* List all notification destinations
|
|
||||||
*/
|
|
||||||
export const listDestinationsResponse = notificationDestinationSchema.array();
|
export const listDestinationsResponse = notificationDestinationSchema.array();
|
||||||
export type ListDestinationsDto = typeof listDestinationsResponse.infer;
|
export type ListDestinationsDto = z.infer<typeof listDestinationsResponse>;
|
||||||
|
|
||||||
export const listDestinationsDto = describeRoute({
|
export const listDestinationsDto = describeRoute({
|
||||||
description: "List all notification destinations",
|
description: "List all notification destinations",
|
||||||
|
|
@ -39,16 +33,13 @@ export const listDestinationsDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
export const createDestinationBody = z.object({
|
||||||
* Create a new notification destination
|
name: z.string(),
|
||||||
*/
|
|
||||||
export const createDestinationBody = type({
|
|
||||||
name: "string",
|
|
||||||
config: notificationConfigSchema,
|
config: notificationConfigSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const createDestinationResponse = notificationDestinationSchema;
|
export const createDestinationResponse = notificationDestinationSchema;
|
||||||
export type CreateDestinationDto = typeof createDestinationResponse.infer;
|
export type CreateDestinationDto = z.infer<typeof createDestinationResponse>;
|
||||||
|
|
||||||
export const createDestinationDto = describeRoute({
|
export const createDestinationDto = describeRoute({
|
||||||
description: "Create a new notification destination",
|
description: "Create a new notification destination",
|
||||||
|
|
@ -66,11 +57,8 @@ export const createDestinationDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a single notification destination
|
|
||||||
*/
|
|
||||||
export const getDestinationResponse = notificationDestinationSchema;
|
export const getDestinationResponse = notificationDestinationSchema;
|
||||||
export type GetDestinationDto = typeof getDestinationResponse.infer;
|
export type GetDestinationDto = z.infer<typeof getDestinationResponse>;
|
||||||
|
|
||||||
export const getDestinationDto = describeRoute({
|
export const getDestinationDto = describeRoute({
|
||||||
description: "Get a notification destination by ID",
|
description: "Get a notification destination by ID",
|
||||||
|
|
@ -91,17 +79,14 @@ export const getDestinationDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
export const updateDestinationBody = z.object({
|
||||||
* Update a notification destination
|
name: z.string().optional(),
|
||||||
*/
|
enabled: z.boolean().optional(),
|
||||||
export const updateDestinationBody = type({
|
config: notificationConfigSchema.optional(),
|
||||||
"name?": "string",
|
|
||||||
"enabled?": "boolean",
|
|
||||||
"config?": notificationConfigSchema,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updateDestinationResponse = notificationDestinationSchema;
|
export const updateDestinationResponse = notificationDestinationSchema;
|
||||||
export type UpdateDestinationDto = typeof updateDestinationResponse.infer;
|
export type UpdateDestinationDto = z.infer<typeof updateDestinationResponse>;
|
||||||
|
|
||||||
export const updateDestinationDto = describeRoute({
|
export const updateDestinationDto = describeRoute({
|
||||||
description: "Update a notification destination",
|
description: "Update a notification destination",
|
||||||
|
|
@ -122,13 +107,10 @@ export const updateDestinationDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
export const deleteDestinationResponse = z.object({
|
||||||
* Delete a notification destination
|
message: z.string(),
|
||||||
*/
|
|
||||||
export const deleteDestinationResponse = type({
|
|
||||||
message: "string",
|
|
||||||
});
|
});
|
||||||
export type DeleteDestinationDto = typeof deleteDestinationResponse.infer;
|
export type DeleteDestinationDto = z.infer<typeof deleteDestinationResponse>;
|
||||||
|
|
||||||
export const deleteDestinationDto = describeRoute({
|
export const deleteDestinationDto = describeRoute({
|
||||||
description: "Delete a notification destination",
|
description: "Delete a notification destination",
|
||||||
|
|
@ -149,13 +131,10 @@ export const deleteDestinationDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
export const testDestinationResponse = z.object({
|
||||||
* Test a notification destination
|
success: z.boolean(),
|
||||||
*/
|
|
||||||
export const testDestinationResponse = type({
|
|
||||||
success: "boolean",
|
|
||||||
});
|
});
|
||||||
export type TestDestinationDto = typeof testDestinationResponse.infer;
|
export type TestDestinationDto = z.infer<typeof testDestinationResponse>;
|
||||||
|
|
||||||
export const testDestinationDto = describeRoute({
|
export const testDestinationDto = describeRoute({
|
||||||
description: "Test a notification destination by sending a test message",
|
description: "Test a notification destination by sending a test message",
|
||||||
|
|
@ -182,27 +161,21 @@ export const testDestinationDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
export const scheduleNotificationAssignmentSchema = z.object({
|
||||||
* Backup Schedule Notification Assignment Schema
|
scheduleId: z.number(),
|
||||||
*/
|
destinationId: z.number(),
|
||||||
export const scheduleNotificationAssignmentSchema = type({
|
notifyOnStart: z.boolean(),
|
||||||
scheduleId: "number",
|
notifyOnSuccess: z.boolean(),
|
||||||
destinationId: "number",
|
notifyOnWarning: z.boolean(),
|
||||||
notifyOnStart: "boolean",
|
notifyOnFailure: z.boolean(),
|
||||||
notifyOnSuccess: "boolean",
|
createdAt: z.number(),
|
||||||
notifyOnWarning: "boolean",
|
|
||||||
notifyOnFailure: "boolean",
|
|
||||||
createdAt: "number",
|
|
||||||
destination: notificationDestinationSchema,
|
destination: notificationDestinationSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ScheduleNotificationAssignmentDto = typeof scheduleNotificationAssignmentSchema.infer;
|
export type ScheduleNotificationAssignmentDto = z.infer<typeof scheduleNotificationAssignmentSchema>;
|
||||||
|
|
||||||
/**
|
|
||||||
* Get notifications for a backup schedule
|
|
||||||
*/
|
|
||||||
export const getScheduleNotificationsResponse = scheduleNotificationAssignmentSchema.array();
|
export const getScheduleNotificationsResponse = scheduleNotificationAssignmentSchema.array();
|
||||||
export type GetScheduleNotificationsDto = typeof getScheduleNotificationsResponse.infer;
|
export type GetScheduleNotificationsDto = z.infer<typeof getScheduleNotificationsResponse>;
|
||||||
|
|
||||||
export const getScheduleNotificationsDto = describeRoute({
|
export const getScheduleNotificationsDto = describeRoute({
|
||||||
description: "Get notification assignments for a backup schedule",
|
description: "Get notification assignments for a backup schedule",
|
||||||
|
|
@ -220,21 +193,20 @@ export const getScheduleNotificationsDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
export const updateScheduleNotificationsBody = z.object({
|
||||||
* Update notifications for a backup schedule
|
assignments: z
|
||||||
*/
|
.object({
|
||||||
export const updateScheduleNotificationsBody = type({
|
destinationId: z.number(),
|
||||||
assignments: type({
|
notifyOnStart: z.boolean(),
|
||||||
destinationId: "number",
|
notifyOnSuccess: z.boolean(),
|
||||||
notifyOnStart: "boolean",
|
notifyOnWarning: z.boolean(),
|
||||||
notifyOnSuccess: "boolean",
|
notifyOnFailure: z.boolean(),
|
||||||
notifyOnWarning: "boolean",
|
})
|
||||||
notifyOnFailure: "boolean",
|
.array(),
|
||||||
}).array(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updateScheduleNotificationsResponse = scheduleNotificationAssignmentSchema.array();
|
export const updateScheduleNotificationsResponse = scheduleNotificationAssignmentSchema.array();
|
||||||
export type UpdateScheduleNotificationsDto = typeof updateScheduleNotificationsResponse.infer;
|
export type UpdateScheduleNotificationsDto = z.infer<typeof updateScheduleNotificationsResponse>;
|
||||||
|
|
||||||
export const updateScheduleNotificationsDto = describeRoute({
|
export const updateScheduleNotificationsDto = describeRoute({
|
||||||
description: "Update notification assignments for a backup schedule",
|
description: "Update notification assignments for a backup schedule",
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ import { buildShoutrrrUrl } from "./builders";
|
||||||
import { notificationConfigSchema, type NotificationConfig, type NotificationEvent } from "~/schemas/notifications";
|
import { notificationConfigSchema, type NotificationConfig, type NotificationEvent } from "~/schemas/notifications";
|
||||||
import type { ResticBackupRunSummaryDto } from "~/schemas/restic-dto";
|
import type { ResticBackupRunSummaryDto } from "~/schemas/restic-dto";
|
||||||
import { toMessage } from "../../utils/errors";
|
import { toMessage } from "../../utils/errors";
|
||||||
import { type } from "arktype";
|
|
||||||
import { getOrganizationId } from "~/server/core/request-context";
|
import { getOrganizationId } from "~/server/core/request-context";
|
||||||
import { formatBytes } from "~/utils/format-bytes";
|
import { formatBytes } from "~/utils/format-bytes";
|
||||||
|
|
||||||
|
|
@ -192,10 +191,11 @@ const updateDestination = async (
|
||||||
updateData.enabled = updates.enabled;
|
updateData.enabled = updates.enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newConfig = notificationConfigSchema(updates.config || existing.config);
|
const newConfigResult = notificationConfigSchema.safeParse(updates.config || existing.config);
|
||||||
if (newConfig instanceof type.errors) {
|
if (!newConfigResult.success) {
|
||||||
throw new BadRequestError("Invalid notification configuration");
|
throw new BadRequestError("Invalid notification configuration");
|
||||||
}
|
}
|
||||||
|
const newConfig = newConfigResult.data;
|
||||||
|
|
||||||
const encryptedConfig = await encryptSensitiveFields(newConfig);
|
const encryptedConfig = await encryptSensitiveFields(newConfig);
|
||||||
updateData.config = encryptedConfig;
|
updateData.config = encryptedConfig;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { type DoctorStep, type DoctorResult, type RepositoryConfig } from "~/schemas/restic";
|
import { type DoctorStep, type DoctorResult, type RepositoryConfig } from "~/schemas/restic";
|
||||||
import { type } from "arktype";
|
import { z } from "zod";
|
||||||
import { getOrganizationId } from "~/server/core/request-context";
|
import { getOrganizationId } from "~/server/core/request-context";
|
||||||
import { restic } from "~/server/utils/restic";
|
import { restic } from "~/server/utils/restic";
|
||||||
import { toMessage } from "~/server/utils/errors";
|
import { toMessage } from "~/server/utils/errors";
|
||||||
|
|
@ -61,21 +61,24 @@ const runRepairIndexStep = async (config: RepositoryConfig, signal: AbortSignal)
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseCheckOutput = (checkOutput: string | null) => {
|
const parseCheckOutput = (checkOutput: string | null) => {
|
||||||
const schema = type({ suggest_repair_index: "boolean", suggest_prune: "boolean" });
|
const schema = z.object({
|
||||||
|
suggest_repair_index: z.boolean(),
|
||||||
|
suggest_prune: z.boolean(),
|
||||||
|
});
|
||||||
const parsedJson = safeJsonParse(checkOutput);
|
const parsedJson = safeJsonParse(checkOutput);
|
||||||
|
|
||||||
if (parsedJson === null) {
|
if (parsedJson === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = schema(parsedJson);
|
const parsed = schema.safeParse(parsedJson);
|
||||||
|
|
||||||
if (parsed instanceof type.errors) {
|
if (!parsed.success) {
|
||||||
logger.error(`Invalid check output format: ${parsed.summary}`);
|
logger.error(`Invalid check output format: ${parsed.error.message}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return parsed;
|
return parsed.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
const checkAbortSignal = (signal: AbortSignal | undefined): void => {
|
const checkAbortSignal = (signal: AbortSignal | undefined): void => {
|
||||||
|
|
|
||||||
|
|
@ -179,8 +179,8 @@ export const repositoriesController = new Hono()
|
||||||
|
|
||||||
const decodedPath = path ? decodeURIComponent(path) : undefined;
|
const decodedPath = path ? decodeURIComponent(path) : undefined;
|
||||||
|
|
||||||
const offset = Math.max(0, Number.parseInt(query.offset ?? "0", 10) || 0);
|
const offset = Math.max(0, query.offset ?? 0);
|
||||||
const limit = Math.min(1000, Math.max(1, Number.parseInt(query.limit ?? "500", 10) || 500));
|
const limit = Math.min(1000, Math.max(1, query.limit ?? 500));
|
||||||
|
|
||||||
const result = await repositoriesService.listSnapshotFiles(shortId, snapshotId, decodedPath, { offset, limit });
|
const result = await repositoriesService.listSnapshotFiles(shortId, snapshotId, decodedPath, { offset, limit });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { type } from "arktype";
|
import { z } from "zod";
|
||||||
import { describeRoute, resolver } from "hono-openapi";
|
import { describeRoute, resolver } from "hono-openapi";
|
||||||
import {
|
import {
|
||||||
COMPRESSION_MODES,
|
COMPRESSION_MODES,
|
||||||
|
|
@ -10,28 +10,25 @@ import {
|
||||||
} from "~/schemas/restic";
|
} from "~/schemas/restic";
|
||||||
import { resticSnapshotSummarySchema, resticStatsSchema } from "~/schemas/restic-dto";
|
import { resticSnapshotSummarySchema, resticStatsSchema } from "~/schemas/restic-dto";
|
||||||
|
|
||||||
export const repositorySchema = type({
|
export const repositorySchema = z.object({
|
||||||
id: "string",
|
id: z.string(),
|
||||||
shortId: "string",
|
shortId: z.string(),
|
||||||
name: "string",
|
name: z.string(),
|
||||||
type: type.valueOf(REPOSITORY_BACKENDS),
|
type: z.enum(REPOSITORY_BACKENDS),
|
||||||
config: repositoryConfigSchema,
|
config: repositoryConfigSchema,
|
||||||
compressionMode: type.valueOf(COMPRESSION_MODES).or("null"),
|
compressionMode: z.enum(COMPRESSION_MODES).nullable(),
|
||||||
status: type.valueOf(REPOSITORY_STATUS).or("null"),
|
status: z.enum(REPOSITORY_STATUS).nullable(),
|
||||||
lastChecked: "number | null",
|
lastChecked: z.number().nullable(),
|
||||||
lastError: "string | null",
|
lastError: z.string().nullable(),
|
||||||
doctorResult: doctorResultSchema.or("null"),
|
doctorResult: doctorResultSchema.nullable(),
|
||||||
createdAt: "number",
|
createdAt: z.number(),
|
||||||
updatedAt: "number",
|
updatedAt: z.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type RepositoryDto = typeof repositorySchema.infer;
|
export type RepositoryDto = z.infer<typeof repositorySchema>;
|
||||||
|
|
||||||
/**
|
|
||||||
* List all repositories
|
|
||||||
*/
|
|
||||||
export const listRepositoriesResponse = repositorySchema.array();
|
export const listRepositoriesResponse = repositorySchema.array();
|
||||||
export type ListRepositoriesDto = typeof listRepositoriesResponse.infer;
|
export type ListRepositoriesDto = z.infer<typeof listRepositoriesResponse>;
|
||||||
|
|
||||||
export const listRepositoriesDto = describeRoute({
|
export const listRepositoriesDto = describeRoute({
|
||||||
description: "List all repositories",
|
description: "List all repositories",
|
||||||
|
|
@ -49,27 +46,24 @@ export const listRepositoriesDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
export const createRepositoryBody = z.object({
|
||||||
* Create a new repository
|
name: z.string(),
|
||||||
*/
|
compressionMode: z.enum(COMPRESSION_MODES).optional(),
|
||||||
export const createRepositoryBody = type({
|
|
||||||
name: "string",
|
|
||||||
compressionMode: type.valueOf(COMPRESSION_MODES).optional(),
|
|
||||||
config: repositoryConfigSchema,
|
config: repositoryConfigSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type CreateRepositoryBody = typeof createRepositoryBody.infer;
|
export type CreateRepositoryBody = z.infer<typeof createRepositoryBody>;
|
||||||
|
|
||||||
export const createRepositoryResponse = type({
|
export const createRepositoryResponse = z.object({
|
||||||
message: "string",
|
message: z.string(),
|
||||||
repository: type({
|
repository: z.object({
|
||||||
id: "string",
|
id: z.string(),
|
||||||
shortId: "string",
|
shortId: z.string(),
|
||||||
name: "string",
|
name: z.string(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type CreateRepositoryDto = typeof createRepositoryResponse.infer;
|
export type CreateRepositoryDto = z.infer<typeof createRepositoryResponse>;
|
||||||
|
|
||||||
export const createRepositoryDto = describeRoute({
|
export const createRepositoryDto = describeRoute({
|
||||||
description: "Create a new restic repository",
|
description: "Create a new restic repository",
|
||||||
|
|
@ -87,11 +81,8 @@ export const createRepositoryDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a single repository
|
|
||||||
*/
|
|
||||||
export const getRepositoryResponse = repositorySchema;
|
export const getRepositoryResponse = repositorySchema;
|
||||||
export type GetRepositoryDto = typeof getRepositoryResponse.infer;
|
export type GetRepositoryDto = z.infer<typeof getRepositoryResponse>;
|
||||||
|
|
||||||
export const getRepositoryDto = describeRoute({
|
export const getRepositoryDto = describeRoute({
|
||||||
description: "Get a single repository by ID",
|
description: "Get a single repository by ID",
|
||||||
|
|
@ -109,12 +100,9 @@ export const getRepositoryDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Get repository stats
|
|
||||||
*/
|
|
||||||
export const repositoryStatsSchema = resticStatsSchema;
|
export const repositoryStatsSchema = resticStatsSchema;
|
||||||
export const getRepositoryStatsResponse = repositoryStatsSchema;
|
export const getRepositoryStatsResponse = repositoryStatsSchema;
|
||||||
export type GetRepositoryStatsDto = typeof getRepositoryStatsResponse.infer;
|
export type GetRepositoryStatsDto = z.infer<typeof getRepositoryStatsResponse>;
|
||||||
|
|
||||||
export const getRepositoryStatsDto = describeRoute({
|
export const getRepositoryStatsDto = describeRoute({
|
||||||
description: "Get repository storage and compression statistics",
|
description: "Get repository storage and compression statistics",
|
||||||
|
|
@ -133,7 +121,7 @@ export const getRepositoryStatsDto = describeRoute({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const refreshRepositoryStatsResponse = repositoryStatsSchema;
|
export const refreshRepositoryStatsResponse = repositoryStatsSchema;
|
||||||
export type RefreshRepositoryStatsDto = typeof refreshRepositoryStatsResponse.infer;
|
export type RefreshRepositoryStatsDto = z.infer<typeof refreshRepositoryStatsResponse>;
|
||||||
|
|
||||||
export const refreshRepositoryStatsDto = describeRoute({
|
export const refreshRepositoryStatsDto = describeRoute({
|
||||||
description: "Refresh repository storage and compression statistics",
|
description: "Refresh repository storage and compression statistics",
|
||||||
|
|
@ -151,14 +139,11 @@ export const refreshRepositoryStatsDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
export const deleteRepositoryResponse = z.object({
|
||||||
* Delete a repository
|
message: z.string(),
|
||||||
*/
|
|
||||||
export const deleteRepositoryResponse = type({
|
|
||||||
message: "string",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type DeleteRepositoryDto = typeof deleteRepositoryResponse.infer;
|
export type DeleteRepositoryDto = z.infer<typeof deleteRepositoryResponse>;
|
||||||
|
|
||||||
export const deleteRepositoryDto = describeRoute({
|
export const deleteRepositoryDto = describeRoute({
|
||||||
description: "Delete a repository",
|
description: "Delete a repository",
|
||||||
|
|
@ -176,19 +161,16 @@ export const deleteRepositoryDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
export const updateRepositoryBody = z.object({
|
||||||
* Update a repository
|
name: z.string().optional(),
|
||||||
*/
|
compressionMode: z.enum(COMPRESSION_MODES).optional(),
|
||||||
export const updateRepositoryBody = type({
|
|
||||||
name: "string?",
|
|
||||||
compressionMode: type.valueOf(COMPRESSION_MODES).optional(),
|
|
||||||
config: repositoryConfigSchema.optional(),
|
config: repositoryConfigSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type UpdateRepositoryBody = typeof updateRepositoryBody.infer;
|
export type UpdateRepositoryBody = z.infer<typeof updateRepositoryBody>;
|
||||||
|
|
||||||
export const updateRepositoryResponse = repositorySchema;
|
export const updateRepositoryResponse = repositorySchema;
|
||||||
export type UpdateRepositoryDto = typeof updateRepositoryResponse.infer;
|
export type UpdateRepositoryDto = z.infer<typeof updateRepositoryResponse>;
|
||||||
|
|
||||||
export const updateRepositoryDto = describeRoute({
|
export const updateRepositoryDto = describeRoute({
|
||||||
description: "Update a repository's name or settings",
|
description: "Update a repository's name or settings",
|
||||||
|
|
@ -215,27 +197,24 @@ export const updateRepositoryDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
export const snapshotSchema = z.object({
|
||||||
* List snapshots in a repository
|
short_id: z.string(),
|
||||||
*/
|
time: z.number(),
|
||||||
export const snapshotSchema = type({
|
paths: z.array(z.string()),
|
||||||
short_id: "string",
|
size: z.number(),
|
||||||
time: "number",
|
duration: z.number(),
|
||||||
paths: "string[]",
|
tags: z.array(z.string()),
|
||||||
size: "number",
|
retentionCategories: z.array(z.string()),
|
||||||
duration: "number",
|
hostname: z.string().optional(),
|
||||||
tags: "string[]",
|
|
||||||
retentionCategories: "string[]",
|
|
||||||
hostname: "string?",
|
|
||||||
summary: resticSnapshotSummarySchema.optional(),
|
summary: resticSnapshotSummarySchema.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const listSnapshotsResponse = snapshotSchema.array();
|
const listSnapshotsResponse = snapshotSchema.array();
|
||||||
|
|
||||||
export type ListSnapshotsDto = typeof listSnapshotsResponse.infer;
|
export type ListSnapshotsDto = z.infer<typeof listSnapshotsResponse>;
|
||||||
|
|
||||||
export const listSnapshotsFilters = type({
|
export const listSnapshotsFilters = z.object({
|
||||||
backupId: "string?",
|
backupId: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const listSnapshotsDto = describeRoute({
|
export const listSnapshotsDto = describeRoute({
|
||||||
|
|
@ -254,12 +233,9 @@ export const listSnapshotsDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Get snapshot details
|
|
||||||
*/
|
|
||||||
export const getSnapshotDetailsResponse = snapshotSchema;
|
export const getSnapshotDetailsResponse = snapshotSchema;
|
||||||
|
|
||||||
export type GetSnapshotDetailsDto = typeof getSnapshotDetailsResponse.infer;
|
export type GetSnapshotDetailsDto = z.infer<typeof getSnapshotDetailsResponse>;
|
||||||
|
|
||||||
export const getSnapshotDetailsDto = describeRoute({
|
export const getSnapshotDetailsDto = describeRoute({
|
||||||
description: "Get details of a specific snapshot",
|
description: "Get details of a specific snapshot",
|
||||||
|
|
@ -277,43 +253,40 @@ export const getSnapshotDetailsDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
export const snapshotFileNodeSchema = z.object({
|
||||||
* List files in a snapshot
|
name: z.string(),
|
||||||
*/
|
type: z.string(),
|
||||||
export const snapshotFileNodeSchema = type({
|
path: z.string(),
|
||||||
name: "string",
|
uid: z.number().optional(),
|
||||||
type: "string",
|
gid: z.number().optional(),
|
||||||
path: "string",
|
size: z.number().optional(),
|
||||||
uid: "number?",
|
mode: z.number().optional(),
|
||||||
gid: "number?",
|
mtime: z.string().optional(),
|
||||||
size: "number?",
|
atime: z.string().optional(),
|
||||||
mode: "number?",
|
ctime: z.string().optional(),
|
||||||
mtime: "string?",
|
|
||||||
atime: "string?",
|
|
||||||
ctime: "string?",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const listSnapshotFilesResponse = type({
|
export const listSnapshotFilesResponse = z.object({
|
||||||
snapshot: type({
|
snapshot: z.object({
|
||||||
id: "string",
|
id: z.string(),
|
||||||
short_id: "string",
|
short_id: z.string(),
|
||||||
time: "string",
|
time: z.string(),
|
||||||
hostname: "string",
|
hostname: z.string().optional(),
|
||||||
paths: "string[]",
|
paths: z.array(z.string()),
|
||||||
}),
|
}),
|
||||||
files: snapshotFileNodeSchema.array(),
|
files: snapshotFileNodeSchema.array(),
|
||||||
offset: "number",
|
offset: z.number(),
|
||||||
limit: "number",
|
limit: z.number(),
|
||||||
total: "number",
|
total: z.number(),
|
||||||
hasMore: "boolean",
|
hasMore: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ListSnapshotFilesDto = typeof listSnapshotFilesResponse.infer;
|
export type ListSnapshotFilesDto = z.infer<typeof listSnapshotFilesResponse>;
|
||||||
|
|
||||||
export const listSnapshotFilesQuery = type({
|
export const listSnapshotFilesQuery = z.object({
|
||||||
path: "string?",
|
path: z.string().optional(),
|
||||||
offset: "string.integer?",
|
offset: z.coerce.number().int().optional(),
|
||||||
limit: "string.integer?",
|
limit: z.coerce.number().int().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const listSnapshotFilesDto = describeRoute({
|
export const listSnapshotFilesDto = describeRoute({
|
||||||
|
|
@ -337,14 +310,11 @@ const DUMP_PATH_KINDS = {
|
||||||
dir: "dir",
|
dir: "dir",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const dumpPathKindSchema = type.valueOf(DUMP_PATH_KINDS);
|
export const dumpPathKindSchema = z.enum(DUMP_PATH_KINDS);
|
||||||
export type DumpPathKind = typeof dumpPathKindSchema.infer;
|
export type DumpPathKind = z.infer<typeof dumpPathKindSchema>;
|
||||||
|
|
||||||
/**
|
export const dumpSnapshotQuery = z.object({
|
||||||
* Download snapshot paths as tar archives (folders) or raw file streams (single files)
|
path: z.string().optional(),
|
||||||
*/
|
|
||||||
export const dumpSnapshotQuery = type({
|
|
||||||
path: "string?",
|
|
||||||
kind: dumpPathKindSchema.optional(),
|
kind: dumpPathKindSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -367,32 +337,29 @@ export const dumpSnapshotDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
export const overwriteModeSchema = z.enum(OVERWRITE_MODES);
|
||||||
* Restore a snapshot
|
|
||||||
*/
|
|
||||||
export const overwriteModeSchema = type.valueOf(OVERWRITE_MODES);
|
|
||||||
|
|
||||||
export const restoreSnapshotBody = type({
|
export const restoreSnapshotBody = z.object({
|
||||||
snapshotId: "string",
|
snapshotId: z.string(),
|
||||||
include: "string[]?",
|
include: z.array(z.string()).optional(),
|
||||||
selectedItemKind: dumpPathKindSchema.optional(),
|
selectedItemKind: dumpPathKindSchema.optional(),
|
||||||
exclude: "string[]?",
|
exclude: z.array(z.string()).optional(),
|
||||||
excludeXattr: "string[]?",
|
excludeXattr: z.array(z.string()).optional(),
|
||||||
delete: "boolean?",
|
delete: z.boolean().optional(),
|
||||||
targetPath: "string?",
|
targetPath: z.string().optional(),
|
||||||
overwrite: overwriteModeSchema.optional(),
|
overwrite: overwriteModeSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type RestoreSnapshotBody = typeof restoreSnapshotBody.infer;
|
export type RestoreSnapshotBody = z.infer<typeof restoreSnapshotBody>;
|
||||||
|
|
||||||
export const restoreSnapshotResponse = type({
|
export const restoreSnapshotResponse = z.object({
|
||||||
success: "boolean",
|
success: z.boolean(),
|
||||||
message: "string",
|
message: z.string(),
|
||||||
filesRestored: "number",
|
filesRestored: z.number(),
|
||||||
filesSkipped: "number",
|
filesSkipped: z.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type RestoreSnapshotDto = typeof restoreSnapshotResponse.infer;
|
export type RestoreSnapshotDto = z.infer<typeof restoreSnapshotResponse>;
|
||||||
|
|
||||||
export const restoreSnapshotDto = describeRoute({
|
export const restoreSnapshotDto = describeRoute({
|
||||||
description: "Restore a snapshot to a target path on the filesystem",
|
description: "Restore a snapshot to a target path on the filesystem",
|
||||||
|
|
@ -410,15 +377,12 @@ export const restoreSnapshotDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
export const startDoctorResponse = z.object({
|
||||||
* Start doctor operation
|
message: z.string(),
|
||||||
*/
|
repositoryId: z.string(),
|
||||||
export const startDoctorResponse = type({
|
|
||||||
message: "string",
|
|
||||||
repositoryId: "string",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type StartDoctorDto = typeof startDoctorResponse.infer;
|
export type StartDoctorDto = z.infer<typeof startDoctorResponse>;
|
||||||
|
|
||||||
export const startDoctorDto = describeRoute({
|
export const startDoctorDto = describeRoute({
|
||||||
description:
|
description:
|
||||||
|
|
@ -440,14 +404,11 @@ export const startDoctorDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
export const cancelDoctorResponse = z.object({
|
||||||
* Cancel running doctor operation
|
message: z.string(),
|
||||||
*/
|
|
||||||
export const cancelDoctorResponse = type({
|
|
||||||
message: "string",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type CancelDoctorDto = typeof cancelDoctorResponse.infer;
|
export type CancelDoctorDto = z.infer<typeof cancelDoctorResponse>;
|
||||||
|
|
||||||
export const cancelDoctorDto = describeRoute({
|
export const cancelDoctorDto = describeRoute({
|
||||||
description: "Cancel a running doctor operation on a repository",
|
description: "Cancel a running doctor operation on a repository",
|
||||||
|
|
@ -468,12 +429,9 @@ export const cancelDoctorDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
const rcloneRemoteSchema = z.object({
|
||||||
* List rclone available remotes
|
name: z.string(),
|
||||||
*/
|
type: z.string(),
|
||||||
const rcloneRemoteSchema = type({
|
|
||||||
name: "string",
|
|
||||||
type: "string",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const listRcloneRemotesResponse = rcloneRemoteSchema.array();
|
const listRcloneRemotesResponse = rcloneRemoteSchema.array();
|
||||||
|
|
@ -494,14 +452,11 @@ export const listRcloneRemotesDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
export const deleteSnapshotResponse = z.object({
|
||||||
* Delete a snapshot
|
message: z.string(),
|
||||||
*/
|
|
||||||
export const deleteSnapshotResponse = type({
|
|
||||||
message: "string",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type DeleteSnapshotDto = typeof deleteSnapshotResponse.infer;
|
export type DeleteSnapshotDto = z.infer<typeof deleteSnapshotResponse>;
|
||||||
|
|
||||||
export const deleteSnapshotDto = describeRoute({
|
export const deleteSnapshotDto = describeRoute({
|
||||||
description: "Delete a specific snapshot from a repository",
|
description: "Delete a specific snapshot from a repository",
|
||||||
|
|
@ -519,18 +474,15 @@ export const deleteSnapshotDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
export const deleteSnapshotsBody = z.object({
|
||||||
* Delete multiple snapshots
|
snapshotIds: z.array(z.string()).min(1),
|
||||||
*/
|
|
||||||
export const deleteSnapshotsBody = type({
|
|
||||||
snapshotIds: "string[]>=1",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const deleteSnapshotsResponse = type({
|
export const deleteSnapshotsResponse = z.object({
|
||||||
message: "string",
|
message: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type DeleteSnapshotsResponseDto = typeof deleteSnapshotsResponse.infer;
|
export type DeleteSnapshotsResponseDto = z.infer<typeof deleteSnapshotsResponse>;
|
||||||
|
|
||||||
export const deleteSnapshotsDto = describeRoute({
|
export const deleteSnapshotsDto = describeRoute({
|
||||||
description: "Delete multiple snapshots from a repository",
|
description: "Delete multiple snapshots from a repository",
|
||||||
|
|
@ -548,21 +500,18 @@ export const deleteSnapshotsDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
export const tagSnapshotsBody = z.object({
|
||||||
* Tag multiple snapshots
|
snapshotIds: z.array(z.string()).min(1),
|
||||||
*/
|
add: z.array(z.string()).optional(),
|
||||||
export const tagSnapshotsBody = type({
|
remove: z.array(z.string()).optional(),
|
||||||
snapshotIds: "string[]>=1",
|
set: z.array(z.string()).optional(),
|
||||||
add: "string[]?",
|
|
||||||
remove: "string[]?",
|
|
||||||
set: "string[]?",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const tagSnapshotsResponse = type({
|
export const tagSnapshotsResponse = z.object({
|
||||||
message: "string",
|
message: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TagSnapshotsResponseDto = typeof tagSnapshotsResponse.infer;
|
export type TagSnapshotsResponseDto = z.infer<typeof tagSnapshotsResponse>;
|
||||||
|
|
||||||
export const tagSnapshotsDto = describeRoute({
|
export const tagSnapshotsDto = describeRoute({
|
||||||
description: "Tag multiple snapshots in a repository",
|
description: "Tag multiple snapshots in a repository",
|
||||||
|
|
@ -580,15 +529,12 @@ export const tagSnapshotsDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
export const refreshSnapshotsResponse = z.object({
|
||||||
* Refresh snapshots cache
|
message: z.string(),
|
||||||
*/
|
count: z.number(),
|
||||||
export const refreshSnapshotsResponse = type({
|
|
||||||
message: "string",
|
|
||||||
count: "number",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type RefreshSnapshotsDto = typeof refreshSnapshotsResponse.infer;
|
export type RefreshSnapshotsDto = z.infer<typeof refreshSnapshotsResponse>;
|
||||||
|
|
||||||
export const refreshSnapshotsDto = describeRoute({
|
export const refreshSnapshotsDto = describeRoute({
|
||||||
description: "Clear snapshot cache and force refresh from repository",
|
description: "Clear snapshot cache and force refresh from repository",
|
||||||
|
|
@ -606,12 +552,12 @@ export const refreshSnapshotsDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const devPanelExecBody = type({
|
export const devPanelExecBody = z.object({
|
||||||
command: "string",
|
command: z.string(),
|
||||||
args: "string[]?",
|
args: z.array(z.string()).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type DevPanelExecBody = typeof devPanelExecBody.infer;
|
export type DevPanelExecBody = z.infer<typeof devPanelExecBody>;
|
||||||
|
|
||||||
export const devPanelExecDto = describeRoute({
|
export const devPanelExecDto = describeRoute({
|
||||||
description: "Execute a restic command against a repository (dev panel only)",
|
description: "Execute a restic command against a repository (dev panel only)",
|
||||||
|
|
@ -632,15 +578,12 @@ export const devPanelExecDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
export const unlockRepositoryResponse = z.object({
|
||||||
* Unlock repository
|
success: z.boolean(),
|
||||||
*/
|
message: z.string(),
|
||||||
export const unlockRepositoryResponse = type({
|
|
||||||
success: "boolean",
|
|
||||||
message: "string",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type UnlockRepositoryDto = typeof unlockRepositoryResponse.infer;
|
export type UnlockRepositoryDto = z.infer<typeof unlockRepositoryResponse>;
|
||||||
|
|
||||||
export const unlockRepositoryDto = describeRoute({
|
export const unlockRepositoryDto = describeRoute({
|
||||||
description: "Unlock a repository by removing all stale locks",
|
description: "Unlock a repository by removing all stale locks",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import nodePath from "node:path";
|
import nodePath from "node:path";
|
||||||
import { type } from "arktype";
|
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { BadRequestError, ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced";
|
import { BadRequestError, ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced";
|
||||||
import {
|
import {
|
||||||
|
|
@ -732,10 +731,11 @@ const updateRepository = async (shortId: ShortId, updates: UpdateRepositoryBody)
|
||||||
throw new NotFoundError("Repository not found");
|
throw new NotFoundError("Repository not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingConfig = repositoryConfigSchema(existing.config);
|
const existingConfigResult = repositoryConfigSchema.safeParse(existing.config);
|
||||||
if (existingConfig instanceof type.errors) {
|
if (!existingConfigResult.success) {
|
||||||
throw new InternalServerError("Invalid repository configuration");
|
throw new InternalServerError("Invalid repository configuration");
|
||||||
}
|
}
|
||||||
|
const existingConfig = existingConfigResult.data;
|
||||||
|
|
||||||
let newName = existing.name;
|
let newName = existing.name;
|
||||||
if (updates.name) {
|
if (updates.name) {
|
||||||
|
|
@ -747,10 +747,11 @@ const updateRepository = async (shortId: ShortId, updates: UpdateRepositoryBody)
|
||||||
|
|
||||||
let parsedConfig = existingConfig;
|
let parsedConfig = existingConfig;
|
||||||
if (updates.config) {
|
if (updates.config) {
|
||||||
const nextConfig = repositoryConfigSchema(updates.config);
|
const nextConfigResult = repositoryConfigSchema.safeParse(updates.config);
|
||||||
if (nextConfig instanceof type.errors) {
|
if (!nextConfigResult.success) {
|
||||||
throw new BadRequestError("Invalid repository configuration");
|
throw new BadRequestError("Invalid repository configuration");
|
||||||
}
|
}
|
||||||
|
const nextConfig = nextConfigResult.data;
|
||||||
|
|
||||||
if (nextConfig.backend !== existing.type) {
|
if (nextConfig.backend !== existing.type) {
|
||||||
throw new BadRequestError("Repository backend cannot be changed");
|
throw new BadRequestError("Repository backend cannot be changed");
|
||||||
|
|
@ -759,7 +760,7 @@ const updateRepository = async (shortId: ShortId, updates: UpdateRepositoryBody)
|
||||||
parsedConfig = nextConfig;
|
parsedConfig = nextConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
const decryptedExisting = existingConfig ? await decryptConfig(existingConfig) : null;
|
const decryptedExisting = await decryptConfig(existingConfig);
|
||||||
const configChanged = updates.config && JSON.stringify(decryptedExisting) !== JSON.stringify(parsedConfig);
|
const configChanged = updates.config && JSON.stringify(decryptedExisting) !== JSON.stringify(parsedConfig);
|
||||||
const encryptedConfig = updates.config ? await encryptConfig(parsedConfig) : existingConfig;
|
const encryptedConfig = updates.config ? await encryptConfig(parsedConfig) : existingConfig;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,16 @@
|
||||||
import { type } from "arktype";
|
import { z } from "zod";
|
||||||
import { describeRoute, resolver } from "hono-openapi";
|
import { describeRoute, resolver } from "hono-openapi";
|
||||||
|
|
||||||
export const publicSsoProvidersDto = type({
|
export const publicSsoProvidersDto = z.object({
|
||||||
providers: type({
|
providers: z
|
||||||
providerId: "string",
|
.object({
|
||||||
organizationSlug: "string",
|
providerId: z.string(),
|
||||||
})
|
organizationSlug: z.string(),
|
||||||
.onUndeclaredKey("delete")
|
})
|
||||||
.array(),
|
.array(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type PublicSsoProvidersDto = typeof publicSsoProvidersDto.infer;
|
export type PublicSsoProvidersDto = z.infer<typeof publicSsoProvidersDto>;
|
||||||
|
|
||||||
export const getPublicSsoProvidersDto = describeRoute({
|
export const getPublicSsoProvidersDto = describeRoute({
|
||||||
description: "Get public SSO providers for the instance",
|
description: "Get public SSO providers for the instance",
|
||||||
|
|
@ -28,25 +28,29 @@ export const getPublicSsoProvidersDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ssoSettingsResponse = type({
|
export const ssoSettingsResponse = z.object({
|
||||||
providers: type({
|
providers: z
|
||||||
providerId: "string",
|
.object({
|
||||||
type: "string",
|
providerId: z.string(),
|
||||||
issuer: "string",
|
type: z.string(),
|
||||||
domain: "string",
|
issuer: z.string(),
|
||||||
autoLinkMatchingEmails: "boolean",
|
domain: z.string(),
|
||||||
organizationId: "string | null",
|
autoLinkMatchingEmails: z.boolean(),
|
||||||
}).array(),
|
organizationId: z.string().nullable(),
|
||||||
invitations: type({
|
})
|
||||||
id: "string",
|
.array(),
|
||||||
email: "string",
|
invitations: z
|
||||||
role: "string",
|
.object({
|
||||||
status: "string",
|
id: z.string(),
|
||||||
expiresAt: "string",
|
email: z.string(),
|
||||||
}).array(),
|
role: z.string(),
|
||||||
|
status: z.string(),
|
||||||
|
expiresAt: z.string(),
|
||||||
|
})
|
||||||
|
.array(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type SsoSettingsDto = typeof ssoSettingsResponse.infer;
|
export type SsoSettingsDto = z.infer<typeof ssoSettingsResponse>;
|
||||||
|
|
||||||
export const getSsoSettingsDto = describeRoute({
|
export const getSsoSettingsDto = describeRoute({
|
||||||
description: "Get SSO providers and invitations for the active organization",
|
description: "Get SSO providers and invitations for the active organization",
|
||||||
|
|
@ -95,8 +99,8 @@ export const deleteSsoInvitationDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updateSsoProviderAutoLinkingBody = type({
|
export const updateSsoProviderAutoLinkingBody = z.object({
|
||||||
enabled: "boolean",
|
enabled: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updateSsoProviderAutoLinkingDto = describeRoute({
|
export const updateSsoProviderAutoLinkingDto = describeRoute({
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,32 @@
|
||||||
import { type } from "arktype";
|
import { z } from "zod";
|
||||||
import { describeRoute, resolver } from "hono-openapi";
|
import { describeRoute, resolver } from "hono-openapi";
|
||||||
|
|
||||||
export const capabilitiesSchema = type({
|
export const capabilitiesSchema = z.object({
|
||||||
rclone: "boolean",
|
rclone: z.boolean(),
|
||||||
sysAdmin: "boolean",
|
sysAdmin: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const systemInfoResponse = type({
|
export const systemInfoResponse = z.object({
|
||||||
capabilities: capabilitiesSchema,
|
capabilities: capabilitiesSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type SystemInfoDto = typeof systemInfoResponse.infer;
|
export type SystemInfoDto = z.infer<typeof systemInfoResponse>;
|
||||||
|
|
||||||
export const releaseInfoSchema = type({
|
export const releaseInfoSchema = z.object({
|
||||||
version: "string",
|
version: z.string(),
|
||||||
url: "string",
|
url: z.string(),
|
||||||
publishedAt: "string",
|
publishedAt: z.string(),
|
||||||
body: "string",
|
body: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updateInfoResponse = type({
|
export const updateInfoResponse = z.object({
|
||||||
currentVersion: "string",
|
currentVersion: z.string(),
|
||||||
latestVersion: "string",
|
latestVersion: z.string(),
|
||||||
hasUpdate: "boolean",
|
hasUpdate: z.boolean(),
|
||||||
missedReleases: releaseInfoSchema.array(),
|
missedReleases: releaseInfoSchema.array(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type UpdateInfoDto = typeof updateInfoResponse.infer;
|
export type UpdateInfoDto = z.infer<typeof updateInfoResponse>;
|
||||||
|
|
||||||
export const systemInfoDto = describeRoute({
|
export const systemInfoDto = describeRoute({
|
||||||
description: "Get system information including available capabilities",
|
description: "Get system information including available capabilities",
|
||||||
|
|
@ -60,8 +60,8 @@ export const getUpdatesDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const downloadResticPasswordBodySchema = type({
|
export const downloadResticPasswordBodySchema = z.object({
|
||||||
password: "string",
|
password: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const downloadResticPasswordDto = describeRoute({
|
export const downloadResticPasswordDto = describeRoute({
|
||||||
|
|
@ -81,14 +81,14 @@ export const downloadResticPasswordDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const registrationStatusResponse = type({
|
export const registrationStatusResponse = z.object({
|
||||||
enabled: "boolean",
|
enabled: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type RegistrationStatusDto = typeof registrationStatusResponse.infer;
|
export type RegistrationStatusDto = z.infer<typeof registrationStatusResponse>;
|
||||||
|
|
||||||
export const registrationStatusBody = type({
|
export const registrationStatusBody = z.object({
|
||||||
enabled: "boolean",
|
enabled: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getRegistrationStatusDto = describeRoute({
|
export const getRegistrationStatusDto = describeRoute({
|
||||||
|
|
@ -123,11 +123,11 @@ export const setRegistrationStatusDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const devPanelResponse = type({
|
export const devPanelResponse = z.object({
|
||||||
enabled: "boolean",
|
enabled: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type DevPanelDto = typeof devPanelResponse.infer;
|
export type DevPanelDto = z.infer<typeof devPanelResponse>;
|
||||||
|
|
||||||
export const getDevPanelDto = describeRoute({
|
export const getDevPanelDto = describeRoute({
|
||||||
description: "Get the dev panel status",
|
description: "Get the dev panel status",
|
||||||
|
|
|
||||||
|
|
@ -110,8 +110,8 @@ export const volumeController = new Hono()
|
||||||
const shortId = asShortId(c.req.param("shortId"));
|
const shortId = asShortId(c.req.param("shortId"));
|
||||||
const { path, ...query } = c.req.valid("query");
|
const { path, ...query } = c.req.valid("query");
|
||||||
|
|
||||||
const offset = Math.max(0, Number.parseInt(query.offset ?? "0", 10) || 0);
|
const offset = Math.max(0, query.offset ?? 0);
|
||||||
const limit = Math.min(1000, Math.max(1, Number.parseInt(query.limit ?? "500", 10) || 500));
|
const limit = Math.min(1000, Math.max(1, query.limit ?? 500));
|
||||||
|
|
||||||
const result = await volumeService.listFiles(shortId, path, offset, limit);
|
const result = await volumeService.listFiles(shortId, path, offset, limit);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,25 @@
|
||||||
import { type } from "arktype";
|
import { z } from "zod";
|
||||||
import { describeRoute, resolver } from "hono-openapi";
|
import { describeRoute, resolver } from "hono-openapi";
|
||||||
import { BACKEND_STATUS, BACKEND_TYPES, volumeConfigSchema } from "~/schemas/volumes";
|
import { BACKEND_STATUS, BACKEND_TYPES, volumeConfigSchema } from "~/schemas/volumes";
|
||||||
|
|
||||||
export const volumeSchema = type({
|
export const volumeSchema = z.object({
|
||||||
id: "number",
|
id: z.number(),
|
||||||
shortId: "string",
|
shortId: z.string(),
|
||||||
name: "string",
|
name: z.string(),
|
||||||
type: type.valueOf(BACKEND_TYPES),
|
type: z.enum(BACKEND_TYPES),
|
||||||
status: type.valueOf(BACKEND_STATUS),
|
status: z.enum(BACKEND_STATUS),
|
||||||
lastError: "string | null",
|
lastError: z.string().nullable(),
|
||||||
createdAt: "number",
|
createdAt: z.number(),
|
||||||
updatedAt: "number",
|
updatedAt: z.number(),
|
||||||
lastHealthCheck: "number",
|
lastHealthCheck: z.number(),
|
||||||
config: volumeConfigSchema,
|
config: volumeConfigSchema,
|
||||||
autoRemount: "boolean",
|
autoRemount: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type VolumeDto = typeof volumeSchema.infer;
|
export type VolumeDto = z.infer<typeof volumeSchema>;
|
||||||
|
|
||||||
/**
|
|
||||||
* List all volumes
|
|
||||||
*/
|
|
||||||
export const listVolumesResponse = volumeSchema.array();
|
export const listVolumesResponse = volumeSchema.array();
|
||||||
export type ListVolumesDto = typeof listVolumesResponse.infer;
|
export type ListVolumesDto = z.infer<typeof listVolumesResponse>;
|
||||||
|
|
||||||
export const listVolumesDto = describeRoute({
|
export const listVolumesDto = describeRoute({
|
||||||
description: "List all volumes",
|
description: "List all volumes",
|
||||||
|
|
@ -40,16 +37,13 @@ export const listVolumesDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
export const createVolumeBody = z.object({
|
||||||
* Create a new volume
|
name: z.string(),
|
||||||
*/
|
|
||||||
export const createVolumeBody = type({
|
|
||||||
name: "string",
|
|
||||||
config: volumeConfigSchema,
|
config: volumeConfigSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const createVolumeResponse = volumeSchema;
|
export const createVolumeResponse = volumeSchema;
|
||||||
export type CreateVolumeDto = typeof createVolumeResponse.infer;
|
export type CreateVolumeDto = z.infer<typeof createVolumeResponse>;
|
||||||
|
|
||||||
export const createVolumeDto = describeRoute({
|
export const createVolumeDto = describeRoute({
|
||||||
description: "Create a new volume",
|
description: "Create a new volume",
|
||||||
|
|
@ -67,13 +61,10 @@ export const createVolumeDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
export const deleteVolumeResponse = z.object({
|
||||||
* Delete a volume
|
message: z.string(),
|
||||||
*/
|
|
||||||
export const deleteVolumeResponse = type({
|
|
||||||
message: "string",
|
|
||||||
});
|
});
|
||||||
export type DeleteVolumeDto = typeof deleteVolumeResponse.infer;
|
export type DeleteVolumeDto = z.infer<typeof deleteVolumeResponse>;
|
||||||
|
|
||||||
export const deleteVolumeDto = describeRoute({
|
export const deleteVolumeDto = describeRoute({
|
||||||
description: "Delete a volume",
|
description: "Delete a volume",
|
||||||
|
|
@ -91,21 +82,19 @@ export const deleteVolumeDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const statfsSchema = type({
|
const statfsSchema = z.object({
|
||||||
total: "number",
|
total: z.number(),
|
||||||
used: "number",
|
used: z.number(),
|
||||||
free: "number",
|
free: z.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const getVolumeResponse = type({
|
const getVolumeResponse = z.object({
|
||||||
volume: volumeSchema,
|
volume: volumeSchema,
|
||||||
statfs: statfsSchema,
|
statfs: statfsSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type GetVolumeDto = typeof getVolumeResponse.infer;
|
export type GetVolumeDto = z.infer<typeof getVolumeResponse>;
|
||||||
/**
|
|
||||||
* Get a volume
|
|
||||||
*/
|
|
||||||
export const getVolumeDto = describeRoute({
|
export const getVolumeDto = describeRoute({
|
||||||
description: "Get a volume by name",
|
description: "Get a volume by name",
|
||||||
operationId: "getVolume",
|
operationId: "getVolume",
|
||||||
|
|
@ -125,19 +114,16 @@ export const getVolumeDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
export const updateVolumeBody = z.object({
|
||||||
* Update a volume
|
name: z.string().optional(),
|
||||||
*/
|
autoRemount: z.boolean().optional(),
|
||||||
export const updateVolumeBody = type({
|
|
||||||
name: "string?",
|
|
||||||
autoRemount: "boolean?",
|
|
||||||
config: volumeConfigSchema.optional(),
|
config: volumeConfigSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type UpdateVolumeBody = typeof updateVolumeBody.infer;
|
export type UpdateVolumeBody = z.infer<typeof updateVolumeBody>;
|
||||||
|
|
||||||
export const updateVolumeResponse = volumeSchema;
|
export const updateVolumeResponse = volumeSchema;
|
||||||
export type UpdateVolumeDto = typeof updateVolumeResponse.infer;
|
export type UpdateVolumeDto = z.infer<typeof updateVolumeResponse>;
|
||||||
|
|
||||||
export const updateVolumeDto = describeRoute({
|
export const updateVolumeDto = describeRoute({
|
||||||
description: "Update a volume's configuration",
|
description: "Update a volume's configuration",
|
||||||
|
|
@ -158,18 +144,15 @@ export const updateVolumeDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
export const testConnectionBody = z.object({
|
||||||
* Test connection
|
|
||||||
*/
|
|
||||||
export const testConnectionBody = type({
|
|
||||||
config: volumeConfigSchema,
|
config: volumeConfigSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const testConnectionResponse = type({
|
export const testConnectionResponse = z.object({
|
||||||
success: "boolean",
|
success: z.boolean(),
|
||||||
message: "string",
|
message: z.string(),
|
||||||
});
|
});
|
||||||
export type TestConnectionDto = typeof testConnectionResponse.infer;
|
export type TestConnectionDto = z.infer<typeof testConnectionResponse>;
|
||||||
|
|
||||||
export const testConnectionDto = describeRoute({
|
export const testConnectionDto = describeRoute({
|
||||||
description: "Test connection to backend",
|
description: "Test connection to backend",
|
||||||
|
|
@ -187,14 +170,11 @@ export const testConnectionDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
export const mountVolumeResponse = z.object({
|
||||||
* Mount volume
|
error: z.string().optional(),
|
||||||
*/
|
status: z.enum(BACKEND_STATUS),
|
||||||
export const mountVolumeResponse = type({
|
|
||||||
error: "string?",
|
|
||||||
status: type.valueOf(BACKEND_STATUS),
|
|
||||||
});
|
});
|
||||||
export type MountVolumeDto = typeof mountVolumeResponse.infer;
|
export type MountVolumeDto = z.infer<typeof mountVolumeResponse>;
|
||||||
|
|
||||||
export const mountVolumeDto = describeRoute({
|
export const mountVolumeDto = describeRoute({
|
||||||
description: "Mount a volume",
|
description: "Mount a volume",
|
||||||
|
|
@ -212,14 +192,11 @@ export const mountVolumeDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
export const unmountVolumeResponse = z.object({
|
||||||
* Unmount volume
|
error: z.string().optional(),
|
||||||
*/
|
status: z.enum(BACKEND_STATUS),
|
||||||
export const unmountVolumeResponse = type({
|
|
||||||
error: "string?",
|
|
||||||
status: type.valueOf(BACKEND_STATUS),
|
|
||||||
});
|
});
|
||||||
export type UnmountVolumeDto = typeof unmountVolumeResponse.infer;
|
export type UnmountVolumeDto = z.infer<typeof unmountVolumeResponse>;
|
||||||
|
|
||||||
export const unmountVolumeDto = describeRoute({
|
export const unmountVolumeDto = describeRoute({
|
||||||
description: "Unmount a volume",
|
description: "Unmount a volume",
|
||||||
|
|
@ -237,11 +214,11 @@ export const unmountVolumeDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const healthCheckResponse = type({
|
export const healthCheckResponse = z.object({
|
||||||
error: "string?",
|
error: z.string().optional(),
|
||||||
status: type.valueOf(BACKEND_STATUS),
|
status: z.enum(BACKEND_STATUS),
|
||||||
});
|
});
|
||||||
export type HealthCheckDto = typeof healthCheckResponse.infer;
|
export type HealthCheckDto = z.infer<typeof healthCheckResponse>;
|
||||||
|
|
||||||
export const healthCheckDto = describeRoute({
|
export const healthCheckDto = describeRoute({
|
||||||
description: "Perform a health check on a volume",
|
description: "Perform a health check on a volume",
|
||||||
|
|
@ -262,31 +239,28 @@ export const healthCheckDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
const fileEntrySchema = z.object({
|
||||||
* List files in a volume
|
name: z.string(),
|
||||||
*/
|
path: z.string(),
|
||||||
const fileEntrySchema = type({
|
type: z.enum(["file", "directory"]),
|
||||||
name: "string",
|
size: z.number().optional(),
|
||||||
path: "string",
|
modifiedAt: z.number().optional(),
|
||||||
type: type.enumerated("file", "directory"),
|
|
||||||
size: "number?",
|
|
||||||
modifiedAt: "number?",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const listFilesResponse = type({
|
export const listFilesResponse = z.object({
|
||||||
files: fileEntrySchema.array(),
|
files: fileEntrySchema.array(),
|
||||||
path: "string",
|
path: z.string(),
|
||||||
offset: "number",
|
offset: z.number(),
|
||||||
limit: "number",
|
limit: z.number(),
|
||||||
total: "number",
|
total: z.number(),
|
||||||
hasMore: "boolean",
|
hasMore: z.boolean(),
|
||||||
});
|
});
|
||||||
export type ListFilesDto = typeof listFilesResponse.infer;
|
export type ListFilesDto = z.infer<typeof listFilesResponse>;
|
||||||
|
|
||||||
export const listFilesQuery = type({
|
export const listFilesQuery = z.object({
|
||||||
path: "string?",
|
path: z.string().optional(),
|
||||||
offset: "string.integer?",
|
offset: z.coerce.number().int().optional(),
|
||||||
limit: "string.integer?",
|
limit: z.coerce.number().int().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const listFilesDto = describeRoute({
|
export const listFilesDto = describeRoute({
|
||||||
|
|
@ -305,14 +279,11 @@ export const listFilesDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
export const browseFilesystemResponse = z.object({
|
||||||
* Browse filesystem directories
|
|
||||||
*/
|
|
||||||
export const browseFilesystemResponse = type({
|
|
||||||
directories: fileEntrySchema.array(),
|
directories: fileEntrySchema.array(),
|
||||||
path: "string",
|
path: z.string(),
|
||||||
});
|
});
|
||||||
export type BrowseFilesystemDto = typeof browseFilesystemResponse.infer;
|
export type BrowseFilesystemDto = z.infer<typeof browseFilesystemResponse>;
|
||||||
|
|
||||||
export const browseFilesystemDto = describeRoute({
|
export const browseFilesystemDto = describeRoute({
|
||||||
description: "Browse directories on the host filesystem",
|
description: "Browse directories on the host filesystem",
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ import { getVolumePath } from "./helpers";
|
||||||
import { logger } from "../../utils/logger";
|
import { logger } from "../../utils/logger";
|
||||||
import { serverEvents } from "../../core/events";
|
import { serverEvents } from "../../core/events";
|
||||||
import { volumeConfigSchema, type BackendConfig } from "~/schemas/volumes";
|
import { volumeConfigSchema, type BackendConfig } from "~/schemas/volumes";
|
||||||
import { type } from "arktype";
|
|
||||||
import { getOrganizationId } from "~/server/core/request-context";
|
import { getOrganizationId } from "~/server/core/request-context";
|
||||||
import { isNodeJSErrnoException } from "~/server/utils/fs";
|
import { isNodeJSErrnoException } from "~/server/utils/fs";
|
||||||
import { asShortId, type ShortId } from "~/server/utils/branded";
|
import { asShortId, type ShortId } from "~/server/utils/branded";
|
||||||
|
|
@ -201,10 +200,11 @@ const updateVolume = async (shortId: ShortId, volumeData: UpdateVolumeBody) => {
|
||||||
await backend.unmount();
|
await backend.unmount();
|
||||||
}
|
}
|
||||||
|
|
||||||
const newConfig = volumeConfigSchema(volumeData.config || existing.config);
|
const newConfigResult = volumeConfigSchema.safeParse(volumeData.config || existing.config);
|
||||||
if (newConfig instanceof type.errors) {
|
if (!newConfigResult.success) {
|
||||||
throw new BadRequestError("Invalid volume configuration");
|
throw new BadRequestError("Invalid volume configuration");
|
||||||
}
|
}
|
||||||
|
const newConfig = newConfigResult.data;
|
||||||
|
|
||||||
const encryptedConfig = await encryptSensitiveFields(newConfig);
|
const encryptedConfig = await encryptSensitiveFields(newConfig);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { type } from "arktype";
|
|
||||||
import { throttle } from "es-toolkit";
|
import { throttle } from "es-toolkit";
|
||||||
import type { CompressionMode, RepositoryConfig } from "~/schemas/restic";
|
import type { CompressionMode, RepositoryConfig } from "~/schemas/restic";
|
||||||
import {
|
import {
|
||||||
|
|
@ -108,11 +107,11 @@ export const backup = async (
|
||||||
if (options.onProgress) {
|
if (options.onProgress) {
|
||||||
try {
|
try {
|
||||||
const jsonData = JSON.parse(data);
|
const jsonData = JSON.parse(data);
|
||||||
const progress = resticBackupProgressSchema(jsonData);
|
const progressResult = resticBackupProgressSchema.safeParse(jsonData);
|
||||||
if (!(progress instanceof type.errors)) {
|
if (progressResult.success) {
|
||||||
options.onProgress(progress);
|
options.onProgress(progressResult.data);
|
||||||
} else {
|
} else {
|
||||||
logger.error(progress.summary);
|
logger.error(progressResult.error.message);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore JSON parse errors for non-JSON lines
|
// Ignore JSON parse errors for non-JSON lines
|
||||||
|
|
@ -168,12 +167,12 @@ export const backup = async (
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(`Restic backup output last line: ${JSON.stringify(summaryLine)}`);
|
logger.debug(`Restic backup output last line: ${JSON.stringify(summaryLine)}`);
|
||||||
const result = resticBackupOutputSchema(summaryLine);
|
const result = resticBackupOutputSchema.safeParse(summaryLine);
|
||||||
|
|
||||||
if (result instanceof type.errors) {
|
if (!result.success) {
|
||||||
logger.error(`Restic backup output validation failed: ${result.summary}`);
|
logger.error(`Restic backup output validation failed: ${result.error.message}`);
|
||||||
return { result: null, exitCode: res.exitCode };
|
return { result: null, exitCode: res.exitCode };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { result, exitCode: res.exitCode };
|
return { result: result.data, exitCode: res.exitCode };
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { type } from "arktype";
|
import { z } from "zod";
|
||||||
import type { RepositoryConfig } from "~/schemas/restic";
|
import type { RepositoryConfig } from "~/schemas/restic";
|
||||||
import { ResticError } from "~/server/utils/errors";
|
import { ResticError } from "~/server/utils/errors";
|
||||||
import { logger } from "~/server/utils/logger";
|
import { logger } from "~/server/utils/logger";
|
||||||
|
|
@ -8,40 +8,54 @@ import { buildEnv } from "../helpers/build-env";
|
||||||
import { buildRepoUrl } from "../helpers/build-repo-url";
|
import { buildRepoUrl } from "../helpers/build-repo-url";
|
||||||
import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys";
|
import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys";
|
||||||
|
|
||||||
const lsNodeSchema = type({
|
const lsNodeSchema = z.object({
|
||||||
name: "string",
|
name: z.string(),
|
||||||
type: "string",
|
type: z.string(),
|
||||||
path: "string",
|
path: z.string(),
|
||||||
uid: "number?",
|
uid: z.number().optional(),
|
||||||
gid: "number?",
|
gid: z.number().optional(),
|
||||||
size: "number?",
|
size: z.number().optional(),
|
||||||
mode: "number?",
|
mode: z.number().optional(),
|
||||||
mtime: "string?",
|
mtime: z.string().optional(),
|
||||||
atime: "string?",
|
atime: z.string().optional(),
|
||||||
ctime: "string?",
|
ctime: z.string().optional(),
|
||||||
struct_type: "'node'",
|
struct_type: z.literal("node"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const lsSnapshotInfoSchema = type({
|
const lsSnapshotInfoSchema = z.object({
|
||||||
time: "string",
|
time: z.string(),
|
||||||
parent: "string?",
|
parent: z.string().optional(),
|
||||||
tree: "string",
|
tree: z.string(),
|
||||||
paths: "string[]",
|
paths: z.array(z.string()),
|
||||||
hostname: "string",
|
hostname: z.string(),
|
||||||
username: "string?",
|
username: z.string().optional(),
|
||||||
id: "string",
|
id: z.string(),
|
||||||
short_id: "string",
|
short_id: z.string(),
|
||||||
struct_type: "'snapshot'",
|
struct_type: z.literal("snapshot"),
|
||||||
message_type: "'snapshot'",
|
message_type: z.literal("snapshot"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type LsNode = z.infer<typeof lsNodeSchema>;
|
||||||
|
type LsSnapshotInfo = z.infer<typeof lsSnapshotInfoSchema>;
|
||||||
|
|
||||||
|
export type ResticLsResult = {
|
||||||
|
snapshot: LsSnapshotInfo | null;
|
||||||
|
nodes: LsNode[];
|
||||||
|
pagination: {
|
||||||
|
offset: number;
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const ls = async (
|
export const ls = async (
|
||||||
config: RepositoryConfig,
|
config: RepositoryConfig,
|
||||||
snapshotId: string,
|
snapshotId: string,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
path?: string,
|
path?: string,
|
||||||
options?: { offset?: number; limit?: number },
|
options?: { offset?: number; limit?: number },
|
||||||
) => {
|
): Promise<ResticLsResult> => {
|
||||||
const repoUrl = buildRepoUrl(config);
|
const repoUrl = buildRepoUrl(config);
|
||||||
const env = await buildEnv(config, organizationId);
|
const env = await buildEnv(config, organizationId);
|
||||||
|
|
||||||
|
|
@ -53,8 +67,8 @@ export const ls = async (
|
||||||
|
|
||||||
addCommonArgs(args, env, config);
|
addCommonArgs(args, env, config);
|
||||||
|
|
||||||
let snapshot: typeof lsSnapshotInfoSchema.infer | null = null;
|
let snapshot: LsSnapshotInfo | null = null;
|
||||||
const nodes: Array<typeof lsNodeSchema.infer> = [];
|
const nodes: LsNode[] = [];
|
||||||
let totalNodes = 0;
|
let totalNodes = 0;
|
||||||
let isFirstLine = true;
|
let isFirstLine = true;
|
||||||
let hasMore = false;
|
let hasMore = false;
|
||||||
|
|
@ -77,21 +91,21 @@ export const ls = async (
|
||||||
|
|
||||||
if (isFirstLine) {
|
if (isFirstLine) {
|
||||||
isFirstLine = false;
|
isFirstLine = false;
|
||||||
const snapshotValidation = lsSnapshotInfoSchema(data);
|
const snapshotValidation = lsSnapshotInfoSchema.safeParse(data);
|
||||||
if (!(snapshotValidation instanceof type.errors)) {
|
if (snapshotValidation.success) {
|
||||||
snapshot = snapshotValidation;
|
snapshot = snapshotValidation.data;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodeValidation = lsNodeSchema(data);
|
const nodeValidation = lsNodeSchema.safeParse(data);
|
||||||
if (nodeValidation instanceof type.errors) {
|
if (!nodeValidation.success) {
|
||||||
logger.warn(`Skipping invalid node: ${nodeValidation.summary}`);
|
logger.warn(`Skipping invalid node: ${nodeValidation.error.message}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (totalNodes >= offset && totalNodes < offset + limit) {
|
if (totalNodes >= offset && totalNodes < offset + limit) {
|
||||||
nodes.push(nodeValidation);
|
nodes.push(nodeValidation.data);
|
||||||
}
|
}
|
||||||
totalNodes++;
|
totalNodes++;
|
||||||
|
|
||||||
|
|
@ -116,7 +130,7 @@ export const ls = async (
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
snapshot: snapshot as typeof lsSnapshotInfoSchema.infer | null,
|
snapshot,
|
||||||
nodes,
|
nodes,
|
||||||
pagination: {
|
pagination: {
|
||||||
offset,
|
offset,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { type } from "arktype";
|
import { z } from "zod";
|
||||||
import { throttle } from "es-toolkit";
|
import { throttle } from "es-toolkit";
|
||||||
import type { OverwriteMode, RepositoryConfig } from "~/schemas/restic";
|
import type { OverwriteMode, RepositoryConfig } from "~/schemas/restic";
|
||||||
import type { ResticRestoreOutputDto } from "~/schemas/restic-dto";
|
import type { ResticRestoreOutputDto } from "~/schemas/restic-dto";
|
||||||
|
|
@ -13,17 +13,17 @@ import { buildEnv } from "../helpers/build-env";
|
||||||
import { buildRepoUrl } from "../helpers/build-repo-url";
|
import { buildRepoUrl } from "../helpers/build-repo-url";
|
||||||
import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys";
|
import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys";
|
||||||
|
|
||||||
const restoreProgressSchema = type({
|
const restoreProgressSchema = z.object({
|
||||||
message_type: "'status' | 'summary'",
|
message_type: z.enum(["status", "summary"]),
|
||||||
seconds_elapsed: "number",
|
seconds_elapsed: z.number(),
|
||||||
percent_done: "number = 0",
|
percent_done: z.number().default(0),
|
||||||
total_files: "number",
|
total_files: z.number(),
|
||||||
files_restored: "number = 0",
|
files_restored: z.number().default(0),
|
||||||
total_bytes: "number = 0",
|
total_bytes: z.number().default(0),
|
||||||
bytes_restored: "number = 0",
|
bytes_restored: z.number().default(0),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type RestoreProgress = typeof restoreProgressSchema.infer;
|
export type RestoreProgress = z.infer<typeof restoreProgressSchema>;
|
||||||
|
|
||||||
export const restore = async (
|
export const restore = async (
|
||||||
config: RepositoryConfig,
|
config: RepositoryConfig,
|
||||||
|
|
@ -100,11 +100,11 @@ export const restore = async (
|
||||||
if (options.onProgress) {
|
if (options.onProgress) {
|
||||||
try {
|
try {
|
||||||
const jsonData = JSON.parse(data);
|
const jsonData = JSON.parse(data);
|
||||||
const progress = restoreProgressSchema(jsonData);
|
const progress = restoreProgressSchema.safeParse(jsonData);
|
||||||
if (!(progress instanceof type.errors)) {
|
if (progress.success) {
|
||||||
options.onProgress(progress);
|
options.onProgress(progress.data);
|
||||||
} else {
|
} else {
|
||||||
logger.error(progress.summary);
|
logger.error(progress.error.message);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore JSON parse errors for non-JSON lines
|
// Ignore JSON parse errors for non-JSON lines
|
||||||
|
|
@ -142,10 +142,10 @@ export const restore = async (
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(`Restic restore output last line: ${JSON.stringify(summaryLine)}`);
|
logger.debug(`Restic restore output last line: ${JSON.stringify(summaryLine)}`);
|
||||||
const result = resticRestoreOutputSchema(summaryLine);
|
const result = resticRestoreOutputSchema.safeParse(summaryLine);
|
||||||
|
|
||||||
if (result instanceof type.errors) {
|
if (!result.success) {
|
||||||
logger.warn(`Restic restore output validation failed: ${result.summary}`);
|
logger.warn(`Restic restore output validation failed: ${result.error.message}`);
|
||||||
logger.info(`Restic restore completed for snapshot ${snapshotId} to target ${target}`);
|
logger.info(`Restic restore completed for snapshot ${snapshotId} to target ${target}`);
|
||||||
const fallback: ResticRestoreOutputDto = {
|
const fallback: ResticRestoreOutputDto = {
|
||||||
message_type: "summary" as const,
|
message_type: "summary" as const,
|
||||||
|
|
@ -159,8 +159,8 @@ export const restore = async (
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`Restic restore completed for snapshot ${snapshotId} to target ${target}: ${result.files_restored} restored, ${result.files_skipped} skipped`,
|
`Restic restore completed for snapshot ${snapshotId} to target ${target}: ${result.data.files_restored} restored, ${result.data.files_skipped} skipped`,
|
||||||
);
|
);
|
||||||
|
|
||||||
return result;
|
return result.data;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { type } from "arktype";
|
import { z } from "zod";
|
||||||
import type { RepositoryConfig } from "~/schemas/restic";
|
import type { RepositoryConfig } from "~/schemas/restic";
|
||||||
import { resticSnapshotSummarySchema } from "~/schemas/restic-dto";
|
import { resticSnapshotSummarySchema } from "~/schemas/restic-dto";
|
||||||
import { logger } from "~/server/utils/logger";
|
import { logger } from "~/server/utils/logger";
|
||||||
|
|
@ -8,18 +8,18 @@ import { buildEnv } from "../helpers/build-env";
|
||||||
import { buildRepoUrl } from "../helpers/build-repo-url";
|
import { buildRepoUrl } from "../helpers/build-repo-url";
|
||||||
import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys";
|
import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys";
|
||||||
|
|
||||||
const snapshotInfoSchema = type({
|
const snapshotInfoSchema = z.object({
|
||||||
gid: "number?",
|
gid: z.number().optional(),
|
||||||
hostname: "string",
|
hostname: z.string(),
|
||||||
id: "string",
|
id: z.string(),
|
||||||
parent: "string?",
|
parent: z.string().optional(),
|
||||||
paths: "string[]",
|
paths: z.array(z.string()),
|
||||||
program_version: "string?",
|
program_version: z.string().optional(),
|
||||||
short_id: "string",
|
short_id: z.string(),
|
||||||
time: "string",
|
time: z.string(),
|
||||||
uid: "number?",
|
uid: z.number().optional(),
|
||||||
username: "string?",
|
username: z.string().optional(),
|
||||||
tags: "string[]?",
|
tags: z.array(z.string()).optional(),
|
||||||
summary: resticSnapshotSummarySchema.optional(),
|
summary: resticSnapshotSummarySchema.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -47,12 +47,12 @@ export const snapshots = async (config: RepositoryConfig, options: { tags?: stri
|
||||||
throw new Error(`Restic snapshots retrieval failed: ${res.stderr}`);
|
throw new Error(`Restic snapshots retrieval failed: ${res.stderr}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = snapshotInfoSchema.array()(JSON.parse(res.stdout));
|
const result = snapshotInfoSchema.array().safeParse(JSON.parse(res.stdout));
|
||||||
|
|
||||||
if (result instanceof type.errors) {
|
if (!result.success) {
|
||||||
logger.error(`Restic snapshots output validation failed: ${result.summary}`);
|
logger.error(`Restic snapshots output validation failed: ${result.error.message}`);
|
||||||
throw new Error(`Restic snapshots output validation failed: ${result.summary}`);
|
throw new Error(`Restic snapshots output validation failed: ${result.error.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result.data;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { type } from "arktype";
|
|
||||||
import type { RepositoryConfig } from "~/schemas/restic";
|
import type { RepositoryConfig } from "~/schemas/restic";
|
||||||
import { resticStatsSchema } from "~/schemas/restic-dto";
|
import { resticStatsSchema } from "~/schemas/restic-dto";
|
||||||
import { safeJsonParse } from "~/server/utils/json";
|
import { safeJsonParse } from "~/server/utils/json";
|
||||||
|
|
@ -26,12 +25,12 @@ export const stats = async (config: RepositoryConfig, options: { organizationId:
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedJson = safeJsonParse<unknown>(res.stdout);
|
const parsedJson = safeJsonParse<unknown>(res.stdout);
|
||||||
const result = resticStatsSchema(parsedJson);
|
const result = resticStatsSchema.safeParse(parsedJson);
|
||||||
|
|
||||||
if (result instanceof type.errors) {
|
if (!result.success) {
|
||||||
logger.error(`Restic stats output validation failed: ${result.summary}`);
|
logger.error(`Restic stats output validation failed: ${result.error.message}`);
|
||||||
throw new Error(`Restic stats output validation failed: ${result.summary}`);
|
throw new Error(`Restic stats output validation failed: ${result.error.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result.data;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
2
bun.lock
2
bun.lock
|
|
@ -34,7 +34,6 @@
|
||||||
"@tanstack/react-router": "^1.166.2",
|
"@tanstack/react-router": "^1.166.2",
|
||||||
"@tanstack/react-router-ssr-query": "^1.166.2",
|
"@tanstack/react-router-ssr-query": "^1.166.2",
|
||||||
"@tanstack/react-start": "^1.166.2",
|
"@tanstack/react-start": "^1.166.2",
|
||||||
"arktype": "^2.2.0",
|
|
||||||
"better-auth": "^1.5.4",
|
"better-auth": "^1.5.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|
@ -69,6 +68,7 @@
|
||||||
"tiny-typed-emitter": "^2.1.0",
|
"tiny-typed-emitter": "^2.1.0",
|
||||||
"web-haptics": "^0.0.6",
|
"web-haptics": "^0.0.6",
|
||||||
"yaml": "^2.8.2",
|
"yaml": "^2.8.2",
|
||||||
|
"zod": "^4.0.1",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/preset-typescript": "^7.28.5",
|
"@babel/preset-typescript": "^7.28.5",
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,6 @@
|
||||||
"@tanstack/react-router": "^1.166.2",
|
"@tanstack/react-router": "^1.166.2",
|
||||||
"@tanstack/react-router-ssr-query": "^1.166.2",
|
"@tanstack/react-router-ssr-query": "^1.166.2",
|
||||||
"@tanstack/react-start": "^1.166.2",
|
"@tanstack/react-start": "^1.166.2",
|
||||||
"arktype": "^2.2.0",
|
|
||||||
"better-auth": "^1.5.4",
|
"better-auth": "^1.5.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|
@ -89,7 +88,8 @@
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tiny-typed-emitter": "^2.1.0",
|
"tiny-typed-emitter": "^2.1.0",
|
||||||
"web-haptics": "^0.0.6",
|
"web-haptics": "^0.0.6",
|
||||||
"yaml": "^2.8.2"
|
"yaml": "^2.8.2",
|
||||||
|
"zod": "^4.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/preset-typescript": "^7.28.5",
|
"@babel/preset-typescript": "^7.28.5",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue