mirror of
https://github.com/Corfirean/coa-server-manager.git
synced 2026-11-11 20:01:01 +00:00
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:
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.
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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))}>
|
||||
|
||||
@@ -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>}
|
||||
|
||||
Reference in new issue
Block a user