diff --git a/.dockerignore b/.dockerignore index 9980d983..d2d3fe67 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,4 @@ -* +** !turbo.json !bun.lock diff --git a/.gitignore b/.gitignore index 9f32df66..99880c84 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ CLAUDE.md mutagen.yml.lock notes.md +smb-password.txt diff --git a/README.md b/README.md index 87289812..f05ece02 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ In order to run Zerobyte, you need to have Docker and Docker Compose installed o ```yaml services: zerobyte: - image: ghcr.io/nicotsx/zerobyte:v0.18 + image: ghcr.io/nicotsx/zerobyte:v0.19 container_name: zerobyte restart: unless-stopped cap_add: @@ -74,7 +74,7 @@ If you only need to back up locally mounted folders and don't require remote sha ```yaml services: zerobyte: - image: ghcr.io/nicotsx/zerobyte:v0.18 + image: ghcr.io/nicotsx/zerobyte:v0.19 container_name: zerobyte restart: unless-stopped ports: @@ -106,7 +106,7 @@ If you want to track a local directory on the same server where Zerobyte is runn ```diff services: zerobyte: - image: ghcr.io/nicotsx/zerobyte:v0.18 + image: ghcr.io/nicotsx/zerobyte:v0.19 container_name: zerobyte restart: unless-stopped cap_add: @@ -174,7 +174,7 @@ Zerobyte can use [rclone](https://rclone.org/) to support 40+ cloud storage prov ```diff services: zerobyte: - image: ghcr.io/nicotsx/zerobyte:v0.18 + image: ghcr.io/nicotsx/zerobyte:v0.19 container_name: zerobyte restart: unless-stopped cap_add: diff --git a/app/app.css b/app/app.css index c12eee55..c84370ed 100644 --- a/app/app.css +++ b/app/app.css @@ -173,3 +173,9 @@ body { --chart-5: oklch(0.645 0.246 16.439); } } + +/* Hide built-in password reveal/clear controls (notably in Edge on Windows) */ +[data-secret-input] input[type="password"]::-ms-reveal, +[data-secret-input] input[type="password"]::-ms-clear { + display: none; +} diff --git a/app/client/api-client/@tanstack/react-query.gen.ts b/app/client/api-client/@tanstack/react-query.gen.ts index 9ac74633..88863b10 100644 --- a/app/client/api-client/@tanstack/react-query.gen.ts +++ b/app/client/api-client/@tanstack/react-query.gen.ts @@ -3,8 +3,8 @@ import { type DefaultError, queryOptions, type UseMutationOptions } from '@tanstack/react-query'; import { client } from '../client.gen'; -import { browseFilesystem, changePassword, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteVolume, doctorRepository, downloadResticPassword, exportFullConfig, getBackupSchedule, getBackupScheduleForVolume, getMe, getMirrorCompatibility, getNotificationDestination, getRepository, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, testNotificationDestination, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateVolume } from '../sdk.gen'; -import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, ExportFullConfigData, ExportFullConfigError, ExportFullConfigResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetMeData, GetMeResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen'; +import { browseFilesystem, changePassword, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteVolume, doctorRepository, downloadResticPassword, exportFullConfig, getBackupSchedule, getBackupScheduleForVolume, getMe, getMirrorCompatibility, getNotificationDestination, getRepository, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, testNotificationDestination, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateVolume } from '../sdk.gen'; +import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, ExportFullConfigData, ExportFullConfigError, ExportFullConfigResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetMeData, GetMeResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, ReorderBackupSchedulesData, ReorderBackupSchedulesResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen'; /** * Register a new user @@ -790,6 +790,23 @@ export const getMirrorCompatibilityOptions = (options: Options>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await reorderBackupSchedules({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + export const listNotificationDestinationsQueryKey = (options?: Options) => createQueryKey("listNotificationDestinations", options); /** diff --git a/app/client/api-client/client.gen.ts b/app/client/api-client/client.gen.ts index 50c3a3f5..d648343f 100644 --- a/app/client/api-client/client.gen.ts +++ b/app/client/api-client/client.gen.ts @@ -14,5 +14,5 @@ import type { ClientOptions as ClientOptions2 } from './types.gen'; export type CreateClientConfig = (override?: Config) => Config & T>; export const client = createClient(createConfig({ - baseUrl: 'http://192.168.2.42:4096' + baseUrl: 'http://localhost:4096' })); diff --git a/app/client/api-client/sdk.gen.ts b/app/client/api-client/sdk.gen.ts index 33f82659..a682452f 100644 --- a/app/client/api-client/sdk.gen.ts +++ b/app/client/api-client/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, ExportFullConfigData, ExportFullConfigErrors, ExportFullConfigResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetMeData, GetMeResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetRepositoryData, GetRepositoryResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen'; +import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, ExportFullConfigData, ExportFullConfigErrors, ExportFullConfigResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetMeData, GetMeResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetRepositoryData, GetRepositoryResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, ReorderBackupSchedulesData, ReorderBackupSchedulesResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen'; export type Options = Options2 & { /** @@ -500,6 +500,20 @@ export const getMirrorCompatibility = (opt }); }; +/** + * Reorder backup schedules by providing an array of schedule IDs in the desired order + */ +export const reorderBackupSchedules = (options?: Options) => { + return (options?.client ?? client).post({ + url: '/api/v1/backups/reorder', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); +}; + /** * List all notification destinations */ diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index 9c04340a..83f4e884 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -1,7 +1,7 @@ // This file is auto-generated by @hey-api/openapi-ts export type ClientOptions = { - baseUrl: 'http://192.168.2.42:4096' | (string & {}); + baseUrl: 'http://localhost:4096' | (string & {}); }; export type RegisterData = { @@ -2376,6 +2376,26 @@ export type GetMirrorCompatibilityResponses = { export type GetMirrorCompatibilityResponse = GetMirrorCompatibilityResponses[keyof GetMirrorCompatibilityResponses]; +export type ReorderBackupSchedulesData = { + body?: { + scheduleIds: Array; + }; + path?: never; + query?: never; + url: '/api/v1/backups/reorder'; +}; + +export type ReorderBackupSchedulesResponses = { + /** + * Backup schedules reordered successfully + */ + 200: { + success: boolean; + }; +}; + +export type ReorderBackupSchedulesResponse = ReorderBackupSchedulesResponses[keyof ReorderBackupSchedulesResponses]; + export type ListNotificationDestinationsData = { body?: never; path?: never; diff --git a/app/client/components/sortable-card.tsx b/app/client/components/sortable-card.tsx new file mode 100644 index 00000000..d5b8897d --- /dev/null +++ b/app/client/components/sortable-card.tsx @@ -0,0 +1,34 @@ +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { GripVertical } from "lucide-react"; +import type { PropsWithChildren } from "react"; + +interface SortableBackupCardProps { + isDragging?: boolean; + uniqueId: number; +} + +export function SortableCard({ isDragging, uniqueId, children }: PropsWithChildren) { + const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ + id: uniqueId, + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + + return ( +
+
+ +
+ {children} +
+ ); +} diff --git a/app/client/components/ui/secret-input.tsx b/app/client/components/ui/secret-input.tsx new file mode 100644 index 00000000..540f8f89 --- /dev/null +++ b/app/client/components/ui/secret-input.tsx @@ -0,0 +1,59 @@ +import { Eye, EyeOff } from "lucide-react"; +import type * as React from "react"; +import { useMemo, useState } from "react"; + +import { cn } from "~/client/lib/utils"; +import { Button } from "./button"; +import { Input } from "./input"; + +export const isStoredSecretValue = (value?: string): boolean => { + if (typeof value !== "string" || value.length === 0) { + return false; + } + + return value.startsWith("env://") || value.startsWith("file://") || value.startsWith("encv1:"); +}; + +type SecretInputProps = Omit, "type"> + +export const SecretInput = ({ className, value, ...props }: SecretInputProps) => { + const [revealed, setRevealed] = useState(false); + + const showAsPlaintext = useMemo(() => { + if (typeof value !== "string") { + return false; + } + + return isStoredSecretValue(value); + }, [value]); + + const type = useMemo(() => { + if (showAsPlaintext) { + return "text"; + } + return revealed ? "text" : "password"; + }, [showAsPlaintext, revealed]); + + return ( +
+ + {!showAsPlaintext && ( + + )} +
+ ); +}; diff --git a/app/client/modules/backups/components/backup-card.tsx b/app/client/modules/backups/components/backup-card.tsx new file mode 100644 index 00000000..85249704 --- /dev/null +++ b/app/client/modules/backups/components/backup-card.tsx @@ -0,0 +1,54 @@ +import { CalendarClock, Database, HardDrive } from "lucide-react"; +import { Link } from "react-router"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card"; +import type { BackupSchedule } from "~/client/lib/types"; +import { BackupStatusDot } from "./backup-status-dot"; + +export const BackupCard = ({ schedule }: { schedule: BackupSchedule }) => { + 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..76f0c714 100644 --- a/app/client/modules/backups/routes/backups.tsx +++ b/app/client/modules/backups/routes/backups.tsx @@ -1,13 +1,28 @@ -import { useQuery } from "@tanstack/react-query"; -import { CalendarClock, Database, HardDrive, Plus } from "lucide-react"; +import { useMutation, useQuery } 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, Plus } from "lucide-react"; import { Link } from "react-router"; -import { BackupStatusDot } from "../components/backup-status-dot"; +import { useState, useEffect } from "react"; import { EmptyState } from "~/client/components/empty-state"; import { Button } from "~/client/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card"; +import { Card, CardContent } from "~/client/components/ui/card"; import type { Route } from "./+types/backups"; import { listBackupSchedules } from "~/client/api-client"; -import { listBackupSchedulesOptions } from "~/client/api-client/@tanstack/react-query.gen"; +import { + listBackupSchedulesOptions, + reorderBackupSchedulesMutation, +} from "~/client/api-client/@tanstack/react-query.gen"; +import { SortableCard } from "~/client/components/sortable-card"; +import { BackupCard } from "../components/backup-card"; export const handle = { breadcrumb: () => [{ label: "Backups" }], @@ -35,6 +50,41 @@ export default function Backups({ loaderData }: Route.ComponentProps) { initialData: loaderData, }); + const [items, setItems] = useState(schedules?.map((s) => s.id) ?? []); + 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({ + ...reorderBackupSchedulesMutation(), + }); + + 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); + reorderMutation.mutate({ body: { scheduleIds: newItems } }); + + return newItems; + }); + } + }; + if (isLoading) { return (
@@ -61,64 +111,33 @@ export default function Backups({ loaderData }: Route.ComponentProps) { ); } + 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/client/modules/notifications/components/create-notification-form.tsx b/app/client/modules/notifications/components/create-notification-form.tsx index dc89d550..2fd240cf 100644 --- a/app/client/modules/notifications/components/create-notification-form.tsx +++ b/app/client/modules/notifications/components/create-notification-form.tsx @@ -14,6 +14,7 @@ import { FormMessage, } from "~/client/components/ui/form"; import { Input } from "~/client/components/ui/input"; +import { SecretInput } from "~/client/components/ui/secret-input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select"; import { Checkbox } from "~/client/components/ui/checkbox"; import { notificationConfigSchema } from "~/schemas/notifications"; @@ -213,7 +214,7 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue Password (Optional) - + @@ -415,7 +416,7 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue App Token - + Application token from Gotify. @@ -510,7 +511,7 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue Password (Optional) - + Password for server authentication, if required. @@ -567,7 +568,7 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue API Token - + Application API token from your Pushover application. @@ -627,7 +628,7 @@ export const CreateNotificationForm = ({ onSubmit, mode = "create", initialValue Bot Token - + Telegram bot token. Get this from BotFather when you create your bot. diff --git a/app/client/modules/repositories/components/create-repository-form.tsx b/app/client/modules/repositories/components/create-repository-form.tsx index dc99c6d4..2f18a482 100644 --- a/app/client/modules/repositories/components/create-repository-form.tsx +++ b/app/client/modules/repositories/components/create-repository-form.tsx @@ -16,6 +16,7 @@ import { FormMessage, } from "../../../components/ui/form"; import { Input } from "../../../components/ui/input"; +import { SecretInput } from "../../../components/ui/secret-input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../components/ui/select"; import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip"; import { useSystemInfo } from "~/client/hooks/use-system-info"; @@ -241,7 +242,11 @@ export const CreateRepositoryForm = ({ Repository Password - + The password used to encrypt this repository. It will be stored securely. diff --git a/app/client/modules/repositories/components/repository-forms/azure-repository-form.tsx b/app/client/modules/repositories/components/repository-forms/azure-repository-form.tsx index de2442fe..bdce82ee 100644 --- a/app/client/modules/repositories/components/repository-forms/azure-repository-form.tsx +++ b/app/client/modules/repositories/components/repository-forms/azure-repository-form.tsx @@ -8,6 +8,7 @@ import { FormMessage, } from "../../../../components/ui/form"; import { Input } from "../../../../components/ui/input"; +import { SecretInput } from "../../../../components/ui/secret-input"; import type { RepositoryFormValues } from "../create-repository-form"; type Props = { @@ -52,7 +53,7 @@ export const AzureRepositoryForm = ({ form }: Props) => { Account Key - + Azure Storage account key for authentication. diff --git a/app/client/modules/repositories/components/repository-forms/r2-repository-form.tsx b/app/client/modules/repositories/components/repository-forms/r2-repository-form.tsx index b4cfa242..94d480a6 100644 --- a/app/client/modules/repositories/components/repository-forms/r2-repository-form.tsx +++ b/app/client/modules/repositories/components/repository-forms/r2-repository-form.tsx @@ -8,6 +8,7 @@ import { FormMessage, } from "../../../../components/ui/form"; import { Input } from "../../../../components/ui/input"; +import { SecretInput } from "../../../../components/ui/secret-input"; import type { RepositoryFormValues } from "../create-repository-form"; type Props = { @@ -68,7 +69,7 @@ export const R2RepositoryForm = ({ form }: Props) => { Secret Access Key - + R2 API token Secret Access Key (shown once when creating token). diff --git a/app/client/modules/repositories/components/repository-forms/rest-repository-form.tsx b/app/client/modules/repositories/components/repository-forms/rest-repository-form.tsx index 5721c1db..401b099c 100644 --- a/app/client/modules/repositories/components/repository-forms/rest-repository-form.tsx +++ b/app/client/modules/repositories/components/repository-forms/rest-repository-form.tsx @@ -8,6 +8,7 @@ import { FormMessage, } from "../../../../components/ui/form"; import { Input } from "../../../../components/ui/input"; +import { SecretInput } from "../../../../components/ui/secret-input"; import type { RepositoryFormValues } from "../create-repository-form"; type Props = { @@ -66,7 +67,7 @@ export const RestRepositoryForm = ({ form }: Props) => { Password (Optional) - + Password for REST server authentication. diff --git a/app/client/modules/repositories/components/repository-forms/s3-repository-form.tsx b/app/client/modules/repositories/components/repository-forms/s3-repository-form.tsx index 16082434..40328c4a 100644 --- a/app/client/modules/repositories/components/repository-forms/s3-repository-form.tsx +++ b/app/client/modules/repositories/components/repository-forms/s3-repository-form.tsx @@ -8,6 +8,7 @@ import { FormMessage, } from "../../../../components/ui/form"; import { Input } from "../../../../components/ui/input"; +import { SecretInput } from "../../../../components/ui/secret-input"; import type { RepositoryFormValues } from "../create-repository-form"; type Props = { @@ -66,7 +67,7 @@ export const S3RepositoryForm = ({ form }: Props) => { Secret Access Key - + S3 secret access key for authentication. diff --git a/app/client/modules/volumes/components/volume-forms/smb-form.tsx b/app/client/modules/volumes/components/volume-forms/smb-form.tsx index 7f995c46..b46fcef3 100644 --- a/app/client/modules/volumes/components/volume-forms/smb-form.tsx +++ b/app/client/modules/volumes/components/volume-forms/smb-form.tsx @@ -9,6 +9,7 @@ import { FormMessage, } from "../../../../components/ui/form"; import { Input } from "../../../../components/ui/input"; +import { SecretInput } from "../../../../components/ui/secret-input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../../components/ui/select"; type Props = { @@ -67,7 +68,7 @@ export const SMBForm = ({ form }: Props) => { Password - + Password for SMB authentication. diff --git a/app/client/modules/volumes/components/volume-forms/webdav-form.tsx b/app/client/modules/volumes/components/volume-forms/webdav-form.tsx index 1a399ed7..3d43892b 100644 --- a/app/client/modules/volumes/components/volume-forms/webdav-form.tsx +++ b/app/client/modules/volumes/components/volume-forms/webdav-form.tsx @@ -9,6 +9,7 @@ import { FormMessage, } from "../../../../components/ui/form"; import { Input } from "../../../../components/ui/input"; +import { SecretInput } from "../../../../components/ui/secret-input"; type Props = { form: UseFormReturn; @@ -66,7 +67,7 @@ export const WebDAVForm = ({ form }: Props) => { Password (Optional) - + Password for WebDAV authentication (optional). diff --git a/app/drizzle/0022_woozy_shen.sql b/app/drizzle/0022_woozy_shen.sql new file mode 100644 index 00000000..df63c5aa --- /dev/null +++ b/app/drizzle/0022_woozy_shen.sql @@ -0,0 +1 @@ +ALTER TABLE `backup_schedules_table` ADD `sort_order` integer DEFAULT 0 NOT NULL; \ No newline at end of file diff --git a/app/drizzle/meta/0022_snapshot.json b/app/drizzle/meta/0022_snapshot.json new file mode 100644 index 00000000..ee9d34d9 --- /dev/null +++ b/app/drizzle/meta/0022_snapshot.json @@ -0,0 +1,831 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "11c24867-3186-4578-b8dd-cee4c48a28d1", + "prevId": "e7c02f6c-e255-402e-9f18-d50a3fef8e4d", + "tables": { + "app_metadata": { + "name": "app_metadata", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "backup_schedule_mirrors_table": { + "name": "backup_schedule_mirrors_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "last_copy_at": { + "name": "last_copy_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_copy_status": { + "name": "last_copy_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_copy_error": { + "name": "last_copy_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "backup_schedule_mirrors_table_schedule_id_repository_id_unique": { + "name": "backup_schedule_mirrors_table_schedule_id_repository_id_unique", + "columns": [ + "schedule_id", + "repository_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "backup_schedule_mirrors_table_schedule_id_backup_schedules_table_id_fk": { + "name": "backup_schedule_mirrors_table_schedule_id_backup_schedules_table_id_fk", + "tableFrom": "backup_schedule_mirrors_table", + "tableTo": "backup_schedules_table", + "columnsFrom": [ + "schedule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "backup_schedule_mirrors_table_repository_id_repositories_table_id_fk": { + "name": "backup_schedule_mirrors_table_repository_id_repositories_table_id_fk", + "tableFrom": "backup_schedule_mirrors_table", + "tableTo": "repositories_table", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "backup_schedule_notifications_table": { + "name": "backup_schedule_notifications_table", + "columns": { + "schedule_id": { + "name": "schedule_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "destination_id": { + "name": "destination_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notify_on_start": { + "name": "notify_on_start", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "notify_on_success": { + "name": "notify_on_success", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "notify_on_warning": { + "name": "notify_on_warning", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "notify_on_failure": { + "name": "notify_on_failure", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "backup_schedule_notifications_table_schedule_id_backup_schedules_table_id_fk": { + "name": "backup_schedule_notifications_table_schedule_id_backup_schedules_table_id_fk", + "tableFrom": "backup_schedule_notifications_table", + "tableTo": "backup_schedules_table", + "columnsFrom": [ + "schedule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "backup_schedule_notifications_table_destination_id_notification_destinations_table_id_fk": { + "name": "backup_schedule_notifications_table_destination_id_notification_destinations_table_id_fk", + "tableFrom": "backup_schedule_notifications_table", + "tableTo": "notification_destinations_table", + "columnsFrom": [ + "destination_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "backup_schedule_notifications_table_schedule_id_destination_id_pk": { + "columns": [ + "schedule_id", + "destination_id" + ], + "name": "backup_schedule_notifications_table_schedule_id_destination_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "backup_schedules_table": { + "name": "backup_schedules_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "volume_id": { + "name": "volume_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retention_policy": { + "name": "retention_policy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "exclude_patterns": { + "name": "exclude_patterns", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'[]'" + }, + "exclude_if_present": { + "name": "exclude_if_present", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'[]'" + }, + "include_patterns": { + "name": "include_patterns", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'[]'" + }, + "last_backup_at": { + "name": "last_backup_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_backup_status": { + "name": "last_backup_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_backup_error": { + "name": "last_backup_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_backup_at": { + "name": "next_backup_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "backup_schedules_table_name_unique": { + "name": "backup_schedules_table_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "backup_schedules_table_volume_id_volumes_table_id_fk": { + "name": "backup_schedules_table_volume_id_volumes_table_id_fk", + "tableFrom": "backup_schedules_table", + "tableTo": "volumes_table", + "columnsFrom": [ + "volume_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "backup_schedules_table_repository_id_repositories_table_id_fk": { + "name": "backup_schedules_table_repository_id_repositories_table_id_fk", + "tableFrom": "backup_schedules_table", + "tableTo": "repositories_table", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification_destinations_table": { + "name": "notification_destinations_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "notification_destinations_table_name_unique": { + "name": "notification_destinations_table_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "repositories_table": { + "name": "repositories_table", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "short_id": { + "name": "short_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "compression_mode": { + "name": "compression_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'auto'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'unknown'" + }, + "last_checked": { + "name": "last_checked", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "repositories_table_short_id_unique": { + "name": "repositories_table_short_id_unique", + "columns": [ + "short_id" + ], + "isUnique": true + }, + "repositories_table_name_unique": { + "name": "repositories_table_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions_table": { + "name": "sessions_table", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_table_user_id_users_table_id_fk": { + "name": "sessions_table_user_id_users_table_id_fk", + "tableFrom": "sessions_table", + "tableTo": "users_table", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users_table": { + "name": "users_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "has_downloaded_restic_password": { + "name": "has_downloaded_restic_password", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "users_table_username_unique": { + "name": "users_table_username_unique", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "volumes_table": { + "name": "volumes_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "short_id": { + "name": "short_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unmounted'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_health_check": { + "name": "last_health_check", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auto_remount": { + "name": "auto_remount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "volumes_table_short_id_unique": { + "name": "volumes_table_short_id_unique", + "columns": [ + "short_id" + ], + "isUnique": true + }, + "volumes_table_name_unique": { + "name": "volumes_table_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/app/drizzle/meta/_journal.json b/app/drizzle/meta/_journal.json index 004e9b77..2b034fbf 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": 1765794552191, + "tag": "0022_woozy_shen", + "breakpoints": true } ] } \ No newline at end of file diff --git a/app/server/core/config.ts b/app/server/core/config.ts index bd138c4b..356b341f 100644 --- a/app/server/core/config.ts +++ b/app/server/core/config.ts @@ -3,9 +3,11 @@ import "dotenv/config"; const envSchema = type({ NODE_ENV: type.enumerated("development", "production", "test").default("production"), + SERVER_IP: 'string = "localhost"', }).pipe((s) => ({ __prod__: s.NODE_ENV === "production", environment: s.NODE_ENV, + serverIp: s.SERVER_IP, })); const parseConfig = (env: unknown) => { 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/index.ts b/app/server/index.ts index db5b87db..669d84f4 100644 --- a/app/server/index.ts +++ b/app/server/index.ts @@ -20,6 +20,8 @@ import { shutdown } from "./modules/lifecycle/shutdown"; import { REQUIRED_MIGRATIONS } from "./core/constants"; import { validateRequiredMigrations } from "./modules/lifecycle/checkpoint"; import { configExportController } from "./modules/lifecycle/config-export.controller"; +import { config } from "./core/config"; + export const generalDescriptor = (app: Hono) => openAPIRouteHandler(app, { @@ -29,7 +31,7 @@ export const generalDescriptor = (app: Hono) => version: "1.0.0", description: "API for managing volumes", }, - servers: [{ url: "http://192.168.2.42:4096", description: "Development Server" }], + servers: [{ url: `http://${config.serverIp}:4096`, description: "Development Server" }], }, }); @@ -43,13 +45,21 @@ const app = new Hono() .use(honoLogger()) .get("healthcheck", (c) => c.json({ status: "ok" })) .route("/api/v1/auth", authController.basePath("/api/v1")) - .route("/api/v1/volumes", volumeController.use(requireAuth)) - .route("/api/v1/repositories", repositoriesController.use(requireAuth)) - .route("/api/v1/backups", backupScheduleController.use(requireAuth)) - .route("/api/v1/notifications", notificationsController.use(requireAuth)) - .route("/api/v1/system", systemController.use(requireAuth)) - .route("/api/v1/events", eventsController.use(requireAuth)) - .route("/api/v1/config", configExportController.use(requireAuth)); + .use("/api/v1/volumes/*", requireAuth) + .use("/api/v1/repositories/*", requireAuth) + .use("/api/v1/backups/*", requireAuth) + .use("/api/v1/notifications/*", requireAuth) + .use("/api/v1/system/*", requireAuth) + .use("/api/v1/events/*", requireAuth) + .use("/api/v1/config/*", requireAuth) + .route("/api/v1/volumes", volumeController) + .route("/api/v1/repositories", repositoriesController) + .route("/api/v1/backups", backupScheduleController) + .route("/api/v1/notifications", notificationsController) + .route("/api/v1/system", systemController) + .route("/api/v1/events", eventsController) + .route("/api/v1/config", configExportController); + app.get("/api/v1/openapi.json", generalDescriptor(app)); app.get("/api/v1/docs", scalarDescriptor); diff --git a/app/server/modules/backends/smb/smb-backend.ts b/app/server/modules/backends/smb/smb-backend.ts index 774e10fd..f5500c29 100644 --- a/app/server/modules/backends/smb/smb-backend.ts +++ b/app/server/modules/backends/smb/smb-backend.ts @@ -34,7 +34,7 @@ const mount = async (config: BackendConfig, path: string) => { const run = async () => { await fs.mkdir(path, { recursive: true }); - const password = await cryptoUtils.decrypt(config.password); + const password = await cryptoUtils.resolveSecret(config.password); const source = `//${config.server}/${config.share}`; const options = [ diff --git a/app/server/modules/backends/webdav/webdav-backend.ts b/app/server/modules/backends/webdav/webdav-backend.ts index 2467736d..5de5f05d 100644 --- a/app/server/modules/backends/webdav/webdav-backend.ts +++ b/app/server/modules/backends/webdav/webdav-backend.ts @@ -50,7 +50,7 @@ const mount = async (config: BackendConfig, path: string) => { : ["uid=1000", "gid=1000", "file_mode=0664", "dir_mode=0775"]; if (config.username && config.password) { - const password = await cryptoUtils.decrypt(config.password); + const password = await cryptoUtils.resolveSecret(config.password); const secretsFile = "/etc/davfs2/secrets"; const secretsContent = `${source} ${config.username} ${password}\n`; await fs.appendFile(secretsFile, secretsContent, { mode: 0o600 }); 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..584fada7 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,36 @@ const getMirrorCompatibility = async (scheduleId: number) => { return compatibility; }; +const reorderSchedules = async (scheduleIds: number[]) => { + const uniqueIds = new Set(scheduleIds); + if (uniqueIds.size !== scheduleIds.length) { + throw new BadRequestError("Duplicate schedule IDs in reorder request"); + } + + const existingSchedules = await db.query.backupSchedulesTable.findMany({ + columns: { id: true }, + }); + const existingIds = new Set(existingSchedules.map((s) => s.id)); + + for (const id of scheduleIds) { + if (!existingIds.has(id)) { + throw new NotFoundError(`Backup schedule with ID ${id} not found`); + } + } + + await db.transaction(async (tx) => { + const now = Date.now(); + await Promise.all( + scheduleIds.map((scheduleId, index) => + tx + .update(backupSchedulesTable) + .set({ sortOrder: index, updatedAt: now }) + .where(eq(backupSchedulesTable.id, scheduleId)), + ), + ); + }); +}; + export const backupsService = { listSchedules, getSchedule, @@ -651,4 +682,5 @@ export const backupsService = { getMirrors, updateMirrors, getMirrorCompatibility, + reorderSchedules, }; diff --git a/app/server/modules/notifications/notifications.service.ts b/app/server/modules/notifications/notifications.service.ts index b25b79f6..0b951a96 100644 --- a/app/server/modules/notifications/notifications.service.ts +++ b/app/server/modules/notifications/notifications.service.ts @@ -38,42 +38,42 @@ async function encryptSensitiveFields(config: NotificationConfig): Promise = { ...config }; if (config.customPassword) { - encryptedConfig.customPassword = await cryptoUtils.encrypt(config.customPassword); + encryptedConfig.customPassword = await cryptoUtils.sealSecret(config.customPassword); } switch (config.backend) { case "s3": case "r2": - encryptedConfig.accessKeyId = await cryptoUtils.encrypt(config.accessKeyId); - encryptedConfig.secretAccessKey = await cryptoUtils.encrypt(config.secretAccessKey); + encryptedConfig.accessKeyId = await cryptoUtils.sealSecret(config.accessKeyId); + encryptedConfig.secretAccessKey = await cryptoUtils.sealSecret(config.secretAccessKey); break; case "gcs": - encryptedConfig.credentialsJson = await cryptoUtils.encrypt(config.credentialsJson); + encryptedConfig.credentialsJson = await cryptoUtils.sealSecret(config.credentialsJson); break; case "azure": - encryptedConfig.accountKey = await cryptoUtils.encrypt(config.accountKey); + encryptedConfig.accountKey = await cryptoUtils.sealSecret(config.accountKey); break; case "rest": if (config.username) { - encryptedConfig.username = await cryptoUtils.encrypt(config.username); + encryptedConfig.username = await cryptoUtils.sealSecret(config.username); } if (config.password) { - encryptedConfig.password = await cryptoUtils.encrypt(config.password); + encryptedConfig.password = await cryptoUtils.sealSecret(config.password); } break; case "sftp": - encryptedConfig.privateKey = await cryptoUtils.encrypt(config.privateKey); + encryptedConfig.privateKey = await cryptoUtils.sealSecret(config.privateKey); break; } diff --git a/app/server/modules/volumes/volume.service.ts b/app/server/modules/volumes/volume.service.ts index 08ed9279..def5c51f 100644 --- a/app/server/modules/volumes/volume.service.ts +++ b/app/server/modules/volumes/volume.service.ts @@ -23,12 +23,12 @@ async function encryptSensitiveFields(config: BackendConfig): Promise { return typeof val === "string" && val.startsWith(encryptionPrefix); }; +/** + * Checks if a string looks like a supported secret reference. + * - env://VAR_NAME -> reads process.env.VAR_NAME + * - file://name -> reads a file /run/secrets/name + */ +const isSecretReference = (val?: string): boolean => { + return typeof val === "string" && (val.startsWith(envSecretPrefix) || val.startsWith(fileSecretPrefix)); +}; + +/** + * Resolves an environment variable secret reference. + */ +const resolveEnvSecret = (ref: string): string => { + const name = ref.slice(envSecretPrefix.length); + if (!name) { + throw new Error("env:// reference is missing variable name"); + } + + const value = process.env[name]; + if (value === undefined) { + throw new Error(`Environment variable not set: ${name}`); + } + + return value; +}; + +/** + * Resolves a file-based secret reference. + * Reads the secret from /run/secrets/{name} + */ +const resolveFileSecret = async (ref: string): Promise => { + const secretName = ref.slice(fileSecretPrefix.length); + if (!secretName) { + throw new Error("file:// reference is missing secret name"); + } + + const normalizedName = secretName.replace(/^\/+/, ""); + if (!normalizedName) { + throw new Error("file:// reference is missing secret name"); + } + if (normalizedName.includes("\0") || normalizedName.includes("/") || normalizedName.includes("\\")) { + throw new Error("Invalid secret reference: secret name must be a single path segment"); + } + + const resolvedPath = path.join("/run/secrets", normalizedName); + + try { + const content = await fs.readFile(resolvedPath, "utf-8"); + return content.trimEnd(); + } catch (error) { + if (isNodeJSErrnoException(error)) { + if (error.code === "ENOENT") { + throw new Error(`Secret file not found: ${resolvedPath}`); + } + if (error.code === "EACCES") { + throw new Error(`Permission denied reading secret file: ${resolvedPath}`); + } + } + throw new Error(`Failed to read secret file ${resolvedPath}: ${(error as Error).message}`); + } +}; + /** * Given a string, encrypts it using a randomly generated salt. * Returns the input unchanged if it's empty or already encrypted. @@ -35,7 +103,7 @@ const encrypt = async (data: string) => { const encrypted = Buffer.concat([cipher.update(data), cipher.final()]); const tag = cipher.getAuthTag(); - return `${encryptionPrefix}:${salt.toString("hex")}:${iv.toString("hex")}:${encrypted.toString("hex")}:${tag.toString("hex")}`; + return `${encryptionPrefix}${salt.toString("hex")}:${iv.toString("hex")}:${encrypted.toString("hex")}:${tag.toString("hex")}`; }; /** @@ -68,8 +136,54 @@ const decrypt = async (encryptedData: string) => { return decrypted.toString(); }; -export const cryptoUtils = { - encrypt, - decrypt, - isEncrypted, +/** + * Resolves secret references and encrypted database values. + * + * - encv1:... -> decrypt + * - env://VAR -> read process.env.VAR + * - file://name -> read /run/secrets/name + * - otherwise returns value unchanged + */ +const resolveSecret = async (value: string): Promise => { + if (!value) { + return value; + } + + if (isEncrypted(value)) { + return decrypt(value); + } + + if (value.startsWith(envSecretPrefix)) { + return resolveEnvSecret(value); + } + + if (value.startsWith(fileSecretPrefix)) { + return resolveFileSecret(value); + } + + return value; +}; + +/** + * Prepares a secret value for storage. + * + * - env://... and file://... are stored as-is (references) + * - encv1:... is stored as-is (already encrypted) + * - otherwise encrypt before storing + */ +const sealSecret = async (value: string): Promise => { + if (!value) { + return value; + } + + if (isEncrypted(value) || isSecretReference(value)) { + return value; + } + + return encrypt(value); +}; + +export const cryptoUtils = { + resolveSecret, + sealSecret, }; diff --git a/app/server/utils/fs.ts b/app/server/utils/fs.ts new file mode 100644 index 00000000..82bad477 --- /dev/null +++ b/app/server/utils/fs.ts @@ -0,0 +1,8 @@ +export const isNodeJSErrnoException = (error: unknown): error is NodeJS.ErrnoException => { + return ( + typeof error === "object" && + error !== null && + "code" in error && + typeof (error as NodeJS.ErrnoException).code === "string" + ); +}; diff --git a/app/server/utils/restic.ts b/app/server/utils/restic.ts index a99d94dc..207bec99 100644 --- a/app/server/utils/restic.ts +++ b/app/server/utils/restic.ts @@ -106,7 +106,7 @@ const buildEnv = async (config: RepositoryConfig) => { }; if (config.isExistingRepository && config.customPassword) { - const decryptedPassword = await cryptoUtils.decrypt(config.customPassword); + const decryptedPassword = await cryptoUtils.resolveSecret(config.customPassword); const passwordFilePath = path.join("/tmp", `zerobyte-pass-${crypto.randomBytes(8).toString("hex")}.txt`); await fs.writeFile(passwordFilePath, decryptedPassword, { mode: 0o600 }); @@ -117,17 +117,17 @@ const buildEnv = async (config: RepositoryConfig) => { switch (config.backend) { case "s3": - env.AWS_ACCESS_KEY_ID = await cryptoUtils.decrypt(config.accessKeyId); - env.AWS_SECRET_ACCESS_KEY = await cryptoUtils.decrypt(config.secretAccessKey); + env.AWS_ACCESS_KEY_ID = await cryptoUtils.resolveSecret(config.accessKeyId); + env.AWS_SECRET_ACCESS_KEY = await cryptoUtils.resolveSecret(config.secretAccessKey); break; case "r2": - env.AWS_ACCESS_KEY_ID = await cryptoUtils.decrypt(config.accessKeyId); - env.AWS_SECRET_ACCESS_KEY = await cryptoUtils.decrypt(config.secretAccessKey); + env.AWS_ACCESS_KEY_ID = await cryptoUtils.resolveSecret(config.accessKeyId); + env.AWS_SECRET_ACCESS_KEY = await cryptoUtils.resolveSecret(config.secretAccessKey); env.AWS_REGION = "auto"; env.AWS_S3_FORCE_PATH_STYLE = "true"; break; case "gcs": { - const decryptedCredentials = await cryptoUtils.decrypt(config.credentialsJson); + const decryptedCredentials = await cryptoUtils.resolveSecret(config.credentialsJson); const credentialsPath = path.join("/tmp", `zerobyte-gcs-${crypto.randomBytes(8).toString("hex")}.json`); await fs.writeFile(credentialsPath, decryptedCredentials, { mode: 0o600 }); env.GOOGLE_PROJECT_ID = config.projectId; @@ -136,7 +136,7 @@ const buildEnv = async (config: RepositoryConfig) => { } case "azure": { env.AZURE_ACCOUNT_NAME = config.accountName; - env.AZURE_ACCOUNT_KEY = await cryptoUtils.decrypt(config.accountKey); + env.AZURE_ACCOUNT_KEY = await cryptoUtils.resolveSecret(config.accountKey); if (config.endpointSuffix) { env.AZURE_ENDPOINT_SUFFIX = config.endpointSuffix; } @@ -144,15 +144,15 @@ const buildEnv = async (config: RepositoryConfig) => { } case "rest": { if (config.username) { - env.RESTIC_REST_USERNAME = await cryptoUtils.decrypt(config.username); + env.RESTIC_REST_USERNAME = await cryptoUtils.resolveSecret(config.username); } if (config.password) { - env.RESTIC_REST_PASSWORD = await cryptoUtils.decrypt(config.password); + env.RESTIC_REST_PASSWORD = await cryptoUtils.resolveSecret(config.password); } break; } case "sftp": { - const decryptedKey = await cryptoUtils.decrypt(config.privateKey); + const decryptedKey = await cryptoUtils.resolveSecret(config.privateKey); const keyPath = path.join("/tmp", `ironmount-ssh-${crypto.randomBytes(8).toString("hex")}`); let normalizedKey = decryptedKey.replace(/\r\n/g, "\n"); diff --git a/bun.lock b/bun.lock index dc0ac731..1668570b 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,9 @@ "workspaces": { "": { "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", @@ -156,6 +159,14 @@ "@dabh/diagnostics": ["@dabh/diagnostics@2.0.8", "", { "dependencies": { "@so-ric/colorspace": "^1.1.6", "enabled": "2.0.x", "kuler": "^2.0.0" } }, "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q=="], + "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], + + "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="], + + "@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="], + + "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], + "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], diff --git a/docker-compose.yml b/docker-compose.yml index 689f167b..5ac4eee7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,8 +12,11 @@ services: - SYS_ADMIN environment: - NODE_ENV=development + # - SMB_PASSWORD=secret ports: - "4096:4096" + # secrets: + # - smb-password volumes: - /etc/localtime:/etc/localtime:ro - /var/lib/zerobyte:/var/lib/zerobyte @@ -42,3 +45,7 @@ services: - /run/docker/plugins:/run/docker/plugins - /var/run/docker.sock:/var/run/docker.sock - ~/.config/rclone:/root/.config/rclone + +# secrets: +# smb-password: +# file: ./smb-password.txt diff --git a/openapi-ts.config.ts b/openapi-ts.config.ts index 08a16687..1cde65df 100644 --- a/openapi-ts.config.ts +++ b/openapi-ts.config.ts @@ -1,7 +1,8 @@ import { defaultPlugins, defineConfig } from "@hey-api/openapi-ts"; +import { config } from "./app/server/core/config.js"; export default defineConfig({ - input: "http://192.168.2.42:4096/api/v1/openapi.json", + input: `http://${config.serverIp}:4096/api/v1/openapi.json`, output: { path: "./app/client/api-client", format: "biome", 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",