"use client" import { useEffect, useState } from "react" import useSWR, { mutate as refreshCache } from "swr" import { ArrowLeft, ChevronDown, DatabaseBackup, ExternalLink, Globe, Pencil, 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 { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "./ui/dropdown-menu" import { fetchApi } from "../lib/api-config" import { monitorLinkError } from "../lib/monitor-link-errors" import { useT } from "../lib/i18n/provider" interface MonitorLink { key: string name: string product: "pve" | "pbs" group: "cluster" | "standalone" | "pbs" url: string | null 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 } interface MonitorLinks { self: { name: string; product: string }; links: MonitorLink[] } 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 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 } } // 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 [] 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 || 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" }) } 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. function takeOrigin(): (Origin & { fresh?: boolean }) | null { if (typeof window === "undefined") return null const params = new URLSearchParams(window.location.search) const from = params.get("pmx_from") if (from !== null) { const name = params.get("pmx_name") || "" const product = params.get("pmx_product") === "pbs" ? "pbs" : "pve" 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, links: links.filter(link => link.url !== parsed.origin) } try { sessionStorage.setItem(ORIGIN_KEY, JSON.stringify(origin)) } catch { /* private mode */ } return { ...origin, fresh: true } } } catch { /* not an address */ } } try { const stored = JSON.parse(sessionStorage.getItem(ORIGIN_KEY) || "null") return stored && typeof stored.url === "string" && /^https?:\/\//.test(stored.url) ? stored : null } catch { return 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 } function ProductIcon({ product, className }: { product: string; className?: string }) { return product === "pbs" ? : } export function MonitorSwitcher({ nodeName, compact = false }: { nodeName: string; compact?: boolean }) { const t = useT() const [manageOpen, setManageOpen] = useState(false) // The backend keeps the list ready, so the menu opens already filled. const { data, error, isLoading } = useSWR("/api/monitor-links", fetcher, { revalidateOnFocus: false, refreshInterval: 300000 }) 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 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) && !isLoopback(row.url)) : [] const declined = (url: string) => { try { return (JSON.parse(sessionStorage.getItem(DECLINED_KEY) || "[]") as string[]).includes(url) } catch { return false } } useEffect(() => { // 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 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 && !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)) return `${href}/?${params.toString()}` } const open = (href: string, newTab: boolean) => { if (newTab) window.open(href, "_blank", "noopener,noreferrer") else window.location.assign(href) } const go = (link: MonitorLink, newTab: boolean) => { const target = destination(link) if (!target) return if (target.native) open(target.href, true) else open(withOrigin(target.href), newTab) } const addSelected = async () => { setAdding(true); setAddError("") try { 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) { setAddError(monitorLinkError(err, t)) try { await refreshCache("/api/monitor-links") } catch { /* SWR shows the load error; keep the localized mutation error. */ } } 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()}> {t("monitorLinks.thisNode")} {nodeName} {origin && !originKnown && open(withOrigin(origin.url), false)}> {t("monitorLinks.backTo", { name: origin.name })} } {isLoading &&

{t("monitorLinks.checking")}

} {error &&

{t("monitorLinks.loadFailed")}

} {data && data.links.length === 0 &&

{t("monitorLinks.empty")}

} {data && GROUPS.map(group => { const rows = data.links.filter(link => link.group === group) if (rows.length === 0) return null return
{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} {link.status !== "online" && {t("monitorLinks.noResponse")}} {target && } })}
})} setManageOpen(true)}> {t("monitorLinks.manage")}
{manageOpen && } {origin && { if (!open) notNow() }}> event.stopPropagation()}> {t("monitorLinks.knownTitle")} {t("monitorLinks.knownBody", { name: origin.name })}
{candidates.map(row => )}
{addError &&

{addError}

}
}
} function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) { const t = useT() const { data, mutate, error: loadError, isLoading } = useSWR("/api/monitor-links?all=1", fetcher, { revalidateOnFocus: false }) const [form, setForm] = useState({ name: "", url: "", product: "pve" }) const [addresses, setAddresses] = useState>({}) const [editing, setEditing] = useState(null) const [busy, setBusy] = useState(false) const [error, setError] = useState("") const run = async (action: () => Promise) => { setBusy(true); setError("") try { await action() await mutate() await refreshCache("/api/monitor-links") } catch (err) { setError(monitorLinkError(err, t)) } finally { setBusy(false) } } const send = (path: string, method: string, body?: unknown) => fetchApi(path, { method, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }) return event.stopPropagation()}> {t("monitorLinks.dialogTitle")} {t("monitorLinks.dialogDescription")}

{t("monitorLinks.addTitle")}

setForm({ ...form, name: event.target.value })} />
setForm({ ...form, url: event.target.value })} />
{error &&

{error}

}
{isLoading &&

{t("monitorLinks.checking")}

} {loadError &&

{t("monitorLinks.loadFailed")}

} {data?.links.length === 0 &&

{t("monitorLinks.empty")}

} {data?.links.map(link => { const own = link.manual || link.custom_url const shown = link.url || "" const draft = addresses[link.key] ?? (own ? shown : "") const isEditing = editing === link.key // A read-only card is a panel; while its address is edited it lets the field stand out. return
{link.name} {link.manual ? t("monitorLinks.added") : t("monitorLinks.detected")} {!link.manual && } {link.manual && }
{link.local && !link.custom_url ?
{t("monitorLinks.pbsWebInterface")} {t("monitorLinks.thisNode")}
: isEditing ?
setAddresses({ ...addresses, [link.key]: event.target.value })} />
:
{shown || "—"} {!link.manual && !link.custom_url && {t("monitorLinks.autoAddress")}}
}
})}
}