Files
coa-server-manager/src/screens/Shell.tsx
T
ДмитрийandClaude Opus 5.5 fdbf38ee4e Server Migration: real analysis and a real modular copy
The page was a mock: it never read the database (always 0 accounts),
showed a hard-coded schema and core version, looked for server files in
the wrong folder, and its "transfer" reported accounts, characters and
tables as moved without moving anything. Its locale was fixed to "ru".

- remove server_migration.rs and its three commands
- modular_copy::preview: classic/modular core, version, database state,
  counts and schemas from a running database (read only), database size,
  settings files, extra worlds; nothing is started or changed
- the page shows that and hands over to the same modular-copy install
  screen as "Manage another server"; texts in en and ru via i18n
- the modular copy now carries world settings changed in Server
  Configuration (Settings/worldserver.conf.template was not copied)
- E2E preview_bare_and_copy_e2e checks the preview and the carried
  setting; passes against preview core 0.261108.9501

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 09:22:22 +05:00

235 lines
11 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import { Archive, BarChart3, Bot, Bug, Gauge, Globe, Loader2, Puzzle, Server, Settings, Swords, Terminal, Users, RotateCcw, type LucideIcon } from "lucide-react";
import { api, type ServerSummary } from "@/lib/api";
import { useT, type Key } from "@/i18n";
import logo from "@/assets/logo.png";
import { cn } from "@/lib/utils";
import { Overview } from "@/screens/Overview";
import { Placeholder } from "@/screens/Placeholder";
import { SettingsPage } from "@/screens/SettingsPage";
import { BackupsPage } from "@/screens/BackupsPage";
import { PlayersPage } from "@/screens/PlayersPage";
import { SettingsHome } from "@/screens/SettingsHome";
import { PlayWithFriends } from "@/screens/PlayWithFriends";
import { ReportPage } from "@/screens/ReportPage";
import { ModulesPage } from "@/screens/ModulesPage";
import { ConsolePage } from "@/screens/ConsolePage";
import { ServerMigrationWizard } from "@/screens/ServerMigrationWizard";
import { PortablePage } from "@/screens/PortablePage";
import { DashboardPage } from "@/screens/DashboardPage";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { startServerUpdatePolling, useServerUpdate } from "@/lib/serverUpdate";
import { startClientPolling } from "@/lib/clientUpdate";
import type { UnsavedChangesGuard } from "@/lib/unsavedGuard";
type Page = "overview" | "bots" | "dashboard" | "server" | "modules" | "players" | "characters" | "friends" | "backups" | "console" | "report" | "settings" | "migration";
const NAV: { id: Page; label: Key; icon: LucideIcon; question: Key }[] = [
{ id: "overview", label: "nav.overview", icon: Gauge, question: "q.overview" },
{ id: "bots", label: "nav.bots", icon: Bot, question: "q.bots" },
{ id: "dashboard", label: "nav.dashboard", icon: BarChart3, question: "q.dashboard" },
{ id: "server", label: "nav.server", icon: Server, question: "q.server" },
{ id: "modules", label: "nav.modules", icon: Puzzle, question: "q.modules" },
{ id: "players", label: "nav.players", icon: Users, question: "q.players" },
{ id: "characters", label: "nav.characters", icon: Swords, question: "q.characters" },
{ id: "friends", label: "nav.friends", icon: Globe, question: "q.friends" },
{ id: "backups", label: "nav.backups", icon: Archive, question: "q.backups" },
{ id: "migration", label: "nav.migration", icon: RotateCcw, question: "q.migration" },
{ id: "console", label: "nav.console", icon: Terminal, question: "q.console" },
{ id: "report", label: "nav.report", icon: Bug, question: "q.report" },
{ id: "settings", label: "nav.settings", icon: Settings, question: "q.settings" },
];
export function Shell(props: {
servers: ServerSummary[];
activeId: string;
onSelect: (id: string) => void;
onAddAnother: () => void;
/** Make a modular copy of a classic server (the install screen takes over). */
onCopy: (path: string) => void;
onForget: (id: string) => Promise<void>;
}) {
const t = useT();
const [page, setPage] = useState<Page>("overview");
const server = props.servers.find((s) => s.id === props.activeId)!;
const current = NAV.find((n) => n.id === page)!;
const update = useServerUpdate(props.activeId);
// A module that is switched off takes its page away (the companions' "Bots" page, for one).
const [botModule, setBotModule] = useState<"companions" | "playerbots">("companions");
const [hiddenPages, setHiddenPages] = useState<string[]>([]);
const guardRef = useRef<UnsavedChangesGuard | null>(null);
const registerGuard = useCallback((g: UnsavedChangesGuard | null) => {
guardRef.current = g;
}, []);
const [pendingNav, setPendingNav] = useState<(() => void) | null>(null);
const [modalSaving, setModalSaving] = useState(false);
const [modalError, setModalError] = useState<string | null>(null);
const requestNavigation = (action: () => void) => {
if (guardRef.current && guardRef.current.isDirty()) {
setPendingNav(() => action);
} else {
action();
}
};
const reloadModules = useCallback(() => {
void api
.modulesList(props.activeId)
.then((list) => {
const available = (m: typeof list[number]) => m.installed && m.compatibility !== "unsupported" && (!m.switchable || m.enabled);
setHiddenPages([...new Set(list.flatMap(m => m.page ? [m.page] : []))].filter(page => !list.some(m => m.page === page && available(m))));
setBotModule(list.some(m => m.id === "playerbots" && available(m)) ? "playerbots" : "companions");
})
.catch(() => setHiddenPages([]));
}, [props.activeId]);
useEffect(() => {
reloadModules();
}, [reloadModules]);
useEffect(() => {
if (hiddenPages.includes(page)) setPage("overview");
}, [hiddenPages, page]);
// Look for a server and a game client update when the app starts and then every five minutes.
useEffect(() => {
const stopServer = startServerUpdatePolling(props.activeId);
const stopClient = startClientPolling(props.activeId);
return () => {
stopServer();
stopClient();
};
}, [props.activeId]);
return (
<div className="flex h-full">
<nav className="flex w-60 shrink-0 flex-col border-r border-line bg-[#0b0c0e] p-3" aria-label={t("nav.main")}>
<div className="flex items-center gap-2 px-3 pb-4 pt-2 text-sm font-semibold tracking-wide text-gold">
<img src={logo} alt="" aria-hidden className="h-7 w-7" />
<div className="flex flex-col">
<span className="leading-tight">{t("app.name")}</span>
<span className="text-[10px] font-mono font-normal tracking-wider text-sky-400">PREVIEW EDITION</span>
</div>
</div>
<ul className="flex flex-col gap-0.5">
{NAV.filter((n) => !hiddenPages.includes(n.id) && (n.id !== "dashboard" || botModule === "playerbots")).map(({ id, label, icon: Icon }) => (
<li key={id}>
<button
onClick={() => requestNavigation(() => setPage(id))}
aria-current={page === id ? "page" : undefined}
className={cn(
"flex h-10 w-full cursor-pointer items-center gap-3 rounded-md px-3 text-left text-[15px] transition-colors",
page === id ? "bg-white/[0.07] text-ink" : "text-muted hover:bg-white/5 hover:text-ink",
)}
>
<Icon className={cn("h-[18px] w-[18px]", page === id && "text-gold")} aria-hidden />
{t(label)}
{id === "settings" && update.available && (
<span className="ml-auto h-2 w-2 rounded-full bg-gold" role="img" aria-label={t("nav.updateDot")} title={t("nav.updateDot")} />
)}
</button>
</li>
))}
</ul>
<div className="mt-auto border-t border-line pt-3">
{props.servers.length > 1 && (
<select
className="mb-2 w-full rounded-md border border-line bg-card px-2 py-2 text-sm"
value={props.activeId}
onChange={(e) => requestNavigation(() => props.onSelect(e.target.value))}
aria-label={t("nav.serverPicker")}
>
{props.servers.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
)}
<Button variant="ghost" size="sm" className="w-full justify-start" onClick={() => requestNavigation(props.onAddAnother)}>
{t("nav.addAnother")}
</Button>
</div>
</nav>
<main className="relative h-full flex-1 overflow-y-auto px-10 py-8">
{page === "overview" ? (
<Overview key={server.id} server={server} companions={botModule === "companions" && !hiddenPages.includes("bots")} onForget={() => props.onForget(server.id)} onOpenUpdates={() => requestNavigation(() => setPage("settings"))} onRealmChanged={reloadModules} onReport={() => requestNavigation(() => setPage("report"))} />
) : page === "bots" || page === "server" ? (
<SettingsPage key={`${server.id}-${page}-${botModule}`} serverId={server.id} scope={page} botModule={botModule} title={t(current.label)} question={t(current.question)} onRegisterGuard={registerGuard} />
) : page === "dashboard" ? (
<DashboardPage key={server.id} serverId={server.id} />
) : page === "console" ? (
<ConsolePage key={server.id} serverId={server.id} />
) : page === "modules" ? (
<ModulesPage key={server.id} serverId={server.id} onChanged={reloadModules} onRegisterGuard={registerGuard} />
) : page === "report" ? (
<ReportPage key={server.id} serverId={server.id} />
) : page === "friends" ? (
<PlayWithFriends key={server.id} serverName={server.name} serverId={server.id} path={server.path} />
) : page === "settings" ? (
<SettingsHome key={server.id} serverId={server.id} serverName={server.name} path={server.path} onForget={() => props.onForget(server.id)} />
) : page === "characters" ? (
<PortablePage key="characters" />
) : page === "players" ? (
<PlayersPage key={server.id} serverId={server.id} />
) : page === "migration" ? (
<ServerMigrationWizard key={server.id} servers={props.servers} currentName={server.name} onCopy={props.onCopy} />
) : page === "backups" ? (
<BackupsPage key={server.id} serverId={server.id} />
) : (
<Placeholder title={t(current.label)} question={t(current.question)} />
)}
</main>
{pendingNav && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-6" role="presentation">
<Card className="w-full max-w-md p-6 shadow-2xl" role="dialog" aria-modal="true" aria-label={t("unsaved.title")}>
<h2 className="text-lg font-semibold">{t("unsaved.title")}</h2>
<p className="mt-2 text-sm text-muted">{t("unsaved.message")}</p>
{modalError && <p className="mt-3 text-sm text-bad" role="alert">{modalError}</p>}
<div className="mt-6 flex flex-wrap justify-end gap-2">
<Button variant="ghost" disabled={modalSaving} onClick={() => { setPendingNav(null); setModalError(null); }}>
{t("common.cancel")}
</Button>
<Button variant="ghost" disabled={modalSaving} onClick={() => {
guardRef.current?.discard();
const act = pendingNav;
setPendingNav(null);
setModalError(null);
act?.();
}}>
{t("set.discard")}
</Button>
<Button variant="primary" disabled={modalSaving} onClick={async () => {
if (!guardRef.current) return;
setModalSaving(true);
setModalError(null);
try {
const ok = await guardRef.current.save();
if (ok) {
const act = pendingNav;
setPendingNav(null);
act?.();
} else {
setModalError(t("unsaved.saveFailed"));
}
} catch (e) {
setModalError(String(e));
} finally {
setModalSaving(false);
}
}}>
{modalSaving && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />}
{t("acc.save")}
</Button>
</div>
</Card>
</div>
)}
</div>
);
}