mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-11-14 00:07:10 +01:00
fix(update): activate new binaries without a supervisor
(cherry picked from commit 9ed4e19f5ea600e185771368c540025df80b11bf)
This commit is contained in:
1 parent
b43bdb991c
commit
46c38a2d10
17 files changed
+407
-179
No files matched your search
@@ -87,11 +87,12 @@ data/ config.yaml` - is put aside and restored, and the result names every file
|
||||
is modified locally or the local history has diverged from the source 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.
|
||||
On macOS and Linux, when started from the `cyberstrike-ai` binary alongside its config, the console
|
||||
defaults to switching to the new binary after a successful build. The old service remains available
|
||||
during the build. Connections briefly drop at the switch and the page reloads when the new service
|
||||
answers. Sign in again afterward because sessions live in memory. A supervisor is still useful for
|
||||
starting at boot and recovering from crashes. If you defer the switch or replace the binary from the
|
||||
CLI, the console offers **Restart now** on installations that support the switch.
|
||||
|
||||
The update source is a single repository address in the `update` section of `config.yaml` (`repo`),
|
||||
or right on the console page: the official repository, your own fork, somebody else's repository or a
|
||||
|
||||
@@ -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 "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 |
|
||||
| console | System settings -> One-click update (`#system-update`, the old deep link still works). **Update** starts a job the page polls. Supported installations switch to the newly built binary by default, and the page reloads once the service comes back. 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 |
|
||||
|
||||
@@ -150,28 +150,24 @@ again after an interruption to finish recovery; the console reports `rollbackPen
|
||||
updates. Keep the recovery record, backup directory and `.prev` file. If recorded binary hashes do not
|
||||
match the files on disk, recovery refuses to overwrite them.
|
||||
|
||||
### The two restart cases
|
||||
### Switching to the new binary
|
||||
|
||||
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. Asking for a restart with no hook wired is a `400`, instead of stopping the service and
|
||||
claiming it restarted.
|
||||
On macOS and Linux, when started from the `cyberstrike-ai` binary alongside its config, the console
|
||||
sends `restart: true` by default. After a successful build and binary swap, the service closes its
|
||||
application resources and executes the new binary with the same PID, arguments, environment and
|
||||
working directory. Installations that cannot switch in place return `400` on an explicit restart
|
||||
request. CLI updates and REST updates without `restart: true` only replace files on disk.
|
||||
|
||||
**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.
|
||||
while a job runs, `400` when unavailable). The console uses the `autoRestart` field as its 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.
|
||||
**The page reloads automatically.** The console polls the session validation endpoint every two
|
||||
seconds for the new boot marker. Sessions live in memory, so users sign in again after the switch.
|
||||
Leaving the console stops the polling.
|
||||
|
||||
## Adding a Business Module
|
||||
|
||||
|
||||
@@ -189,9 +189,10 @@ SQLite 热备份时最好先停止服务,或至少复制 `*.db`、`*.db-wal`
|
||||
并被页面标出,装好 go 后再点一次「一键更新」即补编译。
|
||||
细节见 [开发者指南](developer-guide.md) 的「一键更新」一节。
|
||||
|
||||
检测到 systemd/launchd(环境里有对应启动标记)时,"更新完成后退出进程"默认勾选:更新成功后进程退出、
|
||||
由守护拉起新二进制,页面会在服务回来时自动刷新(会话在内存里,需要重新登录一次)。忘勾、或在命令行
|
||||
换过二进制时,控制台顶部有常驻横幅和「立即重启服务」补上这一步——不必再登录服务器手动重启。
|
||||
在 macOS/Linux 上,从配置文件同目录的 `cyberstrike-ai` 启动时,控制台默认在构建成功后自动切换到新二进制。
|
||||
构建期间旧服务继续提供服务;切换时连接会短暂中断,页面在新服务应答后自动刷新(内存中的登录会话需重新登录)。
|
||||
这一步不依赖 systemd 或 launchd;长期部署仍建议用守护进程做开机自启和异常恢复。取消自动切换、或在命令行换过
|
||||
二进制后,可以在支持自动切换的安装上用控制台的「立即重启服务」启用磁盘上的版本。
|
||||
|
||||
更新源是 `config.yaml` `update` 段里的**一个仓库地址**(`repo`),也可以直接在控制台「一键更新」页填写保存:
|
||||
想跟随官方仓库、自己的二开、别人的二开或镜像,改的都是这一处;不写就是官方仓库。**用 Release 包解压安装、
|
||||
|
||||
@@ -54,7 +54,7 @@ go run ./cmd/server --config config.yaml
|
||||
|
||||
| 入口 | 怎么用 |
|
||||
|---|---|
|
||||
| 控制台 | 「系统设置 → 一键更新」(`#system-update` 老深链仍可用)。打开这一区只读本机状态、不联网;点「检查更新」才去 fetch;「一键更新」发起任务并轮询进度;"更新完成后退出进程"在检测到守护进程(launchd/systemd 的启动标记)时默认勾选,重启期间页面守着重连、新进程一应答就自动刷新(会话在内存里,刷新后需重新登录);另有回滚按钮 |
|
||||
| 控制台 | 「系统设置 → 一键更新」(`#system-update` 老深链仍可用)。「一键更新」发起任务并轮询进度;支持自动切换的安装默认在构建成功后切到新二进制,页面在服务恢复后自动刷新;另有回滚按钮 |
|
||||
| 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` 时是当前目录 |
|
||||
|
||||
@@ -141,21 +141,19 @@ update:
|
||||
`rollbackPending` 提示会暂停新更新。保留恢复记录、备份目录和 `.prev`,不要手动删除它们。
|
||||
如果记录中的二进制哈希无法与现场对应,会拒绝覆盖并保留现场。
|
||||
|
||||
### 重启的两种情形
|
||||
### 切换到新二进制
|
||||
|
||||
进程只有在请求显式带 `restart: true`、且启动时装配了重启钩子时才会优雅退出(`Shutdown` 后退出码 0)。
|
||||
能不能被拉起来取决于外部守护(systemd、`run.sh`),页面如实这么写,而不是承诺一次可能不发生的启动;
|
||||
没装配钩子时要求重启是 `400`,而不是先停掉服务再自称重启了。
|
||||
macOS/Linux 上,从配置文件同目录的 `cyberstrike-ai` 启动时,控制台默认发送 `restart: true`。
|
||||
只有成功构建并换入二进制才会切换:服务先关闭应用资源,再用相同 PID、启动参数、环境和工作目录执行新二进制。
|
||||
不支持的启动方式不提供自动切换;REST 显式请求重启时返回 `400`。REST 不传该字段和 CLI 更新都只换磁盘文件。
|
||||
|
||||
**待生效状态是持久的、可补重启。** 状态接口对比"启动时记下的二进制身份(大小 + 纳秒 mtime)"与磁盘
|
||||
现值:更新、回滚或 CLI 换过二进制而没重启时,`needsRestart` 为真、`binaryBuiltAt` 给出构建时间,
|
||||
控制台顶部出现常驻横幅与「立即重启服务」(`POST /api/system/update/restart`;没有待生效版本时
|
||||
`409 nothing_pending`、有任务在跑 `409`、没钩子 `400`)。`supervised` 字段来自环境标记(launchd 的
|
||||
`XPC_SERVICE_NAME`、systemd 的 `INVOCATION_ID`/`JOURNAL_STREAM`),只用来决定勾选框的默认值。
|
||||
`409 nothing_pending`、有任务在跑 `409`、不支持时 `400`)。控制台根据 `autoRestart` 决定默认值。
|
||||
|
||||
**重启后页面自己回来。** 重启期间控制台换成自恢复视图,每 2 秒探一次状态接口:旧进程还在应答(200)
|
||||
就继续等;连不上说明正在退出;新进程接客但对旧会话只回 401——这就是"重启已完成"的判据,页面随即
|
||||
`location.replace` 回 `#system-update` 整页刷新。离开控制台则静默停表,不把已经走开的用户拽回来。
|
||||
**重启后页面自动刷新。** 控制台每 2 秒通过会话验证接口探测服务启动标记,收到新标记才刷新。
|
||||
现有登录会话保存在内存中,切换后需要重新登录。离开控制台则停止探测。
|
||||
|
||||
## 数据库
|
||||
|
||||
|
||||
+9
-9
@@ -44,6 +44,7 @@ import (
|
||||
|
||||
// App 应用
|
||||
type App struct {
|
||||
shutdownOnce sync.Once
|
||||
config *config.Config
|
||||
logger *logger.Logger
|
||||
router *gin.Engine
|
||||
@@ -611,14 +612,8 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
|
||||
return nil
|
||||
})
|
||||
|
||||
// The one-click update acts on this installation's own tree, rooted where the live config
|
||||
// file is. Its restart hook exits after a graceful shutdown, which is only "a restart" when
|
||||
// something supervises the process - so the endpoint reports whether it is standing down or
|
||||
// whether somebody has to start it again, instead of promising a boot that may not happen.
|
||||
updateHandler := handler.NewUpdateHandler(configDir, log.Logger, auditSvc, func() {
|
||||
app.Shutdown()
|
||||
os.Exit(0)
|
||||
}, configHandler.UpdateSource, configHandler.SetUpdateSource)
|
||||
updateHandler := handler.NewUpdateHandler(configDir, log.Logger, auditSvc,
|
||||
app.updateRestart(configDir), configHandler.UpdateSource, configHandler.SetUpdateSource)
|
||||
|
||||
// 设置路由(使用 App 实例以便动态获取 handler)
|
||||
setupRoutes(
|
||||
@@ -802,6 +797,11 @@ func (a *App) RunWithContext(ctx context.Context) error {
|
||||
|
||||
// Shutdown 关闭应用
|
||||
func (a *App) Shutdown() {
|
||||
|
||||
a.shutdownOnce.Do(a.shutdown)
|
||||
}
|
||||
|
||||
func (a *App) shutdown() {
|
||||
if a.agentHandler != nil {
|
||||
a.agentHandler.ShutdownTasks()
|
||||
}
|
||||
@@ -970,7 +970,7 @@ func setupRoutes(
|
||||
authRoutes.POST("/login", security.RateLimitMiddleware(loginRL), authHandler.Login)
|
||||
authRoutes.POST("/logout", security.AuthMiddleware(authManager), authHandler.Logout)
|
||||
authRoutes.POST("/change-password", security.AuthMiddleware(authManager), security.RequirePermission("auth:self"), authHandler.ChangePassword)
|
||||
authRoutes.GET("/validate", security.AuthMiddleware(authManager), authHandler.Validate)
|
||||
authRoutes.GET("/validate", updateHandler.SetBootHeader, security.AuthMiddleware(authManager), authHandler.Validate)
|
||||
authRoutes.POST("/robot-binding-code", security.AuthMiddleware(authManager), security.RequirePermission("auth:self"), robotHandler.CreateRobotBindingCode)
|
||||
authRoutes.GET("/robot-bindings", security.AuthMiddleware(authManager), security.RequirePermission("auth:self"), robotHandler.ListMyRobotBindings)
|
||||
authRoutes.DELETE("/robot-bindings/:id", security.AuthMiddleware(authManager), security.RequirePermission("auth:self"), robotHandler.DeleteMyRobotBinding)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
//go:build !darwin && !linux
|
||||
|
||||
package app
|
||||
|
||||
func (a *App) updateRestart(string) func() (func(), error) { return nil }
|
||||
@@ -0,0 +1,59 @@
|
||||
//go:build darwin || linux
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"cyberstrike-ai/internal/update"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// updateRestart only replaces the executable this instance was started from.
|
||||
// For example, go run and installs with a separate config directory must not
|
||||
// start a different executable just because the updater wrote one there.
|
||||
func (a *App) updateRestart(root string) func() (func(), error) {
|
||||
dir, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
dir, err = filepath.EvalSymlinks(dir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
path := filepath.Join(dir, update.Binary)
|
||||
self, err := os.Executable()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
self, err = filepath.EvalSymlinks(self)
|
||||
if err != nil || self != path {
|
||||
return nil
|
||||
}
|
||||
return func() (func(), error) {
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("不是普通可执行文件:%s", path)
|
||||
}
|
||||
if err := unix.Access(path, unix.X_OK); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return func() {
|
||||
a.Shutdown()
|
||||
// Keep the same PID, arguments, environment and working directory.
|
||||
// This works both in a terminal and under an existing supervisor.
|
||||
if err := unix.Exec(path, os.Args, os.Environ()); err != nil {
|
||||
a.logger.Error("无法启动更新后的二进制", zap.Error(err))
|
||||
_ = a.logger.Sync()
|
||||
os.Exit(1)
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//go:build darwin || linux
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/update"
|
||||
)
|
||||
|
||||
func TestStandaloneUpdateRestartExecsReplacement(t *testing.T) {
|
||||
self, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := t.TempDir()
|
||||
binary := filepath.Join(root, update.Binary)
|
||||
src, err := os.Open(self)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer src.Close()
|
||||
dst, err := os.OpenFile(binary, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o755)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, copyErr := io.Copy(dst, src)
|
||||
if closeErr := dst.Close(); closeErr != nil {
|
||||
t.Fatal(closeErr)
|
||||
}
|
||||
if copyErr != nil {
|
||||
t.Fatal(copyErr)
|
||||
}
|
||||
replacement := filepath.Join(root, "next")
|
||||
if err := os.WriteFile(replacement, []byte("#!/bin/sh\nprintf 'replacement:%s:%s:%s:%s\\n' \"$$\" \"$PWD\" \"$CSAI_RESTART_TEST\" \"$1\"\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
canonicalRoot, err := filepath.EvalSymlinks(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, binary, "-test.run=^TestUpdateRestartChild$")
|
||||
cmd.Dir = canonicalRoot
|
||||
// No launchd/systemd markers or supervisor in this child.
|
||||
cmd.Env = []string{"CSAI_RESTART_TEST=standalone"}
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("restart helper: %v\n%s", err, out)
|
||||
}
|
||||
want := fmt.Sprintf("replacement:%d:%s:standalone:-test.run=^TestUpdateRestartChild$", cmd.Process.Pid, canonicalRoot)
|
||||
if !strings.Contains(string(out), want) {
|
||||
t.Fatalf("new executable did not inherit PID, cwd, environment and arguments: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateRestartChild(t *testing.T) {
|
||||
if os.Getenv("CSAI_RESTART_TEST") != "standalone" {
|
||||
return
|
||||
}
|
||||
var app App
|
||||
prepare := app.updateRestart(".")
|
||||
if prepare == nil {
|
||||
t.Fatal("standalone install did not offer restart")
|
||||
}
|
||||
if err := os.Rename("next", update.Binary); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restart, err := prepare()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restart()
|
||||
t.Fatal("exec returned instead of switching executables")
|
||||
}
|
||||
@@ -3457,7 +3457,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"confirm": map[string]interface{}{"type": "boolean", "description": "false/缺省=只预览;true=确认执行"},
|
||||
"restart": map[string]interface{}{"type": "boolean", "description": "接入成功后退出进程交给外部守护拉起,默认 false"},
|
||||
"restart": map[string]interface{}{"type": "boolean", "description": "接入并构建成功后自动切换到新二进制,需要 canRestart,默认 false"},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -6549,7 +6549,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"properties": map[string]interface{}{
|
||||
"restart": map[string]interface{}{
|
||||
"type": "boolean",
|
||||
"description": "更新成功后优雅关闭并退出进程;只在有外部守护时才会被拉起,默认 false",
|
||||
"description": "更新并构建成功后自动切换到新二进制,需要 canRestart,默认 false",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+66
-44
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@@ -32,10 +33,9 @@ type UpdateHandler struct {
|
||||
root string // the installation to update: the directory the config file lives in
|
||||
logger *zap.Logger
|
||||
audit *audit.Service
|
||||
// restart is the operator's own shutdown hook. It is only ever called when the
|
||||
// request asked for a restart, because an unsupervised process that exits stays
|
||||
// exited - "I restarted you" would be a lie on most deployments.
|
||||
restart func()
|
||||
// prepareRestart validates the replacement before the running service shuts down.
|
||||
prepareRestart func() (func(), error)
|
||||
bootID string
|
||||
// source reports the configured update source (config.yaml update.repo) as the
|
||||
// operator last saved it; an empty string means "not configured" and the official
|
||||
// repository is used. A function rather than a value because the config can change
|
||||
@@ -59,15 +59,16 @@ type UpdateHandler struct {
|
||||
// returns a handle and the page polls it rather than sitting in one HTTP call until the
|
||||
// compiler finishes.
|
||||
type updateJob struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"` // apply | adopt
|
||||
State string `json:"state"` // running | succeeded | failed
|
||||
Started string `json:"started"`
|
||||
Finished string `json:"finished,omitempty"`
|
||||
Steps []update.Step `json:"steps"`
|
||||
Result *update.Result `json:"result,omitempty"`
|
||||
Failure *update.Error `json:"failure,omitempty"`
|
||||
Restart bool `json:"restartRequested"`
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"` // apply | adopt
|
||||
State string `json:"state"` // running | succeeded | failed
|
||||
Started string `json:"started"`
|
||||
Finished string `json:"finished,omitempty"`
|
||||
Steps []update.Step `json:"steps"`
|
||||
Result *update.Result `json:"result,omitempty"`
|
||||
Failure *update.Error `json:"failure,omitempty"`
|
||||
Restart bool `json:"restartRequested"`
|
||||
RestartError string `json:"restartError,omitempty"`
|
||||
}
|
||||
|
||||
// view copies a job for transport, because its fields are written by the job's own
|
||||
@@ -81,17 +82,18 @@ func (j *updateJob) view() *updateJob {
|
||||
// NewUpdateHandler takes the audit service as a constructor argument rather than through
|
||||
// a SetAudit the assembly has to remember: forgetting here is a compile error instead of
|
||||
// a privileged endpoint that writes no audit records at all.
|
||||
func NewUpdateHandler(root string, logger *zap.Logger, auditSvc *audit.Service, restart func(),
|
||||
func NewUpdateHandler(root string, logger *zap.Logger, auditSvc *audit.Service, prepareRestart func() (func(), error),
|
||||
source func() string, saveSource func(repo string) error) *UpdateHandler {
|
||||
return &UpdateHandler{
|
||||
root: root,
|
||||
logger: logger,
|
||||
audit: auditSvc,
|
||||
restart: restart,
|
||||
source: source,
|
||||
saveSource: saveSource,
|
||||
baseline: update.StampBinary(root, update.Options{Root: root}),
|
||||
jobs: map[string]*updateJob{},
|
||||
root: root,
|
||||
logger: logger,
|
||||
audit: auditSvc,
|
||||
prepareRestart: prepareRestart,
|
||||
bootID: uuid.NewString(),
|
||||
source: source,
|
||||
saveSource: saveSource,
|
||||
baseline: update.StampBinary(root, update.Options{Root: root}),
|
||||
jobs: map[string]*updateJob{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,10 +111,8 @@ func (h *UpdateHandler) restartPending() (bool, string) {
|
||||
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.
|
||||
// restartSupervised is a legacy status hint. These environment markers do not
|
||||
// prove that the supervisor is configured to restart the service.
|
||||
func restartSupervised() bool {
|
||||
return strings.TrimSpace(os.Getenv("XPC_SERVICE_NAME")) != "" ||
|
||||
strings.TrimSpace(os.Getenv("INVOCATION_ID")) != "" ||
|
||||
@@ -154,7 +154,9 @@ func (h *UpdateHandler) GetStatus(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": snap,
|
||||
"job": h.latest(),
|
||||
"canRestart": h.restart != nil,
|
||||
"canRestart": h.prepareRestart != nil,
|
||||
"autoRestart": h.prepareRestart != nil,
|
||||
"bootID": h.bootID,
|
||||
"supervised": restartSupervised(),
|
||||
"needsRestart": needsRestart,
|
||||
"binaryBuiltAt": binaryBuiltAt,
|
||||
@@ -162,6 +164,13 @@ func (h *UpdateHandler) GetStatus(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// SetBootHeader runs before auth on the validation route so a client can tell
|
||||
// a new boot from a revoked or expired session on the current boot.
|
||||
func (h *UpdateHandler) SetBootHeader(c *gin.Context) {
|
||||
c.Header("X-CyberStrike-Boot-ID", h.bootID)
|
||||
c.Header("Cache-Control", "no-store")
|
||||
}
|
||||
|
||||
// Check answers POST /api/system/update/check: fetch the tracked branch and report the
|
||||
// gap. A failed fetch is a 200 with checkError, not a 500 - "offline" and "bad token"
|
||||
// are answers the operator needs to read, not server faults to debug.
|
||||
@@ -235,7 +244,7 @@ func (h *UpdateHandler) Adopt(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if body.Confirm && body.Restart && h.restart == nil {
|
||||
if body.Confirm && body.Restart && h.prepareRestart == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "本次启动没有提供重启钩子,接入完成后请由外部进程管理器重启服务"})
|
||||
return
|
||||
}
|
||||
@@ -271,7 +280,7 @@ func (h *UpdateHandler) Apply(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if body.Restart && h.restart == nil {
|
||||
if body.Restart && h.prepareRestart == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "本次启动没有提供重启钩子,更新完成后请由外部进程管理器重启服务"})
|
||||
return
|
||||
}
|
||||
@@ -286,7 +295,7 @@ 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
|
||||
// Restart answers POST /api/system/update/restart: replace this process 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.
|
||||
@@ -306,7 +315,7 @@ func (h *UpdateHandler) Restart(c *gin.Context) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "更新进行中,等它结束再重启", "job": busy})
|
||||
return
|
||||
}
|
||||
if h.restart == nil {
|
||||
if h.prepareRestart == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "本次启动没有提供重启钩子,请由外部进程管理器重启服务"})
|
||||
return
|
||||
}
|
||||
@@ -318,10 +327,16 @@ func (h *UpdateHandler) Restart(c *gin.Context) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "磁盘上的二进制不存在,无法重启", "reason": "no_binary"})
|
||||
return
|
||||
}
|
||||
restart, err := h.prepareRestart()
|
||||
if err != nil {
|
||||
failure := &update.Error{Reason: "restart_unavailable", Message: "无法启动磁盘上的二进制:" + err.Error()}
|
||||
c.JSON(statusForError(failure), errorBody(failure))
|
||||
return
|
||||
}
|
||||
h.record(c, "update.restart", "started", "重启安装以运行磁盘上的新二进制", nil)
|
||||
c.JSON(http.StatusAccepted, gin.H{"restarting": true})
|
||||
scheduled = true
|
||||
time.AfterFunc(updateRestartDelay, func() { defer release(); h.restart() })
|
||||
time.AfterFunc(updateRestartDelay, func() { defer release(); restart() })
|
||||
}
|
||||
|
||||
// Job answers GET /api/system/update/job: the running job, or the most recent one, with
|
||||
@@ -411,22 +426,29 @@ func (h *UpdateHandler) startJob(kind string, restart bool) (*updateJob, bool) {
|
||||
}
|
||||
job.State = finish
|
||||
job.Finished = time.Now().Format(time.RFC3339)
|
||||
if job.Restart && finish == "succeeded" && res != nil && res.BinaryBuilt && h.restart != nil {
|
||||
if job.Restart && finish == "succeeded" && res != nil && res.BinaryBuilt && h.prepareRestart != nil {
|
||||
release, lockErr := update.LockInstall(opts)
|
||||
if lockErr == nil {
|
||||
h.mu.Unlock()
|
||||
// Keep both CLI and HTTP writers out until the old process exits.
|
||||
time.AfterFunc(updateRestartDelay, func() {
|
||||
defer release()
|
||||
defer func() { h.mu.Lock(); h.active = ""; h.mu.Unlock() }()
|
||||
h.restart()
|
||||
})
|
||||
return
|
||||
restart, prepareErr := h.prepareRestart()
|
||||
if prepareErr == nil {
|
||||
h.mu.Unlock()
|
||||
// Keep both CLI and HTTP writers out until the old executable is replaced.
|
||||
time.AfterFunc(updateRestartDelay, func() {
|
||||
defer release()
|
||||
defer func() { h.mu.Lock(); h.active = ""; h.mu.Unlock() }()
|
||||
restart()
|
||||
})
|
||||
return
|
||||
}
|
||||
release()
|
||||
job.RestartError = prepareErr.Error()
|
||||
} else {
|
||||
// Another writer acquired the lock after the build.
|
||||
job.RestartError = lockErr.Error()
|
||||
}
|
||||
// Another process began an operation after the build. The binary is
|
||||
// ready, but shutting down now would interrupt that operation.
|
||||
job.Restart = false
|
||||
}
|
||||
// A completed job only reports restartRequested if it actually scheduled one.
|
||||
job.Restart = false
|
||||
h.active = ""
|
||||
h.mu.Unlock()
|
||||
}()
|
||||
|
||||
@@ -36,7 +36,11 @@ func newUpdateRouter(root string, restart func()) (*gin.Engine, *UpdateHandler)
|
||||
func newUpdateRouterWithSource(root string, restart func(),
|
||||
source func() string, save func(string) error) (*gin.Engine, *UpdateHandler) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
h := NewUpdateHandler(root, nil, nil, restart, source, save)
|
||||
var prepare func() (func(), error)
|
||||
if restart != nil {
|
||||
prepare = func() (func(), error) { return restart, nil }
|
||||
}
|
||||
h := NewUpdateHandler(root, nil, nil, prepare, source, save)
|
||||
router := gin.New()
|
||||
router.GET("/api/system/update", h.GetStatus)
|
||||
router.GET("/api/system/update/job", h.Job)
|
||||
@@ -313,7 +317,7 @@ func TestUpdateStatusReportsAPendingBinaryAndSupervision(t *testing.T) {
|
||||
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.
|
||||
// Retain the supervision hint for older clients; it does not control autoRestart.
|
||||
t.Setenv("XPC_SERVICE_NAME", "")
|
||||
t.Setenv("INVOCATION_ID", "")
|
||||
t.Setenv("JOURNAL_STREAM", "")
|
||||
@@ -322,7 +326,7 @@ func TestUpdateStatusReportsAPendingBinaryAndSupervision(t *testing.T) {
|
||||
}
|
||||
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")
|
||||
t.Error("XPC_SERVICE_NAME is launchd's own marker")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -147,10 +147,10 @@
|
||||
"blockedLocalEdits": "{{n}} من ملفّات مصدر المنتج معدّلة محلّيًا ولن يستبدلها التحديث: أودِعها أو تراجع عنها أوّلًا.",
|
||||
"blockedDiverged": "السجلّ المحلّي متقدّم بمقدار {{ahead}} إيداع ومتأخّر بمقدار {{behind}}: هذا دمج وليس تنزيلًا، لذا عالجه يدويًا.",
|
||||
"blockedNoUpdate": "مصدر التحديث لا يحتوي على أيّ شيء أحدث من هذا الجهاز (اضغط على التحقّق من التحديثات إن لم تكن قد راجعت بعد).",
|
||||
"restartChoiceLabel": "إنهاء العملية بعد التحديث (لن يعيد تشغيلها إلّا مشرف خارجي مثل systemd أو run.sh؛ أعد تشغيلها بنفسك إن لم يشرف عليها شيء)",
|
||||
"restartNotWired": "لم يوفّر هذا التشغيل خطّاف إعادة تشغيل: أعد تشغيل الخدمة بنفسك بعد التحديث.",
|
||||
"confirmRestartYes": "محدّد: تنتهي العملية بمجرّد نجاح التحديث، ولن يعيد تشغيلها إلّا مشرف خارجي (systemd، run.sh، ...).",
|
||||
"confirmRestartNo": "غير محدّد: تستمرّ العملية في تشغيل الملف الثنائي القديم حتّى تعيد تشغيلها بنفسك.",
|
||||
"restartChoiceLabel": "التبديل تلقائيًا إلى الإصدار الجديد",
|
||||
"restartNotWired": "لا تدعم طريقة التشغيل الحالية التبديل التلقائي. بعد استبدال الملف التنفيذي، أعد تشغيل الخدمة بالطريقة المعتادة في بيئتك.",
|
||||
"confirmRestartYes": "تظلّ الخدمة متاحة أثناء البناء، ثم تعيد التشغيل لفترة وجيزة للتبديل إلى الإصدار الجديد. تُعاد الصفحة تلقائيًا.",
|
||||
"confirmRestartNo": "يستمر الإصدار القديم في العمل إلى أن تعيد تشغيل الخدمة.",
|
||||
"confirmApply": "على وشك التنفيذ: التحديث من {{repo}} إلى {{target}} ({{n}} إيداع جديد، هذا الجهاز عند {{commit}}) -> إعادة بناء الملف الثنائي -> نسخ القديم احتياطيًا واستبداله.",
|
||||
"confirmBuildPending": "على وشك التنفيذ: بناء الملف الثنائي المفقود للمصدر الموجود بالفعل (هذا الجهاز عند {{commit}}) -> إعادة البناء -> نسخ القديم احتياطيًا واستبدال الملف الثنائي القديم.",
|
||||
"applyAccepted": "بدأ التحديث (المهمّة {{id}})؛ يتحدّث التقدّم كلّ 1.5 ثانية.",
|
||||
@@ -169,7 +169,7 @@
|
||||
"stateSucceeded": "نجح",
|
||||
"stateFailed": "فشل",
|
||||
"stateUnknown": "حالة غير معروفة",
|
||||
"restartRequested": "طُلب الإنهاء بعد الاكتمال",
|
||||
"restartRequested": "جرت جدولة التبديل التلقائي",
|
||||
"jobPollFailed": "تعذّرت قراءة التقدّم: {{reason}}",
|
||||
"jobDoneToast": "انتهى التحديث",
|
||||
"buildDoneToast": "انتهى البناء",
|
||||
@@ -178,13 +178,13 @@
|
||||
"restartBanner": "تمّ التحديث إلى {{commit}}، لكنّ هذه العملية لا تزال تشغّل البناء القديم (بُني الملف الثنائي على القرص في {{builtAt}}). يسري المفعول بعد إعادة التشغيل.",
|
||||
"restartBannerNoCommit": "تمّ استبدال الملف الثنائي على القرص (بُني في {{builtAt}})، لكنّ هذه العملية لا تزال تشغّل البناء القديم؛ يسري المفعول بعد إعادة التشغيل.",
|
||||
"restartNowBtn": "إعادة التشغيل الآن",
|
||||
"restartNowConfirm": "ستنتهي عملية الخدمة؛ مع وجود مشرف (systemd/launchd) تعود النسخة الجديدة خلال ثوانٍ وتعيد هذه الصفحة تحميل نفسها. ومن دونه تبقى الخدمة متوقّفة حتّى تشغّلها. إعادة التشغيل الآن؟",
|
||||
"restartRequestedToast": "طُلبت إعادة التشغيل: يعيدها المشرف وتتعافى هذه الصفحة من تلقاء نفسها",
|
||||
"restartNowConfirm": "ستعيد الخدمة تشغيلها لفترة وجيزة لتفعيل الإصدار الموجود على القرص، ثم تُعاد الصفحة تلقائيًا. هل تريد التبديل الآن؟",
|
||||
"restartRequestedToast": "جارٍ تبديل الإصدار. تُعاد الصفحة عند عودة الخدمة.",
|
||||
"restartFailed": "فشل طلب إعادة التشغيل: {{reason}}",
|
||||
"restartOverlayTitle": "جارٍ إعادة تشغيل الخدمة…",
|
||||
"restartOverlayBody": "تعيد هذه الصفحة تحميل نفسها بمجرّد أن تستجيب العملية الجديدة (ستنتهي جلستك، لذا سيتبع ذلك تسجيل دخول جديد).",
|
||||
"restartOverlayBody": "تُعاد الصفحة عند عودة الخدمة. ستحتاج إلى تسجيل الدخول مرة أخرى.",
|
||||
"restartOverlaySlowRunning": "لا تزال الخدمة تستجيب بوصفها العملية القديمة - يبدو أنّ إعادة التشغيل لم تحدث. استخدم الزرّ أدناه لمعاينة الحالة الحالية.",
|
||||
"restartOverlaySlowDown": "لم تعد الخدمة. إن لم يشرف عليها شيء (systemd/launchd) فلن تبدأ من تلقاء نفسها؛ شغّلها ثمّ حدّث هذه الصفحة.",
|
||||
"restartOverlaySlowDown": "لم تعد الخدمة بعد. ستواصل الصفحة المحاولة. راجع سجلّات الخدمة إذا استمر الانقطاع.",
|
||||
"restartOverlayRefresh": "تحديث هذه الصفحة",
|
||||
"failureNoMessage": "لم يقدّم الخادم أيّ رسالة فشل",
|
||||
"resultTitle": "نتيجة التحديث",
|
||||
|
||||
@@ -147,10 +147,10 @@
|
||||
"blockedLocalEdits": "{{n}} product source files are edited locally and an update will not overwrite them: commit or revert them first.",
|
||||
"blockedDiverged": "Local history is {{ahead}} commits ahead and {{behind}} behind: that is a merge, not a download, so handle it by hand.",
|
||||
"blockedNoUpdate": "The update source has nothing newer than this machine (press Check for updates if you have not looked yet).",
|
||||
"restartChoiceLabel": "Exit the process after the update (only an external supervisor such as systemd or run.sh will bring it back; restart it yourself if nothing supervises it)",
|
||||
"restartNotWired": "This start did not provide a restart hook: restart the service yourself after the update.",
|
||||
"confirmRestartYes": "Selected: the process exits once the update succeeds, and only an external supervisor (systemd, run.sh, ...) will bring it back.",
|
||||
"confirmRestartNo": "Not selected: the process keeps running the old binary until you restart it yourself.",
|
||||
"restartChoiceLabel": "Switch to the new version automatically",
|
||||
"restartNotWired": "This installation cannot switch versions automatically. Restart the service through your deployment setup after the binary is replaced.",
|
||||
"confirmRestartYes": "The service stays available during the build, then briefly restarts to switch versions. This page reloads automatically.",
|
||||
"confirmRestartNo": "The old version keeps running until you restart the service.",
|
||||
"confirmApply": "About to run: update from {{repo}} to {{target}} ({{n}} new commits, this machine is at {{commit}}) -> rebuild the binary -> back up and replace the old one.",
|
||||
"confirmBuildPending": "About to run: build the missing binary for the source already in place (this machine is at {{commit}}) -> rebuild -> back up and replace the old binary.",
|
||||
"applyAccepted": "Update started (job {{id}}); progress refreshes every 1.5 seconds.",
|
||||
@@ -169,7 +169,7 @@
|
||||
"stateSucceeded": "Succeeded",
|
||||
"stateFailed": "Failed",
|
||||
"stateUnknown": "Unknown state",
|
||||
"restartRequested": "Exit after completion requested",
|
||||
"restartRequested": "Automatic switch scheduled",
|
||||
"jobPollFailed": "Failed to read the progress: {{reason}}",
|
||||
"jobDoneToast": "Update finished",
|
||||
"buildDoneToast": "Build finished",
|
||||
@@ -178,13 +178,13 @@
|
||||
"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",
|
||||
"restartNowConfirm": "The service will briefly restart to activate the version on disk. This page reloads automatically. Switch now?",
|
||||
"restartRequestedToast": "Switching versions. This page reloads when the service is back.",
|
||||
"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).",
|
||||
"restartOverlayBody": "This page reloads when the service is back. You will need to sign in again.",
|
||||
"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.",
|
||||
"restartOverlaySlowDown": "The service is taking longer to come back. This page will keep trying. Check the service logs if it remains unavailable.",
|
||||
"restartOverlayRefresh": "Refresh this page",
|
||||
"failureNoMessage": "The server gave no failure message",
|
||||
"resultTitle": "Update result",
|
||||
|
||||
@@ -147,10 +147,10 @@
|
||||
"blockedLocalEdits": "{{n}} файлов исходного кода изменены локально, и обновление не будет их перезаписывать: сначала закоммитьте или отмените изменения.",
|
||||
"blockedDiverged": "Локальная история впереди на {{ahead}} коммитов и отстаёт на {{behind}}: это слияние, а не загрузка, разберитесь вручную.",
|
||||
"blockedNoUpdate": "В источнике обновлений нет ничего новее этой машины (нажмите «Проверить обновления», если ещё не смотрели).",
|
||||
"restartChoiceLabel": "Завершить процесс после обновления (вернуть его сможет только внешний супервизор, например systemd или run.sh; если ничего не супервизит — запустите сами)",
|
||||
"restartNotWired": "При этом запуске хук перезапуска не предоставлен: перезапустите службу сами после обновления.",
|
||||
"confirmRestartYes": "Выбрано: процесс завершится после успешного обновления, и вернуть его сможет только внешний супервизор (systemd, run.sh, ...).",
|
||||
"confirmRestartNo": "Не выбрано: процесс продолжит работать со старым бинарным файлом, пока вы сами его не перезапустите.",
|
||||
"restartChoiceLabel": "Автоматически перейти на новую версию",
|
||||
"restartNotWired": "Этот способ запуска не поддерживает автоматический переход. После замены файла перезапустите службу штатным способом.",
|
||||
"confirmRestartYes": "Во время сборки служба продолжит работу. Для перехода она кратковременно перезапустится, и страница обновится сама.",
|
||||
"confirmRestartNo": "Старая версия продолжит работать до перезапуска службы.",
|
||||
"confirmApply": "Сейчас будет выполнено: обновление из {{repo}} до {{target}} ({{n}} новых коммитов, эта машина на {{commit}}) -> пересборка бинарного файла -> резервная копия и замена старого.",
|
||||
"confirmBuildPending": "Сейчас будет выполнено: сборка недостающего бинарного файла для уже установленных исходников (эта машина на {{commit}}) -> пересборка -> резервная копия и замена старого бинарного файла.",
|
||||
"applyAccepted": "Обновление запущено (задача {{id}}); ход обновляется каждые 1,5 секунды.",
|
||||
@@ -169,7 +169,7 @@
|
||||
"stateSucceeded": "Успешно",
|
||||
"stateFailed": "Не удалось",
|
||||
"stateUnknown": "Неизвестное состояние",
|
||||
"restartRequested": "Запрошено завершение после выполнения",
|
||||
"restartRequested": "Автоматический переход запланирован",
|
||||
"jobPollFailed": "Не удалось прочитать ход выполнения: {{reason}}",
|
||||
"jobDoneToast": "Обновление завершено",
|
||||
"buildDoneToast": "Сборка завершена",
|
||||
@@ -178,13 +178,13 @@
|
||||
"restartBanner": "Обновлено до {{commit}}, но процесс всё ещё работает на старой сборке (бинарник на диске собран {{builtAt}}). Изменения вступят в силу после перезапуска.",
|
||||
"restartBannerNoCommit": "Бинарник на диске заменён (собран {{builtAt}}), но процесс всё ещё работает на старой сборке; изменения вступят в силу после перезапуска.",
|
||||
"restartNowBtn": "Перезапустить сейчас",
|
||||
"restartNowConfirm": "Процесс службы завершится; при наличии супервизора (systemd/launchd) новая версия поднимется через несколько секунд, и страница обновится сама. Без супервизора служба останется остановленной. Перезапустить сейчас?",
|
||||
"restartRequestedToast": "Перезапуск запрошен: супервизор поднимет службу, страница восстановится сама",
|
||||
"restartNowConfirm": "Служба кратковременно перезапустится для запуска версии на диске. Страница обновится сама. Перейти сейчас?",
|
||||
"restartRequestedToast": "Переключаем версию. Страница обновится, когда служба снова будет доступна.",
|
||||
"restartFailed": "Не удалось запросить перезапуск: {{reason}}",
|
||||
"restartOverlayTitle": "Перезапуск службы…",
|
||||
"restartOverlayBody": "Страница обновится сама, как только новый процесс начнёт отвечать (сессия будет сброшена — потребуется войти заново).",
|
||||
"restartOverlayBody": "Страница обновится, когда служба снова будет доступна. Потребуется войти заново.",
|
||||
"restartOverlaySlowRunning": "Служба по-прежнему отвечает старым процессом — перезапуск, похоже, не произошёл. Нажмите кнопку ниже, чтобы увидеть текущее состояние.",
|
||||
"restartOverlaySlowDown": "Служба не вернулась. Если её ничто не супервизит (systemd/launchd), она не запустится сама — запустите её и обновите страницу.",
|
||||
"restartOverlaySlowDown": "Служба пока недоступна. Страница продолжит попытки. Если служба долго не отвечает, проверьте её журналы.",
|
||||
"restartOverlayRefresh": "Обновить страницу",
|
||||
"failureNoMessage": "Сервер не передал сообщение об ошибке",
|
||||
"resultTitle": "Результат обновления",
|
||||
|
||||
@@ -147,10 +147,10 @@
|
||||
"blockedLocalEdits": "有 {{n}} 个产品源码文件被本地改过,自动更新不会覆盖它们:先提交或还原。",
|
||||
"blockedDiverged": "本地领先 {{ahead}} 个提交、落后 {{behind}} 个提交:这是合并而不是下载,请先手工处理。",
|
||||
"blockedNoUpdate": "更新源没有比本机更新的提交(还没查过就先点「检查更新」)。",
|
||||
"restartChoiceLabel": "更新完成后退出进程(需 systemd/run.sh 等外部守护才会自动拉起;无人守护请手动重启)",
|
||||
"restartNotWired": "本次启动没有提供重启钩子:更新完成后请自己重启服务。",
|
||||
"confirmRestartYes": "已勾选:更新成功后本进程会退出,只有外部守护(systemd/run.sh 等)才会把它再拉起来。",
|
||||
"confirmRestartNo": "未勾选:进程不退出,运行的仍是旧二进制,需要你自行重启。",
|
||||
"restartChoiceLabel": "更新成功后自动切换到新版本",
|
||||
"restartNotWired": "当前启动方式不支持自动切换。换入二进制后,请通过部署方式重启服务。",
|
||||
"confirmRestartYes": "构建期间服务照常运行;新版本就绪后会短暂重启,页面随后自动刷新。",
|
||||
"confirmRestartNo": "构建完成后仍运行旧版本,待你重启再切换。",
|
||||
"confirmApply": "将要执行:从更新源 {{repo}} 更新到 {{target}}({{n}} 个新提交,本机当前 {{commit}})→ 重新编译二进制 → 备份并替换旧二进制。",
|
||||
"confirmBuildPending": "将要执行:为已经就位的源码补上二进制(本机当前 {{commit}})→ 重新编译 → 备份并替换旧二进制。",
|
||||
"applyAccepted": "更新已开始(任务 {{id}}),进度每 1.5 秒刷新一次。",
|
||||
@@ -169,7 +169,7 @@
|
||||
"stateSucceeded": "已完成",
|
||||
"stateFailed": "失败",
|
||||
"stateUnknown": "未知状态",
|
||||
"restartRequested": "已要求在完成后退出",
|
||||
"restartRequested": "已安排自动切换",
|
||||
"jobPollFailed": "进度读取失败:{{reason}}",
|
||||
"jobDoneToast": "更新完成",
|
||||
"buildDoneToast": "补编译完成",
|
||||
@@ -178,13 +178,13 @@
|
||||
"restartBanner": "已更新到 {{commit}},但当前进程仍在运行旧版本(磁盘上的二进制构建于 {{builtAt}})。重启后新版本才会生效。",
|
||||
"restartBannerNoCommit": "磁盘上的二进制已换新(构建于 {{builtAt}}),但当前进程仍在运行旧版本;重启后新版本才会生效。",
|
||||
"restartNowBtn": "立即重启服务",
|
||||
"restartNowConfirm": "服务进程会退出;有守护进程(systemd/launchd)时几秒内会自动拉起新版本,本页随后自动刷新。没有守护时服务会保持停止,需要手动启动。确定现在重启?",
|
||||
"restartRequestedToast": "已请求重启:进程退出后由守护进程拉起,页面会自动恢复",
|
||||
"restartNowConfirm": "服务将短暂重启以启用磁盘上的版本,页面会自动刷新。现在切换吗?",
|
||||
"restartRequestedToast": "正在切换版本,服务恢复后页面会自动刷新。",
|
||||
"restartFailed": "重启请求失败:{{reason}}",
|
||||
"restartOverlayTitle": "正在重启服务…",
|
||||
"restartOverlayBody": "新进程接客后本页会自动刷新(会话会失效,需要重新登录)。无需手动操作。",
|
||||
"restartOverlayBody": "服务恢复后页面会自动刷新。当前登录会话会结束,请重新登录。",
|
||||
"restartOverlaySlowRunning": "服务仍在以旧进程应答——重启似乎没有发生。可点下方按钮刷新页面查看当前状态。",
|
||||
"restartOverlaySlowDown": "服务还没有回来。若本机没有守护进程(systemd/launchd),它不会自己启动;手动启动服务后再刷新本页。",
|
||||
"restartOverlaySlowDown": "服务仍未恢复,页面会继续重试。如果长时间无法连接,请检查服务日志。",
|
||||
"restartOverlayRefresh": "立即刷新页面",
|
||||
"failureNoMessage": "服务端没有给出失败说明",
|
||||
"resultTitle": "更新结果",
|
||||
|
||||
@@ -94,6 +94,8 @@ function envelope(status, job, canRestart, source, flags) {
|
||||
job: job === undefined ? null : job,
|
||||
canRestart: canRestart !== false,
|
||||
supervised: !!(flags && flags.supervised),
|
||||
autoRestart: flags?.autoRestart,
|
||||
bootID: flags?.bootID,
|
||||
needsRestart: !!(flags && flags.needsRestart),
|
||||
binaryBuiltAt: (flags && flags.binaryBuiltAt) || '',
|
||||
source: source || emptySource,
|
||||
@@ -166,7 +168,8 @@ function harness(options) {
|
||||
const el = {
|
||||
id,
|
||||
innerHTML: '',
|
||||
checked: false,
|
||||
checked: id === 'update-restart-choice' &&
|
||||
/id="update-restart-choice" checked/.test(nodes.get('update-console')?.innerHTML || ''),
|
||||
insertAdjacentHTML(_pos, html) { this.innerHTML = html + this.innerHTML; },
|
||||
};
|
||||
// 控制台住在系统设置页的「一键更新」分区里:页面和分区都得是 active,它才算在前台。
|
||||
@@ -192,10 +195,7 @@ 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'.
|
||||
// Plain fetch observes boot headers without apiFetch's logout side effect.
|
||||
const probeQueue = (opts.probes || ['ok']).slice();
|
||||
const fetchProbes = [];
|
||||
|
||||
@@ -308,7 +308,14 @@ function harness(options) {
|
||||
return Promise.reject(new TypeError('Failed to fetch'));
|
||||
}
|
||||
if (answer === 'unauthorized') {
|
||||
return Promise.resolve({ ok: false, status: 401, json: () => Promise.resolve({ error: 'unauthorized' }) });
|
||||
return Promise.resolve({ ok: false, status: 401, json: () => Promise.resolve({ error: '未授权访问,请先登录' }) });
|
||||
}
|
||||
if (typeof answer === 'object') {
|
||||
return Promise.resolve({
|
||||
status: answer.status,
|
||||
headers: { get: name => name === 'X-CyberStrike-Boot-ID' ? answer.bootID : null },
|
||||
json: () => Promise.resolve(answer.body || {}),
|
||||
});
|
||||
}
|
||||
if (answer === 'bad gateway') {
|
||||
return Promise.resolve({ ok: false, status: 502, json: () => Promise.resolve({}) });
|
||||
@@ -884,7 +891,7 @@ test('the restart tick only appears when the process can actually be restarted',
|
||||
const off = await harness({ canRestart: false });
|
||||
assert.doesNotMatch(off.html(), /id="update-restart-choice"/,
|
||||
'offering a promise the platform cannot keep is a lie');
|
||||
assert.match(off.html(), /本次启动没有提供重启钩子/);
|
||||
assert.match(off.html(), /当前启动方式不支持自动切换/);
|
||||
|
||||
const on = await harness({
|
||||
canRestart: true,
|
||||
@@ -896,19 +903,19 @@ test('the restart tick only appears when the process can actually be restarted',
|
||||
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({
|
||||
const standalone = await harness({
|
||||
canRestart: true,
|
||||
flags: { supervised: true },
|
||||
responses: { 'GET /api/system/update/job': [{ status: 200, body: { job: null } }] },
|
||||
flags: { autoRestart: true, supervised: false },
|
||||
responses: { 'GET /api/system/update/job': [{ status: 200, body: { job: { ...runningJob, restartRequested: true } } }] },
|
||||
});
|
||||
assert.match(supervised.html(), /id="update-restart-choice" checked/,
|
||||
'detected supervision must pre-tick the box');
|
||||
assert.match(standalone.html(), /id="update-restart-choice" checked/,
|
||||
'the standalone executable supports automatic activation');
|
||||
await standalone.api.startUpdateApply();
|
||||
assert.deepEqual(JSON.parse(standalone.calls.find(c => c.url === '/api/system/update/apply').body), { restart: true });
|
||||
// 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()));
|
||||
standalone.sandbox.document.getElementById('update-restart-choice').checked = false;
|
||||
standalone.fire('updateRestartChoiceChanged', { checked: false });
|
||||
assert.ok(!/id="update-restart-choice" checked/.test(standalone.html()));
|
||||
|
||||
on.calls.length = 0;
|
||||
on.fire('startUpdateApply');
|
||||
@@ -1356,7 +1363,7 @@ test('restart now asks first, posts once, and hands the page to the watchdog', a
|
||||
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.match(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');
|
||||
|
||||
@@ -1389,8 +1396,8 @@ test('a refused restart is reported with the server sentence and arms nothing',
|
||||
|
||||
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'],
|
||||
flags: { needsRestart: true, bootID: 'old' },
|
||||
probes: [{ status: 401, bootID: 'old' }, 'down', { status: 401 }, { status: 200, bootID: 'new' }],
|
||||
responses: { 'POST /api/system/update/restart': [{ status: 202, body: { restarting: true } }] },
|
||||
});
|
||||
await h.api.startRestartNow();
|
||||
@@ -1399,18 +1406,18 @@ test('the watchdog reloads only when the NEW process answers, never while the ol
|
||||
|
||||
await h.tick();
|
||||
await h.flush();
|
||||
assert.equal(h.location.replaced.length, 0, 'a 200 from the old process is not "it is back"');
|
||||
assert.equal(h.location.replaced.length, 0, 'an expired session on the old boot is not a restart');
|
||||
|
||||
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');
|
||||
assert.equal(h.location.replaced.length, 0, 'a dead service or proxy 401 does not prove recovery');
|
||||
|
||||
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');
|
||||
'the new boot can be recognized independently of authentication status');
|
||||
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');
|
||||
@@ -1427,7 +1434,7 @@ test('a restart that does not come back says so instead of spinning forever', as
|
||||
downCase.vars.expireWatchdog();
|
||||
await downCase.tick();
|
||||
await downCase.flush();
|
||||
assert.match(downCase.html(), /没有守护进程|不会自己启动/, 'the down advice must name the supervision question');
|
||||
assert.match(downCase.html(), /服务仍未恢复,页面会继续重试/);
|
||||
|
||||
const aliveCase = await harness({
|
||||
flags: { needsRestart: true },
|
||||
@@ -1495,6 +1502,21 @@ test('a job poll that dies while the restart is in flight is not a failure', asy
|
||||
assert.equal(h.location.replaced.length, 1);
|
||||
});
|
||||
|
||||
test('an update that did not build a binary stays on the progress page', async () => {
|
||||
const h = await harness({
|
||||
flags: { autoRestart: true },
|
||||
responses: {
|
||||
'GET /api/system/update/job': [{
|
||||
status: 200,
|
||||
body: { job: { ...succeededJob, result: { ...succeededJob.result, binaryBuilt: false } } },
|
||||
}],
|
||||
},
|
||||
});
|
||||
await h.api.startUpdateApply();
|
||||
assert.deepEqual(JSON.parse(h.calls.find(c => c.url === '/api/system/update/apply').body), { restart: true });
|
||||
assert.equal(h.vars.getWatchdogTimer(), null, 'a source-only update does not cause an outage');
|
||||
});
|
||||
|
||||
test('an interrupted rollback offers recovery and blocks a new update', async () => {
|
||||
const h = await harness({ status: statusOf({ rollbackPending: true, hasRollback: true }) });
|
||||
assert.match(h.html(), /上一次回滚尚未完成/);
|
||||
|
||||
+63
-28
@@ -20,14 +20,15 @@ const UPDATE_FAILURE_KEYS = {
|
||||
restart: 'restartFailed',
|
||||
};
|
||||
|
||||
let updateConsoleState = null; // GET /api/system/update 的整包:{status, job, canRestart, supervised, needsRestart}
|
||||
let updateConsoleState = null;
|
||||
let updateCheck = { done: false, error: '' };
|
||||
let updateBusy = false; // 页面自己发着的动作请求(检查 / 启动更新 / 回滚 / 重启)
|
||||
let updateChecking = false; // 只影响「检查更新」按钮的措辞
|
||||
let updatePollTimer = null;
|
||||
let updateRestartChoice = true; // 勾选框要在轮询重绘之后仍然是勾着的;默认值跟随 supervised
|
||||
let updateRestartChoice = false;
|
||||
let updateRestartChoiceSet = false; // 操作员本次会话手动改过之后,默认值不再顶掉他的选择
|
||||
let updateRestartPending = false; // 已请求重启:进程退出/新进程接客就在眼前
|
||||
let updateRestartExpected = false; // 更新结束后预计重启,构建阶段仍照常读进度
|
||||
let updateRestartPending = false; // 正在等待服务恢复
|
||||
let updateWatchdogTimer = null; // 重启守侧自己的定时器,跟任务进度轮询互不干扰
|
||||
let updateWatchdogDeadline = 0;
|
||||
let updateWatchdogSlow = null; // null=还没超时;'running'=仍由旧进程应答;'down'=服务没回来
|
||||
@@ -145,7 +146,9 @@ async function fetchUpdateStatus() {
|
||||
status: r.data.status || {},
|
||||
job: r.data.job || null,
|
||||
canRestart: !!r.data.canRestart,
|
||||
supervised: !!r.data.supervised,
|
||||
// Old backends have no self-restart capability field.
|
||||
autoRestart: r.data.autoRestart ?? !!r.data.supervised,
|
||||
bootID: r.data.bootID || '',
|
||||
needsRestart: !!r.data.needsRestart,
|
||||
binaryBuiltAt: r.data.binaryBuiltAt || '',
|
||||
source: r.data.source || { repo: '', configured: false, defaultRepo: '' },
|
||||
@@ -154,11 +157,9 @@ async function fetchUpdateStatus() {
|
||||
return updateConsoleState;
|
||||
}
|
||||
|
||||
// 勾选框的默认值:有守护(launchd/systemd 的启动标记)才默认勾上——没人守护时默认勾上
|
||||
// 等于"一键把服务点停"。操作员自己动过勾选框之后,服务端的值不再覆盖他的选择。
|
||||
function noteRestartDefault() {
|
||||
if (updateRestartChoiceSet) return;
|
||||
updateRestartChoice = !!(updateConsoleState && updateConsoleState.supervised);
|
||||
updateRestartChoice = updateCanRestart() && updateConsoleState.autoRestart;
|
||||
}
|
||||
|
||||
// notify 只在平台真的载了 showNotification 时才动。它绝不能成为"操作看起来失败"的原因:
|
||||
@@ -213,7 +214,7 @@ async function loadUpdateConsole() {
|
||||
}
|
||||
resetUpdateCheck();
|
||||
updateRestartChoiceSet = false;
|
||||
updateRestartChoice = true; // 之后由 noteRestartDefault 按 supervised 修正
|
||||
updateRestartChoice = false;
|
||||
updateActionError = ''; // 重新进页 = 干净画面,上一次动作的红字不跟过来
|
||||
const el = document.getElementById('update-console');
|
||||
if (!el) return;
|
||||
@@ -226,7 +227,12 @@ async function loadUpdateConsole() {
|
||||
return;
|
||||
}
|
||||
renderUpdateConsole();
|
||||
// 开页就有一次在跑的更新(比如另开一个标签页点的),接着看它,别让人以为没在动。
|
||||
const job = updateJobOf();
|
||||
updateRestartExpected = !!(job && job.restartRequested && job.state === 'running');
|
||||
if (job && job.state === 'succeeded' && job.restartRequested && job.result?.binaryBuilt) {
|
||||
startUpdateWatchdog();
|
||||
return;
|
||||
}
|
||||
if (isUpdateJobRunning()) startUpdatePolling();
|
||||
scheduleUpdateAutoCheck();
|
||||
}
|
||||
@@ -246,8 +252,7 @@ function stopUpdatePolling() {
|
||||
// 重启守侧:请求重启之后,盯着"新进程是否已经接客",接上了就整页刷新
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// 只认一个新进程已经应答的事实:会话只活在旧进程内存里,新进程对旧令牌只会回 401。
|
||||
// 200 说明还在跟旧进程说话(还没退出),连不上/502 说明正在退出或还没起来——都继续等。
|
||||
// 服务启动标记变了才刷新。会话过期和代理的错误页都不代表换版。
|
||||
// 离开控制台就静默停表:不把已经走开的用户从别的页面拽回更新页。
|
||||
let updateWatchdogProbing = false;
|
||||
|
||||
@@ -259,28 +264,48 @@ async function watchdogProbe() {
|
||||
if (updateWatchdogProbing) return;
|
||||
updateWatchdogProbing = true;
|
||||
let status = 0;
|
||||
let bootID = '';
|
||||
let legacyUnauthorized = false;
|
||||
const previousBoot = updateConsoleState && updateConsoleState.bootID;
|
||||
try {
|
||||
const resp = await fetch('/api/system/update', {
|
||||
const headers = { Accept: 'application/json' };
|
||||
if (typeof authToken === 'string' && authToken) headers.Authorization = 'Bearer ' + authToken;
|
||||
const resp = await fetch('/api/auth/validate', {
|
||||
method: 'GET',
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-store',
|
||||
headers: { Accept: 'application/json' },
|
||||
redirect: 'error',
|
||||
headers: headers,
|
||||
signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(5000) : undefined,
|
||||
});
|
||||
status = resp.status;
|
||||
bootID = resp.headers?.get('X-CyberStrike-Boot-ID') || '';
|
||||
if (status === 401 && !bootID) {
|
||||
// A downgrade may return to a build that predates the boot header.
|
||||
const data = await resp.json().catch(() => null);
|
||||
legacyUnauthorized = data?.error === '未授权访问,请先登录';
|
||||
}
|
||||
} catch (err) {
|
||||
status = 0;
|
||||
} finally {
|
||||
updateWatchdogProbing = false;
|
||||
}
|
||||
if (status === 401) {
|
||||
// 新进程已经接客、且不认我们的会话:整页刷新落到新版本,带 hash 回到本页。
|
||||
// 先停表:页面马上要整体换掉,定时器不该再有第二次动作。
|
||||
if (!isUpdateConsoleActive() || !updateWatchdogTimer) return;
|
||||
if ((bootID && bootID !== previousBoot && (status === 200 || status === 401)) || legacyUnauthorized) {
|
||||
stopUpdateWatchdog();
|
||||
location.replace(location.pathname + '?restarted=' + Date.now() + '#system-update');
|
||||
return;
|
||||
}
|
||||
if (updateRestartExpected && previousBoot && bootID === previousBoot && status === 200) {
|
||||
// The progress request may have hit a transient network error during the build.
|
||||
stopUpdateWatchdog();
|
||||
updateRestartPending = false;
|
||||
renderUpdateConsole();
|
||||
startUpdatePolling();
|
||||
return;
|
||||
}
|
||||
if (updateWatchdogSlow === null && Date.now() > updateWatchdogDeadline) {
|
||||
updateWatchdogSlow = status === 200 ? 'running' : 'down';
|
||||
updateWatchdogSlow = bootID || status === 200 ? 'running' : 'down';
|
||||
renderUpdateWatchdog();
|
||||
}
|
||||
}
|
||||
@@ -316,8 +341,8 @@ async function pollUpdateJob() {
|
||||
}
|
||||
if (!r.ok) {
|
||||
stopUpdatePolling();
|
||||
if (updateRestartPending) {
|
||||
// 进程按请求退了:这不是"读进度失败",是重启进入了下一幕。
|
||||
if (updateRestartExpected) {
|
||||
// The page may have missed the last progress response before handoff.
|
||||
startUpdateWatchdog();
|
||||
return;
|
||||
}
|
||||
@@ -327,12 +352,16 @@ async function pollUpdateJob() {
|
||||
const job = r.data && r.data.job ? r.data.job : null;
|
||||
if (updateConsoleState) updateConsoleState.job = job;
|
||||
renderUpdateConsole();
|
||||
if (job && job.state === 'running') updateRestartExpected = !!job.restartRequested;
|
||||
if (!job && updateRestartExpected) {
|
||||
stopUpdatePolling();
|
||||
startUpdateWatchdog();
|
||||
return;
|
||||
}
|
||||
if (job && job.state !== 'running') {
|
||||
stopUpdatePolling();
|
||||
if (job.state === 'succeeded' && job.restartRequested) {
|
||||
// 进程马上要让位给新二进制:先把结论播报掉,再进自恢复视图;不再回头
|
||||
// 去读一个即将消失的服务(reportFinishedJob 会去读,这里跳过它)。
|
||||
notify(updateDoneToast(job.result || null), 'success');
|
||||
updateRestartExpected = false;
|
||||
if (job.state === 'succeeded' && job.restartRequested && job.result?.binaryBuilt) {
|
||||
startUpdateWatchdog();
|
||||
return;
|
||||
}
|
||||
@@ -357,7 +386,10 @@ function reportFinishedJob(job) {
|
||||
const result = job.result || null;
|
||||
if (job.state === 'succeeded') {
|
||||
notify(updateDoneToast(result), 'success');
|
||||
if (result && result.needsRestart) notify(updateT('needsRestartToast'), 'info');
|
||||
if (job.restartError) {
|
||||
updateActionError = updateT('restartFailed', { reason: job.restartError });
|
||||
notify(updateActionError, 'error');
|
||||
} else if (result && result.needsRestart) notify(updateT('needsRestartToast'), 'info');
|
||||
} else {
|
||||
const reason = (job.failure && job.failure.message) ? job.failure.message : updateT('failureNoMessage');
|
||||
notify(updateT('jobFailedToast') + ': ' + reason, 'error');
|
||||
@@ -479,7 +511,7 @@ async function startUpdateApply() {
|
||||
}
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
resetUpdateCheck();
|
||||
if (restart) updateRestartPending = true;
|
||||
updateRestartExpected = restart;
|
||||
startUpdatePolling();
|
||||
notify(updateT('applyAccepted', { id: r.data.job_id || '-' }), 'success');
|
||||
await pollUpdateJob();
|
||||
@@ -496,6 +528,7 @@ async function startRestartNow() {
|
||||
const r = await runUpdateRequest('POST', '/api/system/update/restart');
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
stopUpdatePolling();
|
||||
updateRestartExpected = false;
|
||||
notify(updateT('restartRequestedToast'), 'info');
|
||||
startUpdateWatchdog();
|
||||
}, 'restart');
|
||||
@@ -587,7 +620,7 @@ async function confirmAdoptSource() {
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
updateAdoptPlan = null;
|
||||
resetUpdateCheck();
|
||||
if (restart) updateRestartPending = true;
|
||||
updateRestartExpected = restart;
|
||||
startUpdatePolling();
|
||||
notify(updateT('adoptAccepted', { id: r.data.job_id || '-' }), 'success');
|
||||
await pollUpdateJob();
|
||||
@@ -949,8 +982,10 @@ function renderRestartBanner() {
|
||||
});
|
||||
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>';
|
||||
(updateCanRestart() ?
|
||||
'<button class="btn-primary update-restart-btn" data-require-permission="update:apply" onclick="startRestartNow()">' +
|
||||
escapeHtml(updateT('restartNowBtn')) + '</button>' : '') +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// 自恢复视图:重启请求发出后控制台画的就是它,直到新进程应答触发整页刷新。
|
||||
|
||||
Reference in new issue
Block a user