Профили серверов: несколько миров с раздельными настройками

Профиль — это отдельный сервер: свой мир, .ini, песочница, набор модов и
лимит памяти JVM. Project Zomboid различает их по -servername, поэтому
файлы профилей не пересекаются. Работает всегда один профиль: серверные
файлы, игровые порты и файл запуска JVM общие, так что переключение
разрешено только на остановленном сервере.

- config: список профилей вместо одиночных server_name/admin_password/
  java_memory; старые поля переносятся в первый профиль при загрузке;
- supervisor: запускает активный профиль и выставляет его -Xmx на каждом
  старте, потому что файл запуска общий;
- бэкапы: архив снимается с одного профиля (мир + его конфиги), имя
  профиля попадает в имя файла, ротация считается по профилям;
- API профилей: список, создание с копированием настроек из другого
  профиля, правка, удаление и переключение активного;
- панель: селектор профиля в шапке и вкладка со списком профилей.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 00:27:12 +03:00
co-authored by Claude Opus 5
parent c384d60416
commit 277ab7af4a
13 changed files with 1317 additions and 183 deletions
+174 -8
View File
@@ -124,6 +124,7 @@ function showApp(login) {
$("#auth-screen").classList.add("hidden");
$("#app").classList.remove("hidden");
$("#current-user").textContent = login;
loadProfiles().catch(fail);
startPolling();
openConsoleStream();
loadTab(currentTab);
@@ -188,6 +189,7 @@ $("#tabs").addEventListener("click", (event) => {
// модов меняются редко, держать их в опросе статуса незачем.
function loadTab(name) {
const loaders = {
profiles: loadProfiles,
config: loadServerConfig,
sandbox: loadSandbox,
mods: loadMods,
@@ -234,6 +236,7 @@ function renderStatus(status) {
$("#status-dot").className = `dot ${status.state}`;
$("#ov-state").textContent = label;
$("#ov-profile").textContent = status.profile_title ? `Профиль: ${status.profile_title}` : "";
$("#ov-uptime").textContent = formatUptime(status.uptime_sec);
$("#ov-pid").textContent = status.pid ? `PID ${status.pid}` : "";
$("#ov-lastexit").textContent =
@@ -267,6 +270,18 @@ function renderStatus(status) {
$("#btn-stop").disabled = !running;
$("#btn-restart").disabled = !status.installed;
$("#btn-install").disabled = running;
// Профили делят игровые порты и файл запуска, поэтому переключать их можно
// только на остановленном сервере.
const picker = $("#profile-select");
picker.disabled = running;
picker.title = running
? "Остановите сервер, чтобы переключить профиль"
: "Профиль сервера: свой мир, конфиг и моды";
// Активный профиль мог смениться в другой вкладке браузера.
if (!running && status.profile && picker.value !== status.profile) {
picker.value = status.profile;
}
}
function renderTask(task) {
@@ -713,6 +728,155 @@ $("#btn-mods-save").addEventListener("click", async () => {
}
});
// ---------------------------------------------------------------------------
// Профили серверов
// ---------------------------------------------------------------------------
let profiles = { list: [], active: "" };
async function loadProfiles() {
const data = await get("/api/profiles");
profiles = { list: data.profiles || [], active: data.active };
renderProfileSelect();
renderProfileCards();
renderCopySource();
}
function renderProfileSelect() {
const select = $("#profile-select");
select.innerHTML = profiles.list
.map((p) => `<option value="${escapeHTML(p.id)}">${escapeHTML(p.title || p.id)}</option>`)
.join("");
select.value = profiles.active;
}
function renderProfileCards() {
$("#profile-cards").innerHTML = profiles.list
.map((p) => {
const badge = p.active ? `<span class="badge">активный</span>` : "";
const state = p.has_config
? p.world_ready
? "мир создан"
: "настроен, мир ещё не создан"
: "ни разу не запускался";
return `<div class="card profile-card" data-profile="${escapeHTML(p.id)}">
<h3>${escapeHTML(p.title || p.id)} ${badge}</h3>
<div class="key">${escapeHTML(p.id)}</div>
<p class="muted small">${state}</p>
<label class="field">
<span>Название</span>
<input type="text" data-field="title" value="${escapeHTML(p.title || "")}">
</label>
<label class="field">
<span>Память JVM</span>
<input type="text" data-field="java_memory" value="${escapeHTML(p.java_memory || "")}">
</label>
<label class="field">
<span>Пароль админа игры</span>
<input type="password" data-field="admin_password" autocomplete="new-password"
placeholder="${p.has_admin_password ? "пароль задан" : "не задан"}">
</label>
<div class="profile-actions">
<button class="btn btn-sm btn-primary" data-action="save">Сохранить</button>
<button class="btn btn-sm" data-action="activate" ${p.active ? "disabled" : ""}>Сделать активным</button>
<button class="btn btn-sm btn-danger" data-action="delete" ${p.active ? "disabled" : ""}>Удалить</button>
</div>
</div>`;
})
.join("");
}
// renderCopySource предлагает копировать настройки только с тех профилей, у
// которых конфиг уже создан игрой.
function renderCopySource() {
$("#new-profile-copy").innerHTML =
`<option value="">не копировать</option>` +
profiles.list
.filter((p) => p.has_config)
.map((p) => `<option value="${escapeHTML(p.id)}">${escapeHTML(p.title || p.id)}</option>`)
.join("");
}
// switchProfile меняет активный профиль. Все вкладки показывают именно его
// конфиг, моды и мир, поэтому открытую вкладку перезагружаем.
async function switchProfile(id) {
try {
await post(`/api/profiles/${encodeURIComponent(id)}/activate`);
await loadProfiles();
ok("Активный профиль переключён");
refreshStatus();
loadTab(currentTab);
} catch (error) {
fail(error);
// Селектор показывает то, чего не произошло, — возвращаем прежнее значение.
$("#profile-select").value = profiles.active;
}
}
$("#profile-select").addEventListener("change", (event) => switchProfile(event.target.value));
$("#profile-cards").addEventListener("click", async (event) => {
const button = event.target.closest("button[data-action]");
if (!button) return;
const card = button.closest(".profile-card");
const id = card.dataset.profile;
const field = (name) => card.querySelector(`[data-field="${name}"]`).value;
try {
switch (button.dataset.action) {
case "save":
await put(`/api/profiles/${encodeURIComponent(id)}`, {
title: field("title"),
java_memory: field("java_memory"),
admin_password: field("admin_password"),
});
ok("Профиль сохранён");
await loadProfiles();
break;
case "activate":
await switchProfile(id);
break;
case "delete":
if (!confirm(`Убрать профиль ${id} из панели?\n\n` +
`Мир и конфиги останутся на диске — профиль можно вернуть, создав его с тем же идентификатором.`)) {
return;
}
await del(`/api/profiles/${encodeURIComponent(id)}`);
ok("Профиль удалён");
await loadProfiles();
break;
}
} catch (error) {
fail(error);
}
});
$("#profile-add-form").addEventListener("submit", async (event) => {
event.preventDefault();
try {
await post("/api/profiles", {
id: $("#new-profile-id").value.trim(),
title: $("#new-profile-title").value.trim(),
java_memory: $("#new-profile-memory").value.trim(),
copy_from: $("#new-profile-copy").value,
});
$("#new-profile-id").value = "";
$("#new-profile-title").value = "";
$("#new-profile-memory").value = "";
ok("Профиль создан");
await loadProfiles();
} catch (error) {
fail(error);
}
});
// ---------------------------------------------------------------------------
// Бэкапы
// ---------------------------------------------------------------------------
@@ -724,6 +888,7 @@ async function loadBackups() {
const rows = (data.backups || []).map(
(archive) => `<tr>
<td>${escapeHTML(archive.name)}</td>
<td>${escapeHTML(profileTitle(archive.profile))}</td>
<td>${formatBytes(archive.size_bytes)}</td>
<td>${formatDate(archive.created_at)}</td>
<td>
@@ -734,7 +899,15 @@ async function loadBackups() {
</tr>`
);
$("#backup-rows").innerHTML = rows.join("") ||
`<tr><td colspan="4" class="muted">Пока ни одного архива</td></tr>`;
`<tr><td colspan="5" class="muted">Пока ни одного архива</td></tr>`;
}
// profileTitle подписывает архив понятным именем профиля. У архивов, снятых до
// появления профилей, идентификатора в имени нет.
function profileTitle(id) {
if (!id) return "—";
const profile = profiles.list.find((p) => p.id === id);
return profile ? profile.title || profile.id : id;
}
$("#backup-form").addEventListener("submit", async (event) => {
@@ -781,9 +954,6 @@ const SETTINGS_FIELDS = [
["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"],
@@ -813,8 +983,6 @@ async function loadSettings() {
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] ?? "";
}
@@ -850,8 +1018,6 @@ $("#btn-settings-save").addEventListener("click", async () => {
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 || "Настройки сохранены");