2 Commits

Author SHA1 Message Date
b87203c538 Merge pull request '## Summary' (#58) from issue-51-bandwidth-limit into master
Reviewed-on: #58
2026-05-23 01:02:28 +02:00
515330c436 ## Summary
- Add per-host rsync bandwidth limit overrides with inherit/unlimited semantics.
- Store the effective bwlimit in run metadata/results and show it in host/run detail views.
- Document recommended starting values for VPN and remote backups.

## Tests
- `.venv/bin/python manage.py makemigrations --check --dry-run`
- `.venv/bin/python manage.py test src.pobsync_backend.tests.test_django_config_source.DjangoConfigSourceTests.test_returns_effective_config_from_database src.pobsync_backend.tests.test_django_config_source.DjangoConfigSourceTests.test_host_can_disable_global_rsync_bandwidth_limit src.pobsync_backend.tests.test_configure_commands.ConfigureCommandsTests.test_configure_host_uses_global_retention_defaults src.pobsync_backend.tests.test_run_scheduled_config_source.RunScheduledConfigSourceTests.test_dry_run_applies_configured_bandwidth_limit src.pobsync_backend.tests.test_run_scheduled_config_source.RunScheduledConfigSourceTests.test_real_run_can_request_verbose_output_args --verbosity 2`
- `.venv/bin/python manage.py test src.pobsync_backend.tests.test_views.ViewTests.test_create_host_config_form_creates_host src.pobsync_backend.tests.test_views.ViewTests.test_host_detail_renders_effective_config_preview src.pobsync_backend.tests.test_views.ViewTests.test_run_detail_renders_result_payload src.pobsync_backend.tests.test_views.ViewTests.test_host_config_form_updates_host_config --verbosity 2`
- `.venv/bin/python manage.py check`

Closes #51
2026-05-23 00:59:55 +02:00
16 changed files with 136 additions and 13 deletions

View File

@@ -154,6 +154,19 @@ The UI includes:
- `/self-check/` for runtime checks - `/self-check/` for runtime checks
- `/logs/` for filtered pobsync service logs - `/logs/` for filtered pobsync service logs
## Bandwidth Limits
Global config can set an rsync bandwidth limit in KB/s. The default `0` means unlimited. Each host can inherit the
global value, set `0` to explicitly run unlimited, or set its own limit for slower remote links.
For VPN-backed or remote backups, start conservatively and adjust after watching normal traffic:
- `2500` KB/s is roughly 20 Mbit/s
- `5000` KB/s is roughly 40 Mbit/s
- `10000` KB/s is roughly 80 Mbit/s
pobsync passes the effective value to rsync as `--bwlimit=<KB/s>` and shows it on the host detail and run detail pages.
## Restoring Data ## Restoring Data
pobsync treats restores as an explicit manual operation. The control panel shows restore guidance on each snapshot pobsync treats restores as an explicit manual operation. The control panel shows restore guidance on each snapshot

View File

@@ -278,6 +278,7 @@ def run_scheduled(
"exit_code": result.exit_code, "exit_code": result.exit_code,
"command": result.command, "command": result.command,
"log_tail": log_tail, "log_tail": log_tail,
"bwlimit_kbps": bwlimit_kbps,
}, },
} }
if result.exit_code != 0: if result.exit_code != 0:
@@ -336,7 +337,7 @@ def run_scheduled(
"ended_at": None, "ended_at": None,
"duration_seconds": None, "duration_seconds": None,
"base": _base_meta_from_path(base_dir, link_dest), "base": _base_meta_from_path(base_dir, link_dest),
"rsync": {"exit_code": None, "command": cmd, "stats": {}}, "rsync": {"exit_code": None, "command": cmd, "stats": {}, "bwlimit_kbps": bwlimit_kbps},
"overrides": {"includes": [], "excludes": [], "base": None}, "overrides": {"includes": [], "excludes": [], "base": None},
} }
@@ -349,7 +350,7 @@ def run_scheduled(
"phase": "preparing", "phase": "preparing",
"snapshot": str(incomplete_dir), "snapshot": str(incomplete_dir),
"log": str(log_path), "log": str(log_path),
"rsync": {"command": cmd, "exit_code": None}, "rsync": {"command": cmd, "exit_code": None, "bwlimit_kbps": bwlimit_kbps},
} }
) )
@@ -362,7 +363,7 @@ def run_scheduled(
"phase": "rsync", "phase": "rsync",
"snapshot": str(incomplete_dir), "snapshot": str(incomplete_dir),
"log": str(log_path), "log": str(log_path),
"rsync": {"command": cmd, "exit_code": None, "pid": pid, "pgid": pgid}, "rsync": {"command": cmd, "exit_code": None, "pid": pid, "pgid": pgid, "bwlimit_kbps": bwlimit_kbps},
} }
) )
@@ -384,7 +385,7 @@ def run_scheduled(
"phase": "finalizing", "phase": "finalizing",
"snapshot": str(incomplete_dir), "snapshot": str(incomplete_dir),
"log": str(log_path), "log": str(log_path),
"rsync": {"command": cmd, "exit_code": result.exit_code, "log_tail": log_tail}, "rsync": {"command": cmd, "exit_code": result.exit_code, "log_tail": log_tail, "bwlimit_kbps": bwlimit_kbps},
} }
) )
@@ -430,6 +431,7 @@ def run_scheduled(
"exit_code": result.exit_code, "exit_code": result.exit_code,
"command": result.command, "command": result.command,
"log_tail": log_tail, "log_tail": log_tail,
"bwlimit_kbps": bwlimit_kbps,
}, },
"failure": classify_rsync_failure(result.exit_code, log_tail), "failure": classify_rsync_failure(result.exit_code, log_tail),
} }
@@ -470,7 +472,7 @@ def run_scheduled(
"log": str(final_log_path), "log": str(final_log_path),
"status": meta["status"], "status": meta["status"],
"warning": warning, "warning": warning,
"rsync": {"exit_code": result.exit_code, "log_tail": log_tail}, "rsync": {"exit_code": result.exit_code, "command": result.command, "log_tail": log_tail, "bwlimit_kbps": bwlimit_kbps},
"verbose_output": bool(verbose_output), "verbose_output": bool(verbose_output),
"duration_seconds": meta["duration_seconds"], "duration_seconds": meta["duration_seconds"],
"stats": meta["stats"], "stats": meta["stats"],

View File

@@ -110,6 +110,7 @@ GLOBAL_SCHEMA = Schema(
HOST_RSYNC_SCHEMA = Schema( HOST_RSYNC_SCHEMA = Schema(
fields={ fields={
"bwlimit_kbps": FieldSpec(int, required=False, min_value=0),
"extra_args": FieldSpec(list, required=False, default=[], item=FieldSpec(str)), "extra_args": FieldSpec(list, required=False, default=[], item=FieldSpec(str)),
}, },
allow_unknown=False, allow_unknown=False,

View File

@@ -73,7 +73,7 @@ class HostConfigAdmin(admin.ModelAdmin):
(None, {"fields": ("host", "address", "enabled")}), (None, {"fields": ("host", "address", "enabled")}),
("SSH override", {"fields": ("ssh_credential", "ssh_user", "ssh_port")}), ("SSH override", {"fields": ("ssh_credential", "ssh_user", "ssh_port")}),
("Source", {"fields": ("source_root", "includes", "excludes_add", "excludes_replace")}), ("Source", {"fields": ("source_root", "includes", "excludes_add", "excludes_replace")}),
("Rsync override", {"fields": ("rsync_extra_args",)}), ("Rsync override", {"fields": ("rsync_extra_args", "rsync_bwlimit_kbps")}),
("Retention", {"fields": ("retention_daily", "retention_weekly", "retention_monthly", "retention_yearly")}), ("Retention", {"fields": ("retention_daily", "retention_weekly", "retention_monthly", "retention_yearly")}),
("Runtime state", {"fields": ("config",), "classes": ("collapse",)}), ("Runtime state", {"fields": ("config",), "classes": ("collapse",)}),
("Timestamps", {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}), ("Timestamps", {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}),

View File

@@ -68,8 +68,12 @@ def _host_runtime_data(host_config: HostConfig) -> dict[str, Any]:
data["excludes_replace"] = list(host_config.excludes_replace or []) data["excludes_replace"] = list(host_config.excludes_replace or [])
else: else:
data["excludes_add"] = list(host_config.excludes_add or []) data["excludes_add"] = list(host_config.excludes_add or [])
if host_config.rsync_extra_args: if host_config.rsync_extra_args or host_config.rsync_bwlimit_kbps is not None:
data["rsync"] = {"extra_args": list(host_config.rsync_extra_args or [])} data["rsync"] = {}
if host_config.rsync_extra_args:
data["rsync"]["extra_args"] = list(host_config.rsync_extra_args or [])
if host_config.rsync_bwlimit_kbps is not None:
data["rsync"]["bwlimit_kbps"] = host_config.rsync_bwlimit_kbps
return validate_dict(data, HOST_SCHEMA, path="host") return validate_dict(data, HOST_SCHEMA, path="host")

View File

@@ -60,6 +60,7 @@ class HostConfigForm(forms.ModelForm):
"excludes_add", "excludes_add",
"excludes_replace", "excludes_replace",
"rsync_extra_args", "rsync_extra_args",
"rsync_bwlimit_kbps",
"retention_daily", "retention_daily",
"retention_weekly", "retention_weekly",
"retention_monthly", "retention_monthly",
@@ -70,6 +71,7 @@ class HostConfigForm(forms.ModelForm):
"ssh_user": "Leave empty to use the global SSH user.", "ssh_user": "Leave empty to use the global SSH user.",
"ssh_port": "Leave empty to use the global SSH port.", "ssh_port": "Leave empty to use the global SSH port.",
"source_root": "Leave empty to use the global default source root.", "source_root": "Leave empty to use the global default source root.",
"rsync_bwlimit_kbps": "Leave empty to inherit the global limit. Use 0 for unlimited on this host.",
} }
@@ -112,6 +114,7 @@ class GlobalConfigForm(forms.ModelForm):
help_texts = { help_texts = {
"name": "Usually 'default'. The backup engine currently reads the default config.", "name": "Usually 'default'. The backup engine currently reads the default config.",
"default_ssh_credential": "Optional. Used by hosts without their own SSH credential.", "default_ssh_credential": "Optional. Used by hosts without their own SSH credential.",
"rsync_bwlimit_kbps": "Rsync bandwidth limit in KB/s. Use 0 for unlimited.",
"default_source_root": "Used by hosts without a custom source root.", "default_source_root": "Used by hosts without a custom source root.",
"default_destination_subdir": "Optional subdirectory below each snapshot.", "default_destination_subdir": "Optional subdirectory below each snapshot.",
} }

View File

@@ -22,6 +22,12 @@ class Command(BaseCommand):
parser.add_argument("--exclude-add", action="append", default=[]) parser.add_argument("--exclude-add", action="append", default=[])
parser.add_argument("--exclude-replace", action="append", default=None) parser.add_argument("--exclude-replace", action="append", default=None)
parser.add_argument("--rsync-extra-arg", action="append", default=[]) parser.add_argument("--rsync-extra-arg", action="append", default=[])
parser.add_argument(
"--rsync-bwlimit-kbps",
type=int,
default=None,
help="Host rsync bandwidth limit in KB/s. Omit to inherit global; set 0 for unlimited.",
)
parser.add_argument("--retention", default=None) parser.add_argument("--retention", default=None)
parser.add_argument("--disabled", action="store_true") parser.add_argument("--disabled", action="store_true")
parser.add_argument("--force", action="store_true", help="Update existing host") parser.add_argument("--force", action="store_true", help="Update existing host")
@@ -42,6 +48,7 @@ class Command(BaseCommand):
"excludes_add": [] if options["exclude_replace"] is not None else list(options["exclude_add"]), "excludes_add": [] if options["exclude_replace"] is not None else list(options["exclude_add"]),
"excludes_replace": options["exclude_replace"], "excludes_replace": options["exclude_replace"],
"rsync_extra_args": list(options["rsync_extra_arg"]), "rsync_extra_args": list(options["rsync_extra_arg"]),
"rsync_bwlimit_kbps": options["rsync_bwlimit_kbps"],
"retention_daily": retention["daily"], "retention_daily": retention["daily"],
"retention_weekly": retention["weekly"], "retention_weekly": retention["weekly"],
"retention_monthly": retention["monthly"], "retention_monthly": retention["monthly"],

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.14 on 2026-05-22 22:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pobsync_backend', '0013_purgedsnapshot'),
]
operations = [
migrations.AddField(
model_name='hostconfig',
name='rsync_bwlimit_kbps',
field=models.PositiveIntegerField(blank=True, null=True),
),
]

View File

@@ -63,6 +63,7 @@ class HostConfig(TimestampedModel):
excludes_add = models.JSONField(default=list, blank=True) excludes_add = models.JSONField(default=list, blank=True)
excludes_replace = models.JSONField(null=True, blank=True) excludes_replace = models.JSONField(null=True, blank=True)
rsync_extra_args = models.JSONField(default=list, blank=True) rsync_extra_args = models.JSONField(default=list, blank=True)
rsync_bwlimit_kbps = models.PositiveIntegerField(null=True, blank=True)
retention_daily = models.PositiveIntegerField(default=14) retention_daily = models.PositiveIntegerField(default=14)
retention_weekly = models.PositiveIntegerField(default=8) retention_weekly = models.PositiveIntegerField(default=8)
retention_monthly = models.PositiveIntegerField(default=12) retention_monthly = models.PositiveIntegerField(default=12)

View File

@@ -373,7 +373,10 @@
<div class="record-fact"><span class="label">Rsync binary:</span><strong>{{ effective_config.rsync.binary }}</strong></div> <div class="record-fact"><span class="label">Rsync binary:</span><strong>{{ effective_config.rsync.binary }}</strong></div>
<div class="record-fact"><span class="label">Rsync args:</span><span>{{ effective_config.rsync.args|join:" " }}</span></div> <div class="record-fact"><span class="label">Rsync args:</span><span>{{ effective_config.rsync.args|join:" " }}</span></div>
<div class="record-fact"><span class="label">Timeout:</span><strong>{{ effective_config.rsync.timeout_seconds }}s</strong></div> <div class="record-fact"><span class="label">Timeout:</span><strong>{{ effective_config.rsync.timeout_seconds }}s</strong></div>
<div class="record-fact"><span class="label">Bandwidth limit:</span><strong>{{ effective_config.rsync.bwlimit_kbps }} KB/s</strong></div> <div class="record-fact">
<span class="label">Bandwidth limit:</span>
<strong>{% if effective_config.rsync.bwlimit_kbps %}{{ effective_config.rsync.bwlimit_kbps }} KB/s{% else %}unlimited{% endif %}</strong>
</div>
</div> </div>
</article> </article>
<article class="record-card"> <article class="record-card">

View File

@@ -50,6 +50,10 @@
<section class="panel"> <section class="panel">
<h2>Rsync Command</h2> <h2>Rsync Command</h2>
<p class="muted">
<strong>Bandwidth limit:</strong>
{% if rsync_bwlimit_kbps %}{{ rsync_bwlimit_kbps }} KB/s{% else %}unlimited{% endif %}
</p>
{% if rsync_command %} {% if rsync_command %}
<pre>{% for part in rsync_command %}{{ part }}{% if not forloop.last %} <pre>{% for part in rsync_command %}{{ part }}{% if not forloop.last %}
{% endif %}{% endfor %}</pre> {% endif %}{% endfor %}</pre>

View File

@@ -42,6 +42,7 @@ class ConfigureCommandsTests(TestCase):
address="web-01.example.test", address="web-01.example.test",
exclude_add=["/tmp/***"], exclude_add=["/tmp/***"],
rsync_extra_arg=["--delete"], rsync_extra_arg=["--delete"],
rsync_bwlimit_kbps=4096,
stdout=out, stdout=out,
) )
@@ -49,10 +50,12 @@ class ConfigureCommandsTests(TestCase):
self.assertEqual(host.retention_daily, 5) self.assertEqual(host.retention_daily, 5)
self.assertEqual(host.excludes_add, ["/tmp/***"]) self.assertEqual(host.excludes_add, ["/tmp/***"])
self.assertEqual(host.rsync_extra_args, ["--delete"]) self.assertEqual(host.rsync_extra_args, ["--delete"])
self.assertEqual(host.rsync_bwlimit_kbps, 4096)
effective = DjangoConfigSource().effective_config_for_host("web-01") effective = DjangoConfigSource().effective_config_for_host("web-01")
self.assertEqual(effective["retention"]["yearly"], 2) self.assertEqual(effective["retention"]["yearly"], 2)
self.assertEqual(effective["excludes_effective"], ["/tmp/***"]) self.assertEqual(effective["excludes_effective"], ["/tmp/***"])
self.assertEqual(effective["rsync"]["bwlimit_kbps"], 4096)
def test_configure_schedule_creates_sql_schedule(self) -> None: def test_configure_schedule_creates_sql_schedule(self) -> None:
host = HostConfig.objects.create(host="web-01", address="web-01.example.test") host = HostConfig.objects.create(host="web-01", address="web-01.example.test")

View File

@@ -17,6 +17,7 @@ class DjangoConfigSourceTests(TestCase):
backup_root="/backups", backup_root="/backups",
rsync_args=["--archive"], rsync_args=["--archive"],
rsync_extra_args=["--numeric-ids"], rsync_extra_args=["--numeric-ids"],
rsync_bwlimit_kbps=10000,
excludes_default=["/proc/***"], excludes_default=["/proc/***"],
retention_daily=7, retention_daily=7,
retention_weekly=4, retention_weekly=4,
@@ -28,6 +29,7 @@ class DjangoConfigSourceTests(TestCase):
address="web-01.example.test", address="web-01.example.test",
excludes_add=["/tmp/***"], excludes_add=["/tmp/***"],
rsync_extra_args=["--delete"], rsync_extra_args=["--delete"],
rsync_bwlimit_kbps=2500,
retention_daily=7, retention_daily=7,
retention_weekly=4, retention_weekly=4,
retention_monthly=3, retention_monthly=3,
@@ -46,6 +48,24 @@ class DjangoConfigSourceTests(TestCase):
self.assertEqual(cfg["address"], "web-01.example.test") self.assertEqual(cfg["address"], "web-01.example.test")
self.assertEqual(cfg["excludes_effective"], ["/proc/***", "/tmp/***"]) self.assertEqual(cfg["excludes_effective"], ["/proc/***", "/tmp/***"])
self.assertEqual(cfg["rsync"]["args_effective"], ["--archive", "--numeric-ids", "--delete"]) self.assertEqual(cfg["rsync"]["args_effective"], ["--archive", "--numeric-ids", "--delete"])
self.assertEqual(cfg["rsync"]["bwlimit_kbps"], 2500)
def test_host_can_disable_global_rsync_bandwidth_limit(self) -> None:
GlobalConfig.objects.create(
name="default",
backup_root="/backups",
rsync_args=["--archive"],
rsync_bwlimit_kbps=5000,
)
HostConfig.objects.create(
host="web-01",
address="web-01.example.test",
rsync_bwlimit_kbps=0,
)
cfg = DjangoConfigSource().effective_config_for_host("web-01")
self.assertEqual(cfg["rsync"]["bwlimit_kbps"], 0)
def test_materializes_global_ssh_credential_for_runtime_config(self) -> None: def test_materializes_global_ssh_credential_for_runtime_config(self) -> None:
credential = SshCredential.objects.create( credential = SshCredential.objects.create(

View File

@@ -12,8 +12,9 @@ from pobsync.rsync import RsyncResult
class FakeConfigSource: class FakeConfigSource:
def __init__(self, backup_root: str = "/tmp/pobsync-test-backups") -> None: def __init__(self, backup_root: str = "/tmp/pobsync-test-backups", bwlimit_kbps: int = 0) -> None:
self.backup_root = backup_root self.backup_root = backup_root
self.bwlimit_kbps = bwlimit_kbps
def effective_config_for_host(self, host: str) -> dict: def effective_config_for_host(self, host: str) -> dict:
return { return {
@@ -25,7 +26,7 @@ class FakeConfigSource:
"binary": "rsync", "binary": "rsync",
"args_effective": ["--archive"], "args_effective": ["--archive"],
"timeout_seconds": 0, "timeout_seconds": 0,
"bwlimit_kbps": 0, "bwlimit_kbps": self.bwlimit_kbps,
}, },
"source_root": "/", "source_root": "/",
"includes": [], "includes": [],
@@ -54,6 +55,21 @@ class RunScheduledConfigSourceTests(SimpleTestCase):
self.assertEqual(result["host"], "web-01") self.assertEqual(result["host"], "web-01")
run_rsync.assert_called_once() run_rsync.assert_called_once()
def test_dry_run_applies_configured_bandwidth_limit(self) -> None:
with patch("pobsync.commands.run_scheduled.run_rsync") as run_rsync:
run_rsync.return_value = RsyncResult(exit_code=0, command=["rsync", "--bwlimit=4096"])
result = run_scheduled(
prefix=Path("/missing-prefix"),
host="web-01",
dry_run=True,
config_source=FakeConfigSource(bwlimit_kbps=4096),
)
command = run_rsync.call_args.args[0]
self.assertIn("--bwlimit=4096", command)
self.assertEqual(result["rsync"]["bwlimit_kbps"], 4096)
def test_failed_dry_run_includes_log_tail(self) -> None: def test_failed_dry_run_includes_log_tail(self) -> None:
def fake_run_rsync(command, log_path, timeout_seconds, cancel_check=None): def fake_run_rsync(command, log_path, timeout_seconds, cancel_check=None):
log_path.parent.mkdir(parents=True, exist_ok=True) log_path.parent.mkdir(parents=True, exist_ok=True)
@@ -186,11 +202,13 @@ class RunScheduledConfigSourceTests(SimpleTestCase):
host="web-01", host="web-01",
dry_run=False, dry_run=False,
verbose_output=True, verbose_output=True,
config_source=FakeConfigSource(backup_root=str(Path(tmp) / "backups")), config_source=FakeConfigSource(backup_root=str(Path(tmp) / "backups"), bwlimit_kbps=2048),
) )
command = run_rsync.call_args.args[0] command = run_rsync.call_args.args[0]
self.assertTrue(result["ok"]) self.assertTrue(result["ok"])
self.assertIn("--bwlimit=2048", command)
self.assertEqual(result["rsync"]["bwlimit_kbps"], 2048)
self.assertIn("--stats", command) self.assertIn("--stats", command)
self.assertIn("--itemize-changes", command) self.assertIn("--itemize-changes", command)
self.assertIn("--info=flist2,progress2,stats2", command) self.assertIn("--info=flist2,progress2,stats2", command)

View File

@@ -921,6 +921,7 @@ class ViewTests(TestCase):
"excludes_add": "*.tmp", "excludes_add": "*.tmp",
"excludes_replace": "", "excludes_replace": "",
"rsync_extra_args": "--numeric-ids", "rsync_extra_args": "--numeric-ids",
"rsync_bwlimit_kbps": "4096",
"retention_daily": "7", "retention_daily": "7",
"retention_weekly": "4", "retention_weekly": "4",
"retention_monthly": "2", "retention_monthly": "2",
@@ -938,6 +939,7 @@ class ViewTests(TestCase):
self.assertEqual(host.includes, ["/srv/www", "/srv/db"]) self.assertEqual(host.includes, ["/srv/www", "/srv/db"])
self.assertEqual(host.excludes_add, ["*.tmp"]) self.assertEqual(host.excludes_add, ["*.tmp"])
self.assertEqual(host.rsync_extra_args, ["--numeric-ids"]) self.assertEqual(host.rsync_extra_args, ["--numeric-ids"])
self.assertEqual(host.rsync_bwlimit_kbps, 4096)
self.assertEqual(host.retention_weekly, 4) self.assertEqual(host.retention_weekly, 4)
def test_create_host_config_uses_global_defaults_and_prepares_directories(self) -> None: def test_create_host_config_uses_global_defaults_and_prepares_directories(self) -> None:
@@ -1077,6 +1079,7 @@ class ViewTests(TestCase):
self.assertContains(response, "default-key") self.assertContains(response, "default-key")
self.assertContains(response, "-oBatchMode=yes") self.assertContains(response, "-oBatchMode=yes")
self.assertContains(response, "--archive --numeric-ids --delete --one-file-system") self.assertContains(response, "--archive --numeric-ids --delete --one-file-system")
self.assertContains(response, "2048 KB/s")
self.assertContains(response, "/srv/www/***") self.assertContains(response, "/srv/www/***")
self.assertContains(response, "/srv/www/cache/***") self.assertContains(response, "/srv/www/cache/***")
self.assertContains(response, "d14") self.assertContains(response, "d14")
@@ -1473,9 +1476,10 @@ class ViewTests(TestCase):
"ok": True, "ok": True,
"snapshot": snapshot.path, "snapshot": snapshot.path,
"rsync": { "rsync": {
"command": ["rsync", "--archive", "root@web-01:/", snapshot.path], "command": ["rsync", "--archive", "--bwlimit=2048", "root@web-01:/", snapshot.path],
"exit_code": 0, "exit_code": 0,
"log_tail": ["sending incremental file list", "sent 500 bytes"], "log_tail": ["sending incremental file list", "sent 500 bytes"],
"bwlimit_kbps": 2048,
}, },
"requested": { "requested": {
"dry_run": True, "dry_run": True,
@@ -1510,6 +1514,8 @@ class ViewTests(TestCase):
self.assertContains(response, "Dry run:</strong> yes") self.assertContains(response, "Dry run:</strong> yes")
self.assertContains(response, "Verbose rsync output:</strong> yes") self.assertContains(response, "Verbose rsync output:</strong> yes")
self.assertContains(response, "Rsync Command") self.assertContains(response, "Rsync Command")
self.assertContains(response, "Bandwidth limit:</strong>")
self.assertContains(response, "2048 KB/s")
self.assertContains(response, "--archive") self.assertContains(response, "--archive")
self.assertContains(response, "Rsync Log") self.assertContains(response, "Rsync Log")
self.assertContains(response, "sending incremental file list") self.assertContains(response, "sending incremental file list")
@@ -2464,6 +2470,7 @@ class ViewTests(TestCase):
"excludes_add": "*.tmp\ncache/", "excludes_add": "*.tmp\ncache/",
"excludes_replace": "", "excludes_replace": "",
"rsync_extra_args": "--numeric-ids\n--delete", "rsync_extra_args": "--numeric-ids\n--delete",
"rsync_bwlimit_kbps": "8192",
"retention_daily": "7", "retention_daily": "7",
"retention_weekly": "4", "retention_weekly": "4",
"retention_monthly": "2", "retention_monthly": "2",
@@ -2483,6 +2490,7 @@ class ViewTests(TestCase):
self.assertEqual(host.excludes_add, ["*.tmp", "cache/"]) self.assertEqual(host.excludes_add, ["*.tmp", "cache/"])
self.assertIsNone(host.excludes_replace) self.assertIsNone(host.excludes_replace)
self.assertEqual(host.rsync_extra_args, ["--numeric-ids", "--delete"]) self.assertEqual(host.rsync_extra_args, ["--numeric-ids", "--delete"])
self.assertEqual(host.rsync_bwlimit_kbps, 8192)
self.assertEqual(host.retention_daily, 7) self.assertEqual(host.retention_daily, 7)
self.assertEqual(host.retention_yearly, 1) self.assertEqual(host.retention_yearly, 1)

View File

@@ -704,6 +704,7 @@ def _run_detail_context(run: BackupRun) -> dict[str, object]:
"stats": run_stats if isinstance(run_stats, dict) else {}, "stats": run_stats if isinstance(run_stats, dict) else {},
"rsync": rsync_result, "rsync": rsync_result,
"rsync_command": _run_rsync_command(rsync_result), "rsync_command": _run_rsync_command(rsync_result),
"rsync_bwlimit_kbps": _run_rsync_bwlimit_kbps(rsync_result),
"failure": failure, "failure": failure,
"failure_summary": failure.get("message") or failure.get("summary") or "", "failure_summary": failure.get("message") or failure.get("summary") or "",
"prune_result": prune_result, "prune_result": prune_result,
@@ -1249,6 +1250,23 @@ def _run_rsync_command(rsync_result: dict) -> list[str]:
return [str(part) for part in command] return [str(part) for part in command]
def _run_rsync_bwlimit_kbps(rsync_result: dict) -> int:
stored_limit = rsync_result.get("bwlimit_kbps")
if stored_limit is not None:
try:
return max(0, int(stored_limit))
except (TypeError, ValueError):
return 0
for part in _run_rsync_command(rsync_result):
if part.startswith("--bwlimit="):
try:
return max(0, int(part.split("=", 1)[1]))
except ValueError:
return 0
return 0
def _run_rsync_log_tail(rsync_result: dict, log_path: Path | None, *, max_lines: int = 30) -> list[str]: def _run_rsync_log_tail(rsync_result: dict, log_path: Path | None, *, max_lines: int = 30) -> list[str]:
log_tail = rsync_result.get("log_tail") log_tail = rsync_result.get("log_tail")
if isinstance(log_tail, list): if isinstance(log_tail, list):