|
|
|
@@ -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<MonitorLinks>(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<string>()
|
|
|
|
|
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<string[]>([])
|
|
|
|
|
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 <div onClick={event => event.stopPropagation()}>
|
|
|
|
|
<DropdownMenu>
|
|
|
|
@@ -147,13 +217,19 @@ export function MonitorSwitcher({ nodeName, compact = false }: { nodeName: strin
|
|
|
|
|
<DropdownMenuLabel className="text-xs font-medium text-muted-foreground">{t(`monitorLinks.groups.${group}`)}</DropdownMenuLabel>
|
|
|
|
|
{rows.map(link => {
|
|
|
|
|
const target = destination(link)
|
|
|
|
|
// A PBS without a Monitor opens its own web interface, not another Monitor.
|
|
|
|
|
if (target?.native) return <DropdownMenuItem key={link.key} className="gap-2" onSelect={() => go(link, false)}>
|
|
|
|
|
<span className="h-2 w-2 shrink-0" />
|
|
|
|
|
<ProductIcon product={link.product} className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
|
|
|
<span className="min-w-0 flex-1 truncate">{link.name}</span>
|
|
|
|
|
<span className="shrink-0 text-[11px] text-muted-foreground">{t("monitorLinks.pbsWebInterface")}</span>
|
|
|
|
|
<Globe className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
return <DropdownMenuItem key={link.key} disabled={!target} className="gap-2" onSelect={() => go(link, false)}>
|
|
|
|
|
<span className={`h-2 w-2 shrink-0 rounded-full ${link.status === "online" ? "bg-green-500" : "bg-gray-500"}`} />
|
|
|
|
|
<ProductIcon product={link.product} className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
|
|
|
<span className="min-w-0 flex-1 truncate">{link.name}</span>
|
|
|
|
|
{target?.native
|
|
|
|
|
? <span className="shrink-0 text-[11px] text-muted-foreground">{t("monitorLinks.pbsInterface")}</span>
|
|
|
|
|
: link.status !== "online" && <span className="shrink-0 text-[11px] text-muted-foreground">{t("monitorLinks.noResponse")}</span>}
|
|
|
|
|
{link.status !== "online" && <span className="shrink-0 text-[11px] text-muted-foreground">{t("monitorLinks.noResponse")}</span>}
|
|
|
|
|
{target && <button type="button" aria-label={t("monitorLinks.openNewTab")} title={t("monitorLinks.openNewTab")}
|
|
|
|
|
className="shrink-0 rounded p-0.5 text-muted-foreground hover:text-foreground"
|
|
|
|
|
onClick={event => { event.preventDefault(); event.stopPropagation(); go(link, true) }}>
|
|
|
|
@@ -170,17 +246,29 @@ export function MonitorSwitcher({ nodeName, compact = false }: { nodeName: strin
|
|
|
|
|
</DropdownMenuContent>
|
|
|
|
|
</DropdownMenu>
|
|
|
|
|
{manageOpen && <ManageLinksDialog open={manageOpen} onOpenChange={setManageOpen} />}
|
|
|
|
|
{origin && <Dialog open={offerOrigin} onOpenChange={setOfferOrigin}>
|
|
|
|
|
{origin && <Dialog open={offerOrigin} onOpenChange={open => { if (!open) notNow() }}>
|
|
|
|
|
<DialogContent className="max-w-md" onClick={event => event.stopPropagation()}>
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
<DialogTitle>{t("monitorLinks.cameFromTitle")}</DialogTitle>
|
|
|
|
|
<DialogDescription>{t("monitorLinks.cameFromBody", { name: origin.name })}</DialogDescription>
|
|
|
|
|
<DialogTitle>{t("monitorLinks.knownTitle")}</DialogTitle>
|
|
|
|
|
<DialogDescription>{t("monitorLinks.knownBody", { name: origin.name })}</DialogDescription>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
<p className="break-all rounded-md border border-border bg-muted/40 px-3 py-2 font-mono text-sm">{origin.url}</p>
|
|
|
|
|
<div className="max-h-72 space-y-2 overflow-y-auto">
|
|
|
|
|
{candidates.map(row => <label key={row.url} className="flex cursor-pointer items-start gap-3 rounded-md border border-border bg-card px-3 py-2">
|
|
|
|
|
<Checkbox className="mt-0.5" checked={selected.includes(row.url)} disabled={adding}
|
|
|
|
|
onCheckedChange={checked => setSelected(current => checked === true ? [...current, row.url] : current.filter(url => url !== row.url))} />
|
|
|
|
|
<ProductIcon product={row.product} className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
|
|
|
|
<span className="min-w-0 flex-1">
|
|
|
|
|
<span className="block truncate text-sm font-medium">{row.name}</span>
|
|
|
|
|
<span className="block break-all font-mono text-xs text-muted-foreground">{row.url}</span>
|
|
|
|
|
</span>
|
|
|
|
|
</label>)}
|
|
|
|
|
</div>
|
|
|
|
|
{addError && <p role="alert" className="text-sm text-red-500">{addError}</p>}
|
|
|
|
|
<div className="flex justify-end gap-2">
|
|
|
|
|
<Button variant="outline" disabled={adding} onClick={() => setOfferOrigin(false)}>{t("monitorLinks.notNow")}</Button>
|
|
|
|
|
<Button disabled={adding} onClick={addOrigin}><Plus className="mr-2 h-4 w-4" />{t("monitorLinks.add")}</Button>
|
|
|
|
|
<Button variant="outline" disabled={adding} onClick={notNow}>{t("monitorLinks.notNow")}</Button>
|
|
|
|
|
<Button className="bg-blue-600 text-white hover:bg-blue-700" disabled={adding || selected.length === 0} onClick={addSelected}>
|
|
|
|
|
<Plus className="mr-2 h-4 w-4" />{t("monitorLinks.addSelected")}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>}
|
|
|
|
@@ -209,13 +297,14 @@ function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange
|
|
|
|
|
fetchApi(path, { method, ...(body === undefined ? {} : { body: JSON.stringify(body) }) })
|
|
|
|
|
|
|
|
|
|
return <Dialog open={open} onOpenChange={onOpenChange}>
|
|
|
|
|
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto" onClick={event => event.stopPropagation()}>
|
|
|
|
|
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background"
|
|
|
|
|
onClick={event => event.stopPropagation()}>
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
<DialogTitle>{t("monitorLinks.dialogTitle")}</DialogTitle>
|
|
|
|
|
<DialogDescription>{t("monitorLinks.dialogDescription")}</DialogDescription>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-3 rounded-lg border border-border p-4">
|
|
|
|
|
<div className="space-y-3 rounded-lg border border-border bg-card p-4">
|
|
|
|
|
<p className="text-sm font-medium">{t("monitorLinks.addTitle")}</p>
|
|
|
|
|
<div className="grid gap-3 sm:grid-cols-[1fr_1.5fr_auto]">
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
@@ -238,7 +327,7 @@ function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<Button size="sm" disabled={busy || !form.url.trim()} onClick={() => run(async () => {
|
|
|
|
|
<Button size="sm" className="bg-blue-600 text-white hover:bg-blue-700" disabled={busy || !form.url.trim()} onClick={() => run(async () => {
|
|
|
|
|
await send("/api/monitor-links", "POST", form)
|
|
|
|
|
setForm({ name: "", url: "", product: "pve" })
|
|
|
|
|
})}><Plus className="mr-2 h-4 w-4" />{t("monitorLinks.add")}</Button>
|
|
|
|
@@ -250,7 +339,7 @@ function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange
|
|
|
|
|
{data?.links.length === 0 && <p className="text-sm text-muted-foreground">{t("monitorLinks.empty")}</p>}
|
|
|
|
|
{data?.links.map(link => {
|
|
|
|
|
const address = addresses[link.key] ?? (link.manual || link.custom_url ? link.url || "" : "")
|
|
|
|
|
return <div key={link.key} className={`space-y-2 rounded-lg border border-border p-3 ${link.hidden ? "opacity-60" : ""}`}>
|
|
|
|
|
return <div key={link.key} className={`space-y-2 rounded-lg border border-border bg-card p-3 ${link.hidden ? "opacity-60" : ""}`}>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<ProductIcon product={link.product} className="h-4 w-4 text-muted-foreground" />
|
|
|
|
|
<span className="min-w-0 flex-1 truncate font-medium">{link.name}</span>
|
|
|
|
|