Initial pve cloud backup app

This commit is contained in:
Codex
2026-07-15 01:00:08 +02:00
commit 7a4c561f8e
37 changed files with 5495 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
# Runtime state
/data/
/logs/
*.db
*.db-*
*.sqlite
*.sqlite-*
# Generated frontend build
/static/
# Python
/backend/.venv/
__pycache__/
*.py[cod]
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
htmlcov/
# Node/Vite
/frontend/node_modules/
/frontend/dist/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Local/editor noise
.DS_Store
Thumbs.db
.idea/
.vscode/
*.swp
*.swo
# Environment/secrets
.env
.env.*
!.env.example
+9
View File
@@ -0,0 +1,9 @@
# Agent notes
- Keep runtime configuration in SQLite only. Do not add `.env`, YAML, JSON, TOML, or other runtime config files.
- Do not use Docker or add distributed-worker infrastructure.
- Do not call `vzdump` directly. Use `pvesh` for backup operations.
- Do not require a crypt remote. Plain OneDrive remotes and crypt remotes are both allowed.
- Do not use `rclone sync`. Use exact-object operations: `copyto`, existence check, and `deletefile`.
- Never delete a local archive unless upload completed and the remote object was verified.
- Mock `pvesh` and `rclone` in automated tests. Manual integration testing is limited to `scripts/integration-test-adguard.sh`.
+138
View File
@@ -0,0 +1,138 @@
# PVE Cloud Backup
Self-hosted Proxmox VE backup manager for uploads to an existing rclone remote such as OneDrive.
The application is intentionally small:
- FastAPI backend and worker
- Vue 3 + TypeScript + Tailwind frontend, compiled once into `static/`
- SQLite for all runtime configuration and state
- `pvesh` for Proxmox API access
- `rclone copyto`, `rclone lsjson`, and `rclone deletefile` for exact remote objects
- systemd services, no Docker
## Layout
All application files live under:
```text
/opt/pve-cloud-backup/
backend/
frontend/
static/
data/
app.db
logs/
scripts/
```
The install script copies the two systemd service files to `/etc/systemd/system/`.
## Prerequisites
Install these on the Proxmox host:
- Python 3 with `venv`
- Node.js and npm for the one-time frontend build
- `rclone`
- a preconfigured rclone remote, for example OneDrive or a crypt remote backed by OneDrive
- working `pvesh` access as the service user
The app does not configure rclone. If no remote exists, setup fails until you configure rclone yourself.
## Install
From the installation directory:
```bash
cd /opt/pve-cloud-backup
./scripts/install.sh
```
The installer:
1. creates required subdirectories,
2. creates a Python virtual environment,
3. installs Python dependencies,
4. builds the Vue frontend into `/opt/pve-cloud-backup/static`,
5. initializes SQLite migrations,
6. installs and starts systemd services.
Open:
```text
http://<proxmox-host>:8080
```
## Setup
On first launch, complete the setup wizard:
- Proxmox node
- Proxmox backup storage
- local backup directory
- rclone executable path
- rclone remote name
- remote backup path/folder within that remote
- Discord webhook URL
- allowed CORS origins
- default compression
- default backup mode
- default retention policy
- max concurrent backups
All settings are stored in SQLite at `/opt/pve-cloud-backup/data/app.db`.
## Recovery behavior
On worker startup, unfinished records in these states are reconciled:
- `pve_running`
- `local_ready`
- `uploading`
- `remote_ready`
- `local_deleting`
- `deleting`
The worker does not delete unknown local files or remote objects. Local archives are deleted only after upload finishes and the remote object is verified.
## Retention cleanup
Retention cleanup runs in two places:
- immediately after a backup completes for that job;
- periodically from the worker service, once per hour, so changed rules and day-based expiry are enforced even if no new backup runs.
Remote deletion uses exact-object `rclone deletefile` calls for backup records already known in SQLite.
## Testing
Run backend tests:
```bash
cd /opt/pve-cloud-backup/backend
./.venv/bin/pytest
```
Run frontend type check and build:
```bash
cd /opt/pve-cloud-backup/frontend
npm run build
```
Manual integration test for the provided Proxmox test target:
```bash
API_URL=http://127.0.0.1:8080 /opt/pve-cloud-backup/scripts/integration-test-adguard.sh
```
This queues a backup for guest `adguard` / VMID `110` using storage `hitachi` and dump path `/mnt/pve/hitachi/dump`. Expected archive size is approximately 250 MB. The script is explicitly for manual integration testing and does not hardcode these values into normal app behavior.
## Uninstall
```bash
/opt/pve-cloud-backup/scripts/uninstall.sh
```
The uninstall script removes and disables systemd services. It leaves `/opt/pve-cloud-backup` intact so `app.db`, logs, and any remaining data are not destroyed accidentally.
+16
View File
@@ -0,0 +1,16 @@
# TODO.md
## TODO
## In Progress
## Completed
- Make timezones configureable
- show a "in xx:xx:xx" counting down to the next backup a bit more
- when deleting a backup that is already deleted, nuke its metadata row entirely
- add a better ui for viewing the details of a backup
- see the id of a backup job in the editor of them
- add backup job counts to the guests list
- no "im progress" thing for deleting a backup nor deleting a job and its still hanging on deleting more stuff
+1
View File
@@ -0,0 +1 @@
+489
View File
@@ -0,0 +1,489 @@
from __future__ import annotations
import asyncio
import uuid
from datetime import datetime, timedelta, timezone
from pathlib import Path
from . import commands
from .db import db, row_to_dict, utc_now
from .jobs import advance_job, due_jobs
from .notifications import notify
from .settings_store import get_settings
ACTIVE_STATES = {
"queued",
"pve_running",
"local_ready",
"uploading",
"remote_ready",
"local_deleting",
"deleting",
}
RUNNING_STATES = ACTIVE_STATES - {"queued"}
RECOVERABLE_STATES = {
"pve_running",
"local_ready",
"uploading",
"remote_ready",
"local_deleting",
"deleting",
}
TERMINAL_STATES = {"completed", "failed", "deleted"}
def _active_for_guest(conn, vmid: int) -> bool:
row = conn.execute(
f"SELECT 1 FROM backups WHERE guest_vmid = ? AND state IN ({','.join('?' for _ in ACTIVE_STATES)}) LIMIT 1",
(vmid, *sorted(ACTIVE_STATES)),
).fetchone()
return row is not None
def _backup_row(backup_id: str) -> dict:
with db() as conn:
row = conn.execute("SELECT * FROM backups WHERE id = ?", (backup_id,)).fetchone()
if not row:
raise KeyError(f"Backup {backup_id} not found")
return dict(row)
def _job_row(job_id: int | None) -> dict | None:
if job_id is None:
return None
with db() as conn:
row = conn.execute("SELECT * FROM backup_jobs WHERE id = ?", (job_id,)).fetchone()
return row_to_dict(row)
def update_backup(backup_id: str, **values) -> dict:
values["updated_at"] = utc_now()
assignments = ", ".join(f"{key} = ?" for key in values)
with db() as conn:
conn.execute(
f"UPDATE backups SET {assignments} WHERE id = ?",
(*values.values(), backup_id),
)
row = conn.execute("SELECT * FROM backups WHERE id = ?", (backup_id,)).fetchone()
return dict(row)
def fail_backup(backup_id: str, error: str) -> dict:
return update_backup(backup_id, state="failed", error_message=error, completed_at=utc_now())
def _claim_queued_backup(backup_id: str) -> dict | None:
now = utc_now()
with db() as conn:
cur = conn.execute(
"UPDATE backups SET state = 'pve_running', updated_at = ? WHERE id = ? AND state = 'queued'",
(now, backup_id),
)
if cur.rowcount == 0:
return None
row = conn.execute("SELECT * FROM backups WHERE id = ?", (backup_id,)).fetchone()
return dict(row)
def queue_backup_for_job(job: dict) -> dict | None:
now = utc_now()
backup_id = str(uuid.uuid4())
settings = get_settings()
max_active = max(1, int(settings["max_concurrent_backups"]))
with db() as conn:
active_count = conn.execute(
f"SELECT COUNT(*) AS count FROM backups WHERE state IN ({','.join('?' for _ in ACTIVE_STATES)})",
tuple(sorted(ACTIVE_STATES)),
).fetchone()["count"]
if active_count >= max_active:
return None
if _active_for_guest(conn, int(job["guest_vmid"])):
return None
conn.execute(
"""
INSERT INTO backups(
id, job_id, guest_vmid, guest_name, guest_type, node, state,
started_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, 'queued', ?, ?)
""",
(
backup_id,
job["id"],
job["guest_vmid"],
job["guest_name"],
job["guest_type"],
job["node"],
now,
now,
),
)
row = conn.execute("SELECT * FROM backups WHERE id = ?", (backup_id,)).fetchone()
return dict(row)
def list_backups(guest_vmid: int | None = None, state: str | None = None) -> list[dict]:
query = "SELECT * FROM backups"
args: list = []
where = []
if guest_vmid is not None:
where.append("guest_vmid = ?")
args.append(guest_vmid)
if state:
where.append("state = ?")
args.append(state)
if where:
query += " WHERE " + " AND ".join(where)
query += " ORDER BY started_at DESC LIMIT 500"
with db() as conn:
return [dict(row) for row in conn.execute(query, args).fetchall()]
def get_backup(backup_id: str) -> dict:
return _backup_row(backup_id)
def purge_deleted_backup(backup_id: str) -> dict:
backup = _backup_row(backup_id)
if backup["state"] != "deleted":
raise RuntimeError(f"Backup {backup_id} is in state {backup['state']} and is not ready to purge")
with db() as conn:
conn.execute("DELETE FROM backups WHERE id = ?", (backup_id,))
return {"id": backup_id, "purged": True}
def dashboard() -> dict:
with db() as conn:
latest = row_to_dict(conn.execute("SELECT * FROM backups ORDER BY started_at DESC LIMIT 1").fetchone())
active = [dict(row) for row in conn.execute(
f"SELECT * FROM backups WHERE state IN ({','.join('?' for _ in ACTIVE_STATES)}) ORDER BY started_at DESC",
tuple(sorted(ACTIVE_STATES)),
).fetchall()]
failed = conn.execute("SELECT COUNT(*) AS count FROM backups WHERE state = 'failed'").fetchone()["count"]
remote = conn.execute("SELECT COUNT(*) AS count FROM backups WHERE state = 'completed' AND remote_path IS NOT NULL").fetchone()["count"]
next_jobs = [dict(row) for row in conn.execute(
"SELECT * FROM backup_jobs WHERE enabled = 1 ORDER BY next_run_at ASC LIMIT 10"
).fetchall()]
settings = get_settings()
local_dir = Path(settings["local_backup_dir"])
usage = {"path": str(local_dir), "bytes": 0}
if local_dir.exists():
usage["bytes"] = sum(p.stat().st_size for p in local_dir.glob("*") if p.is_file())
return {
"latest_backup": latest,
"active_tasks": active,
"failed_backups": failed,
"remote_backup_count": remote,
"local_staging_usage": usage,
"next_scheduled_jobs": next_jobs,
}
async def _wait_for_pve_task(backup: dict) -> dict:
while True:
status = await asyncio.to_thread(commands.get_task_status, backup["node"], backup["upid"])
if status.get("status") == "stopped":
exitstatus = status.get("exitstatus")
if exitstatus == "OK":
return status
raise RuntimeError(f"Proxmox task failed with exitstatus={exitstatus}")
await asyncio.sleep(10)
def _remote_object(settings: dict, backup: dict, archive: Path) -> str:
remote = str(settings["rclone_remote"]).rstrip(":")
base_path = str(settings.get("rclone_remote_path") or "pve-cloud-backup").strip("/")
object_path = f"{backup['id']}/{archive.name}"
if base_path:
object_path = f"{base_path}/{object_path}"
return f"{remote}:{object_path}"
def _remote_parent_dir(remote_object: str) -> str | None:
if ":" not in remote_object:
return None
remote, object_path = remote_object.split(":", 1)
object_path = object_path.strip("/")
if "/" not in object_path:
return None
parent_path = object_path.rsplit("/", 1)[0]
if not parent_path:
return None
return f"{remote}:{parent_path}"
async def _remove_empty_remote_parent(settings: dict, remote_object: str) -> None:
parent = _remote_parent_dir(remote_object)
if not parent:
return
try:
await asyncio.to_thread(commands.rclone_rmdir, settings["rclone_path"], parent)
except commands.CommandError:
# Non-empty folders and backend-specific "directory not found" behavior
# should not turn an already successful exact file delete into a failed
# backup deletion.
return
async def _delete_remote_file_and_empty_parent(settings: dict, remote_object: str) -> None:
await asyncio.to_thread(commands.rclone_delete_file, settings["rclone_path"], remote_object)
await _remove_empty_remote_parent(settings, remote_object)
async def process_backup(backup_id: str) -> None:
backup = _backup_row(backup_id)
settings = get_settings()
job = _job_row(backup.get("job_id"))
try:
if backup["state"] == "queued":
await notify("backup_started", backup=backup)
backup = _claim_queued_backup(backup_id)
if backup is None:
return
upid = await asyncio.to_thread(
commands.start_proxmox_backup,
node=backup["node"],
vmid=int(backup["guest_vmid"]),
storage=(job or {}).get("proxmox_storage") or settings["proxmox_storage"],
mode=(job or {}).get("backup_mode") or settings["default_backup_mode"],
compression=(job or {}).get("compression") or settings["default_compression"],
)
backup = update_backup(backup_id, upid=upid)
if backup["state"] == "pve_running":
if not backup.get("upid"):
raise RuntimeError("Backup is pve_running but has no Proxmox UPID recorded; refusing to poll task status")
await _wait_for_pve_task(backup)
backup = update_backup(backup_id, state="local_ready", pve_completed_at=utc_now())
await notify("pve_completed", backup=backup)
if backup["state"] == "local_ready":
archive = await asyncio.to_thread(
commands.discover_archive,
settings["local_backup_dir"],
int(backup["guest_vmid"]),
backup["guest_type"],
backup["started_at"],
)
size = archive.stat().st_size
remote_object = _remote_object(settings, backup, archive)
backup = update_backup(
backup_id,
local_path=str(archive),
remote_path=remote_object,
size_bytes=size,
state="uploading",
upload_started_at=utc_now(),
)
await notify("upload_started", backup=backup)
if backup["state"] == "uploading":
await asyncio.to_thread(
commands.rclone_copyto,
settings["rclone_path"],
backup["local_path"],
backup["remote_path"],
)
exists = await asyncio.to_thread(
commands.rclone_object_exists,
settings["rclone_path"],
backup["remote_path"],
)
if not exists:
raise RuntimeError("Upload finished but remote object was not found")
backup = update_backup(backup_id, state="remote_ready")
if backup["state"] == "remote_ready":
backup = update_backup(backup_id, state="local_deleting")
if backup["state"] == "local_deleting":
if backup.get("local_path"):
local_path = Path(backup["local_path"])
if local_path.exists():
local_path.unlink()
backup = update_backup(backup_id, state="completed", completed_at=utc_now())
await notify("backup_completed", backup=backup)
await apply_retention_for_job(backup["job_id"])
except Exception as exc:
failed = fail_backup(backup_id, str(exc))
await notify("backup_failed", backup=failed)
async def delete_remote_backup(backup: dict, reason: str) -> None:
if not backup.get("remote_path"):
update_backup(backup["id"], state="deleted", deleted_at=utc_now())
return
settings = get_settings()
update_backup(backup["id"], state="deleting")
await _delete_remote_file_and_empty_parent(settings, backup["remote_path"])
deleted = update_backup(backup["id"], state="deleted", deleted_at=utc_now())
await notify("remote_deleted", backup=deleted, message=reason)
async def delete_backup_manually(backup_id: str, reason: str = "manual deletion") -> dict:
backup = _backup_row(backup_id)
if backup["state"] == "deleted":
return purge_deleted_backup(backup_id)
if backup["state"] in ACTIVE_STATES:
raise RuntimeError(f"Backup {backup_id} is active in state {backup['state']} and cannot be deleted")
settings = get_settings()
if backup.get("remote_path") and backup["state"] != "deleted":
exists = await asyncio.to_thread(
commands.rclone_object_exists,
settings["rclone_path"],
backup["remote_path"],
)
if exists:
update_backup(backup_id, state="deleting")
await _delete_remote_file_and_empty_parent(settings, backup["remote_path"])
else:
await _remove_empty_remote_parent(settings, backup["remote_path"])
if backup.get("local_path"):
local_path = Path(backup["local_path"])
if local_path.exists() and local_path.is_file():
local_path.unlink()
deleted = update_backup(backup_id, state="deleted", deleted_at=utc_now())
await notify("remote_deleted", backup=deleted, message=reason)
return deleted
async def delete_job_with_backups(job_id: int) -> dict:
with db() as conn:
job = row_to_dict(conn.execute("SELECT * FROM backup_jobs WHERE id = ?", (job_id,)).fetchone())
if not job:
raise KeyError(f"Backup job {job_id} not found")
active = conn.execute(
f"SELECT COUNT(*) AS count FROM backups WHERE job_id = ? AND state IN ({','.join('?' for _ in ACTIVE_STATES)})",
(job_id, *sorted(ACTIVE_STATES)),
).fetchone()["count"]
if active:
raise RuntimeError(f"Backup job {job_id} has {active} active backup(s) and cannot be deleted")
backups = [
dict(row)
for row in conn.execute(
"SELECT * FROM backups WHERE job_id = ? AND state != 'deleted' ORDER BY started_at ASC",
(job_id,),
).fetchall()
]
deleted_backup_ids: list[str] = []
for backup in backups:
deleted = await delete_backup_manually(backup["id"], reason=f"job {job_id} deleted")
deleted_backup_ids.append(deleted["id"])
with db() as conn:
conn.execute("DELETE FROM backup_jobs WHERE id = ?", (job_id,))
return {"job_id": job_id, "deleted_backups": deleted_backup_ids}
async def apply_retention_for_job(job_id: int | None) -> None:
if job_id is None:
return
with db() as conn:
job = row_to_dict(conn.execute("SELECT * FROM backup_jobs WHERE id = ?", (job_id,)).fetchone())
backups = [dict(row) for row in conn.execute(
"""
SELECT * FROM backups
WHERE job_id = ? AND state = 'completed' AND remote_path IS NOT NULL
ORDER BY completed_at DESC
""",
(job_id,),
).fetchall()]
if not job:
return
delete: list[dict] = []
if job["retention_type"] == "latest":
delete = backups[int(job["retention_value"]):]
else:
cutoff = datetime.now(timezone.utc) - timedelta(days=int(job["retention_value"]))
for backup in backups:
completed_at = backup.get("completed_at")
if completed_at and datetime.fromisoformat(completed_at) < cutoff:
delete.append(backup)
for backup in delete:
try:
await delete_remote_backup(backup, f"retention {job['retention_type']}={job['retention_value']}")
except Exception as exc:
failed = fail_backup(backup["id"], f"Retention delete failed: {exc}")
await notify("backup_failed", backup=failed)
async def retention_sweep() -> None:
with db() as conn:
job_ids = [
row["id"]
for row in conn.execute(
"""
SELECT DISTINCT backup_jobs.id
FROM backup_jobs
JOIN backups ON backups.job_id = backup_jobs.id
WHERE backups.state = 'completed' AND backups.remote_path IS NOT NULL
"""
).fetchall()
]
for job_id in job_ids:
await apply_retention_for_job(job_id)
async def recover_unfinished() -> None:
with db() as conn:
rows = conn.execute(
f"SELECT * FROM backups WHERE state IN ({','.join('?' for _ in RECOVERABLE_STATES)})",
tuple(sorted(RECOVERABLE_STATES)),
).fetchall()
for row in rows:
backup = dict(row)
await notify("recovery_action", backup=backup, message=f"resuming state {backup['state']}")
if backup["state"] == "deleting":
await delete_remote_backup(backup, "recovery delete resume")
else:
await process_backup(backup["id"])
async def scheduler_tick() -> None:
settings = get_settings()
limit = max(1, int(settings["max_concurrent_backups"]))
with db() as conn:
active_count = conn.execute(
f"SELECT COUNT(*) AS count FROM backups WHERE state IN ({','.join('?' for _ in ACTIVE_STATES)})",
tuple(sorted(ACTIVE_STATES)),
).fetchone()["count"]
free_slots = max(0, limit - active_count)
if free_slots <= 0:
return
for job in due_jobs(free_slots):
queued = queue_backup_for_job(job)
advance_job(job)
if queued:
asyncio.create_task(process_backup(queued["id"]))
async def worker_loop() -> None:
await recover_unfinished()
retention_sweep_interval_seconds = 3600
last_retention_sweep = 0.0
while True:
await scheduler_tick()
now = asyncio.get_running_loop().time()
if now - last_retention_sweep >= retention_sweep_interval_seconds:
await retention_sweep()
last_retention_sweep = now
settings = get_settings()
max_active = max(1, int(settings["max_concurrent_backups"]))
with db() as conn:
active_count = conn.execute(
f"SELECT COUNT(*) AS count FROM backups WHERE state IN ({','.join('?' for _ in RUNNING_STATES)})",
tuple(sorted(RUNNING_STATES)),
).fetchone()["count"]
free_slots = max(0, max_active - active_count)
queued = [dict(row) for row in conn.execute(
"SELECT * FROM backups WHERE state = 'queued' ORDER BY started_at ASC LIMIT ?",
(free_slots,),
).fetchall()]
for backup in queued:
asyncio.create_task(process_backup(backup["id"]))
await asyncio.sleep(30)
+199
View File
@@ -0,0 +1,199 @@
from __future__ import annotations
import json
import re
import shutil
import subprocess
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
@dataclass
class CommandResult:
stdout: str
stderr: str
class CommandError(RuntimeError):
def __init__(self, command: list[str], result: subprocess.CompletedProcess[str]):
super().__init__(
f"Command failed ({result.returncode}): {' '.join(command)}\n{result.stderr.strip()}"
)
self.command = command
self.returncode = result.returncode
self.stdout = result.stdout
self.stderr = result.stderr
def run(command: list[str], timeout: int | None = None) -> CommandResult:
result = subprocess.run(
command,
text=True,
capture_output=True,
timeout=timeout,
check=False,
)
if result.returncode != 0:
raise CommandError(command, result)
return CommandResult(stdout=result.stdout, stderr=result.stderr)
def find_executable(name: str) -> str:
return shutil.which(name) or ""
def pvesh_json(args: list[str], timeout: int | None = 120):
result = run(["pvesh", *args, "--output-format", "json"], timeout=timeout)
text = result.stdout.strip()
return json.loads(text) if text else None
def _extract_upid(text: str):
text = text.strip()
if not text:
return None
if text.startswith("UPID:"):
return text
match = re.search(r"UPID:[^\s\"']+", text)
if match:
return match.group(0)
try:
data = json.loads(text)
except json.JSONDecodeError:
return None
if isinstance(data, str) and data.startswith("UPID:"):
return data
if isinstance(data, dict):
for key in ("upid", "data"):
value = data.get(key)
if isinstance(value, str) and value.startswith("UPID:"):
return value
return None
def list_guests() -> list[dict]:
resources = pvesh_json(["get", "/cluster/resources", "--type", "vm"]) or []
guests: list[dict] = []
for item in resources:
raw_type = item.get("type")
guest_type = "lxc" if raw_type == "lxc" else "vm"
guests.append(
{
"vmid": int(item["vmid"]),
"name": item.get("name") or str(item["vmid"]),
"type": guest_type,
"node": item.get("node") or "",
"status": item.get("status") or "unknown",
}
)
return sorted(guests, key=lambda row: (row["node"], row["vmid"]))
def list_storages(node: str | None = None) -> list[str]:
path = f"/nodes/{node}/storage" if node else "/storage"
data = pvesh_json(["get", path]) or []
return sorted({item.get("storage") for item in data if item.get("storage")})
def start_proxmox_backup(
*,
node: str,
vmid: int,
storage: str,
mode: str,
compression: str,
) -> str:
command = [
"pvesh",
"create",
f"/nodes/{node}/vzdump",
"--vmid",
str(vmid),
"--storage",
storage,
"--mode",
mode,
"--compress",
compression,
"--output-format",
"json",
]
result = run(command, timeout=300)
upid = _extract_upid(result.stdout)
if not upid:
upid = _extract_upid(result.stderr)
if upid:
return upid
# Some pvesh versions ignore --output-format for create actions and return
# plain task text. Include both streams so failures are diagnosable without
# assuming JSON.
raise RuntimeError(
"Could not read UPID from Proxmox response: "
f"stdout={result.stdout.strip()!r} stderr={result.stderr.strip()!r}"
)
def get_task_status(node: str, upid: str) -> dict:
return pvesh_json(["get", f"/nodes/{node}/tasks/{upid}/status"]) or {}
def discover_archive(local_backup_dir: str, vmid: int, guest_type: str, started_at: str) -> Path:
base = Path(local_backup_dir)
prefix = "vzdump-lxc" if guest_type == "lxc" else "vzdump-qemu"
archive_suffixes = (
".tar",
".tar.gz",
".tar.lzo",
".tar.zst",
".vma",
".vma.gz",
".vma.lzo",
".vma.zst",
)
started = datetime.fromisoformat(started_at)
candidates = []
for path in base.glob(f"{prefix}-{vmid}-*"):
if path.is_file() and path.name.endswith(archive_suffixes):
mtime = datetime.fromtimestamp(path.stat().st_mtime, tz=started.tzinfo)
if mtime >= started:
candidates.append(path)
if not candidates:
raise FileNotFoundError(f"No backup archive found for VMID {vmid} in {base}")
return max(candidates, key=lambda p: p.stat().st_mtime)
def rclone_list_remotes(rclone_path: str) -> list[str]:
result = run([rclone_path, "listremotes"], timeout=60)
return [line.rstrip(":") for line in result.stdout.splitlines() if line.strip()]
def rclone_remote_type(rclone_path: str, remote_name: str) -> str:
remote = remote_name.rstrip(":")
result = run([rclone_path, "config", "show", remote])
for line in result.stdout.splitlines():
if line.strip().startswith("type"):
_, value = line.split("=", 1)
return value.strip()
return ""
def rclone_copyto(rclone_path: str, local_path: str, remote_object: str) -> None:
run([rclone_path, "copyto", local_path, remote_object], timeout=None)
def rclone_object_exists(rclone_path: str, remote_object: str) -> bool:
try:
run([rclone_path, "lsjson", remote_object], timeout=120)
return True
except CommandError:
return False
def rclone_delete_file(rclone_path: str, remote_object: str) -> None:
run([rclone_path, "deletefile", remote_object], timeout=300)
def rclone_rmdir(rclone_path: str, remote_dir: str) -> None:
run([rclone_path, "rmdir", remote_dir], timeout=120)
+16
View File
@@ -0,0 +1,16 @@
from pathlib import Path
APP_DIR = Path(__file__).resolve().parents[2]
BACKEND_DIR = APP_DIR / "backend"
FRONTEND_DIR = APP_DIR / "frontend"
STATIC_DIR = APP_DIR / "static"
DATA_DIR = APP_DIR / "data"
LOG_DIR = APP_DIR / "logs"
SCRIPTS_DIR = APP_DIR / "scripts"
DB_PATH = DATA_DIR / "app.db"
def ensure_dirs() -> None:
for path in (STATIC_DIR, DATA_DIR, LOG_DIR, SCRIPTS_DIR):
path.mkdir(parents=True, exist_ok=True)
+37
View File
@@ -0,0 +1,37 @@
import sqlite3
from contextlib import contextmanager
from datetime import datetime, timezone
from typing import Iterator
from .config import DB_PATH, ensure_dirs
def utc_now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
def connect() -> sqlite3.Connection:
ensure_dirs()
conn = sqlite3.connect(DB_PATH, timeout=30)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("PRAGMA journal_mode = WAL")
conn.execute("PRAGMA busy_timeout = 30000")
return conn
@contextmanager
def db() -> Iterator[sqlite3.Connection]:
conn = connect()
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def row_to_dict(row: sqlite3.Row | None) -> dict | None:
return None if row is None else dict(row)
+152
View File
@@ -0,0 +1,152 @@
from __future__ import annotations
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from croniter import croniter
from .db import db, row_to_dict, utc_now
from .settings_store import get_settings
def validate_cron(expr: str) -> None:
if not croniter.is_valid(expr):
raise ValueError("Invalid cron expression")
def _scheduler_timezone(timezone_name: str | None = None) -> ZoneInfo:
return ZoneInfo(timezone_name or get_settings().get("timezone") or "UTC")
def next_run(expr: str, base: datetime | None = None, timezone_name: str | None = None) -> str:
base = base or datetime.now(timezone.utc)
if base.tzinfo is None:
base = base.replace(tzinfo=timezone.utc)
scheduler_tz = _scheduler_timezone(timezone_name)
local_base = base.astimezone(scheduler_tz)
local_next = croniter(expr, local_base).get_next(datetime)
if local_next.tzinfo is None:
local_next = local_next.replace(tzinfo=scheduler_tz)
return local_next.astimezone(timezone.utc).replace(microsecond=0).isoformat()
def list_jobs() -> list[dict]:
with db() as conn:
return [dict(row) for row in conn.execute("SELECT * FROM backup_jobs ORDER BY id DESC")]
def get_job(job_id: int) -> dict:
with db() as conn:
row = conn.execute("SELECT * FROM backup_jobs WHERE id = ?", (job_id,)).fetchone()
if not row:
raise KeyError(f"Backup job {job_id} not found")
return dict(row)
def create_job(payload: dict) -> dict:
validate_cron(payload["cron_schedule"])
now = utc_now()
with db() as conn:
cur = conn.execute(
"""
INSERT INTO backup_jobs(
guest_vmid, guest_name, guest_type, node, enabled, cron_schedule,
proxmox_storage, backup_mode, compression, retention_type, retention_value,
next_run_at, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
payload["guest_vmid"],
payload["guest_name"],
payload["guest_type"],
payload["node"],
int(payload.get("enabled", True)),
payload["cron_schedule"],
payload["proxmox_storage"],
payload["backup_mode"],
payload["compression"],
payload["retention_type"],
int(payload["retention_value"]),
next_run(payload["cron_schedule"]),
now,
now,
),
)
row = conn.execute("SELECT * FROM backup_jobs WHERE id = ?", (cur.lastrowid,)).fetchone()
return dict(row)
def update_job(job_id: int, payload: dict) -> dict:
existing = get_job(job_id)
merged = {**existing, **payload}
validate_cron(merged["cron_schedule"])
merged["next_run_at"] = next_run(merged["cron_schedule"]) if payload.get("cron_schedule") else existing["next_run_at"]
merged["updated_at"] = utc_now()
with db() as conn:
conn.execute(
"""
UPDATE backup_jobs SET
guest_vmid = ?, guest_name = ?, guest_type = ?, node = ?, enabled = ?,
cron_schedule = ?, proxmox_storage = ?, backup_mode = ?, compression = ?,
retention_type = ?, retention_value = ?, next_run_at = ?, updated_at = ?
WHERE id = ?
""",
(
merged["guest_vmid"],
merged["guest_name"],
merged["guest_type"],
merged["node"],
int(merged["enabled"]),
merged["cron_schedule"],
merged["proxmox_storage"],
merged["backup_mode"],
merged["compression"],
merged["retention_type"],
int(merged["retention_value"]),
merged["next_run_at"],
merged["updated_at"],
job_id,
),
)
row = conn.execute("SELECT * FROM backup_jobs WHERE id = ?", (job_id,)).fetchone()
return dict(row)
def delete_job(job_id: int) -> None:
with db() as conn:
conn.execute("DELETE FROM backup_jobs WHERE id = ?", (job_id,))
def recalculate_next_runs() -> None:
now = utc_now()
with db() as conn:
rows = conn.execute("SELECT id, cron_schedule FROM backup_jobs").fetchall()
for row in rows:
conn.execute(
"UPDATE backup_jobs SET next_run_at = ?, updated_at = ? WHERE id = ?",
(next_run(row["cron_schedule"]), now, row["id"]),
)
def due_jobs(limit: int) -> list[dict]:
now = utc_now()
with db() as conn:
rows = conn.execute(
"""
SELECT * FROM backup_jobs
WHERE enabled = 1 AND next_run_at IS NOT NULL AND next_run_at <= ?
ORDER BY next_run_at ASC
LIMIT ?
""",
(now, limit),
).fetchall()
return [dict(row) for row in rows]
def advance_job(job: dict) -> None:
with db() as conn:
conn.execute(
"UPDATE backup_jobs SET next_run_at = ?, updated_at = ? WHERE id = ?",
(next_run(job["cron_schedule"]), utc_now(), job["id"]),
)
+272
View File
@@ -0,0 +1,272 @@
from __future__ import annotations
import socket
from pathlib import Path
from typing import Callable
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from . import commands, jobs
from .backup_service import (
dashboard,
delete_backup_manually,
delete_job_with_backups,
get_backup,
list_backups,
process_backup,
queue_backup_for_job,
)
from .config import STATIC_DIR, ensure_dirs
from .migrations import run_migrations
from .settings_store import get_settings, set_settings
class SettingsPayload(BaseModel):
proxmox_node: str = ""
proxmox_storage: str = ""
local_backup_dir: str = ""
rclone_path: str = ""
rclone_remote: str = ""
rclone_remote_path: str = "pve-cloud-backup"
discord_webhook_url: str = ""
allowed_cors_origins: list[str] = Field(default_factory=list)
default_compression: str = "zstd"
default_backup_mode: str = "snapshot"
default_retention_type: str = "days"
default_retention_value: int = 30
max_concurrent_backups: int = 1
timezone: str = "UTC"
setup_complete: bool = False
class JobPayload(BaseModel):
guest_vmid: int
guest_name: str
guest_type: str
node: str
enabled: bool = True
cron_schedule: str
proxmox_storage: str
backup_mode: str
compression: str
retention_type: str
retention_value: int
class PartialJobPayload(BaseModel):
guest_vmid: int | None = None
guest_name: str | None = None
guest_type: str | None = None
node: str | None = None
enabled: bool | None = None
cron_schedule: str | None = None
proxmox_storage: str | None = None
backup_mode: str | None = None
compression: str | None = None
retention_type: str | None = None
retention_value: int | None = None
app = FastAPI(title="PVE Cloud Backup")
@app.middleware("http")
async def dynamic_cors(request: Request, call_next: Callable):
origin = request.headers.get("origin")
allowed = get_settings().get("allowed_cors_origins", [])
is_allowed = origin and ("*" in allowed or origin in allowed)
if request.method == "OPTIONS":
response = Response(status_code=204)
else:
response = await call_next(request)
if is_allowed:
response.headers["Access-Control-Allow-Origin"] = origin
response.headers["Vary"] = "Origin"
response.headers["Access-Control-Allow-Methods"] = "GET,POST,PUT,PATCH,DELETE,OPTIONS"
response.headers["Access-Control-Allow-Headers"] = "Content-Type,Authorization"
return response
@app.on_event("startup")
async def startup() -> None:
ensure_dirs()
run_migrations()
@app.get("/api/setup/status")
def setup_status():
settings = get_settings()
return {"setup_complete": settings["setup_complete"]}
@app.get("/api/discover")
def discover():
settings = get_settings()
rclone_path = settings.get("rclone_path") or commands.find_executable("rclone")
remotes: list[str] = []
if rclone_path:
try:
remotes = commands.rclone_list_remotes(rclone_path)
except Exception:
remotes = []
storages: list[str] = []
try:
storages = commands.list_storages(settings.get("proxmox_node") or None)
except Exception:
storages = []
return {
"proxmox_node": settings.get("proxmox_node") or socket.gethostname(),
"rclone_path": rclone_path,
"rclone_remotes": remotes,
"proxmox_storages": storages,
}
def _validate_settings(payload: dict) -> None:
try:
ZoneInfo(payload.get("timezone") or "UTC")
except ZoneInfoNotFoundError as exc:
raise HTTPException(400, "Timezone must be a valid IANA timezone, for example Europe/Berlin or UTC") from exc
if payload.get("setup_complete"):
missing = [
key
for key in ("proxmox_node", "proxmox_storage", "local_backup_dir", "rclone_path", "rclone_remote")
if not payload.get(key)
]
if missing:
raise HTTPException(400, f"Missing required settings: {', '.join(missing)}")
try:
remotes = commands.rclone_list_remotes(payload["rclone_path"])
except Exception as exc:
raise HTTPException(400, f"rclone is not usable: {exc}") from exc
if payload["rclone_remote"].rstrip(":") not in remotes:
raise HTTPException(400, "Configured rclone remote was not found. Configure rclone first, then rerun setup.")
if payload.get("max_concurrent_backups", 1) < 1:
raise HTTPException(400, "Maximum concurrent backups must be at least 1")
@app.get("/api/settings")
def read_settings():
return get_settings()
@app.put("/api/settings")
def write_settings(payload: SettingsPayload):
data = payload.model_dump()
_validate_settings(data)
old_timezone = get_settings().get("timezone")
try:
saved = set_settings(data)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
if saved.get("timezone") != old_timezone:
jobs.recalculate_next_runs()
return get_settings()
@app.get("/api/dashboard")
def read_dashboard():
return dashboard()
@app.get("/api/guests")
def read_guests():
try:
return commands.list_guests()
except Exception as exc:
raise HTTPException(502, f"Failed to list Proxmox guests via pvesh: {exc}") from exc
@app.get("/api/jobs")
def read_jobs():
return jobs.list_jobs()
@app.post("/api/jobs")
def create_job(payload: JobPayload):
try:
return jobs.create_job(payload.model_dump())
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
@app.put("/api/jobs/{job_id}")
def update_job(job_id: int, payload: PartialJobPayload):
data = {key: value for key, value in payload.model_dump().items() if value is not None}
try:
return jobs.update_job(job_id, data)
except KeyError as exc:
raise HTTPException(404, str(exc)) from exc
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
@app.delete("/api/jobs/{job_id}")
async def delete_job(job_id: int):
try:
result = await delete_job_with_backups(job_id)
return {"ok": True, **result}
except KeyError as exc:
raise HTTPException(404, str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(409, str(exc)) from exc
@app.post("/api/jobs/{job_id}/run")
async def run_job_now(job_id: int):
try:
job = jobs.get_job(job_id)
except KeyError as exc:
raise HTTPException(404, str(exc)) from exc
queued = queue_backup_for_job(job)
if not queued:
raise HTTPException(409, "This guest already has an active backup")
import asyncio
asyncio.create_task(process_backup(queued["id"]))
return queued
@app.get("/api/backups")
def read_backups(guest_vmid: int | None = None, state: str | None = None):
return list_backups(guest_vmid=guest_vmid, state=state)
@app.get("/api/backups/{backup_id}")
def read_backup(backup_id: str):
try:
return get_backup(backup_id)
except KeyError as exc:
raise HTTPException(404, str(exc)) from exc
@app.delete("/api/backups/{backup_id}")
async def delete_backup(backup_id: str):
try:
return await delete_backup_manually(backup_id)
except KeyError as exc:
raise HTTPException(404, str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(409, str(exc)) from exc
if (STATIC_DIR / "assets").exists():
app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets")
@app.get("/{full_path:path}")
def spa(full_path: str):
index = STATIC_DIR / "index.html"
requested = STATIC_DIR / full_path
if full_path and requested.exists() and requested.is_file():
return FileResponse(requested)
if index.exists():
return FileResponse(index)
return JSONResponse(
{"detail": "Frontend has not been built yet. Run /opt/pve-cloud-backup/scripts/install.sh."},
status_code=503,
)
+149
View File
@@ -0,0 +1,149 @@
from pathlib import Path
from .db import db, utc_now
def _system_timezone() -> str:
timezone_file = Path("/etc/timezone")
if timezone_file.exists():
value = timezone_file.read_text().strip()
if value:
return value
return "UTC"
DEFAULT_SETTINGS = {
"setup_complete": "false",
"proxmox_node": "",
"proxmox_storage": "",
"local_backup_dir": "/var/lib/vz/dump",
"rclone_path": "/usr/bin/rclone",
"rclone_remote": "",
"rclone_remote_path": "pve-cloud-backup",
"discord_webhook_url": "",
"allowed_cors_origins": "http://localhost:5173,http://127.0.0.1:5173",
"default_compression": "zstd",
"default_backup_mode": "snapshot",
"default_retention_type": "days",
"default_retention_value": "30",
"max_concurrent_backups": "1",
"timezone": _system_timezone(),
}
def run_migrations() -> None:
with db() as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL
)
"""
)
applied = {
row["version"]
for row in conn.execute("SELECT version FROM schema_migrations").fetchall()
}
if 1 not in applied:
conn.executescript(
"""
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE backup_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guest_vmid INTEGER NOT NULL,
guest_name TEXT NOT NULL,
guest_type TEXT NOT NULL CHECK (guest_type IN ('vm','lxc')),
node TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
cron_schedule TEXT NOT NULL,
proxmox_storage TEXT NOT NULL,
backup_mode TEXT NOT NULL,
compression TEXT NOT NULL,
retention_type TEXT NOT NULL CHECK (retention_type IN ('days','latest')),
retention_value INTEGER NOT NULL,
next_run_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE backups (
id TEXT PRIMARY KEY,
job_id INTEGER,
guest_vmid INTEGER NOT NULL,
guest_name TEXT NOT NULL,
guest_type TEXT NOT NULL,
node TEXT NOT NULL,
state TEXT NOT NULL,
upid TEXT,
local_path TEXT,
remote_path TEXT,
size_bytes INTEGER,
error_message TEXT,
started_at TEXT NOT NULL,
pve_completed_at TEXT,
upload_started_at TEXT,
completed_at TEXT,
deleted_at TEXT,
updated_at TEXT NOT NULL,
FOREIGN KEY (job_id) REFERENCES backup_jobs(id) ON DELETE SET NULL
);
CREATE TABLE notification_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type TEXT NOT NULL,
backup_id TEXT,
payload TEXT NOT NULL,
success INTEGER NOT NULL,
error_message TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY (backup_id) REFERENCES backups(id) ON DELETE SET NULL
);
CREATE INDEX idx_backup_jobs_enabled_next_run ON backup_jobs(enabled, next_run_at);
CREATE INDEX idx_backups_guest_state ON backups(guest_vmid, state);
CREATE INDEX idx_backups_state ON backups(state);
"""
)
now = utc_now()
conn.executemany(
"INSERT INTO settings(key, value, updated_at) VALUES (?, ?, ?)",
[(key, value, now) for key, value in DEFAULT_SETTINGS.items()],
)
conn.execute(
"INSERT INTO schema_migrations(version, applied_at) VALUES (?, ?)",
(1, now),
)
if 2 not in applied:
now = utc_now()
conn.execute(
"""
INSERT INTO settings(key, value, updated_at)
VALUES ('rclone_remote_path', 'pve-cloud-backup', ?)
ON CONFLICT(key) DO NOTHING
""",
(now,),
)
conn.execute(
"INSERT INTO schema_migrations(version, applied_at) VALUES (?, ?)",
(2, now),
)
if 3 not in applied:
now = utc_now()
conn.execute(
"""
INSERT INTO settings(key, value, updated_at)
VALUES ('timezone', ?, ?)
ON CONFLICT(key) DO NOTHING
""",
(DEFAULT_SETTINGS["timezone"], now),
)
conn.execute(
"INSERT INTO schema_migrations(version, applied_at) VALUES (?, ?)",
(3, now),
)
+79
View File
@@ -0,0 +1,79 @@
from __future__ import annotations
import json
from datetime import datetime
from typing import Any
import httpx
from .db import db, utc_now
from .settings_store import get_setting
COLORS = {
"backup_started": 0x3498DB,
"pve_completed": 0x2ECC71,
"upload_started": 0x9B59B6,
"backup_completed": 0x2ECC71,
"backup_failed": 0xE74C3C,
"remote_deleted": 0xF39C12,
"recovery_action": 0x95A5A6,
}
def _field(name: str, value: Any) -> dict:
return {"name": name, "value": str(value if value is not None else "-"), "inline": True}
def _duration_seconds(backup: dict) -> int | None:
started = backup.get("started_at")
ended = backup.get("completed_at") or backup.get("deleted_at") or backup.get("pve_completed_at")
if not started or not ended:
return None
try:
return int((datetime.fromisoformat(ended) - datetime.fromisoformat(started)).total_seconds())
except Exception:
return None
async def notify(event_type: str, *, backup: dict | None = None, message: str = "") -> None:
webhook_url = get_setting("discord_webhook_url", "")
fields = []
backup_id = None
if backup:
backup_id = backup.get("id")
duration = _duration_seconds(backup)
enriched = {**backup, "duration_seconds": duration}
for key in ("guest_name", "guest_vmid", "id", "state", "size_bytes", "duration_seconds", "error_message"):
if enriched.get(key) not in (None, ""):
fields.append(_field(key, enriched.get(key)))
if message:
fields.append(_field("message", message))
payload = {
"embeds": [
{
"title": event_type.replace("_", " ").title(),
"color": COLORS.get(event_type, 0x3498DB),
"timestamp": utc_now(),
"fields": fields,
}
]
}
success = False
error_message = None
if webhook_url:
try:
async with httpx.AsyncClient(timeout=15) as client:
response = await client.post(webhook_url, json=payload)
response.raise_for_status()
success = True
except Exception as exc: # Notifications must never fail backups.
error_message = str(exc)
with db() as conn:
conn.execute(
"""
INSERT INTO notification_logs(event_type, backup_id, payload, success, error_message, created_at)
VALUES (?, ?, ?, ?, ?, ?)
""",
(event_type, backup_id, json.dumps(payload), int(success), error_message, utc_now()),
)
+63
View File
@@ -0,0 +1,63 @@
from __future__ import annotations
from .db import db, utc_now
from .migrations import DEFAULT_SETTINGS
INT_KEYS = {"default_retention_value", "max_concurrent_backups"}
BOOL_KEYS = {"setup_complete"}
def parse_value(key: str, value: str):
if key in INT_KEYS:
return int(value)
if key in BOOL_KEYS:
return value.lower() == "true"
if key == "allowed_cors_origins":
return [item.strip() for item in value.split(",") if item.strip()]
return value
def serialize_value(key: str, value) -> str:
if key in BOOL_KEYS:
return "true" if bool(value) else "false"
if key == "allowed_cors_origins":
if isinstance(value, list):
return ",".join(str(item).strip() for item in value if str(item).strip())
return str(value)
def get_settings() -> dict:
with db() as conn:
rows = conn.execute("SELECT key, value FROM settings").fetchall()
raw = {row["key"]: row["value"] for row in rows}
for key, value in DEFAULT_SETTINGS.items():
raw.setdefault(key, value)
return {key: parse_value(key, value) for key, value in raw.items()}
def set_settings(values: dict) -> dict:
now = utc_now()
valid_keys = set(DEFAULT_SETTINGS)
unknown = set(values) - valid_keys
if unknown:
raise ValueError(f"Unknown settings: {', '.join(sorted(unknown))}")
with db() as conn:
for key, value in values.items():
conn.execute(
"""
INSERT INTO settings(key, value, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
""",
(key, serialize_value(key, value), now),
)
return get_settings()
def get_setting(key: str, default: str = "") -> str:
with db() as conn:
row = conn.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
if row:
return row["value"]
return default
+21
View File
@@ -0,0 +1,21 @@
import asyncio
import logging
from .backup_service import worker_loop
from .config import LOG_DIR, ensure_dirs
from .migrations import run_migrations
def main() -> None:
ensure_dirs()
logging.basicConfig(
filename=LOG_DIR / "worker.log",
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
run_migrations()
asyncio.run(worker_loop())
if __name__ == "__main__":
main()
+6
View File
@@ -0,0 +1,6 @@
fastapi==0.116.1
uvicorn==0.35.0
pydantic==2.11.7
croniter==6.0.0
httpx==0.28.1
pytest==8.4.1
+30
View File
@@ -0,0 +1,30 @@
import sys
from pathlib import Path
import pytest
BACKEND_DIR = Path(__file__).resolve().parents[1]
if str(BACKEND_DIR) not in sys.path:
sys.path.insert(0, str(BACKEND_DIR))
@pytest.fixture()
def isolated_db(tmp_path, monkeypatch):
from app import db as db_module
monkeypatch.setattr(db_module, "DB_PATH", tmp_path / "app.db")
from app.migrations import run_migrations
run_migrations()
return tmp_path / "app.db"
@pytest.fixture()
def no_notifications(monkeypatch):
async def noop(*args, **kwargs):
return None
import app.backup_service as backup_service
monkeypatch.setattr(backup_service, "notify", noop)
+394
View File
@@ -0,0 +1,394 @@
import asyncio
from pathlib import Path
import pytest
from app.db import db, utc_now
from app.jobs import create_job
from app.settings_store import set_settings
def _settings(tmp_path):
return {
"setup_complete": False,
"proxmox_node": "pve",
"proxmox_storage": "hitachi",
"local_backup_dir": str(tmp_path),
"rclone_path": "/usr/bin/rclone",
"rclone_remote": "onedrive",
"rclone_remote_path": "Backups/Proxmox",
"discord_webhook_url": "",
"allowed_cors_origins": [],
"default_compression": "zstd",
"default_backup_mode": "snapshot",
"default_retention_type": "days",
"default_retention_value": 30,
"max_concurrent_backups": 1,
}
def _job():
return create_job(
{
"guest_vmid": 110,
"guest_name": "adguard",
"guest_type": "vm",
"node": "pve",
"enabled": True,
"cron_schedule": "0 2 * * *",
"proxmox_storage": "hitachi",
"backup_mode": "snapshot",
"compression": "zstd",
"retention_type": "latest",
"retention_value": 1,
}
)
def test_backup_state_transitions_complete_and_delete_local_safely(
isolated_db, no_notifications, tmp_path, monkeypatch
):
from app import backup_service, commands
set_settings(_settings(tmp_path))
job = _job()
backup = backup_service.queue_backup_for_job(job)
archive = tmp_path / "vzdump-qemu-110-2026_01_01-00_00_00.vma.zst"
archive.write_bytes(b"backup")
monkeypatch.setattr(commands, "start_proxmox_backup", lambda **kwargs: "UPID:pve:test")
monkeypatch.setattr(commands, "get_task_status", lambda node, upid: {"status": "stopped", "exitstatus": "OK"})
monkeypatch.setattr(commands, "discover_archive", lambda *args: archive)
monkeypatch.setattr(commands, "rclone_copyto", lambda *args: None)
monkeypatch.setattr(commands, "rclone_object_exists", lambda *args: True)
monkeypatch.setattr(backup_service, "apply_retention_for_job", lambda job_id: asyncio.sleep(0))
asyncio.run(backup_service.process_backup(backup["id"]))
final = backup_service.get_backup(backup["id"])
assert final["state"] == "completed"
assert final["upid"] == "UPID:pve:test"
assert final["remote_path"].startswith("onedrive:Backups/Proxmox/")
assert not archive.exists()
def test_only_one_worker_can_claim_queued_backup(isolated_db, no_notifications, tmp_path):
from app import backup_service
set_settings(_settings(tmp_path))
job = _job()
backup = backup_service.queue_backup_for_job(job)
first = backup_service._claim_queued_backup(backup["id"])
second = backup_service._claim_queued_backup(backup["id"])
assert first is not None
assert first["state"] == "pve_running"
assert second is None
def test_pve_running_without_upid_fails_without_polling_none(isolated_db, no_notifications, monkeypatch):
from app import backup_service, commands
poll_calls = []
monkeypatch.setattr(commands, "get_task_status", lambda *args: poll_calls.append(args))
with db() as conn:
conn.execute(
"""
INSERT INTO backups(id, guest_vmid, guest_name, guest_type, node, state, started_at, updated_at)
VALUES ('missing-upid', 110, 'adguard', 'lxc', 'astrid', 'pve_running', ?, ?)
""",
(utc_now(), utc_now()),
)
asyncio.run(backup_service.process_backup("missing-upid"))
final = backup_service.get_backup("missing-upid")
assert final["state"] == "failed"
assert "no Proxmox UPID" in final["error_message"]
assert poll_calls == []
def test_local_archive_is_not_deleted_when_remote_verification_fails(
isolated_db, no_notifications, tmp_path, monkeypatch
):
from app import backup_service, commands
set_settings(_settings(tmp_path))
job = _job()
backup = backup_service.queue_backup_for_job(job)
archive = tmp_path / "vzdump-qemu-110-2026_01_01-00_00_00.vma.zst"
archive.write_bytes(b"backup")
monkeypatch.setattr(commands, "start_proxmox_backup", lambda **kwargs: "UPID:pve:test")
monkeypatch.setattr(commands, "get_task_status", lambda node, upid: {"status": "stopped", "exitstatus": "OK"})
monkeypatch.setattr(commands, "discover_archive", lambda *args: archive)
monkeypatch.setattr(commands, "rclone_copyto", lambda *args: None)
monkeypatch.setattr(commands, "rclone_object_exists", lambda *args: False)
asyncio.run(backup_service.process_backup(backup["id"]))
final = backup_service.get_backup(backup["id"])
assert final["state"] == "failed"
assert archive.exists()
def test_retention_latest_selects_expired_remote_backups(
isolated_db, no_notifications, tmp_path, monkeypatch
):
from app import backup_service
set_settings(_settings(tmp_path))
job = _job()
deleted = []
async def fake_delete_remote_backup(backup, reason):
deleted.append((backup["id"], reason))
monkeypatch.setattr(backup_service, "delete_remote_backup", fake_delete_remote_backup)
with db() as conn:
for index in range(3):
backup_id = f"backup-{index}"
conn.execute(
"""
INSERT INTO backups(
id, job_id, guest_vmid, guest_name, guest_type, node, state,
remote_path, started_at, completed_at, updated_at
)
VALUES (?, ?, 110, 'adguard', 'vm', 'pve', 'completed', ?, ?, ?, ?)
""",
(
backup_id,
job["id"],
f"crypt:backup/{index}",
f"2026-01-01T00:0{index}:00+00:00",
f"2026-01-01T00:0{index}:30+00:00",
utc_now(),
),
)
asyncio.run(backup_service.apply_retention_for_job(job["id"]))
assert deleted == [
("backup-1", "retention latest=1"),
("backup-0", "retention latest=1"),
]
def test_startup_recovery_resumes_known_states(isolated_db, no_notifications, monkeypatch):
from app import backup_service
resumed = []
deleted = []
async def fake_process_backup(backup_id):
resumed.append(backup_id)
async def fake_delete_remote_backup(backup, reason):
deleted.append((backup["id"], reason))
monkeypatch.setattr(backup_service, "process_backup", fake_process_backup)
monkeypatch.setattr(backup_service, "delete_remote_backup", fake_delete_remote_backup)
with db() as conn:
for backup_id, state in (("b1", "local_ready"), ("b2", "deleting"), ("b3", "completed")):
conn.execute(
"""
INSERT INTO backups(id, guest_vmid, guest_name, guest_type, node, state, started_at, updated_at)
VALUES (?, 110, 'adguard', 'vm', 'pve', ?, ?, ?)
""",
(backup_id, state, utc_now(), utc_now()),
)
asyncio.run(backup_service.recover_unfinished())
assert resumed == ["b1"]
assert deleted == [("b2", "recovery delete resume")]
def test_retention_sweep_applies_all_jobs_with_completed_remote_backups(
isolated_db, no_notifications, tmp_path, monkeypatch
):
from app import backup_service
set_settings(_settings(tmp_path))
job = _job()
swept = []
async def fake_apply_retention_for_job(job_id):
swept.append(job_id)
monkeypatch.setattr(backup_service, "apply_retention_for_job", fake_apply_retention_for_job)
with db() as conn:
conn.execute(
"""
INSERT INTO backups(
id, job_id, guest_vmid, guest_name, guest_type, node, state,
remote_path, started_at, completed_at, updated_at
)
VALUES ('sweep-test', ?, 110, 'adguard', 'vm', 'pve', 'completed',
'onedrive:pve-cloud-backup/sweep-test/archive.tar.zst', ?, ?, ?)
""",
(job["id"], utc_now(), utc_now(), utc_now()),
)
asyncio.run(backup_service.retention_sweep())
assert swept == [job["id"]]
def test_manual_backup_delete_removes_remote_and_known_local_file(
isolated_db, no_notifications, tmp_path, monkeypatch
):
from app import backup_service, commands
set_settings(_settings(tmp_path))
local_file = tmp_path / "orphaned-local.tar.zst"
local_file.write_text("backup")
deleted_files = []
removed_dirs = []
monkeypatch.setattr(commands, "rclone_object_exists", lambda *args: True)
monkeypatch.setattr(commands, "rclone_delete_file", lambda *args: deleted_files.append(args))
monkeypatch.setattr(commands, "rclone_rmdir", lambda *args: removed_dirs.append(args))
with db() as conn:
conn.execute(
"""
INSERT INTO backups(
id, guest_vmid, guest_name, guest_type, node, state,
local_path, remote_path, started_at, completed_at, updated_at
)
VALUES ('manual-delete', 110, 'adguard', 'vm', 'pve', 'completed',
?, 'onedrive:pve-cloud-backup/manual-delete/archive.tar.zst', ?, ?, ?)
""",
(str(local_file), utc_now(), utc_now(), utc_now()),
)
deleted = asyncio.run(backup_service.delete_backup_manually("manual-delete"))
assert deleted["state"] == "deleted"
assert not local_file.exists()
assert deleted_files == [
(
"/usr/bin/rclone",
"onedrive:pve-cloud-backup/manual-delete/archive.tar.zst",
)
]
assert removed_dirs == [
(
"/usr/bin/rclone",
"onedrive:pve-cloud-backup/manual-delete",
)
]
def test_manual_backup_delete_removes_empty_remote_folder_when_file_is_already_missing(
isolated_db, no_notifications, tmp_path, monkeypatch
):
from app import backup_service, commands
set_settings(_settings(tmp_path))
removed_dirs = []
monkeypatch.setattr(commands, "rclone_object_exists", lambda *args: False)
monkeypatch.setattr(commands, "rclone_delete_file", lambda *args: pytest.fail("deletefile should not run"))
monkeypatch.setattr(commands, "rclone_rmdir", lambda *args: removed_dirs.append(args))
with db() as conn:
conn.execute(
"""
INSERT INTO backups(
id, guest_vmid, guest_name, guest_type, node, state,
remote_path, started_at, completed_at, updated_at
)
VALUES ('missing-remote-file', 110, 'adguard', 'vm', 'pve', 'completed',
'onedrive:pve-cloud-backup/missing-remote-file/archive.tar.zst', ?, ?, ?)
""",
(utc_now(), utc_now(), utc_now()),
)
deleted = asyncio.run(backup_service.delete_backup_manually("missing-remote-file"))
assert deleted["state"] == "deleted"
assert removed_dirs == [
(
"/usr/bin/rclone",
"onedrive:pve-cloud-backup/missing-remote-file",
)
]
def test_manual_backup_delete_refuses_active_backup(isolated_db, no_notifications):
from app import backup_service
with db() as conn:
conn.execute(
"""
INSERT INTO backups(id, guest_vmid, guest_name, guest_type, node, state, started_at, updated_at)
VALUES ('active-delete', 110, 'adguard', 'vm', 'pve', 'uploading', ?, ?)
""",
(utc_now(), utc_now()),
)
with pytest.raises(RuntimeError):
asyncio.run(backup_service.delete_backup_manually("active-delete"))
def test_deleting_already_deleted_backup_purges_metadata_row(isolated_db, no_notifications):
from app import backup_service
with db() as conn:
conn.execute(
"""
INSERT INTO backups(
id, guest_vmid, guest_name, guest_type, node, state,
remote_path, started_at, deleted_at, updated_at
)
VALUES ('purge-deleted', 110, 'adguard', 'vm', 'pve', 'deleted',
'onedrive:pve-cloud-backup/purge-deleted/archive.tar.zst', ?, ?, ?)
""",
(utc_now(), utc_now(), utc_now()),
)
result = asyncio.run(backup_service.delete_backup_manually("purge-deleted"))
assert result == {"id": "purge-deleted", "purged": True}
with db() as conn:
assert conn.execute("SELECT COUNT(*) AS count FROM backups WHERE id = 'purge-deleted'").fetchone()["count"] == 0
def test_delete_job_deletes_backups_before_removing_job(
isolated_db, no_notifications, tmp_path, monkeypatch
):
from app import backup_service
set_settings(_settings(tmp_path))
job = _job()
deleted = []
async def fake_delete_backup_manually(backup_id, reason="manual deletion"):
deleted.append((backup_id, reason))
with db() as conn:
conn.execute("UPDATE backups SET state = 'deleted', deleted_at = ?, updated_at = ? WHERE id = ?", (utc_now(), utc_now(), backup_id))
row = conn.execute("SELECT * FROM backups WHERE id = ?", (backup_id,)).fetchone()
return dict(row)
monkeypatch.setattr(backup_service, "delete_backup_manually", fake_delete_backup_manually)
with db() as conn:
conn.execute(
"""
INSERT INTO backups(id, job_id, guest_vmid, guest_name, guest_type, node, state, remote_path, started_at, completed_at, updated_at)
VALUES ('job-delete-backup', ?, 110, 'adguard', 'vm', 'pve', 'completed',
'onedrive:pve-cloud-backup/job-delete-backup/archive.tar.zst', ?, ?, ?)
""",
(job["id"], utc_now(), utc_now(), utc_now()),
)
result = asyncio.run(backup_service.delete_job_with_backups(job["id"]))
assert result == {"job_id": job["id"], "deleted_backups": ["job-delete-backup"]}
assert deleted == [("job-delete-backup", f"job {job['id']} deleted")]
with db() as conn:
assert conn.execute("SELECT COUNT(*) AS count FROM backup_jobs WHERE id = ?", (job["id"],)).fetchone()["count"] == 0
+112
View File
@@ -0,0 +1,112 @@
import json
from pathlib import Path
import pytest
from app import commands
def test_start_proxmox_backup_parses_upid(monkeypatch):
expected = "UPID:pve:0001:0002:0003:vzdump:110:root@pam:"
def fake_run(command, timeout=None):
assert command[:3] == ["pvesh", "create", "/nodes/pve/vzdump"]
assert "--output-format" in command
return commands.CommandResult(stdout=json.dumps(expected), stderr="")
monkeypatch.setattr(commands, "run", fake_run)
assert commands.start_proxmox_backup(
node="pve",
vmid=110,
storage="hitachi",
mode="snapshot",
compression="zstd",
) == expected
def test_start_proxmox_backup_parses_plain_upid(monkeypatch):
expected = "UPID:pve:0001:0002:0003:vzdump:110:root@pam:"
monkeypatch.setattr(commands, "run", lambda command, timeout=None: commands.CommandResult(stdout=expected, stderr=""))
assert commands.start_proxmox_backup(
node="pve",
vmid=110,
storage="hitachi",
mode="snapshot",
compression="zstd",
) == expected
def test_start_proxmox_backup_parses_upid_after_info_lines(monkeypatch):
expected = "UPID:pve:0001:0002:0003:vzdump:110:root@pam:"
output = f"INFO: starting new backup job\nINFO: Backup job finished successfully\n\"{expected}\""
monkeypatch.setattr(commands, "run", lambda command, timeout=None: commands.CommandResult(stdout=output, stderr=""))
assert commands.start_proxmox_backup(
node="pve",
vmid=110,
storage="hitachi",
mode="snapshot",
compression="zstd",
) == expected
def test_list_guests_normalizes_vm_and_lxc(monkeypatch):
payload = [
{"vmid": "110", "name": "adguard", "type": "qemu", "node": "pve", "status": "running"},
{"vmid": "111", "name": "ct", "type": "lxc", "node": "pve", "status": "stopped"},
]
monkeypatch.setattr(commands, "pvesh_json", lambda args: payload)
guests = commands.list_guests()
assert guests == [
{"vmid": 110, "name": "adguard", "type": "vm", "node": "pve", "status": "running"},
{"vmid": 111, "name": "ct", "type": "lxc", "node": "pve", "status": "stopped"},
]
def test_discover_archive_finds_newest_matching_file(tmp_path):
old_file = tmp_path / "vzdump-qemu-110-2026_01_01-00_00_00.vma.zst"
new_file = tmp_path / "vzdump-qemu-110-2026_01_01-00_01_00.vma.zst"
log_file = tmp_path / "vzdump-qemu-110-2026_01_01-00_01_00.log"
wrong_guest = tmp_path / "vzdump-qemu-999-2026_01_01-00_01_00.vma.zst"
for path in (old_file, new_file, log_file, wrong_guest):
path.write_text("data")
old_file.touch()
new_file.touch()
log_file.touch()
found = commands.discover_archive(str(tmp_path), 110, "vm", "2020-01-01T00:00:00+00:00")
assert found == new_file
def test_discover_archive_raises_when_missing(tmp_path):
with pytest.raises(FileNotFoundError):
commands.discover_archive(str(tmp_path), 110, "vm", "2020-01-01T00:00:00+00:00")
def test_rclone_commands_use_exact_objects(monkeypatch):
calls = []
def fake_run(command, timeout=None):
calls.append(command)
return commands.CommandResult(stdout="", stderr="")
monkeypatch.setattr(commands, "run", fake_run)
commands.rclone_copyto("/usr/bin/rclone", "/tmp/archive.zst", "crypt:backup/archive.zst")
commands.rclone_delete_file("/usr/bin/rclone", "crypt:backup/archive.zst")
commands.rclone_rmdir("/usr/bin/rclone", "crypt:backup")
assert calls == [
["/usr/bin/rclone", "copyto", "/tmp/archive.zst", "crypt:backup/archive.zst"],
["/usr/bin/rclone", "deletefile", "crypt:backup/archive.zst"],
["/usr/bin/rclone", "rmdir", "crypt:backup"],
]
assert all("sync" not in call for call in calls for call in call)
+21
View File
@@ -0,0 +1,21 @@
from datetime import datetime, timezone
import pytest
from app.jobs import next_run, validate_cron
def test_cron_validation_accepts_standard_expression():
validate_cron("0 2 * * *")
assert next_run("0 2 * * *")
def test_cron_validation_rejects_invalid_expression():
with pytest.raises(ValueError):
validate_cron("not a cron")
def test_next_run_interprets_cron_in_configured_timezone():
base = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc)
assert next_run("0 2 * * *", base=base, timezone_name="Europe/Berlin") == "2026-01-01T01:00:00+00:00"
+42
View File
@@ -0,0 +1,42 @@
from app import commands
from app.main import _validate_settings
def test_plain_onedrive_remote_is_accepted(monkeypatch):
monkeypatch.setattr(commands, "rclone_list_remotes", lambda rclone_path: ["onedrive"])
_validate_settings(
{
"setup_complete": True,
"proxmox_node": "pve",
"proxmox_storage": "hitachi",
"local_backup_dir": "/mnt/pve/hitachi/dump",
"rclone_path": "/usr/bin/rclone",
"rclone_remote": "onedrive",
"rclone_remote_path": "Backups/Proxmox",
"max_concurrent_backups": 1,
"timezone": "Europe/Berlin",
}
)
def test_invalid_timezone_is_rejected(monkeypatch):
monkeypatch.setattr(commands, "rclone_list_remotes", lambda rclone_path: ["onedrive"])
import pytest
from fastapi import HTTPException
with pytest.raises(HTTPException):
_validate_settings(
{
"setup_complete": True,
"proxmox_node": "pve",
"proxmox_storage": "hitachi",
"local_backup_dir": "/mnt/pve/hitachi/dump",
"rclone_path": "/usr/bin/rclone",
"rclone_remote": "onedrive",
"rclone_remote_path": "Backups/Proxmox",
"max_concurrent_backups": 1,
"timezone": "Not/AZone",
}
)
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PVE Cloud Backup</title>
</head>
<body class="bg-slate-950 text-slate-100">
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+2267
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "pve-cloud-backup-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "vue-tsc --noEmit && vite build --outDir ../static --emptyOutDir",
"dev": "vite"
},
"dependencies": {
"vue": "^3.5.17"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.4",
"autoprefixer": "^10.4.21",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
"typescript": "^5.8.3",
"vite": "^5.4.19",
"vue-tsc": "^3.0.1"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+688
View File
@@ -0,0 +1,688 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
type Settings = {
setup_complete: boolean
proxmox_node: string
proxmox_storage: string
local_backup_dir: string
rclone_path: string
rclone_remote: string
rclone_remote_path: string
discord_webhook_url: string
allowed_cors_origins: string[]
default_compression: string
default_backup_mode: string
default_retention_type: string
default_retention_value: number
max_concurrent_backups: number
timezone: string
}
type Guest = { vmid: number; name: string; type: string; node: string; status: string }
type Job = {
id: number
guest_vmid: number
guest_name: string
guest_type: string
node: string
enabled: number | boolean
cron_schedule: string
proxmox_storage: string
backup_mode: string
compression: string
retention_type: string
retention_value: number
next_run_at?: string
}
type Backup = {
id: string
job_id?: number
guest_vmid: number
guest_name: string
guest_type: string
node: string
state: string
upid?: string
local_path?: string
remote_path?: string
size_bytes?: number
error_message?: string
started_at: string
pve_completed_at?: string
upload_started_at?: string
completed_at?: string
deleted_at?: string
updated_at?: string
}
const emptySettings: Settings = {
setup_complete: false,
proxmox_node: '',
proxmox_storage: '',
local_backup_dir: '/var/lib/vz/dump',
rclone_path: '/usr/bin/rclone',
rclone_remote: '',
rclone_remote_path: 'pve-cloud-backup',
discord_webhook_url: '',
allowed_cors_origins: ['http://localhost:5173', 'http://127.0.0.1:5173'],
default_compression: 'zstd',
default_backup_mode: 'snapshot',
default_retention_type: 'days',
default_retention_value: 30,
max_concurrent_backups: 1,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
}
const page = ref('Dashboard')
const loading = ref(false)
const error = ref('')
const actionMessage = ref('')
const settings = reactive<Settings>({ ...emptySettings })
const corsText = ref('')
const discover = reactive({ proxmox_node: '', rclone_path: '', rclone_remotes: [] as string[], proxmox_storages: [] as string[] })
const guests = ref<Guest[]>([])
const jobs = ref<Job[]>([])
const backups = ref<Backup[]>([])
const dashboard = ref<any>({})
const selectedBackup = ref<Backup | null>(null)
const deletingJobId = ref<number | null>(null)
const deletingBackupId = ref<string | null>(null)
const nowMs = ref(Date.now())
let clockTimer: number | undefined
const filters = reactive({ guest_vmid: '', state: '' })
const newJob = reactive({
guest: '',
cron_schedule: '0 2 * * *',
enabled: true,
retention_type: 'days',
retention_value: 30,
})
const editingJobId = ref<number | null>(null)
const editJobForm = reactive({
enabled: true,
cron_schedule: '',
proxmox_storage: '',
backup_mode: '',
compression: '',
retention_type: 'days',
retention_value: 1,
})
const pages = computed(() => settings.setup_complete ? ['Dashboard', 'Guests', 'Backup jobs', 'Backup history', 'Backup details', 'Settings'] : ['Setup wizard'])
const timezoneOptions = computed(() => {
const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone
return Array.from(new Set([settings.timezone, browserTimezone, 'Europe/Berlin', 'UTC', 'America/New_York', 'America/Los_Angeles', 'Asia/Tokyo', 'Australia/Sydney'].filter(Boolean)))
})
const jobCountsByGuest = computed(() => {
const counts = new Map<number, { total: number; enabled: number }>()
for (const job of jobs.value) {
const existing = counts.get(job.guest_vmid) || { total: 0, enabled: 0 }
existing.total += 1
if (Boolean(job.enabled)) existing.enabled += 1
counts.set(job.guest_vmid, existing)
}
return counts
})
async function api<T>(url: string, options: RequestInit = {}): Promise<T> {
const headers = new Headers(options.headers)
headers.set('Content-Type', 'application/json')
const res = await fetch(url, {
...options,
headers,
})
if (!res.ok) {
const body = await res.json().catch(() => ({ detail: res.statusText }))
throw new Error(body.detail || res.statusText)
}
return res.json()
}
function applySettings(data: Settings) {
Object.assign(settings, data)
corsText.value = data.allowed_cors_origins.join('\n')
newJob.retention_type = data.default_retention_type
newJob.retention_value = data.default_retention_value
}
async function loadSettings() {
applySettings(await api<Settings>('/api/settings'))
page.value = settings.setup_complete ? 'Dashboard' : 'Setup wizard'
}
async function loadDiscover() {
Object.assign(discover, await api('/api/discover'))
if (!settings.proxmox_node) settings.proxmox_node = discover.proxmox_node
if (!settings.rclone_path) settings.rclone_path = discover.rclone_path
if (!settings.rclone_remote && discover.rclone_remotes.length) settings.rclone_remote = discover.rclone_remotes[0]
if (!settings.proxmox_storage && discover.proxmox_storages.length) settings.proxmox_storage = discover.proxmox_storages[0]
}
async function saveSettings(complete = settings.setup_complete) {
loading.value = true
error.value = ''
actionMessage.value = ''
try {
const payload = { ...settings, setup_complete: complete, allowed_cors_origins: corsText.value.split(/\n|,/).map(s => s.trim()).filter(Boolean) }
applySettings(await api<Settings>('/api/settings', { method: 'PUT', body: JSON.stringify(payload) }))
if (settings.setup_complete) page.value = 'Dashboard'
await refresh()
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
async function refresh() {
if (!settings.setup_complete) return
const loaders: Promise<any>[] = []
if (page.value === 'Dashboard') loaders.push(api('/api/dashboard').then(v => dashboard.value = v))
if (page.value === 'Guests' || page.value === 'Backup jobs') loaders.push(api<Guest[]>('/api/guests').then(v => guests.value = v).catch(e => error.value = e.message))
if (page.value === 'Backup jobs' || page.value === 'Dashboard' || page.value === 'Guests') loaders.push(api<Job[]>('/api/jobs').then(v => jobs.value = v))
if (page.value === 'Backup history') await loadBackups()
await Promise.all(loaders)
}
async function loadBackups() {
const params = new URLSearchParams()
if (filters.guest_vmid) params.set('guest_vmid', filters.guest_vmid)
if (filters.state) params.set('state', filters.state)
backups.value = await api<Backup[]>(`/api/backups?${params}`)
}
async function createJob() {
const guest = guests.value.find(g => String(g.vmid) === newJob.guest)
if (!guest) {
error.value = 'Select a guest'
return
}
const payload = {
guest_vmid: guest.vmid,
guest_name: guest.name,
guest_type: guest.type,
node: guest.node,
enabled: newJob.enabled,
cron_schedule: newJob.cron_schedule,
proxmox_storage: settings.proxmox_storage,
backup_mode: settings.default_backup_mode,
compression: settings.default_compression,
retention_type: newJob.retention_type,
retention_value: Number(newJob.retention_value),
}
loading.value = true
error.value = ''
actionMessage.value = 'Creating backup job...'
try {
await api('/api/jobs', { method: 'POST', body: JSON.stringify(payload) })
actionMessage.value = ''
await refresh()
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
async function toggleJob(job: Job) {
loading.value = true
error.value = ''
actionMessage.value = `${job.enabled ? 'Disabling' : 'Enabling'} job #${job.id}...`
try {
await api(`/api/jobs/${job.id}`, { method: 'PUT', body: JSON.stringify({ enabled: !Boolean(job.enabled) }) })
actionMessage.value = ''
await refresh()
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
function beginEditJob(job: Job) {
editingJobId.value = job.id
editJobForm.enabled = Boolean(job.enabled)
editJobForm.cron_schedule = job.cron_schedule
editJobForm.proxmox_storage = job.proxmox_storage
editJobForm.backup_mode = job.backup_mode
editJobForm.compression = job.compression
editJobForm.retention_type = job.retention_type
editJobForm.retention_value = job.retention_value
}
function cancelEditJob() {
editingJobId.value = null
}
async function saveJob(job: Job) {
error.value = ''
loading.value = true
actionMessage.value = `Saving job #${job.id}...`
try {
await api(`/api/jobs/${job.id}`, {
method: 'PUT',
body: JSON.stringify({
enabled: editJobForm.enabled,
cron_schedule: editJobForm.cron_schedule,
proxmox_storage: editJobForm.proxmox_storage,
backup_mode: editJobForm.backup_mode,
compression: editJobForm.compression,
retention_type: editJobForm.retention_type,
retention_value: Number(editJobForm.retention_value),
}),
})
editingJobId.value = null
actionMessage.value = ''
await refresh()
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
async function deleteJob(job: Job) {
error.value = ''
actionMessage.value = ''
const ok = window.confirm(`Delete job for ${job.guest_name} (${job.guest_vmid}) and delete all known remote backups for this job?`)
if (!ok) return
deletingJobId.value = job.id
loading.value = true
actionMessage.value = `Deleting job #${job.id} and its known backups. This can take a while if remote files are large...`
try {
const result = await api<{ deleted_backups: string[] }>(`/api/jobs/${job.id}`, { method: 'DELETE' })
editingJobId.value = null
actionMessage.value = `Deleted job #${job.id}. Deleted ${result.deleted_backups?.length || 0} backup(s).`
await refresh()
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
deletingJobId.value = null
}
}
async function runJob(job: Job) {
loading.value = true
error.value = ''
actionMessage.value = `Queuing job #${job.id}...`
try {
await api(`/api/jobs/${job.id}/run`, { method: 'POST' })
actionMessage.value = ''
page.value = 'Dashboard'
await refresh()
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
async function deleteBackup(backup: Backup) {
error.value = ''
actionMessage.value = ''
const hardPurge = backup.state === 'deleted'
const ok = window.confirm(
hardPurge
? `Nuke deleted backup ${backup.id} from the app database entirely? The remote/local files are already considered deleted.`
: `Delete backup ${backup.id}? If a remote object is recorded, it will be deleted from rclone first.`
)
if (!ok) return
deletingBackupId.value = backup.id
loading.value = true
actionMessage.value = hardPurge
? `Nuking deleted backup ${backup.id} from the app database...`
: `Deleting backup ${backup.id}. This can take a while while rclone removes the remote object...`
try {
const result = await api<Backup | { id: string; purged: boolean }>(`/api/backups/${backup.id}`, { method: 'DELETE' })
if ('purged' in result) {
selectedBackup.value = null
actionMessage.value = `Nuked backup ${backup.id} from the app database.`
page.value = 'Backup history'
} else {
selectedBackup.value = result
actionMessage.value = `Deleted backup ${backup.id}.`
}
await loadBackups()
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
deletingBackupId.value = null
}
}
function fmtBytes(value?: number) {
if (!value) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let n = value
let i = 0
while (n > 1024 && i < units.length - 1) { n /= 1024; i++ }
return `${n.toFixed(i ? 1 : 0)} ${units[i]}`
}
function fmtDate(value?: string) {
if (!value) return '—'
const d = new Date(value)
if (Number.isNaN(d.getTime())) return value
try {
return d.toLocaleString(undefined, { timeZone: settings.timezone || 'UTC' })
} catch {
return d.toLocaleString()
}
}
function countdownTo(value?: string) {
if (!value) return 'not scheduled'
const target = new Date(value).getTime()
if (Number.isNaN(target)) return 'invalid time'
const diff = target - nowMs.value
if (diff <= 0) return 'due now'
const totalSeconds = Math.floor(diff / 1000)
const days = Math.floor(totalSeconds / 86400)
const hours = Math.floor((totalSeconds % 86400) / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
const time = [hours, minutes, seconds].map(v => String(v).padStart(2, '0')).join(':')
return days ? `in ${days}d ${time}` : `in ${time}`
}
function valueOrDash(value?: string | number | null) {
return value === undefined || value === null || value === '' ? '—' : String(value)
}
function stateClass(state?: string) {
if (state === 'completed') return 'border-emerald-800 bg-emerald-950 text-emerald-200'
if (state === 'failed') return 'border-red-800 bg-red-950 text-red-200'
if (state === 'deleted') return 'border-slate-700 bg-slate-800 text-slate-300'
if (state === 'deleting') return 'border-amber-700 bg-amber-950 text-amber-100'
return 'border-cyan-800 bg-cyan-950 text-cyan-200'
}
onMounted(async () => {
clockTimer = window.setInterval(() => {
nowMs.value = Date.now()
}, 1000)
await loadSettings()
await loadDiscover()
await refresh()
})
onUnmounted(() => {
if (clockTimer) window.clearInterval(clockTimer)
})
</script>
<template>
<div class="min-h-screen">
<header class="border-b border-slate-800 bg-slate-950/90">
<div class="mx-auto flex max-w-7xl items-center justify-between px-4 py-3">
<div>
<h1 class="text-lg font-semibold">PVE Cloud Backup</h1>
<p class="text-xs text-slate-400">Proxmox backups to an existing rclone remote</p>
</div>
<button @click="refresh" :disabled="loading || !settings.setup_complete">Refresh</button>
</div>
</header>
<main class="mx-auto max-w-7xl px-4 py-4">
<nav class="mb-4 flex flex-wrap gap-2">
<button v-for="p in pages" :key="p" @click="page = p; refresh()" :class="page === p ? 'bg-cyan-500' : 'bg-slate-800 hover:bg-slate-700'">{{ p }}</button>
</nav>
<div v-if="actionMessage" class="mb-4 rounded border border-cyan-800 bg-cyan-950 px-3 py-2 text-sm text-cyan-100">{{ actionMessage }}</div>
<div v-if="error" class="mb-4 rounded border border-red-800 bg-red-950 px-3 py-2 text-sm text-red-200">{{ error }}</div>
<datalist id="timezones"><option v-for="tz in timezoneOptions" :key="tz" :value="tz" /></datalist>
<section v-if="page === 'Setup wizard'" class="panel max-w-3xl">
<h2 class="mb-3 text-base font-semibold">Initial setup</h2>
<p v-if="!discover.rclone_remotes.length" class="mb-3 rounded border border-amber-700 bg-amber-950 p-3 text-sm text-amber-100">
No rclone remotes were discovered. Configure rclone separately first. This UI intentionally does not configure rclone.
</p>
<div class="grid gap-3 md:grid-cols-2">
<label><span class="label">Proxmox node</span><input v-model="settings.proxmox_node" class="w-full" /></label>
<label><span class="label">Proxmox storage</span><input v-model="settings.proxmox_storage" list="storages" class="w-full" /></label>
<datalist id="storages"><option v-for="s in discover.proxmox_storages" :key="s" :value="s" /></datalist>
<label><span class="label">Local backup directory</span><input v-model="settings.local_backup_dir" class="w-full" /></label>
<label><span class="label">rclone executable</span><input v-model="settings.rclone_path" class="w-full" /></label>
<label><span class="label">rclone remote</span><select v-model="settings.rclone_remote" class="w-full"><option value="">Select remote</option><option v-for="r in discover.rclone_remotes" :key="r" :value="r">{{ r }}</option></select></label>
<label><span class="label">Remote backup path</span><input v-model="settings.rclone_remote_path" placeholder="pve-cloud-backup" class="w-full" /></label>
<label><span class="label">Timezone</span><input v-model="settings.timezone" list="timezones" class="w-full" placeholder="Europe/Berlin" /></label>
<label><span class="label">Compression</span><select v-model="settings.default_compression" class="w-full"><option>zstd</option><option>gzip</option><option>lzo</option><option>none</option></select></label>
<label><span class="label">Backup mode</span><select v-model="settings.default_backup_mode" class="w-full"><option>snapshot</option><option>suspend</option><option>stop</option></select></label>
<label><span class="label">Retention type</span><select v-model="settings.default_retention_type" class="w-full"><option value="days">Keep days</option><option value="latest">Keep latest</option></select></label>
<label><span class="label">Retention value</span><input v-model.number="settings.default_retention_value" type="number" min="1" class="w-full" /></label>
<label><span class="label">Max concurrent backups</span><input v-model.number="settings.max_concurrent_backups" type="number" min="1" class="w-full" /></label>
<label><span class="label">Discord webhook URL</span><input v-model="settings.discord_webhook_url" class="w-full" /></label>
</div>
<label class="mt-3 block"><span class="label">Allowed CORS origins, one per line</span><textarea v-model="corsText" rows="3" class="w-full"></textarea></label>
<div class="mt-4 flex gap-2">
<button @click="saveSettings(true)" :disabled="loading || !discover.rclone_remotes.length">{{ loading ? 'Saving...' : 'Save setup' }}</button>
<button class="bg-slate-700 hover:bg-slate-600" @click="loadDiscover">Rediscover</button>
</div>
</section>
<section v-else-if="page === 'Dashboard'" class="grid gap-4 lg:grid-cols-3">
<div class="panel"><div class="label">Latest backup</div><div class="text-lg">{{ dashboard.latest_backup?.state || 'none' }}</div><div class="text-sm text-slate-400">{{ dashboard.latest_backup?.guest_name }}</div></div>
<div class="panel"><div class="label">Active tasks</div><div class="text-lg">{{ dashboard.active_tasks?.length || 0 }}</div></div>
<div class="panel"><div class="label">Failed backups</div><div class="text-lg">{{ dashboard.failed_backups || 0 }}</div></div>
<div class="panel"><div class="label">Remote backups</div><div class="text-lg">{{ dashboard.remote_backup_count || 0 }}</div></div>
<div class="panel"><div class="label">Local staging usage</div><div class="text-lg">{{ fmtBytes(dashboard.local_staging_usage?.bytes) }}</div><div class="text-xs text-slate-500">{{ dashboard.local_staging_usage?.path }}</div></div>
<div class="panel lg:col-span-3">
<h2 class="mb-2 font-semibold">Next scheduled jobs</h2>
<table class="w-full text-sm">
<tbody>
<tr v-for="j in dashboard.next_scheduled_jobs || []" :key="j.id" class="border-t border-slate-800">
<td class="py-2">{{ j.guest_name }} ({{ j.guest_vmid }})</td>
<td>{{ j.cron_schedule }}</td>
<td>
<div>{{ fmtDate(j.next_run_at) }}</div>
<div class="text-xs text-cyan-300">{{ countdownTo(j.next_run_at) }}</div>
</td>
</tr>
</tbody>
</table>
<p class="mt-2 text-xs text-slate-500">Schedule timezone: {{ settings.timezone }}</p>
</div>
</section>
<section v-else-if="page === 'Guests'" class="panel">
<h2 class="mb-2 font-semibold">Guests</h2>
<table class="w-full text-sm">
<thead class="text-left text-slate-400"><tr><th>VMID</th><th>Name</th><th>Type</th><th>Node</th><th>Status</th><th>Backup jobs</th></tr></thead>
<tbody>
<tr v-for="g in guests" :key="g.vmid" class="border-t border-slate-800">
<td class="py-2">{{ g.vmid }}</td>
<td>{{ g.name }}</td>
<td>{{ g.type }}</td>
<td>{{ g.node }}</td>
<td>{{ g.status }}</td>
<td>
<span class="rounded border border-slate-700 px-2 py-1 text-xs">
{{ jobCountsByGuest.get(g.vmid)?.total || 0 }} total · {{ jobCountsByGuest.get(g.vmid)?.enabled || 0 }} enabled
</span>
</td>
</tr>
</tbody>
</table>
</section>
<section v-else-if="page === 'Backup jobs'" class="grid gap-4 lg:grid-cols-[1fr_2fr]">
<form class="panel" @submit.prevent="createJob">
<h2 class="mb-3 font-semibold">Create job</h2>
<label class="block mb-2"><span class="label">Guest</span><select v-model="newJob.guest" class="w-full"><option value="">Select</option><option v-for="g in guests" :value="String(g.vmid)" :key="g.vmid">{{ g.name }} ({{ g.vmid }})</option></select></label>
<label class="block mb-2"><span class="label">Cron schedule</span><input v-model="newJob.cron_schedule" class="w-full" /></label>
<label class="block mb-2"><span class="label">Retention</span><div class="flex gap-2"><select v-model="newJob.retention_type"><option value="days">days</option><option value="latest">latest</option></select><input v-model.number="newJob.retention_value" type="number" min="1" class="w-24" /></div></label>
<label class="mb-3 flex items-center gap-2 text-sm"><input type="checkbox" v-model="newJob.enabled" /> Enabled</label>
<button :disabled="loading">{{ loading ? 'Creating...' : 'Create' }}</button>
</form>
<div class="panel">
<h2 class="mb-2 font-semibold">Jobs</h2>
<table class="w-full text-sm">
<thead class="text-left text-slate-400"><tr><th>Guest</th><th>Cron / settings</th><th>Retention</th><th>Next</th><th></th></tr></thead>
<tbody>
<tr v-for="j in jobs" :key="j.id" class="border-t border-slate-800 align-top">
<td class="py-2">{{ j.guest_name }} ({{ j.guest_vmid }})<div class="text-xs text-slate-500">{{ j.guest_type }} on {{ j.node }}</div></td>
<template v-if="editingJobId === j.id">
<td class="space-y-1 py-2">
<div class="rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs text-slate-300">Editing job #{{ j.id }}</div>
<input v-model="editJobForm.cron_schedule" class="w-full" />
<div class="grid gap-1 md:grid-cols-3">
<input v-model="editJobForm.proxmox_storage" placeholder="storage" />
<select v-model="editJobForm.backup_mode"><option>snapshot</option><option>suspend</option><option>stop</option></select>
<select v-model="editJobForm.compression"><option>zstd</option><option>gzip</option><option>lzo</option><option>none</option></select>
</div>
<label class="flex items-center gap-2 text-xs"><input type="checkbox" v-model="editJobForm.enabled" /> Enabled</label>
</td>
<td class="py-2">
<div class="flex gap-1">
<select v-model="editJobForm.retention_type"><option value="days">days</option><option value="latest">latest</option></select>
<input v-model.number="editJobForm.retention_value" type="number" min="1" class="w-20" />
</div>
</td>
<td class="py-2">
<div>{{ fmtDate(j.next_run_at) }}</div>
<div class="text-xs text-cyan-300">{{ countdownTo(j.next_run_at) }}</div>
</td>
<td class="flex flex-wrap gap-2 py-2">
<button @click="saveJob(j)" :disabled="loading">{{ loading ? 'Saving...' : 'Save' }}</button>
<button class="bg-slate-700" @click="cancelEditJob" :disabled="loading">Cancel</button>
</td>
</template>
<template v-else>
<td class="py-2">{{ j.cron_schedule }}<div class="text-xs text-slate-500">job #{{ j.id }} · {{ j.proxmox_storage }} · {{ j.backup_mode }} · {{ j.compression }} · {{ j.enabled ? 'enabled' : 'disabled' }}</div></td>
<td class="py-2">{{ j.retention_type }} {{ j.retention_value }}</td>
<td class="py-2">
<div>{{ fmtDate(j.next_run_at) }}</div>
<div class="text-xs text-cyan-300">{{ countdownTo(j.next_run_at) }}</div>
</td>
<td class="flex flex-wrap gap-2 py-2">
<button @click="runJob(j)" :disabled="loading">Run</button>
<button class="bg-slate-700" @click="beginEditJob(j)" :disabled="loading">Edit</button>
<button class="bg-slate-700" @click="toggleJob(j)" :disabled="loading">{{ j.enabled ? 'Disable' : 'Enable' }}</button>
<button class="bg-red-700 hover:bg-red-600" @click="deleteJob(j)" :disabled="loading">
{{ deletingJobId === j.id ? 'Deleting...' : 'Delete' }}
</button>
</td>
</template>
</tr>
</tbody>
</table>
</div>
</section>
<section v-else-if="page === 'Backup history'" class="grid gap-4 lg:grid-cols-[2fr_1fr]">
<div class="panel">
<div class="mb-3 flex flex-wrap gap-2">
<input v-model="filters.guest_vmid" placeholder="VMID filter" />
<select v-model="filters.state"><option value="">All states</option><option v-for="s in ['queued','pve_running','local_ready','uploading','remote_ready','local_deleting','completed','failed','deleting','deleted']" :key="s">{{ s }}</option></select>
<button @click="loadBackups">Filter</button>
</div>
<table class="w-full text-sm">
<thead class="text-left text-slate-400"><tr><th>Started</th><th>Guest</th><th>State</th><th>Size</th></tr></thead>
<tbody><tr v-for="b in backups" :key="b.id" @click="selectedBackup = b; page = 'Backup details'" class="cursor-pointer border-t border-slate-800 hover:bg-slate-800"><td class="py-2">{{ b.started_at }}</td><td>{{ b.guest_name }} ({{ b.guest_vmid }})</td><td>{{ b.state }}</td><td>{{ fmtBytes(b.size_bytes) }}</td></tr></tbody>
</table>
</div>
<div class="panel">
<h2 class="mb-2 font-semibold">Backup details</h2>
<div v-if="selectedBackup" class="space-y-2 text-sm">
<div class="flex items-center gap-2">
<span :class="stateClass(selectedBackup.state)" class="rounded border px-2 py-1 text-xs">{{ selectedBackup.state }}</span>
<span class="text-slate-300">{{ selectedBackup.guest_name }} ({{ selectedBackup.guest_vmid }})</span>
</div>
<div class="break-all text-xs text-slate-400">{{ selectedBackup.id }}</div>
<button class="bg-slate-700" @click="page = 'Backup details'">Open details</button>
</div>
<p v-else class="text-sm text-slate-400">Select a backup.</p>
</div>
</section>
<section v-else-if="page === 'Backup details'" class="panel">
<div class="mb-3 flex items-center justify-between gap-3">
<h2 class="font-semibold">Backup details</h2>
<button v-if="selectedBackup" class="bg-red-700 hover:bg-red-600" @click="deleteBackup(selectedBackup)" :disabled="loading">
{{ deletingBackupId === selectedBackup.id ? (selectedBackup.state === 'deleted' ? 'Nuking...' : 'Deleting...') : (selectedBackup.state === 'deleted' ? 'Nuke metadata' : 'Delete backup') }}
</button>
</div>
<p v-if="!selectedBackup" class="text-sm text-slate-400">Select a backup from Backup history.</p>
<div v-else class="space-y-4">
<div class="grid gap-3 md:grid-cols-4">
<div class="rounded border border-slate-800 bg-slate-950 p-3">
<div class="label">State</div>
<span :class="stateClass(selectedBackup.state)" class="mt-1 inline-block rounded border px-2 py-1 text-xs">{{ selectedBackup.state }}</span>
</div>
<div class="rounded border border-slate-800 bg-slate-950 p-3">
<div class="label">Guest</div>
<div>{{ selectedBackup.guest_name }} ({{ selectedBackup.guest_vmid }})</div>
<div class="text-xs text-slate-500">{{ selectedBackup.guest_type }} on {{ selectedBackup.node }}</div>
</div>
<div class="rounded border border-slate-800 bg-slate-950 p-3">
<div class="label">Size</div>
<div>{{ fmtBytes(selectedBackup.size_bytes) }}</div>
</div>
<div class="rounded border border-slate-800 bg-slate-950 p-3">
<div class="label">Job</div>
<div>#{{ valueOrDash(selectedBackup.job_id) }}</div>
</div>
</div>
<div class="grid gap-3 md:grid-cols-2">
<div class="rounded border border-slate-800 bg-slate-950 p-3">
<h3 class="mb-2 text-sm font-semibold">Timing</h3>
<dl class="grid grid-cols-[8rem_1fr] gap-2 text-sm">
<dt class="text-slate-500">Started</dt><dd>{{ fmtDate(selectedBackup.started_at) }}</dd>
<dt class="text-slate-500">PVE done</dt><dd>{{ fmtDate(selectedBackup.pve_completed_at) }}</dd>
<dt class="text-slate-500">Upload start</dt><dd>{{ fmtDate(selectedBackup.upload_started_at) }}</dd>
<dt class="text-slate-500">Completed</dt><dd>{{ fmtDate(selectedBackup.completed_at) }}</dd>
<dt class="text-slate-500">Deleted</dt><dd>{{ fmtDate(selectedBackup.deleted_at) }}</dd>
<dt class="text-slate-500">Updated</dt><dd>{{ fmtDate(selectedBackup.updated_at) }}</dd>
</dl>
</div>
<div class="rounded border border-slate-800 bg-slate-950 p-3">
<h3 class="mb-2 text-sm font-semibold">Identifiers</h3>
<dl class="grid grid-cols-[8rem_1fr] gap-2 text-sm">
<dt class="text-slate-500">Backup ID</dt><dd class="break-all font-mono text-xs">{{ selectedBackup.id }}</dd>
<dt class="text-slate-500">UPID</dt><dd class="break-all font-mono text-xs">{{ valueOrDash(selectedBackup.upid) }}</dd>
</dl>
</div>
</div>
<div class="rounded border border-slate-800 bg-slate-950 p-3">
<h3 class="mb-2 text-sm font-semibold">Storage paths</h3>
<dl class="grid gap-2 text-sm md:grid-cols-[8rem_1fr]">
<dt class="text-slate-500">Remote</dt><dd class="break-all font-mono text-xs">{{ valueOrDash(selectedBackup.remote_path) }}</dd>
<dt class="text-slate-500">Local</dt><dd class="break-all font-mono text-xs">{{ valueOrDash(selectedBackup.local_path) }}</dd>
</dl>
</div>
<div v-if="selectedBackup.error_message" class="rounded border border-red-800 bg-red-950 p-3 text-sm text-red-100">
<h3 class="mb-2 font-semibold">Error</h3>
<div class="whitespace-pre-wrap break-all">{{ selectedBackup.error_message }}</div>
</div>
</div>
</section>
<section v-else-if="page === 'Settings'" class="panel max-w-3xl">
<h2 class="mb-3 font-semibold">Settings</h2>
<div class="grid gap-3 md:grid-cols-2">
<label><span class="label">Proxmox node</span><input v-model="settings.proxmox_node" class="w-full" /></label>
<label><span class="label">Proxmox storage</span><input v-model="settings.proxmox_storage" class="w-full" /></label>
<label><span class="label">Local backup directory</span><input v-model="settings.local_backup_dir" class="w-full" /></label>
<label><span class="label">rclone executable</span><input v-model="settings.rclone_path" class="w-full" /></label>
<label><span class="label">rclone remote</span><input v-model="settings.rclone_remote" class="w-full" /></label>
<label><span class="label">Remote backup path</span><input v-model="settings.rclone_remote_path" class="w-full" /></label>
<label><span class="label">Timezone</span><input v-model="settings.timezone" list="timezones" class="w-full" placeholder="Europe/Berlin" /></label>
<label><span class="label">Discord webhook URL</span><input v-model="settings.discord_webhook_url" class="w-full" /></label>
<label><span class="label">Compression</span><input v-model="settings.default_compression" class="w-full" /></label>
<label><span class="label">Backup mode</span><input v-model="settings.default_backup_mode" class="w-full" /></label>
<label><span class="label">Retention type</span><select v-model="settings.default_retention_type" class="w-full"><option value="days">days</option><option value="latest">latest</option></select></label>
<label><span class="label">Retention value</span><input v-model.number="settings.default_retention_value" type="number" min="1" class="w-full" /></label>
<label><span class="label">Max concurrent backups</span><input v-model.number="settings.max_concurrent_backups" type="number" min="1" class="w-full" /></label>
</div>
<label class="mt-3 block"><span class="label">Allowed CORS origins</span><textarea v-model="corsText" rows="3" class="w-full"></textarea></label>
<button class="mt-4" @click="saveSettings(true)" :disabled="loading">{{ loading ? 'Saving...' : 'Save settings' }}</button>
</section>
</main>
</div>
</template>
+5
View File
@@ -0,0 +1,5 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, any>
export default component
}
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
createApp(App).mount('#app')
+21
View File
@@ -0,0 +1,21 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
input,
select,
textarea {
@apply bg-slate-900 border border-slate-700 rounded px-2 py-1 text-sm text-slate-100;
}
button {
@apply rounded px-3 py-1.5 text-sm font-medium bg-cyan-600 hover:bg-cyan-500 disabled:opacity-50 disabled:hover:bg-cyan-600;
}
.panel {
@apply rounded-lg border border-slate-800 bg-slate-900/70 p-4;
}
.label {
@apply block text-xs uppercase tracking-wide text-slate-400 mb-1;
}
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{vue,ts}'],
theme: {
extend: {},
},
plugins: [],
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"strict": true,
"jsx": "preserve"
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}
+6
View File
@@ -0,0 +1,6 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
})
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
set -euo pipefail
INSTALL_DIR="/opt/pve-cloud-backup"
BACKEND_DIR="$INSTALL_DIR/backend"
FRONTEND_DIR="$INSTALL_DIR/frontend"
STATIC_DIR="$INSTALL_DIR/static"
DATA_DIR="$INSTALL_DIR/data"
LOG_DIR="$INSTALL_DIR/logs"
if [[ "$(id -u)" -ne 0 ]]; then
echo "Run as root. The services need pvesh access and systemd installation." >&2
exit 1
fi
if [[ ! -d "$INSTALL_DIR" ]]; then
echo "$INSTALL_DIR does not exist. Copy the application files there first." >&2
exit 1
fi
mkdir -p "$BACKEND_DIR" "$FRONTEND_DIR" "$STATIC_DIR" "$DATA_DIR" "$LOG_DIR" "$INSTALL_DIR/scripts"
command -v python3 >/dev/null || { echo "python3 is required" >&2; exit 1; }
command -v npm >/dev/null || { echo "npm is required to build the Vue frontend" >&2; exit 1; }
python3 -m venv "$BACKEND_DIR/.venv"
"$BACKEND_DIR/.venv/bin/pip" install --upgrade pip
"$BACKEND_DIR/.venv/bin/pip" install -r "$BACKEND_DIR/requirements.txt"
cd "$FRONTEND_DIR"
if [[ -f package-lock.json ]]; then
npm ci
else
npm install
fi
npm run build
cd "$BACKEND_DIR"
"$BACKEND_DIR/.venv/bin/python" -c 'from app.migrations import run_migrations; run_migrations()'
install -m 0644 "$INSTALL_DIR/systemd/pve-cloud-backup-web.service" /etc/systemd/system/pve-cloud-backup-web.service
install -m 0644 "$INSTALL_DIR/systemd/pve-cloud-backup-worker.service" /etc/systemd/system/pve-cloud-backup-worker.service
systemctl daemon-reload
systemctl enable --now pve-cloud-backup-web.service
systemctl enable --now pve-cloud-backup-worker.service
echo "Installed. Open http://<server>:8080 and complete the setup wizard."
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
set -euo pipefail
API_URL="${API_URL:-http://127.0.0.1:8080}"
python3 - "$API_URL" <<'PY'
import json
import sys
import urllib.error
import urllib.request
api = sys.argv[1].rstrip("/")
def request(method, path, body=None):
data = None if body is None else json.dumps(body).encode()
req = urllib.request.Request(
api + path,
data=data,
method=method,
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=30) as res:
return json.loads(res.read().decode())
except urllib.error.HTTPError as exc:
raise SystemExit(f"{method} {path} failed: {exc.code} {exc.read().decode()}") from exc
settings = request("GET", "/api/settings")
if settings.get("proxmox_storage") != "hitachi":
raise SystemExit("Expected configured Proxmox storage to be 'hitachi'")
if settings.get("local_backup_dir") != "/mnt/pve/hitachi/dump":
raise SystemExit("Expected configured local backup directory to be '/mnt/pve/hitachi/dump'")
guests = request("GET", "/api/guests")
guest = next((g for g in guests if int(g["vmid"]) == 110 and g["name"] == "adguard"), None)
if not guest:
raise SystemExit("Expected Proxmox guest adguard with VMID 110")
job = request("POST", "/api/jobs", {
"guest_vmid": 110,
"guest_name": "adguard",
"guest_type": guest["type"],
"node": guest["node"],
"enabled": False,
"cron_schedule": "0 3 * * *",
"proxmox_storage": "hitachi",
"backup_mode": settings["default_backup_mode"],
"compression": settings["default_compression"],
"retention_type": "days",
"retention_value": 1,
})
backup = request("POST", f"/api/jobs/{job['id']}/run")
print(json.dumps({"job": job, "queued_backup": backup}, indent=2))
print("Queued integration backup for adguard VMID 110 on storage hitachi (/mnt/pve/hitachi/dump). Expected archive size is approximately 250 MB.")
PY
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
INSTALL_DIR="/opt/pve-cloud-backup"
if [[ "$(id -u)" -ne 0 ]]; then
echo "Run as root." >&2
exit 1
fi
systemctl disable --now pve-cloud-backup-worker.service 2>/dev/null || true
systemctl disable --now pve-cloud-backup-web.service 2>/dev/null || true
rm -f /etc/systemd/system/pve-cloud-backup-worker.service
rm -f /etc/systemd/system/pve-cloud-backup-web.service
systemctl daemon-reload
echo "Services removed."
echo "Application data remains at $INSTALL_DIR. Remove it manually only after confirming backups and app.db are no longer needed."
+15
View File
@@ -0,0 +1,15 @@
[Unit]
Description=PVE Cloud Backup FastAPI web application
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/opt/pve-cloud-backup/backend
ExecStart=/opt/pve-cloud-backup/backend/.venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8080
Restart=on-failure
RestartSec=5
User=root
[Install]
WantedBy=multi-user.target
+15
View File
@@ -0,0 +1,15 @@
[Unit]
Description=PVE Cloud Backup worker and scheduler
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/opt/pve-cloud-backup/backend
ExecStart=/opt/pve-cloud-backup/backend/.venv/bin/python -m app.worker
Restart=on-failure
RestartSec=5
User=root
[Install]
WantedBy=multi-user.target