3 Commits
Author SHA1 Message Date
mrleo1nidandClaude Opus 5 5d9bedaad2 Строчные ссылки на файлы модов и своё ожидание готовности
CI / test (push) Successful in 24s
Release / release (push) Successful in 28s
Игра приводит путь к вложенным файлам анимаций в нижний регистр целиком,
вместе с именем папки мода. На Linux такие файлы не находятся, и сервер
сыплет в лог сотнями FileNotFoundException. Кнопка на вкладке «Моды»
раскладывает рядом строчные симлинки: файлы модов не меняются, повторный
запуск после докачки безопасен, а на файловых системах без различия
регистра кнопка честно говорит, что делать нечего.

Ожидание строки о готовности стало настройкой профиля со значением по
умолчанию 30 минут вместо общих 15. Сборке на три сотни модов пятнадцати
не хватает, и панель объявляла бы работающим сервер, который ещё никого
не пустит.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 17:30:40 +03:00
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
17 changed files with 723 additions and 52 deletions
+53 -3
View File
@@ -18,7 +18,7 @@
сохраняется), перезапуск, автоподъём после падения, автозапуск вместе с сохраняется), перезапуск, автоподъём после падения, автозапуск вместе с
менеджером. «Работает» панель показывает не раньше, чем сервер напечатает менеджером. «Работает» панель показывает не раньше, чем сервер напечатает
`*** SERVER STARTED ****`: пока идёт загрузка мира, состояние остаётся `*** SERVER STARTED ****`: пока идёт загрузка мира, состояние остаётся
«Запускается». «Запускается». Сколько ждать эту строку — настройка профиля.
- **Живая консоль** — вывод сервера в реальном времени и отправка любых команд - **Живая консоль** — вывод сервера в реальном времени и отправка любых команд
(`players`, `save`, `servermsg`, `kickuser`, …). (`players`, `save`, `servermsg`, `kickuser`, …).
- **Мониторинг** — состояние, аптайм, CPU и память процесса сервера, свободные - **Мониторинг** — состояние, аптайм, CPU и память процесса сервера, свободные
@@ -28,6 +28,8 @@
самого конфига, поиск идёт и по названию, и по описанию. самого конфига, поиск идёт и по названию, и по описанию.
- **Мод-менеджер** — моды списком с галочками и перетаскиванием порядка - **Мод-менеджер** — моды списком с галочками и перетаскиванием порядка
загрузки, добавление по ссылке и разворачивание коллекций Steam целиком. загрузки, добавление по ссылке и разворачивание коллекций Steam целиком.
Отдельная кнопка лечит ошибки анимаций у модов с заглавными буквами в
именах файлов.
- **Импорт пресетов сборок** — ZIP-пакет сборки загружается прямо в панель: - **Импорт пресетов сборок** — ZIP-пакет сборки загружается прямо в панель:
конфиг, песочница и точки появления попадают в новый или существующий конфиг, песочница и точки появления попадают в новый или существующий
профиль, свои порты и пароли при этом сохраняются. Отдельно принимаются профиль, свои порты и пароли при этом сохраняются. Отдельно принимаются
@@ -193,8 +195,12 @@ curl -fsSL https://gitea.hsrv.site/mrleo1nid/pzmanager/raw/branch/main/deploy/ge
На вкладке «Профили» есть «Импорт пресета сборки»: выберите ZIP, и панель На вкладке «Профили» есть «Импорт пресета сборки»: выберите ZIP, и панель
покажет, что в нём нашла — название сервера, сколько модов и пакетов покажет, что в нём нашла — название сервера, сколько модов и пакетов
мастерской, какие карты, сколько параметров конфига и песочницы. Ничего не мастерской, какие карты, сколько параметров конфига и песочницы. Ничего не
записывается, пока вы не нажмёте «Импортировать». Если наборов в архиве записывается, пока вы не нажмёте «Импортировать».
несколько, появится список выбора.
Если наборов в архиве несколько, появится список выбора. Первым идёт самый
полный набор — тот, где есть конфиг сервера: только в нём лежат моды и карты.
Рядом с именем написано, что внутри, чтобы случайно не импортировать пресет
песочницы вместо комплекта сервера.
Импортировать можно в новый профиль (идентификатор предлагается по имени Импортировать можно в новый профиль (идентификатор предлагается по имени
файлов из архива) или поверх существующего. Прежние конфиги остаются рядом с файлов из архива) или поверх существующего. Прежние конфиги остаются рядом с
@@ -202,6 +208,11 @@ curl -fsSL https://gitea.hsrv.site/mrleo1nid/pzmanager/raw/branch/main/deploy/ge
импорт не принимает — сначала остановите сервер, иначе PZ перезапишет файлы импорт не принимает — сначала остановите сервер, иначе PZ перезапишет файлы
своими при выходе. своими при выходе.
Галочка «Сделать этот профиль активным» включена по умолчанию: вкладки «Моды»
и «Конфиг сервера» показывают активный профиль, и без переключения кажется,
что импорт ничего не сделал. Если галочку снять, панель напомнит, под каким
профилем искать импортированное.
Галочки «Оставить свои значения» переносят в импортированный конфиг то, что Галочки «Оставить свои значения» переносят в импортированный конфиг то, что
относится к вашей машине, а не к сборке: относится к вашей машине, а не к сборке:
@@ -277,6 +288,32 @@ Mod ID, который нужен серверу, лежит внутри сам
Один пакет мастерской может содержать несколько модов — в списке они идут Один пакет мастерской может содержать несколько модов — в списке они идут
отдельными строками, и включать их можно по отдельности. отдельными строками, и включать их можно по отдельности.
### Регистр имён файлов модов
Project Zomboid приводит путь к вложенным файлам анимаций в нижний регистр
целиком — вместе с именем папки мода. На Windows это незаметно, а на Linux
сервер такие файлы не находит и сыплет в лог сотнями исключений:
```
AnimNode.Parse threw an exception reading file: .../GunsOfMarz/.../UnloadShotgun_HB.xml
FileNotFoundException: .../gunsofmarz/.../loadshotgun_hb.xml (No such file or directory)
```
Читаемый файл — с правильным регистром, а ссылка внутри него резолвится
строчными буквами. Переименование не помогает: в нижний регистр уходит весь
путь, включая папку мода, а ссылки внутри модов написаны вразнобой.
Кнопка **«Разложить строчные ссылки»** на вкладке «Моды» кладёт рядом с каждым
именем, где есть заглавные буквы, симлинк на него же строчными. Файлы модов не
меняются: операция только добавляет имена и никогда не переписывает
существующие. Повторный запуск безопасен и дёшев — уже готовые ссылки
пропускаются, поэтому кнопку удобно нажимать после каждой докачки модов.
Останавливать сервер не нужно, но анимации читаются один раз при загрузке мира
— чтобы ссылки подхватились, сервер надо перезапустить. На файловых системах,
которые сами не различают регистр (Windows, macOS), кнопка сообщает, что делать
нечего.
## Редактор конфигов ## Редактор конфигов
Вкладки «Конфиг сервера» и «Песочница» показывают параметры не сплошным Вкладки «Конфиг сервера» и «Песочница» показывают параметры не сплошным
@@ -336,6 +373,19 @@ Mod ID, который нужен серверу, лежит внутри сам
`server_name`, `admin_password` и `java_memory` превращаются в первый профиль `server_name`, `admin_password` и `java_memory` превращаются в первый профиль
при первом запуске. при первом запуске.
### Ожидание готовности
Панель считает сервер работающим, увидев в его выводе строку
`*** SERVER STARTED ****`. Пока строки нет, состояние — «Запускается», и это
честно: до неё сервер не принимает подключения.
Строка может и не прийти — своя сборка или своя версия сервера назовёт её
иначе. На этот случай у профиля есть **ожидание готовности**: по его истечении
панель считает сервер запущенным и пишет об этом в консоль. По умолчанию — 30
минут; ванильный сервер поднимается за минуту, а сборке на три сотни модов
нужно больше десяти. Если ваша сборка грузится дольше, поднимите значение,
иначе панель скажет «Работает» серверу, который ещё никого не пустит.
## Обновление ## Обновление
Панель показывает свою версию и следит за релизами в репозитории: вкладка Панель показывает свою версию и следит за релизами в репозитории: вкладка
+40
View File
@@ -1,11 +1,14 @@
package api package api
import ( import (
"context"
"fmt"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"github.com/mrleo1nid/pzmanager/internal/casefix"
"github.com/mrleo1nid/pzmanager/internal/i18n" "github.com/mrleo1nid/pzmanager/internal/i18n"
"github.com/mrleo1nid/pzmanager/internal/pzconfig" "github.com/mrleo1nid/pzmanager/internal/pzconfig"
) )
@@ -131,3 +134,40 @@ func (s *Server) handleExpandCollection(w http.ResponseWriter, r *http.Request)
"items": s.workshop.Details(r.Context(), items), "items": s.workshop.Details(r.Context(), items),
}) })
} }
// handleCaseFixMods раскладывает строчные ссылки на файлы модов. Игра опускает
// путь к вложенным файлам анимаций в нижний регистр целиком, и на Linux мод с
// заглавными буквами в имени сыплет в лог сотни FileNotFoundException.
//
// Останавливать сервер не требуется: операция только добавляет имена и ничего
// не переписывает. Подхватятся ссылки при следующем запуске — анимации
// читаются один раз, при загрузке мира.
func (s *Server) handleCaseFixMods(w http.ResponseWriter, r *http.Request) {
name := "Строчные ссылки на файлы модов"
err := s.jobs.Start(name, func(ctx context.Context, log func(string)) error {
result, err := casefix.Apply(ctx, []string{s.cfg.ServerDir, s.cfg.ZomboidDir}, log)
if err != nil {
return err
}
switch {
case result.CaseInsensitive:
log("Файловая система не различает регистр — ссылки не нужны")
case result.Created == 0:
log(fmt.Sprintf("Всё уже на месте: имён просмотрено %d, ссылок было %d",
result.Scanned, result.Existing))
default:
log(fmt.Sprintf("Создано ссылок: %d (было %d, имён просмотрено %d)",
result.Created, result.Existing, result.Scanned))
log("Перезапустите игровой сервер, чтобы он перечитал анимации")
}
if result.Failed > 0 {
log(fmt.Sprintf("Не удалось создать ссылок: %d", result.Failed))
}
return nil
})
if err != nil {
writeOpError(w, r, err)
return
}
writeJSON(w, http.StatusAccepted, map[string]any{"task": name})
}
+19 -3
View File
@@ -52,6 +52,7 @@ func (s *Server) handleImportPreset(w http.ResponseWriter, r *http.Request) {
} }
createNew := r.FormValue("target") != "existing" createNew := r.FormValue("target") != "existing"
activate := r.FormValue("activate") == "1"
id := strings.TrimSpace(r.FormValue("profile")) id := strings.TrimSpace(r.FormValue("profile"))
if id == "" && createNew { if id == "" && createNew {
id = config.SanitizeProfileID(files.Name) id = config.SanitizeProfileID(files.Name)
@@ -68,8 +69,9 @@ func (s *Server) handleImportPreset(w http.ResponseWriter, r *http.Request) {
} }
// Живой сервер держит мир и конфиг в памяти и переписывает файлы при // Живой сервер держит мир и конфиг в памяти и переписывает файлы при
// остановке — импорт под ним просто пропал бы. // остановке — импорт под ним просто пропал бы. Переключать профиль на
if id == s.cfg.Active && s.sup.State() != pzserver.StateStopped { // живом сервере нельзя по той же причине.
if s.sup.State() != pzserver.StateStopped && (id == s.cfg.Active || activate) {
writeError(w, r, http.StatusConflict, "сначала остановите игровой сервер") writeError(w, r, http.StatusConflict, "сначала остановите игровой сервер")
return return
} }
@@ -94,7 +96,17 @@ func (s *Server) handleImportPreset(w http.ResponseWriter, r *http.Request) {
writeError(w, r, http.StatusInternalServerError, err.Error()) writeError(w, r, http.StatusInternalServerError, err.Error())
return return
} }
if createNew { // Вкладки панели показывают активный профиль: без переключения импорт
// выглядит так, будто моды и настройки не подтянулись.
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 { if err := s.cfg.Save(); err != nil {
writeError(w, r, http.StatusInternalServerError, err.Error()) writeError(w, r, http.StatusInternalServerError, err.Error())
return return
@@ -104,9 +116,13 @@ func (s *Server) handleImportPreset(w http.ResponseWriter, r *http.Request) {
summary := files.Describe() summary := files.Describe()
s.sup.AppendLog("manager", fmt.Sprintf("Импортирован пресет %q в профиль %q (модов: %d)", s.sup.AppendLog("manager", fmt.Sprintf("Импортирован пресет %q в профиль %q (модов: %d)",
files.Name, id, summary.Mods)) files.Name, id, summary.Mods))
if switched {
s.sup.AppendLog("manager", fmt.Sprintf("Активный профиль: %s", s.cfg.ActiveProfile().Name()))
}
result["profile"] = id result["profile"] = id
result["created"] = createNew result["created"] = createNew
result["switched"] = switched
result["summary"] = summary result["summary"] = summary
writeJSON(w, http.StatusOK, result) writeJSON(w, http.StatusOK, result)
} }
+55 -29
View File
@@ -5,16 +5,17 @@ import (
"io" "io"
"net/http" "net/http"
"os" "os"
"time"
"github.com/mrleo1nid/pzmanager/internal/config" "github.com/mrleo1nid/pzmanager/internal/config"
"github.com/mrleo1nid/pzmanager/internal/pzconfig" "github.com/mrleo1nid/pzmanager/internal/pzconfig"
"github.com/mrleo1nid/pzmanager/internal/pzserver" "github.com/mrleo1nid/pzmanager/internal/pzserver"
) )
// validateProfileMemory проверяет границы кучи JVM здесь, а не при запуске // validateProfile проверяет числа из формы здесь, а не при запуске сервера:
// сервера: иначе про опечатку в профиле стало бы известно только из лога // иначе про опечатку в профиле стало бы известно только из лога упавшего
// упавшего сервера. // сервера.
func validateProfileMemory(dto profileDTO) error { func validateProfile(dto profileDTO) error {
maxBytes, err := pzconfig.ParseMemory(dto.JavaMemory) maxBytes, err := pzconfig.ParseMemory(dto.JavaMemory)
if err != nil { if err != nil {
return err return err
@@ -27,9 +28,27 @@ func validateProfileMemory(dto profileDTO) error {
return fmt.Errorf("начальный размер памяти (%s) больше максимального (%s)", return fmt.Errorf("начальный размер памяти (%s) больше максимального (%s)",
dto.JavaMemoryMin, dto.JavaMemory) dto.JavaMemoryMin, dto.JavaMemory)
} }
if dto.StartupTimeoutMin < 0 || dto.StartupTimeoutMin > maxStartupTimeoutMin {
return fmt.Errorf("ожидание готовности должно быть от 1 до %d минут",
maxStartupTimeoutMin)
}
return nil return nil
} }
// startupTimeout переводит минуты из формы в длительность. Ноль оставляем
// нулём: он означает «как по умолчанию», и записывать в профиль конкретное
// число значило бы заморозить его при смене умолчания.
func startupTimeout(dto profileDTO) config.Duration {
if dto.StartupTimeoutMin <= 0 {
return 0
}
return config.Duration(time.Duration(dto.StartupTimeoutMin) * time.Minute)
}
// maxStartupTimeoutMin — потолок на ожидание готовности. Больше трёх часов
// это уже не «долго грузится», а зависший сервер.
const maxStartupTimeoutMin = 180
// profileDTO — профиль в том виде, в каком его показывает панель. Пароль // profileDTO — профиль в том виде, в каком его показывает панель. Пароль
// наружу не отдаётся, только признак того, что он задан. // наружу не отдаётся, только признак того, что он задан.
type profileDTO struct { type profileDTO struct {
@@ -39,7 +58,10 @@ type profileDTO struct {
JavaMemoryMin string `json:"java_memory_min"` JavaMemoryMin string `json:"java_memory_min"`
ExtraArgs []string `json:"extra_args"` ExtraArgs []string `json:"extra_args"`
HasAdminPassword bool `json:"has_admin_password"` HasAdminPassword bool `json:"has_admin_password"`
AdminPassword string `json:"admin_password,omitempty"` // StartupTimeoutMin — сколько минут ждать строку о готовности сервера.
// Ноль означает значение по умолчанию, его панель показывает подсказкой.
StartupTimeoutMin int `json:"startup_timeout_min"`
AdminPassword string `json:"admin_password,omitempty"`
// Признаки состояния на диске: по ним панель подсказывает, что профиль // Признаки состояния на диске: по ним панель подсказывает, что профиль
// ещё ни разу не запускался. // ещё ни разу не запускался.
HasConfig bool `json:"has_config"` HasConfig bool `json:"has_config"`
@@ -51,15 +73,16 @@ type profileDTO struct {
func (s *Server) profileToDTO(p config.Profile) profileDTO { func (s *Server) profileToDTO(p config.Profile) profileDTO {
return profileDTO{ return profileDTO{
ID: p.ID, ID: p.ID,
Title: p.Title, Title: p.Title,
JavaMemory: p.JavaMemory, JavaMemory: p.JavaMemory,
JavaMemoryMin: p.JavaMemoryMin, JavaMemoryMin: p.JavaMemoryMin,
ExtraArgs: p.ExtraArgs, ExtraArgs: p.ExtraArgs,
HasAdminPassword: p.AdminPassword != "", HasAdminPassword: p.AdminPassword != "",
HasConfig: fileExists(s.cfg.ServerConfigPathFor(p.ID)), StartupTimeoutMin: int(p.StartupTimeout.Duration().Minutes()),
WorldReady: fileExists(s.cfg.WorldDirFor(p.ID)), HasConfig: fileExists(s.cfg.ServerConfigPathFor(p.ID)),
Active: p.ID == s.cfg.Active, WorldReady: fileExists(s.cfg.WorldDirFor(p.ID)),
Active: p.ID == s.cfg.Active,
} }
} }
@@ -69,8 +92,9 @@ func (s *Server) handleListProfiles(w http.ResponseWriter, r *http.Request) {
profiles = append(profiles, s.profileToDTO(p)) profiles = append(profiles, s.profileToDTO(p))
} }
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"profiles": profiles, "profiles": profiles,
"active": s.cfg.Active, "active": s.cfg.Active,
"default_startup_timeout_min": int(config.DefaultStartupTimeout.Minutes()),
// Переключать профиль можно только на остановленном сервере. // Переключать профиль можно только на остановленном сервере.
"can_switch": s.sup.State() == pzserver.StateStopped, "can_switch": s.sup.State() == pzserver.StateStopped,
}) })
@@ -83,18 +107,19 @@ func (s *Server) handleCreateProfile(w http.ResponseWriter, r *http.Request) {
if !decodeJSON(w, r, &dto) { if !decodeJSON(w, r, &dto) {
return return
} }
if err := validateProfileMemory(dto); err != nil { if err := validateProfile(dto); err != nil {
writeError(w, r, http.StatusBadRequest, err.Error()) writeError(w, r, http.StatusBadRequest, err.Error())
return return
} }
profile := config.Profile{ profile := config.Profile{
ID: dto.ID, ID: dto.ID,
Title: dto.Title, Title: dto.Title,
JavaMemory: dto.JavaMemory, JavaMemory: dto.JavaMemory,
JavaMemoryMin: dto.JavaMemoryMin, JavaMemoryMin: dto.JavaMemoryMin,
ExtraArgs: dto.ExtraArgs, ExtraArgs: dto.ExtraArgs,
AdminPassword: dto.AdminPassword, AdminPassword: dto.AdminPassword,
StartupTimeout: startupTimeout(dto),
} }
if err := s.cfg.AddProfile(profile); err != nil { if err := s.cfg.AddProfile(profile); err != nil {
writeError(w, r, http.StatusBadRequest, err.Error()) writeError(w, r, http.StatusBadRequest, err.Error())
@@ -160,17 +185,18 @@ func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
if !decodeJSON(w, r, &dto) { if !decodeJSON(w, r, &dto) {
return return
} }
if err := validateProfileMemory(dto); err != nil { if err := validateProfile(dto); err != nil {
writeError(w, r, http.StatusBadRequest, err.Error()) writeError(w, r, http.StatusBadRequest, err.Error())
return return
} }
updated := config.Profile{ updated := config.Profile{
ID: id, ID: id,
Title: dto.Title, Title: dto.Title,
JavaMemory: dto.JavaMemory, JavaMemory: dto.JavaMemory,
JavaMemoryMin: dto.JavaMemoryMin, JavaMemoryMin: dto.JavaMemoryMin,
ExtraArgs: dto.ExtraArgs, ExtraArgs: dto.ExtraArgs,
StartupTimeout: startupTimeout(dto),
// Пустой пароль означает "оставить прежний": наружу мы его не отдаём, // Пустой пароль означает "оставить прежний": наружу мы его не отдаём,
// и форма присылает пустую строку, пока его не меняли. // и форма присылает пустую строку, пока его не меняли.
AdminPassword: current.AdminPassword, AdminPassword: current.AdminPassword,
+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
}
+1
View File
@@ -123,6 +123,7 @@ func (s *Server) Handler() http.Handler {
"PUT /api/mods": s.handlePutMods, "PUT /api/mods": s.handlePutMods,
"POST /api/mods/lookup": s.handleLookupMods, "POST /api/mods/lookup": s.handleLookupMods,
"POST /api/mods/collection": s.handleExpandCollection, "POST /api/mods/collection": s.handleExpandCollection,
"POST /api/mods/casefix": s.handleCaseFixMods,
"GET /api/backups": s.handleListBackups, "GET /api/backups": s.handleListBackups,
"POST /api/backups": s.handleCreateBackup, "POST /api/backups": s.handleCreateBackup,
"DELETE /api/backups/{name}": s.handleDeleteBackup, "DELETE /api/backups/{name}": s.handleDeleteBackup,
+172
View File
@@ -0,0 +1,172 @@
// Package casefix лечит расхождение в регистре имён у модов мастерской.
//
// Project Zomboid приводит путь к вложенному файлу анимаций к нижнему регистру
// целиком — вместе с именем папки мода. На Windows это незаметно, а на Linux
// файл просто не находится, и сервер сыплет в лог сотнями исключений вида
//
// AnimNode.Parse> ... loadshotgun_hb.xml (No such file or directory)
//
// при том, что рядом лежит LoadShotgun_HB.xml. Переименование не помогает:
// ссылки внутри модов написаны в разном регистре, и в нижний уходит весь путь.
// Поэтому рядом с каждым именем, где есть заглавные буквы, кладём симлинк на
// него же строчными буквами. Файлы модов при этом не трогаются: операция
// только добавляет имена и никогда не переписывает существующие.
package casefix
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
)
// pzAppID — раздел мастерской, в который Steam кладёт моды Project Zomboid.
const pzAppID = "108600"
// maxDepth ограничивает обход: дерево мода мельче, а бесконечно глубокое
// означает, что мы куда-то не туда попали.
const maxDepth = 24
// logSamples — сколько первых ссылок показать в консоли. Их бывают тысячи, и
// сплошной список вытеснит из буфера всё остальное.
const logSamples = 20
// Result — что получилось. Existing считает уже готовые ссылки: повторный
// запуск после докачки модов должен быть дешёвым и безопасным.
type Result struct {
Roots []string `json:"roots"`
Scanned int `json:"scanned"`
Created int `json:"created"`
Existing int `json:"existing"`
Failed int `json:"failed"`
// CaseInsensitive — файловая система сама не различает регистр, и ссылки
// не нужны. Так ведут себя Windows и macOS: там этой беды просто нет.
CaseInsensitive bool `json:"case_insensitive"`
}
// Apply обходит папки мастерской внутри roots и раскладывает строчные ссылки.
func Apply(ctx context.Context, roots []string, log func(string)) (Result, error) {
if log == nil {
log = func(string) {}
}
var result Result
var firstErr error
for _, root := range roots {
if root == "" {
continue
}
content := filepath.Join(root, "steamapps", "workshop", "content", pzAppID)
if info, err := os.Stat(content); err != nil || !info.IsDir() {
continue
}
result.Roots = append(result.Roots, content)
// Проверяем до обхода: на нечувствительной к регистру ФС каждое имя
// «уже существует», и обход насчитал бы тысячи готовых ссылок там, где
// их нет вовсе.
if caseInsensitive(content) {
result.CaseInsensitive = true
log(content + ": файловая система не различает регистр, ссылки не нужны")
continue
}
log("Просматриваю " + content)
if err := walk(ctx, content, 0, &result, log, &firstErr); err != nil {
return result, err
}
}
if len(result.Roots) == 0 {
return result, fmt.Errorf("папка модов мастерской не найдена: сервер ещё не скачивал моды")
}
if result.CaseInsensitive && result.Scanned == 0 {
return result, nil
}
// Ни одной ссылки не создано, зато все попытки провалились — значит дело
// не в отдельном файле, а в системе: нет прав или ФС не умеет симлинки.
if result.Created == 0 && result.Failed > 0 && firstErr != nil {
return result, fmt.Errorf("не удалось создать ни одной ссылки: %w", firstErr)
}
return result, nil
}
// caseInsensitive выясняет опытом, различает ли файловая система регистр:
// кладёт временный файл с заглавными буквами и ищет его строчными. Пробовать
// надо именно в папке модов — она может лежать на отдельном разделе.
func caseInsensitive(dir string) bool {
probe, err := os.CreateTemp(dir, "PZMcase*.tmp")
if err != nil {
// Права на запись всё равно нужны для ссылок: пусть обход сам
// расскажет, чего именно не хватает.
return false
}
name := probe.Name()
_ = probe.Close()
defer func() { _ = os.Remove(name) }()
_, err = os.Lstat(filepath.Join(filepath.Dir(name), strings.ToLower(filepath.Base(name))))
return err == nil
}
// walk обходит одну директорию. По симлинкам не спускаемся: и чтобы не ходить
// по только что созданным ссылкам, и чтобы не зациклиться на чужих.
func walk(ctx context.Context, dir string, depth int, result *Result, log func(string), firstErr *error) error {
if depth > maxDepth {
return nil
}
if err := ctx.Err(); err != nil {
return err
}
entries, err := os.ReadDir(dir)
if err != nil {
// Нечитаемая папка — повод сказать об этом, но не бросать весь обход.
log("Пропускаю " + dir + ": " + err.Error())
return nil
}
for _, entry := range entries {
result.Scanned++
name := entry.Name()
lower := strings.ToLower(name)
if lower != name {
link(dir, name, lower, result, log, firstErr)
}
// entry.IsDir() у симлинка на папку даёт false: ReadDir не разыменовывает.
if entry.IsDir() {
if err := walk(ctx, filepath.Join(dir, name), depth+1, result, log, firstErr); err != nil {
return err
}
}
}
return nil
}
// link кладёт рядом с name ссылку на него же строчными буквами.
func link(dir, name, lower string, result *Result, log func(string), firstErr *error) {
path := filepath.Join(dir, lower)
// Lstat, а не Stat: битая ссылка тоже занимает имя, и создавать поверх неё
// нечего.
if _, err := os.Lstat(path); err == nil {
result.Existing++
return
}
// Цель относительная — ссылка переживёт переезд папки сервера.
if err := os.Symlink(name, path); err != nil {
result.Failed++
if *firstErr == nil {
*firstErr = err
}
if result.Failed <= logSamples {
log("Не удалось создать ссылку " + path + ": " + err.Error())
}
return
}
result.Created++
if result.Created <= logSamples {
log(lower + " → " + name)
}
if result.Created == logSamples+1 {
log("…дальше молча, итог будет в конце")
}
}
+126
View File
@@ -0,0 +1,126 @@
package casefix
import (
"context"
"os"
"path/filepath"
"testing"
)
// content собирает дерево мастерской с файлами в разном регистре.
func content(t *testing.T) (root, dir string) {
t.Helper()
root = t.TempDir()
dir = filepath.Join(root, "steamapps", "workshop", "content", pzAppID,
"3722134990", "mods", "GunsOfMarz", "42.16", "media", "AnimSets")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
for _, name := range []string{"LoadShotgun_HB.xml", "aim_down.xml"} {
if err := os.WriteFile(filepath.Join(dir, name), []byte("<anim/>"), 0o644); err != nil {
t.Fatal(err)
}
}
return root, dir
}
// Проверять создание ссылок есть смысл только там, где регистр вообще
// различается: на Windows и macOS чинить нечего.
func skipCaseInsensitive(t *testing.T, result Result) {
t.Helper()
if result.CaseInsensitive {
t.Skip("файловая система не различает регистр")
}
}
func TestApplyLinksUppercaseNames(t *testing.T) {
root, dir := content(t)
result, err := Apply(context.Background(), []string{root}, nil)
if err != nil {
t.Fatal(err)
}
skipCaseInsensitive(t, result)
if result.Created == 0 {
t.Fatalf("не создано ни одной ссылки: %+v", result)
}
// Ссылка нужна и на файл, и на каждую папку пути: игра опускает в нижний
// регистр путь целиком, включая имя мода.
link := filepath.Join(dir, "loadshotgun_hb.xml")
if data, err := os.ReadFile(link); err != nil || string(data) != "<anim/>" {
t.Fatalf("ссылка на файл не читается: %v", err)
}
modDir := filepath.Join(root, "steamapps", "workshop", "content", pzAppID,
"3722134990", "mods", "gunsofmarz")
if _, err := os.Stat(modDir); err != nil {
t.Fatalf("нет ссылки на папку мода: %v", err)
}
if _, err := os.Stat(filepath.Join(modDir, "42.16", "media", "animsets", "loadshotgun_hb.xml")); err != nil {
t.Fatalf("путь целиком строчными не собрался: %v", err)
}
}
// Повторный запуск после докачки модов не должен ничего ломать.
func TestApplyIsIdempotent(t *testing.T) {
root, _ := content(t)
first, err := Apply(context.Background(), []string{root}, nil)
if err != nil {
t.Fatal(err)
}
skipCaseInsensitive(t, first)
second, err := Apply(context.Background(), []string{root}, nil)
if err != nil {
t.Fatal(err)
}
if second.Created != 0 {
t.Errorf("второй проход создал ссылки заново: %d", second.Created)
}
if second.Existing != first.Created {
t.Errorf("готовых ссылок %d, а создано было %d", second.Existing, first.Created)
}
}
// Имя, которое уже занято настоящим файлом, не трогаем.
func TestApplyKeepsExistingLowercaseFile(t *testing.T) {
root, dir := content(t)
path := filepath.Join(dir, "loadshotgun_hb.xml")
if err := os.WriteFile(path, []byte("свой файл"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := Apply(context.Background(), []string{root}, nil); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(path)
if err != nil || string(data) != "свой файл" {
t.Fatalf("существующий файл перезаписан: %q, %v", data, err)
}
}
func TestApplyWithoutWorkshopDir(t *testing.T) {
if _, err := Apply(context.Background(), []string{t.TempDir()}, nil); err == nil {
t.Fatal("ожидалась ошибка про ненайденную папку модов")
}
}
// На Windows и macOS кнопка должна честно сказать, что чинить нечего, а не
// пересчитывать все имена как «уже готовые».
func TestApplyReportsCaseInsensitiveFilesystem(t *testing.T) {
root, dir := content(t)
if !caseInsensitive(dir) {
t.Skip("файловая система различает регистр")
}
result, err := Apply(context.Background(), []string{root}, nil)
if err != nil {
t.Fatal(err)
}
if !result.CaseInsensitive {
t.Error("нечувствительность к регистру не распознана")
}
if result.Created != 0 || result.Existing != 0 || result.Scanned != 0 {
t.Errorf("дерево обошли впустую: %+v", result)
}
}
+21
View File
@@ -41,6 +41,27 @@ type Profile struct {
// ExtraArgs — дополнительные аргументы start-server.sh. // ExtraArgs — дополнительные аргументы start-server.sh.
ExtraArgs []string `yaml:"extra_args"` ExtraArgs []string `yaml:"extra_args"`
// StartupTimeout — сколько ждать строку о готовности сервера, прежде чем
// считать его работающим. Сборка на три сотни модов грузит мир десятками
// минут, и общего значения на все профили не подобрать: слишком малое
// объявит рабочим сервер, который ещё никого не пустит. Ноль означает
// DefaultStartupTimeout.
StartupTimeout Duration `yaml:"startup_timeout"`
}
// DefaultStartupTimeout — запас на загрузку мира, когда профиль своего не
// задал. Ванильный сервер поднимается за минуту, сборка с сотнями модов —
// заметно дольше, поэтому запас с большим запасом.
const DefaultStartupTimeout = 30 * time.Minute
// StartupWait возвращает время ожидания готовности с учётом значения по
// умолчанию.
func (p Profile) StartupWait() time.Duration {
if p.StartupTimeout <= 0 {
return DefaultStartupTimeout
}
return p.StartupTimeout.Duration()
} }
// Name возвращает название для интерфейса: заголовок, а если его нет — ID. // Name возвращает название для интерфейса: заголовок, а если его нет — ID.
+26 -1
View File
@@ -160,10 +160,35 @@ func FromZip(data []byte) ([]Files, error) {
if len(out) == 0 { if len(out) == 0 {
return nil, ErrNoServerFiles return nil, ErrNoServerFiles
} }
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) // Первым идёт самый полный набор: панель предлагает его по умолчанию, а
// одинокий пресет песочницы переносит только значения песочницы — ни модов,
// ни карт в нём нет, и импорт выглядит как «ничего не подтянулось».
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 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 type kind int
const ( const (
+22
View File
@@ -192,3 +192,25 @@ func TestFromZipRejectsGarbage(t *testing.T) {
t.Fatal("мусор принят за архив") 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)
}
}
+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)
}
}
+11 -6
View File
@@ -42,10 +42,8 @@ var (
// playersPollInterval — как часто менеджер спрашивает у сервера список игроков. // playersPollInterval — как часто менеджер спрашивает у сервера список игроков.
const playersPollInterval = 30 * time.Second const playersPollInterval = 30 * time.Second
// startupGrace — сколько ждём строку о готовности. Загрузка мира с модами // Сколько ждать строку о готовности — свойство профиля: сборка на сотни модов
// занимает минуты, поэтому запас большой: раньше времени объявлять сервер // грузится в разы дольше ванильной. См. config.Profile.StartupWait.
// работающим хуже, чем подождать.
const startupGrace = 15 * time.Minute
// restartDelay — пауза перед автоподъёмом упавшего сервера. // restartDelay — пауза перед автоподъёмом упавшего сервера.
const restartDelay = 10 * time.Second const restartDelay = 10 * time.Second
@@ -157,6 +155,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) }
@@ -555,15 +556,19 @@ func (s *Supervisor) WaitStopped(timeout time.Duration) bool {
// вечное «Запускается» лишило бы панель опроса игроков. // вечное «Запускается» лишило бы панель опроса игроков.
func (s *Supervisor) checkStartupGrace() { func (s *Supervisor) checkStartupGrace() {
s.mu.Lock() s.mu.Lock()
// Ждём столько, сколько задано у профиля, с которым сервер запускался:
// активный профиль могли переключить, а мир грузится по старому.
wait := s.profile.StartupWait()
overdue := s.state == StateStarting && !s.startedAt.IsZero() && overdue := s.state == StateStarting && !s.startedAt.IsZero() &&
time.Since(s.startedAt) > startupGrace time.Since(s.startedAt) > wait
if overdue { if overdue {
s.state = StateRunning s.state = StateRunning
} }
s.mu.Unlock() s.mu.Unlock()
if overdue { if overdue {
s.AppendLog("manager", "Сервер не сообщил о готовности — считаю его запущенным") s.AppendLog("manager", fmt.Sprintf(
"Сервер не сообщил о готовности за %s — считаю его запущенным", wait))
} }
} }
+79 -8
View File
@@ -384,6 +384,19 @@ $("#btn-install").addEventListener("click", async () => {
} }
}); });
// Строчные ссылки на файлы модов: лечит FileNotFoundException в анимациях на
// файловых системах, различающих регистр. Задача фоновая — вывод идёт в консоль.
$("#btn-mods-casefix").addEventListener("click", async () => {
if (!confirm(t("mods.caseConfirm"))) return;
try {
await post("/api/mods/casefix");
ok(t("mods.caseStarted"));
refreshStatus();
} catch (error) {
fail(error);
}
});
$("#btn-task-cancel").addEventListener("click", async () => { $("#btn-task-cancel").addEventListener("click", async () => {
try { try {
await post("/api/task/cancel"); await post("/api/task/cancel");
@@ -398,17 +411,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;
@@ -417,14 +451,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);
@@ -1449,11 +1488,16 @@ $("#btn-mods-save").addEventListener("click", async () => {
// Профили серверов // Профили серверов
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
let profiles = { list: [], active: "" }; let profiles = { list: [], active: "", default_startup_timeout_min: 0 };
async function loadProfiles() { async function loadProfiles() {
const data = await get("/api/profiles"); const data = await get("/api/profiles");
profiles = { list: data.profiles || [], active: data.active }; profiles = {
list: data.profiles || [],
active: data.active,
// Значение по умолчанию показываем подсказкой в пустом поле.
default_startup_timeout_min: data.default_startup_timeout_min || 0,
};
renderProfileSelect(); renderProfileSelect();
renderProfileCards(); renderProfileCards();
@@ -1496,6 +1540,13 @@ function renderProfileCards() {
placeholder="${t("profiles.memoryMinPlaceholder")}"> placeholder="${t("profiles.memoryMinPlaceholder")}">
<small>${t("profiles.memoryMinHint")}</small> <small>${t("profiles.memoryMinHint")}</small>
</label> </label>
<label class="field">
<span>${t("profiles.startupTimeout")}</span>
<input type="number" min="1" max="180" data-field="startup_timeout_min"
value="${escapeHTML(String(p.startup_timeout_min || ""))}"
placeholder="${escapeHTML(String(profiles.default_startup_timeout_min || ""))}">
<small>${t("profiles.startupTimeoutHint")}</small>
</label>
<label class="field"> <label class="field">
<span>${t("profiles.adminPassword")}</span> <span>${t("profiles.adminPassword")}</span>
<input type="password" data-field="admin_password" autocomplete="new-password" <input type="password" data-field="admin_password" autocomplete="new-password"
@@ -1566,6 +1617,7 @@ $("#profile-cards").addEventListener("click", async (event) => {
title: field("title"), title: field("title"),
java_memory: field("java_memory"), java_memory: field("java_memory"),
java_memory_min: field("java_memory_min"), java_memory_min: field("java_memory_min"),
startup_timeout_min: Number(field("startup_timeout_min")) || 0,
admin_password: field("admin_password"), admin_password: field("admin_password"),
}); });
ok(t("profiles.saved")); ok(t("profiles.saved"));
@@ -1664,7 +1716,14 @@ function renderPresetSets() {
const picker = $("#preset-set"); const picker = $("#preset-set");
picker.innerHTML = presetSets picker.innerHTML = presetSets
.map((set) => `<option value="${escapeHTML(set.name)}">${escapeHTML(set.name)}</option>`) .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(""); .join("");
// Выбор нужен, только когда в архиве несколько наборов. // Выбор нужен, только когда в архиве несколько наборов.
$("#preset-pick").hidden = presetSets.length < 2; $("#preset-pick").hidden = presetSets.length < 2;
@@ -1736,7 +1795,11 @@ $("#btn-preset-import").addEventListener("click", async () => {
const existing = $("#preset-target").value === "existing"; const existing = $("#preset-target").value === "existing";
const profile = existing ? $("#preset-profile").value : $("#preset-id").value.trim(); const profile = existing ? $("#preset-profile").value : $("#preset-id").value.trim();
if (!confirm(t("import.confirm", { name: set.name, profile }))) return; 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(); const form = new FormData();
form.append("file", file); form.append("file", file);
@@ -1748,6 +1811,7 @@ $("#btn-preset-import").addEventListener("click", async () => {
.filter((box) => box.checked) .filter((box) => box.checked)
.map((box) => box.dataset.keep) .map((box) => box.dataset.keep)
.join(",")); .join(","));
form.append("activate", activate ? "1" : "");
const button = $("#btn-preset-import"); const button = $("#btn-preset-import");
button.disabled = true; button.disabled = true;
@@ -1760,10 +1824,17 @@ $("#btn-preset-import").addEventListener("click", async () => {
if (result.applied !== undefined) { if (result.applied !== undefined) {
toast(t("import.applied", { applied: result.applied, skipped: result.skipped })); 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-file").value = "";
$("#preset-details").hidden = true; $("#preset-details").hidden = true;
await loadProfiles(); await loadProfiles();
// Импортированный профиль мог оказаться активным — вкладки показывают // Импортированный профиль мог стать активным — вкладки показывают
// именно его конфиг. // именно его конфиг.
loadTab(currentTab); loadTab(currentTab);
} catch (error) { } catch (error) {
+30
View File
@@ -159,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": "Отправить",
@@ -224,6 +225,11 @@ const STRINGS = {
"mods.mapsTitle": "Карты (Map)", "mods.mapsTitle": "Карты (Map)",
"mods.mapsPlaceholder": "Например: Muldraugh, KY", "mods.mapsPlaceholder": "Например: Muldraugh, KY",
"mods.mapsHint": "Каждая карта с новой строки. Первой должна идти карта мода, последней — базовая.", "mods.mapsHint": "Каждая карта с новой строки. Первой должна идти карта мода, последней — базовая.",
"mods.caseTitle": "Регистр имён файлов модов",
"mods.caseHint": "Игра приводит путь к файлам анимаций в нижний регистр целиком, включая имя папки мода. На Linux такие файлы не находятся, и сервер сыплет в лог ошибки AnimNode.Parse. Кнопка кладёт рядом строчные ссылки — файлы модов при этом не меняются. Запускайте после каждой докачки модов.",
"mods.caseApply": "Разложить строчные ссылки",
"mods.caseConfirm": "Разложить строчные ссылки на файлы модов?\n\nСуществующие файлы не изменятся. Сервер перечитает анимации при следующем запуске.",
"mods.caseStarted": "Задача запущена — следите за вкладкой «Консоль»",
"mods.summary": "Модов: {total}, включено: {enabled}, пакетов мастерской: {workshop}", "mods.summary": "Модов: {total}, включено: {enabled}, пакетов мастерской: {workshop}",
"mods.local": "локальный мод", "mods.local": "локальный мод",
"mods.notFound": "файлы не найдены — сервер скачает их при запуске", "mods.notFound": "файлы не найдены — сервер скачает их при запуске",
@@ -276,6 +282,8 @@ const STRINGS = {
"profiles.memoryMin": "Память JVM, начальная", "profiles.memoryMin": "Память JVM, начальная",
"profiles.memoryMinPlaceholder": "не задана", "profiles.memoryMinPlaceholder": "не задана",
"profiles.memoryMinHint": "Пусто — JVM берёт память по мере надобности. Если задать, она займёт этот объём сразу при старте и не сможет подняться, когда его нет.", "profiles.memoryMinHint": "Пусто — JVM берёт память по мере надобности. Если задать, она займёт этот объём сразу при старте и не сможет подняться, когда его нет.",
"profiles.startupTimeout": "Ожидание готовности, минут",
"profiles.startupTimeoutHint": "Сколько ждать строку «SERVER STARTED», прежде чем считать сервер работающим. Сборке на сотни модов нужны десятки минут. Пусто — значение по умолчанию.",
"profiles.adminPassword": "Пароль админа игры", "profiles.adminPassword": "Пароль админа игры",
"profiles.adminPasswordSet": "пароль задан", "profiles.adminPasswordSet": "пароль задан",
"profiles.adminPasswordUnset": "не задан", "profiles.adminPasswordUnset": "не задан",
@@ -303,6 +311,8 @@ const STRINGS = {
"import.fileHint": "ZIP-пакет сборки или пресет песочницы .cfg. Из архива панель возьмёт серверный .ini, настройки песочницы и точки появления, остальное пропустит.", "import.fileHint": "ZIP-пакет сборки или пресет песочницы .cfg. Из архива панель возьмёт серверный .ini, настройки песочницы и точки появления, остальное пропустит.",
"import.reading": "Читаю архив…", "import.reading": "Читаю архив…",
"import.set": "Набор из архива", "import.set": "Набор из архива",
"import.setServer": "конфиг сервера, модов: {mods}",
"import.setSandbox": "только пресет песочницы, без модов",
"import.server": "Сервер: {name}", "import.server": "Сервер: {name}",
"import.mods": "Моды: {mods} · пакеты мастерской: {workshop}", "import.mods": "Моды: {mods} · пакеты мастерской: {workshop}",
"import.maps": "Карты: {maps}", "import.maps": "Карты: {maps}",
@@ -320,6 +330,11 @@ const STRINGS = {
"import.keepPorts": "Порты и RCON", "import.keepPorts": "Порты и RCON",
"import.keepAccess": "Пароль сервера и объявляемый IP", "import.keepAccess": "Пароль сервера и объявляемый IP",
"import.keepIdentity": "Имя и описание сервера", "import.keepIdentity": "Имя и описание сервера",
"import.activate": "Сделать этот профиль активным",
"import.confirmActivate": "Профиль {profile} станет активным — панель и запуск переключатся на него.",
"import.activateHint": "Вкладки «Моды» и «Настройки» показывают активный профиль. Без переключения импортированные моды там не появятся.",
"import.switched": "Активный профиль: {profile}",
"import.notActive": "Профиль {profile} не активен — импортированные моды и настройки видны только под ним. Переключиться можно на вкладке «Профили».",
"import.apply": "Импортировать", "import.apply": "Импортировать",
"import.noFile": "Выберите ZIP-пакет сборки", "import.noFile": "Выберите ZIP-пакет сборки",
"import.confirm": "Импортировать набор «{name}» в профиль {profile}?\n\nКонфиги профиля будут заменены, прежние останутся рядом с расширением .bak.", "import.confirm": "Импортировать набор «{name}» в профиль {profile}?\n\nКонфиги профиля будут заменены, прежние останутся рядом с расширением .bak.",
@@ -494,6 +509,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",
@@ -559,6 +575,11 @@ const STRINGS = {
"mods.mapsTitle": "Maps (Map)", "mods.mapsTitle": "Maps (Map)",
"mods.mapsPlaceholder": "For example: Muldraugh, KY", "mods.mapsPlaceholder": "For example: Muldraugh, KY",
"mods.mapsHint": "One map per line. The mod map goes first, the base map last.", "mods.mapsHint": "One map per line. The mod map goes first, the base map last.",
"mods.caseTitle": "Mod file name case",
"mods.caseHint": "The game lowercases the whole path to animation files, the mod folder name included. On Linux those files are not found and the server floods the log with AnimNode.Parse errors. The button puts lowercase symlinks next to them — mod files themselves are left alone. Run it after every mod download.",
"mods.caseApply": "Create lowercase links",
"mods.caseConfirm": "Create lowercase links to the mod files?\n\nExisting files are left untouched. The server re-reads animations on the next start.",
"mods.caseStarted": "The task has started — watch the Console tab",
"mods.summary": "Mods: {total}, enabled: {enabled}, Workshop items: {workshop}", "mods.summary": "Mods: {total}, enabled: {enabled}, Workshop items: {workshop}",
"mods.local": "local mod", "mods.local": "local mod",
"mods.notFound": "files not found — the server will download them on startup", "mods.notFound": "files not found — the server will download them on startup",
@@ -611,6 +632,8 @@ const STRINGS = {
"profiles.memoryMin": "JVM memory, initial", "profiles.memoryMin": "JVM memory, initial",
"profiles.memoryMinPlaceholder": "not set", "profiles.memoryMinPlaceholder": "not set",
"profiles.memoryMinHint": "Empty — the JVM takes memory as it needs it. If set, it grabs that much at startup and fails to start when the machine has less.", "profiles.memoryMinHint": "Empty — the JVM takes memory as it needs it. If set, it grabs that much at startup and fails to start when the machine has less.",
"profiles.startupTimeout": "Readiness wait, minutes",
"profiles.startupTimeoutHint": "How long to wait for the \"SERVER STARTED\" line before treating the server as running. A collection with hundreds of mods needs tens of minutes. Empty — the default.",
"profiles.adminPassword": "Game admin password", "profiles.adminPassword": "Game admin password",
"profiles.adminPasswordSet": "password set", "profiles.adminPasswordSet": "password set",
"profiles.adminPasswordUnset": "not set", "profiles.adminPasswordUnset": "not set",
@@ -638,6 +661,8 @@ const STRINGS = {
"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.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.reading": "Reading the archive…",
"import.set": "Preset from 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.server": "Server: {name}",
"import.mods": "Mods: {mods} · Workshop items: {workshop}", "import.mods": "Mods: {mods} · Workshop items: {workshop}",
"import.maps": "Maps: {maps}", "import.maps": "Maps: {maps}",
@@ -655,6 +680,11 @@ const STRINGS = {
"import.keepPorts": "Ports and RCON", "import.keepPorts": "Ports and RCON",
"import.keepAccess": "Server password and announced IP", "import.keepAccess": "Server password and announced IP",
"import.keepIdentity": "Server name and description", "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.apply": "Import",
"import.noFile": "Choose a collection ZIP package", "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.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.",
+8
View File
@@ -224,6 +224,10 @@
<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>
<h3 data-i18n="mods.caseTitle"></h3>
<p class="muted small" data-i18n="mods.caseHint"></p>
<button class="btn" type="button" id="btn-mods-casefix" data-i18n="mods.caseApply"></button>
</div> </div>
</section> </section>
@@ -306,6 +310,10 @@
<span data-i18n="import.keepIdentity"></span></label> <span data-i18n="import.keepIdentity"></span></label>
</div> </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> <button class="btn btn-primary" id="btn-preset-import" data-i18n="import.apply"></button>
</div> </div>
</div> </div>