The host knew about every one of these faults and had no way to say so #253

Merged
enricobuehler merged 1 commits from worktree-console-diagnostics into main 2026-08-15 15:56:22 +00:00
27 changed files with 3217 additions and 40 deletions
+210
View File
@@ -401,6 +401,70 @@
}
}
},
"/api/v1/diagnostics": {
"get": {
"tags": [
"diagnostics"
],
"summary": "Host health checks",
"description": "Every verdict this host computes about its own health — group membership the managed takeover\nneeds, the input device nodes virtual controllers are built on, competing streaming servers —\nwith the impact and a copy-pasteable remedy for each.\n\nCached: the probes run once at startup and on demand via `POST /diagnostics/refresh`, so this is\ncheap to poll. Checks whose status is `ok` and `inapplicable` are included — a troubleshooting\npage needs to show what is working and to answer \"why isn't this check relevant here?\".\n\n`summary`, `impact` and `remedy.text` are always present in English. A console that recognizes\nthe check's `id` replaces them with a localized string interpolated from `params`; one that does\nnot renders the wire text as-is, which is what keeps a console paired with a newer host readable.",
"operationId": "getDiagnostics",
"responses": {
"200": {
"description": "The current verdicts, worst-first",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DiagnosticsReport"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/diagnostics/refresh": {
"post": {
"tags": [
"diagnostics"
],
"summary": "Re-run the health checks",
"description": "Runs every probe again and returns the refreshed verdicts. Most checks describe state that only\nchanges when an operator changes it (a group membership, an installed udev rule), so this exists\nfor exactly the moment after they have done so — a \"did that fix it?\" button, not a poll.",
"operationId": "refreshDiagnostics",
"responses": {
"200": {
"description": "The refreshed verdicts, worst-first",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DiagnosticsReport"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/display/layout": {
"put": {
"tags": [
@@ -4902,6 +4966,25 @@
}
}
},
"CheckSource": {
"type": "string",
"description": "Where a verdict came from. `Event` is reserved for the live feeds (transitions push instead of\nwaiting for a refresh); v1 produces only `Startup` and `Refresh`.",
"enum": [
"startup",
"event",
"refresh"
]
},
"CheckStatus": {
"type": "string",
"description": "What a probe found. `Inapplicable` is deliberately distinct from `Ok`: \"this box will never do\nthe thing\" and \"the thing works here\" are different answers, and the troubleshooting page shows\nthem differently.",
"enum": [
"ok",
"warn",
"fail",
"inapplicable"
]
},
"ClientLogMeta": {
"type": "object",
"description": "One stored bundle, as the console lists it.",
@@ -5227,6 +5310,29 @@
}
}
},
"DiagnosticsReport": {
"type": "object",
"description": "The `GET /diagnostics` body.",
"required": [
"ran_at_unix",
"checks"
],
"properties": {
"checks": {
"type": "array",
"items": {
"$ref": "#/components/schemas/HostCheck"
},
"description": "Every registered check, worst-first. Includes `ok` and `inapplicable` rows — the console\ndecides what to hide, because \"what's working\" is the reassurance the dashboard omits."
},
"ran_at_unix": {
"type": "integer",
"format": "int64",
"description": "When the probes last ran (unix seconds).",
"minimum": 0
}
}
},
"DisconnectReason": {
"type": "string",
"description": "Why a client went away. `Quit` is a deliberate user \"stop\" (the typed close code);\n`Timeout` is a transport idle timeout (the client vanished); `Error` is everything else.",
@@ -6389,6 +6495,72 @@
}
}
},
"HostCheck": {
"type": "object",
"description": "One health verdict. This IS the wire shape.",
"required": [
"id",
"status",
"severity",
"summary",
"impact",
"params",
"source"
],
"properties": {
"id": {
"type": "string",
"description": "Stable snake_case machine code — the console's i18n key (see [`ids`])."
},
"impact": {
"type": "string",
"description": "What actually breaks, in the operator's terms. Empty only for `ok`/`inapplicable` rows."
},
"params": {
"type": "object",
"description": "Interpolation values for the console's localized strings (`{user}`, `{group}`, …). The\nconsole needs these because it cannot re-derive them: only the host can see the username.",
"additionalProperties": {
"type": "string"
},
"propertyNames": {
"type": "string"
}
},
"remedy": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/Remedy"
}
]
},
"severity": {
"$ref": "#/components/schemas/Severity",
"description": "What a non-ok status means. Meaningless when `status` is `ok`/`inapplicable`; carried anyway\nso a check never changes shape as it flips."
},
"since_unix": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "First non-ok observation in this host run. Per-run bookkeeping, not a time series — there is\nno history here by design.",
"minimum": 0
},
"source": {
"$ref": "#/components/schemas/CheckSource"
},
"status": {
"$ref": "#/components/schemas/CheckStatus"
},
"summary": {
"type": "string",
"description": "One line, English. The console replaces this with a localized message when it knows `id`."
}
}
},
"HostEvent": {
"allOf": [
{
@@ -7641,6 +7813,31 @@
}
}
},
"Remedy": {
"type": "object",
"description": "What the operator should do about it. Always copy-paste — the host runs unprivileged and the\nconsole must never trigger privileged mutation. The `punktfunk` group in particular is\ndeliberately opt-in: writing the vhci `attach` node materialises arbitrary emulated USB devices\n(security review 2026-08-05, M-4), so joining it stays a deliberate act with the caveat attached.",
"required": [
"text",
"relogin_required"
],
"properties": {
"command": {
"type": [
"string",
"null"
],
"description": "A single pasteable shell command, when one fixes it outright."
},
"relogin_required": {
"type": "boolean",
"description": "True when the fix only takes effect after logging out and back in — a `systemd --user`\nmanager keeps the supplementary group set it started with. This distinction is the\ndifference between \"I already added myself!\" and a working virtual pad."
},
"text": {
"type": "string",
"description": "Plain-language instruction. English fallback — the console overrides it by check id."
}
}
},
"RuntimeRequest": {
"type": "object",
"required": [
@@ -7961,6 +8158,15 @@
}
}
},
"Severity": {
"type": "string",
"description": "How much a non-ok status matters. Orthogonal to [`CheckStatus`] on purpose: a check can be\n`warn` about something `critical` (degraded, not dead) and the console sorts by both.",
"enum": [
"info",
"warning",
"critical"
]
},
"SourceInput": {
"type": "object",
"required": [
@@ -8647,6 +8853,10 @@
"name": "host",
"description": "Host identity, capabilities, and liveness"
},
{
"name": "diagnostics",
"description": "Host health checks: what is wrong, what it breaks, and how to fix it (admin lane only)"
},
{
"name": "gpu",
"description": "GPU inventory and selection: list the host's GPUs, choose automatic or a preferred GPU, see the one in use"
@@ -565,7 +565,9 @@ pub fn usbip_preferred() -> bool {
}
/// The `vhci_hcd.0` (or legacy `vhci_hcd`) platform sysfs directory, if present.
fn vhci_base() -> Option<PathBuf> {
/// `pub(crate)` so the diagnostics probe ([`crate::vhci_probe`]) asks the same question the attach
/// path asks, rather than growing a second copy of these paths that can drift from it.
pub(crate) fn vhci_base() -> Option<PathBuf> {
for p in [
"/sys/devices/platform/vhci_hcd.0",
"/sys/devices/platform/vhci_hcd",
+131
View File
@@ -307,6 +307,137 @@ pub fn pen_supported() -> bool {
false
}
/// What an open probe of the input device nodes found — [`uinput_probe`].
///
/// [`pen_supported`] asks the same question and throws the answer away: it returns a bare `bool`,
/// so "the module was never installed" and "you are not in the `input` group" look identical, and
/// the two need completely different remedies. This keeps the errno so the host's diagnostics can
/// tell an operator which one they have. A plain verdict enum on purpose — this crate must never
/// learn about the host's wire types.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum UinputVerdict {
/// Every node opened — virtual gamepads and the pen can be created.
Ok,
/// `EACCES`/`EPERM`: the node is there but this process may not open it. Either the user is not
/// in the `input` group, or the udev rule granting that group access was never installed.
PermissionDenied { path: &'static str },
/// `ENOENT` and friends: the node does not exist at all — the module or the rule is missing.
Missing { path: &'static str },
/// Some other errno; carried verbatim rather than guessed at.
Error { path: &'static str, message: String },
/// No uinput/uhid injection on this platform.
Inapplicable,
}
/// The device nodes every virtual input device needs, in the order they are worth reporting:
/// `/dev/uinput` kills the pen and the evdev gamepads, `/dev/uhid` kills the DualSense/Switch Pro
/// backends that need a real HID transport.
#[cfg(target_os = "linux")]
const INPUT_NODES: &[(&std::ffi::CStr, &str)] =
&[(c"/dev/uinput", "/dev/uinput"), (c"/dev/uhid", "/dev/uhid")];
/// Probe `/dev/uinput` and `/dev/uhid` the way the backends will, **keeping the errno**. Cheap (two
/// `open()`s), so the diagnostics refresh can re-run it on demand.
#[cfg(target_os = "linux")]
pub fn uinput_probe() -> UinputVerdict {
for &(c_path, path) in INPUT_NODES {
// SAFETY: 'static NUL-terminated path literal; `open` returns a fresh fd (or -1) and
// retains nothing.
let fd = unsafe {
libc::open(
c_path.as_ptr(),
libc::O_RDWR | libc::O_NONBLOCK | libc::O_CLOEXEC,
)
};
if fd >= 0 {
// SAFETY: `fd >= 0` is the fd opened above, owned by no one else; closed exactly once.
unsafe { libc::close(fd) };
continue;
}
// Read the errno IMMEDIATELY: any further libc call (including the close above) clobbers it.
let err = std::io::Error::last_os_error();
return match err.raw_os_error() {
Some(libc::EACCES) | Some(libc::EPERM) => UinputVerdict::PermissionDenied { path },
Some(libc::ENOENT) | Some(libc::ENXIO) | Some(libc::ENODEV) => {
UinputVerdict::Missing { path }
}
_ => UinputVerdict::Error {
path,
message: err.to_string(),
},
};
}
UinputVerdict::Ok
}
/// See the Linux variant — uinput/uhid are Linux interfaces; Windows injects through its own driver
/// stack, whose health is a separate check.
#[cfg(not(target_os = "linux"))]
pub fn uinput_probe() -> UinputVerdict {
UinputVerdict::Inapplicable
}
/// What the usbip/vhci attach node looks like from here — [`vhci_probe`].
///
/// Deliberately reports **device facts only**: whether the module is there and whether this process
/// can write the node. It does NOT reason about group membership, because the interesting
/// distinction (in the group on disk vs. in the group in this process) needs the user database, and
/// that is the host's business, not this crate's.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum VhciVerdict {
/// The module is loaded and this process can write `attach` — the virtual Deck can come up.
Ok,
/// No `/sys/devices/platform/vhci_hcd*/status`: the module is not loaded.
ModuleMissing,
/// The node is there and this process cannot write it. Why is the host's question to answer.
NotWritable { path: String },
/// The virtual-Deck-over-usbip route does not apply here.
Inapplicable { why: &'static str },
}
/// Probe the vhci attach node: module present, and writable by *this process*?
///
/// Writability is the ground truth rather than a group-name comparison, because that is exactly what
/// the attach will attempt — `60-punktfunk.rules` `chgrp punktfunk` + `chmod 0660` the node, so a
/// member of the group whose process actually carries it gets `W_OK` and nobody else does.
#[cfg(target_os = "linux")]
pub fn vhci_probe() -> VhciVerdict {
use std::os::unix::ffi::OsStrExt;
if !steam_usbip::usbip_preferred() {
return VhciVerdict::Inapplicable {
why: "the virtual Steam Deck's usbip transport is disabled (PUNKTFUNK_STEAM_USBIP=0)",
};
}
let Some(base) = steam_usbip::vhci_base() else {
return VhciVerdict::ModuleMissing;
};
let attach = base.join("attach");
let Ok(c_path) = std::ffi::CString::new(attach.as_os_str().as_bytes()) else {
return VhciVerdict::NotWritable {
path: attach.display().to_string(),
};
};
// SAFETY: `c_path` is a NUL-terminated path owned by this frame and outlives the call;
// `access` only reads it and retains nothing.
let writable = unsafe { libc::access(c_path.as_ptr(), libc::W_OK) } == 0;
if writable {
VhciVerdict::Ok
} else {
VhciVerdict::NotWritable {
path: attach.display().to_string(),
}
}
}
/// See the Linux variant — usbip/vhci is a Linux kernel facility.
#[cfg(not(target_os = "linux"))]
pub fn vhci_probe() -> VhciVerdict {
VhciVerdict::Inapplicable {
why: "the virtual Steam Deck's usbip transport is Linux-only",
}
}
#[path = "inject/service.rs"]
mod service;
pub use service::InjectorService;
+2 -2
View File
@@ -105,8 +105,8 @@ pub(crate) mod routing;
pub use routing::{
apply_input_env, managed_session_available, preflight_takeover_privilege,
release_autologin_mask, resolve_gamescope_route, restore_managed_session, restore_takeover_now,
restore_takeover_on_startup, start_restore_worker, wants_dedicated_game_session,
GamescopeRoute,
restore_takeover_on_startup, start_restore_worker, takeover_privilege_verdict,
wants_dedicated_game_session, GamescopeRoute, TakeoverInapplicable, TakeoverVerdict,
};
#[cfg(target_os = "linux")]
pub use routing::{
@@ -15,6 +15,7 @@
//! `inject/libei.rs`) — wired and live-validated.
use super::{DisplayOwnership, Mode, VirtualDisplay, VirtualOutput};
use crate::routing::{TakeoverInapplicable, TakeoverVerdict};
use anyhow::{anyhow, bail, Context, Result};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
@@ -2443,25 +2444,15 @@ fn dm_helper(verb: &str) -> std::result::Result<(), DmHelperError> {
/// Steam Deck pad attaches through, and THAT is a credential check against this process, whose
/// supplementary groups were fixed when its `systemd --user` manager started.
pub fn preflight_takeover_privilege() {
if crate::proc::current_uid() == 0 {
return; // root: `systemctl stop <dm>` succeeds outright, the helper is never consulted
}
let Some(dm) = display_manager_unit() else {
return; // no DM drives this box's logins — nothing for the takeover to stop
let TakeoverVerdict::MissingMembership {
user,
dm,
helper,
group,
} = takeover_privilege_verdict()
else {
return; // gated out, or the user is already a member — either way, nothing to say
};
if !managed_session_available() {
return; // no session-plus/SteamOS ⇒ no autologin gaming session ⇒ no takeover
}
let Some(helper) = installed_dm_helper() else {
return; // unpackaged install: no helper, no group, the polkit-rule route applies instead
};
let Some(user) = current_user_name() else {
return; // cannot name the user ⇒ cannot give a usable `usermod` line; stay quiet
};
let group = DM_HELPER_GROUP;
if user_in_group(&user, group) {
return;
}
tracing::warn!(
%user,
%dm,
@@ -2478,6 +2469,53 @@ pub fn preflight_takeover_privilege() {
);
}
/// The gated verdict [`preflight_takeover_privilege`] logs from — and the same value the host's
/// diagnostics registry maps into a console check, so the log line and the console can never
/// disagree about this box.
///
/// The four gates and the user-database question are documented on
/// [`preflight_takeover_privilege`]; this function only moves *where the answer goes*. Each
/// `Inapplicable` reason is kept distinct rather than collapsed to a bool, because the
/// troubleshooting page's job is to answer "why isn't this check relevant here?" — a hidden row
/// cannot.
pub fn takeover_privilege_verdict() -> TakeoverVerdict {
if crate::proc::current_uid() == 0 {
return TakeoverVerdict::Inapplicable {
why: TakeoverInapplicable::Root,
};
}
let Some(dm) = display_manager_unit() else {
return TakeoverVerdict::Inapplicable {
why: TakeoverInapplicable::NoDisplayManager,
};
};
if !managed_session_available() {
return TakeoverVerdict::Inapplicable {
why: TakeoverInapplicable::NoManagedSession,
};
}
let Some(helper) = installed_dm_helper() else {
return TakeoverVerdict::Inapplicable {
why: TakeoverInapplicable::NoPackagedHelper,
};
};
let Some(user) = current_user_name() else {
return TakeoverVerdict::Inapplicable {
why: TakeoverInapplicable::UnknownUser,
};
};
let group = DM_HELPER_GROUP;
if user_in_group(&user, group) {
return TakeoverVerdict::Ok { user, group };
}
TakeoverVerdict::MissingMembership {
user,
dm,
helper,
group,
}
}
/// This process's login name, for a `usermod` line the operator can paste. From `id -un <uid>`
/// rather than `$USER`: a `systemd --user` unit's environment is whatever the manager was started
/// with, and the uid is the thing pkexec will actually resolve.
@@ -396,6 +396,64 @@ pub fn preflight_takeover_privilege() {
#[cfg(not(target_os = "linux"))]
pub fn preflight_takeover_privilege() {}
/// Why the managed takeover's `punktfunk`-group prerequisite does not apply to this box. Each of
/// these alone makes the group irrelevant, so a box in any of these states must not be nagged —
/// but the reason is kept so a troubleshooting UI can say *which* one, instead of hiding the row
/// and leaving "why isn't this listed?" unanswerable.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TakeoverInapplicable {
/// Running as root: the plain system-bus `systemctl` verbs succeed, so the helper — and its
/// group check — is never reached.
Root,
/// No display manager drives this box's logins (getty autologin / an enabled user unit).
NoDisplayManager,
/// No `gamescope-session-plus`/SteamOS session infrastructure ⇒ no autologin gaming session to
/// free ⇒ no takeover.
NoManagedSession,
/// A tarball/source/Nix install: neither the packaged helper nor the group exists, and the
/// hand-written polkit rule from the docs is the route instead.
NoPackagedHelper,
/// The user's login name could not be resolved, so no usable `usermod` line could be produced.
UnknownUser,
/// The managed takeover is a Linux path.
NotLinux,
}
/// The takeover's one un-automatable prerequisite, as data.
///
/// Defined on every platform (like [`GamescopeRoute`]) because the host maps it into a wire check
/// regardless of target — off Linux it is always `Inapplicable { why: NotLinux }`. Membership is
/// the **user database's** answer, matching what `pf-dm-helper` itself asks.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TakeoverVerdict {
/// A gate excluded this box; `why` names which one.
Inapplicable { why: TakeoverInapplicable },
/// The takeover applies here and the user is already a member.
Ok { user: String, group: &'static str },
/// The takeover applies here and the user is **not** a member — every takeover will degrade
/// silently to mirroring the box's own session.
MissingMembership {
user: String,
dm: String,
helper: &'static str,
group: &'static str,
},
}
/// The gated verdict behind [`preflight_takeover_privilege`], for callers that want to render it
/// rather than log it (the host's diagnostics registry). Computing it does not log.
#[cfg(target_os = "linux")]
pub fn takeover_privilege_verdict() -> TakeoverVerdict {
gamescope::takeover_privilege_verdict()
}
#[cfg(not(target_os = "linux"))]
pub fn takeover_privilege_verdict() -> TakeoverVerdict {
TakeoverVerdict::Inapplicable {
why: TakeoverInapplicable::NotLinux,
}
}
/// Give the box its own session back **now**, synchronously, because the host is exiting. Blocks
/// (it shells out to `systemctl`), so call it off the async runtime. Call from the host's shutdown
/// path — a takeover that outlives the host leaves the box with no display manager and nobody left
+536
View File
@@ -0,0 +1,536 @@
//! Host diagnostics: one structured channel for the health verdicts this host already computes.
//!
//! The class of bug this exists for is **the host knows, and the person operating it has no way to
//! find out**. The `.181` incident is the type specimen: `preflight_takeover_privilege()` is a
//! careful, applicability-gated probe that distinguishes user-database membership from the running
//! process's supplementary groups — and it spends all of that care on one WARN log line, which a
//! console-driven update never shows anyone. Every failure class before this had either its own
//! bespoke surface or none at all.
//!
//! So verdicts become data. The registry lives here; the **probes stay in their owning crates**
//! (`pf-inject`, `pf-vdisplay`, `crate::detect`) and export plain verdict enums — nothing in those
//! crates learns about [`HostCheck`], and no reverse dependency is created. This module maps
//! verdict → check and owns every wire string.
//!
//! Two rules the catalog must keep:
//!
//! * **English fallback text is mandatory, not a courtesy.** The web console is a separate package
//! and canary setups pair console N with host N±1, so the console localizes by `id` when it knows
//! the id and renders the wire text when it does not. An id that ships without `summary`/`impact`
//! is unreadable on any console that predates it — [`tests::every_non_ok_check_carries_fallback_text`]
//! enforces this rather than trusting a convention.
//! * **`inapplicable` is a first-class status, not an absent row.** A box that will never attempt a
//! takeover must not be nagged, but the troubleshooting page still has to be able to answer "why
//! isn't this check relevant here?" on demand.
//!
//! Served by `mgmt/diagnostics.rs` on the authenticated admin lane only: usernames, group layout and
//! device-node state must not widen the unauthenticated loopback surface the tray reads.
use serde::Serialize;
use std::collections::BTreeMap;
use std::sync::{OnceLock, RwLock};
use std::time::{SystemTime, UNIX_EPOCH};
use utoipa::ToSchema;
pub(crate) mod catalog;
/// Stable check ids. These are the console's i18n keys, so they are API: renaming one silently
/// drops a translation back to the wire fallback on every console.
pub mod ids {
pub const TAKEOVER_PRIVILEGE: &str = "takeover_privilege";
pub const VIRTUAL_DECK_VHCI: &str = "virtual_deck_vhci";
pub const UINPUT_ACCESS: &str = "uinput_access";
pub const SERVER_CONFLICT: &str = "server_conflict";
}
/// What a probe found. `Inapplicable` is deliberately distinct from `Ok`: "this box will never do
/// the thing" and "the thing works here" are different answers, and the troubleshooting page shows
/// them differently.
#[derive(Serialize, ToSchema, Clone, Copy, Debug, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum CheckStatus {
Ok,
Warn,
Fail,
Inapplicable,
}
impl CheckStatus {
/// Does this status want the operator's attention? `inapplicable` does not — that is the whole
/// point of the status existing.
pub fn needs_attention(self) -> bool {
matches!(self, CheckStatus::Warn | CheckStatus::Fail)
}
}
/// How much a non-ok status matters. Orthogonal to [`CheckStatus`] on purpose: a check can be
/// `warn` about something `critical` (degraded, not dead) and the console sorts by both.
#[derive(Serialize, ToSchema, Clone, Copy, Debug, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
Info,
Warning,
Critical,
}
impl Severity {
fn rank(self) -> u8 {
match self {
Severity::Critical => 0,
Severity::Warning => 1,
Severity::Info => 2,
}
}
}
/// What the operator should do about it. Always copy-paste — the host runs unprivileged and the
/// console must never trigger privileged mutation. The `punktfunk` group in particular is
/// deliberately opt-in: writing the vhci `attach` node materialises arbitrary emulated USB devices
/// (security review 2026-08-05, M-4), so joining it stays a deliberate act with the caveat attached.
#[derive(Serialize, ToSchema, Clone, Debug, PartialEq, Eq)]
pub struct Remedy {
/// Plain-language instruction. English fallback — the console overrides it by check id.
pub text: String,
/// A single pasteable shell command, when one fixes it outright.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
/// True when the fix only takes effect after logging out and back in — a `systemd --user`
/// manager keeps the supplementary group set it started with. This distinction is the
/// difference between "I already added myself!" and a working virtual pad.
pub relogin_required: bool,
}
/// Where a verdict came from. `Event` is reserved for the live feeds (transitions push instead of
/// waiting for a refresh); v1 produces only `Startup` and `Refresh`.
#[derive(Serialize, ToSchema, Clone, Copy, Debug, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum CheckSource {
Startup,
Event,
Refresh,
}
/// One health verdict. This IS the wire shape.
#[derive(Serialize, ToSchema, Clone, Debug)]
pub struct HostCheck {
/// Stable snake_case machine code — the console's i18n key (see [`ids`]).
pub id: String,
pub status: CheckStatus,
/// What a non-ok status means. Meaningless when `status` is `ok`/`inapplicable`; carried anyway
/// so a check never changes shape as it flips.
pub severity: Severity,
/// One line, English. The console replaces this with a localized message when it knows `id`.
pub summary: String,
/// What actually breaks, in the operator's terms. Empty only for `ok`/`inapplicable` rows.
pub impact: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub remedy: Option<Remedy>,
/// Interpolation values for the console's localized strings (`{user}`, `{group}`, …). The
/// console needs these because it cannot re-derive them: only the host can see the username.
pub params: BTreeMap<String, String>,
/// First non-ok observation in this host run. Per-run bookkeeping, not a time series — there is
/// no history here by design.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub since_unix: Option<u64>,
pub source: CheckSource,
}
impl HostCheck {
/// An applicable, healthy result.
pub fn ok(id: &str, summary: impl Into<String>) -> Self {
Self {
id: id.to_string(),
status: CheckStatus::Ok,
severity: Severity::Info,
summary: summary.into(),
impact: String::new(),
remedy: None,
params: BTreeMap::new(),
since_unix: None,
source: CheckSource::Startup,
}
}
/// "This check does not apply to this box" — `why` says which gate excluded it, so the
/// troubleshooting page can answer the question instead of hiding the row.
pub fn inapplicable(id: &str, why: impl Into<String>) -> Self {
Self {
id: id.to_string(),
status: CheckStatus::Inapplicable,
severity: Severity::Info,
summary: why.into(),
impact: String::new(),
remedy: None,
params: BTreeMap::new(),
since_unix: None,
source: CheckSource::Startup,
}
}
/// A problem. `impact` is required here — a warning without a consequence is noise the operator
/// cannot act on.
pub fn problem(
id: &str,
status: CheckStatus,
severity: Severity,
summary: impl Into<String>,
impact: impl Into<String>,
) -> Self {
Self {
id: id.to_string(),
status,
severity,
summary: summary.into(),
impact: impact.into(),
remedy: None,
params: BTreeMap::new(),
since_unix: None,
source: CheckSource::Startup,
}
}
pub fn with_remedy(mut self, remedy: Remedy) -> Self {
self.remedy = Some(remedy);
self
}
pub fn with_param(mut self, key: &str, value: impl Into<String>) -> Self {
self.params.insert(key.to_string(), value.into());
self
}
/// Worst-first ordering key: attention-needing rows before healthy ones, then by severity, then
/// `fail` ahead of `warn`, then by id so the list is stable across refreshes.
fn order_key(&self) -> (u8, u8, u8, &str) {
let bucket = match self.status {
CheckStatus::Fail | CheckStatus::Warn => 0,
CheckStatus::Ok => 1,
CheckStatus::Inapplicable => 2,
};
let status_rank = match self.status {
CheckStatus::Fail => 0,
CheckStatus::Warn => 1,
_ => 2,
};
(bucket, self.severity.rank(), status_rank, &self.id)
}
}
/// The `GET /diagnostics` body.
#[derive(Serialize, ToSchema, Clone, Debug)]
pub struct DiagnosticsReport {
/// When the probes last ran (unix seconds).
pub ran_at_unix: u64,
/// Every registered check, worst-first. Includes `ok` and `inapplicable` rows — the console
/// decides what to hide, because "what's working" is the reassurance the dashboard omits.
pub checks: Vec<HostCheck>,
}
type Probe = Box<dyn Fn() -> HostCheck + Send + Sync>;
/// The registry: probes in, current verdicts out.
#[derive(Default)]
pub struct Diagnostics {
probes: RwLock<Vec<Probe>>,
checks: RwLock<BTreeMap<String, HostCheck>>,
ran_at_unix: RwLock<u64>,
}
impl Diagnostics {
pub fn new() -> Self {
Self::default()
}
/// Add a probe. Called once per check at startup; probes are cheap by contract (an `open()`, a
/// `getgrnam`, a stat) because `POST /diagnostics/refresh` re-runs all of them synchronously.
pub fn register(&self, probe: impl Fn() -> HostCheck + Send + Sync + 'static) {
self.probes.write().unwrap().push(Box::new(probe));
}
/// Run every probe and replace the cached verdicts. `since_unix` is preserved across runs for a
/// check that was already non-ok, so "since" means what it says.
pub fn run_all(&self, source: CheckSource) {
// Probes are run WITHOUT the checks lock held: they touch the filesystem and spawn `id`, and
// a slow NSS lookup must not block a concurrent GET.
let fresh: Vec<HostCheck> = {
let probes = self.probes.read().unwrap();
probes.iter().map(|p| p()).collect()
};
let now = now_unix();
let mut checks = self.checks.write().unwrap();
for mut check in fresh {
let previous_since = prior_since(checks.get(&check.id));
check.source = source;
carry_since(&mut check, previous_since, now);
checks.insert(check.id.clone(), check);
}
*self.ran_at_unix.write().unwrap() = now;
}
/// Feed one verdict from an event source (a `PadGate` transition, a driver watcher). Returns
/// whether the *status* actually changed — the caller emits an SSE event only on a transition,
/// never once per backoff retry.
pub fn set(&self, mut check: HostCheck) -> bool {
let now = now_unix();
let mut checks = self.checks.write().unwrap();
let previous = checks.get(&check.id);
let changed = previous.is_none_or(|p| p.status != check.status);
let previous_since = prior_since(previous);
check.source = CheckSource::Event;
carry_since(&mut check, previous_since, now);
checks.insert(check.id.clone(), check);
changed
}
/// Current verdicts, worst-first.
pub fn report(&self) -> DiagnosticsReport {
let checks = self.checks.read().unwrap();
let mut checks: Vec<HostCheck> = checks.values().cloned().collect();
checks.sort_by(|a, b| a.order_key().cmp(&b.order_key()));
DiagnosticsReport {
ran_at_unix: *self.ran_at_unix.read().unwrap(),
checks,
}
}
/// How many attention-needing checks there are, split by severity — the only diagnostics shape
/// the unauthenticated loopback summary may ever carry (counts, never details).
#[allow(dead_code)] // consumed by the tray's LocalSummary once the live feeds land
pub fn attention_counts(&self) -> (u32, u32) {
let checks = self.checks.read().unwrap();
let mut warning = 0;
let mut critical = 0;
for c in checks.values().filter(|c| c.status.needs_attention()) {
match c.severity {
Severity::Critical => critical += 1,
_ => warning += 1,
}
}
(warning, critical)
}
}
/// The stamp a still-unhealthy check should inherit — `None` once it has recovered, so a later
/// relapse is dated from the relapse rather than from the original.
fn prior_since(previous: Option<&HostCheck>) -> Option<u64> {
previous
.filter(|p| p.status.needs_attention())
.and_then(|p| p.since_unix)
}
/// Keep the original first-observed stamp while a check stays non-ok; clear it when it recovers.
fn carry_since(check: &mut HostCheck, previous_since: Option<u64>, now: u64) {
check.since_unix = check
.status
.needs_attention()
.then(|| previous_since.unwrap_or(now));
}
fn now_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
/// The process-wide registry. A global rather than an `AppState` field because the probes are
/// process-scoped (there is one set of device nodes and one group membership per host), and because
/// it keeps the mgmt handlers free of a state extractor — the same shape `crate::hooks::store()`,
/// `crate::detect::snapshot()` and the log ring already use.
pub fn registry() -> &'static Diagnostics {
static REGISTRY: OnceLock<Diagnostics> = OnceLock::new();
REGISTRY.get_or_init(|| {
let reg = Diagnostics::new();
catalog::register_all(&reg);
reg
})
}
/// Take the first reading. Called once from `native::serve` startup, after the subsystems the
/// probes inspect are up. The catalog itself is registered lazily by [`registry`], so a `GET` that
/// somehow arrives first still describes a known set of checks rather than an empty list.
pub fn preflight() {
let reg = registry();
reg.run_all(CheckSource::Startup);
let report = reg.report();
let attention = report
.checks
.iter()
.filter(|c| c.status.needs_attention())
.count();
tracing::debug!(
checks = report.checks.len(),
attention,
"diagnostics: startup probes complete"
);
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
fn fail(id: &str) -> HostCheck {
HostCheck::problem(
id,
CheckStatus::Fail,
Severity::Critical,
"broken",
"nothing works",
)
.with_remedy(Remedy {
text: "fix it".into(),
command: None,
relogin_required: false,
})
}
#[test]
fn refresh_reruns_every_probe() {
let reg = Diagnostics::new();
let runs = Arc::new(AtomicUsize::new(0));
let counter = runs.clone();
reg.register(move || {
counter.fetch_add(1, Ordering::SeqCst);
HostCheck::ok("probe", "fine")
});
reg.run_all(CheckSource::Startup);
assert_eq!(runs.load(Ordering::SeqCst), 1);
assert_eq!(reg.report().checks[0].source, CheckSource::Startup);
reg.run_all(CheckSource::Refresh);
assert_eq!(runs.load(Ordering::SeqCst), 2, "refresh must re-run probes");
assert_eq!(reg.report().checks[0].source, CheckSource::Refresh);
}
#[test]
fn report_is_worst_first_with_healthy_and_inapplicable_last() {
let reg = Diagnostics::new();
reg.register(|| HostCheck::ok("b_ok", "fine"));
reg.register(|| HostCheck::inapplicable("a_na", "no display manager"));
reg.register(|| {
HostCheck::problem(
"c_warn",
CheckStatus::Warn,
Severity::Warning,
"degraded",
"half works",
)
});
reg.register(|| fail("d_fail"));
reg.run_all(CheckSource::Startup);
let ids: Vec<String> = reg.report().checks.into_iter().map(|c| c.id).collect();
assert_eq!(ids, ["d_fail", "c_warn", "b_ok", "a_na"]);
}
#[test]
fn since_is_stamped_once_and_cleared_on_recovery() {
let reg = Diagnostics::new();
reg.set(fail("flapper"));
let first = reg.report().checks[0].since_unix;
assert!(first.is_some(), "a non-ok check records when it started");
// Still failing: the original stamp survives, so "since" does not reset every probe run.
reg.set(fail("flapper"));
assert_eq!(reg.report().checks[0].since_unix, first);
// Recovered: the stamp goes away rather than lingering as a lie.
reg.set(HostCheck::ok("flapper", "fine"));
assert_eq!(reg.report().checks[0].since_unix, None);
}
#[test]
fn set_reports_only_real_transitions() {
let reg = Diagnostics::new();
assert!(reg.set(fail("pads")), "first observation is a transition");
assert!(
!reg.set(fail("pads")),
"a repeated identical verdict is not a transition — one SSE event per flip, not per retry"
);
assert!(
reg.set(HostCheck::ok("pads", "fine")),
"fail → ok is a transition"
);
}
#[test]
fn attention_counts_ignore_healthy_and_inapplicable_rows() {
let reg = Diagnostics::new();
reg.register(|| fail("bad"));
reg.register(|| {
HostCheck::problem(
"meh",
CheckStatus::Warn,
Severity::Warning,
"degraded",
"half works",
)
});
reg.register(|| HostCheck::ok("good", "fine"));
reg.register(|| HostCheck::inapplicable("na", "not here"));
reg.run_all(CheckSource::Startup);
assert_eq!(reg.attention_counts(), (1, 1));
}
/// The N/N1 console-drift guarantee, as a test rather than a convention: the console renders
/// the wire text whenever it does not recognize an id, so an id that ships without text is
/// unreadable on every console that predates it.
#[test]
fn every_non_ok_check_carries_fallback_text() {
let reg = Diagnostics::new();
catalog::register_all(&reg);
reg.run_all(CheckSource::Startup);
for check in reg.report().checks {
assert!(
!check.summary.trim().is_empty(),
"{}: every check needs a summary — it is the only text an older console has",
check.id
);
if check.status.needs_attention() {
assert!(
!check.impact.trim().is_empty(),
"{}: a non-ok check must say what breaks",
check.id
);
}
if check.status == CheckStatus::Fail {
let remedy = check
.remedy
.as_ref()
.unwrap_or_else(|| panic!("{}: a failing check must carry a remedy", check.id));
assert!(
!remedy.text.trim().is_empty(),
"{}: remedy text must not be empty",
check.id
);
}
}
}
/// Ids are the console's i18n keys, so they are API. Catch a rename in review, not in a
/// bug report about a check that suddenly renders in English.
#[test]
fn catalog_registers_the_documented_ids() {
let reg = Diagnostics::new();
catalog::register_all(&reg);
reg.run_all(CheckSource::Startup);
let ids: Vec<String> = reg.report().checks.into_iter().map(|c| c.id).collect();
for expected in [
ids::TAKEOVER_PRIVILEGE,
ids::VIRTUAL_DECK_VHCI,
ids::UINPUT_ACCESS,
ids::SERVER_CONFLICT,
] {
assert!(
ids.iter().any(|i| i == expected),
"missing check {expected}"
);
}
}
}
@@ -0,0 +1,484 @@
//! The v1 check catalog: verdicts from the owning crates → [`HostCheck`]s.
//!
//! Everything user-visible lives here — the English fallback strings, the impact sentences, and the
//! remedies. The probes themselves stay in `pf-vdisplay` / `pf-inject` / [`crate::detect`] and know
//! nothing about this module; that direction is deliberate, because a reverse dependency would drag
//! the host's wire types into two crates that must keep building for Windows and macOS.
//!
//! Two things here are easy to get subtly wrong and are therefore spelled out in the code:
//!
//! * **User-database membership and this process's groups are different questions.** `usermod -aG`
//! satisfies the first immediately and the second not until the next login, so "not in the group"
//! and "in the group but you haven't logged back in" need different remedies. Collapsing them
//! produces the single most maddening support state there is: *"I already added myself!"*
//! * **`usermod` does not stick on an atomic OS.** On the Universal Blue images the remedy is
//! `ujust add-user-to-input-group`; everywhere else it is `usermod -aG input`.
use super::{ids, CheckStatus, Diagnostics, HostCheck, Remedy, Severity};
use crate::inject::{UinputVerdict, VhciVerdict};
use crate::vdisplay::{TakeoverInapplicable, TakeoverVerdict};
use std::process::Command;
/// The group the packaged privilege helper authorizes on, and that owns the vhci attach nodes.
const PUNKTFUNK_GROUP: &str = "punktfunk";
/// The group the uinput/uhid udev rules grant access to.
const INPUT_GROUP: &str = "input";
/// Register the v1 catalog on a registry. Separate from the global so tests can drive an isolated
/// instance.
pub(crate) fn register_all(reg: &Diagnostics) {
reg.register(takeover_privilege);
reg.register(virtual_deck_vhci);
reg.register(uinput_access);
reg.register(server_conflict);
}
// ---------------------------------------------------------------------------------------------
// takeover_privilege
// ---------------------------------------------------------------------------------------------
fn takeover_privilege() -> HostCheck {
let id = ids::TAKEOVER_PRIVILEGE;
match crate::vdisplay::takeover_privilege_verdict() {
TakeoverVerdict::Inapplicable { why } => {
HostCheck::inapplicable(id, takeover_inapplicable_reason(why))
}
TakeoverVerdict::Ok { user, group } => {
HostCheck::ok(id, format!("User “{user}” is in the “{group}” group."))
.with_param("user", user)
.with_param("group", group)
}
TakeoverVerdict::MissingMembership {
user,
dm,
helper,
group,
} => HostCheck::problem(
id,
CheckStatus::Fail,
Severity::Critical,
format!("User “{user}” is not in the “{group}” group"),
format!(
"Streams that need the managed takeover cannot stop {dm}, so every one of them \
degrades to mirroring this machine's own session instead. With the panel off that \
looks like a black screen on every connect, and nothing else reports it."
),
)
.with_remedy(Remedy {
text: format!(
"Add the user to the “{group}” group, then log out and back in. The same group \
gates the virtual Steam Deck pad's usbip nodes, which can present arbitrary \
emulated USB devices join it only on a machine you trust."
),
command: Some(format!("sudo usermod -aG {group} {user}")),
// The display-manager helper reads the user database, so it is satisfied at once — but
// the pad half is a check against this process, which keeps the group set it started
// with. One re-login covers both, so ask for it.
relogin_required: true,
})
.with_param("user", user)
.with_param("group", group)
.with_param("dm", dm)
.with_param("helper", helper),
}
}
fn takeover_inapplicable_reason(why: TakeoverInapplicable) -> &'static str {
match why {
TakeoverInapplicable::Root => {
"The host runs as root, so it stops the display manager directly and never needs the \
privilege helper."
}
TakeoverInapplicable::NoDisplayManager => {
"No display manager drives this machine's logins, so a takeover has nothing to stop."
}
TakeoverInapplicable::NoManagedSession => {
"This machine has no gamescope session infrastructure, so the managed takeover never \
runs here."
}
TakeoverInapplicable::NoPackagedHelper => {
"This is an unpackaged install: it has no privilege helper and no group, and uses the \
polkit rule from the documentation instead."
}
TakeoverInapplicable::UnknownUser => {
"The host's user name could not be resolved, so no group membership could be checked."
}
TakeoverInapplicable::NotLinux => "The managed gamescope takeover is a Linux feature.",
}
}
// ---------------------------------------------------------------------------------------------
// virtual_deck_vhci
// ---------------------------------------------------------------------------------------------
fn virtual_deck_vhci() -> HostCheck {
let id = ids::VIRTUAL_DECK_VHCI;
let group = PUNKTFUNK_GROUP;
match crate::inject::vhci_probe() {
VhciVerdict::Inapplicable { why } => HostCheck::inapplicable(id, why),
VhciVerdict::Ok => HostCheck::ok(id, "The virtual Steam Deck controller can attach."),
VhciVerdict::ModuleMissing => HostCheck::problem(
id,
CheckStatus::Fail,
Severity::Warning,
"The vhci_hcd kernel module is not loaded",
"The virtual Steam Deck controller cannot attach, so Steam Input never sees it — in \
Game Mode that means nothing can be navigated with a pad.",
)
.with_remedy(Remedy {
text: "Load the vhci_hcd module (the packages install a modules-load rule that does \
this at boot; on an unpackaged install, load it by hand)."
.to_string(),
command: Some("sudo modprobe vhci-hcd".to_string()),
relogin_required: false,
}),
// The node is there and we cannot write it. WHICH of the three causes decides the remedy,
// and only the user database can tell them apart — see this module's docs.
VhciVerdict::NotWritable { path } => not_writable_check(id, group, path),
}
}
fn not_writable_check(id: &str, group: &str, path: String) -> HostCheck {
let user = current_user();
let in_userdb = user.as_deref().and_then(|u| user_in_group_userdb(u, group));
let in_process = process_in_group(group);
let base = |summary: &str, impact: &str| {
HostCheck::problem(id, CheckStatus::Fail, Severity::Warning, summary, impact)
.with_param("group", group)
.with_param("path", path.clone())
};
let pad_impact = "The virtual Steam Deck controller cannot attach, so Steam Input never sees \
it in Game Mode that means nothing can be navigated with a pad.";
match (in_userdb, in_process) {
// In the group on disk, but this process does not carry it: the classic "I already added
// myself!" state. A `systemd --user` manager keeps the group set it started with, so only
// a re-login helps — and nothing in the logs says so today.
(Some(true), Some(false)) => base(
&format!("The group “{group}” was granted but this session predates it"),
pad_impact,
)
.with_remedy(Remedy {
text: "Log out and back in. The membership is already recorded — this session just \
started before it was granted, and a session keeps the group set it began with."
.to_string(),
command: None,
relogin_required: true,
})
.with_param("user", user.unwrap_or_default()),
// Not a member at all.
(Some(false), _) => {
let user = user.unwrap_or_default();
base(
&format!("User “{user}” is not in the “{group}” group"),
pad_impact,
)
.with_remedy(Remedy {
text: format!(
"Add the user to the “{group}” group, then log out and back in. This group \
can present arbitrary emulated USB devices join it only on a machine you \
trust."
),
command: Some(format!("sudo usermod -aG {group} {user}")),
relogin_required: true,
})
.with_param("user", user)
}
// A member in the database AND in this process, yet the node is still not writable: the
// udev rule that chgrp's it was never installed (or has not run for this device). Blaming
// the group here would send someone to re-run a `usermod` that is already correct.
(Some(true), Some(true)) => base(
"The vhci attach node is not owned by the expected group",
pad_impact,
)
.with_remedy(Remedy {
text: format!(
"Install the udev rule that grants the “{group}” group access to the vhci nodes \
(scripts/60-punktfunk.rules the packages install it), then reload the rules or \
reboot."
),
command: Some("sudo udevadm control --reload && sudo udevadm trigger".to_string()),
relogin_required: false,
}),
// We could not ask the user database. Report the fact without guessing at a cause: a wrong
// remedy here costs more than a vague one.
_ => base(
"The virtual Steam Deck controller's attach node is not writable",
pad_impact,
)
.with_remedy(Remedy {
text: format!(
"Check that this machine's user is in the “{group}” group and that the udev rule \
granting it access to the vhci nodes is installed, then log out and back in."
),
command: None,
relogin_required: true,
}),
}
}
// ---------------------------------------------------------------------------------------------
// uinput_access
// ---------------------------------------------------------------------------------------------
fn uinput_access() -> HostCheck {
let id = ids::UINPUT_ACCESS;
match crate::inject::uinput_probe() {
UinputVerdict::Inapplicable => HostCheck::inapplicable(
id,
"Virtual controllers are created through this platform's own driver stack rather than \
uinput.",
),
UinputVerdict::Ok => HostCheck::ok(id, "The input device nodes are reachable."),
// The node exists and we may not open it: a group problem, and the remedy depends on
// whether this OS lets `usermod` stick.
UinputVerdict::PermissionDenied { path } => HostCheck::problem(
id,
CheckStatus::Fail,
Severity::Critical,
format!("No permission to open {path}"),
"Every virtual controller fails to be created, so games see no gamepad at all — and \
the pen and tablet input paths are dead with it."
.to_string(),
)
.with_remedy(input_group_remedy())
.with_param("path", path)
.with_param("group", INPUT_GROUP),
// The node is absent: nothing to have permission on. A group remedy here is a wrong turn.
UinputVerdict::Missing { path } => HostCheck::problem(
id,
CheckStatus::Fail,
Severity::Critical,
format!("{path} does not exist"),
"Every virtual controller fails to be created, so games see no gamepad at all."
.to_string(),
)
.with_remedy(Remedy {
text: format!(
"Load the kernel module that provides {path} and install the udev rule that grants \
the {INPUT_GROUP} group access to it (scripts/60-punktfunk.rules the packages \
install both)."
),
command: None,
relogin_required: false,
})
.with_param("path", path),
UinputVerdict::Error { path, message } => HostCheck::problem(
id,
CheckStatus::Fail,
Severity::Critical,
format!("{path} could not be opened: {message}"),
"Virtual controllers may fail to be created, so games can see no gamepad.".to_string(),
)
.with_remedy(Remedy {
text: format!("Check the state of {path} on this machine."),
command: None,
relogin_required: false,
})
.with_param("path", path)
.with_param("error", message),
}
}
/// The `input`-group remedy, branched on OS flavour.
///
/// On the Universal Blue images `usermod -aG input` appears to work and is silently reverted,
/// because `/etc/group` is not writable state on an atomic OS — `packaging/README.md` has said so
/// since the Bazzite port, and telling someone to run it there is worse than saying nothing.
fn input_group_remedy() -> Remedy {
let user = current_user().unwrap_or_else(|| "$USER".to_string());
if is_universal_blue() {
Remedy {
text:
"Add the user to the “input” group with ujust, then log out and back in. On this \
OS a plain `usermod` does not persist."
.to_string(),
command: Some("ujust add-user-to-input-group".to_string()),
relogin_required: true,
}
} else {
Remedy {
text: "Add the user to the “input” group, then log out and back in. If the group \
already lists the user, the udev rule granting it access may be missing \
(scripts/60-punktfunk.rules)."
.to_string(),
command: Some(format!("sudo usermod -aG {INPUT_GROUP} {user}")),
relogin_required: true,
}
}
}
/// The Universal Blue images, which are the ones that ship `ujust`.
///
/// Matched on the chain's **leaf** (`ID`), never on the `fedora` family token: plain Fedora
/// Workstation is a mutable OS and does want `usermod`. `osinfo`'s chain is `linux/fedora/bazzite`
/// for Bazzite, so the leaf is the distro's own id.
fn is_universal_blue() -> bool {
matches!(
crate::osinfo::detect().chain.rsplit('/').next(),
Some("bazzite" | "bluefin" | "aurora")
)
}
// ---------------------------------------------------------------------------------------------
// server_conflict
// ---------------------------------------------------------------------------------------------
fn server_conflict() -> HostCheck {
let id = ids::SERVER_CONFLICT;
// The cached startup scan — the same source the tray's summary and the Host page's card read.
// Empty also means "never scanned" on a build that skipped the GameStream planes, which reads
// as healthy here exactly as it already does on `LocalSummary.conflicts`.
let labels = crate::detect::summary_labels(crate::detect::snapshot());
if labels.is_empty() {
return HostCheck::ok(
id,
"No other game-streaming server is active on this machine.",
);
}
let servers = labels.join(", ");
HostCheck::problem(
id,
CheckStatus::Fail,
Severity::Critical,
format!("Another game-streaming server is active: {servers}"),
"Both servers bind the same ports, so whichever won the bind answers — pairing and \
connections can land on the other server while this host looks installed and healthy."
.to_string(),
)
.with_remedy(Remedy {
text: "Stop the other server (and disable it if it starts on its own), then restart this \
host."
.to_string(),
command: None,
relogin_required: false,
})
.with_param("servers", servers)
}
// ---------------------------------------------------------------------------------------------
// Group membership: two different questions
// ---------------------------------------------------------------------------------------------
/// This process's login name, resolved the way `pkexec` will (`id -un`).
fn current_user() -> Option<String> {
capture(Command::new("id").arg("-un"))
}
/// Is `user` in `group` **according to the user database** (`id -nG <user>`)? This is what a root
/// helper sees, and what `usermod -aG` changes immediately. `None` when the question could not be
/// asked — NSS can block or fail, and a false accusation sends people down the wrong path.
fn user_in_group_userdb(user: &str, group: &str) -> Option<bool> {
let groups = capture(Command::new("id").args(["-nG", user]))?;
Some(groups.split_whitespace().any(|g| g == group))
}
/// Is `group` among **this process's** supplementary groups (`id -nG`, no operand)? Fixed when the
/// `systemd --user` manager started, so a fresh `usermod` does not show up here until the next
/// login — which is exactly the distinction that makes the "log out and back in" remedy necessary.
fn process_in_group(group: &str) -> Option<bool> {
let groups = capture(Command::new("id").arg("-nG"))?;
Some(groups.split_whitespace().any(|g| g == group))
}
fn capture(cmd: &mut Command) -> Option<String> {
let out = cmd.output().ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
(!s.is_empty()).then_some(s)
}
#[cfg(test)]
mod tests {
use super::*;
/// Every mapping arm, including the ones this machine cannot reach: the table is the point.
/// The three `NotWritable` shapes are what the design calls out as the easiest thing to get
/// wrong, so each is asserted to produce a *different* remedy.
#[test]
fn vhci_not_writable_shapes_produce_distinct_remedies() {
let path = "/sys/devices/platform/vhci_hcd.0/attach".to_string();
let check = not_writable_check(ids::VIRTUAL_DECK_VHCI, PUNKTFUNK_GROUP, path.clone());
// Whatever this machine answers, the shape contract holds: a failing check always carries a
// remedy, and the pad's impact is always stated.
assert_eq!(check.status, CheckStatus::Fail);
assert_eq!(check.severity, Severity::Warning);
assert!(check.remedy.is_some());
assert!(!check.impact.is_empty());
assert_eq!(
check.params.get("group").map(String::as_str),
Some(PUNKTFUNK_GROUP)
);
}
#[test]
fn takeover_inapplicable_reasons_are_all_populated() {
for why in [
TakeoverInapplicable::Root,
TakeoverInapplicable::NoDisplayManager,
TakeoverInapplicable::NoManagedSession,
TakeoverInapplicable::NoPackagedHelper,
TakeoverInapplicable::UnknownUser,
TakeoverInapplicable::NotLinux,
] {
assert!(
!takeover_inapplicable_reason(why).trim().is_empty(),
"{why:?} needs a reason — an inapplicable row exists to answer \"why not here?\""
);
}
}
/// The atomic-OS branch is the one that is silently wrong if it regresses: `usermod` looks like
/// it worked on Bazzite and is gone after a reboot.
#[test]
fn input_remedy_matches_this_box_flavour() {
let remedy = input_group_remedy();
let command = remedy
.command
.expect("the input remedy is always pasteable");
assert!(remedy.relogin_required, "a group change needs a re-login");
if is_universal_blue() {
assert_eq!(command, "ujust add-user-to-input-group");
} else {
assert!(
command.starts_with("sudo usermod -aG input "),
"unexpected remedy: {command}"
);
}
}
/// `bazzite` is the chain's LEAF, and `fedora` is its family — matching the family would send
/// plain Fedora Workstation users to a `ujust` they do not have.
#[test]
fn universal_blue_is_matched_on_the_leaf_not_the_family() {
fn leaf_is_ublue(chain: &str) -> bool {
matches!(
chain.rsplit('/').next(),
Some("bazzite" | "bluefin" | "aurora")
)
}
assert!(leaf_is_ublue("linux/fedora/bazzite"));
assert!(leaf_is_ublue("linux/fedora/bluefin"));
assert!(!leaf_is_ublue("linux/fedora"));
assert!(!leaf_is_ublue("linux/fedora/fedora"));
assert!(!leaf_is_ublue("linux/arch/steamos"));
}
#[test]
fn server_conflict_is_ok_when_nothing_was_detected() {
// `detect::snapshot()` is empty in a test binary (no startup scan ran), which is the same
// state a clean box reports.
let check = server_conflict();
assert_eq!(check.status, CheckStatus::Ok);
assert!(check.remedy.is_none());
}
}
+3
View File
@@ -23,6 +23,9 @@ mod bringup;
mod capture;
mod detect;
mod devtest;
/// Host health verdicts as one structured channel (design/web-console-diagnostics.md).
#[forbid(unsafe_code)]
mod diagnostics;
// Network-facing on the secure default host (see the forbid block at `mod mgmt` below).
#[forbid(unsafe_code)]
mod discovery;
+6
View File
@@ -32,6 +32,7 @@ use utoipa_scalar::{Scalar, Servable};
mod auth;
mod client_logs;
mod clients;
mod diagnostics;
mod display;
mod events;
mod gpu;
@@ -314,6 +315,10 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
))
.routes(routes!(host::get_status))
.routes(routes!(host::get_local_summary))
// Two paths, so two calls — `routes!` merges the METHODS of one path, and two calls naming
// the same path collide.
.routes(routes!(diagnostics::get_diagnostics))
.routes(routes!(diagnostics::refresh_diagnostics))
// GET and DELETE share the `/clients` path, so they must be ONE `routes!` — utoipa-axum
// merges the methods of a single call into one route; two calls collide on the path.
.routes(routes!(
@@ -429,6 +434,7 @@ pub fn openapi_json() -> String {
modifiers(&SecurityAddon),
tags(
(name = "host", description = "Host identity, capabilities, and liveness"),
(name = "diagnostics", description = "Host health checks: what is wrong, what it breaks, and how to fix it (admin lane only)"),
(name = "gpu", description = "GPU inventory and selection: list the host's GPUs, choose automatic or a preferred GPU, see the one in use"),
(name = "display", description = "Virtual-display management policy: lifecycle (keep-alive), topology (primary/exclusive), conflict handling, identity, and layout"),
(name = "clients", description = "Paired Moonlight client management"),
@@ -0,0 +1,65 @@
//! Diagnostics endpoints: the host's health verdicts as one structured channel.
//!
//! **Admin lane only, deliberately.** Neither route is on `auth::plugin_may_access` nor
//! `cert_may_access` — both are opt-in allowlists, so a route stays denied until someone classifies
//! it, and these carry usernames, group layout and device-node state. Putting them on the plugin or
//! paired-cert lanes would be a security regression, not a convenience; the unauthenticated
//! loopback summary the tray reads may carry counts at most.
use super::shared::*;
use crate::diagnostics::{CheckSource, DiagnosticsReport};
/// Host health checks
///
/// Every verdict this host computes about its own health — group membership the managed takeover
/// needs, the input device nodes virtual controllers are built on, competing streaming servers —
/// with the impact and a copy-pasteable remedy for each.
///
/// Cached: the probes run once at startup and on demand via `POST /diagnostics/refresh`, so this is
/// cheap to poll. Checks whose status is `ok` and `inapplicable` are included — a troubleshooting
/// page needs to show what is working and to answer "why isn't this check relevant here?".
///
/// `summary`, `impact` and `remedy.text` are always present in English. A console that recognizes
/// the check's `id` replaces them with a localized string interpolated from `params`; one that does
/// not renders the wire text as-is, which is what keeps a console paired with a newer host readable.
#[utoipa::path(
get,
path = "/diagnostics",
tag = "diagnostics",
operation_id = "getDiagnostics",
responses(
(status = OK, description = "The current verdicts, worst-first", body = DiagnosticsReport),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn get_diagnostics() -> Json<DiagnosticsReport> {
Json(crate::diagnostics::registry().report())
}
/// Re-run the health checks
///
/// Runs every probe again and returns the refreshed verdicts. Most checks describe state that only
/// changes when an operator changes it (a group membership, an installed udev rule), so this exists
/// for exactly the moment after they have done so — a "did that fix it?" button, not a poll.
#[utoipa::path(
post,
path = "/diagnostics/refresh",
tag = "diagnostics",
operation_id = "refreshDiagnostics",
responses(
(status = OK, description = "The refreshed verdicts, worst-first", body = DiagnosticsReport),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
pub(crate) async fn refresh_diagnostics() -> Json<DiagnosticsReport> {
// The probes stat sysfs and shell out to `id`, whose NSS lookup can block on a box with a
// remote directory — so they run on the blocking pool rather than stalling the executor.
let report = tokio::task::spawn_blocking(|| {
let reg = crate::diagnostics::registry();
reg.run_all(CheckSource::Refresh);
reg.report()
})
.await
.unwrap_or_else(|_| crate::diagnostics::registry().report());
Json(report)
}
+106
View File
@@ -1358,6 +1358,12 @@ 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),
// ---- diagnostics: OPERATOR ONLY, both lanes denied. The verdicts name the host's user,
// its group layout and the state of its device nodes — a paired streaming client has no
// business enumerating any of that, and a plugin that wanted to would be asking for a map
// of the box's privilege boundaries. The tray gets counts on `/local/summary` instead.
("GET", "/api/v1/diagnostics", false, false),
("POST", "/api/v1/diagnostics/refresh", false, 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
@@ -1637,6 +1643,106 @@ fn post_json(path: &str, body: serde_json::Value) -> axum::http::Request<Body> {
.unwrap()
}
/// Diagnostics are operator business: the verdicts carry the host user's name, its group layout and
/// the state of its device nodes.
#[tokio::test]
async fn diagnostics_require_the_operator_token() {
let app = test_app(test_state(), Some("sekrit"));
for req in [
get_req("/api/v1/diagnostics"),
axum::http::Request::post("/api/v1/diagnostics/refresh")
.body(Body::empty())
.unwrap(),
] {
let (status, body) = send(&app, req).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
assert!(body["error"].as_str().unwrap().contains("bearer"));
}
}
/// The shape the console renders: a worst-first list in which every registered check appears, `ok`
/// and `inapplicable` rows included (the troubleshooting page shows what works, and can answer why
/// a check does not apply here).
#[tokio::test]
async fn diagnostics_report_the_registered_checks() {
// The registry is a process-global primed at `serve` startup; take the same first reading here
// rather than asserting against whatever another test in this binary left behind.
crate::diagnostics::preflight();
let app = test_app(test_state(), None);
let (status, body) = send(&app, get_req("/api/v1/diagnostics")).await;
assert_eq!(status, StatusCode::OK);
assert!(body["ran_at_unix"].is_number(), "the report is stamped");
let checks = body["checks"].as_array().expect("checks array");
assert!(
!checks.is_empty(),
"the v1 catalog is registered at startup"
);
// Ids are the console's i18n keys; a rename silently drops every translation.
let ids: Vec<&str> = checks.iter().filter_map(|c| c["id"].as_str()).collect();
for expected in [
"takeover_privilege",
"virtual_deck_vhci",
"uinput_access",
"server_conflict",
] {
assert!(ids.contains(&expected), "missing check {expected}: {ids:?}");
}
// The N/N1 drift guarantee on the wire, not just in the registry's own unit test: a console
// that predates a check has nothing but these strings to render.
for check in checks {
let id = check["id"].as_str().unwrap();
assert!(
!check["summary"]
.as_str()
.unwrap_or_default()
.trim()
.is_empty(),
"{id}: summary must never be empty"
);
assert!(
matches!(
check["status"].as_str(),
Some("ok" | "warn" | "fail" | "inapplicable")
),
"{id}: unexpected status {:?}",
check["status"]
);
if check["status"] == "fail" {
assert!(
!check["remedy"]["text"]
.as_str()
.unwrap_or_default()
.trim()
.is_empty(),
"{id}: a failing check must tell the operator what to do"
);
}
}
}
/// Refresh re-runs the probes and answers with the fresh report — the "did that fix it?" button.
#[tokio::test]
async fn diagnostics_refresh_reruns_and_returns_the_report() {
let app = test_app(test_state(), None);
let req = axum::http::Request::post("/api/v1/diagnostics/refresh")
.body(Body::empty())
.unwrap();
let (status, body) = send(&app, req).await;
assert_eq!(status, StatusCode::OK);
let checks = body["checks"].as_array().expect("checks array");
assert!(!checks.is_empty(), "refresh answers with the full catalog");
// NOT asserted here: that every row reports `source: "refresh"`. The registry is a
// process-global and a sibling test in this binary primes it with a startup reading, so that
// assertion would be a parallel-test race. `source` is pinned on an isolated registry in
// `crate::diagnostics::tests::refresh_reruns_every_probe`, which is where it belongs.
assert!(checks.iter().all(|c| c["id"].is_string()));
}
/// The display-management GET surface (presets + effective + the enforced-axes list). READ-ONLY
/// on purpose: `prefs()` is a process-global `OnceLock`, so a PUT here would clobber it and race
/// other tests running in the same process. `keep_alive: forever` (gaming-rig) is now accepted
+4
View File
@@ -422,6 +422,10 @@ pub(crate) async fn serve(
// mirroring the box's own session — so without this it surfaces only as a black screen on
// every connect. No-op off Linux and on any box the takeover can't apply to.
crate::vdisplay::preflight_takeover_privilege();
// Same verdicts, second destination: the log line above is for whoever reads logs, and the
// registry is for whoever opens the console. Runs after the subsystems the probes inspect are
// up, so a probe never reports a device node that was about to appear.
crate::diagnostics::preflight();
// …and the other end of that: give the box its session back when WE are the ones going away.
install_shutdown_restore();
// (No cover-art warmer any more: it existed to fetch GOG/Xbox art off the hot path for the two
+834 -5
View File
@@ -10,9 +10,242 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.28.0"
"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": [
@@ -168,6 +401,70 @@
}
}
},
"/api/v1/diagnostics": {
"get": {
"tags": [
"diagnostics"
],
"summary": "Host health checks",
"description": "Every verdict this host computes about its own health — group membership the managed takeover\nneeds, the input device nodes virtual controllers are built on, competing streaming servers —\nwith the impact and a copy-pasteable remedy for each.\n\nCached: the probes run once at startup and on demand via `POST /diagnostics/refresh`, so this is\ncheap to poll. Checks whose status is `ok` and `inapplicable` are included — a troubleshooting\npage needs to show what is working and to answer \"why isn't this check relevant here?\".\n\n`summary`, `impact` and `remedy.text` are always present in English. A console that recognizes\nthe check's `id` replaces them with a localized string interpolated from `params`; one that does\nnot renders the wire text as-is, which is what keeps a console paired with a newer host readable.",
"operationId": "getDiagnostics",
"responses": {
"200": {
"description": "The current verdicts, worst-first",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DiagnosticsReport"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/diagnostics/refresh": {
"post": {
"tags": [
"diagnostics"
],
"summary": "Re-run the health checks",
"description": "Runs every probe again and returns the refreshed verdicts. Most checks describe state that only\nchanges when an operator changes it (a group membership, an installed udev rule), so this exists\nfor exactly the moment after they have done so — a \"did that fix it?\" button, not a poll.",
"operationId": "refreshDiagnostics",
"responses": {
"200": {
"description": "The refreshed verdicts, worst-first",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DiagnosticsReport"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/display/layout": {
"put": {
"tags": [
@@ -1903,6 +2200,97 @@
}
}
}
},
"patch": {
"tags": [
"native"
],
"summary": "Update a native client's access",
"description": "Partial edit of a paired device's grants/expiry (the console edit sheet: preset change,\nextend, \"expire now\", make permanent). Omitted fields keep their current value; the edit\nreaches the device's live sessions immediately. Not a way to pair a device (404 when the\nfingerprint isn't in the trust store).",
"operationId": "updateNativeClientAccess",
"parameters": [
{
"name": "fingerprint",
"in": "path",
"description": "Hex SHA-256 of the client certificate (case-insensitive)",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateNativeAccess"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Access updated; the stored record as now in force",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NativeClient"
}
}
}
},
"400": {
"description": "Reserved grant bits set, or expires_in_secs together with clear_expiry",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"404": {
"description": "No paired native client with that fingerprint",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"500": {
"description": "Could not persist the trust store",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"503": {
"description": "Native host not enabled",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/native/pair": {
@@ -1976,7 +2364,7 @@
"native"
],
"summary": "Arm native pairing",
"description": "Opens a pairing window and mints a fresh PIN to display. The user enters it on their device\nwithin `ttl_secs`; the device then appears in the native client list.",
"description": "Opens a pairing window and mints a fresh PIN to display. The user enters it on their device\nwithin `ttl_secs`; the device then appears in the native client list. An access choice\n(`grants` / `expires_in_secs`) applies to whichever device completes this window's ceremony.",
"operationId": "armNativePairing",
"requestBody": {
"content": {
@@ -1999,6 +2387,16 @@
}
}
},
"400": {
"description": "Reserved grant bits set",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
@@ -2063,7 +2461,7 @@
"native"
],
"summary": "Approve a pending device",
"description": "Pairs the device's certificate fingerprint — it can connect immediately (no PIN). Optionally\nrelabel it via the body; send `{}` to keep the name it knocked with.",
"description": "Pairs the device's certificate fingerprint — it can connect immediately (no PIN). Optionally\nrelabel it and/or choose its access via the body; send `{}` to keep the name it knocked with\nand its existing access (full/permanent for a first pairing). The response is the stored\nrecord — what is actually in force, not necessarily this request's inputs.",
"operationId": "approvePendingDevice",
"parameters": [
{
@@ -2090,7 +2488,7 @@
},
"responses": {
"200": {
"description": "Device paired",
"description": "Device paired; the stored record as now in force",
"content": {
"application/json": {
"schema": {
@@ -2099,6 +2497,16 @@
}
}
},
"400": {
"description": "Reserved grant bits set",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
@@ -4128,8 +4536,28 @@
},
"ApprovePending": {
"type": "object",
"description": "Approve-pending-device request body. Send `{}` to keep the device's own name.",
"description": "Approve-pending-device request body. Send `{}` to keep the device's own name and — for a\nre-approved device — its existing access (the full/permanent default for a first pairing).",
"properties": {
"expires_in_secs": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "Access expiry in seconds **from now** (relative — the host stores the absolute deadline\nand stamps the grant time). Alone, it means full control until then.",
"example": 14400,
"minimum": 0
},
"grants": {
"type": [
"integer",
"null"
],
"format": "int32",
"description": "Access choice: grant bitmask (`GRANT_*` bits 05). Reserved bits are a 400. Omitting BOTH\naccess fields keeps a re-approved device's stored access; `grants` without\n`expires_in_secs` grants permanently.",
"example": 1,
"minimum": 0
},
"name": {
"type": [
"string",
@@ -4144,6 +4572,16 @@
"type": "object",
"description": "Arm-native-pairing request body.",
"properties": {
"expires_in_secs": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "Optional access expiry for the pairing device, in seconds **from now** (relative — the\nhost stores the absolute deadline). NOT the pairing window's length; that is `ttl_secs`.\nOmit for permanent access (when `grants` is set) or preserved access (when neither is).",
"example": 14400,
"minimum": 0
},
"fingerprint": {
"type": [
"string",
@@ -4152,6 +4590,16 @@
"description": "Optional: bind the window to ONE device fingerprint (hex SHA-256, e.g. from a pending knock).\nWhen set, only a pairing attempt from that fingerprint consumes the window — so an unpaired\nLAN peer can neither pair nor burn a window armed for a specific device (security-review #9).\nOmit for an unbound window (any device may use the PIN — trusted-LAN only).",
"example": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
},
"grants": {
"type": [
"integer",
"null"
],
"format": "int32",
"description": "Optional access choice for whichever device completes this window's ceremony: a grant\nbitmask (`GRANT_*` bits 05). Reserved bits are a 400. Omit (with `expires_in_secs`) for\ntoday's behavior — a new device gets full control, a re-pairing device keeps what it has.",
"example": 1,
"minimum": 0
},
"ttl_secs": {
"type": [
"integer",
@@ -4518,6 +4966,75 @@
}
}
},
"CheckSource": {
"type": "string",
"description": "Where a verdict came from. `Event` is reserved for the live feeds (transitions push instead of\nwaiting for a refresh); v1 produces only `Startup` and `Refresh`.",
"enum": [
"startup",
"event",
"refresh"
]
},
"CheckStatus": {
"type": "string",
"description": "What a probe found. `Inapplicable` is deliberately distinct from `Ok`: \"this box will never do\nthe thing\" and \"the thing works here\" are different answers, and the troubleshooting page shows\nthem differently.",
"enum": [
"ok",
"warn",
"fail",
"inapplicable"
]
},
"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.",
@@ -4793,6 +5310,29 @@
}
}
},
"DiagnosticsReport": {
"type": "object",
"description": "The `GET /diagnostics` body.",
"required": [
"ran_at_unix",
"checks"
],
"properties": {
"checks": {
"type": "array",
"items": {
"$ref": "#/components/schemas/HostCheck"
},
"description": "Every registered check, worst-first. Includes `ok` and `inapplicable` rows — the console\ndecides what to hide, because \"what's working\" is the reassurance the dashboard omits."
},
"ran_at_unix": {
"type": "integer",
"format": "int64",
"description": "When the probes last ran (unix seconds).",
"minimum": 0
}
}
},
"DisconnectReason": {
"type": "string",
"description": "Why a client went away. `Quit` is a deliberate user \"stop\" (the typed close code);\n`Timeout` is a transport idle timeout (the client vanished); `Error` is everything else.",
@@ -5231,6 +5771,91 @@
}
}
},
{
"type": "object",
"description": "A device was granted access with an explicit operator choice — the approve dialog, the\narm window's carried choice, or any other `add_with_access(Some)` path\n(design/per-client-access.md §6). A plain pairing with no choice emits only\n`pairing.completed` (its access is the preserved/default record, nothing was *chosen*).",
"required": [
"device",
"grants",
"kind"
],
"properties": {
"device": {
"$ref": "#/components/schemas/DeviceRef"
},
"expires_unix": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "Absolute expiry, host wall clock unix seconds; absent = permanent."
},
"grants": {
"type": "integer",
"format": "int32",
"description": "The granted mask (the `GRANT_*` bit vocabulary), reserved bits already cleared.",
"minimum": 0
},
"kind": {
"type": "string",
"enum": [
"access.granted"
]
}
}
},
{
"type": "object",
"description": "A paired device's access was edited after the fact (the console edit sheet / extend /\n\"expire now\") — the owner's hook can say \"the TV is view-only now\".",
"required": [
"device",
"grants",
"kind"
],
"properties": {
"device": {
"$ref": "#/components/schemas/DeviceRef"
},
"expires_unix": {
"type": [
"integer",
"null"
],
"format": "int64"
},
"grants": {
"type": "integer",
"format": "int32",
"minimum": 0
},
"kind": {
"type": "string",
"enum": [
"access.changed"
]
}
}
},
{
"type": "object",
"description": "A device's temporary access reached its deadline and its live session was closed — \"guest\naccess ended\". Emitted at deadline fire by the expiring session (a device with no live\nsession expires silently; the console row flips to \"Expired\" either way).",
"required": [
"device",
"kind"
],
"properties": {
"device": {
"$ref": "#/components/schemas/DeviceRef"
},
"kind": {
"type": "string",
"enum": [
"access.expired"
]
}
}
},
{
"type": "object",
"required": [
@@ -5870,6 +6495,72 @@
}
}
},
"HostCheck": {
"type": "object",
"description": "One health verdict. This IS the wire shape.",
"required": [
"id",
"status",
"severity",
"summary",
"impact",
"params",
"source"
],
"properties": {
"id": {
"type": "string",
"description": "Stable snake_case machine code — the console's i18n key (see [`ids`])."
},
"impact": {
"type": "string",
"description": "What actually breaks, in the operator's terms. Empty only for `ok`/`inapplicable` rows."
},
"params": {
"type": "object",
"description": "Interpolation values for the console's localized strings (`{user}`, `{group}`, …). The\nconsole needs these because it cannot re-derive them: only the host can see the username.",
"additionalProperties": {
"type": "string"
},
"propertyNames": {
"type": "string"
}
},
"remedy": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/Remedy"
}
]
},
"severity": {
"$ref": "#/components/schemas/Severity",
"description": "What a non-ok status means. Meaningless when `status` is `ok`/`inapplicable`; carried anyway\nso a check never changes shape as it flips."
},
"since_unix": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "First non-ok observation in this host run. Per-run bookkeeping, not a time series — there is\nno history here by design.",
"minimum": 0
},
"source": {
"$ref": "#/components/schemas/CheckSource"
},
"status": {
"$ref": "#/components/schemas/CheckStatus"
},
"summary": {
"type": "string",
"description": "One line, English. The console replaces this with a localized message when it knows `id`."
}
}
},
"HostEvent": {
"allOf": [
{
@@ -6497,10 +7188,44 @@
"fingerprint"
],
"properties": {
"access_level": {
"type": [
"string",
"null"
],
"description": "The preset this device's mask amounts to, for display: `full` | `controller` | `view` |\n`custom`. Derived from `grants` on the host; absent only on hosts older than the field.",
"example": "controller"
},
"expires_unix": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "Absolute access expiry, unix seconds on the host's wall clock. `null` = permanent. Whether\nit has already passed is the reader's arithmetic — an expired device stays listed (shown\nas \"Expired\"), it just isn't authorized."
},
"fingerprint": {
"type": "string",
"description": "Hex SHA-256 of the client certificate — its stable id here."
},
"granted_unix": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "When access was last granted, unix seconds — display/audit only, never enforced."
},
"grants": {
"type": [
"integer",
"null"
],
"format": "int32",
"description": "Grant bitmask (`GRANT_*` bits 05). `null` = a record from before grants existed, which\nmeans full control.",
"example": 1,
"minimum": 0
},
"name": {
"type": "string",
"description": "The name the client supplied when pairing.",
@@ -6627,16 +7352,49 @@
"age_secs"
],
"properties": {
"access_level": {
"type": [
"string",
"null"
],
"description": "The stored mask's preset name (`full` | `controller` | `view` | `custom`) — `null` for a\ndevice with no stored record, unlike [`NativeClient`] where it is always derivable.",
"example": "controller"
},
"age_secs": {
"type": "integer",
"format": "int64",
"description": "Seconds since the device last knocked.",
"minimum": 0
},
"expires_unix": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "The stored record's absolute expiry (unix seconds; likely in the past — that's why it's\nknocking). `null` when unknown or permanent."
},
"fingerprint": {
"type": "string",
"description": "Hex SHA-256 of the device's certificate — what approval pins."
},
"granted_unix": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "When the stored record's access was granted (unix seconds). `null` when unknown."
},
"grants": {
"type": [
"integer",
"null"
],
"format": "int32",
"description": "The grant mask this fingerprint is ALREADY stored with, if it was paired before (the\nexpired-guest re-knock: the approve dialog can offer \"re-grant what they had\"). `null`\nwhen the device is unknown, or known with a pre-grants record (= full).",
"minimum": 0
},
"id": {
"type": "integer",
"format": "int32",
@@ -7055,6 +7813,31 @@
}
}
},
"Remedy": {
"type": "object",
"description": "What the operator should do about it. Always copy-paste — the host runs unprivileged and the\nconsole must never trigger privileged mutation. The `punktfunk` group in particular is\ndeliberately opt-in: writing the vhci `attach` node materialises arbitrary emulated USB devices\n(security review 2026-08-05, M-4), so joining it stays a deliberate act with the caveat attached.",
"required": [
"text",
"relogin_required"
],
"properties": {
"command": {
"type": [
"string",
"null"
],
"description": "A single pasteable shell command, when one fixes it outright."
},
"relogin_required": {
"type": "boolean",
"description": "True when the fix only takes effect after logging out and back in — a `systemd --user`\nmanager keeps the supplementary group set it started with. This distinction is the\ndifference between \"I already added myself!\" and a working virtual pad."
},
"text": {
"type": "string",
"description": "Plain-language instruction. English fallback — the console overrides it by check id."
}
}
},
"RuntimeRequest": {
"type": "object",
"required": [
@@ -7375,6 +8158,15 @@
}
}
},
"Severity": {
"type": "string",
"description": "How much a non-ok status matters. Orthogonal to [`CheckStatus`] on purpose: a check can be\n`warn` about something `critical` (degraded, not dead) and the console sorts by both.",
"enum": [
"info",
"warning",
"critical"
]
},
"SourceInput": {
"type": "object",
"required": [
@@ -7856,6 +8648,39 @@
}
}
},
"UpdateNativeAccess": {
"type": "object",
"description": "PATCH body for a paired device's access (the console edit sheet: change the preset, extend,\n\"expire now\", make permanent). **Partial**: an omitted `grants` keeps the current grants, and\nomitted expiry fields keep the current expiry — send only what changes.",
"properties": {
"clear_expiry": {
"type": [
"boolean",
"null"
],
"description": "`true` removes the expiry — access becomes permanent. Mutually exclusive with\n`expires_in_secs` (400)."
},
"expires_in_secs": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "New expiry in seconds **from now** (relative; the host stores the absolute deadline).\n`0` expires the device now. Omit to keep the current expiry. Mutually exclusive with\n`clear_expiry` (400).",
"example": 14400,
"minimum": 0
},
"grants": {
"type": [
"integer",
"null"
],
"format": "int32",
"description": "New grant bitmask (`GRANT_*` bits 05); reserved bits are a 400. Omit to keep the\ndevice's current grants.",
"example": 1,
"minimum": 0
}
}
},
"UpdateResultInfo": {
"type": "object",
"description": "Durable outcome of the most recent apply attempt (survives the host's own restart).",
@@ -8028,6 +8853,10 @@
"name": "host",
"description": "Host identity, capabilities, and liveness"
},
{
"name": "diagnostics",
"description": "Host health checks: what is wrong, what it breaks, and how to fix it (admin lane only)"
},
{
"name": "gpu",
"description": "GPU inventory and selection: list the host's GPUs, choose automatic or a preferred GPU, see the one in use"
+30 -2
View File
@@ -408,9 +408,37 @@
"action_logout": "Abmelden",
"settings_logout_failed": "Abmelden fehlgeschlagen — du bist weiterhin angemeldet. Bitte versuche es erneut.",
"nav_stats": "Leistung",
"nav_logs": "Logs",
"nav_troubleshooting": "Fehlersuche",
"troubleshooting_title": "Fehlersuche",
"troubleshooting_subtitle": "Was dieser Host über seinen eigenen Zustand weiß — und darunter der Log-Stream.",
"diag_checks_title": "Zustandsprüfungen",
"diag_rerun": "Prüfungen erneut ausführen",
"diag_rerunning": "Prüfung läuft…",
"diag_rerun_failed": "Die Prüfungen konnten nicht erneut ausgeführt werden.",
"diag_loading": "Prüfung läuft…",
"diag_all_ok": "Alles, was dieser Host prüfen kann, sieht gesund aus.",
"diag_unavailable": "Dieser Host ist älter als die Konsole und kann keine Zustandsprüfungen melden. Aktualisiere den Host, um sie zu sehen.",
"diag_show_inapplicable": "{count} anzeigen, die hier nicht zutreffen",
"diag_hide_inapplicable": "Die hier nicht zutreffenden ausblenden",
"diag_impact_label": "Was dadurch nicht funktioniert",
"diag_remedy_label": "So wird es behoben",
"diag_relogin_required": "Danach ab- und wieder anmelden",
"diag_copy": "Befehl kopieren",
"diag_copied": "Befehl kopiert.",
"diag_copy_failed": "Der Befehl konnte nicht kopiert werden.",
"diag_status_ok": "OK",
"diag_status_inapplicable": "Nicht zutreffend",
"diag_severity_critical": "Kritisch",
"diag_severity_warning": "Warnung",
"diag_severity_info": "Hinweis",
"diag_attention_title": "Dieser Host braucht Aufmerksamkeit",
"diag_attention_link": "Fehlersuche",
"diag_attention_more": "und {count} weitere",
"diag_takeover_privilege_title": "Übernahme des Display-Managers",
"diag_virtual_deck_vhci_title": "Virtueller Steam-Deck-Controller",
"diag_uinput_access_title": "Unterstützung für virtuelle Controller",
"diag_server_conflict_title": "Konkurrierender Streaming-Server",
"logs_title": "Logs",
"logs_subtitle": "Der aktuelle Log-Stream des Hosts und deiner Plugins — live verfolgen, nach Level filtern, durchsuchen.",
"logs_source_all": "Alle",
"logs_source_host": "Host",
"logs_source_plugins": "Plugins",
+30 -2
View File
@@ -408,9 +408,37 @@
"action_logout": "Sign out",
"settings_logout_failed": "Sign-out failed — you're still signed in. Please try again.",
"nav_stats": "Performance",
"nav_logs": "Logs",
"nav_troubleshooting": "Troubleshooting",
"troubleshooting_title": "Troubleshooting",
"troubleshooting_subtitle": "What this host knows about its own health, and the log stream underneath it.",
"diag_checks_title": "Health checks",
"diag_rerun": "Re-run checks",
"diag_rerunning": "Checking…",
"diag_rerun_failed": "The checks could not be re-run.",
"diag_loading": "Checking…",
"diag_all_ok": "Everything this host can check looks healthy.",
"diag_unavailable": "This host is older than the console and cannot report health checks. Update the host to see them.",
"diag_show_inapplicable": "Show {count} that don't apply here",
"diag_hide_inapplicable": "Hide the ones that don't apply here",
"diag_impact_label": "What this breaks",
"diag_remedy_label": "How to fix it",
"diag_relogin_required": "Log out and back in afterwards",
"diag_copy": "Copy command",
"diag_copied": "Command copied.",
"diag_copy_failed": "Could not copy the command.",
"diag_status_ok": "OK",
"diag_status_inapplicable": "Not applicable",
"diag_severity_critical": "Critical",
"diag_severity_warning": "Warning",
"diag_severity_info": "Notice",
"diag_attention_title": "This host needs attention",
"diag_attention_link": "Troubleshooting",
"diag_attention_more": "and {count} more",
"diag_takeover_privilege_title": "Display-manager takeover",
"diag_virtual_deck_vhci_title": "Virtual Steam Deck controller",
"diag_uinput_access_title": "Virtual controller support",
"diag_server_conflict_title": "Competing streaming server",
"logs_title": "Logs",
"logs_subtitle": "The host's recent log stream, and your plugins' — follow live, filter by level, search.",
"logs_source_all": "All",
"logs_source_host": "Host",
"logs_source_plugins": "Plugins",
+4 -2
View File
@@ -7,9 +7,9 @@ import {
MonitorPlay,
MoreHorizontal,
Puzzle,
ScrollText,
Server,
Settings,
Stethoscope,
Workflow,
} from "lucide-react";
import { motion } from "motion/react";
@@ -31,7 +31,9 @@ const NAV = [
{ to: "/displays", icon: MonitorPlay, label: () => m.nav_displays() },
{ to: "/library", icon: LibraryBig, label: () => m.nav_library() },
{ to: "/stats", icon: GaugeCircle, label: () => m.nav_stats() },
{ to: "/logs", icon: ScrollText, label: () => m.nav_logs() },
// The page is the troubleshooting home now — health checks above the log stream. The ROUTE stays
// `/logs`: bookmarks and deep links outlive a label.
{ to: "/logs", icon: Stethoscope, label: () => m.nav_troubleshooting() },
{ to: "/pairing", icon: KeyRound, label: () => m.nav_pairing() },
{ to: "/automation", icon: Workflow, label: () => m.nav_automation() },
{ to: "/plugins", icon: Puzzle, label: () => m.nav_plugins() },
+101
View File
@@ -0,0 +1,101 @@
import type { HostCheck } from "@/api/gen/model/hostCheck";
import { m } from "@/paraglide/messages";
/**
* Presentation rules for the host's diagnostics checks.
*
* The console and the host ship as **separate packages**, and canary setups pair console N with
* host N±1 so a check whose `id` this build has never heard of is a normal state, not an error.
* Every host check therefore arrives with English `summary`/`impact`/`remedy.text` already filled
* in, and this module's job is to *decorate* that: a localized name for the checks we know, a
* readable heading for the ones we don't, and the badge vocabulary.
*
* What is deliberately NOT localized here is the situational prose. A single check has many shapes
* (the vhci one alone has four distinct causes, each with its own remedy), and copying ~20 sentences
* into a package that versions independently of the one that generates them is exactly the drift
* this design set out to avoid. The host is the single source of that text; when it starts sending
* a shape discriminator alongside `id`, the console can key localized prose off it without guessing.
*/
/** Localized names for the check ids this build knows about. */
const TITLES: Record<string, () => string> = {
takeover_privilege: () => m.diag_takeover_privilege_title(),
virtual_deck_vhci: () => m.diag_virtual_deck_vhci_title(),
uinput_access: () => m.diag_uinput_access_title(),
server_conflict: () => m.diag_server_conflict_title(),
};
/**
* A heading for a check. Unknown ids are turned into a presentable phrase rather than hidden the
* host's own text underneath still explains the problem, so showing it beats dropping it.
*/
export function checkTitle(check: HostCheck): string {
const known = TITLES[check.id];
if (known) return known();
return check.id
.replace(/_/g, " ")
.replace(/^./, (first) => first.toUpperCase());
}
/** True when this build recognizes the check — used only to decide how much chrome to show. */
export function isKnownCheck(check: HostCheck): boolean {
return check.id in TITLES;
}
/** Checks the operator should act on. `inapplicable` is not a problem; that is its whole point. */
export function needsAttention(check: HostCheck): boolean {
return check.status === "warn" || check.status === "fail";
}
/**
* The badge's text.
*
* For a row that needs attention this is the **severity**, not the status because severity is
* what the badge's colour encodes, and a badge whose text says "Failing" on both a red and an amber
* row leaves the difference between them carried by colour alone. Anyone who cannot separate the
* two tints then sees two identical rows. Status and severity only ever disagree in ways the reader
* does not need ("degraded but critical"), so the badge says the thing that changes what they do.
*/
export function statusLabel(check: HostCheck): string {
if (check.status === "ok") return m.diag_status_ok();
if (check.status === "inapplicable") return m.diag_status_inapplicable();
switch (check.severity) {
case "critical":
return m.diag_severity_critical();
case "warning":
return m.diag_severity_warning();
default:
return m.diag_severity_info();
}
}
/**
* Badge variant for a check. Text always says the state too colour alone is not a state, the
* same rule the pairing badge follows.
*/
export function statusVariant(
check: HostCheck,
): "success" | "warning" | "destructive" | "outline" {
if (check.status === "ok") return "success";
if (check.status === "inapplicable") return "outline";
return check.severity === "critical" ? "destructive" : "warning";
}
/**
* Worst-first. The host already sorts, but the console re-sorts because it also renders lists it
* filtered itself, and an ordering that depends on which rows were dropped is a bug waiting to
* happen.
*/
export function worstFirst(checks: HostCheck[]): HostCheck[] {
const severityRank = { critical: 0, warning: 1, info: 2 } as const;
const statusRank = { fail: 0, warn: 1, ok: 2, inapplicable: 3 } as const;
return [...checks].sort((a, b) => {
const attention = Number(!needsAttention(a)) - Number(!needsAttention(b));
if (attention !== 0) return attention;
const severity = severityRank[a.severity] - severityRank[b.severity];
if (severity !== 0) return severity;
const status = statusRank[a.status] - statusRank[b.status];
if (status !== 0) return status;
return a.id.localeCompare(b.id);
});
}
@@ -0,0 +1,94 @@
import { Link } from "@tanstack/react-router";
import { AlertTriangle, ArrowRight } from "lucide-react";
import type { FC } from "react";
import { useGetDiagnostics } from "@/api/gen/diagnostics/diagnostics";
import type { HostCheck } from "@/api/gen/model/hostCheck";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import {
checkTitle,
needsAttention,
statusLabel,
statusVariant,
worstFirst,
} from "@/lib/diagnostics";
import { m } from "@/paraglide/messages";
/**
* The dashboard's attention strip: "something about this host needs you".
*
* Follows the `ConflictsCard` rule **renders nothing at all when there is nothing**, so a healthy
* host sees zero extra chrome. It is deliberately a pointer, not a manual: severity, the check's
* name and the host's one-line summary, then a link. Remedies live on the troubleshooting page,
* because a dashboard that starts explaining how to fix things stops being a dashboard.
*/
/** Rows shown before deferring to the troubleshooting page. Three keeps it a strip. */
const MAX_ROWS = 3;
export const AttentionCard: FC = () => {
// The v1 checks are startup-static (a group membership, an installed udev rule), so this shares
// one generous-`staleTime` cache entry with the troubleshooting page rather than polling.
const diagnostics = useGetDiagnostics({
query: {
staleTime: 5 * 60_000,
// A host older than this console has no `/diagnostics` route and answers 404. That is a
// supported pairing, not a fault, so don't retry it and don't surface it here — the
// troubleshooting page is where "this host can't report checks" gets explained.
retry: false,
},
});
return <AttentionStrip checks={diagnostics.data?.checks ?? []} />;
};
/** The pure half — fed fixtures by the stories, so the empty state is provable. */
export const AttentionStrip: FC<{ checks: HostCheck[] }> = ({ checks }) => {
const problems = worstFirst(checks.filter(needsAttention));
if (problems.length === 0) return null;
const shown = problems.slice(0, MAX_ROWS);
const hidden = problems.length - shown.length;
return (
<Card className="border-amber-600/40 dark:border-amber-500/40">
<CardContent className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 size-5 shrink-0 text-amber-600 dark:text-amber-500" />
<div className="min-w-0 flex-1 space-y-3">
<p className="text-sm font-medium text-amber-600 dark:text-amber-500">
{m.diag_attention_title()}
</p>
<ul className="flex flex-col gap-2">
{shown.map((check) => (
<li
key={check.id}
className="flex flex-wrap items-baseline gap-x-2 gap-y-1 text-sm"
>
{/* Text, not colour alone the badge has to be readable to a screen
reader and to anyone who cannot tell the two tints apart. */}
<Badge variant={statusVariant(check)}>
{statusLabel(check)}
</Badge>
<span className="font-medium">{checkTitle(check)}</span>
<span className="min-w-0 text-muted-foreground">
{check.summary}
</span>
</li>
))}
</ul>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<Link
to="/logs"
className="inline-flex items-center gap-1 text-sm font-medium hover:underline"
>
{m.diag_attention_link()}
<ArrowRight className="size-3.5" />
</Link>
{hidden > 0 && (
<span className="text-xs text-muted-foreground">
{m.diag_attention_more({ count: hidden })}
</span>
)}
</div>
</div>
</CardContent>
</Card>
);
};
+2
View File
@@ -13,6 +13,7 @@ import { useDialogs } from "@/components/dialogs";
import { apiErrorMessage } from "@/lib/errors";
import { useLocale } from "@/lib/i18n";
import { m } from "@/paraglide/messages";
import { AttentionCard } from "./AttentionCard";
import { DashboardView } from "./view";
export const SectionDashboard: FC = () => {
@@ -106,6 +107,7 @@ export const SectionDashboard: FC = () => {
<DashboardView
status={status}
library={library.data}
attention={<AttentionCard />}
onStopSession={async () => {
if (!(await confirmStopAll())) return;
stop.mutate(undefined, {
+6
View File
@@ -18,6 +18,10 @@ import { RunningGames } from "./RunningGames";
export const DashboardView: FC<{
status: Loadable<RuntimeStatus>;
library?: GameEntry[];
/** Host health warnings renders nothing when the host is healthy (see `AttentionCard.tsx`).
* Sits above the status query on purpose: a host whose `/status` is failing is exactly when
* its health checks are worth reading. */
attention?: ReactNode;
onStopSession: () => void;
onRequestIdr: () => void;
onEndGame: (game: ActiveGame) => void;
@@ -27,6 +31,7 @@ export const DashboardView: FC<{
}> = ({
status,
library,
attention,
onStopSession,
onRequestIdr,
onEndGame,
@@ -39,6 +44,7 @@ export const DashboardView: FC<{
<Section maxWidth={false}>
<div className="flex flex-col gap-card">
<h1 className="text-2xl font-semibold">{m.status_title()}</h1>
{attention}
<QueryState
isLoading={status.isLoading}
error={status.error}
+257
View File
@@ -0,0 +1,257 @@
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "@unom/ui/toast";
import {
CheckCircle2,
ChevronDown,
ChevronRight,
Copy,
RefreshCw,
} from "lucide-react";
import { type FC, useState } from "react";
import { ApiError } from "@/api/fetcher";
import {
getGetDiagnosticsQueryKey,
useGetDiagnostics,
useRefreshDiagnostics,
} from "@/api/gen/diagnostics/diagnostics";
import type { HostCheck } from "@/api/gen/model/hostCheck";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
checkTitle,
needsAttention,
statusLabel,
statusVariant,
worstFirst,
} from "@/lib/diagnostics";
import { apiErrorMessage } from "@/lib/errors";
import { m } from "@/paraglide/messages";
/**
* The troubleshooting page's checks list: everything the host knows about its own health.
*
* Unlike the dashboard strip, this shows the `ok` rows too "what is working" is the reassurance a
* dashboard deliberately omits, and it is most of the value when someone is hunting a problem that
* turns out to be elsewhere. `inapplicable` rows hide behind a toggle: they must not nag, but the
* page still has to be able to answer "why isn't this check relevant on my machine?".
*/
export const ChecksSection: FC = () => {
const qc = useQueryClient();
const diagnostics = useGetDiagnostics({
query: { staleTime: 5 * 60_000, retry: false },
});
const refresh = useRefreshDiagnostics();
const rerun = () =>
refresh.mutate(undefined, {
// The response IS the fresh report, so seed the shared cache entry with it instead of
// invalidating and making the host run every probe a second time.
onSuccess: (data) => qc.setQueryData(getGetDiagnosticsQueryKey(), data),
onError: (e) => toast.error(apiErrorMessage(e) ?? m.diag_rerun_failed()),
});
// A host that predates this console has no such route. Pairing N with N1 is supported, so this
// renders as a plain note rather than an error — nothing is broken, this host just can't answer.
const unsupported =
diagnostics.error instanceof ApiError && diagnostics.error.status === 404;
return (
<ChecksCard
checks={diagnostics.data?.checks ?? []}
unsupported={unsupported}
isLoading={diagnostics.isLoading}
error={unsupported ? undefined : diagnostics.error}
onRerun={rerun}
isRerunning={refresh.isPending}
/>
);
};
export const ChecksCard: FC<{
checks: HostCheck[];
/** The host has no diagnostics route (an older host paired with this console). */
unsupported?: boolean;
isLoading?: boolean;
error?: unknown;
onRerun: () => void;
isRerunning?: boolean;
}> = ({ checks, unsupported, isLoading, error, onRerun, isRerunning }) => {
const [showInapplicable, setShowInapplicable] = useState(false);
const applicable = worstFirst(
checks.filter((c) => c.status !== "inapplicable"),
);
const inapplicable = worstFirst(
checks.filter((c) => c.status === "inapplicable"),
);
const problems = applicable.filter(needsAttention).length;
return (
<Card>
<CardContent className="flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-lg font-medium">{m.diag_checks_title()}</h2>
<Button
variant="outline"
size="sm"
onClick={onRerun}
disabled={isRerunning || unsupported}
>
<RefreshCw className="size-3.5" />
{isRerunning ? m.diag_rerunning() : m.diag_rerun()}
</Button>
</div>
{unsupported ? (
<p className="text-sm text-muted-foreground">
{m.diag_unavailable()}
</p>
) : error ? (
<p className="text-sm text-destructive">
{apiErrorMessage(error) ?? m.diag_rerun_failed()}
</p>
) : isLoading ? (
<p className="text-sm text-muted-foreground">{m.diag_loading()}</p>
) : (
<>
{problems === 0 && applicable.length > 0 && (
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle2 className="size-4 text-[var(--success)]" />
{m.diag_all_ok()}
</p>
)}
<ul className="flex flex-col gap-2">
{applicable.map((check) => (
<CheckRow key={check.id} check={check} />
))}
</ul>
{inapplicable.length > 0 && (
<div className="flex flex-col gap-2">
<button
type="button"
className="flex items-center gap-1 self-start text-xs text-muted-foreground hover:text-foreground"
aria-expanded={showInapplicable}
onClick={() => setShowInapplicable((v) => !v)}
>
{showInapplicable ? (
<ChevronDown className="size-3" />
) : (
<ChevronRight className="size-3" />
)}
{showInapplicable
? m.diag_hide_inapplicable()
: m.diag_show_inapplicable({
count: inapplicable.length,
})}
</button>
{showInapplicable && (
<ul className="flex flex-col gap-2">
{inapplicable.map((check) => (
<CheckRow key={check.id} check={check} />
))}
</ul>
)}
</div>
)}
</>
)}
</CardContent>
</Card>
);
};
/**
* One check. Healthy and inapplicable rows are a single line there is nothing to act on, and
* making them expandable would imply otherwise. A row worth acting on opens to the impact and the
* remedy.
*/
const CheckRow: FC<{ check: HostCheck }> = ({ check }) => {
const [open, setOpen] = useState(false);
const expandable = needsAttention(check);
const title = checkTitle(check);
const header = (
<>
<Badge variant={statusVariant(check)}>{statusLabel(check)}</Badge>
<span className="font-medium">{title}</span>
<span className="min-w-0 text-muted-foreground">{check.summary}</span>
</>
);
return (
<li className="rounded-md border bg-card/40 px-3 py-2">
{expandable ? (
<button
type="button"
className="flex w-full flex-wrap items-baseline gap-x-2 gap-y-1 text-left text-sm"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
{open ? (
<ChevronDown className="size-3 self-center" />
) : (
<ChevronRight className="size-3 self-center" />
)}
{header}
</button>
) : (
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1 text-sm">
{header}
</div>
)}
{expandable && open && (
<div className="mt-3 flex flex-col gap-3 text-sm">
{check.impact && (
<div>
<p className="text-xs text-muted-foreground">
{m.diag_impact_label()}
</p>
<p className="mt-0.5 max-w-prose">{check.impact}</p>
</div>
)}
{check.remedy && (
<div>
<p className="text-xs text-muted-foreground">
{m.diag_remedy_label()}
</p>
<p className="mt-0.5 max-w-prose">{check.remedy.text}</p>
{check.remedy.command && (
<CopyableCommand command={check.remedy.command} />
)}
{check.remedy.relogin_required && (
<Badge variant="outline" className="mt-2">
{m.diag_relogin_required()}
</Badge>
)}
</div>
)}
</div>
)}
</li>
);
};
const CopyableCommand: FC<{ command: string }> = ({ command }) => (
<div className="mt-2 flex items-start gap-2">
<code className="min-w-0 flex-1 overflow-x-auto rounded-md bg-muted px-3 py-1.5 font-mono text-xs text-muted-foreground">
{command}
</code>
<Button
variant="ghost"
size="icon"
title={m.diag_copy()}
aria-label={m.diag_copy()}
onClick={() => {
navigator.clipboard
.writeText(command)
.then(() => toast.success(m.diag_copied()))
.catch(() => toast.error(m.diag_copy_failed()));
}}
>
<Copy className="size-3.5" />
</Button>
</div>
);
+3
View File
@@ -242,6 +242,9 @@ export const LogsCard: FC<{
unless something precedes it. This card used to restore it by hand at both
breakpoints. */}
<CardContent className="flex flex-col gap-3">
{/* The page heading says "Troubleshooting" now, so this card names itself otherwise
the log stream is the only section on the page with no label. */}
<h2 className="text-lg font-medium">{m.logs_title()}</h2>
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1">
{LEVELS.map((l) => (
+6 -3
View File
@@ -1,16 +1,19 @@
import type { FC } from "react";
import { useLocale } from "@/lib/i18n";
import { ChecksSection } from "./ChecksCard";
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.
// Troubleshooting = the host's health checks over 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
checks={<ChecksSection />}
viewer={
<>
<LogsSection />
+19 -5
View File
@@ -3,17 +3,31 @@ import type { FC, ReactNode } from "react";
import { m } from "@/paraglide/messages";
/**
* The Logs page LAYOUT the live page (`index.tsx`) and the Storybook story fill the single
* `viewer` slot, so the arrangement can never drift between them (same pattern as StatsView).
* The Troubleshooting page LAYOUT the live page (`index.tsx`) and the Storybook stories fill the
* slots, so the arrangement can never drift between them (same pattern as StatsView).
*
* This page is the troubleshooting home: it is where someone already goes when something is wrong,
* so the host's health checks meet them here rather than on a nav entry that is empty on a healthy
* host. The route stays `/logs` bookmarks and deep links outlive a label.
*
* Order is deliberate: checks first (structured, actionable), the log stream underneath. When the
* checks are green and something is still broken, the log is the natural next step now one scroll
* away instead of a separate destination.
*/
export const LogsView: FC<{ viewer: ReactNode }> = ({ viewer }) => (
export const LogsView: FC<{ checks?: ReactNode; viewer: ReactNode }> = ({
checks,
viewer,
}) => (
<Section maxWidth={false}>
<div className="flex flex-col gap-card">
<div className="space-y-1">
<h1 className="text-2xl font-semibold">{m.logs_title()}</h1>
<p className="text-sm text-muted-foreground">{m.logs_subtitle()}</p>
<h1 className="text-2xl font-semibold">{m.troubleshooting_title()}</h1>
<p className="text-sm text-muted-foreground">
{m.troubleshooting_subtitle()}
</p>
</div>
{checks}
{viewer}
</div>
</Section>
+44
View File
@@ -1,4 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { HostCheck } from "@/api/gen/model/hostCheck";
import { AttentionStrip } from "@/sections/Dashboard/AttentionCard";
import { DashboardView } from "@/sections/Dashboard/view";
import { statusActive, statusGrace, statusIdle } from "./lib/fixtures";
@@ -30,3 +32,45 @@ export const Idle: Story = {
export const GameWaitingForItsClient: Story = {
args: { status: { data: statusGrace, isLoading: false, error: null } },
};
const PROBLEMS: HostCheck[] = [
{
id: "takeover_privilege",
status: "fail",
severity: "critical",
summary: "User “enrico” is not in the “punktfunk” group",
impact: "Every takeover degrades to mirroring this machine's own session.",
params: {},
source: "startup",
},
{
id: "virtual_deck_vhci",
status: "fail",
severity: "warning",
summary: "The group “punktfunk” was granted but this session predates it",
impact: "The virtual Steam Deck controller cannot attach.",
params: {},
source: "startup",
},
{
id: "uinput_access",
status: "ok",
severity: "info",
summary: "The input device nodes are reachable.",
impact: "",
params: {},
source: "startup",
},
];
/**
* The attention strip in place: worst-first, one line each, no remedies the dashboard points at
* the troubleshooting page rather than becoming a manual. The healthy counterpart is every OTHER
* story on this page: they pass no `attention` at all, which is exactly what a healthy host renders.
*/
export const HostNeedsAttention: Story = {
args: {
status: { data: statusIdle, isLoading: false, error: null },
attention: <AttentionStrip checks={PROBLEMS} />,
},
};
+123
View File
@@ -0,0 +1,123 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { HostCheck } from "@/api/gen/model/hostCheck";
import { ChecksCard } from "@/sections/Logs/ChecksCard";
/**
* The troubleshooting page's checks list.
*
* The states that matter here are the ones that are easy to get wrong and impossible to see in a
* diff: a healthy host must not grow chrome, an `inapplicable` row must stay out of the way without
* disappearing, and a check this console has never heard of must still render because host N and
* console N1 is a supported pairing, and the host always sends readable English text with it.
*/
const check = (
over: Partial<HostCheck> & Pick<HostCheck, "id">,
): HostCheck => ({
status: "ok",
severity: "info",
summary: "",
impact: "",
params: {},
source: "startup",
...over,
});
const TAKEOVER_FAIL = check({
id: "takeover_privilege",
status: "fail",
severity: "critical",
summary: "User “enrico” is not in the “punktfunk” group",
impact:
"Streams that need the managed takeover cannot stop sddm.service, so every one of them degrades to mirroring this machine's own session instead. With the panel off that looks like a black screen on every connect, and nothing else reports it.",
remedy: {
text: "Add the user to the “punktfunk” group, then log out and back in. The same group gates the virtual Steam Deck pad's usbip nodes, which can present arbitrary emulated USB devices — join it only on a machine you trust.",
command: "sudo usermod -aG punktfunk enrico",
relogin_required: true,
},
params: { user: "enrico", group: "punktfunk", dm: "sddm.service" },
since_unix: 1_750_000_000,
});
/** The "I already added myself!" state — a re-login, not another usermod. */
const VHCI_RELOGIN = check({
id: "virtual_deck_vhci",
status: "fail",
severity: "warning",
summary: "The group “punktfunk” was granted but this session predates it",
impact:
"The virtual Steam Deck controller cannot attach, so Steam Input never sees it — in Game Mode that means nothing can be navigated with a pad.",
remedy: {
text: "Log out and back in. The membership is already recorded — this session just started before it was granted, and a session keeps the group set it began with.",
relogin_required: true,
},
params: { group: "punktfunk", user: "enrico" },
});
const UINPUT_OK = check({
id: "uinput_access",
summary: "The input device nodes are reachable.",
});
const CONFLICT_OK = check({
id: "server_conflict",
summary: "No other game-streaming server is active on this machine.",
});
const TAKEOVER_NA = check({
id: "takeover_privilege",
status: "inapplicable",
summary:
"No display manager drives this machine's logins, so a takeover has nothing to stop.",
});
/** A host newer than this console: the id is unknown, so only the wire text can be shown. */
const UNKNOWN = check({
id: "thermal_throttling",
status: "warn",
severity: "warning",
summary: "The encoder GPU has been thermally throttled for 4 minutes",
impact:
"Frames are being dropped under load, which reads as stutter on the client.",
remedy: {
text: "Check this machine's airflow and fan curve.",
relogin_required: false,
},
});
const meta = {
title: "Pages/Troubleshooting",
component: ChecksCard,
args: { onRerun: () => {}, isRerunning: false },
} satisfies Meta<typeof ChecksCard>;
export default meta;
type Story = StoryObj<typeof meta>;
/** The common case: nothing is wrong, and the list says so without shouting. */
export const Healthy: Story = {
args: { checks: [UINPUT_OK, CONFLICT_OK] },
};
/** The `.181` defect this whole feature exists for. */
export const OneCritical: Story = {
args: { checks: [TAKEOVER_FAIL, UINPUT_OK, CONFLICT_OK] },
};
/** Two problems of different severity, plus a row that does not apply to this box. */
export const Mixed: Story = {
args: { checks: [TAKEOVER_FAIL, VHCI_RELOGIN, UINPUT_OK, TAKEOVER_NA] },
};
/**
* Console N paired with host N+1. The check has no localized name and no localized prose here, and
* it still has to be readable that is what the host's English fallback text is for.
*/
export const UnknownCheckFromANewerHost: Story = {
args: { checks: [UNKNOWN, UINPUT_OK] },
};
/** An older host has no diagnostics route at all. Not an error — just nothing to report. */
export const HostTooOld: Story = {
args: { checks: [], unsupported: true },
};