Менеджер запускает сервер PZ как дочерний процесс и держит его stdin/stdout: отсюда живая консоль без RCON, стриминг логов через SSE и корректная остановка по цепочке quit -> SIGTERM -> SIGKILL для всей группы процессов. Сам менеджер работает под systemd. Что входит: - управление жизненным циклом сервера, автозапуск и автоподъём после падения; - метрики процесса из /proc и состояние хоста, список игроков онлайн; - редакторы servertest.ini и SandboxVars.lua, сохраняющие исходное форматирование файлов; - моды: списки Workshop ID и Mod ID с порядком загрузки, названия из Steam; - бэкапы мира по расписанию и вручную, с ротацией и восстановлением; - установка и обновление сервера через SteamCMD; - вход по логину и паролю, первичная настройка по одноразовому коду. Веб-интерфейс без сборщика, вшит в бинарник через embed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
920 lines
34 KiB
JavaScript
920 lines
34 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;
|
|
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 = {
|
|
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-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;
|
|
}
|
|
|
|
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);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Бэкапы
|
|
// ---------------------------------------------------------------------------
|
|
|
|
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>${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="4" class="muted">Пока ни одного архива</td></tr>`;
|
|
}
|
|
|
|
$("#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 или скачать автоматически"],
|
|
["server_name", "Имя конфигурации сервера", "text", "servertest → servertest.ini"],
|
|
["java_memory", "Память JVM", "text", "Например 8g. Применяется при установке/обновлении"],
|
|
["admin_password", "Пароль админа игры", "password", "Оставьте пустым, чтобы не менять"],
|
|
["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 if (key === "admin_password") {
|
|
input.placeholder = config.has_admin_password ? "пароль задан" : "не задан";
|
|
} 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();
|
|
}
|
|
});
|
|
// Пустой пароль означает «не менять» — не отправляем его вовсе.
|
|
if (!payload.admin_password) delete payload.admin_password;
|
|
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);
|
|
});
|