Skip to content

Commit 0b7b830

Browse files
committed
feat: JSON log pretty-printing with TUI toggle and web expand/collapse
1 parent aef1e8f commit 0b7b830

9 files changed

Lines changed: 160 additions & 26 deletions

File tree

Cargo.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ members = [
77
resolver = "2"
88

99
[workspace.package]
10-
version = "0.1.0"
10+
version = "0.2.0"
1111
edition = "2021"
1212
license = "MIT"
1313
authors = ["TailFlow Contributors"]

README.md

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -423,22 +423,6 @@ tailflow/
423423

424424
---
425425

426-
## Roadmap
427-
428-
- [x] Rust core engine with broadcast bus
429-
- [x] ratatui TUI — color-coded sources, regex filter, keyboard scroll
430-
- [x] Docker, process, file, and stdin ingestion sources
431-
- [x] `tailflow.toml` zero-config discovery
432-
- [x] axum SSE daemon with ring buffer
433-
- [x] Preact web dashboard embedded in the daemon binary
434-
- [x] npm / npx distribution — no Rust toolchain required
435-
- [x] Homebrew formula for macOS and Linux
436-
- [x] Server-side `--grep` and `--source` filter flags for the daemon
437-
- [ ] Process restart policy for crashed `[[sources.process]]` entries
438-
- [ ] JSON log pretty-printing — detect structured payloads and expand inline
439-
440-
---
441-
442426
## Contributing
443427

444428
Contributions are welcome. Please open an issue before submitting a large PR so we can align on the approach.

crates/tailflow-core/src/json.rs

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/// Returns `true` if `s` looks like a JSON object or array.
2+
/// Uses a fast prefix check before attempting a full parse.
3+
pub fn is_json(s: &str) -> bool {
4+
let s = s.trim();
5+
(s.starts_with('{') || s.starts_with('['))
6+
&& serde_json::from_str::<serde_json::Value>(s).is_ok()
7+
}
8+
9+
/// Parse `s` as a JSON object and return a compact `key=value` string
10+
/// suitable for single-line TUI display.
11+
///
12+
/// - String values are shown unquoted: `msg=request`
13+
/// - Numbers/bools are shown as-is: `status=200 ok=true`
14+
/// - Nested objects/arrays are inlined as compact JSON: `meta={"host":"x"}`
15+
/// - Returns `None` if `s` is not a valid JSON object (arrays included as
16+
/// pretty JSON).
17+
pub fn flatten_json(s: &str) -> Option<String> {
18+
let s = s.trim();
19+
if !s.starts_with('{') && !s.starts_with('[') {
20+
return None;
21+
}
22+
let v: serde_json::Value = serde_json::from_str(s).ok()?;
23+
match v {
24+
serde_json::Value::Object(map) => {
25+
let parts: Vec<String> = map
26+
.iter()
27+
.map(|(k, val)| {
28+
let formatted = match val {
29+
serde_json::Value::String(s) => s.clone(),
30+
serde_json::Value::Null => "null".to_string(),
31+
other => other.to_string(),
32+
};
33+
format!("{k}={formatted}")
34+
})
35+
.collect();
36+
Some(parts.join(" "))
37+
}
38+
// For arrays, fall back to compact single-line JSON
39+
other => serde_json::to_string(&other).ok(),
40+
}
41+
}
42+
43+
#[cfg(test)]
44+
mod tests {
45+
use super::*;
46+
47+
#[test]
48+
fn is_json_detects_object() {
49+
assert!(is_json(r#"{"level":"info","msg":"ok"}"#));
50+
}
51+
52+
#[test]
53+
fn is_json_detects_array() {
54+
assert!(is_json(r#"[1,2,3]"#));
55+
}
56+
57+
#[test]
58+
fn is_json_rejects_plain_text() {
59+
assert!(!is_json("server started on port 3000"));
60+
assert!(!is_json("ERROR: connection refused"));
61+
}
62+
63+
#[test]
64+
fn is_json_rejects_invalid_json() {
65+
assert!(!is_json("{not valid}"));
66+
}
67+
68+
#[test]
69+
fn flatten_json_produces_key_value_pairs() {
70+
let s = r#"{"level":"info","status":200,"ok":true}"#;
71+
let out = flatten_json(s).unwrap();
72+
assert!(out.contains("level=info"));
73+
assert!(out.contains("status=200"));
74+
assert!(out.contains("ok=true"));
75+
}
76+
77+
#[test]
78+
fn flatten_json_unquotes_string_values() {
79+
let out = flatten_json(r#"{"msg":"hello world"}"#).unwrap();
80+
assert_eq!(out, "msg=hello world");
81+
}
82+
83+
#[test]
84+
fn flatten_json_inlines_nested_objects() {
85+
let out = flatten_json(r#"{"meta":{"host":"x"}}"#).unwrap();
86+
assert!(out.starts_with("meta="));
87+
assert!(out.contains("host"));
88+
}
89+
90+
#[test]
91+
fn flatten_json_returns_none_for_plain_text() {
92+
assert!(flatten_json("not json").is_none());
93+
}
94+
95+
#[test]
96+
fn flatten_json_handles_array() {
97+
let out = flatten_json(r#"[1,2,3]"#).unwrap();
98+
assert_eq!(out, "[1,2,3]");
99+
}
100+
}

crates/tailflow-core/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
pub mod config;
22
pub mod ingestion;
3+
pub mod json;
34
pub mod processor;
45

56
use chrono::{DateTime, Utc};

crates/tailflow-tui/src/app.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ pub struct App {
1919
rx: LogReceiver,
2020
pub scroll: usize,
2121
pub source_colors: SourceColorMap,
22+
/// When true, JSON payloads are shown as flattened key=value pairs.
23+
pub pretty_json: bool,
2224
}
2325

2426
pub struct SourceColorMap {
@@ -68,6 +70,7 @@ impl App {
6870
rx,
6971
scroll: 0,
7072
source_colors: SourceColorMap::new(),
73+
pretty_json: false,
7174
}
7275
}
7376

@@ -141,6 +144,11 @@ impl App {
141144
self.scroll = usize::MAX; // snap to bottom on next render
142145
}
143146

147+
// Toggle JSON pretty-printing
148+
(false, KeyCode::Char('p')) => {
149+
self.pretty_json = !self.pretty_json;
150+
}
151+
144152
// Filter mode input
145153
(true, KeyCode::Esc) => {
146154
self.filter_mode = false;

crates/tailflow-tui/src/ui/mod.rs

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use ratatui::{
66
widgets::{Block, Borders, List, ListItem, Paragraph},
77
Frame,
88
};
9-
use tailflow_core::LogLevel;
9+
use tailflow_core::{json::flatten_json, LogLevel};
1010

1111
pub fn draw(f: &mut Frame, app: &mut App) {
1212
let area = f.area();
@@ -56,18 +56,24 @@ pub fn draw(f: &mut Frame, app: &mut App) {
5656
let scroll = app.scroll;
5757

5858
// ── Collect visible records as owned data (drops borrow on app.records) ─
59+
let pretty_json = app.pretty_json;
5960
let visible_data: Vec<(String, String, LogLevel, String)> = app
6061
.records
6162
.iter()
6263
.filter(|r| matches(&r.payload, &r.source))
6364
.skip(scroll)
6465
.take(list_height)
6566
.map(|r| {
67+
let payload = if pretty_json {
68+
flatten_json(&r.payload).unwrap_or_else(|| r.payload.clone())
69+
} else {
70+
r.payload.clone()
71+
};
6672
(
6773
r.timestamp.format("%H:%M:%S%.3f").to_string(),
6874
r.source.clone(),
6975
r.level,
70-
r.payload.clone(),
76+
payload,
7177
)
7278
})
7379
.collect();
@@ -79,9 +85,15 @@ pub fn draw(f: &mut Frame, app: &mut App) {
7985
.collect();
8086

8187
// ── Header ─────────────────────────────────────────────────────────────
88+
let json_label = if app.pretty_json {
89+
"p:json-on"
90+
} else {
91+
"p:json-off"
92+
};
8293
let header_text = format!(
83-
" TailFlow | {} records | Press / to filter | q to quit",
84-
app.records.len()
94+
" TailFlow | {} records | / filter | {} | q quit",
95+
app.records.len(),
96+
json_label,
8597
);
8698
let header = Paragraph::new(header_text)
8799
.style(Style::default().fg(Color::White).bg(Color::DarkGray))

web/dist/.gitkeep

Whitespace-only changes.

web/src/components/LogRow.tsx

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useState } from 'preact/hooks'
12
import type { LogRecord } from '../types'
23
import { sourceColor, LEVEL_COLOR } from '../types'
34

@@ -6,7 +7,6 @@ interface Props {
67
}
78

89
function formatTs(iso: string): string {
9-
// Show only HH:MM:SS.mmm from the ISO timestamp
1010
const d = new Date(iso)
1111
const hh = String(d.getHours()).padStart(2, '0')
1212
const mm = String(d.getMinutes()).padStart(2, '0')
@@ -15,9 +15,21 @@ function formatTs(iso: string): string {
1515
return `${hh}:${mm}:${ss}.${ms}`
1616
}
1717

18+
function tryParseJson(s: string): object | null {
19+
const t = s.trim()
20+
if (t[0] !== '{' && t[0] !== '[') return null
21+
try {
22+
return JSON.parse(t)
23+
} catch {
24+
return null
25+
}
26+
}
27+
1828
export function LogRow({ record }: Props) {
1929
const sc = sourceColor(record.source)
2030
const lc = LEVEL_COLOR[record.level]
31+
const parsed = tryParseJson(record.payload)
32+
const [expanded, setExpanded] = useState(false)
2133

2234
return (
2335
<div class="log-row">
@@ -28,7 +40,24 @@ export function LogRow({ record }: Props) {
2840
<span class="log-level" style={{ color: lc }}>
2941
{record.level.slice(0, 5).toUpperCase().padEnd(5, ' ')}
3042
</span>
31-
<span class="log-payload">{record.payload}</span>
43+
{parsed ? (
44+
<span class="log-payload log-payload--json">
45+
<button
46+
class="json-toggle"
47+
onClick={() => setExpanded(e => !e)}
48+
title={expanded ? 'Collapse JSON' : 'Expand JSON'}
49+
>
50+
{expanded ? '▼' : '▶'}
51+
</button>
52+
{expanded ? (
53+
<pre class="json-block">{JSON.stringify(parsed, null, 2)}</pre>
54+
) : (
55+
<span class="json-preview">{record.payload}</span>
56+
)}
57+
</span>
58+
) : (
59+
<span class="log-payload">{record.payload}</span>
60+
)}
3261
</div>
3362
)
3463
}

0 commit comments

Comments
 (0)