Skip to content

Commit e7660b3

Browse files
committed
Debug: SMB diagnostics dashboard
A new panel in the Debug window (⌘D in dev) that polls smb2's `SmbClient::diagnostics()` and renders the snapshot live. Per-volume picker, auto-refresh (250 ms — 5 s), summary band (server / dialect / RTT / sig+enc+comp), and 9 cards covering credits + in-flight + next msg id, wire bytes ↑↓, requests sent + caller-side errors, the 4-way routing partition (ok / wire-err / late-after-drop / stray), protocol events (STATUS_PENDING / unsolicited), error counters by type, session info, negotiated parameters, client-level counters, and the DFS cache (when populated). 24 (i) info-icons with substantive tooltips explaining each metric and why it's interesting. Backend - Bump `smb2 = "0.11.0"` with the new `serde` feature for the Serialize impls. - Add `fn as_any(&self) -> &dyn std::any::Any` to the `Volume` trait so SMB-only IPC can downcast `Arc<dyn Volume>` to `&SmbVolume`. Implemented across all 12 concrete + test impls. - Add `SmbVolume::diagnostics() -> Option<smb2::Diagnostics>` — briefly locks the client mutex and calls smb2's snapshot accessor (cheap atomic loads, no I/O). - New IPC commands `list_smb_volumes` / `get_smb_diagnostics` in `commands/smb_diagnostics.rs`, registered through ipc.rs + ipc_collectors.rs. Mirror DTOs (`SmbDiagnosticsDto` and friends) with `specta::Type` so the bindings are typed end-to-end; smb2 itself doesn't (and shouldn't) depend on specta. JSON shape is TS-friendly — `Duration` becomes `*_ms: number`, enums become strings, `Capabilities` becomes its `u32` bits. Frontend - `DebugSmbDiagnosticsPanel.svelte`: Svelte 5 runes, typed `commands.listSmbVolumes()` / `commands.getSmbDiagnostics()`, cmdr's `tooltip` action, design tokens (no raw px). - Registered in `routes/debug/+page.svelte` between the Drive index panel and Toast notifications. Verified end-to-end against the real NAS at 192.168.1.111 via the Tauri MCP bridge — 24 (i) icons, 9 cards, counters tick live. `svelte-check` clean across 1150 files, `cargo check` clean.
1 parent 612ae8a commit e7660b3

21 files changed

Lines changed: 1433 additions & 3 deletions

Cargo.lock

Lines changed: 3 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/desktop/src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ mdns-sd = { version = "0.19", features = ["logging"] }
170170
# SMB2/3 protocol client for share enumeration (pure Rust, pipelined I/O).
171171
# When bumping: also re-vendor the test containers per
172172
# apps/desktop/test/smb-servers/.compose/VENDORED.md
173-
smb2 = "0.10.0"
173+
smb2 = { version = "0.11.0", features = ["serde"] }
174174
# NFD normalization for APFS collation and SMB path normalization
175175
unicode-normalization = "0.1"
176176
# Percent-decoding for MCP resource URI query strings (cross-platform).

apps/desktop/src-tauri/src/commands/file_system/listing.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,9 @@ mod refresh_listing_tests {
459459
fn root(&self) -> &Path {
460460
self.inner.root()
461461
}
462+
fn as_any(&self) -> &dyn std::any::Any {
463+
self
464+
}
462465

463466
fn list_directory<'a>(
464467
&'a self,

apps/desktop/src-tauri/src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ pub mod rename;
2020
pub mod restricted_paths;
2121
pub mod search;
2222
pub mod settings;
23+
pub mod smb_diagnostics;
2324
pub mod sync_status; // Has both macOS and non-macOS implementations
2425
pub mod ui;
2526
mod util;
Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
1+
//! Tauri commands for the SMB diagnostics dashboard (debug-window only).
2+
//!
3+
//! Two commands:
4+
//! - [`list_smb_volumes`] — picker entries for the dashboard's volume selector
5+
//! - [`get_smb_diagnostics`] — snapshot of one volume's `smb2::SmbClient`
6+
//!
7+
//! The snapshot types are cmdr-side mirrors of `smb2::Diagnostics` &
8+
//! friends, with `specta::Type` derives so the typed bindings are
9+
//! end-to-end. Conversion is one `impl From<smb2::X> for XDto` per type.
10+
//! Two reasons we mirror instead of re-exporting:
11+
//!
12+
//! 1. `smb2` doesn't (and shouldn't) depend on `specta`.
13+
//! 2. We can pick a TS-friendly shape (e.g. `Duration` → milliseconds as
14+
//! `u64`, enums as `String`) without leaking Rust's `std::time::Duration`
15+
//! JSON shape (`{secs, nanos}`) to the frontend.
16+
17+
use crate::file_system::SmbVolume;
18+
use crate::file_system::get_volume_manager;
19+
20+
// ── DTO mirror types ──────────────────────────────────────────────────
21+
22+
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
23+
pub struct SmbVolumeRef {
24+
pub volume_id: String,
25+
pub name: String,
26+
pub server: String,
27+
pub disconnected: bool,
28+
}
29+
30+
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
31+
pub struct SmbDiagnosticsDto {
32+
pub client: ClientInfoDto,
33+
pub primary: ConnectionDiagnosticsDto,
34+
pub extra_connections: Vec<ConnectionDiagnosticsDto>,
35+
pub dfs_cache: Vec<DfsCacheEntryDto>,
36+
}
37+
38+
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
39+
pub struct ClientInfoDto {
40+
pub primary_server: String,
41+
pub timeout_ms: u64,
42+
pub auto_reconnect: bool,
43+
pub dfs_enabled: bool,
44+
pub metrics: ClientMetricsDto,
45+
}
46+
47+
#[derive(Debug, Clone, Copy, serde::Serialize, specta::Type)]
48+
pub struct ClientMetricsDto {
49+
pub reconnects: u64,
50+
pub dfs_referrals_resolved: u64,
51+
pub dfs_cache_hits: u64,
52+
}
53+
54+
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
55+
pub struct ConnectionDiagnosticsDto {
56+
pub server: String,
57+
pub negotiated: Option<NegotiatedSummaryDto>,
58+
pub credits: CreditInfoDto,
59+
pub signing: SigningInfoDto,
60+
pub encryption: EncryptionInfoDto,
61+
pub compression: CompressionInfoDto,
62+
pub rtt_estimate_ms: Option<f64>,
63+
pub disconnected: bool,
64+
pub dfs_trees: Vec<u32>,
65+
pub session: Option<SessionDiagnosticsDto>,
66+
pub metrics: MetricsSnapshotDto,
67+
}
68+
69+
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
70+
pub struct NegotiatedSummaryDto {
71+
pub dialect: String,
72+
pub max_read_size: u32,
73+
pub max_write_size: u32,
74+
pub max_transact_size: u32,
75+
pub server_guid_hex: String,
76+
pub signing_required: bool,
77+
pub capabilities_bits: u32,
78+
pub gmac_negotiated: bool,
79+
pub cipher: Option<String>,
80+
pub compression_supported: bool,
81+
}
82+
83+
#[derive(Debug, Clone, Copy, serde::Serialize, specta::Type)]
84+
pub struct CreditInfoDto {
85+
pub available: u16,
86+
pub in_flight: u32,
87+
pub next_message_id: u64,
88+
}
89+
90+
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
91+
pub struct SigningInfoDto {
92+
pub active: bool,
93+
pub algorithm: Option<String>,
94+
}
95+
96+
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
97+
pub struct EncryptionInfoDto {
98+
pub active: bool,
99+
pub cipher: Option<String>,
100+
}
101+
102+
#[derive(Debug, Clone, Copy, serde::Serialize, specta::Type)]
103+
pub struct CompressionInfoDto {
104+
pub requested: bool,
105+
pub negotiated: bool,
106+
}
107+
108+
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
109+
pub struct SessionDiagnosticsDto {
110+
pub session_id_hex: String,
111+
pub should_sign: bool,
112+
pub should_encrypt: bool,
113+
pub signing_algorithm: String,
114+
}
115+
116+
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
117+
pub struct DfsCacheEntryDto {
118+
pub path_prefix: String,
119+
pub target_count: u32,
120+
pub expires_in_ms: Option<u64>,
121+
}
122+
123+
#[derive(Debug, Clone, Copy, Default, serde::Serialize, specta::Type)]
124+
pub struct MetricsSnapshotDto {
125+
pub requests_sent: u64,
126+
pub compound_requests_sent: u64,
127+
pub wire_bytes_sent: u64,
128+
pub explicit_cancels_sent: u64,
129+
pub responses_routed_ok: u64,
130+
pub responses_routed_err: u64,
131+
pub responses_late_after_drop: u64,
132+
pub responses_stray: u64,
133+
pub wire_bytes_received: u64,
134+
pub status_pending_loops: u64,
135+
pub unsolicited_notifications_received: u64,
136+
pub signature_failures: u64,
137+
pub decrypt_failures: u64,
138+
pub decompress_failures: u64,
139+
pub malformed_frames: u64,
140+
pub session_expired_events: u64,
141+
pub requests_returned_err: u64,
142+
}
143+
144+
// ── Conversions ───────────────────────────────────────────────────────
145+
146+
impl From<smb2::Diagnostics> for SmbDiagnosticsDto {
147+
fn from(d: smb2::Diagnostics) -> Self {
148+
Self {
149+
client: d.client.into(),
150+
primary: d.primary.into(),
151+
extra_connections: d.extra_connections.into_iter().map(Into::into).collect(),
152+
dfs_cache: d.dfs_cache.into_iter().map(Into::into).collect(),
153+
}
154+
}
155+
}
156+
157+
impl From<smb2::ClientInfo> for ClientInfoDto {
158+
fn from(c: smb2::ClientInfo) -> Self {
159+
Self {
160+
primary_server: c.primary_server,
161+
timeout_ms: c.timeout.as_millis() as u64,
162+
auto_reconnect: c.auto_reconnect,
163+
dfs_enabled: c.dfs_enabled,
164+
metrics: ClientMetricsDto {
165+
reconnects: c.metrics.reconnects,
166+
dfs_referrals_resolved: c.metrics.dfs_referrals_resolved,
167+
dfs_cache_hits: c.metrics.dfs_cache_hits,
168+
},
169+
}
170+
}
171+
}
172+
173+
impl From<smb2::ConnectionDiagnostics> for ConnectionDiagnosticsDto {
174+
fn from(c: smb2::ConnectionDiagnostics) -> Self {
175+
Self {
176+
server: c.server,
177+
negotiated: c.negotiated.map(Into::into),
178+
credits: CreditInfoDto {
179+
available: c.credits.available,
180+
in_flight: c.credits.in_flight as u32,
181+
next_message_id: c.credits.next_message_id,
182+
},
183+
signing: SigningInfoDto {
184+
active: c.signing.active,
185+
algorithm: c.signing.algorithm.map(|a| format!("{:?}", a)),
186+
},
187+
encryption: EncryptionInfoDto {
188+
active: c.encryption.active,
189+
cipher: c.encryption.cipher.map(|cph| format!("{:?}", cph)),
190+
},
191+
compression: CompressionInfoDto {
192+
requested: c.compression.requested,
193+
negotiated: c.compression.negotiated,
194+
},
195+
rtt_estimate_ms: c.rtt_estimate.map(|d| d.as_secs_f64() * 1000.0),
196+
disconnected: c.disconnected,
197+
dfs_trees: c.dfs_trees.into_iter().map(|t| t.0).collect(),
198+
session: c.session.map(Into::into),
199+
metrics: MetricsSnapshotDto {
200+
requests_sent: c.metrics.requests_sent,
201+
compound_requests_sent: c.metrics.compound_requests_sent,
202+
wire_bytes_sent: c.metrics.wire_bytes_sent,
203+
explicit_cancels_sent: c.metrics.explicit_cancels_sent,
204+
responses_routed_ok: c.metrics.responses_routed_ok,
205+
responses_routed_err: c.metrics.responses_routed_err,
206+
responses_late_after_drop: c.metrics.responses_late_after_drop,
207+
responses_stray: c.metrics.responses_stray,
208+
wire_bytes_received: c.metrics.wire_bytes_received,
209+
status_pending_loops: c.metrics.status_pending_loops,
210+
unsolicited_notifications_received: c
211+
.metrics
212+
.unsolicited_notifications_received,
213+
signature_failures: c.metrics.signature_failures,
214+
decrypt_failures: c.metrics.decrypt_failures,
215+
decompress_failures: c.metrics.decompress_failures,
216+
malformed_frames: c.metrics.malformed_frames,
217+
session_expired_events: c.metrics.session_expired_events,
218+
requests_returned_err: c.metrics.requests_returned_err,
219+
},
220+
}
221+
}
222+
}
223+
224+
impl From<smb2::NegotiatedSummary> for NegotiatedSummaryDto {
225+
fn from(n: smb2::NegotiatedSummary) -> Self {
226+
Self {
227+
dialect: format!("{:?}", n.dialect),
228+
max_read_size: n.max_read_size,
229+
max_write_size: n.max_write_size,
230+
max_transact_size: n.max_transact_size,
231+
server_guid_hex: format!(
232+
"{:08x}-{:04x}-{:04x}-{}",
233+
n.server_guid.data1,
234+
n.server_guid.data2,
235+
n.server_guid.data3,
236+
n.server_guid
237+
.data4
238+
.iter()
239+
.map(|b| format!("{:02x}", b))
240+
.collect::<String>(),
241+
),
242+
signing_required: n.signing_required,
243+
capabilities_bits: n.capabilities.0,
244+
gmac_negotiated: n.gmac_negotiated,
245+
cipher: n.cipher.map(|c| format!("{:?}", c)),
246+
compression_supported: n.compression_supported,
247+
}
248+
}
249+
}
250+
251+
impl From<smb2::SessionDiagnostics> for SessionDiagnosticsDto {
252+
fn from(s: smb2::SessionDiagnostics) -> Self {
253+
Self {
254+
session_id_hex: format!("{:016x}", s.session_id.0),
255+
should_sign: s.should_sign,
256+
should_encrypt: s.should_encrypt,
257+
signing_algorithm: format!("{:?}", s.signing_algorithm),
258+
}
259+
}
260+
}
261+
262+
impl From<smb2::DfsCacheEntry> for DfsCacheEntryDto {
263+
fn from(e: smb2::DfsCacheEntry) -> Self {
264+
Self {
265+
path_prefix: e.path_prefix,
266+
target_count: e.target_count as u32,
267+
expires_in_ms: e.expires_in.map(|d| d.as_millis() as u64),
268+
}
269+
}
270+
}
271+
272+
// ── Commands ──────────────────────────────────────────────────────────
273+
274+
/// List every currently-registered SMB volume, with a one-line summary for
275+
/// the dashboard's volume picker. Returns an empty vec if no SMB volumes
276+
/// are mounted. A volume that's currently disconnected still shows up —
277+
/// `disconnected: true` indicates that, and the dashboard renders it
278+
/// distinctly so the user can see why diagnostics are stale.
279+
#[tauri::command]
280+
#[specta::specta]
281+
pub async fn list_smb_volumes() -> Vec<SmbVolumeRef> {
282+
let manager = get_volume_manager();
283+
let ids: Vec<(String, String)> = manager.list_volumes();
284+
let mut out = Vec::new();
285+
for (id, name) in ids {
286+
let Some(vol) = manager.get(&id) else { continue };
287+
// Hold the Arc<dyn Volume> across the await — downcast is sync,
288+
// returns a borrow tied to `vol`, so the await stays inside the
289+
// borrow's scope.
290+
if vol.as_any().downcast_ref::<SmbVolume>().is_none() {
291+
continue;
292+
}
293+
// Re-downcast to call the async diagnostics() method. Cheap.
294+
let server: String;
295+
let disconnected: bool;
296+
if let Some(smb) = vol.as_any().downcast_ref::<SmbVolume>() {
297+
match smb.diagnostics().await {
298+
Some(diag) => {
299+
server = diag.primary.server.clone();
300+
disconnected = diag.primary.disconnected;
301+
}
302+
None => {
303+
server = String::new();
304+
disconnected = true;
305+
}
306+
}
307+
} else {
308+
continue;
309+
}
310+
out.push(SmbVolumeRef {
311+
volume_id: id,
312+
name,
313+
server,
314+
disconnected,
315+
});
316+
}
317+
out
318+
}
319+
320+
/// Snapshot of the SMB client backing the given volume.
321+
///
322+
/// Returns `Err(message)` if the volume id is unknown, the volume isn't
323+
/// an SMB volume, or the volume is currently disconnected (no client to
324+
/// snapshot). Always cheap — internally a handful of atomic loads and
325+
/// short-critical-section mutex copies; no network I/O.
326+
#[tauri::command]
327+
#[specta::specta]
328+
pub async fn get_smb_diagnostics(volume_id: String) -> Result<SmbDiagnosticsDto, String> {
329+
let manager = get_volume_manager();
330+
let vol = manager
331+
.get(&volume_id)
332+
.ok_or_else(|| format!("no such volume: {volume_id}"))?;
333+
let smb_diag = {
334+
let smb = vol
335+
.as_any()
336+
.downcast_ref::<SmbVolume>()
337+
.ok_or_else(|| format!("volume {volume_id} is not an SMB volume"))?;
338+
smb.diagnostics().await
339+
};
340+
let diag = smb_diag.ok_or_else(|| format!("volume {volume_id} is disconnected"))?;
341+
Ok(diag.into())
342+
}

0 commit comments

Comments
 (0)