Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 63 additions & 13 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down Expand Up @@ -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<string | null>(null)

const loadMe = useCallback(() => {
// `|| "..."` because an empty message reads as no error: api() falls back to
Expand Down Expand Up @@ -182,20 +184,68 @@ export default function App() {
))}
</div>
) : (
<>
<Summary nodes={sorted} />
{sorted.length === 0 ? (
<p className="py-16 text-center text-sm text-muted-foreground">还没有节点</p>
) : (
<div className="grid items-start gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{sorted.map((n: Node) => (
<NodeCard key={n.id} node={n} onOpen={() => go(n.id)} />
))}
</div>
)}
</>
<NodeList nodes={sorted} group={group} onGroup={setGroup} onOpen={go} />
)}
</main>
</div>
)
}

// 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 && (
<div role="group" aria-label="分组" className="-mx-1 flex gap-1 overflow-x-auto px-1 pb-1">
{tabs.map(([value, label, count]) => (
<Button
// Group names are free text, so they carry a prefix no key of
// the 全部 tab can share.
key={value === null ? "*" : `=${value}`}
aria-pressed={current === value}
size="sm"
variant={current === value ? "secondary" : "ghost"}
className="shrink-0"
onClick={() => onGroup(value)}
>
{label}
<span className="tnum text-muted-foreground">{count}</span>
</Button>
))}
</div>
)}
<Summary nodes={shown} group={current} />
{nodes.length === 0 ? (
<p className="py-16 text-center text-sm text-muted-foreground">还没有节点</p>
) : (
<div className="grid items-start gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{shown.map((n) => (
<NodeCard key={n.id} node={n} onOpen={() => onOpen(n.id)} />
))}
</div>
)}
</>
)
}
10 changes: 6 additions & 4 deletions src/components/Summary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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 (
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
Expand Down Expand Up @@ -112,8 +114,8 @@ export function Summary({ nodes }: { nodes: Node[] }) {
<div className="mt-auto pt-1">
<Spark
series={[
{ values: speedHistory.map((s) => 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" },
]}
/>
</div>
Expand Down
18 changes: 17 additions & 1 deletion src/lib/api.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/// <reference types="node" />
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,
Expand All @@ -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")
47 changes: 35 additions & 12 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Node, "group">[]): string[] {
return [...new Set(nodes.map((n) => n.group ?? "").filter(Boolean))]
}

export class ApiError extends Error {
status: number
constructor(status: number, message: string) {
Expand All @@ -79,21 +86,37 @@ export async function api<T>(path: string, init?: RequestInit): Promise<T> {
}

/**
* 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<string | null, { rx: number; tx: number }[]>()

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<string | null, { rx: number; tx: number }>()
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. */
Expand Down
Loading