diff --git a/README.md b/README.md
index 9eca48e..60cfdef 100644
--- a/README.md
+++ b/README.md
@@ -213,6 +213,20 @@ Mod ID, который нужен серверу, лежит внутри сам
При создании профиля можно скопировать `.ini` и настройки песочницы из
существующего: удобно, когда нужен тот же набор правил, но чистый мир.
+### Память JVM
+
+`java_memory` — это `-Xmx`, верхняя граница кучи. `java_memory_min` (`-Xms`)
+по умолчанию пуст, и это осознанно: равные значения избавляют от роста кучи на
+ходу, но заставляют JVM занять весь объём сразу при старте. На машине, где
+столько свободной памяти не набирается, сервер тогда не поднимется вовсе —
+`Failed to allocate initial Java heap`. Задавайте минимум, только если памяти
+заведомо хватает.
+
+Сервер, упавший сразу после запуска три раза подряд, панель перестаёт
+поднимать автоматически: обычно так проявляется нехватка памяти или битый
+конфиг, и перезапуск делу не помогает. Пока идёт отсчёт до автоподъёма, его
+можно отменить кнопкой «Отменить перезапуск».
+
Удаление профиля убирает его только из панели — мир и конфиги остаются на
диске. Чтобы вернуть профиль, создайте его с тем же идентификатором.
@@ -327,7 +341,8 @@ profiles: # серверы: у каждого свой м
- id: vanilla # vanilla -> vanilla.ini, Saves/Multiplayer/vanilla
title: Ванильный мир # как профиль подписан в панели
admin_password: "" # пароль админа игрового сервера
- java_memory: 4g # -Xmx для JVM этого профиля
+ java_memory: 4g # -Xmx: верхняя граница кучи JVM
+ java_memory_min: "" # -Xms: пусто — JVM берёт память по мере надобности
extra_args: [] # доп. аргументы start-server.sh
- id: modded
title: Сборка с модами
diff --git a/internal/updater/updater.go b/internal/updater/updater.go
index 0d7fad1..9875cae 100644
--- a/internal/updater/updater.go
+++ b/internal/updater/updater.go
@@ -16,6 +16,7 @@ import (
"fmt"
"net/http"
"os/exec"
+ "sort"
"strconv"
"strings"
"time"
@@ -27,6 +28,14 @@ const adminCommand = "pzmanager-admin"
// ErrNoAdmin возвращается, когда обновление из панели недоступно.
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 — то, что панель показывает в разделе обновления.
type Info struct {
Current string `json:"current"`
@@ -40,6 +49,10 @@ type Info struct {
CanApply bool `json:"can_apply"`
// ManualCommand — что выполнить в терминале, если сама не может.
ManualCommand string `json:"manual_command"`
+ // Changelog — все версии между установленной и последней, свежие первыми.
+ // Одного описания последнего релиза мало: пропустив пару версий, человек
+ // не увидит, что менялось в промежутке.
+ Changelog []ReleaseNote `json:"changelog"`
// Error — почему не удалось узнать последнюю версию.
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
}
-// Check спрашивает у Gitea последний релиз и сравнивает его с текущей версией.
+// Check спрашивает у Gitea список релизов и оставляет те, что новее
+// установленной версии.
func (u *Updater) Check(ctx context.Context) Info {
info := Info{
Current: u.Version,
CanApply: u.CanApply(ctx),
ManualCommand: u.manualCommand(),
+ Changelog: []ReleaseNote{},
}
api, err := u.apiBase()
@@ -89,7 +104,10 @@ func (u *Updater) Check(ctx context.Context) 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 {
info.Error = err.Error()
return info
@@ -101,36 +119,73 @@ func (u *Updater) Check(ctx context.Context) Info {
}
defer resp.Body.Close()
- if resp.StatusCode == http.StatusNotFound {
- info.Error = "в репозитории ещё нет релизов"
- return info
- }
if resp.StatusCode != http.StatusOK {
info.Error = "репозиторий ответил " + resp.Status
return info
}
- var release struct {
+ var releases []struct {
TagName string `json:"tag_name"`
Body string `json:"body"`
HTMLURL string `json:"html_url"`
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()
return info
}
- info.Latest = release.TagName
- info.Notes = release.Body
- info.URL = release.HTMLURL
- if !release.PublishedAt.IsZero() {
- info.Published = release.PublishedAt.Unix()
+ notes := make([]ReleaseNote, 0, len(releases))
+ for _, r := range releases {
+ if r.Draft || r.Prerelease || r.TagName == "" {
+ continue
+ }
+ note := ReleaseNote{Version: r.TagName, Notes: r.Body, URL: r.HTMLURL}
+ if !r.PublishedAt.IsZero() {
+ note.Published = r.PublishedAt.Unix()
+ }
+ notes = append(notes, note)
}
- info.Available = IsNewer(u.Version, release.TagName)
+ if len(notes) == 0 {
+ info.Error = "в репозитории ещё нет релизов"
+ 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 пускает к
// нему без пароля. Проверяем вызовом: наличие файла ещё не значит, что
// правило sudoers действует.
diff --git a/internal/updater/updater_test.go b/internal/updater/updater_test.go
index ae43f6b..2d19f5c 100644
--- a/internal/updater/updater_test.go
+++ b/internal/updater/updater_test.go
@@ -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))
+ }
+}
diff --git a/web/static/app.js b/web/static/app.js
index 94ab002..88221ac 100644
--- a/web/static/app.js
+++ b/web/static/app.js
@@ -94,6 +94,7 @@ function escapeHTML(value) {
const STATE_LABELS = {
stopped: "Остановлен",
+ restarting: "Ждёт перезапуска",
starting: "Запускается",
running: "Работает",
stopping: "Останавливается",
@@ -230,8 +231,18 @@ async function refreshStatus() {
renderTask(data.task);
}
+// hostStats нужен карточкам профилей: там подсказываем, сколько памяти можно
+// отдать JVM, чтобы не выбирать объём вслепую.
+let hostStats = {};
+
function renderStatus(status) {
- const label = STATE_LABELS[status.state] || status.state;
+ hostStats = status.host || {};
+ let label = STATE_LABELS[status.state] || status.state;
+ // Пока идёт обратный отсчёт, показываем, сколько осталось: иначе непонятно,
+ // происходит ли вообще что-нибудь.
+ if (status.state === "restarting" && status.restart_in_sec > 0) {
+ label = `Перезапуск через ${status.restart_in_sec} с`;
+ }
$("#status-text").textContent = label;
$("#status-dot").className = `dot ${status.state}`;
@@ -274,8 +285,13 @@ function renderStatus(status) {
: "Займёт несколько минут и около 3 ГБ на диске — прогресс виден во вкладке «Консоль».";
const running = status.state !== "stopped";
- $("#btn-start").disabled = running || !status.installed;
+ const waitingRestart = status.state === "restarting";
+
+ // В ожидании автоподъёма обе кнопки осмысленны: «Запустить» поднимает
+ // сервер сразу, «Остановить» отменяет перезапуск.
+ $("#btn-start").disabled = (running && !waitingRestart) || !status.installed;
$("#btn-stop").disabled = !running;
+ $("#btn-stop").textContent = waitingRestart ? "Отменить перезапуск" : "Остановить";
$("#btn-restart").disabled = !status.installed;
$("#btn-install").disabled = running;
@@ -283,16 +299,19 @@ function renderStatus(status) {
// и команда всё равно вернулась бы ошибкой.
const consoleInput = $("#console-command");
const consoleButton = $("#console-form").querySelector("button[type=submit]");
- consoleInput.disabled = !running;
- consoleButton.disabled = !running;
- consoleInput.placeholder = running
+ // Процесс есть только в этих двух состояниях; в ожидании перезапуска писать
+ // некуда, хотя формально сервер и не «остановлен».
+ const consoleReady = status.state === "running" || status.state === "starting";
+ consoleInput.disabled = !consoleReady;
+ consoleButton.disabled = !consoleReady;
+ consoleInput.placeholder = consoleReady
? "Команда сервера, например: players, save, quit"
: "Сервер остановлен — консоль принимает команды только на работающем";
// Профили делят игровые порты и файл запуска, поэтому переключать их можно
// только на остановленном сервере.
const picker = $("#profile-select");
- picker.disabled = running;
+ picker.disabled = running && !waitingRestart;
picker.title = running
? "Остановите сервер, чтобы переключить профиль"
: "Профиль сервера: свой мир, конфиг и моды";
@@ -331,8 +350,11 @@ async function serverAction(path, confirmText) {
}
$("#btn-start").addEventListener("click", () => serverAction("/api/server/start"));
-$("#btn-stop").addEventListener("click", () =>
- serverAction("/api/server/stop", "Остановить игровой сервер? Игроки будут отключены."));
+$("#btn-stop").addEventListener("click", () => {
+ // Отмена перезапуска ничего не рушит, спрашивать незачем.
+ const waiting = $("#btn-stop").textContent === "Отменить перезапуск";
+ serverAction("/api/server/stop", waiting ? "" : "Остановить игровой сервер? Игроки будут отключены.");
+});
$("#btn-restart").addEventListener("click", () =>
serverAction("/api/server/restart", "Перезапустить сервер? Игроки будут отключены."));
@@ -1048,8 +1070,16 @@ function renderProfileCards() {
+