From 976592d8d100d5a74f0e3d49993b8cc0faf4f43f Mon Sep 17 00:00:00 2001 From: stqfdyr <89149493+stqfdyr@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:12:00 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=E8=8A=82=E7=82=B9=E5=88=86?= =?UTF-8?q?=E7=BB=84=EF=BC=8C=E6=89=B9=E9=87=8F=E4=BF=AE=E6=94=B9=E4=B8=8E?= =?UTF-8?q?=E6=89=B9=E9=87=8F=E5=88=A0=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 节点加一个分组(node.group_name,接口字段 group),公开可见,最多 32 字,空为未分组; 分组顺序跟着节点顺序。schema 升到 7。 PUT /api/nodes/batch 把同一份修改应用到选中的全部节点,一个事务,有一台已不存在则整批 不写;名称、备注、地址、国家只能逐台改。单台编辑与批量共用同一套校验和写入。 POST /api/nodes/delete 批量删除,已不存在的跳过,每台一个事务,删除后断开对应 agent。 面板:节点列表加勾选列、分组筛选和批量修改/删除,编辑框加分组输入并列出已有分组, 搜索匹配分组名。NodePicker 加分组筛选,离线通知与延迟监控共用;离线通知的开关改为 一次批量请求。 --- src/api.rs | 187 +++++++++++-- src/db.rs | 143 +++++++--- src/main.rs | 2 + web-admin/src/components/Admin.tsx | 412 ++++++++++++++++++++++++++--- web-admin/src/lib/api.test.ts | 11 +- web-admin/src/lib/api.ts | 14 + 6 files changed, 661 insertions(+), 108 deletions(-) diff --git a/src/api.rs b/src/api.rs index 3a1f37c5..9a65216d 100644 --- a/src/api.rs +++ b/src/api.rs @@ -100,6 +100,9 @@ fn node_view(node: &Node, current: Option<&Agent>, traffic: &Traffic, full: bool // in, which is what a status page conveys, without locating it. The // address it was derived from remains behind the panel. "country": if node.country_pin.is_empty() { &node.country } else { &node.country_pin }, + // Named by the operator for the status page to divide the list by, so + // public like the node's name. Empty is ungrouped. + "group": node.group, "sort": node.sort, "public": node.public, "online": current.is_some(), @@ -535,6 +538,37 @@ fn node_limits(reset_day: Option, price: Option, limit: Option) - None } +/// A theme shows the group as a tab label, so it is held to a short one. +const MAX_GROUP: usize = 32; + +/// Trims a group name, or refuses it. Refused rather than truncated: the panel +/// would otherwise report saved a name that is not the one stored. +fn group_error(group: &mut String) -> Option<&'static str> { + *group = group.trim().to_owned(); + if group.chars().count() > MAX_GROUP || group.chars().any(char::is_control) { + return Some("group must be at most 32 characters, without control characters"); + } + None +} + +/// Normalizes a patch, or names the first value that cannot be stored. The one +/// check both the single and the batch write pass through, so the two accept +/// exactly the same values. +fn patch_error(node: &mut NodePatch) -> Option<&'static str> { + if let Some(name) = &mut node.name { + *name = name.trim().to_owned(); + if name.is_empty() { + return Some("name is required"); + } + } + if let Some(group) = &mut node.group { + if let Some(message) = group_error(group) { + return Some(message); + } + } + node_limits(node.traffic_reset_day, node.price, node.traffic_limit).or_else(|| pins(node)) +} + /// Normalizes the values set by hand, or names the one that cannot stand. Each /// takes the place of an automatic value, so it is held to what that value would /// have to be: the country to the rule a looked-up one passes, as both reach the @@ -598,6 +632,7 @@ pub async fn create_node( } if let Some(message) = node_limits(Some(node.traffic_reset_day), Some(node.price), Some(node.traffic_limit)) + .or_else(|| group_error(&mut node.group)) { return bad(message); } @@ -769,16 +804,7 @@ pub async fn update_node( body: Result, JsonRejection>, ) -> Response { let Ok(Json(mut node)) = body else { return bad("invalid node") }; - if let Some(name) = &mut node.name { - *name = name.trim().to_owned(); - if name.is_empty() { - return bad("name is required"); - } - } - if let Some(message) = node_limits(node.traffic_reset_day, node.price, node.traffic_limit) { - return bad(message); - } - if let Some(message) = pins(&mut node) { + if let Some(message) = patch_error(&mut node) { return bad(message); } match app.db.update_node(id, &node) { @@ -792,14 +818,62 @@ pub async fn update_node( } #[derive(Deserialize)] -pub struct NodeOrder { +pub struct NodeBatch { + ids: Vec, + #[serde(default)] + patch: NodePatch, +} + +/// Applies one patch to every selected node, all or none. +/// +/// Limited to settings a batch can share. A name, a note, an address or a +/// country describes one machine: applied to a selection it would read the same +/// on every node, which is never what was meant. The order has its own route. +pub async fn update_nodes( + _: Admin, + State(app): State, + body: Result, JsonRejection>, +) -> Response { + let Ok(Json(NodeBatch { mut ids, mut patch })) = body else { return bad("invalid batch") }; + ids.sort_unstable(); + ids.dedup(); + if ids.is_empty() { + return bad("no nodes selected"); + } + let p = &patch; + if p.name.is_some() + || p.sort.is_some() + || p.remark.is_some() + || p.country_pin.is_some() + || p.ipv4_pin.is_some() + || p.ipv6_pin.is_some() + { + return bad("name, order, note, addresses and country are set one node at a time"); + } + if let Some(message) = patch_error(&mut patch) { + return bad(message); + } + match app.db.update_nodes(&ids, &patch) { + Ok(true) => { + invalidate_snapshot(&app); + Json(json!({"updated": ids.len()})).into_response() + } + Ok(false) => { + (StatusCode::NOT_FOUND, "有节点已被删除,没有做任何修改;刷新后重新选择").into_response() + } + Err(e) => fail(e), + } +} + +#[derive(Deserialize)] +pub struct NodeIds { ids: Vec, } /// The list must name every node exactly once, checked inside the transaction /// that renumbers rather than here: re-reading the node list first would only /// race the write it guards. -pub async fn reorder_nodes(_: Admin, State(app): State, Json(order): Json) -> Response { +pub async fn reorder_nodes(_: Admin, State(app): State, Json(order): Json) -> Response { match app.db.reorder_nodes(&order.ids) { Ok(()) => { invalidate_snapshot(&app); @@ -816,17 +890,48 @@ pub async fn delete_node(_: Admin, State(app): State, Path(id): Path return no_such_node(), Err(e) => return fail(e), } - // The token is checked only at the handshake, so deleting the row does not - // end a connection already open on it; dropping the sender does. Without - // this the agent would keep reporting under an id SQLite reassigns to the - // next node created, which would then appear online on another node's - // metrics. Dropped after the delete, so the reconnect that follows finds no - // token to accept. The same reasoning applies in `reset_token` below. - app.agents.write().unwrap_or_else(|e| e.into_inner()).remove(&id); - invalidate_snapshot(&app); + disconnect(&app, &[id]); Json(json!({"ok": true})).into_response() } +/// Deletes the selected nodes. Ids already gone are skipped rather than +/// refused, so a list read before another tab deleted some still succeeds. +/// Off the runtime, since each node's latency history takes tens of ms to clear. +pub async fn delete_nodes( + _: Admin, + State(app): State, + Json(NodeIds { ids }): Json, +) -> Response { + if ids.is_empty() { + return bad("no nodes selected"); + } + let (shared, list) = (app.clone(), ids.clone()); + match tokio::task::spawn_blocking(move || shared.db.delete_nodes(&list)).await { + Ok(Ok(deleted)) => { + disconnect(&app, &ids); + Json(json!({"deleted": deleted})).into_response() + } + Ok(Err(e)) => fail(e), + Err(e) => fail(e), + } +} + +/// Ends the sessions of deleted nodes. The token is checked only at the +/// handshake, so deleting the row does not end a connection already open on +/// it; dropping the sender does. Without this the agent would keep reporting +/// under an id SQLite reassigns to the next node created, which would then +/// appear online on another node's metrics. Dropped after the delete, so the +/// reconnect that follows finds no token to accept. The same reasoning applies +/// in `reset_token` below. +fn disconnect(app: &App, ids: &[i64]) { + let mut agents = app.agents.write().unwrap_or_else(|e| e.into_inner()); + for id in ids { + agents.remove(id); + } + drop(agents); + invalidate_snapshot(app); +} + /// Issues a fresh token, invalidating the old one immediately. /// /// Always an explicit action: rotate a token believed to have leaked, then @@ -2242,6 +2347,48 @@ mod tests { assert!(app.agents.read().unwrap().is_empty(), "the node must read as offline at once"); } + /// A batch writes to every selected node or to none, accepts only what a + /// selection can share, and a group reaches the status page. + #[tokio::test] + async fn a_batch_edit_applies_to_all_selected_nodes_or_none() { + let app = std::sync::Arc::new(app()); + let state = || axum::extract::State(app.clone()); + let (a, b, c) = (node(&app, "a", true), node(&app, "b", true), node(&app, "c", true)); + let batch = |ids: Vec, patch: Value| { + Ok(Json(NodeBatch { ids, patch: serde_json::from_value(patch).unwrap() })) + }; + let group = |id| app.db.node(id).unwrap().unwrap().group; + + let r = + update_nodes(Admin, state(), batch(vec![a, b, a], json!({"group": " 香港 ", "notify": true}))) + .await; + assert_eq!(r.status(), StatusCode::OK); + assert_eq!((group(a), group(b), group(c)), ("香港".into(), "香港".into(), String::new())); + assert!(app.db.node(b).unwrap().unwrap().notify); + + // One id gone: nothing is written, not even to the nodes still there. + let r = update_nodes(Admin, state(), batch(vec![a, 999], json!({"group": "东京"}))).await; + assert_eq!(r.status(), StatusCode::NOT_FOUND); + assert_eq!(group(a), "香港", "a refused batch leaves every node as it was"); + + for refused in + [json!({"name": "x"}), json!({"ipv4_pin": "1.2.3.4"}), json!({"group": "g".repeat(33)})] + { + let r = update_nodes(Admin, state(), batch(vec![a], refused.clone())).await; + assert_eq!(r.status(), StatusCode::BAD_REQUEST, "{refused}"); + } + assert_eq!( + update_nodes(Admin, state(), batch(vec![], json!({"public": false}))).await.status(), + StatusCode::BAD_REQUEST + ); + + assert!(live_snapshot(&app, false).as_str().contains(r#""group":"香港""#), "the group is public"); + + let r = delete_nodes(Admin, state(), Json(NodeIds { ids: vec![a, 999] })).await; + assert_eq!(r.status(), StatusCode::OK); + assert!(app.db.node(a).unwrap().is_none() && app.db.node(b).unwrap().is_some()); + } + /// A write naming a node that no longer exists, such as one deleted from /// another tab, is refused rather than reported as saved. #[tokio::test] diff --git a/src/db.rs b/src/db.rs index 260fd7e7..12e148fd 100644 --- a/src/db.rs +++ b/src/db.rs @@ -65,6 +65,9 @@ CREATE TABLE IF NOT EXISTS node ( -- Set in the panel. When not empty it is the country shown, in place of the -- looked-up one, which goes on updating underneath. country_pin TEXT NOT NULL DEFAULT '', + -- Set in the panel and shown on the status page, where a theme may divide the + -- node list by it. Empty is ungrouped. Not `group`, a reserved word. + group_name TEXT NOT NULL DEFAULT '', -- Set in the panel, each replacing the address shown for its family. Empty -- means automatic. Panel only, like the reported addresses. ipv4_pin TEXT NOT NULL DEFAULT '', ipv6_pin TEXT NOT NULL DEFAULT '', @@ -149,7 +152,7 @@ CREATE TABLE IF NOT EXISTS session ( /// cannot: `open` runs `SCHEMA` before migrating, and on an older file the /// column is not there yet. `an_upgraded_release_matches_a_fresh_database` /// holds every migration to these rules, starting from v1.0.0's schema. -const SCHEMA_VERSION: i64 = 6; +const SCHEMA_VERSION: i64 = 7; /// Adds a column older databases lack. A duplicate column indicates the /// migration has already run; every other error must propagate. @@ -266,6 +269,10 @@ fn migrate_to_6(conn: &Connection) -> Result<()> { add_column(conn, "ping_task", "auto_join INTEGER NOT NULL DEFAULT 0") } +fn migrate_to_7(conn: &Connection) -> Result<()> { + add_column(conn, "node", "group_name TEXT NOT NULL DEFAULT ''") +} + /// Brings a database already in service up to `SCHEMA_VERSION` and stamps it. /// `from` is its current version, so a fresh file passes `SCHEMA_VERSION` and /// receives only the stamp. @@ -297,6 +304,9 @@ fn migrate(conn: &Connection, from: i64) -> Result<()> { if from < 6 { migrate_to_6(&tx)?; } + if from < 7 { + migrate_to_7(&tx)?; + } tx.execute_batch(&format!("PRAGMA user_version = {SCHEMA_VERSION}"))?; tx.commit()?; Ok(()) @@ -373,6 +383,9 @@ pub struct Node { /// `country`. What the status page shows is this when present. #[serde(default)] pub country_pin: String, + /// Set in the panel; empty is ungrouped. Public, like the name. + #[serde(default)] + pub group: String, /// Set in the panel, in canonical form, for what neither agent nor hub can /// know: the home line behind a transparent proxy, or which of several public /// addresses to show. Each replaces the address shown for its family; empty @@ -419,6 +432,7 @@ pub struct NodePatch { pub country_pin: Option, pub ipv4_pin: Option, pub ipv6_pin: Option, + pub group: Option, } fn expiry_patch<'de, D: serde::Deserializer<'de>>(d: D) -> Result>, D::Error> { @@ -621,8 +635,9 @@ impl Db { // A new node belongs at the end. The caller sends sort 0, which would // tie with whatever the last reorder placed first. "INSERT INTO node (name, token, sort, public, price, currency, billing_cycle, - expires_at, remark, traffic_limit, traffic_mode, traffic_reset_day, created_at) - VALUES (?1,?2,(SELECT COALESCE(MAX(sort),-1)+1 FROM node),?3,?4,?5,?6,?7,?8,?9,?10,?11,?12)", + expires_at, remark, traffic_limit, traffic_mode, traffic_reset_day, created_at, + group_name) + VALUES (?1,?2,(SELECT COALESCE(MAX(sort),-1)+1 FROM node),?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13)", params![ n.name, token, @@ -635,7 +650,8 @@ impl Db { n.traffic_limit, n.traffic_mode, n.traffic_reset_day, - Utc::now().timestamp() + Utc::now().timestamp(), + n.group ], )?; let id = tx.last_insert_rowid(); @@ -663,38 +679,59 @@ impl Db { /// False when no node has this id. pub fn update_node(&self, id: i64, n: &NodePatch) -> Result { - let found = self.conn().execute( - "UPDATE node SET name=COALESCE(?2,name), sort=COALESCE(?3,sort), public=COALESCE(?4,public), - price=COALESCE(?5,price), currency=COALESCE(?6,currency), - billing_cycle=COALESCE(?7,billing_cycle), - expires_at=CASE WHEN ?8 THEN ?9 ELSE expires_at END, - remark=COALESCE(?10,remark), traffic_limit=COALESCE(?11,traffic_limit), - traffic_mode=COALESCE(?12,traffic_mode), - traffic_reset_day=COALESCE(?13,traffic_reset_day), - notify=COALESCE(?14,notify), country_pin=COALESCE(?15,country_pin), - ipv4_pin=COALESCE(?16,ipv4_pin), ipv6_pin=COALESCE(?17,ipv6_pin) - WHERE id=?1", - params![ - id, - n.name, - n.sort, - n.public, - n.price, - n.currency, - n.billing_cycle, - n.expires_at.is_some(), - n.expires_at.as_ref().and_then(|v| v.as_deref()), - n.remark, - n.traffic_limit, - n.traffic_mode, - n.traffic_reset_day, - n.notify, - n.country_pin, - n.ipv4_pin, - n.ipv6_pin - ], - )?; - Ok(found > 0) + self.update_nodes(&[id], n) + } + + /// Applies one patch to every node in `ids` in a single transaction. False, + /// with nothing written, when any of them no longer exists: a batch applied + /// to part of what was selected would leave the panel to work out which part. + pub fn update_nodes(&self, ids: &[i64], n: &NodePatch) -> Result { + let mut conn = self.conn(); + let tx = conn.transaction()?; + { + let mut update = tx.prepare( + "UPDATE node SET name=COALESCE(?2,name), sort=COALESCE(?3,sort), public=COALESCE(?4,public), + price=COALESCE(?5,price), currency=COALESCE(?6,currency), + billing_cycle=COALESCE(?7,billing_cycle), + expires_at=CASE WHEN ?8 THEN ?9 ELSE expires_at END, + remark=COALESCE(?10,remark), traffic_limit=COALESCE(?11,traffic_limit), + traffic_mode=COALESCE(?12,traffic_mode), + traffic_reset_day=COALESCE(?13,traffic_reset_day), + notify=COALESCE(?14,notify), country_pin=COALESCE(?15,country_pin), + ipv4_pin=COALESCE(?16,ipv4_pin), ipv6_pin=COALESCE(?17,ipv6_pin), + group_name=COALESCE(?18,group_name) + WHERE id=?1", + )?; + for id in ids { + let found = update.execute(params![ + id, + n.name, + n.sort, + n.public, + n.price, + n.currency, + n.billing_cycle, + n.expires_at.is_some(), + n.expires_at.as_ref().and_then(|v| v.as_deref()), + n.remark, + n.traffic_limit, + n.traffic_mode, + n.traffic_reset_day, + n.notify, + n.country_pin, + n.ipv4_pin, + n.ipv6_pin, + n.group + ])?; + // Dropping the transaction uncommitted rolls back the nodes + // already updated. + if found == 0 { + return Ok(false); + } + } + } + tx.commit()?; + Ok(true) } pub fn set_expiry(&self, id: i64, date: &str) -> Result<()> { @@ -729,13 +766,30 @@ impl Db { /// False when no node has this id. pub fn delete_node(&self, id: i64) -> Result { - let conn = self.conn(); - // `ping_record` carries no foreign key -- it is WITHOUT ROWID and keyed - // for the chart query -- so it is cleared explicitly. SQLite reassigns a - // deleted node's id to the next node created, which would otherwise - // inherit the removed machine's latency chart. - conn.execute("DELETE FROM ping_record WHERE node_id = ?1", [id])?; - Ok(conn.execute("DELETE FROM node WHERE id = ?1", [id])? > 0) + Ok(self.delete_nodes(&[id])? > 0) + } + + /// Deletes every node in `ids` and returns how many existed. An id already + /// gone is not an error: the caller wanted it gone. + /// + /// One transaction per node, with the connection released between them. + /// Clearing a node's latency history -- 100,800 rows at a week of ten probes + /// a minute -- measured 50 to 80 ms, so one transaction over a hundred nodes + /// would hold every agent's report back for about six seconds. + pub fn delete_nodes(&self, ids: &[i64]) -> Result { + let mut deleted = 0; + for id in ids { + let mut conn = self.conn(); + let tx = conn.transaction()?; + // `ping_record` carries no foreign key -- it is WITHOUT ROWID and + // keyed for the chart query -- so it is cleared explicitly. SQLite + // reassigns a deleted node's id to the next node created, which + // would otherwise inherit the removed machine's latency chart. + tx.execute("DELETE FROM ping_record WHERE node_id = ?1", [id])?; + deleted += tx.execute("DELETE FROM node WHERE id = ?1", [id])?; + tx.commit()?; + } + Ok(deleted) } /// Replaces a node's token, which immediately locks out the old one. False @@ -1231,7 +1285,7 @@ impl Db { /// Deletes a probe and the results filed under it. /// /// `ping_record` carries no foreign key -- it is WITHOUT ROWID and keyed for - /// the chart query -- so it is cleared explicitly, as in `delete_node`. + /// the chart query -- so it is cleared explicitly, as in `delete_nodes`. /// SQLite reassigns a deleted probe's id to the next one created, and the /// chart selects on `task_id IN (assignments for this node)`: without this /// the new probe would draw the removed one's latency under its own name, @@ -1738,6 +1792,7 @@ fn row_to_node(r: &rusqlite::Row<'_>) -> Node { ipv6: s("ipv6"), country: s("country"), country_pin: s("country_pin"), + group: s("group_name"), ipv4_pin: s("ipv4_pin"), ipv6_pin: s("ipv6_pin"), last_seen: n("last_seen"), diff --git a/src/main.rs b/src/main.rs index 807dbb66..49122629 100644 --- a/src/main.rs +++ b/src/main.rs @@ -451,6 +451,8 @@ async fn main() -> Result<()> { .route("/api/nodes", post(api::create_node)) .route("/api/register-window", post(api::open_register).delete(api::close_register)) .route("/api/nodes/order", put(api::reorder_nodes)) + .route("/api/nodes/batch", put(api::update_nodes)) + .route("/api/nodes/delete", post(api::delete_nodes)) .route("/api/nodes/{id}", put(api::update_node).delete(api::delete_node)) .route("/api/nodes/{id}/token", post(api::reset_token)) .route("/api/nodes/{id}/traffic", put(api::patch_traffic)) diff --git a/web-admin/src/components/Admin.tsx b/web-admin/src/components/Admin.tsx index a25162ea..c8f85eef 100644 --- a/web-admin/src/components/Admin.tsx +++ b/web-admin/src/components/Admin.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react" +import { useCallback, useEffect, useId, useRef, useState } from "react" import { flushSync } from "react-dom" import { ArrowUpCircle, Bell, CalendarClock, ChevronRight, Copy, Database, Download, GripVertical, Palette, Pencil, Plus, Radio, RefreshCw, Search, Send, Server, Settings, Shield, Trash2, Upload } from "lucide-react" import { toast } from "sonner" @@ -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, badIfaceName, behind, changes, currentIface, GIB, ifaceChoice, ifaceSpec, outdatedAgents, provisioningSite, trafficCorrection, upload, type IfaceChoice, type Node, type PingTask, type Source } from "@/lib/api" +import { addresses, api, badIfaceName, behind, changes, groupsOf, inGroup, currentIface, GIB, ifaceChoice, ifaceSpec, outdatedAgents, 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. @@ -74,26 +74,56 @@ function Addresses({ node }: { node: Node }) { ) } -// Name, address and country: what a node is looked up by, in every node list. +// Name, address, country and group: what a node is looked up by, in every node list. function searchNodes(nodes: Node[], query: string) { const needle = query.trim().toLowerCase() if (!needle) return nodes return nodes.filter((n) => - [n.name, n.ip, n.ipv4, n.ipv6, n.ipv4_pin, n.ipv6_pin, n.country].some((v) => v?.toLowerCase().includes(needle))) + [n.name, n.ip, n.ipv4, n.ipv6, n.ipv4_pin, n.ipv6_pin, n.country, n.group].some((v) => v?.toLowerCase().includes(needle))) +} + +// A filter naming a group no node carries any more -- renamed, or its last node +// deleted -- falls back to all rather than showing an empty list. +function useGroupFilter(nodes: Node[]) { + const [filter, setFilter] = useState("all") + const valid = filter === "all" || filter === "none" || nodes.some((n) => n.group === filter.slice(1)) + return [valid ? filter : "all", setFilter] as const +} + +// Offered once some node has a group. 未分组 is where a batch of freshly +// registered machines waits to be assigned one. +function GroupFilter({ nodes, value, onChange, className = "" }: { + nodes: Node[] + value: string + onChange: (value: string) => void + className?: string +}) { + const groups = groupsOf(nodes) + if (!groups.length) return null + return ( + + ) } function NodeSearch({ value, onChange, className = "" }: { value: string; onChange: (value: string) => void; className?: string }) { return (
- onChange(e.target.value)} /> + onChange(e.target.value)} />
) } // Ticks nodes in a searchable grid. 全选 and 全不选 act on the rows in view, so a -// search narrows what they touch: search JP, then 全选. Offline nodes are dimmed -// but remain selectable. +// search or a group narrows what they touch: pick a group, then 全选. Offline +// nodes are dimmed but remain selectable. function NodePicker({ nodes, chosen, onPick, disabled = false }: { nodes: Node[] chosen: Set @@ -101,15 +131,17 @@ function NodePicker({ nodes, chosen, onPick, disabled = false }: { disabled?: boolean }) { const [query, setQuery] = useState("") + const [group, setGroup] = useGroupFilter(nodes) // The unfiltered list's height, held as its floor: in a centred dialog a // shrinking list would move the search box out from under the cursor. const [listHeight, setListHeight] = useState(0) - const visible = searchNodes(nodes, query) + const visible = inGroup(searchNodes(nodes, query), group) const visibleChosen = visible.filter((n) => chosen.has(n.id)).length return (
-
- +
+ +
@@ -183,6 +215,52 @@ function OptionRow({ title, hint, toggle = false, below, children }: { } +// Up to five names, then the count: enough to confirm the selection is the +// intended one. +function sample(nodes: Node[]) { + return nodes.slice(0, 5).map((n) => n.name).join("、") + (nodes.length > 5 ? ` 等 ${nodes.length} 台` : "") +} + +// Free text, with the groups already in use offered, so a group is picked +// rather than retyped, where a typo would start a second one. +function GroupInput({ groups, value, onChange }: { groups: string[]; value: string; onChange: (value: string) => void }) { + const id = useId() + return ( + <> + onChange(e.target.value)} placeholder="未分组" /> + + {groups.map((g) => + + ) +} + +// Deleting a node leaves the agent running on the machine, retrying with a +// token the hub no longer accepts. The command carries nothing per machine. +function UninstallHint({ command, many = false }: { command: string; many?: boolean }) { + if (!command) return null + return ( +
+
+ + +
+ {command} +

+ {many ? "每台机器上以 root 各执行一次,命令都一样" : "在这台机器上以 root 执行"} + ,停止 agent,删除二进制、env 文件和服务文件。 + {many && ( + + 批量执行的做法 + + )} +

+
+ ) +} + function ConfirmDialog({ title, description, confirmLabel, busy = false, onClose, onConfirm, children }: { title: string description: string @@ -255,8 +333,9 @@ function CreateNode({ onClose, onSaved }: { ) } -function NodeForm({ node, onClose, onSaved }: { +function NodeForm({ node, groups, onClose, onSaved }: { node: Node + groups: string[] onClose: () => void onSaved: () => void }) { @@ -281,6 +360,7 @@ function NodeForm({ node, onClose, onSaved }: { name: form.name.trim(), public: form.public, remark: form.remark, + 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(form.traffic_reset_day) || 1))), @@ -328,8 +408,11 @@ function NodeForm({ node, onClose, onSaved }: { set("name", e.target.value)} /> - - set("remark", e.target.value)} placeholder="商家、用途,仅管理员可见" /> + + set("group", v)} /> + + + set("remark", e.target.value)} placeholder="仅管理员可见" />
@@ -500,6 +583,171 @@ function BillingForm({ node, onClose, onSaved }: { ) } +// A field of the batch form. Ticking it is what puts it in the patch, and the +// control stays disabled until then, so nothing edited is silently left out. +function BatchField({ label, hint, on, onToggle, children }: { + label: string + hint?: string + on: boolean + onToggle: (on: boolean) => void + children: React.ReactNode +}) { + return ( +
+ +
{children}
+ {hint &&

{hint}

} +
+ ) +} + +// One patch for every selected node, applied by the hub to all of them or to +// none. Only ticked fields are sent, so what differs between the nodes -- a +// price, an expiry -- stays as it is unless deliberately made the same. Name, +// note and addresses describe one machine and are not offered. +function BatchEdit({ nodes, groups, onClose, onSaved }: { + nodes: Node[] + groups: string[] + onClose: () => void + onSaved: () => void +}) { + const [on, setOn] = useState>({}) + const [form, setForm] = useState({ + group: "", + public: true, + notify: true, + limit: "", + traffic_mode: "sum", + reset: "1", + price: "", + currency: "USD", + billing_cycle: "monthly", + expires_at: "", + }) + const [saving, setSaving] = useState(false) + const set = (k: K, v: (typeof form)[K]) => setForm((f) => ({ ...f, [k]: v })) + // Keyed by the patch field each box controls. + const tick = (key: string) => ({ on: !!on[key], onToggle: (v: boolean) => setOn((o) => ({ ...o, [key]: v })) }) + + async function save() { + const limit = Math.round(Number(form.limit) * GIB) + if (on.traffic_limit && (!Number.isSafeInteger(limit) || limit < 0)) { + return toast.error("流量额度必须是有效的非负数") + } + const patch = Object.fromEntries( + Object.entries({ + group: form.group.trim(), + public: form.public, + notify: form.notify, + traffic_limit: limit, + traffic_mode: form.traffic_mode, + traffic_reset_day: Math.min(31, Math.max(1, Math.round(Number(form.reset) || 1))), + price: Math.max(0, Number(form.price) || 0), + currency: form.currency, + billing_cycle: form.billing_cycle, + expires_at: form.expires_at || null, + }).filter(([key]) => on[key]), + ) + if (!Object.keys(patch).length) return toast.error("先勾选要修改的项") + setSaving(true) + try { + await api("/nodes/batch", { method: "PUT", body: JSON.stringify({ ids: nodes.map((n) => n.id), patch }) }) + toast.success(`已修改 ${nodes.length} 台`) + onClose() + onSaved() + } catch (e) { + toast.error((e as Error).message) + } finally { + setSaving(false) + } + } + + return ( + !open && onClose()}> + e.preventDefault()} className="sm:max-w-2xl"> + + 批量修改 {nodes.length} 台节点 + + {sample(nodes)}。只改勾选的项,没勾选的保持各自原样。 + + +
+
+ + set("group", v)} /> + + + + + + + +
+
+

流量

+
+ + set("limit", e.target.value)} placeholder="1024" /> + + + + + + set("reset", e.target.value)} /> + +
+
+
+

计费

+
+ + set("price", e.target.value)} placeholder="免费" /> + + + + + + + + + set("expires_at", e.target.value)} /> + +
+
+
+ + + + +
+
+ ) +} + // Each command runs the hub's own install.sh and is offered only on an https // domain entry. `args` receives that entry, which the agent is also given as // --server. @@ -845,6 +1093,10 @@ function Nodes({ nodes, refresh, site, refusal }: { nodes: Node[]; refresh: () = const [removing, setRemoving] = useState(false) const [manualOrder, setManualOrder] = useState([]) const [query, setQuery] = useState("") + const [group, setGroup] = useGroupFilter(nodes) + const [selected, setSelected] = useState>(new Set()) + const [batchEditing, setBatchEditing] = useState(false) + const [batchDeleting, setBatchDeleting] = useState(false) const [dragging, setDragging] = useState(null) const orderBeforeDrag = useRef([]) const byId = new Map(nodes.map((node) => [node.id, node])) @@ -855,8 +1107,41 @@ function Nodes({ nodes, refresh, site, refusal }: { nodes: Node[]; refresh: () = ] // `order` itself stays whole, because the order sent on drop is the order of // every node. - const visible = searchNodes(order, query) - const searching = query.trim() !== "" + const visible = inGroup(searchNodes(order, query), group) + const searching = query.trim() !== "" || group !== "all" + // Read from the live list, so a node deleted elsewhere drops out of the + // selection. A selection outlives a change of filter; the bar says how much of + // it is out of view, so nothing hidden is edited unawares. + const chosen = order.filter((n) => selected.has(n.id)) + const hiddenChosen = chosen.filter((n) => !visible.includes(n)).length + const visibleChosen = visible.filter((n) => selected.has(n.id)).length + const pick = (list: Node[], on: boolean) => + setSelected((old) => { + const next = new Set(old) + for (const n of list) { + if (on) next.add(n.id) + else next.delete(n.id) + } + return next + }) + + async function removeChosen() { + setRemoving(true) + try { + const { deleted } = await api<{ deleted: number }>("/nodes/delete", { + method: "POST", + body: JSON.stringify({ ids: chosen.map((n) => n.id) }), + }) + toast.success(`已删除 ${deleted} 台`) + setBatchDeleting(false) + setSelected(new Set()) + refresh() + } catch (e) { + toast.error((e as Error).message) + } finally { + setRemoving(false) + } + } const uninstall = refusal ? "" : uninstallCommand(site) async function remove() { @@ -906,7 +1191,10 @@ function Nodes({ nodes, refresh, site, refusal }: { nodes: Node[]; refresh: () =
{refusal &&

{refusal}

}
- +
+ + +
{/* An open window is visible from the list itself, so nobody has to remember they left one open. */}
+ {chosen.length > 0 && ( +
+ + 已选 {chosen.length} 台 + {hiddenChosen > 0 && ,其中 {hiddenChosen} 台不在当前筛选里} + + + + +
+ )} + {/* Percentages, or the address column swallows every spare pixel and pushes status across the table. */} + + {/* Acts on the rows in view, like 全选 in the node picker. */} + 0 && visibleChosen === visible.length} + ref={(el) => { if (el) el.indeterminate = visibleChosen > 0 && visibleChosen < visible.length }} + onChange={(e) => pick(visible, e.target.checked)} + /> + 名称 IP 状态 @@ -943,6 +1255,15 @@ function Nodes({ nodes, refresh, site, refusal }: { nodes: Node[]; refresh: () = onDragEnter={() => dragging !== null && move(order.findIndex((node) => node.id === dragging), index)} onDrop={(e) => { e.preventDefault(); save(order.map((node) => node.id)) }} > + + pick([n], e.target.checked)} + /> +
-
{n.name}
+
+
{n.name}
+ {n.group &&
{n.group}
} +
{n.country && ( - + 还没有节点,右上角添加 )} {searching && nodes.length > 0 && !visible.length && ( - + 没有匹配的节点 @@ -1058,10 +1382,31 @@ function Nodes({ nodes, refresh, site, refusal }: { nodes: Node[]; refresh: () = {editing && ( setEditing(null)} onSaved={refresh} /> )} + {batchEditing && ( + setBatchEditing(false)} + onSaved={() => { setSelected(new Set()); refresh() }} + /> + )} + {batchDeleting && ( + setBatchDeleting(false)} + onConfirm={removeChosen} + > + + + )} {billing && ( setBilling(null)} onSaved={refresh} /> )} @@ -1084,22 +1429,7 @@ function Nodes({ nodes, refresh, site, refusal }: { nodes: Node[]; refresh: () = onClose={() => setDeleting(null)} onConfirm={remove} > - {/* Deleting the node leaves the agent running on the machine, retrying - with a token the hub no longer accepts. */} - {uninstall && ( -
-
- - -
- {uninstall} -

- 在这台机器上以 root 执行,停止 agent,删除二进制、env 文件和服务文件。 -

-
- )} + )}
@@ -1670,15 +2000,11 @@ function OfflineNodes({ nodes, refresh }: { nodes: Node[]; refresh: () => void } const [busy, setBusy] = useState(false) async function apply(targets: Node[], on: boolean) { + const ids = targets.filter((n) => !!n.notify !== on).map((n) => n.id) + if (!ids.length) return setBusy(true) try { - // Awaited in turn, the requests would cost one round trip per node, and - // the two-second stream would render each one as it lands. - await Promise.all( - targets - .filter((n) => !!n.notify !== on) - .map((n) => api(`/nodes/${n.id}`, { method: "PUT", body: JSON.stringify({ notify: on }) })), - ) + await api("/nodes/batch", { method: "PUT", body: JSON.stringify({ ids, patch: { notify: on } }) }) } catch (e) { toast.error((e as Error).message) } finally { diff --git a/web-admin/src/lib/api.test.ts b/web-admin/src/lib/api.test.ts index 03c6c8f1..91c4da4c 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 { addresses, badIfaceName, behind, changes, currentIface, GIB, ifaceChoice, ifaceSpec, isPublic, loopbackOrigin, outdatedAgents, provisionRefusal, provisioningSite, trafficCorrection } from "./api.ts" +import { addresses, badIfaceName, behind, changes, groupsOf, inGroup, currentIface, GIB, ifaceChoice, ifaceSpec, isPublic, loopbackOrigin, outdatedAgents, provisionRefusal, provisioningSite, 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" }) @@ -108,3 +108,12 @@ assert.equal(ifaceSpec(ifaceChoice("enp1s0,-enp5s0")), "enp1s0,-enp5s0") assert.equal(currentIface({ metrics: null }), undefined) assert.equal(currentIface({ metrics: {} as never }), "") assert.equal(currentIface({ metrics: { iface: "eth1,-eth0" } as never }), "eth1,-eth0") + +// Groups follow the node order, and the filter keeps ungrouped nodes apart from +// a group whose name merely resembles a sentinel. +const fleet2 = [{ group: "东京" }, { group: "" }, { group: "none" }, { group: "东京" }, {}] +assert.deepEqual(groupsOf(fleet2), ["东京", "none"]) +assert.equal(inGroup(fleet2, "all").length, 5) +assert.equal(inGroup(fleet2, "none").length, 2) +assert.deepEqual(inGroup(fleet2, "=none"), [{ group: "none" }]) +assert.equal(inGroup(fleet2, "=东京").length, 2) diff --git a/web-admin/src/lib/api.ts b/web-admin/src/lib/api.ts index db2531a4..4d56ea55 100644 --- a/web-admin/src/lib/api.ts +++ b/web-admin/src/lib/api.ts @@ -59,6 +59,8 @@ export type Node = { hostname?: string /** ISO 3166-1 alpha-2 as shown: the one set by hand, else the one looked up from the node's address. */ country: string + /** Set by hand and public; empty is ungrouped. Absent from a hub predating groups. */ + group?: string /** Panel only. Set by hand; empty is automatic. */ country_pin?: string /** Panel only. The looked-up country, which a pin hides. */ @@ -78,6 +80,18 @@ export type Node = { export type PingTask = { id: number; name: string; target: string; interval: number; nodes: number[]; auto_join: boolean } +/** Every group in use, in the order of the first node carrying it: the node order decides the group order. */ +export function groupsOf(nodes: Pick[]): string[] { + return [...new Set(nodes.map((n) => n.group ?? "").filter(Boolean))] +} + +/** A group filter as its dropdown holds it: "all", "none" for the ungrouped, or "=" and a group's name. */ +export function inGroup>(nodes: T[], filter: string): T[] { + if (filter === "all") return nodes + const group = filter === "none" ? "" : filter.slice(1) + return nodes.filter((n) => (n.group ?? "") === group) +} + /** Form snapshots must never overwrite fields the user did not edit. */ export function changes(initial: T, values: Partial): Partial { return Object.fromEntries(Object.entries(values).filter(([key, value]) => value !== initial[key as keyof T])) as Partial From 1e9f4b0fe2f67151766a8c101819e43a5b33e4a4 Mon Sep 17 00:00:00 2001 From: stqfdyr <89149493+stqfdyr@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:52:49 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=E5=88=86=E7=BB=84=E6=94=B9?= =?UTF-8?q?=E7=94=A8=E5=AF=B9=E8=AF=9D=E6=A1=86=E8=AE=BE=E7=BD=AE=EF=BC=8C?= =?UTF-8?q?=E5=8E=BB=E6=8E=89=E6=89=B9=E9=87=8F=E4=BF=AE=E6=94=B9=E4=B8=8E?= =?UTF-8?q?=E6=89=B9=E9=87=8F=E5=88=A0=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 节点列表不再有勾选列和操作条。工具栏的「分组」打开对话框:填分组名,在节点选择器里勾选 (可按搜索与分组筛选后全选),一次设好;名字留空即移出分组,改名、解散同样由此完成。 选择器的悬停提示带上节点当前的分组。 批量修改价格、周期、流量等的对话框与批量删除一并去掉,POST /api/nodes/delete 删除,删除 节点回到单台接口。PUT /api/nodes/batch 保留,供分组对话框与离线通知使用。 --- src/api.rs | 55 +---- src/db.rs | 33 +-- src/main.rs | 1 - web-admin/src/components/Admin.tsx | 370 +++++++---------------------- 4 files changed, 101 insertions(+), 358 deletions(-) diff --git a/src/api.rs b/src/api.rs index 9a65216d..21cb0bba 100644 --- a/src/api.rs +++ b/src/api.rs @@ -866,14 +866,14 @@ pub async fn update_nodes( } #[derive(Deserialize)] -pub struct NodeIds { +pub struct NodeOrder { ids: Vec, } /// The list must name every node exactly once, checked inside the transaction /// that renumbers rather than here: re-reading the node list first would only /// race the write it guards. -pub async fn reorder_nodes(_: Admin, State(app): State, Json(order): Json) -> Response { +pub async fn reorder_nodes(_: Admin, State(app): State, Json(order): Json) -> Response { match app.db.reorder_nodes(&order.ids) { Ok(()) => { invalidate_snapshot(&app); @@ -890,48 +890,17 @@ pub async fn delete_node(_: Admin, State(app): State, Path(id): Path return no_such_node(), Err(e) => return fail(e), } - disconnect(&app, &[id]); + // The token is checked only at the handshake, so deleting the row does not + // end a connection already open on it; dropping the sender does. Without + // this the agent would keep reporting under an id SQLite reassigns to the + // next node created, which would then appear online on another node's + // metrics. Dropped after the delete, so the reconnect that follows finds no + // token to accept. The same reasoning applies in `reset_token` below. + app.agents.write().unwrap_or_else(|e| e.into_inner()).remove(&id); + invalidate_snapshot(&app); Json(json!({"ok": true})).into_response() } -/// Deletes the selected nodes. Ids already gone are skipped rather than -/// refused, so a list read before another tab deleted some still succeeds. -/// Off the runtime, since each node's latency history takes tens of ms to clear. -pub async fn delete_nodes( - _: Admin, - State(app): State, - Json(NodeIds { ids }): Json, -) -> Response { - if ids.is_empty() { - return bad("no nodes selected"); - } - let (shared, list) = (app.clone(), ids.clone()); - match tokio::task::spawn_blocking(move || shared.db.delete_nodes(&list)).await { - Ok(Ok(deleted)) => { - disconnect(&app, &ids); - Json(json!({"deleted": deleted})).into_response() - } - Ok(Err(e)) => fail(e), - Err(e) => fail(e), - } -} - -/// Ends the sessions of deleted nodes. The token is checked only at the -/// handshake, so deleting the row does not end a connection already open on -/// it; dropping the sender does. Without this the agent would keep reporting -/// under an id SQLite reassigns to the next node created, which would then -/// appear online on another node's metrics. Dropped after the delete, so the -/// reconnect that follows finds no token to accept. The same reasoning applies -/// in `reset_token` below. -fn disconnect(app: &App, ids: &[i64]) { - let mut agents = app.agents.write().unwrap_or_else(|e| e.into_inner()); - for id in ids { - agents.remove(id); - } - drop(agents); - invalidate_snapshot(app); -} - /// Issues a fresh token, invalidating the old one immediately. /// /// Always an explicit action: rotate a token believed to have leaked, then @@ -2383,10 +2352,6 @@ mod tests { ); assert!(live_snapshot(&app, false).as_str().contains(r#""group":"香港""#), "the group is public"); - - let r = delete_nodes(Admin, state(), Json(NodeIds { ids: vec![a, 999] })).await; - assert_eq!(r.status(), StatusCode::OK); - assert!(app.db.node(a).unwrap().is_none() && app.db.node(b).unwrap().is_some()); } /// A write naming a node that no longer exists, such as one deleted from diff --git a/src/db.rs b/src/db.rs index 12e148fd..9cdcba2e 100644 --- a/src/db.rs +++ b/src/db.rs @@ -766,30 +766,13 @@ impl Db { /// False when no node has this id. pub fn delete_node(&self, id: i64) -> Result { - Ok(self.delete_nodes(&[id])? > 0) - } - - /// Deletes every node in `ids` and returns how many existed. An id already - /// gone is not an error: the caller wanted it gone. - /// - /// One transaction per node, with the connection released between them. - /// Clearing a node's latency history -- 100,800 rows at a week of ten probes - /// a minute -- measured 50 to 80 ms, so one transaction over a hundred nodes - /// would hold every agent's report back for about six seconds. - pub fn delete_nodes(&self, ids: &[i64]) -> Result { - let mut deleted = 0; - for id in ids { - let mut conn = self.conn(); - let tx = conn.transaction()?; - // `ping_record` carries no foreign key -- it is WITHOUT ROWID and - // keyed for the chart query -- so it is cleared explicitly. SQLite - // reassigns a deleted node's id to the next node created, which - // would otherwise inherit the removed machine's latency chart. - tx.execute("DELETE FROM ping_record WHERE node_id = ?1", [id])?; - deleted += tx.execute("DELETE FROM node WHERE id = ?1", [id])?; - tx.commit()?; - } - Ok(deleted) + let conn = self.conn(); + // `ping_record` carries no foreign key -- it is WITHOUT ROWID and keyed + // for the chart query -- so it is cleared explicitly. SQLite reassigns a + // deleted node's id to the next node created, which would otherwise + // inherit the removed machine's latency chart. + conn.execute("DELETE FROM ping_record WHERE node_id = ?1", [id])?; + Ok(conn.execute("DELETE FROM node WHERE id = ?1", [id])? > 0) } /// Replaces a node's token, which immediately locks out the old one. False @@ -1285,7 +1268,7 @@ impl Db { /// Deletes a probe and the results filed under it. /// /// `ping_record` carries no foreign key -- it is WITHOUT ROWID and keyed for - /// the chart query -- so it is cleared explicitly, as in `delete_nodes`. + /// the chart query -- so it is cleared explicitly, as in `delete_node`. /// SQLite reassigns a deleted probe's id to the next one created, and the /// chart selects on `task_id IN (assignments for this node)`: without this /// the new probe would draw the removed one's latency under its own name, diff --git a/src/main.rs b/src/main.rs index 49122629..c9539e25 100644 --- a/src/main.rs +++ b/src/main.rs @@ -452,7 +452,6 @@ async fn main() -> Result<()> { .route("/api/register-window", post(api::open_register).delete(api::close_register)) .route("/api/nodes/order", put(api::reorder_nodes)) .route("/api/nodes/batch", put(api::update_nodes)) - .route("/api/nodes/delete", post(api::delete_nodes)) .route("/api/nodes/{id}", put(api::update_node).delete(api::delete_node)) .route("/api/nodes/{id}/token", post(api::reset_token)) .route("/api/nodes/{id}/traffic", put(api::patch_traffic)) diff --git a/web-admin/src/components/Admin.tsx b/web-admin/src/components/Admin.tsx index c8f85eef..c8ce3dca 100644 --- a/web-admin/src/components/Admin.tsx +++ b/web-admin/src/components/Admin.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useId, useRef, useState } from "react" import { flushSync } from "react-dom" -import { ArrowUpCircle, Bell, CalendarClock, ChevronRight, Copy, Database, Download, GripVertical, Palette, Pencil, Plus, Radio, RefreshCw, Search, Send, Server, Settings, Shield, Trash2, Upload } from "lucide-react" +import { ArrowUpCircle, Bell, CalendarClock, ChevronRight, Copy, Database, Download, GripVertical, Layers, Palette, Pencil, Plus, Radio, RefreshCw, Search, Send, Server, Settings, Shield, Trash2, Upload } from "lucide-react" import { toast } from "sonner" import { Badge } from "@/components/ui/badge" @@ -154,7 +154,7 @@ function NodePicker({ nodes, chosen, onPick, disabled = false }: { 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" > {visible.map((n) => ( -
{/* Percentages, or the address column swallows every spare pixel and pushes status across the table. */} - - {/* Acts on the rows in view, like 全选 in the node picker. */} - 0 && visibleChosen === visible.length} - ref={(el) => { if (el) el.indeterminate = visibleChosen > 0 && visibleChosen < visible.length }} - onChange={(e) => pick(visible, e.target.checked)} - /> - 名称 IP 状态 @@ -1255,15 +1064,6 @@ function Nodes({ nodes, refresh, site, refusal }: { nodes: Node[]; refresh: () = onDragEnter={() => dragging !== null && move(order.findIndex((node) => node.id === dragging), index)} onDrop={(e) => { e.preventDefault(); save(order.map((node) => node.id)) }} > - - pick([n], e.target.checked)} - /> -
+
+ {uninstall} +

+ 在这台机器上以 root 执行,停止 agent,删除二进制、env 文件和服务文件。 +

+ + )} )} From 6918f7f6e2af587f5bf255cbd226e3575d27647d Mon Sep 17 00:00:00 2001 From: stqfdyr <89149493+stqfdyr@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:02:20 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=E7=A6=BB=E7=BA=BF=E9=80=9A=E7=9F=A5?= =?UTF-8?q?=E7=9A=84=E5=8B=BE=E9=80=89=E6=94=B9=E4=B8=BA=E7=82=B9=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E6=89=8D=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原来每次勾选、全选都立刻发请求,勾选框要等返回和刷新才变化。现在勾选只是草稿,卡片上显示 待保存的台数,「保存」时打开、关闭各至多一个批量请求,「撤销」丢弃草稿。保存后草稿保留到 列表报回新状态为止,勾选不会闪回旧值。 --- web-admin/src/components/Admin.tsx | 55 ++++++++++++++++++++++++------ 1 file changed, 44 insertions(+), 11 deletions(-) diff --git a/web-admin/src/components/Admin.tsx b/web-admin/src/components/Admin.tsx index c8ce3dca..ee591063 100644 --- a/web-admin/src/components/Admin.tsx +++ b/web-admin/src/components/Admin.tsx @@ -1791,32 +1791,65 @@ function ChannelCard({ title, configured, children }: { title: string; configure } // Offline alerts are opt-in per node, so turning them on for a fleet needs one -// place rather than one dialog per node. +// place rather than one dialog per node. Ticks are a draft until 保存, like every +// other form in the panel: a request per click made each tick wait on a round +// trip and a refresh before it showed. function OfflineNodes({ nodes, refresh }: { nodes: Node[]; refresh: () => void }) { - const [busy, setBusy] = useState(false) + const [draft, setDraft] = useState | null>(null) + const [saving, setSaving] = useState(false) + const saved = new Set(nodes.filter((n) => n.notify).map((n) => n.id)) + const chosen = draft ?? saved + // Against the live list, so a node deleted meanwhile is neither counted nor sent. + const turnOn = nodes.filter((n) => chosen.has(n.id) && !saved.has(n.id)).map((n) => n.id) + const turnOff = nodes.filter((n) => !chosen.has(n.id) && saved.has(n.id)).map((n) => n.id) + const dirty = turnOn.length + turnOff.length > 0 + + // Kept after a save until the list reports it, so the ticks do not flash back + // to the old state for a round trip; dropped then, so a change made in another + // session shows through. Adjusted during render rather than in an effect, as + // it follows from props alone. + if (draft && !dirty && !saving) setDraft(null) + + const pick = (list: Node[], on: boolean) => + setDraft((old) => { + const next = new Set(old ?? saved) + for (const n of list) { + if (on) next.add(n.id) + else next.delete(n.id) + } + return next + }) - async function apply(targets: Node[], on: boolean) { - const ids = targets.filter((n) => !!n.notify !== on).map((n) => n.id) - if (!ids.length) return - setBusy(true) + async function save() { + setSaving(true) try { - await api("/nodes/batch", { method: "PUT", body: JSON.stringify({ ids, patch: { notify: on } }) }) + for (const [ids, on] of [[turnOn, true], [turnOff, false]] as const) { + if (ids.length) await api("/nodes/batch", { method: "PUT", body: JSON.stringify({ ids, patch: { notify: on } }) }) + } + toast.success("离线通知已保存") } catch (e) { toast.error((e as Error).message) } finally { refresh() - setBusy(false) + setSaving(false) } } - const enabled = new Set(nodes.filter((n) => n.notify).map((n) => n.id)) + const pending = [turnOn.length && `打开 ${turnOn.length} 台`, turnOff.length && `关闭 ${turnOff.length} 台`].filter(Boolean) return (

离线通知

-

按节点打开,默认关。已打开 {enabled.size} / {nodes.length} 台

+

+ 按节点打开,默认关。已打开 {saved.size} / {nodes.length} 台 + {pending.length > 0 && ,待保存:{pending.join("、")}} +

+
+ +
+ +
-
) } From 18dd7985cbf7aa824b211f903f981caf72258cf2 Mon Sep 17 00:00:00 2001 From: stqfdyr <89149493+stqfdyr@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:17:49 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=E6=89=B9=E9=87=8F=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E5=8F=AA=E6=8E=A5=E5=8F=97=20group=20?= =?UTF-8?q?=E4=B8=8E=20notify=EF=BC=8C=E7=A6=BB=E7=BA=BF=E9=80=9A=E7=9F=A5?= =?UTF-8?q?=E8=8D=89=E7=A8=BF=E5=8F=AA=E8=AE=B0=E6=9C=AC=E6=AC=A1=E6=94=B9?= =?UTF-8?q?=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PUT /api/nodes/batch 的 patch 换成白名单结构 BatchPatch(deny_unknown_fields),NodePatch 以后加的字段不会默认可批量写 - 离线通知的草稿只记在这里勾过的节点,不再是整个集合的快照;另一个会话同时打开的节点不会在保存时被关掉 - 分组筛选停在「未分组」而分组全被解散时回到全部,拖动排序不再被锁住;失效的筛选直接重置,同名分组再出现时不会自己跳回去 --- src/api.rs | 43 ++++++++++++++-------------- web-admin/src/components/Admin.tsx | 45 ++++++++++++++++-------------- 2 files changed, 45 insertions(+), 43 deletions(-) diff --git a/src/api.rs b/src/api.rs index 21cb0bba..8a0c2738 100644 --- a/src/api.rs +++ b/src/api.rs @@ -817,39 +817,38 @@ pub async fn update_node( } } +/// What a batch may set: the settings the panel applies across a selection. +/// An allowlist, so a field added to [`NodePatch`] later, possibly one that +/// describes a single machine, is refused here until it is listed. +#[derive(Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub struct BatchPatch { + group: Option, + notify: Option, +} + #[derive(Deserialize)] pub struct NodeBatch { ids: Vec, #[serde(default)] - patch: NodePatch, + patch: BatchPatch, } /// Applies one patch to every selected node, all or none. -/// -/// Limited to settings a batch can share. A name, a note, an address or a -/// country describes one machine: applied to a selection it would read the same -/// on every node, which is never what was meant. The order has its own route. pub async fn update_nodes( _: Admin, State(app): State, body: Result, JsonRejection>, ) -> Response { - let Ok(Json(NodeBatch { mut ids, mut patch })) = body else { return bad("invalid batch") }; + let Ok(Json(NodeBatch { mut ids, patch })) = body else { + return bad("invalid batch: only group and notify apply to several nodes at once"); + }; ids.sort_unstable(); ids.dedup(); if ids.is_empty() { return bad("no nodes selected"); } - let p = &patch; - if p.name.is_some() - || p.sort.is_some() - || p.remark.is_some() - || p.country_pin.is_some() - || p.ipv4_pin.is_some() - || p.ipv6_pin.is_some() - { - return bad("name, order, note, addresses and country are set one node at a time"); - } + let mut patch = NodePatch { group: patch.group, notify: patch.notify, ..Default::default() }; if let Some(message) = patch_error(&mut patch) { return bad(message); } @@ -2340,14 +2339,14 @@ mod tests { assert_eq!(r.status(), StatusCode::NOT_FOUND); assert_eq!(group(a), "香港", "a refused batch leaves every node as it was"); - for refused in - [json!({"name": "x"}), json!({"ipv4_pin": "1.2.3.4"}), json!({"group": "g".repeat(33)})] - { - let r = update_nodes(Admin, state(), batch(vec![a], refused.clone())).await; - assert_eq!(r.status(), StatusCode::BAD_REQUEST, "{refused}"); + // Only the listed fields deserialize, so the extractor refuses the rest. + for refused in [json!({"name": "x"}), json!({"ipv4_pin": "1.2.3.4"}), json!({"public": false})] { + assert!(serde_json::from_value::(refused.clone()).is_err(), "{refused}"); } + let r = update_nodes(Admin, state(), batch(vec![a], json!({"group": "g".repeat(33)}))).await; + assert_eq!(r.status(), StatusCode::BAD_REQUEST); assert_eq!( - update_nodes(Admin, state(), batch(vec![], json!({"public": false}))).await.status(), + update_nodes(Admin, state(), batch(vec![], json!({"notify": false}))).await.status(), StatusCode::BAD_REQUEST ); diff --git a/web-admin/src/components/Admin.tsx b/web-admin/src/components/Admin.tsx index ee591063..8d04c66c 100644 --- a/web-admin/src/components/Admin.tsx +++ b/web-admin/src/components/Admin.tsx @@ -83,10 +83,15 @@ function searchNodes(nodes: Node[], query: string) { } // A filter naming a group no node carries any more -- renamed, or its last node -// deleted -- falls back to all rather than showing an empty list. +// deleted -- falls back to all rather than showing an empty list; so does 未分组 +// once no group is left, since the dropdown that would clear it is hidden then. +// Reset rather than masked, so the old filter does not return with a later group +// of the same name. function useGroupFilter(nodes: Node[]) { const [filter, setFilter] = useState("all") - const valid = filter === "all" || filter === "none" || nodes.some((n) => n.group === filter.slice(1)) + const valid = filter === "all" + || (filter === "none" ? nodes.some((n) => n.group) : nodes.some((n) => n.group === filter.slice(1))) + if (!valid) setFilter("all") return [valid ? filter : "all", setFilter] as const } @@ -1792,31 +1797,29 @@ function ChannelCard({ title, configured, children }: { title: string; configure // Offline alerts are opt-in per node, so turning them on for a fleet needs one // place rather than one dialog per node. Ticks are a draft until 保存, like every -// other form in the panel: a request per click made each tick wait on a round -// trip and a refresh before it showed. +// other form in the panel: a request per click would make each tick wait on a +// round trip and a refresh before showing. function OfflineNodes({ nodes, refresh }: { nodes: Node[]; refresh: () => void }) { - const [draft, setDraft] = useState | null>(null) + // Only the ticks changed here, by node id. A snapshot of every node's state + // would send back a node another session switched meanwhile. + const [draft, setDraft] = useState>(new Map()) const [saving, setSaving] = useState(false) - const saved = new Set(nodes.filter((n) => n.notify).map((n) => n.id)) - const chosen = draft ?? saved + const on = (n: Node) => draft.get(n.id) ?? !!n.notify + const chosen = new Set(nodes.filter(on).map((n) => n.id)) // Against the live list, so a node deleted meanwhile is neither counted nor sent. - const turnOn = nodes.filter((n) => chosen.has(n.id) && !saved.has(n.id)).map((n) => n.id) - const turnOff = nodes.filter((n) => !chosen.has(n.id) && saved.has(n.id)).map((n) => n.id) + const turnOn = nodes.filter((n) => on(n) && !n.notify).map((n) => n.id) + const turnOff = nodes.filter((n) => !on(n) && n.notify).map((n) => n.id) const dirty = turnOn.length + turnOff.length > 0 // Kept after a save until the list reports it, so the ticks do not flash back - // to the old state for a round trip; dropped then, so a change made in another - // session shows through. Adjusted during render rather than in an effect, as - // it follows from props alone. - if (draft && !dirty && !saving) setDraft(null) + // to the old state for a round trip. Adjusted during render rather than in an + // effect, as it follows from props alone. + if (draft.size && !dirty && !saving) setDraft(new Map()) - const pick = (list: Node[], on: boolean) => + const pick = (list: Node[], value: boolean) => setDraft((old) => { - const next = new Set(old ?? saved) - for (const n of list) { - if (on) next.add(n.id) - else next.delete(n.id) - } + const next = new Map(old) + for (const n of list) next.set(n.id, value) return next }) @@ -1841,13 +1844,13 @@ function OfflineNodes({ nodes, refresh }: { nodes: Node[]; refresh: () => void }

离线通知

- 按节点打开,默认关。已打开 {saved.size} / {nodes.length} 台 + 按节点打开,默认关。已打开 {nodes.filter((n) => n.notify).length} / {nodes.length} 台 {pending.length > 0 && ,待保存:{pending.join("、")}}

- +