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
147 lines
4.7 KiB
Rust
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"))
|
|
);
|
|
}
|
|
}
|