diff --git a/src/App.tsx b/src/App.tsx index a6ed164..9a3fe71 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,68 @@ 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) + 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), + ...(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..34c738e 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: null for every node, else the tab's group. */ +export function Summary({ nodes, group }: { nodes: Node[]; group: string | null }) { 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..ede2705 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,19 @@ 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(null)?.at(-1)?.rx, speedHistory.get("东京")?.at(-1)?.rx, speedHistory.get("")?.at(-1)?.rx], [12, 5, 7]) +sample([live("", 1)]) +assert.equal(speedHistory.has("东京"), false) +// A group may be named anything, the fleet's own key included. +sample([live("*", 5), live("", 7)]) +assert.deepEqual([speedHistory.get(null)?.at(-1)?.rx, speedHistory.get("*")?.at(-1)?.rx], [12, 5]) +console.log("throughput is kept per group") diff --git a/src/lib/api.ts b/src/lib/api.ts index 3881257..4e7f206 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,37 @@ 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 (null) 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. Keyed null rather than by any string, since a group may be named + * anything, "*" included. */ 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 [null, 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. */