diff --git a/api/openapi.json b/api/openapi.json index bea7fa95..45571cfb 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -10,9 +10,242 @@ "name": "MIT OR Apache-2.0", "identifier": "MIT OR Apache-2.0" }, - "version": "0.28.1" + "version": "0.29.0" }, "paths": { + "/api/v1/client-logs": { + "get": { + "tags": [ + "logs" + ], + "summary": "List uploaded client log bundles", + "description": "Every stored bundle's metadata, newest first.", + "operationId": "clientLogsList", + "responses": { + "200": { + "description": "Stored bundles, newest first", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ClientLogMeta" + } + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + }, + "post": { + "tags": [ + "logs" + ], + "summary": "Upload a client log bundle", + "description": "A PAIRED DEVICE posts its recent client log as plain text, authenticated by its streaming\ncertificate (the same mTLS identity it pairs and streams with) — no bearer token. Bundles are\ncapped at 1 MiB and only the newest few per device are kept. The operator downloads them from\nthe console's Logs page. This is deliberately write-only for devices: uploading grants no read.", + "operationId": "clientLogsUpload", + "requestBody": { + "description": "The client's log text", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Bundle stored", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClientLogUploaded" + } + } + } + }, + "400": { + "description": "No paired-device certificate on the connection", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "403": { + "description": "The device's access has expired (per-client access)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "413": { + "description": "Bundle exceeds the size cap", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "422": { + "description": "Empty body", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Could not store the bundle", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/client-logs/{id}": { + "get": { + "tags": [ + "logs" + ], + "summary": "Download a client log bundle", + "description": "The bundle body as plain text, for saving or attaching to a report.", + "operationId": "clientLogsGet", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The bundle id (its filename stem)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The bundle body", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "No bundle with that id", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "The bundle file is unreadable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "logs" + ], + "summary": "Delete a client log bundle", + "description": "Removes the bundle `id` from disk. `404` if there is no such bundle.", + "operationId": "clientLogsDelete", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The bundle id (its filename stem)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Bundle deleted" + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "No bundle with that id", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Could not delete the bundle", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, "/api/v1/clients": { "get": { "tags": [ @@ -4669,6 +4902,56 @@ } } }, + "ClientLogMeta": { + "type": "object", + "description": "One stored bundle, as the console lists it.", + "required": [ + "id", + "device_name", + "fingerprint_prefix", + "received_ms", + "size_bytes" + ], + "properties": { + "device_name": { + "type": "string", + "description": "The paired device's name at upload time (sanitized for the filesystem)." + }, + "fingerprint_prefix": { + "type": "string", + "description": "First 16 hex chars of the device's pairing fingerprint — enough to correlate with the\npaired-devices roster without repeating the full identity in every filename." + }, + "id": { + "type": "string", + "description": "The bundle id (its filename stem) — pass to the fetch/delete endpoints." + }, + "received_ms": { + "type": "integer", + "format": "int64", + "description": "Upload time (unix ms, from the file's mtime).", + "minimum": 0 + }, + "size_bytes": { + "type": "integer", + "format": "int64", + "description": "Bundle size in bytes.", + "minimum": 0 + } + } + }, + "ClientLogUploaded": { + "type": "object", + "description": "Response to a successful upload.", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string", + "description": "The stored bundle's id." + } + } + }, "ClientRef": { "type": "object", "description": "The connecting/disconnecting client's identity.", diff --git a/clients/session/src/console.rs b/clients/session/src/console.rs index 8f8c377e..2e20b301 100644 --- a/clients/session/src/console.rs +++ b/clients/session/src/console.rs @@ -457,6 +457,45 @@ impl ServiceState { trust::parse_hex32(&fp_hex), ); } + ConsoleCmd::SendLogs { + addr, + mgmt, + fp_hex, + host_name, + } => { + // Blocking network (5 s connect / 10 s global, the library agent's budgets) — + // a worker thread keeps the service loop's host refresh alive meanwhile. The + // result lands as a shared-model notice; the shell toasts it on its next sync. + let identity = self.identity.clone(); + let pin = trust::parse_hex32(&fp_hex); + let console = self.console.clone(); + std::thread::Builder::new() + .name("punktfunk-sendlogs".into()) + .spawn(move || { + let header = format!( + "punktfunk-session {} ({} {}) — client log bundle", + env!("CARGO_PKG_VERSION"), + std::env::consts::OS, + std::env::consts::ARCH, + ); + match pf_client_core::logring::send_to_host( + &addr, mgmt, &identity, pin, &header, + ) { + Ok(id) => { + tracing::info!(host = %host_name, id, "client logs uploaded"); + console.set_notice(format!( + "Logs sent to {host_name} — download them from its web \ + console's Logs page" + )); + } + Err(e) => { + tracing::warn!(host = %host_name, error = %e, "client log upload failed"); + console.set_notice(format!("Couldn't send logs — {e}")); + } + } + }) + .ok(); + } ConsoleCmd::Pair { addr, port, diff --git a/clients/session/src/main.rs b/clients/session/src/main.rs index e06bc5af..6b368c57 100644 --- a/clients/session/src/main.rs +++ b/clients/session/src/main.rs @@ -23,6 +23,7 @@ #[cfg(all(any(target_os = "linux", windows), feature = "ui"))] mod console; +mod ring_layer; /// The session control socket: a line-per-connection unix socket other same-user /// processes use to poke the RUNNING stream — today two verbs, `guide` and `qam`, which @@ -618,14 +619,29 @@ mod session_main { } pub fn run() -> u8 { - // Logs to STDERR — stdout is the machine interface (ready/stats/error lines). - tracing_subscriber::fmt() - .with_writer(std::io::stderr) - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| "info".into()), - ) - .init(); + // Logs to STDERR — stdout is the machine interface (ready/stats/error lines) — plus + // the in-process ring (`pf_client_core::logring`, DEBUG+ regardless of RUST_LOG) that + // "Send logs to host" uploads. The env filter scopes the STDERR layer only: the ring + // exists precisely for the diagnostics nobody enabled before the bug happened. + { + use tracing_subscriber::layer::SubscriberExt; + use tracing_subscriber::util::SubscriberInitExt; + use tracing_subscriber::Layer; + tracing_subscriber::registry() + .with( + tracing_subscriber::fmt::layer() + .with_writer(std::io::stderr) + .with_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info".into()), + ), + ) + .with( + crate::ring_layer::RingLayer + .with_filter(tracing_subscriber::filter::LevelFilter::DEBUG), + ) + .init(); + } // Before ANY Vulkan call — and that includes the two probe flags below, which is the // whole reason this sits at the top of `run` instead of beside the session setup it diff --git a/clients/session/src/ring_layer.rs b/clients/session/src/ring_layer.rs new file mode 100644 index 00000000..dae3e7c5 --- /dev/null +++ b/clients/session/src/ring_layer.rs @@ -0,0 +1,67 @@ +//! Thin `tracing` layer feeding `pf_client_core::logring` — the source for the console's +//! "Send logs to host" action. Captures at DEBUG+ regardless of `RUST_LOG` (its own filter is +//! applied at install), mirroring the host's `log_capture::RingLayer`: the whole point is that +//! a field report carries the diagnostics nobody thought to enable beforehand. + +use std::fmt::Write as _; +use tracing::field::{Field, Visit}; +use tracing_subscriber::layer::Context; + +pub(crate) struct RingLayer; + +impl tracing_subscriber::Layer for RingLayer { + fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) { + struct V(String); + impl Visit for V { + fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { + if field.name() == "message" { + // The message leads; fields follow. Events put it first anyway, so + // this is belt-and-braces against odd macro orderings. + let rest = std::mem::take(&mut self.0); + let _ = write!(self.0, "{value:?}"); + self.0.push_str(&rest); + } else { + let _ = write!(self.0, " {}={:?}", field.name(), value); + } + } + } + let mut v = V(String::new()); + event.record(&mut v); + let meta = event.metadata(); + pf_client_core::logring::note(format!( + "{} {:5} {} {}", + wallclock(), + meta.level().as_str(), + meta.target(), + v.0 + )); + } +} + +/// `2026-08-15T12:03:47.123Z` from the system clock — wall time, so a bundle correlates with +/// the host log it lands next to. No chrono dep; same civil-date derivation the host uses. +fn wallclock() -> String { + let ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + let secs = (ms / 1000) as i64; + let days = secs.div_euclid(86_400); + let tod = secs.rem_euclid(86_400); + // Howard Hinnant's civil_from_days. + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 }.div_euclid(146_097); + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let mo = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if mo <= 2 { y + 1 } else { y }; + let (h, mi, s) = (tod / 3600, (tod % 3600) / 60, tod % 60); + format!( + "{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}.{:03}Z", + ms % 1000 + ) +} diff --git a/crates/pf-client-core/src/lib.rs b/crates/pf-client-core/src/lib.rs index 40b438df..5561ba83 100644 --- a/crates/pf-client-core/src/lib.rs +++ b/crates/pf-client-core/src/lib.rs @@ -34,6 +34,8 @@ pub mod gamepad; pub mod keymap; #[cfg(any(target_os = "linux", windows))] pub mod library; +#[cfg(any(target_os = "linux", windows))] +pub mod logring; // The `punktfunk://` grammar (design/client-deep-links.md §2): one parser/emitter for the // shells, the session and the CLI, held to the Swift/Kotlin ports by a shared vector file. #[cfg(any(target_os = "linux", windows))] diff --git a/crates/pf-client-core/src/library.rs b/crates/pf-client-core/src/library.rs index b5b27163..875d8fb3 100644 --- a/crates/pf-client-core/src/library.rs +++ b/crates/pf-client-core/src/library.rs @@ -298,7 +298,7 @@ pub fn spawn_art_fetch( rx } -fn classify(e: ureq::Error) -> LibraryError { +pub(crate) fn classify(e: ureq::Error) -> LibraryError { match e { ureq::Error::StatusCode(401 | 403) => LibraryError::NotPaired, ureq::Error::StatusCode(code) => LibraryError::Http(code), diff --git a/crates/pf-client-core/src/logring.rs b/crates/pf-client-core/src/logring.rs new file mode 100644 index 00000000..bdd2e0f9 --- /dev/null +++ b/crates/pf-client-core/src/logring.rs @@ -0,0 +1,140 @@ +//! The client's own recent-log ring + the "send logs to host" uploader. +//! +//! Why: on locked-down platforms (a Steam Deck in Gaming Mode, tvOS, webOS) the user cannot get +//! the client's log off the device, so field reports arrive host-log-only and the client half of +//! any stutter/latency story is invisible. The cure is inverted collection: the client keeps its +//! newest few thousand log lines here, and an explicit user action posts them to the PAIRED host +//! (`POST /api/v1/client-logs`, authenticated by the same mTLS identity the stream uses), where +//! the web console lists them next to the host's own logs. +//! +//! The ring is deliberately dependency-free (no `tracing-subscriber` in this crate): each shell +//! installs a thin `Layer` that formats events into [`note`] — see `punktfunk-session`'s +//! `ring_layer`. Bounded by lines AND bytes so a log-storm can't grow memory; the byte budget +//! stays under the host's 1 MiB upload cap so a full ring always uploads whole. + +use std::collections::VecDeque; +use std::sync::{LazyLock, Mutex}; + +/// Newest lines kept (matches the host's own ring depth). +pub const MAX_LINES: usize = 4096; + +/// Byte budget for the ring — under the host's 1 MiB bundle cap with headroom for the header. +pub const MAX_BYTES: usize = 768 * 1024; + +struct Ring { + lines: VecDeque, + bytes: usize, + dropped: u64, +} + +static RING: LazyLock> = LazyLock::new(|| { + Mutex::new(Ring { + lines: VecDeque::new(), + bytes: 0, + dropped: 0, + }) +}); + +/// Append one formatted log line (no trailing newline). Oversized lines are truncated to keep a +/// single event from evicting the whole ring. +pub fn note(mut line: String) { + if line.len() > 2048 { + line.truncate(2048); + line.push('…'); + } + let mut r = RING.lock().unwrap_or_else(|e| e.into_inner()); + r.bytes += line.len(); + r.lines.push_back(line); + while r.lines.len() > MAX_LINES || r.bytes > MAX_BYTES { + if let Some(evicted) = r.lines.pop_front() { + r.bytes -= evicted.len(); + r.dropped += 1; + } else { + break; + } + } +} + +/// The ring rendered as one text bundle, oldest first, prefixed by `header` (the shell's own +/// identity line — binary name, version, platform) and an eviction note when the ring wrapped. +pub fn render(header: &str) -> String { + let r = RING.lock().unwrap_or_else(|e| e.into_inner()); + let mut out = String::with_capacity(r.bytes + header.len() + 64); + out.push_str(header); + out.push('\n'); + if r.dropped > 0 { + out.push_str(&format!( + "… {} older lines evicted from the ring …\n", + r.dropped + )); + } + for line in &r.lines { + out.push_str(line); + out.push('\n'); + } + out +} + +/// Upload the ring to the paired host `addr` and return the stored bundle id. Same transport + +/// trust as the library fetch: TLS client auth with the device identity, host pinned by +/// fingerprint. Errors reuse the library's classification (401/403 ⇒ `NotPaired`, a pin-verifier +/// rejection ⇒ `PinMismatch`), so the shell's existing error strings apply. +pub fn send_to_host( + addr: &str, + mgmt_port: u16, + identity: &(String, String), + pin: Option<[u8; 32]>, + header: &str, +) -> Result { + use crate::library::LibraryError; + let body = render(header); + let agent = crate::library::agent(identity, pin)?; + let url = format!( + "{}/api/v1/client-logs", + crate::library::base_url(addr, mgmt_port) + ); + match agent + .post(&url) + .header("Content-Type", "text/plain; charset=utf-8") + .send(body.as_bytes()) + { + Ok(mut resp) => { + let text = resp + .body_mut() + .read_to_string() + .map_err(|e| LibraryError::Unreachable(format!("read body: {e}")))?; + let id = serde_json::from_str::(&text) + .ok() + .and_then(|v| v.get("id")?.as_str().map(str::to_string)) + .unwrap_or_default(); + Ok(id) + } + Err(e) => Err(crate::library::classify(e)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ring_bounds_and_renders_with_eviction_note() { + // The ring is process-global, so this single test owns the whole lifecycle (parallel + // tests over one global would interleave). + for i in 0..(MAX_LINES + 10) { + note(format!("line {i}")); + } + let text = render("punktfunk-client test"); + assert!(text.starts_with("punktfunk-client test\n")); + assert!(text.contains("older lines evicted")); + assert!( + !text.contains("\nline 0\n"), + "oldest line survived eviction" + ); + assert!(text.ends_with(&format!("line {}\n", MAX_LINES + 9))); + // A pathological line is truncated, not ring-flushing. + note("x".repeat(10_000)); + let text = render("h"); + assert!(text.contains('…')); + } +} diff --git a/crates/pf-console-ui/src/model.rs b/crates/pf-console-ui/src/model.rs index d1d756cd..f916fb1e 100644 --- a/crates/pf-console-ui/src/model.rs +++ b/crates/pf-console-ui/src/model.rs @@ -91,6 +91,10 @@ struct ConsoleState { hosts_gen: u64, pair: PairPhase, wake: Option, + /// A one-shot toast from a service worker (e.g. the log-upload result). The shell + /// takes it on its next sync; unlike [`PairPhase`] there is no modal state to track, + /// so a plain take-once string is the whole protocol. + notice: Option, } /// The shared handle. Service threads write; the shell polls per frame (cheap locks, @@ -131,6 +135,16 @@ impl ConsoleShared { pub(crate) fn wake(&self) -> Option { self.0.lock().unwrap().wake.clone() } + + /// Post a one-shot toast from a service worker. A newer notice replaces an unshown + /// older one — the shell polls per frame, so in practice nothing is ever dropped. + pub fn set_notice(&self, text: String) { + self.0.lock().unwrap().notice = Some(text); + } + + pub(crate) fn take_notice(&self) -> Option { + self.0.lock().unwrap().notice.take() + } } /// Work the shell asks the binary to do. Everything here blocks (network/disk), so it @@ -150,6 +164,16 @@ pub enum ConsoleCmd { pin: String, device_name: String, }, + /// Upload the client's recent log ring to this PAIRED host's management API — the + /// "send logs to host" escape hatch for platforms whose own logs are unreachable + /// (Deck Gaming Mode, tvOS). Same transport + trust as `FetchLibrary`; the result + /// comes back as a shared-model notice toast. + SendLogs { + addr: String, + mgmt: u16, + fp_hex: String, + host_name: String, + }, /// Save a manually entered host (unpaired) and refresh the rows. SaveHost { name: String, diff --git a/crates/pf-console-ui/src/screens/host_options.rs b/crates/pf-console-ui/src/screens/host_options.rs index f8c5d30d..e7d86f0d 100644 --- a/crates/pf-console-ui/src/screens/host_options.rs +++ b/crates/pf-console-ui/src/screens/host_options.rs @@ -25,6 +25,7 @@ use skia_safe::{Canvas, Rect}; #[derive(Clone, Copy, PartialEq, Eq, Debug)] enum Action { Wake, + SendLogs, CopyLink, Edit, Forget, @@ -86,6 +87,14 @@ impl HostOptionsScreen { if self.host.can_wake && !self.host.online { a.push(Action::Wake); } + // "Send logs" needs a paired identity (the upload authenticates with the streaming + // cert) and a reachable host — on anything else the row would only ever toast an + // error. This is the log-escape hatch for platforms whose own filesystem the user + // can't reach (Deck Gaming Mode, tvOS): the bundle lands on the host, listed in + // its web console next to the host's own logs. + if self.host.paired && self.host.online { + a.push(Action::SendLogs); + } a.extend([ Action::CopyLink, Action::Edit, @@ -98,6 +107,7 @@ impl HostOptionsScreen { fn label(&self, a: Action) -> String { match a { Action::Wake => "Wake host".into(), + Action::SendLogs => "Send logs to host".into(), Action::CopyLink => "Copy link".into(), Action::Edit => "Edit\u{2026}".into(), Action::Forget if self.armed => "Forget \u{2014} press again".into(), @@ -168,6 +178,16 @@ impl HostOptionsScreen { }); fx.pop(); } + Action::SendLogs => { + fx.cmds.push(ConsoleCmd::SendLogs { + addr: self.host.addr.clone(), + mgmt: self.host.mgmt_port, + fp_hex: self.host.fp_hex.clone(), + host_name: self.host.name.clone(), + }); + fx.toast = Some(format!("Sending logs to {}\u{2026}", self.host.name)); + fx.pop(); + } Action::CopyLink => { match crate::screens::host_link(&self.host) { Some(url) => { diff --git a/crates/pf-console-ui/src/shell.rs b/crates/pf-console-ui/src/shell.rs index 81af9205..e6c23276 100644 --- a/crates/pf-console-ui/src/shell.rs +++ b/crates/pf-console-ui/src/shell.rs @@ -278,6 +278,11 @@ impl Shell { (self.hosts, self.hosts_gen) = self.console.hosts_snapshot(); } + // Service-worker notices (e.g. the log-upload result) become plain toasts. + if let Some(text) = self.console.take_notice() { + self.show_toast(text); + } + let pair = self.console.pair(); match &pair { PairPhase::Idle => {} diff --git a/crates/punktfunk-host/src/client_logs.rs b/crates/punktfunk-host/src/client_logs.rs new file mode 100644 index 00000000..b26b08ce --- /dev/null +++ b/crates/punktfunk-host/src/client_logs.rs @@ -0,0 +1,287 @@ +//! On-disk store for log bundles PAIRED CLIENTS upload to this host. +//! +//! Why this exists: on locked-down client platforms (a Steam Deck in Gaming Mode, tvOS, webOS) +//! the user has no realistic way to get the client's own log off the device — so every field +//! report from those platforms arrives host-log-only, and the client half of the story +//! (de-jitter, decode rungs, playout underruns) is invisible. "Send logs to host" inverts that: +//! the client POSTs its recent log over the management API with its paired mTLS cert, the bundle +//! lands here, and the web console lists it next to the host's own log export — one place to +//! collect both halves. +//! +//! Deliberately a FILE store, not `log_capture::ring()`: the ring holds the host's newest ~4096 +//! entries, and ingesting a multi-thousand-line client bundle there would evict the host log — +//! destroying the other half of the very report this feature exists to complete. The host log +//! gets one INFO breadcrumb per received bundle instead. +//! +//! Quota: a paired device may upload at will, so the store must be bounded without operator +//! attention — per device (by fingerprint prefix) only the newest [`KEEP_PER_DEVICE`] bundles +//! survive, and a single bundle is capped at [`MAX_BUNDLE_BYTES`] by the endpoint. Paired +//! devices are operator-admitted and enumerable, so the total is bounded too. + +use serde::Serialize; +use std::path::PathBuf; +use utoipa::ToSchema; + +/// Newest bundles kept per device; older ones are pruned on the next upload from that device. +pub const KEEP_PER_DEVICE: usize = 5; + +/// Upload size cap, enforced by the endpoint before the store sees the body. Client rings render +/// to a few hundred KiB; anything past this is not a log bundle. +pub const MAX_BUNDLE_BYTES: usize = 1024 * 1024; + +/// The default store directory: `/client-logs/`, beside `captures/`. +pub fn default_dir() -> PathBuf { + pf_paths::config_dir().join("client-logs") +} + +/// One stored bundle, as the console lists it. +#[derive(Clone, Serialize, ToSchema)] +pub struct ClientLogMeta { + /// The bundle id (its filename stem) — pass to the fetch/delete endpoints. + pub id: String, + /// The paired device's name at upload time (sanitized for the filesystem). + pub device_name: String, + /// First 16 hex chars of the device's pairing fingerprint — enough to correlate with the + /// paired-devices roster without repeating the full identity in every filename. + pub fingerprint_prefix: String, + /// Upload time (unix ms, from the file's mtime). + pub received_ms: u64, + /// Bundle size in bytes. + pub size_bytes: u64, +} + +/// The store: a flat directory of `__.log` files. +pub struct ClientLogStore { + dir: PathBuf, +} + +/// Same id gate as `stats_recorder::valid_id`: the exact charset [`bundle_id`] emits, and the +/// charset excludes `/` and `\`, so `dir.join(id + ".log")` is always a single child of `dir`. +fn valid_id(id: &str) -> bool { + !id.is_empty() + && id != "." + && id != ".." + && id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) +} + +/// Squeeze a paired device's display name into the id charset (never empty — the id must parse +/// back into its three fields, and an all-symbols name would otherwise leave a dangling `_`). +fn sanitize_name(name: &str) -> String { + let cleaned: String = name + .chars() + .filter(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-')) + .take(24) + .collect(); + if cleaned.is_empty() { + "device".into() + } else { + cleaned + } +} + +fn unix_ms_now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +/// `2026-08-15T10-22-33Z_9785592d05ef1234_SteamDeck` — timestamp first so a plain directory sort +/// is newest-last, dashes (not colons) in the time so the stem is a valid Windows filename. +/// Underscores separate the three fields, so name/fp keep to `[A-Za-z0-9.-]`. +fn bundle_id(unix_ms: u64, fp_hex: &str, name: &str) -> String { + let secs = (unix_ms / 1000) as i64; + let days = secs.div_euclid(86_400); + let tod = secs.rem_euclid(86_400); + let (y, mo, d) = crate::stats_recorder::civil_from_days(days); + let (h, mi, s) = (tod / 3600, (tod % 3600) / 60, tod % 60); + let fp16: String = fp_hex + .chars() + .filter(char::is_ascii_alphanumeric) + .take(16) + .collect(); + format!( + "{y:04}-{mo:02}-{d:02}T{h:02}-{mi:02}-{s:02}Z_{fp16}_{}", + sanitize_name(name) + ) +} + +impl ClientLogStore { + /// Open the store, creating `dir` (owner-private, best-effort) if missing. + pub fn new(dir: PathBuf) -> std::sync::Arc { + if let Err(e) = pf_paths::create_private_dir(&dir) { + tracing::warn!(dir = %dir.display(), error = %e, "could not create client-logs dir"); + } + std::sync::Arc::new(ClientLogStore { dir }) + } + + /// Store a bundle from the paired device `fp_hex` named `device_name`; returns the new id. + /// Prunes that device's older bundles past [`KEEP_PER_DEVICE`] (best-effort). + pub fn save(&self, fp_hex: &str, device_name: &str, body: &[u8]) -> std::io::Result { + let id = bundle_id(unix_ms_now(), fp_hex, device_name); + std::fs::write(self.dir.join(format!("{id}.log")), body)?; + // Prune this device's older bundles. The fp16 field is position 2 of the stem, and ids + // sort chronologically because the timestamp leads. + let fp16: String = fp_hex + .chars() + .filter(char::is_ascii_alphanumeric) + .take(16) + .collect(); + let mut mine: Vec = self + .stems() + .into_iter() + .filter(|stem| stem.split('_').nth(1) == Some(fp16.as_str())) + .collect(); + mine.sort(); + if mine.len() > KEEP_PER_DEVICE { + for stale in &mine[..mine.len() - KEEP_PER_DEVICE] { + let _ = std::fs::remove_file(self.dir.join(format!("{stale}.log"))); + } + } + Ok(id) + } + + /// Every stored bundle's metadata, newest first. + pub fn list(&self) -> Vec { + let mut out: Vec = self + .stems() + .into_iter() + .filter_map(|stem| { + let mut parts = stem.splitn(3, '_'); + let _ts = parts.next()?; + let fp = parts.next()?.to_string(); + let name = parts.next()?.to_string(); + let md = std::fs::metadata(self.dir.join(format!("{stem}.log"))).ok()?; + let received_ms = md + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + Some(ClientLogMeta { + id: stem, + device_name: name, + fingerprint_prefix: fp, + received_ms, + size_bytes: md.len(), + }) + }) + .collect(); + out.sort_by(|a, b| b.id.cmp(&a.id)); // timestamp-led stems ⇒ lexicographic = chronological + out + } + + /// The full bundle body for `id`. `NotFound` for unknown AND invalid ids — an id that fails + /// the charset gate must be indistinguishable from an absent one. + pub fn load(&self, id: &str) -> std::io::Result> { + if !valid_id(id) { + return Err(std::io::ErrorKind::NotFound.into()); + } + std::fs::read(self.dir.join(format!("{id}.log"))) + } + + /// Delete the bundle `id` (same invalid-id handling as [`Self::load`]). + pub fn delete(&self, id: &str) -> std::io::Result<()> { + if !valid_id(id) { + return Err(std::io::ErrorKind::NotFound.into()); + } + std::fs::remove_file(self.dir.join(format!("{id}.log"))) + } + + /// Filename stems of every `.log` in the store (unsorted). + fn stems(&self) -> Vec { + let Ok(rd) = std::fs::read_dir(&self.dir) else { + return Vec::new(); + }; + rd.filter_map(|e| { + let name = e.ok()?.file_name(); + let name = name.to_str()?; + let stem = name.strip_suffix(".log")?; + valid_id(stem).then(|| stem.to_string()) + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn store() -> (std::sync::Arc, PathBuf) { + // pid+timestamp alone COLLIDED: the tests run in parallel in one process and both can + // land on the same millisecond — then one test's cleanup deletes the other's live dir. + // A process-wide counter makes each call unique regardless of timing. + static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let dir = std::env::temp_dir().join(format!( + "pf-client-logs-test-{}-{}", + std::process::id(), + N.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + (ClientLogStore::new(dir.clone()), dir) + } + + #[test] + fn save_list_load_delete_roundtrip() { + let (s, dir) = store(); + let id = s + .save("abcdef0123456789ff", "Steam Deck!", b"hello log") + .unwrap(); + assert!(id.contains("abcdef0123456789"), "{id}"); + assert!(id.ends_with("SteamDeck"), "sanitized name: {id}"); + let listed = s.list(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].id, id); + assert_eq!(listed[0].device_name, "SteamDeck"); + assert_eq!(listed[0].size_bytes, 9); + assert_eq!(s.load(&id).unwrap(), b"hello log"); + s.delete(&id).unwrap(); + assert!(s.list().is_empty()); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn traversal_ids_read_as_absent() { + let (s, dir) = store(); + for bad in ["../cert", "..", ".", "a/b", "a\\b", ""] { + assert_eq!( + s.load(bad).unwrap_err().kind(), + std::io::ErrorKind::NotFound, + "{bad:?}" + ); + assert_eq!( + s.delete(bad).unwrap_err().kind(), + std::io::ErrorKind::NotFound, + "{bad:?}" + ); + } + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn per_device_quota_prunes_oldest() { + let (s, dir) = store(); + // Same device, KEEP_PER_DEVICE + 2 uploads. Ids share a second-resolution timestamp in a + // fast test, so disambiguate chronology by writing distinct bodies and checking survival + // by count + the other device's bundle being untouched. + let other = s.save("ffff00000000000000", "other", b"keep me").unwrap(); + let mut ids = Vec::new(); + for i in 0..(KEEP_PER_DEVICE + 2) { + // Distinct mtime-independent ids: the timestamp field has 1 s resolution, so append + // uniqueness through the name (the id embeds it). + ids.push( + s.save("abcdef0123456789", &format!("dev{i}"), b"x") + .unwrap(), + ); + } + let listed = s.list(); + let mine = listed + .iter() + .filter(|m| m.fingerprint_prefix == "abcdef0123456789") + .count(); + assert_eq!(mine, KEEP_PER_DEVICE); + assert!(listed.iter().any(|m| m.id == other), "other device pruned"); + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index b678f84b..eb365171 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -76,6 +76,7 @@ mod interactive; // What this host launched, for whom, and when — so a client that re-dials and re-sends its // `Hello::launch` verbatim neither gets a second copy of its game nor loses sight of the one it has // (design/session-game-lifetime.md). +mod client_logs; mod launchreg; mod library; mod log_capture; diff --git a/crates/punktfunk-host/src/mgmt.rs b/crates/punktfunk-host/src/mgmt.rs index 091140ca..36f71141 100644 --- a/crates/punktfunk-host/src/mgmt.rs +++ b/crates/punktfunk-host/src/mgmt.rs @@ -30,6 +30,7 @@ use utoipa_axum::{router::OpenApiRouter, routes}; use utoipa_scalar::{Scalar, Servable}; mod auth; +mod client_logs; mod clients; mod display; mod events; @@ -181,6 +182,9 @@ pub(crate) struct MgmtState { /// Shared streaming-stats recorder — the same handle the streaming loops emit into, so an /// operator can arm/stop a capture here and review/list/delete saved recordings. stats: Arc, + /// Log bundles paired clients uploaded (`crate::client_logs`) — constructed here (mgmt-only + /// state, nothing streams into it) and listed/served back to the console. + client_logs: Arc, /// Whether this host runs the GameStream/Moonlight-compat planes (`--gamestream`). Surfaced in /// [`HostInfo`] so the web console can hide the Moonlight-only pairing UI on the secure default /// (native-only) host, where a Moonlight PIN can never arrive. @@ -238,12 +242,14 @@ pub async fn run( opts.bind.port(), native, stats, + crate::client_logs::default_dir(), gamestream_enabled, ); serve_https(opts.bind, app, tls).await } /// Compose the full management router (also used directly by the handler tests). +#[allow(clippy::too_many_arguments)] // the composition root wires one state struct; a param per field fn app( state: Arc, token: Option, @@ -251,12 +257,16 @@ fn app( port: u16, native: Option>, stats: Arc, + // Where uploaded client log bundles live — a parameter (not `default_dir()` inline) so the + // handler tests point it at a temp dir instead of the real config dir. + client_logs_dir: std::path::PathBuf, gamestream_enabled: bool, ) -> Router { let shared = Arc::new(MgmtState { app: state, native, stats, + client_logs: crate::client_logs::ClientLogStore::new(client_logs_dir), gamestream_enabled, token, plugin_token, @@ -365,6 +375,14 @@ fn api_router_parts() -> (Router>, utoipa::openapi::OpenApi) { stats::stats_recording_delete )) .routes(routes!(stats::logs_get)) + .routes(routes!( + client_logs::client_logs_upload, + client_logs::client_logs_list + )) + .routes(routes!( + client_logs::client_logs_get, + client_logs::client_logs_delete + )) .routes(routes!(events::stream_events)) .routes(routes!(hooks::get_hooks, hooks::set_hooks)) .routes(routes!(plugins::list_plugins)) diff --git a/crates/punktfunk-host/src/mgmt/auth.rs b/crates/punktfunk-host/src/mgmt/auth.rs index e05fca76..5e73521d 100644 --- a/crates/punktfunk-host/src/mgmt/auth.rs +++ b/crates/punktfunk-host/src/mgmt/auth.rs @@ -289,6 +289,13 @@ fn path_matches(pattern: &str, path: &str) -> bool { /// a streaming client can't administer the host (unpair others, arm/read the PIN, stop sessions, /// edit the library). `/health` is handled separately (always open). pub(crate) fn cert_may_access(method: &Method, path: &str) -> bool { + // The ONE write on this lane: a paired device uploading its own log bundle for the operator + // ("send logs to host" — the only way logs escape a Deck in Gaming Mode or a tvOS box). + // Deliberately write-only: the device gets an id back and can read NOTHING — not the bundle + // list, not even its own upload. Size- and quota-capped in the handler/store. + if method == Method::POST && path == "/api/v1/client-logs" { + return true; + } method == Method::GET && (matches!( path, diff --git a/crates/punktfunk-host/src/mgmt/client_logs.rs b/crates/punktfunk-host/src/mgmt/client_logs.rs new file mode 100644 index 00000000..c0eb0629 --- /dev/null +++ b/crates/punktfunk-host/src/mgmt/client_logs.rs @@ -0,0 +1,207 @@ +//! Client log bundles: the upload endpoint paired devices POST to, and the admin list/fetch/ +//! delete surface the web console reads. See `crate::client_logs` for why this is a file store. +//! +//! Lane split (see `auth`): the UPLOAD is the one write a paired streaming cert may perform — +//! it is write-only (a device can never read anything back, not even its own bundle), size-capped, +//! and quota-bounded per device. Listing/fetching/deleting are operator business: bundles can +//! contain whatever the client logged (addresses, host names), so reading them stays on the +//! loopback-only bearer lane with the host's own logs. + +use super::shared::*; +use crate::client_logs::{ClientLogMeta, MAX_BUNDLE_BYTES}; +use crate::gamestream::tls::PeerCertFingerprint; +use axum::body::Bytes; +use axum::Extension; + +/// Response to a successful upload. +#[derive(Serialize, ToSchema)] +pub(crate) struct ClientLogUploaded { + /// The stored bundle's id. + pub id: String, +} + +/// Upload a client log bundle +/// +/// A PAIRED DEVICE posts its recent client log as plain text, authenticated by its streaming +/// certificate (the same mTLS identity it pairs and streams with) — no bearer token. Bundles are +/// capped at 1 MiB and only the newest few per device are kept. The operator downloads them from +/// the console's Logs page. This is deliberately write-only for devices: uploading grants no read. +#[utoipa::path( + post, + path = "/client-logs", + tag = "logs", + operation_id = "clientLogsUpload", + request_body(content = String, content_type = "text/plain", description = "The client's log text"), + responses( + (status = CREATED, description = "Bundle stored", body = ClientLogUploaded), + (status = BAD_REQUEST, description = "No paired-device certificate on the connection", body = ApiError), + (status = FORBIDDEN, description = "The device's access has expired (per-client access)", body = ApiError), + (status = PAYLOAD_TOO_LARGE, description = "Bundle exceeds the size cap", body = ApiError), + (status = UNPROCESSABLE_ENTITY, description = "Empty body", body = ApiError), + (status = INTERNAL_SERVER_ERROR, description = "Could not store the bundle", body = ApiError), + ) +)] +pub(crate) async fn client_logs_upload( + State(st): State>, + fp: Option>, + body: Bytes, +) -> Response { + // The auth middleware admits this route for paired certs AND (like everything) the admin + // bearer token — but an upload without a device identity has no owner to file it under, so + // the bearer path is a caller error, not a second way in. + let Some(Extension(PeerCertFingerprint(Some(fp)))) = fp else { + return api_error( + StatusCode::BAD_REQUEST, + "client log upload requires a paired device certificate", + ); + }; + if body.is_empty() { + return api_error(StatusCode::UNPROCESSABLE_ENTITY, "empty log bundle"); + } + if body.len() > MAX_BUNDLE_BYTES { + return api_error( + StatusCode::PAYLOAD_TOO_LARGE, + "log bundle exceeds the 1 MiB cap — send the tail", + ); + } + // Per-client access (design/per-client-access.md): the auth gate's `is_paired` is + // EXPIRY-BLIND by design — right for the read-only status GETs (an expired guest still + // appears in rosters), wrong for this lane's one WRITE. `effective` is the authorization + // verb: `None` = unpaired or expired ⇒ a lapsed guest can't keep writing bundles to the + // operator's disk. No specific grant BIT is required — uploading one's own logs is not an + // input capability, and a view-only guest mid-session is exactly who a debug bundle is + // wanted from. + let now_unix = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + if st + .native + .as_ref() + .and_then(|n| n.effective(&fp, now_unix)) + .is_none() + { + return api_error( + StatusCode::FORBIDDEN, + "this device's access has expired — ask the host's operator to approve it again", + ); + } + // Resolve the device's display name from the paired roster (the auth gate already proved + // membership; a race with an unpair between the gate and here just falls back to the prefix). + let device_name = st + .native + .as_ref() + .and_then(|n| { + n.list() + .into_iter() + .find(|c| c.fingerprint.eq_ignore_ascii_case(&fp)) + .map(|c| c.name) + }) + .unwrap_or_else(|| fp.chars().take(16).collect()); + match st.client_logs.save(&fp, &device_name, &body) { + Ok(id) => { + tracing::info!( + device = %device_name, + id = %id, + bytes = body.len(), + "client log bundle received — listed on the console's Logs page" + ); + (StatusCode::CREATED, Json(ClientLogUploaded { id })).into_response() + } + Err(e) => api_error( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("could not store the bundle: {e}"), + ), + } +} + +/// List uploaded client log bundles +/// +/// Every stored bundle's metadata, newest first. +#[utoipa::path( + get, + path = "/client-logs", + tag = "logs", + operation_id = "clientLogsList", + responses( + (status = OK, description = "Stored bundles, newest first", body = [ClientLogMeta]), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + ) +)] +pub(crate) async fn client_logs_list(State(st): State>) -> Json> { + Json(st.client_logs.list()) +} + +/// Download a client log bundle +/// +/// The bundle body as plain text, for saving or attaching to a report. +#[utoipa::path( + get, + path = "/client-logs/{id}", + tag = "logs", + operation_id = "clientLogsGet", + params(("id" = String, Path, description = "The bundle id (its filename stem)")), + responses( + (status = OK, description = "The bundle body", body = String, content_type = "text/plain"), + (status = NOT_FOUND, description = "No bundle with that id", body = ApiError), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + (status = INTERNAL_SERVER_ERROR, description = "The bundle file is unreadable", body = ApiError), + ) +)] +pub(crate) async fn client_logs_get( + State(st): State>, + Path(id): Path, +) -> Response { + match st.client_logs.load(&id) { + Ok(body) => ( + [( + axum::http::header::CONTENT_TYPE, + "text/plain; charset=utf-8", + )], + body, + ) + .into_response(), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + api_error(StatusCode::NOT_FOUND, "no bundle with that id") + } + Err(e) => api_error( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("could not read the bundle: {e}"), + ), + } +} + +/// Delete a client log bundle +/// +/// Removes the bundle `id` from disk. `404` if there is no such bundle. +#[utoipa::path( + delete, + path = "/client-logs/{id}", + tag = "logs", + operation_id = "clientLogsDelete", + params(("id" = String, Path, description = "The bundle id (its filename stem)")), + responses( + (status = NO_CONTENT, description = "Bundle deleted"), + (status = NOT_FOUND, description = "No bundle with that id", body = ApiError), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + (status = INTERNAL_SERVER_ERROR, description = "Could not delete the bundle", body = ApiError), + ) +)] +pub(crate) async fn client_logs_delete( + State(st): State>, + Path(id): Path, +) -> Response { + match st.client_logs.delete(&id) { + Ok(()) => { + tracing::info!(id, "management API: client log bundle deleted"); + StatusCode::NO_CONTENT.into_response() + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + api_error(StatusCode::NOT_FOUND, "no bundle with that id") + } + Err(e) => api_error( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("could not delete the bundle: {e}"), + ), + } +} diff --git a/crates/punktfunk-host/src/mgmt/tests.rs b/crates/punktfunk-host/src/mgmt/tests.rs index c7ab5e92..82cc659d 100644 --- a/crates/punktfunk-host/src/mgmt/tests.rs +++ b/crates/punktfunk-host/src/mgmt/tests.rs @@ -51,6 +51,15 @@ use std::net::{IpAddr, Ipv4Addr}; use std::sync::atomic::Ordering; use tower::ServiceExt; +/// A throwaway client-logs dir (same shape as [`test_stats`] — never the real config dir). +fn test_client_logs_dir() -> std::path::PathBuf { + std::env::temp_dir().join(format!( + "pf-mgmt-clientlogs-{}-{:p}", + std::process::id(), + &0u8 as *const u8 + )) +} + /// A throwaway stats recorder rooted in a unique temp dir (never touches the real config dir). fn test_stats() -> Arc { crate::stats_recorder::StatsRecorder::new(std::env::temp_dir().join(format!( @@ -94,6 +103,7 @@ fn test_app(state: Arc, token: Option<&str>) -> Router { DEFAULT_PORT, None, stats, + test_client_logs_dir(), // GameStream-compat planes off (the secure default the native-only tests model). false, ) @@ -110,6 +120,7 @@ fn test_app_native(state: Arc, np: Arc String { } /// Civil (Y, M, D) from a count of days since the Unix epoch (Howard Hinnant's `civil_from_days`). -fn civil_from_days(z: i64) -> (i64, u32, u32) { +/// `pub(crate)`: `client_logs::bundle_id` builds its timestamp stem the same way. +pub(crate) fn civil_from_days(z: i64) -> (i64, u32, u32) { let z = z + 719_468; let era = if z >= 0 { z } else { z - 146_096 }.div_euclid(146_097); let doe = z - era * 146_097; // [0, 146096] diff --git a/web/messages/de.json b/web/messages/de.json index edc8bf79..87c1db3d 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -427,6 +427,17 @@ "logs_copy": "Logs in die Zwischenablage kopieren", "logs_copied": "Logs in die Zwischenablage kopiert", "logs_share_failed": "Logs konnten nicht geteilt werden", + "client_logs_title": "Client-Logs", + "client_logs_subtitle": "Log-Pakete, die deine Geräte mit „Logs an Host senden“ geschickt haben — von Plattformen, deren eigene Dateien unerreichbar sind, etwa einem Steam Deck im Gaming Mode oder einem Apple TV.", + "client_logs_col_received": "Empfangen", + "client_logs_col_device": "Gerät", + "client_logs_col_size": "Größe", + "client_logs_download": "Paket herunterladen", + "client_logs_download_failed": "Paket konnte nicht heruntergeladen werden", + "client_logs_delete": "Löschen", + "client_logs_delete_confirm": "Dieses Paket löschen?", + "client_logs_delete_body": "Das Gerät kann jederzeit ein neues senden.", + "client_logs_delete_failed": "Paket konnte nicht gelöscht werden", "stats_title": "Leistung", "stats_subtitle": "Zeichne die Pipeline-Zeiten einer Sitzung auf und betrachte sie als Diagramme.", "stats_capture_title": "Aufzeichnung", diff --git a/web/messages/en.json b/web/messages/en.json index 26666691..5f58d94d 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -427,6 +427,17 @@ "logs_copy": "Copy logs to clipboard", "logs_copied": "Logs copied to clipboard", "logs_share_failed": "Couldn't share the logs", + "client_logs_title": "Client logs", + "client_logs_subtitle": "Log bundles your devices sent with “Send logs to host” — from platforms whose own files are out of reach, like a Steam Deck in Gaming Mode or an Apple TV.", + "client_logs_col_received": "Received", + "client_logs_col_device": "Device", + "client_logs_col_size": "Size", + "client_logs_download": "Download bundle", + "client_logs_download_failed": "Couldn't download the bundle", + "client_logs_delete": "Delete", + "client_logs_delete_confirm": "Delete this bundle?", + "client_logs_delete_body": "The device can send a fresh one at any time.", + "client_logs_delete_failed": "Couldn't delete the bundle", "stats_title": "Performance", "stats_subtitle": "Record a session's pipeline timings and review them as graphs.", "stats_capture_title": "Capture", diff --git a/web/src/sections/Logs/ClientLogsCard.tsx b/web/src/sections/Logs/ClientLogsCard.tsx new file mode 100644 index 00000000..73519fd1 --- /dev/null +++ b/web/src/sections/Logs/ClientLogsCard.tsx @@ -0,0 +1,178 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { toast } from "@unom/ui/toast"; +import { Download, Trash2 } from "lucide-react"; +import type { FC } from "react"; +import type { ClientLogMeta } from "@/api/gen/model/clientLogMeta"; +import { + clientLogsGet, + getClientLogsListQueryKey, + useClientLogsDelete, + useClientLogsList, +} from "@/api/gen/logs/logs"; +import { useDialogs } from "@/components/dialogs"; +import { QueryState } from "@/components/query-state"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { apiErrorMessage } from "@/lib/errors"; +import type { Loadable } from "@/lib/query"; +import { m } from "@/paraglide/messages"; +import { fmtTimestamp } from "../Stats/helpers"; + +/** `123.4 KB` — bundles are ≤1 MiB, so two units cover the whole range. */ +const fmtSize = (bytes: number): string => + bytes >= 1024 * 1024 + ? `${(bytes / (1024 * 1024)).toFixed(1)} MB` + : `${(bytes / 1024).toFixed(1)} KB`; + +/** + * Container: log bundles paired clients uploaded via "Send logs to host" — the log-escape hatch + * for platforms whose own files the user can't reach (a Deck in Gaming Mode, tvOS). Owns the + * list query, the text download, and delete. + */ +export const ClientLogsSection: FC = () => { + const qc = useQueryClient(); + const { confirm } = useDialogs(); + const bundles = useClientLogsList(); + const del = useClientLogsDelete(); + + const onDelete = async (id: string) => { + const ok = await confirm({ + title: m.client_logs_delete_confirm(), + description: m.client_logs_delete_body(), + confirmLabel: m.client_logs_delete(), + destructive: true, + }); + if (!ok) return; + del.mutate( + { id }, + { + onSuccess: () => + qc.invalidateQueries({ queryKey: getClientLogsListQueryKey() }), + onError: (e) => + toast.error(apiErrorMessage(e) ?? m.client_logs_delete_failed()), + }, + ); + }; + + // Plain-text bundle → blob download, same shape as the recordings JSON export. + const onDownload = async (id: string) => { + try { + const text = await clientLogsGet(id); + const blob = new Blob([text], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${id}.log`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } catch (e) { + toast.error(apiErrorMessage(e) ?? m.client_logs_download_failed()); + } + }; + + return ( + + ); +}; + +/** Uploaded client log bundles, newest first, with Download / Delete row actions. */ +export const ClientLogsCard: FC<{ + bundles: Loadable; + onDownload: (id: string) => void; + onDelete: (id: string) => void; + isDeleting: boolean; +}> = ({ bundles, onDownload, onDelete, isDeleting }) => { + const rows = bundles.data ?? []; + // No bundles is the ordinary state (nothing was ever sent) — an empty card would just be + // noise on every visit, so the whole card only appears once something arrived. Errors and + // loading still render: a broken list must not look like "nothing was sent". + if (!bundles.isLoading && !bundles.error && rows.length === 0) return null; + return ( + + +
+

{m.client_logs_title()}

+

+ {m.client_logs_subtitle()} +

+
+
+ + + + + + {m.client_logs_col_received()} + {m.client_logs_col_device()} + + {m.client_logs_col_size()} + + + + + + {rows.map((r) => ( + + + {fmtTimestamp(r.received_ms)} + + + {r.device_name} + + {r.fingerprint_prefix} + + + + {fmtSize(r.size_bytes)} + + +
+ + +
+
+
+ ))} +
+
+
+
+
+ ); +}; diff --git a/web/src/sections/Logs/index.tsx b/web/src/sections/Logs/index.tsx index 6c0f5370..1a9deb6a 100644 --- a/web/src/sections/Logs/index.tsx +++ b/web/src/sections/Logs/index.tsx @@ -1,10 +1,22 @@ import type { FC } from "react"; import { useLocale } from "@/lib/i18n"; +import { ClientLogsSection } from "./ClientLogsCard"; import { LogsSection } from "./LogsCard"; import { LogsView } from "./view"; // Logs = one self-contained viewer card owning its polling; this container only binds the layout. +// Client-uploaded bundles ("Send logs to host") render beneath the live host log — same page a +// reporter already exports the host log from, so both halves of a report live in one place. export const SectionLogs: FC = () => { useLocale(); - return } />; + return ( + + + + + } + /> + ); };