From 1acbcc2bb5fb71ca0e4c03c673f1de7a5aeee503 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Thu, 8 Oct 2026 08:33:36 +0500 Subject: [PATCH] Fix the dashboard tab: large answers, damaged history files, browser link and realm drop-down The dashboard now runs through a small launcher that avoids the immediate socket shutdown that cut answers above 64 KB on Windows, supplies the missing level-builds file, removes history files zeroed by a crash, is addressed as 127.0.0.1 and opens in the browser through its own command. The realm drop-down is hidden while both worlds start together. Co-Authored-By: Claude Sonnet 5.5 --- changelog.d/20261008-dashboard-delivery.md | 7 ++ crates/coa-core/src/dashboard.rs | 100 ++++++++++++++++++++- src-tauri/src/lib.rs | 10 +++ src/lib/api.ts | 1 + src/screens/DashboardPage.tsx | 2 +- src/screens/RealmPicker.tsx | 8 +- 6 files changed, 121 insertions(+), 7 deletions(-) create mode 100644 changelog.d/20261008-dashboard-delivery.md diff --git a/changelog.d/20261008-dashboard-delivery.md b/changelog.d/20261008-dashboard-delivery.md new file mode 100644 index 0000000..5caa410 --- /dev/null +++ b/changelog.d/20261008-dashboard-delivery.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: The bot dashboard loads completely and can be opened in the browser +--- +The dashboard no longer fails on large pages, starts again after a crash left its history files empty, and its Open in the browser button works. With both realms started together the realm drop-down is hidden. diff --git a/crates/coa-core/src/dashboard.rs b/crates/coa-core/src/dashboard.rs index 15b6b14..5a4b76b 100644 --- a/crates/coa-core/src/dashboard.rs +++ b/crates/coa-core/src/dashboard.rs @@ -101,7 +101,7 @@ pub fn status(root: &Path) -> Status { squid_tag: squid, running, port, - url: format!("http://localhost:{port}"), + url: format!("http://127.0.0.1:{port}"), } } @@ -174,6 +174,61 @@ fn free_port(preferred: u16) -> Result { .ok_or_else(|| Error::Invalid("No free port was found for the dashboard.".into())) } +/// A crash or power loss while the dashboard saves its history can leave a file of the right length that holds only +/// zero bytes, and the dashboard then fails to start on it. Those files carry nothing, and the dashboard recreates them, +/// so they are removed. The files that come with the release are never touched here (reinstalling restores them). +fn discard_damaged_data(folder: &Path) -> Vec { + const SHIPPED: &[&str] = &["worldmap.json", "dashboard.example.json"]; + let mut removed = Vec::new(); + let Ok(entries) = std::fs::read_dir(folder) else { return removed; }; + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + let path = entry.path(); + if !name.ends_with(".json") || name.starts_with('.') || SHIPPED.contains(&name.as_str()) || !path.is_file() { continue; } + let Ok(bytes) = std::fs::read(&path) else { continue; }; + if !bytes.is_empty() && bytes.iter().all(|b| *b == 0) && std::fs::remove_file(&path).is_ok() { + tracing::warn!(file = %path.display(), "dashboard: removed a data file damaged by an interrupted write"); + removed.push(name); + } + } + removed +} + +/// The dashboard reads `coa-level-builds.json` (class and specialization names) from its own folder; the CoA Bots repack +/// ships it, the GitHub release does not. The server's own reference file has the same content and shape. +fn ensure_builds_file(root: &Path) { + let target = dir(root).join("coa-level-builds.json"); + if target.is_file() { return; } + let reference = root.join("Core/reference/ascensionsidekick-level-builds.json"); + let content = std::fs::read(&reference).ok().filter(|bytes| serde_json::from_slice::(bytes).is_ok_and(|v| v.is_object())); + let _ = fsx::atomic_write(&target, content.as_deref().unwrap_or(b"{}")); +} + +/// Python's HTTP server closes each connection with `shutdown(SHUT_WR)` straight after the answer. On Windows that can +/// throw away what is still queued: any answer above about 64 KB (the dashboard's statistics are 400 KB) arrives cut +/// off, and the page fails to load. This small launcher waits for the client to finish reading and close instead, queues +/// more waiting connections than Python's five, and then runs the dashboard unchanged. +const LAUNCHER: &str = r#"import runpy, socketserver, sys, http.server + +http.server.ThreadingHTTPServer.request_queue_size = 256 + + +def shutdown_request(self, request): + try: + request.settimeout(5) + while request.recv(4096): + pass + except OSError: + pass + self.close_request(request) + + +socketserver.TCPServer.shutdown_request = shutdown_request +script = sys.argv[1] +sys.argv = [script] +runpy.run_path(script, run_name="__main__") +"#; + /// Start the dashboard with the repack's own Python. Returns when it answers on its port. pub fn start(root: &Path) -> Result { let current = status(root); @@ -189,7 +244,12 @@ pub fn start(root: &Path) -> Result { let log_path = folder.join("dashboard.log"); let log = std::fs::File::create(&log_path)?; let mut command = std::process::Command::new(&python); - command.arg("-B").arg("squidbots.py").current_dir(&folder).env("COA_DASHBOARD_PORT", marker.port.to_string()) + ensure_builds_file(root); + discard_damaged_data(&folder); + let script = folder.join("squidbots.py"); + let launcher = folder.join("manager_launch.py"); + fsx::atomic_write(&launcher, LAUNCHER.as_bytes())?; + command.arg("-B").arg(&launcher).arg(&script).current_dir(&folder).env("COA_DASHBOARD_PORT", marker.port.to_string()) .stdin(std::process::Stdio::null()).stdout(log.try_clone()?).stderr(log); #[cfg(windows)] { @@ -256,6 +316,20 @@ mod tests { assert!(same_series("v1.9", "v1.9.1") && !same_series("v1.9", "v1.8.9")); } + #[test] + fn zero_filled_history_files_are_removed_but_shipped_and_valid_files_stay() { + let folder = tempfile::tempdir().unwrap(); + std::fs::write(folder.path().join("history.json"), vec![0u8; 358]).unwrap(); + std::fs::write(folder.path().join("levels.json"), vec![0u8; 100]).unwrap(); + std::fs::write(folder.path().join("xp-history.json"), "[1, 2]").unwrap(); + std::fs::write(folder.path().join("worldmap.json"), vec![0u8; 50]).unwrap(); + std::fs::write(folder.path().join("empty.json"), "").unwrap(); + let mut removed = discard_damaged_data(folder.path()); + removed.sort(); + assert_eq!(removed, ["history.json", "levels.json"]); + assert!(folder.path().join("xp-history.json").exists() && folder.path().join("worldmap.json").exists() && folder.path().join("empty.json").exists()); + } + #[test] fn tags_are_checked_before_they_reach_a_url() { assert!(safe_tag("v1.9.1") && !safe_tag("v1.9/../x") && !safe_tag("") && !safe_tag("a b")); @@ -275,6 +349,28 @@ mod tests { assert!(!installed.running); } + /// Needs a real server folder (`COA_TEST_ROOT`) whose bots are installed and whose database runs: + /// `COA_TEST_ROOT=... cargo test -p coa-core dashboard -- --ignored --nocapture`. + #[test] + #[ignore] + fn the_started_dashboard_delivers_its_large_statistics() { + let root = PathBuf::from(std::env::var("COA_TEST_ROOT").expect("COA_TEST_ROOT")); + let started = start(&root).unwrap(); + assert!(started.running, "{started:?}"); + let client = reqwest::blocking::Client::builder().timeout(std::time::Duration::from_secs(90)).build().unwrap(); + let body = client.get(format!("{}/api/stats", started.url)).send().unwrap().bytes().unwrap(); + println!("stats: {} bytes: {}", body.len(), String::from_utf8_lossy(&body[..body.len().min(300)])); + let live = client.get(format!("{}/api/live", started.url)).send().unwrap().bytes().unwrap(); + stop(&root).unwrap(); + assert!(!status(&root).running); + if String::from_utf8_lossy(&body).starts_with("{\"error\"") { + println!("the database of this server is not running, so the large answer could not be checked"); + } else { + assert!(body.len() > 100_000, "the statistics must arrive whole, got {} bytes", body.len()); + } + assert!(!live.is_empty()); + } + #[test] fn a_folder_without_the_dashboard_is_not_installed_or_running() { let root = tempfile::tempdir().unwrap(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 06cfeff..fa60d83 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -377,6 +377,15 @@ async fn dashboard_install(app: AppHandle, state: State<'_, AppState>, id: Strin blocking(move || coa_core::dashboard::install(&root, &|step| { let _ = app.emit("dashboard-progress", step); })).await } +#[tauri::command] +async fn dashboard_open(state: State<'_, AppState>, id: String) -> std::result::Result<(), UiError> { + let root = path_of(&state, &id)?; + let status = blocking(move || Ok(coa_core::dashboard::status(&root))).await?; + if !status.running { return Err(Error::Invalid("The dashboard is not running.".into()).into()); } + tauri_plugin_opener::open_url(&status.url, None::<&str>).map_err(|e| Error::Invalid(e.to_string()))?; + Ok(()) +} + #[tauri::command] async fn dashboard_start(state: State<'_, AppState>, id: String) -> std::result::Result { let root = path_of(&state, &id)?; @@ -1598,6 +1607,7 @@ pub fn run() { dashboard_status, dashboard_install, dashboard_start, + dashboard_open, dashboard_stop, backup_location, set_backup_location, diff --git a/src/lib/api.ts b/src/lib/api.ts index d79fe75..b580709 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -443,6 +443,7 @@ export const api = { backups: (id: string) => invoke("list_backups", { id }), dashboardStatus: (id: string) => invoke("dashboard_status", { id }), dashboardInstall: (id: string) => invoke("dashboard_install", { id }), + dashboardOpen: (id: string) => invoke("dashboard_open", { id }), dashboardStart: (id: string) => invoke("dashboard_start", { id }), dashboardStop: (id: string) => invoke("dashboard_stop", { id }), backupLocation: (id: string) => invoke("backup_location", { id }), diff --git a/src/screens/DashboardPage.tsx b/src/screens/DashboardPage.tsx index ee94e0f..e41a763 100644 --- a/src/screens/DashboardPage.tsx +++ b/src/screens/DashboardPage.tsx @@ -68,7 +68,7 @@ export function DashboardPage({ serverId }: { serverId: string }) { {busy === "stop" && } {t("dash.stop")} - + ) : ( } - {view && {t("realm.active", { name: view.active === "coa" ? "CoA" : "Wildcard" })}} + {view && !view.simultaneous && {t("realm.active", { name: view.active === "coa" ? "CoA" : "Wildcard" })}}

{t(view?.simultaneous ? "realm.bothRunning" : "realm.separate")}

{selected === "wildcard" &&

{t("realm.modules")}

}