diff --git a/changelog.d/20261014-overview-update-errors.md b/changelog.d/20261014-overview-update-errors.md new file mode 100644 index 0000000..eeac22f --- /dev/null +++ b/changelog.d/20261014-overview-update-errors.md @@ -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. diff --git a/crates/coa-core/src/mysql_snapshot.rs b/crates/coa-core/src/mysql_snapshot.rs index c352c78..0e078a1 100644 --- a/crates/coa-core/src/mysql_snapshot.rs +++ b/crates/coa-core/src/mysql_snapshot.rs @@ -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::(&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(); diff --git a/docs/STABILIZATION-20261014.md b/docs/STABILIZATION-20261014.md index 74c153e..903c739 100644 --- a/docs/STABILIZATION-20261014.md +++ b/docs/STABILIZATION-20261014.md @@ -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, diff --git a/src/screens/Overview.tsx b/src/screens/Overview.tsx index b0f05e1..643bdab 100644 --- a/src/screens/Overview.tsx +++ b/src/screens/Overview.tsx @@ -99,7 +99,13 @@ export function Overview({ server, companions = true, onForget, onOpenUpdates, o const [keepMine, setKeepMine] = useState(true); const [perf, setPerf] = useState(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(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 && } @@ -424,7 +430,7 @@ export function Overview({ server, companions = true, onForget, onOpenUpdates, o )} {running && upd.available && ( - @@ -587,21 +593,21 @@ export function Overview({ server, companions = true, onForget, onOpenUpdates, o )} - {failure && ( + {visibleFailure && ( -

{human(failure.human).title}

-

{human(failure.human).message}

+

{human(visibleFailure.human).title}

+

{human(visibleFailure.human).message}

-
{showDetails && (
-              {failure.technical || t("common.noOutput")}
+              {visibleFailure.technical || t("common.noOutput")}
             
)}
diff --git a/tools/overview-update-error.test.mjs b/tools/overview-update-error.test.mjs new file mode 100644 index 0000000..0028fa8 --- /dev/null +++ b/tools/overview-update-error.test.mjs @@ -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/); +}); diff --git a/tools/server-update-refresh.test.mjs b/tools/server-update-refresh.test.mjs index 1eaea60..2132b30 100644 --- a/tools/server-update-refresh.test.mjs +++ b/tools/server-update-refresh.test.mjs @@ -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; } +});