Phase 4: recovery points, zstd DB dumps, restore via staging schema + atomic table swap, Backups UI

Verified on fixture MySQL: backup, restore (previous schema kept), damaged
backup refused; live install untouched.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ДмитрийandClaude Sonnet 5.5 committed 2026-09-30 07:40:01 +05:00
1 parent 5b5255cca8
commit 217eec338a
13 files changed
+1130 -3

No files matched your search

Generated
+41
View File
@@ -286,6 +286,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
@@ -354,6 +356,7 @@ dependencies = [
"tracing-subscriber",
"uuid",
"windows-sys 0.60.2",
"zstd",
]
[[package]]
@@ -1750,6 +1753,16 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "jobserver"
version = "0.1.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
dependencies = [
"getrandom 0.4.3",
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.106"
@@ -4802,3 +4815,31 @@ name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zstd"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
dependencies = [
"zstd-safe",
]
[[package]]
name = "zstd-safe"
version = "7.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882"
dependencies = [
"zstd-sys",
]
[[package]]
name = "zstd-sys"
version = "2.1.0+zstd.1.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0"
dependencies = [
"cc",
"pkg-config",
]
+3 -1
View File
@@ -9,7 +9,9 @@ Design and audit: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
| 0 Audit + architecture | done |
| 1 Foundation (fsx, registry, manifest, logging, error catalogue) | done |
| 2 Read-only import, process observation, Start/Stop via repack launcher, health diagnosis | done |
| 3 Config UI, 4 Backups, 5 Clean install, 6 Updates/CI, 7 Bots, 8 Client, 9 Friends, 10 Polish | not started |
| 3 Config engine + Bots/Server settings UI + presets + snapshots | done |
| 4 Recovery points, DB dump/restore (staging + atomic swap), Backups UI | done |
| 5 Clean install, 6 Updates/CI, 7 Bots, 8 Client, 9 Friends, 10 Polish | not started |
## Develop
```
+1
View File
@@ -16,6 +16,7 @@ fs4 = "0.13"
tracing = "0.1"
ed25519-dalek = "2"
base64 = "0.22"
zstd = "0.13"
tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "std"] }
[dev-dependencies]
+48
View File
@@ -0,0 +1,48 @@
//! Full backup/restore scenario against a DISPOSABLE fixture (never point this at a real server).
use coa_core::backup::{self, Kind, Trigger};
use std::path::Path;
fn main() {
let root = std::env::args().nth(1).expect("usage: backup_e2e <fixture folder>");
assert!(root.to_lowercase().contains("fixture"), "refusing: not a fixture folder");
let root = Path::new(&root);
let meta = coa_core::registry::metadata_dir_for(root).unwrap();
std::fs::create_dir_all(&meta).unwrap();
let t = std::time::Instant::now();
let point = backup::create(root, &meta, Kind::Quick, Trigger::Manual, Some("e2e".into()), &|s| println!(" .. {s}")).expect("backup");
println!("quick backup {} in {:?}: {:?}", point.id, t.elapsed(), point.components.iter().map(|c| (&c.name, c.bytes, c.tables)).collect::<Vec<_>>());
assert!(backup::verify(&meta, &point.id).unwrap().ok);
// Change live state after the backup.
backup::with_database(root, |db| {
db.query("CREATE TABLE acore_characters.manager_marker (id INT); INSERT INTO acore_characters.manager_marker VALUES (42);")?;
assert_eq!(db.query("SELECT COUNT(*) FROM acore_characters.manager_marker;")?, "1");
Ok(())
})
.unwrap();
let r = backup::restore_database(root, &meta, &point.id, "characters").expect("restore");
println!("restored {} tables; previous database kept as {}; safety backup {}", r.tables_restored, r.previous_schema, r.safety_backup);
backup::with_database(root, |db| {
let live = db.tables("acore_characters")?;
assert!(!live.contains(&"manager_marker".to_string()), "marker must be gone from the restored database");
let old = db.tables(&r.previous_schema)?;
assert!(old.contains(&"manager_marker".to_string()), "the replaced database keeps the marker");
assert_eq!(db.query(&format!("SELECT id FROM {}.manager_marker;", r.previous_schema))?, "42");
assert_eq!(live.len(), r.tables_restored);
println!("OK: live tables {}, old schema tables {}", live.len(), old.len());
Ok(())
})
.unwrap();
// A rejected restore (server "running" is simulated by a damaged backup) must change nothing.
let victim = meta.join("backups").join(&point.id).join("characters.sql.zst");
let mut bytes = std::fs::read(&victim).unwrap();
bytes[10] ^= 0xFF;
std::fs::write(&victim, bytes).unwrap();
assert!(backup::verify(&meta, &point.id).unwrap().problems.len() == 1);
assert!(backup::restore_database(root, &meta, &point.id, "characters").is_err());
println!("damaged backup correctly refused");
}
+1
View File
@@ -5,6 +5,7 @@ fn main() {
let root = args.next().expect("usage: drive <server folder> <start|stop>");
let verb = match args.next().as_deref() {
Some("start") => coa_core::driver::Verb::StartAll,
Some("mysql") => coa_core::driver::Verb::StartMysql,
Some("stop") => coa_core::driver::Verb::StopAll,
_ => panic!("verb must be start or stop"),
};
+459
View File
@@ -0,0 +1,459 @@
//! Recovery points: configuration + database dumps stored under `<install>.manager/backups/<id>/`.
//!
//! Rules enforced here: a recovery point only becomes visible once complete (built in a `.partial` folder),
//! every artefact is checksummed, restore never drops anything (the replaced database is kept under a new name)
//! and restoring a database requires the game servers to be stopped.
use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::db::{self, Account, Db};
use crate::driver::{self, Verb};
use crate::error::{Error, Result};
use crate::fsx;
use crate::layout::read_ports;
use crate::process::{self, ServiceState};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Kind {
/// characters + auth databases, configs, manager metadata.
Quick,
/// Quick + the (large) world database.
Full,
Config,
/// characters + auth databases only.
Database,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Trigger {
Manual,
Automatic,
BeforeUpdate,
BeforeRestore,
BeforeMigration,
BeforeBots,
BeforeRepair,
BeforeDangerousChange,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Component {
/// "characters" | "auth" | "world" | "configs"
pub name: String,
/// File (databases) or folder (configs), relative to the recovery point.
pub path: String,
pub bytes: u64,
pub sha256: Option<String>,
pub tables: Option<usize>,
pub files: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecoveryPoint {
pub schema: u32,
pub id: String,
pub kind: Kind,
pub trigger: Trigger,
pub label: Option<String>,
pub created_at: String,
pub manager_version: String,
pub components: Vec<Component>,
}
fn backups_dir(meta: &Path) -> PathBuf {
meta.join("backups")
}
fn id_ok(id: &str) -> bool {
!id.is_empty() && id.len() < 100 && id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) && !id.contains("..")
}
fn point_dir(meta: &Path, id: &str) -> Result<PathBuf> {
if !id_ok(id) {
return Err(Error::PathRejected(format!("bad backup id {id:?}")));
}
Ok(backups_dir(meta).join(id))
}
pub fn list(meta: &Path) -> Vec<RecoveryPoint> {
let mut out: Vec<RecoveryPoint> = fs::read_dir(backups_dir(meta))
.map(|rd| rd.filter_map(|e| e.ok()).filter_map(|e| fsx::read_json(&e.path().join("backup.json")).ok()).collect())
.unwrap_or_default();
out.sort_by(|a, b| b.id.cmp(&a.id));
out
}
pub fn get(meta: &Path, id: &str) -> Result<RecoveryPoint> {
fsx::read_json(&point_dir(meta, id)?.join("backup.json")).map_err(|_| Error::Invalid(format!("backup {id} was not found")))
}
/// Files that make up "configuration": everything a user or the Manager may have changed, minus secrets.
pub fn config_files(root: &Path) -> Vec<String> {
const MAX: u64 = 8 * 1024 * 1024;
let mut out = Vec::new();
fn walk(root: &Path, dir: &Path, out: &mut Vec<String>) {
let Ok(rd) = fs::read_dir(dir) else { return };
for e in rd.flatten() {
let p = e.path();
let Ok(md) = e.metadata() else { continue };
if md.is_dir() {
walk(root, &p, out);
} else if md.len() <= MAX {
let name = p.file_name().unwrap().to_string_lossy().to_lowercase();
if name.ends_with(".bak") || name.ends_with(".orig") || name.ends_with(".old") || name.contains(".bak-") {
continue;
}
if let Ok(rel) = p.strip_prefix(root) {
out.push(rel.to_string_lossy().replace('\\', "/"));
}
}
}
}
walk(root, &root.join("Core/configs"), &mut out);
if let Ok(rd) = fs::read_dir(root.join("Settings")) {
for e in rd.flatten() {
let n = e.file_name().to_string_lossy().to_string();
// database.json holds generated database passwords; it is launcher state, not configuration.
if e.path().is_file() && (n.contains(".template") || n == "repack.json") {
out.push(format!("Settings/{n}"));
}
}
}
if root.join("RELEASE.json").is_file() {
out.push("RELEASE.json".into());
}
out.sort();
out
}
fn copy_configs(root: &Path, dir: &Path) -> Result<Component> {
let files = config_files(root);
let target = dir.join("files");
let mut bytes = 0;
for rel in &files {
let src = fsx::safe_join(root, rel)?;
let dst = fsx::safe_join(&target, rel)?;
fs::create_dir_all(dst.parent().unwrap())?;
bytes += fs::copy(&src, &dst)?;
}
Ok(Component { name: "configs".into(), path: "files".into(), bytes, sha256: None, tables: None, files: Some(files) })
}
/// Make sure the database is up for `f`. If we had to start it (and nothing else is running), stop it again.
pub fn with_database<T>(root: &Path, f: impl FnOnce(&Db) -> Result<T>) -> Result<T> {
let db = Db::from_repack(root, Account::Admin)?;
let observed = process::observe(root, &read_ports(root));
let mysql_was_up = observed.mysql.state == ServiceState::Running && db.ping();
if !mysql_was_up {
let out = driver::run(root, Verb::StartMysql)?;
if !out.ok {
return Err(Error::Invalid(out.human.map(|h| h.title.to_string()).unwrap_or_else(|| "The database could not be started.".into())));
}
}
let result = f(&db);
if !mysql_was_up {
let now = process::observe(root, &read_ports(root));
if now.world.state == ServiceState::Stopped && now.auth.state == ServiceState::Stopped {
let _ = driver::run(root, Verb::StopAll);
}
}
result
}
fn wanted_databases(kind: Kind) -> &'static [&'static str] {
match kind {
Kind::Quick | Kind::Database => &["characters", "auth"],
Kind::Full => &["characters", "auth", "world"],
Kind::Config => &[],
}
}
/// Create a recovery point. The server may be running: dumps are consistent snapshots (`--single-transaction`).
pub fn create(root: &Path, meta: &Path, kind: Kind, trigger: Trigger, label: Option<String>, progress: &dyn Fn(&str)) -> Result<RecoveryPoint> {
let id = format!("{}-{}", chrono::Utc::now().format("%Y%m%d-%H%M%S"), match trigger {
Trigger::Manual => "manual",
Trigger::Automatic => "auto",
Trigger::BeforeUpdate => "before-update",
Trigger::BeforeRestore => "before-restore",
Trigger::BeforeMigration => "before-migration",
Trigger::BeforeBots => "before-bots",
Trigger::BeforeRepair => "before-repair",
Trigger::BeforeDangerousChange => "before-change",
});
let final_dir = point_dir(meta, &id)?;
let partial = backups_dir(meta).join(format!("{id}.partial"));
fs::create_dir_all(&partial)?;
let build = || -> Result<Vec<Component>> {
let mut components = Vec::new();
for name in wanted_databases(kind) {
let schema = db::schema_of(name)?;
progress(&format!("Backing up {name} database"));
let component = with_database(root, |db| {
let size = db.schema_bytes(schema)?;
fsx::require_space(&partial, size / 2 + 32 * 1024 * 1024)?;
let tables = db.tables(schema)?.len();
let file = format!("{name}.sql.zst");
let (bytes, sha) = db.dump_to(schema, &partial.join(&file))?;
Ok(Component { name: (*name).into(), path: file, bytes, sha256: Some(sha), tables: Some(tables), files: None })
})?;
components.push(component);
}
if kind != Kind::Database {
progress("Saving configuration");
components.push(copy_configs(root, &partial)?);
}
Ok(components)
};
let components = match build() {
Ok(c) => c,
Err(e) => {
let _ = fs::remove_dir_all(&partial); // our own unfinished folder
return Err(e);
}
};
let point = RecoveryPoint {
schema: 1,
id: id.clone(),
kind,
trigger,
label,
created_at: chrono::Utc::now().to_rfc3339(),
manager_version: crate::MANAGER_VERSION.into(),
components,
};
fsx::atomic_write_json(&partial.join("backup.json"), &point)?;
fs::rename(&partial, &final_dir)?;
tracing::info!(%id, ?kind, ?trigger, "recovery point created");
if trigger == Trigger::Automatic {
prune_automatic(meta, 10);
}
Ok(point)
}
/// Keep the newest `keep` automatic recovery points; manual and safety ones are never pruned.
pub fn prune_automatic(meta: &Path, keep: usize) {
let autos: Vec<_> = list(meta).into_iter().filter(|p| p.trigger == Trigger::Automatic).collect();
for p in autos.into_iter().skip(keep) {
if let Ok(dir) = point_dir(meta, &p.id) {
let _ = fs::remove_dir_all(dir);
}
}
}
#[derive(Debug, Serialize)]
pub struct VerifyReport {
pub ok: bool,
pub problems: Vec<String>,
}
pub fn verify(meta: &Path, id: &str) -> Result<VerifyReport> {
let point = get(meta, id)?;
let dir = point_dir(meta, id)?;
let mut problems = Vec::new();
for c in &point.components {
let path = dir.join(&c.path);
match (&c.sha256, &c.files) {
(Some(sha), _) => match fsx::sha256_file(&path) {
Ok(actual) if actual.eq_ignore_ascii_case(sha) => {}
Ok(_) => problems.push(format!("{} is damaged (checksum mismatch)", c.name)),
Err(_) => problems.push(format!("{} is missing", c.name)),
},
(None, Some(files)) => {
for f in files {
if !fsx::safe_join(&path, f).map(|p| p.is_file()).unwrap_or(false) {
problems.push(format!("configuration file {f} is missing"));
}
}
}
_ => {}
}
}
Ok(VerifyReport { ok: problems.is_empty(), problems })
}
/// Delete one recovery point (its own folder only).
pub fn delete(meta: &Path, id: &str) -> Result<()> {
let dir = point_dir(meta, id)?;
get(meta, id)?; // must be a real recovery point
fs::remove_dir_all(dir)?;
Ok(())
}
/// Put configuration files back. Files the backup does not contain are left alone. A safety point is taken first.
pub fn restore_configs(root: &Path, meta: &Path, id: &str) -> Result<RecoveryPoint> {
let point = get(meta, id)?;
let comp = point.components.iter().find(|c| c.name == "configs").ok_or_else(|| Error::Invalid("this backup has no configuration".into()))?;
if !verify(meta, id)?.ok {
return Err(Error::Invalid("This backup is damaged and cannot be restored.".into()));
}
let safety = create(root, meta, Kind::Config, Trigger::BeforeRestore, Some(format!("before restoring {id}")), &|_| {})?;
let src_root = point_dir(meta, id)?.join(&comp.path);
for rel in comp.files.as_deref().unwrap_or_default() {
let dst = fsx::ensure_within(root, &fsx::safe_join(root, rel)?)?;
fsx::atomic_write(&dst, &fs::read(fsx::safe_join(&src_root, rel)?)?)?;
}
Ok(safety)
}
#[derive(Debug, Serialize)]
pub struct DbRestore {
/// The database the restore replaced, kept intact under this name.
pub previous_schema: String,
pub safety_backup: String,
pub tables_restored: usize,
}
/// Restore one database from a recovery point without dropping anything:
/// import into a staging schema, sanity-check it, then swap tables atomically and keep the old schema.
pub fn restore_database(root: &Path, meta: &Path, id: &str, name: &str) -> Result<DbRestore> {
let point = get(meta, id)?;
let comp = point.components.iter().find(|c| c.name == name && c.sha256.is_some()).ok_or_else(|| Error::Invalid(format!("this backup has no {name} database")))?;
if !verify(meta, id)?.ok {
return Err(Error::Invalid("This backup is damaged and cannot be restored.".into()));
}
let now = process::observe(root, &read_ports(root));
if now.world.state != ServiceState::Stopped || now.auth.state != ServiceState::Stopped {
return Err(Error::Invalid("Stop the server before restoring a database.".into()));
}
let live = db::schema_of(name)?;
let expected_tables = comp.tables.unwrap_or(0);
let dump = point_dir(meta, id)?.join(&comp.path);
let stamp = chrono::Utc::now().format("%Y%m%d%H%M%S").to_string();
// 1. Safety copy of the current state, so even a wrong restore is reversible.
let safety = create(root, meta, Kind::Database, Trigger::BeforeRestore, Some(format!("before restoring {name} from {id}")), &|_| {})?;
with_database(root, |db| {
let staging = format!("{live}_restore_{stamp}");
let old = format!("{live}_before_restore_{stamp}");
if db.schema_exists(&staging)? || db.schema_exists(&old)? {
return Err(Error::Invalid("A previous restore left its work schemas behind; try again in a moment.".into()));
}
// 2. Import into staging.
db.query(&format!("CREATE DATABASE `{staging}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"))?;
db.import_from(&staging, &dump)?;
// 3. Sanity checks.
let staged = db.tables(&staging)?;
if staged.is_empty() || (expected_tables > 0 && staged.len() != expected_tables) {
return Err(Error::Invalid(format!("The restored copy looks incomplete ({} of {expected_tables} tables); nothing was changed.", staged.len())));
}
if db.extra_objects(&staging)? > 0 || db.extra_objects(live)? > 0 {
return Err(Error::Invalid("This database contains routines, triggers or views; automatic restore does not support that. Nothing was changed.".into()));
}
// 4. Atomic swap: live tables move to the `before_restore` schema, staged tables move into place.
let current = db.tables(live)?;
db.query(&format!("CREATE DATABASE `{old}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"))?;
let mut renames = Vec::new();
for t in &current {
renames.push(format!("`{live}`.`{t}` TO `{old}`.`{t}`"));
}
for t in &staged {
renames.push(format!("`{staging}`.`{t}` TO `{live}`.`{t}`"));
}
db.query(&format!("RENAME TABLE {};", renames.join(", ")))?;
Ok(DbRestore { previous_schema: old, safety_backup: safety.id.clone(), tables_restored: staged.len() })
})
}
#[cfg(test)]
mod tests {
use super::*;
fn setup() -> (tempfile::TempDir, PathBuf, PathBuf) {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("srv");
let meta = dir.path().join("srv.manager");
crate::layout::testkit::fake_repack(&root);
fs::write(root.join("Settings/database.json"), r#"{"rootPassword":"secret-root","appPassword":"secret-app"}"#).unwrap_or_else(|_| {
fs::create_dir_all(root.join("Settings")).unwrap();
fs::write(root.join("Settings/database.json"), "{}").unwrap();
});
fs::create_dir_all(&meta).unwrap();
(dir, root, meta)
}
#[test]
fn config_backup_excludes_secrets_and_clutter_and_restores_byte_exact() {
let (_d, root, meta) = setup();
fs::create_dir_all(root.join("Settings")).unwrap();
fs::write(root.join("Settings/database.json"), r#"{"rootPassword":"x"}"#).unwrap();
fs::write(root.join("Settings/worldserver.conf.template"), "A = 1\n").unwrap();
fs::write(root.join("Core/configs/modules/old.conf.bak"), "junk").unwrap();
let files = config_files(&root);
assert!(files.contains(&"Core/configs/worldserver.conf".to_string()));
assert!(files.contains(&"Settings/worldserver.conf.template".to_string()));
assert!(!files.iter().any(|f| f.contains("database.json")), "secrets excluded");
assert!(!files.iter().any(|f| f.ends_with(".bak")));
let p = create(&root, &meta, Kind::Config, Trigger::Manual, Some("test".into()), &|_| {}).unwrap();
assert!(verify(&meta, &p.id).unwrap().ok);
assert_eq!(list(&meta).len(), 1);
assert!(!backups_dir(&meta).join(format!("{}.partial", p.id)).exists());
let original = fs::read(root.join("Core/configs/worldserver.conf")).unwrap();
fs::write(root.join("Core/configs/worldserver.conf"), "[worldserver]\nRealmID = 99\n").unwrap();
fs::write(root.join("Core/configs/unrelated_new.conf"), "keep").unwrap();
let safety = restore_configs(&root, &meta, &p.id).unwrap();
assert_eq!(fs::read(root.join("Core/configs/worldserver.conf")).unwrap(), original);
assert!(root.join("Core/configs/unrelated_new.conf").is_file(), "restore never deletes other files");
assert_eq!(safety.trigger, Trigger::BeforeRestore);
assert!(list(&meta).len() >= 2);
}
#[test]
fn verify_detects_tampering_and_restore_refuses_damaged_backup() {
let (_d, root, meta) = setup();
let p = create(&root, &meta, Kind::Config, Trigger::Manual, None, &|_| {}).unwrap();
let victim = point_dir(&meta, &p.id).unwrap().join("files/Core/configs/worldserver.conf");
fs::remove_file(&victim).unwrap();
let v = verify(&meta, &p.id).unwrap();
assert!(!v.ok && v.problems[0].contains("worldserver.conf"));
assert!(restore_configs(&root, &meta, &p.id).is_err());
}
#[test]
fn ids_are_validated_and_delete_only_touches_real_backups() {
let (d, root, meta) = setup();
for bad in ["", "..", "../x", "a/b", "a\\b"] {
assert!(point_dir(&meta, bad).is_err(), "{bad:?}");
}
let victim = d.path().join("srv.manager/important");
fs::create_dir_all(&victim).unwrap();
assert!(delete(&meta, "important").is_err(), "not a recovery point");
assert!(victim.exists());
let p = create(&root, &meta, Kind::Config, Trigger::Manual, None, &|_| {}).unwrap();
delete(&meta, &p.id).unwrap();
assert!(list(&meta).is_empty());
}
#[test]
fn automatic_points_are_pruned_but_manual_and_safety_are_kept() {
let (_d, root, meta) = setup();
let mk = |t: Trigger, id: &str| {
let mut p = create(&root, &meta, Kind::Config, t, None, &|_| {}).unwrap();
// give each a distinct, sortable id by renaming its folder
let from = point_dir(&meta, &p.id).unwrap();
p.id = id.to_string();
fsx::atomic_write_json(&from.join("backup.json"), &p).unwrap();
fs::rename(from, backups_dir(&meta).join(id)).unwrap();
};
mk(Trigger::Manual, "20260101-000000-manual");
mk(Trigger::BeforeRestore, "20260102-000000-before-restore");
for i in 0..5 {
mk(Trigger::Automatic, &format!("2026020{i}-000000-auto"));
}
prune_automatic(&meta, 2);
let left: Vec<String> = list(&meta).into_iter().map(|p| p.id).collect();
assert_eq!(left.iter().filter(|i| i.ends_with("-auto")).count(), 2);
assert!(left.contains(&"20260101-000000-manual".to_string()));
assert!(left.contains(&"20260102-000000-before-restore".to_string()));
assert!(left.contains(&"20260204-000000-auto".to_string()), "newest automatic kept");
}
}
+236
View File
@@ -0,0 +1,236 @@
//! Database access through the MySQL client tools bundled with the repack (`mysql`, `mysqldump`, `mysqladmin`).
//!
//! Passwords travel only in the child's environment (`MYSQL_PWD`), never on a command line or in a log.
use std::fmt;
use std::fs::File;
use std::io::{BufReader, BufWriter, Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use serde::Deserialize;
use crate::error::{Error, ErrorCode, Result};
use crate::fsx;
use crate::layout::{read_ports, Ports};
/// A credential that never prints itself.
#[derive(Clone)]
pub struct Secret(String);
impl Secret {
fn expose(&self) -> &str {
&self.0
}
}
impl fmt::Debug for Secret {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("Secret(***)")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Account {
/// Owner of everything in the packaged MySQL (needed for staging schemas and renames).
Admin,
/// The game servers' own account.
App,
}
#[derive(Debug, Clone)]
pub struct Db {
bin: PathBuf,
port: u16,
user: &'static str,
password: Secret,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Credentials {
root_password: String,
app_password: String,
}
fn no_window(cmd: &mut Command) {
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x0800_0000);
}
#[cfg(not(windows))]
let _ = cmd;
}
/// Schema names of the three game databases.
pub const SCHEMAS: [(&str, &str); 3] = [("characters", "acore_characters"), ("auth", "acore_auth"), ("world", "acore_world")];
pub fn schema_of(kind: &str) -> Result<&'static str> {
SCHEMAS.iter().find(|(k, _)| *k == kind).map(|(_, s)| *s).ok_or_else(|| Error::Invalid(format!("unknown database {kind}")))
}
fn ident_ok(name: &str) -> bool {
!name.is_empty() && name.len() <= 64 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}
impl Db {
pub fn from_repack(root: &Path, account: Account) -> Result<Db> {
let creds: Credentials = fsx::read_json(&root.join("Settings/database.json"))
.map_err(|_| Error::Invalid("The database settings of this server could not be read.".into()))?;
let ports: Ports = read_ports(root);
let bin = root.join("mysql/bin");
if !bin.join("mysqldump.exe").is_file() || !bin.join("mysql.exe").is_file() {
return Err(Error::Invalid("The bundled database tools are missing.".into()));
}
let (user, password) = match account {
Account::Admin => ("root", creds.root_password),
Account::App => ("acore", creds.app_password),
};
Ok(Db { bin, port: ports.mysql, user, password: Secret(password) })
}
fn command(&self, tool: &str) -> Command {
let mut c = Command::new(self.bin.join(tool));
c.env("MYSQL_PWD", self.password.expose())
.arg("--protocol=tcp")
.arg("--host=127.0.0.1")
.arg(format!("--port={}", self.port))
.arg(format!("--user={}", self.user))
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
no_window(&mut c);
c
}
fn fail(&self, stderr: &[u8]) -> Error {
let text = String::from_utf8_lossy(stderr);
let code = if text.contains("2003") || text.contains("Can't connect") { ErrorCode::DatabaseNotRunning } else { ErrorCode::Unknown };
match code {
ErrorCode::DatabaseNotRunning => Error::Invalid("The database is not running. Start the server first.".into()),
_ => Error::Invalid(format!("database command failed: {}", text.trim().lines().last().unwrap_or(""))),
}
}
pub fn ping(&self) -> bool {
let mut c = self.command("mysqladmin.exe");
c.arg("ping").arg("--connect-timeout=3");
c.output().map(|o| o.status.success()).unwrap_or(false)
}
/// Run SQL text (statement(s) supplied on stdin) and return the raw output.
pub fn query(&self, sql: &str) -> Result<String> {
let mut c = self.command("mysql.exe");
c.args(["--batch", "--skip-column-names", "--connect-timeout=5"]);
c.stdin(Stdio::piped());
let mut child = c.spawn()?;
child.stdin.take().expect("piped").write_all(sql.as_bytes())?;
let out = child.wait_with_output()?;
if !out.status.success() {
return Err(self.fail(&out.stderr));
}
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
pub fn schema_exists(&self, name: &str) -> Result<bool> {
if !ident_ok(name) {
return Err(Error::Invalid("bad schema name".into()));
}
Ok(self.query(&format!("SELECT COUNT(*) FROM information_schema.schemata WHERE schema_name='{name}';"))? == "1")
}
pub fn tables(&self, schema: &str) -> Result<Vec<String>> {
if !ident_ok(schema) {
return Err(Error::Invalid("bad schema name".into()));
}
let out = self.query(&format!("SELECT table_name FROM information_schema.tables WHERE table_schema='{schema}' AND table_type='BASE TABLE' ORDER BY table_name;"))?;
Ok(out.lines().map(str::to_string).filter(|s| !s.is_empty()).collect())
}
/// Bytes of data + index the schema occupies (used to estimate backup size).
pub fn schema_bytes(&self, schema: &str) -> Result<u64> {
if !ident_ok(schema) {
return Err(Error::Invalid("bad schema name".into()));
}
let out = self.query(&format!("SELECT COALESCE(SUM(data_length+index_length),0) FROM information_schema.tables WHERE table_schema='{schema}';"))?;
out.trim().parse().map_err(|_| Error::Invalid("unexpected size reply".into()))
}
/// Stored routines / triggers / views make a table-level swap unsafe; count them.
pub fn extra_objects(&self, schema: &str) -> Result<u64> {
if !ident_ok(schema) {
return Err(Error::Invalid("bad schema name".into()));
}
let sql = format!(
"SELECT (SELECT COUNT(*) FROM information_schema.routines WHERE routine_schema='{schema}') + (SELECT COUNT(*) FROM information_schema.triggers WHERE trigger_schema='{schema}') + (SELECT COUNT(*) FROM information_schema.views WHERE table_schema='{schema}');"
);
self.query(&sql)?.trim().parse().map_err(|_| Error::Invalid("unexpected reply".into()))
}
/// Consistent dump of one schema, zstd-compressed to `out`. Returns (compressed bytes, sha256 of the file).
pub fn dump_to(&self, schema: &str, out: &Path) -> Result<(u64, String)> {
if !ident_ok(schema) {
return Err(Error::Invalid("bad schema name".into()));
}
let mut c = self.command("mysqldump.exe");
c.args(["--single-transaction", "--quick", "--routines", "--triggers", "--default-character-set=utf8mb4", "--no-tablespaces", "--skip-comments", "--hex-blob"]);
c.arg(schema);
let mut child = c.spawn()?;
let mut stdout = child.stdout.take().expect("piped");
let mut stderr = child.stderr.take().expect("piped");
let err_thread = std::thread::spawn(move || {
let mut b = Vec::new();
let _ = stderr.read_to_end(&mut b);
b
});
let file = File::create(out)?;
let mut enc = zstd::Encoder::new(BufWriter::new(file), 3)?;
let copy = std::io::copy(&mut stdout, &mut enc);
let finish = enc.finish().and_then(|mut w| w.flush());
let status = child.wait()?;
let stderr_bytes = err_thread.join().unwrap_or_default();
if !status.success() {
let _ = std::fs::remove_file(out);
return Err(self.fail(&stderr_bytes));
}
copy?;
finish?;
let size = std::fs::metadata(out)?.len();
Ok((size, fsx::sha256_file(out)?))
}
/// Import a dump produced by `dump_to` into `schema` (which must already exist and be empty).
pub fn import_from(&self, schema: &str, dump: &Path) -> Result<()> {
if !ident_ok(schema) {
return Err(Error::Invalid("bad schema name".into()));
}
let mut c = self.command("mysql.exe");
c.args(["--default-character-set=utf8mb4", "--max-allowed-packet=128M"]);
c.arg(schema);
c.stdin(Stdio::piped());
let mut child = c.spawn()?;
let mut stdin = child.stdin.take().expect("piped");
let mut stderr = child.stderr.take().expect("piped");
let err_thread = std::thread::spawn(move || {
let mut b = Vec::new();
let _ = stderr.read_to_end(&mut b);
b
});
let mut dec = zstd::Decoder::new(BufReader::new(File::open(dump)?))?;
let copied = std::io::copy(&mut dec, &mut stdin);
drop(stdin);
let status = child.wait()?;
let stderr_bytes = err_thread.join().unwrap_or_default();
if !status.success() {
return Err(self.fail(&stderr_bytes));
}
copied?;
Ok(())
}
}
pub fn valid_identifier(name: &str) -> bool {
ident_ok(name)
}
+3
View File
@@ -16,6 +16,7 @@ use crate::fsx;
#[serde(rename_all = "kebab-case")]
pub enum Verb {
StartAll,
StartMysql,
StopAll,
}
@@ -23,6 +24,7 @@ impl Verb {
fn arg(self) -> &'static str {
match self {
Verb::StartAll => "start-all",
Verb::StartMysql => "start-mysql",
Verb::StopAll => "stop-all",
}
}
@@ -30,6 +32,7 @@ impl Verb {
fn timeout(self) -> Duration {
match self {
Verb::StartAll => Duration::from_secs(420),
Verb::StartMysql => Duration::from_secs(120),
Verb::StopAll => Duration::from_secs(240),
}
}
+2
View File
@@ -1,4 +1,6 @@
pub mod backup;
pub mod config;
pub mod db;
pub mod driver;
pub mod error;
pub mod fsx;
+84 -2
View File
@@ -5,6 +5,7 @@ use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::Mutex;
use coa_core::backup::{self, Kind, RecoveryPoint, Trigger, VerifyReport};
use coa_core::config::{self, Scope, SettingsView};
use coa_core::driver::{self, DriverOutcome, Verb};
use coa_core::error::UiError;
@@ -15,7 +16,7 @@ use coa_core::{Error, Result};
use serde::Serialize;
use serde_json::Value;
use std::collections::BTreeMap;
use tauri::State;
use tauri::{AppHandle, Emitter, State};
struct AppState {
registry: Registry,
@@ -236,6 +237,81 @@ async fn restore_config_snapshot(state: State<'_, AppState>, id: String, snapsho
blocking(move || config::restore_snapshot(&meta_dir(&root)?, &snapshot)).await
}
/// Marks the installation busy for the duration of a long operation, and always clears it.
struct BusyGuard<'a> {
state: &'a AppState,
id: String,
}
impl<'a> BusyGuard<'a> {
fn acquire(state: &'a AppState, id: &str) -> Result<Self> {
let mut b = state.busy.lock().map_err(|_| Error::Invalid("state poisoned".into()))?;
if !b.insert(id.to_string()) {
return Err(Error::Invalid("Another action is still in progress for this server.".into()));
}
Ok(Self { state, id: id.to_string() })
}
}
impl Drop for BusyGuard<'_> {
fn drop(&mut self) {
if let Ok(mut b) = self.state.busy.lock() {
b.remove(&self.id);
}
}
}
#[tauri::command]
fn list_backups(state: State<'_, AppState>, id: String) -> std::result::Result<Vec<RecoveryPoint>, UiError> {
let root = path_of(&state, &id)?;
Ok(backup::list(&meta_dir(&root)?))
}
#[tauri::command]
async fn create_backup(
app: AppHandle,
state: State<'_, AppState>,
id: String,
kind: Kind,
label: Option<String>,
) -> std::result::Result<RecoveryPoint, UiError> {
let root = path_of(&state, &id)?;
let _guard = BusyGuard::acquire(&state, &id)?;
blocking(move || {
let meta = meta_dir(&root)?;
backup::create(&root, &meta, kind, Trigger::Manual, label, &|step| {
let _ = app.emit("backup-progress", step);
})
})
.await
}
#[tauri::command]
async fn verify_backup(state: State<'_, AppState>, id: String, backup_id: String) -> std::result::Result<VerifyReport, UiError> {
let root = path_of(&state, &id)?;
blocking(move || backup::verify(&meta_dir(&root)?, &backup_id)).await
}
#[tauri::command]
fn delete_backup(state: State<'_, AppState>, id: String, backup_id: String) -> std::result::Result<(), UiError> {
let root = path_of(&state, &id)?;
Ok(backup::delete(&meta_dir(&root)?, &backup_id)?)
}
#[tauri::command]
async fn restore_backup_configs(state: State<'_, AppState>, id: String, backup_id: String) -> std::result::Result<RecoveryPoint, UiError> {
let root = path_of(&state, &id)?;
let _guard = BusyGuard::acquire(&state, &id)?;
blocking(move || backup::restore_configs(&root, &meta_dir(&root)?, &backup_id)).await
}
#[tauri::command]
async fn restore_backup_database(state: State<'_, AppState>, id: String, backup_id: String, database: String) -> std::result::Result<backup::DbRestore, UiError> {
let root = path_of(&state, &id)?;
let _guard = BusyGuard::acquire(&state, &id)?;
blocking(move || backup::restore_database(&root, &meta_dir(&root)?, &backup_id, &database)).await
}
pub fn run() {
let dir = data_dir();
let _ = coa_core::logging::init(&dir.join("logs").join("manager.log"));
@@ -256,7 +332,13 @@ pub fn run() {
list_presets,
preview_preset,
list_config_snapshots,
restore_config_snapshot
restore_config_snapshot,
list_backups,
create_backup,
verify_backup,
delete_backup,
restore_backup_configs,
restore_backup_database
])
.run(tauri::generate_context!())
.expect("error while running CoA Server Manager");
+24
View File
@@ -143,6 +143,24 @@ export interface SaveReport {
snapshot: string | null;
}
export type BackupKind = "quick" | "full" | "config" | "database";
export interface RecoveryPoint {
schema: number;
id: string;
kind: BackupKind;
trigger: string;
label: string | null;
created_at: string;
components: { name: string; path: string; bytes: number; tables: number | null; files: string[] | null }[];
}
export interface DbRestore {
previous_schema: string;
safety_backup: string;
tables_restored: number;
}
export const api = {
defaultInstallDir: () => invoke<string>("default_install_dir"),
scan: (path: string) => invoke<ScanReport>("scan_server", { path }),
@@ -155,6 +173,12 @@ export const api = {
settings: (id: string, scope: Scope) => invoke<SettingsView>("get_settings", { id, scope }),
save: (id: string, scope: Scope, changes: Record<string, JsonValue>) => invoke<SaveReport>("save_settings", { id, scope, changes }),
presets: (scope: Scope) => invoke<PresetInfo[]>("list_presets", { scope }),
backups: (id: string) => invoke<RecoveryPoint[]>("list_backups", { id }),
createBackup: (id: string, kind: BackupKind, label?: string) => invoke<RecoveryPoint>("create_backup", { id, kind, label: label ?? null }),
verifyBackup: (id: string, backupId: string) => invoke<{ ok: boolean; problems: string[] }>("verify_backup", { id, backupId }),
deleteBackup: (id: string, backupId: string) => invoke<void>("delete_backup", { id, backupId }),
restoreConfigs: (id: string, backupId: string) => invoke<RecoveryPoint>("restore_backup_configs", { id, backupId }),
restoreDatabase: (id: string, backupId: string, database: string) => invoke<DbRestore>("restore_backup_database", { id, backupId, database }),
previewPreset: (id: string, scope: Scope, preset: string) => invoke<PresetPreview>("preview_preset", { id, scope, preset }),
};
+225
View File
@@ -0,0 +1,225 @@
import { useCallback, useEffect, useState } from "react";
import { listen } from "@tauri-apps/api/event";
import { Loader2, ShieldCheck } from "lucide-react";
import { api, asUiError, type BackupKind, type RecoveryPoint, type UiError } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
const TRIGGER: Record<string, string> = {
manual: "Manual backup",
automatic: "Automatic backup",
"before-update": "Before update",
"before-restore": "Safety copy before a restore",
"before-migration": "Before database update",
"before-bots": "Before installing companions",
"before-repair": "Before repair",
"before-dangerous-change": "Before a risky change",
};
const KIND: Record<BackupKind, string> = {
quick: "Characters, accounts and settings",
full: "Everything, including the world database",
config: "Settings only",
database: "Characters and accounts",
};
const DB_LABEL: Record<string, string> = { characters: "Characters", auth: "Accounts", world: "World data" };
function size(bytes: number): string {
if (bytes >= 1 << 30) return `${(bytes / (1 << 30)).toFixed(1)} GB`;
if (bytes >= 1 << 20) return `${(bytes / (1 << 20)).toFixed(1)} MB`;
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
}
function when(iso: string): string {
const d = new Date(iso);
const today = new Date();
const time = d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
if (d.toDateString() === today.toDateString()) return `Today ${time}`;
const y = new Date(today.getTime() - 86400000);
if (d.toDateString() === y.toDateString()) return `Yesterday ${time}`;
return `${d.toLocaleDateString([], { day: "numeric", month: "short", year: "numeric" })} ${time}`;
}
export function BackupsPage({ serverId }: { serverId: string }) {
const [points, setPoints] = useState<RecoveryPoint[] | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [step, setStep] = useState<string | null>(null);
const [error, setError] = useState<UiError | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [restoring, setRestoring] = useState<RecoveryPoint | null>(null);
const [running, setRunning] = useState(false);
const load = useCallback(async () => {
setPoints(await api.backups(serverId));
const s = await api.status(serverId);
setRunning(s.observed.world.state !== "stopped" || s.observed.auth.state !== "stopped");
}, [serverId]);
useEffect(() => {
void load().catch((e) => setError(asUiError(e)));
const un = listen<string>("backup-progress", (e) => setStep(e.payload));
return () => {
void un.then((f) => f());
};
}, [load]);
async function run(label: string, fn: () => Promise<string | void>) {
setBusy(label);
setError(null);
setNotice(null);
setStep(null);
try {
const msg = await fn();
if (msg) setNotice(msg);
await load();
} catch (e) {
setError(asUiError(e));
} finally {
setBusy(null);
setStep(null);
}
}
const create = (kind: BackupKind) =>
run("create", async () => {
await api.createBackup(serverId, kind);
return "Backup created.";
});
const last = points?.[0];
return (
<div className="max-w-3xl">
<h1 className="text-2xl font-semibold">Backups</h1>
<p className="mt-1 text-muted">Is my data safe?</p>
<Card className="mt-6 p-6">
<div className="flex items-center gap-3">
<ShieldCheck className={last ? "h-6 w-6 text-ok" : "h-6 w-6 text-muted"} aria-hidden />
<div>
<p className="font-medium">{last ? `Last backup: ${when(last.created_at)}` : "No backups yet"}</p>
<p className="text-sm text-muted">Backups are kept next to your server and never overwrite your live data.</p>
</div>
</div>
<div className="mt-5 flex flex-wrap items-center gap-3">
<Button variant="primary" disabled={!!busy} onClick={() => void create("quick")}>
{busy === "create" && <Loader2 className="h-4 w-4 animate-spin" aria-hidden />}
Back up now
</Button>
<Button disabled={!!busy} onClick={() => void create("full")} title="Also saves the large world database">
Full backup
</Button>
<Button variant="ghost" disabled={!!busy} onClick={() => void create("config")}>
Settings only
</Button>
{busy === "create" && <span className="text-sm text-muted" role="status">{step ?? "Working…"}</span>}
</div>
</Card>
{notice && <p className="mt-3 text-sm text-ok" role="status">{notice}</p>}
{error && (
<Card className="mt-4 border-bad/40 p-4" role="alert">
<p className="font-medium text-bad">{error.human.code === "unknown" ? "That did not work" : error.human.title}</p>
<p className="mt-1 text-sm text-muted">{error.human.code === "unknown" ? error.technical : error.human.message}</p>
</Card>
)}
{restoring && (
<Card className="mt-4 border-gold/40 p-5">
<p className="font-medium">Restore from {TRIGGER[restoring.trigger] ?? restoring.trigger}, {when(restoring.created_at)}</p>
<p className="mt-1 text-sm text-muted">
Nothing is deleted. Your current data is saved first, and a replaced database is kept alongside under a new name.
</p>
{running && <p className="mt-2 text-sm text-warn">Stop the server first — databases can only be restored while it is stopped.</p>}
<div className="mt-4 flex flex-wrap gap-2">
{restoring.components.map((c) =>
c.name === "configs" ? (
<Button
key={c.name}
size="sm"
disabled={!!busy}
onClick={() =>
void run("restore", async () => {
await api.restoreConfigs(serverId, restoring.id);
setRestoring(null);
return "Settings restored. A safety copy of the previous settings was saved.";
})
}
>
Restore settings
</Button>
) : (
<Button
key={c.name}
size="sm"
disabled={!!busy || running}
onClick={() => {
if (!window.confirm(`Restore the ${DB_LABEL[c.name] ?? c.name} database from this backup? Progress made since ${when(restoring.created_at)} will be set aside (not deleted).`)) return;
void run("restore", async () => {
const r = await api.restoreDatabase(serverId, restoring.id, c.name);
setRestoring(null);
return `Restored ${r.tables_restored} tables. The previous data is kept as ${r.previous_schema}.`;
});
}}
>
Restore {DB_LABEL[c.name]?.toLowerCase() ?? c.name}
</Button>
),
)}
<Button size="sm" variant="ghost" onClick={() => setRestoring(null)}>
Cancel
</Button>
</div>
</Card>
)}
<div className="mt-6 divide-y divide-line rounded-card border border-line bg-card/60">
{points === null && <p className="p-4 text-muted">Loading…</p>}
{points?.length === 0 && <p className="p-4 text-muted">Your backups will appear here.</p>}
{points?.map((p) => (
<div key={p.id} className="flex items-center justify-between gap-4 p-4">
<div>
<p className="font-medium">{p.label && p.trigger === "manual" && p.label !== "e2e" ? p.label : (TRIGGER[p.trigger] ?? p.trigger)}</p>
<p className="text-sm text-muted">
{when(p.created_at)} · {KIND[p.kind]} · {size(p.components.reduce((a, c) => a + c.bytes, 0))}
</p>
</div>
<div className="flex shrink-0 gap-1">
<Button size="sm" disabled={!!busy} onClick={() => setRestoring(p)}>
Restore
</Button>
<Button
size="sm"
variant="ghost"
disabled={!!busy}
onClick={() =>
void run("verify", async () => {
const v = await api.verifyBackup(serverId, p.id);
return v.ok ? "This backup is intact." : `Problem found: ${v.problems.join("; ")}`;
})
}
>
Verify
</Button>
<Button
size="sm"
variant="ghost"
disabled={!!busy}
onClick={() => {
if (window.confirm("Delete this backup? Only this backup is removed; your server is not affected.")) {
void run("delete", async () => {
await api.deleteBackup(serverId, p.id);
});
}
}}
>
Delete
</Button>
</div>
</div>
))}
</div>
</div>
);
}
+3
View File
@@ -5,6 +5,7 @@ import { cn } from "@/lib/utils";
import { Overview } from "@/screens/Overview";
import { Placeholder } from "@/screens/Placeholder";
import { SettingsPage } from "@/screens/SettingsPage";
import { BackupsPage } from "@/screens/BackupsPage";
import { Button } from "@/components/ui/button";
type Page = "overview" | "bots" | "server" | "players" | "friends" | "backups" | "console" | "settings";
@@ -79,6 +80,8 @@ export function Shell(props: {
<Overview key={server.id} server={server} onForget={() => props.onForget(server.id)} />
) : page === "bots" || page === "server" ? (
<SettingsPage key={`${server.id}-${page}`} serverId={server.id} scope={page} title={current.label} question={current.question} />
) : page === "backups" ? (
<BackupsPage key={server.id} serverId={server.id} />
) : (
<Placeholder title={current.label} question={current.question} />
)}