Merge branch 'worktree-agent-ab342356b0191d35f'
# Conflicts: # src/engine.rs
This commit is contained in:
+13
-1
@@ -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/query", get(run_promql_query))
|
.route("/api/query", get(run_promql_query))
|
||||||
@@ -153,6 +153,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
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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<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) {
|
||||||
@@ -390,6 +433,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)
|
||||||
@@ -401,6 +455,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)
|
||||||
@@ -1085,4 +1140,30 @@ mod tests {
|
|||||||
_ => panic!("expected EngineResponse::Promql(Ok(_))"),
|
_ => 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<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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,145 @@
|
|||||||
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])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="rounded border border-neutral-700 px-2 py-1 text-xs text-neutral-300 hover:bg-neutral-800"
|
||||||
|
>
|
||||||
|
Columns{visible.size > 0 ? ` (${visible.size})` : ''}
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="absolute right-0 z-10 mt-1 w-56 rounded border border-neutral-700 bg-neutral-950 p-2 shadow-lg">
|
||||||
|
{paramNames.length === 0 ? (
|
||||||
|
<div className="px-1 py-1 text-xs text-neutral-500">No declared params yet.</div>
|
||||||
|
) : (
|
||||||
|
<ul className="flex max-h-64 flex-col gap-1 overflow-auto">
|
||||||
|
{paramNames.map((name) => (
|
||||||
|
<li key={name}>
|
||||||
|
<label className="flex cursor-pointer items-center gap-2 rounded px-1 py-0.5 text-xs text-neutral-200 hover:bg-neutral-900">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={visible.has(name)}
|
||||||
|
onChange={() => onToggle(name)}
|
||||||
|
className="accent-neutral-400"
|
||||||
|
/>
|
||||||
|
{name}
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function LogPreview({ logs }: { logs: LogEntry[] }) {
|
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 (
|
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">
|
<div className="flex-1 overflow-auto">
|
||||||
<table className="w-full border-collapse text-left text-xs">
|
<table className="w-full border-collapse text-left text-xs">
|
||||||
<thead className="sticky top-0 bg-neutral-950 text-neutral-500">
|
<thead className="sticky top-0 bg-neutral-950 text-neutral-500">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="w-16 px-3 py-1 font-normal">Seq</th>
|
<th className="w-16 px-3 py-1 font-normal">Seq</th>
|
||||||
<th className="w-40 px-3 py-1 font-normal">Timestamp</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>
|
<th className="px-3 py-1 font-normal">Message</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -18,12 +150,20 @@ export function LogPreview({ logs }: { logs: LogEntry[] }) {
|
|||||||
<td className="whitespace-nowrap px-3 py-1 text-neutral-500">
|
<td className="whitespace-nowrap px-3 py-1 text-neutral-500">
|
||||||
{new Date(log.timestamp_ms).toLocaleTimeString()}
|
{new Date(log.timestamp_ms).toLocaleTimeString()}
|
||||||
</td>
|
</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>
|
<td className="px-3 py-1 text-neutral-200">{log.text}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{logs.length === 0 && (
|
{logs.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={3} className="px-3 py-6 text-center text-neutral-600">
|
<td colSpan={3 + columns.length} className="px-3 py-6 text-center text-neutral-600">
|
||||||
No logs yet — pipe something into ottyl.
|
No logs yet — pipe something into ottyl.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -31,5 +171,6 @@ export function LogPreview({ logs }: { logs: LogEntry[] }) {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user