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:
MacRimi
2026-10-19 18:18:18 +02:00
parent 311952cdf5
commit cb585632c6
19 changed files with 249 additions and 23 deletions
+6 -1
View File
@@ -1,6 +1,7 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { useIsPbs } from "../lib/use-platform"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
import { Input } from "./ui/input" import { Input } from "./ui/input"
import { import {
@@ -283,8 +284,12 @@ function computeVisualRange(
// ─── Component ─────────────────────────────────────────────────────────────── // ─── 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() { export function HealthThresholds() {
const t = useT() const t = useT()
const isPbs = useIsPbs()
const tFallback = (key: string, fallback: string) => { const tFallback = (key: string, fallback: string) => {
const translated = t(key) const translated = t(key)
return translated === key ? fallback : translated return translated === key ? fallback : translated
@@ -773,7 +778,7 @@ export function HealthThresholds() {
Mobile (<md) stays single-column as today. 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"> <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 const Icon = section.icon
return ( return (
<div key={section.id} className="rounded-md border border-border/50 px-3 py-2"> <div key={section.id} className="rounded-md border border-border/50 px-3 py-2">
+23 -5
View File
@@ -22,9 +22,11 @@ interface MonitorLink {
product: "pve" | "pbs" product: "pve" | "pbs"
group: "cluster" | "standalone" | "pbs" group: "cluster" | "standalone" | "pbs"
url: string | null url: string | null
status: "online" | "offline" | "unknown" status: "online" | "offline" | "unknown" | "local"
native_url?: string | null native_url?: string | null
manual: boolean manual: boolean
// A PBS installed on this same host.
local?: boolean
hidden: boolean hidden: boolean
custom_url: boolean custom_url: boolean
} }
@@ -48,6 +50,15 @@ function bareOrigin(value: unknown): string | null {
} catch { return 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. // The Monitors the source already links, carried in the jump so this one can add them too.
function readCarried(raw: string | null): Carried[] { function readCarried(raw: string | null): Carried[] {
if (!raw) return [] if (!raw) return []
@@ -58,7 +69,7 @@ function readCarried(raw: string | null): Carried[] {
const out: Carried[] = [] const out: Carried[] = []
for (const row of rows.slice(0, MAX_CARRIED)) { for (const row of rows.slice(0, MAX_CARRIED)) {
const url = bareOrigin(row?.u) 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) seen.add(url)
out.push({ url, name: String(row?.n || new URL(url).hostname).slice(0, 64), product: row?.p === "pbs" ? "pbs" : "pve" }) 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. // 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 { 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 } 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 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) const originKnown = !!origin && known(origin.url)
// The source Monitor and the Monitors it links that this one does not know yet. // The source Monitor and the Monitors it links that this one does not know yet.
const candidates: Carried[] = origin && data 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) => { const declined = (url: string) => {
try { return (JSON.parse(sessionStorage.getItem(DECLINED_KEY) || "[]") as string[]).includes(url) } catch { return false } 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 withOrigin = (href: string) => {
const target = bareOrigin(href.replace(/\/+$/, "")) const target = bareOrigin(href.replace(/\/+$/, ""))
const carried = (data?.links || []) 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 })) .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" }) 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)) 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" /> <Trash2 className="h-4 w-4 text-red-500" />
</Button>} </Button>}
</div> </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"> ? <div className="flex gap-2">
<Input value={draft} disabled={busy} autoFocus aria-label={t("monitorLinks.address")} <Input value={draft} disabled={busy} autoFocus aria-label={t("monitorLinks.address")}
placeholder={link.manual ? "" : `${t("monitorLinks.autoAddress")}${shown ? `: ${shown}` : ""}`} placeholder={link.manual ? "" : `${t("monitorLinks.autoAddress")}${shown ? `: ${shown}` : ""}`}
+24 -5
View File
@@ -17,7 +17,8 @@ import { fetchApi } from "../lib/api-config"
import { useT } from "../lib/i18n/provider" import { useT } from "../lib/i18n/provider"
interface Connection { id: string; name: string; url: string; token_id: string; fingerprint: string; has_token: boolean } 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 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 Job { id: string; store?: string; schedule?: string; "last-run-state"?: string }
interface PBSStatus { interface PBSStatus {
@@ -122,7 +123,7 @@ export function PBSPanel({ localOnly = false }: { localOnly?: boolean } = {}) {
<Select value={selected || undefined} onValueChange={setSelected}> <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> <SelectTrigger aria-label={t("pbs.server")} className="w-full sm:ml-auto sm:w-56"><SelectValue placeholder={t("pbs.chooseServer")} /></SelectTrigger>
<SelectContent> <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>)} {connections?.servers.map(row => <SelectItem key={row.id} value={row.id}>{row.name}</SelectItem>)}
</SelectContent> </SelectContent>
</Select> </Select>
@@ -137,6 +138,22 @@ export function PBSPanel({ localOnly = false }: { localOnly?: boolean } = {}) {
{String((connectionError || statusError)?.message || t("pbs.loadFailed"))}</p>} {String((connectionError || statusError)?.message || t("pbs.loadFailed"))}</p>}
{isLoading && <p role="status" className="text-sm text-muted-foreground">{t("pbs.loading")}</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>} {!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> {manage && !localOnly && <Card>
<CardHeader><CardTitle>{t("pbs.manage")}</CardTitle></CardHeader> <CardHeader><CardTitle>{t("pbs.manage")}</CardTitle></CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
@@ -171,9 +188,11 @@ export function PBSPanel({ localOnly = false }: { localOnly?: boolean } = {}) {
</Card>} </Card>}
{data && <> {data && <>
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground"> <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> <span>{data.name} · PBS {data.version || "—"} · {t("pbs.updatedAt", { time: new Date(data.fetched_at * 1000).toLocaleTimeString() })}</span>
{Object.entries(data.services).map(([service, state]) => {Object.keys(data.services).length > 0 && Object.values(data.services).every(state => state === "active")
<Badge key={service} variant="outline" className={state === "active" ? badgeTone.ok : badgeTone.error}>{service}: {state}</Badge>)} ? <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> </div>
{Object.entries(data.errors).map(([section, detail]) => <p key={section} role="alert" className="text-sm text-amber-500">{t(`pbs.${section}`)}: {detail}</p>)} {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 && ( {isPbs && (
<TabsContent value="datastores" className="space-y-4 md:space-y-6 mt-0"> <TabsContent value="datastores" className="space-y-4 md:space-y-6 mt-0">
<PBSPanel key={`datastores-${componentKey}`} localOnly /> <PBSPanel key={`datastores-${componentKey}`} localOnly />
<StorageOverview key={`datastore-disks-${componentKey}`} disksOnly />
</TabsContent> </TabsContent>
)} )}
+1 -1
View File
@@ -1878,7 +1878,7 @@ export function Settings() {
the matching toggle in NotificationSettings is hidden (the the matching toggle in NotificationSettings is hidden (the
preference is preserved in the DB and reappears when detection preference is preserved in the DB and reappears when detection
is re-enabled). */} is re-enabled). */}
<LxcUpdateDetection /> {!isPbs && <LxcUpdateDetection />}
{!isPbs && <> {!isPbs && <>
{/* GitHub API — optional authentication for app upstream checks. */} {/* GitHub API — optional authentication for app upstream checks. */}
+6 -5
View File
@@ -231,7 +231,8 @@ function translateAtaError(raw: string, t: (key: string) => string): string | nu
return out.length ? out.join('; ') : null 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() const t = useT()
// User-configurable disk temperature thresholds (Settings → Health // User-configurable disk temperature thresholds (Settings → Health
@@ -284,7 +285,7 @@ export function StorageOverview() {
try { try {
const [data, proxmoxData, mountsData] = await Promise.all([ const [data, proxmoxData, mountsData] = await Promise.all([
fetchApi<StorageData>("/api/storage"), 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 // Sprint 13 — host-level NFS/CIFS/SMB mounts. Wrapped in catch
// so a failure here doesn't blank the whole storage tab. // so a failure here doesn't blank the whole storage tab.
fetchApi<RemoteMountsData>("/api/mounts").catch(() => ({ mounts: [], available: false } as RemoteMountsData)), fetchApi<RemoteMountsData>("/api/mounts").catch(() => ({ mounts: [], available: false } as RemoteMountsData)),
@@ -1134,7 +1135,7 @@ export function StorageOverview() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Storage Summary */} {/* 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) ── */} {/* ── Total Storage (preview restyle: headline + stacked bar Local·Remote·Free) ── */}
{(() => { {(() => {
const totalGB = (totalLocalCapacity || 0) + (totalRemoteCapacity || 0) const totalGB = (totalLocalCapacity || 0) + (totalRemoteCapacity || 0)
@@ -1355,9 +1356,9 @@ export function StorageOverview() {
</Card> </Card>
) )
})()} })()}
</div> </div>}
{proxmoxStorage && proxmoxStorage.storage && proxmoxStorage.storage.length > 0 && ( {!disksOnly && proxmoxStorage && proxmoxStorage.storage && proxmoxStorage.storage.length > 0 && (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
+6
View File
@@ -7296,6 +7296,12 @@
"server": "PBS-Server", "server": "PBS-Server",
"chooseServer": "PBS-Server auswählen", "chooseServer": "PBS-Server auswählen",
"local": "Lokaler PBS", "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", "refresh": "Aktualisieren",
"manage": "PBS-Verbindungen verwalten", "manage": "PBS-Verbindungen verwalten",
"loadFailed": "PBS-Status konnte nicht geladen werden.", "loadFailed": "PBS-Status konnte nicht geladen werden.",
+6
View File
@@ -4,6 +4,12 @@
"server": "PBS server", "server": "PBS server",
"chooseServer": "Choose a PBS server", "chooseServer": "Choose a PBS server",
"local": "Local PBS", "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", "refresh": "Refresh",
"manage": "Manage PBS connections", "manage": "Manage PBS connections",
"loadFailed": "Could not load PBS status.", "loadFailed": "Could not load PBS status.",
+6
View File
@@ -4,6 +4,12 @@
"server": "Servidor PBS", "server": "Servidor PBS",
"chooseServer": "Selecciona un servidor PBS", "chooseServer": "Selecciona un servidor PBS",
"local": "PBS local", "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", "refresh": "Actualizar",
"manage": "Gestionar conexiones PBS", "manage": "Gestionar conexiones PBS",
"loadFailed": "No se pudo consultar el estado de PBS.", "loadFailed": "No se pudo consultar el estado de PBS.",
+6
View File
@@ -7296,6 +7296,12 @@
"server": "Serveur PBS", "server": "Serveur PBS",
"chooseServer": "Choisir un serveur PBS", "chooseServer": "Choisir un serveur PBS",
"local": "PBS local", "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", "refresh": "Actualiser",
"manage": "Gérer les connexions PBS", "manage": "Gérer les connexions PBS",
"loadFailed": "Impossible de charger l'état de PBS.", "loadFailed": "Impossible de charger l'état de PBS.",
+6
View File
@@ -7296,6 +7296,12 @@
"server": "Server PBS", "server": "Server PBS",
"chooseServer": "Scegli un server PBS", "chooseServer": "Scegli un server PBS",
"local": "PBS locale", "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", "refresh": "Aggiorna",
"manage": "Gestisci le connessioni PBS", "manage": "Gestisci le connessioni PBS",
"loadFailed": "Impossibile caricare lo stato di PBS.", "loadFailed": "Impossibile caricare lo stato di PBS.",
+6
View File
@@ -7296,6 +7296,12 @@
"server": "Servidor PBS", "server": "Servidor PBS",
"chooseServer": "Escolher um servidor PBS", "chooseServer": "Escolher um servidor PBS",
"local": "PBS local", "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", "refresh": "Atualizar",
"manage": "Gerir ligações PBS", "manage": "Gerir ligações PBS",
"loadFailed": "Não foi possível carregar o estado do PBS.", "loadFailed": "Não foi possível carregar o estado do PBS.",
+6
View File
@@ -7295,6 +7295,12 @@
"server": "Server PBS", "server": "Server PBS",
"chooseServer": "Vybrať server PBS", "chooseServer": "Vybrať server PBS",
"local": "Miestny 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ť", "refresh": "Obnoviť",
"manage": "Spravovať pripojenia PBS", "manage": "Spravovať pripojenia PBS",
"loadFailed": "Nepodarilo sa načítať stav PBS.", "loadFailed": "Nepodarilo sa načítať stav PBS.",
+6
View File
@@ -7296,6 +7296,12 @@
"server": "PBS-server", "server": "PBS-server",
"chooseServer": "Välj en PBS-server", "chooseServer": "Välj en PBS-server",
"local": "Lokal PBS", "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", "refresh": "Uppdatera",
"manage": "Hantera PBS-anslutningar", "manage": "Hantera PBS-anslutningar",
"loadFailed": "Det gick inte att läsa in PBS-status.", "loadFailed": "Det gick inte att läsa in PBS-status.",
+20 -1
View File
@@ -24,11 +24,30 @@ def platform():
@require_auth @require_auth
def servers(): def servers():
try: 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: except pbs.PBSError as exc:
return jsonify(error=str(exc)), 500 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(): def _protected_configuration():
local = auth_manager.load_auth_config() local = auth_manager.load_auth_config()
return local.get("enabled") and not local.get("declined") and local.get("password_hash") return local.get("enabled") and not local.get("declined") and local.get("password_hash")
+41 -3
View File
@@ -107,6 +107,32 @@ def _is_private(host):
return False 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(): def _cluster_nodes():
try: try:
result = subprocess.run(["pvesh", "get", "/cluster/status", "--output-format", "json"], result = subprocess.run(["pvesh", "get", "/cluster/status", "--output-format", "json"],
@@ -135,7 +161,7 @@ def _storage_pbs():
if current is not None: if current is not None:
servers.append(current) servers.append(current)
continue 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: if current is not None and option:
current[option.group(1)] = option.group(2) current[option.group(1)] = option.group(2)
return [row for row in servers if row.get("server")] 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"])}) found.setdefault(host, {"name": row.get("name") or host, "native_url": normalize_url(row["url"])})
except Exception: except Exception:
pass pass
return [{"key": f"pbs:{host}", "name": info["name"], "product": "pbs", "group": "pbs", servers, local = [], None
"host": host, "native_url": info["native_url"]} for host, info in found.items()] 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): def _probe(url):
@@ -188,6 +223,8 @@ def _resolve(entry, override):
return override["url"], "online" if _probe(override["url"]) else "offline" return override["url"], "online" if _probe(override["url"]) else "offline"
if entry.get("url"): if entry.get("url"):
return entry["url"], "online" if _probe(entry["url"]) else "offline" return entry["url"], "online" if _probe(entry["url"]) else "offline"
if entry.get("local"):
return None, "local"
host = entry["host"] host = entry["host"]
if not _is_private(host): if not _is_private(host):
return None, "unknown" return None, "unknown"
@@ -218,6 +255,7 @@ def list_links(include_hidden=False):
links.append({"key": entry["key"], "name": entry["name"], "product": entry["product"], links.append({"key": entry["key"], "name": entry["name"], "product": entry["product"],
"group": entry["group"], "url": url, "status": state, "group": entry["group"], "url": url, "status": state,
"native_url": entry.get("native_url"), "manual": bool(entry.get("manual")), "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"))}) "hidden": bool(override.get("hidden")), "custom_url": bool(override.get("url"))})
return {"self": {"name": platform.get("hostname") or socket.gethostname(), "product": platform.get("product")}, return {"self": {"name": platform.get("hostname") or socket.gethostname(), "product": platform.get("product")},
"links": links} "links": links}
+23 -2
View File
@@ -11,6 +11,7 @@ from pathlib import Path
import re import re
import secrets import secrets
import shutil import shutil
import socket
import ssl import ssl
import subprocess import subprocess
import tempfile import tempfile
@@ -53,6 +54,26 @@ def is_pbs_host():
return get_platform().get("product") == "pbs" 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(): def _servers_path():
return auth_manager.CONFIG_DIR / "pbs_servers.json" return auth_manager.CONFIG_DIR / "pbs_servers.json"
@@ -248,9 +269,9 @@ def get_status(identifier):
return cached[1] return cached[1]
errors = {} errors = {}
if identifier == "local": if identifier == "local":
if not is_pbs_host(): if not local_available():
raise PBSError("The local host is not a PBS server.") 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() services = _local_services()
operations = {"datastores": _local_datastores, operations = {"datastores": _local_datastores,
"tasks": lambda: _cli("task", "list", "--all", "true", "--limit", "20"), "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.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)) 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): def test_pbs_host_lists_only_manual_entries(self):
links.pbs_manager.get_platform.return_value = {"product": "pbs", "hostname": "pbs-lab"} 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"}) links.add_link({"name": "amd", "url": "https://10.0.0.55:8008", "product": "pve"})
+33
View File
@@ -166,6 +166,39 @@ class PBSManagerTests(unittest.TestCase):
self.assertEqual(result["datastores"][0]["used"], 30) self.assertEqual(result["datastores"][0]["used"], 30)
self.assertEqual(set(result["services"]), set(pbs.PBS_SERVICES)) 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): def test_connection_api_requires_full_admin_and_does_not_return_secret(self):
self.assertEqual(self.client.get("/api/pbs/servers").status_code, 401) self.assertEqual(self.client.get("/api/pbs/servers").status_code, 401)
with mock.patch.object(pbs, "_remote_get", return_value=[]): with mock.patch.object(pbs, "_remote_get", return_value=[]):