From 329d072b15ce30fb1a669d6fb0c950a8913fd742 Mon Sep 17 00:00:00 2001 From: Emir Hasanbegovic Date: Thu, 3 Sep 2026 17:30:10 -0400 Subject: [PATCH] Telemetry page: both directions, every backend, and the counters behind them ## Summary The diagnostics page showed inbound UDP only, named just the preferred backend (so a Windows box with both drivers only ever said "ViGEm"), and painted an idle-but-healthy host red. It also kept polling `/api/debug` twice a second forever once visited. `GET /api/debug` gains `rx`, `tx`, `audio` and `auth` blocks plus the host gauges the page had been inferring, and the page is rebuilt around them. ## Server - `src/app/wire_stats.h`: one header-only `WireStats` for per-message wire counters, so the boundary is visible in one place. Header-only inline, so no per-platform `globals.cpp` can drift. - Inbound: per-type counts in the receiver's cold dispatch branch, and the rejections that never reach a decoder (runt, unknown token, malformed, unknown type). `DispatchResult` gained a `handled` flag so the receiver can split "known opcode, failed its length guard" from "unrecognised" without re-parsing, which keeps `inner_dispatch.cpp` globals-free as its portable test build requires. - Outbound: counted centrally in `ClientAdapter::sendEncryptedPacket`, which now also checks `sendto`'s return value (it was discarded). - Audio: `SessionService` counts mic frames accepted/late/dropped and decoded/FEC-recovered/concealed, and speaker frames sent, suppressed as digital silence, encode-failed and lost to lock contention. Core-pure atomics, no new locks. - `peakLoopUs`: `maxLoopUs` keeps its read-and-zero window semantics for the bench harness, but the receiver's thread-local high-water mark means a zeroed value only rises again on a new all-time record, so the page's "peak" read 0 nearly always. `/api/debug` now folds each windowed read into a true peak. - Also fixed: `webPort` was read by the page but never sent. ## Hot path Untouched. Nothing was added to the accepted-packet path: the inbound counters sit only in rejection branches and the cold non-gamepad branch, no outbound message is reachable from the gamepad path, and the audio counters run at 50 Hz per controller under locks those paths already hold. Inbound byte counting is deliberately absent because it would cost an atomic add per packet. ## Page Bidirectional flow diagram, six grouped sections (54 rows), a mirrored in/out traffic chart, and a list of every backend the host reports with vendor, mode, audio capability, lifecycle and installed-vs-bundled driver version. Idle now reads idle rather than error, polling stops on navigation, and an older satellite degrades to em dashes instead of `undefined`. 80 debug strings across all six locales. ## Tests - New `test_wire_stats` (108 assertions): every opcode's mapping in both directions, the malformed/unknown split, reset coverage checked generically so a forgotten counter fails, and the peak fold. - `test_receiver`: `handled` asserted for every accepted type and every length-guard rejection, including that a well-formed frame the service then refuses on policy still counts as handled. - `test_session_service`: audio counters cross-checked against the existing concealment/reorder scenario and the silence-suppression one. - `test_status_json`: golden shape updated; new test pins the counter blocks to `/api/debug` and keeps them off the hot status/SSE surfaces. - Full ctest 35/35, clang-format clean, core purity gate clean. - Page verified by rendering it headless against five payload shapes (busy, idle, driver-missing, pre-2.0.0 server, Linux/uinput) and two extra locales, and against the live 1.1.0-dev server for backwards compatibility. ## Docs `docs/contract.md` documents the new `/api/debug` fields under the admin API; `docs/architecture.md` records the counter placement rule and the peak-vs-window distinction; CHANGELOG 2.0.0 gains a paragraph. --- CHANGELOG.md | 45 +++ CMakeLists.txt | 1 + docs/architecture.md | 21 +- docs/contract.md | 20 ++ src/adapters/client_adapter.cpp | 28 +- src/app/wire_stats.h | 235 ++++++++++++++ src/core/session_service.cpp | 42 ++- src/core/session_service.h | 20 ++ src/core/types.h | 13 + src/net/inner_dispatch.cpp | 6 + src/net/inner_dispatch.h | 1 + src/net/receiver.cpp | 31 +- src/net/routes_admin.cpp | 15 + src/net/routes_client.cpp | 6 + src/net/status_json.h | 69 +++++ tests/test_receiver.cpp | 45 +++ tests/test_session_service.cpp | 80 +++++ tests/test_status_json.cpp | 54 +++- tests/test_wire_stats.cpp | 187 ++++++++++++ web/debug.js | 524 ++++++++++++++++++++++++-------- web/index.html | 92 ++---- web/lang/bs.json | 76 ++++- web/lang/de.json | 76 ++++- web/lang/en.json | 76 ++++- web/lang/es.json | 76 ++++- web/lang/fr.json | 76 ++++- web/lang/pt-BR.json | 76 ++++- web/style.css | 147 +++++++-- 28 files changed, 1852 insertions(+), 286 deletions(-) create mode 100644 src/app/wire_stats.h create mode 100644 tests/test_wire_stats.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 357293b..962fdd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,51 @@ working. The HIDMaestro backend, the composite audio personas and the unified build story (one script contract shared by CI and local builds) land here too. +The diagnostics page tells both halves of the story. `GET /api/debug` was +eight counters about inbound UDP plus one backend name, and the page built on +it showed one direction, named only the preferred backend (so a Windows box +with both drivers only ever said "ViGEm"), and painted an idle-but-healthy host +red because it read `backendAvailable` -- which means "the bus is open right +now", not "the driver works". The payload gains three blocks. `rx` counts +accepted inbound messages by type (input, heartbeat, motion, battery, pointer, +mic audio) and the four ways a datagram is refused before any decoder sees it +(`malformed` for a known opcode that failed its length guard, `unknownType`, +`runt`, `unknownToken`). `tx` is the direction that had no telemetry at all: +datagrams and bytes sent, split across heartbeat acks, rumble, lightbar, +trigger effects, player LEDs, speaker audio, the mic lamp and session closes, +plus `unroutable` / `encryptFailed` / `oversize` / `sendFailed` -- the last of +which required checking `sendto`'s return value, which the client adapter had +been discarding. `audio` reports the health of the streams this release turns +on: mic frames accepted, arrived-too-late and dropped (a partition of every +inbound frame), how many reached the pad by decode, by Opus in-band FEC and by +concealment, and on the way out how many speaker frames were sent, suppressed +as digital silence, failed to encode, or lost to lock contention. Alongside +them: client-API 401s split `notPaired` / `badProof`, reaped sessions, and the +host gauges the page previously had to infer (`webPort` -- which the old page +read but the server never sent -- `mdnsResponderActive`, `clientApiListening`, +live connection and controller counts). + +None of it touches the gamepad hot path, and the split is deliberate rather +than incidental: the inbound counters live only in the receiver's rejection +branches and its cold non-gamepad dispatch branch (`DispatchResult` grew a +`handled` flag so the receiver can tell a malformed frame from an unrecognised +one without re-parsing, keeping `inner_dispatch.cpp` free of globals as its +portable test build requires), the outbound ones sit in +`ClientAdapter::sendEncryptedPacket`, which no gamepad packet reaches, and the +audio ones in `SessionService` under locks those paths already hold. Inbound +byte counting is deliberately absent: it would cost an atomic add per packet on +the accepted path. `maxLoopUs` keeps its read-and-zero window semantics for +benchmark tooling, and a new `peakLoopUs` reports the peak that field could not: +the receiver's thread-local high-water mark never resets, so a zeroed +`maxLoopUs` climbs again only on a new all-time record, which is why the page's +"peak" read 0 nearly always. The page itself is rebuilt around a bidirectional +flow diagram, six grouped sections, a mirrored in/out traffic chart and a list +of every backend the host reports with its vendor, mode, audio capability, +lifecycle and installed-versus-bundled driver version; it degrades to em dashes +against an older satellite rather than rendering `undefined`, and it stops +polling when you navigate away instead of fetching `/api/debug` twice a second +for the life of the tab. + No elevation prompt when a controller connects. Creating a HIDMaestro virtual device needs an administrator token, and until now Satellite got one by spawning `satellite-hm-helper.exe` with `runas` on the first diff --git a/CMakeLists.txt b/CMakeLists.txt index af6c90c..cc28662 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -884,6 +884,7 @@ satellite_add_pure_test(test_config_json tests/test_config_json.cpp) satellite_add_pure_test(test_network_info tests/test_network_info.cpp src/core/network_info.cpp) satellite_add_pure_test(test_origin_guard tests/test_origin_guard.cpp) satellite_add_pure_test(test_status_json tests/test_status_json.cpp) +satellite_add_pure_test(test_wire_stats tests/test_wire_stats.cpp) satellite_add_pure_test(test_backend_registry tests/test_backend_registry.cpp src/core/backend_registry.cpp) satellite_add_pure_test(test_driver_inf tests/test_driver_inf.cpp) diff --git a/docs/architecture.md b/docs/architecture.md index 9460133..c56fce4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -243,6 +243,8 @@ arrays. The remaining globals are: - `g_config` / `g_configMtx`: application configuration - Atomic telemetry counters (`g_packetCount`, `g_submitOk`, etc.) +- `satellite::g_wire` (`app/wire_stats.h`): per-message wire counters, all + incremented off the gamepad hot path (see "Hot-path discipline") - Log ring buffer (`g_logRing`, `g_logMtx`) - Win32 plumbing (`g_hwnd`, `g_httpServer`, `g_appRunning`) @@ -257,14 +259,14 @@ and runs a `recvfrom` loop. It delegates all business logic to ``` recvfrom() │ - ├─ n < 8 → drop (too small for header) + ├─ n < 28 → drop (too small for header+inner+tag) [g_wire.rxRunt] │ ├─ Extract token (bytes 0-3), counter (bytes 4-7) - │ └─ svc.getDecryptInfo(token) → not found → drop - │ └─ counter <= lastCounter → drop (replay) + │ └─ svc.getDecryptInfo(token) → not found → drop [g_wire.rxUnknownToken] + │ └─ counter <= lastCounter → drop (replay) [g_replayDrop] │ ├─ Decrypt (ChaCha20-Poly1305) - │ └─ Fail → drop + │ └─ Fail → drop [g_decryptFail] │ ├─ svc.updatePostDecrypt(token, counter, ip, port) │ @@ -301,7 +303,16 @@ loop is kept allocation-free and minimally locked. If you touch `inet_ntop` / `std::string` allocation per packet). - Lock-free loop telemetry. `g_maxLoopUs` uses a thread-local high-water-mark so the atomic CAS loop is skipped on the ~99% of - packets below the running per-second peak. + packets below the running per-second peak. Because that mark never + resets, a reader that zeroes `g_maxLoopUs` only sees it rise again on a + new all-time record; `/api/debug` folds each windowed read into + `g_wire.peakLoopUs` and reports that as the true peak. +- No new counters on the accepted-packet path. The per-message counters in + `app/wire_stats.h` are incremented only in the receiver's rejection + branches and in its cold (non-gamepad) dispatch branch, in + `ClientAdapter::sendEncryptedPacket` (no outbound message is reachable + from the gamepad path), and in `SessionService`'s audio paths, which run + at 50 Hz per controller under a lock they already hold. The conceptual pipeline above lists `getDecryptInfo` / `updatePostDecrypt` / `handleGamepadData` as distinct steps; on the diff --git a/docs/contract.md b/docs/contract.md index dfad9f5..c6dce3c 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -846,6 +846,26 @@ hostFeatures; the server drops streams for ungranted features. | `DELETE /api/devices/{deviceId}` | Unpair; closes any live session (notify `unpaired`) | | `GET /api/connections` | Live sessions + per-controller truth (`pluggedIn` reflects the adapter, not `serialNo > 0`) | | `DELETE /api/connections/{connectionId}` | Kick (notify `kicked`); transient, client may reconnect | +| `GET /api/backend/status` | Preferred backend plus the per-host `backends` array (identity, availability, driver/bundled version, lifecycle) | +| `GET /api/debug` | Wire, audio and host telemetry for the diagnostics page | + +### `GET /api/debug` + +Counters are cumulative since the receiver bound its socket (once per process in +practice) and are **not** part of the client contract: this is an operator surface and +fields may be added at any time. Alongside the long-standing scalars (`listening`, +`packets`, `submitOk`, `submitFail`, `lastLoopUs`, `senderIP`, `udpPort`, `decryptFail`, +`replayDrop`, `backendAvailable`, `backend`) it carries: + +| Field | Meaning | +|-------|---------| +| `rx` | Accepted inbound messages by type (`input` = `submitOk + submitFail`, `heartbeat`, `motion`, `battery`, `pointer`, `micAudio`) and the rejections that never reached a decoder (`malformed` = known opcode whose length guard failed, `unknownType`, `runt` = datagram under 28 bytes, `unknownToken`) | +| `tx` | Outbound datagrams (`packets`, `bytes`) and their split by message (`heartbeatAck`, `rumble`, `lightbar`, `triggerEffects`, `playerLeds`, `speakerAudio`, `micLed`, `sessionClose`), plus the four send failures (`unroutable`, `encryptFailed`, `oversize`, `sendFailed`) | +| `audio` | Controller-audio stream health: `micAccepted` / `micLate` / `micDropped` partition every inbound frame, `micDecoded` + `micFecRecovered` + `micConcealed` count what reached the pad, and `speakerSent` / `speakerSilenceSuppressed` / `speakerEncodeFailed` / `speakerLockContended` the outbound side | +| `auth` | Client-API 401s, split `notPaired` / `badProof` | +| `peakLoopUs` | True hot-path peak. `maxLoopUs` keeps its read-and-zero window semantics for benchmark tooling and reads 0 unless a new all-time record was set since the last read | +| `webPort`, `mdnsResponderActive`, `clientApiListening`, `connections`, `controllers`, `maxControllers`, `sessionsReaped` | Host gauges | + The admin surface never sets descriptor fields (single-writer rule). The former `POST /api/devices/touchpad-mode` (both surfaces) and `POST /api/devices/remove` are diff --git a/src/adapters/client_adapter.cpp b/src/adapters/client_adapter.cpp index 3b030da..f6b058a 100644 --- a/src/adapters/client_adapter.cpp +++ b/src/adapters/client_adapter.cpp @@ -4,6 +4,8 @@ #include "net/session_crypto.h" +#include "app/wire_stats.h" + #include void ClientAdapter::setSocket(SOCKET sock) { sock_ = sock; } @@ -51,15 +53,25 @@ uint32_t ClientAdapter::nextTxCounter(uint32_t token) { void ClientAdapter::sendEncryptedPacket(const Connection& conn, const uint8_t* inner, size_t innerLen) { - if (sock_ == INVALID_SOCKET) return; + const uint16_t msgType = ((uint16_t)inner[0] << 8) | (uint16_t)inner[1]; + if (sock_ == INVALID_SOCKET) { + satellite::g_wire.txUnroutable.fetch_add(1, std::memory_order_relaxed); + return; + } // The buffers below are sized for MAX_INNER_MESSAGE_BYTES exactly, and // encryptPacket has no length of its own to check against; an oversized // caller is a bug, and dropping the frame beats overrunning the stack. - if (innerLen > static_cast(MAX_INNER_MESSAGE_BYTES)) return; + if (innerLen > static_cast(MAX_INNER_MESSAGE_BYTES)) { + satellite::g_wire.txOversize.fetch_add(1, std::memory_order_relaxed); + return; + } sockaddr_in addr{}; - if (!getAddr(conn.token, addr)) return; + if (!getAddr(conn.token, addr)) { + satellite::g_wire.txUnroutable.fetch_add(1, std::memory_order_relaxed); + return; + } // Monotonic per-token counter in the nonce; the direction byte keeps this // direction's nonces disjoint from the client's under the shared session key. @@ -72,6 +84,7 @@ void ClientAdapter::sendEncryptedPacket(const Connection& conn, const uint8_t* i unsigned long long ctLen = 0; if (!encryptPacket(conn.sessionKey, CRYPTO_DIR_SERVER_TO_CLIENT, counter, conn.token, inner, innerLen, ct, &ctLen)) { + satellite::g_wire.txEncryptFailed.fetch_add(1, std::memory_order_relaxed); return; } @@ -89,8 +102,13 @@ void ClientAdapter::sendEncryptedPacket(const Connection& conn, const uint8_t* i pkt[7] = (uint8_t)(counter); memcpy(pkt + HEADER_SIZE, ct, ctLen); - sendto(sock_, reinterpret_cast(pkt), (int)(HEADER_SIZE + ctLen), 0, - reinterpret_cast(&addr), sizeof(addr)); + const int sent = sendto(sock_, reinterpret_cast(pkt), (int)(HEADER_SIZE + ctLen), + 0, reinterpret_cast(&addr), sizeof(addr)); + if (sent == SOCKET_ERROR) { + satellite::g_wire.txSendFailed.fetch_add(1, std::memory_order_relaxed); + return; + } + satellite::g_wire.recordOutbound(msgType, static_cast(sent)); } void ClientAdapter::sendHeartbeatAck(const Connection& conn, bool backendAvailable, diff --git a/src/app/wire_stats.h b/src/app/wire_stats.h new file mode 100644 index 0000000..2981a78 --- /dev/null +++ b/src/app/wire_stats.h @@ -0,0 +1,235 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +#pragma once + +#include "core/types.h" + +#include +#include +#include + +namespace satellite { + +struct RxCounts { + uint64_t input = 0; + uint64_t heartbeat = 0; + uint64_t motion = 0; + uint64_t battery = 0; + uint64_t pointer = 0; + uint64_t micAudio = 0; + uint64_t malformed = 0; + uint64_t unknownType = 0; + uint64_t runt = 0; + uint64_t unknownToken = 0; +}; + +struct TxCounts { + uint64_t packets = 0; + uint64_t bytes = 0; + uint64_t heartbeatAck = 0; + uint64_t rumble = 0; + uint64_t lightbar = 0; + uint64_t triggerEffects = 0; + uint64_t playerLeds = 0; + uint64_t speakerAudio = 0; + uint64_t micLed = 0; + uint64_t sessionClose = 0; + uint64_t unroutable = 0; + uint64_t encryptFailed = 0; + uint64_t oversize = 0; + uint64_t sendFailed = 0; +}; + +struct WireCounts { + RxCounts rx; + TxCounts tx; + uint64_t authNotPaired = 0; + uint64_t authBadProof = 0; + uint64_t sessionsReaped = 0; +}; + +struct WireStats { + std::atomic rxHeartbeat{0}; + std::atomic rxMotion{0}; + std::atomic rxBattery{0}; + std::atomic rxPointer{0}; + std::atomic rxMicAudio{0}; + std::atomic rxMalformed{0}; + std::atomic rxUnknownType{0}; + std::atomic rxRunt{0}; + std::atomic rxUnknownToken{0}; + + std::atomic txPackets{0}; + std::atomic txBytes{0}; + std::atomic txHeartbeatAck{0}; + std::atomic txRumble{0}; + std::atomic txLightbar{0}; + std::atomic txTriggerEffects{0}; + std::atomic txPlayerLeds{0}; + std::atomic txSpeakerAudio{0}; + std::atomic txMicLed{0}; + std::atomic txSessionClose{0}; + std::atomic txUnroutable{0}; + std::atomic txEncryptFailed{0}; + std::atomic txOversize{0}; + std::atomic txSendFailed{0}; + + std::atomic authNotPaired{0}; + std::atomic authBadProof{0}; + std::atomic sessionsReaped{0}; + std::atomic peakLoopUs{0}; + + static bool isDispatchedInboundType(uint16_t msgType) { + switch (msgType) { + case MSG_GAMEPAD_DATA: + case MSG_HEARTBEAT_PING: + case MSG_MOTION: + case MSG_BATTERY: + case MSG_TOUCHPAD: + case MSG_MIC_AUDIO: + return true; + default: + return false; + } + } + + void recordInbound(uint16_t msgType, bool handled) { + if (!handled) { + bump(isDispatchedInboundType(msgType) ? rxMalformed : rxUnknownType); + return; + } + switch (msgType) { + case MSG_HEARTBEAT_PING: + bump(rxHeartbeat); + break; + case MSG_MOTION: + bump(rxMotion); + break; + case MSG_BATTERY: + bump(rxBattery); + break; + case MSG_TOUCHPAD: + bump(rxPointer); + break; + case MSG_MIC_AUDIO: + bump(rxMicAudio); + break; + default: + break; + } + } + + void recordOutbound(uint16_t msgType, size_t datagramBytes) { + bump(txPackets); + txBytes.fetch_add(static_cast(datagramBytes), std::memory_order_relaxed); + switch (msgType) { + case MSG_HEARTBEAT_ACK: + bump(txHeartbeatAck); + break; + case MSG_RUMBLE: + bump(txRumble); + break; + case MSG_LIGHTBAR: + bump(txLightbar); + break; + case MSG_TRIGGER_EFFECTS: + bump(txTriggerEffects); + break; + case MSG_PLAYER_LEDS: + bump(txPlayerLeds); + break; + case MSG_SPEAKER_AUDIO: + bump(txSpeakerAudio); + break; + case MSG_MIC_LED: + bump(txMicLed); + break; + case MSG_SESSION_CLOSE: + bump(txSessionClose); + break; + default: + break; + } + } + + WireCounts snapshot() const { + WireCounts c; + c.rx.heartbeat = read(rxHeartbeat); + c.rx.motion = read(rxMotion); + c.rx.battery = read(rxBattery); + c.rx.pointer = read(rxPointer); + c.rx.micAudio = read(rxMicAudio); + c.rx.malformed = read(rxMalformed); + c.rx.unknownType = read(rxUnknownType); + c.rx.runt = read(rxRunt); + c.rx.unknownToken = read(rxUnknownToken); + c.tx.packets = read(txPackets); + c.tx.bytes = read(txBytes); + c.tx.heartbeatAck = read(txHeartbeatAck); + c.tx.rumble = read(txRumble); + c.tx.lightbar = read(txLightbar); + c.tx.triggerEffects = read(txTriggerEffects); + c.tx.playerLeds = read(txPlayerLeds); + c.tx.speakerAudio = read(txSpeakerAudio); + c.tx.micLed = read(txMicLed); + c.tx.sessionClose = read(txSessionClose); + c.tx.unroutable = read(txUnroutable); + c.tx.encryptFailed = read(txEncryptFailed); + c.tx.oversize = read(txOversize); + c.tx.sendFailed = read(txSendFailed); + c.authNotPaired = read(authNotPaired); + c.authBadProof = read(authBadProof); + c.sessionsReaped = read(sessionsReaped); + return c; + } + + uint64_t observePeakLoopUs(uint64_t sampleUs) { + uint64_t prev = peakLoopUs.load(std::memory_order_relaxed); + while (sampleUs > prev && + !peakLoopUs.compare_exchange_weak(prev, sampleUs, std::memory_order_relaxed)) {} + return sampleUs > prev ? sampleUs : prev; + } + + template void forEachCounter(Fn&& fn) { + fn(rxHeartbeat); + fn(rxMotion); + fn(rxBattery); + fn(rxPointer); + fn(rxMicAudio); + fn(rxMalformed); + fn(rxUnknownType); + fn(rxRunt); + fn(rxUnknownToken); + fn(txPackets); + fn(txBytes); + fn(txHeartbeatAck); + fn(txRumble); + fn(txLightbar); + fn(txTriggerEffects); + fn(txPlayerLeds); + fn(txSpeakerAudio); + fn(txMicLed); + fn(txSessionClose); + fn(txUnroutable); + fn(txEncryptFailed); + fn(txOversize); + fn(txSendFailed); + fn(authNotPaired); + fn(authBadProof); + fn(sessionsReaped); + fn(peakLoopUs); + } + + void reset() { + forEachCounter([](std::atomic& c) { c.store(0, std::memory_order_relaxed); }); + } + + private: + static void bump(std::atomic& c) { c.fetch_add(1, std::memory_order_relaxed); } + static uint64_t read(const std::atomic& c) { + return c.load(std::memory_order_relaxed); + } +}; + +inline WireStats g_wire; + +} // namespace satellite diff --git a/src/core/session_service.cpp b/src/core/session_service.cpp index 1b92710..b1a6537 100644 --- a/src/core/session_service.cpp +++ b/src/core/session_service.cpp @@ -903,7 +903,10 @@ bool SessionService::handleSpeakerAudioFromBackend(uint32_t serial, const int16_ // this worker while holding mtx_; blocking here deadlocks. Dropping a // speaker frame is a 20 ms gap the client's PLC already knows how to cover. std::unique_lock lk(mtx_, std::try_to_lock); - if (!lk.owns_lock()) return false; + if (!lk.owns_lock()) { + bumpAudio(audio_.speakerLockContended); + return false; + } Connection* foundConn = nullptr; Controller* foundCtrl = nullptr; @@ -934,6 +937,7 @@ bool SessionService::handleMicAudio(uint32_t token, uint8_t ctrlIdx, uint16_t se Connection& conn = it->second; auto dropOnce = [&](uint8_t cause, const std::string& why) { + bumpAudio(audio_.micDropped); if ((conn.micDropLogged & cause) == 0) { conn.micDropLogged |= cause; log_.logMsg(LogLevel::WARN, "service", @@ -991,7 +995,11 @@ bool SessionService::deliverMicAudioLocked(Controller& ctrl, uint16_t seq, const const AudioJitterWindow::Result pushed = audio.micWindow.push(seq, opus, opusLen); // A frame whose slot has already been played (or concealed past) is worse // than useless: splicing it in now would be an audible jump backwards. - if (pushed.accept != AudioJitterWindow::Accept::Ok) return false; + if (pushed.accept != AudioJitterWindow::Accept::Ok) { + bumpAudio(audio_.micLate); + return false; + } + bumpAudio(audio_.micAccepted); // Taken, but held for reordering. Nothing is due until the frame in front // of it arrives or is proven lost. if (pushed.count == 0) return true; @@ -1014,6 +1022,7 @@ bool SessionService::deliverMicAudioLocked(Controller& ctrl, uint16_t seq, const size_t decoded = 0; if (ev.kind == AudioJitterWindow::Event::Kind::Packet) { decoded = audio.micDecoder->decode(ev.data, ev.len, pcm, AUDIO_FRAME_SAMPLES); + if (decoded > 0) bumpAudio(audio_.micDecoded); } else if (ev.fecCarrier != nullptr) { // The window hands over packet seq+1 precisely because Opus hides a // redundant copy of seq inside it. Order is load-bearing: the FEC @@ -1021,8 +1030,10 @@ bool SessionService::deliverMicAudioLocked(Controller& ctrl, uint16_t seq, const // window emits next. decoded = audio.micDecoder->decodeFec(ev.fecCarrier, ev.fecCarrierLen, pcm, AUDIO_FRAME_SAMPLES); + if (decoded > 0) bumpAudio(audio_.micFecRecovered); } else { decoded = audio.micDecoder->conceal(pcm, AUDIO_FRAME_SAMPLES); + if (decoded > 0) bumpAudio(audio_.micConcealed); } // A backend with no mic endpoint on this serial returns false, which is // not an error: senders keep streaming and the pad simply has nowhere @@ -1043,6 +1054,7 @@ void SessionService::sendSpeakerFrameLocked(Connection& conn, Controller& ctrl, // and the decoder resting on the same last real frame, so neither drifts. if (isDigitalSilence(frame, static_cast(AUDIO_FRAME_SAMPLES) * static_cast(AUDIO_SPEAKER_CHANNELS))) { + bumpAudio(audio_.speakerSilenceSuppressed); return; } @@ -1053,7 +1065,11 @@ void SessionService::sendSpeakerFrameLocked(Connection& conn, Controller& ctrl, // so lets the client conceal a hole instead of silently playing the stream // short and drifting against the game's clock. const uint16_t seq = audio.speakerSeq++; - if (bytes == 0) return; + if (bytes == 0) { + bumpAudio(audio_.speakerEncodeFailed); + return; + } + bumpAudio(audio_.speakerSent); client_.sendSpeakerAudio(conn, ctrl.index, seq, packet, bytes); } @@ -1296,6 +1312,26 @@ int SessionService::reapTimedOut() { bool SessionService::isBackendAvailable() const { return backend_.isBusOpen(); } +int SessionService::activeSessionCount() const { + std::lock_guard lk(mtx_); + return static_cast(connections_.size()); +} + +AudioStreamCounts SessionService::audioCounts() const { + AudioStreamCounts c; + c.micAccepted = audio_.micAccepted.load(std::memory_order_relaxed); + c.micDropped = audio_.micDropped.load(std::memory_order_relaxed); + c.micLate = audio_.micLate.load(std::memory_order_relaxed); + c.micDecoded = audio_.micDecoded.load(std::memory_order_relaxed); + c.micFecRecovered = audio_.micFecRecovered.load(std::memory_order_relaxed); + c.micConcealed = audio_.micConcealed.load(std::memory_order_relaxed); + c.speakerSent = audio_.speakerSent.load(std::memory_order_relaxed); + c.speakerSilenceSuppressed = audio_.speakerSilenceSuppressed.load(std::memory_order_relaxed); + c.speakerEncodeFailed = audio_.speakerEncodeFailed.load(std::memory_order_relaxed); + c.speakerLockContended = audio_.speakerLockContended.load(std::memory_order_relaxed); + return c; +} + #ifdef SATELLITE_BUILD_TESTS void SessionService::backdateForTest(uint32_t token, int lastPacketSecondsAgo, int graceSecondsAgo) { diff --git a/src/core/session_service.h b/src/core/session_service.h index 1dfdef4..8e964ca 100644 --- a/src/core/session_service.h +++ b/src/core/session_service.h @@ -9,6 +9,7 @@ #pragma once #include "ports.h" +#include #include #include #include @@ -248,6 +249,10 @@ class SessionService { }; ConnectionsSnapshot getConnectionsSnapshot() const; + AudioStreamCounts audioCounts() const; + + int activeSessionCount() const; + bool isDeviceConnected(const std::string& deviceId) const; // Per-paired-device link state (server's view): Paired when no live @@ -289,6 +294,21 @@ class SessionService { AudioPolicyFn audioPolicy_; ControllerAudioPolicy audioPolicy() const; + struct AudioCounters { + std::atomic micAccepted{0}; + std::atomic micDropped{0}; + std::atomic micLate{0}; + std::atomic micDecoded{0}; + std::atomic micFecRecovered{0}; + std::atomic micConcealed{0}; + std::atomic speakerSent{0}; + std::atomic speakerSilenceSuppressed{0}; + std::atomic speakerEncodeFailed{0}; + std::atomic speakerLockContended{0}; + }; + AudioCounters audio_; + static void bumpAudio(std::atomic& c) { c.fetch_add(1, std::memory_order_relaxed); } + mutable std::mutex mtx_; // protects connections_, serial state, scan cursor std::unordered_map connections_; bool serialInUse_[MAX_BACKEND_CONTROLLERS] = {}; diff --git a/src/core/types.h b/src/core/types.h index 5e94845..3d7de20 100644 --- a/src/core/types.h +++ b/src/core/types.h @@ -476,6 +476,19 @@ inline const uint8_t MIC_DROP_LOG_NO_CAP = 0x02; inline const uint8_t MIC_DROP_LOG_RATE_LIMIT = 0x04; inline const uint8_t MIC_DROP_LOG_HOST_DISABLED = 0x08; +struct AudioStreamCounts { + uint64_t micAccepted = 0; + uint64_t micDropped = 0; + uint64_t micLate = 0; + uint64_t micDecoded = 0; + uint64_t micFecRecovered = 0; + uint64_t micConcealed = 0; + uint64_t speakerSent = 0; + uint64_t speakerSilenceSuppressed = 0; + uint64_t speakerEncodeFailed = 0; + uint64_t speakerLockContended = 0; +}; + // Motion report (sender to satellite, gyro + accel). Fixed full-scale wire // convention so no downstream renormalisation: // gyro +/-2000 deg/s: int16 LSB = 2000/32767 deg/s diff --git a/src/net/inner_dispatch.cpp b/src/net/inner_dispatch.cpp index 7004798..2ffa0b8 100644 --- a/src/net/inner_dispatch.cpp +++ b/src/net/inner_dispatch.cpp @@ -22,10 +22,12 @@ DispatchResult dispatchInnerMessage(SessionService& svc, uint32_t token, uint16_ memcpy(&report, payload + 1, sizeof(GamepadReport)); result.wasGamepadData = true; result.gamepadOk = svc.handleGamepadData(token, ctrlIdx, report); + result.handled = true; break; } case MSG_HEARTBEAT_PING: svc.handleHeartbeat(token); + result.handled = true; break; // Topology mutation is REST-only: the old registration opcodes no longer @@ -38,6 +40,7 @@ DispatchResult dispatchInnerMessage(SessionService& svc, uint32_t token, uint16_ uint8_t ctrlIdx = payload[0]; MotionReport report = decodeMotionReport(payload + 1); svc.handleMotionData(token, ctrlIdx, report); + result.handled = true; break; } case MSG_BATTERY: { @@ -48,6 +51,7 @@ DispatchResult dispatchInnerMessage(SessionService& svc, uint32_t token, uint16_ report.level = payload[1]; report.status = payload[2]; svc.handleBatteryUpdate(token, ctrlIdx, report); + result.handled = true; break; } case MSG_TOUCHPAD: { @@ -60,6 +64,7 @@ DispatchResult dispatchInnerMessage(SessionService& svc, uint32_t token, uint16_ ? decodeTouchpadReportV2(payload + 1) : decodeTouchpadReportV1(payload + 1); svc.handleTouchpadData(token, ctrlIdx, report); + result.handled = true; break; } case MSG_MIC_AUDIO: { @@ -72,6 +77,7 @@ DispatchResult dispatchInnerMessage(SessionService& svc, uint32_t token, uint16_ AudioFrameHeader hdr = decodeAudioFrameHeader(payload); svc.handleMicAudio(token, hdr.ctrlIdx, hdr.seq, payload + AUDIO_WIRE_HEADER_BYTES, (size_t)(msgLen - AUDIO_WIRE_HEADER_BYTES)); + result.handled = true; break; } default: diff --git a/src/net/inner_dispatch.h b/src/net/inner_dispatch.h index 9e3262c..bbbcf82 100644 --- a/src/net/inner_dispatch.h +++ b/src/net/inner_dispatch.h @@ -14,6 +14,7 @@ class SessionService; struct DispatchResult { bool wasGamepadData = false; bool gamepadOk = false; // only meaningful when wasGamepadData + bool handled = false; }; // Parse one decrypted inner message and delegate to SessionService. `payload` diff --git a/src/net/receiver.cpp b/src/net/receiver.cpp index a379494..3c273a3 100644 --- a/src/net/receiver.cpp +++ b/src/net/receiver.cpp @@ -7,15 +7,22 @@ #include "session_crypto.h" #include "core/session_service.h" #include "adapters/client_adapter.h" +#include "app/wire_stats.h" #ifdef _WIN32 #include // MMCSS: AvSetMmThreadCharacteristics for the RX thread #endif +using satellite::g_wire; + static void reaperLoop(SessionService& svc) { while (g_appRunning) { netSleepMs(1000); - svc.reapTimedOut(); + const int reaped = svc.reapTimedOut(); + if (reaped > 0) { + g_wire.sessionsReaped.fetch_add(static_cast(reaped), + std::memory_order_relaxed); + } } } @@ -91,6 +98,7 @@ void receiverThread(SessionService& svc, ClientAdapter& client) { g_decryptFail.store(0, std::memory_order_relaxed); g_replayDrop.store(0, std::memory_order_relaxed); g_senderIP.store(0); + g_wire.reset(); std::thread reaper(reaperLoop, std::ref(svc)); @@ -112,7 +120,10 @@ void receiverThread(SessionService& svc, ClientAdapter& client) { reinterpret_cast(&sender), &slen); // Minimum packet: header(8) + inner_header(4) + tag(16) = 28 bytes - if (n < HEADER_SIZE + INNER_HEADER_SIZE + AUTH_TAG_SIZE) continue; + if (n < HEADER_SIZE + INNER_HEADER_SIZE + AUTH_TAG_SIZE) { + if (n >= 0) g_wire.rxRunt.fetch_add(1, std::memory_order_relaxed); + continue; + } auto t0 = std::chrono::steady_clock::now(); @@ -124,7 +135,10 @@ void receiverThread(SessionService& svc, ClientAdapter& client) { // Look up connection key (brief lock). uint8_t key[CRYPTO_KEY_SIZE]; uint32_t lastCounter; - if (!svc.getDecryptInfo(token, key, lastCounter)) continue; + if (!svc.getDecryptInfo(token, key, lastCounter)) { + g_wire.rxUnknownToken.fetch_add(1, std::memory_order_relaxed); + continue; + } // Replay protection. if (counter <= lastCounter && lastCounter != 0) { @@ -150,10 +164,16 @@ void receiverThread(SessionService& svc, ClientAdapter& client) { const uint32_t senderIPv4 = sender.sin_addr.s_addr; const uint16_t senderPort = ntohs(sender.sin_port); - if (ptLen < (unsigned long long)INNER_HEADER_SIZE) continue; + if (ptLen < (unsigned long long)INNER_HEADER_SIZE) { + g_wire.rxMalformed.fetch_add(1, std::memory_order_relaxed); + continue; + } uint16_t msgType = ((uint16_t)plaintext[0] << 8) | (uint16_t)plaintext[1]; uint16_t msgLen = ((uint16_t)plaintext[2] << 8) | (uint16_t)plaintext[3]; - if ((size_t)(INNER_HEADER_SIZE + msgLen) > ptLen) continue; + if ((size_t)(INNER_HEADER_SIZE + msgLen) > ptLen) { + g_wire.rxMalformed.fetch_add(1, std::memory_order_relaxed); + continue; + } uint8_t* payload = plaintext + INNER_HEADER_SIZE; // Fast path: MSG_GAMEPAD_DATA hits the fused single-lock entry. @@ -170,6 +190,7 @@ void receiverThread(SessionService& svc, ClientAdapter& client) { } else { svc.updatePostDecryptV4(token, counter, senderIPv4, senderPort); dr = dispatchInnerMessage(svc, token, msgType, payload, msgLen); + g_wire.recordInbound(msgType, dr.handled); } // Hot path only: record loop latency + submit-outcome telemetry. diff --git a/src/net/routes_admin.cpp b/src/net/routes_admin.cpp index 2c39e8c..dfa21a9 100644 --- a/src/net/routes_admin.cpp +++ b/src/net/routes_admin.cpp @@ -563,6 +563,7 @@ void registerAdminRoutes(httplib::Server& server, SessionService& svc) { { std::lock_guard lk(g_configMtx); f.udpPort = g_config.udpPort; + f.webPort = g_config.webPort; } f.listening = g_listening.load(); f.packets = static_cast(g_packetCount.load()); @@ -570,9 +571,23 @@ void registerAdminRoutes(httplib::Server& server, SessionService& svc) { f.submitFail = static_cast(g_submitFail.load()); f.lastLoopUs = static_cast(g_lastLoopUs.load()); f.maxLoopUs = maxUs; + f.peakLoopUs = satellite::g_wire.observePeakLoopUs(maxUs); f.senderIP = senderIP; f.decryptFail = static_cast(g_decryptFail.load()); f.replayDrop = static_cast(g_replayDrop.load()); + f.mdnsResponderActive = g_mdnsResponderActive.load(); + f.clientApiListening = (g_clientServer != nullptr); + f.connections = svc.activeSessionCount(); + f.controllers = svc.totalActiveControllers(); + f.maxControllers = MAX_BACKEND_CONTROLLERS; + const satellite::WireCounts w = satellite::g_wire.snapshot(); + f.rx = w.rx; + f.rx.input = f.submitOk + f.submitFail; + f.tx = w.tx; + f.authNotPaired = w.authNotPaired; + f.authBadProof = w.authBadProof; + f.sessionsReaped = w.sessionsReaped; + f.audio = svc.audioCounts(); res.set_content(buildDebugJson(f), "application/json"); }); diff --git a/src/net/routes_client.cpp b/src/net/routes_client.cpp index ae819c3..acf2be8 100644 --- a/src/net/routes_client.cpp +++ b/src/net/routes_client.cpp @@ -17,6 +17,7 @@ #include "core/json.h" #include "core/session_service.h" #include "core/version.h" +#include "app/wire_stats.h" #include @@ -85,6 +86,11 @@ static bool clientAuthed(const httplib::Request& req, httplib::Response& res, Cl } } + if (std::string(code) == "BAD_PROOF") { + satellite::g_wire.authBadProof.fetch_add(1, std::memory_order_relaxed); + } else { + satellite::g_wire.authNotPaired.fetch_add(1, std::memory_order_relaxed); + } logMsg(LogLevel::WARN, "client", "401 unauthorized " + req.method + " " + req.path + " (" + code + (out.deviceId.empty() ? ", no deviceId supplied" : ", deviceId " + out.deviceId) + diff --git a/src/net/status_json.h b/src/net/status_json.h index 4263317..20476ca 100644 --- a/src/net/status_json.h +++ b/src/net/status_json.h @@ -2,6 +2,7 @@ #pragma once #include "core/json.h" +#include "app/wire_stats.h" #include #include @@ -29,6 +30,17 @@ struct StatusFields { uint64_t decryptFail = 0; uint64_t replayDrop = 0; uint64_t logSeq = 0; + uint64_t peakLoopUs = 0; + bool clientApiListening = false; + int connections = 0; + int controllers = 0; + int maxControllers = 0; + RxCounts rx; + TxCounts tx; + AudioStreamCounts audio; + uint64_t authNotPaired = 0; + uint64_t authBadProof = 0; + uint64_t sessionsReaped = 0; JsonOut backend; }; @@ -59,12 +71,69 @@ inline std::string buildDebugJson(const StatusFields& f) { j["submitFail"] = f.submitFail; j["lastLoopUs"] = f.lastLoopUs; j["maxLoopUs"] = f.maxLoopUs; + j["peakLoopUs"] = f.peakLoopUs; j["senderIP"] = f.senderIP; j["udpPort"] = f.udpPort; + j["webPort"] = f.webPort; j["decryptFail"] = f.decryptFail; j["replayDrop"] = f.replayDrop; j["backendAvailable"] = f.backendAvailable; j["backend"] = f.backend; + j["mdnsResponderActive"] = f.mdnsResponderActive; + j["clientApiListening"] = f.clientApiListening; + j["connections"] = f.connections; + j["controllers"] = f.controllers; + j["maxControllers"] = f.maxControllers; + + JsonOut rx; + rx["input"] = f.rx.input; + rx["heartbeat"] = f.rx.heartbeat; + rx["motion"] = f.rx.motion; + rx["battery"] = f.rx.battery; + rx["pointer"] = f.rx.pointer; + rx["micAudio"] = f.rx.micAudio; + rx["malformed"] = f.rx.malformed; + rx["unknownType"] = f.rx.unknownType; + rx["runt"] = f.rx.runt; + rx["unknownToken"] = f.rx.unknownToken; + j["rx"] = std::move(rx); + + JsonOut tx; + tx["packets"] = f.tx.packets; + tx["bytes"] = f.tx.bytes; + tx["heartbeatAck"] = f.tx.heartbeatAck; + tx["rumble"] = f.tx.rumble; + tx["lightbar"] = f.tx.lightbar; + tx["triggerEffects"] = f.tx.triggerEffects; + tx["playerLeds"] = f.tx.playerLeds; + tx["speakerAudio"] = f.tx.speakerAudio; + tx["micLed"] = f.tx.micLed; + tx["sessionClose"] = f.tx.sessionClose; + tx["unroutable"] = f.tx.unroutable; + tx["encryptFailed"] = f.tx.encryptFailed; + tx["oversize"] = f.tx.oversize; + tx["sendFailed"] = f.tx.sendFailed; + j["tx"] = std::move(tx); + + JsonOut audio; + audio["micAccepted"] = f.audio.micAccepted; + audio["micDropped"] = f.audio.micDropped; + audio["micLate"] = f.audio.micLate; + audio["micDecoded"] = f.audio.micDecoded; + audio["micFecRecovered"] = f.audio.micFecRecovered; + audio["micConcealed"] = f.audio.micConcealed; + audio["speakerSent"] = f.audio.speakerSent; + audio["speakerSilenceSuppressed"] = f.audio.speakerSilenceSuppressed; + audio["speakerEncodeFailed"] = f.audio.speakerEncodeFailed; + audio["speakerLockContended"] = f.audio.speakerLockContended; + j["audio"] = std::move(audio); + + JsonOut auth; + auth["notPaired"] = f.authNotPaired; + auth["badProof"] = f.authBadProof; + j["auth"] = std::move(auth); + + j["sessionsReaped"] = f.sessionsReaped; return jsonDump(j); } diff --git a/tests/test_receiver.cpp b/tests/test_receiver.cpp index 347627a..c6336f5 100644 --- a/tests/test_receiver.cpp +++ b/tests/test_receiver.cpp @@ -363,6 +363,50 @@ static void test_dispatch_micAudio_unknownSlotAndToken() { EXPECT_EQ(lg.countContaining("Mic audio"), 1); // still just the slot line } +static void test_dispatch_handledFlagSplitsMalformedFromUnknown() { + TEST("dispatchInnerMessage: a well-formed frame of every dispatched type reports handled"); + StubGamepad gp; + StubClient cl; + StubLog lg; + SessionService svc(gp, cl, lg); + uint32_t token = openWithController(svc); + + EXPECT(dispatchTight(svc, token, MSG_HEARTBEAT_PING, {}).handled); + EXPECT(dispatchTight(svc, token, MSG_GAMEPAD_DATA, std::vector(13, 0)).handled); + EXPECT(dispatchTight(svc, token, MSG_MOTION, + std::vector(1 + MOTION_WIRE_PAYLOAD_BYTES, 0)) + .handled); + EXPECT(dispatchTight(svc, token, MSG_BATTERY, std::vector(3, 0)).handled); + EXPECT(dispatchTight(svc, token, MSG_TOUCHPAD, + std::vector(1 + TOUCHPAD_WIRE_PAYLOAD_BYTES_V1, 0)) + .handled); + + TEST("dispatchInnerMessage: handled means parsed and delivered, not accepted by policy"); + // The slot advertised no CAP_MIC, so the service drops the frame -- but the + // frame itself was well formed, which is the distinction the caller counts. + EXPECT(dispatchTight(svc, token, MSG_MIC_AUDIO, micFrame(0, 1, 8)).handled); + + TEST("dispatchInnerMessage: every length-guard rejection reports not handled"); + EXPECT(!dispatchTight(svc, token, MSG_GAMEPAD_DATA, std::vector(12, 0)).handled); + EXPECT( + !dispatchTight(svc, token, MSG_MOTION, std::vector(MOTION_WIRE_PAYLOAD_BYTES, 0)) + .handled); + EXPECT(!dispatchTight(svc, token, MSG_BATTERY, std::vector(2, 0)).handled); + EXPECT(!dispatchTight(svc, token, MSG_TOUCHPAD, + std::vector(TOUCHPAD_WIRE_PAYLOAD_BYTES_V1, 0)) + .handled); + for (int len = 0; len < AUDIO_WIRE_MIN_PAYLOAD_BYTES; ++len) { + EXPECT(!dispatchTight(svc, token, MSG_MIC_AUDIO, std::vector((size_t)len, 0)) + .handled); + } + + TEST("dispatchInnerMessage: unknown and deleted opcodes report not handled"); + for (uint16_t t : {(uint16_t)0x0004, (uint16_t)0x0005, (uint16_t)0x0008, (uint16_t)0x000E, + (uint16_t)0x7FFF}) { + EXPECT(!dispatchTight(svc, token, t, std::vector(40, 0)).handled); + } +} + static void test_dispatch_motion_truncatedRejected() { TEST("dispatchInnerMessage: truncated MSG_MOTION is rejected (no decode)"); StubGamepad gp; @@ -627,6 +671,7 @@ int main() { test_isDigitalSilence(); test_micAudioWireConstants(); + test_dispatch_handledFlagSplitsMalformedFromUnknown(); test_dispatch_micAudio_truncatedRejected(); test_dispatch_micAudio_reachesServiceAndRateLimits(); test_dispatch_micAudio_unknownSlotAndToken(); diff --git a/tests/test_session_service.cpp b/tests/test_session_service.cpp index 5b4eecd..7a8939a 100644 --- a/tests/test_session_service.cpp +++ b/tests/test_session_service.cpp @@ -2724,6 +2724,84 @@ static void test_micAudio_unboundSlotSubmitsNothing() { EXPECT_EQ(vigem.submitMicAudioCalls, 6); } +static void test_audioCounts_trackTheMicStream() { + TEST("audio counts: accepted, decoded, FEC-recovered and late all agree with the backend"); + MockViGem vigem; + MockClient client; + MockLog log; + satellite::audio::OpusCodecFactory codecs; + SessionService svc(vigem, client, log, {}, &codecs); + + auto r = upsert(svc, {makeDesc(0, CONTROLLER_TYPE_DUALSENSE, CAP_MIC)}); + MicPacketSource mic; + std::vector> pkts; + for (int i = 0; i < 12; i++) pkts.push_back(mic.next()); + + for (int i = 0; i < 4; i++) { + EXPECT(svc.handleMicAudio(r.token, 0, (uint16_t)i, pkts[i].data(), pkts[i].size())); + } + auto c = svc.audioCounts(); + EXPECT_EQ(c.micAccepted, (uint64_t)4); + EXPECT_EQ(c.micDecoded, (uint64_t)4); + EXPECT_EQ(c.micFecRecovered, (uint64_t)0); + EXPECT_EQ(c.micConcealed, (uint64_t)0); + EXPECT_EQ(c.micLate, (uint64_t)0); + + EXPECT(svc.handleMicAudio(r.token, 0, 5, pkts[5].data(), pkts[5].size())); + c = svc.audioCounts(); + EXPECT_EQ(c.micAccepted, (uint64_t)5); + EXPECT_EQ(c.micDecoded, (uint64_t)4); + + EXPECT(svc.handleMicAudio(r.token, 0, 6, pkts[6].data(), pkts[6].size())); + c = svc.audioCounts(); + EXPECT_EQ(c.micAccepted, (uint64_t)6); + EXPECT_EQ(c.micFecRecovered, (uint64_t)1); + EXPECT_EQ(c.micDecoded, (uint64_t)6); + EXPECT_EQ(c.micConcealed, (uint64_t)0); + EXPECT_EQ(c.micDecoded + c.micFecRecovered + c.micConcealed, + (uint64_t)vigem.submitMicAudioCalls); + + EXPECT(!svc.handleMicAudio(r.token, 0, 4, pkts[4].data(), pkts[4].size())); + c = svc.audioCounts(); + EXPECT_EQ(c.micLate, (uint64_t)1); + EXPECT_EQ(c.micAccepted, (uint64_t)6); + + TEST("audio counts: a gate rejection is a drop, never an accept"); + EXPECT(!svc.handleMicAudio(r.token, 1, 0, pkts[0].data(), pkts[0].size())); + c = svc.audioCounts(); + EXPECT_EQ(c.micDropped, (uint64_t)1); + EXPECT_EQ(c.micAccepted, (uint64_t)6); +} + +static void test_audioCounts_separateSuppressedSilenceFromSentFrames() { + TEST("audio counts: suppressed silence is counted, not sent"); + MockViGem vigem; + MockClient client; + MockLog log; + satellite::audio::OpusCodecFactory codecs; + SessionService svc(vigem, client, log, {}, &codecs); + + upsert(svc, {makeDesc(0, CONTROLLER_TYPE_DUALSENSE, CAP_SPEAKER)}); + const uint32_t serial0 = serialOfSlot(svc, 0); + + const std::vector silence(AUDIO_FRAME_SAMPLES * AUDIO_SPEAKER_CHANNELS, 0); + for (int i = 0; i < 50; i++) { + EXPECT(svc.handleSpeakerAudioFromBackend(serial0, silence.data(), AUDIO_FRAME_SAMPLES)); + } + auto c = svc.audioCounts(); + EXPECT_EQ(c.speakerSilenceSuppressed, (uint64_t)50); + EXPECT_EQ(c.speakerSent, (uint64_t)0); + EXPECT_EQ((uint64_t)client.speakerAudioCalls, c.speakerSent); + + const auto tone = speakerPcm(AUDIO_FRAME_SAMPLES, 0); + EXPECT(svc.handleSpeakerAudioFromBackend(serial0, tone.data(), AUDIO_FRAME_SAMPLES)); + c = svc.audioCounts(); + EXPECT_EQ(c.speakerSent, (uint64_t)1); + EXPECT_EQ(c.speakerSilenceSuppressed, (uint64_t)50); + EXPECT_EQ(c.speakerEncodeFailed, (uint64_t)0); + EXPECT_EQ((uint64_t)client.speakerAudioCalls, c.speakerSent); +} + static void test_speakerAudio_oneWindowBecomesOneWirePacket() { TEST("speaker audio: a 20 ms window becomes one decodable Opus packet on the wire"); MockViGem vigem; @@ -3265,6 +3343,8 @@ int main() { test_speakerAudio_seqIsPerControllerAndWraps(); test_speakerAudio_encoderStateDiesWithThePad(); test_speakerAudio_withoutCodecSendsNothing(); + test_audioCounts_trackTheMicStream(); + test_audioCounts_separateSuppressedSilenceFromSentFrames(); test_audioBackendCallbacks_dropNotBlock_whenLockHeld(); test_feedback_replugResetsCoalesce(); test_backendCallbacks_dropNotBlock_whenLockHeld(); diff --git a/tests/test_status_json.cpp b/tests/test_status_json.cpp index 2c23447..8e495d7 100644 --- a/tests/test_status_json.cpp +++ b/tests/test_status_json.cpp @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later +// SPDX-License-Identifier: LGPL-3.0-or-later #include "../src/net/status_json.h" #include @@ -32,6 +32,17 @@ static StatusFields makeFields() { f.decryptFail = 3; f.replayDrop = 5; f.logSeq = 42; + f.peakLoopUs = 9100; + f.clientApiListening = true; + f.connections = 2; + f.controllers = 3; + f.maxControllers = 16; + f.rx = {1007, 60, 900, 4, 120, 500, 2, 1, 8, 9}; + f.tx = {600, 48000, 60, 12, 4, 2, 1, 520, 3, 1, 6, 0, 0, 11}; + f.audio = {500, 2, 1, 498, 1, 1, 520, 40, 0, 3}; + f.authNotPaired = 4; + f.authBadProof = 2; + f.sessionsReaped = 5; JsonOut backend; backend["kind"] = "vigem"; @@ -103,15 +114,45 @@ static void test_status_carries_the_audio_directions() { EXPECT(jsonDump(buildSseStatusObject(f)).find("controllerAudioSpeaker") == std::string::npos); } +static void test_counterBlocksStayOffTheHotSurfaces() { + TEST("the rx/tx/audio/auth blocks ride /api/debug only"); + StatusFields f = makeFields(); + const std::string status = buildStatusJson(f); + const std::string sse = jsonDump(buildSseStatusObject(f)); + for (const char* key : {"\"rx\"", "\"tx\"", "\"audio\"", "\"auth\"", "peakLoopUs", + "clientApiListening", "sessionsReaped", "maxControllers"}) { + EXPECT(status.find(key) == std::string::npos); + EXPECT(sse.find(key) == std::string::npos); + } + + TEST("buildDebugJson carries every block"); + const std::string dbg = buildDebugJson(f); + for (const char* key : {"\"rx\"", "\"tx\"", "\"audio\"", "\"auth\"", "peakLoopUs", + "clientApiListening", "sessionsReaped", "maxControllers", "webPort"}) { + EXPECT(dbg.find(key) != std::string::npos); + } +} + static void test_debug_exact_shape() { TEST("buildDebugJson: exact JSON shape and field order"); std::string s = buildDebugJson(makeFields()); EXPECT_EQ( - s, - std::string(R"({"listening":true,"packets":12345,"submitOk":1000,"submitFail":7,)" - R"("lastLoopUs":250,"maxLoopUs":9001,"senderIP":"192.168.1.42","udpPort":9876,)" - R"("decryptFail":3,"replayDrop":5,"backendAvailable":true,)" - R"("backend":{"kind":"vigem","available":true}})")); + s, std::string(R"({"listening":true,"packets":12345,"submitOk":1000,"submitFail":7,)" + R"("lastLoopUs":250,"maxLoopUs":9001,"peakLoopUs":9100,"senderIP":"192.168.)" + R"(1.42","udpPort":9876,"webPort":9871,"decryptFail":3,"replayDrop":5,)" + R"("backendAvailable":true,"backend":{"kind":"vigem","available":true},)" + R"("mdnsResponderActive":true,"clientApiListening":true,"connections":2,)" + R"("controllers":3,"maxControllers":16,"rx":{"input":1007,)" + R"("heartbeat":60,"motion":900,"battery":4,"pointer":120,"micAudio":500,)" + R"("malformed":2,"unknownType":1,"runt":8,"unknownToken":9},)" + R"("tx":{"packets":600,"bytes":48000,"heartbeatAck":60,"rumble":12,)" + R"("lightbar":4,"triggerEffects":2,"playerLeds":1,"speakerAudio":520,)" + R"("micLed":3,"sessionClose":1,"unroutable":6,"encryptFailed":0,)" + R"("oversize":0,"sendFailed":11},"audio":{"micAccepted":500,)" + R"("micDropped":2,"micLate":1,"micDecoded":498,"micFecRecovered":1,)" + R"("micConcealed":1,"speakerSent":520,"speakerSilenceSuppressed":40,)" + R"("speakerEncodeFailed":0,"speakerLockContended":3},"auth":{"notPaired":4,)" + R"("badProof":2},"sessionsReaped":5})")); } static void test_sse_exact_shape() { @@ -133,6 +174,7 @@ int main() { test_status_carries_the_audio_directions(); test_debug_exact_shape(); test_sse_exact_shape(); + test_counterBlocksStayOffTheHotSurfaces(); std::cout << "\n=== Test Results ===\n"; std::cout << " Passed: " << g_pass << "\n"; diff --git a/tests/test_wire_stats.cpp b/tests/test_wire_stats.cpp new file mode 100644 index 0000000..1d336af --- /dev/null +++ b/tests/test_wire_stats.cpp @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +#include "../src/app/wire_stats.h" + +#include +#include + +#include "test_util.h" + +using satellite::WireCounts; +using satellite::WireStats; + +static uint64_t totalOf(WireStats& w) { + uint64_t sum = 0; + w.forEachCounter([&sum](std::atomic& c) { sum += c.load(); }); + return sum; +} + +static int counterCount(WireStats& w) { + int n = 0; + w.forEachCounter([&n](std::atomic&) { n++; }); + return n; +} + +static void test_resetCoversEveryCounter() { + TEST("reset() zeroes every counter the struct declares"); + WireStats w; + w.forEachCounter([](std::atomic& c) { c.store(7); }); + const int fields = counterCount(w); + EXPECT(fields > 0); + EXPECT_EQ(totalOf(w), (uint64_t)(7 * fields)); + w.reset(); + EXPECT_EQ(totalOf(w), (uint64_t)0); +} + +struct TypeSlot { + uint16_t type; + const char* name; + std::atomic WireStats::* slot; +}; + +static void test_inboundTypeMapping() { + const TypeSlot cases[] = { + {MSG_HEARTBEAT_PING, "heartbeat", &WireStats::rxHeartbeat}, + {MSG_MOTION, "motion", &WireStats::rxMotion}, + {MSG_BATTERY, "battery", &WireStats::rxBattery}, + {MSG_TOUCHPAD, "pointer", &WireStats::rxPointer}, + {MSG_MIC_AUDIO, "micAudio", &WireStats::rxMicAudio}, + }; + for (const auto& c : cases) { + TEST(std::string("recordInbound: an accepted ") + c.name + " lands in exactly one slot"); + WireStats w; + w.recordInbound(c.type, true); + EXPECT_EQ((w.*(c.slot)).load(), (uint64_t)1); + EXPECT_EQ(totalOf(w), (uint64_t)1); + } +} + +static void test_inboundGamepadIsLeftToTheHotPath() { + TEST("recordInbound: accepted input frames move nothing (submitOk/submitFail own them)"); + WireStats w; + w.recordInbound(MSG_GAMEPAD_DATA, true); + EXPECT_EQ(totalOf(w), (uint64_t)0); +} + +static void test_inboundRejectionSplit() { + TEST("recordInbound: a known opcode that failed its length guard is malformed"); + const uint16_t known[] = {MSG_GAMEPAD_DATA, MSG_HEARTBEAT_PING, MSG_MOTION, + MSG_BATTERY, MSG_TOUCHPAD, MSG_MIC_AUDIO}; + for (uint16_t t : known) { + WireStats w; + w.recordInbound(t, false); + EXPECT_EQ(w.rxMalformed.load(), (uint64_t)1); + EXPECT_EQ(totalOf(w), (uint64_t)1); + } + + TEST("recordInbound: an unrecognised opcode is not malformed"); + const uint16_t unknown[] = {0x0000, 0x0004, 0x0005, 0x0008, 0x000E, 0x0099, 0x7FFF, 0xFFFF}; + for (uint16_t t : unknown) { + WireStats w; + w.recordInbound(t, false); + EXPECT_EQ(w.rxUnknownType.load(), (uint64_t)1); + EXPECT_EQ(w.rxMalformed.load(), (uint64_t)0); + EXPECT_EQ(totalOf(w), (uint64_t)1); + } + + TEST("recordInbound: the deleted registration opcodes stay unknown, never malformed"); + EXPECT(!WireStats::isDispatchedInboundType(0x0004)); + EXPECT(!WireStats::isDispatchedInboundType(0x0005)); + EXPECT(!WireStats::isDispatchedInboundType(0x0008)); + EXPECT(!WireStats::isDispatchedInboundType(0x000E)); +} + +static void test_outboundTypeMapping() { + const TypeSlot cases[] = { + {MSG_HEARTBEAT_ACK, "heartbeatAck", &WireStats::txHeartbeatAck}, + {MSG_RUMBLE, "rumble", &WireStats::txRumble}, + {MSG_LIGHTBAR, "lightbar", &WireStats::txLightbar}, + {MSG_TRIGGER_EFFECTS, "triggerEffects", &WireStats::txTriggerEffects}, + {MSG_PLAYER_LEDS, "playerLeds", &WireStats::txPlayerLeds}, + {MSG_SPEAKER_AUDIO, "speakerAudio", &WireStats::txSpeakerAudio}, + {MSG_MIC_LED, "micLed", &WireStats::txMicLed}, + {MSG_SESSION_CLOSE, "sessionClose", &WireStats::txSessionClose}, + }; + for (const auto& c : cases) { + TEST(std::string("recordOutbound: ") + c.name + " counts once, with its datagram bytes"); + WireStats w; + w.recordOutbound(c.type, 40); + EXPECT_EQ((w.*(c.slot)).load(), (uint64_t)1); + EXPECT_EQ(w.txPackets.load(), (uint64_t)1); + EXPECT_EQ(w.txBytes.load(), (uint64_t)40); + EXPECT_EQ(totalOf(w), (uint64_t)(1 + 1 + 40)); + } + + TEST("recordOutbound: every message the client adapter sends has its own slot"); + WireStats w; + const size_t n = sizeof(cases) / sizeof(cases[0]); + for (const auto& c : cases) w.recordOutbound(c.type, 0); + EXPECT_EQ(w.txPackets.load(), (uint64_t)n); + EXPECT_EQ(totalOf(w), (uint64_t)(2 * n)); +} + +static void test_peakLoopSurvivesTheWindowedRead() { + TEST("observePeakLoopUs: holds the peak across the maxLoopUs exchange that feeds it"); + WireStats w; + EXPECT_EQ(w.observePeakLoopUs(120), (uint64_t)120); + EXPECT_EQ(w.observePeakLoopUs(0), (uint64_t)120); + EXPECT_EQ(w.observePeakLoopUs(90), (uint64_t)120); + EXPECT_EQ(w.observePeakLoopUs(400), (uint64_t)400); + EXPECT_EQ(w.observePeakLoopUs(0), (uint64_t)400); + + TEST("observePeakLoopUs: a rebind's reset clears the peak with everything else"); + w.reset(); + EXPECT_EQ(w.observePeakLoopUs(0), (uint64_t)0); +} + +static void test_snapshotMirrorsTheCounters() { + TEST("snapshot(): every field carried across, none crossed over"); + WireStats w; + w.recordInbound(MSG_MOTION, true); + w.recordInbound(0x7FFF, false); + w.recordOutbound(MSG_RUMBLE, 33); + w.rxRunt.store(4); + w.rxUnknownToken.store(5); + w.txSendFailed.store(6); + w.txUnroutable.store(7); + w.authNotPaired.store(8); + w.authBadProof.store(9); + w.sessionsReaped.store(10); + + const WireCounts c = w.snapshot(); + EXPECT_EQ(c.rx.motion, (uint64_t)1); + EXPECT_EQ(c.rx.unknownType, (uint64_t)1); + EXPECT_EQ(c.rx.heartbeat, (uint64_t)0); + EXPECT_EQ(c.rx.runt, (uint64_t)4); + EXPECT_EQ(c.rx.unknownToken, (uint64_t)5); + EXPECT_EQ(c.tx.rumble, (uint64_t)1); + EXPECT_EQ(c.tx.packets, (uint64_t)1); + EXPECT_EQ(c.tx.bytes, (uint64_t)33); + EXPECT_EQ(c.tx.sendFailed, (uint64_t)6); + EXPECT_EQ(c.tx.unroutable, (uint64_t)7); + EXPECT_EQ(c.authNotPaired, (uint64_t)8); + EXPECT_EQ(c.authBadProof, (uint64_t)9); + EXPECT_EQ(c.sessionsReaped, (uint64_t)10); + EXPECT_EQ(c.rx.input, (uint64_t)0); +} + +int main() { + std::cout << "Running wire stats tests...\n\n"; + test_resetCoversEveryCounter(); + test_inboundTypeMapping(); + test_inboundGamepadIsLeftToTheHotPath(); + test_inboundRejectionSplit(); + test_outboundTypeMapping(); + test_peakLoopSurvivesTheWindowedRead(); + test_snapshotMirrorsTheCounters(); + + std::cout << "\n=== Test Results ===\n"; + std::cout << " Passed: " << g_pass << "\n"; + std::cout << " Failed: " << g_fail << "\n"; + if (g_fail > 0) { + std::cout << " STATUS: FAIL\n"; + return 1; + } + std::cout << " STATUS: ALL PASSED\n"; + return 0; +} diff --git a/web/debug.js b/web/debug.js index d29b4ee..e66b735 100644 --- a/web/debug.js +++ b/web/debug.js @@ -1,158 +1,438 @@ let debugTimer = null; +let debugBackendTimer = null; let prevSnap = null; let prevTime = null; -const rateHistory = []; +const rxHistory = []; +const txHistory = []; const MAX_HISTORY = 60; +let debugRowsBuilt = false; +let lastDebugBackends = null; -// Pull theme tokens from CSS so chart-bar colors track style.css. -function themeColor(name) { - return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); +const DEBUG_POLL_MS = 500; +const DEBUG_BACKEND_POLL_MS = 10000; + +function dashIfNull(v) { + return (v === null || v === undefined) ? '—' : v; +} + +function count(v) { + return (typeof v === 'number') ? v.toLocaleString() : '—'; +} + +function countCls(v, cls) { + if (typeof v !== 'number') return { text: '—', cls: '' }; + return { text: v.toLocaleString(), cls: v > 0 ? cls : 'debug-ok' }; +} + +function bytesText(v) { + if (typeof v !== 'number') return '—'; + if (v < 1024) return v + ' B'; + if (v < 1024 * 1024) return (v / 1024).toFixed(1) + ' KB'; + if (v < 1024 * 1024 * 1024) return (v / (1024 * 1024)).toFixed(2) + ' MB'; + return (v / (1024 * 1024 * 1024)).toFixed(2) + ' GB'; +} + +function usText(v) { + return (typeof v === 'number') ? v.toLocaleString() + ' µs' : '—'; +} + +function boolText(v) { + if (typeof v !== 'boolean') return '—'; + return v ? t('debug.value.yes') : t('debug.value.no'); +} + +function boolRow(v, goodWhenTrue) { + if (typeof v !== 'boolean') return { text: '—', cls: '' }; + const good = goodWhenTrue ? v : !v; + return { text: boolText(v), cls: good ? 'debug-ok' : 'debug-warn' }; +} + +function loopCls(v) { + if (typeof v !== 'number') return ''; + if (v > 1000) return 'debug-err'; + if (v > 500) return 'debug-warn'; + return 'debug-ok'; +} + +function sub(d, group) { + return (d && typeof d[group] === 'object' && d[group] !== null) ? d[group] : {}; +} + +const DEBUG_GROUPS = [ + { + title: 'debug.section.inbound', + rows: [ + { id: 'rx-rate', k: 'debug.rx.rate', v: (d, r) => r.rxPps + ' pps' }, + { id: 'rx-total', k: 'debug.stats.packets-received', v: d => count(d.packets) }, + { id: 'rx-input', k: 'debug.rx.input', v: d => count(sub(d, 'rx').input) }, + { id: 'rx-heartbeat', k: 'debug.rx.heartbeat', v: d => count(sub(d, 'rx').heartbeat) }, + { id: 'rx-motion', k: 'debug.rx.motion', v: d => count(sub(d, 'rx').motion) }, + { id: 'rx-battery', k: 'debug.rx.battery', v: d => count(sub(d, 'rx').battery) }, + { id: 'rx-pointer', k: 'debug.rx.pointer', v: d => count(sub(d, 'rx').pointer) }, + { id: 'rx-mic', k: 'debug.rx.mic', v: d => count(sub(d, 'rx').micAudio) }, + ], + }, + { + title: 'debug.section.outbound', + rows: [ + { id: 'tx-rate', k: 'debug.tx.rate', v: (d, r) => r.txPps + ' pps' }, + { id: 'tx-total', k: 'debug.tx.total', v: d => count(sub(d, 'tx').packets) }, + { id: 'tx-bytes', k: 'debug.tx.bytes', v: d => bytesText(sub(d, 'tx').bytes) }, + { id: 'tx-ack', k: 'debug.tx.heartbeat-ack', v: d => count(sub(d, 'tx').heartbeatAck) }, + { id: 'tx-rumble', k: 'debug.tx.rumble', v: d => count(sub(d, 'tx').rumble) }, + { id: 'tx-lightbar', k: 'debug.tx.lightbar', v: d => count(sub(d, 'tx').lightbar) }, + { id: 'tx-trigger', k: 'debug.tx.trigger-effects', + v: d => count(sub(d, 'tx').triggerEffects) }, + { id: 'tx-leds', k: 'debug.tx.player-leds', v: d => count(sub(d, 'tx').playerLeds) }, + { id: 'tx-speaker', k: 'debug.tx.speaker', v: d => count(sub(d, 'tx').speakerAudio) }, + { id: 'tx-micled', k: 'debug.tx.mic-led', v: d => count(sub(d, 'tx').micLed) }, + { id: 'tx-close', k: 'debug.tx.session-close', v: d => count(sub(d, 'tx').sessionClose) }, + ], + }, + { + title: 'debug.section.latency', + rows: [ + { id: 'last-loop', k: 'debug.stats.last-loop', + v: d => ({ text: usText(d.lastLoopUs), cls: loopCls(d.lastLoopUs) }) }, + { id: 'peak-loop', k: 'debug.stats.max-loop', + v: d => ({ text: usText(d.peakLoopUs), cls: loopCls(d.peakLoopUs) }) }, + { id: 'submit-ok', k: 'debug.stats.submitted-ok', + v: d => ({ text: count(d.submitOk), cls: 'debug-ok' }) }, + { id: 'submit-fail', k: 'debug.stats.submit-errors', + v: d => countCls(d.submitFail, 'debug-err') }, + { id: 'drop-rate', k: 'debug.stats.drop-rate', v: d => { + const total = (d.submitOk || 0) + (d.submitFail || 0); + const pct = total > 0 ? ((d.submitFail / total) * 100) : 0; + return { text: pct.toFixed(2) + '%', cls: d.submitFail > 0 ? 'debug-err' : 'debug-ok' }; + } }, + ], + }, + { + title: 'debug.section.audio', + rows: [ + { id: 'au-mic-ok', k: 'debug.audio.mic-accepted', + v: d => count(sub(d, 'audio').micAccepted) }, + { id: 'au-mic-dec', k: 'debug.audio.mic-decoded', + v: d => count(sub(d, 'audio').micDecoded) }, + { id: 'au-mic-fec', k: 'debug.audio.mic-fec', + v: d => countCls(sub(d, 'audio').micFecRecovered, 'debug-warn') }, + { id: 'au-mic-conceal', k: 'debug.audio.mic-concealed', + v: d => countCls(sub(d, 'audio').micConcealed, 'debug-warn') }, + { id: 'au-mic-late', k: 'debug.audio.mic-late', + v: d => countCls(sub(d, 'audio').micLate, 'debug-warn') }, + { id: 'au-mic-drop', k: 'debug.audio.mic-dropped', + v: d => countCls(sub(d, 'audio').micDropped, 'debug-err') }, + { id: 'au-spk-sent', k: 'debug.audio.speaker-sent', + v: d => count(sub(d, 'audio').speakerSent) }, + { id: 'au-spk-silence', k: 'debug.audio.speaker-silence', + v: d => count(sub(d, 'audio').speakerSilenceSuppressed) }, + { id: 'au-spk-encfail', k: 'debug.audio.speaker-encode-fail', + v: d => countCls(sub(d, 'audio').speakerEncodeFailed, 'debug-err') }, + { id: 'au-spk-lock', k: 'debug.audio.speaker-contended', + v: d => countCls(sub(d, 'audio').speakerLockContended, 'debug-warn') }, + ], + }, + { + title: 'debug.section.rejected', + rows: [ + { id: 'rj-decrypt', k: 'debug.stats.decrypt-failures', + v: d => countCls(d.decryptFail, 'debug-err') }, + { id: 'rj-replay', k: 'debug.stats.replay-drops', + v: d => countCls(d.replayDrop, 'debug-warn') }, + { id: 'rj-malformed', k: 'debug.rx.malformed', + v: d => countCls(sub(d, 'rx').malformed, 'debug-err') }, + { id: 'rj-unknown-type', k: 'debug.rx.unknown-type', + v: d => countCls(sub(d, 'rx').unknownType, 'debug-warn') }, + { id: 'rj-runt', k: 'debug.rx.runt', v: d => countCls(sub(d, 'rx').runt, 'debug-warn') }, + { id: 'rj-token', k: 'debug.rx.unknown-token', + v: d => countCls(sub(d, 'rx').unknownToken, 'debug-warn') }, + { id: 'rj-tx-route', k: 'debug.tx.unroutable', + v: d => countCls(sub(d, 'tx').unroutable, 'debug-warn') }, + { id: 'rj-tx-encrypt', k: 'debug.tx.encrypt-failed', + v: d => countCls(sub(d, 'tx').encryptFailed, 'debug-err') }, + { id: 'rj-tx-oversize', k: 'debug.tx.oversize', + v: d => countCls(sub(d, 'tx').oversize, 'debug-err') }, + { id: 'rj-tx-send', k: 'debug.tx.send-failed', + v: d => countCls(sub(d, 'tx').sendFailed, 'debug-err') }, + { id: 'rj-auth-pair', k: 'debug.auth.not-paired', + v: d => countCls(sub(d, 'auth').notPaired, 'debug-warn') }, + { id: 'rj-auth-proof', k: 'debug.auth.bad-proof', + v: d => countCls(sub(d, 'auth').badProof, 'debug-err') }, + { id: 'rj-reaped', k: 'debug.sessions-reaped', + v: d => countCls(d.sessionsReaped, 'debug-warn') }, + ], + }, + { + title: 'debug.section.host', + rows: [ + { id: 'h-sender', k: 'debug.stats.sender-ip', + v: d => d.senderIP || t('debug.sender.none') }, + { id: 'h-udp', k: 'debug.stats.udp-port', v: d => dashIfNull(d.udpPort) }, + { id: 'h-http', k: 'debug.stats.http-port', + v: d => dashIfNull(d.webPort || Number(location.port) || null) }, + { id: 'h-client-api', k: 'debug.host.client-api', + v: d => boolRow(d.clientApiListening, true) }, + { id: 'h-mdns', k: 'debug.host.mdns', v: d => boolRow(d.mdnsResponderActive, true) }, + { id: 'h-conns', k: 'debug.host.connections', v: d => count(d.connections) }, + { id: 'h-ctrls', k: 'debug.host.controllers', v: d => { + if (typeof d.controllers !== 'number') return '—'; + return d.controllers + ' / ' + (d.maxControllers || 16); + } }, + ], + }, +]; + +function buildDebugRows() { + const host = document.getElementById('debug-groups'); + if (!host) return; + let html = ''; + for (const g of DEBUG_GROUPS) { + html += '

' + esc(t(g.title)) + '

' + + '
'; + for (const row of g.rows) { + html += '
' + + '' + esc(t(row.k)) + '' + + '' + + '
'; + } + html += '
'; + } + host.innerHTML = html; + debugRowsBuilt = true; +} + +function applyDebugRows(d, rates) { + for (const g of DEBUG_GROUPS) { + for (const row of g.rows) { + const el = document.getElementById('d-' + row.id); + if (!el) continue; + let out; + try { + out = row.v(d, rates); + } catch (e) { + out = '—'; + } + if (out && typeof out === 'object') { + el.textContent = out.text; + el.className = 'debug-stat-value' + (out.cls ? ' ' + out.cls : ''); + } else { + el.textContent = String(out); + el.className = 'debug-stat-value'; + } + } + } } function initDebug() { prevSnap = null; prevTime = null; - rateHistory.length = 0; - document.getElementById('d-chart').textContent = ''; + rxHistory.length = 0; + txHistory.length = 0; + const chart = document.getElementById('d-chart'); + if (chart) chart.textContent = ''; + if (!debugRowsBuilt) buildDebugRows(); startDebugPolling(); } function startDebugPolling() { stopDebugPolling(); pollDebug(); - debugTimer = setInterval(pollDebug, 500); + pollDebugBackends(); + debugTimer = setInterval(pollDebug, DEBUG_POLL_MS); + debugBackendTimer = setInterval(pollDebugBackends, DEBUG_BACKEND_POLL_MS); } function stopDebugPolling() { if (debugTimer) { clearInterval(debugTimer); debugTimer = null; } + if (debugBackendTimer) { clearInterval(debugBackendTimer); debugBackendTimer = null; } +} + +function debugRates(d, now) { + const r = { rxPps: 0, txPps: 0, submitPps: 0, eventPps: 0 }; + if (!prevSnap || !prevTime) return r; + const dt = (now - prevTime) / 1000; + if (dt <= 0) return r; + const per = (a, b) => Math.max(0, Math.round(((a || 0) - (b || 0)) / dt)); + r.rxPps = per(d.packets, prevSnap.packets); + r.submitPps = per(d.submitOk, prevSnap.submitOk); + const tx = sub(d, 'tx'); + const ptx = sub(prevSnap, 'tx'); + r.txPps = per(tx.packets, ptx.packets); + const events = o => (o.packets || 0) - (o.heartbeatAck || 0) - (o.sessionClose || 0); + r.eventPps = per(events(tx), events(ptx)); + return r; +} + +function backendStateFor(d) { + const be = d.backend; + if (be && be.supported && !be.available) return 'error'; + if (d.backendAvailable) return 'active'; + return 'idle'; +} + +function renderPipeline(d, rates) { + const setText = (id, text) => { + const el = document.getElementById(id); + if (el) el.textContent = text; + }; + setText('d-rx-pps', rates.rxPps + ' pps'); + setText('d-tx-pps', rates.txPps + ' pps'); + setText('d-submit-pps', rates.submitPps + ' pps'); + setText('d-event-pps', rates.eventPps + ' pps'); + setText('d-client-ip', + (d.senderIP && d.senderIP !== 'none') ? d.senderIP : t('debug.sender.none')); + setText('d-status', d.listening ? t('debug.status.active') : t('debug.status.stopped')); + + const beLabel = document.getElementById('pipe-backend-label'); + const beIcon = document.getElementById('pipe-backend-icon'); + const copy = (d.backend && typeof backendCopy === 'function') ? backendCopy(d.backend.id) : null; + if (beLabel && copy && copy.pipelineLabel) beLabel.textContent = copy.pipelineLabel; + if (beIcon && copy && copy.icon) beIcon.src = copy.icon; + + const beState = backendStateFor(d); + setText('d-backend-state', + beState === 'error' ? t('debug.status.unavailable') + : (beState === 'active' ? t('debug.status.active') + : t('debug.status.idle'))); + + const cls = (id, name) => { + const el = document.getElementById(id); + if (el) el.className = name; + }; + const flowing = d.listening && rates.rxPps > 0; + const stage = d.listening ? (flowing ? ' pipe-active' : ' pipe-idle') : ''; + cls('pipe-client', 'pipe-stage' + stage); + cls('pipe-satellite', 'pipe-stage' + stage); + cls('pipe-backend', 'pipe-stage ' + + (beState === 'error' ? 'pipe-error' : (beState === 'active' ? 'pipe-active' : 'pipe-idle'))); + cls('d-rx-arrow', 'pipe-dir' + (rates.rxPps > 0 ? ' pipe-flow' : '')); + cls('d-tx-arrow', 'pipe-dir pipe-dir-rev' + (rates.txPps > 0 ? ' pipe-flow' : '')); + cls('d-submit-arrow', 'pipe-dir' + (rates.submitPps > 0 ? ' pipe-flow' : '')); + cls('d-event-arrow', 'pipe-dir pipe-dir-rev' + (rates.eventPps > 0 ? ' pipe-flow' : '')); } async function pollDebug() { + let d; try { const r = await fetch('/api/debug'); + if (!r.ok) return; + d = await r.json(); + } catch (e) { + return; + } + const now = performance.now(); + const rates = debugRates(d, now); + prevSnap = d; + prevTime = now; + + renderPipeline(d, rates); + applyDebugRows(d, rates); + + rxHistory.push(rates.rxPps); + txHistory.push(rates.txPps); + if (rxHistory.length > MAX_HISTORY) rxHistory.shift(); + if (txHistory.length > MAX_HISTORY) txHistory.shift(); + renderChart(); +} + +async function pollDebugBackends() { + try { + const r = await fetch('/api/backend/status'); + if (!r.ok) return; const d = await r.json(); - const now = performance.now(); - - let pps = 0, submitRate = 0; - if (prevSnap && prevTime) { - const dt = (now - prevTime) / 1000; - if (dt > 0) { - pps = Math.round((d.packets - prevSnap.packets) / dt); - submitRate = Math.round((d.submitOk - prevSnap.submitOk) / dt); - } - } - prevSnap = d; - prevTime = now; - - document.getElementById('d-pps').textContent = pps + ' pps'; - document.getElementById('d-submit-rate').textContent = submitRate + ' pps'; - document.getElementById('d-status').textContent = - d.listening ? t('debug.status.active') : t('debug.status.stopped'); - - const udp = document.getElementById('pipe-udp'); - const backend = document.getElementById('pipe-backend'); - const sys = document.getElementById('pipe-system'); - const a1 = document.getElementById('pipe-arrow-1'); - const a2 = document.getElementById('pipe-arrow-2'); - const backendUp = d.backendAvailable; - - const beLabelEl = document.getElementById('pipe-backend-label'); - if (beLabelEl && d.backend && typeof BACKEND_COPY === 'object') { - const copy = BACKEND_COPY[d.backend.id]; - if (copy && copy.pipelineLabel) beLabelEl.textContent = copy.pipelineLabel; - } + lastDebugBackends = Array.isArray(d.backends) ? d.backends : (d.id ? [d] : []); + renderDebugBackends(); + } catch (e) { /* keep the last snapshot on screen */ } +} - if (d.listening && pps > 0) { - udp.className = 'pipe-stage pipe-active'; - backend.className = 'pipe-stage ' + (backendUp ? 'pipe-active' : 'pipe-error'); - sys.className = 'pipe-stage ' + (backendUp ? 'pipe-active' : 'pipe-error'); - a1.className = 'pipe-arrow pipe-flow'; - a2.className = 'pipe-arrow ' + (backendUp ? 'pipe-flow' : ''); - } else if (d.listening) { - udp.className = 'pipe-stage pipe-idle'; - backend.className = 'pipe-stage ' + (backendUp ? 'pipe-idle' : 'pipe-error'); - sys.className = 'pipe-stage ' + (backendUp ? 'pipe-idle' : 'pipe-error'); - a1.className = 'pipe-arrow'; - a2.className = 'pipe-arrow'; - } else { - udp.className = 'pipe-stage'; - backend.className = 'pipe-stage' + (backendUp === false ? ' pipe-error' : ''); - sys.className = 'pipe-stage'; - a1.className = 'pipe-arrow'; - a2.className = 'pipe-arrow'; - } +function backendDisplayName(b) { + const copy = (typeof backendCopy === 'function') ? backendCopy(b.id) : null; + if (copy && copy.title) return copy.title; + return b.displayName || b.id || '—'; +} - document.getElementById('d-packets').textContent = d.packets.toLocaleString(); - document.getElementById('d-submit-ok').textContent = d.submitOk.toLocaleString(); - document.getElementById('d-submit-fail').textContent = d.submitFail.toLocaleString(); - - const total = d.submitOk + d.submitFail; - const dropPct = total > 0 ? ((d.submitFail / total) * 100).toFixed(2) : '0.00'; - document.getElementById('d-drop-rate').textContent = dropPct + '%'; - document.getElementById('d-drop-rate').className = - 'debug-stat-value' + (d.submitFail > 0 ? ' debug-err' : ' debug-ok'); - - document.getElementById('d-last-loop').textContent = d.lastLoopUs + ' µs'; - document.getElementById('d-max-loop').textContent = d.maxLoopUs + ' µs'; - document.getElementById('d-sender').textContent = d.senderIP; - document.getElementById('d-port').textContent = d.udpPort; - const httpEl = document.getElementById('d-http-port'); - if (httpEl) httpEl.textContent = d.webPort || location.port || '—'; - - const dfEl = document.getElementById('d-decrypt-fail'); - if (dfEl) dfEl.textContent = (d.decryptFail || 0).toLocaleString(); - const rdEl = document.getElementById('d-replay-drop'); - if (rdEl) rdEl.textContent = (d.replayDrop || 0).toLocaleString(); - - if (dfEl) dfEl.className = 'debug-stat-value' + ((d.decryptFail || 0) > 0 ? ' debug-err' : ' debug-ok'); - if (rdEl) rdEl.className = 'debug-stat-value' + ((d.replayDrop || 0) > 0 ? ' debug-warn' : ' debug-ok'); - - const beRow = document.getElementById('d-backend-row'); - const beLabel = document.getElementById('d-backend-label'); - const beVal = document.getElementById('d-backend-value'); - if (beRow && beLabel && beVal && d.backend) { - if (!d.backend.supported) { - beRow.style.display = 'none'; - } else { - beRow.style.display = ''; - const copy = (typeof BACKEND_COPY === 'object' && BACKEND_COPY[d.backend.id]) || null; - const title = (copy && copy.title) || t('debug.stats.backend'); - beLabel.textContent = title; - if (d.backend.available) { - beVal.textContent = d.backendAvailable ? t('debug.status.active') : t('debug.status.idle'); - beVal.className = 'debug-stat-value debug-ok'; - } else { - const errCopy = copy && copy.errors && copy.errors[d.backend.errorCode]; - beVal.textContent = errCopy ? errCopy.title : (d.backend.errorCode || t('debug.status.unavailable')); - beVal.className = 'debug-stat-value debug-err'; - } - } - } +function backendStatusChip(b) { + const copy = (typeof backendCopy === 'function') ? backendCopy(b.id) : null; + if (b.available) { + return { text: (copy && copy.statusUnknown) || t('debug.status.active'), cls: 'debug-ok' }; + } + const err = copy && copy.errors && copy.errors[b.errorCode]; + return { + text: err ? err.title : (b.errorCode || t('debug.status.unavailable')), + cls: 'debug-err', + }; +} - const loopEl = document.getElementById('d-last-loop'); - if (d.lastLoopUs > 1000) loopEl.className = 'debug-stat-value debug-err'; - else if (d.lastLoopUs > 500) loopEl.className = 'debug-stat-value debug-warn'; - else loopEl.className = 'debug-stat-value debug-ok'; +function renderDebugBackends() { + const host = document.getElementById('debug-backends'); + if (!host) return; + const list = lastDebugBackends; + if (!Array.isArray(list) || list.length === 0) { + host.innerHTML = '

' + esc(t('debug.backends.none')) + '

'; + return; + } + let html = ''; + for (const b of list) { + if (!b) continue; + const copy = (typeof backendCopy === 'function') ? backendCopy(b.id) : null; + const icon = (copy && copy.icon) ? copy.icon : 'img/icons/gamepad_virtual.svg'; + const chip = backendStatusChip(b); + const tags = [b.kernelMode ? t('debug.backends.kernel') : t('debug.backends.user')]; + if (b.audio) tags.push(t('debug.backends.audio')); + if (b.lifecycle && b.lifecycle !== 'supported') tags.push(b.lifecycle); + const meta = [b.vendor, tags.join(' · ')].filter(Boolean).join(' · '); + const bundled = (b.bundledVersion && b.bundledVersion !== b.driverVersion) + ? '' + + esc(t('debug.backends.bundled', [b.bundledVersion])) + '' + : ''; + html += '
' + + '' + + '
' + + '' + esc(backendDisplayName(b)) + '' + + '' + esc(meta) + '' + + '
' + + '
' + + '' + esc(chip.text) + '' + + '' + esc(b.driverVersion || '—') + bundled + + '' + + '
'; + } + host.innerHTML = html; +} - rateHistory.push(pps); - if (rateHistory.length > MAX_HISTORY) rateHistory.shift(); - renderChart(); - } catch (e) { /* ignore */ } +function chartColumns(rxMax, txMax) { + const half = 34; + let html = ''; + for (let i = 0; i < rxHistory.length; i++) { + const rh = Math.max(1, Math.round((rxHistory[i] / rxMax) * half)); + const th = Math.max(1, Math.round((txHistory[i] / txMax) * half)); + html += '
' + + '
' + + '
' + + '
' + + '
' + + '
'; + } + return html; } function renderChart() { const chart = document.getElementById('d-chart'); - if (!chart || rateHistory.length === 0) return; - const max = Math.max(...rateHistory, 1); - const barH = 60; - const bars = rateHistory.map(v => { - const h = Math.max(1, Math.round((v / max) * barH)); - const pct = v / max; - const color = pct > 0.7 ? themeColor('--success') - : pct > 0.3 ? themeColor('--primary') - : themeColor('--error'); - return `
`; - }).join(''); - chart.innerHTML = `
${bars}
${max} pps
`; + if (!chart || rxHistory.length === 0) return; + const rxMax = Math.max(...rxHistory, 1); + const txMax = Math.max(...txHistory, 1); + chart.innerHTML = + '
' + + '' + esc(t('debug.chart.in')) + ' ' + rxMax + ' pps' + + '' + esc(t('debug.chart.out')) + ' ' + txMax + ' pps' + + '
' + + '
' + chartColumns(rxMax, txMax) + '
'; } +const _debugOrigShowView = showView; +showView = function (id) { + if (id !== 'view-debug') stopDebugPolling(); + _debugOrigShowView(id); +}; diff --git a/web/index.html b/web/index.html index d35ee09..4b67459 100644 --- a/web/index.html +++ b/web/index.html @@ -283,81 +283,43 @@

-
-
-
Dish
-
UDP Recv
-
0 pps
+
+
+
+
Client
+
+
+
+
0 pps
+
0 pps
+
+
+
+
Satellite
+
Stopped
+
+
+
0 pps
+
0 pps
-
to
-
+
Inject
-
0 pps
-
-
to
-
-
-
System
-
Stopped
+
-
-
- Packets Received - 0 -
-
- Submitted OK - 0 -
-
- Submit Errors - 0 -
-
- Drop Rate - 0% -
-
- Last Loop - 0 µs -
-
- Max Loop (peak) - 0 µs -
-
- Sender IP - none -
-
- UDP Port - -
-
- HTTP Port - -
-
- Decrypt Failures - 0 -
-
- Replay Drops - 0 -
- +
+ +
+

Backends

+
-

Packet Rate History

+

Traffic history

-

Updates every 500ms · showing last 60 samples (30 seconds)

+

Updates every 500 ms · last 60 samples (30 seconds)

diff --git a/web/lang/bs.json b/web/lang/bs.json index 1359da0..6999062 100644 --- a/web/lang/bs.json +++ b/web/lang/bs.json @@ -293,28 +293,86 @@ "updates.time.minutes-ago": "prije %1$s min", "updates.time.hours-ago": "prije %1$s h", "updates.time.days-ago": "prije %1$s d", - "debug.pipeline.udp-recv": "UDP prijem", + "debug.pipeline.client": "Klijent", + "debug.pipeline.satellite": "Satellite", "debug.pipeline.inject": "Ubacivanje", - "debug.pipeline.system": "Sistem", + "debug.section.inbound": "Dolazno · od klijenta ka Satellite", + "debug.section.outbound": "Odlazno · od Satellite ka klijentu", + "debug.section.latency": "Kritična putanja", + "debug.section.audio": "Zvuk kontrolera", + "debug.section.rejected": "Odbijeno", + "debug.section.host": "Računar", + "debug.section.backends": "Backendi", + "debug.rx.rate": "Dolazna brzina", "debug.stats.packets-received": "Primljeni paketi", + "debug.rx.input": "Okviri unosa", + "debug.rx.heartbeat": "Otkucaji", + "debug.rx.motion": "Okviri pokreta", + "debug.rx.battery": "Prijave baterije", + "debug.rx.pointer": "Okviri pokazivača", + "debug.rx.mic": "Okviri mikrofona", + "debug.tx.rate": "Odlazna brzina", + "debug.tx.total": "Poslani datagrami", + "debug.tx.bytes": "Poslani bajtovi", + "debug.tx.heartbeat-ack": "Potvrde otkucaja", + "debug.tx.rumble": "Vibracija", + "debug.tx.lightbar": "Svjetlosna traka", + "debug.tx.trigger-effects": "Efekti okidača", + "debug.tx.player-leds": "LED igrača", + "debug.tx.speaker": "Okviri zvučnika", + "debug.tx.mic-led": "Lampica isključenog mikrofona", + "debug.tx.session-close": "Zatvaranje sesije", + "debug.stats.last-loop": "Posljednja petlja", + "debug.stats.max-loop": "Maks. petlja (vrhunac)", "debug.stats.submitted-ok": "Uspješno poslano", "debug.stats.submit-errors": "Greške slanja", "debug.stats.drop-rate": "Stopa odbacivanja", - "debug.stats.last-loop": "Posljednja petlja", - "debug.stats.max-loop": "Maks. petlja (vrhunac)", + "debug.audio.mic-accepted": "Prihvaćeni okviri mikrofona", + "debug.audio.mic-decoded": "Dekodirani okviri mikrofona", + "debug.audio.mic-fec": "Vraćeno pomoću FEC-a", + "debug.audio.mic-concealed": "Prikriveno", + "debug.audio.mic-late": "Prekasno za upotrebu", + "debug.audio.mic-dropped": "Odbačeni okviri mikrofona", + "debug.audio.speaker-sent": "Poslani okviri zvučnika", + "debug.audio.speaker-silence": "Tišina potisnuta", + "debug.audio.speaker-encode-fail": "Greške kodiranja", + "debug.audio.speaker-contended": "Odbačeno zbog zauzeća", + "debug.stats.decrypt-failures": "Greške dešifrovanja", + "debug.stats.replay-drops": "Odbačeni ponovljeni", + "debug.rx.malformed": "Neispravni okviri", + "debug.rx.unknown-type": "Nepoznat tip poruke", + "debug.rx.runt": "Premali datagrami", + "debug.rx.unknown-token": "Nepoznat token sesije", + "debug.tx.unroutable": "Nema rute do klijenta", + "debug.tx.encrypt-failed": "Greške šifrovanja", + "debug.tx.oversize": "Preveliki okviri", + "debug.tx.send-failed": "Greške slanja", + "debug.auth.not-paired": "Odbijeno: nije uparen", + "debug.auth.bad-proof": "Odbijeno: neispravan dokaz", + "debug.sessions-reaped": "Istekle sesije", "debug.stats.sender-ip": "IP pošiljaoca", "debug.stats.udp-port": "UDP port", "debug.stats.http-port": "HTTP port", - "debug.stats.decrypt-failures": "Greške dešifrovanja", - "debug.stats.replay-drops": "Odbačeni ponovljeni", - "debug.stats.backend": "Backend", - "debug.chart.title": "Historija brzine paketa", - "debug.chart.hint": "Ažurira se svakih 500ms · prikazuje posljednjih 60 uzoraka (30 sekundi)", + "debug.host.client-api": "Klijent API (HTTPS)", + "debug.host.mdns": "mDNS responder", + "debug.host.connections": "Aktivne veze", + "debug.host.controllers": "Virtuelni kontroleri", + "debug.backends.kernel": "Kernel način", + "debug.backends.user": "Korisnički način", + "debug.backends.audio": "zvuk kontrolera", + "debug.backends.bundled": "isporučeno %1$s", + "debug.backends.none": "Nijedan backend nije prijavljen", + "debug.chart.title": "Historija saobraćaja", + "debug.chart.in": "ulaz", + "debug.chart.out": "izlaz", + "debug.chart.hint": "Ažurira se svakih 500 ms · posljednjih 60 uzoraka (30 sekundi). Dolazno iznad linije, odlazno ispod.", "debug.status.active": "Aktivan", "debug.status.stopped": "Zaustavljen", "debug.status.idle": "Neaktivan", "debug.status.unavailable": "Nedostupan", "debug.sender.none": "nema", + "debug.value.yes": "Da", + "debug.value.no": "Ne", "logs.filter.info": "Info", "logs.filter.warn": "Upozorenja", "logs.filter.error": "Greške", diff --git a/web/lang/de.json b/web/lang/de.json index 12ac109..dccaa7c 100644 --- a/web/lang/de.json +++ b/web/lang/de.json @@ -316,28 +316,86 @@ "updates.time.hours-ago": "vor %1$s Std.", "updates.time.days-ago": "vor %1$s T.", - "debug.pipeline.udp-recv": "UDP Recv", + "debug.pipeline.client": "Client", + "debug.pipeline.satellite": "Satellite", "debug.pipeline.inject": "Inject", - "debug.pipeline.system": "System", + "debug.section.inbound": "Eingehend · Client an Satellite", + "debug.section.outbound": "Ausgehend · Satellite an Client", + "debug.section.latency": "Hot Path", + "debug.section.audio": "Controller-Audio", + "debug.section.rejected": "Abgewiesen", + "debug.section.host": "Host", + "debug.section.backends": "Backends", + "debug.rx.rate": "Eingehende Rate", "debug.stats.packets-received": "Empfangene Pakete", + "debug.rx.input": "Eingabe-Frames", + "debug.rx.heartbeat": "Heartbeats", + "debug.rx.motion": "Bewegungs-Frames", + "debug.rx.battery": "Akku-Meldungen", + "debug.rx.pointer": "Zeiger-Frames", + "debug.rx.mic": "Mikrofon-Frames", + "debug.tx.rate": "Ausgehende Rate", + "debug.tx.total": "Gesendete Datagramme", + "debug.tx.bytes": "Gesendete Bytes", + "debug.tx.heartbeat-ack": "Heartbeat-Bestätigungen", + "debug.tx.rumble": "Vibration", + "debug.tx.lightbar": "Leuchtleiste", + "debug.tx.trigger-effects": "Trigger-Effekte", + "debug.tx.player-leds": "Spieler-LEDs", + "debug.tx.speaker": "Lautsprecher-Frames", + "debug.tx.mic-led": "Mikrofon-Stummlampe", + "debug.tx.session-close": "Sitzungsende", + "debug.stats.last-loop": "Letzter Durchlauf", + "debug.stats.max-loop": "Max. Durchlauf (Spitze)", "debug.stats.submitted-ok": "Erfolgreich übermittelt", "debug.stats.submit-errors": "Übermittlungsfehler", "debug.stats.drop-rate": "Verlustrate", - "debug.stats.last-loop": "Letzter Durchlauf", - "debug.stats.max-loop": "Max. Durchlauf (Spitze)", + "debug.audio.mic-accepted": "Mikrofon-Frames angenommen", + "debug.audio.mic-decoded": "Mikrofon-Frames dekodiert", + "debug.audio.mic-fec": "Per FEC wiederhergestellt", + "debug.audio.mic-concealed": "Verdeckt", + "debug.audio.mic-late": "Zu spät zum Abspielen", + "debug.audio.mic-dropped": "Mikrofon-Frames verworfen", + "debug.audio.speaker-sent": "Lautsprecher-Frames gesendet", + "debug.audio.speaker-silence": "Stille unterdrückt", + "debug.audio.speaker-encode-fail": "Kodierungsfehler", + "debug.audio.speaker-contended": "Bei Auslastung verworfen", + "debug.stats.decrypt-failures": "Entschlüsselungsfehler", + "debug.stats.replay-drops": "Replay-Verwürfe", + "debug.rx.malformed": "Fehlerhafte Frames", + "debug.rx.unknown-type": "Unbekannter Nachrichtentyp", + "debug.rx.runt": "Zu kleine Datagramme", + "debug.rx.unknown-token": "Unbekanntes Sitzungs-Token", + "debug.tx.unroutable": "Kein Weg zum Client", + "debug.tx.encrypt-failed": "Verschlüsselungsfehler", + "debug.tx.oversize": "Zu große Frames", + "debug.tx.send-failed": "Sendefehler", + "debug.auth.not-paired": "Abgewiesen: nicht gekoppelt", + "debug.auth.bad-proof": "Abgewiesen: ungültiger Nachweis", + "debug.sessions-reaped": "Sitzungen abgelaufen", "debug.stats.sender-ip": "Sender-IP", "debug.stats.udp-port": "UDP-Port", "debug.stats.http-port": "HTTP-Port", - "debug.stats.decrypt-failures": "Entschlüsselungsfehler", - "debug.stats.replay-drops": "Replay-Verwürfe", - "debug.stats.backend": "Backend", - "debug.chart.title": "Verlauf der Paketrate", - "debug.chart.hint": "Aktualisiert alle 500 ms · zeigt die letzten 60 Werte (30 Sekunden)", + "debug.host.client-api": "Client-API (HTTPS)", + "debug.host.mdns": "mDNS-Responder", + "debug.host.connections": "Aktive Verbindungen", + "debug.host.controllers": "Virtuelle Controller", + "debug.backends.kernel": "Kernelmodus", + "debug.backends.user": "Benutzermodus", + "debug.backends.audio": "Controller-Audio", + "debug.backends.bundled": "mitgeliefert %1$s", + "debug.backends.none": "Kein Backend gemeldet", + "debug.chart.title": "Datenverkehr-Verlauf", + "debug.chart.in": "ein", + "debug.chart.out": "aus", + "debug.chart.hint": "Aktualisiert alle 500 ms · letzte 60 Werte (30 Sekunden). Eingehend über der Linie, ausgehend darunter.", "debug.status.active": "Aktiv", "debug.status.stopped": "Gestoppt", "debug.status.idle": "Inaktiv", "debug.status.unavailable": "Nicht verfügbar", "debug.sender.none": "keiner", + "debug.value.yes": "Ja", + "debug.value.no": "Nein", "logs.filter.info": "Info", "logs.filter.warn": "Warnung", diff --git a/web/lang/en.json b/web/lang/en.json index 3281e06..a50bdb0 100644 --- a/web/lang/en.json +++ b/web/lang/en.json @@ -316,28 +316,86 @@ "updates.time.hours-ago": "%1$s h ago", "updates.time.days-ago": "%1$s d ago", - "debug.pipeline.udp-recv": "UDP Recv", + "debug.pipeline.client": "Client", + "debug.pipeline.satellite": "Satellite", "debug.pipeline.inject": "Inject", - "debug.pipeline.system": "System", + "debug.section.inbound": "Inbound · client to Satellite", + "debug.section.outbound": "Outbound · Satellite to client", + "debug.section.latency": "Hot path", + "debug.section.audio": "Controller audio", + "debug.section.rejected": "Rejected", + "debug.section.host": "Host", + "debug.section.backends": "Backends", + "debug.rx.rate": "Inbound rate", "debug.stats.packets-received": "Packets Received", + "debug.rx.input": "Input frames", + "debug.rx.heartbeat": "Heartbeats", + "debug.rx.motion": "Motion frames", + "debug.rx.battery": "Battery updates", + "debug.rx.pointer": "Pointer frames", + "debug.rx.mic": "Microphone frames", + "debug.tx.rate": "Outbound rate", + "debug.tx.total": "Datagrams sent", + "debug.tx.bytes": "Bytes sent", + "debug.tx.heartbeat-ack": "Heartbeat acks", + "debug.tx.rumble": "Rumble", + "debug.tx.lightbar": "Light bar", + "debug.tx.trigger-effects": "Trigger effects", + "debug.tx.player-leds": "Player LEDs", + "debug.tx.speaker": "Speaker frames", + "debug.tx.mic-led": "Mic mute lamp", + "debug.tx.session-close": "Session close", + "debug.stats.last-loop": "Last Loop", + "debug.stats.max-loop": "Max Loop (peak)", "debug.stats.submitted-ok": "Submitted OK", "debug.stats.submit-errors": "Submit Errors", "debug.stats.drop-rate": "Drop Rate", - "debug.stats.last-loop": "Last Loop", - "debug.stats.max-loop": "Max Loop (peak)", + "debug.audio.mic-accepted": "Mic frames accepted", + "debug.audio.mic-decoded": "Mic frames decoded", + "debug.audio.mic-fec": "Recovered by FEC", + "debug.audio.mic-concealed": "Concealed", + "debug.audio.mic-late": "Too late to use", + "debug.audio.mic-dropped": "Mic frames dropped", + "debug.audio.speaker-sent": "Speaker frames sent", + "debug.audio.speaker-silence": "Silence suppressed", + "debug.audio.speaker-encode-fail": "Encode failures", + "debug.audio.speaker-contended": "Dropped while busy", + "debug.stats.decrypt-failures": "Decrypt Failures", + "debug.stats.replay-drops": "Replay Drops", + "debug.rx.malformed": "Malformed frames", + "debug.rx.unknown-type": "Unknown message type", + "debug.rx.runt": "Undersized datagrams", + "debug.rx.unknown-token": "Unknown session token", + "debug.tx.unroutable": "No route to client", + "debug.tx.encrypt-failed": "Encryption failures", + "debug.tx.oversize": "Oversized frames", + "debug.tx.send-failed": "Send failures", + "debug.auth.not-paired": "Refused: not paired", + "debug.auth.bad-proof": "Refused: bad proof", + "debug.sessions-reaped": "Sessions timed out", "debug.stats.sender-ip": "Sender IP", "debug.stats.udp-port": "UDP Port", "debug.stats.http-port": "HTTP Port", - "debug.stats.decrypt-failures": "Decrypt Failures", - "debug.stats.replay-drops": "Replay Drops", - "debug.stats.backend": "Backend", - "debug.chart.title": "Packet Rate History", - "debug.chart.hint": "Updates every 500ms · showing last 60 samples (30 seconds)", + "debug.host.client-api": "Client API (HTTPS)", + "debug.host.mdns": "mDNS responder", + "debug.host.connections": "Active connections", + "debug.host.controllers": "Virtual controllers", + "debug.backends.kernel": "Kernel mode", + "debug.backends.user": "User mode", + "debug.backends.audio": "controller audio", + "debug.backends.bundled": "bundled %1$s", + "debug.backends.none": "No backend reported", + "debug.chart.title": "Traffic history", + "debug.chart.in": "in", + "debug.chart.out": "out", + "debug.chart.hint": "Updates every 500 ms · last 60 samples (30 seconds). Inbound above the line, outbound below.", "debug.status.active": "Active", "debug.status.stopped": "Stopped", "debug.status.idle": "Idle", "debug.status.unavailable": "Unavailable", "debug.sender.none": "none", + "debug.value.yes": "Yes", + "debug.value.no": "No", "logs.filter.info": "Info", "logs.filter.warn": "Warn", diff --git a/web/lang/es.json b/web/lang/es.json index a0bdfb1..12747bd 100644 --- a/web/lang/es.json +++ b/web/lang/es.json @@ -293,28 +293,86 @@ "updates.time.minutes-ago": "hace %1$s min", "updates.time.hours-ago": "hace %1$s h", "updates.time.days-ago": "hace %1$s d", - "debug.pipeline.udp-recv": "Recepción UDP", + "debug.pipeline.client": "Cliente", + "debug.pipeline.satellite": "Satellite", "debug.pipeline.inject": "Inyección", - "debug.pipeline.system": "Sistema", + "debug.section.inbound": "Entrante · del cliente a Satellite", + "debug.section.outbound": "Saliente · de Satellite al cliente", + "debug.section.latency": "Ruta crítica", + "debug.section.audio": "Audio del mando", + "debug.section.rejected": "Rechazado", + "debug.section.host": "Equipo", + "debug.section.backends": "Backends", + "debug.rx.rate": "Tasa de entrada", "debug.stats.packets-received": "Paquetes recibidos", + "debug.rx.input": "Tramas de entrada", + "debug.rx.heartbeat": "Latidos", + "debug.rx.motion": "Tramas de movimiento", + "debug.rx.battery": "Avisos de batería", + "debug.rx.pointer": "Tramas de puntero", + "debug.rx.mic": "Tramas de micrófono", + "debug.tx.rate": "Tasa de salida", + "debug.tx.total": "Datagramas enviados", + "debug.tx.bytes": "Bytes enviados", + "debug.tx.heartbeat-ack": "Confirmaciones de latido", + "debug.tx.rumble": "Vibración", + "debug.tx.lightbar": "Barra de luz", + "debug.tx.trigger-effects": "Efectos de gatillo", + "debug.tx.player-leds": "LED de jugador", + "debug.tx.speaker": "Tramas de altavoz", + "debug.tx.mic-led": "Luz de silencio del micrófono", + "debug.tx.session-close": "Cierre de sesión", + "debug.stats.last-loop": "Último ciclo", + "debug.stats.max-loop": "Ciclo máximo (pico)", "debug.stats.submitted-ok": "Enviados correctamente", "debug.stats.submit-errors": "Errores de envío", "debug.stats.drop-rate": "Tasa de descarte", - "debug.stats.last-loop": "Último ciclo", - "debug.stats.max-loop": "Ciclo máximo (pico)", + "debug.audio.mic-accepted": "Tramas de micrófono aceptadas", + "debug.audio.mic-decoded": "Tramas de micrófono decodificadas", + "debug.audio.mic-fec": "Recuperadas por FEC", + "debug.audio.mic-concealed": "Ocultadas", + "debug.audio.mic-late": "Demasiado tarde para usarlas", + "debug.audio.mic-dropped": "Tramas de micrófono descartadas", + "debug.audio.speaker-sent": "Tramas de altavoz enviadas", + "debug.audio.speaker-silence": "Silencio suprimido", + "debug.audio.speaker-encode-fail": "Fallos de codificación", + "debug.audio.speaker-contended": "Descartadas por ocupación", + "debug.stats.decrypt-failures": "Fallos de descifrado", + "debug.stats.replay-drops": "Descartes por repetición", + "debug.rx.malformed": "Tramas mal formadas", + "debug.rx.unknown-type": "Tipo de mensaje desconocido", + "debug.rx.runt": "Datagramas demasiado pequeños", + "debug.rx.unknown-token": "Token de sesión desconocido", + "debug.tx.unroutable": "Sin ruta al cliente", + "debug.tx.encrypt-failed": "Fallos de cifrado", + "debug.tx.oversize": "Tramas demasiado grandes", + "debug.tx.send-failed": "Fallos de envío", + "debug.auth.not-paired": "Rechazado: sin vincular", + "debug.auth.bad-proof": "Rechazado: prueba no válida", + "debug.sessions-reaped": "Sesiones caducadas", "debug.stats.sender-ip": "IP del emisor", "debug.stats.udp-port": "Puerto UDP", "debug.stats.http-port": "Puerto HTTP", - "debug.stats.decrypt-failures": "Fallos de descifrado", - "debug.stats.replay-drops": "Descartes por repetición", - "debug.stats.backend": "Backend", - "debug.chart.title": "Historial de tasa de paquetes", - "debug.chart.hint": "Se actualiza cada 500 ms · muestra las últimas 60 muestras (30 segundos)", + "debug.host.client-api": "API de cliente (HTTPS)", + "debug.host.mdns": "Respondedor mDNS", + "debug.host.connections": "Conexiones activas", + "debug.host.controllers": "Mandos virtuales", + "debug.backends.kernel": "Modo kernel", + "debug.backends.user": "Modo usuario", + "debug.backends.audio": "audio del mando", + "debug.backends.bundled": "incluida %1$s", + "debug.backends.none": "Ningún backend informado", + "debug.chart.title": "Historial de tráfico", + "debug.chart.in": "entrada", + "debug.chart.out": "salida", + "debug.chart.hint": "Se actualiza cada 500 ms · últimas 60 muestras (30 segundos). Entrada sobre la línea, salida debajo.", "debug.status.active": "Activo", "debug.status.stopped": "Detenido", "debug.status.idle": "Inactivo", "debug.status.unavailable": "No disponible", "debug.sender.none": "ninguno", + "debug.value.yes": "Sí", + "debug.value.no": "No", "logs.filter.info": "Info", "logs.filter.warn": "Aviso", "logs.filter.error": "Error", diff --git a/web/lang/fr.json b/web/lang/fr.json index 3d9c0b9..9544106 100644 --- a/web/lang/fr.json +++ b/web/lang/fr.json @@ -293,28 +293,86 @@ "updates.time.minutes-ago": "il y a %1$s min", "updates.time.hours-ago": "il y a %1$s h", "updates.time.days-ago": "il y a %1$s j", - "debug.pipeline.udp-recv": "Réception UDP", + "debug.pipeline.client": "Client", + "debug.pipeline.satellite": "Satellite", "debug.pipeline.inject": "Injection", - "debug.pipeline.system": "Système", + "debug.section.inbound": "Entrant · du client vers Satellite", + "debug.section.outbound": "Sortant · de Satellite vers le client", + "debug.section.latency": "Chemin critique", + "debug.section.audio": "Audio de la manette", + "debug.section.rejected": "Rejeté", + "debug.section.host": "Hôte", + "debug.section.backends": "Backends", + "debug.rx.rate": "Débit entrant", "debug.stats.packets-received": "Paquets reçus", + "debug.rx.input": "Trames d’entrée", + "debug.rx.heartbeat": "Battements", + "debug.rx.motion": "Trames de mouvement", + "debug.rx.battery": "Relevés de batterie", + "debug.rx.pointer": "Trames de pointeur", + "debug.rx.mic": "Trames de microphone", + "debug.tx.rate": "Débit sortant", + "debug.tx.total": "Datagrammes envoyés", + "debug.tx.bytes": "Octets envoyés", + "debug.tx.heartbeat-ack": "Accusés de battement", + "debug.tx.rumble": "Vibration", + "debug.tx.lightbar": "Barre lumineuse", + "debug.tx.trigger-effects": "Effets de gâchette", + "debug.tx.player-leds": "LED joueur", + "debug.tx.speaker": "Trames de haut-parleur", + "debug.tx.mic-led": "Témoin de micro coupé", + "debug.tx.session-close": "Fin de session", + "debug.stats.last-loop": "Dernière boucle", + "debug.stats.max-loop": "Boucle max (pic)", "debug.stats.submitted-ok": "Soumis avec succès", "debug.stats.submit-errors": "Erreurs de soumission", "debug.stats.drop-rate": "Taux de perte", - "debug.stats.last-loop": "Dernière boucle", - "debug.stats.max-loop": "Boucle max (pic)", + "debug.audio.mic-accepted": "Trames micro acceptées", + "debug.audio.mic-decoded": "Trames micro décodées", + "debug.audio.mic-fec": "Récupérées par FEC", + "debug.audio.mic-concealed": "Masquées", + "debug.audio.mic-late": "Trop tardives", + "debug.audio.mic-dropped": "Trames micro rejetées", + "debug.audio.speaker-sent": "Trames haut-parleur envoyées", + "debug.audio.speaker-silence": "Silence supprimé", + "debug.audio.speaker-encode-fail": "Échecs d’encodage", + "debug.audio.speaker-contended": "Rejetées pour cause d’occupation", + "debug.stats.decrypt-failures": "Échecs de déchiffrement", + "debug.stats.replay-drops": "Rejets anti-rejeu", + "debug.rx.malformed": "Trames malformées", + "debug.rx.unknown-type": "Type de message inconnu", + "debug.rx.runt": "Datagrammes trop courts", + "debug.rx.unknown-token": "Jeton de session inconnu", + "debug.tx.unroutable": "Aucune route vers le client", + "debug.tx.encrypt-failed": "Échecs de chiffrement", + "debug.tx.oversize": "Trames trop grandes", + "debug.tx.send-failed": "Échecs d’envoi", + "debug.auth.not-paired": "Refusé : non appairé", + "debug.auth.bad-proof": "Refusé : preuve invalide", + "debug.sessions-reaped": "Sessions expirées", "debug.stats.sender-ip": "IP de l’émetteur", "debug.stats.udp-port": "Port UDP", "debug.stats.http-port": "Port HTTP", - "debug.stats.decrypt-failures": "Échecs de déchiffrement", - "debug.stats.replay-drops": "Rejets anti-rejeu", - "debug.stats.backend": "Backend", - "debug.chart.title": "Historique du débit de paquets", - "debug.chart.hint": "Mise à jour toutes les 500 ms · 60 derniers échantillons (30 secondes)", + "debug.host.client-api": "API client (HTTPS)", + "debug.host.mdns": "Répondeur mDNS", + "debug.host.connections": "Connexions actives", + "debug.host.controllers": "Manettes virtuelles", + "debug.backends.kernel": "Mode noyau", + "debug.backends.user": "Mode utilisateur", + "debug.backends.audio": "audio de la manette", + "debug.backends.bundled": "fournie %1$s", + "debug.backends.none": "Aucun backend signalé", + "debug.chart.title": "Historique du trafic", + "debug.chart.in": "entrée", + "debug.chart.out": "sortie", + "debug.chart.hint": "Mise à jour toutes les 500 ms · 60 derniers échantillons (30 secondes). Entrée au-dessus de la ligne, sortie en dessous.", "debug.status.active": "Actif", "debug.status.stopped": "Arrêté", "debug.status.idle": "Inactif", "debug.status.unavailable": "Indisponible", "debug.sender.none": "aucun", + "debug.value.yes": "Oui", + "debug.value.no": "Non", "logs.filter.info": "Info", "logs.filter.warn": "Avert.", "logs.filter.error": "Erreur", diff --git a/web/lang/pt-BR.json b/web/lang/pt-BR.json index 7c90c87..b39cb07 100644 --- a/web/lang/pt-BR.json +++ b/web/lang/pt-BR.json @@ -293,28 +293,86 @@ "updates.time.minutes-ago": "há %1$s min", "updates.time.hours-ago": "há %1$s h", "updates.time.days-ago": "há %1$s d", - "debug.pipeline.udp-recv": "Recepção UDP", + "debug.pipeline.client": "Cliente", + "debug.pipeline.satellite": "Satellite", "debug.pipeline.inject": "Injeção", - "debug.pipeline.system": "Sistema", + "debug.section.inbound": "Entrada · do cliente para o Satellite", + "debug.section.outbound": "Saída · do Satellite para o cliente", + "debug.section.latency": "Caminho crítico", + "debug.section.audio": "Áudio do controle", + "debug.section.rejected": "Rejeitado", + "debug.section.host": "Host", + "debug.section.backends": "Backends", + "debug.rx.rate": "Taxa de entrada", "debug.stats.packets-received": "Pacotes recebidos", + "debug.rx.input": "Quadros de entrada", + "debug.rx.heartbeat": "Batimentos", + "debug.rx.motion": "Quadros de movimento", + "debug.rx.battery": "Avisos de bateria", + "debug.rx.pointer": "Quadros de ponteiro", + "debug.rx.mic": "Quadros de microfone", + "debug.tx.rate": "Taxa de saída", + "debug.tx.total": "Datagramas enviados", + "debug.tx.bytes": "Bytes enviados", + "debug.tx.heartbeat-ack": "Confirmações de batimento", + "debug.tx.rumble": "Vibração", + "debug.tx.lightbar": "Barra de luz", + "debug.tx.trigger-effects": "Efeitos de gatilho", + "debug.tx.player-leds": "LEDs de jogador", + "debug.tx.speaker": "Quadros de alto-falante", + "debug.tx.mic-led": "Luz de microfone mudo", + "debug.tx.session-close": "Encerramento de sessão", + "debug.stats.last-loop": "Último ciclo", + "debug.stats.max-loop": "Ciclo máximo (pico)", "debug.stats.submitted-ok": "Enviados com sucesso", "debug.stats.submit-errors": "Erros de envio", "debug.stats.drop-rate": "Taxa de descarte", - "debug.stats.last-loop": "Último ciclo", - "debug.stats.max-loop": "Ciclo máximo (pico)", + "debug.audio.mic-accepted": "Quadros de microfone aceitos", + "debug.audio.mic-decoded": "Quadros de microfone decodificados", + "debug.audio.mic-fec": "Recuperados por FEC", + "debug.audio.mic-concealed": "Ocultados", + "debug.audio.mic-late": "Tarde demais para usar", + "debug.audio.mic-dropped": "Quadros de microfone descartados", + "debug.audio.speaker-sent": "Quadros de alto-falante enviados", + "debug.audio.speaker-silence": "Silêncio suprimido", + "debug.audio.speaker-encode-fail": "Falhas de codificação", + "debug.audio.speaker-contended": "Descartados por ocupação", + "debug.stats.decrypt-failures": "Falhas de descriptografia", + "debug.stats.replay-drops": "Descartes por repetição", + "debug.rx.malformed": "Quadros malformados", + "debug.rx.unknown-type": "Tipo de mensagem desconhecido", + "debug.rx.runt": "Datagramas pequenos demais", + "debug.rx.unknown-token": "Token de sessão desconhecido", + "debug.tx.unroutable": "Sem rota para o cliente", + "debug.tx.encrypt-failed": "Falhas de criptografia", + "debug.tx.oversize": "Quadros grandes demais", + "debug.tx.send-failed": "Falhas de envio", + "debug.auth.not-paired": "Recusado: não pareado", + "debug.auth.bad-proof": "Recusado: prova inválida", + "debug.sessions-reaped": "Sessões expiradas", "debug.stats.sender-ip": "IP do remetente", "debug.stats.udp-port": "Porta UDP", "debug.stats.http-port": "Porta HTTP", - "debug.stats.decrypt-failures": "Falhas de descriptografia", - "debug.stats.replay-drops": "Descartes por repetição", - "debug.stats.backend": "Backend", - "debug.chart.title": "Histórico da taxa de pacotes", - "debug.chart.hint": "Atualiza a cada 500ms · mostrando as últimas 60 amostras (30 segundos)", + "debug.host.client-api": "API do cliente (HTTPS)", + "debug.host.mdns": "Respondedor mDNS", + "debug.host.connections": "Conexões ativas", + "debug.host.controllers": "Controles virtuais", + "debug.backends.kernel": "Modo kernel", + "debug.backends.user": "Modo usuário", + "debug.backends.audio": "áudio do controle", + "debug.backends.bundled": "incluída %1$s", + "debug.backends.none": "Nenhum backend informado", + "debug.chart.title": "Histórico de tráfego", + "debug.chart.in": "entrada", + "debug.chart.out": "saída", + "debug.chart.hint": "Atualiza a cada 500 ms · últimas 60 amostras (30 segundos). Entrada acima da linha, saída abaixo.", "debug.status.active": "Ativo", "debug.status.stopped": "Parado", "debug.status.idle": "Ocioso", "debug.status.unavailable": "Indisponível", "debug.sender.none": "nenhum", + "debug.value.yes": "Sim", + "debug.value.no": "Não", "logs.filter.info": "Info", "logs.filter.warn": "Aviso", "logs.filter.error": "Erro", diff --git a/web/style.css b/web/style.css index fdf63e9..d553b94 100644 --- a/web/style.css +++ b/web/style.css @@ -39,6 +39,9 @@ --log-error-text: #F87171; --log-msg: #D1D5DB; --log-source: #A78BFA; + + --traffic-in: #4FE3FF; + --traffic-out: #A78BFA; } * { @@ -339,7 +342,7 @@ button:disabled, } .btn-icon .emoji-icon { width: 18px; height: 18px; vertical-align: 0; } .flow-arrow .emoji-icon, -.pipe-arrow .emoji-icon { width: 1em; height: 1em; vertical-align: -0.15em; } +.pipe-dir .emoji-icon { width: 1em; height: 1em; vertical-align: -0.15em; } .section-title .section-glyph { width: 16px; @@ -957,12 +960,13 @@ h2.section-title { font-size: 12px; } width: 560px; } -.debug-pipeline { +.debug-flow { display: flex; align-items: center; justify-content: center; gap: 6px; padding: 16px 0 20px; + flex-wrap: wrap; } .pipe-stage { @@ -974,7 +978,7 @@ h2.section-title { font-size: 12px; } background: var(--bg); border: 1px solid var(--outline); border-radius: var(--corner-button); - min-width: 100px; + min-width: 92px; transition: border-color 0.3s; } @@ -1002,18 +1006,30 @@ h2.section-title { font-size: 12px; } color: var(--text); } -.pipe-arrow { - color: var(--outline); - font-size: 18px; - font-weight: bold; +.pipe-edge { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 80px; +} + +.pipe-dir { + display: flex; + align-items: center; + gap: 4px; + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-muted); transition: color 0.3s; } -.pipe-arrow.pipe-flow { color: var(--success); } +.pipe-dir-rev { flex-direction: row-reverse; } + +.pipe-dir.pipe-flow { color: var(--success); } .debug-grid { display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 8px; margin-bottom: 16px; } @@ -1021,6 +1037,8 @@ h2.section-title { font-size: 12px; } .debug-stat { display: flex; justify-content: space-between; + align-items: baseline; + gap: 8px; padding: 8px 12px; background: var(--bg); border: 1px solid var(--outline); @@ -1045,34 +1063,121 @@ h2.section-title { font-size: 12px; } .debug-err { color: var(--error); } .debug-chart { - min-height: 70px; + min-height: 88px; +} + +.chart-scale { display: flex; - align-items: flex-end; + justify-content: space-between; + font-family: var(--font-mono); + font-size: 10px; + margin-bottom: 4px; } -.chart-bars { +.chart-scale-rx { color: var(--traffic-in); } +.chart-scale-tx { color: var(--traffic-out); } + +.chart-cols { display: flex; - align-items: flex-end; + align-items: stretch; gap: 2px; + height: 72px; +} + +.chart-col { flex: 1; - height: 60px; + min-width: 3px; + display: flex; + flex-direction: column; } -.chart-bar { +.chart-half-up { flex: 1; - min-width: 4px; - border-radius: 2px 2px 0 0; + display: flex; + align-items: flex-end; + border-bottom: 1px solid var(--outline); +} + +.chart-half-down { + flex: 1; + display: flex; + align-items: flex-start; +} + +.chart-seg { + width: 100%; transition: height 0.2s; } -.chart-max { +.chart-seg-rx { + background: var(--traffic-in); + border-radius: 2px 2px 0 0; +} + +.chart-seg-tx { + background: var(--traffic-out); + border-radius: 0 0 2px 2px; +} + +.debug-backends { + display: flex; + flex-direction: column; + gap: 8px; +} + +.debug-backend { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + background: var(--bg); + border: 1px solid var(--outline); + border-radius: var(--corner-button); +} + +.debug-backend-icon { + width: 20px; + height: 20px; + flex: 0 0 auto; +} + +.debug-backend-body { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1; +} + +.debug-backend-name { + font-family: var(--font-sans); + font-size: 12px; + font-weight: 600; + color: var(--text); +} + +.debug-backend-meta { + font-family: var(--font-sans); + font-size: 11px; + color: var(--text-muted); +} + +.debug-backend-right { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; + text-align: right; +} + +.debug-backend-version { font-family: var(--font-mono); - font-size: 10px; + font-size: 11px; color: var(--text-muted); - writing-mode: vertical-rl; - margin-left: 6px; } +.debug-backend-bundled { margin-left: 6px; } + .log-controls { display: flex; justify-content: space-between;