feat: make a restart part of the one-click update, and recover the page by itself

The console now knows what it is running: the status endpoint compares the binary
identity captured at startup (size + nanosecond mtime) with the file on disk, so
an update, a rollback or a CLI run that swapped the binary without a restart is
reported as needsRestart (with the build time) - a state that used to live only in
one job's result and vanished with the process. That state grows a persistent
banner and POST /api/system/update/restart (409 when there is nothing to activate
or a job owns the tree, 400 without a hook), so a forgotten tick is one click
instead of a shell login.

The restart tick defaults to on exactly where it is safe: launchd/systemd leave
markers in the environment (XPC_SERVICE_NAME, INVOCATION_ID/JOURNAL_STREAM) and
the box follows them; an operator's own choice is never second-guessed afterwards.

While the process is down the console becomes a recovery view that probes the
status endpoint every 2 seconds: 200 means the old process still answers, a dead
socket means it is on its way out, and a 401 - only a new process can fail to know
the session - reloads the page back to #system-update. A job poll that dies during
a requested restart is the restart in flight, not a failure to toast.

Also fixed: updateT now asks i18next not to escape interpolated values, because
the console escapes at render time - the apply confirm dialog has been showing
branch names with / ever since they contained a slash, and the new banner
showed dates the same way.

Tests: 7 new console contract tests (36 total: banner, tick default, restart
confirm/refusal, watchdog 200-vs-401, timeout copy, the in-flight poll death) plus
handler tests for the stamp and the endpoint, all mutation-probed; docs describe
the pending-restart state and the self-recovery.
This commit is contained in:
Sycun committed 2026-10-07 22:53:32 +08:00
1 parent cbd8073beb
commit e80668b2e9
14 files changed
+837 -30

No files matched your search

+6
View File
@@ -86,6 +86,12 @@ kept. For a fork this is the only path that does not overwrite you with somebody
source is modified locally or the branch has diverged it refuses and says why instead of forcing.
See [the developer guide](developer-guide.md) for the full semantics.
When a supervisor is detected (systemd/launchd startup markers in the environment), the "exit after
updating" tick is pre-ticked: the process stands down after a successful update, the supervisor brings
the new binary back, and the page reloads itself once the service answers (sessions live in memory, so
one fresh login follows). Forgot the tick, or swapped the binary from the CLI? The console carries a
persistent banner with **Restart now** - no shell login needed.
The source is chosen either in the `update` section of `config.yaml` (`remote` or `remote_url`, plus
an optional `branch`) or right on the console page: the official repository, your own fork, or
somebody else's second-development repository are all just this one setting; unset, it follows the
+18 -3
View File
@@ -48,7 +48,7 @@ disagree about why an update was refused.
| entry | how it is used |
|---|---|
| console | System settings -> One-click update (`#system-update`, the old deep link still works). Opening this section reads the local tree only and does not go online; **Check** is what performs the fetch; **Update** starts a job the page polls; the page also carries the "exit after updating" option and a rollback button |
| console | System settings -> One-click update (`#system-update`, the old deep link still works). Opening this section reads the local tree only and does not go online; **Check** is what performs the fetch; **Update** starts a job the page polls; the "exit after updating" tick is pre-ticked when a supervisor is detected (launchd/systemd startup markers), and while the process is down the page watches for its return and reloads itself (sessions live in memory, so a fresh login follows); a rollback button sits below |
| REST | `GET /api/system/update` (state on disk, no network), `POST /api/system/update/check` (fetch, then report the gap), `POST /api/system/update/apply` (`202` with `job_id`, polled through `GET /api/system/update/job`), `POST /api/system/update/rollback` |
| CLI | `./cyberstrike-ai -check-update`, `./cyberstrike-ai -update`, `./cyberstrike-ai -update-rollback`. The install root is the directory holding `--config`, or the current directory when `--config` was not given |
@@ -134,8 +134,23 @@ modified locally (`local_source_edits`).
The process only stands down (graceful `Shutdown`, then exit 0) when the request explicitly says
`restart: true` **and** a restart hook was wired at startup. Whether it actually comes back is up to a
supervisor (systemd, `run.sh`), and the page says exactly that rather than promising a boot that may
not happen. Otherwise the API only reports `needsRestart` and the old binary keeps running. Asking for
a restart with no hook wired is a `400`, instead of stopping the service and claiming it restarted.
not happen. Asking for a restart with no hook wired is a `400`, instead of stopping the service and
claiming it restarted.
**A pending binary is a durable state, and it can be activated later.** The status endpoint compares
the binary identity captured at startup (size + nanosecond mtime) with what is on disk now: after an
update, a rollback or a CLI run swapped the file without a restart, `needsRestart` is true,
`binaryBuiltAt` says when it was built, and the console grows a persistent banner with **Restart now**
(`POST /api/system/update/restart`; `409 nothing_pending` when there is nothing to activate, `409`
while a job runs, `400` without a hook). The `supervised` field comes from environment markers
(launchd's `XPC_SERVICE_NAME`, systemd's `INVOCATION_ID`/`JOURNAL_STREAM`) and only decides the tick's
default.
**And the page recovers by itself.** While the process is down the console becomes a recovery view
that probes the status endpoint every 2 seconds: a 200 means the old process is still answering (keep
waiting), a dead connection means it is on its way out, and a 401 from a process that no longer knows
the session is the proof the restart happened - the page then `location.replace`s itself back to
`#system-update`. Leaving the console stops the watching instead of yanking the user back.
## Adding a Business Module
+4
View File
@@ -186,6 +186,10 @@ knowledge_base/ data/ config.yaml` 等运维者内容先暂存再放回,结果
跑 fork 时这是唯一不会"用别人的代码覆盖自己"的路径;本地源码有改动或分支已分叉时它会拒绝并说明原因,
而不是硬来。细节见 [开发者指南](developer-guide.md) 的「一键更新」一节。
检测到 systemd/launchd(环境里有对应启动标记)时,"更新完成后退出进程"默认勾选:更新成功后进程退出、
由守护拉起新二进制,页面会在服务回来时自动刷新(会话在内存里,需要重新登录一次)。忘勾、或在命令行
换过二进制时,控制台顶部有常驻横幅和「立即重启服务」补上这一步——不必再登录服务器手动重启。
更新源可以在 `config.yaml` 的 `update` 段指定(`remote` 或 `remote_url`,可带 `branch`),也可以直接在
控制台「一键更新」页填写保存:想跟随官方仓库、自己的二开或别人的二开,改的都是这一处;不配置时跟随
本目录自己跟踪的远端。**用 Release 包解压安装、目录里没有 git 的**,配好地址后在同一页点「预览并接入」:
+12 -2
View File
@@ -54,7 +54,7 @@ go run ./cmd/server --config config.yaml
| 入口 | 怎么用 |
|---|---|
| 控制台 | 「系统设置 → 一键更新」(`#system-update` 老深链仍可用)。打开这一区只读本机状态、不联网;点「检查更新」才去 fetch;「一键更新」发起任务并轮询进度;页面另有"更新完成后退出进程"勾选与回滚按钮 |
| 控制台 | 「系统设置 → 一键更新」(`#system-update` 老深链仍可用)。打开这一区只读本机状态、不联网;点「检查更新」才去 fetch;「一键更新」发起任务并轮询进度;"更新完成后退出进程"在检测到守护进程(launchd/systemd 的启动标记)时默认勾选,重启期间页面守着重连、新进程一应答就自动刷新(会话在内存里,刷新后需重新登录);另有回滚按钮 |
| REST | `GET /api/system/update`(磁盘现状,不联网)、`POST /api/system/update/check`(fetch 后报告差集)、`POST /api/system/update/apply`(`202` 返回 `job_id`,用 `GET /api/system/update/job` 轮询)、`POST /api/system/update/rollback` |
| CLI | `./cyberstrike-ai -check-update`、`./cyberstrike-ai -update`、`./cyberstrike-ai -update-rollback`。安装目录 = `--config` 所在目录,未给 `--config` 时是当前目录 |
@@ -125,7 +125,17 @@ update:
进程只有在请求显式带 `restart: true`、且启动时装配了重启钩子时才会优雅退出(`Shutdown` 后退出码 0)。
能不能被拉起来取决于外部守护(systemd、`run.sh`),页面如实这么写,而不是承诺一次可能不发生的启动;
没装配钩子时要求重启是 `400`,而不是先停掉服务再自称重启了。未重启时接口只报告 `needsRestart`,旧二进制继续跑。
没装配钩子时要求重启是 `400`,而不是先停掉服务再自称重启了。
**待生效状态是持久的、可补重启。** 状态接口对比"启动时记下的二进制身份(大小 + 纳秒 mtime)"与磁盘
现值:更新、回滚或 CLI 换过二进制而没重启时,`needsRestart` 为真、`binaryBuiltAt` 给出构建时间,
控制台顶部出现常驻横幅与「立即重启服务」(`POST /api/system/update/restart`;没有待生效版本时
`409 nothing_pending`、有任务在跑 `409`、没钩子 `400`)。`supervised` 字段来自环境标记(launchd 的
`XPC_SERVICE_NAME`、systemd 的 `INVOCATION_ID`/`JOURNAL_STREAM`),只用来决定勾选框的默认值。
**重启后页面自己回来。** 重启期间控制台换成自恢复视图,每 2 秒探一次状态接口:旧进程还在应答(200)
就继续等;连不上说明正在退出;新进程接客但对旧会话只回 401——这就是"重启已完成"的判据,页面随即
`location.replace` 回 `#system-update` 整页刷新。离开控制台则静默停表,不把已经走开的用户拽回来。
## 数据库
+1
View File
@@ -1139,6 +1139,7 @@ func setupRoutes(
protected.GET("/system/update/job", updateHandler.Job)
protected.POST("/system/update/check", updateHandler.Check)
protected.POST("/system/update/apply", updateHandler.Apply)
protected.POST("/system/update/restart", updateHandler.Restart)
protected.POST("/system/update/rollback", updateHandler.Rollback)
protected.POST("/system/update/adopt", updateHandler.Adopt)
protected.POST("/system/update/source", updateHandler.SaveSource)
+66 -5
View File
@@ -6,6 +6,7 @@ import (
"cyberstrike-ai/internal/update"
"fmt"
"net/http"
"os"
"strings"
"sync"
"time"
@@ -14,6 +15,11 @@ import (
"go.uber.org/zap"
)
// updateRestartDelay is how long a stand-down waits after the response is written: the
// page that asked for the restart gets one more poll window to read the final state
// before the process it is watching goes away. A variable so tests do not sleep for it.
var updateRestartDelay = 2 * time.Second
// UpdateHandler is the "update my own source" surface: what this installation is, what
// its remote has that it does not, and one action that moves the tree and rebuilds the
// binary.
@@ -37,6 +43,10 @@ type UpdateHandler struct {
// saveSource persists a new source through the config layer; nil means this start
// has no config to write to.
saveSource func(remote, remoteURL, branch string) error
// baseline is the binary this process is actually running: the file on disk at
// construction time. An update, a rollback or a CLI run that replaces it leaves a
// different file behind, and that difference is what "restart to activate" means.
baseline update.BinaryStamp
mu sync.Mutex
jobs map[string]*updateJob
@@ -79,10 +89,35 @@ func NewUpdateHandler(root string, logger *zap.Logger, auditSvc *audit.Service,
restart: restart,
source: source,
saveSource: saveSource,
baseline: update.StampBinary(root, update.Options{Root: root}),
jobs: map[string]*updateJob{},
}
}
// restartPending reports whether the binary on disk is no longer the one this process is
// running, and when that binary was built (empty when there is nothing on disk to run).
func (h *UpdateHandler) restartPending() (bool, string) {
stamp := update.StampBinary(h.root, h.options())
if stamp.Same(h.baseline) {
return false, ""
}
builtAt := ""
if stamp.Exists {
builtAt = stamp.ModTime.Format(time.RFC3339)
}
return true, builtAt
}
// restartSupervised reports whether something outside this process is set up to bring it
// back: launchd (XPC_SERVICE_NAME) and systemd (INVOCATION_ID / JOURNAL_STREAM) both leave
// a marker in the environment. It is only the page's default for the restart tick - the
// operator's own checkbox remains the decision.
func restartSupervised() bool {
return strings.TrimSpace(os.Getenv("XPC_SERVICE_NAME")) != "" ||
strings.TrimSpace(os.Getenv("INVOCATION_ID")) != "" ||
strings.TrimSpace(os.Getenv("JOURNAL_STREAM")) != ""
}
func (h *UpdateHandler) options() update.Options {
opts := update.Options{Root: h.root}
if h.source != nil {
@@ -111,11 +146,15 @@ func (h *UpdateHandler) GetStatus(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
needsRestart, binaryBuiltAt := h.restartPending()
c.JSON(http.StatusOK, gin.H{
"status": snap,
"job": h.latest(),
"canRestart": h.restart != nil,
"source": h.sourceView(),
"status": snap,
"job": h.latest(),
"canRestart": h.restart != nil,
"supervised": restartSupervised(),
"needsRestart": needsRestart,
"binaryBuiltAt": binaryBuiltAt,
"source": h.sourceView(),
})
}
@@ -248,6 +287,28 @@ func (h *UpdateHandler) Apply(c *gin.Context) {
c.JSON(http.StatusAccepted, gin.H{"job_id": job.ID, "state": job.State})
}
// Restart answers POST /api/system/update/restart: stand this process down so the binary
// already on disk - an update or a rollback that ran without one - becomes the thing
// answering requests. It refuses when there is nothing to activate, because a bounce that
// cannot change anything is a dropped service for no reason.
func (h *UpdateHandler) Restart(c *gin.Context) {
if busy := h.activeJob(); busy != nil {
c.JSON(http.StatusConflict, gin.H{"error": "更新进行中,等它结束再重启", "job": busy})
return
}
if h.restart == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "本次启动没有提供重启钩子,请由外部进程管理器重启服务"})
return
}
if needsRestart, _ := h.restartPending(); !needsRestart {
c.JSON(http.StatusConflict, gin.H{"error": "磁盘上的二进制与当前进程一致,没有待生效的版本", "reason": "nothing_pending"})
return
}
h.record(c, "update.restart", "started", "重启安装以运行磁盘上的新二进制", nil)
c.JSON(http.StatusAccepted, gin.H{"restarting": true})
time.AfterFunc(updateRestartDelay, h.restart)
}
// Job answers GET /api/system/update/job: the running job, or the most recent one, with
// every progress line so far.
func (h *UpdateHandler) Job(c *gin.Context) {
@@ -339,7 +400,7 @@ func (h *UpdateHandler) startJob(kind string, restart bool) (*updateJob, bool) {
h.mu.Unlock()
// Give the polling page one more chance to read the final state before the
// process it is watching goes away.
time.AfterFunc(2*time.Second, h.restart)
time.AfterFunc(updateRestartDelay, h.restart)
return
}
h.active = ""
+137
View File
@@ -4,13 +4,26 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"cyberstrike-ai/internal/update"
"github.com/gin-gonic/gin"
)
// writeFakeBinary puts an executable-shaped file where update.StampBinary looks, so the
// "the process is running an older build than the disk" state can be produced for real.
func writeFakeBinary(t *testing.T, root, content string) {
t.Helper()
if err := os.WriteFile(filepath.Join(root, update.Binary), []byte(content), 0o755); err != nil {
t.Fatal(err)
}
}
// The HTTP contract of "update my own source in one click": status without a network
// round trip, an apply that returns a job handle instead of blocking a request open for
// the length of a compile, and refusals that stay distinguishable from accidents.
@@ -28,6 +41,7 @@ func newUpdateRouterWithSource(root string, restart func(),
router.GET("/api/system/update/job", h.Job)
router.POST("/api/system/update/check", h.Check)
router.POST("/api/system/update/apply", h.Apply)
router.POST("/api/system/update/restart", h.Restart)
router.POST("/api/system/update/rollback", h.Rollback)
router.POST("/api/system/update/adopt", h.Adopt)
router.POST("/api/system/update/source", h.SaveSource)
@@ -244,3 +258,126 @@ func TestUpdateAdoptPreviewRefusesWithoutASource(t *testing.T) {
t.Fatalf("reason = %v, want no_source so the page can say what is missing", body["reason"])
}
}
// The "restart to activate" contract: the page must be able to tell that the binary on
// disk is no longer the one answering requests, the supervision hint must come from the
// environment rather than a guess, and the stand-down must never run when it cannot
// change anything.
func TestUpdateStatusReportsAPendingBinaryAndSupervision(t *testing.T) {
root := t.TempDir()
writeFakeBinary(t, root, "the build this process is running")
router, _ := newUpdateRouter(root, func() {})
type statusBody struct {
NeedsRestart bool `json:"needsRestart"`
BinaryBuiltAt string `json:"binaryBuiltAt"`
Supervised bool `json:"supervised"`
Status struct {
HasBinary bool `json:"hasBinary"`
} `json:"status"`
}
read := func() statusBody {
w := doUpdate(router, http.MethodGet, "/api/system/update", "")
if w.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", w.Code, w.Body)
}
var body statusBody
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
return body
}
if got := read(); got.NeedsRestart || got.BinaryBuiltAt != "" {
t.Fatalf("a freshly started process must call the binary it is running current: %+v", got)
}
if !read().Status.HasBinary {
t.Fatal("the fake binary has to be visible as hasBinary for this test to mean anything")
}
// A swap (update, rollback or a CLI run) replaces the file; the process is now old and
// the page has to be able to say so.
writeFakeBinary(t, root, "a different build that is now on disk")
after := read()
if !after.NeedsRestart || after.BinaryBuiltAt == "" {
t.Fatalf("replacing the binary must be reported as pending, with when it was built: %+v", after)
}
// Supervision is read from the markers launchd/systemd leave in the environment.
t.Setenv("XPC_SERVICE_NAME", "")
t.Setenv("INVOCATION_ID", "")
t.Setenv("JOURNAL_STREAM", "")
if read().Supervised {
t.Error("with no supervisor markers in the environment, supervised must be false")
}
t.Setenv("XPC_SERVICE_NAME", "com.example.thing")
if !read().Supervised {
t.Error("XPC_SERVICE_NAME is launchd's own marker; the page's default must follow it")
}
}
func TestUpdateRestartOnlyStandsDownWhenSomethingIsPending(t *testing.T) {
root := t.TempDir()
writeFakeBinary(t, root, "v1")
fired := make(chan struct{}, 1)
router, _ := newUpdateRouter(root, func() { fired <- struct{}{} })
old := updateRestartDelay
updateRestartDelay = 20 * time.Millisecond
t.Cleanup(func() { updateRestartDelay = old })
// Nothing on disk to activate: a bounce would drop the service and change nothing.
w := doUpdate(router, http.MethodPost, "/api/system/update/restart", "")
if w.Code != http.StatusConflict {
t.Fatalf("status = %d body = %s, want 409 when no binary is pending", w.Code, w.Body)
}
if !strings.Contains(w.Body.String(), "nothing_pending") {
t.Errorf("the refusal must carry its reason: %s", w.Body)
}
select {
case <-fired:
t.Fatal("a refused restart must not stand the process down")
case <-time.After(80 * time.Millisecond):
}
// A new binary is on disk now; the restart is exactly what makes it real.
writeFakeBinary(t, root, "v2")
w = doUpdate(router, http.MethodPost, "/api/system/update/restart", "")
if w.Code != http.StatusAccepted {
t.Fatalf("status = %d body = %s, want 202", w.Code, w.Body)
}
select {
case <-fired:
case <-time.After(2 * time.Second):
t.Fatal("the stand-down hook was never called")
}
}
func TestUpdateRestartRefusesWithoutAHookOrDuringAJob(t *testing.T) {
root := t.TempDir()
writeFakeBinary(t, root, "v1")
// No hook: standing down would just stop the platform.
router, _ := newUpdateRouter(root, nil)
writeFakeBinary(t, root, "v2")
w := doUpdate(router, http.MethodPost, "/api/system/update/restart", "")
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d body = %s, want 400 without a restart hook", w.Code, w.Body)
}
// A running job owns the tree; standing down halfway through a merge is how an
// installation becomes unrecoverable.
router2, h := newUpdateRouter(root, func() {})
h.mu.Lock()
h.active = "busy"
h.jobs["busy"] = &updateJob{ID: "busy", Kind: "apply", State: "running"}
h.mu.Unlock()
w = doUpdate(router2, http.MethodPost, "/api/system/update/restart", "")
if w.Code != http.StatusConflict {
t.Fatalf("status = %d body = %s, want 409 while a job is running", w.Code, w.Body)
}
if !strings.Contains(w.Body.String(), "busy") {
t.Errorf("the conflict must hand back the job that owns the tree: %s", w.Body)
}
}
+41
View File
@@ -0,0 +1,41 @@
package update
import (
"os"
"path/filepath"
"time"
)
// BinaryStamp identifies the executable on disk: a rebuild, a rename-swap from an update,
// or a rollback that puts the previous build back all change it. Size plus nanosecond
// mtime is enough to tell two builds apart, and it costs one stat() on a request path -
// hashing the file would not.
type BinaryStamp struct {
Exists bool `json:"exists"`
Size int64 `json:"size,omitempty"`
ModTime time.Time `json:"modTime,omitempty"`
}
// StampBinary reads the install's binary as it is on disk right now. A missing file is a
// legitimate stamp rather than an error: a source checkout has no binary until the first
// update builds one.
func StampBinary(root string, opts Options) BinaryStamp {
info, err := os.Stat(filepath.Join(root, opts.binaryName()))
if err != nil {
return BinaryStamp{}
}
return BinaryStamp{Exists: true, Size: info.Size(), ModTime: info.ModTime()}
}
// Same reports whether two stamps describe the same file. The comparison is deliberately
// stricter than "the disk file is newer": a rollback moves an older build back into place,
// and the process running the newer one still has to restart for the rollback to be real.
func (s BinaryStamp) Same(other BinaryStamp) bool {
if s.Exists != other.Exists {
return false
}
if !s.Exists {
return true
}
return s.Size == other.Size && s.ModTime.Equal(other.ModTime)
}
+70
View File
@@ -46737,6 +46737,76 @@ html[data-theme="dark"] { --tool-blocked-color: #f4bc62; }
gap: 8px;
}
/* 待生效横幅:磁盘二进制与运行进程不一致时的常驻出口(忘勾重启也有救) */
.update-restart-banner {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 10px;
padding: 12px 14px;
border: 1px solid rgba(217, 130, 43, 0.45);
border-radius: 10px;
background: rgba(217, 130, 43, 0.12);
}
.update-restart-banner-text {
flex: 1 1 240px;
min-width: 0;
font-size: 13px;
color: #b06a17;
}
.update-restart-btn {
flex: 0 0 auto;
white-space: nowrap;
}
/* 自恢复视图:重启期间控制台让位给这一屏,新进程应答后整页刷新 */
.update-overlay {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 32px 16px;
text-align: center;
}
.update-overlay-spinner {
width: 28px;
height: 28px;
border: 3px solid rgba(58, 110, 197, 0.25);
border-top-color: #2d5bb0;
border-radius: 50%;
animation: update-spin 0.9s linear infinite;
}
@keyframes update-spin {
to { transform: rotate(360deg); }
}
.update-overlay-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.update-overlay-body {
max-width: 460px;
font-size: 13px;
color: var(--text-secondary);
}
.update-overlay-slow {
max-width: 460px;
font-size: 13px;
color: #b06a17;
}
.update-overlay-actions {
margin-top: 4px;
}
.update-intro {
color: var(--text-secondary);
margin: 0 0 12px;
+11
View File
@@ -151,6 +151,17 @@
"jobDoneToast": "Update finished",
"jobFailedToast": "Update failed",
"needsRestartToast": "The binary was replaced; it takes effect after a restart",
"restartBanner": "Updated to {{commit}}, but this process is still running the old build (the binary on disk was built at {{builtAt}}). It takes effect after a restart.",
"restartBannerNoCommit": "The binary on disk was replaced (built at {{builtAt}}), but this process is still running the old build; it takes effect after a restart.",
"restartNowBtn": "Restart now",
"restartNowConfirm": "The service process will exit; with a supervisor (systemd/launchd) the new version is back in seconds and this page reloads itself. Without one the service stays stopped until you start it. Restart now?",
"restartRequestedToast": "Restart requested: the supervisor brings it back and this page recovers by itself",
"restartFailed": "Restart request failed: {{reason}}",
"restartOverlayTitle": "Restarting the service…",
"restartOverlayBody": "This page reloads by itself once the new process answers (your session will be gone, so a fresh login follows).",
"restartOverlaySlowRunning": "The service is still answering as the old process - the restart does not seem to have happened. Use the button below to look at the current state.",
"restartOverlaySlowDown": "The service has not come back. If nothing supervises it (systemd/launchd) it will not start by itself; start it, then refresh this page.",
"restartOverlayRefresh": "Refresh this page",
"failureNoMessage": "The server gave no failure message",
"resultTitle": "Update result",
"resultFrom": "From",
+11
View File
@@ -151,6 +151,17 @@
"jobDoneToast": "Обновление завершено",
"jobFailedToast": "Обновление не удалось",
"needsRestartToast": "Бинарный файл заменён; он вступит в силу после перезапуска",
"restartBanner": "Обновлено до {{commit}}, но процесс всё ещё работает на старой сборке (бинарник на диске собран {{builtAt}}). Изменения вступят в силу после перезапуска.",
"restartBannerNoCommit": "Бинарник на диске заменён (собран {{builtAt}}), но процесс всё ещё работает на старой сборке; изменения вступят в силу после перезапуска.",
"restartNowBtn": "Перезапустить сейчас",
"restartNowConfirm": "Процесс службы завершится; при наличии супервизора (systemd/launchd) новая версия поднимется через несколько секунд, и страница обновится сама. Без супервизора служба останется остановленной. Перезапустить сейчас?",
"restartRequestedToast": "Перезапуск запрошен: супервизор поднимет службу, страница восстановится сама",
"restartFailed": "Не удалось запросить перезапуск: {{reason}}",
"restartOverlayTitle": "Перезапуск службы…",
"restartOverlayBody": "Страница обновится сама, как только новый процесс начнёт отвечать (сессия будет сброшена — потребуется войти заново).",
"restartOverlaySlowRunning": "Служба по-прежнему отвечает старым процессом — перезапуск, похоже, не произошёл. Нажмите кнопку ниже, чтобы увидеть текущее состояние.",
"restartOverlaySlowDown": "Служба не вернулась. Если её ничто не супервизит (systemd/launchd), она не запустится сама — запустите её и обновите страницу.",
"restartOverlayRefresh": "Обновить страницу",
"failureNoMessage": "Сервер не передал сообщение об ошибке",
"resultTitle": "Результат обновления",
"resultFrom": "От",
+11
View File
@@ -151,6 +151,17 @@
"jobDoneToast": "更新完成",
"jobFailedToast": "更新失败",
"needsRestartToast": "二进制已换新,重启后才会生效",
"restartBanner": "已更新到 {{commit}},但当前进程仍在运行旧版本(磁盘上的二进制构建于 {{builtAt}})。重启后新版本才会生效。",
"restartBannerNoCommit": "磁盘上的二进制已换新(构建于 {{builtAt}}),但当前进程仍在运行旧版本;重启后新版本才会生效。",
"restartNowBtn": "立即重启服务",
"restartNowConfirm": "服务进程会退出;有守护进程(systemd/launchd)时几秒内会自动拉起新版本,本页随后自动刷新。没有守护时服务会保持停止,需要手动启动。确定现在重启?",
"restartRequestedToast": "已请求重启:进程退出后由守护进程拉起,页面会自动恢复",
"restartFailed": "重启请求失败:{{reason}}",
"restartOverlayTitle": "正在重启服务…",
"restartOverlayBody": "新进程接客后本页会自动刷新(会话会失效,需要重新登录)。无需手动操作。",
"restartOverlaySlowRunning": "服务仍在以旧进程应答——重启似乎没有发生。可点下方按钮刷新页面查看当前状态。",
"restartOverlaySlowDown": "服务还没有回来。若本机没有守护进程(systemd/launchd),它不会自己启动;手动启动服务后再刷新本页。",
"restartOverlayRefresh": "立即刷新页面",
"failureNoMessage": "服务端没有给出失败说明",
"resultTitle": "更新结果",
"resultFrom": "从",
+261 -11
View File
@@ -15,7 +15,8 @@ const ru = JSON.parse(fs.readFileSync('web/static/i18n/ru-RU.json', 'utf8'));
// The only handlers this console is allowed to wire into markup.
const HANDLES = ['startUpdateApply', 'checkForUpdates', 'rollbackUpdate',
'updateRestartChoiceChanged', 'loadUpdateConsole',
'saveUpdateSource', 'previewAdoptSource', 'confirmAdoptSource'];
'saveUpdateSource', 'previewAdoptSource', 'confirmAdoptSource',
'startRestartNow', 'refreshUpdatePage'];
// The envelope shape GET /api/system/update answers with, plus what the source endpoints return.
const emptySource = { remote: '', remoteUrl: '', branch: '', configured: false };
@@ -82,12 +83,16 @@ function statusOf(patch) {
return Object.assign({}, baseStatus, patch || {});
}
// The shape is the endpoint's own: {status, job, canRestart, source}. job is null until an update ran.
function envelope(status, job, canRestart, source) {
// The shape is the endpoint's own: {status, job, canRestart, supervised, needsRestart, source}.
// job is null until an update ran.
function envelope(status, job, canRestart, source, flags) {
return {
status: status || statusOf(),
job: job === undefined ? null : job,
canRestart: canRestart !== false,
supervised: !!(flags && flags.supervised),
needsRestart: !!(flags && flags.needsRestart),
binaryBuiltAt: (flags && flags.binaryBuiltAt) || '',
source: source || emptySource,
};
}
@@ -175,7 +180,7 @@ function harness(options) {
}
const responses = Object.assign({
'GET /api/system/update': [{ status: 200, body: envelope(opts.status, opts.job, opts.canRestart, opts.source) }],
'GET /api/system/update': [{ status: 200, body: envelope(opts.status, opts.job, opts.canRestart, opts.source, opts.flags) }],
'POST /api/system/update/check': [{ status: 200, body: { status: statusOf(opts.checkedStatus) } }],
'POST /api/system/update/apply': [{ status: 202, body: { job_id: 'upd-1', state: 'running' } }],
'GET /api/system/update/job': [{ status: 200, body: { job: opts.job || runningJob } }],
@@ -184,6 +189,13 @@ function harness(options) {
'POST /api/system/update/adopt': [{ status: 200, body: { plan: adoptPlan } }],
}, opts.responses || {});
// The watchdog probes with plain fetch (the app's apiFetch turns a 401 into "logged out",
// and the whole point here is to read that 401 as "the new process is up"). Tests script
// the answers: 'ok' (old process still answering), 'unauthorized' (a new process without
// our session), 'down' (nothing is listening), 'bad gateway'.
const probeQueue = (opts.probes || ['ok']).slice();
const fetchProbes = [];
const sandbox = {
document: {
getElementById(id) {
@@ -247,10 +259,17 @@ function harness(options) {
// Resolved against the same zh-CN.json the browser loads, with {{var}} interpolated the
// way i18next does. A key the dictionary does not carry comes back as the raw key, so a
// missing translation fails the copy assertions instead of quietly painting a key name.
// i18next escapes interpolated values by default (including / -> &#x2F;); a call that
// asks for escapeValue:false gets the raw value, exactly like the browser would.
t(key, o) {
const found = resolve(zh, key);
if (found === undefined) return key;
return found.replace(/\{\{(\w+)\}\}/g, (m, name) => (o && name in o ? String(o[name]) : m));
const escape = !(o && o.interpolation && o.interpolation.escapeValue === false);
const esc = (v) => escape ? String(v)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;').replace(/\//g, '&#x2F;')
: String(v);
return found.replace(/\{\{(\w+)\}\}/g, (m, name) => (o && name in o ? esc(o[name]) : m));
},
showNotification(msg, type) { toasts.push({ msg, type }); },
confirm(message) { confirms.push(message); return confirmAnswer; },
@@ -279,6 +298,31 @@ function harness(options) {
json: () => Promise.resolve(entry.body),
});
},
fetch(url, reqOpts = {}) {
fetchProbes.push({ url, opts: reqOpts });
const answer = probeQueue.length > 1 ? probeQueue.shift() : (probeQueue[0] || 'ok');
if (answer === 'down') {
return Promise.reject(new TypeError('Failed to fetch'));
}
if (answer === 'unauthorized') {
return Promise.resolve({ ok: false, status: 401, json: () => Promise.resolve({ error: 'unauthorized' }) });
}
if (answer === 'bad gateway') {
return Promise.resolve({ ok: false, status: 502, json: () => Promise.resolve({}) });
}
return Promise.resolve({
ok: true, status: 200,
json: () => Promise.resolve(envelope(opts.status, opts.job, opts.canRestart, opts.source, opts.flags)),
});
},
location: {
pathname: '/',
hash: '',
replaced: [],
reloads: 0,
replace(target) { this.replaced.push(target); },
reload() { this.reloads++; },
},
};
sandbox.window.document = sandbox.document;
vm.createContext(sandbox);
@@ -286,14 +330,20 @@ function harness(options) {
vm.runInContext(`
this.api = {
loadUpdateConsole, checkForUpdates, startUpdateApply, rollbackUpdate,
updateRestartChoiceChanged, pollUpdateJob, renderUpdateConsole, stopUpdatePolling
updateRestartChoiceChanged, pollUpdateJob, renderUpdateConsole, stopUpdatePolling,
startRestartNow
};
this.vars = {
setBusy(v) { updateBusy = v; },
getRestartChoice() { return updateRestartChoice; },
getPollTimer() { return updatePollTimer; },
getCheckDone() { return updateCheck.done; },
autoCheckDone() { return updateAutoCheckDone; }
autoCheckDone() { return updateAutoCheckDone; },
getWatchdogTimer() { return updateWatchdogTimer; },
getWatchdogSlow() { return updateWatchdogSlow; },
restartPending() { return updateRestartPending; },
// The deadline is wall-clock; tests move it into the past instead of sleeping 90s.
expireWatchdog() { updateWatchdogDeadline = Date.now() - 1; }
};
`, sandbox);
@@ -319,6 +369,8 @@ function harness(options) {
sandbox, nodes, calls, toasts, confirms, timers, flush, runHandler, extractHandlers, responses,
api: sandbox.api,
vars: sandbox.vars,
location: sandbox.location,
probes: fetchProbes,
// The deferred auto-check timer is inspected from this side: `timers` is the harness's own
// array, not something the page's context can see.
deferredTimers() { return timers.filter(t => t.deferred && !t.cleared).map(t => t.ms); },
@@ -642,13 +694,16 @@ test('local edits, a diverged tree and a clean-but-unchecked tree each block the
});
test('applying posts {"restart":false} and then reads the job every 1.5 seconds', async () => {
// This test is about the poll cadence and the final re-read, so the job finishes without
// having asked for a restart; the restart hand-off has its own test below.
const plainSucceededJob = Object.assign({}, succeededJob, { restartRequested: false });
const h = await harness({
responses: {
'POST /api/system/update/apply': [{ status: 202, body: { job_id: 'upd-1', state: 'running' } }],
'GET /api/system/update/job': [
{ status: 200, body: { job: runningJob } },
{ status: 200, body: { job: runningJob } },
{ status: 200, body: { job: succeededJob } },
{ status: 200, body: { job: plainSucceededJob } },
],
'GET /api/system/update': [
{ status: 200, body: envelope(statusOf(), null, true) },
@@ -657,7 +712,7 @@ test('applying posts {"restart":false} and then reads the job every 1.5 seconds'
body: envelope(statusOf({
updateAvailable: false, behind: 0, incoming: [], incomingTotal: 0,
commit: 'f9e8d7c',
}), succeededJob, true),
}), plainSucceededJob, true),
},
],
},
@@ -705,7 +760,6 @@ test('applying posts {"restart":false} and then reads the job every 1.5 seconds'
assert.match(html, /二进制已换新/);
assert.match(html, /需要重启才会运行新版本/);
assert.match(html, /3m12s/);
assert.match(html, /已要求在完成后退出/);
const success = h.toasts.filter(t => t.type === 'success' && /更新完成/.test(t.msg)).pop();
assert.ok(success, 'a finished update must be announced: ' + JSON.stringify(h.toasts));
assert.match(success.msg, /f9e8d7c/, 'the toast names the commit the tree is on now');
@@ -750,7 +804,22 @@ test('the restart tick only appears when the process can actually be restarted',
responses: { 'GET /api/system/update/job': [{ status: 200, body: { job: null } }] },
});
assert.match(on.html(), /<input type="checkbox" id="update-restart-choice"[^>]*onchange="updateRestartChoiceChanged\(this\.checked\)">/);
assert.ok(!/id="update-restart-choice" checked/.test(on.html()), 'the restart tick must default to off');
assert.ok(!/id="update-restart-choice" checked/.test(on.html()),
'with nothing supervising the process, the tick must default to off: ticking it would just stop the platform');
// The default follows the environment: launchd/systemd markers mean a restart is a
// restart, so one click should be the whole update.
const supervised = await harness({
canRestart: true,
flags: { supervised: true },
responses: { 'GET /api/system/update/job': [{ status: 200, body: { job: null } }] },
});
assert.match(supervised.html(), /id="update-restart-choice" checked/,
'detected supervision must pre-tick the box');
// Once the operator decides for themselves, the default stops second-guessing them.
supervised.sandbox.document.getElementById('update-restart-choice').checked = false;
supervised.fire('updateRestartChoiceChanged', { checked: false });
assert.ok(!/id="update-restart-choice" checked/.test(supervised.html()));
on.calls.length = 0;
on.fire('startUpdateApply');
@@ -1107,3 +1176,184 @@ test('adopt previews first, and confirming carries the counts into the dialog',
assert.deepEqual(JSON.parse(adopt.body), { confirm: true, restart: false });
assert.ok(h.vars.getPollTimer(), 'the job it started must be polled');
});
// ---------------------------------------------------------------------------
// 待生效的二进制:常驻横幅 + 立即重启 + 守侧重连。这一组是"忘勾重启也有救、重启后页面
// 自己回来"的直接门禁——任何一处退化成"界面没反应"或"要手动刷新",这里都会红。
// ---------------------------------------------------------------------------
test('a binary swap that never got a restart is announced with a way to activate it', async () => {
const h = await harness({ flags: { needsRestart: true, binaryBuiltAt: '2026-10-07T22:23:04+08:00' } });
assert.match(h.html(), /update-restart-banner/,
'the tree already says the new commit while the process still runs the old build; that must be impossible to miss');
assert.match(h.html(), /立即重启服务/);
assert.match(h.html(), /data-require-permission="update:apply"/);
assert.match(h.html(), /2026/, 'the banner names when the binary on disk was built');
assert.match(h.html(), /\d{1,2}\/\d{1,2}\/\d{4}/,
'the build time must render as a date: i18next escapes / unless the console turns that off');
assert.doesNotMatch(h.html(), /&#x2F;/, 'an interpolated value must not arrive pre-escaped');
assert.match(h.html(), /需要重启才会运行新版本/);
assert.match(source, /interpolation: \{ escapeValue: false \}/,
'the console has to stop i18next from HTML-escaping interpolated values before escapeHtml runs');
// A tree without a commit (not a git install) must still read like a sentence.
const noCommit = await harness({
flags: { needsRestart: true, binaryBuiltAt: '2026-10-07T22:23:04+08:00' },
status: statusOf({ installed: false, commit: '' }),
});
assert.doesNotMatch(noCommit.html(), /已更新到 -/, 'no commit must not read as "updated to -"');
assert.match(noCommit.html(), /磁盘上的二进制已换新/, 'the no-commit sentence must stand on its own');
const clean = await harness({});
assert.doesNotMatch(clean.html(), /update-restart-banner/,
'a process running the binary that is on disk has nothing to restart for');
});
test('restart now asks first, posts once, and hands the page to the watchdog', async () => {
const h = await harness({
flags: { needsRestart: true },
responses: { 'POST /api/system/update/restart': [{ status: 202, body: { restarting: true } }] },
});
h.setConfirm(false);
h.calls.length = 0;
h.fire('startRestartNow');
await h.flush();
assert.equal(h.confirms.length, 1, 'standing the service down is confirmed, never implied');
assert.match(h.confirms[0], /守护/, 'the confirm has to say what brings it back: ' + h.confirms[0]);
assert.deepEqual(h.calls, [], 'a cancelled restart must not post');
assert.equal(h.vars.getWatchdogTimer(), null, 'nothing may be watched for a restart nobody asked for');
h.setConfirm(true);
h.vars.setBusy(false);
h.calls.length = 0;
h.fire('startRestartNow');
await h.flush();
assert.deepEqual(h.calls.map(c => c.method + ' ' + c.url), ['POST /api/system/update/restart']);
assert.match(h.html(), /正在重启服务/, 'the console becomes the recovery view');
assert.ok(h.vars.getWatchdogTimer(), 'the watchdog must be armed once the stand-down is accepted');
// Exactly one watcher: the console is replaced by the recovery view, so there is no second
// button to press - and re-arming must not stack timers anyway.
assert.equal(h.timers.filter(t => !t.cleared && t.ms === 2000).length, 1);
});
test('a refused restart is reported with the server sentence and arms nothing', async () => {
const refusal = '磁盘上的二进制与当前进程一致,没有待生效的版本';
const h = await harness({
flags: { needsRestart: true },
responses: { 'POST /api/system/update/restart': [{ status: 409, body: { error: refusal } }] },
});
await h.api.startRestartNow();
await h.flush();
assert.match(h.toasts[h.toasts.length - 1].msg, /没有待生效的版本/, 'the server sentence is the one to show');
assert.equal(h.toasts[h.toasts.length - 1].type, 'error');
assert.equal(h.vars.getWatchdogTimer(), null, 'a refusal must not start watching for a boot that is not coming');
assert.equal(h.location.replaced.length, 0);
});
test('the watchdog reloads only when the NEW process answers, never while the old one still does', async () => {
const h = await harness({
flags: { needsRestart: true },
probes: ['ok', 'down', 'down', 'unauthorized'],
responses: { 'POST /api/system/update/restart': [{ status: 202, body: { restarting: true } }] },
});
await h.api.startRestartNow();
await h.flush();
assert.equal(h.location.replaced.length, 0);
await h.tick();
await h.flush();
assert.equal(h.location.replaced.length, 0, 'a 200 from the old process is not "it is back"');
await h.tick();
await h.flush();
await h.tick();
await h.flush();
assert.equal(h.location.replaced.length, 0, 'a dead service is not a recovered one');
await h.tick();
await h.flush();
assert.equal(h.location.replaced.length, 1,
'a 401 is the proof: only a new process can fail to know our session');
assert.match(h.location.replaced[0], /\?restarted=\d+/);
assert.match(h.location.replaced[0], /#system-update$/);
assert.equal(h.vars.getWatchdogTimer(), null, 'the page is leaving: the timer must be down');
});
test('a restart that does not come back says so instead of spinning forever', async () => {
const downCase = await harness({
flags: { needsRestart: true },
probes: ['down'],
responses: { 'POST /api/system/update/restart': [{ status: 202, body: { restarting: true } }] },
});
await downCase.api.startRestartNow();
await downCase.flush();
downCase.vars.expireWatchdog();
await downCase.tick();
await downCase.flush();
assert.match(downCase.html(), /没有守护进程|不会自己启动/, 'the down advice must name the supervision question');
const aliveCase = await harness({
flags: { needsRestart: true },
probes: ['ok'],
responses: { 'POST /api/system/update/restart': [{ status: 202, body: { restarting: true } }] },
});
await aliveCase.api.startRestartNow();
await aliveCase.flush();
aliveCase.vars.expireWatchdog();
await aliveCase.tick();
await aliveCase.flush();
assert.equal(aliveCase.vars.getWatchdogSlow(), 'running');
assert.match(aliveCase.html(), /仍在以旧进程应答|重启似乎没有发生/);
});
test('an update that asked for a restart hands the page to the watchdog instead of reporting a failed poll', async () => {
const h = await harness({
flags: { supervised: true },
responses: {
'GET /api/system/update/job': [
{ status: 200, body: { job: runningJob } },
{ status: 200, body: { job: succeededJob } },
],
},
probes: ['unauthorized'],
});
// In a real browser the pre-ticked box makes this true; the harness reads the element.
h.sandbox.document.getElementById('update-restart-choice').checked = true;
h.fire('startUpdateApply');
await h.flush();
const apply = h.calls.find(c => c.url === '/api/system/update/apply');
assert.deepEqual(JSON.parse(apply.body), { restart: true },
'with launchd/systemd detected, one click is the whole update, restart included');
// The apply already polled once (running); this tick reads the finished job, which asked
// for the restart - that is the hand-off point.
await h.tick();
await h.flush();
assert.match(h.html(), /正在重启服务/, 'the succeeded-with-restart job hands over to the recovery view');
assert.deepEqual(h.toasts.filter(t => t.type === 'error'), [],
'the process going away by request is not a polling failure to complain about');
assert.ok(h.vars.restartPending());
await h.tick();
await h.flush();
assert.equal(h.location.replaced.length, 1);
});
test('a job poll that dies while the restart is in flight is not a failure', async () => {
// The realistic race: the process exits (and stops answering the job poll) before the
// page's next poll lands. That is the restart happening, not a broken console.
const h = await harness({
flags: { supervised: true },
rejects: ['GET /api/system/update/job'],
probes: ['unauthorized'],
});
h.sandbox.document.getElementById('update-restart-choice').checked = true;
h.fire('startUpdateApply');
await h.flush();
assert.match(h.html(), /正在重启服务/, 'a dead poll while a restart is requested means the restart is in flight');
assert.deepEqual(h.toasts.filter(t => t.type === 'error'), [], 'and it must not be toasted as a failure');
await h.tick();
await h.flush();
assert.equal(h.location.replaced.length, 1);
});
+188 -9
View File
@@ -5,8 +5,11 @@
// 2) 提示只念真实 HTTP 结果,而且只用平台确实存在的 showNotification;没有就什么都不做。
// 3) 服务端来的字符串进 HTML 一律 escapeHtml,进属性一律 escapeAttr。
const UPDATE_POLL_INTERVAL_MS = 1500;
// 重启守侧:进程退出到新进程接客之间的间隙,页面试探的节奏与放弃前的耐心。
const UPDATE_WATCHDOG_INTERVAL_MS = 2000;
const UPDATE_WATCHDOG_TIMEOUT_MS = 90000;
// withUpdateBusy 按动作挑错误文案的前缀,四个 key 都必须在三份语言里存在。
// withUpdateBusy 按动作挑错误文案的前缀,所有 key 都必须在三份语言里存在。
const UPDATE_FAILURE_KEYS = {
check: 'checkFailed',
apply: 'applyFailed',
@@ -14,14 +17,20 @@ const UPDATE_FAILURE_KEYS = {
load: 'loadFailed',
source: 'sourceSaveFailed',
adopt: 'adoptFailed',
restart: 'restartFailed',
};
let updateConsoleState = null; // GET /api/system/update 的整包:{status, job, canRestart}
let updateConsoleState = null; // GET /api/system/update 的整包:{status, job, canRestart, supervised, needsRestart}
let updateCheck = { done: false, error: '' };
let updateBusy = false; // 页面自己发着的动作请求(检查 / 启动更新 / 回滚)
let updateBusy = false; // 页面自己发着的动作请求(检查 / 启动更新 / 回滚 / 重启)
let updateChecking = false; // 只影响「检查更新」按钮的措辞
let updatePollTimer = null;
let updateRestartChoice = false; // 勾选框要在轮询重绘之后仍然是勾着的
let updateRestartChoice = true; // 勾选框要在轮询重绘之后仍然是勾着的;默认值跟随 supervised
let updateRestartChoiceSet = false; // 操作员本次会话手动改过之后,默认值不再顶掉他的选择
let updateRestartPending = false; // 已请求重启:进程退出/新进程接客就在眼前
let updateWatchdogTimer = null; // 重启守侧自己的定时器,跟任务进度轮询互不干扰
let updateWatchdogDeadline = 0;
let updateWatchdogSlow = null; // null=还没超时;'running'=仍由旧进程应答;'down'=服务没回来
let updateAutoCheckDone = false; // 每个会话只自动查一次远端,不每次重绘都去打扰
let updateSourceDraft = null; // 编辑中的更新源;null=还贴着服务端保存的值
let updateSourceError = ''; // 最近一次保存/接入失败的原因,画在区块里
@@ -29,7 +38,12 @@ let updateAdoptPlan = null; // 预览回来的接入计划;确认后清
function updateT(key, opts) {
const k = 'update.' + key;
return typeof window.t === 'function' ? window.t(k, opts) : k;
if (typeof window.t !== 'function') return k;
// i18next 默认把插值里的 / " ' 也转成 HTML 实体;控制台的结果要么进 escapeHtml(渲染),
// 要么进 confirm(纯文本),两种去向都不需要它先转义——开着反而会把日期、分支名里的 /
// 显示成 &#x2F;(实测:devtree/deploy/... 在确认框里就是这么显示的)。
const merged = Object.assign({ interpolation: { escapeValue: false } }, opts || {});
return window.t(k, merged);
}
function updateSourceOf() {
@@ -134,11 +148,22 @@ async function fetchUpdateStatus() {
status: r.data.status || {},
job: r.data.job || null,
canRestart: !!r.data.canRestart,
supervised: !!r.data.supervised,
needsRestart: !!r.data.needsRestart,
binaryBuiltAt: r.data.binaryBuiltAt || '',
source: r.data.source || { remote: '', remoteUrl: '', branch: '', configured: false },
};
noteRestartDefault();
return updateConsoleState;
}
// 勾选框的默认值:有守护(launchd/systemd 的启动标记)才默认勾上——没人守护时默认勾上
// 等于"一键把服务点停"。操作员自己动过勾选框之后,服务端的值不再覆盖他的选择。
function noteRestartDefault() {
if (updateRestartChoiceSet) return;
updateRestartChoice = !!(updateConsoleState && updateConsoleState.supervised);
}
// notify 只在平台真的载了 showNotification 时才动。它绝不能成为"操作看起来失败"的原因:
// 请求已经成功了,为了播报一句去调一个页面上不存在的函数,会把成功炸成失败。
function notify(message, type) {
@@ -179,8 +204,14 @@ async function withUpdateBusy(fn, label) {
async function loadUpdateConsole() {
stopUpdatePolling();
// 重启已经发起:控制台让位给自恢复视图,不再去读一个正在退出的服务。
if (updateRestartPending) {
startUpdateWatchdog();
return;
}
resetUpdateCheck();
updateRestartChoice = false;
updateRestartChoiceSet = false;
updateRestartChoice = true; // 之后由 noteRestartDefault 按 supervised 修正
const el = document.getElementById('update-console');
if (!el) return;
el.innerHTML = '<div class="empty-state">' + escapeHtml(updateT('loading')) + '</div>';
@@ -208,6 +239,63 @@ function stopUpdatePolling() {
updatePollTimer = null;
}
// ---------------------------------------------------------------------------
// 重启守侧:请求重启之后,盯着"新进程是否已经接客",接上了就整页刷新
// ---------------------------------------------------------------------------
// 只认一个新进程已经应答的事实:会话只活在旧进程内存里,新进程对旧令牌只会回 401。
// 200 说明还在跟旧进程说话(还没退出),连不上/502 说明正在退出或还没起来——都继续等。
// 离开控制台就静默停表:不把已经走开的用户从别的页面拽回更新页。
let updateWatchdogProbing = false;
async function watchdogProbe() {
if (!isUpdateConsoleActive()) {
stopUpdateWatchdog();
return;
}
if (updateWatchdogProbing) return;
updateWatchdogProbing = true;
let status = 0;
try {
const resp = await fetch('/api/system/update', {
method: 'GET',
credentials: 'same-origin',
cache: 'no-store',
headers: { Accept: 'application/json' },
});
status = resp.status;
} catch (err) {
status = 0;
} finally {
updateWatchdogProbing = false;
}
if (status === 401) {
// 新进程已经接客、且不认我们的会话:整页刷新落到新版本,带 hash 回到本页。
// 先停表:页面马上要整体换掉,定时器不该再有第二次动作。
stopUpdateWatchdog();
location.replace(location.pathname + '?restarted=' + Date.now() + '#system-update');
return;
}
if (updateWatchdogSlow === null && Date.now() > updateWatchdogDeadline) {
updateWatchdogSlow = status === 200 ? 'running' : 'down';
renderUpdateWatchdog();
}
}
function startUpdateWatchdog() {
if (updateWatchdogTimer) return;
updateRestartPending = true;
updateWatchdogSlow = null;
updateWatchdogDeadline = Date.now() + UPDATE_WATCHDOG_TIMEOUT_MS;
renderUpdateWatchdog();
updateWatchdogTimer = setInterval(watchdogProbe, UPDATE_WATCHDOG_INTERVAL_MS);
}
function stopUpdateWatchdog() {
if (updateWatchdogTimer) clearInterval(updateWatchdogTimer);
updateWatchdogTimer = null;
}
async function pollUpdateJob() {
if (!isUpdateConsoleActive()) {
// 页面已经切走了。router.js 只在进入时调 initPage,离开时没人喊,
@@ -215,9 +303,21 @@ async function pollUpdateJob() {
stopUpdatePolling();
return;
}
const r = await runUpdateRequest('GET', '/api/system/update/job');
// 传输层死亡(连接被拒、进程已经退出)是 reject 而不是带状态码的回应:两者在这里
// 归一——重启进行中它就是"进程退了",否则才是要播报的读取失败。
let r;
try {
r = await runUpdateRequest('GET', '/api/system/update/job');
} catch (err) {
r = { ok: false, error: err && err.message ? err.message : String(err) };
}
if (!r.ok) {
stopUpdatePolling();
if (updateRestartPending) {
// 进程按请求退了:这不是"读进度失败",是重启进入了下一幕。
startUpdateWatchdog();
return;
}
notify(updateT('jobPollFailed', { reason: r.error }), 'error');
return;
}
@@ -226,6 +326,16 @@ async function pollUpdateJob() {
renderUpdateConsole();
if (job && job.state !== 'running') {
stopUpdatePolling();
if (job.state === 'succeeded' && job.restartRequested) {
// 进程马上要让位给新二进制:先把结论播报掉,再进自恢复视图;不再回头
// 去读一个即将消失的服务(reportFinishedJob 会去读,这里跳过它)。
const result = job.result || null;
const tail = result && result.toCommit ? ' ' + result.fromCommit + ' → ' + result.toCommit : '';
notify(updateT('jobDoneToast') + tail, 'success');
startUpdateWatchdog();
return;
}
updateRestartPending = false;
reportFinishedJob(job);
}
}
@@ -311,6 +421,7 @@ function updateApplyBlockers(status, job) {
function updateRestartChoiceChanged(checked) {
updateRestartChoice = !!checked;
updateRestartChoiceSet = true; // 操作员自己决定过之后,服务端的默认值不再覆盖他
renderUpdateConsole();
}
@@ -355,12 +466,28 @@ async function startUpdateApply() {
}
if (!r.ok) throw new Error(r.error);
resetUpdateCheck();
if (restart) updateRestartPending = true;
startUpdatePolling();
notify(updateT('applyAccepted', { id: r.data.job_id || '-' }), 'success');
await pollUpdateJob();
}, 'apply');
}
// 「立即重启服务」:把磁盘上已经换好的二进制变成正在应答的那一个。服务端只允许在
// 确实有待生效的版本时放行——没有可生效的东西就点停服务,那不叫重启。
async function startRestartNow() {
if (updateBusy) return;
if (!window.confirm(updateT('restartNowConfirm'))) return;
await withUpdateBusy(async () => {
const r = await runUpdateRequest('POST', '/api/system/update/restart');
if (!r.ok) throw new Error(r.error);
stopUpdatePolling();
notify(updateT('restartRequestedToast'), 'info');
startUpdateWatchdog();
}, 'restart');
}
async function rollbackUpdate() {
if (updateBusy) return;
const status = updateStatusOf();
@@ -448,6 +575,7 @@ async function confirmAdoptSource() {
if (!r.ok) throw new Error(r.error);
updateAdoptPlan = null;
resetUpdateCheck();
if (restart) updateRestartPending = true;
startUpdatePolling();
notify(updateT('adoptAccepted', { id: r.data.job_id || '-' }), 'success');
await pollUpdateJob();
@@ -566,8 +694,11 @@ function renderUpdateInstallBody(status) {
status.canBuild ? (status.goToolchain || '') : (status.goToolchain || updateT('toolchainMissingHint'))),
updateMark(!!status.hasBinary, 'binaryPresent', 'binaryMissing'),
updateMark(!!status.hasRollback, 'rollbackPresent', 'rollbackMissing'),
].join('');
return '<div class="update-facts">' + facts + '</div><div class="update-marks">' + marks + '</div>';
];
if (updateConsoleState && updateConsoleState.needsRestart) {
marks.push('<span class="update-chip update-chip-warn">' + escapeHtml(updateT('restartNeeded')) + '</span>');
}
return '<div class="update-facts">' + facts + '</div><div class="update-marks">' + marks.join('') + '</div>';
}
function renderUpdateRemoteBody(status) {
@@ -794,6 +925,51 @@ function renderUpdateRollbackBody(status) {
'onclick="rollbackUpdate()">' + escapeHtml(updateT('rollbackBtn')) + '</button></div>';
}
// 待生效横幅:磁盘上的二进制已经不是这个进程了(更新/回滚跑了但没重启,或 CLI 换过),
// 这里给出一个"现在就让它生效"的出口——否则忘勾一次就只剩命令行一条路。
function formatBinaryBuiltAt() {
const raw = (updateConsoleState && updateConsoleState.binaryBuiltAt) || '';
if (!raw) return '-';
const d = new Date(raw);
return isNaN(d.getTime()) ? raw : d.toLocaleString();
}
function renderRestartBanner() {
const status = updateStatusOf();
const commit = status.commit || '';
const text = updateT(commit ? 'restartBanner' : 'restartBannerNoCommit', {
commit: commit || '-',
builtAt: formatBinaryBuiltAt(),
});
return '<div class="update-restart-banner">' +
'<div class="update-restart-banner-text">' + escapeHtml(text) + '</div>' +
'<button class="btn-primary update-restart-btn" data-require-permission="update:apply" onclick="startRestartNow()">' +
escapeHtml(updateT('restartNowBtn')) + '</button></div>';
}
// 自恢复视图:重启请求发出后控制台画的就是它,直到新进程应答触发整页刷新。
function renderUpdateWatchdog() {
const el = document.getElementById('update-console');
if (!el) return;
const parts = ['<div class="update-overlay">',
'<div class="update-overlay-spinner" aria-hidden="true"></div>',
'<div class="update-overlay-title">' + escapeHtml(updateT('restartOverlayTitle')) + '</div>',
'<div class="update-overlay-body">' + escapeHtml(updateT('restartOverlayBody')) + '</div>'];
if (updateWatchdogSlow) {
parts.push('<div class="update-overlay-slow">' + escapeHtml(updateT(
updateWatchdogSlow === 'running' ? 'restartOverlaySlowRunning' : 'restartOverlaySlowDown')) + '</div>');
}
parts.push('<div class="update-overlay-actions">' +
'<button class="btn-secondary" type="button" onclick="refreshUpdatePage()">' +
escapeHtml(updateT('restartOverlayRefresh')) + '</button></div>');
parts.push('</div>');
el.innerHTML = parts.join('');
}
function refreshUpdatePage() {
location.reload();
}
function renderUpdateConsole() {
const el = document.getElementById('update-console');
if (!el || !updateConsoleState) return;
@@ -801,6 +977,7 @@ function renderUpdateConsole() {
const job = updateJobOf();
const parts = [];
if (updateConsoleState.needsRestart) parts.push(renderRestartBanner());
parts.push(updateSection(updateT('installTitle'), renderUpdateInstallBody(status)));
parts.push(updateSection(updateT('sourceTitle'), renderUpdateSourceBody(status)));
parts.push(updateSection(updateT('remoteTitle'), renderUpdateRemoteBody(status)));
@@ -817,6 +994,8 @@ function renderUpdateConsole() {
window.loadUpdateConsole = loadUpdateConsole;
window.checkForUpdates = checkForUpdates;
window.startUpdateApply = startUpdateApply;
window.startRestartNow = startRestartNow;
window.refreshUpdatePage = refreshUpdatePage;
window.rollbackUpdate = rollbackUpdate;
window.updateRestartChoiceChanged = updateRestartChoiceChanged;
window.stopUpdatePolling = stopUpdatePolling;