From d9e24779f0404a4c662ce62b130de368d483ea58 Mon Sep 17 00:00:00 2001 From: ImBaphomet Date: Wed, 11 Nov 2026 05:16:56 +0100 Subject: [PATCH 1/9] refactor: count who follows a live view in a module of its own, for the map and the entities to share --- crates/observer-app/src/lib.rs | 4 ++- crates/observer-app/src/map.rs | 27 +++++------------ crates/observer-app/src/map/tests.rs | 4 +-- crates/observer-app/src/watching.rs | 41 ++++++++++++++++++++++++++ crates/observer-web/src/live/topics.rs | 18 +++++------ 5 files changed, 62 insertions(+), 32 deletions(-) create mode 100644 crates/observer-app/src/watching.rs diff --git a/crates/observer-app/src/lib.rs b/crates/observer-app/src/lib.rs index 6d766a71..7d6c25c7 100644 --- a/crates/observer-app/src/lib.rs +++ b/crates/observer-app/src/lib.rs @@ -36,6 +36,7 @@ mod tickets; mod tools; pub mod updates; mod users; +mod watching; pub use accounts::Accounts; pub use artifacts::{ArtifactTimings, Artifacts, ArtifactsSetup, ArtifactsTask}; @@ -53,7 +54,7 @@ pub use drops::{DayRates, Drops, DropsSetup, DropsTask, LogQuery}; pub use error::Error; pub use features::{Features, FeaturesSetup, Switched}; pub use files::{CommitGate, Files, Grant, Group, Login, SERVER as FILES_SERVER, file_group}; -pub use map::{ImageKind, LiveMap, MapSetup, MapTask, Watching}; +pub use map::{ImageKind, LiveMap, MapSetup, MapTask}; pub use metrics::{DatabaseFiles, Metrics, MetricsSetup, Sampling, Sources}; pub use own_paths::{OwnPaths, paths_inside}; pub use passwords::PasswordCost; @@ -66,3 +67,4 @@ pub use statistics::Statistics; pub use supervision::{ConsoleLines, LinesMissed, Running, Setup, Supervision}; pub use tools::{ReportError, RunResult, Target, Tools, ToolsHook, ToolsSetup, outcome_text, report}; pub use updates::{Takeover, Updates, UpdatesSetup, UpdatesTask}; +pub use watching::Watching; diff --git a/crates/observer-app/src/map.rs b/crates/observer-app/src/map.rs index 20020eb2..65c5d5dd 100644 --- a/crates/observer-app/src/map.rs +++ b/crates/observer-app/src/map.rs @@ -32,6 +32,7 @@ use self::image::image_size; use crate::audit::Record; use crate::host_file::{HostFileProblem, open_local}; use crate::secrets::{hex, random}; +use crate::watching::{Watchers, Watching}; use crate::{Caller, Clock, Error, Players, Supervision}; /// How long one ask for positions lasts, and how often it is renewed while a panel watches. @@ -61,19 +62,7 @@ struct Inner { players: Players, folder: PathBuf, live: watch::Sender, - watchers: watch::Sender, -} - -/// Held while a panel follows the map: the server stops sending positions once none does. -pub struct Watching { - watchers: watch::Sender, -} - -impl Drop for Watching { - fn drop(&mut self) { - self.watchers - .send_modify(|watchers| *watchers = watchers.saturating_sub(1)); - } + watchers: Watchers, } pub struct MapTask { @@ -115,7 +104,7 @@ impl LiveMap { waiting: 0, onesync: true, }), - watchers: watch::Sender::new(0), + watchers: Watchers::new(), }), }; let stop = CancellationToken::new(); @@ -123,14 +112,12 @@ impl LiveMap { (map, MapTask { stop, task }) } - /// The map as it stands and its changes, for as long as the guard is held. + /// The map as it stands and its changes, for as long as the guard is held: the server sends + /// positions only while someone holds one. pub fn watch(&self) -> (MapLive, watch::Receiver, Watching) { - self.inner.watchers.send_modify(|watchers| *watchers += 1); + let guard = self.inner.watchers.join(); let mut live = self.inner.live.subscribe(); let snapshot = live.borrow_and_update().clone(); - let guard = Watching { - watchers: self.inner.watchers.clone(), - }; (snapshot, live, guard) } @@ -298,7 +285,7 @@ impl LiveMap { Ok(record.write(tx, now)?) }) .await?; - if *self.inner.watchers.borrow() > 0 { + if self.inner.watchers.any() { self.ask(true).await; } Ok(()) diff --git a/crates/observer-app/src/map/tests.rs b/crates/observer-app/src/map/tests.rs index 1fe77de4..a4a239e7 100644 --- a/crates/observer-app/src/map/tests.rs +++ b/crates/observer-app/src/map/tests.rs @@ -257,11 +257,11 @@ async fn the_map_counts_who_watches_it_until_they_leave() { let (snapshot, _, first) = map.watch(); let (_, _, second) = map.watch(); - assert_eq!(*map.inner.watchers.borrow(), 2); + assert_eq!(*map.inner.watchers.subscribe().borrow(), 2); drop(first); drop(second); - assert_eq!(*map.inner.watchers.borrow(), 0); + assert_eq!(*map.inner.watchers.subscribe().borrow(), 0); assert_eq!(snapshot.players, []); assert!(snapshot.onesync); task.close().await; diff --git a/crates/observer-app/src/watching.rs b/crates/observer-app/src/watching.rs new file mode 100644 index 00000000..bdeac0bc --- /dev/null +++ b/crates/observer-app/src/watching.rs @@ -0,0 +1,41 @@ +//! Who follows a live view. The server reads positions and entities only while a panel looks at +//! them: each view counts its followers here, and each follower holds a guard that leaves with it. + +use tokio::sync::watch; + +/// The followers of one live view. +#[derive(Clone)] +pub(crate) struct Watchers(watch::Sender); + +impl Watchers { + pub(crate) fn new() -> Self { + Self(watch::Sender::new(0)) + } + + pub(crate) fn join(&self) -> Watching { + self.0.send_modify(|watchers| *watchers += 1); + Watching { + watchers: self.0.clone(), + } + } + + pub(crate) fn any(&self) -> bool { + *self.0.borrow() > 0 + } + + pub(crate) fn subscribe(&self) -> watch::Receiver { + self.0.subscribe() + } +} + +/// Held while a panel follows a live view: the server stops reading for it once none does. +pub struct Watching { + watchers: watch::Sender, +} + +impl Drop for Watching { + fn drop(&mut self) { + self.watchers + .send_modify(|watchers| *watchers = watchers.saturating_sub(1)); + } +} diff --git a/crates/observer-web/src/live/topics.rs b/crates/observer-web/src/live/topics.rs index c0f50700..f0be265d 100644 --- a/crates/observer-web/src/live/topics.rs +++ b/crates/observer-web/src/live/topics.rs @@ -144,7 +144,7 @@ pub(super) async fn open(topic: Topic, since: Option, state: &AppState) -> let (snapshot, live, watching) = state.map()?.watch(); Ok(Feed { snapshot: TopicSnapshot::ServerMap(snapshot), - events: Some(map_events(live, watching)), + events: Some(watched_events(live, watching, TopicEvent::ServerMap)), }) } Topic::ServerChanges => { @@ -197,18 +197,18 @@ where })) } -/// The map's changes, holding the guard that keeps the server sending positions: it goes with the -/// subscription. -fn map_events(live: watch::Receiver, watching: Watching) -> Events { +/// The changes of a view the server reads for only while someone looks, holding the guard that +/// keeps it reading: it goes with the subscription. +fn watched_events(live: watch::Receiver, watching: Watching, event: fn(T) -> TopicEvent) -> Events +where + T: Clone + Send + Sync + 'static, +{ Box::pin(stream::unfold( (live, watching), - |(mut live, watching)| async move { + move |(mut live, watching)| async move { live.changed().await.ok()?; let current = live.borrow_and_update().clone(); - Some(( - Delivery::Event(Box::new(TopicEvent::ServerMap(current))), - (live, watching), - )) + Some((Delivery::Event(Box::new(event(current))), (live, watching))) }, )) } From d97f7c87df3f52d4a6b7d4a5b7b1763f0fe79ec6 Mon Sep 17 00:00:00 2001 From: ImBaphomet Date: Wed, 11 Nov 2026 05:17:02 +0100 Subject: [PATCH 2/9] refactor(web): share the helpers that read a page's address, and a settings category's loading card and page link --- web/src/drops/search.ts | 17 +---------------- web/src/lib/search.ts | 19 +++++++++++++++++++ web/src/settings/LiveMap.tsx | 23 ++++------------------- web/src/settings/Row.tsx | 31 +++++++++++++++++++++++++++++-- web/src/statistics/search.ts | 4 +--- 5 files changed, 54 insertions(+), 40 deletions(-) create mode 100644 web/src/lib/search.ts diff --git a/web/src/drops/search.ts b/web/src/drops/search.ts index 45301a1f..a774ad7a 100644 --- a/web/src/drops/search.ts +++ b/web/src/drops/search.ts @@ -2,6 +2,7 @@ import type { ChartCategory } from '../api/generated/ChartCategory'; import type { DropCategory } from '../api/generated/DropCategory'; import type { DropKind } from '../api/generated/DropKind'; import type { DropsRange } from '../api/generated/DropsRange'; +import { defined, oneOf, text } from '../lib/search'; // The page keeps its range, its chart and the log's filters in the address, so the profile, the // Diagnostics page and the cards of the page itself link to a filtered view: `/drops?run=12`. @@ -71,28 +72,12 @@ export type DropsSearch = Omit & { /** The filters of the log the address may hold, in the order the query string writes them. */ const FILTERS = ['category', 'kind', 'resource', 'signature', 'run', 'player', 'profile', 'group'] as const; -const oneOf = (choices: readonly T[], value: unknown): T | undefined => - choices.find((choice) => choice === value); - /** A positive whole number, as the address or a form gives it; anything else is left out. */ const id = (value: unknown) => { const number = typeof value === 'string' && /^\d+$/.test(value.trim()) ? Number(value) : value; return typeof number === 'number' && Number.isSafeInteger(number) && number > 0 ? number : undefined; }; -/** - * A text filter, trimmed; an empty one is left out. What the address parsed as a number or a - * boolean, a licence's digits or a name such as `true`, comes back as the text it was. - */ -const text = (value: unknown) => { - const written = typeof value === 'number' || typeof value === 'boolean' ? String(value) : value; - return typeof written === 'string' && written.trim() !== '' ? written.trim() : undefined; -}; - -/** Drops the fields that are not set, so that two equal searches read the same. */ -const defined = (object: T): T => - Object.fromEntries(Object.entries(object).filter(([, value]) => value !== undefined)) as T; - /** * The page's address as the page reads it, whatever was typed into it: unknown or malformed fields * are left out rather than refused, and a kind that is not one of its category's is dropped. diff --git a/web/src/lib/search.ts b/web/src/lib/search.ts new file mode 100644 index 00000000..89e656f7 --- /dev/null +++ b/web/src/lib/search.ts @@ -0,0 +1,19 @@ +// What the pages that keep their state in the address read it with. The address bar is the user's +// to edit: what does not read is left out rather than refused. + +/** One of `choices`, or nothing. */ +export const oneOf = (choices: readonly T[], value: unknown): T | undefined => + choices.find((choice) => choice === value); + +/** + * A text filter, trimmed; an empty one is left out. What the address parsed as a number or a + * boolean, a licence's digits or a name such as `true`, comes back as the text it was. + */ +export const text = (value: unknown) => { + const written = typeof value === 'number' || typeof value === 'boolean' ? String(value) : value; + return typeof written === 'string' && written.trim() !== '' ? written.trim() : undefined; +}; + +/** Drops the fields that are not set, so that two equal searches read the same. */ +export const defined = (object: T): T => + Object.fromEntries(Object.entries(object).filter(([, value]) => value !== undefined)) as T; diff --git a/web/src/settings/LiveMap.tsx b/web/src/settings/LiveMap.tsx index 183a7889..39643d6f 100644 --- a/web/src/settings/LiveMap.tsx +++ b/web/src/settings/LiveMap.tsx @@ -1,6 +1,5 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { Link } from '@tanstack/react-router'; -import { ArrowRight, Check, ChevronDown, Image as ImageIcon, Trash2 } from 'lucide-react'; +import { Check, ChevronDown, Image as ImageIcon, Trash2 } from 'lucide-react'; import { useState, type MouseEvent } from 'react'; import type { MapBounds } from '../api/generated/MapBounds'; @@ -25,7 +24,7 @@ import { boundsFrom, type Spot } from '../map/geometry'; import { useVisit } from '../players/words'; import { useSchedule } from '../schedule/context'; import { SettingsForm, useSection } from './Form'; -import { CategoryHead, SettingRow, SettingsGroup } from './Row'; +import { CategoryHead, CategoryLink, CategoryLoading, SettingRow, SettingsGroup } from './Row'; type Outcome = { tone: 'success' | 'danger'; message: string }; type Way = 'players' | 'edges'; @@ -45,25 +44,11 @@ export function LiveMapSettings() { category="map" aside={ allows(me, Permission.PlayersMap) ? ( - - {t.settings.map.openMap} -