pub mod probe;
use std::io::Write;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::mpsc::{Receiver, RecvTimeoutError};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use gunnar_common::logtap::{self, LogLine};
use tracing::Level;
pub const TICK: Duration = Duration::from_secs(2);
pub const EVENT_ROWS: usize = 20;
pub const DEFAULT_COLS: usize = 80;
pub const DEFAULT_ROWS: usize = 25;
pub const DEVICE_ENV: &str = "GUNNAR_DASHBOARD";
const DEVICE: &str = "/dev/tty0";
#[derive(Debug, Clone)]
pub struct Facts {
pub ssh_bind: SocketAddr,
pub grpc_bind: Option<SocketAddr>,
pub data_root: PathBuf,
pub build: String,
pub engine: Option<crate::storage::Engine>,
pub replication: crate::replication_view::ReplicationView,
pub tenants: TenantView,
pub flow: FlowView,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct TenantCounts {
pub paid: u64,
pub grace: u64,
pub suspended: u64,
}
#[derive(Clone, Default)]
pub struct TenantView {
source: std::sync::Arc<std::sync::OnceLock<Box<dyn Fn() -> TenantCounts + Send + Sync>>>,
}
impl TenantView {
pub fn new() -> Self {
Self::default()
}
pub fn fill(&self, source: impl Fn() -> TenantCounts + Send + Sync + 'static) {
let _ = self.source.set(Box::new(source));
}
pub fn counts(&self) -> Option<TenantCounts> {
self.source.get().map(|source| source())
}
}
impl std::fmt::Debug for TenantView {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TenantView")
.field("filled", &self.source.get().is_some())
.finish()
}
}
pub const SILENT_AFTER_DAYS: i64 = 14;
type FleetSource = Box<dyn Fn() -> Option<gunnar_core::Fleet> + Send + Sync>;
#[derive(Clone, Default)]
pub struct FlowView {
source: std::sync::Arc<std::sync::OnceLock<FleetSource>>,
}
impl FlowView {
pub fn new() -> Self {
Self::default()
}
pub fn fill(&self, source: impl Fn() -> Option<gunnar_core::Fleet> + Send + Sync + 'static) {
let _ = self.source.set(Box::new(source));
}
pub fn fleet(&self) -> Option<gunnar_core::Fleet> {
self.source.get().and_then(|source| source())
}
}
impl std::fmt::Debug for FlowView {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FlowView")
.field("filled", &self.source.get().is_some())
.finish()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Row {
pub at: u64,
pub kind: &'static str,
pub text: String,
pub warn: bool,
}
#[derive(Debug, Clone)]
pub struct Screen {
pub facts: Facts,
pub hostname: Option<String>,
pub addresses: Vec<probe::IfAddr>,
pub dns: Option<Vec<std::net::Ipv4Addr>>,
pub cpu_model: Option<String>,
pub cpus: usize,
pub cpu_percent: Option<u8>,
pub load1: Option<String>,
pub memory: Option<(u64, u64)>,
pub disk: Option<(u64, u64)>,
pub explode: Option<(u64, u64)>,
pub disk_kind: Option<probe::DiskKind>,
pub writer: Option<&'static str>,
pub uptime_secs: Option<u64>,
pub now_unix: u64,
pub rows: std::collections::VecDeque<Row>,
pub dropped: u64,
ticks: Option<probe::CpuTicks>,
pub index: Vec<Row>,
index_last: Vec<(PathBuf, u64, Instant)>,
pub standby: Vec<Row>,
pub tenants: Vec<Row>,
pub flow: Vec<Row>,
}
impl Screen {
pub fn new(facts: Facts) -> Self {
Screen {
facts,
hostname: None,
addresses: Vec::new(),
dns: None,
cpu_model: None,
cpus: 0,
cpu_percent: None,
load1: None,
memory: None,
disk: None,
explode: None,
disk_kind: None,
writer: None,
uptime_secs: None,
now_unix: 0,
rows: std::collections::VecDeque::with_capacity(EVENT_ROWS + 1),
dropped: 0,
ticks: None,
index: Vec::new(),
index_last: Vec::new(),
standby: Vec::new(),
tenants: Vec::new(),
flow: Vec::new(),
}
}
pub fn sample_tenants(&mut self, counts: Option<TenantCounts>) {
self.tenants = counts
.map(|c| Row {
at: self.now_unix,
kind: "tenants",
text: format!(
"paid {} grace {} suspended {}",
c.paid, c.grace, c.suspended
),
warn: c.suspended > 0,
})
.into_iter()
.collect();
}
pub fn sample_flow(&mut self, fleet: Option<gunnar_core::Fleet>) {
self.flow = fleet
.map(|f| Row {
at: self.now_unix,
kind: "flow",
text: format!(
"in {} out {} today silent {} of {}",
short_bytes(f.bytes_in_today),
short_bytes(f.bytes_out_today),
f.silent,
f.namespaces
),
warn: f.silent > 0,
})
.into_iter()
.collect();
}
pub fn sample_replication(&mut self, report: &crate::replication_view::ReplicationReport) {
self.standby = report
.standbys
.iter()
.map(|row| Row {
at: self.now_unix,
kind: "standby",
text: row.describe(report.synced, report.failed, self.now_unix as i64 * 1000),
warn: row.stale,
})
.collect();
if report.configured && report.standbys.is_empty() {
self.standby.push(Row {
at: self.now_unix,
kind: "standby",
text: "replication is configured and reports NO standby rows".to_owned(),
warn: true,
});
}
}
pub fn sample_index(&mut self, facts: &[probe::IndexFacts], now: Instant) {
let mut next_last = Vec::with_capacity(facts.len());
let mut rows = Vec::new();
for f in facts {
if !f.snap.active {
continue;
}
let prev = self.index_last.iter().find(|(d, _, _)| *d == f.git_dir);
let rate = prev.and_then(|(_, bytes, at)| {
let secs = now.saturating_duration_since(*at).as_secs_f64();
(secs > 0.0 && f.snap.bytes_done >= *bytes)
.then(|| ((f.snap.bytes_done - bytes) as f64 / secs) as u64)
});
next_last.push((f.git_dir.clone(), f.snap.bytes_done, now));
rows.push(Row {
at: self.now_unix,
kind: "index",
text: index_row_text(
&repo_label(&f.git_dir, &self.facts.data_root),
&f.snap,
rate,
),
warn: f.snap.phase == "paused",
});
}
self.index_last = next_last;
self.index = rows;
}
pub fn sample(&mut self) {
self.hostname = probe::hostname();
self.addresses = probe::addresses();
self.dns = appliance_pid1::resolver::published();
if self.cpu_model.is_none() {
self.cpu_model = probe::cpu_model();
}
let (ticks, cpus) = probe::cpu_ticks();
if cpus > 0 {
self.cpus = cpus;
}
if let (Some(now), Some(then)) = (ticks, self.ticks) {
if let Some(p) = now.busy_percent_since(then) {
self.cpu_percent = Some(p);
}
}
if ticks.is_some() {
self.ticks = ticks;
}
self.load1 = probe::load1();
self.memory = probe::memory();
self.disk = probe::disk(&self.facts.data_root);
if self.disk_kind.is_none() {
self.disk_kind = probe::disk_kind(&self.facts.data_root);
}
if self.writer.is_none() {
self.writer = crate::storage::writer_arm();
}
self.uptime_secs = probe::uptime_secs();
self.now_unix = now_unix();
self.dropped = logtap::dropped();
if let Some(engine) = self.facts.engine {
let facts = probe::index_progress(engine);
self.sample_index(&facts, Instant::now());
let bands = engine.explode_use();
self.explode = (!bands.is_empty()).then(|| {
bands.iter().fold((0u64, 0u64), |(u, b), (_, band)| {
(u + band.used(), b + band.budget)
})
});
}
self.sample_replication(&self.facts.replication.report());
self.sample_tenants(self.facts.tenants.counts());
self.sample_flow(self.facts.flow.fleet());
}
pub fn push(&mut self, row: Row) {
self.rows.push_back(row);
while self.rows.len() > EVENT_ROWS {
self.rows.pop_front();
}
}
pub fn take(&mut self, line: &LogLine) {
if let Some((kind, text, warn)) = describe(line) {
self.push(Row {
at: now_unix(),
kind,
text,
warn,
});
}
}
pub fn render_frame(&self, cols: usize, rows: usize) -> String {
let width = cols.saturating_sub(1).max(20);
let mut lines = self.header_lines(width);
lines.push("\u{2500}".repeat(width));
for row in self
.index
.iter()
.chain(self.standby.iter())
.chain(self.tenants.iter())
.chain(self.flow.iter())
{
let mark = if row.warn { "!" } else { " " };
lines.push(format!(
"{mark}{} {:<7} {}",
clock(row.at),
row.kind,
row.text
));
}
let body_rows = rows.saturating_sub(lines.len());
let shown = self.rows.iter().rev().take(body_rows).collect::<Vec<_>>();
for row in shown.into_iter().rev() {
let mark = if row.warn { "!" } else { " " };
lines.push(format!(
"{mark}{} {:<7} {}",
clock(row.at),
row.kind,
row.text
));
}
while lines.len() < rows {
lines.push(String::new());
}
lines.truncate(rows);
let mut out = String::with_capacity(rows * (cols + 8) + 8);
out.push_str("\x1b[H");
for (i, line) in lines.iter().enumerate() {
if i > 0 {
out.push_str("\r\n");
}
out.push_str(&pad(line, width));
out.push_str("\x1b[K");
}
out
}
fn header_lines(&self, width: usize) -> Vec<String> {
let up = self
.uptime_secs
.map(fmt_uptime)
.unwrap_or_else(|| "-".into());
let dropped = if self.dropped > 0 {
format!(" (log tap dropped {})", self.dropped)
} else {
String::new()
};
let line1 = format!(
" gunnar {} appliance up {} {} UTC{}",
self.facts.build,
up,
clock(self.now_unix),
dropped
);
let mut line2 = format!(" host {}", self.hostname.as_deref().unwrap_or("(none)"));
if self.addresses.is_empty() {
line2.push_str(" no address");
}
for a in &self.addresses {
let piece = format!(" {a}");
if line2.len() + piece.len() > width {
break;
}
line2.push_str(&piece);
}
let dns = match &self.dns {
None => " dns none".to_string(),
Some(v) if v.is_empty() => " dns (file names none)".to_string(),
Some(v) => format!(
" dns {}",
v.iter()
.map(|s| s.to_string())
.collect::<Vec<_>>()
.join(",")
),
};
if line2.len() + dns.len() <= width {
line2.push_str(&dns);
}
let cpu = match (&self.cpu_model, self.cpus) {
(Some(m), n) if n > 0 => format!("{n}x {m}"),
(Some(m), _) => m.clone(),
(None, n) if n > 0 => format!("{n} cpu"),
(None, _) => "-".into(),
};
let cpu = clip(&cpu, CPU_MODEL_COLS);
let pct = self
.cpu_percent
.map(|p| format!("{p:>3}%"))
.unwrap_or_else(|| " -%".into());
let ram = match self.memory {
Some((total, avail)) => {
let used = total.saturating_sub(avail);
format!(
"{}/{} {:>2}%",
fmt_bytes(used),
fmt_bytes(total),
pct_of(used, total)
)
}
None => "-".into(),
};
let line3 = format!(
" cpu {cpu} {pct} load {} ram {ram}",
self.load1.as_deref().unwrap_or("-")
);
let disk = match self.disk {
Some((total, avail)) => {
let used = total.saturating_sub(avail);
format!(
"{}/{} {:>2}%",
fmt_bytes(used),
fmt_bytes(total),
pct_of(used, total)
)
}
None => "-".into(),
};
let kind = self
.disk_kind
.as_ref()
.map(|k| format!(" {k}"))
.unwrap_or_default();
let kind = match self.writer {
Some(w) if kind.is_empty() => format!(" {w}"),
Some(w) => format!("{kind} {w}"),
None => kind,
};
let explode = match self.explode {
Some((used, budget)) if budget > 0 => {
format!(" explode {}/{}", fmt_bytes(used), fmt_bytes(budget))
}
Some((used, _)) => format!(" explode {} unlimited", fmt_bytes(used)),
None => String::new(),
};
let grpc = self
.facts
.grpc_bind
.map(|g| format!(" grpc {}", fmt_bind(g)))
.unwrap_or_default();
let line4 = format!(
" disk {} {disk}{kind}{explode} ssh {}{grpc}",
clip(&self.facts.data_root.display().to_string(), CPU_MODEL_COLS),
fmt_bind(self.facts.ssh_bind)
);
vec![line1, line2, line3, line4]
}
}
pub fn describe(line: &LogLine) -> Option<(&'static str, String, bool)> {
let warn = line.level <= Level::WARN;
let get = |k: &str| line.get(k).unwrap_or("-").to_owned();
let short_fp = |fp: &str| {
let body = fp.strip_prefix("SHA256:").unwrap_or(fp);
format!("{}\u{2026}", &body[..body.len().min(12)])
};
if let Some(event) = line.event() {
return match event {
"ssh.auth.accepted" => Some((
"login",
format!(
"{} {} {}",
get("principal"),
short_fp(&get("fingerprint")),
get("peer")
),
false,
)),
"ssh.auth.anonymous" => {
Some(("anon", format!("{} public read only", get("peer")), false))
}
"ssh.auth.refused" | "ssh.auth.none.refused" => Some((
"REFUSED",
format!("login {} {}", get("peer"), get("reason")),
true,
)),
"git.transfer.finished" => {
let verb = match line.get("service") {
Some("git-receive-pack") => "push",
Some("git-upload-pack") => "fetch",
_ => "git",
};
Some((
verb,
format!(
"{} {} {} {} ms",
get("repo"),
get("principal"),
get("status"),
get("elapsed_ms")
),
warn,
))
}
"git.transfer.waited" => Some((
"waiting",
format!(
"{} waited mean {} ms worst {} ms limit {}",
get("waited_total"),
get("mean_wait_ms"),
get("peak_wait_ms"),
get("limit")
),
false,
)),
"git.transfer.refused" | "http.transfer.refused" => Some((
"REFUSED",
format!("{} {} {}", get("repo"), get("principal"), line.message),
true,
)),
"policy.gate.refused" => Some((
"REFUSED",
format!("push {} {}", get("repo"), get("principal")),
true,
)),
"repo.created_on_first_push" => Some((
"repo+",
format!("{} {}", get("repo"), get("principal")),
false,
)),
"grpc.call" => {
let decision = line.get("decision").unwrap_or("-");
Some((
if decision == "allow" {
"admin"
} else {
"DENIED"
},
format!(
"{} {} {}",
get("method"),
get("principal"),
get("reason").replace('-', "")
),
decision != "allow",
))
}
"store.maintained" => Some((
"gc",
format!(
"{} {} -> {} B",
get("repo"),
get("bytes_before"),
get("bytes_after")
),
false,
)),
"store.writer_selected" => Some((
"writer",
match line.get("fallback").filter(|f| *f != "-" && !f.is_empty()) {
Some(why) => format!("{} ({why})", get("writer")),
None => get("writer"),
},
false,
)),
"replication.synced" | "replication.failed" => {
Some(("mirror", format!("{} {}", get("repo"), line.message), warn))
}
"http.listening" | "metrics.listening" => Some((
"listen",
format!("{} {}", event.split('.').next().unwrap_or(""), get("addr")),
false,
)),
"install.mode" => Some((
"install",
match (line.get("mode"), line.get("kept")) {
(Some("upgrade"), Some("data-set")) => {
match line.get("kept_set").filter(|s| *s != "-" && !s.is_empty()) {
Some(set) => format!("upgrade /data kept (data set {set})"),
None => "upgrade /data kept (data set)".to_owned(),
}
}
(Some("upgrade"), _) => match line
.get("kept_partno")
.filter(|p| *p != "-" && !p.is_empty())
{
Some(partno) => {
format!("upgrade /data kept (p{partno} {}G)", get("kept_gib"))
}
None => "upgrade /data kept".to_owned(),
},
(Some(m), _) => format!("{m} /data new"),
(None, _) => "- /data new".to_string(),
},
false,
)),
"limits.budget" => Some((
"limits",
format!(
"transfers {} repos {} git mem {} ({}c {}fd {})",
get("transfers"),
get("open_repos"),
crate::budget::human(get("git_memory_bytes").parse().unwrap_or(0)),
get("cores"),
get("fds"),
crate::budget::human(get("mem_available").parse().unwrap_or(0)),
),
false,
)),
"serve.starting" => Some((
"starting",
format!("git refused (retry) until {} finishes", get("waiting_for")),
true,
)),
"serve.ready" => Some((
"serving",
format!("git: {} took {} ms", get("waited_for"), get("ms")),
false,
)),
"limits.handles.reclaimed" => Some((
"limits",
format!(
"repos {} -> {} ({} dropped, {} available)",
get("was"),
get("now"),
get("dropped"),
crate::budget::human(get("mem_available").parse().unwrap_or(0)),
),
true,
)),
"disktest" => Some((
"disktest",
format!("{} {}", clip(&get("path"), CPU_MODEL_COLS), line.message),
warn,
)),
"layout" => Some((
"layout",
format!("{} {}", clip(&get("path"), CPU_MODEL_COLS), line.message),
warn,
)),
"index.paused" => Some((
"index",
format!(
"paused: {} available < {} budget {}",
fmt_bytes(
line.get("available")
.and_then(|v| v.parse().ok())
.unwrap_or(0)
),
fmt_bytes(line.get("budget").and_then(|v| v.parse().ok()).unwrap_or(0)),
short_repo(&get("repo"))
),
true,
)),
"index.resumed" => Some((
"index",
format!(
"resumed after {} ms: {} available {}",
get("paused_ms"),
fmt_bytes(
line.get("available")
.and_then(|v| v.parse().ok())
.unwrap_or(0)
),
short_repo(&get("repo"))
),
false,
)),
"index.gave_up" => Some((
"index",
format!(
"GAVE UP ({}) after {} ms: {} available, {} needed {}",
get("reason"),
get("paused_ms"),
fmt_bytes(
line.get("available")
.and_then(|v| v.parse().ok())
.unwrap_or(0)
),
fmt_bytes(line.get("budget").and_then(|v| v.parse().ok()).unwrap_or(0)),
short_repo(&get("repo"))
),
true,
)),
"index.progress" => Some((
"index",
format!(
"{} {}% {}/{} {} {}/s {} re-inflations {} pauses rss {} avail {}",
short_repo(&get("repo")),
get("percent"),
fmt_count(
line.get("objects_done")
.and_then(|v| v.parse().ok())
.unwrap_or(0)
),
fmt_count(
line.get("objects_total")
.and_then(|v| v.parse().ok())
.unwrap_or(0)
),
get("phase"),
fmt_count(
line.get("objects_per_s")
.and_then(|v| v.parse().ok())
.unwrap_or(0)
),
get("reinflations"),
get("pauses"),
fmt_bytes(
line.get("peak_rss")
.and_then(|v| v.parse().ok())
.unwrap_or(0)
),
fmt_bytes(
line.get("mem_available")
.and_then(|v| v.parse().ok())
.unwrap_or(0)
),
),
false,
)),
"index.finished" => Some((
"index",
format!(
"{} done: {} objects in {} {} re-inflations {} pauses peak rss {}",
short_repo(&get("repo")),
fmt_count(
line.get("objects")
.and_then(|v| v.parse().ok())
.unwrap_or(0)
),
fmt_ms(
line.get("total_ms")
.and_then(|v| v.parse().ok())
.unwrap_or(0)
),
get("reinflations"),
get("pauses"),
fmt_bytes(
line.get("peak_rss")
.and_then(|v| v.parse().ok())
.unwrap_or(0)
)
),
false,
)),
"repo.purged" => Some((
"delete",
format!(
"{} {} reclaimed",
get("repo"),
fmt_bytes(
line.get("bytes_before")
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(0)
.saturating_sub(
line.get("bytes_after")
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(0)
)
)
),
false,
)),
_ => {
if warn {
Some(("WARN", format!("{event} {}", line.message), true))
} else {
None
}
}
};
}
let msg = line.message.as_str();
if msg.starts_with("DHCP")
|| msg.contains("lease") && line.target.contains("dhcp")
|| line.target.contains("pid1") && msg.contains("DHCP")
{
let some = |v: String| {
v.trim_start_matches("Some(")
.trim_end_matches(')')
.to_owned()
};
let text = if msg.starts_with("DHCP ACK") {
format!(
"{} {}/{} via {} lease {}s",
get("interface"),
get("address"),
get("prefix"),
some(get("gateway")),
get("lease_secs")
)
} else if msg.contains("renewed") {
format!(
"{} renewed via {} next in {}s",
get("interface"),
get("via"),
get("next_renew_in_secs")
)
} else if msg.contains("CHANGED") {
format!(
"{} NEW ADDRESS {}/{} via {}",
get("interface"),
get("address"),
get("prefix"),
some(get("gateway"))
)
} else {
format!("{} {}", get("interface"), first_clause(msg))
};
return Some(("dhcp", text, warn));
}
if msg.starts_with("gunnar ssh listener") && line.get("addr").is_some() {
return Some(("listen", format!("ssh {}", get("addr")), false));
}
if warn && (line.target.starts_with("gunnar") || line.target.starts_with("appliance_pid1")) {
return Some((
if line.level == Level::ERROR {
"ERROR"
} else {
"WARN"
},
first_clause(msg),
true,
));
}
None
}
pub fn index_row_text(
repo: &str,
s: &crate::storage::IndexSnapshot,
rate_bytes_per_s: Option<u64>,
) -> String {
let mut t = format!(
"{repo} {}{}% {}/{} {}",
if s.resumed { "resumed " } else { "" },
s.percent(),
fmt_count(s.objects_done),
fmt_count(s.objects_total),
s.phase
);
if let Some(r) = rate_bytes_per_s {
t.push_str(&format!(" {}/s", fmt_bytes(r)));
}
if s.pauses > 0 {
t.push_str(&format!(" {}p", s.pauses));
}
t
}
pub fn fmt_count(n: u64) -> String {
if n >= 1_000_000 {
format!("{:.1}M", n as f64 / 1e6)
} else if n >= 10_000 {
format!("{}k", n / 1000)
} else {
n.to_string()
}
}
pub fn fmt_ms(ms: u64) -> String {
if ms >= 3_600_000 {
format!("{:.1}h", ms as f64 / 3_600_000.0)
} else if ms >= 60_000 {
format!("{}m", ms / 60_000)
} else if ms >= 1000 {
format!("{:.1}s", ms as f64 / 1000.0)
} else {
format!("{ms} ms")
}
}
pub fn repo_label(git_dir: &std::path::Path, data_root: &std::path::Path) -> String {
let rel = git_dir.strip_prefix(data_root).unwrap_or(git_dir);
let parts: Vec<String> = rel
.components()
.filter_map(|c| match c {
std::path::Component::Normal(s) => Some(s.to_string_lossy().into_owned()),
_ => None,
})
.collect();
let n = parts.len();
if n >= 2 {
let name = parts[n - 1].strip_suffix(".git").unwrap_or(&parts[n - 1]);
format!("{}/{name}", parts[n - 2])
} else {
rel.display().to_string()
}
}
fn short_repo(root: &str) -> String {
let p = std::path::Path::new(root);
let dir = if p.file_name().is_some_and(|f| f == "gunnar-git.znippy") {
p.parent().unwrap_or(p)
} else {
p
};
repo_label(dir, std::path::Path::new("/"))
}
fn first_clause(msg: &str) -> String {
let end = msg.find([':', '.']).unwrap_or(msg.len());
msg[..end].trim().to_owned()
}
const CPU_MODEL_COLS: usize = 26;
fn fmt_bind(addr: SocketAddr) -> String {
if addr.ip().is_unspecified() {
format!("*:{}", addr.port())
} else {
addr.to_string()
}
}
fn clip(s: &str, n: usize) -> String {
if s.chars().count() <= n {
return s.to_owned();
}
let mut out: String = s.chars().take(n.saturating_sub(1)).collect();
out.push('\u{2026}');
out
}
fn pad(line: &str, width: usize) -> String {
let mut s: String = line.chars().take(width).collect();
let n = s.chars().count();
s.extend(std::iter::repeat_n(' ', width.saturating_sub(n)));
s
}
fn pct_of(part: u64, whole: u64) -> u64 {
if whole == 0 {
0
} else {
(part as u128 * 100 / whole as u128) as u64
}
}
pub fn fmt_bytes(n: u64) -> String {
const UNITS: [&str; 5] = ["B", "K", "M", "G", "T"];
let mut v = n as f64;
let mut u = 0;
while v >= 1000.0 && u < UNITS.len() - 1 {
v /= 1024.0;
u += 1;
}
if u == 0 {
format!("{n}B")
} else if v < 10.0 {
format!("{v:.1}{}", UNITS[u])
} else {
format!("{v:.0}{}", UNITS[u])
}
}
pub fn fmt_uptime(secs: u64) -> String {
let d = secs / 86_400;
let h = secs % 86_400 / 3600;
let m = secs % 3600 / 60;
let s = secs % 60;
if d > 0 {
format!("{d}d {h:02}:{m:02}:{s:02}")
} else {
format!("{h:02}:{m:02}:{s:02}")
}
}
pub fn clock(unix: u64) -> String {
let t = unix % 86_400;
format!("{:02}:{:02}:{:02}", t / 3600, t % 3600 / 60, t % 60)
}
fn short_bytes(n: u64) -> String {
const K: u64 = 1 << 10;
for (unit, per) in [("T", 1u64 << 40), ("G", 1 << 30), ("M", 1 << 20), ("K", K)] {
if n >= per {
return format!("{}.{}{unit}", n / per, ((n % per) * 10) / per);
}
}
n.to_string()
}
fn now_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub fn spawn(facts: Facts) -> bool {
let explicit = std::env::var(DEVICE_ENV).ok().filter(|p| !p.is_empty());
let gated = appliance_pid1::net::is_init() && appliance_pid1::net::booted_by_korp_start();
if explicit.is_none() && !gated {
tracing::debug!(
"console dashboard: not the appliance's PID 1 and {DEVICE_ENV} unset; not drawing"
);
return false;
}
let device = match &explicit {
Some(path) => std::fs::OpenOptions::new().write(true).open(path).ok(),
None => appliance_pid1::console::open_gfx_console(),
};
let Some(device) = device else {
tracing::info!(
device = explicit.as_deref().unwrap_or(DEVICE),
"console dashboard: the device cannot be opened (no VT in this kernel?); not drawing"
);
return false;
};
let Some(rx) = logtap::take_receiver() else {
tracing::info!(
"console dashboard: the log tap was already taken or never installed; not drawing"
);
return false;
};
if explicit.is_none() {
quiet_printk();
}
tracing::info!(
device = explicit.as_deref().unwrap_or(DEVICE),
"console dashboard drawing"
);
std::thread::Builder::new()
.name("gunnar-dashboard".into())
.spawn(move || {
let result =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(device, rx, facts)));
if result.is_err() {
tracing::error!(
"console dashboard thread panicked; the screen is frozen, serving continues"
);
}
})
.is_ok()
}
fn quiet_printk() {
let _ = std::fs::write("/proc/sys/kernel/printk", "1 4 1 7\n");
}
fn run(mut device: std::fs::File, rx: Receiver<LogLine>, facts: Facts) {
let (cols, rows) = winsize(&device).unwrap_or((DEFAULT_COLS, DEFAULT_ROWS));
let mut screen = Screen::new(facts);
let _ = device.write_all(b"\x1b[?25l\x1b[2J");
screen.sample();
let mut next_tick = Instant::now();
loop {
let now = Instant::now();
if now >= next_tick {
screen.sample();
next_tick = now + TICK;
}
let wait = next_tick.saturating_duration_since(now);
match rx.recv_timeout(wait) {
Ok(line) => {
screen.take(&line);
while let Ok(more) = rx.try_recv() {
screen.take(&more);
}
}
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => break,
}
if device
.write_all(screen.render_frame(cols, rows).as_bytes())
.is_err()
{
break;
}
let _ = device.flush();
}
}
fn winsize(device: &std::fs::File) -> Option<(usize, usize)> {
let ws = rustix::termios::tcgetwinsize(device).ok()?;
if ws.ws_col == 0 || ws.ws_row == 0 {
return None;
}
Some((ws.ws_col as usize, ws.ws_row as usize))
}
#[cfg(test)]
mod tests {
use super::*;
fn line(
level: Level,
target: &'static str,
event: Option<&str>,
msg: &str,
fields: &[(&'static str, &str)],
spans: &[(&'static str, &str)],
) -> LogLine {
let mut f: Vec<(&'static str, String)> =
fields.iter().map(|(k, v)| (*k, v.to_string())).collect();
if let Some(e) = event {
f.insert(0, ("event", e.to_owned()));
}
LogLine {
level,
target,
message: msg.to_owned(),
fields: f,
span_fields: spans.iter().map(|(k, v)| (*k, v.to_string())).collect(),
}
}
#[test]
fn every_shown_event_reads_as_one_short_row() {
let conn = [
("peer", "10.0.2.2:5123"),
("principal", "alice"),
("repo", "ns/repo"),
("service", "git-receive-pack"),
];
let cases: Vec<(LogLine, (&str, &str, bool))> = vec![
(
line(
Level::INFO,
"gunnar::ssh",
Some("ssh.auth.accepted"),
"publickey accepted",
&[(
"fingerprint",
"SHA256:abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG",
)],
&conn,
),
("login", "alice abcdefghijkl\u{2026} 10.0.2.2:5123", false),
),
(
line(
Level::INFO,
"gunnar::git",
Some("git.transfer.finished"),
"transfer finished",
&[("status", "ok"), ("elapsed_ms", "812")],
&conn,
),
("push", "ns/repo alice ok 812 ms", false),
),
(
line(
Level::WARN,
"gunnar::audit",
Some("grpc.call"),
"refused",
&[
("method", "Admin.X"),
("principal", "eve"),
("decision", "deny"),
("reason", "not admin"),
],
&[],
),
("DENIED", "Admin.X eve not admin", true),
),
(
line(
Level::INFO,
"gunnar::audit",
Some("grpc.call"),
"call",
&[
("method", "Identity.AddKey"),
("principal", "alice"),
("decision", "allow"),
],
&[],
),
("admin", "Identity.AddKey alice ", false),
),
(
line(
Level::INFO,
"appliance_pid1::dhcp",
None,
"DHCP ACK: lease taken. The renewal loop keeps it alive",
&[
("interface", "eth0"),
("address", "10.0.2.15"),
("prefix", "24"),
("gateway", "Some(10.0.2.2)"),
("lease_secs", "3600"),
],
&[],
),
("dhcp", "eth0 10.0.2.15/24 via 10.0.2.2 lease 3600s", false),
),
(
line(
Level::WARN,
"appliance_pid1::runtime",
None,
"DHCP renewal CHANGED THE ADDRESS mid-life — a big event",
&[
("interface", "eth0"),
("address", "10.0.2.99"),
("prefix", "24"),
("gateway", "Some(10.0.2.2)"),
],
&[],
),
("dhcp", "eth0 NEW ADDRESS 10.0.2.99/24 via 10.0.2.2", true),
),
(
line(
Level::INFO,
"gunnar::git",
Some("repo.created_on_first_push"),
"created",
&[],
&conn,
),
("repo+", "ns/repo alice", false),
),
(
line(
Level::WARN,
"gunnar_server::wg",
None,
"the tunnel refused: no address. details follow",
&[],
&[],
),
("WARN", "the tunnel refused", true),
),
(
line(
Level::INFO,
"gunnar_server::boot",
Some("install.mode"),
"install record read off /boot/korp/install",
&[
("mode", "upgrade"),
("kept", "korp-data"),
("kept_partno", "3"),
("kept_gib", "225.0"),
],
&[],
),
("install", "upgrade /data kept (p3 225.0G)", false),
),
(
line(
Level::INFO,
"gunnar_server::boot",
Some("install.mode"),
"install record read off /boot/korp/install",
&[
("mode", "fresh"),
("kept", "-"),
("kept_partno", "-"),
("kept_gib", "0.0"),
],
&[],
),
("install", "fresh /data new", false),
),
(
line(
Level::INFO,
"gunnar_server::boot",
Some("install.mode"),
"install record read off /boot/korp/install",
&[
("mode", "upgrade"),
("kept", "data-set"),
("kept_set", "4\u{d7}75G"),
],
&[],
),
(
"install",
"upgrade /data kept (data set 4\u{d7}75G)",
false,
),
),
(
line(
Level::INFO,
"gunnar_server::boot",
Some("install.mode"),
"install record read off /boot/korp/install",
&[("mode", "upgrade"), ("kept", "data-set"), ("kept_set", "-")],
&[],
),
("install", "upgrade /data kept (data set)", false),
),
(
line(
Level::INFO,
"gunnar_server::boot",
Some("install.mode"),
"install record read off /boot/korp/install",
&[("mode", "upgrade"), ("kept", "-")],
&[],
),
("install", "upgrade /data kept", false),
),
(
line(
Level::INFO,
"gunnar_server::disktest",
Some("disktest"),
"seq w 812 MB/s \u{b7} r 1.9 GB/s \u{b7} 4k sync 3100 ops/s (1.6 s)",
&[("path", "/data")],
&[],
),
(
"disktest",
"/data seq w 812 MB/s \u{b7} r 1.9 GB/s \u{b7} 4k sync 3100 ops/s (1.6 s)",
false,
),
),
(
line(
Level::INFO,
"gunnar::slow",
Some("layout"),
"INTEGRITY ONLY \u{b7} off-stripe: p3 /dev/vda3",
&[("path", "/boot/korp/layout.conf"), ("claim", "off-stripe")],
&[],
),
(
"layout",
"/boot/korp/layout.conf INTEGRITY ONLY \u{b7} off-stripe: p3 /dev/vda3",
false,
),
),
(
line(
Level::WARN,
"gunnar::slow",
Some("layout"),
"INTEGRITY ONLY \u{b7} SAME DEVICE as /data \u{2014} not off-stripe \
(fallback /data)",
&[("path", "/boot/korp/layout.conf"), ("claim", "same-device")],
&[],
),
(
"layout",
"/boot/korp/layout.conf INTEGRITY ONLY \u{b7} SAME DEVICE as /data \
\u{2014} not off-stripe (fallback /data)",
true,
),
),
(
line(
Level::INFO,
"gunnar::limits",
Some("limits.budget"),
"transfers 64 \u{b7} open repos 204",
&[
("transfers", "64"),
("open_repos", "204"),
("git_memory_bytes", "4289783800"),
("cores", "4"),
("fds", "1048576"),
("mem_available", "8579567616"),
],
&[],
),
(
"limits",
"transfers 64 repos 204 git mem 4.0G (4c 1048576fd 8.0G)",
false,
),
),
(
line(
Level::INFO,
"gunnar::git",
Some("git.transfer.waited"),
"transfers are WAITING",
&[
("waited_total", "412"),
("mean_wait_ms", "180"),
("peak_wait_ms", "1974"),
("limit", "64"),
],
&[],
),
(
"waiting",
"412 waited mean 180 ms worst 1974 ms limit 64",
false,
),
),
(
line(
Level::INFO,
"gunnar::limits",
Some("serve.starting"),
"git transfers are refused",
&[("waiting_for", "the start-up disk test")],
&[],
),
(
"starting",
"git refused (retry) until the start-up disk test finishes",
true,
),
),
(
line(
Level::INFO,
"gunnar::limits",
Some("serve.ready"),
"serving git",
&[("waited_for", "the start-up disk test"), ("ms", "710")],
&[],
),
("serving", "git: the start-up disk test took 710 ms", false),
),
(
line(
Level::WARN,
"gunnar::limits",
Some("limits.handles.reclaimed"),
"memory is short",
&[
("was", "50"),
("now", "25"),
("dropped", "25"),
("mem_available", "432013312"),
("mem_total", "2147483648"),
],
&[],
),
(
"limits",
"repos 50 -> 25 (25 dropped, 412M available)",
true,
),
),
(
line(
Level::WARN,
"gunnar_server::disktest",
Some("disktest"),
"failed: write: No space left on device",
&[("path", "/data")],
&[],
),
(
"disktest",
"/data failed: write: No space left on device",
true,
),
),
(
line(
Level::INFO,
"gunnar::store",
Some("store.writer_selected"),
"the writer arm",
&[
("writer", "uring"),
("source", "cmdline"),
("fallback", "-"),
],
&[],
),
("writer", "uring", false),
),
(
line(
Level::INFO,
"gunnar::store",
Some("store.writer_selected"),
"the writer arm",
&[
("writer", "safe"),
("source", "cmdline"),
("fallback", "io_uring unavailable: ENOSYS"),
],
&[],
),
("writer", "safe (io_uring unavailable: ENOSYS)", false),
),
];
for (l, (kind, text, warn)) in cases {
let got = describe(&l).unwrap_or_else(|| panic!("{:?} must be shown", l.event()));
assert_eq!((got.0, got.1.as_str(), got.2), (kind, text, warn));
}
}
#[test]
fn the_noise_stays_in_the_log() {
let quiet = [
line(
Level::INFO,
"gunnar::git",
Some("git.upload_pack.advertised"),
"served the advertisement",
&[],
&[],
),
line(
Level::INFO,
"gunnar_server::serve",
None,
"issuer ready (persisted)",
&[],
&[],
),
line(
Level::INFO,
"gunnar::audit",
Some("grpc.request.done"),
"done",
&[],
&[],
),
line(
Level::WARN,
"hyper::proto",
None,
"some third-party warning",
&[],
&[],
),
];
for l in &quiet {
assert_eq!(describe(l), None, "{:?} / {:?}", l.event(), l.message);
}
}
#[test]
fn numbers_format_for_a_narrow_cell() {
assert_eq!(fmt_bytes(900), "900B");
assert_eq!(fmt_bytes(12 * 1024), "12K");
assert_eq!(fmt_bytes(3_300_000_000), "3.1G");
assert_eq!(fmt_bytes(300 * 1024 * 1024 * 1024), "300G");
assert_eq!(
fmt_uptime(3 * 86_400 + 4 * 3600 + 12 * 60 + 7),
"3d 04:12:07"
);
assert_eq!(fmt_uptime(59), "00:00:59");
assert_eq!(clock(1_700_000_000), "22:13:20");
assert_eq!(pad("ab", 4), "ab ");
assert_eq!(pad("abcdef", 4), "abcd");
assert_eq!(
pad("åäö", 4),
"åäö ",
"padding counts characters, not bytes"
);
assert_eq!(
clip(
"32x AMD Ryzen Threadripper PRO 3975WX 32-Cores",
CPU_MODEL_COLS
),
"32x AMD Ryzen Threadrippe\u{2026}"
);
assert_eq!(
clip("2x AMD EPYC 7542 32-Core", CPU_MODEL_COLS),
"2x AMD EPYC 7542 32-Core"
);
assert_eq!(fmt_bind("0.0.0.0:2222".parse().unwrap()), "*:2222");
assert_eq!(fmt_bind("[::]:50051".parse().unwrap()), "*:50051");
assert_eq!(
fmt_bind("127.0.0.1:50051".parse().unwrap()),
"127.0.0.1:50051"
);
}
#[test]
fn the_index_row_says_what_the_indexer_is_doing_and_the_events_read_as_rows() {
use crate::storage::IndexSnapshot;
let snap = |done: u64, phase: &'static str, resumed: bool, pauses: u64| IndexSnapshot {
active: true,
resumed,
pack_id: 3,
objects_done: done,
objects_total: 13_948_244,
bytes_done: done * 600,
bytes_total: 8_700_000_000,
phase,
started_at_ms: 1,
reinflations: 0,
pauses,
};
assert_eq!(
index_row_text(
"alice/linux",
&snap(4_700_000, "resolve", false, 0),
Some(2_100_000_000)
),
"alice/linux 33% 4.7M/13.9M resolve 2.0G/s"
);
assert_eq!(
index_row_text("alice/linux", &snap(0, "walk", true, 0), None),
"alice/linux resumed 0% 0/13.9M walk"
);
assert_eq!(
index_row_text("a/b", &snap(13_948_244, "fold", false, 2), None),
"a/b 100% 13.9M/13.9M fold 2p"
);
let mut s = Screen::new(facts());
s.now_unix = 1_700_000_000;
let dir = PathBuf::from("/data/repos/alice/linux.git");
let t0 = Instant::now();
let f1 = probe::IndexFacts {
git_dir: dir.clone(),
snap: snap(1_000_000, "resolve", false, 0),
};
s.sample_index(&[f1], t0);
assert_eq!(s.index.len(), 1);
assert_eq!(s.index[0].kind, "index");
assert_eq!(s.index[0].text, "alice/linux 7% 1.0M/13.9M resolve");
assert!(!s.index[0].warn);
let f2 = probe::IndexFacts {
git_dir: dir.clone(),
snap: snap(3_000_000, "paused", false, 1),
};
s.sample_index(&[f2], t0 + Duration::from_secs(2));
assert_eq!(
s.index[0].text,
"alice/linux 21% 3.0M/13.9M paused 572M/s 1p"
);
assert!(s.index[0].warn, "a paused pack is a warning row");
let mut idle = snap(3_000_000, "done", false, 1);
idle.active = false;
s.sample_index(
&[probe::IndexFacts {
git_dir: dir,
snap: idle,
}],
t0 + Duration::from_secs(4),
);
assert!(s.index.is_empty(), "an idle counter draws no row");
s.sample_index(
&[probe::IndexFacts {
git_dir: PathBuf::from("/data/repos/a/b.git"),
snap: snap(5, "walk", false, 0),
}],
t0,
);
s.push(Row {
at: 1_700_000_000,
kind: "login",
text: "x".into(),
warn: false,
});
let frame = s.render_frame(80, 25);
let lines: Vec<&str> = frame.split("\r\n").collect();
assert!(
lines[5].contains("index a/b 0% 5/13.9M walk"),
"row 5: {:?}",
lines[5]
);
assert!(lines[6].contains("login x"), "row 6: {:?}", lines[6]);
let paused = line(
Level::WARN,
"gunnar::index",
Some("index.paused"),
"index paused",
&[
("available", "3113851289"),
("budget", "3435973836"),
("repo", "/data/repos/alice/linux.git/gunnar-git.znippy"),
],
&[],
);
assert_eq!(
describe(&paused),
Some((
"index",
"paused: 2.9G available < 3.2G budget alice/linux".to_string(),
true
))
);
let resumed = line(
Level::INFO,
"gunnar::index",
Some("index.resumed"),
"index resumed",
&[
("available", "5368709120"),
("budget", "3435973836"),
("paused_ms", "8120"),
("repo", "/data/repos/alice/linux.git/gunnar-git.znippy"),
],
&[],
);
assert_eq!(
describe(&resumed),
Some((
"index",
"resumed after 8120 ms: 5.0G available alice/linux".to_string(),
false
))
);
let finished = line(
Level::INFO,
"gunnar::index",
Some("index.finished"),
"indexed",
&[
("objects", "13948244"),
("total_ms", "2520000"),
("reinflations", "1234"),
("pauses", "2"),
("peak_rss", "9126805504"),
("repo", "/data/repos/alice/linux.git/gunnar-git.znippy"),
],
&[],
);
assert_eq!(describe(&finished), Some(("index", "alice/linux done: 13.9M objects in 42m 1234 re-inflations 2 pauses peak rss 8.5G".to_string(), false)));
let progress = line(
Level::INFO,
"gunnar::index",
Some("index.progress"),
"index",
&[
("percent", "78"),
("objects_done", "11000000"),
("objects_total", "13948244"),
("phase", "resolve"),
("objects_per_s", "744000"),
("reinflations", "12"),
("pauses", "1"),
("peak_rss", "13958643712"),
("mem_available", "3286511616"),
("repo", "/data/repos/alice/linux.git/gunnar-git.znippy"),
],
&[],
);
assert_eq!(
describe(&progress),
Some((
"index",
"alice/linux 78% 11.0M/13.9M resolve 744k/s 12 re-inflations 1 pauses rss 13G avail 3.1G".to_string(),
false
))
);
let gave_up = line(
Level::WARN,
"gunnar::index",
Some("index.gave_up"),
"gave up",
&[
("reason", "nothing-to-give"),
("paused_ms", "31200"),
("available", "3113851289"),
("budget", "3435973836"),
("repo", "/data/repos/alice/linux.git/gunnar-git.znippy"),
],
&[],
);
assert_eq!(
describe(&gave_up),
Some((
"index",
"GAVE UP (nothing-to-give) after 31200 ms: 2.9G available, 3.2G needed alice/linux".to_string(),
true
))
);
let purged = line(
Level::INFO,
"gunnar::store",
Some("repo.purged"),
"reclaimed",
&[
("repo", "alice/linux"),
("bytes_before", "26843545600"),
("bytes_after", "0"),
],
&[],
);
assert_eq!(
describe(&purged),
Some(("delete", "alice/linux 25G reclaimed".to_string(), false))
);
assert_eq!(fmt_count(812), "812");
assert_eq!(fmt_count(45_000), "45k");
assert_eq!(fmt_ms(3_700_000), "1.0h");
assert_eq!(
repo_label(
std::path::Path::new("/data/repos/ns/x.git"),
std::path::Path::new("/data")
),
"ns/x"
);
}
fn facts() -> Facts {
Facts {
ssh_bind: "0.0.0.0:2222".parse().unwrap(),
grpc_bind: Some("0.0.0.0:50051".parse().unwrap()),
data_root: PathBuf::from("/data"),
build: "v1.1.3-145-g1eb7083".into(),
engine: None,
replication: crate::replication_view::ReplicationView::new(),
tenants: TenantView::new(),
flow: FlowView::new(),
}
}
#[test]
fn the_tenants_row_is_drawn_when_filled_and_absent_when_not() {
let mut s = Screen::new(facts());
s.now_unix = 1_000;
s.sample_tenants(None);
let frame = s.render_frame(80, 24);
assert!(
!frame.contains("tenants"),
"an unfilled view must draw no tenants row:\n{frame}"
);
s.sample_tenants(Some(TenantCounts {
paid: 118,
grace: 4,
suspended: 3,
}));
let frame = s.render_frame(80, 24);
assert!(
frame.contains("tenants paid 118 grace 4 suspended 3"),
"the row names all three counts:\n{frame}"
);
assert!(
frame.contains("!00:16:40 tenants"),
"suspended > 0 is a `!` row:\n{frame}"
);
s.sample_tenants(Some(TenantCounts {
paid: 1,
grace: 0,
suspended: 0,
}));
let frame = s.render_frame(80, 24);
assert!(
frame.contains(" 00:16:40 tenants paid 1 grace 0 suspended 0"),
"nobody suspended is an ordinary row:\n{frame}"
);
}
#[test]
fn a_standby_that_has_never_synced_is_a_warn_row_on_the_band() {
use crate::replication_view::{ReplicationReport, StandbyRow};
let mut s = Screen::new(facts());
s.now_unix = 1_000;
s.sample_replication(&ReplicationReport {
configured: true,
failed: 8,
standbys: vec![StandbyRow {
name: "standby".into(),
remote: "ssh://git@standby:2222".into(),
last_success_unix_ms: 0,
stale: true,
..StandbyRow::default()
}],
..ReplicationReport::default()
});
let frame = s.render_frame(100, 25);
assert!(
frame.contains("last successful replication: NEVER"),
"the band must say it in those words:\n{frame}"
);
assert!(
frame.contains("!00:16:40 standby"),
"a stale standby must be a WARN row (`!`), not an ordinary one:\n{frame}"
);
let mut s = Screen::new(facts());
s.now_unix = 1_000;
s.sample_replication(&ReplicationReport {
configured: true,
synced: 105,
failed: 40,
standbys: vec![StandbyRow {
name: "standby".into(),
last_success_unix_ms: 997_000,
stale: false,
..StandbyRow::default()
}],
..ReplicationReport::default()
});
let frame = s.render_frame(100, 25);
assert!(!frame.contains("NEVER"), "{frame}");
assert!(frame.contains(" 00:16:40 standby"), "{frame}");
assert!(
frame.contains("synced 105 failed 40 last 00:00:03 ago"),
"{frame}"
);
let mut s = Screen::new(facts());
s.now_unix = 1_000;
s.sample_replication(&ReplicationReport {
configured: true,
..ReplicationReport::default()
});
assert!(s.render_frame(100, 25).contains("reports NO standby rows"));
let mut s = Screen::new(facts());
s.now_unix = 1_000;
s.sample_replication(&ReplicationReport::default());
assert!(s.standby.is_empty());
}
#[test]
fn a_frame_is_exactly_rows_lines_of_width_minus_one_and_the_newest_events_win() {
let mut s = Screen::new(facts());
s.hostname = Some("gunnar-appliance".into());
s.addresses = vec![probe::IfAddr {
interface: "eth0".into(),
address: "198.51.100.155".parse().unwrap(),
prefix: 24,
}];
s.memory = Some((16 * 1024 * 1024 * 1024, 15 * 1024 * 1024 * 1024));
s.disk = Some((300 * 1024 * 1024 * 1024, 297 * 1024 * 1024 * 1024));
s.uptime_secs = Some(3 * 86_400 + 4 * 3600 + 12 * 60 + 7);
s.cpus = 4;
s.cpu_model = Some("AMD EPYC 7542".into());
s.disk_kind = Some(probe::DiskKind {
fs: "xfs".into(),
set: Some(probe::SetFacts {
level: "raid0".into(),
members: 4,
member_bytes: 75 * 1024 * 1024 * 1024,
chunk_bytes: 524288,
}),
});
s.cpu_percent = Some(12);
s.load1 = Some("0.10".into());
s.now_unix = 1_700_000_000;
for i in 0..(EVENT_ROWS + 5) {
s.push(Row {
at: i as u64,
kind: "login",
text: format!("user{i}"),
warn: i % 7 == 0,
});
}
let frame = s.render_frame(80, 25);
assert!(
frame.starts_with("\x1b[H"),
"homes the cursor, never clears"
);
let raw: Vec<&str> = frame["\x1b[H".len()..].split("\r\n").collect();
assert_eq!(raw.len(), 25);
assert!(
raw.iter().all(|l| l.ends_with("\x1b[K")),
"every row ends with erase-to-EOL so column 80 is cleared: {:?}",
raw.iter()
.map(|l| &l[l.len().saturating_sub(4)..])
.collect::<Vec<_>>()
);
let lines: Vec<&str> = raw
.iter()
.map(|l| l.strip_suffix("\x1b[K").unwrap())
.collect();
assert!(
lines.iter().all(|l| l.chars().count() == 79),
"every row is padded to cols-1: {:?}",
lines.iter().map(|l| l.chars().count()).collect::<Vec<_>>()
);
assert!(
lines[0].contains("gunnar v1.1.3-145-g1eb7083")
&& lines[0].contains("up 3d 04:12:07")
&& lines[0].contains("22:13:20 UTC"),
"{}",
lines[0]
);
assert!(
lines[1].contains("host gunnar-appliance eth0 198.51.100.155/24"),
"{}",
lines[1]
);
assert!(
lines[2].contains("cpu 4x AMD EPYC 7542 12% load 0.10 ram 1.0G/16G 6%"),
"{}",
lines[2]
);
assert!(
lines[3].contains(
"disk /data 3.0G/300G 1% 4\u{d7}75G raid0 xfs ssh *:2222 grpc *:50051"
),
"{}",
lines[3]
);
s.writer = Some("uring");
let frame = s.render_frame(80, 25);
let l3 = frame["\x1b[H".len()..].split("\r\n").nth(3).unwrap();
assert!(
l3.contains("4\u{d7}75G raid0 xfs uring ssh *:2222 grpc *:50051"),
"{l3}"
);
assert_eq!(
l3.strip_suffix("\x1b[K").unwrap().chars().count(),
79,
"still fits: {l3}"
);
s.writer = None;
assert!(lines[4].starts_with("\u{2500}\u{2500}\u{2500}"));
assert_eq!(s.rows.len(), EVENT_ROWS, "the ring holds EVENT_ROWS");
assert!(
lines[5].contains("user5"),
"the oldest kept row is the first body row: {}",
lines[5]
);
assert!(
lines[24].contains("user24"),
"the newest row is the last: {}",
lines[24]
);
assert!(
lines[7].starts_with('!'),
"a warning row is marked: {}",
lines[7]
);
assert!(
lines[5].starts_with(' '),
"a plain row is not: {}",
lines[5]
);
}
#[test]
fn a_dark_screen_says_dash_not_zero() {
let s = Screen::new(facts());
let frame = s.render_frame(80, 25);
assert!(
frame.contains("up -")
&& frame.contains("host (none) no address")
&& frame.contains("cpu - -% load - ram -")
&& frame.contains("disk /data - ssh *:2222"),
"{frame}"
);
}
#[test]
fn the_disk_cell_says_what_data_is_from_fixture_mounts_and_sysfs() {
const MOUNTS: &str = "rootfs / rootfs rw 0 0\nproc /proc proc rw 0 0\n/dev/md/korp-data /data xfs rw,nosuid,nodev,noexec,relatime,attr2,inode64 0 0\n/dev/vda3 /mnt/fat\\040box vfat rw,dirsync 0 0\n";
let md = probe::parse_mounts(MOUNTS, "/data").unwrap();
assert_eq!(
md,
probe::MountFacts {
source: "/dev/md/korp-data".into(),
fstype: "xfs".into()
}
);
let fat = probe::parse_mounts(MOUNTS, "/mnt/fat box").unwrap();
assert_eq!(fat.fstype, "vfat");
assert_eq!(
probe::parse_mounts(MOUNTS, "/tmp/x"),
None,
"a plain directory is no mount"
);
let dir = tempfile::tempdir().unwrap();
let mdir = dir.path().join("md");
std::fs::create_dir_all(&mdir).unwrap();
std::fs::write(mdir.join("level"), "raid0\n").unwrap();
std::fs::write(mdir.join("raid_disks"), "4\n").unwrap();
std::fs::write(mdir.join("chunk_size"), "524288\n").unwrap();
for m in ["vdb1", "vdc1", "vdd1", "vde1"] {
let d = mdir.join(format!("dev-{m}"));
std::fs::create_dir_all(&d).unwrap();
std::fs::write(d.join("size"), "78643200\n").unwrap();
}
let set = probe::read_set_facts(dir.path()).unwrap();
assert_eq!(
set,
probe::SetFacts {
level: "raid0".into(),
members: 4,
member_bytes: 75 * 1024 * 1024 * 1024,
chunk_bytes: 524288
}
);
assert_eq!(
probe::DiskKind::from_mount(&md, Some(set)).to_string(),
"4\u{d7}75G raid0 xfs"
);
assert_eq!(probe::DiskKind::from_mount(&fat, None).to_string(), "fat");
assert_eq!(
probe::DiskKind::from_mount(&md, None).to_string(),
"xfs",
"a plain xfs disk names only the filesystem"
);
let half = tempfile::tempdir().unwrap();
std::fs::create_dir_all(half.path().join("md")).unwrap();
std::fs::write(half.path().join("md/level"), "raid0\n").unwrap();
std::fs::write(half.path().join("md/raid_disks"), "4\n").unwrap();
assert_eq!(probe::read_set_facts(half.path()), None);
assert_eq!(probe::read_set_facts(&dir.path().join("nowhere")), None);
}
#[test]
fn the_address_line_does_not_overflow_the_width() {
let mut s = Screen::new(facts());
for i in 0..12u8 {
s.addresses.push(probe::IfAddr {
interface: format!("eth{i}"),
address: format!("10.0.{i}.15").parse().unwrap(),
prefix: 24,
});
}
let frame = s.render_frame(80, 25);
let line2 = frame.split("\r\n").nth(1).unwrap();
assert!(
line2.contains("eth0 10.0.0.15/24") && !line2.contains("eth11"),
"{line2}"
);
}
}