diff --git a/.github/scripts/build_translation_cache.py b/.github/scripts/build_translation_cache.py new file mode 100644 index 00000000..e3603aaa --- /dev/null +++ b/.github/scripts/build_translation_cache.py @@ -0,0 +1,388 @@ +#!/usr/bin/env python3 +""" +Build the ProxMenux translation cache from translate calls in scripts/. + +The generated JSON keeps the same shape used by scripts/utils.sh: + +{ + "Original English text": { + "es": "Translated text", + "fr": "Translated text" + } +} +""" + +from __future__ import annotations + +import argparse +import ast +import json +import os +import subprocess +import re +import sys +import time +from pathlib import Path +from typing import Iterable +from urllib.parse import quote +from urllib.request import Request, urlopen + + +DEFAULT_LANGUAGES = ("es", "fr", "de", "it", "pt") +DEFAULT_CONTEXT = "Context: Technical message for Proxmox and IT. Translate:" +TRANSLATE_CALL_RE = re.compile( + r"""translate\s+(?P["'])(?P(?:\\.|(?! (?P=quote) ).)*?)(?P=quote)""", + re.VERBOSE | re.DOTALL, +) + + +def iter_script_files( + scripts_dir: Path, extra_files: Iterable[Path] = () +) -> Iterable[Path]: + # Walk the main scripts tree. + for path in sorted(scripts_dir.rglob("*")): + if not path.is_file(): + continue + if path.name == "utils.sh": + continue + if path.suffix not in {".sh", ".func"}: + continue + yield path + # Yield additional files passed explicitly (e.g. the root-level `menu` + # entry point or install_proxmenux*.sh). These live outside scripts/ + # but still contain translate "..." calls we want in the cache. + # No extension filter and no utils.sh skip — the caller decided + # they belong, we just check the file actually exists. + for extra in extra_files: + if extra.is_file(): + yield extra + + +def decode_shell_string(raw: str, quote_char: str) -> str: + if quote_char == "'": + return raw + try: + return ast.literal_eval(f'"{raw}"') + except Exception: + return raw.replace(r"\"", '"').replace(r"\\", "\\") + + +def extract_translate_texts( + scripts_dir: Path, extra_files: Iterable[Path] = () +) -> list[str]: + found: dict[str, None] = {} + for path in iter_script_files(scripts_dir, extra_files): + try: + content = path.read_text(encoding="utf-8") + except UnicodeDecodeError: + content = path.read_text(encoding="utf-8", errors="replace") + + for match in TRANSLATE_CALL_RE.finditer(content): + text = decode_shell_string(match.group("text"), match.group("quote")) + text = text.strip() + if text and "$" not in text and "`" not in text: + found.setdefault(text, None) + + return sorted(found) + + +def translate_googletrans(text: str, dest_lang: str, context: str) -> str: + try: + from googletrans import Translator # type: ignore + except Exception as exc: + raise RuntimeError( + "googletrans is not installed. Install googletrans==4.0.0-rc1 " + "or run with --provider google-web." + ) from exc + + translator = Translator() + full_text = f"{context} {text}".strip() + return translator.translate(full_text, dest=dest_lang).text + + +def translate_google_web(text: str, dest_lang: str, context: str, timeout: int) -> str: + # The public Google endpoint is not prompt-aware: if we prepend context, + # it often translates and returns that context as part of the result. + full_text = text + url = ( + "https://translate.googleapis.com/translate_a/single" + f"?client=gtx&sl=en&tl={quote(dest_lang)}&dt=t&q={quote(full_text)}" + ) + req = Request(url, headers={"User-Agent": "ProxMenux translation cache builder"}) + with urlopen(req, timeout=timeout) as response: + payload = json.loads(response.read().decode("utf-8")) + return "".join(part[0] for part in payload[0] if part and part[0]) + + +def translate_appimage( + text: str, + dest_lang: str, + context: str, + timeout: int, + appimage_path: Path, +) -> str: + if not appimage_path.exists(): + prev_path = appimage_path.with_name(appimage_path.name + ".prev") + if prev_path.exists(): + appimage_path = prev_path + else: + raise FileNotFoundError(f"AppImage not found: {appimage_path}") + + req = { + "text": text, + "dest_lang": dest_lang, + "context": context, + "cache_file": "", + } + env = os.environ.copy() + env.setdefault("APPIMAGE_EXTRACT_AND_RUN", "1") + completed = subprocess.run( + [str(appimage_path), "--translate"], + input=json.dumps(req, ensure_ascii=False), + text=True, + capture_output=True, + timeout=timeout, + check=False, + env=env, + ) + if completed.returncode != 0: + raise RuntimeError((completed.stderr or completed.stdout).strip()) + + # AppRun may print a startup line before translate_cli.py emits JSON. + for line in reversed(completed.stdout.splitlines()): + line = line.strip() + if not line.startswith("{"): + continue + payload = json.loads(line) + if payload.get("success"): + return str(payload.get("text", text)) + raise RuntimeError(str(payload.get("error", "unknown AppImage translation error"))) + + raise RuntimeError(f"AppImage did not return JSON: {completed.stdout.strip()}") + + +def clean_translation(value: str) -> str: + separator = r"[\s\u00a0]*[::]" + translate_labels = "Translate|Traducir|Traduire|Übersetzen|Tradurre|Traduci|Traduzir" + context_labels = "Context|Contexto|Contexte|Kontext|Contesto" + value = re.sub( + rf"^.*?({translate_labels}){separator}", + "", + value, + flags=re.IGNORECASE | re.DOTALL, + ) + value = re.sub( + rf"^.*?({context_labels}){separator}.*?({translate_labels}){separator}", + "", + value, + flags=re.IGNORECASE | re.DOTALL, + ) + value = re.sub( + rf"^.*?({context_labels}){separator}", + "", + value, + flags=re.IGNORECASE | re.DOTALL, + ) + return value.strip() + + +def translate_text( + text: str, + dest_lang: str, + provider: str, + context: str, + timeout: int, + appimage_path: Path, +) -> str: + if provider == "googletrans": + translated = translate_googletrans(text, dest_lang, context) + elif provider == "google-web": + translated = translate_google_web(text, dest_lang, context, timeout) + elif provider == "appimage": + translated = translate_appimage(text, dest_lang, context, timeout, appimage_path) + else: + raise ValueError(f"Unknown provider: {provider}") + return clean_translation(translated) or text + + +def load_language_cache(path: Path) -> dict[str, str]: + if not path.exists(): + return {} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except Exception: + return {} + if not isinstance(data, dict): + return {} + return {str(text): str(value) for text, value in data.items()} + + +def write_language_cache(path: Path, cache: dict[str, str]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = path.with_suffix(path.suffix + ".tmp") + tmp_path.write_text( + json.dumps(cache, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + tmp_path.replace(path) + + +def build_arg_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Extract translate calls from scripts/ and build json/cache.json." + ) + parser.add_argument("--scripts-dir", default="scripts", type=Path) + parser.add_argument( + "--extra-file", + action="append", + default=[], + type=Path, + metavar="PATH", + help=( + "Extra individual files to scan for translate calls in addition " + "to --scripts-dir. Useful for the root-level `menu` entry point " + "and install_proxmenux*.sh, which sit outside scripts/. " + "Pass multiple times to add more than one file." + ), + ) + parser.add_argument( + "--output-dir", + default=Path("lang"), + type=Path, + help="Directory where per-language JSON files are written. Default: lang", + ) + parser.add_argument( + "--output", + default=None, + type=Path, + help="Deprecated combined cache path. If used, per-language files are written next to it under its parent directory.", + ) + parser.add_argument( + "--languages", + default=",".join(DEFAULT_LANGUAGES), + help="Comma-separated destination languages. Default: es,fr,de,it,pt", + ) + parser.add_argument( + "--provider", + choices=("appimage", "googletrans", "google-web"), + default="appimage", + help="Translation provider to use. Default: appimage", + ) + parser.add_argument( + "--appimage-path", + default=Path("/usr/local/share/proxmenux/ProxMenux-Monitor.AppImage"), + type=Path, + help="Path to the ProxMenux AppImage when using --provider appimage.", + ) + parser.add_argument("--context", default=DEFAULT_CONTEXT) + parser.add_argument("--timeout", default=30, type=int) + parser.add_argument("--sleep", default=0.15, type=float) + parser.add_argument( + "--refresh", + action="store_true", + help="Translate all entries again instead of reusing existing cache values.", + ) + parser.add_argument( + "--extract-only", + action="store_true", + help="Only update the cache keys; missing translations are left empty.", + ) + parser.add_argument( + "--limit", + type=int, + default=0, + help="Only process the first N extracted strings. Useful for test runs.", + ) + parser.add_argument( + "--save-every", + type=int, + default=1, + help="Write the output JSON every N translated items. Default: 1", + ) + return parser + + +def main() -> int: + args = build_arg_parser().parse_args() + scripts_dir = args.scripts_dir.resolve() + if args.output is not None: + output_dir = args.output.resolve().parent / "lang" + else: + output_dir = args.output_dir.resolve() + languages = [lang.strip() for lang in args.languages.split(",") if lang.strip()] + + if not scripts_dir.is_dir(): + print(f"Scripts directory not found: {scripts_dir}", file=sys.stderr) + return 1 + if not languages: + print("No destination languages selected.", file=sys.stderr) + return 1 + + texts = extract_translate_texts(scripts_dir, args.extra_file) + if args.limit > 0: + texts = texts[: args.limit] + existing_by_lang = { + lang: load_language_cache(output_dir / f"{lang}.json") + for lang in languages + } + next_by_lang: dict[str, dict[str, str]] = {lang: {} for lang in languages} + print(f"Found {len(texts)} unique translate strings.", flush=True) + print(f"Output directory: {output_dir}", flush=True) + print(f"Languages: {', '.join(languages)}", flush=True) + + failures: list[tuple[str, str, str]] = [] + total = len(texts) * len(languages) + done = 0 + + for lang in languages: + existing = existing_by_lang.get(lang, {}) + print(f"Starting language: {lang}", flush=True) + + for index, text in enumerate(texts, start=1): + done += 1 + if not args.refresh and existing.get(text): + next_by_lang[lang][text] = existing[text] + continue + if args.extract_only: + next_by_lang[lang][text] = existing.get(text, "") + continue + + print(f"[{done}/{total}] {lang} ({index}/{len(texts)}): {text[:80]}", flush=True) + try: + next_by_lang[lang][text] = translate_text( + text, + lang, + args.provider, + args.context, + args.timeout, + args.appimage_path, + ) + print(f" => {next_by_lang[lang][text][:100]}", flush=True) + except Exception as exc: + next_by_lang[lang][text] = existing.get(text, text) + failures.append((text, lang, str(exc))) + print(f" failed: {exc}", file=sys.stderr, flush=True) + if args.save_every > 0 and index % args.save_every == 0: + write_language_cache(output_dir / f"{lang}.json", next_by_lang[lang]) + time.sleep(args.sleep) + + write_language_cache(output_dir / f"{lang}.json", next_by_lang[lang]) + print(f"Completed language: {lang}", flush=True) + + for lang, cache in next_by_lang.items(): + write_language_cache(output_dir / f"{lang}.json", cache) + + if failures: + print(f"Completed with {len(failures)} translation failures.", file=sys.stderr, flush=True) + for text, lang, error in failures[:20]: + print(f"- {lang}: {text[:80]} -> {error}", file=sys.stderr, flush=True) + if len(failures) > 20: + print(f"... and {len(failures) - 20} more.", file=sys.stderr, flush=True) + return 2 + + print("Translation cache generated successfully.", flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/build-translation-cache.yml b/.github/workflows/build-translation-cache.yml new file mode 100644 index 00000000..9ed029d1 --- /dev/null +++ b/.github/workflows/build-translation-cache.yml @@ -0,0 +1,99 @@ +name: Build translation cache + +# Regenerates lang/*.json whenever a bash script under scripts/ changes. +# The runtime translate() in scripts/utils.sh reads these JSON files and +# falls back to the English source on miss, so keeping them up-to-date is +# what makes ProxMenux multilingual without any runtime googletrans +# dependency on the user's host. +# +# Triggers: +# - push to develop touching scripts/**/*.sh +# - manual via workflow_dispatch (force --refresh) + +on: + push: + branches: [develop] + paths: + - 'scripts/**/*.sh' + - 'menu' + - 'install_proxmenux.sh' + - 'install_proxmenux_beta.sh' + - '.github/scripts/build_translation_cache.py' + - '.github/workflows/build-translation-cache.yml' + workflow_dispatch: + inputs: + refresh: + description: 'Re-translate every entry (ignore cached values)' + type: boolean + default: false + +# Avoid two cache rebuilds from racing each other on the same branch and +# fighting over the auto-commit. +concurrency: + group: build-translation-cache-${{ github.ref }} + cancel-in-progress: false + +jobs: + rebuild-cache: + runs-on: ubuntu-latest + permissions: + contents: write # auto-commit lang/*.json back to develop + + steps: + - name: Checkout develop + uses: actions/checkout@v4 + with: + ref: develop + # Need full history so the auto-commit doesn't fail when the + # cache job runs minutes after the trigger push (GH default + # fetch-depth=1 sometimes diverges from origin/develop after a + # quick follow-up push). + fetch-depth: 0 + # Use a PAT (or default GITHUB_TOKEN if branch protections allow + # it) so the push back to develop actually fires later steps + # (workflow runs from auto-commits) if you ever need them. + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install googletrans + run: | + python -m pip install --upgrade pip + # 4.0.0-rc1 is the same pin that build_translation_cache.py + # was written against. Bump in lockstep with the script. + pip install 'googletrans==4.0.0-rc1' 'httpx==0.13.3' 'httpcore==0.9.1' 'h11==0.9.0' + + - name: Regenerate lang/*.json + run: | + REFRESH_FLAG="" + if [[ "${{ github.event.inputs.refresh }}" == "true" ]]; then + REFRESH_FLAG="--refresh" + fi + # Extra files outside scripts/ that also contain translate "..." + # calls. Keep this list in sync with the `paths` trigger above. + python .github/scripts/build_translation_cache.py \ + --scripts-dir scripts \ + --extra-file menu \ + --extra-file install_proxmenux.sh \ + --extra-file install_proxmenux_beta.sh \ + --output-dir lang \ + --provider googletrans \ + $REFRESH_FLAG + + - name: Commit + push if changed + run: | + if git diff --quiet -- lang/; then + echo "No translation changes — skipping commit." + exit 0 + fi + git config user.name "ProxMenuxBot" + git config user.email "bot@proxmenux.local" + git add lang/ + git commit -m "chore(lang): auto-rebuild translation cache + + Source: ${GITHUB_SHA::7} + Triggered by: ${{ github.event_name }}" + git push origin develop diff --git a/AppImage/ProxMenux-1.2.2.AppImage b/AppImage/ProxMenux-1.2.2.AppImage deleted file mode 100755 index 74c81a32..00000000 Binary files a/AppImage/ProxMenux-1.2.2.AppImage and /dev/null differ diff --git a/AppImage/ProxMenux-1.2.3.AppImage b/AppImage/ProxMenux-1.2.3.AppImage new file mode 100755 index 00000000..1c7f21dd Binary files /dev/null and b/AppImage/ProxMenux-1.2.3.AppImage differ diff --git a/AppImage/ProxMenux-Monitor.AppImage.sha256 b/AppImage/ProxMenux-Monitor.AppImage.sha256 index 0a4c8f89..c3dd36ec 100644 --- a/AppImage/ProxMenux-Monitor.AppImage.sha256 +++ b/AppImage/ProxMenux-Monitor.AppImage.sha256 @@ -1 +1 @@ -e0128ac327ea74b645b37bcfab03aa744f067a355f9960a822b411c6ac75cb02 ProxMenux-1.2.2.AppImage +4a4f069f6e1dc78fc16741b810bfb38ad1c2fc08e4f06490901bfe2b42a6a560 ProxMenux-1.2.3.AppImage diff --git a/AppImage/README.md b/AppImage/README.md index 28ba074b..8c221020 100644 --- a/AppImage/README.md +++ b/AppImage/README.md @@ -27,19 +27,97 @@ A modern, responsive dashboard for monitoring Proxmox VE systems built with Next ## Overview -**ProxMenux Monitor** is a comprehensive, real-time monitoring dashboard for Proxmox VE environments. Built with modern web technologies, it provides an intuitive interface to monitor system resources, virtual machines, containers, storage, network traffic, and system logs. +**ProxMenux Monitor** is a comprehensive, real-time monitoring dashboard for Proxmox VE environments. Built with modern web technologies, it provides an intuitive interface to monitor system resources, virtual machines, containers, storage, network traffic, backups, health status and system logs — all from a single browser tab. The application runs as a standalone AppImage on your Proxmox server and serves a web interface accessible from any device on your network. +**Full documentation:** [proxmenux.com/docs/monitor](https://proxmenux.com/docs/monitor) — per-feature walkthroughs, API reference and integration guides. + ## Screenshots -Get a quick overview of ProxMenux Monitor's main features: +A tour of the Monitor's main areas. See the [public documentation](https://proxmenux.com/docs/monitor) for the full walkthrough. + +### Dashboard overview

- Overview Dashboard + Dashboard home
- System Overview - Monitor CPU, memory, temperature, and uptime in real-time + Real-time CPU, memory, temperature, storage and network activity on one glance-optimised page. +

+ +### Backup & Restore + +

+ Scheduled backup jobs +
+ Integrated host backup & restore — Local, PBS or Borg destinations; own timer or attached to a PVE vzdump job with live-inherited retention; PBS encryption with paired recovery blobs; direction-aware cross-kernel restore. +

+ +### Storage & SMART + +

+ Storage top row +
+ Per-disk cards with capacity palette shared across Storage and Backups. USB-NVMe enclosures (ASMedia / JMicron / Realtek) show the drive's real identity and temperature, not the bridge. +

+ +

+ Disk SMART modal +
+ Per-disk detail modal — SMART attributes, temperature history, observations and downloadable PDF report. +

+ +### Network — live topology + +

+ Network Flow diagram +
+ Network Flow — live view of NICs → host → bridges → guests with animated rx / tx pulses on every link. Bridges without active guests are hidden. +

+ +

+ Network latency historical +
+ Latency modal — historical view and real-time ping test against Gateway / Cloudflare / Google, with a downloadable PDF report. +

+ +### Virtual machines & LXCs + +

+ VMs & LXCs overview +
+ Inventory of running VMs and containers with resource usage and per-guest controls. +

+ +

+ VM detail modal — status +
+ Per-guest modal with status, backups, mounts and (for LXC) apt / apk / community-scripts update inventory. +

+ +### Health Monitor + +

+ Health Monitor +
+ Ten categories of proactive health checks with hysteresis, per-error dismiss (24 h / 7 d / permanent) and an Active Suppressions panel to revoke. +

+ +### System overview — top processes + +

+ Top processes +
+ Per-process CPU / memory / I/O sorted by consumer, with a click-through detail modal. +

+ +### Mobile + +

+ Mobile responsive layout +
+ Fully responsive — every panel adapts to phone and tablet layouts.

@@ -47,18 +125,21 @@ Get a quick overview of ProxMenux Monitor's main features: ## Features -- **System Overview**: Real-time monitoring of CPU, memory, temperature, and system uptime -- **Storage Management**: Visual representation of storage distribution, disk health, and SMART data -- **Network Monitoring**: Network interface statistics, real-time traffic graphs, and bandwidth usage -- **Virtual Machines & LXC**: Comprehensive view of all VMs and containers with resource usage and controls -- **Hardware Information**: Detailed hardware specifications including CPU, GPU, PCIe devices, and disks -- **System Logs**: Real-time system log monitoring with filtering and search capabilities -- **Health Monitoring**: Proactive system health checks with persistent error tracking -- **Authentication & 2FA**: Optional password protection with TOTP-based two-factor authentication -- **RESTful API**: Complete API access for integrations with Homepage, Home Assistant, and custom dashboards -- **Dark/Light Theme**: Toggle between themes with Proxmox-inspired design -- **Responsive Design**: Works seamlessly on desktop, tablet, and mobile devices -- **Release Notes**: Automatic notifications of new features and improvements +- **Host Backup & Restore** — integrated section covering Local, PBS and Borg destinations; schedule with a systemd timer or attach to an existing PVE vzdump job; PBS encryption with paired recovery blobs; direction-aware cross-kernel restore with kernel-agnostic hydration (IOMMU / VFIO / GRUB), cascade-safe package replay, and NIC auto-remap by MAC after a motherboard swap +- **System Overview** — real-time CPU / memory / temperature / uptime with a per-process drill-down (Top Processes view) and click-through detail modal +- **Storage & SMART** — per-disk cards in a responsive grid; temperature history, SMART attributes and a downloadable PDF SMART report per drive. USB-NVMe bridges (ASMedia / JMicron / Realtek) show the real drive's identity and temperature instead of the enclosure's +- **Network** — live topology (Network Flow) with animated rx / tx pulses, per-interface RRD charts, latency modal against Gateway / Cloudflare / Google with a downloadable PDF report +- **Virtual Machines & LXCs** — inventory, per-guest metrics and controls, per-LXC update inventory (apt / apk / community-scripts), backup state, mount inspection +- **Hardware** — CPU / GPU / PCIe / disks / NICs, with correct SSD vs HDD classification even behind USB-SATA bridges +- **System Logs** — journalctl with severity / since / free-text filters and a download-as-text action +- **Health Monitor** — ten categories of proactive checks with hysteresis and configurable thresholds; per-error dismiss (24 h / 7 d / permanent) and an Active Suppressions panel to revoke +- **Notifications** — five channels (Telegram, Discord, Gotify, Email, Apprise for ~80 endpoints), per-event toggles, Quiet Hours, Daily Digest, optional AI enrichment (Groq / OpenAI / Ollama / Gemini / Anthropic / OpenRouter) +- **Authentication & 2FA** — optional password protection with TOTP-based two-factor authentication and long-lived API tokens (365 days) for integrations +- **RESTful API** — complete access with JWT auth; ready-made recipes for Homepage, Home Assistant and custom dashboards; Prometheus scrape endpoint +- **Reports (PDF)** — SMART report per-disk and Network Latency report per-target, ready to send to a vendor or an ISP +- **Dark / Light Theme** — Proxmox-inspired palette with live switch +- **Responsive Design** — desktop, tablet and mobile layouts +- **Release Notes** — automatic notification of new features on each Monitor upgrade ## Technology Stack @@ -106,7 +187,7 @@ On first launch, you'll be presented with three options: 2. **Enable 2FA** - Add TOTP-based two-factor authentication for enhanced security 3. **Skip** - Continue without authentication (not recommended for production environments) -![Authentication Setup](AppImage/public/images/docs/auth-setup.png) +![Authentication Setup](https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/auth-setup.png) ### Two-Factor Authentication (2FA) @@ -118,7 +199,7 @@ After setting up your password, you can enable 2FA using any TOTP authenticator 4. Enter the 6-digit code to verify 5. Save your backup codes in a secure location -![2FA Setup](AppImage/public/images/docs/2fa-setup.png) +![2FA Setup](https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/2fa-setup.png) ### Security Best Practices for API Tokens @@ -254,7 +335,7 @@ The easiest way to generate an API token is through the ProxMenux Monitor web in 6. Click **Generate Token** 7. Copy the token immediately - it will not be shown again -![Generate API Token](AppImage/public/images/docs/generate-api-token.png) +![Generate API Token](https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/api-tokens.png) The token will be valid for **365 days** (1 year) and can be used for integrations with Homepage, Home Assistant, or any custom application. @@ -643,8 +724,6 @@ Finally, reference the secret in your `services.yaml`: format: bytes ``` -![Homepage Integration Example](AppImage/public/images/docs/homepage-integration.png) - ### Home Assistant Integration [Home Assistant](https://www.home-assistant.io/) is an open-source home automation platform. @@ -727,24 +806,13 @@ entities: icon: mdi:clock-outline ``` -![Home Assistant Integration Example](AppImage/public/images/docs/homeassistant-integration.png) - - --- ## License -This project is licensed under the **Creative Commons Attribution-NonCommercial 4.0 International License (CC BY-NC 4.0)**. +This project is licensed under the **GNU General Public License, version 3 (GPL-3.0)**. -You are free to: -- Share — copy and redistribute the material in any medium or format -- Adapt — remix, transform, and build upon the material - -Under the following terms: -- Attribution — You must give appropriate credit, provide a link to the license, and indicate if changes were made -- NonCommercial — You may not use the material for commercial purposes - -For more details, see the [full license](https://creativecommons.org/licenses/by-nc/4.0/). +You are free to use, study, share and modify the software under the terms of the licence. Any distributed derivative work must be licensed under the same terms and include the full source code — see the [full licence text](https://www.gnu.org/licenses/gpl-3.0.html) or the [`LICENSE`](https://github.com/MacRimi/ProxMenux/blob/main/LICENSE) file at the repository root. diff --git a/AppImage/app/layout.tsx b/AppImage/app/layout.tsx index 1f13d3e9..2dd4be27 100644 --- a/AppImage/app/layout.tsx +++ b/AppImage/app/layout.tsx @@ -3,6 +3,7 @@ import type { Metadata, Viewport } from "next" import { GeistSans } from "geist/font/sans" import { GeistMono } from "geist/font/mono" import { ThemeProvider } from "../components/theme-provider" +import { PwaRegister } from "../components/pwa-register" import { Suspense } from "react" import "./globals.css" @@ -46,6 +47,7 @@ export default function RootLayout({ {children} + ) diff --git a/AppImage/components/disk-temperature-card.tsx b/AppImage/components/disk-temperature-card.tsx index b892f0cb..1a9904e8 100644 --- a/AppImage/components/disk-temperature-card.tsx +++ b/AppImage/components/disk-temperature-card.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from "react" import { Thermometer } from "lucide-react" import { Badge } from "./ui/badge" -import { AreaChart, Area, ResponsiveContainer, Tooltip } from "recharts" +import { AreaChart, Area, ResponsiveContainer, Tooltip, YAxis } from "recharts" import { fetchApi } from "@/lib/api-config" import { useDiskTempThresholds } from "@/lib/health-thresholds" @@ -64,8 +64,11 @@ export function DiskTemperatureCard({ const fetchHistory = async () => { setLoading(true) try { + // 24-h timeframe gives a more useful "is this drive trending + // up over a day" view; the 1-h window was too short to spot + // anything that mattered. const result = await fetchApi<{ data: TempPoint[] }>( - `/api/disk/${encodeURIComponent(diskName)}/temperature/history?timeframe=hour`, + `/api/disk/${encodeURIComponent(diskName)}/temperature/history?timeframe=day`, ) if (cancelled.current) return setData(result?.data || []) @@ -142,6 +145,18 @@ export function DiskTemperatureCard({ + {/* Y domain is computed the same way the detail modal + does — floor(min−3) / ceil(max+3), floor at 0 — so + the line shape here matches the bigger chart instead + of recharts' auto-domain collapsing 24 → 29 °C into + a near-flat line that doesn't look like the modal. */} + Math.max(0, Math.floor(dataMin - 3)), + (dataMax: number) => Math.ceil(dataMax + 3), + ]} + /> } cursor={{ stroke: lineColor, strokeOpacity: 0.3, strokeWidth: 1 }} /> { const getDiskTypeBadge = (diskName: string, rotationRate: number | string | undefined) => { - let diskType = "HDD" - - // Check if it's NVMe - if (diskName.startsWith("nvme")) { - diskType = "NVMe" - } - // Check rotation rate for SSD vs HDD - else if (rotationRate !== undefined && rotationRate !== null) { - // Handle both number and string formats - const rateNum = typeof rotationRate === "string" ? Number.parseInt(rotationRate) : rotationRate - if (rateNum === 0 || isNaN(rateNum)) { - diskType = "SSD" - } - } - // If rotation_rate is "Solid State Device" string - else if (typeof rotationRate === "string" && rotationRate.includes("Solid State")) { - diskType = "SSD" - } - + // Classifier lives in lib/disk-type.ts — same rules + // the Storage page uses, so a drive can't show up as + // HDD here while showing as SSD there. + const diskType = getDiskType(diskName, rotationRate) const badgeStyles: Record = { NVMe: { className: "bg-purple-500/10 text-purple-500 border-purple-500/20", @@ -2600,38 +2586,22 @@ return (
Type {(() => { - const getDiskTypeBadge = (diskName: string, rotationRate: number | string | undefined) => { - let diskType = "HDD" - - if (diskName.startsWith("nvme")) { - diskType = "NVMe" - } else if (rotationRate !== undefined && rotationRate !== null) { - const rateNum = typeof rotationRate === "string" ? Number.parseInt(rotationRate) : rotationRate - if (rateNum === 0 || isNaN(rateNum)) { - diskType = "SSD" - } - } else if (typeof rotationRate === "string" && rotationRate.includes("Solid State")) { - diskType = "SSD" - } - - const badgeStyles: Record = { - NVMe: { - className: "bg-purple-500/10 text-purple-500 border-purple-500/20", - label: "NVMe SSD", - }, - SSD: { - className: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20", - label: "SSD", - }, - HDD: { - className: "bg-blue-500/10 text-blue-500 border-blue-500/20", - label: "HDD", - }, - } - return badgeStyles[diskType] + const diskType = getDiskType(selectedDisk.name, selectedDisk.rotation_rate) + const badgeStyles: Record = { + NVMe: { + className: "bg-purple-500/10 text-purple-500 border-purple-500/20", + label: "NVMe SSD", + }, + SSD: { + className: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20", + label: "SSD", + }, + HDD: { + className: "bg-blue-500/10 text-blue-500 border-blue-500/20", + label: "HDD", + }, } - - const diskBadge = getDiskTypeBadge(selectedDisk.name, selectedDisk.rotation_rate) + const diskBadge = badgeStyles[diskType] return {diskBadge.label} })()}
diff --git a/AppImage/components/health-thresholds.tsx b/AppImage/components/health-thresholds.tsx index adcba2cb..935cc73c 100644 --- a/AppImage/components/health-thresholds.tsx +++ b/AppImage/components/health-thresholds.tsx @@ -250,6 +250,35 @@ function pathKey(path: string[]): string { return path.join("/") } +// Trim the visible slider range to a window around the saved + +// recommended values so the track has usable resolution (e.g. CPU +// 60–100 instead of the backend's 0–100). Derived from stable inputs +// so the range does NOT shift under an active drag. +function computeVisualRange( + values: number[], + backendMin: number, + backendMax: number, + step: number, +): { min: number; max: number } { + const totalRange = Math.max(1, backendMax - backendMin) + // Margin ≈ 25% of total range, clamped to at least 5 steps so tiny + // step sizes (e.g. step=1 on 0–100) still get a usable window. + const rawMargin = Math.max(step * 5, Math.round(totalRange * 0.25)) + const lo = Math.min(...values) + const hi = Math.max(...values) + const snap = (n: number) => Math.round(n / step) * step + let visMin = Math.max(backendMin, snap(lo - rawMargin)) + let visMax = Math.min(backendMax, snap(hi + rawMargin)) + // Ensure the window is at least 4 steps wide so the slider has + // room to move even if all inputs collapse to one value. + if (visMax - visMin < step * 4) { + const mid = (visMax + visMin) / 2 + visMin = Math.max(backendMin, snap(mid - step * 2)) + visMax = Math.min(backendMax, snap(mid + step * 2)) + } + return { min: visMin, max: visMax } +} + // ─── Component ─────────────────────────────────────────────────────────────── export function HealthThresholds() { @@ -394,29 +423,13 @@ export function HealthThresholds() { } const renderField = (path: string[], label: string) => { + // Kept for single-value leaves that don't have a warn/crit pair + // (e.g. Memory's swap_critical). The pair-cases route to + // renderThresholdRange below. const leaf = getLeaf(tree, path) if (!leaf) return null const key = pathKey(path) const editingValue = pending[key] ?? String(leaf.value) - // Visual rules (rebuilt — the original used /40 opacity borders + - // a blue ring stacked on top of the colour border, both of which - // were nearly invisible in read-only mode and stacked weirdly when - // a value was customised): - // - // • Read-only mode (editMode=false): keep severity colour on the - // border at a higher opacity (/70 instead of /40) and on the - // background (/10) so the field is clearly readable, and - // restore foreground colour (no `opacity-70` washout). This is - // the default state the user sees most of the time — it must - // match the visual weight of the rest of the Settings page. - // • Edit mode + value matches the recommended default: severity - // border + soft severity bg, same as read-only. - // • Edit mode + value customised: ONE border in blue, replacing - // (not stacking on top of) the severity border. This is the - // single signal that "this value differs from recommended". - // - // `swap_critical` and any other `*_critical` leaf falls into the - // red bucket via the substring check. const last = path[path.length - 1] || "" const isCritical = last.toLowerCase().includes("critical") const isWarning = last.toLowerCase().includes("warning") @@ -456,6 +469,206 @@ export function HealthThresholds() { ) } + // Single-handle slider for thresholds that don't have a warn/crit + // pair (only Memory's swap_critical today). Same visual language as + // the dual-handle: red handle, value above, OK / CRIT zones below + // — so the operator doesn't read it as a different control. + const renderSingleThresholdSlider = (path: string[], severity: "warning" | "critical" = "critical") => { + const leaf = getLeaf(tree, path) + if (!leaf) return null + const key = pathKey(path) + const val = Number(pending[key] ?? leaf.value) + const step = leaf.step || 1 + const unit = leaf.unit || "" + const { min, max } = computeVisualRange( + [leaf.value, leaf.recommended], + leaf.min, + leaf.max, + step, + ) + const pct = ((Math.max(min, Math.min(max, val)) - min) / (max - min)) * 100 + const custom = leaf.customised && !(key in pending) + const color = severity === "critical" ? "red" : "amber" + const handleClass = severity === "critical" + ? "[&::-webkit-slider-thumb]:bg-red-500 [&::-moz-range-thumb]:bg-red-500" + : "[&::-webkit-slider-thumb]:bg-amber-500 [&::-moz-range-thumb]:bg-amber-500" + const numberColor = custom + ? "text-blue-400" + : severity === "critical" + ? "text-red-500" + : "text-amber-500" + const fillColor = severity === "critical" ? "bg-red-500/30" : "bg-amber-500/30" + + return ( +
+
+ + {val}{unit} + +
+
+
+
+ setPending((p) => ({ ...p, [key]: e.target.value }))} + className={`absolute inset-0 w-full appearance-none bg-transparent pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-moz-range-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-8 [&::-webkit-slider-thumb]:w-8 sm:[&::-webkit-slider-thumb]:h-4 sm:[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-background [&::-webkit-slider-thumb]:shadow [&::-moz-range-thumb]:h-8 [&::-moz-range-thumb]:w-8 sm:[&::-moz-range-thumb]:h-4 sm:[&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-background ${handleClass}`} + title={`Recommended: ${leaf.recommended}${unit}`} + /> +
+
+ OK < {val}{unit} + {severity === "critical" ? "CRIT" : "WARN"} > {val}{unit} +
+
+ ) + } + + // Dual-handle range slider replacing the two stacked number inputs + // for warn/crit pairs. Visual model: a horizontal track split into + // three zones — OK (left of warning, muted), WARN→CRIT (between + // handles, amber-to-red gradient), and OVER-CRIT (right of critical, + // dark red overlay). The handles themselves stay coloured (amber for + // warning, red for critical) so the operator reads the same severity + // mapping they're used to from the old inputs. Numbers ride above + // each handle and double as a click-to-edit affordance — clicking a + // number swaps it for a tight `` so precise + // values are still keyboard-friendly. Customised values surface as a + // small blue dot on the affected handle (same signal as the old blue + // ring, less visual weight). + const renderThresholdRange = ( + basePath: string[], + options?: { hideLabels?: boolean } + ) => { + const wPath = [...basePath, "warning"] + const cPath = [...basePath, "critical"] + const wLeaf = getLeaf(tree, wPath) + const cLeaf = getLeaf(tree, cPath) + if (!wLeaf || !cLeaf) return null + + const wKey = pathKey(wPath) + const cKey = pathKey(cPath) + const wVal = Number(pending[wKey] ?? wLeaf.value) + const cVal = Number(pending[cKey] ?? cLeaf.value) + + // Backend validates warning <= critical on save. + const step = Math.max(wLeaf.step, cLeaf.step) || 1 + const backendMin = Math.min(wLeaf.min, cLeaf.min) + const backendMax = Math.max(wLeaf.max, cLeaf.max) + const { min, max } = computeVisualRange( + [wLeaf.value, cLeaf.value, wLeaf.recommended, cLeaf.recommended], + backendMin, + backendMax, + step, + ) + const pct = (v: number) => ((Math.max(min, Math.min(max, v)) - min) / (max - min)) * 100 + const wPct = pct(wVal) + const cPct = pct(cVal) + const unit = wLeaf.unit || cLeaf.unit || "" + + const wCustom = wLeaf.customised && !(wKey in pending) + const cCustom = cLeaf.customised && !(cKey in pending) + + const setVal = (key: string, value: number, peer: number, isWarn: boolean) => { + // Clamp on the fly: warning can't cross critical and vice-versa, + // matching the backend invariant so the user can't drag into an + // invalid state. + let v = value + if (isWarn && v >= peer) v = peer - step + if (!isWarn && v <= peer) v = peer + step + setPending((p) => ({ ...p, [key]: String(v) })) + } + + return ( +
+ {/* Numeric labels above each handle, positioned absolutely so + they ride above the corresponding thumb regardless of the + slider width. Pointer events disabled so they don't steal + clicks from the underlying range inputs. */} +
+ + {wVal}{unit} + + + {cVal}{unit} + +
+ + {/* Two range inputs stacked. Mobile track box is taller so the + enlarged thumbs (h-7) have room to sit without clipping. */} +
+ {/* Background track: OK zone (muted) running the full width */} +
+ {/* Warn-to-Crit gradient between the two handles */} +
+ {/* OVER-CRIT zone (right of critical) — solid red dim */} +
+ {/* Two superposed range inputs. Pointer-events on the thumb + only, so the inert track bar above stays visible. */} + setVal(wKey, Number(e.target.value), cVal, true)} + className="absolute inset-0 w-full appearance-none bg-transparent pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-moz-range-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-8 [&::-webkit-slider-thumb]:w-8 sm:[&::-webkit-slider-thumb]:h-4 sm:[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-amber-500 [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-background [&::-webkit-slider-thumb]:shadow [&::-moz-range-thumb]:h-8 [&::-moz-range-thumb]:w-8 sm:[&::-moz-range-thumb]:h-4 sm:[&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-amber-500 [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-background" + title={`Warning (recommended: ${wLeaf.recommended}${unit})`} + /> + setVal(cKey, Number(e.target.value), wVal, false)} + className="absolute inset-0 w-full appearance-none bg-transparent pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-moz-range-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-8 [&::-webkit-slider-thumb]:w-8 sm:[&::-webkit-slider-thumb]:h-4 sm:[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-red-500 [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-background [&::-webkit-slider-thumb]:shadow [&::-moz-range-thumb]:h-8 [&::-moz-range-thumb]:w-8 sm:[&::-moz-range-thumb]:h-4 sm:[&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-red-500 [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-background" + title={`Critical (recommended: ${cLeaf.recommended}${unit})`} + /> +
+ + {/* Zone labels — explicit ranges so the operator knows where + "warn" starts and ends without having to read the handles. */} + {!options?.hideLabels && ( +
+ OK < {wVal}{unit} + WARN {wVal}–{cVal}{unit} + CRIT > {cVal}{unit} +
+ )} +
+ ) + } + return ( @@ -518,9 +731,9 @@ export function HealthThresholds() {
The Health Monitor and notifications fire when these thresholds are crossed. - Amber inputs are warning levels, red inputs are critical levels. A blue ring - marks a value you've customised away from the recommended default — hover the - field to see the recommendation, or use Reset to restore it. + Drag the amber handle to set the warning level and the red handle to set the + critical level. Values that differ from the recommended default appear in blue — + hover a handle to see the recommendation, or use Reset to restore it. @@ -556,7 +769,7 @@ export function HealthThresholds() {

{section.title}

- {!editMode && ( + {editMode && ( + + + )} + {!installed && ( + + )} +
+
+ + !v && closeUpload()}> + + + Upload PBS keyfile + + Import a keyfile you already have. It lands at /usr/local/share/proxmenux/pbs-key.conf and every subsequent encrypted backup reuses it. Recovery escrow stays off — use the setup wizard if you want to enable it. + + +
+ {pveMatch && ( +
+
+ +
+
PVE-managed keyfile detected for this PBS
+
+ Proxmox already stores an encryption key for storage {pveMatch.name} at{" "} + {pveMatch.path}. Import it in one click. +
+
+
+
+ +
+
+ )} + {pveMatch &&
— or upload your own —
} +
+ + setImportFile(e.target.files?.[0] ?? null)} + disabled={busy || !!importPath.trim()} + className="h-9 mt-1" + /> +
+
— or —
+
+ + setImportPath(e.target.value)} + disabled={busy || !!importFile} + placeholder="e.g. /etc/pve/priv/storage/.enc or /root/my-pbs-key" + className="h-9 mt-1 font-mono text-xs" + /> +
+ {err && ( +
{err}
+ )} +
+ + + + +
+
+ + !v && closeDelete()}> + + + + + Delete keyfile + + + Backups already stored on PBS were encrypted with the current keyfile. After this action: + + +
    +
  • New backups will use no encryption on this host until a new keyfile is set up.
  • +
  • Downloading pre-existing encrypted backups from this host will fail unless you kept a copy of the current key.
  • +
  • Existing recovery blobs on PBS stay intact — they still recover the old key with its original passphrase.
  • +
+ {err && ( +
{err}
+ )} + + + + +
+
+
+ ) +} + +function KeyfileActionsBar({ + mutateStatus, + escrowMode, +}: { + mutateStatus: () => Promise + escrowMode?: "none" | "local" | "full" +}) { + const [busy, setBusy] = useState(false) + const [err, setErr] = useState(null) + const [pass1, setPass1] = useState("") + const [pass2, setPass2] = useState("") + // Pending radio state — reflects the operator's intended mode after + // Apply. Seeded from the current mode so a No-op click on Apply is + // rejected. Binary UX ('none' vs 'full') matches the setup wizard. + const currentIsFull = escrowMode === "full" + const [pendingMode, setPendingMode] = useState<"none" | "full">(currentIsFull ? "full" : "none") + + const runApply = async () => { + // Yes → passphrase required + match. No → passphrase ignored. + if (pendingMode === "full") { + if (!pass1) { + setErr("Recovery passphrase is required.") + return + } + if (pass1 !== pass2) { + setErr("Passphrases do not match.") + return + } + } + setBusy(true) + setErr(null) + try { + const body: Record = { escrow_mode: pendingMode } + if (pendingMode === "full") body.escrow_passphrase = pass1 + await fetchApi("/api/host-backups/pbs-encryption/set-escrow-mode", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + await mutateStatus() + setPass1("") + setPass2("") + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)) + } finally { + setBusy(false) + } + } + + // Contextual Apply-button label. Three transitions: + // No → Yes → "Start uploading" + // Yes → No → "Stop uploading" + // Yes → Yes (new pw) → "Update passphrase" (rewraps envelope) + const applyLabel = + pendingMode === "full" && !currentIsFull + ? "Start uploading" + : pendingMode === "none" && currentIsFull + ? "Stop uploading" + : pendingMode === "full" && currentIsFull + ? "Update passphrase" + : "Apply" + + // Apply is enabled when there is a real change to commit. + const canApply = ( + (pendingMode !== (currentIsFull ? "full" : "none")) || // mode change + (pendingMode === "full" && !!pass1 && pass1 === pass2) // passphrase update + ) + + const download = () => { + const a = document.createElement("a") + a.href = "/api/host-backups/pbs-encryption/download-keyfile" + a.download = "pbs-key.conf" + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + } + + return ( +
+
Manage installed keyfile
+ + {/* Current status — icon + colour by state, no truncated fp. */} + {escrowMode !== undefined && ( +
+ Upload to PBS: + {currentIsFull ? ( + + + Yes — envelope uploaded on every backup + + ) : ( + + No — kept only on this host + + )} +
+ )} + + {/* Change mode + passphrase — one integrated form, no popover. + Radio picks the intent; the passphrase pair unfolds when the + intent is Yes (both for a first-time upload and for a + passphrase rotation while already in Yes). */} +
+
Upload key to PBS?
+
+ + +
+ {pendingMode === "full" && ( +
+
+ + setPass1(e.target.value)} + placeholder={currentIsFull ? "Type a new passphrase to rotate" : "Long random string — write it down somewhere safe"} + className="font-mono mt-1 h-8 text-xs" + /> +
+
+ + setPass2(e.target.value)} + placeholder="Type it again" + className="font-mono mt-1 h-8 text-xs" + /> + {pass1 && pass2 && pass1 !== pass2 && ( +

Passphrases don't match.

+ )} +
+
+ )} + {err && ( +
{err}
+ )} +
+ +
+
+ +
+ +
+ + {/* Deletion lives inside the PBS destination row (PbsKeyfileActions); + setup menus of Create Job / Manual Backup never surface it. */} +
+ ) +} + +export function HostBackup() { + const { data: jobsResp, error: jobsErr, mutate: mutateJobs } = useSWR<{ jobs: BackupJob[] }>( + "/api/host-backups/jobs", + fetcher, + { refreshInterval: 30000 }, + ) + const [busyJobId, setBusyJobId] = useState(null) + const [actionError, setActionError] = useState(null) + const [jobToDelete, setJobToDelete] = useState(null) + const [creatingJob, setCreatingJob] = useState(false) + const [editingJobId, setEditingJobId] = useState(null) + const [viewingJobId, setViewingJobId] = useState(null) + const [watchingManualId, setWatchingManualId] = useState(null) + const [runningManual, setRunningManual] = useState(false) + // Independent tracker for the most recent manual backup that's still + // running. Survives the operator closing the ManualJobWatchModal — + // the modal only drives auto-refresh while it's OPEN, so if the + // operator dismisses it before the job finishes, the archive lists + // would only refresh on the next SWR tick (30-60 s). Polling the + // job status here at 3 s and calling mutate once we see the run + // transition to completed closes that gap regardless of modal + // state. Set = single slot per launch; a fresh manual replaces the + // slot (the previous one falls back to the periodic SWR tick). + const [pendingManualJobId, setPendingManualJobId] = useState(null) + + async function confirmDeleteJob() { + if (!jobToDelete) return + const id = jobToDelete.id + setBusyJobId(id) + setActionError(null) + try { + await fetchApi(`/api/host-backups/jobs/${encodeURIComponent(id)}`, { method: "DELETE" }) + mutateJobs() + setJobToDelete(null) + } catch (e) { + setActionError(`Failed to delete "${id}": ${e instanceof Error ? e.message : String(e)}`) + } finally { + setBusyJobId(null) + } + } + const { data: destinationsResp, mutate: mutateDestinations } = useSWR( + "/api/host-backups/destinations", + fetcher, + { refreshInterval: 60000 }, + ) + const { data: remoteArchivesResp, error: remoteArchivesErr, mutate: mutateRemoteArchives } = useSWR( + "/api/host-backups/remote-archives", + fetcher, + { refreshInterval: 60000 }, + ) + const { data: archivesResp, error: archivesErr, mutate: mutateArchives } = useSWR<{ archives: BackupArchive[] }>( + "/api/host-backups/archives", + fetcher, + { refreshInterval: 30000 }, + ) + + // Background poll for the pending manual backup, independent of the + // watch modal being open. Fires mutateArchives/mutateRemoteArchives + // as soon as the run flips to done so the operator sees the new + // entry in Available Archives without a manual refresh. + const { data: pendingManualPoll } = useSWR( + pendingManualJobId ? `/api/host-backups/jobs/${encodeURIComponent(pendingManualJobId)}` : null, + fetcher, + { refreshInterval: 3000 }, + ) + useEffect(() => { + const d = pendingManualPoll as JobDetail | undefined + if (pendingManualJobId && d?.last_run_at && d?.last_result) { + mutateJobs() + mutateArchives() + mutateRemoteArchives() + setPendingManualJobId(null) + } + }, [pendingManualPoll, pendingManualJobId, mutateJobs, mutateArchives, mutateRemoteArchives]) + + const [inspectingArchive, setInspectingArchive] = useState(null) + + // Merge local archives + remote snapshots (PBS + Borg) into a single + // sorted list. Newest first, regardless of source — operators care + // about "the most recent backup", not "the most recent local backup". + const unifiedArchives: UnifiedArchive[] = (() => { + const out: UnifiedArchive[] = [] + if (archivesResp?.archives) { + for (const a of archivesResp.archives) { + out.push({ + source: "local", + display_id: a.id, + size_bytes: a.size_bytes, + created_at: a.mtime, + source_label: a.path.replace(/\/[^/]+$/, "") || "/", + local: a, + }) + } + } + if (remoteArchivesResp?.snapshots) { + for (const s of remoteArchivesResp.snapshots) { + out.push({ + source: s.backend, + display_id: s.snapshot, + size_bytes: s.size_bytes, + created_at: s.backup_time, + source_label: `${s.backend.toUpperCase()} ${s.repo_name}`, + remote: s, + }) + } + } + out.sort((a, b) => b.created_at - a.created_at) + return out + })() + + return ( +
+ {/* ── Post-restore progress card ──────────────────── + Renders only while a restore is running or its + summary hasn't been acknowledged. Once dismissed, + it collapses to a "Past restores" ghost button. */} + + + {/* ── Scheduled jobs ───────────────────────────────── */} + + +
+ + Scheduled Backup Jobs + + {jobsResp?.jobs?.filter((j) => !j.manual).length ?? 0} + +
+ +
+ + {jobsErr ? ( +
Failed to load jobs
+ ) : !jobsResp ? ( +
+ + Loading... +
+ ) : jobsResp.jobs.filter((j) => !j.manual).length === 0 ? null : ( +
+ {actionError && ( +
+ {actionError} +
+ )} + {jobsResp.jobs.filter((j) => !j.manual).map((j) => { + const status = parseJobStatus(j.last_status) + const running = isJobRunning(j) + // While running we override the ok/failed pill with a + // running spinner — same way the modal does. Clicking + // the row re-opens JobDetailModal which auto-detects + // the in-progress state and resumes streaming. + const statusBadge = running + ? { label: "running", cls: "bg-blue-500/10 border-blue-500/40 text-blue-300" } + : status?.result === "ok" + ? { label: "ok", cls: "bg-emerald-500/10 border-emerald-500/40 text-emerald-400" } + : status?.result + ? { label: status.result, cls: "bg-red-500/10 border-red-500/40 text-red-400" } + : null + const lastRunWhen = formatRunAt(status?.runAt ?? null) + return ( + + ) + })} +
+ )} +
+
+ + {/* ── Backup configuration (destinations + custom paths) ── */} + mutateDestinations()} + /> + + {/* ── Manual backups (entry point) ──────────────────── */} + + +
+ + Manual backups +
+
+ +

+ Manual backups run once and stop — no schedule. +

+ + {/* In-progress manual jobs. If the operator closed the + ManualBackupDialog before the runner finished, this + banner is the way back into the live log — clicking + opens the JobDetailModal which automatically resumes + the streaming view for any in-progress job. */} + {jobsResp?.jobs + ?.filter((j) => j.manual && isJobRunning(j)) + .map((j) => ( + + ))} +
+
+ + {/* ── Available archives ─────────────────────────────── */} + + +
+ + Available Archives +
+ {unifiedArchives.length} +
+ +

+ All backups visible from this host — local .tar.zst files (PVE default dump dir, configured local target, USB mountpoints, scheduled jobs' destinations) and PBS backups from every configured datastore. Click an entry to inspect, restore or download it — downloads of PBS backups are extracted on-demand only when you request them. +

+ {remoteArchivesResp?.errors && remoteArchivesResp.errors.length > 0 && ( +
+
Some remote backends couldn't be queried:
+ {remoteArchivesResp.errors.map((e, i) => ( +
+ {e.backend}/{e.repo_name}: {e.error} +
+ ))} +
+ )} + {archivesErr && remoteArchivesErr ? ( +
Failed to load archives
+ ) : !archivesResp && !remoteArchivesResp ? ( +
+ + Loading... +
+ ) : unifiedArchives.length === 0 ? ( +
+ No backups found yet. Use Run manual backup above, configure a scheduled job, or check that the configured PBS / Borg destinations have backups. +
+ ) : ( +
+ {unifiedArchives.map((u) => { + const localKind = u.local?.kind + const localJobId = u.local?.job_id + const localHost = u.local?.source_hostname + const localPath = u.local?.path + // Same palette as the DestinationRow accents so each + // backend reads identically across the two cards. + const sourceBadgeCls = + u.source === "pbs" + ? "text-purple-400 border-purple-500/20 bg-purple-500/10" + : u.source === "borg" + ? "text-fuchsia-400 border-fuchsia-500/20 bg-fuchsia-500/10" + : "text-blue-400 border-blue-500/20 bg-blue-500/10" + return ( + + ) + })} +
+ )} +
+
+ + {/* ── Inspect / preflight modal ──────────────────────── */} + setInspectingArchive(null)} + onDeleted={() => { + setInspectingArchive(null) + // Refresh both lists — a deleted item might have been local + // or remote (PBS/Borg). Cheap enough to revalidate both. + mutateArchives() + mutateRemoteArchives() + }} + /> + + {/* ── Create / Edit job wizard ─────────────────────── */} + setCreatingJob(false)} + onCreated={() => { + setCreatingJob(false) + mutateJobs() + }} + /> + setEditingJobId(null)} + onCreated={() => { + setEditingJobId(null) + mutateJobs() + }} + /> + + {/* ── One-shot manual backup ─────────────────────── */} + setRunningManual(false)} + onLaunched={(jobId) => { + // Hand off straight to the live-progress modal so the + // operator never has to chase the "View progress" banner + // after pressing Run. Mutate jobs first so the new manual + // entry is in cache when the watch modal opens. + setRunningManual(false) + mutateJobs() + setWatchingManualId(jobId) + // Register the job with the modal-independent background + // poller so the Available Archives list refreshes the + // instant the run finishes, even if the operator closes + // the watch modal before then. Replaces the old fire-and- + // forget 5 s mutate that used to miss slow backups. + setPendingManualJobId(jobId) + }} + /> + + {/* ── Job detail / actions modal ─────────────────────────── */} + setViewingJobId(null)} + onEdit={(id) => { + setViewingJobId(null) + setEditingJobId(id) + }} + onRequestDelete={(id) => { + const job = jobsResp?.jobs.find((j) => j.id === id) + if (job) { + setViewingJobId(null) + setJobToDelete(job) + } + }} + onChanged={() => { + mutateJobs() + mutateArchives() + mutateRemoteArchives() + }} + /> + + {/* Manual jobs use their own modal — no Schedule/Retention/ + Profile, no Edit/Run/Disable. Only the live log; the modal + is closed with the dialog's own X (no extra footer). */} + setWatchingManualId(null)} + onChanged={() => { + // The watch modal fires onChanged when a tracked run + // finishes (last_result transitions from null → ok/failed). + // Refresh jobs (status badge), local archives (new tar.zst) + // and remote archives (Borg/PBS snapshots) so the operator + // sees the new backup without waiting for the next tick. + mutateJobs() + mutateArchives() + mutateRemoteArchives() + }} + /> + + {/* ── Delete confirmation ────────────────────────────── */} + { if (!v) setJobToDelete(null) }}> + + + + + Delete backup job? + + + This action cannot be undone. + + + {jobToDelete && ( +
+
+
Job ID
+
{jobToDelete.id}
+ {jobToDelete.attached && jobToDelete.pve_storage && ( + <> +
Type
+
attached to PVE storage {jobToDelete.pve_storage}
+ + )} +
+ {jobToDelete.attached ? ( +

+ Only the ProxMenux host backup hook is removed. + PVE vzdump jobs targeting this storage stay intact and keep running. +

+ ) : ( +

+ The systemd timer and service for this job will be stopped, disabled and removed. + Existing backup archives on disk are NOT deleted. +

+ )} +
+ )} +
+ + +
+
+
+
+ ) +} + +// ────────────────────────────────────────────────────────────── +// Inspect modal — shows manifest summary + lets the operator pick +// a restore mode and run the dry-run preflight + plan against this +// host. No mutating actions; --apply stays on the CLI for 1.3.0. +// ────────────────────────────────────────────────────────────── +function InspectModal({ + archive, + onClose, + onDeleted, +}: { + archive: UnifiedArchive | null + onClose: () => void + onDeleted?: () => void +}) { + const open = archive !== null + // Aliases to the source-specific payloads — saves on `.local!` / + // `.remote!` repetition later. PBS and Borg share the same shape. + const localArc = archive?.source === "local" ? archive.local : undefined + const remoteArc = archive && archive.source !== "local" ? archive.remote : undefined + const isRemote = archive?.source === "pbs" || archive?.source === "borg" + const backendLabel = archive?.source === "pbs" ? "PBS" : archive?.source === "borg" ? "Borg" : "Local" + const [mode, setMode] = useState("full") + const [report, setReport] = useState(null) + const [running, setRunning] = useState(false) + const [error, setError] = useState(null) + const [instructions, setInstructions] = useState<{ + archive_basename: string + mode_label: string + shell_command: string + menu_path: string[] + reboot_required: boolean + notes: string[] + } | null>(null) + const [fetchingInstructions, setFetchingInstructions] = useState(false) + const [downloading, setDownloading] = useState(false) + // Per-archive runner log — only available for local archives, where + // the runner writes `.log` next to the `.tar.zst` in + // /var/log/proxmenux/backup-jobs/. PBS / Borg snapshots don't have + // a co-located run log on this host. + const { data: archiveLog } = useSWR<{ + log_path: string | null + size: number + content: string + tail: string[] + }>( + open && archive?.source === "local" && localArc?.id + ? `/api/host-backups/archives/${encodeURIComponent(localArc.id)}/log` + : null, + fetcher, + ) + const [showArchiveFullLog, setShowArchiveFullLog] = useState(false) + const [showDeleteArchiveConfirm, setShowDeleteArchiveConfirm] = useState(false) + const [viewingContents, setViewingContents] = useState(false) + // Restore flow state: + // restorePreparing — extract running (blocking spinner overlay) + // restoreOptions — modal open after prepare succeeded + // restoreTerminal — ScriptTerminalModal open with monitor_apply.sh + // The extract happens BEFORE the options modal so the custom + // checklist can be filled from `components_available` (what's + // actually inside this backup) instead of a hardcoded list. + const [restorePreparing, setRestorePreparing] = useState(false) + const [restoreError, setRestoreError] = useState(null) + const [restoreOptions, setRestoreOptions] = useState<{ + stagingPath: string + pathsAvailable: string[] + rollbackPlan?: RollbackPlan + // Cross-kernel awareness passed through to RestoreOptionsModal: + // when direction === "bk_older" the modal shows the safe-subset + // banner and grays out any path prefixed by anything in + // blockedPaths. "bk_newer" and "same" pass through as no-ops. + crossKernel?: { + direction: "same" | "bk_older" | "bk_newer" + backupKernel: string + targetKernel: string + blockedPaths: string[] + } + // Kernel-agnostic hydration preview (bk_older only): what the + // restore will re-apply to the target via merge instead of + // whole-file copy — IOMMU cmdline tokens, VFIO modules, + // operator's GRUB keys, whitelisted vfio/nvidia files. + hydration?: { + applies: boolean + actions: string[] + } + } | null>(null) + const [restoreTerminal, setRestoreTerminal] = useState<{ + stagingPath: string + mode: "full" | "custom" + paths: string[] + rollbackExecute?: boolean + } | null>(null) + + // ── Keyfile-required inline import ──────────────────────────── + // When this modal opens on an encrypted PBS backup and the local + // keyfile is missing at /usr/local/share/proxmenux/pbs-key.conf, + // Restore / Download / View contents would all fail with + // "missing key". We block the buttons and offer an inline Import + // panel that reuses /api/host-backups/pbs-encryption/import with + // source=content + escrow_mode=none — same endpoint as the setup + // flow, so the imported keyfile lands at the canonical path and is + // reused by every subsequent backup + restore. + const { data: keyfileInfo, mutate: mutateKeyfileInfo } = useSWR<{ + installed: boolean + fingerprint?: string + path: string + }>( + open && remoteArc?.encrypted ? "/api/host-backups/pbs-encryption/keyfile-info" : null, + fetcher, + ) + const needsKeyfile = !!(remoteArc?.encrypted && keyfileInfo && !keyfileInfo.installed) + // Same PVE-managed keyfile auto-discovery the setup wizards use: when + // the encrypted snapshot lives on a PBS whose PVE storage.cfg entry + // has an `encryption-key` file at /etc/pve/priv/storage/.enc, + // surface a one-click "Use this key" shortcut in the amber panel so + // the operator doesn't have to type the full path. Only fetched when + // the amber gate is actually rendered (encrypted + no keyfile). + const { data: pveDiscoverInspect } = useSWR<{ + entries: Array<{ name: string; server: string; datastore: string; path: string; matches_repository: boolean }> + }>( + needsKeyfile && remoteArc?.repo_repository + ? `/api/host-backups/pbs-encryption/discover-pve-keyfiles?repository=${encodeURIComponent(remoteArc.repo_repository)}` + : null, + fetcher, + ) + const pveMatchInspect = pveDiscoverInspect?.entries?.find((e) => e.matches_repository) || null + const [importFile, setImportFile] = useState(null) + const [importPath, setImportPath] = useState("") + const [importing, setImporting] = useState(false) + const [importError, setImportError] = useState(null) + + const runImportKeyfile = async () => { + // Either a file upload OR a typed absolute path on this host — + // both land at /usr/local/share/proxmenux/pbs-key.conf with + // escrow_mode='none'. When both are provided the file wins. + if (!importFile && !importPath.trim()) { + setImportError("Pick a keyfile file or enter an absolute path on this host.") + return + } + setImporting(true) + setImportError(null) + try { + const body: Record = { escrow_mode: "none" } + if (importFile) { + const buf = new Uint8Array(await importFile.arrayBuffer()) + let bin = "" + for (let i = 0; i < buf.length; i++) bin += String.fromCharCode(buf[i]) + body.source = "content" + body.content_b64 = btoa(bin) + } else { + body.source = "path" + body.keyfile_path = importPath.trim() + } + await fetchApi("/api/host-backups/pbs-encryption/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + await mutateKeyfileInfo() + setImportFile(null) + setImportPath("") + } catch (e) { + setImportError(e instanceof Error ? e.message : String(e)) + } finally { + setImporting(false) + } + } + + const beginRestore = async () => { + if (!archive) return + setRestoreError(null) + setRestorePreparing(true) + const body: Record = { source: archive.source } + if (archive.source === "local") { + if (!localArc?.path) { setRestoreError("Local archive path missing"); setRestorePreparing(false); return } + body.path = localArc.path + } else if (remoteArc) { + body.repo_name = remoteArc.repo_name + body.snapshot = remoteArc.snapshot + } else { + setRestoreError("Snapshot info missing") + setRestorePreparing(false) + return + } + try { + const r: any = await fetchApi("/api/host-backups/restore/prepare", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + if (!r?.staging_path) throw new Error("backend did not return a staging path") + const ck = r.cross_kernel || {} + const hyd = r.hydration || {} + setRestoreOptions({ + stagingPath: r.staging_path, + pathsAvailable: Array.isArray(r.paths_available) ? r.paths_available : [], + rollbackPlan: r.rollback_plan, + crossKernel: ck.direction + ? { + direction: ck.direction, + backupKernel: ck.backup_kernel || "", + targetKernel: ck.target_kernel || "", + blockedPaths: Array.isArray(ck.blocked_paths) ? ck.blocked_paths : [], + } + : undefined, + hydration: hyd.applies + ? { + applies: true, + actions: Array.isArray(hyd.actions) ? hyd.actions : [], + } + : undefined, + }) + } catch (e) { + setRestoreError(e instanceof Error ? e.message : String(e)) + } finally { + setRestorePreparing(false) + } + } + const [deletingArchive, setDeletingArchive] = useState(false) + const [archiveDeleteResult, setArchiveDeleteResult] = useState(null) + + // Local download. Uses a single-use ticket appended to the URL + + // `
` instead of fetch+blob — the previous fetch+blob + // approach loaded the whole archive into the browser's RAM via + // URL.createObjectURL(), which broke around 2 GB on most browsers. + // With the ticket-on-URL approach the browser streams the response + // straight to disk, so archive size is bounded only by free space. + const downloadLocalArchive = async () => { + if (!localArc) return + setDownloading(true) + setError(null) + try { + const ticket = await fetchTerminalTicket() + let url = getApiUrl(`/api/host-backups/archives/${encodeURIComponent(localArc.id)}/download`) + if (ticket) url += `?ticket=${encodeURIComponent(ticket)}` + const a = document.createElement("a") + a.href = url + a.download = localArc.id + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + } catch (e) { + setError(`Download failed: ${e instanceof Error ? e.message : String(e)}`) + } finally { + // The download itself runs in the browser's network stack; + // we just initiated it. Clear the spinner immediately. + setDownloading(false) + } + } + + // Remote export-then-download (PBS or Borg) — kicks off the + // server-side extract + pack, polls until the .tar.zst is ready, + // then streams it like the local case. Stage feedback (queued / + // restoring / packing) goes into `exportTask.state` so the modal + // can show what's happening live. + const [exportTask, setExportTask] = useState(null) + const downloadRemoteArchive = async () => { + if (!remoteArc) return + setDownloading(true) + setError(null) + setExportTask({ + task_id: "", + backend: remoteArc.backend, + repo_name: remoteArc.repo_name, + snapshot: remoteArc.snapshot, + state: "queued", + message: "Starting export…", + size_bytes: 0, + output_path: null, + error: null, + }) + try { + const started = await fetchApi<{ task_id: string; state: string }>( + "/api/host-backups/remote-archives/export", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + backend: remoteArc.backend, + repo_name: remoteArc.repo_name, + snapshot: remoteArc.snapshot, + }), + }, + ) + // Poll until done. The export can take from seconds (small host + // config) to minutes (multi-GB pxar) — poll every 1.5s so the + // UI feels responsive without DoS-ing Flask. + let task: ExportTask | null = null + while (true) { + await new Promise((res) => setTimeout(res, 1500)) + task = await fetchApi( + `/api/host-backups/remote-archives/export/${encodeURIComponent(started.task_id)}`, + ) + setExportTask(task) + if (task.state === "completed" || task.state === "failed") break + } + if (!task || task.state !== "completed") { + throw new Error(task?.error || "export did not complete") + } + // Stream the resulting .tar.zst with a ticketed URL + . + // Same rationale as downloadLocalArchive: bypass fetch+blob to + // avoid the ~2 GB browser-RAM limit; the browser writes + // straight to disk. + const ticket = await fetchTerminalTicket() + let url = getApiUrl(`/api/host-backups/remote-archives/export/${encodeURIComponent(started.task_id)}/download`) + if (ticket) url += `?ticket=${encodeURIComponent(ticket)}` + const safeSnap = remoteArc.snapshot.replace(/\//g, "_") + const a = document.createElement("a") + a.href = url + a.download = `${remoteArc.backend}-${remoteArc.repo_name}-${safeSnap}.tar.zst` + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + setExportTask(null) + } catch (e) { + setError(`Download failed: ${e instanceof Error ? e.message : String(e)}`) + } finally { + setDownloading(false) + } + } + + const downloadArchive = () => { + if (isRemote) return downloadRemoteArchive() + return downloadLocalArchive() + } + + // Archive deletion — works for local, PBS and Borg backends. + // • local: DELETE /archives/ → removes .tar.zst + sidecar + run log. + // • pbs: DELETE /remote-archives → `proxmox-backup-client snapshot forget`. + // • borg: DELETE /remote-archives → `borg delete ::`. + // PBS-protected snapshots come back as 409 with the original message so + // the operator can decide whether to lift the protection first. + const deleteArchive = async () => { + if (!archive) return + setDeletingArchive(true) + setError(null) + try { + let removed: string[] = [] + if (archive.source === "local" && localArc) { + const resp = await fetchApi<{ status: string; removed: string[] }>( + `/api/host-backups/archives/${encodeURIComponent(localArc.id)}`, + { method: "DELETE" }, + ) + removed = resp.removed || [] + } else if (remoteArc) { + const resp = await fetchApi<{ status: string; removed: string }>( + `/api/host-backups/remote-archives`, + { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + backend: remoteArc.backend, + repo_name: remoteArc.repo_name, + snapshot: remoteArc.snapshot, + }), + }, + ) + removed = resp.removed ? [resp.removed] : [] + } + setArchiveDeleteResult(removed) + setShowDeleteArchiveConfirm(false) + if (onDeleted) onDeleted(); else onClose() + } catch (e) { + setError(`Failed to delete: ${e instanceof Error ? e.message : String(e)}`) + } finally { + setDeletingArchive(false) + } + } + + // Reset instructions when archive or mode changes — the previous + // command is no longer relevant. + useEffect(() => { + setInstructions(null) + }, [archive, mode]) + + const fetchInstructions = async () => { + if (!localArc) return + setFetchingInstructions(true) + setError(null) + try { + const res = await fetchApi( + `/api/host-backups/archives/${encodeURIComponent(localArc.id)}/prepare-restore`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mode }), + } + ) as NonNullable + setInstructions(res) + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setFetchingInstructions(false) + } + } + + const { data: manifest, error: manifestErr } = useSWR<{ + source_host: ManifestSourceHost + proxmenux_installed_components: Array<{ id: string; version_at_backup: string | null }> + vms_lxcs_at_backup: { vms: unknown[]; lxcs: unknown[] } + storage_inventory?: { zfs_pools?: unknown[]; lvm?: { vgs?: unknown[] } } + }>( + localArc ? `/api/host-backups/archives/${encodeURIComponent(localArc.id)}/manifest` : null, + fetcher, + ) + + const runPreflight = async () => { + if (!localArc) return + setRunning(true) + setError(null) + setReport(null) + try { + const res = await fetchApi( + `/api/host-backups/archives/${encodeURIComponent(localArc.id)}/preflight`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mode }), + }, + ) + setReport(res) + } catch (e: any) { + setError(e?.message || "Preflight failed") + } finally { + setRunning(false) + } + } + + // Reset state when archive changes — the key= prop on DialogContent + // forces React to unmount + remount so all useState() values are + // discarded automatically. Cleaner than manually tracking each. + const archiveKey = archive ? `${archive.source}:${archive.display_id}` : "" + + return ( + <> + { if (!v) onClose() }}> + + + + + {archive?.display_id} + + + + {/* ── Body: same shape for the 3 backends ─────────────── */} + +
+ {/* Backup info — uniform grid, with backend-specific + rows mixed in only when they carry data. Backend + + encryption badges live here (instead of the header, + where they used to overlap the close button). */} +
+
+
Backup
+
+ {archive && ( + + {archive.source} + + )} + {remoteArc?.encrypted && ( + + + + )} +
+
+
+ {/* Time + size — present for every backend. */} + {archive && (archive.source === "pbs" || archive.source === "borg") && remoteArc ? ( + <> +
Backup time: {formatMtime(remoteArc.backup_time)}
+ {remoteArc.size_bytes > 0 &&
Size: {formatBytes(remoteArc.size_bytes)}
} +
Repository: {remoteArc.repo_repository}
+
Repo name: {remoteArc.repo_name}
+
+ {remoteArc.backend === "pbs" ? "Backup group:" : "Archive name:"}{" "} + {remoteArc.backend === "pbs" ? `${remoteArc.backup_type}/${remoteArc.backup_id}` : remoteArc.backup_id} +
+ {remoteArc.owner &&
Owner: {remoteArc.owner}
} + {remoteArc.borg_id &&
Borg id: {remoteArc.borg_id}
} + + ) : localArc ? ( + <> +
Created: {formatMtime(localArc.mtime)}
+
Size: {formatBytes(localArc.size_bytes)}
+
Path: {localArc.path}
+ {localArc.job_id &&
Job id: {localArc.job_id}
} + {localArc.profile &&
Profile: {localArc.profile}
} + {localArc.source_hostname &&
Source host: {localArc.source_hostname}
} +
Detected via: {localArc.detected_via}
+ + ) : null} +
+ {/* PBS pxar files list — only PBS exposes this. */} + {remoteArc?.files && remoteArc.files.length > 0 && ( +
+
Files in this backup
+
    + {remoteArc.files.map((f) => ( +
  • + {f.filename} + {formatBytes(f.size)} +
  • + ))} +
+
+ )} +
+ + {/* Run log — only Local has a co-located .log. */} + {archive?.source === "local" && archiveLog && archiveLog.log_path && archiveLog.tail.length > 0 && ( +
+

+ Run log +

+
+
+{archiveLog.tail.join("\n")}
+                  
+
+ + tail · {formatBytes(archiveLog.size)} + {archiveLog.log_path} + + +
+
+
+ )} + + {/* In-flight export task feedback (Download for PBS/Borg). */} + {exportTask && ( +
+
+ + {exportTask.state} + — {exportTask.message} +
+ {exportTask.state === "failed" && exportTask.error && ( +
{exportTask.error}
+ )} + {exportTask.state === "completed" && exportTask.size_bytes > 0 && ( +
Packed size: {formatBytes(exportTask.size_bytes)}
+ )} +
+ )} + + {error && (() => { + const kf = parseKeyfileError(error) + return kf ? ( + + ) : ( +
+ {error} +
+ ) + })()} + + {/* Encrypted-backup gate: no local keyfile → block the + three actions until the operator imports the key. The + import lands at the canonical path with escrow_mode= + 'none', so subsequent runs reuse it silently. */} + {needsKeyfile && ( +
+
+ +
+
Encrypted backup — keyfile required
+
+ This snapshot is encrypted but no local keyfile is installed at + {" "}/usr/local/share/proxmenux/pbs-key.conf. + Import the keyfile that was used at backup time to continue. +
+
+
+ {pveMatchInspect && ( +
+
+ +
+
PVE-managed keyfile detected for this PBS
+
+ Proxmox stores an encryption key for storage {pveMatchInspect.name} at{" "} + {pveMatchInspect.path}. Import it in one click. +
+
+
+
+ +
+
+ )} +
+ + setImportFile(e.target.files?.[0] ?? null)} + disabled={importing || !!importPath.trim()} + className="h-8 text-[11px]" + /> +
+
— or —
+
+ + setImportPath(e.target.value)} + disabled={importing || !!importFile} + placeholder="e.g. /etc/pve/priv/storage/.enc or /root/my-pbs-key" + className="h-8 text-[11px] font-mono" + /> +
+ {importError && ( +
+ {importError} +
+ )} +
+ +
+
+ )} +
+
+ + {/* ── Footer: same 4 buttons for the 3 backends ───────── + Restore (green) · Download (blue) · View contents (blue) + on the left · Delete (red) on the right. On mobile only + Restore keeps its label — the others are icon-only to fit + the narrower viewport without wrapping. */} +
+
+ + + +
+ +
+
+
+ + {/* ── View contents — extract + parse the snapshot, show HTML ──── */} + setViewingContents(false)} + source={(archive?.source as "pbs" | "borg" | "local" | null) ?? null} + repo_name={remoteArc?.repo_name} + snapshot={remoteArc?.snapshot} + path={localArc?.path} + display_id={archive?.display_id} + /> + + {/* ── Restore error toast (prepare failed) ─────────────────── */} + {restoreError && (() => { + const kf = parseKeyfileError(restoreError) + return ( + setRestoreError(null)}> + + + + + {kf ? "Restore blocked — encrypted backup" : "Restore preparation failed"} + + {!kf && ( + + {restoreError} + + )} + + {kf && } +
+ +
+
+
+ ) + })()} + + {/* ── Restore options modal: opens only AFTER the prepare step, + so the Custom checklist already knows what's inside. ──── */} + { + const sp = restoreOptions?.stagingPath + setRestoreOptions(null) + if (sp) { + fetchApi("/api/host-backups/restore/cleanup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ staging_path: sp }), + }).catch(() => undefined) + } + }} + onLaunch={(mode, paths, rollbackExecute) => { + if (!restoreOptions) return + const sp = restoreOptions.stagingPath + setRestoreOptions(null) + setRestoreTerminal({ stagingPath: sp, mode, paths, rollbackExecute }) + }} + /> + + {/* ── Restore terminal — uses the canonical ScriptTerminalModal + (the same component Hardware/Security/Settings use to run + their scripts). Stable `key` per stagingPath so each restore + session is a fresh terminal. */} + {restoreTerminal && ( + { + const sp = restoreTerminal.stagingPath + fetchApi("/api/host-backups/restore/cleanup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ staging_path: sp }), + }).catch(() => undefined) + setRestoreTerminal(null) + }} + // Auto-dismiss when the script's PTY closes (i.e. the bash + // wrapper exited cleanly — the operator pressed Enter at the + // "Press Enter to close" prompt). Without this the operator + // would have to click Close manually after each restore. + onComplete={() => { + const sp = restoreTerminal.stagingPath + fetchApi("/api/host-backups/restore/cleanup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ staging_path: sp }), + }).catch(() => undefined) + setRestoreTerminal(null) + }} + scriptPath="/usr/local/share/proxmenux/scripts/backup_restore/restore/monitor_apply.sh" + scriptName="monitor_apply" + title={`Restore — ${restoreTerminal.mode === "full" ? "Complete" : "Custom by paths"}`} + description={ + restoreTerminal.mode === "custom" + ? `${restoreTerminal.paths.length} path(s) selected` + : "Complete restore — applies the whole backup" + } + params={{ + EXECUTION_MODE: "web", + STAGING: restoreTerminal.stagingPath, + MODE: restoreTerminal.mode, + ...(restoreTerminal.mode === "custom" && restoreTerminal.paths.length > 0 + ? { PATHS: restoreTerminal.paths.join(",") } + : {}), + ...(restoreTerminal.rollbackExecute ? { ROLLBACK_EXECUTE: "1" } : {}), + }} + /> + )} + + {/* ── Confirm archive deletion ───────────────────────────── */} + + + + + + Delete {backendLabel} backup + + + {archive?.source === "local" + ? "Removes the archive, its sidecar JSON and the matching run log. The action is permanent — restore needs an off-host copy." + : archive?.source === "pbs" + ? `Forgets this snapshot from the PBS repository "${remoteArc?.repo_name ?? ""}". The action is permanent — PBS GC may reclaim the underlying chunks at the next garbage-collection run.` + : `Deletes this archive from the Borg repository "${remoteArc?.repo_name ?? ""}". The action is permanent — Borg compacts the freed space at the next prune.`} + + +
+ {archive?.source === "local" ? localArc?.id : remoteArc?.snapshot} +
+
+ + +
+
+
+ + {/* ── Full run log viewer ─────────────────────────────────── */} + + + + + + Run log + + + {archiveLog?.log_path} + + +
+{archiveLog?.content ?? ""}
+        
+
+ +
+
+
+ + ) +} + +// ── Manifest summary panel ─────────────────────────────────── +function ManifestSummary({ + manifest, +}: { + manifest: { + source_host: ManifestSourceHost + proxmenux_installed_components: Array<{ id: string; version_at_backup: string | null }> + vms_lxcs_at_backup: { vms: unknown[]; lxcs: unknown[] } + storage_inventory?: { zfs_pools?: unknown[]; lvm?: { vgs?: unknown[] } } + } +}) { + const sh = manifest.source_host + const zfsCount = manifest.storage_inventory?.zfs_pools?.length ?? 0 + const lvmCount = manifest.storage_inventory?.lvm?.vgs?.length ?? 0 + return ( +
+
+ } label="Source host" value={sh.hostname} /> + + + + + + + + +
+ {manifest.proxmenux_installed_components.length > 0 && ( +
+
ProxMenux components at backup time:
+
+ {manifest.proxmenux_installed_components.map((c) => ( + + {c.id}{c.version_at_backup ? ` @ ${c.version_at_backup}` : ""} + + ))} +
+
+ )} +
+ ) +} + +function Field({ icon, label, value, mono, labelClassName }: { icon?: React.ReactNode; label: string; value: string; mono?: boolean; labelClassName?: string }) { + return ( +
+
+ {icon} + {label} +
+
+ {value} +
+
+ ) +} + +// Renders the retention policy as a row of labeled badges. The runner's +// .env stores six discrete keys (KEEP_LAST / HOURLY / DAILY / WEEKLY / +// MONTHLY / YEARLY); the original UI dumped them as a comma-separated +// `key=val, key=val…` string which read like a config file. This view +// drops zero-valued entries and presents what survives as ordered chips. +function RetentionDisplay({ retention }: { retention: Record }) { + const order: Array<[string, string]> = [ + ["keep_last", "last"], + ["keep_hourly", "hourly"], + ["keep_daily", "daily"], + ["keep_weekly", "weekly"], + ["keep_monthly", "monthly"], + ["keep_yearly", "yearly"], + ] + const items = order + .map(([k, lbl]) => { + const v = (retention[k] || "").trim() + const n = Number.parseInt(v, 10) + if (!v || !Number.isFinite(n) || n <= 0) return null + return { label: lbl, value: n } + }) + .filter((x): x is { label: string; value: number } => x !== null) + + return ( +
+
+ retention +
+ {items.length === 0 ? ( +
No retention rules — backups will accumulate.
+ ) : ( +
+ {items.map((it) => ( + + {it.label} + {it.value} + + ))} +
+ )} +
+ ) +} + +// Renders the backup profile paths as a 2-column grid of monospaced +// rows instead of a single ` · `-separated line. With ~30 paths the +// old layout forced a horizontal scroll on the dialog because the +// line couldn't be wrapped without breaking the path identifiers. +function PathsDisplay({ paths }: { paths: string[] }) { + return ( +
+
+ paths + ({paths.length}) +
+
+ {paths.map((p) => ( + + {p} + + ))} +
+
+ ) +} + +// ── Preflight report view ──────────────────────────────────── +function PreflightReportView({ report }: { report: PreflightReport }) { + const { summary, checks } = report.preflight + const passColor = "text-emerald-500" + const warnColor = "text-amber-500" + const failColor = "text-red-500" + + return ( +
+ {/* Summary line */} +
+ + + {summary.pass} pass + + + + {summary.warn} warn + + + + {summary.fail} fail + + {summary.fail > 0 && ( + + --apply would be refused + + )} +
+ + {/* Per-check list */} +
+ {checks.map((c) => { + const color = + c.severity === "pass" ? passColor : + c.severity === "warn" ? warnColor : + failColor + const Icon = + c.severity === "pass" ? CheckCircle2 : + c.severity === "warn" ? AlertTriangle : + XCircle + return ( +
+ +
+ {c.id} + {c.message} +
+
+ ) + })} +
+ + {/* Storage / network counts */} +
+
+
Storage [in mode: {String(report.storage.in_selected_mode)}]
+
+ {report.storage.zfs.length} ZFS pool(s) · + {" "}{report.storage.lvm.length} LVM VG(s) · + {" "}{report.storage.pve_storage.length} PVE storage(s) +
+
+
+
Network [in mode: {String(report.network.in_selected_mode)}]
+
+ {report.network.keep.length} keep · + {" "}{report.network.remap.length} remap · + {" "}{report.network.orphan.length} orphan · + {" "}{report.network.new.length} new +
+
+
+ + {/* Driver plan */} + {report.driver_reinstall.plan.length > 0 && ( +
+
Driver reinstall plan ({report.driver_reinstall.plan.length})
+
+ {report.driver_reinstall.plan.map((p) => ( +
+ {p.component_id} + {p.action} +
+ ))} +
+
+ )} + + {/* Abort reason (if --apply would have been refused) */} + {report.abort_reason && ( +
+ {report.abort_reason} +
+ )} +
+ ) +} + +// ────────────────────────────────────────────────────────────── +// Create job wizard (A3.1 — attached jobs only). +// +// Pre-loads three things in parallel so the form can render selects +// the moment the operator opens it: +// - PVE vzdump jobs (filtered by backend once they pick one) +// - Pre-configured destinations (PBS repos, Borg targets, local) +// - Default profile path list (used when profile_mode === "default" +// and also as the pool the custom checklist is populated from) +// ────────────────────────────────────────────────────────────── + +interface PveVzdumpJob { + id: string + storage: string | null + storage_type: string | null + schedule: string | null + prune: string | null + enabled: boolean +} + +// All Destination shapes carry `jobs_using`: list of job_ids that +// currently depend on the destination. Surfaced in the Delete- +// destination confirm flow so the operator sees the cascade impact +// before agreeing. + +interface PbsRepo { + name: string + repository: string + fingerprint: string | null + source: "proxmox" | "manual" + jobs_using?: string[] +} + +interface BorgRepo { + name: string + repository: string + ssh_key_path?: string + // Encryption + saved-passphrase metadata. Newer backends ship these; + // older deployments without the fields default to "repokey" (the + // shell installer's historical default) and unknown-passphrase. + encrypt_mode?: "none" | "repokey" | "keyfile" | "authenticated" + has_passphrase?: boolean + jobs_using?: string[] +} + +interface LocalTargetEntry { + path: string + source: "default" | "custom" + removable: boolean + jobs_using?: string[] +} + +interface LocalTarget { + configured: string | null + default: string + effective: string + // Multi-path layout: the default plus any custom paths stacked on + // top. Present on every recent backend; older deployments without + // this field fall back to the single configured/effective accessors. + entries?: LocalTargetEntry[] +} + +interface DestinationsResp { + pbs: PbsRepo[] + borg: BorgRepo[] + local: LocalTarget +} + +interface JobDetail { + id: string + attached: boolean + enabled: boolean + manual: boolean + method: string + destination: string + on_calendar: string // human-formatted ("attached → storage:pbs" or "*-*-* 02:00") + next_run: string | null + pve_storage: string | null + pve_parent_job_id: string | null + profile_mode: string + paths: string[] + on_calendar_raw: string | null + retention: Record + pbs_repository: string | null + pbs_backup_id: string | null + pbs_fingerprint: string | null + has_pbs_password: boolean + // Legacy — kept for backend responses that only track the on/off + // toggle. Newer builds also send pbs_encrypt_mode ("none" | "new" + // | "existing"); loadFromJobDetail prefers that when present. + pbs_encrypt: boolean + pbs_encrypt_mode?: "none" | "new" | "existing" + local_dest_dir: string | null + local_archive_ext: string | null + borg_repo: string | null + borg_encrypt_mode: string + has_borg_passphrase: boolean + // Log fields from Sprint Pulido P1 + last_result: string | null // "ok" / "failed" / etc. + last_run_at: string | null // ISO-8601 + last_log_path: string | null + last_log_size: number + last_log_tail: string[] +} + +function CreateJobDialog({ + open, + onClose, + onCreated, + editingJobId, +}: { + open: boolean + onClose: () => void + onCreated: () => void + editingJobId?: string | null +}) { + const isEdit = !!editingJobId + const [step, setStep] = useState<1 | 2 | 3 | 4 | 5>(1) + const [jobId, setJobId] = useState("") + const [backend, setBackend] = useState<"pbs" | "local" | "borg">("pbs") + const [mode, setMode] = useState<"new" | "attach">("new") + const [pveJobId, setPveJobId] = useState("") + const [profileMode, setProfileMode] = useState<"default" | "custom">("default") + const [customPaths, setCustomPaths] = useState>(new Set()) + + // New (timer-based) mode fields — onCalendar is computed from the + // builder controls below; the raw expression is only directly edited + // when scheduleType === "advanced". + const [scheduleType, setScheduleType] = useState<"daily" | "hourly" | "weekly" | "monthly" | "advanced">("daily") + const [scheduleTime, setScheduleTime] = useState("02:00") + const [scheduleMinute, setScheduleMinute] = useState("0") + const [scheduleWeekdays, setScheduleWeekdays] = useState>(new Set(["Sun"])) + const [scheduleDayOfMonth, setScheduleDayOfMonth] = useState("1") + const [scheduleAdvanced, setScheduleAdvanced] = useState("daily") + const [keepLast, setKeepLast] = useState("7") + const [keepHourly, setKeepHourly] = useState("0") + const [keepDaily, setKeepDaily] = useState("7") + const [keepWeekly, setKeepWeekly] = useState("4") + const [keepMonthly, setKeepMonthly] = useState("3") + const [keepYearly, setKeepYearly] = useState("0") + + // Backend-specific fields + const [pbsRepository, setPbsRepository] = useState("") + const [pbsBackupId, setPbsBackupId] = useState("") + // Client-side PBS encryption mode. Three modes match the shell wizard: + // - "none" — plain backup, no --keyfile + // - "new" — backend generates a fresh per-host keyfile (default) + // - "existing" — operator uploads their own keyfile (shared across + // hosts). The upload happens via a dedicated endpoint + // BEFORE the job create call — see handleCreate below. + const [pbsEncryptMode, setPbsEncryptMode] = useState<"none" | "new" | "existing">("none") + // File picked in the "existing" mode. Kept in a ref-like state so a + // stale reference doesn't linger across modal opens. + const [pbsImportFile, setPbsImportFile] = useState(null) + // Absolute-path alternative to the file upload — mirrors the shell + // wizard's `_hb_pbs_import_dialog` when there is no PVE .enc match. + const [pbsImportPath, setPbsImportPath] = useState("") + const [pbsImportBusy, setPbsImportBusy] = useState(false) + // Legacy alias for the many recovery / gating checks below — a truthy + // value means "the operator wants an encrypted backup" regardless of + // whether that keyfile came from a fresh generate or from an import. + const pbsEncrypt = pbsEncryptMode !== "none" + // Optional recovery passphrase. When set, the backend encrypts the + // keyfile with openssl and the runner uploads that blob to PBS with + // every backup so the keyfile can be rebuilt on a fresh host with + // only the passphrase. Mirrors hb_pbs_setup_recovery from the shell. + const [pbsRecoveryPass, setPbsRecoveryPass] = useState("") + const [pbsRecoveryPass2, setPbsRecoveryPass2] = useState("") + // Operator opted to replace an already-configured recovery escrow. + // Default behavior matches the shell: don't prompt for the + // passphrase if one is already saved — only show the inputs when + // there's no escrow yet OR the operator explicitly asks to change. + const [pbsRecoveryChange, setPbsRecoveryChange] = useState(false) + // Binary "upload key to PBS?" toggle. `true` maps to escrow_mode='full' + // (passphrase required, envelope uploaded on every backup); `false` + // maps to escrow_mode='none' (keyfile stays local, operator handles + // the offsite copy). Defaults to 'No' — the keyfile is always + // downloadable from the Monitor, so the operator can grab it and + // save it offsite themselves without leaving a passphrase-wrapped + // copy on the PBS server. + const [pbsUploadToPbs, setPbsUploadToPbs] = useState(false) + // Whether the host already has an escrow blob configured. When + // present, the passphrase input becomes optional ("leave blank to + // keep saved"). Refreshed after a successful setup call. + const { data: pbsRecoveryStatus, mutate: mutatePbsRecovery } = useSWR<{ + has_keyfile: boolean; has_recovery: boolean; has_keyfile_passphrase?: boolean; escrow_mode?: "none" | "local" | "full" + }>( + open && backend === "pbs" ? "/api/host-backups/pbs-recovery/status" : null, + fetcher, + ) + // Auto-discover a PVE-managed keyfile for the currently selected + // PBS repository. When one is found, the "Use an existing keyfile" + // radio uses it silently (matches the shell wizard's PVE auto-detect + // path — the operator doesn't have to upload the same file twice). + const { data: pbsPveDiscover } = useSWR<{ + entries: Array<{ name: string; server: string; datastore: string; path: string; matches_repository: boolean }> + }>( + open && backend === "pbs" && pbsRepository && !pbsRecoveryStatus?.has_keyfile + ? `/api/host-backups/pbs-encryption/discover-pve-keyfiles?repository=${encodeURIComponent(pbsRepository)}` + : null, + fetcher, + ) + const pbsPveMatch = pbsPveDiscover?.entries?.find((e) => e.matches_repository) + const [localDestDir, setLocalDestDir] = useState("") + const [borgRepoSelected, setBorgRepoSelected] = useState("") + const [borgPassphrase, setBorgPassphrase] = useState("") + const [borgEncryptMode, setBorgEncryptMode] = useState<"none" | "repokey" | "keyfile" | "authenticated">("repokey") + + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + // Build the canonical OnCalendar string from the builder controls. + // The dropdown plus the small sub-inputs are friendlier than asking + // the operator to remember the systemd Calendar Events grammar. + const padHH = (s: string) => { + const [hh = "00", mm = "00"] = s.split(":") + return `${hh.padStart(2, "0")}:${mm.padStart(2, "0")}:00` + } + let onCalendar = "daily" + if (scheduleType === "daily") { + onCalendar = `*-*-* ${padHH(scheduleTime)}` + } else if (scheduleType === "hourly") { + const m = Math.max(0, Math.min(59, Number(scheduleMinute) || 0)) + onCalendar = `*-*-* *:${String(m).padStart(2, "0")}:00` + } else if (scheduleType === "weekly") { + const days = Array.from(scheduleWeekdays) + onCalendar = days.length > 0 + ? `${days.join(",")} *-*-* ${padHH(scheduleTime)}` + : `*-*-* ${padHH(scheduleTime)}` + } else if (scheduleType === "monthly") { + const d = Math.max(1, Math.min(31, Number(scheduleDayOfMonth) || 1)) + onCalendar = `*-*-${String(d).padStart(2, "0")} ${padHH(scheduleTime)}` + } else if (scheduleType === "advanced") { + onCalendar = scheduleAdvanced.trim() + } + + // Edit mode: fetch the full job (paths, retention, backend-specific) once + // the dialog opens, then pre-populate the form fields on arrival. + const { data: jobDetail } = useSWR( + open && isEdit ? `/api/host-backups/jobs/${encodeURIComponent(editingJobId || "")}` : null, + fetcher, + ) + + const [editPreloaded, setEditPreloaded] = useState(false) + // Snapshot of the job's backend/mode at the moment we entered edit + // mode, so Step 5 can call out a change explicitly ("you're now + // configuring a Borg destination" etc.) instead of just dropping the + // operator into an unfamiliar set of fields. + const [originalBackend, setOriginalBackend] = useState<"pbs" | "local" | "borg" | null>(null) + const [originalMode, setOriginalMode] = useState<"new" | "attach" | null>(null) + useEffect(() => { + if (!isEdit || !jobDetail || editPreloaded) return + setJobId(jobDetail.id) + setBackend((jobDetail.method as "pbs" | "local" | "borg") || "pbs") + setMode(jobDetail.attached ? "attach" : "new") + setOriginalBackend((jobDetail.method as "pbs" | "local" | "borg") || "pbs") + setOriginalMode(jobDetail.attached ? "attach" : "new") + setProfileMode((jobDetail.profile_mode as "default" | "custom") || "default") + if (jobDetail.profile_mode === "custom") { + setCustomPaths(new Set(jobDetail.paths)) + } + if (!jobDetail.attached && jobDetail.on_calendar_raw) { + setScheduleType("advanced") + setScheduleAdvanced(jobDetail.on_calendar_raw) + } + if (jobDetail.attached && jobDetail.pve_parent_job_id) { + setPveJobId(jobDetail.pve_parent_job_id) + } + const r = jobDetail.retention || {} + if (r.keep_last !== undefined) setKeepLast(String(r.keep_last)) + if (r.keep_hourly !== undefined) setKeepHourly(String(r.keep_hourly)) + if (r.keep_daily !== undefined) setKeepDaily(String(r.keep_daily)) + if (r.keep_weekly !== undefined) setKeepWeekly(String(r.keep_weekly)) + if (r.keep_monthly !== undefined) setKeepMonthly(String(r.keep_monthly)) + if (r.keep_yearly !== undefined) setKeepYearly(String(r.keep_yearly)) + if (jobDetail.pbs_repository) setPbsRepository(jobDetail.pbs_repository) + if (jobDetail.pbs_backup_id) setPbsBackupId(jobDetail.pbs_backup_id) + // Prefer the new pbs_encrypt_mode string when the backend sends it; + // fall back to the legacy bool for older `.env` layouts that only + // remember the on/off toggle. + const jd = jobDetail as unknown as { pbs_encrypt_mode?: string; pbs_encrypt?: boolean } + if (jd.pbs_encrypt_mode === "new" || jd.pbs_encrypt_mode === "existing" || jd.pbs_encrypt_mode === "none") { + setPbsEncryptMode(jd.pbs_encrypt_mode) + } else { + setPbsEncryptMode(jd.pbs_encrypt ? "new" : "none") + } + if (jobDetail.local_dest_dir) setLocalDestDir(jobDetail.local_dest_dir) + if (jobDetail.borg_repo) setBorgRepoSelected(jobDetail.borg_repo) + if (jobDetail.borg_encrypt_mode) { + setBorgEncryptMode(jobDetail.borg_encrypt_mode as "none" | "repokey" | "keyfile" | "authenticated") + } + setEditPreloaded(true) + }, [isEdit, jobDetail, editPreloaded]) + + // Reset the preload flag whenever the dialog closes so the next open + // (potentially for a different job) re-populates fresh. + useEffect(() => { + if (!open) { + setEditPreloaded(false) + setOriginalBackend(null) + setOriginalMode(null) + } + }, [open]) + + const { data: pveJobsResp } = useSWR<{ jobs: PveVzdumpJob[] }>( + open && mode === "attach" && backend !== "borg" ? `/api/host-backups/pve-vzdump-jobs?backend=${backend}` : null, + fetcher, + ) + + // Live calendar preview — only fetch when we're actually on Step 3 in + // "new" mode, otherwise the request is noise. + const calendarPreviewKey = open && mode === "new" && step === 3 && onCalendar.trim().length > 0 + ? `calendar-preview::${onCalendar}` + : null + const { data: calendarPreview } = useSWR<{ + valid: boolean + error?: string + normalized?: string + next_elapse?: string + from_now?: string + }>( + calendarPreviewKey, + () => + fetchApi("/api/host-backups/calendar-preview", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ expr: onCalendar }), + }), + { dedupingInterval: 400 }, + ) + const { data: destResp, mutate: mutateDest } = useSWR( + open ? "/api/host-backups/destinations" : null, + fetcher, + ) + const { data: pathsResp } = useSWR<{ paths: string[] }>( + open ? "/api/host-backups/default-paths" : null, + fetcher, + ) + // The wizard can spawn AddDestinationDialog inline so the operator + // configures a PBS / Borg destination without leaving the flow. + // When AddDestinationDialog saves, we capture the new repository + // string and auto-select it on the next destResp refresh — saves the + // operator from picking what they just configured from the dropdown. + const [addingDestType, setAddingDestType] = useState<"pbs" | "borg" | "local" | null>(null) + const [pendingAutoSelectDest, setPendingAutoSelectDest] = useState<{ kind: "pbs" | "borg"; repository: string } | null>(null) + + // When the destinations load, auto-pick the first PBS repo + fill in + // the fingerprint reference so the operator doesn't have to navigate + // away just to copy it. Same convenience for the backup-id. + const selectedPbs: PbsRepo | undefined = + destResp?.pbs?.find((r) => r.repository === pbsRepository) ?? destResp?.pbs?.[0] + + // Reset state on open / backend change + useEffect(() => { + if (!open) { + setStep(1) + setJobId("") + setBackend("pbs") + setMode("new") + setPveJobId("") + setProfileMode("default") + setCustomPaths(new Set()) + setScheduleType("daily") + setScheduleTime("02:00") + setScheduleMinute("0") + setScheduleWeekdays(new Set(["Sun"])) + setScheduleDayOfMonth("1") + setScheduleAdvanced("daily") + setKeepLast("7") + setKeepHourly("0") + setKeepDaily("7") + setKeepWeekly("4") + setKeepMonthly("3") + setKeepYearly("0") + setPbsRepository("") + setPbsBackupId("") + setPbsEncryptMode("none") + setPbsImportFile(null) + setPbsImportPath("") + setPbsImportBusy(false) + setPbsRecoveryPass("") + setPbsRecoveryPass2("") + setPbsRecoveryChange(false) + setPbsUploadToPbs(false) + setLocalDestDir("") + setBorgRepoSelected("") + setBorgPassphrase("") + setBorgEncryptMode("repokey") + setError(null) + setSubmitting(false) + } + }, [open]) + + // Borg can only be timer-based; coerce mode back to "new" if the + // operator picks borg after having selected attach. + useEffect(() => { + if (backend === "borg" && mode === "attach") { + setMode("new") + } + }, [backend, mode]) + + useEffect(() => { + setPveJobId("") + }, [backend, mode]) + + useEffect(() => { + if (backend === "pbs" && !pbsRepository && destResp?.pbs?.length) { + setPbsRepository(destResp.pbs[0].repository) + } + if (backend === "borg" && !borgRepoSelected && destResp?.borg?.length) { + setBorgRepoSelected(destResp.borg[0].repository) + } + }, [backend, destResp, pbsRepository, borgRepoSelected]) + + useEffect(() => { + if (!pendingAutoSelectDest || !destResp) return + if (pendingAutoSelectDest.kind === "pbs") { + const hit = destResp.pbs?.find((r) => r.repository === pendingAutoSelectDest.repository) + if (hit) { + setPbsRepository(hit.repository) + setPendingAutoSelectDest(null) + } + } else { + const hit = destResp.borg?.find((r) => r.repository === pendingAutoSelectDest.repository) + if (hit) { + setBorgRepoSelected(hit.repository) + setPendingAutoSelectDest(null) + } + } + }, [destResp, pendingAutoSelectDest]) + + const togglePath = (path: string) => { + setCustomPaths((prev) => { + const next = new Set(prev) + if (next.has(path)) next.delete(path) + else next.add(path) + return next + }) + } + + const selectedPveJob: PveVzdumpJob | undefined = pveJobsResp?.jobs?.find((j) => j.id === pveJobId) + const selectedBorgRepo: BorgRepo | undefined = destResp?.borg?.find((r) => r.repository === borgRepoSelected) + + // ── Step validation gates ── + const idValid = /^[a-zA-Z0-9_-]+$/.test(jobId) && jobId.length > 0 + const canAdvanceFrom1 = idValid + const canAdvanceFrom2 = true // selecting New/Attach is always possible + + // Step 3 depends on mode. For "new" we additionally block advancing + // if the live calendar preview already came back invalid, or if + // weekly was picked without any day selected. + const canAdvanceFrom3 = + mode === "attach" + ? !!pveJobId + : onCalendar.trim().length > 0 + && (calendarPreview === undefined || calendarPreview.valid !== false) + && (scheduleType !== "weekly" || scheduleWeekdays.size > 0) + + // Step 4: profile + const canAdvanceFrom4 = + profileMode === "default" || (profileMode === "custom" && customPaths.size > 0) + + // Step 5 (submit) — backend-specific requirements + const backendValid = + backend === "pbs" + ? !!pbsRepository + : backend === "local" + ? true + : /* borg */ !!borgRepoSelected + + // PBS encryption gate — choosing "Encrypt" makes the recovery + // passphrase mandatory. The button stays disabled until a matching + // passphrase pair is entered (or an already-saved escrow is reused). + // There is no opt-out: encrypting without recovery would leave the + // keyfile only on this host, and a reinstall would render every + // encrypted backup unrecoverable. + // Passphrase requirement only kicks in when: encryption is on AND + // there's no keyfile installed yet (fresh setup) AND the operator + // opted to upload the key to PBS. Every other case (reuse existing + // keyfile, or "no upload" mode) doesn't need a passphrase. + const pbsRecoveryOk = !(backend === "pbs" && pbsEncrypt) || + pbsRecoveryStatus?.has_keyfile || + !pbsUploadToPbs || + (pbsRecoveryPass !== "" && pbsRecoveryPass === pbsRecoveryPass2) + + const canSubmit = + canAdvanceFrom1 && canAdvanceFrom2 && canAdvanceFrom3 && canAdvanceFrom4 && backendValid && pbsRecoveryOk + + async function handleCreate() { + if (!canSubmit) return + if (mode === "attach" && !selectedPveJob) return + setSubmitting(true) + setError(null) + // Encryption submit paths: + // • enabled + keyfile already on disk → mode = "existing", no upload + // (backend reuses the canonical file at _PBS_KEYFILE_PATH). + // • enabled + no keyfile + Generate → mode = "new", backend creates it. + // • enabled + no keyfile + Import → upload the file first, then + // mode = "existing" so the backend uses the freshly-installed key. + // + // Only the third branch actually needs to POST to + // /api/host-backups/pbs-encryption/import — the second is handled + // server-side, and the first has nothing to upload. + // Unified encryption setup: one atomic call to /pbs-encryption/import + // covers keyfile install + escrow setup, whether the operator picked + // "generate a new keyfile" or "import an existing one", and whether + // they opted to upload the envelope to PBS or keep the key local. + const needsInstall = + backend === "pbs" && + pbsEncrypt && + !pbsRecoveryStatus?.has_keyfile + if (needsInstall) { + // Existing + no PVE auto-detect requires either a file upload + // or a typed absolute path — mirrors the shell wizard. + // Existing + PVE auto-detect uses source=pve-storage. + if (pbsEncryptMode === "existing" && !pbsPveMatch && !pbsImportFile && !pbsImportPath.trim()) { + setError("Pick a keyfile file or enter an absolute path on this host.") + setSubmitting(false) + return + } + if (pbsUploadToPbs && pbsRecoveryPass !== pbsRecoveryPass2) { + setError("Recovery passphrases don't match.") + setSubmitting(false) + return + } + setPbsImportBusy(true) + try { + // Source picker mirrors the shell wizard's dispatch: + // generate → new keyfile via proxmox-backup-client + // pve-storage → reuse /etc/pve/priv/storage/.enc + // content → upload a file the operator picked + // path → read a file at an absolute path + let source: "generate" | "pve-storage" | "content" | "path" + if (pbsEncryptMode === "new") source = "generate" + else if (pbsPveMatch) source = "pve-storage" + else if (pbsImportFile) source = "content" + else source = "path" + const body: Record = { + source, + escrow_mode: pbsUploadToPbs ? "full" : "none", + } + if (pbsUploadToPbs) body.escrow_passphrase = pbsRecoveryPass + if (source === "pve-storage") { + body.pve_storage_name = pbsPveMatch!.name + } else if (source === "content") { + const buf = new Uint8Array(await pbsImportFile!.arrayBuffer()) + let bin = "" + for (let i = 0; i < buf.length; i++) bin += String.fromCharCode(buf[i]) + body.content_b64 = btoa(bin) + } else if (source === "path") { + body.keyfile_path = pbsImportPath.trim() + } + await fetchApi("/api/host-backups/pbs-encryption/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + mutatePbsRecovery() + } catch (e) { + const err = e as Error & { body?: { tool_output?: string; tool_exit_code?: number } } + const detail = err.body?.tool_output + ? `${err.message}\n\nproxmox-backup-client output:\n${err.body.tool_output}` + : err.message + setError(`Encryption setup failed: ${detail || String(e)}`) + setPbsImportBusy(false) + setSubmitting(false) + return + } + setPbsImportBusy(false) + } + // NOTE: recovery escrow is now bundled into the /pbs-encryption/import + // call above. No separate /pbs-recovery/setup pass is needed. + try { + const body: Record = { + id: jobId, + backend, + attached: mode === "attach", + profile_mode: profileMode, + enabled: true, + } + if (mode === "attach" && selectedPveJob) { + body.pve_storage = selectedPveJob.storage + body.pve_parent_job_id = selectedPveJob.id + } else { + body.on_calendar = onCalendar.trim() + body.retention = { + keep_last: Number(keepLast) || 0, + keep_hourly: Number(keepHourly) || 0, + keep_daily: Number(keepDaily) || 0, + keep_weekly: Number(keepWeekly) || 0, + keep_monthly: Number(keepMonthly) || 0, + keep_yearly: Number(keepYearly) || 0, + } + } + if (profileMode === "custom") { + body.paths = Array.from(customPaths) + } + if (backend === "pbs") { + body.pbs_repository = pbsRepository + body.pbs_password = "" + if (pbsBackupId) body.pbs_backup_id = pbsBackupId + if (selectedPbs?.fingerprint) body.pbs_fingerprint = selectedPbs.fingerprint + body.pbs_encrypt_mode = pbsEncryptMode + } else if (backend === "local") { + if (localDestDir.trim()) body.local_dest_dir = localDestDir.trim() + } else if (backend === "borg") { + // Passphrase + encrypt_mode are inherited from the destination + // (the runner reads borg-pass-.txt + the 4th field of + // borg-targets.txt). Only send overrides when the operator + // really typed a new one in an edit flow. + body.borg_repo = borgRepoSelected + if (isEdit && borgPassphrase) body.borg_passphrase = borgPassphrase + } + // fetchApi returns the parsed JSON and throws (with the backend's + // error message in `error.message` when the body was JSON) on any + // non-2xx status — no need to inspect .ok or call .json() ourselves. + const url = isEdit + ? `/api/host-backups/jobs/${encodeURIComponent(editingJobId || "")}` + : "/api/host-backups/jobs" + await fetchApi(url, { + method: isEdit ? "PUT" : "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + onCreated() + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setSubmitting(false) + } + } + + const compatibleJobs = pveJobsResp?.jobs ?? [] + const defaultPaths = pathsResp?.paths ?? [] + + return ( + { if (!v) onClose() }}> + + + + {isEdit ? ( + + ) : ( + + )} + {isEdit ? "Edit scheduled backup job" : "Create scheduled backup job"} + + + Step {step} of 5 · {mode === "attach" ? "Attached to PVE vzdump" : "Standalone scheduled job"} + + + + {/* Step progress bar */} +
+ {[1, 2, 3, 4, 5].map((n) => ( +
+ ))} +
+ +
+ {/* ── Step 1: ID + Backend ─────────────────────── */} + {step === 1 && ( +
+
+ + setJobId(e.target.value)} + disabled={isEdit} + className="font-mono mt-1" + placeholder="my-host-backup" + /> +

+ {isEdit + ? "The job name can't be changed. Delete and recreate the job if you want to rename it." + : <>A short name to identify this job in the list, logs, and shell menu. Letters, digits, _ and - only (no spaces or accents).} +

+ {!idValid && jobId.length > 0 && !isEdit && ( +

Invalid characters. Use letters, digits, _ or -.

+ )} +
+ +
+ + {isEdit && ( +

+ You can change where the backup is sent. The destination of the new option is set on Step 5. +

+ )} +
+ {(["pbs", "local", "borg"] as const).map((b) => { + const Icon = b === "pbs" ? Server : b === "local" ? HardDrive : Archive + const desc = b === "pbs" + ? "Proxmox Backup Server. Incremental, encrypted, dedup." + : b === "local" + ? "tar.zst archive into a local directory or mounted disk." + : "Borg repo over SSH or on a local/USB disk (timer only)." + return ( + + ) + })} +
+
+
+ )} + + {/* ── Step 2: How to schedule ──────────────────── */} + {step === 2 && ( +
+
+ +

+ {backend === "borg" + ? "Borg backups only run on their own timer — they're not produced by PVE vzdump." + : "Either run on a schedule you define here, or hook into an existing PVE vzdump job and inherit its schedule + retention."} +

+
+
+ + +
+
+ )} + + {/* ── Step 3: branches by mode ─────────────────── */} + {step === 3 && mode === "attach" && ( +
+
+ +

+ The host config backup will fire on every job-end of this job. +

+
+ {compatibleJobs.length === 0 ? ( +
+
+ + No compatible PVE vzdump job +
+

+ No PVE vzdump job currently uses a {backend} storage. Create one in Datacenter → Backup first, then come back here to attach. +

+
+ ) : ( +
+ {compatibleJobs.map((j) => ( + + ))} +
+ )} +
+ )} + + {step === 3 && mode === "new" && ( +
+
+ +

+ Pick how often this backup runs. The expression is built and validated for you. +

+ +
+ + {scheduleType === "daily" && ( +
+ + setScheduleTime(e.target.value)} + className="font-mono mt-1 max-w-[140px]" + /> +
+ )} + + {scheduleType === "hourly" && ( +
+ + setScheduleMinute(e.target.value)} + className="font-mono mt-1 max-w-[120px]" + /> +

+ The job fires every hour at this minute. 0 = on the hour, 30 = half past, etc. +

+
+ )} + + {scheduleType === "weekly" && ( +
+
+ +
+ {["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].map((d) => { + const active = scheduleWeekdays.has(d) + return ( + + ) + })} +
+ {scheduleWeekdays.size === 0 && ( +

Pick at least one day.

+ )} +
+
+ + setScheduleTime(e.target.value)} + className="font-mono mt-1 max-w-[140px]" + /> +
+
+ )} + + {scheduleType === "monthly" && ( +
+
+ + setScheduleDayOfMonth(e.target.value)} + className="font-mono mt-1 max-w-[120px]" + /> +

+ If the chosen day doesn't exist in a given month (e.g. 31 in February), systemd skips that month. +

+
+
+ + setScheduleTime(e.target.value)} + className="font-mono mt-1 max-w-[140px]" + /> +
+
+ )} + + {scheduleType === "advanced" && ( +
+ + setScheduleAdvanced(e.target.value)} + className="font-mono mt-1" + placeholder="*-*-* 02:00, Mon..Fri *-*-* 04:00, daily, ..." + /> +

+ Any expression accepted by systemd-analyze calendar. See man systemd.time for the full grammar. +

+
+ )} + + {/* Live preview from the backend */} +
+
Preview
+
+ Expression: + {onCalendar} +
+ {calendarPreview ? ( + calendarPreview.valid ? ( + <> + {calendarPreview.normalized && calendarPreview.normalized !== onCalendar && ( +
+ Normalized: + {calendarPreview.normalized} +
+ )} + {calendarPreview.next_elapse && ( +
+ Next run: + {calendarPreview.next_elapse} + {calendarPreview.from_now && ( + ({calendarPreview.from_now}) + )} +
+ )} + + ) : ( +
+ Invalid: {calendarPreview.error} +
+ ) + ) : ( +
checking…
+ )} +
+ +
+ +

Zero disables that bucket.

+
+ {[ + { id: "keep-last", lbl: "keep-last", v: keepLast, set: setKeepLast }, + { id: "keep-hourly", lbl: "keep-hourly", v: keepHourly, set: setKeepHourly }, + { id: "keep-daily", lbl: "keep-daily", v: keepDaily, set: setKeepDaily }, + { id: "keep-weekly", lbl: "keep-weekly", v: keepWeekly, set: setKeepWeekly }, + { id: "keep-monthly", lbl: "keep-monthly", v: keepMonthly, set: setKeepMonthly }, + { id: "keep-yearly", lbl: "keep-yearly", v: keepYearly, set: setKeepYearly }, + ].map((row) => ( +
+ + row.set(e.target.value)} + className="font-mono mt-1" + /> +
+ ))} +
+
+
+ )} + + {/* ── Step 4: Profile + paths ──────────────────── */} + {step === 4 && ( +
+
+ +
+ + +
+
+ {profileMode === "custom" && ( +
+ + + {defaultPaths.map((p) => ( + + ))} + +
+ )} +
+ )} + + {/* ── Step 5: Backend-specific + summary ──────── */} + {step === 5 && ( +
+ {backend === "pbs" && ( + <> +
+ + {destResp?.pbs?.length ? ( +
+ + +
+ ) : ( +
+
No PBS repository configured yet. You can add one without leaving this wizard.
+ +
+ )} +
+
+ + setPbsBackupId(e.target.value)} + placeholder="Leave blank for the default" + className="font-mono mt-1" + /> +

+ PBS organises backups into named groups, each with its own retention. Leave blank to use the automatic default for this host (recommended). +

+
+ {/* PBS client-side encryption — mirrors the shell wizard: + step 1 is a plain yes/no, step 2 only appears when + no keyfile is installed yet. Once a keyfile exists, + every future encrypted job silently reuses it. */} +
+ + + + {pbsEncrypt && !pbsRecoveryStatus?.has_keyfile && ( +
+
+ No encryption key is stored on this host. Choose how to set one up: +
+ + + {pbsEncryptMode === "existing" && pbsPveMatch && ( +
+
+ +
+
Auto-detected from PVE storage '{pbsPveMatch.name}'
+
+ Existing key at {pbsPveMatch.path} +
+
Will be copied to the ProxMenux state directory. No file upload needed.
+
+
+
+ )} + {pbsEncryptMode === "existing" && !pbsPveMatch && ( +
+
+ + setPbsImportFile(e.target.files?.[0] ?? null)} + disabled={pbsImportBusy || !!pbsImportPath.trim()} + className="h-8 text-[11px]" + /> +
+
— or —
+
+ + setPbsImportPath(e.target.value)} + disabled={pbsImportBusy || !!pbsImportFile} + placeholder="e.g. /etc/pve/priv/storage/.enc or /root/my-pbs-key" + className="h-8 text-[11px] font-mono" + /> +

+ Either option works. The file lands at /usr/local/share/proxmenux/pbs-key.conf and every subsequent encrypted backup reuses it. +

+
+
+ )} +
+ )} + + {pbsEncrypt && ( +
+ {pbsRecoveryStatus?.has_keyfile && !pbsRecoveryChange && ( +
+ +
+
Keyfile installed. Backups reuse it silently.
+
The current PBS-upload setting stays in effect.
+
+ +
+ )} + {pbsRecoveryStatus?.has_keyfile && pbsRecoveryChange && ( + <> + + + + )} + {!pbsRecoveryStatus?.has_keyfile && ( + <> +
+
Upload key to PBS?
+
+ + +
+
+ {pbsUploadToPbs ? ( +
+
+ + setPbsRecoveryPass(e.target.value)} + placeholder="Long random string — write it down somewhere safe" + className="font-mono mt-1 h-8 text-xs" + /> +
+
+ + setPbsRecoveryPass2(e.target.value)} + placeholder="Type it again" + className="font-mono mt-1 h-8 text-xs" + /> + {pbsRecoveryPass && pbsRecoveryPass2 && pbsRecoveryPass !== pbsRecoveryPass2 && ( +

Passphrases don't match.

+ )} +
+
+ ) : ( +
+
Keep an offsite copy of the keyfile.
+
After the job is created, copy /usr/local/share/proxmenux/pbs-key.conf to an external medium (USB, password manager, another host).
+
Without that copy, losing this host makes every encrypted backup on PBS unreadable.
+
+ )} + + )} +
+ )} +
+ + )} + {backend === "local" && ( +
+ + {mode === "attach" ? ( + <> + setLocalDestDir(e.target.value)} + placeholder="Auto (derived from the PVE storage path)" + className="font-mono" + /> +

+ Leave blank to derive automatically from the parent PVE job's storage at backup time. Default is the storage's /dump subdir, falling back to /var/lib/vz/dump. +

+ + ) : ( + <> + {(destResp?.local?.entries?.length ?? 0) > 0 && ( +
+ + +
+ )} + setLocalDestDir(e.target.value)} + placeholder={destResp?.local?.effective || "/var/lib/vz/dump"} + className="font-mono" + /> +

+ Pick a configured destination above, or type a custom directory path. Empty input falls back to {destResp?.local?.effective || "/var/lib/vz/dump"}. +

+ + )} +
+ )} + {backend === "borg" && ( + <> +
+ + {destResp?.borg?.length ? ( +
+ + +
+ ) : ( +
+
No Borg repository configured yet. You can add one without leaving this wizard (SSH or local/USB path supported).
+ +
+ )} +
+ {/* Encryption + passphrase aren't asked here — they + live on the borg destination. The runner reads + the saved sidecar at run time. */} + {(() => { + const dest = destResp?.borg?.find((r) => r.repository === borgRepoSelected) + if (!dest) return null + const mode = dest.encrypt_mode || "repokey" + return ( +

+ Encryption + passphrase are taken from the destination ({mode === "none" ? "no encryption" : mode}). Edit the destination if you need to change them. +

+ ) + })()} + + )} + + {/* Summary preview — mirrors the JobDetailModal styling so + what the operator sees at Create time is what they'll + see when they open the job later. */} + {(() => { + const retentionPairs: Array<[string, string]> = [ + ["last", String(keepLast || "")], + ["hourly", String(keepHourly || "")], + ["daily", String(keepDaily || "")], + ["weekly", String(keepWeekly || "")], + ["monthly", String(keepMonthly || "")], + ["yearly", String(keepYearly || "")], + ].filter(([, v]) => Number(v) > 0) as Array<[string, string]> + return ( +
+
Summary
+
Name: {jobId}
+
+ Backend: + + {backend} + + + {mode === "attach" ? "attached" : "scheduled"} + +
+ {mode === "attach" && selectedPveJob && ( + <> +
PVE job: {selectedPveJob.id}
+
+ + inherited schedule: + {humanizeOnCalendar(selectedPveJob.schedule)} +
+
+ + inherited retention: + {selectedPveJob.prune} +
+ + )} + {mode === "new" && ( + <> +
+ + when: + {humanizeOnCalendar(onCalendar)} +
+
+ + retention: + + {retentionPairs.length === 0 ? ( + No retention rules + ) : ( +
+ {retentionPairs.map(([k, v]) => ( + + {k} + {v} + + ))} +
+ )} +
+ + )} +
+ + profile: + {profileMode} + ({profileMode === "default" ? defaultPaths.length : customPaths.size} paths) +
+
+ ) + })()} + + {error && ( +
+ {error} +
+ )} +
+ )} +
+ + {/* Footer navigation */} +
+ +
+ {step > 1 && ( + + )} + {step < 5 ? ( + + ) : ( + + )} +
+
+ + + {/* AddDestinationDialog spawned from inside the wizard. When the + operator saves a new PBS or Borg destination we refresh the + dest list and queue the freshly-saved repository so the next + destResp render auto-selects it in the dropdown. */} + setAddingDestType(null)} + onSaved={(repo) => { + if (repo && (addingDestType === "pbs" || addingDestType === "borg")) { + setPendingAutoSelectDest({ kind: addingDestType, repository: repo }) + } + setAddingDestType(null) + mutateDest() + }} + /> +
+ ) +} + +// ────────────────────────────────────────────────────────────── +// Manual backup dialog (Sprint B). +// +// One-shot version of CreateJobDialog: same backends + profile + paths +// + backend-specific config, but NO schedule, NO retention, NO timer. +// Writes a `manual-` .env with ENABLED=0 + MANUAL_RUN=1 and fires +// the runner in the background. The resulting job appears in the +// scheduler list with a "manual" badge and can be deleted when the +// operator wants to clean up. +// ────────────────────────────────────────────────────────────── + +function ManualBackupDialog({ + open, + onClose, + onLaunched, +}: { + open: boolean + onClose: () => void + // The job_id of the just-launched manual run. The caller uses it to + // transition straight into the live-progress modal so the operator + // never has to chase a banner after pressing Run. + onLaunched: (jobId: string) => void +}) { + const [step, setStep] = useState<1 | 2>(1) + const [backend, setBackend] = useState<"pbs" | "local" | "borg">("local") + const [profileMode, setProfileMode] = useState<"default" | "custom">("default") + const [customPaths, setCustomPaths] = useState>(new Set()) + + const [pbsRepository, setPbsRepository] = useState("") + const [pbsBackupId, setPbsBackupId] = useState("") + // Encryption mode. See CreateJobDialog above for the full comment + // block — the three modes and the import flow are identical here. + const [pbsEncryptMode, setPbsEncryptMode] = useState<"none" | "new" | "existing">("none") + const [pbsImportFile, setPbsImportFile] = useState(null) + // Absolute-path alternative to the file upload — mirrors the shell + // wizard's `_hb_pbs_import_dialog` when there is no PVE .enc match. + const [pbsImportPath, setPbsImportPath] = useState("") + const [pbsImportBusy, setPbsImportBusy] = useState(false) + const pbsEncrypt = pbsEncryptMode !== "none" + const [pbsRecoveryPass, setPbsRecoveryPass] = useState("") + const [pbsRecoveryPass2, setPbsRecoveryPass2] = useState("") + // Operator opted to replace an already-configured recovery escrow. + // Default behavior matches the shell: don't prompt for the + // passphrase if one is already saved — only show the inputs when + // there's no escrow yet OR the operator explicitly asks to change. + const [pbsRecoveryChange, setPbsRecoveryChange] = useState(false) + // Binary "upload key to PBS?" toggle. `true` maps to escrow_mode='full' + // (passphrase required, envelope uploaded on every backup); `false` + // maps to escrow_mode='none' (keyfile stays local, operator handles + // the offsite copy). Defaults to 'No' — the keyfile is always + // downloadable from the Monitor, so the operator can grab it and + // save it offsite themselves without leaving a passphrase-wrapped + // copy on the PBS server. + const [pbsUploadToPbs, setPbsUploadToPbs] = useState(false) + // Whether the host already has an escrow blob configured. When + // present, the passphrase input becomes optional ("leave blank to + // keep saved"). Refreshed after a successful setup call. + const { data: pbsRecoveryStatus, mutate: mutatePbsRecovery } = useSWR<{ + has_keyfile: boolean; has_recovery: boolean; has_keyfile_passphrase?: boolean; escrow_mode?: "none" | "local" | "full" + }>( + open && backend === "pbs" ? "/api/host-backups/pbs-recovery/status" : null, + fetcher, + ) + // Auto-discover a PVE-managed keyfile for the currently selected + // PBS repository. When one is found, the "Use an existing keyfile" + // radio uses it silently (matches the shell wizard's PVE auto-detect + // path — the operator doesn't have to upload the same file twice). + const { data: pbsPveDiscover } = useSWR<{ + entries: Array<{ name: string; server: string; datastore: string; path: string; matches_repository: boolean }> + }>( + open && backend === "pbs" && pbsRepository && !pbsRecoveryStatus?.has_keyfile + ? `/api/host-backups/pbs-encryption/discover-pve-keyfiles?repository=${encodeURIComponent(pbsRepository)}` + : null, + fetcher, + ) + const pbsPveMatch = pbsPveDiscover?.entries?.find((e) => e.matches_repository) + const [localDestDir, setLocalDestDir] = useState("") + const [borgRepoSelected, setBorgRepoSelected] = useState("") + const [borgPassphrase, setBorgPassphrase] = useState("") + const [borgEncryptMode, setBorgEncryptMode] = useState<"none" | "repokey" | "keyfile" | "authenticated">("repokey") + + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + const { data: destResp, mutate: mutateDest } = useSWR( + open ? "/api/host-backups/destinations" : null, + fetcher, + ) + const { data: pathsResp } = useSWR<{ paths: string[] }>( + open ? "/api/host-backups/default-paths" : null, + fetcher, + ) + // Same inline AddDestinationDialog flow as the scheduled CreateJobDialog. + const [addingDestType, setAddingDestType] = useState<"pbs" | "borg" | "local" | null>(null) + const [pendingAutoSelectDest, setPendingAutoSelectDest] = useState<{ kind: "pbs" | "borg"; repository: string } | null>(null) + useEffect(() => { + if (!pendingAutoSelectDest || !destResp) return + if (pendingAutoSelectDest.kind === "pbs") { + const hit = destResp.pbs?.find((r) => r.repository === pendingAutoSelectDest.repository) + if (hit) { + setPbsRepository(hit.repository) + setPendingAutoSelectDest(null) + } + } else { + const hit = destResp.borg?.find((r) => r.repository === pendingAutoSelectDest.repository) + if (hit) { + setBorgRepoSelected(hit.repository) + setPendingAutoSelectDest(null) + } + } + }, [destResp, pendingAutoSelectDest]) + + const selectedPbs = destResp?.pbs?.find((r) => r.repository === pbsRepository) ?? destResp?.pbs?.[0] + + useEffect(() => { + if (!open) { + setStep(1) + setBackend("local") + setProfileMode("default") + setCustomPaths(new Set()) + setPbsRepository("") + setPbsBackupId("") + setPbsEncryptMode("none") + setPbsImportFile(null) + setPbsImportPath("") + setPbsImportBusy(false) + setPbsRecoveryPass("") + setPbsRecoveryPass2("") + setPbsRecoveryChange(false) + setPbsUploadToPbs(false) + setLocalDestDir("") + setBorgRepoSelected("") + setBorgPassphrase("") + setBorgEncryptMode("repokey") + setError(null) + setSubmitting(false) + } + }, [open]) + + useEffect(() => { + if (backend === "pbs" && !pbsRepository && destResp?.pbs?.length) { + setPbsRepository(destResp.pbs[0].repository) + } + if (backend === "borg" && !borgRepoSelected && destResp?.borg?.length) { + setBorgRepoSelected(destResp.borg[0].repository) + } + }, [backend, destResp, pbsRepository, borgRepoSelected]) + + const togglePath = (path: string) => { + setCustomPaths((prev) => { + const next = new Set(prev) + if (next.has(path)) next.delete(path) + else next.add(path) + return next + }) + } + + const defaultPaths = pathsResp?.paths ?? [] + const canAdvanceFrom1 = profileMode === "default" || (profileMode === "custom" && customPaths.size > 0) + const backendValid = + backend === "pbs" + ? !!pbsRepository + : backend === "local" + ? true + // Borg destination carries its own passphrase + encryption. + // No need to gate on local state — the wizard no longer asks. + : !!borgRepoSelected + // Recovery gate for PBS encryption. Choosing "Encrypt" makes the + // recovery passphrase mandatory — same as the CLI. The "Run backup" + // button stays disabled until a matching passphrase pair is entered + // (or an already-saved escrow is reused). No accept-risk escape: + // encrypting without recovery would leave the keyfile only on this + // host, and a reinstall would render every encrypted backup + // unrecoverable. + // Passphrase requirement only kicks in when: encryption is on AND + // there's no keyfile installed yet (fresh setup) AND the operator + // opted to upload the key to PBS. Every other case (reuse existing + // keyfile, or "no upload" mode) doesn't need a passphrase. + const pbsRecoveryOk = !(backend === "pbs" && pbsEncrypt) || + pbsRecoveryStatus?.has_keyfile || + !pbsUploadToPbs || + (pbsRecoveryPass !== "" && pbsRecoveryPass === pbsRecoveryPass2) + const canSubmit = canAdvanceFrom1 && backendValid && pbsRecoveryOk + + async function handleRun() { + if (!canSubmit) return + setSubmitting(true) + setError(null) + // Unified encryption setup — mirrors CreateJobDialog. One atomic + // /pbs-encryption/import call covers keyfile install + escrow + // setup with source picked from what the operator provided. + const needsInstall = + backend === "pbs" && + pbsEncrypt && + !pbsRecoveryStatus?.has_keyfile + if (needsInstall) { + if (pbsEncryptMode === "existing" && !pbsPveMatch && !pbsImportFile && !pbsImportPath.trim()) { + setError("Pick a keyfile file or enter an absolute path on this host.") + setSubmitting(false) + return + } + if (pbsUploadToPbs && pbsRecoveryPass !== pbsRecoveryPass2) { + setError("Recovery passphrases don't match.") + setSubmitting(false) + return + } + setPbsImportBusy(true) + try { + let source: "generate" | "pve-storage" | "content" | "path" + if (pbsEncryptMode === "new") source = "generate" + else if (pbsPveMatch) source = "pve-storage" + else if (pbsImportFile) source = "content" + else source = "path" + const body: Record = { + source, + escrow_mode: pbsUploadToPbs ? "full" : "none", + } + if (pbsUploadToPbs) body.escrow_passphrase = pbsRecoveryPass + if (source === "pve-storage") { + body.pve_storage_name = pbsPveMatch!.name + } else if (source === "content") { + const buf = new Uint8Array(await pbsImportFile!.arrayBuffer()) + let bin = "" + for (let i = 0; i < buf.length; i++) bin += String.fromCharCode(buf[i]) + body.content_b64 = btoa(bin) + } else if (source === "path") { + body.keyfile_path = pbsImportPath.trim() + } + await fetchApi("/api/host-backups/pbs-encryption/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + mutatePbsRecovery() + } catch (e) { + const err = e as Error & { body?: { tool_output?: string; tool_exit_code?: number } } + const detail = err.body?.tool_output + ? `${err.message}\n\nproxmox-backup-client output:\n${err.body.tool_output}` + : err.message + setError(`Encryption setup failed: ${detail || String(e)}`) + setPbsImportBusy(false) + setSubmitting(false) + return + } + setPbsImportBusy(false) + } + // NOTE: recovery escrow is now bundled into the /pbs-encryption/import + // call above. No separate /pbs-recovery/setup pass is needed. + try { + const body: Record = { + backend, + profile_mode: profileMode, + } + if (profileMode === "custom") { + body.paths = Array.from(customPaths) + } + if (backend === "pbs") { + body.pbs_repository = pbsRepository + body.pbs_password = "" + if (pbsBackupId) body.pbs_backup_id = pbsBackupId + if (selectedPbs?.fingerprint) body.pbs_fingerprint = selectedPbs.fingerprint + body.pbs_encrypt_mode = pbsEncryptMode + } else if (backend === "local") { + if (localDestDir.trim()) body.local_dest_dir = localDestDir.trim() + } else if (backend === "borg") { + // Passphrase + encrypt_mode are inherited from the destination + // (borg-pass-.txt sidecar + 4th field of borg-targets.txt). + body.borg_repo = borgRepoSelected + } + const resp = await fetchApi<{ status: string; job_id: string }>( + "/api/host-backups/manual-run", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ) + onLaunched(resp.job_id) + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setSubmitting(false) + } + } + + return ( + { if (!v) onClose() }}> + + + + + Run a one-shot backup + + + Step {step} of 2 · The backup runs once. It appears in the list with a "manual" badge and won't fire again. + + + +
+ {[1, 2].map((n) => ( +
+ ))} +
+ +
+ {step === 1 && ( +
+
+ +
+ {(["pbs", "local", "borg"] as const).map((b) => { + const Icon = b === "pbs" ? Server : b === "local" ? HardDrive : Archive + const desc = b === "pbs" + ? "Proxmox Backup Server." + : b === "local" + ? "tar.zst archive into a local directory." + : "Borg repo (SSH or local/USB disk)." + return ( + + ) + })} +
+
+ +
+ +
+ + +
+
+ + {profileMode === "custom" && ( +
+ + + {defaultPaths.map((p) => ( + + ))} + +
+ )} +
+ )} + + {step === 2 && ( +
+ {backend === "pbs" && ( + <> +
+ + {destResp?.pbs?.length ? ( +
+ + +
+ ) : ( +
+
No PBS repository configured yet. You can add one without leaving this dialog.
+ +
+ )} +
+
+ + setPbsBackupId(e.target.value)} + placeholder="Leave blank for the default" + className="font-mono mt-1" + /> +
+
+ + + + {pbsEncrypt && !pbsRecoveryStatus?.has_keyfile && ( +
+
+ No encryption key is stored on this host. Choose how to set one up: +
+ + + {pbsEncryptMode === "existing" && pbsPveMatch && ( +
+
+ +
+
Auto-detected from PVE storage '{pbsPveMatch.name}'
+
+ Existing key at {pbsPveMatch.path} +
+
Will be copied to the ProxMenux state directory. No file upload needed.
+
+
+
+ )} + {pbsEncryptMode === "existing" && !pbsPveMatch && ( +
+
+ + setPbsImportFile(e.target.files?.[0] ?? null)} + disabled={pbsImportBusy || !!pbsImportPath.trim()} + className="h-8 text-[11px]" + /> +
+
— or —
+
+ + setPbsImportPath(e.target.value)} + disabled={pbsImportBusy || !!pbsImportFile} + placeholder="e.g. /etc/pve/priv/storage/.enc or /root/my-pbs-key" + className="h-8 text-[11px] font-mono" + /> +

+ Either option works. The file lands at /usr/local/share/proxmenux/pbs-key.conf and every subsequent encrypted backup reuses it. +

+
+
+ )} +
+ )} + + {pbsEncrypt && ( +
+ {pbsRecoveryStatus?.has_keyfile && !pbsRecoveryChange && ( +
+ +
+
Keyfile installed. This backup reuses it silently.
+
The current PBS-upload setting stays in effect.
+
+ +
+ )} + {pbsRecoveryStatus?.has_keyfile && pbsRecoveryChange && ( + <> + + + + )} + {!pbsRecoveryStatus?.has_keyfile && ( + <> +
+
Upload key to PBS?
+
+ + +
+
+ {pbsUploadToPbs ? ( +
+
+ + setPbsRecoveryPass(e.target.value)} + placeholder="Long random string — write it down somewhere safe" + className="font-mono mt-1 h-8 text-xs" + /> +
+
+ + setPbsRecoveryPass2(e.target.value)} + placeholder="Type it again" + className="font-mono mt-1 h-8 text-xs" + /> + {pbsRecoveryPass && pbsRecoveryPass2 && pbsRecoveryPass !== pbsRecoveryPass2 && ( +

Passphrases don't match.

+ )} +
+
+ ) : ( +
+
Keep an offsite copy of the keyfile.
+
After this backup, copy /usr/local/share/proxmenux/pbs-key.conf to an external medium (USB, password manager, another host).
+
Without that copy, losing this host makes every encrypted backup on PBS unreadable.
+
+ )} + + )} +
+ )} +
+ + )} + {backend === "local" && ( +
+ + {(destResp?.local?.entries?.length ?? 0) > 0 && ( +
+ + +
+ )} +

+ Pick one of the configured local destinations, or type a custom directory path below. Empty input falls back to the selected destination above. +

+ setLocalDestDir(e.target.value)} + placeholder={destResp?.local?.effective || "/var/lib/vz/dump"} + className="font-mono" + /> +
+ )} + {backend === "borg" && ( + <> +
+ + {destResp?.borg?.length ? ( +
+ + +
+ ) : ( +
+
No Borg repository configured yet. You can add one without leaving this dialog (SSH or local/USB path supported).
+ +
+ )} +
+ {(() => { + const dest = destResp?.borg?.find((r) => r.repository === borgRepoSelected) + if (!dest) return null + const mode = dest.encrypt_mode || "repokey" + return ( +

+ Encryption + passphrase are taken from the destination ({mode === "none" ? "no encryption" : mode}). Edit the destination if you need to change them. +

+ ) + })()} + + )} + + {/* Summary — mirrors the styling of the JobDetailModal. */} +
+
Summary
+
+ Backend: + + {backend} + + + manual / one-shot + +
+
+ + profile: + {profileMode} + ({profileMode === "default" ? defaultPaths.length : customPaths.size} paths) +
+
+ + {error && ( +
+ {error} +
+ )} +
+ )} +
+ +
+ +
+ {step > 1 && ( + + )} + {step < 2 ? ( + + ) : ( + + )} +
+
+ + + {/* AddDestinationDialog spawned from inside the manual-backup + dialog. Same auto-select-on-save behavior as the wizard. */} + setAddingDestType(null)} + onSaved={(repo) => { + if (repo && (addingDestType === "pbs" || addingDestType === "borg")) { + setPendingAutoSelectDest({ kind: addingDestType, repository: repo }) + } + setAddingDestType(null) + mutateDest() + }} + /> +
+ ) +} + +// ────────────────────────────────────────────────────────────── +// Backup destinations CRUD (Sprint D). +// +// Three persistence files live in $HB_STATE_DIR (= /usr/local/share/proxmenux/): +// - pbs-manual-configs.txt (manual PBS configs the shell also reads) +// - borg-targets.txt (Borg repos saved by the shell) +// - local-target.conf (single local target — default or override) +// +// Auto-discovered PBS storages from /etc/pve/storage.cfg show with a +// "proxmox" badge and are NOT deletable here (PVE owns those). +// ────────────────────────────────────────────────────────────── + +// One entry in the unified destinations list. PBS / Borg / Local all +// flatten into this shape so the row component doesn't need to know +// which backend a given destination targets — only the color, the +// label and the action set. +type UnifiedDest = + | { id: string; kind: "local"; path: string; source: "default" | "custom"; removable: boolean; jobsUsing: string[] } + | { + id: string; kind: "pbs"; name: string; repository: string; + source: "proxmox" | "manual"; fingerprint?: string; removable: boolean + jobsUsing: string[] + } + | { + id: string; kind: "borg"; name: string; repository: string; + isSsh: boolean; ssh?: { user: string; host: string; remotePath: string }; + sshKeyPath?: string; + // The destination's encryption mode + whether a passphrase is + // saved server-side; used to render a Lock / Unlock badge so the + // operator sees at a glance which Borg repos are encrypted. + encryptMode?: "none" | "repokey" | "keyfile" | "authenticated" + hasPassphrase?: boolean + removable: boolean + jobsUsing: string[] + } + +interface CapacityInfo { + id: string + total?: number + available?: number + used?: number + is_usb?: boolean + remote?: boolean + error?: string +} + +// Parse a Borg repository string into {user, host, remotePath} when +// it's an SSH URL — used for the capacity probe over ssh. +function parseBorgSsh(repo: string): { user: string; host: string; remotePath: string } | null { + const m = repo.match(/^ssh:\/\/([^@]+)@([^/]+)\/(.+)$/) + if (!m) return null + return { user: m[1], host: m[2], remotePath: `/${m[3]}` } +} + +function DestinationsSection({ + destinations, + onChanged, +}: { + destinations?: DestinationsResp + onChanged: () => void +}) { + const [configuring, setConfiguring] = useState(false) + const [editingDest, setEditingDest] = useState(null) + const [confirmingDest, setConfirmingDest] = useState(null) + const [busyKey, setBusyKey] = useState(null) + const [error, setError] = useState(null) + + // Used by the Delete-destination confirm to tell the operator how + // many backups already live on this target (they're kept after the + // destination + jobs cascade, so the warning is informational). + const { data: localArchivesResp } = useSWR<{ archives: BackupArchive[] }>( + "/api/host-backups/archives", + fetcher, + { refreshInterval: 60_000, revalidateOnFocus: false }, + ) + const { data: remoteArchivesResp } = useSWR( + "/api/host-backups/remote-archives", + fetcher, + { refreshInterval: 60_000, revalidateOnFocus: false }, + ) + // Recovery state — only needed when at least one PBS destination + // exists. Drives the "Recover keyfile" banner and the "PBS encryption + // key" management block. + const hasPbs = (destinations?.pbs?.length ?? 0) > 0 + const { data: pbsRecoveryStatus, mutate: mutatePbsRecovery } = useSWR<{ + has_keyfile: boolean; has_recovery: boolean; has_keyfile_passphrase?: boolean; escrow_mode?: "none" | "local" | "full"; keyfile_fingerprint?: string + }>(hasPbs ? "/api/host-backups/pbs-recovery/status" : null, fetcher) + const { data: pbsRecoveryAvailable } = useSWR<{ + snapshots: Array<{ repo_name: string; repo_repository: string; backup_id: string; source_host: string; backup_time: number; snapshot: string }> + errors: Array<{ repo_name: string; error: string }> + }>( + hasPbs && pbsRecoveryStatus && !pbsRecoveryStatus.has_keyfile + ? "/api/host-backups/pbs-recovery/available" + : null, + fetcher, + ) + const [recoveringKeyfile, setRecoveringKeyfile] = useState(false) + const backupsCountFor = (it: UnifiedDest): number => { + if (it.kind === "local") { + const root = it.path.replace(/\/+$/, "") + "/" + return (localArchivesResp?.archives || []).filter( + (a) => (a.path || "").startsWith(root), + ).length + } + const name = it.kind === "pbs" ? it.name : it.kind === "borg" ? it.name : "" + return (remoteArchivesResp?.snapshots || []).filter( + (s) => s.backend === it.kind && s.repo_name === name, + ).length + } + + // Flatten PBS + Borg + Local into one list. Order: locals first + // (default always on top), then PBS, then Borg — matches the + // mental model of "what's already on this machine" before any + // remote target. + const items: UnifiedDest[] = (() => { + const out: UnifiedDest[] = [] + for (const e of destinations?.local?.entries || []) { + out.push({ + id: `local:${e.path}`, + kind: "local", + path: e.path, + source: e.source as "default" | "custom", + removable: e.removable, + jobsUsing: e.jobs_using || [], + }) + } + for (const r of destinations?.pbs || []) { + out.push({ + id: `pbs:${r.name}:${r.repository}`, + kind: "pbs", + name: r.name, + repository: r.repository, + source: r.source as "proxmox" | "manual", + fingerprint: r.fingerprint || undefined, + removable: r.source === "manual", + jobsUsing: r.jobs_using || [], + }) + } + for (const r of destinations?.borg || []) { + const ssh = parseBorgSsh(r.repository) + out.push({ + id: `borg:${r.name}`, + kind: "borg", + name: r.name, + repository: r.repository, + isSsh: !!ssh, + ssh: ssh ?? undefined, + sshKeyPath: (r as { ssh_key_path?: string }).ssh_key_path, + encryptMode: r.encrypt_mode, + hasPassphrase: r.has_passphrase, + removable: true, + jobsUsing: r.jobs_using || [], + }) + } + return out + })() + + // Build the capacity-probe payload + key for SWR. The key includes + // every destination's id so SWR re-fetches when the list changes + // (add / remove). 30 s refresh keeps the bars roughly live without + // hammering ssh / pbs every render. + const capacityTargets = items.map((it) => { + if (it.kind === "local") return { id: it.id, kind: "local", path: it.path } + if (it.kind === "pbs") return { id: it.id, kind: "pbs", name: it.name, repository: it.repository } + if (it.isSsh && it.ssh) { + return { + id: it.id, kind: "borg-ssh", + host: it.ssh.host, user: it.ssh.user, + remote_path: it.ssh.remotePath, + key_path: it.sshKeyPath || "", + } + } + return { id: it.id, kind: "borg-local", path: it.repository } + }) + const capacityKey = items.length + ? `/api/host-backups/destinations/capacity?keys=${items.map((i) => i.id).join(",")}` + : null + const { data: capacityResp } = useSWR<{ results: CapacityInfo[] }>( + capacityKey, + () => + fetchApi("/api/host-backups/destinations/capacity", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ targets: capacityTargets }), + }), + { refreshInterval: 30_000, revalidateOnFocus: false }, + ) + const capByEdid = new Map( + (capacityResp?.results || []).map((r) => [r.id, r] as const), + ) + + async function removePbs(name: string, force = false) { + setBusyKey(`pbs:${name}`) + setError(null) + try { + await fetchApi(`/api/host-backups/destinations/pbs/${encodeURIComponent(name)}${force ? "?force=true" : ""}`, { method: "DELETE" }) + onChanged() + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setBusyKey(null) + } + } + async function removeBorg(name: string, force = false) { + setBusyKey(`borg:${name}`) + setError(null) + try { + await fetchApi(`/api/host-backups/destinations/borg/${encodeURIComponent(name)}${force ? "?force=true" : ""}`, { method: "DELETE" }) + onChanged() + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setBusyKey(null) + } + } + async function removeLocal(path: string, force = false) { + setBusyKey(`local:${path}`) + setError(null) + try { + const params = new URLSearchParams({ path }) + if (force) params.set("force", "true") + await fetchApi( + `/api/host-backups/destinations/local?${params.toString()}`, + { method: "DELETE" }, + ) + onChanged() + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setBusyKey(null) + } + } + async function confirmDeleteDest() { + const it = confirmingDest + if (!it) return + const force = it.jobsUsing.length > 0 + setConfirmingDest(null) + if (it.kind === "pbs") return removePbs(it.name, force) + if (it.kind === "borg") return removeBorg(it.name, force) + if (it.kind === "local") return removeLocal(it.path, force) + } + // Detach a USB drive — only the filesystem is unmounted; the path + // stays in local-target.conf so re-plugging the same disk picks it + // up again without re-adding the destination. + async function unmountUsb(mountpoint: string) { + setBusyKey(`umount:${mountpoint}`) + setError(null) + try { + await fetchApi("/api/host-backups/usb-drives/unmount", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mountpoint }), + }) + onChanged() + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setBusyKey(null) + } + } + + return ( +
+
+ +
+ {error && ( +
+ {error} +
+ )} + {/* Recovery banner: only when there's no local keyfile AND at + least one escrow blob was found on a configured PBS. Click + opens a passphrase dialog and the keyfile is rebuilt from + the blob. Equivalent to hb_pbs_try_keyfile_recovery in the + shell — what runs after a fresh PVE install when the + operator points the new host at the same PBS. */} + {hasPbs && pbsRecoveryStatus && !pbsRecoveryStatus.has_keyfile && (pbsRecoveryAvailable?.snapshots?.length ?? 0) > 0 && ( + + )} + +
+ {items.map((it) => { + const cap = capByEdid.get(it.id) + const busy = busyKey === it.id || + (it.kind === "pbs" && busyKey === `pbs:${it.name}`) || + (it.kind === "borg" && busyKey === `borg:${it.name}`) || + (it.kind === "local" && busyKey === `local:${it.path}`) + // Borg local-mode repos sit on top of a path — the same + // /mnt/proxmenux-backup-disk-* USB mountpoint a Local target + // would use. Expose Unmount for them too so the operator + // doesn't have to remember which destination type is on the + // disk they want to detach. + const borgLocalUsbPath = it.kind === "borg" && !it.isSsh && cap?.is_usb + ? it.repository + : null + const umountTarget = it.kind === "local" ? it.path : borgLocalUsbPath + const unmountBusy = !!umountTarget && busyKey === `umount:${umountTarget}` + return ( + setConfirmingDest(it)} + onUnmount={ + umountTarget && cap?.is_usb + ? () => unmountUsb(umountTarget) + : undefined + } + onEdit={ + // Only PBS-manual and Borg are editable from the UI. + // PBS-proxmox is managed by Datacenter → Storage and + // local destinations have no fields beyond the path. + it.kind === "pbs" && it.source === "manual" + ? () => { + const [user, rest] = it.repository.includes("@") + ? [it.repository.split("@").slice(0, -1).join("@"), it.repository.split("@").pop() || ""] + : ["root@pam", it.repository] + const [server, datastore] = rest.includes(":") + ? [rest.split(":").slice(0, -1).join(":"), rest.split(":").pop() || ""] + : [rest, ""] + setEditingDest({ + kind: "pbs", + name: it.name, + username: user, + server, + datastore, + fingerprint: it.fingerprint, + has_password: true, + }) + } + : it.kind === "borg" + ? () => setEditingDest({ + kind: "borg", + name: it.name, + repository: it.repository, + ssh_key_path: it.sshKeyPath, + encrypt_mode: (destinations?.borg?.find((b) => b.name === it.name)?.encrypt_mode) || "repokey", + has_passphrase: !!destinations?.borg?.find((b) => b.name === it.name)?.has_passphrase, + }) + : undefined + } + /> + ) + })} +
+ setConfiguring(false)} + onSaved={() => { + setConfiguring(false) + onChanged() + }} + /> + {/* Edit-an-existing-destination dialog. Uses the same form as + Add, just hydrated with the saved values and with `name` + locked. The POST endpoint is upsert-by-name on the backend + so no separate route is needed. */} + setEditingDest(null)} + onSaved={() => { + setEditingDest(null) + onChanged() + }} + /> + setRecoveringKeyfile(false)} + onRecovered={() => { + setRecoveringKeyfile(false) + mutatePbsRecovery() + }} + /> + {/* Delete-destination confirm. Always opens (uniform UX) but + the body adapts: a plain "Remove?" when no jobs/backups + depend on the destination, or a warning summarizing the + cascade (jobs go, backups stay) when there's something at + stake. The destination + its sidecars are removed; backups + on disk / on the remote stay untouched so the operator can + decide what to do with them later. */} + {confirmingDest && (() => { + const it = confirmingDest + const jobs = it.jobsUsing + const backups = backupsCountFor(it) + const headline = + it.kind === "local" ? it.path + : it.kind === "pbs" ? `${it.name} — ${it.repository}` + : `${it.name} — ${it.repository}` + return ( + { if (!v) setConfirmingDest(null) }}> + + + + 0 ? "text-amber-500" : "text-red-500"}`} /> + Remove destination + + + {jobs.length === 0 && backups === 0 + ? "Removes the destination from the list. Nothing else depends on it." + : "The destination + its saved credentials will be removed. Backups already stored stay where they are — you decide what to do with them later."} + + +
+ {headline} +
+ {(jobs.length > 0 || backups > 0) && ( +
+ {jobs.length > 0 && ( +
+
+ {jobs.length} job{jobs.length === 1 ? "" : "s"} will be deleted (cascade): +
+
+ {jobs.map((j) => ( + + {j} + + ))} +
+
+ )} + {backups > 0 && ( +
+ {backups} backup{backups === 1 ? "" : "s"} already at this destination — they are kept. +
+ )} +
+ )} +
+ + +
+
+
+ ) + })()} +
+ ) +} + +// Single row in the unified destinations list. Layout mirrors the +// disk-card chrome from storage-overview.tsx: icon + headline + all +// badges + actions on ONE top row, capacity bar in storage-page blue, +// stats grid below. No hover effect — these rows are inert (no modal +// behind them), so the contrast change would only confuse the eye. +function DestinationRow({ + item, + capacity, + busy, + unmountBusy, + onDelete, + onUnmount, + onEdit, +}: { + item: UnifiedDest + capacity?: CapacityInfo + busy: boolean + unmountBusy: boolean + onDelete: () => void + onUnmount?: () => void + onEdit?: () => void +}) { + const accent = + item.kind === "pbs" ? "bg-purple-500/10 text-purple-400 border-purple-500/20" + : item.kind === "borg" ? "bg-fuchsia-500/10 text-fuchsia-400 border-fuchsia-500/20" + : "bg-blue-500/10 text-blue-400 border-blue-500/20" + const iconColor = + item.kind === "pbs" ? "text-purple-400" + : item.kind === "borg" ? "text-fuchsia-400" + : "text-blue-400" + const Icon = item.kind === "pbs" ? Server : item.kind === "borg" ? Archive : HardDrive + const headline = + item.kind === "pbs" ? item.name + : item.kind === "borg" ? item.name + : item.path + const subline = + item.kind === "pbs" ? item.repository + : item.kind === "borg" ? item.repository + : null + const isUsb = !!capacity?.is_usb || (item.kind === "borg" && !item.isSsh && /\/(?:mnt|media)\//.test(item.repository)) + const sourceLabel = + item.kind === "local" ? (item.source === "default" ? "built-in default" : "manually added") + : item.kind === "pbs" ? (item.source === "proxmox" ? "Datacenter → Storage" : "manually added") + // Borg targets are always operator-configured (no Datacenter + // auto-discovery path), so they all share the "manually added" + // wording for visual consistency with the Local custom row. + : "manually added" + + const pct = capacity?.total && capacity.available !== undefined + ? Math.min(100, Math.round(((capacity.total - capacity.available) / capacity.total) * 100)) + : null + + return ( +
+ {/* Top row split in two: a wrappable left side (icon + headline + + badges) and a fixed right side (Unmount + Remove). The right + side never falls to a new line even if `headline` is long, + because the outer container is a non-wrapping flex. */} +
+
+
+ +

{headline}

+ + {item.kind} + + {item.kind === "local" && item.source === "default" && ( + + default + + )} + {item.kind === "borg" ? ( + // For Borg the sub-type is one of three (mutually exclusive): + // ssh → remote repo + // usb → local repo whose path lives on a USB mount + // local → local repo on an internal disk / regular dir + // Previously we showed BORG + LOCAL + USB which read like + // three orthogonal facets and confused the badge with the + // Local destination kind. Collapsing to a single sub-type + // badge avoids the overlap. + <> + {item.isSsh ? ( + + ssh + + ) : isUsb ? ( + + + USB + + ) : ( + + local + + )} + {/* Encryption indicator — icon-only chip for encrypted + repos. Plaintext repos get no chip (visually quiet + for the common case). The tooltip carries the mode + + saved-passphrase state for hover detail. */} + {item.encryptMode && item.encryptMode !== "none" && ( + + {/* Icon sized to roughly the text-[10px] line-box of + the neighbouring text badges (BORG, USB, …) so the + pill itself ends up the same height — matching + visual weight without adding a label. */} + + + )} + + ) : isUsb && ( + // PBS / Local destinations: USB badge is additive (the kind + // badge alone doesn't say where the path lives). + + + USB + + )} +
+ {/* Action chips — always pinned to the right, never wrap. + The outer flex container has no flex-wrap so even a very + long headline keeps these two buttons in place. Chip + style (square border + tinted bg) makes them read as + actionable rather than decorative icons. */} +
+ {onEdit && ( + + )} + {onUnmount && ( + + )} + {item.removable ? ( + + ) : ( + + {item.kind === "local" ? "built-in" : "managed by PVE"} + + )} +
+
+ {subline && ( +

+ {subline} +

+ )} +
+ + {/* Capacity bar — matches the height of the component + used on the Storage page (h-2). Bar colour comes from the + shared storage palette so a 100% PBS datastore flags red + here too, not just on the Storage page. */} + {capacity?.total && capacity.available !== undefined && ( +
+
+
+ )} + + {/* Stats grid */} +
+ {capacity?.total !== undefined && ( +
+

Size

+

{formatBytes(capacity.total)}

+
+ )} + {capacity?.used !== undefined && capacity.total !== undefined && ( +
+

Used

+

+ {formatBytes(capacity.used)} + {pct !== null && ({pct}%)} +

+
+ )} + {capacity?.available !== undefined && ( +
+

Free

+

+ {formatBytes(capacity.available)} +

+
+ )} +
+

Source

+

{sourceLabel}

+
+
+ + {capacity?.error && ( +

+ Capacity unavailable: {capacity.error} +

+ )} + {!capacity && ( +

+ Probing capacity… +

+ )} + {/* Keyfile actions live inside each PBS row — the encryption key + is host-wide but conceptually belongs to PBS usage, so the + Download / Upload / Delete controls sit next to the + destination(s) they support. */} + {item.kind === "pbs" && } +
+ ) +} + +// Single-button wizard that replaces the old per-backend Add buttons. +// Step 1 lets the operator pick the backend; Step 2 hands off to the +// existing AddDestinationDialog with that backend pre-selected, so we +// don't duplicate the per-backend forms. +function ConfigureDestinationWizard({ + open, + onClose, + onSaved, +}: { + open: boolean + onClose: () => void + onSaved: () => void +}) { + const [picked, setPicked] = useState<"pbs" | "local" | "borg" | null>(null) + useEffect(() => { + if (!open) setPicked(null) + }, [open]) + + if (picked) { + // Hand off to the existing form. Closing or saving collapses the + // whole wizard so the operator lands back in the destinations list. + return ( + { setPicked(null); onClose() }} + onSaved={() => { setPicked(null); onSaved() }} + /> + ) + } + + return ( + { if (!v) onClose() }}> + + + + + Configure destination + + + Pick the backend you want to add. The next step has the credentials and path fields for that backend only. + + +
+ {([ + { type: "pbs", label: "PBS", accent: "border-purple-500/40 hover:border-purple-400 text-purple-400", blurb: "Proxmox Backup Server. Incremental, encrypted, dedup." }, + { type: "local", label: "Local", accent: "border-blue-500/40 hover:border-blue-400 text-blue-400", blurb: "tar.zst archive into a local directory or mounted disk." }, + { type: "borg", label: "Borg", accent: "border-fuchsia-500/40 hover:border-fuchsia-400 text-fuchsia-400", blurb: "Borg repo over SSH or on a local / USB disk." }, + ] as const).map((opt) => ( + + ))} +
+
+ +
+
+
+ ) +} + +// One dialog handles all three Add flows — the form is small enough +// that branching by type keeps it cleaner than three separate components. +// Pre-fill payload for editing an existing destination. `name` stays +// fixed (it's the key the sidecar / state files are scoped to); +// everything else can be re-typed. PBS password and Borg passphrase +// can be left blank to keep the saved one. +interface EditingDest { + kind: "pbs" | "borg" + name: string + // PBS fields + server?: string + datastore?: string + username?: string + fingerprint?: string + has_password?: boolean + // Borg fields + repository?: string + ssh_key_path?: string + encrypt_mode?: "none" | "repokey" | "keyfile" | "authenticated" + has_passphrase?: boolean +} + +function AddDestinationDialog({ + type, + editing, + onClose, + onSaved, +}: { + type: "pbs" | "borg" | "local" | null + editing?: EditingDest | null + onClose: () => void + // The repository string of the just-saved destination, so callers + // can auto-select it in a dropdown (the wizard does this when an + // operator adds a PBS / Borg repo inline). Undefined for `local` + // and for the legacy callers that don't care. + onSaved: (repository?: string) => void +}) { + const [name, setName] = useState("") + // PBS fields + const [server, setServer] = useState("") + const [datastore, setDatastore] = useState("") + const [username, setUsername] = useState("root@pam") + const [password, setPassword] = useState("") + const [fingerprint, setFingerprint] = useState("") + // Borg fields (local mode) + const [borgRepo, setBorgRepo] = useState("") + // Borg SSH mode + const [borgMode, setBorgMode] = useState<"local" | "ssh">("local") + const [borgSshUser, setBorgSshUser] = useState("borg") + const [borgSshHost, setBorgSshHost] = useState("") + const [borgSshRemotePath, setBorgSshRemotePath] = useState("") + const [borgSshKeyPath, setBorgSshKeyPath] = useState("/root/.ssh/proxmenux_borg") + const [generatedKey, setGeneratedKey] = useState<{ public_key: string; authorized_keys_line: string } | null>(null) + const [generatingKey, setGeneratingKey] = useState(false) + // Borg encryption + passphrase live on the destination, not on each + // job. Two-mode UI: encrypted (repokey, the default) or none. Other + // borg modes (keyfile / authenticated) stay supported by the backend + // for legacy shell-created configs. + const [borgEncryptionEnabled, setBorgEncryptionEnabled] = useState(true) + const [borgPassphrase, setBorgPassphraseLocal] = useState("") + const [borgPassphrase2, setBorgPassphrase2] = useState("") + // Local field + const [localPath, setLocalPath] = useState("") + + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + // Read the current destinations so the UsbPicker can hide any USB + // mountpoint already in use as a Local or Borg-local destination — + // no point offering the same disk twice. + const { data: existingDest } = useSWR( + type !== null ? "/api/host-backups/destinations" : null, + fetcher, + ) + const usedPaths: string[] = (() => { + const out: string[] = [] + for (const e of existingDest?.local?.entries || []) { + if (e.source === "custom") out.push(e.path) + } + for (const r of existingDest?.borg || []) { + // Borg local-mode: the `repository` IS the path. SSH repos + // start with `ssh://` and are excluded automatically. + if (!r.repository.startsWith("ssh://")) out.push(r.repository) + } + return out + })() + + // Reset every time the dialog OPENS — not just on close. If we're + // opening in edit mode, hydrate the form from the saved destination + // so the operator only has to change what they want. Name stays + // read-only in edit mode (it's the key for sidecars / state files). + useEffect(() => { + setError(null) + setSubmitting(false) + setGeneratedKey(null) + setGeneratingKey(false) + setPassword("") + setBorgPassphraseLocal("") + setBorgPassphrase2("") + if (editing && editing.kind === "pbs") { + setName(editing.name) + setServer(editing.server || "") + setDatastore(editing.datastore || "") + setUsername(editing.username || "root@pam") + setFingerprint(editing.fingerprint || "") + setBorgRepo(""); setBorgMode("local"); setBorgSshUser("borg") + setBorgSshHost(""); setBorgSshRemotePath("") + setBorgSshKeyPath("/root/.ssh/proxmenux_borg") + setBorgEncryptionEnabled(true); setLocalPath("") + return + } + if (editing && editing.kind === "borg") { + const repo = editing.repository || "" + const ssh = repo.match(/^ssh:\/\/([^@]+)@([^/]+)\/(.+)$/) + setName(editing.name) + if (ssh) { + setBorgMode("ssh") + setBorgSshUser(ssh[1]) + setBorgSshHost(ssh[2]) + setBorgSshRemotePath(`/${ssh[3]}`) + setBorgSshKeyPath(editing.ssh_key_path || "/root/.ssh/proxmenux_borg") + setBorgRepo("") + } else { + setBorgMode("local") + setBorgRepo(repo) + setBorgSshUser("borg"); setBorgSshHost(""); setBorgSshRemotePath("") + setBorgSshKeyPath("/root/.ssh/proxmenux_borg") + } + const mode = editing.encrypt_mode || "repokey" + setBorgEncryptionEnabled(mode !== "none") + setServer(""); setDatastore(""); setUsername("root@pam"); setFingerprint("") + setLocalPath("") + return + } + // Add-new path: full reset. + setName("") + setServer("") + setDatastore("") + setUsername("root@pam") + setFingerprint("") + setBorgRepo("") + setBorgMode("local") + setBorgSshUser("borg") + setBorgSshHost("") + setBorgSshRemotePath("") + setBorgSshKeyPath("/root/.ssh/proxmenux_borg") + setBorgEncryptionEnabled(true) + setLocalPath("") + }, [type, editing]) + + const isEditing = !!editing + const nameValid = /^[a-zA-Z0-9_-]+$/.test(name) + const borgPathValid = + borgMode === "local" + ? !!borgRepo.trim() + : !!(borgSshUser.trim() && borgSshHost.trim() && borgSshRemotePath.trim()) + // In edit mode the saved passphrase / password is reused when the + // operator leaves the inputs blank, so validation relaxes. + const borgPassphraseValid = + !borgEncryptionEnabled + || (isEditing && !borgPassphrase && editing?.has_passphrase) + || (borgPassphrase.length > 0 && borgPassphrase === borgPassphrase2) + const borgValid = borgPathValid && borgPassphraseValid + const canSubmit = + type === "pbs" + ? nameValid && server.trim() && datastore.trim() && + (!!password || (isEditing && !!editing?.has_password)) + : type === "borg" + ? nameValid && borgValid + : type === "local" + ? localPath.trim().startsWith("/") + : false + + async function generateBorgKey() { + setGeneratingKey(true) + setError(null) + try { + const resp = await fetchApi<{ public_key: string; authorized_keys_line: string }>( + "/api/host-backups/ssh-keys/generate", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + key_path: borgSshKeyPath.trim() || "/root/.ssh/proxmenux_borg", + remote_path: borgSshRemotePath.trim(), + }), + } + ) + setGeneratedKey(resp) + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setGeneratingKey(false) + } + } + + async function handleSave() { + if (!canSubmit) return + setSubmitting(true) + setError(null) + try { + let savedRepo: string | undefined + if (type === "pbs") { + const resp = await fetchApi<{ repository?: string }>("/api/host-backups/destinations/pbs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name, + server: server.trim(), + datastore: datastore.trim(), + username: username.trim() || "root@pam", + password, + fingerprint: fingerprint.trim() || undefined, + }), + }) + savedRepo = resp?.repository + } else if (type === "borg") { + const body: Record = { + name, + mode: borgMode, + encrypt_mode: borgEncryptionEnabled ? "repokey" : "none", + } + if (borgEncryptionEnabled) { + body.passphrase = borgPassphrase + } + if (borgMode === "local") { + body.repo = borgRepo.trim() + } else { + body.ssh_user = borgSshUser.trim() + body.ssh_host = borgSshHost.trim() + body.ssh_remote_path = borgSshRemotePath.trim() + if (borgSshKeyPath.trim()) body.ssh_key_path = borgSshKeyPath.trim() + } + const resp = await fetchApi<{ repo?: string }>("/api/host-backups/destinations/borg", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + savedRepo = resp?.repo + } else if (type === "local") { + await fetchApi("/api/host-backups/destinations/local", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: localPath.trim() }), + }) + } + onSaved(savedRepo) + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setSubmitting(false) + } + } + + return ( + { if (!v) onClose() }}> + + + + {isEditing ? ( + + ) : ( + + )} + {isEditing ? "Edit" : "Add"} {type === "pbs" ? "PBS" : type === "borg" ? "Borg" : "local"} destination + + +
+ {type === "pbs" && ( + <> +
+ + setName(e.target.value)} className="font-mono mt-1" placeholder="my-pbs" disabled={isEditing} /> +

+ {isEditing ? "Name is the key for saved credentials and can't be changed." : "A short identifier. Letters, digits, _ or -."} +

+
+
+ + setServer(e.target.value)} className="font-mono mt-1" placeholder="192.168.1.10" /> +
+
+ + setDatastore(e.target.value)} className="font-mono mt-1" placeholder="pbs" /> +
+
+ + setUsername(e.target.value)} className="font-mono mt-1" /> +

User that runs proxmox-backup-client. Default root@pam.

+
+
+ + setPassword(e.target.value)} + className="font-mono mt-1" + placeholder={isEditing && editing?.has_password ? "(unchanged — type to replace)" : ""} + /> + {isEditing && editing?.has_password && ( +

Leave blank to keep the saved password.

+ )} +
+
+ + setFingerprint(e.target.value)} className="font-mono mt-1" placeholder="aa:bb:cc:…" /> +

Required only if the PBS uses a self-signed certificate.

+
+ + )} + {type === "borg" && ( + <> +
+ + setName(e.target.value)} className="font-mono mt-1" placeholder="usb-borg or remote-borg" disabled={isEditing} /> + {isEditing && ( +

Name is the key for the saved passphrase and can't be changed.

+ )} +
+ +
+ +
+ + +
+
+ + {borgMode === "local" ? ( +
+
+ + setBorgRepo(e.target.value)} className="font-mono mt-1" placeholder="/mnt/usb-disk/borgbackup" /> +

+ Absolute path on this host. Pick a USB drive below to auto-fill, or type the path manually. +

+
+ setBorgRepo(p)} excludePaths={usedPaths} /> +
+ ) : ( + <> +
+ + setBorgSshUser(e.target.value)} className="font-mono mt-1" placeholder="borg" /> +

+ User on the remote host that runs borg serve. Conventionally borg, not root. +

+
+
+ + setBorgSshHost(e.target.value)} className="font-mono mt-1" placeholder="backup.example.com" /> +
+
+ + setBorgSshRemotePath(e.target.value)} className="font-mono mt-1" placeholder="/backup/borgbackup" /> +
+
+ + setBorgSshKeyPath(e.target.value)} className="font-mono mt-1" /> +

+ If you already have an SSH key registered with the server, point to it here. + Otherwise click Generate key below and paste the line shown into the server's authorized_keys. +

+
+ +
+
+ Generate a new SSH key + +
+ {generatedKey ? ( + <> +

+ On the Borg server, append this single line to ~{borgSshUser}/.ssh/authorized_keys: +

+