From 4a346c4ed90ba92a74ecd8e54cffc5dbcb185939 Mon Sep 17 00:00:00 2001 From: mrleo1nid Date: Sat, 5 Sep 2026 02:25:16 +0300 Subject: [PATCH] =?UTF-8?q?=D0=92=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B8=20=D0=B2=D0=B8=D0=B4=D0=BD=D0=BE=20?= =?UTF-8?q?=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B2?= =?UTF-8?q?=D1=81=D0=B5=D1=85=20=D0=BF=D1=80=D0=BE=D0=BF=D1=83=D1=89=D0=B5?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D1=85=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Панель показывала описание только последнего релиза. Пропустив пару версий, человек не узнал бы, что менялось в промежутке, — теперь запрашивается список релизов и показываются все, что новее установленной, каждая со своим заголовком и датой. Черновики и предрелизы отбрасываются, порядок восстанавливается по номерам версий, а не по тому, как их отдал Gitea. Заодно в карточке профиля появились подсказки: сколько на машине ОЗУ (не выбирать же лимит JVM вслепую) и что означает пустое поле начальной памяти. Co-Authored-By: Claude Opus 5 --- README.md | 17 ++++- internal/updater/updater.go | 83 +++++++++++++++++---- internal/updater/updater_test.go | 30 ++++++++ web/static/app.js | 123 +++++++++++++++++++++++-------- web/static/style.css | 11 ++- 5 files changed, 217 insertions(+), 47 deletions(-) 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() { +