mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
feat(frontend): backup jobs page
This commit is contained in:
@@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
36
apps/client/app/components/ui/badge.tsx
Normal file
36
apps/client/app/components/ui/badge.tsx
Normal 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 };
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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"),
|
||||
|
||||
112
apps/client/app/routes/backup-jobs.tsx
Normal file
112
apps/client/app/routes/backup-jobs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
175
apps/client/app/routes/schedule-details.tsx
Normal file
175
apps/client/app/routes/schedule-details.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -17,7 +17,7 @@ export const s3RepositoryConfigSchema = type({
|
||||
|
||||
export const localRepositoryConfigSchema = type({
|
||||
backend: "'local'",
|
||||
path: "string",
|
||||
name: "string",
|
||||
});
|
||||
|
||||
export const repositoryConfigSchema = s3RepositoryConfigSchema.or(localRepositoryConfigSchema);
|
||||
|
||||
Reference in New Issue
Block a user