Files
ottyl/src/main.rs
T
Hugh KaznowskiandClaude Sonnet 5 dd3320812c Add alert rules with FilterBar-based triggers and ntfy delivery (tickets 017-019)
Adds AlertRule/AlertCondition/AlertSink models with CRUD persisted on
Settings (mirroring CssEntry's shape), per-line trigger evaluation hooked
into the engine's ingest path (Immediate/Threshold/NewValue/Ratio
conditions), and best-effort ntfy delivery spawned off the ingest hot path.

Design notes:
- Alert queries reuse FilterBar syntax but only support the
  regex()/sequence/timestamp subset (no metric[param] comparisons), since
  resolving those would require a full bucket rescan per alert per ingested
  line rather than cost proportional to new logs.
- Ratio's zero-denominator case skips evaluation for that line rather than
  treating the ratio as infinite.
- NewValue's seen-value set is in-memory only and does not survive a
  restart, matching the engine's other non-persisted runtime state.

engine::run now takes a SettingsHandle (moved from api.rs to settings.rs so
engine.rs can depend on it without a circular import) to read the current
alert rules on each ingest.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
2026-09-05 16:30:14 +01:00

147 lines
4.7 KiB
Rust

mod alerts;
mod api;
mod data_value;
mod dto;
mod engine;
mod filter_datetime;
mod filter_parser;
mod index_key;
mod ingest;
mod metric_primitive;
mod profile;
mod promql;
mod settings;
mod timestamp;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use clap::Parser;
use tokio::net::TcpListener;
use tokio::sync::mpsc;
use profile::ProfileStore;
use settings::Settings;
/// Observability dashboard from terminal output.
#[derive(Parser, Debug)]
struct Args {
/// Port to serve the API and webapp on. Defaults to 0, letting the OS assign
/// an ephemeral free port.
#[arg(long, default_value_t = 0)]
port: u16,
/// Path to the settings file. Defaults to a per-OS config location
/// (e.g. `~/.config/ottyl/settings.json` on Linux).
#[arg(long)]
settings_path: Option<PathBuf>,
/// File to tail for log lines, instead of reading from stdin. Behaves like
/// `tail -f`: reads existing content then keeps following appended lines,
/// and keeps tailing if the file is deleted/recreated at the same path.
#[arg(long)]
file: Option<PathBuf>,
}
#[tokio::main]
async fn main() {
let args = Args::parse();
let settings_path = args.settings_path.clone().unwrap_or_else(|| {
settings::default_settings_path()
.expect("could not determine a default settings path; pass --settings-path")
});
let settings = Arc::new(Mutex::new(Settings::load(&settings_path)));
let (engine_tx, engine_rx) = mpsc::channel(1024);
tokio::spawn(engine::run(engine_rx, settings.clone()));
{
let max_logs = settings.lock().unwrap().max_logs;
let _ = engine_tx.send(engine::EngineMessage::SetMaxLogs(max_logs)).await;
}
// Ticket 009: reload the active profile's param/metric declarations into the
// engine BEFORE starting ingest below. Ordering matters: declarations must be
// in place before any line is ingested so that backfill/live-derivation
// semantics (ticket 003) apply uniformly to every line, including ones that
// arrive immediately once ingest starts (there's no "declare after the fact
// and go back to reprocess early lines" step, so doing it after ingest starts
// could silently miss lines that raced ahead of the declarations).
{
let (active_profile_name, profiles_dir) = {
let mut settings_guard = settings.lock().unwrap();
let profiles_dir = profile::resolve_profiles_dir(&settings_guard, &settings_path);
let mut store = ProfileStore::load(&profile::profiles_store_path(&profiles_dir));
store
.ensure_default(&profile::profiles_store_path(&profiles_dir), &mut settings_guard, &settings_path)
.expect("failed to ensure the Default profile exists");
(settings_guard.profiles.active_profile.clone(), profiles_dir)
};
if let Some(profile_name) = active_profile_name {
let store = ProfileStore::load(&profile::profiles_store_path(&profiles_dir));
match store.get(&profile_name) {
Some(active_profile) => {
profile::load_into_engine(&engine_tx, active_profile).await;
}
None => {
eprintln!(
"warning: active profile '{profile_name}' not found under {}; skipping",
profiles_dir.display()
);
}
}
}
}
match args.file.clone() {
Some(path) => {
tokio::spawn(ingest::run_file(engine_tx.clone(), path));
}
None => {
tokio::spawn(ingest::run(engine_tx.clone()));
}
}
let app = api::router(engine_tx, settings, settings_path);
let listener = TcpListener::bind(("0.0.0.0", args.port))
.await
.expect("failed to bind listener");
let bound_port = listener
.local_addr()
.expect("failed to read back bound address")
.port();
let source = match &args.file {
Some(path) => format!("tailing {}", path.display()),
None => "reading logs from stdin".to_string(),
};
eprintln!(
"ottyl serving on http://localhost:{} ({})",
bound_port, source
);
axum::serve(listener, app)
.await
.expect("server error");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn settings_path_defaults_to_none() {
let args = Args::parse_from(["ottyl"]);
assert_eq!(args.settings_path, None);
}
#[test]
fn settings_path_flag_overrides_default() {
let args = Args::parse_from(["ottyl", "--settings-path", "/tmp/custom-settings.json"]);
assert_eq!(
args.settings_path,
Some(PathBuf::from("/tmp/custom-settings.json"))
);
}
}