mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 10:22:08 +00:00
Merge pull request #467 from jcastro/feature/pbs-support
Support PBS hosts and remote PBS monitoring
This commit is contained in:
@@ -4,9 +4,13 @@ lives: the scripts it launches are the ones the menus launch."""
|
|||||||
|
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
import runpy
|
import runpy
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[3]
|
ROOT = Path(__file__).resolve().parents[3]
|
||||||
@@ -42,11 +46,13 @@ class IndexTests(unittest.TestCase):
|
|||||||
self.assertEqual(unknown, [])
|
self.assertEqual(unknown, [])
|
||||||
self.assertEqual([item["menu"] for item in self.index if item["menu"] not in self.spanish], [])
|
self.assertEqual([item["menu"] for item in self.index if item["menu"] not in self.spanish], [])
|
||||||
|
|
||||||
def test_every_script_a_menu_launches_can_be_found(self):
|
def test_every_script_a_pve_menu_launches_can_be_found(self):
|
||||||
indexed = {item["script"] for item in self.index}
|
indexed = {item["script"] for item in self.index}
|
||||||
launched = set()
|
launched = set()
|
||||||
for menu in sorted((SCRIPTS / "menus").glob("*.sh")):
|
for menu in sorted((SCRIPTS / "menus").glob("*.sh")):
|
||||||
if menu.name in ("main_menu.sh", "lxc_menu.sh"):
|
# PBS replaces the PVE main menu and does not use its search.
|
||||||
|
# The exclusive platform dispatch is executed in the test below.
|
||||||
|
if menu.name in ("main_menu.sh", "lxc_menu.sh", "pbs_menu.sh"):
|
||||||
continue
|
continue
|
||||||
launched |= set(re.findall(r'bash "\$LOCAL_SCRIPTS/([A-Za-z0-9_./-]+\.sh)"', menu.read_text()))
|
launched |= set(re.findall(r'bash "\$LOCAL_SCRIPTS/([A-Za-z0-9_./-]+\.sh)"', menu.read_text()))
|
||||||
launched -= {"menus/main_menu.sh"}
|
launched -= {"menus/main_menu.sh"}
|
||||||
@@ -54,6 +60,27 @@ class IndexTests(unittest.TestCase):
|
|||||||
launched -= {"post_install/auto_post_install.sh", "utilities/proxmox_update.sh", "utilities/upgrade_pve8_to_pve9.sh"}
|
launched -= {"post_install/auto_post_install.sh", "utilities/proxmox_update.sh", "utilities/upgrade_pve8_to_pve9.sh"}
|
||||||
self.assertEqual(sorted(launched - indexed), [])
|
self.assertEqual(sorted(launched - indexed), [])
|
||||||
|
|
||||||
|
def test_pbs_replaces_the_searchable_pve_menu(self):
|
||||||
|
menu = (SCRIPTS / "menus/main_menu.sh").read_text()
|
||||||
|
dispatch = menu[menu.rindex('source "$LOCAL_SCRIPTS/global/platform-functions.sh"'):]
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
scripts = Path(directory)
|
||||||
|
(scripts / "global").mkdir()
|
||||||
|
(scripts / "menus").mkdir()
|
||||||
|
helper = scripts / "global/platform-functions.sh"
|
||||||
|
helper.write_text(
|
||||||
|
"source " + shlex.quote(str(SCRIPTS / "global/platform-functions.sh")) + "\n"
|
||||||
|
'pmx_platform_field() { printf "%s" "$TEST_PROXMENUX_PRODUCT"; }\n')
|
||||||
|
(scripts / "menus/pbs_menu.sh").write_text('printf "PBS menu\\n"\n')
|
||||||
|
harness = 'show_menu() { printf "PVE search menu\\n"; }\n' + dispatch
|
||||||
|
for product, expected in (("pve", "PVE search menu"), ("pbs", "PBS menu")):
|
||||||
|
with self.subTest(product=product):
|
||||||
|
result = subprocess.run(["bash", "-e", "-c", harness], text=True, capture_output=True,
|
||||||
|
env={**os.environ, "LOCAL_SCRIPTS": str(scripts),
|
||||||
|
"TEST_PROXMENUX_PRODUCT": product})
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertEqual(result.stdout.strip(), expected)
|
||||||
|
|
||||||
def test_the_main_menu_offers_the_search_and_opens_what_it_hands_back(self):
|
def test_the_main_menu_offers_the_search_and_opens_what_it_hands_back(self):
|
||||||
menu = (SCRIPTS / "menus/main_menu.sh").read_text()
|
menu = (SCRIPTS / "menus/main_menu.sh").read_text()
|
||||||
self.assertIn('search "$(translate "Search options and applications")"', menu)
|
self.assertIn('search "$(translate "Search options and applications")"', menu)
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
name: PBS support
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- '.github/workflows/test-pbs.yml'
|
||||||
|
- 'AppImage/scripts/*pbs*.py'
|
||||||
|
- 'AppImage/scripts/tests/test_pbs.py'
|
||||||
|
- 'AppImage/scripts/health_monitor.py'
|
||||||
|
- 'AppImage/scripts/auth_manager.py'
|
||||||
|
- 'AppImage/scripts/build_appimage.sh'
|
||||||
|
- 'AppImage/components/pbs-*.tsx'
|
||||||
|
- 'AppImage/components/proxmox-dashboard.tsx'
|
||||||
|
- 'AppImage/tests/pbs-navigation.cjs'
|
||||||
|
- 'scripts/global/proxmox_platform.py'
|
||||||
|
- 'scripts/global/platform-functions.sh'
|
||||||
|
- 'scripts/global/repository-functions.sh'
|
||||||
|
- 'scripts/menus/pbs_menu.sh'
|
||||||
|
- 'scripts/utilities/pbs_update.sh'
|
||||||
|
push:
|
||||||
|
branches: [main, develop]
|
||||||
|
paths:
|
||||||
|
- 'AppImage/scripts/*pbs*.py'
|
||||||
|
- 'scripts/global/proxmox_platform.py'
|
||||||
|
- '.github/workflows/test-pbs.yml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
pbs:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
python: ['3.11', '3.13']
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python }}
|
||||||
|
- name: Install test dependencies
|
||||||
|
run: python -m pip install Flask PyJWT cryptography pyotp segno
|
||||||
|
- name: Platform, real TLS, APIs, health and AppImage packaging
|
||||||
|
run: |
|
||||||
|
python -B -m unittest discover -s AppImage/scripts/tests -p test_pbs.py -v
|
||||||
|
python -B -m unittest discover -s AppImage/scripts/tests -p test_auth_manager_ssl_reload.py -v
|
||||||
|
for script in scripts/global/platform-functions.sh scripts/global/repository-functions.sh scripts/menus/pbs_menu.sh scripts/utilities/pbs_update.sh scripts/menus/main_menu.sh scripts/post_install/auto_post_install.sh scripts/post_install/customizable_post_install.sh; do
|
||||||
|
bash -n "$script"
|
||||||
|
done
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
if: matrix.python == '3.11'
|
||||||
|
with:
|
||||||
|
node-version: '22.14.0'
|
||||||
|
- name: Dashboard platform routing
|
||||||
|
if: matrix.python == '3.11'
|
||||||
|
run: |
|
||||||
|
npm ci --prefix .github/ci/offline-node --legacy-peer-deps --ignore-scripts --no-audit --no-fund
|
||||||
|
NODE_PATH="$PWD/.github/ci/offline-node/node_modules" node AppImage/tests/pbs-navigation.cjs
|
||||||
@@ -7,10 +7,11 @@ import { useState } from "react"
|
|||||||
import { DatabaseBackup } from "lucide-react"
|
import { DatabaseBackup } from "lucide-react"
|
||||||
import { HostBackup } from "./host-backup"
|
import { HostBackup } from "./host-backup"
|
||||||
import { VmBackupJobs } from "./vm-backup-jobs"
|
import { VmBackupJobs } from "./vm-backup-jobs"
|
||||||
|
import { PBSPanel } from "./pbs-panel"
|
||||||
import { BackupStorageRetention } from "./backup-storage-retention"
|
import { BackupStorageRetention } from "./backup-storage-retention"
|
||||||
import { useT } from "../lib/i18n/provider"
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
|
||||||
type BackupView = "host" | "guests"
|
type BackupView = "host" | "guests" | "pbs"
|
||||||
|
|
||||||
export function BackupTab() {
|
export function BackupTab() {
|
||||||
const t = useT()
|
const t = useT()
|
||||||
@@ -30,7 +31,7 @@ export function BackupTab() {
|
|||||||
className="flex w-full rounded-lg border border-border bg-muted/40 p-1 gap-1
|
className="flex w-full rounded-lg border border-border bg-muted/40 p-1 gap-1
|
||||||
sm:inline-flex sm:w-auto"
|
sm:inline-flex sm:w-auto"
|
||||||
>
|
>
|
||||||
{(["host", "guests"] as const).map((key) => (
|
{(["host", "guests", "pbs"] as const).map((key) => (
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -42,7 +43,7 @@ export function BackupTab() {
|
|||||||
: "text-muted-foreground hover:text-foreground hover:bg-background/60"
|
: "text-muted-foreground hover:text-foreground hover:bg-background/60"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t(`backup.viewSwitch.${key}`)}
|
{key === "pbs" ? t("pbs.title") : t(`backup.viewSwitch.${key}`)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -53,6 +54,7 @@ export function BackupTab() {
|
|||||||
<HostBackup />
|
<HostBackup />
|
||||||
</div>
|
</div>
|
||||||
{view === "guests" && <><VmBackupJobs /><BackupStorageRetention /></>}
|
{view === "guests" && <><VmBackupJobs /><BackupStorageRetention /></>}
|
||||||
|
{view === "pbs" && <PBSPanel />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import useSWR from "swr"
|
||||||
|
import { PBSPanel } from "./pbs-panel"
|
||||||
|
import { Security } from "./security"
|
||||||
|
import { SystemLogs } from "./system-logs"
|
||||||
|
import { TerminalPanel } from "./terminal-panel"
|
||||||
|
import { ThemeToggle } from "./theme-toggle"
|
||||||
|
import { Button } from "./ui/button"
|
||||||
|
import { Card, CardContent } from "./ui/card"
|
||||||
|
import { fetchApi } from "../lib/api-config"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
|
||||||
|
interface Vitals { hostname: string; cpu_usage: number; memory_usage: number; memory_used: number; memory_total: number; uptime: string }
|
||||||
|
|
||||||
|
export function PBSDashboard() {
|
||||||
|
const t = useT()
|
||||||
|
const [tab, setTab] = useState("overview")
|
||||||
|
const { data } = useSWR<Vitals>("/api/system", url => fetchApi<Vitals>(url), { refreshInterval: 10000 })
|
||||||
|
return <div className="container mx-auto max-w-7xl space-y-6 px-4 py-6">
|
||||||
|
<header className="flex flex-wrap items-center gap-3 border-b pb-4">
|
||||||
|
<div className="mr-auto"><h1 className="text-2xl font-bold">ProxMenux · PBS</h1><p className="text-sm text-muted-foreground">{data?.hostname || t("pbs.loading")}</p></div>
|
||||||
|
<ThemeToggle />
|
||||||
|
<Button variant="outline" onClick={() => { localStorage.removeItem("proxmenux-auth-token"); window.location.reload() }}>{t("pbs.logout")}</Button>
|
||||||
|
</header>
|
||||||
|
<nav aria-label={t("pbs.navigation")} className="flex flex-wrap gap-2">
|
||||||
|
{(["overview", "logs", "terminal", "security"] as const).map(key =>
|
||||||
|
<Button key={key} variant={tab === key ? "default" : "outline"} onClick={() => setTab(key)}>{t(`pbs.${key}`)}</Button>)}
|
||||||
|
</nav>
|
||||||
|
{tab === "overview" && <>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
{[[t("pbs.cpu"), data ? `${data.cpu_usage.toFixed(1)}%` : "—"],
|
||||||
|
[t("pbs.memory"), data ? `${data.memory_used.toFixed(1)} / ${data.memory_total.toFixed(1)} GiB (${data.memory_usage.toFixed(1)}%)` : "—"],
|
||||||
|
[t("pbs.uptime"), data?.uptime || "—"]].map(([label, value]) =>
|
||||||
|
<Card key={label}><CardContent className="p-4"><p className="text-xs text-muted-foreground">{label}</p><p className="mt-1 text-lg font-semibold">{value}</p></CardContent></Card>)}
|
||||||
|
</div>
|
||||||
|
<PBSPanel />
|
||||||
|
</>}
|
||||||
|
{tab === "logs" && <SystemLogs />}
|
||||||
|
{tab === "terminal" && <TerminalPanel />}
|
||||||
|
{tab === "security" && <Security hostType="pbs" />}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import useSWR from "swr"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
|
||||||
|
import { Button } from "./ui/button"
|
||||||
|
import { Input } from "./ui/input"
|
||||||
|
import { Label } from "./ui/label"
|
||||||
|
import { fetchApi } from "../lib/api-config"
|
||||||
|
import { formatBytes } from "../lib/utils"
|
||||||
|
import { useT } from "../lib/i18n/provider"
|
||||||
|
|
||||||
|
interface Connection { id: string; name: string; url: string; token_id: string; fingerprint: string; has_token: boolean }
|
||||||
|
interface Connections { local_available: boolean; servers: Connection[] }
|
||||||
|
interface Datastore { name: string; path?: string; total?: number | null; used?: number | null; avail?: number | null; error?: string }
|
||||||
|
interface Task { upid: string; worker_type?: string; worker_id?: string; starttime?: number; endtime?: number; status?: string }
|
||||||
|
interface Job { id: string; store?: string; schedule?: string; "last-run-state"?: string }
|
||||||
|
interface PBSStatus {
|
||||||
|
name: string; version: string | null; fetched_at: number; services: Record<string, string>;
|
||||||
|
datastores: Datastore[] | null; tasks: Task[] | null;
|
||||||
|
sync: Job[] | null; verify: Job[] | null; prune: Job[] | null; errors: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyForm = { id: "", name: "", url: "", token_id: "", fingerprint: "", token_secret: "" }
|
||||||
|
const selectClass = "h-10 rounded-md border bg-background px-3 text-sm"
|
||||||
|
|
||||||
|
export function PBSPanel() {
|
||||||
|
const t = useT()
|
||||||
|
const { data: connections, error: connectionError, mutate: reloadConnections } = useSWR<Connections>(
|
||||||
|
"/api/pbs/servers", url => fetchApi<Connections>(url), { revalidateOnFocus: false })
|
||||||
|
const [selected, setSelected] = useState("")
|
||||||
|
const [manage, setManage] = useState(false)
|
||||||
|
const [form, setForm] = useState(emptyForm)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [message, setMessage] = useState("")
|
||||||
|
const [error, setError] = useState("")
|
||||||
|
const { data, error: statusError, isLoading, mutate } = useSWR<PBSStatus>(
|
||||||
|
selected ? `/api/pbs/status?server=${encodeURIComponent(selected)}` : null,
|
||||||
|
url => fetchApi<PBSStatus>(url), { refreshInterval: 30000, revalidateOnFocus: false })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!connections) return
|
||||||
|
if (selected === "local" && connections.local_available) return
|
||||||
|
if (connections.servers.some(row => row.id === selected)) return
|
||||||
|
setSelected(connections.local_available ? "local" : connections.servers[0]?.id || "")
|
||||||
|
}, [connections, selected])
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setBusy(true); setError(""); setMessage("")
|
||||||
|
try {
|
||||||
|
const result = await fetchApi<{ id: string }>("/api/pbs/servers", { method: "POST", body: JSON.stringify({
|
||||||
|
...form, id: form.id || undefined, token_secret: form.token_secret || undefined,
|
||||||
|
}) })
|
||||||
|
await reloadConnections()
|
||||||
|
setSelected(result.id)
|
||||||
|
setForm(emptyForm)
|
||||||
|
setMessage(t("pbs.saved"))
|
||||||
|
} catch (err) { setError(err instanceof Error ? err.message : t("pbs.saveFailed")) }
|
||||||
|
finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const remove = async () => {
|
||||||
|
if (!form.id || !window.confirm(t("pbs.removeConfirm"))) return
|
||||||
|
setBusy(true); setError(""); setMessage("")
|
||||||
|
try {
|
||||||
|
await fetchApi(`/api/pbs/servers/${encodeURIComponent(form.id)}`, { method: "DELETE" })
|
||||||
|
setForm(emptyForm)
|
||||||
|
await reloadConnections()
|
||||||
|
} catch (err) { setError(err instanceof Error ? err.message : t("pbs.saveFailed")) }
|
||||||
|
finally { setBusy(false) }
|
||||||
|
}
|
||||||
|
const capacity = (value?: number | null) => value == null ? "—" : formatBytes(value)
|
||||||
|
|
||||||
|
return <div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<h2 className="text-xl font-semibold">{t("pbs.title")}</h2>
|
||||||
|
<select aria-label={t("pbs.server")} className={`${selectClass} sm:ml-auto`} value={selected}
|
||||||
|
onChange={event => setSelected(event.target.value)}>
|
||||||
|
{!selected && <option value="">{t("pbs.chooseServer")}</option>}
|
||||||
|
{connections?.local_available && <option value="local">{t("pbs.local")}</option>}
|
||||||
|
{connections?.servers.map(row => <option key={row.id} value={row.id}>{row.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<Button variant="outline" disabled={isLoading || !selected} onClick={() => mutate()}>{t("pbs.refresh")}</Button>
|
||||||
|
<Button variant="outline" onClick={() => setManage(!manage)}>{t("pbs.manage")}</Button>
|
||||||
|
</div>
|
||||||
|
{(connectionError || statusError) && <p role="alert" className="text-sm text-red-500">
|
||||||
|
{String((connectionError || statusError)?.message || t("pbs.loadFailed"))}</p>}
|
||||||
|
{isLoading && <p role="status" className="text-sm text-muted-foreground">{t("pbs.loading")}</p>}
|
||||||
|
{!selected && !connectionError && connections && <p className="text-sm text-muted-foreground">{t("pbs.noServer")}</p>}
|
||||||
|
{manage && <Card>
|
||||||
|
<CardHeader><CardTitle>{t("pbs.manage")}</CardTitle></CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">{t("pbs.permissionsHelp")}</p>
|
||||||
|
<select aria-label={t("pbs.editConnection")} className={`${selectClass} w-full`} value={form.id}
|
||||||
|
onChange={event => {
|
||||||
|
const row = connections?.servers.find(item => item.id === event.target.value)
|
||||||
|
setForm(row ? { ...row, token_secret: "" } : emptyForm)
|
||||||
|
}}>
|
||||||
|
<option value="">{t("pbs.add")}</option>
|
||||||
|
{connections?.servers.map(row => <option key={row.id} value={row.id}>{row.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
{(["name", "url", "token_id", "token_secret", "fingerprint"] as const).map(field =>
|
||||||
|
<div key={field} className="space-y-2">
|
||||||
|
<Label htmlFor={`pbs-${field}`}>{t(`pbs.${field}`)}</Label>
|
||||||
|
<Input id={`pbs-${field}`} disabled={busy} type={field === "token_secret" ? "password" : field === "url" ? "url" : "text"}
|
||||||
|
autoComplete={field === "token_secret" ? "new-password" : "off"}
|
||||||
|
value={form[field]} onChange={event => setForm({ ...form, [field]: event.target.value })} />
|
||||||
|
</div>)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{t("pbs.tlsHelp")}</p>
|
||||||
|
{error && <p role="alert" className="text-sm text-red-500">{error}</p>}
|
||||||
|
{message && <p role="status" className="text-sm text-green-500">{message}</p>}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button disabled={busy} onClick={save}>{busy ? t("pbs.testing") : t("pbs.save")}</Button>
|
||||||
|
{form.id && <Button variant="destructive" disabled={busy} onClick={remove}>{t("pbs.remove")}</Button>}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>}
|
||||||
|
{data && <>
|
||||||
|
<p className="text-sm text-muted-foreground">{data.name} · PBS {data.version || "—"} · {t("pbs.sampled")} {new Date(data.fetched_at * 1000).toLocaleTimeString()}</p>
|
||||||
|
{Object.keys(data.services).length > 0 && <div className="flex flex-wrap gap-3 text-sm">
|
||||||
|
{Object.entries(data.services).map(([service, state]) => <span key={service} className={state === "active" ? "text-green-500" : "text-red-500"}>{service}: {state}</span>)}
|
||||||
|
</div>}
|
||||||
|
{Object.entries(data.errors).map(([section, detail]) => <p key={section} role="alert" className="text-sm text-amber-500">{t(`pbs.${section}`)}: {detail}</p>)}
|
||||||
|
<Card><CardHeader><CardTitle>{t("pbs.datastores")}</CardTitle></CardHeader><CardContent>
|
||||||
|
{data.datastores?.length === 0 && <p className="text-sm text-muted-foreground">{t("pbs.noDatastores")}</p>}
|
||||||
|
{data.datastores && data.datastores.length > 0 && <div className="overflow-x-auto"><table className="w-full text-sm">
|
||||||
|
<thead><tr className="border-b text-left"><th className="p-2">{t("pbs.name")}</th><th>{t("pbs.total")}</th><th>{t("pbs.used")}</th><th>{t("pbs.free")}</th></tr></thead>
|
||||||
|
<tbody>{data.datastores.map(row => <tr key={row.name} className="border-b"><td className="p-2 font-medium">{row.name}{row.error && <p className="font-normal text-xs text-amber-500">{row.error}</p>}</td><td>{capacity(row.total)}</td><td>{capacity(row.used)}</td><td>{capacity(row.avail)}</td></tr>)}</tbody>
|
||||||
|
</table></div>}
|
||||||
|
</CardContent></Card>
|
||||||
|
<Card><CardHeader><CardTitle>{t("pbs.tasks")}</CardTitle></CardHeader><CardContent>
|
||||||
|
{data.tasks?.length === 0 && <p className="text-sm text-muted-foreground">{t("pbs.noTasks")}</p>}
|
||||||
|
{data.tasks && data.tasks.length > 0 && <div className="overflow-x-auto"><table className="w-full text-sm">
|
||||||
|
<thead><tr className="border-b text-left"><th className="p-2">{t("pbs.task")}</th><th>{t("pbs.started")}</th><th>{t("pbs.status")}</th></tr></thead>
|
||||||
|
<tbody>{data.tasks.map(row => <tr key={row.upid} className="border-b"><td className="p-2">{row.worker_type} {row.worker_id}</td><td>{row.starttime ? new Date(row.starttime * 1000).toLocaleString() : "—"}</td><td>{row.status || (row.endtime ? "—" : t("pbs.running"))}</td></tr>)}</tbody>
|
||||||
|
</table></div>}
|
||||||
|
</CardContent></Card>
|
||||||
|
<Card><CardHeader><CardTitle>{t("pbs.jobs")}</CardTitle></CardHeader><CardContent>
|
||||||
|
<div className="overflow-x-auto"><table className="w-full text-sm">
|
||||||
|
<thead><tr className="border-b text-left"><th className="p-2">{t("pbs.job")}</th><th>{t("pbs.datastores")}</th><th>{t("pbs.schedule")}</th><th>{t("pbs.status")}</th></tr></thead>
|
||||||
|
<tbody>{(["sync", "verify", "prune"] as const).flatMap(kind => (data[kind] || []).map(row =>
|
||||||
|
<tr key={`${kind}-${row.id}`} className="border-b"><td className="p-2">{t(`pbs.${kind}`)} · {row.id}</td><td>{row.store || "—"}</td><td>{row.schedule || "—"}</td><td>{row["last-run-state"] || "—"}</td></tr>))}</tbody>
|
||||||
|
</table></div>
|
||||||
|
</CardContent></Card>
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import { Security } from "./security"
|
|||||||
import { Profile } from "./profile"
|
import { Profile } from "./profile"
|
||||||
import { About } from "./about"
|
import { About } from "./about"
|
||||||
import { BackupTab } from "./backup-tab"
|
import { BackupTab } from "./backup-tab"
|
||||||
|
import { PBSDashboard } from "./pbs-dashboard"
|
||||||
import { OnboardingCarousel } from "./onboarding-carousel"
|
import { OnboardingCarousel } from "./onboarding-carousel"
|
||||||
import { HealthStatusModal } from "./health-status-modal"
|
import { HealthStatusModal } from "./health-status-modal"
|
||||||
import { ReleaseNotesModal, useVersionCheck } from "./release-notes-modal"
|
import { ReleaseNotesModal, useVersionCheck } from "./release-notes-modal"
|
||||||
@@ -96,6 +97,14 @@ interface FlaskSystemInfo {
|
|||||||
const _dashboardPrefetchFetcher = (url: string) => fetchApi(url)
|
const _dashboardPrefetchFetcher = (url: string) => fetchApi(url)
|
||||||
|
|
||||||
export function ProxmoxDashboard() {
|
export function ProxmoxDashboard() {
|
||||||
|
const t = useT()
|
||||||
|
const { data, error } = useSWR<{ product: string }>("/api/platform", url => fetchApi<{ product: string }>(url), { revalidateOnFocus: false })
|
||||||
|
if (!data && !error) return <p role="status" className="p-8">{t("app.loading")}</p>
|
||||||
|
if (data?.product === "pbs") return <PBSDashboard />
|
||||||
|
return <PVEDashboard />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PVEDashboard() {
|
||||||
const t = useT()
|
const t = useT()
|
||||||
useSWR("/api/apps/custom-links", _dashboardPrefetchFetcher, { revalidateOnFocus: false })
|
useSWR("/api/apps/custom-links", _dashboardPrefetchFetcher, { revalidateOnFocus: false })
|
||||||
useSWR("/api/apps/categories", _dashboardPrefetchFetcher, { revalidateOnFocus: false })
|
useSWR("/api/apps/categories", _dashboardPrefetchFetcher, { revalidateOnFocus: false })
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ function validatePasswordStrength(pw: string, t: (key: string) => string): strin
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Security() {
|
export function Security({ hostType = "pve" }: { hostType?: "pve" | "pbs" } = {}) {
|
||||||
const { language, t } = useI18n()
|
const { language, t } = useI18n()
|
||||||
const st = (key: string, params?: Record<string, string | number>) => t(`securityPage.${key}`, params)
|
const st = (key: string, params?: Record<string, string | number>) => t(`securityPage.${key}`, params)
|
||||||
const interfaceTypeLabel = (type: string) =>
|
const interfaceTypeLabel = (type: string) =>
|
||||||
@@ -292,9 +292,11 @@ export function Security() {
|
|||||||
checkAuthStatus()
|
checkAuthStatus()
|
||||||
loadApiTokens()
|
loadApiTokens()
|
||||||
loadSslStatus()
|
loadSslStatus()
|
||||||
loadFirewallStatus()
|
if (hostType === "pve") {
|
||||||
loadNetworkInterfaces()
|
loadFirewallStatus()
|
||||||
loadSecurityTools()
|
loadNetworkInterfaces()
|
||||||
|
loadSecurityTools()
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const loadFirewallStatus = async () => {
|
const loadFirewallStatus = async () => {
|
||||||
@@ -2778,6 +2780,7 @@ ${(report.sections && report.sections.length > 0) ? `
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Proxmox VE Security Group ── */}
|
{/* ── Proxmox VE Security Group ── */}
|
||||||
|
{hostType === "pve" && <>
|
||||||
<div className="flex items-center gap-3 mt-4">
|
<div className="flex items-center gap-3 mt-4">
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-orange-500">Proxmox VE</h2>
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-orange-500">Proxmox VE</h2>
|
||||||
<div className="flex-1 h-px bg-orange-500/20" />
|
<div className="flex-1 h-px bg-orange-500/20" />
|
||||||
@@ -4833,6 +4836,8 @@ ${(report.sections && report.sections.length > 0) ? `
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
</>}
|
||||||
|
|
||||||
<TwoFactorSetup
|
<TwoFactorSetup
|
||||||
open={show2FASetup}
|
open={show2FASetup}
|
||||||
onClose={() => setShow2FASetup(false)}
|
onClose={() => setShow2FASetup(false)}
|
||||||
|
|||||||
@@ -7268,5 +7268,58 @@
|
|||||||
"diagnosticsOmitted": "Weitere Diagnosezeilen oder Text ausgelassen: {count}. Originalbericht bleibt erhalten."
|
"diagnosticsOmitted": "Weitere Diagnosezeilen oder Text ausgelassen: {count}. Originalbericht bleibt erhalten."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"pbs": {
|
||||||
|
"title": "Proxmox Backup Server",
|
||||||
|
"server": "PBS-Server",
|
||||||
|
"chooseServer": "PBS-Server auswählen",
|
||||||
|
"local": "Lokaler PBS",
|
||||||
|
"refresh": "Aktualisieren",
|
||||||
|
"manage": "PBS-Verbindungen verwalten",
|
||||||
|
"loadFailed": "PBS-Status konnte nicht geladen werden.",
|
||||||
|
"loading": "PBS-Daten werden geladen…",
|
||||||
|
"noServer": "Fügen Sie eine PBS-Verbindung hinzu, um Datenspeicher, Aufgaben und Zeitpläne anzuzeigen.",
|
||||||
|
"permissionsHelp": "Aktivieren Sie die Monitor-Authentifizierung, bevor Sie Zugangsdaten speichern. Verwenden Sie ein eigenes PBS-API-Token mit der Rolle Audit auf / sowohl für den Benutzer als auch für die Token-ACLs, jeweils mit Vererbung. Dieses Panel liest PBS-Daten ausschließlich.",
|
||||||
|
"editConnection": "Zu bearbeitende Verbindung",
|
||||||
|
"add": "Neue Verbindung hinzufügen",
|
||||||
|
"name": "Name",
|
||||||
|
"url": "HTTPS-URL (für den nativen PBS-Port :8007 angeben)",
|
||||||
|
"token_id": "API-Token-ID (user@realm!token)",
|
||||||
|
"token_secret": "API-Token-Geheimnis (leer lassen, um das gespeicherte Geheimnis beizubehalten)",
|
||||||
|
"fingerprint": "SHA256-Zertifikatfingerabdruck (optional)",
|
||||||
|
"tlsHelp": "Lassen Sie den Fingerabdruck für die normale Prüfung von Zertifizierungsstelle und Hostname leer. Kopieren Sie bei einem selbstsignierten Zertifikat dessen SHA256-Fingerabdruck aus der PBS-Konsole oder einem anderen vertrauenswürdigen Kanal. Der Fingerabdruck wird vor der Übermittlung von Zugangsdaten geprüft.",
|
||||||
|
"saved": "PBS-Verbindung geprüft und gespeichert.",
|
||||||
|
"saveFailed": "PBS-Verbindung konnte nicht gespeichert werden.",
|
||||||
|
"testing": "Verbindung wird geprüft…",
|
||||||
|
"save": "Prüfen und speichern",
|
||||||
|
"remove": "Verbindung entfernen",
|
||||||
|
"removeConfirm": "Diese gespeicherte PBS-Verbindung und ihr Token aus Monitor entfernen? PBS-Daten und -Aufträge bleiben unverändert.",
|
||||||
|
"sampled": "Erfasst am",
|
||||||
|
"datastores": "Datenspeicher",
|
||||||
|
"total": "Dateisystemkapazität",
|
||||||
|
"used": "Belegt",
|
||||||
|
"free": "Verfügbar",
|
||||||
|
"noDatastores": "Keine zugänglichen Datenspeicher. Prüfen Sie die Datenspeicherkonfiguration und die Berechtigungen des Benutzers und Tokens.",
|
||||||
|
"tasks": "Letzte Aufgaben",
|
||||||
|
"noTasks": "Es wurden keine aktuellen Aufgaben zurückgegeben.",
|
||||||
|
"task": "Aufgabe",
|
||||||
|
"started": "Gestartet",
|
||||||
|
"status": "Status",
|
||||||
|
"running": "Wird ausgeführt",
|
||||||
|
"jobs": "Wartungszeitpläne",
|
||||||
|
"job": "Auftrag",
|
||||||
|
"schedule": "Zeitplan",
|
||||||
|
"sync": "Synchronisierung",
|
||||||
|
"verify": "Verifizierung",
|
||||||
|
"prune": "Bereinigung",
|
||||||
|
"overview": "Übersicht",
|
||||||
|
"logs": "Systemprotokolle",
|
||||||
|
"terminal": "Terminal",
|
||||||
|
"security": "Sicherheit",
|
||||||
|
"logout": "Abmelden",
|
||||||
|
"navigation": "PBS-Navigation",
|
||||||
|
"cpu": "CPU",
|
||||||
|
"memory": "Arbeitsspeicher",
|
||||||
|
"uptime": "Betriebszeit"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,57 @@
|
|||||||
{
|
{
|
||||||
|
"pbs": {
|
||||||
|
"title": "Proxmox Backup Server",
|
||||||
|
"server": "PBS server",
|
||||||
|
"chooseServer": "Choose a PBS server",
|
||||||
|
"local": "Local PBS",
|
||||||
|
"refresh": "Refresh",
|
||||||
|
"manage": "Manage PBS connections",
|
||||||
|
"loadFailed": "Could not load PBS status.",
|
||||||
|
"loading": "Loading PBS data…",
|
||||||
|
"noServer": "Add a PBS connection to view datastores, tasks and schedules.",
|
||||||
|
"permissionsHelp": "Enable Monitor authentication before saving credentials. Use a dedicated PBS API token with the Audit role on / for both its user and token ACLs, with propagation. This panel only reads PBS data.",
|
||||||
|
"editConnection": "Connection to edit",
|
||||||
|
"add": "Add a new connection",
|
||||||
|
"name": "Name",
|
||||||
|
"url": "HTTPS URL (include :8007 for the native PBS port)",
|
||||||
|
"token_id": "API token ID (user@realm!token)",
|
||||||
|
"token_secret": "API token secret (leave empty to keep the stored secret)",
|
||||||
|
"fingerprint": "SHA256 certificate fingerprint (optional)",
|
||||||
|
"tlsHelp": "Leave the fingerprint empty for normal CA and hostname verification. For a self-signed certificate, copy its SHA256 fingerprint from the PBS console or another trusted channel. The pin is checked before sending credentials.",
|
||||||
|
"saved": "PBS connection verified and saved.",
|
||||||
|
"saveFailed": "Could not save the PBS connection.",
|
||||||
|
"testing": "Verifying connection…",
|
||||||
|
"save": "Verify and save",
|
||||||
|
"remove": "Remove connection",
|
||||||
|
"removeConfirm": "Remove this saved PBS connection and its token from Monitor? PBS data and jobs are unaffected.",
|
||||||
|
"sampled": "Sampled at",
|
||||||
|
"datastores": "Datastores",
|
||||||
|
"total": "Filesystem capacity",
|
||||||
|
"used": "Used",
|
||||||
|
"free": "Available",
|
||||||
|
"noDatastores": "No accessible datastores. Check datastore configuration and the user/token permissions.",
|
||||||
|
"tasks": "Recent tasks",
|
||||||
|
"noTasks": "No recent tasks were returned.",
|
||||||
|
"task": "Task",
|
||||||
|
"started": "Started",
|
||||||
|
"status": "Status",
|
||||||
|
"running": "Running",
|
||||||
|
"jobs": "Maintenance schedules",
|
||||||
|
"job": "Job",
|
||||||
|
"schedule": "Schedule",
|
||||||
|
"sync": "Sync",
|
||||||
|
"verify": "Verification",
|
||||||
|
"prune": "Prune",
|
||||||
|
"overview": "Overview",
|
||||||
|
"logs": "System logs",
|
||||||
|
"terminal": "Terminal",
|
||||||
|
"security": "Security",
|
||||||
|
"logout": "Log out",
|
||||||
|
"navigation": "PBS navigation",
|
||||||
|
"cpu": "CPU",
|
||||||
|
"memory": "Memory",
|
||||||
|
"uptime": "Uptime"
|
||||||
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"title": "ProxMenux Monitor",
|
"title": "ProxMenux Monitor",
|
||||||
"description": "Proxmox System Dashboard",
|
"description": "Proxmox System Dashboard",
|
||||||
|
|||||||
@@ -1,4 +1,57 @@
|
|||||||
{
|
{
|
||||||
|
"pbs": {
|
||||||
|
"title": "Proxmox Backup Server",
|
||||||
|
"server": "Servidor PBS",
|
||||||
|
"chooseServer": "Selecciona un servidor PBS",
|
||||||
|
"local": "PBS local",
|
||||||
|
"refresh": "Actualizar",
|
||||||
|
"manage": "Gestionar conexiones PBS",
|
||||||
|
"loadFailed": "No se pudo consultar el estado de PBS.",
|
||||||
|
"loading": "Cargando datos de PBS…",
|
||||||
|
"noServer": "Añade una conexión PBS para ver datastores, tareas y programaciones.",
|
||||||
|
"permissionsHelp": "Activa la autenticación de Monitor antes de guardar credenciales. Usa un token API dedicado de PBS con el rol Audit en / para el usuario y el token, con propagación. Este panel solo consulta datos de PBS.",
|
||||||
|
"editConnection": "Conexión que deseas editar",
|
||||||
|
"add": "Añadir una conexión",
|
||||||
|
"name": "Nombre",
|
||||||
|
"url": "URL HTTPS (incluye :8007 para el puerto nativo de PBS)",
|
||||||
|
"token_id": "ID del token API (usuario@realm!token)",
|
||||||
|
"token_secret": "Secreto del token API (vacío para conservar el guardado)",
|
||||||
|
"fingerprint": "Huella SHA256 del certificado (opcional)",
|
||||||
|
"tlsHelp": "Deja la huella vacía para verificar la CA y el nombre del servidor. Si el certificado es autofirmado, copia su huella SHA256 desde la consola de PBS u otro canal fiable. Se verifica antes de enviar las credenciales.",
|
||||||
|
"saved": "Conexión PBS verificada y guardada.",
|
||||||
|
"saveFailed": "No se pudo guardar la conexión PBS.",
|
||||||
|
"testing": "Verificando conexión…",
|
||||||
|
"save": "Verificar y guardar",
|
||||||
|
"remove": "Eliminar conexión",
|
||||||
|
"removeConfirm": "¿Eliminar de Monitor esta conexión PBS y su token guardado? Los datos y las tareas de PBS no se modifican.",
|
||||||
|
"sampled": "Consulta realizada a las",
|
||||||
|
"datastores": "Datastores",
|
||||||
|
"total": "Capacidad del sistema de archivos",
|
||||||
|
"used": "Usado",
|
||||||
|
"free": "Disponible",
|
||||||
|
"noDatastores": "No hay datastores accesibles. Revisa su configuración y los permisos del usuario y del token.",
|
||||||
|
"tasks": "Tareas recientes",
|
||||||
|
"noTasks": "No se han devuelto tareas recientes.",
|
||||||
|
"task": "Tarea",
|
||||||
|
"started": "Inicio",
|
||||||
|
"status": "Estado",
|
||||||
|
"running": "En ejecución",
|
||||||
|
"jobs": "Programaciones de mantenimiento",
|
||||||
|
"job": "Tarea",
|
||||||
|
"schedule": "Programación",
|
||||||
|
"sync": "Sincronización",
|
||||||
|
"verify": "Verificación",
|
||||||
|
"prune": "Prune",
|
||||||
|
"overview": "Resumen",
|
||||||
|
"logs": "Registros del sistema",
|
||||||
|
"terminal": "Terminal",
|
||||||
|
"security": "Seguridad",
|
||||||
|
"logout": "Cerrar sesión",
|
||||||
|
"navigation": "Navegación de PBS",
|
||||||
|
"cpu": "CPU",
|
||||||
|
"memory": "Memoria",
|
||||||
|
"uptime": "Tiempo de actividad"
|
||||||
|
},
|
||||||
"_meta": "Community translation target. Copy keys from ../en/common.json and translate the values.",
|
"_meta": "Community translation target. Copy keys from ../en/common.json and translate the values.",
|
||||||
"app": {
|
"app": {
|
||||||
"title": "ProxMenux Monitor",
|
"title": "ProxMenux Monitor",
|
||||||
|
|||||||
@@ -7268,5 +7268,58 @@
|
|||||||
"diagnosticsOmitted": "Lignes ou texte de diagnostic omis : {count}. Rapport original conservé."
|
"diagnosticsOmitted": "Lignes ou texte de diagnostic omis : {count}. Rapport original conservé."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"pbs": {
|
||||||
|
"title": "Proxmox Backup Server",
|
||||||
|
"server": "Serveur PBS",
|
||||||
|
"chooseServer": "Choisir un serveur PBS",
|
||||||
|
"local": "PBS local",
|
||||||
|
"refresh": "Actualiser",
|
||||||
|
"manage": "Gérer les connexions PBS",
|
||||||
|
"loadFailed": "Impossible de charger l'état de PBS.",
|
||||||
|
"loading": "Chargement des données PBS…",
|
||||||
|
"noServer": "Ajoutez une connexion PBS pour afficher les magasins de données, les tâches et les planifications.",
|
||||||
|
"permissionsHelp": "Activez l'authentification Monitor avant d'enregistrer des identifiants. Utilisez un jeton API PBS dédié avec le rôle Audit sur / pour les ACL de l'utilisateur et du jeton, avec propagation. Ce panneau accède aux données PBS uniquement en lecture.",
|
||||||
|
"editConnection": "Connexion à modifier",
|
||||||
|
"add": "Ajouter une connexion",
|
||||||
|
"name": "Nom",
|
||||||
|
"url": "URL HTTPS (inclure :8007 pour le port natif de PBS)",
|
||||||
|
"token_id": "Identifiant du jeton API (user@realm!token)",
|
||||||
|
"token_secret": "Secret du jeton API (laisser vide pour conserver le secret enregistré)",
|
||||||
|
"fingerprint": "Empreinte SHA256 du certificat (facultative)",
|
||||||
|
"tlsHelp": "Laissez l'empreinte vide pour la vérification habituelle de l'autorité de certification et du nom d'hôte. Pour un certificat autosigné, copiez son empreinte SHA256 depuis la console PBS ou un autre canal de confiance. L'empreinte est vérifiée avant l'envoi des identifiants.",
|
||||||
|
"saved": "Connexion PBS vérifiée et enregistrée.",
|
||||||
|
"saveFailed": "Impossible d'enregistrer la connexion PBS.",
|
||||||
|
"testing": "Vérification de la connexion…",
|
||||||
|
"save": "Vérifier et enregistrer",
|
||||||
|
"remove": "Supprimer la connexion",
|
||||||
|
"removeConfirm": "Supprimer de Monitor cette connexion PBS enregistrée et son jeton ? Les données et tâches PBS restent inchangées.",
|
||||||
|
"sampled": "Relevé effectué à",
|
||||||
|
"datastores": "Magasins de données",
|
||||||
|
"total": "Capacité du système de fichiers",
|
||||||
|
"used": "Utilisé",
|
||||||
|
"free": "Disponible",
|
||||||
|
"noDatastores": "Aucun magasin de données accessible. Vérifiez leur configuration et les permissions de l'utilisateur et du jeton.",
|
||||||
|
"tasks": "Tâches récentes",
|
||||||
|
"noTasks": "Aucune tâche récente n'a été renvoyée.",
|
||||||
|
"task": "Tâche",
|
||||||
|
"started": "Démarrée",
|
||||||
|
"status": "État",
|
||||||
|
"running": "En cours",
|
||||||
|
"jobs": "Planifications de maintenance",
|
||||||
|
"job": "Tâche planifiée",
|
||||||
|
"schedule": "Planification",
|
||||||
|
"sync": "Synchronisation",
|
||||||
|
"verify": "Vérification",
|
||||||
|
"prune": "Purge",
|
||||||
|
"overview": "Vue d'ensemble",
|
||||||
|
"logs": "Journaux système",
|
||||||
|
"terminal": "Terminal",
|
||||||
|
"security": "Sécurité",
|
||||||
|
"logout": "Se déconnecter",
|
||||||
|
"navigation": "Navigation PBS",
|
||||||
|
"cpu": "CPU",
|
||||||
|
"memory": "Mémoire",
|
||||||
|
"uptime": "Durée de fonctionnement"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7268,5 +7268,58 @@
|
|||||||
"diagnosticsOmitted": "Righe o testo diagnostico omessi: {count}. Il report originale è conservato."
|
"diagnosticsOmitted": "Righe o testo diagnostico omessi: {count}. Il report originale è conservato."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"pbs": {
|
||||||
|
"title": "Proxmox Backup Server",
|
||||||
|
"server": "Server PBS",
|
||||||
|
"chooseServer": "Scegli un server PBS",
|
||||||
|
"local": "PBS locale",
|
||||||
|
"refresh": "Aggiorna",
|
||||||
|
"manage": "Gestisci le connessioni PBS",
|
||||||
|
"loadFailed": "Impossibile caricare lo stato di PBS.",
|
||||||
|
"loading": "Caricamento dei dati PBS…",
|
||||||
|
"noServer": "Aggiungi una connessione PBS per visualizzare datastore, attività e pianificazioni.",
|
||||||
|
"permissionsHelp": "Abilita l'autenticazione di Monitor prima di salvare le credenziali. Usa un token API PBS dedicato con il ruolo Audit su / sia nelle ACL dell'utente sia in quelle del token, con propagazione. Questo pannello legge soltanto i dati PBS.",
|
||||||
|
"editConnection": "Connessione da modificare",
|
||||||
|
"add": "Aggiungi una nuova connessione",
|
||||||
|
"name": "Nome",
|
||||||
|
"url": "URL HTTPS (includere :8007 per la porta nativa di PBS)",
|
||||||
|
"token_id": "ID del token API (user@realm!token)",
|
||||||
|
"token_secret": "Segreto del token API (lasciare vuoto per mantenere quello salvato)",
|
||||||
|
"fingerprint": "Impronta SHA256 del certificato (facoltativa)",
|
||||||
|
"tlsHelp": "Lascia vuota l'impronta per la normale verifica dell'autorità di certificazione e del nome host. Per un certificato autofirmato, copia la sua impronta SHA256 dalla console PBS o da un altro canale attendibile. L'impronta viene verificata prima dell'invio delle credenziali.",
|
||||||
|
"saved": "Connessione PBS verificata e salvata.",
|
||||||
|
"saveFailed": "Impossibile salvare la connessione PBS.",
|
||||||
|
"testing": "Verifica della connessione…",
|
||||||
|
"save": "Verifica e salva",
|
||||||
|
"remove": "Rimuovi connessione",
|
||||||
|
"removeConfirm": "Rimuovere da Monitor questa connessione PBS salvata e il suo token? I dati e le attività PBS non vengono modificati.",
|
||||||
|
"sampled": "Rilevato alle",
|
||||||
|
"datastores": "Datastore",
|
||||||
|
"total": "Capacità del filesystem",
|
||||||
|
"used": "Usato",
|
||||||
|
"free": "Disponibile",
|
||||||
|
"noDatastores": "Nessun datastore accessibile. Controlla la configurazione dei datastore e i permessi dell'utente e del token.",
|
||||||
|
"tasks": "Attività recenti",
|
||||||
|
"noTasks": "Non sono state restituite attività recenti.",
|
||||||
|
"task": "Attività",
|
||||||
|
"started": "Avviata",
|
||||||
|
"status": "Stato",
|
||||||
|
"running": "In esecuzione",
|
||||||
|
"jobs": "Pianificazioni di manutenzione",
|
||||||
|
"job": "Attività pianificata",
|
||||||
|
"schedule": "Pianificazione",
|
||||||
|
"sync": "Sincronizzazione",
|
||||||
|
"verify": "Verifica",
|
||||||
|
"prune": "Pulizia",
|
||||||
|
"overview": "Panoramica",
|
||||||
|
"logs": "Registri di sistema",
|
||||||
|
"terminal": "Terminale",
|
||||||
|
"security": "Sicurezza",
|
||||||
|
"logout": "Disconnetti",
|
||||||
|
"navigation": "Navigazione PBS",
|
||||||
|
"cpu": "CPU",
|
||||||
|
"memory": "Memoria",
|
||||||
|
"uptime": "Tempo di attività"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7268,5 +7268,58 @@
|
|||||||
"diagnosticsOmitted": "Linhas ou texto de diagnóstico omitidos: {count}. Relatório original preservado."
|
"diagnosticsOmitted": "Linhas ou texto de diagnóstico omitidos: {count}. Relatório original preservado."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"pbs": {
|
||||||
|
"title": "Proxmox Backup Server",
|
||||||
|
"server": "Servidor PBS",
|
||||||
|
"chooseServer": "Escolher um servidor PBS",
|
||||||
|
"local": "PBS local",
|
||||||
|
"refresh": "Atualizar",
|
||||||
|
"manage": "Gerir ligações PBS",
|
||||||
|
"loadFailed": "Não foi possível carregar o estado do PBS.",
|
||||||
|
"loading": "A carregar dados do PBS…",
|
||||||
|
"noServer": "Adicione uma ligação PBS para ver os armazenamentos de dados, tarefas e agendamentos.",
|
||||||
|
"permissionsHelp": "Ative a autenticação do Monitor antes de guardar credenciais. Utilize um token API PBS dedicado com a função Audit em / nas ACL do utilizador e do token, com propagação. Este painel apenas lê dados do PBS.",
|
||||||
|
"editConnection": "Ligação a editar",
|
||||||
|
"add": "Adicionar uma nova ligação",
|
||||||
|
"name": "Nome",
|
||||||
|
"url": "URL HTTPS (inclua :8007 para a porta nativa do PBS)",
|
||||||
|
"token_id": "ID do token API (user@realm!token)",
|
||||||
|
"token_secret": "Segredo do token API (deixe vazio para manter o segredo guardado)",
|
||||||
|
"fingerprint": "Impressão digital SHA256 do certificado (opcional)",
|
||||||
|
"tlsHelp": "Deixe a impressão digital vazia para a verificação normal da autoridade de certificação e do nome de anfitrião. Para um certificado autoassinado, copie a sua impressão digital SHA256 da consola PBS ou de outro canal de confiança. A impressão digital é verificada antes do envio de credenciais.",
|
||||||
|
"saved": "Ligação PBS verificada e guardada.",
|
||||||
|
"saveFailed": "Não foi possível guardar a ligação PBS.",
|
||||||
|
"testing": "A verificar a ligação…",
|
||||||
|
"save": "Verificar e guardar",
|
||||||
|
"remove": "Remover ligação",
|
||||||
|
"removeConfirm": "Remover do Monitor esta ligação PBS guardada e o respetivo token? Os dados e as tarefas PBS não são afetados.",
|
||||||
|
"sampled": "Recolhido em",
|
||||||
|
"datastores": "Armazenamentos de dados",
|
||||||
|
"total": "Capacidade do sistema de ficheiros",
|
||||||
|
"used": "Utilizado",
|
||||||
|
"free": "Disponível",
|
||||||
|
"noDatastores": "Nenhum armazenamento de dados acessível. Verifique a configuração e as permissões do utilizador e do token.",
|
||||||
|
"tasks": "Tarefas recentes",
|
||||||
|
"noTasks": "Não foram devolvidas tarefas recentes.",
|
||||||
|
"task": "Tarefa",
|
||||||
|
"started": "Iniciada",
|
||||||
|
"status": "Estado",
|
||||||
|
"running": "Em execução",
|
||||||
|
"jobs": "Agendamentos de manutenção",
|
||||||
|
"job": "Tarefa agendada",
|
||||||
|
"schedule": "Agendamento",
|
||||||
|
"sync": "Sincronização",
|
||||||
|
"verify": "Verificação",
|
||||||
|
"prune": "Limpeza",
|
||||||
|
"overview": "Visão geral",
|
||||||
|
"logs": "Registos do sistema",
|
||||||
|
"terminal": "Terminal",
|
||||||
|
"security": "Segurança",
|
||||||
|
"logout": "Terminar sessão",
|
||||||
|
"navigation": "Navegação PBS",
|
||||||
|
"cpu": "CPU",
|
||||||
|
"memory": "Memória",
|
||||||
|
"uptime": "Tempo de atividade"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7267,5 +7267,58 @@
|
|||||||
"diagnosticsOmitted": "Ďalšie diagnostické riadky alebo text vynechaný: {count}. Pôvodná správa sa zachovala."
|
"diagnosticsOmitted": "Ďalšie diagnostické riadky alebo text vynechaný: {count}. Pôvodná správa sa zachovala."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"pbs": {
|
||||||
|
"title": "Proxmox Backup Server",
|
||||||
|
"server": "Server PBS",
|
||||||
|
"chooseServer": "Vybrať server PBS",
|
||||||
|
"local": "Miestny PBS",
|
||||||
|
"refresh": "Obnoviť",
|
||||||
|
"manage": "Spravovať pripojenia PBS",
|
||||||
|
"loadFailed": "Nepodarilo sa načítať stav PBS.",
|
||||||
|
"loading": "Načítavajú sa údaje PBS…",
|
||||||
|
"noServer": "Pridajte pripojenie PBS na zobrazenie dátových úložísk, úloh a plánov.",
|
||||||
|
"permissionsHelp": "Pred uložením prihlasovacích údajov povoľte overovanie v Monitore. Použite vyhradený API token PBS s rolou Audit na / v ACL používateľa aj tokenu, so zapnutým dedením. Tento panel údaje PBS iba číta.",
|
||||||
|
"editConnection": "Pripojenie na úpravu",
|
||||||
|
"add": "Pridať nové pripojenie",
|
||||||
|
"name": "Názov",
|
||||||
|
"url": "HTTPS URL (pre pôvodný port PBS uveďte :8007)",
|
||||||
|
"token_id": "ID API tokenu (user@realm!token)",
|
||||||
|
"token_secret": "Tajný kľúč API tokenu (ponechajte prázdne, ak chcete zachovať uložený kľúč)",
|
||||||
|
"fingerprint": "Odtlačok SHA256 certifikátu (voliteľný)",
|
||||||
|
"tlsHelp": "Pre bežné overenie certifikačnej autority a názvu hostiteľa ponechajte odtlačok prázdny. Pri certifikáte s vlastným podpisom skopírujte jeho odtlačok SHA256 z konzoly PBS alebo iného dôveryhodného kanála. Odtlačok sa kontroluje pred odoslaním prihlasovacích údajov.",
|
||||||
|
"saved": "Pripojenie PBS bolo overené a uložené.",
|
||||||
|
"saveFailed": "Nepodarilo sa uložiť pripojenie PBS.",
|
||||||
|
"testing": "Overuje sa pripojenie…",
|
||||||
|
"save": "Overiť a uložiť",
|
||||||
|
"remove": "Odstrániť pripojenie",
|
||||||
|
"removeConfirm": "Odstrániť z Monitora toto uložené pripojenie PBS a jeho token? Údaje a úlohy PBS zostanú nezmenené.",
|
||||||
|
"sampled": "Údaje získané",
|
||||||
|
"datastores": "Dátové úložiská",
|
||||||
|
"total": "Kapacita súborového systému",
|
||||||
|
"used": "Použité",
|
||||||
|
"free": "Dostupné",
|
||||||
|
"noDatastores": "Žiadne dostupné dátové úložiská. Skontrolujte konfiguráciu úložísk a oprávnenia používateľa a tokenu.",
|
||||||
|
"tasks": "Nedávne úlohy",
|
||||||
|
"noTasks": "Neboli vrátené žiadne nedávne úlohy.",
|
||||||
|
"task": "Úloha",
|
||||||
|
"started": "Spustené",
|
||||||
|
"status": "Stav",
|
||||||
|
"running": "Prebieha",
|
||||||
|
"jobs": "Plány údržby",
|
||||||
|
"job": "Plánovaná úloha",
|
||||||
|
"schedule": "Plán",
|
||||||
|
"sync": "Synchronizácia",
|
||||||
|
"verify": "Overenie",
|
||||||
|
"prune": "Prerezávanie",
|
||||||
|
"overview": "Prehľad",
|
||||||
|
"logs": "Systémové záznamy",
|
||||||
|
"terminal": "Terminál",
|
||||||
|
"security": "Zabezpečenie",
|
||||||
|
"logout": "Odhlásiť sa",
|
||||||
|
"navigation": "Navigácia PBS",
|
||||||
|
"cpu": "CPU",
|
||||||
|
"memory": "Pamäť",
|
||||||
|
"uptime": "Čas prevádzky"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7268,5 +7268,58 @@
|
|||||||
"diagnosticsOmitted": "Utelämnade diagnosrader eller text: {count}. Originalrapporten bevaras."
|
"diagnosticsOmitted": "Utelämnade diagnosrader eller text: {count}. Originalrapporten bevaras."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"pbs": {
|
||||||
|
"title": "Proxmox Backup Server",
|
||||||
|
"server": "PBS-server",
|
||||||
|
"chooseServer": "Välj en PBS-server",
|
||||||
|
"local": "Lokal PBS",
|
||||||
|
"refresh": "Uppdatera",
|
||||||
|
"manage": "Hantera PBS-anslutningar",
|
||||||
|
"loadFailed": "Det gick inte att läsa in PBS-status.",
|
||||||
|
"loading": "Läser in PBS-data…",
|
||||||
|
"noServer": "Lägg till en PBS-anslutning för att visa datalager, uppgifter och scheman.",
|
||||||
|
"permissionsHelp": "Aktivera Monitor-autentisering innan du sparar inloggningsuppgifter. Använd en särskild PBS-API-token med rollen Audit på / i både användarens och tokenens ACL, med nedärvning. Denna panel läser endast PBS-data.",
|
||||||
|
"editConnection": "Anslutning att redigera",
|
||||||
|
"add": "Lägg till en ny anslutning",
|
||||||
|
"name": "Namn",
|
||||||
|
"url": "HTTPS-URL (ta med :8007 för PBS ordinarie port)",
|
||||||
|
"token_id": "API-token-ID (user@realm!token)",
|
||||||
|
"token_secret": "API-tokenhemlighet (lämna tomt för att behålla den sparade hemligheten)",
|
||||||
|
"fingerprint": "Certifikatets SHA256-fingeravtryck (valfritt)",
|
||||||
|
"tlsHelp": "Lämna fingeravtrycket tomt för vanlig kontroll av certifikatutfärdare och värdnamn. För ett självsignerat certifikat kopierar du dess SHA256-fingeravtryck från PBS-konsolen eller en annan betrodd kanal. Fingeravtrycket kontrolleras innan inloggningsuppgifter skickas.",
|
||||||
|
"saved": "PBS-anslutningen har verifierats och sparats.",
|
||||||
|
"saveFailed": "Det gick inte att spara PBS-anslutningen.",
|
||||||
|
"testing": "Verifierar anslutningen…",
|
||||||
|
"save": "Verifiera och spara",
|
||||||
|
"remove": "Ta bort anslutningen",
|
||||||
|
"removeConfirm": "Ta bort denna sparade PBS-anslutning och dess token från Monitor? PBS-data och jobb påverkas inte.",
|
||||||
|
"sampled": "Avläst",
|
||||||
|
"datastores": "Datalager",
|
||||||
|
"total": "Filsystemets kapacitet",
|
||||||
|
"used": "Använt",
|
||||||
|
"free": "Tillgängligt",
|
||||||
|
"noDatastores": "Inga tillgängliga datalager. Kontrollera datalagrets konfiguration samt användarens och tokenens behörigheter.",
|
||||||
|
"tasks": "Senaste uppgifter",
|
||||||
|
"noTasks": "Inga senaste uppgifter returnerades.",
|
||||||
|
"task": "Uppgift",
|
||||||
|
"started": "Startad",
|
||||||
|
"status": "Status",
|
||||||
|
"running": "Körs",
|
||||||
|
"jobs": "Underhållsscheman",
|
||||||
|
"job": "Jobb",
|
||||||
|
"schedule": "Schema",
|
||||||
|
"sync": "Synkronisering",
|
||||||
|
"verify": "Verifiering",
|
||||||
|
"prune": "Gallring",
|
||||||
|
"overview": "Översikt",
|
||||||
|
"logs": "Systemloggar",
|
||||||
|
"terminal": "Terminal",
|
||||||
|
"security": "Säkerhet",
|
||||||
|
"logout": "Logga ut",
|
||||||
|
"navigation": "PBS-navigering",
|
||||||
|
"cpu": "CPU",
|
||||||
|
"memory": "Minne",
|
||||||
|
"uptime": "Drifttid"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1115,6 +1115,9 @@ def _detect_proxmox_certificate_paths():
|
|||||||
return PROXMOX_CUSTOM_CERT_PATH, PROXMOX_CUSTOM_KEY_PATH
|
return PROXMOX_CUSTOM_CERT_PATH, PROXMOX_CUSTOM_KEY_PATH
|
||||||
if os.path.isfile(PROXMOX_CERT_PATH) and os.path.isfile(PROXMOX_KEY_PATH):
|
if os.path.isfile(PROXMOX_CERT_PATH) and os.path.isfile(PROXMOX_KEY_PATH):
|
||||||
return PROXMOX_CERT_PATH, PROXMOX_KEY_PATH
|
return PROXMOX_CERT_PATH, PROXMOX_KEY_PATH
|
||||||
|
pbs_cert, pbs_key = "/etc/proxmox-backup/proxy.pem", "/etc/proxmox-backup/proxy.key"
|
||||||
|
if os.path.isfile(pbs_cert) and os.path.isfile(pbs_key):
|
||||||
|
return pbs_cert, pbs_key
|
||||||
return "", ""
|
return "", ""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -161,6 +161,9 @@ cp "$SCRIPT_DIR/flask_terminal_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || ech
|
|||||||
cp "$SCRIPT_DIR/hardware_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ hardware_monitor.py not found"
|
cp "$SCRIPT_DIR/hardware_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ hardware_monitor.py not found"
|
||||||
cp "$SCRIPT_DIR/temperature_sensor_resolver.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ temperature_sensor_resolver.py not found"
|
cp "$SCRIPT_DIR/temperature_sensor_resolver.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ temperature_sensor_resolver.py not found"
|
||||||
cp "$SCRIPT_DIR/proxmox_storage_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ proxmox_storage_monitor.py not found"
|
cp "$SCRIPT_DIR/proxmox_storage_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ proxmox_storage_monitor.py not found"
|
||||||
|
cp "$SCRIPT_DIR/pbs_manager.py" "$APP_DIR/usr/bin/"
|
||||||
|
cp "$SCRIPT_DIR/flask_pbs_routes.py" "$APP_DIR/usr/bin/"
|
||||||
|
cp "$APPIMAGE_ROOT/../scripts/global/proxmox_platform.py" "$APP_DIR/usr/bin/"
|
||||||
cp "$SCRIPT_DIR/flask_script_runner.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_script_runner.py not found"
|
cp "$SCRIPT_DIR/flask_script_runner.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_script_runner.py not found"
|
||||||
cp "$SCRIPT_DIR/security_manager.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ security_manager.py not found"
|
cp "$SCRIPT_DIR/security_manager.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ security_manager.py not found"
|
||||||
cp "$SCRIPT_DIR/flask_security_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_security_routes.py not found"
|
cp "$SCRIPT_DIR/flask_security_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_security_routes.py not found"
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Read-only PBS status and administrative connection management."""
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request
|
||||||
|
import auth_manager
|
||||||
|
import pbs_manager as pbs
|
||||||
|
from jwt_middleware import require_auth, require_admin_scope
|
||||||
|
|
||||||
|
pbs_bp = Blueprint("pbs", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@pbs_bp.after_request
|
||||||
|
def no_cache(response):
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@pbs_bp.route("/api/platform")
|
||||||
|
@require_auth
|
||||||
|
def platform():
|
||||||
|
return jsonify(pbs.get_platform())
|
||||||
|
|
||||||
|
|
||||||
|
@pbs_bp.route("/api/pbs/servers")
|
||||||
|
@require_auth
|
||||||
|
def servers():
|
||||||
|
try:
|
||||||
|
return jsonify(servers=pbs.public_servers(), local_available=pbs.is_pbs_host())
|
||||||
|
except pbs.PBSError as exc:
|
||||||
|
return jsonify(error=str(exc)), 500
|
||||||
|
|
||||||
|
|
||||||
|
def _protected_configuration():
|
||||||
|
local = auth_manager.load_auth_config()
|
||||||
|
return local.get("enabled") and not local.get("declined") and local.get("password_hash")
|
||||||
|
|
||||||
|
|
||||||
|
@pbs_bp.route("/api/pbs/servers", methods=["POST"])
|
||||||
|
@require_admin_scope
|
||||||
|
def save_server():
|
||||||
|
if not _protected_configuration():
|
||||||
|
return jsonify(error="Enable local authentication before saving remote PBS credentials."), 409
|
||||||
|
try:
|
||||||
|
identifier = pbs.save_server(request.get_json(silent=True))
|
||||||
|
return jsonify(success=True, id=identifier, servers=pbs.public_servers())
|
||||||
|
except pbs.PBSError as exc:
|
||||||
|
return jsonify(error=str(exc)), 400
|
||||||
|
except OSError:
|
||||||
|
return jsonify(error="Could not save PBS connection."), 500
|
||||||
|
|
||||||
|
|
||||||
|
@pbs_bp.route("/api/pbs/servers/<identifier>", methods=["DELETE"])
|
||||||
|
@require_admin_scope
|
||||||
|
def delete_server(identifier):
|
||||||
|
if not _protected_configuration():
|
||||||
|
return jsonify(error="Enable local authentication before changing PBS connections."), 409
|
||||||
|
try:
|
||||||
|
pbs.delete_server(identifier)
|
||||||
|
return jsonify(success=True)
|
||||||
|
except pbs.PBSError as exc:
|
||||||
|
return jsonify(error=str(exc)), 400
|
||||||
|
except OSError:
|
||||||
|
return jsonify(error="Could not remove PBS connection."), 500
|
||||||
|
|
||||||
|
|
||||||
|
@pbs_bp.route("/api/pbs/status")
|
||||||
|
@require_auth
|
||||||
|
def status():
|
||||||
|
try:
|
||||||
|
return jsonify(pbs.get_status(request.args.get("server", "local")))
|
||||||
|
except pbs.PBSError as exc:
|
||||||
|
return jsonify(error=str(exc)), 502
|
||||||
@@ -90,6 +90,8 @@ from flask_terminal_routes import ( # noqa: E402
|
|||||||
from flask_health_routes import health_bp # noqa: E402
|
from flask_health_routes import health_bp # noqa: E402
|
||||||
from flask_auth_routes import auth_bp # noqa: E402
|
from flask_auth_routes import auth_bp # noqa: E402
|
||||||
from flask_proxmenux_routes import proxmenux_bp # noqa: E402
|
from flask_proxmenux_routes import proxmenux_bp # noqa: E402
|
||||||
|
from flask_pbs_routes import pbs_bp # noqa: E402
|
||||||
|
from pbs_manager import get_platform, is_pbs_host # noqa: E402
|
||||||
from flask_security_routes import security_bp # noqa: E402
|
from flask_security_routes import security_bp # noqa: E402
|
||||||
from flask_notification_routes import notification_bp # noqa: E402
|
from flask_notification_routes import notification_bp # noqa: E402
|
||||||
from flask_oci_routes import oci_bp # noqa: E402
|
from flask_oci_routes import oci_bp # noqa: E402
|
||||||
@@ -195,6 +197,7 @@ post_install_versions.scan_at_startup()
|
|||||||
app.register_blueprint(auth_bp)
|
app.register_blueprint(auth_bp)
|
||||||
app.register_blueprint(health_bp)
|
app.register_blueprint(health_bp)
|
||||||
app.register_blueprint(proxmenux_bp)
|
app.register_blueprint(proxmenux_bp)
|
||||||
|
app.register_blueprint(pbs_bp)
|
||||||
app.register_blueprint(security_bp)
|
app.register_blueprint(security_bp)
|
||||||
app.register_blueprint(notification_bp)
|
app.register_blueprint(notification_bp)
|
||||||
app.register_blueprint(oci_bp)
|
app.register_blueprint(oci_bp)
|
||||||
@@ -3476,6 +3479,8 @@ def get_coral_info():
|
|||||||
def get_proxmox_version():
|
def get_proxmox_version():
|
||||||
"""Get Proxmox version if available. Cached for 6 hours."""
|
"""Get Proxmox version if available. Cached for 6 hours."""
|
||||||
global _system_info_cache
|
global _system_info_cache
|
||||||
|
if is_pbs_host():
|
||||||
|
return get_platform().get('version')
|
||||||
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if _system_info_cache['proxmox_version'] is not None and \
|
if _system_info_cache['proxmox_version'] is not None and \
|
||||||
@@ -9837,6 +9842,7 @@ def api_system():
|
|||||||
'cpu_cores': cpu_cores,
|
'cpu_cores': cpu_cores,
|
||||||
'cpu_threads': cpu_threads,
|
'cpu_threads': cpu_threads,
|
||||||
'proxmox_version': proxmox_version,
|
'proxmox_version': proxmox_version,
|
||||||
|
'platform': get_platform(),
|
||||||
'kernel_version': kernel_version,
|
'kernel_version': kernel_version,
|
||||||
'available_updates': available_updates
|
'available_updates': available_updates
|
||||||
})
|
})
|
||||||
@@ -23187,9 +23193,11 @@ if __name__ == '__main__':
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[ProxMenux] Disk temperature history init failed: {e}")
|
print(f"[ProxMenux] Disk temperature history init failed: {e}")
|
||||||
try:
|
try:
|
||||||
import managed_installs
|
# A PBS host has no guests whose applications could be tracked.
|
||||||
managed_installs.detect_and_register()
|
if not is_pbs_host():
|
||||||
print("[ProxMenux] Managed-installs registry initialised")
|
import managed_installs
|
||||||
|
managed_installs.detect_and_register()
|
||||||
|
print("[ProxMenux] Managed-installs registry initialised")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[ProxMenux] managed_installs init failed: {e}")
|
print(f"[ProxMenux] managed_installs init failed: {e}")
|
||||||
# Docker inventories are derived runtime data and intentionally
|
# Docker inventories are derived runtime data and intentionally
|
||||||
@@ -23331,6 +23339,9 @@ if __name__ == '__main__':
|
|||||||
# the 24 h PollingCollector cycle. Runs in a daemon thread with
|
# the 24 h PollingCollector cycle. Runs in a daemon thread with
|
||||||
# a delay so it doesn't block startup — notification_manager needs
|
# a delay so it doesn't block startup — notification_manager needs
|
||||||
# a moment to bind channels before we emit.
|
# a moment to bind channels before we emit.
|
||||||
|
# The guest services below read VMs, LXCs and the PVE node metrics,
|
||||||
|
# none of which exist on a Proxmox Backup Server.
|
||||||
|
pve_host = not is_pbs_host()
|
||||||
try:
|
try:
|
||||||
def _startup_emit_pending_app_updates():
|
def _startup_emit_pending_app_updates():
|
||||||
time.sleep(15) # let channels bind + first metrics collect
|
time.sleep(15) # let channels bind + first metrics collect
|
||||||
@@ -23340,16 +23351,18 @@ if __name__ == '__main__':
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[ProxMenux] startup emit_all_pending_updates failed: {e}",
|
print(f"[ProxMenux] startup emit_all_pending_updates failed: {e}",
|
||||||
file=sys.stderr, flush=True)
|
file=sys.stderr, flush=True)
|
||||||
threading.Thread(target=_startup_emit_pending_app_updates, daemon=True,
|
if pve_host:
|
||||||
name='app-updates-startup-emit').start()
|
threading.Thread(target=_startup_emit_pending_app_updates, daemon=True,
|
||||||
|
name='app-updates-startup-emit').start()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[ProxMenux] app-updates startup emitter failed to arm: {e}")
|
print(f"[ProxMenux] app-updates startup emitter failed to arm: {e}")
|
||||||
|
|
||||||
# Prewarm only the Overview's default day range; other ranges are lazy.
|
# Prewarm only the Overview's default day range; other ranges are lazy.
|
||||||
try:
|
try:
|
||||||
metrics_thread = threading.Thread(target=_node_metrics_prewarmer_loop, daemon=True, name='node-metrics-prewarmer')
|
if pve_host:
|
||||||
metrics_thread.start()
|
metrics_thread = threading.Thread(target=_node_metrics_prewarmer_loop, daemon=True, name='node-metrics-prewarmer')
|
||||||
print("[ProxMenux] Node-metrics prewarmer started (day range, 90s interval)")
|
metrics_thread.start()
|
||||||
|
print("[ProxMenux] Node-metrics prewarmer started (day range, 90s interval)")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[ProxMenux] Node-metrics prewarmer failed to start: {e}")
|
print(f"[ProxMenux] Node-metrics prewarmer failed to start: {e}")
|
||||||
|
|
||||||
@@ -23360,9 +23373,10 @@ if __name__ == '__main__':
|
|||||||
# after the tab has been closed for minutes. The React-side
|
# after the tab has been closed for minutes. The React-side
|
||||||
# prefetcher only ran while the page was open.
|
# prefetcher only ran while the page was open.
|
||||||
try:
|
try:
|
||||||
vm_modal_thread = threading.Thread(target=_vm_modal_prewarmer_loop, daemon=True, name='vm-modal-prewarmer')
|
if pve_host:
|
||||||
vm_modal_thread.start()
|
vm_modal_thread = threading.Thread(target=_vm_modal_prewarmer_loop, daemon=True, name='vm-modal-prewarmer')
|
||||||
print("[ProxMenux] VM-modal prewarmer started (one-shot warm-up; caches refreshed by event invalidation only)")
|
vm_modal_thread.start()
|
||||||
|
print("[ProxMenux] VM-modal prewarmer started (one-shot warm-up; caches refreshed by event invalidation only)")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[ProxMenux] VM-modal prewarmer failed to start: {e}")
|
print(f"[ProxMenux] VM-modal prewarmer failed to start: {e}")
|
||||||
|
|
||||||
@@ -23386,8 +23400,9 @@ if __name__ == '__main__':
|
|||||||
# identical (backup, restart, /usr/bin/update + custom command
|
# identical (backup, restart, /usr/bin/update + custom command
|
||||||
# chain) between manual and scheduled invocations.
|
# chain) between manual and scheduled invocations.
|
||||||
try:
|
try:
|
||||||
scheduler_thread = threading.Thread(target=_scheduler_loop, daemon=True)
|
if pve_host:
|
||||||
scheduler_thread.start()
|
scheduler_thread = threading.Thread(target=_scheduler_loop, daemon=True)
|
||||||
|
scheduler_thread.start()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[ProxMenux] LXC update scheduler failed to start: {e}")
|
print(f"[ProxMenux] LXC update scheduler failed to start: {e}")
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ DEBUG_PERF = False
|
|||||||
# ─── Startup Grace Period ────────────────────────────────────────────────────
|
# ─── Startup Grace Period ────────────────────────────────────────────────────
|
||||||
# Import centralized startup grace management for consistent behavior
|
# Import centralized startup grace management for consistent behavior
|
||||||
import startup_grace
|
import startup_grace
|
||||||
|
from pbs_manager import get_platform, PBS_SERVICES
|
||||||
|
|
||||||
def _is_startup_health_grace() -> bool:
|
def _is_startup_health_grace() -> bool:
|
||||||
"""Check if we're within the startup health grace period (5 min).
|
"""Check if we're within the startup health grace period (5 min).
|
||||||
@@ -440,6 +441,7 @@ class HealthMonitor:
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Initialize health monitor with state tracking"""
|
"""Initialize health monitor with state tracking"""
|
||||||
|
self.platform_type = get_platform().get("product")
|
||||||
self.state_history = defaultdict(list)
|
self.state_history = defaultdict(list)
|
||||||
self.last_check_times = {}
|
self.last_check_times = {}
|
||||||
self.cached_results = {}
|
self.cached_results = {}
|
||||||
@@ -893,9 +895,9 @@ class HealthMonitor:
|
|||||||
_perf_log("services", (time.time() - _t) * 1000)
|
_perf_log("services", (time.time() - _t) * 1000)
|
||||||
details['services'] = services_status
|
details['services'] = services_status
|
||||||
if services_status['status'] == 'CRITICAL':
|
if services_status['status'] == 'CRITICAL':
|
||||||
critical_issues.append(f"PVE Services: {services_status.get('reason', 'Service failure')}")
|
critical_issues.append(f"Proxmox Services: {services_status.get('reason', 'Service failure')}")
|
||||||
elif services_status['status'] == 'WARNING':
|
elif services_status['status'] == 'WARNING':
|
||||||
warning_issues.append(f"PVE Services: {services_status.get('reason', 'Service issue')}")
|
warning_issues.append(f"Proxmox Services: {services_status.get('reason', 'Service issue')}")
|
||||||
|
|
||||||
# Priority 1.5: Proxmox Storage Check (External Module)
|
# Priority 1.5: Proxmox Storage Check (External Module)
|
||||||
_t = time.time()
|
_t = time.time()
|
||||||
@@ -2327,6 +2329,8 @@ class HealthMonitor:
|
|||||||
# The new _check_proxmox_storage function handles this logic better.
|
# The new _check_proxmox_storage function handles this logic better.
|
||||||
def _check_proxmox_storages(self) -> Dict[str, Any]:
|
def _check_proxmox_storages(self) -> Dict[str, Any]:
|
||||||
"""Check Proxmox-specific storages (only report problems)"""
|
"""Check Proxmox-specific storages (only report problems)"""
|
||||||
|
if getattr(self, "platform_type", "pve") == "pbs":
|
||||||
|
return {'status': 'OK', 'reason': 'Not applicable on PBS', 'checks': {}, 'not_applicable': True}
|
||||||
storages = {}
|
storages = {}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -3495,6 +3499,8 @@ class HealthMonitor:
|
|||||||
Optimized VM/CT check - detects qmp failures and startup errors from logs.
|
Optimized VM/CT check - detects qmp failures and startup errors from logs.
|
||||||
Improved detection of container and VM errors from journalctl.
|
Improved detection of container and VM errors from journalctl.
|
||||||
"""
|
"""
|
||||||
|
if getattr(self, "platform_type", "pve") == "pbs":
|
||||||
|
return {'status': 'OK', 'reason': 'Not applicable on PBS', 'checks': {}, 'not_applicable': True}
|
||||||
try:
|
try:
|
||||||
# First: auto-resolve any persisted VM/CT errors where the guest
|
# First: auto-resolve any persisted VM/CT errors where the guest
|
||||||
# is now running. This clears stale "Failed to start" / QMP
|
# is now running. This clears stale "Failed to start" / QMP
|
||||||
@@ -3658,6 +3664,8 @@ class HealthMonitor:
|
|||||||
Check VMs/CTs with persistent error tracking.
|
Check VMs/CTs with persistent error tracking.
|
||||||
Errors persist until VM starts or 48h elapsed.
|
Errors persist until VM starts or 48h elapsed.
|
||||||
"""
|
"""
|
||||||
|
if getattr(self, "platform_type", "pve") == "pbs":
|
||||||
|
return {'status': 'OK', 'reason': 'Not applicable on PBS', 'checks': {}, 'not_applicable': True}
|
||||||
try:
|
try:
|
||||||
issues = []
|
issues = []
|
||||||
vm_details = {}
|
vm_details = {}
|
||||||
@@ -3899,8 +3907,18 @@ class HealthMonitor:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Build service list: base PVE services + corosync if clustered
|
# Build service list: base PVE services + corosync if clustered
|
||||||
services_to_check = list(self.PVE_SERVICES)
|
pbs_host = getattr(self, "platform_type", "pve") == "pbs"
|
||||||
is_cluster = os.path.exists('/etc/corosync/corosync.conf')
|
service_prefix = "pbs" if pbs_host else "pve"
|
||||||
|
product_name = "PBS" if pbs_host else "PVE"
|
||||||
|
services_to_check = list(PBS_SERVICES) if pbs_host else list(self.PVE_SERVICES)
|
||||||
|
if pbs_host:
|
||||||
|
# Old Monitor versions incorrectly treated absent PVE services
|
||||||
|
# as PBS failures; remove only those known inapplicable errors.
|
||||||
|
for service in self.PVE_SERVICES:
|
||||||
|
old_key = f'pve_service_{service}'
|
||||||
|
if health_persistence.is_error_active(old_key):
|
||||||
|
health_persistence.clear_error(old_key)
|
||||||
|
is_cluster = not pbs_host and os.path.exists('/etc/corosync/corosync.conf')
|
||||||
if is_cluster and 'corosync' not in services_to_check:
|
if is_cluster and 'corosync' not in services_to_check:
|
||||||
services_to_check.append('corosync')
|
services_to_check.append('corosync')
|
||||||
|
|
||||||
@@ -3932,14 +3950,14 @@ class HealthMonitor:
|
|||||||
# cleanup loop). Audit Tier 5 — Health stack.
|
# cleanup loop). Audit Tier 5 — Health stack.
|
||||||
for svc in services_to_check:
|
for svc in services_to_check:
|
||||||
if svc not in failed_services:
|
if svc not in failed_services:
|
||||||
error_key = f'pve_service_{svc}'
|
error_key = f'{service_prefix}_service_{svc}'
|
||||||
if health_persistence.is_error_active(error_key):
|
if health_persistence.is_error_active(error_key):
|
||||||
health_persistence.clear_error(error_key)
|
health_persistence.clear_error(error_key)
|
||||||
|
|
||||||
# Build checks dict with status per service
|
# Build checks dict with status per service
|
||||||
checks = {}
|
checks = {}
|
||||||
for svc in services_to_check:
|
for svc in services_to_check:
|
||||||
error_key = f'pve_service_{svc}'
|
error_key = f'{service_prefix}_service_{svc}'
|
||||||
if svc in failed_services:
|
if svc in failed_services:
|
||||||
state = service_details.get(svc, 'inactive')
|
state = service_details.get(svc, 'inactive')
|
||||||
checks[svc] = {
|
checks[svc] = {
|
||||||
@@ -3967,12 +3985,12 @@ class HealthMonitor:
|
|||||||
# Record each failed service in persistence, respecting dismiss
|
# Record each failed service in persistence, respecting dismiss
|
||||||
active_failed = []
|
active_failed = []
|
||||||
for svc in failed_services:
|
for svc in failed_services:
|
||||||
error_key = f'pve_service_{svc}'
|
error_key = f'{service_prefix}_service_{svc}'
|
||||||
rec_result = health_persistence.record_error(
|
rec_result = health_persistence.record_error(
|
||||||
error_key=error_key,
|
error_key=error_key,
|
||||||
category='pve_services',
|
category='pve_services',
|
||||||
severity='CRITICAL',
|
severity='CRITICAL',
|
||||||
reason=f'PVE service {svc} is {service_details.get(svc, "inactive")}',
|
reason=f'{product_name} service {svc} is {service_details.get(svc, "inactive")}',
|
||||||
details={'service': svc, 'state': service_details.get(svc, 'inactive')}
|
details={'service': svc, 'state': service_details.get(svc, 'inactive')}
|
||||||
)
|
)
|
||||||
if rec_result and rec_result.get('type') == 'skipped_acknowledged':
|
if rec_result and rec_result.get('type') == 'skipped_acknowledged':
|
||||||
@@ -5796,6 +5814,8 @@ class HealthMonitor:
|
|||||||
- No persistent errors are recorded
|
- No persistent errors are recorded
|
||||||
This prevents false positives when NFS/PBS/remote storage is still mounting.
|
This prevents false positives when NFS/PBS/remote storage is still mounting.
|
||||||
"""
|
"""
|
||||||
|
if getattr(self, "platform_type", "pve") == "pbs":
|
||||||
|
return None
|
||||||
if not PROXMOX_STORAGE_AVAILABLE:
|
if not PROXMOX_STORAGE_AVAILABLE:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -6185,6 +6205,8 @@ class HealthMonitor:
|
|||||||
Notification cooldown is the standard 1h via the storage
|
Notification cooldown is the standard 1h via the storage
|
||||||
category, so a CT teetering at 96% won't spam the channel.
|
category, so a CT teetering at 96% won't spam the channel.
|
||||||
"""
|
"""
|
||||||
|
if getattr(self, "platform_type", "pve") == "pbs":
|
||||||
|
return None
|
||||||
# Cheap pre-check: bail out when there are no running LXCs. On
|
# Cheap pre-check: bail out when there are no running LXCs. On
|
||||||
# nodes without CTs, the previous version still paid ~860ms per
|
# nodes without CTs, the previous version still paid ~860ms per
|
||||||
# cycle for a `pvesh /cluster/resources` call that produced no
|
# cycle for a `pvesh /cluster/resources` call that produced no
|
||||||
@@ -6345,6 +6367,8 @@ class HealthMonitor:
|
|||||||
path, so a slow / dead guest agent can't stretch the health
|
path, so a slow / dead guest agent can't stretch the health
|
||||||
cycle.
|
cycle.
|
||||||
"""
|
"""
|
||||||
|
if getattr(self, "platform_type", "pve") == "pbs":
|
||||||
|
return None
|
||||||
try:
|
try:
|
||||||
import flask_server # deferred — avoids circular import
|
import flask_server # deferred — avoids circular import
|
||||||
resources = flask_server.get_cached_pvesh_cluster_resources_vm() or []
|
resources = flask_server.get_cached_pvesh_cluster_resources_vm() or []
|
||||||
@@ -6504,6 +6528,8 @@ class HealthMonitor:
|
|||||||
check (already covered) because those are usually large data
|
check (already covered) because those are usually large data
|
||||||
mounts whose fill rate is driven by user content, not log spam.
|
mounts whose fill rate is driven by user content, not log spam.
|
||||||
"""
|
"""
|
||||||
|
if getattr(self, "platform_type", "pve") == "pbs":
|
||||||
|
return None
|
||||||
if not MOUNT_MONITOR_AVAILABLE:
|
if not MOUNT_MONITOR_AVAILABLE:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
@@ -6607,6 +6633,8 @@ class HealthMonitor:
|
|||||||
check via _check_filesystem. Re-alerting on the same condition
|
check via _check_filesystem. Re-alerting on the same condition
|
||||||
through two channels just clutters the inbox.
|
through two channels just clutters the inbox.
|
||||||
"""
|
"""
|
||||||
|
if getattr(self, "platform_type", "pve") == "pbs":
|
||||||
|
return None
|
||||||
if not PROXMOX_STORAGE_AVAILABLE:
|
if not PROXMOX_STORAGE_AVAILABLE:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -3850,6 +3850,13 @@ class PollingCollector:
|
|||||||
return
|
return
|
||||||
self._last_managed_check = now
|
self._last_managed_check = now
|
||||||
|
|
||||||
|
try:
|
||||||
|
from pbs_manager import is_pbs_host
|
||||||
|
if is_pbs_host():
|
||||||
|
return # a Proxmox Backup Server has no guests or integrations to track
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import managed_installs
|
import managed_installs
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
"""Local PBS maintenance information and remote, read-only PBS monitoring."""
|
||||||
|
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import http.client
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import shutil
|
||||||
|
import ssl
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
import auth_manager
|
||||||
|
|
||||||
|
try:
|
||||||
|
from proxmox_platform import detect_platform
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
# Development checkout; AppImage bundles the canonical shared detector.
|
||||||
|
_path = Path(__file__).resolve().parents[2] / "scripts/global/proxmox_platform.py"
|
||||||
|
_spec = importlib.util.spec_from_file_location("proxmox_platform", _path)
|
||||||
|
_platform_module = importlib.util.module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(_platform_module)
|
||||||
|
detect_platform = _platform_module.detect_platform
|
||||||
|
|
||||||
|
_lock = threading.RLock()
|
||||||
|
_platform_cache = (0, {})
|
||||||
|
_status_cache = {}
|
||||||
|
PBS_SERVICES = ("proxmox-backup", "proxmox-backup-proxy")
|
||||||
|
MAX_RESPONSE = 4 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
class PBSError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def get_platform():
|
||||||
|
global _platform_cache
|
||||||
|
with _lock:
|
||||||
|
if _platform_cache[0] <= time.monotonic():
|
||||||
|
_platform_cache = (time.monotonic() + 60, detect_platform())
|
||||||
|
return dict(_platform_cache[1])
|
||||||
|
|
||||||
|
|
||||||
|
def is_pbs_host():
|
||||||
|
return get_platform().get("product") == "pbs"
|
||||||
|
|
||||||
|
|
||||||
|
def _servers_path():
|
||||||
|
return auth_manager.CONFIG_DIR / "pbs_servers.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_servers():
|
||||||
|
try:
|
||||||
|
data = json.loads(_servers_path().read_text())
|
||||||
|
if not isinstance(data, list) or not all(isinstance(row, dict) for row in data):
|
||||||
|
raise ValueError()
|
||||||
|
return data
|
||||||
|
except FileNotFoundError:
|
||||||
|
return []
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
raise PBSError("Could not read the PBS server configuration.") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _save_servers(servers):
|
||||||
|
auth_manager.ensure_config_dir()
|
||||||
|
descriptor, temporary = tempfile.mkstemp(prefix=".pbs-servers-", dir=auth_manager.CONFIG_DIR)
|
||||||
|
try:
|
||||||
|
with os.fdopen(descriptor, "w") as handle:
|
||||||
|
os.fchmod(handle.fileno(), 0o600)
|
||||||
|
json.dump(servers, handle, indent=2)
|
||||||
|
os.replace(temporary, _servers_path())
|
||||||
|
finally:
|
||||||
|
if os.path.exists(temporary):
|
||||||
|
os.unlink(temporary)
|
||||||
|
_status_cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def public_servers():
|
||||||
|
fields = ("id", "name", "url", "token_id", "fingerprint")
|
||||||
|
with _lock:
|
||||||
|
return [{**{key: row.get(key, "") for key in fields}, "has_token": bool(row.get("token_secret"))}
|
||||||
|
for row in _load_servers()]
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_server(data, previous=None):
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise PBSError("Invalid PBS server configuration.")
|
||||||
|
previous = previous or {}
|
||||||
|
row = {field: data.get(field, previous.get(field, ""))
|
||||||
|
for field in ("name", "url", "token_id", "token_secret", "fingerprint")}
|
||||||
|
if any(not isinstance(value, str) or len(value) > 4096 for value in row.values()):
|
||||||
|
raise PBSError("Invalid PBS connection field.")
|
||||||
|
row["name"] = row["name"].strip()
|
||||||
|
row["url"] = row["url"].strip().rstrip("/")
|
||||||
|
try:
|
||||||
|
parts = urlsplit(row["url"])
|
||||||
|
parts.port
|
||||||
|
if (parts.scheme != "https" or not parts.hostname or parts.username or parts.password
|
||||||
|
or parts.query or parts.fragment):
|
||||||
|
raise ValueError()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise PBSError("Use an HTTPS PBS URL without embedded credentials, query or fragment.") from exc
|
||||||
|
if not row["name"] or len(row["name"]) > 128:
|
||||||
|
raise PBSError("A server name of at most 128 characters is required.")
|
||||||
|
row["token_id"] = row["token_id"].strip()
|
||||||
|
if not re.fullmatch(r"[^\s:!]+@[^\s:!]+![^\s:!]+", row["token_id"]):
|
||||||
|
raise PBSError("Enter a PBS API token ID in user@realm!token format.")
|
||||||
|
if (not row["token_secret"] or not row["token_secret"].isascii()
|
||||||
|
or any(char.isspace() or ord(char) < 32 for char in row["token_secret"])):
|
||||||
|
raise PBSError("A valid API token secret is required.")
|
||||||
|
if not row["token_id"].isascii():
|
||||||
|
raise PBSError("Invalid API token ID.")
|
||||||
|
fingerprint = row["fingerprint"].strip().lower().replace(":", "")
|
||||||
|
if fingerprint and not re.fullmatch("[a-f0-9]{64}", fingerprint):
|
||||||
|
raise PBSError("The certificate fingerprint must be SHA256 (64 hex characters).")
|
||||||
|
row["fingerprint"] = fingerprint
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _remote_get(server, endpoint):
|
||||||
|
"""Validate the TLS peer BEFORE transmitting the PBS token (no redirects)."""
|
||||||
|
parts = urlsplit(server["url"])
|
||||||
|
fingerprint = server.get("fingerprint")
|
||||||
|
if fingerprint:
|
||||||
|
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||||
|
context.check_hostname = False
|
||||||
|
context.verify_mode = ssl.CERT_NONE # Authenticated by the explicit pin below.
|
||||||
|
else:
|
||||||
|
context = ssl.create_default_context()
|
||||||
|
connection = http.client.HTTPSConnection(parts.hostname, parts.port or 443,
|
||||||
|
timeout=5, context=context)
|
||||||
|
try:
|
||||||
|
connection.connect()
|
||||||
|
if fingerprint:
|
||||||
|
actual = hashlib.sha256(connection.sock.getpeercert(binary_form=True)).hexdigest()
|
||||||
|
if not hmac.compare_digest(actual, fingerprint):
|
||||||
|
raise PBSError("PBS certificate fingerprint does not match. No credentials were sent.")
|
||||||
|
path = parts.path.rstrip("/") + "/api2/json" + endpoint
|
||||||
|
connection.request("GET", path, headers={
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Authorization": "PBSAPIToken=" + server["token_id"] + ":" + server["token_secret"],
|
||||||
|
})
|
||||||
|
response = connection.getresponse()
|
||||||
|
content = response.read(MAX_RESPONSE + 1)
|
||||||
|
if response.status in (401, 403):
|
||||||
|
raise PBSError("PBS rejected the API token or its permissions. Check both the user and token ACLs.")
|
||||||
|
if response.status != 200 or len(content) > MAX_RESPONSE:
|
||||||
|
raise PBSError("PBS returned an invalid response. Redirects are not followed.")
|
||||||
|
document = json.loads(content)
|
||||||
|
if not isinstance(document, dict) or "data" not in document:
|
||||||
|
raise PBSError("PBS returned an invalid JSON document.")
|
||||||
|
return document["data"]
|
||||||
|
except PBSError:
|
||||||
|
raise
|
||||||
|
except (OSError, http.client.HTTPException, ValueError) as exc:
|
||||||
|
raise PBSError("Could not connect to PBS over verified HTTPS. Check the address, certificate and service.") from exc
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
|
||||||
|
def save_server(data):
|
||||||
|
identifier = data.get("id") if isinstance(data, dict) else None
|
||||||
|
if identifier is not None and not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", str(identifier)):
|
||||||
|
raise PBSError("Invalid PBS server ID.")
|
||||||
|
with _lock:
|
||||||
|
previous = next((row for row in _load_servers() if row["id"] == identifier), None)
|
||||||
|
row = _validate_server(data, previous)
|
||||||
|
# /version may be public; validate credentials on the protected status API.
|
||||||
|
usage = _remote_get(row, "/status/datastore-usage")
|
||||||
|
if not isinstance(usage, list):
|
||||||
|
raise PBSError("PBS returned an invalid datastore list.")
|
||||||
|
row.update(id=identifier or "pbs-" + secrets.token_hex(6), revision=secrets.token_hex(16))
|
||||||
|
with _lock:
|
||||||
|
servers = _load_servers()
|
||||||
|
if previous is None and len(servers) >= 32:
|
||||||
|
raise PBSError("At most 32 PBS connections may be configured.")
|
||||||
|
servers = [existing for existing in servers if existing["id"] != row["id"]]
|
||||||
|
_save_servers([*servers, row])
|
||||||
|
return row["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def delete_server(identifier):
|
||||||
|
with _lock:
|
||||||
|
servers = _load_servers()
|
||||||
|
remaining = [row for row in servers if row["id"] != identifier]
|
||||||
|
if len(remaining) == len(servers):
|
||||||
|
raise PBSError("PBS server not found.")
|
||||||
|
_save_servers(remaining)
|
||||||
|
|
||||||
|
|
||||||
|
def _cli(*arguments):
|
||||||
|
try:
|
||||||
|
result = subprocess.run(["proxmox-backup-manager", *arguments, "--output-format", "json"],
|
||||||
|
check=True, text=True, capture_output=True, timeout=8)
|
||||||
|
data = json.loads(result.stdout)
|
||||||
|
if not isinstance(data, list) or not all(isinstance(row, dict) for row in data):
|
||||||
|
raise ValueError()
|
||||||
|
return data
|
||||||
|
except (OSError, subprocess.SubprocessError, ValueError) as exc:
|
||||||
|
raise PBSError("Could not read PBS " + " ".join(arguments[:2]) + ". Check its service and permissions.") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _local_datastores():
|
||||||
|
stores = _cli("datastore", "list")
|
||||||
|
result = []
|
||||||
|
for store in stores:
|
||||||
|
row = {"name": store.get("name"), "path": store.get("path"),
|
||||||
|
"maintenance_mode": store.get("maintenance-mode"), "total": None, "used": None, "avail": None}
|
||||||
|
path = store.get("path")
|
||||||
|
# A removable datastore directory may survive unmounting. Do not report
|
||||||
|
# its underlying root filesystem as the missing datastore's capacity.
|
||||||
|
inactive = store.get("backing-device") and not (path and os.path.ismount(path))
|
||||||
|
if not path or inactive or store.get("maintenance-mode"):
|
||||||
|
row["error"] = "Datastore is offline, in maintenance or has no mounted backing device."
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
capacity = shutil.disk_usage(path)
|
||||||
|
row.update(total=capacity.total, used=capacity.used, avail=capacity.free)
|
||||||
|
except OSError:
|
||||||
|
row["error"] = "Datastore filesystem is not available."
|
||||||
|
result.append(row)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _local_services():
|
||||||
|
result = {}
|
||||||
|
for service in PBS_SERVICES:
|
||||||
|
try:
|
||||||
|
check = subprocess.run(["systemctl", "is-active", service], capture_output=True, text=True, timeout=3)
|
||||||
|
result[service] = check.stdout.strip() if check.returncode == 0 else "inactive"
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
result[service] = "unknown"
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_status(identifier):
|
||||||
|
with _lock:
|
||||||
|
cached = _status_cache.get(identifier)
|
||||||
|
if cached and cached[0] > time.monotonic():
|
||||||
|
return cached[1]
|
||||||
|
errors = {}
|
||||||
|
if identifier == "local":
|
||||||
|
if not is_pbs_host():
|
||||||
|
raise PBSError("The local host is not a PBS server.")
|
||||||
|
name, version = "Local PBS", get_platform().get("version")
|
||||||
|
services = _local_services()
|
||||||
|
operations = {"datastores": _local_datastores,
|
||||||
|
"tasks": lambda: _cli("task", "list", "--all", "true", "--limit", "20"),
|
||||||
|
"sync": lambda: _cli("sync-job", "list"),
|
||||||
|
"verify": lambda: _cli("verify-job", "list"),
|
||||||
|
"prune": lambda: _cli("prune-job", "list")}
|
||||||
|
else:
|
||||||
|
with _lock:
|
||||||
|
server = next((row for row in _load_servers() if row["id"] == identifier), None)
|
||||||
|
if not server:
|
||||||
|
raise PBSError("PBS server not found.")
|
||||||
|
metadata = _remote_get(server, "/version")
|
||||||
|
name, version = server["name"], metadata.get("version") if isinstance(metadata, dict) else None
|
||||||
|
services = {}
|
||||||
|
endpoints = {"datastores": "/status/datastore-usage", "tasks": "/nodes/localhost/tasks?limit=20",
|
||||||
|
"sync": "/admin/sync", "verify": "/admin/verify", "prune": "/admin/prune"}
|
||||||
|
operations = {key: (lambda endpoint=path: _remote_get(server, endpoint)) for key, path in endpoints.items()}
|
||||||
|
result = {"server": identifier, "name": name, "version": version, "services": services,
|
||||||
|
"fetched_at": int(time.time()), "errors": errors}
|
||||||
|
with ThreadPoolExecutor(max_workers=5) as executor:
|
||||||
|
futures = {key: executor.submit(operation) for key, operation in operations.items()}
|
||||||
|
for key, future in futures.items():
|
||||||
|
try:
|
||||||
|
data = future.result()
|
||||||
|
if not isinstance(data, list) or not all(isinstance(row, dict) for row in data):
|
||||||
|
raise PBSError("PBS returned an invalid list.")
|
||||||
|
result[key] = [{**row, "name": row.get("store", row.get("name"))} for row in data] if key == "datastores" else data
|
||||||
|
except PBSError as exc:
|
||||||
|
result[key] = None # Unknown/forbidden is never presented as zero.
|
||||||
|
errors[key] = str(exc)
|
||||||
|
with _lock:
|
||||||
|
if len(_status_cache) >= 33:
|
||||||
|
_status_cache.clear()
|
||||||
|
_status_cache[identifier] = (time.monotonic() + 15, result)
|
||||||
|
return result
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
"""PBS platform, HTTPS protocol, authorization and isolated AppImage regressions."""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import hashlib
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import shlex
|
||||||
|
import ssl
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
sys.path.insert(0, str(ROOT / "AppImage/scripts"))
|
||||||
|
|
||||||
|
from cryptography import x509
|
||||||
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
|
from cryptography.x509.oid import NameOID
|
||||||
|
from flask import Flask
|
||||||
|
import jwt
|
||||||
|
import auth_manager
|
||||||
|
import pbs_manager as pbs
|
||||||
|
from flask_pbs_routes import pbs_bp
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformTests(unittest.TestCase):
|
||||||
|
def test_supported_platforms_and_partial_dpkg_failure(self):
|
||||||
|
module = pbs.detect_platform.__globals__
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
release = Path(directory) / "os-release"
|
||||||
|
for package, version, suite, product in (
|
||||||
|
("pve-manager", "8.4.15", "bookworm", "pve"),
|
||||||
|
("pve-manager", "9.2.1", "trixie", "pve"),
|
||||||
|
("proxmox-backup-server", "3.4.0-1", "bookworm", "pbs"),
|
||||||
|
("proxmox-backup-server", "4.2.5-1", "trixie", "pbs"),
|
||||||
|
):
|
||||||
|
release.write_text(f'VERSION_CODENAME="{suite}"\n')
|
||||||
|
output = f"{package}\tinstall ok installed\t{version}\n"
|
||||||
|
with self.subTest(product=product, version=version), \
|
||||||
|
mock.patch.dict(module, {"OS_RELEASE": release}), \
|
||||||
|
mock.patch.object(subprocess, "run", return_value=SimpleNamespace(stdout=output, returncode=1)):
|
||||||
|
detected = pbs.detect_platform()
|
||||||
|
self.assertEqual(detected["product"], product)
|
||||||
|
self.assertEqual(detected["version"], version)
|
||||||
|
self.assertTrue(detected["supported"])
|
||||||
|
|
||||||
|
def test_client_only_and_removed_server_are_not_pbs(self):
|
||||||
|
for output in ("proxmox-backup-client\tinstall ok installed\t4.2.5\n",
|
||||||
|
"proxmox-backup-server\tdeinstall ok config-files\t4.2.5\n", ""):
|
||||||
|
with self.subTest(output=output), mock.patch.object(subprocess, "run", return_value=SimpleNamespace(stdout=output)):
|
||||||
|
self.assertEqual(pbs.detect_platform()["product"], "unknown")
|
||||||
|
|
||||||
|
def test_pbs_utilities_preserve_sources_and_never_call_pve_policy(self):
|
||||||
|
helper = ROOT / "scripts/global/repository-functions.sh"
|
||||||
|
for supported in (True, False):
|
||||||
|
harness = f'''
|
||||||
|
source {shlex.quote(str(helper))}
|
||||||
|
pmx_platform_field() {{ if [[ "$1" == product ]]; then echo pbs; else echo {str(supported).lower()}; fi; }}
|
||||||
|
msg_info() {{ :; }}
|
||||||
|
msg_error() {{ :; }}
|
||||||
|
translate() {{ printf '%s' "$1"; }}
|
||||||
|
pveversion() {{ echo 'FORBIDDEN: pveversion'; return 90; }}
|
||||||
|
repository_policy() {{ echo 'FORBIDDEN: repository mutation'; return 90; }}
|
||||||
|
apt-get() {{ echo 'FORBIDDEN: apt'; return 90; }}
|
||||||
|
ensure_repositories
|
||||||
|
'''
|
||||||
|
result = subprocess.run(["bash"], input=harness, text=True, capture_output=True)
|
||||||
|
self.assertNotIn("FORBIDDEN", result.stdout + result.stderr)
|
||||||
|
self.assertEqual(result.returncode, 0 if supported else 1)
|
||||||
|
|
||||||
|
|
||||||
|
class PBSManagerTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
temporary = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(temporary.cleanup)
|
||||||
|
self.root = Path(temporary.name)
|
||||||
|
patch = mock.patch.multiple(auth_manager, CONFIG_DIR=self.root, AUTH_CONFIG_FILE=self.root / "auth.json")
|
||||||
|
patch.start(); self.addCleanup(patch.stop)
|
||||||
|
auth_manager.save_auth_config({"enabled": True, "username": "admin", "password_hash": "configured",
|
||||||
|
"jwt_secret": "s" * 48, "api_tokens": [], "revoked_tokens": []})
|
||||||
|
auth_manager._invalidate_revoked_cache()
|
||||||
|
pbs._status_cache.clear()
|
||||||
|
self.server = {"name": "Backup", "url": "https://pbs.test:8007", "token_id": "audit@pbs!monitor",
|
||||||
|
"token_secret": "fixture-token-secret", "fingerprint": "a" * 64}
|
||||||
|
app = Flask(__name__); app.register_blueprint(pbs_bp)
|
||||||
|
self.client = app.test_client()
|
||||||
|
self.headers = {"Authorization": "Bearer " + auth_manager.generate_token("admin")}
|
||||||
|
|
||||||
|
def test_save_redacts_secret_and_probes_authenticated_endpoint(self):
|
||||||
|
with mock.patch.object(pbs, "_remote_get", return_value=[]) as request:
|
||||||
|
identifier = pbs.save_server(self.server)
|
||||||
|
request.assert_called_once()
|
||||||
|
self.assertEqual(request.call_args.args[1], "/status/datastore-usage")
|
||||||
|
self.assertEqual((self.root / "pbs_servers.json").stat().st_mode & 0o777, 0o600)
|
||||||
|
public = pbs.public_servers()
|
||||||
|
self.assertEqual(public[0]["id"], identifier)
|
||||||
|
self.assertNotIn("fixture-token-secret", json.dumps(public))
|
||||||
|
self.assertTrue(public[0]["has_token"])
|
||||||
|
with mock.patch.object(pbs, "_remote_get", return_value=[]):
|
||||||
|
pbs.save_server({"id": identifier, "name": "Renamed"})
|
||||||
|
self.assertEqual(pbs._load_servers()[0]["token_secret"], "fixture-token-secret")
|
||||||
|
pbs.delete_server(identifier)
|
||||||
|
self.assertEqual(pbs.public_servers(), [])
|
||||||
|
|
||||||
|
def test_failed_credentials_or_invalid_config_do_not_replace_saved_connection(self):
|
||||||
|
with mock.patch.object(pbs, "_remote_get", return_value=[]):
|
||||||
|
identifier = pbs.save_server(self.server)
|
||||||
|
original = (self.root / "pbs_servers.json").read_bytes()
|
||||||
|
with mock.patch.object(pbs, "_remote_get", side_effect=pbs.PBSError("Rejected token")):
|
||||||
|
with self.assertRaises(pbs.PBSError):
|
||||||
|
pbs.save_server({**self.server, "id": identifier, "token_secret": "wrong"})
|
||||||
|
for changes in ({"url": "http://pbs.test"}, {"url": "https://user:pass@pbs.test"},
|
||||||
|
{"fingerprint": "bad-pin"}, {"token_id": "not-a-token"},
|
||||||
|
{"token_secret": "line\nbreak"}):
|
||||||
|
with self.subTest(changes=changes), self.assertRaises(pbs.PBSError):
|
||||||
|
pbs.save_server({**self.server, **changes, "id": identifier})
|
||||||
|
self.assertEqual((self.root / "pbs_servers.json").read_bytes(), original)
|
||||||
|
|
||||||
|
def test_remote_partial_failure_is_unknown_instead_of_zero(self):
|
||||||
|
def remote(server, endpoint):
|
||||||
|
if endpoint == "/version": return {"version": "4.2.5"}
|
||||||
|
if endpoint == "/status/datastore-usage": return [{"store": "backup", "total": 100, "used": 30, "avail": 70}]
|
||||||
|
if endpoint == "/admin/verify": raise pbs.PBSError("Permission denied")
|
||||||
|
return []
|
||||||
|
with mock.patch.object(pbs, "_remote_get", side_effect=remote) as request:
|
||||||
|
identifier = pbs.save_server(self.server)
|
||||||
|
result = pbs.get_status(identifier)
|
||||||
|
count = request.call_count
|
||||||
|
self.assertEqual(pbs.get_status(identifier), result)
|
||||||
|
self.assertEqual(request.call_count, count)
|
||||||
|
self.assertEqual(result["datastores"][0]["name"], "backup")
|
||||||
|
self.assertIsNone(result["verify"])
|
||||||
|
self.assertIn("verify", result["errors"])
|
||||||
|
|
||||||
|
def test_unmounted_removable_datastore_has_no_fabricated_capacity(self):
|
||||||
|
with mock.patch.object(pbs, "_cli", return_value=[{"name": "removable", "path": "/not-mounted", "backing-device": "uuid"}]), \
|
||||||
|
mock.patch.object(pbs.os.path, "ismount", return_value=False), \
|
||||||
|
mock.patch.object(pbs.shutil, "disk_usage") as usage:
|
||||||
|
result = pbs._local_datastores()
|
||||||
|
usage.assert_not_called()
|
||||||
|
self.assertIsNone(result[0]["total"])
|
||||||
|
self.assertIn("error", result[0])
|
||||||
|
|
||||||
|
def test_local_status_reads_pbs_commands_and_filesystem_capacity(self):
|
||||||
|
def run(command, **kwargs):
|
||||||
|
self.assertNotIn("shell", kwargs)
|
||||||
|
self.assertEqual(command[0], "proxmox-backup-manager")
|
||||||
|
self.assertEqual(command[-2:], ["--output-format", "json"])
|
||||||
|
data = [{"name": "backup", "path": str(self.root)}] if command[1:3] == ["datastore", "list"] else []
|
||||||
|
return SimpleNamespace(stdout=json.dumps(data), returncode=0)
|
||||||
|
with mock.patch.object(pbs, "get_platform", return_value={"product": "pbs", "version": "4.2.5"}), \
|
||||||
|
mock.patch.object(pbs, "_local_services", return_value={key: "active" for key in pbs.PBS_SERVICES}), \
|
||||||
|
mock.patch.object(pbs.subprocess, "run", side_effect=run), \
|
||||||
|
mock.patch.object(pbs.shutil, "disk_usage", return_value=SimpleNamespace(total=100, used=30, free=70)):
|
||||||
|
result = pbs.get_status("local")
|
||||||
|
self.assertEqual(result["version"], "4.2.5")
|
||||||
|
self.assertEqual(result["errors"], {})
|
||||||
|
self.assertEqual(result["datastores"][0]["used"], 30)
|
||||||
|
self.assertEqual(set(result["services"]), set(pbs.PBS_SERVICES))
|
||||||
|
|
||||||
|
def test_connection_api_requires_full_admin_and_does_not_return_secret(self):
|
||||||
|
self.assertEqual(self.client.get("/api/pbs/servers").status_code, 401)
|
||||||
|
with mock.patch.object(pbs, "_remote_get", return_value=[]):
|
||||||
|
response = self.client.post("/api/pbs/servers", headers=self.headers, json=self.server)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertNotIn("fixture-token-secret", response.text)
|
||||||
|
payload = jwt.decode(self.headers["Authorization"].split()[1], "s" * 48,
|
||||||
|
algorithms=["HS256"], audience=auth_manager.JWT_AUDIENCE)
|
||||||
|
payload["scope"] = "read_only"
|
||||||
|
read_only = {"Authorization": "Bearer " + jwt.encode(payload, "s" * 48, algorithm="HS256")}
|
||||||
|
self.assertEqual(self.client.post("/api/pbs/servers", headers=read_only, json=self.server).status_code, 403)
|
||||||
|
self.assertEqual(self.client.get("/api/pbs/servers", headers=read_only).status_code, 200)
|
||||||
|
auth_manager.save_auth_config({"enabled": False})
|
||||||
|
self.assertEqual(self.client.post("/api/pbs/servers", json=self.server).status_code, 409)
|
||||||
|
|
||||||
|
def test_health_uses_pbs_services_and_skips_pve_only_checks(self):
|
||||||
|
source = ast.parse((ROOT / "AppImage/scripts/health_monitor.py").read_text())
|
||||||
|
names = ("_check_pve_services", "_check_vms_cts_optimized", "_check_vms_cts_with_persistence",
|
||||||
|
"_check_proxmox_storages", "_check_proxmox_storage", "_check_lxc_disk_usage",
|
||||||
|
"_check_vm_disk_usage", "_check_lxc_mount_capacity", "_check_pve_storage_capacity")
|
||||||
|
functions = [node for node in ast.walk(source) if isinstance(node, ast.FunctionDef) and node.name in names]
|
||||||
|
persistence = mock.Mock(); persistence.is_error_active.return_value = False
|
||||||
|
run = mock.Mock(return_value=SimpleNamespace(returncode=0, stdout="active\n"))
|
||||||
|
namespace = {"Dict": dict, "Any": object, "Optional": __import__('typing').Optional,
|
||||||
|
"PBS_SERVICES": pbs.PBS_SERVICES, "health_persistence": persistence,
|
||||||
|
"subprocess": SimpleNamespace(run=run), "os": __import__('os')}
|
||||||
|
exec(compile(ast.Module(body=functions, type_ignores=[]), '<health-pbs-test>', 'exec'), namespace)
|
||||||
|
instance = SimpleNamespace(platform_type="pbs", PVE_SERVICES=["pveproxy", "pvedaemon", "pvestatd", "pve-cluster"])
|
||||||
|
services = namespace["_check_pve_services"](instance)
|
||||||
|
self.assertEqual(services["status"], "OK")
|
||||||
|
self.assertEqual([call.args[0][2] for call in run.call_args_list], list(pbs.PBS_SERVICES))
|
||||||
|
for name in names[1:]:
|
||||||
|
result = namespace[name](instance)
|
||||||
|
self.assertTrue(result is None or result.get("not_applicable"))
|
||||||
|
|
||||||
|
def test_appimage_copies_and_imports_pbs_dependencies_outside_source_tree(self):
|
||||||
|
builder = (ROOT / "AppImage/scripts/build_appimage.sh").read_text()
|
||||||
|
modules = ("pbs_manager", "flask_pbs_routes", "auth_manager", "jwt_middleware")
|
||||||
|
copies = []
|
||||||
|
for name in modules:
|
||||||
|
matches = [line for line in builder.splitlines() if line.startswith(f'cp "$SCRIPT_DIR/{name}.py"')]
|
||||||
|
self.assertEqual(len(matches), 1, name)
|
||||||
|
copies.extend(matches)
|
||||||
|
copies.extend(line for line in builder.splitlines() if line.startswith('cp "$APPIMAGE_ROOT/../scripts/global/proxmox_platform.py"'))
|
||||||
|
self.assertEqual(len(copies), len(modules) + 1)
|
||||||
|
app = self.root / 'AppDir'; (app / 'usr/bin').mkdir(parents=True)
|
||||||
|
q = shlex.quote
|
||||||
|
shell = f'APP_DIR={q(str(app))}\nSCRIPT_DIR={q(str(ROOT / "AppImage/scripts"))}\nAPPIMAGE_ROOT={q(str(ROOT / "AppImage"))}\n' + '\n'.join(copies)
|
||||||
|
subprocess.run(['bash', '-e', '-c', shell], check=True)
|
||||||
|
code = f'import sys; sys.path.insert(0, {str(app / "usr/bin")!r}); import pbs_manager, flask_pbs_routes, proxmox_platform'
|
||||||
|
subprocess.run([sys.executable, '-I', '-B', '-c', code], cwd=self.root, check=True, capture_output=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TLSTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.temporary = tempfile.TemporaryDirectory()
|
||||||
|
root = Path(cls.temporary.name)
|
||||||
|
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
|
subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "localhost")])
|
||||||
|
certificate = (x509.CertificateBuilder().subject_name(subject).issuer_name(subject).public_key(key.public_key())
|
||||||
|
.serial_number(x509.random_serial_number()).not_valid_before(datetime.now(timezone.utc) - timedelta(minutes=1))
|
||||||
|
.not_valid_after(datetime.now(timezone.utc) + timedelta(hours=1))
|
||||||
|
.add_extension(x509.SubjectAlternativeName([x509.IPAddress(ipaddress.ip_address("127.0.0.1"))]), critical=False)
|
||||||
|
.sign(key, hashes.SHA256()))
|
||||||
|
cert_path, key_path = root / 'server.pem', root / 'server.key'
|
||||||
|
cert_path.write_bytes(certificate.public_bytes(serialization.Encoding.PEM))
|
||||||
|
key_path.write_bytes(key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()))
|
||||||
|
cls.fingerprint = hashlib.sha256(certificate.public_bytes(serialization.Encoding.DER)).hexdigest()
|
||||||
|
cls.requests = []
|
||||||
|
cls.response_status = 200
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def handle(self):
|
||||||
|
try:
|
||||||
|
super().handle()
|
||||||
|
except ConnectionResetError:
|
||||||
|
pass # A rejected TLS pin closes before any HTTP request.
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
cls.requests.append((self.path, self.headers.get("Authorization")))
|
||||||
|
body = json.dumps({"data": []}).encode()
|
||||||
|
self.send_response(cls.response_status)
|
||||||
|
self.send_header('Content-Length', str(len(body)))
|
||||||
|
self.end_headers(); self.wfile.write(body)
|
||||||
|
|
||||||
|
def log_message(self, *args): pass
|
||||||
|
|
||||||
|
cls.server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
|
||||||
|
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER); context.load_cert_chain(cert_path, key_path)
|
||||||
|
cls.server.socket = context.wrap_socket(cls.server.socket, server_side=True)
|
||||||
|
cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True); cls.thread.start()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
cls.server.shutdown(); cls.server.server_close(); cls.thread.join(timeout=5); cls.temporary.cleanup()
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.requests.clear(); type(self).response_status = 200
|
||||||
|
self.connection = {"url": f'https://127.0.0.1:{self.server.server_port}',
|
||||||
|
"fingerprint": self.fingerprint, "token_id": "audit@pbs!monitor", "token_secret": "fake-secret"}
|
||||||
|
|
||||||
|
def test_real_tls_matching_pin_sends_correct_pbs_authorization(self):
|
||||||
|
self.assertEqual(pbs._remote_get(self.connection, '/status/datastore-usage'), [])
|
||||||
|
self.assertEqual(self.requests, [('/api2/json/status/datastore-usage', 'PBSAPIToken=audit@pbs!monitor:fake-secret')])
|
||||||
|
|
||||||
|
def test_bad_pin_and_untrusted_ca_send_no_http_credentials(self):
|
||||||
|
for fingerprint in ('0' * 64, ''):
|
||||||
|
with self.subTest(fingerprint=fingerprint), self.assertRaises(pbs.PBSError):
|
||||||
|
pbs._remote_get({**self.connection, "fingerprint": fingerprint}, '/status/datastore-usage')
|
||||||
|
self.assertEqual(self.requests, [])
|
||||||
|
|
||||||
|
def test_bad_credentials_and_redirects_are_rejected(self):
|
||||||
|
for status in (401, 403, 302):
|
||||||
|
type(self).response_status = status
|
||||||
|
with self.subTest(status=status), self.assertRaises(pbs.PBSError):
|
||||||
|
pbs._remote_get(self.connection, '/status/datastore-usage')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const source = fs.readFileSync(path.join(__dirname, '../components/proxmox-dashboard.tsx'), 'utf8')
|
||||||
|
const tree = ts.createSourceFile('dashboard.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
||||||
|
const wrapper = tree.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'ProxmoxDashboard')
|
||||||
|
assert.ok(wrapper, 'platform gate must remain separate from PVE hooks/prewarmers')
|
||||||
|
const code = ts.transpileModule(ts.createPrinter().printNode(ts.EmitHint.Unspecified, wrapper, tree), {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX },
|
||||||
|
}).outputText
|
||||||
|
const PBS = () => {}, PVE = () => {}
|
||||||
|
for (const [data, error, expected] of [
|
||||||
|
[{ product: 'pbs' }, null, PBS], [{ product: 'pve' }, null, PVE],
|
||||||
|
[undefined, new Error('older API'), PVE], [undefined, null, 'p'],
|
||||||
|
]) {
|
||||||
|
const sandbox = {
|
||||||
|
exports: {}, PBSDashboard: PBS, PVEDashboard: PVE,
|
||||||
|
useT: () => key => key, useSWR: () => ({ data, error }),
|
||||||
|
require: () => ({ jsx: (type, props) => ({ type, props }) }),
|
||||||
|
}
|
||||||
|
vm.runInNewContext(code, sandbox)
|
||||||
|
assert.equal(sandbox.exports.ProxmoxDashboard().type, expected)
|
||||||
|
}
|
||||||
|
console.log('PASS: PBS dashboard routing and loading gate do not mount PVE prewarmers')
|
||||||
|
|
||||||
|
// The PVE Backup tab keeps native guest retention alongside jobs when adding
|
||||||
|
// its PBS connection view. Exercise the shipped JSX for all three selections.
|
||||||
|
const backupSource = fs.readFileSync(path.join(__dirname, '../components/backup-tab.tsx'), 'utf8')
|
||||||
|
const backupTree = ts.createSourceFile('backup-tab.tsx', backupSource, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
||||||
|
const backupTab = backupTree.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'BackupTab')
|
||||||
|
assert.ok(backupTab, 'Backup tab must keep a platform-specific view switch')
|
||||||
|
const backupCode = ts.transpileModule(ts.createPrinter().printNode(ts.EmitHint.Unspecified, backupTab, backupTree), {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX },
|
||||||
|
}).outputText
|
||||||
|
const HostBackup = () => {}, VmBackupJobs = () => {}, BackupStorageRetention = () => {}, PBSPanel = () => {}
|
||||||
|
for (const [view, expected] of [
|
||||||
|
['host', [HostBackup]], ['guests', [VmBackupJobs, BackupStorageRetention]], ['pbs', [PBSPanel]],
|
||||||
|
]) {
|
||||||
|
const selected = []
|
||||||
|
const sandbox = {
|
||||||
|
exports: {}, HostBackup, VmBackupJobs, BackupStorageRetention, PBSPanel, DatabaseBackup: () => {},
|
||||||
|
useT: () => key => key, useState: () => [view, value => selected.push(value)],
|
||||||
|
require: () => ({ jsx: (type, props) => ({ type, props }), jsxs: (type, props) => ({ type, props }), Fragment: Symbol('Fragment') }),
|
||||||
|
}
|
||||||
|
vm.runInNewContext(backupCode, sandbox)
|
||||||
|
const visible = [], mountedHosts = [], buttons = []
|
||||||
|
const components = new Set([HostBackup, VmBackupJobs, BackupStorageRetention, PBSPanel])
|
||||||
|
function walk(node, hidden = false) {
|
||||||
|
if (Array.isArray(node)) { node.forEach(item => walk(item, hidden)); return }
|
||||||
|
if (!node || typeof node !== 'object') return
|
||||||
|
hidden ||= node.props?.className === 'hidden'
|
||||||
|
if (node.type === HostBackup) mountedHosts.push(node)
|
||||||
|
if (components.has(node.type) && !hidden) visible.push(node.type)
|
||||||
|
if (node.type === 'button') buttons.push(node)
|
||||||
|
walk(node.props?.children, hidden)
|
||||||
|
}
|
||||||
|
walk(sandbox.exports.BackupTab())
|
||||||
|
assert.deepEqual(visible, expected, `visible backup panels for ${view}`)
|
||||||
|
assert.equal(mountedHosts.length, 1, 'host backup progress must stay mounted across view changes')
|
||||||
|
assert.equal(buttons.length, 3)
|
||||||
|
assert.equal(buttons.filter(button => button.props['aria-pressed']).length, 1)
|
||||||
|
const pbsButton = buttons.find(button => button.props.children === 'pbs.title')
|
||||||
|
assert.ok(pbsButton, 'PBS navigation button must exist')
|
||||||
|
pbsButton.props.onClick()
|
||||||
|
assert.deepEqual(selected, ['pbs'])
|
||||||
|
}
|
||||||
|
console.log('PASS: host progress stays mounted, guest jobs and retention stay together, and PBS navigation remains separate')
|
||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
# Proxmox Backup Server support
|
||||||
|
|
||||||
|
ProxMenux detects PBS from the installed **proxmox-backup-server** package.
|
||||||
|
Installing proxmox-backup-client on PVE or another Debian system does not turn
|
||||||
|
that host into PBS. Supported maintenance platforms are PBS 3/Debian 12 and
|
||||||
|
PBS 4/Debian 13; PVE 8/9 detection remains separate. If both server products
|
||||||
|
are co-installed, PVE takes precedence.
|
||||||
|
|
||||||
|
## On a PBS host
|
||||||
|
|
||||||
|
The main CLI opens a PBS menu with package/version information, datastores,
|
||||||
|
recent tasks, the existing system-utility installer, settings and a PBS package
|
||||||
|
update. Utility installation preserves the operator's APT sources instead of
|
||||||
|
trying to find a PVE version or enabling PVE/Ceph repositories. Configure a
|
||||||
|
working subscription or no-subscription PBS repository in PBS itself if APT
|
||||||
|
reports an error.
|
||||||
|
|
||||||
|
The update refreshes configured indexes, displays the simulated full-upgrade
|
||||||
|
plan and requires confirmation before applying it. It does not run PVE repository
|
||||||
|
cleanup, major-version migration, kernel cleanup or guest scripts. The automatic
|
||||||
|
and customizable PVE post-install entry points reject PBS before adjustments.
|
||||||
|
|
||||||
|
Monitor opens a PBS dashboard with CPU, RAM, uptime, local PBS status,
|
||||||
|
datastores, recent tasks and maintenance schedules, plus logs, terminal and
|
||||||
|
Monitor authentication/HTTPS settings. PVE guest prewarmers, firewall controls
|
||||||
|
and guest-storage checks are not part of this dashboard. Health checks monitor
|
||||||
|
proxmox-backup and proxmox-backup-proxy rather than reporting absent PVE daemons.
|
||||||
|
Monitor can reuse the native PBS proxy.pem/proxy.key pair, including renewal.
|
||||||
|
|
||||||
|
Local data is read with proxmox-backup-manager JSON commands. Datastore capacity
|
||||||
|
is filesystem capacity, not the logical sum of backups; datastores sharing one
|
||||||
|
filesystem can show the same capacity. An unmounted removable datastore or a
|
||||||
|
failed check has unknown capacity, not zero/free space.
|
||||||
|
|
||||||
|
## Monitor a remote PBS from PVE
|
||||||
|
|
||||||
|
Open **Backup → Proxmox Backup Server → Manage PBS connections**. Enable local
|
||||||
|
Monitor authentication first, then add a named HTTPS endpoint (for example
|
||||||
|
https://pbs.example:8007), a dedicated API token ID (`user@pbs!monitor`) and its
|
||||||
|
secret. Give both the user and token the PBS **Audit** role on `/`, with
|
||||||
|
propagation, to view system tasks and datastore status. Token ACLs cannot exceed
|
||||||
|
the user's effective permissions. A restricted token may show only its permitted
|
||||||
|
data; denied sections are displayed as unavailable.
|
||||||
|
|
||||||
|
Use a CA-trusted certificate or copy the certificate's SHA256 fingerprint from
|
||||||
|
the PBS console/a trusted channel. With a pin, Monitor checks the peer certificate
|
||||||
|
before sending the token. With no pin, the normal system CA and hostname checks
|
||||||
|
apply. Connections go directly to the configured HTTPS endpoint and do not follow
|
||||||
|
redirects. A reverse-proxy prefix is supported; omit the port for HTTPS 443 or
|
||||||
|
include the native PBS port explicitly.
|
||||||
|
|
||||||
|
Saving tests an authenticated datastore-status endpoint. Secrets are stored
|
||||||
|
atomically with mode 0600 in `~/.config/proxmenux-monitor/pbs_servers.json`, never
|
||||||
|
returned by the API, and retained when the edit form's secret is left empty.
|
||||||
|
Connection changes require full administrator access; read-only Monitor API tokens
|
||||||
|
can read monitoring data but cannot change connections. Removing a connection
|
||||||
|
removes its saved credentials only. Monitoring does not start prune, garbage
|
||||||
|
collection, verification or sync jobs and does not modify PBS backups.
|
||||||
|
|
||||||
|
Status is cached for 15 seconds and the panel refreshes every 30 seconds. Task or
|
||||||
|
schedule permission failures are shown independently, without hiding successful
|
||||||
|
datastore data or presenting failed queries as empty healthy results.
|
||||||
|
|
||||||
|
References: [PBS command syntax](https://pbs.proxmox.com/docs/command-syntax.html),
|
||||||
|
[PBS API](https://pbs.proxmox.com/docs/api-viewer/index.html),
|
||||||
|
[PBS users, tokens and ACLs](https://pbs.proxmox.com/docs/user-management.html).
|
||||||
+19
-1
@@ -8155,5 +8155,23 @@
|
|||||||
"⚠ Disk data will NOT be erased.": "⚠ Festplattendaten werden NICHT gelöscht.",
|
"⚠ Disk data will NOT be erased.": "⚠ Festplattendaten werden NICHT gelöscht.",
|
||||||
"⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ Die Festplatte wird ausgehängt und aus /etc/fstab entfernt.",
|
"⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ Die Festplatte wird ausgehängt und aus /etc/fstab entfernt.",
|
||||||
"⚠ The /etc/fstab entry will be removed.": "⚠ Der /etc/fstab-Eintrag wird entfernt.",
|
"⚠ The /etc/fstab entry will be removed.": "⚠ Der /etc/fstab-Eintrag wird entfernt.",
|
||||||
"⚠ The disk will be unmounted.": "⚠ Die Festplatte wird ausgehängt."
|
"⚠ The disk will be unmounted.": "⚠ Die Festplatte wird ausgehängt.",
|
||||||
|
"APT refresh failed. Check the PBS subscription and repositories in the native PBS interface; no source was changed.": "APT-Aktualisierung fehlgeschlagen. Prüfen Sie das PBS-Abonnement und die Repositories in der nativen PBS-Oberfläche; es wurden keine Paketquellen geändert.",
|
||||||
|
"Apply PBS package upgrade": "PBS-Paketaktualisierung anwenden",
|
||||||
|
"Apply the reviewed package upgrade now? Services may restart and packages listed for removal will be removed.": "Die geprüfte Paketaktualisierung jetzt anwenden? Dienste können neu starten und zur Entfernung aufgeführte Pakete werden entfernt.",
|
||||||
|
"Command failed": "Befehl fehlgeschlagen",
|
||||||
|
"PBS datastores": "PBS-Datenspeicher",
|
||||||
|
"PBS package update": "PBS-Paketaktualisierung",
|
||||||
|
"PBS package upgrade completed. Check whether a reboot is required before your next backup window.": "PBS-Paketaktualisierung abgeschlossen. Prüfen Sie vor dem nächsten Sicherungszeitfenster, ob ein Neustart erforderlich ist.",
|
||||||
|
"PBS package upgrade failed. Review the APT output before retrying.": "PBS-Paketaktualisierung fehlgeschlagen. Prüfen Sie vor einem erneuten Versuch die APT-Ausgabe.",
|
||||||
|
"PBS version and packages": "PBS-Version und Pakete",
|
||||||
|
"PBS: using the configured APT repositories without changing sources.": "PBS: Die konfigurierten APT-Repositories werden verwendet, ohne die Paketquellen zu ändern.",
|
||||||
|
"Recent PBS tasks": "Letzte PBS-Aufgaben",
|
||||||
|
"Refresh the configured APT repositories and review the PBS package upgrade? Repository files will be preserved.": "Die konfigurierten APT-Repositories aktualisieren und die PBS-Paketaktualisierung prüfen? Repository-Dateien bleiben erhalten.",
|
||||||
|
"Review package upgrade": "Paketaktualisierung prüfen",
|
||||||
|
"Run this operation as root.": "Führen Sie diesen Vorgang als root aus.",
|
||||||
|
"This menu requires Proxmox Backup Server.": "Dieses Menü erfordert Proxmox Backup Server.",
|
||||||
|
"This operation requires a Proxmox VE host. Use the PBS menu on Proxmox Backup Server.": "Dieser Vorgang erfordert einen Proxmox-VE-Host. Verwenden Sie auf Proxmox Backup Server das PBS-Menü.",
|
||||||
|
"This operation supports PBS 3 on Debian 12 and PBS 4 on Debian 13.": "Dieser Vorgang unterstützt PBS 3 unter Debian 12 und PBS 4 unter Debian 13.",
|
||||||
|
"Update PBS using configured repositories": "PBS mit den konfigurierten Repositories aktualisieren"
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-1
@@ -8127,5 +8127,23 @@
|
|||||||
"⚠ Disk data will NOT be erased.": "⚠ Los datos del disco NO se borrarán.",
|
"⚠ Disk data will NOT be erased.": "⚠ Los datos del disco NO se borrarán.",
|
||||||
"⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ El disco se desmontará y se eliminará de /etc/fstab.",
|
"⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ El disco se desmontará y se eliminará de /etc/fstab.",
|
||||||
"⚠ The /etc/fstab entry will be removed.": "⚠ Se eliminará la entrada /etc/fstab.",
|
"⚠ The /etc/fstab entry will be removed.": "⚠ Se eliminará la entrada /etc/fstab.",
|
||||||
"⚠ The disk will be unmounted.": "⚠ El disco se desmontará."
|
"⚠ The disk will be unmounted.": "⚠ El disco se desmontará.",
|
||||||
|
"APT refresh failed. Check the PBS subscription and repositories in the native PBS interface; no source was changed.": "No se pudieron actualizar los índices de APT. Comprueba la suscripción y los repositorios de PBS en su interfaz nativa; no se ha cambiado ninguna fuente.",
|
||||||
|
"Apply PBS package upgrade": "Aplicar la actualización de paquetes de PBS",
|
||||||
|
"Apply the reviewed package upgrade now? Services may restart and packages listed for removal will be removed.": "¿Aplicar ahora la actualización de paquetes revisada? Los servicios podrían reiniciarse y se eliminarán los paquetes indicados para su eliminación.",
|
||||||
|
"Command failed": "El comando ha fallado",
|
||||||
|
"PBS datastores": "Datastores de PBS",
|
||||||
|
"PBS package update": "Actualización de paquetes de PBS",
|
||||||
|
"PBS package upgrade completed. Check whether a reboot is required before your next backup window.": "Actualización de paquetes de PBS completada. Comprueba si es necesario reiniciar antes del próximo período de copias de seguridad.",
|
||||||
|
"PBS package upgrade failed. Review the APT output before retrying.": "La actualización de paquetes de PBS ha fallado. Revisa la salida de APT antes de volver a intentarlo.",
|
||||||
|
"PBS version and packages": "Versión y paquetes de PBS",
|
||||||
|
"PBS: using the configured APT repositories without changing sources.": "PBS: se utilizan los repositorios de APT configurados sin cambiar las fuentes.",
|
||||||
|
"Recent PBS tasks": "Tareas recientes de PBS",
|
||||||
|
"Refresh the configured APT repositories and review the PBS package upgrade? Repository files will be preserved.": "¿Actualizar los índices de los repositorios de APT configurados y revisar la actualización de paquetes de PBS? Se conservarán los archivos de los repositorios.",
|
||||||
|
"Review package upgrade": "Revisar la actualización de paquetes",
|
||||||
|
"Run this operation as root.": "Ejecuta esta operación como root.",
|
||||||
|
"This menu requires Proxmox Backup Server.": "Este menú requiere Proxmox Backup Server.",
|
||||||
|
"This operation requires a Proxmox VE host. Use the PBS menu on Proxmox Backup Server.": "Esta operación requiere un host de Proxmox VE. Utiliza el menú de PBS en Proxmox Backup Server.",
|
||||||
|
"This operation supports PBS 3 on Debian 12 and PBS 4 on Debian 13.": "Esta operación admite PBS 3 en Debian 12 y PBS 4 en Debian 13.",
|
||||||
|
"Update PBS using configured repositories": "Actualizar PBS con los repositorios configurados"
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-1
@@ -8154,5 +8154,23 @@
|
|||||||
"⚠ Disk data will NOT be erased.": "⚠ Les données du disque ne seront PAS effacées.",
|
"⚠ Disk data will NOT be erased.": "⚠ Les données du disque ne seront PAS effacées.",
|
||||||
"⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ Le disque sera démonté et supprimé de /etc/fstab.",
|
"⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ Le disque sera démonté et supprimé de /etc/fstab.",
|
||||||
"⚠ The /etc/fstab entry will be removed.": "⚠ L'entrée /etc/fstab sera supprimée.",
|
"⚠ The /etc/fstab entry will be removed.": "⚠ L'entrée /etc/fstab sera supprimée.",
|
||||||
"⚠ The disk will be unmounted.": "⚠ Le disque sera démonté."
|
"⚠ The disk will be unmounted.": "⚠ Le disque sera démonté.",
|
||||||
|
"APT refresh failed. Check the PBS subscription and repositories in the native PBS interface; no source was changed.": "Échec de l'actualisation APT. Vérifiez l'abonnement et les dépôts de PBS dans son interface native ; aucune source n'a été modifiée.",
|
||||||
|
"Apply PBS package upgrade": "Appliquer la mise à niveau des paquets PBS",
|
||||||
|
"Apply the reviewed package upgrade now? Services may restart and packages listed for removal will be removed.": "Appliquer maintenant la mise à niveau des paquets examinée ? Des services peuvent redémarrer et les paquets indiqués pour suppression seront supprimés.",
|
||||||
|
"Command failed": "Échec de la commande",
|
||||||
|
"PBS datastores": "Magasins de données PBS",
|
||||||
|
"PBS package update": "Mise à jour des paquets PBS",
|
||||||
|
"PBS package upgrade completed. Check whether a reboot is required before your next backup window.": "Mise à niveau des paquets PBS terminée. Vérifiez si un redémarrage est nécessaire avant votre prochaine plage de sauvegarde.",
|
||||||
|
"PBS package upgrade failed. Review the APT output before retrying.": "Échec de la mise à niveau des paquets PBS. Consultez la sortie APT avant de réessayer.",
|
||||||
|
"PBS version and packages": "Version et paquets PBS",
|
||||||
|
"PBS: using the configured APT repositories without changing sources.": "PBS : utilisation des dépôts APT configurés sans modifier les sources.",
|
||||||
|
"Recent PBS tasks": "Tâches PBS récentes",
|
||||||
|
"Refresh the configured APT repositories and review the PBS package upgrade? Repository files will be preserved.": "Actualiser les dépôts APT configurés et examiner la mise à niveau des paquets PBS ? Les fichiers des dépôts seront conservés.",
|
||||||
|
"Review package upgrade": "Examiner la mise à niveau des paquets",
|
||||||
|
"Run this operation as root.": "Exécutez cette opération en tant que root.",
|
||||||
|
"This menu requires Proxmox Backup Server.": "Ce menu nécessite Proxmox Backup Server.",
|
||||||
|
"This operation requires a Proxmox VE host. Use the PBS menu on Proxmox Backup Server.": "Cette opération nécessite un hôte Proxmox VE. Utilisez le menu PBS sur Proxmox Backup Server.",
|
||||||
|
"This operation supports PBS 3 on Debian 12 and PBS 4 on Debian 13.": "Cette opération prend en charge PBS 3 sur Debian 12 et PBS 4 sur Debian 13.",
|
||||||
|
"Update PBS using configured repositories": "Mettre à jour PBS avec les dépôts configurés"
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-1
@@ -8155,5 +8155,23 @@
|
|||||||
"⚠ Disk data will NOT be erased.": "⚠ I dati del disco NON verranno cancellati.",
|
"⚠ Disk data will NOT be erased.": "⚠ I dati del disco NON verranno cancellati.",
|
||||||
"⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ Il disco verrà smontato e rimosso da /etc/fstab.",
|
"⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ Il disco verrà smontato e rimosso da /etc/fstab.",
|
||||||
"⚠ The /etc/fstab entry will be removed.": "⚠ La voce /etc/fstab verrà rimossa.",
|
"⚠ The /etc/fstab entry will be removed.": "⚠ La voce /etc/fstab verrà rimossa.",
|
||||||
"⚠ The disk will be unmounted.": "⚠ Il disco verrà smontato."
|
"⚠ The disk will be unmounted.": "⚠ Il disco verrà smontato.",
|
||||||
|
"APT refresh failed. Check the PBS subscription and repositories in the native PBS interface; no source was changed.": "Aggiornamento degli indici APT non riuscito. Controlla l'abbonamento e i repository PBS nell'interfaccia nativa di PBS; nessuna sorgente è stata modificata.",
|
||||||
|
"Apply PBS package upgrade": "Applica l'aggiornamento dei pacchetti PBS",
|
||||||
|
"Apply the reviewed package upgrade now? Services may restart and packages listed for removal will be removed.": "Applicare ora l'aggiornamento dei pacchetti esaminato? I servizi potrebbero riavviarsi e i pacchetti indicati per la rimozione verranno rimossi.",
|
||||||
|
"Command failed": "Comando non riuscito",
|
||||||
|
"PBS datastores": "Datastore PBS",
|
||||||
|
"PBS package update": "Aggiornamento dei pacchetti PBS",
|
||||||
|
"PBS package upgrade completed. Check whether a reboot is required before your next backup window.": "Aggiornamento dei pacchetti PBS completato. Verifica se è necessario un riavvio prima della prossima finestra di backup.",
|
||||||
|
"PBS package upgrade failed. Review the APT output before retrying.": "Aggiornamento dei pacchetti PBS non riuscito. Esamina l'output di APT prima di riprovare.",
|
||||||
|
"PBS version and packages": "Versione e pacchetti PBS",
|
||||||
|
"PBS: using the configured APT repositories without changing sources.": "PBS: vengono usati i repository APT configurati senza modificare le sorgenti.",
|
||||||
|
"Recent PBS tasks": "Attività PBS recenti",
|
||||||
|
"Refresh the configured APT repositories and review the PBS package upgrade? Repository files will be preserved.": "Aggiornare gli indici dei repository APT configurati ed esaminare l'aggiornamento dei pacchetti PBS? I file dei repository verranno conservati.",
|
||||||
|
"Review package upgrade": "Esamina l'aggiornamento dei pacchetti",
|
||||||
|
"Run this operation as root.": "Esegui questa operazione come root.",
|
||||||
|
"This menu requires Proxmox Backup Server.": "Questo menu richiede Proxmox Backup Server.",
|
||||||
|
"This operation requires a Proxmox VE host. Use the PBS menu on Proxmox Backup Server.": "Questa operazione richiede un host Proxmox VE. Usa il menu PBS su Proxmox Backup Server.",
|
||||||
|
"This operation supports PBS 3 on Debian 12 and PBS 4 on Debian 13.": "Questa operazione supporta PBS 3 su Debian 12 e PBS 4 su Debian 13.",
|
||||||
|
"Update PBS using configured repositories": "Aggiorna PBS usando i repository configurati"
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-1
@@ -8155,5 +8155,23 @@
|
|||||||
"⚠ Disk data will NOT be erased.": "⚠ Os dados do disco NÃO serão apagados.",
|
"⚠ Disk data will NOT be erased.": "⚠ Os dados do disco NÃO serão apagados.",
|
||||||
"⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ O disco será desmontado e removido de /etc/fstab.",
|
"⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ O disco será desmontado e removido de /etc/fstab.",
|
||||||
"⚠ The /etc/fstab entry will be removed.": "⚠ A entrada /etc/fstab será removida.",
|
"⚠ The /etc/fstab entry will be removed.": "⚠ A entrada /etc/fstab será removida.",
|
||||||
"⚠ The disk will be unmounted.": "⚠ O disco será desmontado."
|
"⚠ The disk will be unmounted.": "⚠ O disco será desmontado.",
|
||||||
|
"APT refresh failed. Check the PBS subscription and repositories in the native PBS interface; no source was changed.": "Falha ao atualizar os índices APT. Verifique a subscrição e os repositórios PBS na interface nativa do PBS; nenhuma fonte foi alterada.",
|
||||||
|
"Apply PBS package upgrade": "Aplicar a atualização de pacotes PBS",
|
||||||
|
"Apply the reviewed package upgrade now? Services may restart and packages listed for removal will be removed.": "Aplicar agora a atualização de pacotes revista? Os serviços podem reiniciar e os pacotes indicados para remoção serão removidos.",
|
||||||
|
"Command failed": "O comando falhou",
|
||||||
|
"PBS datastores": "Armazenamentos de dados PBS",
|
||||||
|
"PBS package update": "Atualização de pacotes PBS",
|
||||||
|
"PBS package upgrade completed. Check whether a reboot is required before your next backup window.": "Atualização de pacotes PBS concluída. Verifique se é necessário reiniciar antes do próximo período de cópias de segurança.",
|
||||||
|
"PBS package upgrade failed. Review the APT output before retrying.": "A atualização de pacotes PBS falhou. Reveja a saída do APT antes de tentar novamente.",
|
||||||
|
"PBS version and packages": "Versão e pacotes PBS",
|
||||||
|
"PBS: using the configured APT repositories without changing sources.": "PBS: a usar os repositórios APT configurados sem alterar as fontes.",
|
||||||
|
"Recent PBS tasks": "Tarefas PBS recentes",
|
||||||
|
"Refresh the configured APT repositories and review the PBS package upgrade? Repository files will be preserved.": "Atualizar os índices dos repositórios APT configurados e rever a atualização de pacotes PBS? Os ficheiros dos repositórios serão preservados.",
|
||||||
|
"Review package upgrade": "Rever a atualização de pacotes",
|
||||||
|
"Run this operation as root.": "Execute esta operação como root.",
|
||||||
|
"This menu requires Proxmox Backup Server.": "Este menu requer Proxmox Backup Server.",
|
||||||
|
"This operation requires a Proxmox VE host. Use the PBS menu on Proxmox Backup Server.": "Esta operação requer um anfitrião Proxmox VE. Utilize o menu PBS no Proxmox Backup Server.",
|
||||||
|
"This operation supports PBS 3 on Debian 12 and PBS 4 on Debian 13.": "Esta operação suporta PBS 3 em Debian 12 e PBS 4 em Debian 13.",
|
||||||
|
"Update PBS using configured repositories": "Atualizar PBS com os repositórios configurados"
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-1
@@ -8156,5 +8156,23 @@
|
|||||||
"⚠ Disk data will NOT be erased.": "⚠ Dáta na disku sa NEVYMAŽÚ.",
|
"⚠ Disk data will NOT be erased.": "⚠ Dáta na disku sa NEVYMAŽÚ.",
|
||||||
"⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ Disk sa odpojí a odstráni z /etc/fstab.",
|
"⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ Disk sa odpojí a odstráni z /etc/fstab.",
|
||||||
"⚠ The /etc/fstab entry will be removed.": "⚠ Záznam v /etc/fstab bude odstránený.",
|
"⚠ The /etc/fstab entry will be removed.": "⚠ Záznam v /etc/fstab bude odstránený.",
|
||||||
"⚠ The disk will be unmounted.": "⚠ Disk bude odpojený."
|
"⚠ The disk will be unmounted.": "⚠ Disk bude odpojený.",
|
||||||
|
"APT refresh failed. Check the PBS subscription and repositories in the native PBS interface; no source was changed.": "Obnovenie indexov APT zlyhalo. Skontrolujte predplatné a repozitáre PBS v pôvodnom rozhraní PBS; žiadne zdroje sa nezmenili.",
|
||||||
|
"Apply PBS package upgrade": "Použiť aktualizáciu balíkov PBS",
|
||||||
|
"Apply the reviewed package upgrade now? Services may restart and packages listed for removal will be removed.": "Použiť teraz skontrolovanú aktualizáciu balíkov? Služby sa môžu reštartovať a balíky označené na odstránenie budú odstránené.",
|
||||||
|
"Command failed": "Príkaz zlyhal",
|
||||||
|
"PBS datastores": "Dátové úložiská PBS",
|
||||||
|
"PBS package update": "Aktualizácia balíkov PBS",
|
||||||
|
"PBS package upgrade completed. Check whether a reboot is required before your next backup window.": "Aktualizácia balíkov PBS bola dokončená. Pred ďalším zálohovacím oknom skontrolujte, či je potrebný reštart.",
|
||||||
|
"PBS package upgrade failed. Review the APT output before retrying.": "Aktualizácia balíkov PBS zlyhala. Pred ďalším pokusom skontrolujte výstup APT.",
|
||||||
|
"PBS version and packages": "Verzia PBS a balíky",
|
||||||
|
"PBS: using the configured APT repositories without changing sources.": "PBS: používajú sa nakonfigurované repozitáre APT bez zmeny zdrojov.",
|
||||||
|
"Recent PBS tasks": "Nedávne úlohy PBS",
|
||||||
|
"Refresh the configured APT repositories and review the PBS package upgrade? Repository files will be preserved.": "Obnoviť indexy nakonfigurovaných repozitárov APT a skontrolovať aktualizáciu balíkov PBS? Súbory repozitárov sa zachovajú.",
|
||||||
|
"Review package upgrade": "Skontrolovať aktualizáciu balíkov",
|
||||||
|
"Run this operation as root.": "Túto operáciu spustite ako root.",
|
||||||
|
"This menu requires Proxmox Backup Server.": "Táto ponuka vyžaduje Proxmox Backup Server.",
|
||||||
|
"This operation requires a Proxmox VE host. Use the PBS menu on Proxmox Backup Server.": "Táto operácia vyžaduje hostiteľa Proxmox VE. Na Proxmox Backup Serveri použite ponuku PBS.",
|
||||||
|
"This operation supports PBS 3 on Debian 12 and PBS 4 on Debian 13.": "Táto operácia podporuje PBS 3 na Debiane 12 a PBS 4 na Debiane 13.",
|
||||||
|
"Update PBS using configured repositories": "Aktualizovať PBS pomocou nakonfigurovaných repozitárov"
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-1
@@ -8155,5 +8155,23 @@
|
|||||||
"⚠ Disk data will NOT be erased.": "⚠ Diskdata kommer INTE att raderas.",
|
"⚠ Disk data will NOT be erased.": "⚠ Diskdata kommer INTE att raderas.",
|
||||||
"⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ Disken kommer att avmonteras och tas bort från /etc/fstab.",
|
"⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ Disken kommer att avmonteras och tas bort från /etc/fstab.",
|
||||||
"⚠ The /etc/fstab entry will be removed.": "⚠ /etc/fstab-posten kommer att tas bort.",
|
"⚠ The /etc/fstab entry will be removed.": "⚠ /etc/fstab-posten kommer att tas bort.",
|
||||||
"⚠ The disk will be unmounted.": "⚠ Disken kommer att avmonteras."
|
"⚠ The disk will be unmounted.": "⚠ Disken kommer att avmonteras.",
|
||||||
|
"APT refresh failed. Check the PBS subscription and repositories in the native PBS interface; no source was changed.": "APT-uppdateringen misslyckades. Kontrollera PBS-prenumerationen och paketförråden i PBS eget gränssnitt; inga paketkällor ändrades.",
|
||||||
|
"Apply PBS package upgrade": "Tillämpa PBS-paketuppgraderingen",
|
||||||
|
"Apply the reviewed package upgrade now? Services may restart and packages listed for removal will be removed.": "Tillämpa den granskade paketuppgraderingen nu? Tjänster kan startas om och paket som anges för borttagning kommer att tas bort.",
|
||||||
|
"Command failed": "Kommandot misslyckades",
|
||||||
|
"PBS datastores": "PBS-datalager",
|
||||||
|
"PBS package update": "PBS-paketuppdatering",
|
||||||
|
"PBS package upgrade completed. Check whether a reboot is required before your next backup window.": "PBS-paketuppgraderingen är klar. Kontrollera om en omstart behövs före nästa säkerhetskopieringsfönster.",
|
||||||
|
"PBS package upgrade failed. Review the APT output before retrying.": "PBS-paketuppgraderingen misslyckades. Granska APT-utdata innan du försöker igen.",
|
||||||
|
"PBS version and packages": "PBS-version och paket",
|
||||||
|
"PBS: using the configured APT repositories without changing sources.": "PBS: använder de konfigurerade APT-paketförråden utan att ändra källor.",
|
||||||
|
"Recent PBS tasks": "Senaste PBS-uppgifter",
|
||||||
|
"Refresh the configured APT repositories and review the PBS package upgrade? Repository files will be preserved.": "Uppdatera de konfigurerade APT-paketförråden och granska PBS-paketuppgraderingen? Paketförrådens filer bevaras.",
|
||||||
|
"Review package upgrade": "Granska paketuppgraderingen",
|
||||||
|
"Run this operation as root.": "Kör denna åtgärd som root.",
|
||||||
|
"This menu requires Proxmox Backup Server.": "Denna meny kräver Proxmox Backup Server.",
|
||||||
|
"This operation requires a Proxmox VE host. Use the PBS menu on Proxmox Backup Server.": "Denna åtgärd kräver en Proxmox VE-värd. Använd PBS-menyn på Proxmox Backup Server.",
|
||||||
|
"This operation supports PBS 3 on Debian 12 and PBS 4 on Debian 13.": "Denna åtgärd stöder PBS 3 på Debian 12 och PBS 4 på Debian 13.",
|
||||||
|
"Update PBS using configured repositories": "Uppdatera PBS med de konfigurerade paketförråden"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Canonical detection is shared with the Monitor; never infer PBS from a client package.
|
||||||
|
pmx_platform_field() {
|
||||||
|
python3 "$(dirname "${BASH_SOURCE[0]}")/proxmox_platform.py" --field "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
pmx_is_pbs() {
|
||||||
|
[[ "$(pmx_platform_field product)" == pbs ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Only a host known to be PBS is refused: if the package list cannot be read,
|
||||||
|
# the PVE scripts keep working as they did before PBS support.
|
||||||
|
pmx_require_pve() {
|
||||||
|
if pmx_is_pbs; then
|
||||||
|
msg_error "$(translate 'This operation requires a Proxmox VE host. Use the PBS menu on Proxmox Backup Server.')"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Shared PVE/PBS platform detection for the menus and the Monitor (stdlib only)."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
OS_RELEASE = Path("/etc/os-release")
|
||||||
|
SUPPORTED = {("pve", 8): "bookworm", ("pve", 9): "trixie",
|
||||||
|
("pbs", 3): "bookworm", ("pbs", 4): "trixie"}
|
||||||
|
|
||||||
|
|
||||||
|
def detect_platform():
|
||||||
|
codename = ""
|
||||||
|
try:
|
||||||
|
match = re.search(r'^VERSION_CODENAME=["\']?([a-z0-9-]+)', OS_RELEASE.read_text(), re.M)
|
||||||
|
codename = match.group(1) if match else ""
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
installed = {}
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["dpkg-query", "-W", "-f=${Package}\t${Status}\t${Version}\n",
|
||||||
|
"pve-manager", "proxmox-backup-server"], capture_output=True, text=True, timeout=5,
|
||||||
|
)
|
||||||
|
# dpkg returns nonzero if ONE requested package is missing; its other
|
||||||
|
# installed rows remain authoritative. A PBS client alone is not PBS.
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
columns = line.split("\t")
|
||||||
|
if len(columns) == 3 and columns[1] == "install ok installed":
|
||||||
|
installed[columns[0]] = columns[2]
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
pass
|
||||||
|
for package, product in (("pve-manager", "pve"), ("proxmox-backup-server", "pbs")):
|
||||||
|
version = installed.get(package, "")
|
||||||
|
match = re.fullmatch(r"(?:[0-9]+:)?([0-9]+)\.[0-9]+(?:\.[0-9]+)?(?:[-+~.a-zA-Z0-9]*)", version)
|
||||||
|
if match:
|
||||||
|
major = int(match.group(1))
|
||||||
|
return {"product": product, "version": version, "major": major,
|
||||||
|
"codename": codename, "supported": SUPPORTED.get((product, major)) == codename}
|
||||||
|
return {"product": "unknown", "version": None, "major": None,
|
||||||
|
"codename": codename, "supported": False}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--field", choices=("product", "version", "major", "codename", "supported"))
|
||||||
|
arguments = parser.parse_args()
|
||||||
|
data = detect_platform()
|
||||||
|
value = data.get(arguments.field) if arguments.field else data
|
||||||
|
print(value if isinstance(value, str) else json.dumps(value))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -2,12 +2,21 @@
|
|||||||
# Shared repository policy for package-install and safe-update flows.
|
# Shared repository policy for package-install and safe-update flows.
|
||||||
# Proxmox's own repository API parses .list/.sources and edits individual
|
# Proxmox's own repository API parses .list/.sources and edits individual
|
||||||
# entries. No shell rewriting of operator-maintained APT files.
|
# entries. No shell rewriting of operator-maintained APT files.
|
||||||
|
source "$(dirname "${BASH_SOURCE[0]}")/platform-functions.sh"
|
||||||
repository_policy() {
|
repository_policy() {
|
||||||
python3 "$(dirname "${BASH_SOURCE[0]}")/repository_policy.py" "$@"
|
python3 "$(dirname "${BASH_SOURCE[0]}")/repository_policy.py" "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
ensure_repositories() {
|
ensure_repositories() {
|
||||||
local version suite decision refresh_status
|
local version suite decision refresh_status
|
||||||
|
if pmx_is_pbs; then
|
||||||
|
if [[ "$(pmx_platform_field supported)" != true ]]; then
|
||||||
|
msg_error "$(translate 'This operation supports PBS 3 on Debian 12 and PBS 4 on Debian 13.')"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
msg_info "$(translate 'PBS: using the configured APT repositories without changing sources.')"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+' | head -1)
|
version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+' | head -1)
|
||||||
case "$version" in
|
case "$version" in
|
||||||
8) suite=bookworm ;;
|
8) suite=bookworm ;;
|
||||||
|
|||||||
@@ -128,4 +128,8 @@ show_menu() {
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
source "$LOCAL_SCRIPTS/global/platform-functions.sh"
|
||||||
|
if pmx_is_pbs; then
|
||||||
|
exec bash "$LOCAL_SCRIPTS/menus/pbs_menu.sh"
|
||||||
|
fi
|
||||||
show_menu
|
show_menu
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ==========================================================
|
||||||
|
# ProxMenux - Proxmox Backup Server menu
|
||||||
|
# ==========================================================
|
||||||
|
# PBS exposes compatible host tools; PVE guest/repository optimizations
|
||||||
|
# stay in the PVE menu.
|
||||||
|
# ==========================================================
|
||||||
|
BASE_DIR="/usr/local/share/proxmenux"
|
||||||
|
LOCAL_SCRIPTS="$BASE_DIR/scripts"
|
||||||
|
source "$BASE_DIR/utils.sh"
|
||||||
|
source "$LOCAL_SCRIPTS/global/platform-functions.sh"
|
||||||
|
load_language
|
||||||
|
initialize_cache
|
||||||
|
|
||||||
|
if ! pmx_is_pbs; then
|
||||||
|
msg_error "$(translate 'This menu requires Proxmox Backup Server.')"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
pbs_show() {
|
||||||
|
local title="$1" output
|
||||||
|
shift
|
||||||
|
output=$(mktemp)
|
||||||
|
if "$@" > "$output" 2>&1; then
|
||||||
|
dialog --backtitle "ProxMenux" --title "$title" --textbox "$output" 24 110
|
||||||
|
else
|
||||||
|
dialog --backtitle "ProxMenux" --title "$(translate 'Command failed')" --textbox "$output" 24 110
|
||||||
|
fi
|
||||||
|
rm -f "$output"
|
||||||
|
}
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
choice=$(dialog --clear --backtitle "ProxMenux" --title "Proxmox Backup Server" \
|
||||||
|
--menu "$(translate 'Select an option:')" 18 75 8 \
|
||||||
|
1 "$(translate 'PBS version and packages')" \
|
||||||
|
2 "$(translate 'PBS datastores')" \
|
||||||
|
3 "$(translate 'Recent PBS tasks')" \
|
||||||
|
4 "$(translate 'System Utilities Installer')" \
|
||||||
|
5 "$(translate 'Update PBS using configured repositories')" \
|
||||||
|
s "$(translate 'Settings')" \
|
||||||
|
0 "$(translate 'Exit')" 2>&1 >/dev/tty) || break
|
||||||
|
case "$choice" in
|
||||||
|
1) pbs_show "$(translate 'PBS version and packages')" proxmox-backup-manager versions --verbose ;;
|
||||||
|
2) pbs_show "$(translate 'PBS datastores')" proxmox-backup-manager datastore list ;;
|
||||||
|
3) pbs_show "$(translate 'Recent PBS tasks')" proxmox-backup-manager task list --all true --limit 20 ;;
|
||||||
|
4) bash "$LOCAL_SCRIPTS/utilities/system_utils.sh" ;;
|
||||||
|
5) bash "$LOCAL_SCRIPTS/utilities/pbs_update.sh" ;;
|
||||||
|
s) exec bash "$LOCAL_SCRIPTS/menus/config_menu.sh" ;;
|
||||||
|
0) break ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
clear
|
||||||
|
msg_ok "$(translate "Thank you for using ProxMenux. Goodbye!")"
|
||||||
@@ -57,6 +57,11 @@ fi
|
|||||||
load_language
|
load_language
|
||||||
initialize_cache
|
initialize_cache
|
||||||
|
|
||||||
|
if [[ -f "$LOCAL_SCRIPTS/global/platform-functions.sh" ]]; then
|
||||||
|
source "$LOCAL_SCRIPTS/global/platform-functions.sh"
|
||||||
|
pmx_require_pve || exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# Global variables
|
# Global variables
|
||||||
OS_CODENAME="$(grep "VERSION_CODENAME=" /etc/os-release | cut -d"=" -f 2 | xargs)"
|
OS_CODENAME="$(grep "VERSION_CODENAME=" /etc/os-release | cut -d"=" -f 2 | xargs)"
|
||||||
RAM_SIZE_GB=$(awk '/^MemTotal:/{print int($2/1024/1024)}' /proc/meminfo)
|
RAM_SIZE_GB=$(awk '/^MemTotal:/{print int($2/1024/1024)}' /proc/meminfo)
|
||||||
|
|||||||
@@ -51,6 +51,11 @@ fi
|
|||||||
load_language
|
load_language
|
||||||
initialize_cache
|
initialize_cache
|
||||||
|
|
||||||
|
if [[ -f "$LOCAL_SCRIPTS/global/platform-functions.sh" ]]; then
|
||||||
|
source "$LOCAL_SCRIPTS/global/platform-functions.sh"
|
||||||
|
pmx_require_pve || exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# Load shared global functions
|
# Load shared global functions
|
||||||
if [[ -f "$LOCAL_SCRIPTS/global/common-functions.sh" ]]; then
|
if [[ -f "$LOCAL_SCRIPTS/global/common-functions.sh" ]]; then
|
||||||
source "$LOCAL_SCRIPTS/global/common-functions.sh"
|
source "$LOCAL_SCRIPTS/global/common-functions.sh"
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Use PBS's existing repositories; never run PVE repo, Ceph or major-upgrade scripts.
|
||||||
|
BASE_DIR="/usr/local/share/proxmenux"
|
||||||
|
LOCAL_SCRIPTS="$BASE_DIR/scripts"
|
||||||
|
source "$BASE_DIR/utils.sh"
|
||||||
|
source "$LOCAL_SCRIPTS/global/platform-functions.sh"
|
||||||
|
load_language
|
||||||
|
initialize_cache
|
||||||
|
|
||||||
|
if ! pmx_is_pbs || [[ "$(pmx_platform_field supported)" != true ]]; then
|
||||||
|
msg_error "$(translate 'This operation supports PBS 3 on Debian 12 and PBS 4 on Debian 13.')"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
[[ $EUID -eq 0 ]] || { msg_error "$(translate 'Run this operation as root.')"; exit 1; }
|
||||||
|
dialog --backtitle "ProxMenux" --title "$(translate 'PBS package update')" \
|
||||||
|
--yesno "$(translate 'Refresh the configured APT repositories and review the PBS package upgrade? Repository files will be preserved.')" 10 80 || exit 0
|
||||||
|
if ! apt-get -o APT::Update::Error-Mode=any update; then
|
||||||
|
msg_error "$(translate 'APT refresh failed. Check the PBS subscription and repositories in the native PBS interface; no source was changed.')"
|
||||||
|
msg_success "$(translate "Press Enter to continue...")"
|
||||||
|
read -r
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
plan=$(mktemp)
|
||||||
|
trap 'rm -f "$plan"' EXIT
|
||||||
|
if ! apt-get -s full-upgrade > "$plan" 2>&1; then
|
||||||
|
dialog --backtitle "ProxMenux" --title "$(translate 'Review package upgrade')" --textbox "$plan" 24 100
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
dialog --backtitle "ProxMenux" --title "$(translate 'Review package upgrade')" --textbox "$plan" 24 100
|
||||||
|
dialog --backtitle "ProxMenux" --defaultno --title "$(translate 'Apply PBS package upgrade')" \
|
||||||
|
--yesno "$(translate 'Apply the reviewed package upgrade now? Services may restart and packages listed for removal will be removed.')" 11 80 || exit 0
|
||||||
|
if apt-get -y full-upgrade; then
|
||||||
|
msg_ok "$(translate 'PBS package upgrade completed. Check whether a reboot is required before your next backup window.')"
|
||||||
|
else
|
||||||
|
msg_error "$(translate 'PBS package upgrade failed. Review the APT output before retrying.')"
|
||||||
|
msg_success "$(translate "Press Enter to continue...")"
|
||||||
|
read -r
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
msg_success "$(translate "Press Enter to continue...")"
|
||||||
|
read -r
|
||||||
Reference in New Issue
Block a user