From 18115b374c873fd399e628363df0f2586b270171 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Sat, 1 Nov 2025 17:09:43 +0100 Subject: [PATCH] feat(frontend): backup jobs page --- apps/client/app/api-client/types.gen.ts | 72 ++++++- apps/client/app/components/app-sidebar.tsx | 7 +- .../app/components/create-repository-form.tsx | 17 -- apps/client/app/components/ui/badge.tsx | 36 ++++ apps/client/app/lib/breadcrumbs.ts | 17 ++ .../app/modules/details/tabs/backups.tsx | 4 +- .../components/restore-snapshot-dialog.tsx | 1 - apps/client/app/routes.ts | 2 + apps/client/app/routes/backup-jobs.tsx | 112 +++++++++++ apps/client/app/routes/schedule-details.tsx | 175 ++++++++++++++++++ apps/server/src/db/schema.ts | 30 ++- .../server/src/modules/backups/backups.dto.ts | 9 +- .../src/modules/backups/backups.service.ts | 4 + .../modules/repositories/repositories.dto.ts | 2 +- apps/server/src/modules/volumes/volume.dto.ts | 2 +- apps/server/src/utils/restic.ts | 2 +- docker-compose.yml | 1 + openapi-ts.config.ts | 2 +- packages/schemas/src/restic.ts | 2 +- 19 files changed, 459 insertions(+), 38 deletions(-) create mode 100644 apps/client/app/components/ui/badge.tsx create mode 100644 apps/client/app/routes/backup-jobs.tsx create mode 100644 apps/client/app/routes/schedule-details.tsx diff --git a/apps/client/app/api-client/types.gen.ts b/apps/client/app/api-client/types.gen.ts index 7702039..707e063 100644 --- a/apps/client/app/api-client/types.gen.ts +++ b/apps/client/app/api-client/types.gen.ts @@ -670,7 +670,7 @@ export type ListRepositoriesResponses = { } | { backend: "local"; - path: string; + name: string; }; createdAt: number; id: string; @@ -697,7 +697,7 @@ export type CreateRepositoryData = { } | { backend: "local"; - path: string; + name: string; }; name: string; compressionMode?: "auto" | "better" | "fastest" | "max" | "off"; @@ -767,7 +767,7 @@ export type GetRepositoryResponses = { } | { backend: "local"; - path: string; + name: string; }; createdAt: number; id: string; @@ -856,7 +856,6 @@ export type RestoreSnapshotData = { snapshotId: string; exclude?: Array; include?: Array; - path?: string; }; path: { name: string; @@ -1018,6 +1017,29 @@ export type GetBackupScheduleResponses = { lastBackupError: string | null; lastBackupStatus: "error" | "success" | null; nextBackupAt: number | null; + repository: { + compressionMode: "auto" | "better" | "fastest" | "max" | "off" | null; + config: + | { + accessKeyId: string; + backend: "s3"; + bucket: string; + endpoint: string; + secretAccessKey: string; + } + | { + backend: "local"; + name: string; + }; + createdAt: number; + id: string; + lastChecked: number | null; + lastError: string | null; + name: string; + status: "error" | "healthy" | "unknown" | null; + type: "local" | "s3"; + updatedAt: number; + }; repositoryId: string; retentionPolicy: { keepDaily?: number; @@ -1029,6 +1051,48 @@ export type GetBackupScheduleResponses = { keepYearly?: number; } | null; updatedAt: number; + 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; + }; volumeId: number; }; }; diff --git a/apps/client/app/components/app-sidebar.tsx b/apps/client/app/components/app-sidebar.tsx index d0fbd5b..7107db4 100644 --- a/apps/client/app/components/app-sidebar.tsx +++ b/apps/client/app/components/app-sidebar.tsx @@ -1,4 +1,4 @@ -import { Database, HardDrive, Mountain } from "lucide-react"; +import { CalendarClock, Database, HardDrive, Mountain } from "lucide-react"; import { Link, NavLink } from "react-router"; import { Sidebar, @@ -25,6 +25,11 @@ const items = [ url: "/repositories", icon: Database, }, + { + title: "Backup jobs", + url: "/backup-jobs", + icon: CalendarClock, + }, ]; export function AppSidebar() { diff --git a/apps/client/app/components/create-repository-form.tsx b/apps/client/app/components/create-repository-form.tsx index 25c472f..3153af1 100644 --- a/apps/client/app/components/create-repository-form.tsx +++ b/apps/client/app/components/create-repository-form.tsx @@ -134,23 +134,6 @@ export const CreateRepositoryForm = ({ )} /> - {watchedBackend === "local" && ( - ( - - Path - - - - Local filesystem path where the repository will be stored. - - - )} - /> - )} - {watchedBackend === "s3" && ( <> svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<"span"> & VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span"; + + return ; +} + +export { Badge, badgeVariants }; diff --git a/apps/client/app/lib/breadcrumbs.ts b/apps/client/app/lib/breadcrumbs.ts index 8c94f25..d3b3e7d 100644 --- a/apps/client/app/lib/breadcrumbs.ts +++ b/apps/client/app/lib/breadcrumbs.ts @@ -42,6 +42,23 @@ export function generateBreadcrumbs(pathname: string, params: Record { To schedule automated backups, you need to create a repository first. Repositories are secure storage locations where your backups will be stored.

- + + + + ); + } + + return ( +
+
+ {schedules.map((schedule) => ( + + + +
+
+ + Volume #{schedule.volumeId} +
+ + {schedule.enabled ? "Active" : "Paused"} + +
+ + + {schedule.repositoryId} + +
+ +
+
+ Schedule + {schedule.cronExpression} +
+
+ Last backup + + {schedule.lastBackupAt ? new Date(schedule.lastBackupAt).toLocaleDateString() : "Never"} + +
+
+ Next backup + + {schedule.nextBackupAt ? new Date(schedule.nextBackupAt).toLocaleDateString() : "N/A"} + +
+ {schedule.lastBackupStatus && ( +
+ Status + + {schedule.lastBackupStatus} + +
+ )} +
+
+
+ + ))} +
+
+ ); +} diff --git a/apps/client/app/routes/schedule-details.tsx b/apps/client/app/routes/schedule-details.tsx new file mode 100644 index 0000000..102e644 --- /dev/null +++ b/apps/client/app/routes/schedule-details.tsx @@ -0,0 +1,175 @@ +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { Link, useParams } from "react-router"; +import { toast } from "sonner"; +import { Button } from "~/components/ui/button"; +import { Card, CardContent } from "~/components/ui/card"; +import { + upsertBackupScheduleMutation, + getBackupScheduleOptions, + runBackupNowMutation, +} from "~/api-client/@tanstack/react-query.gen"; +import { parseError } from "~/lib/errors"; +import { CreateScheduleForm, type BackupScheduleFormValues } from "~/modules/details/components/create-schedule-form"; +import { ScheduleSummary } from "~/modules/details/components/schedule-summary"; + +const getCronExpression = (frequency: string, dailyTime?: string, weeklyDay?: string): string => { + if (frequency === "hourly") { + return "0 * * * *"; + } + + if (!dailyTime) { + dailyTime = "02:00"; + } + + const [hours, minutes] = dailyTime.split(":"); + + if (frequency === "daily") { + return `${minutes} ${hours} * * *`; + } + + return `${minutes} ${hours} * * ${weeklyDay ?? "0"}`; +}; + +export default function ScheduleDetailsPage() { + const { scheduleId } = useParams<{ scheduleId: string }>(); + const queryClient = useQueryClient(); + const [isEditMode, setIsEditMode] = useState(false); + + const { data: schedule, isLoading: loadingSchedule } = useQuery({ + ...getBackupScheduleOptions({ + path: { scheduleId: scheduleId || "" }, + }), + }); + + console.log("Schedule Details:", schedule); + + const upsertSchedule = useMutation({ + ...upsertBackupScheduleMutation(), + onSuccess: () => { + toast.success("Backup schedule saved successfully"); + queryClient.invalidateQueries({ queryKey: ["listBackupSchedules"] }); + queryClient.invalidateQueries({ queryKey: ["getBackupSchedule", scheduleId] }); + setIsEditMode(false); + }, + onError: (error) => { + toast.error("Failed to save backup schedule", { + description: parseError(error)?.message, + }); + }, + }); + + const runBackupNow = useMutation({ + ...runBackupNowMutation(), + onSuccess: () => { + toast.success("Backup started successfully"); + queryClient.invalidateQueries({ queryKey: ["getBackupSchedule", scheduleId] }); + }, + onError: (error) => { + toast.error("Failed to start backup", { + description: parseError(error)?.message, + }); + }, + }); + + const handleSubmit = (formValues: BackupScheduleFormValues) => { + if (!schedule) return; + + const cronExpression = getCronExpression(formValues.frequency, formValues.dailyTime, formValues.weeklyDay); + + const retentionPolicy: Record = {}; + if (formValues.keepLast) retentionPolicy.keepLast = formValues.keepLast; + if (formValues.keepHourly) retentionPolicy.keepHourly = formValues.keepHourly; + if (formValues.keepDaily) retentionPolicy.keepDaily = formValues.keepDaily; + if (formValues.keepWeekly) retentionPolicy.keepWeekly = formValues.keepWeekly; + if (formValues.keepMonthly) retentionPolicy.keepMonthly = formValues.keepMonthly; + if (formValues.keepYearly) retentionPolicy.keepYearly = formValues.keepYearly; + + upsertSchedule.mutate({ + body: { + volumeId: schedule.volumeId, + repositoryId: formValues.repositoryId, + enabled: schedule.enabled, + cronExpression, + retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined, + }, + }); + }; + + const handleToggleEnabled = (enabled: boolean) => { + if (!schedule) return; + + upsertSchedule.mutate({ + body: { + volumeId: schedule.volumeId, + repositoryId: schedule.repositoryId, + enabled, + cronExpression: schedule.cronExpression, + retentionPolicy: schedule.retentionPolicy || undefined, + }, + }); + }; + + const handleRunBackupNow = () => { + if (!schedule) return; + + runBackupNow.mutate({ + path: { + scheduleId: schedule.id.toString(), + }, + }); + }; + + if (loadingSchedule && !schedule) { + return ( +
+ + +

Loading...

+
+
+
+ ); + } + + if (!schedule) { + return ( +
+ + +

Schedule not found

+ +
+
+
+ ); + } + + if (!isEditMode) { + return ( +
+ +
+ ); + } + + return ( +
+
+ +
+ +
+ ); +} diff --git a/apps/server/src/db/schema.ts b/apps/server/src/db/schema.ts index 4006fa1..d3d0f4c 100644 --- a/apps/server/src/db/schema.ts +++ b/apps/server/src/db/schema.ts @@ -5,9 +5,12 @@ import type { repositoryConfigSchema, RepositoryStatus, } from "@ironmount/schemas/restic"; -import { sql } from "drizzle-orm"; +import { relations, sql } from "drizzle-orm"; import { int, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +/** + * Volumes Table + */ export const volumesTable = sqliteTable("volumes_table", { id: int().primaryKey({ autoIncrement: true }), name: text().notNull().unique(), @@ -20,9 +23,11 @@ export const volumesTable = sqliteTable("volumes_table", { config: text("config", { mode: "json" }).$type().notNull(), autoRemount: int("auto_remount", { mode: "boolean" }).notNull().default(true), }); - export type Volume = typeof volumesTable.$inferSelect; +/** + * Users Table + */ export const usersTable = sqliteTable("users_table", { id: int().primaryKey({ autoIncrement: true }), username: text().notNull().unique(), @@ -30,9 +35,7 @@ export const usersTable = sqliteTable("users_table", { 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; - export const sessionsTable = sqliteTable("sessions_table", { id: text().primaryKey(), userId: int("user_id") @@ -41,9 +44,11 @@ export const sessionsTable = sqliteTable("sessions_table", { expiresAt: int("expires_at", { mode: "number" }).notNull(), createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`), }); - export type Session = typeof sessionsTable.$inferSelect; +/** + * Repositories Table + */ export const repositoriesTable = sqliteTable("repositories_table", { id: text().primaryKey(), name: text().notNull().unique(), @@ -56,9 +61,11 @@ export const repositoriesTable = sqliteTable("repositories_table", { 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; +/** + * Backup Schedules Table + */ export const backupSchedulesTable = sqliteTable("backup_schedules_table", { id: int().primaryKey({ autoIncrement: true }), volumeId: int("volume_id") @@ -88,5 +95,14 @@ export const backupSchedulesTable = sqliteTable("backup_schedules_table", { createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`), updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`), }); - +export const backupScheduleRelations = relations(backupSchedulesTable, ({ one }) => ({ + volume: one(volumesTable, { + fields: [backupSchedulesTable.volumeId], + references: [volumesTable.id], + }), + repository: one(repositoriesTable, { + fields: [backupSchedulesTable.repositoryId], + references: [repositoriesTable.id], + }), +})); export type BackupSchedule = typeof backupSchedulesTable.$inferSelect; diff --git a/apps/server/src/modules/backups/backups.dto.ts b/apps/server/src/modules/backups/backups.dto.ts index 33fda1e..a7caca3 100644 --- a/apps/server/src/modules/backups/backups.dto.ts +++ b/apps/server/src/modules/backups/backups.dto.ts @@ -1,5 +1,7 @@ import { type } from "arktype"; import { describeRoute, resolver } from "hono-openapi"; +import { volumeSchema } from "../volumes/volume.dto"; +import { repositorySchema } from "../repositories/repositories.dto"; const retentionPolicySchema = type({ keepLast: "number?", @@ -58,7 +60,12 @@ export const listBackupSchedulesDto = describeRoute({ /** * Get a single backup schedule */ -export const getBackupScheduleResponse = backupScheduleSchema; +export const getBackupScheduleResponse = backupScheduleSchema.and( + type({ + volume: volumeSchema, + repository: repositorySchema, + }), +); export type GetBackupScheduleDto = typeof getBackupScheduleResponse.infer; diff --git a/apps/server/src/modules/backups/backups.service.ts b/apps/server/src/modules/backups/backups.service.ts index cfeda74..907cbb3 100644 --- a/apps/server/src/modules/backups/backups.service.ts +++ b/apps/server/src/modules/backups/backups.service.ts @@ -34,6 +34,10 @@ const listSchedules = async () => { const getSchedule = async (scheduleId: number) => { const schedule = await db.query.backupSchedulesTable.findFirst({ where: eq(volumesTable.id, scheduleId), + with: { + volume: true, + repository: true, + }, }); if (!schedule) { diff --git a/apps/server/src/modules/repositories/repositories.dto.ts b/apps/server/src/modules/repositories/repositories.dto.ts index 61d6145..4e47160 100644 --- a/apps/server/src/modules/repositories/repositories.dto.ts +++ b/apps/server/src/modules/repositories/repositories.dto.ts @@ -7,7 +7,7 @@ import { import { type } from "arktype"; import { describeRoute, resolver } from "hono-openapi"; -const repositorySchema = type({ +export const repositorySchema = type({ id: "string", name: "string", type: type.valueOf(REPOSITORY_BACKENDS), diff --git a/apps/server/src/modules/volumes/volume.dto.ts b/apps/server/src/modules/volumes/volume.dto.ts index 6aeb550..bb345f9 100644 --- a/apps/server/src/modules/volumes/volume.dto.ts +++ b/apps/server/src/modules/volumes/volume.dto.ts @@ -2,7 +2,7 @@ import { BACKEND_STATUS, BACKEND_TYPES, volumeConfigSchema } from "@ironmount/sc import { type } from "arktype"; import { describeRoute, resolver } from "hono-openapi"; -const volumeSchema = type({ +export const volumeSchema = type({ id: "number", name: "string", path: "string", diff --git a/apps/server/src/utils/restic.ts b/apps/server/src/utils/restic.ts index 0c39fa6..5a0c7cd 100644 --- a/apps/server/src/utils/restic.ts +++ b/apps/server/src/utils/restic.ts @@ -69,7 +69,7 @@ const ensurePassfile = async () => { const buildRepoUrl = (config: RepositoryConfig): string => { switch (config.backend) { case "local": - return config.path; + return `/repositories/${config.name}`; case "s3": return `s3:${config.endpoint}/${config.bucket}`; default: { diff --git a/docker-compose.yml b/docker-compose.yml index cf7b561..5e8734c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,7 @@ services: - /var/run/docker.sock:/var/run/docker.sock - /run/docker/plugins:/run/docker/plugins - ./data/volumes/:/volumes + - ./data/repositories/:/repositories # - /proc:/host/proc:ro - ./data:/data diff --git a/openapi-ts.config.ts b/openapi-ts.config.ts index 5331d2c..d0c26b1 100644 --- a/openapi-ts.config.ts +++ b/openapi-ts.config.ts @@ -1,7 +1,7 @@ import { defaultPlugins, defineConfig } from "@hey-api/openapi-ts"; export default defineConfig({ - input: "http://192.168.2.42:4096/api/v1/openapi.json", + input: "http://localhost:4096/api/v1/openapi.json", output: { path: "./apps/client/app/api-client", format: "biome", diff --git a/packages/schemas/src/restic.ts b/packages/schemas/src/restic.ts index 2b5ba7a..0d6fb92 100644 --- a/packages/schemas/src/restic.ts +++ b/packages/schemas/src/restic.ts @@ -17,7 +17,7 @@ export const s3RepositoryConfigSchema = type({ export const localRepositoryConfigSchema = type({ backend: "'local'", - path: "string", + name: "string", }); export const repositoryConfigSchema = s3RepositoryConfigSchema.or(localRepositoryConfigSchema);