feat: 版本徽章新增近3个历史版本在线回退与手动回退指引

后端:
- UpdateService 新增 ListRollbackVersions/RollbackToVersion,回退目标强制
  限定为比当前版本更旧的近3个正式版本(排除当前/更新/预发布/草稿),
  越界返回 400 ROLLBACK_VERSION_NOT_ALLOWED;下载复用既有 HTTPS 域名
  白名单 + checksum 校验 + 原子换二进制管线
- GitHubReleaseClient 新增 FetchRecentReleases(releases 列表 API)
- 新增 GET /admin/system/rollback-versions;POST /admin/system/rollback
  支持可选 {"version"} body,无 body 保持原 .backup 本地回退行为不变
- 端点均在 /admin 组 adminAuth(IsAdmin+TokenVersion)保护内

前端:
- VersionBadge「已是最新版本」状态下新增版本回退面板:radio 卡片选择
  近3个版本,手动回退方式按部署形态 tab 切换(脚本部署 curl 命令 /
  Docker 镜像 tag 指引),支持一键复制;release 构建提供一键回退并
  复用重启流程;源码构建仅提示不支持在线回退
- 下拉根元素重置 whitespace-normal,修复 sidebar-brand 的
  white-space:nowrap 继承导致的长文本溢出裁切

测试:服务层过滤/排序/上限/非法目标拒绝、handler 双模式与鉴权错误映射、
GitHub client 列表拉取、前端 API 请求体行为共 16 个新增用例
This commit is contained in:
shaw
2026-07-09 22:30:04 +08:00
parent 1a3cc2a787
commit 1c2e6503c6
12 changed files with 1064 additions and 37 deletions
@@ -26,6 +26,8 @@ type systemUpdateService interface {
CheckUpdate(ctx context.Context, force bool) (*service.UpdateInfo, error)
PerformUpdate(ctx context.Context) error
Rollback() error
ListRollbackVersions(ctx context.Context) ([]service.RollbackVersion, error)
RollbackToVersion(ctx context.Context, version string) error
}
// NewSystemHandler creates a new SystemHandler
@@ -102,11 +104,42 @@ func (h *SystemHandler) PerformUpdate(c *gin.Context) {
})
}
// Rollback restores the previous version
// GetRollbackVersions lists versions available for rollback
// GET /api/v1/admin/system/rollback-versions
func (h *SystemHandler) GetRollbackVersions(c *gin.Context) {
versions, err := h.updateSvc.ListRollbackVersions(c.Request.Context())
if err != nil {
response.Error(c, http.StatusInternalServerError, err.Error())
return
}
response.Success(c, gin.H{
"versions": versions,
})
}
// Rollback restores a previous version.
// Without a body (or with an empty version) it restores the local .backup binary
// left by the last in-place update. With {"version": "x.y.z"} it downloads and
// installs that specific release (must be one of the recent rollback versions).
// POST /api/v1/admin/system/rollback
func (h *SystemHandler) Rollback(c *gin.Context) {
operationID := buildSystemOperationID(c, "rollback")
payload := gin.H{"operation_id": operationID}
var req struct {
Version string `json:"version"`
}
if c.Request.Body != nil && c.Request.ContentLength > 0 {
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, http.StatusBadRequest, "invalid request body")
return
}
}
targetVersion := strings.TrimSpace(req.Version)
operation := "rollback"
if targetVersion != "" {
operation = "rollback:" + targetVersion
}
operationID := buildSystemOperationID(c, operation)
payload := gin.H{"operation_id": operationID, "version": targetVersion}
executeAdminIdempotentJSON(c, "admin.system.rollback", payload, service.DefaultSystemOperationIdempotencyTTL(), func(ctx context.Context) (any, error) {
lock, release, err := h.acquireSystemLock(ctx, operationID)
if err != nil {
@@ -118,7 +151,12 @@ func (h *SystemHandler) Rollback(c *gin.Context) {
release(releaseReason, succeeded)
}()
if err := h.updateSvc.Rollback(); err != nil {
if targetVersion != "" {
err = h.updateSvc.RollbackToVersion(ctx, targetVersion)
} else {
err = h.updateSvc.Rollback()
}
if err != nil {
releaseReason = "SYSTEM_ROLLBACK_FAILED"
return nil, err
}
@@ -127,6 +165,7 @@ func (h *SystemHandler) Rollback(c *gin.Context) {
return gin.H{
"message": "Rollback completed. Please restart the service.",
"need_restart": true,
"version": targetVersion,
"operation_id": lock.OperationID(),
}, nil
})
@@ -8,6 +8,7 @@ import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
@@ -17,11 +18,18 @@ import (
)
type systemHandlerUpdateServiceStub struct {
performErr error
updateInfo *service.UpdateInfo
checkErr error
checkForces []bool
performCall int
performErr error
updateInfo *service.UpdateInfo
checkErr error
checkForces []bool
performCall int
rollbackCall int
rollbackToCall int
rollbackToVersions []string
rollbackToErr error
rollbackVersions []service.RollbackVersion
rollbackVersionsErr error
rollbackVersionsCall int
}
func (s *systemHandlerUpdateServiceStub) CheckUpdate(_ context.Context, force bool) (*service.UpdateInfo, error) {
@@ -35,9 +43,21 @@ func (s *systemHandlerUpdateServiceStub) PerformUpdate(context.Context) error {
}
func (s *systemHandlerUpdateServiceStub) Rollback() error {
s.rollbackCall++
return nil
}
func (s *systemHandlerUpdateServiceStub) ListRollbackVersions(context.Context) ([]service.RollbackVersion, error) {
s.rollbackVersionsCall++
return s.rollbackVersions, s.rollbackVersionsErr
}
func (s *systemHandlerUpdateServiceStub) RollbackToVersion(_ context.Context, version string) error {
s.rollbackToCall++
s.rollbackToVersions = append(s.rollbackToVersions, version)
return s.rollbackToErr
}
type systemUpdateResponseEnvelope struct {
Code int `json:"code"`
Message string `json:"message"`
@@ -71,6 +91,8 @@ func newSystemHandlerTestRouter(t *testing.T, updateSvc *systemHandlerUpdateServ
router := gin.New()
router.POST("/api/v1/admin/system/update", handler.PerformUpdate)
router.POST("/api/v1/admin/system/rollback", handler.Rollback)
router.GET("/api/v1/admin/system/rollback-versions", handler.GetRollbackVersions)
return router
}
@@ -142,3 +164,104 @@ func TestSystemHandlerPerformUpdateFailureStillReturnsInternalError(t *testing.T
require.Equal(t, http.StatusInternalServerError, body.Code)
require.Equal(t, "internal error", body.Message)
}
func TestSystemHandlerRollbackWithoutBodyUsesLegacyBackup(t *testing.T) {
updateSvc := &systemHandlerUpdateServiceStub{}
repo := newMemoryIdempotencyRepoStub()
router := newSystemHandlerTestRouter(t, updateSvc, repo)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/system/rollback", nil)
req.Header.Set("Idempotency-Key", "legacy-rollback")
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, 1, updateSvc.rollbackCall)
require.Equal(t, 0, updateSvc.rollbackToCall)
requireSystemLockStatus(t, repo, service.IdempotencyStatusSucceeded)
}
func TestSystemHandlerRollbackWithVersionCallsRollbackToVersion(t *testing.T) {
updateSvc := &systemHandlerUpdateServiceStub{}
repo := newMemoryIdempotencyRepoStub()
router := newSystemHandlerTestRouter(t, updateSvc, repo)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/system/rollback",
strings.NewReader(`{"version":"0.1.146"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "rollback-to-146")
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, 0, updateSvc.rollbackCall)
require.Equal(t, 1, updateSvc.rollbackToCall)
require.Equal(t, []string{"0.1.146"}, updateSvc.rollbackToVersions)
requireSystemLockStatus(t, repo, service.IdempotencyStatusSucceeded)
var body systemUpdateResponseEnvelope
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
require.Equal(t, 0, body.Code)
require.Equal(t, "Rollback completed. Please restart the service.", body.Data.Message)
}
func TestSystemHandlerRollbackWithDisallowedVersionReturnsBadRequest(t *testing.T) {
updateSvc := &systemHandlerUpdateServiceStub{
rollbackToErr: service.ErrRollbackVersionNotAllowed,
}
repo := newMemoryIdempotencyRepoStub()
router := newSystemHandlerTestRouter(t, updateSvc, repo)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/system/rollback",
strings.NewReader(`{"version":"9.9.9"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "rollback-to-bad")
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusBadRequest, rec.Code)
require.Equal(t, 1, updateSvc.rollbackToCall)
}
func TestSystemHandlerGetRollbackVersions(t *testing.T) {
updateSvc := &systemHandlerUpdateServiceStub{
rollbackVersions: []service.RollbackVersion{
{Version: "0.1.146", PublishedAt: "2026-07-07T00:00:00Z", HTMLURL: "https://example.com/v0.1.146"},
{Version: "0.1.145", PublishedAt: "2026-07-06T00:00:00Z", HTMLURL: "https://example.com/v0.1.145"},
},
}
repo := newMemoryIdempotencyRepoStub()
router := newSystemHandlerTestRouter(t, updateSvc, repo)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/system/rollback-versions", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, 1, updateSvc.rollbackVersionsCall)
var body struct {
Code int `json:"code"`
Data struct {
Versions []service.RollbackVersion `json:"versions"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
require.Equal(t, 0, body.Code)
require.Len(t, body.Data.Versions, 2)
require.Equal(t, "0.1.146", body.Data.Versions[0].Version)
}
func TestSystemHandlerGetRollbackVersionsError(t *testing.T) {
updateSvc := &systemHandlerUpdateServiceStub{
rollbackVersionsErr: errors.New("github unavailable"),
}
repo := newMemoryIdempotencyRepoStub()
router := newSystemHandlerTestRouter(t, updateSvc, repo)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/system/rollback-versions", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusInternalServerError, rec.Code)
}
@@ -67,6 +67,10 @@ func (c *githubReleaseClientError) FetchLatestRelease(ctx context.Context, repo
return nil, c.err
}
func (c *githubReleaseClientError) FetchRecentReleases(ctx context.Context, repo string, perPage int) ([]*service.GitHubRelease, error) {
return nil, c.err
}
func (c *githubReleaseClientError) DownloadFile(ctx context.Context, url, dest string, maxSize int64) error {
return c.err
}
@@ -103,6 +107,40 @@ func (c *githubReleaseClient) FetchLatestRelease(ctx context.Context, repo strin
return &release, nil
}
func (c *githubReleaseClient) FetchRecentReleases(ctx context.Context, repo string, perPage int) ([]*service.GitHubRelease, error) {
if perPage <= 0 {
perPage = 10
}
if perPage > 100 {
perPage = 100 // GitHub API hard limit
}
url := fmt.Sprintf("https://api.github.com/repos/%s/releases?per_page=%d", repo, perPage)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/vnd.github.v3+json")
req.Header.Set("User-Agent", "Sub2API-Updater")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitHub API returned %d", resp.StatusCode)
}
var releases []*service.GitHubRelease
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
return nil, err
}
return releases, nil
}
func (c *githubReleaseClient) DownloadFile(ctx context.Context, url, dest string, maxSize int64) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
@@ -29,6 +29,9 @@ type testTransport struct {
func (t *testTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// Rewrite the URL to point to our test server
testURL := t.testServerURL + req.URL.Path
if req.URL.RawQuery != "" {
testURL += "?" + req.URL.RawQuery
}
newReq, err := http.NewRequestWithContext(req.Context(), req.Method, testURL, req.Body)
if err != nil {
return nil, err
@@ -245,6 +248,76 @@ func (s *GitHubReleaseServiceSuite) TestFetchLatestRelease_Success() {
require.Equal(s.T(), "app-linux-amd64.tar.gz", release.Assets[0].Name)
}
func (s *GitHubReleaseServiceSuite) TestFetchRecentReleases_Success() {
releasesJSON := `[
{
"tag_name": "v1.0.1",
"name": "Release 1.0.1",
"html_url": "https://github.com/test/repo/releases/v1.0.1",
"published_at": "2026-07-08T00:00:00Z",
"prerelease": false,
"assets": [
{
"name": "app-linux-amd64.tar.gz",
"browser_download_url": "https://github.com/test/repo/releases/download/v1.0.1/app-linux-amd64.tar.gz"
}
]
},
{
"tag_name": "v1.0.1-rc1",
"name": "Release 1.0.1-rc1",
"prerelease": true
},
{
"tag_name": "v1.0.0",
"name": "Release 1.0.0"
}
]`
s.srv = newLocalTestServer(s.T(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(s.T(), "/repos/test/repo/releases", r.URL.Path)
require.Equal(s.T(), "15", r.URL.Query().Get("per_page"))
require.Equal(s.T(), "application/vnd.github.v3+json", r.Header.Get("Accept"))
require.Equal(s.T(), "Sub2API-Updater", r.Header.Get("User-Agent"))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(releasesJSON))
}))
s.client = &githubReleaseClient{
httpClient: &http.Client{
Transport: &testTransport{testServerURL: s.srv.URL},
},
downloadHTTPClient: &http.Client{},
}
releases, err := s.client.FetchRecentReleases(context.Background(), "test/repo", 15)
require.NoError(s.T(), err)
require.Len(s.T(), releases, 3)
require.Equal(s.T(), "v1.0.1", releases[0].TagName)
require.False(s.T(), releases[0].Prerelease)
require.Len(s.T(), releases[0].Assets, 1)
require.True(s.T(), releases[1].Prerelease)
require.Equal(s.T(), "v1.0.0", releases[2].TagName)
}
func (s *GitHubReleaseServiceSuite) TestFetchRecentReleases_Non200() {
s.srv = newLocalTestServer(s.T(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
}))
s.client = &githubReleaseClient{
httpClient: &http.Client{
Transport: &testTransport{testServerURL: s.srv.URL},
},
downloadHTTPClient: &http.Client{},
}
_, err := s.client.FetchRecentReleases(context.Background(), "test/repo", 15)
require.Error(s.T(), err)
require.Contains(s.T(), err.Error(), "403")
}
func (s *GitHubReleaseServiceSuite) TestFetchLatestRelease_Non200() {
s.srv = newLocalTestServer(s.T(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
+1
View File
@@ -545,6 +545,7 @@ func registerSystemRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
{
system.GET("/version", h.Admin.System.GetVersion)
system.GET("/check-updates", h.Admin.System.CheckUpdates)
system.GET("/rollback-versions", h.Admin.System.GetRollbackVersions)
system.POST("/update", h.Admin.System.PerformUpdate)
system.POST("/rollback", h.Admin.System.Rollback)
system.POST("/restart", h.Admin.System.RestartService)
+122 -2
View File
@@ -14,6 +14,7 @@ import (
"os"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"time"
@@ -22,7 +23,8 @@ import (
)
var (
ErrNoUpdateAvailable = infraerrors.Conflict("ALREADY_UP_TO_DATE", "no update available; current version is latest")
ErrNoUpdateAvailable = infraerrors.Conflict("ALREADY_UP_TO_DATE", "no update available; current version is latest")
ErrRollbackVersionNotAllowed = infraerrors.BadRequest("ROLLBACK_VERSION_NOT_ALLOWED", "version is not in the allowed rollback list")
)
const (
@@ -36,6 +38,11 @@ const (
// Security: max download size (500MB)
maxDownloadSize = 500 * 1024 * 1024
// Rollback: expose at most the 3 most recent versions older than current
maxRollbackVersions = 3
// Fetch a few extra releases so filtering (current/newer/prerelease) still leaves enough candidates
rollbackFetchPageSize = 15
)
// UpdateCache defines cache operations for update service
@@ -47,6 +54,7 @@ type UpdateCache interface {
// GitHubReleaseClient 获取 GitHub release 信息的接口
type GitHubReleaseClient interface {
FetchLatestRelease(ctx context.Context, repo string) (*GitHubRelease, error)
FetchRecentReleases(ctx context.Context, repo string, perPage int) ([]*GitHubRelease, error)
DownloadFile(ctx context.Context, url, dest string, maxSize int64) error
FetchChecksumFile(ctx context.Context, url string) ([]byte, error)
}
@@ -103,9 +111,18 @@ type GitHubRelease struct {
Body string `json:"body"`
PublishedAt string `json:"published_at"`
HTMLURL string `json:"html_url"`
Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"`
Assets []GitHubAsset `json:"assets"`
}
// RollbackVersion describes a release version the system can roll back to
type RollbackVersion struct {
Version string `json:"version"` // without "v" prefix, e.g. "0.1.146"
PublishedAt string `json:"published_at"`
HTMLURL string `json:"html_url"`
}
type GitHubAsset struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
@@ -155,12 +172,19 @@ func (s *UpdateService) PerformUpdate(ctx context.Context) error {
return ErrNoUpdateAvailable
}
return s.applyReleaseAssets(ctx, info.ReleaseInfo.Assets)
}
// applyReleaseAssets downloads the platform archive from the given release assets,
// verifies its checksum, and atomically swaps the running binary.
// Shared by PerformUpdate (latest) and RollbackToVersion (specific older version).
func (s *UpdateService) applyReleaseAssets(ctx context.Context, releaseAssets []Asset) error {
// Find matching archive and checksum for current platform
archiveName := s.getArchiveName()
var downloadURL string
var checksumURL string
for _, asset := range info.ReleaseInfo.Assets {
for _, asset := range releaseAssets {
if strings.Contains(asset.Name, archiveName) && !strings.HasSuffix(asset.Name, ".txt") {
downloadURL = asset.DownloadURL
}
@@ -279,6 +303,102 @@ func (s *UpdateService) Rollback() error {
return nil
}
// ListRollbackVersions returns up to maxRollbackVersions release versions that are
// strictly older than the current version (the current version itself is excluded),
// newest first. Draft and prerelease entries are skipped.
func (s *UpdateService) ListRollbackVersions(ctx context.Context) ([]RollbackVersion, error) {
releases, err := s.fetchRollbackCandidates(ctx)
if err != nil {
return nil, err
}
versions := make([]RollbackVersion, 0, len(releases))
for _, r := range releases {
versions = append(versions, RollbackVersion{
Version: strings.TrimPrefix(r.TagName, "v"),
PublishedAt: r.PublishedAt,
HTMLURL: r.HTMLURL,
})
}
return versions, nil
}
// RollbackToVersion downloads and installs a specific older version.
// The target must be one of the versions returned by ListRollbackVersions;
// anything else (including the current version) is rejected.
func (s *UpdateService) RollbackToVersion(ctx context.Context, version string) error {
target := strings.TrimPrefix(strings.TrimSpace(version), "v")
if target == "" {
return ErrRollbackVersionNotAllowed
}
releases, err := s.fetchRollbackCandidates(ctx)
if err != nil {
return err
}
var match *GitHubRelease
for _, r := range releases {
if strings.TrimPrefix(r.TagName, "v") == target {
match = r
break
}
}
if match == nil {
return ErrRollbackVersionNotAllowed
}
assets := make([]Asset, len(match.Assets))
for i, a := range match.Assets {
assets[i] = Asset{
Name: a.Name,
DownloadURL: a.BrowserDownloadURL,
Size: a.Size,
}
}
return s.applyReleaseAssets(ctx, assets)
}
// fetchRollbackCandidates fetches recent releases and keeps the newest
// maxRollbackVersions entries strictly older than the current version.
func (s *UpdateService) fetchRollbackCandidates(ctx context.Context) ([]*GitHubRelease, error) {
releases, err := s.githubClient.FetchRecentReleases(ctx, githubRepo, rollbackFetchPageSize)
if err != nil {
return nil, err
}
seen := make(map[string]bool, len(releases))
candidates := make([]*GitHubRelease, 0, maxRollbackVersions)
for _, r := range releases {
if r == nil || r.Draft || r.Prerelease {
continue
}
v := strings.TrimPrefix(r.TagName, "v")
if v == "" || seen[v] {
continue
}
// Only versions strictly older than current (also excludes current itself)
if compareVersions(v, s.currentVersion) >= 0 {
continue
}
seen[v] = true
candidates = append(candidates, r)
}
sort.SliceStable(candidates, func(i, j int) bool {
return compareVersions(
strings.TrimPrefix(candidates[i].TagName, "v"),
strings.TrimPrefix(candidates[j].TagName, "v"),
) > 0
})
if len(candidates) > maxRollbackVersions {
candidates = candidates[:maxRollbackVersions]
}
return candidates, nil
}
func (s *UpdateService) fetchLatestRelease(ctx context.Context) (*UpdateInfo, error) {
release, err := s.githubClient.FetchLatestRelease(ctx, githubRepo)
if err != nil {
+124 -1
View File
@@ -28,13 +28,19 @@ func (s *updateServiceCacheStub) SetUpdateInfo(_ context.Context, data string, _
}
type updateServiceGitHubClientStub struct {
release *GitHubRelease
release *GitHubRelease
recentReleases []*GitHubRelease
recentErr error
}
func (s *updateServiceGitHubClientStub) FetchLatestRelease(context.Context, string) (*GitHubRelease, error) {
return s.release, nil
}
func (s *updateServiceGitHubClientStub) FetchRecentReleases(context.Context, string, int) ([]*GitHubRelease, error) {
return s.recentReleases, s.recentErr
}
func (s *updateServiceGitHubClientStub) DownloadFile(context.Context, string, string, int64) error {
panic("DownloadFile should not be called when no update is available")
}
@@ -62,3 +68,120 @@ func TestUpdateServicePerformUpdateNoUpdateReturnsSentinel(t *testing.T) {
require.True(t, errors.Is(err, ErrNoUpdateAvailable))
require.ErrorIs(t, err, ErrNoUpdateAvailable)
}
func newRollbackTestService(current string, releases []*GitHubRelease) *UpdateService {
return NewUpdateService(
&updateServiceCacheStub{},
&updateServiceGitHubClientStub{recentReleases: releases},
current,
"release",
)
}
func TestUpdateServiceListRollbackVersionsFiltersAndCaps(t *testing.T) {
releases := []*GitHubRelease{
{TagName: "v0.1.148", PublishedAt: "2026-07-09T00:00:00Z"}, // newer than current: excluded
{TagName: "v0.1.147", PublishedAt: "2026-07-08T00:00:00Z"}, // current: excluded
{TagName: "v0.1.146-rc1", PublishedAt: "2026-07-07T12:00:00Z", Prerelease: true}, // prerelease: excluded
{TagName: "v0.1.146", PublishedAt: "2026-07-07T00:00:00Z"},
{TagName: "v0.1.145", PublishedAt: "2026-07-06T00:00:00Z", Draft: true}, // draft: excluded
{TagName: "v0.1.144", PublishedAt: "2026-07-05T00:00:00Z"},
{TagName: "v0.1.144", PublishedAt: "2026-07-05T00:00:00Z"}, // duplicate: excluded
{TagName: "v0.1.143", PublishedAt: "2026-07-04T00:00:00Z"},
{TagName: "v0.1.142", PublishedAt: "2026-07-03T00:00:00Z"}, // beyond cap of 3: excluded
}
svc := newRollbackTestService("0.1.147", releases)
versions, err := svc.ListRollbackVersions(context.Background())
require.NoError(t, err)
require.Len(t, versions, 3)
require.Equal(t, "0.1.146", versions[0].Version)
require.Equal(t, "0.1.144", versions[1].Version)
require.Equal(t, "0.1.143", versions[2].Version)
}
func TestUpdateServiceListRollbackVersionsSortsUnorderedInput(t *testing.T) {
releases := []*GitHubRelease{
{TagName: "v0.1.144"},
{TagName: "v0.1.146"},
{TagName: "v0.1.145"},
}
svc := newRollbackTestService("0.1.147", releases)
versions, err := svc.ListRollbackVersions(context.Background())
require.NoError(t, err)
require.Len(t, versions, 3)
require.Equal(t, "0.1.146", versions[0].Version)
require.Equal(t, "0.1.145", versions[1].Version)
require.Equal(t, "0.1.144", versions[2].Version)
}
func TestUpdateServiceListRollbackVersionsEmptyWhenNoneOlder(t *testing.T) {
releases := []*GitHubRelease{
{TagName: "v0.1.147"},
{TagName: "v0.1.148"},
}
svc := newRollbackTestService("0.1.147", releases)
versions, err := svc.ListRollbackVersions(context.Background())
require.NoError(t, err)
require.Empty(t, versions)
}
func TestUpdateServiceListRollbackVersionsPropagatesFetchError(t *testing.T) {
svc := NewUpdateService(
&updateServiceCacheStub{},
&updateServiceGitHubClientStub{recentErr: errors.New("github unavailable")},
"0.1.147",
"release",
)
_, err := svc.ListRollbackVersions(context.Background())
require.Error(t, err)
require.Contains(t, err.Error(), "github unavailable")
}
func TestUpdateServiceRollbackToVersionRejectsDisallowedTargets(t *testing.T) {
releases := []*GitHubRelease{
{TagName: "v0.1.148"},
{TagName: "v0.1.147"},
{TagName: "v0.1.146"},
{TagName: "v0.1.145"},
{TagName: "v0.1.144"},
{TagName: "v0.1.143"},
{TagName: "v0.1.142"},
}
svc := newRollbackTestService("0.1.147", releases)
for _, target := range []string{
"", // empty
"0.1.147", // current version
"v0.1.147", // current version with prefix
"0.1.148", // newer than current
"0.1.142", // older than the 3 most recent
"9.9.9", // nonexistent
} {
err := svc.RollbackToVersion(context.Background(), target)
require.ErrorIs(t, err, ErrRollbackVersionNotAllowed, "target %q should be rejected", target)
}
}
func TestUpdateServiceRollbackToVersionAcceptsVPrefix(t *testing.T) {
// No platform asset in the release: the target passes the allowlist check
// and fails later at asset lookup, proving the version itself was accepted.
releases := []*GitHubRelease{
{TagName: "v0.1.147"},
{TagName: "v0.1.146"},
}
svc := newRollbackTestService("0.1.147", releases)
err := svc.RollbackToVersion(context.Background(), "v0.1.146")
require.Error(t, err)
require.NotErrorIs(t, err, ErrRollbackVersionNotAllowed)
require.Contains(t, err.Error(), "no compatible release found")
}
@@ -0,0 +1,55 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { get, post } = vi.hoisted(() => ({
get: vi.fn(),
post: vi.fn(),
}))
vi.mock('../client', () => ({
apiClient: {
get,
post,
},
}))
import { getRollbackVersions, rollback, type RollbackVersionInfo } from '@/api/admin/system'
describe('admin system rollback API', () => {
beforeEach(() => {
get.mockReset()
post.mockReset()
})
it('getRollbackVersions fetches the rollback version list', async () => {
const versions: RollbackVersionInfo[] = [
{
version: '0.1.146',
published_at: '2026-07-07T00:00:00Z',
html_url: 'https://github.com/Wei-Shaw/sub2api/releases/tag/v0.1.146'
}
]
get.mockResolvedValue({ data: { versions } })
const result = await getRollbackVersions()
expect(get).toHaveBeenCalledWith('/admin/system/rollback-versions')
expect(result.versions).toEqual(versions)
})
it('rollback posts the target version in the request body', async () => {
post.mockResolvedValue({ data: { message: 'ok', need_restart: true } })
const result = await rollback('0.1.146')
expect(post).toHaveBeenCalledWith('/admin/system/rollback', { version: '0.1.146' })
expect(result.need_restart).toBe(true)
})
it('rollback without a version posts no body (legacy backup rollback)', async () => {
post.mockResolvedValue({ data: { message: 'ok', need_restart: true } })
await rollback()
expect(post).toHaveBeenCalledWith('/admin/system/rollback', undefined)
})
})
+24 -3
View File
@@ -45,6 +45,22 @@ export interface UpdateResult {
need_restart: boolean
}
export interface RollbackVersionInfo {
version: string
published_at: string
html_url: string
}
/**
* Get versions available for rollback (up to 3 versions older than current)
*/
export async function getRollbackVersions(): Promise<{ versions: RollbackVersionInfo[] }> {
const { data } = await apiClient.get<{ versions: RollbackVersionInfo[] }>(
'/admin/system/rollback-versions'
)
return data
}
/**
* Perform system update
* Downloads and applies the latest version
@@ -55,10 +71,14 @@ export async function performUpdate(): Promise<UpdateResult> {
}
/**
* Rollback to previous version
* Rollback to a previous version
* @param version - Target version (e.g. "0.1.146"); omit to restore the local backup binary
*/
export async function rollback(): Promise<UpdateResult> {
const { data } = await apiClient.post<UpdateResult>('/admin/system/rollback')
export async function rollback(version?: string): Promise<UpdateResult> {
const { data } = await apiClient.post<UpdateResult>(
'/admin/system/rollback',
version ? { version } : undefined
)
return data
}
@@ -74,6 +94,7 @@ export const systemAPI = {
getVersion,
checkUpdates,
performUpdate,
getRollbackVersions,
rollback,
restartService
}
+419 -20
View File
@@ -31,7 +31,8 @@
<div
v-if="dropdownOpen"
ref="dropdownRef"
class="absolute left-0 z-50 mt-2 w-64 overflow-hidden rounded-xl border border-gray-200 bg-white shadow-lg dark:border-dark-700 dark:bg-dark-800"
class="absolute left-0 z-50 mt-2 overflow-hidden whitespace-normal rounded-xl border border-gray-200 bg-white shadow-lg transition-all duration-200 dark:border-dark-700 dark:bg-dark-800"
:class="rollbackPanelOpen && isReleaseBuild ? 'w-80' : 'w-64'"
>
<!-- Header with refresh button -->
<div
@@ -168,7 +169,11 @@
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-green-700 dark:text-green-300">
{{ t('version.updateComplete') }}
{{
successKind === 'rollback'
? t('version.rollbackComplete')
: t('version.updateComplete')
}}
</p>
<p class="text-xs text-green-600/70 dark:text-green-400/70">
{{ t('version.restartRequired') }}
@@ -350,23 +355,275 @@
</a>
</div>
<!-- Priority 5: Up to date - show GitHub link -->
<a
v-else-if="releaseInfo?.html_url && releaseInfo.html_url !== '#'"
:href="releaseInfo.html_url"
target="_blank"
rel="noopener noreferrer"
class="flex items-center justify-center gap-2 py-2 text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-dark-400 dark:hover:text-dark-200"
>
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.17 6.839 9.49.5.092.682-.217.682-.482 0-.237-.008-.866-.013-1.7-2.782.604-3.369-1.34-3.369-1.34-.454-1.156-1.11-1.464-1.11-1.464-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.831.092-.646.35-1.086.636-1.336-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0112 6.836c.85.004 1.705.114 2.504.336 1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.167 22 16.418 22 12c0-5.523-4.477-10-10-10z"
/>
</svg>
{{ t('version.viewRelease') }}
</a>
<!-- Priority 5: Up to date - GitHub link + version rollback -->
<div v-else class="space-y-2">
<a
v-if="releaseInfo?.html_url && releaseInfo.html_url !== '#'"
:href="releaseInfo.html_url"
target="_blank"
rel="noopener noreferrer"
class="flex items-center justify-center gap-2 py-2 text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-dark-400 dark:hover:text-dark-200"
>
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.17 6.839 9.49.5.092.682-.217.682-.482 0-.237-.008-.866-.013-1.7-2.782.604-3.369-1.34-3.369-1.34-.454-1.156-1.11-1.464-1.11-1.464-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.831.092-.646.35-1.086.636-1.336-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0112 6.836c.85.004 1.705.114 2.504.336 1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.167 22 16.418 22 12c0-5.523-4.477-10-10-10z"
/>
</svg>
{{ t('version.viewRelease') }}
</a>
<!-- Version rollback entry -->
<div class="border-t border-gray-100 pt-2 dark:border-dark-700">
<button
@click="toggleRollbackPanel"
class="group flex w-full items-center justify-between rounded-lg px-2 py-1.5 text-xs text-gray-400 transition-colors hover:bg-gray-50 hover:text-gray-600 dark:text-dark-500 dark:hover:bg-dark-700/50 dark:hover:text-dark-300"
>
<span class="flex items-center gap-1.5">
<Icon name="clock" size="xs" :stroke-width="2" />
{{ t('version.rollback') }}
</span>
<Icon
name="chevronDown"
size="xs"
:stroke-width="2"
class="transition-transform duration-200"
:class="{ 'rotate-180': rollbackPanelOpen }"
/>
</button>
<transition name="rollback">
<div v-if="rollbackPanelOpen" class="mt-2 space-y-2">
<!-- Source build: online rollback unavailable, use git instead -->
<div
v-if="!isReleaseBuild"
class="flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 p-2 dark:border-blue-800/50 dark:bg-blue-900/20"
>
<svg
class="h-3.5 w-3.5 flex-shrink-0 text-blue-500 dark:text-blue-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<p class="min-w-0 flex-1 text-xs leading-4 text-blue-600 dark:text-blue-400">
{{ t('version.rollbackSourceHint') }}
</p>
</div>
<!-- Loading versions -->
<div
v-else-if="rollbackVersionsLoading"
class="flex items-center justify-center py-4"
>
<svg
class="h-5 w-5 animate-spin text-primary-500"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</div>
<!-- Load error + retry -->
<div v-else-if="rollbackVersionsError" class="space-y-2">
<p
class="rounded-lg border border-red-200 bg-red-50 p-2.5 text-xs text-red-600 dark:border-red-800/50 dark:bg-red-900/20 dark:text-red-400"
>
{{ rollbackVersionsError }}
</p>
<button
@click="loadRollbackVersions"
class="w-full rounded-lg border border-gray-200 py-1.5 text-xs text-gray-500 transition-colors hover:bg-gray-50 hover:text-gray-700 dark:border-dark-700 dark:text-dark-400 dark:hover:bg-dark-700/50 dark:hover:text-dark-200"
>
{{ t('version.retry') }}
</button>
</div>
<!-- No versions available -->
<p
v-else-if="rollbackVersions.length === 0"
class="py-3 text-center text-xs text-gray-400 dark:text-dark-500"
>
{{ t('version.noRollbackVersions') }}
</p>
<!-- Version list -->
<template v-else>
<p class="px-0.5 text-[11px] text-gray-400 dark:text-dark-500">
{{ t('version.rollbackSelectVersion') }}
</p>
<button
v-for="item in rollbackVersions"
:key="item.version"
@click="selectRollbackVersion(item.version)"
:disabled="rollingBack"
class="flex w-full items-center justify-between rounded-lg border px-3 py-2 text-left transition-all disabled:cursor-not-allowed disabled:opacity-60"
:class="
selectedRollbackVersion === item.version
? 'border-amber-300 bg-amber-50 shadow-sm dark:border-amber-700 dark:bg-amber-900/20'
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50 dark:border-dark-700 dark:hover:border-dark-600 dark:hover:bg-dark-700/40'
"
>
<span class="flex items-center gap-2">
<span
class="flex h-3.5 w-3.5 items-center justify-center rounded-full border transition-colors"
:class="
selectedRollbackVersion === item.version
? 'border-amber-500'
: 'border-gray-300 dark:border-dark-500'
"
>
<span
v-if="selectedRollbackVersion === item.version"
class="h-1.5 w-1.5 rounded-full bg-amber-500"
></span>
</span>
<span
class="text-sm font-semibold"
:class="
selectedRollbackVersion === item.version
? 'text-amber-700 dark:text-amber-300'
: 'text-gray-700 dark:text-dark-200'
"
>v{{ item.version }}</span
>
</span>
<span class="text-[11px] tabular-nums text-gray-400 dark:text-dark-500">
{{ formatPublishedAt(item.published_at) }}
</span>
</button>
<!-- Selected version: manual command (per deploy method) + confirm -->
<transition name="rollback">
<div v-if="selectedRollbackVersion" class="space-y-2">
<p class="px-0.5 text-[11px] text-gray-400 dark:text-dark-500">
{{ t('version.manualRollbackCommand') }}
</p>
<!-- Terminal-style block with deploy-method tabs -->
<div
class="overflow-hidden rounded-lg border border-gray-200 dark:border-dark-600"
>
<div
class="flex items-center justify-between border-b border-gray-200 bg-gray-100 px-2 py-1.5 dark:border-dark-600 dark:bg-dark-700"
>
<div
class="flex items-center gap-0.5 rounded-md bg-gray-200/70 p-0.5 dark:bg-dark-600/70"
>
<button
v-for="tab in manualTabs"
:key="tab.key"
@click="manualTab = tab.key"
class="rounded px-2 py-0.5 text-[11px] font-medium transition-colors"
:class="
manualTab === tab.key
? 'bg-white text-gray-700 shadow-sm dark:bg-dark-800 dark:text-dark-100'
: 'text-gray-400 hover:text-gray-600 dark:text-dark-400 dark:hover:text-dark-200'
"
>
{{ tab.label }}
</button>
</div>
<button
@click="copyToClipboard(activeManualCommand)"
class="flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] text-gray-400 transition-colors hover:bg-gray-200 hover:text-gray-600 dark:text-dark-400 dark:hover:bg-dark-600 dark:hover:text-dark-200"
>
<Icon
:name="copied ? 'check' : 'copy'"
size="xs"
:stroke-width="2"
:class="copied ? 'text-green-500' : ''"
/>
{{ copied ? t('version.copied') : t('version.copyCommand') }}
</button>
</div>
<code
class="block select-all whitespace-pre-wrap break-all bg-gray-50 p-2.5 font-mono text-[10px] leading-relaxed text-gray-600 dark:bg-dark-900 dark:text-dark-300"
>{{ activeManualCommand }}</code
>
</div>
<p
class="flex items-start gap-1.5 px-0.5 text-[11px] leading-4 text-amber-600 dark:text-amber-400"
>
<Icon
name="exclamationTriangle"
size="xs"
:stroke-width="2"
class="mt-px flex-shrink-0"
/>
{{ t('version.rollbackWarning') }}
</p>
<p
v-if="rollbackError"
class="rounded-lg border border-red-200 bg-red-50 p-2 text-xs text-red-600 dark:border-red-800/50 dark:bg-red-900/20 dark:text-red-400"
>
{{ rollbackError }}
</p>
<button
@click="handleRollback"
:disabled="rollingBack"
class="flex w-full items-center justify-center gap-2 rounded-lg bg-amber-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-amber-600 disabled:cursor-not-allowed disabled:opacity-50"
>
<svg
v-if="rollingBack"
class="h-4 w-4 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<Icon v-else name="clock" size="sm" :stroke-width="2" />
<span>{{
rollingBack
? t('version.rollingBack')
: t('version.rollbackConfirm', {
version: 'v' + selectedRollbackVersion
})
}}</span>
</button>
</div>
</transition>
</template>
</div>
</transition>
</div>
</div>
</template>
</div>
</div>
@@ -384,9 +641,20 @@
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAuthStore, useAppStore } from '@/stores'
import { performUpdate, restartService } from '@/api/admin/system'
import {
performUpdate,
restartService,
getRollbackVersions,
rollback as rollbackAPI,
type RollbackVersionInfo
} from '@/api/admin/system'
import { useClipboard } from '@/composables/useClipboard'
import Icon from '@/components/icons/Icon.vue'
const GITHUB_REPO = 'Wei-Shaw/sub2api'
// Docker Hub image published by CI (tags carry no "v" prefix, e.g. weishaw/sub2api:0.1.146)
const DOCKER_IMAGE = 'weishaw/sub2api'
const { t } = useI18n()
const props = defineProps<{
@@ -416,6 +684,49 @@ const needRestart = ref(false)
const updateError = ref('')
const updateSuccess = ref(false)
const restartCountdown = ref(0)
// Distinguishes the success + restart panel between update and rollback flows
const successKind = ref<'update' | 'rollback'>('update')
// Rollback states
const rollbackPanelOpen = ref(false)
const rollbackVersions = ref<RollbackVersionInfo[]>([])
const rollbackVersionsLoading = ref(false)
const rollbackVersionsError = ref('')
const selectedRollbackVersion = ref('')
const rollingBack = ref(false)
const rollbackError = ref('')
const { copied, copyToClipboard } = useClipboard()
// Manual rollback methods differ by deployment: script installs use install.sh,
// docker deployments pin the image tag instead
const manualTab = ref<'script' | 'docker'>('script')
const manualTabs = computed(() => [
{ key: 'script' as const, label: t('version.deployScript') },
{ key: 'docker' as const, label: t('version.deployDocker') }
])
const scriptRollbackCommand = computed(() => {
if (!selectedRollbackVersion.value) return ''
const tag = `v${selectedRollbackVersion.value}`
return `curl -sSL https://raw.githubusercontent.com/${GITHUB_REPO}/${tag}/deploy/install.sh | sudo bash -s -- rollback ${tag}`
})
const dockerRollbackCommand = computed(() => {
if (!selectedRollbackVersion.value) return ''
return [
`# ${t('version.dockerEditCompose')}`,
`image: ${DOCKER_IMAGE}:${selectedRollbackVersion.value}`,
'',
`# ${t('version.dockerRecreate')}`,
'docker compose up -d'
].join('\n')
})
const activeManualCommand = computed(() =>
manualTab.value === 'docker' ? dockerRollbackCommand.value : scriptRollbackCommand.value
)
// Only show update check for release builds (binary/docker deployment)
const isReleaseBuild = computed(() => buildType.value === 'release')
@@ -435,6 +746,7 @@ async function refreshVersion(force = true) {
updateError.value = ''
updateSuccess.value = false
needRestart.value = false
resetRollbackState()
await appStore.fetchVersion(force)
}
@@ -448,6 +760,7 @@ async function handleUpdate() {
try {
const result = await performUpdate()
successKind.value = 'update'
updateSuccess.value = true
needRestart.value = result.need_restart
// Clear version cache to reflect update completed
@@ -460,6 +773,81 @@ async function handleUpdate() {
}
}
function resetRollbackState() {
rollbackPanelOpen.value = false
rollbackVersions.value = []
rollbackVersionsError.value = ''
selectedRollbackVersion.value = ''
rollbackError.value = ''
manualTab.value = 'script'
}
async function toggleRollbackPanel() {
if (!isAdmin.value) return
rollbackPanelOpen.value = !rollbackPanelOpen.value
// Source builds only show a hint, no version list to fetch
if (
rollbackPanelOpen.value &&
isReleaseBuild.value &&
rollbackVersions.value.length === 0 &&
!rollbackVersionsLoading.value
) {
await loadRollbackVersions()
}
}
async function loadRollbackVersions() {
if (!isAdmin.value) return
rollbackVersionsLoading.value = true
rollbackVersionsError.value = ''
try {
const data = await getRollbackVersions()
rollbackVersions.value = data.versions || []
} catch (error: unknown) {
const err = error as { response?: { data?: { message?: string } }; message?: string }
rollbackVersionsError.value =
err.response?.data?.message || err.message || t('version.loadVersionsFailed')
} finally {
rollbackVersionsLoading.value = false
}
}
function selectRollbackVersion(version: string) {
if (rollingBack.value) return
rollbackError.value = ''
selectedRollbackVersion.value = selectedRollbackVersion.value === version ? '' : version
}
function formatPublishedAt(publishedAt: string): string {
if (!publishedAt) return ''
const date = new Date(publishedAt)
if (Number.isNaN(date.getTime())) return ''
return date.toLocaleDateString()
}
async function handleRollback() {
if (!isAdmin.value) return
if (rollingBack.value || !selectedRollbackVersion.value) return
rollingBack.value = true
rollbackError.value = ''
try {
const result = await rollbackAPI(selectedRollbackVersion.value)
successKind.value = 'rollback'
updateSuccess.value = true
needRestart.value = result.need_restart
rollbackPanelOpen.value = false
// Clear version cache so the next check reflects the rolled-back version
appStore.clearVersionCache()
} catch (error: unknown) {
const err = error as { response?: { data?: { message?: string } }; message?: string }
rollbackError.value = err.response?.data?.message || err.message || t('version.rollbackFailed')
} finally {
rollingBack.value = false
}
}
async function handleRestart() {
if (restarting.value) return
@@ -546,6 +934,17 @@ onBeforeUnmount(() => {
transform: scale(0.95) translateY(-4px);
}
.rollback-enter-active,
.rollback-leave-active {
transition: all 0.2s ease;
}
.rollback-enter-from,
.rollback-leave-to {
opacity: 0;
transform: translateY(-4px);
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
+19 -1
View File
@@ -38,7 +38,25 @@ export default {
restartRequired: 'Please restart the service to apply the update',
restartNow: 'Restart Now',
restarting: 'Restarting...',
retry: 'Retry'
retry: 'Retry',
rollback: 'Version Rollback',
rollbackSelectVersion: 'Select a version to roll back to (last 3 versions)',
rollbackConfirm: 'Roll back to {version}',
rollbackWarning:
'Rollback downloads the selected version and replaces the current binary. A service restart is required afterwards.',
rollingBack: 'Rolling back...',
rollbackComplete: 'Rollback Complete',
rollbackFailed: 'Rollback Failed',
manualRollbackCommand: 'Manual rollback',
copyCommand: 'Copy',
copied: 'Copied',
noRollbackVersions: 'No versions available for rollback',
loadVersionsFailed: 'Failed to load versions',
rollbackSourceHint: 'Online rollback is not available for source builds',
deployScript: 'Script',
deployDocker: 'Docker',
dockerEditCompose: 'Edit the image tag in docker-compose.yml',
dockerRecreate: 'Recreate the container'
},
// Recharge / Subscription Page
+18 -1
View File
@@ -38,7 +38,24 @@ export default {
restartRequired: '请重启服务以应用更新',
restartNow: '立即重启',
restarting: '正在重启...',
retry: '重试'
retry: '重试',
rollback: '版本回退',
rollbackSelectVersion: '选择要回退到的版本(近 3 个版本)',
rollbackConfirm: '回退到 {version}',
rollbackWarning: '回退将下载所选版本并替换当前程序,完成后需重启服务',
rollingBack: '正在回退...',
rollbackComplete: '回退完成',
rollbackFailed: '回退失败',
manualRollbackCommand: '手动回退方式',
copyCommand: '复制',
copied: '已复制',
noRollbackVersions: '暂无可回退的版本',
loadVersionsFailed: '获取版本列表失败',
rollbackSourceHint: '源码构建不支持在线回退',
deployScript: '脚本部署',
deployDocker: 'Docker',
dockerEditCompose: '修改 docker-compose.yml 中的镜像版本',
dockerRecreate: '重新创建容器'
},
// Recharge / Subscription Page