diff --git a/CHANGELOG.md b/CHANGELOG.md index 3455e33..6029e8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,15 @@ All notable changes to Setu. Release notes are cut from the section matching the tag. +## 0.1.6 — 2026-10-22 + +### Fixed + +- **Site logs are plain text.** The agent strips terminal escape sequences + (colours, cursor moves, window titles) from each journal line, so an app + that colours its logs under systemd no longer fills the Logs tab with + `ESC[32m` noise. + ## 0.1.5 — 2026-10-21 Spec: `docs/specs/2026-10-21-setu-0.1.5-design.md`. diff --git a/Cargo.lock b/Cargo.lock index 3cfb77a..38a8c0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -977,7 +977,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hello" -version = "0.1.5" +version = "0.1.6" dependencies = [ "axum", "reqwest", @@ -2170,7 +2170,7 @@ dependencies = [ [[package]] name = "setu-agent" -version = "0.1.5" +version = "0.1.6" dependencies = [ "anyhow", "async-trait", @@ -2204,7 +2204,7 @@ dependencies = [ [[package]] name = "setu-api" -version = "0.1.5" +version = "0.1.6" dependencies = [ "argon2", "async-trait", @@ -2248,7 +2248,7 @@ dependencies = [ [[package]] name = "setu-common" -version = "0.1.5" +version = "0.1.6" dependencies = [ "anyhow", "axum", @@ -2274,7 +2274,7 @@ dependencies = [ [[package]] name = "setu-db" -version = "0.1.5" +version = "0.1.6" dependencies = [ "anyhow", "base64 0.22.1", @@ -2296,7 +2296,7 @@ dependencies = [ [[package]] name = "setu-jobs" -version = "0.1.5" +version = "0.1.6" dependencies = [ "async-trait", "chrono", @@ -2317,7 +2317,7 @@ dependencies = [ [[package]] name = "setu-manifest" -version = "0.1.5" +version = "0.1.6" dependencies = [ "hex", "rand 0.9.5", @@ -2331,7 +2331,7 @@ dependencies = [ [[package]] name = "setu-proto" -version = "0.1.5" +version = "0.1.6" dependencies = [ "chrono", "serde", @@ -2341,7 +2341,7 @@ dependencies = [ [[package]] name = "setu-testkit" -version = "0.1.5" +version = "0.1.6" dependencies = [ "sqlx", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 5305e19..0eb4bb4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ members = [ ] [workspace.package] -version = "0.1.5" +version = "0.1.6" edition = "2021" license = "AGPL-3.0-only" # Never published to crates.io (single binary + private repo); also diff --git a/crates/agent/src/journal.rs b/crates/agent/src/journal.rs index f660c19..4e1ad89 100644 --- a/crates/agent/src/journal.rs +++ b/crates/agent/src/journal.rs @@ -40,14 +40,14 @@ pub fn parse_journal_json(text: &str) -> LogStream { next = Some(c.to_string()); } match v.get("MESSAGE") { - Some(serde_json::Value::String(s)) => lines.push(s.clone()), + Some(serde_json::Value::String(s)) => lines.push(strip_ansi(s)), // Non-UTF-8 messages arrive as a byte array. Some(serde_json::Value::Array(bytes)) => { let raw: Vec = bytes .iter() .filter_map(|b| b.as_u64().and_then(|b| u8::try_from(b).ok())) .collect(); - lines.push(String::from_utf8_lossy(&raw).into_owned()); + lines.push(strip_ansi(&String::from_utf8_lossy(&raw))); } _ => {} } @@ -55,6 +55,49 @@ pub fn parse_journal_json(text: &str) -> LogStream { LogStream { lines, next } } +/// Drops terminal escape sequences (colours, cursor moves, titles) from a +/// log line. Apps colour their logs whether or not a terminal is reading, +/// and the panel shows plain text, so `ESC[32mINFO` would arrive as +/// garbage. Handles CSI (`ESC [ … final`), OSC (`ESC ] … BEL` or +/// `ESC \\`) and two-byte `ESC x` escapes; any other text is kept. +pub fn strip_ansi(line: &str) -> String { + if !line.contains('\u{1b}') { + return line.to_owned(); + } + let mut out = String::with_capacity(line.len()); + let mut chars = line.chars().peekable(); + while let Some(c) = chars.next() { + if c != '\u{1b}' { + out.push(c); + continue; + } + match chars.next() { + // CSI: parameters and intermediates up to a final byte in @..~. + Some('[') => { + for c in chars.by_ref() { + if ('@'..='~').contains(&c) { + break; + } + } + } + // OSC: up to BEL or the string terminator ESC \. + Some(']') => { + while let Some(c) = chars.next() { + if c == '\u{7}' { + break; + } + if c == '\u{1b}' && chars.peek() == Some(&'\\') { + chars.next(); + break; + } + } + } + _ => {} + } + } + out +} + /// Read up to `lines` entries after `cursor`. /// `program` is `journalctl` in production (a fake script in tests). pub async fn read( @@ -109,4 +152,36 @@ mod tests { assert_eq!(s.lines, ["hello", "hi"]); assert_eq!(s.next.as_deref(), Some("c2")); } + + #[test] + fn colour_codes_are_stripped_from_messages() { + // What a tracing subscriber writes when it thinks a terminal is + // reading: dim timestamp, green level, dim target. + let coloured = "\u{1b}[2m2026-10-22T06:37:20Z\u{1b}[0m \u{1b}[32m INFO\u{1b}[0m \u{1b}[2mvyasa::host\u{1b}[0m\u{1b}[2m:\u{1b}[0m docs sync finished"; + let text = format!( + "{}\n{}\n", + serde_json::json!({"__CURSOR": "c1", "MESSAGE": coloured}), + // journald hands control characters over as a byte array. + serde_json::json!({"__CURSOR": "c2", "MESSAGE": "\u{1b}[31mfail\u{1b}[0m".bytes().collect::>()}), + ); + let s = parse_journal_json(&text); + assert_eq!( + s.lines, + [ + "2026-10-22T06:37:20Z INFO vyasa::host: docs sync finished", + "fail" + ] + ); + } + + #[test] + fn strip_ansi_keeps_plain_text_and_drops_osc_titles() { + assert_eq!(strip_ansi("plain [text] ok"), "plain [text] ok"); + assert_eq!(strip_ansi("\u{1b}]0;title\u{7}after"), "after"); + assert_eq!( + strip_ansi("a\u{1b}]8;;http://x\u{1b}\\link\u{1b}]8;;\u{1b}\\b"), + "alinkb" + ); + assert_eq!(strip_ansi("bold \u{1b}[1;31mred\u{1b}[0m"), "bold red"); + } } diff --git a/ui/package.json b/ui/package.json index 339c2bc..78d8f51 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,7 +1,7 @@ { "name": "setu-ui", "private": true, - "version": "0.1.5", + "version": "0.1.6", "type": "module", "scripts": { "gen:api": "tsx scripts/gen-api.ts",