From 376ee52616d61ce9c3442457a7aa4e3e184a57a0 Mon Sep 17 00:00:00 2001 From: stqfdyr <89149493+stqfdyr@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:13:58 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E5=85=AC=E5=BC=80=E9=A1=B5?= =?UTF-8?q?=E6=8C=89=E5=88=86=E7=BB=84=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 节点有分组时,列表上方出现「全部 / 各分组 / 未分组」标签,分组顺序跟着节点顺序;一个分组 都没有时页面不变。汇总跟着所选标签只算这一组,实时网速按分组各记一条序列。标签的选择放在 App 里,看完节点详情返回时保留。 --- src/App.tsx | 71 +++++++++++++++++++++++++++++++------- src/components/Summary.tsx | 10 +++--- src/lib/api.test.ts | 15 +++++++- src/lib/api.ts | 46 +++++++++++++++++------- 4 files changed, 112 insertions(+), 30 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index a6ed164..9b1f80a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,7 +5,7 @@ import { NodeCard } from "@/components/NodeCard" import { Summary } from "@/components/Summary" import { Button } from "@/components/ui/button" import { Skeleton } from "@/components/ui/skeleton" -import { api, useNodes, type Node } from "@/lib/api" +import { api, groupsOf, useNodes, type Node } from "@/lib/api" type Me = { authed: boolean; github: boolean; site_name: string; public_page: boolean } @@ -84,6 +84,8 @@ export default function App() { const [meError, setMeError] = useState("") const { nodes, error, closed } = useNodes() const [open, go] = useNodeRoute() + // The list's group tab, held here so it survives a visit to a node's page. + const [group, setGroup] = useState(null) const loadMe = useCallback(() => { // `|| "..."` because an empty message reads as no error: api() falls back to @@ -182,20 +184,63 @@ export default function App() { ))} ) : ( - <> - - {sorted.length === 0 ? ( -

还没有节点

- ) : ( -
- {sorted.map((n: Node) => ( - go(n.id)} /> - ))} -
- )} - + )} ) } + +// Group tabs appear only once the operator has grouped something, so a hub +// without groups keeps the page it always had. The summary follows the tab. +function NodeList({ nodes, group, onGroup, onOpen }: { + nodes: Node[] + /** null is every node, "" the ungrouped. */ + group: string | null + onGroup: (group: string | null) => void + onOpen: (id: number) => void +}) { + const groups = groupsOf(nodes) + // A group that has since emptied or been renamed falls back to every node + // rather than to an empty page. + const current = group === null || group === "" || groups.includes(group) ? group : null + const shown = current === null ? nodes : nodes.filter((n) => (n.group ?? "") === current) + const ungrouped = nodes.filter((n) => !n.group).length + const tabs = [ + [null, "全部", nodes.length] as const, + ...groups.map((g) => [g, g, nodes.filter((n) => n.group === g).length] as const), + ...(ungrouped ? [["", "未分组", ungrouped] as const] : []), + ] + return ( + <> + {groups.length > 0 && ( +
+ {tabs.map(([value, label, count]) => ( + + ))} +
+ )} + + {nodes.length === 0 ? ( +

还没有节点

+ ) : ( +
+ {shown.map((n) => ( + onOpen(n.id)} /> + ))} +
+ )} + + ) +} diff --git a/src/components/Summary.tsx b/src/components/Summary.tsx index f757d12..f6894bc 100644 --- a/src/components/Summary.tsx +++ b/src/components/Summary.tsx @@ -64,7 +64,8 @@ function Spark({ series }: { series: { values: number[]; className: string }[] } ) } -export function Summary({ nodes }: { nodes: Node[] }) { +/** `group` picks the throughput series: "*" for every node, else the tab's group. */ +export function Summary({ nodes, group }: { nodes: Node[]; group: string }) { const online = nodes.filter((n) => n.online) const sum = (pick: (n: Node) => number) => nodes.reduce((total, n) => total + pick(n), 0) @@ -77,7 +78,8 @@ export function Summary({ nodes }: { nodes: Node[] }) { const cpu = busiest?.metrics?.cpu ?? 0 // The same push produced `nodes` and this sample, so the figure above the line // is that line's last point. - const now = speedHistory.at(-1) ?? { rx: 0, tx: 0 } + const history = speedHistory.get(group) ?? [] + const now = history.at(-1) ?? { rx: 0, tx: 0 } return (
@@ -112,8 +114,8 @@ export function Summary({ nodes }: { nodes: Node[] }) {
s.rx), className: "text-foreground" }, - { values: speedHistory.map((s) => s.tx), className: "text-muted-foreground" }, + { values: history.map((s) => s.rx), className: "text-foreground" }, + { values: history.map((s) => s.tx), className: "text-muted-foreground" }, ]} />
diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts index abba160..a9c0330 100644 --- a/src/lib/api.test.ts +++ b/src/lib/api.test.ts @@ -1,6 +1,6 @@ /// import assert from "node:assert/strict" -import { safeNodes, type Node } from "./api.ts" +import { groupsOf, safeNodes, sample, speedHistory, type Node } from "./api.ts" const node = { id: 1, metrics: { uptime: 100, cpu: 1, load: [0.1, 0.2, 0.3], mem_total: 1024, mem_used: 512, swap_total: 0, swap_used: 0, disk_total: 2048, disk_used: 1024, @@ -14,3 +14,16 @@ for (const patch of [{ load: null }, { load: [1, "bad", 3] }, { cpu: "bad" }, { assert.equal(result[1], node) } console.log("invalid live reports are isolated") + +// Tabs follow the node order; ungrouped nodes and a hub without the field add none. +assert.deepEqual(groupsOf([{ group: "东京" }, { group: "" }, {}, { group: "香港" }, { group: "东京" }]), ["东京", "香港"]) +console.log("groups follow the node order") + +// Each group keeps its own throughput line beside the fleet's, and a group that +// empties stops being tracked. +const live = (group: string, rx: number) => ({ ...node, group, online: true, metrics: { ...node.metrics!, net_rx: rx, net_tx: 0 } }) as Node +sample([live("东京", 5), live("", 7), { ...live("东京", 9), online: false }]) +assert.deepEqual([speedHistory.get("*")?.at(-1)?.rx, speedHistory.get("东京")?.at(-1)?.rx, speedHistory.get("")?.at(-1)?.rx], [12, 5, 7]) +sample([live("", 1)]) +assert.equal(speedHistory.has("东京"), false) +console.log("throughput is kept per group") diff --git a/src/lib/api.ts b/src/lib/api.ts index 3881257..ebf05d7 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -29,6 +29,8 @@ export type Node = { online: boolean /** ISO 3166-1 alpha-2, or empty when the hub could not locate the address. */ country: string + /** Set by the operator; empty is ungrouped. Absent from a hub predating groups. */ + group?: string last_seen: number metrics: Metrics | null os: string @@ -61,6 +63,11 @@ export type Node = { remark?: string } +/** Every group in use, in the order of the first node carrying it: the operator's node order decides the tab order. */ +export function groupsOf(nodes: Pick[]): string[] { + return [...new Set(nodes.map((n) => n.group ?? "").filter(Boolean))] +} + export class ApiError extends Error { status: number constructor(status: number, message: string) { @@ -79,21 +86,36 @@ export async function api(path: string, init?: RequestInit): Promise { } /** - * Fleet throughput, one sample per push. Held beside the stream that feeds it - * rather than in the tile that draws it: the summary unmounts while a node page is - * open, so a buffer held there would restart empty on every return. Two minutes at - * the hub's push interval. + * Throughput, one sample per push, as a series for every node ("*") and one per + * group ("" for the ungrouped), so the summary above a group tab draws that + * group's line rather than the fleet's. Held beside the stream that feeds it + * rather than in the tile that draws it: the summary unmounts while a node page + * is open, so a buffer held there would restart empty on every return. Two + * minutes at the hub's push interval; a group no node carries any more is + * dropped. */ const KEEP = 60 -export const speedHistory: { rx: number; tx: number }[] = [] +export const speedHistory = new Map() -function sample(nodes: Node[]) { - const live = nodes.filter((n) => n.online && n.metrics) - speedHistory.push({ - rx: live.reduce((s, n) => s + n.metrics!.net_rx, 0), - tx: live.reduce((s, n) => s + n.metrics!.net_tx, 0), - }) - if (speedHistory.length > KEEP) speedHistory.shift() +export function sample(nodes: Node[]) { + const totals = new Map() + for (const n of nodes) { + for (const key of ["*", n.group ?? ""]) { + const total = totals.get(key) ?? { rx: 0, tx: 0 } + if (n.online && n.metrics) { + total.rx += n.metrics.net_rx + total.tx += n.metrics.net_tx + } + totals.set(key, total) + } + } + for (const key of speedHistory.keys()) if (!totals.has(key)) speedHistory.delete(key) + for (const [key, total] of totals) { + const series = speedHistory.get(key) ?? [] + series.push(total) + if (series.length > KEEP) series.shift() + speedHistory.set(key, series) + } } /** A malformed report must not remove every other node from the page. */ From 58fb4c2b51bef5182ea172f8031ed312f4ddd7b1 Mon Sep 17 00:00:00 2001 From: stqfdyr <89149493+stqfdyr@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:19:58 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E6=9C=AA=E5=88=86=E7=BB=84=E6=A0=87?= =?UTF-8?q?=E7=AD=BE=E6=B8=85=E7=A9=BA=E5=90=8E=E5=9B=9E=E5=88=B0=E5=85=A8?= =?UTF-8?q?=E9=83=A8=EF=BC=8C=E5=85=A8=E7=AB=99=E7=BD=91=E9=80=9F=E5=BA=8F?= =?UTF-8?q?=E5=88=97=E4=B8=8D=E5=86=8D=E4=B8=8E=E5=90=8D=E4=B8=BA=20*=20?= =?UTF-8?q?=E7=9A=84=E5=88=86=E7=BB=84=E5=85=B1=E7=94=A8=20key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 选中「未分组」后未分组节点全被设了分组,页面回到全部,不再是没有标签高亮的空白网格;失效的标签直接忘掉,同名分组再出现时不会自己跳回去 - 全站序列以 null 为 key,分组名是任意文本,叫 * 的分组不再被重复计入全站网速;标签按钮的 key 同理加前缀 - 标签改用 aria-pressed 的按钮组,不再宣告一个没有方向键操作的 tablist --- src/App.tsx | 23 ++++++++++++++--------- src/components/Summary.tsx | 4 ++-- src/lib/api.test.ts | 5 ++++- src/lib/api.ts | 11 ++++++----- 4 files changed, 26 insertions(+), 17 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 9b1f80a..9a3fe71 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -201,11 +201,15 @@ function NodeList({ nodes, group, onGroup, onOpen }: { onOpen: (id: number) => void }) { const groups = groupsOf(nodes) - // A group that has since emptied or been renamed falls back to every node - // rather than to an empty page. - const current = group === null || group === "" || groups.includes(group) ? group : null - const shown = current === null ? nodes : nodes.filter((n) => (n.group ?? "") === current) const ungrouped = nodes.filter((n) => !n.group).length + // A tab that has since emptied or been renamed -- 未分组 included -- falls back + // to every node rather than to an empty page, and is forgotten, so a later + // group of the same name does not take the page over. + const current = group === null || (group === "" ? ungrouped > 0 : groups.includes(group)) ? group : null + useEffect(() => { + if (current !== group) onGroup(current) + }, [current, group, onGroup]) + const shown = current === null ? nodes : nodes.filter((n) => (n.group ?? "") === current) const tabs = [ [null, "全部", nodes.length] as const, ...groups.map((g) => [g, g, nodes.filter((n) => n.group === g).length] as const), @@ -214,12 +218,13 @@ function NodeList({ nodes, group, onGroup, onOpen }: { return ( <> {groups.length > 0 && ( -
+
{tabs.map(([value, label, count]) => (