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.
This commit is contained in:
Raj Dave 2026-01-10 14:03:45 +03:00
parent 8ba855f4ca
commit db127afe5a
9 changed files with 1561 additions and 30 deletions

2
.gitignore vendored
View file

@ -20,3 +20,5 @@ data/
.env* .env*
!.env.example !.env.example
!.env.test !.env.test
.idea/

View file

@ -7,12 +7,15 @@ import {
FormLabel, FormLabel,
FormMessage, FormMessage,
} from "../../../../components/ui/form"; } from "../../../../components/ui/form";
import { Input } from "../../../../components/ui/input";
import { Textarea } from "../../../../components/ui/textarea"; import { Textarea } from "../../../../components/ui/textarea";
import { Checkbox } from "../../../../components/ui/checkbox"; 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 { Tooltip, TooltipContent, TooltipTrigger } from "../../../../components/ui/tooltip";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../../../../components/ui/collapsible"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../../../../components/ui/collapsible";
import type { RepositoryFormValues } from "../create-repository-form"; import type { RepositoryFormValues } from "../create-repository-form";
import { cn } from "~/client/lib/utils"; import { cn } from "~/client/lib/utils";
import { BANDWIDTH_UNITS } from "~/schemas/restic";
type Props = { type Props = {
form: UseFormReturn<RepositoryFormValues>; form: UseFormReturn<RepositoryFormValues>;
@ -21,11 +24,193 @@ type Props = {
export const AdvancedForm = ({ form }: Props) => { export const AdvancedForm = ({ form }: Props) => {
const insecureTls = form.watch("insecureTls"); const insecureTls = form.watch("insecureTls");
const cacert = form.watch("cacert"); const cacert = form.watch("cacert");
const uploadEnabled = form.watch("uploadLimit.enabled");
const downloadEnabled = form.watch("downloadLimit.enabled");
return ( return (
<Collapsible> <Collapsible>
<CollapsibleTrigger className="">Advanced Settings</CollapsibleTrigger> <CollapsibleTrigger className="">Advanced Settings</CollapsibleTrigger>
<CollapsibleContent className="pb-4 space-y-4"> <CollapsibleContent className="pb-4 space-y-4">
{/* Bandwidth Limit Controls */}
<div className="space-y-6 rounded-lg border bg-card p-6">
<div className="flex items-center gap-3">
<div>
<h3 className="text-base font-semibold">Bandwidth Limits</h3>
<p className="text-sm text-muted-foreground">
Control upload and download speeds to prevent saturating network bandwidth
</p>
</div>
</div>
<div className="grid gap-6 sm:grid-cols-2">
{/* Upload Limit */}
<div className="space-y-4 rounded-lg border bg-background/50 p-4">
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium">Upload Limit</h4>
</div>
<FormField
control={form.control}
name="uploadLimit.enabled"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox
checked={field.value ?? false}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Enable upload speed limit</FormLabel>
<FormDescription className="text-xs">
Limit upload speed to the repository
</FormDescription>
</div>
</FormItem>
)}
/>
{uploadEnabled && (
<div className="space-y-3 pt-2">
<div className="flex items-center gap-2">
<FormField
control={form.control}
name="uploadLimit.value"
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<div className="relative">
<Input
type="number"
placeholder="10"
min="0"
step="0.1"
className="pr-12"
{...field}
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
/>
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
<div className="h-4 w-px bg-border" />
</div>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="uploadLimit.unit"
render={({ field }) => (
<FormItem className="w-24">
<Select onValueChange={field.onChange} defaultValue={field.value || "MB/s"} value={field.value || "MB/s"}>
<FormControl>
<SelectTrigger className="text-xs">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{Object.keys(BANDWIDTH_UNITS).map((unit) => (
<SelectItem key={unit} value={unit} className="text-xs">
{unit}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
)}
</div>
{/* Download Limit */}
<div className="space-y-4 rounded-lg border bg-background/50 p-4">
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium">Download Limit</h4>
</div>
<FormField
control={form.control}
name="downloadLimit.enabled"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox
checked={field.value ?? false}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>Enable download speed limit</FormLabel>
<FormDescription className="text-xs">
Limit download speed from the repository
</FormDescription>
</div>
</FormItem>
)}
/>
{downloadEnabled && (
<div className="space-y-3 pt-2">
<div className="flex items-center gap-2">
<FormField
control={form.control}
name="downloadLimit.value"
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<div className="relative">
<Input
type="number"
placeholder="10"
min="0"
step="0.1"
className="pr-12"
{...field}
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
/>
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
<div className="h-4 w-px bg-border" />
</div>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="downloadLimit.unit"
render={({ field }) => (
<FormItem className="w-24">
<Select onValueChange={field.onChange} defaultValue={field.value || "MB/s"} value={field.value || "MB/s"}>
<FormControl>
<SelectTrigger className="text-xs">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{Object.keys(BANDWIDTH_UNITS).map((unit) => (
<SelectItem key={unit} value={unit} className="text-xs">
{unit}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
)}
</div>
</div>
</div>
<FormField <FormField
control={form.control} control={form.control}
name="insecureTls" name="insecureTls"

View file

@ -0,0 +1,6 @@
ALTER TABLE `repositories_table` ADD `upload_limit_enabled` integer DEFAULT false NOT NULL;--> 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;

File diff suppressed because it is too large Load diff

View file

@ -225,6 +225,13 @@
"when": 1767863951955, "when": 1767863951955,
"tag": "0031_graceful_squadron_supreme", "tag": "0031_graceful_squadron_supreme",
"breakpoints": true "breakpoints": true
},
{
"idx": 32,
"version": "6",
"when": 1768040838612,
"tag": "0032_outstanding_ultron",
"breakpoints": true
} }
] ]
} }

View file

@ -13,12 +13,32 @@ export const REPOSITORY_BACKENDS = {
export type RepositoryBackend = keyof typeof 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 // Common fields for all repository configs
const baseRepositoryConfigSchema = type({ const baseRepositoryConfigSchema = type({
isExistingRepository: "boolean?", isExistingRepository: "boolean?",
customPassword: "string?", customPassword: "string?",
cacert: "string?", cacert: "string?",
insecureTls: "boolean?", insecureTls: "boolean?",
// Bandwidth controls
uploadLimit: bandwidthLimitSchema.optional(),
downloadLimit: bandwidthLimitSchema.optional(),
}); });
export const s3RepositoryConfigSchema = type({ export const s3RepositoryConfigSchema = type({

View file

@ -159,6 +159,13 @@ export const repositoriesTable = sqliteTable("repositories_table", {
status: text().$type<RepositoryStatus>().default("unknown"), status: text().$type<RepositoryStatus>().default("unknown"),
lastChecked: int("last_checked", { mode: "number" }), lastChecked: int("last_checked", { mode: "number" }),
lastError: text("last_error"), 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" }) createdAt: int("created_at", { mode: "number" })
.notNull() .notNull()
.default(sql`(unixepoch() * 1000)`), .default(sql`(unixepoch() * 1000)`),

View file

View file

@ -10,7 +10,7 @@ import { logger } from "./logger";
import { cryptoUtils } from "./crypto"; import { cryptoUtils } from "./crypto";
import type { RetentionPolicy } from "../modules/backups/backups.dto"; import type { RetentionPolicy } from "../modules/backups/backups.dto";
import { safeSpawn } from "./spawn"; 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"; import { ResticError } from "./errors";
const backupOutputSchema = type({ const backupOutputSchema = type({
@ -229,7 +229,7 @@ const init = async (config: RepositoryConfig) => {
const env = await buildEnv(config); const env = await buildEnv(config);
const args = ["init", "--repo", repoUrl]; const args = ["init", "--repo", repoUrl];
addCommonArgs(args, env); addCommonArgs(args, env, config);
const res = await safeSpawn({ command: "restic", args, env }); const res = await safeSpawn({ command: "restic", args, env });
await cleanupTemporaryKeys(env); await cleanupTemporaryKeys(env);
@ -321,7 +321,7 @@ const backup = async (
} }
} }
addCommonArgs(args, env); addCommonArgs(args, env, config);
const logData = throttle((data: string) => { const logData = throttle((data: string) => {
logger.info(data.trim()); logger.info(data.trim());
@ -453,7 +453,7 @@ const restore = async (
} }
} }
addCommonArgs(args, env); addCommonArgs(args, env, config);
logger.debug(`Executing: restic ${args.join(" ")}`); logger.debug(`Executing: restic ${args.join(" ")}`);
const res = await safeSpawn({ command: "restic", args, env }); 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 }); const res = await safeSpawn({ command: "restic", args, env });
await cleanupTemporaryKeys(env); await cleanupTemporaryKeys(env);
@ -565,7 +565,7 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra:
} }
args.push("--prune"); args.push("--prune");
addCommonArgs(args, env); addCommonArgs(args, env, config);
const res = await safeSpawn({ command: "restic", args, env }); const res = await safeSpawn({ command: "restic", args, env });
await cleanupTemporaryKeys(env); await cleanupTemporaryKeys(env);
@ -587,7 +587,7 @@ const deleteSnapshots = async (config: RepositoryConfig, snapshotIds: string[])
} }
const args: string[] = ["--repo", repoUrl, "forget", ...snapshotIds, "--prune"]; const args: string[] = ["--repo", repoUrl, "forget", ...snapshotIds, "--prune"];
addCommonArgs(args, env); addCommonArgs(args, env, config);
const res = await safeSpawn({ command: "restic", args, env }); const res = await safeSpawn({ command: "restic", args, env });
await cleanupTemporaryKeys(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 }); const res = await safeSpawn({ command: "restic", args, env });
await cleanupTemporaryKeys(env); await cleanupTemporaryKeys(env);
@ -686,7 +686,7 @@ const ls = async (config: RepositoryConfig, snapshotId: string, path?: string) =
args.push(path); args.push(path);
} }
addCommonArgs(args, env); addCommonArgs(args, env, config);
const res = await safeSpawn({ command: "restic", args, env }); const res = await safeSpawn({ command: "restic", args, env });
await cleanupTemporaryKeys(env); await cleanupTemporaryKeys(env);
@ -737,7 +737,7 @@ const unlock = async (config: RepositoryConfig) => {
const env = await buildEnv(config); const env = await buildEnv(config);
const args = ["unlock", "--repo", repoUrl, "--remove-all"]; const args = ["unlock", "--repo", repoUrl, "--remove-all"];
addCommonArgs(args, env); addCommonArgs(args, env, config);
const res = await safeSpawn({ command: "restic", args, env }); const res = await safeSpawn({ command: "restic", args, env });
await cleanupTemporaryKeys(env); await cleanupTemporaryKeys(env);
@ -761,7 +761,7 @@ const check = async (config: RepositoryConfig, options?: { readData?: boolean })
args.push("--read-data"); args.push("--read-data");
} }
addCommonArgs(args, env); addCommonArgs(args, env, config);
const res = await safeSpawn({ command: "restic", args, env }); const res = await safeSpawn({ command: "restic", args, env });
await cleanupTemporaryKeys(env); await cleanupTemporaryKeys(env);
@ -794,7 +794,7 @@ const repairIndex = async (config: RepositoryConfig) => {
const env = await buildEnv(config); const env = await buildEnv(config);
const args = ["repair", "index", "--repo", repoUrl]; const args = ["repair", "index", "--repo", repoUrl];
addCommonArgs(args, env); addCommonArgs(args, env, config);
const res = await safeSpawn({ command: "restic", args, env }); const res = await safeSpawn({ command: "restic", args, env });
await cleanupTemporaryKeys(env); await cleanupTemporaryKeys(env);
@ -846,7 +846,7 @@ const copy = async (
args.push("latest"); args.push("latest");
} }
addCommonArgs(args, env); addCommonArgs(args, env, destConfig);
if (sourceConfig.backend === "sftp" && sourceEnv._SFTP_SSH_ARGS) { if (sourceConfig.backend === "sftp" && sourceEnv._SFTP_SSH_ARGS) {
args.push("-o", `sftp.args=${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<string, string>) => { // Helper function to convert bandwidth limit to restic format
if (env._SFTP_KEY_PATH) { const formatBandwidthLimit = (limit: BandwidthLimit): string => {
await fs.unlink(env._SFTP_KEY_PATH).catch(() => {}); if (!limit.enabled || limit.value <= 0) {
return "";
} }
if (env._SFTP_KNOWN_HOSTS_PATH) { // Convert to bytes per second for restic
await fs.unlink(env._SFTP_KNOWN_HOSTS_PATH).catch(() => {}); 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) { return `${Math.floor(bytesPerSecond)}`;
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(() => {});
}
}; };
export const addCommonArgs = (args: string[], env: Record<string, string>) => { export const addCommonArgs = (args: string[], env: Record<string, string>, config?: RepositoryConfig) => {
args.push("--json"); args.push("--json");
if (env._SFTP_SSH_ARGS) { if (env._SFTP_SSH_ARGS) {
@ -910,6 +913,40 @@ export const addCommonArgs = (args: string[], env: Record<string, string>) => {
if (env.RESTIC_CACERT) { if (env.RESTIC_CACERT) {
args.push("--cacert", 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 = { export const restic = {
@ -928,3 +965,27 @@ export const restic = {
repairIndex, repairIndex,
copy, copy,
}; };
// Helper function to clean up temporary files
const cleanupTemporaryKeys = async (env: Record<string, string>) => {
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
}
}
};