Веб-панель управления сервером 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:
2026-09-03 22:41:11 +03:00
co-authored by Claude Opus 5
commit d0ff64c272
38 changed files with 6054 additions and 0 deletions
+919
View File
@@ -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);
});
+237
View File
@@ -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>
+384
View File
@@ -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
View File
@@ -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")
}