diff --git a/src/api.rs b/src/api.rs index 840803ce..9ddb7c31 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) { @@ -791,6 +817,53 @@ 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: BatchPatch, +} + +/// Applies one patch to every selected node, all or none. +pub async fn update_nodes( + _: Admin, + State(app): State, + body: Result, JsonRejection>, +) -> Response { + 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 mut patch = NodePatch { group: patch.group, notify: patch.notify, ..Default::default() }; + 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 NodeOrder { ids: Vec, @@ -2301,6 +2374,44 @@ 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"); + + // 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!({"notify": false}))).await.status(), + StatusCode::BAD_REQUEST + ); + + assert!(live_snapshot(&app, false).as_str().contains(r#""group":"香港""#), "the group is public"); + } + /// What the panel saves is what an anonymous visitor reads, under the same /// condition as the node list, and only an installed theme takes a write. #[tokio::test] diff --git a/src/db.rs b/src/db.rs index 992fc80a..52e9412d 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> { @@ -626,8 +640,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, @@ -640,7 +655,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(); @@ -668,38 +684,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<()> { @@ -1743,6 +1780,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 6f61900b..dee2023d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -452,6 +452,7 @@ 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/{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 4a7b405d..1abb4f67 100644 --- a/web-admin/src/components/Admin.tsx +++ b/web-admin/src/components/Admin.tsx @@ -1,6 +1,6 @@ -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, SlidersHorizontal, 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, SlidersHorizontal, Trash2, Upload } from "lucide-react" import { toast } from "sonner" import { Badge } from "@/components/ui/badge" @@ -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, configFields, configForm, configOverrides, configSections, configValues, currentIface, fits, GIB, ifaceChoice, ifaceSpec, outdatedAgents, provisioningSite, trafficCorrection, upload, type ConfigField, type IfaceChoice, type Node, type PingTask, type Source } from "@/lib/api" +import { addresses, api, badIfaceName, behind, changes, configFields, configForm, configOverrides, configSections, configValues, currentIface, fits, GIB, groupsOf, ifaceChoice, ifaceSpec, inGroup, outdatedAgents, provisioningSite, trafficCorrection, upload, type ConfigField, 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,61 @@ 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; 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) : nodes.some((n) => n.group === filter.slice(1))) + if (!valid) setFilter("all") + 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 +136,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 (
-
- +
+ +
@@ -122,7 +159,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) => ( -
@@ -845,6 +963,8 @@ 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 [grouping, setGrouping] = useState(false) const [dragging, setDragging] = useState(null) const orderBeforeDrag = useRef([]) const byId = new Map(nodes.map((node) => [node.id, node])) @@ -855,8 +975,8 @@ 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" const uninstall = refusal ? "" : uninstallCommand(site) async function remove() { @@ -906,7 +1026,13 @@ 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. */} -
{n.name}
+
+
{n.name}
+ {n.group &&
{n.group}
} +
{n.country && ( setEditing(null)} onSaved={refresh} /> )} + {grouping && setGrouping(false)} onSaved={refresh} />} {billing && ( setBilling(null)} onSaved={refresh} /> )} @@ -1857,36 +1988,63 @@ 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 would make each tick wait on a +// round trip and a refresh before showing. function OfflineNodes({ nodes, refresh }: { nodes: Node[]; refresh: () => void }) { - const [busy, setBusy] = useState(false) + // 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 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) => 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. 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[], value: boolean) => + setDraft((old) => { + const next = new Map(old) + for (const n of list) next.set(n.id, value) + return next + }) - async function apply(targets: Node[], on: boolean) { - setBusy(true) + async function save() { + setSaving(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 }) })), - ) + 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} 台

+

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

+
+ +
+ +
-
) } diff --git a/web-admin/src/lib/api.test.ts b/web-admin/src/lib/api.test.ts index 983c63e7..e8ec4adf 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, configFields, configForm, configOverrides, configSections, configValues, currentIface, fits, GIB, ifaceChoice, ifaceSpec, isPublic, loopbackOrigin, outdatedAgents, provisionRefusal, provisioningSite, trafficCorrection } from "./api.ts" +import { addresses, badIfaceName, behind, changes, configFields, configForm, configOverrides, configSections, configValues, currentIface, fits, GIB, groupsOf, ifaceChoice, ifaceSpec, inGroup, isPublic, loopbackOrigin, outdatedAgents, provisioningSite, provisionRefusal, 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" }) @@ -109,6 +109,15 @@ 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) + // A theme's form: malformed fields drop out one by one, a saved value the field // can no longer hold shows the default, and only changes from a default are stored. const entries = [ diff --git a/web-admin/src/lib/api.ts b/web-admin/src/lib/api.ts index 33faa0a1..95aa0971 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