mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 10:22:08 +00:00
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
This commit is contained in:
@@ -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 (<md) stays single-column as today.
|
||||
*/}
|
||||
<div className="columns-1 md:columns-2 2xl:columns-3 gap-4 space-y-4 [&>*]:break-inside-avoid">
|
||||
{SECTIONS.map((section) => {
|
||||
{SECTIONS.filter((section) => !isPbs || !PVE_ONLY_SECTIONS.has(section.id)).map((section) => {
|
||||
const Icon = section.icon
|
||||
return (
|
||||
<div key={section.id} className="rounded-md border border-border/50 px-3 py-2">
|
||||
|
||||
@@ -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
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>}
|
||||
</div>
|
||||
{isEditing
|
||||
{link.local && !link.custom_url
|
||||
? <div className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-foreground">{t("monitorLinks.pbsWebInterface")}</span>
|
||||
<span className="shrink-0 rounded border border-border px-1.5 py-0.5 text-[11px] text-muted-foreground">{t("monitorLinks.thisNode")}</span>
|
||||
</div>
|
||||
: isEditing
|
||||
? <div className="flex gap-2">
|
||||
<Input value={draft} disabled={busy} autoFocus aria-label={t("monitorLinks.address")}
|
||||
placeholder={link.manual ? "" : `${t("monitorLinks.autoAddress")}${shown ? `: ${shown}` : ""}`}
|
||||
|
||||
@@ -17,7 +17,8 @@ import { fetchApi } from "../lib/api-config"
|
||||
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 Detected { name: string; url: string; fingerprint: string }
|
||||
interface Connections { local_available: boolean; servers: Connection[]; detected?: Detected[] }
|
||||
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 {
|
||||
@@ -122,7 +123,7 @@ export function PBSPanel({ localOnly = false }: { localOnly?: boolean } = {}) {
|
||||
<Select value={selected || undefined} onValueChange={setSelected}>
|
||||
<SelectTrigger aria-label={t("pbs.server")} className="w-full sm:ml-auto sm:w-56"><SelectValue placeholder={t("pbs.chooseServer")} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{connections?.local_available && <SelectItem value="local">{t("pbs.local")}</SelectItem>}
|
||||
{connections?.local_available && <SelectItem value="local">{t("pbs.thisNode")}</SelectItem>}
|
||||
{connections?.servers.map(row => <SelectItem key={row.id} value={row.id}>{row.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -137,6 +138,22 @@ export function PBSPanel({ localOnly = false }: { localOnly?: boolean } = {}) {
|
||||
{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>}
|
||||
{!localOnly && !manage && (connections?.detected?.length || 0) > 0 && <Card className="bg-card border-border">
|
||||
<CardHeader><CardTitle className="flex items-center text-foreground"><DatabaseBackup className="mr-2 h-5 w-5" />{t("pbs.detectedTitle")}</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">{t("pbs.detectedHelp")}</p>
|
||||
{connections?.detected?.map(row => <div key={row.url} className="flex flex-wrap items-center gap-3 rounded-lg border border-border px-3 py-2">
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-foreground">{row.name}</span>
|
||||
<span className="block break-all font-mono text-xs text-muted-foreground">{row.url}</span>
|
||||
</span>
|
||||
<Button size="sm" className="bg-blue-600 text-white hover:bg-blue-700" onClick={() => {
|
||||
setForm({ ...emptyForm, name: row.name, url: row.url, fingerprint: row.fingerprint })
|
||||
setError(""); setMessage(""); setManage(true)
|
||||
}}>{t("pbs.connect")}</Button>
|
||||
</div>)}
|
||||
</CardContent>
|
||||
</Card>}
|
||||
{manage && !localOnly && <Card>
|
||||
<CardHeader><CardTitle>{t("pbs.manage")}</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
@@ -171,9 +188,11 @@ export function PBSPanel({ localOnly = false }: { localOnly?: boolean } = {}) {
|
||||
</Card>}
|
||||
{data && <>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{data.name} · PBS {data.version || "—"} · {t("pbs.sampled")} {new Date(data.fetched_at * 1000).toLocaleTimeString()}</span>
|
||||
{Object.entries(data.services).map(([service, state]) =>
|
||||
<Badge key={service} variant="outline" className={state === "active" ? badgeTone.ok : badgeTone.error}>{service}: {state}</Badge>)}
|
||||
<span>{data.name} · PBS {data.version || "—"} · {t("pbs.updatedAt", { time: new Date(data.fetched_at * 1000).toLocaleTimeString() })}</span>
|
||||
{Object.keys(data.services).length > 0 && Object.values(data.services).every(state => state === "active")
|
||||
? <Badge variant="outline" className={badgeTone.ok}>{t("pbs.servicesActive")}</Badge>
|
||||
: Object.entries(data.services).filter(([, state]) => state !== "active").map(([service, state]) =>
|
||||
<Badge key={service} variant="outline" className={badgeTone.error}>{service}: {state}</Badge>)}
|
||||
</div>
|
||||
{Object.entries(data.errors).map(([section, detail]) => <p key={section} role="alert" className="text-sm text-amber-500">{t(`pbs.${section}`)}: {detail}</p>)}
|
||||
|
||||
|
||||
@@ -843,6 +843,7 @@ function MonitorDashboard({ product }: { product: Product }) {
|
||||
{isPbs && (
|
||||
<TabsContent value="datastores" className="space-y-4 md:space-y-6 mt-0">
|
||||
<PBSPanel key={`datastores-${componentKey}`} localOnly />
|
||||
<StorageOverview key={`datastore-disks-${componentKey}`} disksOnly />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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). */}
|
||||
<LxcUpdateDetection />
|
||||
{!isPbs && <LxcUpdateDetection />}
|
||||
|
||||
{!isPbs && <>
|
||||
{/* GitHub API — optional authentication for app upstream checks. */}
|
||||
|
||||
@@ -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<StorageData>("/api/storage"),
|
||||
fetchApi<ProxmoxStorageData>("/api/proxmox-storage"),
|
||||
disksOnly ? Promise.resolve(null) : fetchApi<ProxmoxStorageData>("/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<RemoteMountsData>("/api/mounts").catch(() => ({ mounts: [], available: false } as RemoteMountsData)),
|
||||
@@ -1134,7 +1135,7 @@ export function StorageOverview() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Storage Summary */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-3 xl:gap-6">
|
||||
{!disksOnly && <div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-3 xl:gap-6">
|
||||
{/* ── Total Storage (preview restyle: headline + stacked bar Local·Remote·Free) ── */}
|
||||
{(() => {
|
||||
const totalGB = (totalLocalCapacity || 0) + (totalRemoteCapacity || 0)
|
||||
@@ -1355,9 +1356,9 @@ export function StorageOverview() {
|
||||
</Card>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{proxmoxStorage && proxmoxStorage.storage && proxmoxStorage.storage.length > 0 && (
|
||||
{!disksOnly && proxmoxStorage && proxmoxStorage.storage && proxmoxStorage.storage.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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=[]):
|
||||
|
||||
Reference in New Issue
Block a user