diff --git a/changelog.d/20261111-real-server-migration.md b/changelog.d/20261111-real-server-migration.md new file mode 100644 index 0000000..1672d4a --- /dev/null +++ b/changelog.d/20261111-real-server-migration.md @@ -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. diff --git a/crates/coa-core/examples/preview_bare_and_copy_e2e.rs b/crates/coa-core/examples/preview_bare_and_copy_e2e.rs index 2d10632..63858a2 100644 --- a/crates/coa-core/examples/preview_bare_and_copy_e2e.rs +++ b/crates/coa-core/examples/preview_bare_and_copy_e2e.rs @@ -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 = 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 = 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(()) diff --git a/crates/coa-core/src/lib.rs b/crates/coa-core/src/lib.rs index bd7c679..b8245b9 100644 --- a/crates/coa-core/src/lib.rs +++ b/crates/coa-core/src/lib.rs @@ -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; diff --git a/crates/coa-core/src/modular_copy.rs b/crates/coa-core/src/modular_copy.rs index 23820aa..e084aab 100644 --- a/crates/coa-core/src/modular_copy.rs +++ b/crates/coa-core/src/modular_copy.rs @@ -38,6 +38,84 @@ pub struct CopyReport { pub config_files: Vec, /// 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, + /// Changed world settings the new server's configuration does not take, with the reason. + pub world_settings_skipped: Vec, +} + +/// 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, + /// "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, + pub characters: Option, + pub schemas: Vec, + /// 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, + /// Extra worlds of the old server (`.realms/`). 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, +} + +pub fn preview(old: &Path) -> Result { + 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 = 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 Result<(Vec, Vec)> { + let current: std::collections::BTreeMap = + 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(); diff --git a/crates/coa-core/src/server_migration.rs b/crates/coa-core/src/server_migration.rs deleted file mode 100644 index 07973dc..0000000 --- a/crates/coa-core/src/server_migration.rs +++ /dev/null @@ -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, - 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, - pub schema_version: Option, - pub accounts_count: usize, - pub characters_count: usize, - pub detected_modules: Vec, - pub module_tables: BTreeMap>, - pub custom_race_support: bool, - pub compatibility_notes: Vec, -} - -#[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, -} - -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, - pub dest_tables: Vec, - pub data_loss_risks: Vec, - 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, - pub tables_to_migrate: Vec, - pub quarantined_tables: Vec, - pub estimated_disk_mb: u64, - pub warnings: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MigrationExecutionReport { - pub success: bool, - pub backup_path: Option, - pub accounts_transferred: usize, - pub characters_transferred: usize, - pub items_transferred: usize, - pub modules_migrated: Vec, - pub tables_migrated: Vec, - pub quarantined_tables: Vec, - pub warnings: Vec, - pub errors: Vec, - 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) -> Result>; -} - -// ---------------- 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 = 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) -> Result> { - 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 = 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) -> Result> { - 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 = 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) -> Result> { - 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 = 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) -> Result> { - 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 = 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) -> Result> { - 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> { - 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 { - 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 = 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::(&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 { - 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 = 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::()); - - 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 { - 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())); - } -} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 70b5eb0..d96737a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3374,42 +3374,8 @@ async fn preview_export_diagnostics( } #[tauri::command] -async fn preview_migration_inspect( - source_path: String, -) -> std::result::Result { - 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 { - 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 { - blocking(move || { - coa_core::server_migration::execute_migration(&plan).map_err(Into::into) - }) - .await +async fn modular_copy_preview(path: String) -> std::result::Result { + 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, diff --git a/src/App.tsx b/src/App.tsx index 74e013b..154ec57 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,6 +21,8 @@ function Main() { const [activeId, setActiveId] = useState(null); // A classic server the owner chose: the install screen makes a modular copy of it next to it. const [copyFrom, setCopyFrom] = useState(null); + // Where Cancel on that install screen goes back to: the import screen or the server's own pages. + const [copyBack, setCopyBack] = useState("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"); }} /> diff --git a/src/dev/mock.ts b/src/dev/mock.ts index 7b8e566..8f68e8e 100644 --- a/src/dev/mock.ts +++ b/src/dev/mock.ts @@ -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:""};} diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index dc96805..2bff79c 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -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.", diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index edd2755..6e12da1 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -849,7 +849,50 @@ const ru: Partial> = { "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": "Выберите типы для новых лотов. Уже выставленные лоты останутся. При включении возвращаются прежние веса этой сессии или значения по умолчанию.", diff --git a/src/lib/api.ts b/src/lib/api.ts index 736cc3d..2d12f70 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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; - 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("preview_list_installed_plugins", { realmId }), previewExportDiagnostics: (realmId?: string) => invoke("preview_export_diagnostics", { realmId: realmId ?? null }), - previewMigrationInspect: (sourcePath: string) => - invoke("preview_migration_inspect", { sourcePath }), - previewMigrationPlan: (sourcePath: string, destRealmId: string, scope: MigrationScope, conflictPolicy: ConflictResolutionPolicy) => - invoke("preview_migration_plan", { sourcePath, destRealmId, scope, conflictPolicy }), - previewMigrationExecute: (plan: MigrationPlan) => - invoke("preview_migration_execute", { plan }), + modularCopyPreview: (path: string) => invoke("modular_copy_preview", { path }), previewCheckUpdates: (realmId: string) => invoke("preview_check_updates", { realmId }), }; diff --git a/src/screens/InstallServer.tsx b/src/screens/InstallServer.tsx index 74a8bd9..3c1c18c 100644 --- a/src/screens/InstallServer.tsx +++ b/src/screens/InstallServer.tsx @@ -143,8 +143,15 @@ export function InstallServer(props: { canCancel: boolean; copyFrom?: string; on {copied && (

{t("install.copy.done", { accounts: copied.accounts, characters: copied.characters, settings: copied.config_files.length })} + {" "}{t("install.copy.worldSettings", { n: copied.world_settings.length })}

)} + {copied && copied.world_settings_skipped.length > 0 && ( + +

{t("install.copy.worldSkipped")}

+
{copied.world_settings_skipped.join("\n")}
+
+ )} {classicCore && (

{t("install.classicCoreTitle")}

diff --git a/src/screens/ServerMigrationWizard.tsx b/src/screens/ServerMigrationWizard.tsx index 3928380..7d92d1b 100644 --- a/src/screens/ServerMigrationWizard.tsx +++ b/src/screens/ServerMigrationWizard.tsx @@ -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(null); + const [error, setError] = useState(null); -type WizardStep = "source" | "inspect" | "scope" | "plan" | "executing" | "result"; - -export function ServerMigrationWizard({ - currentRealmId, - onClose, - onSuccess, -}: ServerMigrationWizardProps) { - const locale = "ru"; - - const [step, setStep] = useState("source"); - const [sourcePath, setSourcePath] = useState(""); - const [inspecting, setInspecting] = useState(false); - const [report, setReport] = useState(null); - - // Scope - const [scope, setScope] = useState({ - accounts_and_characters: true, - module_owned_data: true, - server_configuration: true, - selected_modules: [], - }); - - // Conflict policy - const [conflictPolicy, setConflictPolicy] = useState( - "remap_colliding_guids" - ); - - // Plan & Execution - const [planning, setPlanning] = useState(false); - const [plan, setPlan] = useState(null); - const [executing, setExecuting] = useState(false); - const [execReport, setExecReport] = useState(null); - const [errorMsg, setErrorMsg] = useState(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 ( -
- {/* Header */} -
-
-

- - {locale === "ru" ? "Центр переноса сервера" : "Server Migration Center"} - - MIGRATION WIZARD - -

-

- {locale === "ru" - ? "Перенос персонажей, аккаунтов, данных модулей и конфигураций в изолированный модульный сервер Preview." - : "Migrate characters, accounts, module data and configs into an isolated modular Preview realm."} -

-
- {onClose && ( - +
+
+

{t("migrate.title")}

+

{t("migrate.subtitle")}

+
+ + +

{t("migrate.source")}

+ {props.servers.length > 0 && ( +
+ {props.servers.map((s) => ( + + ))} +
)} -
+
+ setPath(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && void inspect(path)} + /> + + +
+

{t("migrate.readOnly")}

+ - {errorMsg && ( - - -
-

{locale === "ru" ? "Ошибка переноса" : "Migration Error"}

-

{errorMsg}

-
+ {error && ( + +

{human(error.human).title}

+

{human(error.human).message}

)} - {/* Stepper Progress */} -
- 1. Исходный сервер - - 2. Анализ данных - - 3. Область переноса - - 4. План и риски - - 5. Перенос -
- - {/* STEP 1: SELECT SOURCE */} - {step === "source" && ( - -

- - {locale === "ru" ? "Шаг 1: Укажите каталог исходного сервера" : "Step 1: Select Source Server"} -

-

- {locale === "ru" - ? "Поддерживаются серверы под управлением CoA Server Manager, устаревшие CoA-серверы со встроенными ботами, а также стандартные серверы AzerothCore." - : "Supports CSM-managed servers, legacy embedded CoA installations, and standard AzerothCore."} -

- -
- 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" - /> - -
- -
-

- - {locale === "ru" ? "Гарантия безопасности данных" : "Safety Guarantee"} -

-

- {locale === "ru" - ? "Анализ выполняется строго в режиме чтения (Read-Only). Никакие скрипты и изменения на исходном сервере не выполняются." - : "Source server inspection is strictly read-only. No scripts or writes are performed."} -

-
- -
- -
-
- )} - - {/* STEP 2: INSPECTION REPORT */} - {step === "inspect" && report && ( - -

- - {locale === "ru" ? "Шаг 2: Результаты анализа исходного сервера" : "Step 2: Source Inspection Results"} -

- -
-
- {locale === "ru" ? "Тип сервера" : "Server Type"} -

- {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"} -

-
-
- {locale === "ru" ? "Аккаунтов" : "Accounts"} -

- - {report.accounts_count} -

-
-
- {locale === "ru" ? "Персонажей" : "Characters"} -

- - {report.characters_count} -

-
-
- {locale === "ru" ? "Схема БД" : "DB Schema"} -

- {report.schema_version ?? "2026_10_28_00"} -

-
-
- - {/* Detected Modules */} -
-

- {locale === "ru" ? "Обнаруженные модули и таблицы:" : "Detected Modules & Tables:"} -

- {report.detected_modules.length === 0 ? ( -

- {locale === "ru" ? "Сторонние модули не обнаружены." : "No external modules detected."} -

+ {info && ( + +
+ {info.classic ? ( + ) : ( -
- {report.detected_modules.map((m) => ( -
-
-

{m.name} ({m.id})

-

Обнаружен через: {m.detected_via}

-
- - ✓ Адаптер готов - -
- ))} -
+ )} -
- -
- - -
-
- )} - - {/* STEP 3: SCOPE & CONFLICT RESOLUTION */} - {step === "scope" && report && ( - -

- - {locale === "ru" ? "Шаг 3: Выбор категорий данных и политика конфликтов" : "Step 3: Migration Scope"} -

- -
- - - - - -
- - {/* Conflict Policy */} -
-

- {locale === "ru" ? "Политика разрешения конфликтов GUID:" : "GUID Conflict Resolution Policy:"} -

-
- - - -
-
- -
- - -
-
- )} - - {/* STEP 4: PLAN & RISKS */} - {step === "plan" && plan && ( - -

- - {locale === "ru" ? "Шаг 4: План переноса и подтверждение" : "Step 4: Preflight Plan"} -

- -
-

- - {locale === "ru" ? "Автоматическое создание точки восстановления" : "Automatic Restore Point"} -

-

- {locale === "ru" - ? "Перед началом переноса будет создана полная резервная копия целевой базы данных и файлов. В случае любой ошибки состояние будет полностью восстановлено." - : "A complete backup of the destination realm will be created before any changes. Full rollback is guaranteed on failure."} -

-
- - {plan.warnings.length > 0 && ( -
-

- - {locale === "ru" ? "Предупреждения и риски:" : "Warnings:"} -

-
    - {plan.warnings.map((w, idx) => ( -
  • {w}
  • - ))} -
-
- )} - -
-

- {locale === "ru" ? "Таблицы к переносу (" + plan.tables_to_migrate.length + "):" : "Tables to Migrate:"} -

-
- {plan.tables_to_migrate.map((t) => ( - - {t} - - ))} -
-
- -
- - -
-
- )} - - {/* STEP 5: EXECUTING */} - {step === "executing" && ( - - -

- {locale === "ru" ? "Выполняется перенос сервера..." : "Migrating Server Data..."} -

-

- {locale === "ru" - ? "Создание резервной копии, перенос персонажей, адаптация таблиц модулей и проверка ссылочной целостности." - : "Creating backup, remapping character GUIDs, executing module adapters, and verifying integrity."} -

-
- )} - - {/* STEP 6: RESULT */} - {step === "result" && execReport && ( - -
-
-

- {locale === "ru" ? "Перенос сервера успешно завершен!" : "Migration Completed Successfully!"} -

-

- {locale === "ru" - ? `Все выбранные данные перенесены за ${(execReport.duration_ms / 1000).toFixed(1)} с.` - : `All data migrated in ${(execReport.duration_ms / 1000).toFixed(1)}s.`} +

+ {info.classic ? t("migrate.classic.title") : info.modular ? t("migrate.modular.title") : t("migrate.none.title")} +

+

+ {info.classic ? t("migrate.classic.text") : info.modular ? t("migrate.modular.text") : t("migrate.none.text")}

+

{info.path}

-
-
- Аккаунтов -

{execReport.accounts_transferred}

-
-
- Персонажей -

{execReport.characters_transferred}

-
-
- Модулей адаптировано -

{execReport.modules_migrated.length}

-
-
- Таблиц перенесено -

{execReport.tables_migrated.length}

-
-
- - {execReport.backup_path && ( -
-

{locale === "ru" ? "Точка восстановления:" : "Restore Point:"}

-

{execReport.backup_path}

-
+ {(info.classic || info.modular) && ( +
+ + + + +
)} -
- -
+ {info.classic && info.database === "missing" && ( +

+ {t("migrate.db.missingText")} +

+ )} + + {info.classic && info.database !== "missing" && ( + <> +
+

+ {t("migrate.copied")} +

+
    +
  • + {info.schemas.length > 0 + ? t("migrate.copied.schemas", { list: info.schemas.join(", ") }) + : t("migrate.copied.allSchemas")} +
  • +
  • {t("migrate.copied.settings", { n: info.config_files.length })}
  • +
  • {t("migrate.copied.world")}
  • +
  • {t("migrate.copied.ports")}
  • +
  • {t("migrate.copied.modules")}
  • + {info.extra_worlds.length > 0 && ( +
  • {t("migrate.copied.worlds", { list: info.extra_worlds.join(", ") })}
  • + )} +
+
+
+

{t("migrate.notCopied")}

+
    +
  • {t("migrate.notCopied.conf")}
  • +
  • {t("migrate.notCopied.merge", { name: props.currentName })}
  • +
+
+
+

+ {t("migrate.risks")} +

+
    +
  • {t("migrate.risk.oldKept")}
  • +
  • {t("migrate.risk.ports")}
  • + {info.database === "running" ? ( +
  • {t("migrate.risk.running")}
  • + ) : ( +
  • {t("migrate.risk.stopped", { gb: gb(info.database_bytes) })}
  • + )} +
  • {t("migrate.risk.build")}
  • +
+
+
+ +
+ + )}
)}
); } + +function Fact(props: { label: string; value: string }) { + return ( +
+
{props.label}
+
{props.value}
+
+ ); +} diff --git a/src/screens/Shell.tsx b/src/screens/Shell.tsx index 7b511f9..f790313 100644 --- a/src/screens/Shell.tsx +++ b/src/screens/Shell.tsx @@ -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; }) { const t = useT(); @@ -174,7 +176,7 @@ export function Shell(props: { ) : page === "players" ? ( ) : page === "migration" ? ( - setPage("overview")} /> + ) : page === "backups" ? ( ) : (