Skip to content

Commit 5a764ab

Browse files
authored
Merge pull request #15 from seyeongkim-lab/feature/flow-view-and-concurrent-probes
Fix stream payload, probe concurrently, add a flow view
2 parents f30deb7 + 2c70beb commit 5a764ab

16 files changed

Lines changed: 1655 additions & 158 deletions

File tree

.github/workflows/ci.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,24 @@ jobs:
3131
run: cargo build --verbose
3232
- name: Test
3333
run: cargo test --verbose
34+
35+
msrv:
36+
runs-on: ubuntu-latest
37+
steps:
38+
- uses: actions/checkout@v4
39+
- name: Resolve declared MSRV
40+
id: msrv
41+
run: |
42+
set -euo pipefail
43+
version=$(sed -n 's/^rust-version = "\(.*\)"$/\1/p' Cargo.toml)
44+
if [ -z "$version" ]; then
45+
echo "rust-version is missing from Cargo.toml" >&2
46+
exit 1
47+
fi
48+
echo "version=$version" >> "$GITHUB_OUTPUT"
49+
- uses: dtolnay/rust-toolchain@master
50+
with:
51+
toolchain: ${{ steps.msrv.outputs.version }}
52+
- uses: Swatinem/rust-cache@v2
53+
- name: Build with the declared MSRV
54+
run: cargo build --locked --verbose

CHANGELOG.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,49 @@ Notable changes are documented here. The format follows
66

77
## [Unreleased]
88

9+
### Added
10+
11+
- Added a flow view, opened with `m`, that shows the OSD, placement group, and
12+
object behind the ops in the trace buffer. radostrace lines already name all
13+
three, so the view needs no extra remote command; with only osdtrace running
14+
it collapses to OSD and placement group. `o` orders it by op count or by
15+
latency and `s` reverses the order. Each row also carries the mean op size,
16+
which separates a slow heavy request from one that is slow for the little it
17+
asks for. A read reports the length it requested rather than bytes returned,
18+
so a client that always asks for 4MiB reports 4MiB whatever the object holds.
19+
- Insights now name the checks behind a `HEALTH_WARN` or `HEALTH_ERR` instead of
20+
telling the operator to go run `ceph health detail`. `ceph -s` already carried
21+
them, so this costs no extra remote command.
22+
- The OSD table shows the commit and apply latency from `ceph osd perf`, which
23+
puts Ceph's own view of a slow OSD next to the eBPF numbers. The query is
24+
optional, so a cluster that refuses it keeps the rest of its status.
25+
- The node table shows the share of the last 10s that a host spent stalled on IO,
26+
read from `/proc/pressure/io`, and an insight fires past 5%. Unlike a device
27+
utilization figure this needs no OSD to block device mapping to be meaningful.
28+
29+
### Fixed
30+
31+
- `x` now clears every captured trace source rather than osdtrace alone.
32+
- Node readiness no longer breaks on hosts without `ceph-osd` processes. The
33+
remote OSD count fell back through `pgrep -c ... || echo 0`, which emitted two
34+
lines because `pgrep -c` prints `0` and exits 1 on no match. The extra line
35+
made the node stream payload invalid JSON on mon-only hosts.
36+
37+
### Changed
38+
39+
- The cluster status stream now runs its three admin queries at once instead of
40+
one after another. On a four node microceph cluster the tick period dropped
41+
from 2278ms to 1400ms at the default `refresh_secs = 1`, with the same payload.
42+
`refresh_secs` is the pause between ticks, not the period, and the README now
43+
says so.
44+
- `doctor` and `snapshot` now probe hosts concurrently instead of one at a time,
45+
with at most 8 hosts in flight. Each host still sees one SSH connection at a
46+
time and the doctor report keeps its previous order.
47+
- Raised the declared minimum supported Rust version to 1.88. The source uses
48+
let chains, which are stable only from 1.88, so builds on 1.85 through 1.87
49+
failed despite the previous `rust-version = "1.85"`. CI now builds against the
50+
declared MSRV.
51+
952
## [0.1.4] - 2026-07-07
1053

1154
### Added

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
name = "cephlens"
33
version = "0.1.4"
44
edition = "2024"
5-
rust-version = "1.85"
5+
rust-version = "1.88.0"
66
authors = ["cephlens contributors"]
77
description = "A lab-first Ceph investigation TUI with SSH-based status and osdtrace views"
88
readme = "README.md"

README.md

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
<h1 align="center">CephLens</h1>
66

77
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
8-
![Rust](https://img.shields.io/badge/rust-1.85%2B-orange.svg)
8+
![Rust](https://img.shields.io/badge/rust-1.88%2B-orange.svg)
99
[![CI](https://github.com/xtrusia/cephlens/actions/workflows/ci.yml/badge.svg)](https://github.com/xtrusia/cephlens/actions/workflows/ci.yml)
1010

1111
An SSH-driven Ceph investigation TUI with live cluster status, per-node
@@ -50,8 +50,16 @@ In a source clone, use `cargo run -- init-config`, edit the generated file, then
5050
## Features
5151

5252
- Live cluster health, quorum, OSD counts, and IO throughput over a single SSH stream.
53-
- Per-node readiness: connection state, OSD ids, CPU and memory percent, and Ceph version/deployment.
53+
- Per-node readiness: connection state, OSD ids, CPU and memory percent, IO stall
54+
share from `/proc/pressure/io`, and Ceph version/deployment.
55+
- Per-OSD commit and apply latency from `ceph osd perf` next to the eBPF trace
56+
numbers, and health check names read straight out of `ceph -s`.
5457
- osdtrace eBPF latency tracing with per-OSD and per-PG breakdown of queue, BlueStore, and KV-commit latency.
58+
- A flow view of the OSD, placement group, and object behind the observed
59+
ops, built from the trace lines already streaming. radostrace names the
60+
object so the view has three levels; with only osdtrace it collapses to
61+
OSD and placement group. Rows carry the mean op size next to the latency; a
62+
read reports the length it requested, not the bytes returned.
5563
- No standing agent: no permanent daemon on the nodes; the osdtrace runner script removes itself on stop, quit, or TTL expiry. (The cephtrace tracer binaries you deploy do persist under `~/.cephlens/bin/`.)
5664
- Edit hosts and trace settings live in the TUI; changes apply to open SSH streams immediately.
5765
- Export recorded sessions as Markdown reports with the same diagnostic rules used by the TUI.
@@ -60,7 +68,8 @@ In a source clone, use `cargo run -- init-config`, edit the generated file, then
6068

6169
Controller (where the TUI runs):
6270

63-
- Rust 1.85+ (edition 2024) to build.
71+
- Rust 1.88+ (edition 2024) to build. The source uses let chains, which are
72+
stable only from 1.88 onward.
6473
- An OpenSSH client on `PATH`, with every host reachable over non-interactive SSH (key-based, no password prompt). Windows 10/11 ship this as the optional OpenSSH Client feature; macOS and Linux include it by default.
6574

6675
Ceph nodes:
@@ -216,6 +225,7 @@ admin host:
216225
sudo -n ceph -s --format json
217226
sudo -n ceph osd tree --format json
218227
sudo -n ceph osd df --format json
228+
sudo -n ceph osd perf --format json
219229
sudo -n rados --version
220230
221231
bench command:
@@ -281,6 +291,9 @@ p run a probe readiness check
281291
c edit config
282292
t/f/r view osdtrace / kfstrace / radostrace; press again to start or stop (confirmed)
283293
a start or stop all trace sources (confirmed)
294+
m flow view: the osd -> pg -> object mapping behind the live ops
295+
o flow view: order by op count or by latency
296+
s flow view: reverse the order
284297
i install osdtrace
285298
x clear captured trace events
286299
? toggle the help overlay
@@ -312,9 +325,11 @@ The integrated trace panel can show osdtrace, kfstrace, or radostrace data. The
312325
On wide terminals the trace panel appears on the right; on tall terminals it
313326
appears below the dashboard.
314327
Live TUI mode keeps one SSH stream open for cluster status and one stream per
315-
host for node readiness. Each stream emits data once per second by default and
316-
the node table shows connection state (`live`, `dial`, `retry`, `error`), OSD
317-
ids, CPU percentage, and memory percentage.
328+
host for node readiness. `refresh_secs` is the pause between ticks, so the
329+
period an operator sees is that pause plus the time the remote queries take. On
330+
a four node microceph cluster the cluster stream ticks about every 1.4s at the
331+
default `refresh_secs = 1`. The node table shows connection state (`live`,
332+
`dial`, `retry`, `error`), OSD ids, CPU percentage, and memory percentage.
318333
When `trace_auto_start` is true, cephlens starts osdtrace runners as soon as the
319334
TUI opens. The default config keeps it false so an operator explicitly starts
320335
and stops tracing with `t`, `f`, `r`, or `a`.

src/app.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ use serde_json::Value;
1919
use crate::{
2020
collect::{parse_cluster_summary, parse_osds, run_probe},
2121
editor::ConfigEditor,
22+
flow::{FlowMetric, FlowSort},
2223
kfstrace::{KfsEvent, parse_kfs_event},
2324
model::{NodeSummary, Snapshot},
2425
radostrace::{RadosEvent, parse_rados_event},
@@ -177,6 +178,10 @@ pub(crate) struct App {
177178
pub(crate) trace_following: bool,
178179
pub(crate) trace_session: Option<String>,
179180
pub(crate) trace_source: TraceSource,
181+
pub(crate) flow_view: bool,
182+
pub(crate) flow_metric: FlowMetric,
183+
pub(crate) flow_sort: FlowSort,
184+
pub(crate) flow_scroll: usize,
180185
pub(crate) kfstrace_events: Vec<KfsEvent>,
181186
pub(crate) kfstrace_active: usize,
182187
pub(crate) kfstrace_stop: Arc<AtomicBool>,
@@ -605,14 +610,15 @@ fn handle_stream_payload(app: &mut App, id: &str, payload: &str) -> Result<()> {
605610
let df = value
606611
.pointer("/df")
607612
.ok_or_else(|| anyhow!("cluster stream missing df"))?;
613+
let perf = value.pointer("/perf").filter(|perf| !perf.is_null());
608614
let snapshot = Snapshot {
609615
captured_at: Utc::now(),
610616
profile: app.profile.clone(),
611617
admin_host: app.admin_host.clone(),
612618
hosts: app.hosts.clone(),
613619
cluster: parse_cluster_summary(status),
614620
nodes: ordered_nodes(app),
615-
osds: parse_osds(tree, df),
621+
osds: parse_osds(tree, df, perf),
616622
};
617623
record_session_snapshot(app, &snapshot);
618624
app.snapshot = Some(snapshot);

src/collect.rs

Lines changed: 128 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,27 +6,35 @@ use serde_json::Value;
66

77
use crate::{
88
config::ResolvedConfig,
9-
model::{ClusterSummary, NodeSummary, OsdSummary, Snapshot},
9+
model::{ClusterSummary, HealthCheck, NodeSummary, OsdSummary, Snapshot},
1010
ssh::ssh_capture,
1111
stream::NODE_FACTS_SNIPPET,
12-
util::{ptr_f64, ptr_i64, ptr_str, ptr_u64, shell_quote},
12+
util::{MAX_PARALLEL_HOSTS, map_parallel, ptr_f64, ptr_i64, ptr_str, ptr_u64, shell_quote},
1313
};
1414

1515
pub(crate) fn collect_snapshot(cfg: &ResolvedConfig) -> Result<Snapshot> {
1616
let status_out = ssh_capture(&cfg.admin_host, "sudo -n ceph -s --format json")?;
1717
let tree_out = ssh_capture(&cfg.admin_host, "sudo -n ceph osd tree --format json")?;
1818
let df_out = ssh_capture(&cfg.admin_host, "sudo -n ceph osd df --format json")?;
19+
// Latency is supplementary, so a cluster that refuses this query still gets
20+
// a snapshot.
21+
let perf_out = ssh_capture(&cfg.admin_host, "sudo -n ceph osd perf --format json").ok();
1922

2023
let status: Value = serde_json::from_str(status_out.trim())
2124
.with_context(|| "failed to parse ceph status json")?;
2225
let tree: Value =
2326
serde_json::from_str(tree_out.trim()).with_context(|| "failed to parse osd tree json")?;
2427
let df: Value =
2528
serde_json::from_str(df_out.trim()).with_context(|| "failed to parse osd df json")?;
29+
let perf = perf_out.and_then(|perf| serde_json::from_str::<Value>(perf.trim()).ok());
2630

2731
let cluster = parse_cluster_summary(&status);
28-
let osds = parse_osds(&tree, &df);
29-
let nodes = cfg.hosts.iter().map(|host| collect_node(host)).collect();
32+
let osds = parse_osds(&tree, &df, perf.as_ref());
33+
let nodes = map_parallel(&cfg.hosts, MAX_PARALLEL_HOSTS, |host| collect_node(host))
34+
.into_iter()
35+
.zip(&cfg.hosts)
36+
.map(|(node, host)| node.unwrap_or_else(|| node_worker_panicked(host)))
37+
.collect();
3038

3139
Ok(Snapshot {
3240
captured_at: Utc::now(),
@@ -91,10 +99,50 @@ pub(crate) fn parse_cluster_summary(status: &Value) -> ClusterSummary {
9199
read_ops_sec: ptr_u64(status, "/pgmap/read_op_per_sec"),
92100
write_ops_sec: ptr_u64(status, "/pgmap/write_op_per_sec"),
93101
pg_states,
102+
health_checks: parse_health_checks(status),
94103
}
95104
}
96105

97-
pub(crate) fn parse_osds(tree: &Value, df: &Value) -> Vec<OsdSummary> {
106+
fn parse_health_checks(status: &Value) -> Vec<HealthCheck> {
107+
let Some(checks) = status.pointer("/health/checks").and_then(Value::as_object) else {
108+
return Vec::new();
109+
};
110+
let mut parsed = checks
111+
.iter()
112+
.map(|(code, check)| HealthCheck {
113+
code: code.clone(),
114+
severity: ptr_str(check, "/severity"),
115+
message: ptr_str(check, "/summary/message"),
116+
})
117+
.collect::<Vec<_>>();
118+
parsed.sort_by(|left, right| left.code.cmp(&right.code));
119+
parsed
120+
}
121+
122+
/// Maps OSD id to the latency `ceph osd perf` reports. The payload is optional,
123+
/// so a cluster where the query fails keeps its status, tree, and df data.
124+
pub(crate) fn parse_osd_perf(perf: Option<&Value>) -> HashMap<i64, (u64, u64)> {
125+
let mut by_osd = HashMap::new();
126+
let Some(infos) = perf
127+
.and_then(|perf| perf.pointer("/osdstats/osd_perf_infos"))
128+
.and_then(Value::as_array)
129+
else {
130+
return by_osd;
131+
};
132+
for info in infos {
133+
by_osd.insert(
134+
ptr_i64(info, "/id"),
135+
(
136+
ptr_u64(info, "/perf_stats/commit_latency_ms"),
137+
ptr_u64(info, "/perf_stats/apply_latency_ms"),
138+
),
139+
);
140+
}
141+
by_osd
142+
}
143+
144+
pub(crate) fn parse_osds(tree: &Value, df: &Value, perf: Option<&Value>) -> Vec<OsdSummary> {
145+
let latency_by_osd = parse_osd_perf(perf);
98146
let mut host_by_osd = HashMap::new();
99147
let mut status_by_osd = HashMap::new();
100148

@@ -120,6 +168,8 @@ pub(crate) fn parse_osds(tree: &Value, df: &Value) -> Vec<OsdSummary> {
120168
if let Some(nodes) = df.pointer("/nodes").and_then(Value::as_array) {
121169
for node in nodes {
122170
let id = ptr_i64(node, "/id");
171+
let (commit_latency_ms, apply_latency_ms) =
172+
latency_by_osd.get(&id).copied().unwrap_or_default();
123173
osds.push(OsdSummary {
124174
id,
125175
name: ptr_str(node, "/name"),
@@ -133,6 +183,8 @@ pub(crate) fn parse_osds(tree: &Value, df: &Value) -> Vec<OsdSummary> {
133183
pgs: ptr_u64(node, "/pgs"),
134184
used_kb: ptr_u64(node, "/kb_used"),
135185
avail_kb: ptr_u64(node, "/kb_avail"),
186+
commit_latency_ms,
187+
apply_latency_ms,
136188
});
137189
}
138190
}
@@ -164,6 +216,8 @@ printf 'ceph_osd_processes=%s\n' "$count"
164216
printf 'osd_ids=%s\n' "$ids"
165217
printf 'cpu_percent=%s\n' "$cpu_pct"
166218
printf 'mem_percent=%s\n' "$mem_pct"
219+
printf 'io_stall_percent=%s\n' "$io_stall"
220+
printf 'cpu_stall_percent=%s\n' "$cpu_stall"
167221
"#,
168222
facts = NODE_FACTS_SNIPPET
169223
);
@@ -189,6 +243,14 @@ printf 'mem_percent=%s\n' "$mem_pct"
189243
.get("mem_percent")
190244
.and_then(|s| s.parse().ok())
191245
.unwrap_or_default(),
246+
io_stall_percent: map
247+
.get("io_stall_percent")
248+
.and_then(|s| s.parse().ok())
249+
.unwrap_or_default(),
250+
cpu_stall_percent: map
251+
.get("cpu_stall_percent")
252+
.and_then(|s| s.parse().ok())
253+
.unwrap_or_default(),
192254
error: None,
193255
}
194256
}
@@ -200,6 +262,14 @@ printf 'mem_percent=%s\n' "$mem_pct"
200262
}
201263
}
202264

265+
fn node_worker_panicked(host: &str) -> NodeSummary {
266+
NodeSummary {
267+
host: host.to_owned(),
268+
error: Some("node collection worker panicked".to_owned()),
269+
..NodeSummary::default()
270+
}
271+
}
272+
203273
pub(crate) fn run_bench(
204274
host: &str,
205275
seconds: u64,
@@ -311,6 +381,59 @@ fn parse_key_values(output: &str) -> HashMap<String, String> {
311381
mod tests {
312382
use super::*;
313383

384+
// Shapes taken from ceph 19.2.3 on a microceph cluster.
385+
#[test]
386+
fn cluster_summary_names_the_failing_health_checks() {
387+
let status: Value = serde_json::from_str(
388+
r#"{"health":{"status":"HEALTH_WARN","checks":{
389+
"OSD_NEARFULL":{"severity":"HEALTH_WARN","summary":{"message":"1 nearfull osd(s)"}},
390+
"MON_CLOCK_SKEW":{"severity":"HEALTH_WARN","summary":{"message":"clock skew detected"}}}}}"#,
391+
)
392+
.unwrap();
393+
394+
let cluster = parse_cluster_summary(&status);
395+
396+
let codes = cluster
397+
.health_checks
398+
.iter()
399+
.map(|check| check.code.as_str())
400+
.collect::<Vec<_>>();
401+
assert_eq!(codes, vec!["MON_CLOCK_SKEW", "OSD_NEARFULL"]);
402+
assert_eq!(cluster.health_checks[1].message, "1 nearfull osd(s)");
403+
}
404+
405+
#[test]
406+
fn healthy_cluster_reports_no_checks() {
407+
let status: Value =
408+
serde_json::from_str(r#"{"health":{"status":"HEALTH_OK","checks":{},"mutes":[]}}"#)
409+
.unwrap();
410+
411+
assert!(parse_cluster_summary(&status).health_checks.is_empty());
412+
}
413+
414+
#[test]
415+
fn osd_latency_is_merged_by_id_and_optional() {
416+
let tree: Value = serde_json::from_str(r#"{"nodes":[]}"#).unwrap();
417+
let df: Value =
418+
serde_json::from_str(r#"{"nodes":[{"id":1,"name":"osd.1"},{"id":2,"name":"osd.2"}]}"#)
419+
.unwrap();
420+
let perf: Value = serde_json::from_str(
421+
r#"{"osdstats":{"osd_perf_infos":[
422+
{"id":2,"perf_stats":{"commit_latency_ms":7,"apply_latency_ms":3}}]}}"#,
423+
)
424+
.unwrap();
425+
426+
let merged = parse_osds(&tree, &df, Some(&perf));
427+
assert_eq!(merged[0].commit_latency_ms, 0, "osd.1 has no perf entry");
428+
assert_eq!(merged[1].commit_latency_ms, 7);
429+
assert_eq!(merged[1].apply_latency_ms, 3);
430+
431+
// A cluster that refuses `ceph osd perf` still gets its OSD rows.
432+
let without = parse_osds(&tree, &df, None);
433+
assert_eq!(without.len(), 2);
434+
assert_eq!(without[1].commit_latency_ms, 0);
435+
}
436+
314437
#[test]
315438
fn bench_command_cleans_up_unique_pool_on_exit() {
316439
let pool = bench_pool_name("20260716-120000", 42);

0 commit comments

Comments
 (0)