Skip to main content

scxtui/
logs.rs

1// SPDX-License-Identifier: GPL-2.0
2
3//! Journal access for the log view.
4//!
5//! Spawns `journalctl` as a one-shot subprocess with `--output=json` rather
6//! than linking sd-journal bindings: no libsystemd build dependency, and the
7//! JSON output carries `PRIORITY` reliably, which drives the coloring.
8//! Multi-line messages (scheduler `Opts { ... }` dumps and the like) are
9//! flattened into display lines here, with continuations marked so the UI
10//! can indent or fold them.
11
12use anyhow::{Context, Result, bail};
13use chrono::TimeZone;
14
15use crate::system::system_command;
16
17/// The unit the log view inspects: the scheduler runs as its main process,
18/// so its stdout/stderr land in the unit's journal.
19pub const UNIT: &str = "scx.service";
20
21/// One display line of the log view.
22pub struct LogLine {
23    /// Local wall-clock time, `HH:MM:SS`. Empty for continuation lines.
24    pub time: String,
25    /// syslog priority (0-7); 6 (info) when the entry carries none.
26    pub priority: u8,
27    pub text: String,
28    /// Second and further lines of a multi-line journal entry.
29    pub continuation: bool,
30}
31
32/// Tail limit for a single journal fetch. Without it, a chatty scheduler
33/// on a long-running boot can hand back tens of megabytes of JSON, all
34/// parsed synchronously while the UI is frozen. The newest entries are
35/// what the view opens on anyway; anything older is `journalctl`'s job.
36const MAX_ENTRIES: usize = 5000;
37
38/// Result of one journal fetch: flattened display lines plus whether the
39/// tail limit cut the beginning off, so the UI can say so.
40pub struct LogFetch {
41    pub lines: Vec<LogLine>,
42    pub truncated: bool,
43}
44
45/// Fetches the journal tail for `unit` (up to [`MAX_ENTRIES`] entries),
46/// current boot or the previous one, and flattens it into display lines
47/// (oldest first).
48pub fn fetch(unit: &str, previous_boot: bool) -> Result<LogFetch> {
49    let boot = if previous_boot { "-1" } else { "0" };
50    let limit = MAX_ENTRIES.to_string();
51    let output = system_command("journalctl")?
52        .args([
53            "--unit",
54            unit,
55            "--boot",
56            boot,
57            "--lines",
58            &limit,
59            "--output=json",
60            "--no-pager",
61            "--quiet",
62        ])
63        .output()
64        .context("failed to run journalctl — is this a systemd system?")?;
65
66    if !output.status.success() {
67        let stderr = String::from_utf8_lossy(&output.stderr);
68        let stderr = stderr.trim();
69        // journalctl exits non-zero e.g. when boot -1 is absent (volatile
70        // journal) — surface its own wording, it is usually clear enough.
71        bail!(
72            "journalctl failed{}{}",
73            if stderr.is_empty() { "" } else { ": " },
74            stderr
75        );
76    }
77
78    let stdout = String::from_utf8_lossy(&output.stdout);
79    // `--lines` limits journal *entries*; display lines can be more after
80    // multi-line flattening. Hitting the entry count exactly means the
81    // tail almost certainly cut something off.
82    let entries = stdout.lines().filter(|line| !line.is_empty()).count();
83    Ok(LogFetch {
84        lines: parse_entries(&stdout),
85        truncated: entries >= MAX_ENTRIES,
86    })
87}
88
89/// Pure parsing core: one `journalctl --output=json` line per entry in,
90/// flattened display lines out. Malformed entries are skipped rather than
91/// failing the whole view.
92fn parse_entries(stdout: &str) -> Vec<LogLine> {
93    let mut lines = Vec::new();
94    for raw in stdout.lines() {
95        if raw.is_empty() {
96            continue;
97        }
98        let Ok(entry) = serde_json::from_str::<serde_json::Value>(raw) else {
99            continue;
100        };
101
102        let priority = entry
103            .get("PRIORITY")
104            .and_then(|p| p.as_str())
105            .and_then(|p| p.parse().ok())
106            .unwrap_or(6);
107        let time = entry
108            .get("__REALTIME_TIMESTAMP")
109            .and_then(|t| t.as_str())
110            .and_then(|t| t.parse::<i64>().ok())
111            .map(format_local_time)
112            .unwrap_or_default();
113        // MESSAGE is a JSON string for UTF-8 payloads and a byte array
114        // otherwise (journald convention); handle both.
115        let message = match entry.get("MESSAGE") {
116            Some(serde_json::Value::String(s)) => s.clone(),
117            Some(serde_json::Value::Array(bytes)) => {
118                let raw: Vec<u8> = bytes
119                    .iter()
120                    .filter_map(|b| b.as_u64().and_then(|b| u8::try_from(b).ok()))
121                    .collect();
122                String::from_utf8_lossy(&raw).into_owned()
123            }
124            _ => continue,
125        };
126
127        for (i, text) in message.lines().enumerate() {
128            lines.push(LogLine {
129                time: if i == 0 { time.clone() } else { String::new() },
130                priority,
131                text: text.to_owned(),
132                continuation: i > 0,
133            });
134        }
135    }
136    lines
137}
138
139fn format_local_time(usec: i64) -> String {
140    chrono::Local
141        .timestamp_opt(usec / 1_000_000, 0)
142        .single()
143        .map(|dt| dt.format("%H:%M:%S").to_string())
144        .unwrap_or_default()
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn parses_priority_with_info_default() {
153        let entries = parse_entries(concat!(
154            r#"{"PRIORITY":"3","MESSAGE":"boom"}"#,
155            "\n",
156            r#"{"MESSAGE":"plain"}"#,
157        ));
158        assert_eq!(entries.len(), 2);
159        assert_eq!(entries[0].priority, 3);
160        assert_eq!(entries[1].priority, 6);
161    }
162
163    #[test]
164    fn flattens_multiline_messages() {
165        let entries = parse_entries(
166            r#"{"PRIORITY":"6","MESSAGE":"Opts {\n  verbose: 0,\n}","__REALTIME_TIMESTAMP":"1753000000000000"}"#,
167        );
168        assert_eq!(entries.len(), 3);
169        assert!(!entries[0].continuation);
170        assert!(entries[1].continuation && entries[2].continuation);
171        assert_eq!(entries[1].time, "");
172        assert_eq!(entries[1].text, "  verbose: 0,");
173        // Continuation lines inherit the entry's priority.
174        assert_eq!(entries[2].priority, 6);
175    }
176
177    #[test]
178    fn decodes_byte_array_messages() {
179        let entries = parse_entries(r#"{"MESSAGE":[104,105]}"#);
180        assert_eq!(entries.len(), 1);
181        assert_eq!(entries[0].text, "hi");
182    }
183
184    #[test]
185    fn skips_malformed_and_empty_lines() {
186        let entries = parse_entries("not json\n\n{\"MESSAGE\":\"ok\"}\n{\"NO_MESSAGE\":true}");
187        assert_eq!(entries.len(), 1);
188        assert_eq!(entries[0].text, "ok");
189    }
190}