This commit is contained in:
Hugh Kaznowski
2026-09-05 12:23:46 +01:00
parent 818f8d09ea
commit 68548911bc
34 changed files with 3262 additions and 4 deletions
Generated
+279
View File
@@ -2,6 +2,56 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
@@ -60,12 +110,80 @@ dependencies = [
"tracing",
]
[[package]]
name = "bitflags"
version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "clap"
version = "4.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@@ -90,6 +208,12 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-sink"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
[[package]]
name = "futures-task"
version = "0.3.34"
@@ -108,6 +232,12 @@ dependencies = [
"slab",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "http"
version = "1.5.0"
@@ -141,6 +271,12 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "http-range-header"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
[[package]]
name = "httparse"
version = "1.10.1"
@@ -188,6 +324,12 @@ dependencies = [
"tower-service",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itoa"
version = "1.0.18"
@@ -200,6 +342,15 @@ version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.34"
@@ -224,6 +375,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]]
name = "mio"
version = "1.2.3"
@@ -241,12 +402,45 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "ottyl"
version = "0.1.0"
dependencies = [
"axum",
"clap",
"serde",
"serde_json",
"tokio",
"tower-http",
]
[[package]]
name = "parking_lot"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-link",
]
[[package]]
@@ -279,12 +473,27 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
]
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "serde"
version = "1.0.229"
@@ -292,6 +501,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
@@ -350,6 +560,16 @@ dependencies = [
"serde",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "slab"
version = "0.4.12"
@@ -372,6 +592,12 @@ dependencies = [
"windows-sys",
]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "3.0.5"
@@ -395,9 +621,12 @@ version = "1.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
dependencies = [
"bytes",
"libc",
"mio",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys",
@@ -414,6 +643,19 @@ dependencies = [
"syn",
]
[[package]]
name = "tokio-util"
version = "0.7.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"pin-project-lite",
"tokio",
]
[[package]]
name = "tower"
version = "0.5.3"
@@ -430,6 +672,31 @@ dependencies = [
"tracing",
]
[[package]]
name = "tower-http"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"bitflags",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"http-range-header",
"httpdate",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"tokio",
"tokio-util",
"tower-layer",
"tower-service",
]
[[package]]
name = "tower-layer"
version = "0.3.3"
@@ -462,12 +729,24 @@ dependencies = [
"once_cell",
]
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
+6 -2
View File
@@ -4,5 +4,9 @@ version = "0.1.0"
edition = "2024"
[dependencies]
axum = "0.8.9"
tokio = "1.53.1"
axum = { version = "0.8.9", features = ["json"] }
tokio = { version = "1.53.1", features = ["full"] }
tower-http = { version = "0.6", features = ["fs", "cors"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
clap = { version = "4", features = ["derive"] }
+66
View File
@@ -0,0 +1,66 @@
use crate::engine::{EngineHandle, EngineMessage, EngineQuery, EngineResponse};
use crate::dto::{LogsParams, TimelineParams};
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json};
use axum::routing::get;
use axum::Router;
use tokio::sync::oneshot;
use tower_http::services::{ServeDir, ServeFile};
#[derive(Clone)]
struct ApiState {
engine: EngineHandle,
}
pub fn router(engine: EngineHandle, static_dir: &str) -> Router {
let state = ApiState { engine };
let index_html = format!("{static_dir}/index.html");
let static_service = ServeDir::new(static_dir).fallback(ServeFile::new(index_html));
Router::new()
.route("/api/logs", get(get_logs))
.route("/api/timeline", get(get_timeline))
.route("/api/stats", get(get_stats))
.with_state(state)
.fallback_service(static_service)
}
async fn ask(engine: &EngineHandle, query: EngineQuery) -> Result<EngineResponse, StatusCode> {
let (respond_to, response) = oneshot::channel();
engine
.send(EngineMessage::Query { query, respond_to })
.await
.map_err(|_| StatusCode::SERVICE_UNAVAILABLE)?;
response.await.map_err(|_| StatusCode::SERVICE_UNAVAILABLE)
}
async fn get_logs(
State(state): State<ApiState>,
Query(params): Query<LogsParams>,
) -> impl IntoResponse {
match ask(&state.engine, EngineQuery::Logs(params)).await {
Ok(EngineResponse::Logs(entries)) => Json(entries).into_response(),
Ok(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
Err(status) => status.into_response(),
}
}
async fn get_timeline(
State(state): State<ApiState>,
Query(params): Query<TimelineParams>,
) -> impl IntoResponse {
match ask(&state.engine, EngineQuery::Timeline(params)).await {
Ok(EngineResponse::Timeline(buckets)) => Json(buckets).into_response(),
Ok(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
Err(status) => status.into_response(),
}
}
async fn get_stats(State(state): State<ApiState>) -> impl IntoResponse {
match ask(&state.engine, EngineQuery::Stats).await {
Ok(EngineResponse::Stats(stats)) => Json(stats).into_response(),
Ok(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
Err(status) => status.into_response(),
}
}
+37
View File
@@ -0,0 +1,37 @@
use crate::metric_primitive::MetricPrimitive;
use crate::timestamp::Timestamp;
use serde::Serialize;
/// A single parsed metric primitive extracted from a regex capture group.
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize)]
pub struct RegexToMetricPrimitiveMatcher {
pub param_name: String,
pub capture_group: String,
pub kind: MetricPrimitiveKind,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, Serialize)]
pub enum MetricPrimitiveKind {
String,
Number,
Timestamp,
}
/// Value type paired with [`crate::index_key::IndexKey`] in the engine's primary index.
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize)]
pub enum DataValue {
/// Paired with `IndexKey::IngestedLog`.
LogLiteral { text: String },
/// Paired with `IndexKey::MetricDeclaration`.
MetricDeclaration {
message_match_regex: String,
params: Vec<RegexToMetricPrimitiveMatcher>,
},
/// Paired with `IndexKey::Bucket`.
BucketValue {
values: Vec<(u64, Timestamp, MetricPrimitive)>,
},
}
+38
View File
@@ -0,0 +1,38 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize)]
pub struct LogEntryDto {
pub sequence: u64,
pub timestamp_ms: i64,
pub text: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct TimelineBucketDto {
pub start_ms: i64,
pub count: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct StatsDto {
pub log_count: u64,
pub first_sequence: Option<u64>,
pub last_sequence: Option<u64>,
pub first_timestamp_ms: Option<i64>,
pub last_timestamp_ms: Option<i64>,
}
#[derive(Debug, Deserialize)]
pub struct LogsParams {
pub since_sequence: Option<u64>,
pub start_ms: Option<i64>,
pub end_ms: Option<i64>,
pub limit: Option<usize>,
}
#[derive(Debug, Deserialize)]
pub struct TimelineParams {
pub start_ms: Option<i64>,
pub end_ms: Option<i64>,
pub bucket_ms: Option<i64>,
}
+129
View File
@@ -0,0 +1,129 @@
use crate::data_value::DataValue;
use crate::dto::{LogEntryDto, LogsParams, StatsDto, TimelineBucketDto, TimelineParams};
use crate::index_key::IndexKey;
use crate::timestamp::Timestamp;
use std::collections::BTreeMap;
use tokio::sync::{mpsc, oneshot};
pub struct IngestedLine {
pub sequence: u64,
pub timestamp: Timestamp,
pub text: String,
}
pub enum EngineQuery {
Logs(LogsParams),
Timeline(TimelineParams),
Stats,
}
pub enum EngineMessage {
Ingest(IngestedLine),
Query {
query: EngineQuery,
respond_to: oneshot::Sender<EngineResponse>,
},
}
pub enum EngineResponse {
Logs(Vec<LogEntryDto>),
Timeline(Vec<TimelineBucketDto>),
Stats(StatsDto),
}
pub type EngineHandle = mpsc::Sender<EngineMessage>;
/// Sole owner of the primary index. Runs single-threaded so the map never needs a
/// lock, and does its own response serialization/DTO construction so callers never
/// have to clone the index to answer a query.
pub async fn run(mut rx: mpsc::Receiver<EngineMessage>) {
let mut index: BTreeMap<IndexKey, DataValue> = BTreeMap::new();
while let Some(message) = rx.recv().await {
match message {
EngineMessage::Ingest(line) => {
index.insert(
IndexKey::IngestedLog {
sequence: line.sequence,
timestamp: line.timestamp,
},
DataValue::LogLiteral { text: line.text },
);
}
EngineMessage::Query { query, respond_to } => {
let response = handle_query(&index, query);
let _ = respond_to.send(response);
}
}
}
}
fn logs_range(index: &BTreeMap<IndexKey, DataValue>) -> impl Iterator<Item = (u64, Timestamp, &str)> {
index.range(IndexKey::Start..IndexKey::LogParamSplit).filter_map(|(key, value)| {
match (key, value) {
(IndexKey::IngestedLog { sequence, timestamp }, DataValue::LogLiteral { text }) => {
Some((*sequence, *timestamp, text.as_str()))
}
_ => None,
}
})
}
fn handle_query(index: &BTreeMap<IndexKey, DataValue>, query: EngineQuery) -> EngineResponse {
match query {
EngineQuery::Logs(params) => {
let limit = params.limit.unwrap_or(500).min(10_000);
let entries: Vec<LogEntryDto> = logs_range(index)
.filter(|(sequence, timestamp, _)| {
params.since_sequence.is_none_or(|since| *sequence > since)
&& params.start_ms.is_none_or(|start| timestamp.as_millis() >= start)
&& params.end_ms.is_none_or(|end| timestamp.as_millis() <= end)
})
.take(limit)
.map(|(sequence, timestamp, text)| LogEntryDto {
sequence,
timestamp_ms: timestamp.as_millis(),
text: text.to_string(),
})
.collect();
EngineResponse::Logs(entries)
}
EngineQuery::Timeline(params) => {
let bucket_ms = params.bucket_ms.unwrap_or(1_000).max(1);
let mut buckets: BTreeMap<i64, u64> = BTreeMap::new();
for (_, timestamp, _) in logs_range(index).filter(|(_, timestamp, _)| {
params.start_ms.is_none_or(|start| timestamp.as_millis() >= start)
&& params.end_ms.is_none_or(|end| timestamp.as_millis() <= end)
}) {
let bucket_start = (timestamp.as_millis() / bucket_ms) * bucket_ms;
*buckets.entry(bucket_start).or_insert(0) += 1;
}
let out = buckets
.into_iter()
.map(|(start_ms, count)| TimelineBucketDto { start_ms, count })
.collect();
EngineResponse::Timeline(out)
}
EngineQuery::Stats => {
let mut log_count = 0u64;
let mut first_sequence = None;
let mut last_sequence = None;
let mut first_timestamp_ms = None;
let mut last_timestamp_ms = None;
for (sequence, timestamp, _) in logs_range(index) {
log_count += 1;
first_sequence.get_or_insert(sequence);
last_sequence = Some(sequence);
first_timestamp_ms.get_or_insert(timestamp.as_millis());
last_timestamp_ms = Some(timestamp.as_millis());
}
EngineResponse::Stats(StatsDto {
log_count,
first_sequence,
last_sequence,
first_timestamp_ms,
last_timestamp_ms,
})
}
}
}
+38
View File
@@ -0,0 +1,38 @@
use crate::metric_primitive::MetricPrimitive;
use crate::timestamp::Timestamp;
use serde::Serialize;
use std::collections::BTreeMap;
/// Key type for the engine's primary index.
///
/// Variants are ordered top to bottom as declared (derived `Ord` compares the
/// discriminant first), which is what carves the map into contiguous ranges:
/// logs, then params, then metric declarations, then buckets.
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub enum IndexKey {
/// Marker for the start of the whole range. Never inserted, only navigated to.
Start,
IngestedLog {
sequence: u64,
timestamp: Timestamp,
},
/// Marker between the log range and the param range.
LogParamSplit,
ParamDeclaration {
name: String,
},
/// Marker between the param range and the metric declaration range.
ParamMetricSplit,
MetricDeclaration {
name: String,
},
/// Marker between metric declarations and their buckets.
MetricBucketSplit,
Bucket {
metric_name: String,
labels: BTreeMap<String, MetricPrimitive>,
},
/// Marker for the end of the whole range. Nothing sorts after this.
End,
}
+25
View File
@@ -0,0 +1,25 @@
use crate::engine::{EngineMessage, IngestedLine};
use crate::timestamp::Timestamp;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::sync::mpsc;
/// Reads stdin line by line, sequences and timestamps each line, and forwards it
/// to the engine task.
pub async fn run(engine: mpsc::Sender<EngineMessage>) {
let stdin = tokio::io::stdin();
let mut lines = BufReader::new(stdin).lines();
let mut sequence: u64 = 0;
while let Ok(Some(text)) = lines.next_line().await {
let line = IngestedLine {
sequence,
timestamp: Timestamp::now(),
text,
};
sequence += 1;
if engine.send(EngineMessage::Ingest(line)).await.is_err() {
break;
}
}
}
+46 -2
View File
@@ -1,3 +1,47 @@
fn main() {
println!("Hello, world!");
mod api;
mod data_value;
mod dto;
mod engine;
mod index_key;
mod ingest;
mod metric_primitive;
mod timestamp;
use clap::Parser;
use tokio::net::TcpListener;
use tokio::sync::mpsc;
/// Observability dashboard from terminal output.
#[derive(Parser, Debug)]
struct Args {
/// Port to serve the API and webapp on.
#[arg(long, default_value_t = 8420)]
port: u16,
/// Directory containing the built webapp (index.html + assets).
#[arg(long, default_value = "webapp/dist")]
static_dir: String,
}
#[tokio::main]
async fn main() {
let args = Args::parse();
let (engine_tx, engine_rx) = mpsc::channel(1024);
tokio::spawn(engine::run(engine_rx));
tokio::spawn(ingest::run(engine_tx.clone()));
let app = api::router(engine_tx, &args.static_dir);
let listener = TcpListener::bind(("0.0.0.0", args.port))
.await
.expect("failed to bind listener");
eprintln!(
"ottyl serving on http://localhost:{} (reading logs from stdin)",
args.port
);
axum::serve(listener, app)
.await
.expect("server error");
}
+82
View File
@@ -0,0 +1,82 @@
use crate::timestamp::Timestamp;
use serde::Serialize;
use std::cmp::Ordering;
/// A simple, self-describing value used by metrics/labels without pulling in JSON.
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize)]
pub enum MetricPrimitive {
String(String),
Number(Number),
Timestamp(Timestamp),
}
impl PartialEq for MetricPrimitive {
fn eq(&self, other: &Self) -> bool {
self.cmp(other) == Ordering::Equal
}
}
impl Eq for MetricPrimitive {}
impl PartialOrd for MetricPrimitive {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for MetricPrimitive {
fn cmp(&self, other: &Self) -> Ordering {
fn rank(v: &MetricPrimitive) -> u8 {
match v {
MetricPrimitive::String(_) => 0,
MetricPrimitive::Number(_) => 1,
MetricPrimitive::Timestamp(_) => 2,
}
}
match (self, other) {
(MetricPrimitive::String(a), MetricPrimitive::String(b)) => a.cmp(b),
(MetricPrimitive::Number(a), MetricPrimitive::Number(b)) => a.cmp(b),
(MetricPrimitive::Timestamp(a), MetricPrimitive::Timestamp(b)) => a.cmp(b),
(a, b) => rank(a).cmp(&rank(b)),
}
}
}
/// Numbers are kept as a tagged int/float pair so exact integers stay exact.
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, Serialize)]
pub enum Number {
Int(i64),
Float(f64),
}
impl Number {
fn as_f64(&self) -> f64 {
match self {
Number::Int(v) => *v as f64,
Number::Float(v) => *v,
}
}
}
impl PartialEq for Number {
fn eq(&self, other: &Self) -> bool {
self.cmp(other) == Ordering::Equal
}
}
impl Eq for Number {}
impl PartialOrd for Number {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Number {
fn cmp(&self, other: &Self) -> Ordering {
if let (Number::Int(a), Number::Int(b)) = (self, other) {
return a.cmp(b);
}
self.as_f64().total_cmp(&other.as_f64())
}
}
+29
View File
@@ -0,0 +1,29 @@
use serde::Serialize;
use std::time::{SystemTime, UNIX_EPOCH};
/// Wall-clock instant stored as nanoseconds since the Unix epoch.
///
/// The README's data model sketches this field as `Instant`, but `Instant` has no
/// epoch and can't be serialized to the frontend, so wall-clock time is stored here
/// instead.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub struct Timestamp(i64);
impl Timestamp {
pub fn now() -> Self {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock is before the Unix epoch")
.as_nanos() as i64;
Timestamp(nanos)
}
#[allow(dead_code)]
pub fn from_millis(millis: i64) -> Self {
Timestamp(millis * 1_000_000)
}
pub fn as_millis(&self) -> i64 {
self.0 / 1_000_000
}
}
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+32
View File
@@ -0,0 +1,32 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webapp</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1942
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "webapp",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.3.3",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"tailwindcss": "^4.3.3"
},
"devDependencies": {
"@types/node": "^24.13.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^6.1.0",
"oxlint": "^1.79.0",
"typescript": "~6.0.2",
"vite": "^8.2.2"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+60
View File
@@ -0,0 +1,60 @@
import { useEffect, useState } from 'react'
import { NavMenu } from './components/NavMenu'
import { FilterBar } from './components/FilterBar'
import { LogTimeline, type TimeRange } from './components/LogTimeline'
import { LogPreview } from './components/LogPreview'
import { ComingSoon } from './components/ComingSoon'
import { fetchLogs, fetchStats, fetchTimeline, type LogEntry, type Stats, type TimelineBucket } from './lib/api'
const POLL_MS = 2000
export default function App() {
const [stats, setStats] = useState<Stats | null>(null)
const [logs, setLogs] = useState<LogEntry[]>([])
const [buckets, setBuckets] = useState<TimelineBucket[]>([])
const [range, setRange] = useState<TimeRange | null>(null)
const [filter, setFilter] = useState('')
useEffect(() => {
let cancelled = false
async function poll() {
try {
const [nextStats, nextBuckets, nextLogs] = await Promise.all([
fetchStats(),
fetchTimeline({ startMs: range?.startMs, endMs: range?.endMs }),
fetchLogs({ startMs: range?.startMs, endMs: range?.endMs, limit: 1000 }),
])
if (!cancelled) {
setStats(nextStats)
setBuckets(nextBuckets)
setLogs(nextLogs)
}
} catch {
// engine not reachable yet; retry on next tick
}
}
poll()
const id = setInterval(poll, POLL_MS)
return () => {
cancelled = true
clearInterval(id)
}
}, [range])
const visibleLogs = filter ? logs.filter((l) => l.text.includes(filter)) : logs
return (
<div className="flex h-screen w-screen overflow-hidden">
<NavMenu stats={stats} />
<div className="flex min-w-0 flex-1 flex-col">
<FilterBar value={filter} onChange={setFilter} />
<LogTimeline buckets={buckets} range={range} onSelectRange={setRange} />
<LogPreview logs={visibleLogs} />
<ComingSoon title="Metrics" />
<ComingSoon title="Dashboards" />
</div>
</div>
)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+7
View File
@@ -0,0 +1,7 @@
export function ComingSoon({ title }: { title: string }) {
return (
<div className="border-b border-neutral-800 p-3 text-xs text-neutral-600">
{title} — coming soon
</div>
)
}
+32
View File
@@ -0,0 +1,32 @@
import { useState } from 'react'
/**
* Placeholder for the syntax-aware pest-parsed filter language described in the
* README. For now this only does a plain substring match, applied client-side.
*/
export function FilterBar({ value, onChange }: { value: string; onChange: (value: string) => void }) {
const [draft, setDraft] = useState(value)
return (
<form
className="flex items-center gap-2 border-b border-neutral-800 px-3 py-2"
onSubmit={(e) => {
e.preventDefault()
onChange(draft)
}}
>
<input
className="flex-1 rounded border border-neutral-700 bg-neutral-900 px-2 py-1 text-sm text-neutral-100 placeholder:text-neutral-500 focus:border-neutral-500 focus:outline-none"
placeholder='regex("error") and sequence<5 or timestamp>12:00'
value={draft}
onChange={(e) => setDraft(e.target.value)}
/>
<button
type="submit"
className="rounded border border-neutral-700 px-3 py-1 text-sm text-neutral-300 hover:bg-neutral-800"
>
Filter
</button>
</form>
)
}
+35
View File
@@ -0,0 +1,35 @@
import type { LogEntry } from '../lib/api'
export function LogPreview({ logs }: { logs: LogEntry[] }) {
return (
<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>
<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>
<td className="px-3 py-1 text-neutral-200">{log.text}</td>
</tr>
))}
{logs.length === 0 && (
<tr>
<td colSpan={3} className="px-3 py-6 text-center text-neutral-600">
No logs yet — pipe something into ottyl.
</td>
</tr>
)}
</tbody>
</table>
</div>
)
}
+99
View File
@@ -0,0 +1,99 @@
import { useRef, useState } from 'react'
import type { TimelineBucket } from '../lib/api'
export interface TimeRange {
startMs: number
endMs: number
}
export function LogTimeline({
buckets,
range,
onSelectRange,
}: {
buckets: TimelineBucket[]
range: TimeRange | null
onSelectRange: (range: TimeRange | null) => void
}) {
const containerRef = useRef<HTMLDivElement>(null)
const [dragStartX, setDragStartX] = useState<number | null>(null)
const [dragCurrentX, setDragCurrentX] = useState<number | null>(null)
const maxCount = Math.max(1, ...buckets.map((b) => b.count))
const domainStart = range?.startMs ?? buckets[0]?.start_ms ?? 0
const domainEnd = range?.endMs ?? Date.now()
const domainSpan = Math.max(1, domainEnd - domainStart)
function xToMs(x: number) {
const rect = containerRef.current?.getBoundingClientRect()
if (!rect) return domainStart
const ratio = Math.min(1, Math.max(0, (x - rect.left) / rect.width))
return domainStart + ratio * domainSpan
}
function handleMouseDown(e: React.MouseEvent) {
setDragStartX(e.clientX)
setDragCurrentX(e.clientX)
}
function handleMouseMove(e: React.MouseEvent) {
if (dragStartX !== null) setDragCurrentX(e.clientX)
}
function handleMouseUp() {
if (dragStartX !== null && dragCurrentX !== null && Math.abs(dragCurrentX - dragStartX) > 3) {
const a = xToMs(Math.min(dragStartX, dragCurrentX))
const b = xToMs(Math.max(dragStartX, dragCurrentX))
onSelectRange({ startMs: a, endMs: b })
}
setDragStartX(null)
setDragCurrentX(null)
}
const dragging = dragStartX !== null && dragCurrentX !== null
const selRect = dragging
? {
left: Math.min(dragStartX!, dragCurrentX!),
width: Math.abs(dragCurrentX! - dragStartX!),
}
: null
return (
<div className="border-b border-neutral-800 p-3">
<div className="mb-1 flex items-center justify-between text-xs text-neutral-500">
<span>Timeline</span>
{range && (
<button className="underline hover:text-neutral-300" onClick={() => onSelectRange(null)}>
Clear selection
</button>
)}
</div>
<div
ref={containerRef}
className="relative h-24 w-full cursor-crosshair select-none rounded bg-neutral-900"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={() => {
setDragStartX(null)
setDragCurrentX(null)
}}
>
<div className="flex h-full w-full items-end gap-px overflow-hidden px-1">
{buckets.map((b) => (
<div
key={b.start_ms}
className="min-w-[2px] flex-1 rounded-t bg-sky-500/70"
style={{ height: `${(b.count / maxCount) * 100}%` }}
title={`${new Date(b.start_ms).toLocaleTimeString()} — ${b.count}`}
/>
))}
</div>
{selRect && (
<div
className="pointer-events-none absolute top-0 h-full border border-sky-400 bg-sky-400/20"
style={{ left: selRect.left - (containerRef.current?.getBoundingClientRect().left ?? 0), width: selRect.width }}
/>
)}
</div>
</div>
)
}
+30
View File
@@ -0,0 +1,30 @@
import type { Stats } from '../lib/api'
export function NavMenu({ stats }: { stats: Stats | null }) {
return (
<div className="flex h-full w-48 shrink-0 flex-col justify-between border-r border-neutral-800 p-3">
<div>
<div className="mb-4 text-lg font-semibold text-neutral-100">ottyl</div>
<nav className="flex flex-col gap-1 text-sm text-neutral-400">
<span className="rounded bg-neutral-800 px-2 py-1 text-neutral-100">Dashboard</span>
<span className="cursor-not-allowed px-2 py-1 opacity-50" title="Coming soon">
Presets
</span>
<span className="cursor-not-allowed px-2 py-1 opacity-50" title="Coming soon">
Settings
</span>
</nav>
</div>
<div className="text-xs text-neutral-500">
{stats ? (
<>
<div>{stats.log_count} logs</div>
<div>seq {stats.first_sequence ?? 0}–{stats.last_sequence ?? 0}</div>
</>
) : (
<div>connecting…</div>
)}
</div>
</div>
)
}
+17
View File
@@ -0,0 +1,17 @@
@import "tailwindcss";
:root {
color-scheme: light dark;
}
html,
body,
#root {
height: 100%;
margin: 0;
}
body {
@apply bg-neutral-950 text-neutral-200;
font: 14px/1.5 ui-monospace, Consolas, monospace;
}
+56
View File
@@ -0,0 +1,56 @@
export interface LogEntry {
sequence: number
timestamp_ms: number
text: string
}
export interface TimelineBucket {
start_ms: number
count: number
}
export interface Stats {
log_count: number
first_sequence: number | null
last_sequence: number | null
first_timestamp_ms: number | null
last_timestamp_ms: number | null
}
async function getJson<T>(path: string): Promise<T> {
const res = await fetch(path)
if (!res.ok) {
throw new Error(`${path} failed: ${res.status}`)
}
return res.json() as Promise<T>
}
export function fetchLogs(params: {
sinceSequence?: number
startMs?: number
endMs?: number
limit?: number
}): Promise<LogEntry[]> {
const search = new URLSearchParams()
if (params.sinceSequence !== undefined) search.set('since_sequence', String(params.sinceSequence))
if (params.startMs !== undefined) search.set('start_ms', String(params.startMs))
if (params.endMs !== undefined) search.set('end_ms', String(params.endMs))
if (params.limit !== undefined) search.set('limit', String(params.limit))
return getJson(`/api/logs?${search.toString()}`)
}
export function fetchTimeline(params: {
startMs?: number
endMs?: number
bucketMs?: number
}): Promise<TimelineBucket[]> {
const search = new URLSearchParams()
if (params.startMs !== undefined) search.set('start_ms', String(params.startMs))
if (params.endMs !== undefined) search.set('end_ms', String(params.endMs))
if (params.bucketMs !== undefined) search.set('bucket_ms', String(params.bucketMs))
return getJson(`/api/timeline?${search.toString()}`)
}
export function fetchStats(): Promise<Stats> {
return getJson('/api/stats')
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+13
View File
@@ -0,0 +1,13 @@
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
proxy: {
'/api': 'http://localhost:8420',
},
},
})