From cb585632c61b2c6d5ef39c4ebef3a2fd1ca57bcd Mon Sep 17 00:00:00 2001 From: MacRimi Date: Mon, 19 Oct 2026 18:18:18 +0200 Subject: [PATCH] fix(monitor): handle a PBS on the same host and complete the PBS views - Open a PBS installed on this host on the address in use, without probing it - Never carry or offer localhost addresses between Monitors - Read the PBS of a PVE+PBS host in Backup Server, with its hostname and version - Offer the PBS storages without a connection, with address and fingerprint - Show the disks and pools of a PBS in its Datastores tab - Hide guest and PVE storage settings on a PBS - Show a clearer status line for a PBS --- AppImage/components/health-thresholds.tsx | 7 +++- AppImage/components/monitor-switcher.tsx | 28 ++++++++++--- AppImage/components/pbs-panel.tsx | 29 ++++++++++--- AppImage/components/proxmox-dashboard.tsx | 1 + AppImage/components/settings.tsx | 2 +- AppImage/components/storage-overview.tsx | 11 ++--- AppImage/messages/de/common.json | 6 +++ AppImage/messages/en/common.json | 6 +++ AppImage/messages/es/common.json | 6 +++ AppImage/messages/fr/common.json | 6 +++ AppImage/messages/it/common.json | 6 +++ AppImage/messages/pt/common.json | 6 +++ AppImage/messages/sk/common.json | 6 +++ AppImage/messages/sv/common.json | 6 +++ AppImage/scripts/flask_pbs_routes.py | 21 +++++++++- AppImage/scripts/monitor_links.py | 44 ++++++++++++++++++-- AppImage/scripts/pbs_manager.py | 25 ++++++++++- AppImage/scripts/tests/test_monitor_links.py | 23 ++++++++++ AppImage/scripts/tests/test_pbs.py | 33 +++++++++++++++ 19 files changed, 249 insertions(+), 23 deletions(-) diff --git a/AppImage/components/health-thresholds.tsx b/AppImage/components/health-thresholds.tsx index 2926f8b7..e82ecfc0 100644 --- a/AppImage/components/health-thresholds.tsx +++ b/AppImage/components/health-thresholds.tsx @@ -1,6 +1,7 @@ "use client" import { useEffect, useState } from "react" +import { useIsPbs } from "../lib/use-platform" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card" import { Input } from "./ui/input" import { @@ -283,8 +284,12 @@ function computeVisualRange( // ─── Component ─────────────────────────────────────────────────────────────── +// Guests and PVE storages do not exist on a Proxmox Backup Server. +const PVE_ONLY_SECTIONS = new Set(["lxc_rootfs", "lxc_mount", "pve_storage"]) + export function HealthThresholds() { const t = useT() + const isPbs = useIsPbs() const tFallback = (key: string, fallback: string) => { const translated = t(key) return translated === key ? fallback : translated @@ -773,7 +778,7 @@ export function HealthThresholds() { Mobile ( - {SECTIONS.map((section) => { + {SECTIONS.filter((section) => !isPbs || !PVE_ONLY_SECTIONS.has(section.id)).map((section) => { const Icon = section.icon return (
diff --git a/AppImage/components/monitor-switcher.tsx b/AppImage/components/monitor-switcher.tsx index 63b57948..89f8d249 100644 --- a/AppImage/components/monitor-switcher.tsx +++ b/AppImage/components/monitor-switcher.tsx @@ -22,9 +22,11 @@ interface MonitorLink { product: "pve" | "pbs" group: "cluster" | "standalone" | "pbs" url: string | null - status: "online" | "offline" | "unknown" + status: "online" | "offline" | "unknown" | "local" native_url?: string | null manual: boolean + // A PBS installed on this same host. + local?: boolean hidden: boolean custom_url: boolean } @@ -48,6 +50,15 @@ function bareOrigin(value: unknown): string | null { } catch { return null } } +// localhost names the computer that opens the address, never another node. +function isLoopback(url: string): boolean { + try { + const host = new URL(url).hostname.replace(/^\[|\]$/g, "").toLowerCase() + return host === "localhost" || host.endsWith(".localhost") || /^127\./.test(host) + || host === "::1" || /^::ffff:127\./.test(host) || /^::ffff:7f/.test(host) + } catch { return false } +} + // The Monitors the source already links, carried in the jump so this one can add them too. function readCarried(raw: string | null): Carried[] { if (!raw) return [] @@ -58,7 +69,7 @@ function readCarried(raw: string | null): Carried[] { const out: Carried[] = [] for (const row of rows.slice(0, MAX_CARRIED)) { const url = bareOrigin(row?.u) - if (!url || url === window.location.origin || seen.has(url)) continue + if (!url || url === window.location.origin || isLoopback(url) || seen.has(url)) continue seen.add(url) out.push({ url, name: String(row?.n || new URL(url).hostname).slice(0, 64), product: row?.p === "pbs" ? "pbs" : "pve" }) } @@ -102,6 +113,7 @@ function takeOrigin(): (Origin & { fresh?: boolean }) | null { // Where a link leads: its Monitor, or the native PBS interface when no Monitor answers there. function destination(link: MonitorLink): { href: string; native: boolean } | null { + if (link.local && !link.url) return { href: `https://${window.location.hostname}:8007`, native: true } if (link.product === "pbs" && link.status !== "online" && link.native_url) return { href: link.native_url, native: true } return link.url ? { href: link.url, native: false } : null } @@ -127,7 +139,7 @@ export function MonitorSwitcher({ nodeName, compact = false }: { nodeName: strin const originKnown = !!origin && known(origin.url) // The source Monitor and the Monitors it links that this one does not know yet. const candidates: Carried[] = origin && data - ? [origin, ...(origin.links || [])].filter(row => !known(row.url)) + ? [origin, ...(origin.links || [])].filter(row => !known(row.url) && !isLoopback(row.url)) : [] const declined = (url: string) => { try { return (JSON.parse(sessionStorage.getItem(DECLINED_KEY) || "[]") as string[]).includes(url) } catch { return false } @@ -147,7 +159,8 @@ export function MonitorSwitcher({ nodeName, compact = false }: { nodeName: strin const withOrigin = (href: string) => { const target = bareOrigin(href.replace(/\/+$/, "")) const carried = (data?.links || []) - .filter(link => link.url && link.status === "online" && !destination(link)?.native && link.url !== target) + .filter(link => link.url && link.status === "online" && !destination(link)?.native && link.url !== target + && !isLoopback(link.url)) .map(link => ({ url: link.url as string, name: link.name, product: link.product })) const params = new URLSearchParams({ pmx_from: window.location.origin, pmx_name: nodeName, pmx_product: data?.self.product === "pbs" ? "pbs" : "pve" }) if (carried.length > 0) params.set("pmx_links", writeCarried(carried)) @@ -359,7 +372,12 @@ function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange }
- {isEditing + {link.local && !link.custom_url + ?
+ {t("monitorLinks.pbsWebInterface")} + {t("monitorLinks.thisNode")} +
+ : isEditing ?
- {connections?.local_available && {t("pbs.local")}} + {connections?.local_available && {t("pbs.thisNode")}} {connections?.servers.map(row => {row.name})} @@ -137,6 +138,22 @@ export function PBSPanel({ localOnly = false }: { localOnly?: boolean } = {}) { {String((connectionError || statusError)?.message || t("pbs.loadFailed"))}

} {isLoading &&

{t("pbs.loading")}

} {!selected && !connectionError && connections &&

{t("pbs.noServer")}

} + {!localOnly && !manage && (connections?.detected?.length || 0) > 0 && + {t("pbs.detectedTitle")} + +

{t("pbs.detectedHelp")}

+ {connections?.detected?.map(row =>
+ + {row.name} + {row.url} + + +
)} +
+
} {manage && !localOnly && {t("pbs.manage")} @@ -171,9 +188,11 @@ export function PBSPanel({ localOnly = false }: { localOnly?: boolean } = {}) { } {data && <>
- {data.name} · PBS {data.version || "—"} · {t("pbs.sampled")} {new Date(data.fetched_at * 1000).toLocaleTimeString()} - {Object.entries(data.services).map(([service, state]) => - {service}: {state})} + {data.name} · PBS {data.version || "—"} · {t("pbs.updatedAt", { time: new Date(data.fetched_at * 1000).toLocaleTimeString() })} + {Object.keys(data.services).length > 0 && Object.values(data.services).every(state => state === "active") + ? {t("pbs.servicesActive")} + : Object.entries(data.services).filter(([, state]) => state !== "active").map(([service, state]) => + {service}: {state})}
{Object.entries(data.errors).map(([section, detail]) =>

{t(`pbs.${section}`)}: {detail}

)} diff --git a/AppImage/components/proxmox-dashboard.tsx b/AppImage/components/proxmox-dashboard.tsx index 4b426082..c4e62768 100644 --- a/AppImage/components/proxmox-dashboard.tsx +++ b/AppImage/components/proxmox-dashboard.tsx @@ -843,6 +843,7 @@ function MonitorDashboard({ product }: { product: Product }) { {isPbs && ( + )} diff --git a/AppImage/components/settings.tsx b/AppImage/components/settings.tsx index 37e3edc8..913e4c86 100644 --- a/AppImage/components/settings.tsx +++ b/AppImage/components/settings.tsx @@ -1878,7 +1878,7 @@ export function Settings() { the matching toggle in NotificationSettings is hidden (the preference is preserved in the DB and reappears when detection is re-enabled). */} - + {!isPbs && } {!isPbs && <> {/* GitHub API — optional authentication for app upstream checks. */} diff --git a/AppImage/components/storage-overview.tsx b/AppImage/components/storage-overview.tsx index 7525b7e8..2f8907ba 100644 --- a/AppImage/components/storage-overview.tsx +++ b/AppImage/components/storage-overview.tsx @@ -231,7 +231,8 @@ function translateAtaError(raw: string, t: (key: string) => string): string | nu return out.length ? out.join('; ') : null } -export function StorageOverview() { +// `disksOnly` is the PBS Monitor: its disks and pools, without Proxmox VE storages. +export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } = {}) { const t = useT() // User-configurable disk temperature thresholds (Settings → Health @@ -284,7 +285,7 @@ export function StorageOverview() { try { const [data, proxmoxData, mountsData] = await Promise.all([ fetchApi("/api/storage"), - fetchApi("/api/proxmox-storage"), + disksOnly ? Promise.resolve(null) : fetchApi("/api/proxmox-storage"), // Sprint 13 — host-level NFS/CIFS/SMB mounts. Wrapped in catch // so a failure here doesn't blank the whole storage tab. fetchApi("/api/mounts").catch(() => ({ mounts: [], available: false } as RemoteMountsData)), @@ -1134,7 +1135,7 @@ export function StorageOverview() { return (
{/* Storage Summary */} -
+ {!disksOnly &&
{/* ── Total Storage (preview restyle: headline + stacked bar Local·Remote·Free) ── */} {(() => { const totalGB = (totalLocalCapacity || 0) + (totalRemoteCapacity || 0) @@ -1355,9 +1356,9 @@ export function StorageOverview() { ) })()} -
+
} - {proxmoxStorage && proxmoxStorage.storage && proxmoxStorage.storage.length > 0 && ( + {!disksOnly && proxmoxStorage && proxmoxStorage.storage && proxmoxStorage.storage.length > 0 && ( diff --git a/AppImage/messages/de/common.json b/AppImage/messages/de/common.json index 932c91eb..3be56c1b 100644 --- a/AppImage/messages/de/common.json +++ b/AppImage/messages/de/common.json @@ -7296,6 +7296,12 @@ "server": "PBS-Server", "chooseServer": "PBS-Server auswählen", "local": "Lokaler PBS", + "thisNode": "PBS auf diesem Knoten", + "updatedAt": "Aktualisiert um {time}", + "servicesActive": "Dienste aktiv", + "detectedTitle": "Im Proxmox-Speicher erkannte PBS", + "detectedHelp": "Diese Proxmox Backup Server sind als Backup-Speicher eingerichtet. Verbinden Sie jeden mit einem schreibgeschützten API-Token, um hier seine Datastores, Aufgaben und Jobs zu sehen.", + "connect": "Verbinden", "refresh": "Aktualisieren", "manage": "PBS-Verbindungen verwalten", "loadFailed": "PBS-Status konnte nicht geladen werden.", diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index 926cb9cb..91b899b4 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -4,6 +4,12 @@ "server": "PBS server", "chooseServer": "Choose a PBS server", "local": "Local PBS", + "thisNode": "PBS on this node", + "updatedAt": "Updated at {time}", + "servicesActive": "Services active", + "detectedTitle": "PBS detected in Proxmox storage", + "detectedHelp": "These Proxmox Backup Servers are configured as backup storage. Connect each one with a read-only API token to see its datastores, tasks and jobs here.", + "connect": "Connect", "refresh": "Refresh", "manage": "Manage PBS connections", "loadFailed": "Could not load PBS status.", diff --git a/AppImage/messages/es/common.json b/AppImage/messages/es/common.json index a9cb8e80..00127ee2 100644 --- a/AppImage/messages/es/common.json +++ b/AppImage/messages/es/common.json @@ -4,6 +4,12 @@ "server": "Servidor PBS", "chooseServer": "Selecciona un servidor PBS", "local": "PBS local", + "thisNode": "PBS de este nodo", + "updatedAt": "Actualizado a las {time}", + "servicesActive": "Servicios activos", + "detectedTitle": "PBS detectados en el almacenamiento de Proxmox", + "detectedHelp": "Estos Proxmox Backup Server están configurados como almacenamiento de backups. Conecta cada uno con un token API de solo lectura para ver aquí sus datastores, tareas y trabajos.", + "connect": "Conectar", "refresh": "Actualizar", "manage": "Gestionar conexiones PBS", "loadFailed": "No se pudo consultar el estado de PBS.", diff --git a/AppImage/messages/fr/common.json b/AppImage/messages/fr/common.json index 39d7802e..bbd434d6 100644 --- a/AppImage/messages/fr/common.json +++ b/AppImage/messages/fr/common.json @@ -7296,6 +7296,12 @@ "server": "Serveur PBS", "chooseServer": "Choisir un serveur PBS", "local": "PBS local", + "thisNode": "PBS de ce nœud", + "updatedAt": "Mis à jour à {time}", + "servicesActive": "Services actifs", + "detectedTitle": "PBS détectés dans le stockage Proxmox", + "detectedHelp": "Ces Proxmox Backup Server sont configurés comme stockage de sauvegarde. Connectez chacun avec un jeton API en lecture seule pour voir ici ses datastores, tâches et travaux.", + "connect": "Connecter", "refresh": "Actualiser", "manage": "Gérer les connexions PBS", "loadFailed": "Impossible de charger l'état de PBS.", diff --git a/AppImage/messages/it/common.json b/AppImage/messages/it/common.json index b264b434..e7c191bf 100644 --- a/AppImage/messages/it/common.json +++ b/AppImage/messages/it/common.json @@ -7296,6 +7296,12 @@ "server": "Server PBS", "chooseServer": "Scegli un server PBS", "local": "PBS locale", + "thisNode": "PBS di questo nodo", + "updatedAt": "Aggiornato alle {time}", + "servicesActive": "Servizi attivi", + "detectedTitle": "PBS rilevati nello storage di Proxmox", + "detectedHelp": "Questi Proxmox Backup Server sono configurati come storage di backup. Collega ciascuno con un token API di sola lettura per vedere qui i suoi datastore, attività e job.", + "connect": "Collega", "refresh": "Aggiorna", "manage": "Gestisci le connessioni PBS", "loadFailed": "Impossibile caricare lo stato di PBS.", diff --git a/AppImage/messages/pt/common.json b/AppImage/messages/pt/common.json index 0d1307c2..371090c4 100644 --- a/AppImage/messages/pt/common.json +++ b/AppImage/messages/pt/common.json @@ -7296,6 +7296,12 @@ "server": "Servidor PBS", "chooseServer": "Escolher um servidor PBS", "local": "PBS local", + "thisNode": "PBS deste nó", + "updatedAt": "Atualizado às {time}", + "servicesActive": "Serviços ativos", + "detectedTitle": "PBS detetados no armazenamento do Proxmox", + "detectedHelp": "Estes Proxmox Backup Server estão configurados como armazenamento de backups. Ligue cada um com um token API só de leitura para ver aqui os seus datastores, tarefas e trabalhos.", + "connect": "Ligar", "refresh": "Atualizar", "manage": "Gerir ligações PBS", "loadFailed": "Não foi possível carregar o estado do PBS.", diff --git a/AppImage/messages/sk/common.json b/AppImage/messages/sk/common.json index 3984d716..be43d02c 100644 --- a/AppImage/messages/sk/common.json +++ b/AppImage/messages/sk/common.json @@ -7295,6 +7295,12 @@ "server": "Server PBS", "chooseServer": "Vybrať server PBS", "local": "Miestny PBS", + "thisNode": "PBS na tomto uzle", + "updatedAt": "Aktualizované o {time}", + "servicesActive": "Služby sú aktívne", + "detectedTitle": "PBS zistené v úložisku Proxmox", + "detectedHelp": "Tieto Proxmox Backup Servery sú nastavené ako úložisko záloh. Pripojte každý z nich API tokenom len na čítanie, aby ste tu videli jeho datastory, úlohy a joby.", + "connect": "Pripojiť", "refresh": "Obnoviť", "manage": "Spravovať pripojenia PBS", "loadFailed": "Nepodarilo sa načítať stav PBS.", diff --git a/AppImage/messages/sv/common.json b/AppImage/messages/sv/common.json index ec2ad5f9..f4d49723 100644 --- a/AppImage/messages/sv/common.json +++ b/AppImage/messages/sv/common.json @@ -7296,6 +7296,12 @@ "server": "PBS-server", "chooseServer": "Välj en PBS-server", "local": "Lokal PBS", + "thisNode": "PBS på den här noden", + "updatedAt": "Uppdaterad {time}", + "servicesActive": "Tjänsterna är aktiva", + "detectedTitle": "PBS som hittats i Proxmox-lagringen", + "detectedHelp": "Dessa Proxmox Backup Server är konfigurerade som backuplagring. Anslut var och en med en skrivskyddad API-token för att se deras datastores, uppgifter och jobb här.", + "connect": "Anslut", "refresh": "Uppdatera", "manage": "Hantera PBS-anslutningar", "loadFailed": "Det gick inte att läsa in PBS-status.", diff --git a/AppImage/scripts/flask_pbs_routes.py b/AppImage/scripts/flask_pbs_routes.py index 4e41936f..83f9d07e 100644 --- a/AppImage/scripts/flask_pbs_routes.py +++ b/AppImage/scripts/flask_pbs_routes.py @@ -24,11 +24,30 @@ def platform(): @require_auth def servers(): try: - return jsonify(servers=pbs.public_servers(), local_available=pbs.is_pbs_host()) + servers = pbs.public_servers() + return jsonify(servers=servers, local_available=pbs.local_available(), detected=_detected(servers)) except pbs.PBSError as exc: return jsonify(error=str(exc)), 500 +def _detected(servers): + """PBS storages of this PVE that have no monitoring connection yet.""" + if pbs.is_pbs_host(): + return [] + import monitor_links + from urllib.parse import urlsplit + known = {urlsplit(row.get("url", "")).hostname for row in servers} + found = {} + for row in monitor_links._storage_pbs(): + host = row["server"] + if host in known or monitor_links._is_this_host(host): + continue + port = row.get("port") if str(row.get("port", "")).isdigit() else monitor_links.PBS_UI_PORT + url = monitor_links._host_url(host, port, "https") + found.setdefault(url, {"name": row["name"], "url": url, "fingerprint": row.get("fingerprint", "")}) + return list(found.values()) + + def _protected_configuration(): local = auth_manager.load_auth_config() return local.get("enabled") and not local.get("declined") and local.get("password_hash") diff --git a/AppImage/scripts/monitor_links.py b/AppImage/scripts/monitor_links.py index 35ff58a2..169ca0f0 100644 --- a/AppImage/scripts/monitor_links.py +++ b/AppImage/scripts/monitor_links.py @@ -107,6 +107,32 @@ def _is_private(host): return False +def _own_addresses(): + addresses = set() + try: + import psutil + for entries in psutil.net_if_addrs().values(): + for entry in entries: + if entry.family in (socket.AF_INET, socket.AF_INET6): + addresses.add(entry.address.split("%")[0]) + except Exception: + pass + return addresses + + +def _is_this_host(host): + """A PBS installed next to PVE is named localhost or one of the host's own addresses.""" + try: + resolved = {info[4][0].split("%")[0] for info in socket.getaddrinfo(host, None)} + except OSError: + return False + own = _own_addresses() + try: + return bool(resolved) and all(ipaddress.ip_address(a).is_loopback or a in own for a in resolved) + except ValueError: + return False + + def _cluster_nodes(): try: result = subprocess.run(["pvesh", "get", "/cluster/status", "--output-format", "json"], @@ -135,7 +161,7 @@ def _storage_pbs(): if current is not None: servers.append(current) continue - option = re.match(r"^\s+(server|port)\s+(\S+)", line) + option = re.match(r"^\s+(server|port|fingerprint)\s+(\S+)", line) if current is not None and option: current[option.group(1)] = option.group(2) return [row for row in servers if row.get("server")] @@ -154,8 +180,17 @@ def _pbs_servers(): found.setdefault(host, {"name": row.get("name") or host, "native_url": normalize_url(row["url"])}) except Exception: pass - return [{"key": f"pbs:{host}", "name": info["name"], "product": "pbs", "group": "pbs", - "host": host, "native_url": info["native_url"]} for host, info in found.items()] + servers, local = [], None + for host, info in found.items(): + if _is_this_host(host): + # Its web interface is on the address the browser already uses. + if local is None: + local = {"key": "pbs:local", "name": info["name"], "product": "pbs", "group": "pbs", "local": True} + servers.append(local) + continue + servers.append({"key": f"pbs:{host}", "name": info["name"], "product": "pbs", "group": "pbs", + "host": host, "native_url": info["native_url"]}) + return servers def _probe(url): @@ -188,6 +223,8 @@ def _resolve(entry, override): return override["url"], "online" if _probe(override["url"]) else "offline" if entry.get("url"): return entry["url"], "online" if _probe(entry["url"]) else "offline" + if entry.get("local"): + return None, "local" host = entry["host"] if not _is_private(host): return None, "unknown" @@ -218,6 +255,7 @@ def list_links(include_hidden=False): links.append({"key": entry["key"], "name": entry["name"], "product": entry["product"], "group": entry["group"], "url": url, "status": state, "native_url": entry.get("native_url"), "manual": bool(entry.get("manual")), + "local": bool(entry.get("local")), "hidden": bool(override.get("hidden")), "custom_url": bool(override.get("url"))}) return {"self": {"name": platform.get("hostname") or socket.gethostname(), "product": platform.get("product")}, "links": links} diff --git a/AppImage/scripts/pbs_manager.py b/AppImage/scripts/pbs_manager.py index 83eedac8..4e90f8be 100644 --- a/AppImage/scripts/pbs_manager.py +++ b/AppImage/scripts/pbs_manager.py @@ -11,6 +11,7 @@ from pathlib import Path import re import secrets import shutil +import socket import ssl import subprocess import tempfile @@ -53,6 +54,26 @@ def is_pbs_host(): return get_platform().get("product") == "pbs" +def local_available(): + """This host runs PBS: as its product, or installed next to PVE.""" + if is_pbs_host(): + return True + return bool(shutil.which("proxmox-backup-manager")) and any( + Path(folder, "proxmox-backup-proxy.service").exists() + for folder in ("/lib/systemd/system", "/usr/lib/systemd/system")) + + +def _local_version(): + if is_pbs_host(): + return get_platform().get("version") + try: + result = subprocess.run(["dpkg-query", "-W", "-f=${Version}", "proxmox-backup-server"], + capture_output=True, text=True, timeout=5) + return result.stdout.strip() or None if result.returncode == 0 else None + except (OSError, subprocess.SubprocessError): + return None + + def _servers_path(): return auth_manager.CONFIG_DIR / "pbs_servers.json" @@ -248,9 +269,9 @@ def get_status(identifier): return cached[1] errors = {} if identifier == "local": - if not is_pbs_host(): + if not local_available(): raise PBSError("The local host is not a PBS server.") - name, version = "Local PBS", get_platform().get("version") + name, version = get_platform().get("hostname") or socket.gethostname(), _local_version() services = _local_services() operations = {"datastores": _local_datastores, "tasks": lambda: _cli("task", "list", "--all", "true", "--limit", "20"), diff --git a/AppImage/scripts/tests/test_monitor_links.py b/AppImage/scripts/tests/test_monitor_links.py index 48036393..1c652f4b 100644 --- a/AppImage/scripts/tests/test_monitor_links.py +++ b/AppImage/scripts/tests/test_monitor_links.py @@ -62,6 +62,29 @@ class MonitorLinksTests(unittest.TestCase): self.assertEqual(by_key["pbs:pbs.example.com"]["native_url"], "https://pbs.example.com:8443") self.assertFalse(any("example.com" in url for url in probed)) + def test_pbs_installed_on_this_host_opens_its_own_interface_and_is_never_probed(self): + links.STORAGE_CFG.write_text("pbs: PBS\n\tdatastore PBS\n\tserver localhost\n\n" + "pbs: Same\n\tdatastore x\n\tserver 10.0.0.55\n\n" + "pbs: Lab\n\tdatastore store1\n\tserver 10.0.0.49\n") + probed = [] + with mock.patch.object(links, "_own_addresses", return_value={"10.0.0.55"}), \ + mock.patch.object(links, "_probe", side_effect=lambda url: probed.append(url) or False), \ + mock.patch.object(links, "_is_private", return_value=True): + result = links.list_links() + pbs_rows = [row for row in result["links"] if row["group"] == "pbs"] + self.assertEqual([row["key"] for row in pbs_rows], ["pbs:local", "pbs:10.0.0.49"]) + local = pbs_rows[0] + self.assertEqual((local["name"], local["local"], local["url"], local["status"]), ("PBS", True, None, "local")) + self.assertIsNone(local["native_url"]) + self.assertFalse(any("localhost" in url or "10.0.0.55" in url for url in probed)) + self.assertFalse(pbs_rows[1]["local"]) + + def test_loopback_names_are_this_host(self): + with mock.patch.object(links, "_own_addresses", return_value=set()): + self.assertTrue(links._is_this_host("127.0.0.1")) + self.assertTrue(links._is_this_host("::1")) + self.assertFalse(links._is_this_host("10.0.0.49")) + def test_pbs_host_lists_only_manual_entries(self): links.pbs_manager.get_platform.return_value = {"product": "pbs", "hostname": "pbs-lab"} links.add_link({"name": "amd", "url": "https://10.0.0.55:8008", "product": "pve"}) diff --git a/AppImage/scripts/tests/test_pbs.py b/AppImage/scripts/tests/test_pbs.py index 878debc7..0f1f49f3 100644 --- a/AppImage/scripts/tests/test_pbs.py +++ b/AppImage/scripts/tests/test_pbs.py @@ -166,6 +166,39 @@ class PBSManagerTests(unittest.TestCase): self.assertEqual(result["datastores"][0]["used"], 30) self.assertEqual(set(result["services"]), set(pbs.PBS_SERVICES)) + def test_pbs_installed_next_to_pve_is_read_locally_with_its_own_version(self): + def run(command, **kwargs): + if command[0] == "dpkg-query": + return SimpleNamespace(stdout="4.2.8-1", returncode=0) + return SimpleNamespace(stdout="[]", returncode=0) + with mock.patch.object(pbs, "get_platform", return_value={"product": "pve", "version": "9.1.4", "hostname": "rimegra"}), \ + mock.patch.object(pbs.shutil, "which", return_value="/usr/sbin/proxmox-backup-manager"), \ + mock.patch.object(pbs.Path, "exists", return_value=True), \ + 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): + self.assertTrue(pbs.local_available()) + result = pbs.get_status("local") + self.assertEqual((result["name"], result["version"]), ("rimegra", "4.2.8-1")) + with mock.patch.object(pbs, "get_platform", return_value={"product": "pve"}), \ + mock.patch.object(pbs.shutil, "which", return_value=None): + self.assertFalse(pbs.local_available()) + + def test_storage_pbs_without_a_connection_is_offered_with_its_address_and_fingerprint(self): + import monitor_links + storage = self.root / "storage.cfg" + storage.write_text("pbs: Local\n\tserver localhost\n\tdatastore a\n\n" + "pbs: Lab\n\tserver 10.0.0.49\n\tdatastore b\n\tfingerprint AA:BB\n\n" + "pbs: Known\n\tserver pbs.test\n\tdatastore c\n\tport 8007\n") + with mock.patch.object(monitor_links, "STORAGE_CFG", storage), \ + mock.patch.object(monitor_links, "_own_addresses", return_value=set()), \ + mock.patch.object(pbs, "is_pbs_host", return_value=False), \ + mock.patch.object(pbs, "local_available", return_value=False), \ + mock.patch.object(pbs, "_remote_get", return_value=[]): + pbs.save_server(self.server) + response = self.client.get("/api/pbs/servers", headers=self.headers) + self.assertEqual(response.json["detected"], + [{"name": "Lab", "url": "https://10.0.0.49:8007", "fingerprint": "AA:BB"}]) + 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=[]):