5 Commits
Author SHA1 Message Date
mrleo1nidandClaude Opus 5 e0c33706b3 Консоль не пустеет после перезапуска менеджера
CI / test (push) Successful in 25s
Release / release (push) Successful in 37s
Номера строк консоли живут в памяти менеджера и после его перезапуска
начинаются заново, а открытая вкладка отбрасывает всё, чей номер не больше
последнего увиденного. После обновления панели консоль оставалась пустой при
живом сервере — до перезагрузки страницы.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 21:05:10 +03:00
mrleo1nidandClaude Opus 5 ae8c511a9a Импорт пресета не терял моды: полный набор и активный профиль
CI / test (push) Successful in 24s
Release / release (push) Successful in 33s
В пакете сборки рядом с комплектом сервера лежит пресет песочницы одиночной
игры. Наборы сортировались по имени, и пресет песочницы часто оказывался
первым — панель предлагала его по умолчанию, а модов и карт в нём нет.
Теперь первым идёт набор с конфигом сервера, а в списке выбора видно, что
внутри каждого.

Вкладки показывают активный профиль, поэтому импорт в новый выглядел так,
будто ничего не подтянулось. Появилась галочка «Сделать этот профиль
активным»; если её снять, панель говорит, где искать импортированное.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 20:21:41 +03:00
mrleo1nidandClaude Opus 5 bfb9271635 Импорт пресета песочницы .cfg и списка модов
CI / test (push) Successful in 24s
Release / release (push) Successful in 32s
Сборки нередко раздают только пресет одиночной игры. Внутри это плоский
список «Параметр=значение» с точками вместо вложенности, поэтому значения
переносятся в SandboxVars.lua профиля по одному: комментарии игры и порядок
остаются на месте, а параметры модов, которых в файле ещё нет, пропускаются
со счётом. Если файла нет вовсе, он собирается из пресета целиком.

Заодно панель принимает строку из игровой кнопки «Поделиться»
(«Название:ModA;ModB;…»): она заменяет список включённых модов, сохраняя
порядок загрузки. Идентификаторов мастерской в такой строке нет, поэтому
пакеты добавляются только для модов, чьи файлы уже на диске, а про остальные
панель предупреждает.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 17:32:28 +03:00
mrleo1nidandClaude Opus 5 aec932f68f Импорт пресетов сборок из ZIP-пакета
Release / release (push) Successful in 30s
CI / test (push) Successful in 23s
Крупные сборки раздают готовый комплект настроек архивом. Панель разбирает
его сама: находит серверный .ini, песочницу и точки появления, показывает,
что нашла (сколько модов, карт, параметров), и только по подтверждению
раскладывает файлы в новый или существующий профиль.

Порты, пароли и название сервера при этом можно оставить свои — они относятся
к машине, а не к сборке; у нового профиля они берутся из активного. Прежние
конфиги остаются рядом как .bak, мир не трогается, а импорт в работающий
профиль отклоняется: PZ перезаписал бы файлы своими при остановке.

Посторонние .ini из архива (настройки модов) за конфиг сервера не
принимаются: он опознаётся по знакомым параметрам.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 17:18:44 +03:00
mrleo1nidandClaude Opus 5 1e659678b9 «Работает» — только после строки о готовности сервера
CI / test (push) Successful in 23s
Release / release (push) Successful in 30s
Регистрацию в Steam сервер печатает задолго до того, как мир загружен, и по
ней панель показывала «Работает» на сервере, который ещё никого не пустит.
Теперь готовность распознаётся только по «*** SERVER STARTED ****», а пока
идёт загрузка, состояние остаётся «Запускается» с пояснением под ним и
счётчиком времени загрузки.

Если строки о готовности так и не будет (своя версия, свои моды), через
пятнадцать минут менеджер сочтёт сервер запущенным и скажет об этом в
консоли: иначе панель навсегда осталась бы без опроса игроков.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 14:15:54 +03:00
19 changed files with 1801 additions and 19 deletions
+80 -1
View File
@@ -16,7 +16,9 @@
песочница, моды и лимит памяти) с переключением одной кнопкой. песочница, моды и лимит памяти) с переключением одной кнопкой.
- **Управление сервером** — запуск, остановка (через команду `quit`, мир - **Управление сервером** — запуск, остановка (через команду `quit`, мир
сохраняется), перезапуск, автоподъём после падения, автозапуск вместе с сохраняется), перезапуск, автоподъём после падения, автозапуск вместе с
менеджером. менеджером. «Работает» панель показывает не раньше, чем сервер напечатает
`*** SERVER STARTED ****`: пока идёт загрузка мира, состояние остаётся
«Запускается».
- **Живая консоль** — вывод сервера в реальном времени и отправка любых команд - **Живая консоль** — вывод сервера в реальном времени и отправка любых команд
(`players`, `save`, `servermsg`, `kickuser`, …). (`players`, `save`, `servermsg`, `kickuser`, …).
- **Мониторинг** — состояние, аптайм, CPU и память процесса сервера, свободные - **Мониторинг** — состояние, аптайм, CPU и память процесса сервера, свободные
@@ -26,6 +28,11 @@
самого конфига, поиск идёт и по названию, и по описанию. самого конфига, поиск идёт и по названию, и по описанию.
- **Мод-менеджер** — моды списком с галочками и перетаскиванием порядка - **Мод-менеджер** — моды списком с галочками и перетаскиванием порядка
загрузки, добавление по ссылке и разворачивание коллекций Steam целиком. загрузки, добавление по ссылке и разворачивание коллекций Steam целиком.
- **Импорт пресетов сборок** — ZIP-пакет сборки загружается прямо в панель:
конфиг, песочница и точки появления попадают в новый или существующий
профиль, свои порты и пароли при этом сохраняются. Отдельно принимаются
пресет песочницы одиночной игры (`.cfg`) и строка со списком модов из
игровой кнопки «Поделиться».
- **Бэкапы** — ручные и по расписанию, отдельно по каждому профилю, с - **Бэкапы** — ручные и по расписанию, отдельно по каждому профилю, с
ротацией, скачиванием и восстановлением (перед восстановлением ротацией, скачиванием и восстановлением (перед восстановлением
автоматически снимается страховочная копия). автоматически снимается страховочная копия).
@@ -176,6 +183,78 @@ curl -fsSL https://gitea.hsrv.site/mrleo1nid/pzmanager/raw/branch/main/deploy/ge
менеджера в консоли. Вывод самого игрового сервера остаётся как есть — он менеджера в консоли. Вывод самого игрового сервера остаётся как есть — он
приходит от Project Zomboid. приходит от Project Zomboid.
## Импорт пресетов сборок
Крупные сборки модов раздают готовый комплект настроек архивом: внутри лежат
папка `Server` с `<имя>.ini`, `<имя>_SandboxVars.lua` и
`<имя>_spawnregions.lua`, а рядом — файлы для одиночной игры, которые серверу
не нужны.
На вкладке «Профили» есть «Импорт пресета сборки»: выберите ZIP, и панель
покажет, что в нём нашла — название сервера, сколько модов и пакетов
мастерской, какие карты, сколько параметров конфига и песочницы. Ничего не
записывается, пока вы не нажмёте «Импортировать».
Если наборов в архиве несколько, появится список выбора. Первым идёт самый
полный набор — тот, где есть конфиг сервера: только в нём лежат моды и карты.
Рядом с именем написано, что внутри, чтобы случайно не импортировать пресет
песочницы вместо комплекта сервера.
Импортировать можно в новый профиль (идентификатор предлагается по имени
файлов из архива) или поверх существующего. Прежние конфиги остаются рядом с
расширением `.bak`, мир не трогается. Профиль, который сейчас работает,
импорт не принимает — сначала остановите сервер, иначе PZ перезапишет файлы
своими при выходе.
Галочка «Сделать этот профиль активным» включена по умолчанию: вкладки «Моды»
и «Конфиг сервера» показывают активный профиль, и без переключения кажется,
что импорт ничего не сделал. Если галочку снять, панель напомнит, под каким
профилем искать импортированное.
Галочки «Оставить свои значения» переносят в импортированный конфиг то, что
относится к вашей машине, а не к сборке:
| группа | параметры |
| --- | --- |
| Порты и RCON | `DefaultPort`, `UDPPort`, `RCONPort`, `RCONPassword` |
| Пароль сервера и объявляемый IP | `Password`, `server_browser_announced_ip` |
| Имя и описание сервера | `PublicName`, `PublicDescription` |
Для нового профиля эти значения берутся из активного: порты и пароли на
машине общие. Всё остальное — списки модов, карты, правила и песочница —
приходит из пресета.
Посторонние `.ini` из архива (настройки модов, например) не принимаются за
конфиг сервера: панель опознаёт его по знакомым параметрам.
### Пресеты песочницы одиночной игры
Сборки нередко раздают только пресет для одиночной игры — файл `.cfg` из папки
`Sandbox Presets`. Панель принимает и его: как отдельный файл, и как часть
архива. Внутри это плоский список `Параметр=значение` с точками вместо
вложенности (`ZombieLore.Speed=2`), а серверу нужна таблица Lua, поэтому
значения переносятся по одному.
Если у профиля уже есть свой `SandboxVars.lua`, панель меняет в нём только
значения — комментарии игры, порядок и остальные параметры остаются на месте.
Параметры, которых в файле нет (обычно это настройки модов, пока моды не
установлены и сервер ни разу не запускался), пропускаются, и панель говорит,
сколько перенесла и сколько пропустила. Если файла ещё нет, он создаётся
целиком из пресета.
### Список модов из кнопки «Поделиться»
Игра умеет отдавать набор модов одной строкой: `Название:ModA;ModB;ModC`.
На вкладке «Моды» есть поле «Импорт списка модов» — вставьте её туда, и
список включённых модов заменится на этот, в том же порядке загрузки. Список
попадает в конфиг только после кнопки «Сохранить», так что до неё всё можно
отменить, просто переключив вкладку.
В такой строке нет идентификаторов мастерской, поэтому сама по себе она не
заставит сервер что-то скачать: панель добавит пакеты только тех модов, чьи
файлы уже лежат на диске, и скажет, для скольких модов файлов не нашлось.
Пакеты для них добавляются коллекцией на той же вкладке.
## Моды ## Моды
Project Zomboid хранит моды в двух строках конфига: `WorkshopItems` — что Project Zomboid хранит моды в двух строках конфига: `WorkshopItems` — что
+299
View File
@@ -0,0 +1,299 @@
package api
import (
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/mrleo1nid/pzmanager/internal/config"
"github.com/mrleo1nid/pzmanager/internal/preset"
"github.com/mrleo1nid/pzmanager/internal/pzconfig"
"github.com/mrleo1nid/pzmanager/internal/pzserver"
)
// keepGroups — то, что при импорте разумно оставить своим. Пресет собирали не
// под эту машину: его порты, пароли и название сервера к ней отношения не
// имеют, и молча их подменять — верный способ потерять доступ к серверу.
var keepGroups = map[string][]string{
"ports": {"DefaultPort", "UDPPort", "RCONPort", "RCONPassword"},
"access": {"Password", "server_browser_announced_ip", "PublicServerIP", "ServerBrowserAnnouncedIP"},
"identity": {"PublicName", "PublicDescription"},
}
// handlePreviewPreset разбирает загруженный архив и рассказывает, что в нём
// нашлось. Ничего не пишет: решение принимает человек.
func (s *Server) handlePreviewPreset(w http.ResponseWriter, r *http.Request) {
sets, ok := s.readPresetUpload(w, r)
if !ok {
return
}
summaries := make([]preset.Summary, 0, len(sets))
for _, set := range sets {
summaries = append(summaries, set.Describe())
}
writeJSON(w, http.StatusOK, map[string]any{"presets": summaries})
}
// handleImportPreset записывает выбранный набор в профиль — новый или уже
// существующий.
func (s *Server) handleImportPreset(w http.ResponseWriter, r *http.Request) {
sets, ok := s.readPresetUpload(w, r)
if !ok {
return
}
files, err := pickPreset(sets, r.FormValue("preset"))
if err != nil {
writeError(w, r, http.StatusBadRequest, err.Error())
return
}
createNew := r.FormValue("target") != "existing"
activate := r.FormValue("activate") == "1"
id := strings.TrimSpace(r.FormValue("profile"))
if id == "" && createNew {
id = config.SanitizeProfileID(files.Name)
}
_, exists := s.cfg.Profile(id)
switch {
case createNew && exists:
writeError(w, r, http.StatusConflict, fmt.Sprintf("профиль %q уже существует", id))
return
case !createNew && !exists:
writeError(w, r, http.StatusNotFound, "профиль не найден")
return
}
// Живой сервер держит мир и конфиг в памяти и переписывает файлы при
// остановке — импорт под ним просто пропал бы. Переключать профиль на
// живом сервере нельзя по той же причине.
if s.sup.State() != pzserver.StateStopped && (id == s.cfg.Active || activate) {
writeError(w, r, http.StatusConflict, "сначала остановите игровой сервер")
return
}
if createNew {
profile := config.Profile{ID: id, Title: strings.TrimSpace(r.FormValue("title"))}
if profile.Title == "" {
profile.Title = files.Name
}
if err := s.cfg.AddProfile(profile); err != nil {
writeError(w, r, http.StatusBadRequest, err.Error())
return
}
}
result, err := s.applyPreset(files, id, parseKeep(r.FormValue("keep")))
if err != nil {
if createNew {
// Профиль без файлов настроек только мешал бы: откатываем.
_ = s.cfg.DeleteProfile(id)
}
writeError(w, r, http.StatusInternalServerError, err.Error())
return
}
// Вкладки панели показывают активный профиль: без переключения импорт
// выглядит так, будто моды и настройки не подтянулись.
switched := false
if activate && s.cfg.Active != id {
if err := s.cfg.SetActive(id); err != nil {
writeError(w, r, http.StatusInternalServerError, err.Error())
return
}
switched = true
}
if createNew || switched {
if err := s.cfg.Save(); err != nil {
writeError(w, r, http.StatusInternalServerError, err.Error())
return
}
}
summary := files.Describe()
s.sup.AppendLog("manager", fmt.Sprintf("Импортирован пресет %q в профиль %q (модов: %d)",
files.Name, id, summary.Mods))
if switched {
s.sup.AppendLog("manager", fmt.Sprintf("Активный профиль: %s", s.cfg.ActiveProfile().Name()))
}
result["profile"] = id
result["created"] = createNew
result["switched"] = switched
result["summary"] = summary
writeJSON(w, http.StatusOK, result)
}
// applyPreset раскладывает файлы набора под именем профиля. Прежние версии
// сохраняются рядом с расширением .bak — так же, как при правке в редакторе.
func (s *Server) applyPreset(files preset.Files, id string, keep []string) (map[string]any, error) {
written := []string{}
kept := []string{}
keptFrom := ""
if files.INI != nil {
ini := pzconfig.ParseINI(files.INI)
// Свои значения берём из конфига самого профиля, а у нового профиля
// их ещё нет — тогда из активного: порты и пароли на машине общие.
source, from := s.keepSource(id)
if source != nil {
for _, key := range keep {
if source.Has(key) {
ini.Set(key, source.Get(key))
kept = append(kept, key)
}
}
if len(kept) > 0 {
keptFrom = from
}
}
path := s.cfg.ServerConfigPathFor(id)
if err := pzconfig.SaveINI(path, ini); err != nil {
return nil, err
}
written = append(written, filepath.Base(path))
}
result := map[string]any{"kept": kept, "kept_from": keptFrom}
sandboxPath := s.cfg.SandboxConfigPathFor(id)
switch {
case files.Sandbox != nil:
// Готовый файл сервера кладём как есть.
if err := pzconfig.SaveRaw(sandboxPath, files.Sandbox); err != nil {
return nil, err
}
written = append(written, filepath.Base(sandboxPath))
case files.SandboxPreset != nil:
applied, skipped, err := applySandboxPreset(sandboxPath, files.SandboxPreset)
if err != nil {
return nil, err
}
written = append(written, filepath.Base(sandboxPath))
result["applied"] = applied
result["skipped"] = skipped
}
if files.SpawnRegions != nil {
path := spawnRegionsPath(s.cfg, id)
if err := pzconfig.SaveRaw(path, files.SpawnRegions); err != nil {
return nil, err
}
written = append(written, filepath.Base(path))
}
result["files"] = written
return result, nil
}
// applySandboxPreset переносит значения пресета одиночной игры в песочницу
// профиля. Пресет плоский, а файл сервера — таблица Lua с комментариями игры,
// поэтому значения переставляются по одному, а сам файл остаётся прежним.
// Если файла ещё нет (профиль ни разу не запускался), он создаётся целиком.
func applySandboxPreset(path string, data []byte) (applied, skipped int, err error) {
values := preset.ParseSandboxPreset(data)
sandbox, err := pzconfig.LoadSandbox(path)
if os.IsNotExist(err) {
return len(values.Values), 0, pzconfig.SaveRaw(path, values.Lua())
}
if err != nil {
return 0, 0, err
}
for _, key := range values.Order() {
// Параметров модов в файле нет, пока моды не установлены и сервер не
// запускался: такие значения пропускаем и считаем.
if err := sandbox.Set(key, values.Values[key]); err != nil {
skipped++
continue
}
applied++
}
if applied == 0 {
return 0, skipped, errors.New("ни один параметр пресета не подошёл к настройкам профиля")
}
return applied, skipped, pzconfig.SaveSandbox(path, sandbox)
}
// keepSource выбирает конфиг, из которого берутся сохраняемые значения.
func (s *Server) keepSource(id string) (*pzconfig.INI, string) {
candidates := []string{id}
if id != s.cfg.Active {
candidates = append(candidates, s.cfg.Active)
}
for _, candidate := range candidates {
ini, err := pzconfig.LoadINI(s.cfg.ServerConfigPathFor(candidate))
if err == nil {
return ini, candidate
}
}
return nil, ""
}
// spawnRegionsPath — <профиль>_spawnregions.lua рядом с остальными конфигами.
func spawnRegionsPath(cfg *config.Config, id string) string {
return filepath.Join(filepath.Dir(cfg.ServerConfigPathFor(id)), id+"_spawnregions.lua")
}
// parseKeep разбирает список групп, которые человек отметил в панели.
func parseKeep(value string) []string {
keys := []string{}
for _, group := range strings.Split(value, ",") {
keys = append(keys, keepGroups[strings.TrimSpace(group)]...)
}
return keys
}
// pickPreset выбирает набор по имени; когда набор один, имя необязательно.
func pickPreset(sets []preset.Files, name string) (preset.Files, error) {
if name == "" {
if len(sets) == 1 {
return sets[0], nil
}
return preset.Files{}, errors.New("в архиве несколько наборов настроек — выберите один")
}
for _, set := range sets {
if set.Name == name {
return set, nil
}
}
return preset.Files{}, fmt.Errorf("набор %q не найден в архиве", name)
}
// readPresetUpload принимает архив из формы и разбирает его.
func (s *Server) readPresetUpload(w http.ResponseWriter, r *http.Request) ([]preset.Files, bool) {
r.Body = http.MaxBytesReader(w, r.Body, preset.MaxArchiveSize)
if err := r.ParseMultipartForm(8 << 20); err != nil {
writeError(w, r, http.StatusBadRequest, "файл не принят: "+err.Error())
return nil, false
}
file, header, err := r.FormFile("file")
if err != nil {
writeError(w, r, http.StatusBadRequest, "файл не выбран")
return nil, false
}
defer file.Close()
name := strings.ToLower(header.Filename)
if !strings.HasSuffix(name, ".zip") && !strings.HasSuffix(name, ".cfg") {
writeError(w, r, http.StatusBadRequest,
"ожидается ZIP-пакет настроек или пресет песочницы .cfg")
return nil, false
}
data, err := io.ReadAll(io.LimitReader(file, preset.MaxArchiveSize))
if err != nil {
writeError(w, r, http.StatusBadRequest, "файл не прочитан: "+err.Error())
return nil, false
}
sets, err := preset.FromUpload(header.Filename, data)
if err != nil {
writeError(w, r, http.StatusBadRequest, err.Error())
return nil, false
}
return sets, true
}
+22 -2
View File
@@ -79,7 +79,10 @@ func (s *Server) handleConsole(w http.ResponseWriter, r *http.Request) {
// handleLogs отдаёт историю консоли начиная с указанного номера строки. // handleLogs отдаёт историю консоли начиная с указанного номера строки.
func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) { func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) {
since, _ := strconv.ParseInt(r.URL.Query().Get("since"), 10, 64) since, _ := strconv.ParseInt(r.URL.Query().Get("since"), 10, 64)
writeJSON(w, http.StatusOK, map[string]any{"lines": translateLines(r, s.sup.Lines(since))}) writeJSON(w, http.StatusOK, map[string]any{
"epoch": s.sup.LogEpoch(),
"lines": translateLines(r, s.sup.Lines(since)),
})
} }
// translateLines переводит строки консоли на язык читающего. Буфер один на // translateLines переводит строки консоли на язык читающего. Буфер один на
@@ -119,7 +122,13 @@ func (s *Server) handleLogStream(w http.ResponseWriter, r *http.Request) {
lang := i18n.FromRequest(r) lang := i18n.FromRequest(r)
// Сначала отдаём хвост истории, чтобы вкладка не открывалась пустой. // Метка запуска идёт первой: по ней вкладка понимает, что менеджер
// перезапустился и нумерация строк началась заново.
if !writeSSEEvent(w, "meta", map[string]string{"epoch": s.sup.LogEpoch()}) {
return
}
// Затем хвост истории, чтобы вкладка не открывалась пустой.
since, _ := strconv.ParseInt(r.URL.Query().Get("since"), 10, 64) since, _ := strconv.ParseInt(r.URL.Query().Get("since"), 10, 64)
for _, line := range translateLines(r, s.sup.Lines(since)) { for _, line := range translateLines(r, s.sup.Lines(since)) {
if !writeSSE(w, line) { if !writeSSE(w, line) {
@@ -162,3 +171,14 @@ func writeSSE(w http.ResponseWriter, line pzserver.LogLine) bool {
_, err = fmt.Fprintf(w, "data: %s\n\n", payload) _, err = fmt.Fprintf(w, "data: %s\n\n", payload)
return err == nil return err == nil
} }
// writeSSEEvent отправляет именованное событие: браузер ловит его отдельным
// слушателем и не путает со строками консоли.
func writeSSEEvent(w http.ResponseWriter, name string, value any) bool {
payload, err := json.Marshal(value)
if err != nil {
return true
}
_, err = fmt.Fprintf(w, "event: %s\ndata: %s\n\n", name, payload)
return err == nil
}
+2
View File
@@ -117,6 +117,8 @@ func (s *Server) Handler() http.Handler {
"PUT /api/profiles/{id}": s.handleUpdateProfile, "PUT /api/profiles/{id}": s.handleUpdateProfile,
"DELETE /api/profiles/{id}": s.handleDeleteProfile, "DELETE /api/profiles/{id}": s.handleDeleteProfile,
"POST /api/profiles/{id}/activate": s.handleActivateProfile, "POST /api/profiles/{id}/activate": s.handleActivateProfile,
"POST /api/presets/preview": s.handlePreviewPreset,
"POST /api/presets/import": s.handleImportPreset,
"GET /api/mods": s.handleGetMods, "GET /api/mods": s.handleGetMods,
"PUT /api/mods": s.handlePutMods, "PUT /api/mods": s.handlePutMods,
"POST /api/mods/lookup": s.handleLookupMods, "POST /api/mods/lookup": s.handleLookupMods,
+19
View File
@@ -59,6 +59,25 @@ func ValidateProfileID(id string) error {
return nil return nil
} }
// SanitizeProfileID превращает имя файла из чужого пакета настроек в годный
// идентификатор профиля: пробелы и точки в именах пресетов не редкость, а
// идентификатор становится именем конфига и папки мира.
func SanitizeProfileID(name string) string {
var b strings.Builder
for _, r := range name {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
b.WriteRune(r)
default:
b.WriteRune('-')
}
if b.Len() >= 32 {
break
}
}
return strings.Trim(b.String(), "-")
}
// Config — всё, что менеджеру нужно знать о хосте и о серверах PZ. // Config — всё, что менеджеру нужно знать о хосте и о серверах PZ.
type Config struct { type Config struct {
// Listen — адрес веб-панели. Для доступа только через SSH-туннель // Listen — адрес веб-панели. Для доступа только через SSH-туннель
+16
View File
@@ -70,6 +70,12 @@ var messages = map[string]string{
"Останавливаю сервер командой quit...": "Stopping the server with the quit command...", "Останавливаю сервер командой quit...": "Stopping the server with the quit command...",
"Сервер не ответил на quit — отправляю SIGTERM": "The server did not answer quit — sending SIGTERM", "Сервер не ответил на quit — отправляю SIGTERM": "The server did not answer quit — sending SIGTERM",
"Сервер завис — отправляю SIGKILL": "The server hung — sending SIGKILL", "Сервер завис — отправляю SIGKILL": "The server hung — sending SIGKILL",
"Сервер не сообщил о готовности — считаю его запущенным": "The server did not report that it is ready — treating it as running",
"в архиве нет серверных файлов Project Zomboid": "the archive has no Project Zomboid server files",
"распакованный архив слишком велик": "the unpacked archive is too large",
"файл не выбран": "no file selected",
"ожидается ZIP-архив пакета настроек": "a ZIP package of settings is expected",
"в архиве несколько наборов настроек — выберите один": "the archive holds several sets of settings — pick one",
"Автоперезапуск отменён": "Automatic restart cancelled", "Автоперезапуск отменён": "Automatic restart cancelled",
"Посмотрите ошибку выше и запустите сервер вручную.": "Check the error above and start the server manually.", "Посмотрите ошибку выше и запустите сервер вручную.": "Check the error above and start the server manually.",
"Запускаю обновление — оно идёт отдельной службой и переживёт перезапуск панели": "Starting the update — it runs as a separate service and survives the panel restart", "Запускаю обновление — оно идёт отдельной службой и переживёт перезапуск панели": "Starting the update — it runs as a separate service and survives the panel restart",
@@ -173,6 +179,16 @@ var formats = [][2]string{
{"Останавливаю игровой сервер перед %s", "Stopping the game server before %s"}, {"Останавливаю игровой сервер перед %s", "Stopping the game server before %s"},
{"Плановый бэкап не запущен: %s", "The scheduled backup did not start: %s"}, {"Плановый бэкап не запущен: %s", "The scheduled backup did not start: %s"},
// --- Импорт пресетов ---
{"архив не читается: %s", "the archive cannot be read: %s"},
{"в архиве слишком много файлов: %s", "the archive has too many files: %s"},
{"файл %s слишком велик", "the file %s is too large"},
{"файл не принят: %s", "the file was not accepted: %s"},
{"файл не прочитан: %s", "the file was not read: %s"},
{"набор %s не найден в архиве", "the set %s was not found in the archive"},
{"Импортирован пресет %s в профиль %s (модов: %s)",
"Preset %s imported into the profile %s (mods: %s)"},
// --- Бэкапы: ход работы --- // --- Бэкапы: ход работы ---
{"Создаю архив %s (профиль %s)", "Creating the archive %s (profile %s)"}, {"Создаю архив %s (профиль %s)", "Creating the archive %s (profile %s)"},
{"Архив готов: %s (%s, файлов: %s)", "Archive ready: %s (%s, files: %s)"}, {"Архив готов: %s (%s, файлов: %s)", "Archive ready: %s (%s, files: %s)"},
+284
View File
@@ -0,0 +1,284 @@
// Package preset разбирает пакеты настроек, которые раздают сборки модов.
//
// Сборка обычно приходит одним архивом с папкой Server внутри: там лежат
// готовый .ini со списком модов, настройки песочницы и точки появления. Всё
// остальное в архиве (файлы одиночной игры, картинки, changelog) менеджеру
// не нужно и молча пропускается.
package preset
import (
"archive/zip"
"bytes"
"errors"
"fmt"
"io"
"path"
"sort"
"strings"
"github.com/mrleo1nid/pzmanager/internal/pzconfig"
)
// Пределы разбора. Архив приходит от человека, но верить ему нельзя: без
// ограничений маленький файл разворачивается в гигабайты.
const (
// MaxArchiveSize — предел на сам загружаемый файл.
MaxArchiveSize = 32 << 20
maxFileSize = 16 << 20
maxTotalSize = 64 << 20
maxEntries = 10000
)
// ErrNoServerFiles возвращается, когда в архиве нет ни одного серверного файла.
var ErrNoServerFiles = errors.New("в архиве нет серверных файлов Project Zomboid")
// ErrNoSandboxPreset — загруженный .cfg не похож на пресет песочницы.
var ErrNoSandboxPreset = errors.New("файл не похож на пресет песочницы")
// Files — серверная часть пакета: то, что PZ читает при запуске.
type Files struct {
// Name — имя, под которым файлы лежали в архиве (sophie-server-42-2).
Name string
INI []byte
Sandbox []byte
// SandboxPreset — пресет песочницы одиночной игры (.cfg). Готового файла
// сервера в пакете может не быть: сборки нередко раздают только его.
SandboxPreset []byte
SpawnRegions []byte
}
// Summary — то, что панель показывает до импорта, чтобы человек понимал, что
// именно он собирается записать поверх своих настроек.
type Summary struct {
Name string `json:"name"`
ServerName string `json:"server_name"`
Maps []string `json:"maps"`
Mods int `json:"mods"`
WorkshopItems int `json:"workshop_items"`
Settings int `json:"settings"`
SandboxSettings int `json:"sandbox_settings"`
HasINI bool `json:"has_ini"`
HasSandbox bool `json:"has_sandbox"`
HasSandboxPreset bool `json:"has_sandbox_preset"`
HasSpawnRegions bool `json:"has_spawn_regions"`
}
// markers — параметры, по которым .ini опознаётся как конфиг сервера. В
// архивах сборок хватает и других .ini (настройки модов, например), и брать
// их за конфиг нельзя.
var markers = []string{
"PublicName", "PublicDescription", "DefaultPort", "UDPPort", "MaxPlayers",
"Mods", "WorkshopItems", "Map", "PVP", "Open", "RCONPort", "ServerWelcomeMessage",
"SaveWorldEveryMinutes", "SpawnPoint",
}
const markersRequired = 3
// FromUpload разбирает то, что загрузили в панель: ZIP-пакет сборки или
// одиночный пресет песочницы.
func FromUpload(filename string, data []byte) ([]Files, error) {
if strings.HasSuffix(strings.ToLower(filename), ".cfg") {
if !looksLikeSandboxPreset(data) {
return nil, ErrNoSandboxPreset
}
name := strings.TrimSuffix(path.Base(filename), path.Ext(filename))
return []Files{{Name: name, SandboxPreset: data}}, nil
}
return FromZip(data)
}
// FromZip достаёт из архива все наборы серверных файлов. Наборов может быть
// несколько: сборки иногда кладут рядом варианты конфигов.
func FromZip(data []byte) ([]Files, error) {
reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, fmt.Errorf("архив не читается: %w", err)
}
if len(reader.File) > maxEntries {
return nil, fmt.Errorf("в архиве слишком много файлов: %d", len(reader.File))
}
sets := map[string]*Files{}
total := 0
for _, entry := range reader.File {
if entry.FileInfo().IsDir() {
continue
}
name := path.Base(strings.ReplaceAll(entry.Name, "\\", "/"))
base, kind := classify(name)
if kind == kindOther {
continue
}
if entry.UncompressedSize64 > maxFileSize {
continue
}
if total+int(entry.UncompressedSize64) > maxTotalSize {
return nil, errors.New("распакованный архив слишком велик")
}
content, err := readEntry(entry)
if err != nil {
return nil, err
}
total += len(content)
if kind == kindINI && !looksLikeServerConfig(content) {
continue
}
// .cfg в архиве хватает и помимо песочницы: списки модов, настройки
// папок. Отличаем по знакомым параметрам.
if kind == kindSandboxPreset && !looksLikeSandboxPreset(content) {
continue
}
set := sets[base]
if set == nil {
set = &Files{Name: base}
sets[base] = set
}
switch kind {
case kindINI:
set.INI = content
case kindSandbox:
set.Sandbox = content
case kindSandboxPreset:
set.SandboxPreset = content
case kindSpawnRegions:
set.SpawnRegions = content
}
}
out := make([]Files, 0, len(sets))
for _, set := range sets {
// Одни точки появления без конфига и песочницы импортировать некуда.
if set.INI == nil && set.Sandbox == nil && set.SandboxPreset == nil {
continue
}
out = append(out, *set)
}
if len(out) == 0 {
return nil, ErrNoServerFiles
}
// Первым идёт самый полный набор: панель предлагает его по умолчанию, а
// одинокий пресет песочницы переносит только значения песочницы — ни модов,
// ни карт в нём нет, и импорт выглядит как «ничего не подтянулось».
sort.Slice(out, func(i, j int) bool {
wi, wj := weight(out[i]), weight(out[j])
if wi != wj {
return wi > wj
}
return out[i].Name < out[j].Name
})
return out, nil
}
// weight — насколько набор полон. Конфиг сервера перевешивает всё остальное:
// только в нём есть список модов, карты и настройки самого сервера.
func weight(f Files) int {
total := 0
if f.INI != nil {
total += 4
}
if f.Sandbox != nil {
total += 2
}
if f.SpawnRegions != nil {
total++
}
return total
}
type kind int
const (
kindOther kind = iota
kindINI
kindSandbox
kindSandboxPreset
kindSpawnRegions
)
// classify определяет по имени файла, часть какого набора перед нами.
func classify(name string) (string, kind) {
lower := strings.ToLower(name)
switch {
case strings.HasSuffix(lower, "_sandboxvars.lua"):
return name[:len(name)-len("_SandboxVars.lua")], kindSandbox
case strings.HasSuffix(lower, "_spawnregions.lua"):
return name[:len(name)-len("_spawnregions.lua")], kindSpawnRegions
case strings.HasSuffix(lower, ".ini"):
return name[:len(name)-len(".ini")], kindINI
case strings.HasSuffix(lower, ".cfg"):
return name[:len(name)-len(".cfg")], kindSandboxPreset
}
return "", kindOther
}
// looksLikeServerConfig отличает конфиг сервера от постороннего .ini.
func looksLikeServerConfig(data []byte) bool {
ini := pzconfig.ParseINI(data)
found := 0
for _, key := range markers {
if ini.Has(key) {
found++
}
}
return found >= markersRequired
}
func readEntry(entry *zip.File) ([]byte, error) {
rc, err := entry.Open()
if err != nil {
return nil, fmt.Errorf("прочитать %s: %w", entry.Name, err)
}
defer rc.Close()
// Заявленный размер может врать, поэтому читаем с запасом в один байт и
// проверяем, что файл на нём кончился.
content, err := io.ReadAll(io.LimitReader(rc, maxFileSize+1))
if err != nil {
return nil, fmt.Errorf("прочитать %s: %w", entry.Name, err)
}
if len(content) > maxFileSize {
return nil, fmt.Errorf("файл %s слишком велик", entry.Name)
}
return content, nil
}
// Describe собирает сводку по набору: сколько модов, карт и параметров.
func (f Files) Describe() Summary {
s := Summary{
Name: f.Name,
HasINI: f.INI != nil,
HasSandbox: f.Sandbox != nil,
HasSandboxPreset: f.SandboxPreset != nil,
HasSpawnRegions: f.SpawnRegions != nil,
Maps: []string{},
}
if f.INI != nil {
ini := pzconfig.ParseINI(f.INI)
s.ServerName = ini.Get("PublicName")
s.Mods = len(splitList(ini.Get("Mods")))
s.WorkshopItems = len(splitList(ini.Get("WorkshopItems")))
s.Maps = splitList(ini.Get("Map"))
s.Settings = len(ini.Keys())
}
switch {
case f.Sandbox != nil:
s.SandboxSettings = len(pzconfig.ParseSandbox(f.Sandbox).Entries())
case f.SandboxPreset != nil:
s.SandboxSettings = len(ParseSandboxPreset(f.SandboxPreset).Values)
}
return s
}
// splitList разбирает перечисления PZ: значения через точку с запятой.
func splitList(value string) []string {
out := []string{}
for _, part := range strings.Split(value, ";") {
if part = strings.TrimSpace(part); part != "" {
out = append(out, part)
}
}
return out
}
+216
View File
@@ -0,0 +1,216 @@
package preset
import (
"archive/zip"
"bytes"
"errors"
"strings"
"testing"
)
// serverINI — сокращённый конфиг сервера: маркеров хватает, чтобы отличить его
// от постороннего .ini.
const serverINI = `PublicName=Sophie Server
PublicDescription=Vanilla++
DefaultPort=16261
MaxPlayers=32
PVP=true
Map=Brandenburg, KY;Muldraugh, KY
Mods=SophieCore;SophieExtras
WorkshopItems=3773856464;2392709985
`
const sandboxLua = `SandboxVars = {
VERSION = 5,
Zombies = 3,
ZombieLore = {
Speed = 2,
},
}
`
const spawnRegions = `function SpawnRegions()
return { { name = "Muldraugh,KY", file = "media/maps/Muldraugh, KY/spawnpoints.lua" } }
end
`
func buildZip(t *testing.T, files map[string]string) []byte {
t.Helper()
var buf bytes.Buffer
w := zip.NewWriter(&buf)
for name, content := range files {
f, err := w.Create(name)
if err != nil {
t.Fatal(err)
}
if _, err := f.Write([]byte(content)); err != nil {
t.Fatal(err)
}
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
// Пакет сборки — это архив пользовательской папки Zomboid: серверные файлы в
// нём лежат вперемешку с тем, что нужно только одиночной игре.
func TestFromZipPack(t *testing.T) {
data := buildZip(t, map[string]string{
"readme.txt": "put these folders...",
"changelog.txt": "v2",
"THIS IS WHERE TO PUT THESE FOLDERS.png": "\x89PNG fake",
"Lua/ModOptions.ini": "[SomeMod]\nenabled=true\n",
"Lua/ModFolders.cfg": "folders",
"Sandbox Presets/Sophie-42.cfg": "Version=6\nZombies=4\n",
"Server/sophie-42.ini": serverINI,
"Server/sophie-42_SandboxVars.lua": sandboxLua,
"Server/sophie-42_spawnregions.lua": spawnRegions,
})
sets, err := FromZip(data)
if err != nil {
t.Fatal(err)
}
if len(sets) != 1 {
names := []string{}
for _, s := range sets {
names = append(names, s.Name)
}
t.Fatalf("наборов: %d (%s), ожидался один", len(sets), strings.Join(names, ", "))
}
set := sets[0]
if set.Name != "sophie-42" {
t.Errorf("имя набора = %q", set.Name)
}
if set.INI == nil || set.Sandbox == nil || set.SpawnRegions == nil {
t.Fatalf("не все файлы найдены: ini=%v sandbox=%v spawn=%v",
set.INI != nil, set.Sandbox != nil, set.SpawnRegions != nil)
}
got := set.Describe()
if got.ServerName != "Sophie Server" {
t.Errorf("имя сервера = %q", got.ServerName)
}
if got.Mods != 2 || got.WorkshopItems != 2 {
t.Errorf("модов %d, пакетов мастерской %d", got.Mods, got.WorkshopItems)
}
if len(got.Maps) != 2 || got.Maps[0] != "Brandenburg, KY" {
t.Errorf("карты = %v", got.Maps)
}
if got.SandboxSettings == 0 {
t.Error("параметры песочницы не посчитаны")
}
if !got.HasSpawnRegions {
t.Error("точки появления не найдены")
}
}
// Настройки мода лежат в таком же .ini, и принимать их за конфиг сервера
// нельзя: импорт затёр бы настоящий конфиг мусором.
func TestFromZipIgnoresForeignINI(t *testing.T) {
data := buildZip(t, map[string]string{
"Lua/ModOptions.ini": "[SomeMod]\nenabled=true\nPVP=true\n",
"notes.txt": "hi",
})
if _, err := FromZip(data); !errors.Is(err, ErrNoServerFiles) {
t.Fatalf("ожидалась ErrNoServerFiles, получено %v", err)
}
}
// Файлы могут лежать в корне архива или в папке с другим именем — важен
// только их суффикс.
func TestFromZipFlatLayout(t *testing.T) {
data := buildZip(t, map[string]string{
"my-server.ini": serverINI,
"configs/my-server_SandboxVars.lua": sandboxLua,
})
sets, err := FromZip(data)
if err != nil {
t.Fatal(err)
}
if len(sets) != 1 || sets[0].Name != "my-server" {
t.Fatalf("наборы = %+v", sets)
}
if sets[0].Sandbox == nil {
t.Error("песочница из другой папки не подхвачена")
}
}
// Несколько вариантов конфигов в одном архиве — человек выбирает сам, поэтому
// набора должно быть два, а не один слипшийся.
func TestFromZipSeveralSets(t *testing.T) {
data := buildZip(t, map[string]string{
"Server/pvp.ini": serverINI,
"Server/pvp_SandboxVars.lua": sandboxLua,
"Server/pve.ini": serverINI,
"Server/pve_SandboxVars.lua": sandboxLua,
"Server/pve_spawnregions.lua": spawnRegions,
})
sets, err := FromZip(data)
if err != nil {
t.Fatal(err)
}
if len(sets) != 2 {
t.Fatalf("наборов: %d", len(sets))
}
// Порядок задан именами: панель показывает их списком.
if sets[0].Name != "pve" || sets[1].Name != "pvp" {
t.Fatalf("порядок наборов: %q, %q", sets[0].Name, sets[1].Name)
}
}
// Одни точки появления импортировать некуда.
func TestFromZipSpawnRegionsAlone(t *testing.T) {
data := buildZip(t, map[string]string{"Server/x_spawnregions.lua": spawnRegions})
if _, err := FromZip(data); !errors.Is(err, ErrNoServerFiles) {
t.Fatalf("ожидалась ErrNoServerFiles, получено %v", err)
}
}
// Имя файла из архива в путь не превращается: берём только последний сегмент,
// поэтому «../» из имени записи никуда не ведёт.
func TestFromZipIgnoresPaths(t *testing.T) {
data := buildZip(t, map[string]string{
"../../../etc/evil.ini": serverINI,
"..\\..\\windows\\evil_SandboxVars.lua": sandboxLua,
})
sets, err := FromZip(data)
if err != nil {
t.Fatal(err)
}
for _, set := range sets {
if strings.ContainsAny(set.Name, `/\.`) {
t.Errorf("в имени набора остался путь: %q", set.Name)
}
}
}
func TestFromZipRejectsGarbage(t *testing.T) {
if _, err := FromZip([]byte("это не архив")); err == nil {
t.Fatal("мусор принят за архив")
}
}
// В пакете сборки рядом с конфигом сервера лежит и пресет песочницы для
// одиночной игры. Первым должен идти набор с конфигом: панель предлагает его по
// умолчанию, а из пресета песочницы моды и карты не возьмутся.
func TestFromZipServerSetGoesFirst(t *testing.T) {
data := buildZip(t, map[string]string{
// Имя пресета нарочно сортируется раньше по алфавиту.
"Sandbox Presets/Alpha-Sandbox.cfg": sandboxCFG,
"Server/zulu-server.ini": serverINI,
"Server/zulu-server_SandboxVars.lua": sandboxLua,
})
sets, err := FromZip(data)
if err != nil {
t.Fatal(err)
}
if len(sets) != 2 {
t.Fatalf("наборов: %d", len(sets))
}
if sets[0].INI == nil {
t.Fatalf("первым идёт набор без конфига сервера: %q", sets[0].Name)
}
}
+170
View File
@@ -0,0 +1,170 @@
package preset
import (
"bytes"
"fmt"
"regexp"
"strconv"
"strings"
)
// Пресет песочницы одиночной игры — это плоский key=value с точками вместо
// вложенных таблиц:
//
// Version=6
// # Комментарий игры
// -- 1 = Insane
// Zombies=4
// ZombieLore.Speed=2
// KDOT.SpecialVehicleChance=4.0
//
// Серверу нужен тот же набор значений, но в виде Lua-таблицы SandboxVars.
var (
// sandboxKeyRe — путь параметра: имя или несколько имён через точку.
sandboxKeyRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$`)
// numberRe — то, что в Lua записывается без кавычек.
numberRe = regexp.MustCompile(`^-?\d+(?:\.\d+)?$`)
)
// sandboxMarkers — параметры, которые есть в любом пресете песочницы. По ним
// он отличается от прочих .cfg в архиве (списки модов, настройки папок).
var sandboxMarkers = []string{
"Zombies", "Distribution", "DayLength", "StartYear", "StartMonth", "WaterShut",
"ElecShut", "XpMultiplier", "StatsDecrease", "Temperature", "Rain", "ZombieLore.Speed",
"ZombieLore.Strength", "LootRespawn", "FoodRotSpeed", "Nutrition",
}
const sandboxMarkersRequired = 3
// SandboxPreset — разобранный пресет песочницы.
type SandboxPreset struct {
// Version — версия формата из первой строки пресета.
Version string
// Values — путь параметра → значение, уже записанное на языке Lua.
Values map[string]string
// order хранит порядок появления: файл песочницы читают глазами, и
// перемешивать параметры незачем.
order []string
}
// ParseSandboxPreset разбирает пресет. Строки, не похожие на параметр
// (комментарии игры, расшифровки значений), пропускаются.
func ParseSandboxPreset(data []byte) *SandboxPreset {
p := &SandboxPreset{Values: map[string]string{}}
text := strings.ReplaceAll(string(data), "\r\n", "\n")
for _, line := range strings.Split(text, "\n") {
trimmed := strings.TrimSpace(line)
// Расшифровки значений игра пишет как "-- 1 = Insane", и знак равенства
// в них есть — без этой проверки они стали бы параметрами.
if trimmed == "" || strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "--") {
continue
}
key, value, found := strings.Cut(trimmed, "=")
key = strings.TrimSpace(key)
if !found || !sandboxKeyRe.MatchString(key) {
continue
}
if strings.EqualFold(key, "Version") {
p.Version = strings.TrimSpace(value)
continue
}
if _, seen := p.Values[key]; !seen {
p.order = append(p.order, key)
}
p.Values[key] = luaValue(value)
}
return p
}
// Order возвращает пути параметров в порядке их появления в пресете.
func (p *SandboxPreset) Order() []string { return p.order }
// looksLikeSandboxPreset отличает пресет песочницы от прочих .cfg.
func looksLikeSandboxPreset(data []byte) bool {
p := ParseSandboxPreset(data)
found := 0
for _, key := range sandboxMarkers {
if _, ok := p.Values[key]; ok {
found++
}
}
return found >= sandboxMarkersRequired
}
// luaValue переводит значение пресета в запись Lua: числа и логические
// значения идут как есть, всё остальное — строка в кавычках.
func luaValue(raw string) string {
value := strings.TrimSpace(raw)
switch {
case value == "true" || value == "false":
return value
case numberRe.MatchString(value):
return value
default:
return strconv.Quote(value)
}
}
// node — узел дерева параметров: либо значение, либо вложенная таблица.
// Дерево нужно потому, что в Lua «ZombieLore.Speed = 2» внутри таблицы
// написать нельзя — только настоящей вложенностью.
type node struct {
value string
order []string
children map[string]*node
}
func (n *node) child(name string) *node {
if n.children == nil {
n.children = map[string]*node{}
}
if _, ok := n.children[name]; !ok {
n.children[name] = &node{}
n.order = append(n.order, name)
}
return n.children[name]
}
// Lua собирает из пресета целый SandboxVars.lua. Нужен, когда переносить
// значения некуда: профиль ещё ни разу не запускался, и своего файла
// песочницы у него нет.
func (p *SandboxPreset) Lua() []byte {
root := &node{}
version := p.Version
if version == "" {
version = "1"
}
root.child("VERSION").value = version
for _, key := range p.order {
current := root
for _, name := range strings.Split(key, ".") {
current = current.child(name)
}
current.value = p.Values[key]
}
var buf bytes.Buffer
buf.WriteString("SandboxVars = {\n")
writeNode(&buf, root, " ")
buf.WriteString("}\n")
return buf.Bytes()
}
func writeNode(buf *bytes.Buffer, n *node, indent string) {
for _, name := range n.order {
child := n.children[name]
// Значение и вложенные параметры одновременно означали бы, что в
// пресете есть и «A», и «A.B»: доверяем вложенности, она полнее.
if len(child.order) == 0 {
fmt.Fprintf(buf, "%s%s = %s,\n", indent, name, child.value)
continue
}
fmt.Fprintf(buf, "%s%s = {\n", indent, name)
writeNode(buf, child, indent+" ")
fmt.Fprintf(buf, "%s},\n", indent)
}
}
+116
View File
@@ -0,0 +1,116 @@
package preset
import (
"strings"
"testing"
"github.com/mrleo1nid/pzmanager/internal/pzconfig"
)
// sandboxCFG повторяет то, как пресет выглядит у игры: комментарии с решёткой,
// расшифровки значений со знаком равенства внутри и точки вместо вложенности.
const sandboxCFG = `Version=6
# Changing this also sets the "Population Multiplier" in Advanced Zombie Options. Default = Normal
-- 1 = Insane
-- 4 = Normal
Zombies=4
# Controls whether some randomization is applied.
ZombieVoronoiNoise=true
DayLength=3
XpMultiplier=1.5
StatsDecrease=3
Temperature=3
Rain=3
LootRespawn=1
FoodRotSpeed=5
Nutrition=true
WorldItemRemovalList=Base.Hat, Base.Glasses
ZombieLore.Speed=2
ZombieLore.Strength=2
KDOT.SpecialVehicleChance=4.0
`
func TestParseSandboxPreset(t *testing.T) {
p := ParseSandboxPreset([]byte(sandboxCFG))
if p.Version != "6" {
t.Errorf("версия = %q", p.Version)
}
// Строки «-- 4 = Normal» тоже содержат знак равенства, и параметрами они
// не являются.
for key := range p.Values {
if !sandboxKeyRe.MatchString(key) {
t.Errorf("в значения попал мусор: %q", key)
}
}
want := map[string]string{
"Zombies": "4",
"ZombieVoronoiNoise": "true",
"XpMultiplier": "1.5",
"ZombieLore.Speed": "2",
"KDOT.SpecialVehicleChance": "4.0",
"WorldItemRemovalList": `"Base.Hat, Base.Glasses"`,
}
for key, value := range want {
if got := p.Values[key]; got != value {
t.Errorf("%s = %q, ожидалось %q", key, got, value)
}
}
if _, ok := p.Values["Version"]; ok {
t.Error("Version не должен попадать в значения: у файла сервера свой VERSION")
}
}
// Сгенерированный файл читает и сама игра, и парсер панели — второе проверить
// можно прямо здесь.
func TestSandboxPresetLuaRoundTrip(t *testing.T) {
p := ParseSandboxPreset([]byte(sandboxCFG))
lua := p.Lua()
parsed := pzconfig.ParseSandbox(lua)
if got, ok := parsed.Get("VERSION"); !ok || got != "6" {
t.Errorf("VERSION = %q (%v)", got, ok)
}
for key, want := range p.Values {
got, ok := parsed.Get(key)
if !ok {
t.Errorf("параметр %s не читается обратно:\n%s", key, lua)
continue
}
if got != want {
t.Errorf("%s = %q, ожидалось %q", key, got, want)
}
}
// Вложенность должна быть настоящей: «ZombieLore.Speed = 2» внутри таблицы
// Lua не примет.
if strings.Contains(string(lua), "ZombieLore.Speed =") {
t.Errorf("точка осталась в имени параметра:\n%s", lua)
}
}
// Пути бывают глубже одного уровня — файл должен остаться разбираемым.
func TestSandboxPresetDeepPath(t *testing.T) {
p := ParseSandboxPreset([]byte("A.B.C=7\nZombies=1\nDayLength=3\nRain=2\n"))
parsed := pzconfig.ParseSandbox(p.Lua())
if got, ok := parsed.Get("A.B.C"); !ok || got != "7" {
t.Fatalf("A.B.C = %q (%v)\n%s", got, ok, p.Lua())
}
}
func TestLooksLikeSandboxPreset(t *testing.T) {
if !looksLikeSandboxPreset([]byte(sandboxCFG)) {
t.Error("пресет песочницы не опознан")
}
// В том же архиве лежат списки модов и настройки папок с тем же
// расширением .cfg.
modFolders := "version=2\nnext=15\nfolder=3|1|4D,61,70,73\n"
if looksLikeSandboxPreset([]byte(modFolders)) {
t.Error("настройки папок приняты за пресет песочницы")
}
modList := "!fav!:\nSophie-42-v2:ModA;ModB;ModC\n"
if looksLikeSandboxPreset([]byte(modList)) {
t.Error("список модов принят за пресет песочницы")
}
}
+10
View File
@@ -172,6 +172,16 @@ func SaveINI(path string, ini *INI) error {
return writeAtomic(path, ini.Render(), 0o644) return writeAtomic(path, ini.Render(), 0o644)
} }
// SaveRaw кладёт на место файл, который менеджер не разбирает (настройки
// песочницы и точки появления из пакета сборки), с той же страховкой: копия
// прежней версии рядом и атомарная замена.
func SaveRaw(path string, data []byte) error {
if err := backupFile(path); err != nil {
return err
}
return writeAtomic(path, data, 0o644)
}
func backupFile(path string) error { func backupFile(path string) error {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if os.IsNotExist(err) { if os.IsNotExist(err) {
+20
View File
@@ -1,8 +1,12 @@
package pzserver package pzserver
import ( import (
"crypto/rand"
"encoding/hex"
"regexp" "regexp"
"strconv"
"sync" "sync"
"time"
) )
// ansiRe ловит управляющие последовательности терминала: SteamCMD и сам // ansiRe ловит управляющие последовательности терминала: SteamCMD и сам
@@ -32,6 +36,7 @@ type logBuffer struct {
lines []LogLine lines []LogLine
capacity int capacity int
nextSeq int64 nextSeq int64
epoch string
subs map[int]chan LogLine subs map[int]chan LogLine
nextSub int nextSub int
} }
@@ -41,10 +46,25 @@ func newLogBuffer(capacity int) *logBuffer {
lines: make([]LogLine, 0, capacity), lines: make([]LogLine, 0, capacity),
capacity: capacity, capacity: capacity,
nextSeq: 1, nextSeq: 1,
epoch: newEpoch(),
subs: make(map[int]chan LogLine), subs: make(map[int]chan LogLine),
} }
} }
// newEpoch помечает этот запуск менеджера. Номера строк живут только в памяти
// и после перезапуска начинаются заново — открытая вкладка приняла бы новые
// строки за уже показанные и молчала бы до перезагрузки страницы. По смене
// метки браузер понимает, что нумерацию надо начать с нуля.
func newEpoch() string {
var raw [8]byte
if _, err := rand.Read(raw[:]); err != nil {
// Случайность здесь не нужна для безопасности: метку достаточно
// отличать от предыдущей, и время запуска с этим справляется.
return strconv.FormatInt(time.Now().UnixNano(), 36)
}
return hex.EncodeToString(raw[:])
}
// append кладёт строку в буфер и рассылает подписчикам. // append кладёт строку в буфер и рассылает подписчикам.
func (b *logBuffer) append(line LogLine) { func (b *logBuffer) append(line LogLine) {
// Чистим здесь, а не у каждого источника: через буфер проходят и вывод // Чистим здесь, а не у каждого источника: через буфер проходят и вывод
+18
View File
@@ -39,3 +39,21 @@ func TestAppendStripsANSI(t *testing.T) {
t.Errorf("в буфер попала строка с кодами: %q", lines[0].Text) t.Errorf("в буфер попала строка с кодами: %q", lines[0].Text)
} }
} }
// Номера строк живут в памяти менеджера: после перезапуска они начинаются
// заново. Метка запуска должна это показывать, иначе открытая вкладка примет
// новые строки за уже показанные.
func TestLogBufferEpochDiffers(t *testing.T) {
first, second := newLogBuffer(8), newLogBuffer(8)
if first.epoch == "" {
t.Fatal("метка запуска пустая")
}
if first.epoch == second.epoch {
t.Fatalf("метки совпали: %q", first.epoch)
}
first.append(LogLine{Stream: "manager", Text: "первый запуск"})
second.append(LogLine{Stream: "manager", Text: "второй запуск"})
if got := second.since(0); len(got) != 1 || got[0].Seq != 1 {
t.Fatalf("нумерация нового буфера: %+v", got)
}
}
+32 -3
View File
@@ -42,6 +42,11 @@ var (
// playersPollInterval — как часто менеджер спрашивает у сервера список игроков. // playersPollInterval — как часто менеджер спрашивает у сервера список игроков.
const playersPollInterval = 30 * time.Second const playersPollInterval = 30 * time.Second
// startupGrace — сколько ждём строку о готовности. Загрузка мира с модами
// занимает минуты, поэтому запас большой: раньше времени объявлять сервер
// работающим хуже, чем подождать.
const startupGrace = 15 * time.Minute
// restartDelay — пауза перед автоподъёмом упавшего сервера. // restartDelay — пауза перед автоподъёмом упавшего сервера.
const restartDelay = 10 * time.Second const restartDelay = 10 * time.Second
@@ -152,6 +157,9 @@ func (s *Supervisor) AppendLog(stream, text string) {
// Lines возвращает историю консоли начиная с номера seq. // Lines возвращает историю консоли начиная с номера seq.
func (s *Supervisor) Lines(seq int64) []LogLine { return s.log.since(seq) } func (s *Supervisor) Lines(seq int64) []LogLine { return s.log.since(seq) }
// LogEpoch — метка этого запуска менеджера. Меняется вместе с нумерацией строк.
func (s *Supervisor) LogEpoch() string { return s.log.epoch }
// Subscribe отдаёт канал новых строк консоли и функцию отписки. // Subscribe отдаёт канал новых строк консоли и функцию отписки.
func (s *Supervisor) Subscribe() (<-chan LogLine, func()) { return s.log.subscribe(256) } func (s *Supervisor) Subscribe() (<-chan LogLine, func()) { return s.log.subscribe(256) }
@@ -355,10 +363,13 @@ func (s *Supervisor) handleLine(stream, text string) {
} }
// isServerReady распознаёт строку, которой PZ сообщает о готовности принимать // isServerReady распознаёт строку, которой PZ сообщает о готовности принимать
// игроков. // игроков: «LOG : Network f:0 st:81,093,763> *** SERVER STARTED ****».
//
// Других маркеров тут нет намеренно. Регистрацию в Steam сервер печатает
// задолго до того, как мир загружен, и по ней панель показывала «Работает» на
// сервере, который ещё никого не пустит.
func isServerReady(line string) bool { func isServerReady(line string) bool {
return strings.Contains(line, "SERVER STARTED") || return strings.Contains(strings.ToUpper(line), "SERVER STARTED")
strings.Contains(line, "Server Steam ID")
} }
// wait дожидается завершения процесса и решает, поднимать ли его заново. // wait дожидается завершения процесса и решает, поднимать ли его заново.
@@ -542,6 +553,23 @@ func (s *Supervisor) WaitStopped(timeout time.Duration) bool {
return s.State() == StateStopped return s.State() == StateStopped
} }
// checkStartupGrace страхует от сервера, который так и не напечатал строку о
// готовности: со своими модами и своей версией он может назвать её иначе, а
// вечное «Запускается» лишило бы панель опроса игроков.
func (s *Supervisor) checkStartupGrace() {
s.mu.Lock()
overdue := s.state == StateStarting && !s.startedAt.IsZero() &&
time.Since(s.startedAt) > startupGrace
if overdue {
s.state = StateRunning
}
s.mu.Unlock()
if overdue {
s.AppendLog("manager", "Сервер не сообщил о готовности — считаю его запущенным")
}
}
// pollPlayers периодически спрашивает у работающего сервера, кто онлайн. // pollPlayers периодически спрашивает у работающего сервера, кто онлайн.
func (s *Supervisor) pollPlayers() { func (s *Supervisor) pollPlayers() {
ticker := time.NewTicker(playersPollInterval) ticker := time.NewTicker(playersPollInterval)
@@ -551,6 +579,7 @@ func (s *Supervisor) pollPlayers() {
case <-s.quit: case <-s.quit:
return return
case <-ticker.C: case <-ticker.C:
s.checkStartupGrace()
if s.State() != StateRunning { if s.State() != StateRunning {
continue continue
} }
+33
View File
@@ -0,0 +1,33 @@
package pzserver
import "testing"
// Строка о готовности решает, когда панель перестанет писать «Запускается».
// Ошибиться в обе стороны одинаково неприятно: рано — покажем работающим
// сервер, который никого не пустит; поздно — сервер работает, а панель молчит.
func TestIsServerReady(t *testing.T) {
ready := []string{
"LOG : Network f:0 st:81,093,763> *** SERVER STARTED ****",
"*** SERVER STARTED ****",
"Server started",
}
for _, line := range ready {
if !isServerReady(line) {
t.Errorf("строка о готовности не распознана: %q", line)
}
}
// Регистрацию в Steam и загрузку мира сервер печатает задолго до того,
// как начнёт принимать подключения.
notReady := []string{
"Server Steam ID 90271993034366481",
"znet: Java_zombie_core_znet_SteamGameServer_Init",
"LOG : General f:0 st:1,234> loading world",
"Loading: SERVER settings",
}
for _, line := range notReady {
if isServerReady(line) {
t.Errorf("строка принята за готовность: %q", line)
}
}
}
+271 -6
View File
@@ -68,7 +68,9 @@ function formatBytes(bytes) {
return `${value.toFixed(value < 10 && unit > 0 ? 1 : 0)} ${units[unit]}`; return `${value.toFixed(value < 10 && unit > 0 ? 1 : 0)} ${units[unit]}`;
} }
function formatUptime(seconds) { // key разделяет два смысла одного счётчика: сервер либо уже работает, либо
// всё ещё загружается.
function formatUptime(seconds, key = "overview.uptime") {
if (!seconds) return ""; if (!seconds) return "";
const days = Math.floor(seconds / 86400); const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600); const hours = Math.floor((seconds % 86400) / 3600);
@@ -77,7 +79,7 @@ function formatUptime(seconds) {
if (days) parts.push(t("unit.days", { value: days })); if (days) parts.push(t("unit.days", { value: days }));
if (hours) parts.push(t("unit.hours", { value: hours })); if (hours) parts.push(t("unit.hours", { value: hours }));
parts.push(t("unit.minutes", { value: minutes })); parts.push(t("unit.minutes", { value: minutes }));
return t("overview.uptime", { value: parts.join(" ") }); return t(key, { value: parts.join(" ") });
} }
function formatDate(unixSeconds) { function formatDate(unixSeconds) {
@@ -253,7 +255,14 @@ function renderStatus(status) {
$("#ov-profile").textContent = status.profile_title $("#ov-profile").textContent = status.profile_title
? t("overview.profile", { title: status.profile_title }) ? t("overview.profile", { title: status.profile_title })
: ""; : "";
$("#ov-uptime").textContent = formatUptime(status.uptime_sec); // Процесс уже запущен, но мир грузится минутами, и всё это время сервер
// никого не пустит: пишем об этом прямо, иначе «Работает» вводит в
// заблуждение.
const starting = status.state === "starting";
$("#ov-note").textContent = starting ? t("overview.starting") : "";
$("#status-pill").title = starting ? t("overview.starting") : "";
$("#ov-uptime").textContent =
formatUptime(status.uptime_sec, starting ? "overview.loading" : "overview.uptime");
$("#ov-pid").textContent = status.pid ? `PID ${status.pid}` : ""; $("#ov-pid").textContent = status.pid ? `PID ${status.pid}` : "";
$("#ov-lastexit").textContent = $("#ov-lastexit").textContent =
status.state === "stopped" && status.last_exit status.state === "stopped" && status.last_exit
@@ -389,17 +398,38 @@ $("#btn-task-cancel").addEventListener("click", async () => {
let eventSource = null; let eventSource = null;
let lastSeq = 0; let lastSeq = 0;
// Метка запуска менеджера: номера строк живут в его памяти и после
// перезапуска начинаются заново.
let logEpoch = null;
const MAX_CONSOLE_LINES = 3000; const MAX_CONSOLE_LINES = 3000;
function openConsoleStream() { function openConsoleStream() {
closeConsoleStream(); closeConsoleStream();
eventSource = new EventSource(`/api/logs/stream?since=${lastSeq}`); eventSource = new EventSource(`/api/logs/stream?since=${lastSeq}`);
eventSource.addEventListener("meta", (event) => handleConsoleMeta(JSON.parse(event.data)));
eventSource.onmessage = (event) => appendConsoleLine(JSON.parse(event.data)); eventSource.onmessage = (event) => appendConsoleLine(JSON.parse(event.data));
eventSource.onerror = () => { eventSource.onerror = () => {
// Браузер переподключается сам; сообщать об этом каждый раз не нужно. // Браузер переподключается сам; сообщать об этом каждый раз не нужно.
}; };
} }
// handleConsoleMeta ловит перезапуск менеджера: без сброса счётчика вкладка
// приняла бы новые строки за уже показанные и осталась бы пустой до
// перезагрузки страницы.
function handleConsoleMeta(meta) {
if (logEpoch === meta.epoch) return;
const restarted = logEpoch !== null;
logEpoch = meta.epoch;
lastSeq = 0;
if (!restarted) return;
$("#console-output").innerHTML = "";
pushConsoleLine("manager", t("console.managerRestarted"));
// Переоткрываем поток: этот запрашивал историю с прежнего номера, а её у
// нового запуска нет.
openConsoleStream();
}
function closeConsoleStream() { function closeConsoleStream() {
if (eventSource) eventSource.close(); if (eventSource) eventSource.close();
eventSource = null; eventSource = null;
@@ -408,14 +438,19 @@ function closeConsoleStream() {
function appendConsoleLine(line) { function appendConsoleLine(line) {
if (line.seq <= lastSeq) return; if (line.seq <= lastSeq) return;
lastSeq = line.seq; lastSeq = line.seq;
pushConsoleLine(line.stream, line.text);
}
// pushConsoleLine рисует строку на экране. Отдельно от нумерации: свои
// сообщения панель показывает без номера из буфера менеджера.
function pushConsoleLine(stream, text) {
const output = $("#console-output"); const output = $("#console-output");
const filter = $("#console-filter").value.trim().toLowerCase(); const filter = $("#console-filter").value.trim().toLowerCase();
const el = document.createElement("div"); const el = document.createElement("div");
el.className = `line-${line.stream}`; el.className = `line-${stream}`;
el.textContent = line.text; el.textContent = text;
el.dataset.text = line.text.toLowerCase(); el.dataset.text = text.toLowerCase();
if (filter && !el.dataset.text.includes(filter)) el.hidden = true; if (filter && !el.dataset.text.includes(filter)) el.hidden = true;
output.appendChild(el); output.appendChild(el);
@@ -1318,6 +1353,51 @@ $("#mod-list").addEventListener("drop", (event) => {
renderMods(); renderMods();
}); });
// Игра умеет делиться набором модов строкой вида «Название:ModA;ModB;…».
// Workshop ID в ней нет, поэтому список задаёт только порядок загрузки: файлы
// сервер возьмёт из пакетов мастерской, которые уже перечислены в конфиге.
function parseModList(text) {
// Перед двоеточием стоит имя набора; у самих модов двоеточий не бывает.
const colon = text.indexOf(":");
const body = colon >= 0 ? text.slice(colon + 1) : text;
const seen = new Set();
const out = [];
body.split(/[;\n\r]+/).forEach((raw) => {
const id = raw.trim();
if (!id || seen.has(id)) return;
seen.add(id);
out.push(id);
});
return out;
}
$("#mod-list-form").addEventListener("submit", (event) => {
event.preventDefault();
const ids = parseModList($("#mod-list-input").value);
if (!ids.length) {
toast(t("mods.listEmpty"), "err");
return;
}
if (!confirm(t("mods.listConfirm", { count: ids.length }))) return;
mods.enabled = ids;
// Пакет мастерской известен для тех модов, чьи файлы уже лежат на диске:
// добавляем их, чтобы сервер не потерял мод при следующем запуске.
mods.installed.forEach((mod) => {
if (!ids.includes(mod.mod_id) || !mod.workshop_id) return;
if (!mods.workshop.includes(mod.workshop_id)) mods.workshop.push(mod.workshop_id);
});
const installed = new Set(mods.installed.map((mod) => mod.mod_id));
const missing = ids.filter((id) => !installed.has(id)).length;
$("#mod-list-input").value = "";
renderMods();
ok(t("mods.listDone", { count: ids.length }));
if (missing) toast(t("mods.listNoFiles", { count: missing }));
});
$("#map-list").addEventListener("input", refreshModsSaveState); $("#map-list").addEventListener("input", refreshModsSaveState);
$("#mod-filter").addEventListener("input", renderMods); $("#mod-filter").addEventListener("input", renderMods);
$("#mod-only-enabled").addEventListener("change", renderMods); $("#mod-only-enabled").addEventListener("change", renderMods);
@@ -1553,6 +1633,191 @@ $("#profile-add-form").addEventListener("submit", async (event) => {
} }
}); });
// ---------------------------------------------------------------------------
// Импорт пресета сборки
// ---------------------------------------------------------------------------
// Сборки модов раздают готовый комплект настроек одним архивом. Панель сначала
// показывает, что в нём нашла, и только потом что-то пишет: пресет заменяет
// конфиг профиля целиком, и делать это вслепую нельзя.
let presetSets = [];
// Файл отправляется дважды — на разбор и на импорт. Держать его на сервере
// между запросами не за чем: он уже лежит в браузере.
async function postFile(path, form) {
const response = await fetch(path, {
method: "POST",
headers: { [CSRF_HEADER]: "1" },
credentials: "same-origin",
body: form,
});
if (response.status === 401) {
showAuth(false);
throw new Error(t("auth.expired"));
}
const text = await response.text();
const data = text ? JSON.parse(text) : {};
if (!response.ok) {
throw new Error(data.error || t("error.http", { status: response.status }));
}
return data;
}
function presetFile() {
const input = $("#preset-file");
return input.files && input.files[0] ? input.files[0] : null;
}
$("#preset-file").addEventListener("change", async () => {
const file = presetFile();
$("#preset-details").hidden = true;
if (!file) return;
const form = new FormData();
form.append("file", file);
toast(t("import.reading"));
try {
const data = await postFile("/api/presets/preview", form);
presetSets = data.presets || [];
renderPresetSets();
} catch (error) {
fail(error);
}
});
function renderPresetSets() {
if (!presetSets.length) return;
const picker = $("#preset-set");
picker.innerHTML = presetSets
.map((set) => {
// Подпись сразу говорит, что в наборе: пресет песочницы без конфига
// сервера не принесёт ни модов, ни карт.
const note = set.has_ini
? t("import.setServer", { mods: set.mods })
: t("import.setSandbox");
return `<option value="${escapeHTML(set.name)}">${escapeHTML(`${set.name}${note}`)}</option>`;
})
.join("");
// Выбор нужен, только когда в архиве несколько наборов.
$("#preset-pick").hidden = presetSets.length < 2;
$("#preset-details").hidden = false;
renderPresetSummary();
}
function currentPreset() {
const name = $("#preset-set").value;
return presetSets.find((set) => set.name === name) || presetSets[0];
}
function renderPresetSummary() {
const set = currentPreset();
if (!set) return;
const lines = [];
if (set.server_name) lines.push(t("import.server", { name: set.server_name }));
if (set.has_ini) {
lines.push(t("import.mods", { mods: set.mods, workshop: set.workshop_items }));
if ((set.maps || []).length) lines.push(t("import.maps", { maps: set.maps.join(", ") }));
} else if (!set.has_sandbox_preset) {
// У пресета песочницы конфига сервера и не должно быть — про него сказано
// отдельной строкой ниже.
lines.push(t("import.noINI"));
}
if (set.has_sandbox_preset) {
// Пресет одиночной игры — не файл сервера: он переносится по значениям.
lines.push(t("import.sandboxPreset", { count: set.sandbox_settings }));
lines.push(t("import.sandboxPresetNote"));
} else {
lines.push(t("import.counts", { settings: set.settings, sandbox: set.sandbox_settings }));
}
if (set.has_ini || set.has_spawn_regions) {
lines.push(t(set.has_spawn_regions ? "import.spawnYes" : "import.spawnNo"));
}
$("#preset-summary").replaceChildren(...lines.map((line) => {
const el = document.createElement("div");
el.textContent = line;
return el;
}));
// Идентификатор профиля предлагаем по имени файлов из архива: обычно оно и
// называет сборку.
$("#preset-id").value = set.name.replace(/[^A-Za-z0-9_-]/g, "-").slice(0, 32);
$("#preset-profile").innerHTML = profiles.list
.map((p) => `<option value="${escapeHTML(p.id)}">${escapeHTML(p.title || p.id)}</option>`)
.join("");
$("#preset-profile").value = profiles.active;
}
$("#preset-set").addEventListener("change", renderPresetSummary);
$("#preset-target").addEventListener("change", () => {
const existing = $("#preset-target").value === "existing";
$("#preset-new-fields").hidden = existing;
$("#preset-existing-fields").hidden = !existing;
});
$("#btn-preset-import").addEventListener("click", async () => {
const file = presetFile();
const set = currentPreset();
if (!file || !set) {
toast(t("import.noFile"), "err");
return;
}
const existing = $("#preset-target").value === "existing";
const profile = existing ? $("#preset-profile").value : $("#preset-id").value.trim();
const activate = $("#preset-activate").checked;
// Смена активного профиля — заметное действие: о нём предупреждаем заранее.
const question = t("import.confirm", { name: set.name, profile })
+ (activate && profile !== profiles.active ? "\n\n" + t("import.confirmActivate", { profile }) : "");
if (!confirm(question)) return;
const form = new FormData();
form.append("file", file);
form.append("preset", set.name);
form.append("target", existing ? "existing" : "new");
form.append("profile", profile);
form.append("title", set.server_name || set.name);
form.append("keep", $$("#preset-details [data-keep]")
.filter((box) => box.checked)
.map((box) => box.dataset.keep)
.join(","));
form.append("activate", activate ? "1" : "");
const button = $("#btn-preset-import");
button.disabled = true;
try {
const result = await postFile("/api/presets/import", form);
ok(t("import.done", { profile: result.profile, files: (result.files || []).join(", ") }));
if ((result.kept || []).length) {
toast(t("import.kept", { profile: result.kept_from, keys: result.kept.join(", ") }));
}
if (result.applied !== undefined) {
toast(t("import.applied", { applied: result.applied, skipped: result.skipped }));
}
if (result.switched) {
toast(t("import.switched", { profile: result.profile }));
} else if (result.profile !== profiles.active) {
// Вкладки показывают активный профиль: без этого импорт выглядит так,
// будто моды и настройки никуда не попали.
toast(t("import.notActive", { profile: result.profile }));
}
$("#preset-file").value = "";
$("#preset-details").hidden = true;
await loadProfiles();
// Импортированный профиль мог стать активным — вкладки показывают
// именно его конфиг.
loadTab(currentTab);
} catch (error) {
fail(error);
} finally {
button.disabled = false;
}
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Бэкапы // Бэкапы
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+96
View File
@@ -133,6 +133,8 @@ const STRINGS = {
"overview.load": "Load average", "overview.load": "Load average",
"overview.diskFree": "Диск свободно", "overview.diskFree": "Диск свободно",
"overview.uptime": "Работает: {value}", "overview.uptime": "Работает: {value}",
"overview.loading": "Загружается: {value}",
"overview.starting": "Мир загружается — сервер ещё не принимает подключения",
"overview.profile": "Профиль: {title}", "overview.profile": "Профиль: {title}",
"overview.lastExit": "Последнее завершение: {reason}", "overview.lastExit": "Последнее завершение: {reason}",
"overview.memoryOf": "{free} из {total}", "overview.memoryOf": "{free} из {total}",
@@ -157,6 +159,7 @@ const STRINGS = {
"console.autoscroll": "Прокручивать за выводом", "console.autoscroll": "Прокручивать за выводом",
"console.filter": "Фильтр по строке…", "console.filter": "Фильтр по строке…",
"console.clear": "Очистить экран", "console.clear": "Очистить экран",
"console.managerRestarted": "— менеджер перезапущен, консоль начата заново —",
"console.placeholder": "Команда сервера, например: players, save, quit", "console.placeholder": "Команда сервера, например: players, save, quit",
"console.placeholderOffline": "Сервер остановлен — консоль принимает команды только на работающем", "console.placeholderOffline": "Сервер остановлен — консоль принимает команды только на работающем",
"console.send": "Отправить", "console.send": "Отправить",
@@ -297,6 +300,51 @@ const STRINGS = {
"profiles.copyNone": "не копировать", "profiles.copyNone": "не копировать",
"profiles.create": "Создать профиль", "profiles.create": "Создать профиль",
"import.title": "Импорт пресета сборки",
"import.fileHint": "ZIP-пакет сборки или пресет песочницы .cfg. Из архива панель возьмёт серверный .ini, настройки песочницы и точки появления, остальное пропустит.",
"import.reading": "Читаю архив…",
"import.set": "Набор из архива",
"import.setServer": "конфиг сервера, модов: {mods}",
"import.setSandbox": "только пресет песочницы, без модов",
"import.server": "Сервер: {name}",
"import.mods": "Моды: {mods} · пакеты мастерской: {workshop}",
"import.maps": "Карты: {maps}",
"import.counts": "Настройки сервера: {settings} · песочница: {sandbox}",
"import.spawnYes": "Точки появления: есть",
"import.spawnNo": "Точки появления: нет",
"import.noINI": "Конфига сервера в наборе нет — импортируются только настройки песочницы.",
"import.target": "Куда импортировать",
"import.targetNew": "В новый профиль",
"import.targetExisting": "Поверх существующего профиля",
"import.newID": "Идентификатор нового профиля",
"import.profile": "Профиль",
"import.overwriteHint": "Конфиги профиля будут заменены, прежние останутся рядом с расширением .bak. Мир не трогается.",
"import.keep": "Оставить свои значения вместо тех, что в пресете:",
"import.keepPorts": "Порты и RCON",
"import.keepAccess": "Пароль сервера и объявляемый IP",
"import.keepIdentity": "Имя и описание сервера",
"import.activate": "Сделать этот профиль активным",
"import.confirmActivate": "Профиль {profile} станет активным — панель и запуск переключатся на него.",
"import.activateHint": "Вкладки «Моды» и «Настройки» показывают активный профиль. Без переключения импортированные моды там не появятся.",
"import.switched": "Активный профиль: {profile}",
"import.notActive": "Профиль {profile} не активен — импортированные моды и настройки видны только под ним. Переключиться можно на вкладке «Профили».",
"import.apply": "Импортировать",
"import.noFile": "Выберите ZIP-пакет сборки",
"import.confirm": "Импортировать набор «{name}» в профиль {profile}?\n\nКонфиги профиля будут заменены, прежние останутся рядом с расширением .bak.",
"import.done": "Пресет импортирован в профиль {profile}. Записано файлов: {files}.",
"import.kept": "Свои значения взяты из профиля {profile}: {keys}",
"import.sandboxPreset": "Пресет песочницы одиночной игры: {count} параметров",
"import.sandboxPresetNote": "Значения перенесутся в песочницу профиля. Параметры модов, которых в ней ещё нет, пропускаются — такой пресет лучше импортировать после первого запуска с модами.",
"import.applied": "Перенесено параметров: {applied}, пропущено: {skipped}",
"mods.listTitle": "Импорт списка модов",
"mods.listHint": "Строка из кнопки «Поделиться» в игре: «Название:ModA;ModB;…». Заменит список включённых модов — он сохранится по кнопке «Сохранить».",
"mods.listPlaceholder": "Mymods:ModA;ModB;ModC",
"mods.listApply": "Применить список",
"mods.listEmpty": "В строке нет идентификаторов модов",
"mods.listConfirm": "Заменить список включённых модов на {count} из вставленной строки?",
"mods.listDone": "Список принят: модов {count}. Нажмите «Сохранить», чтобы записать его в конфиг.",
"mods.listNoFiles": "Файлов не найдено у {count} модов. Чтобы сервер их скачал, добавьте коллекцию мастерской.",
"settings.versionTitle": "Версия панели", "settings.versionTitle": "Версия панели",
"settings.title": "Настройки менеджера", "settings.title": "Настройки менеджера",
"settings.save": "Сохранить настройки", "settings.save": "Сохранить настройки",
@@ -428,6 +476,8 @@ const STRINGS = {
"overview.load": "Load average", "overview.load": "Load average",
"overview.diskFree": "Disk free", "overview.diskFree": "Disk free",
"overview.uptime": "Uptime: {value}", "overview.uptime": "Uptime: {value}",
"overview.loading": "Loading: {value}",
"overview.starting": "The world is loading — the server does not accept connections yet",
"overview.profile": "Profile: {title}", "overview.profile": "Profile: {title}",
"overview.lastExit": "Last exit: {reason}", "overview.lastExit": "Last exit: {reason}",
"overview.memoryOf": "{free} of {total}", "overview.memoryOf": "{free} of {total}",
@@ -452,6 +502,7 @@ const STRINGS = {
"console.autoscroll": "Scroll with output", "console.autoscroll": "Scroll with output",
"console.filter": "Filter by text…", "console.filter": "Filter by text…",
"console.clear": "Clear screen", "console.clear": "Clear screen",
"console.managerRestarted": "— the manager restarted, the console starts over —",
"console.placeholder": "Server command, for example: players, save, quit", "console.placeholder": "Server command, for example: players, save, quit",
"console.placeholderOffline": "Server stopped — the console only accepts commands while it runs", "console.placeholderOffline": "Server stopped — the console only accepts commands while it runs",
"console.send": "Send", "console.send": "Send",
@@ -592,6 +643,51 @@ const STRINGS = {
"profiles.copyNone": "do not copy", "profiles.copyNone": "do not copy",
"profiles.create": "Create profile", "profiles.create": "Create profile",
"import.title": "Import a collection preset",
"import.fileHint": "A collection ZIP package or a .cfg sandbox preset. From the archive the panel takes the server .ini, the sandbox settings and the spawn regions, and skips the rest.",
"import.reading": "Reading the archive…",
"import.set": "Preset from the archive",
"import.setServer": "server config, mods: {mods}",
"import.setSandbox": "sandbox preset only, no mods",
"import.server": "Server: {name}",
"import.mods": "Mods: {mods} · Workshop items: {workshop}",
"import.maps": "Maps: {maps}",
"import.counts": "Server settings: {settings} · sandbox: {sandbox}",
"import.spawnYes": "Spawn regions: included",
"import.spawnNo": "Spawn regions: none",
"import.noINI": "The preset has no server config — only the sandbox settings will be imported.",
"import.target": "Where to import",
"import.targetNew": "Into a new profile",
"import.targetExisting": "Over an existing profile",
"import.newID": "Identifier of the new profile",
"import.profile": "Profile",
"import.overwriteHint": "The profile configs will be replaced, the previous ones stay next to them with a .bak extension. The world is left alone.",
"import.keep": "Keep your own values instead of the ones from the preset:",
"import.keepPorts": "Ports and RCON",
"import.keepAccess": "Server password and announced IP",
"import.keepIdentity": "Server name and description",
"import.activate": "Make this profile active",
"import.confirmActivate": "The profile {profile} will become active — the panel and the server start will switch to it.",
"import.activateHint": "The Mods and Settings tabs show the active profile. Without switching, the imported mods will not appear there.",
"import.switched": "Active profile: {profile}",
"import.notActive": "The profile {profile} is not active — the imported mods and settings are only visible under it. You can switch on the Profiles tab.",
"import.apply": "Import",
"import.noFile": "Choose a collection ZIP package",
"import.confirm": "Import the preset \"{name}\" into the profile {profile}?\n\nThe profile configs will be replaced, the previous ones stay next to them with a .bak extension.",
"import.done": "Preset imported into the profile {profile}. Files written: {files}.",
"import.kept": "Your own values were taken from the profile {profile}: {keys}",
"import.sandboxPreset": "Single-player sandbox preset: {count} settings",
"import.sandboxPresetNote": "The values are transferred into the profile's sandbox. Mod settings that are not there yet are skipped — such a preset is best imported after the first start with the mods.",
"import.applied": "Settings transferred: {applied}, skipped: {skipped}",
"mods.listTitle": "Import a mod list",
"mods.listHint": "The line from the game's Share button: \"Name:ModA;ModB;…\". It replaces the list of enabled mods — press Save to write it into the config.",
"mods.listPlaceholder": "Mymods:ModA;ModB;ModC",
"mods.listApply": "Apply the list",
"mods.listEmpty": "The line has no mod identifiers",
"mods.listConfirm": "Replace the list of enabled mods with the {count} from the pasted line?",
"mods.listDone": "List accepted: {count} mods. Press Save to write it into the config.",
"mods.listNoFiles": "No files found for {count} mods. Add the Workshop collection so the server downloads them.",
"settings.versionTitle": "Panel version", "settings.versionTitle": "Panel version",
"settings.title": "Manager settings", "settings.title": "Manager settings",
"settings.save": "Save settings", "settings.save": "Save settings",
+60
View File
@@ -101,6 +101,7 @@
<div class="card"> <div class="card">
<h3 data-i18n="overview.stateCard"></h3> <h3 data-i18n="overview.stateCard"></h3>
<div class="stat" id="ov-state"></div> <div class="stat" id="ov-state"></div>
<div class="starting-note" id="ov-note"></div>
<div class="muted" id="ov-profile"></div> <div class="muted" id="ov-profile"></div>
<div class="muted" id="ov-uptime"></div> <div class="muted" id="ov-uptime"></div>
<div class="muted" id="ov-pid"></div> <div class="muted" id="ov-pid"></div>
@@ -212,6 +213,14 @@
<ul class="mod-list" id="mod-list"></ul> <ul class="mod-list" id="mod-list"></ul>
<h3 data-i18n="mods.listTitle"></h3>
<p class="muted small" data-i18n="mods.listHint"></p>
<form class="mod-add" id="mod-list-form">
<textarea id="mod-list-input" rows="3" spellcheck="false"
data-i18n-placeholder="mods.listPlaceholder"></textarea>
<button class="btn" type="submit" data-i18n="mods.listApply"></button>
</form>
<h3 data-i18n="mods.mapsTitle"></h3> <h3 data-i18n="mods.mapsTitle"></h3>
<textarea id="map-list" rows="3" spellcheck="false" data-i18n-placeholder="mods.mapsPlaceholder"></textarea> <textarea id="map-list" rows="3" spellcheck="false" data-i18n-placeholder="mods.mapsPlaceholder"></textarea>
<p class="muted small" data-i18n="mods.mapsHint"></p> <p class="muted small" data-i18n="mods.mapsHint"></p>
@@ -254,6 +263,57 @@
<div id="profile-cards" class="cards"></div> <div id="profile-cards" class="cards"></div>
<h3 data-i18n="import.title"></h3>
<div class="card import-card">
<p class="muted small" data-i18n="import.fileHint"></p>
<input type="file" id="preset-file" accept=".zip,.cfg">
<div id="preset-details" hidden>
<div class="cfg-field" id="preset-pick" hidden>
<label for="preset-set" data-i18n="import.set"></label>
<select id="preset-set"></select>
</div>
<div class="preset-summary" id="preset-summary"></div>
<div class="fields">
<div class="cfg-field">
<label for="preset-target" data-i18n="import.target"></label>
<select id="preset-target">
<option value="new" data-i18n="import.targetNew"></option>
<option value="existing" data-i18n="import.targetExisting"></option>
</select>
</div>
<div class="cfg-field" id="preset-new-fields">
<label for="preset-id" data-i18n="import.newID"></label>
<input type="text" id="preset-id" autocomplete="off">
<div class="hint" data-i18n="profiles.idHint"></div>
</div>
<div class="cfg-field" id="preset-existing-fields" hidden>
<label for="preset-profile" data-i18n="import.profile"></label>
<select id="preset-profile"></select>
<div class="hint" data-i18n="import.overwriteHint"></div>
</div>
</div>
<p class="muted small" data-i18n="import.keep"></p>
<div class="preset-keep">
<label class="checkbox"><input type="checkbox" data-keep="ports" checked>
<span data-i18n="import.keepPorts"></span></label>
<label class="checkbox"><input type="checkbox" data-keep="access" checked>
<span data-i18n="import.keepAccess"></span></label>
<label class="checkbox"><input type="checkbox" data-keep="identity" checked>
<span data-i18n="import.keepIdentity"></span></label>
</div>
<label class="checkbox"><input type="checkbox" id="preset-activate" checked>
<span data-i18n="import.activate"></span></label>
<div class="hint" data-i18n="import.activateHint"></div>
<button class="btn btn-primary" id="btn-preset-import" data-i18n="import.apply"></button>
</div>
</div>
<h3 data-i18n="profiles.newTitle"></h3> <h3 data-i18n="profiles.newTitle"></h3>
<form id="profile-add-form" class="fields"> <form id="profile-add-form" class="fields">
<div class="cfg-field"> <div class="cfg-field">
+30
View File
@@ -109,6 +109,10 @@ code {
} }
.dot.running { background: var(--accent); box-shadow: 0 0 8px var(--accent); } .dot.running { background: var(--accent); box-shadow: 0 0 8px var(--accent); }
.dot.starting, .dot.stopping, .dot.restarting { background: var(--warn); animation: pulse 1.2s infinite; } .dot.starting, .dot.stopping, .dot.restarting { background: var(--warn); animation: pulse 1.2s infinite; }
/* Пояснение под состоянием — тем же цветом, что и пульсирующий индикатор:
пока он мигает, сервер ещё не принимает игроков. */
.starting-note { color: var(--warn); }
.dot.stopped { background: #5b636f; } .dot.stopped { background: #5b636f; }
@keyframes pulse { 50% { opacity: .35; } } @keyframes pulse { 50% { opacity: .35; } }
@@ -326,6 +330,10 @@ input.input-sm, .input-sm { width: auto; padding: 6px 10px; font-size: 13px; }
.mod-add { display: flex; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; } .mod-add { display: flex; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; }
.mod-add input { flex: 1; min-width: 180px; } .mod-add input { flex: 1; min-width: 180px; }
/* Списком модов делятся одной длинной строкой: поле должно быть шире поля ввода. */
.mod-add textarea { flex: 1; min-width: 260px; resize: vertical; }
/* Кнопка не должна растягиваться на всю высоту поля со списком. */
#mod-list-form { align-items: flex-start; }
.mod-list { list-style: none; padding: 0; margin: 0 0 18px; } .mod-list { list-style: none; padding: 0; margin: 0 0 18px; }
@@ -566,3 +574,25 @@ input.input-sm, .input-sm { width: auto; padding: 6px 10px; font-size: 13px; }
margin-top: 18px; margin-top: 18px;
width: 100%; width: 100%;
} }
/* Импорт пресета: карточка с разбором архива и параметрами импорта. */
.import-card { margin-bottom: 20px; }
.import-card input[type=file] {
width: auto;
max-width: 100%;
margin: 4px 0 12px;
padding: 8px;
color: var(--text);
background: var(--bg-soft);
border: 1px dashed var(--border);
border-radius: var(--radius);
}
.preset-summary {
margin: 10px 0 14px;
padding: 10px 12px;
background: var(--bg-soft);
border-radius: var(--radius);
font-size: 13px;
line-height: 1.7;
}
.preset-keep { display: flex; flex-wrap: wrap; gap: 14px; margin: 6px 0 14px; }