From 93be60ed99b31cc8f792c7ae003e2cbca0acd15c Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:02:40 +0800 Subject: [PATCH] add docker hot update management --- Dockerfile | 1 + docker-compose.yml | 8 + internal/handler/routes_admin.go | 3 + internal/handler/system_extra.go | 9 + internal/handler/system_update.go | 49 +++ internal/service/service.go | 1 + internal/service/service_builder.go | 1 + internal/service/system_update.go | 458 +++++++++++++++++++++ internal/service/system_update_test.go | 77 ++++ internal/service/task_tracker.go | 1 + web/src/api/admin.ts | 25 ++ web/src/components/LayoutUserMenu.tsx | 127 +++--- web/src/pages/SettingsPage.tsx | 54 +-- web/src/pages/SystemUpdatePanel.tsx | 246 +++++++++++ web/src/pages/settingsGroupSystemUpdate.ts | 33 ++ web/src/pages/settingsGroups.ts | 2 + 16 files changed, 1023 insertions(+), 72 deletions(-) create mode 100644 internal/handler/system_update.go create mode 100644 internal/service/system_update.go create mode 100644 internal/service/system_update_test.go create mode 100644 web/src/pages/SystemUpdatePanel.tsx create mode 100644 web/src/pages/settingsGroupSystemUpdate.ts diff --git a/Dockerfile b/Dockerfile index c0888a6..925de01 100644 --- a/Dockerfile +++ b/Dockerfile @@ -49,6 +49,7 @@ ARG WITH_VAAPI=false # NVENC requires the proprietary NVIDIA Container Toolkit on the host only. RUN apk add --no-cache \ ffmpeg \ + docker-cli \ tzdata \ ca-certificates \ su-exec \ diff --git a/docker-compose.yml b/docker-compose.yml index 8f02b7f..05416ad 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -64,6 +64,11 @@ services: # - D:/Downloads:/downloads - ./downloads:/downloads + # 管理面板「系统更新」需要访问 Docker 引擎。 + # 需要一键更新 Docker 镜像时取消下一行注释;如果提示权限不足, + # 请确认 PUID/PGID 对 /var/run/docker.sock 有读写权限。 + # - /var/run/docker.sock:/var/run/docker.sock + environment: TZ: Asia/Shanghai @@ -94,6 +99,9 @@ services: MEDIASTATION_DATABASE_DB_PATH: /data/mediastation.db MEDIASTATION_CACHE_CACHE_DIR: /cache + # 管理面板热更新默认拉取此镜像,并用 Watchtower 一次性重建当前容器。 + MEDIASTATION_UPDATE_IMAGE: ghcr.io/shukebta/mediastation-go:latest + # 路径换算配置。 # 如果上面 volumes 的 ./media 改成 /vol1/1000/Media, # 这里也要改成同一个宿主机真实路径。 diff --git a/internal/handler/routes_admin.go b/internal/handler/routes_admin.go index 27beb11..d7683d1 100644 --- a/internal/handler/routes_admin.go +++ b/internal/handler/routes_admin.go @@ -79,6 +79,9 @@ func registerAdminDownloadClientRoutes(admin *gin.RouterGroup, svc *service.Cont func registerAdminSystemRoutes(admin *gin.RouterGroup, svc *service.Container) { admin.POST("/system/scheduler/:name/trigger", schedulerTriggerHandler(svc)) + admin.GET("/system/update", systemUpdateStatusHandler(svc)) + admin.POST("/system/update/check", systemUpdateCheckHandler(svc)) + admin.POST("/system/update/apply", systemUpdateApplyHandler(svc)) } func registerAdminBackupRoutes(admin *gin.RouterGroup, svc *service.Container) { diff --git a/internal/handler/system_extra.go b/internal/handler/system_extra.go index 9eec159..5c26c12 100644 --- a/internal/handler/system_extra.go +++ b/internal/handler/system_extra.go @@ -131,6 +131,15 @@ func schemaHandler(_ *service.Container) gin.HandlerFunc { {"key": "license.hmac_secret", "type": "text", "label": "HMAC 签名密钥(旧版兼容)"}, }, }, + { + "key": "system-update", + "label": "系统更新", + "items": []gin.H{ + {"key": "system.update.image", "type": "text", "label": "应用镜像"}, + {"key": "system.update.watchtower_image", "type": "text", "label": "Watchtower 镜像"}, + {"key": "system.update.command", "type": "textarea", "label": "自定义更新命令"}, + }, + }, }, }) } diff --git a/internal/handler/system_update.go b/internal/handler/system_update.go new file mode 100644 index 0000000..4d6360c --- /dev/null +++ b/internal/handler/system_update.go @@ -0,0 +1,49 @@ +package handler + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/ShukeBta/MediaStationGo/internal/service" +) + +func systemUpdateStatusHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + if svc.SystemUpdate == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "system update service unavailable"}) + return + } + c.JSON(http.StatusOK, svc.SystemUpdate.Status(c.Request.Context())) + } +} + +func systemUpdateCheckHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + if svc.SystemUpdate == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "system update service unavailable"}) + return + } + c.JSON(http.StatusOK, svc.SystemUpdate.Check(c.Request.Context())) + } +} + +func systemUpdateApplyHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + if svc.SystemUpdate == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "system update service unavailable"}) + return + } + status, err := svc.SystemUpdate.Apply(c.Request.Context()) + if err != nil { + if errors.Is(err, service.ErrSystemUpdateRunning) { + c.JSON(http.StatusConflict, gin.H{"error": err.Error(), "status": status}) + return + } + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "status": status}) + return + } + c.JSON(http.StatusAccepted, status) + } +} diff --git a/internal/service/service.go b/internal/service/service.go index c9fd2ec..acdad49 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -63,6 +63,7 @@ type Container struct { Permissions *PermissionService StorageCfg *StorageConfigService STRM *STRMService + SystemUpdate *SystemUpdateService DownloadClients *DownloadClientService Assistant *AssistantService Organizer *OrganizerService diff --git a/internal/service/service_builder.go b/internal/service/service_builder.go index 99d617b..c4dbebb 100644 --- a/internal/service/service_builder.go +++ b/internal/service/service_builder.go @@ -45,6 +45,7 @@ func (b *serviceContainerBuilder) startRealtimeServices() { b.c.WSHub = NewHub(b.log) go b.c.WSHub.Run() b.c.Tasks = NewTaskTrackerService(b.log, b.c.WSHub) + b.c.SystemUpdate = NewSystemUpdateService(b.cfg, b.log, b.repos, b.c.Tasks) b.c.SSEHub = NewSSEHub(b.log) go b.c.SSEHub.Run() diff --git a/internal/service/system_update.go b/internal/service/system_update.go new file mode 100644 index 0000000..c00108b --- /dev/null +++ b/internal/service/system_update.go @@ -0,0 +1,458 @@ +package service + +import ( + "bytes" + "context" + "errors" + "os" + "os/exec" + "regexp" + "runtime" + "strings" + "sync" + "time" + + "go.uber.org/zap" + + "github.com/ShukeBta/MediaStationGo/internal/config" + "github.com/ShukeBta/MediaStationGo/internal/repository" +) + +const ( + SystemUpdateImageSettingKey = "system.update.image" + SystemUpdateWatchtowerImageSettingKey = "system.update.watchtower_image" + SystemUpdateCommandSettingKey = "system.update.command" + + DefaultSystemUpdateImage = "ghcr.io/shukebta/mediastation-go:latest" + DefaultSystemUpdateWatchtowerImage = "containrrr/watchtower:latest" +) + +const ( + systemUpdateCheckTimeout = 20 * time.Second + systemUpdateRunTimeout = 15 * time.Minute +) + +var ErrSystemUpdateRunning = errors.New("system update already running") + +type SystemUpdateStatus struct { + Image string `json:"image"` + WatchtowerImage string `json:"watchtower_image,omitempty"` + ContainerID string `json:"container_id,omitempty"` + ContainerName string `json:"container_name,omitempty"` + CurrentImageID string `json:"current_image_id,omitempty"` + LocalDigest string `json:"local_digest,omitempty"` + RemoteDigest string `json:"remote_digest,omitempty"` + DockerAvailable bool `json:"docker_available"` + CanApply bool `json:"can_apply"` + UpdateAvailable *bool `json:"update_available,omitempty"` + Running bool `json:"running"` + TaskID string `json:"task_id,omitempty"` + Message string `json:"message,omitempty"` + Details string `json:"details,omitempty"` + CheckedAt *string `json:"checked_at,omitempty"` + StartedAt *string `json:"started_at,omitempty"` +} + +type SystemUpdateService struct { + cfg *config.Config + log *zap.Logger + repo *repository.Container + tasks *TaskTrackerService + + mu sync.Mutex + running bool + last *SystemUpdateStatus +} + +func NewSystemUpdateService(cfg *config.Config, log *zap.Logger, repo *repository.Container, tasks *TaskTrackerService) *SystemUpdateService { + return &SystemUpdateService{cfg: cfg, log: log, repo: repo, tasks: tasks} +} + +func (s *SystemUpdateService) Status(ctx context.Context) SystemUpdateStatus { + s.mu.Lock() + if s.last != nil { + status := cloneSystemUpdateStatus(*s.last) + status.Running = s.running + s.mu.Unlock() + return status + } + running := s.running + s.mu.Unlock() + + status := s.baseStatus(ctx) + status.Running = running + status.Message = "尚未检查更新" + return status +} + +func (s *SystemUpdateService) Check(ctx context.Context) SystemUpdateStatus { + status := s.check(ctx) + s.mu.Lock() + status.Running = s.running + s.last = &status + s.mu.Unlock() + return status +} + +func (s *SystemUpdateService) Apply(ctx context.Context) (SystemUpdateStatus, error) { + s.mu.Lock() + if s.running { + status := SystemUpdateStatus{} + if s.last != nil { + status = cloneSystemUpdateStatus(*s.last) + } + status.Running = true + s.mu.Unlock() + return status, ErrSystemUpdateRunning + } + s.running = true + s.mu.Unlock() + + status := s.check(ctx) + if !status.CanApply { + s.finishWithoutTask(status) + return status, errors.New(firstNonEmpty(status.Message, "当前环境不支持自动更新")) + } + + task := s.startUpdateTask(status) + if task != nil { + status.TaskID = task.id + } + now := time.Now().Format(time.RFC3339) + status.StartedAt = &now + status.Running = true + status.Message = "已开始后台更新,容器会在更新完成后重启" + s.mu.Lock() + s.last = &status + s.mu.Unlock() + + go s.runUpdate(context.Background(), status, task) + return status, nil +} + +func (s *SystemUpdateService) finishWithoutTask(status SystemUpdateStatus) { + status.Running = false + s.mu.Lock() + s.running = false + s.last = &status + s.mu.Unlock() +} + +func (s *SystemUpdateService) check(ctx context.Context) SystemUpdateStatus { + status := s.baseStatus(ctx) + now := time.Now().Format(time.RFC3339) + status.CheckedAt = &now + + customCommand := s.rawUpdateCommand(ctx) + dockerPath, err := exec.LookPath("docker") + if err != nil { + if customCommand != "" { + status.CanApply = true + status.Message = "当前环境无法检查 Docker;将执行自定义更新命令" + status.Details = err.Error() + return status + } + status.Message = "当前镜像内未安装 docker CLI,无法自动拉取并重启 Docker 镜像" + return status + } + if runtime.GOOS != "windows" { + if _, err := os.Stat("/var/run/docker.sock"); err != nil { + if customCommand != "" { + status.CanApply = true + status.Message = "未检测到 Docker socket;将执行自定义更新命令" + status.Details = err.Error() + return status + } + status.Message = "未检测到 /var/run/docker.sock,请挂载 Docker socket 后再使用热更新" + status.Details = err.Error() + return status + } + } + + checkCtx, cancel := context.WithTimeout(ctx, systemUpdateCheckTimeout) + defer cancel() + if out, err := runSystemUpdateCommand(checkCtx, dockerPath, "version", "--format", "{{.Server.Version}}"); err != nil { + if customCommand != "" { + status.CanApply = true + status.Message = "无法连接 Docker 引擎;将执行自定义更新命令" + status.Details = strings.TrimSpace(out + "\n" + err.Error()) + return status + } + status.Message = "无法连接 Docker 引擎,请检查 Docker socket 权限" + status.Details = strings.TrimSpace(out + "\n" + err.Error()) + return status + } + status.DockerAvailable = true + + containerID := currentContainerID() + status.ContainerID = containerID + if containerID == "" { + status.Message = "无法识别当前容器 ID;请配置自定义更新命令" + if customCommand != "" { + status.CanApply = true + status.Message = "无法识别当前容器 ID;将执行自定义更新命令" + } + return status + } + + if out, err := runSystemUpdateCommand(checkCtx, dockerPath, "inspect", containerID, "--format", "{{.Name}}|{{.Image}}"); err == nil { + name, imageID := parseContainerInspectLine(out) + status.ContainerName = name + status.CurrentImageID = imageID + } + if out, err := runSystemUpdateCommand(checkCtx, dockerPath, "image", "inspect", status.Image, "--format", "{{json .RepoDigests}}"); err == nil { + status.LocalDigest = firstDockerDigest(out) + } + if out, err := runSystemUpdateCommand(checkCtx, dockerPath, "manifest", "inspect", "--verbose", status.Image); err == nil { + status.RemoteDigest = firstDockerDigest(out) + } else if status.Details == "" { + status.Details = strings.TrimSpace(out + "\n" + err.Error()) + } + status.UpdateAvailable = compareDockerDigests(status.LocalDigest, status.RemoteDigest) + status.CanApply = true + status.Message = systemUpdateCheckMessage(status) + return status +} + +func (s *SystemUpdateService) runUpdate(parent context.Context, status SystemUpdateStatus, task *TaskHandle) { + runCtx, cancel := context.WithTimeout(parent, systemUpdateRunTimeout) + defer cancel() + + command := s.updateCommand(runCtx, status) + if task != nil { + task.Update(TaskUpdate{ + Stage: "pull_restart", + Message: "正在拉取最新镜像并重建当前容器", + Details: []string{ + "image=" + status.Image, + "container=" + firstNonEmpty(status.ContainerName, status.ContainerID), + "command=" + redactSystemUpdateCommand(command), + }, + }) + } + + output, err := runSystemUpdateShell(runCtx, command) + details := systemUpdateOutputDetails(output) + if task != nil { + task.Finish(err, TaskUpdate{ + Stage: "completed", + Message: "更新命令已执行;如果容器未自动重启,请查看 Docker 日志", + Details: details, + }) + } + status.Running = false + status.Message = "更新命令已执行;如果容器未自动重启,请查看 Docker 日志" + status.Details = strings.Join(details, "\n") + if err != nil { + status.Message = "更新失败:" + err.Error() + } + s.mu.Lock() + s.running = false + s.last = &status + s.mu.Unlock() + if err != nil && s.log != nil { + s.log.Warn("system update failed", zap.Error(err), zap.String("output", output)) + } +} + +func (s *SystemUpdateService) startUpdateTask(status SystemUpdateStatus) *TaskHandle { + if s == nil || s.tasks == nil { + return nil + } + return s.tasks.Start(TaskKindUpdate, "Docker 镜像热更新", TaskUpdate{ + Stage: "prepare", + SourcePath: status.Image, + DestPath: firstNonEmpty(status.ContainerName, status.ContainerID), + Message: "准备拉取最新版 Docker 镜像并重启容器", + }) +} + +func (s *SystemUpdateService) baseStatus(ctx context.Context) SystemUpdateStatus { + image := s.setting(ctx, SystemUpdateImageSettingKey, os.Getenv("MEDIASTATION_UPDATE_IMAGE")) + if image == "" { + image = DefaultSystemUpdateImage + } + watchtowerImage := s.setting(ctx, SystemUpdateWatchtowerImageSettingKey, os.Getenv("MEDIASTATION_UPDATE_WATCHTOWER_IMAGE")) + if watchtowerImage == "" { + watchtowerImage = DefaultSystemUpdateWatchtowerImage + } + return SystemUpdateStatus{ + Image: image, + WatchtowerImage: watchtowerImage, + ContainerID: currentContainerID(), + } +} + +func (s *SystemUpdateService) setting(ctx context.Context, key, fallback string) string { + if s == nil || s.repo == nil || s.repo.Setting == nil { + return strings.TrimSpace(fallback) + } + if value, err := s.repo.Setting.Get(ctx, key); err == nil && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + return strings.TrimSpace(fallback) +} + +func (s *SystemUpdateService) updateCommand(ctx context.Context, status SystemUpdateStatus) string { + custom := s.rawUpdateCommand(ctx) + if strings.TrimSpace(custom) != "" { + return renderSystemUpdateCommand(custom, status) + } + return renderSystemUpdateCommand(defaultSystemUpdateCommand(), status) +} + +func (s *SystemUpdateService) rawUpdateCommand(ctx context.Context) string { + return s.setting(ctx, SystemUpdateCommandSettingKey, os.Getenv("MEDIASTATION_UPDATE_COMMAND")) +} + +func defaultSystemUpdateCommand() string { + return "docker run --rm -v /var/run/docker.sock:/var/run/docker.sock {{watchtower_image}} --run-once --cleanup {{container}}" +} + +func renderSystemUpdateCommand(template string, status SystemUpdateStatus) string { + replacements := map[string]string{ + "{{image}}": shellQuote(status.Image), + "{{watchtower_image}}": shellQuote(firstNonEmpty(status.WatchtowerImage, DefaultSystemUpdateWatchtowerImage)), + "{{container}}": shellQuote(firstNonEmpty(status.ContainerName, status.ContainerID)), + "{{container_id}}": shellQuote(status.ContainerID), + "{{container_name}}": shellQuote(status.ContainerName), + } + out := template + for marker, value := range replacements { + out = strings.ReplaceAll(out, marker, value) + } + return strings.TrimSpace(out) +} + +func runSystemUpdateCommand(ctx context.Context, name string, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, name, args...) + var buf bytes.Buffer + cmd.Stdout = &buf + cmd.Stderr = &buf + err := cmd.Run() + return buf.String(), err +} + +func runSystemUpdateShell(ctx context.Context, command string) (string, error) { + if strings.TrimSpace(command) == "" { + return "", errors.New("empty update command") + } + if runtime.GOOS == "windows" { + return runSystemUpdateCommand(ctx, "cmd", "/C", command) + } + return runSystemUpdateCommand(ctx, "/bin/sh", "-c", command) +} + +func currentContainerID() string { + if value := strings.TrimSpace(os.Getenv("HOSTNAME")); looksLikeContainerID(value) { + return value + } + host, err := os.Hostname() + if err != nil { + return "" + } + host = strings.TrimSpace(host) + if looksLikeContainerID(host) { + return host + } + return "" +} + +func looksLikeContainerID(value string) bool { + if len(value) < 12 { + return false + } + for _, r := range value { + if (r >= 'a' && r <= 'f') || (r >= '0' && r <= '9') { + continue + } + return false + } + return true +} + +func parseContainerInspectLine(raw string) (string, string) { + line := strings.TrimSpace(raw) + parts := strings.SplitN(line, "|", 2) + if len(parts) != 2 { + return strings.TrimPrefix(line, "/"), "" + } + return strings.TrimPrefix(strings.TrimSpace(parts[0]), "/"), strings.TrimSpace(parts[1]) +} + +var dockerDigestPattern = regexp.MustCompile(`sha256:[a-fA-F0-9]{64}`) + +func firstDockerDigest(raw string) string { + match := dockerDigestPattern.FindString(raw) + return strings.ToLower(strings.TrimSpace(match)) +} + +func compareDockerDigests(localDigest, remoteDigest string) *bool { + localDigest = strings.ToLower(strings.TrimSpace(localDigest)) + remoteDigest = strings.ToLower(strings.TrimSpace(remoteDigest)) + if localDigest == "" || remoteDigest == "" { + return nil + } + available := localDigest != remoteDigest + return &available +} + +func systemUpdateCheckMessage(status SystemUpdateStatus) string { + if status.UpdateAvailable == nil { + return "已连接 Docker,可执行更新;当前环境无法精确比较本地与远端镜像摘要" + } + if *status.UpdateAvailable { + return "检测到远端镜像摘要与本地不同,可以执行更新" + } + return "当前本地镜像摘要与远端一致" +} + +func systemUpdateOutputDetails(output string) []string { + output = strings.TrimSpace(output) + if output == "" { + return nil + } + lines := strings.Split(output, "\n") + if len(lines) > 12 { + lines = lines[len(lines)-12:] + } + for i := range lines { + lines[i] = strings.TrimRight(lines[i], "\r") + } + return lines +} + +func redactSystemUpdateCommand(command string) string { + command = strings.TrimSpace(command) + if len(command) > 500 { + return command[:500] + "..." + } + return command +} + +func shellQuote(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "''" + } + if runtime.GOOS == "windows" { + return `"` + strings.ReplaceAll(value, `"`, `\"`) + `"` + } + return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'" +} + +func cloneSystemUpdateStatus(status SystemUpdateStatus) SystemUpdateStatus { + if status.UpdateAvailable != nil { + v := *status.UpdateAvailable + status.UpdateAvailable = &v + } + if status.CheckedAt != nil { + v := *status.CheckedAt + status.CheckedAt = &v + } + if status.StartedAt != nil { + v := *status.StartedAt + status.StartedAt = &v + } + return status +} diff --git a/internal/service/system_update_test.go b/internal/service/system_update_test.go new file mode 100644 index 0000000..9886a25 --- /dev/null +++ b/internal/service/system_update_test.go @@ -0,0 +1,77 @@ +package service + +import ( + "strings" + "testing" +) + +func TestRenderSystemUpdateCommand(t *testing.T) { + status := SystemUpdateStatus{ + Image: "ghcr.io/shukebta/mediastation-go:latest", + WatchtowerImage: "containrrr/watchtower:latest", + ContainerID: "abc123def456", + ContainerName: "mediastation-go", + } + + got := renderSystemUpdateCommand( + "docker run {{watchtower_image}} --run-once {{container}} --image {{image}} --id {{container_id}}", + status, + ) + for _, marker := range []string{"{{watchtower_image}}", "{{container}}", "{{image}}", "{{container_id}}"} { + if strings.Contains(got, marker) { + t.Fatalf("command still contains marker %s: %q", marker, got) + } + } + for _, want := range []string{ + "containrrr/watchtower:latest", + "mediastation-go", + "ghcr.io/shukebta/mediastation-go:latest", + "abc123def456", + } { + if !strings.Contains(got, want) { + t.Fatalf("command %q does not contain %q", got, want) + } + } +} + +func TestParseContainerInspectLine(t *testing.T) { + name, imageID := parseContainerInspectLine("/mediastation-go|sha256:abc") + if name != "mediastation-go" || imageID != "sha256:abc" { + t.Fatalf("parse inspect line = %q %q", name, imageID) + } + + name, imageID = parseContainerInspectLine("/fallback") + if name != "fallback" || imageID != "" { + t.Fatalf("parse fallback line = %q %q", name, imageID) + } +} + +func TestDockerDigestHelpers(t *testing.T) { + const local = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + const remote = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + + raw := `{"Descriptor":{"digest":"` + remote + `"}}` + if got := firstDockerDigest(raw); got != remote { + t.Fatalf("firstDockerDigest = %q, want %q", got, remote) + } + if got := compareDockerDigests("", remote); got != nil { + t.Fatalf("missing local digest should be unknown, got %#v", *got) + } + if got := compareDockerDigests(local, remote); got == nil || !*got { + t.Fatalf("different digests should mean update available, got %#v", got) + } + if got := compareDockerDigests(local, local); got == nil || *got { + t.Fatalf("same digests should mean no update, got %#v", got) + } +} + +func TestSystemUpdateOutputDetailsKeepsTail(t *testing.T) { + lines := make([]string, 0, 14) + for i := 0; i < 14; i++ { + lines = append(lines, "line") + } + got := systemUpdateOutputDetails(strings.Join(lines, "\n")) + if len(got) != 12 { + t.Fatalf("details length = %d, want 12", len(got)) + } +} diff --git a/internal/service/task_tracker.go b/internal/service/task_tracker.go index d3d4cb5..f71ae43 100644 --- a/internal/service/task_tracker.go +++ b/internal/service/task_tracker.go @@ -17,6 +17,7 @@ const ( TaskKindOrganize = "organize" TaskKindScan = "scan" TaskKindScrape = "scrape" + TaskKindUpdate = "update" ) // BackgroundTask is the compact, operator-facing shape shown on the live tasks diff --git a/web/src/api/admin.ts b/web/src/api/admin.ts index 19468e4..e65b5a3 100644 --- a/web/src/api/admin.ts +++ b/web/src/api/admin.ts @@ -1,6 +1,25 @@ import { api } from './client' import type { AccessLog, Setting, User } from '../types' +export interface SystemUpdateStatus { + image: string + watchtower_image?: string + container_id?: string + container_name?: string + current_image_id?: string + local_digest?: string + remote_digest?: string + docker_available: boolean + can_apply: boolean + update_available?: boolean + running: boolean + task_id?: string + message?: string + details?: string + checked_at?: string + started_at?: string +} + export const adminAPI = { listUsers: () => api.get('/admin/users').then((r) => r.data), @@ -24,4 +43,10 @@ export const adminAPI = { api.put('/admin/settings', { key, value }).then((r) => r.data), recentLogs: () => api.get('/admin/logs').then((r) => r.data), + + systemUpdateStatus: () => api.get('/admin/system/update').then((r) => r.data), + + systemUpdateCheck: () => api.post('/admin/system/update/check').then((r) => r.data), + + systemUpdateApply: () => api.post('/admin/system/update/apply').then((r) => r.data), } diff --git a/web/src/components/LayoutUserMenu.tsx b/web/src/components/LayoutUserMenu.tsx index d6f0a24..64764ea 100644 --- a/web/src/components/LayoutUserMenu.tsx +++ b/web/src/components/LayoutUserMenu.tsx @@ -1,4 +1,5 @@ -import { Link } from 'react-router-dom' +import { useEffect, useRef } from 'react' +import { Link, useLocation } from 'react-router-dom' import { AnimatePresence, motion } from 'framer-motion' import { ChevronDown, LogOut, Settings, User as UserIcon, UserCog } from 'lucide-react' import clsx from 'clsx' @@ -35,10 +36,44 @@ export function LayoutUserMenu({ onSwitchProfile, onLogout, }: LayoutUserMenuProps) { + const location = useLocation() + const rootRef = useRef(null) + const lastLocationRef = useRef(`${location.pathname}${location.search}`) + + useEffect(() => { + if (!isOpen) return undefined + + const handlePointerDown = (event: PointerEvent) => { + const root = rootRef.current + const target = event.target + if (!root || !(target instanceof Node) || root.contains(target)) return + onClose() + } + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') onClose() + } + + document.addEventListener('pointerdown', handlePointerDown, true) + document.addEventListener('keydown', handleKeyDown) + return () => { + document.removeEventListener('pointerdown', handlePointerDown, true) + document.removeEventListener('keydown', handleKeyDown) + } + }, [isOpen, onClose]) + + useEffect(() => { + const nextLocation = `${location.pathname}${location.search}` + if (lastLocationRef.current === nextLocation) return + lastLocationRef.current = nextLocation + if (isOpen) onClose() + }, [isOpen, location.pathname, location.search, onClose]) + return ( -
+
+ {profiles.map((profile) => ( - {profiles.map((profile) => ( - - ))} -
+ ))}
- } label="管理观影 Profile" onClick={onClose} /> -
- - - +
+ } label="管理观影 Profile" onClick={onClose} /> +
+ + )}
diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index 5d769a2..73f97b8 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -7,6 +7,7 @@ import { libraryAPI } from '../api/library' import type { Library, Setting } from '../types' import { SettingRow } from './SettingsRow' import { ALL_KEYS, GROUPS } from './settingsGroups' +import { SystemUpdatePanel } from './SystemUpdatePanel' export function SettingsPage() { const [activeGroup, setActiveGroup] = useState(GROUPS[0].key) @@ -104,31 +105,34 @@ export function SettingsPage() { )} {!loading && ( -
- {group.description &&

{group.description}

} - {group.items.map((it) => ( - onChange(it.key, v)} - libraries={libraries} - /> - ))} -
- - {dirty.size > 0 ? `有 ${dirty.size} 项未保存` : '所有更改已保存'} - - -
- +
+ {group.key === 'system-update' && } +
+ {group.description &&

{group.description}

} + {group.items.map((it) => ( + onChange(it.key, v)} + libraries={libraries} + /> + ))} +
+ + {dirty.size > 0 ? `有 ${dirty.size} 项未保存` : '所有更改已保存'} + + +
+ +
)} ) diff --git a/web/src/pages/SystemUpdatePanel.tsx b/web/src/pages/SystemUpdatePanel.tsx new file mode 100644 index 0000000..5045e4b --- /dev/null +++ b/web/src/pages/SystemUpdatePanel.tsx @@ -0,0 +1,246 @@ +import { useCallback, useEffect, useState } from 'react' +import { Link } from 'react-router-dom' +import toast from 'react-hot-toast' +import { + AlertTriangle, + CheckCircle2, + Clock3, + Loader2, + PackageCheck, + RefreshCw, + RotateCw, + Server, +} from 'lucide-react' + +import { adminAPI, type SystemUpdateStatus } from '../api/admin' + +type UpdateErrorPayload = { + response?: { + data?: { + error?: string + status?: SystemUpdateStatus + } + } +} + +export function SystemUpdatePanel() { + const [status, setStatus] = useState(null) + const [loading, setLoading] = useState(true) + const [checking, setChecking] = useState(false) + const [applying, setApplying] = useState(false) + + const refreshStatus = useCallback(async () => { + const next = await adminAPI.systemUpdateStatus() + setStatus(next) + return next + }, []) + + useEffect(() => { + let cancelled = false + setLoading(true) + adminAPI + .systemUpdateStatus() + .then((next) => { + if (!cancelled) setStatus(next) + }) + .catch(() => undefined) + .finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { + cancelled = true + } + }, []) + + useEffect(() => { + if (!status?.running) return undefined + const id = window.setInterval(() => { + refreshStatus().catch(() => undefined) + }, 3_000) + return () => window.clearInterval(id) + }, [refreshStatus, status?.running]) + + const checkUpdate = async () => { + setChecking(true) + try { + const next = await adminAPI.systemUpdateCheck() + setStatus(next) + toast.success(next.message || '检查完成') + } catch (err: unknown) { + toast.error(apiErrorMessage(err, '检查更新失败')) + } finally { + setChecking(false) + } + } + + const applyUpdate = async () => { + setApplying(true) + try { + const next = await adminAPI.systemUpdateApply() + setStatus(next) + toast.success(next.message || '更新任务已启动') + } catch (err: unknown) { + const payload = err as UpdateErrorPayload + if (payload.response?.data?.status) setStatus(payload.response.data.status) + toast.error(apiErrorMessage(err, '启动更新失败')) + } finally { + setApplying(false) + } + } + + const tone = updateTone(status) + const ToneIcon = tone.icon + + return ( +
+
+
+
+ +
+
+
+

Docker 热更新

+ + + {tone.label} + +
+

+ {loading ? '正在读取更新状态…' : status?.message || '尚未检查更新'} +

+
+
+ +
+ + + +
+
+ +
+ + + + +
+ +
+ + +
+ + {status?.task_id && ( +
+ + 后台任务:{status.task_id} + + + 查看任务 + +
+ )} + + {status?.details && ( +
+          {status.details}
+        
+ )} +
+ ) +} + +function UpdateFact({ + icon: Icon, + label, + value, + mono = false, +}: { + icon?: typeof Server + label: string + value: string + mono?: boolean +}) { + return ( +
+
+ {Icon && } + {label} +
+
+ {value} +
+
+ ) +} + +function DigestRow({ label, digest }: { label: string; digest?: string }) { + return ( +
+ {label} + + {shortDigest(digest)} + +
+ ) +} + +function updateTone(status: SystemUpdateStatus | null): { + label: string + className: string + icon: typeof Clock3 +} { + if (!status) { + return { label: '读取中', className: 'border-gray-200 text-sand-500', icon: Clock3 } + } + if (status.running) { + return { label: '更新中', className: 'border-yellow-400/50 text-yellow-600', icon: Loader2 } + } + if (!status.docker_available || !status.can_apply) { + return { label: '不可更新', className: 'border-red-400/50 text-red-500', icon: AlertTriangle } + } + if (status.update_available === true) { + return { label: '有新版', className: 'border-emerald-400/50 text-emerald-600', icon: CheckCircle2 } + } + if (status.update_available === false) { + return { label: '已最新', className: 'border-gray-300 text-ink-100', icon: CheckCircle2 } + } + return { label: '可执行', className: 'border-brand-300/60 text-brand-500', icon: CheckCircle2 } +} + +function shortDigest(digest?: string): string { + if (!digest) return '-' + return digest.length > 24 ? `${digest.slice(0, 18)}…${digest.slice(-8)}` : digest +} + +function formatDate(value?: string): string { + if (!value) return '-' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return value + return date.toLocaleString() +} + +function apiErrorMessage(err: unknown, fallback: string): string { + return (err as UpdateErrorPayload).response?.data?.error || fallback +} diff --git a/web/src/pages/settingsGroupSystemUpdate.ts b/web/src/pages/settingsGroupSystemUpdate.ts new file mode 100644 index 0000000..a55daa4 --- /dev/null +++ b/web/src/pages/settingsGroupSystemUpdate.ts @@ -0,0 +1,33 @@ +import type { SettingGroup } from './settingsGroupTypes' + +export const systemUpdateSettingsGroup: SettingGroup = { + key: 'system-update', + label: '系统更新', + description: 'Docker 部署可在这里检查并拉取最新版镜像。', + items: [ + { + key: 'system.update.image', + label: '应用镜像', + type: 'text', + defaultValue: 'ghcr.io/shukebta/mediastation-go:latest', + placeholder: 'ghcr.io/shukebta/mediastation-go:latest', + hint: '用于检查远端摘要;保持 latest 即可跟随主分支镜像。', + }, + { + key: 'system.update.watchtower_image', + label: 'Watchtower 镜像', + type: 'text', + defaultValue: 'containrrr/watchtower:latest', + placeholder: 'containrrr/watchtower:latest', + hint: '默认使用一次性 Watchtower 更新当前容器。', + }, + { + key: 'system.update.command', + label: '自定义更新命令', + type: 'textarea', + placeholder: + 'docker run --rm -v /var/run/docker.sock:/var/run/docker.sock {{watchtower_image}} --run-once --cleanup {{container}}', + hint: '留空时使用默认命令。支持 {{image}}、{{watchtower_image}}、{{container}}、{{container_id}}、{{container_name}}。', + }, + ], +} diff --git a/web/src/pages/settingsGroups.ts b/web/src/pages/settingsGroups.ts index 3065804..872d693 100644 --- a/web/src/pages/settingsGroups.ts +++ b/web/src/pages/settingsGroups.ts @@ -2,6 +2,7 @@ import { adultSettingsGroup } from './settingsGroupAccess' import { cloudUploadSettingsGroup } from './settingsGroupCloud' import { generalSettingsGroup, licenseSettingsGroup } from './settingsGroupGeneral' import { subscriptionSettingsGroup } from './settingsGroupSubscriptions' +import { systemUpdateSettingsGroup } from './settingsGroupSystemUpdate' import type { SettingGroup } from './settingsGroupTypes' export type { SettingGroup } from './settingsGroupTypes' @@ -9,6 +10,7 @@ export type { SettingGroup } from './settingsGroupTypes' export const GROUPS: SettingGroup[] = [ generalSettingsGroup, licenseSettingsGroup, + systemUpdateSettingsGroup, subscriptionSettingsGroup, cloudUploadSettingsGroup, adultSettingsGroup,