Add selectable declared-param columns to LogPreview (ticket 015)

Extend GET /api/logs to derive each declared param's value per log line
(regex + capture group + kind, matching that line's text) and include it as
a params map on LogEntryDto, omitting entries that don't match rather than
sending nulls. Add GET /api/params (list_params, mirroring list_metrics) so
the frontend can discover currently-declared param names. In LogPreview, add
a column-picker popover backed by that list, with the selection persisted in
localStorage under `ottyl.logPreview.visibleParamColumns`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
This commit is contained in:
Hugh Kaznowski
2026-09-05 13:09:25 +01:00
co-authored by Claude Sonnet 5
parent e48c03218f
commit 7514052048
5 changed files with 301 additions and 30 deletions
+13 -1
View File
@@ -102,7 +102,7 @@ pub fn router(
.route("/api/logs", get(get_logs)) .route("/api/logs", get(get_logs))
.route("/api/timeline", get(get_timeline)) .route("/api/timeline", get(get_timeline))
.route("/api/stats", get(get_stats)) .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", get(list_metrics).post(declare_metric))
.route("/api/metrics/{name}", get(get_metric_bucket_values)) .route("/api/metrics/{name}", get(get_metric_bucket_values))
.route("/api/settings/css", get(list_css).post(import_css)) .route("/api/settings/css", get(list_css).post(import_css))
@@ -152,6 +152,18 @@ async fn get_stats(State(state): State<ApiState>) -> impl IntoResponse {
} }
} }
/// `GET /api/params` — lists all declared params (name, regex, capture
/// group, kind) as `Vec<ParamDeclarationDto>`. 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<ApiState>) -> 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`: /// `POST /api/params` — declares a param. Body mirrors `DeclareParamRequest`:
/// `{ "name": "...", "message_match_regex": "...", "capture_group": "...", /// `{ "name": "...", "message_match_regex": "...", "capture_group": "...",
/// "kind": "string" | "number" | "timestamp" }`. On success, also persists /// "kind": "string" | "number" | "timestamp" }`. On success, also persists
+7
View File
@@ -9,6 +9,13 @@ pub struct LogEntryDto {
pub sequence: u64, pub sequence: u64,
pub timestamp_ms: i64, pub timestamp_ms: i64,
pub text: String, 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<String, MetricPrimitive>,
} }
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
+81
View File
@@ -364,6 +364,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<IndexKey, DataValue>,
) -> 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<String, MetricPrimitive> {
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<IndexKey, DataValue>) -> impl Iterator<Item = (u64, Timestamp, &str)> { fn logs_range(index: &BTreeMap<IndexKey, DataValue>) -> impl Iterator<Item = (u64, Timestamp, &str)> {
index.range(IndexKey::Start..IndexKey::LogParamSplit).filter_map(|(key, value)| { index.range(IndexKey::Start..IndexKey::LogParamSplit).filter_map(|(key, value)| {
match (key, value) { match (key, value) {
@@ -379,6 +422,17 @@ fn handle_query(index: &BTreeMap<IndexKey, DataValue>, query: EngineQuery) -> En
match query { match query {
EngineQuery::Logs(params) => { EngineQuery::Logs(params) => {
let limit = params.limit.unwrap_or(500).min(10_000); 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<LogEntryDto> = logs_range(index) let entries: Vec<LogEntryDto> = logs_range(index)
.filter(|(sequence, timestamp, _)| { .filter(|(sequence, timestamp, _)| {
params.since_sequence.is_none_or(|since| *sequence > since) params.since_sequence.is_none_or(|since| *sequence > since)
@@ -390,6 +444,7 @@ fn handle_query(index: &BTreeMap<IndexKey, DataValue>, query: EngineQuery) -> En
sequence, sequence,
timestamp_ms: timestamp.as_millis(), timestamp_ms: timestamp.as_millis(),
text: text.to_string(), text: text.to_string(),
params: derive_log_params(text, &compiled_params),
}) })
.collect(); .collect();
EngineResponse::Logs(entries) EngineResponse::Logs(entries)
@@ -835,4 +890,30 @@ mod tests {
assert_eq!(bucket_values(&index, "requests", None).count(), 1); assert_eq!(bucket_values(&index, "requests", None).count(), 1);
assert_eq!(bucket_values(&index, "durations", None).count(), 1); assert_eq!(bucket_values(&index, "durations", None).count(), 1);
} }
#[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<level>\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());
}
} }
+170 -29
View File
@@ -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<string>
onToggle: (name: string) => void
}) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(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 ( return (
<div className="flex-1 overflow-auto"> <div ref={ref} className="relative">
<table className="w-full border-collapse text-left text-xs"> <button
<thead className="sticky top-0 bg-neutral-950 text-neutral-500"> type="button"
<tr> onClick={() => setOpen((v) => !v)}
<th className="w-16 px-3 py-1 font-normal">Seq</th> className="rounded border border-neutral-700 px-2 py-1 text-xs text-neutral-300 hover:bg-neutral-800"
<th className="w-40 px-3 py-1 font-normal">Timestamp</th> >
<th className="px-3 py-1 font-normal">Message</th> Columns{visible.size > 0 ? ` (${visible.size})` : ''}
</tr> </button>
</thead> {open && (
<tbody> <div className="absolute right-0 z-10 mt-1 w-56 rounded border border-neutral-700 bg-neutral-950 p-2 shadow-lg">
{logs.map((log) => ( {paramNames.length === 0 ? (
<tr key={log.sequence} className="border-t border-neutral-900 hover:bg-neutral-900/60"> <div className="px-1 py-1 text-xs text-neutral-500">No declared params yet.</div>
<td className="whitespace-nowrap px-3 py-1 text-neutral-500">{log.sequence}</td> ) : (
<td className="whitespace-nowrap px-3 py-1 text-neutral-500"> <ul className="flex max-h-64 flex-col gap-1 overflow-auto">
{new Date(log.timestamp_ms).toLocaleTimeString()} {paramNames.map((name) => (
</td> <li key={name}>
<td className="px-3 py-1 text-neutral-200">{log.text}</td> <label className="flex cursor-pointer items-center gap-2 rounded px-1 py-0.5 text-xs text-neutral-200 hover:bg-neutral-900">
</tr> <input
))} type="checkbox"
{logs.length === 0 && ( checked={visible.has(name)}
<tr> onChange={() => onToggle(name)}
<td colSpan={3} className="px-3 py-6 text-center text-neutral-600"> className="accent-neutral-400"
No logs yet — pipe something into ottyl. />
</td> {name}
</tr> </label>
</li>
))}
</ul>
)} )}
</tbody> </div>
</table> )}
</div>
)
}
export function LogPreview({ logs }: { logs: LogEntry[] }) {
const [paramNames, setParamNames] = useState<string[]>([])
const [visibleParamColumns, setVisibleParamColumns] = useState<string[]>(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 (
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex justify-end border-b border-neutral-900 px-3 py-1">
<ColumnPicker paramNames={paramNames} visible={visible} onToggle={toggleColumn} />
</div>
<div className="flex-1 overflow-auto">
<table className="w-full border-collapse text-left text-xs">
<thead className="sticky top-0 bg-neutral-950 text-neutral-500">
<tr>
<th className="w-16 px-3 py-1 font-normal">Seq</th>
<th className="w-40 px-3 py-1 font-normal">Timestamp</th>
{columns.map((name) => (
<th key={name} className="w-32 px-3 py-1 font-normal">
{name}
</th>
))}
<th className="px-3 py-1 font-normal">Message</th>
</tr>
</thead>
<tbody>
{logs.map((log) => (
<tr key={log.sequence} className="border-t border-neutral-900 hover:bg-neutral-900/60">
<td className="whitespace-nowrap px-3 py-1 text-neutral-500">{log.sequence}</td>
<td className="whitespace-nowrap px-3 py-1 text-neutral-500">
{new Date(log.timestamp_ms).toLocaleTimeString()}
</td>
{columns.map((name) => {
const value = log.params[name]
return (
<td key={name} className="whitespace-nowrap px-3 py-1 text-neutral-400">
{value ? metricPrimitiveToDisplayString(value) : '—'}
</td>
)
})}
<td className="px-3 py-1 text-neutral-200">{log.text}</td>
</tr>
))}
{logs.length === 0 && (
<tr>
<td colSpan={3 + columns.length} className="px-3 py-6 text-center text-neutral-600">
No logs yet — pipe something into ottyl.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div> </div>
) )
} }
+30
View File
@@ -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 { export interface LogEntry {
sequence: number sequence: number
timestamp_ms: number timestamp_ms: number
text: string 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<string, MetricPrimitive>
}
export interface ParamDeclaration {
name: string
message_match_regex: string
capture_group: string
kind: 'string' | 'number' | 'timestamp'
} }
export interface TimelineBucket { export interface TimelineBucket {
@@ -54,3 +80,7 @@ export function fetchTimeline(params: {
export function fetchStats(): Promise<Stats> { export function fetchStats(): Promise<Stats> {
return getJson('/api/stats') return getJson('/api/stats')
} }
export function fetchParamDeclarations(): Promise<ParamDeclaration[]> {
return getJson('/api/params')
}