"use client" import { useEffect, useState } from "react" import useSWR, { mutate as refreshCache } from "swr" import { ArrowLeft, ChevronDown, DatabaseBackup, ExternalLink, 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 { 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 { 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" native_url?: string | null manual: 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" interface Origin { url: string; name: string; product: "pve" | "pbs" } // 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" for (const key of ["pmx_from", "pmx_name", "pmx_product"]) 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 } 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.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 [adding, setAdding] = useState(false) const [addError, setAddError] = useState("") useEffect(() => { setOrigin(takeOrigin()) }, []) const originKnown = !!origin && !!data?.links.some(link => link.url === origin.url) useEffect(() => { // Offered once, right after arriving, when this Monitor has no way back yet. if (origin?.fresh && data && !originKnown) setOfferOrigin(true) }, [origin, data, originKnown]) // The address carries where the jump comes from, so the other Monitor can offer the way back. const withOrigin = (href: string) => { const params = new URLSearchParams({ pmx_from: window.location.origin, pmx_name: nodeName, pmx_product: data?.self.product === "pbs" ? "pbs" : "pve" }) 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 addOrigin = async () => { if (!origin) return setAdding(true); setAddError("") try { await fetchApi("/api/monitor-links", { method: "POST", body: JSON.stringify({ name: origin.name, url: origin.url, product: origin.product }) }) await refreshCache("/api/monitor-links") setOfferOrigin(false) } catch (err) { setAddError(err instanceof Error ? err.message : t("monitorLinks.saveFailed")) } finally { setAdding(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) return go(link, false)}> {link.name} {target?.native ? {t("monitorLinks.pbsInterface")} : link.status !== "online" && {t("monitorLinks.noResponse")}} {target && } })}
})} setManageOpen(true)}> {t("monitorLinks.manage")}
{manageOpen && } {origin && event.stopPropagation()}> {t("monitorLinks.cameFromTitle")} {t("monitorLinks.cameFromBody", { name: origin.name })}

{origin.url}

{addError &&

{addError}

}
}
} function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) { const t = useT() const { data, mutate } = useSWR("/api/monitor-links?all=1", fetcher, { revalidateOnFocus: false }) const [form, setForm] = useState({ name: "", url: "", product: "pve" }) const [addresses, setAddresses] = useState>({}) 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(err instanceof Error ? err.message : t("monitorLinks.saveFailed")) } 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}

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

{t("monitorLinks.empty")}

} {data?.links.map(link => { const address = addresses[link.key] ?? (link.manual || link.custom_url ? link.url || "" : "") return
{link.name} {link.manual ? t("monitorLinks.added") : t("monitorLinks.detected")} {!link.manual && } {link.manual && }
setAddresses({ ...addresses, [link.key]: event.target.value })} />
})}
}