From c830c775659e6686c66f364a5c2864e066cf2d29 Mon Sep 17 00:00:00 2001 From: Threepwood-7 Date: Sun, 25 Oct 2026 18:40:18 +0100 Subject: [PATCH] feat(diagnostics): capture operational snapshots (RUST-CI-031) --- crates/emulebb-rest/src/diagnostic_bundle.rs | 137 ++++++++++++++++++- crates/emulebb-rest/src/tests/route_app.rs | 17 ++- webui/tests/unit/diagnostics-view.test.tsx | 2 +- 3 files changed, 151 insertions(+), 5 deletions(-) diff --git a/crates/emulebb-rest/src/diagnostic_bundle.rs b/crates/emulebb-rest/src/diagnostic_bundle.rs index 026e9700..3886f0b5 100644 --- a/crates/emulebb-rest/src/diagnostic_bundle.rs +++ b/crates/emulebb-rest/src/diagnostic_bundle.rs @@ -9,20 +9,31 @@ use std::{ use anyhow::Result; use chrono::Utc; +use emulebb_core::Search; use serde_json::{Value, json}; use crate::{ RestState, handlers::logs::recent_log_values, - responses::{app_info_response, runtime_diagnostics_response, status_response}, + responses::{ + app_info_response, runtime_diagnostics_response, search_results_response, + search_session_response, server_response, shared_file_response, status_response, + }, }; -pub(crate) const DIAGNOSTIC_BUNDLE_SCHEMA: &str = "emulebb.diagnostic-bundle.v4"; +pub(crate) const DIAGNOSTIC_BUNDLE_SCHEMA: &str = "emulebb.diagnostic-bundle.v5"; const MAX_DIRECTORY_ENTRIES: usize = 4_096; const MAX_LOG_RECORDS: usize = 200; const MAX_TAIL_BYTES: u64 = 256 * 1024; const MAX_TAIL_RECORDS: usize = 64; +const MAX_SERVER_SNAPSHOTS: usize = 512; +const MAX_SHARED_FILE_SNAPSHOTS: usize = 512; +const MAX_SEARCH_SNAPSHOTS: usize = 64; +const MAX_SEARCH_RESULTS_PER_SNAPSHOT: usize = 128; +const MAX_TRANSFER_SNAPSHOTS: usize = 512; +const MAX_UPLOAD_SNAPSHOTS: usize = 512; +const MAX_UPLOAD_QUEUE_SNAPSHOTS: usize = 512; const ARTIFACT_FAMILIES: &[ArtifactFamilySpec] = &[ArtifactFamilySpec { family: "unifiedEvents", @@ -90,6 +101,7 @@ pub(crate) async fn capture(state: &RestState) -> Result { unified_run.as_ref(), ); let logs = recent_log_values(MAX_LOG_RECORDS); + let operational_snapshots = operational_snapshots(state).await; let payload = json!({ "schema": DIAGNOSTIC_BUNDLE_SCHEMA, @@ -105,6 +117,7 @@ pub(crate) async fn capture(state: &RestState) -> Result { "apiKey": state.api_key.as_str(), }, "recentLogs": logs, + "operationalSnapshots": operational_snapshots, "diagnosticHealth": { "writerIssues": writer_issues, }, @@ -119,6 +132,13 @@ pub(crate) async fn capture(state: &RestState) -> Result { "directoryEntriesScanned": MAX_DIRECTORY_ENTRIES, "tailBytesPerArtifact": MAX_TAIL_BYTES, "tailRecordsPerArtifact": MAX_TAIL_RECORDS, + "serverSnapshots": MAX_SERVER_SNAPSHOTS, + "sharedFileSnapshots": MAX_SHARED_FILE_SNAPSHOTS, + "searchSnapshots": MAX_SEARCH_SNAPSHOTS, + "searchResultsPerSnapshot": MAX_SEARCH_RESULTS_PER_SNAPSHOT, + "transferSnapshots": MAX_TRANSFER_SNAPSHOTS, + "uploadSnapshots": MAX_UPLOAD_SNAPSHOTS, + "uploadQueueSnapshots": MAX_UPLOAD_QUEUE_SNAPSHOTS, }, "summary": { "artifactFileCount": artifacts.file_count, @@ -130,6 +150,100 @@ pub(crate) async fn capture(state: &RestState) -> Result { Ok(payload) } +async fn operational_snapshots(state: &RestState) -> Value { + let mut servers = state.core.servers().await; + servers.sort_by(|left, right| left.endpoint.cmp(&right.endpoint)); + let (shared_files, shared_file_total) = + state.core.shares_page(0, MAX_SHARED_FILE_SNAPSHOTS).await; + let mut searches = state.core.searches().await; + searches.sort_by(|left, right| { + left.created_at + .cmp(&right.created_at) + .then_with(|| left.id.cmp(&right.id)) + }); + let mut transfers = state.core.transfers().await; + transfers.sort_by(|left, right| { + left.added_at + .cmp(&right.added_at) + .then_with(|| left.hash.cmp(&right.hash)) + }); + let uploads = state.core.uploads().await; + let upload_queue = state.core.upload_queue().await; + json!({ + "servers": bounded_prefix_collection( + &servers, + servers.len(), + MAX_SERVER_SNAPSHOTS, + server_response, + ), + "sharedFiles": bounded_prefix_collection( + &shared_files, + shared_file_total, + MAX_SHARED_FILE_SNAPSHOTS, + |share| json!(shared_file_response(share)), + ), + "searches": bounded_tail_collection(&searches, MAX_SEARCH_SNAPSHOTS, search_snapshot), + "transfers": bounded_tail_collection(&transfers, MAX_TRANSFER_SNAPSHOTS, |transfer| json!(transfer)), + "uploads": bounded_prefix_collection( + &uploads, + uploads.len(), + MAX_UPLOAD_SNAPSHOTS, + |upload| json!(upload), + ), + "uploadQueue": bounded_prefix_collection( + &upload_queue, + upload_queue.len(), + MAX_UPLOAD_QUEUE_SNAPSHOTS, + |upload| json!(upload), + ), + }) +} + +fn bounded_tail_collection(items: &[T], limit: usize, render: impl Fn(&T) -> Value) -> Value { + let omitted_before_limit = items.len().saturating_sub(limit); + let retained = &items[omitted_before_limit..]; + json!({ + "total": items.len(), + "included": retained.len(), + "omittedBeforeLimit": omitted_before_limit, + "limit": limit, + "items": retained.iter().map(render).collect::>(), + }) +} + +fn bounded_prefix_collection( + items: &[T], + total: usize, + limit: usize, + render: impl Fn(&T) -> Value, +) -> Value { + let included = items.len().min(limit); + let retained = &items[..included]; + json!({ + "total": total, + "included": retained.len(), + "omittedAfterLimit": total.saturating_sub(retained.len()), + "limit": limit, + "items": retained.iter().map(render).collect::>(), + }) +} + +fn search_snapshot(search: &Search) -> Value { + let included = search.results.len().min(MAX_SEARCH_RESULTS_PER_SNAPSHOT); + let retained = &search.results[..included]; + let mut snapshot = search_session_response(search); + snapshot["createdAt"] = json!(search.created_at); + snapshot["updatedAt"] = json!(search.updated_at); + snapshot["results"] = json!({ + "total": search.results.len(), + "included": retained.len(), + "omittedAfterLimit": search.results.len().saturating_sub(retained.len()), + "limit": MAX_SEARCH_RESULTS_PER_SNAPSHOT, + "items": search_results_response(retained, true), + }); + snapshot +} + fn diagnostic_writer_issues(runtime_diagnostics: &Value) -> Vec { let Some(writer) = runtime_diagnostics.get("diagnosticWriter") else { return vec![json!({"code": "diagnosticWriterMissing"})]; @@ -575,6 +689,25 @@ mod tests { assert_eq!(encode_hex(b"{not-json:\xff}"), "7b6e6f742d6a736f6e3aff7d"); } + #[test] + fn bounded_operational_collections_report_exact_omissions() { + let values = ["zero", "one", "two", "three"]; + + let tail = bounded_tail_collection(&values, 2, |value| json!(value)); + let prefix = bounded_prefix_collection(&values, values.len(), 2, |value| json!(value)); + + assert_eq!(tail["total"], 4); + assert_eq!(tail["included"], 2); + assert_eq!(tail["omittedBeforeLimit"], 2); + assert_eq!(tail["limit"], 2); + assert_eq!(tail["items"], json!(["two", "three"])); + assert_eq!(prefix["total"], 4); + assert_eq!(prefix["included"], 2); + assert_eq!(prefix["omittedAfterLimit"], 2); + assert_eq!(prefix["limit"], 2); + assert_eq!(prefix["items"], json!(["zero", "one"])); + } + #[test] fn disabled_writer_adds_no_bundle_health_issue() { let issues = diagnostic_writer_issues(&json!({ diff --git a/crates/emulebb-rest/src/tests/route_app.rs b/crates/emulebb-rest/src/tests/route_app.rs index a54d5550..bc3a3230 100644 --- a/crates/emulebb-rest/src/tests/route_app.rs +++ b/crates/emulebb-rest/src/tests/route_app.rs @@ -99,7 +99,7 @@ async fn diagnostic_bundle_is_returned_inline_without_daemon_persistence() { assert!(value["data"].get("fullMemoryIncluded").is_none()); assert!(value["data"].get("path").is_none()); assert!(value["data"].get("byteCount").is_none()); - assert_eq!(value["data"]["schema"], "emulebb.diagnostic-bundle.v4"); + assert_eq!(value["data"]["schema"], "emulebb.diagnostic-bundle.v5"); assert_eq!(value["data"]["summary"]["artifactFileCount"], 0); assert_eq!( value["data"]["summary"]["missingArtifactFamilies"] @@ -111,7 +111,7 @@ async fn diagnostic_bundle_is_returned_inline_without_daemon_persistence() { assert!(!runtime_dir.join("diagnostics").exists()); let bundle = &value["data"]; - assert_eq!(bundle["schema"], "emulebb.diagnostic-bundle.v4"); + assert_eq!(bundle["schema"], "emulebb.diagnostic-bundle.v5"); assert!(bundle.get("memory").is_none()); assert_eq!(bundle["settingsProvenance"]["apiKeyIncluded"], true); assert_eq!(bundle["settingsProvenance"]["sensitiveValues"], "exact"); @@ -134,8 +134,21 @@ async fn diagnostic_bundle_is_returned_inline_without_daemon_persistence() { ); assert!(bundle["settings"].is_object()); assert!(bundle["recentLogs"].is_array()); + assert!(bundle["operationalSnapshots"]["servers"]["items"].is_array()); + assert!(bundle["operationalSnapshots"]["sharedFiles"]["items"].is_array()); + assert!(bundle["operationalSnapshots"]["searches"]["items"].is_array()); + assert!(bundle["operationalSnapshots"]["transfers"]["items"].is_array()); + assert!(bundle["operationalSnapshots"]["uploads"]["items"].is_array()); + assert!(bundle["operationalSnapshots"]["uploadQueue"]["items"].is_array()); assert!(bundle["diagnosticArtifacts"]["families"].is_array()); assert_eq!(bundle["limits"]["artifactFamilies"], 1); + assert_eq!(bundle["limits"]["serverSnapshots"], 512); + assert_eq!(bundle["limits"]["sharedFileSnapshots"], 512); + assert_eq!(bundle["limits"]["searchSnapshots"], 64); + assert_eq!(bundle["limits"]["searchResultsPerSnapshot"], 128); + assert_eq!(bundle["limits"]["transferSnapshots"], 512); + assert_eq!(bundle["limits"]["uploadSnapshots"], 512); + assert_eq!(bundle["limits"]["uploadQueueSnapshots"], 512); assert!(serde_json::to_string(&bundle).unwrap().contains("secret")); } diff --git a/webui/tests/unit/diagnostics-view.test.tsx b/webui/tests/unit/diagnostics-view.test.tsx index fd8b8953..c06a0f3b 100644 --- a/webui/tests/unit/diagnostics-view.test.tsx +++ b/webui/tests/unit/diagnostics-view.test.tsx @@ -13,7 +13,7 @@ afterEach(() => { describe("DiagnosticsView", () => { it("downloads caller-owned inline diagnostic bundles", async () => { const bundle = { - schema: "emulebb.diagnostic-bundle.v4", + schema: "emulebb.diagnostic-bundle.v5", capturedAt: "2026-10-25T12:34:56Z", summary: { artifactFileCount: 1, missingArtifactFamilies: [], issueCount: 0 } };