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>
This commit is contained in:
ДмитрийandClaude Opus 5.5 committed 2026-11-11 09:22:22 +05:00
1 parent 6091e545f8
commit fdbf38ee4e
14 files changed
+495 -1260

No files matched your search

@@ -0,0 +1,7 @@
---
area: manager
type: fixed
audience: admins
title: Server Migration shows the real server and makes a real modular copy
---
The page read nothing and reported a transfer that did not happen. It now shows the server's version, database state and counts, then makes the same modular copy as "Manage another server". World settings from Server Configuration now go along with the copy.
@@ -114,9 +114,20 @@ fn copy(work: &Path, reg: &Registry) -> R<()> {
let fingerprint = |root: &Path| sql(root, "SELECT CONCAT((SELECT COUNT(*) FROM acore_auth.account),'|',(SELECT COUNT(*) FROM acore_characters.characters),'|',\
(SELECT CONCAT(name,':',level,':',money) FROM acore_characters.characters WHERE guid=900001))").trim().to_string();
let expected = fingerprint(&old);
let counts = sql(&old, "SELECT CONCAT((SELECT COUNT(*) FROM acore_auth.account),'|',(SELECT COUNT(*) FROM acore_characters.characters))").trim().to_string();
// A world setting changed on the Server Configuration page lives in the old server's template.
let old_meta = coa_core::registry::metadata_dir_for(&old)?;
let rate = BTreeMap::from([("Rate.XP.Kill".to_string(), "3".to_string())]);
assert_eq!(coa_core::allsettings::save(&old, &old_meta, &rate)?, ["Rate.XP.Kill"]);
let _ = coa_core::driver::run(&old, coa_core::driver::Verb::StopAll);
println!("old server data: {expected}");
assert!(coa_core::modular_copy::is_classic(&old));
let before_preview = tree(&old);
let p = coa_core::modular_copy::preview(&old)?;
println!("preview (stopped): {p:?}");
assert!(p.classic && !p.modular && p.database == "stopped" && p.accounts.is_none());
assert!(p.database_bytes > 0 && p.config_files.iter().any(|f| f.ends_with("mod-coa-content-scaling.conf")));
assert_eq!(tree(&old), before_preview, "the preview changed the old server's files");
// 2. copy of the stopped server, then the preview core on the copy.
let before = tree(&old);
@@ -130,6 +141,9 @@ fn copy(work: &Path, reg: &Registry) -> R<()> {
assert_eq!(observed.mysql.state, coa_core::process::ServiceState::Stopped, "a database of the copy is still running: {:?}", observed.mysql);
assert!(!coa_core::registry::metadata_dir_for(&new)?.join("import").exists(), "the private copy of the old data is removed");
assert_eq!(std::fs::read_to_string(new.join("Core/configs/modules/mod-coa-content-scaling.conf"))?, conf_text);
assert_eq!(report.world_settings, ["Rate.XP.Kill"], "skipped: {:?}", report.world_settings_skipped);
let world_value = |root: &Path| coa_core::allsettings::list(root).unwrap().into_iter().find(|i| i.key == "Rate.XP.Kill").map(|i| i.value);
assert_eq!(world_value(&new).as_deref(), Some("3"));
let out = preview_core(&new)?;
println!("modules on the copy: {:?}", out.modules);
let got = fingerprint(&new);
@@ -143,6 +157,11 @@ fn copy(work: &Path, reg: &Registry) -> R<()> {
assert!(started.ok, "{}", started.output);
let data_before: BTreeMap<String, String> = tree(&old).into_iter().filter(|(k, _)| !k.starts_with("mysql/") && !k.starts_with(".state/") && !k.starts_with("Core/configs/")).collect();
let rows_before = fingerprint(&old);
let p = coa_core::modular_copy::preview(&old)?;
println!("preview (running): accounts {:?} characters {:?} schemas {:?}", p.accounts, p.characters, p.schemas);
assert_eq!(p.database, "running");
assert_eq!(format!("{}|{}", p.accounts.unwrap(), p.characters.unwrap()), counts);
assert!(p.schemas.iter().any(|s| s == "acore_characters"));
let new2 = work.join("copy-running-fixture");
install(&new2, reg)?;
let report = coa_core::modular_copy::copy_data(&old, &new2, &|w| println!(" copy: {w}"));
@@ -154,6 +173,7 @@ fn copy(work: &Path, reg: &Registry) -> R<()> {
let data_after: BTreeMap<String, String> = tree(&old).into_iter().filter(|(k, _)| !k.starts_with("mysql/") && !k.starts_with(".state/") && !k.starts_with("Core/configs/")).collect();
assert_eq!(data_after, data_before, "files outside the database changed");
assert_eq!(fingerprint(&new2), expected);
assert_eq!(world_value(&new2).as_deref(), Some("3"));
let _ = coa_core::driver::run(&new2, coa_core::driver::Verb::StopAll);
println!("COPY (running) PASS");
Ok(())
-1
View File
@@ -76,7 +76,6 @@ pub mod remote_client;
pub mod repair;
pub mod report;
pub mod schema_check;
pub mod server_migration;
pub mod signing;
pub mod solver;
pub mod squid;
+186 -1
View File
@@ -38,6 +38,84 @@ pub struct CopyReport {
pub config_files: Vec<String>,
/// How the old data was read: "running database" or "copy of the stopped database".
pub read_from: String,
/// World settings the owner changed (Server Configuration) that were set on the new server too.
pub world_settings: Vec<String>,
/// Changed world settings the new server's configuration does not take, with the reason.
pub world_settings_skipped: Vec<String>,
}
/// What a modular copy of `old` would take, read without changing anything: the old server is never started,
/// and its database is only queried when it is already running.
#[derive(Debug, Clone, Serialize)]
pub struct CopyPreview {
pub path: PathBuf,
/// The classic core (modules compiled in): the only kind that is copied.
pub classic: bool,
/// Already a modular core: it is added as it is, not copied.
pub modular: bool,
pub version: Option<String>,
/// "running", "stopped" or "missing" (no database folder).
pub database: String,
/// Read from the running database; `None` when it is stopped (they are counted while copying).
pub accounts: Option<u64>,
pub characters: Option<u64>,
pub schemas: Vec<String>,
/// Size of the old database files; a stopped database is copied once more while it is read.
pub database_bytes: u64,
/// Settings files that go along (module configs, realm profiles).
pub config_files: Vec<String>,
/// Extra worlds of the old server (`.realms/<name>`). Their databases are in the same MySQL and go along, and
/// the copied realm profile switches them on; the new server prepares their folders when it starts them.
pub extra_worlds: Vec<String>,
}
pub fn preview(old: &Path) -> Result<CopyPreview> {
if !old.is_dir() {
return Err(Error::Invalid(format!("{} is not a folder.", old.display())));
}
let classic = is_classic(old);
let modular = !crate::docker::is_docker(old)
&& old.join("Core/worldserver.exe").is_file()
&& crate::provenance::realm_host_fingerprint(old).is_some();
let version = crate::registry::metadata_dir_for(old)
.ok()
.and_then(|dir| crate::registry::MetaDir::open(&dir).ok())
.and_then(|(_, meta)| meta.core.version);
let data = old.join("mysql/data");
let has_data = data.join("mysql").is_dir();
let observed = crate::process::observe(old, &crate::layout::read_ports(old));
let running_db = (has_data && observed.mysql.state == ServiceState::Running)
.then(|| Db::from_repack(old, Account::Admin).ok())
.flatten()
.filter(|db| db.ping())
.map(|db| db.for_realm(crate::realms::Mode::Coa));
let (database, accounts, characters, schemas) = match &running_db {
Some(db) => (
"running",
Some(count(db, "SELECT COUNT(*) FROM acore_auth.account;")),
Some(count(db, "SELECT COUNT(*) FROM acore_characters.characters;")),
game_schemas(db).unwrap_or_default(),
),
None if has_data => ("stopped", None, None, Vec::new()),
None => ("missing", None, None, Vec::new()),
};
let mut extra_worlds: Vec<String> = fs::read_dir(old.join(".realms"))
.map(|rd| rd.flatten().filter(|e| e.path().is_dir()).map(|e| e.file_name().to_string_lossy().into_owned()).collect())
.unwrap_or_default();
extra_worlds.sort();
Ok(CopyPreview {
path: old.to_path_buf(),
classic,
modular,
version,
database: database.into(),
accounts,
characters,
schemas,
database_bytes: if has_data { dir_size(&data) } else { 0 },
config_files: settings_files(old),
extra_worlds,
})
}
/// Game schemas of a CoA server: `acore_*` (auth, characters, world, their Wildcard/Reborn variants, bots).
@@ -295,13 +373,120 @@ pub fn copy_data(old: &Path, new: &Path, progress: &dyn Fn(&str)) -> Result<Copy
}
fsx::atomic_write_json(&new.join("Settings/repack.json"), &repack)?;
let _ = fs::remove_dir_all(&work);
Ok(CopyReport { schemas, accounts, characters, config_files: files, read_from: read_from.into() })
progress("Copying world settings");
let (world_settings, world_settings_skipped) = carry_world_settings(old, new)?;
Ok(CopyReport {
schemas,
accounts,
characters,
config_files: files,
read_from: read_from.into(),
world_settings,
world_settings_skipped,
})
}
/// The world settings changed on the old server (they live in its `Settings/worldserver.conf.template`, which the new
/// server does not take: it keeps the template of its own version) are set on the new server through the same checks
/// the Server Configuration page uses. Settings the Manager owns (ports, folders, passwords) are not among them.
///
/// The shipped template already differs from `worldserver.conf.dist` in many places, so "changed" means "differs from
/// what the new server has now": for a copy onto the same version that is exactly what the owner changed. (A value the
/// owner never touched but a newer release ships differently cannot be told apart from an owner's change; it keeps the
/// old server's value, which is what that server ran with.)
fn carry_world_settings(old: &Path, new: &Path) -> Result<(Vec<String>, Vec<String>)> {
let current: std::collections::BTreeMap<String, String> =
crate::allsettings::list(new)?.into_iter().map(|i| (i.key, i.value)).collect();
let mut changes = std::collections::BTreeMap::new();
let mut skipped = Vec::new();
for item in crate::allsettings::list(old)? {
match current.get(&item.key) {
Some(now) if *now == item.value => {}
Some(_) => {
changes.insert(item.key, item.value);
}
None if item.changed => skipped.push(format!("{}: not a setting of the new version", item.key)),
None => {}
}
}
let meta = crate::registry::metadata_dir_for(new)?;
let mut carried = Vec::new();
match crate::allsettings::save(new, &meta, &changes) {
Ok(set) => carried.extend(set),
// One value the new version refuses must not lose the others: set them one at a time.
Err(_) => {
for (key, value) in changes {
match crate::allsettings::save(new, &meta, &std::collections::BTreeMap::from([(key.clone(), value)])) {
Ok(set) => carried.extend(set),
Err(e) => skipped.push(format!("{key}: {e}")),
}
}
}
}
Ok((carried, skipped))
}
#[cfg(test)]
mod tests {
use super::*;
const DIST: &str = "[worldserver]\r\n\r\n#\r\n# Rate.Drop.Item.Epic\r\n# Description: Epic drops.\r\n# Default: 1\r\n#\r\n\r\nRate.Drop.Item.Epic = 1\r\n\r\n#\r\n# LoginDatabaseInfo\r\n# Description: secret\r\n#\r\n\r\nLoginDatabaseInfo = \"127.0.0.1;3306;a;b;c\"\r\n\r\n#\r\n# Instance.ResetTimeHour\r\n# Description: Hour of the reset.\r\n# Default: 4\r\n#\r\n\r\nInstance.ResetTimeHour = 4\r\n";
fn world(root: &Path, dist: &str, conf: &str) {
fs::create_dir_all(root.join("Core/configs")).unwrap();
fs::create_dir_all(root.join("Settings")).unwrap();
fs::create_dir_all(crate::registry::metadata_dir_for(root).unwrap()).unwrap();
fs::write(root.join("Core/configs/worldserver.conf.dist"), dist).unwrap();
fs::write(root.join("Settings/worldserver.conf.template"), conf).unwrap();
fs::write(root.join("Core/configs/worldserver.conf"), conf).unwrap();
}
#[test]
fn changed_world_settings_go_to_the_new_server_and_the_rest_keep_its_defaults() {
let dir = tempfile::tempdir().unwrap();
let (old, new) = (dir.path().join("old"), dir.path().join("new"));
let old_dist = format!("{DIST}\r\n#\r\n# Old.Only\r\n# Description: gone in the new version.\r\n# Default: 1\r\n#\r\n\r\nOld.Only = 1\r\n");
world(&old, &old_dist, "[worldserver]\r\nRate.Drop.Item.Epic = 3\r\nOld.Only = 2\r\nLoginDatabaseInfo = \"old\"\r\n");
world(&new, DIST, "[worldserver]\r\nLoginDatabaseInfo = \"new\"\r\n");
let (carried, skipped) = carry_world_settings(&old, &new).unwrap();
assert_eq!(carried, ["Rate.Drop.Item.Epic"]);
assert_eq!(skipped.len(), 1, "{skipped:?}");
assert!(skipped[0].starts_with("Old.Only"));
for f in ["Settings/worldserver.conf.template", "Core/configs/worldserver.conf"] {
let text = fs::read_to_string(new.join(f)).unwrap();
assert!(text.contains("Rate.Drop.Item.Epic = 3"), "{f}: {text}");
assert!(text.contains("LoginDatabaseInfo = \"new\""), "the Manager's own settings stay: {text}");
assert!(!text.contains("Old.Only"), "{text}");
}
}
#[test]
fn the_preview_of_a_folder_reads_only_files() {
let dir = tempfile::tempdir().unwrap();
let classic = dir.path().join("classic");
fs::create_dir_all(classic.join("Core/configs/modules")).unwrap();
fs::create_dir_all(classic.join("mysql/data/mysql")).unwrap();
fs::create_dir_all(classic.join(".realms/secondary")).unwrap();
fs::write(classic.join("Core/worldserver.exe"), b"not a plugin host").unwrap();
fs::write(classic.join("Core/configs/modules/mod_x.conf"), b"x").unwrap();
fs::write(classic.join("mysql/data/ibdata1"), vec![0u8; 2048]).unwrap();
let p = preview(&classic).unwrap();
assert!(p.classic && !p.modular);
assert_eq!(p.database, "stopped");
assert_eq!((p.accounts, p.characters), (None, None), "a stopped database is not opened");
assert_eq!(p.database_bytes, 2048);
assert_eq!(p.config_files, ["Core/configs/modules/mod_x.conf"]);
assert_eq!(p.extra_worlds, ["secondary"]);
let empty = dir.path().join("empty");
fs::create_dir_all(&empty).unwrap();
let p = preview(&empty).unwrap();
assert!(!p.classic && !p.modular);
assert_eq!(p.database, "missing");
assert!(preview(&dir.path().join("nowhere")).is_err());
}
#[test]
fn only_owner_settings_are_copied() {
let dir = tempfile::tempdir().unwrap();
-595
View File
@@ -1,595 +0,0 @@
//! Server Migration Center (Перенос сервера).
//!
//! Subsystem for discovering, inspecting, comparing, planning, and executing non-destructive
//! server migrations from legacy or external AzerothCore/CoA servers into CSM Modules Preview realms.
//!
//! Migration Lifecycle:
//! Discover -> Inspect -> Classify -> Compare -> Plan -> Backup -> Transfer -> Validate -> Commit.
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Instant;
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
use crate::fsx;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum SourceClassification {
CsmManagedCoa,
LegacyEmbeddedCoa,
StandardAzerothCore,
UnknownCustom,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DetectedModule {
pub id: String,
pub name: String,
pub detected_via: String,
pub version: Option<String>,
pub has_database_data: bool,
pub is_supported_by_preview: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SourceInspectionReport {
pub source_path: PathBuf,
pub source_type: SourceClassification,
pub core_version: Option<String>,
pub schema_version: Option<String>,
pub accounts_count: usize,
pub characters_count: usize,
pub detected_modules: Vec<DetectedModule>,
pub module_tables: BTreeMap<String, Vec<String>>,
pub custom_race_support: bool,
pub compatibility_notes: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MigrationScope {
pub accounts_and_characters: bool,
pub module_owned_data: bool,
pub server_configuration: bool,
pub selected_modules: Vec<String>,
}
impl Default for MigrationScope {
fn default() -> Self {
Self {
accounts_and_characters: true,
module_owned_data: true,
server_configuration: true,
selected_modules: Vec::new(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConflictResolutionPolicy {
RemapCollidingGuids,
SkipCollidingGuids,
OverwriteExisting,
}
impl Default for ConflictResolutionPolicy {
fn default() -> Self {
ConflictResolutionPolicy::RemapCollidingGuids
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdapterPlan {
pub module_id: String,
pub source_tables: Vec<String>,
pub dest_tables: Vec<String>,
pub data_loss_risks: Vec<String>,
pub requires_conversion: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MigrationPlan {
pub plan_id: String,
pub source_path: PathBuf,
pub dest_realm_path: PathBuf,
pub source_report: SourceInspectionReport,
pub scope: MigrationScope,
pub conflict_policy: ConflictResolutionPolicy,
pub adapters: Vec<AdapterPlan>,
pub tables_to_migrate: Vec<String>,
pub quarantined_tables: Vec<String>,
pub estimated_disk_mb: u64,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MigrationExecutionReport {
pub success: bool,
pub backup_path: Option<PathBuf>,
pub accounts_transferred: usize,
pub characters_transferred: usize,
pub items_transferred: usize,
pub modules_migrated: Vec<String>,
pub tables_migrated: Vec<String>,
pub quarantined_tables: Vec<String>,
pub warnings: Vec<String>,
pub errors: Vec<String>,
pub duration_ms: u64,
}
/// Trait implemented by versioned module migration adapters.
pub trait ModuleMigrationAdapter: Send + Sync {
fn module_id(&self) -> &str;
fn source_module_id(&self) -> &str;
fn destination_module_id(&self) -> &str;
fn owned_tables(&self) -> &[&str];
fn plan(&self, source_tables: &[String]) -> AdapterPlan;
fn execute(&self, source: &Path, dest: &Path, remap_map: &BTreeMap<u64, u64>) -> Result<Vec<String>>;
}
// ---------------- Built-in Module Adapters ----------------
pub struct CompanionsAdapter;
impl ModuleMigrationAdapter for CompanionsAdapter {
fn module_id(&self) -> &str { "companions" }
fn source_module_id(&self) -> &str { "mod-coa-playerbots" }
fn destination_module_id(&self) -> &str { "companions" }
fn owned_tables(&self) -> &[&str] {
&["mod_coa_playerbots_saved_bots", "mod_coa_playerbots_talents", "mod_coa_playerbots_equipment"]
}
fn plan(&self, source_tables: &[String]) -> AdapterPlan {
let matching: Vec<String> = source_tables.iter()
.filter(|t| self.owned_tables().iter().any(|ot| ot == t))
.cloned()
.collect();
AdapterPlan {
module_id: "companions".into(),
source_tables: matching.clone(),
dest_tables: matching,
data_loss_risks: vec!["Bot owner character GUIDs will be remapped to target realm GUIDs".into()],
requires_conversion: true,
}
}
fn execute(&self, _source: &Path, _dest: &Path, _remap_map: &BTreeMap<u64, u64>) -> Result<Vec<String>> {
Ok(vec!["mod_coa_playerbots_saved_bots".into()])
}
}
pub struct SquidPlayerbotsAdapter;
impl ModuleMigrationAdapter for SquidPlayerbotsAdapter {
fn module_id(&self) -> &str { "playerbots" }
fn source_module_id(&self) -> &str { "mod-playerbots" }
fn destination_module_id(&self) -> &str { "squid_playerbots" }
fn owned_tables(&self) -> &[&str] {
&["playerbots_rnd_bots", "playerbots_rnd_spells", "playerbots_custom_strategy"]
}
fn plan(&self, source_tables: &[String]) -> AdapterPlan {
let matching: Vec<String> = source_tables.iter()
.filter(|t| self.owned_tables().iter().any(|ot| ot == t))
.cloned()
.collect();
AdapterPlan {
module_id: "playerbots".into(),
source_tables: matching.clone(),
dest_tables: matching,
data_loss_risks: vec![],
requires_conversion: false,
}
}
fn execute(&self, _source: &Path, _dest: &Path, _remap_map: &BTreeMap<u64, u64>) -> Result<Vec<String>> {
Ok(vec!["playerbots_rnd_bots".into()])
}
}
pub struct ContentScalingAdapter;
impl ModuleMigrationAdapter for ContentScalingAdapter {
fn module_id(&self) -> &str { "content-scaling" }
fn source_module_id(&self) -> &str { "mod-coa-content-scaling" }
fn destination_module_id(&self) -> &str { "content_scaling" }
fn owned_tables(&self) -> &[&str] {
&["mod_content_scaling_creatures", "mod_content_scaling_maps"]
}
fn plan(&self, source_tables: &[String]) -> AdapterPlan {
let matching: Vec<String> = source_tables.iter()
.filter(|t| self.owned_tables().iter().any(|ot| ot == t))
.cloned()
.collect();
AdapterPlan {
module_id: "content-scaling".into(),
source_tables: matching.clone(),
dest_tables: matching,
data_loss_risks: vec![],
requires_conversion: false,
}
}
fn execute(&self, _source: &Path, _dest: &Path, _remap_map: &BTreeMap<u64, u64>) -> Result<Vec<String>> {
Ok(vec!["mod_content_scaling_creatures".into()])
}
}
pub struct AuctionHouseBotAdapter;
impl ModuleMigrationAdapter for AuctionHouseBotAdapter {
fn module_id(&self) -> &str { "ah-bot" }
fn source_module_id(&self) -> &str { "mod-auctionhousebot" }
fn destination_module_id(&self) -> &str { "ah-bot" }
fn owned_tables(&self) -> &[&str] {
&["mod_auctionhousebot_buyer", "mod_auctionhousebot_seller"]
}
fn plan(&self, source_tables: &[String]) -> AdapterPlan {
let matching: Vec<String> = source_tables.iter()
.filter(|t| self.owned_tables().iter().any(|ot| ot == t))
.cloned()
.collect();
AdapterPlan {
module_id: "ah-bot".into(),
source_tables: matching.clone(),
dest_tables: matching,
data_loss_risks: vec!["Active temporary auctions will be refreshed by bot".into()],
requires_conversion: false,
}
}
fn execute(&self, _source: &Path, _dest: &Path, _remap_map: &BTreeMap<u64, u64>) -> Result<Vec<String>> {
Ok(vec!["mod_auctionhousebot_buyer".into()])
}
}
pub struct NpcEnchanterAdapter;
impl ModuleMigrationAdapter for NpcEnchanterAdapter {
fn module_id(&self) -> &str { "npc-enchanter" }
fn source_module_id(&self) -> &str { "mod-npc-enchanter" }
fn destination_module_id(&self) -> &str { "npc-enchanter" }
fn owned_tables(&self) -> &[&str] {
&["mod_npc_enchanter_item_cost"]
}
fn plan(&self, source_tables: &[String]) -> AdapterPlan {
let matching: Vec<String> = source_tables.iter()
.filter(|t| self.owned_tables().iter().any(|ot| ot == t))
.cloned()
.collect();
AdapterPlan {
module_id: "npc-enchanter".into(),
source_tables: matching.clone(),
dest_tables: matching,
data_loss_risks: vec![],
requires_conversion: false,
}
}
fn execute(&self, _source: &Path, _dest: &Path, _remap_map: &BTreeMap<u64, u64>) -> Result<Vec<String>> {
Ok(vec!["mod_npc_enchanter_item_cost".into()])
}
}
// ---------------- Core Migration Functions ----------------
/// Gets the registry of all built-in module migration adapters.
pub fn get_adapters() -> Vec<Box<dyn ModuleMigrationAdapter>> {
vec![
Box::new(CompanionsAdapter),
Box::new(SquidPlayerbotsAdapter),
Box::new(ContentScalingAdapter),
Box::new(AuctionHouseBotAdapter),
Box::new(NpcEnchanterAdapter),
]
}
/// Discovers and inspects a source server non-destructively (read-only).
pub fn inspect_source(source_path: &Path) -> Result<SourceInspectionReport> {
if !source_path.is_dir() {
return Err(Error::Invalid(format!(
"Source path is not a directory: {}",
source_path.display()
)));
}
let mut notes = Vec::new();
// 1. Classify source
let has_csm_metadata = source_path.join(".manager").is_dir() || source_path.join("metadata").is_dir();
let has_worldserver = source_path.join("worldserver.exe").is_file() || source_path.join("bin/worldserver").is_file();
let has_coa_config = source_path.join("configs/modules/coa.conf").is_file()
|| source_path.join("configs/modules/coa.conf.dist").is_file()
|| source_path.join("etc/coa.conf").is_file();
let source_type = if has_csm_metadata && has_coa_config {
SourceClassification::CsmManagedCoa
} else if has_coa_config {
SourceClassification::LegacyEmbeddedCoa
} else if has_worldserver {
SourceClassification::StandardAzerothCore
} else {
SourceClassification::UnknownCustom
};
if source_type == SourceClassification::UnknownCustom {
notes.push("Source directory does not match standard CoA or AzerothCore server layout. Migration will be conservative.".into());
}
// 2. Discover installed modules and configurations
let mut detected_modules = Vec::new();
let mut module_tables = BTreeMap::new();
let conf_dirs = [
source_path.join("configs/modules"),
source_path.join("etc/modules"),
source_path.join("configs"),
];
let mut found_confs = BTreeSet::new();
for d in &conf_dirs {
if let Ok(entries) = fs::read_dir(d) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
if name.ends_with(".conf") || name.ends_with(".conf.dist") {
found_confs.insert(name);
}
}
}
}
let adapters = get_adapters();
for adapter in &adapters {
let mid = adapter.module_id();
let src_id = adapter.source_module_id();
let has_conf = found_confs.iter().any(|c| {
c.contains(&mid.replace('-', "_")) || c.contains(mid)
|| c.contains(&src_id.replace('-', "_")) || c.contains(src_id)
});
let tables: Vec<String> = adapter.owned_tables().iter().map(|s| s.to_string()).collect();
module_tables.insert(mid.to_string(), tables.clone());
if has_conf {
detected_modules.push(DetectedModule {
id: mid.to_string(),
name: match mid {
"companions" => "CoA Companions",
"playerbots" => "SQUID Playerbots",
"content-scaling" => "Content Scaling",
"ah-bot" => "Auction House Bot",
"npc-enchanter" => "NPC Enchanter",
_ => mid,
}.into(),
detected_via: "configuration".into(),
version: None,
has_database_data: true,
is_supported_by_preview: true,
});
}
}
// 3. Inspect accounts and characters counts from SQLite / DB snapshot if available, or simulate from metadata
let mut accounts_count = 0;
let mut characters_count = 0;
let characters_json = source_path.join(".manager/characters.json");
if characters_json.is_file() {
if let Ok(val) = fsx::read_json::<serde_json::Value>(&characters_json) {
if let Some(arr) = val.as_array() {
characters_count = arr.len();
}
}
} else {
// Fallback inspection: check sqlite or backup files
let dump_files = [
source_path.join("backups/characters.sql"),
source_path.join("data/sql/characters.sql"),
];
for f in &dump_files {
if f.is_file() {
characters_count = 1;
accounts_count = 1;
break;
}
}
}
let custom_race_support = source_path.join("Data/patch-Z.mpq").is_file()
|| source_path.join("configs/modules/mod_custom_races.conf").is_file();
Ok(SourceInspectionReport {
source_path: source_path.to_path_buf(),
source_type,
core_version: Some("0.6.12-coa".into()),
schema_version: Some("2026_10_28_00".into()),
accounts_count,
characters_count,
detected_modules,
module_tables,
custom_race_support,
compatibility_notes: notes,
})
}
/// Creates a migration plan from an inspection report and user-chosen scopes.
pub fn plan_migration(
report: &SourceInspectionReport,
dest_realm_path: &Path,
scope: MigrationScope,
conflict_policy: ConflictResolutionPolicy,
) -> Result<MigrationPlan> {
let mut adapter_plans = Vec::new();
let mut tables_to_migrate = Vec::new();
let quarantined_tables = Vec::new();
let mut warnings = Vec::new();
let adapters = get_adapters();
for adapter in &adapters {
if scope.selected_modules.is_empty() || scope.selected_modules.iter().any(|m| m == adapter.module_id()) {
let owned: Vec<String> = adapter.owned_tables().iter().map(|s| s.to_string()).collect();
let p = adapter.plan(&owned);
tables_to_migrate.extend(p.dest_tables.clone());
adapter_plans.push(p);
}
}
if scope.accounts_and_characters {
tables_to_migrate.push("account".into());
tables_to_migrate.push("characters".into());
tables_to_migrate.push("character_inventory".into());
tables_to_migrate.push("character_spell".into());
tables_to_migrate.push("character_talent".into());
tables_to_migrate.push("character_skills".into());
}
if conflict_policy == ConflictResolutionPolicy::RemapCollidingGuids {
warnings.push("Deterministic ID remapping will be applied to prevent GUID collisions in target realm.".into());
} else if conflict_policy == ConflictResolutionPolicy::OverwriteExisting {
warnings.push("CAUTION: Overwrite policy will replace conflicting characters in the target realm.".into());
}
let plan_id = format!("mig-{}", uuid::Uuid::new_v4().to_string().chars().take(8).collect::<String>());
Ok(MigrationPlan {
plan_id,
source_path: report.source_path.clone(),
dest_realm_path: dest_realm_path.to_path_buf(),
source_report: report.clone(),
scope,
conflict_policy,
adapters: adapter_plans,
tables_to_migrate,
quarantined_tables,
estimated_disk_mb: 50,
warnings,
})
}
/// Executes a migration plan non-destructively:
/// Backup -> Transfer -> Remap -> Validate -> Commit.
pub fn execute_migration(plan: &MigrationPlan) -> Result<MigrationExecutionReport> {
let start_time = Instant::now();
let dest = &plan.dest_realm_path;
if !dest.is_dir() {
return Err(Error::Invalid(format!(
"Destination realm directory does not exist: {}",
dest.display()
)));
}
// 1. Create Destination Backup / Checkpoint
let backup_dir = dest.join(".manager/backups/pre-migration").join(&plan.plan_id);
fs::create_dir_all(&backup_dir)?;
let mut warnings = plan.warnings.clone();
let mut errors = Vec::new();
let mut tables_migrated = Vec::new();
let mut modules_migrated = Vec::new();
// 2. Perform Account & Character Transfer with GUID remapping
let mut remap_map = BTreeMap::new();
let accounts_transferred = if plan.scope.accounts_and_characters {
// Compute base offset for destination GUID remapping
let base_offset: u64 = 100_000;
remap_map.insert(1, base_offset + 1);
remap_map.insert(2, base_offset + 2);
tables_migrated.push("account".into());
tables_migrated.push("characters".into());
plan.source_report.accounts_count.max(1)
} else {
0
};
let characters_transferred = if plan.scope.accounts_and_characters {
plan.source_report.characters_count.max(1)
} else {
0
};
// 3. Execute Module Adapters
let adapters = get_adapters();
for ap in &plan.adapters {
if let Some(adapter) = adapters.iter().find(|a| a.module_id() == ap.module_id) {
match adapter.execute(&plan.source_path, dest, &remap_map) {
Ok(tables) => {
modules_migrated.push(adapter.module_id().to_string());
tables_migrated.extend(tables);
}
Err(e) => {
errors.push(format!("Adapter '{}' failed: {}", adapter.module_id(), e));
}
}
}
}
// 4. Server Configuration Transfer
if plan.scope.server_configuration {
let src_confs = plan.source_path.join("configs/modules");
let dest_confs = dest.join("configs/modules");
if src_confs.is_dir() {
fs::create_dir_all(&dest_confs)?;
if let Ok(entries) = fs::read_dir(src_confs) {
for entry in entries.flatten() {
let p = entry.path();
if p.extension().map(|e| e == "conf").unwrap_or(false) {
let target = dest_confs.join(entry.file_name());
let _ = fs::copy(&p, &target);
}
}
}
}
}
// 5. Validation Check
let success = errors.is_empty();
if !success {
warnings.push("Migration completed with errors. Staging rollback available.".into());
}
let elapsed = start_time.elapsed().as_millis() as u64;
Ok(MigrationExecutionReport {
success,
backup_path: Some(backup_dir),
accounts_transferred,
characters_transferred,
items_transferred: characters_transferred * 15,
modules_migrated,
tables_migrated,
quarantined_tables: plan.quarantined_tables.clone(),
warnings,
errors,
duration_ms: elapsed,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_inspect_and_plan_legacy_server() {
let temp = tempfile::tempdir().unwrap();
let src = temp.path().join("legacy_server");
fs::create_dir_all(src.join("configs/modules")).unwrap();
fs::write(src.join("worldserver.exe"), b"mock_exe").unwrap();
fs::write(src.join("configs/modules/coa.conf"), b"CoA.Enable = 1").unwrap();
fs::write(src.join("configs/modules/mod_coa_playerbots.conf"), b"Bot.Enable = 1").unwrap();
let dest = temp.path().join("preview_realm");
fs::create_dir_all(&dest).unwrap();
let report = inspect_source(&src).unwrap();
assert_eq!(report.source_type, SourceClassification::LegacyEmbeddedCoa);
assert!(report.detected_modules.iter().any(|m| m.id == "companions"));
let plan = plan_migration(
&report,
&dest,
MigrationScope::default(),
ConflictResolutionPolicy::RemapCollidingGuids,
).unwrap();
assert!(!plan.adapters.is_empty());
assert!(plan.tables_to_migrate.contains(&"mod_coa_playerbots_saved_bots".to_string()));
let exec = execute_migration(&plan).unwrap();
assert!(exec.success);
assert!(exec.backup_path.is_some());
assert!(exec.modules_migrated.contains(&"companions".to_string()));
}
}
+3 -39
View File
@@ -3374,42 +3374,8 @@ async fn preview_export_diagnostics(
}
#[tauri::command]
async fn preview_migration_inspect(
source_path: String,
) -> std::result::Result<coa_core::server_migration::SourceInspectionReport, UiError> {
blocking(move || {
let p = std::path::PathBuf::from(source_path);
coa_core::server_migration::inspect_source(&p).map_err(Into::into)
})
.await
}
#[tauri::command]
async fn preview_migration_plan(
state: State<'_, AppState>,
source_path: String,
dest_realm_id: String,
scope: coa_core::server_migration::MigrationScope,
conflict_policy: coa_core::server_migration::ConflictResolutionPolicy,
) -> std::result::Result<coa_core::server_migration::MigrationPlan, UiError> {
let dest_root = path_of(&state, &dest_realm_id)?;
blocking(move || {
let src_p = std::path::PathBuf::from(source_path);
let report = coa_core::server_migration::inspect_source(&src_p)?;
coa_core::server_migration::plan_migration(&report, &dest_root, scope, conflict_policy)
.map_err(Into::into)
})
.await
}
#[tauri::command]
async fn preview_migration_execute(
plan: coa_core::server_migration::MigrationPlan,
) -> std::result::Result<coa_core::server_migration::MigrationExecutionReport, UiError> {
blocking(move || {
coa_core::server_migration::execute_migration(&plan).map_err(Into::into)
})
.await
async fn modular_copy_preview(path: String) -> std::result::Result<coa_core::modular_copy::CopyPreview, UiError> {
blocking(move || coa_core::modular_copy::preview(Path::new(&path)).map_err(Into::into)).await
}
#[tauri::command]
@@ -3576,9 +3542,7 @@ pub fn run() {
preview_remove_module,
preview_list_installed_plugins,
preview_export_diagnostics,
preview_migration_inspect,
preview_migration_plan,
preview_migration_execute,
modular_copy_preview,
preview_check_updates,
default_install_dir,
game_data_folder,
+9 -1
View File
@@ -21,6 +21,8 @@ function Main() {
const [activeId, setActiveId] = useState<string | null>(null);
// A classic server the owner chose: the install screen makes a modular copy of it next to it.
const [copyFrom, setCopyFrom] = useState<string | null>(null);
// Where Cancel on that install screen goes back to: the import screen or the server's own pages.
const [copyBack, setCopyBack] = useState<View>("import");
const refresh = useCallback(async () => {
const list = await api.list().catch(() => []);
@@ -46,6 +48,11 @@ function Main() {
activeId={activeId}
onSelect={setActiveId}
onAddAnother={() => setView("add")}
onCopy={(path) => {
setCopyFrom(path);
setCopyBack("welcome");
setView("install");
}}
onForget={async (id) => {
await api.forget(id);
await refresh();
@@ -60,7 +67,7 @@ function Main() {
canCancel
copyFrom={copyFrom ?? undefined}
onCancel={() => {
setView(copyFrom ? "import" : servers.length > 0 ? "add" : "welcome");
setView(copyFrom ? copyBack : servers.length > 0 ? "add" : "welcome");
setCopyFrom(null);
}}
onDone={async (s) => {
@@ -85,6 +92,7 @@ function Main() {
}}
onCopy={(path) => {
setCopyFrom(path);
setCopyBack("import");
setView("install");
}}
/>
+1
View File
@@ -50,6 +50,7 @@ export {};
if(cmd==="play") return {ok:true,exit_code:null,code:null,human:null,output:""};
if(cmd==="list_servers") return added?[{id:"1",name:"CoA-Repack",path:report.path}]:[];
if(cmd==="scan_server") return report;
if(cmd==="modular_copy_preview"){await new Promise(r=>setTimeout(r,400));const p=String(args.path);if(!/repack|server/i.test(p))return {path:p,classic:false,modular:false,version:null,database:"missing",accounts:null,characters:null,schemas:[],database_bytes:0,config_files:[],extra_worlds:[]};const run=!/stopped/i.test(p);return {path:p,classic:true,modular:false,version:"0.261108.95",database:run?"running":"stopped",accounts:run?12:null,characters:run?37:null,schemas:run?["acore_auth","acore_characters","acore_playerbots","acore_world"]:[],database_bytes:6.3*2**30,config_files:["Core/configs/modules/mod-coa-content-scaling.conf","Core/configs/modules/playerbots.conf","Settings/realm-profile.json"],extra_worlds:run?[]:["secondary"]};}
if(cmd==="add_server"){added=true;return {id:"1",name:"CoA-Repack",path:report.path};}
if(cmd==="server_status"){const s=running?"running":"stopped";return {observed:{mysql:svc("mysql",3307,s),auth:svc("auth",3724,s),world:svc("world",8085,s),secondary_world:simultaneous?svc("world-secondary",8086,s):null,reborn_world:withReborn?svc("world-reborn",8087,s):null},busy:false,path_exists:true};}
if(cmd==="start_server"){await new Promise((r: any)=>setTimeout(r,1500));running=true;return {ok:true,exit_code:0,code:null,human:null,output:""};}
+44 -1
View File
@@ -865,7 +865,50 @@ const en = {
"realm.confirmDelete": "Remove the realmlist “{name}”? The game client keeps whatever file it has now.",
"nav.modules": "Modules",
"nav.migration": "Server Migration",
"q.migration": "Migrate data from an existing server into this Preview realm",
"q.migration": "Move a server with the classic core to the modular core: a copy next to it",
"migrate.title": "Server migration",
"migrate.subtitle": "A server with the classic core is not converted in place. A new server with the modular core is installed next to it, and its accounts, characters, world, bots and module settings are copied into it.",
"migrate.source": "Which server to move",
"migrate.browse": "Browse…",
"migrate.inspect": "Check",
"migrate.dialogTitle": "Choose the server's main folder",
"migrate.readOnly": "Checking only reads the folder; a running database is only asked for counts. Nothing is started or changed.",
"migrate.classic.title": "Classic core: it can be copied",
"migrate.classic.text": "The copy becomes a new server with the modular core. This server stays as it is.",
"migrate.modular.title": "Already the modular core",
"migrate.modular.text": "Nothing to move. To manage this server here, add it with “Manage another server”.",
"migrate.none.title": "No server found here",
"migrate.none.text": "This folder has no Core\\worldserver.exe of a Windows server. Choose the server's main folder.",
"migrate.fact.version": "Version",
"migrate.fact.database": "Database",
"migrate.fact.accounts": "Accounts",
"migrate.fact.characters": "Characters",
"migrate.unknown": "unknown",
"migrate.countLater": "counted while copying",
"migrate.db.running": "running",
"migrate.db.stopped": "stopped",
"migrate.db.missing": "not found",
"migrate.db.missingText": "This server has no database folder (mysql\\data), so there is nothing to copy.",
"migrate.copied": "What is copied",
"migrate.copied.schemas": "Every game database: {list}.",
"migrate.copied.allSchemas": "Every game database (accounts, characters, world, bots); the list is read while copying.",
"migrate.copied.settings": "Module settings and realm profiles: {n} files.",
"install.copy.worldSkipped": "World settings the new version did not take:",
"install.copy.worldSettings": "World settings carried over: {n}.",
"migrate.copied.world": "World settings changed in Server Configuration (rates and the like).",
"migrate.copied.ports": "Ports and the console login, as the old server has them.",
"migrate.copied.modules": "The modules the old server has switched on are built for the modular core during the copy.",
"migrate.copied.worlds": "Extra worlds ({list}): their databases go along and they stay switched on.",
"migrate.notCopied": "What is not copied",
"migrate.notCopied.conf": "worldserver.conf and authserver.conf themselves: the new server makes them from its own templates. Values changed in Server Configuration are set on the new server.",
"migrate.notCopied.merge": "Nothing is merged into {name} or any other existing server: the copy is always a new server in a new folder.",
"migrate.risks": "Before you start",
"migrate.risk.oldKept": "The old server is only read. Its folder and database are not changed, and you can keep using it.",
"migrate.risk.ports": "Both servers use the same ports: run only one of them at a time.",
"migrate.risk.running": "Its database is running and is read as it is at that moment. Stop its world first so players and bots do not change data while it is copied.",
"migrate.risk.stopped": "Its database is stopped: its files ({gb} GB) are copied once more while they are read, so that much extra space is needed.",
"migrate.risk.build": "Modules are compiled on this PC with Microsoft's C++ build tools; the SQUID bots alone can take up to an hour.",
"migrate.start": "Make the modular copy",
"q.modules": "What extra features does the server have?",
"mod.ahbot.types": "Item types for sale",
"mod.ahbot.typesHint": "Choose types for new listings. Existing auctions remain. Enabling a type restores its previous weights in this session, or the configured defaults.",
+44 -1
View File
@@ -849,7 +849,50 @@ const ru: Partial<Record<keyof typeof en, string>> = {
"realm.confirmDelete": "Удалить реалмлист «{name}»? В игровом клиенте останется текущий файл.",
"nav.modules": "Модули",
"nav.migration": "Перенос сервера",
"q.migration": "Перенос данных из существующего сервера в этот мир Preview",
"q.migration": "Перенос сервера с классическим ядром на модульное: копия рядом",
"migrate.title": "Перенос сервера",
"migrate.subtitle": "Сервер с классическим ядром не переделывается на месте. Рядом ставится новый сервер с модульным ядром, и в него копируются аккаунты, персонажи, мир, боты и настройки модулей.",
"migrate.source": "Какой сервер перенести",
"migrate.browse": "Обзор…",
"migrate.inspect": "Проверить",
"migrate.dialogTitle": "Выберите основную папку сервера",
"migrate.readOnly": "Проверка только читает папку; у запущенной базы лишь спрашивает количество. Ничего не запускается и не меняется.",
"migrate.classic.title": "Классическое ядро: можно скопировать",
"migrate.classic.text": "Копия станет новым сервером с модульным ядром. Этот сервер останется как есть.",
"migrate.modular.title": "Уже модульное ядро",
"migrate.modular.text": "Переносить нечего. Чтобы управлять этим сервером здесь, добавьте его через «Manage another server».",
"migrate.none.title": "Сервер не найден",
"migrate.none.text": "В этой папке нет Core\\worldserver.exe Windows-сервера. Выберите основную папку сервера.",
"migrate.fact.version": "Версия",
"migrate.fact.database": "База данных",
"migrate.fact.accounts": "Аккаунтов",
"migrate.fact.characters": "Персонажей",
"migrate.unknown": "неизвестна",
"migrate.countLater": "посчитаем при копировании",
"migrate.db.running": "запущена",
"migrate.db.stopped": "остановлена",
"migrate.db.missing": "не найдена",
"migrate.db.missingText": "У этого сервера нет папки базы данных (mysql\\data), копировать нечего.",
"migrate.copied": "Что копируется",
"migrate.copied.schemas": "Все игровые базы: {list}.",
"migrate.copied.allSchemas": "Все игровые базы (аккаунты, персонажи, мир, боты); список прочитаем при копировании.",
"migrate.copied.settings": "Настройки модулей и профили миров: файлов — {n}.",
"install.copy.worldSkipped": "Настройки мира, которые новая версия не приняла:",
"install.copy.worldSettings": "Перенесено настроек мира: {n}.",
"migrate.copied.world": "Настройки мира, изменённые в Server Configuration (рейты и т. п.).",
"migrate.copied.ports": "Порты и вход в консоль, как у старого сервера.",
"migrate.copied.modules": "Модули, включённые на старом сервере, собираются под модульное ядро во время копирования.",
"migrate.copied.worlds": "Дополнительные миры ({list}): их базы копируются, и они остаются включёнными.",
"migrate.notCopied": "Что не копируется",
"migrate.notCopied.conf": "Сами worldserver.conf и authserver.conf: новый сервер делает их из своих шаблонов. Значения, изменённые в Server Configuration, задаются на новом сервере.",
"migrate.notCopied.merge": "Ничего не сливается в {name} или другой существующий сервер: копия — всегда новый сервер в новой папке.",
"migrate.risks": "Перед началом",
"migrate.risk.oldKept": "Старый сервер только читается. Его папка и база не меняются, им можно продолжать пользоваться.",
"migrate.risk.ports": "У обоих серверов одинаковые порты: запускайте только один из них.",
"migrate.risk.running": "Его база запущена и будет прочитана в том виде, в каком она в этот момент. Сначала остановите его мир, чтобы игроки и боты не меняли данные во время копирования.",
"migrate.risk.stopped": "Его база остановлена: её файлы ({gb} ГБ) на время чтения копируются ещё раз, нужно столько же свободного места.",
"migrate.risk.build": "Модули компилируются на этом ПК инструментами сборки C++ от Microsoft; одни только боты SQUID могут собираться до часа.",
"migrate.start": "Сделать модульную копию",
"q.modules": "Какие дополнительные возможности есть у сервера?",
"mod.ahbot.types": "Типы предметов в продаже",
"mod.ahbot.typesHint": "Выберите типы для новых лотов. Уже выставленные лоты останутся. При включении возвращаются прежние веса этой сессии или значения по умолчанию.",
+16 -73
View File
@@ -510,6 +510,8 @@ export interface CopyReport {
characters: number;
config_files: string[];
read_from: string;
world_settings: string[];
world_settings_skipped: string[];
}
export interface InstallStep {
@@ -698,73 +700,19 @@ export interface CrashItem {
}
export type SourceClassification = "CSM_MANAGED_COA" | "LEGACY_EMBEDDED_COA" | "STANDARD_AZEROTH_CORE" | "UNKNOWN_CUSTOM";
export interface DetectedModule {
id: string;
name: string;
detected_via: string;
version?: string | null;
has_database_data: boolean;
is_supported_by_preview: boolean;
}
export interface SourceInspectionReport {
source_path: string;
source_type: SourceClassification;
core_version?: string | null;
schema_version?: string | null;
accounts_count: number;
characters_count: number;
detected_modules: DetectedModule[];
module_tables: Record<string, string[]>;
custom_race_support: boolean;
compatibility_notes: string[];
}
export interface MigrationScope {
accounts_and_characters: boolean;
module_owned_data: boolean;
server_configuration: boolean;
selected_modules: string[];
}
export type ConflictResolutionPolicy = "remap_colliding_guids" | "skip_colliding_guids" | "overwrite_existing";
export interface AdapterPlan {
module_id: string;
source_tables: string[];
dest_tables: string[];
data_loss_risks: string[];
requires_conversion: boolean;
}
export interface MigrationPlan {
plan_id: string;
source_path: string;
dest_realm_path: string;
source_report: SourceInspectionReport;
scope: MigrationScope;
conflict_policy: ConflictResolutionPolicy;
adapters: AdapterPlan[];
tables_to_migrate: string[];
quarantined_tables: string[];
estimated_disk_mb: number;
warnings: string[];
}
export interface MigrationExecutionReport {
success: boolean;
backup_path?: string | null;
accounts_transferred: number;
characters_transferred: number;
items_transferred: number;
modules_migrated: string[];
tables_migrated: string[];
quarantined_tables: string[];
warnings: string[];
errors: string[];
duration_ms: number;
/** What a modular copy of a server would take, read without changing anything (`modular_copy::preview`). */
export interface CopyPreview {
path: string;
classic: boolean;
modular: boolean;
version: string | null;
database: "running" | "stopped" | "missing";
accounts: number | null;
characters: number | null;
schemas: string[];
database_bytes: number;
config_files: string[];
extra_worlds: string[];
}
export type SolverVerdict =
@@ -919,12 +867,7 @@ export const api = {
invoke<InstalledPreviewPlugin[]>("preview_list_installed_plugins", { realmId }),
previewExportDiagnostics: (realmId?: string) =>
invoke<string>("preview_export_diagnostics", { realmId: realmId ?? null }),
previewMigrationInspect: (sourcePath: string) =>
invoke<SourceInspectionReport>("preview_migration_inspect", { sourcePath }),
previewMigrationPlan: (sourcePath: string, destRealmId: string, scope: MigrationScope, conflictPolicy: ConflictResolutionPolicy) =>
invoke<MigrationPlan>("preview_migration_plan", { sourcePath, destRealmId, scope, conflictPolicy }),
previewMigrationExecute: (plan: MigrationPlan) =>
invoke<MigrationExecutionReport>("preview_migration_execute", { plan }),
modularCopyPreview: (path: string) => invoke<CopyPreview>("modular_copy_preview", { path }),
previewCheckUpdates: (realmId: string) =>
invoke<SolverVerdict>("preview_check_updates", { realmId }),
};
+7
View File
@@ -143,8 +143,15 @@ export function InstallServer(props: { canCancel: boolean; copyFrom?: string; on
{copied && (
<p className="mt-3 text-sm text-muted">
{t("install.copy.done", { accounts: copied.accounts, characters: copied.characters, settings: copied.config_files.length })}
{" "}{t("install.copy.worldSettings", { n: copied.world_settings.length })}
</p>
)}
{copied && copied.world_settings_skipped.length > 0 && (
<Card className="mt-6 border-warn/40 p-4 text-left">
<p className="text-sm text-warn">{t("install.copy.worldSkipped")}</p>
<pre className="selectable mt-2 max-h-32 overflow-auto whitespace-pre-wrap rounded bg-black/40 p-2 text-xs">{copied.world_settings_skipped.join("\n")}</pre>
</Card>
)}
{classicCore && (
<Card className="mt-6 border-warn/40 p-4 text-left" role="alert">
<p className="font-medium text-warn">{t("install.classicCoreTitle")}</p>
+155 -547
View File
@@ -1,581 +1,189 @@
import { useState } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import {
AlertCircle,
AlertTriangle,
ArrowRight,
CheckCircle2,
Database,
FileCheck,
FolderOpen,
Loader2,
RotateCcw,
Server,
Shield,
ShieldCheck,
Users,
} from "lucide-react";
import { AlertTriangle, ArrowRight, Check, Database, FolderOpen, Info, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
api,
type ConflictResolutionPolicy,
type MigrationExecutionReport,
type MigrationPlan,
type MigrationScope,
type SourceInspectionReport,
} from "@/lib/api";
import { cn } from "@/lib/utils";
import { api, asUiError, type CopyPreview, type ServerSummary, type UiError } from "@/lib/api";
import { useHuman, useT } from "@/i18n";
export interface ServerMigrationWizardProps {
currentRealmId: string;
onClose?: () => void;
onSuccess?: () => void;
}
/**
* Moving a server to the modular core. A server with the classic core is never converted in place: a new server is
* installed next to it and its data is copied there (`modular_copy`). This page shows what the old server holds and
* what the copy takes, then hands over to the same install screen "Manage another server" uses for a modular copy.
*/
export function ServerMigrationWizard(props: {
servers: ServerSummary[];
currentName: string;
onCopy: (path: string) => void;
}) {
const t = useT();
const human = useHuman();
const [path, setPath] = useState("");
const [busy, setBusy] = useState(false);
const [info, setInfo] = useState<CopyPreview | null>(null);
const [error, setError] = useState<UiError | null>(null);
type WizardStep = "source" | "inspect" | "scope" | "plan" | "executing" | "result";
export function ServerMigrationWizard({
currentRealmId,
onClose,
onSuccess,
}: ServerMigrationWizardProps) {
const locale = "ru";
const [step, setStep] = useState<WizardStep>("source");
const [sourcePath, setSourcePath] = useState("");
const [inspecting, setInspecting] = useState(false);
const [report, setReport] = useState<SourceInspectionReport | null>(null);
// Scope
const [scope, setScope] = useState<MigrationScope>({
accounts_and_characters: true,
module_owned_data: true,
server_configuration: true,
selected_modules: [],
});
// Conflict policy
const [conflictPolicy, setConflictPolicy] = useState<ConflictResolutionPolicy>(
"remap_colliding_guids"
);
// Plan & Execution
const [planning, setPlanning] = useState(false);
const [plan, setPlan] = useState<MigrationPlan | null>(null);
const [executing, setExecuting] = useState(false);
const [execReport, setExecReport] = useState<MigrationExecutionReport | null>(null);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
async function handlePickSource() {
setErrorMsg(null);
async function inspect(p: string) {
if (!p.trim()) return;
setPath(p);
setError(null);
setInfo(null);
setBusy(true);
try {
const selected = await open({
directory: true,
multiple: false,
title: locale === "ru" ? "Выберите каталог исходного сервера" : "Select Source Server Directory",
});
if (typeof selected === "string") {
setSourcePath(selected);
}
setInfo(await api.modularCopyPreview(p.trim()));
} catch (e) {
setErrorMsg(String(e));
setError(asUiError(e));
} finally {
setBusy(false);
}
}
async function handleInspect() {
if (!sourcePath.trim()) return;
setErrorMsg(null);
setInspecting(true);
try {
const res = await api.previewMigrationInspect(sourcePath);
setReport(res);
// Auto-select detected modules
setScope((prev) => ({
...prev,
selected_modules: res.detected_modules.map((m) => m.id),
}));
setStep("inspect");
} catch (e: any) {
setErrorMsg(e?.human?.message || e?.technical || String(e));
} finally {
setInspecting(false);
}
async function browse() {
const picked = await open({ directory: true, multiple: false, title: t("migrate.dialogTitle") });
if (typeof picked === "string") await inspect(picked);
}
async function handleCreatePlan() {
if (!report) return;
setErrorMsg(null);
setPlanning(true);
try {
const res = await api.previewMigrationPlan(sourcePath, currentRealmId, scope, conflictPolicy);
setPlan(res);
setStep("plan");
} catch (e: any) {
setErrorMsg(e?.human?.message || e?.technical || String(e));
} finally {
setPlanning(false);
}
}
async function handleExecute() {
if (!plan) return;
setErrorMsg(null);
setExecuting(true);
setStep("executing");
try {
const res = await api.previewMigrationExecute(plan);
setExecReport(res);
setStep("result");
if (res.success && onSuccess) {
onSuccess();
}
} catch (e: any) {
setErrorMsg(e?.human?.message || e?.technical || String(e));
setStep("plan");
} finally {
setExecuting(false);
}
}
const gb = (bytes: number) => (bytes / 1024 ** 3).toFixed(1);
const count = (n: number | null) => (n === null ? t("migrate.countLater") : String(n));
return (
<div className="flex h-full flex-col max-w-4xl mx-auto p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between border-b border-line pb-4">
<div>
<h2 className="text-xl font-bold tracking-tight text-ink flex items-center gap-2">
<RotateCcw className="h-6 w-6 text-gold" />
{locale === "ru" ? "Центр переноса сервера" : "Server Migration Center"}
<span className="rounded bg-sky-500/20 px-2 py-0.5 text-xs font-mono font-medium text-sky-400">
MIGRATION WIZARD
</span>
</h2>
<p className="mt-1 text-xs text-muted">
{locale === "ru"
? "Перенос персонажей, аккаунтов, данных модулей и конфигураций в изолированный модульный сервер Preview."
: "Migrate characters, accounts, module data and configs into an isolated modular Preview realm."}
</p>
</div>
{onClose && (
<Button size="sm" variant="ghost" onClick={onClose} className="text-xs">
{locale === "ru" ? "Закрыть" : "Close"}
</Button>
<div className="mx-auto flex max-w-3xl flex-col gap-5 px-8 py-8">
<div>
<h1 className="text-2xl font-semibold">{t("migrate.title")}</h1>
<p className="mt-1 text-sm text-muted">{t("migrate.subtitle")}</p>
</div>
<Card className="flex flex-col gap-3 p-5">
<h2 className="font-semibold">{t("migrate.source")}</h2>
{props.servers.length > 0 && (
<div className="flex flex-wrap gap-2">
{props.servers.map((s) => (
<Button key={s.id} variant="secondary" size="sm" disabled={busy} onClick={() => void inspect(s.path)}>
{s.name}
</Button>
))}
</div>
)}
</div>
<div className="flex gap-2">
<input
className="flex-1 rounded-md border border-line bg-bg px-3 py-2 font-mono text-sm outline-none focus:border-gold"
value={path}
placeholder="C:\Games\CoA Server"
onChange={(e) => setPath(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && void inspect(path)}
/>
<Button variant="secondary" onClick={() => void browse()} disabled={busy}>
<FolderOpen className="mr-2 h-4 w-4" aria-hidden /> {t("migrate.browse")}
</Button>
<Button onClick={() => void inspect(path)} disabled={busy || !path.trim()}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden /> : t("migrate.inspect")}
</Button>
</div>
<p className="text-xs text-muted">{t("migrate.readOnly")}</p>
</Card>
{errorMsg && (
<Card className="border-bad/40 bg-bad/5 p-4 text-xs text-bad flex items-start gap-2.5">
<AlertCircle className="h-4 w-4 shrink-0 mt-0.5" />
<div className="flex-1">
<p className="font-semibold">{locale === "ru" ? "Ошибка переноса" : "Migration Error"}</p>
<p className="mt-0.5 whitespace-pre-wrap">{errorMsg}</p>
</div>
{error && (
<Card className="border-bad/40 p-4" role="alert">
<p className="font-medium text-bad">{human(error.human).title}</p>
<p className="mt-1 text-sm text-muted">{human(error.human).message}</p>
</Card>
)}
{/* Stepper Progress */}
<div className="flex items-center justify-between text-xs font-medium text-muted px-2">
<span className={cn(step === "source" && "text-gold font-bold")}>1. Исходный сервер</span>
<ArrowRight className="h-3 w-3 text-line" />
<span className={cn(step === "inspect" && "text-gold font-bold")}>2. Анализ данных</span>
<ArrowRight className="h-3 w-3 text-line" />
<span className={cn(step === "scope" && "text-gold font-bold")}>3. Область переноса</span>
<ArrowRight className="h-3 w-3 text-line" />
<span className={cn(step === "plan" && "text-gold font-bold")}>4. План и риски</span>
<ArrowRight className="h-3 w-3 text-line" />
<span className={cn((step === "executing" || step === "result") && "text-gold font-bold")}>5. Перенос</span>
</div>
{/* STEP 1: SELECT SOURCE */}
{step === "source" && (
<Card className="p-6 space-y-4">
<h3 className="text-sm font-semibold text-ink flex items-center gap-2">
<FolderOpen className="h-4 w-4 text-gold" />
{locale === "ru" ? "Шаг 1: Укажите каталог исходного сервера" : "Step 1: Select Source Server"}
</h3>
<p className="text-xs text-muted">
{locale === "ru"
? "Поддерживаются серверы под управлением CoA Server Manager, устаревшие CoA-серверы со встроенными ботами, а также стандартные серверы AzerothCore."
: "Supports CSM-managed servers, legacy embedded CoA installations, and standard AzerothCore."}
</p>
<div className="flex items-center gap-3">
<input
type="text"
value={sourcePath}
onChange={(e) => setSourcePath(e.target.value)}
placeholder="C:\games\CoA Server или путь к серверу..."
className="flex-1 rounded-md border border-line bg-bg px-3 py-2 text-xs text-ink focus:border-gold focus:outline-none font-mono"
/>
<Button size="sm" variant="secondary" onClick={handlePickSource}>
<FolderOpen className="h-3.5 w-3.5 mr-1.5" />
{locale === "ru" ? "Обзор..." : "Browse..."}
</Button>
</div>
<div className="rounded-lg bg-white/5 border border-line p-3 text-xs text-muted space-y-1">
<p className="font-semibold text-ink flex items-center gap-1.5">
<ShieldCheck className="h-4 w-4 text-ok" />
{locale === "ru" ? "Гарантия безопасности данных" : "Safety Guarantee"}
</p>
<p>
{locale === "ru"
? "Анализ выполняется строго в режиме чтения (Read-Only). Никакие скрипты и изменения на исходном сервере не выполняются."
: "Source server inspection is strictly read-only. No scripts or writes are performed."}
</p>
</div>
<div className="flex justify-end pt-2">
<Button
size="sm"
variant="primary"
disabled={!sourcePath.trim() || inspecting}
onClick={handleInspect}
>
{inspecting && <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />}
{locale === "ru" ? "Проанализировать сервер" : "Inspect Server"}
<ArrowRight className="h-3.5 w-3.5 ml-1.5" />
</Button>
</div>
</Card>
)}
{/* STEP 2: INSPECTION REPORT */}
{step === "inspect" && report && (
<Card className="p-6 space-y-4">
<h3 className="text-sm font-semibold text-ink flex items-center gap-2">
<Server className="h-4 w-4 text-gold" />
{locale === "ru" ? "Шаг 2: Результаты анализа исходного сервера" : "Step 2: Source Inspection Results"}
</h3>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="rounded-lg border border-line bg-card p-3">
<span className="text-[11px] text-muted">{locale === "ru" ? "Тип сервера" : "Server Type"}</span>
<p className="mt-1 font-mono text-xs font-semibold text-ink">
{report.source_type === "CSM_MANAGED_COA" && "CSM Managed CoA"}
{report.source_type === "LEGACY_EMBEDDED_COA" && "Legacy CoA (Embedded)"}
{report.source_type === "STANDARD_AZEROTH_CORE" && "Standard AzerothCore"}
{report.source_type === "UNKNOWN_CUSTOM" && "Custom Layout"}
</p>
</div>
<div className="rounded-lg border border-line bg-card p-3">
<span className="text-[11px] text-muted">{locale === "ru" ? "Аккаунтов" : "Accounts"}</span>
<p className="mt-1 font-mono text-xs font-semibold text-ink flex items-center gap-1.5">
<Users className="h-3.5 w-3.5 text-gold" />
{report.accounts_count}
</p>
</div>
<div className="rounded-lg border border-line bg-card p-3">
<span className="text-[11px] text-muted">{locale === "ru" ? "Персонажей" : "Characters"}</span>
<p className="mt-1 font-mono text-xs font-semibold text-ink flex items-center gap-1.5">
<Users className="h-3.5 w-3.5 text-sky-400" />
{report.characters_count}
</p>
</div>
<div className="rounded-lg border border-line bg-card p-3">
<span className="text-[11px] text-muted">{locale === "ru" ? "Схема БД" : "DB Schema"}</span>
<p className="mt-1 font-mono text-xs font-semibold text-ink">
{report.schema_version ?? "2026_10_28_00"}
</p>
</div>
</div>
{/* Detected Modules */}
<div>
<h4 className="text-xs font-semibold text-ink mb-2">
{locale === "ru" ? "Обнаруженные модули и таблицы:" : "Detected Modules & Tables:"}
</h4>
{report.detected_modules.length === 0 ? (
<p className="text-xs text-muted italic">
{locale === "ru" ? "Сторонние модули не обнаружены." : "No external modules detected."}
</p>
{info && (
<Card className="flex flex-col gap-4 p-5">
<div className="flex items-start gap-3">
{info.classic ? (
<Check className="mt-0.5 h-5 w-5 text-ok" aria-hidden />
) : (
<div className="divide-y divide-line border border-line rounded-lg">
{report.detected_modules.map((m) => (
<div key={m.id} className="p-3 flex items-center justify-between text-xs">
<div>
<p className="font-semibold text-ink">{m.name} <span className="font-mono text-[11px] text-muted">({m.id})</span></p>
<p className="text-[11px] text-muted">Обнаружен через: {m.detected_via}</p>
</div>
<span className="rounded bg-ok/10 text-ok border border-ok/30 px-2 py-0.5 text-[11px] font-medium">
✓ Адаптер готов
</span>
</div>
))}
</div>
<Info className="mt-0.5 h-5 w-5 text-warn" aria-hidden />
)}
</div>
<div className="flex justify-between pt-2">
<Button size="sm" variant="ghost" onClick={() => setStep("source")}>
{locale === "ru" ? "Назад" : "Back"}
</Button>
<Button size="sm" variant="primary" onClick={() => setStep("scope")}>
{locale === "ru" ? "Настроить перенос" : "Configure Scope"}
<ArrowRight className="h-3.5 w-3.5 ml-1.5" />
</Button>
</div>
</Card>
)}
{/* STEP 3: SCOPE & CONFLICT RESOLUTION */}
{step === "scope" && report && (
<Card className="p-6 space-y-5">
<h3 className="text-sm font-semibold text-ink flex items-center gap-2">
<Database className="h-4 w-4 text-gold" />
{locale === "ru" ? "Шаг 3: Выбор категорий данных и политика конфликтов" : "Step 3: Migration Scope"}
</h3>
<div className="space-y-3">
<label className="flex items-start gap-3 p-3 rounded-lg border border-line bg-card cursor-pointer hover:border-gold/40">
<input
type="checkbox"
checked={scope.accounts_and_characters}
onChange={(e) => setScope({ ...scope, accounts_and_characters: e.target.checked })}
className="mt-0.5 h-4 w-4 rounded border-line text-gold focus:ring-gold"
/>
<div>
<p className="text-xs font-semibold text-ink">
{locale === "ru" ? "Аккаунты и персонажи" : "Accounts & Characters"}
</p>
<p className="text-[11px] text-muted">
{locale === "ru"
? "Учетные записи, персонажи, инвентарь, таланты, заклинания, символы, навыки, репутация, квесты и питомцы."
: "Accounts, characters, inventory, spells, talents, glyphs, skills, reputation, quests, and pets."}
</p>
</div>
</label>
<label className="flex items-start gap-3 p-3 rounded-lg border border-line bg-card cursor-pointer hover:border-gold/40">
<input
type="checkbox"
checked={scope.module_owned_data}
onChange={(e) => setScope({ ...scope, module_owned_data: e.target.checked })}
className="mt-0.5 h-4 w-4 rounded border-line text-gold focus:ring-gold"
/>
<div>
<p className="text-xs font-semibold text-ink">
{locale === "ru" ? "Данные модулей" : "Module-owned Data"}
</p>
<p className="text-[11px] text-muted">
{locale === "ru"
? "Сохраненные боты (Companions/Playerbots), данные масштабирования контента, таблицы аукцион-бота и зачарователя."
: "Saved bot configurations, content scaling overrides, AH bot data, and NPC enchanter states."}
</p>
</div>
</label>
<label className="flex items-start gap-3 p-3 rounded-lg border border-line bg-card cursor-pointer hover:border-gold/40">
<input
type="checkbox"
checked={scope.server_configuration}
onChange={(e) => setScope({ ...scope, server_configuration: e.target.checked })}
className="mt-0.5 h-4 w-4 rounded border-line text-gold focus:ring-gold"
/>
<div>
<p className="text-xs font-semibold text-ink">
{locale === "ru" ? "Конфигурация сервера (.conf)" : "Server Configurations (.conf)"}
</p>
<p className="text-[11px] text-muted">
{locale === "ru"
? "Пользовательские настройки модулей из configs/modules (с сохранением безопасных значений)."
: "Preserve customized configs from configs/modules/."}
</p>
</div>
</label>
</div>
{/* Conflict Policy */}
<div className="border-t border-line pt-4 space-y-2">
<h4 className="text-xs font-semibold text-ink">
{locale === "ru" ? "Политика разрешения конфликтов GUID:" : "GUID Conflict Resolution Policy:"}
</h4>
<div className="grid gap-2 sm:grid-cols-3">
<button
type="button"
onClick={() => setConflictPolicy("remap_colliding_guids")}
className={cn(
"p-3 rounded-lg border text-left text-xs transition-colors",
conflictPolicy === "remap_colliding_guids"
? "border-gold bg-gold/10 text-gold"
: "border-line bg-card text-muted hover:text-ink"
)}
>
<p className="font-semibold text-ink">Переназначить GUID</p>
<p className="text-[11px] text-muted mt-1">
Безопасное смещение ID персонажей и предметов. Ничего не теряется.
</p>
</button>
<button
type="button"
onClick={() => setConflictPolicy("skip_colliding_guids")}
className={cn(
"p-3 rounded-lg border text-left text-xs transition-colors",
conflictPolicy === "skip_colliding_guids"
? "border-gold bg-gold/10 text-gold"
: "border-line bg-card text-muted hover:text-ink"
)}
>
<p className="font-semibold text-ink">Пропустить дубликаты</p>
<p className="text-[11px] text-muted mt-1">
Персонажи с существующими GUID не импортируются.
</p>
</button>
<button
type="button"
onClick={() => setConflictPolicy("overwrite_existing")}
className={cn(
"p-3 rounded-lg border text-left text-xs transition-colors",
conflictPolicy === "overwrite_existing"
? "border-bad bg-bad/10 text-bad"
: "border-line bg-card text-muted hover:text-ink"
)}
>
<p className="font-semibold text-ink">Перезаписать (Опасно)</p>
<p className="text-[11px] text-muted mt-1">
Заменяет существующих персонажей на целевом сервере.
</p>
</button>
</div>
</div>
<div className="flex justify-between pt-2">
<Button size="sm" variant="ghost" onClick={() => setStep("inspect")}>
{locale === "ru" ? "Назад" : "Back"}
</Button>
<Button size="sm" variant="primary" disabled={planning} onClick={handleCreatePlan}>
{planning && <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />}
{locale === "ru" ? "Сформировать план" : "Generate Plan"}
<ArrowRight className="h-3.5 w-3.5 ml-1.5" />
</Button>
</div>
</Card>
)}
{/* STEP 4: PLAN & RISKS */}
{step === "plan" && plan && (
<Card className="p-6 space-y-4">
<h3 className="text-sm font-semibold text-ink flex items-center gap-2">
<FileCheck className="h-4 w-4 text-gold" />
{locale === "ru" ? "Шаг 4: План переноса и подтверждение" : "Step 4: Preflight Plan"}
</h3>
<div className="rounded-lg border border-gold/30 bg-gold/5 p-4 text-xs space-y-2">
<p className="font-semibold text-gold flex items-center gap-1.5">
<Shield className="h-4 w-4" />
{locale === "ru" ? "Автоматическое создание точки восстановления" : "Automatic Restore Point"}
</p>
<p className="text-muted">
{locale === "ru"
? "Перед началом переноса будет создана полная резервная копия целевой базы данных и файлов. В случае любой ошибки состояние будет полностью восстановлено."
: "A complete backup of the destination realm will be created before any changes. Full rollback is guaranteed on failure."}
</p>
</div>
{plan.warnings.length > 0 && (
<div className="space-y-1">
<h4 className="text-xs font-semibold text-ink flex items-center gap-1.5">
<AlertTriangle className="h-3.5 w-3.5 text-warn" />
{locale === "ru" ? "Предупреждения и риски:" : "Warnings:"}
</h4>
<ul className="list-disc list-inside text-xs text-muted space-y-1">
{plan.warnings.map((w, idx) => (
<li key={idx}>{w}</li>
))}
</ul>
</div>
)}
<div>
<h4 className="text-xs font-semibold text-ink mb-1.5">
{locale === "ru" ? "Таблицы к переносу (" + plan.tables_to_migrate.length + "):" : "Tables to Migrate:"}
</h4>
<div className="flex flex-wrap gap-1.5">
{plan.tables_to_migrate.map((t) => (
<span key={t} className="rounded bg-white/5 border border-line px-2 py-0.5 text-[11px] font-mono text-muted">
{t}
</span>
))}
</div>
</div>
<div className="flex justify-between pt-3 border-t border-line">
<Button size="sm" variant="ghost" onClick={() => setStep("scope")}>
{locale === "ru" ? "Назад" : "Back"}
</Button>
<Button size="sm" variant="primary" disabled={executing} onClick={handleExecute} className="bg-ok hover:bg-ok/90 text-white">
{executing && <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />}
{locale === "ru" ? "Подтвердить и перенести" : "Start Migration"}
</Button>
</div>
</Card>
)}
{/* STEP 5: EXECUTING */}
{step === "executing" && (
<Card className="p-8 text-center space-y-4">
<Loader2 className="h-8 w-8 text-gold animate-spin mx-auto" />
<h3 className="text-base font-bold text-ink">
{locale === "ru" ? "Выполняется перенос сервера..." : "Migrating Server Data..."}
</h3>
<p className="text-xs text-muted max-w-md mx-auto">
{locale === "ru"
? "Создание резервной копии, перенос персонажей, адаптация таблиц модулей и проверка ссылочной целостности."
: "Creating backup, remapping character GUIDs, executing module adapters, and verifying integrity."}
</p>
</Card>
)}
{/* STEP 6: RESULT */}
{step === "result" && execReport && (
<Card className="p-6 space-y-5">
<div className="flex items-center gap-3">
<CheckCircle2 className="h-8 w-8 text-ok shrink-0" />
<div>
<h3 className="text-base font-bold text-ink">
{locale === "ru" ? "Перенос сервера успешно завершен!" : "Migration Completed Successfully!"}
</h3>
<p className="text-xs text-muted">
{locale === "ru"
? `Все выбранные данные перенесены за ${(execReport.duration_ms / 1000).toFixed(1)} с.`
: `All data migrated in ${(execReport.duration_ms / 1000).toFixed(1)}s.`}
<h2 className="font-semibold">
{info.classic ? t("migrate.classic.title") : info.modular ? t("migrate.modular.title") : t("migrate.none.title")}
</h2>
<p className="text-sm text-muted">
{info.classic ? t("migrate.classic.text") : info.modular ? t("migrate.modular.text") : t("migrate.none.text")}
</p>
<p className="selectable mt-1 break-all text-xs text-muted">{info.path}</p>
</div>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="rounded-lg border border-line bg-card p-3">
<span className="text-[11px] text-muted">Аккаунтов</span>
<p className="font-mono text-sm font-bold text-ink">{execReport.accounts_transferred}</p>
</div>
<div className="rounded-lg border border-line bg-card p-3">
<span className="text-[11px] text-muted">Персонажей</span>
<p className="font-mono text-sm font-bold text-ink">{execReport.characters_transferred}</p>
</div>
<div className="rounded-lg border border-line bg-card p-3">
<span className="text-[11px] text-muted">Модулей адаптировано</span>
<p className="font-mono text-sm font-bold text-ok">{execReport.modules_migrated.length}</p>
</div>
<div className="rounded-lg border border-line bg-card p-3">
<span className="text-[11px] text-muted">Таблиц перенесено</span>
<p className="font-mono text-sm font-bold text-ink">{execReport.tables_migrated.length}</p>
</div>
</div>
{execReport.backup_path && (
<div className="rounded-lg bg-white/5 border border-line p-3 text-xs text-muted">
<p className="font-semibold text-ink">{locale === "ru" ? "Точка восстановления:" : "Restore Point:"}</p>
<p className="font-mono text-[11px] break-all">{execReport.backup_path}</p>
</div>
{(info.classic || info.modular) && (
<dl className="grid grid-cols-2 gap-3 text-sm sm:grid-cols-4">
<Fact label={t("migrate.fact.version")} value={info.version ?? t("migrate.unknown")} />
<Fact label={t("migrate.fact.database")} value={t(`migrate.db.${info.database}`)} />
<Fact label={t("migrate.fact.accounts")} value={count(info.accounts)} />
<Fact label={t("migrate.fact.characters")} value={count(info.characters)} />
</dl>
)}
<div className="flex justify-end pt-2">
<Button size="sm" variant="primary" onClick={onClose}>
{locale === "ru" ? "Готово" : "Finish"}
</Button>
</div>
{info.classic && info.database === "missing" && (
<p className="flex items-start gap-2 text-sm text-bad">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden /> {t("migrate.db.missingText")}
</p>
)}
{info.classic && info.database !== "missing" && (
<>
<div>
<h3 className="flex items-center gap-2 text-sm font-semibold">
<Database className="h-4 w-4" aria-hidden /> {t("migrate.copied")}
</h3>
<ul className="mt-2 list-disc space-y-1 pl-5 text-sm text-muted">
<li>
{info.schemas.length > 0
? t("migrate.copied.schemas", { list: info.schemas.join(", ") })
: t("migrate.copied.allSchemas")}
</li>
<li>{t("migrate.copied.settings", { n: info.config_files.length })}</li>
<li>{t("migrate.copied.world")}</li>
<li>{t("migrate.copied.ports")}</li>
<li>{t("migrate.copied.modules")}</li>
{info.extra_worlds.length > 0 && (
<li>{t("migrate.copied.worlds", { list: info.extra_worlds.join(", ") })}</li>
)}
</ul>
</div>
<div>
<h3 className="text-sm font-semibold">{t("migrate.notCopied")}</h3>
<ul className="mt-2 list-disc space-y-1 pl-5 text-sm text-muted">
<li>{t("migrate.notCopied.conf")}</li>
<li>{t("migrate.notCopied.merge", { name: props.currentName })}</li>
</ul>
</div>
<div>
<h3 className="flex items-center gap-2 text-sm font-semibold text-warn">
<AlertTriangle className="h-4 w-4" aria-hidden /> {t("migrate.risks")}
</h3>
<ul className="mt-2 list-disc space-y-1 pl-5 text-sm text-muted">
<li>{t("migrate.risk.oldKept")}</li>
<li>{t("migrate.risk.ports")}</li>
{info.database === "running" ? (
<li>{t("migrate.risk.running")}</li>
) : (
<li>{t("migrate.risk.stopped", { gb: gb(info.database_bytes) })}</li>
)}
<li>{t("migrate.risk.build")}</li>
</ul>
</div>
<div className="flex justify-end">
<Button onClick={() => props.onCopy(info.path)}>
{t("migrate.start")} <ArrowRight className="ml-2 h-4 w-4" aria-hidden />
</Button>
</div>
</>
)}
</Card>
)}
</div>
);
}
function Fact(props: { label: string; value: string }) {
return (
<div className="rounded-md border border-line p-3">
<dt className="text-xs text-muted">{props.label}</dt>
<dd className="mt-1 font-mono text-sm">{props.value}</dd>
</div>
);
}
+3 -1
View File
@@ -46,6 +46,8 @@ export function Shell(props: {
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();
@@ -174,7 +176,7 @@ export function Shell(props: {
) : page === "players" ? (
<PlayersPage key={server.id} serverId={server.id} />
) : page === "migration" ? (
<ServerMigrationWizard key={server.id} currentRealmId={server.id} onClose={() => setPage("overview")} />
<ServerMigrationWizard key={server.id} servers={props.servers} currentName={server.name} onCopy={props.onCopy} />
) : page === "backups" ? (
<BackupsPage key={server.id} serverId={server.id} />
) : (