diff --git a/src/api.rs b/src/api.rs index eb2c3845..446ffcba 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1721,9 +1721,8 @@ async fn update(app: &App, short: &str) -> Result<(bool, String), anyhow::Error> Ok((true, theme.version)) } -/// The thumbnail the theme list displays, where the theme provides one. A theme -/// without one returns 404, on which the panel hides the image, so nothing need -/// report whether a preview exists. +/// The thumbnail the theme list displays, where the theme provides one; the list +/// reports which do. pub async fn theme_preview(_: Admin, State(app): State, Path(short): Path) -> Response { match crate::frontend::preview(&app.themes, &short) { // Not cached: reinstalling a theme under the same name also replaces the @@ -1807,7 +1806,14 @@ pub async fn save_theme_config( pub async fn themes(_: Admin, State(app): State) -> Response { match crate::frontend::themes(&app) { - Ok(themes) => Json(json!({"themes": themes})).into_response(), + Ok(mut themes) => { + // Reads each image to answer, as serving it would: a handful of + // themes, each image capped at 8 MiB. + for theme in &mut themes { + theme.preview = crate::frontend::preview(&app.themes, &theme.short).is_some(); + } + Json(json!({"themes": themes})).into_response() + } Err(e) => fail(e), } } diff --git a/src/frontend.rs b/src/frontend.rs index eecf81dc..98a7a9d4 100644 --- a/src/frontend.rs +++ b/src/frontend.rs @@ -55,6 +55,10 @@ pub struct Theme { /// deleted. #[serde(skip_deserializing)] pub builtin: bool, + /// Whether [`preview`] has an image for it. Filled in for the panel's list + /// only, so it lays each card out once rather than when the image arrives. + #[serde(skip_deserializing)] + pub preview: bool, } pub async fn serve(State(app): State, headers: HeaderMap, uri: Uri) -> Response { diff --git a/web-admin/src/App.tsx b/web-admin/src/App.tsx index b8604831..7096e87b 100644 --- a/web-admin/src/App.tsx +++ b/web-admin/src/App.tsx @@ -6,6 +6,7 @@ import { Admin } from "@/components/Admin" import { Login } from "@/components/Login" import { Button } from "@/components/ui/button" import { Skeleton } from "@/components/ui/skeleton" +import { TooltipProvider } from "@/components/ui/tooltip" import { api, provisionRefusal, useNodes } from "@/lib/api" type Me = { authed: boolean; github: boolean; site_name: string; public_page: boolean; site: string } @@ -61,8 +62,18 @@ function useTheme() { ) const dark = saved ? saved === "dark" : system + // Switched with every transition held. Buttons, badges and table rows fade + // their colours over 150 ms while everything else changes at once, leaving + // the page in both palettes for that long. useEffect(() => { + const hold = document.createElement("style") + hold.textContent = "*,*::before,*::after{transition:none!important}" + document.head.append(hold) document.documentElement.classList.toggle("dark", dark) + // Resolves the new colours while transitions are off, so removing the + // hold starts none. + void document.body.offsetWidth + hold.remove() }, [dark]) return [ @@ -128,53 +139,58 @@ export default function App() { } return ( -
-
-
- {/* The site name is the way back to the status page, as in the - theme's own header. */} - - {me.site_name || "Monitor"} - - 后台 - -
- -
- {error &&

{error}

} - {!nodes ? ( - - ) : ( - - )} -
- - -
+ 后台 +
+ {/* The status page is a separate app, so this is a navigation. */} + + + +
+ + +
+ {error &&

{error}

} + {!nodes ? ( + + ) : ( + + )} +
+ + + + ) } diff --git a/web-admin/src/components/Admin.tsx b/web-admin/src/components/Admin.tsx index 5556fc43..1b2e9651 100644 --- a/web-admin/src/components/Admin.tsx +++ b/web-admin/src/components/Admin.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useId, useRef, useState } from "react" +import { memo, useCallback, useEffect, useId, useRef, useState } from "react" import { flushSync } from "react-dom" import { ArrowUpCircle, Bell, CalendarClock, Check, ChevronDown, ChevronRight, Copy, Database, Download, GripVertical, Layers, Palette, Pencil, Plus, Radio, RefreshCw, Search, Send, Server, Settings, Shield, SlidersHorizontal, Trash2, Upload } from "lucide-react" import { toast } from "sonner" @@ -13,7 +13,8 @@ import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Switch } from "@/components/ui/switch" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" -import { api, badIfaceName, behind, changes, configFields, configForm, configOverrides, configSections, configValues, currentIface, fits, GIB, groupsOf, ifaceChoice, ifaceSpec, inGroup, outdatedAgents, provisioningSite, trafficCorrection, upload, type ConfigField, type IfaceChoice, type Node, type PingTask, type Source } from "@/lib/api" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" +import { api, badIfaceName, behind, changes, configFields, configForm, configOverrides, configSections, configValues, currentIface, fits, GIB, groupsOf, ifaceChoice, ifaceSpec, inGroup, outdatedAgents, provisioningSite, shortAddress, trafficCorrection, upload, type ConfigField, type IfaceChoice, type Node, type PingTask, type Source } from "@/lib/api" import { bytes, CYCLES, FOREVER, money, uptime } from "@/lib/format" // Counters the panel can correct after migration or an accounting error. @@ -30,12 +31,19 @@ const TRAFFIC_MODES: Record = { down: "仅下行", } -// Reordering uses the browser's view transitions, so displaced rows slide. -// Browsers without support jump instead. A drag starts a transition on every row -// it crosses, each skipping the last, and a skipped transition rejects `ready`. -function animate(update: () => void) { - if (document.startViewTransition) document.startViewTransition(() => flushSync(update)).ready.catch(() => {}) - else flushSync(update) +// Displaced rows slide from where they were drawn to their new place: each is +// offset back by the distance it moved, then released. Transforms leave layout +// and stacking alone, so hit-testing mid-slide reads layout positions and the +// sticky header stays on top. A slide cut short restarts from where it was drawn. +function slide(rows: HTMLTableSectionElement | null, update: () => void) { + const before = new Map([...(rows?.rows ?? [])].map((row) => [row, row.getBoundingClientRect().top])) + for (const row of before.keys()) row.getAnimations().forEach((a) => a.id === "slide" && a.cancel()) + flushSync(update) + if (matchMedia("(prefers-reduced-motion: reduce)").matches) return + for (const [row, top] of before) { + const dy = top - row.getBoundingClientRect().top + if (dy) row.animate({ transform: [`translateY(${dy}px)`, "none"] }, { id: "slide", duration: 150, easing: "ease-out" }) + } } // Drag-to-reorder for a table whose order the hub stores at `/${path}/order`. @@ -46,10 +54,6 @@ function useDragOrder(items: T[], path: string, reload const [manualOrder, setManualOrder] = useState([]) const [dragging, setDragging] = useState(null) const orderBeforeDrag = useRef([]) - // The order last asked for. A view transition renders it a frame or more - // later, and a repeated key or a quick drag must build on it rather than on - // the order still on screen. - const pending = useRef(null) const body = useRef(null) // One save in flight at a time, so two quick reorders reach the hub in order. const saving = useRef>(Promise.resolve()) @@ -59,7 +63,7 @@ function useDragOrder(items: T[], path: string, reload ...manualOrder.map((id) => byId.get(id)).filter((item): item is T => Boolean(item)), ...items.filter((item) => !orderedIds.has(item.id)), ] - const ids = () => pending.current ?? order.map((item) => item.id) + const ids = () => order.map((item) => item.id) // Once the hub lists this order, its list is followed again, so a reorder made // in another tab appears here instead of being overwritten by the next drag. @@ -67,60 +71,61 @@ function useDragOrder(items: T[], path: string, reload setManualOrder([]) } - // Handled on the document by where the pointer is, not by the row under it: - // while a view transition runs, Chrome hit-tests drag events to the root - // element, so row handlers would miss every row crossed during a 150 ms slide, - // and a release then would count as a drop outside the table. Re-attached on - // every render, since `order` changes as rows are displaced. + // Handled on the document by layout position, not by the row under the + // pointer: a sliding row is drawn away from its place, and the one under the + // pointer mid-slide is not the one it would displace. Re-attached on every + // render, since `order` changes as rows are displaced. + // + // Dragenter is accepted as well as dragover: over a new element the browser + // fires only dragenter until its next update, and a release in between -- as + // when a reorder brings another row under a still pointer -- would otherwise + // count as a drop outside and restore the order. useEffect(() => { const rows = body.current if (dragging === null || !rows) return const over = (e: DragEvent) => { - const table = rows.getBoundingClientRect() + // The header row counts as inside: a drag to the top readily overshoots + // onto it, and a release there would otherwise discard the drag. + const table = rows.parentElement!.getBoundingClientRect() if (e.clientX < table.left || e.clientX > table.right || e.clientY < table.top || e.clientY > table.bottom) return e.preventDefault() if (e.type === "drop") return if (e.dataTransfer) e.dataTransfer.dropEffect = "move" - const at = [...rows.rows].findIndex((row) => { - const r = row.getBoundingClientRect() - return e.clientY >= r.top && e.clientY < r.bottom - }) - // By position rather than by the row there, which may be one a pending - // transition has yet to move. - if (at >= 0) move(dragging, at) + const list = [...rows.rows] + // Offsets count from the rows' container, which does not slide. + const y = e.clientY - list[0].offsetParent!.getBoundingClientRect().top + const from = list.findIndex((row) => row.dataset.id === String(dragging)) + const to = list.findIndex((row) => y >= row.offsetTop && y < row.offsetTop + row.offsetHeight) + if (from < 0 || to < 0 || from === to) return + // Moved only where the pointer would then rest on the dragged row. Rows + // differ in height: a short row moved past a tall one would leave the + // tall one under the pointer, and the two would swap back and forth. + const target = list[to] + const height = list[from].offsetHeight + if (to > from ? y < target.offsetTop + target.offsetHeight - height : y >= target.offsetTop + height) return + move(dragging, to) } - document.addEventListener("dragover", over) - document.addEventListener("drop", over) + const types = ["dragenter", "dragover", "drop"] as const + for (const type of types) document.addEventListener(type, over) return () => { - document.removeEventListener("dragover", over) - document.removeEventListener("drop", over) + for (const type of types) document.removeEventListener(type, over) } }) - // A transition superseded before it ran applies nothing: a later move, or a - // refused save falling back to the hub's order, has replaced it. - function show(next: number[]) { - pending.current = next - animate(() => { - if (pending.current !== next) return - pending.current = null - setManualOrder(next) - }) - } - function move(id: number, to: number) { const next = [...ids()] const from = next.indexOf(id) if (from < 0 || to < 0 || to >= next.length || from === to) return next.splice(to, 0, ...next.splice(from, 1)) - show(next) + slide(body.current, () => setManualOrder(next)) return next } // Dropped outside the table or cancelled with Escape: the order is restored. function cancel() { setDragging(null) - if (orderBeforeDrag.current.length) show(orderBeforeDrag.current) + const before = orderBeforeDrag.current + if (before.length) slide(body.current, () => setManualOrder(before)) } function save(next: number[]) { @@ -132,7 +137,6 @@ function useDragOrder(items: T[], path: string, reload // A refusal falls back to whatever the hub holds, which a save queued // behind it may still change. saving.current = saving.current.then(put).then(reload, (e: Error) => { - pending.current = null setManualOrder([]) reload() toast.error(e.message) @@ -142,9 +146,12 @@ function useDragOrder(items: T[], path: string, reload return { order, row: (id: number) => ({ - style: { viewTransitionName: `${path}-${id}` }, + "data-id": id, "data-dragging": dragging === id || undefined, - className: "transition-opacity data-[dragging]:opacity-40", + // Opaque, with the dragged row beneath the rest: two rows crossing mid-slide + // would otherwise draw their text over each other. No hover tint during a + // drag, since the browser keeps it on whichever row reaches the start point. + className: `relative z-1 bg-card transition-opacity data-[dragging]:z-0 data-[dragging]:opacity-40 ${dragging === null ? "" : "hover:bg-card"}`, }), handle: (id: number) => ({ onDragStart: (e: React.DragEvent) => { @@ -161,6 +168,7 @@ function useDragOrder(items: T[], path: string, reload if (!delta) return e.preventDefault() orderBeforeDrag.current = ids() + body.current = e.currentTarget.closest("tbody") const next = move(id, ids().indexOf(id) + delta) if (next) save(next) }, @@ -184,9 +192,18 @@ function DragHandle({ name, disabled, title = "拖动排序", ...events }: React ) } -function copy(text: string) { +function copy(text: string, done = "已复制") { + // navigator.clipboard exists only in a secure context. Over plain http the + // copy command still works from a click, the clipboard filled from its event. + if (!navigator.clipboard) { + const put = (e: ClipboardEvent) => { e.clipboardData?.setData("text/plain", text); e.preventDefault() } + document.addEventListener("copy", put) + const ok = document.execCommand("copy") + document.removeEventListener("copy", put) + return ok ? toast.success(done) : toast.error("复制失败") + } navigator.clipboard.writeText(text).then( - () => toast.success("已复制"), + () => toast.success(done), () => toast.error("复制失败"), ) } @@ -201,26 +218,46 @@ const SOURCES: Record = { // The address a node is reached by, one per family, each click-to-copy: pasting // one into an ssh command is why they are shown. Where each came from is in the // tooltip, keeping the column to addresses alone. -function Addresses({ node }: { node: Node }) { - const list = node.addresses ?? [] - if (!list.length) return — - return ( -
- {list.map(({ address, source }) => ( - - ))} -
- ) -} +// +// Drawn again only when the addresses change. The table re-renders on every +// push, and redrawing a tooltip per address would raise the panel's script time +// at a hundred nodes from 66 to 128 ms a second. +const Addresses = memo( + function Addresses({ list }: { list: NonNullable }) { + if (!list.length) return — + return ( + // As wide as the longer address, so both tooltips open from one right + // edge and the one for a short IPv4 does not cover the IPv6 below it. +
+ {list.map(({ address, source }) => ( + // Beside the addresses rather than under one, where it would cover + // the other; and gone once the pointer leaves it. + + + + + +
{address}
+
{SOURCES[source]},点击复制
+
+
+ ))} +
+ ) + }, + // Rows arrive as fresh objects on every push, so the list is compared by value. + (a, b) => JSON.stringify(a.list) === JSON.stringify(b.list), +) // Name, address, country and group: what a node is looked up by, in every node list. function searchNodes(nodes: Node[], query: string) { @@ -269,7 +306,15 @@ function NodeSearch({ value, onChange, className = "" }: { value: string; onChan return (
- onChange(e.target.value)} /> + onChange(e.target.value)} + // Inside a dialog's form, Enter would otherwise save the dialog. + onKeyDown={(e) => e.key === "Enter" && e.preventDefault()} + />
) } @@ -295,16 +340,17 @@ function NodePicker({ nodes, chosen, onPick, disabled = false }: {
- - + +
- {/* Three columns keep a few dozen nodes within one scroll. */} + {/* Three columns keep a few dozen nodes within one scroll. A phone gets + one: two cut a name to a few characters, and a tap shows no title. */}
{ if (el && !listHeight) setListHeight(el.offsetHeight) }} // Capped like the height itself, which a min-height would otherwise // override once the viewport shrinks. style={{ minHeight: listHeight ? `min(${listHeight}px, 16rem, 40dvh)` : undefined }} - className="grid max-h-[min(16rem,40dvh)] grid-cols-2 content-start gap-0.5 overflow-y-auto p-1.5 sm:grid-cols-3" + className="grid max-h-[min(16rem,40dvh)] grid-cols-1 content-start gap-0.5 overflow-y-auto p-1.5 min-[480px]:grid-cols-2 sm:grid-cols-3" > {visible.map((n) => (
+ + + + + ) @@ -835,49 +895,51 @@ function BillingForm({ node, onClose, onSaved }: { {node.name} -
-
- - setPrice(e.target.value)} - placeholder="免费" - /> - - - - -
-
- - - - - set("expires_at", e.target.value)} /> - +
{ e.preventDefault(); save() }}> +
+
+ + setPrice(e.target.value)} + placeholder="免费" + /> + + + + +
+
+ + + + + set("expires_at", e.target.value)} /> + +
-
- - - - + + + + + ) @@ -1227,7 +1289,40 @@ function InstallDialog({ node, site, onClose, onRotated }: { ) } +// Width in ems, near enough: a CJK character is one, anything else about half. +const ems = (word: string) => [...word].reduce((n, c) => n + (c > "\u2e7f" ? 1 : 0.55), 0) + +// A node name, broken at its spaces and after the dots of a hostname -- +// registered nodes are named after theirs. A word up to eight ems stays whole, +// a city, a label or a hyphenated one such as GIA-E, where the browser would +// leave its last letter to open the next line. A wider word, as a name typed +// without spaces usually is, breaks between its CJK characters, and a run of +// letters only where nothing else fits: kept whole, it would set the column's +// minimum width and push the table past the screen. A separator such as the · +// in 香港 09 · DMIT, or a dash, is held to the word before it, so no line opens +// with one. +function nameText(name: string) { + return name + .replace(/ (?=[·|/|—–-] )/g, "\u00a0") + .split(/( )/) + .map((word, i) => { + const parts = word.split(".").flatMap((part, j) => (j ? [".", , part] : [part])) + if (ems(word) > 8) return {parts} + return word.includes("-") ? {word} : parts + }) +} + +// Traffic turns a subdued orange at the alert threshold and a subdued red once +// the allowance is used up, so the table agrees with the alerts. With alerts +// off, the threshold's default of 80 % still marks a node running short. +function trafficTone(n: Node, warnAt: number) { + if (n.traffic_limit <= 0) return "" + if (n.month_used >= n.traffic_limit) return "text-over" + return n.month_used * 100 >= n.traffic_limit * warnAt ? "text-near" : "" +} + function Nodes({ nodes, refresh, site, refusal }: { nodes: Node[]; refresh: () => void; site: string; refusal: string }) { + const warnAt = Number(useSettings().s?.notify_traffic) || 80 const [creating, setCreating] = useState(false) const [editing, setEditing] = useState(null) const [billing, setBilling] = useState(null) @@ -1239,8 +1334,17 @@ function Nodes({ nodes, refresh, site, refusal }: { nodes: Node[]; refresh: () = const [query, setQuery] = useState("") const [group, setGroup] = useGroupFilter(nodes) const [grouping, setGrouping] = useState(false) + // A new node lands last, a hundred rows down on a large fleet, so it is + // brought into view once the list carries it. A filter hiding it cancels the + // scroll rather than leaving one to fire when the filter is cleared. + const added = useRef(null) const drag = useDragOrder(nodes, "nodes", refresh) const visible = inGroup(searchNodes(drag.order, query), group) + useEffect(() => { + if (added.current === null || !nodes.some((n) => n.id === added.current)) return + document.querySelector(`tbody tr[data-id="${added.current}"]`)?.scrollIntoView({ block: "center", behavior: "smooth" }) + added.current = null + }) const searching = query.trim() !== "" || group !== "all" const uninstall = refusal ? "" : uninstallCommand(site) @@ -1286,19 +1390,23 @@ function Nodes({ nodes, refresh, site, refusal }: { nodes: Node[]; refresh: () = {/* Percentages, or the address column swallows every spare pixel and pushes status across the table. */} - 名称 - IP + 名称 + IP 状态 流量 - 价格 - 到期 + {/* Below xl the expiry date moves under the price: seven + columns leave a 1024px window no room for names. */} + 价格 / 到期 + 到期 操作 {visible.map((n) => ( - + {/* Wraps: under the cell's default nowrap, one long name would + widen the table until the actions left the screen. */} +
-
-
{n.name}
- {n.group &&
{n.group}
} +
+ {/* Lines are balanced, so none ends on a character or two. */} +
+ {nameText(n.name)} + {/* In the name's flow, a fixed gap after its last word. + Beside the block it would sit at the cell's edge + whenever the name or group wraps, since a wrapped + block spans the whole width. The gap is a figure + space, which does not break: the badge moves down + with the last word rather than open a line alone. */} + {n.country && "\u2007"} + {n.country && ( + + {n.country} + + )} +
+ {n.group &&
{n.group}
}
- {n.country && ( - - {n.country} - - )}
{/* Addresses live only here, never on the public page. */} - + - - {n.online ? "在线" : "离线"} - - {!n.public && 不公开} - {/* Under the badge, not inside it: the column is a tenth of - the table and the three do not share one line. */} - {!n.online && n.last_seen > 0 && Date.now() / 1000 - n.last_seen >= 60 && ( -
- {uptime(Date.now() / 1000 - n.last_seen)} -
- )} + {/* Stacked and centred on one axis. The slot is as wide as + the three-character 不公开, so a lone pill sits where it + would above that one and every row lines up. */} +
+ + {n.online ? "在线" : "离线"} + + {!n.public && 不公开} + {/* Under the badge, not inside it: the column is a tenth of + the table and the three do not share one line. A slot of + the pills' width that the text spills out of evenly, so a + long duration does not widen the slot and move the + pills off the axis the other rows share. */} + {!n.online && n.last_seen > 0 && Date.now() / 1000 - n.last_seen >= 60 && ( +
+ + {uptime(Date.now() / 1000 - n.last_seen)} + +
+ )} +
{/* Counted by the node's own billing rule, as on the public - page. */} - - {bytes(n.month_used)} - - {" / "}{n.traffic_limit > 0 ? bytes(n.traffic_limit) : FOREVER} + page. Two unbreakable halves, so a narrow table moves the + limit to a second line rather than splitting a figure. */} + + + {bytes(n.month_used)} + {" "} + + / {n.traffic_limit > 0 ? bytes(n.traffic_limit) : FOREVER} {n.price > 0 ? money(n.price, n.currency) : "免费"} +
{n.expires_at || FOREVER}
- {n.expires_at || FOREVER} + {n.expires_at || FOREVER} - - +
{ e.preventDefault(); save() }}> +
+ {/* On a phone the name takes the first row and the target shares the + second with the interval, so the tab order matches the screen. */} +
+ + {/* A new monitor starts empty, so the cursor belongs here; + editing an existing one starts with nothing selected. */} + setForm({ ...form, name: e.target.value })} placeholder="Cloudflare" /> + + + setForm({ ...form, target: e.target.value })} placeholder="1.1.1.1:443" /> + + + setEvery(e.target.value)} /> + +
+
+
+

运行节点

+ 已选 {chosenCount} / {nodes.length} +
+ + + setForm({ ...form, auto_join: v })} /> + +
+
+ + + + +
) } function Ping({ nodes }: { nodes: Node[] }) { - const [tasks, setTasks] = useState([]) + // null until loaded, so the empty state does not flash before the list. + const [tasks, setTasks] = useState(null) const [editing, setEditing] = useState | null>(null) const [deleting, setDeleting] = useState(null) const [removing, setRemoving] = useState(false) - const load = () => api<{ tasks: PingTask[] }>("/ping-tasks").then((d) => setTasks(d.tasks)).catch(() => {}) + // A failed first load draws the page empty, keeping 添加监控 in reach. + const load = () => + api<{ tasks: PingTask[] }>("/ping-tasks") + .then((d) => setTasks(d.tasks)) + .catch((e: Error) => { + toast.error(e.message) + setTasks((tasks) => tasks ?? []) + }) // A node added or removed changes assignments on the hub: auto_join adds, a // deletion cascades. useEffect(() => { load() }, [nodes.length]) // Unfiltered, so the handles are never disabled. - const drag = useDragOrder(tasks, "ping-tasks", load) + const drag = useDragOrder(tasks ?? [], "ping-tasks", load) async function remove() { if (!deleting) return @@ -1567,6 +1712,7 @@ function Ping({ nodes }: { nodes: Node[] }) { } } + if (!tasks) return null return (
@@ -1648,6 +1794,8 @@ type Theme = { builtin: boolean // theme.json 里声明的设置表单,原样转过来,由 configFields 挑出能画的字段。 config?: unknown + // 主题包是否带 preview.png,由 hub 告知,卡片的高度一次排定,不因图片晚到而改变。 + preview: boolean } // 主题在 theme.json 里声明的设置。hub 只存与默认值不同的项,其余由主题用自己的默认值补上, @@ -1939,27 +2087,17 @@ function Themes() {
{themes.map((theme) => ( - {/* 主题包里可选的 preview.png,所以后端不用告诉前端有没有这张图: - 没有就是 404,图一直不显示。hidden 挂在 上而不是 上—— - 隐藏的是整个链接,否则卡片里留着一个高度为 0 却照样吃 gap-4 的空 - 链接。必须从 hidden 开始:带边框的 aspect-video 空盒子会在响应回 - 来之前就画出来,闪一下再消失。 - 缩略图被压到卡片那点宽度,比例不是 16:9 的还会被 object-cover + {/* 缩略图被压到卡片那点宽度,比例不是 16:9 的还会被 object-cover 裁掉边,所以图本身要能点开看原尺寸——就地开一个对话框,不跳走。 */} - + {theme.preview && ( + + )}
@@ -1974,7 +2112,7 @@ function Themes() { {theme.selected ? "使用中" : "使用"} {configFields(theme.config).length > 0 && ( - )} @@ -1983,6 +2121,7 @@ function Themes() { size="icon" variant="ghost" title="从 GitHub 更新" + aria-label="从 GitHub 更新" disabled={!!busy} onClick={() => update(theme)} > @@ -1993,7 +2132,7 @@ function Themes() { directory to delete -- it is also the fallback everything else lands on. */} {!theme.builtin && ( - )} @@ -2056,13 +2195,20 @@ function useSettings() { return { s, set: (k: string, v: string) => setS((old) => ({ ...(old ?? {}), [k]: v })), - save: async (patch: Record) => { + // Resolves to whether the hub took the patch; a failure is already toasted. + save: async (patch: Record, done = "已保存") => { try { await api("/settings", { method: "PUT", body: JSON.stringify(patch) }) - toast.success("已保存") - // Only the saved keys and the `*_set` flags are taken from the hub: a - // credential comes back as a flag, so the typed value must not linger, - // while another card's unsaved edits on the same page must survive. + } catch (e) { + toast.error((e as Error).message) + return false + } + toast.success(done) + // Only the saved keys and the `*_set` flags are taken from the hub: a + // credential comes back as a flag, so the typed value must not linger, + // while another card's unsaved edits on the same page must survive. A + // failed read leaves the form as typed; the save itself stands. + try { const fresh = await api("/settings") setS((old) => { const next = { ...old } @@ -2073,11 +2219,13 @@ function useSettings() { } catch (e) { toast.error((e as Error).message) } + return true }, } } -function SettingsTab() { +// `onSaved` refreshes what the header shows, the site name among it. +function SettingsTab({ onSaved }: { onSaved: () => void }) { const { s, set, save } = useSettings() if (!s) return null @@ -2129,7 +2277,7 @@ function SettingsTab() { retention_days: String(s.retention_days || "7"), github_proxy: String(s.github_proxy ?? ""), public_page: s.public_page === "off" ? "off" : "on", - }) + }).then((ok) => ok && onSaved()) } > 保存站点设置 @@ -2334,7 +2482,7 @@ function Notify({ nodes, refresh }: { nodes: Node[]; refresh: () => void }) { 保存 Telegram {s.notify_telegram_token_set && ( - )} @@ -2372,12 +2520,12 @@ function Notify({ nodes, refresh }: { nodes: Node[]; refresh: () => void }) { 保存 Webhook {s.notify_webhook_headers_set && ( - )} {s.notify_webhook_url_set && ( - )} @@ -2427,19 +2575,33 @@ function Notify({ nodes, refresh }: { nodes: Node[]; refresh: () => void }) { // and the password that works when GitHub does not. type Session = { id: string; current: boolean; created_at: number } -function Sessions() { +function useSessions() { const [rows, setRows] = useState(null) - const [busy, setBusy] = useState("") + // A failed load leaves the list empty rather than absent: the security page + // waits for it, and the password card must stay reachable. + const load = useCallback( + () => + api("/sessions") + .then(setRows) + .catch((e: Error) => { + toast.error(e.message) + setRows((rows) => rows ?? []) + }), + [], + ) + useEffect(() => { load() }, [load]) + return { rows, load } +} - const load = () => api("/sessions").then(setRows).catch((e: Error) => toast.error(e.message)) - useEffect(() => { load() }, []) +function Sessions({ rows, reload }: { rows: Session[]; reload: () => void }) { + const [busy, setBusy] = useState("") async function remove(id: string) { setBusy(id) try { await api(`/sessions/${id}`, { method: "DELETE" }) toast.success("已删除会话") - load() + reload() } catch (e) { toast.error((e as Error).message) } finally { @@ -2447,7 +2609,6 @@ function Sessions() { } } - if (!rows) return null return (
@@ -2460,13 +2621,13 @@ function Sessions() { {rows.map((s) => (
- {new Date(s.created_at * 1000).toLocaleString()} + {new Date(s.created_at * 1000).toLocaleString("zh-CN")} {s.current && 当前设备}
{/* 当前会话没有删除按钮:右上角的退出登录做的就是这件事,而在这里删 只会让已经渲染好的面板以为自己还登着。 */} {!s.current && ( - )} @@ -2479,19 +2640,22 @@ function Sessions() { function Security({ site }: { site: string }) { const { s, set, save } = useSettings() + const sessions = useSessions() const [password, setPassword] = useState("") - if (!s) return null + // Drawn once both have arrived, so the list does not land late above the + // cards and push them down. + if (!s || !sessions.rows) return null const callback = `${site}/api/auth/github/callback` return (
- +

GitHub 单点登录

- OAuth App 回调地址 {callback} + OAuth App 回调地址 {callback}

@@ -2499,7 +2663,13 @@ function Security({ site }: { site: string }) { set("github_client_id", e.target.value)} /> - set("github_client_secret", e.target.value)} /> + set("github_client_secret", e.target.value)} + />
{String(s.github_client_id ?? "") !== "" && String(s.github_allowed_users ?? "").trim() === "" && ( @@ -2543,7 +2713,8 @@ function Security({ site }: { site: string }) { @@ -2652,8 +2823,7 @@ function Data() {

回收空间

- 按保留天数清掉过期明细,再重建数据库文件把空出来的页还给磁盘(SQLite 的 VACUUM)。 - 重建期间需要与数据库等量的空闲磁盘,过程中面板和上报会短暂变慢。 + 按保留天数清掉过期明细,再重建数据库文件把空出来的页还给磁盘(SQLite 的 VACUUM)。重建期间需要与数据库等量的空闲磁盘,过程中面板和上报会短暂变慢。

@@ -2667,8 +2837,7 @@ function Data() {

备份

- 导出的是整个数据库,含节点凭证与登录密码哈希,请当作密钥保管。恢复会用备份文件整体覆盖当前数据, - 当前节点、设置、历史全部作废,所有设备需要重新登录。 + 导出的是整个数据库,含节点凭证与登录密码哈希,请当作密钥保管。恢复会用备份文件整体覆盖当前数据,当前节点、设置、历史全部作废,所有设备需要重新登录。
请用这里导出的文件恢复:直接复制 monitor.db 会丢掉预写日志里还没落盘的那部分。

@@ -2701,7 +2870,7 @@ function Data() { setConfirm(null)} onConfirm={vacuum} @@ -2913,6 +3082,7 @@ export function Admin({ refresh, site, refusal, + reloadMe, }: { path: string go: (to: string) => void @@ -2920,6 +3090,7 @@ export function Admin({ refresh: () => void site: string refusal: string + reloadMe: () => void }) { const { versions, reload } = useVersions() // One dot for both; the page separates them. Absent when switched off on that @@ -2963,7 +3134,7 @@ export function Admin({ ) : path === "/admin/security" ? ( ) : path === "/admin/settings" ? ( - + ) : path === "/admin/update" ? ( ) : ( diff --git a/web-admin/src/components/ui/tooltip.tsx b/web-admin/src/components/ui/tooltip.tsx new file mode 100644 index 00000000..eb64010f --- /dev/null +++ b/web-admin/src/components/ui/tooltip.tsx @@ -0,0 +1,54 @@ +import * as React from "react" +import { cn } from "@/lib/utils" +import { Tooltip as TooltipPrimitive } from "radix-ui" + +function TooltipProvider({ + delayDuration = 0, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function Tooltip({ + ...props +}: React.ComponentProps) { + return +} + +function TooltipTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function TooltipContent({ + className, + sideOffset = 0, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + ) +} + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } diff --git a/web-admin/src/index.css b/web-admin/src/index.css index fe59aa06..4a0c0440 100644 --- a/web-admin/src/index.css +++ b/web-admin/src/index.css @@ -39,6 +39,8 @@ --chart-5: oklch(0.769 0.188 70.08); --ok: oklch(0.63 0.17 149); --warn: oklch(0.75 0.17 70); + --near: oklch(0.575 0.12 55); + --over: oklch(0.5 0.14 27); } .dark { @@ -67,6 +69,8 @@ --chart-5: oklch(0.645 0.246 16.439); --ok: oklch(0.7 0.16 150); --warn: oklch(0.8 0.16 75); + --near: oklch(0.8 0.1 65); + --over: oklch(0.7 0.11 25); } @theme inline { @@ -99,6 +103,8 @@ --color-chart-5: var(--chart-5); --color-ok: var(--ok); --color-warn: var(--warn); + --color-near: var(--near); + --color-over: var(--over); } @layer base { @@ -125,19 +131,3 @@ /* Tabular figures keep live numbers from shifting as they update. */ .tnum { font-variant-numeric: tabular-nums; } - -/* Reordering animates the rows themselves; the rest of the page swaps. */ -::view-transition-old(root), -::view-transition-new(root) { - animation: none; -} - -::view-transition-group(*) { - animation-duration: 150ms; -} - -@media (prefers-reduced-motion: reduce) { - ::view-transition-group(*) { - animation: none; - } -} diff --git a/web-admin/src/lib/api.test.ts b/web-admin/src/lib/api.test.ts index 7befd6b7..ec299665 100644 --- a/web-admin/src/lib/api.test.ts +++ b/web-admin/src/lib/api.test.ts @@ -1,6 +1,6 @@ /// import assert from "node:assert/strict" -import { badIfaceName, behind, changes, configFields, configForm, configOverrides, configSections, configValues, currentIface, fits, GIB, groupsOf, ifaceChoice, ifaceSpec, inGroup, loopbackOrigin, outdatedAgents, provisioningSite, provisionRefusal, trafficCorrection } from "./api.ts" +import { badIfaceName, behind, changes, configFields, configForm, configOverrides, configSections, configValues, currentIface, fits, GIB, groupsOf, ifaceChoice, ifaceSpec, inGroup, loopbackOrigin, outdatedAgents, provisioningSite, provisionRefusal, shortAddress, trafficCorrection } from "./api.ts" assert.deepEqual(changes({ public: true, price: 5 }, { price: 20 }), { price: 20 }) assert.deepEqual(changes({ total_rx: "100", month_tx: "2" }, { total_rx: "100", month_tx: "3" }), { month_tx: "3" }) @@ -52,6 +52,13 @@ assert.deepEqual(trafficCorrection(shown, { ...shown, total_rx: " " }), {}) assert.deepEqual(trafficCorrection(shown, { ...shown, total_rx: "0" }), { total_rx: 0 }) assert.deepEqual(trafficCorrection(shown, { ...shown, total_tx: "3" }), { total_tx: 3 * GIB }) assert.deepEqual(trafficCorrection(shown, shown), {}) +// A long IPv6 keeps two groups each side; short ones and IPv4 are left whole, +// and a :: in what is kept survives. +assert.equal(shortAddress("2605:52c0:1:ca9:be24:11ff:fe84:2c48"), "2605:52c0…fe84:2c48") +assert.equal(shortAddress("2604:a880:800:10::7a1:e001"), "2604:a880…7a1:e001") +assert.equal(shortAddress("2a0e:1d80:ffff:ffff::1234"), "2a0e:1d80…ffff::1234") +assert.equal(shortAddress("2001:db8:1054:84::a4"), "2001:db8:1054:84::a4") +assert.equal(shortAddress("203.0.113.84"), "203.0.113.84") console.log("partial edits, traffic corrections and provisioning checks passed") // --iface from the two lists the install dialogs show, and back. diff --git a/web-admin/src/lib/api.ts b/web-admin/src/lib/api.ts index 9142f49a..669f3541 100644 --- a/web-admin/src/lib/api.ts +++ b/web-admin/src/lib/api.ts @@ -237,6 +237,20 @@ export function trafficCorrection( ) } +/** + * An address as the node table shows it. An IPv6 longer than 22 characters -- + * a full SLAAC address runs to 39 -- keeps its first two groups and its last + * two, which name the provider and tell machines on one prefix apart, and + * elides the middle. The groups are cut from the text as written, so a `::` + * inside what is kept stays intact rather than reading as a lone colon. + */ +export function shortAddress(address: string): string { + const groups = [...address.matchAll(/[^:]+/g)] + if (address.length <= 22 || groups.length < 5) return address + const head = groups[1], tail = groups[groups.length - 2] + return `${address.slice(0, head.index + head[0].length)}…${address.slice(tail.index)}` +} + /** Where a shown address comes from, which the panel gives as its tooltip. */ export type Source = "manual" | "interface" | "exit" | "connection" @@ -465,14 +479,18 @@ export function useNodes() { let retry: ReturnType | null = null let closed = false + // A refresh replaces this effect, and an answer to the one it replaced may + // still arrive after the newer one; it is dropped rather than shown. const fetchOnce = () => api<{ nodes: Node[]; admin: boolean }>("/nodes") .then((d) => { + if (closed) return setNodes(d.nodes) setAdmin(d.admin) setError(null) }) .catch((e: Error) => { + if (closed) return setError(e.message) // With the public page switched off, a revoked session receives a 401 // here and on the stream, so the frame that would report admin=false