Send client logs to the host — the log-escape hatch for locked-down platforms #247

Merged
enricobuehler merged 1 commits from worktree-client-log-upload into main 2026-08-15 10:57:19 +00:00
21 changed files with 1360 additions and 12 deletions
+284 -1
View File
@@ -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.",
+39
View File
@@ -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,
+24 -8
View File
@@ -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
+67
View File
@@ -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<S: tracing::Subscriber> tracing_subscriber::Layer<S> 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
)
}
+2
View File
@@ -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))]
+1 -1
View File
@@ -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),
+140
View File
@@ -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<String>,
bytes: usize,
dropped: u64,
}
static RING: LazyLock<Mutex<Ring>> = 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<String, crate::library::LibraryError> {
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::<serde_json::Value>(&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('…'));
}
}
+24
View File
@@ -91,6 +91,10 @@ struct ConsoleState {
hosts_gen: u64,
pair: PairPhase,
wake: Option<WakeStatus>,
/// 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<String>,
}
/// 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<WakeStatus> {
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<String> {
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,
@@ -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) => {
+5
View File
@@ -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 => {}
+287
View File
@@ -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: `<config-dir>/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 `<ts>_<fp16>_<name>.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<Self> {
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<String> {
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<String> = 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<ClientLogMeta> {
let mut out: Vec<ClientLogMeta> = 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<Vec<u8>> {
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<String> {
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<ClientLogStore>, 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);
}
}
+1
View File
@@ -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;
+18
View File
@@ -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<crate::stats_recorder::StatsRecorder>,
/// 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<crate::client_logs::ClientLogStore>,
/// 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<AppState>,
token: Option<String>,
@@ -251,12 +257,16 @@ fn app(
port: u16,
native: Option<Arc<crate::native_pairing::NativePairing>>,
stats: Arc<crate::stats_recorder::StatsRecorder>,
// 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<Arc<MgmtState>>, 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))
+7
View File
@@ -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,
@@ -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<Arc<MgmtState>>,
fp: Option<Extension<PeerCertFingerprint>>,
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<Arc<MgmtState>>) -> Json<Vec<ClientLogMeta>> {
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<Arc<MgmtState>>,
Path(id): Path<String>,
) -> 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<Arc<MgmtState>>,
Path(id): Path<String>,
) -> 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}"),
),
}
}
+19
View File
@@ -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> {
crate::stats_recorder::StatsRecorder::new(std::env::temp_dir().join(format!(
@@ -94,6 +103,7 @@ fn test_app(state: Arc<AppState>, 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<AppState>, np: Arc<crate::native_pairing::NativePa
DEFAULT_PORT,
Some(np),
stats,
test_client_logs_dir(),
false,
)
}
@@ -1347,6 +1358,14 @@ fn every_route_is_classified_for_the_plugin_and_cert_lanes() {
("GET", "/api/v1/compositors", true, true),
("GET", "/api/v1/events", true, false),
("GET", "/api/v1/logs", true, false),
// ---- client log bundles: the UPLOAD is the cert lane's single write — write-only,
// size/quota-capped ("send logs to host" from a Deck in Gaming Mode / tvOS). Reading
// bundles back is operator business (they can contain whatever the client logged), so
// list/fetch/delete stay bearer-only in both lanes.
("POST", "/api/v1/client-logs", false, true),
("GET", "/api/v1/client-logs", false, false),
("GET", "/api/v1/client-logs/{id}", false, false),
("DELETE", "/api/v1/client-logs/{id}", false, false),
// ---- paired-device rosters: readable by a plugin, never by another paired client, and
// removal is pairing administration in both lanes.
("GET", "/api/v1/clients", true, false),
+2 -1
View File
@@ -200,7 +200,8 @@ fn capture_id(unix_ms: u64, width: u32, height: u32) -> 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]
+11
View File
@@ -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",
+11
View File
@@ -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",
+178
View File
@@ -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 (
<ClientLogsCard
bundles={bundles}
onDownload={onDownload}
onDelete={onDelete}
isDeleting={del.isPending}
/>
);
};
/** Uploaded client log bundles, newest first, with Download / Delete row actions. */
export const ClientLogsCard: FC<{
bundles: Loadable<ClientLogMeta[]>;
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 (
<Card>
<CardHeader>
<div className="space-y-1">
<h2 className="text-lg font-medium">{m.client_logs_title()}</h2>
<p className="text-sm text-muted-foreground">
{m.client_logs_subtitle()}
</p>
</div>
</CardHeader>
<QueryState
isLoading={bundles.isLoading}
error={bundles.error}
refetch={bundles.refetch}
>
<CardContent flush>
<Table>
<TableHeader>
<TableRow>
<TableHead>{m.client_logs_col_received()}</TableHead>
<TableHead>{m.client_logs_col_device()}</TableHead>
<TableHead className="text-right">
{m.client_logs_col_size()}
</TableHead>
<TableHead className="w-24" />
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r) => (
<TableRow key={r.id}>
<TableCell className="whitespace-nowrap font-medium">
{fmtTimestamp(r.received_ms)}
</TableCell>
<TableCell>
<span>{r.device_name}</span>
<span className="ml-2 font-mono text-xs text-muted-foreground">
{r.fingerprint_prefix}
</span>
</TableCell>
<TableCell className="text-right tabular-nums">
{fmtSize(r.size_bytes)}
</TableCell>
<TableCell>
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="icon"
aria-label={m.client_logs_download()}
title={m.client_logs_download()}
onClick={() => onDownload(r.id)}
>
<Download className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
aria-label={m.client_logs_delete()}
title={m.client_logs_delete()}
disabled={isDeleting}
onClick={() => onDelete(r.id)}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</QueryState>
</Card>
);
};
+13 -1
View File
@@ -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 <LogsView viewer={<LogsSection />} />;
return (
<LogsView
viewer={
<>
<LogsSection />
<ClientLogsSection />
</>
}
/>
);
};