mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-10-18 21:47:17 +02:00
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:
1 parent
cbd8073beb
commit
e80668b2e9
14 files changed
+837
-30
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -186,6 +186,10 @@ knowledge_base/ data/ config.yaml` 等运维者内容先暂存再放回,结果
|
||||
跑 fork 时这是唯一不会"用别人的代码覆盖自己"的路径;本地源码有改动或分支已分叉时它会拒绝并说明原因,
|
||||
而不是硬来。细节见 [开发者指南](developer-guide.md) 的「一键更新」一节。
|
||||
|
||||
检测到 systemd/launchd(环境里有对应启动标记)时,"更新完成后退出进程"默认勾选:更新成功后进程退出、
|
||||
由守护拉起新二进制,页面会在服务回来时自动刷新(会话在内存里,需要重新登录一次)。忘勾、或在命令行
|
||||
换过二进制时,控制台顶部有常驻横幅和「立即重启服务」补上这一步——不必再登录服务器手动重启。
|
||||
|
||||
更新源可以在 `config.yaml` 的 `update` 段指定(`remote` 或 `remote_url`,可带 `branch`),也可以直接在
|
||||
控制台「一键更新」页填写保存:想跟随官方仓库、自己的二开或别人的二开,改的都是这一处;不配置时跟随
|
||||
本目录自己跟踪的远端。**用 Release 包解压安装、目录里没有 git 的**,配好地址后在同一页点「预览并接入」:
|
||||
|
||||
@@ -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` 整页刷新。离开控制台则静默停表,不把已经走开的用户拽回来。
|
||||
|
||||
## 数据库
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "От",
|
||||
|
||||
@@ -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": "从",
|
||||
|
||||
@@ -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 / -> /); 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''').replace(/\//g, '/')
|
||||
: 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(), ///, '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
@@ -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(纯文本),两种去向都不需要它先转义——开着反而会把日期、分支名里的 /
|
||||
// 显示成 /(实测: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;
|
||||
|
||||
Reference in new issue
Block a user