diff --git a/src/api.rs b/src/api.rs index 4287255..12d546e 100644 --- a/src/api.rs +++ b/src/api.rs @@ -102,7 +102,7 @@ pub fn router( .route("/api/logs", get(get_logs)) .route("/api/timeline", get(get_timeline)) .route("/api/stats", get(get_stats)) - .route("/api/params", post(declare_param)) + .route("/api/params", get(list_params).post(declare_param)) .route("/api/metrics", get(list_metrics).post(declare_metric)) .route("/api/metrics/{name}", get(get_metric_bucket_values)) .route("/api/query", get(run_promql_query)) @@ -153,6 +153,18 @@ async fn get_stats(State(state): State) -> impl IntoResponse { } } +/// `GET /api/params` — lists all declared params (name, regex, capture +/// group, kind) as `Vec`. Mirrors `list_metrics` below +/// (ticket 015 needed this for LogPreview's column picker; ticket 004 only +/// exposed the equivalent for metrics). +async fn list_params(State(state): State) -> impl IntoResponse { + match ask(&state.engine, EngineQuery::ParamDeclarations).await { + Ok(EngineResponse::ParamDeclarations(declarations)) => Json(declarations).into_response(), + Ok(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + Err(status) => status.into_response(), + } +} + /// `POST /api/params` — declares a param. Body mirrors `DeclareParamRequest`: /// `{ "name": "...", "message_match_regex": "...", "capture_group": "...", /// "kind": "string" | "number" | "timestamp" }`. On success, also persists diff --git a/src/dto.rs b/src/dto.rs index a16c223..0b6b635 100644 --- a/src/dto.rs +++ b/src/dto.rs @@ -9,6 +9,13 @@ pub struct LogEntryDto { pub sequence: u64, pub timestamp_ms: i64, pub text: String, + /// Derived param values (ticket 001's `ParamDeclaration`s) for this line, + /// keyed by param name. Only params whose regex actually matched this + /// line's text (and whose captured group parsed as the declared `kind`) + /// are present — a non-matching param is omitted rather than sent as + /// null, so the frontend can render a blank/dash for anything missing + /// from the map. + pub params: BTreeMap, } #[derive(Debug, Clone, Serialize)] diff --git a/src/engine.rs b/src/engine.rs index 883c151..ff13364 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -375,6 +375,49 @@ fn param_declarations_range( }) } +/// Compiles every currently-declared param's regex once, so `derive_log_params` +/// can be applied to many lines without recompiling each param's regex per +/// line. A param whose stored regex somehow fails to compile (shouldn't +/// happen — `declare_param` validates it before storing) is skipped rather +/// than panicking. +fn compiled_param_declarations( + index: &BTreeMap, +) -> Vec<(String, Regex, String, MetricPrimitiveKind)> { + param_declarations_range(index) + .filter_map(|(name, message_match_regex, capture_group, kind)| { + Regex::new(message_match_regex) + .ok() + .map(|re| (name.to_string(), re, capture_group.to_string(), kind)) + }) + .collect() +} + +/// Runs every declared param's regex against one log line's text, returning +/// the derived `MetricPrimitive` values keyed by param name. A param is +/// omitted from the result (rather than included as null/empty) when its +/// regex doesn't match the line, its capture group doesn't match, or the +/// captured text fails to parse as the param's declared `kind` — see +/// `LogEntryDto::params`. +fn derive_log_params( + text: &str, + declarations: &[(String, Regex, String, MetricPrimitiveKind)], +) -> BTreeMap { + let mut params = BTreeMap::new(); + for (name, regex, capture_group, kind) in declarations { + let Some(captures) = regex.captures(text) else { + continue; + }; + let Some(raw) = captures.name(capture_group) else { + continue; + }; + let Some(value) = parse_metric_primitive(raw.as_str(), *kind) else { + continue; + }; + params.insert(name.clone(), value); + } + params +} + fn logs_range(index: &BTreeMap) -> impl Iterator { index.range(IndexKey::Start..IndexKey::LogParamSplit).filter_map(|(key, value)| { match (key, value) { @@ -390,6 +433,17 @@ fn handle_query(index: &BTreeMap, query: EngineQuery) -> En match query { EngineQuery::Logs(params) => { let limit = params.limit.unwrap_or(500).min(10_000); + // Compile each currently-declared param's regex once up front, then + // apply the compiled set to every *returned* line below (ticket 015). + // Perf note: this is a straightforward per-returned-line, + // per-declared-param regex scan — O(lines_returned * declared_params) + // — rather than precomputing/caching derived params for the whole log + // range. That's fine at this codebase's current scale (v1), but if + // either the log volume or number of declared params grows a lot, + // this should move to incremental derivation at ingest time (mirroring + // how `apply_all_metrics` already does for metric buckets) instead of + // recomputing on every `/api/logs` query. + let compiled_params = compiled_param_declarations(index); let entries: Vec = logs_range(index) .filter(|(sequence, timestamp, _)| { params.since_sequence.is_none_or(|since| *sequence > since) @@ -401,6 +455,7 @@ fn handle_query(index: &BTreeMap, query: EngineQuery) -> En sequence, timestamp_ms: timestamp.as_millis(), text: text.to_string(), + params: derive_log_params(text, &compiled_params), }) .collect(); EngineResponse::Logs(entries) @@ -1085,4 +1140,30 @@ mod tests { _ => panic!("expected EngineResponse::Promql(Ok(_))"), } } + + #[test] + fn logs_query_includes_declared_params_on_matching_lines_only() { + let mut index = BTreeMap::new(); + declare_param(&mut index, request("level", r"level=(?P\w+)", "level")).unwrap(); + ingest_log(&mut index, 0, "level=info hello"); + ingest_log(&mut index, 1, "no match here"); + + let EngineResponse::Logs(entries) = + handle_query(&index, EngineQuery::Logs(LogsParams { + since_sequence: None, + start_ms: None, + end_ms: None, + limit: None, + })) + else { + panic!("expected Logs response"); + }; + + assert_eq!(entries.len(), 2); + assert_eq!( + entries[0].params.get("level"), + Some(&MetricPrimitive::String("info".to_string())) + ); + assert!(entries[1].params.get("level").is_none()); + } } diff --git a/webapp/src/components/LogPreview.tsx b/webapp/src/components/LogPreview.tsx index ad22b6f..1554c1d 100644 --- a/webapp/src/components/LogPreview.tsx +++ b/webapp/src/components/LogPreview.tsx @@ -1,35 +1,176 @@ -import type { LogEntry } from '../lib/api' +import { useEffect, useRef, useState } from 'react' +import { + fetchParamDeclarations, + metricPrimitiveToDisplayString, + type LogEntry, +} from '../lib/api' + +/// localStorage key for LogPreview's selected param columns. Namespaced +/// under `ottyl.` since this is the app's first localStorage usage — kept +/// distinct enough that future per-viewer preferences elsewhere in the app +/// won't collide with it. +const VISIBLE_PARAM_COLUMNS_STORAGE_KEY = 'ottyl.logPreview.visibleParamColumns' + +function loadVisibleParamColumns(): string[] { + try { + const raw = localStorage.getItem(VISIBLE_PARAM_COLUMNS_STORAGE_KEY) + if (!raw) return [] + const parsed = JSON.parse(raw) + return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === 'string') : [] + } catch { + return [] + } +} + +function saveVisibleParamColumns(columns: string[]) { + try { + localStorage.setItem(VISIBLE_PARAM_COLUMNS_STORAGE_KEY, JSON.stringify(columns)) + } catch { + // best-effort — a private browsing mode or full storage just means the + // selection won't persist across reloads. + } +} + +function ColumnPicker({ + paramNames, + visible, + onToggle, +}: { + paramNames: string[] + visible: Set + onToggle: (name: string) => void +}) { + const [open, setOpen] = useState(false) + const ref = useRef(null) + + useEffect(() => { + if (!open) return + function onClickOutside(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) + } + document.addEventListener('mousedown', onClickOutside) + return () => document.removeEventListener('mousedown', onClickOutside) + }, [open]) -export function LogPreview({ logs }: { logs: LogEntry[] }) { return ( -
- - - - - - - - - - {logs.map((log) => ( - - - - - - ))} - {logs.length === 0 && ( - - - +
+ + {open && ( +
+ {paramNames.length === 0 ? ( +
No declared params yet.
+ ) : ( +
    + {paramNames.map((name) => ( +
  • + +
  • + ))} +
)} -
-
SeqTimestampMessage
{log.sequence} - {new Date(log.timestamp_ms).toLocaleTimeString()} - {log.text}
- No logs yet — pipe something into ottyl. -
+
+ )} + + ) +} + +export function LogPreview({ logs }: { logs: LogEntry[] }) { + const [paramNames, setParamNames] = useState([]) + const [visibleParamColumns, setVisibleParamColumns] = useState(loadVisibleParamColumns) + + useEffect(() => { + let cancelled = false + fetchParamDeclarations() + .then((declarations) => { + if (!cancelled) setParamNames(declarations.map((d) => d.name)) + }) + .catch(() => { + // params API not reachable yet; column picker just stays empty until + // the next successful fetch. + }) + return () => { + cancelled = true + } + }, []) + + const visible = new Set(visibleParamColumns) + + function toggleColumn(name: string) { + setVisibleParamColumns((current) => { + const next = current.includes(name) + ? current.filter((c) => c !== name) + : [...current, name] + saveVisibleParamColumns(next) + return next + }) + } + + // Only render columns for params that are both selected and still known to + // exist, so a stale localStorage entry for a since-removed param doesn't + // leave a dangling column. + const columns = visibleParamColumns.filter((name) => paramNames.includes(name)) + + return ( +
+
+ +
+
+ + + + + + {columns.map((name) => ( + + ))} + + + + + {logs.map((log) => ( + + + + {columns.map((name) => { + const value = log.params[name] + return ( + + ) + })} + + + ))} + {logs.length === 0 && ( + + + + )} + +
SeqTimestamp + {name} + Message
{log.sequence} + {new Date(log.timestamp_ms).toLocaleTimeString()} + + {value ? metricPrimitiveToDisplayString(value) : '—'} + {log.text}
+ No logs yet — pipe something into ottyl. +
+
) } diff --git a/webapp/src/lib/api.ts b/webapp/src/lib/api.ts index 3442a60..bc44d95 100644 --- a/webapp/src/lib/api.ts +++ b/webapp/src/lib/api.ts @@ -1,7 +1,33 @@ +/// `MetricPrimitive` is a Rust enum with derived `Serialize`, so its JSON +/// form is externally tagged, e.g. `{ "String": "info" }`, `{ "Number": +/// { "Int": 5 } }`, `{ "Timestamp": 1710000000000 }`. +export type MetricPrimitive = + | { String: string } + | { Number: { Int: number } | { Float: number } } + | { Timestamp: number } + +export function metricPrimitiveToDisplayString(value: MetricPrimitive): string { + if ('String' in value) return value.String + if ('Timestamp' in value) return String(value.Timestamp) + if ('Int' in value.Number) return String(value.Number.Int) + return String(value.Number.Float) +} + export interface LogEntry { sequence: number timestamp_ms: number text: string + /// Derived param values (declared via `POST /api/params`) that matched + /// this line's text, keyed by param name. A param with no match on this + /// line is simply absent from the map. + params: Record +} + +export interface ParamDeclaration { + name: string + message_match_regex: string + capture_group: string + kind: 'string' | 'number' | 'timestamp' } export interface TimelineBucket { @@ -54,3 +80,7 @@ export function fetchTimeline(params: { export function fetchStats(): Promise { return getJson('/api/stats') } + +export function fetchParamDeclarations(): Promise { + return getJson('/api/params') +}