refactor: simplify dtos and improve type saftey in json returns

This commit is contained in:
Nicolas Meienberger
2025-10-29 18:28:00 +01:00
parent d1c1adaba7
commit b188a84af3
26 changed files with 667 additions and 751 deletions

View File

@@ -12,7 +12,6 @@ import type {
LogoutResponses, LogoutResponses,
GetMeData, GetMeData,
GetMeResponses, GetMeResponses,
GetMeErrors,
GetStatusData, GetStatusData,
GetStatusResponses, GetStatusResponses,
ListVolumesData, ListVolumesData,
@@ -34,16 +33,13 @@ import type {
GetContainersUsingVolumeErrors, GetContainersUsingVolumeErrors,
MountVolumeData, MountVolumeData,
MountVolumeResponses, MountVolumeResponses,
MountVolumeErrors,
UnmountVolumeData, UnmountVolumeData,
UnmountVolumeResponses, UnmountVolumeResponses,
UnmountVolumeErrors,
HealthCheckVolumeData, HealthCheckVolumeData,
HealthCheckVolumeResponses, HealthCheckVolumeResponses,
HealthCheckVolumeErrors, HealthCheckVolumeErrors,
ListFilesData, ListFilesData,
ListFilesResponses, ListFilesResponses,
ListFilesErrors,
ListRepositoriesData, ListRepositoriesData,
ListRepositoriesResponses, ListRepositoriesResponses,
CreateRepositoryData, CreateRepositoryData,
@@ -130,7 +126,7 @@ export const logout = <ThrowOnError extends boolean = false>(options?: Options<L
* Get current authenticated user * Get current authenticated user
*/ */
export const getMe = <ThrowOnError extends boolean = false>(options?: Options<GetMeData, ThrowOnError>) => { export const getMe = <ThrowOnError extends boolean = false>(options?: Options<GetMeData, ThrowOnError>) => {
return (options?.client ?? _heyApiClient).get<GetMeResponses, GetMeErrors, ThrowOnError>({ return (options?.client ?? _heyApiClient).get<GetMeResponses, unknown, ThrowOnError>({
url: "/api/v1/auth/me", url: "/api/v1/auth/me",
...options, ...options,
}); });
@@ -246,7 +242,7 @@ export const getContainersUsingVolume = <ThrowOnError extends boolean = false>(
* Mount a volume * Mount a volume
*/ */
export const mountVolume = <ThrowOnError extends boolean = false>(options: Options<MountVolumeData, ThrowOnError>) => { export const mountVolume = <ThrowOnError extends boolean = false>(options: Options<MountVolumeData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<MountVolumeResponses, MountVolumeErrors, ThrowOnError>({ return (options.client ?? _heyApiClient).post<MountVolumeResponses, unknown, ThrowOnError>({
url: "/api/v1/volumes/{name}/mount", url: "/api/v1/volumes/{name}/mount",
...options, ...options,
}); });
@@ -258,7 +254,7 @@ export const mountVolume = <ThrowOnError extends boolean = false>(options: Optio
export const unmountVolume = <ThrowOnError extends boolean = false>( export const unmountVolume = <ThrowOnError extends boolean = false>(
options: Options<UnmountVolumeData, ThrowOnError>, options: Options<UnmountVolumeData, ThrowOnError>,
) => { ) => {
return (options.client ?? _heyApiClient).post<UnmountVolumeResponses, UnmountVolumeErrors, ThrowOnError>({ return (options.client ?? _heyApiClient).post<UnmountVolumeResponses, unknown, ThrowOnError>({
url: "/api/v1/volumes/{name}/unmount", url: "/api/v1/volumes/{name}/unmount",
...options, ...options,
}); });
@@ -280,7 +276,7 @@ export const healthCheckVolume = <ThrowOnError extends boolean = false>(
* List files in a volume directory * List files in a volume directory
*/ */
export const listFiles = <ThrowOnError extends boolean = false>(options: Options<ListFilesData, ThrowOnError>) => { export const listFiles = <ThrowOnError extends boolean = false>(options: Options<ListFilesData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).get<ListFilesResponses, ListFilesErrors, ThrowOnError>({ return (options.client ?? _heyApiClient).get<ListFilesResponses, unknown, ThrowOnError>({
url: "/api/v1/volumes/{name}/files", url: "/api/v1/volumes/{name}/files",
...options, ...options,
}); });

View File

@@ -23,8 +23,9 @@ export type RegisterResponses = {
*/ */
201: { 201: {
message: string; message: string;
user: { success: boolean;
id: string; user?: {
id: number;
username: string; username: string;
}; };
}; };
@@ -55,8 +56,9 @@ export type LoginResponses = {
*/ */
200: { 200: {
message: string; message: string;
user: { success: boolean;
id: string; user?: {
id: number;
username: string; username: string;
}; };
}; };
@@ -76,7 +78,7 @@ export type LogoutResponses = {
* Logout successful * Logout successful
*/ */
200: { 200: {
message: string; success: boolean;
}; };
}; };
@@ -89,21 +91,15 @@ export type GetMeData = {
url: "/api/v1/auth/me"; url: "/api/v1/auth/me";
}; };
export type GetMeErrors = {
/**
* Not authenticated
*/
401: unknown;
};
export type GetMeResponses = { export type GetMeResponses = {
/** /**
* Current user information * Current user information
*/ */
200: { 200: {
message: string; message: string;
user: { success: boolean;
id: string; user?: {
id: number;
username: string; username: string;
}; };
}; };
@@ -232,11 +228,46 @@ export type CreateVolumeResponses = {
* Volume created successfully * Volume created successfully
*/ */
201: { 201: {
message: string; autoRemount: boolean;
volume: { config:
name: string; | {
path: string; backend: "directory";
}; }
| {
backend: "nfs";
exportPath: string;
server: string;
version: "3" | "4" | "4.1";
port?: number;
}
| {
backend: "smb";
password: string;
server: string;
share: string;
username: string;
vers?: "1.0" | "2.0" | "2.1" | "3.0";
port?: number;
domain?: string;
}
| {
backend: "webdav";
path: string;
server: string;
port?: number;
password?: string;
ssl?: boolean;
username?: string;
};
createdAt: number;
id: number;
lastError: string | null;
lastHealthCheck: number;
name: string;
path: string;
status: "error" | "mounted" | "unmounted";
type: "directory" | "nfs" | "smb" | "webdav";
updatedAt: number;
}; };
}; };
@@ -438,49 +469,46 @@ export type UpdateVolumeResponses = {
* Volume updated successfully * Volume updated successfully
*/ */
200: { 200: {
message: string; autoRemount: boolean;
volume: { config:
autoRemount: boolean; | {
config: backend: "directory";
| { }
backend: "directory"; | {
} backend: "nfs";
| { exportPath: string;
backend: "nfs"; server: string;
exportPath: string; version: "3" | "4" | "4.1";
server: string; port?: number;
version: "3" | "4" | "4.1"; }
port?: number; | {
} backend: "smb";
| { password: string;
backend: "smb"; server: string;
password: string; share: string;
server: string; username: string;
share: string; vers?: "1.0" | "2.0" | "2.1" | "3.0";
username: string; port?: number;
vers?: "1.0" | "2.0" | "2.1" | "3.0"; domain?: string;
port?: number; }
domain?: string; | {
} backend: "webdav";
| { path: string;
backend: "webdav"; server: string;
path: string; port?: number;
server: string; password?: string;
port?: number; ssl?: boolean;
password?: string; username?: string;
ssl?: boolean; };
username?: string; createdAt: number;
}; id: number;
createdAt: number; lastError: string | null;
id: number; lastHealthCheck: number;
lastError: string | null; name: string;
lastHealthCheck: number; path: string;
name: string; status: "error" | "mounted" | "unmounted";
path: string; type: "directory" | "nfs" | "smb" | "webdav";
status: "error" | "mounted" | "unmounted"; updatedAt: number;
type: "directory" | "nfs" | "smb" | "webdav";
updatedAt: number;
};
}; };
}; };
@@ -506,14 +534,12 @@ export type GetContainersUsingVolumeResponses = {
/** /**
* List of containers using the volume * List of containers using the volume
*/ */
200: { 200: Array<{
containers: Array<{ id: string;
id: string; image: string;
image: string; name: string;
name: string; state: string;
state: string; }>;
}>;
};
}; };
export type GetContainersUsingVolumeResponse = export type GetContainersUsingVolumeResponse =
@@ -528,13 +554,6 @@ export type MountVolumeData = {
url: "/api/v1/volumes/{name}/mount"; url: "/api/v1/volumes/{name}/mount";
}; };
export type MountVolumeErrors = {
/**
* Volume not found
*/
404: unknown;
};
export type MountVolumeResponses = { export type MountVolumeResponses = {
/** /**
* Volume mounted successfully * Volume mounted successfully
@@ -556,13 +575,6 @@ export type UnmountVolumeData = {
url: "/api/v1/volumes/{name}/unmount"; url: "/api/v1/volumes/{name}/unmount";
}; };
export type UnmountVolumeErrors = {
/**
* Volume not found
*/
404: unknown;
};
export type UnmountVolumeResponses = { export type UnmountVolumeResponses = {
/** /**
* Volume unmounted successfully * Volume unmounted successfully
@@ -617,13 +629,6 @@ export type ListFilesData = {
url: "/api/v1/volumes/{name}/files"; url: "/api/v1/volumes/{name}/files";
}; };
export type ListFilesErrors = {
/**
* Volume not found
*/
404: unknown;
};
export type ListFilesResponses = { export type ListFilesResponses = {
/** /**
* List of files in the volume * List of files in the volume
@@ -653,31 +658,29 @@ export type ListRepositoriesResponses = {
/** /**
* List of repositories * List of repositories
*/ */
200: { 200: Array<{
repositories: Array<{ compressionMode: "auto" | "better" | "fastest" | "max" | "off" | null;
compressionMode: "auto" | "better" | "fastest" | "max" | "off" | null; config:
config: | {
| { accessKeyId: string;
accessKeyId: string; backend: "s3";
backend: "s3"; bucket: string;
bucket: string; endpoint: string;
endpoint: string; secretAccessKey: string;
secretAccessKey: string; }
} | {
| { backend: "local";
backend: "local"; path: string;
path: string; };
}; createdAt: number;
createdAt: number; id: string;
id: string; lastChecked: number | null;
lastChecked: number | null; lastError: string | null;
lastError: string | null; name: string;
name: string; status: "error" | "healthy" | "unknown" | null;
status: "error" | "healthy" | "unknown" | null; type: "local" | "s3";
type: "local" | "s3"; updatedAt: number;
updatedAt: number; }>;
}>;
};
}; };
export type ListRepositoriesResponse = ListRepositoriesResponses[keyof ListRepositoriesResponses]; export type ListRepositoriesResponse = ListRepositoriesResponses[keyof ListRepositoriesResponses];
@@ -753,29 +756,27 @@ export type GetRepositoryResponses = {
* Repository details * Repository details
*/ */
200: { 200: {
repository: { compressionMode: "auto" | "better" | "fastest" | "max" | "off" | null;
compressionMode: "auto" | "better" | "fastest" | "max" | "off" | null; config:
config: | {
| { accessKeyId: string;
accessKeyId: string; backend: "s3";
backend: "s3"; bucket: string;
bucket: string; endpoint: string;
endpoint: string; secretAccessKey: string;
secretAccessKey: string; }
} | {
| { backend: "local";
backend: "local"; path: string;
path: string; };
}; createdAt: number;
createdAt: number; id: string;
id: string; lastChecked: number | null;
lastChecked: number | null; lastError: string | null;
lastError: string | null; name: string;
name: string; status: "error" | "healthy" | "unknown" | null;
status: "error" | "healthy" | "unknown" | null; type: "local" | "s3";
type: "local" | "s3"; updatedAt: number;
updatedAt: number;
};
}; };
}; };
@@ -818,32 +819,30 @@ export type ListBackupSchedulesResponses = {
/** /**
* List of backup schedules * List of backup schedules
*/ */
200: { 200: Array<{
schedules: Array<{ createdAt: number;
createdAt: number; cronExpression: string;
cronExpression: string; enabled: boolean;
enabled: boolean; excludePatterns: Array<string> | null;
excludePatterns: Array<string> | null; id: number;
id: number; includePatterns: Array<string> | null;
includePatterns: Array<string> | null; lastBackupAt: number | null;
lastBackupAt: number | null; lastBackupError: string | null;
lastBackupError: string | null; lastBackupStatus: "error" | "success" | null;
lastBackupStatus: "error" | "success" | null; nextBackupAt: number | null;
nextBackupAt: number | null; repositoryId: string;
repositoryId: string; retentionPolicy: {
retentionPolicy: { keepDaily?: number;
keepDaily?: number; keepHourly?: number;
keepHourly?: number; keepLast?: number;
keepLast?: number; keepMonthly?: number;
keepMonthly?: number; keepWeekly?: number;
keepWeekly?: number; keepWithinDuration?: string;
keepWithinDuration?: string; keepYearly?: number;
keepYearly?: number; } | null;
} | null; updatedAt: number;
updatedAt: number; volumeId: number;
volumeId: number; }>;
}>;
};
}; };
export type ListBackupSchedulesResponse = ListBackupSchedulesResponses[keyof ListBackupSchedulesResponses]; export type ListBackupSchedulesResponse = ListBackupSchedulesResponses[keyof ListBackupSchedulesResponses];
@@ -877,31 +876,28 @@ export type CreateBackupScheduleResponses = {
* Backup schedule created successfully * Backup schedule created successfully
*/ */
201: { 201: {
message: string; createdAt: number;
schedule: { cronExpression: string;
createdAt: number; enabled: boolean;
cronExpression: string; excludePatterns: Array<string> | null;
enabled: boolean; id: number;
excludePatterns: Array<string> | null; includePatterns: Array<string> | null;
id: number; lastBackupAt: number | null;
includePatterns: Array<string> | null; lastBackupError: string | null;
lastBackupAt: number | null; lastBackupStatus: "error" | "success" | null;
lastBackupError: string | null; nextBackupAt: number | null;
lastBackupStatus: "error" | "success" | null; repositoryId: string;
nextBackupAt: number | null; retentionPolicy: {
repositoryId: string; keepDaily?: number;
retentionPolicy: { keepHourly?: number;
keepDaily?: number; keepLast?: number;
keepHourly?: number; keepMonthly?: number;
keepLast?: number; keepWeekly?: number;
keepMonthly?: number; keepWithinDuration?: string;
keepWeekly?: number; keepYearly?: number;
keepWithinDuration?: string; } | null;
keepYearly?: number; updatedAt: number;
} | null; volumeId: number;
updatedAt: number;
volumeId: number;
};
}; };
}; };
@@ -921,7 +917,7 @@ export type DeleteBackupScheduleResponses = {
* Backup schedule deleted successfully * Backup schedule deleted successfully
*/ */
200: { 200: {
message: string; success: boolean;
}; };
}; };
@@ -941,30 +937,28 @@ export type GetBackupScheduleResponses = {
* Backup schedule details * Backup schedule details
*/ */
200: { 200: {
schedule: { createdAt: number;
createdAt: number; cronExpression: string;
cronExpression: string; enabled: boolean;
enabled: boolean; excludePatterns: Array<string> | null;
excludePatterns: Array<string> | null; id: number;
id: number; includePatterns: Array<string> | null;
includePatterns: Array<string> | null; lastBackupAt: number | null;
lastBackupAt: number | null; lastBackupError: string | null;
lastBackupError: string | null; lastBackupStatus: "error" | "success" | null;
lastBackupStatus: "error" | "success" | null; nextBackupAt: number | null;
nextBackupAt: number | null; repositoryId: string;
repositoryId: string; retentionPolicy: {
retentionPolicy: { keepDaily?: number;
keepDaily?: number; keepHourly?: number;
keepHourly?: number; keepLast?: number;
keepLast?: number; keepMonthly?: number;
keepMonthly?: number; keepWeekly?: number;
keepWeekly?: number; keepWithinDuration?: string;
keepWithinDuration?: string; keepYearly?: number;
keepYearly?: number; } | null;
} | null; updatedAt: number;
updatedAt: number; volumeId: number;
volumeId: number;
};
}; };
}; };
@@ -1000,31 +994,28 @@ export type UpdateBackupScheduleResponses = {
* Backup schedule updated successfully * Backup schedule updated successfully
*/ */
200: { 200: {
message: string; createdAt: number;
schedule: { cronExpression: string;
createdAt: number; enabled: boolean;
cronExpression: string; excludePatterns: Array<string> | null;
enabled: boolean; id: number;
excludePatterns: Array<string> | null; includePatterns: Array<string> | null;
id: number; lastBackupAt: number | null;
includePatterns: Array<string> | null; lastBackupError: string | null;
lastBackupAt: number | null; lastBackupStatus: "error" | "success" | null;
lastBackupError: string | null; nextBackupAt: number | null;
lastBackupStatus: "error" | "success" | null; repositoryId: string;
nextBackupAt: number | null; retentionPolicy: {
repositoryId: string; keepDaily?: number;
retentionPolicy: { keepHourly?: number;
keepDaily?: number; keepLast?: number;
keepHourly?: number; keepMonthly?: number;
keepLast?: number; keepWeekly?: number;
keepMonthly?: number; keepWithinDuration?: string;
keepWeekly?: number; keepYearly?: number;
keepWithinDuration?: string; } | null;
keepYearly?: number; updatedAt: number;
} | null; volumeId: number;
updatedAt: number;
volumeId: number;
};
}; };
}; };
@@ -1086,8 +1077,7 @@ export type RunBackupNowResponses = {
* Backup started successfully * Backup started successfully
*/ */
200: { 200: {
backupStarted: boolean; success: boolean;
message: string;
}; };
}; };

View File

@@ -1,355 +1,298 @@
import * as React from "react" // @ts-nocheck
import * as RechartsPrimitive from "recharts" // biome-ignore-all lint: reason
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR } // Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = { export type ChartConfig = {
[k in string]: { [k in string]: {
label?: React.ReactNode label?: React.ReactNode;
icon?: React.ComponentType icon?: React.ComponentType;
} & ( } & ({ color?: string; theme?: never } | { color?: never; theme: Record<keyof typeof THEMES, string> });
| { color?: string; theme?: never } };
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = { type ChartContextProps = {
config: ChartConfig config: ChartConfig;
} };
const ChartContext = React.createContext<ChartContextProps | null>(null) const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() { function useChart() {
const context = React.useContext(ChartContext) const context = React.useContext(ChartContext);
if (!context) { if (!context) {
throw new Error("useChart must be used within a <ChartContainer />") throw new Error("useChart must be used within a <ChartContainer />");
} }
return context return context;
} }
function ChartContainer({ function ChartContainer({
id, id,
className, className,
children, children,
config, config,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<"div"> & {
config: ChartConfig config: ChartConfig;
children: React.ComponentProps< children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}) { }) {
const uniqueId = React.useId() const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}` const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return ( return (
<ChartContext.Provider value={{ config }}> <ChartContext.Provider value={{ config }}>
<div <div
data-slot="chart" data-slot="chart"
data-chart={chartId} data-chart={chartId}
className={cn( className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden", "[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className className,
)} )}
{...props} {...props}
> >
<ChartStyle id={chartId} config={config} /> <ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer> <RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
{children} </div>
</RechartsPrimitive.ResponsiveContainer> </ChartContext.Provider>
</div> );
</ChartContext.Provider>
)
} }
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter( const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color);
([, config]) => config.theme || config.color
)
if (!colorConfig.length) { if (!colorConfig.length) {
return null return null;
} }
return ( return (
<style <style
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: Object.entries(THEMES) __html: Object.entries(THEMES)
.map( .map(
([theme, prefix]) => ` ([theme, prefix]) => `
${prefix} [data-chart=${id}] { ${prefix} [data-chart=${id}] {
${colorConfig ${colorConfig
.map(([key, itemConfig]) => { .map(([key, itemConfig]) => {
const color = const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || return color ? ` --color-${key}: ${color};` : null;
itemConfig.color })
return color ? ` --color-${key}: ${color};` : null .join("\n")}
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
} }
`,
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip const ChartTooltip = RechartsPrimitive.Tooltip;
function ChartTooltipContent({ function ChartTooltipContent({
active, active,
payload, payload,
className, className,
indicator = "dot", indicator = "dot",
hideLabel = false, hideLabel = false,
hideIndicator = false, hideIndicator = false,
label, label,
labelFormatter, labelFormatter,
labelClassName, labelClassName,
formatter, formatter,
color, color,
nameKey, nameKey,
labelKey, labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> & }: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & { React.ComponentProps<"div"> & {
hideLabel?: boolean hideLabel?: boolean;
hideIndicator?: boolean hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed" indicator?: "line" | "dot" | "dashed";
nameKey?: string nameKey?: string;
labelKey?: string labelKey?: string;
}) { }) {
const { config } = useChart() const { config } = useChart();
const tooltipLabel = React.useMemo(() => { const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) { if (hideLabel || !payload?.length) {
return null return null;
} }
const [item] = payload const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || "value"}` const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key) const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value = const value =
!labelKey && typeof label === "string" !labelKey && typeof label === "string" ? config[label as keyof typeof config]?.label || label : itemConfig?.label;
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) { if (labelFormatter) {
return ( return <div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>;
<div className={cn("font-medium", labelClassName)}> }
{labelFormatter(value, payload)}
</div>
)
}
if (!value) { if (!value) {
return null return null;
} }
return <div className={cn("font-medium", labelClassName)}>{value}</div> return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [ }, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) { if (!active || !payload?.length) {
return null return null;
} }
const nestLabel = payload.length === 1 && indicator !== "dot" const nestLabel = payload.length === 1 && indicator !== "dot";
return ( return (
<div <div
className={cn( className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl", "border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className className,
)} )}
> >
{!nestLabel ? tooltipLabel : null} {!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5"> <div className="grid gap-1.5">
{payload {payload
.filter((item) => item.type !== "none") .filter((item) => item.type !== "none")
.map((item, index) => { .map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}` const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key) const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color const indicatorColor = color || item.payload.fill || item.color;
return ( return (
<div <div
key={item.dataKey} key={item.dataKey}
className={cn( className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5", "[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center" indicator === "dot" && "items-center",
)} )}
> >
{formatter && item?.value !== undefined && item.name ? ( {formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload) formatter(item.value, item.name, item, index, item.payload)
) : ( ) : (
<> <>
{itemConfig?.icon ? ( {itemConfig?.icon ? (
<itemConfig.icon /> <itemConfig.icon />
) : ( ) : (
!hideIndicator && ( !hideIndicator && (
<div <div
className={cn( className={cn("shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)", {
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)", "h-2.5 w-2.5": indicator === "dot",
{ "w-1": indicator === "line",
"h-2.5 w-2.5": indicator === "dot", "w-0 border-[1.5px] border-dashed bg-transparent": indicator === "dashed",
"w-1": indicator === "line", "my-0.5": nestLabel && indicator === "dashed",
"w-0 border-[1.5px] border-dashed bg-transparent": })}
indicator === "dashed", style={
"my-0.5": nestLabel && indicator === "dashed", {
} "--color-bg": indicatorColor,
)} "--color-border": indicatorColor,
style={ } as React.CSSProperties
{ }
"--color-bg": indicatorColor, />
"--color-border": indicatorColor, )
} as React.CSSProperties )}
} <div
/> className={cn(
) "flex flex-1 justify-between leading-none",
)} nestLabel ? "items-end" : "items-center",
<div )}
className={cn( >
"flex flex-1 justify-between leading-none", <div className="grid gap-1.5">
nestLabel ? "items-end" : "items-center" {nestLabel ? tooltipLabel : null}
)} <span className="text-muted-foreground">{itemConfig?.label || item.name}</span>
> </div>
<div className="grid gap-1.5"> {item.value && (
{nestLabel ? tooltipLabel : null} <span className="text-foreground font-mono font-medium tabular-nums">
<span className="text-muted-foreground"> {item.value.toLocaleString()}
{itemConfig?.label || item.name} </span>
</span> )}
</div> </div>
{item.value && ( </>
<span className="text-foreground font-mono font-medium tabular-nums"> )}
{item.value.toLocaleString()} </div>
</span> );
)} })}
</div> </div>
</> </div>
)} );
</div>
)
})}
</div>
</div>
)
} }
const ChartLegend = RechartsPrimitive.Legend const ChartLegend = RechartsPrimitive.Legend;
function ChartLegendContent({ function ChartLegendContent({
className, className,
hideIcon = false, hideIcon = false,
payload, payload,
verticalAlign = "bottom", verticalAlign = "bottom",
nameKey, nameKey,
}: React.ComponentProps<"div"> & }: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & { Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean hideIcon?: boolean;
nameKey?: string nameKey?: string;
}) { }) {
const { config } = useChart() const { config } = useChart();
if (!payload?.length) { if (!payload?.length) {
return null return null;
} }
return ( return (
<div <div className={cn("flex items-center justify-center gap-4", verticalAlign === "top" ? "pb-3" : "pt-3", className)}>
className={cn( {payload
"flex items-center justify-center gap-4", .filter((item) => item.type !== "none")
verticalAlign === "top" ? "pb-3" : "pt-3", .map((item) => {
className const key = `${nameKey || item.dataKey || "value"}`;
)} const itemConfig = getPayloadConfigFromPayload(config, item, key);
>
{payload
.filter((item) => item.type !== "none")
.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return ( return (
<div <div
key={item.value} key={item.value}
className={cn( className={cn("[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3")}
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3" >
)} {itemConfig?.icon && !hideIcon ? (
> <itemConfig.icon />
{itemConfig?.icon && !hideIcon ? ( ) : (
<itemConfig.icon /> <div
) : ( className="h-2 w-2 shrink-0 rounded-[2px]"
<div style={{
className="h-2 w-2 shrink-0 rounded-[2px]" backgroundColor: item.color,
style={{ }}
backgroundColor: item.color, />
}} )}
/> {itemConfig?.label}
)} </div>
{itemConfig?.label} );
</div> })}
) </div>
})} );
</div>
)
} }
// Helper to extract item config from a payload. // Helper to extract item config from a payload.
function getPayloadConfigFromPayload( function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
config: ChartConfig, if (typeof payload !== "object" || payload === null) {
payload: unknown, return undefined;
key: string }
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload = const payloadPayload =
"payload" in payload && "payload" in payload && typeof payload.payload === "object" && payload.payload !== null
typeof payload.payload === "object" && ? payload.payload
payload.payload !== null : undefined;
? payload.payload
: undefined
let configLabelKey: string = key let configLabelKey: string = key;
if ( if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
key in payload && configLabelKey = payload[key as keyof typeof payload] as string;
typeof payload[key as keyof typeof payload] === "string" } else if (
) { payloadPayload &&
configLabelKey = payload[key as keyof typeof payload] as string key in payloadPayload &&
} else if ( typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
payloadPayload && ) {
key in payloadPayload && configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
typeof payloadPayload[key as keyof typeof payloadPayload] === "string" }
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
? config[configLabelKey]
: config[key as keyof typeof config]
} }
export { export { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, ChartStyle };
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}

View File

@@ -6,6 +6,6 @@ export type VolumeStatus = Volume["status"];
export type User = GetMeResponse["user"]; export type User = GetMeResponse["user"];
export type Repository = GetRepositoryResponse["repository"]; export type Repository = GetRepositoryResponse;
export type BackupSchedule = GetBackupScheduleResponse["schedule"]; export type BackupSchedule = GetBackupScheduleResponse;

View File

@@ -7,7 +7,7 @@ export const authMiddleware: MiddlewareFunction = async ({ context, request }) =
const isAuthRoute = ["/login", "/onboarding"].includes(new URL(request.url).pathname); const isAuthRoute = ["/login", "/onboarding"].includes(new URL(request.url).pathname);
if (!session.data?.user.id && !isAuthRoute) { if (!session.data?.user?.id && !isAuthRoute) {
const status = await getStatus(); const status = await getStatus();
if (!status.data?.hasUsers) { if (!status.data?.hasUsers) {
throw redirect("/onboarding"); throw redirect("/onboarding");
@@ -16,7 +16,7 @@ export const authMiddleware: MiddlewareFunction = async ({ context, request }) =
throw redirect("/login"); throw redirect("/login");
} }
if (session.data?.user.id) { if (session.data?.user?.id) {
context.set(appContext, { user: session.data.user, hasUsers: true }); context.set(appContext, { user: session.data.user, hasUsers: true });
if (isAuthRoute) { if (isAuthRoute) {

View File

@@ -114,7 +114,7 @@ export const CreateScheduleForm = ({ initialValues, onSubmit, volume, loading, s
<SelectValue placeholder="Select a repository" /> <SelectValue placeholder="Select a repository" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{repositoriesData?.repositories.map((repo) => ( {repositoriesData?.map((repo) => (
<SelectItem key={repo.id} value={repo.id}> <SelectItem key={repo.id} value={repo.id}>
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<RepositoryIcon backend={repo.type} /> <RepositoryIcon backend={repo.type} />

View File

@@ -35,7 +35,7 @@ export const HealthchecksCard = ({ volume }: Props) => {
...updateVolumeMutation(), ...updateVolumeMutation(),
onSuccess: (d) => { onSuccess: (d) => {
toast.success("Volume updated", { toast.success("Volume updated", {
description: `Auto remount is now ${d.volume.autoRemount ? "enabled" : "paused"}.`, description: `Auto remount is now ${d.autoRemount ? "enabled" : "paused"}.`,
}); });
}, },
onError: (error) => { onError: (error) => {

View File

@@ -51,7 +51,7 @@ export const VolumeBackupsTabContent = ({ volume }: Props) => {
const [isEnabled, setIsEnabled] = useState(existingSchedule?.enabled ?? true); const [isEnabled, setIsEnabled] = useState(existingSchedule?.enabled ?? true);
const repositories = repositoriesData?.repositories || []; const repositories = repositoriesData || [];
const selectedRepository = repositories.find((r) => r.id === (existingSchedule?.repositoryId ?? "")); const selectedRepository = repositories.find((r) => r.id === (existingSchedule?.repositoryId ?? ""));
const summary = useMemo(() => { const summary = useMemo(() => {

View File

@@ -38,7 +38,7 @@ export const DockerTabContent = ({ volume }: Props) => {
refetchOnWindowFocus: true, refetchOnWindowFocus: true,
}); });
const containers = containersData?.containers || []; const containers = containersData || [];
const getStateClass = (state: string) => { const getStateClass = (state: string) => {
switch (state) { switch (state) {

View File

@@ -26,8 +26,8 @@ export function meta(_: Route.MetaArgs) {
export const clientLoader = async () => { export const clientLoader = async () => {
const repositories = await listRepositories(); const repositories = await listRepositories();
if (repositories.data) return { repositories: repositories.data.repositories }; if (repositories.data) return repositories.data;
return { repositories: [] }; return [];
}; };
export default function Repositories({ loaderData }: Route.ComponentProps) { export default function Repositories({ loaderData }: Route.ComponentProps) {
@@ -52,14 +52,14 @@ export default function Repositories({ loaderData }: Route.ComponentProps) {
}); });
const filteredRepositories = const filteredRepositories =
data?.repositories.filter((repository) => { data?.filter((repository) => {
const matchesSearch = repository.name.toLowerCase().includes(searchQuery.toLowerCase()); const matchesSearch = repository.name.toLowerCase().includes(searchQuery.toLowerCase());
const matchesStatus = !statusFilter || repository.status === statusFilter; const matchesStatus = !statusFilter || repository.status === statusFilter;
const matchesBackend = !backendFilter || repository.type === backendFilter; const matchesBackend = !backendFilter || repository.type === backendFilter;
return matchesSearch && matchesStatus && matchesBackend; return matchesSearch && matchesStatus && matchesBackend;
}) || []; }) || [];
const hasNoRepositories = data?.repositories.length === 0; const hasNoRepositories = data?.length === 0;
const hasNoFilteredRepositories = filteredRepositories.length === 0 && !hasNoRepositories; const hasNoFilteredRepositories = filteredRepositories.length === 0 && !hasNoRepositories;
if (hasNoRepositories) { if (hasNoRepositories) {

View File

@@ -83,8 +83,6 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
return <div>Loading...</div>; return <div>Loading...</div>;
} }
const { repository } = data;
return ( return (
<> <>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
@@ -94,14 +92,14 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
className={cn( className={cn(
"inline-flex items-center gap-2 px-2 py-1 rounded-md text-xs bg-gray-500/10 text-gray-500", "inline-flex items-center gap-2 px-2 py-1 rounded-md text-xs bg-gray-500/10 text-gray-500",
{ {
"bg-green-500/10 text-green-500": repository.status === "healthy", "bg-green-500/10 text-green-500": data.status === "healthy",
"bg-red-500/10 text-red-500": repository.status === "error", "bg-red-500/10 text-red-500": data.status === "error",
}, },
)} )}
> >
{repository.status || "unknown"} {data.status || "unknown"}
</span> </span>
<span className="text-xs bg-primary/10 rounded-md px-2 py-1">{repository.type}</span> <span className="text-xs bg-primary/10 rounded-md px-2 py-1">{data.type}</span>
</div> </div>
</div> </div>
<div className="flex gap-4"> <div className="flex gap-4">
@@ -117,10 +115,10 @@ export default function RepositoryDetailsPage({ loaderData }: Route.ComponentPro
<TabsTrigger value="snapshots">Snapshots</TabsTrigger> <TabsTrigger value="snapshots">Snapshots</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="info"> <TabsContent value="info">
<RepositoryInfoTabContent repository={data.repository} /> <RepositoryInfoTabContent repository={data} />
</TabsContent> </TabsContent>
<TabsContent value="snapshots"> <TabsContent value="snapshots">
<RepositorySnapshotsTabContent repository={data.repository} /> <RepositorySnapshotsTabContent repository={data} />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</> </>

View File

@@ -6,7 +6,7 @@ import type {
RepositoryStatus, RepositoryStatus,
} from "@ironmount/schemas/restic"; } from "@ironmount/schemas/restic";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { int, sqliteTable, text } from "drizzle-orm/sqlite-core"; import { int, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const volumesTable = sqliteTable("volumes_table", { export const volumesTable = sqliteTable("volumes_table", {
id: int().primaryKey({ autoIncrement: true }), id: int().primaryKey({ autoIncrement: true }),
@@ -14,9 +14,9 @@ export const volumesTable = sqliteTable("volumes_table", {
type: text().$type<BackendType>().notNull(), type: text().$type<BackendType>().notNull(),
status: text().$type<BackendStatus>().notNull().default("unmounted"), status: text().$type<BackendStatus>().notNull().default("unmounted"),
lastError: text("last_error"), lastError: text("last_error"),
lastHealthCheck: int("last_health_check", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`), lastHealthCheck: integer("last_health_check", { mode: "number" }).notNull().default(sql`(unixepoch())`),
createdAt: int("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`), createdAt: integer("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
updatedAt: int("updated_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`), updatedAt: integer("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
config: text("config", { mode: "json" }).$type<typeof volumeConfigSchema.inferOut>().notNull(), config: text("config", { mode: "json" }).$type<typeof volumeConfigSchema.inferOut>().notNull(),
autoRemount: int("auto_remount", { mode: "boolean" }).notNull().default(true), autoRemount: int("auto_remount", { mode: "boolean" }).notNull().default(true),
}); });
@@ -27,8 +27,8 @@ export const usersTable = sqliteTable("users_table", {
id: int().primaryKey({ autoIncrement: true }), id: int().primaryKey({ autoIncrement: true }),
username: text().notNull().unique(), username: text().notNull().unique(),
passwordHash: text("password_hash").notNull(), passwordHash: text("password_hash").notNull(),
createdAt: int("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`), createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
updatedAt: int("updated_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`), updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
}); });
export type User = typeof usersTable.$inferSelect; export type User = typeof usersTable.$inferSelect;
@@ -38,8 +38,8 @@ export const sessionsTable = sqliteTable("sessions_table", {
userId: int("user_id") userId: int("user_id")
.notNull() .notNull()
.references(() => usersTable.id, { onDelete: "cascade" }), .references(() => usersTable.id, { onDelete: "cascade" }),
expiresAt: int("expires_at", { mode: "timestamp" }).notNull(), expiresAt: int("expires_at", { mode: "number" }).notNull(),
createdAt: int("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`), createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
}); });
export type Session = typeof sessionsTable.$inferSelect; export type Session = typeof sessionsTable.$inferSelect;
@@ -51,10 +51,10 @@ export const repositoriesTable = sqliteTable("repositories_table", {
config: text("config", { mode: "json" }).$type<typeof repositoryConfigSchema.inferOut>().notNull(), config: text("config", { mode: "json" }).$type<typeof repositoryConfigSchema.inferOut>().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: "timestamp" }), lastChecked: int("last_checked", { mode: "number" }),
lastError: text("last_error"), lastError: text("last_error"),
createdAt: int("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`), createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
updatedAt: int("updated_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`), updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
}); });
export type Repository = typeof repositoriesTable.$inferSelect; export type Repository = typeof repositoriesTable.$inferSelect;
@@ -81,12 +81,12 @@ export const backupSchedulesTable = sqliteTable("backup_schedules_table", {
}>(), }>(),
excludePatterns: text("exclude_patterns", { mode: "json" }).$type<string[]>().default([]), excludePatterns: text("exclude_patterns", { mode: "json" }).$type<string[]>().default([]),
includePatterns: text("include_patterns", { mode: "json" }).$type<string[]>().default([]), includePatterns: text("include_patterns", { mode: "json" }).$type<string[]>().default([]),
lastBackupAt: int("last_backup_at", { mode: "timestamp" }), lastBackupAt: int("last_backup_at", { mode: "number" }),
lastBackupStatus: text("last_backup_status").$type<"success" | "error">(), lastBackupStatus: text("last_backup_status").$type<"success" | "error">(),
lastBackupError: text("last_backup_error"), lastBackupError: text("last_backup_error"),
nextBackupAt: int("next_backup_at", { mode: "timestamp" }), nextBackupAt: int("next_backup_at", { mode: "number" }),
createdAt: int("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`), createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
updatedAt: int("updated_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`), updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
}); });
export type BackupSchedule = typeof backupSchedulesTable.$inferSelect; export type BackupSchedule = typeof backupSchedulesTable.$inferSelect;

View File

@@ -0,0 +1,10 @@
import { Job } from "../core/scheduler";
import { authService } from "../modules/auth/auth.service";
export class CleanupSessionsJob extends Job {
async run() {
authService.cleanupExpiredSessions();
return { done: true, timestamp: new Date() };
}
}

View File

@@ -10,8 +10,14 @@ import {
logoutDto, logoutDto,
registerBodySchema, registerBodySchema,
registerDto, registerDto,
type GetMeDto,
type GetStatusDto,
type LoginDto,
type LogoutDto,
type RegisterDto,
} from "./auth.dto"; } from "./auth.dto";
import { authService } from "./auth.service"; import { authService } from "./auth.service";
import { toMessage } from "../../utils/errors";
const COOKIE_NAME = "session_id"; const COOKIE_NAME = "session_id";
const COOKIE_OPTIONS = { const COOKIE_OPTIONS = {
@@ -33,9 +39,12 @@ export const authController = new Hono()
expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
}); });
return c.json({ message: "User registered successfully", user: { id: user.id, username: user.username } }, 201); return c.json<RegisterDto>(
{ success: true, message: "User registered successfully", user: { id: user.id, username: user.username } },
201,
);
} catch (error) { } catch (error) {
return c.json({ message: error instanceof Error ? error.message : "Registration failed" }, 400); return c.json<RegisterDto>({ success: false, message: toMessage(error) }, 400);
} }
}) })
.post("/login", loginDto, validator("json", loginBodySchema), async (c) => { .post("/login", loginDto, validator("json", loginBodySchema), async (c) => {
@@ -46,15 +55,16 @@ export const authController = new Hono()
setCookie(c, COOKIE_NAME, sessionId, { setCookie(c, COOKIE_NAME, sessionId, {
...COOKIE_OPTIONS, ...COOKIE_OPTIONS,
expires: expiresAt, expires: new Date(expiresAt),
}); });
return c.json({ return c.json<LoginDto>({
success: true,
message: "Login successful", message: "Login successful",
user: { id: user.id, username: user.username }, user: { id: user.id, username: user.username },
}); });
} catch (error) { } catch (error) {
return c.json({ message: error instanceof Error ? error.message : "Login failed" }, 401); return c.json<LoginDto>({ success: false, message: toMessage(error) }, 401);
} }
}) })
.post("/logout", logoutDto, async (c) => { .post("/logout", logoutDto, async (c) => {
@@ -65,13 +75,13 @@ export const authController = new Hono()
deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS); deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS);
} }
return c.json({ message: "Logout successful" }); return c.json<LogoutDto>({ success: true });
}) })
.get("/me", getMeDto, async (c) => { .get("/me", getMeDto, async (c) => {
const sessionId = getCookie(c, COOKIE_NAME); const sessionId = getCookie(c, COOKIE_NAME);
if (!sessionId) { if (!sessionId) {
return c.json({ message: "Not authenticated" }, 401); return c.json<GetMeDto>({ success: false, message: "Not authenticated" }, 401);
} }
const session = await authService.verifySession(sessionId); const session = await authService.verifySession(sessionId);
@@ -81,11 +91,13 @@ export const authController = new Hono()
return c.json({ message: "Not authenticated" }, 401); return c.json({ message: "Not authenticated" }, 401);
} }
return c.json({ return c.json<GetMeDto>({
success: true,
user: session.user, user: session.user,
message: "Authenticated",
}); });
}) })
.get("/status", getStatusDto, async (c) => { .get("/status", getStatusDto, async (c) => {
const hasUsers = await authService.hasUsers(); const hasUsers = await authService.hasUsers();
return c.json({ hasUsers }); return c.json<GetStatusDto>({ hasUsers });
}); });

View File

@@ -14,10 +14,11 @@ export const registerBodySchema = type({
const loginResponseSchema = type({ const loginResponseSchema = type({
message: "string", message: "string",
success: "boolean",
user: type({ user: type({
id: "string", id: "number",
username: "string", username: "string",
}), }).optional(),
}); });
export const loginDto = describeRoute({ export const loginDto = describeRoute({
@@ -39,6 +40,8 @@ export const loginDto = describeRoute({
}, },
}); });
export type LoginDto = typeof loginResponseSchema.infer;
export const registerDto = describeRoute({ export const registerDto = describeRoute({
description: "Register a new user", description: "Register a new user",
operationId: "register", operationId: "register",
@@ -58,6 +61,12 @@ export const registerDto = describeRoute({
}, },
}); });
export type RegisterDto = typeof loginResponseSchema.infer;
const logoutResponseSchema = type({
success: "boolean",
});
export const logoutDto = describeRoute({ export const logoutDto = describeRoute({
description: "Logout current user", description: "Logout current user",
operationId: "logout", operationId: "logout",
@@ -67,13 +76,15 @@ export const logoutDto = describeRoute({
description: "Logout successful", description: "Logout successful",
content: { content: {
"application/json": { "application/json": {
schema: resolver(type({ message: "string" })), schema: resolver(logoutResponseSchema),
}, },
}, },
}, },
}, },
}); });
export type LogoutDto = typeof logoutResponseSchema.infer;
export const getMeDto = describeRoute({ export const getMeDto = describeRoute({
description: "Get current authenticated user", description: "Get current authenticated user",
operationId: "getMe", operationId: "getMe",
@@ -87,12 +98,11 @@ export const getMeDto = describeRoute({
}, },
}, },
}, },
401: {
description: "Not authenticated",
},
}, },
}); });
export type GetMeDto = typeof loginResponseSchema.infer;
const statusResponseSchema = type({ const statusResponseSchema = type({
hasUsers: "boolean", hasUsers: "boolean",
}); });
@@ -113,5 +123,7 @@ export const getStatusDto = describeRoute({
}, },
}); });
export type GetStatusDto = typeof statusResponseSchema.infer;
export type LoginBody = typeof loginBodySchema.infer; export type LoginBody = typeof loginBodySchema.infer;
export type RegisterBody = typeof registerBodySchema.infer; export type RegisterBody = typeof registerBodySchema.infer;

View File

@@ -1,4 +1,4 @@
import { eq } from "drizzle-orm"; import { eq, lt } from "drizzle-orm";
import { db } from "../../db/db"; import { db } from "../../db/db";
import { sessionsTable, usersTable } from "../../db/schema"; import { sessionsTable, usersTable } from "../../db/schema";
import { logger } from "../../utils/logger"; import { logger } from "../../utils/logger";
@@ -30,7 +30,7 @@ export class AuthService {
logger.info(`User registered: ${username}`); logger.info(`User registered: ${username}`);
const sessionId = crypto.randomUUID(); const sessionId = crypto.randomUUID();
const expiresAt = new Date(Date.now() + SESSION_DURATION); const expiresAt = new Date(Date.now() + SESSION_DURATION).getTime();
await db.insert(sessionsTable).values({ await db.insert(sessionsTable).values({
id: sessionId, id: sessionId,
@@ -58,7 +58,7 @@ export class AuthService {
} }
const sessionId = crypto.randomUUID(); const sessionId = crypto.randomUUID();
const expiresAt = new Date(Date.now() + SESSION_DURATION); const expiresAt = new Date(Date.now() + SESSION_DURATION).getTime();
await db.insert(sessionsTable).values({ await db.insert(sessionsTable).values({
id: sessionId, id: sessionId,
@@ -100,7 +100,7 @@ export class AuthService {
return null; return null;
} }
if (session.session.expiresAt < new Date()) { if (session.session.expiresAt < Date.now()) {
await db.delete(sessionsTable).where(eq(sessionsTable.id, sessionId)); await db.delete(sessionsTable).where(eq(sessionsTable.id, sessionId));
return null; return null;
} }
@@ -121,7 +121,7 @@ export class AuthService {
* Clean up expired sessions * Clean up expired sessions
*/ */
async cleanupExpiredSessions() { async cleanupExpiredSessions() {
const result = await db.delete(sessionsTable).where(eq(sessionsTable.expiresAt, new Date())).returning(); const result = await db.delete(sessionsTable).where(lt(sessionsTable.expiresAt, Date.now())).returning();
if (result.length > 0) { if (result.length > 0) {
logger.info(`Cleaned up ${result.length} expired sessions`); logger.info(`Cleaned up ${result.length} expired sessions`);
} }

View File

@@ -10,6 +10,13 @@ import {
runBackupNowDto, runBackupNowDto,
updateBackupScheduleBody, updateBackupScheduleBody,
updateBackupScheduleDto, updateBackupScheduleDto,
type CreateBackupScheduleDto,
type DeleteBackupScheduleDto,
type GetBackupScheduleDto,
type GetBackupScheduleForVolumeResponseDto,
type ListBackupSchedulesResponseDto,
type RunBackupNowDto,
type UpdateBackupScheduleDto,
} from "./backups.dto"; } from "./backups.dto";
import { backupsService } from "./backups.service"; import { backupsService } from "./backups.service";
@@ -17,27 +24,27 @@ export const backupScheduleController = new Hono()
.get("/", listBackupSchedulesDto, async (c) => { .get("/", listBackupSchedulesDto, async (c) => {
const schedules = await backupsService.listSchedules(); const schedules = await backupsService.listSchedules();
return c.json({ schedules }, 200); return c.json<ListBackupSchedulesResponseDto>(schedules, 200);
}) })
.get("/:scheduleId", getBackupScheduleDto, async (c) => { .get("/:scheduleId", getBackupScheduleDto, async (c) => {
const scheduleId = c.req.param("scheduleId"); const scheduleId = c.req.param("scheduleId");
const schedule = await backupsService.getSchedule(Number(scheduleId)); const schedule = await backupsService.getSchedule(Number(scheduleId));
return c.json({ schedule }, 200); return c.json<GetBackupScheduleDto>(schedule, 200);
}) })
.get("/volume/:volumeId", getBackupScheduleForVolumeDto, async (c) => { .get("/volume/:volumeId", getBackupScheduleForVolumeDto, async (c) => {
const volumeId = c.req.param("volumeId"); const volumeId = c.req.param("volumeId");
const schedule = await backupsService.getScheduleForVolume(Number(volumeId)); const schedule = await backupsService.getScheduleForVolume(Number(volumeId));
return c.json(schedule, 200); return c.json<GetBackupScheduleForVolumeResponseDto>(schedule, 200);
}) })
.post("/", createBackupScheduleDto, validator("json", createBackupScheduleBody), async (c) => { .post("/", createBackupScheduleDto, validator("json", createBackupScheduleBody), async (c) => {
const body = c.req.valid("json"); const body = c.req.valid("json");
const schedule = await backupsService.createSchedule(body); const schedule = await backupsService.createSchedule(body);
return c.json({ message: "Backup schedule created successfully", schedule }, 201); return c.json<CreateBackupScheduleDto>(schedule, 201);
}) })
.patch("/:scheduleId", updateBackupScheduleDto, validator("json", updateBackupScheduleBody), async (c) => { .patch("/:scheduleId", updateBackupScheduleDto, validator("json", updateBackupScheduleBody), async (c) => {
const scheduleId = c.req.param("scheduleId"); const scheduleId = c.req.param("scheduleId");
@@ -45,14 +52,14 @@ export const backupScheduleController = new Hono()
const schedule = await backupsService.updateSchedule(Number(scheduleId), body); const schedule = await backupsService.updateSchedule(Number(scheduleId), body);
return c.json({ message: "Backup schedule updated successfully", schedule }, 200); return c.json<UpdateBackupScheduleDto>(schedule, 200);
}) })
.delete("/:scheduleId", deleteBackupScheduleDto, async (c) => { .delete("/:scheduleId", deleteBackupScheduleDto, async (c) => {
const scheduleId = c.req.param("scheduleId"); const scheduleId = c.req.param("scheduleId");
await backupsService.deleteSchedule(Number(scheduleId)); await backupsService.deleteSchedule(Number(scheduleId));
return c.json({ message: "Backup schedule deleted successfully" }, 200); return c.json<DeleteBackupScheduleDto>({ success: true }, 200);
}) })
.post("/:scheduleId/run", runBackupNowDto, async (c) => { .post("/:scheduleId/run", runBackupNowDto, async (c) => {
const scheduleId = c.req.param("scheduleId"); const scheduleId = c.req.param("scheduleId");
@@ -61,11 +68,5 @@ export const backupScheduleController = new Hono()
console.error("Backup execution failed:", error); console.error("Backup execution failed:", error);
}); });
return c.json( return c.json<RunBackupNowDto>({ success: true }, 200);
{
message: "Backup started",
backupStarted: true,
},
200,
);
}); });

View File

@@ -35,9 +35,7 @@ export type BackupScheduleDto = typeof backupScheduleSchema.infer;
/** /**
* List all backup schedules * List all backup schedules
*/ */
export const listBackupSchedulesResponse = type({ export const listBackupSchedulesResponse = backupScheduleSchema.array();
schedules: backupScheduleSchema.array(),
});
export type ListBackupSchedulesResponseDto = typeof listBackupSchedulesResponse.infer; export type ListBackupSchedulesResponseDto = typeof listBackupSchedulesResponse.infer;
@@ -60,9 +58,7 @@ export const listBackupSchedulesDto = describeRoute({
/** /**
* Get a single backup schedule * Get a single backup schedule
*/ */
export const getBackupScheduleResponse = type({ export const getBackupScheduleResponse = backupScheduleSchema;
schedule: backupScheduleSchema,
});
export type GetBackupScheduleDto = typeof getBackupScheduleResponse.infer; export type GetBackupScheduleDto = typeof getBackupScheduleResponse.infer;
@@ -118,10 +114,7 @@ export const createBackupScheduleBody = type({
export type CreateBackupScheduleBody = typeof createBackupScheduleBody.infer; export type CreateBackupScheduleBody = typeof createBackupScheduleBody.infer;
export const createBackupScheduleResponse = type({ export const createBackupScheduleResponse = backupScheduleSchema;
message: "string",
schedule: backupScheduleSchema,
});
export type CreateBackupScheduleDto = typeof createBackupScheduleResponse.infer; export type CreateBackupScheduleDto = typeof createBackupScheduleResponse.infer;
@@ -156,10 +149,9 @@ export const updateBackupScheduleBody = type({
export type UpdateBackupScheduleBody = typeof updateBackupScheduleBody.infer; export type UpdateBackupScheduleBody = typeof updateBackupScheduleBody.infer;
export const updateBackupScheduleResponse = type({ export const updateBackupScheduleResponse = backupScheduleSchema;
message: "string",
schedule: backupScheduleSchema, export type UpdateBackupScheduleDto = typeof updateBackupScheduleResponse.infer;
});
export const updateBackupScheduleDto = describeRoute({ export const updateBackupScheduleDto = describeRoute({
description: "Update a backup schedule", description: "Update a backup schedule",
@@ -181,9 +173,11 @@ export const updateBackupScheduleDto = describeRoute({
* Delete a backup schedule * Delete a backup schedule
*/ */
export const deleteBackupScheduleResponse = type({ export const deleteBackupScheduleResponse = type({
message: "string", success: "boolean",
}); });
export type DeleteBackupScheduleDto = typeof deleteBackupScheduleResponse.infer;
export const deleteBackupScheduleDto = describeRoute({ export const deleteBackupScheduleDto = describeRoute({
description: "Delete a backup schedule", description: "Delete a backup schedule",
operationId: "deleteBackupSchedule", operationId: "deleteBackupSchedule",
@@ -204,10 +198,11 @@ export const deleteBackupScheduleDto = describeRoute({
* Run a backup immediately * Run a backup immediately
*/ */
export const runBackupNowResponse = type({ export const runBackupNowResponse = type({
message: "string", success: "boolean",
backupStarted: "boolean",
}); });
export type RunBackupNowDto = typeof runBackupNowResponse.infer;
export const runBackupNowDto = describeRoute({ export const runBackupNowDto = describeRoute({
description: "Trigger a backup immediately for a schedule", description: "Trigger a backup immediately for a schedule",
operationId: "runBackupNow", operationId: "runBackupNow",

View File

@@ -10,19 +10,19 @@ import { getVolumePath } from "../volumes/helpers";
import type { CreateBackupScheduleBody, UpdateBackupScheduleBody } from "./backups.dto"; import type { CreateBackupScheduleBody, UpdateBackupScheduleBody } from "./backups.dto";
import { toMessage } from "../../utils/errors"; import { toMessage } from "../../utils/errors";
const calculateNextRun = (cronExpression: string): Date => { const calculateNextRun = (cronExpression: string): number => {
try { try {
const interval = CronExpressionParser.parse(cronExpression, { const interval = CronExpressionParser.parse(cronExpression, {
currentDate: new Date(), currentDate: new Date(),
tz: "UTC", tz: "UTC",
}); });
return interval.next().toDate(); return interval.next().getTime();
} catch (error) { } catch (error) {
logger.error(`Failed to parse cron expression "${cronExpression}": ${error}`); logger.error(`Failed to parse cron expression "${cronExpression}": ${error}`);
const fallback = new Date(); const fallback = new Date();
fallback.setMinutes(fallback.getMinutes() + 1); fallback.setMinutes(fallback.getMinutes() + 1);
return fallback; return fallback.getTime();
} }
}; };
@@ -123,7 +123,7 @@ const updateSchedule = async (scheduleId: number, data: UpdateBackupScheduleBody
const [updated] = await db const [updated] = await db
.update(backupSchedulesTable) .update(backupSchedulesTable)
.set({ ...data, nextBackupAt, updatedAt: new Date() }) .set({ ...data, nextBackupAt, updatedAt: Date.now() })
.where(eq(backupSchedulesTable.id, scheduleId)) .where(eq(backupSchedulesTable.id, scheduleId))
.returning(); .returning();
@@ -204,11 +204,11 @@ const executeBackup = async (scheduleId: number) => {
await db await db
.update(backupSchedulesTable) .update(backupSchedulesTable)
.set({ .set({
lastBackupAt: new Date(), lastBackupAt: Date.now(),
lastBackupStatus: "success", lastBackupStatus: "success",
lastBackupError: null, lastBackupError: null,
nextBackupAt: nextBackupAt, nextBackupAt: nextBackupAt,
updatedAt: new Date(), updatedAt: Date.now(),
}) })
.where(eq(backupSchedulesTable.id, scheduleId)); .where(eq(backupSchedulesTable.id, scheduleId));
@@ -219,10 +219,10 @@ const executeBackup = async (scheduleId: number) => {
await db await db
.update(backupSchedulesTable) .update(backupSchedulesTable)
.set({ .set({
lastBackupAt: new Date(), lastBackupAt: Date.now(),
lastBackupStatus: "error", lastBackupStatus: "error",
lastBackupError: toMessage(error), lastBackupError: toMessage(error),
updatedAt: new Date(), updatedAt: Date.now(),
}) })
.where(eq(backupSchedulesTable.id, scheduleId)); .where(eq(backupSchedulesTable.id, scheduleId));
@@ -231,7 +231,7 @@ const executeBackup = async (scheduleId: number) => {
}; };
const getSchedulesToExecute = async () => { const getSchedulesToExecute = async () => {
const now = new Date(); const now = Date.now();
const schedules = await db.query.backupSchedulesTable.findMany({ const schedules = await db.query.backupSchedulesTable.findMany({
where: eq(backupSchedulesTable.enabled, true), where: eq(backupSchedulesTable.enabled, true),
}); });

View File

@@ -8,6 +8,7 @@ import { volumeService } from "../volumes/volume.service";
import { CleanupDanglingMountsJob } from "../../jobs/cleanup-dangling"; import { CleanupDanglingMountsJob } from "../../jobs/cleanup-dangling";
import { VolumeHealthCheckJob } from "../../jobs/healthchecks"; import { VolumeHealthCheckJob } from "../../jobs/healthchecks";
import { BackupExecutionJob } from "../../jobs/backup-execution"; import { BackupExecutionJob } from "../../jobs/backup-execution";
import { CleanupSessionsJob } from "../../jobs/cleanup-sessions";
export const startup = async () => { export const startup = async () => {
await Scheduler.start(); await Scheduler.start();
@@ -30,6 +31,7 @@ export const startup = async () => {
} }
Scheduler.build(CleanupDanglingMountsJob).schedule("0 * * * *"); Scheduler.build(CleanupDanglingMountsJob).schedule("0 * * * *");
Scheduler.build(VolumeHealthCheckJob).schedule("* * * * *"); Scheduler.build(VolumeHealthCheckJob).schedule("*/5 * * * *");
Scheduler.build(BackupExecutionJob).schedule("* * * * *"); Scheduler.build(BackupExecutionJob).schedule("* * * * *");
Scheduler.build(CleanupSessionsJob).schedule("0 0 * * *");
}; };

View File

@@ -5,11 +5,12 @@ import {
createRepositoryDto, createRepositoryDto,
deleteRepositoryDto, deleteRepositoryDto,
getRepositoryDto, getRepositoryDto,
type GetRepositoryResponseDto,
type ListRepositoriesResponseDto,
listRepositoriesDto, listRepositoriesDto,
listSnapshotsDto, listSnapshotsDto,
type ListSnapshotsResponseDto, type DeleteRepositoryDto,
type GetRepositoryDto,
type ListRepositoriesDto,
type ListSnapshotsDto,
} from "./repositories.dto"; } from "./repositories.dto";
import { repositoriesService } from "./repositories.service"; import { repositoriesService } from "./repositories.service";
@@ -17,16 +18,7 @@ export const repositoriesController = new Hono()
.get("/", listRepositoriesDto, async (c) => { .get("/", listRepositoriesDto, async (c) => {
const repositories = await repositoriesService.listRepositories(); const repositories = await repositoriesService.listRepositories();
const response = { return c.json<ListRepositoriesDto>(repositories, 200);
repositories: repositories.map((repository) => ({
...repository,
updatedAt: repository.updatedAt.getTime(),
createdAt: repository.createdAt.getTime(),
lastChecked: repository.lastChecked?.getTime() ?? null,
})),
} satisfies ListRepositoriesResponseDto;
return c.json(response, 200);
}) })
.post("/", createRepositoryDto, validator("json", createRepositoryBody), async (c) => { .post("/", createRepositoryDto, validator("json", createRepositoryBody), async (c) => {
const body = c.req.valid("json"); const body = c.req.valid("json");
@@ -38,22 +30,13 @@ export const repositoriesController = new Hono()
const { name } = c.req.param(); const { name } = c.req.param();
const res = await repositoriesService.getRepository(name); const res = await repositoriesService.getRepository(name);
const response = { return c.json<GetRepositoryDto>(res.repository, 200);
repository: {
...res.repository,
createdAt: res.repository.createdAt.getTime(),
updatedAt: res.repository.updatedAt.getTime(),
lastChecked: res.repository.lastChecked?.getTime() ?? null,
},
} satisfies GetRepositoryResponseDto;
return c.json(response, 200);
}) })
.delete("/:name", deleteRepositoryDto, async (c) => { .delete("/:name", deleteRepositoryDto, async (c) => {
const { name } = c.req.param(); const { name } = c.req.param();
await repositoriesService.deleteRepository(name); await repositoriesService.deleteRepository(name);
return c.json({ message: "Repository deleted" }, 200); return c.json<DeleteRepositoryDto>({ message: "Repository deleted" }, 200);
}) })
.get("/:name/snapshots", listSnapshotsDto, async (c) => { .get("/:name/snapshots", listSnapshotsDto, async (c) => {
const { name } = c.req.param(); const { name } = c.req.param();
@@ -77,9 +60,9 @@ export const repositoriesController = new Hono()
}; };
}); });
const response = { snapshots } satisfies ListSnapshotsResponseDto; const response = { snapshots };
c.header("Cache-Control", "max-age=30, stale-while-revalidate=300"); c.header("Cache-Control", "max-age=30, stale-while-revalidate=300");
return c.json(response, 200); return c.json<ListSnapshotsDto>(response, 200);
}); });

View File

@@ -25,10 +25,8 @@ export type RepositoryDto = typeof repositorySchema.infer;
/** /**
* List all repositories * List all repositories
*/ */
export const listRepositoriesResponse = type({ export const listRepositoriesResponse = repositorySchema.array();
repositories: repositorySchema.array(), export type ListRepositoriesDto = typeof listRepositoriesResponse.infer;
});
export type ListRepositoriesResponseDto = typeof listRepositoriesResponse.infer;
export const listRepositoriesDto = describeRoute({ export const listRepositoriesDto = describeRoute({
description: "List all repositories", description: "List all repositories",
@@ -65,6 +63,8 @@ export const createRepositoryResponse = type({
}), }),
}); });
export type CreateRepositoryDto = typeof createRepositoryResponse.infer;
export const createRepositoryDto = describeRoute({ export const createRepositoryDto = describeRoute({
description: "Create a new restic repository", description: "Create a new restic repository",
operationId: "createRepository", operationId: "createRepository",
@@ -84,10 +84,8 @@ export const createRepositoryDto = describeRoute({
/** /**
* Get a single repository * Get a single repository
*/ */
export const getRepositoryResponse = type({ export const getRepositoryResponse = repositorySchema;
repository: repositorySchema, export type GetRepositoryDto = typeof getRepositoryResponse.infer;
});
export type GetRepositoryResponseDto = typeof getRepositoryResponse.infer;
export const getRepositoryDto = describeRoute({ export const getRepositoryDto = describeRoute({
description: "Get a single repository by name", description: "Get a single repository by name",
@@ -112,6 +110,8 @@ export const deleteRepositoryResponse = type({
message: "string", message: "string",
}); });
export type DeleteRepositoryDto = typeof deleteRepositoryResponse.infer;
export const deleteRepositoryDto = describeRoute({ export const deleteRepositoryDto = describeRoute({
description: "Delete a repository", description: "Delete a repository",
tags: ["Repositories"], tags: ["Repositories"],
@@ -143,7 +143,7 @@ const listSnapshotsResponse = type({
snapshots: snapshotSchema.array(), snapshots: snapshotSchema.array(),
}); });
export type ListSnapshotsResponseDto = typeof listSnapshotsResponse.infer; export type ListSnapshotsDto = typeof listSnapshotsResponse.infer;
export const listSnapshotsDto = describeRoute({ export const listSnapshotsDto = describeRoute({
description: "List all snapshots in a repository", description: "List all snapshots in a repository",

View File

@@ -65,7 +65,7 @@ const createRepository = async (name: string, config: RepositoryConfig, compress
.update(repositoriesTable) .update(repositoriesTable)
.set({ .set({
status: "healthy", status: "healthy",
lastChecked: new Date(), lastChecked: Date.now(),
lastError: null, lastError: null,
}) })
.where(eq(repositoriesTable.id, id)); .where(eq(repositoriesTable.id, id));

View File

@@ -4,22 +4,23 @@ import {
createVolumeBody, createVolumeBody,
createVolumeDto, createVolumeDto,
deleteVolumeDto, deleteVolumeDto,
type GetVolumeResponseDto,
getContainersDto, getContainersDto,
getVolumeDto, getVolumeDto,
healthCheckDto, healthCheckDto,
type ListContainersResponseDto, type ListVolumesDto,
type ListFilesResponseDto,
type ListVolumesResponseDto,
listFilesDto, listFilesDto,
listVolumesDto, listVolumesDto,
mountVolumeDto, mountVolumeDto,
testConnectionBody, testConnectionBody,
testConnectionDto, testConnectionDto,
type UpdateVolumeResponseDto,
unmountVolumeDto, unmountVolumeDto,
updateVolumeBody, updateVolumeBody,
updateVolumeDto, updateVolumeDto,
type CreateVolumeDto,
type GetVolumeDto,
type ListContainersDto,
type UpdateVolumeDto,
type ListFilesDto,
} from "./volume.dto"; } from "./volume.dto";
import { volumeService } from "./volume.service"; import { volumeService } from "./volume.service";
import { getVolumePath } from "./helpers"; import { getVolumePath } from "./helpers";
@@ -32,19 +33,21 @@ export const volumeController = new Hono()
volumes: volumes.map((volume) => ({ volumes: volumes.map((volume) => ({
path: getVolumePath(volume.name), path: getVolumePath(volume.name),
...volume, ...volume,
updatedAt: volume.updatedAt.getTime(),
createdAt: volume.createdAt.getTime(),
lastHealthCheck: volume.lastHealthCheck.getTime(),
})), })),
} satisfies ListVolumesResponseDto; };
return c.json(response, 200); return c.json<ListVolumesDto>(response, 200);
}) })
.post("/", createVolumeDto, validator("json", createVolumeBody), async (c) => { .post("/", createVolumeDto, validator("json", createVolumeBody), async (c) => {
const body = c.req.valid("json"); const body = c.req.valid("json");
const res = await volumeService.createVolume(body.name, body.config); const res = await volumeService.createVolume(body.name, body.config);
return c.json({ message: "Volume created", volume: res.volume }, 201); const response = {
...res.volume,
path: getVolumePath(res.volume.name),
};
return c.json<CreateVolumeDto>(response, 201);
}) })
.post("/test-connection", testConnectionDto, validator("json", testConnectionBody), async (c) => { .post("/test-connection", testConnectionDto, validator("json", testConnectionBody), async (c) => {
const body = c.req.valid("json"); const body = c.req.valid("json");
@@ -66,28 +69,21 @@ export const volumeController = new Hono()
volume: { volume: {
...res.volume, ...res.volume,
path: getVolumePath(res.volume.name), path: getVolumePath(res.volume.name),
createdAt: res.volume.createdAt.getTime(),
updatedAt: res.volume.updatedAt.getTime(),
lastHealthCheck: res.volume.lastHealthCheck.getTime(),
}, },
statfs: { statfs: {
total: res.statfs.total ?? 0, total: res.statfs.total ?? 0,
used: res.statfs.used ?? 0, used: res.statfs.used ?? 0,
free: res.statfs.free ?? 0, free: res.statfs.free ?? 0,
}, },
} satisfies GetVolumeResponseDto; };
return c.json(response, 200); return c.json<GetVolumeDto>(response, 200);
}) })
.get("/:name/containers", getContainersDto, async (c) => { .get("/:name/containers", getContainersDto, async (c) => {
const { name } = c.req.param(); const { name } = c.req.param();
const { containers } = await volumeService.getContainersUsingVolume(name); const { containers } = await volumeService.getContainersUsingVolume(name);
const response = { return c.json<ListContainersDto>(containers, 200);
containers,
} satisfies ListContainersResponseDto;
return c.json(response, 200);
}) })
.put("/:name", updateVolumeDto, validator("json", updateVolumeBody), async (c) => { .put("/:name", updateVolumeDto, validator("json", updateVolumeBody), async (c) => {
const { name } = c.req.param(); const { name } = c.req.param();
@@ -95,17 +91,11 @@ export const volumeController = new Hono()
const res = await volumeService.updateVolume(name, body); const res = await volumeService.updateVolume(name, body);
const response = { const response = {
message: "Volume updated", ...res.volume,
volume: { path: getVolumePath(res.volume.name),
...res.volume, };
path: getVolumePath(res.volume.name),
createdAt: res.volume.createdAt.getTime(),
updatedAt: res.volume.updatedAt.getTime(),
lastHealthCheck: res.volume.lastHealthCheck.getTime(),
},
} satisfies UpdateVolumeResponseDto;
return c.json(response, 200); return c.json<UpdateVolumeDto>(response, 200);
}) })
.post("/:name/mount", mountVolumeDto, async (c) => { .post("/:name/mount", mountVolumeDto, async (c) => {
const { name } = c.req.param(); const { name } = c.req.param();
@@ -133,9 +123,9 @@ export const volumeController = new Hono()
const response = { const response = {
files: result.files, files: result.files,
path: result.path, path: result.path,
} satisfies ListFilesResponseDto; };
c.header("Cache-Control", "public, max-age=10, stale-while-revalidate=60"); c.header("Cache-Control", "public, max-age=10, stale-while-revalidate=60");
return c.json(response, 200); return c.json<ListFilesDto>(response, 200);
}); });

View File

@@ -24,7 +24,7 @@ export type VolumeDto = typeof volumeSchema.infer;
export const listVolumesResponse = type({ export const listVolumesResponse = type({
volumes: volumeSchema.array(), volumes: volumeSchema.array(),
}); });
export type ListVolumesResponseDto = typeof listVolumesResponse.infer; export type ListVolumesDto = typeof listVolumesResponse.infer;
export const listVolumesDto = describeRoute({ export const listVolumesDto = describeRoute({
description: "List all volumes", description: "List all volumes",
@@ -50,13 +50,8 @@ export const createVolumeBody = type({
config: volumeConfigSchema, config: volumeConfigSchema,
}); });
export const createVolumeResponse = type({ export const createVolumeResponse = volumeSchema;
message: "string", export type CreateVolumeDto = typeof createVolumeResponse.infer;
volume: type({
name: "string",
path: "string",
}),
});
export const createVolumeDto = describeRoute({ export const createVolumeDto = describeRoute({
description: "Create a new volume", description: "Create a new volume",
@@ -80,6 +75,7 @@ export const createVolumeDto = describeRoute({
export const deleteVolumeResponse = type({ export const deleteVolumeResponse = type({
message: "string", message: "string",
}); });
export type DeleteVolumeDto = typeof deleteVolumeResponse.infer;
export const deleteVolumeDto = describeRoute({ export const deleteVolumeDto = describeRoute({
description: "Delete a volume", description: "Delete a volume",
@@ -108,7 +104,7 @@ const getVolumeResponse = type({
statfs: statfsSchema, statfs: statfsSchema,
}); });
export type GetVolumeResponseDto = typeof getVolumeResponse.infer; export type GetVolumeDto = typeof getVolumeResponse.infer;
/** /**
* Get a volume * Get a volume
*/ */
@@ -141,10 +137,8 @@ export const updateVolumeBody = type({
export type UpdateVolumeBody = typeof updateVolumeBody.infer; export type UpdateVolumeBody = typeof updateVolumeBody.infer;
export const updateVolumeResponse = type({ export const updateVolumeResponse = volumeSchema;
message: "string", export type UpdateVolumeDto = typeof updateVolumeResponse.infer;
volume: volumeSchema,
});
export const updateVolumeDto = describeRoute({ export const updateVolumeDto = describeRoute({
description: "Update a volume's configuration", description: "Update a volume's configuration",
@@ -165,8 +159,6 @@ export const updateVolumeDto = describeRoute({
}, },
}); });
export type UpdateVolumeResponseDto = typeof updateVolumeResponse.infer;
/** /**
* Test connection * Test connection
*/ */
@@ -178,6 +170,7 @@ export const testConnectionResponse = type({
success: "boolean", success: "boolean",
message: "string", message: "string",
}); });
export type TestConnectionDto = typeof testConnectionResponse.infer;
export const testConnectionDto = describeRoute({ export const testConnectionDto = describeRoute({
description: "Test connection to backend", description: "Test connection to backend",
@@ -202,6 +195,7 @@ export const mountVolumeResponse = type({
error: "string?", error: "string?",
status: type.valueOf(BACKEND_STATUS), status: type.valueOf(BACKEND_STATUS),
}); });
export type MountVolumeDto = typeof mountVolumeResponse.infer;
export const mountVolumeDto = describeRoute({ export const mountVolumeDto = describeRoute({
description: "Mount a volume", description: "Mount a volume",
@@ -216,9 +210,6 @@ export const mountVolumeDto = describeRoute({
}, },
}, },
}, },
404: {
description: "Volume not found",
},
}, },
}); });
@@ -229,6 +220,7 @@ export const unmountVolumeResponse = type({
error: "string?", error: "string?",
status: type.valueOf(BACKEND_STATUS), status: type.valueOf(BACKEND_STATUS),
}); });
export type UnmountVolumeDto = typeof unmountVolumeResponse.infer;
export const unmountVolumeDto = describeRoute({ export const unmountVolumeDto = describeRoute({
description: "Unmount a volume", description: "Unmount a volume",
@@ -243,9 +235,6 @@ export const unmountVolumeDto = describeRoute({
}, },
}, },
}, },
404: {
description: "Volume not found",
},
}, },
}); });
@@ -253,6 +242,7 @@ export const healthCheckResponse = type({
error: "string?", error: "string?",
status: type.valueOf(BACKEND_STATUS), status: type.valueOf(BACKEND_STATUS),
}); });
export type HealthCheckDto = typeof healthCheckResponse.infer;
export const healthCheckDto = describeRoute({ export const healthCheckDto = describeRoute({
description: "Perform a health check on a volume", description: "Perform a health check on a volume",
@@ -283,10 +273,8 @@ const containerSchema = type({
image: "string", image: "string",
}); });
export const listContainersResponse = type({ export const listContainersResponse = containerSchema.array();
containers: containerSchema.array(), export type ListContainersDto = typeof listContainersResponse.infer;
});
export type ListContainersResponseDto = typeof listContainersResponse.infer;
export const getContainersDto = describeRoute({ export const getContainersDto = describeRoute({
description: "Get containers using a volume by name", description: "Get containers using a volume by name",
@@ -322,7 +310,7 @@ export const listFilesResponse = type({
files: fileEntrySchema.array(), files: fileEntrySchema.array(),
path: "string", path: "string",
}); });
export type ListFilesResponseDto = typeof listFilesResponse.infer; export type ListFilesDto = typeof listFilesResponse.infer;
export const listFilesDto = describeRoute({ export const listFilesDto = describeRoute({
description: "List files in a volume directory", description: "List files in a volume directory",
@@ -348,8 +336,5 @@ export const listFilesDto = describeRoute({
}, },
}, },
}, },
404: {
description: "Volume not found",
},
}, },
}); });

View File

@@ -6,7 +6,6 @@ import Docker from "dockerode";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced"; import { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced";
import slugify from "slugify"; import slugify from "slugify";
import { VOLUME_MOUNT_BASE } from "../../core/constants";
import { db } from "../../db/db"; import { db } from "../../db/db";
import { volumesTable } from "../../db/schema"; import { volumesTable } from "../../db/schema";
import { toMessage } from "../../utils/errors"; import { toMessage } from "../../utils/errors";
@@ -51,7 +50,7 @@ const createVolume = async (name: string, backendConfig: BackendConfig) => {
await db await db
.update(volumesTable) .update(volumesTable)
.set({ status, lastError: error ?? null, lastHealthCheck: new Date() }) .set({ status, lastError: error ?? null, lastHealthCheck: Date.now() })
.where(eq(volumesTable.name, slug)); .where(eq(volumesTable.name, slug));
return { volume: created, status: 201 }; return { volume: created, status: 201 };
@@ -85,7 +84,7 @@ const mountVolume = async (name: string) => {
await db await db
.update(volumesTable) .update(volumesTable)
.set({ status, lastError: error ?? null, lastHealthCheck: new Date() }) .set({ status, lastError: error ?? null, lastHealthCheck: Date.now() })
.where(eq(volumesTable.name, name)); .where(eq(volumesTable.name, name));
return { error, status }; return { error, status };
@@ -149,7 +148,7 @@ const updateVolume = async (name: string, volumeData: UpdateVolumeBody) => {
config: volumeData.config, config: volumeData.config,
type: volumeData.config?.backend, type: volumeData.config?.backend,
autoRemount: volumeData.autoRemount, autoRemount: volumeData.autoRemount,
updatedAt: new Date(), updatedAt: Date.now(),
}) })
.where(eq(volumesTable.name, name)) .where(eq(volumesTable.name, name))
.returning(); .returning();
@@ -163,7 +162,7 @@ const updateVolume = async (name: string, volumeData: UpdateVolumeBody) => {
const { error, status } = await backend.mount(); const { error, status } = await backend.mount();
await db await db
.update(volumesTable) .update(volumesTable)
.set({ status, lastError: error ?? null, lastHealthCheck: new Date() }) .set({ status, lastError: error ?? null, lastHealthCheck: Date.now() })
.where(eq(volumesTable.name, name)); .where(eq(volumesTable.name, name));
} }
@@ -178,9 +177,9 @@ const testConnection = async (backendConfig: BackendConfig) => {
name: "test-connection", name: "test-connection",
path: tempDir, path: tempDir,
config: backendConfig, config: backendConfig,
createdAt: new Date(), createdAt: Date.now(),
updatedAt: new Date(), updatedAt: Date.now(),
lastHealthCheck: new Date(), lastHealthCheck: Date.now(),
type: backendConfig.backend, type: backendConfig.backend,
status: "unmounted" as const, status: "unmounted" as const,
lastError: null, lastError: null,
@@ -215,7 +214,7 @@ const checkHealth = async (name: string) => {
await db await db
.update(volumesTable) .update(volumesTable)
.set({ lastHealthCheck: new Date(), status, lastError: error ?? null }) .set({ lastHealthCheck: Date.now(), status, lastError: error ?? null })
.where(eq(volumesTable.name, volume.name)); .where(eq(volumesTable.name, volume.name));
return { status, error }; return { status, error };