Веб-панель управления сервером Project Zomboid
Менеджер запускает сервер PZ как дочерний процесс и держит его stdin/stdout: отсюда живая консоль без RCON, стриминг логов через SSE и корректная остановка по цепочке quit -> SIGTERM -> SIGKILL для всей группы процессов. Сам менеджер работает под systemd. Что входит: - управление жизненным циклом сервера, автозапуск и автоподъём после падения; - метрики процесса из /proc и состояние хоста, список игроков онлайн; - редакторы servertest.ini и SandboxVars.lua, сохраняющие исходное форматирование файлов; - моды: списки Workshop ID и Mod ID с порядком загрузки, названия из Steam; - бэкапы мира по расписанию и вручную, с ротацией и восстановлением; - установка и обновление сервера через SteamCMD; - вход по логину и паролю, первичная настройка по одноразовому коду. Веб-интерфейс без сборщика, вшит в бинарник через embed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,919 @@
|
||||
"use strict";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Обращения к API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Заголовок обязателен для всех изменяющих запросов: сервер использует его как
|
||||
// защиту от CSRF вместо отдельных токенов.
|
||||
const CSRF_HEADER = "X-PZM-Request";
|
||||
|
||||
async function api(method, path, body) {
|
||||
const options = {
|
||||
method,
|
||||
headers: { [CSRF_HEADER]: "1" },
|
||||
credentials: "same-origin",
|
||||
};
|
||||
if (body !== undefined) {
|
||||
options.headers["Content-Type"] = "application/json";
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const response = await fetch(path, options);
|
||||
if (response.status === 401) {
|
||||
showAuth(false);
|
||||
throw new Error("Сессия истекла, войдите заново");
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
const data = text ? JSON.parse(text) : {};
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || `Ошибка ${response.status}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
const get = (path) => api("GET", path);
|
||||
const post = (path, body) => api("POST", path, body);
|
||||
const put = (path, body) => api("PUT", path, body);
|
||||
const del = (path) => api("DELETE", path);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Мелкие утилиты
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
const $$ = (selector) => Array.from(document.querySelectorAll(selector));
|
||||
|
||||
function toast(message, kind = "") {
|
||||
const el = document.createElement("div");
|
||||
el.className = `toast ${kind}`;
|
||||
el.textContent = message;
|
||||
$("#toasts").appendChild(el);
|
||||
setTimeout(() => el.remove(), kind === "err" ? 7000 : 4000);
|
||||
}
|
||||
|
||||
const ok = (message) => toast(message, "ok");
|
||||
const fail = (error) => toast(error.message || String(error), "err");
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (!bytes) return "—";
|
||||
const units = ["Б", "КБ", "МБ", "ГБ", "ТБ"];
|
||||
let value = bytes;
|
||||
let unit = 0;
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024;
|
||||
unit++;
|
||||
}
|
||||
return `${value.toFixed(value < 10 && unit > 0 ? 1 : 0)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
function formatUptime(seconds) {
|
||||
if (!seconds) return "";
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const parts = [];
|
||||
if (days) parts.push(`${days} д`);
|
||||
if (hours) parts.push(`${hours} ч`);
|
||||
parts.push(`${minutes} мин`);
|
||||
return "Работает: " + parts.join(" ");
|
||||
}
|
||||
|
||||
function formatDate(unixSeconds) {
|
||||
return new Date(unixSeconds * 1000).toLocaleString("ru-RU");
|
||||
}
|
||||
|
||||
// Экранирование не нужно там, где мы пишем в textContent, но пригодится для
|
||||
// заголовков модов, которые приходят из Steam.
|
||||
function escapeHTML(value) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = value ?? "";
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
const STATE_LABELS = {
|
||||
stopped: "Остановлен",
|
||||
starting: "Запускается",
|
||||
running: "Работает",
|
||||
stopping: "Останавливается",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Вход и первичная настройка
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let setupMode = false;
|
||||
|
||||
function showAuth(needSetup) {
|
||||
setupMode = needSetup;
|
||||
$("#app").classList.add("hidden");
|
||||
$("#auth-screen").classList.remove("hidden");
|
||||
$("#token-field").hidden = !needSetup;
|
||||
$("#auth-subtitle").textContent = needSetup
|
||||
? "Создайте учётную запись администратора"
|
||||
: "Управление сервером Project Zomboid";
|
||||
$("#auth-submit").textContent = needSetup ? "Создать администратора" : "Войти";
|
||||
$("#auth-form").querySelector("[name=password]").autocomplete = needSetup
|
||||
? "new-password"
|
||||
: "current-password";
|
||||
stopPolling();
|
||||
}
|
||||
|
||||
function showApp(login) {
|
||||
$("#auth-screen").classList.add("hidden");
|
||||
$("#app").classList.remove("hidden");
|
||||
$("#current-user").textContent = login;
|
||||
startPolling();
|
||||
openConsoleStream();
|
||||
loadTab(currentTab);
|
||||
}
|
||||
|
||||
$("#auth-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.target);
|
||||
const payload = {
|
||||
login: form.get("login").trim(),
|
||||
password: form.get("password"),
|
||||
};
|
||||
if (setupMode) payload.token = (form.get("token") || "").trim();
|
||||
|
||||
const errorBox = $("#auth-error");
|
||||
errorBox.hidden = true;
|
||||
try {
|
||||
const result = await post(setupMode ? "/api/setup" : "/api/login", payload);
|
||||
event.target.reset();
|
||||
showApp(result.login);
|
||||
} catch (error) {
|
||||
errorBox.textContent = error.message;
|
||||
errorBox.hidden = false;
|
||||
}
|
||||
});
|
||||
|
||||
$("#btn-logout").addEventListener("click", async () => {
|
||||
try {
|
||||
await post("/api/logout");
|
||||
} catch (error) {
|
||||
/* даже при ошибке уводим на экран входа */
|
||||
}
|
||||
closeConsoleStream();
|
||||
showAuth(false);
|
||||
});
|
||||
|
||||
async function checkSession() {
|
||||
const session = await get("/api/session");
|
||||
if (session.authenticated) {
|
||||
showApp(session.login);
|
||||
} else {
|
||||
showAuth(session.setup_needed);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Вкладки
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let currentTab = "overview";
|
||||
|
||||
$("#tabs").addEventListener("click", (event) => {
|
||||
const tab = event.target.closest(".tab");
|
||||
if (!tab) return;
|
||||
currentTab = tab.dataset.tab;
|
||||
$$(".tab").forEach((t) => t.classList.toggle("active", t === tab));
|
||||
$$(".panel").forEach((p) => p.classList.toggle("active", p.dataset.panel === currentTab));
|
||||
loadTab(currentTab);
|
||||
});
|
||||
|
||||
// loadTab подгружает данные вкладки при переходе на неё: конфиги и списки
|
||||
// модов меняются редко, держать их в опросе статуса незачем.
|
||||
function loadTab(name) {
|
||||
const loaders = {
|
||||
config: loadServerConfig,
|
||||
sandbox: loadSandbox,
|
||||
mods: loadMods,
|
||||
backups: loadBackups,
|
||||
settings: loadSettings,
|
||||
};
|
||||
if (loaders[name]) loaders[name]().catch(fail);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Статус и управление сервером
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let pollTimer = null;
|
||||
let lastTaskName = "";
|
||||
|
||||
function startPolling() {
|
||||
stopPolling();
|
||||
refreshStatus();
|
||||
pollTimer = setInterval(refreshStatus, 2000);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
|
||||
async function refreshStatus() {
|
||||
let data;
|
||||
try {
|
||||
data = await get("/api/status");
|
||||
} catch (error) {
|
||||
$("#status-text").textContent = "Нет связи с менеджером";
|
||||
$("#status-dot").className = "dot";
|
||||
return;
|
||||
}
|
||||
renderStatus(data.server);
|
||||
renderTask(data.task);
|
||||
}
|
||||
|
||||
function renderStatus(status) {
|
||||
const label = STATE_LABELS[status.state] || status.state;
|
||||
$("#status-text").textContent = label;
|
||||
$("#status-dot").className = `dot ${status.state}`;
|
||||
|
||||
$("#ov-state").textContent = label;
|
||||
$("#ov-uptime").textContent = formatUptime(status.uptime_sec);
|
||||
$("#ov-pid").textContent = status.pid ? `PID ${status.pid}` : "";
|
||||
$("#ov-lastexit").textContent =
|
||||
status.state === "stopped" && status.last_exit ? `Последнее завершение: ${status.last_exit}` : "";
|
||||
|
||||
const players = status.players || [];
|
||||
$("#ov-players-count").textContent = players.length;
|
||||
$("#ov-players").innerHTML = players.map((name) => `<li>${escapeHTML(name)}</li>`).join("");
|
||||
|
||||
$("#ov-cpu").textContent = status.proc.cpu_percent ? `${status.proc.cpu_percent.toFixed(0)} %` : "—";
|
||||
$("#ov-rss").textContent = formatBytes(status.proc.rss_bytes);
|
||||
$("#ov-threads").textContent = status.proc.threads || "—";
|
||||
|
||||
const host = status.host || {};
|
||||
$("#ov-mem").textContent = host.mem_total_bytes
|
||||
? `${formatBytes(host.mem_available_bytes)} из ${formatBytes(host.mem_total_bytes)}`
|
||||
: "—";
|
||||
$("#ov-load").textContent = host.cpu_count
|
||||
? `${(host.load1 || 0).toFixed(2)} на ${host.cpu_count} ядер`
|
||||
: (host.load1 || 0).toFixed(2);
|
||||
$("#ov-disk").textContent = host.disk_total_bytes
|
||||
? `${formatBytes(host.disk_free_bytes)} из ${formatBytes(host.disk_total_bytes)}`
|
||||
: "—";
|
||||
|
||||
$("#install-state").textContent = status.installed
|
||||
? "Серверные файлы установлены."
|
||||
: "Серверные файлы не найдены — установите их перед первым запуском.";
|
||||
|
||||
const running = status.state !== "stopped";
|
||||
$("#btn-start").disabled = running || !status.installed;
|
||||
$("#btn-stop").disabled = !running;
|
||||
$("#btn-restart").disabled = !status.installed;
|
||||
$("#btn-install").disabled = running;
|
||||
}
|
||||
|
||||
function renderTask(task) {
|
||||
const banner = $("#task-banner");
|
||||
banner.hidden = !task || !task.running;
|
||||
if (task && task.running) {
|
||||
$("#task-name").textContent = task.name;
|
||||
lastTaskName = task.name;
|
||||
} else if (task && task.name && task.name === lastTaskName) {
|
||||
// Сообщаем об исходе один раз — на следующем опросе имя уже совпадать не будет.
|
||||
lastTaskName = "";
|
||||
if (task.error) {
|
||||
toast(`${task.name}: ${task.error}`, "err");
|
||||
} else {
|
||||
ok(`${task.name} — готово`);
|
||||
loadTab(currentTab);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function serverAction(path, confirmText) {
|
||||
if (confirmText && !confirm(confirmText)) return;
|
||||
try {
|
||||
await post(path);
|
||||
refreshStatus();
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
}
|
||||
}
|
||||
|
||||
$("#btn-start").addEventListener("click", () => serverAction("/api/server/start"));
|
||||
$("#btn-stop").addEventListener("click", () =>
|
||||
serverAction("/api/server/stop", "Остановить игровой сервер? Игроки будут отключены."));
|
||||
$("#btn-restart").addEventListener("click", () =>
|
||||
serverAction("/api/server/restart", "Перезапустить сервер? Игроки будут отключены."));
|
||||
|
||||
$("#btn-install").addEventListener("click", async () => {
|
||||
if (!confirm("Запустить установку/обновление серверных файлов через SteamCMD?")) return;
|
||||
try {
|
||||
await post("/api/install");
|
||||
ok("Задача запущена — следите за вкладкой «Консоль»");
|
||||
refreshStatus();
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
}
|
||||
});
|
||||
|
||||
$("#btn-task-cancel").addEventListener("click", async () => {
|
||||
try {
|
||||
await post("/api/task/cancel");
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Консоль
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let eventSource = null;
|
||||
let lastSeq = 0;
|
||||
const MAX_CONSOLE_LINES = 3000;
|
||||
|
||||
function openConsoleStream() {
|
||||
closeConsoleStream();
|
||||
eventSource = new EventSource(`/api/logs/stream?since=${lastSeq}`);
|
||||
eventSource.onmessage = (event) => appendConsoleLine(JSON.parse(event.data));
|
||||
eventSource.onerror = () => {
|
||||
// Браузер переподключается сам; сообщать об этом каждый раз не нужно.
|
||||
};
|
||||
}
|
||||
|
||||
function closeConsoleStream() {
|
||||
if (eventSource) eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
|
||||
function appendConsoleLine(line) {
|
||||
if (line.seq <= lastSeq) return;
|
||||
lastSeq = line.seq;
|
||||
|
||||
const output = $("#console-output");
|
||||
const filter = $("#console-filter").value.trim().toLowerCase();
|
||||
|
||||
const el = document.createElement("div");
|
||||
el.className = `line-${line.stream}`;
|
||||
el.textContent = line.text;
|
||||
el.dataset.text = line.text.toLowerCase();
|
||||
if (filter && !el.dataset.text.includes(filter)) el.hidden = true;
|
||||
output.appendChild(el);
|
||||
|
||||
while (output.childElementCount > MAX_CONSOLE_LINES) {
|
||||
output.removeChild(output.firstElementChild);
|
||||
}
|
||||
if ($("#console-autoscroll").checked) {
|
||||
output.scrollTop = output.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
$("#console-filter").addEventListener("input", (event) => {
|
||||
const filter = event.target.value.trim().toLowerCase();
|
||||
Array.from($("#console-output").children).forEach((line) => {
|
||||
line.hidden = filter && !line.dataset.text.includes(filter);
|
||||
});
|
||||
});
|
||||
|
||||
$("#btn-console-clear").addEventListener("click", () => {
|
||||
// Чистим только экран: история остаётся в буфере менеджера.
|
||||
$("#console-output").innerHTML = "";
|
||||
});
|
||||
|
||||
// История отправленных команд: та же логика, что и в обычном терминале.
|
||||
const commandHistory = [];
|
||||
let historyIndex = 0;
|
||||
|
||||
$("#console-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const input = $("#console-command");
|
||||
const command = input.value.trim();
|
||||
if (!command) return;
|
||||
try {
|
||||
await post("/api/console", { command });
|
||||
if (commandHistory[commandHistory.length - 1] !== command) commandHistory.push(command);
|
||||
historyIndex = commandHistory.length;
|
||||
input.value = "";
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
}
|
||||
});
|
||||
|
||||
$("#console-command").addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") {
|
||||
// Отправляем явно: полагаться на неявную отправку формы по Enter не стоит,
|
||||
// она зависит от браузера.
|
||||
event.preventDefault();
|
||||
$("#console-form").requestSubmit();
|
||||
return;
|
||||
}
|
||||
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
|
||||
|
||||
event.preventDefault();
|
||||
historyIndex += event.key === "ArrowUp" ? -1 : 1;
|
||||
historyIndex = Math.max(0, Math.min(historyIndex, commandHistory.length));
|
||||
event.target.value = commandHistory[historyIndex] ?? "";
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Конфиг сервера
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Подсказки к самым востребованным параметрам: остальные показываются как есть.
|
||||
const CONFIG_HINTS = {
|
||||
PublicName: "Название сервера в списке серверов",
|
||||
PublicDescription: "Описание сервера",
|
||||
Public: "Показывать сервер в публичном списке (true/false)",
|
||||
Password: "Пароль для входа на сервер (пусто — без пароля)",
|
||||
MaxPlayers: "Максимум игроков",
|
||||
DefaultPort: "Игровой порт (UDP), по умолчанию 16261",
|
||||
UDPPort: "Второй игровой порт, по умолчанию 16262",
|
||||
PVP: "Разрешить бой между игроками",
|
||||
PauseEmpty: "Ставить мир на паузу, когда никого нет онлайн",
|
||||
GlobalChat: "Общий чат для всего сервера",
|
||||
Open: "Пускать игроков без записи в белом списке",
|
||||
ServerWelcomeMessage: "Приветственное сообщение",
|
||||
SaveWorldEveryMinutes: "Как часто сохранять мир, минут",
|
||||
BackupsOnStart: "Делать бэкап силами самой игры при старте",
|
||||
RCONPort: "Порт RCON",
|
||||
RCONPassword: "Пароль RCON",
|
||||
AdminSafehouse: "Разрешить админам входить в чужие убежища",
|
||||
SafetySystem: "Система защиты от PVP-урона",
|
||||
DisplayUserName: "Показывать ники над персонажами",
|
||||
SpawnPoint: "Точка появления новых игроков (x,y,z)",
|
||||
Mods: "Список модов — редактируется во вкладке «Моды»",
|
||||
WorkshopItems: "Моды мастерской — редактируется во вкладке «Моды»",
|
||||
Map: "Карты — редактируется во вкладке «Моды»",
|
||||
};
|
||||
|
||||
const BOOLEAN_VALUES = new Set(["true", "false"]);
|
||||
|
||||
let serverConfigValues = {};
|
||||
|
||||
async function loadServerConfig() {
|
||||
const data = await get("/api/config/server");
|
||||
$("#config-path").textContent = data.path;
|
||||
$("#config-empty").hidden = data.exists;
|
||||
$("#config-fields").innerHTML = "";
|
||||
|
||||
if (!data.exists) {
|
||||
$("#config-empty").textContent = data.hint;
|
||||
return;
|
||||
}
|
||||
serverConfigValues = data.values;
|
||||
const container = $("#config-fields");
|
||||
data.keys.forEach((key) => {
|
||||
container.appendChild(buildConfigField(key, data.values[key], CONFIG_HINTS[key]));
|
||||
});
|
||||
applyFieldFilter("#config-filter", "#config-fields");
|
||||
}
|
||||
|
||||
// buildConfigField выбирает тип поля по текущему значению: булевы значения
|
||||
// удобнее переключать списком, числа — числовым полем.
|
||||
function buildConfigField(key, value, hint) {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "cfg-field";
|
||||
wrapper.dataset.key = key.toLowerCase();
|
||||
wrapper.dataset.hint = (hint || "").toLowerCase();
|
||||
|
||||
const label = document.createElement("label");
|
||||
label.textContent = hint || key;
|
||||
wrapper.appendChild(label);
|
||||
|
||||
let input;
|
||||
if (BOOLEAN_VALUES.has(value.toLowerCase())) {
|
||||
input = document.createElement("select");
|
||||
["true", "false"].forEach((option) => {
|
||||
const el = document.createElement("option");
|
||||
el.value = option;
|
||||
el.textContent = option === "true" ? "да" : "нет";
|
||||
input.appendChild(el);
|
||||
});
|
||||
input.value = value.toLowerCase();
|
||||
} else if (/^-?\d+$/.test(value) && value.length < 10) {
|
||||
input = document.createElement("input");
|
||||
input.type = "number";
|
||||
input.value = value;
|
||||
} else {
|
||||
input = document.createElement("input");
|
||||
input.type = key.toLowerCase().includes("password") ? "password" : "text";
|
||||
input.value = value;
|
||||
}
|
||||
input.dataset.original = value;
|
||||
input.name = key;
|
||||
input.addEventListener("input", () => {
|
||||
wrapper.classList.toggle("changed", input.value !== input.dataset.original);
|
||||
});
|
||||
wrapper.appendChild(input);
|
||||
|
||||
const keyLabel = document.createElement("div");
|
||||
keyLabel.className = "key";
|
||||
keyLabel.textContent = key;
|
||||
wrapper.appendChild(keyLabel);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
// collectChanged собирает только изменённые поля: отправлять весь файл целиком
|
||||
// рискованно, если его правили ещё и вручную.
|
||||
function collectChanged(containerSelector) {
|
||||
const changed = {};
|
||||
$$(`${containerSelector} .cfg-field`).forEach((field) => {
|
||||
const input = field.querySelector("input, select");
|
||||
if (input && input.value !== input.dataset.original) {
|
||||
changed[input.name] = input.value;
|
||||
}
|
||||
});
|
||||
return changed;
|
||||
}
|
||||
|
||||
function markSaved(containerSelector) {
|
||||
$$(`${containerSelector} .cfg-field`).forEach((field) => {
|
||||
const input = field.querySelector("input, select");
|
||||
if (input) input.dataset.original = input.value;
|
||||
field.classList.remove("changed");
|
||||
});
|
||||
}
|
||||
|
||||
$("#btn-config-save").addEventListener("click", async () => {
|
||||
const values = collectChanged("#config-fields");
|
||||
if (!Object.keys(values).length) {
|
||||
toast("Нет изменений");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await put("/api/config/server", { values });
|
||||
markSaved("#config-fields");
|
||||
ok("Конфиг сохранён. Изменения применятся после перезапуска сервера.");
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
}
|
||||
});
|
||||
|
||||
function applyFieldFilter(inputSelector, containerSelector) {
|
||||
const filter = $(inputSelector).value.trim().toLowerCase();
|
||||
$$(`${containerSelector} .cfg-field`).forEach((field) => {
|
||||
field.hidden = filter && !field.dataset.key.includes(filter) && !field.dataset.hint.includes(filter);
|
||||
});
|
||||
}
|
||||
|
||||
$("#config-filter").addEventListener("input", () => applyFieldFilter("#config-filter", "#config-fields"));
|
||||
$("#sandbox-filter").addEventListener("input", () => applyFieldFilter("#sandbox-filter", "#sandbox-fields"));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Песочница
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function loadSandbox() {
|
||||
const data = await get("/api/config/sandbox");
|
||||
$("#sandbox-path").textContent = data.path;
|
||||
$("#sandbox-empty").hidden = data.exists;
|
||||
$("#sandbox-fields").innerHTML = "";
|
||||
|
||||
if (!data.exists) {
|
||||
$("#sandbox-empty").textContent = data.hint;
|
||||
return;
|
||||
}
|
||||
const container = $("#sandbox-fields");
|
||||
data.entries.forEach((entry) => {
|
||||
container.appendChild(buildConfigField(entry.path, entry.value, null));
|
||||
});
|
||||
applyFieldFilter("#sandbox-filter", "#sandbox-fields");
|
||||
}
|
||||
|
||||
$("#btn-sandbox-save").addEventListener("click", async () => {
|
||||
const values = collectChanged("#sandbox-fields");
|
||||
if (!Object.keys(values).length) {
|
||||
toast("Нет изменений");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await put("/api/config/sandbox", { values });
|
||||
markSaved("#sandbox-fields");
|
||||
ok("Песочница сохранена. Часть параметров действует только на новый мир.");
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Моды
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let mods = { workshop: [], modIDs: [], maps: [] };
|
||||
|
||||
async function loadMods() {
|
||||
const data = await get("/api/mods");
|
||||
$("#mods-empty").hidden = data.exists;
|
||||
$("#mods-body").hidden = !data.exists;
|
||||
if (!data.exists) {
|
||||
$("#mods-empty").textContent = data.hint;
|
||||
return;
|
||||
}
|
||||
|
||||
mods = {
|
||||
workshop: data.workshop_items || [],
|
||||
modIDs: data.mods || [],
|
||||
maps: data.map || [],
|
||||
};
|
||||
$("#map-list").value = mods.maps.join("\n");
|
||||
renderMods();
|
||||
lookupWorkshopTitles().catch(() => {
|
||||
/* без интернета останутся одни идентификаторы — это не ошибка панели */
|
||||
});
|
||||
}
|
||||
|
||||
function renderMods(details = {}) {
|
||||
$("#workshop-list").innerHTML = mods.workshop
|
||||
.map((id, index) => {
|
||||
const info = details[id] || {};
|
||||
const title = info.error ? `<span class="error">${escapeHTML(info.error)}</span>` : escapeHTML(info.title || "…");
|
||||
const preview = info.preview
|
||||
? `<img src="${escapeHTML(info.preview)}" alt="">`
|
||||
: "";
|
||||
return `<li>
|
||||
${preview}
|
||||
<div class="mod-title"><b>${title}</b><small>${escapeHTML(id)}</small></div>
|
||||
<button class="btn btn-sm btn-ghost" data-move="workshop" data-index="${index}" data-dir="-1">↑</button>
|
||||
<button class="btn btn-sm btn-ghost" data-move="workshop" data-index="${index}" data-dir="1">↓</button>
|
||||
<button class="btn btn-sm btn-danger" data-remove="workshop" data-index="${index}">Убрать</button>
|
||||
</li>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
$("#modid-list").innerHTML = mods.modIDs
|
||||
.map(
|
||||
(id, index) => `<li>
|
||||
<div class="mod-title"><b>${escapeHTML(id)}</b></div>
|
||||
<button class="btn btn-sm btn-ghost" data-move="mod" data-index="${index}" data-dir="-1">↑</button>
|
||||
<button class="btn btn-sm btn-ghost" data-move="mod" data-index="${index}" data-dir="1">↓</button>
|
||||
<button class="btn btn-sm btn-danger" data-remove="mod" data-index="${index}">Убрать</button>
|
||||
</li>`
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
async function lookupWorkshopTitles() {
|
||||
if (!mods.workshop.length) return;
|
||||
const data = await post("/api/mods/lookup", { ids: mods.workshop });
|
||||
const details = {};
|
||||
(data.items || []).forEach((item) => {
|
||||
details[item.id] = item;
|
||||
});
|
||||
renderMods(details);
|
||||
}
|
||||
|
||||
// Обе кнопки — перемещение и удаление — обрабатываем одним слушателем на списке.
|
||||
$("#mods-body").addEventListener("click", (event) => {
|
||||
const button = event.target.closest("button[data-move], button[data-remove]");
|
||||
if (!button) return;
|
||||
|
||||
const index = Number(button.dataset.index);
|
||||
const list = (button.dataset.move || button.dataset.remove) === "workshop" ? mods.workshop : mods.modIDs;
|
||||
|
||||
if (button.dataset.move) {
|
||||
const target = index + Number(button.dataset.dir);
|
||||
if (target < 0 || target >= list.length) return;
|
||||
[list[index], list[target]] = [list[target], list[index]];
|
||||
} else {
|
||||
list.splice(index, 1);
|
||||
}
|
||||
renderMods();
|
||||
lookupWorkshopTitles().catch(() => {});
|
||||
});
|
||||
|
||||
$("#mod-add-form").addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
const workshopID = $("#mod-workshop-id").value.trim();
|
||||
const modID = $("#mod-mod-id").value.trim();
|
||||
|
||||
if (workshopID && !/^\d+$/.test(workshopID)) {
|
||||
toast("Workshop ID — это число из ссылки на мод", "err");
|
||||
return;
|
||||
}
|
||||
if (!workshopID && !modID) return;
|
||||
|
||||
if (workshopID && !mods.workshop.includes(workshopID)) mods.workshop.push(workshopID);
|
||||
if (modID && !mods.modIDs.includes(modID)) mods.modIDs.push(modID);
|
||||
|
||||
$("#mod-workshop-id").value = "";
|
||||
$("#mod-mod-id").value = "";
|
||||
renderMods();
|
||||
lookupWorkshopTitles().catch(() => {});
|
||||
});
|
||||
|
||||
$("#btn-mods-save").addEventListener("click", async () => {
|
||||
const maps = $("#map-list").value.split("\n").map((line) => line.trim()).filter(Boolean);
|
||||
try {
|
||||
await put("/api/mods", {
|
||||
mods: mods.modIDs,
|
||||
workshop_items: mods.workshop,
|
||||
map: maps,
|
||||
});
|
||||
ok("Список модов сохранён. Перезапустите сервер, чтобы он их подтянул.");
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Бэкапы
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function loadBackups() {
|
||||
const data = await get("/api/backups");
|
||||
$("#backup-dir").textContent = data.dir;
|
||||
|
||||
const rows = (data.backups || []).map(
|
||||
(archive) => `<tr>
|
||||
<td>${escapeHTML(archive.name)}</td>
|
||||
<td>${formatBytes(archive.size_bytes)}</td>
|
||||
<td>${formatDate(archive.created_at)}</td>
|
||||
<td>
|
||||
<a class="btn btn-sm btn-ghost" href="/api/backups/${encodeURIComponent(archive.name)}/download">Скачать</a>
|
||||
<button class="btn btn-sm" data-restore="${escapeHTML(archive.name)}">Восстановить</button>
|
||||
<button class="btn btn-sm btn-danger" data-delete="${escapeHTML(archive.name)}">Удалить</button>
|
||||
</td>
|
||||
</tr>`
|
||||
);
|
||||
$("#backup-rows").innerHTML = rows.join("") ||
|
||||
`<tr><td colspan="4" class="muted">Пока ни одного архива</td></tr>`;
|
||||
}
|
||||
|
||||
$("#backup-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
await post("/api/backups", { note: $("#backup-note").value.trim() });
|
||||
$("#backup-note").value = "";
|
||||
ok("Бэкап запущен — прогресс во вкладке «Консоль»");
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
}
|
||||
});
|
||||
|
||||
$("#backup-rows").addEventListener("click", async (event) => {
|
||||
const restore = event.target.closest("button[data-restore]");
|
||||
const remove = event.target.closest("button[data-delete]");
|
||||
|
||||
try {
|
||||
if (restore) {
|
||||
const name = restore.dataset.restore;
|
||||
if (!confirm(`Восстановить мир из ${name}? Текущий мир будет перезаписан.\n` +
|
||||
`Перед восстановлением менеджер сам сделает страховочный бэкап.`)) return;
|
||||
await post(`/api/backups/${encodeURIComponent(name)}/restore`);
|
||||
ok("Восстановление запущено");
|
||||
} else if (remove) {
|
||||
const name = remove.dataset.delete;
|
||||
if (!confirm(`Удалить архив ${name}? Это действие необратимо.`)) return;
|
||||
await del(`/api/backups/${encodeURIComponent(name)}`);
|
||||
ok("Архив удалён");
|
||||
loadBackups();
|
||||
}
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Настройки менеджера и пользователи
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Описание формы настроек: ключ в API, подпись, тип и пояснение.
|
||||
const SETTINGS_FIELDS = [
|
||||
["listen", "Адрес веб-панели", "text", "Например 127.0.0.1:8080. Применится после перезапуска pzmanager"],
|
||||
["server_dir", "Директория сервера", "text", "Куда SteamCMD ставит файлы Project Zomboid"],
|
||||
["zomboid_dir", "Директория данных (~/Zomboid)", "text", "Здесь лежат миры, конфиги и логи игры"],
|
||||
["steamcmd_path", "Путь к steamcmd.sh", "text", "Пусто — найти в PATH или скачать автоматически"],
|
||||
["server_name", "Имя конфигурации сервера", "text", "servertest → servertest.ini"],
|
||||
["java_memory", "Память JVM", "text", "Например 8g. Применяется при установке/обновлении"],
|
||||
["admin_password", "Пароль админа игры", "password", "Оставьте пустым, чтобы не менять"],
|
||||
["autostart", "Запускать сервер вместе с менеджером", "checkbox", ""],
|
||||
["autorestart", "Поднимать сервер после падения", "checkbox", ""],
|
||||
["stop_timeout_sec", "Таймаут остановки, сек", "number", "Сколько ждать после команды quit до SIGTERM"],
|
||||
["log_buffer_lines", "Строк консоли в памяти", "number", ""],
|
||||
["backup_dir", "Директория бэкапов", "text", ""],
|
||||
["backup_schedule_min", "Автобэкап, минут", "number", "0 — отключить автоматические бэкапы"],
|
||||
["backup_keep", "Хранить архивов", "number", "0 — не удалять старые"],
|
||||
["backup_stop_server", "Останавливать сервер на время бэкапа", "checkbox",
|
||||
"Даёт согласованный снимок, но выкидывает игроков"],
|
||||
];
|
||||
|
||||
async function loadSettings() {
|
||||
const config = await get("/api/config/manager");
|
||||
const form = $("#settings-form");
|
||||
form.innerHTML = "";
|
||||
|
||||
SETTINGS_FIELDS.forEach(([key, label, type, hint]) => {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "cfg-field";
|
||||
|
||||
const labelEl = document.createElement("label");
|
||||
labelEl.textContent = label;
|
||||
wrapper.appendChild(labelEl);
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = type;
|
||||
input.name = key;
|
||||
if (type === "checkbox") {
|
||||
input.checked = Boolean(config[key]);
|
||||
} else if (key === "admin_password") {
|
||||
input.placeholder = config.has_admin_password ? "пароль задан" : "не задан";
|
||||
} else {
|
||||
input.value = config[key] ?? "";
|
||||
}
|
||||
wrapper.appendChild(input);
|
||||
|
||||
if (hint) {
|
||||
const hintEl = document.createElement("div");
|
||||
hintEl.className = "hint";
|
||||
hintEl.textContent = hint;
|
||||
wrapper.appendChild(hintEl);
|
||||
}
|
||||
form.appendChild(wrapper);
|
||||
});
|
||||
|
||||
const pathNote = document.createElement("p");
|
||||
pathNote.className = "muted small";
|
||||
pathNote.textContent = `Файл конфигурации: ${config.config_path}`;
|
||||
form.appendChild(pathNote);
|
||||
|
||||
await loadUsers();
|
||||
}
|
||||
|
||||
$("#btn-settings-save").addEventListener("click", async () => {
|
||||
const payload = {};
|
||||
SETTINGS_FIELDS.forEach(([key, , type]) => {
|
||||
const input = $(`#settings-form [name="${key}"]`);
|
||||
if (!input) return;
|
||||
if (type === "checkbox") {
|
||||
payload[key] = input.checked;
|
||||
} else if (type === "number") {
|
||||
payload[key] = Number(input.value || 0);
|
||||
} else {
|
||||
payload[key] = input.value.trim();
|
||||
}
|
||||
});
|
||||
// Пустой пароль означает «не менять» — не отправляем его вовсе.
|
||||
if (!payload.admin_password) delete payload.admin_password;
|
||||
try {
|
||||
const result = await put("/api/config/manager", payload);
|
||||
ok(result.note || "Настройки сохранены");
|
||||
loadSettings();
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
}
|
||||
});
|
||||
|
||||
async function loadUsers() {
|
||||
const data = await get("/api/users");
|
||||
$("#user-list").innerHTML = (data.users || [])
|
||||
.map(
|
||||
(login) => `<li>
|
||||
<span>${escapeHTML(login)}</span>
|
||||
<button class="btn btn-sm" data-passwd="${escapeHTML(login)}">Сменить пароль</button>
|
||||
<button class="btn btn-sm btn-danger" data-deluser="${escapeHTML(login)}">Удалить</button>
|
||||
</li>`
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
$("#user-list").addEventListener("click", async (event) => {
|
||||
const passwd = event.target.closest("button[data-passwd]");
|
||||
const remove = event.target.closest("button[data-deluser]");
|
||||
|
||||
try {
|
||||
if (passwd) {
|
||||
const password = prompt(`Новый пароль для ${passwd.dataset.passwd} (минимум 8 символов):`);
|
||||
if (!password) return;
|
||||
await post(`/api/users/${encodeURIComponent(passwd.dataset.passwd)}/password`, { password });
|
||||
ok("Пароль изменён");
|
||||
} else if (remove) {
|
||||
if (!confirm(`Удалить пользователя ${remove.dataset.deluser}?`)) return;
|
||||
await del(`/api/users/${encodeURIComponent(remove.dataset.deluser)}`);
|
||||
ok("Пользователь удалён");
|
||||
loadUsers();
|
||||
}
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
}
|
||||
});
|
||||
|
||||
$("#user-add-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
await post("/api/users", {
|
||||
login: $("#new-user-login").value.trim(),
|
||||
password: $("#new-user-password").value,
|
||||
});
|
||||
$("#new-user-login").value = "";
|
||||
$("#new-user-password").value = "";
|
||||
ok("Пользователь добавлен");
|
||||
loadUsers();
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
checkSession().catch((error) => {
|
||||
fail(error);
|
||||
showAuth(false);
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>PZ Manager</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text y='26' font-size='26'>🧟</text></svg>">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Экран входа: он же первичная настройка, когда учёток ещё нет. -->
|
||||
<div id="auth-screen" class="auth-screen hidden">
|
||||
<form id="auth-form" class="auth-card">
|
||||
<h1 class="auth-title">🧟 PZ Manager</h1>
|
||||
<p id="auth-subtitle" class="auth-subtitle">Управление сервером Project Zomboid</p>
|
||||
|
||||
<label class="field" id="token-field" hidden>
|
||||
<span>Код первичной настройки</span>
|
||||
<input type="text" name="token" autocomplete="off" spellcheck="false">
|
||||
<small>Код напечатан в журнале менеджера при запуске: <code>journalctl -u pzmanager</code></small>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Логин</span>
|
||||
<input type="text" name="login" autocomplete="username" required>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Пароль</span>
|
||||
<input type="password" name="password" autocomplete="current-password" required>
|
||||
</label>
|
||||
|
||||
<p id="auth-error" class="error" hidden></p>
|
||||
<button type="submit" class="btn btn-primary btn-block" id="auth-submit">Войти</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Основное приложение -->
|
||||
<div id="app" class="app hidden">
|
||||
<header class="topbar">
|
||||
<div class="brand">🧟 <span>PZ Manager</span></div>
|
||||
|
||||
<div class="status-pill" id="status-pill">
|
||||
<span class="dot" id="status-dot"></span>
|
||||
<span id="status-text">—</span>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button class="btn btn-start" id="btn-start">Запустить</button>
|
||||
<button class="btn btn-stop" id="btn-stop">Остановить</button>
|
||||
<button class="btn" id="btn-restart">Перезапустить</button>
|
||||
</div>
|
||||
|
||||
<div class="spacer"></div>
|
||||
<span class="user" id="current-user"></span>
|
||||
<button class="btn btn-ghost" id="btn-logout">Выйти</button>
|
||||
</header>
|
||||
|
||||
<div id="task-banner" class="task-banner" hidden>
|
||||
<span class="spinner"></span>
|
||||
<span id="task-name"></span>
|
||||
<button class="btn btn-ghost btn-sm" id="btn-task-cancel">Отменить</button>
|
||||
</div>
|
||||
|
||||
<nav class="tabs" id="tabs">
|
||||
<button class="tab active" data-tab="overview">Обзор</button>
|
||||
<button class="tab" data-tab="console">Консоль</button>
|
||||
<button class="tab" data-tab="config">Конфиг сервера</button>
|
||||
<button class="tab" data-tab="sandbox">Песочница</button>
|
||||
<button class="tab" data-tab="mods">Моды</button>
|
||||
<button class="tab" data-tab="backups">Бэкапы</button>
|
||||
<button class="tab" data-tab="settings">Настройки</button>
|
||||
</nav>
|
||||
|
||||
<main class="content">
|
||||
|
||||
<!-- Обзор -->
|
||||
<section class="panel active" data-panel="overview">
|
||||
<div class="cards">
|
||||
<div class="card">
|
||||
<h3>Состояние</h3>
|
||||
<div class="stat" id="ov-state">—</div>
|
||||
<div class="muted" id="ov-uptime"></div>
|
||||
<div class="muted" id="ov-pid"></div>
|
||||
<div class="muted error" id="ov-lastexit"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Игроки онлайн</h3>
|
||||
<div class="stat" id="ov-players-count">0</div>
|
||||
<ul class="player-list" id="ov-players"></ul>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Процесс сервера</h3>
|
||||
<div class="metric"><span>CPU</span><b id="ov-cpu">—</b></div>
|
||||
<div class="metric"><span>Память</span><b id="ov-rss">—</b></div>
|
||||
<div class="metric"><span>Потоки</span><b id="ov-threads">—</b></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Хост</h3>
|
||||
<div class="metric"><span>ОЗУ свободно</span><b id="ov-mem">—</b></div>
|
||||
<div class="metric"><span>Load average</span><b id="ov-load">—</b></div>
|
||||
<div class="metric"><span>Диск свободно</span><b id="ov-disk">—</b></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="install-card">
|
||||
<h3>Серверные файлы</h3>
|
||||
<p id="install-state" class="muted"></p>
|
||||
<button class="btn btn-primary" id="btn-install">Установить / обновить через SteamCMD</button>
|
||||
<p class="muted small">Сервер должен быть остановлен. Обновление занимает несколько минут — прогресс виден во вкладке «Консоль».</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Консоль -->
|
||||
<section class="panel" data-panel="console">
|
||||
<div class="console-toolbar">
|
||||
<label class="checkbox"><input type="checkbox" id="console-autoscroll" checked> Прокручивать за выводом</label>
|
||||
<input type="search" id="console-filter" placeholder="Фильтр по строке…" class="input-sm">
|
||||
<button class="btn btn-ghost btn-sm" id="btn-console-clear">Очистить экран</button>
|
||||
</div>
|
||||
<pre id="console-output" class="console"></pre>
|
||||
<form class="console-input" id="console-form">
|
||||
<input type="text" id="console-command" placeholder="Команда сервера, например: players, save, quit"
|
||||
autocomplete="off" spellcheck="false">
|
||||
<button class="btn btn-primary" type="submit">Отправить</button>
|
||||
</form>
|
||||
<p class="muted small">Полезные команды: <code>players</code>, <code>save</code>, <code>servermsg "текст"</code>,
|
||||
<code>kickuser "ник"</code>, <code>banuser "ник"</code>, <code>checkModsNeedUpdate</code>.</p>
|
||||
</section>
|
||||
|
||||
<!-- Конфиг сервера -->
|
||||
<section class="panel" data-panel="config">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>Конфигурация сервера</h2>
|
||||
<p class="muted small" id="config-path"></p>
|
||||
</div>
|
||||
<div>
|
||||
<input type="search" id="config-filter" placeholder="Поиск параметра…" class="input-sm">
|
||||
<button class="btn btn-primary" id="btn-config-save">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="muted" id="config-empty" hidden></p>
|
||||
<div id="config-fields" class="fields"></div>
|
||||
</section>
|
||||
|
||||
<!-- Песочница -->
|
||||
<section class="panel" data-panel="sandbox">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>Настройки песочницы</h2>
|
||||
<p class="muted small" id="sandbox-path"></p>
|
||||
</div>
|
||||
<div>
|
||||
<input type="search" id="sandbox-filter" placeholder="Поиск параметра…" class="input-sm">
|
||||
<button class="btn btn-primary" id="btn-sandbox-save">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="muted" id="sandbox-empty" hidden></p>
|
||||
<div id="sandbox-fields" class="fields"></div>
|
||||
</section>
|
||||
|
||||
<!-- Моды -->
|
||||
<section class="panel" data-panel="mods">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>Моды</h2>
|
||||
<p class="muted small">Сервер сам скачивает моды из мастерской при запуске.</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" id="btn-mods-save">Сохранить</button>
|
||||
</div>
|
||||
|
||||
<p class="muted" id="mods-empty" hidden></p>
|
||||
|
||||
<div id="mods-body">
|
||||
<form class="mod-add" id="mod-add-form">
|
||||
<input type="text" id="mod-workshop-id" placeholder="Workshop ID, например 2392709985" inputmode="numeric">
|
||||
<input type="text" id="mod-mod-id" placeholder="Mod ID (из описания мода)">
|
||||
<button class="btn" type="submit">Добавить</button>
|
||||
</form>
|
||||
<p class="muted small">Workshop ID — число из ссылки на мод. Mod ID автор указывает в описании; у одного мода
|
||||
мастерской их может быть несколько.</p>
|
||||
|
||||
<h3>Моды из мастерской</h3>
|
||||
<ul class="mod-list" id="workshop-list"></ul>
|
||||
|
||||
<h3>Порядок загрузки (Mods)</h3>
|
||||
<ul class="mod-list" id="modid-list"></ul>
|
||||
|
||||
<h3>Карты (Map)</h3>
|
||||
<textarea id="map-list" rows="3" spellcheck="false"
|
||||
placeholder="Например: Muldraugh, KY"></textarea>
|
||||
<p class="muted small">Каждая карта с новой строки. Первой должна идти карта мода, последней — базовая.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Бэкапы -->
|
||||
<section class="panel" data-panel="backups">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>Резервные копии</h2>
|
||||
<p class="muted small" id="backup-dir"></p>
|
||||
</div>
|
||||
<form id="backup-form">
|
||||
<input type="text" id="backup-note" placeholder="Пометка (необязательно)" class="input-sm">
|
||||
<button class="btn btn-primary" type="submit">Создать бэкап</button>
|
||||
</form>
|
||||
</div>
|
||||
<table class="table">
|
||||
<thead><tr><th>Архив</th><th>Размер</th><th>Создан</th><th></th></tr></thead>
|
||||
<tbody id="backup-rows"></tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<!-- Настройки -->
|
||||
<section class="panel" data-panel="settings">
|
||||
<h2>Настройки менеджера</h2>
|
||||
<form id="settings-form" class="fields"></form>
|
||||
<button class="btn btn-primary" id="btn-settings-save">Сохранить настройки</button>
|
||||
|
||||
<h2>Пользователи панели</h2>
|
||||
<ul class="user-list" id="user-list"></ul>
|
||||
<form id="user-add-form" class="mod-add">
|
||||
<input type="text" id="new-user-login" placeholder="Логин" autocomplete="off">
|
||||
<input type="password" id="new-user-password" placeholder="Пароль (минимум 8 символов)" autocomplete="new-password">
|
||||
<button class="btn" type="submit">Добавить пользователя</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="toasts" class="toasts"></div>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,384 @@
|
||||
/* Палитра панели: тёмная, с зелёным акцентом «в тему» Project Zomboid. */
|
||||
:root {
|
||||
--bg: #14171c;
|
||||
--bg-soft: #1b1f26;
|
||||
--bg-card: #20252e;
|
||||
--border: #2c333f;
|
||||
--text: #e6e9ef;
|
||||
--muted: #98a2b3;
|
||||
--accent: #6bbf59;
|
||||
--accent-dark: #4f9440;
|
||||
--danger: #e05c5c;
|
||||
--warn: #e0a95c;
|
||||
--info: #5c9ce0;
|
||||
--radius: 10px;
|
||||
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 14px/1.5 var(--font);
|
||||
}
|
||||
|
||||
.hidden { display: none !important; }
|
||||
/* Атрибут hidden обязан побеждать display из правил ниже: иначе элементы с
|
||||
display: flex/block остаются на экране. */
|
||||
[hidden] { display: none !important; }
|
||||
.muted { color: var(--muted); }
|
||||
.small { font-size: 12px; }
|
||||
.error { color: var(--danger); }
|
||||
.spacer { flex: 1; }
|
||||
|
||||
h1, h2, h3 { margin: 0 0 12px; font-weight: 600; }
|
||||
h2 { font-size: 17px; }
|
||||
h3 { font-size: 14px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
|
||||
|
||||
code {
|
||||
font-family: var(--mono);
|
||||
background: var(--bg-soft);
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ---------- Экран входа ---------- */
|
||||
|
||||
.auth-screen {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.auth-title { font-size: 22px; text-align: center; }
|
||||
.auth-subtitle { text-align: center; color: var(--muted); margin: 0 0 22px; }
|
||||
|
||||
/* ---------- Каркас ---------- */
|
||||
|
||||
.app { min-height: 100vh; display: flex; flex-direction: column; }
|
||||
|
||||
/* Кнопки управления и статус сервера должны оставаться под рукой на длинных
|
||||
вкладках — конфиг и моды прокручиваются далеко вниз. */
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
background: var(--bg-soft);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.brand { font-weight: 600; font-size: 16px; }
|
||||
.user { color: var(--muted); font-size: 13px; }
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 5px 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--muted);
|
||||
}
|
||||
.dot.running { background: var(--accent); box-shadow: 0 0 8px var(--accent); }
|
||||
.dot.starting, .dot.stopping { background: var(--warn); animation: pulse 1.2s infinite; }
|
||||
.dot.stopped { background: #5b636f; }
|
||||
|
||||
@keyframes pulse { 50% { opacity: .35; } }
|
||||
|
||||
.controls { display: flex; gap: 8px; }
|
||||
|
||||
.tabs {
|
||||
position: sticky;
|
||||
top: 53px;
|
||||
z-index: 19;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 0 20px;
|
||||
background: var(--bg-soft);
|
||||
border-bottom: 1px solid var(--border);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tab {
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--muted);
|
||||
padding: 11px 14px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tab:hover { color: var(--text); }
|
||||
.tab.active { color: var(--text); border-bottom-color: var(--accent); }
|
||||
|
||||
.content { padding: 20px; max-width: 1100px; width: 100%; margin: 0 auto; flex: 1; }
|
||||
|
||||
.panel { display: none; }
|
||||
.panel.active { display: block; }
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.panel-head > div:last-child { display: flex; gap: 8px; align-items: center; }
|
||||
|
||||
/* ---------- Кнопки и поля ---------- */
|
||||
|
||||
.btn {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
transition: background .15s, border-color .15s;
|
||||
}
|
||||
.btn:hover:not(:disabled) { background: #2a303a; }
|
||||
.btn:disabled { opacity: .45; cursor: not-allowed; }
|
||||
|
||||
.btn-primary { background: var(--accent-dark); border-color: var(--accent-dark); }
|
||||
.btn-primary:hover:not(:disabled) { background: var(--accent); border-color: var(--accent); }
|
||||
.btn-start { border-color: var(--accent-dark); color: var(--accent); }
|
||||
.btn-stop { border-color: #6a3c3c; color: var(--danger); }
|
||||
.btn-danger { border-color: #6a3c3c; color: var(--danger); }
|
||||
.btn-ghost { background: transparent; }
|
||||
.btn-sm { padding: 5px 10px; font-size: 13px; }
|
||||
.btn-block { width: 100%; margin-top: 8px; }
|
||||
|
||||
input[type=text], input[type=password], input[type=search], input[type=number], textarea, select {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
font: inherit;
|
||||
width: 100%;
|
||||
}
|
||||
input:focus, textarea:focus, select:focus { outline: none; border-color: var(--accent-dark); }
|
||||
/* Селектор с тегом нужен, чтобы перебить width из правила input[type=...]
|
||||
выше: у него специфичность выше, чем у одного класса. */
|
||||
input.input-sm, .input-sm { width: auto; padding: 6px 10px; font-size: 13px; }
|
||||
|
||||
.field { display: block; margin-bottom: 14px; }
|
||||
.field > span { display: block; margin-bottom: 5px; font-size: 13px; color: var(--muted); }
|
||||
.field small { display: block; margin-top: 4px; color: var(--muted); font-size: 12px; }
|
||||
|
||||
.checkbox { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font-size: 13px; }
|
||||
.checkbox input { width: auto; }
|
||||
|
||||
/* ---------- Карточки обзора ---------- */
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 14px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.stat { font-size: 24px; font-weight: 600; margin-bottom: 4px; }
|
||||
|
||||
.metric {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.metric:last-child { border-bottom: none; }
|
||||
.metric span { color: var(--muted); }
|
||||
|
||||
.player-list { list-style: none; margin: 6px 0 0; padding: 0; max-height: 120px; overflow-y: auto; }
|
||||
.player-list li { padding: 3px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
|
||||
|
||||
/* ---------- Консоль ---------- */
|
||||
|
||||
.console-toolbar { display: flex; gap: 10px; align-items: center; margin-bottom: 10px; flex-wrap: wrap; }
|
||||
.console-toolbar #console-filter { flex: 1 1 200px; max-width: 320px; }
|
||||
|
||||
.console {
|
||||
background: #0f1216;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
height: 60vh;
|
||||
min-height: 320px;
|
||||
overflow-y: auto;
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.console .line-manager { color: var(--info); }
|
||||
.console .line-task { color: var(--warn); }
|
||||
.console .line-console { color: var(--accent); }
|
||||
.console .line-stderr { color: #e08c8c; }
|
||||
|
||||
.console-input { display: flex; gap: 8px; margin-top: 10px; }
|
||||
.console-input input { font-family: var(--mono); }
|
||||
|
||||
/* ---------- Формы конфигов ---------- */
|
||||
|
||||
.fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.cfg-field {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
}
|
||||
.cfg-field.changed { border-color: var(--accent-dark); }
|
||||
.cfg-field label { display: block; font-size: 13px; margin-bottom: 6px; }
|
||||
.cfg-field .hint { color: var(--muted); font-size: 12px; margin-top: 5px; }
|
||||
.cfg-field .key { color: var(--muted); font-family: var(--mono); font-size: 11px; }
|
||||
|
||||
/* ---------- Моды ---------- */
|
||||
|
||||
.mod-add { display: flex; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; }
|
||||
.mod-add input { flex: 1; min-width: 180px; }
|
||||
|
||||
.mod-list { list-style: none; padding: 0; margin: 0 0 18px; }
|
||||
|
||||
.mod-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 9px 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.mod-list .mod-title { flex: 1; min-width: 0; }
|
||||
.mod-list .mod-title b { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.mod-list .mod-title small { color: var(--muted); font-family: var(--mono); font-size: 11px; }
|
||||
.mod-list img { width: 40px; height: 40px; object-fit: cover; border-radius: 6px; background: var(--bg); }
|
||||
|
||||
/* ---------- Таблицы ---------- */
|
||||
|
||||
.table { width: 100%; border-collapse: collapse; }
|
||||
.table th {
|
||||
text-align: left;
|
||||
color: var(--muted);
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.table td { padding: 10px 8px; border-bottom: 1px solid var(--border); }
|
||||
.table td:last-child { text-align: right; white-space: nowrap; }
|
||||
|
||||
.user-list { list-style: none; padding: 0; margin: 0 0 12px; }
|
||||
.user-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 12px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.user-list li span { flex: 1; }
|
||||
|
||||
/* ---------- Задача и уведомления ---------- */
|
||||
|
||||
.task-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 20px;
|
||||
background: #2a2618;
|
||||
border-bottom: 1px solid #4a4020;
|
||||
color: var(--warn);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border: 2px solid rgba(224, 169, 92, .3);
|
||||
border-top-color: var(--warn);
|
||||
border-radius: 50%;
|
||||
animation: spin .8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.toasts {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
z-index: 50;
|
||||
max-width: 380px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--info);
|
||||
border-radius: 8px;
|
||||
padding: 11px 14px;
|
||||
font-size: 13px;
|
||||
animation: slide-in .2s ease-out;
|
||||
}
|
||||
.toast.ok { border-left-color: var(--accent); }
|
||||
.toast.err { border-left-color: var(--danger); }
|
||||
|
||||
@keyframes slide-in { from { opacity: 0; transform: translateX(16px); } }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.topbar { gap: 8px; }
|
||||
.controls { width: 100%; }
|
||||
.controls .btn { flex: 1; }
|
||||
.content { padding: 14px; }
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
// Package web содержит статические файлы панели, вшитые в бинарник.
|
||||
package web
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed static
|
||||
var files embed.FS
|
||||
|
||||
// Static возвращает файловую систему с содержимым web/static.
|
||||
func Static() (fs.FS, error) {
|
||||
return fs.Sub(files, "static")
|
||||
}
|
||||
Reference in New Issue
Block a user