feat(frontend): backup jobs page

This commit is contained in:
Nicolas Meienberger
2025-11-01 17:09:43 +01:00
parent d81f3653ec
commit 18115b374c
19 changed files with 459 additions and 38 deletions

View File

@@ -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<string>;
include?: Array<string>;
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;
};
};

View File

@@ -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() {

View File

@@ -134,23 +134,6 @@ export const CreateRepositoryForm = ({
)}
/>
{watchedBackend === "local" && (
<FormField
control={form.control}
name="path"
render={({ field }) => (
<FormItem>
<FormLabel>Path</FormLabel>
<FormControl>
<Input placeholder="/path/to/repository" {...field} />
</FormControl>
<FormDescription>Local filesystem path where the repository will be stored.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{watchedBackend === "s3" && (
<>
<FormField

View File

@@ -0,0 +1,36 @@
import type * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "~/lib/utils";
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>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<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span";
return <Comp data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };

View File

@@ -42,6 +42,23 @@ export function generateBreadcrumbs(pathname: string, params: Record<string, str
return breadcrumbs;
}
if (pathname.startsWith("/backup-jobs")) {
breadcrumbs.push({
label: "Backup jobs",
href: "/backup-jobs",
isCurrentPage: pathname === "/backup-jobs",
});
if (pathname.startsWith("/backup-jobs/") && params.scheduleId) {
breadcrumbs.push({
label: `Schedule #${params.scheduleId}`,
isCurrentPage: true,
});
}
return breadcrumbs;
}
breadcrumbs.push({
label: "Volumes",
href: "/volumes",

View File

@@ -133,8 +133,8 @@ export const VolumeBackupsTabContent = ({ volume }: Props) => {
To schedule automated backups, you need to create a repository first. Repositories are secure storage
locations where your backups will be stored.
</p>
<Button asChild>
<Link to="/repositories">
<Button>
<Link to="/repositories" className="flex items-center">
<Plus className="h-4 w-4 mr-2" />
Create a repository
</Link>

View File

@@ -56,7 +56,6 @@ export const RestoreSnapshotDialog = ({ name, snapshotId }: Props) => {
path: { name },
body: {
snapshotId,
path: values.path || undefined,
include: include && include.length > 0 ? include : undefined,
exclude: exclude && exclude.length > 0 ? exclude : undefined,
},

View File

@@ -7,6 +7,8 @@ export default [
route("/", "./routes/root.tsx"),
route("volumes", "./routes/home.tsx"),
route("volumes/:name", "./routes/details.tsx"),
route("backup-jobs", "./routes/backup-jobs.tsx"),
route("backup-jobs/:scheduleId", "./routes/schedule-details.tsx"),
route("repositories", "./modules/repositories/routes/repositories.tsx"),
route("repositories/:name", "./modules/repositories/routes/repository-details.tsx"),
route("repositories/:name/:snapshotId", "./modules/repositories/routes/snapshot-details.tsx"),

View File

@@ -0,0 +1,112 @@
import { useQuery } from "@tanstack/react-query";
import { CalendarClock, Database, HardDrive, Plus } from "lucide-react";
import { Link } from "react-router";
import { listBackupSchedulesOptions } from "~/api-client/@tanstack/react-query.gen";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
export default function BackupJobsPage() {
const { data: schedules, isLoading } = useQuery({
...listBackupSchedulesOptions(),
refetchInterval: 10000,
refetchOnWindowFocus: true,
});
if (isLoading) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-muted-foreground">Loading backup schedules...</p>
</div>
);
}
if (!schedules || schedules.length === 0) {
return (
<Card>
<CardContent className="py-16">
<div className="flex flex-col items-center justify-center text-center">
<div className="relative mb-6">
<div className="absolute inset-0 animate-pulse">
<div className="w-24 h-24 rounded-full bg-primary/10 blur-2xl" />
</div>
<div className="relative flex items-center justify-center w-24 h-24 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 border-2 border-primary/20">
<CalendarClock className="w-16 h-16 text-primary/70" strokeWidth={1.5} />
</div>
</div>
<h3 className="text-xl font-semibold mb-2">No backup job created</h3>
<p className="text-muted-foreground text-sm mb-6 max-w-md">
Backup jobs allow you to create automated backup schedules for your volumes. Set up your first backup job
to ensure your data is securely backed up.
</p>
<Button>
<Link to="/repositories" className="flex items-center">
<Plus className="h-4 w-4 mr-2" />
Create a backup job
</Link>
</Button>
</div>
</CardContent>
</Card>
);
}
return (
<div className="container mx-auto space-y-6">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{schedules.map((schedule) => (
<Link key={schedule.id} to={`/backup-jobs/${schedule.id}`}>
<Card key={schedule.id} className="flex flex-col">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 flex-1 min-w-0">
<HardDrive className="h-5 w-5 text-muted-foreground flex-shrink-0" />
<CardTitle className="text-lg truncate">Volume #{schedule.volumeId}</CardTitle>
</div>
<Badge variant={schedule.enabled ? "default" : "secondary"} className="flex-shrink-0">
{schedule.enabled ? "Active" : "Paused"}
</Badge>
</div>
<CardDescription className="flex items-center gap-2 mt-2">
<Database className="h-4 w-4" />
<span className="truncate">{schedule.repositoryId}</span>
</CardDescription>
</CardHeader>
<CardContent className="flex-1 space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Schedule</span>
<code className="text-xs bg-muted px-2 py-1 rounded">{schedule.cronExpression}</code>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Last backup</span>
<span className="font-medium">
{schedule.lastBackupAt ? new Date(schedule.lastBackupAt).toLocaleDateString() : "Never"}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Next backup</span>
<span className="font-medium">
{schedule.nextBackupAt ? new Date(schedule.nextBackupAt).toLocaleDateString() : "N/A"}
</span>
</div>
{schedule.lastBackupStatus && (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Status</span>
<Badge
variant={schedule.lastBackupStatus === "success" ? "default" : "destructive"}
className="text-xs"
>
{schedule.lastBackupStatus}
</Badge>
</div>
)}
</div>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
);
}

View File

@@ -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<string, number> = {};
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 (
<div className="container mx-auto p-4 sm:p-8">
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">Loading...</p>
</CardContent>
</Card>
</div>
);
}
if (!schedule) {
return (
<div className="container mx-auto p-4 sm:p-8">
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">Schedule not found</p>
<Button asChild className="mt-4">
<Link to="/backup-jobs">Back to Backup Jobs</Link>
</Button>
</CardContent>
</Card>
</div>
);
}
if (!isEditMode) {
return (
<div className="container mx-auto p-4 sm:p-8">
<ScheduleSummary
handleToggleEnabled={handleToggleEnabled}
handleRunBackupNow={handleRunBackupNow}
repository={schedule.repository}
setIsEditMode={setIsEditMode}
schedule={schedule}
volume={schedule.volume}
/>
</div>
);
}
return (
<div className="container mx-auto p-4 sm:p-8 space-y-4">
<div className="flex justify-end">
<Button variant="outline" onClick={() => setIsEditMode(false)}>
Cancel
</Button>
</div>
<CreateScheduleForm volume={schedule.volume} initialValues={schedule} onSubmit={handleSubmit} />
</div>
);
}

View File

@@ -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<typeof volumeConfigSchema.inferOut>().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;

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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),

View File

@@ -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",

View File

@@ -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: {

View File

@@ -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

View File

@@ -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",

View File

@@ -17,7 +17,7 @@ export const s3RepositoryConfigSchema = type({
export const localRepositoryConfigSchema = type({
backend: "'local'",
path: "string",
name: "string",
});
export const repositoryConfigSchema = s3RepositoryConfigSchema.or(localRepositoryConfigSchema);