diff --git a/app/client/modules/backups/components/sortable-backup-card.tsx b/app/client/modules/backups/components/sortable-backup-card.tsx new file mode 100644 index 00000000..8a7b98f5 --- /dev/null +++ b/app/client/modules/backups/components/sortable-backup-card.tsx @@ -0,0 +1,82 @@ +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { CalendarClock, Database, GripVertical, HardDrive } from "lucide-react"; +import { Link } from "react-router"; +import { BackupStatusDot } from "./backup-status-dot"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card"; +import type { ListBackupSchedulesResponse } from "~/client/api-client"; + +type Schedule = ListBackupSchedulesResponse[number]; + +interface SortableBackupCardProps { + schedule: Schedule; + isDragging?: boolean; +} + +export function SortableBackupCard({ schedule, isDragging }: SortableBackupCardProps) { + const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ + id: schedule.id, + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + + return ( +
+
+ +
+ + + +
+
+ + {schedule.name} +
+ +
+ + + {schedule.volume.name} + + + {schedule.repository.name} + +
+ +
+
+ Schedule + {schedule.cronExpression} +
+
+ Last backup + + {schedule.lastBackupAt ? new Date(schedule.lastBackupAt).toLocaleDateString() : "Never"} + +
+
+ Next backup + + {schedule.nextBackupAt ? new Date(schedule.nextBackupAt).toLocaleDateString() : "N/A"} + +
+
+
+
+ +
+ ); +} diff --git a/app/client/modules/backups/routes/backups.tsx b/app/client/modules/backups/routes/backups.tsx index 651c86f4..929da13c 100644 --- a/app/client/modules/backups/routes/backups.tsx +++ b/app/client/modules/backups/routes/backups.tsx @@ -1,6 +1,18 @@ -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { arrayMove, SortableContext, sortableKeyboardCoordinates, rectSortingStrategy } from "@dnd-kit/sortable"; import { CalendarClock, Database, HardDrive, Plus } from "lucide-react"; import { Link } from "react-router"; +import { useState, useEffect } from "react"; +import { SortableBackupCard } from "../components/sortable-backup-card"; import { BackupStatusDot } from "../components/backup-status-dot"; import { EmptyState } from "~/client/components/empty-state"; import { Button } from "~/client/components/ui/button"; @@ -9,6 +21,21 @@ import type { Route } from "./+types/backups"; import { listBackupSchedules } from "~/client/api-client"; import { listBackupSchedulesOptions } from "~/client/api-client/@tanstack/react-query.gen"; +// Temporary helper until API client is regenerated +async function reorderBackupSchedules(scheduleIds: number[]) { + const response = await fetch("/api/v1/backups/reorder", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ scheduleIds }), + }); + if (!response.ok) { + throw new Error("Failed to reorder backup schedules"); + } + return response.json(); +} + export const handle = { breadcrumb: () => [{ label: "Backups" }], }; @@ -30,11 +57,58 @@ export const clientLoader = async () => { }; export default function Backups({ loaderData }: Route.ComponentProps) { + const queryClient = useQueryClient(); const { data: schedules, isLoading } = useQuery({ ...listBackupSchedulesOptions(), initialData: loaderData, }); + const [items, setItems] = useState(schedules?.map((s) => s.id) ?? []); + + // Keep items in sync with schedules + useEffect(() => { + if (schedules) { + setItems(schedules.map((s) => s.id)); + } + }, [schedules]); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + const reorderMutation = useMutation({ + mutationFn: async (scheduleIds: number[]) => { + await reorderBackupSchedules(scheduleIds); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["listBackupSchedules"] }); + }, + }); + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + + if (over && active.id !== over.id) { + setItems((items) => { + const oldIndex = items.indexOf(active.id as number); + const newIndex = items.indexOf(over.id as number); + const newItems = arrayMove(items, oldIndex, newIndex); + + // Save the new order + reorderMutation.mutate(newItems); + + return newItems; + }); + } + }; + if (isLoading) { return (
@@ -61,64 +135,30 @@ export default function Backups({ loaderData }: Route.ComponentProps) { ); } + // Create a map for quick lookup + const scheduleMap = new Map(schedules.map((s) => [s.id, s])); + return (
-
- {schedules.map((schedule) => ( - - - -
-
- - {schedule.name} -
- -
- - - {schedule.volume.name} - - - {schedule.repository.name} - -
- -
-
- Schedule - {schedule.cronExpression} -
-
- Last backup - - {schedule.lastBackupAt ? new Date(schedule.lastBackupAt).toLocaleDateString() : "Never"} - -
-
- Next backup - - {schedule.nextBackupAt ? new Date(schedule.nextBackupAt).toLocaleDateString() : "N/A"} - -
-
-
-
- - ))} - - - - - Create a backup job - - - -
+ + +
+ {items.map((id) => { + const schedule = scheduleMap.get(id); + if (!schedule) return null; + return ; + })} + + + + + Create a backup job + + + +
+
+
); } diff --git a/app/drizzle/0022_add_backup_sort_order.sql b/app/drizzle/0022_add_backup_sort_order.sql new file mode 100644 index 00000000..487a1bff --- /dev/null +++ b/app/drizzle/0022_add_backup_sort_order.sql @@ -0,0 +1,2 @@ +ALTER TABLE `backup_schedules_table` ADD `sort_order` integer DEFAULT 0 NOT NULL; + diff --git a/app/drizzle/meta/0022_snapshot.json b/app/drizzle/meta/0022_snapshot.json new file mode 100644 index 00000000..bf0ce995 --- /dev/null +++ b/app/drizzle/meta/0022_snapshot.json @@ -0,0 +1,17 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "0022_add_backup_sort_order", + "prevId": "0021_steady_viper", + "tables": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} + diff --git a/app/drizzle/meta/_journal.json b/app/drizzle/meta/_journal.json index 004e9b77..f4f849f8 100644 --- a/app/drizzle/meta/_journal.json +++ b/app/drizzle/meta/_journal.json @@ -155,6 +155,13 @@ "when": 1765307881092, "tag": "0021_steady_viper", "breakpoints": true + }, + { + "idx": 22, + "version": "6", + "when": 1734278400000, + "tag": "0022_add_backup_sort_order", + "breakpoints": true } ] } \ No newline at end of file diff --git a/app/server/db/schema.ts b/app/server/db/schema.ts index 55c741c2..cb94d29b 100644 --- a/app/server/db/schema.ts +++ b/app/server/db/schema.ts @@ -92,6 +92,7 @@ export const backupSchedulesTable = sqliteTable("backup_schedules_table", { lastBackupStatus: text("last_backup_status").$type<"success" | "error" | "in_progress" | "warning">(), lastBackupError: text("last_backup_error"), nextBackupAt: int("next_backup_at", { mode: "number" }), + sortOrder: int("sort_order", { mode: "number" }).notNull().default(0), createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`), updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch() * 1000)`), }); diff --git a/app/server/modules/backups/backups.controller.ts b/app/server/modules/backups/backups.controller.ts index 0bcc93eb..653cd659 100644 --- a/app/server/modules/backups/backups.controller.ts +++ b/app/server/modules/backups/backups.controller.ts @@ -16,6 +16,8 @@ import { updateScheduleMirrorsDto, updateScheduleMirrorsBody, getMirrorCompatibilityDto, + reorderBackupSchedulesDto, + reorderBackupSchedulesBody, type CreateBackupScheduleDto, type DeleteBackupScheduleDto, type GetBackupScheduleDto, @@ -28,6 +30,7 @@ import { type GetScheduleMirrorsDto, type UpdateScheduleMirrorsDto, type GetMirrorCompatibilityDto, + type ReorderBackupSchedulesDto, } from "./backups.dto"; import { backupsService } from "./backups.service"; import { @@ -139,4 +142,11 @@ export const backupScheduleController = new Hono() const compatibility = await backupsService.getMirrorCompatibility(scheduleId); return c.json(compatibility, 200); + }) + .post("/reorder", reorderBackupSchedulesDto, validator("json", reorderBackupSchedulesBody), async (c) => { + const body = c.req.valid("json"); + + await backupsService.reorderSchedules(body.scheduleIds); + + return c.json({ success: true }, 200); }); diff --git a/app/server/modules/backups/backups.dto.ts b/app/server/modules/backups/backups.dto.ts index fc771bf1..6173bd82 100644 --- a/app/server/modules/backups/backups.dto.ts +++ b/app/server/modules/backups/backups.dto.ts @@ -367,3 +367,34 @@ export const getMirrorCompatibilityDto = describeRoute({ }, }, }); + +/** + * Reorder backup schedules + */ +export const reorderBackupSchedulesBody = type({ + scheduleIds: "number[]", +}); + +export type ReorderBackupSchedulesBody = typeof reorderBackupSchedulesBody.infer; + +export const reorderBackupSchedulesResponse = type({ + success: "boolean", +}); + +export type ReorderBackupSchedulesDto = typeof reorderBackupSchedulesResponse.infer; + +export const reorderBackupSchedulesDto = describeRoute({ + description: "Reorder backup schedules by providing an array of schedule IDs in the desired order", + operationId: "reorderBackupSchedules", + tags: ["Backups"], + responses: { + 200: { + description: "Backup schedules reordered successfully", + content: { + "application/json": { + schema: resolver(reorderBackupSchedulesResponse), + }, + }, + }, + }, +}); diff --git a/app/server/modules/backups/backups.service.ts b/app/server/modules/backups/backups.service.ts index 9994e503..372ac0d8 100644 --- a/app/server/modules/backups/backups.service.ts +++ b/app/server/modules/backups/backups.service.ts @@ -1,4 +1,4 @@ -import { and, eq, ne } from "drizzle-orm"; +import { and, asc, eq, ne } from "drizzle-orm"; import cron from "node-cron"; import { CronExpressionParser } from "cron-parser"; import { NotFoundError, BadRequestError, ConflictError } from "http-errors-enhanced"; @@ -38,6 +38,7 @@ const listSchedules = async () => { volume: true, repository: true, }, + orderBy: [asc(backupSchedulesTable.sortOrder), asc(backupSchedulesTable.id)], }); return schedules; }; @@ -637,6 +638,16 @@ const getMirrorCompatibility = async (scheduleId: number) => { return compatibility; }; +const reorderSchedules = async (scheduleIds: number[]) => { + // Update each schedule's sortOrder based on its position in the array + for (let i = 0; i < scheduleIds.length; i++) { + await db + .update(backupSchedulesTable) + .set({ sortOrder: i, updatedAt: Date.now() }) + .where(eq(backupSchedulesTable.id, scheduleIds[i])); + } +}; + export const backupsService = { listSchedules, getSchedule, @@ -651,4 +662,5 @@ export const backupsService = { getMirrors, updateMirrors, getMirrorCompatibility, + reorderSchedules, }; diff --git a/package.json b/package.json index e6d2fcb9..697d0fe4 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,9 @@ "studio": "drizzle-kit studio" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@hono/standard-validator": "^0.2.0", "@hookform/resolvers": "^5.2.2", "@radix-ui/react-alert-dialog": "^1.1.15",