В обновлении видно изменения всех пропущенных версий
CI / test (push) Successful in 21s
Release / release (push) Successful in 29s

Панель показывала описание только последнего релиза. Пропустив пару
версий, человек не узнал бы, что менялось в промежутке, — теперь
запрашивается список релизов и показываются все, что новее установленной,
каждая со своим заголовком и датой. Черновики и предрелизы отбрасываются,
порядок восстанавливается по номерам версий, а не по тому, как их отдал
Gitea.

Заодно в карточке профиля появились подсказки: сколько на машине ОЗУ (не
выбирать же лимит JVM вслепую) и что означает пустое поле начальной
памяти.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-05 02:25:16 +03:00
co-authored by Claude Opus 5
parent e78a72d516
commit 4a346c4ed9
5 changed files with 217 additions and 47 deletions
+69 -14
View File
@@ -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 действует.
+30
View File
@@ -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))
}
}