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 11653990..e042c4d9 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 @@ -10,7 +10,13 @@ import { 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 { + 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"; @@ -103,7 +109,7 @@ export const AdvancedForm = ({ form }: Props) => { name="uploadLimit.unit" render={({ field }) => ( - @@ -186,7 +192,7 @@ export const AdvancedForm = ({ form }: Props) => { name="downloadLimit.unit" render={({ field }) => ( - diff --git a/app/schemas/restic.ts b/app/schemas/restic.ts index 7a8f2e7d..f9b5123c 100644 --- a/app/schemas/restic.ts +++ b/app/schemas/restic.ts @@ -14,9 +14,9 @@ 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", + "Kbps": "Kbps", + "Mbps": "Mbps", + "Gbps": "Gbps", } as const; export type BandwidthUnit = keyof typeof BANDWIDTH_UNITS; @@ -24,8 +24,8 @@ 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"), + value: "number >= 0 = 0", + unit: type.valueOf(BANDWIDTH_UNITS).default("Mbps"), }); export type BandwidthLimit = typeof bandwidthLimitSchema.infer; diff --git a/app/server/db/schema.ts b/app/server/db/schema.ts index 4c5456ff..09c8ee55 100644 --- a/app/server/db/schema.ts +++ b/app/server/db/schema.ts @@ -1,6 +1,6 @@ import { relations, sql } from "drizzle-orm"; -import { index, int, integer, sqliteTable, text, primaryKey, unique } from "drizzle-orm/sqlite-core"; -import type { CompressionMode, RepositoryBackend, repositoryConfigSchema, RepositoryStatus } from "~/schemas/restic"; +import { index, int, integer, sqliteTable, text, real, primaryKey, unique } from "drizzle-orm/sqlite-core"; +import type { CompressionMode, RepositoryBackend, repositoryConfigSchema, RepositoryStatus, BandwidthUnit } from "~/schemas/restic"; import type { BackendStatus, BackendType, volumeConfigSchema } from "~/schemas/volumes"; import type { NotificationType, notificationConfigSchema } from "~/schemas/notifications"; @@ -161,11 +161,11 @@ export const repositoriesTable = sqliteTable("repositories_table", { 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"), + uploadLimitValue: real("upload_limit_value").notNull().default(0), + uploadLimitUnit: text("upload_limit_unit").$type().notNull().default("Mbps"), 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"), + downloadLimitValue: real("download_limit_value").notNull().default(0), + downloadLimitUnit: text("download_limit_unit").$type().notNull().default("Mbps"), createdAt: int("created_at", { mode: "number" }) .notNull() .default(sql`(unixepoch() * 1000)`), diff --git a/app/server/utils/restic.ts b/app/server/utils/restic.ts index 97677084..6d4ad9e1 100644 --- a/app/server/utils/restic.ts +++ b/app/server/utils/restic.ts @@ -846,8 +846,39 @@ const copy = async ( args.push("latest"); } + // Apply bandwidth limits from both source and destination configs + // First apply destination config limits (for the main operation) addCommonArgs(args, env, destConfig); + // Then explicitly apply source-side bandwidth limits for the from-repo operation + if (sourceConfig.uploadLimit?.enabled) { + const sourceUploadLimit = formatBandwidthLimit(sourceConfig.uploadLimit); + if (sourceUploadLimit) { + if (sourceConfig.backend === "rclone") { + // For rclone source backends, use rclone.bwlimit for the from-repo + args.push("-o", `rclone.from.bwlimit=${sourceUploadLimit}`); + } else { + // For restic source backends, the download from source becomes an upload limit + args.push("--limit-upload", sourceUploadLimit); + } + } + } + + if (sourceConfig.downloadLimit?.enabled) { + const sourceDownloadLimit = formatBandwidthLimit(sourceConfig.downloadLimit); + if (sourceDownloadLimit) { + if (sourceConfig.backend === "rclone") { + // For rclone source backends + const sourceUploadLimit = sourceConfig.uploadLimit?.enabled ? formatBandwidthLimit(sourceConfig.uploadLimit) : ""; + const effectiveLimit = sourceUploadLimit && parseInt(sourceUploadLimit) < parseInt(sourceDownloadLimit) ? sourceUploadLimit : sourceDownloadLimit; + args.push("-o", `rclone.from.bwlimit=${effectiveLimit}`); + } else { + // For restic source backends, this affects reading from the source repo + args.push("--limit-download", sourceDownloadLimit); + } + } + } + if (sourceConfig.backend === "sftp" && sourceEnv._SFTP_SSH_ARGS) { args.push("-o", `sftp.args=${sourceEnv._SFTP_SSH_ARGS}`); } @@ -874,29 +905,33 @@ const copy = async ( }; }; -// Helper function to convert bandwidth limit to restic format +// Helper function to convert bandwidth limit to restic/rclone format const formatBandwidthLimit = (limit: BandwidthLimit): string => { if (!limit.enabled || limit.value <= 0) { return ""; } - // Convert to bytes per second for restic - let bytesPerSecond: number; + // Convert to KiB/s for restic compatibility, or use suffixed format for rclone + let kibibytesPerSecond: number; switch (limit.unit) { - case "KB/s": - bytesPerSecond = limit.value * 1024; + case "Kbps": + // Kilobits per second to KiB/s: divide by 8 (bits to bytes), then by 1024 (bytes to KiB) + kibibytesPerSecond = limit.value / (8 * 1024); break; - case "MB/s": - bytesPerSecond = limit.value * 1024 * 1024; + case "Mbps": + // Megabits per second to KiB/s: multiply by 1000000 (Mb to bits), divide by 8 (bits to bytes), then by 1024 (bytes to KiB) + kibibytesPerSecond = (limit.value * 1000000) / (8 * 1024); break; - case "GB/s": - bytesPerSecond = limit.value * 1024 * 1024 * 1024; + case "Gbps": + // Gigabits per second to KiB/s: multiply by 1000000000 (Gb to bits), divide by 8 (bits to bytes), then by 1024 (bytes to KiB) + kibibytesPerSecond = (limit.value * 1000000000) / (8 * 1024); break; default: return ""; } - return `${Math.floor(bytesPerSecond)}`; + // Return as integer KiB/s for restic + return `${Math.floor(kibibytesPerSecond)}`; }; export const addCommonArgs = (args: string[], env: Record, config?: RepositoryConfig) => { @@ -968,7 +1003,7 @@ export const restic = { // 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']; + const keysToClean = ["_SFTP_KEY_PATH", "_SFTP_KNOWN_HOSTS_PATH", "RESTIC_CACERT", "GOOGLE_APPLICATION_CREDENTIALS"]; for (const key of keysToClean) { if (env[key]) {