diff --git a/crates/api/src/dynamic_settings.rs b/crates/api/src/dynamic_settings.rs index 57f3b4fb9c..00a8c4e6eb 100644 --- a/crates/api/src/dynamic_settings.rs +++ b/crates/api/src/dynamic_settings.rs @@ -42,6 +42,9 @@ pub struct DynamicSettings { /// Whether log tracing should be enabled pub tracing_enabled: Arc, + + /// Log stream used to feed the admin web UI. + pub log_stream: crate::logging::stream::LogStream, } /// How often to check if the log filter (RUST_LOG) needs resetting diff --git a/crates/api/src/logging/mod.rs b/crates/api/src/logging/mod.rs index 0f2d541c30..c92e0e87b1 100644 --- a/crates/api/src/logging/mod.rs +++ b/crates/api/src/logging/mod.rs @@ -21,3 +21,4 @@ pub mod log_limiter; pub mod metrics_endpoint; pub mod service_health_metrics; pub mod setup; +pub mod stream; diff --git a/crates/api/src/logging/setup.rs b/crates/api/src/logging/setup.rs index 0ffebcb390..1541bbe805 100644 --- a/crates/api/src/logging/setup.rs +++ b/crates/api/src/logging/setup.rs @@ -35,6 +35,7 @@ use tracing_subscriber::util::SubscriberInitExt; use tracing_subscriber::{Layer, filter, reload}; use super::level_filter::ActiveLevel; +use super::stream::{LogStream, LogStreamLayer}; use crate::api::metrics::ApiMetricsEmitter; use crate::logging::level_filter::ReloadableFilter; @@ -43,6 +44,8 @@ pub struct Logging { pub filter: Arc, pub tracing_enabled: Arc, pub spancount_reader: Option, + /// Log stream used to feed the admin web UI. + pub log_stream: LogStream, } #[derive(Debug, Clone)] @@ -102,6 +105,10 @@ pub fn setup_logging( let spancount_layer = spancounter::layer(); let spancount_reader = spancount_layer.reader(); + // Used as part of a layer for collecting + brodcasting + // log events to the admin web UI. + let log_stream = LogStream::default(); + // == Dynamic filter for tracing enabled/disabled == // This doesn't track levels but instead just enabled/disabled (when we want tracing enabled, we // typically want a high level of verbosity.) Enabled by default if debug is enabled. @@ -147,6 +154,7 @@ pub fn setup_logging( .with(spancount_layer.with_filter(log_level)) .with(maybe_otel_tracing_layer) .with(logfmt_stdout_formatter.with_filter(logfmt_stdout_filter)) + .with(LogStreamLayer::new(log_stream.clone()).with_filter(initial_log_filter.clone())) .with(sqlx_query_tracing::create_sqlx_query_tracing_layer()) .try_init() .wrap_err("new tracing subscriber try_init()")?; @@ -167,6 +175,7 @@ pub fn setup_logging( .into(), tracing_enabled, spancount_reader: Some(spancount_reader), + log_stream, }) } } diff --git a/crates/api/src/logging/stream.rs b/crates/api/src/logging/stream.rs new file mode 100644 index 0000000000..9a8113384e --- /dev/null +++ b/crates/api/src/logging/stream.rs @@ -0,0 +1,375 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +//! Streaming layer of `tracing` log events for the web UI log viewer. +//! +//! [`LogStreamLayer`] is installed in the subscriber registry (see +//! [`crate::logging::setup`]) and forwards to a bounded broadcast channel (plus +//! a small replay ring buffer): +//! * every `tracing` event, tagged with the `span_id` of its enclosing span, and +//! * a `SPAN` summary line when each span closes, carrying the span's +//! accumulated fields and how long it was open. +//! +//! The span summaries are where request outcomes live (URL, gRPC status + error +//! message, SQL counts, timing), mirroring what the `logfmt` formatter writes to +//! stdout on span close. +//! +//! Backpressure is intentional: the broadcast channel is bounded, so a slow or +//! paused viewer falls behind and observes a `Lagged` notification (surfaced to +//! the UI as dropped lines) rather than ever blocking the logging hot path; we +//! are fine dropping/losing lines in this case. + +use std::collections::{BTreeMap, VecDeque}; +use std::sync::{Arc, Mutex}; +use std::time::Instant; + +use serde::Serialize; +use tokio::sync::broadcast; +use tracing::field::{Field, Visit}; +use tracing::span::{Attributes, Id, Record}; +use tracing::{Event, Subscriber}; +use tracing_subscriber::Layer; +use tracing_subscriber::layer::Context; +use tracing_subscriber::registry::LookupSpan; + +/// Number of lines buffered in the broadcast channel. A subscriber that falls +/// this far behind observes a `Lagged` notification and skips ahead rather than +/// blocking the sender. +const BROADCAST_CAPACITY: usize = 1024; + +/// Number of recent lines retained for replay-on-connect, so a freshly opened +/// viewer shows recent (but not all) history instead of a blank pane. +const REPLAY_CAPACITY: usize = 500; + +/// A single structured log line, serialized to the browser as JSON. Produced +/// both for `tracing` events and for span completions (`level == "SPAN"`). +#[derive(Debug, Clone, Serialize)] +pub struct LogLine { + /// RFC 3339 timestamp captured when the line was observed. + pub timestamp: String, + /// Log level, e.g. `"INFO"`, or `"SPAN"` for a span-completion summary. + pub level: &'static str, + /// Event target (module path / target string). + pub target: String, + /// The event's `message`, or the span name for a `SPAN` line. + pub message: String, + /// Remaining structured key/value fields, ordered by key. + pub fields: BTreeMap, + /// Source location `"file:line"`, if available. + pub location: Option, + /// `span_id` of the enclosing span (for events) or of the span itself (for + /// `SPAN` lines), when the span carries one. Drives correlation and + /// click-to-filter (by span ID) in the viewer. + pub span_id: Option, +} + +/// Shared handle to the live log stream: a broadcast sender plus a ring buffer +/// of recent lines. Cheap to clone fwiw; everything inside is an `Arc`/`Sender`. +#[derive(Debug, Clone)] +pub struct LogStream { + tx: broadcast::Sender>, + recent: Arc>>>, + replay_capacity: usize, +} + +impl LogStream { + pub fn new(broadcast_capacity: usize, replay_capacity: usize) -> Self { + let (tx, _rx) = broadcast::channel(broadcast_capacity); + Self { + tx, + recent: Arc::new(Mutex::new(VecDeque::with_capacity(replay_capacity))), + replay_capacity, + } + } + + /// Subscribe to lines published from this point forward. + pub fn subscribe(&self) -> broadcast::Receiver> { + self.tx.subscribe() + } + + /// Snapshot of recent lines, oldest first, for replay when a viewer connects. + pub fn recent(&self) -> Vec> { + match self.recent.lock() { + Ok(q) => q.iter().cloned().collect(), + Err(_) => Vec::new(), + } + } + + /// Record a lineby appending to the ring buffer (dropping the oldest + /// if full), and broadcast to live subscribers. + fn publish(&self, line: LogLine) { + let line = Arc::new(line); + if let Ok(mut q) = self.recent.lock() { + while q.len() >= self.replay_capacity { + q.pop_front(); + } + q.push_back(Arc::clone(&line)); + } + // `send` errors when there are no subscribers, so we just + // ignore the error. + let _ = self.tx.send(line); + } +} + +impl Default for LogStream { + fn default() -> Self { + Self::new(BROADCAST_CAPACITY, REPLAY_CAPACITY) + } +} + +/// Per-span data accumulated while a span is open, so we can emit a summary line +/// (with its fields + duration) when the span closes, and surface its `span_id` +/// on the events logged inside it. +struct SpanData { + fields: BTreeMap, + opened_at: Instant, +} + +/// A `tracing` layer that forwards events and span-completion +/// summaries to a [`LogStream`]. +pub struct LogStreamLayer { + stream: LogStream, +} + +impl LogStreamLayer { + pub fn new(stream: LogStream) -> Self { + Self { stream } + } +} + +impl Layer for LogStreamLayer +where + S: Subscriber + for<'a> LookupSpan<'a>, +{ + fn on_new_span(&self, attrs: &Attributes<'_>, id: &Id, ctx: Context<'_, S>) { + let Some(span) = ctx.span(id) else { return }; + let mut visitor = LogLineVisitor::default(); + attrs.record(&mut visitor); + span.extensions_mut().insert(SpanData { + fields: visitor.fields, + opened_at: Instant::now(), + }); + } + + fn on_record(&self, id: &Id, values: &Record<'_>, ctx: Context<'_, S>) { + let Some(span) = ctx.span(id) else { return }; + let mut visitor = LogLineVisitor::default(); + values.record(&mut visitor); + let mut extensions = span.extensions_mut(); + if let Some(data) = extensions.get_mut::() { + data.fields.extend(visitor.fields); + } + } + + fn on_event(&self, event: &Event<'_>, ctx: Context<'_, S>) { + let meta = event.metadata(); + + let mut visitor = LogLineVisitor::default(); + event.record(&mut visitor); + + self.stream.publish(LogLine { + timestamp: now_rfc3339(), + level: meta.level().as_str(), + target: meta.target().to_string(), + message: visitor.message.unwrap_or_default(), + fields: visitor.fields, + location: location_of(meta), + span_id: enclosing_span_id(&ctx, event), + }); + } + + fn on_close(&self, id: Id, ctx: Context<'_, S>) { + let Some(span) = ctx.span(&id) else { return }; + + let (mut fields, elapsed_ms) = { + let extensions = span.extensions(); + let Some(data) = extensions.get::() else { + return; + }; + (data.fields.clone(), data.opened_at.elapsed().as_millis()) + }; + + // Put `span_id` to its own slot (for span click-to-filter) + // and record how long the span was open. + let span_id = fields.remove("span_id"); + fields.insert("elapsed_ms".to_string(), elapsed_ms.to_string()); + + let meta = span.metadata(); + self.stream.publish(LogLine { + timestamp: now_rfc3339(), + level: "SPAN", + target: meta.target().to_string(), + message: meta.name().to_string(), + fields, + location: location_of(meta), + span_id, + }); + } +} + +fn now_rfc3339() -> String { + chrono::Utc::now().to_rfc3339() +} + +fn location_of(meta: &tracing::Metadata<'_>) -> Option { + match (meta.file(), meta.line()) { + (Some(file), Some(line)) => Some(format!("{file}:{line}")), + (Some(file), None) => Some(file.to_string()), + _ => None, + } +} + +/// Walk from the event's span up through its ancestors and +/// return the first `span_id` field found. +fn enclosing_span_id(ctx: &Context<'_, S>, event: &Event<'_>) -> Option +where + S: Subscriber + for<'a> LookupSpan<'a>, +{ + let span = ctx.event_span(event)?; + span.scope().find_map(|s| { + s.extensions() + .get::() + .and_then(|data| data.fields.get("span_id").cloned()) + }) +} + +/// Collects a `message` and remaining fields from an event or span, +/// mirroring the field handling in the crate's logfmt layer (the `message` +/// field is special-cased; span attributes have no message and leave +/// it `None`). +#[derive(Default)] +struct LogLineVisitor { + message: Option, + fields: BTreeMap, +} + +impl LogLineVisitor { + fn insert(&mut self, field: &Field, value: String) { + if field.name() == "message" { + self.message = Some(value); + } else { + self.fields.insert(field.name().to_string(), value); + } + } +} + +impl Visit for LogLineVisitor { + fn record_str(&mut self, field: &Field, value: &str) { + self.insert(field, value.to_string()); + } + + fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { + self.insert(field, format!("{value:?}")); + } + + fn record_bool(&mut self, field: &Field, value: bool) { + self.insert(field, value.to_string()); + } + + fn record_i64(&mut self, field: &Field, value: i64) { + self.insert(field, value.to_string()); + } + + fn record_u64(&mut self, field: &Field, value: u64) { + self.insert(field, value.to_string()); + } + + fn record_f64(&mut self, field: &Field, value: f64) { + self.insert(field, value.to_string()); + } + + fn record_error(&mut self, field: &Field, value: &(dyn std::error::Error + 'static)) { + self.insert(field, value.to_string()); + } +} + +#[cfg(test)] +mod tests { + use tracing_subscriber::prelude::*; + + use super::*; + + #[test] + fn captures_event_level_target_message_and_fields() { + let stream = LogStream::new(16, 8); + let mut rx = stream.subscribe(); + let subscriber = tracing_subscriber::registry().with(LogStreamLayer::new(stream.clone())); + + tracing::subscriber::with_default(subscriber, || { + tracing::info!(target: "test_target", answer = 42, "hello world"); + }); + + let line = rx.try_recv().expect("a line should have been broadcast"); + assert_eq!(line.level, "INFO"); + assert_eq!(line.target, "test_target"); + assert_eq!(line.message, "hello world"); + assert_eq!(line.fields.get("answer").map(String::as_str), Some("42")); + assert_eq!(line.span_id, None); + + // The same line is retained for replay-on-connect. + let recent = stream.recent(); + assert_eq!(recent.len(), 1); + assert_eq!(recent[0].message, "hello world"); + } + + #[test] + fn replay_buffer_drops_oldest_at_capacity() { + let stream = LogStream::new(8, 3); + let subscriber = tracing_subscriber::registry().with(LogStreamLayer::new(stream.clone())); + + tracing::subscriber::with_default(subscriber, || { + for i in 0..5 { + tracing::info!(target: "t", "line {i}"); + } + }); + + let recent = stream.recent(); + assert_eq!(recent.len(), 3, "ring buffer should cap at replay_capacity"); + assert_eq!(recent[0].message, "line 2"); + assert_eq!(recent[2].message, "line 4"); + } + + #[test] + fn carries_span_id_on_events_and_emits_span_summary() { + let stream = LogStream::new(32, 16); + let mut rx = stream.subscribe(); + let subscriber = tracing_subscriber::registry().with(LogStreamLayer::new(stream)); + + tracing::subscriber::with_default(subscriber, || { + let span = tracing::info_span!("request", span_id = "0xabc", http_url = "/x"); + let _enter = span.enter(); + tracing::info!("inside span"); + }); + + // The event inside the span carries the span's id. + let event_line = rx.try_recv().expect("event line"); + assert_eq!(event_line.message, "inside span"); + assert_eq!(event_line.span_id.as_deref(), Some("0xabc")); + + // Closing the span produces a SPAN summary line carrying its fields. + let span_line = rx.try_recv().expect("span summary line"); + assert_eq!(span_line.level, "SPAN"); + assert_eq!(span_line.message, "request"); + assert_eq!(span_line.span_id.as_deref(), Some("0xabc")); + assert_eq!( + span_line.fields.get("http_url").map(String::as_str), + Some("/x") + ); + assert!(span_line.fields.contains_key("elapsed_ms")); + assert!(!span_line.fields.contains_key("span_id")); + } +} diff --git a/crates/api/src/run.rs b/crates/api/src/run.rs index be384b5905..7fb9f3fd8f 100644 --- a/crates/api/src/run.rs +++ b/crates/api/src/run.rs @@ -132,6 +132,7 @@ pub async fn run( create_machines: carbide_config.site_explorer.create_machines.clone(), bmc_proxy: carbide_config.site_explorer.bmc_proxy.clone(), tracing_enabled: tconf.tracing_enabled, + log_stream: tconf.log_stream, }; dynamic_settings.start_reset_task( &mut join_set, diff --git a/crates/api/src/tests/common/api_fixtures/mod.rs b/crates/api/src/tests/common/api_fixtures/mod.rs index 9bad166633..69ef1b2afa 100644 --- a/crates/api/src/tests/common/api_fixtures/mod.rs +++ b/crates/api/src/tests/common/api_fixtures/mod.rs @@ -1618,6 +1618,7 @@ pub async fn create_test_env_with_overrides( create_machines: config.site_explorer.create_machines.clone(), bmc_proxy: config.site_explorer.bmc_proxy.clone(), tracing_enabled: Arc::new(false.into()), + log_stream: Default::default(), }; let bmc_proxy = Arc::new(ArcSwap::new(None.into())); diff --git a/crates/api/src/web/logs.rs b/crates/api/src/web/logs.rs new file mode 100644 index 0000000000..a6e29a1dbb --- /dev/null +++ b/crates/api/src/web/logs.rs @@ -0,0 +1,96 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +//! Admin UI live log viewer. +//! +//! `page()` serves up the unified logs hub. `stream()` is a Server-Sent Events +//! endpoint that replays the recent in-process tracing buffer and then tails live +//! events from [`crate::logging::stream::LogStream`]. Only the `api` source is +//! wired in for now. The idea is this logs hub will eventually be a place where +//! we can get similar log streaming for Scout and DPU agents via ScoutStream. + +use std::convert::Infallible; +use std::sync::Arc; + +use askama::Template; +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::sse::{Event, KeepAlive, Sse}; +use axum::response::{Html, IntoResponse, Response}; +use futures::stream::{self, StreamExt}; +use tokio::sync::broadcast::error::RecvError; + +use super::Base; +use crate::api::Api; +use crate::logging::stream::LogLine; + +#[derive(Template)] +#[template(path = "api_logs.html")] +struct LogsPage {} + +impl Base for LogsPage {} + +/// `GET /admin/logs` — the unified live log viewer hub. +pub async fn page() -> Html { + Html(LogsPage {}.render().unwrap()) +} + +/// Handle `GET /admin/logs/{source}/stream`, which opens up +/// the Server-Sent Events stream of nico-api log lines. +pub async fn stream(State(state): State>, Path(source): Path) -> Response { + if source != "api" { + return ( + StatusCode::NOT_FOUND, + format!("log source {source:?} is not available yet"), + ) + .into_response(); + } + + let log_stream = state.dynamic_settings.log_stream.clone(); + // Subscribe before snapshotting the backlog so no line slips through the gap + // between the two. + let rx = log_stream.subscribe(); + let backlog = log_stream.recent(); + + let replay = stream::iter( + backlog + .into_iter() + .map(|line| Ok::<_, Infallible>(line_event(line.as_ref()))), + ); + + let live = stream::unfold(rx, |mut rx| async move { + match rx.recv().await { + Ok(line) => Some((Ok::<_, Infallible>(line_event(line.as_ref())), rx)), + // A subscriber that fell behind the bounded channel: tell the viewer + // how many lines it missed rather than dropping the connection. + Err(RecvError::Lagged(skipped)) => { + let ev = Event::default().event("lag").data(skipped.to_string()); + Some((Ok(ev), rx)) + } + Err(RecvError::Closed) => None, + } + }); + + Sse::new(replay.chain(live)) + .keep_alive(KeepAlive::default()) + .into_response() +} + +/// Serialize a log line into an SSE data frame (one JSON object per event). +fn line_event(line: &LogLine) -> Event { + Event::default().data(serde_json::to_string(line).unwrap_or_default()) +} diff --git a/crates/api/src/web/mod.rs b/crates/api/src/web/mod.rs index e840d7e382..5495bee8c4 100644 --- a/crates/api/src/web/mod.rs +++ b/crates/api/src/web/mod.rs @@ -246,6 +246,7 @@ mod instance_type; mod interface; mod ipam; mod ipxe_template; +mod logs; mod machine; mod machine_validation; pub mod managed_host; @@ -774,6 +775,8 @@ pub fn routes(api: Arc) -> eyre::Result> { get(machine_validation::external_configs), ) .route("/ufm-browser", get(ufm_browser::query)) + .route("/logs", get(logs::page)) + .route("/logs/{source}/stream", get(logs::stream)) .layer(axum::middleware::from_fn(auth_oauth2)) .layer(Extension(oauth_extension_layer)) .with_state(api), diff --git a/crates/api/templates/api_logs.html b/crates/api/templates/api_logs.html new file mode 100644 index 0000000000..d6b2699c80 --- /dev/null +++ b/crates/api/templates/api_logs.html @@ -0,0 +1,183 @@ +{% extends "base.html" %} + +{% block title %}Logs{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +

Logs

+
+ + + +
+
+ + + + + + connecting… +
+
+{% endblock %} + +{% block script %} +{% raw %} +(function () { + const out = document.getElementById('log-output'); + if (!out) return; + const levelSel = document.getElementById('log-level-filter'); + const textInput = document.getElementById('log-text-filter'); + const pauseBtn = document.getElementById('log-pause'); + const clearBtn = document.getElementById('log-clear'); + const statusEl = document.getElementById('log-status'); + const showSpans = document.getElementById('log-show-spans'); + + const RANK = { TRACE: 0, DEBUG: 1, INFO: 2, WARN: 3, ERROR: 4 }; + const MAX_ROWS = 5000; + let paused = false; + + function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, function (c) { + return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; + }); + } + + function rowMatches(row) { + if (row.dataset.level === 'SPAN') { + if (!showSpans.checked) return false; + } else { + const minLevel = parseInt(levelSel.value, 10); + if ((RANK[row.dataset.level] || 0) < minLevel) return false; + } + const q = textInput.value.trim().toLowerCase(); + if (q && row.dataset.text.indexOf(q) === -1) return false; + return true; + } + + function applyFilter(row) { + row.classList.toggle('hidden', !rowMatches(row)); + } + + function refilterAll() { + out.querySelectorAll('.log-row').forEach(applyFilter); + } + + function atBottom() { + return out.scrollHeight - out.scrollTop - out.clientHeight < 40; + } + + function addLine(line) { + const stick = !paused && atBottom(); + const row = document.createElement('div'); + const level = line.level || 'INFO'; + row.className = 'log-row lvl-' + level; + row.dataset.level = level; + + let fieldsStr = ''; + if (line.fields) { + for (const k of Object.keys(line.fields)) fieldsStr += ' ' + k + '=' + line.fields[k]; + } + row.dataset.text = ((line.target || '') + ' ' + (line.message || '') + fieldsStr + ' ' + (line.location || '') + ' ' + (line.span_id || '')).toLowerCase(); + + const ts = (line.timestamp || '') + .replace('T', ' ').replace(/\.\d+/, '').replace(/[+-]\d\d:\d\d$/, '').replace('Z', ''); + const sid = line.span_id + ? ' ' + escapeHtml(line.span_id) + '' + : ''; + row.innerHTML = + '' + escapeHtml(ts) + ' ' + + escapeHtml(level) + sid + ' ' + + '' + escapeHtml(line.target || '') + ' ' + + escapeHtml(line.message || '') + + (fieldsStr ? escapeHtml(fieldsStr) : '') + + (line.location ? ' ' + escapeHtml(line.location) + '' : ''); + + applyFilter(row); + out.appendChild(row); + while (out.childElementCount > MAX_ROWS) out.removeChild(out.firstChild); + if (stick) out.scrollTop = out.scrollHeight; + } + + function addLag(n) { + const stick = !paused && atBottom(); + const row = document.createElement('div'); + row.className = 'log-row lag'; + row.dataset.level = 'ERROR'; + row.dataset.text = 'dropped lines'; + row.textContent = '… ' + n + ' line(s) dropped (viewer fell behind) …'; + out.appendChild(row); + if (stick) out.scrollTop = out.scrollHeight; + } + + levelSel.addEventListener('change', refilterAll); + textInput.addEventListener('input', refilterAll); + showSpans.addEventListener('change', refilterAll); + out.addEventListener('click', function (e) { + if (e.target && e.target.classList.contains('sid')) { + textInput.value = e.target.dataset.sid; + refilterAll(); + } + }); + pauseBtn.addEventListener('click', function () { + paused = !paused; + pauseBtn.textContent = paused ? 'Resume' : 'Pause'; + if (!paused) out.scrollTop = out.scrollHeight; + }); + clearBtn.addEventListener('click', function () { out.replaceChildren(); }); + + const es = new EventSource('/admin/logs/api/stream'); + es.onopen = function () { statusEl.textContent = 'streaming'; }; + es.onmessage = function (e) { + try { addLine(JSON.parse(e.data)); } catch (_) { /* ignore malformed payload */ } + }; + es.addEventListener('lag', function (e) { addLag(e.data); }); + es.onerror = function () { statusEl.textContent = 'reconnecting…'; }; +})(); +{% endraw %} +{% endblock %} diff --git a/crates/api/templates/base.html b/crates/api/templates/base.html index b4da27df53..5eae0f20a7 100644 --- a/crates/api/templates/base.html +++ b/crates/api/templates/base.html @@ -16,6 +16,7 @@

carbide-web


Racks