Files
pzmanager/web/static/app.js
T
mrleo1nidandClaude Opus 5 9a224bc070
CI / test (push) Successful in 22s
Release / release (push) Successful in 29s
Русский и английский языки интерфейса
Язык выбирается в шапке панели, а до первого выбора берётся из настроек
браузера. Переведены интерфейс, подписи и описания всех параметров конфигов,
ошибки API и служебные сообщения менеджера в консоли.

Подписи параметров вынесены из app.js в hints-<язык>.json: их больше четырёхсот
на язык, и грузить чужой язык незачем. Сообщения менеджера переводятся на
выходе из API по словарю, где ключом служит русская фраза из кода, — вывод
самого игрового сервера через него проходит без изменений.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 21:14:21 +03:00

1974 lines
75 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]}`;
}
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(t("unit.days", { value: days }));
if (hours) parts.push(t("unit.hours", { value: hours }));
parts.push(t("unit.minutes", { value: minutes }));
return t("overview.uptime", { 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 })
: "";
$("#ov-uptime").textContent = formatUptime(status.uptime_sec);
$("#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);
}
});
$("#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] ?? "";
});
// ---------------------------------------------------------------------------
// Конфиг сервера
// ---------------------------------------------------------------------------
// Подписей и описаний параметров — сотни на каждый язык, и держать их в коде
// панели незачем: они лежат отдельными файлами и нужны только двум вкладкам.
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>`;
}
async function lookupWorkshopTitles() {
if (!mods.workshop.length) return;
const data = await post("/api/mods/lookup", { ids: mods.workshop });
(data.items || []).forEach((item) => {
mods.details[item.id] = item;
});
renderMods();
}
// 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();
});
$("#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: "" };
async function loadProfiles() {
const data = await get("/api/profiles");
profiles = { list: data.profiles || [], active: data.active };
renderProfileSelect();
renderProfileCards();
renderCopySource();
}
function renderProfileSelect() {
const select = $("#profile-select");
select.innerHTML = profiles.list
.map((p) => `<option value="${escapeHTML(p.id)}">${escapeHTML(p.title || p.id)}</option>`)
.join("");
select.value = profiles.active;
}
function renderProfileCards() {
$("#profile-cards").innerHTML = profiles.list
.map((p) => {
const badge = p.active ? `<span class="badge">${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.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"),
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);
}
});
// ---------------------------------------------------------------------------
// Бэкапы
// ---------------------------------------------------------------------------
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);
});