Show Overview update failures and cover copied launcher configuration

This commit is contained in:
Дмитрий committed 2026-10-14 08:26:19 +05:00
1 parent 24d593d787
commit be2f252593
6 files changed
+114 -7

No files matched your search

@@ -0,0 +1,7 @@
---
area: manager
type: fixed
audience: admins
title: Failed server updates show an error on the Overview screen
---
The error stays visible after the progress bar disappears, with technical details and a copy button. Recovery-state errors are shown there too.
+4
View File
@@ -420,6 +420,8 @@ mod tests {
let mut server = start();
db.query("CREATE DATABASE acore_characters; CREATE TABLE acore_characters.shutdown_probe(id INT PRIMARY KEY); INSERT INTO acore_characters.shutdown_probe VALUES(73);").unwrap();
db.query("CREATE DATABASE acore_auth; CREATE DATABASE acore_world; CREATE VIEW acore_characters.shutdown_view AS SELECT id FROM acore_characters.shutdown_probe;").unwrap();
let copied_configuration = serde_json::json!({"root":temp.path().join("original-installation"),"ports":{"mysqlPort":ports[0],"authPort":ports[1],"worldPort":ports[2],"raPort":ports[3]}});
fsx::atomic_write_json(&root.join(".state/configuration.json"), &copied_configuration).unwrap();
let meta = crate::registry::metadata_dir_for(&root).unwrap();
crate::registry::MetaDir::create(&root, &crate::registry::InstallMeta::new(crate::registry::InstallKind::New, &root)).unwrap();
let point = crate::backup::create(&root, &meta, crate::backup::Kind::Full, crate::backup::Trigger::BeforeRepair, None, &|_| {}).unwrap();
@@ -428,6 +430,8 @@ mod tests {
crate::update::RepackEnv { root: &root, meta_dir: &meta }.verify_recovery_point(&point).unwrap();
assert!(server.0.wait().unwrap().success());
assert_eq!(fs::read(root.join("Settings/worldserver.conf.template")).unwrap(), original);
assert_eq!(fsx::read_json::<serde_json::Value>(&root.join(".state/configuration.json")).unwrap(), copied_configuration);
fs::remove_file(root.join(".state/configuration.json")).unwrap();
let mut restarted = start();
assert_eq!(db.query("SELECT id FROM acore_characters.shutdown_probe;").unwrap(), "73");
fsx::atomic_write_json(&root.join(".state/mysql.json"), &crate::process::identity(std::process::id()).unwrap()).unwrap();
+15
View File
@@ -64,6 +64,21 @@ for startup readiness, after executable/port/creation-time checks.
These are package/SQL/recovery tests on representative minimal database tables;
they do not claim successful client login, combat or an affected user's installation.
## Overview update failure at 2%
The additional screenshot reports a failed StopAll inside a private rehearsal,
with the launcher's moved-folder/changed-port guard, followed by a disappearing
progress bar on Overview. The shutdown adapter already bypasses startup preparation.
The native recovery test now also supplies a copied configuration signature with
an old installation path, verifies shutdown preserves it, and retains its database
row/view recovery assertions.
Overview previously rendered only its local start/stop failure, ignoring stored
update and recovery-state errors. It now displays those errors with the existing
technical-details and copy controls, even when the update offer and progress bar
are gone. A new update attempt clears an older local action error. Render tests
reproduced the missing alerts before the fix; all 12 frontend tests pass afterward.
## Validation
- Workspace tests, frontend build, frontend logic tests, translation validation,
+13 -7
View File
@@ -99,7 +99,13 @@ export function Overview({ server, companions = true, onForget, onOpenUpdates, o
const [keepMine, setKeepMine] = useState(true);
const [perf, setPerf] = useState<Performance | null>(null);
const upd = useServerUpdate(server.id);
const visibleFailure = failure ?? upd.stateError ?? upd.uiError;
const needsDecision = (upd.preview?.conflicts.length ?? 0) > 0;
const runServerUpdate = async () => {
setFailure(null);
setShowDetails(false);
await applyServerUpdate(server.id);
};
const [startBots, setStartBots] = useState<number | null | undefined>(undefined);
const [autoRestart, setAutoRestart] = useAutoRestart(server.id);
const autoRestartRef = useRef(autoRestart);
@@ -401,7 +407,7 @@ export function Overview({ server, companions = true, onForget, onOpenUpdates, o
variant="primary"
size="xl"
disabled={transitioning || upd.applying}
onClick={() => (needsDecision ? onOpenUpdates() : void applyServerUpdate(server.id))}
onClick={() => (needsDecision ? onOpenUpdates() : void runServerUpdate())}
className="min-w-56"
>
{upd.applying && <Loader2 className="h-5 w-5 animate-spin" aria-hidden />}
@@ -424,7 +430,7 @@ export function Overview({ server, companions = true, onForget, onOpenUpdates, o
</Button>
)}
{running && upd.available && (
<Button variant="secondary" size="sm" disabled={transitioning || upd.applying} onClick={() => (needsDecision ? onOpenUpdates() : void applyServerUpdate(server.id))}>
<Button variant="secondary" size="sm" disabled={transitioning || upd.applying} onClick={() => (needsDecision ? onOpenUpdates() : void runServerUpdate())}>
{upd.applying && <Loader2 className="h-4 w-4 animate-spin" aria-hidden />}
{needsDecision ? t("overview.openUpdates") : t("btn.updateTo", { v: upd.preview?.to_version ?? "" })}
</Button>
@@ -587,21 +593,21 @@ export function Overview({ server, companions = true, onForget, onOpenUpdates, o
</Card>
)}
{failure && (
{visibleFailure && (
<Card className="mt-4 border-bad/40 p-5" role="alert">
<p className="font-medium text-bad">{human(failure.human).title}</p>
<p className="mt-1 text-sm text-muted">{human(failure.human).message}</p>
<p className="font-medium text-bad">{human(visibleFailure.human).title}</p>
<p className="mt-1 text-sm text-muted">{human(visibleFailure.human).message}</p>
<div className="mt-3 flex gap-2">
<Button size="sm" onClick={() => setShowDetails((v) => !v)}>
{showDetails ? t("common.hideDetails") : t("common.viewDetails")}
</Button>
<Button size="sm" variant="ghost" onClick={() => void navigator.clipboard.writeText(failure.technical)}>
<Button size="sm" variant="ghost" onClick={() => void navigator.clipboard.writeText(visibleFailure.technical)}>
{t("common.copyError")}
</Button>
</div>
{showDetails && (
<pre className="mt-3 max-h-56 overflow-auto whitespace-pre-wrap rounded bg-black/40 p-3 text-xs text-muted">
{failure.technical || t("common.noOutput")}
{visibleFailure.technical || t("common.noOutput")}
</pre>
)}
</Card>
+61
View File
@@ -0,0 +1,61 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import { rolldown } from "rolldown";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
const stubs = {
react: `export const useEffect=()=>{}; export const useCallback=f=>f; export const useRef=v=>({current:v}); export const useState=v=>[globalThis.__overviewStateCalls++===0?globalThis.__overviewStatus:v,()=>{}];`,
"@/lib/api": `export const api={}; export const asUiError=e=>e;`,
"@/lib/utils": `export const cn=(...s)=>s.filter(Boolean).join(' '); export const formatBytes=String; export const formatUptime=String;`,
"@/i18n": `export const useT=()=>k=>k; export const useHuman=()=>h=>h;`,
"@/lib/serverUpdate": `export const applyServerUpdate=async()=>false; export const useServerUpdate=()=>globalThis.__overviewUpdate;`,
"@/lib/clientJob": `export const dismissClientJob=()=>{}; export const startClientUpdate=()=>{}; export const stopClientJob=()=>{}; export const syncClient=()=>{}; export const useClientJob=()=>({phase:'idle'});`,
"@/lib/clientUpdate": `export const checkClient=()=>{}; export const useClientStatus=()=>({status:null});`,
"@/lib/autoRestartPrefs": `export const useAutoRestart=()=>[false,()=>{}];`,
"@/components/ui/button": `import {createElement} from 'react'; export const Button=({children,disabled,onClick,className})=>createElement('button',{disabled,onClick,className},children);`,
"@/components/ui/card": `import {createElement} from 'react'; export const Card=({children,...props})=>createElement('div',props,children);`,
};
const bundle = await rolldown({
input: fileURLToPath(new URL("../src/screens/Overview.tsx", import.meta.url)),
transform: { jsx: "react-jsx" },
external: (id, importer) => id === "react/jsx-runtime" || id === "lucide-react" || (id === "react" && !importer?.endsWith("Overview.tsx")),
plugins: [{ name: "overview-test",
resolveId(id, importer) {
if (id.startsWith("@/") || (id === "react" && importer?.endsWith("Overview.tsx"))) return "\0" + id;
},
load(id) {
if (!id.startsWith("\0")) return;
const name = id.slice(1);
return stubs[name] ?? `export const ${name.split('/').at(-1)}=()=>null;`;
},
}],
});
const result = await bundle.generate({ format: "cjs" });
await bundle.close();
const module = { exports: {} };
new Function("require", "module", "exports", result.output[0].code)(createRequire(import.meta.url), module, module.exports);
function overview(update) {
globalThis.__overviewStateCalls = 0;
globalThis.__overviewStatus = { path_exists: true, busy: false, observed: Object.fromEntries(["mysql", "auth", "world"].map(k => [k, { state: "stopped" }])) };
globalThis.__overviewUpdate = { available: false, applying: false, preview: null, uiError: null, stateError: null, ...update };
return renderToStaticMarkup(createElement(module.exports.Overview, { server: { id: "fixture", path: "C:/CoA_Server" }, onForget() {}, onOpenUpdates() {}, onRealmChanged() {}, onReport() {} }));
}
const failure = { human: { code: "unknown", title: "Server update failed", message: "See the technical details." }, technical: "Stop this repack before moving its folder or changing its ports." };
test("Overview displays failed updates after the progress and cached offer disappear", () => {
const html = overview({ uiError: failure });
assert.match(html, /role="alert"/);
assert.match(html, /Server update failed/);
assert.match(html, /common.viewDetails/);
assert.match(html, /common.copyError/);
});
test("Overview displays update-state failures even without an update offer", () => {
assert.match(overview({ stateError: failure }), /Server update failed/);
});
test("Overview does not display an update failure when there is none", () => {
assert.doesNotMatch(overview({}), /Server update failed/);
});
+14
View File
@@ -76,3 +76,17 @@ test("a rejected recheck removes an earlier cached update offer", async () => {
assert.equal(useServerUpdate("stale-offer").preview, null);
} finally { globalThis.__hotfixApi.checkUpdate = original; }
});
test("a successful recheck keeps the last failed update details visible", async () => {
applyFailure = true;
stateFailure = false;
pending = null;
try {
assert.equal(await applyServerUpdate("visible-error", { choices: {} }), false);
await checkServerUpdate("visible-error");
const state = useServerUpdate("visible-error");
assert.equal(state.applying, false);
assert.equal(state.available, false);
assert.equal(state.uiError.technical, "SQL failure");
} finally { applyFailure = false; }
});