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 <noreply@anthropic.com>
This commit is contained in:
ДмитрийandClaude Sonnet 5.5 committed 2026-10-08 08:33:36 +05:00
1 parent 838cc07e73
commit 1acbcc2bb5
6 files changed
+121 -7

No files matched your search

@@ -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.
+98 -2
View File
@@ -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<u16> {
.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<String> {
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::<serde_json::Value>(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<Status> {
let current = status(root);
@@ -189,7 +244,12 @@ pub fn start(root: &Path) -> Result<Status> {
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();
+10
View File
@@ -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<coa_core::dashboard::Status, UiError> {
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,
+1
View File
@@ -443,6 +443,7 @@ export const api = {
backups: (id: string) => invoke<RecoveryPoint[]>("list_backups", { id }),
dashboardStatus: (id: string) => invoke<DashboardStatus>("dashboard_status", { id }),
dashboardInstall: (id: string) => invoke<DashboardStatus>("dashboard_install", { id }),
dashboardOpen: (id: string) => invoke<void>("dashboard_open", { id }),
dashboardStart: (id: string) => invoke<DashboardStatus>("dashboard_start", { id }),
dashboardStop: (id: string) => invoke<DashboardStatus>("dashboard_stop", { id }),
backupLocation: (id: string) => invoke<BackupLocation>("backup_location", { id }),
+1 -1
View File
@@ -68,7 +68,7 @@ export function DashboardPage({ serverId }: { serverId: string }) {
{busy === "stop" && <Loader2 className="h-4 w-4 animate-spin" aria-hidden />}
{t("dash.stop")}
</Button>
<Button size="sm" variant="ghost" onClick={() => void api.openLink(status.url)}>{t("dash.openBrowser")}</Button>
<Button size="sm" variant="ghost" onClick={() => void api.dashboardOpen(serverId).catch((e) => setError(asUiError(e)))}>{t("dash.openBrowser")}</Button>
</>
) : (
<Button size="sm" variant="primary" disabled={!!busy} onClick={() => void run("start", () => api.dashboardStart(serverId))}>
+4 -4
View File
@@ -34,15 +34,15 @@ export function RealmPicker({ serverId, running, disabled, onChanged, onBusy }:
<div className="flex flex-wrap items-center gap-3">
<Layers className="h-5 w-5 text-gold" aria-hidden />
<label htmlFor="realm-profile" className="font-medium">{t("realm.profileLabel")}</label>
<select id="realm-profile" value={selected} disabled={!view || disabled || busy} onChange={e => setSelected(e.target.value as RealmMode)} className="min-w-52 rounded-md border border-line bg-card-2 px-3 py-2 text-ink">
{view?.simultaneous ? <span className="text-sm text-muted">Conquest of Azeroth + Wildcard</span> : <select id="realm-profile" value={selected} disabled={!view || disabled || busy} onChange={e => setSelected(e.target.value as RealmMode)} className="min-w-52 rounded-md border border-line bg-card-2 px-3 py-2 text-ink">
<option value="coa">Conquest of Azeroth</option>
<option value="wildcard" disabled={!view?.supported && !view?.wildcard_created}>Wildcard · Darkmoon</option>
</select>
{view && (selected !== view.active || view.recovery_pending) && <Button size="sm" disabled={disabled || busy} onClick={() => void select()}>
</select>}
{view && !view.simultaneous && (selected !== view.active || view.recovery_pending) && <Button size="sm" disabled={disabled || busy} onClick={() => void select()}>
{busy && <Loader2 className="h-4 w-4 animate-spin" aria-hidden />}
{busy ? t("realm.switching") : selected === "wildcard" && !view.wildcard_created ? t("realm.create") : running ? t("realm.restart") : t("realm.select")}
</Button>}
{view && <span className="ml-auto text-xs text-muted">{t("realm.active", { name: view.active === "coa" ? "CoA" : "Wildcard" })}</span>}
{view && !view.simultaneous && <span className="ml-auto text-xs text-muted">{t("realm.active", { name: view.active === "coa" ? "CoA" : "Wildcard" })}</span>}
</div>
<p className="mt-3 text-sm text-muted">{t(view?.simultaneous ? "realm.bothRunning" : "realm.separate")}</p>
{selected === "wildcard" && <p className="mt-2 text-sm text-warn">{t("realm.modules")}</p>}