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)
This commit is contained in:
Дмитрий committed 2026-10-08 18:58:41 +05:00
1 parent 58c1e1aee0
commit be68eb4341
21 files changed
+985 -51

No files matched your search

+29
View File
@@ -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/<hash>_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) и кнопку разворота полного текста крашлога / копирования.
+249
View File
@@ -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<String>,
pub location: Option<String>,
pub function: Option<String>,
pub condition: Option<String>,
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<Vec<CrashItem>> {
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::<Vec<_>>();
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<class Player>::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<class Player>::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"));
}
}
+1
View File
@@ -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;
+7
View File
@@ -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<usize>) -> std::result::Result<Vec<coa_core::crashes::CrashItem>, 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
+1
View File
@@ -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;}
+15
View File
@@ -867,6 +867,21 @@ const de: Partial<Record<keyof typeof en, string>> = {
"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;
+15
View File
@@ -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;
+15
View File
@@ -867,6 +867,21 @@ const es: Partial<Record<keyof typeof en, string>> = {
"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;
+15
View File
@@ -867,6 +867,21 @@ const fr: Partial<Record<keyof typeof en, string>> = {
"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;
+15
View File
@@ -888,6 +888,21 @@ const ru: Partial<Record<keyof typeof en, string>> = {
"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;
+15
View File
@@ -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;
+16
View File
@@ -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<RemoteConnection>("remote_connection"),
remoteConnect: (host: string) => invoke<RemoteConnection>("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<string>("export_diagnostics", { id }),
listCrashes: (id: string, limit?: number) => invoke<CrashItem[]>("list_crashes", { id, limit: limit ?? null }),
consoleTail: (id: string, source: ConsoleSource, filter?: string, lines = 300) =>
invoke<ConsoleLine[]>("console_tail", { id, source, filter: filter ?? null, lines }),
consoleRisk: (command: string) => invoke<"normal" | "dangerous">("console_risk", { command }),
+32
View File
@@ -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);
},
];
}
+5
View File
@@ -0,0 +1,5 @@
export interface UnsavedChangesGuard {
isDirty: () => boolean;
save: () => Promise<boolean>;
discard: () => void;
}
+24
View File
@@ -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 (
<Card className="mt-6 p-6">
<h2 className="font-semibold">{t("crash.autoRestart")}</h2>
<label className="mt-3 flex cursor-pointer items-center gap-3">
<input
type="checkbox"
checked={autoRestart}
onChange={(e) => setAutoRestart(e.target.checked)}
className="h-4 w-4 accent-[#c9a24a]"
/>
<span className="text-sm font-medium">{t("crash.autoRestart")}</span>
</label>
<p className="mt-2 text-sm text-muted">{t("crash.autoRestartDesc")}</p>
</Card>
);
}
+198
View File
@@ -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<CrashItem[]>([]);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [copiedId, setCopiedId] = useState<string | null>(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 (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 md:p-6"
role="presentation"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<Card
className="flex max-h-[85vh] w-full max-w-2xl flex-col p-6 shadow-2xl"
role="dialog"
aria-modal="true"
aria-label={t("crash.historyTitle")}
>
<div className="flex items-center justify-between border-b border-line pb-4">
<div className="flex items-center gap-2.5">
<AlertTriangle className="h-5 w-5 text-warn" aria-hidden />
<h2 className="text-lg font-semibold">{t("crash.historyTitle")}</h2>
</div>
<button
type="button"
onClick={onClose}
className="cursor-pointer text-muted transition-colors hover:text-ink"
aria-label={t("btn.close")}
>
<X className="h-5 w-5" aria-hidden />
</button>
</div>
<div className="flex-1 overflow-y-auto py-4">
{loading ? (
<div className="flex items-center justify-center py-12 text-muted">
<Loader2 className="h-6 w-6 animate-spin" aria-hidden />
</div>
) : crashes.length === 0 ? (
<div className="py-12 text-center text-muted">
<p>{t("crash.noCrashes")}</p>
</div>
) : (
<div className="space-y-4">
{crashes.map((item) => {
const isExpanded = expandedId === item.id;
const isCopied = copiedId === item.id;
return (
<div
key={item.id}
className="rounded-lg border border-line bg-card-2 p-4 text-sm transition-all"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<span
className={`inline-block rounded border px-2 py-0.5 text-xs font-medium uppercase tracking-wide ${getCategoryColor(
item.category,
)}`}
>
{item.exception_code ? `${item.category} (${item.exception_code})` : item.category}
</span>
<h3 className="mt-1.5 font-semibold text-ink">{item.title}</h3>
</div>
<span className="text-xs text-muted tabular-nums">{item.date_str}</span>
</div>
<p className="mt-2 text-xs leading-relaxed text-muted">{item.explanation}</p>
<div className="mt-3 flex items-center justify-between border-t border-line/50 pt-2.5">
<button
type="button"
onClick={() => setExpandedId(isExpanded ? null : item.id)}
className="inline-flex cursor-pointer items-center gap-1.5 text-xs font-medium text-gold transition-colors hover:underline"
>
{isExpanded ? (
<>
<ChevronUp className="h-3.5 w-3.5" aria-hidden />
{t("crash.hideRaw")}
</>
) : (
<>
<ChevronDown className="h-3.5 w-3.5" aria-hidden />
{t("crash.viewRaw")}
</>
)}
</button>
<Button
variant="ghost"
size="sm"
onClick={() => void copyReport(item)}
className="h-7 gap-1.5 px-2 text-xs text-muted hover:text-ink"
>
{isCopied ? (
<>
<Check className="h-3.5 w-3.5 text-ok" aria-hidden />
<span className="text-ok">{t("crash.copied")}</span>
</>
) : (
<>
<Copy className="h-3.5 w-3.5" aria-hidden />
<span>{t("crash.copyReport")}</span>
</>
)}
</Button>
</div>
{isExpanded && (
<div className="mt-3">
<pre className="selectable max-h-56 overflow-x-auto overflow-y-auto rounded border border-line bg-black/40 p-3 font-mono text-[11px] leading-relaxed text-muted">
{item.full_log}
</pre>
</div>
)}
</div>
);
})}
</div>
)}
</div>
<div className="flex justify-end border-t border-line pt-4">
<Button variant="secondary" onClick={onClose}>
{t("btn.close")}
</Button>
</div>
</Card>
</div>
);
}
+46 -6
View File
@@ -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<ModuleView[] | null>(null);
const [open, setOpen] = useState<string | null>(null);
const [error, setError] = useState<UiError | null>(null);
const [changed, setChanged] = useState(false);
const [guard, setGuard] = useState<UnsavedChangesGuard | null>(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) && (
<SettingsPanel key={selected.id} className="sm:col-span-2">
<h2 className="font-semibold">{t("mod.settingsOf", { name: selected.name })}</h2>
<ModuleSettings serverId={serverId} module={selected} onSaved={() => setChanged(true)} />
<ModuleSettings serverId={serverId} module={selected} onSaved={() => setChanged(true)} onRegisterGuard={setGuard} />
</SettingsPanel>
)}
</Fragment>
@@ -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<boolean> {
const errors: Record<string, string> = {};
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 && <span className="text-sm text-ok" role="status">{note}</span>}
</div>}
{error && <p className="mt-2 text-sm text-bad" role="alert">{error.human.code === "unknown" ? error.technical : human(error.human).message}</p>}
{dirty && (
<div className="fixed bottom-0 left-60 right-0 z-40 flex items-center justify-between border-t border-line bg-[#0b0c0e]/95 px-10 py-3 shadow-lg">
<span className="text-sm text-muted">{tn("set.unsaved", Object.keys(edits).length)}</span>
<div className="flex gap-2">
<Button variant="ghost" size="sm" disabled={busy} onClick={() => { setEdits({}); setFieldErrors({}); setNote(null); }}>
{t("set.discard")}
</Button>
<Button variant="primary" size="sm" disabled={busy} onClick={() => void save()}>
{busy && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />}
{t("acc.save")}
</Button>
</div>
</div>
)}
</div>
);
}
+181 -33
View File
@@ -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<number | null | undefined>(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<string | null>(null);
const userStoppedRef = useRef(false);
const wasWorldRunningRef = useRef(false);
const actionRef = useRef<Action>(null);
const crashTimestampsRef = useRef<number[]>([]);
const pollRef = useRef<() => Promise<void>>(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<Action, null>) => {
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<Action, null>) {
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
<Card className="mt-6 p-7">
<div className="mb-1 text-xs font-medium uppercase tracking-[0.14em] text-muted">{realms ? `${realms.first} · ${t("overview.server")}` : t("overview.server")}</div>
<div className={cn("flex items-center gap-3 text-3xl font-semibold", tone)} role="status" aria-live="polite">
<span
aria-hidden
className={cn("h-3 w-3 rounded-full", running && !transitioning ? "bg-ok" : transitioning || anyUp ? "bg-warn" : "bg-muted/50")}
/>
{headline}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className={cn("flex items-center gap-3 text-3xl font-semibold", tone)} role="status" aria-live="polite">
<span
aria-hidden
className={cn("h-3 w-3 rounded-full", running && !transitioning ? "bg-ok" : transitioning || anyUp ? "bg-warn" : "bg-muted/50")}
/>
{headline}
</div>
{(crashCount > 0 || hasCrashHistory) && (
<button
type="button"
onClick={() => setShowCrashModal(true)}
className={cn(
"inline-flex cursor-pointer items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium transition-colors",
crashCount > 0
? "border border-bad/40 bg-bad/15 text-bad hover:bg-bad/25"
: "border border-line bg-card-2 text-muted hover:text-ink hover:border-gold/40",
)}
title={t("crash.historyTitle")}
>
<AlertTriangle className="h-3.5 w-3.5" aria-hidden />
<span>{crashCount > 0 ? t("crash.badge", { n: crashCount }) : t("crash.historyTitle")}</span>
</button>
)}
</div>
{crashRestartNotice && !crashLoopPaused && (
<div className="mt-3 flex items-center justify-between rounded-md border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-300">
<span>{crashRestartNotice}</span>
<button
type="button"
onClick={() => setCrashRestartNotice(null)}
className="cursor-pointer text-amber-300/70 hover:text-amber-300"
aria-label={t("btn.close")}
>
<X className="h-4 w-4" />
</button>
</div>
)}
{crashLoopPaused && (
<div className="mt-3 flex items-center justify-between rounded-md border border-bad/40 bg-bad/15 p-3 text-xs text-bad">
<span>{t("crash.loopPaused")}</span>
<button
type="button"
onClick={() => setShowCrashModal(true)}
className="cursor-pointer font-medium underline"
>
{t("crash.historyTitle")}
</button>
</div>
)}
{!transitioning && !running && anyUp && (
<p className="mt-2 text-sm text-muted">
{t("overview.reportHint")}{" "}
@@ -363,6 +483,27 @@ export function Overview({ server, companions = true, onForget, onOpenUpdates, o
)}
</div>
</div>
<div className="mt-6 flex flex-wrap items-center justify-between gap-3 border-t border-line/60 pt-4 text-xs text-muted">
<label className="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={autoRestart}
onChange={(e) => setAutoRestart(e.target.checked)}
className="h-3.5 w-3.5 accent-[#c9a24a]"
/>
<span className="font-medium text-ink/80">{t("crash.autoRestart")}</span>
<span className="hidden sm:inline text-muted">— {t("crash.autoRestartDesc")}</span>
</label>
{(crashCount > 0 || hasCrashHistory) && (
<button
type="button"
onClick={() => setShowCrashModal(true)}
className="cursor-pointer text-gold hover:underline"
>
{t("crash.historyTitle")}
</button>
)}
</div>
{anyUp && !running && !transitioning && (
<p className="mt-3 text-sm text-muted">{t("overview.partial")}</p>
)}
@@ -477,6 +618,13 @@ export function Overview({ server, companions = true, onForget, onOpenUpdates, o
onPlayAnyway={() => void play()}
/>
)}
{showCrashModal && (
<CrashHistoryModal
serverId={server.id}
onClose={() => setShowCrashModal(false)}
/>
)}
</div>
);
}
+2
View File
@@ -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
<ClientCard serverId={serverId} />
<RealmStartupSettings serverId={serverId} />
<AutoRestartSettings serverId={serverId} />
<DiagnosticsCard serverId={serverId} />
<RepairCard key={serverId} serverId={serverId} />
+32 -4
View File
@@ -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, string | number>) => 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<boolean> {
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) {
+72 -8
View File
@@ -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<string[]>([]);
const guardRef = useRef<UnsavedChangesGuard | null>(null);
const registerGuard = useCallback((g: UnsavedChangesGuard | null) => {
guardRef.current = g;
}, []);
const [pendingNav, setPendingNav] = useState<(() => void) | null>(null);
const [modalSaving, setModalSaving] = useState(false);
const [modalError, setModalError] = useState<string | null>(null);
const requestNavigation = (action: () => void) => {
if (guardRef.current && guardRef.current.isDirty()) {
setPendingNav(() => action);
} else {
action();
}
};
const reloadModules = useCallback(() => {
void api
.modulesList(props.activeId)
@@ -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 }) => (
<li key={id}>
<button
onClick={() => setPage(id)}
onClick={() => requestNavigation(() => setPage(id))}
aria-current={page === id ? "page" : undefined}
className={cn(
"flex h-10 w-full cursor-pointer items-center gap-3 rounded-md px-3 text-left text-[15px] transition-colors",
@@ -111,7 +129,7 @@ export function Shell(props: {
<select
className="mb-2 w-full rounded-md border border-line bg-card px-2 py-2 text-sm"
value={props.activeId}
onChange={(e) => props.onSelect(e.target.value)}
onChange={(e) => requestNavigation(() => props.onSelect(e.target.value))}
aria-label={t("nav.serverPicker")}
>
{props.servers.map((s) => (
@@ -121,7 +139,7 @@ export function Shell(props: {
))}
</select>
)}
<Button variant="ghost" size="sm" className="w-full justify-start" onClick={props.onAddAnother}>
<Button variant="ghost" size="sm" className="w-full justify-start" onClick={() => requestNavigation(props.onAddAnother)}>
{t("nav.addAnother")}
</Button>
</div>
@@ -129,15 +147,15 @@ export function Shell(props: {
<main className="relative h-full flex-1 overflow-y-auto px-10 py-8">
{page === "overview" ? (
<Overview key={server.id} server={server} companions={botModule === "companions" && !hiddenPages.includes("bots")} onForget={() => props.onForget(server.id)} onOpenUpdates={() => setPage("settings")} onRealmChanged={reloadModules} onReport={() => setPage("report")} />
<Overview key={server.id} server={server} companions={botModule === "companions" && !hiddenPages.includes("bots")} onForget={() => props.onForget(server.id)} onOpenUpdates={() => requestNavigation(() => setPage("settings"))} onRealmChanged={reloadModules} onReport={() => requestNavigation(() => setPage("report"))} />
) : page === "bots" || page === "server" ? (
<SettingsPage key={`${server.id}-${page}-${botModule}`} serverId={server.id} scope={page} botModule={botModule} title={t(current.label)} question={t(current.question)} />
<SettingsPage key={`${server.id}-${page}-${botModule}`} serverId={server.id} scope={page} botModule={botModule} title={t(current.label)} question={t(current.question)} onRegisterGuard={registerGuard} />
) : page === "dashboard" ? (
<DashboardPage key={server.id} serverId={server.id} />
) : page === "console" ? (
<ConsolePage key={server.id} serverId={server.id} />
) : page === "modules" ? (
<ModulesPage key={server.id} serverId={server.id} onChanged={reloadModules} />
<ModulesPage key={server.id} serverId={server.id} onChanged={reloadModules} onRegisterGuard={registerGuard} />
) : page === "report" ? (
<ReportPage key={server.id} serverId={server.id} />
) : page === "friends" ? (
@@ -152,6 +170,52 @@ export function Shell(props: {
<Placeholder title={t(current.label)} question={t(current.question)} />
)}
</main>
{pendingNav && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-6" role="presentation">
<Card className="w-full max-w-md p-6 shadow-2xl" role="dialog" aria-modal="true" aria-label={t("unsaved.title")}>
<h2 className="text-lg font-semibold">{t("unsaved.title")}</h2>
<p className="mt-2 text-sm text-muted">{t("unsaved.message")}</p>
{modalError && <p className="mt-3 text-sm text-bad" role="alert">{modalError}</p>}
<div className="mt-6 flex flex-wrap justify-end gap-2">
<Button variant="ghost" disabled={modalSaving} onClick={() => { setPendingNav(null); setModalError(null); }}>
{t("common.cancel")}
</Button>
<Button variant="ghost" disabled={modalSaving} onClick={() => {
guardRef.current?.discard();
const act = pendingNav;
setPendingNav(null);
setModalError(null);
act?.();
}}>
{t("set.discard")}
</Button>
<Button variant="primary" disabled={modalSaving} onClick={async () => {
if (!guardRef.current) return;
setModalSaving(true);
setModalError(null);
try {
const ok = await guardRef.current.save();
if (ok) {
const act = pendingNav;
setPendingNav(null);
act?.();
} else {
setModalError(t("unsaved.saveFailed"));
}
} catch (e) {
setModalError(String(e));
} finally {
setModalSaving(false);
}
}}>
{modalSaving && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />}
{t("acc.save")}
</Button>
</div>
</Card>
</div>
)}
</div>
);
}