Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08908bede8 | ||
|
|
3dc8fdae05 | ||
|
|
1e0b0a8d2b | ||
|
|
41c7cb9bb0 | ||
|
|
e25294db16 | ||
|
|
a8d86e0c48 |
@@ -1,5 +1,6 @@
|
||||
# PZ Manager
|
||||
|
||||
[](https://gitea.hsrv.site/mrleo1nid/pzmanager/releases/latest)
|
||||
[](LICENSE)
|
||||
[](https://go.dev/)
|
||||
[](https://ubuntu.com/)
|
||||
@@ -84,28 +85,31 @@ curl -fsSL https://gitea.hsrv.site/mrleo1nid/pzmanager/raw/branch/main/deploy/ge
|
||||
```
|
||||
|
||||
**Обновление** — та же команда или кнопка в панели (вкладка «Настройки» →
|
||||
«Версия панели»). Конфиг, миры и учётные записи не трогаются, меняется только
|
||||
бинарник.
|
||||
«Версия панели»). Конфиг, миры и учётные записи не трогаются. Повторный запуск
|
||||
с той же версией службу не трогает вовсе: если бинарник не изменился,
|
||||
установщик не станет ради него ронять игровой сервер.
|
||||
|
||||
Дальше:
|
||||
|
||||
1. Посмотрите код первичной настройки:
|
||||
|
||||
```bash
|
||||
journalctl -u pzmanager -n 30 --no-pager
|
||||
```
|
||||
|
||||
2. Откройте панель в браузере и создайте администратора, введя этот код.
|
||||
Если панель слушает localhost, пробросьте порт:
|
||||
1. Откройте панель в браузере и создайте администратора, введя код первичной
|
||||
настройки — установщик печатает его последним сообщением. Если панель
|
||||
слушает localhost, пробросьте порт:
|
||||
|
||||
```bash
|
||||
ssh -L 8080:127.0.0.1:8080 пользователь@сервер
|
||||
```
|
||||
|
||||
3. Во вкладке «Обзор» нажмите **Установить / обновить через SteamCMD** и
|
||||
Код одноразовый и перестаёт работать сразу после создания администратора.
|
||||
Если вывод установщика потерялся, код есть в журнале:
|
||||
|
||||
```bash
|
||||
journalctl -u pzmanager -n 30 --no-pager
|
||||
```
|
||||
|
||||
2. Во вкладке «Обзор» нажмите **Установить / обновить через SteamCMD** и
|
||||
дождитесь окончания — прогресс виден во вкладке «Консоль».
|
||||
|
||||
4. Запустите сервер. При первом запуске PZ создаст `Zomboid/Server/*.ini` —
|
||||
3. Запустите сервер. При первом запуске PZ создаст `Zomboid/Server/*.ini` —
|
||||
после этого станут доступны вкладки «Конфиг сервера», «Песочница» и «Моды».
|
||||
|
||||
Порты, которые нужно открыть:
|
||||
@@ -131,6 +135,32 @@ make build-linux
|
||||
sudo PZ_LISTEN=10.10.1.142:8080 ./deploy/install.sh
|
||||
```
|
||||
|
||||
### Удаление
|
||||
|
||||
Убрать панель, оставив миры и настройки:
|
||||
|
||||
```bash
|
||||
sudo /opt/pzmanager/install.sh --uninstall
|
||||
```
|
||||
|
||||
Удалить всё вместе с мирами, бэкапами и учётными записями:
|
||||
|
||||
```bash
|
||||
sudo /opt/pzmanager/install.sh --uninstall --purge
|
||||
```
|
||||
|
||||
`--purge` спрашивает подтверждение; в неинтерактивном запуске нужен ещё и
|
||||
`--yes`. Каталог сносится, только если похож на установку панели — иначе
|
||||
опечатка в `PZ_HOME` стоила бы чужих файлов.
|
||||
|
||||
Установщик кладёт свою копию в `/opt/pzmanager/install.sh`, так что для
|
||||
удаления ничего скачивать не нужно. Если копии нет, те же аргументы принимает
|
||||
и установочная команда:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://gitea.hsrv.site/mrleo1nid/pzmanager/raw/branch/main/deploy/get.sh | sudo bash -s -- --uninstall --purge
|
||||
```
|
||||
|
||||
## Моды
|
||||
|
||||
Project Zomboid хранит моды в двух строках конфига: `WorkshopItems` — что
|
||||
@@ -206,9 +236,19 @@ Mod ID, который нужен серверу, лежит внутри сам
|
||||
pzserver ALL=(root) NOPASSWD: /usr/local/bin/pzmanager-admin
|
||||
```
|
||||
|
||||
Скрипт качает бинарник нужной архитектуры, сверяет sha256 с `checksums.txt`
|
||||
релиза, подменяет файл и перезапускает службу. Панель к этим файлам доступа не
|
||||
имеет — она может только попросить выполнить `update` или `restart`.
|
||||
Скрипт не повторяет логику установки, а забирает из релиза `get.sh` (сверив
|
||||
его sha256) и отдаёт работу ему — поэтому кнопка обновляет всё сразу: бинарник,
|
||||
скрипты установки и systemd-юнит. Панель к этим файлам доступа не имеет, она
|
||||
может только попросить выполнить `update` или `restart`.
|
||||
|
||||
Работа уходит в отдельный разовый юнит (`systemd-run --unit=pzmanager-update`).
|
||||
Иначе обновление не пережило бы само себя: панель вызывает скрипт через sudo,
|
||||
тот живёт в cgroup её юнита, и остановка службы убила бы его на середине.
|
||||
Побочный эффект — вывод идёт не в консоль панели, а в свой журнал:
|
||||
|
||||
```bash
|
||||
journalctl -u pzmanager-update -f
|
||||
```
|
||||
|
||||
Если правило sudo не установлено (панель поставлена вручную или в системе нет
|
||||
`sudo`), кнопка не появится, а рядом будет показана команда для терминала:
|
||||
|
||||
@@ -131,10 +131,19 @@ func serve(cfg *config.Config, users *auth.Store, secureCookies bool) error {
|
||||
// Пока учёток нет, создать первого администратора можно только с этим
|
||||
// кодом — иначе панель захватит любой, кто откроет её первым.
|
||||
setupToken := ""
|
||||
setupTokenFile := filepath.Join(cfg.DataDir, "setup-token")
|
||||
if users.IsEmpty() {
|
||||
setupToken = randomToken()
|
||||
// Код кладём и в файл: установщик читает его оттуда и показывает сразу
|
||||
// после установки, не заставляя идти в журнал.
|
||||
if err := os.WriteFile(setupTokenFile, []byte(setupToken+"\n"), 0o600); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "не удалось записать %s: %v\n", setupTokenFile, err)
|
||||
}
|
||||
fmt.Printf("\n Панель ещё не настроена.\n"+
|
||||
" Откройте её в браузере и введите код первичной настройки:\n\n %s\n\n", setupToken)
|
||||
} else {
|
||||
// Панель уже настроена: файл мог остаться от прошлой установки.
|
||||
os.Remove(setupTokenFile)
|
||||
}
|
||||
|
||||
apiServer := api.New(api.Options{
|
||||
@@ -148,6 +157,7 @@ func serve(cfg *config.Config, users *auth.Store, secureCookies bool) error {
|
||||
Static: static,
|
||||
SecureCookies: secureCookies,
|
||||
SetupToken: setupToken,
|
||||
SetupTokenFile: setupTokenFile,
|
||||
Repo: repoURL(),
|
||||
})
|
||||
|
||||
|
||||
+37
-5
@@ -7,6 +7,15 @@
|
||||
#
|
||||
# curl -fsSL .../get.sh | sudo PZ_LISTEN=10.10.1.142:8080 bash
|
||||
#
|
||||
# Удаление (аргументы уходят установщику как есть):
|
||||
#
|
||||
# curl -fsSL .../get.sh | sudo bash -s -- --uninstall
|
||||
# curl -fsSL .../get.sh | sudo bash -s -- --uninstall --purge
|
||||
#
|
||||
# Если панель уже установлена, то же самое умеет её локальная копия:
|
||||
#
|
||||
# sudo /opt/pzmanager/install.sh --uninstall --purge
|
||||
#
|
||||
# Переменные окружения:
|
||||
# PZ_VERSION какой тег ставить (по умолчанию последний релиз)
|
||||
# PZ_HOME куда ставить (по умолчанию /opt/pzmanager)
|
||||
@@ -29,6 +38,19 @@ die() { echo "Ошибка: $*" >&2; exit 1; }
|
||||
|
||||
[[ $EUID -eq 0 ]] || die "запустите с правами root (sudo)"
|
||||
|
||||
# Аргументы уходят установщику. Для удаления бинарник качать незачем — это
|
||||
# лишние восемь мегабайт ради скрипта, который его же и сотрёт.
|
||||
ARGS=("$@")
|
||||
NEED_BINARY=1
|
||||
# Идиома ${ARGS[@]+...} нужна из-за set -u: обычная "${ARGS[@]:-}" на пустом
|
||||
# массиве разворачивается в один пустой аргумент, и установщик спотыкается о
|
||||
# него при обычной установке.
|
||||
for arg in ${ARGS[@]+"${ARGS[@]}"}; do
|
||||
case "$arg" in
|
||||
--uninstall | --remove | --purge) NEED_BINARY=0 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
for tool in curl sha256sum; do
|
||||
command -v "$tool" >/dev/null 2>&1 || die "не найдена утилита $tool"
|
||||
done
|
||||
@@ -68,10 +90,13 @@ asset_url() {
|
||||
}
|
||||
|
||||
BIN_NAME="pzmanager-linux-${ARCH}"
|
||||
if [[ "$NEED_BINARY" == "1" ]]; then
|
||||
BIN_URL="$(asset_url "$BIN_NAME")"
|
||||
[[ -n "$BIN_URL" ]] || die "в релизе ${TAG} нет файла ${BIN_NAME}"
|
||||
|
||||
echo "==> Ставлю ${TAG} (${ARCH})"
|
||||
else
|
||||
echo "==> Беру установщик из ${TAG} для удаления"
|
||||
fi
|
||||
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
@@ -91,13 +116,17 @@ download() {
|
||||
curl -fsSL -o "$name" "$url" || die "не удалось скачать ${name}"
|
||||
}
|
||||
|
||||
download "$BIN_NAME"
|
||||
download install.sh
|
||||
download pzmanager.service
|
||||
FILES=(install.sh pzmanager.service)
|
||||
|
||||
if [[ "$NEED_BINARY" == "1" ]]; then
|
||||
download "$BIN_NAME"
|
||||
FILES+=("$BIN_NAME")
|
||||
fi
|
||||
|
||||
# Скрипт привилегированных действий появился не сразу: со старым релизом
|
||||
# ставим без него, панель просто не покажет кнопку обновления.
|
||||
FILES=("$BIN_NAME" install.sh pzmanager.service)
|
||||
if download pzmanager-admin.sh optional; then
|
||||
FILES+=(pzmanager-admin.sh)
|
||||
fi
|
||||
@@ -120,8 +149,11 @@ mv install.sh pzmanager.service deploy/
|
||||
if [[ -f pzmanager-admin.sh ]]; then
|
||||
mv pzmanager-admin.sh deploy/
|
||||
fi
|
||||
chmod +x deploy/install.sh
|
||||
if [[ "$NEED_BINARY" == "1" ]]; then
|
||||
mv "$BIN_NAME" pzmanager
|
||||
chmod +x pzmanager deploy/install.sh
|
||||
chmod +x pzmanager
|
||||
fi
|
||||
|
||||
echo "==> Запускаю установщик"
|
||||
BINARY="$TMP/pzmanager" bash deploy/install.sh
|
||||
BINARY="$TMP/pzmanager" bash deploy/install.sh ${ARGS[@]+"${ARGS[@]}"}
|
||||
|
||||
+176
-9
@@ -1,10 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# Установка и обновление pzmanager на Ubuntu/Debian. Запускать от root:
|
||||
# Установка, обновление и удаление pzmanager на Ubuntu/Debian. От root:
|
||||
#
|
||||
# sudo ./deploy/install.sh
|
||||
# sudo ./deploy/install.sh установить или обновить
|
||||
# sudo ./deploy/install.sh --uninstall убрать панель, данные оставить
|
||||
# sudo ./deploy/install.sh --uninstall --purge убрать вместе с мирами
|
||||
#
|
||||
# Скрипт идемпотентен: на уже настроенной машине он обновляет бинарник и
|
||||
# юнит, не трогая конфиг, миры и учётные записи панели.
|
||||
# Установка идемпотентна: на уже настроенной машине обновляются бинарник и
|
||||
# юнит, а конфиг, миры и учётные записи панели остаются нетронутыми.
|
||||
#
|
||||
# Переменные окружения:
|
||||
# PZ_HOME куда ставить всё хозяйство (по умолчанию /opt/pzmanager)
|
||||
@@ -29,11 +31,96 @@ ADMIN_SRC="${SCRIPT_DIR}/pzmanager-admin.sh"
|
||||
TARGET_BINARY=/usr/local/bin/pzmanager
|
||||
ADMIN_BINARY=/usr/local/bin/pzmanager-admin
|
||||
|
||||
UNINSTALL=0
|
||||
PURGE=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--uninstall | --remove) UNINSTALL=1 ;;
|
||||
--purge) UNINSTALL=1; PURGE=1 ;;
|
||||
--yes | -y) PZ_ASSUME_YES=1 ;;
|
||||
-h | --help)
|
||||
# Справка — это шапка файла: печатаем комментарии со второй строки и
|
||||
# останавливаемся, как только они кончились.
|
||||
awk 'NR > 1 && /^#/ { sub(/^# ?/, ""); print; next } NR > 1 { exit }' "$0"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Неизвестный аргумент: $arg" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo "Запустите скрипт от root: sudo $0" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# uninstall снимает панель с машины. Данные (миры, бэкапы, учётки) удаляются
|
||||
# только по --purge: потерять сохранения из-за неудачно набранной команды
|
||||
# гораздо обиднее, чем лишний раз убрать каталог руками.
|
||||
uninstall() {
|
||||
echo "==> Удаляю pzmanager"
|
||||
|
||||
if systemctl list-unit-files pzmanager.service &>/dev/null &&
|
||||
systemctl is-active --quiet pzmanager; then
|
||||
echo "==> Останавливаю службу (игровой сервер завершится штатно, это небыстро)"
|
||||
systemctl stop pzmanager || true
|
||||
fi
|
||||
systemctl disable --quiet pzmanager 2>/dev/null || true
|
||||
rm -f /etc/systemd/system/pzmanager.service
|
||||
systemctl daemon-reload
|
||||
systemctl reset-failed pzmanager 2>/dev/null || true
|
||||
|
||||
rm -f /usr/local/bin/pzmanager /usr/local/bin/pzmanager-admin /etc/sudoers.d/pzmanager
|
||||
echo "Служба, бинарники и правило sudo удалены."
|
||||
|
||||
if [[ "$PURGE" != "1" ]]; then
|
||||
echo
|
||||
echo "Данные остались в ${PZ_HOME} (миры, бэкапы, конфиг, учётные записи)."
|
||||
echo "Удалить и их: sudo $0 --uninstall --purge"
|
||||
return
|
||||
fi
|
||||
|
||||
# Защита от опечатки в PZ_HOME: сносим только то, что похоже на нашу
|
||||
# установку, и никогда — корень или домашние каталоги.
|
||||
local home_clean="${PZ_HOME%/}"
|
||||
if [[ -z "$home_clean" || "$home_clean" == "/" || "$home_clean" != /* ]]; then
|
||||
echo "!! PZ_HOME=${PZ_HOME} не похож на каталог установки — не трогаю" >&2
|
||||
return
|
||||
fi
|
||||
if [[ ! -e "$home_clean/config.yaml" && ! -d "$home_clean/data" ]]; then
|
||||
echo "!! В ${home_clean} нет ни config.yaml, ни data/ — не похоже на установку pzmanager," >&2
|
||||
echo " поэтому каталог оставлен нетронутым." >&2
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ "${PZ_ASSUME_YES:-0}" != "1" ]]; then
|
||||
echo
|
||||
echo "Будет безвозвратно удалено: ${home_clean}"
|
||||
echo "Это миры, бэкапы, конфиг и учётные записи панели."
|
||||
if [[ -t 0 ]]; then
|
||||
read -r -p "Введите YES для подтверждения: " answer
|
||||
[[ "$answer" == "YES" ]] || { echo "Отменено."; return; }
|
||||
else
|
||||
echo "Запуск неинтерактивный — повторите с флагом --yes, если уверены." >&2
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
rm -rf "$home_clean"
|
||||
if id "$PZ_USER" &>/dev/null; then
|
||||
deluser --system "$PZ_USER" &>/dev/null || userdel "$PZ_USER" &>/dev/null || true
|
||||
echo "Пользователь ${PZ_USER} удалён."
|
||||
fi
|
||||
echo "Удалено полностью: ${home_clean}"
|
||||
}
|
||||
|
||||
if [[ "$UNINSTALL" == "1" ]]; then
|
||||
uninstall
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ ! -f "$BINARY" ]]; then
|
||||
echo "Не найден бинарник $BINARY. Соберите его: make build-linux" >&2
|
||||
exit 1
|
||||
@@ -118,17 +205,29 @@ EOF
|
||||
chmod 0640 "$CONFIG"
|
||||
fi
|
||||
|
||||
# Если ставим ровно то же самое, что уже лежит, службу трогать незачем: её
|
||||
# остановка кладёт и игровой сервер, а повторный запуск установщика — обычное
|
||||
# дело (например, чтобы обновить скрипты).
|
||||
SAME_BINARY=0
|
||||
if [[ "$MODE" == "update" ]] &&
|
||||
[[ "$(sha256sum "$BINARY" | cut -d' ' -f1)" == "$(sha256sum "$TARGET_BINARY" | cut -d' ' -f1)" ]]; then
|
||||
SAME_BINARY=1
|
||||
echo "==> Бинарник не изменился — службу не останавливаю"
|
||||
fi
|
||||
|
||||
# Останавливаем сервис перед заменой бинарника: менеджер должен корректно
|
||||
# завершить игровой сервер, а не быть подменённым на ходу.
|
||||
RESTART_AFTER=0
|
||||
if [[ "$MODE" == "update" ]] && systemctl is-active --quiet pzmanager; then
|
||||
if [[ "$MODE" == "update" && "$SAME_BINARY" == "0" ]] && systemctl is-active --quiet pzmanager; then
|
||||
echo "==> Останавливаю pzmanager (игровой сервер завершится штатно)"
|
||||
systemctl stop pzmanager
|
||||
RESTART_AFTER=1
|
||||
fi
|
||||
|
||||
if [[ "$SAME_BINARY" == "0" ]]; then
|
||||
echo "==> Копирую бинарник в $TARGET_BINARY"
|
||||
install -m 0755 "$BINARY" "$TARGET_BINARY"
|
||||
fi
|
||||
|
||||
# Панель работает от непривилегированного пользователя и не может ни заменить
|
||||
# бинарник, ни дёрнуть systemctl. Даём ей ровно одну команду через sudo.
|
||||
@@ -136,6 +235,8 @@ if [[ -f "$ADMIN_SRC" ]] && command -v sudo >/dev/null 2>&1; then
|
||||
echo "==> Ставлю $ADMIN_BINARY и правило sudo"
|
||||
sed -e "s|@PZ_REPO@|${PZ_REPO}|" \
|
||||
-e "s|@PZ_BINARY@|${TARGET_BINARY}|" \
|
||||
-e "s|@PZ_HOME@|${PZ_HOME}|" \
|
||||
-e "s|@PZ_USER@|${PZ_USER}|" \
|
||||
"$ADMIN_SRC" > "$ADMIN_BINARY"
|
||||
chown root:root "$ADMIN_BINARY"
|
||||
chmod 0755 "$ADMIN_BINARY"
|
||||
@@ -152,6 +253,14 @@ else
|
||||
echo "!! $ADMIN_SRC не найден или нет sudo — обновление из панели будет недоступно" >&2
|
||||
fi
|
||||
|
||||
# Кладём сам установщик рядом с данными: удалить или переустановить панель
|
||||
# можно будет одной локальной командой, без похода в репозиторий.
|
||||
install -m 0755 "$0" "$PZ_HOME/install.sh"
|
||||
if [[ -f "$ADMIN_SRC" ]]; then
|
||||
install -m 0755 "$ADMIN_SRC" "$PZ_HOME/pzmanager-admin.sh"
|
||||
fi
|
||||
install -m 0644 "$UNIT_SRC" "$PZ_HOME/pzmanager.service"
|
||||
|
||||
echo "==> Ставлю systemd-юнит"
|
||||
sed -e "s|/opt/pzmanager|${PZ_HOME}|g" \
|
||||
-e "s|User=pzserver|User=${PZ_USER}|" \
|
||||
@@ -161,10 +270,25 @@ sed -e "s|/opt/pzmanager|${PZ_HOME}|g" \
|
||||
systemctl daemon-reload
|
||||
systemctl enable --quiet pzmanager
|
||||
|
||||
# wait_active ждёт, пока служба поднимется, и говорит, чем всё кончилось:
|
||||
# молчаливый systemctl start оставлял пользователя гадать, работает ли панель.
|
||||
wait_active() {
|
||||
local i
|
||||
for i in $(seq 1 15); do
|
||||
if systemctl is-active --quiet pzmanager; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
if [[ "$MODE" == "update" ]]; then
|
||||
if [[ "$RESTART_AFTER" == "1" ]]; then
|
||||
echo "==> Запускаю pzmanager"
|
||||
systemctl start pzmanager
|
||||
fi
|
||||
|
||||
echo
|
||||
if [[ "$OLD_VERSION" == "$NEW_VERSION" ]]; then
|
||||
echo "Переустановлена версия ${NEW_VERSION}."
|
||||
@@ -172,17 +296,50 @@ if [[ "$MODE" == "update" ]]; then
|
||||
echo "Обновлено: ${OLD_VERSION} -> ${NEW_VERSION}."
|
||||
fi
|
||||
echo "Конфиг, миры и учётные записи не тронуты."
|
||||
|
||||
LISTEN_NOW="$(grep -E '^listen:' "$CONFIG" | head -1 | awk '{print $2}')"
|
||||
echo "Панель слушает ${LISTEN_NOW:-?}."
|
||||
if [[ "$RESTART_AFTER" == "1" ]]; then
|
||||
if wait_active; then
|
||||
echo "Панель работает и слушает ${LISTEN_NOW:-?}."
|
||||
else
|
||||
echo "!! Служба не поднялась. Посмотрите, что случилось:" >&2
|
||||
echo " journalctl -u pzmanager -n 40 --no-pager" >&2
|
||||
exit 1
|
||||
fi
|
||||
elif systemctl is-active --quiet pzmanager; then
|
||||
echo "Панель работает и слушает ${LISTEN_NOW:-?}."
|
||||
else
|
||||
# Службу останавливали не мы — значит и поднимать её самовольно не стоит.
|
||||
echo "Служба сейчас остановлена. Запустить: systemctl start pzmanager"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
systemctl start pzmanager
|
||||
# Дадим сервису секунду подняться, чтобы код настройки успел попасть в журнал.
|
||||
sleep 2
|
||||
|
||||
# Панель кладёт код первичной настройки в файл, как только поднимется. Ждём
|
||||
# его, чтобы показать код прямо здесь: иначе за ним пришлось бы идти в журнал.
|
||||
SETUP_TOKEN=""
|
||||
TOKEN_FILE="$PZ_HOME/data/setup-token"
|
||||
for _ in $(seq 1 20); do
|
||||
if [[ -s "$TOKEN_FILE" ]]; then
|
||||
SETUP_TOKEN="$(head -1 "$TOKEN_FILE")"
|
||||
break
|
||||
fi
|
||||
if ! systemctl is-active --quiet pzmanager; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo
|
||||
if wait_active; then
|
||||
echo "Готово. Панель запущена и слушает ${PZ_LISTEN}."
|
||||
else
|
||||
echo "!! Служба не поднялась. Посмотрите, что случилось:" >&2
|
||||
echo " journalctl -u pzmanager -n 40 --no-pager" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo
|
||||
if [[ "$PZ_LISTEN" == 127.0.0.1:* || "$PZ_LISTEN" == localhost:* ]]; then
|
||||
echo "Панель доступна только с самой машины. Пробросьте порт:"
|
||||
@@ -198,8 +355,18 @@ else
|
||||
echo " sudo ufw allow ${PORT}/tcp"
|
||||
echo
|
||||
fi
|
||||
echo "Код первичной настройки — в журнале:"
|
||||
if [[ -n "$SETUP_TOKEN" ]]; then
|
||||
echo "Код первичной настройки (введите его в браузере):"
|
||||
echo
|
||||
echo " ${SETUP_TOKEN}"
|
||||
echo
|
||||
echo "Код одноразовый: после создания администратора он перестаёт работать."
|
||||
else
|
||||
echo "Код первичной настройки не удалось прочитать. Посмотрите журнал:"
|
||||
echo
|
||||
echo " journalctl -u pzmanager -n 30 --no-pager"
|
||||
fi
|
||||
echo
|
||||
echo "Игровые порты сервера: udp/16261 и udp/16262."
|
||||
echo
|
||||
echo "Удалить панель целиком: sudo ${PZ_HOME}/install.sh --uninstall --purge"
|
||||
|
||||
+68
-34
@@ -11,7 +11,11 @@ set -euo pipefail
|
||||
|
||||
PZ_REPO="@PZ_REPO@"
|
||||
PZ_BINARY="@PZ_BINARY@"
|
||||
PZ_HOME="@PZ_HOME@"
|
||||
PZ_USER="@PZ_USER@"
|
||||
PZ_SERVICE="pzmanager"
|
||||
# Имя разового юнита, в котором идёт обновление.
|
||||
UPDATE_UNIT="pzmanager-update"
|
||||
|
||||
die() { echo "Ошибка: $*" >&2; exit 1; }
|
||||
|
||||
@@ -22,21 +26,57 @@ api_base() {
|
||||
printf '%s/api/v1/repos/%s' "$host" "${url#"$host"/}"
|
||||
}
|
||||
|
||||
# asset_url ищет в описании релиза ссылку на файл с точным именем.
|
||||
asset_url() {
|
||||
local release="$1" name="$2"
|
||||
printf '%s' "$release" \
|
||||
| grep -o '"browser_download_url" *: *"[^"]*"' \
|
||||
| cut -d'"' -f4 \
|
||||
| while read -r url; do
|
||||
if [[ "${url##*/}" == "$name" ]]; then
|
||||
printf '%s' "$url"
|
||||
return
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# cmd_update только запускает работу и сразу возвращает управление панели.
|
||||
#
|
||||
# Обновление обязано пережить перезапуск службы: установщик остановит
|
||||
# pzmanager, а вместе с ним systemd убьёт и этот скрипт — панель вызывает его
|
||||
# через sudo, так что он живёт в cgroup её юнита. Поэтому саму работу уносим
|
||||
# в отдельный разовый юнит, который к pzmanager отношения не имеет.
|
||||
cmd_update() {
|
||||
local version="${1:-latest}"
|
||||
local api release tag url name tmp arch
|
||||
# Версия приходит из панели: проверяем до запуска юнита, чтобы не плодить
|
||||
# заведомо провальные.
|
||||
[[ "$version" == "latest" || "$version" =~ ^[A-Za-z0-9._-]+$ ]] \
|
||||
|| die "недопустимая версия: $version"
|
||||
command -v systemd-run >/dev/null 2>&1 \
|
||||
|| die "нет systemd-run — обновите панель командой из README"
|
||||
|
||||
case "$(uname -m)" in
|
||||
x86_64 | amd64) arch=amd64 ;;
|
||||
aarch64 | arm64) arch=arm64 ;;
|
||||
*) die "архитектура $(uname -m) не поддерживается" ;;
|
||||
esac
|
||||
# --collect убирает юнит после завершения, в том числе неуспешного: иначе
|
||||
# следующий запуск упрётся в оставшийся failed-юнит с тем же именем.
|
||||
systemd-run --quiet --collect --unit="$UPDATE_UNIT" \
|
||||
--description="Обновление pzmanager" \
|
||||
"$0" do-update "$version" \
|
||||
|| die "не удалось запустить обновление разовым юнитом"
|
||||
|
||||
echo "Обновление запущено в фоне."
|
||||
echo "Ход работы: journalctl -u ${UPDATE_UNIT} -f"
|
||||
}
|
||||
|
||||
# cmd_do_update выполняется уже внутри разового юнита. Обновляет всё сразу:
|
||||
# бинарник, скрипты установки и systemd-юнит, — поэтому просто отдаёт работу
|
||||
# get.sh из релиза вместо того, чтобы повторять его логику.
|
||||
cmd_do_update() {
|
||||
local version="${1:-latest}" api release tag url tmp
|
||||
|
||||
api="$(api_base)"
|
||||
if [[ "$version" == "latest" ]]; then
|
||||
release="$(curl -fsSL "${api}/releases/latest")" || die "не удалось запросить релиз"
|
||||
else
|
||||
# Тег приходит из панели; всё, кроме безопасных символов, отвергаем.
|
||||
# Версия приходит из панели; всё, кроме безопасных символов, отвергаем.
|
||||
[[ "$version" =~ ^[A-Za-z0-9._-]+$ ]] || die "недопустимая версия: $version"
|
||||
release="$(curl -fsSL "${api}/releases/tags/${version}")" || die "релиз ${version} не найден"
|
||||
fi
|
||||
@@ -44,52 +84,46 @@ cmd_update() {
|
||||
tag="$(printf '%s' "$release" | grep -o '"tag_name" *: *"[^"]*"' | head -1 | cut -d'"' -f4)"
|
||||
[[ -n "$tag" ]] || die "в ответе нет тега"
|
||||
|
||||
name="pzmanager-linux-${arch}"
|
||||
url="$(printf '%s' "$release" \
|
||||
| grep -o '"browser_download_url" *: *"[^"]*"' | cut -d'"' -f4 \
|
||||
| while read -r candidate; do
|
||||
[[ "${candidate##*/}" == "$name" ]] && { printf '%s' "$candidate"; break; }
|
||||
done)"
|
||||
[[ -n "$url" ]] || die "в релизе ${tag} нет файла ${name}"
|
||||
|
||||
url="$(asset_url "$release" get.sh)"
|
||||
[[ -n "$url" ]] || die "в релизе ${tag} нет get.sh"
|
||||
local sums_url
|
||||
sums_url="$(printf '%s' "$release" \
|
||||
| grep -o '"browser_download_url" *: *"[^"]*"' | cut -d'"' -f4 \
|
||||
| while read -r candidate; do
|
||||
[[ "${candidate##*/}" == "checksums.txt" ]] && { printf '%s' "$candidate"; break; }
|
||||
done)"
|
||||
sums_url="$(asset_url "$release" checksums.txt)"
|
||||
[[ -n "$sums_url" ]] || die "в релизе ${tag} нет checksums.txt"
|
||||
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp"' RETURN
|
||||
|
||||
echo "==> Скачиваю ${tag} (${arch})"
|
||||
curl -fsSL -o "${tmp}/${name}" "$url" || die "не удалось скачать ${name}"
|
||||
echo "==> Беру установщик из ${tag}"
|
||||
curl -fsSL -o "${tmp}/get.sh" "$url" || die "не удалось скачать get.sh"
|
||||
curl -fsSL -o "${tmp}/checksums.txt" "$sums_url" || die "не удалось скачать checksums.txt"
|
||||
|
||||
echo "==> Проверяю контрольную сумму"
|
||||
( cd "$tmp" && grep " ${name}\$" checksums.txt > expected.txt && sha256sum -c expected.txt ) \
|
||||
|| die "контрольная сумма не сошлась — файл повреждён или подменён"
|
||||
( cd "$tmp" && awk '$NF == "get.sh"' checksums.txt > expected.txt \
|
||||
&& [[ -s expected.txt ]] && sha256sum -c expected.txt ) \
|
||||
|| die "контрольная сумма get.sh не сошлась"
|
||||
|
||||
echo "==> Заменяю ${PZ_BINARY}"
|
||||
chmod 0755 "${tmp}/${name}"
|
||||
# Через временный файл рядом и rename: перезаписать работающий бинарник
|
||||
# нельзя, а переименовать поверх — можно.
|
||||
install -m 0755 "${tmp}/${name}" "${PZ_BINARY}.new"
|
||||
mv -f "${PZ_BINARY}.new" "${PZ_BINARY}"
|
||||
echo "==> Обновляю до ${tag}"
|
||||
# get.sh скачает бинарник нужной архитектуры и запустит install.sh, который
|
||||
# сам остановит службу, заменит файлы и поднимет её обратно.
|
||||
PZ_HOME="$PZ_HOME" PZ_USER="$PZ_USER" PZ_REPO="$PZ_REPO" PZ_VERSION="$tag" \
|
||||
bash "${tmp}/get.sh" \
|
||||
|| die "обновление не удалось"
|
||||
|
||||
echo "==> Перезапускаю ${PZ_SERVICE}"
|
||||
systemctl restart "$PZ_SERVICE"
|
||||
echo "Обновлено до ${tag}"
|
||||
echo "Обновление до ${tag} завершено"
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
update)
|
||||
cmd_update "${2:-latest}"
|
||||
;;
|
||||
do-update)
|
||||
cmd_do_update "${2:-latest}"
|
||||
;;
|
||||
restart)
|
||||
echo "==> Перезапускаю ${PZ_SERVICE}"
|
||||
systemctl restart "$PZ_SERVICE"
|
||||
# --no-block: команда ставит задание и выходит, не дожидаясь остановки.
|
||||
# Иначе systemd убил бы нас вместе со службой на середине ожидания.
|
||||
systemctl restart --no-block "$PZ_SERVICE"
|
||||
;;
|
||||
version)
|
||||
"$PZ_BINARY" version
|
||||
|
||||
@@ -19,7 +19,11 @@ KillMode=mixed
|
||||
TimeoutStopSec=300
|
||||
|
||||
# Всё своё панель держит в /opt/pzmanager, домашние каталоги ей не нужны.
|
||||
NoNewPrivileges=true
|
||||
#
|
||||
# NoNewPrivileges здесь намеренно не выставлен: он запрещает процессу и его
|
||||
# потомкам повышать привилегии, а значит ломает sudo — и вместе с ним кнопки
|
||||
# обновления и перезапуска панели. Прикрытием служит sudoers: сервисному
|
||||
# пользователю разрешена ровно одна команда, pzmanager-admin.
|
||||
PrivateTmp=true
|
||||
ProtectSystem=full
|
||||
ProtectHome=true
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/mrleo1nid/pzmanager/internal/auth"
|
||||
@@ -55,6 +56,10 @@ func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
// Администратор создан — код больше не нужен и не должен лежать на диске.
|
||||
if s.setupTokenFile != "" {
|
||||
os.Remove(s.setupTokenFile)
|
||||
}
|
||||
s.setSessionCookie(w, id, sessionTTL)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"authenticated": true, "login": creds.Login})
|
||||
}
|
||||
|
||||
@@ -35,7 +35,8 @@ func (s *Server) handleApplyUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
// Игровой сервер останавливаем сами: иначе это сделает systemd уже в
|
||||
// момент перезапуска, и ожидание растянется на таймаут остановки.
|
||||
s.stopServerBefore(log, "обновлением")
|
||||
log("Запускаю обновление, панель перезапустится")
|
||||
log("Запускаю обновление — оно идёт отдельной службой и переживёт перезапуск панели")
|
||||
log("Подробности: journalctl -u pzmanager-update -f")
|
||||
return s.updater.Apply(ctx, log, body.Version)
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -40,8 +40,11 @@ type Server struct {
|
||||
// secureCookies включает флаг Secure у cookie сессии (панель за HTTPS).
|
||||
secureCookies bool
|
||||
// setupToken защищает первичное создание администратора: менеджер печатает
|
||||
// его в журнал при старте, когда учёток ещё нет.
|
||||
// его при старте, когда учёток ещё нет.
|
||||
setupToken string
|
||||
// setupTokenFile — копия кода на диске, которую показывает установщик.
|
||||
// После создания администратора файл больше не нужен.
|
||||
setupTokenFile string
|
||||
}
|
||||
|
||||
// Options — то, что нужно собрать серверу API.
|
||||
@@ -57,6 +60,9 @@ type Options struct {
|
||||
Static fs.FS
|
||||
SecureCookies bool
|
||||
SetupToken string
|
||||
// SetupTokenFile — файл с кодом первичной настройки, удаляется после
|
||||
// создания администратора.
|
||||
SetupTokenFile string
|
||||
// Repo — репозиторий, из которого панель берёт обновления.
|
||||
Repo string
|
||||
}
|
||||
@@ -76,6 +82,7 @@ func New(o Options) *Server {
|
||||
static: o.Static,
|
||||
secureCookies: o.SecureCookies,
|
||||
setupToken: o.SetupToken,
|
||||
setupTokenFile: o.SetupTokenFile,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+118
-1
@@ -471,6 +471,7 @@ async function loadServerConfig() {
|
||||
|
||||
if (!data.exists) {
|
||||
$("#config-empty").textContent = data.hint;
|
||||
refreshFieldSaveState(SAVE_TARGETS[0]);
|
||||
return;
|
||||
}
|
||||
serverConfigValues = data.values;
|
||||
@@ -479,6 +480,7 @@ async function loadServerConfig() {
|
||||
container.appendChild(buildConfigField(key, data.values[key], CONFIG_HINTS[key]));
|
||||
});
|
||||
applyFieldFilter("#config-filter", "#config-fields");
|
||||
refreshFieldSaveState(SAVE_TARGETS[0]);
|
||||
}
|
||||
|
||||
// buildConfigField выбирает тип поля по текущему значению: булевы значения
|
||||
@@ -539,12 +541,35 @@ function collectChanged(containerSelector) {
|
||||
return changed;
|
||||
}
|
||||
|
||||
// Кнопка «Сохранить» включается, только когда есть что сохранять: на пустой
|
||||
// вкладке (файл ещё не создан игрой) или без правок нажимать её незачем.
|
||||
const SAVE_TARGETS = [
|
||||
{ fields: "#config-fields", button: "#btn-config-save" },
|
||||
{ fields: "#sandbox-fields", button: "#btn-sandbox-save" },
|
||||
];
|
||||
|
||||
function refreshFieldSaveState(target) {
|
||||
const count = Object.keys(collectChanged(target.fields)).length;
|
||||
const button = $(target.button);
|
||||
button.disabled = count === 0;
|
||||
button.textContent = count ? `Сохранить (${count})` : "Сохранить";
|
||||
button.title = count ? `Изменено параметров: ${count}` : "Изменений нет";
|
||||
}
|
||||
|
||||
SAVE_TARGETS.forEach((target) => {
|
||||
// Слушаем на контейнере: поля пересоздаются при каждой загрузке вкладки.
|
||||
["input", "change"].forEach((event) =>
|
||||
$(target.fields).addEventListener(event, () => refreshFieldSaveState(target)));
|
||||
});
|
||||
|
||||
function markSaved(containerSelector) {
|
||||
$$(`${containerSelector} .cfg-field`).forEach((field) => {
|
||||
const input = field.querySelector("input, select");
|
||||
if (input) input.dataset.original = input.value;
|
||||
field.classList.remove("changed");
|
||||
});
|
||||
const target = SAVE_TARGETS.find((t) => t.fields === containerSelector);
|
||||
if (target) refreshFieldSaveState(target);
|
||||
}
|
||||
|
||||
$("#btn-config-save").addEventListener("click", async () => {
|
||||
@@ -584,6 +609,7 @@ async function loadSandbox() {
|
||||
|
||||
if (!data.exists) {
|
||||
$("#sandbox-empty").textContent = data.hint;
|
||||
refreshFieldSaveState(SAVE_TARGETS[1]);
|
||||
return;
|
||||
}
|
||||
const container = $("#sandbox-fields");
|
||||
@@ -591,6 +617,7 @@ async function loadSandbox() {
|
||||
container.appendChild(buildConfigField(entry.path, entry.value, null));
|
||||
});
|
||||
applyFieldFilter("#sandbox-filter", "#sandbox-fields");
|
||||
refreshFieldSaveState(SAVE_TARGETS[1]);
|
||||
}
|
||||
|
||||
$("#btn-sandbox-save").addEventListener("click", async () => {
|
||||
@@ -616,6 +643,10 @@ $("#btn-sandbox-save").addEventListener("click", async () => {
|
||||
// WorkshopItems — что скачать из мастерской, Mods — что включить и в каком
|
||||
// порядке. Панель показывает их одним списком: строка на мод, галочка =
|
||||
// присутствие в Mods, порядок строк = порядок в Mods.
|
||||
// Снимок списков на момент загрузки: по нему видно, есть ли несохранённые
|
||||
// правки. Пустая строка означает, что конфига ещё нет и сохранять нечего.
|
||||
let modsSnapshot = "";
|
||||
|
||||
let mods = {
|
||||
workshop: [], // WorkshopItems
|
||||
enabled: [], // Mods, порядок важен
|
||||
@@ -630,6 +661,8 @@ async function loadMods() {
|
||||
$("#mods-body").hidden = !data.exists;
|
||||
if (!data.exists) {
|
||||
$("#mods-empty").textContent = data.hint;
|
||||
modsSnapshot = "";
|
||||
refreshModsSaveState();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -641,6 +674,7 @@ async function loadMods() {
|
||||
details: mods.details,
|
||||
};
|
||||
$("#map-list").value = mods.maps.join("\n");
|
||||
modsSnapshot = modsState();
|
||||
renderMods();
|
||||
lookupWorkshopTitles().catch(() => {
|
||||
/* без интернета останутся идентификаторы — это не ошибка панели */
|
||||
@@ -711,6 +745,23 @@ function buildModRows() {
|
||||
return rows;
|
||||
}
|
||||
|
||||
// modsState сводит списки к строке — так проще всего заметить и переключённую
|
||||
// галочку, и переставленный порядок загрузки.
|
||||
function modsState() {
|
||||
return JSON.stringify({
|
||||
enabled: mods.enabled,
|
||||
workshop: mods.workshop,
|
||||
maps: $("#map-list").value.split("\n").map((line) => line.trim()).filter(Boolean),
|
||||
});
|
||||
}
|
||||
|
||||
function refreshModsSaveState() {
|
||||
const button = $("#btn-mods-save");
|
||||
const changed = modsSnapshot !== "" && modsState() !== modsSnapshot;
|
||||
button.disabled = !changed;
|
||||
button.title = changed ? "Есть несохранённые изменения" : "Изменений нет";
|
||||
}
|
||||
|
||||
function renderMods() {
|
||||
const filter = $("#mod-filter").value.trim().toLowerCase();
|
||||
const onlyEnabled = $("#mod-only-enabled").checked;
|
||||
@@ -725,6 +776,8 @@ function renderMods() {
|
||||
$("#mod-summary").textContent =
|
||||
`Модов: ${rows.length}, включено: ${mods.enabled.length}, пакетов мастерской: ${mods.workshop.length}`;
|
||||
|
||||
refreshModsSaveState();
|
||||
|
||||
$("#mod-list").innerHTML = visible
|
||||
.map((row) => {
|
||||
const on = mods.enabled.includes(row.modID);
|
||||
@@ -857,6 +910,7 @@ $("#mod-list").addEventListener("drop", (event) => {
|
||||
renderMods();
|
||||
});
|
||||
|
||||
$("#map-list").addEventListener("input", refreshModsSaveState);
|
||||
$("#mod-filter").addEventListener("input", renderMods);
|
||||
$("#mod-only-enabled").addEventListener("change", renderMods);
|
||||
|
||||
@@ -921,6 +975,8 @@ $("#btn-mods-save").addEventListener("click", async () => {
|
||||
workshop_items: mods.workshop,
|
||||
map: maps,
|
||||
});
|
||||
modsSnapshot = modsState();
|
||||
refreshModsSaveState();
|
||||
ok("Список модов сохранён. Перезапустите сервер, чтобы он их подтянул.");
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
@@ -1185,7 +1241,7 @@ async function loadUpdateInfo() {
|
||||
|
||||
const notes = $("#update-notes");
|
||||
notes.hidden = !(info.available && info.notes);
|
||||
if (!notes.hidden) $("#update-notes-body").textContent = info.notes;
|
||||
if (!notes.hidden) renderReleaseNotes(info.notes, info.url);
|
||||
|
||||
$("#btn-manager-restart").disabled = !info.can_apply;
|
||||
$("#btn-manager-restart").title = info.can_apply
|
||||
@@ -1193,6 +1249,47 @@ async function loadUpdateInfo() {
|
||||
: "Недоступно: панель не может управлять своей службой";
|
||||
}
|
||||
|
||||
// Описание релиза приходит из Gitea разметкой Markdown, и показывать её как
|
||||
// есть неудобно: первым идёт раздел про установку, который в уже работающей
|
||||
// панели ни к чему. Берём только список изменений.
|
||||
function renderReleaseNotes(text, url) {
|
||||
const body = $("#update-notes-body");
|
||||
body.replaceChildren();
|
||||
|
||||
const changesSection = text.split(/^##\s+Изменения\s*$/m)[1];
|
||||
const items = (changesSection ?? text)
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.startsWith("- "))
|
||||
.map((line) => line.slice(2).trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (items.length) {
|
||||
const list = document.createElement("ul");
|
||||
items.forEach((item) => {
|
||||
const li = document.createElement("li");
|
||||
li.textContent = item;
|
||||
list.appendChild(li);
|
||||
});
|
||||
body.appendChild(list);
|
||||
} else {
|
||||
// Непривычный формат описания — показываем как есть, лишь бы не потерять.
|
||||
const raw = document.createElement("pre");
|
||||
raw.textContent = text.trim();
|
||||
body.appendChild(raw);
|
||||
}
|
||||
|
||||
if (url) {
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.target = "_blank";
|
||||
link.rel = "noopener noreferrer";
|
||||
link.className = "release-link";
|
||||
link.textContent = "Полное описание релиза";
|
||||
body.appendChild(link);
|
||||
}
|
||||
}
|
||||
|
||||
$("#btn-update-check").addEventListener("click", async () => {
|
||||
const button = $("#btn-update-check");
|
||||
button.disabled = true;
|
||||
@@ -1212,6 +1309,9 @@ $("#btn-update-apply").addEventListener("click", async () => {
|
||||
try {
|
||||
await post("/api/update", { version: updateInfo.latest });
|
||||
waitForRestart("Обновление запущено");
|
||||
// Обновление выполняется отдельной службой, и её вывод в консоль панели
|
||||
// не попадает — панель в это время перезапускается.
|
||||
toast("Ход обновления: journalctl -u pzmanager-update -f");
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
}
|
||||
@@ -1299,6 +1399,7 @@ async function loadSettings() {
|
||||
} else {
|
||||
input.value = config[key] ?? "";
|
||||
}
|
||||
input.dataset.original = type === "checkbox" ? String(input.checked) : input.value;
|
||||
wrapper.appendChild(input);
|
||||
|
||||
if (hint) {
|
||||
@@ -1315,6 +1416,7 @@ async function loadSettings() {
|
||||
pathNote.textContent = `Файл конфигурации: ${config.config_path}`;
|
||||
form.appendChild(pathNote);
|
||||
|
||||
refreshSettingsSaveState();
|
||||
await loadUsers();
|
||||
// Проверка обновления ходит в интернет и может не ответить — она не должна
|
||||
// мешать показу остальных настроек.
|
||||
@@ -1323,6 +1425,21 @@ async function loadSettings() {
|
||||
});
|
||||
}
|
||||
|
||||
function refreshSettingsSaveState() {
|
||||
const changed = $$("#settings-form input").filter((input) => {
|
||||
const current = input.type === "checkbox" ? String(input.checked) : input.value;
|
||||
return current !== input.dataset.original;
|
||||
}).length;
|
||||
|
||||
const button = $("#btn-settings-save");
|
||||
button.disabled = changed === 0;
|
||||
button.textContent = changed ? `Сохранить настройки (${changed})` : "Сохранить настройки";
|
||||
button.title = changed ? `Изменено полей: ${changed}` : "Изменений нет";
|
||||
}
|
||||
|
||||
["input", "change"].forEach((event) =>
|
||||
$("#settings-form").addEventListener(event, refreshSettingsSaveState));
|
||||
|
||||
$("#btn-settings-save").addEventListener("click", async () => {
|
||||
const payload = {};
|
||||
SETTINGS_FIELDS.forEach(([key, , type]) => {
|
||||
|
||||
@@ -145,7 +145,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<input type="search" id="config-filter" placeholder="Поиск параметра…" class="input-sm">
|
||||
<button class="btn btn-primary" id="btn-config-save">Сохранить</button>
|
||||
<button class="btn btn-primary" id="btn-config-save" disabled>Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="muted" id="config-empty" hidden></p>
|
||||
@@ -161,7 +161,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<input type="search" id="sandbox-filter" placeholder="Поиск параметра…" class="input-sm">
|
||||
<button class="btn btn-primary" id="btn-sandbox-save">Сохранить</button>
|
||||
<button class="btn btn-primary" id="btn-sandbox-save" disabled>Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="muted" id="sandbox-empty" hidden></p>
|
||||
@@ -176,7 +176,7 @@
|
||||
<p class="muted small">Галочка включает мод, перетаскивание задаёт порядок загрузки.
|
||||
Сервер сам скачивает моды из мастерской при запуске.</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" id="btn-mods-save">Сохранить</button>
|
||||
<button class="btn btn-primary" id="btn-mods-save" disabled>Сохранить</button>
|
||||
</div>
|
||||
|
||||
<p class="muted" id="mods-empty" hidden></p>
|
||||
@@ -284,13 +284,13 @@
|
||||
<p class="muted small" id="update-manual" hidden></p>
|
||||
<details id="update-notes" hidden>
|
||||
<summary>Что нового</summary>
|
||||
<pre class="release-notes" id="update-notes-body"></pre>
|
||||
<div class="release-notes" id="update-notes-body"></div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<h2>Настройки менеджера</h2>
|
||||
<form id="settings-form" class="fields"></form>
|
||||
<button class="btn btn-primary" id="btn-settings-save">Сохранить настройки</button>
|
||||
<button class="btn btn-primary" id="btn-settings-save" disabled>Сохранить настройки</button>
|
||||
|
||||
<h2>Пользователи панели</h2>
|
||||
<ul class="user-list" id="user-list"></ul>
|
||||
|
||||
+22
-6
@@ -465,16 +465,32 @@ input.input-sm, .input-sm { width: auto; padding: 6px 10px; font-size: 13px; }
|
||||
|
||||
.release-notes {
|
||||
margin: 10px 0 0;
|
||||
padding: 12px;
|
||||
padding: 12px 14px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 12.5px;
|
||||
max-height: 240px;
|
||||
/* Прокручивается сам блок, а не страница под ним. */
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.release-notes ul { margin: 0; padding-left: 20px; }
|
||||
.release-notes li { margin-bottom: 5px; }
|
||||
.release-notes li:last-child { margin-bottom: 0; }
|
||||
|
||||
.release-notes pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.release-link { display: inline-block; margin-top: 10px; color: var(--info); }
|
||||
|
||||
/* Команду для терминала удобнее читать моноширинным. */
|
||||
#update-manual { font-family: var(--mono); word-break: break-all; }
|
||||
|
||||
.update-available { color: var(--accent); }
|
||||
|
||||
Reference in New Issue
Block a user