From 0258c1f120562e3395816bcccf8c63f8db9c9594 Mon Sep 17 00:00:00 2001 From: stqfdyr <89149493+stqfdyr@users.noreply.github.com> Date: Sat, 19 Sep 2026 07:02:15 +0800 Subject: [PATCH 01/10] =?UTF-8?q?feat(install):=20=E6=94=AF=E6=8C=81=20--i?= =?UTF-8?q?face=EF=BC=8C=E9=87=8D=E8=B7=91=E5=AE=89=E8=A3=85=E6=97=B6?= =?UTF-8?q?=E6=B2=BF=E7=94=A8=E5=B7=B2=E8=AE=BE=E7=BD=AE=E7=9A=84=E7=BD=91?= =?UTF-8?q?=E5=8D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - --iface 写入 agent.env 的 MONITOR_IFACE;重跑不带 --iface 时沿用,--iface '' 清除。 批量注册命令由整个机队共用,带不了每台机器的网卡名 - 值只允许网卡名、逗号、开头的 - 与末尾的 *:OpenRC 以 shell 方式载入 agent.env - accumulate 注释补充:agent 设了 --iface 时会拼进 boot_id,改设置即重新对基线 --- install.sh | 22 ++++++++++++++++++++-- src/db.rs | 16 +++++++++------- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/install.sh b/install.sh index b354b703..e4264c56 100755 --- a/install.sh +++ b/install.sh @@ -20,6 +20,8 @@ LOG_FILE="/var/log/monitor-agent.log" SERVER="" TOKEN="" REGISTER="" +IFACE="" +IFACE_SET="" INTERVAL=1 INSECURE="" UNINSTALL="" @@ -28,13 +30,14 @@ while [ $# -gt 0 ]; do # A flag with no argument: under set -u, `$2` aborts with the shell's own # message rather than the usage below, and `shift 2` cannot proceed. case "$1" in - --server | --token | --register | --interval) + --server | --token | --register | --iface | --interval) [ $# -ge 2 ] || { echo "$1 needs a value" >&2; exit 2; } ;; esac case "$1" in --server) SERVER="$2"; shift 2 ;; --token) TOKEN="$2"; shift 2 ;; --register) REGISTER="$2"; shift 2 ;; + --iface) IFACE="$2"; IFACE_SET=1; shift 2 ;; --interval) INTERVAL="$2"; shift 2 ;; --insecure) INSECURE=1; shift ;; --uninstall) UNINSTALL=1; shift ;; @@ -63,12 +66,26 @@ if [ -n "$UNINSTALL" ]; then fi [ -n "$SERVER" ] && { [ -n "$TOKEN" ] || [ -n "$REGISTER" ]; } || { - echo "usage: install.sh --server URL (--token TOKEN | --register KEY) [--interval SECONDS] [--insecure]" >&2 + echo "usage: install.sh --server URL (--token TOKEN | --register KEY) [--interval SECONDS] [--iface LIST] [--insecure]" >&2 echo " install.sh --uninstall" >&2 exit 2 } case "$INTERVAL" in "" | *[!0-9]*) echo "interval must be an integer from 1 to 3600" >&2; exit 2 ;; esac [ "$INTERVAL" -ge 1 ] && [ "$INTERVAL" -le 3600 ] || { echo "interval must be from 1 to 3600" >&2; exit 2; } +# Which interfaces carry this machine's traffic is known only on the machine, +# and the batch command a fleet shares cannot carry one value per machine. A +# rerun without --iface, the documented upgrade, therefore keeps the value in +# the env file; --iface '' clears it. That file is root-only: without root this +# reads nothing, and the install stops at the root check regardless. +if [ -z "$IFACE_SET" ]; then + IFACE=$(sed -n 's/^MONITOR_IFACE=//p' "$ENV_FILE" 2>/dev/null || true) + [ -z "$IFACE" ] || echo "keeping --iface $IFACE from the previous install" +fi +# OpenRC sources the env file as shell, so the value is held to what names, +# commas, a leading `-` and a trailing `*` require. +case "$IFACE" in +*[!A-Za-z0-9._*,-]*) echo "--iface takes interface names separated by commas, not: $IFACE" >&2; exit 2 ;; +esac # A bare host implies TLS, matching the upgrade the agent's ws_url() performs, # and the same reversal under --insecure where the hub has no TLS to upgrade to. # Without this the two diverge: the agent would dial wss:// while curl below @@ -233,6 +250,7 @@ install -m 0755 "$TMP" "$BIN" MONITOR_SERVER=$SERVER MONITOR_TOKEN=$TOKEN ENV + [ -z "$IFACE" ] || echo "MONITOR_IFACE=$IFACE" >>"$ENV_FILE" ) if [ "$INIT" = openrc ]; then diff --git a/src/db.rs b/src/db.rs index bd816fca..9efaaa5b 100644 --- a/src/db.rs +++ b/src/db.rs @@ -882,9 +882,10 @@ impl Db { /// Folds one report's raw kernel counters into the node's running totals. /// - /// A changed boot_id, or a counter that moved backwards, means the kernel - /// restarted its counting; the total must not follow it downward. `None` - /// denotes a report carrying no readable counters at all -- see below. + /// A changed boot_id, or a counter that moved backwards, means the readings + /// no longer continue the previous ones; the total must not follow them + /// downward. `None` denotes a report carrying no readable counters at all -- + /// see below. /// /// The billing reset day is read here rather than passed in: it is one join /// from a row this already reads, and fetching it separately would cost every @@ -935,10 +936,11 @@ impl Db { // first report has none. A reading that shrank under the same boot lost // one -- an interface included in the sum has disappeared -- so the // reading is the remainder of that history and booking it would count it - // twice. A changed boot_id means the counters restarted or, - // indistinguishably from here, that a second machine shares the token. - // Realigning costs the seconds since the reboot; the alternative costs - // hundreds of gigabytes against a total that only increases. + // twice. A changed boot_id means the counters restarted, that the agent + // now sums a different set of interfaces (it appends its --iface list), + // or, indistinguishably from here, that a second machine shares the + // token. Realigning costs the seconds since the reboot; the alternative + // costs hundreds of gigabytes against a total that only increases. // // A fourth case: no reading at all. The row is left exactly as it was, // since writing zero would realign the baseline to zero and book the next From dcf0ef1538f34753e0838ba710e5ef1b34633d5c Mon Sep 17 00:00:00 2001 From: stqfdyr <89149493+stqfdyr@users.noreply.github.com> Date: Sat, 19 Sep 2026 07:16:16 +0800 Subject: [PATCH 02/10] =?UTF-8?q?feat(panel):=20=E5=AE=89=E8=A3=85?= =?UTF-8?q?=E5=BC=B9=E7=AA=97=E5=8F=AF=E6=8C=87=E5=AE=9A=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=E7=9A=84=E7=BD=91=E5=8D=A1=EF=BC=8C=E5=AE=89=E8=A3=85=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E9=9A=8F=E4=B9=8B=E5=AE=9E=E6=97=B6=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 安装与批量添加弹窗新增「指定统计的网卡」:只统计 / 不统计两栏,合成 --iface; 关闭时不带 --iface,install.sh 沿用机器原有设置;打开且两栏留空即恢复默认规则 - 在线节点从 boot_id 读出当前 --iface,显示为「当前:…」并预填 - 名字不合法时指出是哪一个、标红所在输入框,命令暂不生成 - 安装弹窗改为「安装选项 / 安装命令」两段,上报间隔改成与开关一致的卡片样式 - 修正批量添加说明中 JSX 换行在中文里留下的空格 --- web-admin/src/components/Admin.tsx | 148 ++++++++++++++++++++++++----- web-admin/src/lib/api.test.ts | 22 ++++- web-admin/src/lib/api.ts | 46 +++++++++ 3 files changed, 192 insertions(+), 24 deletions(-) diff --git a/web-admin/src/components/Admin.tsx b/web-admin/src/components/Admin.tsx index fb9bbc3a..dcb8f180 100644 --- a/web-admin/src/components/Admin.tsx +++ b/web-admin/src/components/Admin.tsx @@ -12,7 +12,7 @@ import { Label } from "@/components/ui/label" 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 { addresses, api, changes, GIB, provisioningSite, trafficCorrection, upload, type Node, type PingTask, type Source } from "@/lib/api" +import { addresses, api, badIfaceName, changes, currentIface, GIB, ifaceChoice, ifaceSpec, provisioningSite, trafficCorrection, upload, 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. @@ -505,16 +505,22 @@ function scriptCommand(site: string, args: (site: string) => string[]) { // Built here rather than fetched: the node list already carries the token, so // viewing an install command is a read rather than an action. Reissuing one to // display it would take the running agent offline. -function installCommand(site: string, token: string, seconds: number) { - return scriptCommand(site, (s) => [`--server ${s}`, `--token ${token}`, `--interval ${seconds}`]) +function installCommand(site: string, token: string, seconds: number, iface: string | undefined) { + return scriptCommand(site, (s) => [`--server ${s}`, `--token ${token}`, `--interval ${seconds}`, ...ifaceArg(iface)]) +} + +// Quoted for the `*` a pattern may end in. ifaceSpec admits no quote, and '' +// is how install.sh is told to clear a value it would otherwise keep. +function ifaceArg(iface: string | undefined) { + return iface === undefined ? [] : [`--iface '${iface}'`] } // One command for a batch of machines. The key belongs to the hub, is valid only // within the window it opened, and each machine exchanges it for a token of its // own, so unlike an install command this text is no one's credential and can be // used directly in a loop. -function registerCommand(site: string, key: string) { - return scriptCommand(site, (s) => [`--server ${s}`, `--register ${key}`]) +function registerCommand(site: string, key: string, iface: string | undefined) { + return scriptCommand(site, (s) => [`--server ${s}`, `--register ${key}`, ...ifaceArg(iface)]) } // Carries no token, so it is the same for every node and remains valid after the @@ -568,7 +574,8 @@ function RegisterDialog({ site, reg, onClose }: { reg: ReturnType onClose: () => void }) { - const command = reg.left > 0 ? registerCommand(site, reg.key) : "" + const iface = useIfaceOption(undefined) + const command = reg.left > 0 && iface.valid ? registerCommand(site, reg.key, iface.flag) : "" const clock = `${Math.floor(reg.left / 60)}:${String(reg.left % 60).padStart(2, "0")}` return ( @@ -577,18 +584,26 @@ function RegisterDialog({ site, reg, onClose }: { 批量添加 -
+
+ {/* One string: JSX turns a line break inside CJK text into a visible space. */}

- 开一个一小时的注册窗口。期间这条命令在任意机器上跑一次,那台机器就会自己出现在 - 列表里,名字取自它的 hostname。命令里没有任何一台机器的凭证,可以直接进循环。 + {"开一个一小时的注册窗口。期间这条命令在任意机器上跑一次,那台机器就会自己出现在列表里," + + "名字取自它的 hostname。命令里没有任何一台机器的凭证,可以直接进循环。"}

- {command ? ( - - {command} +
+

安装选项

+ +
+ {reg.left > 0 ? ( +
+

安装命令

+ + {command || "网卡名有误,改正后显示命令"} + - +
) : ( )} @@ -604,6 +619,72 @@ function RegisterDialog({ site, reg, onClose }: { ) } +// The `--iface` part of an install command. Off leaves the flag out, and +// install.sh then keeps whatever the machine already has; on with both lists +// empty passes '' and restores the default rules. It opens on for a node whose +// agent reports a list, so reinstalling from here repeats that list rather than +// relying on the machine to remember it. +function useIfaceOption(current: string | undefined) { + const [on, setOn] = useState(!!current) + const [choice, setChoice] = useState(() => ifaceChoice(current ?? "")) + const bad = on ? badIfaceName(choice) : undefined + const spec = ifaceSpec(choice) + return { on, setOn, choice, setChoice, current, bad, valid: !bad, flag: on && spec !== null ? spec : undefined } +} + +function describeIface(spec: string) { + const { only, skip } = ifaceChoice(spec) + const parts = [only && `只统计 ${only.replaceAll(",", "、")}`, skip && `不统计 ${skip.replaceAll(",", "、")}`] + return parts.filter(Boolean).join(";") || "默认规则" +} + +function IfaceOption({ option, batch = false }: { option: ReturnType; batch?: boolean }) { + const { on, setOn, choice, setChoice, current, bad } = option + const field = (list: keyof IfaceChoice, label: string, placeholder: string) => ( + + setChoice({ ...choice, [list]: e.target.value })} + placeholder={placeholder} + spellCheck={false} + aria-invalid={bad?.list === list} + className="bg-background font-mono text-xs" + /> + + ) + return ( +
+ + {on && ( +
+
+ {field("only", "只统计", "如 eth1 或 pppoe-wan")} + {field("skip", "不统计", "如 vxlan100, nebula*")} +
+

+ {bad + ? `「${bad.name}」不是有效的网卡名:多个用逗号分隔,* 只能放在末尾` + : "逗号分隔,末尾的 * 匹配前缀。两项都留空即恢复默认规则。"} +

+
+ )} +
+ ) +} + function InstallDialog({ node, site, onClose, onRotated }: { node: Node site: string @@ -614,9 +695,10 @@ function InstallDialog({ node, site, onClose, onRotated }: { const [interval, setInterval] = useState("1") const [rotating, setRotating] = useState(false) const [confirmRotate, setConfirmRotate] = useState(false) + const iface = useIfaceOption(currentIface(node)) const seconds = Math.min(3600, Math.max(1, Math.round(Number(interval) || 1))) - const command = token ? installCommand(site, token, seconds) : "" + const command = token && iface.valid ? installCommand(site, token, seconds, iface.flag) : "" async function rotate() { setRotating(true) @@ -640,16 +722,36 @@ function InstallDialog({ node, site, onClose, onRotated }: { {node.name}
- - setInterval(e.target.value)} /> - - - - {/* A node added before the hub kept tokens has nothing to show - until one is reissued. */} - {command || "旧版本创建的凭证不可读取,换发后显示"} +
+

安装选项

+
+ + 上报间隔 + 1–3600 秒,默认 1 秒 + + + setInterval(e.target.value)} + aria-label="上报间隔(秒)" + className="tnum h-8 w-20 bg-background text-right" + /> + 秒 + +
+ +
+
+

安装命令

+ {/* A node added before the hub kept tokens has nothing to show + until one is reissued. */} + + {command || (token ? "网卡名有误,改正后显示命令" : "旧版本创建的凭证不可读取,换发后显示")} - +