From 62ddd7599ba3e4722201298b32884971e7de223e Mon Sep 17 00:00:00 2001 From: MacRimi Date: Sun, 18 Oct 2026 18:43:26 +0200 Subject: [PATCH] Carry the linked nodes across Monitor jumps and clarify PBS web links - A jump between Monitors carries the Monitors the source links, and the destination offers them in one dialog with a checkbox each - The offer is not repeated in the same browser session after choosing Not now, and disappears once every node is known - A Proxmox Backup Server without a Monitor is shown as its web interface, with its own icon - The linked nodes form follows the edit-mode contrast rule, with a blue Add button - New texts translated into all languages --- AppImage/components/monitor-switcher.tsx | 139 +++++++++++++++++++---- 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 +- 9 files changed, 154 insertions(+), 33 deletions(-) diff --git a/AppImage/components/monitor-switcher.tsx b/AppImage/components/monitor-switcher.tsx index 0cd2d34b..0e2f6501 100644 --- a/AppImage/components/monitor-switcher.tsx +++ b/AppImage/components/monitor-switcher.tsx @@ -2,11 +2,12 @@ import { useEffect, useState } from "react" import useSWR, { mutate as refreshCache } from "swr" -import { ArrowLeft, ChevronDown, DatabaseBackup, ExternalLink, Plus, Server, Trash2 } from "lucide-react" +import { ArrowLeft, ChevronDown, DatabaseBackup, ExternalLink, Globe, Plus, Server, Trash2 } from "lucide-react" import { Button } from "./ui/button" import { Input } from "./ui/input" import { Label } from "./ui/label" import { Switch } from "./ui/switch" +import { Checkbox } from "./ui/checkbox" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog" import { @@ -32,8 +33,43 @@ interface MonitorLinks { self: { name: string; product: string }; links: Monitor const GROUPS = ["cluster", "standalone", "pbs"] as const const fetcher = (url: string) => fetchApi(url) const ORIGIN_KEY = "proxmenux-came-from" +const DECLINED_KEY = "proxmenux-links-declined" +const MAX_CARRIED = 16 -interface Origin { url: string; name: string; product: "pve" | "pbs" } +interface Carried { url: string; name: string; product: "pve" | "pbs" } +interface Origin extends Carried { links?: Carried[] } + +// A Monitor address is accepted only as a bare http(s) origin. +function bareOrigin(value: unknown): string | null { + if (typeof value !== "string") return null + try { + const parsed = new URL(value) + return /^https?:$/.test(parsed.protocol) && parsed.origin === value ? parsed.origin : null + } catch { return null } +} + +// 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 [] + try { + const rows = JSON.parse(decodeURIComponent(escape(atob(raw.replace(/-/g, "+").replace(/_/g, "/"))))) + if (!Array.isArray(rows)) return [] + const seen = new Set() + 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 + seen.add(url) + out.push({ url, name: String(row?.n || new URL(url).hostname).slice(0, 64), product: row?.p === "pbs" ? "pbs" : "pve" }) + } + return out + } catch { return [] } +} + +function writeCarried(rows: Carried[]): string { + const json = JSON.stringify(rows.slice(0, MAX_CARRIED).map(row => ({ n: row.name, u: row.url, p: row.product }))) + return btoa(unescape(encodeURIComponent(json))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") +} // The Monitor a jump came from, as the browser reached it. Only a bare http(s) // origin is accepted, and the parameters leave the address at once. @@ -44,13 +80,15 @@ function takeOrigin(): (Origin & { fresh?: boolean }) | null { if (from !== null) { const name = params.get("pmx_name") || "" const product = params.get("pmx_product") === "pbs" ? "pbs" : "pve" - for (const key of ["pmx_from", "pmx_name", "pmx_product"]) params.delete(key) + const links = readCarried(params.get("pmx_links")) + for (const key of ["pmx_from", "pmx_name", "pmx_product", "pmx_links"]) params.delete(key) const query = params.toString() window.history.replaceState(null, "", window.location.pathname + (query ? `?${query}` : "") + window.location.hash) try { const parsed = new URL(from) if (/^https?:$/.test(parsed.protocol) && parsed.origin === from && parsed.origin !== window.location.origin) { - const origin: Origin = { url: parsed.origin, name: (name || parsed.hostname).slice(0, 64), product } + const origin: Origin = { url: parsed.origin, name: (name || parsed.hostname).slice(0, 64), product, + links: links.filter(link => link.url !== parsed.origin) } try { sessionStorage.setItem(ORIGIN_KEY, JSON.stringify(origin)) } catch { /* private mode */ } return { ...origin, fresh: true } } @@ -80,18 +118,39 @@ export function MonitorSwitcher({ nodeName, compact = false }: { nodeName: strin const [origin, setOrigin] = useState<(Origin & { fresh?: boolean }) | null>(null) const [offerOrigin, setOfferOrigin] = useState(false) + const [selected, setSelected] = useState([]) + const [offered, setOffered] = useState(false) const [adding, setAdding] = useState(false) const [addError, setAddError] = useState("") useEffect(() => { setOrigin(takeOrigin()) }, []) - const originKnown = !!origin && !!data?.links.some(link => link.url === origin.url) + const known = (url: string) => !!data?.links.some(link => link.url === url) + 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)) + : [] + const declined = (url: string) => { + try { return (JSON.parse(sessionStorage.getItem(DECLINED_KEY) || "[]") as string[]).includes(url) } catch { return false } + } useEffect(() => { - // Offered once, right after arriving, when this Monitor has no way back yet. - if (origin?.fresh && data && !originKnown) setOfferOrigin(true) - }, [origin, data, originKnown]) + // Offered right after arriving, until every node the source links is known here. + if (!offered && origin?.fresh && data && candidates.length > 0 && !declined(origin.url)) { + setOffered(true) + setSelected(candidates.map(row => row.url)) + setOfferOrigin(true) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [origin, data]) - // The address carries where the jump comes from, so the other Monitor can offer the way back. + // The address carries where the jump comes from and the Monitors this one links, + // so the other Monitor can offer the way back and the rest of the nodes. 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) + .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)) return `${href}/?${params.toString()}` } const open = (href: string, newTab: boolean) => { @@ -104,17 +163,28 @@ export function MonitorSwitcher({ nodeName, compact = false }: { nodeName: strin if (target.native) open(target.href, true) else open(withOrigin(target.href), newTab) } - const addOrigin = async () => { - if (!origin) return + const addSelected = async () => { setAdding(true); setAddError("") try { - await fetchApi("/api/monitor-links", { method: "POST", body: JSON.stringify({ name: origin.name, url: origin.url, product: origin.product }) }) + for (const row of candidates.filter(row => selected.includes(row.url))) { + await fetchApi("/api/monitor-links", { method: "POST", body: JSON.stringify({ name: row.name, url: row.url, product: row.product }) }) + } await refreshCache("/api/monitor-links") setOfferOrigin(false) } catch (err) { + await refreshCache("/api/monitor-links") setAddError(err instanceof Error ? err.message : t("monitorLinks.saveFailed")) } finally { setAdding(false) } } + const notNow = () => { + if (origin) { + try { + const list = JSON.parse(sessionStorage.getItem(DECLINED_KEY) || "[]") as string[] + sessionStorage.setItem(DECLINED_KEY, JSON.stringify([...list.filter(url => url !== origin.url), origin.url])) + } catch { /* private mode */ } + } + setOfferOrigin(false) + } return
event.stopPropagation()}> @@ -147,13 +217,19 @@ export function MonitorSwitcher({ nodeName, compact = false }: { nodeName: strin {t(`monitorLinks.groups.${group}`)} {rows.map(link => { const target = destination(link) + // A PBS without a Monitor opens its own web interface, not another Monitor. + if (target?.native) return go(link, false)}> + + + {link.name} + {t("monitorLinks.pbsWebInterface")} + + return go(link, false)}> {link.name} - {target?.native - ? {t("monitorLinks.pbsInterface")} - : link.status !== "online" && {t("monitorLinks.noResponse")}} + {link.status !== "online" && {t("monitorLinks.noResponse")}} {target && - + +
} @@ -209,13 +297,14 @@ function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange fetchApi(path, { method, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }) return - event.stopPropagation()}> + event.stopPropagation()}> {t("monitorLinks.dialogTitle")} {t("monitorLinks.dialogDescription")} -
+

{t("monitorLinks.addTitle")}

@@ -238,7 +327,7 @@ function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange
- @@ -250,7 +339,7 @@ function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange {data?.links.length === 0 &&

{t("monitorLinks.empty")}

} {data?.links.map(link => { const address = addresses[link.key] ?? (link.manual || link.custom_url ? link.url || "" : "") - return
+ return
{link.name} diff --git a/AppImage/messages/de/common.json b/AppImage/messages/de/common.json index 78a78171..7e4012c9 100644 --- a/AppImage/messages/de/common.json +++ b/AppImage/messages/de/common.json @@ -7375,6 +7375,10 @@ "backTo": "Zurück zu {name}", "cameFromTitle": "Monitorknoten hinzufügen?", "cameFromBody": "{name} zu den verknüpften Knoten dieses Monitors hinzufügen.", - "notNow": "Nicht jetzt" + "notNow": "Nicht jetzt", + "knownTitle": "Verknüpfte Knoten hinzufügen?", + "knownBody": "{name} verknüpft diese Monitore. Wählen Sie aus, welche Sie diesem Monitor hinzufügen möchten.", + "addSelected": "Auswahl hinzufügen", + "pbsWebInterface": "PBS-Weboberfläche" } } diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index 884db22e..522e6aa9 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -6583,6 +6583,10 @@ "backTo": "Back to {name}", "cameFromTitle": "Add Monitor node?", "cameFromBody": "Add {name} to the linked nodes of this Monitor.", - "notNow": "Not now" + "notNow": "Not now", + "knownTitle": "Add linked nodes?", + "knownBody": "{name} links these Monitors. Choose the ones to add to this Monitor.", + "addSelected": "Add selected", + "pbsWebInterface": "PBS web interface" } } diff --git a/AppImage/messages/es/common.json b/AppImage/messages/es/common.json index 23ae6dbf..72ae2d03 100644 --- a/AppImage/messages/es/common.json +++ b/AppImage/messages/es/common.json @@ -7375,6 +7375,10 @@ "backTo": "Volver a {name}", "cameFromTitle": "¿Añadir nodo Monitor?", "cameFromBody": "Añade {name} a los nodos enlazados de este Monitor.", - "notNow": "Ahora no" + "notNow": "Ahora no", + "knownTitle": "¿Añadir nodos enlazados?", + "knownBody": "{name} enlaza estos Monitores. Elige los que quieres añadir a este Monitor.", + "addSelected": "Añadir seleccionados", + "pbsWebInterface": "Interfaz web de PBS" } } diff --git a/AppImage/messages/fr/common.json b/AppImage/messages/fr/common.json index 7b08954f..774e0806 100644 --- a/AppImage/messages/fr/common.json +++ b/AppImage/messages/fr/common.json @@ -7375,6 +7375,10 @@ "backTo": "Retour à {name}", "cameFromTitle": "Ajouter un nœud Moniteur ?", "cameFromBody": "Ajoutez {name} aux nœuds liés de ce moniteur.", - "notNow": "Pas maintenant" + "notNow": "Pas maintenant", + "knownTitle": "Ajouter des nœuds liés ?", + "knownBody": "{name} relie ces moniteurs. Choisissez ceux à ajouter à ce moniteur.", + "addSelected": "Ajouter la sélection", + "pbsWebInterface": "Interface web de PBS" } } diff --git a/AppImage/messages/it/common.json b/AppImage/messages/it/common.json index b0c07368..aa45651e 100644 --- a/AppImage/messages/it/common.json +++ b/AppImage/messages/it/common.json @@ -7375,6 +7375,10 @@ "backTo": "torna a {name}", "cameFromTitle": "aggiungi il nodo Monitor?", "cameFromBody": "aggiungi {name} ai nodi collegati di questo monitor.", - "notNow": "Non ora" + "notNow": "Non ora", + "knownTitle": "Aggiungere nodi collegati?", + "knownBody": "{name} collega questi monitor. Scegli quelli da aggiungere a questo monitor.", + "addSelected": "Aggiungi selezionati", + "pbsWebInterface": "Interfaccia web di PBS" } } diff --git a/AppImage/messages/pt/common.json b/AppImage/messages/pt/common.json index 668600bd..78a21e14 100644 --- a/AppImage/messages/pt/common.json +++ b/AppImage/messages/pt/common.json @@ -7375,6 +7375,10 @@ "backTo": "Voltar para {name}", "cameFromTitle": "Adicionar nó Monitor?", "cameFromBody": "Adicione {name} aos nós vinculados deste Monitor.", - "notNow": "agora não" + "notNow": "agora não", + "knownTitle": "Adicionar nós vinculados?", + "knownBody": "{name} liga estes Monitores. Escolha os que quer adicionar a este Monitor.", + "addSelected": "Adicionar selecionados", + "pbsWebInterface": "Interface web do PBS" } } diff --git a/AppImage/messages/sk/common.json b/AppImage/messages/sk/common.json index f699d7dd..6d619e64 100644 --- a/AppImage/messages/sk/common.json +++ b/AppImage/messages/sk/common.json @@ -7374,6 +7374,10 @@ "backTo": "Späť na {name}", "cameFromTitle": "Pridať uzol monitora?", "cameFromBody": "Pridajte {name} do prepojených uzlov tohto monitora.", - "notNow": "Teraz nie" + "notNow": "Teraz nie", + "knownTitle": "Pridať prepojené uzly?", + "knownBody": "{name} prepája tieto monitory. Vyberte tie, ktoré chcete pridať do tohto monitora.", + "addSelected": "Pridať vybrané", + "pbsWebInterface": "Webové rozhranie PBS" } } diff --git a/AppImage/messages/sv/common.json b/AppImage/messages/sv/common.json index 962de218..8a5434e8 100644 --- a/AppImage/messages/sv/common.json +++ b/AppImage/messages/sv/common.json @@ -7375,6 +7375,10 @@ "backTo": "Tillbaka till {name}", "cameFromTitle": "Lägg till monitornod?", "cameFromBody": "Lägg till {name} till de länkade noderna på denna monitor.", - "notNow": "Inte nu" + "notNow": "Inte nu", + "knownTitle": "Lägga till länkade noder?", + "knownBody": "{name} länkar dessa monitorer. Välj vilka du vill lägga till på denna monitor.", + "addSelected": "Lägg till valda", + "pbsWebInterface": "PBS webbgränssnitt" } }