Files
mrleo1nidandClaude Opus 5 20795b053d
CI / test (push) Successful in 24s
Release / release (push) Successful in 31s
Консоль не вешает вкладку под потоком логов сборки
Загрузка мира со сборкой модов печатает десятки тысяч строк: каждое
исключение мода — стек на два десятка строк. Консоль рисовала их по одной
и после каждой прокручивалась вниз, заставляя браузер пересчитывать
раскладку всего списка. Десять тысяч строк блокировали вкладку на 29
секунд, и запуск сервера выглядел как зависшая панель.

Строки копятся и выводятся пачкой раз в кадр: те же десять тысяч теперь
занимают 14 мс. Узлы сверх предела списка не создаются вовсе, а на скрытой
вкладке очередь ограничена, чтобы не расти в памяти.

Заодно вывод игрового сервера больше не гоняется через словарь переводов:
он английский, в словаре менеджера его нет, а перебор шаблонов регулярками
на таком потоке стоит дорого.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 17:57:11 +03:00

2326 lines
90 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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(t("auth.expired"));
}
const text = await response.text();
const data = text ? JSON.parse(text) : {};
if (!response.ok) {
throw new Error(data.error || t("error.http", { status: 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 = [t("unit.b"), t("unit.kb"), t("unit.mb"), t("unit.gb"), t("unit.tb")];
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]}`;
}
// key разделяет два смысла одного счётчика: сервер либо уже работает, либо
// всё ещё загружается.
function formatUptime(seconds, key = "overview.uptime") {
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(t("unit.days", { value: days }));
if (hours) parts.push(t("unit.hours", { value: hours }));
parts.push(t("unit.minutes", { value: minutes }));
return t(key, { value: parts.join(" ") });
}
function formatDate(unixSeconds) {
return new Date(unixSeconds * 1000).toLocaleString(t("locale.tag"));
}
// Экранирование не нужно там, где мы пишем в textContent, но пригодится для
// заголовков модов, которые приходят из Steam.
function escapeHTML(value) {
const div = document.createElement("div");
div.textContent = value ?? "";
return div.innerHTML;
}
// Состояние приходит с сервера строкой: подпись ищем в словаре, а незнакомое
// показываем как есть — так новое состояние не превратится в пустоту.
function stateLabel(state) {
const key = `state.${state}`;
const label = t(key);
return label === key ? state : label;
}
// ---------------------------------------------------------------------------
// Вход и первичная настройка
// ---------------------------------------------------------------------------
let setupMode = false;
function showAuth(needSetup) {
setupMode = needSetup;
$("#app").classList.add("hidden");
$("#auth-screen").classList.remove("hidden");
$("#token-field").hidden = !needSetup;
$("#auth-subtitle").textContent = t(needSetup ? "auth.subtitleSetup" : "auth.subtitle");
$("#auth-submit").textContent = t(needSetup ? "auth.createAdmin" : "auth.signIn");
$("#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 = t("error.offline");
$("#status-dot").className = "dot";
return;
}
renderStatus(data.server);
renderTask(data.task);
}
// hostStats нужен карточкам профилей: там подсказываем, сколько памяти можно
// отдать JVM, чтобы не выбирать объём вслепую.
let hostStats = {};
// Что показывают кнопки, зависит от состояния сервера, и сравнивать их подписи
// с текстом было бы неверно: он меняется вместе с языком.
let restartPending = false;
let serverInstalled = false;
function renderStatus(status) {
hostStats = status.host || {};
let label = stateLabel(status.state);
// Пока идёт обратный отсчёт, показываем, сколько осталось: иначе непонятно,
// происходит ли вообще что-нибудь.
if (status.state === "restarting" && status.restart_in_sec > 0) {
label = t("state.restartIn", { sec: status.restart_in_sec });
}
$("#status-text").textContent = label;
$("#status-dot").className = `dot ${status.state}`;
$("#ov-state").textContent = label;
$("#ov-profile").textContent = status.profile_title
? t("overview.profile", { title: status.profile_title })
: "";
// Процесс уже запущен, но мир грузится минутами, и всё это время сервер
// никого не пустит: пишем об этом прямо, иначе «Работает» вводит в
// заблуждение.
const starting = status.state === "starting";
$("#ov-note").textContent = starting ? t("overview.starting") : "";
$("#status-pill").title = starting ? t("overview.starting") : "";
$("#ov-uptime").textContent =
formatUptime(status.uptime_sec, starting ? "overview.loading" : "overview.uptime");
$("#ov-pid").textContent = status.pid ? `PID ${status.pid}` : "";
$("#ov-lastexit").textContent =
status.state === "stopped" && status.last_exit
? t("overview.lastExit", { reason: 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
? t("overview.memoryOf", {
free: formatBytes(host.mem_available_bytes),
total: formatBytes(host.mem_total_bytes),
})
: "—";
$("#ov-load").textContent = host.cpu_count
? t("overview.loadCores", { load: (host.load1 || 0).toFixed(2), count: host.cpu_count })
: (host.load1 || 0).toFixed(2);
$("#ov-disk").textContent = host.disk_total_bytes
? t("overview.memoryOf", {
free: formatBytes(host.disk_free_bytes),
total: formatBytes(host.disk_total_bytes),
})
: "—";
// Кнопка называет то, что произойдёт: на чистой машине это установка, на
// готовой — обновление.
serverInstalled = Boolean(status.installed);
$("#install-state").textContent = t(serverInstalled ? "install.installed" : "install.missing");
$("#btn-install").textContent = t(serverInstalled ? "install.update" : "install.install");
$("#install-hint").textContent = t(serverInstalled ? "install.hintUpdate" : "install.hintInstall");
const running = status.state !== "stopped";
const waitingRestart = status.state === "restarting";
restartPending = waitingRestart;
// В ожидании автоподъёма обе кнопки осмысленны: «Запустить» поднимает
// сервер сразу, «Остановить» отменяет перезапуск.
$("#btn-start").disabled = (running && !waitingRestart) || !status.installed;
$("#btn-stop").disabled = !running;
$("#btn-stop").textContent = t(waitingRestart ? "btn.cancelRestart" : "btn.stop");
$("#btn-restart").disabled = !status.installed;
$("#btn-install").disabled = running;
// Консоль есть только у живого процесса: у остановленного сервера нет stdin,
// и команда всё равно вернулась бы ошибкой.
const consoleInput = $("#console-command");
const consoleButton = $("#console-form").querySelector("button[type=submit]");
// Процесс есть только в этих двух состояниях; в ожидании перезапуска писать
// некуда, хотя формально сервер и не «остановлен».
const consoleReady = status.state === "running" || status.state === "starting";
consoleInput.disabled = !consoleReady;
consoleButton.disabled = !consoleReady;
consoleInput.placeholder = t(consoleReady ? "console.placeholder" : "console.placeholderOffline");
// Профили делят игровые порты и файл запуска, поэтому переключать их можно
// только на остановленном сервере.
const picker = $("#profile-select");
picker.disabled = running && !waitingRestart;
picker.title = t(running ? "profiles.pickerLocked" : "profiles.pickerTitle");
// Активный профиль мог смениться в другой вкладке браузера.
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(t("task.done", { name: 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", restartPending ? "" : t("confirm.stop"));
});
$("#btn-restart").addEventListener("click", () =>
serverAction("/api/server/restart", t("confirm.restart")));
$("#btn-install").addEventListener("click", async () => {
// Тот же вопрос, что и на кнопке: «установить» и «обновить» — разные вещи
// по ощущениям, хотя команда SteamCMD одна.
if (!confirm(t(serverInstalled ? "install.confirmUpdate" : "install.confirmInstall"))) return;
try {
await post("/api/install");
ok(t("install.started"));
refreshStatus();
} catch (error) {
fail(error);
}
});
// Строчные ссылки на файлы модов: лечит FileNotFoundException в анимациях на
// файловых системах, различающих регистр. Задача фоновая — вывод идёт в консоль.
$("#btn-mods-casefix").addEventListener("click", async () => {
if (!confirm(t("mods.caseConfirm"))) return;
try {
await post("/api/mods/casefix");
ok(t("mods.caseStarted"));
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;
// Метка запуска менеджера: номера строк живут в его памяти и после
// перезапуска начинаются заново.
let logEpoch = null;
const MAX_CONSOLE_LINES = 3000;
function openConsoleStream() {
closeConsoleStream();
eventSource = new EventSource(`/api/logs/stream?since=${lastSeq}`);
eventSource.addEventListener("meta", (event) => handleConsoleMeta(JSON.parse(event.data)));
eventSource.onmessage = (event) => appendConsoleLine(JSON.parse(event.data));
eventSource.onerror = () => {
// Браузер переподключается сам; сообщать об этом каждый раз не нужно.
};
}
// handleConsoleMeta ловит перезапуск менеджера: без сброса счётчика вкладка
// приняла бы новые строки за уже показанные и осталась бы пустой до
// перезагрузки страницы.
function handleConsoleMeta(meta) {
if (logEpoch === meta.epoch) return;
const restarted = logEpoch !== null;
logEpoch = meta.epoch;
lastSeq = 0;
if (!restarted) return;
clearConsoleScreen();
queueConsoleLine("manager", t("console.managerRestarted"));
// Переоткрываем поток: этот запрашивал историю с прежнего номера, а её у
// нового запуска нет.
openConsoleStream();
}
function closeConsoleStream() {
if (eventSource) eventSource.close();
eventSource = null;
}
function appendConsoleLine(line) {
if (line.seq <= lastSeq) return;
lastSeq = line.seq;
queueConsoleLine(line.stream, line.text);
}
// Загрузка мира со сборкой модов печатает десятки тысяч строк за минуты:
// каждое исключение — стек на два десятка строк. Рисовать их по одной нельзя:
// прокрутка вниз после каждой строки заставляет браузер пересчитывать раскладку
// всего списка, и вкладка встаёт колом на десятки секунд. Поэтому строки
// копятся и выводятся пачкой раз в кадр.
let consoleQueue = [];
let consoleFrame = 0;
// queueConsoleLine ставит строку в очередь на отрисовку. Отдельно от нумерации:
// свои сообщения панель показывает без номера из буфера менеджера.
function queueConsoleLine(stream, text) {
consoleQueue.push({ stream, text });
// На скрытой вкладке кадры не идут, и очередь росла бы без предела: держим
// столько, сколько всё равно поместится на экране.
if (consoleQueue.length > MAX_CONSOLE_LINES * 2) {
consoleQueue = consoleQueue.slice(-MAX_CONSOLE_LINES);
}
if (!consoleFrame) consoleFrame = requestAnimationFrame(flushConsoleQueue);
}
function flushConsoleQueue() {
consoleFrame = 0;
const pending = consoleQueue;
consoleQueue = [];
if (!pending.length) return;
const output = $("#console-output");
const filter = $("#console-filter").value.trim().toLowerCase();
// Из потопа сохранится всё равно только хвост — остальные узлы незачем даже
// создавать.
const from = Math.max(0, pending.length - MAX_CONSOLE_LINES);
const batch = document.createDocumentFragment();
for (let i = from; i < pending.length; i++) {
const { stream, text } = pending[i];
const el = document.createElement("div");
el.className = `line-${stream}`;
el.textContent = text;
el.dataset.text = text.toLowerCase();
if (filter && !el.dataset.text.includes(filter)) el.hidden = true;
batch.appendChild(el);
}
output.appendChild(batch);
while (output.childElementCount > MAX_CONSOLE_LINES) {
output.removeChild(output.firstElementChild);
}
// Прокрутка — один раз на пачку: именно она и стоит дорого.
if ($("#console-autoscroll").checked) {
output.scrollTop = output.scrollHeight;
}
}
// clearConsoleScreen убирает и то, что уже нарисовано, и то, что ждёт кадра.
function clearConsoleScreen() {
consoleQueue = [];
$("#console-output").innerHTML = "";
}
$("#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", () => {
// Чистим только экран: история остаётся в буфере менеджера.
clearConsoleScreen();
});
// История отправленных команд: та же логика, что и в обычном терминале.
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] ?? "";
});
// ---------------------------------------------------------------------------
// Конфиг сервера
// ---------------------------------------------------------------------------
// Подписей и описаний параметров — сотни на каждый язык, и держать их в коде
// панели незачем: они лежат отдельными файлами и нужны только двум вкладкам.
let paramHints = null;
async function loadHints() {
if (paramHints) return paramHints;
try {
const response = await fetch(`/hints-${LANG}.json`, { credentials: "same-origin" });
paramHints = response.ok ? await response.json() : {};
} catch (error) {
// Без словаря панель остаётся рабочей: параметры покажутся по именам ключей.
paramHints = {};
}
paramHints.config = paramHints.config || {};
paramHints.sandbox = paramHints.sandbox || {};
return paramHints;
}
const BOOLEAN_VALUES = new Set(["true", "false"]);
// Категории параметров servertest.ini. Порядок важен: в нём и рисуются
// разделы, а всё, чего здесь нет, попадает в «Прочее» — конфиг PZ меняется от
// версии к версии, и новые ключи не должны пропадать со страницы.
const CONFIG_GROUPS = [
{
titleKey: "group.config.general",
keys: ["PublicName", "PublicDescription", "Public", "Open", "MaxPlayers",
"ServerWelcomeMessage", "ResetID", "ServerPlayerID", "Seed",
"server_browser_announced_ip", "PublicServerIP", "ServerBrowserAnnouncedIP"],
},
{
titleKey: "group.config.access",
keys: ["Password", "MaxAccountsPerUser", "DenyLoginOnOverloadedServer",
"AutoCreateUserInWhiteList", "DropOffWhiteListAfterDeath", "LoginQueueEnabled",
"LoginQueueConnectTimeout", "AllowCoop", "AllowNonAsciiUsername",
"SteamScoreboard", "SteamVAC", "DoLuaChecksum"],
},
{
titleKey: "group.config.network",
keys: ["DefaultPort", "UDPPort", "RCONPort", "RCONPassword", "PingLimit", "UPnP",
"MaxPacketsPerSecond", "ClientCommandFilter", "MultiplayerStatisticsPeriod"],
},
{
titleKey: "group.config.pvp",
keys: ["PVP", "PVPLogToolChat", "PVPLogToolFile", "SafetySystem", "ShowSafety",
"SafetyToggleTimer", "SafetyCooldownTimer", "SafetyDisconnectDelay",
"PVPMeleeDamageModifier", "PVPFirearmDamageModifier", "PVPMeleeWhileHitReaction",
"KnockedDownAllowed", "SneakModeHideFromOtherPlayers", "PlayerBumpPlayer",
"UsePhysicsHitReaction", "CarEngineAttractionModifier"],
},
{
titleKey: "group.config.world",
keys: ["Map", "SpawnPoint", "SpawnItems", "SaveWorldEveryMinutes", "PauseEmpty",
"NoFire", "AllowDestructionBySledgehammer", "SledgehammerOnlyInSafehouse",
"PlayerRespawnWithSelf", "PlayerRespawnWithOther", "FastForwardMultiplier",
"RemovePlayerCorpsesOnCorpseRemoval", "TrashDeleteAll", "BloodSplatLifespanDays",
"ItemNumbersLimitPerContainer", "SafehousePreventsLootRespawn",
"ConstructionPreventsLootRespawn", "HoursForLootRespawn", "MaxItemsForLootRespawn",
"SwitchZombiesOwnershipEachUpdate", "UltraSpeedDoesnotAffectToAnimals",
"SleepAllowed", "SleepNeeded", "SpeedLimit"],
},
{
titleKey: "group.config.backups",
keys: ["BackupsCount", "BackupsOnStart", "BackupsOnVersionChange", "BackupsPeriod"],
},
{
titleKey: "group.config.mods",
keys: ["Mods", "WorkshopItems"],
},
{
titleKey: "group.config.chat",
keys: ["GlobalChat", "ChatStreams", "VoiceEnable", "VoiceMinDistance", "VoiceMaxDistance",
"Voice3D", "ChatMessageCharacterLimit", "ChatMessageSlowModeTime", "AnnounceDeath",
"AnnounceAnimalDeath", "MinutesPerPage", "BanKickGlobalSound"],
},
{
titleKey: "group.config.badwords",
keys: ["BadWordListFile", "GoodWordListFile", "BadWordPolicy", "BadWordReplacement"],
},
{
titleKey: "group.config.visibility",
keys: ["DisplayUserName", "ShowFirstAndLastName", "UsernameDisguises",
"HideDisguisedUserName", "MouseOverToSeeDisplayName", "HidePlayersBehindYou",
"MapRemotePlayerVisibility", "ShowCoordinates", "DisableScoreboard",
"HideAdminsInPlayerList"],
},
{
titleKey: "group.config.safehouse",
keys: ["PlayerSafehouse", "AdminSafehouse", "SafehouseAllowTrepass", "SafehouseAllowFire",
"SafehouseAllowLoot", "SafehouseAllowRespawn", "SafehouseDaySurvivedToClaim",
"SafeHouseRemovalTime", "SafehouseAllowNonResidential", "SafehouseDisableDisguises",
"MaxSafezoneSize", "DisableSafehouseWhenOwnerConnected",
"DisableSafehouseWhenPlayerConnected"],
},
{
titleKey: "group.config.factions",
keys: ["Faction", "FactionDaySurvivedToCreate", "FactionPlayersRequiredForTag",
"War", "WarStartDelay", "WarDuration", "WarSafehouseHitPoints"],
},
{
titleKey: "group.config.radio",
keys: ["DisableRadioStaff", "DisableRadioAdmin", "DisableRadioGM", "DisableRadioOverseer",
"DisableRadioModerator", "DisableRadioInvisible"],
},
{
titleKey: "group.config.discord",
keys: ["DiscordEnable", "DiscordToken", "DiscordChannel", "DiscordChannelID",
"DiscordChatChannel", "DiscordLogChannel", "DiscordCommandChannel", "WebhookAddress"],
},
{
titleKey: "group.config.anticheat",
keys: ["AntiCheatSafety", "AntiCheatSpeed", "AntiCheatNoClip", "AntiCheatHit",
"AntiCheatPacketException", "AntiCheatPermission", "AntiCheatXP", "AntiCheatSafeHouse",
"AntiCheatPlayer", "AntiCheatChecksum", "KickFastPlayers", "ClientActionLogs", "PerkLogs"],
// Нумерованных проверок два десятка, перечислять их поимённо бессмысленно.
prefixes: ["AntiCheatProtectionType"],
},
{
titleKey: "group.config.vehicles",
keys: ["DisableVehicleTowing", "DisableTrailerTowing", "DisableBurntTowing"],
},
];
// Категории верхнеуровневых параметров песочницы. Вложенные (ZombieLore.Speed
// и подобные) группируются сами — по имени таблицы, в которой лежат.
const SANDBOX_GROUPS = [
{
titleKey: "group.sandbox.zombies",
keys: ["Zombies", "Distribution", "ZombiesRespawn", "ZombieRespawn", "ZombieMigrate",
"ZombieVoronoiNoise", "ZombieAttractionMultiplier", "ZombieHealthImpact",
"ZombiePopLootEffect", "MultiHitZombies", "RearVulnerability", "SirenEffectsZombies",
"RallyGroupSize", "RallyTravelDistance", "RallyGroupSeparation", "RallyGroupRadius",
"HoursForCorpseRemoval", "DecayingCorpseHealthImpact", "BloodLevel",
"BloodSplatLifespanDays"],
},
{
titleKey: "group.sandbox.world",
keys: ["DayLength", "StartYear", "StartMonth", "StartDay", "StartTime", "DayNightCycle",
"NightLength", "NightDarkness", "TimeSinceApo", "WaterShut", "ElecShut",
"WaterShutModifier", "ElecShutModifier", "Alarm", "AlarmDecay", "AlarmDecayModifier",
"LockedHouses", "ErosionSpeed", "ErosionDays", "EndRegen", "Helicopter", "MetaEvent",
"SleepingEvent", "AnnotatedMapChance", "VehicleStoryChance", "ZoneStoryChance",
"SurvivorHouseChance", "MaximumLootedBuildingRooms"],
},
{
titleKey: "group.sandbox.weather",
keys: ["ClimateCycle", "FogCycle", "Temperature", "TemperatureModifier", "Rain",
"RainModifier", "MaxFogIntensity", "MaxRainFxIntensity", "EnableSnowOnGround"],
},
{
titleKey: "group.sandbox.loot",
keys: ["LootRespawn", "SeenHoursPreventLootRespawn", "HoursForLootRespawn",
"MaxItemsForLootRespawn", "ConstructionPreventsLootRespawn", "RollsMultiplier",
"LootItemRemovalList", "RemoveStoryLoot", "RemoveZombieLoot", "WorldItemRemovalList",
"HoursForWorldItemRemoval", "ItemRemovalListBlacklistToggle", "MaximumLooted",
"DaysUntilMaximumLooted", "RuralLooted", "MaximumDiminishedLoot",
"DaysUntilMaximumDiminishedLoot", "SkillBookLoot", "RecipeResourceLoot"],
// *Loot, *LootNew и *LootFactor — десятки однотипных ключей.
prefixes: ["Food", "Canned", "Literature", "Medical", "SurvivalGears", "Weapon",
"RangedWeapon", "Ammo", "Mechanics", "Other", "Clothing", "Container", "Key", "Media",
"Memento", "Cookware", "Material", "Farming", "Tool"],
suffixes: ["Loot", "LootNew", "LootFactor"],
},
{
titleKey: "group.sandbox.character",
keys: ["XpMultiplier", "XpMultiplierAffectsPassive", "StatsDecrease", "Endurance",
"Nutrition", "StarterKit", "InjurySeverity", "BoneFracture", "CharacterFreePoints",
"ConstructionBonusPoints", "NightVisionEnabled", "InjuredHealingModifier",
"MuscleStrainFactor", "DiscomfortFactor", "WoundInfectionFactor", "NegativeTraitsPenalty",
"EnablePoisoning", "ClothingDegradation", "AllClothesUnlocked", "NoBlackClothes",
"EasyClimbing", "AttackBlockMovements", "MetaKnowledge", "SeeNotLearntRecipe",
"LiteratureCooldown", "MinutesPerPage", "LevelForMediaXPCutoff",
"LevelForDismantleXPCutoff"],
},
{
titleKey: "group.sandbox.food",
keys: ["FoodRotSpeed", "FridgeFactor", "MaggotSpawn", "EnableTaintedWaterText",
"FishAbundance", "NatureAbundance", "PlantResilience", "PlantAbundance", "Farming",
"FarmingSpeedNew", "FarmingAmountNew", "CompostTime", "KillInsideCrops",
"PlantGrowingSeasons", "PlaceDirtAboveground", "ClayLakeChance", "ClayRiverChance",
"DaysForRottenFoodRemoval", "AidPackage"],
},
{
titleKey: "group.sandbox.animals",
keys: ["MaximumRatIndex", "DaysUntilMaximumRatIndex"],
prefixes: ["Animal"],
},
{
titleKey: "group.sandbox.vehicles",
keys: ["CarSpawnRate", "ChanceHasGas", "InitialGas", "CarGasConsumption", "LockedCar",
"CarGeneralCondition", "CarDamageOnImpact", "DamageToPlayerFromHitByACar", "TrafficJam",
"CarAlarm", "PlayerDamageFromCrash", "SirenShutoffHours", "RecentlySurvivorVehicles",
"EnableVehicles", "VehicleEasyUse", "MassInvasion"],
prefixes: ["FuelStation"],
},
{
titleKey: "group.sandbox.firearms",
keys: [],
prefixes: ["Firearm"],
},
{
titleKey: "group.sandbox.power",
keys: ["AllowExteriorGenerator", "FireSpread", "MaximumFireFuelHours", "LightBulbLifespan"],
prefixes: ["Generator"],
},
{
titleKey: "group.sandbox.service",
keys: ["VERSION"],
},
];
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;
refreshFieldSaveState(SAVE_TARGETS[0]);
return;
}
serverConfigValues = data.values;
const hints = await loadHints();
const descriptions = data.descriptions || {};
const entries = data.keys.map((key) => ({
key,
value: data.values[key],
description: descriptions[key],
}));
renderGroupedFields("#config-fields", entries, CONFIG_GROUPS, hints.config);
applyFieldFilter("#config-filter", "#config-fields");
refreshFieldSaveState(SAVE_TARGETS[0]);
}
// groupEntries раскладывает параметры по разделам. Неизвестные ключи не
// теряются: они собираются в «Прочее» — конфиг PZ меняется от версии к версии.
function groupEntries(entries, groups) {
const byKey = new Map();
entries.forEach((entry) => byKey.set(entry.key, entry));
const result = [];
const used = new Set();
groups.forEach((group) => {
const items = [];
group.keys.forEach((key) => {
const entry = byKey.get(key);
if (entry && !used.has(key)) {
used.add(key);
items.push(entry);
}
});
(group.prefixes || []).forEach((prefix) => {
entries.forEach((entry) => {
if (used.has(entry.key)) return;
// Для лута prefixes перечисляет вид припасов, suffixes — вид ключа
// (FoodLoot, FoodLootNew, RareLootFactor): совпасть должны оба.
const suffixes = group.suffixes;
const matches = suffixes
? suffixes.some((suffix) => entry.key === prefix + suffix)
: entry.key.startsWith(prefix);
if (matches) {
used.add(entry.key);
items.push(entry);
}
});
});
// Суффиксы без своей пары в prefixes — например RareLootFactor.
(group.suffixes || []).forEach((suffix) => {
entries.forEach((entry) => {
if (!used.has(entry.key) && entry.key.endsWith(suffix)) {
used.add(entry.key);
items.push(entry);
}
});
});
if (items.length) result.push({ id: group.titleKey, title: t(group.titleKey), items });
});
// Вложенные параметры песочницы сами собой образуют разделы: у них путь вида
// ZombieLore.Speed.
const nested = new Map();
const rest = [];
entries.forEach((entry) => {
if (used.has(entry.key)) return;
const dot = entry.key.indexOf(".");
if (dot > 0) {
const table = entry.key.slice(0, dot);
if (!nested.has(table)) nested.set(table, []);
nested.get(table).push(entry);
return;
}
rest.push(entry);
});
Array.from(nested.entries())
.sort((a, b) => a[0].localeCompare(b[0]))
.forEach(([table, items]) => {
// Имя таблицы — готовый ключ перевода. Незнакомую показываем как есть:
// песочница пополняется, и новый раздел не должен пропадать.
const key = `group.sandbox.${table}`;
const title = t(key);
result.push({ id: key, title: title === key ? table : title, items });
});
if (rest.length) result.push({ id: "group.other", title: t("group.other"), items: rest });
return result;
}
// Какие разделы человек раскрыл: вкладка перерисовывается при каждом переходе,
// и без этого раскрытый раздел схлопывался бы обратно.
const openedGroups = new Map();
function rememberOpenGroups(containerSelector) {
const opened = new Set();
$$(`${containerSelector} .cfg-group`).forEach((group) => {
if (group.open) opened.add(group.dataset.group);
});
openedGroups.set(containerSelector, opened);
}
// renderGroupedFields рисует разделы: свёрнутые по умолчанию, кроме первого —
// иначе сотня параметров снова превращается в простыню.
function renderGroupedFields(containerSelector, entries, groups, hints) {
const container = $(containerSelector);
container.replaceChildren();
const opened = openedGroups.get(containerSelector);
groupEntries(entries, groups).forEach((group, index) => {
const section = document.createElement("details");
section.className = "cfg-group";
section.dataset.group = group.id;
section.open = opened ? opened.has(group.id) : index === 0;
const summary = document.createElement("summary");
summary.innerHTML =
`${escapeHTML(group.title)} <span class="cfg-count">${group.items.length}</span>`;
section.appendChild(summary);
const fields = document.createElement("div");
fields.className = "fields";
group.items.forEach((entry) => {
// Свой словарь важнее комментария из файла: игра пишет его на языке
// сервера, а он почти всегда английский.
let hint = hints ? hints[entry.key] : null;
// Нумерованные проверки античита описывать поимённо нечем: игра не
// раскрывает, что стоит за каждым номером.
if (!hint && entry.key.startsWith("AntiCheatProtectionType")) {
hint = [
t("config.anticheatLabel", { number: entry.key.replace("AntiCheatProtectionType", "") }),
t("config.anticheatHint"),
];
}
const label = Array.isArray(hint) ? hint[0] : hint;
const ownDescription = Array.isArray(hint) ? hint[1] : null;
// Своё описание и текст из файла дополняют друг друга: в файле игра
// расписывает, что означает каждое значение, и терять это не стоит.
const description = [ownDescription, entry.description]
.filter(Boolean)
.join("\n\n");
fields.appendChild(buildConfigField(entry.key, entry.value, label, description));
});
section.appendChild(fields);
container.appendChild(section);
});
}
// buildConfigField выбирает тип поля по текущему значению: булевы значения
// удобнее переключать списком, числа — числовым полем.
function buildConfigField(key, value, hint, description) {
const wrapper = document.createElement("div");
wrapper.className = "cfg-field";
wrapper.dataset.key = key.toLowerCase();
// Поиск идёт и по описанию: по-русски параметр искать привычнее, чем по
// английскому имени ключа.
wrapper.dataset.hint = `${hint || ""} ${description || ""}`.toLowerCase();
const label = document.createElement("label");
label.textContent = hint || key;
if (description) {
// Полный текст — во всплывающей подсказке: описания бывают в несколько
// строк, и разворачивать их для каждого параметра значит утопить форму.
wrapper.title = description;
}
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 = t(option === "true" ? "config.yes" : "config.no");
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);
if (description) {
const hintLine = document.createElement("div");
hintLine.className = "hint";
// Под полем — первый абзац: остальное (обычно расшифровка значений из
// конфига) читается во всплывающей подсказке.
hintLine.textContent = description.split("\n\n")[0];
wrapper.appendChild(hintLine);
}
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;
}
// Кнопка «Сохранить» включается, только когда есть что сохранять: на пустой
// вкладке (файл ещё не создан игрой) или без правок нажимать её незачем.
const SAVE_TARGETS = [
{ fields: "#config-fields", button: "#btn-config-save" },
{ fields: "#sandbox-fields", button: "#btn-sandbox-save" },
];
function refreshFieldSaveState(target) {
const count = Object.keys(collectChanged(target.fields)).length;
const button = $(target.button);
button.disabled = count === 0;
button.textContent = count ? t("btn.saveCount", { count }) : t("btn.save");
button.title = count ? t("config.changedCount", { count }) : t("config.noChangesTitle");
}
SAVE_TARGETS.forEach((target) => {
// Слушаем на контейнере: поля пересоздаются при каждой загрузке вкладки.
["input", "change"].forEach((event) =>
$(target.fields).addEventListener(event, () => refreshFieldSaveState(target)));
});
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");
});
const target = SAVE_TARGETS.find((t) => t.fields === containerSelector);
if (target) refreshFieldSaveState(target);
}
$("#btn-config-save").addEventListener("click", async () => {
const values = collectChanged("#config-fields");
if (!Object.keys(values).length) {
toast(t("config.noChanges"));
return;
}
try {
await put("/api/config/server", { values });
markSaved("#config-fields");
ok(t("config.saved"));
} 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);
});
// Раздел без совпадений прячем целиком, а с совпадениями — раскрываем:
// искать параметр по свёрнутым разделам бессмысленно.
$$(`${containerSelector} .cfg-group`).forEach((group) => {
const visible = Array.from(group.querySelectorAll(".cfg-field")).filter((f) => !f.hidden);
group.hidden = visible.length === 0;
if (filter) {
group.open = visible.length > 0;
}
const count = group.querySelector(".cfg-count");
if (count) {
count.textContent = filter ? `${visible.length}` : `${group.querySelectorAll(".cfg-field").length}`;
}
});
}
// toggle не всплывает, поэтому слушаем на фазе перехвата.
["#config-fields", "#sandbox-fields"].forEach((selector) =>
$(selector).addEventListener("toggle", () => rememberOpenGroups(selector), true));
$("#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;
refreshFieldSaveState(SAVE_TARGETS[1]);
return;
}
const hints = await loadHints();
const entries = data.entries.map((entry) => ({
key: entry.path,
value: entry.value,
description: entry.comment,
}));
renderGroupedFields("#sandbox-fields", entries, SANDBOX_GROUPS, hints.sandbox);
applyFieldFilter("#sandbox-filter", "#sandbox-fields");
refreshFieldSaveState(SAVE_TARGETS[1]);
}
$("#btn-sandbox-save").addEventListener("click", async () => {
const values = collectChanged("#sandbox-fields");
if (!Object.keys(values).length) {
toast(t("config.noChanges"));
return;
}
try {
await put("/api/config/sandbox", { values });
markSaved("#sandbox-fields");
ok(t("sandbox.saved"));
} catch (error) {
fail(error);
}
});
// ---------------------------------------------------------------------------
// Моды
// ---------------------------------------------------------------------------
// В Project Zomboid список модов задаётся двумя строками конфига:
// WorkshopItems — что скачать из мастерской, Mods — что включить и в каком
// порядке. Панель показывает их одним списком: строка на мод, галочка =
// присутствие в Mods, порядок строк = порядок в Mods.
// Снимок списков на момент загрузки: по нему видно, есть ли несохранённые
// правки. Пустая строка означает, что конфига ещё нет и сохранять нечего.
let modsSnapshot = "";
let mods = {
workshop: [], // WorkshopItems
enabled: [], // Mods, порядок важен
maps: [],
installed: [], // что реально лежит на диске (из mod.info)
details: {}, // сведения из мастерской по Workshop ID
};
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;
modsSnapshot = "";
refreshModsSaveState();
return;
}
mods = {
workshop: data.workshop_items || [],
enabled: data.mods || [],
maps: data.map || [],
installed: data.installed || [],
details: mods.details,
};
$("#map-list").value = mods.maps.join("\n");
modsSnapshot = modsState();
renderMods();
lookupWorkshopTitles().catch(() => {
/* без интернета останутся идентификаторы — это не ошибка панели */
});
}
// buildModRows сводит три источника в один список строк: каталог с диска,
// включённые моды и пакеты мастерской, файлы которых ещё не скачаны.
function buildModRows() {
const rows = [];
const byModID = new Map();
const push = (row) => {
byModID.set(row.modID, row);
rows.push(row);
};
// 1. Моды, найденные на диске. Показываем только те, чей пакет есть в
// списке скачиваемых, плюс локальные (без Workshop ID).
const wanted = new Set(mods.workshop);
mods.installed.forEach((mod) => {
if (mod.workshop_id && !wanted.has(mod.workshop_id)) return;
push({
modID: mod.mod_id,
workshopID: mod.workshop_id,
name: mod.name || mod.mod_id,
note: mod.workshop_id ? "" : t("mods.local"),
installed: true,
});
});
// 2. Включённые моды, файлов которых мы не нашли: либо сервер их ещё не
// скачал, либо мод пропал из мастерской.
mods.enabled.forEach((modID) => {
if (byModID.has(modID)) return;
push({
modID,
workshopID: "",
name: modID,
note: t("mods.notFound"),
missing: true,
installed: false,
});
});
// 3. Пакеты мастерской, из которых пока ничего не установлено.
const covered = new Set(rows.map((row) => row.workshopID).filter(Boolean));
mods.workshop.forEach((workshopID) => {
if (covered.has(workshopID)) return;
const info = mods.details[workshopID] || {};
rows.push({
modID: "",
workshopID,
name: info.title || t("mods.unnamed", { id: workshopID }),
note: info.error || t("mods.notDownloaded"),
missing: Boolean(info.error),
installed: false,
});
});
// Включённые идут первыми и в том порядке, в каком их грузит игра.
const order = (row) => {
const index = mods.enabled.indexOf(row.modID);
return index === -1 ? Number.MAX_SAFE_INTEGER : index;
};
rows.sort((a, b) => {
const diff = order(a) - order(b);
return diff !== 0 ? diff : a.name.localeCompare(b.name, LANG);
});
return rows;
}
// modsState сводит списки к строке — так проще всего заметить и переключённую
// галочку, и переставленный порядок загрузки.
function modsState() {
return JSON.stringify({
enabled: mods.enabled,
workshop: mods.workshop,
maps: $("#map-list").value.split("\n").map((line) => line.trim()).filter(Boolean),
});
}
function refreshModsSaveState() {
const button = $("#btn-mods-save");
const changed = modsSnapshot !== "" && modsState() !== modsSnapshot;
button.disabled = !changed;
button.title = t(changed ? "mods.unsaved" : "config.noChangesTitle");
}
function renderMods() {
const filter = $("#mod-filter").value.trim().toLowerCase();
const onlyEnabled = $("#mod-only-enabled").checked;
const rows = buildModRows();
const visible = rows.filter((row) => {
if (onlyEnabled && !mods.enabled.includes(row.modID)) return false;
if (!filter) return true;
return `${row.name} ${row.modID} ${row.workshopID}`.toLowerCase().includes(filter);
});
$("#mod-summary").textContent = t("mods.summary", {
total: rows.length,
enabled: mods.enabled.length,
workshop: mods.workshop.length,
});
refreshModsSaveState();
$("#mod-list").innerHTML = visible
.map((row) => {
const on = mods.enabled.includes(row.modID);
const info = mods.details[row.workshopID] || {};
const preview = info.preview
? `<img src="${escapeHTML(info.preview)}" alt="">`
: `<span class="mod-thumb"></span>`;
const noteClass = row.missing ? "error" : "muted";
const workshopLink = row.workshopID
? `<a href="https://steamcommunity.com/sharedfiles/filedetails/?id=${escapeHTML(row.workshopID)}"
target="_blank" rel="noopener noreferrer">${escapeHTML(row.workshopID)}</a>`
: "";
// Перетаскивать можно только включённые моды: порядок хранится в Mods.
return `<li class="mod-row${on ? " enabled" : ""}" data-mod="${escapeHTML(row.modID)}"
data-workshop="${escapeHTML(row.workshopID)}" ${on ? 'draggable="true"' : ""}>
<span class="drag-handle" title="${t(on ? "mods.dragTitle" : "mods.dragDisabled")}">⠿</span>
<input type="checkbox" class="mod-toggle" ${on ? "checked" : ""}
${row.modID ? "" : "disabled"} title="${t("mods.toggleTitle")}">
${preview}
<div class="mod-title">
<b>${escapeHTML(row.name)}</b>
<small>${row.modID ? escapeHTML(row.modID) : t("mods.unknownID")}${workshopLink ? " · " + workshopLink : ""}</small>
${row.note ? `<small class="${noteClass}">${escapeHTML(row.note)}</small>` : ""}
</div>
<button class="btn btn-sm btn-ghost" data-move="-1" ${on ? "" : "disabled"} title="${t("mods.up")}">↑</button>
<button class="btn btn-sm btn-ghost" data-move="1" ${on ? "" : "disabled"} title="${t("mods.down")}">↓</button>
<button class="btn btn-sm btn-danger" data-remove="1">${t("mods.remove")}</button>
</li>`;
})
.join("") || `<li class="muted">${t("mods.empty")}</li>`;
}
// LOOKUP_BATCH — сколько идентификаторов уходит в Steam за раз. Сервер не
// принимает больше двух сотен, а у крупной сборки их втрое больше: одним
// запросом список названий не пришёл бы вовсе.
const LOOKUP_BATCH = 100;
async function lookupWorkshopTitles() {
if (!mods.workshop.length) return;
let failure = null;
let received = 0;
for (let from = 0; from < mods.workshop.length; from += LOOKUP_BATCH) {
const ids = mods.workshop.slice(from, from + LOOKUP_BATCH);
try {
const data = await post("/api/mods/lookup", { ids });
(data.items || []).forEach((item) => {
mods.details[item.id] = item;
received++;
});
// Рисуем по мере ответов: у сотен модов ожидание заметно.
renderMods();
} catch (error) {
failure = error;
}
}
// Без интернета названий не будет, и это не ошибка панели — но молчать тоже
// нельзя: иначе непонятно, почему вместо названий одни номера.
if (failure && received === 0) {
toast(t("mods.titlesFailed", { reason: failure.message }), "err");
}
}
// moveEnabled двигает мод в списке загрузки на delta позиций.
function moveEnabled(modID, delta) {
const from = mods.enabled.indexOf(modID);
const to = from + delta;
if (from === -1 || to < 0 || to >= mods.enabled.length) return;
mods.enabled.splice(to, 0, mods.enabled.splice(from, 1)[0]);
renderMods();
}
$("#mod-list").addEventListener("change", (event) => {
const toggle = event.target.closest(".mod-toggle");
if (!toggle) return;
const modID = toggle.closest(".mod-row").dataset.mod;
if (!modID) return;
if (toggle.checked) {
// Новый мод грузится последним: обычно так и надо, а порядок правится
// перетаскиванием.
if (!mods.enabled.includes(modID)) mods.enabled.push(modID);
} else {
mods.enabled = mods.enabled.filter((id) => id !== modID);
}
renderMods();
});
$("#mod-list").addEventListener("click", (event) => {
const row = event.target.closest(".mod-row");
if (!row) return;
const move = event.target.closest("button[data-move]");
if (move) {
moveEnabled(row.dataset.mod, Number(move.dataset.move));
return;
}
if (!event.target.closest("button[data-remove]")) return;
const { mod: modID, workshop: workshopID } = row.dataset;
// Убираем и пакет мастерской, и все включённые моды из него: иначе сервер
// продолжит скачивать то, что уже не нужно.
if (workshopID) {
mods.workshop = mods.workshop.filter((id) => id !== workshopID);
const fromPackage = mods.installed
.filter((mod) => mod.workshop_id === workshopID)
.map((mod) => mod.mod_id);
mods.enabled = mods.enabled.filter((id) => id !== modID && !fromPackage.includes(id));
} else {
mods.enabled = mods.enabled.filter((id) => id !== modID);
}
renderMods();
});
// Перетаскивание порядка загрузки.
let dragged = null;
$("#mod-list").addEventListener("dragstart", (event) => {
const row = event.target.closest(".mod-row");
if (!row || !row.classList.contains("enabled")) return;
dragged = row.dataset.mod;
row.classList.add("dragging");
event.dataTransfer.effectAllowed = "move";
});
$("#mod-list").addEventListener("dragend", () => {
dragged = null;
$$(".mod-row").forEach((row) => row.classList.remove("dragging", "drop-target"));
});
$("#mod-list").addEventListener("dragover", (event) => {
if (!dragged) return;
const row = event.target.closest(".mod-row.enabled");
if (!row || row.dataset.mod === dragged) return;
event.preventDefault();
$$(".mod-row").forEach((el) => el.classList.toggle("drop-target", el === row));
});
$("#mod-list").addEventListener("drop", (event) => {
if (!dragged) return;
const row = event.target.closest(".mod-row.enabled");
if (!row) return;
event.preventDefault();
const from = mods.enabled.indexOf(dragged);
const to = mods.enabled.indexOf(row.dataset.mod);
if (from === -1 || to === -1) return;
mods.enabled.splice(to, 0, mods.enabled.splice(from, 1)[0]);
dragged = null;
renderMods();
});
// Игра умеет делиться набором модов строкой вида «Название:ModA;ModB;…».
// Workshop ID в ней нет, поэтому список задаёт только порядок загрузки: файлы
// сервер возьмёт из пакетов мастерской, которые уже перечислены в конфиге.
function parseModList(text) {
// Перед двоеточием стоит имя набора; у самих модов двоеточий не бывает.
const colon = text.indexOf(":");
const body = colon >= 0 ? text.slice(colon + 1) : text;
const seen = new Set();
const out = [];
body.split(/[;\n\r]+/).forEach((raw) => {
const id = raw.trim();
if (!id || seen.has(id)) return;
seen.add(id);
out.push(id);
});
return out;
}
$("#mod-list-form").addEventListener("submit", (event) => {
event.preventDefault();
const ids = parseModList($("#mod-list-input").value);
if (!ids.length) {
toast(t("mods.listEmpty"), "err");
return;
}
if (!confirm(t("mods.listConfirm", { count: ids.length }))) return;
mods.enabled = ids;
// Пакет мастерской известен для тех модов, чьи файлы уже лежат на диске:
// добавляем их, чтобы сервер не потерял мод при следующем запуске.
mods.installed.forEach((mod) => {
if (!ids.includes(mod.mod_id) || !mod.workshop_id) return;
if (!mods.workshop.includes(mod.workshop_id)) mods.workshop.push(mod.workshop_id);
});
const installed = new Set(mods.installed.map((mod) => mod.mod_id));
const missing = ids.filter((id) => !installed.has(id)).length;
$("#mod-list-input").value = "";
renderMods();
ok(t("mods.listDone", { count: ids.length }));
if (missing) toast(t("mods.listNoFiles", { count: missing }));
});
$("#map-list").addEventListener("input", refreshModsSaveState);
$("#mod-filter").addEventListener("input", renderMods);
$("#mod-only-enabled").addEventListener("change", renderMods);
// extractWorkshopID принимает как голый идентификатор, так и ссылку на
// страницу мастерской.
function extractWorkshopID(input) {
const match = input.match(/(\d{5,})/);
return match ? match[1] : "";
}
$("#mod-add-form").addEventListener("submit", (event) => {
event.preventDefault();
const id = extractWorkshopID($("#mod-input").value.trim());
if (!id) {
toast(t("mods.badInput"), "err");
return;
}
if (mods.workshop.includes(id)) {
toast(t("mods.duplicate"));
return;
}
mods.workshop.push(id);
$("#mod-input").value = "";
renderMods();
lookupWorkshopTitles().catch(() => {});
});
$("#btn-add-collection").addEventListener("click", async () => {
const input = $("#mod-input").value.trim();
if (!input) {
toast(t("mods.needCollection"), "err");
return;
}
try {
const data = await post("/api/mods/collection", { id: input });
const items = data.items || [];
let added = 0;
items.forEach((item) => {
mods.details[item.id] = item;
if (!mods.workshop.includes(item.id)) {
mods.workshop.push(item.id);
added++;
}
});
$("#mod-input").value = "";
renderMods();
ok(added
? t("mods.collectionAdded", { added, total: items.length })
: t("mods.collectionKnown", { total: items.length }));
} catch (error) {
fail(error);
}
});
$("#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.enabled,
workshop_items: mods.workshop,
map: maps,
});
modsSnapshot = modsState();
refreshModsSaveState();
ok(t("mods.saved"));
} catch (error) {
fail(error);
}
});
// ---------------------------------------------------------------------------
// Профили серверов
// ---------------------------------------------------------------------------
let profiles = { list: [], active: "", default_startup_timeout_min: 0 };
async function loadProfiles() {
const data = await get("/api/profiles");
profiles = {
list: data.profiles || [],
active: data.active,
// Значение по умолчанию показываем подсказкой в пустом поле.
default_startup_timeout_min: data.default_startup_timeout_min || 0,
};
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">${t("profiles.active")}</span>` : "";
const state = p.has_config
? t(p.world_ready ? "profiles.worldReady" : "profiles.configured")
: t("profiles.never");
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>${t("profiles.name")}</span>
<input type="text" data-field="title" value="${escapeHTML(p.title || "")}">
</label>
<label class="field">
<span>${t("profiles.memoryMax")}</span>
<input type="text" data-field="java_memory" value="${escapeHTML(p.java_memory || "")}">
<small>${escapeHTML(memoryHint())}</small>
</label>
<label class="field">
<span>${t("profiles.memoryMin")}</span>
<input type="text" data-field="java_memory_min" value="${escapeHTML(p.java_memory_min || "")}"
placeholder="${t("profiles.memoryMinPlaceholder")}">
<small>${t("profiles.memoryMinHint")}</small>
</label>
<label class="field">
<span>${t("profiles.startupTimeout")}</span>
<input type="number" min="1" max="180" data-field="startup_timeout_min"
value="${escapeHTML(String(p.startup_timeout_min || ""))}"
placeholder="${escapeHTML(String(profiles.default_startup_timeout_min || ""))}">
<small>${t("profiles.startupTimeoutHint")}</small>
</label>
<label class="field">
<span>${t("profiles.adminPassword")}</span>
<input type="password" data-field="admin_password" autocomplete="new-password"
placeholder="${t(p.has_admin_password ? "profiles.adminPasswordSet" : "profiles.adminPasswordUnset")}">
</label>
<div class="profile-actions">
<button class="btn btn-sm btn-primary" data-action="save">${t("btn.save")}</button>
<button class="btn btn-sm" data-action="activate" ${p.active ? "disabled" : ""}>${t("profiles.activate")}</button>
<button class="btn btn-sm btn-danger" data-action="delete" ${p.active ? "disabled" : ""}>${t("btn.delete")}</button>
</div>
</div>`;
})
.join("");
}
// renderCopySource предлагает копировать настройки только с тех профилей, у
// которых конфиг уже создан игрой.
// memoryHint подсказывает разумный потолок: JVM должна поместиться в свободную
// память, иначе сервер не поднимется вовсе.
function memoryHint() {
if (!hostStats.mem_total_bytes) return t("profiles.memoryExample");
return t("profiles.memoryHost", {
total: formatBytes(hostStats.mem_total_bytes),
free: formatBytes(hostStats.mem_available_bytes),
});
}
function renderCopySource() {
$("#new-profile-copy").innerHTML =
`<option value="">${t("profiles.copyNone")}</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(t("profiles.switched"));
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"),
java_memory_min: field("java_memory_min"),
startup_timeout_min: Number(field("startup_timeout_min")) || 0,
admin_password: field("admin_password"),
});
ok(t("profiles.saved"));
await loadProfiles();
break;
case "activate":
await switchProfile(id);
break;
case "delete":
if (!confirm(t("profiles.confirmDelete", { id }))) return;
await del(`/api/profiles/${encodeURIComponent(id)}`);
ok(t("profiles.deleted"));
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(t("profiles.created"));
await loadProfiles();
} catch (error) {
fail(error);
}
});
// ---------------------------------------------------------------------------
// Импорт пресета сборки
// ---------------------------------------------------------------------------
// Сборки модов раздают готовый комплект настроек одним архивом. Панель сначала
// показывает, что в нём нашла, и только потом что-то пишет: пресет заменяет
// конфиг профиля целиком, и делать это вслепую нельзя.
let presetSets = [];
// Файл отправляется дважды — на разбор и на импорт. Держать его на сервере
// между запросами не за чем: он уже лежит в браузере.
async function postFile(path, form) {
const response = await fetch(path, {
method: "POST",
headers: { [CSRF_HEADER]: "1" },
credentials: "same-origin",
body: form,
});
if (response.status === 401) {
showAuth(false);
throw new Error(t("auth.expired"));
}
const text = await response.text();
const data = text ? JSON.parse(text) : {};
if (!response.ok) {
throw new Error(data.error || t("error.http", { status: response.status }));
}
return data;
}
function presetFile() {
const input = $("#preset-file");
return input.files && input.files[0] ? input.files[0] : null;
}
$("#preset-file").addEventListener("change", async () => {
const file = presetFile();
$("#preset-details").hidden = true;
if (!file) return;
const form = new FormData();
form.append("file", file);
toast(t("import.reading"));
try {
const data = await postFile("/api/presets/preview", form);
presetSets = data.presets || [];
renderPresetSets();
} catch (error) {
fail(error);
}
});
function renderPresetSets() {
if (!presetSets.length) return;
const picker = $("#preset-set");
picker.innerHTML = presetSets
.map((set) => {
// Подпись сразу говорит, что в наборе: пресет песочницы без конфига
// сервера не принесёт ни модов, ни карт.
const note = set.has_ini
? t("import.setServer", { mods: set.mods })
: t("import.setSandbox");
return `<option value="${escapeHTML(set.name)}">${escapeHTML(`${set.name}${note}`)}</option>`;
})
.join("");
// Выбор нужен, только когда в архиве несколько наборов.
$("#preset-pick").hidden = presetSets.length < 2;
$("#preset-details").hidden = false;
renderPresetSummary();
}
function currentPreset() {
const name = $("#preset-set").value;
return presetSets.find((set) => set.name === name) || presetSets[0];
}
function renderPresetSummary() {
const set = currentPreset();
if (!set) return;
const lines = [];
if (set.server_name) lines.push(t("import.server", { name: set.server_name }));
if (set.has_ini) {
lines.push(t("import.mods", { mods: set.mods, workshop: set.workshop_items }));
if ((set.maps || []).length) lines.push(t("import.maps", { maps: set.maps.join(", ") }));
} else if (!set.has_sandbox_preset) {
// У пресета песочницы конфига сервера и не должно быть — про него сказано
// отдельной строкой ниже.
lines.push(t("import.noINI"));
}
if (set.has_sandbox_preset) {
// Пресет одиночной игры — не файл сервера: он переносится по значениям.
lines.push(t("import.sandboxPreset", { count: set.sandbox_settings }));
lines.push(t("import.sandboxPresetNote"));
} else {
lines.push(t("import.counts", { settings: set.settings, sandbox: set.sandbox_settings }));
}
if (set.has_ini || set.has_spawn_regions) {
lines.push(t(set.has_spawn_regions ? "import.spawnYes" : "import.spawnNo"));
}
$("#preset-summary").replaceChildren(...lines.map((line) => {
const el = document.createElement("div");
el.textContent = line;
return el;
}));
// Идентификатор профиля предлагаем по имени файлов из архива: обычно оно и
// называет сборку.
$("#preset-id").value = set.name.replace(/[^A-Za-z0-9_-]/g, "-").slice(0, 32);
$("#preset-profile").innerHTML = profiles.list
.map((p) => `<option value="${escapeHTML(p.id)}">${escapeHTML(p.title || p.id)}</option>`)
.join("");
$("#preset-profile").value = profiles.active;
}
$("#preset-set").addEventListener("change", renderPresetSummary);
$("#preset-target").addEventListener("change", () => {
const existing = $("#preset-target").value === "existing";
$("#preset-new-fields").hidden = existing;
$("#preset-existing-fields").hidden = !existing;
});
$("#btn-preset-import").addEventListener("click", async () => {
const file = presetFile();
const set = currentPreset();
if (!file || !set) {
toast(t("import.noFile"), "err");
return;
}
const existing = $("#preset-target").value === "existing";
const profile = existing ? $("#preset-profile").value : $("#preset-id").value.trim();
const activate = $("#preset-activate").checked;
// Смена активного профиля — заметное действие: о нём предупреждаем заранее.
const question = t("import.confirm", { name: set.name, profile })
+ (activate && profile !== profiles.active ? "\n\n" + t("import.confirmActivate", { profile }) : "");
if (!confirm(question)) return;
const form = new FormData();
form.append("file", file);
form.append("preset", set.name);
form.append("target", existing ? "existing" : "new");
form.append("profile", profile);
form.append("title", set.server_name || set.name);
form.append("keep", $$("#preset-details [data-keep]")
.filter((box) => box.checked)
.map((box) => box.dataset.keep)
.join(","));
form.append("activate", activate ? "1" : "");
const button = $("#btn-preset-import");
button.disabled = true;
try {
const result = await postFile("/api/presets/import", form);
ok(t("import.done", { profile: result.profile, files: (result.files || []).join(", ") }));
if ((result.kept || []).length) {
toast(t("import.kept", { profile: result.kept_from, keys: result.kept.join(", ") }));
}
if (result.applied !== undefined) {
toast(t("import.applied", { applied: result.applied, skipped: result.skipped }));
}
if (result.switched) {
toast(t("import.switched", { profile: result.profile }));
} else if (result.profile !== profiles.active) {
// Вкладки показывают активный профиль: без этого импорт выглядит так,
// будто моды и настройки никуда не попали.
toast(t("import.notActive", { profile: result.profile }));
}
$("#preset-file").value = "";
$("#preset-details").hidden = true;
await loadProfiles();
// Импортированный профиль мог стать активным — вкладки показывают
// именно его конфиг.
loadTab(currentTab);
} catch (error) {
fail(error);
} finally {
button.disabled = false;
}
});
// ---------------------------------------------------------------------------
// Бэкапы
// ---------------------------------------------------------------------------
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">${t("backups.download")}</a>
<button class="btn btn-sm" data-restore="${escapeHTML(archive.name)}">${t("backups.restore")}</button>
<button class="btn btn-sm btn-danger" data-delete="${escapeHTML(archive.name)}">${t("btn.delete")}</button>
</td>
</tr>`
);
$("#backup-rows").innerHTML = rows.join("") ||
`<tr><td colspan="5" class="muted">${t("backups.empty")}</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(t("backups.started"));
} 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(t("backups.confirmRestore", { name }))) return;
await post(`/api/backups/${encodeURIComponent(name)}/restore`);
ok(t("backups.restoreStarted"));
} else if (remove) {
const name = remove.dataset.delete;
if (!confirm(t("backups.confirmDelete", { name }))) return;
await del(`/api/backups/${encodeURIComponent(name)}`);
ok(t("backups.deleted"));
loadBackups();
}
} catch (error) {
fail(error);
}
});
// ---------------------------------------------------------------------------
// Версия панели и обновление
// ---------------------------------------------------------------------------
let updateInfo = null;
async function loadUpdateInfo() {
const info = await get("/api/update");
updateInfo = info;
$("#update-current").textContent = info.current || "?";
const state = $("#update-state");
const applyBtn = $("#btn-update-apply");
const manual = $("#update-manual");
applyBtn.hidden = !info.available;
manual.hidden = true;
if (info.error) {
state.className = "muted small";
state.textContent = t("update.failed", { error: info.error });
} else if (info.available) {
state.className = "small update-available";
state.textContent = t("update.available", { version: info.latest });
applyBtn.textContent = t("update.applyTo", { version: info.latest });
// Панель обновляется сама, только если установщик прописал ей права на
// это. Иначе показываем команду для терминала.
if (!info.can_apply) {
applyBtn.hidden = true;
manual.hidden = false;
manual.textContent = t("update.manual", { command: info.manual_command });
}
} else {
state.className = "muted small";
state.textContent = info.latest
? t("update.current", { version: info.latest })
: t("update.unknown");
}
const notes = $("#update-notes");
const changelog = info.changelog || [];
notes.hidden = !(info.available && changelog.length);
if (!notes.hidden) {
notes.querySelector("summary").textContent = changelog.length > 1
? t("update.whatsNewCount", { count: changelog.length })
: t("update.whatsNew");
renderChangelog(changelog);
}
$("#btn-manager-restart").disabled = !info.can_apply;
$("#btn-manager-restart").title = t(info.can_apply
? "update.restartTitle"
: "update.restartUnavailable");
}
// Описание релиза приходит из Gitea разметкой Markdown, и показывать её как
// есть неудобно: первым идёт раздел про установку, который в уже работающей
// панели ни к чему. Берём только список изменений.
function releaseItems(text) {
const changes = text.split(/^##\s+Изменения\s*$/m)[1];
return (changes ?? text)
.split("\n")
.map((line) => line.trim())
.filter((line) => line.startsWith("- "))
.map((line) => line.slice(2).trim())
.filter(Boolean);
}
// renderChangelog показывает изменения всех версий между установленной и
// последней: пропустив несколько релизов, иначе не узнать, что было в них.
function renderChangelog(releases) {
const body = $("#update-notes-body");
body.replaceChildren();
releases.forEach((release) => {
const heading = document.createElement("h4");
heading.className = "release-version";
heading.textContent = release.published
? `${release.version}${formatDate(release.published)}`
: release.version;
body.appendChild(heading);
const items = releaseItems(release.notes || "");
if (items.length) {
const list = document.createElement("ul");
items.forEach((item) => {
const li = document.createElement("li");
li.textContent = item;
list.appendChild(li);
});
body.appendChild(list);
return;
}
// Непривычный формат описания — показываем как есть, лишь бы не потерять.
const raw = document.createElement("pre");
raw.textContent = (release.notes || "").trim() || t("update.noNotes");
body.appendChild(raw);
});
const newest = releases[0];
if (newest && newest.url) {
const link = document.createElement("a");
link.href = newest.url;
link.target = "_blank";
link.rel = "noopener noreferrer";
link.className = "release-link";
link.textContent = t("update.fullNotes");
body.appendChild(link);
}
}
$("#btn-update-check").addEventListener("click", async () => {
const button = $("#btn-update-check");
button.disabled = true;
try {
await loadUpdateInfo();
ok(updateInfo.available
? t("update.hasNew", { version: updateInfo.latest })
: t("update.upToDate"));
} catch (error) {
fail(error);
} finally {
button.disabled = false;
}
});
$("#btn-update-apply").addEventListener("click", async () => {
if (!confirm(t("update.confirm", { version: updateInfo.latest }))) return;
try {
await post("/api/update", { version: updateInfo.latest });
waitForRestart(t("update.started"));
// Обновление выполняется отдельной службой, и её вывод в консоль панели
// не попадает — панель в это время перезапускается.
toast(t("update.follow"));
} catch (error) {
fail(error);
}
});
$("#btn-manager-restart").addEventListener("click", async () => {
if (!confirm(t("update.confirmRestart"))) return;
try {
await post("/api/manager/restart");
waitForRestart(t("update.restarting"));
} catch (error) {
fail(error);
}
});
// waitForRestart дожидается, пока служба поднимется заново, и перезагружает
// страницу: во время перезапуска панель недоступна, и обычные опросы статуса
// будут только сыпать ошибками.
function waitForRestart(message) {
ok(t("update.waiting", { message }));
stopPolling();
closeConsoleStream();
const deadline = Date.now() + 5 * 60 * 1000;
const state = $("#update-state");
state.className = "small";
state.textContent = t("update.waitingState", { message });
const probe = setInterval(async () => {
if (Date.now() > deadline) {
clearInterval(probe);
state.textContent = t("update.timeout");
return;
}
try {
const response = await fetch("/api/session", { credentials: "same-origin" });
if (!response.ok) return;
clearInterval(probe);
location.reload();
} catch (error) {
// Служба ещё перезапускается — это ожидаемо.
}
}, 3000);
}
// ---------------------------------------------------------------------------
// Настройки менеджера и пользователи
// ---------------------------------------------------------------------------
// Описание формы настроек: ключ в API, ключ подписи, тип и ключ пояснения.
const SETTINGS_FIELDS = [
["listen", "settings.listen", "text", "settings.listenHint"],
["server_dir", "settings.serverDir", "text", "settings.serverDirHint"],
["zomboid_dir", "settings.zomboidDir", "text", "settings.zomboidDirHint"],
["steamcmd_path", "settings.steamcmdPath", "text", "settings.steamcmdPathHint"],
["autostart", "settings.autostart", "checkbox", ""],
["autorestart", "settings.autorestart", "checkbox", ""],
["stop_timeout_sec", "settings.stopTimeout", "number", "settings.stopTimeoutHint"],
["log_buffer_lines", "settings.logBuffer", "number", ""],
["backup_dir", "settings.backupDir", "text", ""],
["backup_schedule_min", "settings.backupSchedule", "number", "settings.backupScheduleHint"],
["backup_keep", "settings.backupKeep", "number", "settings.backupKeepHint"],
["backup_stop_server", "settings.backupStopServer", "checkbox", "settings.backupStopServerHint"],
];
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 = t(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] ?? "";
}
input.dataset.original = type === "checkbox" ? String(input.checked) : input.value;
wrapper.appendChild(input);
if (hint) {
const hintEl = document.createElement("div");
hintEl.className = "hint";
hintEl.textContent = t(hint);
wrapper.appendChild(hintEl);
}
form.appendChild(wrapper);
});
const pathNote = document.createElement("p");
pathNote.className = "muted small";
pathNote.textContent = t("settings.configPath", { path: config.config_path });
form.appendChild(pathNote);
refreshSettingsSaveState();
await loadUsers();
// Проверка обновления ходит в интернет и может не ответить — она не должна
// мешать показу остальных настроек.
loadUpdateInfo().catch((error) => {
$("#update-state").textContent = t("update.failed", { error: error.message });
});
}
function refreshSettingsSaveState() {
const changed = $$("#settings-form input").filter((input) => {
const current = input.type === "checkbox" ? String(input.checked) : input.value;
return current !== input.dataset.original;
}).length;
const button = $("#btn-settings-save");
button.disabled = changed === 0;
button.textContent = changed ? t("settings.saveCount", { count: changed }) : t("settings.save");
button.title = changed ? t("settings.changedCount", { count: changed }) : t("config.noChangesTitle");
}
["input", "change"].forEach((event) =>
$("#settings-form").addEventListener(event, refreshSettingsSaveState));
$("#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 || t("settings.saved"));
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)}">${t("users.changePassword")}</button>
<button class="btn btn-sm btn-danger" data-deluser="${escapeHTML(login)}">${t("btn.delete")}</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(t("users.newPassword", { login: passwd.dataset.passwd }));
if (!password) return;
await post(`/api/users/${encodeURIComponent(passwd.dataset.passwd)}/password`, { password });
ok(t("users.passwordChanged"));
} else if (remove) {
if (!confirm(t("users.confirmDelete", { login: remove.dataset.deluser }))) return;
await del(`/api/users/${encodeURIComponent(remove.dataset.deluser)}`);
ok(t("users.deleted"));
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(t("users.added"));
loadUsers();
} catch (error) {
fail(error);
}
});
// ---------------------------------------------------------------------------
// Запуск
// ---------------------------------------------------------------------------
// Подписи разметки проставляются один раз: дальше их меняет только смена
// языка, а она перезагружает страницу.
applyStaticTranslations();
$$(".lang-select").forEach((select) => {
select.value = LANG;
select.addEventListener("change", (event) => setLang(event.target.value));
});
checkSession().catch((error) => {
fail(error);
showAuth(false);
});