From 9f8918ef77e029aa07694e4cca67dc3c4d88f123 Mon Sep 17 00:00:00 2001 From: ImBaphomet Date: Wed, 11 Nov 2026 05:09:10 +0100 Subject: [PATCH 1/5] feat(fxserver): have the bridge list every resource FXServer knows, and let Observer write start, stop, restart and refresh on its console --- crates/observer-fxserver/src/bridge.rs | 90 ++- crates/observer-fxserver/src/lib.rs | 1 + crates/observer-fxserver/src/resources.rs | 801 +++++++++++++++++++++ crates/observer-fxserver/src/start.rs | 176 ++++- crates/observer-fxserver/src/stdin.rs | 140 +++- crates/observer-fxserver/src/supervisor.rs | 199 ++++- crates/observer/src/fxserver.rs | 3 +- resource/observer/server.lua | 58 ++ 8 files changed, 1421 insertions(+), 47 deletions(-) create mode 100644 crates/observer-fxserver/src/resources.rs diff --git a/crates/observer-fxserver/src/bridge.rs b/crates/observer-fxserver/src/bridge.rs index 7b3fc8bb..b590fa7f 100644 --- a/crates/observer-fxserver/src/bridge.rs +++ b/crates/observer-fxserver/src/bridge.rs @@ -11,7 +11,8 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::layout::Layout; -use crate::stdin::MAX_BRIDGE_JSON; +use crate::resources::Reported; +use crate::stdin::{MAX_BRIDGE_JSON, safe}; use crate::trace::TraceKind; /// The server convar that holds the bridge's name, for resources to find it. Set with `+set`, @@ -290,14 +291,6 @@ pub fn start_changes( event(low) } -/// Whether `text` is 1 to `max` bytes of ASCII letters, digits and `extra`. -fn safe(text: &str, max: usize, extra: &[u8]) -> bool { - (1..=max).contains(&text.len()) - && text - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || extra.contains(&byte)) -} - impl BridgeEvent { pub fn name(&self) -> &'static str { match self { @@ -455,6 +448,15 @@ pub enum Report { #[serde(default)] external: u64, }, + /// One page of every resource FXServer knows, in name order: once the cfg has run, and again + /// after each `refresh`. The pages of one list carry its number, as `list`. + ResourceList { + #[serde(rename = "list")] + number: u64, + page: u32, + last: bool, + resources: Vec, + }, } impl Report { @@ -1089,6 +1091,76 @@ mod tests { ); } + #[test] + fn a_page_of_the_resource_list_is_read_with_what_each_manifest_says() { + let page = Report::from_trace( + &TraceKind::Script { + resource: BRIDGE.to_owned(), + payload: json!({ + "type": "observer", + "kind": "resource_list", + "list": 2, + "page": 1, + "last": false, + "resources": [ + { + "name": "ox_inventory", + "state": "started", + "path": "C:/srv/resources//[ox]/ox_inventory", + "version": "2.44.1", + "needs": ["/onesync", "ox_lib"], + "provides": ["inventory"] + }, + { "name": "taxi", "state": "stopped" } + ] + }), + }, + BRIDGE, + ); + + assert_eq!( + page, + Some(Report::ResourceList { + number: 2, + page: 1, + last: false, + resources: vec![ + Reported { + name: "ox_inventory".to_owned(), + state: "started".to_owned(), + path: Some("C:/srv/resources//[ox]/ox_inventory".to_owned()), + version: Some("2.44.1".to_owned()), + needs: vec!["/onesync".to_owned(), "ox_lib".to_owned()], + provides: vec!["inventory".to_owned()], + }, + Reported { + name: "taxi".to_owned(), + state: "stopped".to_owned(), + ..Reported::default() + }, + ], + }) + ); + } + + #[test] + fn the_resource_reports_the_list_once_the_cfg_has_run_and_after_each_refresh() { + let lua = FILES[1].1; + + assert!(lua.contains("report('resource_list', { list = list, page = page, last = page == pages")); + assert!(lua.contains(" reportResourceList()\n reportPlayers()\n")); + assert!(lua.contains("AddEventHandler('onResourceListRefresh', function()")); + for native in [ + "GetResourceState(name)", + "GetResourcePath(name)", + "GetResourceMetadata(name, 'version', 0)", + "'dependency', 'dependencie'", + "'provide'", + ] { + assert!(lua.contains(native), "{native}"); + } + } + #[test] fn a_resources_report_carries_its_starts_and_stops_when_it_has_some() { let report = |payload: Value| { diff --git a/crates/observer-fxserver/src/lib.rs b/crates/observer-fxserver/src/lib.rs index 599da905..32a14fa4 100644 --- a/crates/observer-fxserver/src/lib.rs +++ b/crates/observer-fxserver/src/lib.rs @@ -17,6 +17,7 @@ pub mod perf; pub mod players; pub mod pre_launch; pub mod recovery; +pub mod resources; pub mod server_data; pub mod server_files; pub mod start; diff --git a/crates/observer-fxserver/src/resources.rs b/crates/observer-fxserver/src/resources.rs new file mode 100644 index 00000000..424a83fc --- /dev/null +++ b/crates/observer-fxserver/src/resources.rs @@ -0,0 +1,801 @@ +//! The resources FXServer knows while it runs, as its bridge reports them: every one with its +//! folder, its version and what its manifest depends on once the cfg has run and after each +//! `refresh`, then those started whenever one starts or stops. How FXServer names, finds and stops +//! resources is read from its sources: `ServerResourceList.cpp`, `ResourceManager.cpp` and +//! `ResourceDependencyLoader.cpp`. + +use std::collections::{HashMap, HashSet}; + +use serde::Deserialize; + +/// Resources kept of one list. A large server holds a few hundred. +pub const MAX_RESOURCES: usize = 5000; +const MAX_NAME: usize = 128; +const MAX_VERSION: usize = 64; +/// Dependencies or provided names kept of one manifest. +const MAX_LINKS: usize = 256; +const MAX_CATEGORIES: usize = 16; +/// The folder of a build that holds FXServer's own resources, and Observer's bridge. +const SYSTEM_RESOURCES: &str = "system_resources"; + +/// One resource of the bridge's list, as FXServer gives it. +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] +pub struct Reported { + #[serde(default)] + pub name: String, + /// What `GetResourceState` said. + #[serde(default)] + pub state: String, + /// What `GetResourcePath` said: a folder of the host. + #[serde(default)] + pub path: Option, + #[serde(default)] + pub version: Option, + /// The manifest's dependencies, constraints such as `/onesync` among them. + #[serde(default)] + pub needs: Vec, + #[serde(default)] + pub provides: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum State { + Started, + Starting, + Stopped, + Stopping, + /// Anything else FXServer may say: `uninitialized`, `missing`, `unknown`. + Unknown, +} + +impl State { + fn read(text: &str) -> Self { + match text { + "started" => Self::Started, + "starting" => Self::Starting, + "stopped" => Self::Stopped, + "stopping" => Self::Stopping, + _ => Self::Unknown, + } + } +} + +/// Which of FXServer's two roots a resource lies under. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Origin { + /// The server folder's `resources`. + Server, + /// The build's `system_resources`. + Build, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TurnKind { + Started, + Stopped, + Restarted, +} + +/// What last happened to a resource while the server ran. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Turn { + pub at_ms: i64, + pub kind: TurnKind, + /// The resource it stopped with, being one that depends on it. + pub with: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Known { + /// As FXServer names it. + pub name: String, + pub state: State, + pub origin: Origin, + /// The category folders it lies under, outermost first, as `[core]/[ox]`: empty at the root. + pub folder: String, + pub version: Option, + /// The resources its manifest depends on, by the names it gives. + pub needs: Vec, + /// The names it answers to besides its own. + pub provides: Vec, + pub turn: Option, +} + +/// The resources a list brought that the list before it did not hold. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Added { + pub at_ms: i64, + pub names: Vec, +} + +/// A list being received, page by page. +#[derive(Debug, Clone)] +struct Incoming { + list: u64, + next_page: u32, + resources: Vec, + truncated: bool, +} + +/// What one run of FXServer knows of its resources. +#[derive(Debug, Clone, Default)] +pub struct Book { + known: Vec, + listed: bool, + truncated: bool, + added: Option, + incoming: Option, + /// The resources a report said were started while a list was being received: the list's + /// first pages were read before it. + held: Option>, +} + +impl Book { + /// Whether FXServer has listed its resources in this run. + pub fn listed(&self) -> bool { + self.listed + } + + /// In name order, without regard to case. + pub fn resources(&self) -> &[Known] { + &self.known + } + + /// The list held more resources than are kept, or names too long to keep. + pub fn truncated(&self) -> bool { + self.truncated + } + + /// What the latest list added to the one before: none for the first list of a run. + pub fn added(&self) -> Option<&Added> { + self.added.as_ref() + } + + pub fn find(&self, name: &str) -> Option<&Known> { + self.known.iter().find(|known| known.name == name) + } + + /// Takes one page of the list numbered `number`, and says whether it completed the list. Pages + /// come in order from 1; a page out of turn, or of another list than the one being received, + /// is dropped. `bridge`, Observer's own resource in this run, is left out. + pub fn page( + &mut self, + number: u64, + page: u32, + last: bool, + resources: Vec, + bridge: &str, + at_ms: i64, + ) -> bool { + if page == 1 { + self.incoming = Some(Incoming { + list: number, + next_page: 1, + resources: Vec::new(), + truncated: false, + }); + } + let Some(incoming) = self + .incoming + .as_mut() + .filter(|incoming| incoming.list == number && incoming.next_page == page) + else { + return false; + }; + incoming.next_page += 1; + let room = MAX_RESOURCES.saturating_sub(incoming.resources.len()); + incoming.truncated |= resources.len() > room; + incoming.resources.extend(resources.into_iter().take(room)); + if !last { + return false; + } + let Some(incoming) = self.incoming.take() else { + return false; + }; + self.replace(incoming, bridge, at_ms); + if let Some(held) = self.held.take() { + self.started(&held, &[], at_ms); + } + true + } + + /// The list just received takes the place of the one before, whose turns the resources still + /// there keep. + fn replace(&mut self, incoming: Incoming, bridge: &str, at_ms: i64) { + let before = std::mem::take(&mut self.known); + let names_before: Option> = self + .listed + .then(|| before.iter().map(|known| known.name.clone()).collect()); + let mut turns: HashMap = before + .into_iter() + .filter_map(|known| Some((known.name, known.turn?))) + .collect(); + let mut truncated = incoming.truncated; + let mut seen = HashSet::new(); + let mut known: Vec = incoming + .resources + .into_iter() + .filter_map(|reported| { + let name = reported.name; + if name.is_empty() || name.eq_ignore_ascii_case(bridge) { + return None; + } + if name.len() > MAX_NAME { + truncated = true; + return None; + } + if !seen.insert(name.clone()) { + return None; + } + let (origin, folder) = place(reported.path.as_deref().unwrap_or_default()); + Some(Known { + state: State::read(&reported.state), + origin, + folder, + version: reported + .version + .map(|version| version.trim().chars().take(MAX_VERSION).collect::()) + .filter(|version| !version.is_empty()), + needs: links(reported.needs, true), + provides: links(reported.provides, false), + turn: turns.remove(&name), + name, + }) + }) + .collect(); + known.sort_by(|a, b| { + a.name + .to_ascii_lowercase() + .cmp(&b.name.to_ascii_lowercase()) + .then_with(|| a.name.cmp(&b.name)) + }); + self.added = names_before.map(|before| Added { + at_ms, + names: known + .iter() + .filter(|known| !before.contains(&known.name)) + .map(|known| known.name.clone()) + .collect(), + }); + self.known = known; + self.truncated = truncated; + self.listed = true; + } + + /// Takes the resources started now and the starts and stops since the report before, as + /// `(name, started)` in the order they happened, and says whether anything changed. Nothing is + /// known of a run that has not listed its resources yet. + pub fn started(&mut self, started: &[String], events: &[(String, bool)], at_ms: i64) -> bool { + if self.incoming.is_some() { + self.held = Some(started.to_vec()); + } + if !self.listed { + return false; + } + let mut turns = turns(events); + let stopped: Vec = turns + .iter() + .filter(|(_, kind)| *kind != TurnKind::Started) + .map(|(name, _)| name.clone()) + .collect(); + let with: HashMap = stopped + .iter() + .filter_map(|name| Some((name.clone(), self.stopped_with(name, &stopped)?))) + .collect(); + let up: HashSet<&str> = started.iter().map(String::as_str).collect(); + let mut changed = false; + for known in &mut self.known { + let state = match (up.contains(known.name.as_str()), known.state) { + (true, _) => State::Started, + (false, State::Started) => State::Stopped, + (false, state) => state, + }; + let told = turns + .iter() + .position(|(name, _)| *name == known.name) + .map(|index| turns.swap_remove(index).1); + // A report tells 64 starts and stops at most: past them, the states alone say. + let kind = told.or(match (known.state, state) { + (before, State::Started) if before != State::Started => Some(TurnKind::Started), + (State::Started, State::Stopped) => Some(TurnKind::Stopped), + _ => None, + }); + if let Some(kind) = kind { + known.turn = Some(Turn { + at_ms, + kind, + with: with.get(&known.name).cloned(), + }); + changed = true; + } + changed |= known.state != state; + known.state = state; + } + changed + } + + /// The resource among `stopped` that `name` stopped with: one it depends on, directly or not, + /// that depends on none of the others. FXServer stops what depends on a resource before the + /// resource itself, so the report tells both. + fn stopped_with(&self, name: &str, stopped: &[String]) -> Option { + let needed = self.needed_by(name); + stopped + .iter() + .filter(|other| other.as_str() != name && needed.contains(other.as_str())) + .find(|root| { + let above = self.needed_by(root); + !stopped.iter().any(|other| above.contains(other.as_str())) + }) + .cloned() + } + + /// Every resource `name` depends on, directly or not, whatever is started. + fn needed_by(&self, name: &str) -> HashSet<&str> { + let mut needed: HashSet<&str> = HashSet::new(); + let mut queue = vec![name]; + while let Some(current) = queue.pop() { + let Some(known) = self.find(current) else { + continue; + }; + for need in &known.needs { + let found = self.find(need).or_else(|| { + self.known + .iter() + .find(|other| other.provides.iter().any(|provided| provided == need)) + }); + if let Some(found) = found + && found.name != name + && needed.insert(found.name.as_str()) + { + queue.push(found.name.as_str()); + } + } + } + needed + } + + /// What depends on what among the started resources, as they stand: worked out once, for + /// every resource asked of it after. + pub fn dependants(&self) -> Dependants<'_> { + let known = &self.known; + let started = |at: usize| known[at].state == State::Started; + let named: HashMap<&str, usize> = known + .iter() + .enumerate() + .map(|(at, resource)| (resource.name.as_str(), at)) + .collect(); + // `ResourceManager::GetResource` finds a dependency by the resource of that name when it + // is started, or else by a started one that provides the name. + let mut provided: HashMap<&str, usize> = HashMap::new(); + for (at, resource) in known.iter().enumerate().filter(|(at, _)| started(*at)) { + for name in &resource.provides { + provided.entry(name.as_str()).or_insert(at); + } + } + let mut direct: Vec> = vec![Vec::new(); known.len()]; + for (at, resource) in known.iter().enumerate().filter(|(at, _)| started(*at)) { + for need in &resource.needs { + let on = named + .get(need.as_str()) + .copied() + .filter(|on| started(*on)) + .or_else(|| provided.get(need.as_str()).copied()); + if let Some(on) = on + && on != at + && !direct[on].contains(&at) + { + direct[on].push(at); + } + } + } + Dependants { known, named, direct } + } +} + +/// The started resources of a run by what depends on what. +pub struct Dependants<'a> { + known: &'a [Known], + named: HashMap<&'a str, usize>, + /// For each resource, the started ones whose manifest names it. + direct: Vec>, +} + +impl Dependants<'_> { + /// The started resources that depend on `name`, directly or not, the nearest first: FXServer + /// stops them with it, and starts none of them again. + pub fn of(&self, name: &str) -> Vec { + let Some(&root) = self.named.get(name) else { + return Vec::new(); + }; + let mut seen = vec![false; self.known.len()]; + seen[root] = true; + let mut found: Vec = Vec::new(); + let mut frontier = vec![root]; + while !frontier.is_empty() { + let mut next: Vec = frontier + .iter() + .flat_map(|at| &self.direct[*at]) + .copied() + .filter(|at| !std::mem::replace(&mut seen[*at], true)) + .collect(); + next.sort_unstable(); + found.extend(&next); + frontier = next; + } + found.into_iter().map(|at| self.known[at].name.clone()).collect() + } +} + +/// What became of each resource over one report's starts and stops, in the order each first +/// showed: a stop then a start is a restart. +fn turns(events: &[(String, bool)]) -> Vec<(String, TurnKind)> { + let mut turns: Vec<(String, TurnKind)> = Vec::new(); + for (name, started) in events { + let turn = turns.iter_mut().find(|(known, _)| known == name); + match (turn, started) { + (Some((_, kind)), true) => { + *kind = match kind { + TurnKind::Stopped | TurnKind::Restarted => TurnKind::Restarted, + TurnKind::Started => TurnKind::Started, + }; + } + (Some((_, kind)), false) => *kind = TurnKind::Stopped, + (None, true) => turns.push((name.clone(), TurnKind::Started)), + (None, false) => turns.push((name.clone(), TurnKind::Stopped)), + } + } + turns +} + +/// Where a resource lies, from its path as FXServer gives it. `ServerResourceList.cpp` takes a +/// folder named `[x]` for a category, read in turn, and any other for a resource: the categories +/// are the folders that end the path, and what holds them is the root. +fn place(path: &str) -> (Origin, String) { + let mut folders: Vec<&str> = path.split(['/', '\\']).filter(|part| !part.is_empty()).collect(); + folders.pop(); + let categories = folders + .iter() + .rev() + .take_while(|folder| folder.starts_with('[') && folder.ends_with(']')) + .count(); + let root = folders.len() - categories; + let origin = if root > 0 && folders[root - 1] == SYSTEM_RESOURCES { + Origin::Build + } else { + Origin::Server + }; + let kept = &folders[root..]; + let kept = &kept[kept.len().saturating_sub(MAX_CATEGORIES)..]; + let folder = kept + .iter() + .filter(|category| category.len() <= MAX_NAME) + .copied() + .collect::>() + .join("/"); + (origin, folder) +} + +/// The names a manifest gives, each once. A dependency that starts with `/` is a constraint on the +/// server (`ResourceManagerConstraintsComponent.cpp`), not a resource. +fn links(names: Vec, constraints: bool) -> Vec { + let mut kept: Vec = Vec::new(); + for name in names { + let fits = !name.is_empty() && name.len() <= MAX_NAME && !(constraints && name.starts_with('/')); + if fits && kept.len() < MAX_LINKS && !kept.contains(&name) { + kept.push(name); + } + } + kept +} + +#[cfg(test)] +mod tests { + use super::*; + + const BRIDGE: &str = "sys_k3jx7qmw2pza"; + const ROOT: &str = "C:/srv/server-data/resources/"; + + fn reported(name: &str, state: &str, folder: &str, needs: &[&str]) -> Reported { + Reported { + name: name.to_owned(), + state: state.to_owned(), + path: Some(format!("{ROOT}/{folder}{name}")), + version: Some("1.0.0".to_owned()), + needs: needs.iter().map(|&need| need.to_owned()).collect(), + provides: Vec::new(), + } + } + + /// A framework, two resources that depend on it, one that depends on one of those, and one + /// that depends on nothing. + fn listed() -> Book { + let mut book = Book::default(); + let resources = vec![ + reported("ox_lib", "started", "[core]/", &[]), + reported( + "ox_inventory", + "started", + "[core]/[ox]/", + &["/onesync", "ox_lib", "oxmysql"], + ), + reported("ox_target", "started", "[core]/[ox]/", &["ox_lib"]), + reported("shops", "started", "[jobs]/", &["ox_inventory"]), + reported("oxmysql", "started", "[core]/", &[]), + reported("taxi", "stopped", "[jobs]/", &["ox_lib"]), + reported(BRIDGE, "started", "", &[]), + ]; + assert!(book.page(1, 1, true, resources, BRIDGE, 1000)); + book + } + + fn names(book: &Book) -> Vec<&str> { + book.resources().iter().map(|known| known.name.as_str()).collect() + } + + fn events(events: &[(&str, bool)]) -> Vec<(String, bool)> { + events + .iter() + .map(|&(name, started)| (name.to_owned(), started)) + .collect() + } + + fn started(names: &[&str]) -> Vec { + names.iter().map(|&name| name.to_owned()).collect() + } + + #[test] + fn a_list_is_kept_in_name_order_without_the_bridge_or_the_constraints() { + let book = listed(); + + assert!(book.listed()); + assert_eq!( + names(&book), + ["ox_inventory", "ox_lib", "ox_target", "oxmysql", "shops", "taxi"] + ); + let inventory = book.find("ox_inventory").unwrap(); + assert_eq!(inventory.needs, ["ox_lib", "oxmysql"]); + assert_eq!( + (inventory.state, inventory.origin, inventory.folder.as_str()), + (State::Started, Origin::Server, "[core]/[ox]") + ); + assert_eq!(inventory.version.as_deref(), Some("1.0.0")); + assert_eq!(book.find("taxi").unwrap().state, State::Stopped); + assert_eq!(book.added(), None, "the first list of a run adds to nothing"); + } + + #[test] + fn a_resource_lies_under_the_categories_that_end_its_path() { + for (path, origin, folder) in [ + ("C:/srv/data/resources//chat", Origin::Server, ""), + ( + "C:/srv/data/resources//[core]/[ox]/ox_lib", + Origin::Server, + "[core]/[ox]", + ), + ( + "C:\\srv\\data\\resources\\[maps]\\docks", + Origin::Server, + "[maps]", + ), + ("/home/fx/[servers]/main/resources/[a]/b", Origin::Server, "[a]"), + ("C:/fx/38435/citizen/system_resources//monitor", Origin::Build, ""), + ("/opt/cfx/citizen/system_resources/[x]/y", Origin::Build, "[x]"), + ("relative", Origin::Server, ""), + ("", Origin::Server, ""), + ] { + assert_eq!(place(path), (origin, folder.to_owned()), "{path}"); + } + } + + #[test] + fn pages_out_of_turn_or_of_another_list_are_dropped() { + let mut book = Book::default(); + let page = |name: &str| vec![reported(name, "started", "", &[])]; + + assert!(!book.page(1, 2, true, page("late"), BRIDGE, 0), "no first page"); + assert!(!book.page(1, 1, false, page("a"), BRIDGE, 0)); + assert!( + !book.page(2, 1, false, page("x"), BRIDGE, 0), + "a newer list begins" + ); + assert!( + !book.page(1, 2, true, page("b"), BRIDGE, 0), + "the older one's next page" + ); + assert!(!book.listed()); + assert!(!book.page(2, 3, true, page("z"), BRIDGE, 0), "page 2 never came"); + assert!(book.page(2, 2, true, page("y"), BRIDGE, 0)); + + assert_eq!(names(&book), ["x", "y"]); + } + + #[test] + fn a_report_that_comes_between_two_pages_of_a_list_still_counts() { + let mut book = Book::default(); + let page = |name: &str| vec![reported(name, "started", "", &[])]; + + book.page(1, 1, false, page("a"), BRIDGE, 0); + book.started(&started(&["b"]), &events(&[("a", false)]), 10); + book.page(1, 2, true, page("b"), BRIDGE, 20); + + let state = |name: &str| book.find(name).unwrap().state; + assert_eq!((state("a"), state("b")), (State::Stopped, State::Started)); + } + + #[test] + fn a_later_list_says_what_it_added_and_keeps_what_happened_to_the_others() { + let mut book = listed(); + book.started( + &started(&["ox_inventory", "ox_lib", "ox_target", "oxmysql", "shops"]), + &events(&[("oxmysql", false), ("oxmysql", true)]), + 2000, + ); + + let again = vec![ + reported("ox_lib", "started", "[core]/", &[]), + reported("oxmysql", "started", "[core]/", &[]), + reported("bank_heist", "stopped", "[jobs]/", &[]), + ]; + assert!(book.page(2, 1, true, again, BRIDGE, 3000)); + + assert_eq!( + book.added(), + Some(&Added { + at_ms: 3000, + names: vec!["bank_heist".to_owned()] + }) + ); + assert_eq!(names(&book), ["bank_heist", "ox_lib", "oxmysql"]); + assert_eq!( + book.find("oxmysql").unwrap().turn, + Some(Turn { + at_ms: 2000, + kind: TurnKind::Restarted, + with: None + }) + ); + } + + #[test] + fn the_started_resources_that_depend_on_one_are_found_through_the_others() { + let mut book = listed(); + + let dependants = book.dependants(); + assert_eq!(dependants.of("ox_lib"), ["ox_inventory", "ox_target", "shops"]); + assert_eq!(dependants.of("ox_inventory"), ["shops"]); + assert_eq!(dependants.of("oxmysql"), ["ox_inventory", "shops"]); + assert_eq!(dependants.of("shops"), Vec::::new()); + assert_eq!(dependants.of("unknown"), Vec::::new()); + + book.started(&started(&["ox_lib", "ox_target", "oxmysql"]), &[], 2000); + assert_eq!( + book.dependants().of("ox_lib"), + ["ox_target"], + "a stopped resource has nothing more to lose" + ); + } + + #[test] + fn a_dependency_is_found_by_a_name_another_resource_provides() { + let mut book = Book::default(); + let mut provider = reported("oxmysql", "started", "", &[]); + provider.provides = vec!["mysql-async".to_owned(), "ghmattimysql".to_owned()]; + let mut old = reported("mysql-async", "stopped", "", &[]); + old.version = Some(" ".to_owned()); + let resources = vec![ + provider, + old, + reported("garage", "started", "", &["mysql-async"]), + reported("bank", "started", "", &["ghmattimysql"]), + ]; + book.page(1, 1, true, resources, BRIDGE, 0); + + assert_eq!(book.dependants().of("oxmysql"), ["bank", "garage"]); + assert_eq!(book.dependants().of("mysql-async"), Vec::::new()); + assert_eq!(book.find("mysql-async").unwrap().version, None); + } + + #[test] + fn a_report_of_started_resources_sets_every_state_and_what_happened() { + let mut book = listed(); + + let changed = book.started( + &started(&[BRIDGE, "ox_lib", "oxmysql", "taxi"]), + &events(&[ + ("shops", false), + ("ox_inventory", false), + ("ox_target", false), + ("ox_lib", false), + ("ox_lib", true), + ("taxi", true), + ]), + 5000, + ); + + assert!(changed); + let turn = |name: &str| { + let known = book.find(name).unwrap(); + let turn = known.turn.clone().unwrap(); + assert_eq!(turn.at_ms, 5000, "{name}"); + (known.state, turn.kind, turn.with) + }; + let with_lib = Some("ox_lib".to_owned()); + assert_eq!(turn("ox_lib"), (State::Started, TurnKind::Restarted, None)); + assert_eq!( + turn("ox_inventory"), + (State::Stopped, TurnKind::Stopped, with_lib.clone()) + ); + assert_eq!( + turn("ox_target"), + (State::Stopped, TurnKind::Stopped, with_lib.clone()) + ); + assert_eq!( + turn("shops"), + (State::Stopped, TurnKind::Stopped, with_lib), + "the root of what it depends on, not the resource in between" + ); + assert_eq!(turn("taxi"), (State::Started, TurnKind::Started, None)); + assert_eq!(book.find("oxmysql").unwrap().turn, None); + assert!(!book.started(&started(&[BRIDGE, "ox_lib", "oxmysql", "taxi"]), &[], 6000)); + } + + #[test] + fn states_that_changed_without_an_event_still_say_what_happened() { + let mut book = listed(); + + book.started(&started(&["ox_lib", "taxi"]), &[], 7000); + + let kind = |name: &str| book.find(name).unwrap().turn.as_ref().map(|turn| turn.kind); + assert_eq!(kind("oxmysql"), Some(TurnKind::Stopped)); + assert_eq!(kind("taxi"), Some(TurnKind::Started)); + assert_eq!(kind("ox_lib"), None); + } + + #[test] + fn a_run_that_has_not_listed_its_resources_takes_no_report() { + let mut book = Book::default(); + + assert!(!book.started(&started(&["chat"]), &events(&[("chat", true)]), 1)); + assert_eq!(book.resources(), []); + } + + #[test] + fn a_stop_then_a_start_of_one_resource_is_a_restart_wherever_they_stand() { + let folded = turns(&events(&[ + ("a", false), + ("b", false), + ("a", true), + ("c", true), + ("c", false), + ("b", true), + ("b", false), + ])); + + assert_eq!( + folded, + [ + ("a".to_owned(), TurnKind::Restarted), + ("b".to_owned(), TurnKind::Stopped), + ("c".to_owned(), TurnKind::Stopped) + ] + ); + } + + #[test] + fn a_list_keeps_what_fits_and_says_when_it_left_some_out() { + let mut book = Book::default(); + let mut long = reported(&"n".repeat(MAX_NAME + 1), "started", "", &[]); + long.needs = vec!["x".to_owned()]; + let mut twice = reported("twice", "started", "", &["a", "a", "", "/server:4500"]); + twice.version = Some("v".repeat(200)); + let resources = vec![long, twice.clone(), twice, reported("", "started", "", &[])]; + + book.page(1, 1, true, resources, BRIDGE, 0); + + assert_eq!(names(&book), ["twice"]); + assert!(book.truncated()); + let twice = book.find("twice").unwrap(); + assert_eq!(twice.needs, ["a"]); + assert_eq!(twice.version.as_ref().map(String::len), Some(MAX_VERSION)); + } +} diff --git a/crates/observer-fxserver/src/start.rs b/crates/observer-fxserver/src/start.rs index e72b587a..5e798509 100644 --- a/crates/observer-fxserver/src/start.rs +++ b/crates/observer-fxserver/src/start.rs @@ -2,7 +2,7 @@ //! once it is up, and nothing of the one it is starting. The command of the cfg it is at is worked //! out from the resources each command starts. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::sync::{Mutex, PoisonError}; use std::time::{Duration, Instant}; @@ -82,6 +82,12 @@ impl Plan { enum Said<'a> { /// How many resources the folder holds, said once as the scan of it ends. Found(u32), + /// What a `refresh` found: new resources, and how many of those it read again changed. + Refreshed { + added: u32, + changed: u32, + read: u32, + }, /// A resource with server scripts begins to start. Entering(&'a str), Started { @@ -132,6 +138,15 @@ impl<'a> Said<'a> { return (!resource.is_empty()).then_some(Self::Started { resource, warnings }); } let found = text.strip_prefix("Found ")?.strip_suffix(" resources.")?; + // `ServerResources.cpp` says it this way once it read again resources it knew. + if let Some((added, read)) = found.split_once(" new resources, and refreshed ") { + let (changed, read) = read.split_once('/')?; + return Some(Self::Refreshed { + added: added.parse().ok()?, + changed: changed.parse().ok()?, + read: read.parse().ok()?, + }); + } return found.parse().ok().map(Self::Found); } if channel == "c-scripting-core" { @@ -246,6 +261,32 @@ impl Warnings { } } +/// What a `refresh` found, as FXServer said it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Refreshed { + pub at_ms: i64, + /// Resources it did not know. + pub added: u32, + /// Resources whose manifest changed, of those it read again. + pub changed: u32, + pub read: u32, +} + +/// What a run's console said of its resources, each by its name in lower case. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Marks { + /// The resources FXServer could not start and has not started since, with when it said so. + pub failed: HashMap, + /// What FXServer counted as it last started each resource it warned about. + pub warned: HashMap, + /// The heavy files it named of each resource, and how many of them oversized. + pub heavy: HashMap, + /// The files a manifest names that its resource does not hold. + pub missing: HashMap, + /// The latest `refresh` of the run. + pub refreshed: Option, +} + /// The last resource FXServer said it started. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct Started { @@ -303,7 +344,11 @@ struct State { plan: Plan, found: Option, started: HashSet, - failed: HashSet, + /// When FXServer said it could not start each. + failed: HashMap, + /// What FXServer counted as it last started each resource it warned about. + warned: HashMap, + refreshed: Option, last: Option<(Instant, Started)>, /// When FXServer last said anything of its start, which the next resource's time counts from. mark: Instant, @@ -333,7 +378,7 @@ impl State { } fn settled(&self, resource: &str) -> bool { - self.started.contains(resource) || self.failed.contains(resource) + self.started.contains(resource) || self.failed.contains_key(resource) } fn settle(&mut self, at_ms: i64) { @@ -404,7 +449,9 @@ impl Progress { plan, found: None, started: HashSet::new(), - failed: HashSet::new(), + failed: HashMap::new(), + warned: HashMap::new(), + refreshed: None, last: None, mark: spawned, moved: None, @@ -432,6 +479,15 @@ impl Progress { let mut state = self.state(); match said { Said::Found(found) => state.found = Some(found), + Said::Refreshed { added, changed, read } => { + state.refreshed = Some(Refreshed { + at_ms, + added, + changed, + read, + }); + return; + } Said::Entering(resource) => { state.entering = Some(resource.to_owned()); return; @@ -476,6 +532,11 @@ impl Progress { state.timed(resource, took); } state.failed.remove(&lower); + if warnings == 0 { + state.warned.remove(&lower); + } else { + state.warned.insert(lower.clone(), warnings); + } state.last = Some(( at, Started { @@ -492,7 +553,7 @@ impl Progress { state.place_missing(resource); let lower = resource.to_ascii_lowercase(); if !state.started.contains(&lower) { - state.failed.insert(lower); + state.failed.insert(lower, at_ms); } state.entering = None; } @@ -521,9 +582,31 @@ impl Progress { self.state().warnings.clone() } + /// What the console said of each resource so far, in this run. + pub fn marks(&self) -> Marks { + let state = self.state(); + let mut heavy: HashMap = HashMap::new(); + for asset in &state.warnings.heavy { + let (files, oversized) = heavy.entry(asset.resource.to_ascii_lowercase()).or_default(); + *files += 1; + *oversized += u32::from(asset.oversized); + } + let mut missing: HashMap = HashMap::new(); + for file in &state.warnings.missing { + *missing.entry(file.resource.to_ascii_lowercase()).or_default() += 1; + } + Marks { + failed: state.failed.clone(), + warned: state.warned.clone(), + heavy, + missing, + refreshed: state.refreshed, + } + } + pub fn snapshot(&self) -> Snapshot { let state = self.state(); - let count = |set: &HashSet| u32::try_from(set.len()).unwrap_or(u32::MAX); + let count = |resources: usize| u32::try_from(resources).unwrap_or(u32::MAX); let at = state.plan.steps.get(state.step).map(|step| At { file: step.file.clone(), line: step.line, @@ -535,8 +618,8 @@ impl Progress { resource: state.entering.clone(), }); Snapshot { - started: count(&state.started), - failed: count(&state.failed), + started: count(state.started.len()), + failed: count(state.failed.len()), found: state.found, planned: state.plan.resources, last: state.last.as_ref().map(|(_, started)| started.clone()), @@ -928,6 +1011,83 @@ mod tests { ); } + #[test] + fn what_the_console_says_of_a_resource_is_kept_by_its_name_until_it_starts_clean() { + let start = Start::new(plan(Vec::new())); + start + .says(1, "[ resources:maps:str] Asset maps/dock.ydr uses 49.5 MiB of physical memory. Oversized assets can and WILL lead to streaming issues (such as models not loading/rendering).") + .says(1, "[ resources:maps:str] Asset maps/pier.ytd uses 16 MiB of virtual memory.") + .says(1, "[resources:Maps:strea] Warning: could not find file `x.meta` (defined in fxmanifest.lua:12)") + .starts(2, "Maps (3 warnings)") + .starts(3, "chat") + .says(4, "[ citizen-server-impl] Couldn't start resource Nursery.") + .says(5, "[ citizen-server-impl] Couldn't start resource jobs."); + + let marks = start.progress.marks(); + assert_eq!(marks.warned, HashMap::from([("maps".to_owned(), 3)])); + assert_eq!(marks.heavy, HashMap::from([("maps".to_owned(), (2, 1))])); + assert_eq!(marks.missing, HashMap::from([("maps".to_owned(), 1)])); + assert_eq!( + marks.failed, + HashMap::from([("nursery".to_owned(), 4000), ("jobs".to_owned(), 5000)]) + ); + assert_eq!(marks.refreshed, None); + + start.starts(6, "maps").starts(7, "jobs"); + let marks = start.progress.marks(); + assert_eq!(marks.warned, HashMap::new(), "it started without a warning since"); + assert_eq!(marks.failed, HashMap::from([("nursery".to_owned(), 4000)])); + } + + #[test] + fn a_refresh_says_what_it_found_and_a_start_only_how_many() { + assert_eq!( + Said::read("[ resources] Found 2 new resources, and refreshed 3/391 resources."), + Some(Said::Refreshed { + added: 2, + changed: 3, + read: 391 + }) + ); + assert_eq!( + Said::read("[ resources] Found 0 new resources, and refreshed 0/7 resources."), + Some(Said::Refreshed { + added: 0, + changed: 0, + read: 7 + }) + ); + for line in [ + "[ resources] Found 2 new resources, and refreshed 3 resources.", + "[ resources] Found x new resources, and refreshed 3/391 resources.", + "[ script:evil] Found 2 new resources, and refreshed 3/391 resources.", + ] { + assert_eq!(Said::read(line), None, "{line}"); + } + let start = Start::new(plan(Vec::new())); + + start.says(1, "[ resources] Found 391 resources.").says( + 90, + "[ resources] Found 1 new resources, and refreshed 0/391 resources.", + ); + + assert_eq!( + start.progress.marks().refreshed, + Some(Refreshed { + at_ms: 90_000, + added: 1, + changed: 0, + read: 391 + }) + ); + assert_eq!(start.progress.snapshot().found, Some(391)); + assert_eq!( + start.progress.moved(), + Some(start.spawned + Duration::from_secs(1)), + "a refresh is no part of the start" + ); + } + #[test] fn the_resource_whose_scripts_load_is_named_until_it_is_up() { let start = Start::new(plan(vec![step(3, "[core]", &["framework", "hud"])])); diff --git a/crates/observer-fxserver/src/stdin.rs b/crates/observer-fxserver/src/stdin.rs index 82b68b1d..2b026447 100644 --- a/crates/observer-fxserver/src/stdin.rs +++ b/crates/observer-fxserver/src/stdin.rs @@ -35,8 +35,56 @@ const QUIT_REASONS: [StopReason; 5] = [ StopReason::CrashLoop, ]; +const RESOURCE_VERBS: [ResourceVerb; 3] = [ResourceVerb::Start, ResourceVerb::Stop, ResourceVerb::Restart]; +const REFRESH: &str = "refresh"; +/// The longest resource name a line of Observer's carries. +const MAX_RESOURCE_NAME: usize = 64; + +/// What Observer has FXServer do with one resource, by FXServer's own command of that name +/// (`ServerResources.cpp`): `restart` leaves a stopped resource stopped. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ResourceVerb { + Start, + Stop, + Restart, +} + +impl ResourceVerb { + pub fn as_str(self) -> &'static str { + match self { + Self::Start => "start", + Self::Stop => "stop", + Self::Restart => "restart", + } + } +} + +/// A resource's name fit for a console command: 1 to 64 ASCII letters, digits, `_`, `.` and `-`, +/// which leaves no space, quote, bracket or semicolon for the console to read otherwise. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResourceName(String); + +impl ResourceName { + pub fn new(name: &str) -> Option { + safe(name, MAX_RESOURCE_NAME, b"_.-").then(|| Self(name.to_owned())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Whether `text` is 1 to `max` bytes of ASCII letters, digits and `extra`. +pub(crate) fn safe(text: &str, max: usize, extra: &[u8]) -> bool { + (1..=max).contains(&text.len()) + && text + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || extra.contains(&byte)) +} + /// A line Observer writes on its own behalf. No variant carries text a user typed: the reason of -/// `quit` comes from a closed list, and bridge events travel hex-encoded. +/// `quit` comes from a closed list, bridge events travel hex-encoded, and a resource is named as +/// FXServer reported it, by a name the console reads as one word. #[derive(Debug, Clone, PartialEq, Eq)] pub enum SystemLine { Quit(StopReason), @@ -45,6 +93,9 @@ pub enum SystemLine { SetPerfUser, SetPerfPassword(String), SetPlayersToken(String), + Resource(ResourceVerb, ResourceName), + /// Has FXServer read its resource folders again: new resources and changed manifests. + Refresh, } impl SystemLine { @@ -56,6 +107,8 @@ impl SystemLine { Self::SetPerfUser => format!("set sv_prometheusBasicAuthUser {PERF_USER}"), Self::SetPerfPassword(password) => format!("set sv_prometheusBasicAuthPassword {password}"), Self::SetPlayersToken(token) => format!("set sv_playersToken {token}"), + Self::Resource(verb, name) => format!("{} {}", verb.as_str(), name.as_str()), + Self::Refresh => REFRESH.to_owned(), } } @@ -63,7 +116,7 @@ impl SystemLine { /// connection gets. fn echo(&self) -> Option { match self { - Self::Quit(_) => Some(self.render()), + Self::Quit(_) | Self::Resource(..) | Self::Refresh => Some(self.render()), Self::Bridge(event) => Some(format!("observer_cmd {}", event.name())), // Verdicts come one per connection and positions every few seconds: the console would // fill with them. @@ -97,7 +150,12 @@ pub fn allowed(line: &str) -> bool { if let Some(token) = line.strip_prefix("set sv_playersToken ") { return is_token(token); } - line == format!("set sv_prometheusBasicAuthUser {PERF_USER}") + if let Some((verb, name)) = line.split_once(' ') + && RESOURCE_VERBS.iter().any(|known| known.as_str() == verb) + { + return ResourceName::new(name).is_some(); + } + line == REFRESH || line == format!("set sv_prometheusBasicAuthUser {PERF_USER}") } fn is_token(text: &str) -> bool { @@ -252,7 +310,16 @@ pub enum StdinError { pub(crate) enum Write { System(SystemLine), - User { command: UserCommand, actor: String }, + /// A line of Observer's that someone asked for: checked as any of them, and echoed as that + /// person's command. + Asked { + line: SystemLine, + actor: String, + }, + User { + command: UserCommand, + actor: String, + }, } type Request = (Write, oneshot::Sender>); @@ -289,13 +356,17 @@ impl StdinWriter { async fn own(stdin: Stdin, mut queue: mpsc::Receiver, generation: u32, console: Arc) { while let Some((write, reply)) = queue.recv().await { let (line, echo) = match &write { - Write::System(line) => { + Write::System(line) | Write::Asked { line, .. } => { let text = line.render(); if !allowed(&text) { let _ = reply.send(Err(StdinError::NotAllowed)); continue; } - let echo = line.echo().map(|echo| (LineKind::Observer, None, echo, false)); + let (kind, actor) = match &write { + Write::Asked { actor, .. } => (LineKind::Command, Some(actor.clone())), + _ => (LineKind::Observer, None), + }; + let echo = line.echo().map(|echo| (kind, actor, echo, false)); (text, echo) } Write::User { command, actor } => { @@ -395,6 +466,11 @@ mod tests { let mut lines: Vec = QUIT_REASONS.into_iter().map(SystemLine::Quit).collect(); lines.extend(credentials.lines()); lines.extend(events_carrying("alice").into_iter().map(SystemLine::Bridge)); + for name in ["chat", "ox_lib", "pma-voice", "es.extended", &"n".repeat(64)] { + let name = ResourceName::new(name).unwrap(); + lines.extend(RESOURCE_VERBS.map(|verb| SystemLine::Resource(verb, name.clone()))); + } + lines.push(SystemLine::Refresh); for line in lines { assert!(allowed(&line.render()), "{:?} was refused", line.render()); @@ -430,11 +506,61 @@ mod tests { "set sv_prometheusBasicAuthPassword short", "set sv_playersToken 0123456789abcdef0123456789abcdeg", "set sv_licenseKey cfxk_x", - "refresh", + "refresh ", + "refresh; quit", + "start", + "start ", + "stop chat", + "stop chat ", + "restart chat; quit", + "start chat\nquit", + "stop \"chat\"", + "stop [maps]", + "start chat extra", + "ensure chat", + "Stop chat", + "exec chat", ] { assert!(!allowed(line), "{line:?} was allowed"); } assert!(!allowed(&format!("observer_cmd {}", "ab".repeat(MAX_BRIDGE_HEX)))); + assert!(!allowed(&format!("stop {}", "n".repeat(MAX_RESOURCE_NAME + 1)))); + } + + #[test] + fn a_resource_is_named_in_a_line_only_by_a_name_the_console_reads_as_one_word() { + for name in ["chat", "ox_lib", "pma-voice", "es.extended", "9mm", "A"] { + let line = SystemLine::Resource(ResourceVerb::Stop, ResourceName::new(name).unwrap()).render(); + + assert_eq!(line, format!("stop {name}")); + assert_eq!(command::split(&line), [line.as_str()]); + assert_eq!(command::words(&line), ["stop", name]); + } + for name in [ + "", + "two words", + "evil;quit", + "\"quoted\"", + "[maps]", + "tab\tbed", + "line\nbreak", + "back\\slash", + "caf\u{e9}", + "//comment", + "#comment", + "$var", + &"n".repeat(MAX_RESOURCE_NAME + 1), + ] { + assert_eq!(ResourceName::new(name), None, "{name:?}"); + } + } + + #[test] + fn a_line_someone_asked_for_shows_in_the_console_as_it_is_written() { + let stop = SystemLine::Resource(ResourceVerb::Stop, ResourceName::new("chat").unwrap()); + + assert_eq!(stop.echo().as_deref(), Some("stop chat")); + assert_eq!(SystemLine::Refresh.echo().as_deref(), Some("refresh")); } #[test] diff --git a/crates/observer-fxserver/src/supervisor.rs b/crates/observer-fxserver/src/supervisor.rs index c8b1982c..f5cfadcc 100644 --- a/crates/observer-fxserver/src/supervisor.rs +++ b/crates/observer-fxserver/src/supervisor.rs @@ -37,8 +37,12 @@ use crate::perf::TickReport; use crate::players::{PlayerReport, PlayerReportKind}; use crate::pre_launch::{self, PreLaunch, PreLaunchRequest, PreLaunchResult, TOOLS}; use crate::recovery::{Next, Recovery, RecoveryPolicy}; +use crate::resources::Book; use crate::start::{self, Progress}; -use crate::stdin::{CommandRefusal, Credentials, StdinError, StdinWriter, SystemLine, UserCommand, Write}; +use crate::stdin::{ + CommandRefusal, Credentials, ResourceName, ResourceVerb, StdinError, StdinWriter, SystemLine, + UserCommand, Write, +}; use crate::trace::{self, Trace, TraceDecoder, TraceKind}; use crate::tree::{self, TreeContext}; @@ -301,6 +305,11 @@ pub enum EventKind { rss: u64, external: u64, }, + /// The bridge listed every resource FXServer knows, once its cfg has run and after each + /// `refresh`: [`Supervisor::resources`] has them. + ResourceList { + resources: usize, + }, } /// A resource the server started or stopped while it ran. @@ -482,6 +491,8 @@ pub enum ControlError { } type Reply = oneshot::Sender>; +/// How a line handed to the stdin writer went, once it is known. +type Written = oneshot::Receiver>; enum Request { Start { @@ -515,7 +526,17 @@ enum Request { Command { command: UserCommand, actor: String, - reply: Reply>>, + reply: Reply, + }, + Resource { + verb: ResourceVerb, + name: ResourceName, + by: String, + reply: Reply, + }, + RefreshResources { + by: String, + reply: Reply, }, Shutdown { shortened: bool, @@ -527,14 +548,62 @@ enum Request { SkipHangSnapshots, } +/// The bridge's report of the resources started, with the starts and stops since the one before +/// as `[name, "start" | "stop"]`: taken into what the run knows of its resources, and handed on. +fn resources_started( + held: &RunResources, + started: Vec, + events: Vec<(String, String)>, + dropped: u32, +) -> EventKind { + let events: Vec = events + .into_iter() + .filter_map(|(name, change)| { + let started = match change.as_str() { + "start" => true, + "stop" => false, + _ => return None, + }; + Some(ResourceEvent { name, started }) + }) + .collect(); + let turns: Vec<(String, bool)> = events + .iter() + .map(|event| (event.name.clone(), event.started)) + .collect(); + if let Some((_, book)) = &mut *held.lock().unwrap_or_else(PoisonError::into_inner) { + book.started(&started, &turns, now_ms()); + } + EventKind::Resources { + started, + events, + dropped, + } +} + /// The latest spawn's progress and when it was spawned: what FXServer said as it started, kept /// after the start and until the next one. type LatestStart = Arc)>>>; +/// What the bridge of the run that is up reported of its resources, with the run's generation: +/// none while no server runs. +type RunResources = Arc>>; + +/// The resources of the run that is up. +#[derive(Debug, Clone)] +pub struct Resources { + pub generation: u32, + /// As the bridge reported them: nothing listed until the cfg has run. + pub book: Book, + /// What the console said of them. + pub marks: start::Marks, +} + /// The handle to the supervisor task. Clones talk to the same task. #[derive(Clone)] pub struct Supervisor { latest_start: LatestStart, + resources: RunResources, latest_hook: LatestHook, requests: mpsc::Sender, events: broadcast::Sender, @@ -575,9 +644,11 @@ impl Supervisor { let (signals_tx, signals) = mpsc::channel(CHANNEL); let (probes_tx, probes) = mpsc::channel(CHANNEL); let latest_start = LatestStart::default(); + let resources = RunResources::default(); let latest_hook = LatestHook::default(); let actor = Actor { latest_start: Arc::clone(&latest_start), + resources: Arc::clone(&resources), latest_hook: Arc::clone(&latest_hook), counts: counts.clone(), recovery: Recovery::new(settings.recovery.clone()), @@ -614,6 +685,7 @@ impl Supervisor { ( Self { latest_start, + resources, latest_hook, requests, events, @@ -662,6 +734,60 @@ impl Supervisor { .map(|(spawned_ms, progress)| (*spawned_ms, progress.warnings())) } + /// The resources of the run that is up, as its bridge reported them and as its console spoke + /// of them. None while no server runs. + pub fn resources(&self) -> Option { + let (generation, book) = self + .resources + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clone()?; + let marks = self + .latest_start + .lock() + .unwrap_or_else(PoisonError::into_inner) + .as_ref() + .map(|(_, progress)| progress.marks()) + .unwrap_or_default(); + Some(Resources { + generation, + book, + marks, + }) + } + + /// Writes FXServer's own `start`, `stop` or `restart` of `name` to its console, echoed there + /// as `by`'s command: only on a running server whose bridge has reported, and never for the + /// bridge itself. + pub async fn resource( + &self, + verb: ResourceVerb, + name: ResourceName, + by: &str, + ) -> Result<(), ControlError> { + let written = self + .ask(|reply| Request::Resource { + verb, + name, + by: by.to_owned(), + reply, + }) + .await?; + Ok(written.await.map_err(|_| StdinError::Closed)??) + } + + /// Writes FXServer's `refresh`, which reads its resource folders again, under the conditions + /// of [`Supervisor::resource`]. + pub async fn refresh_resources(&self, by: &str) -> Result<(), ControlError> { + let written = self + .ask(|reply| Request::RefreshResources { + by: by.to_owned(), + reply, + }) + .await?; + Ok(written.await.map_err(|_| StdinError::Closed)??) + } + /// Gives the start under way [`START_EXTENSION`] more before it is taken for stuck, counted /// from when it would have been. Only of a server whose cfg is still running. pub async fn extend_start(&self, by: &str) -> Result<(), ControlError> { @@ -1012,6 +1138,7 @@ enum Wake { struct Actor { latest_start: LatestStart, + resources: RunResources, latest_hook: LatestHook, settings: Settings, console: Arc, @@ -1132,6 +1259,17 @@ impl Actor { } => { let _ = reply.send(self.command(command, actor)); } + Request::Resource { + verb, + name, + by, + reply, + } => { + let _ = reply.send(self.resource(verb, name, by)); + } + Request::RefreshResources { by, reply } => { + let _ = reply.send(self.asked(SystemLine::Refresh, by)); + } Request::Shutdown { shortened, reason } => self.shutdown(shortened, reason), Request::Configure(launch) => self.configure(*launch), Request::SkipHangSnapshots => self.settings.hang_snapshot = None, @@ -1369,11 +1507,23 @@ impl Actor { } } - fn command( - &mut self, - command: UserCommand, - actor: String, - ) -> Result>, ControlError> { + fn resource(&self, verb: ResourceVerb, name: ResourceName, by: String) -> Result { + self.asked(SystemLine::Resource(verb, name), by) + } + + /// Writes a line of Observer's that `by` asked for, on a running server whose bridge has + /// reported: nothing is queued for a server still starting or already stopping. + fn asked(&self, line: SystemLine, by: String) -> Result { + match (&self.run, self.phase) { + (Some(run), Phase::Running) if run.bridge_ready => { + Ok(run.stdin.send(Write::Asked { line, actor: by })) + } + (Some(_), Phase::Notice { .. } | Phase::Quitting { .. }) => Err(ControlError::Stopping), + _ => Err(ControlError::NotRunning), + } + } + + fn command(&mut self, command: UserCommand, actor: String) -> Result { let Some(run) = &mut self.run else { return Err(ControlError::NotRunning); }; @@ -1786,21 +1936,24 @@ impl Actor { started, events, dropped, - }) => EventKind::Resources { - started, - events: events - .into_iter() - .filter_map(|(name, change)| { - let started = match change.as_str() { - "start" => true, - "stop" => false, - _ => return None, - }; - Some(ResourceEvent { name, started }) - }) - .collect(), - dropped, - }, + }) => resources_started(&self.resources, started, events, dropped), + Some(Report::ResourceList { + number, + page, + last, + resources, + }) => { + let mut held = self.resources.lock().unwrap_or_else(PoisonError::into_inner); + let Some((_, book)) = &mut *held else { + return; + }; + if !book.page(number, page, last, resources, &run.bridge, now_ms()) { + return; + } + EventKind::ResourceList { + resources: book.resources().len(), + } + } Some(Report::Heap { heap_used, heap_total, @@ -2118,6 +2271,7 @@ impl Actor { let cancel = CancellationToken::new(); let (stdin, stdin_task) = StdinWriter::start(spawned.stdin, generation, Arc::clone(&self.console)); let progress = self.progress_of(prepared.plan); + *self.resources.lock().unwrap_or_else(PoisonError::into_inner) = Some((generation, Book::default())); let mut tasks = vec![ tokio::spawn(read_console( spawned.stdout, @@ -2292,6 +2446,7 @@ impl Actor { let Some(run) = self.run.take() else { return; }; + *self.resources.lock().unwrap_or_else(PoisonError::into_inner) = None; self.readings.send_replace(Readings::default()); self.collect_dumps(&run.layout).await; let Run { after, ending, .. } = run; diff --git a/crates/observer/src/fxserver.rs b/crates/observer/src/fxserver.rs index 9d9b1b0a..e39c6cf8 100644 --- a/crates/observer/src/fxserver.rs +++ b/crates/observer/src/fxserver.rs @@ -346,7 +346,8 @@ async fn print_events(mut events: broadcast::Receiver, terminal: Terminal match &event.kind { EventKind::State { .. } => {} // Routine reports, every few seconds. - kind @ (EventKind::Heap { .. } | EventKind::Resources { .. }) => { + kind + @ (EventKind::Heap { .. } | EventKind::Resources { .. } | EventKind::ResourceList { .. }) => { debug!(generation = event.generation, ?kind, "bridge report"); } kind => { diff --git a/resource/observer/server.lua b/resource/observer/server.lua index f355072d..37491644 100644 --- a/resource/observer/server.lua +++ b/resource/observer/server.lua @@ -316,6 +316,63 @@ AddEventHandler('playerDropped', function(reason, resource, category) end report('drop', { id = id, t = GetGameTimer(), reason = reason, resource = resource, category = category, ped = ped }) end) + +-- Every resource FXServer knows, for Observer alone: no part of the contract with the server's +-- resources. In pages, as a server holds hundreds. +local RESOURCE_LIST_PAGE = 100 +-- A table given to `dependencies` in a manifest is kept under the key less its last letter, which +-- FXServer reads its dependencies from as well. +local NEEDS_KEYS = { 'dependency', 'dependencie' } +local PROVIDES_KEYS = { 'provide' } +local resourceLists = 0 + +local function manifestEntries(name, keys) + local entries = {} + for _, key in ipairs(keys) do + for index = 0, GetNumResourceMetadata(name, key) - 1 do + entries[#entries + 1] = GetResourceMetadata(name, key, index) + end + end + return #entries > 0 and entries or nil +end + +local function reportResourceList() + resourceLists = resourceLists + 1 + local list = resourceLists + local names = {} + for index = 0, GetNumResources() - 1 do + local name = GetResourceByFindIndex(index) + if name then + names[#names + 1] = name + end + end + table.sort(names) + local pages = math.max(1, math.ceil(#names / RESOURCE_LIST_PAGE)) + for page = 1, pages do + local entries = {} + for index = (page - 1) * RESOURCE_LIST_PAGE + 1, math.min(page * RESOURCE_LIST_PAGE, #names) do + local name = names[index] + entries[#entries + 1] = { + name = name, + state = GetResourceState(name), + path = GetResourcePath(name), + version = GetResourceMetadata(name, 'version', 0), + needs = manifestEntries(name, NEEDS_KEYS), + provides = manifestEntries(name, PROVIDES_KEYS), + } + end + report('resource_list', { list = list, page = page, last = page == pages, resources = entries }) + if page < pages then + Wait(0) + end + end +end + +-- `refresh` reads every manifest again and may find new resources. +AddEventHandler('onResourceListRefresh', function() + CreateThread(reportResourceList) +end) + local cfgRan = false -- The thread first runs once the cfg has run, so `ready` means its resources were started. @@ -323,6 +380,7 @@ CreateThread(function() cfgRan = true report('ready', { contractVersion = CONTRACT_VERSION }) report('resources', { started = startedResources() }) + reportResourceList() reportPlayers() while true do report('hb') From ad2af3063e3eadecf28d377b601430fa63acc073 Mon Sep 17 00:00:00 2001 From: ImBaphomet Date: Wed, 11 Nov 2026 05:09:18 +0100 Subject: [PATCH 2/5] feat(fake-fxserver): start, stop, restart and refresh resources as FXServer's sources say, and list them as the bridge does --- crates/fake-fxserver/src/main.rs | 236 ++++++++++++++--- crates/fake-fxserver/src/resources.rs | 367 ++++++++++++++++++++++++++ 2 files changed, 567 insertions(+), 36 deletions(-) create mode 100644 crates/fake-fxserver/src/resources.rs diff --git a/crates/fake-fxserver/src/main.rs b/crates/fake-fxserver/src/main.rs index f007d8f7..fd8c62f7 100644 --- a/crates/fake-fxserver/src/main.rs +++ b/crates/fake-fxserver/src/main.rs @@ -3,8 +3,9 @@ //! its main loop, writes structured traces on fd 3 and serves `/dynamic.json`, `/perf/`, //! `/players.json` and `/info.json` on the cfg's `endpoint_add_tcp`. Started, a folder of the //! build's `citizen/system_resources` that holds Observer's bridge plays it, under the folder's name -//! as FXServer knows the bridge: `ready` and the started resources once, then a heartbeat and the -//! heap. The build's `citizen/` is the `citizen_dir` convar, as on Linux, or else the one next to the +//! as FXServer knows the bridge: `ready`, the started resources and the list of every resource once, +//! then a heartbeat and the heap, the started resources again half a second after one starts or +//! stops, and the list again after a `refresh`. The build's `citizen/` is the `citizen_dir` convar, as on Linux, or else the one next to the //! executable. //! //! Failures on demand, through convars (`+set fake_x y`, in the cfg, or in a `fake-build.cfg` next @@ -17,6 +18,14 @@ //! for one of them. FXServer 38435 prints `Started resource` once a resource is up and nothing //! while it reads its stream files, which took a clothing pack minutes on a first start; //! - `fake_fail_start `: that resource does not start, as one whose asset is disabled; +//! - `start`, `stop`, `restart`, `ensure` and `refresh` do what `ServerResources.cpp`, +//! `ServerResourceList.cpp` and `ResourceDependencyLoader.cpp` say they do, read at the commit of +//! build 38435 and measured on no server: a resource starts after those its manifest depends +//! on; one that stops takes with it, first, every started one that depends on it, and nothing +//! starts those again; `restart` leaves a stopped resource stopped, with `Can't restart a +//! stopped resource.`; `refresh` scans the folders again and says what it found. The line +//! FXServer prints of a dependency it cannot find is not printed; +//! - `fake_resource_page ` (100): how many resources one page of the bridge's list holds; //! - `fake_heavy `, `fake_missing `, each a list separated by commas: as it starts //! one of them, FXServer warns of two heavy streamed files, one of them oversized, or of a script //! its manifest names and it does not hold, on the channel `resources::stream` cut at @@ -81,6 +90,7 @@ mod console; mod dump; mod http; mod players; +mod resources; #[allow(unsafe_code)] mod traces; @@ -98,6 +108,7 @@ use serde_json::json; use crate::console::{CMD, LICENSE, MAIN, NET, RESOURCES, SCRIPTING, SERVER}; use crate::players::{Player, Players}; +use crate::resources::Registry; use crate::traces::Traces; const TICK: Duration = Duration::from_millis(50); @@ -108,6 +119,10 @@ const HOLD_PIPES: &str = "--fake-hold-pipes"; const DUMP_SERVER: &str = "-dumpserver:"; /// FXServer's main thread, as FXServer 35245 names it. const SV_MAIN: &str = "UV loop: svMain"; +/// Starts and stops the bridge tells in one report, past which it only counts them. +const RESOURCE_EVENTS_MAX: usize = 64; +/// How long the bridge waits after a start or a stop before it reports, unless its script says. +const RESOURCES_SETTLE_MS: u64 = 500; /// What the HTTP threads share with the main loop. pub struct Server { @@ -202,6 +217,16 @@ struct Bridge { ready: bool, /// The `CONTRACT_VERSION` of the script, which the real bridge reports when it is ready. contract: u64, + /// The script's `RESOURCES_SETTLE_MS`: one report covers the starts and stops of that long. + settle: Duration, +} + +/// The starts and stops the bridge has not reported yet. +#[derive(Default)] +struct Unreported { + events: Vec<[String; 2]>, + dropped: u64, + report_at: Option, } /// While the build's scheduler loads Observer's hook: every resource counts and reports. @@ -237,6 +262,12 @@ struct Fake { endpoint: Option, bridge: Option, counting: Option, + registry: Registry, + /// The cfg has run: from then on `ensure` stops a started resource before it starts it. + configured: bool, + unreported: Unreported, + /// The lists of resources the bridge sent, which number their pages. + lists: u64, /// Every resource started, in order. started: Vec, quit_at: Option, @@ -351,6 +382,10 @@ fn main() { endpoint: None, bridge: None, counting: None, + registry: Registry::default(), + configured: false, + unreported: Unreported::default(), + lists: 0, started: Vec::new(), quit_at: None, children: Vec::new(), @@ -401,6 +436,7 @@ impl Fake { for command in commands.iter().filter(|command| is_set(command)) { self.execute(command); } + self.registry = Registry::scan(&server_folder(), self.system_resources().as_deref()); console::line(RESOURCES, "\x1b[32mScanning resources.\x1b[0m"); console::line(RESOURCES, "\x1b[32mFound 4 resources.\x1b[0m"); console::line(LICENSE, "Authenticating server license key..."); @@ -421,6 +457,7 @@ impl Fake { for command in commands.iter().filter(|command| !is_set(command)) { self.execute(command); } + self.configured = true; if let Some(address) = self.endpoint.clone() { if self.server.flag("fake_bind_error") { @@ -462,6 +499,7 @@ impl Fake { .retain_mut(|child| matches!(child.try_wait(), Ok(None))); self.admit_unanswered(now); self.report_positions(now); + self.report_resources(now); self.beat(now); self.count(now); self.server.tick(); @@ -484,20 +522,11 @@ impl Fake { ("ensure" | "start", Some(category)) if category.starts_with('[') && category.ends_with(']') => { self.ensure_category(category); } - ("ensure" | "start", Some(resource)) => self.ensure(resource), - ("restart", Some(resource)) => { - stop(resource); - self.start(resource); - self.resources_changed(resource, &["stop", "start"]); - } - ("stop", Some(resource)) => { - stop(resource); - self.started.retain(|started| started != resource); - if self.bridge.as_ref().is_some_and(|bridge| bridge.name == resource) { - self.bridge = None; - } - self.resources_changed(resource, &["stop"]); - } + ("ensure", Some(resource)) => self.ensure(resource), + ("start", Some(resource)) => self.start_command(resource), + ("restart", Some(resource)) => self.restart(resource), + ("stop", Some(resource)) => self.stop(resource), + ("refresh", None) => self.refresh(), ("quit", reason) => self.quit(reason), ("fake_crash", code) => self.crash(code.and_then(|code| code.parse().ok())), ("fake_hang", _) => hang(), @@ -593,18 +622,91 @@ impl Fake { } } + fn is_started(&self, resource: &str) -> bool { + self.started.iter().any(|started| started == resource) + } + + /// FXServer's `ensure`: a start, after a stop of a started resource once the cfg has run. fn ensure(&mut self, resource: &str) { - if self.server.convar("fake_fail_start").as_deref() == Some(resource) { + if self.configured && self.is_started(resource) { + self.stop(resource); + } + self.start_command(resource); + } + + /// FXServer's `start`: nothing for a resource that is up. + fn start_command(&mut self, resource: &str) { + if !self.is_started(resource) && !self.start_after_needs(resource, &mut Vec::new()) { console::line(SERVER, &format!("Couldn't start resource {resource}.")); + } + } + + /// Starts `resource` once every resource its manifest depends on is up, each started in turn. + /// `starting` holds those on the way, so that two that depend on each other end. + fn start_after_needs(&mut self, resource: &str, starting: &mut Vec) -> bool { + if self.server.convar("fake_fail_start").as_deref() == Some(resource) { + return false; + } + starting.push(resource.to_owned()); + for need in self.registry.needs(resource) { + let on = self.registry.resolve(&need, &self.started); + let up = self.is_started(&on) || starting.contains(&on); + if !up && !self.start_after_needs(&on, starting) { + return false; + } + } + self.start(resource); + self.resource_event(resource, "start"); + true + } + + /// FXServer's `stop`: every started resource that depends on this one stops first, and + /// nothing starts it again. + fn stop(&mut self, resource: &str) { + if !self.is_started(resource) { return; } - let restarted = self.started.iter().any(|started| started == resource); + // Out of the started ones first, so that two that depend on each other end. + self.started.retain(|started| started != resource); + for dependant in self.registry.dependants(resource, &self.started) { + self.stop(&dependant); + } + console::line(RESOURCES, &format!("Stopping resource {resource}")); + self.resource_event(resource, "stop"); if self.bridge.as_ref().is_some_and(|bridge| bridge.name == resource) { - stop(resource); + self.bridge = None; + self.unreported = Unreported::default(); + } + } + + /// FXServer's `restart`: a stop then a start, of a started resource only. + fn restart(&mut self, resource: &str) { + if !self.is_started(resource) { + console::line(SERVER, "Can't restart a stopped resource."); + return; + } + self.stop(resource); + self.start_command(resource); + } + + /// FXServer's `refresh`: the folders are scanned again, and the bridge hears of it. + fn refresh(&mut self) { + console::line(RESOURCES, "\x1b[32mScanning resources.\x1b[0m"); + let refreshed = self + .registry + .refresh(&server_folder(), self.system_resources().as_deref()); + let found = if refreshed.read > 0 { + format!( + "Found {} new resources, and refreshed {}/{} resources.", + refreshed.added, refreshed.changed, refreshed.read + ) + } else { + format!("Found {} resources.", refreshed.added) + }; + console::line(RESOURCES, &format!("\x1b[32m{found}\x1b[0m")); + if self.bridge.as_ref().is_some_and(|bridge| bridge.ready) { + self.report_resource_list(); } - self.start(resource); - let events: &[&str] = if restarted { &["stop", "start"] } else { &["start"] }; - self.resources_changed(resource, events); } fn start(&mut self, resource: &str) { @@ -648,7 +750,12 @@ impl Fake { if !self.started.iter().any(|started| started == resource) { self.started.push(resource.to_owned()); } - if let Some(contract) = self.bridge_script(resource).as_deref().and_then(contract_version) { + let script = self.bridge_script(resource); + if let Some(contract) = script.as_deref().and_then(contract_version) { + let settle = script + .as_deref() + .and_then(|script| constant(script, "RESOURCES_SETTLE_MS")) + .unwrap_or(RESOURCES_SETTLE_MS); let heap_period = Duration::from_millis(self.server.number("fake_heap_ms").unwrap_or(10_000)); self.counting = self.hooked(resource).then(|| Counting { next: Instant::now(), @@ -661,20 +768,61 @@ impl Fake { next_heap: Instant::now() + heap_period, ready: false, contract, + settle: Duration::from_millis(settle), }); } } - /// The bridge's report of a resource started or stopped once the cfg has run. - fn resources_changed(&mut self, resource: &str, changes: &[&str]) { + /// A start or a stop the bridge hears of once the cfg has run: it reports them together a + /// moment later, 64 of them at most. + fn resource_event(&mut self, resource: &str, change: &str) { let Some(bridge) = self.bridge.as_ref().filter(|bridge| bridge.ready) else { return; }; - let events: Vec<[&str; 2]> = changes.iter().map(|change| [resource, *change]).collect(); - self.traces.script( - &bridge.name, - &json!({ "type": "observer", "kind": "resources", "started": self.started, "events": events }), - ); + if self.unreported.events.len() < RESOURCE_EVENTS_MAX { + self.unreported + .events + .push([resource.to_owned(), change.to_owned()]); + } else { + self.unreported.dropped += 1; + } + self.unreported + .report_at + .get_or_insert_with(|| Instant::now() + bridge.settle); + } + + /// The bridge's report of the resources started, with the starts and stops since the last. + fn report_resources(&mut self, now: Instant) { + if self.unreported.report_at.is_none_or(|at| now < at) { + return; + } + let unreported = std::mem::take(&mut self.unreported); + let mut report = json!({ "type": "observer", "kind": "resources", "started": self.started }); + if !unreported.events.is_empty() { + report["events"] = json!(unreported.events); + } + if unreported.dropped > 0 { + report["dropped"] = json!(unreported.dropped); + } + self.bridge_report(&report); + } + + /// The bridge's list of every resource FXServer knows, in pages. + fn report_resource_list(&mut self) { + self.lists += 1; + let per_page = usize::try_from(self.server.number("fake_resource_page").unwrap_or(100)) + .unwrap_or(100) + .max(1); + let listed = self.registry.listed(&self.started); + let pages = listed.len().div_ceil(per_page).max(1); + for page in 0..pages { + let resources: Vec<&serde_json::Value> = + listed.iter().skip(page * per_page).take(per_page).collect(); + self.bridge_report(&json!({ + "type": "observer", "kind": "resource_list", "list": self.lists, + "page": page + 1, "last": page + 1 == pages, "resources": resources, + })); + } } /// The bridge's traces. They come from the main loop, so they stop while it is hung. @@ -693,11 +841,16 @@ impl Fake { &bridge.name, &json!({ "type": "observer", "kind": "resources", "started": self.started }), ); + let name = bridge.name.clone(); + self.report_resource_list(); let roster = self.server.players().roster(self.server.uptime_ms()); for page in roster { - self.traces.script(&bridge.name, &page); + self.traces.script(&name, &page); } } + let Some(bridge) = &mut self.bridge else { + return; + }; if now >= bridge.next_heartbeat { bridge.next_heartbeat = now + period; self.traces @@ -975,9 +1128,13 @@ impl Fake { self.traces.script(resource, &report); } + /// The build's own resources, where the bridge is written too. + fn system_resources(&self) -> Option { + Some(self.citizen()?.join("system_resources")) + } + fn bridge_script(&self, resource: &str) -> Option { - let citizen = self.citizen()?; - let script = citizen.join("system_resources").join(resource).join("server.lua"); + let script = self.system_resources()?.join(resource).join("server.lua"); std::fs::read_to_string(script) .ok() .filter(|script| script.contains("RegisterCommand('observer_cmd'")) @@ -1227,8 +1384,9 @@ fn dump_id() -> String { ) } -fn stop(resource: &str) { - console::line(RESOURCES, &format!("Stopping resource {resource}")); +/// The server folder, which FXServer runs in. +fn server_folder() -> PathBuf { + std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) } /// FXServer 35245 exits with code 1 after `quit` on Windows, and dies of SIGSEGV on Linux. @@ -1309,9 +1467,15 @@ fn build_cfg() -> Vec> { /// The `local CONTRACT_VERSION = ` a bridge script reports when it is ready. fn contract_version(script: &str) -> Option { + constant(script, "CONTRACT_VERSION") +} + +/// The number a bridge script gives one of its constants, as `local NAME = `. +fn constant(script: &str, name: &str) -> Option { + let declared = format!("local {name} = "); script .lines() - .find_map(|line| line.strip_prefix("local CONTRACT_VERSION = "))? + .find_map(|line| line.strip_prefix(declared.as_str()))? .trim() .parse() .ok() diff --git a/crates/fake-fxserver/src/resources.rs b/crates/fake-fxserver/src/resources.rs new file mode 100644 index 00000000..72665e94 --- /dev/null +++ b/crates/fake-fxserver/src/resources.rs @@ -0,0 +1,367 @@ +//! The resources the fake knows: the folders with a manifest under the server folder's `resources` +//! and the build's `system_resources`, as `ServerResourceList.cpp` scans them, with what each +//! manifest says of its version, of what it depends on and of the names it provides. A name no +//! folder holds is a resource all the same, which the fake always let start. + +use std::fs; +use std::path::Path; + +use serde_json::{Value, json}; + +const MANIFESTS: [&str; 2] = ["fxmanifest.lua", "__resource.lua"]; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Resource { + pub name: String, + /// As `GetResourcePath` gives it: the root, `/`, then the folders down to the resource's own. + pub path: String, + pub version: Option, + pub needs: Vec, + pub provides: Vec, +} + +/// What a `refresh` found: the resources it did not know, those whose manifest changed, and how +/// many it read again. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Refreshed { + pub added: usize, + pub changed: usize, + pub read: usize, +} + +#[derive(Debug, Default)] +pub struct Registry { + known: Vec, +} + +impl Registry { + /// Scans the two roots, the server's first: of two folders of one name, the first found is + /// the resource. + pub fn scan(server: &Path, system: Option<&Path>) -> Self { + let mut known = Vec::new(); + for root in [Some(server.join("resources")), system.map(Path::to_path_buf)] + .into_iter() + .flatten() + { + let shown = format!("{}/", root.to_string_lossy().replace('\\', "/")); + walk(&root, &shown, &mut known); + } + Self { known } + } + + /// Scans again, as `refresh` does. A resource whose folder is gone stays known. + pub fn refresh(&mut self, server: &Path, system: Option<&Path>) -> Refreshed { + let mut refreshed = Refreshed { + added: 0, + changed: 0, + read: 0, + }; + for found in Self::scan(server, system).known { + if let Some(known) = self.known.iter_mut().find(|known| known.name == found.name) { + refreshed.read += 1; + refreshed.changed += usize::from(*known != found); + *known = found; + } else { + refreshed.added += 1; + self.known.push(found); + } + } + refreshed + } + + fn find(&self, name: &str) -> Option<&Resource> { + self.known.iter().find(|known| known.name == name) + } + + /// The resources `name` depends on, constraints such as `/onesync` left out. + pub fn needs(&self, name: &str) -> Vec { + self.find(name) + .map(|known| { + known + .needs + .iter() + .filter(|need| !need.starts_with('/')) + .cloned() + .collect() + }) + .unwrap_or_default() + } + + /// The resource a dependency named `need` stands for, as `ResourceManager::GetResource` finds + /// it: the one of that name unless it is stopped and a started one provides the name. + pub fn resolve(&self, need: &str, started: &[String]) -> String { + let up = |name: &str| started.iter().any(|started| started == name); + if up(need) { + return need.to_owned(); + } + self.known + .iter() + .filter(|known| known.provides.iter().any(|provided| provided == need)) + .find(|known| up(&known.name) || self.find(need).is_none()) + .map_or_else(|| need.to_owned(), |provider| provider.name.clone()) + } + + /// The started resources whose manifest depends on `name`. + pub fn dependants(&self, name: &str, started: &[String]) -> Vec { + started + .iter() + .filter(|other| other.as_str() != name) + .filter(|other| { + self.needs(other) + .iter() + .any(|need| self.resolve(need, started) == name) + }) + .cloned() + .collect() + } + + /// Every resource as the bridge lists it, in name order: the known ones, and those started + /// that no folder holds. + pub fn listed(&self, started: &[String]) -> Vec { + let state = |name: &str| { + if started.iter().any(|started| started == name) { + "started" + } else { + "stopped" + } + }; + let mut entries: Vec<(String, Value)> = self + .known + .iter() + .map(|known| { + let mut entry = json!({ + "name": known.name, + "state": state(&known.name), + "path": known.path, + }); + if let Some(version) = &known.version { + entry["version"] = json!(version); + } + if !known.needs.is_empty() { + entry["needs"] = json!(known.needs); + } + if !known.provides.is_empty() { + entry["provides"] = json!(known.provides); + } + (known.name.clone(), entry) + }) + .collect(); + for name in started { + if self.find(name).is_none() { + entries.push((name.clone(), json!({ "name": name, "state": "started" }))); + } + } + entries.sort_by(|a, b| a.0.cmp(&b.0)); + entries.into_iter().map(|(_, entry)| entry).collect() + } +} + +/// Reads `folder`: a folder named `[x]` is a category, read in turn, and any other that holds a +/// manifest is a resource. +fn walk(folder: &Path, shown: &str, known: &mut Vec) { + let Ok(entries) = fs::read_dir(folder) else { + return; + }; + let mut folders: Vec<(String, std::path::PathBuf)> = entries + .flatten() + .filter(|entry| entry.path().is_dir()) + .filter_map(|entry| Some((entry.file_name().to_str()?.to_owned(), entry.path()))) + .collect(); + folders.sort(); + for (name, path) in folders { + let shown = format!("{shown}/{name}"); + if name.starts_with('[') && name.ends_with(']') { + walk(&path, &shown, known); + continue; + } + let Some(manifest) = MANIFESTS + .iter() + .find_map(|manifest| fs::read_to_string(path.join(manifest)).ok()) + else { + continue; + }; + if name.starts_with('.') || known.iter().any(|known| known.name == name) { + continue; + } + let mut resource = Resource { + name, + path: shown, + version: None, + needs: Vec::new(), + provides: Vec::new(), + }; + read_manifest(&manifest, &mut resource); + known.push(resource); + } +} + +/// What a manifest says of its version, its dependencies and the names it provides: the keyword +/// at the start of a line, then one quoted value, or several between braces over any lines. +fn read_manifest(manifest: &str, resource: &mut Resource) { + let mut lines = manifest + .lines() + .map(|line| line.split("--").next().unwrap_or_default()); + while let Some(line) = lines.next() { + let line = line.trim_start(); + let Some(keyword) = ["version", "dependencies", "dependency", "provides", "provide"] + .into_iter() + .find(|keyword| { + line.strip_prefix(keyword) + .is_some_and(|rest| rest.starts_with([' ', '\t', '\'', '"', '{', '('])) + }) + else { + continue; + }; + let mut statement = line[keyword.len()..].to_owned(); + while statement.contains('{') && !statement.contains('}') { + let Some(more) = lines.next() else { + break; + }; + statement.push(' '); + statement.push_str(more); + } + let values = quoted(&statement); + match keyword { + "version" => resource.version = values.into_iter().next(), + "dependency" | "dependencies" => resource.needs.extend(values), + _ => resource.provides.extend(values), + } + } +} + +/// The values between single or double quotes of `text`. +fn quoted(text: &str) -> Vec { + let mut values = Vec::new(); + let mut rest = text; + while let Some(start) = rest.find(['\'', '"']) { + let quote = rest[start..].chars().next().unwrap_or('\''); + let inside = &rest[start + 1..]; + let Some(end) = inside.find(quote) else { + break; + }; + values.push(inside[..end].to_owned()); + rest = &inside[end + 1..]; + } + values +} + +#[cfg(test)] +mod tests { + use super::*; + + fn resource(root: &Path, folder: &str, manifest: &str) { + let path = root.join(folder); + fs::create_dir_all(&path).unwrap(); + fs::write(path.join("fxmanifest.lua"), manifest).unwrap(); + } + + fn started(names: &[&str]) -> Vec { + names.iter().map(|&name| name.to_owned()).collect() + } + + #[test] + fn the_two_roots_are_scanned_for_folders_with_a_manifest_under_their_categories() { + let dir = tempfile::tempdir().unwrap(); + let (server, system) = (dir.path().join("data"), dir.path().join("system_resources")); + resource( + &server, + "resources/[core]/[ox]/ox_inventory", + "fx_version 'cerulean'\nversion '2.44.1' -- the release\ndependencies {\n '/onesync',\n 'ox_lib', -- first\n \"oxmysql\",\n}\nprovide 'inventory'\n", + ); + resource( + &server, + "resources/ox_lib", + "version \"3.30.6\"\ndependency 'unused' -- kept\n", + ); + resource(&server, "resources/[core]/ox_lib", "version 'a copy'\n"); + resource(&server, "resources/.hidden", "version '1'\n"); + fs::create_dir_all(server.join("resources/no_manifest")).unwrap(); + resource(&system, "monitor", "provides { 'txAdmin', 'tx' }\n"); + + let registry = Registry::scan(&server, Some(&system)); + + let root = server.join("resources").to_string_lossy().replace('\\', "/"); + let names: Vec<&str> = registry.known.iter().map(|known| known.name.as_str()).collect(); + assert_eq!(names, ["ox_inventory", "ox_lib", "monitor"]); + let inventory = registry.find("ox_inventory").unwrap(); + assert_eq!(inventory.path, format!("{root}//[core]/[ox]/ox_inventory")); + assert_eq!(inventory.version.as_deref(), Some("2.44.1")); + assert_eq!(inventory.needs, ["/onesync", "ox_lib", "oxmysql"]); + assert_eq!(inventory.provides, ["inventory"]); + assert_eq!(registry.needs("ox_inventory"), ["ox_lib", "oxmysql"]); + assert_eq!( + registry.find("ox_lib").unwrap().version.as_deref(), + Some("a copy"), + "the first folder found, in name order" + ); + assert_eq!(registry.find("monitor").unwrap().provides, ["txAdmin", "tx"]); + } + + #[test] + fn a_dependency_stands_for_the_started_resource_that_provides_its_name() { + let dir = tempfile::tempdir().unwrap(); + let server = dir.path(); + resource(server, "resources/oxmysql", "provide 'mysql-async'\n"); + resource(server, "resources/mysql-async", "version '3'\n"); + resource(server, "resources/garage", "dependency 'mysql-async'\n"); + resource(server, "resources/bank", "dependencies { 'ghost', 'garage' }\n"); + let registry = Registry::scan(server, None); + + let up = started(&["oxmysql", "garage", "bank"]); + assert_eq!(registry.resolve("mysql-async", &up), "oxmysql"); + assert_eq!( + registry.resolve("mysql-async", &started(&["mysql-async"])), + "mysql-async" + ); + assert_eq!(registry.resolve("mysql-async", &[]), "mysql-async"); + assert_eq!(registry.resolve("ghost", &up), "ghost"); + assert_eq!(registry.dependants("oxmysql", &up), ["garage"]); + assert_eq!(registry.dependants("garage", &up), ["bank"]); + assert_eq!(registry.dependants("bank", &up), Vec::::new()); + } + + #[test] + fn a_refresh_counts_what_is_new_and_what_changed_and_forgets_nothing() { + let dir = tempfile::tempdir().unwrap(); + let server = dir.path(); + resource(server, "resources/a", "version '1'\n"); + resource(server, "resources/b", "version '1'\n"); + resource(server, "resources/gone", "version '1'\n"); + let mut registry = Registry::scan(server, None); + + resource(server, "resources/b", "version '2'\n"); + resource(server, "resources/[new]/c", "version '1'\n"); + fs::remove_dir_all(server.join("resources/gone")).unwrap(); + let refreshed = registry.refresh(server, None); + + assert_eq!( + refreshed, + Refreshed { + added: 1, + changed: 1, + read: 2 + } + ); + assert_eq!(registry.known.len(), 4); + assert_eq!(registry.find("b").unwrap().version.as_deref(), Some("2")); + } + + #[test] + fn the_list_holds_what_is_known_and_what_started_without_a_folder() { + let dir = tempfile::tempdir().unwrap(); + resource(dir.path(), "resources/b", "version '1'\ndependency 'a'\n"); + let registry = Registry::scan(dir.path(), None); + + let listed = registry.listed(&started(&["ghost", "b"])); + + let root = dir.path().join("resources").to_string_lossy().replace('\\', "/"); + assert_eq!( + listed, + [ + json!({ "name": "b", "state": "started", "path": format!("{root}//b"), "version": "1", "needs": ["a"] }), + json!({ "name": "ghost", "state": "started" }), + ] + ); + assert_eq!(registry.listed(&[])[0]["state"], "stopped"); + } +} From 2a39c3fa441aae2e5040948922aca8cf96d05461 Mon Sep 17 00:00:00 2001 From: ImBaphomet Date: Wed, 11 Nov 2026 05:09:26 +0100 Subject: [PATCH 3/5] feat: follow the server's resources live, and start, stop, restart or rescan them through the API under a permission of their own --- crates/fake-fxserver/tests/runtime.rs | 483 +++++++++++ crates/observer-app/src/changes.rs | 16 +- crates/observer-app/src/supervision.rs | 34 +- .../observer-app/src/supervision/resources.rs | 783 ++++++++++++++++++ crates/observer-domain/src/audit.rs | 21 +- crates/observer-domain/src/error.rs | 12 + crates/observer-domain/src/lib.rs | 1 + crates/observer-domain/src/live.rs | 13 +- crates/observer-domain/src/permissions.rs | 11 + crates/observer-domain/src/resources.rs | 204 +++++ crates/observer-fxserver/src/supervisor.rs | 10 + crates/observer-web/src/api.rs | 8 + crates/observer-web/src/api/resources.rs | 47 ++ crates/observer-web/src/error.rs | 7 +- crates/observer-web/src/live/topics.rs | 10 + crates/observer-web/src/tests.rs | 3 +- crates/observer-web/src/tests/resources.rs | 119 +++ web/src/api/generated/AuditAction.ts | 2 +- web/src/api/generated/ErrorCode.ts | 2 +- web/src/api/generated/Permission.ts | 1 + web/src/api/generated/ResourceAck.ts | 11 + web/src/api/generated/ResourceAsked.ts | 7 + web/src/api/generated/ResourceOrder.ts | 11 + web/src/api/generated/ResourceOrigin.ts | 6 + web/src/api/generated/ResourceRow.ts | 45 + web/src/api/generated/ResourceState.ts | 3 + web/src/api/generated/ResourceTurn.ts | 12 + web/src/api/generated/ResourceWarnings.ts | 18 + web/src/api/generated/ResourcesRescan.ts | 23 + web/src/api/generated/ResourcesStage.ts | 6 + web/src/api/generated/ResourcesView.ts | 30 + web/src/api/generated/Topic.ts | 2 +- web/src/api/generated/TopicEvent.ts | 3 +- web/src/api/generated/TopicSnapshot.ts | 3 +- web/src/audit/details.test.ts | 11 + web/src/audit/details.ts | 3 +- web/src/i18n/en.ts | 19 + web/src/live/messages.test.ts | 70 ++ web/src/live/messages.ts | 84 ++ 39 files changed, 2135 insertions(+), 19 deletions(-) create mode 100644 crates/observer-app/src/supervision/resources.rs create mode 100644 crates/observer-domain/src/resources.rs create mode 100644 crates/observer-web/src/api/resources.rs create mode 100644 crates/observer-web/src/tests/resources.rs create mode 100644 web/src/api/generated/ResourceAck.ts create mode 100644 web/src/api/generated/ResourceAsked.ts create mode 100644 web/src/api/generated/ResourceOrder.ts create mode 100644 web/src/api/generated/ResourceOrigin.ts create mode 100644 web/src/api/generated/ResourceRow.ts create mode 100644 web/src/api/generated/ResourceState.ts create mode 100644 web/src/api/generated/ResourceTurn.ts create mode 100644 web/src/api/generated/ResourceWarnings.ts create mode 100644 web/src/api/generated/ResourcesRescan.ts create mode 100644 web/src/api/generated/ResourcesStage.ts create mode 100644 web/src/api/generated/ResourcesView.ts diff --git a/crates/fake-fxserver/tests/runtime.rs b/crates/fake-fxserver/tests/runtime.rs index 874cd277..28a2bc5f 100644 --- a/crates/fake-fxserver/tests/runtime.rs +++ b/crates/fake-fxserver/tests/runtime.rs @@ -18,6 +18,7 @@ use observer_app::{ }; use observer_domain::audit::{ActorKind, AuditAction, AuditEntry, AuditFilter, Outcome}; use observer_domain::bans::{BanFilter, BanRequest}; +use observer_domain::changes::LiveChangeKind; use observer_domain::diagnostics::{ Alert, AlertKind, AnalysisState, CrashFileKind, CrashKind, DiagnosticsEvent, ListenerRole, ProcessKind, Protocol, Scope, @@ -28,6 +29,9 @@ use observer_domain::launch::{ExtraArgument, LaunchSettings, OneSync}; use observer_domain::metrics::{LiveSample, MarkerKind, Range, Series, ThreadGroup, ThreadShares}; use observer_domain::permissions::Permissions; use observer_domain::players::{Confirmation, IdentifierKind, PlayerLabel, PlayerProfile, PlayerSort}; +use observer_domain::resources::{ + ResourceAck, ResourceOrigin, ResourceRow, ResourceState, ResourceWarnings, ResourcesStage, ResourcesView, +}; use observer_domain::schedule::{ OneOffRequest, OneOffRestart, RestartSource, ScheduleChange, ScheduleSettings, }; @@ -1902,3 +1906,482 @@ async fn a_crash_and_a_kick_reach_the_drops_with_their_signature_and_reason_grou .unwrap(); assert_eq!(crashes.drops, 1); } + +/// What the resources tests start with: a library, one resource that depends on it, one the cfg +/// does not start, one FXServer cannot start and one it warns about, listed two a page. +const RESOURCES_CFG: &str = "set fake_resource_page 2\nset fake_fail_start mechanic\nset fake_heavy docks\nset fake_missing docks\nensure ox_lib\nensure ox_target\nensure mechanic\nensure docks\n"; + +impl Observer { + /// A resource of the server folder, under `resources`, with its manifest. + fn resource(&self, folder: &str, manifest: &str) { + let path = self.dir.path().join("server-data/resources").join(folder); + fs::create_dir_all(&path).unwrap(); + fs::write(path.join("fxmanifest.lua"), manifest).unwrap(); + } + + /// An Observer whose server folder holds the resources of [`RESOURCES_CFG`], not started yet. + async fn with_resources() -> Self { + let observer = Self::new(false).await; + observer.resource("[core]/ox_lib", "fx_version 'cerulean'\nversion '3.30.6'\n"); + observer.resource( + "[core]/[ox]/ox_target", + "version '1.17.2'\ndependencies {\n '/onesync',\n 'ox_lib',\n}\n", + ); + observer.resource("[jobs]/taxi", "fx_version 'cerulean'\n"); + observer.resource("[jobs]/mechanic", "version '0.8.0'\n"); + observer.resource("[maps]/docks", "version '0.9'\n"); + let cfg = observer.dir.path().join("server-data/server.cfg"); + let mut text = fs::read_to_string(&cfg).unwrap(); + text.push_str(RESOURCES_CFG); + fs::write(cfg, text).unwrap(); + observer + } + + async fn resources_until(&self, wanted: impl Fn(&ResourcesView) -> bool) -> ResourcesView { + let mut view = self.supervision.resources(); + let found = timeout(WAIT, view.wait_for(|view| wanted(view))) + .await + .map(|found| found.unwrap().clone()); + found.unwrap_or_else(|_| panic!("the resources never came to that; last {:#?}", view.borrow())) + } + + /// Starts the server and waits for its resources to be listed. + async fn listed(&self) -> ResourcesView { + self.supervision.start_server(&self.owner).await.unwrap(); + self.resources_until(|view| view.stage == ResourcesStage::Listed) + .await + } +} + +fn row<'a>(view: &'a ResourcesView, name: &str) -> &'a ResourceRow { + view.resources + .iter() + .find(|row| row.name == name) + .unwrap_or_else(|| panic!("no resource {name} in {view:#?}")) +} + +fn is(view: &ResourcesView, name: &str, state: ResourceState) -> bool { + view.resources + .iter() + .any(|row| row.name == name && row.state == state) +} + +#[tokio::test] +async fn the_resources_of_a_running_server_are_listed_with_what_fxserver_says_of_each() { + let mut observer = Observer::with_resources().await; + let before = observer.supervision.resources().borrow().clone(); + + let view = observer.listed().await; + + assert_eq!(before, ResourcesView::default(), "no server, no resource"); + assert_eq!((view.generation, view.truncated, &view.rescan), (1, false, &None)); + let rows: Vec<(&str, &str, ResourceState, Option<&str>)> = view + .resources + .iter() + .map(|row| { + ( + row.name.as_str(), + row.folder.as_str(), + row.state, + row.version.as_deref(), + ) + }) + .collect(); + assert_eq!( + rows, + [ + ("docks", "[maps]", ResourceState::Started, Some("0.9")), + ("mechanic", "[jobs]", ResourceState::Stopped, Some("0.8.0")), + ("ox_lib", "[core]", ResourceState::Started, Some("3.30.6")), + ("ox_target", "[core]/[ox]", ResourceState::Started, Some("1.17.2")), + ("taxi", "[jobs]", ResourceState::Stopped, None), + ], + "every resource of the folder, in two pages and a half, and the bridge left out" + ); + for listed in &view.resources { + assert_eq!(listed.origin, ResourceOrigin::Server, "{}", listed.name); + assert!(listed.controllable, "{}", listed.name); + assert_eq!( + listed.last, None, + "{}: nothing happened since the cfg ran", + listed.name + ); + } + assert_eq!(row(&view, "ox_lib").dependants, ["ox_target"]); + assert_eq!(row(&view, "ox_target").dependants, Vec::::new()); + assert_eq!( + row(&view, "docks").warnings, + Some(ResourceWarnings { + count: 3, + heavy: 2, + oversized: 1, + missing: 1 + }) + ); + assert!( + row(&view, "mechanic").failed_at.is_some(), + "FXServer said it could not start it" + ); + assert_eq!(row(&view, "taxi").failed_at, None, "the cfg never started it"); + + observer.supervision.stop_server(&observer.owner).await.unwrap(); + let stopped = observer + .resources_until(|view| view.stage == ResourcesStage::Stopped) + .await; + assert_eq!(stopped.resources, Vec::new()); + observer.stop_observer().await; +} + +/// What last happened to a resource: how, who asked for it, and the resource it stopped with. +fn last(row: &ResourceRow) -> (LiveChangeKind, Option, Option) { + let last = row.last.clone().unwrap(); + (last.kind, last.by, last.with) +} + +#[tokio::test] +async fn a_stop_takes_what_depends_on_the_resource_and_a_start_brings_what_it_needs() { + let mut observer = Observer::with_resources().await; + observer.listed().await; + let owner = observer.owner.clone(); + let ada = Some("ada".to_owned()); + + let stopped = observer + .supervision + .stop_resource(&owner, "ox_lib") + .await + .unwrap(); + let view = observer + .resources_until(|view| is(view, "ox_lib", ResourceState::Stopped)) + .await; + + assert_eq!( + stopped, + ResourceAck { + delivery: Delivery::Written, + dependants: vec!["ox_target".to_owned()] + } + ); + let (lib, target) = (row(&view, "ox_lib"), row(&view, "ox_target")); + assert_eq!( + target.state, + ResourceState::Stopped, + "FXServer stopped it with ox_lib" + ); + assert_eq!(last(lib), (LiveChangeKind::Stopped, ada.clone(), None)); + assert_eq!(lib.asked, None, "FXServer answered the order"); + assert_eq!( + last(target), + (LiveChangeKind::Stopped, None, Some("ox_lib".to_owned())) + ); + let echo = observer.console_until("stop ox_lib").await; + assert_eq!((echo.3, echo.4.as_deref()), (LineKind::Command, Some("ada"))); + let audited = observer.audited(AuditAction::ResourceStopped).await; + assert_eq!(audited.len(), 1); + assert_eq!( + (audited[0].target.as_deref(), &audited[0].details), + ( + Some("ox_lib"), + &json!({ "command": "stop ox_lib", "dependants": ["ox_target"] }) + ) + ); + + // FXServer starts what a resource depends on before the resource. + observer + .supervision + .start_resource(&owner, "ox_target") + .await + .unwrap(); + let view = observer + .resources_until(|view| is(view, "ox_target", ResourceState::Started)) + .await; + + assert_eq!(row(&view, "ox_lib").state, ResourceState::Started); + assert_eq!( + last(row(&view, "ox_target")), + (LiveChangeKind::Started, ada, None) + ); + assert_eq!(row(&view, "ox_lib").dependants, ["ox_target"]); + let audited = observer.audited(AuditAction::ResourceStarted).await; + assert_eq!(audited.len(), 1); + assert_eq!(audited[0].details, json!({ "command": "start ox_target" })); + observer.stop_observer().await; +} + +#[tokio::test] +async fn a_restart_starts_again_what_depends_on_the_resource_when_asked_to() { + let mut observer = Observer::with_resources().await; + observer.listed().await; + let ada = Some("ada".to_owned()); + + let restarted = observer + .supervision + .restart_resource(&observer.owner, "ox_lib", true) + .await + .unwrap(); + let view = observer + .resources_until(|view| { + let lib = row(view, "ox_lib"); + let back = lib.state == ResourceState::Started + && lib + .last + .as_ref() + .is_some_and(|last| last.kind == LiveChangeKind::Restarted); + // Its stop shows first when the two lines fall in two reports of the bridge. + back && row(view, "ox_target") + .last + .as_ref() + .is_some_and(|last| last.kind != LiveChangeKind::Stopped) + }) + .await; + + assert_eq!(restarted.dependants, ["ox_target"]); + assert_eq!( + last(row(&view, "ox_lib")), + (LiveChangeKind::Restarted, ada.clone(), None) + ); + let target = row(&view, "ox_target"); + assert_eq!(target.state, ResourceState::Started); + assert_eq!( + target.last.as_ref().unwrap().by, + ada, + "started again by the same order" + ); + let restart = observer.line_of("restart ox_lib").await; + let again = observer.console_until("start ox_target").await; + assert!(restart < again.0, "the resource first, then what depends on it"); + assert_eq!((again.3, again.4.as_deref()), (LineKind::Command, Some("ada"))); + let audited = observer.audited(AuditAction::ResourceRestarted).await; + assert_eq!( + audited[0].details, + json!({ "command": "restart ox_lib", "dependants": ["ox_target"], "startedAgain": true }) + ); + assert_eq!( + observer.audited(AuditAction::ResourceStarted).await.len(), + 0, + "one order, one entry" + ); + observer.stop_observer().await; +} + +#[tokio::test] +async fn a_restart_alone_leaves_stopped_what_fxserver_stopped_with_the_resource() { + let mut observer = Observer::with_resources().await; + observer.listed().await; + + observer + .supervision + .restart_resource(&observer.owner, "ox_lib", false) + .await + .unwrap(); + let view = observer + .resources_until(|view| { + row(view, "ox_lib") + .last + .as_ref() + .is_some_and(|last| last.kind == LiveChangeKind::Restarted) + }) + .await; + + assert_eq!(row(&view, "ox_lib").state, ResourceState::Started); + let target = row(&view, "ox_target"); + assert_eq!(target.state, ResourceState::Stopped); + assert_eq!( + target.last.as_ref().and_then(|last| last.with.as_deref()), + Some("ox_lib") + ); + assert_eq!(row(&view, "ox_lib").dependants, Vec::::new()); + observer.stop_observer().await; +} + +#[tokio::test] +async fn an_order_names_only_a_resource_fxserver_listed_and_never_the_bridge() { + let mut observer = Observer::with_resources().await; + observer.resource("bad name", "version '1'\n"); + let cfg = observer.dir.path().join("server-data/server.cfg"); + let mut text = fs::read_to_string(&cfg).unwrap(); + text.push_str("ensure \"bad name\"\n"); + fs::write(cfg, text).unwrap(); + let owner = observer.owner.clone(); + let refused = |result: Result| result.unwrap_err().code(); + + assert_eq!( + refused(observer.supervision.stop_resource(&owner, "ox_lib").await), + Some(ErrorCode::ServerNotRunning) + ); + assert_eq!( + refused(observer.supervision.rescan_resources(&owner).await), + Some(ErrorCode::ServerNotRunning) + ); + let view = observer.listed().await; + let started = plain(&observer.console_until("Started resource sys_").await.5); + let bridge = started.rsplit(' ').next().unwrap().to_owned(); + + assert!(!row(&view, "bad name").controllable); + assert!( + view.resources.iter().all(|listed| listed.name != bridge), + "the bridge is Observer's own" + ); + for name in [ + bridge.as_str(), + &bridge.to_ascii_uppercase(), + "ghost", + "OX_LIB", + "ox_lib;quit", + ] { + assert_eq!( + refused(observer.supervision.stop_resource(&owner, name).await), + Some(ErrorCode::ResourceNotFound), + "{name}" + ); + assert_eq!( + refused(observer.supervision.restart_resource(&owner, name, true).await), + Some(ErrorCode::ResourceNotFound), + "{name}" + ); + } + assert_eq!( + refused(observer.supervision.stop_resource(&owner, "bad name").await), + Some(ErrorCode::ResourceNameRefused) + ); + for action in [AuditAction::ResourceStopped, AuditAction::ResourceRestarted] { + assert_eq!(observer.audited(action).await.len(), 0, "{action:?}"); + } + assert!( + observer.console().iter().all(|line| line.3 != LineKind::Command), + "nothing reached the console" + ); + observer.stop_observer().await; +} + +#[tokio::test] +async fn an_order_is_taken_for_the_state_it_needs_and_one_at_a_time_for_a_resource() { + let mut observer = Observer::with_resources().await; + observer.listed().await; + let owner = observer.owner.clone(); + let refused = |result: Result| result.unwrap_err().code(); + + assert_eq!( + refused(observer.supervision.start_resource(&owner, "ox_lib").await), + Some(ErrorCode::ResourceChanged), + "it is up" + ); + assert_eq!( + refused(observer.supervision.stop_resource(&owner, "taxi").await), + Some(ErrorCode::ResourceChanged), + "it is not" + ); + assert_eq!( + refused(observer.supervision.restart_resource(&owner, "taxi", true).await), + Some(ErrorCode::ResourceChanged) + ); + + observer + .supervision + .stop_resource(&owner, "ox_lib") + .await + .unwrap(); + let second = refused(observer.supervision.stop_resource(&owner, "ox_lib").await); + // FXServer answers within half a second, and the resource is stopped from then on. + assert!( + matches!(second, Some(ErrorCode::ResourceBusy | ErrorCode::ResourceChanged)), + "{second:?}" + ); + let view = observer + .resources_until(|view| is(view, "ox_lib", ResourceState::Stopped)) + .await; + + assert_eq!(row(&view, "ox_lib").asked, None, "FXServer answered"); + assert_eq!( + observer.audited(AuditAction::ResourceStopped).await.len(), + 1, + "a refused order is not one" + ); + let written = observer + .console() + .iter() + .filter(|line| line.3 == LineKind::Command) + .count(); + assert_eq!(written, 1, "one line reached the console"); + observer.stop_observer().await; +} + +#[tokio::test] +async fn a_rescan_says_what_fxserver_found_and_a_failed_start_shows_as_one() { + let mut observer = Observer::with_resources().await; + observer.listed().await; + let owner = observer.owner.clone(); + + observer.resource("[jobs]/bank", "version '1.0.0'\n"); + observer.resource("[jobs]/taxi", "fx_version 'cerulean'\nversion '2.0.0'\n"); + let asked = observer.supervision.rescan_resources(&owner).await.unwrap(); + let view = observer.resources_until(|view| view.rescan.is_some()).await; + + assert_eq!(asked.delivery, Delivery::Written); + assert!(!view.rescanning, "the list came again"); + let rescan = view.rescan.clone().unwrap(); + assert_eq!( + (rescan.by.as_deref(), rescan.added.as_slice(), rescan.added_count), + (Some("ada"), ["bank".to_owned()].as_slice(), 1) + ); + assert_eq!( + (row(&view, "bank").state, row(&view, "bank").folder.as_str()), + (ResourceState::Stopped, "[jobs]") + ); + assert_eq!(row(&view, "taxi").version.as_deref(), Some("2.0.0")); + assert_eq!( + row(&view, "ox_lib").state, + ResourceState::Started, + "nothing stopped" + ); + let echo = observer.console_until("refresh").await; + assert_eq!((echo.3, echo.4.as_deref()), (LineKind::Command, Some("ada"))); + observer + .console_until("Found 1 new resources, and refreshed 1/") + .await; + let view = observer + .resources_until(|view| { + view.rescan + .as_ref() + .is_some_and(|rescan| rescan.changed.is_some()) + }) + .await; + assert_eq!(view.rescan.unwrap().changed, Some(1), "taxi's manifest"); + let audited = observer.audited(AuditAction::ResourcesRescanned).await; + assert_eq!( + (audited.len(), audited[0].target.as_deref()), + (1, Some("default")) + ); + + observer + .supervision + .command( + &owner, + "set fake_fail_start bank", + CONSOLE_ROUTE.0, + CONSOLE_ROUTE.1, + ) + .await + .unwrap(); + observer.supervision.start_resource(&owner, "bank").await.unwrap(); + let view = observer + .resources_until(|view| row(view, "bank").failed_at.is_some()) + .await; + assert_eq!(row(&view, "bank").state, ResourceState::Stopped); + + observer + .supervision + .command( + &owner, + "set fake_fail_start nothing", + CONSOLE_ROUTE.0, + CONSOLE_ROUTE.1, + ) + .await + .unwrap(); + observer.supervision.start_resource(&owner, "bank").await.unwrap(); + let view = observer + .resources_until(|view| is(view, "bank", ResourceState::Started)) + .await; + assert_eq!(row(&view, "bank").failed_at, None, "it started since"); + observer.stop_observer().await; +} diff --git a/crates/observer-app/src/changes.rs b/crates/observer-app/src/changes.rs index 6138337e..e158011f 100644 --- a/crates/observer-app/src/changes.rs +++ b/crates/observer-app/src/changes.rs @@ -46,6 +46,14 @@ pub(crate) const SERVER: &str = Servers::DEFAULT; const UPKEEP_EVERY: Duration = Duration::from_hours(1); /// A console command matches a live change this long before it at most. const COMMAND_WINDOW: SignedDuration = SignedDuration::from_secs(2); +/// What starts, stops or restarts a resource through Observer, each with its `command` in the +/// audit log: a line typed in the console, or an order of the Resources page. +const COMMANDS: [AuditAction; 4] = [ + AuditAction::ConsoleCommandSent, + AuditAction::ResourceStarted, + AuditAction::ResourceStopped, + AuditAction::ResourceRestarted, +]; /// Live changes a run records at most; past them a crash loop of `ensure` adds nothing. const MAX_LIVE_CHANGES: u32 = 2000; const MAX_NAME: usize = 64; @@ -643,9 +651,7 @@ impl Changes { self.inner .shared .db - .read(move |conn| { - observer_store::audit::of_actions(conn, &[AuditAction::ConsoleCommandSent], from, at, 100) - }) + .read(move |conn| observer_store::audit::of_actions(conn, &COMMANDS, from, at, 100)) .await? }; let attributed: Vec = changes @@ -857,8 +863,8 @@ fn live_changes(events: &[ResourceEvent], at: Timestamp) -> Vec { changes } -/// Who typed a command in Observer's console that starts, stops or restarts `name`, and the -/// command as the audit log shows it, masked. +/// Who had `name` started, stopped or restarted through Observer, by a command typed in its +/// console or an order of the Resources page, and the command as the audit log shows it, masked. fn command_for(name: &str, commands: &[observer_domain::audit::AuditEntry]) -> Option<(String, String)> { commands.iter().rev().find_map(|entry| { let shown = entry.details.get("command")?.as_str()?; diff --git a/crates/observer-app/src/supervision.rs b/crates/observer-app/src/supervision.rs index 70e5e42f..911e4d8f 100644 --- a/crates/observer-app/src/supervision.rs +++ b/crates/observer-app/src/supervision.rs @@ -1,8 +1,10 @@ //! FXServer as Observer runs it: the supervisor, started from the stored launch settings and the //! selected build, the status the panel shows of it, the console it fills, the commands operators -//! send it, the console log files, and the server setup's checks of a folder and its cfg. +//! send it, its resources as they stand, the console log files, and the server setup's checks of a +//! folder and its cfg. mod cfg; +mod resources; mod setup; use std::collections::{HashMap, VecDeque}; @@ -17,6 +19,7 @@ use observer_domain::audit::{ActorKind, AuditAction, Outcome}; use observer_domain::error::{ApiError, ErrorCode}; use observer_domain::launch::{Platform, StoredLaunch}; use observer_domain::permissions::Permissions; +use observer_domain::resources::ResourcesView; use observer_domain::schedule::RestartSource; use observer_domain::server::{ AssetMemory, CommandAck, CommandRefusal, ConsoleLine, ConsoleSnapshot, Delivery, HangCause, @@ -38,7 +41,7 @@ use observer_store::artifacts::{self as stored_artifacts, ArtifactState}; use observer_store::launch; use serde_json::json; use tokio::sync::broadcast::error::{RecvError, TryRecvError}; -use tokio::sync::{Mutex as AsyncMutex, Semaphore, broadcast, oneshot, watch}; +use tokio::sync::{Mutex as AsyncMutex, Notify, Semaphore, broadcast, oneshot, watch}; use tokio::task::JoinHandle; use tokio::time::Instant; use tokio_util::sync::CancellationToken; @@ -105,6 +108,11 @@ struct Inner { console: Arc, console_logs: PathBuf, status: watch::Receiver, + resources: watch::Receiver, + /// Who asked for what on the Resources page, in the run that is up. + orders: Arc>, + /// Told at each order of the page, which the view of the resources shows at once. + ordered: Arc, /// When each user sent their latest commands, within the window. commands: Mutex>>, /// The setup's reads of the host's folders under way: each holds a blocking thread, which a @@ -115,11 +123,12 @@ struct Inner { takeover: Mutex>>, } -/// The supervisor's task and the one that follows it for the panel. Once the supervisor has -/// stopped FXServer and ended, [`Running::close`] ends the other. +/// The supervisor's task and those that follow it for the panel. Once the supervisor has stopped +/// FXServer and ended, [`Running::close`] ends the others. pub struct Running { supervisor: Option>, follower: JoinHandle<()>, + resources: JoinHandle<()>, stop: CancellationToken, } @@ -139,6 +148,7 @@ impl Running { self.stopped().await; self.stop.cancel(); let _ = self.follower.await; + let _ = self.resources.await; } } @@ -274,6 +284,16 @@ impl Supervision { readiness, stop.clone(), )); + let (resources_tx, resources) = watch::channel(ResourcesView::default()); + let orders = Arc::new(Mutex::new(resources::Orders::default())); + let ordered = Arc::new(Notify::new()); + let following_resources = tokio::spawn(resources::follow( + supervisor.clone(), + Arc::clone(&orders), + Arc::clone(&ordered), + resources_tx, + stop.clone(), + )); let supervision = Self { inner: Arc::new(Inner { @@ -289,6 +309,9 @@ impl Supervision { console, console_logs: setup.console_logs, status, + resources, + orders, + ordered, commands: Mutex::default(), folder_reads: Semaphore::new(FOLDER_READS), takeover: Mutex::default(), @@ -297,6 +320,7 @@ impl Supervision { let running = Running { supervisor: Some(supervisor_task), follower, + resources: following_resources, stop, }; Ok((supervision, running)) @@ -1083,6 +1107,8 @@ fn control_error(error: ControlError) -> Error { ErrorCode::ServerNotRunning.into() } ControlError::Stopping => ErrorCode::ServerStopping.into(), + // The bridge is no resource of the list the page acts on. + ControlError::Bridge => ErrorCode::ResourceNotFound.into(), ControlError::NotStarting => ErrorCode::ServerNotStarting.into(), ControlError::ShuttingDown => ErrorCode::ShuttingDown.into(), ControlError::Launch(error) => ApiError::new(ErrorCode::LaunchFailed) diff --git a/crates/observer-app/src/supervision/resources.rs b/crates/observer-app/src/supervision/resources.rs new file mode 100644 index 00000000..8add4cb5 --- /dev/null +++ b/crates/observer-app/src/supervision/resources.rs @@ -0,0 +1,783 @@ +//! The server's resources as the panel follows them: what FXServer's bridge lists and its console +//! says of each, who asked for what on the Resources page, and the orders the page sends, which +//! are FXServer's own `start`, `stop`, `restart` and `refresh`. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; +use std::time::{Duration, Instant}; + +use jiff::Timestamp; +use observer_domain::audit::AuditAction; +use observer_domain::changes::LiveChangeKind; +use observer_domain::error::ErrorCode; +use observer_domain::resources::{ + MAX_ADDED_NAMED, ResourceAck, ResourceAsked, ResourceOrigin, ResourceRow, ResourceState, ResourceTurn, + ResourceWarnings, ResourcesRescan, ResourcesStage, ResourcesView, +}; +use observer_domain::server::Delivery; +use observer_fxserver::resources::{Dependants, Known, Origin, State as Held, TurnKind}; +use observer_fxserver::start::Marks; +use observer_fxserver::stdin::{ResourceName, ResourceVerb, StdinError}; +use observer_fxserver::supervisor::{self as fx, ControlError, EventKind, State, Supervisor}; +use serde_json::json; +use tokio::sync::broadcast::error::RecvError; +use tokio::sync::{Notify, watch}; +use tokio_util::sync::CancellationToken; + +use super::{Supervision, control_error}; +use crate::audit::Record; +use crate::{Caller, Error, Servers}; + +/// An order FXServer has not answered within this is given up: a resource that takes minutes to +/// start answers late, and what happens to it after is no longer put down to who asked. +const ANSWERED_WITHIN: Duration = Duration::from_secs(120); +/// The view is worked out again at most this often while reports come in. +const VIEW_EVERY: Duration = Duration::from_millis(250); +/// What the console says of a resource it could not start comes with no report of the bridge: the +/// view is worked out again this often while a panel follows it. +const WATCHED_EVERY: Duration = Duration::from_secs(1); +/// A rescan's count of changed manifests is the console's, read within this of the new list. +const RESCAN_SAID_WITHIN_MS: i64 = 60_000; + +/// What the page asked, until it shows. +struct Asked { + by: String, + at: Instant, + /// When the resource last started or stopped as the order was made, or the list last grew. + before: Option, +} + +/// An order on one resource, until FXServer answers it. +struct Order { + asked: Asked, + kind: LiveChangeKind, + /// When FXServer last said it could not start the resource, as the order was made. + failed_before: Option, +} + +/// Who asked for what on the page in the run that is up, by resource. +#[derive(Default)] +pub(super) struct Orders { + generation: u32, + asked: HashMap, + /// Who a resource's latest start or stop is put down to, with when it happened. + credited: HashMap, + rescan: Option, + rescanned: Option<(i64, String)>, +} + +impl Orders { + fn ask(&mut self, resources: &fx::Resources, name: &str, kind: LiveChangeKind, by: &str) { + self.of(resources.generation); + let before = resources + .book + .find(name) + .and_then(|known| Some(known.turn.as_ref()?.at_ms)); + self.asked.insert( + name.to_owned(), + Order { + asked: Asked { + by: by.to_owned(), + at: Instant::now(), + before, + }, + kind, + failed_before: failed_at(&resources.marks, name), + }, + ); + } + + /// The order's line never reached FXServer. + fn withdraw(&mut self, name: &str) { + self.asked.remove(name); + } + + /// The order FXServer has not answered on a resource. + fn waiting(&self, name: &str) -> Option { + self.asked.get(name).map(|order| ResourceAsked { + kind: order.kind, + by: order.asked.by.clone(), + }) + } + + fn ask_rescan(&mut self, resources: &fx::Resources, by: &str) { + self.of(resources.generation); + self.rescan = Some(Asked { + by: by.to_owned(), + at: Instant::now(), + before: resources.book.added().map(|added| added.at_ms), + }); + } + + /// The orders of another run are of no resource of this one. + fn of(&mut self, generation: u32) { + if self.generation != generation { + *self = Self { + generation, + ..Self::default() + }; + } + } + + /// Puts down to who asked what happened to a resource since their order, until FXServer + /// answered it: the resource is as the order wanted it, or FXServer said it could not start + /// it. A restart may show as a stop, then as a start. + fn credit(&mut self, resources: &fx::Resources) { + self.of(resources.generation); + let (book, marks) = (&resources.book, &resources.marks); + let credited = &mut self.credited; + self.asked.retain(|name, order| { + if order.asked.at.elapsed() >= ANSWERED_WITHIN { + return false; + } + let stops = order.kind == LiveChangeKind::Stopped; + let turned = book + .find(name) + .and_then(|known| known.turn.as_ref()) + .filter(|turn| Some(turn.at_ms) != order.asked.before); + if let Some(turn) = turned { + credited.insert(name.clone(), (turn.at_ms, order.asked.by.clone())); + if stops == (turn.kind == TurnKind::Stopped) { + return false; + } + } + stops || failed_at(marks, name) == order.failed_before + }); + if let Some(asked) = &self.rescan { + let grew = book + .added() + .map(|added| added.at_ms) + .filter(|at| Some(*at) != asked.before); + if let Some(at) = grew { + self.rescanned = Some((at, asked.by.clone())); + } + if grew.is_some() || asked.at.elapsed() >= ANSWERED_WITHIN { + self.rescan = None; + } + } + } + + fn by(&self, name: &str, at_ms: i64) -> Option { + self.credited + .get(name) + .filter(|(at, _)| *at == at_ms) + .map(|(_, by)| by.clone()) + } +} + +/// When FXServer last said it could not start a resource. The console's marks go by lower case. +fn failed_at(marks: &Marks, name: &str) -> Option { + marks.failed.get(&name.to_ascii_lowercase()).copied() +} + +/// The view of the resources as they stand, with what `orders` knows of who asked. +fn view(supervisor: &Supervisor, orders: &Mutex) -> ResourcesView { + let state = supervisor.status().borrow().state; + let unlisted = |generation: u32| ResourcesView { + stage: match state { + State::Starting => ResourcesStage::Starting, + State::Online | State::Degraded | State::Hung | State::Stopping => ResourcesStage::Unreported, + State::Stopped | State::Crashed | State::CrashLoop => ResourcesStage::Stopped, + }, + generation, + ..ResourcesView::default() + }; + let Some(resources) = supervisor.resources() else { + return unlisted(0); + }; + if !resources.book.listed() { + return unlisted(resources.generation); + } + let mut orders = orders.lock().unwrap_or_else(PoisonError::into_inner); + orders.credit(&resources); + let (book, marks) = (&resources.book, &resources.marks); + let dependants = book.dependants(); + ResourcesView { + stage: ResourcesStage::Listed, + generation: resources.generation, + resources: book + .resources() + .iter() + .map(|known| row(known, &dependants, marks, &orders)) + .collect(), + truncated: book.truncated(), + rescan: book.added().map(|added| ResourcesRescan { + at: at(added.at_ms), + by: orders + .rescanned + .as_ref() + .filter(|(at_ms, _)| *at_ms == added.at_ms) + .map(|(_, by)| by.clone()), + added: added.names.iter().take(MAX_ADDED_NAMED).cloned().collect(), + added_count: u32::try_from(added.names.len()).unwrap_or(u32::MAX), + changed: marks + .refreshed + .filter(|said| (added.at_ms - said.at_ms).abs() <= RESCAN_SAID_WITHIN_MS) + .map(|said| said.changed), + }), + rescanning: orders.rescan.is_some(), + } +} + +fn row(known: &Known, dependants: &Dependants<'_>, marks: &Marks, orders: &Orders) -> ResourceRow { + let key = known.name.to_ascii_lowercase(); + let started = known.state == Held::Started; + ResourceRow { + name: known.name.clone(), + folder: known.folder.clone(), + origin: match known.origin { + Origin::Server => ResourceOrigin::Server, + Origin::Build => ResourceOrigin::Build, + }, + version: known.version.clone(), + state: match known.state { + Held::Started => ResourceState::Started, + Held::Starting => ResourceState::Starting, + Held::Stopped => ResourceState::Stopped, + Held::Stopping => ResourceState::Stopping, + Held::Unknown => ResourceState::Unknown, + }, + failed_at: marks + .failed + .get(&key) + .filter(|_| !started) + .map(|at_ms| at(*at_ms)), + warnings: marks.warned.get(&key).map(|count| { + let (heavy, oversized) = marks.heavy.get(&key).copied().unwrap_or_default(); + ResourceWarnings { + count: *count, + heavy, + oversized, + missing: marks.missing.get(&key).copied().unwrap_or_default(), + } + }), + dependants: if started { + dependants.of(&known.name) + } else { + Vec::new() + }, + controllable: ResourceName::new(&known.name).is_some(), + last: known.turn.as_ref().map(|turn| ResourceTurn { + at: at(turn.at_ms), + kind: match turn.kind { + TurnKind::Started => LiveChangeKind::Started, + TurnKind::Stopped => LiveChangeKind::Stopped, + TurnKind::Restarted => LiveChangeKind::Restarted, + }, + by: orders.by(&known.name, turn.at_ms), + with: turn.with.clone(), + }), + asked: orders.waiting(&known.name), + } +} + +fn at(ms: i64) -> Timestamp { + Timestamp::from_millisecond(ms).unwrap_or_default() +} + +/// Keeps the view in step with what the supervisor hears and the page asks: a report of the +/// bridge, a change of state, an order, and each second while a panel follows it, for what only +/// the console says. +pub(super) async fn follow( + supervisor: Supervisor, + orders: Arc>, + ordered: Arc, + live: watch::Sender, + stop: CancellationToken, +) { + let mut events = supervisor.events(); + let mut status = supervisor.status(); + let mut watched = tokio::time::interval(WATCHED_EVERY); + watched.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + let mut last = tokio::time::Instant::now(); + loop { + tokio::select! { + () = stop.cancelled() => return, + event = events.recv() => match event { + Ok(event) => match event.kind { + EventKind::Resources { .. } + | EventKind::ResourceList { .. } + | EventKind::State { .. } + | EventKind::Exited { .. } => {} + _ => continue, + }, + // The supervisor still holds what the missed events said. + Err(RecvError::Lagged(_)) => {} + Err(RecvError::Closed) => return, + }, + changed = status.changed() => { + if changed.is_err() { + return; + } + status.borrow_and_update(); + } + () = ordered.notified() => {} + // The service keeps one receiver of its own. + _ = watched.tick() => { + if live.receiver_count() <= 1 { + continue; + } + } + } + tokio::select! { + () = stop.cancelled() => return, + () = tokio::time::sleep_until(last + VIEW_EVERY) => {} + } + last = tokio::time::Instant::now(); + let next = view(&supervisor, &orders); + live.send_if_modified(|current| { + let changed = *current != next; + *current = next; + changed + }); + } +} + +impl Supervision { + /// The resources of the run that is up, as they change. + pub fn resources(&self) -> watch::Receiver { + self.inner.resources.clone() + } + + pub async fn start_resource(&self, caller: &Caller, name: &str) -> Result { + self.order(caller, ResourceVerb::Start, name, false).await + } + + /// Stops a resource. FXServer stops with it every started resource that depends on it, and + /// starts none of them again: the answer names them. + pub async fn stop_resource(&self, caller: &Caller, name: &str) -> Result { + self.order(caller, ResourceVerb::Stop, name, false).await + } + + /// Restarts a started resource. With `again`, the resources that depend on it, which FXServer + /// stops with it, are started once it is back. + pub async fn restart_resource( + &self, + caller: &Caller, + name: &str, + again: bool, + ) -> Result { + self.order(caller, ResourceVerb::Restart, name, again).await + } + + /// The order is audited before anything is written, as a console command is, and counts as + /// one of the caller's commands of the window. It shows on the list from then on, and no + /// other is taken on its resource until FXServer answers it. + async fn order( + &self, + caller: &Caller, + verb: ResourceVerb, + name: &str, + again: bool, + ) -> Result { + let resources = self.listed()?; + let known = resources.book.find(name).ok_or(ErrorCode::ResourceNotFound)?; + let target = ResourceName::new(&known.name).ok_or(ErrorCode::ResourceNameRefused)?; + let started = known.state == Held::Started; + // FXServer starts nothing that is up and stops nothing that is not: no answer would come. + if started == (verb == ResourceVerb::Start) { + return Err(ErrorCode::ResourceChanged.into()); + } + // Nothing depends on a resource that is not up. + let dependants = if started { + resources.book.dependants().of(&known.name) + } else { + Vec::new() + }; + self.throttle(caller)?; + + let command = format!("{} {}", verb.as_str(), target.as_str()); + let (kind, action) = match verb { + ResourceVerb::Start => (LiveChangeKind::Started, AuditAction::ResourceStarted), + ResourceVerb::Stop => (LiveChangeKind::Stopped, AuditAction::ResourceStopped), + ResourceVerb::Restart => (LiveChangeKind::Restarted, AuditAction::ResourceRestarted), + }; + // The log names what stopped with the resource, and whether a restart started it again. + let details = if dependants.is_empty() { + json!({ "command": command }) + } else if verb == ResourceVerb::Restart { + json!({ "command": command, "dependants": dependants, "startedAgain": again }) + } else { + json!({ "command": command, "dependants": dependants }) + }; + let again: Vec = if again { + dependants + .iter() + .filter_map(|dependant| ResourceName::new(dependant)) + .collect() + } else { + Vec::new() + }; + { + let mut orders = self.orders(); + orders.credit(&resources); + if orders.waiting(target.as_str()).is_some() { + return Err(ErrorCode::ResourceBusy.into()); + } + orders.ask(&resources, target.as_str(), kind, &caller.username); + // FXServer stops them with the resource, then the order starts each again. + for dependant in &again { + orders.ask( + &resources, + dependant.as_str(), + LiveChangeKind::Restarted, + &caller.username, + ); + } + } + self.inner.ordered.notify_one(); + + let record = Record::by(caller, action) + .target(known.name.clone()) + .details(details); + let mut answer = self.audit(record).await.map(|()| Delivery::Written); + let lines = std::iter::once((verb, target)).chain( + again + .into_iter() + .map(|dependant| (ResourceVerb::Start, dependant)), + ); + // The orders whose line never reached FXServer: no answer comes for those. + let mut unwritten = Vec::new(); + for (verb, name) in lines { + let asked = name.as_str().to_owned(); + if matches!(answer, Ok(Delivery::Written)) { + answer = self.write(verb, name, caller).await; + if matches!(answer, Ok(Delivery::Written | Delivery::Pending)) { + continue; + } + } + unwritten.push(asked); + } + if !unwritten.is_empty() { + let mut orders = self.orders(); + for name in &unwritten { + orders.withdraw(name); + } + drop(orders); + self.inner.ordered.notify_one(); + } + Ok(ResourceAck { + delivery: answer?, + dependants, + }) + } + + /// Has FXServer read its resource folders again: new resources and changed manifests. Nothing + /// starts or stops. + pub async fn rescan_resources(&self, caller: &Caller) -> Result { + let resources = self.listed()?; + self.throttle(caller)?; + let record = Record::by(caller, AuditAction::ResourcesRescanned) + .target(Servers::DEFAULT) + .details(json!({ "command": "refresh" })); + self.audit(record).await?; + self.orders().ask_rescan(&resources, &caller.username); + self.inner.ordered.notify_one(); + let written = self.inner.supervisor.refresh_resources(&caller.username).await; + let answer = delivery(written); + if !matches!(answer, Ok(Delivery::Written | Delivery::Pending)) { + self.orders().rescan = None; + self.inner.ordered.notify_one(); + } + Ok(ResourceAck { + delivery: answer?, + dependants: Vec::new(), + }) + } + + fn orders(&self) -> MutexGuard<'_, Orders> { + self.inner.orders.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// The resources of a server that listed them: an order names one of those, and no other. + fn listed(&self) -> Result { + match self.inner.supervisor.resources() { + Some(resources) if resources.book.listed() => Ok(resources), + Some(_) => Err(ErrorCode::ResourcesNotListed.into()), + None => Err(ErrorCode::ServerNotRunning.into()), + } + } + + async fn write( + &self, + verb: ResourceVerb, + name: ResourceName, + caller: &Caller, + ) -> Result { + delivery(self.inner.supervisor.resource(verb, name, &caller.username).await) + } +} + +/// How a line went: a server that is not reading its console is an answer, not a failure. +fn delivery(written: Result<(), ControlError>) -> Result { + match written { + Ok(()) => Ok(Delivery::Written), + Err(ControlError::Stdin(StdinError::Pending)) => Ok(Delivery::Pending), + Err(ControlError::Stdin(StdinError::Busy)) => Ok(Delivery::Busy), + Err(error) => Err(control_error(error)), + } +} + +#[cfg(test)] +mod tests { + use observer_fxserver::resources::{Book, Reported}; + + use super::*; + + const BRIDGE: &str = "sys_k3jx7qmw2pza"; + + fn reported(name: &str, state: &str, needs: &[&str]) -> Reported { + Reported { + name: name.to_owned(), + state: state.to_owned(), + path: Some(format!("C:/srv/resources//[core]/{name}")), + version: Some("1.2.0".to_owned()), + needs: needs.iter().map(|&need| need.to_owned()).collect(), + provides: Vec::new(), + } + } + + fn resources(generation: u32) -> fx::Resources { + let mut book = Book::default(); + book.page( + 1, + 1, + true, + vec![ + reported("ox_lib", "started", &[]), + reported("ox_target", "started", &["ox_lib"]), + reported("taxi", "stopped", &[]), + reported("bad name", "started", &[]), + ], + BRIDGE, + 1000, + ); + fx::Resources { + generation, + book, + marks: Marks::default(), + } + } + + fn turned(resources: &mut fx::Resources, started: &[&str], events: &[(&str, bool)], at_ms: i64) { + let started: Vec = started.iter().map(|&name| name.to_owned()).collect(); + let events: Vec<(String, bool)> = events + .iter() + .map(|&(name, started)| (name.to_owned(), started)) + .collect(); + resources.book.started(&started, &events, at_ms); + } + + #[test] + fn a_row_says_what_the_bridge_listed_and_what_the_console_said() { + let mut resources = resources(3); + resources.marks.failed.insert("taxi".to_owned(), 4000); + resources.marks.failed.insert("ox_lib".to_owned(), 4000); + resources.marks.warned.insert("ox_target".to_owned(), 3); + resources.marks.heavy.insert("ox_target".to_owned(), (2, 1)); + resources.marks.missing.insert("ox_target".to_owned(), 1); + let mut orders = Orders::default(); + orders.ask(&resources, "taxi", LiveChangeKind::Started, "ada"); + let row = |name: &str| { + row( + resources.book.find(name).unwrap(), + &resources.book.dependants(), + &resources.marks, + &orders, + ) + }; + + let lib = row("ox_lib"); + assert_eq!( + (lib.folder.as_str(), lib.origin, lib.state, lib.version.as_deref()), + ( + "[core]", + ResourceOrigin::Server, + ResourceState::Started, + Some("1.2.0") + ) + ); + assert_eq!(lib.dependants, ["ox_target"]); + assert_eq!(lib.failed_at, None, "it started since"); + assert!(lib.controllable); + assert_eq!(lib.asked, None); + assert_eq!( + row("ox_target").warnings, + Some(ResourceWarnings { + count: 3, + heavy: 2, + oversized: 1, + missing: 1 + }) + ); + let taxi = row("taxi"); + assert_eq!( + (taxi.state, taxi.failed_at), + (ResourceState::Stopped, Some(at(4000))) + ); + assert_eq!(taxi.dependants, Vec::::new()); + assert_eq!( + taxi.asked, + Some(asked(LiveChangeKind::Started)), + "an order FXServer has not answered" + ); + assert!(!row("bad name").controllable); + } + + fn asked(kind: LiveChangeKind) -> ResourceAsked { + ResourceAsked { + kind, + by: "ada".to_owned(), + } + } + + #[test] + fn what_follows_an_order_is_put_down_to_who_made_it_until_fxserver_answered() { + let mut resources = resources(3); + let mut orders = Orders::default(); + orders.ask(&resources, "ox_lib", LiveChangeKind::Restarted, "ada"); + orders.ask(&resources, "bad name", LiveChangeKind::Stopped, "ada"); + orders.credit(&resources); + assert_eq!(orders.by("ox_lib", 1000), None, "nothing happened yet"); + assert_eq!(orders.waiting("ox_lib"), Some(asked(LiveChangeKind::Restarted))); + + // A restart may fall in two reports of the bridge: its stop, then its start. + turned( + &mut resources, + &["bad name"], + &[("ox_target", false), ("ox_lib", false)], + 2000, + ); + orders.credit(&resources); + assert_eq!(orders.by("ox_lib", 2000).as_deref(), Some("ada")); + assert_eq!(orders.by("ox_target", 2000), None, "it stopped with the other"); + assert_eq!( + orders.waiting("ox_lib"), + Some(asked(LiveChangeKind::Restarted)), + "it is not back up yet" + ); + assert_eq!( + orders.waiting("bad name"), + Some(asked(LiveChangeKind::Stopped)), + "its order still waits" + ); + + turned(&mut resources, &["bad name", "ox_lib"], &[("ox_lib", true)], 3000); + orders.credit(&resources); + assert_eq!(orders.by("ox_lib", 3000).as_deref(), Some("ada")); + assert_eq!(orders.waiting("ox_lib"), None, "FXServer answered"); + + turned(&mut resources, &["bad name"], &[("ox_lib", false)], 4000); + orders.credit(&resources); + assert_eq!( + orders.by("ox_lib", 4000), + None, + "another's stop, after the answer" + ); + + turned(&mut resources, &[], &[("bad name", false)], 5000); + orders.credit(&resources); + assert_eq!(orders.by("bad name", 5000).as_deref(), Some("ada")); + assert!(orders.asked.is_empty()); + } + + #[test] + fn a_restart_told_in_one_report_is_answered_at_once() { + let mut resources = resources(3); + let mut orders = Orders::default(); + orders.ask(&resources, "ox_lib", LiveChangeKind::Restarted, "ada"); + + turned( + &mut resources, + &["bad name", "ox_lib"], + &[("ox_target", false), ("ox_lib", false), ("ox_lib", true)], + 2000, + ); + orders.credit(&resources); + + assert_eq!(orders.by("ox_lib", 2000).as_deref(), Some("ada")); + assert_eq!(orders.waiting("ox_lib"), None); + } + + #[test] + fn a_start_fxserver_could_not_do_is_answered_and_a_silent_order_is_given_up() { + let mut resources = resources(3); + let mut orders = Orders::default(); + orders.ask(&resources, "taxi", LiveChangeKind::Started, "ada"); + orders.credit(&resources); + assert_eq!(orders.waiting("taxi"), Some(asked(LiveChangeKind::Started))); + + resources.marks.failed.insert("taxi".to_owned(), 2000); + orders.credit(&resources); + assert_eq!(orders.waiting("taxi"), None, "FXServer said it could not"); + turned( + &mut resources, + &["bad name", "ox_lib", "ox_target", "taxi"], + &[("taxi", true)], + 3000, + ); + orders.credit(&resources); + assert_eq!(orders.by("taxi", 3000), None, "another's start, after the answer"); + + orders.ask(&resources, "taxi", LiveChangeKind::Stopped, "ada"); + orders.withdraw("taxi"); + assert_eq!(orders.waiting("taxi"), None, "its line never reached FXServer"); + + orders.ask(&resources, "ox_lib", LiveChangeKind::Stopped, "ada"); + for order in orders.asked.values_mut() { + order.asked.at -= ANSWERED_WITHIN; + } + turned( + &mut resources, + &["bad name", "taxi"], + &[("ox_target", false), ("ox_lib", false)], + 9000, + ); + orders.credit(&resources); + assert_eq!(orders.by("ox_lib", 9000), None, "the order is too old to count"); + assert!(orders.asked.is_empty()); + } + + #[test] + fn the_orders_of_a_run_say_nothing_of_the_next() { + let mut first = resources(3); + let mut orders = Orders::default(); + orders.ask(&first, "ox_lib", LiveChangeKind::Stopped, "ada"); + orders.ask_rescan(&first, "ada"); + turned(&mut first, &["ox_target"], &[("ox_lib", false)], 2000); + orders.credit(&first); + assert_eq!(orders.by("ox_lib", 2000).as_deref(), Some("ada")); + + let mut next = resources(4); + turned(&mut next, &["ox_target"], &[("ox_lib", false)], 2000); + orders.credit(&next); + + assert_eq!(orders.by("ox_lib", 2000), None); + assert!(orders.rescan.is_none()); + } + + #[test] + fn a_rescan_is_put_down_to_who_asked_once_the_list_came_again() { + let mut resources = resources(3); + let mut orders = Orders::default(); + orders.ask_rescan(&resources, "ada"); + orders.credit(&resources); + assert_eq!(orders.rescanned, None); + + resources.book.page( + 2, + 1, + true, + vec![ + reported("ox_lib", "started", &[]), + reported("bank", "stopped", &[]), + ], + BRIDGE, + 5000, + ); + orders.credit(&resources); + + assert_eq!(orders.rescanned, Some((5000, "ada".to_owned()))); + assert!(orders.rescan.is_none()); + } +} diff --git a/crates/observer-domain/src/audit.rs b/crates/observer-domain/src/audit.rs index 1dc32d68..eb843c0b 100644 --- a/crates/observer-domain/src/audit.rs +++ b/crates/observer-domain/src/audit.rs @@ -238,10 +238,21 @@ pub enum AuditAction { /// `to`); a failure when the release could not be downloaded or verified (`reason`), or when /// the new version did not finish starting and the previous one came back (`rolledBack`). ObserverUpdateFinished, + /// Someone started a resource from the panel. The target is the resource; `details` hold the + /// `command` written to FXServer's console. + ResourceStarted, + /// Someone stopped a resource. `details` hold the `command` and the `dependants`: the started + /// resources that depend on it, which FXServer stops with it. + ResourceStopped, + /// Someone restarted a resource. `details` hold the `command`, the `dependants` and whether + /// Observer started those again (`startedAgain`). + ResourceRestarted, + /// Someone had FXServer read its resource folders again. + ResourcesRescanned, } impl AuditAction { - pub const ALL: [Self; 111] = [ + pub const ALL: [Self; 115] = [ Self::SetupCompleted, Self::SetupLinkCreated, Self::LoginSucceeded, @@ -353,6 +364,10 @@ impl AuditAction { Self::ObserverUpdateStarted, Self::ObserverUpdateCancelled, Self::ObserverUpdateFinished, + Self::ResourceStarted, + Self::ResourceStopped, + Self::ResourceRestarted, + Self::ResourcesRescanned, ]; #[expect(clippy::too_many_lines, reason = "one arm per action, read best as one list")] @@ -469,6 +484,10 @@ impl AuditAction { Self::ObserverUpdateStarted => "observer_update_started", Self::ObserverUpdateCancelled => "observer_update_cancelled", Self::ObserverUpdateFinished => "observer_update_finished", + Self::ResourceStarted => "resource_started", + Self::ResourceStopped => "resource_stopped", + Self::ResourceRestarted => "resource_restarted", + Self::ResourcesRescanned => "resources_rescanned", } } diff --git a/crates/observer-domain/src/error.rs b/crates/observer-domain/src/error.rs index 8d74da76..96a86d7c 100644 --- a/crates/observer-domain/src/error.rs +++ b/crates/observer-domain/src/error.rs @@ -237,6 +237,18 @@ pub enum ErrorCode { UpdateRestarting, /// Params: `reason`, an `UpdateBlock`. UpdateBlocked, + /// The server has not listed its resources in this run: it does once its cfg has run. + ResourcesNotListed, + /// FXServer listed no resource of this name in this run. + ResourceNotFound, + /// The resource's name holds what a console command cannot carry: Observer names a resource + /// in a command only by ASCII letters, digits, `_`, `.` and `-`, 64 at most. + ResourceNameRefused, + /// FXServer has not answered the last order of the Resources page on this resource yet. + ResourceBusy, + /// The resource is not in the state the order needs: a start needs it stopped, a stop and a + /// restart need it started. + ResourceChanged, } /// The body of every error response. diff --git a/crates/observer-domain/src/lib.rs b/crates/observer-domain/src/lib.rs index d81de765..74c2b3d1 100644 --- a/crates/observer-domain/src/lib.rs +++ b/crates/observer-domain/src/lib.rs @@ -27,6 +27,7 @@ pub mod map; pub mod metrics; pub mod permissions; pub mod players; +pub mod resources; pub mod schedule; pub mod server; pub mod server_data; diff --git a/crates/observer-domain/src/live.rs b/crates/observer-domain/src/live.rs index aa49dfc8..52424181 100644 --- a/crates/observer-domain/src/live.rs +++ b/crates/observer-domain/src/live.rs @@ -13,6 +13,7 @@ use crate::diagnostics::{AlertList, DiagnosticsEvent, DiagnosticsSnapshot}; use crate::error::ErrorCode; use crate::map::MapLive; use crate::metrics::{LiveSample, MetricsSnapshot}; +use crate::resources::ResourcesView; use crate::schedule::ScheduleStatus; use crate::server::{ConsoleBatch, ConsoleSnapshot, ServerStatus}; @@ -73,10 +74,14 @@ pub enum Topic { /// only then. Needs the live map permission. #[serde(rename = "server.map")] ServerMap, + /// Every resource FXServer knows in the run that is up, as it stands. Needs the dashboard + /// permission, or the one that starts and stops resources. + #[serde(rename = "server.resources")] + ServerResources, } impl Topic { - pub const ALL: [Self; 11] = [ + pub const ALL: [Self; 12] = [ Self::Status, Self::Audit, Self::ServerStatus, @@ -88,6 +93,7 @@ impl Topic { Self::ServerDiagnostics, Self::ServerChanges, Self::ServerMap, + Self::ServerResources, ]; } @@ -165,6 +171,8 @@ pub enum TopicSnapshot { ServerChanges(ChangesLive), #[serde(rename = "server.map")] ServerMap(MapLive), + #[serde(rename = "server.resources")] + ServerResources(ResourcesView), } #[derive(Debug, Clone, PartialEq, Serialize, TS)] @@ -196,6 +204,9 @@ pub enum TopicEvent { /// Every placed player again, at each report of the bridge. #[serde(rename = "server.map")] ServerMap(MapLive), + /// Every resource again, whenever one changes: a few times a second at most. + #[serde(rename = "server.resources")] + ServerResources(ResourcesView), } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, TS)] diff --git a/crates/observer-domain/src/permissions.rs b/crates/observer-domain/src/permissions.rs index 8104324e..e29b46d0 100644 --- a/crates/observer-domain/src/permissions.rs +++ b/crates/observer-domain/src/permissions.rs @@ -41,6 +41,7 @@ impl Permissions { pub const PLAYERS_BAN: Self = Self(1 << 23); pub const BANS_LIFT: Self = Self(1 << 24); pub const PLAYERS_MAP: Self = Self(1 << 25); + pub const RESOURCES_CONTROL: Self = Self(1 << 26); /// Every permission in the catalog, which is what the owner holds. pub fn all() -> Self { @@ -462,6 +463,15 @@ pub const CATALOG: &[PermissionInfo] = &[ danger: Danger::Sensitive, implies: Permissions::PLAYERS_VIEW, }, + PermissionInfo { + bit: 26, + constant: "RESOURCES_CONTROL", + key: "resourcesControl", + area: PermissionArea::Server, + // A resource that stops takes with it every one that depends on it, under the players. + danger: Danger::Sensitive, + implies: Permissions::NONE, + }, ]; #[cfg(test)] @@ -502,6 +512,7 @@ mod tests { (23, "PLAYERS_BAN", Permissions::PLAYERS_BAN), (24, "BANS_LIFT", Permissions::BANS_LIFT), (25, "PLAYERS_MAP", Permissions::PLAYERS_MAP), + (26, "RESOURCES_CONTROL", Permissions::RESOURCES_CONTROL), ]; assert_eq!(CATALOG.len(), frozen.len()); diff --git a/crates/observer-domain/src/resources.rs b/crates/observer-domain/src/resources.rs new file mode 100644 index 00000000..a817e6cb --- /dev/null +++ b/crates/observer-domain/src/resources.rs @@ -0,0 +1,204 @@ +//! The server's resources as the panel follows them: every resource FXServer knows in the run that +//! is up, as it stands, and what the panel may ask of one. + +use jiff::Timestamp; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::changes::LiveChangeKind; +use crate::server::Delivery; + +/// The resources a rescan found named at most: the count says the rest. +pub const MAX_ADDED_NAMED: usize = 20; + +/// The `server.resources` topic: the resources of the run that is up, whole whenever any of it +/// changes. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct ResourcesView { + pub stage: ResourcesStage, + /// The start the list is of. + pub generation: u32, + /// In name order, without regard to case. + pub resources: Vec, + /// FXServer knows more resources than the list holds. + pub truncated: bool, + /// The latest rescan of this run. + pub rescan: Option, + /// A rescan asked on the page has not shown yet. + pub rescanning: bool, +} + +/// Whether there is a list to show. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub enum ResourcesStage { + /// No server runs. + #[default] + Stopped, + /// The server runs its cfg: it lists its resources once that is done. + Starting, + Listed, + /// The server runs, and its bridge listed nothing. + Unreported, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct ResourceRow { + /// As FXServer names it. + pub name: String, + /// The category folders it lies under, outermost first, as `[core]/[ox]`: empty at the root. + pub folder: String, + pub origin: ResourceOrigin, + /// What its manifest gives as its version. + pub version: Option, + pub state: ResourceState, + /// When FXServer said it could not start it, for one it has not started since. + pub failed_at: Option, + /// What FXServer warned of as it last started it. + pub warnings: Option, + /// The started resources that depend on it, the nearest first: FXServer stops them with it and + /// starts none of them again. + pub dependants: Vec, + /// Whether Observer can name it in a console command: it starts and stops no other. + pub controllable: bool, + /// What last happened to it in this run. + pub last: Option, + /// An order of the page FXServer has not answered yet: no other is taken meanwhile. + pub asked: Option, +} + +/// An order of the Resources page, by what it makes of the resource. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct ResourceAsked { + pub kind: LiveChangeKind, + pub by: String, +} + +/// Which of FXServer's two roots a resource lies under. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub enum ResourceOrigin { + /// The server folder's `resources`. + Server, + /// The build's own `system_resources`. + Build, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub enum ResourceState { + Started, + Starting, + Stopped, + Stopping, + /// A state FXServer gave that is none of these. + Unknown, +} + +/// What FXServer said as it started a resource. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct ResourceWarnings { + /// How many warnings FXServer counted on the line that says it started. + pub count: u32, + /// Streamed files it finds heavy, and how many of them it calls oversized. + pub heavy: u32, + pub oversized: u32, + /// Files the manifest names that the resource does not hold. + pub missing: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct ResourceTurn { + pub at: Timestamp, + pub kind: LiveChangeKind, + /// Who asked for it on this page. + pub by: Option, + /// The resource it stopped with, being one that depends on it. + pub with: Option, +} + +/// What a rescan found. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct ResourcesRescan { + pub at: Timestamp, + /// Who asked for it on this page: none for a `refresh` typed in the console. + pub by: Option, + /// The resources FXServer did not know before, by name: the first of them, when there are + /// more than a line can say. + pub added: Vec, + /// How many there are in all. + pub added_count: u32, + /// The manifests that changed among those FXServer read again, when it said. + pub changed: Option, +} + +/// `POST /api/servers/default/resources/start`, `/stop` and `/restart`. +#[derive(Debug, Clone, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export)] +pub struct ResourceOrder { + pub name: String, + /// For a restart: also start again the resources that depend on this one, which FXServer + /// stops with it and leaves stopped. + #[serde(default)] + pub dependants: bool, +} + +/// What became of an order, once it was audited. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct ResourceAck { + pub delivery: Delivery, + /// The resources that stop with this one, as known when the order was made. + pub dependants: Vec, +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn an_order_names_a_resource_and_restarts_what_depends_on_it_only_when_asked() { + let order: ResourceOrder = serde_json::from_value(json!({ "name": "ox_lib" })).unwrap(); + assert_eq!((order.name.as_str(), order.dependants), ("ox_lib", false)); + + let order: ResourceOrder = + serde_json::from_value(json!({ "name": "ox_lib", "dependants": true })).unwrap(); + assert!(order.dependants); + + assert!(serde_json::from_value::(json!({ "name": "a", "command": "quit" })).is_err()); + } + + #[test] + fn the_view_of_a_stopped_server_is_empty() { + assert_eq!( + serde_json::to_value(ResourcesView::default()).unwrap(), + json!({ + "stage": "stopped", + "generation": 0, + "resources": [], + "truncated": false, + "rescan": null, + "rescanning": false + }) + ); + } +} diff --git a/crates/observer-fxserver/src/supervisor.rs b/crates/observer-fxserver/src/supervisor.rs index f5cfadcc..5839b712 100644 --- a/crates/observer-fxserver/src/supervisor.rs +++ b/crates/observer-fxserver/src/supervisor.rs @@ -488,6 +488,8 @@ pub enum ControlError { Refused(#[from] CommandRefusal), #[error(transparent)] Stdin(#[from] StdinError), + #[error("Observer's bridge is not a resource to start or stop")] + Bridge, } type Reply = oneshot::Sender>; @@ -1507,7 +1509,15 @@ impl Actor { } } + /// The bridge is Observer's own: it ends with the server, and by nothing asked for here. fn resource(&self, verb: ResourceVerb, name: ResourceName, by: String) -> Result { + let bridge = self + .run + .as_ref() + .is_some_and(|run| name.as_str().eq_ignore_ascii_case(&run.bridge)); + if bridge { + return Err(ControlError::Bridge); + } self.asked(SystemLine::Resource(verb, name), by) } diff --git a/crates/observer-web/src/api.rs b/crates/observer-web/src/api.rs index 290a580f..cffc4ddb 100644 --- a/crates/observer-web/src/api.rs +++ b/crates/observer-web/src/api.rs @@ -19,6 +19,7 @@ mod files; mod map; mod metrics; mod players; +mod resources; mod schedule; mod servers; mod settings; @@ -78,6 +79,9 @@ pub(crate) fn routes() -> Vec { // which the handler checks once it has read the line. let command = Permission(P::CONSOLE_WRITE); let logs = Permission(P::LOGS_DOWNLOAD); + // Starting, stopping and restarting a resource, and having FXServer read its folders again: + // one permission of their own, less than the console's, which runs any command. + let resources = Permission(P::RESOURCES_CONTROL); // The schedule's settings take the password again; skipping, restoring or delaying its next // restart are operations, and so is a single restart, which is the server control's. let plan = Permission(P::SCHEDULE_MANAGE); @@ -200,6 +204,10 @@ pub(crate) fn routes() -> Vec { route(M::POST, "/servers/default/console", command, servers::command), route(M::GET, "/servers/default/console/logs", logs, servers::log_files), route(M::GET, "/servers/default/console/logs/{name}", logs, servers::download_log), + route(M::POST, "/servers/default/resources/start", resources, resources::start), + route(M::POST, "/servers/default/resources/stop", resources, resources::stop), + route(M::POST, "/servers/default/resources/restart", resources, resources::restart), + route(M::POST, "/servers/default/resources/rescan", resources, resources::rescan), route(M::PUT, "/servers/default/schedule", edit_schedule, schedule::change), route(M::POST, "/servers/default/schedule/skip", plan, schedule::skip), route(M::DELETE, "/servers/default/schedule/skip", plan, schedule::restore), diff --git a/crates/observer-web/src/api/resources.rs b/crates/observer-web/src/api/resources.rs new file mode 100644 index 00000000..2b50957e --- /dev/null +++ b/crates/observer-web/src/api/resources.rs @@ -0,0 +1,47 @@ +use axum::extract::State; +use axum::{Extension, Json}; +use observer_app::Caller; +use observer_domain::resources::{ResourceAck, ResourceOrder}; + +use crate::AppState; +use crate::error::Failure; +use crate::extract::JsonBody; + +/// The orders answer once FXServer's console has the line; the `server.resources` topic follows +/// what FXServer does of it. +pub(crate) async fn start( + State(state): State, + Extension(caller): Extension, + JsonBody(order): JsonBody, +) -> Result, Failure> { + Ok(Json( + state.supervision.start_resource(&caller, &order.name).await?, + )) +} + +pub(crate) async fn stop( + State(state): State, + Extension(caller): Extension, + JsonBody(order): JsonBody, +) -> Result, Failure> { + Ok(Json(state.supervision.stop_resource(&caller, &order.name).await?)) +} + +pub(crate) async fn restart( + State(state): State, + Extension(caller): Extension, + JsonBody(order): JsonBody, +) -> Result, Failure> { + let ack = state + .supervision + .restart_resource(&caller, &order.name, order.dependants) + .await?; + Ok(Json(ack)) +} + +pub(crate) async fn rescan( + State(state): State, + Extension(caller): Extension, +) -> Result, Failure> { + Ok(Json(state.supervision.rescan_resources(&caller).await?)) +} diff --git a/crates/observer-web/src/error.rs b/crates/observer-web/src/error.rs index b82d44f5..b684e264 100644 --- a/crates/observer-web/src/error.rs +++ b/crates/observer-web/src/error.rs @@ -71,6 +71,7 @@ fn status(code: ErrorCode) -> StatusCode { | ErrorCode::StartNotFound | ErrorCode::PlayerNotFound | ErrorCode::BanNotFound + | ErrorCode::ResourceNotFound | ErrorCode::NoMapImage | ErrorCode::FeatureDisabled => StatusCode::NOT_FOUND, ErrorCode::ContentNotKept => StatusCode::GONE, @@ -113,7 +114,11 @@ fn status(code: ErrorCode) -> StatusCode { | ErrorCode::UpdateRunning | ErrorCode::NoUpdate | ErrorCode::UpdateRestarting - | ErrorCode::UpdateBlocked => StatusCode::CONFLICT, + | ErrorCode::UpdateBlocked + | ErrorCode::ResourcesNotListed + | ErrorCode::ResourceNameRefused + | ErrorCode::ResourceBusy + | ErrorCode::ResourceChanged => StatusCode::CONFLICT, ErrorCode::LaunchFailed | ErrorCode::DelayTooLong | ErrorCode::NotDownloadable diff --git a/crates/observer-web/src/live/topics.rs b/crates/observer-web/src/live/topics.rs index c0f50700..da58cf9d 100644 --- a/crates/observer-web/src/live/topics.rs +++ b/crates/observer-web/src/live/topics.rs @@ -58,6 +58,8 @@ pub(super) fn allows(topic: Topic, held: Permissions) -> bool { Topic::ServerDiagnostics => any(&[Permissions::DIAGNOSTICS_VIEW]), Topic::ServerChanges => any(&[Permissions::CHANGES_VIEW]), Topic::ServerMap => any(&[Permissions::PLAYERS_MAP]), + // Names, versions and states: what the dashboard's start already shows one by one. + Topic::ServerResources => any(&[Permissions::DASHBOARD_VIEW, Permissions::RESOURCES_CONTROL]), } } @@ -155,6 +157,14 @@ pub(super) async fn open(topic: Topic, since: Option, state: &AppState) -> events: Some(status_events(live, TopicEvent::ServerChanges)), }) } + Topic::ServerResources => { + let mut resources = state.supervision.resources(); + let snapshot = resources.borrow_and_update().clone(); + Ok(Feed { + snapshot: TopicSnapshot::ServerResources(snapshot), + events: Some(status_events(resources, TopicEvent::ServerResources)), + }) + } } } diff --git a/crates/observer-web/src/tests.rs b/crates/observer-web/src/tests.rs index ad0b75d0..4889f8e0 100644 --- a/crates/observer-web/src/tests.rs +++ b/crates/observer-web/src/tests.rs @@ -16,6 +16,7 @@ mod map; mod metrics; mod players; mod proxy; +mod resources; mod schedule; mod servers; mod settings; @@ -708,7 +709,7 @@ async fn setup_signs_the_owner_in_with_a_strict_http_only_cookie() { assert_eq!(session["sessionKey"].as_str().unwrap().len(), 64); assert_eq!(session["me"]["username"], "ada"); assert_eq!(session["me"]["isOwner"], true); - assert_eq!(session["me"]["permissions"], "3ffffff"); + assert_eq!(session["me"]["permissions"], "7ffffff"); let again = server.call(Method::POST, "/api/setup", None, Some(body)).await; assert_eq!(again.status, StatusCode::CONFLICT); diff --git a/crates/observer-web/src/tests/resources.rs b/crates/observer-web/src/tests/resources.rs new file mode 100644 index 00000000..a29b19c3 --- /dev/null +++ b/crates/observer-web/src/tests/resources.rs @@ -0,0 +1,119 @@ +//! The orders of the Resources page and its topic, without an FXServer to run: who they refuse, +//! why, and what they audit. `fake-fxserver/tests/runtime.rs` drives resources through them. + +use axum::http::{Method, StatusCode}; +use serde_json::{Value, json}; + +use super::Server; +use super::live::{receive, send}; + +const START: &str = "/api/servers/default/resources/start"; +const STOP: &str = "/api/servers/default/resources/stop"; +const RESTART: &str = "/api/servers/default/resources/restart"; +const RESCAN: &str = "/api/servers/default/resources/rescan"; +/// `RESOURCES_CONTROL` alone. +const KEEPER: &str = "4000000"; + +#[tokio::test] +async fn orders_on_resources_take_their_own_permission_and_a_server_that_listed_them() { + let server = Server::new().await; + let owner = server.owner().await; + // The dashboard, the console and the server's controls: none of them starts a resource here. + let operator = server.member(&owner, "grace", "f").await; + let keeper = server.member(&owner, "kenji", KEEPER).await; + let order = json!({ "name": "chat" }); + + for path in [START, STOP, RESTART, RESCAN] { + let body = (path != RESCAN).then(|| order.clone()); + let refused = server.call(Method::POST, path, Some(&operator), body).await; + assert_eq!(refused.status, StatusCode::FORBIDDEN, "{path}"); + assert_eq!(refused.json()["params"]["permission"], "resourcesControl"); + } + let denied = server.audited("permission_denied").await; + assert_eq!( + denied.len(), + 4, + "a refused order is audited: the permission is sensitive" + ); + assert_eq!(denied[0]["target"], format!("POST {RESCAN}")); + + for path in [START, STOP, RESTART, RESCAN] { + let body = (path != RESCAN).then(|| order.clone()); + let reply = server.call(Method::POST, path, Some(&keeper), body).await; + assert_eq!( + (reply.status, reply.code()), + (StatusCode::CONFLICT, json!("server_not_running")), + "{path}" + ); + } + for action in [ + "resource_started", + "resource_stopped", + "resource_restarted", + "resources_rescanned", + ] { + assert_eq!( + server.audited(action).await, + Vec::::new(), + "{action}: nothing was asked of a server" + ); + } +} + +#[tokio::test] +async fn an_order_names_a_resource_and_nothing_else() { + let server = Server::new().await; + let owner = server.owner().await; + + for body in [ + json!({}), + json!({ "name": 3 }), + json!({ "name": "chat", "command": "quit" }), + json!({ "name": "chat", "dependants": "yes" }), + ] { + let reply = server + .call(Method::POST, RESTART, Some(&owner), Some(body.clone())) + .await; + assert_eq!( + (reply.status, reply.code()), + (StatusCode::BAD_REQUEST, json!("invalid_request")), + "{body}" + ); + } +} + +#[tokio::test] +async fn the_resources_topic_is_open_to_the_dashboard_and_to_who_starts_and_stops_them() { + let server = Server::new().await; + let owner = server.owner().await; + let viewer = server.member(&owner, "grace", "1").await; + let keeper = server.member(&owner, "kenji", KEEPER).await; + let auditor = server.member(&owner, "cid", "100").await; + let sub = json!({ "type": "sub", "topic": "server.resources" }); + + for session in [&viewer, &keeper] { + let mut socket = server.socket(session).await; + send(&mut socket, sub.clone()).await; + assert_eq!( + receive(&mut socket).await, + json!({ + "type": "snapshot", + "topic": "server.resources", + "data": { + "stage": "stopped", + "generation": 0, + "resources": [], + "truncated": false, + "rescan": null, + "rescanning": false + } + }) + ); + } + let mut refused = server.socket(&auditor).await; + send(&mut refused, sub).await; + assert_eq!( + receive(&mut refused).await, + json!({ "type": "unsubscribed", "topic": "server.resources", "reason": "forbidden" }) + ); +} diff --git a/web/src/api/generated/AuditAction.ts b/web/src/api/generated/AuditAction.ts index 6aaf945a..dff2ad25 100644 --- a/web/src/api/generated/AuditAction.ts +++ b/web/src/api/generated/AuditAction.ts @@ -3,4 +3,4 @@ /** * Every action the audit log records. Stored by name, so a variant is never renamed. */ -export type AuditAction = "setup_completed" | "setup_link_created" | "login_succeeded" | "login_failed" | "logout" | "session_revoked" | "password_changed" | "user_created" | "user_deleted" | "user_permissions_changed" | "user_password_reset" | "user_unlocked" | "permission_denied" | "audit_exported" | "reauthenticated" | "launch_settings_changed" | "server_started" | "server_stopped" | "server_restarted" | "server_start_extended" | "server_cfg_changed" | "server_cfg_restored" | "console_command_sent" | "console_log_downloaded" | "server_crash_loop" | "schedule_changed" | "scheduled_restart_skipped" | "scheduled_restart_restored" | "scheduled_restart_delayed" | "scheduled_restart_missed" | "one_off_restart_planned" | "one_off_restart_cancelled" | "build_install_started" | "build_install_cancelled" | "build_install_finished" | "build_selected" | "pending_build_set" | "pending_build_cancelled" | "pending_build_applied" | "pending_build_dropped" | "probation_passed" | "probation_failed" | "build_rolled_back" | "build_deleted" | "artifact_settings_changed" | "observer_stopped" | "tree_process_started" | "hang_snapshot_taken" | "crash_file_downloaded" | "crash_files_deleted" | "tree_records_full" | "file_session_opened" | "file_session_closed" | "file_login_failed" | "file_written" | "file_deleted" | "folder_created" | "folder_removed" | "file_renamed" | "file_refused" | "file_records_full" | "file_group_created" | "file_group_changed" | "file_group_deleted" | "file_access_changed" | "service_access_created" | "service_access_changed" | "service_access_deleted" | "file_key_added" | "file_key_removed" | "file_session_disconnected" | "file_settings_changed" | "file_host_key_created" | "player_forgotten" | "players_imported" | "players_retention_changed" | "player_drops_exported" | "player_banned" | "ban_lifted" | "bans_settings_changed" | "map_image_changed" | "map_settings_changed" | "drop_rates_exported" | "drops_retention_changed" | "change_contents_viewed" | "change_settings_changed" | "change_contents_purged" | "change_verify_requested" | "change_check_requested" | "tool_settings_changed" | "tool_files_changed" | "tool_files_restored" | "tool_fix_staged" | "tool_fix_unstaged" | "feature_changed" | "sftp_listen_changed" | "debug_host_known" | "backup_settings_changed" | "backup_destination_created" | "backup_destination_changed" | "backup_destination_deleted" | "backup_finished" | "backup_cancelled" | "backup_downloaded" | "backup_deleted" | "backups_expired" | "statistics_link_dismissed" | "statistics_link_restored" | "observer_update_started" | "observer_update_cancelled" | "observer_update_finished"; +export type AuditAction = "setup_completed" | "setup_link_created" | "login_succeeded" | "login_failed" | "logout" | "session_revoked" | "password_changed" | "user_created" | "user_deleted" | "user_permissions_changed" | "user_password_reset" | "user_unlocked" | "permission_denied" | "audit_exported" | "reauthenticated" | "launch_settings_changed" | "server_started" | "server_stopped" | "server_restarted" | "server_start_extended" | "server_cfg_changed" | "server_cfg_restored" | "console_command_sent" | "console_log_downloaded" | "server_crash_loop" | "schedule_changed" | "scheduled_restart_skipped" | "scheduled_restart_restored" | "scheduled_restart_delayed" | "scheduled_restart_missed" | "one_off_restart_planned" | "one_off_restart_cancelled" | "build_install_started" | "build_install_cancelled" | "build_install_finished" | "build_selected" | "pending_build_set" | "pending_build_cancelled" | "pending_build_applied" | "pending_build_dropped" | "probation_passed" | "probation_failed" | "build_rolled_back" | "build_deleted" | "artifact_settings_changed" | "observer_stopped" | "tree_process_started" | "hang_snapshot_taken" | "crash_file_downloaded" | "crash_files_deleted" | "tree_records_full" | "file_session_opened" | "file_session_closed" | "file_login_failed" | "file_written" | "file_deleted" | "folder_created" | "folder_removed" | "file_renamed" | "file_refused" | "file_records_full" | "file_group_created" | "file_group_changed" | "file_group_deleted" | "file_access_changed" | "service_access_created" | "service_access_changed" | "service_access_deleted" | "file_key_added" | "file_key_removed" | "file_session_disconnected" | "file_settings_changed" | "file_host_key_created" | "player_forgotten" | "players_imported" | "players_retention_changed" | "player_drops_exported" | "player_banned" | "ban_lifted" | "bans_settings_changed" | "map_image_changed" | "map_settings_changed" | "drop_rates_exported" | "drops_retention_changed" | "change_contents_viewed" | "change_settings_changed" | "change_contents_purged" | "change_verify_requested" | "change_check_requested" | "tool_settings_changed" | "tool_files_changed" | "tool_files_restored" | "tool_fix_staged" | "tool_fix_unstaged" | "feature_changed" | "sftp_listen_changed" | "debug_host_known" | "backup_settings_changed" | "backup_destination_created" | "backup_destination_changed" | "backup_destination_deleted" | "backup_finished" | "backup_cancelled" | "backup_downloaded" | "backup_deleted" | "backups_expired" | "statistics_link_dismissed" | "statistics_link_restored" | "observer_update_started" | "observer_update_cancelled" | "observer_update_finished" | "resource_started" | "resource_stopped" | "resource_restarted" | "resources_rescanned"; diff --git a/web/src/api/generated/ErrorCode.ts b/web/src/api/generated/ErrorCode.ts index 8c321803..8bed511f 100644 --- a/web/src/api/generated/ErrorCode.ts +++ b/web/src/api/generated/ErrorCode.ts @@ -4,4 +4,4 @@ * Why the API refused a request. The panel turns the code into a message; the API never sends * sentences. */ -export type ErrorCode = "unauthenticated" | "invalid_credentials" | "temporary_password_expired" | "too_many_attempts" | "busy" | "too_many_sockets" | "password_change_required" | "reauth_required" | "wrong_password" | "forbidden" | "owner_only" | "self_edit" | "owner_protected" | "escalation" | "hierarchy" | "conflict" | "not_found" | "name_taken" | "invalid_username" | "password_too_short" | "password_too_long" | "password_unchanged" | "unknown_permissions" | "invalid_launch_settings" | "launch_not_configured" | "launch_failed" | "server_running" | "server_not_running" | "server_stopping" | "server_not_starting" | "cfg_not_editable" | "cfg_changed" | "invalid_cfg_text" | "cfg_version_not_found" | "shutting_down" | "command_refused" | "too_many_commands" | "already_set_up" | "invalid_setup_token" | "origin_mismatch" | "unknown_host" | "json_required" | "invalid_request" | "internal" | "invalid_schedule" | "nothing_to_skip" | "already_skipped" | "nothing_to_restore" | "nothing_to_delay" | "delay_too_long" | "no_one_off" | "job_running" | "no_job" | "job_committing" | "catalog_unavailable" | "build_not_listed" | "not_downloadable" | "already_installed" | "end_of_life" | "build_not_installed" | "build_broken" | "kvp_rollback" | "no_scheduled_restart" | "already_selected" | "no_pending" | "build_protected" | "build_in_use" | "invalid_artifact_settings" | "no_build_selected" | "invalid_import_folder" | "crash_not_found" | "crash_file_not_found" | "crash_file_too_large" | "setup_blocked" | "invalid_rule" | "invalid_public_key" | "key_in_use" | "too_many_keys" | "invalid_access" | "player_not_found" | "address_search" | "invalid_search" | "invalid_players_settings" | "invalid_players_file" | "invalid_drops_settings" | "ban_not_found" | "ban_not_active" | "invalid_ban" | "invalid_bans_settings" | "invalid_map_image" | "no_map_image" | "invalid_map_settings" | "changes_unavailable" | "start_not_found" | "content_hidden" | "content_not_kept" | "diff_too_large" | "check_running" | "check_too_soon" | "invalid_glob" | "plan_changed" | "server_not_stopped" | "tool_not_available" | "feature_disabled" | "feature_locked" | "sftp_listen_pinned" | "invalid_sftp_listen" | "backup_running" | "invalid_backup_settings" | "invalid_backup_destination" | "backup_not_configured" | "backup_copy_unavailable" | "update_not_available" | "update_running" | "no_update" | "update_restarting" | "update_blocked"; +export type ErrorCode = "unauthenticated" | "invalid_credentials" | "temporary_password_expired" | "too_many_attempts" | "busy" | "too_many_sockets" | "password_change_required" | "reauth_required" | "wrong_password" | "forbidden" | "owner_only" | "self_edit" | "owner_protected" | "escalation" | "hierarchy" | "conflict" | "not_found" | "name_taken" | "invalid_username" | "password_too_short" | "password_too_long" | "password_unchanged" | "unknown_permissions" | "invalid_launch_settings" | "launch_not_configured" | "launch_failed" | "server_running" | "server_not_running" | "server_stopping" | "server_not_starting" | "cfg_not_editable" | "cfg_changed" | "invalid_cfg_text" | "cfg_version_not_found" | "shutting_down" | "command_refused" | "too_many_commands" | "already_set_up" | "invalid_setup_token" | "origin_mismatch" | "unknown_host" | "json_required" | "invalid_request" | "internal" | "invalid_schedule" | "nothing_to_skip" | "already_skipped" | "nothing_to_restore" | "nothing_to_delay" | "delay_too_long" | "no_one_off" | "job_running" | "no_job" | "job_committing" | "catalog_unavailable" | "build_not_listed" | "not_downloadable" | "already_installed" | "end_of_life" | "build_not_installed" | "build_broken" | "kvp_rollback" | "no_scheduled_restart" | "already_selected" | "no_pending" | "build_protected" | "build_in_use" | "invalid_artifact_settings" | "no_build_selected" | "invalid_import_folder" | "crash_not_found" | "crash_file_not_found" | "crash_file_too_large" | "setup_blocked" | "invalid_rule" | "invalid_public_key" | "key_in_use" | "too_many_keys" | "invalid_access" | "player_not_found" | "address_search" | "invalid_search" | "invalid_players_settings" | "invalid_players_file" | "invalid_drops_settings" | "ban_not_found" | "ban_not_active" | "invalid_ban" | "invalid_bans_settings" | "invalid_map_image" | "no_map_image" | "invalid_map_settings" | "changes_unavailable" | "start_not_found" | "content_hidden" | "content_not_kept" | "diff_too_large" | "check_running" | "check_too_soon" | "invalid_glob" | "plan_changed" | "server_not_stopped" | "tool_not_available" | "feature_disabled" | "feature_locked" | "sftp_listen_pinned" | "invalid_sftp_listen" | "backup_running" | "invalid_backup_settings" | "invalid_backup_destination" | "backup_not_configured" | "backup_copy_unavailable" | "update_not_available" | "update_running" | "no_update" | "update_restarting" | "update_blocked" | "resources_not_listed" | "resource_not_found" | "resource_name_refused" | "resource_busy" | "resource_changed"; diff --git a/web/src/api/generated/Permission.ts b/web/src/api/generated/Permission.ts index 93d337e5..5c80e332 100644 --- a/web/src/api/generated/Permission.ts +++ b/web/src/api/generated/Permission.ts @@ -27,4 +27,5 @@ export const Permission = { PlayersBan: 1n << 23n, BansLift: 1n << 24n, PlayersMap: 1n << 25n, + ResourcesControl: 1n << 26n, } as const; diff --git a/web/src/api/generated/ResourceAck.ts b/web/src/api/generated/ResourceAck.ts new file mode 100644 index 00000000..624d5bba --- /dev/null +++ b/web/src/api/generated/ResourceAck.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Delivery } from "./Delivery"; + +/** + * What became of an order, once it was audited. + */ +export type ResourceAck = { delivery: Delivery, +/** + * The resources that stop with this one, as known when the order was made. + */ +dependants: Array, }; diff --git a/web/src/api/generated/ResourceAsked.ts b/web/src/api/generated/ResourceAsked.ts new file mode 100644 index 00000000..355edfd9 --- /dev/null +++ b/web/src/api/generated/ResourceAsked.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { LiveChangeKind } from "./LiveChangeKind"; + +/** + * An order of the Resources page, by what it makes of the resource. + */ +export type ResourceAsked = { kind: LiveChangeKind, by: string, }; diff --git a/web/src/api/generated/ResourceOrder.ts b/web/src/api/generated/ResourceOrder.ts new file mode 100644 index 00000000..f76e160d --- /dev/null +++ b/web/src/api/generated/ResourceOrder.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * `POST /api/servers/default/resources/start`, `/stop` and `/restart`. + */ +export type ResourceOrder = { name: string, +/** + * For a restart: also start again the resources that depend on this one, which FXServer + * stops with it and leaves stopped. + */ +dependants: boolean, }; diff --git a/web/src/api/generated/ResourceOrigin.ts b/web/src/api/generated/ResourceOrigin.ts new file mode 100644 index 00000000..713f5222 --- /dev/null +++ b/web/src/api/generated/ResourceOrigin.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Which of FXServer's two roots a resource lies under. + */ +export type ResourceOrigin = "server" | "build"; diff --git a/web/src/api/generated/ResourceRow.ts b/web/src/api/generated/ResourceRow.ts new file mode 100644 index 00000000..9c95975d --- /dev/null +++ b/web/src/api/generated/ResourceRow.ts @@ -0,0 +1,45 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ResourceAsked } from "./ResourceAsked"; +import type { ResourceOrigin } from "./ResourceOrigin"; +import type { ResourceState } from "./ResourceState"; +import type { ResourceTurn } from "./ResourceTurn"; +import type { ResourceWarnings } from "./ResourceWarnings"; + +export type ResourceRow = { +/** + * As FXServer names it. + */ +name: string, +/** + * The category folders it lies under, outermost first, as `[core]/[ox]`: empty at the root. + */ +folder: string, origin: ResourceOrigin, +/** + * What its manifest gives as its version. + */ +version: string | null, state: ResourceState, +/** + * When FXServer said it could not start it, for one it has not started since. + */ +failedAt: string | null, +/** + * What FXServer warned of as it last started it. + */ +warnings: ResourceWarnings | null, +/** + * The started resources that depend on it, the nearest first: FXServer stops them with it and + * starts none of them again. + */ +dependants: Array, +/** + * Whether Observer can name it in a console command: it starts and stops no other. + */ +controllable: boolean, +/** + * What last happened to it in this run. + */ +last: ResourceTurn | null, +/** + * An order of the page FXServer has not answered yet: no other is taken meanwhile. + */ +asked: ResourceAsked | null, }; diff --git a/web/src/api/generated/ResourceState.ts b/web/src/api/generated/ResourceState.ts new file mode 100644 index 00000000..d6e0e7ce --- /dev/null +++ b/web/src/api/generated/ResourceState.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ResourceState = "started" | "starting" | "stopped" | "stopping" | "unknown"; diff --git a/web/src/api/generated/ResourceTurn.ts b/web/src/api/generated/ResourceTurn.ts new file mode 100644 index 00000000..8490aefb --- /dev/null +++ b/web/src/api/generated/ResourceTurn.ts @@ -0,0 +1,12 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { LiveChangeKind } from "./LiveChangeKind"; + +export type ResourceTurn = { at: string, kind: LiveChangeKind, +/** + * Who asked for it on this page. + */ +by: string | null, +/** + * The resource it stopped with, being one that depends on it. + */ +with: string | null, }; diff --git a/web/src/api/generated/ResourceWarnings.ts b/web/src/api/generated/ResourceWarnings.ts new file mode 100644 index 00000000..e10d9ae3 --- /dev/null +++ b/web/src/api/generated/ResourceWarnings.ts @@ -0,0 +1,18 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * What FXServer said as it started a resource. + */ +export type ResourceWarnings = { +/** + * How many warnings FXServer counted on the line that says it started. + */ +count: number, +/** + * Streamed files it finds heavy, and how many of them it calls oversized. + */ +heavy: number, oversized: number, +/** + * Files the manifest names that the resource does not hold. + */ +missing: number, }; diff --git a/web/src/api/generated/ResourcesRescan.ts b/web/src/api/generated/ResourcesRescan.ts new file mode 100644 index 00000000..b5229a7e --- /dev/null +++ b/web/src/api/generated/ResourcesRescan.ts @@ -0,0 +1,23 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * What a rescan found. + */ +export type ResourcesRescan = { at: string, +/** + * Who asked for it on this page: none for a `refresh` typed in the console. + */ +by: string | null, +/** + * The resources FXServer did not know before, by name: the first of them, when there are + * more than a line can say. + */ +added: Array, +/** + * How many there are in all. + */ +addedCount: number, +/** + * The manifests that changed among those FXServer read again, when it said. + */ +changed: number | null, }; diff --git a/web/src/api/generated/ResourcesStage.ts b/web/src/api/generated/ResourcesStage.ts new file mode 100644 index 00000000..050e5de1 --- /dev/null +++ b/web/src/api/generated/ResourcesStage.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Whether there is a list to show. + */ +export type ResourcesStage = "stopped" | "starting" | "listed" | "unreported"; diff --git a/web/src/api/generated/ResourcesView.ts b/web/src/api/generated/ResourcesView.ts new file mode 100644 index 00000000..ed8a8af1 --- /dev/null +++ b/web/src/api/generated/ResourcesView.ts @@ -0,0 +1,30 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ResourceRow } from "./ResourceRow"; +import type { ResourcesRescan } from "./ResourcesRescan"; +import type { ResourcesStage } from "./ResourcesStage"; + +/** + * The `server.resources` topic: the resources of the run that is up, whole whenever any of it + * changes. + */ +export type ResourcesView = { stage: ResourcesStage, +/** + * The start the list is of. + */ +generation: number, +/** + * In name order, without regard to case. + */ +resources: Array, +/** + * FXServer knows more resources than the list holds. + */ +truncated: boolean, +/** + * The latest rescan of this run. + */ +rescan: ResourcesRescan | null, +/** + * A rescan asked on the page has not shown yet. + */ +rescanning: boolean, }; diff --git a/web/src/api/generated/Topic.ts b/web/src/api/generated/Topic.ts index 937b1b07..8de3099b 100644 --- a/web/src/api/generated/Topic.ts +++ b/web/src/api/generated/Topic.ts @@ -3,4 +3,4 @@ /** * What the panel can follow. */ -export type Topic = "status" | "audit" | "server.status" | "console" | "metrics.live" | "server.schedule" | "server.artifacts" | "server.alerts" | "server.diagnostics" | "server.changes" | "server.map"; +export type Topic = "status" | "audit" | "server.status" | "console" | "metrics.live" | "server.schedule" | "server.artifacts" | "server.alerts" | "server.diagnostics" | "server.changes" | "server.map" | "server.resources"; diff --git a/web/src/api/generated/TopicEvent.ts b/web/src/api/generated/TopicEvent.ts index 11fc201a..b4b7f037 100644 --- a/web/src/api/generated/TopicEvent.ts +++ b/web/src/api/generated/TopicEvent.ts @@ -7,7 +7,8 @@ import type { ConsoleBatch } from "./ConsoleBatch"; import type { DiagnosticsEvent } from "./DiagnosticsEvent"; import type { LiveSample } from "./LiveSample"; import type { MapLive } from "./MapLive"; +import type { ResourcesView } from "./ResourcesView"; import type { ScheduleStatus } from "./ScheduleStatus"; import type { ServerStatus } from "./ServerStatus"; -export type TopicEvent = { "topic": "audit", "data": AuditEntry } | { "topic": "server.status", "data": ServerStatus } | { "topic": "console", "data": ConsoleBatch } | { "topic": "metrics.live", "data": LiveSample } | { "topic": "server.schedule", "data": ScheduleStatus } | { "topic": "server.artifacts", "data": ArtifactsStatus } | { "topic": "server.alerts", "data": AlertList } | { "topic": "server.diagnostics", "data": DiagnosticsEvent } | { "topic": "server.changes", "data": ChangesLive } | { "topic": "server.map", "data": MapLive }; +export type TopicEvent = { "topic": "audit", "data": AuditEntry } | { "topic": "server.status", "data": ServerStatus } | { "topic": "console", "data": ConsoleBatch } | { "topic": "metrics.live", "data": LiveSample } | { "topic": "server.schedule", "data": ScheduleStatus } | { "topic": "server.artifacts", "data": ArtifactsStatus } | { "topic": "server.alerts", "data": AlertList } | { "topic": "server.diagnostics", "data": DiagnosticsEvent } | { "topic": "server.changes", "data": ChangesLive } | { "topic": "server.map", "data": MapLive } | { "topic": "server.resources", "data": ResourcesView }; diff --git a/web/src/api/generated/TopicSnapshot.ts b/web/src/api/generated/TopicSnapshot.ts index cd3b9de4..9ffbbb94 100644 --- a/web/src/api/generated/TopicSnapshot.ts +++ b/web/src/api/generated/TopicSnapshot.ts @@ -8,7 +8,8 @@ import type { DiagnosticsSnapshot } from "./DiagnosticsSnapshot"; import type { MapLive } from "./MapLive"; import type { MetricsSnapshot } from "./MetricsSnapshot"; import type { ObserverStatus } from "./ObserverStatus"; +import type { ResourcesView } from "./ResourcesView"; import type { ScheduleStatus } from "./ScheduleStatus"; import type { ServerStatus } from "./ServerStatus"; -export type TopicSnapshot = { "topic": "status", "data": ObserverStatus } | { "topic": "audit", "data": AuditSnapshot } | { "topic": "server.status", "data": ServerStatus } | { "topic": "console", "data": ConsoleSnapshot } | { "topic": "metrics.live", "data": MetricsSnapshot } | { "topic": "server.schedule", "data": ScheduleStatus } | { "topic": "server.artifacts", "data": ArtifactsStatus } | { "topic": "server.alerts", "data": AlertList } | { "topic": "server.diagnostics", "data": DiagnosticsSnapshot } | { "topic": "server.changes", "data": ChangesLive } | { "topic": "server.map", "data": MapLive }; +export type TopicSnapshot = { "topic": "status", "data": ObserverStatus } | { "topic": "audit", "data": AuditSnapshot } | { "topic": "server.status", "data": ServerStatus } | { "topic": "console", "data": ConsoleSnapshot } | { "topic": "metrics.live", "data": MetricsSnapshot } | { "topic": "server.schedule", "data": ScheduleStatus } | { "topic": "server.artifacts", "data": ArtifactsStatus } | { "topic": "server.alerts", "data": AlertList } | { "topic": "server.diagnostics", "data": DiagnosticsSnapshot } | { "topic": "server.changes", "data": ChangesLive } | { "topic": "server.map", "data": MapLive } | { "topic": "server.resources", "data": ResourcesView }; diff --git a/web/src/audit/details.test.ts b/web/src/audit/details.test.ts index be071da7..befd0e87 100644 --- a/web/src/audit/details.test.ts +++ b/web/src/audit/details.test.ts @@ -152,6 +152,17 @@ describe('auditDetails', () => { expect(auditDetails(en, format, { from: 'signal' })).toBe('From: a signal'); }); + it('should name the resources an order stopped along, and whether a restart started them again', () => { + expect(auditDetails(en, format, { command: 'start taxi' })).toBe('Command: start taxi'); + expect( + auditDetails(en, format, { + command: 'restart ox_lib', + dependants: ['ox_target', 'ox_inventory'], + startedAgain: true, + }), + ).toBe('Command: restart ox_lib · Stopped with it: ox_target, ox_inventory · Started again: yes'); + }); + it('should show what it does not know as the log stores it', () => { expect(auditDetails(en, format, { trigger: 'manual', refusal: 'unheard_of', extra: { a: 1 } })).toBe( 'trigger: manual · Reason: unheard_of · extra: {"a":1}', diff --git a/web/src/audit/details.ts b/web/src/audit/details.ts index ad62b10a..0933e323 100644 --- a/web/src/audit/details.ts +++ b/web/src/audit/details.ts @@ -24,7 +24,7 @@ const TIMES = new Set([ 'startedAt', ]); const ACTIONS = new Set(['attempted', 'action']); -/** The schedule's lists, and the nested entries of its audit details. */ +/** The schedule's lists, the resources an order stopped, and the nested entries of the details. */ const LISTS = new Set([ 'times', 'warnings', @@ -33,6 +33,7 @@ const LISTS = new Set([ 'extensions', 'excluded', 'hidden', + 'dependants', ]); const NESTED = new Set(['scheduled', 'dropped']); /** The schedule's settings counted in minutes. */ diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index bf30a1bd..d2a07fc0 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -218,6 +218,11 @@ const permissions = { name: 'See the live map', description: 'Where every connected player stands, as the server sees it, a few times a minute.', }, + resourcesControl: { + name: 'Start and stop resources', + description: + 'Start, stop and restart a resource of the running server, and have FXServer read its resource folders again.', + }, } satisfies Record; const permissionNames = (hex: string) => @@ -5705,6 +5710,10 @@ export const en = { observer_update_started: 'Observer update started', observer_update_cancelled: 'Observer update cancelled', observer_update_finished: 'Observer update finished', + resource_started: 'Resource started', + resource_stopped: 'Resource stopped', + resource_restarted: 'Resource restarted', + resources_rescanned: 'Resource folders rescanned', } satisfies Record, outcomes: { success: 'Success', @@ -5895,6 +5904,9 @@ export const en = { updated: 'Updated', forgotten: 'Skipped as forgotten', invalid: 'Invalid', + // The started resources that depended on the one stopped or restarted. + dependants: 'Stopped with it', + startedAgain: 'Started again', }, days: (value: number) => count(value, { one: 'day', other: 'days' }), // A profile whose retention is unset is never deleted. @@ -6369,6 +6381,13 @@ export const en = { const key = stringParam(params, 'reason'); return isUpdateBlock(key) ? updateBlocks[key] : 'This Observer cannot install a release.'; }, + resources_not_listed: 'FXServer has not listed its resources yet. It does once its cfg has run.', + resource_not_found: 'FXServer knows no resource of that name in this run.', + resource_name_refused: + 'Observer puts a resource in a console command only by letters, digits, _, . and -. Start and stop this one from the console.', + resource_busy: 'FXServer has not answered the last order on this resource yet.', + resource_changed: + 'The resource was started or stopped since: it is no longer as this order needs it.', } satisfies Record, failure: { unknown: 'Something went wrong. Try again.', diff --git a/web/src/live/messages.test.ts b/web/src/live/messages.test.ts index 2e53eb19..6190f000 100644 --- a/web/src/live/messages.test.ts +++ b/web/src/live/messages.test.ts @@ -431,3 +431,73 @@ describe('the diagnostics topics', () => { } }); }); + +const RESOURCE = { + name: 'ox_lib', + folder: '[core]', + origin: 'server', + version: '3.30.6', + state: 'started', + failedAt: null, + warnings: { count: 2, heavy: 1, oversized: 0, missing: 1 }, + dependants: ['ox_target'], + controllable: true, + last: { at: '2026-11-11T14:02:00Z', kind: 'restarted', by: 'ada', with: null }, + asked: { kind: 'stopped', by: 'ada' }, +}; + +const RESOURCES = { + stage: 'listed', + generation: 3, + resources: [RESOURCE], + truncated: false, + rescan: { at: '2026-11-11T14:05:00Z', by: null, added: ['bank'], addedCount: 1, changed: 2 }, + rescanning: false, +}; + +describe('the resources topic', () => { + it('should accept its snapshot and its events', () => { + const stopped = { + stage: 'stopped', + generation: 0, + resources: [], + truncated: false, + rescan: null, + rescanning: false, + }; + const plain = { + ...RESOURCE, + version: null, + warnings: null, + last: null, + asked: null, + failedAt: RESOURCES.rescan.at, + }; + for (const message of [ + { type: 'snapshot', topic: 'server.resources', data: stopped }, + { type: 'snapshot', topic: 'server.resources', data: RESOURCES }, + { type: 'event', topic: 'server.resources', data: { ...RESOURCES, resources: [plain] } }, + ]) { + expect(parse(message), JSON.stringify(message)).toEqual(message); + } + }); + + it('should refuse a field out of the contract', () => { + for (const data of [ + { ...RESOURCES, stage: 'paused' }, + { ...RESOURCES, resources: [{ ...RESOURCE, state: 'crashed' }] }, + { ...RESOURCES, resources: [{ ...RESOURCE, origin: 'cloud' }] }, + { ...RESOURCES, resources: [{ ...RESOURCE, dependants: [3] }] }, + { ...RESOURCES, resources: [{ ...RESOURCE, warnings: { count: -1 } }] }, + { ...RESOURCES, resources: [{ ...RESOURCE, last: { ...RESOURCE.last, kind: 'crashed' } }] }, + { ...RESOURCES, resources: [{ ...RESOURCE, asked: { kind: 'restarted' } }] }, + { ...RESOURCES, resources: [{ ...RESOURCE, asked: undefined }] }, + { ...RESOURCES, rescanning: 'yes' }, + { ...RESOURCES, rescan: { ...RESOURCES.rescan, added: 'bank' } }, + { ...RESOURCES, generation: undefined }, + ]) { + const message = { type: 'event', topic: 'server.resources', data }; + expect(parse(message), JSON.stringify(message)).toBeNull(); + } + }); +}); diff --git a/web/src/live/messages.ts b/web/src/live/messages.ts index d767e794..d898645d 100644 --- a/web/src/live/messages.ts +++ b/web/src/live/messages.ts @@ -27,6 +27,7 @@ import type { LaunchReadiness } from '../api/generated/LaunchReadiness'; import type { LineKind } from '../api/generated/LineKind'; import type { Listener } from '../api/generated/Listener'; import type { ListenerRole } from '../api/generated/ListenerRole'; +import type { LiveChangeKind } from '../api/generated/LiveChangeKind'; import type { LiveSample } from '../api/generated/LiveSample'; import type { MapLive } from '../api/generated/MapLive'; import type { NoticeKind } from '../api/generated/NoticeKind'; @@ -35,6 +36,11 @@ import type { Outcome } from '../api/generated/Outcome'; import type { PartialReason } from '../api/generated/PartialReason'; import type { ProcessKind } from '../api/generated/ProcessKind'; import type { Protocol } from '../api/generated/Protocol'; +import type { ResourceOrigin } from '../api/generated/ResourceOrigin'; +import type { ResourceRow } from '../api/generated/ResourceRow'; +import type { ResourceState } from '../api/generated/ResourceState'; +import type { ResourcesStage } from '../api/generated/ResourcesStage'; +import type { ResourcesView } from '../api/generated/ResourcesView'; import type { RestartSource } from '../api/generated/RestartSource'; import type { ScheduleMode } from '../api/generated/ScheduleMode'; import type { ScheduleStatus } from '../api/generated/ScheduleStatus'; @@ -72,6 +78,7 @@ const TOPICS = { 'server.diagnostics': true, 'server.changes': true, 'server.map': true, + 'server.resources': true, } satisfies Record; const UNSUBSCRIBE_REASONS = { forbidden: true, disabled: true } satisfies Record; @@ -268,6 +275,10 @@ const AUDIT_ACTIONS = { observer_update_started: true, observer_update_cancelled: true, observer_update_finished: true, + resource_started: true, + resource_stopped: true, + resource_restarted: true, + resources_rescanned: true, } satisfies Record; const OUTCOMES = { success: true, failure: true, denied: true } satisfies Record; @@ -961,6 +972,75 @@ export const isMapLive = (value: unknown): value is MapLive => orNull(isTimestamp)(value.at) && isCount(value.waiting) && isFlag(value.onesync); + +const RESOURCES_STAGES = { + stopped: true, + starting: true, + listed: true, + unreported: true, +} satisfies Record; +const RESOURCE_ORIGINS = { server: true, build: true } satisfies Record; +const RESOURCE_STATES = { + started: true, + starting: true, + stopped: true, + stopping: true, + unknown: true, +} satisfies Record; +const LIVE_CHANGE_KINDS = { + started: true, + stopped: true, + restarted: true, +} satisfies Record; + +const isResourceWarnings = (value: unknown) => + isRecord(value) && + isCount(value.count) && + isCount(value.heavy) && + isCount(value.oversized) && + isCount(value.missing); + +const isResourceTurn = (value: unknown) => + isRecord(value) && + isTimestamp(value.at) && + oneOf(LIVE_CHANGE_KINDS)(value.kind) && + orNull(isText)(value.by) && + orNull(isText)(value.with); + +const isResourceAsked = (value: unknown) => + isRecord(value) && oneOf(LIVE_CHANGE_KINDS)(value.kind) && isText(value.by); + +const isResourceRow = (value: unknown): value is ResourceRow => + isRecord(value) && + isText(value.name) && + isText(value.folder) && + oneOf(RESOURCE_ORIGINS)(value.origin) && + orNull(isText)(value.version) && + oneOf(RESOURCE_STATES)(value.state) && + orNull(isTimestamp)(value.failedAt) && + (value.warnings === null || isResourceWarnings(value.warnings)) && + isListOf(isText)(value.dependants) && + isFlag(value.controllable) && + (value.last === null || isResourceTurn(value.last)) && + (value.asked === null || isResourceAsked(value.asked)); + +const isResourcesRescan = (value: unknown) => + isRecord(value) && + isTimestamp(value.at) && + orNull(isText)(value.by) && + isListOf(isText)(value.added) && + isCount(value.addedCount) && + orNull(isCount)(value.changed); + +export const isResourcesView = (value: unknown): value is ResourcesView => + isRecord(value) && + oneOf(RESOURCES_STAGES)(value.stage) && + isCount(value.generation) && + isListOf(isResourceRow)(value.resources) && + isFlag(value.truncated) && + (value.rescan === null || isResourcesRescan(value.rescan)) && + isFlag(value.rescanning); + const isSnapshotData = (topic: Topic, data: unknown) => { if (!isRecord(data)) { return false; @@ -993,6 +1073,8 @@ const isSnapshotData = (topic: Topic, data: unknown) => { return isChangesLive(data); case 'server.map': return isMapLive(data); + case 'server.resources': + return isResourcesView(data); } }; @@ -1020,6 +1102,8 @@ const isEventData = (topic: Topic, data: unknown) => { return isChangesLive(data); case 'server.map': return isMapLive(data); + case 'server.resources': + return isResourcesView(data); } }; From 5fda1b9828d18137dd4ea6ce660413adc93db449 Mon Sep 17 00:00:00 2001 From: ImBaphomet Date: Wed, 11 Nov 2026 05:09:32 +0100 Subject: [PATCH 4/5] feat(web): list the server's resources by folder on a Resources page, and start, stop or restart them, naming what stops with each --- README.md | 53 ++- web/src/api/resources.ts | 18 + web/src/components/Segmented.tsx | 8 +- web/src/i18n/en.ts | 146 ++++++++ web/src/pages/ResourcesPage.test.tsx | 464 ++++++++++++++++++++++++++ web/src/pages/ResourcesPage.tsx | 297 +++++++++++++++++ web/src/resources/Dialogs.tsx | 112 +++++++ web/src/resources/Folder.tsx | 227 +++++++++++++ web/src/resources/list.test.ts | 89 +++++ web/src/resources/list.ts | 95 ++++++ web/src/resources/live.test.tsx | 244 ++++++++++++++ web/src/resources/live.ts | 78 +++++ web/src/resources/sent.test.ts | 63 ++++ web/src/resources/sent.ts | 59 ++++ web/src/resources/words.test.ts | 85 +++++ web/src/resources/words.ts | 56 ++++ web/src/router.tsx | 14 + web/src/shell/AppShell.test.tsx | 1 + web/src/shell/CommandPalette.test.tsx | 8 +- web/src/shell/nav.ts | 8 + 20 files changed, 2122 insertions(+), 3 deletions(-) create mode 100644 web/src/api/resources.ts create mode 100644 web/src/pages/ResourcesPage.test.tsx create mode 100644 web/src/pages/ResourcesPage.tsx create mode 100644 web/src/resources/Dialogs.tsx create mode 100644 web/src/resources/Folder.tsx create mode 100644 web/src/resources/list.test.ts create mode 100644 web/src/resources/list.ts create mode 100644 web/src/resources/live.test.tsx create mode 100644 web/src/resources/live.ts create mode 100644 web/src/resources/sent.test.ts create mode 100644 web/src/resources/sent.ts create mode 100644 web/src/resources/words.test.ts create mode 100644 web/src/resources/words.ts diff --git a/README.md b/README.md index 356c2471..7171f013 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ what to do with them. that never got ready), and FXServer's whole process tree stops with it. A server stuck for 6 minutes is restarted; the delay can be raised, or hang detection turned off. - **Live console.** Search and filters, commands gated by permission, secrets masked, daily log files. +- **Resources.** Every resource of the running server with its state, live, and a start, a stop or a + restart of each, the resources that stop with it named before you confirm. - **Scheduled restarts.** Fixed times or an interval in the time zone you choose, with warnings to your resources, skip, delay and one-off restarts. - **FXServer builds.** The Cfx.re catalog, verified downloads, a switch at the next start or restart, @@ -512,6 +514,55 @@ The page edits a plain UTF-8 file of at most 512 KiB. It refuses a link, a read- another encoding, which a save would damage, and the files the cfg runs with `exec`: those are edited on the host or over SFTP, and the checks still read them. +## Resources + +The **Resources** page lists every resource FXServer knows while the server runs: the server's own +under their category folder (`[core]/[ox]`), then those of the build (`system_resources`). Each has +its state, its manifest's version and what last happened to it since the start: started, stopped or +restarted, how long ago, by whom when it was asked on this page, and with which resource when +FXServer stopped it along with another. A resource FXServer said it could not start is marked **Did +not start**. One it warned about as it started it carries the count of its warnings, and says what +they were: streamed files that are heavy or oversized, files its manifest names and does not hold. +The filters count each kind, the search narrows the list by name, a folder folds under the line that +counts what it has started, which the browser remembers, and the page follows the server as it goes: +nothing to reload. + +The list is FXServer's own. The bridge reports it once the cfg has run, and again after each rescan: +while the server starts, the page says how many resources are up so far, and a stopped server lists +nothing. + +With **Start and stop resources**, each row gets **Start**, or **Restart** and **Stop**, and the page +gets **Rescan**. Each is FXServer's own console command, `start`, `stop`, `restart` or `refresh`, +which Observer writes with the name FXServer listed. The line shows in the live console under the +account's name, and every order is in the audit log. Until FXServer has answered an order, its row +says who asked for it, to everyone who has the page open, and takes no other. + +- **What stops with a resource.** Before it stops a resource, FXServer stops every started resource + that depends on it, by the `dependency` and `dependencies` of their manifests, and it starts none + of them again. A restart is a stop and a start, so it does the same. The page names them before a + stop or a restart that takes others along, and a restart offers to start them again once the + resource is back, which Observer does with one `start` each. +- **Start** starts what the resource depends on first, as FXServer does. +- **Rescan** has FXServer read its resource folders again: a folder added since the start becomes a + resource, a manifest that changed is read again. Nothing starts or stops, and the page says what + FXServer found. +- **Until the next restart.** What is started or stopped here lasts as long as the run: the cfg + decides what starts with the server (see [Editing server.cfg](#editing-servercfg)). + +Whoever has **View the dashboard** or **Start and stop resources** reads the page. Its buttons take +the second, a permission of its own, and sensitive: a stopped resource is gone for every player. + +**Limits.** + +- **Names the console cannot take.** An order is a console line, so Observer only writes a name made + of letters, digits, `_`, `-` and `.`, 64 characters at most. A resource named otherwise is listed + with **Console only** in place of its buttons. +- **Observer's bridge is not listed,** and no order reaches it: it is how Observer hears the server. +- **A folder deleted while the server runs** stays listed until the server restarts, since FXServer + keeps the resource it read. A folder whose manifest is missing or broken is no resource to + FXServer, and is not listed. +- **The first 5,000 resources** are listed; the page says so when FXServer knows more. + ## File access over SFTP Observer can let people and CI pipelines read and change chosen parts of the server folder over SFTP, @@ -562,7 +613,7 @@ Whatever the groups say, no one reaches the cfg files (`*.cfg`, which hold the l database string), `cache/`, Observer's own folders and `observer.toml`, `.git`, `.env` files and key files. Every change is in the audit log; uploads are written to a temporary file and swapped in only when complete, so FXServer never reads half a file. Observer never restarts a resource after an -upload: restart it from the console, or let your own resource react. +upload: restart it on the [Resources](#resources) page, or let your own resource react. ## What changed since the last start diff --git a/web/src/api/resources.ts b/web/src/api/resources.ts new file mode 100644 index 00000000..3cea2f51 --- /dev/null +++ b/web/src/api/resources.ts @@ -0,0 +1,18 @@ +import { send } from './client'; +import type { ResourceAck } from './generated/ResourceAck'; +import type { ResourceOrder } from './generated/ResourceOrder'; + +const PATH = '/servers/default/resources'; + +/** What the Resources page asks of one resource, by FXServer's own command of that name. */ +export type ResourceVerb = 'start' | 'stop' | 'restart'; + +/** + * Has FXServer start, stop or restart a resource. The answer comes once its console has the line: + * the `server.resources` topic says what became of it. + */ +export const orderResource = (verb: ResourceVerb, order: ResourceOrder) => + send('POST', `${PATH}/${verb}`, order); + +/** Has FXServer read its resource folders again. */ +export const rescanResources = () => send('POST', `${PATH}/rescan`); diff --git a/web/src/components/Segmented.tsx b/web/src/components/Segmented.tsx index a15e0e70..3d2314c2 100644 --- a/web/src/components/Segmented.tsx +++ b/web/src/components/Segmented.tsx @@ -9,6 +9,7 @@ export function Segmented({ choices, value, words, + counts, onChange, className, }: { @@ -17,6 +18,8 @@ export function Segmented({ choices: readonly Choice[]; value: Choice; words: Record; + /** How many each choice shows, written after its word. */ + counts?: Record; onChange: (choice: Choice) => void; className?: string; }) { @@ -34,9 +37,12 @@ export function Segmented({ type="button" aria-pressed={value === choice} onClick={() => onChange(choice)} - className="inline-flex h-6.5 items-center rounded-[7px] px-2.5 text-small font-medium text-ink-3 transition-colors hover:text-ink focus-visible:outline-offset-1 aria-pressed:bg-raise aria-pressed:text-ink aria-pressed:shadow-[var(--elev-sm),0_0_0_1px_var(--line)] max-md:h-11 max-md:min-w-11 max-md:justify-center" + className="inline-flex h-6.5 items-center gap-1.5 rounded-[7px] px-2.5 text-small font-medium text-ink-3 transition-colors hover:text-ink focus-visible:outline-offset-1 aria-pressed:bg-raise aria-pressed:text-ink aria-pressed:shadow-[var(--elev-sm),0_0_0_1px_var(--line)] max-md:h-11 max-md:min-w-11 max-md:justify-center" > {words[choice]} + {counts !== undefined && ( + {counts[choice]} + )} ))} diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index d2a07fc0..ecb11c24 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -73,6 +73,7 @@ import type { PlayersFileProblem } from '../api/generated/PlayersFileProblem'; import type { Protection } from '../api/generated/Protection'; import type { Range } from '../api/generated/Range'; import type { ResourceChangeKind } from '../api/generated/ResourceChangeKind'; +import type { ResourceState } from '../api/generated/ResourceState'; import type { ScheduleMode } from '../api/generated/ScheduleMode'; import type { ScheduleProblem } from '../api/generated/ScheduleProblem'; import type { ServerFolder } from '../api/generated/ServerFolder'; @@ -99,9 +100,11 @@ import type { UpdateBlock } from '../api/generated/UpdateBlock'; import type { UpdateStep } from '../api/generated/UpdateStep'; import type { UpdateWhen } from '../api/generated/UpdateWhen'; import type { DestinationKind } from '../api/backups'; +import type { ResourceVerb } from '../api/resources'; import { parsePermissions, permissionKeys, type PermissionKey } from '../auth/permissions'; import type { InvisibleKind } from '../changes/invisible'; import type { LinkState } from '../live/realtime'; +import type { Filter as ResourceFilter } from '../resources/list'; import type { Applies as SettingApplies, CategoryGroup, CategoryId, SettingId } from '../settings/catalog'; import type { LaunchStep, SetupStep } from '../setup/state'; import { formatBytes, formatDuration, formatNumber, plural } from './format'; @@ -468,6 +471,7 @@ export const en = { overview: 'Overview', statistics: 'Statistics', console: 'Console', + resources: 'Resources', groupPlayers: 'Players', groupServer: 'Server', groupInsights: 'Insights', @@ -4207,6 +4211,148 @@ export const en = { loadFailed: 'The log files could not be listed.', }, }, + resources: { + title: 'Resources', + lead: 'Every resource FXServer knows, as it stands now. What is started or stopped here lasts until the next restart: the cfg decides what starts with the server.', + rescan: 'Rescan', + filtersLabel: 'Which resources show', + filters: { + all: 'All', + started: 'Started', + stopped: 'Stopped', + failed: 'Did not start', + warned: 'With warnings', + } satisfies Record, + find: 'Find a resource', + noMatch: 'No resource matches.', + none: 'FXServer knows no resource.', + states: { + started: 'Started', + starting: 'Starting', + stopped: 'Stopped', + stopping: 'Stopping', + unknown: 'State unknown', + } satisfies Record, + didNotStart: 'Did not start', + folder: { + started: (started: number, total: number) => + `${formatNumber('en', started)} of ${formatNumber('en', total)} started`, + build: 'FXServer’s own', + }, + verbs: { start: 'Start', stop: 'Stop', restart: 'Restart' } satisfies Record, + actionsOn: (name: string) => `Actions on ${name}`, + consoleOnly: 'Console only', + consoleOnlyTip: + 'Observer names a resource in a console command only by letters, digits, _, . and -. Start and stop this one from the console.', + ago: (duration: string | null) => (duration === null ? 'just now' : `${duration} ago`), + last: { + started: 'started', + stopped: 'stopped', + restarted: 'restarted', + } satisfies Record, + // `stopped with ox_lib, 2 min ago`: the resource it depends on, which FXServer stopped first. + happened: (what: string, by: string | null, along: string | null, ago: string) => { + const how = [along !== null && `with ${along}`, by !== null && `by ${by}`] + .filter((part) => part !== false) + .join(' '); + return how === '' ? `${what} ${ago}` : `${what} ${how}, ${ago}`; + }, + couldNotStart: (ago: string) => `FXServer could not start it, ${ago}`, + asked: { + start: (by: string) => `start asked by ${by}`, + stop: (by: string) => `stop asked by ${by}`, + restart: (by: string) => `restart asked by ${by}`, + } satisfies Record string>, + warnings: (value: number) => count(value, { one: 'warning', other: 'warnings' }), + warned: (times: number) => + times === 1 + ? 'FXServer warned once as it started it:' + : `FXServer warned ${formatNumber('en', times)} times as it started it:`, + heavy: (files: number, oversized: number) => { + const streamed = count(files, { one: 'streamed file is', other: 'streamed files are' }); + if (oversized >= files) { + return `${streamed} oversized`; + } + return oversized === 0 + ? `${streamed} heavy` + : `${streamed} heavy, ${formatNumber('en', oversized)} of them oversized`; + }, + missing: (files: number) => + `${count(files, { one: 'file its manifest names is', other: 'files its manifest names are' })} missing`, + otherWarnings: (value: number) => + `${count(value, { one: 'other warning', other: 'other warnings' })}, in the console`, + restartTitle: (name: ReactNode): ReactNode => ['Restart ', name, '?'], + stopTitle: (name: ReactNode): ReactNode => ['Stop ', name, '?'], + restartStops: (dependants: number, name: ReactNode): ReactNode => [ + 'When FXServer stops a resource, it stops every resource that depends on it too, and does not start them again. ', + count(dependants, { one: 'started resource depends', other: 'started resources depend' }), + ' on ', + name, + ':', + ], + stopStops: (dependants: number, name: ReactNode): ReactNode => [ + 'When FXServer stops a resource, it stops every resource that depends on it too. ', + count(dependants, { one: 'started resource depends', other: 'started resources depend' }), + ' on ', + name, + dependants === 1 ? ' and stops with it:' : ' and stop with it:', + ], + again: (dependants: number, name: string) => + dependants === 1 + ? `Start it again once ${name} is back` + : `Start them again once ${name} is back`, + againHint: (dependants: number) => + dependants === 1 + ? 'Without it the resource stays stopped until someone starts it, or the server restarts.' + : 'Without it they stay stopped until someone starts them, or the server restarts.', + stayStopped: (dependants: number) => + dependants === 1 + ? 'It stays stopped until someone starts it, or the server restarts.' + : 'They stay stopped until someone starts them, or the server restarts.', + confirmRestart: (name: string, resources: number) => + resources > 1 ? `Restart ${formatNumber('en', resources)} resources` : `Restart ${name}`, + confirmStop: (name: string, resources: number) => + resources > 1 ? `Stop ${formatNumber('en', resources)} resources` : `Stop ${name}`, + rescanned: { + nothing: 'FXServer read its folders again: nothing new.', + found: (what: ReactNode): ReactNode => ['FXServer read its folders again: ', what, '.'], + added: (value: number) => count(value, { one: 'new resource', other: 'new resources' }), + more: (value: number) => `${formatNumber('en', value)} more`, + changed: (value: number) => count(value, { one: 'changed manifest', other: 'changed manifests' }), + and: ', and ', + }, + ordering: { + start: (name: string) => `Starting ${name}`, + stop: (name: string) => `Stopping ${name}`, + restart: (name: string) => `Restarting ${name}`, + } satisfies Record string>, + rescanning: 'The rescan', + orderFailed: (what: string, why: string) => `${what} did not go through. ${why}`, + truncated: (listed: number) => + `FXServer knows more resources than the ${formatNumber('en', listed)} listed here.`, + unavailable: 'The resources show again once the live link is back.', + empty: { + stopped: { + title: 'The server is not running', + text: 'Its resources show here once it has started and run its cfg.', + }, + starting: { + title: 'The server is starting', + text: (up: number | null, total: number | null) => { + if (up === null) { + return 'Its resources show here once its cfg has run.'; + } + return total === null + ? `Its resources show here once its cfg has run: ${count(up, { one: 'is', other: 'are' })} up so far.` + : `Its resources show here once its cfg has run: ${formatNumber('en', up)} of ${formatNumber('en', total)} are up so far.`; + }, + }, + unreported: { + title: 'FXServer has not listed its resources', + text: 'The list comes from Observer’s bridge once the cfg has run. If it stays away, the bridge is not reporting: the Overview says what the server is doing.', + }, + }, + }, notFound: { title: 'This page does not exist.', back: 'Back to the panel', diff --git a/web/src/pages/ResourcesPage.test.tsx b/web/src/pages/ResourcesPage.test.tsx new file mode 100644 index 00000000..b67c79c3 --- /dev/null +++ b/web/src/pages/ResourcesPage.test.tsx @@ -0,0 +1,464 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, cleanup, fireEvent, render, within } from '@testing-library/react'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Me } from '../api/generated/Me'; +import type { ResourceRow } from '../api/generated/ResourceRow'; +import type { ResourcesView } from '../api/generated/ResourcesView'; +import type { ServerStatus } from '../api/generated/ServerStatus'; +import { MeContext } from '../auth/me'; +import { en } from '../i18n/en'; +import { I18nProvider } from '../i18n/I18nProvider'; +import { LiveContext } from '../live/context'; +import { Realtime, type SocketEvents } from '../live/realtime'; +import { ServerStatusProvider } from '../server/context'; +import { ResourcesPage } from './ResourcesPage'; + +const words = en.resources; + +const OWNER: Me = { + id: 1, + username: 'ada', + isOwner: true, + permissions: '7ffffff', + mustChangePassword: false, +}; +/** Sees the dashboard, and nothing else. */ +const READER: Me = { id: 2, username: 'grace', isOwner: false, permissions: '1', mustChangePassword: false }; + +/** Half a minute past the minute, so that the time the page counts from never reads a minute short. */ +const minutesAgo = (minutes: number) => new Date(Date.now() - minutes * 60_000 - 30_000).toISOString(); + +const row = (name: string, folder: string, changes: Partial = {}): ResourceRow => ({ + name, + folder, + origin: 'server', + version: null, + state: 'started', + failedAt: null, + warnings: null, + dependants: [], + controllable: true, + last: null, + asked: null, + ...changes, +}); + +const rows = (): ResourceRow[] => [ + row('bad name', '[jobs]', { controllable: false }), + row('chat', '', { origin: 'build', version: '1.0.0' }), + row('docks', '[maps]', { warnings: { count: 3, heavy: 2, oversized: 1, missing: 1 } }), + row('loadscreen', '', { version: '2.1.0' }), + row('mechanic', '[jobs]', { state: 'stopped', failedAt: minutesAgo(31) }), + row('ox_inventory', '[core]/[ox]', { version: '2.44.1' }), + row('ox_lib', '[core]', { version: '3.30.6', dependants: ['ox_inventory', 'ox_target'] }), + row('ox_target', '[core]/[ox]'), + row('taxi', '[jobs]', { + state: 'stopped', + last: { at: minutesAgo(31), kind: 'stopped', by: 'maxime', with: null }, + }), +]; + +const listed = (resources: ResourceRow[] = rows(), changes: Partial = {}): ResourcesView => ({ + stage: 'listed', + generation: 3, + resources, + truncated: false, + rescan: null, + rescanning: false, + ...changes, +}); + +const STARTING: ServerStatus = { + state: 'starting', + cause: null, + since: '2026-11-11T14:00:00Z', + startedAt: null, + generation: 3, + problems: [], + build: null, + players: null, + restartAt: null, + launch: 'ready', + name: null, + port: null, + start: { + started: 212, + failed: 0, + found: 400, + planned: 394, + last: null, + at: null, + slowest: [], + stuckAt: null, + extensionMinutes: 15, + }, +}; + +const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 0))); + +/** The orders the API was sent, each answered when the test says. */ +const orders: { path: string; body: unknown; answer: (response: Response) => void }[] = []; + +/** The page for `me`, with a socket the test speaks for. */ +const page = async (me: Me = OWNER) => { + let socket: SocketEvents | undefined; + const live = new Realtime({ + ticket: async () => 'ticket', + connect: (_, events) => { + socket = events; + return { send: () => {}, close: () => {} }; + }, + refetch: () => {}, + }); + const view = render( + + + + + + + + + + + , + ); + live.start(); + await settle(); + const tell = (message: unknown) => act(() => socket?.message(JSON.stringify(message))); + tell({ type: 'hello', connectionId: 1, contract: 1 }); + const line = (name: string) => { + const item = view.getByText(name, { selector: 'li span' }).closest('li'); + if (item === null) { + throw new Error(`no row for ${name}`); + } + return within(item); + }; + return { + ...view, + live, + line, + say: (type: 'snapshot' | 'event', data: ResourcesView) => + tell({ type, topic: 'server.resources', data }), + status: (data: ServerStatus) => tell({ type: 'snapshot', topic: 'server.status', data }), + press: async (button: HTMLElement) => { + fireEvent.click(button); + await settle(); + }, + answer: async (index: number, body: unknown, status = 200) => { + orders[index]?.answer(Response.json(body, { status })); + await settle(); + }, + }; +}; + +const folders = (container: HTMLElement) => + [...container.querySelectorAll('section h2 button')].map((button) => button.textContent); + +describe('ResourcesPage', () => { + beforeAll(() => { + // jsdom has the element but not its modal methods. + HTMLDialogElement.prototype.showModal ??= function (this: HTMLDialogElement) { + this.open = true; + }; + HTMLDialogElement.prototype.close ??= function (this: HTMLDialogElement) { + this.open = false; + }; + }); + + beforeEach(() => { + vi.stubGlobal( + 'fetch', + (url: string, init?: RequestInit) => + new Promise((answer) => { + const body: unknown = + init?.body === undefined ? undefined : JSON.parse(String(init.body)); + orders.push({ path: url, body, answer }); + }), + ); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + orders.length = 0; + localStorage.clear(); + }); + + it('should list the resources by folder, the server root first and the build last, with what each filter counts', async () => { + const { container, say, line, getByRole, live } = await page(); + say('snapshot', listed()); + + expect(folders(container)).toEqual([ + `resources${words.folder.started(1, 1)}`, + `[core]${words.folder.started(1, 1)}`, + `[core]/[ox]${words.folder.started(2, 2)}`, + `[jobs]${words.folder.started(1, 3)}`, + `[maps]${words.folder.started(1, 1)}`, + `system_resources${words.folder.build}${words.folder.started(1, 1)}`, + ]); + const filters = within(getByRole('group', { name: words.filtersLabel })); + expect(filters.getAllByRole('button').map((button) => button.textContent)).toEqual([ + `${words.filters.all}9`, + `${words.filters.started}7`, + `${words.filters.stopped}1`, + `${words.filters.failed}1`, + `${words.filters.warned}1`, + ]); + expect(line('ox_lib').getByText('3.30.6')).not.toBeNull(); + expect(line('ox_lib').getByText(words.states.started)).not.toBeNull(); + expect(line('mechanic').getByText(words.didNotStart)).not.toBeNull(); + expect(line('mechanic').getByText('FXServer could not start it, 31 min ago')).not.toBeNull(); + expect(line('taxi').getByText('stopped by maxime, 31 min ago')).not.toBeNull(); + live.stop(); + }); + + it('should narrow the list by filter and by name, and say when nothing is left', async () => { + const { container, say, getByRole, getByText, queryByText, live } = await page(); + say('snapshot', listed()); + + fireEvent.click(getByRole('button', { name: new RegExp(`^${words.filters.failed}`) })); + expect(folders(container)).toEqual([`[jobs]${words.folder.started(1, 3)}`]); + expect(queryByText('taxi')).toBeNull(); + + fireEvent.click(getByRole('button', { name: new RegExp(`^${words.filters.all}`) })); + fireEvent.change(getByRole('searchbox', { name: words.find }), { target: { value: ' OX_ ' } }); + expect(folders(container).map((folder) => folder?.split(/\d/)[0])).toEqual(['[core]', '[core]/[ox]']); + + fireEvent.change(getByRole('searchbox', { name: words.find }), { target: { value: 'nothing' } }); + expect(getByText(words.noMatch)).not.toBeNull(); + live.stop(); + }); + + it('should fold a folder on a click, and find it folded when the page opens again', async () => { + const first = await page(); + first.say('snapshot', listed()); + + fireEvent.click(first.getByRole('button', { name: /^\[jobs\]/ })); + expect(first.queryByText('taxi')).toBeNull(); + expect(first.getByRole('button', { name: /^\[jobs\]/ }).getAttribute('aria-expanded')).toBe('false'); + expect(first.getByText('ox_lib')).not.toBeNull(); + first.live.stop(); + cleanup(); + + const again = await page(); + again.say('snapshot', listed()); + expect(again.queryByText('taxi')).toBeNull(); + fireEvent.click(again.getByRole('button', { name: /^\[jobs\]/ })); + expect(again.getByText('taxi')).not.toBeNull(); + again.live.stop(); + }); + + it('should start a resource at once, say who asked until the list has it, and take no other order meanwhile', async () => { + const { say, line, press, answer, live } = await page(); + say('snapshot', listed()); + + await press(line('taxi').getByRole('button', { name: words.verbs.start })); + + expect(orders.map((order) => [order.path, order.body])).toEqual([ + ['/api/servers/default/resources/start', { name: 'taxi', dependants: false }], + ]); + expect(line('taxi').getByText(words.asked.start('ada'))).not.toBeNull(); + const start = line('taxi').getByRole('button', { name: words.verbs.start }); + expect([start.hasAttribute('disabled'), start.getAttribute('aria-busy')]).toEqual([true, 'true']); + + await answer(0, { delivery: 'written', dependants: [] }); + const started = rows().map((each) => + each.name === 'taxi' + ? { + ...each, + state: 'started' as const, + last: { ...each.last!, kind: 'started' as const, by: 'ada' }, + } + : each, + ); + say('event', listed(started)); + expect(line('taxi').queryByRole('button', { name: words.verbs.start })).toBeNull(); + expect(line('taxi').getByRole('button', { name: words.verbs.stop }).hasAttribute('disabled')).toBe( + false, + ); + live.stop(); + }); + + it('should name what stops with a resource before it stops it', async () => { + const { say, line, press, getByRole, queryByRole, live } = await page(); + say('snapshot', listed()); + + await press(line('ox_lib').getByRole('button', { name: words.verbs.stop })); + expect(orders).toHaveLength(0); + const dialog = within(getByRole('alertdialog')); + expect(dialog.getAllByRole('listitem').map((item) => item.textContent)).toEqual([ + 'ox_inventory', + 'ox_target', + ]); + expect(dialog.getByText(words.stayStopped(2))).not.toBeNull(); + + await press(dialog.getByRole('button', { name: words.confirmStop('ox_lib', 3) })); + + expect(queryByRole('alertdialog')).toBeNull(); + expect(orders.map((order) => [order.path, order.body])).toEqual([ + ['/api/servers/default/resources/stop', { name: 'ox_lib', dependants: false }], + ]); + expect(line('ox_lib').getByText(words.asked.stop('ada'))).not.toBeNull(); + live.stop(); + }); + + it('should offer to start again what a restart stops, and restart the resource alone when told to', async () => { + const { say, line, press, getByRole, live } = await page(); + say('snapshot', listed()); + + await press(line('ox_lib').getByRole('button', { name: words.verbs.restart })); + const dialog = within(getByRole('alertdialog')); + const again = dialog.getByRole('checkbox', { name: words.again(2, 'ox_lib') }); + expect(again).toHaveProperty('checked', true); + expect(dialog.getByRole('button', { name: words.confirmRestart('ox_lib', 3) })).not.toBeNull(); + await press(dialog.getByRole('button', { name: en.common.cancel })); + expect(orders).toHaveLength(0); + + await press(line('ox_lib').getByRole('button', { name: words.verbs.restart })); + await press( + within(getByRole('alertdialog')).getByRole('button', { name: words.confirmRestart('ox_lib', 3) }), + ); + expect(orders[0]?.body).toEqual({ name: 'ox_lib', dependants: true }); + live.stop(); + }); + + it('should restart a resource without the others when the box is cleared', async () => { + const { say, line, press, getByRole, live } = await page(); + say('snapshot', listed()); + + await press(line('ox_lib').getByRole('button', { name: words.verbs.restart })); + const dialog = within(getByRole('alertdialog')); + fireEvent.click(dialog.getByRole('checkbox', { name: words.again(2, 'ox_lib') })); + await press(dialog.getByRole('button', { name: words.confirmRestart('ox_lib', 1) })); + + expect(orders.map((order) => [order.path, order.body])).toEqual([ + ['/api/servers/default/resources/restart', { name: 'ox_lib', dependants: false }], + ]); + live.stop(); + }); + + it('should show an order anyone made until FXServer answers it, its buttons waiting', async () => { + const { say, line, live } = await page(); + const asked = rows().map((each) => + each.name === 'ox_target' + ? { ...each, asked: { kind: 'restarted' as const, by: 'maxime' } } + : each, + ); + say('snapshot', listed(asked)); + + expect(line('ox_target').getByText(words.asked.restart('maxime'))).not.toBeNull(); + const restart = line('ox_target').getByRole('button', { name: words.verbs.restart }); + const stop = line('ox_target').getByRole('button', { name: words.verbs.stop }); + expect([restart.getAttribute('aria-busy'), stop.hasAttribute('disabled')]).toEqual(['true', true]); + expect( + line('ox_inventory').getByRole('button', { name: words.verbs.stop }).hasAttribute('disabled'), + ).toBe(false); + live.stop(); + }); + + it('should leave who only reads the list without a button, and one it cannot name to the console', async () => { + const reader = await page(READER); + reader.say('snapshot', listed()); + expect(reader.queryByRole('button', { name: words.rescan })).toBeNull(); + expect(reader.queryByRole('button', { name: words.verbs.start })).toBeNull(); + expect(reader.queryByRole('button', { name: words.verbs.restart })).toBeNull(); + expect(reader.line('taxi').getByText(words.states.stopped)).not.toBeNull(); + reader.live.stop(); + cleanup(); + + const owner = await page(); + owner.say('snapshot', listed()); + expect(owner.line('bad name').queryByRole('button', { name: words.verbs.stop })).toBeNull(); + expect(owner.line('bad name').getByRole('button', { name: words.consoleOnly })).not.toBeNull(); + owner.live.stop(); + }); + + it('should tell what FXServer warned of as it started a resource', async () => { + const { say, line, getByRole, live } = await page(); + say('snapshot', listed()); + + fireEvent.click(line('docks').getByRole('button', { name: words.warnings(3) })); + + expect(getByRole('tooltip').textContent).toBe( + `${words.warned(3)}${words.heavy(2, 1)}${words.missing(1)}`, + ); + live.stop(); + }); + + it('should have FXServer read its folders again, and say what it found', async () => { + const { say, press, answer, getByRole, getByText, live } = await page(); + say('snapshot', listed()); + + await press(getByRole('button', { name: words.rescan })); + expect(orders.map((order) => [order.path, order.body])).toEqual([ + ['/api/servers/default/resources/rescan', undefined], + ]); + expect(getByRole('button', { name: words.rescan }).getAttribute('aria-busy')).toBe('true'); + + await answer(0, { delivery: 'written', dependants: [] }); + const found = [...rows(), row('bank', '[jobs]', { state: 'stopped' })]; + say( + 'event', + listed(found, { + rescan: { at: minutesAgo(0), by: 'ada', added: ['bank'], addedCount: 1, changed: 2 }, + }), + ); + + expect( + getByText( + (_, element) => + element?.textContent === + `FXServer read its folders again: 1 new resource, bank, and 2 changed manifests.`, + { selector: '[role="status"] div' }, + ), + ).not.toBeNull(); + expect(getByRole('button', { name: words.rescan }).getAttribute('aria-busy')).toBeNull(); + live.stop(); + }); + + it('should say why there is no list: no server, a server that starts, a bridge that lists nothing', async () => { + const { say, status, getByRole, getByText, queryByRole, live } = await page(); + + say('snapshot', listed([], { stage: 'stopped', generation: 0 })); + expect(getByRole('heading', { name: words.empty.stopped.title })).not.toBeNull(); + expect(queryByRole('button', { name: words.rescan })).toBeNull(); + + status(STARTING); + say('event', listed([], { stage: 'starting' })); + expect(getByRole('heading', { name: words.empty.starting.title })).not.toBeNull(); + expect(getByText(words.empty.starting.text(212, 394))).not.toBeNull(); + + say('event', listed([], { stage: 'unreported' })); + expect(getByText(words.empty.unreported.text)).not.toBeNull(); + + say('event', listed([])); + expect(getByText(words.none)).not.toBeNull(); + live.stop(); + }); + + it('should say what an order ran into, and let go of it', async () => { + const { say, line, press, answer, getByRole, queryByRole, live } = await page(); + say('snapshot', listed()); + + await press(line('taxi').getByRole('button', { name: words.verbs.start })); + await answer(0, { code: 'resource_changed' }, 409); + + const alert = getByRole('alert'); + expect(alert.textContent).toBe( + words.orderFailed(words.ordering.start('taxi'), en.errors.resource_changed), + ); + expect(line('taxi').getByRole('button', { name: words.verbs.start }).hasAttribute('disabled')).toBe( + false, + ); + + fireEvent.click(within(alert).getByRole('button', { name: en.controls.dismiss })); + expect(queryByRole('alert')).toBeNull(); + + await press(line('taxi').getByRole('button', { name: words.verbs.start })); + await answer(1, { delivery: 'busy', dependants: [] }); + expect(getByRole('alert').textContent).toBe(en.console.busy); + live.stop(); + }); +}); diff --git a/web/src/pages/ResourcesPage.tsx b/web/src/pages/ResourcesPage.tsx new file mode 100644 index 00000000..f294fe1e --- /dev/null +++ b/web/src/pages/ResourcesPage.tsx @@ -0,0 +1,297 @@ +import { ScanSearch, X } from 'lucide-react'; +import { useState, type ReactNode } from 'react'; + +import { Permission } from '../api/generated/Permission'; +import type { ResourceRow } from '../api/generated/ResourceRow'; +import type { ResourcesRescan } from '../api/generated/ResourcesRescan'; +import type { ResourceVerb } from '../api/resources'; +import { allows, useMe } from '../auth/me'; +import { Button } from '../components/Button'; +import { Notice } from '../components/Notice'; +import { Segmented } from '../components/Segmented'; +import { Find } from '../debug/parts'; +import { errorMessage } from '../i18n/errors'; +import { useI18n } from '../i18n/I18nProvider'; +import { cn } from '../lib/cn'; +import { useNow } from '../lib/useNow'; +import { useLinkState } from '../live/context'; +import { startTotal } from '../overview/StartCard'; +import { RestartDialog, StopDialog } from '../resources/Dialogs'; +import { Folder } from '../resources/Folder'; +import { countByFilter, FILTERS, groupByFolder, type Filter } from '../resources/list'; +import { useResources, type Outcome } from '../resources/live'; +import { RESCAN, underWay } from '../resources/sent'; +import type { Order } from '../resources/words'; +import { useServerStatus } from '../server/context'; + +const FOLDED_KEY = 'observer.resources.folded'; +/** What happened to a resource reads to the minute: the page counts again this often. */ +const NOW_EVERY = 15_000; +/** What a rescan found stays on the page this long. */ +const RESCAN_SHOWN = 5 * 60_000; + +const readFolded = (): ReadonlySet => { + try { + const saved: unknown = JSON.parse(localStorage.getItem(FOLDED_KEY) ?? '[]'); + return new Set( + Array.isArray(saved) ? saved.filter((key): key is string => typeof key === 'string') : [], + ); + } catch { + return new Set(); + } +}; + +const keepFolded = (folded: ReadonlySet) => { + try { + localStorage.setItem(FOLDED_KEY, JSON.stringify([...folded])); + } catch { + // Private browsing: the folds last for this page only. + } +}; + +/** What stands in the list's place while the server has none to give. */ +function Empty({ led, title, text }: { led: string; title: string; text: string }) { + return ( +
+
+

+

+

{text}

+
+
+ ); +} + +/** What FXServer found as it read its folders again: the new resources by name, the manifests that changed. */ +function Rescanned({ rescan }: { rescan: ResourcesRescan }) { + const { t } = useI18n(); + const words = t.resources.rescanned; + const changed = rescan.changed ?? 0; + if (rescan.addedCount === 0 && changed === 0) { + return {words.nothing}; + } + const what: ReactNode[] = []; + if (rescan.addedCount > 0) { + what.push(words.added(rescan.addedCount)); + for (const name of rescan.added) { + what.push( + ', ', + + {name} + , + ); + } + if (rescan.addedCount > rescan.added.length) { + what.push(', ', words.more(rescan.addedCount - rescan.added.length)); + } + } + if (changed > 0) { + what.push(what.length > 0 ? words.and : '', words.changed(changed)); + } + return {words.found(what)}; +} + +/** An order that did not simply go through, until it is dismissed or another is made. */ +function OrderNotice({ outcome, onDismiss }: { outcome: Outcome; onDismiss: () => void }) { + const { t } = useI18n(); + const words = t.resources; + const text = + outcome.kind === 'pending' + ? t.console.pending + : outcome.kind === 'busy' + ? t.console.busy + : words.orderFailed( + outcome.verb === 'rescan' ? words.rescanning : words.ordering[outcome.verb](outcome.name), + errorMessage(t, outcome.error), + ); + return ( + +
+

{text}

+ +
+
+ ); +} + +/** + * Every resource FXServer knows in the run that is up, by folder, as `server.resources` says them, + * with a start, a stop or a restart of each for who may. A stop or a restart that takes other + * resources down asks first, and names them. + */ +export function ResourcesPage() { + const { t, format } = useI18n(); + const words = t.resources; + const me = useMe(); + const canAct = allows(me, Permission.ResourcesControl); + const { view, sent, outcome, dismiss, order, rescan } = useResources(); + const link = useLinkState(); + const { status } = useServerStatus(); + const now = useNow(NOW_EVERY); + const [filter, setFilter] = useState('all'); + const [find, setFind] = useState(''); + const [folded, setFolded] = useState(readFolded); + const [confirming, setConfirming] = useState<{ verb: 'restart' | 'stop'; name: string } | null>(null); + + const listed = view !== null && view.stage === 'listed' ? view : null; + const rows = listed?.resources ?? []; + const target = confirming === null ? undefined : rows.find((row) => row.name === confirming.name); + // The resource went down or away, or took another order, while the question stood. + if ( + confirming !== null && + (target === undefined || target.state !== 'started' || underWay(target, sent) !== null) + ) { + setConfirming(null); + } + + const orderOf = (row: ResourceRow): Order | null => { + const verb = underWay(row, sent); + if (verb === null) { + return null; + } + return { verb, by: sent.has(row.name) ? me.username : (row.asked?.by ?? me.username) }; + }; + const ask = (verb: ResourceVerb, row: ResourceRow) => { + if (verb !== 'start' && row.dependants.length > 0) { + setConfirming({ verb, name: row.name }); + } else { + order(verb, row.name); + } + }; + const fold = (key: string, on: boolean) => { + const next = new Set(folded); + if (on) { + next.add(key); + } else { + next.delete(key); + } + setFolded(next); + keepFolded(next); + }; + + const counts = countByFilter(rows); + const groups = groupByFolder(rows, filter, find); + const start = status?.start ?? null; + const rescanned = + listed !== null && listed.rescan !== null && now - Date.parse(listed.rescan.at) < RESCAN_SHOWN + ? listed.rescan + : null; + + return ( +
+
+

{words.title}

+ {canAct && listed !== null && ( + + )} +
+

{words.lead}

+ {outcome !== null && } + {view === null && + (link === 'offline' || link === 'refused' ? ( + {words.unavailable} + ) : ( +
+ {t.common.loading} +
+ ))} + {view?.stage === 'stopped' && ( + + )} + {view?.stage === 'starting' && ( + + )} + {view?.stage === 'unreported' && ( + + )} + {listed !== null && ( + <> + {listed.truncated && {words.truncated(rows.length)}} + {rescanned !== null && } +
+ + +
+ {groups.length === 0 ? ( +

+ {rows.length === 0 ? words.none : words.noMatch} +

+ ) : ( +
+ {groups.map((group) => ( + fold(group.key, on)} + orderOf={orderOf} + onAsk={canAct ? ask : null} + /> + ))} +
+ )} + + )} + {confirming?.verb === 'restart' && target !== undefined && ( + setConfirming(null)} + onConfirm={(again) => { + setConfirming(null); + order('restart', target.name, again); + }} + /> + )} + {confirming?.verb === 'stop' && target !== undefined && ( + setConfirming(null)} + onConfirm={() => { + setConfirming(null); + order('stop', target.name); + }} + /> + )} +
+ ); +} diff --git a/web/src/resources/Dialogs.tsx b/web/src/resources/Dialogs.tsx new file mode 100644 index 00000000..45aac7cd --- /dev/null +++ b/web/src/resources/Dialogs.tsx @@ -0,0 +1,112 @@ +import { RotateCw, Square } from 'lucide-react'; +import { useState } from 'react'; + +import type { ResourceRow } from '../api/generated/ResourceRow'; +import { Button } from '../components/Button'; +import { CheckRow } from '../components/CheckRow'; +import { Chip } from '../components/Chip'; +import { Dialog } from '../components/Dialog'; +import { useI18n } from '../i18n/I18nProvider'; + +const mono = (name: string) => ( + + {name} + +); + +/** The started resources that depend on the one asked about, as FXServer names them. */ +function Dependants({ names }: { names: readonly string[] }) { + return ( +
    + {names.map((name) => ( +
  • + {name} +
  • + ))} +
+ ); +} + +/** + * Asked before a restart that takes other resources down: FXServer stops what depends on the + * resource and leaves it stopped, unless the order starts each of them again. + */ +export function RestartDialog({ + row, + onClose, + onConfirm, +}: { + row: ResourceRow; + onClose: () => void; + /** `again`: start the resources that depend on it once it is back. */ + onConfirm: (again: boolean) => void; +}) { + const { t } = useI18n(); + const words = t.resources; + const [again, setAgain] = useState(true); + const dependants = row.dependants.length; + return ( + + + + + } + > +

{words.restartStops(dependants, mono(row.name))}

+ +
+ +
+
+ ); +} + +/** Asked before a stop that takes other resources down with it. */ +export function StopDialog({ + row, + onClose, + onConfirm, +}: { + row: ResourceRow; + onClose: () => void; + onConfirm: () => void; +}) { + const { t } = useI18n(); + const words = t.resources; + const dependants = row.dependants.length; + return ( + + + + + } + > +

{words.stopStops(dependants, mono(row.name))}

+ +

{words.stayStopped(dependants)}

+
+ ); +} diff --git a/web/src/resources/Folder.tsx b/web/src/resources/Folder.tsx new file mode 100644 index 00000000..f164152f --- /dev/null +++ b/web/src/resources/Folder.tsx @@ -0,0 +1,227 @@ +import { ChevronDown, Ellipsis, LoaderCircle, Play, RotateCw, Square, TriangleAlert } from 'lucide-react'; + +import type { ResourceRow } from '../api/generated/ResourceRow'; +import type { ResourceState } from '../api/generated/ResourceState'; +import type { ResourceVerb } from '../api/resources'; +import { Button, buttonVariants } from '../components/Button'; +import { chipVariants } from '../components/Chip'; +import { Menu } from '../components/Menu'; +import { Toggletip } from '../components/Toggletip'; +import { useI18n } from '../i18n/I18nProvider'; +import { cn } from '../lib/cn'; +import { didNotStart, type Group } from './list'; +import { changeLine, warningLines, type Order } from './words'; + +const LEDS = { + started: 'led-ok', + starting: 'led-pending', + stopped: 'led-off', + stopping: 'led-pending', + unknown: 'led-off', +} satisfies Record; + +const ICONS = { start: Play, stop: Square, restart: RotateCw } satisfies Record; + +type Ask = (verb: ResourceVerb, row: ResourceRow) => void; + +type RowProps = { + row: ResourceRow; + now: number; + /** The order FXServer has not answered on it: no other is taken meanwhile. */ + order: Order | null; + /** Null for an account that reads the list without starting or stopping anything. */ + onAsk: Ask | null; +}; + +/** + * The orders a resource takes as it stands: a start while it is down, a restart or a stop while it + * is up. A restart under way keeps its place on a resource FXServer has stopped and not yet started. + */ +const verbsOf = (row: ResourceRow): ResourceVerb[] => + row.state === 'started' ? ['restart', 'stop'] : ['start']; + +/** Which of a row's buttons waits: the one asked for, or the only one left once the state moved. */ +const waits = (verb: ResourceVerb, verbs: ResourceVerb[], order: Order | null) => + order !== null && (order.verb === verb || !verbs.includes(order.verb)); + +function Warnings({ row }: { row: ResourceRow }) { + const { t } = useI18n(); + const words = t.resources; + if (row.warnings === null) { + return null; + } + return ( + + {words.warned(row.warnings.count)} + {warningLines(t, row.warnings).map((line) => ( + {line} + ))} + + } + > + + ); +} + +function Row({ row, now, order, onAsk }: RowProps) { + const { t, format } = useI18n(); + const words = t.resources; + const failed = didNotStart(row); + const change = changeLine(t, format, row, now, order); + const verbs = verbsOf(row); + return ( +
  • +
    + + {row.name} + + +
    + {row.version ?? ''} +
    + + + {change !== null && ( + + {change} + + )} + {row.version !== null && ( + v{row.version} + )} +
    + {onAsk !== null && !row.controllable && ( +
    + + {words.consoleOnly} + +
    + )} + {onAsk !== null && row.controllable && ( + <> +
    + {verbs.map((verb) => ( + + ))} +
    +
    + {order === null ? ( + ({ + label: words.verbs[verb], + icon: ICONS[verb], + onSelect: () => onAsk(verb, row), + }))} + > + + ) : ( + + + )} +
    + + )} +
  • + ); +} + +type FolderProps = { + group: Group; + now: number; + folded: boolean; + onFold: (folded: boolean) => void; + orderOf: (row: ResourceRow) => Order | null; + onAsk: Ask | null; +}; + +/** + * The resources of one folder, under a line that folds them and counts those started among all it + * holds, whatever the filters show. + */ +export function Folder({ group, now, folded, onFold, orderOf, onAsk }: FolderProps) { + const { t } = useI18n(); + const words = t.resources; + return ( +
    +

    + +

    + {!folded && ( +
      + {group.rows.map((row) => ( + + ))} +
    + )} +
    + ); +} diff --git a/web/src/resources/list.test.ts b/web/src/resources/list.test.ts new file mode 100644 index 00000000..38bcf388 --- /dev/null +++ b/web/src/resources/list.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; + +import type { ResourceRow } from '../api/generated/ResourceRow'; +import { countByFilter, didNotStart, groupByFolder, passes } from './list'; + +const row = (name: string, folder: string, changes: Partial = {}): ResourceRow => ({ + name, + folder, + origin: 'server', + version: null, + state: 'started', + failedAt: null, + warnings: null, + dependants: [], + controllable: true, + last: null, + asked: null, + ...changes, +}); + +const WARNED = { count: 2, heavy: 1, oversized: 0, missing: 1 }; + +const ROWS = [ + row('chat', '', { origin: 'build' }), + row('docks', '[maps]', { warnings: WARNED }), + row('loadscreen', ''), + row('mechanic', '[jobs]', { state: 'stopped', failedAt: '2026-11-11T14:02:00Z' }), + row('monitor', '', { origin: 'build', state: 'stopped' }), + row('ox_lib', '[core]'), + row('ox_target', '[core]/[ox]', { state: 'stopped' }), + row('Police', '[jobs]', { failedAt: '2026-11-11T13:00:00Z' }), + row('taxi', '[jobs]', { state: 'stopped', warnings: WARNED }), + row('webpack', '[build]', { origin: 'build', state: 'starting' }), +]; + +const names = (rows: readonly ResourceRow[]) => rows.map((each) => each.name); + +describe('the filters of the resources', () => { + it('take a resource FXServer could not start for one that did not start, until it starts', () => { + expect(names(ROWS.filter(didNotStart))).toEqual(['mechanic']); + expect(names(ROWS.filter((each) => passes(each, 'stopped')))).toEqual([ + 'monitor', + 'ox_target', + 'taxi', + ]); + }); + + it('count what each of them shows', () => { + expect(countByFilter(ROWS)).toEqual({ all: 10, started: 5, stopped: 3, failed: 1, warned: 2 }); + expect(countByFilter([])).toEqual({ all: 0, started: 0, stopped: 0, failed: 0, warned: 0 }); + }); +}); + +describe('the resources by folder', () => { + it("come root first, then the server's categories by name, then the build's own", () => { + const groups = groupByFolder(ROWS, 'all', ''); + + expect(groups.map((group) => [group.folder, names(group.rows)])).toEqual([ + ['resources', ['loadscreen']], + ['[core]', ['ox_lib']], + ['[core]/[ox]', ['ox_target']], + ['[jobs]', ['mechanic', 'Police', 'taxi']], + ['[maps]', ['docks']], + ['system_resources', ['chat', 'monitor']], + ['system_resources/[build]', ['webpack']], + ]); + expect(new Set(groups.map((group) => group.key)).size).toBe(groups.length); + }); + + it('count every resource of a folder, whatever the filter and the search show of it', () => { + const groups = groupByFolder(ROWS, 'stopped', ''); + + expect(groups.map((group) => group.folder)).toEqual(['[core]/[ox]', '[jobs]', 'system_resources']); + const jobs = groups[1]; + expect(jobs).toMatchObject({ started: 1, total: 3 }); + expect(names(jobs?.rows ?? [])).toEqual(['taxi']); + }); + + it('are searched by name, without regard to case, and a folder with no match is left out', () => { + const found = groupByFolder(ROWS, 'all', ' POL '); + + expect(found.map((group) => [group.folder, names(group.rows)])).toEqual([['[jobs]', ['Police']]]); + expect(groupByFolder(ROWS, 'failed', 'pol')).toEqual([]); + expect(groupByFolder(ROWS, 'warned', '').flatMap((group) => names(group.rows))).toEqual([ + 'taxi', + 'docks', + ]); + }); +}); diff --git a/web/src/resources/list.ts b/web/src/resources/list.ts new file mode 100644 index 00000000..be478f30 --- /dev/null +++ b/web/src/resources/list.ts @@ -0,0 +1,95 @@ +import type { ResourceOrigin } from '../api/generated/ResourceOrigin'; +import type { ResourceRow } from '../api/generated/ResourceRow'; + +export const FILTERS = ['all', 'started', 'stopped', 'failed', 'warned'] as const; +export type Filter = (typeof FILTERS)[number]; + +/** FXServer said it could not start the resource, and has not started it since. */ +export const didNotStart = (row: ResourceRow) => row.failedAt !== null && row.state !== 'started'; + +/** + * Whether a resource shows under a filter. One FXServer could not start is not counted with the + * stopped ones: someone stopped those, or nothing ever started them. + */ +export const passes = (row: ResourceRow, filter: Filter) => { + switch (filter) { + case 'all': + return true; + case 'started': + return row.state === 'started'; + case 'stopped': + return row.state === 'stopped' && !didNotStart(row); + case 'failed': + return didNotStart(row); + case 'warned': + return row.warnings !== null; + } +}; + +/** How many resources each filter shows. */ +export const countByFilter = (rows: readonly ResourceRow[]): Record => { + const counts: Record = { all: 0, started: 0, stopped: 0, failed: 0, warned: 0 }; + for (const row of rows) { + for (const filter of FILTERS) { + if (passes(row, filter)) { + counts[filter] += 1; + } + } + } + return counts; +}; + +/** The resources of one folder. */ +export type Group = { + /** What tells the group from the others, for a list's key and a remembered fold. */ + key: string; + /** The folder as it reads on disk: `resources`, `[core]/[ox]`, `system_resources`. */ + folder: string; + origin: ResourceOrigin; + /** Those the filter and the search keep, in name order. */ + rows: ResourceRow[]; + /** Of every resource of the folder, whatever is shown. */ + started: number; + total: number; +}; + +/** The folder that holds the build's own resources, and the server's at their root. */ +const ROOTS: Record = { server: 'resources', build: 'system_resources' }; + +const folderOf = (row: ResourceRow) => + row.origin === 'build' + ? row.folder === '' + ? ROOTS.build + : `${ROOTS.build}/${row.folder}` + : row.folder === '' + ? ROOTS.server + : row.folder; + +/** + * The resources by folder, as the filter and the search leave them: the root of the server's + * `resources` first, its categories by name, then the build's own. A folder left with nothing to + * show is left out. + */ +export const groupByFolder = (rows: readonly ResourceRow[], filter: Filter, find: string): Group[] => { + const needle = find.trim().toLowerCase(); + const groups = new Map(); + for (const row of rows) { + const key = `${row.origin}:${row.folder}`; + let group = groups.get(key); + if (group === undefined) { + group = { key, folder: folderOf(row), origin: row.origin, rows: [], started: 0, total: 0 }; + groups.set(key, group); + } + group.total += 1; + group.started += row.state === 'started' ? 1 : 0; + if (passes(row, filter) && row.name.toLowerCase().includes(needle)) { + group.rows.push(row); + } + } + const rank = (group: Group) => (group.origin === 'build' ? 2 : group.folder === ROOTS.server ? 0 : 1); + return [...groups.values()] + .filter((group) => group.rows.length > 0) + .toSorted( + (a, b) => rank(a) - rank(b) || a.folder.localeCompare(b.folder, 'en', { sensitivity: 'base' }), + ); +}; diff --git a/web/src/resources/live.test.tsx b/web/src/resources/live.test.tsx new file mode 100644 index 00000000..32a11844 --- /dev/null +++ b/web/src/resources/live.test.tsx @@ -0,0 +1,244 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, cleanup, render } from '@testing-library/react'; +import { useEffect } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ApiError } from '../api/client'; +import type { ResourceRow } from '../api/generated/ResourceRow'; +import type { ResourcesView } from '../api/generated/ResourcesView'; +import { LiveContext } from '../live/context'; +import { Realtime, type SocketEvents } from '../live/realtime'; +import { useResources } from './live'; +import { RESCAN, underWay } from './sent'; + +type Followed = ReturnType; + +const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 0))); + +const row = (name: string, changes: Partial = {}): ResourceRow => ({ + name, + folder: '[core]', + origin: 'server', + version: null, + state: 'started', + failedAt: null, + warnings: null, + dependants: [], + controllable: true, + last: null, + asked: null, + ...changes, +}); + +const listed = (resources: ResourceRow[], changes: Partial = {}): ResourcesView => ({ + stage: 'listed', + generation: 3, + resources, + truncated: false, + rescan: null, + rescanning: false, + ...changes, +}); + +const LIB = row('ox_lib', { dependants: ['ox_target'] }); +const LIST = listed([LIB, row('ox_target')]); + +function Follower({ onRender }: { onRender: (followed: Followed) => void }) { + const followed = useResources(); + useEffect(() => onRender(followed)); + return null; +} + +/** The orders the API was sent, each answered when the test says. */ +const orders: { path: string; body: unknown; answer: (response: Response) => void }[] = []; + +/** `server.resources` as the test says it, and what `useResources` makes of it. */ +const follow = async () => { + let socket: SocketEvents | undefined; + const live = new Realtime({ + ticket: async () => 'ticket', + connect: (_, events) => { + socket = events; + return { send: () => {}, close: () => {} }; + }, + refetch: () => {}, + }); + let latest: Followed | undefined; + render( + + + { + latest = followed; + }} + /> + + , + ); + live.start(); + await settle(); + act(() => socket?.message(JSON.stringify({ type: 'hello', connectionId: 1, contract: 1 }))); + const now = () => { + if (latest === undefined) { + throw new Error('the hook never rendered'); + } + return latest; + }; + return { + live, + now, + say: (type: 'snapshot' | 'event', data: ResourcesView) => + act(() => socket?.message(JSON.stringify({ type, topic: 'server.resources', data }))), + cut: () => act(() => socket?.close(1006)), + ask: async (order: (followed: Followed) => void) => { + act(() => order(now())); + await settle(); + }, + answer: async (index: number, body: unknown, status = 200) => { + orders[index]?.answer(Response.json(body, { status })); + await settle(); + }, + }; +}; + +describe('useResources', () => { + beforeEach(() => { + vi.stubGlobal( + 'fetch', + (url: string, init?: RequestInit) => + new Promise((answer) => { + const body: unknown = + init?.body === undefined ? undefined : JSON.parse(String(init.body)); + orders.push({ path: url, body, answer }); + }), + ); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + orders.length = 0; + }); + + it('should hold the list as the topic says it, whole at each change, and nothing once the link is down', async () => { + const { live, now, say, cut } = await follow(); + expect(now().view).toBeNull(); + + say('snapshot', LIST); + expect(now().view).toEqual(LIST); + + const stopped = listed([row('ox_lib', { state: 'stopped' }), row('ox_target', { state: 'stopped' })]); + say('event', stopped); + expect(now().view).toEqual(stopped); + + cut(); + expect(now().view).toBeNull(); + live.stop(); + }); + + it('should show an order from the click until the list has it, whichever of the list and the answer comes first', async () => { + const { live, now, say, ask, answer } = await follow(); + say('snapshot', LIST); + + await ask((followed) => followed.order('restart', 'ox_lib', true)); + + expect(orders).toHaveLength(1); + expect([orders[0]?.path, orders[0]?.body]).toEqual([ + '/api/servers/default/resources/restart', + { name: 'ox_lib', dependants: true }, + ]); + expect(underWay(LIB, now().sent)).toBe('restart'); + + // Observer shows the order on the list before it answers the request. + const asked = { ...LIB, asked: { kind: 'restarted' as const, by: 'ada' } }; + say('event', listed([asked, row('ox_target')])); + expect(now().sent.has('ox_lib')).toBe(true); + + await answer(0, { delivery: 'written', dependants: ['ox_target'] }); + expect(now().sent.has('ox_lib')).toBe(true); + expect(now().outcome).toBeNull(); + + say('event', LIST); + expect(now().sent.size).toBe(0); + expect(underWay(LIB, now().sent)).toBeNull(); + live.stop(); + }); + + it('should send one order at a time for a resource', async () => { + const { live, say, ask } = await follow(); + say('snapshot', LIST); + + await ask((followed) => followed.order('stop', 'ox_lib')); + await ask((followed) => followed.order('restart', 'ox_lib')); + await ask((followed) => followed.order('stop', 'ox_target')); + + expect(orders.map((order) => order.path)).toEqual([ + '/api/servers/default/resources/stop', + '/api/servers/default/resources/stop', + ]); + expect(orders.map((order) => order.body)).toEqual([ + { name: 'ox_lib', dependants: false }, + { name: 'ox_target', dependants: false }, + ]); + live.stop(); + }); + + it('should say when FXServer was not reading its console, and keep only the order that waits for it', async () => { + const { live, now, say, ask, answer } = await follow(); + say('snapshot', LIST); + + await ask((followed) => followed.order('stop', 'ox_lib')); + await answer(0, { delivery: 'pending', dependants: [] }); + expect(now().outcome).toEqual({ kind: 'pending', verb: 'stop', name: 'ox_lib' }); + expect(now().sent.has('ox_lib')).toBe(true); + + await ask((followed) => followed.order('stop', 'ox_target')); + expect(now().outcome).toBeNull(); + await answer(1, { delivery: 'busy', dependants: [] }); + expect(now().outcome).toEqual({ kind: 'busy', verb: 'stop', name: 'ox_target' }); + expect(now().sent.has('ox_target')).toBe(false); + + act(() => now().dismiss()); + expect(now().outcome).toBeNull(); + live.stop(); + }); + + it('should let go of an order the API refused, and keep why', async () => { + const { live, now, say, ask, answer } = await follow(); + say('snapshot', LIST); + + await ask((followed) => followed.order('start', 'ox_lib')); + await answer(0, { code: 'resource_changed' }, 409); + + const outcome = now().outcome; + expect(outcome).toMatchObject({ kind: 'failed', verb: 'start', name: 'ox_lib' }); + expect(outcome?.kind === 'failed' && outcome.error).toEqual(new ApiError(409, 'resource_changed')); + expect(now().sent.size).toBe(0); + live.stop(); + }); + + it('should hold a rescan apart from the resources, until the list came again', async () => { + const { live, now, say, ask, answer } = await follow(); + say('snapshot', LIST); + + await ask((followed) => followed.rescan()); + await ask((followed) => followed.rescan()); + + expect(orders).toHaveLength(1); + expect([orders[0]?.path, orders[0]?.body]).toEqual([ + '/api/servers/default/resources/rescan', + undefined, + ]); + expect(now().sent.has(RESCAN)).toBe(true); + expect(underWay(LIB, now().sent)).toBeNull(); + + await answer(0, { delivery: 'written', dependants: [] }); + const again = listed(LIST.resources, { + rescan: { at: '2026-11-11T14:05:00Z', by: 'ada', added: [], addedCount: 0, changed: 0 }, + }); + say('event', again); + expect(now().sent.size).toBe(0); + expect(now().view?.rescan?.by).toBe('ada'); + live.stop(); + }); +}); diff --git a/web/src/resources/live.ts b/web/src/resources/live.ts new file mode 100644 index 00000000..1c353b56 --- /dev/null +++ b/web/src/resources/live.ts @@ -0,0 +1,78 @@ +import { useMutation } from '@tanstack/react-query'; +import { useState } from 'react'; + +import type { ResourcesView } from '../api/generated/ResourcesView'; +import { orderResource, rescanResources, type ResourceVerb } from '../api/resources'; +import { useTopic } from '../live/context'; +import { answer, drop, NOTHING_SENT, RESCAN, seen, send, type Sent } from './sent'; + +/** What the page asks: FXServer's own command on a resource, or a rescan, which names none. */ +type Asked = { verb: ResourceVerb; name: string } | { verb: 'rescan' }; +type Order = { verb: ResourceVerb; name: string; dependants: boolean } | { verb: 'rescan' }; + +/** + * An order that did not simply go through. FXServer was not reading its console: the line waits + * for it (`pending`), or was never written (`busy`). Or the API refused the order (`failed`). + */ +export type Outcome = Asked & ({ kind: 'pending' } | { kind: 'busy' } | { kind: 'failed'; error: unknown }); + +const keyOf = (order: Order) => (order.verb === 'rescan' ? RESCAN : order.name); +const askedBy = (order: Order): Asked => + order.verb === 'rescan' ? { verb: 'rescan' } : { verb: order.verb, name: order.name }; + +/** + * The resources of the run that is up as `server.resources` says them, whole at each change, with + * the orders of this page. `view` is null until the topic's snapshot, while the link is down and + * for an account the topic refuses. + */ +export const useResources = () => { + const [view, setView] = useState(null); + const [sent, setSent] = useState(NOTHING_SENT); + const [outcome, setOutcome] = useState(null); + const took = (next: ResourcesView) => { + setView(next); + setSent(seen); + }; + const lost = () => { + setView(null); + setSent(NOTHING_SENT); + }; + useTopic('server.resources', { snapshot: took, event: took, refused: lost, cleared: lost }); + + const { mutate } = useMutation({ + mutationFn: (order: Order) => + order.verb === 'rescan' + ? rescanResources() + : orderResource(order.verb, { name: order.name, dependants: order.dependants }), + onMutate: (order) => { + setOutcome(null); + setSent((held) => send(held, keyOf(order), order.verb)); + }, + onSuccess: ({ delivery }, order) => { + setSent((held) => (delivery === 'busy' ? drop(held, keyOf(order)) : answer(held, keyOf(order)))); + if (delivery !== 'written') { + setOutcome({ ...askedBy(order), kind: delivery }); + } + }, + onError: (error, order) => { + setSent((held) => drop(held, keyOf(order))); + setOutcome({ ...askedBy(order), kind: 'failed', error }); + }, + }); + // One order at a time for a resource, as the API takes them. + const ask = (order: Order) => { + if (!sent.has(keyOf(order))) { + mutate(order); + } + }; + + return { + view, + sent, + outcome, + dismiss: () => setOutcome(null), + /** `dependants`, for a restart: start again the resources FXServer stops with this one. */ + order: (verb: ResourceVerb, name: string, dependants = false) => ask({ verb, name, dependants }), + rescan: () => ask({ verb: 'rescan' }), + }; +}; diff --git a/web/src/resources/sent.test.ts b/web/src/resources/sent.test.ts new file mode 100644 index 00000000..a57a996a --- /dev/null +++ b/web/src/resources/sent.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; + +import type { ResourceRow } from '../api/generated/ResourceRow'; +import { answer, drop, NOTHING_SENT, RESCAN, seen, send, underWay } from './sent'; + +const row = (name: string, changes: Partial = {}): ResourceRow => ({ + name, + folder: '', + origin: 'server', + version: null, + state: 'started', + failedAt: null, + warnings: null, + dependants: [], + controllable: true, + last: null, + asked: null, + ...changes, +}); + +describe('the orders a page sent', () => { + it('hold from the click until the first view after the answer', () => { + const clicked = send(NOTHING_SENT, 'ox_lib', 'restart'); + expect(underWay(row('ox_lib'), clicked)).toBe('restart'); + expect(underWay(row('taxi'), clicked)).toBeNull(); + + // The view that shows the order may come before the answer: the order is still this page's. + const early = seen(clicked); + expect(early).toBe(clicked); + + const answered = answer(early, 'ox_lib'); + expect(underWay(row('ox_lib'), answered)).toBe('restart'); + expect(seen(answered).size).toBe(0); + }); + + it('go when the API refused them or FXServer never got them', () => { + const two = send(send(NOTHING_SENT, 'ox_lib', 'stop'), 'taxi', 'start'); + + const left = drop(two, 'ox_lib'); + + expect([...left.keys()]).toEqual(['taxi']); + expect(drop(left, 'ox_lib')).toBe(left); + expect(answer(left, 'ox_lib')).toBe(left); + }); + + it('give way to what the list says FXServer has not answered, whoever asked', () => { + const asked = row('police', { asked: { kind: 'restarted', by: 'maxime' } }); + expect(underWay(asked, NOTHING_SENT)).toBe('restart'); + expect(underWay(row('taxi', { asked: { kind: 'started', by: 'ada' } }), NOTHING_SENT)).toBe('start'); + expect(underWay(row('chat', { asked: { kind: 'stopped', by: 'ada' } }), NOTHING_SENT)).toBe('stop'); + + // This page's own order shows first: the list may still say the one before it. + expect(underWay(asked, send(NOTHING_SENT, 'police', 'stop'))).toBe('stop'); + }); + + it('keep a rescan apart from every resource', () => { + const rescanning = send(NOTHING_SENT, RESCAN, 'rescan'); + + expect(rescanning.has(RESCAN)).toBe(true); + expect(underWay(row('ox_lib'), rescanning)).toBeNull(); + expect(seen(answer(rescanning, RESCAN)).has(RESCAN)).toBe(false); + }); +}); diff --git a/web/src/resources/sent.ts b/web/src/resources/sent.ts new file mode 100644 index 00000000..856ddecd --- /dev/null +++ b/web/src/resources/sent.ts @@ -0,0 +1,59 @@ +import type { LiveChangeKind } from '../api/generated/LiveChangeKind'; +import type { ResourceRow } from '../api/generated/ResourceRow'; +import type { ResourceVerb } from '../api/resources'; + +type Order = { verb: ResourceVerb | 'rescan'; answered: boolean }; + +/** + * The orders this page sent that the list does not show yet, by resource. The list says an order + * waits on FXServer from the view that follows it, and that view may come before the API's answer + * or after it: an order is held from the click until the first view that comes after the answer. + */ +export type Sent = ReadonlyMap; + +export const NOTHING_SENT: Sent = new Map(); + +/** What a rescan is held under: no resource has an empty name. */ +export const RESCAN = ''; + +export const send = (held: Sent, name: string, verb: Order['verb']): Sent => + new Map(held).set(name, { verb, answered: false }); + +/** The API took the order: the list shows it from its next view on. */ +export const answer = (held: Sent, name: string): Sent => { + const order = held.get(name); + return order === undefined ? held : new Map(held).set(name, { ...order, answered: true }); +}; + +/** The order went nowhere: refused, or never written to FXServer's console. */ +export const drop = (held: Sent, name: string): Sent => { + if (!held.has(name)) { + return held; + } + const left = new Map(held); + left.delete(name); + return left; +}; + +/** A view of the list came: the orders answered before it are the list's to show. */ +export const seen = (held: Sent): Sent => { + const left = new Map([...held].filter(([, order]) => !order.answered)); + return left.size === held.size ? held : left; +}; + +const VERBS = { started: 'start', stopped: 'stop', restarted: 'restart' } satisfies Record< + LiveChangeKind, + ResourceVerb +>; + +/** + * The order under way on a resource: one this page just sent, or one of anyone's the list says + * FXServer has not answered. No other is taken meanwhile. + */ +export const underWay = (row: ResourceRow, held: Sent): ResourceVerb | null => { + const mine = held.get(row.name)?.verb; + if (mine !== undefined && mine !== 'rescan') { + return mine; + } + return row.asked === null ? null : VERBS[row.asked.kind]; +}; diff --git a/web/src/resources/words.test.ts b/web/src/resources/words.test.ts new file mode 100644 index 00000000..7bf7feae --- /dev/null +++ b/web/src/resources/words.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; + +import type { ResourceRow } from '../api/generated/ResourceRow'; +import { en } from '../i18n/en'; +import { formatDuration } from '../i18n/format'; +import { changeLine, warningLines } from './words'; + +const format = { duration: (seconds: number) => formatDuration('en', seconds) }; +const NOW = Date.parse('2026-11-11T14:30:00Z'); + +const row = (changes: Partial = {}): ResourceRow => ({ + name: 'ox_target', + folder: '[core]/[ox]', + origin: 'server', + version: null, + state: 'started', + failedAt: null, + warnings: null, + dependants: [], + controllable: true, + last: null, + asked: null, + ...changes, +}); + +const line = (changes: Partial, order: Parameters[4] = null) => + changeLine(en, format, row(changes), NOW, order); + +describe('what a row says under its state', () => { + it('should say nothing of a resource nothing happened to since the cfg ran', () => { + expect(line({})).toBeNull(); + }); + + it('should say what last happened, who asked for it and the resource it stopped with', () => { + const last = { at: '2026-11-11T14:28:00Z', kind: 'restarted', by: null, with: null } as const; + expect(line({ last })).toBe('restarted 2 min ago'); + expect(line({ last: { ...last, at: '2026-11-11T14:29:40Z', by: 'maxime' } })).toBe( + 'restarted by maxime, just now', + ); + expect(line({ state: 'stopped', last: { ...last, kind: 'stopped', with: 'ox_lib' } })).toBe( + 'stopped with ox_lib, 2 min ago', + ); + expect(line({ last: { ...last, by: 'ada', with: 'ox_lib' } })).toBe( + 'restarted with ox_lib by ada, 2 min ago', + ); + expect(line({ last: { ...last, kind: 'started', at: '2026-11-11T11:28:00Z' } })).toBe( + 'started 3h 2m ago', + ); + }); + + it('should say that FXServer could not start a resource, until it starts', () => { + const failed = { state: 'stopped', failedAt: '2026-11-11T13:59:00Z' } as const; + const stopped = { at: '2026-11-11T13:00:00Z', kind: 'stopped', by: 'ada', with: null } as const; + expect(line({ ...failed, last: stopped })).toBe('FXServer could not start it, 31 min ago'); + expect(line({ ...failed, state: 'started', last: { ...stopped, kind: 'started' } })).toBe( + 'started by ada, 1h 30m ago', + ); + }); + + it('should say first the order FXServer has not answered, and who asked for it', () => { + const last = { at: '2026-11-11T14:28:00Z', kind: 'stopped', by: null, with: null } as const; + expect(line({ last }, { verb: 'restart', by: 'maxime' })).toBe('restart asked by maxime'); + expect(line({ state: 'stopped' }, { verb: 'start', by: 'ada' })).toBe('start asked by ada'); + expect(line({}, { verb: 'stop', by: 'ada' })).toBe('stop asked by ada'); + }); +}); + +describe('what FXServer warned of as it started a resource', () => { + it('should tell the heavy files, the oversized among them, the missing ones and the rest', () => { + expect(warningLines(en, { count: 3, heavy: 2, oversized: 1, missing: 1 })).toEqual([ + '2 streamed files are heavy, 1 of them oversized', + '1 file its manifest names is missing', + ]); + expect(warningLines(en, { count: 4, heavy: 1, oversized: 1, missing: 0 })).toEqual([ + '1 streamed file is oversized', + '3 other warnings, in the console', + ]); + expect(warningLines(en, { count: 2, heavy: 0, oversized: 0, missing: 2 })).toEqual([ + '2 files its manifest names are missing', + ]); + expect(warningLines(en, { count: 1, heavy: 0, oversized: 0, missing: 0 })).toEqual([ + '1 other warning, in the console', + ]); + }); +}); diff --git a/web/src/resources/words.ts b/web/src/resources/words.ts new file mode 100644 index 00000000..24635cef --- /dev/null +++ b/web/src/resources/words.ts @@ -0,0 +1,56 @@ +import type { ResourceRow } from '../api/generated/ResourceRow'; +import type { ResourceWarnings } from '../api/generated/ResourceWarnings'; +import type { ResourceVerb } from '../api/resources'; +import type { Messages } from '../i18n/en'; +import { secondsSince } from '../lib/useNow'; +import { didNotStart } from './list'; + +type Format = { duration: (seconds: number) => string }; + +/** An order FXServer has not answered yet, and who asked for it. */ +export type Order = { verb: ResourceVerb; by: string }; + +/** How long ago, to the minute: under one, it happened just now. */ +const ago = (t: Messages, format: Format, at: string, now: number) => { + const seconds = secondsSince(at, now); + return t.resources.ago(seconds < 60 ? null : format.duration(seconds)); +}; + +/** + * What a row says under its state: the order under way and who asked for it, or else that FXServer + * could not start the resource, or else what last happened to it in this run. + */ +export const changeLine = ( + t: Messages, + format: Format, + row: ResourceRow, + now: number, + order: Order | null, +) => { + const words = t.resources; + if (order !== null) { + return words.asked[order.verb](order.by); + } + if (row.failedAt !== null && didNotStart(row)) { + return words.couldNotStart(ago(t, format, row.failedAt, now)); + } + if (row.last === null) { + return null; + } + const { kind, by, with: along, at } = row.last; + return words.happened(words.last[kind], by, along, ago(t, format, at, now)); +}; + +/** + * What FXServer warned of as it started a resource, a line each: the streamed files it finds + * heavy, the files the manifest names without holding them, and what it counted besides. + */ +export const warningLines = (t: Messages, warnings: ResourceWarnings) => { + const words = t.resources; + const others = warnings.count - warnings.heavy - warnings.missing; + return [ + warnings.heavy > 0 && words.heavy(warnings.heavy, warnings.oversized), + warnings.missing > 0 && words.missing(warnings.missing), + others > 0 && words.otherWarnings(others), + ].filter((line) => line !== false); +}; diff --git a/web/src/router.tsx b/web/src/router.tsx index 98bcc87b..03eb6890 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -34,6 +34,9 @@ import { FocusShell } from './shell/FocusShell'; const ConsolePage = lazyCode(() => import('./pages/ConsolePage').then((page) => ({ default: page.ConsolePage })), ); +const ResourcesPage = lazyCode(() => + import('./pages/ResourcesPage').then((page) => ({ default: page.ResourcesPage })), +); const SchedulePage = lazyCode(() => import('./pages/SchedulePage').then((page) => ({ default: page.SchedulePage })), ); @@ -224,6 +227,16 @@ const consoleRoute = createRoute({ ), }); +const resourcesRoute = createRoute({ + getParentRoute: () => panelRoute, + path: '/resources', + component: () => ( + + + + ), +}); + const scheduleRoute = createRoute({ getParentRoute: () => panelRoute, path: '/schedule', @@ -504,6 +517,7 @@ const routeTree = rootRoute.addChildren([ overviewRoute, statisticsRoute, consoleRoute, + resourcesRoute, scheduleRoute, artifactsRoute, diagnosticsRoute, diff --git a/web/src/shell/AppShell.test.tsx b/web/src/shell/AppShell.test.tsx index 2eb2291d..804f755e 100644 --- a/web/src/shell/AppShell.test.tsx +++ b/web/src/shell/AppShell.test.tsx @@ -105,6 +105,7 @@ describe('AppShell', () => { ).toEqual([ en.nav.overview, en.nav.console, + en.nav.resources, en.nav.players, en.nav.map, en.nav.bans, diff --git a/web/src/shell/CommandPalette.test.tsx b/web/src/shell/CommandPalette.test.tsx index edca62cb..c174d9f9 100644 --- a/web/src/shell/CommandPalette.test.tsx +++ b/web/src/shell/CommandPalette.test.tsx @@ -75,7 +75,13 @@ describe('CommandPalette', () => { cleanup(); palette(VIEWER); - expect(listed()).toEqual([en.nav.overview, en.nav.schedule, en.nav.statistics, en.nav.settings]); + expect(listed()).toEqual([ + en.nav.overview, + en.nav.resources, + en.nav.schedule, + en.nav.statistics, + en.nav.settings, + ]); }); it('should go to the page the arrows are on when Enter is pressed', () => { diff --git a/web/src/shell/nav.ts b/web/src/shell/nav.ts index 3c60840d..092c6fdd 100644 --- a/web/src/shell/nav.ts +++ b/web/src/shell/nav.ts @@ -1,5 +1,6 @@ import { Ban, + Blocks, CalendarClock, ChartLine, Contact, @@ -31,6 +32,7 @@ export type NavEntry = { | '/' | '/statistics' | '/console' + | '/resources' | '/schedule' | '/artifacts' | '/diagnostics' @@ -79,6 +81,12 @@ export const navSections = (t: Messages) => ({ main: [ { to: '/', label: t.nav.overview, icon: LayoutDashboard, access: Permission.DashboardView }, { to: '/console', label: t.nav.console, icon: SquareTerminal, access: Permission.ConsoleView }, + { + to: '/resources', + label: t.nav.resources, + icon: Blocks, + access: [Permission.DashboardView, Permission.ResourcesControl], + }, ] satisfies NavEntry[], people: [ { to: '/players', label: t.nav.players, icon: Contact, access: Permission.PlayersView, nested: true }, From bbcee69241579f57d4bc7adfd5b6bf88cb9e3df8 Mon Sep 17 00:00:00 2001 From: ImBaphomet Date: Wed, 11 Nov 2026 05:09:33 +0100 Subject: [PATCH 5/5] test(e2e): stop a resource and what depends on it from the Resources page, then start them back --- web/e2e/flows.spec.ts | 32 ++++++++++++++++++++++++++++++++ web/e2e/observer.ts | 14 +++++++++----- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/web/e2e/flows.spec.ts b/web/e2e/flows.spec.ts index 2bd1468b..865f8e1c 100644 --- a/web/e2e/flows.spec.ts +++ b/web/e2e/flows.spec.ts @@ -153,6 +153,38 @@ test.describe.serial('Observer in a browser', () => { await expect(page.getByText(en.console.sent('fake_players 5'))).toBeVisible(); }); + test('a resource stops with the one that depends on it, and starting that one brings both back', async () => { + const words = en.resources; + await page.getByRole('link', { name: en.nav.resources, exact: true }).click(); + await expect(page.getByRole('heading', { level: 1 })).toContainText(words.title); + const row = (name: string) => + page.getByRole('listitem').filter({ has: page.getByText(name, { exact: true }) }); + await expect(row('hello')).toContainText(words.states.started); + await expect(row('greeter')).toContainText(words.states.started); + + // FXServer stops what depends on a resource before the resource: the page names it first. + await row('hello').getByRole('button', { name: words.verbs.stop, exact: true }).click(); + const dialog = page.getByRole('alertdialog'); + await expect(dialog.getByRole('listitem')).toHaveText(['greeter']); + await dialog.getByRole('button', { name: words.confirmStop('hello', 2), exact: true }).click(); + await expect(dialog).toBeHidden(); + const justNow = words.ago(null); + await expect(row('hello')).toContainText(words.happened(words.last.stopped, 'ada', null, justNow)); + await expect(row('greeter')).toContainText( + words.happened(words.last.stopped, null, 'hello', justNow), + ); + + // And it starts what a resource depends on before the resource. + await row('greeter').getByRole('button', { name: words.verbs.start, exact: true }).click(); + await expect(row('greeter')).toContainText(words.happened(words.last.started, 'ada', null, justNow)); + await expect(row('hello')).toContainText(words.states.started); + + await page.getByRole('link', { name: en.nav.audit, exact: true }).click(); + const log = page.getByRole('table', { name: en.audit.caption }); + await expect(log.getByText(en.audit.actions.resource_stopped, { exact: true })).toBeVisible(); + await expect(log.getByText(en.audit.actions.resource_started, { exact: true })).toBeVisible(); + }); + test('the Overview sums FXServer up, and its details show every measure it reports', async () => { await overview(); // Folded, the details hold no region: the four figures are the only ones named so. diff --git a/web/e2e/observer.ts b/web/e2e/observer.ts index 17651c31..600ffc56 100644 --- a/web/e2e/observer.ts +++ b/web/e2e/observer.ts @@ -115,13 +115,17 @@ export default async function setup() { const dir = mkdtempSync(join(tmpdir(), 'observer-e2e-')); const serverData = join(dir, 'server-data'); installFake(join(dir, 'data', 'artifacts'), fake); - // The server setup takes a folder only once its resources folder holds a resource. - const resource = join(serverData, 'resources', '[local]', 'hello'); - mkdirSync(resource, { recursive: true }); - writeFileSync(join(resource, 'fxmanifest.lua'), "fx_version 'cerulean'\ngame 'gta5'\n"); + // The server setup takes a folder only once its resources folder holds a resource. A second + // one depends on the first, which FXServer therefore stops and starts it with. + const resources = join(serverData, 'resources', '[local]'); + const manifest = "fx_version 'cerulean'\ngame 'gta5'\n"; + mkdirSync(join(resources, 'hello'), { recursive: true }); + writeFileSync(join(resources, 'hello', 'fxmanifest.lua'), manifest); + mkdirSync(join(resources, 'greeter')); + writeFileSync(join(resources, 'greeter', 'fxmanifest.lua'), `${manifest}dependency 'hello'\n`); writeFileSync( join(serverData, 'server.cfg'), - `endpoint_add_tcp "127.0.0.1:${fxserverPort}"\nendpoint_add_udp "127.0.0.1:${fxserverPort}"\nsv_maxclients 32\nensure hello\n`, + `endpoint_add_tcp "127.0.0.1:${fxserverPort}"\nendpoint_add_udp "127.0.0.1:${fxserverPort}"\nsv_maxclients 32\nensure hello\nensure greeter\n`, ); const config = join(dir, 'observer.toml'); writeFileSync(config, `data_dir = "data"\n\n[http]\nlisten = "127.0.0.1:${port}"\n`);