From db127afe5ab3e61be94b38e7b4dcfd0421137e85 Mon Sep 17 00:00:00 2001 From: Raj Dave Date: Sat, 10 Jan 2026 14:03:45 +0300 Subject: [PATCH] Adds bandwidth limiting to repositories Implements bandwidth limits for repository uploads and downloads. Adds UI components to configure bandwidth limits. Adds database fields to store bandwidth limit configuration. Integrates bandwidth limiting into restic commands. --- .gitignore | 2 + .../repository-forms/advanced-tls-form.tsx | 185 +++ app/drizzle/0032_outstanding_ultron.sql | 6 + app/drizzle/meta/0032_snapshot.json | 1243 +++++++++++++++++ app/drizzle/meta/_journal.json | 7 + app/schemas/restic.ts | 20 + app/server/db/schema.ts | 7 + app/server/utils/rclone-dummy.ts | 0 app/server/utils/restic.ts | 121 +- 9 files changed, 1561 insertions(+), 30 deletions(-) create mode 100644 app/drizzle/0032_outstanding_ultron.sql create mode 100644 app/drizzle/meta/0032_snapshot.json create mode 100644 app/server/utils/rclone-dummy.ts diff --git a/.gitignore b/.gitignore index c2b6cf20..887dcd56 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,5 @@ data/ .env* !.env.example !.env.test + +.idea/ diff --git a/app/client/modules/repositories/components/repository-forms/advanced-tls-form.tsx b/app/client/modules/repositories/components/repository-forms/advanced-tls-form.tsx index d5f50256..11653990 100644 --- a/app/client/modules/repositories/components/repository-forms/advanced-tls-form.tsx +++ b/app/client/modules/repositories/components/repository-forms/advanced-tls-form.tsx @@ -7,12 +7,15 @@ import { FormLabel, FormMessage, } from "../../../../components/ui/form"; +import { Input } from "../../../../components/ui/input"; import { Textarea } from "../../../../components/ui/textarea"; import { Checkbox } from "../../../../components/ui/checkbox"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../../components/ui/select"; import { Tooltip, TooltipContent, TooltipTrigger } from "../../../../components/ui/tooltip"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../../../../components/ui/collapsible"; import type { RepositoryFormValues } from "../create-repository-form"; import { cn } from "~/client/lib/utils"; +import { BANDWIDTH_UNITS } from "~/schemas/restic"; type Props = { form: UseFormReturn; @@ -21,11 +24,193 @@ type Props = { export const AdvancedForm = ({ form }: Props) => { const insecureTls = form.watch("insecureTls"); const cacert = form.watch("cacert"); + const uploadEnabled = form.watch("uploadLimit.enabled"); + const downloadEnabled = form.watch("downloadLimit.enabled"); return ( Advanced Settings + {/* Bandwidth Limit Controls */} +
+
+
+

Bandwidth Limits

+

+ Control upload and download speeds to prevent saturating network bandwidth +

+
+
+ +
+ {/* Upload Limit */} +
+
+

Upload Limit

+
+ + ( + + + + +
+ Enable upload speed limit + + Limit upload speed to the repository + +
+
+ )} + /> + + {uploadEnabled && ( +
+
+ ( + + +
+ field.onChange(parseFloat(e.target.value) || 0)} + /> +
+
+
+
+ + + + )} + /> + ( + + + + + )} + /> +
+
+ )} +
+ + {/* Download Limit */} +
+
+

Download Limit

+
+ + ( + + + + +
+ Enable download speed limit + + Limit download speed from the repository + +
+
+ )} + /> + + {downloadEnabled && ( +
+
+ ( + + +
+ field.onChange(parseFloat(e.target.value) || 0)} + /> +
+
+
+
+ + + + )} + /> + ( + + + + + )} + /> +
+
+ )} +
+
+
+ statement-breakpoint +ALTER TABLE `repositories_table` ADD `upload_limit_value` integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE `repositories_table` ADD `upload_limit_unit` text DEFAULT 'MB/s' NOT NULL;--> statement-breakpoint +ALTER TABLE `repositories_table` ADD `download_limit_enabled` integer DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE `repositories_table` ADD `download_limit_value` integer DEFAULT 0 NOT NULL;--> statement-breakpoint +ALTER TABLE `repositories_table` ADD `download_limit_unit` text DEFAULT 'MB/s' NOT NULL; \ No newline at end of file diff --git a/app/drizzle/meta/0032_snapshot.json b/app/drizzle/meta/0032_snapshot.json new file mode 100644 index 00000000..3722f852 --- /dev/null +++ b/app/drizzle/meta/0032_snapshot.json @@ -0,0 +1,1243 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "2d6b79a4-2d75-4240-8943-1180372847ba", + "prevId": "ae0e3a39-ef4d-4d39-bb97-d67d49193bc7", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "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": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_users_table_id_fk": { + "name": "account_user_id_users_table_id_fk", + "tableFrom": "account", + "tableTo": "users_table", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "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 + }, + "short_id": { + "name": "short_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "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_short_id_unique": { + "name": "backup_schedules_table_short_id_unique", + "columns": [ + "short_id" + ], + "isUnique": true + }, + "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 + }, + "upload_limit_enabled": { + "name": "upload_limit_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "upload_limit_value": { + "name": "upload_limit_value", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "upload_limit_unit": { + "name": "upload_limit_unit", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'MB/s'" + }, + "download_limit_enabled": { + "name": "download_limit_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "download_limit_value": { + "name": "download_limit_value", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "download_limit_unit": { + "name": "download_limit_unit", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'MB/s'" + }, + "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 + } + }, + "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": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "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)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "sessions_table_token_unique": { + "name": "sessions_table_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "sessionsTable_userId_idx": { + "name": "sessionsTable_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "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": {} + }, + "two_factor": { + "name": "two_factor", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "backup_codes": { + "name": "backup_codes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "twoFactor_secret_idx": { + "name": "twoFactor_secret_idx", + "columns": [ + "secret" + ], + "isUnique": false + }, + "twoFactor_userId_idx": { + "name": "twoFactor_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "two_factor_user_id_users_table_id_fk": { + "name": "two_factor_user_id_users_table_id_fk", + "tableFrom": "two_factor", + "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": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "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)" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "display_username": { + "name": "display_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "two_factor_enabled": { + "name": "two_factor_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "users_table_username_unique": { + "name": "users_table_username_unique", + "columns": [ + "username" + ], + "isUnique": true + }, + "users_table_email_unique": { + "name": "users_table_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "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)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "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 f7fbfcbb..a7ced5d0 100644 --- a/app/drizzle/meta/_journal.json +++ b/app/drizzle/meta/_journal.json @@ -225,6 +225,13 @@ "when": 1767863951955, "tag": "0031_graceful_squadron_supreme", "breakpoints": true + }, + { + "idx": 32, + "version": "6", + "when": 1768040838612, + "tag": "0032_outstanding_ultron", + "breakpoints": true } ] } \ No newline at end of file diff --git a/app/schemas/restic.ts b/app/schemas/restic.ts index f6001fb1..7a8f2e7d 100644 --- a/app/schemas/restic.ts +++ b/app/schemas/restic.ts @@ -13,12 +13,32 @@ export const REPOSITORY_BACKENDS = { export type RepositoryBackend = keyof typeof REPOSITORY_BACKENDS; +export const BANDWIDTH_UNITS = { + "KB/s": "KB/s", + "MB/s": "MB/s", + "GB/s": "GB/s", +} as const; + +export type BandwidthUnit = keyof typeof BANDWIDTH_UNITS; + +// Bandwidth limit configuration +export const bandwidthLimitSchema = type({ + enabled: "boolean = false", + value: "number >= 0", + unit: type.valueOf(BANDWIDTH_UNITS).default("MB/s"), +}); + +export type BandwidthLimit = typeof bandwidthLimitSchema.infer; + // Common fields for all repository configs const baseRepositoryConfigSchema = type({ isExistingRepository: "boolean?", customPassword: "string?", cacert: "string?", insecureTls: "boolean?", + // Bandwidth controls + uploadLimit: bandwidthLimitSchema.optional(), + downloadLimit: bandwidthLimitSchema.optional(), }); export const s3RepositoryConfigSchema = type({ diff --git a/app/server/db/schema.ts b/app/server/db/schema.ts index 647a2f4e..4c5456ff 100644 --- a/app/server/db/schema.ts +++ b/app/server/db/schema.ts @@ -159,6 +159,13 @@ export const repositoriesTable = sqliteTable("repositories_table", { status: text().$type().default("unknown"), lastChecked: int("last_checked", { mode: "number" }), lastError: text("last_error"), + // Bandwidth limit fields + uploadLimitEnabled: int("upload_limit_enabled", { mode: "boolean" }).notNull().default(false), + uploadLimitValue: int("upload_limit_value").notNull().default(0), + uploadLimitUnit: text("upload_limit_unit").notNull().default("MB/s"), + downloadLimitEnabled: int("download_limit_enabled", { mode: "boolean" }).notNull().default(false), + downloadLimitValue: int("download_limit_value").notNull().default(0), + downloadLimitUnit: text("download_limit_unit").notNull().default("MB/s"), createdAt: int("created_at", { mode: "number" }) .notNull() .default(sql`(unixepoch() * 1000)`), diff --git a/app/server/utils/rclone-dummy.ts b/app/server/utils/rclone-dummy.ts new file mode 100644 index 00000000..e69de29b diff --git a/app/server/utils/restic.ts b/app/server/utils/restic.ts index 4eabbb49..97677084 100644 --- a/app/server/utils/restic.ts +++ b/app/server/utils/restic.ts @@ -10,7 +10,7 @@ import { logger } from "./logger"; import { cryptoUtils } from "./crypto"; import type { RetentionPolicy } from "../modules/backups/backups.dto"; import { safeSpawn } from "./spawn"; -import type { CompressionMode, RepositoryConfig, OverwriteMode } from "~/schemas/restic"; +import type { CompressionMode, RepositoryConfig, OverwriteMode, BandwidthLimit } from "~/schemas/restic"; import { ResticError } from "./errors"; const backupOutputSchema = type({ @@ -229,7 +229,7 @@ const init = async (config: RepositoryConfig) => { const env = await buildEnv(config); const args = ["init", "--repo", repoUrl]; - addCommonArgs(args, env); + addCommonArgs(args, env, config); const res = await safeSpawn({ command: "restic", args, env }); await cleanupTemporaryKeys(env); @@ -321,7 +321,7 @@ const backup = async ( } } - addCommonArgs(args, env); + addCommonArgs(args, env, config); const logData = throttle((data: string) => { logger.info(data.trim()); @@ -453,7 +453,7 @@ const restore = async ( } } - addCommonArgs(args, env); + addCommonArgs(args, env, config); logger.debug(`Executing: restic ${args.join(" ")}`); const res = await safeSpawn({ command: "restic", args, env }); @@ -516,7 +516,7 @@ const snapshots = async (config: RepositoryConfig, options: { tags?: string[] } } } - addCommonArgs(args, env); + addCommonArgs(args, env, config); const res = await safeSpawn({ command: "restic", args, env }); await cleanupTemporaryKeys(env); @@ -565,7 +565,7 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra: } args.push("--prune"); - addCommonArgs(args, env); + addCommonArgs(args, env, config); const res = await safeSpawn({ command: "restic", args, env }); await cleanupTemporaryKeys(env); @@ -587,7 +587,7 @@ const deleteSnapshots = async (config: RepositoryConfig, snapshotIds: string[]) } const args: string[] = ["--repo", repoUrl, "forget", ...snapshotIds, "--prune"]; - addCommonArgs(args, env); + addCommonArgs(args, env, config); const res = await safeSpawn({ command: "restic", args, env }); await cleanupTemporaryKeys(env); @@ -636,7 +636,7 @@ const tagSnapshots = async ( } } - addCommonArgs(args, env); + addCommonArgs(args, env, config); const res = await safeSpawn({ command: "restic", args, env }); await cleanupTemporaryKeys(env); @@ -686,7 +686,7 @@ const ls = async (config: RepositoryConfig, snapshotId: string, path?: string) = args.push(path); } - addCommonArgs(args, env); + addCommonArgs(args, env, config); const res = await safeSpawn({ command: "restic", args, env }); await cleanupTemporaryKeys(env); @@ -737,7 +737,7 @@ const unlock = async (config: RepositoryConfig) => { const env = await buildEnv(config); const args = ["unlock", "--repo", repoUrl, "--remove-all"]; - addCommonArgs(args, env); + addCommonArgs(args, env, config); const res = await safeSpawn({ command: "restic", args, env }); await cleanupTemporaryKeys(env); @@ -761,7 +761,7 @@ const check = async (config: RepositoryConfig, options?: { readData?: boolean }) args.push("--read-data"); } - addCommonArgs(args, env); + addCommonArgs(args, env, config); const res = await safeSpawn({ command: "restic", args, env }); await cleanupTemporaryKeys(env); @@ -794,7 +794,7 @@ const repairIndex = async (config: RepositoryConfig) => { const env = await buildEnv(config); const args = ["repair", "index", "--repo", repoUrl]; - addCommonArgs(args, env); + addCommonArgs(args, env, config); const res = await safeSpawn({ command: "restic", args, env }); await cleanupTemporaryKeys(env); @@ -846,7 +846,7 @@ const copy = async ( args.push("latest"); } - addCommonArgs(args, env); + addCommonArgs(args, env, destConfig); if (sourceConfig.backend === "sftp" && sourceEnv._SFTP_SSH_ARGS) { args.push("-o", `sftp.args=${sourceEnv._SFTP_SSH_ARGS}`); @@ -874,29 +874,32 @@ const copy = async ( }; }; -export const cleanupTemporaryKeys = async (env: Record) => { - if (env._SFTP_KEY_PATH) { - await fs.unlink(env._SFTP_KEY_PATH).catch(() => {}); +// Helper function to convert bandwidth limit to restic format +const formatBandwidthLimit = (limit: BandwidthLimit): string => { + if (!limit.enabled || limit.value <= 0) { + return ""; } - if (env._SFTP_KNOWN_HOSTS_PATH) { - await fs.unlink(env._SFTP_KNOWN_HOSTS_PATH).catch(() => {}); + // Convert to bytes per second for restic + let bytesPerSecond: number; + switch (limit.unit) { + case "KB/s": + bytesPerSecond = limit.value * 1024; + break; + case "MB/s": + bytesPerSecond = limit.value * 1024 * 1024; + break; + case "GB/s": + bytesPerSecond = limit.value * 1024 * 1024 * 1024; + break; + default: + return ""; } - if (env.RESTIC_PASSWORD_FILE && env.RESTIC_PASSWORD_FILE !== RESTIC_PASS_FILE) { - await fs.unlink(env.RESTIC_PASSWORD_FILE).catch(() => {}); - } - - if (env.GOOGLE_APPLICATION_CREDENTIALS) { - await fs.unlink(env.GOOGLE_APPLICATION_CREDENTIALS).catch(() => {}); - } - - if (env.RESTIC_CACERT) { - await fs.unlink(env.RESTIC_CACERT).catch(() => {}); - } + return `${Math.floor(bytesPerSecond)}`; }; -export const addCommonArgs = (args: string[], env: Record) => { +export const addCommonArgs = (args: string[], env: Record, config?: RepositoryConfig) => { args.push("--json"); if (env._SFTP_SSH_ARGS) { @@ -910,6 +913,40 @@ export const addCommonArgs = (args: string[], env: Record) => { if (env.RESTIC_CACERT) { args.push("--cacert", env.RESTIC_CACERT); } + + // Add bandwidth limits if configuration is provided + if (config) { + // Handle upload limit + if (config.uploadLimit?.enabled) { + const uploadLimit = formatBandwidthLimit(config.uploadLimit); + if (uploadLimit) { + if (config.backend === "rclone") { + // For rclone backends, use --bwlimit + args.push("-o", `rclone.bwlimit=${uploadLimit}`); + } else { + // For restic backends, use --limit-upload + args.push("--limit-upload", uploadLimit); + } + } + } + + // Handle download limit + if (config.downloadLimit?.enabled) { + const downloadLimit = formatBandwidthLimit(config.downloadLimit); + if (downloadLimit) { + if (config.backend === "rclone") { + // For rclone, bwlimit affects both upload and download + // If both limits are set, use the more restrictive one + const uploadLimit = config.uploadLimit?.enabled ? formatBandwidthLimit(config.uploadLimit) : ""; + const effectiveLimit = uploadLimit && parseInt(uploadLimit) < parseInt(downloadLimit) ? uploadLimit : downloadLimit; + args.push("-o", `rclone.bwlimit=${effectiveLimit}`); + } else { + // For restic backends, use --limit-download + args.push("--limit-download", downloadLimit); + } + } + } + } }; export const restic = { @@ -928,3 +965,27 @@ export const restic = { repairIndex, copy, }; + +// Helper function to clean up temporary files +const cleanupTemporaryKeys = async (env: Record) => { + const keysToClean = ['_SFTP_KEY_PATH', '_SFTP_KNOWN_HOSTS_PATH', 'RESTIC_CACERT', 'GOOGLE_APPLICATION_CREDENTIALS']; + + for (const key of keysToClean) { + if (env[key]) { + try { + await fs.unlink(env[key]); + } catch (error) { + // Ignore errors when cleaning up temporary files + } + } + } + + // Clean up custom password files + if (env.RESTIC_PASSWORD_FILE && env.RESTIC_PASSWORD_FILE !== RESTIC_PASS_FILE) { + try { + await fs.unlink(env.RESTIC_PASSWORD_FILE); + } catch (error) { + // Ignore errors when cleaning up temporary files + } + } +};