commit 7a4c561f8e697daa620a125e89b2dad564e5013b Author: Codex Date: Wed Jul 15 01:00:08 2026 +0200 Initial pve cloud backup app diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bb4b7af --- /dev/null +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2a7557e --- /dev/null +++ b/AGENTS.md @@ -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`. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a5a060e --- /dev/null +++ b/README.md @@ -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://: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. diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..1bc8239 --- /dev/null +++ b/TODO.md @@ -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 diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/backup_service.py b/backend/app/backup_service.py new file mode 100644 index 0000000..2954620 --- /dev/null +++ b/backend/app/backup_service.py @@ -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) diff --git a/backend/app/commands.py b/backend/app/commands.py new file mode 100644 index 0000000..91aa44e --- /dev/null +++ b/backend/app/commands.py @@ -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) diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..7d84ec9 --- /dev/null +++ b/backend/app/config.py @@ -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) diff --git a/backend/app/db.py b/backend/app/db.py new file mode 100644 index 0000000..ffdc300 --- /dev/null +++ b/backend/app/db.py @@ -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) diff --git a/backend/app/jobs.py b/backend/app/jobs.py new file mode 100644 index 0000000..152e183 --- /dev/null +++ b/backend/app/jobs.py @@ -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"]), + ) diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..aa0c304 --- /dev/null +++ b/backend/app/main.py @@ -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, + ) diff --git a/backend/app/migrations.py b/backend/app/migrations.py new file mode 100644 index 0000000..26e815c --- /dev/null +++ b/backend/app/migrations.py @@ -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), + ) diff --git a/backend/app/notifications.py b/backend/app/notifications.py new file mode 100644 index 0000000..a47910a --- /dev/null +++ b/backend/app/notifications.py @@ -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()), + ) diff --git a/backend/app/settings_store.py b/backend/app/settings_store.py new file mode 100644 index 0000000..626fed6 --- /dev/null +++ b/backend/app/settings_store.py @@ -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 diff --git a/backend/app/worker.py b/backend/app/worker.py new file mode 100644 index 0000000..99deaf0 --- /dev/null +++ b/backend/app/worker.py @@ -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() diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..c8893a2 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..981a67f --- /dev/null +++ b/backend/tests/conftest.py @@ -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) diff --git a/backend/tests/test_backup_service.py b/backend/tests/test_backup_service.py new file mode 100644 index 0000000..6a54d77 --- /dev/null +++ b/backend/tests/test_backup_service.py @@ -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 diff --git a/backend/tests/test_commands.py b/backend/tests/test_commands.py new file mode 100644 index 0000000..59e40cf --- /dev/null +++ b/backend/tests/test_commands.py @@ -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) diff --git a/backend/tests/test_jobs.py b/backend/tests/test_jobs.py new file mode 100644 index 0000000..eec3574 --- /dev/null +++ b/backend/tests/test_jobs.py @@ -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" diff --git a/backend/tests/test_settings_api.py b/backend/tests/test_settings_api.py new file mode 100644 index 0000000..dccd92b --- /dev/null +++ b/backend/tests/test_settings_api.py @@ -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", + } + ) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..72f3f6c --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + PVE Cloud Backup + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..0cb9e0a --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2267 @@ +{ + "name": "pve-cloud-backup-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pve-cloud-backup-frontend", + "version": "0.1.0", + "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" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", + "dev": true, + "dependencies": { + "@volar/source-map": "2.4.28" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", + "dev": true + }, + "node_modules/@volar/typescript": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", + "dev": true, + "dependencies": { + "@volar/language-core": "2.4.28", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.39.tgz", + "integrity": "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.39", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.39.tgz", + "integrity": "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==", + "dependencies": { + "@vue/compiler-core": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.39.tgz", + "integrity": "sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.39", + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.15", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.39.tgz", + "integrity": "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/language-core": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.3.7.tgz", + "integrity": "sha512-LzmkKinXAMMoh8Jfi/jMUSDUjuPdv8mynH5WJGKfXyZtDw3hQ6GBaoI6Bcnl/Xqlu32q/0Z6i/trp4VXykzyLw==", + "dev": true, + "dependencies": { + "@volar/language-core": "2.4.28", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.2.1", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.4" + } + }, + "node_modules/@vue/language-core/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.39.tgz", + "integrity": "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==", + "dependencies": { + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.39.tgz", + "integrity": "sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw==", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/shared": "3.5.39" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.39.tgz", + "integrity": "sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww==", + "dependencies": { + "@vue/reactivity": "3.5.39", + "@vue/runtime-core": "3.5.39", + "@vue/shared": "3.5.39", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.39.tgz", + "integrity": "sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw==", + "dependencies": { + "@vue/compiler-ssr": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "vue": "3.5.39" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.39.tgz", + "integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==" + }, + "node_modules/alien-signals": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.2.1.tgz", + "integrity": "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==", + "dev": true + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true + }, + "node_modules/autoprefixer": { + "version": "10.5.3", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.3.tgz", + "integrity": "sha512-bJRzflk8GgE4JX+iZNEwz9f9p460NCHnU7bd+CZ9vIjIlZuTkt6F3WSl2oNO8StZBFx17nLEsiQ6H2wcZiY7nA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "browserslist": "^4.28.6", + "caniuse-lite": "^1.0.30001805", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.43", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", + "integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==", + "dev": true, + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.6", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", + "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001803", + "electron-to-chromium": "^1.5.389", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001805", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001805.tgz", + "integrity": "sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true + }, + "node_modules/electron-to-chromium": { + "version": "1.5.389", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz", + "integrity": "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==", + "dev": true + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "dev": true, + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true + }, + "node_modules/vue": { + "version": "3.5.39", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.39.tgz", + "integrity": "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==", + "dependencies": { + "@vue/compiler-dom": "3.5.39", + "@vue/compiler-sfc": "3.5.39", + "@vue/runtime-dom": "3.5.39", + "@vue/server-renderer": "3.5.39", + "@vue/shared": "3.5.39" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-tsc": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.3.7.tgz", + "integrity": "sha512-+C+rgD49wAQ5bUTl2sp5a8Bzg4YoldMNXM+g7CFe604MYcQ8PrZPMQhIjJSzKXtPBCa+C5ayMipqjbA7splekQ==", + "dev": true, + "dependencies": { + "@volar/typescript": "2.4.28", + "@vue/language-core": "3.3.7" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..79d5e06 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..3190eec --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,688 @@ + + + diff --git a/frontend/src/env.d.ts b/frontend/src/env.d.ts new file mode 100644 index 0000000..186f24d --- /dev/null +++ b/frontend/src/env.d.ts @@ -0,0 +1,5 @@ +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent + export default component +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..fe5bae3 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +import App from './App.vue' +import './style.css' + +createApp(App).mount('#app') diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..347910b --- /dev/null +++ b/frontend/src/style.css @@ -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; +} diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..bbd2c86 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,8 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./index.html', './src/**/*.{vue,ts}'], + theme: { + extend: {}, + }, + plugins: [], +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..87566dc --- /dev/null +++ b/frontend/tsconfig.json @@ -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"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..c40aa3c --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], +}) diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..a8d8b6c --- /dev/null +++ b/scripts/install.sh @@ -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://:8080 and complete the setup wizard." diff --git a/scripts/integration-test-adguard.sh b/scripts/integration-test-adguard.sh new file mode 100755 index 0000000..8151617 --- /dev/null +++ b/scripts/integration-test-adguard.sh @@ -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 diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh new file mode 100755 index 0000000..63c7650 --- /dev/null +++ b/scripts/uninstall.sh @@ -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." diff --git a/systemd/pve-cloud-backup-web.service b/systemd/pve-cloud-backup-web.service new file mode 100644 index 0000000..9d4af1d --- /dev/null +++ b/systemd/pve-cloud-backup-web.service @@ -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 diff --git a/systemd/pve-cloud-backup-worker.service b/systemd/pve-cloud-backup-worker.service new file mode 100644 index 0000000..8f80d5a --- /dev/null +++ b/systemd/pve-cloud-backup-worker.service @@ -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