From be68eb4341e90d43d478890703c7a2574d5c6083 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Thu, 8 Oct 2026 18:58:41 +0500 Subject: [PATCH] Add unsaved changes navigation guard and server auto-restart with crash analyzer - Add UnsavedChangesGuard modal (Save/Discard/Cancel) when navigating between tabs - Wire guard into ModulesPage and SettingsPage with floating action bar - Implement crash parser and explanation engine in coa-core - Expose list_crashes Tauri command and CrashItem API - Add auto-restart on unexpected server crash with crash loop protection - Add crash counter badge and CrashHistoryModal with human-readable explanations and full crashlogs - Support all 6 locales (en, ru, de, fr, es, zh) --- TODO.md | 29 ++++ crates/coa-core/src/crashes.rs | 249 ++++++++++++++++++++++++++++ crates/coa-core/src/lib.rs | 1 + src-tauri/src/lib.rs | 7 + src/dev/mock.ts | 1 + src/i18n/locales/de.ts | 15 ++ src/i18n/locales/en.ts | 15 ++ src/i18n/locales/es.ts | 15 ++ src/i18n/locales/fr.ts | 15 ++ src/i18n/locales/ru.ts | 15 ++ src/i18n/locales/zh.ts | 15 ++ src/lib/api.ts | 16 ++ src/lib/autoRestartPrefs.ts | 32 ++++ src/lib/unsavedGuard.ts | 5 + src/screens/AutoRestartSettings.tsx | 24 +++ src/screens/CrashHistoryModal.tsx | 198 ++++++++++++++++++++++ src/screens/ModulesPage.tsx | 52 +++++- src/screens/Overview.tsx | 214 ++++++++++++++++++++---- src/screens/SettingsHome.tsx | 2 + src/screens/SettingsPage.tsx | 36 +++- src/screens/Shell.tsx | 80 ++++++++- 21 files changed, 985 insertions(+), 51 deletions(-) create mode 100644 TODO.md create mode 100644 crates/coa-core/src/crashes.rs create mode 100644 src/lib/autoRestartPrefs.ts create mode 100644 src/lib/unsavedGuard.ts create mode 100644 src/screens/AutoRestartSettings.tsx create mode 100644 src/screens/CrashHistoryModal.tsx diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..3b6df58 --- /dev/null +++ b/TODO.md @@ -0,0 +1,29 @@ +# TODO + +## UI / UX +- [x] **Предупреждение о несохранённых изменениях при переходе между вкладками (Unsaved Changes Guard / Modal)**: + - Если у пользователя есть несохранённые изменения в настройках (`ModulesPage`, `SettingsPage` — сервер, боты и др.) и он переходит на другую вкладку (в боковом меню `Shell.tsx` или кнопками навигации), показывать pop-up диалог подтверждения. + - Опции в диалоге: + - **Сохранить (Save)** — сохранить внесённые изменения и перейти на выбранную страницу. + - **Сбросить (Discard)** — отменить несохранённые изменения и перейти на выбранную страницу. + - **Отмена (Cancel)** — остаться на текущей странице с сохранением введённых данных. + - *Контекст:* Предотвратит случайную потерю настроек, когда пользователь меняет параметры модуля/сервера и сразу уходит на вкладку Overview для запуска сервера (см. [Issue #45](https://github.com/Corfirean/coa-server-manager/issues/45)). + +## Сервер и стабильность +- [x] **Автоподнятие сервера при падении (Auto-restart on crash) со счётчиком и расшифровкой крашей**: + - **Настройка:** Чекбокс «Автоматически перезапускать сервер при падении» в настройках сервера / менеджера. + - **Поведение автоподнятия:** + - Перезапускать процесс сервера только если падение произошло внезапно (процесс завершился с ненулевым exit code, крашем или без команды от менеджера). + - Если пользователь нажал кнопку «Стоп» (Stop Server), сервер останавливается полностью, автоподнятие не срабатывает. + - Защита от бесконечного цикла крашей (Crash Loop Protection): если за короткий интервал (например, 2-3 минуты) сервер падает более 3-5 раз, автоподнятие временно прекращается, а в UI выводится критическое оповещение. + - **Счётчик падений в интерфейсе:** + - Рядом со статусом сервера (или в шапке/панели состояния) показывать бейдж со счётчиком крашей с момента старта (например: `Крашей: 2`). + - При клике на счётчик открывать окно / модалку со списком последних падений с точным временем. + - **Расшифровка причин краша (Crash Explanation Engine):** + - *Как парсить и определять причину:* + 1. Читать свежий текстовый файл из `Core/Crashes/_worldserver.exe_[date].txt`. + 2. Смотреть `Exception code` (например, `C0000005` — нарушение доступа к памяти/access violation, `C0000420` — сработавший assert ядра, `C00000FD` — переполнение стека, `E06D7363` — исключение C++ / std::bad_alloc). + 3. При `C0000420` парсить блок `ASSERTION FAILED` (`Location`, `Function`, `Condition`). + 4. Анализировать последние строки `Core/Logs/Server.log` и `Errors.log` на критические маркеры (потеря соединения с MySQL, нехватка RAM/OOM, ошибка бинда портов). + 5. Проверять по встроенному словарю известных сигнатур (паттерны частых крашей с понятным описанием, например: «Сбой удаления объекта из сетки карты», «Ошибка запроса к базе данных», «Нехватка оперативной памяти»). + - *Fallback:* Если сигнатура не опознана в словаре известных проблем, показывать краткую техническую выжимку (код исключения + сработавший ассерт или вершина Call Stack) и кнопку разворота полного текста крашлога / копирования. diff --git a/crates/coa-core/src/crashes.rs b/crates/coa-core/src/crashes.rs new file mode 100644 index 0000000..2a94e73 --- /dev/null +++ b/crates/coa-core/src/crashes.rs @@ -0,0 +1,249 @@ +//! Crash log discovery and human-friendly explanation for worldserver crashes. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; +use serde::{Deserialize, Serialize}; +use crate::error::Result; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct CrashItem { + pub id: String, + pub timestamp_ms: u64, + pub date_str: String, + pub title: String, + pub explanation: String, + pub category: String, + pub exception_code: Option, + pub location: Option, + pub function: Option, + pub condition: Option, + pub preview: String, + pub full_log: String, +} + +/// Lists recent crash reports from `Core/Crashes`, sorted newest first. +pub fn list(root: &Path, limit: usize) -> Result> { + let dir = root.join("Core/Crashes"); + let mut files: Vec<(PathBuf, SystemTime)> = Vec::new(); + if let Ok(entries) = fs::read_dir(&dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() && path.extension().and_then(|e| e.to_str()) == Some("txt") { + let mtime = entry.metadata().and_then(|m| m.modified()).unwrap_or(SystemTime::UNIX_EPOCH); + files.push((path, mtime)); + } + } + } + files.sort_by_key(|(_, t)| std::cmp::Reverse(*t)); + files.truncate(limit.max(1)); + + let mut items = Vec::new(); + for (path, mtime) in files { + if let Ok(content) = fs::read_to_string(&path) { + let item = parse_crash_content(&path, mtime, &content); + items.push(item); + } + } + Ok(items) +} + +/// Parses crash report content and generates human-friendly titles and explanations. +pub fn parse_crash_content(path: &Path, mtime: SystemTime, content: &str) -> CrashItem { + let filename = path.file_name().unwrap_or_default().to_string_lossy().into_owned(); + let timestamp_ms = mtime.duration_since(SystemTime::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0); + + let mut date_str = String::new(); + let mut exception_code = None; + let mut location = None; + let mut function = None; + let mut condition = None; + + let lines: Vec<&str> = content.lines().collect(); + + let mut in_assertion = false; + for line in &lines { + let trimmed = line.trim(); + if trimmed.starts_with("Date ") && date_str.is_empty() { + date_str = trimmed.to_string(); + } else if trimmed.starts_with("Exception code:") { + let parts: Vec<&str> = trimmed.split_whitespace().collect(); + if parts.len() >= 3 { + exception_code = Some(parts[2].to_string()); + } + } else if trimmed.contains(">> ASSERTION FAILED") { + in_assertion = true; + } else if in_assertion { + if trimmed.starts_with("# Location:") { + let loc = trimmed.trim_start_matches("# Location:").trim(); + let clean = loc.rsplit(['\\', '/']).take(2).collect::>(); + location = Some(if clean.len() == 2 { format!("{}/{}", clean[1], clean[0]) } else { loc.to_string() }); + } else if trimmed.starts_with("# Function:") { + function = Some(trimmed.trim_start_matches("# Function:").trim().to_string()); + } else if trimmed.starts_with("# Condition:") { + condition = Some(trimmed.trim_start_matches("# Condition:").trim().to_string()); + } else if trimmed.starts_with("#---") && condition.is_some() { + in_assertion = false; + } + } + } + + if date_str.is_empty() { + if let Some(pos) = filename.find("_[") { + if let Some(end) = filename[pos..].find(']') { + date_str = filename[pos + 2..pos + end].replace('_', " "); + } + } + } + + let (category, title, explanation) = explain(exception_code.as_deref(), function.as_deref(), condition.as_deref(), content); + + let preview_lines: Vec<&str> = lines.iter().take(25).copied().collect(); + let preview = preview_lines.join("\n"); + + CrashItem { + id: filename, + timestamp_ms, + date_str, + title, + explanation, + category, + exception_code, + location, + function, + condition, + preview, + full_log: content.to_string(), + } +} + +fn explain(code: Option<&str>, func: Option<&str>, cond: Option<&str>, full: &str) -> (String, String, String) { + let lower_full = full.to_lowercase(); + let lower_func = func.map(|f| f.to_lowercase()).unwrap_or_default(); + let lower_cond = cond.map(|c| c.to_lowercase()).unwrap_or_default(); + + if let Some(c) = code { + if c.eq_ignore_ascii_case("C0000420") { + if lower_func.contains("removefromgrid") || lower_cond.contains("isingrid") { + return ( + "assertion".into(), + "Сбой удаления сущности из сетки карты (Grid / Despawn)".into(), + "Сервер попытался выгрузить объект (игрока, NPC или бота), который уже не находился в сетке карты. Обычно происходит при резком разрыве соединения, телепортации или одновременном деспавне нескольких сущностей.".into(), + ); + } + if lower_func.contains("map::") || lower_func.contains("grid::") { + return ( + "assertion".into(), + "Ошибка обновления сетки игрового мира (Map Grid)".into(), + "Сбой при загрузке или обновлении клеток локации (Grid/Cell). Может быть вызван перемещением сущностей через границы чанков.".into(), + ); + } + if lower_func.contains("playerbot") || lower_func.contains("aiplayerbot") { + return ( + "assertion".into(), + "Ошибка в модуле ботов (Playerbots)".into(), + "Внутренний сбой в логике ИИ ботов (экипировка, заклинание или поиск пути).".into(), + ); + } + let cond_label = cond.unwrap_or("Assert condition failed"); + return ( + "assertion".into(), + format!("Сработала внутренняя проверка ядра ({cond_label})"), + format!("Сервер обнаружил нарушение целостности данных в функции {}. Выполнение было аварийно остановлено.", func.unwrap_or("неизвестно")), + ); + } else if c.eq_ignore_ascii_case("C0000005") { + return ( + "access_violation".into(), + "Нарушение доступа к памяти (Access Violation)".into(), + "Попытка чтения или записи по некорректному адресу памяти (разыменование null-указателя или освобождённого объекта).".into(), + ); + } else if c.eq_ignore_ascii_case("C00000FD") { + return ( + "stack_overflow".into(), + "Переполнение стека (Stack Overflow)".into(), + "Исчерпание стека вызовов, вызванное бесконечной рекурсией или слишком глубоким стеком вызовов функций.".into(), + ); + } else if c.eq_ignore_ascii_case("E06D7363") { + if lower_full.contains("bad_alloc") { + return ( + "out_of_memory".into(), + "Нехватка оперативной памяти (Out of Memory)".into(), + "Серверу не удалось выделить оперативную память (std::bad_alloc). Проверьте объем доступной RAM.".into(), + ); + } + return ( + "exception".into(), + "Необработанное исключение C++ (C++ Exception)".into(), + "Произошло критическое исключение C++, которое не было перехвачено ядром сервера.".into(), + ); + } + } + + if lower_full.contains("mysql server has gone away") || lower_full.contains("connection lost") { + return ( + "database".into(), + "Потеряна связь с базой данных (MySQL)".into(), + "Связь сервера с СУБД MySQL была прервана или служба базы данных упала.".into(), + ); + } + + if lower_full.contains("address already in use") || lower_full.contains("bind failed") { + return ( + "network".into(), + "Сетевой порт уже занят".into(), + "Порт мира, авторизации или базы данных уже занят другим приложением.".into(), + ); + } + + ( + "generic".into(), + "Непредвиденный сбой процесса сервера".into(), + "Процесс worldserver аварийно завершился. Детали и стек вызовов доступны в полном тексте отчёта.".into(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_assertion_report() { + let report = r#"Revision: AzerothCore rev. df5bcdf26092 2026-10-02 22:08:42 +0500 (coa-bots branch) (Win64, RelWithDebInfo, Static) +Date 3:10:2026. Time 20:20 +//===================================================== +Exception code: C0000420 Ein Assertionsfehler ist aufgetreten. + +Assertion message: #----------------------------------------------------------------------# + +>> ASSERTION FAILED + +# Location: D:\a\coa-server-build\src\server\game\Entities\Object\Object.h:376 +# Function: GridObject::RemoveFromGrid +# Condition: IsInGrid() + +#----------------------------------------------------------------------# + +Fault address: 00007FF7ECD3645E +"#; + let item = parse_crash_content(Path::new("dummy_worldserver.exe_[3-10_20-20-51].txt"), SystemTime::UNIX_EPOCH, report); + println!("ITEM: {item:#?}"); + assert_eq!(item.exception_code.as_deref(), Some("C0000420")); + assert_eq!(item.category, "assertion"); + assert_eq!(item.condition.as_deref(), Some("IsInGrid()")); + assert_eq!(item.function.as_deref(), Some("GridObject::RemoveFromGrid")); + assert!(item.title.contains("Grid")); + assert!(item.explanation.contains("сетке карты")); + } + + #[test] + fn parses_access_violation() { + let report = r#"Date 4:10:2026. Time 12:00 +Exception code: C0000005 ACCESS_VIOLATION +Fault address: 00007FF7ECD3645E +"#; + let item = parse_crash_content(Path::new("crash.txt"), SystemTime::UNIX_EPOCH, report); + assert_eq!(item.exception_code.as_deref(), Some("C0000005")); + assert_eq!(item.category, "access_violation"); + assert!(item.title.contains("Access Violation")); + } +} diff --git a/crates/coa-core/src/lib.rs b/crates/coa-core/src/lib.rs index b105692..46c35e5 100644 --- a/crates/coa-core/src/lib.rs +++ b/crates/coa-core/src/lib.rs @@ -7,6 +7,7 @@ pub mod remote_client; pub mod clientdl; pub mod config; pub mod console; +pub mod crashes; pub mod dashboard; pub mod db; pub mod diag; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fa60d83..f5ae476 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1543,6 +1543,12 @@ async fn export_diagnostics(state: State<'_, AppState>, id: String) -> std::resu .await } +#[tauri::command] +async fn list_crashes(state: State<'_, AppState>, id: String, limit: Option) -> std::result::Result, UiError> { + let root = path_of(&state, &id)?; + blocking(move || coa_core::crashes::list(&root, limit.unwrap_or(10))).await +} + #[tauri::command] async fn console_tail( @@ -1677,6 +1683,7 @@ pub fn run() { run_diagnostics, verify_files, export_diagnostics, + list_crashes, console_tail, console_risk, console_command diff --git a/src/dev/mock.ts b/src/dev/mock.ts index cba1688..6156484 100644 --- a/src/dev/mock.ts +++ b/src/dev/mock.ts @@ -91,6 +91,7 @@ export {}; if(cmd==="report_targets") return [{id:"manager",repo:"Corfirean/coa-server-manager",ours:true},{id:"companions",repo:"Corfirean/mod-coa-playerbots",ours:true},{id:"squid",repo:"Zyth45/mod-playerbots",ours:false}]; if(cmd==="open_link"){(window as any).__lastLink=args.url;return null;} if(cmd==="export_diagnostics") return "C:\\Users\\you\\Desktop\\CoA-Diagnostics-20261001-140000.zip"; + if(cmd==="list_crashes") return []; if(cmd==="install_preflight") return {ok:true,problems:[],free_bytes:210*2**30}; if(cmd==="install_requirements") return {download_bytes:Math.round(5.9*2**30),unpacked_bytes:Math.round(11.4*2**30),version:"0.2.0"}; if(cmd==="list_accounts"){const w=window as any;w.__acc=w.__acc??[{id:2,name:"ALICE",access:3,online:true,last_login:"2026-10-01 12:04:11",characters:4},{id:5,name:"BOB",access:0,online:false,last_login:null,characters:0},{id:6,name:"CAROL",access:2,online:false,last_login:"2026-09-30 21:40:02",characters:2}];return w.__acc;} diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index ac023bd..8371e8b 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -867,6 +867,21 @@ const de: Partial> = { "fr.lan.unavailable": "Ausgewählte Adresse nicht verfügbar", "fr.lan.unavailableText": "Die ausgewählte Adresse ist derzeit keinem erkannten lokalen Netzwerkadapter zugewiesen. Wählen Sie eine andere Adresse oder Automatisch, oder behalten Sie diese Adresse bewusst bei.", "fr.lan.invalid": "Ungültige IPv4-Adresse", + "unsaved.title": "Nicht gespeicherte Änderungen", + "unsaved.message": "Sie haben nicht gespeicherte Änderungen. Möchten Sie diese vor dem Verlassen der Seite speichern?", + "unsaved.saveFailed": "Änderungen konnten nicht gespeichert werden. Bitte Eingaben überprüfen.", + "crash.autoRestart": "Automatischer Neustart bei Absturz", + "crash.autoRestartDesc": "Startet den Server automatisch neu, falls er unerwartet abstürzt.", + "crash.badge": "Abstürze: {n}", + "crash.historyTitle": "Absturzverlauf des Servers", + "crash.noCrashes": "Keine Absturzberichte gefunden.", + "crash.restartNotice": "Der Server ist abgestürzt und wurde automatisch neu gestartet (Absturz #{n}).", + "crash.loopPaused": "Automatischer Neustart pausiert: Der Server ist mehrfach kurz hintereinander abgestürzt.", + "crash.details": "Absturzdetails", + "crash.viewRaw": "Vollständigen Bericht anzeigen", + "crash.hideRaw": "Vollständigen Bericht verbergen", + "crash.copyReport": "Bericht kopieren", + "crash.copied": "Kopiert!", }; export default de; diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 4354787..3434769 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -890,6 +890,21 @@ const en = { "fr.lan.unavailable": "Selected address unavailable", "fr.lan.unavailableText": "The selected address is not currently assigned to a detected local interface. Choose another address or switch to Automatic, or keep this address intentionally.", "fr.lan.invalid": "Invalid IPv4 address", + "unsaved.title": "Unsaved changes", + "unsaved.message": "You have unsaved changes. Do you want to save them before leaving this page?", + "unsaved.saveFailed": "Failed to save changes. Please check fields for errors.", + "crash.autoRestart": "Auto-restart on crash", + "crash.autoRestartDesc": "Automatically restart the world server if it crashes or stops unexpectedly.", + "crash.badge": "Crashes: {n}", + "crash.historyTitle": "Server Crash History", + "crash.noCrashes": "No crash reports found.", + "crash.restartNotice": "Server crashed and was automatically restarted (Crash #{n}).", + "crash.loopPaused": "Auto-restart paused: the server crashed repeatedly in a short period.", + "crash.details": "Crash Details", + "crash.viewRaw": "View full crash report", + "crash.hideRaw": "Hide full report", + "crash.copyReport": "Copy crash report", + "crash.copied": "Copied!", } as const; export default en; diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 15177b3..33441b3 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -867,6 +867,21 @@ const es: Partial> = { "fr.lan.unavailable": "Dirección seleccionada no disponible", "fr.lan.unavailableText": "La dirección seleccionada no está asignada actualmente a ninguna interfaz local detectada. Elige otra dirección o Automática, o conserva esta dirección si así lo deseas.", "fr.lan.invalid": "Dirección IPv4 no válida", + "unsaved.title": "Cambios sin guardar", + "unsaved.message": "¿Tienes cambios sin guardar. ¿Quieres guardarlos antes de salir de esta página?", + "unsaved.saveFailed": "Error al guardar los cambios. Revisa los campos.", + "crash.autoRestart": "Reinicio automático tras caída", + "crash.autoRestartDesc": "Reinicia automáticamente el servidor si se detiene de forma inesperada.", + "crash.badge": "Caídas: {n}", + "crash.historyTitle": "Historial de caídas del servidor", + "crash.noCrashes": "No se encontraron informes de caídas.", + "crash.restartNotice": "El servidor se cayó y se reinició automáticamente (Caída #{n}).", + "crash.loopPaused": "Reinicio automático pausado: el servidor se ha caído varias veces en poco tiempo.", + "crash.details": "Detalles de la caída", + "crash.viewRaw": "Ver informe completo", + "crash.hideRaw": "Ocultar informe completo", + "crash.copyReport": "Copiar informe", + "crash.copied": "¡Copiado!", }; export default es; diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 6e37e65..4174370 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -867,6 +867,21 @@ const fr: Partial> = { "fr.lan.unavailable": "Adresse sélectionnée indisponible", "fr.lan.unavailableText": "Cette adresse n’est actuellement attribuée à aucune interface locale détectée. Choisissez une autre adresse ou le mode Automatique, ou conservez volontairement cette adresse.", "fr.lan.invalid": "Adresse IPv4 non valide", + "unsaved.title": "Modifications non enregistrées", + "unsaved.message": "Vous avez des modifications non enregistrées. Voulez-vous les enregistrer avant de quitter cette page ?", + "unsaved.saveFailed": "Échec de l’enregistrement. Veuillez vérifier les champs.", + "crash.autoRestart": "Redémarrage automatique en cas de plantage", + "crash.autoRestartDesc": "Redémarre automatiquement le serveur s’il plante ou s’arrête de manière inattendue.", + "crash.badge": "Plantages : {n}", + "crash.historyTitle": "Historique des plantages du serveur", + "crash.noCrashes": "Aucun rapport de plantage trouvé.", + "crash.restartNotice": "Le serveur a planté et a été redémarré automatiquement (Plantage #{n}).", + "crash.loopPaused": "Redémarrage automatique suspendu : le serveur a planté plusieurs fois successivement.", + "crash.details": "Détails du plantage", + "crash.viewRaw": "Afficher le rapport complet", + "crash.hideRaw": "Masquer le rapport complet", + "crash.copyReport": "Copier le rapport", + "crash.copied": "Copié !", }; export default fr; diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index 71d3ed7..e86b34e 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -888,6 +888,21 @@ const ru: Partial> = { "fr.lan.unavailable": "Выбранный адрес недоступен", "fr.lan.unavailableText": "Выбранный адрес сейчас не назначен обнаруженному сетевому интерфейсу. Выберите другой адрес, переключитесь на автоматический выбор или намеренно сохраните этот адрес.", "fr.lan.invalid": "Некорректный IPv4-адрес", + "unsaved.title": "Несохранённые изменения", + "unsaved.message": "У вас есть несохранённые изменения. Сохранить их перед переходом на другую страницу?", + "unsaved.saveFailed": "Не удалось сохранить настройки. Проверьте правильность значений.", + "crash.autoRestart": "Автоподнятие при падении", + "crash.autoRestartDesc": "Автоматически перезапускать сервер при внезапном падении или сбое.", + "crash.badge": "Крашей: {n}", + "crash.historyTitle": "История падений сервера", + "crash.noCrashes": "Отчётов о падениях не найдено.", + "crash.restartNotice": "Сервер упал и был автоматически перезапущен (Сбой #{n}).", + "crash.loopPaused": "Автоподнятие приостановлено: сервер упал несколько раз подряд за короткое время.", + "crash.details": "Подробности сбоя", + "crash.viewRaw": "Показать полный отчёт", + "crash.hideRaw": "Скрыть полный отчёт", + "crash.copyReport": "Скопировать отчёт", + "crash.copied": "Скопировано!", }; export default ru; diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index 72e8d16..a9e6fb3 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -869,6 +869,21 @@ const zh = { "fr.lan.unavailable": "所选地址不可用", "fr.lan.unavailableText": "所选地址目前未分配给检测到的本地网络接口。请选择其他地址、切换到自动,或有意保留此地址。", "fr.lan.invalid": "无效的 IPv4 地址", + "unsaved.title": "未保存的修改", + "unsaved.message": "您有未保存的修改。是否在离开前保存?", + "unsaved.saveFailed": "保存失败,请检查填写内容。", + "crash.autoRestart": "崩溃后自动重启", + "crash.autoRestartDesc": "如果服务器意外崩溃或停止,自动重新拉起。", + "crash.badge": "崩溃次数: {n}", + "crash.historyTitle": "服务器崩溃记录", + "crash.noCrashes": "未找到崩溃日志。", + "crash.restartNotice": "服务器崩溃并已自动重新拉起(崩溃 #{n})。", + "crash.loopPaused": "自动重启已暂停:服务器在短时间内连续崩溃。", + "crash.details": "崩溃详情", + "crash.viewRaw": "查看完整日志", + "crash.hideRaw": "隐藏完整日志", + "crash.copyReport": "复制崩溃报告", + "crash.copied": "已复制!", } as const; export default zh; diff --git a/src/lib/api.ts b/src/lib/api.ts index b580709..b5a3b85 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -421,6 +421,21 @@ export interface ConsoleLine { level: "info" | "warn" | "error"; } +export interface CrashItem { + id: string; + timestamp_ms: number; + date_str: string; + title: string; + explanation: string; + category: "assertion" | "access_violation" | "stack_overflow" | "out_of_memory" | "database" | "network" | "exception" | "generic"; + exception_code?: string | null; + location?: string | null; + function?: string | null; + condition?: string | null; + preview: string; + full_log: string; +} + export const api = { remoteConnection: () => invoke("remote_connection"), remoteConnect: (host: string) => invoke("remote_connect", { host }), @@ -512,6 +527,7 @@ export const api = { runDiagnostics: (id: string) => invoke<{ checks: DiagCheck[]; problems: number }>("run_diagnostics", { id }), verifyFiles: (id: string) => invoke<{ path: string; kind: "missing" | "changed" }[]>("verify_files", { id }), exportDiagnostics: (id: string) => invoke("export_diagnostics", { id }), + listCrashes: (id: string, limit?: number) => invoke("list_crashes", { id, limit: limit ?? null }), consoleTail: (id: string, source: ConsoleSource, filter?: string, lines = 300) => invoke("console_tail", { id, source, filter: filter ?? null, lines }), consoleRisk: (command: string) => invoke<"normal" | "dangerous">("console_risk", { command }), diff --git a/src/lib/autoRestartPrefs.ts b/src/lib/autoRestartPrefs.ts new file mode 100644 index 0000000..48ee8e1 --- /dev/null +++ b/src/lib/autoRestartPrefs.ts @@ -0,0 +1,32 @@ +import { useState } from "react"; + +const key = (id: string) => `coa-auto-restart-on-crash:${id}`; +const fallback = () => false; + +export function autoRestartOnCrash(id: string): boolean { + try { + const value = localStorage.getItem(key(id)); + return value === null ? fallback() : value === "1"; + } catch { + return fallback(); + } +} + +export function setAutoRestartOnCrash(id: string, on: boolean) { + try { + localStorage.setItem(key(id), on ? "1" : "0"); + } catch { + /* storage unavailable */ + } +} + +export function useAutoRestart(id: string): [boolean, (on: boolean) => void] { + const [on, setOn] = useState(() => autoRestartOnCrash(id)); + return [ + on, + (next) => { + setAutoRestartOnCrash(id, next); + setOn(next); + }, + ]; +} diff --git a/src/lib/unsavedGuard.ts b/src/lib/unsavedGuard.ts new file mode 100644 index 0000000..2e0db84 --- /dev/null +++ b/src/lib/unsavedGuard.ts @@ -0,0 +1,5 @@ +export interface UnsavedChangesGuard { + isDirty: () => boolean; + save: () => Promise; + discard: () => void; +} diff --git a/src/screens/AutoRestartSettings.tsx b/src/screens/AutoRestartSettings.tsx new file mode 100644 index 0000000..78c521c --- /dev/null +++ b/src/screens/AutoRestartSettings.tsx @@ -0,0 +1,24 @@ +import { Card } from "@/components/ui/card"; +import { useT } from "@/i18n"; +import { useAutoRestart } from "@/lib/autoRestartPrefs"; + +export function AutoRestartSettings({ serverId }: { serverId: string }) { + const t = useT(); + const [autoRestart, setAutoRestart] = useAutoRestart(serverId); + + return ( + +

{t("crash.autoRestart")}

+ +

{t("crash.autoRestartDesc")}

+
+ ); +} diff --git a/src/screens/CrashHistoryModal.tsx b/src/screens/CrashHistoryModal.tsx new file mode 100644 index 0000000..b795935 --- /dev/null +++ b/src/screens/CrashHistoryModal.tsx @@ -0,0 +1,198 @@ +import { useEffect, useState } from "react"; +import { AlertTriangle, Check, ChevronDown, ChevronUp, Copy, Loader2, X } from "lucide-react"; +import { api, type CrashItem } from "@/lib/api"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { useT } from "@/i18n"; + +export function CrashHistoryModal({ + serverId, + onClose, +}: { + serverId: string; + onClose: () => void; +}) { + const t = useT(); + const [loading, setLoading] = useState(true); + const [crashes, setCrashes] = useState([]); + const [expandedId, setExpandedId] = useState(null); + const [copiedId, setCopiedId] = useState(null); + + useEffect(() => { + let alive = true; + void api + .listCrashes(serverId, 30) + .then((items) => { + if (alive) { + setCrashes(items); + setLoading(false); + } + }) + .catch(() => { + if (alive) setLoading(false); + }); + return () => { + alive = false; + }; + }, [serverId]); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [onClose]); + + const copyReport = async (item: CrashItem) => { + try { + await navigator.clipboard.writeText(item.full_log); + setCopiedId(item.id); + setTimeout(() => setCopiedId(null), 2500); + } catch { + /* clipboard write failed */ + } + }; + + const getCategoryColor = (category: CrashItem["category"]) => { + switch (category) { + case "assertion": + return "bg-amber-500/15 text-amber-400 border-amber-500/30"; + case "access_violation": + case "stack_overflow": + case "out_of_memory": + return "bg-bad/15 text-bad border-bad/30"; + case "network": + case "database": + return "bg-blue-500/15 text-blue-400 border-blue-500/30"; + default: + return "bg-muted/15 text-muted border-line"; + } + }; + + return ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > + +
+
+ +

{t("crash.historyTitle")}

+
+ +
+ +
+ {loading ? ( +
+ +
+ ) : crashes.length === 0 ? ( +
+

{t("crash.noCrashes")}

+
+ ) : ( +
+ {crashes.map((item) => { + const isExpanded = expandedId === item.id; + const isCopied = copiedId === item.id; + return ( +
+
+
+ + {item.exception_code ? `${item.category} (${item.exception_code})` : item.category} + +

{item.title}

+
+ {item.date_str} +
+ +

{item.explanation}

+ +
+ + + +
+ + {isExpanded && ( +
+
+                          {item.full_log}
+                        
+
+ )} +
+ ); + })} +
+ )} +
+ +
+ +
+
+
+ ); +} diff --git a/src/screens/ModulesPage.tsx b/src/screens/ModulesPage.tsx index 55a9c29..c0935bc 100644 --- a/src/screens/ModulesPage.tsx +++ b/src/screens/ModulesPage.tsx @@ -7,6 +7,7 @@ import { Card } from "@/components/ui/card"; import { useHuman, useI18n, useSchemaText, type Key } from "@/i18n"; import { auctionWeightKey, canonicalModuleValue, moduleEnableKey, moduleField } from "@/lib/moduleSettings"; import { AuctionTypes } from "@/screens/AuctionTypes"; +import type { UnsavedChangesGuard } from "@/lib/unsavedGuard"; /** The GitHub mark (lucide dropped its brand icons). */ function GithubMark(props: { className?: string }) { @@ -78,13 +79,19 @@ function Tile({ icon, off }: { icon: string; off: boolean }) { } /** The server's optional modules: square cards with a switch, the module's status, a link to GitHub and its settings. */ -export function ModulesPage({ serverId, onChanged }: { serverId: string; onChanged?: () => void }) { +export function ModulesPage({ serverId, onChanged, onRegisterGuard }: { serverId: string; onChanged?: () => void; onRegisterGuard?: (guard: UnsavedChangesGuard | null) => void }) { const { t, locale } = useI18n(); const human = useHuman(); const [modules, setModules] = useState(null); const [open, setOpen] = useState(null); const [error, setError] = useState(null); const [changed, setChanged] = useState(false); + const [guard, setGuard] = useState(null); + + useEffect(() => { + onRegisterGuard?.(guard); + return () => onRegisterGuard?.(null); + }, [guard, onRegisterGuard]); const load = useCallback(async () => { try { @@ -174,7 +181,7 @@ export function ModulesPage({ serverId, onChanged }: { serverId: string; onChang {selected?.has_settings && !selected.page && selectedAt >= 0 && Math.floor(selectedAt / 2) === Math.floor(i / 2) && (i % 2 === 1 || i === modules.length - 1) && (

{t("mod.settingsOf", { name: selected.name })}

- setChanged(true)} /> + setChanged(true)} onRegisterGuard={setGuard} />
)} @@ -184,8 +191,8 @@ export function ModulesPage({ serverId, onChanged }: { serverId: string; onChang ); } -function ModuleSettings({ serverId, module, onSaved }: { serverId: string; module: ModuleView; onSaved: () => void }) { - const { t, locale } = useI18n(); +function ModuleSettings({ serverId, module, onSaved, onRegisterGuard }: { serverId: string; module: ModuleView; onSaved: () => void; onRegisterGuard?: (guard: UnsavedChangesGuard | null) => void }) { + const { t, tn, locale } = useI18n(); const sx = useSchemaText(); const human = useHuman(); const [advanced, setAdvanced] = useState(false); @@ -208,7 +215,7 @@ function ModuleSettings({ serverId, module, onSaved }: { serverId: string; modul return () => { cancelled = true; }; }, [serverId, module.id]); - async function save() { + async function save(): Promise { const errors: Record = {}; for (const [key, value] of Object.entries(edits)) { const field = moduleField(key, locale, items?.find(item => item.key === key)?.field); @@ -222,7 +229,7 @@ function ModuleSettings({ serverId, module, onSaved }: { serverId: string; modul } } setFieldErrors(errors); - if (Object.keys(errors).length) return; + if (Object.keys(errors).length) return false; setBusy(true); setError(null); setNote(null); @@ -234,14 +241,33 @@ function ModuleSettings({ serverId, module, onSaved }: { serverId: string; modul onSaved(); setNote(t("mod.saved", { n: done.length })); } + return true; } catch (e) { setError(asUiError(e)); + return false; } finally { setBusy(false); } } const dirty = Object.keys(edits).length > 0; + + useEffect(() => { + if (dirty) { + onRegisterGuard?.({ + isDirty: () => Object.keys(edits).length > 0, + save: () => save(), + discard: () => { + setEdits({}); + setFieldErrors({}); + setNote(null); + }, + }); + } else { + onRegisterGuard?.(null); + } + return () => onRegisterGuard?.(null); + }, [dirty, edits, onRegisterGuard]); const edit = (key: string, value: string) => setEdits(previous => { const next = { ...previous }; if (value === items?.find(item => item.key === key)?.value) delete next[key]; @@ -324,6 +350,20 @@ function ModuleSettings({ serverId, module, onSaved }: { serverId: string; modul {note && {note}} } {error &&

{error.human.code === "unknown" ? error.technical : human(error.human).message}

} + {dirty && ( +
+ {tn("set.unsaved", Object.keys(edits).length)} +
+ + +
+
+ )} ); } diff --git a/src/screens/Overview.tsx b/src/screens/Overview.tsx index ee3ebac..58175c7 100644 --- a/src/screens/Overview.tsx +++ b/src/screens/Overview.tsx @@ -1,6 +1,6 @@ import { dismissClientJob, startClientUpdate, stopClientJob, syncClient, useClientJob } from "@/lib/clientJob"; import { useCallback, useEffect, useRef, useState } from "react"; -import { Loader2, X } from "lucide-react"; +import { AlertTriangle, Loader2, X } from "lucide-react"; import { api, asUiError, type ClientInfo, type Human, type Performance, type Population, type ServerSummary, type ServiceStatus, type StatusView } from "@/lib/api"; import { cn, formatBytes, formatUptime } from "@/lib/utils"; import { useHuman, useT } from "@/i18n"; @@ -9,6 +9,8 @@ import { checkClient, useClientStatus } from "@/lib/clientUpdate"; import { ClientDialog, type ClientDialogMode } from "@/screens/ClientDialog"; import { RealmlistMenu } from "@/screens/RealmlistMenu"; import { RealmPicker } from "@/screens/RealmPicker"; +import { CrashHistoryModal } from "@/screens/CrashHistoryModal"; +import { useAutoRestart } from "@/lib/autoRestartPrefs"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; @@ -98,6 +100,35 @@ export function Overview({ server, companions = true, onForget, onOpenUpdates, o const upd = useServerUpdate(server.id); const needsDecision = (upd.preview?.conflicts.length ?? 0) > 0; const [startBots, setStartBots] = useState(undefined); + const [autoRestart, setAutoRestart] = useAutoRestart(server.id); + const autoRestartRef = useRef(autoRestart); + autoRestartRef.current = autoRestart; + + const [crashCount, setCrashCount] = useState(0); + const crashCountRef = useRef(0); + const [showCrashModal, setShowCrashModal] = useState(false); + const [hasCrashHistory, setHasCrashHistory] = useState(false); + const [crashLoopPaused, setCrashLoopPaused] = useState(false); + const [crashRestartNotice, setCrashRestartNotice] = useState(null); + + const userStoppedRef = useRef(false); + const wasWorldRunningRef = useRef(false); + const actionRef = useRef(null); + const crashTimestampsRef = useRef([]); + const pollRef = useRef<() => Promise>(undefined); + + useEffect(() => { + let active = true; + void api + .listCrashes(server.id, 1) + .then((items) => { + if (active && items.length > 0) setHasCrashHistory(true); + }) + .catch(() => {}); + return () => { + active = false; + }; + }, [server.id]); useEffect(() => { void api @@ -115,18 +146,87 @@ export function Overview({ server, companions = true, onForget, onOpenUpdates, o if (job.phase === "done" || job.phase === "current") void api.clientInfo(server.id).then(setClient).catch(() => undefined); }, [job.phase, server.id]); + const run = useCallback( + async (kind: Exclude) => { + if (kind === "stopping") { + userStoppedRef.current = true; + } else { + userStoppedRef.current = false; + setCrashLoopPaused(false); + setCrashRestartNotice(null); + } + setFailure(null); + setShowDetails(false); + setAction(kind); + actionRef.current = kind; + try { + if (kind !== "starting") { + const out = await api.stop(server.id); + if (!out.ok) throw { human: out.human, technical: out.output }; + } + if (kind !== "stopping") { + setAction("starting"); + actionRef.current = "starting"; + const out = await api.start(server.id); + if (!out.ok) throw { human: out.human, technical: out.output }; + } + } catch (e) { + const ui = asUiError(e); + setFailure({ human: ui.human ?? asUiError(String(e)).human, technical: ui.technical }); + } finally { + setAction(null); + actionRef.current = null; + void pollRef.current?.(); + } + }, + [server.id], + ); + const poll = useCallback(async () => { try { const s = await api.status(server.id); if (alive.current) setStatus(s); - if (s.observed.world.state === "running") { + const isWorldRunning = s.observed.world.state === "running"; + if (isWorldRunning) { + wasWorldRunningRef.current = true; + userStoppedRef.current = false; const p = await api.population(server.id).catch(() => null); if (alive.current) setPop(p); - } else if (alive.current) setPop(null); + } else { + if (alive.current) setPop(null); + if ( + wasWorldRunningRef.current && + !userStoppedRef.current && + actionRef.current === null + ) { + wasWorldRunningRef.current = false; + setHasCrashHistory(true); + const now = Date.now(); + const recent = crashTimestampsRef.current.filter((ts) => now - ts < 180000); + recent.push(now); + crashTimestampsRef.current = recent; + + crashCountRef.current += 1; + const currentCrashCount = crashCountRef.current; + setCrashCount(currentCrashCount); + + if (recent.length >= 3) { + setCrashLoopPaused(true); + setCrashRestartNotice(null); + } else if (autoRestartRef.current) { + setCrashRestartNotice(t("crash.restartNotice", { n: currentCrashCount })); + void run("starting"); + } + } + } } catch { /* transient; next poll retries */ } - }, [server.id]); + }, [server.id, t, run]); + + useEffect(() => { + pollRef.current = poll; + }, [poll]); // The tick rate comes from the server console (one short connection per reading), so it is read less often. const worldUp = status?.observed.world.state === "running"; @@ -156,29 +256,6 @@ export function Overview({ server, companions = true, onForget, onOpenUpdates, o }; }, [poll]); - async function run(kind: Exclude) { - setFailure(null); - setShowDetails(false); - setAction(kind); - try { - if (kind !== "starting") { - const out = await api.stop(server.id); - if (!out.ok) throw { human: out.human, technical: out.output }; - } - if (kind !== "stopping") { - setAction("starting"); - const out = await api.start(server.id); - if (!out.ok) throw { human: out.human, technical: out.output }; - } - } catch (e) { - const ui = asUiError(e); - setFailure({ human: ui.human ?? asUiError(String(e)).human, technical: ui.technical }); - } finally { - setAction(null); - void poll(); - } - } - async function play() { setFailure(null); setShowDetails(false); @@ -234,13 +311,56 @@ export function Overview({ server, companions = true, onForget, onOpenUpdates, o
{realms ? `${realms.first} · ${t("overview.server")}` : t("overview.server")}
-
- - {headline} +
+
+ + {headline} +
+ {(crashCount > 0 || hasCrashHistory) && ( + + )}
+ {crashRestartNotice && !crashLoopPaused && ( +
+ {crashRestartNotice} + +
+ )} + {crashLoopPaused && ( +
+ {t("crash.loopPaused")} + +
+ )} {!transitioning && !running && anyUp && (

{t("overview.reportHint")}{" "} @@ -363,6 +483,27 @@ export function Overview({ server, companions = true, onForget, onOpenUpdates, o )}

+
+ + {(crashCount > 0 || hasCrashHistory) && ( + + )} +
{anyUp && !running && !transitioning && (

{t("overview.partial")}

)} @@ -477,6 +618,13 @@ export function Overview({ server, companions = true, onForget, onOpenUpdates, o onPlayAnyway={() => void play()} /> )} + + {showCrashModal && ( + setShowCrashModal(false)} + /> + )} ); } diff --git a/src/screens/SettingsHome.tsx b/src/screens/SettingsHome.tsx index b41c09a..e3e999e 100644 --- a/src/screens/SettingsHome.tsx +++ b/src/screens/SettingsHome.tsx @@ -15,6 +15,7 @@ import { hasKey, useHuman, useI18n, type Key } from "@/i18n"; import { LanguagePicker } from "@/components/LanguagePicker"; import { RealmStartupSettings } from "@/screens/RealmStartupSettings"; import { RepairCard } from "@/screens/RepairCard"; +import { AutoRestartSettings } from "@/screens/AutoRestartSettings"; function mb(bytes: number) { return bytes >= 1 << 20 ? `${(bytes / (1 << 20)).toFixed(1)} MB` : `${Math.max(1, Math.round(bytes / 1024))} KB`; @@ -171,6 +172,7 @@ export function SettingsHome({ serverId, path, onForget }: { serverId: string; p + diff --git a/src/screens/SettingsPage.tsx b/src/screens/SettingsPage.tsx index 6d03c6e..ceb2172 100644 --- a/src/screens/SettingsPage.tsx +++ b/src/screens/SettingsPage.tsx @@ -22,6 +22,7 @@ import { Card } from "@/components/ui/card"; import { CollectionRows, RawRows, collectionsOf } from "@/screens/ExtraRows"; import { CompanionsCard } from "@/screens/CompanionsCard"; import { useHuman, useI18n, useSchemaText, useT, type Key } from "@/i18n"; +import type { UnsavedChangesGuard } from "@/lib/unsavedGuard"; type T = (k: Key, v?: Record) => string; @@ -154,7 +155,14 @@ function Row(props: { s: SettingView; value: JsonValue; error: string | null; on ); } -export function SettingsPage(props: { serverId: string; scope: Scope; botModule?: "companions" | "playerbots"; title: string; question: string }) { +export function SettingsPage(props: { + serverId: string; + scope: Scope; + botModule?: "companions" | "playerbots"; + title: string; + question: string; + onRegisterGuard?: (guard: UnsavedChangesGuard | null) => void; +}) { const { serverId, scope } = props; const { t, tn, locale } = useI18n(); const squid = scope === "bots" && props.botModule === "playerbots"; @@ -317,10 +325,10 @@ export function SettingsPage(props: { serverId: string; scope: Scope; botModule? } /** The save bar saves all three kinds of edits: the curated settings, the collection switches and the other documented ones. */ - async function saveEverything() { - if (dirtyKeys.length && !(await save(Object.fromEntries(dirtyKeys.map((k) => [k, draft[k]]))))) return; + async function saveEverything(): Promise { + if (dirtyKeys.length && !(await save(Object.fromEntries(dirtyKeys.map((k) => [k, draft[k]]))))) return false; const extra = [...Object.keys(rawEdits), ...Object.keys(colEdits)]; - if (!extra.length) return; + if (!extra.length) return true; setBusy(true); setSaveErr(null); try { @@ -337,13 +345,33 @@ export function SettingsPage(props: { serverId: string; scope: Scope; botModule? const more = extra.map((key) => ({ key, title: key, restart: "world" as const, dangerous: false })); setSaved((prev) => ({ changed: [...(prev?.changed ?? []), ...more], restart: prev?.restart ?? "world", snapshot: prev?.snapshot ?? null })); void api.status(serverId).then((st) => setRunning(st.observed.world.state === "running")); + return true; } catch (e) { setSaveErr(asUiError(e)); + return false; } finally { setBusy(false); } } + useEffect(() => { + if (pending > 0) { + props.onRegisterGuard?.({ + isDirty: () => pending > 0, + save: () => saveEverything(), + discard: () => { + setDraft({}); + setErrors({}); + setRawEdits({}); + setColEdits({}); + }, + }); + } else { + props.onRegisterGuard?.(null); + } + return () => props.onRegisterGuard?.(null); + }, [pending, props.onRegisterGuard]); + async function openPreset(id: string) { setSaved(null); if (squid) { diff --git a/src/screens/Shell.tsx b/src/screens/Shell.tsx index dd6d310..af663e6 100644 --- a/src/screens/Shell.tsx +++ b/src/screens/Shell.tsx @@ -1,5 +1,5 @@ -import { useCallback, useEffect, useState } from "react"; -import { Archive, BarChart3, Bot, Bug, Gauge, Globe, Puzzle, Server, Settings, Terminal, Users, type LucideIcon } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Archive, BarChart3, Bot, Bug, Gauge, Globe, Loader2, Puzzle, Server, Settings, Terminal, Users, type LucideIcon } from "lucide-react"; import { api, type ServerSummary } from "@/lib/api"; import { useT, type Key } from "@/i18n"; import logo from "@/assets/logo.png"; @@ -16,8 +16,10 @@ import { ModulesPage } from "@/screens/ModulesPage"; import { ConsolePage } from "@/screens/ConsolePage"; import { DashboardPage } from "@/screens/DashboardPage"; import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; import { startServerUpdatePolling, useServerUpdate } from "@/lib/serverUpdate"; import { startClientPolling } from "@/lib/clientUpdate"; +import type { UnsavedChangesGuard } from "@/lib/unsavedGuard"; type Page = "overview" | "bots" | "dashboard" | "server" | "modules" | "players" | "friends" | "backups" | "console" | "report" | "settings"; @@ -51,6 +53,22 @@ export function Shell(props: { // A module that is switched off takes its page away (the companions' "Bots" page, for one). const [botModule, setBotModule] = useState<"companions" | "playerbots">("companions"); const [hiddenPages, setHiddenPages] = useState([]); + const guardRef = useRef(null); + const registerGuard = useCallback((g: UnsavedChangesGuard | null) => { + guardRef.current = g; + }, []); + const [pendingNav, setPendingNav] = useState<(() => void) | null>(null); + const [modalSaving, setModalSaving] = useState(false); + const [modalError, setModalError] = useState(null); + + const requestNavigation = (action: () => void) => { + if (guardRef.current && guardRef.current.isDirty()) { + setPendingNav(() => action); + } else { + action(); + } + }; + const reloadModules = useCallback(() => { void api .modulesList(props.activeId) @@ -89,7 +107,7 @@ export function Shell(props: { {NAV.filter((n) => !hiddenPages.includes(n.id) && (n.id !== "dashboard" || botModule === "playerbots")).map(({ id, label, icon: Icon }) => (
  • @@ -129,15 +147,15 @@ export function Shell(props: {
    {page === "overview" ? ( - props.onForget(server.id)} onOpenUpdates={() => setPage("settings")} onRealmChanged={reloadModules} onReport={() => setPage("report")} /> + props.onForget(server.id)} onOpenUpdates={() => requestNavigation(() => setPage("settings"))} onRealmChanged={reloadModules} onReport={() => requestNavigation(() => setPage("report"))} /> ) : page === "bots" || page === "server" ? ( - + ) : page === "dashboard" ? ( ) : page === "console" ? ( ) : page === "modules" ? ( - + ) : page === "report" ? ( ) : page === "friends" ? ( @@ -152,6 +170,52 @@ export function Shell(props: { )}
    + + {pendingNav && ( +
    + +

    {t("unsaved.title")}

    +

    {t("unsaved.message")}

    + {modalError &&

    {modalError}

    } +
    + + + +
    +
    +
    + )} ); }