Профили серверов: несколько миров с раздельными настройками
Профиль — это отдельный сервер: свой мир, .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:
+174
-8
@@ -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 || "Настройки сохранены");
|
||||
|
||||
Reference in New Issue
Block a user