Initial commit

This commit is contained in:
2026-03-30 22:58:06 +01:00
commit d05390ab7a
10 changed files with 979 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"WebFetch(domain:crates.io)"
]
}
}
+1
View File
@@ -0,0 +1 @@
/target
Generated
+457
View File
@@ -0,0 +1,457 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
]
[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "bitflags"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
[[package]]
name = "block2"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
dependencies = [
"objc2",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "clap"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]]
name = "ctrlc"
version = "3.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162"
dependencies = [
"dispatch2",
"nix",
"windows-sys",
]
[[package]]
name = "dispatch2"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [
"bitflags",
"block2",
"libc",
"objc2",
]
[[package]]
name = "flate2"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]]
name = "futures-core"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "inotify"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199"
dependencies = [
"bitflags",
"futures-core",
"inotify-sys",
"libc",
"tokio",
]
[[package]]
name = "inotify-sys"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
dependencies = [
"libc",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
version = "0.2.183"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "mio"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
dependencies = [
"libc",
"wasi",
"windows-sys",
]
[[package]]
name = "nix"
version = "0.31.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3"
dependencies = [
"bitflags",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "objc2"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
dependencies = [
"objc2-encode",
]
[[package]]
name = "objc2-encode"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "simd-adler32"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "socket2"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "srvmon"
version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"ctrlc",
"flate2",
"inotify",
"serde",
"serde_json",
]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tokio"
version = "1.50.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
dependencies = [
"libc",
"mio",
"pin-project-lite",
"socket2",
"windows-sys",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "srvmon"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1.0.102"
clap = { version = "4.6.0", features = ["derive"] }
ctrlc = "3.5.2"
flate2 = "1.1.9"
inotify = "0.11.1"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
+85
View File
@@ -0,0 +1,85 @@
use std::io::{self, Write};
use crate::latest::Run;
use crate::top::TopEntry;
fn format_count(n: u64) -> String {
if n >= 1_000_000 {
format!("{}M", n / 1_000_000)
} else if n >= 1_000 {
format!("{}K", n / 1_000)
} else {
n.to_string()
}
}
pub fn enter_alt_screen() {
print!("\x1b[?1049h\x1b[?25l");
io::stdout().flush().unwrap();
}
pub fn leave_alt_screen() {
print!("\x1b[?25h\x1b[?1049l");
io::stdout().flush().unwrap();
}
pub fn render(latest: &[Run], top: &[TopEntry], lines: usize) {
let latest_slice = &latest[..latest.len().min(lines)];
let top_slice = &top[..top.len().min(lines)];
let ln_w = latest_slice.iter().map(|r| r.service.len()).max().unwrap_or(0);
let lc_w = latest_slice
.iter()
.map(|r| format_count(r.count).len())
.max()
.unwrap_or(0);
let rn_w = top_slice.iter().map(|t| t.service.len()).max().unwrap_or(0);
let rc_w = top_slice
.iter()
.map(|t| format_count(t.count).len())
.max()
.unwrap_or(0);
let left_w = if ln_w > 0 { ln_w + 1 + lc_w } else { 6 };
let left_w = left_w.max(6);
let mut out = String::new();
out.push_str("\x1b[H");
out.push_str(&format!(
"\x1b[1m{:<lw$} {}\x1b[0m\x1b[K\n",
"Latest",
"Top",
lw = left_w
));
for i in 0..lines {
let left = if i < latest_slice.len() {
format!(
"{:<w$} {:>cw$}",
latest_slice[i].service,
format_count(latest_slice[i].count),
w = ln_w,
cw = lc_w
)
} else {
" ".repeat(left_w)
};
let right = if i < top_slice.len() {
format!(
"{:<w$} {:>cw$}",
top_slice[i].service,
format_count(top_slice[i].count),
w = rn_w,
cw = rc_w
)
} else {
String::new()
};
out.push_str(&format!("{} {}\x1b[K\n", left, right));
}
out.push_str("\x1b[J");
print!("{out}");
io::stdout().flush().unwrap();
}
+44
View File
@@ -0,0 +1,44 @@
use crate::logs::Entry;
pub struct Run {
pub service: String,
pub count: u64,
}
pub fn build(entries: &mut [Entry]) -> Vec<Run> {
entries.sort_by(|a, b| a.ts.partial_cmp(&b.ts).unwrap_or(std::cmp::Ordering::Equal));
let mut runs: Vec<Run> = Vec::new();
for entry in entries.iter() {
if let Some(last) = runs.last_mut() {
if last.service == entry.service {
last.count += 1;
continue;
}
}
runs.push(Run {
service: entry.service.clone(),
count: 1,
});
}
runs.reverse();
runs
}
pub fn push(latest: &mut Vec<Run>, entries: &[Entry]) {
for entry in entries {
if let Some(first) = latest.first_mut() {
if first.service == entry.service {
first.count += 1;
continue;
}
}
latest.insert(
0,
Run {
service: entry.service.clone(),
count: 1,
},
);
}
latest.truncate(1000);
}
+153
View File
@@ -0,0 +1,153 @@
use std::fs::{self, File};
use std::io::{BufReader, Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use anyhow::Result;
use flate2::read::GzDecoder;
use serde::Deserialize;
pub struct LogFile {
pub path: PathBuf,
pub name: String,
pub service: String,
}
pub struct Entry {
pub service: String,
pub ts: f64,
}
#[derive(Deserialize)]
struct LogLine {
ts: f64,
}
pub fn extract_service(filename: &str) -> Option<String> {
filename
.strip_suffix(".access.log.gz")
.or_else(|| filename.strip_suffix(".access.log"))
.map(String::from)
}
pub fn discover(dir: &Path, suffix: &str) -> Result<Vec<LogFile>> {
let mut files = Vec::new();
for entry in fs::read_dir(dir)? {
let entry = entry?;
let name = entry.file_name().to_string_lossy().to_string();
if name.ends_with(suffix) {
if let Some(service) = extract_service(&name) {
files.push(LogFile {
path: entry.path(),
name,
service,
});
}
}
}
Ok(files)
}
fn count_newlines(mut reader: impl Read) -> Result<u64> {
let mut buf = [0u8; 65536];
let mut count = 0u64;
loop {
let n = reader.read(&mut buf)?;
if n == 0 {
break;
}
count += buf[..n].iter().filter(|&&b| b == b'\n').count() as u64;
}
Ok(count)
}
/// Returns (new_offset, count, truncated).
/// If the file was truncated (size < offset), counts from 0 and signals truncation
/// so the caller can reset the service's total count.
pub fn count_lines_from(path: &Path, offset: u64) -> Result<(u64, u64, bool)> {
let mut file = File::open(path)?;
let size = file.metadata()?.len();
let truncated = size < offset;
let actual_offset = if truncated { 0 } else { offset };
if size == actual_offset {
return Ok((size, 0, truncated));
}
file.seek(SeekFrom::Start(actual_offset))?;
let count = count_newlines(BufReader::with_capacity(65536, file))?;
Ok((size, count, truncated))
}
pub fn count_lines_gz(path: &Path) -> Result<u64> {
count_newlines(GzDecoder::new(File::open(path)?))
}
pub fn read_tail_entries(path: &Path, service: &str, tail_bytes: u64) -> Result<Vec<Entry>> {
let mut file = File::open(path)?;
let size = file.metadata()?.len();
let start = size.saturating_sub(tail_bytes);
file.seek(SeekFrom::Start(start))?;
let mut data = Vec::new();
file.read_to_end(&mut data)?;
let mut entries = Vec::new();
let skip_first = start > 0;
for (i, line) in data.split(|&b| b == b'\n').enumerate() {
if skip_first && i == 0 {
continue;
}
if line.is_empty() {
continue;
}
if let Ok(s) = std::str::from_utf8(line) {
if let Ok(parsed) = serde_json::from_str::<LogLine>(s) {
entries.push(Entry {
service: service.to_string(),
ts: parsed.ts,
});
}
}
}
Ok(entries)
}
/// Returns (new_offset, count, truncated, entries).
pub fn read_new_entries(
path: &Path,
service: &str,
offset: u64,
) -> Result<(u64, u64, bool, Vec<Entry>)> {
let mut file = File::open(path)?;
let size = file.metadata()?.len();
let truncated = size < offset;
let actual_offset = if truncated { 0 } else { offset };
if size == actual_offset {
return Ok((actual_offset, 0, truncated, vec![]));
}
file.seek(SeekFrom::Start(actual_offset))?;
let mut data = Vec::new();
file.read_to_end(&mut data)?;
let end = match data.iter().rposition(|&b| b == b'\n') {
Some(pos) => pos + 1,
None => return Ok((actual_offset, 0, truncated, vec![])),
};
let new_offset = actual_offset + end as u64;
let mut entries = Vec::new();
for line in data[..end].split(|&b| b == b'\n') {
if line.is_empty() {
continue;
}
if let Ok(s) = std::str::from_utf8(line) {
if let Ok(parsed) = serde_json::from_str::<LogLine>(s) {
entries.push(Entry {
service: service.to_string(),
ts: parsed.ts,
});
}
}
}
let count = entries.len() as u64;
Ok((new_offset, count, truncated, entries))
}
+170
View File
@@ -0,0 +1,170 @@
mod display;
mod latest;
mod logs;
mod state;
mod top;
use std::collections::HashMap;
use std::ffi::OsString;
use std::io;
use std::path::PathBuf;
use anyhow::{bail, Result};
use clap::Parser;
use inotify::{Inotify, WatchDescriptor, WatchMask};
#[derive(Parser)]
#[command(name = "srvmon", about = "Monitor Caddy access logs")]
struct Cli {
/// Log directory path
#[arg(short, long, default_value = "/var/log/caddy")]
dir: PathBuf,
/// Number of lines to display per column
#[arg(short, long, default_value_t = 8)]
lines: usize,
}
fn main() -> Result<()> {
let cli = Cli::parse();
let dir = &cli.dir;
if !dir.is_dir() {
bail!("{} is not a directory", dir.display());
}
ctrlc::set_handler(|| {
display::leave_alt_screen();
std::process::exit(0);
})?;
let mut state = state::State::load(dir);
// Process compressed logs
let gz_new: Vec<_> = logs::discover(dir, ".access.log.gz")?
.into_iter()
.filter(|f| !state.processed_gz.contains(&f.name))
.collect();
for (i, f) in gz_new.iter().enumerate() {
eprint!("\rProcessing compressed logs ({}/{})...", i + 1, gz_new.len());
let count = logs::count_lines_gz(&f.path)?;
*state.counts.entry(f.service.clone()).or_default() += count;
state.processed_gz.insert(f.name.clone());
}
if !gz_new.is_empty() {
eprintln!(" done");
}
// Process active log files
let log_files = logs::discover(dir, ".access.log")?;
let mut all_recent = Vec::new();
for (i, f) in log_files.iter().enumerate() {
eprint!("\rProcessing logs ({}/{})...", i + 1, log_files.len());
let offset = state.offsets.get(&f.name).copied().unwrap_or(0);
let (new_offset, count, truncated) = logs::count_lines_from(&f.path, offset)?;
if truncated {
state.counts.insert(f.service.clone(), count);
} else {
*state.counts.entry(f.service.clone()).or_default() += count;
}
state.offsets.insert(f.name.clone(), new_offset);
let tail = logs::read_tail_entries(&f.path, &f.service, 65536)?;
all_recent.extend(tail);
}
if !log_files.is_empty() {
eprintln!(" done");
}
let mut latest_runs = latest::build(&mut all_recent);
state.save(dir)?;
display::enter_alt_screen();
display::render(&latest_runs, &top::sorted(&state.counts), cli.lines);
// Set up inotify
let mut inotify = Inotify::init()?;
let dir_wd = inotify
.watches()
.add(dir, WatchMask::CREATE | WatchMask::MOVED_TO)?;
let mut wd_map: HashMap<WatchDescriptor, logs::LogFile> = HashMap::new();
for f in logs::discover(dir, ".access.log")? {
let wd = inotify.watches().add(&f.path, WatchMask::MODIFY)?;
wd_map.insert(wd, f);
}
let mut buffer = [0u8; 4096];
loop {
let events = match inotify.read_events_blocking(&mut buffer) {
Ok(ev) => ev,
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
Err(e) => return Err(e.into()),
};
let events: Vec<(WatchDescriptor, Option<OsString>)> = events
.map(|e| (e.wd, e.name.map(|n| n.to_owned())))
.collect();
let mut new_entries = Vec::new();
let mut changed = false;
for (wd, name) in &events {
if *wd == dir_wd {
let Some(name) = name else { continue };
let name_str = name.to_string_lossy();
if name_str.ends_with(".access.log") {
if let Some(service) = logs::extract_service(&name_str) {
let path = dir.join(&*name_str);
let file_wd = inotify.watches().add(&path, WatchMask::MODIFY)?;
wd_map.insert(file_wd, logs::LogFile {
path,
name: name_str.into_owned(),
service,
});
changed = true;
}
} else if name_str.ends_with(".access.log.gz") {
if let Some(service) = logs::extract_service(&name_str) {
if !state.processed_gz.contains(&*name_str) {
let path = dir.join(&*name_str);
match logs::count_lines_gz(&path) {
Ok(count) => {
*state.counts.entry(service).or_default() += count;
state.processed_gz.insert(name_str.into_owned());
changed = true;
}
Err(_) => {} // file not ready yet, will catch on next event
}
}
}
}
} else if let Some(f) = wd_map.get(wd) {
let offset = state.offsets.get(&f.name).copied().unwrap_or(0);
let (new_offset, count, truncated, entries) =
logs::read_new_entries(&f.path, &f.service, offset)?;
if count > 0 {
if truncated {
state.counts.insert(f.service.clone(), count);
} else {
*state.counts.entry(f.service.clone()).or_default() += count;
}
state.offsets.insert(f.name.clone(), new_offset);
new_entries.extend(entries);
changed = true;
}
}
}
if changed {
new_entries
.sort_by(|a, b| a.ts.partial_cmp(&b.ts).unwrap_or(std::cmp::Ordering::Equal));
latest::push(&mut latest_runs, &new_entries);
state.save(dir)?;
display::render(&latest_runs, &top::sorted(&state.counts), cli.lines);
}
}
}
+31
View File
@@ -0,0 +1,31 @@
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::Path;
use anyhow::Result;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Default)]
pub struct State {
pub counts: HashMap<String, u64>,
pub offsets: HashMap<String, u64>,
pub processed_gz: HashSet<String>,
}
impl State {
pub fn load(dir: &Path) -> Self {
let path = dir.join(".srvmon.state.json");
fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
pub fn save(&self, dir: &Path) -> Result<()> {
let tmp = dir.join(".srvmon.state.json.tmp");
let final_path = dir.join(".srvmon.state.json");
fs::write(&tmp, serde_json::to_string(self)?)?;
fs::rename(&tmp, &final_path)?;
Ok(())
}
}
+18
View File
@@ -0,0 +1,18 @@
use std::collections::HashMap;
pub struct TopEntry {
pub service: String,
pub count: u64,
}
pub fn sorted(counts: &HashMap<String, u64>) -> Vec<TopEntry> {
let mut top: Vec<_> = counts
.iter()
.map(|(s, &c)| TopEntry {
service: s.clone(),
count: c,
})
.collect();
top.sort_by(|a, b| b.count.cmp(&a.count));
top
}