From 492aa4178df87570f8b199c91377acf3e9b9cc56 Mon Sep 17 00:00:00 2001 From: Nico <47644445+nicotsx@users.noreply.github.com> Date: Sun, 21 Dec 2025 14:21:08 +0100 Subject: [PATCH 01/14] feat: allow to control --one-file-system option from the schedule config (#201) --- .../api-client/core/serverSentEvents.gen.ts | 2 + app/client/api-client/types.gen.ts | 7 + .../components/create-schedule-form.tsx | 25 + .../modules/backups/routes/backup-details.tsx | 2 + .../modules/backups/routes/create-backup.tsx | 1 + app/drizzle/0023_special_thor.sql | 2 + app/drizzle/meta/0023_snapshot.json | 839 ++++++++++++++++++ app/drizzle/meta/_journal.json | 7 + app/server/db/schema.ts | 1 + app/server/modules/backups/backups.dto.ts | 3 + app/server/modules/backups/backups.service.ts | 3 + app/server/utils/restic.ts | 14 +- 12 files changed, 898 insertions(+), 8 deletions(-) create mode 100644 app/drizzle/0023_special_thor.sql create mode 100644 app/drizzle/meta/0023_snapshot.json diff --git a/app/client/api-client/core/serverSentEvents.gen.ts b/app/client/api-client/core/serverSentEvents.gen.ts index f8fd78e2..343d25af 100644 --- a/app/client/api-client/core/serverSentEvents.gen.ts +++ b/app/client/api-client/core/serverSentEvents.gen.ts @@ -169,6 +169,8 @@ export const createSseClient = ({ const { done, value } = await reader.read(); if (done) break; buffer += value; + // Normalize line endings: CRLF -> LF, then CR -> LF + buffer = buffer.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); const chunks = buffer.split('\n\n'); buffer = chunks.pop() ?? ''; diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index eb9dc447..5d015721 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -1305,6 +1305,7 @@ export type ListBackupSchedulesResponses = { lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null; name: string; nextBackupAt: number | null; + oneFileSystem: boolean; repository: { compressionMode: 'auto' | 'max' | 'off' | null; config: { @@ -1453,6 +1454,7 @@ export type CreateBackupScheduleData = { excludeIfPresent?: Array; excludePatterns?: Array; includePatterns?: Array; + oneFileSystem?: boolean; retentionPolicy?: { keepDaily?: number; keepHourly?: number; @@ -1486,6 +1488,7 @@ export type CreateBackupScheduleResponses = { lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null; name: string; nextBackupAt: number | null; + oneFileSystem: boolean; repositoryId: string; retentionPolicy: { keepDaily?: number; @@ -1549,6 +1552,7 @@ export type GetBackupScheduleResponses = { lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null; name: string; nextBackupAt: number | null; + oneFileSystem: boolean; repository: { compressionMode: 'auto' | 'max' | 'off' | null; config: { @@ -1696,6 +1700,7 @@ export type UpdateBackupScheduleData = { excludePatterns?: Array; includePatterns?: Array; name?: string; + oneFileSystem?: boolean; retentionPolicy?: { keepDaily?: number; keepHourly?: number; @@ -1731,6 +1736,7 @@ export type UpdateBackupScheduleResponses = { lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null; name: string; nextBackupAt: number | null; + oneFileSystem: boolean; repositoryId: string; retentionPolicy: { keepDaily?: number; @@ -1774,6 +1780,7 @@ export type GetBackupScheduleForVolumeResponses = { lastBackupStatus: 'error' | 'in_progress' | 'success' | 'warning' | null; name: string; nextBackupAt: number | null; + oneFileSystem: boolean; repository: { compressionMode: 'auto' | 'max' | 'off' | null; config: { diff --git a/app/client/modules/backups/components/create-schedule-form.tsx b/app/client/modules/backups/components/create-schedule-form.tsx index 2cf62e15..56457b93 100644 --- a/app/client/modules/backups/components/create-schedule-form.tsx +++ b/app/client/modules/backups/components/create-schedule-form.tsx @@ -6,6 +6,7 @@ import { useForm } from "react-hook-form"; import { listRepositoriesOptions } from "~/client/api-client/@tanstack/react-query.gen"; import { RepositoryIcon } from "~/client/components/repository-icon"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card"; +import { Checkbox } from "~/client/components/ui/checkbox"; import { Form, FormControl, @@ -38,6 +39,7 @@ const internalFormSchema = type({ keepWeekly: "number?", keepMonthly: "number?", keepYearly: "number?", + oneFileSystem: "boolean?", }); const cleanSchema = type.pipe((d) => internalFormSchema(deepClean(d))); @@ -101,6 +103,7 @@ const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalFormValu includePatternsText: textPatterns.length > 0 ? textPatterns.join("\n") : undefined, excludePatternsText: schedule.excludePatterns?.join("\n") || undefined, excludeIfPresentText: schedule.excludeIfPresent?.join("\n") || undefined, + oneFileSystem: schedule.oneFileSystem ?? false, ...schedule.retentionPolicy, }; }; @@ -416,6 +419,24 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: )} /> + ( + + + + +
+ Stay on one file system + + Prevent Restic from crossing file system boundaries. This is useful to avoid backing up network + mounts or other partitions that might be mounted inside your backup source. + +
+
+ )} + /> @@ -629,6 +650,10 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: )} +
+

One file system

+

{formValues.oneFileSystem ? "Enabled" : "Disabled"}

+

Retention

diff --git a/app/client/modules/backups/routes/backup-details.tsx b/app/client/modules/backups/routes/backup-details.tsx index 4804c4b7..9ddecf1f 100644 --- a/app/client/modules/backups/routes/backup-details.tsx +++ b/app/client/modules/backups/routes/backup-details.tsx @@ -162,6 +162,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon includePatterns: formValues.includePatterns, excludePatterns: formValues.excludePatterns, excludeIfPresent: formValues.excludeIfPresent, + oneFileSystem: formValues.oneFileSystem, }, }); }; @@ -177,6 +178,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon includePatterns: schedule.includePatterns || [], excludePatterns: schedule.excludePatterns || [], excludeIfPresent: schedule.excludeIfPresent || [], + oneFileSystem: schedule.oneFileSystem, }, }); }; diff --git a/app/client/modules/backups/routes/create-backup.tsx b/app/client/modules/backups/routes/create-backup.tsx index f0c47639..f0592b67 100644 --- a/app/client/modules/backups/routes/create-backup.tsx +++ b/app/client/modules/backups/routes/create-backup.tsx @@ -92,6 +92,7 @@ export default function CreateBackup({ loaderData }: Route.ComponentProps) { includePatterns: formValues.includePatterns, excludePatterns: formValues.excludePatterns, excludeIfPresent: formValues.excludeIfPresent, + oneFileSystem: formValues.oneFileSystem, }, }); }; diff --git a/app/drizzle/0023_special_thor.sql b/app/drizzle/0023_special_thor.sql new file mode 100644 index 00000000..97e81bd0 --- /dev/null +++ b/app/drizzle/0023_special_thor.sql @@ -0,0 +1,2 @@ +ALTER TABLE `backup_schedules_table` ADD `one_file_system` integer DEFAULT false NOT NULL; +UPDATE `backup_schedules_table` SET `one_file_system` = true; \ No newline at end of file diff --git a/app/drizzle/meta/0023_snapshot.json b/app/drizzle/meta/0023_snapshot.json new file mode 100644 index 00000000..0a2d13f9 --- /dev/null +++ b/app/drizzle/meta/0023_snapshot.json @@ -0,0 +1,839 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "3e3841ca-67a8-493a-a061-9c2a780878ed", + "prevId": "11c24867-3186-4578-b8dd-cee4c48a28d1", + "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 + }, + "one_file_system": { + "name": "one_file_system", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 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 2b034fbf..2d8e50d3 100644 --- a/app/drizzle/meta/_journal.json +++ b/app/drizzle/meta/_journal.json @@ -162,6 +162,13 @@ "when": 1765794552191, "tag": "0022_woozy_shen", "breakpoints": true + }, + { + "idx": 23, + "version": "6", + "when": 1766320570509, + "tag": "0023_special_thor", + "breakpoints": true } ] } \ No newline at end of file diff --git a/app/server/db/schema.ts b/app/server/db/schema.ts index e35ca820..6942bd15 100644 --- a/app/server/db/schema.ts +++ b/app/server/db/schema.ts @@ -94,6 +94,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" }), + oneFileSystem: int("one_file_system", { mode: "boolean" }).notNull().default(false), 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.dto.ts b/app/server/modules/backups/backups.dto.ts index 6173bd82..1d6c14c1 100644 --- a/app/server/modules/backups/backups.dto.ts +++ b/app/server/modules/backups/backups.dto.ts @@ -26,6 +26,7 @@ const backupScheduleSchema = type({ excludePatterns: "string[] | null", excludeIfPresent: "string[] | null", includePatterns: "string[] | null", + oneFileSystem: "boolean", lastBackupAt: "number | null", lastBackupStatus: "'success' | 'error' | 'in_progress' | 'warning' | null", lastBackupError: "string | null", @@ -131,6 +132,7 @@ export const createBackupScheduleBody = type({ excludePatterns: "string[]?", excludeIfPresent: "string[]?", includePatterns: "string[]?", + oneFileSystem: "boolean?", tags: "string[]?", }); @@ -168,6 +170,7 @@ export const updateBackupScheduleBody = type({ excludePatterns: "string[]?", excludeIfPresent: "string[]?", includePatterns: "string[]?", + oneFileSystem: "boolean?", tags: "string[]?", }); diff --git a/app/server/modules/backups/backups.service.ts b/app/server/modules/backups/backups.service.ts index 1ff51247..f8dc2837 100644 --- a/app/server/modules/backups/backups.service.ts +++ b/app/server/modules/backups/backups.service.ts @@ -124,6 +124,7 @@ const createSchedule = async (data: CreateBackupScheduleBody) => { excludePatterns: data.excludePatterns ?? [], excludeIfPresent: data.excludeIfPresent ?? [], includePatterns: data.includePatterns ?? [], + oneFileSystem: data.oneFileSystem, nextBackupAt: nextBackupAt, }) .returning(); @@ -273,9 +274,11 @@ const executeBackup = async (scheduleId: number, manual = false) => { excludeIfPresent?: string[]; include?: string[]; tags?: string[]; + oneFileSystem?: boolean; signal?: AbortSignal; } = { tags: [schedule.id.toString()], + oneFileSystem: schedule.oneFileSystem, signal: abortController.signal, }; diff --git a/app/server/utils/restic.ts b/app/server/utils/restic.ts index 8cb4502f..6753e139 100644 --- a/app/server/utils/restic.ts +++ b/app/server/utils/restic.ts @@ -238,6 +238,7 @@ const backup = async ( excludeIfPresent?: string[]; include?: string[]; tags?: string[]; + oneFileSystem?: boolean; compressionMode?: CompressionMode; signal?: AbortSignal; onProgress?: (progress: BackupProgress) => void; @@ -246,14 +247,11 @@ const backup = async ( const repoUrl = buildRepoUrl(config); const env = await buildEnv(config); - const args: string[] = [ - "--repo", - repoUrl, - "backup", - "--one-file-system", - "--compression", - options?.compressionMode ?? "auto", - ]; + const args: string[] = ["--repo", repoUrl, "backup", "--compression", options?.compressionMode ?? "auto"]; + + if (options?.oneFileSystem) { + args.push("--one-file-system"); + } if (options?.tags && options.tags.length > 0) { for (const tag of options.tags) { From 8cdd06ec49a40cb8672cb4ef07f028aa41607085 Mon Sep 17 00:00:00 2001 From: Nico <47644445+nicotsx@users.noreply.github.com> Date: Sun, 21 Dec 2025 14:25:45 +0100 Subject: [PATCH 02/14] fix(stop): always update status to warning when stop is executed (#202) * fix(stop): always update status to warning when stop is executed * fix: automatically put in_progress backups to warning during startup --- .../backups/components/schedule-summary.tsx | 3 +- app/server/modules/backups/backups.service.ts | 34 ++++++++++--------- app/server/modules/lifecycle/startup.ts | 14 +++++++- 3 files changed, 33 insertions(+), 18 deletions(-) diff --git a/app/client/modules/backups/components/schedule-summary.tsx b/app/client/modules/backups/components/schedule-summary.tsx index 8bdd3b9d..87efda27 100644 --- a/app/client/modules/backups/components/schedule-summary.tsx +++ b/app/client/modules/backups/components/schedule-summary.tsx @@ -181,7 +181,8 @@ export const ScheduleSummary = (props: Props) => {

Warning Details

- Last backup completed with warnings. Check your container logs for more details. + {schedule.lastBackupError ?? + "Last backup completed with warnings. Check your container logs for more details."}

)} diff --git a/app/server/modules/backups/backups.service.ts b/app/server/modules/backups/backups.service.ts index f8dc2837..120dc7cc 100644 --- a/app/server/modules/backups/backups.service.ts +++ b/app/server/modules/backups/backups.service.ts @@ -431,23 +431,25 @@ const stopBackup = async (scheduleId: number) => { throw new NotFoundError("Backup schedule not found"); } - const abortController = runningBackups.get(scheduleId); - if (!abortController) { - throw new ConflictError("No backup is currently running for this schedule"); + try { + const abortController = runningBackups.get(scheduleId); + if (!abortController) { + throw new ConflictError("No backup is currently running for this schedule"); + } + + logger.info(`Stopping backup for schedule ${scheduleId}`); + + abortController.abort(); + } finally { + await db + .update(backupSchedulesTable) + .set({ + lastBackupStatus: "warning", + lastBackupError: "Backup was stopped by user", + updatedAt: Date.now(), + }) + .where(eq(backupSchedulesTable.id, scheduleId)); } - - logger.info(`Stopping backup for schedule ${scheduleId}`); - - abortController.abort(); - - await db - .update(backupSchedulesTable) - .set({ - lastBackupStatus: "warning", - lastBackupError: "Backup was stopped by user", - updatedAt: Date.now(), - }) - .where(eq(backupSchedulesTable.id, scheduleId)); }; const runForget = async (scheduleId: number, repositoryId?: string) => { diff --git a/app/server/modules/lifecycle/startup.ts b/app/server/modules/lifecycle/startup.ts index 2761bf88..1bf3265a 100644 --- a/app/server/modules/lifecycle/startup.ts +++ b/app/server/modules/lifecycle/startup.ts @@ -1,7 +1,7 @@ import { Scheduler } from "../../core/scheduler"; import { and, eq, or } from "drizzle-orm"; import { db } from "../../db/db"; -import { volumesTable } from "../../db/schema"; +import { backupSchedulesTable, volumesTable } from "../../db/schema"; import { logger } from "../../utils/logger"; import { restic } from "../../utils/restic"; import { volumeService } from "../volumes/volume.service"; @@ -63,6 +63,18 @@ export const startup = async () => { }); } + await db + .update(backupSchedulesTable) + .set({ + lastBackupStatus: "warning", + lastBackupError: "Zerobyte was restarted during the last scheduled backup", + updatedAt: Date.now(), + }) + .where(eq(backupSchedulesTable.lastBackupStatus, "in_progress")) + .catch((err) => { + logger.error(`Failed to update stuck backup schedules on startup: ${err.message}`); + }); + Scheduler.build(CleanupDanglingMountsJob).schedule("0 * * * *"); Scheduler.build(VolumeHealthCheckJob).schedule("*/30 * * * *"); Scheduler.build(RepositoryHealthCheckJob).schedule("50 12 * * *"); From ea9d867cd2a793a0826f18a28e2f0dccb03105de Mon Sep 17 00:00:00 2001 From: Nico <47644445+nicotsx@users.noreply.github.com> Date: Sun, 21 Dec 2025 14:39:30 +0100 Subject: [PATCH 03/14] feat: version link to gh release in sidebar (#203) --- app/client/components/app-sidebar.tsx | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/app/client/components/app-sidebar.tsx b/app/client/components/app-sidebar.tsx index 813115dc..7796b467 100644 --- a/app/client/components/app-sidebar.tsx +++ b/app/client/components/app-sidebar.tsx @@ -46,10 +46,15 @@ const items = [ export function AppSidebar() { const { state } = useSidebar(); + const displayVersion = APP_VERSION.startsWith("v") || APP_VERSION === "dev" ? APP_VERSION : `v${APP_VERSION}`; + const releaseUrl = + APP_VERSION === "dev" + ? "https://github.com/nicotsx/zerobyte" + : `https://github.com/nicotsx/zerobyte/releases/tag/${displayVersion}`; return ( - + Zerobyte Logo -
- {APP_VERSION} -
+ {displayVersion} +
); From 1d7897b7450024d605a0f6cef1c6e0ac2336cfc7 Mon Sep 17 00:00:00 2001 From: Nico <47644445+nicotsx@users.noreply.github.com> Date: Sun, 21 Dec 2025 15:13:54 +0100 Subject: [PATCH 04/14] Configure Dependabot for npm and GitHub Actions Updated Dependabot configuration to include npm and GitHub Actions with daily update schedules. --- .github/dependabot.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..2379bbf0 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,18 @@ +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "daily" + rebase-strategy: 'auto' + groups: + minor-patch: + update-types: + - minor + - patch + + - package-ecosystem: 'github-actions' + directory: '/' + schedule: + interval: 'daily' + rebase-strategy: 'auto' From 6cc10293e1fe3489321e3001af5abbf91c5bb894 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Sun, 21 Dec 2025 15:03:10 +0100 Subject: [PATCH 05/14] fix: migration using 1 instead of true --- app/drizzle/0023_special_thor.sql | 4 +- app/drizzle/0024_schedules-one-fs.sql | 1 + app/drizzle/meta/0024_snapshot.json | 839 ++++++++++++++++++++++++++ app/drizzle/meta/_journal.json | 7 + 4 files changed, 849 insertions(+), 2 deletions(-) create mode 100644 app/drizzle/0024_schedules-one-fs.sql create mode 100644 app/drizzle/meta/0024_snapshot.json diff --git a/app/drizzle/0023_special_thor.sql b/app/drizzle/0023_special_thor.sql index 97e81bd0..066219ad 100644 --- a/app/drizzle/0023_special_thor.sql +++ b/app/drizzle/0023_special_thor.sql @@ -1,2 +1,2 @@ -ALTER TABLE `backup_schedules_table` ADD `one_file_system` integer DEFAULT false NOT NULL; -UPDATE `backup_schedules_table` SET `one_file_system` = true; \ No newline at end of file +ALTER TABLE `backup_schedules_table` ADD `one_file_system` integer DEFAULT false NOT NULL;--> statement-breakpoint +UPDATE `backup_schedules_table` SET `one_file_system` = true; diff --git a/app/drizzle/0024_schedules-one-fs.sql b/app/drizzle/0024_schedules-one-fs.sql new file mode 100644 index 00000000..d9a74d70 --- /dev/null +++ b/app/drizzle/0024_schedules-one-fs.sql @@ -0,0 +1 @@ +UPDATE `backup_schedules_table` SET `one_file_system` = 1; diff --git a/app/drizzle/meta/0024_snapshot.json b/app/drizzle/meta/0024_snapshot.json new file mode 100644 index 00000000..73c62581 --- /dev/null +++ b/app/drizzle/meta/0024_snapshot.json @@ -0,0 +1,839 @@ +{ + "id": "f19cb32f-2280-42dd-a86a-aba7c0409d9f", + "prevId": "3e3841ca-67a8-493a-a061-9c2a780878ed", + "version": "6", + "dialect": "sqlite", + "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", + "columnsFrom": [ + "schedule_id" + ], + "tableTo": "backup_schedules_table", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "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", + "columnsFrom": [ + "repository_id" + ], + "tableTo": "repositories_table", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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", + "columnsFrom": [ + "schedule_id" + ], + "tableTo": "backup_schedules_table", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "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", + "columnsFrom": [ + "destination_id" + ], + "tableTo": "notification_destinations_table", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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 + }, + "one_file_system": { + "name": "one_file_system", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 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", + "columnsFrom": [ + "volume_id" + ], + "tableTo": "volumes_table", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "backup_schedules_table_repository_id_repositories_table_id_fk": { + "name": "backup_schedules_table_repository_id_repositories_table_id_fk", + "tableFrom": "backup_schedules_table", + "columnsFrom": [ + "repository_id" + ], + "tableTo": "repositories_table", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users_table", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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": { + "columns": {}, + "schemas": {}, + "tables": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/app/drizzle/meta/_journal.json b/app/drizzle/meta/_journal.json index 2d8e50d3..e368216c 100644 --- a/app/drizzle/meta/_journal.json +++ b/app/drizzle/meta/_journal.json @@ -169,6 +169,13 @@ "when": 1766320570509, "tag": "0023_special_thor", "breakpoints": true + }, + { + "idx": 24, + "version": "6", + "when": 1766325504548, + "tag": "0024_schedules-one-fs", + "breakpoints": true } ] } \ No newline at end of file From f7822527bc8daa91c7173203ee36859a87ee414d Mon Sep 17 00:00:00 2001 From: Nico <47644445+nicotsx@users.noreply.github.com> Date: Sun, 21 Dec 2025 15:29:25 +0100 Subject: [PATCH 06/14] Change package-ecosystem from npm to bun --- .github/dependabot.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 2379bbf0..cd0c8146 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,6 +1,6 @@ version: 2 updates: - - package-ecosystem: "npm" + - package-ecosystem: "bun" directory: "/" schedule: interval: "daily" From f926d5ec1f78f7e5634cadf375d0bee4aba02c58 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 21 Dec 2025 15:36:24 +0100 Subject: [PATCH 07/14] chore(deps): bump actions/checkout from 5 to 6 (#204) Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d7d4d7e..9a081d44 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1d7f7158..326ffa0f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,7 +37,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 0 ref: ${{ github.ref }} From c426bdcbf8e1625a05bdf9a89c5e2a730cf041b5 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Sun, 21 Dec 2025 15:27:32 +0100 Subject: [PATCH 08/14] fix: sftp keep alive --- app/server/utils/restic.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/server/utils/restic.ts b/app/server/utils/restic.ts index 6753e139..a324db31 100644 --- a/app/server/utils/restic.ts +++ b/app/server/utils/restic.ts @@ -176,6 +176,10 @@ const buildEnv = async (config: RepositoryConfig) => { "UserKnownHostsFile=/dev/null", "-o", "LogLevel=VERBOSE", + "-o", + "ServerAliveInterval=60", + "-o", + "ServerAliveCountMax=240", "-i", keyPath, ]; From 91f166715814265bba7a8ea02967a6e270631474 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 21 Dec 2025 19:47:16 +0100 Subject: [PATCH 09/14] chore(deps-dev): bump vite-tsconfig-paths from 5.1.4 to 6.0.3 (#210) Bumps [vite-tsconfig-paths](https://github.com/aleclarson/vite-tsconfig-paths) from 5.1.4 to 6.0.3. - [Release notes](https://github.com/aleclarson/vite-tsconfig-paths/releases) - [Commits](https://github.com/aleclarson/vite-tsconfig-paths/compare/v5.1.4...v6.0.3) --- updated-dependencies: - dependency-name: vite-tsconfig-paths dependency-version: 6.0.3 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- bun.lock | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index 82e3a37c..ed145714 100644 --- a/bun.lock +++ b/bun.lock @@ -76,7 +76,7 @@ "typescript": "^5.9.3", "vite": "^7.2.6", "vite-bundle-analyzer": "^1.2.3", - "vite-tsconfig-paths": "^5.1.4", + "vite-tsconfig-paths": "^6.0.3", }, }, }, @@ -1068,7 +1068,7 @@ "vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="], - "vite-tsconfig-paths": ["vite-tsconfig-paths@5.1.4", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" }, "optionalPeers": ["vite"] }, "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w=="], + "vite-tsconfig-paths": ["vite-tsconfig-paths@6.0.3", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" }, "optionalPeers": ["vite"] }, "sha512-7bL7FPX/DSviaZGYUKowWF1AiDVWjMjxNbE8lyaVGDezkedWqfGhlnQ4BZXre0ZN5P4kAgIJfAlgFDVyjrCIyg=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], diff --git a/package.json b/package.json index c6e73e60..d14adbc5 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,6 @@ "typescript": "^5.9.3", "vite": "^7.2.6", "vite-bundle-analyzer": "^1.2.3", - "vite-tsconfig-paths": "^5.1.4" + "vite-tsconfig-paths": "^6.0.3" } } From fe8eb1326dce84f9d0317c209af30fe9b3353bbb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 21 Dec 2025 19:51:03 +0100 Subject: [PATCH 10/14] chore(deps-dev): bump @types/node from 24.10.4 to 25.0.3 (#209) * chore(deps-dev): bump @types/node from 24.10.4 to 25.0.3 Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 24.10.4 to 25.0.3. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) --- updated-dependencies: - dependency-name: "@types/node" dependency-version: 25.0.3 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] * chore: use rm instead of rmdir --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Nicolas Meienberger --- app/server/jobs/cleanup-dangling.ts | 2 +- bun.lock | 6 ++++-- package.json | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/app/server/jobs/cleanup-dangling.ts b/app/server/jobs/cleanup-dangling.ts index 2051b027..858d8565 100644 --- a/app/server/jobs/cleanup-dangling.ts +++ b/app/server/jobs/cleanup-dangling.ts @@ -40,7 +40,7 @@ export class CleanupDanglingMountsJob extends Job { if (!matchingVolume) { const fullPath = path.join(VOLUME_MOUNT_BASE, dir); logger.info(`Found dangling mount directory at ${fullPath}, attempting to remove...`); - await fs.rmdir(fullPath, { recursive: true }).catch((err) => { + await fs.rm(fullPath, { recursive: true, force: true }).catch((err) => { logger.warn(`Failed to remove dangling mount directory ${fullPath}: ${toMessage(err)}`); }); } diff --git a/bun.lock b/bun.lock index ed145714..63d08e3d 100644 --- a/bun.lock +++ b/bun.lock @@ -64,7 +64,7 @@ "@tailwindcss/vite": "^4.1.17", "@tanstack/react-query-devtools": "^5.91.1", "@types/bun": "^1.3.4", - "@types/node": "^24.10.1", + "@types/node": "^25.0.3", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "dotenv-cli": "^11.0.0", @@ -484,7 +484,7 @@ "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], - "@types/node": ["@types/node@24.10.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg=="], + "@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], "@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="], @@ -1138,6 +1138,8 @@ "body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + "bun-types/@types/node": ["@types/node@24.10.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg=="], + "c12/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "compression/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], diff --git a/package.json b/package.json index d14adbc5..4f3c2416 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,7 @@ "@tailwindcss/vite": "^4.1.17", "@tanstack/react-query-devtools": "^5.91.1", "@types/bun": "^1.3.4", - "@types/node": "^24.10.1", + "@types/node": "^25.0.3", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "dotenv-cli": "^11.0.0", From 11f2c27f7f3bcce1045ae636bbc02b8d10d54e9b Mon Sep 17 00:00:00 2001 From: Alessandro Zappia Date: Fri, 19 Dec 2025 21:24:31 +0100 Subject: [PATCH 11/14] add monthly backup frequency with multi-day selection --- .../components/create-schedule-form.tsx | 53 +++++++++++++++++-- .../modules/backups/routes/backup-details.tsx | 2 +- .../modules/backups/routes/create-backup.tsx | 2 +- app/utils/utils.ts | 8 ++- 4 files changed, 58 insertions(+), 7 deletions(-) diff --git a/app/client/modules/backups/components/create-schedule-form.tsx b/app/client/modules/backups/components/create-schedule-form.tsx index 56457b93..4ae1faf5 100644 --- a/app/client/modules/backups/components/create-schedule-form.tsx +++ b/app/client/modules/backups/components/create-schedule-form.tsx @@ -1,4 +1,5 @@ import { arktypeResolver } from "@hookform/resolvers/arktype"; + import { useQuery } from "@tanstack/react-query"; import { type } from "arktype"; import { useCallback, useState } from "react"; @@ -18,6 +19,7 @@ import { } from "~/client/components/ui/form"; import { Input } from "~/client/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select"; +import { Button } from "~/client/components/ui/button"; import { Textarea } from "~/client/components/ui/textarea"; import { VolumeFileBrowser } from "~/client/components/volume-file-browser"; import type { BackupSchedule, Volume } from "~/client/lib/types"; @@ -33,6 +35,7 @@ const internalFormSchema = type({ frequency: "string", dailyTime: "string?", weeklyDay: "string?", + monthlyDays: "string[]?", keepLast: "number?", keepHourly: "number?", keepDaily: "number?", @@ -78,15 +81,19 @@ const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalFormValu } const parts = schedule.cronExpression.split(" "); - const [minutePart, hourPart, , , dayOfWeekPart] = parts; + const [minutePart, hourPart, dayOfMonthPart, , dayOfWeekPart] = parts; const isHourly = hourPart === "*"; - const isDaily = !isHourly && dayOfWeekPart === "*"; - const frequency = isHourly ? "hourly" : isDaily ? "daily" : "weekly"; + const isMonthly = !isHourly && dayOfMonthPart !== "*" && dayOfWeekPart === "*"; + const isDaily = !isHourly && dayOfMonthPart === "*" && dayOfWeekPart === "*"; + + const frequency = isHourly ? "hourly" : isMonthly ? "monthly" : isDaily ? "daily" : "weekly"; const dailyTime = isHourly ? undefined : `${hourPart.padStart(2, "0")}:${minutePart.padStart(2, "0")}`; const weeklyDay = frequency === "weekly" ? dayOfWeekPart : undefined; + + const monthlyDays = isMonthly ? dayOfMonthPart.split(",") : undefined; const patterns = schedule.includePatterns || []; const isGlobPattern = (p: string) => /[*?[\]]/.test(p); @@ -96,7 +103,8 @@ const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalFormValu return { name: schedule.name, repositoryId: schedule.repositoryId, - frequency, + frequency, + monthlyDays, dailyTime, weeklyDay, includePatterns: fileBrowserPaths.length > 0 ? fileBrowserPaths : undefined, @@ -249,6 +257,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: Hourly Daily Weekly + Monthly @@ -302,6 +311,42 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: )} /> )} + {frequency === "monthly" && ( + ( + + Days of the month + +
+ {Array.from({ length: 31 }, (_, i) => { + const day = (i + 1).toString(); + const isSelected = field.value?.includes(day); + return ( + + ); + })} +
+
+ Select one or more days when the backup should run. + +
+ )} + /> + )} diff --git a/app/client/modules/backups/routes/backup-details.tsx b/app/client/modules/backups/routes/backup-details.tsx index 9ddecf1f..dd67883c 100644 --- a/app/client/modules/backups/routes/backup-details.tsx +++ b/app/client/modules/backups/routes/backup-details.tsx @@ -141,7 +141,7 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon const handleSubmit = (formValues: BackupScheduleFormValues) => { if (!schedule) return; - const cronExpression = getCronExpression(formValues.frequency, formValues.dailyTime, formValues.weeklyDay); + const cronExpression = getCronExpression(formValues.frequency, formValues.dailyTime, formValues.weeklyDay, formValues.monthlyDays); const retentionPolicy: Record = {}; if (formValues.keepLast) retentionPolicy.keepLast = formValues.keepLast; diff --git a/app/client/modules/backups/routes/create-backup.tsx b/app/client/modules/backups/routes/create-backup.tsx index f0592b67..4c51ac34 100644 --- a/app/client/modules/backups/routes/create-backup.tsx +++ b/app/client/modules/backups/routes/create-backup.tsx @@ -71,7 +71,7 @@ export default function CreateBackup({ loaderData }: Route.ComponentProps) { const handleSubmit = (formValues: BackupScheduleFormValues) => { if (!selectedVolumeId) return; - const cronExpression = getCronExpression(formValues.frequency, formValues.dailyTime, formValues.weeklyDay); + const cronExpression = getCronExpression(formValues.frequency, formValues.dailyTime, formValues.weeklyDay, formValues.monthlyDays); const retentionPolicy: Record = {}; if (formValues.keepLast) retentionPolicy.keepLast = formValues.keepLast; diff --git a/app/utils/utils.ts b/app/utils/utils.ts index dc5d3a29..ba382152 100644 --- a/app/utils/utils.ts +++ b/app/utils/utils.ts @@ -1,6 +1,6 @@ import { intervalToDuration } from "date-fns"; -export const getCronExpression = (frequency: string, dailyTime?: string, weeklyDay?: string): string => { +export const getCronExpression = (frequency: string, dailyTime?: string, weeklyDay?: string,monthlyDays?: string[]): string => { if (frequency === "hourly") { return "0 * * * *"; } @@ -14,6 +14,12 @@ export const getCronExpression = (frequency: string, dailyTime?: string, weeklyD if (frequency === "daily") { return `${minutes} ${hours} * * *`; } + + if (frequency === "monthly") { + const sortedDays = (monthlyDays || []).map(Number).sort((a, b) => a - b); + const days = sortedDays.length > 0 ? sortedDays.join(",") : "1"; + return `${minutes} ${hours} ${days} * *`; + } return `${minutes} ${hours} * * ${weeklyDay ?? "0"}`; }; From 47aefa9bcb5db5f930ce5df7027e85e0f4775b0f Mon Sep 17 00:00:00 2001 From: Alessandro Zappia <60665910+alexzapd@users.noreply.github.com> Date: Fri, 19 Dec 2025 22:31:41 +0100 Subject: [PATCH 12/14] Update app/utils/utils.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- app/utils/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/utils/utils.ts b/app/utils/utils.ts index ba382152..ab015f48 100644 --- a/app/utils/utils.ts +++ b/app/utils/utils.ts @@ -1,6 +1,6 @@ import { intervalToDuration } from "date-fns"; -export const getCronExpression = (frequency: string, dailyTime?: string, weeklyDay?: string,monthlyDays?: string[]): string => { +export const getCronExpression = (frequency: string, dailyTime?: string, weeklyDay?: string, monthlyDays?: string[]): string => { if (frequency === "hourly") { return "0 * * * *"; } From e350843d4ec457cf76b2a984fadc259e79d5465b Mon Sep 17 00:00:00 2001 From: Alessandro Zappia <60665910+alexzapd@users.noreply.github.com> Date: Fri, 19 Dec 2025 22:34:19 +0100 Subject: [PATCH 13/14] Update app/utils/utils.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- app/utils/utils.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/utils/utils.ts b/app/utils/utils.ts index ab015f48..bd8fd5a1 100644 --- a/app/utils/utils.ts +++ b/app/utils/utils.ts @@ -16,7 +16,10 @@ export const getCronExpression = (frequency: string, dailyTime?: string, weeklyD } if (frequency === "monthly") { - const sortedDays = (monthlyDays || []).map(Number).sort((a, b) => a - b); + const sortedDays = (monthlyDays || []) + .map(Number) + .filter((day) => day >= 1 && day <= 31) + .sort((a, b) => a - b); const days = sortedDays.length > 0 ? sortedDays.join(",") : "1"; return `${minutes} ${hours} ${days} * *`; } From a985c95a31256a14b04f312e6a01199441e2d728 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Sun, 21 Dec 2025 20:34:33 +0100 Subject: [PATCH 14/14] chore: wording and formatting --- .../components/create-schedule-form.tsx | 81 +++++++++---------- .../modules/backups/routes/backup-details.tsx | 7 +- .../modules/backups/routes/create-backup.tsx | 7 +- app/utils/utils.ts | 23 +++--- 4 files changed, 65 insertions(+), 53 deletions(-) diff --git a/app/client/modules/backups/components/create-schedule-form.tsx b/app/client/modules/backups/components/create-schedule-form.tsx index 4ae1faf5..adfaf820 100644 --- a/app/client/modules/backups/components/create-schedule-form.tsx +++ b/app/client/modules/backups/components/create-schedule-form.tsx @@ -88,12 +88,9 @@ const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalFormValu const isDaily = !isHourly && dayOfMonthPart === "*" && dayOfWeekPart === "*"; const frequency = isHourly ? "hourly" : isMonthly ? "monthly" : isDaily ? "daily" : "weekly"; - const dailyTime = isHourly ? undefined : `${hourPart.padStart(2, "0")}:${minutePart.padStart(2, "0")}`; - const weeklyDay = frequency === "weekly" ? dayOfWeekPart : undefined; - - const monthlyDays = isMonthly ? dayOfMonthPart.split(",") : undefined; + const monthlyDays = isMonthly ? dayOfMonthPart.split(",") : undefined; const patterns = schedule.includePatterns || []; const isGlobPattern = (p: string) => /[*?[\]]/.test(p); @@ -103,8 +100,8 @@ const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalFormValu return { name: schedule.name, repositoryId: schedule.repositoryId, - frequency, - monthlyDays, + frequency, + monthlyDays, dailyTime, weeklyDay, includePatterns: fileBrowserPaths.length > 0 ? fileBrowserPaths : undefined, @@ -257,7 +254,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: Hourly Daily Weekly - Monthly + Specific days @@ -312,41 +309,41 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: /> )} {frequency === "monthly" && ( - ( - - Days of the month - -
- {Array.from({ length: 31 }, (_, i) => { - const day = (i + 1).toString(); - const isSelected = field.value?.includes(day); - return ( - - ); - })} -
-
- Select one or more days when the backup should run. - -
- )} - /> - )} + ( + + Days of the month + +
+ {Array.from({ length: 31 }, (_, i) => { + const day = (i + 1).toString(); + const isSelected = field.value?.includes(day); + return ( + + ); + })} +
+
+ Select one or more days when the backup should run. + +
+ )} + /> + )} diff --git a/app/client/modules/backups/routes/backup-details.tsx b/app/client/modules/backups/routes/backup-details.tsx index dd67883c..6081a7a6 100644 --- a/app/client/modules/backups/routes/backup-details.tsx +++ b/app/client/modules/backups/routes/backup-details.tsx @@ -141,7 +141,12 @@ export default function ScheduleDetailsPage({ params, loaderData }: Route.Compon const handleSubmit = (formValues: BackupScheduleFormValues) => { if (!schedule) return; - const cronExpression = getCronExpression(formValues.frequency, formValues.dailyTime, formValues.weeklyDay, formValues.monthlyDays); + const cronExpression = getCronExpression( + formValues.frequency, + formValues.dailyTime, + formValues.weeklyDay, + formValues.monthlyDays, + ); const retentionPolicy: Record = {}; if (formValues.keepLast) retentionPolicy.keepLast = formValues.keepLast; diff --git a/app/client/modules/backups/routes/create-backup.tsx b/app/client/modules/backups/routes/create-backup.tsx index 4c51ac34..7b6484f5 100644 --- a/app/client/modules/backups/routes/create-backup.tsx +++ b/app/client/modules/backups/routes/create-backup.tsx @@ -71,7 +71,12 @@ export default function CreateBackup({ loaderData }: Route.ComponentProps) { const handleSubmit = (formValues: BackupScheduleFormValues) => { if (!selectedVolumeId) return; - const cronExpression = getCronExpression(formValues.frequency, formValues.dailyTime, formValues.weeklyDay, formValues.monthlyDays); + const cronExpression = getCronExpression( + formValues.frequency, + formValues.dailyTime, + formValues.weeklyDay, + formValues.monthlyDays, + ); const retentionPolicy: Record = {}; if (formValues.keepLast) retentionPolicy.keepLast = formValues.keepLast; diff --git a/app/utils/utils.ts b/app/utils/utils.ts index bd8fd5a1..9225e300 100644 --- a/app/utils/utils.ts +++ b/app/utils/utils.ts @@ -1,6 +1,11 @@ import { intervalToDuration } from "date-fns"; -export const getCronExpression = (frequency: string, dailyTime?: string, weeklyDay?: string, monthlyDays?: string[]): string => { +export const getCronExpression = ( + frequency: string, + dailyTime?: string, + weeklyDay?: string, + monthlyDays?: string[], +): string => { if (frequency === "hourly") { return "0 * * * *"; } @@ -14,15 +19,15 @@ export const getCronExpression = (frequency: string, dailyTime?: string, weeklyD if (frequency === "daily") { return `${minutes} ${hours} * * *`; } - + if (frequency === "monthly") { - const sortedDays = (monthlyDays || []) - .map(Number) - .filter((day) => day >= 1 && day <= 31) - .sort((a, b) => a - b); - const days = sortedDays.length > 0 ? sortedDays.join(",") : "1"; - return `${minutes} ${hours} ${days} * *`; - } + const sortedDays = (monthlyDays || []) + .map(Number) + .filter((day) => day >= 1 && day <= 31) + .sort((a, b) => a - b); + const days = sortedDays.length > 0 ? sortedDays.join(",") : "1"; + return `${minutes} ${hours} ${days} * *`; + } return `${minutes} ${hours} * * ${weeklyDay ?? "0"}`; };