Профиль — это отдельный сервер: свой мир, .ini, песочница, набор модов и лимит памяти JVM. Project Zomboid различает их по -servername, поэтому файлы профилей не пересекаются. Работает всегда один профиль: серверные файлы, игровые порты и файл запуска JVM общие, так что переключение разрешено только на остановленном сервере. - config: список профилей вместо одиночных server_name/admin_password/ java_memory; старые поля переносятся в первый профиль при загрузке; - supervisor: запускает активный профиль и выставляет его -Xmx на каждом старте, потому что файл запуска общий; - бэкапы: архив снимается с одного профиля (мир + его конфиги), имя профиля попадает в имя файла, ротация считается по профилям; - API профилей: список, создание с копированием настроек из другого профиля, правка, удаление и переключение активного; - панель: селектор профиля в шапке и вкладка со списком профилей. Удаление профиля убирает его только из панели: мир и конфиги остаются на диске, чтобы случайное нажатие не стоило сохранений. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1086 lines
40 KiB
JavaScript
1086 lines
40 KiB
JavaScript
"use strict";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Обращения к API
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Заголовок обязателен для всех изменяющих запросов: сервер использует его как
|
|
// защиту от CSRF вместо отдельных токенов.
|
|
const CSRF_HEADER = "X-PZM-Request";
|
|
|
|
async function api(method, path, body) {
|
|
const options = {
|
|
method,
|
|
headers: { [CSRF_HEADER]: "1" },
|
|
credentials: "same-origin",
|
|
};
|
|
if (body !== undefined) {
|
|
options.headers["Content-Type"] = "application/json";
|
|
options.body = JSON.stringify(body);
|
|
}
|
|
|
|
const response = await fetch(path, options);
|
|
if (response.status === 401) {
|
|
showAuth(false);
|
|
throw new Error("Сессия истекла, войдите заново");
|
|
}
|
|
|
|
const text = await response.text();
|
|
const data = text ? JSON.parse(text) : {};
|
|
if (!response.ok) {
|
|
throw new Error(data.error || `Ошибка ${response.status}`);
|
|
}
|
|
return data;
|
|
}
|
|
|
|
const get = (path) => api("GET", path);
|
|
const post = (path, body) => api("POST", path, body);
|
|
const put = (path, body) => api("PUT", path, body);
|
|
const del = (path) => api("DELETE", path);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Мелкие утилиты
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const $ = (selector) => document.querySelector(selector);
|
|
const $$ = (selector) => Array.from(document.querySelectorAll(selector));
|
|
|
|
function toast(message, kind = "") {
|
|
const el = document.createElement("div");
|
|
el.className = `toast ${kind}`;
|
|
el.textContent = message;
|
|
$("#toasts").appendChild(el);
|
|
setTimeout(() => el.remove(), kind === "err" ? 7000 : 4000);
|
|
}
|
|
|
|
const ok = (message) => toast(message, "ok");
|
|
const fail = (error) => toast(error.message || String(error), "err");
|
|
|
|
function formatBytes(bytes) {
|
|
if (!bytes) return "—";
|
|
const units = ["Б", "КБ", "МБ", "ГБ", "ТБ"];
|
|
let value = bytes;
|
|
let unit = 0;
|
|
while (value >= 1024 && unit < units.length - 1) {
|
|
value /= 1024;
|
|
unit++;
|
|
}
|
|
return `${value.toFixed(value < 10 && unit > 0 ? 1 : 0)} ${units[unit]}`;
|
|
}
|
|
|
|
function formatUptime(seconds) {
|
|
if (!seconds) return "";
|
|
const days = Math.floor(seconds / 86400);
|
|
const hours = Math.floor((seconds % 86400) / 3600);
|
|
const minutes = Math.floor((seconds % 3600) / 60);
|
|
const parts = [];
|
|
if (days) parts.push(`${days} д`);
|
|
if (hours) parts.push(`${hours} ч`);
|
|
parts.push(`${minutes} мин`);
|
|
return "Работает: " + parts.join(" ");
|
|
}
|
|
|
|
function formatDate(unixSeconds) {
|
|
return new Date(unixSeconds * 1000).toLocaleString("ru-RU");
|
|
}
|
|
|
|
// Экранирование не нужно там, где мы пишем в textContent, но пригодится для
|
|
// заголовков модов, которые приходят из Steam.
|
|
function escapeHTML(value) {
|
|
const div = document.createElement("div");
|
|
div.textContent = value ?? "";
|
|
return div.innerHTML;
|
|
}
|
|
|
|
const STATE_LABELS = {
|
|
stopped: "Остановлен",
|
|
starting: "Запускается",
|
|
running: "Работает",
|
|
stopping: "Останавливается",
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Вход и первичная настройка
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let setupMode = false;
|
|
|
|
function showAuth(needSetup) {
|
|
setupMode = needSetup;
|
|
$("#app").classList.add("hidden");
|
|
$("#auth-screen").classList.remove("hidden");
|
|
$("#token-field").hidden = !needSetup;
|
|
$("#auth-subtitle").textContent = needSetup
|
|
? "Создайте учётную запись администратора"
|
|
: "Управление сервером Project Zomboid";
|
|
$("#auth-submit").textContent = needSetup ? "Создать администратора" : "Войти";
|
|
$("#auth-form").querySelector("[name=password]").autocomplete = needSetup
|
|
? "new-password"
|
|
: "current-password";
|
|
stopPolling();
|
|
}
|
|
|
|
function showApp(login) {
|
|
$("#auth-screen").classList.add("hidden");
|
|
$("#app").classList.remove("hidden");
|
|
$("#current-user").textContent = login;
|
|
loadProfiles().catch(fail);
|
|
startPolling();
|
|
openConsoleStream();
|
|
loadTab(currentTab);
|
|
}
|
|
|
|
$("#auth-form").addEventListener("submit", async (event) => {
|
|
event.preventDefault();
|
|
const form = new FormData(event.target);
|
|
const payload = {
|
|
login: form.get("login").trim(),
|
|
password: form.get("password"),
|
|
};
|
|
if (setupMode) payload.token = (form.get("token") || "").trim();
|
|
|
|
const errorBox = $("#auth-error");
|
|
errorBox.hidden = true;
|
|
try {
|
|
const result = await post(setupMode ? "/api/setup" : "/api/login", payload);
|
|
event.target.reset();
|
|
showApp(result.login);
|
|
} catch (error) {
|
|
errorBox.textContent = error.message;
|
|
errorBox.hidden = false;
|
|
}
|
|
});
|
|
|
|
$("#btn-logout").addEventListener("click", async () => {
|
|
try {
|
|
await post("/api/logout");
|
|
} catch (error) {
|
|
/* даже при ошибке уводим на экран входа */
|
|
}
|
|
closeConsoleStream();
|
|
showAuth(false);
|
|
});
|
|
|
|
async function checkSession() {
|
|
const session = await get("/api/session");
|
|
if (session.authenticated) {
|
|
showApp(session.login);
|
|
} else {
|
|
showAuth(session.setup_needed);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Вкладки
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let currentTab = "overview";
|
|
|
|
$("#tabs").addEventListener("click", (event) => {
|
|
const tab = event.target.closest(".tab");
|
|
if (!tab) return;
|
|
currentTab = tab.dataset.tab;
|
|
$$(".tab").forEach((t) => t.classList.toggle("active", t === tab));
|
|
$$(".panel").forEach((p) => p.classList.toggle("active", p.dataset.panel === currentTab));
|
|
loadTab(currentTab);
|
|
});
|
|
|
|
// loadTab подгружает данные вкладки при переходе на неё: конфиги и списки
|
|
// модов меняются редко, держать их в опросе статуса незачем.
|
|
function loadTab(name) {
|
|
const loaders = {
|
|
profiles: loadProfiles,
|
|
config: loadServerConfig,
|
|
sandbox: loadSandbox,
|
|
mods: loadMods,
|
|
backups: loadBackups,
|
|
settings: loadSettings,
|
|
};
|
|
if (loaders[name]) loaders[name]().catch(fail);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Статус и управление сервером
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let pollTimer = null;
|
|
let lastTaskName = "";
|
|
|
|
function startPolling() {
|
|
stopPolling();
|
|
refreshStatus();
|
|
pollTimer = setInterval(refreshStatus, 2000);
|
|
}
|
|
|
|
function stopPolling() {
|
|
if (pollTimer) clearInterval(pollTimer);
|
|
pollTimer = null;
|
|
}
|
|
|
|
async function refreshStatus() {
|
|
let data;
|
|
try {
|
|
data = await get("/api/status");
|
|
} catch (error) {
|
|
$("#status-text").textContent = "Нет связи с менеджером";
|
|
$("#status-dot").className = "dot";
|
|
return;
|
|
}
|
|
renderStatus(data.server);
|
|
renderTask(data.task);
|
|
}
|
|
|
|
function renderStatus(status) {
|
|
const label = STATE_LABELS[status.state] || status.state;
|
|
$("#status-text").textContent = label;
|
|
$("#status-dot").className = `dot ${status.state}`;
|
|
|
|
$("#ov-state").textContent = label;
|
|
$("#ov-profile").textContent = status.profile_title ? `Профиль: ${status.profile_title}` : "";
|
|
$("#ov-uptime").textContent = formatUptime(status.uptime_sec);
|
|
$("#ov-pid").textContent = status.pid ? `PID ${status.pid}` : "";
|
|
$("#ov-lastexit").textContent =
|
|
status.state === "stopped" && status.last_exit ? `Последнее завершение: ${status.last_exit}` : "";
|
|
|
|
const players = status.players || [];
|
|
$("#ov-players-count").textContent = players.length;
|
|
$("#ov-players").innerHTML = players.map((name) => `<li>${escapeHTML(name)}</li>`).join("");
|
|
|
|
$("#ov-cpu").textContent = status.proc.cpu_percent ? `${status.proc.cpu_percent.toFixed(0)} %` : "—";
|
|
$("#ov-rss").textContent = formatBytes(status.proc.rss_bytes);
|
|
$("#ov-threads").textContent = status.proc.threads || "—";
|
|
|
|
const host = status.host || {};
|
|
$("#ov-mem").textContent = host.mem_total_bytes
|
|
? `${formatBytes(host.mem_available_bytes)} из ${formatBytes(host.mem_total_bytes)}`
|
|
: "—";
|
|
$("#ov-load").textContent = host.cpu_count
|
|
? `${(host.load1 || 0).toFixed(2)} на ${host.cpu_count} ядер`
|
|
: (host.load1 || 0).toFixed(2);
|
|
$("#ov-disk").textContent = host.disk_total_bytes
|
|
? `${formatBytes(host.disk_free_bytes)} из ${formatBytes(host.disk_total_bytes)}`
|
|
: "—";
|
|
|
|
$("#install-state").textContent = status.installed
|
|
? "Серверные файлы установлены."
|
|
: "Серверные файлы не найдены — установите их перед первым запуском.";
|
|
|
|
const running = status.state !== "stopped";
|
|
$("#btn-start").disabled = running || !status.installed;
|
|
$("#btn-stop").disabled = !running;
|
|
$("#btn-restart").disabled = !status.installed;
|
|
$("#btn-install").disabled = running;
|
|
|
|
// Профили делят игровые порты и файл запуска, поэтому переключать их можно
|
|
// только на остановленном сервере.
|
|
const picker = $("#profile-select");
|
|
picker.disabled = running;
|
|
picker.title = running
|
|
? "Остановите сервер, чтобы переключить профиль"
|
|
: "Профиль сервера: свой мир, конфиг и моды";
|
|
// Активный профиль мог смениться в другой вкладке браузера.
|
|
if (!running && status.profile && picker.value !== status.profile) {
|
|
picker.value = status.profile;
|
|
}
|
|
}
|
|
|
|
function renderTask(task) {
|
|
const banner = $("#task-banner");
|
|
banner.hidden = !task || !task.running;
|
|
if (task && task.running) {
|
|
$("#task-name").textContent = task.name;
|
|
lastTaskName = task.name;
|
|
} else if (task && task.name && task.name === lastTaskName) {
|
|
// Сообщаем об исходе один раз — на следующем опросе имя уже совпадать не будет.
|
|
lastTaskName = "";
|
|
if (task.error) {
|
|
toast(`${task.name}: ${task.error}`, "err");
|
|
} else {
|
|
ok(`${task.name} — готово`);
|
|
loadTab(currentTab);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function serverAction(path, confirmText) {
|
|
if (confirmText && !confirm(confirmText)) return;
|
|
try {
|
|
await post(path);
|
|
refreshStatus();
|
|
} catch (error) {
|
|
fail(error);
|
|
}
|
|
}
|
|
|
|
$("#btn-start").addEventListener("click", () => serverAction("/api/server/start"));
|
|
$("#btn-stop").addEventListener("click", () =>
|
|
serverAction("/api/server/stop", "Остановить игровой сервер? Игроки будут отключены."));
|
|
$("#btn-restart").addEventListener("click", () =>
|
|
serverAction("/api/server/restart", "Перезапустить сервер? Игроки будут отключены."));
|
|
|
|
$("#btn-install").addEventListener("click", async () => {
|
|
if (!confirm("Запустить установку/обновление серверных файлов через SteamCMD?")) return;
|
|
try {
|
|
await post("/api/install");
|
|
ok("Задача запущена — следите за вкладкой «Консоль»");
|
|
refreshStatus();
|
|
} catch (error) {
|
|
fail(error);
|
|
}
|
|
});
|
|
|
|
$("#btn-task-cancel").addEventListener("click", async () => {
|
|
try {
|
|
await post("/api/task/cancel");
|
|
} catch (error) {
|
|
fail(error);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Консоль
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let eventSource = null;
|
|
let lastSeq = 0;
|
|
const MAX_CONSOLE_LINES = 3000;
|
|
|
|
function openConsoleStream() {
|
|
closeConsoleStream();
|
|
eventSource = new EventSource(`/api/logs/stream?since=${lastSeq}`);
|
|
eventSource.onmessage = (event) => appendConsoleLine(JSON.parse(event.data));
|
|
eventSource.onerror = () => {
|
|
// Браузер переподключается сам; сообщать об этом каждый раз не нужно.
|
|
};
|
|
}
|
|
|
|
function closeConsoleStream() {
|
|
if (eventSource) eventSource.close();
|
|
eventSource = null;
|
|
}
|
|
|
|
function appendConsoleLine(line) {
|
|
if (line.seq <= lastSeq) return;
|
|
lastSeq = line.seq;
|
|
|
|
const output = $("#console-output");
|
|
const filter = $("#console-filter").value.trim().toLowerCase();
|
|
|
|
const el = document.createElement("div");
|
|
el.className = `line-${line.stream}`;
|
|
el.textContent = line.text;
|
|
el.dataset.text = line.text.toLowerCase();
|
|
if (filter && !el.dataset.text.includes(filter)) el.hidden = true;
|
|
output.appendChild(el);
|
|
|
|
while (output.childElementCount > MAX_CONSOLE_LINES) {
|
|
output.removeChild(output.firstElementChild);
|
|
}
|
|
if ($("#console-autoscroll").checked) {
|
|
output.scrollTop = output.scrollHeight;
|
|
}
|
|
}
|
|
|
|
$("#console-filter").addEventListener("input", (event) => {
|
|
const filter = event.target.value.trim().toLowerCase();
|
|
Array.from($("#console-output").children).forEach((line) => {
|
|
line.hidden = filter && !line.dataset.text.includes(filter);
|
|
});
|
|
});
|
|
|
|
$("#btn-console-clear").addEventListener("click", () => {
|
|
// Чистим только экран: история остаётся в буфере менеджера.
|
|
$("#console-output").innerHTML = "";
|
|
});
|
|
|
|
// История отправленных команд: та же логика, что и в обычном терминале.
|
|
const commandHistory = [];
|
|
let historyIndex = 0;
|
|
|
|
$("#console-form").addEventListener("submit", async (event) => {
|
|
event.preventDefault();
|
|
const input = $("#console-command");
|
|
const command = input.value.trim();
|
|
if (!command) return;
|
|
try {
|
|
await post("/api/console", { command });
|
|
if (commandHistory[commandHistory.length - 1] !== command) commandHistory.push(command);
|
|
historyIndex = commandHistory.length;
|
|
input.value = "";
|
|
} catch (error) {
|
|
fail(error);
|
|
}
|
|
});
|
|
|
|
$("#console-command").addEventListener("keydown", (event) => {
|
|
if (event.key === "Enter") {
|
|
// Отправляем явно: полагаться на неявную отправку формы по Enter не стоит,
|
|
// она зависит от браузера.
|
|
event.preventDefault();
|
|
$("#console-form").requestSubmit();
|
|
return;
|
|
}
|
|
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
|
|
|
|
event.preventDefault();
|
|
historyIndex += event.key === "ArrowUp" ? -1 : 1;
|
|
historyIndex = Math.max(0, Math.min(historyIndex, commandHistory.length));
|
|
event.target.value = commandHistory[historyIndex] ?? "";
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Конфиг сервера
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Подсказки к самым востребованным параметрам: остальные показываются как есть.
|
|
const CONFIG_HINTS = {
|
|
PublicName: "Название сервера в списке серверов",
|
|
PublicDescription: "Описание сервера",
|
|
Public: "Показывать сервер в публичном списке (true/false)",
|
|
Password: "Пароль для входа на сервер (пусто — без пароля)",
|
|
MaxPlayers: "Максимум игроков",
|
|
DefaultPort: "Игровой порт (UDP), по умолчанию 16261",
|
|
UDPPort: "Второй игровой порт, по умолчанию 16262",
|
|
PVP: "Разрешить бой между игроками",
|
|
PauseEmpty: "Ставить мир на паузу, когда никого нет онлайн",
|
|
GlobalChat: "Общий чат для всего сервера",
|
|
Open: "Пускать игроков без записи в белом списке",
|
|
ServerWelcomeMessage: "Приветственное сообщение",
|
|
SaveWorldEveryMinutes: "Как часто сохранять мир, минут",
|
|
BackupsOnStart: "Делать бэкап силами самой игры при старте",
|
|
RCONPort: "Порт RCON",
|
|
RCONPassword: "Пароль RCON",
|
|
AdminSafehouse: "Разрешить админам входить в чужие убежища",
|
|
SafetySystem: "Система защиты от PVP-урона",
|
|
DisplayUserName: "Показывать ники над персонажами",
|
|
SpawnPoint: "Точка появления новых игроков (x,y,z)",
|
|
Mods: "Список модов — редактируется во вкладке «Моды»",
|
|
WorkshopItems: "Моды мастерской — редактируется во вкладке «Моды»",
|
|
Map: "Карты — редактируется во вкладке «Моды»",
|
|
};
|
|
|
|
const BOOLEAN_VALUES = new Set(["true", "false"]);
|
|
|
|
let serverConfigValues = {};
|
|
|
|
async function loadServerConfig() {
|
|
const data = await get("/api/config/server");
|
|
$("#config-path").textContent = data.path;
|
|
$("#config-empty").hidden = data.exists;
|
|
$("#config-fields").innerHTML = "";
|
|
|
|
if (!data.exists) {
|
|
$("#config-empty").textContent = data.hint;
|
|
return;
|
|
}
|
|
serverConfigValues = data.values;
|
|
const container = $("#config-fields");
|
|
data.keys.forEach((key) => {
|
|
container.appendChild(buildConfigField(key, data.values[key], CONFIG_HINTS[key]));
|
|
});
|
|
applyFieldFilter("#config-filter", "#config-fields");
|
|
}
|
|
|
|
// buildConfigField выбирает тип поля по текущему значению: булевы значения
|
|
// удобнее переключать списком, числа — числовым полем.
|
|
function buildConfigField(key, value, hint) {
|
|
const wrapper = document.createElement("div");
|
|
wrapper.className = "cfg-field";
|
|
wrapper.dataset.key = key.toLowerCase();
|
|
wrapper.dataset.hint = (hint || "").toLowerCase();
|
|
|
|
const label = document.createElement("label");
|
|
label.textContent = hint || key;
|
|
wrapper.appendChild(label);
|
|
|
|
let input;
|
|
if (BOOLEAN_VALUES.has(value.toLowerCase())) {
|
|
input = document.createElement("select");
|
|
["true", "false"].forEach((option) => {
|
|
const el = document.createElement("option");
|
|
el.value = option;
|
|
el.textContent = option === "true" ? "да" : "нет";
|
|
input.appendChild(el);
|
|
});
|
|
input.value = value.toLowerCase();
|
|
} else if (/^-?\d+$/.test(value) && value.length < 10) {
|
|
input = document.createElement("input");
|
|
input.type = "number";
|
|
input.value = value;
|
|
} else {
|
|
input = document.createElement("input");
|
|
input.type = key.toLowerCase().includes("password") ? "password" : "text";
|
|
input.value = value;
|
|
}
|
|
input.dataset.original = value;
|
|
input.name = key;
|
|
input.addEventListener("input", () => {
|
|
wrapper.classList.toggle("changed", input.value !== input.dataset.original);
|
|
});
|
|
wrapper.appendChild(input);
|
|
|
|
const keyLabel = document.createElement("div");
|
|
keyLabel.className = "key";
|
|
keyLabel.textContent = key;
|
|
wrapper.appendChild(keyLabel);
|
|
return wrapper;
|
|
}
|
|
|
|
// collectChanged собирает только изменённые поля: отправлять весь файл целиком
|
|
// рискованно, если его правили ещё и вручную.
|
|
function collectChanged(containerSelector) {
|
|
const changed = {};
|
|
$$(`${containerSelector} .cfg-field`).forEach((field) => {
|
|
const input = field.querySelector("input, select");
|
|
if (input && input.value !== input.dataset.original) {
|
|
changed[input.name] = input.value;
|
|
}
|
|
});
|
|
return changed;
|
|
}
|
|
|
|
function markSaved(containerSelector) {
|
|
$$(`${containerSelector} .cfg-field`).forEach((field) => {
|
|
const input = field.querySelector("input, select");
|
|
if (input) input.dataset.original = input.value;
|
|
field.classList.remove("changed");
|
|
});
|
|
}
|
|
|
|
$("#btn-config-save").addEventListener("click", async () => {
|
|
const values = collectChanged("#config-fields");
|
|
if (!Object.keys(values).length) {
|
|
toast("Нет изменений");
|
|
return;
|
|
}
|
|
try {
|
|
await put("/api/config/server", { values });
|
|
markSaved("#config-fields");
|
|
ok("Конфиг сохранён. Изменения применятся после перезапуска сервера.");
|
|
} catch (error) {
|
|
fail(error);
|
|
}
|
|
});
|
|
|
|
function applyFieldFilter(inputSelector, containerSelector) {
|
|
const filter = $(inputSelector).value.trim().toLowerCase();
|
|
$$(`${containerSelector} .cfg-field`).forEach((field) => {
|
|
field.hidden = filter && !field.dataset.key.includes(filter) && !field.dataset.hint.includes(filter);
|
|
});
|
|
}
|
|
|
|
$("#config-filter").addEventListener("input", () => applyFieldFilter("#config-filter", "#config-fields"));
|
|
$("#sandbox-filter").addEventListener("input", () => applyFieldFilter("#sandbox-filter", "#sandbox-fields"));
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Песочница
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function loadSandbox() {
|
|
const data = await get("/api/config/sandbox");
|
|
$("#sandbox-path").textContent = data.path;
|
|
$("#sandbox-empty").hidden = data.exists;
|
|
$("#sandbox-fields").innerHTML = "";
|
|
|
|
if (!data.exists) {
|
|
$("#sandbox-empty").textContent = data.hint;
|
|
return;
|
|
}
|
|
const container = $("#sandbox-fields");
|
|
data.entries.forEach((entry) => {
|
|
container.appendChild(buildConfigField(entry.path, entry.value, null));
|
|
});
|
|
applyFieldFilter("#sandbox-filter", "#sandbox-fields");
|
|
}
|
|
|
|
$("#btn-sandbox-save").addEventListener("click", async () => {
|
|
const values = collectChanged("#sandbox-fields");
|
|
if (!Object.keys(values).length) {
|
|
toast("Нет изменений");
|
|
return;
|
|
}
|
|
try {
|
|
await put("/api/config/sandbox", { values });
|
|
markSaved("#sandbox-fields");
|
|
ok("Песочница сохранена. Часть параметров действует только на новый мир.");
|
|
} catch (error) {
|
|
fail(error);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Моды
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let mods = { workshop: [], modIDs: [], maps: [] };
|
|
|
|
async function loadMods() {
|
|
const data = await get("/api/mods");
|
|
$("#mods-empty").hidden = data.exists;
|
|
$("#mods-body").hidden = !data.exists;
|
|
if (!data.exists) {
|
|
$("#mods-empty").textContent = data.hint;
|
|
return;
|
|
}
|
|
|
|
mods = {
|
|
workshop: data.workshop_items || [],
|
|
modIDs: data.mods || [],
|
|
maps: data.map || [],
|
|
};
|
|
$("#map-list").value = mods.maps.join("\n");
|
|
renderMods();
|
|
lookupWorkshopTitles().catch(() => {
|
|
/* без интернета останутся одни идентификаторы — это не ошибка панели */
|
|
});
|
|
}
|
|
|
|
function renderMods(details = {}) {
|
|
$("#workshop-list").innerHTML = mods.workshop
|
|
.map((id, index) => {
|
|
const info = details[id] || {};
|
|
const title = info.error ? `<span class="error">${escapeHTML(info.error)}</span>` : escapeHTML(info.title || "…");
|
|
const preview = info.preview
|
|
? `<img src="${escapeHTML(info.preview)}" alt="">`
|
|
: "";
|
|
return `<li>
|
|
${preview}
|
|
<div class="mod-title"><b>${title}</b><small>${escapeHTML(id)}</small></div>
|
|
<button class="btn btn-sm btn-ghost" data-move="workshop" data-index="${index}" data-dir="-1">↑</button>
|
|
<button class="btn btn-sm btn-ghost" data-move="workshop" data-index="${index}" data-dir="1">↓</button>
|
|
<button class="btn btn-sm btn-danger" data-remove="workshop" data-index="${index}">Убрать</button>
|
|
</li>`;
|
|
})
|
|
.join("");
|
|
|
|
$("#modid-list").innerHTML = mods.modIDs
|
|
.map(
|
|
(id, index) => `<li>
|
|
<div class="mod-title"><b>${escapeHTML(id)}</b></div>
|
|
<button class="btn btn-sm btn-ghost" data-move="mod" data-index="${index}" data-dir="-1">↑</button>
|
|
<button class="btn btn-sm btn-ghost" data-move="mod" data-index="${index}" data-dir="1">↓</button>
|
|
<button class="btn btn-sm btn-danger" data-remove="mod" data-index="${index}">Убрать</button>
|
|
</li>`
|
|
)
|
|
.join("");
|
|
}
|
|
|
|
async function lookupWorkshopTitles() {
|
|
if (!mods.workshop.length) return;
|
|
const data = await post("/api/mods/lookup", { ids: mods.workshop });
|
|
const details = {};
|
|
(data.items || []).forEach((item) => {
|
|
details[item.id] = item;
|
|
});
|
|
renderMods(details);
|
|
}
|
|
|
|
// Обе кнопки — перемещение и удаление — обрабатываем одним слушателем на списке.
|
|
$("#mods-body").addEventListener("click", (event) => {
|
|
const button = event.target.closest("button[data-move], button[data-remove]");
|
|
if (!button) return;
|
|
|
|
const index = Number(button.dataset.index);
|
|
const list = (button.dataset.move || button.dataset.remove) === "workshop" ? mods.workshop : mods.modIDs;
|
|
|
|
if (button.dataset.move) {
|
|
const target = index + Number(button.dataset.dir);
|
|
if (target < 0 || target >= list.length) return;
|
|
[list[index], list[target]] = [list[target], list[index]];
|
|
} else {
|
|
list.splice(index, 1);
|
|
}
|
|
renderMods();
|
|
lookupWorkshopTitles().catch(() => {});
|
|
});
|
|
|
|
$("#mod-add-form").addEventListener("submit", (event) => {
|
|
event.preventDefault();
|
|
const workshopID = $("#mod-workshop-id").value.trim();
|
|
const modID = $("#mod-mod-id").value.trim();
|
|
|
|
if (workshopID && !/^\d+$/.test(workshopID)) {
|
|
toast("Workshop ID — это число из ссылки на мод", "err");
|
|
return;
|
|
}
|
|
if (!workshopID && !modID) return;
|
|
|
|
if (workshopID && !mods.workshop.includes(workshopID)) mods.workshop.push(workshopID);
|
|
if (modID && !mods.modIDs.includes(modID)) mods.modIDs.push(modID);
|
|
|
|
$("#mod-workshop-id").value = "";
|
|
$("#mod-mod-id").value = "";
|
|
renderMods();
|
|
lookupWorkshopTitles().catch(() => {});
|
|
});
|
|
|
|
$("#btn-mods-save").addEventListener("click", async () => {
|
|
const maps = $("#map-list").value.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
try {
|
|
await put("/api/mods", {
|
|
mods: mods.modIDs,
|
|
workshop_items: mods.workshop,
|
|
map: maps,
|
|
});
|
|
ok("Список модов сохранён. Перезапустите сервер, чтобы он их подтянул.");
|
|
} catch (error) {
|
|
fail(error);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Профили серверов
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let profiles = { list: [], active: "" };
|
|
|
|
async function loadProfiles() {
|
|
const data = await get("/api/profiles");
|
|
profiles = { list: data.profiles || [], active: data.active };
|
|
|
|
renderProfileSelect();
|
|
renderProfileCards();
|
|
renderCopySource();
|
|
}
|
|
|
|
function renderProfileSelect() {
|
|
const select = $("#profile-select");
|
|
select.innerHTML = profiles.list
|
|
.map((p) => `<option value="${escapeHTML(p.id)}">${escapeHTML(p.title || p.id)}</option>`)
|
|
.join("");
|
|
select.value = profiles.active;
|
|
}
|
|
|
|
function renderProfileCards() {
|
|
$("#profile-cards").innerHTML = profiles.list
|
|
.map((p) => {
|
|
const badge = p.active ? `<span class="badge">активный</span>` : "";
|
|
const state = p.has_config
|
|
? p.world_ready
|
|
? "мир создан"
|
|
: "настроен, мир ещё не создан"
|
|
: "ни разу не запускался";
|
|
|
|
return `<div class="card profile-card" data-profile="${escapeHTML(p.id)}">
|
|
<h3>${escapeHTML(p.title || p.id)} ${badge}</h3>
|
|
<div class="key">${escapeHTML(p.id)}</div>
|
|
<p class="muted small">${state}</p>
|
|
|
|
<label class="field">
|
|
<span>Название</span>
|
|
<input type="text" data-field="title" value="${escapeHTML(p.title || "")}">
|
|
</label>
|
|
<label class="field">
|
|
<span>Память JVM</span>
|
|
<input type="text" data-field="java_memory" value="${escapeHTML(p.java_memory || "")}">
|
|
</label>
|
|
<label class="field">
|
|
<span>Пароль админа игры</span>
|
|
<input type="password" data-field="admin_password" autocomplete="new-password"
|
|
placeholder="${p.has_admin_password ? "пароль задан" : "не задан"}">
|
|
</label>
|
|
|
|
<div class="profile-actions">
|
|
<button class="btn btn-sm btn-primary" data-action="save">Сохранить</button>
|
|
<button class="btn btn-sm" data-action="activate" ${p.active ? "disabled" : ""}>Сделать активным</button>
|
|
<button class="btn btn-sm btn-danger" data-action="delete" ${p.active ? "disabled" : ""}>Удалить</button>
|
|
</div>
|
|
</div>`;
|
|
})
|
|
.join("");
|
|
}
|
|
|
|
// renderCopySource предлагает копировать настройки только с тех профилей, у
|
|
// которых конфиг уже создан игрой.
|
|
function renderCopySource() {
|
|
$("#new-profile-copy").innerHTML =
|
|
`<option value="">не копировать</option>` +
|
|
profiles.list
|
|
.filter((p) => p.has_config)
|
|
.map((p) => `<option value="${escapeHTML(p.id)}">${escapeHTML(p.title || p.id)}</option>`)
|
|
.join("");
|
|
}
|
|
|
|
// switchProfile меняет активный профиль. Все вкладки показывают именно его
|
|
// конфиг, моды и мир, поэтому открытую вкладку перезагружаем.
|
|
async function switchProfile(id) {
|
|
try {
|
|
await post(`/api/profiles/${encodeURIComponent(id)}/activate`);
|
|
await loadProfiles();
|
|
ok("Активный профиль переключён");
|
|
refreshStatus();
|
|
loadTab(currentTab);
|
|
} catch (error) {
|
|
fail(error);
|
|
// Селектор показывает то, чего не произошло, — возвращаем прежнее значение.
|
|
$("#profile-select").value = profiles.active;
|
|
}
|
|
}
|
|
|
|
$("#profile-select").addEventListener("change", (event) => switchProfile(event.target.value));
|
|
|
|
$("#profile-cards").addEventListener("click", async (event) => {
|
|
const button = event.target.closest("button[data-action]");
|
|
if (!button) return;
|
|
|
|
const card = button.closest(".profile-card");
|
|
const id = card.dataset.profile;
|
|
const field = (name) => card.querySelector(`[data-field="${name}"]`).value;
|
|
|
|
try {
|
|
switch (button.dataset.action) {
|
|
case "save":
|
|
await put(`/api/profiles/${encodeURIComponent(id)}`, {
|
|
title: field("title"),
|
|
java_memory: field("java_memory"),
|
|
admin_password: field("admin_password"),
|
|
});
|
|
ok("Профиль сохранён");
|
|
await loadProfiles();
|
|
break;
|
|
|
|
case "activate":
|
|
await switchProfile(id);
|
|
break;
|
|
|
|
case "delete":
|
|
if (!confirm(`Убрать профиль ${id} из панели?\n\n` +
|
|
`Мир и конфиги останутся на диске — профиль можно вернуть, создав его с тем же идентификатором.`)) {
|
|
return;
|
|
}
|
|
await del(`/api/profiles/${encodeURIComponent(id)}`);
|
|
ok("Профиль удалён");
|
|
await loadProfiles();
|
|
break;
|
|
}
|
|
} catch (error) {
|
|
fail(error);
|
|
}
|
|
});
|
|
|
|
$("#profile-add-form").addEventListener("submit", async (event) => {
|
|
event.preventDefault();
|
|
try {
|
|
await post("/api/profiles", {
|
|
id: $("#new-profile-id").value.trim(),
|
|
title: $("#new-profile-title").value.trim(),
|
|
java_memory: $("#new-profile-memory").value.trim(),
|
|
copy_from: $("#new-profile-copy").value,
|
|
});
|
|
$("#new-profile-id").value = "";
|
|
$("#new-profile-title").value = "";
|
|
$("#new-profile-memory").value = "";
|
|
ok("Профиль создан");
|
|
await loadProfiles();
|
|
} catch (error) {
|
|
fail(error);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Бэкапы
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function loadBackups() {
|
|
const data = await get("/api/backups");
|
|
$("#backup-dir").textContent = data.dir;
|
|
|
|
const rows = (data.backups || []).map(
|
|
(archive) => `<tr>
|
|
<td>${escapeHTML(archive.name)}</td>
|
|
<td>${escapeHTML(profileTitle(archive.profile))}</td>
|
|
<td>${formatBytes(archive.size_bytes)}</td>
|
|
<td>${formatDate(archive.created_at)}</td>
|
|
<td>
|
|
<a class="btn btn-sm btn-ghost" href="/api/backups/${encodeURIComponent(archive.name)}/download">Скачать</a>
|
|
<button class="btn btn-sm" data-restore="${escapeHTML(archive.name)}">Восстановить</button>
|
|
<button class="btn btn-sm btn-danger" data-delete="${escapeHTML(archive.name)}">Удалить</button>
|
|
</td>
|
|
</tr>`
|
|
);
|
|
$("#backup-rows").innerHTML = rows.join("") ||
|
|
`<tr><td colspan="5" class="muted">Пока ни одного архива</td></tr>`;
|
|
}
|
|
|
|
// profileTitle подписывает архив понятным именем профиля. У архивов, снятых до
|
|
// появления профилей, идентификатора в имени нет.
|
|
function profileTitle(id) {
|
|
if (!id) return "—";
|
|
const profile = profiles.list.find((p) => p.id === id);
|
|
return profile ? profile.title || profile.id : id;
|
|
}
|
|
|
|
$("#backup-form").addEventListener("submit", async (event) => {
|
|
event.preventDefault();
|
|
try {
|
|
await post("/api/backups", { note: $("#backup-note").value.trim() });
|
|
$("#backup-note").value = "";
|
|
ok("Бэкап запущен — прогресс во вкладке «Консоль»");
|
|
} catch (error) {
|
|
fail(error);
|
|
}
|
|
});
|
|
|
|
$("#backup-rows").addEventListener("click", async (event) => {
|
|
const restore = event.target.closest("button[data-restore]");
|
|
const remove = event.target.closest("button[data-delete]");
|
|
|
|
try {
|
|
if (restore) {
|
|
const name = restore.dataset.restore;
|
|
if (!confirm(`Восстановить мир из ${name}? Текущий мир будет перезаписан.\n` +
|
|
`Перед восстановлением менеджер сам сделает страховочный бэкап.`)) return;
|
|
await post(`/api/backups/${encodeURIComponent(name)}/restore`);
|
|
ok("Восстановление запущено");
|
|
} else if (remove) {
|
|
const name = remove.dataset.delete;
|
|
if (!confirm(`Удалить архив ${name}? Это действие необратимо.`)) return;
|
|
await del(`/api/backups/${encodeURIComponent(name)}`);
|
|
ok("Архив удалён");
|
|
loadBackups();
|
|
}
|
|
} catch (error) {
|
|
fail(error);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Настройки менеджера и пользователи
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Описание формы настроек: ключ в API, подпись, тип и пояснение.
|
|
const SETTINGS_FIELDS = [
|
|
["listen", "Адрес веб-панели", "text", "Например 127.0.0.1:8080. Применится после перезапуска pzmanager"],
|
|
["server_dir", "Директория сервера", "text", "Куда SteamCMD ставит файлы Project Zomboid"],
|
|
["zomboid_dir", "Директория данных (~/Zomboid)", "text", "Здесь лежат миры, конфиги и логи игры"],
|
|
["steamcmd_path", "Путь к steamcmd.sh", "text", "Пусто — найти в PATH или скачать автоматически"],
|
|
["autostart", "Запускать сервер вместе с менеджером", "checkbox", ""],
|
|
["autorestart", "Поднимать сервер после падения", "checkbox", ""],
|
|
["stop_timeout_sec", "Таймаут остановки, сек", "number", "Сколько ждать после команды quit до SIGTERM"],
|
|
["log_buffer_lines", "Строк консоли в памяти", "number", ""],
|
|
["backup_dir", "Директория бэкапов", "text", ""],
|
|
["backup_schedule_min", "Автобэкап, минут", "number", "0 — отключить автоматические бэкапы"],
|
|
["backup_keep", "Хранить архивов", "number", "0 — не удалять старые"],
|
|
["backup_stop_server", "Останавливать сервер на время бэкапа", "checkbox",
|
|
"Даёт согласованный снимок, но выкидывает игроков"],
|
|
];
|
|
|
|
async function loadSettings() {
|
|
const config = await get("/api/config/manager");
|
|
const form = $("#settings-form");
|
|
form.innerHTML = "";
|
|
|
|
SETTINGS_FIELDS.forEach(([key, label, type, hint]) => {
|
|
const wrapper = document.createElement("div");
|
|
wrapper.className = "cfg-field";
|
|
|
|
const labelEl = document.createElement("label");
|
|
labelEl.textContent = label;
|
|
wrapper.appendChild(labelEl);
|
|
|
|
const input = document.createElement("input");
|
|
input.type = type;
|
|
input.name = key;
|
|
if (type === "checkbox") {
|
|
input.checked = Boolean(config[key]);
|
|
} else {
|
|
input.value = config[key] ?? "";
|
|
}
|
|
wrapper.appendChild(input);
|
|
|
|
if (hint) {
|
|
const hintEl = document.createElement("div");
|
|
hintEl.className = "hint";
|
|
hintEl.textContent = hint;
|
|
wrapper.appendChild(hintEl);
|
|
}
|
|
form.appendChild(wrapper);
|
|
});
|
|
|
|
const pathNote = document.createElement("p");
|
|
pathNote.className = "muted small";
|
|
pathNote.textContent = `Файл конфигурации: ${config.config_path}`;
|
|
form.appendChild(pathNote);
|
|
|
|
await loadUsers();
|
|
}
|
|
|
|
$("#btn-settings-save").addEventListener("click", async () => {
|
|
const payload = {};
|
|
SETTINGS_FIELDS.forEach(([key, , type]) => {
|
|
const input = $(`#settings-form [name="${key}"]`);
|
|
if (!input) return;
|
|
if (type === "checkbox") {
|
|
payload[key] = input.checked;
|
|
} else if (type === "number") {
|
|
payload[key] = Number(input.value || 0);
|
|
} else {
|
|
payload[key] = input.value.trim();
|
|
}
|
|
});
|
|
try {
|
|
const result = await put("/api/config/manager", payload);
|
|
ok(result.note || "Настройки сохранены");
|
|
loadSettings();
|
|
} catch (error) {
|
|
fail(error);
|
|
}
|
|
});
|
|
|
|
async function loadUsers() {
|
|
const data = await get("/api/users");
|
|
$("#user-list").innerHTML = (data.users || [])
|
|
.map(
|
|
(login) => `<li>
|
|
<span>${escapeHTML(login)}</span>
|
|
<button class="btn btn-sm" data-passwd="${escapeHTML(login)}">Сменить пароль</button>
|
|
<button class="btn btn-sm btn-danger" data-deluser="${escapeHTML(login)}">Удалить</button>
|
|
</li>`
|
|
)
|
|
.join("");
|
|
}
|
|
|
|
$("#user-list").addEventListener("click", async (event) => {
|
|
const passwd = event.target.closest("button[data-passwd]");
|
|
const remove = event.target.closest("button[data-deluser]");
|
|
|
|
try {
|
|
if (passwd) {
|
|
const password = prompt(`Новый пароль для ${passwd.dataset.passwd} (минимум 8 символов):`);
|
|
if (!password) return;
|
|
await post(`/api/users/${encodeURIComponent(passwd.dataset.passwd)}/password`, { password });
|
|
ok("Пароль изменён");
|
|
} else if (remove) {
|
|
if (!confirm(`Удалить пользователя ${remove.dataset.deluser}?`)) return;
|
|
await del(`/api/users/${encodeURIComponent(remove.dataset.deluser)}`);
|
|
ok("Пользователь удалён");
|
|
loadUsers();
|
|
}
|
|
} catch (error) {
|
|
fail(error);
|
|
}
|
|
});
|
|
|
|
$("#user-add-form").addEventListener("submit", async (event) => {
|
|
event.preventDefault();
|
|
try {
|
|
await post("/api/users", {
|
|
login: $("#new-user-login").value.trim(),
|
|
password: $("#new-user-password").value,
|
|
});
|
|
$("#new-user-login").value = "";
|
|
$("#new-user-password").value = "";
|
|
ok("Пользователь добавлен");
|
|
loadUsers();
|
|
} catch (error) {
|
|
fail(error);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
checkSession().catch((error) => {
|
|
fail(error);
|
|
showAuth(false);
|
|
});
|