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;