feat(host,web): host log ring + GET /api/v1/logs + console Logs page
Remote debugging without shell access: a tracing layer tees every event at DEBUG-and-up — independent of the RUST_LOG filter gating stderr/host.log, so console-side debugging never needs a restart — into a bounded in-memory ring (log_capture.rs, 4096 newest entries, OnceLock singleton like config()), installed at both init sites (stderr path in main, the Windows service file path). The mgmt API serves it cursor-paged at GET /api/v1/logs?after=&limit= — bearer-only and deliberately NOT on the mTLS cert allowlist (log lines can name client identities and host paths). The web console grows a Logs page (follow/pause · min-level filter · text search · eviction-gap badge); polling self-paces: a non-empty page advances the after-cursor (new query key → immediate refetch, drains backlogs), an empty page idles at the 2s interval. OpenAPI regenerated; ring pagination/eviction, layer wiring, and the authed route are unit-tested; Storybook story included. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -578,6 +578,62 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/logs": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"logs"
|
||||||
|
],
|
||||||
|
"summary": "Host logs",
|
||||||
|
"description": "The host's recent log entries — an in-memory ring of the newest few thousand, captured at\nDEBUG and above regardless of `RUST_LOG`. Follow live by polling with `after` set to the last\nresponse's `next` cursor; a `dropped: true` means entries were evicted between polls (the ring\nwrapped). Bearer-only: logs can reference client identities and host paths, so this is part of\nthe loopback-only admin surface, never the LAN-readable mTLS one.",
|
||||||
|
"operationId": "logsGet",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "after",
|
||||||
|
"in": "query",
|
||||||
|
"description": "Return entries with seq greater than this (omitted/0 = oldest retained)",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64",
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "limit",
|
||||||
|
"in": "query",
|
||||||
|
"description": "Max entries per response (default and cap 1000)",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Entries after the cursor, oldest first",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/LogPage"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid bearer token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/native/clients": {
|
"/api/v1/native/clients": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -2027,6 +2083,70 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"LogEntry": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "One captured log event.",
|
||||||
|
"required": [
|
||||||
|
"seq",
|
||||||
|
"ts_ms",
|
||||||
|
"level",
|
||||||
|
"target",
|
||||||
|
"msg"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"level": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "`ERROR` | `WARN` | `INFO` | `DEBUG` | `TRACE`."
|
||||||
|
},
|
||||||
|
"msg": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The formatted message, structured fields appended as `key=value`."
|
||||||
|
},
|
||||||
|
"seq": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64",
|
||||||
|
"description": "Monotonic sequence number (1-based) — pass the last one back as the `after` cursor.",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"target": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The emitting module path (tracing target)."
|
||||||
|
},
|
||||||
|
"ts_ms": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64",
|
||||||
|
"description": "Unix timestamp in milliseconds.",
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"LogPage": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "One poll's worth of log entries.",
|
||||||
|
"required": [
|
||||||
|
"entries",
|
||||||
|
"next",
|
||||||
|
"dropped"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"dropped": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "True when entries between `after` and the first returned one were already evicted."
|
||||||
|
},
|
||||||
|
"entries": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/LogEntry"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"next": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64",
|
||||||
|
"description": "Cursor for the next poll (the last returned seq, or the request's `after` when empty).",
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"NativeClient": {
|
"NativeClient": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "A paired native (punktfunk/1) client.",
|
"description": "A paired native (punktfunk/1) client.",
|
||||||
@@ -2571,6 +2691,10 @@
|
|||||||
{
|
{
|
||||||
"name": "stats",
|
"name": "stats",
|
||||||
"description": "Streaming performance-stats capture: arm/stop a recording, read the live + saved time-series for graphing"
|
"description": "Streaming performance-stats capture: arm/stop a recording, read the live + saved time-series for graphing"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "logs",
|
||||||
|
"description": "Host log stream: the newest in-memory log entries, cursor-paged for live following"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
//! In-memory capture of the host's own log stream for the web console.
|
||||||
|
//!
|
||||||
|
//! A `tracing` layer tees every event at DEBUG and above — independent of the `RUST_LOG` filter
|
||||||
|
//! that gates stderr/file output — into a bounded in-process ring, and the management API serves
|
||||||
|
//! it as `GET /api/v1/logs` (see `mgmt.rs`). That gives an operator the host's recent logs from
|
||||||
|
//! the web console without shell access to the box, which is where gamepad-driver / capture /
|
||||||
|
//! encoder failures otherwise go to die ("it just doesn't work" bug reports).
|
||||||
|
//!
|
||||||
|
//! The ring keeps the *newest* [`CAPACITY`] entries (a log tail — unlike the stats recorder,
|
||||||
|
//! which keeps the head of a capture). Readers poll with an `after` sequence cursor.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
/// Ring capacity — bounds memory at a few MB worst case ([`MAX_MSG`]-sized entries).
|
||||||
|
const CAPACITY: usize = 4096;
|
||||||
|
/// Per-entry message cap; log lines are short, anything longer is a payload dump we truncate.
|
||||||
|
const MAX_MSG: usize = 2048;
|
||||||
|
/// Hard cap on entries returned per poll (the client immediately re-polls to drain a backlog).
|
||||||
|
pub const MAX_PAGE: usize = 1000;
|
||||||
|
|
||||||
|
/// One captured log event.
|
||||||
|
#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)]
|
||||||
|
pub struct LogEntry {
|
||||||
|
/// Monotonic sequence number (1-based) — pass the last one back as the `after` cursor.
|
||||||
|
pub seq: u64,
|
||||||
|
/// Unix timestamp in milliseconds.
|
||||||
|
pub ts_ms: u64,
|
||||||
|
/// `ERROR` | `WARN` | `INFO` | `DEBUG` | `TRACE`.
|
||||||
|
pub level: String,
|
||||||
|
/// The emitting module path (tracing target).
|
||||||
|
pub target: String,
|
||||||
|
/// The formatted message, structured fields appended as `key=value`.
|
||||||
|
pub msg: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One poll's worth of log entries.
|
||||||
|
#[derive(Serialize, Deserialize, ToSchema, Debug)]
|
||||||
|
pub struct LogPage {
|
||||||
|
pub entries: Vec<LogEntry>,
|
||||||
|
/// Cursor for the next poll (the last returned seq, or the request's `after` when empty).
|
||||||
|
pub next: u64,
|
||||||
|
/// True when entries between `after` and the first returned one were already evicted.
|
||||||
|
pub dropped: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The process-wide log ring (see [`ring`]).
|
||||||
|
pub struct LogRing {
|
||||||
|
inner: Mutex<Inner>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Inner {
|
||||||
|
entries: VecDeque<LogEntry>,
|
||||||
|
next_seq: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LogRing {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
inner: Mutex::new(Inner {
|
||||||
|
entries: VecDeque::with_capacity(CAPACITY),
|
||||||
|
next_seq: 1,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `pub(crate)` for the mgmt handler tests; production entries only come from [`RingLayer`].
|
||||||
|
pub(crate) fn push(&self, level: &tracing::Level, target: &str, msg: String) {
|
||||||
|
let ts_ms = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_millis() as u64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let seq = inner.next_seq;
|
||||||
|
inner.next_seq += 1;
|
||||||
|
if inner.entries.len() == CAPACITY {
|
||||||
|
inner.entries.pop_front();
|
||||||
|
}
|
||||||
|
inner.entries.push_back(LogEntry {
|
||||||
|
seq,
|
||||||
|
ts_ms,
|
||||||
|
level: level.to_string(),
|
||||||
|
target: target.to_string(),
|
||||||
|
msg,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entries with `seq > after`, oldest first, capped at `limit` (≤ [`MAX_PAGE`]).
|
||||||
|
pub fn since(&self, after: u64, limit: usize) -> LogPage {
|
||||||
|
let limit = limit.clamp(1, MAX_PAGE);
|
||||||
|
let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
// Entries are seq-ordered and contiguous: index of the first wanted one is derivable.
|
||||||
|
let first_seq = inner.entries.front().map_or(inner.next_seq, |e| e.seq);
|
||||||
|
let dropped = after != 0 && after + 1 < first_seq;
|
||||||
|
let skip = after
|
||||||
|
.saturating_sub(first_seq)
|
||||||
|
.saturating_add(u64::from(after >= first_seq)) as usize;
|
||||||
|
let entries: Vec<LogEntry> = inner
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.skip(skip)
|
||||||
|
.take(limit)
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
let next = entries.last().map_or(after, |e| e.seq);
|
||||||
|
LogPage {
|
||||||
|
entries,
|
||||||
|
next,
|
||||||
|
dropped,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The process-wide ring — a `OnceLock` singleton so the tracing layer (installed in `main()`
|
||||||
|
/// before any host state exists) and the mgmt handler share it without threading an `Arc`.
|
||||||
|
pub fn ring() -> &'static LogRing {
|
||||||
|
static RING: OnceLock<LogRing> = OnceLock::new();
|
||||||
|
RING.get_or_init(LogRing::new)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tee: a `tracing_subscriber` layer pushing every event into [`ring`]. Install with a
|
||||||
|
/// per-layer `LevelFilter::DEBUG` so the ring sees DEBUG even when `RUST_LOG` keeps stderr at
|
||||||
|
/// `info` (remote debugging must not require a restart with a different env).
|
||||||
|
pub struct RingLayer;
|
||||||
|
|
||||||
|
impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for RingLayer {
|
||||||
|
fn on_event(
|
||||||
|
&self,
|
||||||
|
event: &tracing::Event<'_>,
|
||||||
|
_ctx: tracing_subscriber::layer::Context<'_, S>,
|
||||||
|
) {
|
||||||
|
let meta = event.metadata();
|
||||||
|
let mut fields = FieldFmt::default();
|
||||||
|
event.record(&mut fields);
|
||||||
|
ring().push(meta.level(), meta.target(), fields.finish());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats an event's fields like the default fmt layer: the `message` field first, every other
|
||||||
|
/// field appended as ` key=value`.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct FieldFmt {
|
||||||
|
msg: String,
|
||||||
|
fields: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl tracing::field::Visit for FieldFmt {
|
||||||
|
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
|
||||||
|
use std::fmt::Write;
|
||||||
|
if field.name() == "message" {
|
||||||
|
let _ = write!(self.msg, "{value:?}");
|
||||||
|
} else {
|
||||||
|
let _ = write!(self.fields, " {}={:?}", field.name(), value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
|
||||||
|
use std::fmt::Write;
|
||||||
|
if field.name() == "message" {
|
||||||
|
self.msg.push_str(value);
|
||||||
|
} else {
|
||||||
|
let _ = write!(self.fields, " {}={value}", field.name());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FieldFmt {
|
||||||
|
fn finish(mut self) -> String {
|
||||||
|
if self.msg.is_empty() {
|
||||||
|
self.msg = self.fields.trim_start().to_string();
|
||||||
|
} else {
|
||||||
|
self.msg.push_str(&self.fields);
|
||||||
|
}
|
||||||
|
if self.msg.len() > MAX_MSG {
|
||||||
|
let mut end = MAX_MSG;
|
||||||
|
while !self.msg.is_char_boundary(end) {
|
||||||
|
end -= 1;
|
||||||
|
}
|
||||||
|
self.msg.truncate(end);
|
||||||
|
self.msg.push('…');
|
||||||
|
}
|
||||||
|
self.msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn push_n(ring: &LogRing, n: usize) {
|
||||||
|
for i in 0..n {
|
||||||
|
ring.push(&tracing::Level::INFO, "test", format!("m{i}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cursor_pagination_and_eviction() {
|
||||||
|
let ring = LogRing::new();
|
||||||
|
push_n(&ring, 10);
|
||||||
|
|
||||||
|
// Full backfill from 0.
|
||||||
|
let page = ring.since(0, 100);
|
||||||
|
assert_eq!(page.entries.len(), 10);
|
||||||
|
assert_eq!(page.next, 10);
|
||||||
|
assert!(!page.dropped);
|
||||||
|
|
||||||
|
// Incremental: nothing new.
|
||||||
|
let page = ring.since(10, 100);
|
||||||
|
assert!(page.entries.is_empty());
|
||||||
|
assert_eq!(page.next, 10);
|
||||||
|
|
||||||
|
// Incremental: partial.
|
||||||
|
let page = ring.since(4, 3);
|
||||||
|
assert_eq!(
|
||||||
|
page.entries.iter().map(|e| e.seq).collect::<Vec<_>>(),
|
||||||
|
vec![5, 6, 7]
|
||||||
|
);
|
||||||
|
assert_eq!(page.next, 7);
|
||||||
|
assert!(!page.dropped);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn eviction_reports_dropped() {
|
||||||
|
let ring = LogRing::new();
|
||||||
|
push_n(&ring, CAPACITY + 50);
|
||||||
|
// Seqs 1..=50 were evicted; a cursor inside the gap must flag it.
|
||||||
|
let page = ring.since(10, 5);
|
||||||
|
assert!(page.dropped);
|
||||||
|
assert_eq!(page.entries.first().map(|e| e.seq), Some(51));
|
||||||
|
// A cursor at the ring head is not a gap.
|
||||||
|
let head = ring.since(page.next, 5);
|
||||||
|
assert!(!head.dropped);
|
||||||
|
assert_eq!(head.entries.first().map(|e| e.seq), Some(page.next + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn layer_captures_events_into_the_singleton_ring() {
|
||||||
|
use tracing_subscriber::layer::SubscriberExt;
|
||||||
|
|
||||||
|
// The singleton ring is process-wide — find its current tail first (parallel tests may
|
||||||
|
// interleave, so only assert on OUR event appearing after it).
|
||||||
|
let mut cur = 0;
|
||||||
|
loop {
|
||||||
|
let page = ring().since(cur, MAX_PAGE);
|
||||||
|
if page.entries.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
cur = page.next;
|
||||||
|
}
|
||||||
|
|
||||||
|
let subscriber = tracing_subscriber::registry().with(RingLayer);
|
||||||
|
tracing::subscriber::with_default(subscriber, || {
|
||||||
|
tracing::warn!(answer = 42, "ring layer test message");
|
||||||
|
});
|
||||||
|
|
||||||
|
let page = ring().since(cur, MAX_PAGE);
|
||||||
|
let hit = page
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.find(|e| e.msg.contains("ring layer test message"))
|
||||||
|
.expect("event captured");
|
||||||
|
assert_eq!(hit.level, "WARN");
|
||||||
|
assert!(
|
||||||
|
hit.msg.contains("answer=42"),
|
||||||
|
"fields appended: {}",
|
||||||
|
hit.msg
|
||||||
|
);
|
||||||
|
assert!(hit.target.contains("log_capture"), "target: {}", hit.target);
|
||||||
|
assert!(hit.ts_ms > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn message_truncation_keeps_char_boundary() {
|
||||||
|
let f = FieldFmt {
|
||||||
|
msg: "ä".repeat(MAX_MSG), // 2 bytes each — exceeds the cap at a multi-byte boundary
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let out = f.finish();
|
||||||
|
assert!(out.ends_with('…'));
|
||||||
|
assert!(out.len() <= MAX_MSG + '…'.len_utf8());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ mod install;
|
|||||||
#[path = "windows/interactive.rs"]
|
#[path = "windows/interactive.rs"]
|
||||||
mod interactive;
|
mod interactive;
|
||||||
mod library;
|
mod library;
|
||||||
|
mod log_capture;
|
||||||
mod mgmt;
|
mod mgmt;
|
||||||
mod mgmt_token;
|
mod mgmt_token;
|
||||||
mod native_pairing;
|
mod native_pairing;
|
||||||
@@ -92,9 +93,20 @@ fn main() {
|
|||||||
service::init_file_logging(filter);
|
service::init_file_logging(filter);
|
||||||
} else {
|
} else {
|
||||||
// Logs go to stderr so stdout stays machine-readable (`punktfunk-host openapi > spec.json`).
|
// Logs go to stderr so stdout stays machine-readable (`punktfunk-host openapi > spec.json`).
|
||||||
tracing_subscriber::fmt()
|
// A second layer tees DEBUG-and-up into the in-memory ring served by GET /api/v1/logs —
|
||||||
.with_env_filter(filter)
|
// deliberately not gated by RUST_LOG, so console-side debugging never needs a restart.
|
||||||
|
use tracing_subscriber::layer::SubscriberExt;
|
||||||
|
use tracing_subscriber::util::SubscriberInitExt;
|
||||||
|
use tracing_subscriber::Layer;
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(
|
||||||
|
log_capture::RingLayer.with_filter(tracing_subscriber::filter::LevelFilter::DEBUG),
|
||||||
|
)
|
||||||
|
.with(
|
||||||
|
tracing_subscriber::fmt::layer()
|
||||||
.with_writer(std::io::stderr)
|
.with_writer(std::io::stderr)
|
||||||
|
.with_filter(filter),
|
||||||
|
)
|
||||||
.init();
|
.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,10 +25,11 @@ use crate::gamestream::{
|
|||||||
tls::{serve_https, PeerAddr, PeerCertFingerprint},
|
tls::{serve_https, PeerAddr, PeerCertFingerprint},
|
||||||
AppState, APP_VERSION, AUDIO_PORT, CONTROL_PORT, GFE_VERSION, RTSP_PORT, VIDEO_PORT,
|
AppState, APP_VERSION, AUDIO_PORT, CONTROL_PORT, GFE_VERSION, RTSP_PORT, VIDEO_PORT,
|
||||||
};
|
};
|
||||||
|
use crate::log_capture::LogPage;
|
||||||
use crate::stats_recorder::{Capture, CaptureMeta, StatsStatus};
|
use crate::stats_recorder::{Capture, CaptureMeta, StatsStatus};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, Request, State},
|
extract::{Path, Query, Request, State},
|
||||||
http::{header, Method, StatusCode},
|
http::{header, Method, StatusCode},
|
||||||
middleware::{self, Next},
|
middleware::{self, Next},
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
@@ -179,7 +180,8 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
|
|||||||
.routes(routes!(stats_capture_status))
|
.routes(routes!(stats_capture_status))
|
||||||
.routes(routes!(stats_capture_live))
|
.routes(routes!(stats_capture_live))
|
||||||
.routes(routes!(stats_recordings_list))
|
.routes(routes!(stats_recordings_list))
|
||||||
.routes(routes!(stats_recording_get, stats_recording_delete)),
|
.routes(routes!(stats_recording_get, stats_recording_delete))
|
||||||
|
.routes(routes!(logs_get)),
|
||||||
)
|
)
|
||||||
.split_for_parts()
|
.split_for_parts()
|
||||||
}
|
}
|
||||||
@@ -213,6 +215,7 @@ pub fn openapi_json() -> String {
|
|||||||
(name = "session", description = "Active streaming session control"),
|
(name = "session", description = "Active streaming session control"),
|
||||||
(name = "library", description = "Game library: installed-store titles (Steam) plus user-curated custom entries"),
|
(name = "library", description = "Game library: installed-store titles (Steam) plus user-curated custom entries"),
|
||||||
(name = "stats", description = "Streaming performance-stats capture: arm/stop a recording, read the live + saved time-series for graphing"),
|
(name = "stats", description = "Streaming performance-stats capture: arm/stop a recording, read the live + saved time-series for graphing"),
|
||||||
|
(name = "logs", description = "Host log stream: the newest in-memory log entries, cursor-paged for live following"),
|
||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
struct ApiDoc;
|
struct ApiDoc;
|
||||||
@@ -1730,6 +1733,39 @@ async fn stats_recording_delete(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Query for `GET /logs` — a cursor poll.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct LogsQuery {
|
||||||
|
after: Option<u64>,
|
||||||
|
limit: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Host logs
|
||||||
|
///
|
||||||
|
/// The host's recent log entries — an in-memory ring of the newest few thousand, captured at
|
||||||
|
/// DEBUG and above regardless of `RUST_LOG`. Follow live by polling with `after` set to the last
|
||||||
|
/// response's `next` cursor; a `dropped: true` means entries were evicted between polls (the ring
|
||||||
|
/// wrapped). Bearer-only: logs can reference client identities and host paths, so this is part of
|
||||||
|
/// the loopback-only admin surface, never the LAN-readable mTLS one.
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/logs",
|
||||||
|
tag = "logs",
|
||||||
|
operation_id = "logsGet",
|
||||||
|
params(
|
||||||
|
("after" = Option<u64>, Query, description = "Return entries with seq greater than this (omitted/0 = oldest retained)"),
|
||||||
|
("limit" = Option<u32>, Query, description = "Max entries per response (default and cap 1000)"),
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = OK, description = "Entries after the cursor, oldest first", body = LogPage),
|
||||||
|
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn logs_get(Query(q): Query<LogsQuery>) -> Json<LogPage> {
|
||||||
|
let limit = q.limit.map_or(crate::log_capture::MAX_PAGE, |l| l as usize);
|
||||||
|
Json(crate::log_capture::ring().since(q.after.unwrap_or(0), limit))
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------
|
||||||
// Tests
|
// Tests
|
||||||
// ---------------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------------
|
||||||
@@ -2509,4 +2545,34 @@ mod tests {
|
|||||||
.await;
|
.await;
|
||||||
assert_eq!(s, StatusCode::BAD_REQUEST);
|
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn logs_endpoint_pages_by_cursor() {
|
||||||
|
let app = test_app(test_state(), None);
|
||||||
|
|
||||||
|
// The ring is a process-wide singleton — start from wherever its cursor currently is.
|
||||||
|
let (s, json) = send(&app, get_req("/api/v1/logs")).await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
let start = json["next"].as_u64().unwrap();
|
||||||
|
|
||||||
|
let ring = crate::log_capture::ring();
|
||||||
|
ring.push(&tracing::Level::WARN, "mgmt::tests", "first".into());
|
||||||
|
ring.push(&tracing::Level::INFO, "mgmt::tests", "second".into());
|
||||||
|
|
||||||
|
let (s, json) = send(&app, get_req(&format!("/api/v1/logs?after={start}"))).await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
let entries = json["entries"].as_array().unwrap();
|
||||||
|
assert_eq!(entries.len(), 2);
|
||||||
|
assert_eq!(entries[0]["msg"], "first");
|
||||||
|
assert_eq!(entries[0]["level"], "WARN");
|
||||||
|
assert_eq!(json["next"].as_u64().unwrap(), start + 2);
|
||||||
|
assert_eq!(json["dropped"], false);
|
||||||
|
|
||||||
|
// Nothing newer → empty page, cursor unchanged.
|
||||||
|
let after = start + 2;
|
||||||
|
let (s, json) = send(&app, get_req(&format!("/api/v1/logs?after={after}"))).await;
|
||||||
|
assert_eq!(s, StatusCode::OK);
|
||||||
|
assert!(json["entries"].as_array().unwrap().is_empty());
|
||||||
|
assert_eq!(json["next"].as_u64().unwrap(), after);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,23 +130,38 @@ fn host_log_path() -> PathBuf {
|
|||||||
|
|
||||||
/// Initialise tracing to the service log file (the SCM gives the service no console/stderr). Falls
|
/// Initialise tracing to the service log file (the SCM gives the service no console/stderr). Falls
|
||||||
/// back to stderr if the file can't be opened. Called from `main()` only for `service run`.
|
/// back to stderr if the file can't be opened. Called from `main()` only for `service run`.
|
||||||
|
/// Also tees into the in-memory log ring (`log_capture`), like the stderr path in `main()` — the
|
||||||
|
/// supervisor serves no mgmt API itself, but the layer is harmless and keeps both inits uniform.
|
||||||
pub fn init_file_logging(filter: tracing_subscriber::EnvFilter) {
|
pub fn init_file_logging(filter: tracing_subscriber::EnvFilter) {
|
||||||
|
use tracing_subscriber::layer::SubscriberExt;
|
||||||
|
use tracing_subscriber::util::SubscriberInitExt;
|
||||||
|
use tracing_subscriber::Layer;
|
||||||
|
let ring =
|
||||||
|
crate::log_capture::RingLayer.with_filter(tracing_subscriber::filter::LevelFilter::DEBUG);
|
||||||
match std::fs::OpenOptions::new()
|
match std::fs::OpenOptions::new()
|
||||||
.create(true)
|
.create(true)
|
||||||
.append(true)
|
.append(true)
|
||||||
.open(service_log_path())
|
.open(service_log_path())
|
||||||
{
|
{
|
||||||
Ok(file) => {
|
Ok(file) => {
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::registry()
|
||||||
.with_env_filter(filter)
|
.with(ring)
|
||||||
|
.with(
|
||||||
|
tracing_subscriber::fmt::layer()
|
||||||
.with_ansi(false)
|
.with_ansi(false)
|
||||||
.with_writer(move || file.try_clone().expect("clone service log handle"))
|
.with_writer(move || file.try_clone().expect("clone service log handle"))
|
||||||
|
.with_filter(filter),
|
||||||
|
)
|
||||||
.init();
|
.init();
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::registry()
|
||||||
.with_env_filter(filter)
|
.with(ring)
|
||||||
|
.with(
|
||||||
|
tracing_subscriber::fmt::layer()
|
||||||
.with_writer(std::io::stderr)
|
.with_writer(std::io::stderr)
|
||||||
|
.with_filter(filter),
|
||||||
|
)
|
||||||
.init();
|
.init();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,6 +121,15 @@
|
|||||||
"login_signing_in": "Anmeldung läuft…",
|
"login_signing_in": "Anmeldung läuft…",
|
||||||
"action_logout": "Abmelden",
|
"action_logout": "Abmelden",
|
||||||
"nav_stats": "Leistung",
|
"nav_stats": "Leistung",
|
||||||
|
"nav_logs": "Logs",
|
||||||
|
"logs_title": "Logs",
|
||||||
|
"logs_subtitle": "Der aktuelle Log-Stream des Hosts — live verfolgen, nach Level filtern, durchsuchen.",
|
||||||
|
"logs_follow": "Folgen",
|
||||||
|
"logs_pause": "Pause",
|
||||||
|
"logs_clear": "Leeren",
|
||||||
|
"logs_search": "Logs durchsuchen…",
|
||||||
|
"logs_empty": "Keine passenden Logeinträge — Filter anpassen oder auf Host-Aktivität warten.",
|
||||||
|
"logs_dropped": "Einige Einträge wurden verdrängt, bevor sie abgeholt werden konnten",
|
||||||
"stats_title": "Leistung",
|
"stats_title": "Leistung",
|
||||||
"stats_subtitle": "Zeichne die Pipeline-Zeiten einer Sitzung auf und betrachte sie als Diagramme.",
|
"stats_subtitle": "Zeichne die Pipeline-Zeiten einer Sitzung auf und betrachte sie als Diagramme.",
|
||||||
"stats_capture_title": "Aufzeichnung",
|
"stats_capture_title": "Aufzeichnung",
|
||||||
|
|||||||
@@ -121,6 +121,15 @@
|
|||||||
"login_signing_in": "Signing in…",
|
"login_signing_in": "Signing in…",
|
||||||
"action_logout": "Sign out",
|
"action_logout": "Sign out",
|
||||||
"nav_stats": "Performance",
|
"nav_stats": "Performance",
|
||||||
|
"nav_logs": "Logs",
|
||||||
|
"logs_title": "Logs",
|
||||||
|
"logs_subtitle": "The host's recent log stream — follow live, filter by level, search.",
|
||||||
|
"logs_follow": "Follow",
|
||||||
|
"logs_pause": "Pause",
|
||||||
|
"logs_clear": "Clear",
|
||||||
|
"logs_search": "Search logs…",
|
||||||
|
"logs_empty": "No log entries match — adjust the filter or wait for host activity.",
|
||||||
|
"logs_dropped": "Some entries were evicted before they could be fetched",
|
||||||
"stats_title": "Performance",
|
"stats_title": "Performance",
|
||||||
"stats_subtitle": "Record a session's pipeline timings and review them as graphs.",
|
"stats_subtitle": "Record a session's pipeline timings and review them as graphs.",
|
||||||
"stats_capture_title": "Capture",
|
"stats_capture_title": "Capture",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
GaugeCircle,
|
GaugeCircle,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
LibraryBig,
|
LibraryBig,
|
||||||
|
ScrollText,
|
||||||
Server,
|
Server,
|
||||||
Settings,
|
Settings,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -22,6 +23,7 @@ const NAV = [
|
|||||||
{ to: "/host", icon: Server, label: () => m.nav_host() },
|
{ to: "/host", icon: Server, label: () => m.nav_host() },
|
||||||
{ to: "/library", icon: LibraryBig, label: () => m.nav_library() },
|
{ to: "/library", icon: LibraryBig, label: () => m.nav_library() },
|
||||||
{ to: "/stats", icon: GaugeCircle, label: () => m.nav_stats() },
|
{ to: "/stats", icon: GaugeCircle, label: () => m.nav_stats() },
|
||||||
|
{ to: "/logs", icon: ScrollText, label: () => m.nav_logs() },
|
||||||
{ to: "/pairing", icon: KeyRound, label: () => m.nav_pairing() },
|
{ to: "/pairing", icon: KeyRound, label: () => m.nav_pairing() },
|
||||||
{ to: "/settings", icon: Settings, label: () => m.nav_settings() },
|
{ to: "/settings", icon: Settings, label: () => m.nav_settings() },
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { SectionLogs } from "@/sections/Logs";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/logs")({ component: SectionLogs });
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { Pause, Play, Trash2 } from "lucide-react";
|
||||||
|
import { type FC, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useLogsGet } from "@/api/gen/logs/logs";
|
||||||
|
import type { LogEntry } from "@/api/gen/model/logEntry";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { m } from "@/paraglide/messages";
|
||||||
|
|
||||||
|
const LEVELS = ["DEBUG", "INFO", "WARN", "ERROR"] as const;
|
||||||
|
type MinLevel = (typeof LEVELS)[number];
|
||||||
|
const RANK: Record<string, number> = {
|
||||||
|
TRACE: 0,
|
||||||
|
DEBUG: 1,
|
||||||
|
INFO: 2,
|
||||||
|
WARN: 3,
|
||||||
|
ERROR: 4,
|
||||||
|
};
|
||||||
|
const LEVEL_CLASS: Record<string, string> = {
|
||||||
|
ERROR: "text-red-400",
|
||||||
|
WARN: "text-amber-400",
|
||||||
|
INFO: "text-sky-300",
|
||||||
|
DEBUG: "text-muted-foreground",
|
||||||
|
TRACE: "text-muted-foreground",
|
||||||
|
};
|
||||||
|
|
||||||
|
const KEEP = 5_000; // accumulated entries (client memory bound)
|
||||||
|
const SHOW = 1_000; // rendered rows (DOM bound)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Container: cursor-paged log polling. A non-empty page advances the cursor — a new query key,
|
||||||
|
* so the next page fetches immediately and a backlog drains fast; an empty page leaves the key
|
||||||
|
* unchanged and `refetchInterval` paces the idle poll. Pausing (follow off) stops the interval.
|
||||||
|
*/
|
||||||
|
export const LogsSection: FC = () => {
|
||||||
|
const [cursor, setCursor] = useState(0);
|
||||||
|
const [entries, setEntries] = useState<LogEntry[]>([]);
|
||||||
|
const [follow, setFollow] = useState(true);
|
||||||
|
const [dropped, setDropped] = useState(false);
|
||||||
|
|
||||||
|
const query = useLogsGet(
|
||||||
|
{ after: cursor > 0 ? cursor : undefined },
|
||||||
|
{ query: { refetchInterval: follow ? 2_000 : false } },
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = query.data;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data || data.entries.length === 0) return;
|
||||||
|
setEntries((prev) => [...prev, ...data.entries].slice(-KEEP));
|
||||||
|
setDropped((d) => d || data.dropped);
|
||||||
|
setCursor(data.next);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LogsCard
|
||||||
|
entries={entries}
|
||||||
|
follow={follow}
|
||||||
|
onFollow={setFollow}
|
||||||
|
onClear={() => {
|
||||||
|
setEntries([]);
|
||||||
|
setDropped(false);
|
||||||
|
}}
|
||||||
|
dropped={dropped}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Pure log viewer: level/min filter + text search (local UI state), follow + clear controls. */
|
||||||
|
export const LogsCard: FC<{
|
||||||
|
entries: LogEntry[];
|
||||||
|
follow: boolean;
|
||||||
|
onFollow: (follow: boolean) => void;
|
||||||
|
onClear: () => void;
|
||||||
|
dropped: boolean;
|
||||||
|
}> = ({ entries, follow, onFollow, onClear, dropped }) => {
|
||||||
|
const [minLevel, setMinLevel] = useState<MinLevel>("DEBUG");
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const min = RANK[minLevel] ?? 0;
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
return entries
|
||||||
|
.filter(
|
||||||
|
(e) =>
|
||||||
|
(RANK[e.level] ?? 0) >= min &&
|
||||||
|
(q === "" ||
|
||||||
|
e.msg.toLowerCase().includes(q) ||
|
||||||
|
e.target.toLowerCase().includes(q)),
|
||||||
|
)
|
||||||
|
.slice(-SHOW);
|
||||||
|
}, [entries, minLevel, search]);
|
||||||
|
|
||||||
|
// Keep the tail in view while following (entries are append-only, so length is a good signal).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!follow) return;
|
||||||
|
const el = listRef.current;
|
||||||
|
if (el) el.scrollTop = el.scrollHeight;
|
||||||
|
}, [follow, filtered.length]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col gap-3 pt-6">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{LEVELS.map((l) => (
|
||||||
|
<Button
|
||||||
|
key={l}
|
||||||
|
size="sm"
|
||||||
|
variant={minLevel === l ? "secondary" : "ghost"}
|
||||||
|
onClick={() => setMinLevel(l)}
|
||||||
|
>
|
||||||
|
{l}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder={m.logs_search()}
|
||||||
|
className="max-w-xs"
|
||||||
|
/>
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
{dropped && (
|
||||||
|
<Badge variant="secondary">{m.logs_dropped()}</Badge>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={follow ? "secondary" : "outline"}
|
||||||
|
onClick={() => onFollow(!follow)}
|
||||||
|
>
|
||||||
|
{follow ? (
|
||||||
|
<Pause className="mr-1 size-3.5" />
|
||||||
|
) : (
|
||||||
|
<Play className="mr-1 size-3.5" />
|
||||||
|
)}
|
||||||
|
{follow ? m.logs_pause() : m.logs_follow()}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={onClear}>
|
||||||
|
<Trash2 className="mr-1 size-3.5" />
|
||||||
|
{m.logs_clear()}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={listRef}
|
||||||
|
className="max-h-[65vh] overflow-auto rounded-md border bg-card/40 p-2 font-mono text-xs leading-5"
|
||||||
|
>
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<p className="p-2 text-muted-foreground">{m.logs_empty()}</p>
|
||||||
|
) : (
|
||||||
|
filtered.map((e) => (
|
||||||
|
<div key={e.seq} className="whitespace-pre-wrap break-words">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{fmtTime(e.ts_ms)}{" "}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"font-medium",
|
||||||
|
LEVEL_CLASS[e.level] ?? "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{e.level.padEnd(5)}{" "}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">{e.target} </span>
|
||||||
|
<span>{e.msg}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtTime = (ts: number): string => {
|
||||||
|
const d = new Date(ts);
|
||||||
|
const p = (n: number, w = 2) => String(n).padStart(w, "0");
|
||||||
|
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}.${p(d.getMilliseconds(), 3)}`;
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
|
import { useLocale } from "@/lib/i18n";
|
||||||
|
import { LogsSection } from "./LogsCard";
|
||||||
|
import { LogsView } from "./view";
|
||||||
|
|
||||||
|
// Logs = one self-contained viewer card owning its polling; this container only binds the layout.
|
||||||
|
export const SectionLogs: FC = () => {
|
||||||
|
useLocale();
|
||||||
|
return <LogsView viewer={<LogsSection />} />;
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import Section from "@unom/ui/section";
|
||||||
|
import type { FC, ReactNode } from "react";
|
||||||
|
import { m } from "@/paraglide/messages";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Logs page LAYOUT — the live page (`index.tsx`) and the Storybook story fill the single
|
||||||
|
* `viewer` slot, so the arrangement can never drift between them (same pattern as StatsView).
|
||||||
|
*/
|
||||||
|
export const LogsView: FC<{ viewer: ReactNode }> = ({ viewer }) => (
|
||||||
|
<Section maxWidth={false}>
|
||||||
|
<div className="flex flex-col gap-card">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="text-2xl font-semibold">{m.logs_title()}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{m.logs_subtitle()}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{viewer}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import type { LogEntry } from "@/api/gen/model/logEntry";
|
||||||
|
import { LogsCard } from "@/sections/Logs/LogsCard";
|
||||||
|
import { LogsView } from "@/sections/Logs/view";
|
||||||
|
|
||||||
|
const noop = () => {};
|
||||||
|
|
||||||
|
// A deterministic slice of host logs covering every level, incl. the gamepad-driver health lines
|
||||||
|
// the page exists to surface — no live host needed.
|
||||||
|
const BASE = 1_750_000_000_000;
|
||||||
|
const entry = (
|
||||||
|
seq: number,
|
||||||
|
level: string,
|
||||||
|
target: string,
|
||||||
|
msg: string,
|
||||||
|
): LogEntry => ({ seq, ts_ms: BASE + seq * 750, level, target, msg });
|
||||||
|
|
||||||
|
const fixtureEntries: LogEntry[] = [
|
||||||
|
entry(1, "INFO", "punktfunk_host", "punktfunk-host 0.4.2 (punktfunk_core ABI v2)"),
|
||||||
|
entry(2, "INFO", "punktfunk_host::mgmt", "management API listening over HTTPS addr=0.0.0.0:47990"),
|
||||||
|
entry(3, "DEBUG", "punktfunk_host::discovery", "mDNS advertise _punktfunk._udp pair=required"),
|
||||||
|
entry(4, "INFO", "punktfunk_host::punktfunk1", "session start mode=1920x1080@60 codec=hevc"),
|
||||||
|
entry(5, "INFO", "punktfunk_host::inject", "virtual Xbox 360 created (Windows XUSB companion)"),
|
||||||
|
entry(6, "WARN", "punktfunk_host::inject", "gamepad driver not attached to Global\\pfxusb-shm-0 after 3s — is the pf_xusb driver installed? (punktfunk-host.exe driver install --gamepad)"),
|
||||||
|
entry(7, "ERROR", "punktfunk_host::inject", "virtual Xbox 360 creation failed — controller input disabled (is the pf_xusb driver installed?)"),
|
||||||
|
entry(8, "INFO", "punktfunk_host::encode", "NVENC opened 1920x1080 nv12 gop=inf rfi=on"),
|
||||||
|
];
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: "Pages/Logs",
|
||||||
|
component: LogsView,
|
||||||
|
parameters: { layout: "padded" },
|
||||||
|
} satisfies Meta<typeof LogsView>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
// The real page layout (LogsView) with the pure viewer card + fixture entries in its slot.
|
||||||
|
export const Following: Story = {
|
||||||
|
args: {
|
||||||
|
viewer: (
|
||||||
|
<LogsCard
|
||||||
|
entries={fixtureEntries}
|
||||||
|
follow
|
||||||
|
onFollow={noop}
|
||||||
|
onClear={noop}
|
||||||
|
dropped={false}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PausedWithGap: Story = {
|
||||||
|
args: {
|
||||||
|
viewer: (
|
||||||
|
<LogsCard
|
||||||
|
entries={fixtureEntries}
|
||||||
|
follow={false}
|
||||||
|
onFollow={noop}
|
||||||
|
onClear={noop}
|
||||||
|
dropped
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user