diff --git a/CHANGELOG.md b/CHANGELOG.md index c24d9bf..8f4c041 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,36 @@ Release notes for each version. The section of a version becomes the text of its GitHub release, and Stackline shows it in the update dialog. +## Unreleased + +Stackline now works like Kubri: lists you can drive with the keyboard, an inspector beside the list, and a log workspace that keeps running while you look around. Read-only is now a switch per connection, and with it off Stackline can restart, stop, start and scale services. + +### Security +- **SSH connections no longer open a port on this computer.** Stackline reached a server's Docker through a local TCP port that any program on the computer could also use, and a web page could try to. Whoever reaches that port controls the server's Docker. On macOS and Linux the connection now goes through a socket file in a folder only you can open, removed when Stackline stops. Windows still uses a local port for now + +### Changed +- **New layout.** The sidebar groups the pages (Overview; Workloads: Stacks, Services, Tasks; Cluster: Nodes; Logs) and lists your pinned services, with a box that finds a page or service by name. The top bar has Back and Forward, the connection, a **stack picker** that narrows every list to one stack, **Search or run a command**, the theme and the read-only switch. A status bar at the bottom shows whether the connection is live and the keys of the page +- **Stacks, Services, Tasks and Nodes are lists of their own**, each with a filter (`/`), tabs (All, Needs attention, Pinned or Running), sortable columns and an action bar that names the row it acts on. Move with `j`/`k` or the arrow keys; **Enter opens what is inside**: a stack's services, a service's tasks, a node's tasks. Back returns to the list as you left it, filter and cursor included. Filter and tab are kept after a restart +- **The service panel is now an inspector docked beside the list.** Click a row or press `d`. Moving the cursor takes the inspector along. Its tabs are Overview (with the likely problem of a failing service), Events (deploys, stops and crashes), Config (command, health check, networks, mounts, environment, labels), Logs and Errors. The expand button lets it use the whole page. Nodes get an inspector too +- **The Tasks page** (Containers on a host without swarm) lists every task with its node, state, exit code, failed attempts, CPU, memory and Docker's message +- **A connection that reaches another server no longer deletes its logs by itself.** When the server behind a connection changes (its address was edited, an SSH alias points elsewhere, the swarm was set up again), Stackline stops collecting and asks: keep the old logs, or delete them. Before, they were deleted without asking +- **At the size limit, a busy server no longer pushes out the logs of a quiet one.** Each connection gets an equal share of the space, and only connections above their share lose their oldest lines + +### Added +- **Read-only is a switch per connection.** Every connection starts read-only, as before: only reads reach Docker. Click **Read-only** in the top bar (or run "Allow changes" from the palette) and confirm to allow changes there; the badge turns amber and says **Changes allowed**. Click it again to lock the connection. The server checks this on every change, and `stackline serve --read-only` (or `STACKLINE_READ_ONLY=1`) keeps every connection locked. Changing a connection's host or kind locks it again, and the switch holds only for the server it was switched on: when the connection reaches another server, changes are refused until you allow them there +- **Restart, stop, start and scale.** With changes allowed, the Services and Containers lists and the inspector offer them: `r` restarts a service (on swarm, like `docker service update --force`), `x` stops and `u` starts its containers (Compose and lone containers, also one container from the Containers list), `c` scales a replicated swarm service. Each one asks first and shows the `docker` command it amounts to, and only that command runs: if the service changed in between (a container was replaced), Stackline refuses and asks again. Nothing is ever created or removed. Every change is marked on the time graphs and listed in the service's events, with how it went +- **Log workspace.** Press `l` on a service, stack, task or node (or **Tail below** in the inspector) to follow its lines in a tab at the bottom of the window. The tabs keep following while you use the rest of the app, can be resized, shrunk with `w`, and are remembered per connection +- **Keys everywhere.** `:` or Ctrl K opens the command palette, which also runs commands (narrow the lists to a stack, close the workspace, show the keys). `?` lists every key. `g` then a letter goes to a page (`g s` Services, `g t` Tasks, `g k` Stacks, `g l` Logs…); Alt ← and Alt → go back and forward + +### Fixed +- **"Clear stored logs" during a read could leave part of that read behind**, and those lines then kept the cleared range from filling again +- **A replica read for the first time held up all the others.** Reading hours of a busy replica can take minutes; other replicas and container events now go on being read meanwhile +- **Deploys could go missing from the Overview** when a service crashed thousands of times after them +- **`--host 0.0.0.0` answered nobody.** Other machines were turned away because they address Stackline by an IP address it did not know. Any IP address of the machine now works; names are still refused, which keeps DNS rebinding out. Stackline also warns that the link travels unencrypted +- **A settings file that could not be read kept Stackline from starting.** It is now set aside as `config.broken.json`, Stackline starts with the default settings and says so +- **A log line could run as a formula** when a CSV download was opened in a spreadsheet app. Cells that start with `=`, `+`, `-` or `@` now start with `'` +- **A Docker that accepted a request and then never answered stopped log collection for good.** Requests now give up after 30 seconds without an answer and are tried again on the next poll + ## 0.5.1 Logs no longer end without warning: the stream pages through every stored line, and long lines are stored whole. diff --git a/README.md b/README.md index b07ef4b..d7aec21 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ [![Release](https://img.shields.io/github/v/release/snaketech-tu/stackline)](https://github.com/snaketech-tu/stackline/releases/latest) [![License: Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-blue)](LICENSE) -**A read-only dashboard for Docker Swarm and Compose, with logs that keep stack traces together.** +**A dashboard for Docker Swarm and Compose that is read-only until you say otherwise, with logs that keep stack traces together.** See which services need attention, inspect failed tasks, and investigate errors across replicas. Run Stackline on your computer and connect to Docker locally @@ -101,7 +101,7 @@ the binary to `stackline` (`stackline.exe` on Windows) and putting it on your `P you can customize startup: ```text -stackline serve [--port 7070] [--host 127.0.0.1] [--no-open] [--data-dir DIR] +stackline serve [--port 7070] [--host 127.0.0.1] [--no-open] [--data-dir DIR] [--read-only] ``` ## Connect a host @@ -212,8 +212,16 @@ app uses system notifications; in a browser, allow notifications for the Stackline tab. Only the services whose logs are collected count toward the alert. -Stackline is read-only toward Docker. It writes its own settings, cached logs, -exports and installed updates on the computer where it runs. +**Every connection starts read-only**: only reads reach Docker. Click +**Read-only** in the top bar to allow changes on that connection. Stackline can +then restart, stop and start a service's containers, and restart or scale a +swarm service. It never creates or removes anything, and each change asks first +and shows the `docker` command it amounts to. The server enforces this, not +only the page. `stackline serve --read-only` (or `STACKLINE_READ_ONLY=1`) keeps +every connection read-only, whatever was switched before. + +Stackline writes its own settings, cached logs, exports and installed updates +on the computer where it runs. | Data | Linux | macOS | Windows | | --- | --- | --- | --- | diff --git a/crates/stackline-core/src/actions.rs b/crates/stackline-core/src/actions.rs new file mode 100644 index 0000000..72a5a31 --- /dev/null +++ b/crates/stackline-core/src/actions.rs @@ -0,0 +1,340 @@ +//! The changes Stackline can make, once a connection's read-only mode is +//! switched off: restart, stop and start a service's containers, and restart +//! or scale a swarm service. Nothing is ever created or removed. +//! +//! Each action is first turned into a `Plan`: the Docker API calls it needs and +//! the matching `docker` command, so the interface can show what will happen +//! before it does. Planning is pure and tested; `run` only sends the calls. + +use anyhow::{Result, anyhow, bail}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::docker::DockerClient; +use crate::model::{Service, StackKind}; + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum Kind { + Restart, + Stop, + Start, + Scale, +} + +/// What someone asked for, as the API receives it. +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Request { + pub action: Kind, + /// Full service name, e.g. `shop_api`. + pub service: String, + /// The stack it belongs to, for when two stacks have a service of the same name. + #[serde(default)] + pub stack: Option, + /// Only work out the plan, so it can be shown before anything happens. + #[serde(default)] + pub dry: bool, + /// Only this replica (its slot), instead of every one of the service. + #[serde(default)] + pub replica: Option, + /// For `scale`: the new number of replicas. + #[serde(default)] + pub replicas: Option, + /// The fingerprint of the plan that was shown (from a dry run). When the + /// plan made now differs, nothing runs: see [`Changed`]. + #[serde(default)] + pub expect: Option, +} + +/// The plan differs from the one that was shown and confirmed. +#[derive(Debug)] +pub struct Changed; + +impl std::fmt::Display for Changed { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("The service changed since you confirmed, so nothing was done. Look at it again and retry.") + } +} + +impl std::error::Error for Changed {} + +/// One call to the Docker API. +#[derive(Clone, Debug, PartialEq)] +pub enum Call { + /// POST a path without a body, such as `/containers//restart`. + Post(String), + /// Change a swarm service: read its spec, change it, write it back. + UpdateService { id: String, change: SpecChange }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SpecChange { + /// Replace every task, like `docker service update --force`. + Force, + Replicas(u32), +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Plan { + pub calls: Vec, + /// The same change as a command someone could type. + pub command: String, + /// What was done, said after it worked. + pub done: String, +} + +const MAX_REPLICAS: u32 = 100; + +impl Plan { + /// A short value that changes whenever the calls do, so a confirmed plan + /// can be checked against the one about to run (FNV-1a of the calls). + pub fn fingerprint(&self) -> String { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for b in format!("{:?}", self.calls).bytes() { + h ^= b as u64; + h = h.wrapping_mul(0x0100_0000_01b3); + } + format!("{h:016x}") + } +} + +/// Works out the calls for a request on a service of a stack of `kind`. +pub fn plan(req: &Request, svc: &Service, kind: StackKind) -> Result { + let name = &svc.full_name; + if kind == StackKind::Swarm { + if req.replica.is_some() { + bail!("Swarm restarts a service's tasks as a whole. Restart {name} instead of one replica."); + } + return match req.action { + Kind::Restart => Ok(Plan { + calls: vec![Call::UpdateService { id: svc.id.clone(), change: SpecChange::Force }], + command: format!("docker service update --force {name}"), + done: format!("Restarting the tasks of {name}. Swarm replaces them one after another."), + }), + Kind::Scale => { + if svc.mode != "replicated" { + bail!("{name} runs in {} mode: it has one task per node and cannot be scaled.", svc.mode); + } + let n = req.replicas.ok_or_else(|| anyhow!("Say how many replicas {name} should run."))?; + if n > MAX_REPLICAS { + bail!("At most {MAX_REPLICAS} replicas can be set from Stackline."); + } + Ok(Plan { + calls: vec![Call::UpdateService { id: svc.id.clone(), change: SpecChange::Replicas(n) }], + command: format!("docker service scale {name}={n}"), + done: format!("Scaling {name} to {n} {}.", if n == 1 { "replica" } else { "replicas" }), + }) + } + Kind::Stop | Kind::Start => { + bail!("Swarm keeps a service's tasks running. Scale {name} to 0 to stop it, and back up to start it.") + } + }; + } + if req.action == Kind::Scale { + bail!("Compose services are scaled with `docker compose up --scale`, which Stackline does not run."); + } + let tasks: Vec<_> = svc + .tasks + .iter() + .filter(|t| req.replica.as_ref().is_none_or(|r| &t.slot == r)) + .filter(|t| match req.action { + Kind::Stop => stoppable(&t.state), + Kind::Start => startable(&t.state), + _ => !matches!(t.state.as_str(), "dead" | "removing"), + }) + .filter_map(|t| t.container_id.clone().map(|id| (id, t.container_name.clone().unwrap_or_else(|| t.slot.clone())))) + .collect(); + if tasks.is_empty() { + bail!(match req.action { + Kind::Stop => format!("Nothing of {name} is running."), + Kind::Start => format!("Everything of {name} is running already."), + _ => format!("{name} has no container to restart."), + }); + } + let verb = match req.action { + Kind::Restart => "restart", + Kind::Stop => "stop", + _ => "start", + }; + let names: Vec<_> = tasks.iter().map(|(_, n)| n.as_str()).collect(); + let n = tasks.len(); + Ok(Plan { + calls: tasks.iter().map(|(id, _)| Call::Post(format!("/containers/{id}/{verb}"))).collect(), + command: format!("docker {verb} {}", names.join(" ")), + done: format!( + "{} {n} {} of {name}.", + match req.action { + Kind::Restart => "Restarted", + Kind::Stop => "Stopped", + _ => "Started", + }, + if n == 1 { "container" } else { "containers" } + ), + }) +} + +/// Container states `docker stop` acts on. +fn stoppable(state: &str) -> bool { + matches!(state, "running" | "restarting" | "paused") +} + +/// Container states `docker start` acts on. A dead or removing container cannot be started. +fn startable(state: &str) -> bool { + matches!(state, "exited" | "created") +} + +/// Changes a service spec as `change` says. The spec is what +/// `GET /services/` returns under `Spec`. +pub fn change_spec(spec: &mut Value, change: SpecChange) -> Result<()> { + match change { + SpecChange::Force => { + let tt = + spec.get_mut("TaskTemplate").and_then(Value::as_object_mut).ok_or_else(|| anyhow!("the service has no task template"))?; + let n = tt.get("ForceUpdate").and_then(Value::as_u64).unwrap_or(0); + tt.insert("ForceUpdate".into(), Value::from(n + 1)); + } + SpecChange::Replicas(n) => { + let r = spec + .pointer_mut("/Mode/Replicated") + .and_then(Value::as_object_mut) + .ok_or_else(|| anyhow!("the service is not replicated, so it cannot be scaled"))?; + r.insert("Replicas".into(), Value::from(n)); + } + } + Ok(()) +} + +/// Sends the calls of a plan, in order. When one fails after others went +/// through, the error says how many did, so nobody assumes nothing changed. +pub async fn run(client: &DockerClient, plan: &Plan) -> Result<()> { + let n = plan.calls.len(); + for (i, call) in plan.calls.iter().enumerate() { + let res = match call { + Call::Post(path) => client.post(path, None).await, + Call::UpdateService { id, change } => update_service(client, id, *change).await, + }; + if let Err(e) = res { + return Err(if i == 0 { e } else { e.context(format!("{i} of {n} went through before this one failed")) }); + } + } + Ok(()) +} + +async fn update_service(client: &DockerClient, id: &str, change: SpecChange) -> Result<()> { + let svc: Value = client.get_json(&format!("/services/{id}")).await?; + let version = svc.pointer("/Version/Index").and_then(Value::as_u64).ok_or_else(|| anyhow!("Docker sent no service version"))?; + let mut spec = svc.get("Spec").cloned().ok_or_else(|| anyhow!("Docker sent no service spec"))?; + change_spec(&mut spec, change)?; + client.post(&format!("/services/{id}/update?version={version}"), Some(&spec)).await +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::Task; + use serde_json::json; + + fn task(slot: &str, state: &str, id: Option<&str>) -> Task { + Task { + id: format!("t{slot}"), + slot: slot.into(), + node: "vm".into(), + state: state.into(), + desired_state: "running".into(), + message: None, + error: None, + health: None, + container_id: id.map(str::to_string), + container_name: id.map(|i| format!("shop-api-{i}")), + since: None, + exit_code: None, + failures: 0, + cpu_percent: None, + mem_used: None, + } + } + + fn svc(mode: &str, tasks: Vec) -> Service { + Service { + id: "svc1".into(), + name: "api".into(), + full_name: "shop_api".into(), + stack: "shop".into(), + image: "alpine".into(), + mode: mode.into(), + desired: 2, + running: 1, + ports: vec![], + updated_at: None, + tty: false, + tasks, + cpu_percent: None, + mem_used: None, + config: Default::default(), + } + } + + fn req(action: Kind) -> Request { + Request { action, service: "shop_api".into(), stack: None, dry: false, replica: None, replicas: None, expect: None } + } + + #[test] + fn compose_restart_hits_every_container_and_says_the_command() { + let s = svc("compose", vec![task("1", "running", Some("a1")), task("2", "exited", Some("b2"))]); + let p = plan(&req(Kind::Restart), &s, StackKind::Compose).unwrap(); + assert_eq!(p.calls, vec![Call::Post("/containers/a1/restart".into()), Call::Post("/containers/b2/restart".into())]); + assert_eq!(p.command, "docker restart shop-api-a1 shop-api-b2"); + } + + #[test] + fn stop_and_start_only_touch_containers_that_need_it() { + let s = svc("compose", vec![task("1", "running", Some("a1")), task("2", "exited", Some("b2"))]); + assert_eq!(plan(&req(Kind::Stop), &s, StackKind::Compose).unwrap().calls, vec![Call::Post("/containers/a1/stop".into())]); + assert_eq!(plan(&req(Kind::Start), &s, StackKind::Compose).unwrap().calls, vec![Call::Post("/containers/b2/start".into())]); + let one = Request { replica: Some("2".into()), ..req(Kind::Restart) }; + assert_eq!(plan(&one, &s, StackKind::Compose).unwrap().calls, vec![Call::Post("/containers/b2/restart".into())]); + // A paused container is stopped, a dead one is not started. + let odd = svc("compose", vec![task("1", "paused", Some("p1")), task("2", "dead", Some("d2"))]); + assert_eq!(plan(&req(Kind::Stop), &odd, StackKind::Compose).unwrap().calls, vec![Call::Post("/containers/p1/stop".into())]); + assert!(plan(&req(Kind::Start), &odd, StackKind::Compose).is_err()); + let all_up = svc("compose", vec![task("1", "running", Some("a1"))]); + assert!(plan(&req(Kind::Start), &all_up, StackKind::Compose).unwrap_err().to_string().contains("running already")); + } + + #[test] + fn swarm_restarts_and_scales_through_the_service() { + let s = svc("replicated", vec![task("1", "running", Some("a1"))]); + let p = plan(&req(Kind::Restart), &s, StackKind::Swarm).unwrap(); + assert_eq!(p.calls, vec![Call::UpdateService { id: "svc1".into(), change: SpecChange::Force }]); + assert_eq!(p.command, "docker service update --force shop_api"); + let p = plan(&Request { replicas: Some(3), ..req(Kind::Scale) }, &s, StackKind::Swarm).unwrap(); + assert_eq!(p.command, "docker service scale shop_api=3"); + assert!(plan(&Request { replicas: Some(500), ..req(Kind::Scale) }, &s, StackKind::Swarm).is_err()); + assert!(plan(&req(Kind::Stop), &s, StackKind::Swarm).is_err()); + assert!(plan(&Request { replicas: Some(2), ..req(Kind::Scale) }, &svc("global", vec![]), StackKind::Swarm).is_err()); + assert!(plan(&Request { replicas: Some(2), ..req(Kind::Scale) }, &s, StackKind::Compose).is_err()); + } + + #[test] + fn a_plan_made_after_a_container_was_replaced_has_another_fingerprint() { + let before = svc("compose", vec![task("1", "running", Some("a1"))]); + let after = svc("compose", vec![task("1", "running", Some("a2"))]); + let shown = plan(&req(Kind::Restart), &before, StackKind::Compose).unwrap().fingerprint(); + assert_eq!(plan(&req(Kind::Restart), &before, StackKind::Compose).unwrap().fingerprint(), shown); + assert_ne!(plan(&req(Kind::Restart), &after, StackKind::Compose).unwrap().fingerprint(), shown); + assert_ne!(plan(&req(Kind::Stop), &before, StackKind::Compose).unwrap().fingerprint(), shown); + } + + #[test] + fn spec_changes_keep_the_rest_of_the_spec() { + let mut spec = json!({ "Name": "shop_api", "TaskTemplate": { "ContainerSpec": { "Image": "alpine" }, "ForceUpdate": 4 }, "Mode": { "Replicated": { "Replicas": 2 } } }); + change_spec(&mut spec, SpecChange::Force).unwrap(); + assert_eq!(spec["TaskTemplate"]["ForceUpdate"], 5); + assert_eq!(spec["TaskTemplate"]["ContainerSpec"]["Image"], "alpine"); + change_spec(&mut spec, SpecChange::Replicas(0)).unwrap(); + assert_eq!(spec["Mode"]["Replicated"]["Replicas"], 0); + let mut global = json!({ "TaskTemplate": {}, "Mode": { "Global": {} } }); + assert!(change_spec(&mut global, SpecChange::Replicas(1)).is_err()); + } +} diff --git a/crates/stackline-core/src/collector.rs b/crates/stackline-core/src/collector.rs index a0e0916..69ab72a 100644 --- a/crates/stackline-core/src/collector.rs +++ b/crates/stackline-core/src/collector.rs @@ -53,6 +53,24 @@ pub struct Collector { store: Arc, states: HashMap, epoch: u64, + /// First reads under way, by source. They run apart from the regular + /// polls: reading hours of a busy replica can take minutes, and the other + /// replicas and the events must not wait for it. + first: HashMap>, +} + +/// How many first reads of one connection run at once. +const FIRST_READS: usize = 2; +/// How long a poll waits for first reads to finish, so that a quick one +/// (most of them) is stored by the poll that started it. +const FIRST_WAIT: Duration = Duration::from_secs(2); + +impl Drop for Collector { + fn drop(&mut self) { + for (_, h) in self.first.drain() { + h.abort(); + } + } } #[derive(Debug, Default, Clone)] @@ -65,7 +83,7 @@ pub struct PollReport { impl Collector { pub fn new(conn: &str, store: Arc) -> Self { let epoch = store.epoch(); - Self { conn: conn.to_string(), store, states: HashMap::new(), epoch } + Self { conn: conn.to_string(), store, states: HashMap::new(), epoch, first: HashMap::new() } } /// Reads new lines from every source. The first read of a new source goes @@ -79,10 +97,18 @@ impl Collector { if refill { self.states.clear(); self.epoch = epoch; + // What they read belongs to the lines just cleared. + for (_, h) in self.first.drain() { + h.abort(); + } } + let started = Instant::now(); let keys: std::collections::HashSet = sources.iter().map(|s| s.key.clone()).collect(); self.states.retain(|k, _| keys.contains(k)); for src in sources { + if self.first.contains_key(&src.key) { + continue; + } let st = self.states.entry(src.key.clone()).or_insert_with(|| SourceState { assembler: Assembler::new(&src.service), cursor: None, @@ -112,48 +138,90 @@ impl Collector { .filter(|(_, s)| s.src.running || s.last_read.is_none_or(|t| t.elapsed() > Duration::from_secs(60))) .map(|(k, _)| k.clone()) .collect(); - let mut taken: Vec = due.iter().filter_map(|k| self.states.remove(k)).collect(); + let taken: Vec = due.iter().filter_map(|k| self.states.remove(k)).collect(); let mut report = PollReport { sources: taken.len(), ..Default::default() }; + let (fresh, known): (Vec, Vec) = taken.into_iter().partition(|st| st.cursor.is_none()); + + // First reads start in the background, a few at a time; the rest wait for a later poll. + for st in fresh { + if self.first.len() >= FIRST_READS { + self.states.insert(st.src.key.clone(), st); + continue; + } + let (conn, store, client) = (self.conn.clone(), self.store.clone(), client.clone()); + let key = st.src.key.clone(); + self.first.insert(key, tokio::spawn(read_one(client, store, conn, st, now, retention_us))); + } let conn = self.conn.clone(); let store = self.store.clone(); - let results: Vec<(SourceState, usize)> = futures_util::stream::iter(taken.drain(..)) - .map(|mut st| { - let (conn, store, client) = (conn.clone(), store.clone(), client.clone()); - async move { - let n = match read_source(&client, &store, &conn, &mut st, now, retention_us).await { - Ok(n) => { - st.error = None; - n - } - Err(e) => { - st.error = Some(format!("{}: {e:#}", st.src.service)); - // Batches stored before the error stay stored: go on - // after them, or the next poll stores them twice. - // A half-built entry is dropped for the same reason. - if st.stored > st.cursor { - st.cursor = st.stored; - } - st.assembler = Assembler::new(&st.src.service); - 0 - } - }; - st.last_read = Some(Instant::now()); - (st, n) - } - }) + let results: Vec<(SourceState, usize)> = futures_util::stream::iter(known) + .map(|st| read_one(client.clone(), store.clone(), conn.clone(), st, now, retention_us)) .buffer_unordered(6) .collect() .await; for (st, n) in results { - report.entries += n; - if let Some(e) = &st.error { - report.errors.push(e.clone()); + self.done(st, n, &mut report); + } + + // First reads that finished, waiting a moment for quick ones. + let deadline = started + FIRST_WAIT; + let keys: Vec = self.first.keys().cloned().collect(); + for key in keys { + let Some(h) = self.first.get_mut(&key) else { continue }; + match tokio::time::timeout_at(deadline.into(), h).await { + Err(_) => continue, + Ok(r) => { + self.first.remove(&key); + // A read that panicked is started again from the stored cursor. + if let Ok((st, n)) = r + && st.epoch == self.epoch + { + self.done(st, n, &mut report); + } + } } - self.states.insert(st.src.key.clone(), st); } report } + + fn done(&mut self, st: SourceState, n: usize, report: &mut PollReport) { + report.entries += n; + if let Some(e) = &st.error { + report.errors.push(e.clone()); + } + self.states.insert(st.src.key.clone(), st); + } +} + +/// Reads one source and says how many entries it stored. +async fn read_one( + client: DockerClient, + store: Arc, + conn: String, + mut st: SourceState, + now: i64, + retention_us: i64, +) -> (SourceState, usize) { + let n = match read_source(&client, &store, &conn, &mut st, now, retention_us).await { + Ok(n) => { + st.error = None; + n + } + Err(e) => { + st.error = Some(format!("{}: {e:#}", st.src.service)); + // Batches stored before the error stay stored: go on + // after them, or the next poll stores them twice. + // A half-built entry is dropped for the same reason. + if st.stored > st.cursor { + st.cursor = st.stored; + } + st.assembler = Assembler::new(&st.src.service); + 0 + } + }; + st.last_read = Some(Instant::now()); + (st, n) } async fn read_source( @@ -306,19 +374,11 @@ async fn flush(store: &Arc, conn: &str, st: &mut SourceState, entries: &m (t, _) => t, }; let (store, conn, src, epoch) = (store.clone(), conn.to_string(), st.src.clone(), st.epoch); - tokio::task::spawn_blocking(move || -> Result<()> { - // The store was cleared during this read: the next poll starts over. - if store.epoch() != epoch { - return Ok(()); - } - store.insert(&conn, &src, &batch)?; - if let Some(ts) = last_ts { - store.set_cursor(&conn, &src.key, ts)?; - } - Ok(()) - }) - .await??; - st.stored = last_ts.or(st.stored); + // A store cleared during this read keeps nothing of it: the next poll starts over. + let stored = tokio::task::spawn_blocking(move || store.store_batch(&conn, &src, &batch, last_ts, epoch)).await??; + if stored { + st.stored = last_ts.or(st.stored); + } Ok(n) } @@ -485,6 +545,69 @@ mod tests { assert_eq!(stored(&store), 5); } + #[tokio::test] + async fn a_slow_first_read_does_not_hold_up_the_other_replicas() { + // Answers every log request at once with one new line, except those of + // the replica `slow`, which take 8 seconds: a first read of hours of logs. + // Two polls wait at most 2 × FIRST_WAIT for first reads, well short of that. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let line = Arc::new(Mutex::new(0i64)); + let l = line.clone(); + tokio::spawn(async move { + loop { + let Ok((mut sock, _)) = listener.accept().await else { return }; + let l = l.clone(); + tokio::spawn(async move { + let mut buf = Vec::new(); + let mut chunk = [0u8; 1024]; + while !buf.windows(4).any(|w| w == b"\r\n\r\n") { + match sock.read(&mut chunk).await { + Ok(0) | Err(_) => return, + Ok(n) => buf.extend_from_slice(&chunk[..n]), + } + } + let head = String::from_utf8_lossy(&buf).to_string(); + if head.contains("/slow/") { + tokio::time::sleep(Duration::from_secs(8)).await; + } + let ts = *l.lock().unwrap(); + let body = frames(&[(ts, "INFO hello".to_string())], "/"); + let res = format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n", body.len()); + let _ = sock.write_all(res.as_bytes()).await; + let _ = sock.write_all(&body).await; + }); + } + }); + let client = DockerClient::new(Endpoint::Tcp(addr)); + let store = Arc::new(Store::open_in_memory().unwrap()); + let now = chrono::Utc::now().timestamp_micros(); + let fast = LogSource { key: "fast".into(), ..source() }; + let slow = LogSource { key: "slow".into(), service: "shop_worker".into(), ..source() }; + let mut c = Collector::new("conn", store.clone()); + + *line.lock().unwrap() = now - 10_000_000; + let first = Instant::now(); + let started = first; + let r = c.poll(&client, vec![fast.clone(), slow.clone()], RETENTION).await; + assert!(started.elapsed() < Duration::from_secs(3), "the poll waited {:?} for the slow first read", started.elapsed()); + assert_eq!(r.entries, 1, "{r:?}"); + + // The next polls read the fast replica again while the slow one is still busy. + *line.lock().unwrap() = now - 5_000_000; + let started = Instant::now(); + let r = c.poll(&client, vec![fast.clone(), slow.clone()], RETENTION).await; + assert!(started.elapsed() < Duration::from_secs(3)); + assert_eq!(r.entries, 1, "{r:?}"); + + // Once done, the slow replica's lines are stored and it is polled like any other. + let left = Duration::from_secs(9).saturating_sub(first.elapsed()); + tokio::time::sleep(left).await; + c.poll(&client, vec![fast, slow], RETENTION).await; + assert!(store.cursor("conn", "slow").unwrap().is_some()); + assert_eq!(stored(&store), 3); + } + #[tokio::test] async fn a_new_replica_is_read_6_hours_back_and_a_clear_refills_the_retention() { let docker = FakeDocker::start().await; diff --git a/crates/stackline-core/src/config.rs b/crates/stackline-core/src/config.rs index c9b4fae..7dbb0a9 100644 --- a/crates/stackline-core/src/config.rs +++ b/crates/stackline-core/src/config.rs @@ -21,6 +21,14 @@ pub struct AppConfig { /// Which services' logs are collected, per connection id. Missing means all. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub collect: BTreeMap, + /// Connections whose read-only mode was switched off, so Stackline may + /// restart, stop, start and scale their services, each with the server + /// it was switched off for (`swarm:` or `engine:`, see + /// `Snapshot::identity`). Changes are only allowed while the connection + /// reaches that same server. Every other connection, new ones too, is + /// read-only. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub writable: BTreeMap, } /// The services of one connection whose logs Stackline reads and stores. @@ -116,11 +124,17 @@ impl Default for AppConfig { settings: Settings::default(), pins: BTreeMap::new(), collect: BTreeMap::new(), + writable: BTreeMap::new(), } } } impl AppConfig { + /// Whether changes are allowed on connection `id` while it reaches `host`. + pub fn allows_changes(&self, id: &str, host: Option<&str>) -> bool { + host.is_some_and(|h| self.writable.get(id).is_some_and(|w| w == h)) + } + pub fn load(path: &Path) -> Result { match std::fs::read(path) { Ok(bytes) => Ok(serde_json::from_slice(&bytes)?), @@ -129,6 +143,31 @@ impl AppConfig { } } + /// Like [`AppConfig::load`], but a file that cannot be read as settings + /// (edited by hand, say) does not keep Stackline from starting: it is + /// moved aside as `config.broken.json` and the defaults are used. The + /// second value says what happened, for the UI to show. + pub fn load_or_set_aside(path: &Path) -> Result<(AppConfig, Option)> { + let bytes = match std::fs::read(path) { + Ok(b) => b, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok((AppConfig::default(), None)), + Err(e) => return Err(e.into()), + }; + match serde_json::from_slice(&bytes) { + Ok(c) => Ok((c, None)), + Err(e) => { + let aside = path.with_file_name("config.broken.json"); + std::fs::rename(path, &aside)?; + tracing::warn!("{} could not be read ({e}); moved to {}", path.display(), aside.display()); + let msg = format!( + "Your settings file could not be read ({e}), so Stackline started with the default settings. The old file was kept as {}.", + aside.display() + ); + Ok((AppConfig::default(), Some(msg))) + } + } + } + pub fn save(&self, path: &Path) -> Result<()> { if let Some(dir) = path.parent() { std::fs::create_dir_all(dir)?; @@ -255,6 +294,36 @@ mod tests { assert!(!base.exists()); } + #[test] + fn a_broken_settings_file_is_set_aside_not_fatal() { + let dir = std::env::temp_dir().join(format!("stackline-broken-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("config.json"); + std::fs::write(&path, r#"{"connections": [ oops"#).unwrap(); + let (c, warning) = AppConfig::load_or_set_aside(&path).unwrap(); + assert_eq!(c.connections.len(), 1); + assert!(warning.unwrap().contains("config.broken.json")); + assert!(!path.exists()); + assert_eq!(std::fs::read_to_string(dir.join("config.broken.json")).unwrap(), r#"{"connections": [ oops"#); + // A good file loads as it is, with nothing to say. + AppConfig::default().save(&path).unwrap(); + assert!(AppConfig::load_or_set_aside(&path).unwrap().1.is_none()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn changes_are_allowed_only_on_the_server_they_were_allowed_for() { + let mut c = AppConfig::default(); + assert!(!c.allows_changes("local", Some("engine:A"))); + c.writable.insert("local".into(), "engine:A".into()); + assert!(c.allows_changes("local", Some("engine:A"))); + // The same connection reaching another server, or none yet, is read-only. + assert!(!c.allows_changes("local", Some("engine:B"))); + assert!(!c.allows_changes("local", None)); + assert!(!c.allows_changes("other", Some("engine:A"))); + } + #[test] fn collect_lists_exceptions_or_choices() { let all = Collect::default(); diff --git a/crates/stackline-core/src/docker/client.rs b/crates/stackline-core/src/docker/client.rs index ca6c339..28a4dcb 100644 --- a/crates/stackline-core/src/docker/client.rs +++ b/crates/stackline-core/src/docker/client.rs @@ -1,13 +1,14 @@ //! A deliberately small Docker Engine API client. //! -//! Stackline is read-only by construction: this client can only send `GET` -//! requests. There is no code path that creates, updates or removes anything. +//! Everything Stackline reads goes through `get`. The only other request is +//! `post`, used by `actions` alone, and only for a connection whose read-only +//! mode was switched off. Nothing here creates or removes anything. use std::time::Duration; use anyhow::{Context, Result, anyhow, bail}; use bytes::Bytes; -use http_body_util::{BodyExt, Empty}; +use http_body_util::{BodyExt, Empty, Full}; use hyper::body::Incoming; use hyper::header::{HOST, USER_AGENT}; use hyper::{Method, Request, Response, StatusCode}; @@ -17,6 +18,9 @@ use serde::de::DeserializeOwned; use super::endpoint::Endpoint; const JSON_TIMEOUT: Duration = Duration::from_secs(20); +/// How long a request may wait for the response headers. Log and event +/// streams send them at once and the body later. +const HEAD_TIMEOUT: Duration = Duration::from_secs(30); #[derive(Clone, Debug)] pub struct DockerClient { @@ -33,7 +37,16 @@ impl DockerClient { } /// Sends a GET request and returns the response with its body still streaming. + /// Fails when Docker does not answer with headers within [`HEAD_TIMEOUT`]: + /// a daemon or tunnel that accepts the connection and then hangs would + /// otherwise stop log collection for good. pub async fn get(&self, path_and_query: &str) -> Result> { + tokio::time::timeout(HEAD_TIMEOUT, self.get_inner(path_and_query)) + .await + .map_err(|_| anyhow!("Docker did not start answering {path_and_query} within {HEAD_TIMEOUT:?}"))? + } + + async fn get_inner(&self, path_and_query: &str) -> Result> { let stream = self.endpoint.connect().await.with_context(|| format!("could not reach Docker at {}", self.endpoint.describe()))?; let (mut sender, conn) = hyper::client::conn::http1::handshake(TokioIo::new(stream)).await.context("HTTP handshake with Docker failed")?; @@ -77,6 +90,39 @@ impl DockerClient { Ok(res.into_body()) } + /// Sends a POST with a JSON body (or none) and fails on a non-2xx status. + /// Only `actions` calls this. + pub async fn post(&self, path_and_query: &str, body: Option<&serde_json::Value>) -> Result<()> { + let fut = async { + let stream = + self.endpoint.connect().await.with_context(|| format!("could not reach Docker at {}", self.endpoint.describe()))?; + let (mut sender, conn) = + hyper::client::conn::http1::handshake(TokioIo::new(stream)).await.context("HTTP handshake with Docker failed")?; + tokio::spawn(async move { + let _ = conn.await; + }); + let bytes = body.map(serde_json::to_vec).transpose()?.unwrap_or_default(); + let req = Request::builder() + .method(Method::POST) + .uri(path_and_query) + .header(HOST, "docker") + .header(USER_AGENT, concat!("stackline/", env!("CARGO_PKG_VERSION"))) + .header(hyper::header::CONTENT_TYPE, "application/json") + .body(Full::new(Bytes::from(bytes)))?; + let res = sender.send_request(req).await.context("Docker request failed")?; + let status = res.status(); + let body = res.into_body().collect().await?.to_bytes(); + if !status.is_success() { + return Err(api_error(status, &body, path_and_query)); + } + Ok(()) + }; + // Stopping a container waits for its grace period, which can be long. + tokio::time::timeout(Duration::from_secs(120), fut) + .await + .map_err(|_| anyhow!("Docker did not answer {path_and_query} within 2 minutes"))? + } + pub async fn ping(&self) -> Result<()> { let res = self.get("/_ping").await?; if res.status() != StatusCode::OK { diff --git a/crates/stackline-core/src/docker/ssh.rs b/crates/stackline-core/src/docker/ssh.rs index 0d370d4..b0b7efc 100644 --- a/crates/stackline-core/src/docker/ssh.rs +++ b/crates/stackline-core/src/docker/ssh.rs @@ -1,9 +1,14 @@ //! SSH access to a remote Docker host. //! //! Stackline runs your system `ssh` so that `~/.ssh/config`, the agent, keys and -//! ProxyJump all work exactly as in a terminal. One SSH connection forwards a -//! local TCP port to the remote Docker socket; every API request then goes -//! through that single connection. Nothing is installed or opened on the server. +//! ProxyJump all work exactly as in a terminal. One SSH connection forwards the +//! remote Docker socket; every API request then goes through that single +//! connection. Nothing is installed or opened on the server. +//! +//! Whoever can reach the local end of the forward controls the server's Docker, +//! so on macOS and Linux it is a socket file in a folder only this user can +//! open, never a TCP port that every program on the computer (or a web page in +//! a browser) could connect to. Windows still forwards a loopback port. use std::process::Stdio; use std::sync::{Arc, Mutex}; @@ -14,6 +19,8 @@ use serde::{Deserialize, Serialize}; use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::process::{Child, Command}; +use super::Endpoint; + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub struct SshTarget { /// `host`, `user@host` or an alias from `~/.ssh/config`. @@ -37,14 +44,54 @@ pub fn default_socket() -> String { pub struct SshTunnel { child: Child, - local_addr: String, + local: Local, stderr: Arc>, } +/// The local end of the forward. +enum Local { + /// A socket file in a private folder, removed with the tunnel. + #[cfg(unix)] + Socket(PrivateDir), + #[cfg(not(unix))] + Port(String), +} + +impl Local { + fn new() -> Result { + #[cfg(unix)] + { + Ok(Local::Socket(PrivateDir::new()?)) + } + #[cfg(not(unix))] + { + Ok(Local::Port(format!("127.0.0.1:{}", free_port()?))) + } + } + + /// What `ssh -L` listens on. + fn listen_spec(&self) -> String { + match self { + #[cfg(unix)] + Local::Socket(d) => d.socket().display().to_string(), + #[cfg(not(unix))] + Local::Port(a) => a.clone(), + } + } + + fn endpoint(&self) -> Endpoint { + match self { + #[cfg(unix)] + Local::Socket(d) => Endpoint::Unix(d.socket()), + #[cfg(not(unix))] + Local::Port(a) => Endpoint::Tcp(a.clone()), + } + } +} + impl SshTunnel { pub async fn open(t: &SshTarget) -> Result { - let port = free_port()?; - let local_addr = format!("127.0.0.1:{port}"); + let local = Local::new()?; let mut cmd = Command::new(ssh_binary()); cmd.args([ "-N", @@ -69,7 +116,10 @@ impl SshTunnel { if let Some(j) = t.jump.as_deref().filter(|s| !s.is_empty()) { cmd.arg("-J").arg(j); } - cmd.arg("-L").arg(format!("{local_addr}:{}", t.socket)); + #[cfg(unix)] + // Replace a stale socket file, and keep the new one to this user. + cmd.args(["-o", "StreamLocalBindUnlink=yes", "-o", "StreamLocalBindMask=0177"]); + cmd.arg("-L").arg(format!("{}:{}", local.listen_spec(), t.socket)); cmd.arg(&t.target); cmd.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::piped()).kill_on_drop(true); cmd.env_remove("STACKLINE_TOKEN").env_remove("SWARMDASH_TOKEN"); @@ -104,7 +154,7 @@ impl SshTunnel { let msg = stderr.lock().unwrap().trim().to_string(); bail!("ssh to {} exited ({status}){}", t.target, if msg.is_empty() { String::new() } else { format!(": {msg}") }); } - if tokio::net::TcpStream::connect(&local_addr).await.is_ok() { + if local.endpoint().connect().await.is_ok() { break; } if started.elapsed() > Duration::from_secs(30) { @@ -112,11 +162,12 @@ impl SshTunnel { } tokio::time::sleep(Duration::from_millis(150)).await; } - Ok(SshTunnel { child, local_addr, stderr }) + Ok(SshTunnel { child, local, stderr }) } - pub fn local_addr(&self) -> &str { - &self.local_addr + /// Where the Docker API of the server is reached through this tunnel. + pub fn endpoint(&self) -> Endpoint { + self.local.endpoint() } pub fn is_alive(&mut self) -> bool { @@ -126,6 +177,10 @@ impl SshTunnel { /// Stops ssh right away, e.g. before Stackline exits or restarts. pub fn kill(&mut self) { let _ = self.child.start_kill(); + #[cfg(unix)] + let Local::Socket(dir) = &self.local; + #[cfg(unix)] + let _ = std::fs::remove_dir_all(&dir.0); } /// The last thing ssh printed, e.g. a permission error on the remote socket. @@ -135,6 +190,54 @@ impl SshTunnel { } } +/// A folder only this user can open, holding the tunnel's socket. Removed +/// when the tunnel goes away. +#[cfg(unix)] +struct PrivateDir(std::path::PathBuf); + +#[cfg(unix)] +impl PrivateDir { + fn new() -> Result { + use std::os::unix::fs::DirBuilderExt; + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT: AtomicU64 = AtomicU64::new(0); + // The runtime folder is private already; the temporary folder is shared, + // so the new folder must not exist yet (no one can have prepared it). + let base = dirs::runtime_dir().filter(|d| d.is_dir()).unwrap_or_else(std::env::temp_dir); + let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.subsec_nanos()).unwrap_or(0); + for _ in 0..20 { + let n = NEXT.fetch_add(1, Ordering::Relaxed); + let dir = base.join(format!("stackline-{}-{nanos:x}-{n}", std::process::id())); + match std::fs::DirBuilder::new().mode(0o700).create(&dir) { + Ok(()) => { + let d = PrivateDir(dir); + // A socket path longer than about 100 bytes cannot be bound (macOS: 104). + let len = d.socket().as_os_str().len(); + if len > 100 { + bail!("the folder for the SSH socket has too long a path ({len} bytes): {}", d.0.display()); + } + return Ok(d); + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e).with_context(|| format!("could not create a folder in {}", base.display())), + } + } + bail!("could not create a private folder in {}", base.display()) + } + + fn socket(&self) -> std::path::PathBuf { + self.0.join("docker.sock") + } +} + +#[cfg(unix)] +impl Drop for PrivateDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +#[cfg(not(unix))] fn free_port() -> Result { let l = std::net::TcpListener::bind("127.0.0.1:0")?; Ok(l.local_addr()?.port()) @@ -153,3 +256,70 @@ fn expand_tilde(p: &str) -> String { } p.to_string() } + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use crate::docker::DockerClient; + + /// Stands in for `ssh`: serves the `-L` socket and answers every request + /// like Docker's `/version`, and writes down the arguments it got. + const FAKE_SSH: &str = r#"#!/usr/bin/env python3 +import os, socket, sys +args = sys.argv[1:] +open(os.environ["FAKE_SSH_ARGS"], "w").write("\n".join(args)) +local = args[args.index("-L") + 1].rsplit(":", 1)[0] +s = socket.socket(socket.AF_UNIX) +s.bind(local) +s.listen(8) +while True: + c, _ = s.accept() + data = b"" + while b"\r\n\r\n" not in data: + chunk = c.recv(4096) + if not chunk: + break + data += chunk + body = b'{"Version":"fake"}' + try: + if data: + c.sendall(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: %d\r\n\r\n%s" % (len(body), body)) + except OSError: + pass + c.close() +"#; + + #[tokio::test] + async fn the_tunnel_is_a_private_socket_not_a_port() { + use std::os::unix::fs::PermissionsExt; + let dir = std::env::temp_dir().join(format!("stackline-fake-ssh-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let ssh = dir.join("ssh"); + std::fs::write(&ssh, FAKE_SSH).unwrap(); + std::fs::set_permissions(&ssh, std::fs::Permissions::from_mode(0o755)).unwrap(); + let args_file = dir.join("args"); + // Only this test runs ssh, so changing the environment is safe here. + unsafe { + std::env::set_var("STACKLINE_SSH", &ssh); + std::env::set_var("FAKE_SSH_ARGS", &args_file); + } + let target = SshTarget { target: "me@example".into(), port: None, identity: None, jump: None, socket: default_socket() }; + let mut tunnel = SshTunnel::open(&target).await.unwrap(); + + let Endpoint::Unix(sock) = tunnel.endpoint() else { panic!("an SSH tunnel must not listen on a TCP port") }; + let folder = sock.parent().unwrap().to_path_buf(); + assert_eq!(std::fs::metadata(&folder).unwrap().permissions().mode() & 0o777, 0o700); + let args = std::fs::read_to_string(&args_file).unwrap(); + assert!(args.contains(&format!("{}:/var/run/docker.sock", sock.display())), "{args}"); + assert!(args.contains("StreamLocalBindMask=0177") && args.contains("BatchMode=yes"), "{args}"); + + let v: serde_json::Value = DockerClient::new(tunnel.endpoint()).get_json("/version").await.unwrap(); + assert_eq!(v["Version"], "fake"); + + tunnel.kill(); + drop(tunnel); + assert!(!folder.exists(), "the socket folder is removed with the tunnel"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/stackline-core/src/events.rs b/crates/stackline-core/src/events.rs index 909c8e7..684736b 100644 --- a/crates/stackline-core/src/events.rs +++ b/crates/stackline-core/src/events.rs @@ -49,6 +49,9 @@ pub enum Kind { Failed, /// The service runs something else than before; see `deploys`. Deploy, + /// A change made from Stackline: restart, stop, start or scale. `detail` + /// holds the docker command and how it went. + Action, } impl Kind { @@ -62,11 +65,12 @@ impl Kind { Kind::Unhealthy => "unhealthy", Kind::Failed => "failed", Kind::Deploy => "deploy", + Kind::Action => "action", } } pub fn parse(s: &str) -> Option { - [Kind::Start, Kind::Exit, Kind::Stop, Kind::Crash, Kind::Oom, Kind::Unhealthy, Kind::Failed, Kind::Deploy] + [Kind::Start, Kind::Exit, Kind::Stop, Kind::Crash, Kind::Oom, Kind::Unhealthy, Kind::Failed, Kind::Deploy, Kind::Action] .into_iter() .find(|k| k.as_str() == s) } @@ -132,16 +136,9 @@ impl EventPoller { events.extend(snap.task_failures.iter().filter(|t| t.node != names.local || t.container.is_empty()).map(from_task)); let (store, conn) = (self.store.clone(), self.conn.clone()); - tokio::task::spawn_blocking(move || { - // A clear while reading: what was read belongs to the lines just deleted. - if store.epoch() != epoch { - return Ok(0); - } - let n = store.insert_events(&conn, &events)?; - store.set_cursor(&conn, CURSOR, now)?; - Ok(n) - }) - .await? + // A clear while reading: what was read belongs to the lines just deleted. + let n = tokio::task::spawn_blocking(move || store.store_events(&conn, &events, Some(now), epoch)).await??; + Ok(n.unwrap_or(0)) } } diff --git a/crates/stackline-core/src/lib.rs b/crates/stackline-core/src/lib.rs index 9eca4a9..b038864 100644 --- a/crates/stackline-core/src/lib.rs +++ b/crates/stackline-core/src/lib.rs @@ -1,4 +1,5 @@ -//! Stackline core: a read-only view of Docker hosts, swarms and their logs. +//! Stackline core: a view of Docker hosts, swarms and their logs. Read-only +//! unless a connection's read-only mode is switched off; see `actions`. /// Compiles a regular expression once and caches it. macro_rules! regex { @@ -10,6 +11,7 @@ macro_rules! regex { }}; } +pub mod actions; pub mod alerts; pub mod collector; pub mod config; diff --git a/crates/stackline-core/src/report.rs b/crates/stackline-core/src/report.rs index 0936c34..3476c0b 100644 --- a/crates/stackline-core/src/report.rs +++ b/crates/stackline-core/src/report.rs @@ -180,7 +180,8 @@ pub fn markdown(r: &Report) -> String { } else { format!("- {} replica {}: {}", time_us(e.ts), e.replica, event_label(e)) }; - if let Some(d) = e.detail.as_deref().filter(|d| !d.is_empty() && !matches!(e.kind, Kind::Failed | Kind::Deploy)) { + if let Some(d) = e.detail.as_deref().filter(|d| !d.is_empty() && !matches!(e.kind, Kind::Failed | Kind::Deploy | Kind::Action)) + { let _ = write!(line, " · {}", one_line(d)); } o.push_str(&line); @@ -288,6 +289,10 @@ fn event_label(e: &Event) -> String { Some(d) => format!("deployed: {d}"), None => "deployed".into(), }, + Kind::Action => match e.detail.as_deref().filter(|d| !d.is_empty()) { + Some(d) => format!("changed by Stackline: {}", one_line(d)), + None => "changed by Stackline".into(), + }, Kind::Failed => match e.detail.as_deref().filter(|d| !d.is_empty()) { Some(d) => format!("failed: {}{code}", one_line(d)), None => format!("failed{code}"), diff --git a/crates/stackline-core/src/runtime.rs b/crates/stackline-core/src/runtime.rs index 3e112dd..a2f9bad 100644 --- a/crates/stackline-core/src/runtime.rs +++ b/crates/stackline-core/src/runtime.rs @@ -17,7 +17,7 @@ use crate::docker::{DockerClient, Endpoint}; use crate::events::EventPoller; use crate::model::Snapshot; use crate::snapshot::{self, Options}; -use crate::store::Store; +use crate::store::{HostCheck, Store}; #[derive(Clone, Debug, Serialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] @@ -37,6 +37,10 @@ pub struct ConnState { pub updated_at: Option, pub logs_synced_at: Option, pub log_errors: Vec, + /// The connection now reaches another host or swarm than its stored + /// lines come from. Nothing is collected until the user decides whether + /// the old lines stay ([`crate::store::Store::accept_host`]). + pub other_host: bool, } /// One opened connection. @@ -65,6 +69,7 @@ impl Runtime { updated_at: None, logs_synced_at: None, log_errors: vec![], + other_host: false, }), client: tokio::sync::Mutex::new(None), tasks: Mutex::new(vec![]), @@ -81,6 +86,16 @@ impl Runtime { self.state.read().unwrap().clone() } + /// The host this connection reaches now, as the store remembers hosts. + pub fn identity(&self) -> Option { + self.snapshot()?.identity() + } + + /// The user answered the question about another host: collecting goes on. + pub fn host_accepted(&self) { + self.state.write().unwrap().other_host = false; + } + /// Reads the full configuration of a service by its full name. pub async fn details(&self, name: &str) -> Result> { let Some(snap) = self.snapshot() else { return Ok(None) }; @@ -92,6 +107,71 @@ impl Runtime { Ok(Some(crate::details::fetch(&client, svc, kind).await?)) } + /// Makes a change (restart, stop, start, scale). The caller checks that the + /// connection is not read-only. Returns what was done and the matching command. + /// Reads the state again right after, so the next look shows the change. + /// `host` is the server changes were allowed for: nothing is sent unless + /// the state the plan is made from comes from that server. + /// Every change is also stored as an event of kind `action`, so it shows + /// on the time graphs next to what it was meant to fix. + pub async fn act( + &self, + req: &crate::actions::Request, + host: &str, + settings: &Settings, + store: &Arc, + ) -> Result { + let snap = self.snapshot().ok_or_else(|| anyhow::anyhow!("Not connected yet."))?; + if snap.identity().as_deref() != Some(host) { + anyhow::bail!("{} now reaches another server than changes were allowed for.", self.cfg.name); + } + let (svc, kind) = snap + .stacks + .iter() + .filter(|st| req.stack.as_ref().is_none_or(|n| &st.name == n)) + .find_map(|st| st.services.iter().find(|s| s.full_name == req.service).map(|s| (s, st.kind))) + .ok_or_else(|| anyhow::anyhow!("{} is not running here.", req.service))?; + let plan = crate::actions::plan(req, svc, kind)?; + if req.dry { + return Ok(plan); + } + // What runs must be what was shown: a container replaced since then + // would otherwise be restarted or stopped without anyone seeing it. + if let Some(expected) = &req.expect + && *expected != plan.fingerprint() + { + return Err(anyhow::Error::new(crate::actions::Changed)); + } + let client = self.client().await?; + tracing::info!(connection = %self.cfg.name, command = %plan.command, "changing a service"); + let res = crate::actions::run(&client, &plan).await; + let ts = chrono::Utc::now().timestamp_micros(); + let outcome = match &res { + Ok(()) => "done".to_string(), + Err(e) => format!("failed: {e:#}"), + }; + let event = crate::events::Event { + key: format!("action:{ts}:{}", plan.fingerprint()), + ts, + stack: svc.stack.clone(), + service: svc.full_name.clone(), + replica: req.replica.clone().unwrap_or_default(), + node: String::new(), + source: String::new(), + kind: crate::events::Kind::Action, + exit_code: None, + detail: Some(format!("{} · {outcome}", plan.command)), + }; + let (store, conn) = (store.clone(), self.cfg.id.clone()); + match tokio::task::spawn_blocking(move || store.insert_events(&conn, &[event])).await { + Ok(Ok(_)) => {} + Ok(Err(e)) => tracing::warn!("could not store the change made on {}: {e:#}", self.cfg.name), + Err(_) => {} + } + self.refresh(settings).await; + res.map(|()| plan) + } + /// Returns a working client, (re)opening the SSH tunnel when needed. async fn client(&self) -> Result { let mut guard = self.client.lock().await; @@ -161,7 +241,7 @@ pub async fn open(cfg: &ConnectionConfig) -> Result<(DockerClient, Option { let tunnel = SshTunnel::open(t).await?; - let client = DockerClient::new(Endpoint::Tcp(tunnel.local_addr().to_string())); + let client = DockerClient::new(tunnel.endpoint()); Ok((client, Some(tunnel))) } } @@ -198,6 +278,28 @@ fn explain(msg: &str) -> String { } } +/// Whether changes are allowed on a connection. +#[derive(Clone, Copy, Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Access { + pub writable: bool, + /// Read-only for every connection, set when Stackline was started. + pub forced: bool, +} + +static FORCE_READ_ONLY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// Makes every connection read-only for as long as Stackline runs (`--read-only`). +pub fn force_read_only() { + FORCE_READ_ONLY.store(true, std::sync::atomic::Ordering::Relaxed); +} + +/// `--read-only` or `STACKLINE_READ_ONLY=1`: nothing can be changed. +pub fn read_only_forced() -> bool { + FORCE_READ_ONLY.load(std::sync::atomic::Ordering::Relaxed) + || crate::config::env_var("READ_ONLY").is_some_and(|v| !matches!(v.as_str(), "" | "0" | "false" | "no")) +} + /// Owns configuration, the log store and all opened connections. pub struct Manager { paths: Paths, @@ -209,11 +311,13 @@ pub struct Manager { deleting: Arc>>, /// Why the last cleanup of the store failed, until one succeeds again. maintenance_error: Mutex>, + /// The settings file could not be read at startup and was set aside. + pub config_warning: Option, } impl Manager { pub fn new(paths: Paths) -> Result> { - let config = AppConfig::load(&paths.config)?; + let (config, config_warning) = AppConfig::load_or_set_aside(&paths.config)?; let store = Arc::new(Store::open(&paths.store)?); let m = Arc::new(Manager { paths, @@ -223,6 +327,7 @@ impl Manager { runtimes: Mutex::new(HashMap::new()), deleting: Arc::default(), maintenance_error: Mutex::default(), + config_warning, }); let store = m.store.clone(); tokio::task::spawn_blocking(move || { @@ -239,6 +344,34 @@ impl Manager { self.config.read().unwrap().clone() } + /// Whether a connection may be changed, and whether read-only is forced + /// for every connection (`--read-only` or `STACKLINE_READ_ONLY`). + /// Changes are only allowed while the connection reaches the server + /// they were allowed for. + pub fn access(&self, id: &str) -> Access { + let forced = read_only_forced(); + let host = self.runtimes.lock().unwrap().get(id).and_then(|r| r.identity()); + Access { writable: !forced && self.config.read().unwrap().allows_changes(id, host.as_deref()), forced } + } + + /// Switches a connection's read-only mode off (`writable`) or on. + pub fn set_writable(&self, id: &str, writable: bool) -> Result { + if writable && read_only_forced() { + anyhow::bail!("Stackline was started read-only, so no connection can be changed."); + } + let host = self.runtimes.lock().unwrap().get(id).and_then(|r| r.identity()); + if writable && host.is_none() { + anyhow::bail!("Wait until Stackline is connected: changes are allowed for the server it reaches."); + } + self.update_config(|c| { + c.writable.remove(id); + if let (true, Some(h)) = (writable, host) { + c.writable.insert(id.to_string(), h); + } + })?; + Ok(self.access(id)) + } + pub fn store_path(&self) -> &std::path::Path { &self.paths.store } @@ -328,14 +461,18 @@ impl Manager { // Checked when the identity is first seen or changes, not on every poll. if let Some(identity) = snap.identity().filter(|i| bound.as_ref() != Some(i)) { let (store, conn, id2) = (m.store.clone(), r.cfg.id.clone(), identity.clone()); - // Clearing a large store takes a while; keep it off the async workers. - match tokio::task::spawn_blocking(move || store.bind_host(&conn, &id2)).await { - Ok(Ok(cleared)) => { - if cleared { - tracing::info!("{} now reaches another host; its stored logs were cleared", r.cfg.name); - collector = Collector::new(&r.cfg.id, m.store.clone()); - events = EventPoller::new(&r.cfg.id, m.store.clone()); + match tokio::task::spawn_blocking(move || store.check_host(&conn, &id2)).await { + Ok(Ok(HostCheck::Changed)) => { + // Nothing is read until the user says what happens to the old + // lines; asked again on every poll until then. + if !std::mem::replace(&mut r.state.write().unwrap().other_host, true) { + tracing::warn!("{} now reaches another host; collecting waits for your answer", r.cfg.name); } + tokio::time::sleep(Duration::from_secs(settings.log_poll_secs.max(1) as u64)).await; + continue; + } + Ok(Ok(_)) => { + r.state.write().unwrap().other_host = false; bound = Some(identity); } Ok(Err(e)) => tracing::warn!("could not check the host of {}: {e:#}", r.cfg.name), diff --git a/crates/stackline-core/src/store.rs b/crates/stackline-core/src/store.rs index b6a530a..1a8ff44 100644 --- a/crates/stackline-core/src/store.rs +++ b/crates/stackline-core/src/store.rs @@ -43,6 +43,9 @@ const IDLE_READERS: usize = 4; /// Keeps the search index in step with new lines. Their per-minute counts /// are added by [`Store::insert`], once per minute instead of once per line. +const SET_CURSOR: &str = "INSERT INTO cursors (conn, source, ts) VALUES (?1, ?2, ?3) + ON CONFLICT (conn, source) DO UPDATE SET ts = excluded.ts"; + const INSERT_TRIGGER: &str = "CREATE TRIGGER entries_ai AFTER INSERT ON entries BEGIN INSERT INTO entries_fts (rowid, text) VALUES (new.id, new.text); END;"; @@ -321,6 +324,36 @@ fn new_groups(db: &Connection, conn: &str, now: i64, oldest: Option) -> Res Ok(out) } +/// What [`Store::check_host`] found. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HostCheck { + /// The host the stored lines come from. + Same, + /// The first host seen for the connection; now remembered. + First, + /// Another host than the stored lines come from. + Changed, +} + +/// How many lines each connection gives up so that `keep` lines are left: +/// the lines kept are shared out equally, and a connection with fewer lines +/// than its share keeps all of them, which leaves more for the others. +fn over_share(counts: &[(String, u64)], keep: u64) -> Vec<(String, u64)> { + let mut sorted: Vec<&(String, u64)> = counts.iter().collect(); + sorted.sort_by_key(|(_, n)| *n); + let mut left = keep; + let mut out = Vec::new(); + for (i, (conn, n)) in sorted.iter().enumerate() { + let share = left / (sorted.len() - i) as u64; + let kept = (*n).min(share); + left -= kept; + if *n > kept { + out.push((conn.clone(), n - kept)); + } + } + out +} + fn escape_like(s: &str) -> String { s.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_") } @@ -591,53 +624,83 @@ impl Store { } let mut db = self.db.lock().unwrap(); let tx = db.transaction()?; - { - let mut ins = tx.prepare_cached( - "INSERT INTO entries (conn, ts, stack, service, replica, node, source, stream, level, exc, lines, fp, text, raw) + insert_entries(&tx, conn, src, entries)?; + tx.commit()?; + Ok(()) + } + + /// Stores lines of a read and moves the source's cursor in one step, + /// unless the lines of the connection were cleared since `epoch` (see + /// [`Store::epoch`]): then nothing is stored and false comes back. The + /// check holds the write lock, so a clear either comes first and the + /// batch is dropped, or comes after and deletes it with the rest. + pub fn store_batch(&self, conn: &str, src: &LogSource, entries: &[LogEntry], cursor: Option, epoch: u64) -> Result { + let mut db = self.db.lock().unwrap(); + if self.epoch() != epoch { + return Ok(false); + } + let tx = db.transaction()?; + insert_entries(&tx, conn, src, entries)?; + if let Some(ts) = cursor { + tx.execute(SET_CURSOR, params![conn, src.key, ts])?; + } + tx.commit()?; + Ok(true) + } +} + +/// Writes lines, their error patterns and their per-minute counts. +fn insert_entries(tx: &rusqlite::Transaction<'_>, conn: &str, src: &LogSource, entries: &[LogEntry]) -> Result<()> { + if entries.is_empty() { + return Ok(()); + } + { + let mut ins = tx.prepare_cached( + "INSERT INTO entries (conn, ts, stack, service, replica, node, source, stream, level, exc, lines, fp, text, raw) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", - )?; - let mut pat = tx.prepare_cached( - "INSERT INTO patterns (conn, fp, pattern) VALUES (?1, ?2, ?3) + )?; + let mut pat = tx.prepare_cached( + "INSERT INTO patterns (conn, fp, pattern) VALUES (?1, ?2, ?3) ON CONFLICT (conn, fp) DO UPDATE SET pattern = excluded.pattern WHERE pattern != excluded.pattern", - )?; - // Counts per minute: debug, info, warn, error, then the same with a stack trace. - let mut minutes: HashMap = HashMap::new(); - for e in entries { - let m = minutes.entry(e.ts.div_euclid(MINUTE)).or_default(); - let l = e.level.as_i64() as usize - 1; - m[l] += 1; - m[4 + l] += e.exc as i64; - ins.execute(params![ - conn, - e.ts, - src.stack, - src.service, - src.replica, - src.node, - src.key, - if e.stream == StreamKind::Stderr { 2 } else { 1 }, - e.level.as_i64(), - e.exc as i64, - e.lines, - e.fp, - e.text, - e.raw, - ])?; - if let (Some(fp), Some(p)) = (&e.fp, &e.pattern) { - pat.execute(params![conn, fp, p])?; - } + )?; + // Counts per minute: debug, info, warn, error, then the same with a stack trace. + let mut minutes: HashMap = HashMap::new(); + for e in entries { + let m = minutes.entry(e.ts.div_euclid(MINUTE)).or_default(); + let l = e.level.as_i64() as usize - 1; + m[l] += 1; + m[4 + l] += e.exc as i64; + ins.execute(params![ + conn, + e.ts, + src.stack, + src.service, + src.replica, + src.node, + src.key, + if e.stream == StreamKind::Stderr { 2 } else { 1 }, + e.level.as_i64(), + e.exc as i64, + e.lines, + e.fp, + e.text, + e.raw, + ])?; + if let (Some(fp), Some(p)) = (&e.fp, &e.pattern) { + pat.execute(params![conn, fp, p])?; } - let mut add = tx.prepare_cached(ADD_MINUTE)?; - for (minute, c) in minutes { - for (service, stack) in [(src.service.as_str(), src.stack.as_str()), ("", "")] { - add.execute(params![conn, minute, service, stack, c[0], c[1], c[2], c[3], c[4], c[5], c[6], c[7]])?; - } + } + let mut add = tx.prepare_cached(ADD_MINUTE)?; + for (minute, c) in minutes { + for (service, stack) in [(src.service.as_str(), src.stack.as_str()), ("", "")] { + add.execute(params![conn, minute, service, stack, c[0], c[1], c[2], c[3], c[4], c[5], c[6], c[7]])?; } } - tx.commit()?; - Ok(()) } + Ok(()) +} +impl Store { pub fn cursor(&self, conn: &str, source: &str) -> Result> { let db = self.db.lock().unwrap(); Ok(db.query_row("SELECT ts FROM cursors WHERE conn = ?1 AND source = ?2", params![conn, source], |r| r.get(0)).optional()?) @@ -645,11 +708,7 @@ impl Store { pub fn set_cursor(&self, conn: &str, source: &str, ts: i64) -> Result<()> { let db = self.db.lock().unwrap(); - db.execute( - "INSERT INTO cursors (conn, source, ts) VALUES (?1, ?2, ?3) - ON CONFLICT (conn, source) DO UPDATE SET ts = excluded.ts", - params![conn, source, ts], - )?; + db.execute(SET_CURSOR, params![conn, source, ts])?; Ok(()) } @@ -709,7 +768,18 @@ impl Store { /// Stores events read from Docker; ones already stored are skipped. /// Returns how many were new. pub fn insert_events(&self, conn: &str, events: &[Event]) -> Result { + let epoch = self.epoch(); + Ok(self.store_events(conn, events, None, epoch)?.unwrap_or(0)) + } + + /// Like [`Store::insert_events`], and moves the event cursor to `cursor` + /// in the same step, unless the connection was cleared since `epoch` + /// (then None comes back and nothing is stored). + pub fn store_events(&self, conn: &str, events: &[Event], cursor: Option, epoch: u64) -> Result> { let mut db = self.db.lock().unwrap(); + if self.epoch() != epoch { + return Ok(None); + } let tx = db.transaction()?; let mut n = 0; { @@ -733,15 +803,27 @@ impl Store { ])?; } } + if let Some(ts) = cursor { + tx.execute(SET_CURSOR, params![conn, crate::events::CURSOR, ts])?; + } tx.commit()?; - Ok(n) + Ok(Some(n)) } /// The newest events in the filter's time range and sources, oldest /// first. Levels, error groups and search words do not apply to events. pub fn events(&self, conn: &str, f: &LogFilter, limit: usize) -> Result> { + self.events_of(conn, f, &[], limit) + } + + /// Like [`Store::events`], only events of these kinds (all when empty). + pub fn events_of(&self, conn: &str, f: &LogFilter, kinds: &[Kind], limit: usize) -> Result> { let f = LogFilter { fps: vec![], text: vec![], levels: vec![], no_levels: false, ..f.clone() }; - let (w, mut p) = f.where_clause(conn, false); + let (mut w, mut p) = f.where_clause(conn, false); + if !kinds.is_empty() { + w.push_str(&format!(" AND kind IN ({})", vec!["?"; kinds.len()].join(","))); + p.extend(kinds.iter().map(|k| Sql::Text(k.as_str().into()))); + } p.push(Sql::Integer(limit as i64)); self.read(|db| { let mut stmt = db.prepare(&format!( @@ -822,31 +904,40 @@ impl Store { .execute("DELETE FROM minutes WHERE conn = ?1 AND minute < ?2", params![conn, before.div_euclid(MINUTE)])?; } self.db.lock().unwrap().execute("DELETE FROM events WHERE ts < ?1", params![before])?; - // Over the size limit: drop the oldest lines first, down to 90% of the - // limit so this does not run again a minute later. Ids grow with - // arrival, so the rowid order finds them without sorting the table. - // Deleted lines stay in the search index until it is merged, so the - // index is optimized before the size is measured again. + // Over the size limit: drop the oldest lines, down to 90% of the + // limit so this does not run again a minute later. Every connection + // gets an equal share of what is kept, and only connections above + // their share give up lines: a chatty server must not push out the + // whole history of a quiet one. Deleted lines stay in the search index + // until it is merged, so the index is merged before the size is + // measured again. for _ in 0..4 { let live = live_bytes(&self.db.lock().unwrap())?; if live <= max_bytes { break; } - let total: u64 = self.db.lock().unwrap().query_row("SELECT COUNT(*) FROM entries", [], |r| r.get(0))?; + let counts: Vec<(String, u64)> = { + let db = self.db.lock().unwrap(); + let mut st = db.prepare("SELECT conn, COUNT(*) FROM entries GROUP BY conn")?; + st.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?.collect::>()? + }; + let total: u64 = counts.iter().map(|(_, n)| n).sum(); if total == 0 { break; } let target = max_bytes / 10 * 9; - let mut left = ((total as u128 * (live - target) as u128).div_ceil(live as u128) as u64).clamp(1, total); - while left > 0 { - let n = self.db.lock().unwrap().execute( - "DELETE FROM entries WHERE id IN (SELECT id FROM entries ORDER BY id LIMIT ?1)", - params![left.min(BATCH as u64)], - )? as u64; - deleted += n; - left = left.saturating_sub(n); - if n == 0 { - break; + let left = ((total as u128 * (live - target) as u128).div_ceil(live as u128) as u64).clamp(1, total); + for (conn, mut drop) in over_share(&counts, total - left) { + while drop > 0 { + let n = self.db.lock().unwrap().execute( + "DELETE FROM entries WHERE id IN (SELECT id FROM entries WHERE conn = ?1 ORDER BY ts LIMIT ?2)", + params![conn, drop.min(BATCH as u64)], + )? as u64; + deleted += n; + drop = drop.saturating_sub(n); + if n == 0 { + break; + } } } self.merge_index()?; @@ -995,26 +1086,34 @@ impl Store { self.read(|db| error_rate(db, conn, from, to)) } - /// Remembers which host or swarm a connection's logs come from. When a - /// connection now reaches a different one (its address was edited, or an - /// alias points elsewhere), the old lines are deleted so the two never mix. - /// Returns true when lines were deleted. - pub fn bind_host(&self, conn: &str, identity: &str) -> Result { + /// Checks which host or swarm a connection's logs come from against the + /// one remembered. The first one seen is remembered. A different one is + /// only reported: its lines must not mix with the old ones, and whether + /// the old ones go is the user's call ([`Store::accept_host`]). + pub fn check_host(&self, conn: &str, identity: &str) -> Result { let db = self.db.lock().unwrap(); let known: Option = db.query_row("SELECT identity FROM hosts WHERE conn = ?1", params![conn], |r| r.get(0)).optional()?; - if known.as_deref() == Some(identity) { - return Ok(false); + match known { + Some(k) if k == identity => Ok(HostCheck::Same), + Some(_) => Ok(HostCheck::Changed), + None => { + db.execute("INSERT INTO hosts (conn, identity) VALUES (?1, ?2)", params![conn, identity])?; + Ok(HostCheck::First) + } } - let changed = known.is_some(); - drop(db); - if changed { + } + + /// Makes `identity` the host of a connection. With `keep_old` false the + /// lines from the host before are deleted first. + pub fn accept_host(&self, conn: &str, identity: &str, keep_old: bool) -> Result<()> { + if !keep_old { self.clear(Some(conn))?; } self.db.lock().unwrap().execute( "INSERT INTO hosts (conn, identity) VALUES (?1, ?2) ON CONFLICT (conn) DO UPDATE SET identity = excluded.identity", params![conn, identity], )?; - Ok(changed) + Ok(()) } pub fn stats(&self) -> Result { @@ -1037,7 +1136,12 @@ impl Store { pub fn clear(&self, conn: Option<&str>) -> Result<()> { // First, so lines a collector is reading right now are not stored // after the clear with a cursor that would skip everything before them. - self.epoch.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + // Under the write lock: a batch being stored right now either lands + // before this and is deleted below, or sees the new epoch and is dropped. + { + let _db = self.db.lock().unwrap(); + self.epoch.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + } let deleted = self.delete_entries(conn, None)?; if deleted > 0 { self.merge_index()?; @@ -1493,7 +1597,10 @@ fn row_to_entry(r: &rusqlite::Row<'_>) -> rusqlite::Result { } fn csv(s: &str) -> String { - if s.contains([',', '"', '\n', '\r']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() } + // A cell starting like a formula is run by spreadsheet apps when the file + // is opened; a log line must never do that, so it starts with ' instead. + let s = if s.starts_with(['=', '+', '-', '@', '\t', '\r']) { format!("'{s}") } else { s.to_string() }; + if s.contains([',', '"', '\n', '\r']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s } } #[cfg(test)] @@ -1659,6 +1766,87 @@ mod tests { assert!(keys(&all, 10).is_empty()); } + #[test] + fn csv_cells_never_start_a_formula() { + assert_eq!(csv("=HYPERLINK(\"http://x\")"), "\"'=HYPERLINK(\"\"http://x\"\")\""); + assert_eq!(csv("-1 retries left"), "'-1 retries left"); + assert_eq!(csv("@admin logged in"), "'@admin logged in"); + assert_eq!(csv("plain, text"), "\"plain, text\""); + assert_eq!(csv("ok"), "ok"); + } + + #[test] + fn deploys_are_found_among_many_other_events() { + let store = Store::open_in_memory().unwrap(); + let ev = |key: String, ts: i64, kind: Kind| Event { + key, + ts, + stack: "demo".into(), + service: "demo_app".into(), + replica: "1".into(), + node: "box".into(), + source: "c0".into(), + kind, + exit_code: None, + detail: None, + }; + let mut batch = vec![ev("deploy".into(), 10, Kind::Deploy)]; + batch.extend((0..6000).map(|i| ev(format!("crash{i}"), 100 + i, Kind::Crash))); + store.insert_events("c1", &batch).unwrap(); + let all = LogFilter { from: 0, to: i64::MAX, ..Default::default() }; + let deploys = store.events_of("c1", &all, &[Kind::Deploy], 50).unwrap(); + assert_eq!(deploys.iter().map(|e| e.key.as_str()).collect::>(), ["deploy"]); + assert_eq!(store.events_of("c1", &all, &[], 10).unwrap().len(), 10); + } + + #[test] + fn a_batch_read_before_a_clear_is_not_stored_after_it() { + let store = Store::open_in_memory().unwrap(); + let epoch = store.epoch(); + assert!(store.store_batch("c1", &src("shop_api", "1"), &entries(&[(10, "before")]), Some(10), epoch).unwrap()); + // "Clear stored logs" while a read of the same source is under way. + store.clear(Some("c1")).unwrap(); + let stored = store.store_batch("c1", &src("shop_api", "1"), &entries(&[(20, "read before the clear")]), Some(20), epoch).unwrap(); + assert!(!stored); + let all = LogFilter { from: 0, to: 100, ..Default::default() }; + assert_eq!(store.query("c1", &all, 1, 10).unwrap().entries.len(), 0); + // No cursor either, so the next read starts over and fills the range again. + assert_eq!(store.cursor("c1", "shop_api-1").unwrap(), None); + assert_eq!(store.store_events("c1", &[], Some(20), epoch).unwrap(), None); + assert_eq!(store.cursor("c1", crate::events::CURSOR).unwrap(), None); + } + + #[test] + fn a_chatty_connection_does_not_push_out_a_quiet_one() { + assert_eq!(over_share(&[("a".into(), 900), ("b".into(), 100)], 500), [("a".to_string(), 500)]); + assert_eq!(over_share(&[("a".into(), 900), ("b".into(), 300)], 400), [("b".to_string(), 100), ("a".to_string(), 700)]); + let dir = std::env::temp_dir().join(format!("stackline-fair-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let store = Store::open(&dir.join("logs.db")).unwrap(); + // The quiet connection logged first, long ago; the chatty one fills the store. + store.insert("quiet", &src("db", "1"), &entries(&[(1, "the quiet server's oldest line")])).unwrap(); + let mut t = 1_000; + for round in 0..40 { + let lines: Vec<(i64, String)> = (0..2000) + .map(|i| { + t += 1; + (t, format!("GET /api/items/{round}-{i} 200 id={:016x}", (round * 2000 + i) as u64 * 2654435761)) + }) + .collect(); + let refs: Vec<(i64, &str)> = lines.iter().map(|(t, l)| (*t, l.as_str())).collect(); + store.insert("chatty", &src("api", "1"), &entries(&refs)).unwrap(); + } + store.retain(0, 2 << 20).unwrap(); + let all = LogFilter { from: 0, to: i64::MAX, ..Default::default() }; + let quiet = store.query("quiet", &all, 1, 10).unwrap(); + assert_eq!(quiet.entries.len(), 1, "the quiet server lost its history"); + let chatty = store.query("chatty", &all, 1, 1).unwrap(); + assert!(chatty.total < 80_000 && chatty.entries[0].ts == t, "the chatty server keeps its newest lines"); + drop(store); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn picked_stacks_and_services_combine_with_or() { let store = Store::open_in_memory().unwrap(); @@ -1756,7 +1944,7 @@ mod tests { } #[test] - fn a_connection_that_reaches_another_host_drops_the_old_lines() { + fn another_host_keeps_the_old_lines_until_the_user_decides() { let store = Store::open_in_memory().unwrap(); let now = 1_700_000_000_000_000i64; let s1 = src("shop_api", "1"); @@ -1764,17 +1952,30 @@ mod tests { store.insert("c2", &s1, &entries(&[(now - 1_000_000, "2026-10-27 ERROR boom")])).unwrap(); store.set_cursor("c1", &s1.key, now).unwrap(); let all = LogFilter { from: 0, to: now, ..Default::default() }; + let total = |c: &str| store.query(c, &all, 1, 10).unwrap().total; // The first host seen keeps what is stored. - assert!(!store.bind_host("c1", "engine:A").unwrap()); - assert!(!store.bind_host("c1", "engine:A").unwrap()); - assert_eq!(store.query("c1", &all, 1, 10).unwrap().total, 1); - - // Another host under the same connection: its lines start from scratch. - assert!(store.bind_host("c1", "engine:B").unwrap()); - assert_eq!(store.query("c1", &all, 1, 10).unwrap().total, 0); + assert_eq!(store.check_host("c1", "engine:A").unwrap(), HostCheck::First); + assert_eq!(store.check_host("c1", "engine:A").unwrap(), HostCheck::Same); + assert_eq!(total("c1"), 1); + + // Another host under the same connection: reported, nothing deleted. + assert_eq!(store.check_host("c1", "engine:B").unwrap(), HostCheck::Changed); + assert_eq!(store.check_host("c1", "engine:B").unwrap(), HostCheck::Changed); + assert_eq!(total("c1"), 1); + + // "Keep them": the new host is remembered, the old lines stay. + store.accept_host("c1", "engine:B", true).unwrap(); + assert_eq!(store.check_host("c1", "engine:B").unwrap(), HostCheck::Same); + assert_eq!(total("c1"), 1); + + // "Delete them": lines and cursors of that connection go, others stay. + assert_eq!(store.check_host("c1", "engine:C").unwrap(), HostCheck::Changed); + store.accept_host("c1", "engine:C", false).unwrap(); + assert_eq!(store.check_host("c1", "engine:C").unwrap(), HostCheck::Same); + assert_eq!(total("c1"), 0); assert_eq!(store.cursor("c1", &s1.key).unwrap(), None); - assert_eq!(store.query("c2", &all, 1, 10).unwrap().total, 1, "other connections are untouched"); + assert_eq!(total("c2"), 1, "other connections are untouched"); } #[test] diff --git a/crates/stackline/src/api.rs b/crates/stackline/src/api.rs index c116405..7e111dc 100644 --- a/crates/stackline/src/api.rs +++ b/crates/stackline/src/api.rs @@ -1,5 +1,7 @@ -//! JSON API used by the UI. Every Docker access behind it is read-only; the -//! only things written are Stackline's own settings and log exports. +//! JSON API used by the UI. Every Docker access behind it is a read, except +//! `POST /c/{id}/action`, which only works on a connection whose read-only +//! mode was switched off (`PUT /c/{id}/access`). Besides that, the only things +//! written are Stackline's own settings and log exports. use std::sync::Arc; @@ -49,7 +51,10 @@ pub fn router() -> Router> { .route("/connections/{id}", put(update_connection).delete(delete_connection)) .route("/c/{id}/snapshot", get(snapshot)) .route("/c/{id}/pins", put(put_pins)) + .route("/c/{id}/access", put(put_access)) + .route("/c/{id}/action", post(action)) .route("/c/{id}/collect", put(put_collect)) + .route("/c/{id}/host", post(accept_host)) .route("/c/{id}/usage", get(usage)) .route("/c/{id}/usage/clear", post(clear_usage)) .route("/c/{id}/logs", get(logs)) @@ -76,6 +81,7 @@ async fn meta(st: St) -> Json { "desktop": st.desktop, "storePath": st.manager.store_path().display().to_string(), "platform": std::env::consts::OS, + "configWarning": st.manager.config_warning, })) } @@ -130,6 +136,10 @@ async fn update_connection(st: St, Path(id): Path, Json(input): Json) -> Result) -> Result, ApiErro let mut cfg = st.manager.config(); let pins = cfg.pins.remove(&id).unwrap_or_default(); let collect = cfg.collect.remove(&id).unwrap_or_default(); - Ok(Json(json!({ "state": rt.state(), "snapshot": rt.snapshot().as_deref(), "pins": pins, "collect": collect }))) + Ok(Json( + json!({ "state": rt.state(), "snapshot": rt.snapshot().as_deref(), "pins": pins, "collect": collect, "access": st.manager.access(&id) }), + )) +} + +#[derive(Deserialize)] +struct AccessInput { + writable: bool, +} + +/// Switches a connection's read-only mode off or on. +async fn put_access(st: St, Path(id): Path, Json(input): Json) -> Result, ApiError> { + st.manager.runtime(&id).ok_or_else(not_found)?; + let a = st.manager.set_writable(&id, input.writable).map_err(|e| ApiError(StatusCode::FORBIDDEN, format!("{e:#}")))?; + tracing::info!(connection = %id, writable = a.writable, "read-only mode changed"); + Ok(Json(json!(a))) +} + +/// Restarts, stops, starts or scales a service. Refused while the connection is read-only. +async fn action(st: St, Path(id): Path, Json(req): Json) -> Result, ApiError> { + let rt = st.manager.runtime(&id).ok_or_else(not_found)?; + let access = st.manager.access(&id); + let host = st.manager.config().writable.get(&id).cloned(); + let (true, Some(host)) = (access.writable, host) else { + let why = if access.forced { + "Stackline was started read-only." + } else { + "This connection is read-only. Switch read-only off to make changes." + }; + return Err(ApiError(StatusCode::FORBIDDEN, why.into())); + }; + let plan = rt.act(&req, &host, &st.manager.config().settings, &st.manager.store).await.map_err(|e| { + let code = if e.is::() { StatusCode::CONFLICT } else { StatusCode::BAD_REQUEST }; + ApiError(code, format!("{e:#}")) + })?; + // A dry run says what would happen; `done` is only sent once it happened. + if req.dry { + return Ok(Json(json!({ "command": plan.command, "calls": plan.calls.len(), "plan": plan.fingerprint() }))); + } + Ok(Json(json!({ "done": plan.done, "command": plan.command }))) } /// Replaces the pinned services of a connection. Each pin is `stack/service`. @@ -197,6 +247,24 @@ async fn put_pins(st: St, Path(id): Path, Json(pins): Json>) Ok(Json(clean)) } +#[derive(Deserialize)] +struct HostInput { + /// Keep the lines stored from the host before, next to the new ones. + keep: bool, +} + +/// Answers the question the UI asks when a connection reaches another host +/// than its stored lines come from: delete those lines, or keep them. +async fn accept_host(st: St, Path(id): Path, Json(input): Json) -> Result { + let rt = st.manager.runtime(&id).ok_or_else(not_found)?; + let identity = rt.identity().ok_or_else(|| ApiError(StatusCode::SERVICE_UNAVAILABLE, "Not connected yet.".into()))?; + let store = st.manager.store.clone(); + // Deleting a large store takes a while; keep it off the async workers. + tokio::task::spawn_blocking(move || store.accept_host(&id, &identity, input.keep)).await.map_err(|e| anyhow::anyhow!(e))??; + rt.host_accepted(); + Ok(StatusCode::NO_CONTENT) +} + /// Chooses which services of a connection have their logs collected. async fn put_collect(st: St, Path(id): Path, Json(c): Json) -> Result, ApiError> { if !st.manager.config().connections.iter().any(|x| x.id == id) { @@ -313,8 +381,7 @@ async fn deploys(st: St, Path(id): Path, Query(p): Query) -> let store = st.manager.store.clone(); let now = chrono::Utc::now().timestamp_micros(); let out = tokio::task::spawn_blocking(move || -> anyhow::Result> { - let mut events: Vec<_> = - store.events(&id, &f, 5000)?.into_iter().filter(|e| e.kind == stackline_core::events::Kind::Deploy).collect(); + let mut events: Vec<_> = store.events_of(&id, &f, &[stackline_core::events::Kind::Deploy], 50)?; events.sort_by_key(|e| std::cmp::Reverse(e.ts)); events.truncate(50); let mut next: std::collections::HashMap = Default::default(); diff --git a/crates/stackline/src/lib.rs b/crates/stackline/src/lib.rs index c0e3b2d..8400ac8 100644 --- a/crates/stackline/src/lib.rs +++ b/crates/stackline/src/lib.rs @@ -44,6 +44,8 @@ pub struct AppState { pub token: String, pub desktop: bool, pub allowed_hosts: Vec, + /// Also answer any `Host` that is an IP address with this port. + pub ip_hosts: Option, pub updater: Arc, } @@ -71,6 +73,10 @@ pub async fn start(opts: ServeOptions) -> Result { if !addr.ip().is_loopback() { allowed_hosts.push(addr.to_string()); } + // Listening beyond this computer (`--host`): other machines address it by + // one of its IP addresses, which cannot be known in advance for 0.0.0.0. + // Names stay refused, which is what keeps DNS rebinding out. + let ip_hosts = (!addr.ip().is_loopback()).then_some(addr.port()); manager.open_all(); let updater = Updater::new(opts.installer); updater.spawn_checks(manager.clone()); @@ -79,6 +85,7 @@ pub async fn start(opts: ServeOptions) -> Result { token: token.clone(), desktop: opts.desktop, allowed_hosts, + ip_hosts, updater: updater.clone(), }); let app = web::router(state); diff --git a/crates/stackline/src/main.rs b/crates/stackline/src/main.rs index 68f659b..7a1a1d7 100644 --- a/crates/stackline/src/main.rs +++ b/crates/stackline/src/main.rs @@ -6,7 +6,7 @@ use clap::{Parser, Subcommand}; use stackline::{ServeOptions, start, update}; use stackline_core::config::Paths; -/// A read-only dashboard for Docker Swarm and Compose stacks. +/// A dashboard for Docker Swarm and Compose stacks, read-only unless you switch it off per connection. #[derive(Parser)] #[command(version, about)] struct Cli { @@ -30,6 +30,10 @@ enum Command { /// Keep config and logs in this folder instead of the default locations. #[arg(long)] data_dir: Option, + /// Never change anything, whatever a connection's read-only switch says. + /// The same as STACKLINE_READ_ONLY=1. + #[arg(long)] + read_only: bool, }, } @@ -43,10 +47,14 @@ async fn main() -> Result<()> { // file has been swapped and the new one must be started from this path. let exe = std::env::current_exe()?; let restarted = stackline_core::config::env_var("RESTARTED").is_some(); - let (port, host, no_open, data_dir) = match cli.command { - Some(Command::Serve { port, host, no_open, data_dir }) => (port, host, no_open, data_dir), - None => (7070, IpAddr::from([127, 0, 0, 1]), false, None), + let (port, host, no_open, data_dir, read_only) = match cli.command { + Some(Command::Serve { port, host, no_open, data_dir, read_only }) => (port, host, no_open, data_dir, read_only), + None => (7070, IpAddr::from([127, 0, 0, 1]), false, None, false), }; + // A restart after an update passes the same arguments, so this stays on. + if read_only { + stackline_core::runtime::force_read_only(); + } let paths = data_dir.as_deref().map(Paths::in_dir).unwrap_or_else(Paths::default_paths); let server = start(ServeOptions { addr: SocketAddr::new(host, port), paths, desktop: false, installer: None, notifier: None }).await?; let url = server.url(); @@ -54,6 +62,13 @@ async fn main() -> Result<()> { println!("Stackline {} is running at {url}", env!("CARGO_PKG_VERSION")); } else { println!("Stackline is running. Open this link to use it:\n\n {url}\n\nPress Ctrl+C to stop."); + if !host.is_loopback() { + println!( + "\nStackline listens beyond this computer ({host}). Other machines open it with this computer's IP address\n\ + in place of {}. The link carries the sign-in and travels unencrypted: share it only on a network you trust.", + server.addr.ip() + ); + } if !no_open { let _ = webbrowser::open(&url); } diff --git a/crates/stackline/src/web.rs b/crates/stackline/src/web.rs index d6cc314..b36a5a5 100644 --- a/crates/stackline/src/web.rs +++ b/crates/stackline/src/web.rs @@ -32,7 +32,8 @@ pub fn router(state: Arc) -> Router { /// Only answers requests addressed to this machine, and only API calls that carry the session token. async fn guard(State(st): State>, req: Request, next: Next) -> Response { let host = req.headers().get(header::HOST).and_then(|h| h.to_str().ok()).unwrap_or(""); - if !st.allowed_hosts.iter().any(|h| h == host) { + let ip_host = st.ip_hosts.is_some_and(|port| host.parse::().is_ok_and(|a| a.port() == port)); + if !ip_host && !st.allowed_hosts.iter().any(|h| h == host) { return (StatusCode::FORBIDDEN, "Stackline only answers on its own address").into_response(); } let path = req.uri().path().to_string(); diff --git a/crates/stackline/tests/api.rs b/crates/stackline/tests/api.rs index 5af22e3..9930e5f 100644 --- a/crates/stackline/tests/api.rs +++ b/crates/stackline/tests/api.rs @@ -30,14 +30,25 @@ impl Drop for App { } fn app() -> App { + app_with(None) +} + +/// An app listening beyond this computer (`--host`) on this port. +fn app_with(ip_hosts: Option) -> App { // A counter, because tests run in parallel and macOS clocks tick in microseconds. static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); let dir = std::env::temp_dir().join(format!("stackline-api-test-{}-{nanos}-{n}", std::process::id())); let manager = Manager::new(Paths::in_dir(&dir)).unwrap(); - let state = - Arc::new(AppState { manager, token: TOKEN.into(), desktop: false, allowed_hosts: vec![HOST.into()], updater: Updater::new(None) }); + let state = Arc::new(AppState { + manager, + token: TOKEN.into(), + desktop: false, + allowed_hosts: vec![HOST.into()], + ip_hosts, + updater: Updater::new(None), + }); App { router: router(state.clone()), state, dir } } @@ -109,6 +120,27 @@ async fn only_answers_on_its_own_address() { assert_eq!(send(&app, elsewhere).await.0, StatusCode::FORBIDDEN); } +#[tokio::test] +async fn listening_on_the_network_answers_ip_addresses_but_no_names() { + let at = |app: &App, host: &str| { + let r = Request::builder() + .uri("/api/meta") + .header(header::HOST, host) + .header(header::COOKIE, format!("stackline_token={TOKEN}")) + .body(Body::empty()) + .unwrap(); + let router = app.router.clone(); + async move { router.oneshot(r).await.unwrap().status() } + }; + let app = app_with(Some(7070)); + assert_eq!(at(&app, "192.168.1.20:7070").await, StatusCode::OK); + assert_eq!(at(&app, "[fd00::5]:7070").await, StatusCode::OK); + assert_eq!(at(&app, "192.168.1.20:8080").await, StatusCode::FORBIDDEN); + assert_eq!(at(&app, "attacker.example:7070").await, StatusCode::FORBIDDEN); + // On this computer only, an IP address of the network is refused like a name. + assert_eq!(at(&app_with(None), "192.168.1.20:7070").await, StatusCode::FORBIDDEN); +} + #[tokio::test] async fn the_link_with_the_token_signs_the_browser_in() { let app = app(); @@ -172,3 +204,43 @@ async fn deleting_a_connection_forgets_its_pins_and_its_collect_choice() { send(&app, req("POST", "/api/connections", Some(json!({ "name": "x", "kind": "ssh", "target": "-oProxyCommand=evil" })))).await; assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); } + +#[tokio::test] +async fn a_connection_is_read_only_until_switched_and_again_on_another_host() { + let app = app(); + let input = json!({ "name": "Prod EU", "kind": "ssh", "target": "deploy@203.0.113.10", "socket": "/var/run/docker.sock" }); + let (status, c) = send(&app, req("POST", "/api/connections", Some(input))).await; + assert_eq!(status, StatusCode::OK, "{c}"); + + // New connections are read-only: a change is refused before Docker is asked. + let restart = json!({ "action": "restart", "service": "shop_api" }); + let (status, body) = send(&app, req("POST", "/api/c/prod-eu/action", Some(restart.clone()))).await; + assert_eq!(status, StatusCode::FORBIDDEN, "{body}"); + assert!(body["error"].as_str().unwrap().contains("read-only"), "{body}"); + let (_, snap) = send(&app, req("GET", "/api/c/prod-eu/snapshot", None)).await; + assert_eq!(snap["access"], json!({ "writable": false, "forced": false })); + + // Changes are allowed for the server the connection reaches, so not before it is connected. + let (status, a) = send(&app, req("PUT", "/api/c/prod-eu/access", Some(json!({ "writable": true })))).await; + assert_eq!(status, StatusCode::FORBIDDEN, "{a}"); + assert!(a["error"].as_str().unwrap().contains("connected"), "{a}"); + assert!(app.state.manager.config().writable.is_empty()); + + // Allowed for a server, but the connection does not reach it (here: none): still refused. + app.state.manager.update_config(|c| drop(c.writable.insert("prod-eu".into(), "engine:A".into()))).unwrap(); + let (status, body) = send(&app, req("POST", "/api/c/prod-eu/action", Some(restart))).await; + assert_eq!(status, StatusCode::FORBIDDEN, "{body}"); + let (_, snap) = send(&app, req("GET", "/api/c/prod-eu/snapshot", None)).await; + assert_eq!(snap["access"]["writable"], false); + + // Pointing the connection at another host forgets the switch. + let other = json!({ "name": "Prod EU", "kind": "ssh", "target": "deploy@203.0.113.11", "socket": "/var/run/docker.sock" }); + let (status, _) = send(&app, req("PUT", "/api/connections/prod-eu", Some(other))).await; + assert_eq!(status, StatusCode::OK); + assert!(app.state.manager.config().writable.is_empty()); + + // So does deleting it, if it is ever added again under the same name. + app.state.manager.update_config(|c| drop(c.writable.insert("prod-eu".into(), "engine:A".into()))).unwrap(); + send(&app, req("DELETE", "/api/connections/prod-eu", None)).await; + assert!(app.state.manager.config().writable.is_empty()); +} diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 8b99975..fba7cd3 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -16,8 +16,8 @@ "targets": ["deb", "rpm", "appimage", "nsis", "msi", "dmg", "app"], "createUpdaterArtifacts": true, "category": "DeveloperTool", - "shortDescription": "Read-only dashboard for Docker Swarm and Compose stacks", - "longDescription": "Stackline shows nodes, stacks, services and logs of Docker Swarm and Docker Compose hosts, locally or over SSH. It only reads and never changes anything on the server.", + "shortDescription": "Dashboard for Docker Swarm and Compose stacks", + "longDescription": "Stackline shows nodes, stacks, services and logs of Docker Swarm and Docker Compose hosts, locally or over SSH. It is read-only by default; restarts, stops and scaling can be allowed per server, and each one is shown before it runs.", "copyright": "SnakeTech", "license": "Apache-2.0", "icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"], diff --git a/e2e/tests/changes.spec.ts b/e2e/tests/changes.spec.ts new file mode 100644 index 0000000..a2f6d2d --- /dev/null +++ b/e2e/tests/changes.spec.ts @@ -0,0 +1,104 @@ +import { expect, test } from '@playwright/test' +import { TOKEN } from '../playwright.config' +import { inspect, open, serviceRow } from './helpers' + +const headers = { 'x-stackline-token': TOKEN } + +// Whatever a test leaves behind, the connection is read-only again for the next one. +test.afterEach(async ({ request }) => { + await request.put('/api/c/local/access', { headers, data: { writable: false } }) +}) + +test('a connection refuses changes until read-only is switched off', async ({ request }) => { + const snap = await (await request.get('/api/c/local/snapshot', { headers })).json() + expect(snap.access).toEqual({ writable: false, forced: false }) + const r = await request.post('/api/c/local/action', { headers, data: { action: 'restart', service: 'demo_shop', dry: true } }) + expect(r.status()).toBe(403) + expect((await r.json()).error).toContain('read-only') +}) + +test('switching read-only off asks first, and every change shows its docker command before it runs', async ({ page }) => { + await open(page, 'services') + const badge = page.locator('header.top').getByRole('switch', { name: /Read-only/ }) + await expect(badge).toHaveAttribute('aria-checked', 'true') + const bar = page.getByLabel('Actions') + await serviceRow(page, 'demo/shop').click() + // While read-only, the lists offer no changes. + await expect(bar.getByRole('button', { name: /Restart/ })).toHaveCount(0) + + await badge.click() + const ask = page.getByRole('alertdialog') + await expect(ask).toContainText('Allow changes on This computer?') + // Cancelled: still read-only. + await page.keyboard.press('Escape') + await expect(ask).toHaveCount(0) + await expect(badge).toHaveAttribute('aria-checked', 'true') + + await badge.click() + await ask.getByRole('button', { name: 'Allow changes' }).click() + const open_ = page.locator('header.top').getByRole('switch', { name: /Changes allowed/ }) + await expect(open_).toHaveAttribute('aria-checked', 'false') + await expect(page.locator('.rl-foot')).toContainText('each one asks first') + + // `r` on the row asks to restart, naming the containers like `docker restart` would. + await page.locator('main.view').click({ position: { x: 5, y: 5 } }) + await page.keyboard.press('r') + await expect(ask).toContainText('Restart demo_shop?') + await expect(ask.locator('code')).toHaveText(/^docker restart \S+shop\S* \S+shop\S*$/) + await ask.getByRole('button', { name: 'Cancel' }).click() + await expect(ask).toHaveCount(0) + + // Stop on one container from the containers list. + await serviceRow(page, 'demo/shop').dblclick() + await expect(page).toHaveURL(/#tasks\?service=demo%2Fshop$/) + await page.getByRole('tab', { name: /^Running/ }).click() + await page.keyboard.press('j') + await bar.getByRole('button', { name: /Stop/ }).click() + await expect(ask.locator('code')).toHaveText(/^docker stop \S+$/) + await page.keyboard.press('Escape') + await expect(ask).toHaveCount(0) + + // Back to read-only with one click; the changes are gone from the lists. + await open_.click() + await expect(badge).toHaveAttribute('aria-checked', 'true') + await expect(bar.getByRole('button', { name: /Stop/ })).toHaveCount(0) +}) + +test('the inspector offers the changes too, while they are allowed', async ({ page, request }) => { + await request.put('/api/c/local/access', { headers, data: { writable: true } }) + const panel = await inspect(page, 'demo/worker') + await expect(panel.getByRole('group', { name: 'Change this service' })).toBeVisible() + await panel.getByRole('button', { name: 'Restart' }).click() + await expect(page.getByRole('alertdialog').locator('code')).toContainText('docker restart') + await page.keyboard.press('Escape') + await expect(page.getByRole('alertdialog')).toHaveCount(0) + // Escape closed only the dialog, not the inspector. + await expect(panel).toBeVisible() +}) + +test('the palette finds "allow changes", and the dialog keeps its keys after a click inside it', async ({ page }) => { + await open(page, 'services') + await page.keyboard.press(':') + await page.keyboard.type('allow changes') + await page.keyboard.press('Enter') + const ask = page.getByRole('alertdialog') + // The Enter that ran the command does not also confirm the dialog. + await expect(ask).toContainText('Allow changes on This computer?') + await expect(page.locator('header.top').getByRole('switch')).toHaveAttribute('aria-checked', 'true') + // A click on its text takes focus off the buttons; Escape still closes it. + await ask.locator('p').click() + await page.keyboard.press('Escape') + await expect(ask).toHaveCount(0) + await expect(page.locator('header.top').getByRole('switch')).toHaveAttribute('aria-checked', 'true') +}) + +test('a change runs only as confirmed', async ({ request }) => { + await request.put('/api/c/local/access', { headers, data: { writable: true } }) + const ask = { action: 'restart', service: 'demo_worker' } + const dry = await (await request.post('/api/c/local/action', { headers, data: { ...ask, dry: true } })).json() + expect(dry.command).toContain('docker restart') + expect(dry.plan).toBeTruthy() + // A plan made from another state of the service is refused before anything is sent. + const stale = await request.post('/api/c/local/action', { headers, data: { ...ask, expect: 'not-this-plan' } }) + expect(stale.status()).toBe(409) +}) diff --git a/e2e/tests/errors.spec.ts b/e2e/tests/errors.spec.ts index 1d310d0..10c0881 100644 --- a/e2e/tests/errors.spec.ts +++ b/e2e/tests/errors.spec.ts @@ -1,10 +1,8 @@ import { expect, test, type Page } from '@playwright/test' -import { clipped, open } from './helpers' +import { clipped, inspect } from './helpers' async function openErrors(page: Page, service: string) { - await open(page, 'stacks') - await page.getByRole('button', { name: new RegExp(`^Errors of .*${service}$`) }).click() - const drawer = page.getByRole('dialog') + const drawer = await inspect(page, `demo/${service}`, /^Errors/) await expect(drawer.getByRole('tab', { name: /Errors/ })).toHaveAttribute('aria-selected', 'true') await expect(drawer.locator('.ecard').first()).toBeVisible() return drawer @@ -40,7 +38,7 @@ for (const [service, width] of [['shop', 1400], ['worker', 1400], ['worker', 100 test('an error group opens its lines in Logs', async ({ page }) => { const drawer = await openErrors(page, 'shop') await drawer.locator('.ecard').first().getByRole('button', { name: /^Show all .* in Logs$/ }).click() - await expect(page.getByRole('dialog')).toHaveCount(0) + await expect(page.locator('.drawer')).toHaveCount(0) await expect(page).toHaveURL(/#logs\?.*g=[0-9a-f]+/) await expect(page.locator('.stream .row').first()).toBeVisible() }) diff --git a/e2e/tests/events.spec.ts b/e2e/tests/events.spec.ts index 0ff99a9..2270ee0 100644 --- a/e2e/tests/events.spec.ts +++ b/e2e/tests/events.spec.ts @@ -1,17 +1,15 @@ import { expect, test } from '@playwright/test' -import { open } from './helpers' +import { inspect } from './helpers' // `migrate` prints its lines and exits with 0 right after the demo starts. test('a service panel lists when its containers stopped, and opens the lines before', async ({ page }) => { - await open(page, 'stacks') - await page.getByRole('row', { name: /demo_migrate/ }).click() - const drawer = page.getByRole('dialog') - const list = drawer.locator('div', { has: page.getByRole('heading', { name: /Deploys, stops and crashes/ }) }) + const drawer = await inspect(page, 'demo/migrate', 'Events') + const list = drawer.locator('div', { has: page.getByRole('heading', { name: /Deploys, changes, stops and crashes/ }) }) const row = list.getByRole('row', { name: /exited · exit 0/ }).first() await expect(row).toBeVisible() await expect(row).toContainText('#1') // An exit on purpose is not a crash. - await expect(drawer.getByRole('heading', { name: /Deploys, stops and crashes/ })).not.toContainText(/\d+ crash/) + await expect(drawer.getByRole('heading', { name: /Deploys, changes, stops and crashes/ })).not.toContainText(/\d+ crash/) await row.getByRole('button', { name: 'Lines before' }).click() await expect(page.locator('.chiprow')).toContainText('demo_migrate') @@ -22,10 +20,8 @@ test('a service panel lists when its containers stopped, and opens the lines bef // The demo was deployed when the tests started: each service has a deploy marker. test('a deploy is listed with what it runs, and opens the lines after it', async ({ page }) => { - await open(page, 'stacks') - await page.getByRole('row', { name: /demo_shop/ }).click() - const drawer = page.getByRole('dialog') - const list = drawer.locator('div', { has: page.getByRole('heading', { name: /Deploys, stops and crashes/ }) }) + const drawer = await inspect(page, 'demo/shop', 'Events') + const list = drawer.locator('div', { has: page.getByRole('heading', { name: /Deploys, changes, stops and crashes/ }) }) const row = list.getByRole('row', { name: /deployed: image alpine:3.20/ }) await expect(row).toBeVisible() await expect(row).not.toContainText('#') diff --git a/e2e/tests/helpers.ts b/e2e/tests/helpers.ts index c5d43fa..7ca3747 100644 --- a/e2e/tests/helpers.ts +++ b/e2e/tests/helpers.ts @@ -9,7 +9,23 @@ export async function open(page: Page, view = 'overview') { /** Goes to another page with the sidebar, the way people do. */ export async function nav(page: Page, label: string | RegExp) { - await page.locator('nav.nav').getByRole('link', { name: label }).click() + await page.locator('aside.side').getByRole('link', { name: label }).click() +} + +/** The row of a service (`stack/name`) in the services list. */ +export const serviceRow = (page: Page, key: string) => page.locator(`.rlist tbody tr[data-key="${key}"]`) + +/** The inspector docked beside the list, showing the service with this full name. */ +export const inspector = (page: Page, name: string) => page.getByRole('complementary', { name }) + +/** Opens the services list and the inspector of one service, on a tab. */ +export async function inspect(page: Page, key: string, tab?: string | RegExp) { + await open(page, 'services') + await serviceRow(page, key).click() + const panel = inspector(page, key.replace('/', '_')) + await expect(panel).toBeVisible() + if (tab) await panel.getByRole('tab', { name: tab }).click() + return panel } /** Elements of `selector` whose content is wider or taller than their box shows. */ diff --git a/e2e/tests/logs.spec.ts b/e2e/tests/logs.spec.ts index 6a5cacd..392f319 100644 --- a/e2e/tests/logs.spec.ts +++ b/e2e/tests/logs.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test' -import { nav, open } from './helpers' +import { nav, open, serviceRow } from './helpers' test('a multi-line stack trace is one entry', async ({ page }) => { await open(page, 'logs') @@ -29,8 +29,10 @@ test('log filters stay when you go to another page and back', async ({ page }) = }) test('the source picked in Logs is kept after a reload', async ({ page }) => { - await open(page, 'stacks') - await page.getByRole('button', { name: /^Logs of .*worker$/ }).click() + await open(page, 'services') + await serviceRow(page, 'demo/worker').click() + // `o` opens the lines of the row on the Logs page. + await page.keyboard.press('o') await expect(page).toHaveURL(/#logs\?.*s=demo_worker/) const rows = page.locator('.stream .row .svc') await expect(rows.first()).toContainText('worker') @@ -39,17 +41,17 @@ test('the source picked in Logs is kept after a reload', async ({ page }) => { expect(await rows.allTextContents()).not.toContainEqual(expect.stringMatching(/shop|app|caddy/)) }) -test('the Stacks filters stay when you go to another page and back', async ({ page }) => { - await open(page, 'stacks') - await page.getByPlaceholder('Filter by service, image or stack').fill('shop') +test('the list filters stay when you go to another page and back', async ({ page }) => { + await open(page, 'services') + await page.getByPlaceholder('Filter by name, image, stack or node').fill('shop') await expect(page.getByRole('row', { name: /worker/ })).toHaveCount(0) await nav(page, 'Overview') - await nav(page, 'Stacks & services') - await expect(page.getByPlaceholder('Filter by service, image or stack')).toHaveValue('shop') + await nav(page, /^Services/) + await expect(page.getByPlaceholder('Filter by name, image, stack or node')).toHaveValue('shop') await expect(page.getByRole('row', { name: /worker/ })).toHaveCount(0) await page.reload() - await expect(page.getByPlaceholder('Filter by service, image or stack')).toHaveValue('shop') - await page.getByPlaceholder('Filter by service, image or stack').fill('') + await expect(page.getByPlaceholder('Filter by name, image, stack or node')).toHaveValue('shop') + await page.getByPlaceholder('Filter by name, image, stack or node').fill('') }) test('the address of the Logs page opens the same view in another tab', async ({ page, context }) => { diff --git a/e2e/tests/navigation.spec.ts b/e2e/tests/navigation.spec.ts new file mode 100644 index 0000000..b554df9 --- /dev/null +++ b/e2e/tests/navigation.spec.ts @@ -0,0 +1,78 @@ +import { expect, test } from '@playwright/test' +import { inspector, open, serviceRow } from './helpers' + +test('the keyboard drills from a stack to its services and tasks, and Back returns', async ({ page }) => { + await open(page, 'stacks') + const bar = page.getByLabel('Actions') + // Nothing is picked until the cursor moves, so no action can hit a row out of sight. + await expect(bar.getByRole('button', { name: /^Services/ })).toBeDisabled() + await page.keyboard.press('j') + await expect(bar).toContainText('demo') + await page.keyboard.press('Enter') + await expect(page).toHaveURL(/#services\?stack=demo$/) + await expect(page.locator('.scopechip')).toContainText('of stack demo') + // Pick `shop` with the filter, then go down to its replicas. + await page.keyboard.press('/') + await page.keyboard.type('shop') + await page.keyboard.press('Enter') + await expect(bar).toContainText('demo_shop') + await page.keyboard.press('Enter') + await expect(page).toHaveURL(/#tasks\?service=demo%2Fshop$/) + await expect(page.locator('.rlist tbody tr')).toHaveCount(2) + await page.goBack() + await expect(page).toHaveURL(/#services\?stack=demo$/) + // The list is as it was left: filter and cursor. + await expect(page.getByLabel('Filter services')).toHaveValue('shop') + await expect(page.locator('.rlist tbody tr.cur')).toHaveAttribute('data-key', 'demo/shop') + await page.getByLabel('Filter services').fill('') +}) + +test('a click inspects a service beside the list, and the inspector follows the cursor', async ({ page }) => { + await open(page, 'services') + await serviceRow(page, 'demo/shop').click() + await expect(inspector(page, 'demo_shop')).toBeVisible() + // The list stays usable next to it. + await expect(serviceRow(page, 'demo/worker')).toBeVisible() + await page.keyboard.press('j') + await expect(inspector(page, 'demo_worker')).toBeVisible() + await page.keyboard.press('Escape') + await expect(page.locator('.drawer')).toHaveCount(0) +}) + +test('l follows a service in the log workspace while you browse', async ({ page }) => { + await open(page, 'services') + await serviceRow(page, 'demo/shop').click() + await page.keyboard.press('Escape') + await page.keyboard.press('l') + const dock = page.getByRole('region', { name: 'Log workspace' }) + await expect(dock.getByRole('tab', { name: 'demo_shop' })).toHaveAttribute('aria-selected', 'true') + await expect(dock.locator('.stream .row').first()).toBeVisible() + // It stays open on other pages, and after a reload. + await page.keyboard.press('g') + await page.keyboard.press('o') + await expect(page).toHaveURL(/#overview$/) + await expect(dock.locator('.stream .row').first()).toBeVisible() + await page.reload() + await expect(dock.getByRole('tab', { name: 'demo_shop' })).toBeVisible() + await dock.getByRole('button', { name: 'Close demo_shop' }).click() + await expect(dock).toHaveCount(0) +}) + +test('the stack picker narrows every list, and : and ? open the palette and the keys', async ({ page }) => { + await open(page, 'tasks') + await page.locator('header.top').getByRole('button', { name: /^Stack/ }).click() + await page.getByRole('menuitemradio', { name: /demo/ }).click() + await expect(page.locator('.crumbs')).toContainText('demo') + await page.locator('header.top').getByRole('button', { name: /^Stack/ }).click() + await page.getByRole('menuitemradio', { name: /All stacks/ }).click() + + await page.keyboard.press(':') + await expect(page.getByRole('dialog', { name: 'Jump to' })).toBeVisible() + await page.keyboard.type('nodes') + await page.keyboard.press('Enter') + await expect(page).toHaveURL(/#nodes$/) + await page.keyboard.press('?') + await expect(page.getByRole('dialog', { name: 'Keys' })).toContainText('Follow its logs in the workspace below') + await page.keyboard.press('Escape') + await expect(page.getByRole('dialog', { name: 'Keys' })).toHaveCount(0) +}) diff --git a/e2e/tests/report.spec.ts b/e2e/tests/report.spec.ts index cfd9f19..4dc0b9e 100644 --- a/e2e/tests/report.spec.ts +++ b/e2e/tests/report.spec.ts @@ -1,12 +1,11 @@ import { expect, test } from '@playwright/test' -import { open } from './helpers' +import { inspect } from './helpers' test.use({ permissions: ['clipboard-read', 'clipboard-write'] }) test('a service panel copies a debug report as Markdown', async ({ page }) => { - await open(page, 'stacks') - await page.getByRole('row', { name: /demo_shop/ }).first().click() - await page.getByRole('dialog').getByRole('button', { name: 'Debug report' }).click() + const drawer = await inspect(page, 'demo/shop') + await drawer.getByRole('button', { name: 'Debug report' }).click() await expect(page.locator('.toast')).toContainText('Debug report copied') const md = await page.evaluate(() => navigator.clipboard.readText()) expect(md).toContain('# Debug report: demo_shop') diff --git a/ui/src/App.svelte b/ui/src/App.svelte index d82dae0..c771d82 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -1,10 +1,17 @@
- +
-

{title}

+
+ + +
- {connLine} + {#if app.snap && app.snap.stacks.length}{/if} {#if showUpdate && latest} {/if} - - -
-
- {#if app.fatal} -
{app.fatal}
- {:else if app.view === 'connections'} - - {:else if app.view === 'prefs'} - - {:else if app.conn?.status === 'error' && !app.snap} -
Cannot reach {app.active?.name ?? 'this connection'}.{app.conn.error}
-
Retrying every few seconds. Check the connection under Connections.
- {:else if !app.snap} -
Connecting to {app.active?.name ?? '…'}
- {:else} - {#if app.conn?.status === 'error'} -
Connection lost.{app.conn.error} Showing the last known state.
- {/if} - - {#key app.activeId} - {#if app.view === 'overview'} - {:else if app.view === 'nodes'} - {:else if app.view === 'stacks'} - {:else if app.view === 'logs'}{/if} - {/key} + {#if needsConn && app.snap} + {/if} -
+ + +
+
+
+ {#if app.meta?.configWarning && !configSeen} + + {/if} + {#if app.fatal} +
{app.fatal}
+ {:else if app.view === 'connections'} +

{title}

+ + {:else if app.view === 'prefs'} +

{title}

+ + {:else if app.conn?.status === 'error' && !app.snap} +
Cannot reach {app.active?.name ?? 'this connection'}.{app.conn.error}
+
Retrying every few seconds. Check the connection under Connections.
+ {:else if !app.snap} +
Connecting to {app.active?.name ?? '…'}
+ {:else} + {#if app.conn?.status === 'error'} +
Connection lost.{app.conn.error} Showing the last known state.
+ {/if} + {#if app.conn?.otherHost} + + {/if} + {#if !isList} +
+ +

{title}

+
+ {/if} + + {#key app.activeId} + {#if app.view === 'overview'} + {:else if app.view === 'stacks'} + {:else if app.view === 'services'} + {:else if app.view === 'tasks'} + {:else if app.view === 'nodes'} + {:else if app.view === 'logs'}{/if} + {/key} + {/if} +
+ {#if docked && app.drawer?.kind === 'service'} + {#key app.drawer.key}{/key} + {:else if docked && app.drawer?.kind === 'node'}{/if} +
+ {#if app.dock.tabs.length && app.snap}{/if} +
+
+ {status.text} + + + {#each hints as [k, what] (k)}{k}{what}{/each} + +
-{#if app.drawer?.kind === 'service'} - {#key app.drawer.key}{/key} -{:else if app.drawer?.kind === 'node'} -{:else if app.drawer?.kind === 'connection'} +{#if app.drawer?.kind === 'connection'} {:else if app.drawer?.kind === 'update'}{/if} {#if app.palette}{/if} +{#if app.help}{/if} +{#if app.ask}{#key app.ask}{/key}{/if} {#if app.toast}
{app.toast.text}
{/if} diff --git a/ui/src/app.css b/ui/src/app.css index eb1d521..fb2b0fa 100644 --- a/ui/src/app.css +++ b/ui/src/app.css @@ -314,6 +314,7 @@ button:disabled{opacity:.5;cursor:default} a{color:var(--accent)} .err-banner{display:flex;gap:12px;align-items:flex-start;padding:14px 18px;border-radius:var(--r);background:color-mix(in srgb,var(--err) 9%,var(--panel));border:1px solid color-mix(in srgb,var(--err) 30%,transparent);white-space:pre-wrap} .warn-banner{display:flex;gap:12px;align-items:flex-start;padding:12px 18px;border-radius:var(--r);background:color-mix(in srgb,var(--warn) 10%,var(--panel));border:1px solid color-mix(in srgb,var(--warn) 30%,transparent)} +.host-banner{align-items:center;flex-wrap:wrap;margin-bottom:12px} .center-empty{display:grid;place-items:center;gap:10px;padding:60px 20px;text-align:center;color:var(--muted)} .spinner{width:22px;height:22px;border-radius:50%;border:3px solid var(--line);border-top-color:var(--accent);animation:spin .8s linear infinite} @keyframes spin{to{transform:rotate(360deg)}} @@ -568,3 +569,175 @@ tr.editrow>td{background:var(--panel-2);padding:16px 18px} .usage .deleting{display:inline-flex;gap:6px;align-items:center;font-size:12.5px} .spinner.sm{width:12px;height:12px;border-width:2px} .stopped{font-size:12px;margin-left:8px;white-space:nowrap} + +/* ---- shell, laid out like Kubri: sidebar, top bar, page + docked inspector, log workspace, status bar ---- */ +.app{grid-template-columns:232px minmax(0,1fr);height:100dvh;overflow:hidden} +.side{padding:14px 10px 10px;gap:6px;overflow:hidden;min-height:0} +.brand{padding:2px 8px 8px;gap:9px;font-size:16px} +.brand .bname{flex:1} +.brand .ver{font-size:10.5px;color:var(--side-muted);font-weight:500} +.find input{width:100%;height:32px;border-radius:8px;border:1px solid var(--side-2);background:color-mix(in srgb,var(--side-2) 60%,transparent);color:var(--side-fg);padding:0 10px;font-size:13px;outline:0} +.find input::placeholder{color:var(--side-muted)} +.find input:focus{border-color:var(--accent)} +.side .nav{flex:1;min-height:0;overflow-y:auto;display:flex;flex-direction:column;gap:1px;margin:0 -4px;padding:0 4px} +.nav .grp-h{font-size:10.5px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:var(--side-muted);padding:14px 10px 5px;opacity:.85} +.nav a{padding:7px 10px} +.nav .cnt{background:var(--side-2);color:var(--side-muted);font-weight:600} +.nav .cnt.bad{background:var(--err);color:#fff} +.svc-link{all:unset;box-sizing:border-box;display:flex;align-items:center;gap:9px;padding:6px 10px;border-radius:8px;color:var(--side-muted);cursor:pointer;font-size:13px;min-width:0} +.svc-link:hover,.svc-link:focus-visible{background:var(--side-2);color:var(--side-fg)} +.svc-link.on{background:color-mix(in srgb,var(--accent) 16%,var(--side));color:#fff} +.svc-link .t{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0} +.svc-link .st{margin-left:auto;font-size:11px;opacity:.7;white-space:nowrap} +.svc-link .dot{width:7px;height:7px;box-shadow:none} +.side-foot{display:grid;gap:1px;border-top:1px solid var(--side-2);padding-top:8px;margin:0 -4px} +.side-foot a,.side-foot button{all:unset;box-sizing:border-box;display:flex;align-items:center;gap:10px;padding:7px 14px;border-radius:8px;color:var(--side-muted);cursor:pointer;font-weight:500;font-size:13.5px} +.side-foot a:hover,.side-foot button:hover,.side-foot a:focus-visible,.side-foot button:focus-visible{background:var(--side-2);color:var(--side-fg)} +.side-foot a[aria-current="page"]{background:color-mix(in srgb,var(--accent) 16%,var(--side));color:#fff} +.side-foot a[aria-current="page"] svg{color:var(--accent)} +.side-foot kbd{margin-left:auto;background:transparent;border-color:var(--side-2);color:var(--side-muted)} +.side-foot .upd{color:var(--accent)} +.main{min-height:0;height:100dvh} +.top{gap:8px;padding:8px 14px 8px 12px;min-height:56px} +.top .history{display:flex;gap:2px} +.sq{display:inline-grid;place-items:center;width:32px;height:32px;flex:none;border:1px solid transparent;border-radius:8px;background:none;color:var(--fg);padding:0;cursor:pointer} +.sq:hover{background:var(--panel-2)} +.sq.bordered{border-color:var(--line);background:var(--panel)} +.help-btn{font-weight:700;font-size:14px} +.divider{width:1px;height:26px;background:var(--line);flex:none} +.picker{display:inline-grid;grid-template-columns:auto 14px;grid-template-rows:auto auto;column-gap:8px;align-items:center;text-align:left;border:1px solid transparent;border-radius:8px;background:none;padding:3px 8px;cursor:pointer;min-width:0;max-width:240px} +.picker:hover,.picker[aria-expanded="true"]{background:var(--panel-2);border-color:var(--line)} +.picker .cap{grid-column:1;font-size:10.5px;color:var(--muted);line-height:1.2} +.picker .val{grid-column:1;font-weight:600;line-height:1.25;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.picker svg{grid-column:2;grid-row:1/3;color:var(--muted)} +.ro-badge{display:inline-flex;align-items:center;gap:6px;height:32px;padding:0 10px;border-radius:8px;border:1px solid transparent;font:inherit;font-size:12px;font-weight:600;color:var(--accent);background:color-mix(in srgb,var(--accent) 13%,var(--panel));white-space:nowrap;cursor:pointer} +.ro-badge:hover{border-color:color-mix(in srgb,var(--accent) 45%,transparent)} +.ro-badge.open{color:var(--warn);background:color-mix(in srgb,var(--warn) 14%,var(--panel))} +.ro-badge.open:hover{border-color:color-mix(in srgb,var(--warn) 50%,transparent)} +.ro-badge.forced{cursor:default} +.ask-dlg{width:min(520px,100%);display:flex;flex-direction:column} +.ask-dlg .c-body{display:grid;gap:12px;padding:18px 20px} +.ask-dlg h3{margin:0;font-size:16px} +.ask-dlg p{margin:0;color:var(--muted);line-height:1.5} +.ask-dlg .c-cmd{display:grid;gap:4px;font-size:12px} +.ask-dlg .c-cmd code{display:block;padding:8px 10px;border-radius:8px;background:var(--panel-2);border:1px solid var(--line);font-family:var(--mono);font-size:12.5px;color:var(--fg);overflow-wrap:anywhere} +.ask-dlg .c-num{display:flex;align-items:center;gap:10px;font-weight:600} +.ask-dlg .c-num input{width:90px;height:30px;padding:0 8px;border-radius:7px;border:1px solid var(--line);background:var(--panel);color:var(--fg);font:inherit} +.ask-dlg .c-num input[aria-invalid="true"]{border-color:var(--err)} +.ask-dlg .c-err{color:var(--err)} +.ask-dlg .c-foot{display:flex;align-items:center;gap:8px;padding:12px 16px;border-top:1px solid var(--line);background:var(--panel-2);font-size:12px} +.ask-dlg .c-foot .btn{font-size:13px} +.area{flex:1;min-height:0;display:flex;flex-direction:column} +.content{flex:1;min-height:0;display:grid;grid-template-columns:minmax(0,1fr)} +.content.with-inspector{grid-template-columns:minmax(0,1fr) min(clamp(420px,38vw,720px),calc(100% - 360px))} +.content.full{grid-template-columns:minmax(0,1fr)} +.content.full>.view{display:none} +.content>.view{min-height:0;padding:16px 20px 28px;gap:14px} +.content>.view>*{flex-shrink:0} +/* the inspector, docked beside the page */ +.drawer.docked{position:relative;inset:auto;width:auto;z-index:auto;box-shadow:none;border-left:1px solid var(--line);min-height:0;padding-top:0} +.drawer .dh .dtitle{flex:1;min-width:0;display:grid} +.drawer .dh .dtitle h3{flex:none} +.drawer .eyebrow{font-size:10.5px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--muted)} +.callout.warnish{all:unset;box-sizing:border-box;display:grid;gap:4px;padding:10px 12px;border-radius:8px;border:1px solid color-mix(in srgb,var(--warn) 40%,var(--line));background:color-mix(in srgb,var(--warn) 8%,var(--panel));cursor:pointer} +.callout.warnish .h{display:flex;gap:6px;align-items:center;font-weight:600;color:var(--warn)} +.callout.warnish:hover{border-color:var(--warn)} +/* page heads */ +.phead{display:grid;gap:2px} +.crumbs{display:flex;align-items:center;gap:6px;font-size:12.5px;color:var(--muted)} +.crumbs svg{opacity:.6} +.ptitle{display:flex;align-items:center;gap:10px;flex-wrap:wrap;min-height:34px} +.ptitle h2{margin:0;font-size:24px;font-weight:700;letter-spacing:-.02em;color:var(--fg);text-transform:none} +.ptitle .pcount{font-size:16px;color:var(--muted);font-variant-numeric:tabular-nums;font-weight:500} +.scopechip{height:26px} +.phead~.hint,.view>.hint{display:block;padding:0 4px;line-height:1.7} +.view>.hint svg{vertical-align:-2px;margin-right:4px} +/* lists */ +.rlist{display:flex;flex-direction:column;min-height:0} +.rl-filter{padding:12px 14px 8px} +.rl-filter .search{height:36px} +.rl-tabs{display:flex;gap:2px;padding:0 10px;border-bottom:1px solid var(--line);overflow-x:auto} +.rl-tabs button{border:0;background:none;padding:8px 8px;font-weight:600;font-size:13px;color:var(--muted);border-bottom:2px solid transparent;margin-bottom:-1px;cursor:pointer;white-space:nowrap;display:inline-flex;gap:6px;align-items:center} +.rl-tabs button[aria-selected="true"]{color:var(--fg);border-bottom-color:var(--accent)} +.rl-tabs .n{font-family:var(--mono);font-size:11px;padding:0 5px;border-radius:8px;background:var(--panel-2);color:var(--muted);font-weight:500} +.rl-tabs .n.bad{background:color-mix(in srgb,var(--err) 13%,transparent);color:var(--err)} +.rl-actions{display:flex;gap:6px;align-items:center;flex-wrap:wrap;padding:8px 14px;border-bottom:1px solid var(--line);min-height:44px} +.rl-actions .target{font-size:12.5px;color:var(--fg);max-width:min(340px,40%);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-right:4px} +.rl-actions .target.none{font-family:var(--sans);color:var(--muted)} +.rl-actions .btn kbd{margin-left:2px;font-size:10px;padding:0 4px} +.rl-actions .btn:disabled{opacity:.45;cursor:default} +.rl-actions .btn:disabled:hover{border-color:var(--line)} +.rlist th .sorter{all:unset;cursor:pointer;display:inline-flex;gap:4px;align-items:center} +.rlist th .sorter:hover{color:var(--fg)} +.rlist th .sorter:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:3px} +.rlist th .arrow{opacity:.5;font-size:10px} +.rlist th[aria-sort] .arrow{opacity:1;color:var(--accent)} +.rlist th.num,.rlist td.num{text-align:right} +.rlist td{padding:8px 12px} +.rlist tbody tr.cur td{background:color-mix(in srgb,var(--accent) 9%,var(--panel))} +.rlist tbody tr.cur td:first-child{box-shadow:inset 3px 0 0 var(--accent)} +.rlist tbody tr.inspected:not(.cur) td{background:color-mix(in srgb,var(--accent) 5%,var(--panel))} +.rlist td.clip{max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.rlist .state{font-size:12.5px;color:var(--muted)} +.rlist .state.ok{color:var(--ok)}.rlist .state.warn{color:var(--warn)}.rlist .state.err{color:var(--err)} +.rl-foot{display:flex;gap:8px;padding:8px 14px;border-top:1px solid var(--line);font-size:12px;color:var(--muted)} +.meterc{display:flex;gap:8px;align-items:center} +/* log workspace */ +.dock{flex:none;display:flex;flex-direction:column;border-top:1px solid var(--line);background:var(--panel);position:relative;min-height:0} +.dock.min{height:auto} +.dock-grip{position:absolute;top:-4px;left:0;right:0;height:8px;cursor:ns-resize;z-index:2} +.dock-grip:focus-visible{outline:2px solid var(--accent);outline-offset:-3px} +.dock.min .dock-grip{display:none} +.dock-tabs{display:flex;align-items:center;gap:2px;padding:0 8px;border-bottom:1px solid var(--line);background:var(--panel-2);min-height:36px;overflow-x:auto} +.dock.min .dock-tabs{border-bottom:0} +.dtab{display:inline-flex;align-items:center;border-right:1px solid var(--line);height:36px;flex:none} +.dtab>button[role=tab]{all:unset;display:inline-flex;align-items:center;gap:6px;padding:0 6px 0 12px;height:100%;cursor:pointer;font-family:var(--mono);font-size:12px;color:var(--muted);max-width:260px} +.dtab .t{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.dtab .x{all:unset;display:inline-grid;place-items:center;width:20px;height:20px;border-radius:5px;color:var(--muted);cursor:pointer;margin-right:6px} +.dtab .x:hover{background:var(--line);color:var(--fg)} +.dtab.on{background:var(--panel);box-shadow:inset 0 -2px 0 var(--accent)} +.dtab.on>button[role=tab]{color:var(--fg)} +.dock-body{flex:1;min-height:0;display:flex;flex-direction:column} +.dl-bar{display:flex;gap:6px;align-items:center;flex-wrap:wrap;padding:6px 10px;border-bottom:1px solid var(--line)} +.dl-bar .search{flex:1 1 200px;height:28px} +.lv.sm,.ctl.sm{height:28px;font-size:12px;padding:0 8px} +.fchip.static{cursor:default} +.dl-body{flex:1;min-height:0;overflow:auto} +.dl-body .stream .row{grid-template-columns:96px 52px 170px minmax(0,1fr)} +.dl-body .stream.nosvc .row{grid-template-columns:96px 52px minmax(0,1fr)} +.dl-body .stream .row .detail{grid-column:1/-1} +/* status bar */ +.statusbar{display:flex;align-items:center;gap:10px;padding:3px 14px;border-top:1px solid var(--line);background:var(--panel);font-size:12px;color:var(--muted);min-height:28px;white-space:nowrap;overflow:hidden} +.statusbar .status{display:inline-flex;align-items:center;gap:7px} +.statusbar .dot{width:7px;height:7px;box-shadow:none} +.statusbar .hints{display:flex;gap:12px;overflow:hidden} +.statusbar .hints span{display:inline-flex;gap:5px;align-items:center} +/* help */ +.help-ov{width:min(880px,calc(100% - 32px))} +.help-ov .in{gap:10px} +.help-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(360px,1fr));gap:4px 28px;padding:12px 18px 18px;max-height:70vh;overflow:auto} +.help-grid h4{margin:12px 0 6px;font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted)} +.help-grid dl{display:grid;grid-template-columns:128px minmax(0,1fr);gap:6px 12px;margin:0;font-size:13px} +.help-grid dt{display:flex;gap:3px;align-items:center;flex-wrap:wrap} +.help-grid dd{margin:0} +.help-grid .or{color:var(--muted);font-size:11px;padding:0 2px} +@media (max-width:1100px){ + .content.with-inspector{grid-template-columns:minmax(0,1fr)} + .drawer.docked{position:fixed;top:0;right:0;bottom:0;width:min(620px,100%);z-index:21;box-shadow:-20px 0 50px -20px rgba(0,0,0,.35)} +} +@media (max-width:760px){ + .app{grid-template-columns:minmax(0,1fr);grid-template-rows:auto minmax(0,1fr);height:100dvh} + .side{flex-direction:row;overflow-x:auto;padding:6px 12px;gap:4px} + .side .find,.side .brand .ver,.side .brand .bname,.nav .grp-h,.svc-link,.side-foot kbd,.side-foot .upd{display:none} + .side .nav{flex-direction:row;overflow:visible;flex:none} + .side-foot{display:flex;border-top:0;padding-top:0;margin:0} + .side-foot a span.t,.side-foot button span.t{display:none} + .main{height:auto;min-height:0} + .top .history,.divider{display:none} + .picker{max-width:140px} + .statusbar .hints{display:none} + .rl-actions .target.none,.rl-actions kbd{display:none} +} +.warn-t{color:var(--warn)} +.changes{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:8px 10px;border-radius:8px;border:1px dashed color-mix(in srgb,var(--warn) 45%,var(--line));background:color-mix(in srgb,var(--warn) 6%,var(--panel))} +.changes .muted{display:inline-flex;align-items:center;gap:5px;font-size:12px;margin-right:auto;color:var(--warn)} diff --git a/ui/src/components/ConfirmChange.svelte b/ui/src/components/ConfirmChange.svelte new file mode 100644 index 0000000..5fdff48 --- /dev/null +++ b/ui/src/components/ConfirmChange.svelte @@ -0,0 +1,95 @@ + + + + + +
{ if (e.target === e.currentTarget) close() }}> +
+
+

{ask.title}

+ {#if ask.text}

{ask.text}

{/if} + {#if ask.replicas !== undefined} + + {#if !valid}

Pick a whole number from 0 to 100.

{/if} + {/if} + {#if command} +
Same as{command}
+ {/if} + {#if error}{/if} +
+
+ Enter to confirm, Esc to cancel + + + +
+
+
diff --git a/ui/src/components/Dock.svelte b/ui/src/components/Dock.svelte new file mode 100644 index 0000000..5dbef11 --- /dev/null +++ b/ui/src/components/Dock.svelte @@ -0,0 +1,52 @@ + + +
+ + +
+ {#each D.tabs as t (t.id)} +
+ + +
+ {/each} + + {#if D.tabs.length > 1}{/if} + +
+ {#if !D.min && active} + {#key active.id + app.activeId}
{/key} + {/if} +
diff --git a/ui/src/components/DockLog.svelte b/ui/src/components/DockLog.svelte new file mode 100644 index 0000000..b5868a5 --- /dev/null +++ b/ui/src/components/DockLog.svelte @@ -0,0 +1,117 @@ + + +
+ + {#each LEVELS as l (l)} + + {/each} + + + {#if tab.q}{tab.q}{/if} + + +
+{#if error}
{error}
{/if} +
+
+ {#each pages.entries as e (e.id)} + F.toggleRow(e.id)} /> + {:else} +
{pages.head ? 'No lines stored that match.' : 'Reading lines…'}
+ {/each} + {#if pages.head && pages.entries.length} +
+ {#if pages.olderError} + Older lines could not be read: {pages.olderError} + {:else if pages.more && !pages.full} + + {pages.entries.length.toLocaleString()} of {pages.head.result.total.toLocaleString()} shown + {:else if pages.more} + The workspace shows at most {LINES.max.toLocaleString()} lines. Open in Logs for older ones. + {:else} + These are all the stored lines that match. + {/if} +
+ {/if} +
+
diff --git a/ui/src/components/Drawer.svelte b/ui/src/components/Drawer.svelte index c66e3ae..bdd04b2 100644 --- a/ui/src/components/Drawer.svelte +++ b/ui/src/components/Drawer.svelte @@ -1,31 +1,48 @@ - -
(app.drawer = null)}>
- + diff --git a/ui/src/components/HelpOverlay.svelte b/ui/src/components/HelpOverlay.svelte new file mode 100644 index 0000000..a64857d --- /dev/null +++ b/ui/src/components/HelpOverlay.svelte @@ -0,0 +1,65 @@ + + + +
{ if (e.target === e.currentTarget) app.help = false }}> + +
diff --git a/ui/src/components/NodeDrawer.svelte b/ui/src/components/NodeDrawer.svelte index 14aa6b4..4d89033 100644 --- a/ui/src/components/NodeDrawer.svelte +++ b/ui/src/components/NodeDrawer.svelte @@ -18,7 +18,7 @@ } - + {#if !n}
This node is gone.
{:else} @@ -27,7 +27,8 @@ {nodeRole(n)} {#if n.availability !== 'active'}{n.availability}{/if} - {#if app.snap && app.snap.nodes.length > 1}{/if} + + {#if app.snap && app.snap.nodes.length > 1}{/if}
Address
{n.addr || '–'}
diff --git a/ui/src/components/Palette.svelte b/ui/src/components/Palette.svelte index 91b5cbf..ad98d3f 100644 --- a/ui/src/components/Palette.svelte +++ b/ui/src/components/Palette.svelte @@ -1,5 +1,5 @@ + + + + + +
+ +
+

{title}

{shown.length} + {#if scope} + + {/if} + + {@render toolbar?.()} +
+
+ +{@render before?.()} + +
+
+ +
+
+ {#each tabs as t (t.id)} + + {/each} +
+
+ {#if cur} + {label(cur)} + {:else} + Pick a row with j k or a click + {/if} + {#if onopen}{/if} + {#if oninspect}{/if} + {#each barActs as a (a.key)} + {@const ok = !!cur && (!a.when || a.when(cur))} + + {/each} +
+
+ + + + {#each cols as c (c.id)} + + {/each} + + + + {#each shown as r (key(r))} + click(r)} ondblclick={() => onopen?.(r)} aria-selected={cur === r}> + {@render row(r)} + + {:else} + + {/each} + +
+ {#if c.sort} + + {:else}{c.label}{/if} +
+ {needle || tab.id !== tabs[0].id ? `No ${noun[1]} match.` : empty} + {#if needle || tab.id !== tabs[0].id}{/if} +
+
+
+ {shown.length === items.length ? `${items.length} ${items.length === 1 ? noun[0] : noun[1]}` : `${shown.length} of ${items.length} ${noun[1]}`} + + {#if app.access.writable}Changes allowed: each one asks first + {:else}Read-only: nothing here changes your containers{/if} +
+
diff --git a/ui/src/components/ServiceDrawer.svelte b/ui/src/components/ServiceDrawer.svelte index e6de4c5..d51b807 100644 --- a/ui/src/components/ServiceDrawer.svelte +++ b/ui/src/components/ServiceDrawer.svelte @@ -1,10 +1,10 @@ - + {#snippet actions()} - {/snippet} {#snippet sub()} {#if s} {@const [cls] = svcState(s)} - {s.running}/{s.desired}{s.mode}{s.kind} + {s.running}/{s.desired}{s.mode}{#if s.kind !== s.mode}{s.kind}{/if} {#if s.updatedAt}updated {ago(s.updatedAt, app.now)}{/if} {/if} {/snippet} {#snippet tabs()} - {#each [['overview', 'Overview'], ['logs', 'Logs'], ['errors', 'Errors']] as [k, label] (k)} - + {#each [['overview', 'Overview'], ['events', 'Events'], ['config', 'Config'], ['logs', 'Logs'], ['errors', 'Errors']] as [k, label] (k)} + {/each} {/snippet} {#snippet foot()} - Read-only. Nothing here can restart or change this service. - {#if s}Collect logs{/if} + + {#if s}{/if} {#if s}{/if} {/snippet} @@ -225,6 +219,15 @@ {#if !s}
This service is no longer running on {app.active?.name}.
{:else if tab === 'overview'} + {#if app.access.writable} +
+ Changes allowed + {#if can.restart(s)}{/if} + {#if can.stop(s)}{/if} + {#if can.start(s)}{/if} + {#if can.scale(s)}{/if} +
+ {/if} {#if failing} {@const t = s.tasks.find((t) => t.state !== 'running')} {@const fails = s.tasks.reduce((a, t) => a + t.failures, 0)} @@ -267,35 +270,15 @@ {#if d.newErrors.length > 3}and {d.newErrors.length - 3} more{/if} {/if} - {#if notable.length} -
-

Deploys, stops and crashes · last 24 h{#if crashes}{' · '}{crashes} {crashes === 1 ? 'crash' : 'crashes'}{/if}

-
- - - {#each notable.slice(0, 8) as e (e.ts + e.source + e.kind)} - - - - - - - {/each} - -
{when(e.ts)}{e.kind === 'deploy' ? '' : `#${e.replica}`}{eventLabel(e)} - {#if e.kind === 'deploy'} - - {:else} - - {/if} -
-
- {#if notable.length > 8}and {notable.length - 8} earlier{/if} -
+ {#if crashes} + {/if} {@const d = details}
-

Service

+

At a glance

Image
{d?.image || s.image}
Ports
{(d?.ports.length ? d.ports : s.ports).join(', ') || '–'}
@@ -314,31 +297,6 @@
{#if detailsError && !d}More details could not be read: {detailsError}{/if}
- {#if d && (d.entrypoint || d.command || d.workdir || d.user)} -
-

Command

-
- {#if d.entrypoint}
Entrypoint
{d.entrypoint}
{/if} - {#if d.command}
Command
{d.command}
{/if} - {#if d.workdir}
Working dir
{d.workdir}
{/if} - {#if d.user}
User
{d.user}
{/if} - {#if d.hostname}
Hostname
{d.hostname}
{/if} -
-
- {/if} - {#if d?.healthcheck} -
-

Health check

-
Runs
{d.healthcheck}
- {#if d.healthLog.length} -
- {#each d.healthLog as h, i (i)} -
{h.at ? hhmm(h.at) : '–'}{h.exitCode === 0 ? 'ok' : `exit ${h.exitCode}`}
{h.output || '(no output)'}
- {/each} -
- {/if} -
- {/if}

{s.kind === 'swarm' ? 'Tasks' : 'Containers'}

@@ -366,8 +324,66 @@
- {#if details} - {@const d = details} + {:else if tab === 'events'} +
+

Errors & warnings · {win.to - win.from > 5.9 * 3600e3 ? 'last 6h' : `since ${hhmm(win.from)}`}

+ {#if hist}
{/if} +
+ {#if notable.length} +
+

Deploys, changes, stops and crashes · last 24 h{#if crashes}{' · '}{crashes} {crashes === 1 ? 'crash' : 'crashes'}{/if}

+
+ + + {#each notable as e (e.ts + e.source + e.kind)} + + + + + + + {/each} + +
{when(e.ts)}{isServiceWide(e) ? '' : `#${e.replica}`}{eventLabel(e)} + {#if e.kind === 'deploy' || e.kind === 'action'} + + {:else} + + {/if} +
+
+
+ {/if} + {#if !notable.length}
No deploys, changes, stops or crashes of this service in the last 24 hours.
{/if} + {:else if tab === 'config'} + {@const d = details} + {#if !d}
{detailsError ? `The configuration could not be read: ${detailsError}` : 'Reading the configuration…'}
{/if} + {#if d && (d.entrypoint || d.command || d.workdir || d.user)} +
+

Command

+
+ {#if d.entrypoint}
Entrypoint
{d.entrypoint}
{/if} + {#if d.command}
Command
{d.command}
{/if} + {#if d.workdir}
Working dir
{d.workdir}
{/if} + {#if d.user}
User
{d.user}
{/if} + {#if d.hostname}
Hostname
{d.hostname}
{/if} +
+
+ {/if} + {#if d?.healthcheck} +
+

Health check

+
Runs
{d.healthcheck}
+ {#if d.healthLog.length} +
+ {#each d.healthLog as h, i (i)} +
{h.at ? hhmm(h.at) : '–'}{h.exitCode === 0 ? 'ok' : `exit ${h.exitCode}`}
{h.output || '(no output)'}
+ {/each} +
+ {/if} +
+ {/if} + {#if d} {#if d.networks.length || d.mounts.length}

Networks and storage

@@ -406,10 +422,6 @@ {/if} {/if} -
-

Errors & warnings · {win.to - win.from > 5.9 * 3600e3 ? 'last 6h' : `since ${hhmm(win.from)}`}

- {#if hist}
{/if} -
{:else if tab === 'logs'}
diff --git a/ui/src/components/Sidebar.svelte b/ui/src/components/Sidebar.svelte new file mode 100644 index 0000000..c960edb --- /dev/null +++ b/ui/src/components/Sidebar.svelte @@ -0,0 +1,108 @@ + + + diff --git a/ui/src/components/StackPicker.svelte b/ui/src/components/StackPicker.svelte new file mode 100644 index 0000000..266b051 --- /dev/null +++ b/ui/src/components/StackPicker.svelte @@ -0,0 +1,39 @@ + + + +{#if open && btn} + (open = false)}> +
Show the lists for
+ + {#each stacks as st (st.name)} + {@const s = stackState(st.services)} + + {/each} +
+{/if} diff --git a/ui/src/lib/changes.ts b/ui/src/lib/changes.ts new file mode 100644 index 0000000..92601d4 --- /dev/null +++ b/ui/src/lib/changes.ts @@ -0,0 +1,63 @@ +// The changes a list or the inspector offers once a connection allows them: +// restart, stop, start and scale. Each one asks first (see `app.act`). + +import type { Act } from '../components/ResourceList.svelte' +import { app } from './state.svelte' +import type { ServiceRef, Task } from './types' + +const swarm = (s: ServiceRef) => s.kind === 'swarm' +// The same container states the server acts on (see `actions.rs`). +const stoppable = (t: Task) => !!t.containerId && ['running', 'restarting', 'paused'].includes(t.state) +const startable = (t: Task) => !!t.containerId && ['exited', 'created'].includes(t.state) +const restartable = (t: Task) => !!t.containerId && !['dead', 'removing'].includes(t.state) +const req = (s: ServiceRef) => ({ service: s.fullName, stack: s.stack }) + +export function restart(s: ServiceRef) { + app.act({ action: 'restart', ...req(s) }, swarm(s) ? `Restart the tasks of ${s.fullName}?` : `Restart ${s.fullName}?`) +} +export function stop(s: ServiceRef) { + app.act({ action: 'stop', ...req(s) }, `Stop ${s.fullName}?`) +} +export function start(s: ServiceRef) { + app.act({ action: 'start', ...req(s) }, `Start ${s.fullName}?`) +} +export function scale(s: ServiceRef) { + app.act({ action: 'scale', ...req(s), replicas: s.desired }, `Scale ${s.fullName}`) +} + +/** Which of the changes fit a service. */ +export const can = { + restart: (s: ServiceRef) => swarm(s) || s.tasks.some(restartable), + stop: (s: ServiceRef) => !swarm(s) && s.tasks.some(stoppable), + start: (s: ServiceRef) => !swarm(s) && s.tasks.some(startable), + scale: (s: ServiceRef) => swarm(s) && s.mode === 'replicated', +} + +/** Leaves out the changes that fit none of the rows, such as Scale on a host without swarm. */ +const fitting = (rows: T[], acts: Act[]) => acts.filter((a) => rows.some((r) => !a.when || a.when(r))) + +/** The changes for rows of a list, offered only while the connection allows them. */ +export function serviceActs(rows: T[], of: (r: T) => ServiceRef): Act[] { + if (!app.access.writable) return [] + return fitting(rows, [ + { key: 'r', label: 'Restart', icon: 'restart', run: (r) => restart(of(r)), when: (r) => can.restart(of(r)) }, + { key: 'x', label: 'Stop', icon: 'stop', run: (r) => stop(of(r)), when: (r) => can.stop(of(r)) }, + { key: 'u', label: 'Start', icon: 'play', run: (r) => start(of(r)), when: (r) => can.start(of(r)) }, + { key: 'c', label: 'Scale', icon: 'scale', run: (r) => scale(of(r)), when: (r) => can.scale(of(r)) }, + ]) +} + +/** The changes for one container of a Compose service or a lone container. Swarm tasks go through their service. */ +export function taskActs(rows: T[], of: (r: T) => { s: ServiceRef; t: Task }): Act[] { + if (!app.access.writable) return [] + const one = (r: T, action: 'restart' | 'stop' | 'start', verb: string) => { + const { s, t } = of(r) + app.act({ action, ...req(s), replica: t.slot }, `${verb} ${t.containerName ?? `${s.fullName} #${t.slot}`}?`) + } + const container = (r: T, fits: (t: Task) => boolean) => !swarm(of(r).s) && fits(of(r).t) + return fitting(rows, [ + { key: 'r', label: 'Restart', icon: 'restart', run: (r) => (swarm(of(r).s) ? restart(of(r).s) : one(r, 'restart', 'Restart')), when: (r) => swarm(of(r).s) || container(r, restartable) }, + { key: 'x', label: 'Stop', icon: 'stop', run: (r) => one(r, 'stop', 'Stop'), when: (r) => container(r, stoppable) }, + { key: 'u', label: 'Start', icon: 'play', run: (r) => one(r, 'start', 'Start'), when: (r) => container(r, startable) }, + ]) +} diff --git a/ui/src/lib/dock.svelte.ts b/ui/src/lib/dock.svelte.ts new file mode 100644 index 0000000..ab0f21b --- /dev/null +++ b/ui/src/lib/dock.svelte.ts @@ -0,0 +1,85 @@ +// The log workspace at the bottom of the window: one tab per log source +// someone opened. The tabs keep following their lines while the rest of +// the app is used, and are remembered per connection. + +import { load, save } from './storage' + +export interface DockTab { + id: string + /** What the tab is named. */ + label: string + /** A full service name, `stack:` for a whole stack, or empty for every service. */ + source: string + /** Search the tab starts with, such as `replica:2` or `node:web-1`. */ + q: string +} + +const MIN = 160 + +export class DockState { + tabs = $state([]) + active = $state(null) + /** Height in pixels of the open workspace. */ + height = $state(Math.max(MIN, Number(load('stackline-dock-height')) || 300)) + /** Only the tab strip shows. */ + min = $state(false) + #conn = '' + + /** Puts back the tabs last open on a connection. */ + restore(conn: string) { + this.#conn = conn + this.tabs = [] + this.active = null + try { + const v = JSON.parse(load(`stackline-dock-${conn}`) ?? 'null') + if (v && Array.isArray(v.tabs)) { + this.tabs = v.tabs.filter((t: DockTab) => t && typeof t.id === 'string' && typeof t.source === 'string') + this.active = this.tabs.some((t) => t.id === v.active) ? v.active : (this.tabs[0]?.id ?? null) + } + } catch { + // A damaged entry means no tabs. + } + } + + #save() { + save(`stackline-dock-${this.#conn}`, JSON.stringify({ tabs: this.tabs, active: this.active })) + } + + /** Opens a tab for these lines, or shows the one already open for them. */ + open(source: string, label: string, q = '') { + const id = `${source}|${q}` + if (!this.tabs.some((t) => t.id === id)) this.tabs = [...this.tabs, { id, label, source, q }] + this.active = id + this.min = false + this.#save() + } + + close(id: string) { + const i = this.tabs.findIndex((t) => t.id === id) + if (i < 0) return + this.tabs = this.tabs.filter((t) => t.id !== id) + if (this.active === id) this.active = (this.tabs[i] ?? this.tabs[i - 1])?.id ?? null + this.#save() + } + + closeAll() { + this.tabs = [] + this.active = null + this.#save() + } + + pick(id: string) { + this.active = id + this.min = false + this.#save() + } + + toggle() { + if (this.tabs.length) this.min = !this.min + } + + resize(h: number) { + this.height = Math.max(MIN, Math.min(h, innerHeight - 220)) + save('stackline-dock-height', String(Math.round(this.height))) + } +} diff --git a/ui/src/lib/events.ts b/ui/src/lib/events.ts index 12411e1..d156ec6 100644 --- a/ui/src/lib/events.ts +++ b/ui/src/lib/events.ts @@ -25,6 +25,8 @@ export function eventLabel(e: DockerEvent): string { return (e.detail ? `failed: ${e.detail}` : 'failed') + code case 'deploy': return e.detail ? `deployed: ${e.detail}` : 'deployed' + case 'action': + return e.detail ? `changed: ${e.detail}` : 'changed by Stackline' } } @@ -33,11 +35,15 @@ export function eventColor(e: DockerEvent): string { if (isCrash(e)) return 'var(--err)' if (e.kind === 'unhealthy') return 'var(--warn)' if (e.kind === 'deploy') return 'var(--accent)' + if (e.kind === 'action') return 'var(--info)' return 'var(--muted)' } +/** Whether an event belongs to the whole service: a deploy, or a change made to all replicas. */ +export const isServiceWide = (e: DockerEvent) => e.kind === 'deploy' || (e.kind === 'action' && !e.replica) + /** Who an event happened to: `shop_api #2`, or just the service for a deploy. */ -export const eventWho = (e: DockerEvent) => (e.kind === 'deploy' ? e.service : `${e.service} #${e.replica}`) +export const eventWho = (e: DockerEvent) => (isServiceWide(e) ? e.service : `${e.service} #${e.replica}`) /** The 2 minutes before an event and 15 s after it, in milliseconds. */ export function beforeEvent(e: DockerEvent): [number, number] { diff --git a/ui/src/lib/icons.ts b/ui/src/lib/icons.ts index 4f6edac..bee94c5 100644 --- a/ui/src/lib/icons.ts +++ b/ui/src/lib/icons.ts @@ -9,9 +9,21 @@ export const ICON: Record = { download: '', search: '', lock: '', + unlock: '', + restart: '', + play: '', + stop: '', + scale: '', star: '', chev: '', chevD: '', + chevU: '', + tasks: '', + services: '', + back: '', + forward: '', + help: '', + keyboard: '', sparkle: '', link: '', copy: '', diff --git a/ui/src/lib/lists.svelte.ts b/ui/src/lib/lists.svelte.ts new file mode 100644 index 0000000..1ce878d --- /dev/null +++ b/ui/src/lib/lists.svelte.ts @@ -0,0 +1,58 @@ +// What each list (stacks, services, tasks, nodes) remembers while someone +// moves around the app: the filter text, the tab, the sort and the row the +// cursor is on. Back to a list shows it the way it was left. + +import { load } from './storage' + +export type ListTab = 'all' | 'attention' | 'pinned' | 'running' +export type ListView = 'stacks' | 'services' | 'tasks' | 'nodes' + +export class ListState { + q = $state('') + tab = $state('all') + /** Column id and direction; null is the list's own order. */ + sort = $state<{ col: string; desc: boolean } | null>(null) + /** Key of the row the cursor is on. Shown only once someone moved it (a key, a click, a link). */ + cursor = $state(null) + + /** Sorts by a column: first ascending, then descending, then back to the list's own order. */ + sortBy(col: string) { + if (this.sort?.col !== col) this.sort = { col, desc: false } + else if (!this.sort.desc) this.sort = { col, desc: true } + else this.sort = null + } + + reset() { + this.q = '' + this.tab = 'all' + this.sort = null + this.cursor = null + } +} + +const VIEWS: ListView[] = ['stacks', 'services', 'tasks', 'nodes'] + +/** The lists, with the filter and tab each had when Stackline was last open. */ +export function newLists(): Record { + const out = {} as Record + for (const v of VIEWS) { + const l = (out[v] = new ListState()) + try { + const saved = JSON.parse(load(`stackline-list-${v}`) ?? 'null') + if (saved && typeof saved.q === 'string') l.q = saved.q + if (saved && ['all', 'attention', 'pinned', 'running'].includes(saved.tab)) l.tab = saved.tab + } catch { + // A damaged entry means the defaults. + } + } + return out +} + +/** Compares two sort values: numbers as numbers, the rest as text; empty values last. */ +export function compare(a: string | number | null | undefined, b: string | number | null | undefined): number { + const ea = a === null || a === undefined || a === '' + const eb = b === null || b === undefined || b === '' + if (ea || eb) return ea === eb ? 0 : ea ? 1 : -1 + if (typeof a === 'number' && typeof b === 'number') return a - b + return String(a).localeCompare(String(b), undefined, { numeric: true }) +} diff --git a/ui/src/lib/state.svelte.ts b/ui/src/lib/state.svelte.ts index d39eb12..9639fd2 100644 --- a/ui/src/lib/state.svelte.ts +++ b/ui/src/lib/state.svelte.ts @@ -5,22 +5,30 @@ import { untrack } from 'svelte' import { SvelteSet } from 'svelte/reactivity' import { ApiError, api, fetchDeploys, post, put, type LogParams } from './api' import { beforeEvent } from './events' +import { load, save } from './storage' +import { DockState } from './dock.svelte' +import { newLists, type ListView } from './lists.svelte' import type { - Alert, AlertsResponse, Collect, ConnState, Connection, Deploy, DockerEvent, ErrorSummary, Level, Meta, ServiceRef, Settings, Snapshot, SnapshotResponse, + Access, ActionRequest, Alert, AlertsResponse, Collect, ConnState, Connection, Deploy, DockerEvent, ErrorSummary, Level, Meta, ServiceRef, Settings, Snapshot, SnapshotResponse, UpdateState, } from './types' -export type View = 'overview' | 'nodes' | 'stacks' | 'logs' | 'connections' | 'prefs' -export const VIEWS: [View, string][] = [ - ['overview', 'Overview'], - ['nodes', 'Nodes'], - ['stacks', 'Stacks & services'], - ['logs', 'Logs'], - ['connections', 'Connections'], - ['prefs', 'Preferences'], +export type View = 'overview' | 'stacks' | 'services' | 'tasks' | 'nodes' | 'logs' | 'connections' | 'prefs' +/** Every page: its name, and the group the sidebar shows it in. */ +export const VIEWS: [View, string, string][] = [ + ['overview', 'Overview', ''], + ['stacks', 'Stacks', 'Workloads'], + ['services', 'Services', 'Workloads'], + ['tasks', 'Tasks', 'Workloads'], + ['nodes', 'Nodes', 'Cluster'], + ['logs', 'Logs', 'Logs'], + ['connections', 'Connections', 'App'], + ['prefs', 'Preferences', 'App'], ] +/** The lists, which have a cursor, tabs and a drill-down. */ +export const LIST_VIEWS: View[] = ['stacks', 'services', 'tasks', 'nodes'] -export type ServiceTab = 'overview' | 'logs' | 'errors' +export type ServiceTab = 'overview' | 'events' | 'config' | 'logs' | 'errors' export type Drawer = | { kind: 'service'; key: string; tab: ServiceTab } | { kind: 'node'; id: string } @@ -33,23 +41,8 @@ export type Preset = 'auto' | '15m' | '1h' | '3h' | '24h' | '7d' /** Minutes per fixed preset. `auto` starts at the oldest stored line instead. */ export const PRESETS: Record, number> = { '15m': 15, '1h': 60, '3h': 180, '24h': 1440, '7d': 10080 } export const LEVELS: Level[] = ['ERROR', 'WARN', 'INFO', 'DEBUG'] -export type StackFilter = 'all' | 'problems' | 'pinned' -/** Reads a remembered setting. Settings saved before the rename from swarmdash to Stackline are read under their old name. */ -export function load(key: string): string | null { - try { - return localStorage.getItem(key) ?? localStorage.getItem(key.replace(/^stackline-/, 'swarmdash-')) - } catch { - return null - } -} -export function save(key: string, v: string) { - try { - localStorage.setItem(key, v) - } catch { - // Storage blocked: the setting just isn't remembered. - } -} +export { load, save } /** The pin key of a service: `stack/service`. */ export const pinKey = (s: { stack: string; name: string }) => `${s.stack}/${s.name}` @@ -226,6 +219,21 @@ class LogsState extends LogFilters { } } +/** A change waiting for a yes. `run` gets the number typed when the dialog asks for replicas. */ +export interface Ask { + title: string + text: string + /** The docker command it amounts to. */ + command?: string + confirm: string + danger?: boolean + /** Asks for a number of replicas, starting at this one. */ + replicas?: number + /** The service a scale is for, so the command can follow the typed number. */ + scaleOf?: string + run: (replicas?: number) => Promise +} + class AppState { meta = $state(null) connections = $state([]) @@ -236,6 +244,10 @@ class AppState { pins = $state([]) /** Which services of the active connection have their logs collected. */ collect = $state({ only: false, services: [] }) + /** Whether the active connection allows changes. Read-only until someone switches it off. */ + access = $state({ writable: false, forced: false }) + /** A change waiting for a yes in the confirm dialog. */ + ask = $state(null) errors = $state.raw(null) /** Deploys of the last 24 hours, newest first, with the errors new since each. */ deploys = $state.raw([]) @@ -244,11 +256,21 @@ class AppState { settings = $state(null) update = $state(null) view = $state('overview') + /** What the address says after the page name: `services?stack=shop` narrows the services to one stack. */ + params = $state.raw(new URLSearchParams()) drawer = $state(null) + /** The inspector covers the list instead of standing beside it. */ + inspectorFull = $state(load('stackline-drawer-full') === '1') palette = $state(false) - /** Filters of the stacks view, set from the overview too. */ - stackFilter = $state((load('stackline-stack-filter') as StackFilter) || 'all') - stackQ = $state(load('stackline-stack-q') ?? '') + /** What the command palette starts with. */ + paletteQ = $state('') + help = $state(false) + /** The stack the lists are narrowed to (the top bar's stack picker); empty for all stacks. */ + scope = $state('') + /** Filter, tab, sort and cursor of each list. */ + lists = newLists() + /** The log workspace at the bottom. */ + dock = new DockState() theme = $state((load('stackline-theme') as Theme) || 'system') toast = $state<{ text: string; kind: 'ok' | 'err' } | null>(null) /** Set when the API cannot be used at all (server gone, signed out). */ @@ -285,6 +307,8 @@ class AppState { constructor() { this.logs.restoreSources(this.activeId) + this.dock.restore(this.activeId) + this.scope = load(`stackline-scope-${this.activeId}`) ?? '' // Filters survive moving around the app and restarting it. Sources are kept per connection. $effect.root(() => { $effect(() => { @@ -299,8 +323,16 @@ class AppState { if (location.hash !== h) history.replaceState(history.state, '', h) }) $effect(() => { - save('stackline-stack-filter', this.stackFilter) - save('stackline-stack-q', this.stackQ) + save(`stackline-scope-${untrack(() => this.activeId)}`, this.scope) + }) + // The filter and tab of each list survive a restart. + $effect(() => { + for (const [v, l] of Object.entries(this.lists)) save(`stackline-list-${v}`, JSON.stringify({ q: l.q, tab: l.tab })) + }) + // A stack that is gone (removed, or another connection) no longer narrows the lists. + $effect(() => { + const stacks = this.snap?.stacks + if (stacks && this.scope && !stacks.some((s) => s.name === this.scope)) untrack(() => (this.scope = '')) }) }) } @@ -330,6 +362,8 @@ class AppState { this.activeId = connections.find((c) => c.id === load('stackline-conn'))?.id ?? connections[0]?.id ?? 'local' if (missing) this.notify(`This link is for the connection “${missing}”, which is not set up here. It shows ${this.active?.name ?? 'another one'} instead.`, 'err') else this.logs.restoreSources(this.activeId) + this.dock.restore(this.activeId) + this.scope = load(`stackline-scope-${this.activeId}`) ?? '' } else if (this.#linkConn) save('stackline-conn', this.activeId) this.#linkConn = null } catch (e) { @@ -435,7 +469,9 @@ class AppState { const at = hash.indexOf('?') const h = (at < 0 ? hash : hash.slice(0, at)) as View this.view = VIEWS.some(([v]) => v === h) ? h : 'overview' - if (this.view === 'logs' && at >= 0) this.openLink(new URLSearchParams(hash.slice(at + 1))) + const params = new URLSearchParams(at < 0 ? '' : hash.slice(at + 1)) + this.params = this.view === 'logs' ? new URLSearchParams() : params + if (this.view === 'logs' && at >= 0) this.openLink(params) } /** Opens a log link: its connection, then its filters. */ @@ -458,10 +494,47 @@ class AppState { } go(view: View) { - if (location.hash.slice(1).split('?')[0] === view) this.view = view + if (location.hash.slice(1) === view) this.view = view else location.hash = view } + /** + * Opens a list narrowed by `params` (`stack`, `service` or `node`), as a + * step of Back, so Back returns to the list it came from. + */ + drill(view: View, params: Record = {}, cursor?: string) { + if (cursor !== undefined && (LIST_VIEWS as string[]).includes(view)) this.lists[view as ListView].cursor = cursor + const q = new URLSearchParams(params).toString() + const h = q ? `${view}?${q}` : view + if (location.hash.slice(1) === h) this.route() + else location.hash = h + } + + /** The services list on a tab, all stacks unless the picker narrows them. */ + showServices(tab: 'all' | 'attention' | 'pinned' = 'all') { + const l = this.lists.services + l.q = '' + l.tab = tab + this.drill('services') + } + + setInspectorFull(v: boolean) { + this.inspectorFull = v + save('stackline-drawer-full', v ? '1' : '0') + } + + /** When `g` was pressed: the next key picks a page (g s, g l…) and is not a list's key. */ + goAt = 0 + awaitingGo(): boolean { + return Date.now() - this.goAt < 1000 + } + + /** Opens the command palette, optionally with text already typed. */ + openPalette(q = '') { + this.paletteQ = q + this.palette = true + } + select(id: string) { if (id === this.activeId) return this.activeId = id @@ -471,11 +544,14 @@ class AppState { this.errors = null this.pins = [] this.collect = { only: false, services: [] } + this.access = { writable: false, forced: false } + this.ask = null this.drawer = null this.logs.reset() this.logs.restoreSources(id) - this.stackQ = '' - this.stackFilter = 'all' + for (const l of Object.values(this.lists)) l.reset() + this.scope = load(`stackline-scope-${id}`) ?? '' + this.dock.restore(id) this.poll() } @@ -484,6 +560,7 @@ class AppState { if (this.#timer) clearTimeout(this.#timer) const seq = ++this.#seq const id = this.activeId + const started = Date.now() try { const now = Date.now() const [res, errors, deploys] = await Promise.all([ @@ -495,7 +572,9 @@ class AppState { this.conn = res.state if (res.snapshot) this.snap = res.snapshot this.pins = res.pins - if (!this.#collectBusy) this.collect = res.collect ?? { only: false, services: [] } + // A switch flipped while this refresh was on its way is newer than what it brings. + if (!this.#collectBusy && this.#collectAt < started) this.collect = res.collect ?? { only: false, services: [] } + if (!this.#accessBusy && this.#accessAt < started) this.access = res.access ?? { writable: false, forced: false } if (errors) this.errors = errors if (deploys) this.deploys = deploys this.fatal = null @@ -512,6 +591,21 @@ class AppState { this.#timer = setTimeout(() => this.poll(), document.hidden ? Math.max(every, 30000) : every) } + /** + * Answers the question about another host behind the active connection: + * keep the logs stored from the host before, or delete them. + */ + async acceptHost(keep: boolean) { + const id = this.activeId + try { + await post(`/c/${encodeURIComponent(id)}/host`, { keep }) + if (this.conn && id === this.activeId) this.conn = { ...this.conn, otherHost: false } + this.notify(keep ? 'Kept the old logs. Collecting goes on.' : 'Deleted the old logs. Collecting starts over.', 'ok') + } catch (e) { + this.notify(this.describe(e), 'err') + } + } + async reloadConnections() { this.connections = await api('/connections') if (!this.connections.some((c) => c.id === this.activeId) && this.connections[0]) this.select(this.connections[0].id) @@ -539,6 +633,73 @@ class AppState { } #collectBusy = 0 + #collectAt = 0 + #accessBusy = 0 + #accessAt = 0 + + /** Switches read-only mode of the active connection. Switching it off asks first. */ + setWritable(on: boolean) { + if (this.access.forced) { + this.notify('Stackline was started with --read-only, so nothing can be changed. Start it without the flag to allow changes.', 'err') + return + } + const name = this.active?.name ?? 'this connection' + const send = async () => { + this.#accessBusy++ + try { + const conn = this.activeId + const a = await put(`/c/${encodeURIComponent(conn)}/access`, { writable: on }) + if (conn !== this.activeId) return + this.access = a + this.#accessAt = Date.now() + this.notify(on ? `Changes are allowed on ${name}. Each one still asks first.` : `${name} is read-only again.`) + } finally { + this.#accessAt = Date.now() + this.#accessBusy-- + } + } + if (!on) { + send().catch((e) => this.notify(this.describe(e), 'err')) + return + } + this.ask = { + title: `Allow changes on ${name}?`, + text: 'Stackline can then restart, stop, start and scale services here. It never creates or removes anything, and every change asks first and shows the docker command it runs. This stays on for this connection until you switch it back.', + confirm: 'Allow changes', + run: send, + } + } + + /** Asks to make a change, showing the docker command it runs, and makes it on a yes. */ + async act(req: ActionRequest, title: string) { + if (!this.access.writable) return + const conn = this.activeId + const path = `/c/${encodeURIComponent(conn)}/action` + let command = '' + // The plan shown; the server refuses to run another one (a container replaced meanwhile). + let plan: string | undefined + try { + if (req.action !== 'scale') ({ command, plan } = await post<{ command: string; plan: string }>(path, { ...req, dry: true })) + } catch (e) { + this.notify(this.describe(e), 'err') + return + } + if (conn !== this.activeId) return + this.ask = { + title, + text: req.action === 'stop' ? 'The containers stay stopped until someone starts them again.' : req.action === 'scale' ? 'Swarm starts or removes tasks until this many run. 0 stops the service.' : '', + command, + replicas: req.action === 'scale' ? (req.replicas ?? 1) : undefined, + scaleOf: req.action === 'scale' ? req.service : undefined, + confirm: req.action === 'restart' ? 'Restart' : req.action === 'stop' ? 'Stop' : req.action === 'start' ? 'Start' : 'Scale', + danger: req.action === 'stop', + run: async (n) => { + const r = await post<{ done: string }>(path, { ...req, replicas: n ?? req.replicas, expect: plan }) + this.notify(r.done) + if (conn === this.activeId) this.poll() + }, + } + } async saveCollect(next: Collect) { const before = this.collect @@ -546,6 +707,7 @@ class AppState { this.#collectBusy++ try { this.collect = await put(`/c/${encodeURIComponent(this.activeId)}/collect`, next) + this.#collectAt = Date.now() } catch (e) { this.collect = before this.notify(this.describe(e), 'err') diff --git a/ui/src/lib/storage.ts b/ui/src/lib/storage.ts new file mode 100644 index 0000000..99f48b2 --- /dev/null +++ b/ui/src/lib/storage.ts @@ -0,0 +1,17 @@ +// Settings remembered in the browser. Storage can be blocked; then nothing is remembered. + +/** Reads a remembered setting. Settings saved before the rename from swarmdash to Stackline are read under their old name. */ +export function load(key: string): string | null { + try { + return localStorage.getItem(key) ?? localStorage.getItem(key.replace(/^stackline-/, 'swarmdash-')) + } catch { + return null + } +} +export function save(key: string, v: string) { + try { + localStorage.setItem(key, v) + } catch { + // Storage blocked: the setting just isn't remembered. + } +} diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index dbf43c7..2c6a33b 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -37,6 +37,8 @@ export interface Snapshot { export interface ConnState { status: 'connecting' | 'connected' | 'error'; error: string | null; latencyMs: number | null endpoint: string | null; updatedAt: number | null; logsSyncedAt: number | null; logErrors: string[] + /** The connection reaches another host than its stored logs come from; collecting waits for an answer. */ + otherHost?: boolean } export interface SshFields { target: string; port?: number | null; identity?: string | null; jump?: string | null; socket: string } @@ -50,7 +52,11 @@ export interface Settings { alertEnabled: boolean; alertWindowMins: number; alertThreshold: number } -export interface Meta { version: string; desktop: boolean; storePath: string; platform: string } +export interface Meta { + version: string; desktop: boolean; storePath: string; platform: string + /** The settings file could not be read at startup and was set aside. */ + configWarning?: string | null +} export interface Bucket { error: number; warn: number; info: number; debug: number; exc: number } export interface Group { @@ -73,7 +79,12 @@ export interface StoreStats { /** Why the last cleanup of the store failed; it may grow past its limit until one succeeds. */ maintenanceError?: string | null } -export interface SnapshotResponse { state: ConnState; snapshot: Snapshot | null; pins: string[]; collect: Collect } +export interface SnapshotResponse { state: ConnState; snapshot: Snapshot | null; pins: string[]; collect: Collect; access?: Access } +/** Whether changes are allowed on a connection. `forced`: Stackline was started read-only. */ +export interface Access { writable: boolean; forced: boolean } +export type ActionKind = 'restart' | 'stop' | 'start' | 'scale' +/** A change to a service: what is sent to `POST /c/{id}/action`. */ +export interface ActionRequest { action: ActionKind; service: string; stack?: string; replica?: string; replicas?: number; dry?: boolean; expect?: string } /** Which services get their logs collected: all but the listed ones, or only the listed ones (full names). */ export interface Collect { only: boolean; services: string[] } export interface ServiceUsage { service: string; entries: number; textBytes: number } @@ -118,7 +129,7 @@ export interface AlertsResponse { alerts: Alert[]; native: boolean } /** A service together with the stack it belongs to. */ export interface ServiceRef extends Service { kind: Stack['kind']; key: string } -export type EventKind = 'start' | 'exit' | 'stop' | 'crash' | 'oom' | 'unhealthy' | 'failed' | 'deploy' +export type EventKind = 'start' | 'exit' | 'stop' | 'crash' | 'oom' | 'unhealthy' | 'failed' | 'deploy' | 'action' /** A container start, stop or crash. `ts` in Unix microseconds, like log entries. */ export interface DockerEvent { ts: number; stack: string; service: string; replica: string; node: string; source: string diff --git a/ui/src/views/Logs.svelte b/ui/src/views/Logs.svelte index f7f830d..3a04435 100644 --- a/ui/src/views/Logs.svelte +++ b/ui/src/views/Logs.svelte @@ -182,7 +182,7 @@ function keydown(e: KeyboardEvent) { const tag = (document.activeElement as HTMLElement | null)?.tagName - if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || app.drawer || app.palette || e.ctrlKey || e.metaKey || e.altKey) return + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || app.drawer || app.palette || app.help || app.ask || e.ctrlKey || e.metaKey || e.altKey) return if (e.key === 'f') { L.maxi = !L.maxi } else if (e.key === '/') { diff --git a/ui/src/views/Nodes.svelte b/ui/src/views/Nodes.svelte index 9dd6ea8..30552cc 100644 --- a/ui/src/views/Nodes.svelte +++ b/ui/src/views/Nodes.svelte @@ -1,54 +1,65 @@ + let engines = $derived(new Set(snap.nodes.map((n) => n.engine)).size) -{#if snap.mode === 'standalone'} -
- Plain DockerThis host is not part of a swarm. Containers are grouped by their Compose project. -
-{:else if snap.nodes.length === 1} -
- Single-node swarmThis node is both manager and worker. -
-{/if} + const cols: Col[] = [ + { id: 'name', label: 'Node', sort: (n) => n.hostname }, + { id: 'role', label: 'Role', sort: (n) => nodeRole(n) }, + { id: 'state', label: 'Status', sort: (n) => n.state }, + { id: 'avail', label: 'Availability', sort: (n) => n.availability }, + { id: 'cpu', label: 'CPU', sort: (n) => n.cpuPercent }, + { id: 'mem', label: 'Memory', sort: (n) => memPct(n.memUsed, n.memTotal) }, + { id: 'tasks', label: 'Running', sort: (n) => n.tasksRunning, cls: 'num' }, + { id: 'engine', label: 'Engine', sort: (n) => n.engine, cls: 'hide-sm' }, + { id: 'addr', label: 'Address', sort: (n) => n.addr, cls: 'hide-sm' }, + ] + const tabs: Tab[] = [ + { id: 'all', label: 'All', match: () => true }, + { id: 'attention', label: 'Needs attention', match: (n) => n.state !== 'ready' || n.availability !== 'active' }, + ] + const actions: Act[] = [ + { key: 'l', label: 'Logs', icon: 'logs', run: (n) => app.dock.open('', `node ${n.hostname}`, `node:${n.hostname}`) }, + ] + const match = (n: Node, q: string) => n.hostname.toLowerCase().includes(q) || n.addr.includes(q) || n.role.includes(q) + let inspecting = $derived(app.drawer?.kind === 'node' ? app.drawer.id : null) + -
-
-

{plural(snap.nodes.length, 'node')}

- readydrain / pausedown -
-
- - - - {#each snap.nodes as n (n.id)} - {@const mem = memPct(n.memUsed, n.memTotal)} - (app.drawer = { kind: 'node', id: n.id })}> - - - - - - - - - - - {/each} - -
NodeRoleStatusAvailabilityCPUMemoryRunningEngineAddress
{n.hostname}{#if n.isLocal && snap.nodes.length > 1} this{/if}{nodeRole(n)}{#if n.state === 'ready'}ready{:else}{n.state}{/if}{#if n.availability === 'active'}active{:else}{n.availability}{/if}
70} style:width="{n.cpuPercent ?? 0}%">{pct(n.cpuPercent)}
75} style:width="{mem ?? 0}%">{bytes(n.memUsed)}/{bytes(n.memTotal)}
{n.tasksRunning} 1 && n.engine !== common ? 'var(--warn)' : 'var(--muted)'}>{n.engine}{n.addr}
-
- {#if snap.nodes.some((n) => !n.isLocal && n.cpuPercent === null)} -
CPU and memory are only read for the node Stackline is connected to. Docker does not offer them for other nodes without connecting to each one.
- {/if} -
+ n.id} label={(n) => n.hostname} {cols} {tabs} {match} {actions} + title="Nodes" noun={['node', 'nodes']} {inspecting} + onopen={(n) => app.drill('tasks', { node: n.hostname })} openLabel={snap.mode === 'swarm' ? 'Tasks' : 'Containers'} + oninspect={(n) => (app.drawer = { kind: 'node', id: n.id })}> + {#snippet before()} + {#if snap.mode === 'standalone'} +
Plain Docker This host is not part of a swarm. Containers are grouped by their Compose project.
+ {:else if snap.nodes.length === 1} +
Single-node swarm This node is both manager and worker.
+ {/if} + {#if snap.nodes.some((n) => !n.isLocal && n.cpuPercent === null)} +
CPU and memory are only read for the node Stackline is connected to. Docker does not offer them for other nodes without connecting to each one.
+ {/if} + {/snippet} + {#snippet row(n)} + {@const mem = memPct(n.memUsed, n.memTotal)} + {n.hostname}{#if n.isLocal && snap.nodes.length > 1} this{/if} + {nodeRole(n)} + {#if n.state === 'ready'}ready{:else}{n.state}{/if} + {#if n.availability === 'active'}active{:else}{n.availability}{/if} +
70} style:width="{n.cpuPercent ?? 0}%">{pct(n.cpuPercent)}
+
75} style:width="{mem ?? 0}%">{bytes(n.memUsed)}/{bytes(n.memTotal)}
+ {n.tasksRunning} + 1 && n.engine !== common ? 'var(--warn)' : 'var(--muted)'}>{n.engine} + {n.addr} + {/snippet} +
diff --git a/ui/src/views/Overview.svelte b/ui/src/views/Overview.svelte index 212327c..84fce6f 100644 --- a/ui/src/views/Overview.svelte +++ b/ui/src/views/Overview.svelte @@ -50,7 +50,7 @@ const to = Date.now() fetchEvents(id, { from: to - 6 * 3600e3, to: to + 60e3, limit: 2000 }) .then((x) => { - if (id === app.activeId) events = x.filter((e) => isCrash(e) || e.kind === 'unhealthy' || e.kind === 'deploy') + if (id === app.activeId) events = x.filter((e) => isCrash(e) || e.kind === 'unhealthy' || e.kind === 'deploy' || e.kind === 'action') }) .catch(() => {}) }) @@ -182,9 +182,7 @@ return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!) } function openStack(name: string) { - app.stackQ = name - app.stackFilter = 'all' - app.go('stacks') + app.drill('services', { stack: name }) } function stackErrors(name: string) { const st = snap.stacks.find((s) => s.name === name) @@ -228,19 +226,19 @@ All nodes → {/if} - -
{#if app.pinnedServices.length}
-

Pinned services

+

Pinned services

{/if} @@ -320,7 +319,7 @@ {/if}
- +
diff --git a/ui/src/views/Services.svelte b/ui/src/views/Services.svelte new file mode 100644 index 0000000..5c6bb4e --- /dev/null +++ b/ui/src/views/Services.svelte @@ -0,0 +1,94 @@ + + + s.key} label={(s) => s.fullName} {cols} {tabs} {match} {actions} + title="Services" noun={['service', 'services']} {inspecting} + scope={drilled ? `of stack ${drilled}` : undefined} onscopeclear={() => app.drill('services')} + onopen={(s) => app.drill('tasks', { service: pinKey(s) })} openLabel="Tasks" + oninspect={(s) => app.openService(s.key, app.drawer?.kind === 'service' ? app.drawer.tab : 'overview')} + empty={stack ? `No services in the stack ${stack}.` : 'No services or containers on this host.'} + rowClass={(s) => (app.isPinned(s.key) ? 'pinned' : '')}> + {#snippet toolbar()} +
+ + +
+ {/snippet} + {#snippet before()} + {#if app.collect.only && !collected && app.services.length} +
No logs are collected right now. Switch on the services you want to follow in the Collect column.
+ {:else if !app.pinnedServices.length && L.tab === 'all' && !L.q && app.services.length} +
Pin the services you care about most with the star or p. They stay on top here, in the sidebar and in the log sources.
+ {/if} + {/snippet} + {#snippet row(s)} + {@const [cls, state] = svcState(s)} + {@const e = errs(s)} +
+ + + + + + + + + + + + {/snippet} + diff --git a/ui/src/views/Stacks.svelte b/ui/src/views/Stacks.svelte index b7edf10..9a967c7 100644 --- a/ui/src/views/Stacks.svelte +++ b/ui/src/views/Stacks.svelte @@ -1,81 +1,68 @@ - -
-
- {#each FILTERS as [k, label] (k)} - - {/each} -
- -
- - -
- {shown === app.services.length ? plural(shown, 'service') : `${shown} of ${app.services.length} services`} in {plural(snap.stacks.length, 'stack')} -
-{#if !app.pinnedServices.length && app.stackFilter === 'all' && !needle && app.services.length} -
Pin the services you care about most with the star. They stay on top here, on the overview and in the log sources.
-{/if} - -{#if app.collect.only && !collected && app.services.length} -
No logs are collected right now. Switch on the services you want to follow in the Collect column.
-{:else if app.collect.only || app.services.some((s) => !app.collects(s.fullName))} -
Logs are collected for {collected} of {plural(app.services.length, 'service')}. {app.collect.only ? 'New services are not collected until you switch them on.' : 'New services are collected.'}
-{/if} + const cols: Col[] = [ + { id: 'name', label: 'Stack', sort: (r) => r.name }, + { id: 'kind', label: 'Kind', sort: (r) => r.kind }, + { id: 'status', label: 'Status', sort: (r) => r.state.label }, + { id: 'services', label: 'Services', sort: (r) => r.services.length, cls: 'num' }, + { id: 'replicas', label: 'Running', sort: (r) => r.running, cls: 'num' }, + { id: 'cpu', label: 'CPU', sort: (r) => r.cpu, cls: 'num hide-sm' }, + { id: 'mem', label: 'Memory', sort: (r) => r.mem, cls: 'num hide-sm' }, + { id: 'errors', label: 'Errors 1h', sort: (r) => r.errors, cls: 'num' }, + { id: 'collect', label: 'Collect', cls: 'collectcell', title: 'Collect the logs of every service of the stack' }, + { id: 'updated', label: 'Last update', sort: (r) => r.updated, cls: 'hide-sm' }, + ] + const tabs: Tab[] = [ + { id: 'all', label: 'All', match: () => true }, + { id: 'attention', label: 'Needs attention', match: (r) => r.state.cls === 'err' || r.state.cls === 'warn' }, + ] + const actions: Act[] = [ + { key: 'l', label: 'Logs', icon: 'logs', run: (r) => app.dock.open(`stack:${r.name}`, `stack ${r.name}`) }, + { key: 't', label: 'Tasks', run: (r) => app.drill('tasks', { stack: r.name }) }, + { key: 's', label: 'Narrow to it', run: (r) => (app.scope = app.scope === r.name ? '' : r.name) }, + { key: 'o', label: 'Logs page', run: (r) => app.showLogs([`stack:${r.name}`]) }, + ] + const match = (r: Row, q: string) => r.name.toLowerCase().includes(q) || r.services.some((s) => s.name.toLowerCase().includes(q) || s.image.toLowerCase().includes(q)) + -{#if !snap.stacks.length} -
No stacks or containers on this host.
-{:else} -
- {#if groups.length}{:else} -
- {app.stackFilter === 'problems' ? 'Every service runs as it should.' : app.stackFilter === 'pinned' ? 'No pinned services yet. Pin one with the star.' : 'No services match.'} - {#if needle || app.stackFilter !== 'all'}{/if} -
- {/if} -
-{/if} + r.name} label={(r) => r.name} {cols} {tabs} {match} {actions} + title="Stacks" noun={['stack', 'stacks']} onopen={(r) => app.drill('services', { stack: r.name })} openLabel="Services" + empty="No stacks or containers on this host."> + {#snippet row(r)} +
+ + + + + + + + + + {/snippet} + diff --git a/ui/src/views/Tasks.svelte b/ui/src/views/Tasks.svelte new file mode 100644 index 0000000..c654515 --- /dev/null +++ b/ui/src/views/Tasks.svelte @@ -0,0 +1,79 @@ + + + r.key} label={(r) => `${r.s.fullName}.${r.t.slot}`} {cols} {tabs} {match} {actions} + title={swarm ? 'Tasks' : 'Containers'} noun={swarm ? ['task', 'tasks'] : ['container', 'containers']} + inspecting={inspecting ? items.find((r) => r.s.key === inspecting && r.key === L.cursor)?.key ?? null : null} + {scope} onscopeclear={() => app.drill('tasks')} + onopen={(r) => app.openService(r.s.key, 'overview')} openLabel="Service" + oninspect={(r) => app.openService(r.s.key, app.drawer?.kind === 'service' ? app.drawer.tab : 'overview')} + empty="Nothing is scheduled."> + {#snippet row(r)} + + + + + + + + + + + {/snippet} +
StackServicesStatusErrors 1hLast updateActions
{s.name}{s.stack}{s.running}/{s.desired}{state}{s.image}{s.ports.join(', ') || '–'}{pct(s.cpuPercent, 1)}{bytes(s.memUsed)}{e}{s.updatedAt ? ago(s.updatedAt, app.now) : '–'}{r.name}{#if app.scope === r.name} picked{/if}{r.kind}{r.state.label}{#if r.state.stopped}{r.state.stopped} stopped{/if}{r.services.length}{r.running}/{r.desired}{pct(r.cpu, 1)}{bytes(r.mem)}{r.errors} s.fullName)} label={r.name} />{r.updated ? ago(r.updated, app.now) : '–'}{r.name}{r.s.stack}{r.t.node}{r.pill[1]}{#if r.t.health && r.t.health !== 'unhealthy' && r.t.state === 'running'} {r.t.health}{/if}{r.t.exitCode ?? '–'}{r.t.failures}{pct(r.t.cpuPercent, 1)}{bytes(r.t.memUsed)}{r.t.since ? ago(r.t.since, app.now) : '–'}{r.t.error || (r.t.state !== 'running' ? r.t.message : '') || ''}