From f533a476399ab4b69f532d1920af9c9c8d0ad756 Mon Sep 17 00:00:00 2001 From: stqfdyr <89149493+stqfdyr@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:48:14 +0800 Subject: [PATCH 01/10] =?UTF-8?q?fix:=20=E9=9D=A2=E6=9D=BF=E5=9C=A8?= =?UTF-8?q?=E5=A4=A7=E9=87=8F=E8=8A=82=E7=82=B9=E4=B8=8B=E7=9A=84=E5=B8=83?= =?UTF-8?q?=E5=B1=80=E4=B8=8E=E4=BA=A4=E4=BA=92=E7=BB=86=E8=8A=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 切换深浅色时暂停过渡,按钮、徽章、表格行不再各自渐变 - 节点表名称、分组、状态、流量可换行,1280 以下到期日收在价格下方,1024 宽起操作列不再被挤出屏幕 - 拖动排序只给屏幕内的行做换位动画;新添加的节点滚动到视野内 - 编辑节点、续费、延迟监控、分组对话框回车保存;每月重置日与探测间隔可清空重填 - 延迟页、安全页、主题页数据到齐再渲染,不再闪空状态或被晚到的内容下推 - 改站点名后页头即时更新,改密码后会话列表即时刷新,Client Secret 保存后清空 - 手机上短字段两两并排,选节点列表单列,回调地址可换行 --- web-admin/src/App.tsx | 11 + web-admin/src/components/Admin.tsx | 577 ++++++++++++++++------------- 2 files changed, 337 insertions(+), 251 deletions(-) diff --git a/web-admin/src/App.tsx b/web-admin/src/App.tsx index b8604831..3c4ca424 100644 --- a/web-admin/src/App.tsx +++ b/web-admin/src/App.tsx @@ -61,8 +61,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 [ @@ -170,6 +180,7 @@ export default function App() { // Why this page cannot add nodes, measured by the rule the hub applies // to the `Origin` it receives; empty when it can. refusal={provisionRefusal(location.origin, me.site)} + reloadMe={loadMe} /> )} diff --git a/web-admin/src/components/Admin.tsx b/web-admin/src/components/Admin.tsx index 5556fc43..c22884e0 100644 --- a/web-admin/src/components/Admin.tsx +++ b/web-admin/src/components/Admin.tsx @@ -99,8 +99,16 @@ function useDragOrder(items: T[], path: string, reload // 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. + // + // Only the rows on screen are named for the slide. Every named element is + // captured on each transition: with all of a hundred rows named, a drag + // across twelve held the main thread for 2.7 s, against 0.3 s with none. function show(next: number[]) { pending.current = next + for (const row of body.current?.rows ?? []) { + const { top, bottom } = row.getBoundingClientRect() + row.style.viewTransitionName = bottom > 0 && top < innerHeight ? `${path}-${row.dataset.id}` : "" + } animate(() => { if (pending.current !== next) return pending.current = null @@ -142,7 +150,7 @@ 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", }), @@ -161,6 +169,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) }, @@ -269,7 +278,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 +312,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 +861,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)} /> + +
-
- - - - + + + + + ) @@ -1239,8 +1267,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. + const added = useRef(null) const drag = useDragOrder(nodes, "nodes", refresh) const visible = inGroup(searchNodes(drag.order, query), group) + useEffect(() => { + const row = added.current === null ? null : document.querySelector(`tbody tr[data-id="${added.current}"]`) + if (!row) return + row.scrollIntoView({ block: "center", behavior: "smooth" }) + added.current = null + }) const searching = query.trim() !== "" || group !== "all" const uninstall = refusal ? "" : uninstallCommand(site) @@ -1290,15 +1327,19 @@ function Nodes({ nodes, refresh, site, refusal }: { nodes: Node[]; refresh: () = IP 状态 流量 - 价格 - 到期 + {/* Below xl the expiry date moves under the price: seven + columns leave a 1024px window no room for names. */} + 价格 / 到期 + 到期 操作 {visible.map((n) => ( - + {/* Wraps: the cell's nowrap let one long name widen the table + until the actions left the screen. */} +
-
-
{n.name}
- {n.group &&
{n.group}
} +
+
{n.name}
+ {n.group &&
{n.group}
}
{n.country && ( - + {n.online ? "在线" : "离线"} @@ -1339,17 +1380,19 @@ function Nodes({ nodes, refresh, site, refusal }: { nodes: Node[]; refresh: () = )} {/* 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) @@ -1550,7 +1599,7 @@ function Ping({ nodes }: { nodes: Node[] }) { // 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 +1616,7 @@ function Ping({ nodes }: { nodes: Node[] }) { } } + if (!tasks) return null return (
@@ -1648,8 +1698,21 @@ type Theme = { builtin: boolean // theme.json 里声明的设置表单,原样转过来,由 configFields 挑出能画的字段。 config?: unknown + // 面板自己探测:preview.png 能否加载。 + preview?: boolean } +// 预览图是主题包里可选的 preview.png,hub 不报告有没有,只能加载一次看看。在画卡片 +// 之前探测完:图片晚到会把标题和按钮整体往下推一截。同一文档里再用这个地址时直接 +// 取已加载的图,不会下载第二次。 +const probePreview = (short: string) => + new Promise((resolve) => { + const img = new Image() + img.onload = () => resolve(true) + img.onerror = () => resolve(false) + img.src = `/api/themes/${short}/preview` + }) + // 主题在 theme.json 里声明的设置。hub 只存与默认值不同的项,其余由主题用自己的默认值补上, // 所以主题作者日后改了某个默认值,没动过这一项的站点会跟着变。 // @@ -1830,7 +1893,12 @@ function Themes() { const picker = useRef(null) const load = () => - api<{ themes: Theme[] }>("/themes").then((data) => setThemes(data.themes)).catch(() => setThemes([])) + api<{ themes: Theme[] }>("/themes") + .then(async ({ themes }) => { + const shown = await Promise.all(themes.map((theme) => probePreview(theme.short))) + setThemes(themes.map((theme, i) => ({ ...theme, preview: shown[i] }))) + }) + .catch(() => setThemes([])) useEffect(() => { load() }, []) async function select(short: string) { @@ -1939,27 +2007,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 +2032,7 @@ function Themes() { {theme.selected ? "使用中" : "使用"} {configFields(theme.config).length > 0 && ( - )} @@ -1983,6 +2041,7 @@ function Themes() { size="icon" variant="ghost" title="从 GitHub 更新" + aria-label="从 GitHub 更新" disabled={!!busy} onClick={() => update(theme)} > @@ -1993,7 +2052,7 @@ function Themes() { directory to delete -- it is also the fallback everything else lands on. */} {!theme.builtin && ( - )} @@ -2056,10 +2115,11 @@ 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("已保存") + 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. @@ -2070,14 +2130,17 @@ function useSettings() { for (const [key, value] of Object.entries(fresh)) if (key.endsWith("_set")) next[key] = value return next }) + return true } catch (e) { toast.error((e as Error).message) + return false } }, } } -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 +2192,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 +2397,7 @@ function Notify({ nodes, refresh }: { nodes: Node[]; refresh: () => void }) { 保存 Telegram {s.notify_telegram_token_set && ( - )} @@ -2372,12 +2435,12 @@ function Notify({ nodes, refresh }: { nodes: Node[]; refresh: () => void }) { 保存 Webhook {s.notify_webhook_headers_set && ( - )} {s.notify_webhook_url_set && ( - )} @@ -2427,19 +2490,22 @@ 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("") + const load = useCallback(() => api("/sessions").then(setRows).catch((e: Error) => toast.error(e.message)), []) + 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 +2513,6 @@ function Sessions() { } } - if (!rows) return null return (
@@ -2460,13 +2525,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 +2544,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 +2567,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 +2617,8 @@ function Security({ site }: { site: string }) { @@ -2652,8 +2727,7 @@ function Data() {

回收空间

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

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

备份

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

@@ -2701,7 +2774,7 @@ function Data() { setConfirm(null)} onConfirm={vacuum} @@ -2913,6 +2986,7 @@ export function Admin({ refresh, site, refusal, + reloadMe, }: { path: string go: (to: string) => void @@ -2920,6 +2994,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 +3038,7 @@ export function Admin({ ) : path === "/admin/security" ? ( ) : path === "/admin/settings" ? ( - + ) : path === "/admin/update" ? ( ) : ( From a3ae6c87c46b42c1600808c8f86e135ef8ba64c1 Mon Sep 17 00:00:00 2001 From: stqfdyr <89149493+stqfdyr@users.noreply.github.com> Date: Fri, 25 Sep 2026 14:14:38 +0800 Subject: [PATCH 02/10] =?UTF-8?q?fix:=20=E6=8B=96=E5=8A=A8=E6=8E=92?= =?UTF-8?q?=E5=BA=8F=E4=B8=8D=E5=86=8D=E4=B8=A4=E8=A1=8C=E6=9D=A5=E5=9B=9E?= =?UTF-8?q?=E6=8D=A2=E4=BD=8D=EF=BC=8C=E6=BB=91=E5=8A=A8=E7=9A=84=E8=A1=8C?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E7=9B=96=E4=BD=8F=E9=A1=B6=E6=A0=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 行高不同时按指针落点判断:只有换过去后指针仍落在被拖的行上才换位;上一次换位还在等过渡生效时不处理新的拖动事件 - 顶栏参与视图过渡并抬到所有行之上,页面滚动后压在顶栏下的行不再在换位时盖住它 --- web-admin/src/components/Admin.tsx | 19 +++++++++++++++---- web-admin/src/index.css | 6 ++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/web-admin/src/components/Admin.tsx b/web-admin/src/components/Admin.tsx index c22884e0..f45a88c2 100644 --- a/web-admin/src/components/Admin.tsx +++ b/web-admin/src/components/Admin.tsx @@ -81,13 +81,24 @@ function useDragOrder(items: T[], path: string, reload e.preventDefault() if (e.type === "drop") return if (e.dataTransfer) e.dataTransfer.dropEffect = "move" - const at = [...rows.rows].findIndex((row) => { + // Nothing while a move waits on its transition: the rows on screen are + // not yet the order asked for, and the next dragover follows within + // 50 ms. + if (pending.current) return + const list = [...rows.rows] + const from = list.findIndex((row) => row.dataset.id === String(dragging)) + const to = list.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) + 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].getBoundingClientRect() + const height = list[from].getBoundingClientRect().height + if (to > from ? e.clientY < target.bottom - height : e.clientY >= target.top + height) return + move(dragging, to) } document.addEventListener("dragover", over) document.addEventListener("drop", over) diff --git a/web-admin/src/index.css b/web-admin/src/index.css index fe59aa06..bfc0378f 100644 --- a/web-admin/src/index.css +++ b/web-admin/src/index.css @@ -136,6 +136,12 @@ animation-duration: 150ms; } +/* The sliding rows are drawn above the whole page, the sticky header included, + so a row scrolled beneath it would cover it for each slide. The header takes + part as well and is raised over them. */ +header { view-transition-name: page-header; } +::view-transition-group(page-header) { z-index: 1; } + @media (prefers-reduced-motion: reduce) { ::view-transition-group(*) { animation: none; From 7dddd8383a8ce3cad018857da7fee3652d15cc64 Mon Sep 17 00:00:00 2001 From: stqfdyr <89149493+stqfdyr@users.noreply.github.com> Date: Fri, 25 Sep 2026 14:18:31 +0800 Subject: [PATCH 03/10] =?UTF-8?q?fix:=20=E8=8A=82=E7=82=B9=E8=A1=A8?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E5=88=97=E4=B8=A4=E4=B8=AA=E8=83=B6=E5=9B=8A?= =?UTF-8?q?=E6=8D=A2=E8=A1=8C=E6=97=B6=E5=B7=A6=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用 flex 的 gap 代替左边距:放得下同一行,放不下时两个胶囊上下左对齐并留出间距,不再缩进 4px、上下贴在一起。 --- web-admin/src/components/Admin.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/web-admin/src/components/Admin.tsx b/web-admin/src/components/Admin.tsx index f45a88c2..8a08dbcb 100644 --- a/web-admin/src/components/Admin.tsx +++ b/web-admin/src/components/Admin.tsx @@ -1377,11 +1377,15 @@ function Nodes({ nodes, refresh, site, refusal }: { nodes: Node[]; refresh: () = - - - {n.online ? "在线" : "离线"} - - {!n.public && 不公开} + + {/* Gapped rather than margined, so a narrow column stacks the + two flush left instead of indenting the second. */} +
+ + {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 && ( From 54823db765958bf0eae2d30a3e9737a21b5356bf Mon Sep 17 00:00:00 2001 From: stqfdyr <89149493+stqfdyr@users.noreply.github.com> Date: Fri, 25 Sep 2026 14:23:49 +0800 Subject: [PATCH 04/10] =?UTF-8?q?fix:=20=E8=8A=82=E7=82=B9=E8=A1=A8?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E5=88=97=E7=9A=84=E8=83=B6=E5=9B=8A=E7=AD=89?= =?UTF-8?q?=E5=AE=BD=E4=B8=8A=E4=B8=8B=E6=8E=92=E5=88=97=EF=BC=8C=E7=A6=BB?= =?UTF-8?q?=E7=BA=BF=E6=97=B6=E9=95=BF=E5=B1=85=E4=B8=AD=E5=85=B6=E4=B8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在线/离线与不公开统一按三个字的宽度、文字居中、固定上下排列,两个胶囊左右边缘都对齐,各行之间也一样宽;离线时长比胶囊短时居中在胶囊下方,更长时从左边缘开始。 --- web-admin/src/components/Admin.tsx | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/web-admin/src/components/Admin.tsx b/web-admin/src/components/Admin.tsx index 8a08dbcb..5f244834 100644 --- a/web-admin/src/components/Admin.tsx +++ b/web-admin/src/components/Admin.tsx @@ -1378,21 +1378,23 @@ function Nodes({ nodes, refresh, site, refusal }: { nodes: Node[]; refresh: () =
- {/* Gapped rather than margined, so a narrow column stacks the - two flush left instead of indenting the second. */} -
- + {/* Stacked, and every pill as wide as the three-character + 不公开 with its text centred, so the two line up on both + edges and the column reads the same down every row. */} +
+ {n.online ? "在线" : "离线"} - {!n.public && 不公开} + {!n.public && 不公开} + {/* Under the badge, not inside it: the column is a tenth of + the table and the three do not share one line. Centred + under it while shorter, flush left once longer. */} + {!n.online && n.last_seen > 0 && Date.now() / 1000 - n.last_seen >= 60 && ( +
+ {uptime(Date.now() / 1000 - n.last_seen)} +
+ )}
- {/* 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)} -
- )} {/* Counted by the node's own billing rule, as on the public page. Two unbreakable halves, so a narrow table moves the From b38d8330ee98a4a89de855529fdd9eeeba7d6e8a Mon Sep 17 00:00:00 2001 From: stqfdyr <89149493+stqfdyr@users.noreply.github.com> Date: Fri, 25 Sep 2026 14:50:09 +0800 Subject: [PATCH 05/10] =?UTF-8?q?fix:=20=E8=8A=82=E7=82=B9=E8=A1=A8?= =?UTF-8?q?=E5=9C=B0=E5=9D=80=E4=B8=8E=E7=8A=B6=E6=80=81=E5=88=97=E7=9A=84?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E7=BB=86=E8=8A=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 状态列胶囊保持原宽,放在同一宽度的槽位里居中,上下与各行之间对齐;离线时长居中其下 - 列宽改为名称 26%、IP 18%,同宽度下折行的节点名明显减少 - 超过 19 个字符的 IPv6 显示前两组与后两组,中间省略,不再撑宽表格 - 地址悬停改用面板自己的提示框,300 ms 出现,显示完整地址与来源;复制后的提示带上完整地址 --- web-admin/src/App.tsx | 101 +++++++++++++----------- web-admin/src/components/Admin.tsx | 64 +++++++++------ web-admin/src/components/ui/tooltip.tsx | 54 +++++++++++++ 3 files changed, 148 insertions(+), 71 deletions(-) create mode 100644 web-admin/src/components/ui/tooltip.tsx diff --git a/web-admin/src/App.tsx b/web-admin/src/App.tsx index 3c4ca424..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 } @@ -138,54 +139,58 @@ export default function App() { } return ( -
+ 后台 +
+ {/* 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 5f244834..f4f90968 100644 --- a/web-admin/src/components/Admin.tsx +++ b/web-admin/src/components/Admin.tsx @@ -13,6 +13,7 @@ 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 { 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, trafficCorrection, upload, type ConfigField, type IfaceChoice, type Node, type PingTask, type Source } from "@/lib/api" import { bytes, CYCLES, FOREVER, money, uptime } from "@/lib/format" @@ -204,9 +205,9 @@ function DragHandle({ name, disabled, title = "拖动排序", ...events }: React ) } -function copy(text: string) { +function copy(text: string, done = "已复制") { navigator.clipboard.writeText(text).then( - () => toast.success("已复制"), + () => toast.success(done), () => toast.error("复制失败"), ) } @@ -221,22 +222,40 @@ 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. +// A full-length IPv6 is shown as its first two groups and its last two, which +// name the provider and tell machines on one prefix apart. Cut at group +// boundaries rather than at the column's edge, which would split a group; the +// title and the copy carry the whole address. +function shortAddress(address: string) { + const groups = address.split(":") + return address.length > 19 && groups.length > 4 ? `${groups.slice(0, 2).join(":")}…${groups.slice(-2).join(":")}` : address +} + function Addresses({ node }: { node: Node }) { const list = node.addresses ?? [] if (!list.length) return — return (
{list.map(({ address, source }) => ( - + + + + + +
{address}
+
{SOURCES[source]},点击复制
+
+
))}
) @@ -1334,8 +1353,8 @@ 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 @@ -1378,19 +1397,18 @@ function Nodes({ nodes, refresh, site, refusal }: { nodes: Node[]; refresh: () = - {/* Stacked, and every pill as wide as the three-character - 不公开 with its text centred, so the two line up on both - edges and the column reads the same down every row. */} -
- + {/* 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 && 不公开} + {!n.public && 不公开} {/* Under the badge, not inside it: the column is a tenth of - the table and the three do not share one line. Centred - under it while shorter, flush left once longer. */} + 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)}
)} 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 } From 3585239e18b112b1a768453f3fb8902bd818b8b6 Mon Sep 17 00:00:00 2001 From: stqfdyr <89149493+stqfdyr@users.noreply.github.com> Date: Fri, 25 Sep 2026 14:56:35 +0800 Subject: [PATCH 06/10] =?UTF-8?q?fix:=20=E5=9C=B0=E5=9D=80=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E6=A1=86=E5=BC=B9=E5=9C=A8=E5=8F=B3=E4=BE=A7=EF=BC=8C?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E6=8C=A1=E4=BD=8F=E5=90=8C=E4=B8=80=E6=A0=BC?= =?UTF-8?q?=E7=9A=84=E5=8F=A6=E4=B8=80=E4=B8=AA=E5=9C=B0=E5=9D=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同一格的两个地址按较长的那个等宽,提示框从共同的右边缘弹出;指针离开地址即收起,不因停在提示框上而保持打开。 --- web-admin/src/components/Admin.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/web-admin/src/components/Admin.tsx b/web-admin/src/components/Admin.tsx index f4f90968..8e825291 100644 --- a/web-admin/src/components/Admin.tsx +++ b/web-admin/src/components/Admin.tsx @@ -235,9 +235,13 @@ function Addresses({ node }: { node: Node }) { const list = node.addresses ?? [] 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]},点击复制
From c8773935b886309db163f3565ca9f2bcb51b06f4 Mon Sep 17 00:00:00 2001 From: stqfdyr <89149493+stqfdyr@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:28:24 +0800 Subject: [PATCH 07/10] =?UTF-8?q?fix:=20=E6=8B=96=E5=8A=A8=E6=8E=92?= =?UTF-8?q?=E5=BA=8F=E6=94=B9=E7=94=A8=20FLIP=20=E5=8A=A8=E7=94=BB?= =?UTF-8?q?=EF=BC=8C=E8=8A=82=E7=82=B9=E5=90=8D=E4=B8=8E=E5=9B=BD=E5=AE=B6?= =?UTF-8?q?=E7=A0=81=E7=9A=84=E6=8A=98=E8=A1=8C=E7=BB=86=E8=8A=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 拖动排序不再用视图过渡:行用 transform 从原位置滑到新位置,不再间歇性整表空白, 也不必把顶栏抬进过渡层。行有底色、被拖的行垫在下面,交错时文字不叠在一起; 拖动中不留悬停底色;dragenter 与 dragover 一样接受,行刚被重排到指针下就松手 不再被当成拖到表外;拖过头落在表头上也算放下。偏好减少动画时不滑动。 - 国家码跟在名字最后一个词后面,间距固定,不再随名字折行忽远忽近,也不单独占一行。 - 名字列至少容得下最长的词,BandwagonHost 这类词不再从中间断开; GIA-E 这类短连字符词不拆开。 - 长 IPv6 在表格里保留首尾各两段、中间省略,悬停与复制仍是完整地址。 - 离线时长放进与状态胶囊同宽的槽位里居中。 - 节点列表刷新后,被取代的旧请求晚到时不再覆盖新数据。 --- web-admin/src/components/Admin.tsx | 173 ++++++++++++++++------------- web-admin/src/index.css | 22 ---- web-admin/src/lib/api.test.ts | 9 +- web-admin/src/lib/api.ts | 18 +++ 4 files changed, 120 insertions(+), 102 deletions(-) diff --git a/web-admin/src/components/Admin.tsx b/web-admin/src/components/Admin.tsx index 8e825291..54f0a589 100644 --- a/web-admin/src/components/Admin.tsx +++ b/web-admin/src/components/Admin.tsx @@ -14,7 +14,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Switch } from "@/components/ui/switch" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" 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, trafficCorrection, upload, type ConfigField, type IfaceChoice, type Node, type PingTask, type Source } from "@/lib/api" +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. @@ -31,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`. @@ -47,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()) @@ -60,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. @@ -68,79 +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" - // Nothing while a move waits on its transition: the rows on screen are - // not yet the order asked for, and the next dragover follows within - // 50 ms. - if (pending.current) return 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) => { - const r = row.getBoundingClientRect() - return e.clientY >= r.top && e.clientY < r.bottom - }) + 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].getBoundingClientRect() - const height = list[from].getBoundingClientRect().height - if (to > from ? e.clientY < target.bottom - height : e.clientY >= target.top + height) return + 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. - // - // Only the rows on screen are named for the slide. Every named element is - // captured on each transition: with all of a hundred rows named, a drag - // across twelve held the main thread for 2.7 s, against 0.3 s with none. - function show(next: number[]) { - pending.current = next - for (const row of body.current?.rows ?? []) { - const { top, bottom } = row.getBoundingClientRect() - row.style.viewTransitionName = bottom > 0 && top < innerHeight ? `${path}-${row.dataset.id}` : "" - } - 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[]) { @@ -152,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) @@ -164,7 +148,10 @@ function useDragOrder(items: T[], path: string, reload row: (id: number) => ({ "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) => { @@ -222,15 +209,6 @@ 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. -// A full-length IPv6 is shown as its first two groups and its last two, which -// name the provider and tell machines on one prefix apart. Cut at group -// boundaries rather than at the column's edge, which would split a group; the -// title and the copy carry the whole address. -function shortAddress(address: string) { - const groups = address.split(":") - return address.length > 19 && groups.length > 4 ? `${groups.slice(0, 2).join(":")}…${groups.slice(-2).join(":")}` : address -} - function Addresses({ node }: { node: Node }) { const list = node.addresses ?? [] if (!list.length) return — @@ -1289,6 +1267,25 @@ function InstallDialog({ node, site, onClose, onRotated }: { ) } +// A node name, broken at its spaces and after the dots of a hostname -- +// registered nodes are named after theirs -- so neither a city nor a label is +// split. A short hyphenated word such as GIA-E stays whole, where the browser +// would leave its last letter to open the next line; a long one may still break +// at its hyphens. A separator such as the · in 香港 09 · DMIT 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) => + word.includes("-") && word.length <= 12 ? ( + {word} + ) : ( + word.split(".").flatMap((part, j) => (j ? [".", , part] : [part])) + ), + ) +} + function Nodes({ nodes, refresh, site, refusal }: { nodes: Node[]; refresh: () => void; site: string; refusal: string }) { const [creating, setCreating] = useState(false) const [editing, setEditing] = useState(null) @@ -1382,18 +1379,31 @@ function Nodes({ nodes, refresh, site, refusal }: { nodes: Node[]; refresh: () = title={searching ? "清空搜索和分组筛选后可拖动排序" : undefined} />
-
{n.name}
- {n.group &&
{n.group}
} + {/* Lines are balanced, so neither ends on a character or + two. The column is at least as wide as the longest + word, which breaks only when it exceeds the whole + table. */} +
+ {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. */} @@ -1410,10 +1420,15 @@ function Nodes({ nodes, refresh, site, refusal }: { nodes: Node[]; refresh: () = {!n.public && 不公开} {/* Under the badge, not inside it: the column is a tenth of - the table and the three do not share one line. */} + 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)} +
+ + {uptime(Date.now() / 1000 - n.last_seen)} +
)}
diff --git a/web-admin/src/index.css b/web-admin/src/index.css index bfc0378f..859c7406 100644 --- a/web-admin/src/index.css +++ b/web-admin/src/index.css @@ -125,25 +125,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; -} - -/* The sliding rows are drawn above the whole page, the sticky header included, - so a row scrolled beneath it would cover it for each slide. The header takes - part as well and is raised over them. */ -header { view-transition-name: page-header; } -::view-transition-group(page-header) { z-index: 1; } - -@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 From 3c7af5a2aa4ad4c28b06819c4c7a295035022b8e Mon Sep 17 00:00:00 2001 From: stqfdyr <89149493+stqfdyr@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:33:16 +0800 Subject: [PATCH 08/10] =?UTF-8?q?fix:=20=E8=8A=82=E7=82=B9=E8=A1=A8?= =?UTF-8?q?=E6=B5=81=E9=87=8F=E8=B6=85=E5=87=BA=E9=A2=9D=E5=BA=A6=E6=97=B6?= =?UTF-8?q?=E6=A0=87=E7=BA=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web-admin/src/components/Admin.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/web-admin/src/components/Admin.tsx b/web-admin/src/components/Admin.tsx index 54f0a589..5e477276 100644 --- a/web-admin/src/components/Admin.tsx +++ b/web-admin/src/components/Admin.tsx @@ -1434,10 +1434,13 @@ function Nodes({ nodes, refresh, site, refusal }: { nodes: Node[]; refresh: () =
{/* Counted by the node's own billing rule, as on the public - page. Two unbreakable halves, so a narrow table moves the - limit to a second line rather than splitting a figure. */} + page, and red once past the allowance. Two unbreakable + halves, so a narrow table moves the limit to a second line + rather than splitting a figure. */} - {bytes(n.month_used)}{" "} + 0 && n.month_used > n.traffic_limit ? "text-destructive" : ""}`}> + {bytes(n.month_used)} + {" "} / {n.traffic_limit > 0 ? bytes(n.traffic_limit) : FOREVER} From 6f9055b7eb566501e1f40edd4d691431d1422ec0 Mon Sep 17 00:00:00 2001 From: stqfdyr <89149493+stqfdyr@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:27:55 +0800 Subject: [PATCH 09/10] =?UTF-8?q?fix:=20=E6=B5=81=E9=87=8F=E6=8C=89?= =?UTF-8?q?=E6=8F=90=E9=86=92=E9=98=88=E5=80=BC=E5=88=86=E6=9A=97=E6=A9=99?= =?UTF-8?q?=E3=80=81=E6=9A=97=E7=BA=A2=E4=B8=A4=E6=A1=A3=EF=BC=8C=E5=AE=A1?= =?UTF-8?q?=E6=9F=A5=E5=8F=91=E7=8E=B0=E7=9A=84=E9=9D=A2=E6=9D=BF=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 节点表流量到达流量提醒阈值(关闭提醒时按 80%)显示暗橙,用满额度显示暗红。 - 不带空格的长名字在汉字之间折行,不再撑宽名称列;宽度不超过八个字宽的词整体不断, 连字符词也按宽度判断;「 - 」「—」「|」等分隔符不出现在行首。 - 主题列表由 hub 返回每个主题是否带预览图,面板不再等所有预览图下载完才显示。 - 安全页、延迟页的列表加载失败时照常显示页面并提示。 - http 访问时复制改用复制命令,不再点了没反应。 - 地址单元格只在地址变化时重绘,100 台节点下实时刷新的脚本耗时从每秒 128 ms 降到 66 ms。 - 对话框关闭浏览器原生校验,额度可填小数;每月重置日超出 1–31 时提示而不是悄悄改值。 - 设置保存成功后重新读取失败不再当作保存失败;新节点被筛选隐藏时不再在清空筛选后突然滚动。 --- src/api.rs | 14 +- src/frontend.rs | 4 + web-admin/src/components/Admin.tsx | 234 +++++++++++++++++------------ web-admin/src/index.css | 6 + 4 files changed, 158 insertions(+), 100 deletions(-) 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/components/Admin.tsx b/web-admin/src/components/Admin.tsx index 5e477276..c5621e98 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" @@ -193,6 +193,15 @@ function DragHandle({ name, disabled, title = "拖动排序", ...events }: React } 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(done), () => toast.error("复制失败"), @@ -209,39 +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 ( - // 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]},点击复制
-
-
- ))} -
- ) -} +// +// Drawn again only when the addresses change. The table re-renders on every +// push, and redrawing a tooltip per address took 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) { @@ -574,7 +590,7 @@ function GroupDialog({ nodes, onClose, onSaved }: { nodes: Node[]; onClose: () = 勾选节点,设为同一个分组。改名或解散:先筛选出这个分组、全选,再填新名字或清空。 -
{ e.preventDefault(); save() }}> + { e.preventDefault(); save() }}>
@@ -690,6 +706,8 @@ function NodeForm({ node, nodes, onClose, onSaved }: { async function save() { if (!form.name.trim()) return toast.error("请填写节点名称") + const resetOn = Number(resetDay) + if (!Number.isInteger(resetOn) || resetOn < 1 || resetOn > 31) return toast.error("每月重置日要填 1–31 之间的整数") const patch = changes(node, { name: form.name.trim(), public: form.public, @@ -697,7 +715,7 @@ function NodeForm({ node, nodes, onClose, onSaved }: { group: (form.group ?? "").trim(), traffic_mode: form.traffic_mode, traffic_limit: Math.round(Number(limitGib) * GIB), - traffic_reset_day: Math.min(31, Math.max(1, Math.round(Number(resetDay) || node.traffic_reset_day))), + traffic_reset_day: resetOn, notify: !!form.notify, ipv4_pin: (form.ipv4_pin ?? "").trim(), ipv6_pin: (form.ipv6_pin ?? "").trim(), @@ -736,7 +754,11 @@ function NodeForm({ node, nodes, onClose, onSaved }: { {node.name} - { e.preventDefault(); save() }}> + {/* noValidate here and in the other dialogs: save() checks every field. + The browser's own check would refuse a fractional GB against the + default step of 1, and cannot point at a field folded inside + 流量校正, so the save button would do nothing. */} + { e.preventDefault(); save() }}>
@@ -873,7 +895,7 @@ function BillingForm({ node, onClose, onSaved }: { {node.name} - { e.preventDefault(); save() }}> + { e.preventDefault(); save() }}>
@@ -1267,26 +1289,43 @@ 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 -- so neither a city nor a label is -// split. A short hyphenated word such as GIA-E stays whole, where the browser -// would leave its last letter to open the next line; a long one may still break -// at its hyphens. A separator such as the · in 香港 09 · DMIT is held to the word -// before it, so no line opens with one. +// 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") + .replace(/ (?=[·|/|—–-] )/g, "\u00a0") .split(/( )/) - .map((word, i) => - word.includes("-") && word.length <= 12 ? ( - {word} - ) : ( - word.split(".").flatMap((part, j) => (j ? [".", , part] : [part])) - ), - ) + .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, setWarnAt] = useState(80) + useEffect(() => { + api("/settings").then((s) => Number(s.notify_traffic) > 0 && setWarnAt(Number(s.notify_traffic))).catch(() => {}) + }, []) const [creating, setCreating] = useState(false) const [editing, setEditing] = useState(null) const [billing, setBilling] = useState(null) @@ -1299,14 +1338,14 @@ function Nodes({ nodes, refresh, site, refusal }: { nodes: Node[]; refresh: () = 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. + // 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(() => { - const row = added.current === null ? null : document.querySelector(`tbody tr[data-id="${added.current}"]`) - if (!row) return - row.scrollIntoView({ block: "center", behavior: "smooth" }) + 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" @@ -1368,8 +1407,8 @@ function Nodes({ nodes, refresh, site, refusal }: { nodes: Node[]; refresh: () = {visible.map((n) => ( - {/* Wraps: the cell's nowrap let one long name widen the table - until the actions left the screen. */} + {/* Wraps: under the cell's default nowrap, one long name would + widen the table until the actions left the screen. */}
- {/* Lines are balanced, so neither ends on a character or - two. The column is at least as wide as the longest - word, which breaks only when it exceeds the whole - table. */} -
+ {/* 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 @@ -1402,13 +1438,13 @@ function Nodes({ nodes, refresh, site, refusal }: { nodes: Node[]; refresh: () = )}
- {n.group &&
{n.group}
} + {n.group &&
{n.group}
}
{/* Addresses live only here, never on the public page. */} - + {/* Stacked and centred on one axis. The slot is as wide as @@ -1434,11 +1470,10 @@ function Nodes({ nodes, refresh, site, refusal }: { nodes: Node[]; refresh: () =
{/* Counted by the node's own billing rule, as on the public - page, and red once past the allowance. Two unbreakable - halves, so a narrow table moves the limit to a second line - rather than splitting a figure. */} + page. Two unbreakable halves, so a narrow table moves the + limit to a second line rather than splitting a figure. */} - 0 && n.month_used > n.traffic_limit ? "text-destructive" : ""}`}> + {bytes(n.month_used)} {" "} @@ -1606,7 +1641,7 @@ function PingForm({ task, nodes, onClose, onSaved }: { {task.id ? "编辑监控" : "添加监控"} - { e.preventDefault(); save() }}> + { 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. */} @@ -1651,7 +1686,14 @@ function Ping({ nodes }: { nodes: Node[] }) { 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]) @@ -1755,21 +1797,10 @@ type Theme = { builtin: boolean // theme.json 里声明的设置表单,原样转过来,由 configFields 挑出能画的字段。 config?: unknown - // 面板自己探测:preview.png 能否加载。 - preview?: boolean + // 主题包是否带 preview.png,由 hub 告知,卡片的高度一次排定,不因图片晚到而改变。 + preview: boolean } -// 预览图是主题包里可选的 preview.png,hub 不报告有没有,只能加载一次看看。在画卡片 -// 之前探测完:图片晚到会把标题和按钮整体往下推一截。同一文档里再用这个地址时直接 -// 取已加载的图,不会下载第二次。 -const probePreview = (short: string) => - new Promise((resolve) => { - const img = new Image() - img.onload = () => resolve(true) - img.onerror = () => resolve(false) - img.src = `/api/themes/${short}/preview` - }) - // 主题在 theme.json 里声明的设置。hub 只存与默认值不同的项,其余由主题用自己的默认值补上, // 所以主题作者日后改了某个默认值,没动过这一项的站点会跟着变。 // @@ -1950,12 +1981,7 @@ function Themes() { const picker = useRef(null) const load = () => - api<{ themes: Theme[] }>("/themes") - .then(async ({ themes }) => { - const shown = await Promise.all(themes.map((theme) => probePreview(theme.short))) - setThemes(themes.map((theme, i) => ({ ...theme, preview: shown[i] }))) - }) - .catch(() => setThemes([])) + api<{ themes: Theme[] }>("/themes").then((data) => setThemes(data.themes)).catch(() => setThemes([])) useEffect(() => { load() }, []) async function select(short: string) { @@ -2176,10 +2202,16 @@ function useSettings() { save: async (patch: Record, done = "已保存") => { try { await api("/settings", { method: "PUT", body: JSON.stringify(patch) }) - 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. + } 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 } @@ -2187,11 +2219,10 @@ function useSettings() { for (const [key, value] of Object.entries(fresh)) if (key.endsWith("_set")) next[key] = value return next }) - return true } catch (e) { toast.error((e as Error).message) - return false } + return true }, } } @@ -2549,7 +2580,18 @@ type Session = { id: string; current: boolean; created_at: number } function useSessions() { const [rows, setRows] = useState(null) - const load = useCallback(() => api("/sessions").then(setRows).catch((e: Error) => toast.error(e.message)), []) + // 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 } } diff --git a/web-admin/src/index.css b/web-admin/src/index.css index 859c7406..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 { From 375264ed066bc4c46d3138f2b068bb69b4992c62 Mon Sep 17 00:00:00 2001 From: stqfdyr <89149493+stqfdyr@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:54:29 +0800 Subject: [PATCH 10/10] =?UTF-8?q?refactor:=20=E8=8A=82=E7=82=B9=E8=A1=A8?= =?UTF-8?q?=E8=AF=BB=E6=B5=81=E9=87=8F=E6=8F=90=E9=86=92=E9=98=88=E5=80=BC?= =?UTF-8?q?=E5=A4=8D=E7=94=A8=20useSettings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web-admin/src/components/Admin.tsx | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/web-admin/src/components/Admin.tsx b/web-admin/src/components/Admin.tsx index c5621e98..1b2e9651 100644 --- a/web-admin/src/components/Admin.tsx +++ b/web-admin/src/components/Admin.tsx @@ -220,8 +220,8 @@ const SOURCES: Record = { // tooltip, keeping the column to addresses alone. // // Drawn again only when the addresses change. The table re-renders on every -// push, and redrawing a tooltip per address took the panel's script time at a -// hundred nodes from 66 to 128 ms a second. +// 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 — @@ -1322,10 +1322,7 @@ function trafficTone(n: Node, warnAt: number) { } function Nodes({ nodes, refresh, site, refusal }: { nodes: Node[]; refresh: () => void; site: string; refusal: string }) { - const [warnAt, setWarnAt] = useState(80) - useEffect(() => { - api("/settings").then((s) => Number(s.notify_traffic) > 0 && setWarnAt(Number(s.notify_traffic))).catch(() => {}) - }, []) + const warnAt = Number(useSettings().s?.notify_traffic) || 80 const [creating, setCreating] = useState(false) const [editing, setEditing] = useState(null) const [billing, setBilling] = useState(null)