mirror of
https://github.com/Corfirean/coa-server-manager.git
synced 2026-11-11 03:51:01 +00:00
Show Overview update failures and cover copied launcher configuration
This commit is contained in:
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.
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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; }
|
||||
});
|
||||
Reference in new issue
Block a user