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,
GetMeData,
GetMeResponses,
GetMeErrors,
GetStatusData,
GetStatusResponses,
ListVolumesData,
@@ -34,16 +33,13 @@ import type {
GetContainersUsingVolumeErrors,
MountVolumeData,
MountVolumeResponses,
MountVolumeErrors,
UnmountVolumeData,
UnmountVolumeResponses,
UnmountVolumeErrors,
HealthCheckVolumeData,
HealthCheckVolumeResponses,
HealthCheckVolumeErrors,
ListFilesData,
ListFilesResponses,
ListFilesErrors,
ListRepositoriesData,
ListRepositoriesResponses,
CreateRepositoryData,
@@ -130,7 +126,7 @@ export const logout = <ThrowOnError extends boolean = false>(options?: Options<L
* Get current authenticated user
*/
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",
...options,
});
@@ -246,7 +242,7 @@ export const getContainersUsingVolume = <ThrowOnError extends boolean = false>(
* Mount a volume
*/
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",
...options,
});
@@ -258,7 +254,7 @@ export const mountVolume = <ThrowOnError extends boolean = false>(options: Optio
export const unmountVolume = <ThrowOnError extends boolean = false>(
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",
...options,
});
@@ -280,7 +276,7 @@ export const healthCheckVolume = <ThrowOnError extends boolean = false>(
* List files in a volume directory
*/
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",
...options,
});

View File

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

View File

@@ -1,355 +1,298 @@
import * as React from "react"
import * as RechartsPrimitive from "recharts"
// @ts-nocheck
// 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 }
const THEMES = { light: "", dark: ".dark" } as const
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & ({ color?: string; theme?: never } | { color?: never; theme: Record<keyof typeof THEMES, string> });
};
type ChartContextProps = {
config: ChartConfig
}
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null)
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext)
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context
return context;
}
function ChartContainer({
id,
className,
children,
config,
...props
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
config: ChartConfig;
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
}) {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
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",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
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",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
)
const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color);
if (!colorConfig.length) {
return null
}
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
.map(([key, itemConfig]) => {
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`,
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltip = RechartsPrimitive.Tooltip;
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}) {
const { config } = useChart()
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
}) {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string" ? config[label as keyof typeof config]?.label || label : itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (labelFormatter) {
return <div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>;
}
if (!value) {
return null
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
if (!active || !payload?.length) {
return null
}
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot"
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
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",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
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",
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
"[&>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"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--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="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
return (
<div
key={item.dataKey}
className={cn(
"[&>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",
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn("shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)", {
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent": indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
})}
style={
{
"--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="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">{itemConfig?.label || item.name}</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
const ChartLegend = RechartsPrimitive.Legend
const ChartLegend = RechartsPrimitive.Legend;
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}) {
const { config } = useChart()
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}) {
const { config } = useChart();
if (!payload?.length) {
return null
}
if (!payload?.length) {
return null;
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div className={cn("flex items-center justify-center gap-4", verticalAlign === "top" ? "pb-3" : "pt-3", className)}>
{payload
.filter((item) => item.type !== "none")
.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
return (
<div
key={item.value}
className={cn("[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3")}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
const payloadPayload =
"payload" in payload && typeof payload.payload === "object" && payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}
export { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, ChartStyle };

View File

@@ -6,6 +6,6 @@ export type VolumeStatus = Volume["status"];
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);
if (!session.data?.user.id && !isAuthRoute) {
if (!session.data?.user?.id && !isAuthRoute) {
const status = await getStatus();
if (!status.data?.hasUsers) {
throw redirect("/onboarding");
@@ -16,7 +16,7 @@ export const authMiddleware: MiddlewareFunction = async ({ context, request }) =
throw redirect("/login");
}
if (session.data?.user.id) {
if (session.data?.user?.id) {
context.set(appContext, { user: session.data.user, hasUsers: true });
if (isAuthRoute) {

View File

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

View File

@@ -35,7 +35,7 @@ export const HealthchecksCard = ({ volume }: Props) => {
...updateVolumeMutation(),
onSuccess: (d) => {
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) => {

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ import type {
RepositoryStatus,
} from "@ironmount/schemas/restic";
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", {
id: int().primaryKey({ autoIncrement: true }),
@@ -14,9 +14,9 @@ export const volumesTable = sqliteTable("volumes_table", {
type: text().$type<BackendType>().notNull(),
status: text().$type<BackendStatus>().notNull().default("unmounted"),
lastError: text("last_error"),
lastHealthCheck: int("last_health_check", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
createdAt: int("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
updatedAt: int("updated_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
lastHealthCheck: integer("last_health_check", { mode: "number" }).notNull().default(sql`(unixepoch())`),
createdAt: integer("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
updatedAt: integer("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
config: text("config", { mode: "json" }).$type<typeof volumeConfigSchema.inferOut>().notNull(),
autoRemount: int("auto_remount", { mode: "boolean" }).notNull().default(true),
});
@@ -27,8 +27,8 @@ export const usersTable = sqliteTable("users_table", {
id: int().primaryKey({ autoIncrement: true }),
username: text().notNull().unique(),
passwordHash: text("password_hash").notNull(),
createdAt: int("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
updatedAt: int("updated_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
});
export type User = typeof usersTable.$inferSelect;
@@ -38,8 +38,8 @@ export const sessionsTable = sqliteTable("sessions_table", {
userId: int("user_id")
.notNull()
.references(() => usersTable.id, { onDelete: "cascade" }),
expiresAt: int("expires_at", { mode: "timestamp" }).notNull(),
createdAt: int("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
expiresAt: int("expires_at", { mode: "number" }).notNull(),
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
});
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(),
compressionMode: text("compression_mode").$type<CompressionMode>().default("auto"),
status: text().$type<RepositoryStatus>().default("unknown"),
lastChecked: int("last_checked", { mode: "timestamp" }),
lastChecked: int("last_checked", { mode: "number" }),
lastError: text("last_error"),
createdAt: int("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
updatedAt: int("updated_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
});
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([]),
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">(),
lastBackupError: text("last_backup_error"),
nextBackupAt: int("next_backup_at", { mode: "timestamp" }),
createdAt: int("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
updatedAt: int("updated_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
nextBackupAt: int("next_backup_at", { mode: "number" }),
createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`),
});
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,
registerBodySchema,
registerDto,
type GetMeDto,
type GetStatusDto,
type LoginDto,
type LogoutDto,
type RegisterDto,
} from "./auth.dto";
import { authService } from "./auth.service";
import { toMessage } from "../../utils/errors";
const COOKIE_NAME = "session_id";
const COOKIE_OPTIONS = {
@@ -33,9 +39,12 @@ export const authController = new Hono()
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) {
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) => {
@@ -46,15 +55,16 @@ export const authController = new Hono()
setCookie(c, COOKIE_NAME, sessionId, {
...COOKIE_OPTIONS,
expires: expiresAt,
expires: new Date(expiresAt),
});
return c.json({
return c.json<LoginDto>({
success: true,
message: "Login successful",
user: { id: user.id, username: user.username },
});
} 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) => {
@@ -65,13 +75,13 @@ export const authController = new Hono()
deleteCookie(c, COOKIE_NAME, COOKIE_OPTIONS);
}
return c.json({ message: "Logout successful" });
return c.json<LogoutDto>({ success: true });
})
.get("/me", getMeDto, async (c) => {
const sessionId = getCookie(c, COOKIE_NAME);
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);
@@ -81,11 +91,13 @@ export const authController = new Hono()
return c.json({ message: "Not authenticated" }, 401);
}
return c.json({
return c.json<GetMeDto>({
success: true,
user: session.user,
message: "Authenticated",
});
})
.get("/status", getStatusDto, async (c) => {
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({
message: "string",
success: "boolean",
user: type({
id: "string",
id: "number",
username: "string",
}),
}).optional(),
});
export const loginDto = describeRoute({
@@ -39,6 +40,8 @@ export const loginDto = describeRoute({
},
});
export type LoginDto = typeof loginResponseSchema.infer;
export const registerDto = describeRoute({
description: "Register a new user",
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({
description: "Logout current user",
operationId: "logout",
@@ -67,13 +76,15 @@ export const logoutDto = describeRoute({
description: "Logout successful",
content: {
"application/json": {
schema: resolver(type({ message: "string" })),
schema: resolver(logoutResponseSchema),
},
},
},
},
});
export type LogoutDto = typeof logoutResponseSchema.infer;
export const getMeDto = describeRoute({
description: "Get current authenticated user",
operationId: "getMe",
@@ -87,12 +98,11 @@ export const getMeDto = describeRoute({
},
},
},
401: {
description: "Not authenticated",
},
},
});
export type GetMeDto = typeof loginResponseSchema.infer;
const statusResponseSchema = type({
hasUsers: "boolean",
});
@@ -113,5 +123,7 @@ export const getStatusDto = describeRoute({
},
});
export type GetStatusDto = typeof statusResponseSchema.infer;
export type LoginBody = typeof loginBodySchema.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 { sessionsTable, usersTable } from "../../db/schema";
import { logger } from "../../utils/logger";
@@ -30,7 +30,7 @@ export class AuthService {
logger.info(`User registered: ${username}`);
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({
id: sessionId,
@@ -58,7 +58,7 @@ export class AuthService {
}
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({
id: sessionId,
@@ -100,7 +100,7 @@ export class AuthService {
return null;
}
if (session.session.expiresAt < new Date()) {
if (session.session.expiresAt < Date.now()) {
await db.delete(sessionsTable).where(eq(sessionsTable.id, sessionId));
return null;
}
@@ -121,7 +121,7 @@ export class AuthService {
* Clean up expired sessions
*/
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) {
logger.info(`Cleaned up ${result.length} expired sessions`);
}

View File

@@ -10,6 +10,13 @@ import {
runBackupNowDto,
updateBackupScheduleBody,
updateBackupScheduleDto,
type CreateBackupScheduleDto,
type DeleteBackupScheduleDto,
type GetBackupScheduleDto,
type GetBackupScheduleForVolumeResponseDto,
type ListBackupSchedulesResponseDto,
type RunBackupNowDto,
type UpdateBackupScheduleDto,
} from "./backups.dto";
import { backupsService } from "./backups.service";
@@ -17,27 +24,27 @@ export const backupScheduleController = new Hono()
.get("/", listBackupSchedulesDto, async (c) => {
const schedules = await backupsService.listSchedules();
return c.json({ schedules }, 200);
return c.json<ListBackupSchedulesResponseDto>(schedules, 200);
})
.get("/:scheduleId", getBackupScheduleDto, async (c) => {
const scheduleId = c.req.param("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) => {
const volumeId = c.req.param("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) => {
const body = c.req.valid("json");
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) => {
const scheduleId = c.req.param("scheduleId");
@@ -45,14 +52,14 @@ export const backupScheduleController = new Hono()
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) => {
const scheduleId = c.req.param("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) => {
const scheduleId = c.req.param("scheduleId");
@@ -61,11 +68,5 @@ export const backupScheduleController = new Hono()
console.error("Backup execution failed:", error);
});
return c.json(
{
message: "Backup started",
backupStarted: true,
},
200,
);
return c.json<RunBackupNowDto>({ success: true }, 200);
});

View File

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

View File

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

View File

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

View File

@@ -5,11 +5,12 @@ import {
createRepositoryDto,
deleteRepositoryDto,
getRepositoryDto,
type GetRepositoryResponseDto,
type ListRepositoriesResponseDto,
listRepositoriesDto,
listSnapshotsDto,
type ListSnapshotsResponseDto,
type DeleteRepositoryDto,
type GetRepositoryDto,
type ListRepositoriesDto,
type ListSnapshotsDto,
} from "./repositories.dto";
import { repositoriesService } from "./repositories.service";
@@ -17,16 +18,7 @@ export const repositoriesController = new Hono()
.get("/", listRepositoriesDto, async (c) => {
const repositories = await repositoriesService.listRepositories();
const response = {
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);
return c.json<ListRepositoriesDto>(repositories, 200);
})
.post("/", createRepositoryDto, validator("json", createRepositoryBody), async (c) => {
const body = c.req.valid("json");
@@ -38,22 +30,13 @@ export const repositoriesController = new Hono()
const { name } = c.req.param();
const res = await repositoriesService.getRepository(name);
const response = {
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);
return c.json<GetRepositoryDto>(res.repository, 200);
})
.delete("/:name", deleteRepositoryDto, async (c) => {
const { name } = c.req.param();
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) => {
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");
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
*/
export const listRepositoriesResponse = type({
repositories: repositorySchema.array(),
});
export type ListRepositoriesResponseDto = typeof listRepositoriesResponse.infer;
export const listRepositoriesResponse = repositorySchema.array();
export type ListRepositoriesDto = typeof listRepositoriesResponse.infer;
export const listRepositoriesDto = describeRoute({
description: "List all repositories",
@@ -65,6 +63,8 @@ export const createRepositoryResponse = type({
}),
});
export type CreateRepositoryDto = typeof createRepositoryResponse.infer;
export const createRepositoryDto = describeRoute({
description: "Create a new restic repository",
operationId: "createRepository",
@@ -84,10 +84,8 @@ export const createRepositoryDto = describeRoute({
/**
* Get a single repository
*/
export const getRepositoryResponse = type({
repository: repositorySchema,
});
export type GetRepositoryResponseDto = typeof getRepositoryResponse.infer;
export const getRepositoryResponse = repositorySchema;
export type GetRepositoryDto = typeof getRepositoryResponse.infer;
export const getRepositoryDto = describeRoute({
description: "Get a single repository by name",
@@ -112,6 +110,8 @@ export const deleteRepositoryResponse = type({
message: "string",
});
export type DeleteRepositoryDto = typeof deleteRepositoryResponse.infer;
export const deleteRepositoryDto = describeRoute({
description: "Delete a repository",
tags: ["Repositories"],
@@ -143,7 +143,7 @@ const listSnapshotsResponse = type({
snapshots: snapshotSchema.array(),
});
export type ListSnapshotsResponseDto = typeof listSnapshotsResponse.infer;
export type ListSnapshotsDto = typeof listSnapshotsResponse.infer;
export const listSnapshotsDto = describeRoute({
description: "List all snapshots in a repository",

View File

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

View File

@@ -4,22 +4,23 @@ import {
createVolumeBody,
createVolumeDto,
deleteVolumeDto,
type GetVolumeResponseDto,
getContainersDto,
getVolumeDto,
healthCheckDto,
type ListContainersResponseDto,
type ListFilesResponseDto,
type ListVolumesResponseDto,
type ListVolumesDto,
listFilesDto,
listVolumesDto,
mountVolumeDto,
testConnectionBody,
testConnectionDto,
type UpdateVolumeResponseDto,
unmountVolumeDto,
updateVolumeBody,
updateVolumeDto,
type CreateVolumeDto,
type GetVolumeDto,
type ListContainersDto,
type UpdateVolumeDto,
type ListFilesDto,
} from "./volume.dto";
import { volumeService } from "./volume.service";
import { getVolumePath } from "./helpers";
@@ -32,19 +33,21 @@ export const volumeController = new Hono()
volumes: volumes.map((volume) => ({
path: getVolumePath(volume.name),
...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) => {
const body = c.req.valid("json");
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) => {
const body = c.req.valid("json");
@@ -66,28 +69,21 @@ export const volumeController = new Hono()
volume: {
...res.volume,
path: getVolumePath(res.volume.name),
createdAt: res.volume.createdAt.getTime(),
updatedAt: res.volume.updatedAt.getTime(),
lastHealthCheck: res.volume.lastHealthCheck.getTime(),
},
statfs: {
total: res.statfs.total ?? 0,
used: res.statfs.used ?? 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) => {
const { name } = c.req.param();
const { containers } = await volumeService.getContainersUsingVolume(name);
const response = {
containers,
} satisfies ListContainersResponseDto;
return c.json(response, 200);
return c.json<ListContainersDto>(containers, 200);
})
.put("/:name", updateVolumeDto, validator("json", updateVolumeBody), async (c) => {
const { name } = c.req.param();
@@ -95,17 +91,11 @@ export const volumeController = new Hono()
const res = await volumeService.updateVolume(name, body);
const response = {
message: "Volume updated",
volume: {
...res.volume,
path: getVolumePath(res.volume.name),
createdAt: res.volume.createdAt.getTime(),
updatedAt: res.volume.updatedAt.getTime(),
lastHealthCheck: res.volume.lastHealthCheck.getTime(),
},
} satisfies UpdateVolumeResponseDto;
...res.volume,
path: getVolumePath(res.volume.name),
};
return c.json(response, 200);
return c.json<UpdateVolumeDto>(response, 200);
})
.post("/:name/mount", mountVolumeDto, async (c) => {
const { name } = c.req.param();
@@ -133,9 +123,9 @@ export const volumeController = new Hono()
const response = {
files: result.files,
path: result.path,
} satisfies ListFilesResponseDto;
};
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({
volumes: volumeSchema.array(),
});
export type ListVolumesResponseDto = typeof listVolumesResponse.infer;
export type ListVolumesDto = typeof listVolumesResponse.infer;
export const listVolumesDto = describeRoute({
description: "List all volumes",
@@ -50,13 +50,8 @@ export const createVolumeBody = type({
config: volumeConfigSchema,
});
export const createVolumeResponse = type({
message: "string",
volume: type({
name: "string",
path: "string",
}),
});
export const createVolumeResponse = volumeSchema;
export type CreateVolumeDto = typeof createVolumeResponse.infer;
export const createVolumeDto = describeRoute({
description: "Create a new volume",
@@ -80,6 +75,7 @@ export const createVolumeDto = describeRoute({
export const deleteVolumeResponse = type({
message: "string",
});
export type DeleteVolumeDto = typeof deleteVolumeResponse.infer;
export const deleteVolumeDto = describeRoute({
description: "Delete a volume",
@@ -108,7 +104,7 @@ const getVolumeResponse = type({
statfs: statfsSchema,
});
export type GetVolumeResponseDto = typeof getVolumeResponse.infer;
export type GetVolumeDto = typeof getVolumeResponse.infer;
/**
* Get a volume
*/
@@ -141,10 +137,8 @@ export const updateVolumeBody = type({
export type UpdateVolumeBody = typeof updateVolumeBody.infer;
export const updateVolumeResponse = type({
message: "string",
volume: volumeSchema,
});
export const updateVolumeResponse = volumeSchema;
export type UpdateVolumeDto = typeof updateVolumeResponse.infer;
export const updateVolumeDto = describeRoute({
description: "Update a volume's configuration",
@@ -165,8 +159,6 @@ export const updateVolumeDto = describeRoute({
},
});
export type UpdateVolumeResponseDto = typeof updateVolumeResponse.infer;
/**
* Test connection
*/
@@ -178,6 +170,7 @@ export const testConnectionResponse = type({
success: "boolean",
message: "string",
});
export type TestConnectionDto = typeof testConnectionResponse.infer;
export const testConnectionDto = describeRoute({
description: "Test connection to backend",
@@ -202,6 +195,7 @@ export const mountVolumeResponse = type({
error: "string?",
status: type.valueOf(BACKEND_STATUS),
});
export type MountVolumeDto = typeof mountVolumeResponse.infer;
export const mountVolumeDto = describeRoute({
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?",
status: type.valueOf(BACKEND_STATUS),
});
export type UnmountVolumeDto = typeof unmountVolumeResponse.infer;
export const unmountVolumeDto = describeRoute({
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?",
status: type.valueOf(BACKEND_STATUS),
});
export type HealthCheckDto = typeof healthCheckResponse.infer;
export const healthCheckDto = describeRoute({
description: "Perform a health check on a volume",
@@ -283,10 +273,8 @@ const containerSchema = type({
image: "string",
});
export const listContainersResponse = type({
containers: containerSchema.array(),
});
export type ListContainersResponseDto = typeof listContainersResponse.infer;
export const listContainersResponse = containerSchema.array();
export type ListContainersDto = typeof listContainersResponse.infer;
export const getContainersDto = describeRoute({
description: "Get containers using a volume by name",
@@ -322,7 +310,7 @@ export const listFilesResponse = type({
files: fileEntrySchema.array(),
path: "string",
});
export type ListFilesResponseDto = typeof listFilesResponse.infer;
export type ListFilesDto = typeof listFilesResponse.infer;
export const listFilesDto = describeRoute({
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 { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced";
import slugify from "slugify";
import { VOLUME_MOUNT_BASE } from "../../core/constants";
import { db } from "../../db/db";
import { volumesTable } from "../../db/schema";
import { toMessage } from "../../utils/errors";
@@ -51,7 +50,7 @@ const createVolume = async (name: string, backendConfig: BackendConfig) => {
await db
.update(volumesTable)
.set({ status, lastError: error ?? null, lastHealthCheck: new Date() })
.set({ status, lastError: error ?? null, lastHealthCheck: Date.now() })
.where(eq(volumesTable.name, slug));
return { volume: created, status: 201 };
@@ -85,7 +84,7 @@ const mountVolume = async (name: string) => {
await db
.update(volumesTable)
.set({ status, lastError: error ?? null, lastHealthCheck: new Date() })
.set({ status, lastError: error ?? null, lastHealthCheck: Date.now() })
.where(eq(volumesTable.name, name));
return { error, status };
@@ -149,7 +148,7 @@ const updateVolume = async (name: string, volumeData: UpdateVolumeBody) => {
config: volumeData.config,
type: volumeData.config?.backend,
autoRemount: volumeData.autoRemount,
updatedAt: new Date(),
updatedAt: Date.now(),
})
.where(eq(volumesTable.name, name))
.returning();
@@ -163,7 +162,7 @@ const updateVolume = async (name: string, volumeData: UpdateVolumeBody) => {
const { error, status } = await backend.mount();
await db
.update(volumesTable)
.set({ status, lastError: error ?? null, lastHealthCheck: new Date() })
.set({ status, lastError: error ?? null, lastHealthCheck: Date.now() })
.where(eq(volumesTable.name, name));
}
@@ -178,9 +177,9 @@ const testConnection = async (backendConfig: BackendConfig) => {
name: "test-connection",
path: tempDir,
config: backendConfig,
createdAt: new Date(),
updatedAt: new Date(),
lastHealthCheck: new Date(),
createdAt: Date.now(),
updatedAt: Date.now(),
lastHealthCheck: Date.now(),
type: backendConfig.backend,
status: "unmounted" as const,
lastError: null,
@@ -215,7 +214,7 @@ const checkHealth = async (name: string) => {
await db
.update(volumesTable)
.set({ lastHealthCheck: new Date(), status, lastError: error ?? null })
.set({ lastHealthCheck: Date.now(), status, lastError: error ?? null })
.where(eq(volumesTable.name, volume.name));
return { status, error };