Files
pzmanager/web/static/app.js
T
mrleo1nidandClaude Opus 5 27a2fd6386
CI / test (push) Successful in 20s
Release / release (push) Successful in 25s
Консоль не принимает команды у остановленного сервера
Поле ввода и кнопка «Отправить» были активны всегда, хотя команда уходит
в stdin процесса: у остановленного сервера его просто нет, и панель
отвечала ошибкой уже после отправки. Теперь консоль блокируется, а
подсказка объясняет, почему.

Заодно из вывода убираются управляющие последовательности терминала.
SteamCMD раскрашивает свои сообщения, и в браузере это выглядело как
«[0mWaiting for user info...[0mOK». Чистка стоит на общем пути записи в
буфер: через него идёт и вывод сервера, и SteamCMD, и сообщения панели.

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

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