Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20795b053d | ||
|
|
6d27fd049d | ||
|
|
5d9bedaad2 | ||
|
|
e0c33706b3 | ||
|
|
ae8c511a9a | ||
|
|
bfb9271635 | ||
|
|
aec932f68f | ||
|
|
1e659678b9 | ||
|
|
9a224bc070 | ||
|
|
d5a308ac8d | ||
|
|
bb2facd4d1 | ||
|
|
c706665196 | ||
|
|
5d50b609f6 | ||
|
|
4a346c4ed9 | ||
|
|
e78a72d516 | ||
|
|
f6eba8c75c |
@@ -16,18 +16,30 @@
|
|||||||
песочница, моды и лимит памяти) с переключением одной кнопкой.
|
песочница, моды и лимит памяти) с переключением одной кнопкой.
|
||||||
- **Управление сервером** — запуск, остановка (через команду `quit`, мир
|
- **Управление сервером** — запуск, остановка (через команду `quit`, мир
|
||||||
сохраняется), перезапуск, автоподъём после падения, автозапуск вместе с
|
сохраняется), перезапуск, автоподъём после падения, автозапуск вместе с
|
||||||
менеджером.
|
менеджером. «Работает» панель показывает не раньше, чем сервер напечатает
|
||||||
|
`*** SERVER STARTED ****`: пока идёт загрузка мира, состояние остаётся
|
||||||
|
«Запускается». Сколько ждать эту строку — настройка профиля.
|
||||||
- **Живая консоль** — вывод сервера в реальном времени и отправка любых команд
|
- **Живая консоль** — вывод сервера в реальном времени и отправка любых команд
|
||||||
(`players`, `save`, `servermsg`, `kickuser`, …).
|
(`players`, `save`, `servermsg`, `kickuser`, …).
|
||||||
- **Мониторинг** — состояние, аптайм, CPU и память процесса сервера, свободные
|
- **Мониторинг** — состояние, аптайм, CPU и память процесса сервера, свободные
|
||||||
ОЗУ и диск на хосте, список игроков онлайн.
|
ОЗУ и диск на хосте, список игроков онлайн.
|
||||||
- **Редактор конфигов** — `servertest.ini` и `SandboxVars.lua` прямо в
|
- **Редактор конфигов** — `servertest.ini` и `SandboxVars.lua` прямо в
|
||||||
браузере, с поиском по параметрам и сохранением комментариев в файле.
|
браузере: параметры разложены по разделам, у каждого видно описание из
|
||||||
|
самого конфига, поиск идёт и по названию, и по описанию.
|
||||||
- **Мод-менеджер** — моды списком с галочками и перетаскиванием порядка
|
- **Мод-менеджер** — моды списком с галочками и перетаскиванием порядка
|
||||||
загрузки, добавление по ссылке и разворачивание коллекций Steam целиком.
|
загрузки, добавление по ссылке и разворачивание коллекций Steam целиком.
|
||||||
|
Отдельная кнопка лечит ошибки анимаций у модов с заглавными буквами в
|
||||||
|
именах файлов.
|
||||||
|
- **Импорт пресетов сборок** — ZIP-пакет сборки загружается прямо в панель:
|
||||||
|
конфиг, песочница и точки появления попадают в новый или существующий
|
||||||
|
профиль, свои порты и пароли при этом сохраняются. Отдельно принимаются
|
||||||
|
пресет песочницы одиночной игры (`.cfg`) и строка со списком модов из
|
||||||
|
игровой кнопки «Поделиться».
|
||||||
- **Бэкапы** — ручные и по расписанию, отдельно по каждому профилю, с
|
- **Бэкапы** — ручные и по расписанию, отдельно по каждому профилю, с
|
||||||
ротацией, скачиванием и восстановлением (перед восстановлением
|
ротацией, скачиванием и восстановлением (перед восстановлением
|
||||||
автоматически снимается страховочная копия).
|
автоматически снимается страховочная копия).
|
||||||
|
- **Два языка** — русский и английский, с выбором в шапке и автоопределением
|
||||||
|
по языку браузера.
|
||||||
- **Установка и обновление** сервера через SteamCMD прямо из панели.
|
- **Установка и обновление** сервера через SteamCMD прямо из панели.
|
||||||
- **Обновление самой панели** — кнопкой из интерфейса или одной командой в
|
- **Обновление самой панели** — кнопкой из интерфейса или одной командой в
|
||||||
терминале.
|
терминале.
|
||||||
@@ -161,6 +173,90 @@ sudo /opt/pzmanager/install.sh --uninstall --purge
|
|||||||
curl -fsSL https://gitea.hsrv.site/mrleo1nid/pzmanager/raw/branch/main/deploy/get.sh | sudo bash -s -- --uninstall --purge
|
curl -fsSL https://gitea.hsrv.site/mrleo1nid/pzmanager/raw/branch/main/deploy/get.sh | sudo bash -s -- --uninstall --purge
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Язык интерфейса
|
||||||
|
|
||||||
|
Панель говорит по-русски и по-английски. Язык выбирается списком в шапке (и на
|
||||||
|
экране входа, до того как вошли), а до первого выбора берётся из настроек
|
||||||
|
браузера: русский — русским, всем остальным — английский. Выбор запоминается в
|
||||||
|
браузере, поэтому у каждого, кто заходит в панель, он свой.
|
||||||
|
|
||||||
|
Переводится всё, что показывает панель: интерфейс, подписи и описания всех
|
||||||
|
параметров `servertest.ini` и `SandboxVars.lua`, ошибки и служебные сообщения
|
||||||
|
менеджера в консоли. Вывод самого игрового сервера остаётся как есть — он
|
||||||
|
приходит от 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` — что
|
||||||
@@ -184,6 +280,10 @@ Mod ID, который нужен серверу, лежит внутри сам
|
|||||||
~/Zomboid/mods/<мод>/mod.info # моды, положенные вручную
|
~/Zomboid/mods/<мод>/mod.info # моды, положенные вручную
|
||||||
```
|
```
|
||||||
|
|
||||||
|
В Build 42 `mod.info` лежит не в корне мода, а в папке версии — `common` для
|
||||||
|
общей части, `42` и `42.16` под конкретные сборки игры. Панель смотрит и туда,
|
||||||
|
выбирая самую свежую версию; корневой `mod.info` (раскладка Build 41) главнее.
|
||||||
|
|
||||||
Пока сервер не скачал мод, его строка помечена «ещё не скачан» и галочка
|
Пока сервер не скачал мод, его строка помечена «ещё не скачан» и галочка
|
||||||
недоступна: Mod ID неизвестен. Файлы загружает сам сервер при запуске по
|
недоступна: Mod ID неизвестен. Файлы загружает сам сервер при запуске по
|
||||||
списку `WorkshopItems` — то есть достаточно добавить моды, сохранить и
|
списку `WorkshopItems` — то есть достаточно добавить моды, сохранить и
|
||||||
@@ -192,6 +292,49 @@ 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), кнопка сообщает, что делать
|
||||||
|
нечего.
|
||||||
|
|
||||||
|
## Редактор конфигов
|
||||||
|
|
||||||
|
Вкладки «Конфиг сервера» и «Песочница» показывают параметры не сплошным
|
||||||
|
списком, а разделами: «Основное», «PVP и урон», «Убежища», «Античит» и так
|
||||||
|
далее. Разделы сворачиваются, а поиск сверху раскрывает те, где нашлось
|
||||||
|
совпадение. Ключи, которых панель не знает (в новой версии игры их всегда
|
||||||
|
добавляют), не теряются — они собираются в раздел «Прочее».
|
||||||
|
|
||||||
|
Подписи и описания у панели свои, на языке интерфейса: игра пишет свои
|
||||||
|
комментарии на языке сервера, а на Linux он почти всегда английский. Расшифровку
|
||||||
|
конкретных значений (что означает 1, 2, 3) панель добавляет из самого конфига.
|
||||||
|
Полный текст виден при наведении, начало — прямо под полем.
|
||||||
|
|
||||||
|
Правка меняет ровно ту строку, которую поправили: комментарии, порядок и
|
||||||
|
переводы строк файла сохраняются, а рядом остаётся копия прежней версии с
|
||||||
|
расширением `.bak`.
|
||||||
|
|
||||||
## Профили серверов
|
## Профили серверов
|
||||||
|
|
||||||
Профиль — это отдельный сервер: свой мир, свой `*.ini`, свои настройки
|
Профиль — это отдельный сервер: свой мир, свой `*.ini`, свои настройки
|
||||||
@@ -213,6 +356,20 @@ Mod ID, который нужен серверу, лежит внутри сам
|
|||||||
При создании профиля можно скопировать `.ini` и настройки песочницы из
|
При создании профиля можно скопировать `.ini` и настройки песочницы из
|
||||||
существующего: удобно, когда нужен тот же набор правил, но чистый мир.
|
существующего: удобно, когда нужен тот же набор правил, но чистый мир.
|
||||||
|
|
||||||
|
### Память JVM
|
||||||
|
|
||||||
|
`java_memory` — это `-Xmx`, верхняя граница кучи. `java_memory_min` (`-Xms`)
|
||||||
|
по умолчанию пуст, и это осознанно: равные значения избавляют от роста кучи на
|
||||||
|
ходу, но заставляют JVM занять весь объём сразу при старте. На машине, где
|
||||||
|
столько свободной памяти не набирается, сервер тогда не поднимется вовсе —
|
||||||
|
`Failed to allocate initial Java heap`. Задавайте минимум, только если памяти
|
||||||
|
заведомо хватает.
|
||||||
|
|
||||||
|
Сервер, упавший сразу после запуска три раза подряд, панель перестаёт
|
||||||
|
поднимать автоматически: обычно так проявляется нехватка памяти или битый
|
||||||
|
конфиг, и перезапуск делу не помогает. Пока идёт отсчёт до автоподъёма, его
|
||||||
|
можно отменить кнопкой «Отменить перезапуск».
|
||||||
|
|
||||||
Удаление профиля убирает его только из панели — мир и конфиги остаются на
|
Удаление профиля убирает его только из панели — мир и конфиги остаются на
|
||||||
диске. Чтобы вернуть профиль, создайте его с тем же идентификатором.
|
диске. Чтобы вернуть профиль, создайте его с тем же идентификатором.
|
||||||
|
|
||||||
@@ -220,6 +377,19 @@ Mod ID, который нужен серверу, лежит внутри сам
|
|||||||
`server_name`, `admin_password` и `java_memory` превращаются в первый профиль
|
`server_name`, `admin_password` и `java_memory` превращаются в первый профиль
|
||||||
при первом запуске.
|
при первом запуске.
|
||||||
|
|
||||||
|
### Ожидание готовности
|
||||||
|
|
||||||
|
Панель считает сервер работающим, увидев в его выводе строку
|
||||||
|
`*** SERVER STARTED ****`. Пока строки нет, состояние — «Запускается», и это
|
||||||
|
честно: до неё сервер не принимает подключения.
|
||||||
|
|
||||||
|
Строка может и не прийти — своя сборка или своя версия сервера назовёт её
|
||||||
|
иначе. На этот случай у профиля есть **ожидание готовности**: по его истечении
|
||||||
|
панель считает сервер запущенным и пишет об этом в консоль. По умолчанию — 30
|
||||||
|
минут; ванильный сервер поднимается за минуту, а сборке на три сотни модов
|
||||||
|
нужно больше десяти. Если ваша сборка грузится дольше, поднимите значение,
|
||||||
|
иначе панель скажет «Работает» серверу, который ещё никого не пустит.
|
||||||
|
|
||||||
## Обновление
|
## Обновление
|
||||||
|
|
||||||
Панель показывает свою версию и следит за релизами в репозитории: вкладка
|
Панель показывает свою версию и следит за релизами в репозитории: вкладка
|
||||||
@@ -327,7 +497,8 @@ profiles: # серверы: у каждого свой м
|
|||||||
- id: vanilla # vanilla -> vanilla.ini, Saves/Multiplayer/vanilla
|
- id: vanilla # vanilla -> vanilla.ini, Saves/Multiplayer/vanilla
|
||||||
title: Ванильный мир # как профиль подписан в панели
|
title: Ванильный мир # как профиль подписан в панели
|
||||||
admin_password: "" # пароль админа игрового сервера
|
admin_password: "" # пароль админа игрового сервера
|
||||||
java_memory: 4g # -Xmx для JVM этого профиля
|
java_memory: 4g # -Xmx: верхняя граница кучи JVM
|
||||||
|
java_memory_min: "" # -Xms: пусто — JVM берёт память по мере надобности
|
||||||
extra_args: [] # доп. аргументы start-server.sh
|
extra_args: [] # доп. аргументы start-server.sh
|
||||||
- id: modded
|
- id: modded
|
||||||
title: Сборка с модами
|
title: Сборка с модами
|
||||||
@@ -413,6 +584,12 @@ make run # локальный запуск с конфигом в ./dev
|
|||||||
Веб-интерфейс — обычные HTML, CSS и JS в `web/static`, без сборщика; при
|
Веб-интерфейс — обычные HTML, CSS и JS в `web/static`, без сборщика; при
|
||||||
`go build` они вшиваются в бинарник через `embed`.
|
`go build` они вшиваются в бинарник через `embed`.
|
||||||
|
|
||||||
|
Переводы лежат рядом: строки интерфейса — в `web/static/i18n.js`, подписи
|
||||||
|
параметров конфигов — в `web/static/hints-ru.json` и `hints-en.json`
|
||||||
|
(загружается только нужный язык), сообщения самого менеджера — в
|
||||||
|
`internal/i18n/catalog.go`, где ключом служит русская фраза из кода. Тесты в
|
||||||
|
`web` и `internal/i18n` следят, чтобы словари не разъезжались.
|
||||||
|
|
||||||
## Лицензия
|
## Лицензия
|
||||||
|
|
||||||
[MIT](LICENSE) — делайте с кодом что угодно, но без каких-либо гарантий.
|
[MIT](LICENSE) — делайте с кодом что угодно, но без каких-либо гарантий.
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ func (s *Server) handleSession(w http.ResponseWriter, r *http.Request) {
|
|||||||
// handleSetup создаёт первого администратора. Доступен, только пока учёток нет.
|
// handleSetup создаёт первого администратора. Доступен, только пока учёток нет.
|
||||||
func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
|
||||||
if !s.users.IsEmpty() {
|
if !s.users.IsEmpty() {
|
||||||
writeError(w, http.StatusConflict, "панель уже настроена")
|
writeError(w, r, http.StatusConflict, "панель уже настроена")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var creds credentials
|
var creds credentials
|
||||||
@@ -44,16 +44,16 @@ func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Токен печатается в журнал менеджера при старте: без него первым
|
// Токен печатается в журнал менеджера при старте: без него первым
|
||||||
// администратором стал бы любой, кто успел открыть страницу.
|
// администратором стал бы любой, кто успел открыть страницу.
|
||||||
if s.setupToken == "" || subtle.ConstantTimeCompare([]byte(creds.Token), []byte(s.setupToken)) != 1 {
|
if s.setupToken == "" || subtle.ConstantTimeCompare([]byte(creds.Token), []byte(s.setupToken)) != 1 {
|
||||||
writeError(w, http.StatusForbidden, "неверный код первичной настройки")
|
writeError(w, r, http.StatusForbidden, "неверный код первичной настройки")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.users.AddUser(creds.Login, creds.Password); err != nil {
|
if err := s.users.AddUser(creds.Login, creds.Password); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, r, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
id, err := s.users.Login(creds.Login, creds.Password)
|
id, err := s.users.Login(creds.Login, creds.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, r, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Администратор создан — код больше не нужен и не должен лежать на диске.
|
// Администратор создан — код больше не нужен и не должен лежать на диске.
|
||||||
@@ -72,10 +72,10 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
id, err := s.users.Login(creds.Login, creds.Password)
|
id, err := s.users.Login(creds.Login, creds.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, auth.ErrBadCredentials) {
|
if errors.Is(err, auth.ErrBadCredentials) {
|
||||||
writeError(w, http.StatusUnauthorized, err.Error())
|
writeError(w, r, http.StatusUnauthorized, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, r, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.setSessionCookie(w, id, sessionTTL)
|
s.setSessionCookie(w, id, sessionTTL)
|
||||||
@@ -100,7 +100,7 @@ func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.users.AddUser(creds.Login, creds.Password); err != nil {
|
if err := s.users.AddUser(creds.Login, creds.Password); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, r, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusCreated, map[string]any{"login": creds.Login})
|
writeJSON(w, http.StatusCreated, map[string]any{"login": creds.Login})
|
||||||
@@ -110,11 +110,11 @@ func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
login := r.PathValue("login")
|
login := r.PathValue("login")
|
||||||
current, _ := s.users.SessionFromRequest(r)
|
current, _ := s.users.SessionFromRequest(r)
|
||||||
if login == current {
|
if login == current {
|
||||||
writeError(w, http.StatusBadRequest, "нельзя удалить учётную запись, под которой вы вошли")
|
writeError(w, r, http.StatusBadRequest, "нельзя удалить учётную запись, под которой вы вошли")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.users.DeleteUser(login); err != nil {
|
if err := s.users.DeleteUser(login); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, r, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"deleted": login})
|
writeJSON(w, http.StatusOK, map[string]any{"deleted": login})
|
||||||
@@ -128,7 +128,7 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.users.SetPassword(r.PathValue("login"), body.Password); err != nil {
|
if err := s.users.SetPassword(r.PathValue("login"), body.Password); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, r, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mrleo1nid/pzmanager/internal/config"
|
"github.com/mrleo1nid/pzmanager/internal/config"
|
||||||
|
"github.com/mrleo1nid/pzmanager/internal/i18n"
|
||||||
"github.com/mrleo1nid/pzmanager/internal/pzconfig"
|
"github.com/mrleo1nid/pzmanager/internal/pzconfig"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,12 +20,12 @@ func (s *Server) handleGetServerConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"exists": false,
|
"exists": false,
|
||||||
"path": path,
|
"path": path,
|
||||||
"hint": "Файл создаётся при первом запуске сервера",
|
"hint": i18n.T(i18n.FromRequest(r), "Файл создаётся при первом запуске сервера"),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, r, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
@@ -32,6 +33,9 @@ func (s *Server) handleGetServerConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
"path": path,
|
"path": path,
|
||||||
"keys": ini.Keys(),
|
"keys": ini.Keys(),
|
||||||
"values": ini.Map(),
|
"values": ini.Map(),
|
||||||
|
// Игра сама пишет в конфиг описания параметров на языке сервера —
|
||||||
|
// показываем их, вместо того чтобы придумывать свои.
|
||||||
|
"descriptions": ini.Comments(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,14 +51,14 @@ func (s *Server) handlePutServerConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
path := s.cfg.ServerConfigPath()
|
path := s.cfg.ServerConfigPath()
|
||||||
ini, err := pzconfig.LoadINI(path)
|
ini, err := pzconfig.LoadINI(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "конфиг сервера недоступен: "+err.Error())
|
writeError(w, r, http.StatusBadRequest, "конфиг сервера недоступен: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for key, value := range body.Values {
|
for key, value := range body.Values {
|
||||||
ini.Set(key, value)
|
ini.Set(key, value)
|
||||||
}
|
}
|
||||||
if err := pzconfig.SaveINI(path, ini); err != nil {
|
if err := pzconfig.SaveINI(path, ini); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, r, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.sup.AppendLog("manager", "Конфиг сервера сохранён (изменений: "+strconv.Itoa(len(body.Values))+"). Изменения вступят в силу после перезапуска.")
|
s.sup.AppendLog("manager", "Конфиг сервера сохранён (изменений: "+strconv.Itoa(len(body.Values))+"). Изменения вступят в силу после перезапуска.")
|
||||||
@@ -68,12 +72,12 @@ func (s *Server) handleGetSandbox(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"exists": false,
|
"exists": false,
|
||||||
"path": path,
|
"path": path,
|
||||||
"hint": "Файл создаётся при первом запуске сервера",
|
"hint": i18n.T(i18n.FromRequest(r), "Файл создаётся при первом запуске сервера"),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, r, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
@@ -93,17 +97,17 @@ func (s *Server) handlePutSandbox(w http.ResponseWriter, r *http.Request) {
|
|||||||
path := s.cfg.SandboxConfigPath()
|
path := s.cfg.SandboxConfigPath()
|
||||||
sb, err := pzconfig.LoadSandbox(path)
|
sb, err := pzconfig.LoadSandbox(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "настройки песочницы недоступны: "+err.Error())
|
writeError(w, r, http.StatusBadRequest, "настройки песочницы недоступны: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for key, value := range body.Values {
|
for key, value := range body.Values {
|
||||||
if err := sb.Set(key, value); err != nil {
|
if err := sb.Set(key, value); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, r, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := pzconfig.SaveSandbox(path, sb); err != nil {
|
if err := pzconfig.SaveSandbox(path, sb); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, r, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.sup.AppendLog("manager", "Настройки песочницы сохранены. Часть параметров применяется только к новому миру.")
|
s.sup.AppendLog("manager", "Настройки песочницы сохранены. Часть параметров применяется только к новому миру.")
|
||||||
@@ -174,16 +178,16 @@ func (s *Server) handlePutManagerConfig(w http.ResponseWriter, r *http.Request)
|
|||||||
c.Backup.Schedule = config.Duration(time.Duration(dto.BackupScheduleMin) * time.Minute)
|
c.Backup.Schedule = config.Duration(time.Duration(dto.BackupScheduleMin) * time.Minute)
|
||||||
|
|
||||||
if err := c.Save(); err != nil {
|
if err := c.Save(); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, r, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := c.EnsureDirs(); err != nil {
|
if err := c.EnsureDirs(); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, r, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.sup.AppendLog("manager", "Настройки менеджера сохранены")
|
s.sup.AppendLog("manager", "Настройки менеджера сохранены")
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"saved": true,
|
"saved": true,
|
||||||
"note": "Адрес прослушивания применится после перезапуска pzmanager",
|
"note": i18n.T(i18n.FromRequest(r), "Адрес прослушивания применится после перезапуска pzmanager"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
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/pzconfig"
|
"github.com/mrleo1nid/pzmanager/internal/pzconfig"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -24,12 +28,12 @@ func (s *Server) handleGetMods(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"exists": false,
|
"exists": false,
|
||||||
"path": path,
|
"path": path,
|
||||||
"hint": "Файл конфигурации появится после первого запуска сервера",
|
"hint": i18n.T(i18n.FromRequest(r), "Файл конфигурации появится после первого запуска сервера"),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, r, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +71,7 @@ func (s *Server) handlePutMods(w http.ResponseWriter, r *http.Request) {
|
|||||||
path := s.cfg.ServerConfigPath()
|
path := s.cfg.ServerConfigPath()
|
||||||
ini, err := pzconfig.LoadINI(path)
|
ini, err := pzconfig.LoadINI(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "конфиг сервера недоступен: "+err.Error())
|
writeError(w, r, http.StatusBadRequest, "конфиг сервера недоступен: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ini.SetMods(body.Mods, body.WorkshopItems)
|
ini.SetMods(body.Mods, body.WorkshopItems)
|
||||||
@@ -75,7 +79,7 @@ func (s *Server) handlePutMods(w http.ResponseWriter, r *http.Request) {
|
|||||||
ini.Set(pzconfig.KeyMap, pzconfig.JoinList(body.Map))
|
ini.Set(pzconfig.KeyMap, pzconfig.JoinList(body.Map))
|
||||||
}
|
}
|
||||||
if err := pzconfig.SaveINI(path, ini); err != nil {
|
if err := pzconfig.SaveINI(path, ini); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, r, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.sup.AppendLog("manager", "Список модов обновлён. Сервер докачает моды из мастерской при следующем запуске.")
|
s.sup.AppendLog("manager", "Список модов обновлён. Сервер докачает моды из мастерской при следующем запуске.")
|
||||||
@@ -95,7 +99,7 @@ func (s *Server) handleLookupMods(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(body.IDs) > 200 {
|
if len(body.IDs) > 200 {
|
||||||
writeError(w, http.StatusBadRequest, "слишком много идентификаторов за один запрос")
|
writeError(w, r, http.StatusBadRequest, "слишком много идентификаторов за один запрос")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
items := s.workshop.Details(r.Context(), body.IDs)
|
items := s.workshop.Details(r.Context(), body.IDs)
|
||||||
@@ -114,13 +118,13 @@ func (s *Server) handleExpandCollection(w http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
match := idFromInput.FindString(body.ID)
|
match := idFromInput.FindString(body.ID)
|
||||||
if match == "" {
|
if match == "" {
|
||||||
writeError(w, http.StatusBadRequest, "не похоже на идентификатор или ссылку на коллекцию")
|
writeError(w, r, http.StatusBadRequest, "не похоже на идентификатор или ссылку на коллекцию")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
items, err := s.workshop.Collection(r.Context(), match)
|
items, err := s.workshop.Collection(r.Context(), match)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadGateway, err.Error())
|
writeError(w, r, http.StatusBadGateway, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Названия подтягиваем сразу: иначе панель показала бы десяток голых
|
// Названия подтягиваем сразу: иначе панель показала бы десяток голых
|
||||||
@@ -130,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})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -5,19 +5,62 @@ 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/pzserver"
|
"github.com/mrleo1nid/pzmanager/internal/pzserver"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// validateProfile проверяет числа из формы здесь, а не при запуске сервера:
|
||||||
|
// иначе про опечатку в профиле стало бы известно только из лога упавшего
|
||||||
|
// сервера.
|
||||||
|
func validateProfile(dto profileDTO) error {
|
||||||
|
maxBytes, err := pzconfig.ParseMemory(dto.JavaMemory)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
minBytes, err := pzconfig.ParseMemory(dto.JavaMemoryMin)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if maxBytes > 0 && minBytes > maxBytes {
|
||||||
|
return fmt.Errorf("начальный размер памяти (%s) больше максимального (%s)",
|
||||||
|
dto.JavaMemoryMin, dto.JavaMemory)
|
||||||
|
}
|
||||||
|
if dto.StartupTimeoutMin < 0 || dto.StartupTimeoutMin > maxStartupTimeoutMin {
|
||||||
|
return fmt.Errorf("ожидание готовности должно быть от 1 до %d минут",
|
||||||
|
maxStartupTimeoutMin)
|
||||||
|
}
|
||||||
|
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 {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
JavaMemory string `json:"java_memory"`
|
JavaMemory string `json:"java_memory"`
|
||||||
|
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"`
|
||||||
|
// StartupTimeoutMin — сколько минут ждать строку о готовности сервера.
|
||||||
|
// Ноль означает значение по умолчанию, его панель показывает подсказкой.
|
||||||
|
StartupTimeoutMin int `json:"startup_timeout_min"`
|
||||||
AdminPassword string `json:"admin_password,omitempty"`
|
AdminPassword string `json:"admin_password,omitempty"`
|
||||||
// Признаки состояния на диске: по ним панель подсказывает, что профиль
|
// Признаки состояния на диске: по ним панель подсказывает, что профиль
|
||||||
// ещё ни разу не запускался.
|
// ещё ни разу не запускался.
|
||||||
@@ -33,8 +76,10 @@ func (s *Server) profileToDTO(p config.Profile) profileDTO {
|
|||||||
ID: p.ID,
|
ID: p.ID,
|
||||||
Title: p.Title,
|
Title: p.Title,
|
||||||
JavaMemory: p.JavaMemory,
|
JavaMemory: p.JavaMemory,
|
||||||
|
JavaMemoryMin: p.JavaMemoryMin,
|
||||||
ExtraArgs: p.ExtraArgs,
|
ExtraArgs: p.ExtraArgs,
|
||||||
HasAdminPassword: p.AdminPassword != "",
|
HasAdminPassword: p.AdminPassword != "",
|
||||||
|
StartupTimeoutMin: int(p.StartupTimeout.Duration().Minutes()),
|
||||||
HasConfig: fileExists(s.cfg.ServerConfigPathFor(p.ID)),
|
HasConfig: fileExists(s.cfg.ServerConfigPathFor(p.ID)),
|
||||||
WorldReady: fileExists(s.cfg.WorldDirFor(p.ID)),
|
WorldReady: fileExists(s.cfg.WorldDirFor(p.ID)),
|
||||||
Active: p.ID == s.cfg.Active,
|
Active: p.ID == s.cfg.Active,
|
||||||
@@ -49,6 +94,7 @@ func (s *Server) handleListProfiles(w http.ResponseWriter, r *http.Request) {
|
|||||||
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,
|
||||||
})
|
})
|
||||||
@@ -61,16 +107,22 @@ func (s *Server) handleCreateProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !decodeJSON(w, r, &dto) {
|
if !decodeJSON(w, r, &dto) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := validateProfile(dto); err != nil {
|
||||||
|
writeError(w, r, http.StatusBadRequest, err.Error())
|
||||||
|
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,
|
||||||
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, http.StatusBadRequest, err.Error())
|
writeError(w, r, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,13 +131,13 @@ func (s *Server) handleCreateProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Профиль уже добавлен в память — откатываем, чтобы не остался
|
// Профиль уже добавлен в память — откатываем, чтобы не остался
|
||||||
// наполовину настроенный.
|
// наполовину настроенный.
|
||||||
_ = s.cfg.DeleteProfile(profile.ID)
|
_ = s.cfg.DeleteProfile(profile.ID)
|
||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, r, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.cfg.Save(); err != nil {
|
if err := s.cfg.Save(); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, r, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.sup.AppendLog("manager", fmt.Sprintf("Создан профиль %q", profile.Name()))
|
s.sup.AppendLog("manager", fmt.Sprintf("Создан профиль %q", profile.Name()))
|
||||||
@@ -125,7 +177,7 @@ func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
id := r.PathValue("id")
|
id := r.PathValue("id")
|
||||||
current, ok := s.cfg.Profile(id)
|
current, ok := s.cfg.Profile(id)
|
||||||
if !ok {
|
if !ok {
|
||||||
writeError(w, http.StatusNotFound, "профиль не найден")
|
writeError(w, r, http.StatusNotFound, "профиль не найден")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,12 +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 := validateProfile(dto); err != nil {
|
||||||
|
writeError(w, r, http.StatusBadRequest, err.Error())
|
||||||
|
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,
|
||||||
ExtraArgs: dto.ExtraArgs,
|
ExtraArgs: dto.ExtraArgs,
|
||||||
|
StartupTimeout: startupTimeout(dto),
|
||||||
// Пустой пароль означает "оставить прежний": наружу мы его не отдаём,
|
// Пустой пароль означает "оставить прежний": наружу мы его не отдаём,
|
||||||
// и форма присылает пустую строку, пока его не меняли.
|
// и форма присылает пустую строку, пока его не меняли.
|
||||||
AdminPassword: current.AdminPassword,
|
AdminPassword: current.AdminPassword,
|
||||||
@@ -148,11 +206,11 @@ func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := s.cfg.UpdateProfile(id, updated); err != nil {
|
if err := s.cfg.UpdateProfile(id, updated); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, r, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.cfg.Save(); err != nil {
|
if err := s.cfg.Save(); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, r, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,11 +224,11 @@ func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (s *Server) handleDeleteProfile(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleDeleteProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
id := r.PathValue("id")
|
id := r.PathValue("id")
|
||||||
if err := s.cfg.DeleteProfile(id); err != nil {
|
if err := s.cfg.DeleteProfile(id); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, r, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.cfg.Save(); err != nil {
|
if err := s.cfg.Save(); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, r, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.sup.AppendLog("manager", fmt.Sprintf(
|
s.sup.AppendLog("manager", fmt.Sprintf(
|
||||||
@@ -183,16 +241,16 @@ func (s *Server) handleDeleteProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
// сервере нельзя: профили делят игровые порты и файлы запуска.
|
// сервере нельзя: профили делят игровые порты и файлы запуска.
|
||||||
func (s *Server) handleActivateProfile(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleActivateProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
if s.sup.State() != pzserver.StateStopped {
|
if s.sup.State() != pzserver.StateStopped {
|
||||||
writeError(w, http.StatusConflict, "сначала остановите игровой сервер")
|
writeError(w, r, http.StatusConflict, "сначала остановите игровой сервер")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
id := r.PathValue("id")
|
id := r.PathValue("id")
|
||||||
if err := s.cfg.SetActive(id); err != nil {
|
if err := s.cfg.SetActive(id); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, r, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.cfg.Save(); err != nil {
|
if err := s.cfg.Save(); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, r, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.sup.AppendLog("manager", fmt.Sprintf("Активный профиль: %s", s.cfg.ActiveProfile().Name()))
|
s.sup.AppendLog("manager", fmt.Sprintf("Активный профиль: %s", s.cfg.ActiveProfile().Name()))
|
||||||
|
|||||||
@@ -8,19 +8,26 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/mrleo1nid/pzmanager/internal/i18n"
|
||||||
"github.com/mrleo1nid/pzmanager/internal/pzserver"
|
"github.com/mrleo1nid/pzmanager/internal/pzserver"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
lang := i18n.FromRequest(r)
|
||||||
"server": s.sup.Status(),
|
status := s.sup.Status()
|
||||||
"task": s.jobs.Status(),
|
status.LastExit = i18n.T(lang, status.LastExit)
|
||||||
})
|
|
||||||
|
// Имя задачи придумано менеджером и висит в панели, пока задача идёт.
|
||||||
|
task := s.jobs.Status()
|
||||||
|
task.Name = i18n.T(lang, task.Name)
|
||||||
|
task.Error = i18n.T(lang, task.Error)
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"server": status, "task": task})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleStart(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleStart(w http.ResponseWriter, r *http.Request) {
|
||||||
if err := s.sup.Start(); err != nil {
|
if err := s.sup.Start(); err != nil {
|
||||||
writeOpError(w, err)
|
writeOpError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"state": s.sup.State()})
|
writeJSON(w, http.StatusOK, map[string]any{"state": s.sup.State()})
|
||||||
@@ -31,7 +38,7 @@ func (s *Server) handleStart(w http.ResponseWriter, r *http.Request) {
|
|||||||
// видит прогресс по состоянию и логам.
|
// видит прогресс по состоянию и логам.
|
||||||
func (s *Server) handleStop(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleStop(w http.ResponseWriter, r *http.Request) {
|
||||||
if s.sup.State() == pzserver.StateStopped {
|
if s.sup.State() == pzserver.StateStopped {
|
||||||
writeOpError(w, pzserver.ErrNotRunning)
|
writeOpError(w, r, pzserver.ErrNotRunning)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
go func() {
|
go func() {
|
||||||
@@ -44,7 +51,7 @@ func (s *Server) handleStop(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (s *Server) handleRestart(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleRestart(w http.ResponseWriter, r *http.Request) {
|
||||||
if !s.sup.Installed() {
|
if !s.sup.Installed() {
|
||||||
writeOpError(w, pzserver.ErrNotInstalled)
|
writeOpError(w, r, pzserver.ErrNotInstalled)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
go func() {
|
go func() {
|
||||||
@@ -63,7 +70,7 @@ func (s *Server) handleConsole(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.sup.Send(body.Command); err != nil {
|
if err := s.sup.Send(body.Command); err != nil {
|
||||||
writeOpError(w, err)
|
writeOpError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"sent": body.Command})
|
writeJSON(w, http.StatusOK, map[string]any{"sent": body.Command})
|
||||||
@@ -72,7 +79,34 @@ 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": s.sup.Lines(since)})
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"epoch": s.sup.LogEpoch(),
|
||||||
|
"lines": translateLines(r, s.sup.Lines(since)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// translatable отсеивает вывод самого игрового сервера: он на английском и в
|
||||||
|
// словаре менеджера его нет. Перебирать ради него все шаблоны накладно —
|
||||||
|
// загрузка мира со сборкой модов печатает десятки тысяч строк.
|
||||||
|
func translatable(stream string) bool {
|
||||||
|
return stream == "manager" || stream == "task"
|
||||||
|
}
|
||||||
|
|
||||||
|
// translateLines переводит строки консоли на язык читающего. Буфер один на
|
||||||
|
// всех, поэтому перевод делается на выходе, у каждого подключения свой.
|
||||||
|
func translateLines(r *http.Request, lines []pzserver.LogLine) []pzserver.LogLine {
|
||||||
|
lang := i18n.FromRequest(r)
|
||||||
|
if lang == i18n.Default {
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
out := make([]pzserver.LogLine, len(lines))
|
||||||
|
for i, line := range lines {
|
||||||
|
if translatable(line.Stream) {
|
||||||
|
line.Text = i18n.T(lang, line.Text)
|
||||||
|
}
|
||||||
|
out[i] = line
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleLogStream — живая консоль через Server-Sent Events. SSE выбран вместо
|
// handleLogStream — живая консоль через Server-Sent Events. SSE выбран вместо
|
||||||
@@ -80,7 +114,7 @@ func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (s *Server) handleLogStream(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleLogStream(w http.ResponseWriter, r *http.Request) {
|
||||||
flusher, ok := w.(http.Flusher)
|
flusher, ok := w.(http.Flusher)
|
||||||
if !ok {
|
if !ok {
|
||||||
writeError(w, http.StatusInternalServerError, "потоковая отдача не поддерживается")
|
writeError(w, r, http.StatusInternalServerError, "потоковая отдача не поддерживается")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,9 +128,17 @@ func (s *Server) handleLogStream(w http.ResponseWriter, r *http.Request) {
|
|||||||
lines, unsubscribe := s.sup.Subscribe()
|
lines, unsubscribe := s.sup.Subscribe()
|
||||||
defer unsubscribe()
|
defer unsubscribe()
|
||||||
|
|
||||||
// Сначала отдаём хвост истории, чтобы вкладка не открывалась пустой.
|
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 s.sup.Lines(since) {
|
for _, line := range translateLines(r, s.sup.Lines(since)) {
|
||||||
if !writeSSE(w, line) {
|
if !writeSSE(w, line) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -115,6 +157,9 @@ func (s *Server) handleLogStream(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if translatable(line.Stream) {
|
||||||
|
line.Text = i18n.T(lang, line.Text)
|
||||||
|
}
|
||||||
if !writeSSE(w, line) {
|
if !writeSSE(w, line) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -136,3 +181,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
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// Вывод игрового сервера идёт мимо словаря: он английский, а перебор шаблонов
|
||||||
|
// на десятках тысяч строк загрузки мира стоит дорого.
|
||||||
|
func TestTranslatableOnlyManagerStreams(t *testing.T) {
|
||||||
|
for _, stream := range []string{"manager", "task"} {
|
||||||
|
if !translatable(stream) {
|
||||||
|
t.Errorf("строки потока %q должны переводиться", stream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, stream := range []string{"stdout", "stderr", ""} {
|
||||||
|
if translatable(stream) {
|
||||||
|
t.Errorf("строки потока %q переводить незачем", stream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ func (s *Server) handleTaskStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (s *Server) handleTaskCancel(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleTaskCancel(w http.ResponseWriter, r *http.Request) {
|
||||||
if !s.jobs.Busy() {
|
if !s.jobs.Busy() {
|
||||||
writeError(w, http.StatusConflict, "нет активной задачи")
|
writeError(w, r, http.StatusConflict, "нет активной задачи")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.jobs.Cancel()
|
s.jobs.Cancel()
|
||||||
@@ -31,7 +31,7 @@ func (s *Server) handleTaskCancel(w http.ResponseWriter, r *http.Request) {
|
|||||||
// JVM, и сервер упадёт непредсказуемо.
|
// JVM, и сервер упадёт непредсказуемо.
|
||||||
func (s *Server) handleInstall(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleInstall(w http.ResponseWriter, r *http.Request) {
|
||||||
if s.sup.State() != pzserver.StateStopped {
|
if s.sup.State() != pzserver.StateStopped {
|
||||||
writeError(w, http.StatusConflict, "сначала остановите игровой сервер")
|
writeError(w, r, http.StatusConflict, "сначала остановите игровой сервер")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ func (s *Server) handleInstall(w http.ResponseWriter, r *http.Request) {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeOpError(w, err)
|
writeOpError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusAccepted, map[string]any{"task": name})
|
writeJSON(w, http.StatusAccepted, map[string]any{"task": name})
|
||||||
@@ -55,7 +55,7 @@ func (s *Server) handleInstall(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (s *Server) handleListBackups(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleListBackups(w http.ResponseWriter, r *http.Request) {
|
||||||
archives, err := s.backups.List()
|
archives, err := s.backups.List()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, r, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Фильтр по профилю необязателен: панель показывает архивы всех профилей,
|
// Фильтр по профилю необязателен: панель показывает архивы всех профилей,
|
||||||
@@ -88,7 +88,7 @@ func (s *Server) handleCreateBackup(w http.ResponseWriter, r *http.Request) {
|
|||||||
body.Profile = s.cfg.Active
|
body.Profile = s.cfg.Active
|
||||||
}
|
}
|
||||||
if _, ok := s.cfg.Profile(body.Profile); !ok {
|
if _, ok := s.cfg.Profile(body.Profile); !ok {
|
||||||
writeError(w, http.StatusBadRequest, "профиль не найден")
|
writeError(w, r, http.StatusBadRequest, "профиль не найден")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Живой сервер держит мир в памяти, и снимок чужого профиля тут ни при
|
// Живой сервер держит мир в памяти, и снимок чужого профиля тут ни при
|
||||||
@@ -119,7 +119,7 @@ func (s *Server) handleCreateBackup(w http.ResponseWriter, r *http.Request) {
|
|||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeOpError(w, err)
|
writeOpError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusAccepted, map[string]any{"task": name})
|
writeJSON(w, http.StatusAccepted, map[string]any{"task": name})
|
||||||
@@ -127,7 +127,7 @@ func (s *Server) handleCreateBackup(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (s *Server) handleDeleteBackup(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleDeleteBackup(w http.ResponseWriter, r *http.Request) {
|
||||||
if err := s.backups.Delete(r.PathValue("name")); err != nil {
|
if err := s.backups.Delete(r.PathValue("name")); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, r, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"deleted": r.PathValue("name")})
|
writeJSON(w, http.StatusOK, map[string]any{"deleted": r.PathValue("name")})
|
||||||
@@ -137,18 +137,18 @@ func (s *Server) handleDownloadBackup(w http.ResponseWriter, r *http.Request) {
|
|||||||
name := r.PathValue("name")
|
name := r.PathValue("name")
|
||||||
path, err := s.backups.Path(name)
|
path, err := s.backups.Path(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, r, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
f, err := os.Open(path)
|
f, err := os.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusNotFound, "архив не найден")
|
writeError(w, r, http.StatusNotFound, "архив не найден")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
info, err := f.Stat()
|
info, err := f.Stat()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, r, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,12 +162,12 @@ func (s *Server) handleDownloadBackup(w http.ResponseWriter, r *http.Request) {
|
|||||||
// своим состоянием при следующем сохранении.
|
// своим состоянием при следующем сохранении.
|
||||||
func (s *Server) handleRestoreBackup(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleRestoreBackup(w http.ResponseWriter, r *http.Request) {
|
||||||
if s.sup.State() != pzserver.StateStopped {
|
if s.sup.State() != pzserver.StateStopped {
|
||||||
writeError(w, http.StatusConflict, "сначала остановите игровой сервер")
|
writeError(w, r, http.StatusConflict, "сначала остановите игровой сервер")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
name := r.PathValue("name")
|
name := r.PathValue("name")
|
||||||
if _, err := s.backups.Path(name); err != nil {
|
if _, err := s.backups.Path(name); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, r, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Страховочную копию снимаем с того профиля, поверх которого ляжет архив;
|
// Страховочную копию снимаем с того профиля, поверх которого ляжет архив;
|
||||||
@@ -188,7 +188,7 @@ func (s *Server) handleRestoreBackup(w http.ResponseWriter, r *http.Request) {
|
|||||||
return s.backups.Restore(ctx, log, name)
|
return s.backups.Restore(ctx, log, name)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeOpError(w, err)
|
writeOpError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusAccepted, map[string]any{"task": taskName})
|
writeJSON(w, http.StatusAccepted, map[string]any{"task": taskName})
|
||||||
@@ -236,12 +236,16 @@ func StartBackupScheduler(ctx context.Context, s *Server) {
|
|||||||
// При обычном старте то же самое делает супервизор.
|
// При обычном старте то же самое делает супервизор.
|
||||||
func (s *Server) applyJavaMemory(log func(string)) error {
|
func (s *Server) applyJavaMemory(log func(string)) error {
|
||||||
profile := s.cfg.ActiveProfile()
|
profile := s.cfg.ActiveProfile()
|
||||||
if profile.JavaMemory == "" {
|
if profile.JavaMemory == "" && profile.JavaMemoryMin == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if err := pzconfig.SetJavaMemory(s.cfg.ServerDir, profile.JavaMemory); err != nil {
|
if err := pzconfig.SetJavaMemory(s.cfg.ServerDir, profile.JavaMemory, profile.JavaMemoryMin); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if profile.JavaMemoryMin != "" {
|
||||||
|
log(fmt.Sprintf("Память JVM: от %s до %s", profile.JavaMemoryMin, profile.JavaMemory))
|
||||||
|
} else {
|
||||||
log("Лимит памяти JVM выставлен: " + profile.JavaMemory)
|
log("Лимит памяти JVM выставлен: " + profile.JavaMemory)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ func (s *Server) handleApplyUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !s.updater.CanApply(r.Context()) {
|
if !s.updater.CanApply(r.Context()) {
|
||||||
writeError(w, http.StatusPreconditionFailed, updater.ErrNoAdmin.Error())
|
writeError(w, r, http.StatusPreconditionFailed, updater.ErrNoAdmin.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ func (s *Server) handleApplyUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
return s.updater.Apply(ctx, log, body.Version)
|
return s.updater.Apply(ctx, log, body.Version)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeOpError(w, err)
|
writeOpError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusAccepted, map[string]any{"task": name})
|
writeJSON(w, http.StatusAccepted, map[string]any{"task": name})
|
||||||
@@ -49,7 +49,7 @@ func (s *Server) handleApplyUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
// handleRestartManager перезапускает саму панель.
|
// handleRestartManager перезапускает саму панель.
|
||||||
func (s *Server) handleRestartManager(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleRestartManager(w http.ResponseWriter, r *http.Request) {
|
||||||
if !s.updater.CanApply(r.Context()) {
|
if !s.updater.CanApply(r.Context()) {
|
||||||
writeError(w, http.StatusPreconditionFailed, updater.ErrNoAdmin.Error())
|
writeError(w, r, http.StatusPreconditionFailed, updater.ErrNoAdmin.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ func (s *Server) handleRestartManager(w http.ResponseWriter, r *http.Request) {
|
|||||||
return s.updater.Restart(ctx, log)
|
return s.updater.Restart(ctx, log)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeOpError(w, err)
|
writeOpError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusAccepted, map[string]any{"task": name})
|
writeJSON(w, http.StatusAccepted, map[string]any{"task": name})
|
||||||
|
|||||||
+14
-9
@@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/mrleo1nid/pzmanager/internal/auth"
|
"github.com/mrleo1nid/pzmanager/internal/auth"
|
||||||
"github.com/mrleo1nid/pzmanager/internal/backup"
|
"github.com/mrleo1nid/pzmanager/internal/backup"
|
||||||
"github.com/mrleo1nid/pzmanager/internal/config"
|
"github.com/mrleo1nid/pzmanager/internal/config"
|
||||||
|
"github.com/mrleo1nid/pzmanager/internal/i18n"
|
||||||
"github.com/mrleo1nid/pzmanager/internal/job"
|
"github.com/mrleo1nid/pzmanager/internal/job"
|
||||||
"github.com/mrleo1nid/pzmanager/internal/pzconfig"
|
"github.com/mrleo1nid/pzmanager/internal/pzconfig"
|
||||||
"github.com/mrleo1nid/pzmanager/internal/pzserver"
|
"github.com/mrleo1nid/pzmanager/internal/pzserver"
|
||||||
@@ -116,10 +117,13 @@ 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,
|
||||||
"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,
|
||||||
@@ -175,7 +179,7 @@ func (s *Server) withCommonHeaders(next http.Handler) http.Handler {
|
|||||||
|
|
||||||
if r.Method != http.MethodGet && r.Method != http.MethodHead &&
|
if r.Method != http.MethodGet && r.Method != http.MethodHead &&
|
||||||
strings.HasPrefix(r.URL.Path, "/api/") && r.Header.Get(csrfHeader) == "" {
|
strings.HasPrefix(r.URL.Path, "/api/") && r.Header.Get(csrfHeader) == "" {
|
||||||
writeError(w, http.StatusForbidden, "запрос без заголовка "+csrfHeader)
|
writeError(w, r, http.StatusForbidden, "запрос без заголовка "+csrfHeader)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
@@ -186,7 +190,7 @@ func (s *Server) withCommonHeaders(next http.Handler) http.Handler {
|
|||||||
func (s *Server) requireAuth(next http.HandlerFunc) http.Handler {
|
func (s *Server) requireAuth(next http.HandlerFunc) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if _, ok := s.users.SessionFromRequest(r); !ok {
|
if _, ok := s.users.SessionFromRequest(r); !ok {
|
||||||
writeError(w, http.StatusUnauthorized, "требуется вход")
|
writeError(w, r, http.StatusUnauthorized, "требуется вход")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next(w, r)
|
next(w, r)
|
||||||
@@ -228,20 +232,21 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// writeError отправляет ошибку в том же формате, что и остальные ответы.
|
// writeError отправляет ошибку в том же формате, что и остальные ответы.
|
||||||
func writeError(w http.ResponseWriter, status int, message string) {
|
// Текст переводится на язык панели: в коде сообщения написаны по-русски.
|
||||||
writeJSON(w, status, map[string]string{"error": message})
|
func writeError(w http.ResponseWriter, r *http.Request, status int, message string) {
|
||||||
|
writeJSON(w, status, map[string]string{"error": i18n.T(i18n.FromRequest(r), message)})
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeOpError переводит ошибку операции в подходящий HTTP-код.
|
// writeOpError переводит ошибку операции в подходящий HTTP-код.
|
||||||
func writeOpError(w http.ResponseWriter, err error) {
|
func writeOpError(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, pzserver.ErrAlreadyRunning), errors.Is(err, pzserver.ErrNotRunning),
|
case errors.Is(err, pzserver.ErrAlreadyRunning), errors.Is(err, pzserver.ErrNotRunning),
|
||||||
errors.Is(err, job.ErrBusy):
|
errors.Is(err, job.ErrBusy):
|
||||||
writeError(w, http.StatusConflict, err.Error())
|
writeError(w, r, http.StatusConflict, err.Error())
|
||||||
case errors.Is(err, pzserver.ErrNotInstalled):
|
case errors.Is(err, pzserver.ErrNotInstalled):
|
||||||
writeError(w, http.StatusPreconditionFailed, err.Error())
|
writeError(w, r, http.StatusPreconditionFailed, err.Error())
|
||||||
default:
|
default:
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, r, http.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,7 +256,7 @@ func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
|
|||||||
dec := json.NewDecoder(r.Body)
|
dec := json.NewDecoder(r.Body)
|
||||||
dec.DisallowUnknownFields()
|
dec.DisallowUnknownFields()
|
||||||
if err := dec.Decode(dst); err != nil {
|
if err := dec.Decode(dst); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "некорректный JSON: "+err.Error())
|
writeError(w, r, http.StatusBadRequest, "некорректный JSON: "+err.Error())
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -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("…дальше молча, итог будет в конце")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,8 +34,34 @@ type Profile struct {
|
|||||||
// запуске: файл запуска сервера общий для всех профилей.
|
// запуске: файл запуска сервера общий для всех профилей.
|
||||||
JavaMemory string `yaml:"java_memory"`
|
JavaMemory string `yaml:"java_memory"`
|
||||||
|
|
||||||
|
// JavaMemoryMin — -Xms. Пусто (по умолчанию) означает, что JVM занимает
|
||||||
|
// память по мере надобности: равные -Xms и -Xmx заставляют её забрать всё
|
||||||
|
// сразу, и на машине с небольшой ОЗУ сервер не поднимется.
|
||||||
|
JavaMemoryMin string `yaml:"java_memory_min"`
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -54,6 +80,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-туннель
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package i18n
|
||||||
|
|
||||||
|
// Словарь сообщений менеджера. Ключ — русская фраза ровно в том виде, в каком
|
||||||
|
// она собирается в коде; значение — английский перевод.
|
||||||
|
//
|
||||||
|
// В formats оба текста записаны как форматные строки: %s отмечает место, куда
|
||||||
|
// при сборке сообщения подставилось значение. Порядок значений в переводе
|
||||||
|
// должен совпадать с оригиналом.
|
||||||
|
|
||||||
|
var messages = map[string]string{
|
||||||
|
// --- Вход и учётные записи ---
|
||||||
|
"панель уже настроена": "the panel is already set up",
|
||||||
|
"неверный код первичной настройки": "wrong setup code",
|
||||||
|
"неверный логин или пароль": "wrong login or password",
|
||||||
|
"требуется вход": "sign-in required",
|
||||||
|
"логин не может быть пустым": "the login cannot be empty",
|
||||||
|
"пароль короче 8 символов": "the password is shorter than 8 characters",
|
||||||
|
"пароли не совпадают": "the passwords do not match",
|
||||||
|
"пользователь не найден": "user not found",
|
||||||
|
"пользователь уже существует": "the user already exists",
|
||||||
|
"нельзя удалить учётную запись, под которой вы вошли": "you cannot delete the account you are signed in with",
|
||||||
|
|
||||||
|
// --- Сервер и задачи ---
|
||||||
|
"сервер не запущен": "the server is not running",
|
||||||
|
"сервер уже запущен": "the server is already running",
|
||||||
|
"сначала остановите игровой сервер": "stop the game server first",
|
||||||
|
"серверные файлы не найдены — сначала выполните установку": "server files not found — install the server first",
|
||||||
|
"нет активной задачи": "no active task",
|
||||||
|
"уже выполняется другая задача": "another task is already running",
|
||||||
|
"пустая команда": "empty command",
|
||||||
|
"потоковая отдача не поддерживается": "streaming is not supported",
|
||||||
|
"штатное завершение": "clean shutdown",
|
||||||
|
|
||||||
|
// --- Профили ---
|
||||||
|
"профиль не найден": "profile not found",
|
||||||
|
"нельзя удалить активный профиль — сначала переключитесь на другой": "the active profile cannot be deleted — switch to another one first",
|
||||||
|
"нельзя удалить единственный профиль": "the only profile cannot be deleted",
|
||||||
|
"путь к конфигу неизвестен": "the config path is unknown",
|
||||||
|
|
||||||
|
// --- Бэкапы и обновление ---
|
||||||
|
"архив не найден": "archive not found",
|
||||||
|
"обновление из панели недоступно: нет pzmanager-admin или правила sudo": "updating from the panel is unavailable: pzmanager-admin or the sudo rule is missing",
|
||||||
|
"длительность должна быть строкой вида 90s или числом секунд": "the duration must be a string like 90s or a number of seconds",
|
||||||
|
|
||||||
|
// --- Моды ---
|
||||||
|
"слишком много идентификаторов за один запрос": "too many identifiers in one request",
|
||||||
|
"не похоже на идентификатор или ссылку на коллекцию": "that does not look like a collection ID or link",
|
||||||
|
|
||||||
|
// --- Подсказки и пометки в ответах API ---
|
||||||
|
"Файл создаётся при первом запуске сервера": "The file is created on the first server start",
|
||||||
|
"Файл конфигурации появится после первого запуска сервера": "The configuration file appears after the first server start",
|
||||||
|
"Адрес прослушивания применится после перезапуска pzmanager": "The listen address applies after a pzmanager restart",
|
||||||
|
|
||||||
|
// --- Имена задач ---
|
||||||
|
"Установка/обновление сервера через SteamCMD": "Installing or updating the server via SteamCMD",
|
||||||
|
"Обновление панели": "Panel update",
|
||||||
|
"Перезапуск панели": "Panel restart",
|
||||||
|
|
||||||
|
// --- Сообщения в консоли панели ---
|
||||||
|
"Настройки менеджера сохранены": "Manager settings saved",
|
||||||
|
"Настройки песочницы сохранены. Часть параметров применяется только к новому миру.": "Sandbox settings saved. Some of them only affect a freshly created world.",
|
||||||
|
"Список модов обновлён. Сервер докачает моды из мастерской при следующем запуске.": "Mod list updated. The server will download the missing mods on its next start.",
|
||||||
|
"Профиль сохранён": "Profile saved",
|
||||||
|
"Профиль сохранён. Память и аргументы запуска применятся после перезапуска сервера.": "Profile saved. Memory and launch arguments apply after a server restart.",
|
||||||
|
"Останавливаю сервер перед бэкапом": "Stopping the server before the backup",
|
||||||
|
"Делаю страховочный бэкап текущего мира": "Taking a safety backup of the current world",
|
||||||
|
"Поднимаю сервер обратно": "Bringing the server back up",
|
||||||
|
"Плановый бэкап пропущен: выполняется другая задача": "Scheduled backup skipped: another task is running",
|
||||||
|
"Мир ещё не создан — архивирую только конфиги": "The world does not exist yet — archiving the configs only",
|
||||||
|
"Останавливаю сервер командой quit...": "Stopping the server with the quit command...",
|
||||||
|
"Сервер не ответил на quit — отправляю SIGTERM": "The server did not answer quit — sending SIGTERM",
|
||||||
|
"Сервер завис — отправляю 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",
|
||||||
|
"Посмотрите ошибку выше и запустите сервер вручную.": "Check the error above and start the server manually.",
|
||||||
|
"Запускаю обновление — оно идёт отдельной службой и переживёт перезапуск панели": "Starting the update — it runs as a separate service and survives the panel restart",
|
||||||
|
"Подробности: journalctl -u pzmanager-update -f": "Details: journalctl -u pzmanager-update -f",
|
||||||
|
"Перезапускаю панель": "Restarting the panel",
|
||||||
|
"обновлением": "the update",
|
||||||
|
"перезапуском": "the restart",
|
||||||
|
}
|
||||||
|
|
||||||
|
var formats = [][2]string{
|
||||||
|
// --- Ошибки API ---
|
||||||
|
{"запрос без заголовка %s", "request without the %s header"},
|
||||||
|
{"некорректный JSON: %s", "malformed JSON: %s"},
|
||||||
|
{"конфиг сервера недоступен: %s", "the server config is unavailable: %s"},
|
||||||
|
{"настройки песочницы недоступны: %s", "the sandbox settings are unavailable: %s"},
|
||||||
|
|
||||||
|
// --- Профили ---
|
||||||
|
{"профиль %s не найден", "profile %s not found"},
|
||||||
|
{"профиль %s уже существует", "profile %s already exists"},
|
||||||
|
{"профиль-источник %s не найден", "source profile %s not found"},
|
||||||
|
{"идентификатор %s недопустим: разрешены латинские буквы, цифры, дефис и подчёркивание (до 32 символов)",
|
||||||
|
"identifier %s is not allowed: use Latin letters, digits, hyphen and underscore (up to 32 characters)"},
|
||||||
|
{"у профиля %s ещё нет файлов настроек — сначала запустите его хотя бы раз",
|
||||||
|
"profile %s has no config files yet — start it at least once first"},
|
||||||
|
{"скопировать настройки: %s", "copy the settings: %s"},
|
||||||
|
|
||||||
|
// --- Память JVM ---
|
||||||
|
{"объём памяти должен выглядеть как 4g или 8192m, получено %s",
|
||||||
|
"the memory size must look like 4g or 8192m, got %s"},
|
||||||
|
{"не разобран объём памяти %s", "could not parse the memory size %s"},
|
||||||
|
{"начальный размер памяти (%s) больше максимального (%s)",
|
||||||
|
"the initial memory size (%s) is larger than the maximum (%s)"},
|
||||||
|
{"начальный размер кучи (%s) больше максимального (%s)",
|
||||||
|
"the initial heap size (%s) is larger than the maximum (%s)"},
|
||||||
|
|
||||||
|
// --- Бэкапы ---
|
||||||
|
{"недопустимое имя архива: %s", "invalid archive name: %s"},
|
||||||
|
{"небезопасный путь в архиве: %s", "unsafe path inside the archive: %s"},
|
||||||
|
{"абсолютный путь в архиве: %s", "absolute path inside the archive: %s"},
|
||||||
|
{"архив повреждён: %s", "the archive is damaged: %s"},
|
||||||
|
{"страховочный бэкап не создан, восстановление отменено: %s",
|
||||||
|
"the safety backup failed, the restore was cancelled: %s"},
|
||||||
|
|
||||||
|
// --- Steam и моды ---
|
||||||
|
{"Steam недоступен: %s", "Steam is unavailable: %s"},
|
||||||
|
{"Steam ответил %s", "Steam answered %s"},
|
||||||
|
{"ответ Steam не разобран: %s", "the Steam answer could not be parsed: %s"},
|
||||||
|
{"коллекция %s не найдена", "collection %s not found"},
|
||||||
|
{"коллекция %s недоступна — проверьте ссылку и что она открыта всем",
|
||||||
|
"collection %s is unavailable — check the link and that it is public"},
|
||||||
|
{"в коллекции %s нет модов", "collection %s has no mods"},
|
||||||
|
|
||||||
|
// --- Конфиги ---
|
||||||
|
{"параметр %s не найден в SandboxVars", "setting %s not found in SandboxVars"},
|
||||||
|
{"недопустимое значение для %s", "invalid value for %s"},
|
||||||
|
{"не удалось разобрать строку параметра %s", "could not parse the line of setting %s"},
|
||||||
|
{"прочитать конфиг %s: %s", "read the config %s: %s"},
|
||||||
|
{"создать конфиг %s: %s", "create the config %s: %s"},
|
||||||
|
{"обновить конфиг %s: %s", "update the config %s: %s"},
|
||||||
|
{"разобрать конфиг %s: %s", "parse the config %s: %s"},
|
||||||
|
{"прочитать %s: %s", "read %s: %s"},
|
||||||
|
{"записать %s: %s", "write %s: %s"},
|
||||||
|
{"создать %s: %s", "create %s: %s"},
|
||||||
|
{"разобрать %s: %s", "parse %s: %s"},
|
||||||
|
{"запустить %s: %s", "start %s: %s"},
|
||||||
|
{"запись в консоль: %s", "write to the console: %s"},
|
||||||
|
{"не разобрана длительность %s: %s", "could not parse the duration %s: %s"},
|
||||||
|
{"скачать SteamCMD: %s", "download SteamCMD: %s"},
|
||||||
|
|
||||||
|
// --- Имена задач ---
|
||||||
|
{"Резервное копирование мира: %s", "World backup: %s"},
|
||||||
|
{"Плановое резервное копирование: %s", "Scheduled world backup: %s"},
|
||||||
|
{"Восстановление из архива %s", "Restore from the archive %s"},
|
||||||
|
|
||||||
|
// --- Сообщения в консоли панели ---
|
||||||
|
{"Конфиг сервера сохранён (изменений: %s). Изменения вступят в силу после перезапуска.",
|
||||||
|
"Server config saved (changes: %s). They take effect after a restart."},
|
||||||
|
{"Создан профиль %s", "Profile %s created"},
|
||||||
|
{"Настройки профиля %s скопированы в %s", "The settings of profile %s were copied to %s"},
|
||||||
|
{"Активный профиль: %s", "Active profile: %s"},
|
||||||
|
{"Профиль %s удалён из панели. Мир и конфиги остались на диске: %s",
|
||||||
|
"Profile %s removed from the panel. Its world and configs stayed on disk: %s"},
|
||||||
|
{"Запуск профиля %s: %s %s (pid %s)", "Starting profile %s: %s %s (pid %s)"},
|
||||||
|
{"Запускаю: %s", "Running: %s"},
|
||||||
|
{"Сервер остановлен: %s", "Server stopped: %s"},
|
||||||
|
{"Автоперезапуск через %s (можно отменить кнопкой «Остановить»)",
|
||||||
|
"Automatic restart in %s (the Stop button cancels it)"},
|
||||||
|
{"Автоперезапуск не удался: %s", "Automatic restart failed: %s"},
|
||||||
|
{"Сервер падает сразу после запуска %s раза подряд — автоперезапуск остановлен. ",
|
||||||
|
"The server has crashed right after start %s times in a row — automatic restart is off. "},
|
||||||
|
{"Не удалось выставить границы памяти JVM: %s", "Could not set the JVM memory limits: %s"},
|
||||||
|
{"Не удалось выставить объём памяти JVM: %s", "Could not set the JVM memory size: %s"},
|
||||||
|
{"Память JVM: от %s до %s", "JVM memory: from %s to %s"},
|
||||||
|
{"Лимит памяти JVM выставлен: %s", "JVM memory limit set: %s"},
|
||||||
|
{"Не удалось отправить quit: %s", "Could not send quit: %s"},
|
||||||
|
{"SIGTERM не доставлен: %s", "SIGTERM was not delivered: %s"},
|
||||||
|
{"Остановка не удалась: %s", "Stopping failed: %s"},
|
||||||
|
{"Остановить сервер не удалось: %s", "The server could not be stopped: %s"},
|
||||||
|
{"Перезапуск не удался: %s", "Restart failed: %s"},
|
||||||
|
{"Не удалось запустить сервер: %s", "Could not start the server: %s"},
|
||||||
|
{"Останавливаю игровой сервер перед %s", "Stopping the game server before %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, файлов: %s)", "Archive ready: %s (%s, files: %s)"},
|
||||||
|
{"Восстанавливаю %s в %s", "Restoring %s into %s"},
|
||||||
|
{"Восстановлено файлов: %s", "Files restored: %s"},
|
||||||
|
{"Удалён старый архив %s", "Old archive %s deleted"},
|
||||||
|
{"Ротация не удалась: %s", "Rotation failed: %s"},
|
||||||
|
{"Пропускаю %s: %s", "Skipping %s: %s"},
|
||||||
|
{"Не прочитан %s: %s", "Not read %s: %s"},
|
||||||
|
|
||||||
|
// --- SteamCMD ---
|
||||||
|
{"SteamCMD найден: %s", "SteamCMD found: %s"},
|
||||||
|
{"SteamCMD не найден, скачиваю в %s", "SteamCMD not found, downloading into %s"},
|
||||||
|
{"SteamCMD установлен: %s", "SteamCMD installed: %s"},
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
// Package i18n переводит сообщения менеджера на язык веб-панели.
|
||||||
|
//
|
||||||
|
// Исходный язык кода — русский, он же служит ключом перевода: словарь связывает
|
||||||
|
// русскую фразу с английской, а незнакомая фраза остаётся как есть. Благодаря
|
||||||
|
// этому через перевод можно безопасно пропускать и вывод игрового сервера — он
|
||||||
|
// английский и в словаре не встретится.
|
||||||
|
package i18n
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Lang — язык сообщений панели.
|
||||||
|
type Lang string
|
||||||
|
|
||||||
|
const (
|
||||||
|
RU Lang = "ru"
|
||||||
|
EN Lang = "en"
|
||||||
|
// Default — язык, на котором сообщения написаны в коде.
|
||||||
|
Default = RU
|
||||||
|
)
|
||||||
|
|
||||||
|
// CookieName — кука, которой панель сообщает выбранный человеком язык.
|
||||||
|
const CookieName = "pzm_lang"
|
||||||
|
|
||||||
|
// Parse разбирает код языка; для незнакомого возвращает язык по умолчанию.
|
||||||
|
func Parse(value string) (Lang, bool) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
|
case "ru":
|
||||||
|
return RU, true
|
||||||
|
case "en":
|
||||||
|
return EN, true
|
||||||
|
}
|
||||||
|
return Default, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// FromRequest определяет язык ответа. Выбор человека (кука, которую ставит
|
||||||
|
// панель) важнее списка языков браузера: на сервер могут заходить с чужой
|
||||||
|
// машины, а язык панели человек выбирает сам.
|
||||||
|
func FromRequest(r *http.Request) Lang {
|
||||||
|
if r == nil {
|
||||||
|
return Default
|
||||||
|
}
|
||||||
|
if cookie, err := r.Cookie(CookieName); err == nil {
|
||||||
|
if lang, ok := Parse(cookie.Value); ok {
|
||||||
|
return lang
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fromAcceptLanguage(r.Header.Get("Accept-Language"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// fromAcceptLanguage берёт первый понятный язык из заголовка. Веса (q=) не
|
||||||
|
// разбираем: браузеры и так перечисляют языки по убыванию предпочтения.
|
||||||
|
func fromAcceptLanguage(header string) Lang {
|
||||||
|
for _, part := range strings.Split(header, ",") {
|
||||||
|
tag := strings.TrimSpace(part)
|
||||||
|
if i := strings.IndexByte(tag, ';'); i >= 0 {
|
||||||
|
tag = tag[:i]
|
||||||
|
}
|
||||||
|
if i := strings.IndexByte(tag, '-'); i >= 0 {
|
||||||
|
tag = tag[:i]
|
||||||
|
}
|
||||||
|
if lang, ok := Parse(tag); ok {
|
||||||
|
return lang
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Default
|
||||||
|
}
|
||||||
|
|
||||||
|
// T переводит сообщение на указанный язык. Неизвестное сообщение возвращается
|
||||||
|
// без изменений: потерять текст хуже, чем показать его на чужом языке.
|
||||||
|
func T(lang Lang, message string) string {
|
||||||
|
if lang == Default || lang == "" || message == "" {
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
return translate(message, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxDepth ограничивает разбор вложенных ошибок: «прочитать X: создать Y: ...».
|
||||||
|
const maxDepth = 4
|
||||||
|
|
||||||
|
func translate(message string, depth int) string {
|
||||||
|
if text, ok := messages[message]; ok {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
if depth >= maxDepth {
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
for _, p := range patterns {
|
||||||
|
match := p.re.FindStringSubmatch(message)
|
||||||
|
if match == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Подставленные значения переводим тоже: в них попадает вложенная
|
||||||
|
// ошибка целиком, и она может быть знакома словарю.
|
||||||
|
args := make([]any, 0, len(match)-1)
|
||||||
|
for _, group := range match[1:] {
|
||||||
|
args = append(args, translate(group, depth+1))
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(p.en, args...)
|
||||||
|
}
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
|
||||||
|
// pattern — пара форматов «русский → английский». Значения, подставленные в
|
||||||
|
// сообщение при его сборке, вылавливаются группами регулярного выражения.
|
||||||
|
type pattern struct {
|
||||||
|
re *regexp.Regexp
|
||||||
|
en string
|
||||||
|
// weight — длина неизменяемой части. Чем она больше, тем конкретнее
|
||||||
|
// шаблон, и тем раньше его стоит примерить.
|
||||||
|
weight int
|
||||||
|
}
|
||||||
|
|
||||||
|
var patterns []pattern
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
patterns = make([]pattern, 0, len(formats))
|
||||||
|
for _, f := range formats {
|
||||||
|
patterns = append(patterns, compile(f[0], f[1]))
|
||||||
|
}
|
||||||
|
sort.SliceStable(patterns, func(i, j int) bool {
|
||||||
|
return patterns[i].weight > patterns[j].weight
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func compile(ru, en string) pattern {
|
||||||
|
parts := strings.Split(ru, "%s")
|
||||||
|
weight := 0
|
||||||
|
for _, part := range parts {
|
||||||
|
weight += len(part)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body strings.Builder
|
||||||
|
body.WriteString(regexp.QuoteMeta(parts[0]))
|
||||||
|
for i := 1; i < len(parts); i++ {
|
||||||
|
// Последняя группа жадная: в неё попадает вложенная ошибка целиком,
|
||||||
|
// вместе со своими двоеточиями.
|
||||||
|
group := "(.+?)"
|
||||||
|
if i == len(parts)-1 && parts[i] == "" {
|
||||||
|
group = "(.+)"
|
||||||
|
}
|
||||||
|
body.WriteString(group)
|
||||||
|
body.WriteString(regexp.QuoteMeta(parts[i]))
|
||||||
|
}
|
||||||
|
// (?s) — сообщения бывают многострочными.
|
||||||
|
return pattern{re: regexp.MustCompile("(?s)^" + body.String() + "$"), en: en, weight: weight}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package i18n
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTKnownMessage(t *testing.T) {
|
||||||
|
got := T(EN, "сервер не запущен")
|
||||||
|
if got != "the server is not running" {
|
||||||
|
t.Fatalf("T(EN, ...) = %q", got)
|
||||||
|
}
|
||||||
|
if got := T(RU, "сервер не запущен"); got != "сервер не запущен" {
|
||||||
|
t.Fatalf("русский язык не должен трогать сообщение: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вывод игрового сервера проходит через перевод как есть: словарь его не знает.
|
||||||
|
func TestTKeepsUnknown(t *testing.T) {
|
||||||
|
line := "LOG : General f:1 Zombies spawned: 1000"
|
||||||
|
if got := T(EN, line); got != line {
|
||||||
|
t.Fatalf("незнакомая строка изменена: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTFormat(t *testing.T) {
|
||||||
|
got := T(EN, `профиль "modded" не найден`)
|
||||||
|
if got != `profile "modded" not found` {
|
||||||
|
t.Fatalf("T = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вложенная ошибка переводится вместе с обёрткой, а более конкретный шаблон
|
||||||
|
// («прочитать конфиг») должен выиграть у общего («прочитать»).
|
||||||
|
func TestTNestedError(t *testing.T) {
|
||||||
|
msg := "конфиг сервера недоступен: прочитать конфиг /srv/servertest.ini: open: no such file"
|
||||||
|
want := "the server config is unavailable: read the config /srv/servertest.ini: open: no such file"
|
||||||
|
if got := T(EN, msg); got != want {
|
||||||
|
t.Fatalf("T = %q, ожидалось %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTTaskName(t *testing.T) {
|
||||||
|
if got := T(EN, "Резервное копирование мира: modded"); got != "World backup: modded" {
|
||||||
|
t.Fatalf("T = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// В переводе должно быть столько же подстановок, сколько в оригинале, иначе
|
||||||
|
// Sprintf выведет %!s(MISSING) прямо в панель.
|
||||||
|
func TestCatalogPlaceholders(t *testing.T) {
|
||||||
|
for _, f := range formats {
|
||||||
|
ru := strings.Count(f[0], "%s")
|
||||||
|
en := strings.Count(f[1], "%s")
|
||||||
|
if ru == 0 {
|
||||||
|
t.Errorf("шаблон без подстановки: %q", f[0])
|
||||||
|
}
|
||||||
|
if ru != en {
|
||||||
|
t.Errorf("%q: подстановок %d, в переводе %d", f[0], ru, en)
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(strings.ReplaceAll(f[0], "%s", ""), "%") {
|
||||||
|
t.Errorf("в шаблоне остался другой глагол форматирования: %q", f[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for ru, en := range messages {
|
||||||
|
if strings.Contains(ru, "%") || strings.Contains(en, "%") {
|
||||||
|
t.Errorf("сообщение с подстановкой должно лежать в formats: %q", ru)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFromRequest(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
cookie string
|
||||||
|
header string
|
||||||
|
want Lang
|
||||||
|
}{
|
||||||
|
{"по умолчанию", "", "", Default},
|
||||||
|
{"выбор человека", "en", "ru-RU,ru;q=0.9", EN},
|
||||||
|
{"мусор в куке", "klingon", "en-US,en;q=0.9", EN},
|
||||||
|
{"язык браузера", "", "en-GB,en;q=0.9", EN},
|
||||||
|
{"неизвестный язык браузера", "", "de-DE,de;q=0.9", Default},
|
||||||
|
{"первый понятный из списка", "", "de-DE;q=0.9,en;q=0.8", EN},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
r, err := http.NewRequest(http.MethodGet, "/api/status", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if c.cookie != "" {
|
||||||
|
r.AddCookie(&http.Cookie{Name: CookieName, Value: c.cookie})
|
||||||
|
}
|
||||||
|
if c.header != "" {
|
||||||
|
r.Header.Set("Accept-Language", c.header)
|
||||||
|
}
|
||||||
|
if got := FromRequest(r); got != c.want {
|
||||||
|
t.Fatalf("FromRequest = %q, ожидалось %q", got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("список модов принят за пресет песочницы")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,10 @@ type iniLine struct {
|
|||||||
Key string
|
Key string
|
||||||
Value string
|
Value string
|
||||||
Raw string
|
Raw string
|
||||||
|
// Comment — описание параметра: строки комментария прямо перед ним.
|
||||||
|
// Project Zomboid пишет их на языке сервера, так что панели есть что
|
||||||
|
// показать человеку, не выдумывая своих формулировок.
|
||||||
|
Comment string
|
||||||
}
|
}
|
||||||
|
|
||||||
// INI — конфиг PZ в виде плоских key=value. Разбор сохраняет исходный порядок
|
// INI — конфиг PZ в виде плоских key=value. Разбор сохраняет исходный порядок
|
||||||
@@ -23,22 +27,46 @@ type iniLine struct {
|
|||||||
type INI struct {
|
type INI struct {
|
||||||
lines []iniLine
|
lines []iniLine
|
||||||
index map[string]int
|
index map[string]int
|
||||||
|
// crlf — файл был с виндовыми переводами строк. Сохраняем как было: иначе
|
||||||
|
// правка одного параметра переписывает весь файл целиком.
|
||||||
|
crlf bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseINI разбирает содержимое конфига.
|
// ParseINI разбирает содержимое конфига.
|
||||||
func ParseINI(data []byte) *INI {
|
func ParseINI(data []byte) *INI {
|
||||||
ini := &INI{index: make(map[string]int)}
|
ini := &INI{index: make(map[string]int)}
|
||||||
text := strings.ReplaceAll(string(data), "\r\n", "\n")
|
raw := string(data)
|
||||||
|
ini.crlf = strings.Contains(raw, "\r\n")
|
||||||
|
text := strings.ReplaceAll(raw, "\r\n", "\n")
|
||||||
|
|
||||||
|
// Комментарии копим до ближайшего параметра: в конфигах PZ описание стоит
|
||||||
|
// строкой выше. Пустая строка обрывает связь — иначе описание прилипло бы
|
||||||
|
// к следующему блоку.
|
||||||
|
var comment []string
|
||||||
|
|
||||||
for _, raw := range strings.Split(text, "\n") {
|
for _, raw := range strings.Split(text, "\n") {
|
||||||
trimmed := strings.TrimSpace(raw)
|
trimmed := strings.TrimSpace(raw)
|
||||||
key, value, found := strings.Cut(raw, "=")
|
key, value, found := strings.Cut(raw, "=")
|
||||||
if !found || trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
|
||||||
|
if strings.HasPrefix(trimmed, "#") {
|
||||||
|
comment = append(comment, strings.TrimSpace(strings.TrimPrefix(trimmed, "#")))
|
||||||
ini.lines = append(ini.lines, iniLine{Raw: raw})
|
ini.lines = append(ini.lines, iniLine{Raw: raw})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if !found || trimmed == "" {
|
||||||
|
comment = nil
|
||||||
|
ini.lines = append(ini.lines, iniLine{Raw: raw})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
key = strings.TrimSpace(key)
|
key = strings.TrimSpace(key)
|
||||||
ini.index[key] = len(ini.lines)
|
ini.index[key] = len(ini.lines)
|
||||||
ini.lines = append(ini.lines, iniLine{Key: key, Value: strings.TrimSpace(value)})
|
ini.lines = append(ini.lines, iniLine{
|
||||||
|
Key: key,
|
||||||
|
Value: strings.TrimSpace(value),
|
||||||
|
Comment: strings.Join(comment, " "),
|
||||||
|
})
|
||||||
|
comment = nil
|
||||||
}
|
}
|
||||||
return ini
|
return ini
|
||||||
}
|
}
|
||||||
@@ -90,6 +118,17 @@ func (i *INI) Keys() []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Comments возвращает описания параметров: ключ -> текст комментария.
|
||||||
|
func (i *INI) Comments() map[string]string {
|
||||||
|
out := make(map[string]string, len(i.index))
|
||||||
|
for _, l := range i.lines {
|
||||||
|
if l.Key != "" && l.Comment != "" {
|
||||||
|
out[l.Key] = l.Comment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// Map отдаёт все параметры в виде словаря.
|
// Map отдаёт все параметры в виде словаря.
|
||||||
func (i *INI) Map() map[string]string {
|
func (i *INI) Map() map[string]string {
|
||||||
out := make(map[string]string, len(i.index))
|
out := make(map[string]string, len(i.index))
|
||||||
@@ -103,6 +142,11 @@ func (i *INI) Map() map[string]string {
|
|||||||
|
|
||||||
// Render собирает файл обратно в текст.
|
// Render собирает файл обратно в текст.
|
||||||
func (i *INI) Render() []byte {
|
func (i *INI) Render() []byte {
|
||||||
|
newline := "\n"
|
||||||
|
if i.crlf {
|
||||||
|
newline = "\r\n"
|
||||||
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
for n, l := range i.lines {
|
for n, l := range i.lines {
|
||||||
if l.Key == "" {
|
if l.Key == "" {
|
||||||
@@ -113,7 +157,7 @@ func (i *INI) Render() []byte {
|
|||||||
buf.WriteString(l.Value)
|
buf.WriteString(l.Value)
|
||||||
}
|
}
|
||||||
if n < len(i.lines)-1 {
|
if n < len(i.lines)-1 {
|
||||||
buf.WriteByte('\n')
|
buf.WriteString(newline)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return buf.Bytes()
|
return buf.Bytes()
|
||||||
@@ -128,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) {
|
||||||
|
|||||||
@@ -91,7 +91,11 @@ func readModsDir(dir, workshopID string) []InstalledMod {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
modDir := filepath.Join(dir, entry.Name())
|
modDir := filepath.Join(dir, entry.Name())
|
||||||
mod, ok := readModInfo(filepath.Join(modDir, "mod.info"))
|
info, found := modInfoPath(modDir)
|
||||||
|
if !found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mod, ok := readModInfo(info)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -105,6 +109,55 @@ func readModsDir(dir, workshopID string) []InstalledMod {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// modInfoPath ищет mod.info внутри папки мода.
|
||||||
|
//
|
||||||
|
// В Build 41 он лежал в корне, а в Build 42 мод раскладывают по папкам версий:
|
||||||
|
// common — общая часть, 42 и 42.16 — под конкретные сборки игры. Если искать
|
||||||
|
// только в корне, мод из мастерской выглядит нескачанным, его Mod ID остаётся
|
||||||
|
// неизвестным и включить его через панель нельзя.
|
||||||
|
func modInfoPath(modDir string) (string, bool) {
|
||||||
|
if root := filepath.Join(modDir, "mod.info"); fileExists(root) {
|
||||||
|
return root, true
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(modDir)
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
var versions []string
|
||||||
|
common := ""
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !fileExists(filepath.Join(modDir, entry.Name(), "mod.info")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.EqualFold(entry.Name(), "common") {
|
||||||
|
common = entry.Name()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
versions = append(versions, entry.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сначала самая свежая версия: «42.16» важнее «42», а «common» —
|
||||||
|
// последняя, это общая часть на случай, когда версии своего mod.info не
|
||||||
|
// завели. Обратный порядок строк для номеров версий работает и здесь.
|
||||||
|
sort.Sort(sort.Reverse(sort.StringSlice(versions)))
|
||||||
|
if common != "" {
|
||||||
|
versions = append(versions, common)
|
||||||
|
}
|
||||||
|
if len(versions) == 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return filepath.Join(modDir, versions[0], "mod.info"), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileExists(path string) bool {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
return err == nil && !info.IsDir()
|
||||||
|
}
|
||||||
|
|
||||||
// readModInfo разбирает mod.info — файл вида "ключ=значение" с необязательными
|
// readModInfo разбирает mod.info — файл вида "ключ=значение" с необязательными
|
||||||
// комментариями. Нас интересуют id, name и description.
|
// комментариями. Нас интересуют id, name и description.
|
||||||
func readModInfo(path string) (InstalledMod, bool) {
|
func readModInfo(path string) (InstalledMod, bool) {
|
||||||
|
|||||||
@@ -75,3 +75,63 @@ func writeModInfo(t *testing.T, dir, content string) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// В Build 42 mod.info лежит не в корне мода, а в папке версии. Мод из такой
|
||||||
|
// раскладки должен находиться, иначе панель считает его нескачанным.
|
||||||
|
func TestScanInstalledModsBuild42Layout(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
content := filepath.Join(root, "server", "steamapps", "workshop", "content", pzAppID)
|
||||||
|
|
||||||
|
writeModInfo(t, filepath.Join(content, "111", "mods", "Wipes", "common"),
|
||||||
|
"name=Cleaning Wipes\nid=dustinguished_cleaning_wipes\n")
|
||||||
|
// Мод с несколькими версиями: берём самую свежую, а не common.
|
||||||
|
mod := filepath.Join(content, "222", "mods", "NewMusicG90s")
|
||||||
|
writeModInfo(t, filepath.Join(mod, "common"), "name=Mixtape (common)\nid=NewMusicG90s\n")
|
||||||
|
writeModInfo(t, filepath.Join(mod, "42"), "name=Mixtape (42)\nid=NewMusicG90s\n")
|
||||||
|
writeModInfo(t, filepath.Join(mod, "42.16"), "name=Mixtape (42.16)\nid=NewMusicG90s\n")
|
||||||
|
|
||||||
|
mods := ScanInstalledMods([]string{filepath.Join(root, "server")}, "")
|
||||||
|
|
||||||
|
byID := make(map[string]InstalledMod, len(mods))
|
||||||
|
for _, m := range mods {
|
||||||
|
byID[m.ModID] = m
|
||||||
|
}
|
||||||
|
if len(byID) != 2 {
|
||||||
|
t.Fatalf("найдено %d модов, ожидалось 2: %v", len(byID), byID)
|
||||||
|
}
|
||||||
|
if got := byID["dustinguished_cleaning_wipes"]; got.Name != "Cleaning Wipes" || got.WorkshopID != "111" {
|
||||||
|
t.Errorf("мод из common разобран неверно: %+v", got)
|
||||||
|
}
|
||||||
|
if got := byID["NewMusicG90s"]; got.Name != "Mixtape (42.16)" {
|
||||||
|
t.Errorf("взята не самая свежая версия: %+v", got)
|
||||||
|
}
|
||||||
|
// Dir указывает на папку мода, а не на папку версии: так его удобнее найти.
|
||||||
|
if got := byID["NewMusicG90s"]; filepath.Base(got.Dir) != "NewMusicG90s" {
|
||||||
|
t.Errorf("Dir указывает не на папку мода: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Корневой mod.info главнее версий: так раскладывают моды Build 41.
|
||||||
|
func TestModInfoPathPrefersRoot(t *testing.T) {
|
||||||
|
mod := t.TempDir()
|
||||||
|
writeModInfo(t, mod, "name=Root\nid=Root\n")
|
||||||
|
writeModInfo(t, filepath.Join(mod, "42"), "name=Version\nid=Version\n")
|
||||||
|
|
||||||
|
path, ok := modInfoPath(mod)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("mod.info не найден")
|
||||||
|
}
|
||||||
|
if filepath.Dir(path) != mod {
|
||||||
|
t.Errorf("взят mod.info из папки версии: %s", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModInfoPathWithoutModInfo(t *testing.T) {
|
||||||
|
mod := t.TempDir()
|
||||||
|
if err := os.MkdirAll(filepath.Join(mod, "media"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, ok := modInfoPath(mod); ok {
|
||||||
|
t.Error("mod.info найден там, где его нет")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+63
-13
@@ -6,24 +6,71 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// javaLauncherFile — файл, из которого сервер берёт аргументы JVM.
|
// javaLauncherFile — файл, из которого сервер берёт аргументы JVM.
|
||||||
const javaLauncherFile = "ProjectZomboid64.json"
|
const javaLauncherFile = "ProjectZomboid64.json"
|
||||||
|
|
||||||
var xmxRe = regexp.MustCompile(`^-Xm[sx]`)
|
var (
|
||||||
|
heapArgRe = regexp.MustCompile(`^-Xm[sx]`)
|
||||||
|
memoryRe = regexp.MustCompile(`^([0-9]+)([kKmMgG])$`)
|
||||||
|
)
|
||||||
|
|
||||||
// SetJavaMemory прописывает лимит памяти JVM в ProjectZomboid64.json. Именно
|
// ParseMemory переводит запись вида "4g" в байты. Возвращает 0, если строка
|
||||||
// оттуда start-server.sh берёт -Xmx, поэтому менять размер памяти в аргументах
|
// пустая, и ошибку, если формат непонятен.
|
||||||
// запуска бесполезно.
|
func ParseMemory(value string) (int64, error) {
|
||||||
func SetJavaMemory(serverDir, memory string) error {
|
value = strings.TrimSpace(value)
|
||||||
memory = strings.TrimSpace(memory)
|
if value == "" {
|
||||||
if memory == "" {
|
return 0, nil
|
||||||
|
}
|
||||||
|
match := memoryRe.FindStringSubmatch(value)
|
||||||
|
if match == nil {
|
||||||
|
return 0, fmt.Errorf("объём памяти должен выглядеть как 4g или 8192m, получено %q", value)
|
||||||
|
}
|
||||||
|
amount, err := strconv.ParseInt(match[1], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("не разобран объём памяти %q", value)
|
||||||
|
}
|
||||||
|
switch strings.ToLower(match[2]) {
|
||||||
|
case "k":
|
||||||
|
return amount * 1024, nil
|
||||||
|
case "m":
|
||||||
|
return amount * 1024 * 1024, nil
|
||||||
|
default:
|
||||||
|
return amount * 1024 * 1024 * 1024, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetJavaMemory прописывает границы кучи JVM в ProjectZomboid64.json. Именно
|
||||||
|
// оттуда start-server.sh берёт -Xmx и -Xms, поэтому менять размер памяти в
|
||||||
|
// аргументах запуска бесполезно.
|
||||||
|
//
|
||||||
|
// maxMemory задаёт -Xmx (верхнюю границу), minMemory — -Xms. Пустой minMemory
|
||||||
|
// означает, что -Xms не выставляется вовсе: JVM тогда занимает память по мере
|
||||||
|
// надобности. Равные значения удобны тем, что куча не растёт на ходу, но JVM
|
||||||
|
// обязана занять весь объём при старте — на машине с небольшой ОЗУ сервер
|
||||||
|
// просто не поднимется.
|
||||||
|
func SetJavaMemory(serverDir, maxMemory, minMemory string) error {
|
||||||
|
maxMemory = strings.TrimSpace(maxMemory)
|
||||||
|
minMemory = strings.TrimSpace(minMemory)
|
||||||
|
if maxMemory == "" && minMemory == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if !regexp.MustCompile(`^[0-9]+[kKmMgG]$`).MatchString(memory) {
|
|
||||||
return fmt.Errorf("объём памяти должен выглядеть как 4g или 8192m, получено %q", memory)
|
maxBytes, err := ParseMemory(maxMemory)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
minBytes, err := ParseMemory(minMemory)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// JVM с -Xms больше -Xmx не стартует вообще, поэтому ловим это здесь, а не
|
||||||
|
// в логе упавшего сервера.
|
||||||
|
if maxBytes > 0 && minBytes > maxBytes {
|
||||||
|
return fmt.Errorf("начальный размер кучи (%s) больше максимального (%s)", minMemory, maxMemory)
|
||||||
}
|
}
|
||||||
|
|
||||||
path := filepath.Join(serverDir, javaLauncherFile)
|
path := filepath.Join(serverDir, javaLauncherFile)
|
||||||
@@ -41,14 +88,17 @@ func SetJavaMemory(serverDir, memory string) error {
|
|||||||
args, _ := doc["vmArgs"].([]any)
|
args, _ := doc["vmArgs"].([]any)
|
||||||
updated := make([]any, 0, len(args)+2)
|
updated := make([]any, 0, len(args)+2)
|
||||||
for _, a := range args {
|
for _, a := range args {
|
||||||
if str, ok := a.(string); ok && xmxRe.MatchString(str) {
|
if str, ok := a.(string); ok && heapArgRe.MatchString(str) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
updated = append(updated, a)
|
updated = append(updated, a)
|
||||||
}
|
}
|
||||||
// -Xms равным -Xmx: JVM сразу забирает память и не тратит время на рост
|
if minMemory != "" {
|
||||||
// кучи в момент, когда на сервере много игроков.
|
updated = append(updated, "-Xms"+minMemory)
|
||||||
updated = append(updated, "-Xms"+memory, "-Xmx"+memory)
|
}
|
||||||
|
if maxMemory != "" {
|
||||||
|
updated = append(updated, "-Xmx"+maxMemory)
|
||||||
|
}
|
||||||
doc["vmArgs"] = updated
|
doc["vmArgs"] = updated
|
||||||
|
|
||||||
out, err := json.MarshalIndent(doc, "", " ")
|
out, err := json.MarshalIndent(doc, "", " ")
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package pzconfig
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSetJavaMemoryKeepsOnlyXmx: -Xms равным -Xmx заставляет JVM занять весь
|
||||||
|
// объём при старте, и на машине с небольшой ОЗУ сервер падает с «Failed to
|
||||||
|
// allocate initial Java heap». Ставим только верхнюю границу.
|
||||||
|
func TestSetJavaMemoryKeepsOnlyXmx(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeLauncher(t, dir, map[string]any{
|
||||||
|
"mainClass": "zombie/network/GameServer",
|
||||||
|
"vmArgs": []any{
|
||||||
|
"-Djava.awt.headless=true",
|
||||||
|
"-XX:+UseZGC",
|
||||||
|
// Значения от прошлой установки должны замениться.
|
||||||
|
"-Xms8g",
|
||||||
|
"-Xmx8g",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := SetJavaMemory(dir, "3g", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
args := launcherArgs(t, dir)
|
||||||
|
var xms, xmx int
|
||||||
|
for _, a := range args {
|
||||||
|
switch {
|
||||||
|
case len(a) > 4 && a[:4] == "-Xms":
|
||||||
|
xms++
|
||||||
|
t.Errorf("в аргументах остался %s — JVM займёт всю память при старте", a)
|
||||||
|
case a == "-Xmx3g":
|
||||||
|
xmx++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if xms != 0 {
|
||||||
|
t.Errorf("-Xms не должен добавляться, найдено вхождений: %d", xms)
|
||||||
|
}
|
||||||
|
if xmx != 1 {
|
||||||
|
t.Errorf("-Xmx3g встречается %d раз, ожидалось 1: %v", xmx, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Чужие аргументы должны остаться нетронутыми.
|
||||||
|
if !contains(args, "-XX:+UseZGC") || !contains(args, "-Djava.awt.headless=true") {
|
||||||
|
t.Errorf("потеряны исходные аргументы: %v", args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetJavaMemoryValidates(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeLauncher(t, dir, map[string]any{"vmArgs": []any{}})
|
||||||
|
|
||||||
|
for _, bad := range []string{"4", "4gb", "много", "-4g", "4g; rm -rf /"} {
|
||||||
|
if err := SetJavaMemory(dir, bad, ""); err == nil {
|
||||||
|
t.Errorf("значение %q принято, ожидалась ошибка", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Пустые значения — «не трогать», это не ошибка.
|
||||||
|
if err := SetJavaMemory(dir, "", ""); err != nil {
|
||||||
|
t.Errorf("пустое значение вернуло ошибку: %v", err)
|
||||||
|
}
|
||||||
|
for _, good := range []string{"512m", "4g", "8G", "16384M"} {
|
||||||
|
if err := SetJavaMemory(dir, good, ""); err != nil {
|
||||||
|
t.Errorf("значение %q отклонено: %v", good, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// JVM с -Xms больше -Xmx не стартует, поэтому ловим это заранее.
|
||||||
|
if err := SetJavaMemory(dir, "2g", "4g"); err == nil {
|
||||||
|
t.Error("начальный размер больше максимального принят, ожидалась ошибка")
|
||||||
|
}
|
||||||
|
if err := SetJavaMemory(dir, "4g", "2g"); err != nil {
|
||||||
|
t.Errorf("корректная пара 2g/4g отклонена: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeLauncher(t *testing.T, dir string, doc map[string]any) {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := json.MarshalIndent(doc, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, javaLauncherFile), raw, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func launcherArgs(t *testing.T, dir string) []string {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := os.ReadFile(filepath.Join(dir, javaLauncherFile))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var doc struct {
|
||||||
|
VMArgs []string `json:"vmArgs"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return doc.VMArgs
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(list []string, want string) bool {
|
||||||
|
for _, item := range list {
|
||||||
|
if item == want {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetJavaMemoryWritesXmsWhenAsked: минимальный размер кучи задаётся
|
||||||
|
// отдельно и попадает в файл, только если его действительно указали.
|
||||||
|
func TestSetJavaMemoryWritesXmsWhenAsked(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeLauncher(t, dir, map[string]any{"vmArgs": []any{"-XX:+UseZGC"}})
|
||||||
|
|
||||||
|
if err := SetJavaMemory(dir, "6g", "2g"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
args := launcherArgs(t, dir)
|
||||||
|
if !contains(args, "-Xms2g") || !contains(args, "-Xmx6g") {
|
||||||
|
t.Fatalf("границы кучи не выставлены: %v", args)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Убрали минимум — из файла он тоже должен уйти.
|
||||||
|
if err := SetJavaMemory(dir, "6g", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
args = launcherArgs(t, dir)
|
||||||
|
for _, a := range args {
|
||||||
|
if len(a) > 4 && a[:4] == "-Xms" {
|
||||||
|
t.Errorf("после очистки минимума остался %s: %v", a, args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !contains(args, "-Xmx6g") {
|
||||||
|
t.Errorf("максимум потерян: %v", args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMemory(t *testing.T) {
|
||||||
|
cases := map[string]int64{
|
||||||
|
"": 0,
|
||||||
|
"512m": 512 * 1024 * 1024,
|
||||||
|
"4g": 4 * 1024 * 1024 * 1024,
|
||||||
|
"8G": 8 * 1024 * 1024 * 1024,
|
||||||
|
"1024k": 1024 * 1024,
|
||||||
|
}
|
||||||
|
for input, want := range cases {
|
||||||
|
got, err := ParseMemory(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("ParseMemory(%q): %v", input, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("ParseMemory(%q) = %d, ожидалось %d", input, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := ParseMemory("4гб"); err == nil {
|
||||||
|
t.Error("значение с кириллицей принято")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -154,3 +154,34 @@ func TestSandboxEntriesFollowFileOrder(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestKeepsWindowsLineEndings: конфиги, созданные игрой на Windows, приходят с
|
||||||
|
// CRLF. Если при сохранении переписать их в LF, изменится каждая строка файла —
|
||||||
|
// правка одного параметра превратится в полную перезапись.
|
||||||
|
func TestKeepsWindowsLineEndings(t *testing.T) {
|
||||||
|
ini := ParseINI([]byte("# Комментарий\r\nMaxPlayers=32\r\nPVP=true\r\n"))
|
||||||
|
ini.Set("MaxPlayers", "64")
|
||||||
|
|
||||||
|
got := string(ini.Render())
|
||||||
|
if strings.Count(got, "\r\n") != 3 {
|
||||||
|
t.Errorf("переводы строк не сохранены:\n%q", got)
|
||||||
|
}
|
||||||
|
if strings.Contains(strings.ReplaceAll(got, "\r\n", ""), "\n") {
|
||||||
|
t.Errorf("в файле появились одиночные LF:\n%q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Файл с LF таким и остаётся.
|
||||||
|
unix := ParseINI([]byte("MaxPlayers=32\nPVP=true"))
|
||||||
|
unix.Set("PVP", "false")
|
||||||
|
if strings.Contains(string(unix.Render()), "\r") {
|
||||||
|
t.Error("в файл с LF добавились CR")
|
||||||
|
}
|
||||||
|
|
||||||
|
sandbox := ParseSandbox([]byte("SandboxVars = {\r\n Zombies = 3,\r\n}\r\n"))
|
||||||
|
if err := sandbox.Set("Zombies", "1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Count(string(sandbox.Render()), "\r\n") != 3 {
|
||||||
|
t.Errorf("песочница потеряла CRLF:\n%q", string(sandbox.Render()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import (
|
|||||||
type SandboxEntry struct {
|
type SandboxEntry struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Value string `json:"value"`
|
Value string `json:"value"`
|
||||||
|
// Comment — строки "--" перед параметром: игра расписывает там смысл
|
||||||
|
// значения и расшифровку вариантов.
|
||||||
|
Comment string `json:"comment,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sandbox — разбор <server>_SandboxVars.lua. Файл генерируется игрой и имеет
|
// Sandbox — разбор <server>_SandboxVars.lua. Файл генерируется игрой и имеет
|
||||||
@@ -24,6 +27,10 @@ type Sandbox struct {
|
|||||||
lines []string
|
lines []string
|
||||||
// index сопоставляет путь параметра с номером строки.
|
// index сопоставляет путь параметра с номером строки.
|
||||||
index map[string]int
|
index map[string]int
|
||||||
|
// comments хранит описание параметра по его пути.
|
||||||
|
comments map[string]string
|
||||||
|
// crlf — файл был с виндовыми переводами строк; сохраняем как было.
|
||||||
|
crlf bool
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -33,14 +40,24 @@ var (
|
|||||||
|
|
||||||
// ParseSandbox разбирает содержимое SandboxVars.lua.
|
// ParseSandbox разбирает содержимое SandboxVars.lua.
|
||||||
func ParseSandbox(data []byte) *Sandbox {
|
func ParseSandbox(data []byte) *Sandbox {
|
||||||
s := &Sandbox{index: make(map[string]int)}
|
s := &Sandbox{index: make(map[string]int), comments: make(map[string]string)}
|
||||||
text := strings.ReplaceAll(string(data), "\r\n", "\n")
|
raw := string(data)
|
||||||
|
s.crlf = strings.Contains(raw, "\r\n")
|
||||||
|
text := strings.ReplaceAll(raw, "\r\n", "\n")
|
||||||
s.lines = strings.Split(text, "\n")
|
s.lines = strings.Split(text, "\n")
|
||||||
|
|
||||||
var stack []string
|
var stack []string
|
||||||
|
// Комментарии копим до ближайшего параметра, как и в .ini.
|
||||||
|
var comment []string
|
||||||
|
|
||||||
for n, line := range s.lines {
|
for n, line := range s.lines {
|
||||||
trimmed := strings.TrimSpace(line)
|
trimmed := strings.TrimSpace(line)
|
||||||
if trimmed == "" || strings.HasPrefix(trimmed, "--") {
|
if strings.HasPrefix(trimmed, "--") {
|
||||||
|
comment = append(comment, strings.TrimSpace(strings.TrimPrefix(trimmed, "--")))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if trimmed == "" {
|
||||||
|
comment = nil
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if sandboxCloseRe.MatchString(line) {
|
if sandboxCloseRe.MatchString(line) {
|
||||||
@@ -62,6 +79,7 @@ func ParseSandbox(data []byte) *Sandbox {
|
|||||||
} else {
|
} else {
|
||||||
stack = stack[:0]
|
stack = stack[:0]
|
||||||
}
|
}
|
||||||
|
comment = nil
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
path := name
|
path := name
|
||||||
@@ -69,6 +87,10 @@ func ParseSandbox(data []byte) *Sandbox {
|
|||||||
path = strings.Join(stack, ".") + "." + name
|
path = strings.Join(stack, ".") + "." + name
|
||||||
}
|
}
|
||||||
s.index[path] = n
|
s.index[path] = n
|
||||||
|
if len(comment) > 0 {
|
||||||
|
s.comments[path] = strings.Join(comment, " ")
|
||||||
|
}
|
||||||
|
comment = nil
|
||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
@@ -95,7 +117,11 @@ func (s *Sandbox) Entries() []SandboxEntry {
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, SandboxEntry{Path: path, Value: s.valueAt(n)})
|
out = append(out, SandboxEntry{
|
||||||
|
Path: path,
|
||||||
|
Value: s.valueAt(n),
|
||||||
|
Comment: s.comments[path],
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -139,11 +165,16 @@ func (s *Sandbox) Set(path, value string) error {
|
|||||||
|
|
||||||
// Render собирает файл обратно.
|
// Render собирает файл обратно.
|
||||||
func (s *Sandbox) Render() []byte {
|
func (s *Sandbox) Render() []byte {
|
||||||
|
newline := "\n"
|
||||||
|
if s.crlf {
|
||||||
|
newline = "\r\n"
|
||||||
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
for n, line := range s.lines {
|
for n, line := range s.lines {
|
||||||
buf.WriteString(line)
|
buf.WriteString(line)
|
||||||
if n < len(s.lines)-1 {
|
if n < len(s.lines)-1 {
|
||||||
buf.WriteByte('\n')
|
buf.WriteString(newline)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return buf.Bytes()
|
return buf.Bytes()
|
||||||
|
|||||||
@@ -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) {
|
||||||
// Чистим здесь, а не у каждого источника: через буфер проходят и вывод
|
// Чистим здесь, а не у каждого источника: через буфер проходят и вывод
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ type State string
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
StateStopped State = "stopped"
|
StateStopped State = "stopped"
|
||||||
|
// StateRestarting — сервер упал и ждёт автоподъёма. Отдельное состояние
|
||||||
|
// нужно, чтобы это ожидание можно было отменить кнопкой.
|
||||||
|
StateRestarting State = "restarting"
|
||||||
StateStarting State = "starting"
|
StateStarting State = "starting"
|
||||||
StateRunning State = "running"
|
StateRunning State = "running"
|
||||||
StateStopping State = "stopping"
|
StateStopping State = "stopping"
|
||||||
@@ -39,9 +42,20 @@ var (
|
|||||||
// playersPollInterval — как часто менеджер спрашивает у сервера список игроков.
|
// playersPollInterval — как часто менеджер спрашивает у сервера список игроков.
|
||||||
const playersPollInterval = 30 * time.Second
|
const playersPollInterval = 30 * time.Second
|
||||||
|
|
||||||
|
// Сколько ждать строку о готовности — свойство профиля: сборка на сотни модов
|
||||||
|
// грузится в разы дольше ванильной. См. config.Profile.StartupWait.
|
||||||
|
|
||||||
// restartDelay — пауза перед автоподъёмом упавшего сервера.
|
// restartDelay — пауза перед автоподъёмом упавшего сервера.
|
||||||
const restartDelay = 10 * time.Second
|
const restartDelay = 10 * time.Second
|
||||||
|
|
||||||
|
// Сервер, падающий сразу после старта, автоперезапуском не лечится: обычно
|
||||||
|
// это нехватка памяти или испорченный конфиг. После нескольких таких падений
|
||||||
|
// подряд перестаём поднимать его сам, чтобы не крутить бесконечный цикл.
|
||||||
|
const (
|
||||||
|
quickCrashWindow = time.Minute
|
||||||
|
maxQuickCrashes = 3
|
||||||
|
)
|
||||||
|
|
||||||
// Status — снимок состояния для веб-панели.
|
// Status — снимок состояния для веб-панели.
|
||||||
type Status struct {
|
type Status struct {
|
||||||
State State `json:"state"`
|
State State `json:"state"`
|
||||||
@@ -56,6 +70,8 @@ type Status struct {
|
|||||||
PlayersAt int64 `json:"players_at"`
|
PlayersAt int64 `json:"players_at"`
|
||||||
LastExit string `json:"last_exit"`
|
LastExit string `json:"last_exit"`
|
||||||
AutoRestart bool `json:"auto_restart"`
|
AutoRestart bool `json:"auto_restart"`
|
||||||
|
// RestartInSec — сколько секунд осталось до автоподъёма (0, если не ждём).
|
||||||
|
RestartInSec int64 `json:"restart_in_sec"`
|
||||||
Proc sysinfo.ProcStats `json:"proc"`
|
Proc sysinfo.ProcStats `json:"proc"`
|
||||||
Host sysinfo.HostStats `json:"host"`
|
Host sysinfo.HostStats `json:"host"`
|
||||||
}
|
}
|
||||||
@@ -89,6 +105,13 @@ type Supervisor struct {
|
|||||||
captured []string
|
captured []string
|
||||||
autoQuery bool
|
autoQuery bool
|
||||||
|
|
||||||
|
// restartCancel закрывается, чтобы отменить ожидание автоперезапуска;
|
||||||
|
// restartAt — когда оно закончится.
|
||||||
|
restartCancel chan struct{}
|
||||||
|
restartAt time.Time
|
||||||
|
// crashStreak — сколько раз подряд сервер упал сразу после запуска.
|
||||||
|
crashStreak int
|
||||||
|
|
||||||
sampler *sysinfo.Sampler
|
sampler *sysinfo.Sampler
|
||||||
stopOnce sync.Once
|
stopOnce sync.Once
|
||||||
quit chan struct{}
|
quit chan struct{}
|
||||||
@@ -132,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) }
|
||||||
|
|
||||||
@@ -165,9 +191,14 @@ func (s *Supervisor) Status() Status {
|
|||||||
if s.cmd != nil && s.cmd.Process != nil {
|
if s.cmd != nil && s.cmd.Process != nil {
|
||||||
st.PID = s.cmd.Process.Pid
|
st.PID = s.cmd.Process.Pid
|
||||||
}
|
}
|
||||||
if !s.startedAt.IsZero() && s.state != StateStopped {
|
if !s.startedAt.IsZero() && s.state != StateStopped && s.state != StateRestarting {
|
||||||
st.UptimeSec = int64(time.Since(s.startedAt).Seconds())
|
st.UptimeSec = int64(time.Since(s.startedAt).Seconds())
|
||||||
}
|
}
|
||||||
|
if s.state == StateRestarting && !s.restartAt.IsZero() {
|
||||||
|
if left := time.Until(s.restartAt).Seconds(); left > 0 {
|
||||||
|
st.RestartInSec = int64(left + 0.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
st.Installed = s.Installed()
|
st.Installed = s.Installed()
|
||||||
@@ -185,9 +216,28 @@ func (s *Supervisor) Status() Status {
|
|||||||
func (s *Supervisor) Start() error {
|
func (s *Supervisor) Start() error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
// Запуск руками — знак, что причину падений устранили.
|
||||||
|
s.crashStreak = 0
|
||||||
|
// Если ждали автоподъёма, поднимаем немедленно.
|
||||||
|
s.cancelRestartLocked()
|
||||||
return s.startLocked()
|
return s.startLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cancelRestartLocked снимает запланированный автоперезапуск. Вызывается под
|
||||||
|
// удержанным s.mu.
|
||||||
|
func (s *Supervisor) cancelRestartLocked() {
|
||||||
|
if s.restartCancel == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
close(s.restartCancel)
|
||||||
|
s.restartCancel = nil
|
||||||
|
s.restartAt = time.Time{}
|
||||||
|
if s.state == StateRestarting {
|
||||||
|
s.state = StateStopped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Supervisor) startLocked() error {
|
func (s *Supervisor) startLocked() error {
|
||||||
if s.state != StateStopped {
|
if s.state != StateStopped {
|
||||||
return ErrAlreadyRunning
|
return ErrAlreadyRunning
|
||||||
@@ -204,8 +254,8 @@ func (s *Supervisor) startLocked() error {
|
|||||||
profile := s.cfg.ActiveProfile()
|
profile := s.cfg.ActiveProfile()
|
||||||
// Лимит памяти живёт в файле запуска, общем для всех профилей, поэтому
|
// Лимит памяти живёт в файле запуска, общем для всех профилей, поэтому
|
||||||
// выставляем его на каждом старте — иначе профиль унаследует чужой -Xmx.
|
// выставляем его на каждом старте — иначе профиль унаследует чужой -Xmx.
|
||||||
if err := pzconfig.SetJavaMemory(s.cfg.ServerDir, profile.JavaMemory); err != nil {
|
if err := pzconfig.SetJavaMemory(s.cfg.ServerDir, profile.JavaMemory, profile.JavaMemoryMin); err != nil {
|
||||||
s.AppendLog("manager", "Не удалось выставить лимит памяти JVM: "+err.Error())
|
s.AppendLog("manager", "Не удалось выставить границы памяти JVM: "+err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
args := []string{"-servername", profile.ID}
|
args := []string{"-servername", profile.ID}
|
||||||
@@ -311,10 +361,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 дожидается завершения процесса и решает, поднимать ли его заново.
|
||||||
@@ -322,6 +375,8 @@ func (s *Supervisor) wait(cmd *exec.Cmd, exited chan struct{}) {
|
|||||||
err := cmd.Wait()
|
err := cmd.Wait()
|
||||||
close(exited)
|
close(exited)
|
||||||
|
|
||||||
|
lifetime := time.Since(s.startedAt)
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
wasIntentional := s.intentionalStop
|
wasIntentional := s.intentionalStop
|
||||||
s.state = StateStopped
|
s.state = StateStopped
|
||||||
@@ -336,6 +391,15 @@ func (s *Supervisor) wait(cmd *exec.Cmd, exited chan struct{}) {
|
|||||||
s.lastExit = "штатное завершение"
|
s.lastExit = "штатное завершение"
|
||||||
}
|
}
|
||||||
reason := s.lastExit
|
reason := s.lastExit
|
||||||
|
|
||||||
|
// Падение сразу после запуска — признак того, что перезапуск не поможет:
|
||||||
|
// не хватило памяти, испорчен конфиг, нет файлов сервера.
|
||||||
|
if !wasIntentional && lifetime < quickCrashWindow {
|
||||||
|
s.crashStreak++
|
||||||
|
} else {
|
||||||
|
s.crashStreak = 0
|
||||||
|
}
|
||||||
|
streak := s.crashStreak
|
||||||
autoRestart := s.cfg.AutoRestart && !wasIntentional
|
autoRestart := s.cfg.AutoRestart && !wasIntentional
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
@@ -343,14 +407,38 @@ func (s *Supervisor) wait(cmd *exec.Cmd, exited chan struct{}) {
|
|||||||
if !autoRestart {
|
if !autoRestart {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if streak >= maxQuickCrashes {
|
||||||
|
s.AppendLog("manager", fmt.Sprintf(
|
||||||
|
"Сервер падает сразу после запуска %d раза подряд — автоперезапуск остановлен. "+
|
||||||
|
"Посмотрите ошибку выше и запустите сервер вручную.", streak))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
cancel := make(chan struct{})
|
||||||
|
s.restartCancel = cancel
|
||||||
|
s.restartAt = time.Now().Add(restartDelay)
|
||||||
|
s.state = StateRestarting
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
s.AppendLog("manager", fmt.Sprintf("Автоперезапуск через %s (можно отменить кнопкой «Остановить»)", restartDelay))
|
||||||
|
|
||||||
s.AppendLog("manager", fmt.Sprintf("Автоперезапуск через %s", restartDelay))
|
|
||||||
select {
|
select {
|
||||||
case <-time.After(restartDelay):
|
case <-time.After(restartDelay):
|
||||||
|
case <-cancel:
|
||||||
|
// Состояние уже переставил тот, кто отменил ожидание.
|
||||||
|
s.AppendLog("manager", "Автоперезапуск отменён")
|
||||||
|
return
|
||||||
case <-s.quit:
|
case <-s.quit:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
|
s.restartCancel = nil
|
||||||
|
s.restartAt = time.Time{}
|
||||||
|
if s.state == StateRestarting {
|
||||||
|
s.state = StateStopped
|
||||||
|
}
|
||||||
err = s.startLocked()
|
err = s.startLocked()
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
if err != nil && !errors.Is(err, ErrAlreadyRunning) {
|
if err != nil && !errors.Is(err, ErrAlreadyRunning) {
|
||||||
@@ -394,6 +482,12 @@ func (s *Supervisor) sendQuiet(command string) error {
|
|||||||
// мир), затем SIGTERM группе процессов и в крайнем случае SIGKILL.
|
// мир), затем SIGTERM группе процессов и в крайнем случае SIGKILL.
|
||||||
func (s *Supervisor) Stop() error {
|
func (s *Supervisor) Stop() error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
|
// Ждём автоподъёма — «остановить» здесь значит «не поднимать».
|
||||||
|
if s.state == StateRestarting {
|
||||||
|
s.cancelRestartLocked()
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if s.state == StateStopped || s.cmd == nil {
|
if s.state == StateStopped || s.cmd == nil {
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
return ErrNotRunning
|
return ErrNotRunning
|
||||||
@@ -457,6 +551,27 @@ func (s *Supervisor) WaitStopped(timeout time.Duration) bool {
|
|||||||
return s.State() == StateStopped
|
return s.State() == StateStopped
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkStartupGrace страхует от сервера, который так и не напечатал строку о
|
||||||
|
// готовности: со своими модами и своей версией он может назвать её иначе, а
|
||||||
|
// вечное «Запускается» лишило бы панель опроса игроков.
|
||||||
|
func (s *Supervisor) checkStartupGrace() {
|
||||||
|
s.mu.Lock()
|
||||||
|
// Ждём столько, сколько задано у профиля, с которым сервер запускался:
|
||||||
|
// активный профиль могли переключить, а мир грузится по старому.
|
||||||
|
wait := s.profile.StartupWait()
|
||||||
|
overdue := s.state == StateStarting && !s.startedAt.IsZero() &&
|
||||||
|
time.Since(s.startedAt) > wait
|
||||||
|
if overdue {
|
||||||
|
s.state = StateRunning
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if overdue {
|
||||||
|
s.AppendLog("manager", fmt.Sprintf(
|
||||||
|
"Сервер не сообщил о готовности за %s — считаю его запущенным", wait))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// pollPlayers периодически спрашивает у работающего сервера, кто онлайн.
|
// pollPlayers периодически спрашивает у работающего сервера, кто онлайн.
|
||||||
func (s *Supervisor) pollPlayers() {
|
func (s *Supervisor) pollPlayers() {
|
||||||
ticker := time.NewTicker(playersPollInterval)
|
ticker := time.NewTicker(playersPollInterval)
|
||||||
@@ -466,6 +581,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+69
-14
@@ -16,6 +16,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -27,6 +28,14 @@ const adminCommand = "pzmanager-admin"
|
|||||||
// ErrNoAdmin возвращается, когда обновление из панели недоступно.
|
// ErrNoAdmin возвращается, когда обновление из панели недоступно.
|
||||||
var ErrNoAdmin = errors.New("обновление из панели недоступно: нет pzmanager-admin или правила sudo")
|
var ErrNoAdmin = errors.New("обновление из панели недоступно: нет pzmanager-admin или правила sudo")
|
||||||
|
|
||||||
|
// ReleaseNote — одна версия в списке изменений.
|
||||||
|
type ReleaseNote struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
Published int64 `json:"published"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
// Info — то, что панель показывает в разделе обновления.
|
// Info — то, что панель показывает в разделе обновления.
|
||||||
type Info struct {
|
type Info struct {
|
||||||
Current string `json:"current"`
|
Current string `json:"current"`
|
||||||
@@ -40,6 +49,10 @@ type Info struct {
|
|||||||
CanApply bool `json:"can_apply"`
|
CanApply bool `json:"can_apply"`
|
||||||
// ManualCommand — что выполнить в терминале, если сама не может.
|
// ManualCommand — что выполнить в терминале, если сама не может.
|
||||||
ManualCommand string `json:"manual_command"`
|
ManualCommand string `json:"manual_command"`
|
||||||
|
// Changelog — все версии между установленной и последней, свежие первыми.
|
||||||
|
// Одного описания последнего релиза мало: пропустив пару версий, человек
|
||||||
|
// не увидит, что менялось в промежутке.
|
||||||
|
Changelog []ReleaseNote `json:"changelog"`
|
||||||
// Error — почему не удалось узнать последнюю версию.
|
// Error — почему не удалось узнать последнюю версию.
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -75,12 +88,14 @@ func (u *Updater) apiBase() (string, error) {
|
|||||||
return fmt.Sprintf("%s://%s/api/v1/repos/%s", scheme, host, path), nil
|
return fmt.Sprintf("%s://%s/api/v1/repos/%s", scheme, host, path), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check спрашивает у Gitea последний релиз и сравнивает его с текущей версией.
|
// Check спрашивает у Gitea список релизов и оставляет те, что новее
|
||||||
|
// установленной версии.
|
||||||
func (u *Updater) Check(ctx context.Context) Info {
|
func (u *Updater) Check(ctx context.Context) Info {
|
||||||
info := Info{
|
info := Info{
|
||||||
Current: u.Version,
|
Current: u.Version,
|
||||||
CanApply: u.CanApply(ctx),
|
CanApply: u.CanApply(ctx),
|
||||||
ManualCommand: u.manualCommand(),
|
ManualCommand: u.manualCommand(),
|
||||||
|
Changelog: []ReleaseNote{},
|
||||||
}
|
}
|
||||||
|
|
||||||
api, err := u.apiBase()
|
api, err := u.apiBase()
|
||||||
@@ -89,7 +104,10 @@ func (u *Updater) Check(ctx context.Context) Info {
|
|||||||
return info
|
return info
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, api+"/releases/latest", nil)
|
// limit с запасом: показываем изменения за все пропущенные версии, но не
|
||||||
|
// тянем историю репозитория целиком.
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
|
||||||
|
api+"/releases?limit=30&draft=false", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
info.Error = err.Error()
|
info.Error = err.Error()
|
||||||
return info
|
return info
|
||||||
@@ -101,36 +119,73 @@ func (u *Updater) Check(ctx context.Context) Info {
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode == http.StatusNotFound {
|
|
||||||
info.Error = "в репозитории ещё нет релизов"
|
|
||||||
return info
|
|
||||||
}
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
info.Error = "репозиторий ответил " + resp.Status
|
info.Error = "репозиторий ответил " + resp.Status
|
||||||
return info
|
return info
|
||||||
}
|
}
|
||||||
|
|
||||||
var release struct {
|
var releases []struct {
|
||||||
TagName string `json:"tag_name"`
|
TagName string `json:"tag_name"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
HTMLURL string `json:"html_url"`
|
HTMLURL string `json:"html_url"`
|
||||||
PublishedAt time.Time `json:"published_at"`
|
PublishedAt time.Time `json:"published_at"`
|
||||||
|
Draft bool `json:"draft"`
|
||||||
|
Prerelease bool `json:"prerelease"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
|
||||||
info.Error = "ответ репозитория не разобран: " + err.Error()
|
info.Error = "ответ репозитория не разобран: " + err.Error()
|
||||||
return info
|
return info
|
||||||
}
|
}
|
||||||
|
|
||||||
info.Latest = release.TagName
|
notes := make([]ReleaseNote, 0, len(releases))
|
||||||
info.Notes = release.Body
|
for _, r := range releases {
|
||||||
info.URL = release.HTMLURL
|
if r.Draft || r.Prerelease || r.TagName == "" {
|
||||||
if !release.PublishedAt.IsZero() {
|
continue
|
||||||
info.Published = release.PublishedAt.Unix()
|
|
||||||
}
|
}
|
||||||
info.Available = IsNewer(u.Version, release.TagName)
|
note := ReleaseNote{Version: r.TagName, Notes: r.Body, URL: r.HTMLURL}
|
||||||
|
if !r.PublishedAt.IsZero() {
|
||||||
|
note.Published = r.PublishedAt.Unix()
|
||||||
|
}
|
||||||
|
notes = append(notes, note)
|
||||||
|
}
|
||||||
|
if len(notes) == 0 {
|
||||||
|
info.Error = "в репозитории ещё нет релизов"
|
||||||
return info
|
return info
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Gitea отдаёт релизы свежими вперёд, но полагаться на это не станем.
|
||||||
|
sort.Slice(notes, func(i, j int) bool {
|
||||||
|
if IsNewer(notes[j].Version, notes[i].Version) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if IsNewer(notes[i].Version, notes[j].Version) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return notes[i].Published > notes[j].Published
|
||||||
|
})
|
||||||
|
|
||||||
|
latest := notes[0]
|
||||||
|
info.Latest = latest.Version
|
||||||
|
info.Notes = latest.Notes
|
||||||
|
info.URL = latest.URL
|
||||||
|
info.Published = latest.Published
|
||||||
|
info.Available = IsNewer(u.Version, latest.Version)
|
||||||
|
info.Changelog = NewerThan(notes, u.Version)
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewerThan оставляет из списка релизы новее указанной версии, сохраняя
|
||||||
|
// порядок «свежие первыми».
|
||||||
|
func NewerThan(notes []ReleaseNote, current string) []ReleaseNote {
|
||||||
|
out := make([]ReleaseNote, 0, len(notes))
|
||||||
|
for _, note := range notes {
|
||||||
|
if IsNewer(current, note.Version) {
|
||||||
|
out = append(out, note)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// CanApply проверяет, что привилегированный скрипт на месте и sudo пускает к
|
// CanApply проверяет, что привилегированный скрипт на месте и sudo пускает к
|
||||||
// нему без пароля. Проверяем вызовом: наличие файла ещё не значит, что
|
// нему без пароля. Проверяем вызовом: наличие файла ещё не значит, что
|
||||||
// правило sudoers действует.
|
// правило sudoers действует.
|
||||||
|
|||||||
@@ -57,3 +57,33 @@ func TestAPIBase(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewerThan(t *testing.T) {
|
||||||
|
notes := []ReleaseNote{
|
||||||
|
{Version: "v0.3.0"},
|
||||||
|
{Version: "v0.2.2"},
|
||||||
|
{Version: "v0.2.1"},
|
||||||
|
{Version: "v0.2.0"},
|
||||||
|
{Version: "v0.1.3"},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := NewerThan(notes, "v0.2.0")
|
||||||
|
want := []string{"v0.3.0", "v0.2.2", "v0.2.1"}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("отобрано %d версий, ожидалось %d: %v", len(got), len(want), got)
|
||||||
|
}
|
||||||
|
for i, version := range want {
|
||||||
|
if got[i].Version != version {
|
||||||
|
t.Errorf("changelog[%d] = %s, ожидалось %s", i, got[i].Version, version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// На последней версии показывать нечего.
|
||||||
|
if got := NewerThan(notes, "v0.3.0"); len(got) != 0 {
|
||||||
|
t.Errorf("на последней версии отобрано %d записей: %v", len(got), got)
|
||||||
|
}
|
||||||
|
// Сборка из рабочей копии не знает своей версии — показываем всё.
|
||||||
|
if got := NewerThan(notes, "dev"); len(got) != len(notes) {
|
||||||
|
t.Errorf("для dev отобрано %d из %d записей", len(got), len(notes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Переводы легко разъезжаются: строку добавили в один язык и забыли про другой.
|
||||||
|
// Тесты ниже сверяют словари между собой и с разметкой.
|
||||||
|
|
||||||
|
func TestUIStringsCoverBothLanguages(t *testing.T) {
|
||||||
|
tables := parseStrings(t)
|
||||||
|
ru, en := tables["ru"], tables["en"]
|
||||||
|
if len(ru) == 0 || len(en) == 0 {
|
||||||
|
t.Fatalf("словари не разобраны: ru=%d en=%d", len(ru), len(en))
|
||||||
|
}
|
||||||
|
for key := range ru {
|
||||||
|
if _, ok := en[key]; !ok {
|
||||||
|
t.Errorf("нет английского перевода: %s", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for key := range en {
|
||||||
|
if _, ok := ru[key]; !ok {
|
||||||
|
t.Errorf("нет русского перевода: %s", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подстановки вида {count} должны совпадать: иначе в одном языке значение
|
||||||
|
// просто не появится.
|
||||||
|
func TestUIStringsPlaceholders(t *testing.T) {
|
||||||
|
tables := parseStrings(t)
|
||||||
|
placeholder := regexp.MustCompile(`\{(\w+)\}`)
|
||||||
|
|
||||||
|
for key, ruText := range tables["ru"] {
|
||||||
|
enText, ok := tables["en"][key]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if got, want := names(placeholder, enText), names(placeholder, ruText); !equal(got, want) {
|
||||||
|
t.Errorf("%s: подстановки ru=%v, en=%v", key, want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Каждый ключ из разметки должен существовать в словаре, иначе на странице
|
||||||
|
// вместо подписи окажется сам ключ.
|
||||||
|
func TestMarkupKeysExist(t *testing.T) {
|
||||||
|
markup, err := os.ReadFile("static/index.html")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tables := parseStrings(t)
|
||||||
|
attr := regexp.MustCompile(`data-i18n(?:-html|-placeholder|-title)?="([^"]+)"`)
|
||||||
|
|
||||||
|
for _, match := range attr.FindAllStringSubmatch(string(markup), -1) {
|
||||||
|
if _, ok := tables["ru"][match[1]]; !ok {
|
||||||
|
t.Errorf("ключ из index.html отсутствует в словаре: %s", match[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
script, err := os.ReadFile("static/app.js")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Ключи, собранные из кусков (`state.${state}`), сюда не попадают — у них
|
||||||
|
// в коде есть запасной вариант на случай отсутствия перевода.
|
||||||
|
call := regexp.MustCompile(`\bt\("([^"]+)"`)
|
||||||
|
found := 0
|
||||||
|
for _, match := range call.FindAllStringSubmatch(string(script), -1) {
|
||||||
|
found++
|
||||||
|
if _, ok := tables["ru"][match[1]]; !ok {
|
||||||
|
t.Errorf("ключ из app.js отсутствует в словаре: %s", match[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found == 0 {
|
||||||
|
t.Error("в app.js не найдено ни одного обращения к словарю")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подписи параметров конфигов лежат отдельными файлами: в них должны быть одни
|
||||||
|
// и те же ключи, и у каждого — подпись и описание.
|
||||||
|
func TestParamHintsMatch(t *testing.T) {
|
||||||
|
ru := readHints(t, "static/hints-ru.json")
|
||||||
|
en := readHints(t, "static/hints-en.json")
|
||||||
|
|
||||||
|
for _, section := range []string{"config", "sandbox"} {
|
||||||
|
if len(ru[section]) == 0 {
|
||||||
|
t.Fatalf("раздел %s пуст", section)
|
||||||
|
}
|
||||||
|
if len(ru[section]) != len(en[section]) {
|
||||||
|
t.Errorf("%s: ru=%d, en=%d параметров", section, len(ru[section]), len(en[section]))
|
||||||
|
}
|
||||||
|
for key, value := range ru[section] {
|
||||||
|
translated, ok := en[section][key]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("%s: нет английской подписи для %s", section, key)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, pair := range [][]string{value, translated} {
|
||||||
|
if len(pair) != 2 || strings.TrimSpace(pair[0]) == "" || strings.TrimSpace(pair[1]) == "" {
|
||||||
|
t.Errorf("%s.%s: ожидались подпись и описание, получено %v", section, key, pair)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readHints(t *testing.T, path string) map[string]map[string][]string {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var hints map[string]map[string][]string
|
||||||
|
if err := json.Unmarshal(data, &hints); err != nil {
|
||||||
|
t.Fatalf("%s: %v", path, err)
|
||||||
|
}
|
||||||
|
return hints
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseStrings вытаскивает словари из i18n.js. Разбирать JavaScript целиком
|
||||||
|
// незачем: строки записаны построчно, ключ в кавычках и двоеточие.
|
||||||
|
func parseStrings(t *testing.T) map[string]map[string]string {
|
||||||
|
t.Helper()
|
||||||
|
source, err := os.ReadFile("static/i18n.js")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tables := map[string]map[string]string{}
|
||||||
|
entry := regexp.MustCompile(`^\s{4}"([^"]+)": "(.*)",$`)
|
||||||
|
current := ""
|
||||||
|
for _, line := range strings.Split(string(source), "\n") {
|
||||||
|
switch strings.TrimSpace(strings.TrimSuffix(line, "\r")) {
|
||||||
|
case "ru: {":
|
||||||
|
current = "ru"
|
||||||
|
tables[current] = map[string]string{}
|
||||||
|
continue
|
||||||
|
case "en: {":
|
||||||
|
current = "en"
|
||||||
|
tables[current] = map[string]string{}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if current == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if match := entry.FindStringSubmatch(strings.TrimSuffix(line, "\r")); match != nil {
|
||||||
|
tables[current][match[1]] = match[2]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tables
|
||||||
|
}
|
||||||
|
|
||||||
|
func names(re *regexp.Regexp, text string) []string {
|
||||||
|
found := []string{}
|
||||||
|
for _, match := range re.FindAllStringSubmatch(text, -1) {
|
||||||
|
found = append(found, match[1])
|
||||||
|
}
|
||||||
|
sort.Strings(found)
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
func equal(a, b []string) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range a {
|
||||||
|
if a[i] != b[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
+1008
-213
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,784 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Локализация интерфейса
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Язык выбирается человеком и хранится в localStorage; пока выбора не было,
|
||||||
|
// панель ориентируется на язык браузера. Строки лежат плоским словарём:
|
||||||
|
// ключ -> текст, подстановки записываются как {name}.
|
||||||
|
|
||||||
|
const LANG_KEY = "pzm-lang";
|
||||||
|
const LANGS = ["ru", "en"];
|
||||||
|
const FALLBACK_LANG = "en";
|
||||||
|
|
||||||
|
function detectLang() {
|
||||||
|
let saved = null;
|
||||||
|
try {
|
||||||
|
saved = localStorage.getItem(LANG_KEY);
|
||||||
|
} catch (error) {
|
||||||
|
// Приватный режим может запрещать хранилище — тогда просто определяем язык.
|
||||||
|
}
|
||||||
|
if (LANGS.includes(saved)) return saved;
|
||||||
|
|
||||||
|
const preferred = navigator.languages && navigator.languages.length
|
||||||
|
? navigator.languages
|
||||||
|
: [navigator.language || ""];
|
||||||
|
for (const tag of preferred) {
|
||||||
|
const code = String(tag).toLowerCase().split("-")[0];
|
||||||
|
if (LANGS.includes(code)) return code;
|
||||||
|
}
|
||||||
|
return FALLBACK_LANG;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LANG = detectLang();
|
||||||
|
|
||||||
|
// Сервер отдаёт свои сообщения об ошибках на языке панели, и узнаёт его из
|
||||||
|
// куки: заголовок Accept-Language говорит о браузере, а не о выборе человека.
|
||||||
|
document.cookie = `pzm_lang=${LANG}; path=/; max-age=31536000; samesite=lax`;
|
||||||
|
document.documentElement.lang = LANG;
|
||||||
|
|
||||||
|
function setLang(lang) {
|
||||||
|
if (!LANGS.includes(lang) || lang === LANG) return;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LANG_KEY, lang);
|
||||||
|
} catch (error) {
|
||||||
|
// Без хранилища выбор не переживёт перезагрузку, но применится сейчас.
|
||||||
|
}
|
||||||
|
document.cookie = `pzm_lang=${lang}; path=/; max-age=31536000; samesite=lax`;
|
||||||
|
// Перерисовать всё на месте было бы можно, но перезагрузка честнее: она
|
||||||
|
// заново читает и подписи параметров, и данные с сервера.
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
// t возвращает строку по ключу. Неизвестный ключ — это ошибка перевода, а не
|
||||||
|
// повод показать пустое место: отдаём сам ключ, его видно при проверке.
|
||||||
|
function t(key, vars) {
|
||||||
|
const table = STRINGS[LANG] || {};
|
||||||
|
let text = table[key];
|
||||||
|
if (text === undefined) text = (STRINGS[FALLBACK_LANG] || {})[key];
|
||||||
|
if (text === undefined) return key;
|
||||||
|
if (!vars) return text;
|
||||||
|
return text.replace(/\{(\w+)\}/g, (match, name) =>
|
||||||
|
(vars[name] === undefined ? match : String(vars[name])));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Разметка помечена атрибутами: data-i18n ставит текст, а варианты с суффиксом
|
||||||
|
// — соответствующий атрибут. Так HTML остаётся читаемым и не обрастает кодом.
|
||||||
|
function applyStaticTranslations(root = document) {
|
||||||
|
root.querySelectorAll("[data-i18n]").forEach((el) => {
|
||||||
|
el.textContent = t(el.dataset.i18n);
|
||||||
|
});
|
||||||
|
root.querySelectorAll("[data-i18n-html]").forEach((el) => {
|
||||||
|
el.innerHTML = t(el.dataset.i18nHtml);
|
||||||
|
});
|
||||||
|
root.querySelectorAll("[data-i18n-placeholder]").forEach((el) => {
|
||||||
|
el.placeholder = t(el.dataset.i18nPlaceholder);
|
||||||
|
});
|
||||||
|
root.querySelectorAll("[data-i18n-title]").forEach((el) => {
|
||||||
|
el.title = t(el.dataset.i18nTitle);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const STRINGS = {
|
||||||
|
ru: {
|
||||||
|
"lang.label": "Язык",
|
||||||
|
|
||||||
|
"auth.subtitle": "Управление сервером Project Zomboid",
|
||||||
|
"auth.subtitleSetup": "Создайте учётную запись администратора",
|
||||||
|
"auth.token": "Код первичной настройки",
|
||||||
|
"auth.tokenHint": "Код напечатан в журнале менеджера при запуске: <code>journalctl -u pzmanager</code>",
|
||||||
|
"auth.login": "Логин",
|
||||||
|
"auth.password": "Пароль",
|
||||||
|
"auth.signIn": "Войти",
|
||||||
|
"auth.createAdmin": "Создать администратора",
|
||||||
|
"auth.expired": "Сессия истекла, войдите заново",
|
||||||
|
|
||||||
|
"error.http": "Ошибка {status}",
|
||||||
|
"error.offline": "Нет связи с менеджером",
|
||||||
|
|
||||||
|
"btn.start": "Запустить",
|
||||||
|
"btn.stop": "Остановить",
|
||||||
|
"btn.restart": "Перезапустить",
|
||||||
|
"btn.cancelRestart": "Отменить перезапуск",
|
||||||
|
"btn.logout": "Выйти",
|
||||||
|
"btn.cancel": "Отменить",
|
||||||
|
"btn.save": "Сохранить",
|
||||||
|
"btn.saveCount": "Сохранить ({count})",
|
||||||
|
"btn.delete": "Удалить",
|
||||||
|
|
||||||
|
"state.stopped": "Остановлен",
|
||||||
|
"state.restarting": "Ждёт перезапуска",
|
||||||
|
"state.starting": "Запускается",
|
||||||
|
"state.running": "Работает",
|
||||||
|
"state.stopping": "Останавливается",
|
||||||
|
"state.restartIn": "Перезапуск через {sec} с",
|
||||||
|
|
||||||
|
"tab.overview": "Обзор",
|
||||||
|
"tab.console": "Консоль",
|
||||||
|
"tab.config": "Конфиг сервера",
|
||||||
|
"tab.sandbox": "Песочница",
|
||||||
|
"tab.mods": "Моды",
|
||||||
|
"tab.backups": "Бэкапы",
|
||||||
|
"tab.profiles": "Профили",
|
||||||
|
"tab.settings": "Настройки",
|
||||||
|
|
||||||
|
"overview.stateCard": "Состояние",
|
||||||
|
"overview.playersCard": "Игроки онлайн",
|
||||||
|
"overview.processCard": "Процесс сервера",
|
||||||
|
"overview.hostCard": "Хост",
|
||||||
|
"overview.cpu": "CPU",
|
||||||
|
"overview.memory": "Память",
|
||||||
|
"overview.threads": "Потоки",
|
||||||
|
"overview.ramFree": "ОЗУ свободно",
|
||||||
|
"overview.load": "Load average",
|
||||||
|
"overview.diskFree": "Диск свободно",
|
||||||
|
"overview.uptime": "Работает: {value}",
|
||||||
|
"overview.loading": "Загружается: {value}",
|
||||||
|
"overview.starting": "Мир загружается — сервер ещё не принимает подключения",
|
||||||
|
"overview.profile": "Профиль: {title}",
|
||||||
|
"overview.lastExit": "Последнее завершение: {reason}",
|
||||||
|
"overview.memoryOf": "{free} из {total}",
|
||||||
|
"overview.loadCores": "{load} на {count} ядер",
|
||||||
|
|
||||||
|
"install.card": "Серверные файлы",
|
||||||
|
"install.installed": "Серверные файлы установлены.",
|
||||||
|
"install.missing": "Серверные файлы не найдены — сервер ещё не установлен.",
|
||||||
|
"install.install": "Установить сервер через SteamCMD",
|
||||||
|
"install.update": "Обновить сервер через SteamCMD",
|
||||||
|
"install.hintInstall": "Займёт несколько минут и около 3 ГБ на диске — прогресс виден во вкладке «Консоль».",
|
||||||
|
"install.hintUpdate": "Сервер должен быть остановлен. Обновление занимает несколько минут — прогресс виден во вкладке «Консоль».",
|
||||||
|
"install.confirmInstall": "Установить сервер через SteamCMD?",
|
||||||
|
"install.confirmUpdate": "Обновить серверные файлы через SteamCMD?",
|
||||||
|
"install.started": "Задача запущена — следите за вкладкой «Консоль»",
|
||||||
|
|
||||||
|
"confirm.stop": "Остановить игровой сервер? Игроки будут отключены.",
|
||||||
|
"confirm.restart": "Перезапустить сервер? Игроки будут отключены.",
|
||||||
|
|
||||||
|
"task.done": "{name} — готово",
|
||||||
|
|
||||||
|
"console.autoscroll": "Прокручивать за выводом",
|
||||||
|
"console.filter": "Фильтр по строке…",
|
||||||
|
"console.clear": "Очистить экран",
|
||||||
|
"console.managerRestarted": "— менеджер перезапущен, консоль начата заново —",
|
||||||
|
"console.placeholder": "Команда сервера, например: players, save, quit",
|
||||||
|
"console.placeholderOffline": "Сервер остановлен — консоль принимает команды только на работающем",
|
||||||
|
"console.send": "Отправить",
|
||||||
|
"console.help": "Полезные команды: <code>players</code>, <code>save</code>, <code>servermsg \"текст\"</code>, <code>kickuser \"ник\"</code>, <code>banuser \"ник\"</code>, <code>checkModsNeedUpdate</code>.",
|
||||||
|
|
||||||
|
"config.title": "Конфигурация сервера",
|
||||||
|
"config.search": "Поиск параметра…",
|
||||||
|
"config.saved": "Конфиг сохранён. Изменения применятся после перезапуска сервера.",
|
||||||
|
"config.noChanges": "Нет изменений",
|
||||||
|
"config.changedCount": "Изменено параметров: {count}",
|
||||||
|
"config.noChangesTitle": "Изменений нет",
|
||||||
|
"config.yes": "да",
|
||||||
|
"config.no": "нет",
|
||||||
|
"config.anticheatLabel": "Античит: проверка {number}",
|
||||||
|
"config.anticheatHint": "Одна из встроенных проверок. Отключайте только при ложных срабатываниях.",
|
||||||
|
|
||||||
|
"sandbox.title": "Настройки песочницы",
|
||||||
|
"sandbox.saved": "Песочница сохранена. Часть параметров действует только на новый мир.",
|
||||||
|
|
||||||
|
"group.other": "Прочее",
|
||||||
|
"group.config.general": "Основное",
|
||||||
|
"group.config.access": "Доступ и учётные записи",
|
||||||
|
"group.config.network": "Сеть и порты",
|
||||||
|
"group.config.pvp": "PVP и урон",
|
||||||
|
"group.config.world": "Мир и правила",
|
||||||
|
"group.config.backups": "Бэкапы самой игры",
|
||||||
|
"group.config.mods": "Моды",
|
||||||
|
"group.config.chat": "Чат и голос",
|
||||||
|
"group.config.badwords": "Фильтр брани",
|
||||||
|
"group.config.visibility": "Видимость игроков",
|
||||||
|
"group.config.safehouse": "Убежища",
|
||||||
|
"group.config.factions": "Фракции и войны",
|
||||||
|
"group.config.radio": "Рации",
|
||||||
|
"group.config.discord": "Discord",
|
||||||
|
"group.config.anticheat": "Античит и журналы",
|
||||||
|
"group.config.vehicles": "Транспорт",
|
||||||
|
|
||||||
|
"group.sandbox.zombies": "Зомби",
|
||||||
|
"group.sandbox.world": "Мир и время",
|
||||||
|
"group.sandbox.weather": "Погода",
|
||||||
|
"group.sandbox.loot": "Лут и предметы",
|
||||||
|
"group.sandbox.character": "Персонаж и навыки",
|
||||||
|
"group.sandbox.food": "Еда, вода и хозяйство",
|
||||||
|
"group.sandbox.animals": "Животные",
|
||||||
|
"group.sandbox.vehicles": "Транспорт",
|
||||||
|
"group.sandbox.firearms": "Огнестрельное оружие",
|
||||||
|
"group.sandbox.power": "Электричество и огонь",
|
||||||
|
"group.sandbox.service": "Служебное",
|
||||||
|
"group.sandbox.ZombieLore": "Зомби: повадки и характеристики",
|
||||||
|
"group.sandbox.ZombieConfig": "Зомби: популяция и респавн",
|
||||||
|
"group.sandbox.MultiplierConfig": "Множители опыта навыков",
|
||||||
|
"group.sandbox.Map": "Карта",
|
||||||
|
"group.sandbox.Basement": "Подвалы",
|
||||||
|
|
||||||
|
"mods.title": "Моды",
|
||||||
|
"mods.subtitle": "Галочка включает мод, перетаскивание задаёт порядок загрузки. Сервер сам скачивает моды из мастерской при запуске.",
|
||||||
|
"mods.inputPlaceholder": "Ссылка на мод или коллекцию, либо просто ID",
|
||||||
|
"mods.add": "Добавить мод",
|
||||||
|
"mods.addCollection": "Добавить коллекцию",
|
||||||
|
"mods.collectionHint": "Коллекция разворачивается в список входящих в неё модов. Сами файлы сервер скачивает сам при запуске — после этого моды можно включать галочками.",
|
||||||
|
"mods.search": "Поиск по названию…",
|
||||||
|
"mods.onlyEnabled": "Только включённые",
|
||||||
|
"mods.mapsTitle": "Карты (Map)",
|
||||||
|
"mods.mapsPlaceholder": "Например: Muldraugh, KY",
|
||||||
|
"mods.mapsHint": "Каждая карта с новой строки. Первой должна идти карта мода, последней — базовая.",
|
||||||
|
"mods.titlesFailed": "Названия модов не получены из мастерской: {reason}",
|
||||||
|
"mods.caseTitle": "Регистр имён файлов модов",
|
||||||
|
"mods.caseHint": "Игра приводит путь к файлам анимаций в нижний регистр целиком, включая имя папки мода. На Linux такие файлы не находятся, и сервер сыплет в лог ошибки AnimNode.Parse. Кнопка кладёт рядом строчные ссылки — файлы модов при этом не меняются. Запускайте после каждой докачки модов.",
|
||||||
|
"mods.caseApply": "Разложить строчные ссылки",
|
||||||
|
"mods.caseConfirm": "Разложить строчные ссылки на файлы модов?\n\nСуществующие файлы не изменятся. Сервер перечитает анимации при следующем запуске.",
|
||||||
|
"mods.caseStarted": "Задача запущена — следите за вкладкой «Консоль»",
|
||||||
|
"mods.summary": "Модов: {total}, включено: {enabled}, пакетов мастерской: {workshop}",
|
||||||
|
"mods.local": "локальный мод",
|
||||||
|
"mods.notFound": "файлы не найдены — сервер скачает их при запуске",
|
||||||
|
"mods.notDownloaded": "ещё не скачан — включить можно после запуска сервера",
|
||||||
|
"mods.unnamed": "Мод {id}",
|
||||||
|
"mods.unknownID": "Mod ID неизвестен",
|
||||||
|
"mods.dragTitle": "Перетащите, чтобы изменить порядок",
|
||||||
|
"mods.dragDisabled": "Порядок задаётся у включённых модов",
|
||||||
|
"mods.toggleTitle": "Включить мод на сервере",
|
||||||
|
"mods.up": "Выше",
|
||||||
|
"mods.down": "Ниже",
|
||||||
|
"mods.remove": "Убрать",
|
||||||
|
"mods.empty": "Ничего не найдено",
|
||||||
|
"mods.unsaved": "Есть несохранённые изменения",
|
||||||
|
"mods.badInput": "Не похоже на ссылку или ID мода",
|
||||||
|
"mods.duplicate": "Этот мод уже в списке",
|
||||||
|
"mods.needCollection": "Вставьте ссылку на коллекцию или её ID",
|
||||||
|
"mods.collectionAdded": "Из коллекции добавлено модов: {added} (всего в ней {total})",
|
||||||
|
"mods.collectionKnown": "Все {total} модов коллекции уже были в списке",
|
||||||
|
"mods.saved": "Список модов сохранён. Перезапустите сервер, чтобы он их подтянул.",
|
||||||
|
|
||||||
|
"backups.title": "Резервные копии",
|
||||||
|
"backups.note": "Бэкап снимается с активного профиля: его мир и конфиги.",
|
||||||
|
"backups.notePlaceholder": "Пометка (необязательно)",
|
||||||
|
"backups.create": "Создать бэкап",
|
||||||
|
"backups.colArchive": "Архив",
|
||||||
|
"backups.colProfile": "Профиль",
|
||||||
|
"backups.colSize": "Размер",
|
||||||
|
"backups.colCreated": "Создан",
|
||||||
|
"backups.download": "Скачать",
|
||||||
|
"backups.restore": "Восстановить",
|
||||||
|
"backups.empty": "Пока ни одного архива",
|
||||||
|
"backups.started": "Бэкап запущен — прогресс во вкладке «Консоль»",
|
||||||
|
"backups.confirmRestore": "Восстановить мир из {name}? Текущий мир будет перезаписан.\nПеред восстановлением менеджер сам сделает страховочный бэкап.",
|
||||||
|
"backups.restoreStarted": "Восстановление запущено",
|
||||||
|
"backups.confirmDelete": "Удалить архив {name}? Это действие необратимо.",
|
||||||
|
"backups.deleted": "Архив удалён",
|
||||||
|
|
||||||
|
"profiles.title": "Профили серверов",
|
||||||
|
"profiles.subtitle": "У каждого профиля свой мир, конфиг, песочница и набор модов. Одновременно работает один — переключать можно на остановленном сервере.",
|
||||||
|
"profiles.picker": "Профиль",
|
||||||
|
"profiles.pickerTitle": "Профиль сервера: свой мир, конфиг и моды",
|
||||||
|
"profiles.pickerLocked": "Остановите сервер, чтобы переключить профиль",
|
||||||
|
"profiles.active": "активный",
|
||||||
|
"profiles.worldReady": "мир создан",
|
||||||
|
"profiles.configured": "настроен, мир ещё не создан",
|
||||||
|
"profiles.never": "ни разу не запускался",
|
||||||
|
"profiles.name": "Название",
|
||||||
|
"profiles.memoryMax": "Память JVM, максимум",
|
||||||
|
"profiles.memoryMin": "Память JVM, начальная",
|
||||||
|
"profiles.memoryMinPlaceholder": "не задана",
|
||||||
|
"profiles.memoryMinHint": "Пусто — JVM берёт память по мере надобности. Если задать, она займёт этот объём сразу при старте и не сможет подняться, когда его нет.",
|
||||||
|
"profiles.startupTimeout": "Ожидание готовности, минут",
|
||||||
|
"profiles.startupTimeoutHint": "Сколько ждать строку «SERVER STARTED», прежде чем считать сервер работающим. Сборке на сотни модов нужны десятки минут. Пусто — значение по умолчанию.",
|
||||||
|
"profiles.adminPassword": "Пароль админа игры",
|
||||||
|
"profiles.adminPasswordSet": "пароль задан",
|
||||||
|
"profiles.adminPasswordUnset": "не задан",
|
||||||
|
"profiles.activate": "Сделать активным",
|
||||||
|
"profiles.memoryExample": "Например 4g",
|
||||||
|
"profiles.memoryHost": "На машине {total} ОЗУ, свободно {free}. Оставьте запас системе.",
|
||||||
|
"profiles.switched": "Активный профиль переключён",
|
||||||
|
"profiles.saved": "Профиль сохранён",
|
||||||
|
"profiles.confirmDelete": "Убрать профиль {id} из панели?\n\nМир и конфиги останутся на диске — профиль можно вернуть, создав его с тем же идентификатором.",
|
||||||
|
"profiles.deleted": "Профиль удалён",
|
||||||
|
"profiles.created": "Профиль создан",
|
||||||
|
"profiles.newTitle": "Новый профиль",
|
||||||
|
"profiles.id": "Идентификатор",
|
||||||
|
"profiles.idHint": "Латиница, цифры, дефис. Станет именем конфига и папки мира.",
|
||||||
|
"profiles.nameHint": "Как профиль подписан в панели.",
|
||||||
|
"profiles.namePlaceholder": "Сборка с модами",
|
||||||
|
"profiles.memory": "Память JVM",
|
||||||
|
"profiles.memoryHint": "Применяется при запуске этого профиля.",
|
||||||
|
"profiles.copyFrom": "Скопировать настройки из",
|
||||||
|
"profiles.copyHint": "Копирует .ini и настройки песочницы. Мир останется новым.",
|
||||||
|
"profiles.copyNone": "не копировать",
|
||||||
|
"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.title": "Настройки менеджера",
|
||||||
|
"settings.save": "Сохранить настройки",
|
||||||
|
"settings.saveCount": "Сохранить настройки ({count})",
|
||||||
|
"settings.changedCount": "Изменено полей: {count}",
|
||||||
|
"settings.saved": "Настройки сохранены",
|
||||||
|
"settings.configPath": "Файл конфигурации: {path}",
|
||||||
|
"settings.usersTitle": "Пользователи панели",
|
||||||
|
|
||||||
|
"settings.listen": "Адрес веб-панели",
|
||||||
|
"settings.listenHint": "Например 127.0.0.1:8080. Применится после перезапуска pzmanager",
|
||||||
|
"settings.serverDir": "Директория сервера",
|
||||||
|
"settings.serverDirHint": "Куда SteamCMD ставит файлы Project Zomboid",
|
||||||
|
"settings.zomboidDir": "Директория данных (~/Zomboid)",
|
||||||
|
"settings.zomboidDirHint": "Здесь лежат миры, конфиги и логи игры",
|
||||||
|
"settings.steamcmdPath": "Путь к steamcmd.sh",
|
||||||
|
"settings.steamcmdPathHint": "Пусто — найти в PATH или скачать автоматически",
|
||||||
|
"settings.autostart": "Запускать сервер вместе с менеджером",
|
||||||
|
"settings.autorestart": "Поднимать сервер после падения",
|
||||||
|
"settings.stopTimeout": "Таймаут остановки, сек",
|
||||||
|
"settings.stopTimeoutHint": "Сколько ждать после команды quit до SIGTERM",
|
||||||
|
"settings.logBuffer": "Строк консоли в памяти",
|
||||||
|
"settings.backupDir": "Директория бэкапов",
|
||||||
|
"settings.backupSchedule": "Автобэкап, минут",
|
||||||
|
"settings.backupScheduleHint": "0 — отключить автоматические бэкапы",
|
||||||
|
"settings.backupKeep": "Хранить архивов",
|
||||||
|
"settings.backupKeepHint": "0 — не удалять старые",
|
||||||
|
"settings.backupStopServer": "Останавливать сервер на время бэкапа",
|
||||||
|
"settings.backupStopServerHint": "Даёт согласованный снимок, но выкидывает игроков",
|
||||||
|
|
||||||
|
"users.loginPlaceholder": "Логин",
|
||||||
|
"users.passwordPlaceholder": "Пароль (минимум 8 символов)",
|
||||||
|
"users.add": "Добавить пользователя",
|
||||||
|
"users.changePassword": "Сменить пароль",
|
||||||
|
"users.newPassword": "Новый пароль для {login} (минимум 8 символов):",
|
||||||
|
"users.passwordChanged": "Пароль изменён",
|
||||||
|
"users.confirmDelete": "Удалить пользователя {login}?",
|
||||||
|
"users.deleted": "Пользователь удалён",
|
||||||
|
"users.added": "Пользователь добавлен",
|
||||||
|
|
||||||
|
"update.checking": "Проверяю обновления…",
|
||||||
|
"update.check": "Проверить",
|
||||||
|
"update.apply": "Обновить",
|
||||||
|
"update.applyTo": "Обновить до {version}",
|
||||||
|
"update.restart": "Перезапустить панель",
|
||||||
|
"update.restartTitle": "Перезапустить службу панели",
|
||||||
|
"update.restartUnavailable": "Недоступно: панель не может управлять своей службой",
|
||||||
|
"update.failed": "Не удалось проверить обновления: {error}",
|
||||||
|
"update.available": "Доступна версия {version}",
|
||||||
|
"update.manual": "Обновить можно командой: {command}",
|
||||||
|
"update.current": "Установлена последняя версия ({version})",
|
||||||
|
"update.unknown": "Сведений о релизах нет",
|
||||||
|
"update.whatsNew": "Что нового",
|
||||||
|
"update.whatsNewCount": "Что нового (версий: {count})",
|
||||||
|
"update.noNotes": "Описание не заполнено",
|
||||||
|
"update.fullNotes": "Полное описание релизов",
|
||||||
|
"update.hasNew": "Есть новая версия: {version}",
|
||||||
|
"update.upToDate": "Обновлений нет",
|
||||||
|
"update.confirm": "Обновить панель до {version}?\n\nИгровой сервер будет остановлен, панель перезапустится.",
|
||||||
|
"update.started": "Обновление запущено",
|
||||||
|
"update.follow": "Ход обновления: journalctl -u pzmanager-update -f",
|
||||||
|
"update.confirmRestart": "Перезапустить панель?\n\nИгровой сервер будет остановлен.",
|
||||||
|
"update.restarting": "Панель перезапускается",
|
||||||
|
"update.waiting": "{message} — жду, пока панель поднимется",
|
||||||
|
"update.waitingState": "{message}…",
|
||||||
|
"update.timeout": "Панель не поднялась за пять минут — проверьте journalctl -u pzmanager",
|
||||||
|
|
||||||
|
"unit.b": "Б",
|
||||||
|
"unit.kb": "КБ",
|
||||||
|
"unit.mb": "МБ",
|
||||||
|
"unit.gb": "ГБ",
|
||||||
|
"unit.tb": "ТБ",
|
||||||
|
"unit.days": "{value} д",
|
||||||
|
"unit.hours": "{value} ч",
|
||||||
|
"unit.minutes": "{value} мин",
|
||||||
|
"locale.tag": "ru-RU",
|
||||||
|
},
|
||||||
|
|
||||||
|
en: {
|
||||||
|
"lang.label": "Language",
|
||||||
|
|
||||||
|
"auth.subtitle": "Project Zomboid server management",
|
||||||
|
"auth.subtitleSetup": "Create an administrator account",
|
||||||
|
"auth.token": "Setup code",
|
||||||
|
"auth.tokenHint": "The code is printed to the manager log at startup: <code>journalctl -u pzmanager</code>",
|
||||||
|
"auth.login": "Login",
|
||||||
|
"auth.password": "Password",
|
||||||
|
"auth.signIn": "Sign in",
|
||||||
|
"auth.createAdmin": "Create administrator",
|
||||||
|
"auth.expired": "Session expired, please sign in again",
|
||||||
|
|
||||||
|
"error.http": "Error {status}",
|
||||||
|
"error.offline": "No connection to the manager",
|
||||||
|
|
||||||
|
"btn.start": "Start",
|
||||||
|
"btn.stop": "Stop",
|
||||||
|
"btn.restart": "Restart",
|
||||||
|
"btn.cancelRestart": "Cancel restart",
|
||||||
|
"btn.logout": "Sign out",
|
||||||
|
"btn.cancel": "Cancel",
|
||||||
|
"btn.save": "Save",
|
||||||
|
"btn.saveCount": "Save ({count})",
|
||||||
|
"btn.delete": "Delete",
|
||||||
|
|
||||||
|
"state.stopped": "Stopped",
|
||||||
|
"state.restarting": "Waiting to restart",
|
||||||
|
"state.starting": "Starting",
|
||||||
|
"state.running": "Running",
|
||||||
|
"state.stopping": "Stopping",
|
||||||
|
"state.restartIn": "Restarting in {sec} s",
|
||||||
|
|
||||||
|
"tab.overview": "Overview",
|
||||||
|
"tab.console": "Console",
|
||||||
|
"tab.config": "Server config",
|
||||||
|
"tab.sandbox": "Sandbox",
|
||||||
|
"tab.mods": "Mods",
|
||||||
|
"tab.backups": "Backups",
|
||||||
|
"tab.profiles": "Profiles",
|
||||||
|
"tab.settings": "Settings",
|
||||||
|
|
||||||
|
"overview.stateCard": "State",
|
||||||
|
"overview.playersCard": "Players online",
|
||||||
|
"overview.processCard": "Server process",
|
||||||
|
"overview.hostCard": "Host",
|
||||||
|
"overview.cpu": "CPU",
|
||||||
|
"overview.memory": "Memory",
|
||||||
|
"overview.threads": "Threads",
|
||||||
|
"overview.ramFree": "RAM free",
|
||||||
|
"overview.load": "Load average",
|
||||||
|
"overview.diskFree": "Disk free",
|
||||||
|
"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.lastExit": "Last exit: {reason}",
|
||||||
|
"overview.memoryOf": "{free} of {total}",
|
||||||
|
"overview.loadCores": "{load} on {count} cores",
|
||||||
|
|
||||||
|
"install.card": "Server files",
|
||||||
|
"install.installed": "Server files are installed.",
|
||||||
|
"install.missing": "Server files not found — the server is not installed yet.",
|
||||||
|
"install.install": "Install server via SteamCMD",
|
||||||
|
"install.update": "Update server via SteamCMD",
|
||||||
|
"install.hintInstall": "Takes a few minutes and about 3 GB of disk space — watch the progress on the Console tab.",
|
||||||
|
"install.hintUpdate": "The server must be stopped. The update takes a few minutes — watch the progress on the Console tab.",
|
||||||
|
"install.confirmInstall": "Install the server via SteamCMD?",
|
||||||
|
"install.confirmUpdate": "Update the server files via SteamCMD?",
|
||||||
|
"install.started": "Task started — watch the Console tab",
|
||||||
|
|
||||||
|
"confirm.stop": "Stop the game server? Players will be disconnected.",
|
||||||
|
"confirm.restart": "Restart the server? Players will be disconnected.",
|
||||||
|
|
||||||
|
"task.done": "{name} — done",
|
||||||
|
|
||||||
|
"console.autoscroll": "Scroll with output",
|
||||||
|
"console.filter": "Filter by text…",
|
||||||
|
"console.clear": "Clear screen",
|
||||||
|
"console.managerRestarted": "— the manager restarted, the console starts over —",
|
||||||
|
"console.placeholder": "Server command, for example: players, save, quit",
|
||||||
|
"console.placeholderOffline": "Server stopped — the console only accepts commands while it runs",
|
||||||
|
"console.send": "Send",
|
||||||
|
"console.help": "Handy commands: <code>players</code>, <code>save</code>, <code>servermsg \"text\"</code>, <code>kickuser \"name\"</code>, <code>banuser \"name\"</code>, <code>checkModsNeedUpdate</code>.",
|
||||||
|
|
||||||
|
"config.title": "Server configuration",
|
||||||
|
"config.search": "Find a setting…",
|
||||||
|
"config.saved": "Config saved. Changes apply after a server restart.",
|
||||||
|
"config.noChanges": "Nothing changed",
|
||||||
|
"config.changedCount": "Settings changed: {count}",
|
||||||
|
"config.noChangesTitle": "No changes",
|
||||||
|
"config.yes": "yes",
|
||||||
|
"config.no": "no",
|
||||||
|
"config.anticheatLabel": "Anti-cheat: check {number}",
|
||||||
|
"config.anticheatHint": "One of the built-in checks. Disable it only if it fires on legitimate players.",
|
||||||
|
|
||||||
|
"sandbox.title": "Sandbox settings",
|
||||||
|
"sandbox.saved": "Sandbox saved. Some settings only affect a freshly created world.",
|
||||||
|
|
||||||
|
"group.other": "Other",
|
||||||
|
"group.config.general": "General",
|
||||||
|
"group.config.access": "Access and accounts",
|
||||||
|
"group.config.network": "Network and ports",
|
||||||
|
"group.config.pvp": "PVP and damage",
|
||||||
|
"group.config.world": "World and rules",
|
||||||
|
"group.config.backups": "In-game backups",
|
||||||
|
"group.config.mods": "Mods",
|
||||||
|
"group.config.chat": "Chat and voice",
|
||||||
|
"group.config.badwords": "Profanity filter",
|
||||||
|
"group.config.visibility": "Player visibility",
|
||||||
|
"group.config.safehouse": "Safehouses",
|
||||||
|
"group.config.factions": "Factions and wars",
|
||||||
|
"group.config.radio": "Radio",
|
||||||
|
"group.config.discord": "Discord",
|
||||||
|
"group.config.anticheat": "Anti-cheat and logs",
|
||||||
|
"group.config.vehicles": "Vehicles",
|
||||||
|
|
||||||
|
"group.sandbox.zombies": "Zombies",
|
||||||
|
"group.sandbox.world": "World and time",
|
||||||
|
"group.sandbox.weather": "Weather",
|
||||||
|
"group.sandbox.loot": "Loot and items",
|
||||||
|
"group.sandbox.character": "Character and skills",
|
||||||
|
"group.sandbox.food": "Food, water and farming",
|
||||||
|
"group.sandbox.animals": "Animals",
|
||||||
|
"group.sandbox.vehicles": "Vehicles",
|
||||||
|
"group.sandbox.firearms": "Firearms",
|
||||||
|
"group.sandbox.power": "Power and fire",
|
||||||
|
"group.sandbox.service": "Service",
|
||||||
|
"group.sandbox.ZombieLore": "Zombies: behaviour and traits",
|
||||||
|
"group.sandbox.ZombieConfig": "Zombies: population and respawn",
|
||||||
|
"group.sandbox.MultiplierConfig": "Skill XP multipliers",
|
||||||
|
"group.sandbox.Map": "Map",
|
||||||
|
"group.sandbox.Basement": "Basements",
|
||||||
|
|
||||||
|
"mods.title": "Mods",
|
||||||
|
"mods.subtitle": "The checkbox enables a mod, dragging sets the load order. The server downloads mods from the Workshop on startup.",
|
||||||
|
"mods.inputPlaceholder": "Link to a mod or collection, or just an ID",
|
||||||
|
"mods.add": "Add mod",
|
||||||
|
"mods.addCollection": "Add collection",
|
||||||
|
"mods.collectionHint": "A collection expands into the list of mods it contains. The server downloads the files itself on startup — after that the mods can be enabled with checkboxes.",
|
||||||
|
"mods.search": "Search by name…",
|
||||||
|
"mods.onlyEnabled": "Enabled only",
|
||||||
|
"mods.mapsTitle": "Maps (Map)",
|
||||||
|
"mods.mapsPlaceholder": "For example: Muldraugh, KY",
|
||||||
|
"mods.mapsHint": "One map per line. The mod map goes first, the base map last.",
|
||||||
|
"mods.titlesFailed": "Could not get the mod titles from the workshop: {reason}",
|
||||||
|
"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.local": "local mod",
|
||||||
|
"mods.notFound": "files not found — the server will download them on startup",
|
||||||
|
"mods.notDownloaded": "not downloaded yet — can be enabled after a server start",
|
||||||
|
"mods.unnamed": "Mod {id}",
|
||||||
|
"mods.unknownID": "Mod ID unknown",
|
||||||
|
"mods.dragTitle": "Drag to change the order",
|
||||||
|
"mods.dragDisabled": "Order applies to enabled mods only",
|
||||||
|
"mods.toggleTitle": "Enable the mod on the server",
|
||||||
|
"mods.up": "Up",
|
||||||
|
"mods.down": "Down",
|
||||||
|
"mods.remove": "Remove",
|
||||||
|
"mods.empty": "Nothing found",
|
||||||
|
"mods.unsaved": "There are unsaved changes",
|
||||||
|
"mods.badInput": "That does not look like a mod link or ID",
|
||||||
|
"mods.duplicate": "This mod is already on the list",
|
||||||
|
"mods.needCollection": "Paste a collection link or its ID",
|
||||||
|
"mods.collectionAdded": "Mods added from the collection: {added} (of {total} in it)",
|
||||||
|
"mods.collectionKnown": "All {total} mods of the collection were already on the list",
|
||||||
|
"mods.saved": "Mod list saved. Restart the server so it picks them up.",
|
||||||
|
|
||||||
|
"backups.title": "Backups",
|
||||||
|
"backups.note": "The backup is taken from the active profile: its world and configs.",
|
||||||
|
"backups.notePlaceholder": "Note (optional)",
|
||||||
|
"backups.create": "Create backup",
|
||||||
|
"backups.colArchive": "Archive",
|
||||||
|
"backups.colProfile": "Profile",
|
||||||
|
"backups.colSize": "Size",
|
||||||
|
"backups.colCreated": "Created",
|
||||||
|
"backups.download": "Download",
|
||||||
|
"backups.restore": "Restore",
|
||||||
|
"backups.empty": "No archives yet",
|
||||||
|
"backups.started": "Backup started — progress on the Console tab",
|
||||||
|
"backups.confirmRestore": "Restore the world from {name}? The current world will be overwritten.\nThe manager takes a safety backup first.",
|
||||||
|
"backups.restoreStarted": "Restore started",
|
||||||
|
"backups.confirmDelete": "Delete the archive {name}? This cannot be undone.",
|
||||||
|
"backups.deleted": "Archive deleted",
|
||||||
|
|
||||||
|
"profiles.title": "Server profiles",
|
||||||
|
"profiles.subtitle": "Every profile has its own world, config, sandbox and mod set. Only one runs at a time — switch it while the server is stopped.",
|
||||||
|
"profiles.picker": "Profile",
|
||||||
|
"profiles.pickerTitle": "Server profile: its own world, config and mods",
|
||||||
|
"profiles.pickerLocked": "Stop the server to switch the profile",
|
||||||
|
"profiles.active": "active",
|
||||||
|
"profiles.worldReady": "world created",
|
||||||
|
"profiles.configured": "configured, world not created yet",
|
||||||
|
"profiles.never": "never started",
|
||||||
|
"profiles.name": "Name",
|
||||||
|
"profiles.memoryMax": "JVM memory, maximum",
|
||||||
|
"profiles.memoryMin": "JVM memory, initial",
|
||||||
|
"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.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.adminPasswordSet": "password set",
|
||||||
|
"profiles.adminPasswordUnset": "not set",
|
||||||
|
"profiles.activate": "Make active",
|
||||||
|
"profiles.memoryExample": "For example 4g",
|
||||||
|
"profiles.memoryHost": "The machine has {total} of RAM, {free} free. Leave some for the system.",
|
||||||
|
"profiles.switched": "Active profile switched",
|
||||||
|
"profiles.saved": "Profile saved",
|
||||||
|
"profiles.confirmDelete": "Remove the profile {id} from the panel?\n\nThe world and configs stay on disk — the profile can be brought back by creating it with the same identifier.",
|
||||||
|
"profiles.deleted": "Profile deleted",
|
||||||
|
"profiles.created": "Profile created",
|
||||||
|
"profiles.newTitle": "New profile",
|
||||||
|
"profiles.id": "Identifier",
|
||||||
|
"profiles.idHint": "Latin letters, digits, hyphen. Becomes the config name and the world folder.",
|
||||||
|
"profiles.nameHint": "How the profile is labelled in the panel.",
|
||||||
|
"profiles.namePlaceholder": "Modded run",
|
||||||
|
"profiles.memory": "JVM memory",
|
||||||
|
"profiles.memoryHint": "Applied when this profile starts.",
|
||||||
|
"profiles.copyFrom": "Copy settings from",
|
||||||
|
"profiles.copyHint": "Copies the .ini and the sandbox settings. The world stays new.",
|
||||||
|
"profiles.copyNone": "do not copy",
|
||||||
|
"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.title": "Manager settings",
|
||||||
|
"settings.save": "Save settings",
|
||||||
|
"settings.saveCount": "Save settings ({count})",
|
||||||
|
"settings.changedCount": "Fields changed: {count}",
|
||||||
|
"settings.saved": "Settings saved",
|
||||||
|
"settings.configPath": "Configuration file: {path}",
|
||||||
|
"settings.usersTitle": "Panel users",
|
||||||
|
|
||||||
|
"settings.listen": "Web panel address",
|
||||||
|
"settings.listenHint": "For example 127.0.0.1:8080. Applies after a pzmanager restart",
|
||||||
|
"settings.serverDir": "Server directory",
|
||||||
|
"settings.serverDirHint": "Where SteamCMD installs the Project Zomboid files",
|
||||||
|
"settings.zomboidDir": "Data directory (~/Zomboid)",
|
||||||
|
"settings.zomboidDirHint": "Worlds, configs and game logs live here",
|
||||||
|
"settings.steamcmdPath": "Path to steamcmd.sh",
|
||||||
|
"settings.steamcmdPathHint": "Empty — look it up in PATH or download automatically",
|
||||||
|
"settings.autostart": "Start the server together with the manager",
|
||||||
|
"settings.autorestart": "Bring the server back up after a crash",
|
||||||
|
"settings.stopTimeout": "Stop timeout, sec",
|
||||||
|
"settings.stopTimeoutHint": "How long to wait after the quit command before SIGTERM",
|
||||||
|
"settings.logBuffer": "Console lines kept in memory",
|
||||||
|
"settings.backupDir": "Backup directory",
|
||||||
|
"settings.backupSchedule": "Auto backup, minutes",
|
||||||
|
"settings.backupScheduleHint": "0 — turn automatic backups off",
|
||||||
|
"settings.backupKeep": "Archives to keep",
|
||||||
|
"settings.backupKeepHint": "0 — never delete old ones",
|
||||||
|
"settings.backupStopServer": "Stop the server while backing up",
|
||||||
|
"settings.backupStopServerHint": "Gives a consistent snapshot, but kicks the players",
|
||||||
|
|
||||||
|
"users.loginPlaceholder": "Login",
|
||||||
|
"users.passwordPlaceholder": "Password (at least 8 characters)",
|
||||||
|
"users.add": "Add user",
|
||||||
|
"users.changePassword": "Change password",
|
||||||
|
"users.newPassword": "New password for {login} (at least 8 characters):",
|
||||||
|
"users.passwordChanged": "Password changed",
|
||||||
|
"users.confirmDelete": "Delete the user {login}?",
|
||||||
|
"users.deleted": "User deleted",
|
||||||
|
"users.added": "User added",
|
||||||
|
|
||||||
|
"update.checking": "Checking for updates…",
|
||||||
|
"update.check": "Check",
|
||||||
|
"update.apply": "Update",
|
||||||
|
"update.applyTo": "Update to {version}",
|
||||||
|
"update.restart": "Restart panel",
|
||||||
|
"update.restartTitle": "Restart the panel service",
|
||||||
|
"update.restartUnavailable": "Unavailable: the panel cannot manage its own service",
|
||||||
|
"update.failed": "Could not check for updates: {error}",
|
||||||
|
"update.available": "Version {version} is available",
|
||||||
|
"update.manual": "Update with this command: {command}",
|
||||||
|
"update.current": "The latest version is installed ({version})",
|
||||||
|
"update.unknown": "No release information",
|
||||||
|
"update.whatsNew": "What's new",
|
||||||
|
"update.whatsNewCount": "What's new ({count} versions)",
|
||||||
|
"update.noNotes": "No description",
|
||||||
|
"update.fullNotes": "Full release notes",
|
||||||
|
"update.hasNew": "A new version is available: {version}",
|
||||||
|
"update.upToDate": "No updates",
|
||||||
|
"update.confirm": "Update the panel to {version}?\n\nThe game server will be stopped and the panel will restart.",
|
||||||
|
"update.started": "Update started",
|
||||||
|
"update.follow": "Update progress: journalctl -u pzmanager-update -f",
|
||||||
|
"update.confirmRestart": "Restart the panel?\n\nThe game server will be stopped.",
|
||||||
|
"update.restarting": "The panel is restarting",
|
||||||
|
"update.waiting": "{message} — waiting for the panel to come back",
|
||||||
|
"update.waitingState": "{message}…",
|
||||||
|
"update.timeout": "The panel did not come back within five minutes — check journalctl -u pzmanager",
|
||||||
|
|
||||||
|
"unit.b": "B",
|
||||||
|
"unit.kb": "KB",
|
||||||
|
"unit.mb": "MB",
|
||||||
|
"unit.gb": "GB",
|
||||||
|
"unit.tb": "TB",
|
||||||
|
"unit.days": "{value} d",
|
||||||
|
"unit.hours": "{value} h",
|
||||||
|
"unit.minutes": "{value} min",
|
||||||
|
"locale.tag": "en-US",
|
||||||
|
},
|
||||||
|
};
|
||||||
+178
-96
@@ -13,26 +13,34 @@
|
|||||||
<div id="auth-screen" class="auth-screen hidden">
|
<div id="auth-screen" class="auth-screen hidden">
|
||||||
<form id="auth-form" class="auth-card">
|
<form id="auth-form" class="auth-card">
|
||||||
<h1 class="auth-title">🧟 PZ Manager</h1>
|
<h1 class="auth-title">🧟 PZ Manager</h1>
|
||||||
<p id="auth-subtitle" class="auth-subtitle">Управление сервером Project Zomboid</p>
|
<p id="auth-subtitle" class="auth-subtitle" data-i18n="auth.subtitle"></p>
|
||||||
|
|
||||||
<label class="field" id="token-field" hidden>
|
<label class="field" id="token-field" hidden>
|
||||||
<span>Код первичной настройки</span>
|
<span data-i18n="auth.token"></span>
|
||||||
<input type="text" name="token" autocomplete="off" spellcheck="false">
|
<input type="text" name="token" autocomplete="off" spellcheck="false">
|
||||||
<small>Код напечатан в журнале менеджера при запуске: <code>journalctl -u pzmanager</code></small>
|
<small data-i18n-html="auth.tokenHint"></small>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Логин</span>
|
<span data-i18n="auth.login"></span>
|
||||||
<input type="text" name="login" autocomplete="username" required>
|
<input type="text" name="login" autocomplete="username" required>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>Пароль</span>
|
<span data-i18n="auth.password"></span>
|
||||||
<input type="password" name="password" autocomplete="current-password" required>
|
<input type="password" name="password" autocomplete="current-password" required>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<p id="auth-error" class="error" hidden></p>
|
<p id="auth-error" class="error" hidden></p>
|
||||||
<button type="submit" class="btn btn-primary btn-block" id="auth-submit">Войти</button>
|
<button type="submit" class="btn btn-primary btn-block" id="auth-submit" data-i18n="auth.signIn"></button>
|
||||||
|
|
||||||
|
<label class="lang-picker lang-picker-auth">
|
||||||
|
<span data-i18n="lang.label"></span>
|
||||||
|
<select class="lang-select">
|
||||||
|
<option value="ru">Русский</option>
|
||||||
|
<option value="en">English</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -47,36 +55,42 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="profile-picker">
|
<label class="profile-picker">
|
||||||
<span>Профиль</span>
|
<span data-i18n="profiles.picker"></span>
|
||||||
<select id="profile-select" title="Профиль сервера: свой мир, конфиг и моды"></select>
|
<select id="profile-select" data-i18n-title="profiles.pickerTitle"></select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<button class="btn btn-start" id="btn-start">Запустить</button>
|
<button class="btn btn-start" id="btn-start" data-i18n="btn.start"></button>
|
||||||
<button class="btn btn-stop" id="btn-stop">Остановить</button>
|
<button class="btn btn-stop" id="btn-stop" data-i18n="btn.stop"></button>
|
||||||
<button class="btn" id="btn-restart">Перезапустить</button>
|
<button class="btn" id="btn-restart" data-i18n="btn.restart"></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
|
<label class="lang-picker">
|
||||||
|
<select class="lang-select" data-i18n-title="lang.label">
|
||||||
|
<option value="ru">Русский</option>
|
||||||
|
<option value="en">English</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<span class="user" id="current-user"></span>
|
<span class="user" id="current-user"></span>
|
||||||
<button class="btn btn-ghost" id="btn-logout">Выйти</button>
|
<button class="btn btn-ghost" id="btn-logout" data-i18n="btn.logout"></button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div id="task-banner" class="task-banner" hidden>
|
<div id="task-banner" class="task-banner" hidden>
|
||||||
<span class="spinner"></span>
|
<span class="spinner"></span>
|
||||||
<span id="task-name"></span>
|
<span id="task-name"></span>
|
||||||
<button class="btn btn-ghost btn-sm" id="btn-task-cancel">Отменить</button>
|
<button class="btn btn-ghost btn-sm" id="btn-task-cancel" data-i18n="btn.cancel"></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="tabs" id="tabs">
|
<nav class="tabs" id="tabs">
|
||||||
<button class="tab active" data-tab="overview">Обзор</button>
|
<button class="tab active" data-tab="overview" data-i18n="tab.overview"></button>
|
||||||
<button class="tab" data-tab="console">Консоль</button>
|
<button class="tab" data-tab="console" data-i18n="tab.console"></button>
|
||||||
<button class="tab" data-tab="config">Конфиг сервера</button>
|
<button class="tab" data-tab="config" data-i18n="tab.config"></button>
|
||||||
<button class="tab" data-tab="sandbox">Песочница</button>
|
<button class="tab" data-tab="sandbox" data-i18n="tab.sandbox"></button>
|
||||||
<button class="tab" data-tab="mods">Моды</button>
|
<button class="tab" data-tab="mods" data-i18n="tab.mods"></button>
|
||||||
<button class="tab" data-tab="backups">Бэкапы</button>
|
<button class="tab" data-tab="backups" data-i18n="tab.backups"></button>
|
||||||
<button class="tab" data-tab="profiles">Профили</button>
|
<button class="tab" data-tab="profiles" data-i18n="tab.profiles"></button>
|
||||||
<button class="tab" data-tab="settings">Настройки</button>
|
<button class="tab" data-tab="settings" data-i18n="tab.settings"></button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main class="content">
|
<main class="content">
|
||||||
@@ -85,125 +99,135 @@
|
|||||||
<section class="panel active" data-panel="overview">
|
<section class="panel active" data-panel="overview">
|
||||||
<div class="cards">
|
<div class="cards">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>Состояние</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>
|
||||||
<div class="muted error" id="ov-lastexit"></div>
|
<div class="muted error" id="ov-lastexit"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>Игроки онлайн</h3>
|
<h3 data-i18n="overview.playersCard"></h3>
|
||||||
<div class="stat" id="ov-players-count">0</div>
|
<div class="stat" id="ov-players-count">0</div>
|
||||||
<ul class="player-list" id="ov-players"></ul>
|
<ul class="player-list" id="ov-players"></ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>Процесс сервера</h3>
|
<h3 data-i18n="overview.processCard"></h3>
|
||||||
<div class="metric"><span>CPU</span><b id="ov-cpu">—</b></div>
|
<div class="metric"><span data-i18n="overview.cpu"></span><b id="ov-cpu">—</b></div>
|
||||||
<div class="metric"><span>Память</span><b id="ov-rss">—</b></div>
|
<div class="metric"><span data-i18n="overview.memory"></span><b id="ov-rss">—</b></div>
|
||||||
<div class="metric"><span>Потоки</span><b id="ov-threads">—</b></div>
|
<div class="metric"><span data-i18n="overview.threads"></span><b id="ov-threads">—</b></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>Хост</h3>
|
<h3 data-i18n="overview.hostCard"></h3>
|
||||||
<div class="metric"><span>ОЗУ свободно</span><b id="ov-mem">—</b></div>
|
<div class="metric"><span data-i18n="overview.ramFree"></span><b id="ov-mem">—</b></div>
|
||||||
<div class="metric"><span>Load average</span><b id="ov-load">—</b></div>
|
<div class="metric"><span data-i18n="overview.load"></span><b id="ov-load">—</b></div>
|
||||||
<div class="metric"><span>Диск свободно</span><b id="ov-disk">—</b></div>
|
<div class="metric"><span data-i18n="overview.diskFree"></span><b id="ov-disk">—</b></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card" id="install-card">
|
<div class="card" id="install-card">
|
||||||
<h3>Серверные файлы</h3>
|
<h3 data-i18n="install.card"></h3>
|
||||||
<p id="install-state" class="muted"></p>
|
<p id="install-state" class="muted"></p>
|
||||||
<button class="btn btn-primary" id="btn-install">Установить / обновить через SteamCMD</button>
|
<button class="btn btn-primary" id="btn-install" data-i18n="install.install"></button>
|
||||||
<p class="muted small">Сервер должен быть остановлен. Обновление занимает несколько минут — прогресс виден во вкладке «Консоль».</p>
|
<p class="muted small" id="install-hint" data-i18n="install.hintInstall"></p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Консоль -->
|
<!-- Консоль -->
|
||||||
<section class="panel" data-panel="console">
|
<section class="panel" data-panel="console">
|
||||||
<div class="console-toolbar">
|
<div class="console-toolbar">
|
||||||
<label class="checkbox"><input type="checkbox" id="console-autoscroll" checked> Прокручивать за выводом</label>
|
<label class="checkbox"><input type="checkbox" id="console-autoscroll" checked>
|
||||||
<input type="search" id="console-filter" placeholder="Фильтр по строке…" class="input-sm">
|
<span data-i18n="console.autoscroll"></span></label>
|
||||||
<button class="btn btn-ghost btn-sm" id="btn-console-clear">Очистить экран</button>
|
<input type="search" id="console-filter" data-i18n-placeholder="console.filter" class="input-sm">
|
||||||
|
<button class="btn btn-ghost btn-sm" id="btn-console-clear" data-i18n="console.clear"></button>
|
||||||
</div>
|
</div>
|
||||||
<pre id="console-output" class="console"></pre>
|
<pre id="console-output" class="console"></pre>
|
||||||
<form class="console-input" id="console-form">
|
<form class="console-input" id="console-form">
|
||||||
<input type="text" id="console-command" placeholder="Команда сервера, например: players, save, quit"
|
<input type="text" id="console-command" data-i18n-placeholder="console.placeholder"
|
||||||
autocomplete="off" spellcheck="false" disabled>
|
autocomplete="off" spellcheck="false" disabled>
|
||||||
<button class="btn btn-primary" type="submit" disabled>Отправить</button>
|
<button class="btn btn-primary" type="submit" data-i18n="console.send" disabled></button>
|
||||||
</form>
|
</form>
|
||||||
<p class="muted small">Полезные команды: <code>players</code>, <code>save</code>, <code>servermsg "текст"</code>,
|
<p class="muted small" data-i18n-html="console.help"></p>
|
||||||
<code>kickuser "ник"</code>, <code>banuser "ник"</code>, <code>checkModsNeedUpdate</code>.</p>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Конфиг сервера -->
|
<!-- Конфиг сервера -->
|
||||||
<section class="panel" data-panel="config">
|
<section class="panel" data-panel="config">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<div>
|
<div>
|
||||||
<h2>Конфигурация сервера</h2>
|
<h2 data-i18n="config.title"></h2>
|
||||||
<p class="muted small" id="config-path"></p>
|
<p class="muted small" id="config-path"></p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<input type="search" id="config-filter" placeholder="Поиск параметра…" class="input-sm">
|
<input type="search" id="config-filter" data-i18n-placeholder="config.search" class="input-sm">
|
||||||
<button class="btn btn-primary" id="btn-config-save" disabled>Сохранить</button>
|
<button class="btn btn-primary" id="btn-config-save" data-i18n="btn.save" disabled></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted" id="config-empty" hidden></p>
|
<p class="muted" id="config-empty" hidden></p>
|
||||||
<div id="config-fields" class="fields"></div>
|
<div id="config-fields"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Песочница -->
|
<!-- Песочница -->
|
||||||
<section class="panel" data-panel="sandbox">
|
<section class="panel" data-panel="sandbox">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<div>
|
<div>
|
||||||
<h2>Настройки песочницы</h2>
|
<h2 data-i18n="sandbox.title"></h2>
|
||||||
<p class="muted small" id="sandbox-path"></p>
|
<p class="muted small" id="sandbox-path"></p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<input type="search" id="sandbox-filter" placeholder="Поиск параметра…" class="input-sm">
|
<input type="search" id="sandbox-filter" data-i18n-placeholder="config.search" class="input-sm">
|
||||||
<button class="btn btn-primary" id="btn-sandbox-save" disabled>Сохранить</button>
|
<button class="btn btn-primary" id="btn-sandbox-save" data-i18n="btn.save" disabled></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted" id="sandbox-empty" hidden></p>
|
<p class="muted" id="sandbox-empty" hidden></p>
|
||||||
<div id="sandbox-fields" class="fields"></div>
|
<div id="sandbox-fields"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Моды -->
|
<!-- Моды -->
|
||||||
<section class="panel" data-panel="mods">
|
<section class="panel" data-panel="mods">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<div>
|
<div>
|
||||||
<h2>Моды</h2>
|
<h2 data-i18n="mods.title"></h2>
|
||||||
<p class="muted small">Галочка включает мод, перетаскивание задаёт порядок загрузки.
|
<p class="muted small" data-i18n="mods.subtitle"></p>
|
||||||
Сервер сам скачивает моды из мастерской при запуске.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary" id="btn-mods-save" disabled>Сохранить</button>
|
<button class="btn btn-primary" id="btn-mods-save" data-i18n="btn.save" disabled></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="muted" id="mods-empty" hidden></p>
|
<p class="muted" id="mods-empty" hidden></p>
|
||||||
|
|
||||||
<div id="mods-body">
|
<div id="mods-body">
|
||||||
<form class="mod-add" id="mod-add-form">
|
<form class="mod-add" id="mod-add-form">
|
||||||
<input type="text" id="mod-input" autocomplete="off"
|
<input type="text" id="mod-input" autocomplete="off" data-i18n-placeholder="mods.inputPlaceholder">
|
||||||
placeholder="Ссылка на мод или коллекцию, либо просто ID">
|
<button class="btn" type="submit" data-i18n="mods.add"></button>
|
||||||
<button class="btn" type="submit">Добавить мод</button>
|
<button class="btn" type="button" id="btn-add-collection" data-i18n="mods.addCollection"></button>
|
||||||
<button class="btn" type="button" id="btn-add-collection">Добавить коллекцию</button>
|
|
||||||
</form>
|
</form>
|
||||||
<p class="muted small">Коллекция разворачивается в список входящих в неё модов.
|
<p class="muted small" data-i18n="mods.collectionHint"></p>
|
||||||
Сами файлы сервер скачивает сам при запуске — после этого моды можно включать галочками.</p>
|
|
||||||
|
|
||||||
<div class="mod-toolbar">
|
<div class="mod-toolbar">
|
||||||
<span class="muted small" id="mod-summary"></span>
|
<span class="muted small" id="mod-summary"></span>
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
<input type="search" id="mod-filter" class="input-sm" placeholder="Поиск по названию…">
|
<input type="search" id="mod-filter" class="input-sm" data-i18n-placeholder="mods.search">
|
||||||
<label class="checkbox"><input type="checkbox" id="mod-only-enabled"> Только включённые</label>
|
<label class="checkbox"><input type="checkbox" id="mod-only-enabled">
|
||||||
|
<span data-i18n="mods.onlyEnabled"></span></label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul class="mod-list" id="mod-list"></ul>
|
<ul class="mod-list" id="mod-list"></ul>
|
||||||
|
|
||||||
<h3>Карты (Map)</h3>
|
<h3 data-i18n="mods.listTitle"></h3>
|
||||||
<textarea id="map-list" rows="3" spellcheck="false"
|
<p class="muted small" data-i18n="mods.listHint"></p>
|
||||||
placeholder="Например: Muldraugh, KY"></textarea>
|
<form class="mod-add" id="mod-list-form">
|
||||||
<p class="muted small">Каждая карта с новой строки. Первой должна идти карта мода, последней — базовая.</p>
|
<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>
|
||||||
|
<textarea id="map-list" rows="3" spellcheck="false" data-i18n-placeholder="mods.mapsPlaceholder"></textarea>
|
||||||
|
<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>
|
||||||
|
|
||||||
@@ -211,17 +235,23 @@
|
|||||||
<section class="panel" data-panel="backups">
|
<section class="panel" data-panel="backups">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<div>
|
<div>
|
||||||
<h2>Резервные копии</h2>
|
<h2 data-i18n="backups.title"></h2>
|
||||||
<p class="muted small" id="backup-dir"></p>
|
<p class="muted small" id="backup-dir"></p>
|
||||||
<p class="muted small">Бэкап снимается с активного профиля: его мир и конфиги.</p>
|
<p class="muted small" data-i18n="backups.note"></p>
|
||||||
</div>
|
</div>
|
||||||
<form id="backup-form">
|
<form id="backup-form">
|
||||||
<input type="text" id="backup-note" placeholder="Пометка (необязательно)" class="input-sm">
|
<input type="text" id="backup-note" data-i18n-placeholder="backups.notePlaceholder" class="input-sm">
|
||||||
<button class="btn btn-primary" type="submit">Создать бэкап</button>
|
<button class="btn btn-primary" type="submit" data-i18n="backups.create"></button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead><tr><th>Архив</th><th>Профиль</th><th>Размер</th><th>Создан</th><th></th></tr></thead>
|
<thead><tr>
|
||||||
|
<th data-i18n="backups.colArchive"></th>
|
||||||
|
<th data-i18n="backups.colProfile"></th>
|
||||||
|
<th data-i18n="backups.colSize"></th>
|
||||||
|
<th data-i18n="backups.colCreated"></th>
|
||||||
|
<th></th>
|
||||||
|
</tr></thead>
|
||||||
<tbody id="backup-rows"></tbody>
|
<tbody id="backup-rows"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
@@ -230,74 +260,125 @@
|
|||||||
<section class="panel" data-panel="profiles">
|
<section class="panel" data-panel="profiles">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<div>
|
<div>
|
||||||
<h2>Профили серверов</h2>
|
<h2 data-i18n="profiles.title"></h2>
|
||||||
<p class="muted small">У каждого профиля свой мир, конфиг, песочница и набор модов.
|
<p class="muted small" data-i18n="profiles.subtitle"></p>
|
||||||
Одновременно работает один — переключать можно на остановленном сервере.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="profile-cards" class="cards"></div>
|
<div id="profile-cards" class="cards"></div>
|
||||||
|
|
||||||
<h3>Новый профиль</h3>
|
<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>
|
||||||
<form id="profile-add-form" class="fields">
|
<form id="profile-add-form" class="fields">
|
||||||
<div class="cfg-field">
|
<div class="cfg-field">
|
||||||
<label for="new-profile-id">Идентификатор</label>
|
<label for="new-profile-id" data-i18n="profiles.id"></label>
|
||||||
<input type="text" id="new-profile-id" placeholder="modded" autocomplete="off">
|
<input type="text" id="new-profile-id" placeholder="modded" autocomplete="off">
|
||||||
<div class="hint">Латиница, цифры, дефис. Станет именем конфига и папки мира.</div>
|
<div class="hint" data-i18n="profiles.idHint"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="cfg-field">
|
<div class="cfg-field">
|
||||||
<label for="new-profile-title">Название</label>
|
<label for="new-profile-title" data-i18n="profiles.name"></label>
|
||||||
<input type="text" id="new-profile-title" placeholder="Сборка с модами" autocomplete="off">
|
<input type="text" id="new-profile-title" data-i18n-placeholder="profiles.namePlaceholder" autocomplete="off">
|
||||||
<div class="hint">Как профиль подписан в панели.</div>
|
<div class="hint" data-i18n="profiles.nameHint"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="cfg-field">
|
<div class="cfg-field">
|
||||||
<label for="new-profile-memory">Память JVM</label>
|
<label for="new-profile-memory" data-i18n="profiles.memory"></label>
|
||||||
<input type="text" id="new-profile-memory" placeholder="8g" autocomplete="off">
|
<input type="text" id="new-profile-memory" placeholder="8g" autocomplete="off">
|
||||||
<div class="hint">Применяется при запуске этого профиля.</div>
|
<div class="hint" data-i18n="profiles.memoryHint"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="cfg-field">
|
<div class="cfg-field">
|
||||||
<label for="new-profile-copy">Скопировать настройки из</label>
|
<label for="new-profile-copy" data-i18n="profiles.copyFrom"></label>
|
||||||
<select id="new-profile-copy"></select>
|
<select id="new-profile-copy"></select>
|
||||||
<div class="hint">Копирует .ini и настройки песочницы. Мир останется новым.</div>
|
<div class="hint" data-i18n="profiles.copyHint"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="cfg-field">
|
<div class="cfg-field">
|
||||||
<button class="btn btn-primary" type="submit">Создать профиль</button>
|
<button class="btn btn-primary" type="submit" data-i18n="profiles.create"></button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Настройки -->
|
<!-- Настройки -->
|
||||||
<section class="panel" data-panel="settings">
|
<section class="panel" data-panel="settings">
|
||||||
<h2>Версия панели</h2>
|
<h2 data-i18n="settings.versionTitle"></h2>
|
||||||
<div class="card update-card">
|
<div class="card update-card">
|
||||||
<div class="update-row">
|
<div class="update-row">
|
||||||
<div>
|
<div>
|
||||||
<div class="stat" id="update-current">—</div>
|
<div class="stat" id="update-current">—</div>
|
||||||
<p class="muted small" id="update-state">Проверяю обновления…</p>
|
<p class="muted small" id="update-state" data-i18n="update.checking"></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="update-actions">
|
<div class="update-actions">
|
||||||
<button class="btn btn-sm" id="btn-update-check">Проверить</button>
|
<button class="btn btn-sm" id="btn-update-check" data-i18n="update.check"></button>
|
||||||
<button class="btn btn-sm btn-primary" id="btn-update-apply" hidden>Обновить</button>
|
<button class="btn btn-sm btn-primary" id="btn-update-apply" data-i18n="update.apply" hidden></button>
|
||||||
<button class="btn btn-sm" id="btn-manager-restart">Перезапустить панель</button>
|
<button class="btn btn-sm" id="btn-manager-restart" data-i18n="update.restart"></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted small" id="update-manual" hidden></p>
|
<p class="muted small" id="update-manual" hidden></p>
|
||||||
<details id="update-notes" hidden>
|
<details id="update-notes" hidden>
|
||||||
<summary>Что нового</summary>
|
<summary data-i18n="update.whatsNew"></summary>
|
||||||
<div class="release-notes" id="update-notes-body"></div>
|
<div class="release-notes" id="update-notes-body"></div>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2>Настройки менеджера</h2>
|
<h2 data-i18n="settings.title"></h2>
|
||||||
<form id="settings-form" class="fields"></form>
|
<form id="settings-form" class="fields"></form>
|
||||||
<button class="btn btn-primary" id="btn-settings-save" disabled>Сохранить настройки</button>
|
<button class="btn btn-primary" id="btn-settings-save" data-i18n="settings.save" disabled></button>
|
||||||
|
|
||||||
<h2>Пользователи панели</h2>
|
<h2 data-i18n="settings.usersTitle"></h2>
|
||||||
<ul class="user-list" id="user-list"></ul>
|
<ul class="user-list" id="user-list"></ul>
|
||||||
<form id="user-add-form" class="mod-add">
|
<form id="user-add-form" class="mod-add">
|
||||||
<input type="text" id="new-user-login" placeholder="Логин" autocomplete="off">
|
<input type="text" id="new-user-login" data-i18n-placeholder="users.loginPlaceholder" autocomplete="off">
|
||||||
<input type="password" id="new-user-password" placeholder="Пароль (минимум 8 символов)" autocomplete="new-password">
|
<input type="password" id="new-user-password" data-i18n-placeholder="users.passwordPlaceholder"
|
||||||
<button class="btn" type="submit">Добавить пользователя</button>
|
autocomplete="new-password">
|
||||||
|
<button class="btn" type="submit" data-i18n="users.add"></button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -305,6 +386,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="toasts" class="toasts"></div>
|
<div id="toasts" class="toasts"></div>
|
||||||
|
<script src="/i18n.js"></script>
|
||||||
<script src="/app.js"></script>
|
<script src="/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+104
-2
@@ -108,7 +108,11 @@ code {
|
|||||||
background: var(--muted);
|
background: var(--muted);
|
||||||
}
|
}
|
||||||
.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 { 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; } }
|
||||||
@@ -267,6 +271,40 @@ input.input-sm, .input-sm { width: auto; padding: 6px 10px; font-size: 13px; }
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cfg-group {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg-soft);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cfg-group > summary {
|
||||||
|
padding: 11px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
user-select: none;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.cfg-group > summary::-webkit-details-marker { display: none; }
|
||||||
|
.cfg-group > summary::before {
|
||||||
|
content: "▸";
|
||||||
|
display: inline-block;
|
||||||
|
width: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.cfg-group[open] > summary::before { content: "▾"; }
|
||||||
|
.cfg-group > summary:hover { background: var(--bg-card); border-radius: var(--radius); }
|
||||||
|
|
||||||
|
.cfg-count {
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cfg-group > .fields { padding: 0 14px 14px; }
|
||||||
|
|
||||||
.cfg-field {
|
.cfg-field {
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -275,13 +313,27 @@ input.input-sm, .input-sm { width: auto; padding: 6px 10px; font-size: 13px; }
|
|||||||
}
|
}
|
||||||
.cfg-field.changed { border-color: var(--accent-dark); }
|
.cfg-field.changed { border-color: var(--accent-dark); }
|
||||||
.cfg-field label { display: block; font-size: 13px; margin-bottom: 6px; }
|
.cfg-field label { display: block; font-size: 13px; margin-bottom: 6px; }
|
||||||
.cfg-field .hint { color: var(--muted); font-size: 12px; margin-top: 5px; }
|
.cfg-field .hint {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 5px;
|
||||||
|
/* Описания из конфига бывают в абзац: показываем начало, остальное — в
|
||||||
|
подсказке при наведении. */
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
.cfg-field .key { color: var(--muted); font-family: var(--mono); font-size: 11px; }
|
.cfg-field .key { color: var(--muted); font-family: var(--mono); font-size: 11px; }
|
||||||
|
|
||||||
/* ---------- Моды ---------- */
|
/* ---------- Моды ---------- */
|
||||||
|
|
||||||
.mod-add { display: flex; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; }
|
.mod-add { 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; }
|
||||||
|
|
||||||
@@ -477,6 +529,15 @@ input.input-sm, .input-sm { width: auto; padding: 6px 10px; font-size: 13px; }
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.release-version {
|
||||||
|
margin: 14px 0 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--accent);
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
.release-version:first-child { margin-top: 0; }
|
||||||
|
|
||||||
.release-notes ul { margin: 0; padding-left: 20px; }
|
.release-notes ul { margin: 0; padding-left: 20px; }
|
||||||
.release-notes li { margin-bottom: 5px; }
|
.release-notes li { margin-bottom: 5px; }
|
||||||
.release-notes li:last-child { margin-bottom: 0; }
|
.release-notes li:last-child { margin-bottom: 0; }
|
||||||
@@ -494,3 +555,44 @@ input.input-sm, .input-sm { width: auto; padding: 6px 10px; font-size: 13px; }
|
|||||||
#update-manual { font-family: var(--mono); word-break: break-all; }
|
#update-manual { font-family: var(--mono); word-break: break-all; }
|
||||||
|
|
||||||
.update-available { color: var(--accent); }
|
.update-available { color: var(--accent); }
|
||||||
|
|
||||||
|
/* Переключатель языка: в шапке — только список, на экране входа — с подписью. */
|
||||||
|
.lang-picker {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.lang-picker select {
|
||||||
|
width: auto;
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.lang-picker-auth {
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 18px;
|
||||||
|
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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user