feat: update this installation's own source in one action

An operator running a fork has no honest way to upgrade: upgrade.sh downloads the
Release tarball of one hard-coded third-party repository and rsyncs it over the
tree, so "upgrade" means "overwrite yourself with somebody else's code", and the
safe alternative is to pull by hand and remember the build command.

internal/update drives the remote the directory already tracks: fetch, fast-forward,
go build into .update-staging, atomic rename of the binary (the previous one kept as
cyberstrike-ai.prev), and a .update-state.json so a rollback has a target that is
not guessed at. Refusals are the point - local source edits and a diverged branch
stop with the file names and commit counts in the error rather than being reset to
match, and operator-owned content (roles/skills/tools/agents/data/config.yaml) is put
aside before the merge and restored over it, with what was kept listed in keptContent
instead of quietly dropped.

Consumers: GET/POST /api/system/update* behind update:read / update:apply (mutations
are process-global, so they need a global-scope session); a console page at
#system-update with an apply button that names why it is blocked, a job polled from
the server and a rollback control; the same three actions as -check-update / -update
/ -update-rollback on the binary; OpenAPI entries for all five endpoints. Permission
gating no longer re-enables a control a page disabled for a reason of its own
(data-state-disabled), which every state-disabled, permission-gated button in the
console was subject to. upgrade.sh becomes a thin shell over the same implementation
and only falls back to the tarball path for installs that are not git work trees.

Docs and the zh/EN/RU dictionaries are updated. The package runs 13 tests against
real temporary git repositories; the HTTP layer, RBAC and the console page each
carry their own contract tests.
This commit is contained in:
Sycun committed 2026-10-07 12:07:26 +08:00
1 parent d2084a9481
commit cb8bc6b824
34 files changed
+5225 -112

No files matched your search

+290
View File
@@ -46715,3 +46715,293 @@ html[data-theme="dark"] { --tool-blocked-color: #f4bc62; }
white-space: nowrap;
line-height: 1.5;
}
/* ---------------------------------------------------------------------------
一键更新控制台(system-update):这里说的每一句状态都要能被眼睛分区——
本机事实、远端比较、能不能点、正在跑的进度、以及跑完的结论。
ok/warn/danger 三色是给"说谎"留的对照组:查不了必须是 warn/danger,不能长得像 ok。
--------------------------------------------------------------------------- */
.update-console {
display: flex;
flex-direction: column;
gap: 20px;
}
.update-intro {
color: var(--text-secondary);
margin: 0 0 12px;
}
.update-section-title {
font-size: 15px;
margin: 0 0 10px;
}
.update-sub-title {
font-size: 13px;
font-weight: 600;
margin: 14px 0 6px;
color: var(--text-secondary);
}
.update-facts {
display: flex;
flex-wrap: wrap;
gap: 8px 20px;
padding: 12px 16px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 12px;
}
.update-fact {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 13px;
color: var(--text-secondary);
min-width: 0;
max-width: 100%;
}
.update-fact code {
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 420px;
}
.update-marks {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.update-chip {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
border: 1px solid var(--border-color);
color: var(--text-secondary);
}
.update-chip-ok {
background: rgba(37, 168, 112, 0.14);
border-color: rgba(37, 168, 112, 0.4);
color: #1f8a5b;
}
.update-chip-warn {
background: rgba(217, 130, 43, 0.14);
border-color: rgba(217, 130, 43, 0.4);
color: #b06a17;
cursor: help;
}
.update-chip-danger {
background: rgba(192, 57, 43, 0.14);
border-color: rgba(192, 57, 43, 0.4);
color: var(--danger-color, #c0392b);
}
.update-chip-running {
background: rgba(58, 110, 197, 0.14);
border-color: rgba(58, 110, 197, 0.4);
color: #2d5bb0;
}
.update-ok {
color: #1f8a5b;
font-size: 13px;
}
.update-behind {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.update-hint {
color: var(--text-secondary);
font-size: 13px;
line-height: 1.6;
margin: 6px 0;
}
.update-warn {
background: rgba(217, 130, 43, 0.12);
border: 1px solid rgba(217, 130, 43, 0.35);
border-radius: 8px;
padding: 8px 12px;
font-size: 13px;
color: #b06a17;
margin: 6px 0;
}
.update-error {
background: rgba(192, 57, 43, 0.1);
border: 1px solid rgba(192, 57, 43, 0.32);
border-radius: 8px;
padding: 8px 12px;
color: var(--danger-color, #c0392b);
font-size: 13px;
margin: 6px 0;
}
/* 「检查失败」那一行是这一页最不该被看漏的字,所以它比别的错误横幅再重一档。 */
.update-check-failed {
font-weight: 600;
}
/* 这三个按钮的名字既是样式钩子也是测试钩子:禁用态要看得出是禁用,而不是"点了没反应"。 */
.update-apply-btn:disabled,
.update-check-btn:disabled,
.update-rollback-btn:disabled {
cursor: not-allowed;
opacity: .55;
}
.update-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin: 10px 0;
}
.update-restart-choice {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 13px;
color: var(--text-primary);
cursor: pointer;
margin: 4px 0;
}
.update-restart-choice input {
margin-top: 2px;
}
.update-restart-preview {
border-left: 3px solid var(--border-color);
padding-left: 10px;
}
.update-blocker-list {
margin: 6px 0 0;
padding-left: 18px;
font-size: 13px;
}
.update-commit-list,
.update-kept-list,
.update-item-list {
margin: 0;
padding-left: 18px;
font-size: 13px;
line-height: 1.8;
}
.update-commit-list code,
.update-kept-list code,
.update-item-list code {
color: var(--text-primary);
}
.update-commit-subject {
color: var(--text-secondary);
}
.update-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.update-table th,
.update-table td {
text-align: left;
padding: 6px 8px;
border-bottom: 1px solid var(--border-color);
word-break: break-all;
}
.update-row-blocking td {
background: rgba(192, 57, 43, 0.06);
}
.update-row-kept td {
background: rgba(37, 168, 112, 0.06);
}
.update-job-head {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
font-size: 13px;
color: var(--text-secondary);
}
.update-job-time {
font-size: 12px;
color: var(--text-secondary);
}
.update-step-list {
margin: 10px 0 0;
padding: 10px 12px;
list-style: decimal inside;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 10px;
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: 12.5px;
}
.update-step {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 3px 0;
border-bottom: 1px dashed var(--border-color);
}
.update-step:last-child {
border-bottom: none;
}
.update-step-phase {
color: #2d5bb0;
flex: 0 0 auto;
}
.update-step-message {
color: var(--text-primary);
flex: 1 1 320px;
white-space: pre-wrap;
word-break: break-word;
}
.update-step-at {
color: var(--text-secondary);
flex: 0 0 auto;
}
.update-result {
margin-top: 12px;
}
.update-failure-title {
font-weight: 600;
margin-bottom: 4px;
}
.update-failure-message {
white-space: pre-wrap;
word-break: break-word;
}
+95
View File
@@ -79,7 +79,102 @@
"passwordPlaceholder": "Enter password",
"submit": "Sign in"
},
"update": {
"title": "One-Click Update",
"intro": "This page changes the very installation that is answering your requests: it pulls the remote this tree already tracks, rebuilds the binary and swaps it in. The roles/skills/tools and configuration you maintain here are not overwritten.",
"loading": "Reading this installation's state",
"loadFailed": "Failed to read this installation's state: {{reason}}",
"installTitle": "This installation",
"remoteTitle": "Against the remote",
"applyTitle": "One-click update",
"jobTitle": "Update progress",
"rollbackTitle": "Rollback",
"root": "Install directory",
"branch": "Branch",
"remote": "Remote",
"commit": "Current commit",
"committedAt": "Committed at",
"subject": "Commit subject",
"gitTree": "git work tree",
"notGitTree": "Not a git work tree",
"toolchainFound": "Go toolchain present",
"toolchainMissing": "No Go toolchain",
"toolchainMissingHint": "No go command found: an update can only move the source, the build needs another machine",
"binaryPresent": "Binary present",
"binaryMissing": "Binary missing",
"rollbackPresent": "Rollback point exists",
"rollbackMissing": "No rollback point",
"notChecked": "The remote has not been consulted yet. Opening this page queues one automatic check (once per session); press Check for updates to ask again any time.",
"checkBtn": "Check for updates",
"checking": "Checking the remote...",
"checkFailed": "Check failed: {{reason}}",
"applyFailed": "Failed to start the update: {{reason}}",
"rollbackFailed": "Rollback failed: {{reason}}",
"behindCount": "{{n}} commits behind",
"aheadCount": "{{n}} commits ahead locally",
"upToDate": "Up to date: local {{commit}} matches the remote",
"remoteHead": "Remote head",
"remoteSubject": "Remote commit subject",
"incomingTitle": "Incoming commits",
"incomingTruncated": "{{total}} new commits in total; only the first {{shown}} are listed",
"applyBtn": "Update now",
"jobRunningHint": "An update is already running; its progress is below.",
"noToolchainWarn": "No Go toolchain here: the source will be updated but the binary will not be replaced; build it externally, then restart.",
"blockedNotGitTree": "This directory is not a git work tree, so it cannot be updated automatically.",
"blockedJobRunning": "An update is already running; wait for it to finish.",
"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 remote 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.",
"confirmApply": "About to run: fast-forward {{remote}}/{{branch}} to {{target}} ({{n}} commits, this machine is at {{commit}}) -> rebuild the binary -> back up and replace the old one.",
"applyAccepted": "Update started (job {{id}}); progress refreshes every 1.5 seconds.",
"localChangesTitle": "Local changes",
"localChangesHint": "Files marked local content are kept exactly as they are; files marked product source block the automatic update.",
"noLocalChanges": "Working tree clean: no tracked file is modified or deleted.",
"colPath": "File",
"colChangeStatus": "State",
"colOwner": "Owner",
"keptFile": "Local content (kept by the update)",
"productSource": "Product source (blocks the update)",
"noJob": "No update has run yet",
"noSteps": "The job has not produced a progress line yet",
"pollingHint": "Progress is read every {{sec}} seconds",
"stateRunning": "Running",
"stateSucceeded": "Succeeded",
"stateFailed": "Failed",
"stateUnknown": "Unknown state",
"restartRequested": "Exit after completion requested",
"jobPollFailed": "Failed to read the progress: {{reason}}",
"jobDoneToast": "Update finished",
"jobFailedToast": "Update failed",
"needsRestartToast": "The binary was replaced; it takes effect after a restart",
"failureNoMessage": "The server gave no failure message",
"resultTitle": "Update result",
"resultFrom": "From",
"resultTo": "To",
"resultCommits": "Commits brought in",
"resultFiles": "Files touched",
"resultDuration": "Duration",
"resultBackup": "Backup directory",
"keptTitle": "Local content files that were kept",
"nothingKept": "No local content file had to be moved aside",
"binarySwapped": "Binary replaced",
"binaryNotSwapped": "Binary unchanged",
"restartNeeded": "A restart is needed to run the new version",
"restartNotNeeded": "No restart needed",
"failureTitle": "Why it failed",
"noRollback": "No rollback point: one exists only after an update has run",
"rollbackTarget": "Rollback target: {{commit}} (where this tree stood before the last update)",
"rollbackBtn": "Roll back the update",
"confirmRollback": "Roll back to {{commit}}? The source returns to before that update and the previous binary is restored.",
"rollbackDone": "Rolled back to {{commit}}",
"rollbackItems": "Files that were not rolled back"
},
"nav": {
"systemUpdate": "One-Click Update",
"security": "Security",
"toolGuard": "Call blocking",
"dashboard": "Dashboard",
+95
View File
@@ -79,7 +79,102 @@
"passwordPlaceholder": "Введите пароль",
"submit": "Войти"
},
"update": {
"title": "Обновление в один клик",
"intro": "Эта страница изменяет саму установку, которая отвечает на ваши запросы: она подтягивает удалённый репозиторий, который уже отслеживает это дерево, пересобирает бинарный файл и подменяет его. Ваши roles/skills/tools и конфигурация не перезаписываются.",
"loading": "Чтение состояния этой установки",
"loadFailed": "Не удалось прочитать состояние установки: {{reason}}",
"installTitle": "Эта установка",
"remoteTitle": "Сравнение с удалённым репозиторием",
"applyTitle": "Обновление в один клик",
"jobTitle": "Ход обновления",
"rollbackTitle": "Откат",
"root": "Каталог установки",
"branch": "Ветка",
"remote": "Удалённый репозиторий",
"commit": "Текущий коммит",
"committedAt": "Дата коммита",
"subject": "Заголовок коммита",
"gitTree": "git-рабочее дерево",
"notGitTree": "Не git-рабочее дерево",
"toolchainFound": "Инструментарий Go на месте",
"toolchainMissing": "Нет инструментария Go",
"toolchainMissingHint": "Команда go не найдена: обновление сможет только перенести исходники, для сборки нужна другая машина",
"binaryPresent": "Бинарный файл на месте",
"binaryMissing": "Бинарный файл отсутствует",
"rollbackPresent": "Точка отката есть",
"rollbackMissing": "Точки отката нет",
"notChecked": "Удалённый репозиторий ещё не проверялся. При открытии страницы запускается одна автоматическая проверка (по одной за сессию); нажмите «Проверить обновления», чтобы спросить ещё раз.",
"checkBtn": "Проверить обновления",
"checking": "Проверка удалённого репозитория...",
"checkFailed": "Проверка не удалась: {{reason}}",
"applyFailed": "Не удалось запустить обновление: {{reason}}",
"rollbackFailed": "Откат не удался: {{reason}}",
"behindCount": "Отставание на {{n}} коммитов",
"aheadCount": "Локально на {{n}} коммитов впереди",
"upToDate": "Всё актуально: локальный {{commit}} совпадает с удалённым",
"remoteHead": "Вершина удалённого репозитория",
"remoteSubject": "Заголовок удалённого коммита",
"incomingTitle": "Входящие коммиты",
"incomingTruncated": "Всего новых коммитов: {{total}}; показаны только первые {{shown}}",
"applyBtn": "Обновить сейчас",
"jobRunningHint": "Обновление уже выполняется; его ход показан ниже.",
"noToolchainWarn": "Здесь нет инструментария Go: исходники будут обновлены, но бинарный файл не заменится; соберите его извне и перезапустите службу.",
"blockedNotGitTree": "Этот каталог не является git-рабочим деревом, автоматическое обновление невозможно.",
"blockedJobRunning": "Обновление уже выполняется; дождитесь его завершения.",
"blockedLocalEdits": "{{n}} файлов исходного кода изменены локально, и обновление не будет их перезаписывать: сначала закоммитьте или отмените изменения.",
"blockedDiverged": "Локальная история впереди на {{ahead}} коммитов и отстаёт на {{behind}}: это слияние, а не загрузка, разберитесь вручную.",
"blockedNoUpdate": "В удалённом репозитории нет ничего новее этой машины (нажмите «Проверить обновления», если ещё не смотрели).",
"restartChoiceLabel": "Завершить процесс после обновления (вернуть его сможет только внешний супервизор, например systemd или run.sh; если ничего не супервизит — запустите сами)",
"restartNotWired": "При этом запуске хук перезапуска не предоставлен: перезапустите службу сами после обновления.",
"confirmRestartYes": "Выбрано: процесс завершится после успешного обновления, и вернуть его сможет только внешний супервизор (systemd, run.sh, ...).",
"confirmRestartNo": "Не выбрано: процесс продолжит работать со старым бинарным файлом, пока вы сами его не перезапустите.",
"confirmApply": "Сейчас будет выполнено: fast-forward {{remote}}/{{branch}} до {{target}} ({{n}} коммитов, эта машина на {{commit}}) -> пересборка бинарного файла -> резервная копия и замена старого.",
"applyAccepted": "Обновление запущено (задача {{id}}); ход обновляется каждые 1,5 секунды.",
"localChangesTitle": "Локальные изменения",
"localChangesHint": "Файлы, помеченные как локальное содержимое, сохраняются как есть; файлы, помеченные как исходный код продукта, блокируют автоматическое обновление.",
"noLocalChanges": "Рабочее дерево чистое: ни один отслеживаемый файл не изменён и не удалён.",
"colPath": "Файл",
"colChangeStatus": "Состояние",
"colOwner": "Владелец",
"keptFile": "Локальное содержимое (сохраняется при обновлении)",
"productSource": "Исходный код продукта (блокирует обновление)",
"noJob": "Обновление ещё не запускалось",
"noSteps": "Задача ещё не выдала ни одной строки о ходе выполнения",
"pollingHint": "Ход читается каждые {{sec}} секунд",
"stateRunning": "Выполняется",
"stateSucceeded": "Успешно",
"stateFailed": "Не удалось",
"stateUnknown": "Неизвестное состояние",
"restartRequested": "Запрошено завершение после выполнения",
"jobPollFailed": "Не удалось прочитать ход выполнения: {{reason}}",
"jobDoneToast": "Обновление завершено",
"jobFailedToast": "Обновление не удалось",
"needsRestartToast": "Бинарный файл заменён; он вступит в силу после перезапуска",
"failureNoMessage": "Сервер не передал сообщение об ошибке",
"resultTitle": "Результат обновления",
"resultFrom": "От",
"resultTo": "До",
"resultCommits": "Принято коммитов",
"resultFiles": "Затронуто файлов",
"resultDuration": "Длительность",
"resultBackup": "Каталог резервных копий",
"keptTitle": "Сохранённые файлы локального содержимого",
"nothingKept": "Ни один файл локального содержимого не пришлось откладывать",
"binarySwapped": "Бинарный файл заменён",
"binaryNotSwapped": "Бинарный файл не изменён",
"restartNeeded": "Для запуска новой версии нужен перезапуск",
"restartNotNeeded": "Перезапуск не требуется",
"failureTitle": "Почему не удалось",
"noRollback": "Точки отката нет: она появляется только после выполненного обновления",
"rollbackTarget": "Цель отката: {{commit}} (где это дерево было до последнего обновления)",
"rollbackBtn": "Откатить обновление",
"confirmRollback": "Откатиться до {{commit}}? Исходники вернутся к состоянию до того обновления, а прежний бинарный файл будет восстановлен.",
"rollbackDone": "Откат выполнен до {{commit}}",
"rollbackItems": "Файлы, которые не были откачены"
},
"nav": {
"systemUpdate": "Обновление в один клик",
"security": "Безопасность",
"toolGuard": "Блокировка вызовов",
"dashboard": "Обзор",
+95
View File
@@ -79,7 +79,102 @@
"passwordPlaceholder": "输入登录密码",
"submit": "登录"
},
"update": {
"title": "一键更新",
"intro": "这一页改的就是当前正在应答请求的这套安装:拉取它自己跟踪的远端、重新编译、换掉二进制。你本机维护的 roles/skills/tools 与配置不会被覆盖。",
"loading": "正在读取本机安装状态",
"loadFailed": "读取本机安装状态失败:{{reason}}",
"installTitle": "本机安装",
"remoteTitle": "与远端比较",
"applyTitle": "一键更新",
"jobTitle": "更新进度",
"rollbackTitle": "回滚",
"root": "安装目录",
"branch": "分支",
"remote": "远端",
"commit": "当前提交",
"committedAt": "提交时间",
"subject": "提交说明",
"gitTree": "git 工作树",
"notGitTree": "不是 git 工作树",
"toolchainFound": "有 Go 工具链",
"toolchainMissing": "没有 Go 工具链",
"toolchainMissingHint": "未检测到 go 命令:更新只能挪源码,编译要另找机器",
"binaryPresent": "本机有二进制",
"binaryMissing": "本机没有二进制",
"rollbackPresent": "有可回滚点",
"rollbackMissing": "无可回滚点",
"notChecked": "还没查过远端。进入本页后会自动查一次(每个会话只查一次);也可以随时点「检查更新」。",
"checkBtn": "检查更新",
"checking": "正在检查远端…",
"checkFailed": "检查失败:{{reason}}",
"applyFailed": "启动更新失败:{{reason}}",
"rollbackFailed": "回滚失败:{{reason}}",
"behindCount": "落后 {{n}} 个提交",
"aheadCount": "本地领先 {{n}} 个提交",
"upToDate": "已是最新:本机 {{commit}} 已与远端一致",
"remoteHead": "远端最新提交",
"remoteSubject": "远端提交说明",
"incomingTitle": "待并入的提交",
"incomingTruncated": "共 {{total}} 个新提交,这里只列出前 {{shown}} 个",
"applyBtn": "一键更新",
"jobRunningHint": "已有一次更新在进行中,下面是它的进度。",
"noToolchainWarn": "本机没有 Go 工具链:源码会更新,二进制不会更换,要由外部构建产出后再重启。",
"blockedNotGitTree": "这个目录不是 git 工作树,无法自动更新。",
"blockedJobRunning": "已有更新在进行中,请等它结束。",
"blockedLocalEdits": "有 {{n}} 个产品源码文件被本地改过,自动更新不会覆盖它们:先提交或还原。",
"blockedDiverged": "本地领先 {{ahead}} 个提交、落后 {{behind}} 个提交:这是合并而不是下载,请先手工处理。",
"blockedNoUpdate": "远端没有比本机更新的提交(还没查过就先点「检查更新」)。",
"restartChoiceLabel": "更新完成后退出进程(需 systemd/run.sh 等外部守护才会自动拉起;无人守护请手动重启)",
"restartNotWired": "本次启动没有提供重启钩子:更新完成后请自己重启服务。",
"confirmRestartYes": "已勾选:更新成功后本进程会退出,只有外部守护(systemd/run.sh 等)才会把它再拉起来。",
"confirmRestartNo": "未勾选:进程不退出,运行的仍是旧二进制,需要你自行重启。",
"confirmApply": "将要执行:把 {{remote}}/{{branch}} 快进到 {{target}}({{n}} 个提交,本机当前 {{commit}})→ 重新编译二进制 → 备份并替换旧二进制。",
"applyAccepted": "更新已开始(任务 {{id}}),进度每 1.5 秒刷新一次。",
"localChangesTitle": "本机改动",
"localChangesHint": "标为「本机内容」的文件更新后原样保留;标为「产品源码」的文件会阻止自动更新。",
"noLocalChanges": "工作树干净:没有被修改或被删除的受跟踪文件。",
"colPath": "文件",
"colChangeStatus": "状态",
"colOwner": "归属",
"keptFile": "本机内容(更新保留)",
"productSource": "产品源码(会阻止更新)",
"noJob": "还没有更新记录",
"noSteps": "任务刚起来,还没有进度输出",
"pollingHint": "每 {{sec}} 秒读取一次进度",
"stateRunning": "进行中",
"stateSucceeded": "已完成",
"stateFailed": "失败",
"stateUnknown": "未知状态",
"restartRequested": "已要求在完成后退出",
"jobPollFailed": "进度读取失败:{{reason}}",
"jobDoneToast": "更新完成",
"jobFailedToast": "更新失败",
"needsRestartToast": "二进制已换新,重启后才会生效",
"failureNoMessage": "服务端没有给出失败说明",
"resultTitle": "更新结果",
"resultFrom": "从",
"resultTo": "到",
"resultCommits": "并入提交数",
"resultFiles": "改动文件数",
"resultDuration": "用时",
"resultBackup": "备份目录",
"keptTitle": "保留下来的本机内容文件",
"nothingKept": "没有本机内容文件被挪动过",
"binarySwapped": "二进制已换新",
"binaryNotSwapped": "二进制未更换",
"restartNeeded": "需要重启才会运行新版本",
"restartNotNeeded": "不需要重启",
"failureTitle": "失败原因",
"noRollback": "没有可回滚点:更新过一次之后才会有",
"rollbackTarget": "回滚目标:{{commit}}(上一次更新之前的提交)",
"rollbackBtn": "回滚到更新前",
"confirmRollback": "确定回滚到 {{commit}} 吗?源码回到那次更新之前,二进制用回备份的那一份。",
"rollbackDone": "已回滚到 {{commit}}",
"rollbackItems": "未能回滚的文件"
},
"nav": {
"systemUpdate": "一键更新",
"security": "安全防护",
"toolGuard": "调用拦截",
"dashboard": "仪表盘",
+10 -2
View File
@@ -443,6 +443,7 @@ const PAGE_PERMISSION_MAP = {
roles: 'roles:read',
'roles-management': 'roles:read',
'platform-rbac': 'rbac:read',
'system-update': 'update:read',
settings: 'config:read',
};
@@ -519,11 +520,18 @@ function applyPermissionElement(el) {
const allowed = permissionAllowedForElement(el);
el.hidden = !allowed;
el.classList.toggle('rbac-permission-denied', !allowed);
// Permission decides whether this user may ever use the control; it does not decide
// whether the control is ready. A page that disables one for a reason of its own marks
// it with data-state-disabled, because writing el.disabled = !allowed here otherwise
// re-enables it for exactly the users who are allowed - and a button whose preconditions
// are unmet is then pressed, and fails.
const blockedByState = el.dataset && el.dataset.stateDisabled === 'true';
const disabled = !allowed || blockedByState;
if ('disabled' in el) {
el.disabled = !allowed;
el.disabled = disabled;
}
el.setAttribute('aria-hidden', allowed ? 'false' : 'true');
el.setAttribute('aria-disabled', allowed ? 'false' : 'true');
el.setAttribute('aria-disabled', disabled ? 'true' : 'false');
}
let permissionClickGuardInstalled = false;
@@ -0,0 +1,133 @@
// The platform's permission gate must not re-enable a control a page disabled for a reason
// of its own.
//
// How the bug looked in a real browser: the 一键更新 page rendered an *enabled* apply button
// directly under the text "有 53 个产品源码文件被本地改过,自动更新不会覆盖它们". Nothing in
// the page's own tests could see it, because the page really does write `disabled` - the
// gate then runs over every [data-require-permission] element afterwards and writes
// `el.disabled = !allowed`, which for an administrator is `false`. Permission says whether a
// user may ever use a control; it does not say whether the control is ready.
const test = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const AUTH = fs.readFileSync(path.join(__dirname, 'auth.js'), 'utf8');
const UPDATE = fs.readFileSync(path.join(__dirname, 'update.js'), 'utf8');
function sourceOf(name, src) {
const at = src.indexOf('function ' + name + '(');
assert.ok(at >= 0, `${name} is not in auth.js any more - this test is where the gate lives`);
let depth = 0;
for (let i = src.indexOf('{', at); i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') {
depth--;
if (depth === 0) return src.slice(at, i + 1);
}
}
assert.fail(`${name}: unbalanced`);
}
function element(attrs = {}) {
const el = {
hidden: false,
disabled: false,
dataset: {},
_attrs: { ...attrs },
classList: { classes: new Set(), toggle(c, on) { on ? this.classes.add(c) : this.classes.delete(c); } },
getAttribute(k) { return this._attrs[k] ?? null; },
setAttribute(k, v) { this._attrs[k] = String(v); },
};
if (attrs['data-state-disabled']) el.dataset.stateDisabled = attrs['data-state-disabled'];
// Buttons in a DOM have a `disabled` property; a plain object only gets one if the page
// asked for it, and the gate's `if ('disabled' in el)` is what decides whether state
// survives - so it is the part worth keeping honest.
return el;
}
function makeGate(granted) {
const sandbox = {
hasPermission: (p) => granted.has(p),
hasAnyPermission: (list) => list.some((p) => granted.has(p)),
console,
};
vm.createContext(sandbox);
vm.runInContext(sourceOf('permissionAllowedForElement', AUTH) + '\n' + sourceOf('applyPermissionElement', AUTH), sandbox);
return sandbox.applyPermissionElement;
}
test('a state-disabled control stays disabled for a user who holds the permission', () => {
const gate = makeGate(new Set(['update:apply']));
const el = element({ 'data-require-permission': 'update:apply', 'data-state-disabled': 'true' });
el.disabled = true;
gate(el);
assert.strictEqual(el.disabled, true, 'permission granted must not re-enable a control the page blocked');
assert.strictEqual(el.getAttribute('aria-disabled'), 'true', 'the assistive state must agree with the real one');
assert.strictEqual(el.hidden, false);
});
test('a permitted and ready control is enabled, and an unpermitted one is not', () => {
const gate = makeGate(new Set(['update:apply']));
const ready = element({ 'data-require-permission': 'update:apply' });
gate(ready);
assert.strictEqual(ready.disabled, false);
assert.strictEqual(ready.getAttribute('aria-disabled'), 'false');
// applyPermissionElement returns nothing, so the assertions read the element itself.
const deniedEl = element({ 'data-require-permission': 'update:apply' });
makeGate(new Set())(deniedEl);
assert.strictEqual(deniedEl.hidden, true);
assert.strictEqual(deniedEl.disabled, true);
assert.strictEqual(deniedEl.classList.classes.has('rbac-permission-denied'), true);
});
test('every control the update page disables by state carries the marker the gate honors', () => {
// The other half of the pair: a page that writes `disabled` without the marker silently
// loses it the moment the gate runs. Asserted over the rendered strings rather than by
// reading the source, so a refactor that drops the attribute is what fails.
const sandbox = {
console,
escapeHtml: (v) => String(v),
updateT: (key) => key,
renderUpdateRestartChoice: () => '',
renderUpdateChangesTable: () => '<div class="update-changes"></div>',
isUpdateJobRunning: () => true,
updateApplyBlockers: (status) => (status.blockingChanges || []).length ? ['blocked'] : [],
updateChecking: true,
};
vm.createContext(sandbox);
for (const fn of ['renderUpdateApplyBody', 'renderUpdateRollbackBody']) {
vm.runInContext(sourceOf(fn, UPDATE), sandbox);
}
const withBlocker = sandbox.renderUpdateApplyBody(
{ installed: true, canBuild: true, blockingChanges: [{ path: 'a.go' }], updateAvailable: true, diverged: false }, null);
const noBlocker = sandbox.renderUpdateApplyBody(
{ installed: true, canBuild: true, blockingChanges: [], updateAvailable: true, diverged: false }, null);
const rollbackRunning = sandbox.renderUpdateRollbackBody({ hasRollback: true, rollbackTo: 'abc' });
for (const [label, html] of [['apply(blocked)', withBlocker], ['rollback(running)', rollbackRunning]]) {
const buttons = html.match(/<button[^>]*>/g) || [];
assert.ok(buttons.length > 0, `${label}: rendered no button`);
const disabledTags = buttons.filter((t) => / disabled/.test(t));
assert.ok(disabledTags.length > 0, `${label}: expected a disabled button`);
for (const tag of disabledTags) {
assert.match(tag, /data-state-disabled="true"/, `${label}: state-disabled must carry the marker the gate reads: ${tag}`);
assert.match(tag, /data-require-permission=/, `${label}: the marker only matters on a permission-gated control: ${tag}`);
}
}
// And the *apply* control of an unblocked install must not advertise itself as blocked,
// or the gate would keep it dead for someone who can legitimately press it. Other buttons
// in the same render may still be state-disabled (checking is one), which is why this
// asserts about the apply button by name.
const applyTag = (noBlocker.match(/<button[^>]*update-apply-btn[^>]*>/) || [])[0];
assert.ok(applyTag, 'no apply button rendered when nothing blocks it');
assert.ok(!/disabled/.test(applyTag), `a ready apply control must not be disabled: ${applyTag}`);
for (const tag of (noBlocker.match(/<button[^>]*>/g) || [])) {
if (/ disabled/.test(tag)) {
assert.match(tag, /data-state-disabled="true"/, `every state-disabled control needs the marker: ${tag}`);
}
}
});
+48
View File
@@ -0,0 +1,48 @@
// The console's own hash form is `#system-update`, not `#/system-update` - and before this,
// typing or sharing the slashed form silently did nothing: the id missed the whitelist and
// the router stayed on whatever page had loaded, with no message anywhere. Since every bit of
// documentation (and every human habit) writes the slashed form, the router now normalizes it
// instead of rejecting it.
const test = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const ROUTER = fs.readFileSync(path.join(__dirname, 'router.js'), 'utf8');
test('both hash entry points strip a leading slash before the whitelist', () => {
// Two parsers exist (initial load and hash change); fixing only one is how this class of
// bug survives a "works for me" check.
const sites = [...ROUTER.matchAll(/let pageId = hashParts\[0\]([^\n]*)/g)];
assert.ok(sites.length >= 2, `expected two hash parsers, found ${sites.length}`);
for (const s of sites) {
assert.match(s[1], /replace\(\/\^#\?\\\/\//, 'this parser does not normalize the slashed form: ' + s[0]);
}
});
test('the slashed form resolves to the same page id as the plain one', () => {
// Mirrors the router exactly: it takes location.hash.slice(1), so the '#' is already gone
// before the split. A helper here that expected the '#' would "pass" the wrong rule.
const normalize = (fullHash) => {
const raw = fullHash.startsWith('#') ? fullHash.slice(1) : fullHash;
return raw.split('?')[0].replace(/^#?\//, '');
};
for (const page of ['system-update', 'plugins-management', 'dashboard', 'chat']) {
assert.strictEqual(normalize('#' + page), page);
assert.strictEqual(normalize('#/' + page), page);
}
// Query-carrying hashes must keep working the same way they did.
assert.strictEqual(normalize('#chat?conversation=abc'), 'chat');
});
test('the docs use a form the router actually accepts', () => {
const docs = ['../../../docs/zh-CN/developer-guide.md', '../../../docs/en-US/developer-guide.md'];
for (const rel of docs) {
const p = path.join(__dirname, rel);
const text = fs.readFileSync(p, 'utf8');
// Not a style preference: `#/x` was a dead link when it was written, so a doc that
// tells an operator to open it is telling them to open nothing.
assert.ok(!/#\/system-update/.test(text), `${rel} still advertises the dead #/system-update form`);
assert.match(text, /#system-update/, `${rel} must name the form that works`);
}
});
+11 -4
View File
@@ -108,9 +108,9 @@ function initRouter() {
const hash = window.location.hash.slice(1);
if (hash) {
const hashParts = hash.split('?');
let pageId = hashParts[0];
let pageId = hashParts[0].replace(/^#?\//, '');
if (pageId === 'c2') pageId = 'c2-listeners';
if (pageId && ['dashboard', 'chat', 'hitl', 'tool-guard', 'asset-overview', 'asset-library', 'info-collect', 'projects', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'platform-rbac', 'workflows', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'tasks', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) {
if (pageId && ['dashboard', 'chat', 'hitl', 'tool-guard', 'asset-overview', 'asset-library', 'info-collect', 'projects', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'platform-rbac', 'workflows', 'skills-monitor', 'skills-management', 'agents-management', 'system-update', 'settings', 'tasks', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) {
switchPage(pageId);
if (pageId === 'chat') {
scheduleChatConversationFromHash(0);
@@ -524,6 +524,9 @@ async function initPage(pageId) {
initChatFilesPage();
}
break;
case 'system-update':
if (typeof loadUpdateConsole === 'function') loadUpdateConsole();
break;
case 'settings':
// 初始化设置页面(不需要加载工具列表)
if (typeof loadConfig === 'function') {
@@ -605,6 +608,10 @@ async function initPage(pageId) {
if (pageId !== 'tasks' && typeof cleanupTasksPage === 'function') {
cleanupTasksPage();
}
// 一键更新的进度轮询同理:离开那一页就没必要每 1.5 秒读一次任务了。
if (pageId !== 'system-update' && typeof stopUpdatePolling === 'function') {
stopUpdatePolling();
}
}
// 页面加载完成后初始化路由
@@ -618,10 +625,10 @@ document.addEventListener('DOMContentLoaded', function() {
const hash = window.location.hash.slice(1);
// 处理带参数的hash(如 chat?conversation=xxx)
const hashParts = hash.split('?');
let pageId = hashParts[0];
let pageId = hashParts[0].replace(/^#?\//, '');
if (pageId === 'c2') pageId = 'c2-listeners';
if (pageId && ['dashboard', 'chat', 'hitl', 'tool-guard', 'asset-overview', 'asset-library', 'info-collect', 'projects', 'tasks', 'workflows', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'platform-rbac', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) {
if (pageId && ['dashboard', 'chat', 'hitl', 'tool-guard', 'asset-overview', 'asset-library', 'info-collect', 'projects', 'tasks', 'workflows', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'platform-rbac', 'skills-monitor', 'skills-management', 'agents-management', 'system-update', 'settings', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) {
switchPage(pageId);
if (pageId === 'chat') {
scheduleChatConversationFromHash(0);
+984
View File
@@ -0,0 +1,984 @@
const fs = require('node:fs');
const vm = require('node:vm');
const test = require('node:test');
const assert = require('node:assert/strict');
const source = fs.readFileSync('web/static/js/update.js', 'utf8');
const template = fs.readFileSync('web/templates/index.html', 'utf8');
const router = fs.readFileSync('web/static/js/router.js', 'utf8');
const sheet = fs.readFileSync('web/static/css/style.css', 'utf8');
const zh = JSON.parse(fs.readFileSync('web/static/i18n/zh-CN.json', 'utf8'));
const en = JSON.parse(fs.readFileSync('web/static/i18n/en-US.json', 'utf8'));
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'];
function flatKeys(obj, prefix, out) {
Object.keys(obj).forEach(k => {
const v = obj[k];
const path = prefix ? prefix + '.' + k : k;
if (v && typeof v === 'object') flatKeys(v, path, out);
else out.add(path);
});
return out;
}
function resolve(dict, dotted) {
let node = dict;
for (const part of dotted.split('.')) {
if (!node || typeof node !== 'object') return undefined;
node = node[part];
}
return typeof node === 'string' ? node : undefined;
}
const baseStatus = {
root: '/srv/csai',
installed: true,
branch: 'main',
remote: 'mine',
commit: 'a1b2c3d',
subject: 'fix: 修好一个东西',
committedAt: '2026-09-30 18:04:11 +0800',
behind: 3,
ahead: 0,
diverged: false,
updateAvailable: true,
remoteCommit: 'f9e8d7c',
remoteSubject: 'feat: 新东西',
incoming: [{ commit: 'f9e8d7c', subject: 'feat: 新东西' }],
incomingTotal: 3,
localChanges: [],
blockingChanges: [],
goToolchain: 'go1.23.4',
canBuild: true,
hasBinary: true,
hasRollback: true,
rollbackTo: 'a1b2c3d',
checkError: '',
};
function statusOf(patch) {
return Object.assign({}, baseStatus, patch || {});
}
// The shape is the endpoint's own: {status, job, canRestart}. job is null until an update ran.
function envelope(status, job, canRestart) {
return { status: status || statusOf(), job: job === undefined ? null : job, canRestart: canRestart !== false };
}
const runningJob = {
id: 'upd-1',
state: 'running',
started: '2026-10-01T04:00:00+08:00',
steps: [{ phase: 'fetch', message: '开始拉取 mine/main', at: '2026-10-01T04:00:01+08:00' }],
restartRequested: false,
};
const succeededJob = {
id: 'upd-1',
state: 'succeeded',
started: '2026-10-01T04:00:00+08:00',
finished: '2026-10-01T04:03:12+08:00',
steps: [
{ phase: 'fetch', message: 'mine/main 有 3 个新提交:a1b2c3d → f9e8d7c', at: '04:00:01' },
{ phase: 'protect', message: '已暂存 2 个本地内容文件', at: '04:00:05' },
{ phase: 'build', message: '开始编译二进制', at: '04:00:09' },
{ phase: 'done', message: '已更新 3 个提交并换好二进制', at: '04:03:10' },
],
result: {
fromCommit: 'a1b2c3d',
toCommit: 'f9e8d7c',
commits: 3,
filesTouched: 27,
keptContent: ['roles/我的角色.md', 'tools/semgrep.yaml'],
binaryPath: '/srv/csai/cyberstrike-ai',
binaryBuilt: true,
prevBinary: '/srv/csai/cyberstrike-ai.prev',
backupDir: '/srv/csai/.update-backup/20261001_040005',
needsRestart: true,
duration: '3m12s',
},
restartRequested: true,
};
const failedJob = {
id: 'upd-2',
state: 'failed',
started: '2026-10-01T05:00:00+08:00',
finished: '2026-10-01T05:00:40+08:00',
steps: [{ phase: 'build', message: '开始编译二进制(首次会下载依赖,可能需要几分钟)', at: '05:00:20' }],
failure: {
reason: 'build_failed',
message: '编译失败,二进制保持原版本:\ninternal/foo.go:12: undefined: bar',
items: ['roles/我的角色.md', 'internal/foo.go'],
},
restartRequested: false,
};
// harness() loads the page for real (loadUpdateConsole) before handing it back, so every test
// starts from the markup that would be on screen and can execute the handlers that markup emits.
function harness(options) {
const opts = options || {};
const nodes = new Map();
const calls = [];
const toasts = [];
const confirms = [];
const timers = [];
let active = true;
let confirmAnswer = opts.confirm === false ? false : true;
let nextTimerId = 1;
function makeEl(id) {
const el = {
id,
innerHTML: '',
checked: false,
insertAdjacentHTML(_pos, html) { this.innerHTML = html + this.innerHTML; },
};
el.classList = {
_set: new Set(id === 'page-system-update' && active ? ['page', 'active'] : ['page']),
contains(c) { return this._set.has(c); },
add(c) { this._set.add(c); },
remove(c) { this._set.delete(c); },
};
return el;
}
const responses = Object.assign({
'GET /api/system/update': [{ status: 200, body: envelope(opts.status, opts.job, opts.canRestart) }],
'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 } }],
'POST /api/system/update/rollback': [{ status: 200, body: { result: { fromCommit: 'f9e8d7c', toCommit: 'a1b2c3d' } } }],
}, opts.responses || {});
const sandbox = {
document: {
getElementById(id) {
if (!nodes.has(id)) nodes.set(id, makeEl(id));
return nodes.get(id);
},
createElement() {
const el = { textContent: '', innerHTML: '' };
Object.defineProperty(el, 'textContent', {
set(v) {
el._t = String(v == null ? '' : v);
// The browser's textContent -> innerHTML leaves quotes alone; a harness that
// escaped them would make the attribute-injection assertions test nothing.
el.innerHTML = el._t.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
},
get() { return el._t; },
});
return el;
},
querySelectorAll() { return []; },
},
console,
Promise,
JSON,
Set,
Object,
String,
Date,
Math,
escapeHtml(text) {
const div = sandbox.document.createElement('div');
div.textContent = text;
return div.innerHTML;
},
escapeAttr(text) {
return sandbox.escapeHtml(text).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
},
showNotification(msg, type) { toasts.push({ msg, type }); },
setInterval(fn, ms) {
const t = { id: nextTimerId++, fn, ms, cleared: false };
timers.push(t);
return t.id;
},
clearInterval(id) {
const t = timers.find(x => x.id === id);
if (t) t.cleared = true;
},
// The page schedules one deferred remote check; a harness without setTimeout would make
// that code path untestable rather than absent, so the browser global is provided and the
// timer is recorded instead of fired.
setTimeout(fn, ms) {
const t = { id: nextTimerId++, fn, ms, cleared: false, deferred: true };
timers.push(t);
return t.id;
},
clearTimeout(id) {
const t = timers.find(x => x.id === id);
if (t) t.cleared = true;
},
window: {
// 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.
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));
},
showNotification(msg, type) { toasts.push({ msg, type }); },
confirm(message) { confirms.push(message); return confirmAnswer; },
applyRBACToUI() {},
},
apiFetch(url, reqOpts = {}) {
const method = reqOpts.method || 'GET';
calls.push({ url, method, body: reqOpts.body });
if ((opts.rejects || []).includes(method + ' ' + url)) {
// Not a status code: the request itself dies (401 handling, a dropped socket),
// which is the path that leaves a page stuck on a spinner if it is not unwound.
return Promise.reject(new Error('network unreachable'));
}
const queue = responses[method + ' ' + url];
const entry = queue && queue.length ? (queue.length > 1 ? queue.shift() : queue[0]) : null;
if (!entry) {
return Promise.resolve({
ok: false, status: 500,
json: () => Promise.resolve({ error: 'no fixture for ' + method + ' ' + url }),
});
}
const status = entry.status === undefined ? 200 : entry.status;
return Promise.resolve({
ok: status >= 200 && status < 300,
status,
json: () => Promise.resolve(entry.body),
});
},
};
sandbox.window.document = sandbox.document;
vm.createContext(sandbox);
vm.runInContext(source, sandbox);
vm.runInContext(`
this.api = {
loadUpdateConsole, checkForUpdates, startUpdateApply, rollbackUpdate,
updateRestartChoiceChanged, pollUpdateJob, renderUpdateConsole, stopUpdatePolling
};
this.vars = {
setBusy(v) { updateBusy = v; },
getRestartChoice() { return updateRestartChoice; },
getPollTimer() { return updatePollTimer; },
getCheckDone() { return updateCheck.done; },
autoCheckDone() { return updateAutoCheckDone; }
};
`, sandbox);
const flush = async () => { for (let i = 0; i < 14; i++) await new Promise(r => setImmediate(r)); };
// Running the attribute text inside the page's own context is the point of this harness: a
// handler wired to the wrong function, or reading the wrong element, still looks perfectly
// fine when the attribute is only ever inspected as a string.
function runHandler(text, thisArg) {
const fn = vm.runInContext('(function(){' + text + '})', sandbox);
return fn.call(thisArg || {});
}
function extractHandlers(html) {
const found = { onclick: [], onchange: [] };
const re = /on(click|change)="([^"]*)"/g;
let m;
while ((m = re.exec(html)) !== null) found['on' + m[1]].push(m[2]);
return found;
}
const h = {
sandbox, nodes, calls, toasts, confirms, timers, flush, runHandler, extractHandlers, responses,
api: sandbox.api,
vars: sandbox.vars,
// 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); },
fireDeferred() { timers.filter(t => t.deferred && !t.cleared).forEach(t => { t.cleared = true; t.fn(); }); },
html() { return nodes.get('update-console').innerHTML; },
fire(name, thisArg) {
const all = extractHandlers(h.html());
const text = all.onclick.concat(all.onchange).find(v => v.startsWith(name + '('));
if (!text) throw new Error('handler ' + name + ' is not present in the rendered markup');
return runHandler(text, thisArg);
},
// Flipping the router's page-active class is all that leaving a page means for this console.
setActive(v) {
active = v;
const node = nodes.get('page-system-update');
if (v) node.classList.add('active'); else node.classList.remove('active');
},
setConfirm(v) { confirmAnswer = v; },
liveTimers() { return timers.filter(t => !t.cleared && !t.deferred); },
tick() {
const live = h.liveTimers();
if (!live.length) throw new Error('no poll timer is armed');
return live[live.length - 1].fn();
},
};
return Promise.resolve(sandbox.api.loadUpdateConsole()).then(flush).then(() => h);
}
test('the page is wired in everywhere a page has to be registered', () => {
assert.match(template, /<div class="nav-item" data-page="system-update">/);
assert.match(template, /<div id="page-system-update" class="page">/);
assert.match(template, /id="update-console"/);
assert.match(template, /<script src="\/static\/js\/update\.js\?v=\{\{\.Version\}\}"><\/script>/);
const group = template.indexOf('<span data-i18n="navGroups.administration">平台管理</span>');
const rbac = template.indexOf('<div class="nav-item" data-page="platform-rbac">');
const update = template.indexOf('<div class="nav-item" data-page="system-update">');
const settings = template.indexOf('<div class="nav-item" data-page="settings">');
assert.ok(group > -1 && rbac > group && update > rbac && settings > update,
'the nav item must sit in 平台管理, after 平台权限 and before 系统设置');
assert.match(template, /data-page="system-update"[\s\S]{0,400}?onclick="switchPage\('system-update'\)"/);
assert.match(template, /<div class="nav-item-content" data-title="一键更新" onclick="switchPage\('system-update'\)" data-i18n="nav\.systemUpdate" data-i18n-attr="data-title"/);
assert.match(template, /<div class="page-header">\s*\n\s*<h2 data-i18n="update\.title">/);
// Registered in the router twice on purpose: the hash whitelist on boot, and the one used when
// a page builds its own hash. A page missing from either is silently unreachable.
const lists = router.split('\n').filter(l => l.includes("['dashboard'") && l.includes("'system-update'"));
assert.equal(lists.length, 2, 'system-update must appear in both router page lists');
assert.match(router, /case 'system-update':\s*\n\s*if \(typeof loadUpdateConsole === 'function'\) loadUpdateConsole\(\);/);
// And the leave-page cleanup: the router only calls initPage on entry, so the progress poller
// has to be stopped from the same tail that already does this for the tasks page.
assert.match(router, /if \(pageId !== 'system-update' && typeof stopUpdatePolling === 'function'\)/);
});
test('the update console has its own stylesheet section', () => {
const header = sheet.indexOf('一键更新控制台(system-update)');
assert.ok(header > -1, 'style.css must carry the update console section header');
const block = sheet.slice(header);
assert.ok(block.includes('.update-console'), 'the .update-* section must follow its own section header');
['.update-console', '.update-section-title', '.update-facts', '.update-chip-ok', '.update-chip-warn',
'.update-chip-danger', '.update-error', '.update-check-failed', '.update-step-list', '.update-table',
'.update-restart-choice', '.update-blocker-list', '.update-apply-btn:disabled'].forEach(sel => {
assert.ok(sheet.includes(sel), sel + ' must be defined');
});
// Every class the console emits has to be styled or be an existing shared one: a hook name
// that appears only in JS is a button nobody can see the state of.
const emitted = [...new Set(source.match(/update-[a-z][a-z-]*/g) || [])]
.filter(t => /^update-[a-z-]+$/.test(t));
emitted.forEach(cls => {
assert.ok(sheet.includes('.' + cls), cls + ' is emitted by the console but style.css never mentions it');
});
assert.ok(emitted.length >= 25, 'the class scan found too little to be a gate: ' + emitted.length);
});
test('all locales carry the same update keys, and everything the page asks for exists', () => {
const zhKeys = flatKeys(zh.update, '', new Set());
const enKeys = flatKeys(en.update, '', new Set());
const ruKeys = flatKeys(ru.update, '', new Set());
assert.deepEqual([...zhKeys].filter(k => !enKeys.has(k)), [], 'keys missing from en-US');
assert.deepEqual([...enKeys].filter(k => !zhKeys.has(k)), [], 'keys missing from zh-CN');
assert.deepEqual([...enKeys].filter(k => !ruKeys.has(k)), [], 'keys missing from ru-RU');
assert.deepEqual([...ruKeys].filter(k => !enKeys.has(k)), [], 'ru-RU has keys other locales do not');
assert.ok(zhKeys.size >= 60, 'the update namespace has gone thin: ' + zhKeys.size);
assert.ok(zh.nav.systemUpdate && en.nav.systemUpdate && ru.nav.systemUpdate, 'nav.systemUpdate must exist in every locale');
assert.equal(zh.nav.systemUpdate, '一键更新');
assert.equal(en.nav.systemUpdate, 'One-Click Update');
assert.equal(ru.nav.systemUpdate, 'Обновление в один клик');
// The non-Chinese dictionaries must not be holding Chinese text.
[...enKeys].forEach(k => {
const value = resolve(en.update, k) || '';
assert.ok(!/[一-龥]/.test(value), 'en-US still has Chinese in update.' + k + ': ' + value);
assert.ok(value !== zh.update[k], 'en-US still has the Chinese string for update.' + k);
});
[...ruKeys].forEach(k => {
const value = resolve(ru.update, k) || '';
assert.ok(!/[一-龥]/.test(value), 'ru-RU still has Chinese in update.' + k + ': ' + value);
assert.ok(value !== zh.update[k], 'ru-RU still has the Chinese string for update.' + k);
});
const htmlKeys = [...template.matchAll(/data-i18n="([^"]+)"/g)].map(m => m[1])
.filter(k => k.startsWith('update.') || k === 'nav.systemUpdate');
assert.ok(htmlKeys.length >= 3, 'the page must carry its own translated titles: ' + htmlKeys);
htmlKeys.forEach(k => {
assert.ok(resolve(zh, k), 'zh-CN missing ' + k);
assert.ok(resolve(en, k), 'en-US missing ' + k);
assert.ok(resolve(ru, k), 'ru-RU missing ' + k);
});
const jsKeys = [...source.matchAll(/updateT\('([^']+)'/g)].map(m => m[1]);
assert.ok(jsKeys.length >= 40, 'the console should be fully translated, got ' + jsKeys.length);
jsKeys.forEach(k => {
assert.ok(resolve(zh, 'update.' + k), 'zh-CN missing update.' + k);
assert.ok(resolve(en, 'update.' + k), 'en-US missing update.' + k);
assert.ok(resolve(ru, 'update.' + k), 'ru-RU missing update.' + k);
});
// withUpdateBusy picks its error prefix out of a map, so those keys are referenced dynamically
// and a typo would survive as a painted key name instead of failing here.
const mapBody = (source.match(/const UPDATE_FAILURE_KEYS = \{([\s\S]*?)\n\};/) || [])[1] || '';
const mapped = [...mapBody.matchAll(/'([a-zA-Z]+)'/g)].map(m => m[1]);
assert.ok(mapped.length >= 4, 'the failure-key map was not found in update.js');
mapped.forEach(k => {
assert.ok(resolve(zh, 'update.' + k), 'zh-CN missing update.' + k);
assert.ok(resolve(en, 'update.' + k), 'en-US missing update.' + k);
assert.ok(resolve(ru, 'update.' + k), 'ru-RU missing update.' + k);
});
// No key may be dead weight: every entry has to be reachable from the page or the console.
[...zhKeys].forEach(k => {
const used = source.includes("'" + k.split('.').pop() + "'") || template.includes('update.' + k);
assert.ok(used, 'update.' + k + ' is defined but nothing asks for it');
});
});
test('opening the page reads the install tree and claims nothing about the remote', async () => {
const h = await harness({ status: statusOf({ updateAvailable: false, behind: 0, incoming: [], incomingTotal: 0 }) });
assert.deepEqual(h.calls.map(c => c.method + ' ' + c.url), ['GET /api/system/update'],
'opening the page must not fetch the remote and must not start polling');
const html = h.html();
assert.match(html, /\/srv\/csai/);
assert.match(html, /main/);
assert.match(html, /a1b2c3d/);
assert.match(html, /2026-09-30 18:04:11/);
assert.match(html, /git 工作树/);
assert.match(html, /go1\.23\.4/);
assert.match(html, /有可回滚点/);
assert.match(html, /待并入|本机安装/, 'the console must be laid out in labelled sections');
// behind is 0 only because nobody looked yet: that is "not checked", never "up to date".
assert.match(html, /还没查过远端/);
assert.doesNotMatch(html, /已是最新/, 'a page that did not check must not say 已是最新');
assert.doesNotMatch(html, /update\.[a-zA-Z]/, 'a raw i18n key leaked into the markup');
assert.doesNotMatch(html, /\{\{/, 'an un-interpolated placeholder leaked into the markup');
});
test('a check that could not run is reported as a failure, never as "already up to date"', async () => {
const h = await harness({
status: statusOf({ updateAvailable: false, behind: 0, incoming: [], incomingTotal: 0 }),
responses: {
'POST /api/system/update/check': [{
status: 200,
body: {
status: statusOf({
checkError: 'fatal: unable to access github: Could not resolve host',
updateAvailable: false, behind: 0, incoming: [], incomingTotal: 0,
}),
},
}],
},
});
h.calls.length = 0;
h.toasts.length = 0;
await h.api.checkForUpdates();
await h.flush();
assert.deepEqual(h.calls.map(c => c.method + ' ' + c.url), ['POST /api/system/update/check'],
'a check must not re-read the tree and must not start a poller');
const html = h.html();
assert.match(html, /检查失败:/, 'the reason must be stated as a check failure');
assert.match(html, /Could not resolve host/);
assert.doesNotMatch(html, /已是最新/, 'a failed check must never be painted as up to date');
assert.equal(h.toasts.length, 1);
assert.match(h.toasts[0].msg, /检查失败:/);
assert.equal(h.toasts[0].type, 'error', 'a check that failed must not toast success');
assert.equal(h.liveTimers().length, 0, 'a check is not a job: nothing may be polling');
});
test('a transport failure of the check endpoint is still "查不了"', async () => {
const h = await harness({
responses: { 'POST /api/system/update/check': [{ status: 500, body: { error: 'git exited 128' } }] },
});
h.toasts.length = 0;
await h.api.checkForUpdates();
await h.flush();
const html = h.html();
assert.match(html, /检查失败:/);
assert.match(html, /git exited 128/);
assert.doesNotMatch(html, /已是最新/);
assert.match(h.toasts[h.toasts.length - 1].msg, /检查失败:git exited 128/);
assert.equal(h.toasts[h.toasts.length - 1].type, 'error');
});
test('a check whose request dies mid-flight releases the button and says so', async () => {
const h = await harness({ rejects: ['POST /api/system/update/check'] });
h.toasts.length = 0;
await h.api.checkForUpdates();
await h.flush();
const html = h.html();
assert.match(html, /检查失败:/, 'a dead request is a failure, and it has to be called one');
assert.match(html, /network unreachable/);
assert.doesNotMatch(html, /正在检查远端/, 'the button must not stay frozen on "checking"');
assert.doesNotMatch(html, /update-check-btn" disabled/, 'and it must be clickable again');
assert.doesNotMatch(html, /已是最新/, 'a request that went nowhere is not an answer about the remote');
assert.match(html, /还没查过远端/, 'the page falls back to the honest "nobody looked yet"');
assert.equal(h.toasts.length, 1);
assert.equal(h.toasts[0].type, 'error');
assert.equal(h.liveTimers().length, 0);
});
test('behind is counted from incomingTotal and a truncated list says so', async () => {
const many = [];
for (let i = 0; i < 20; i++) many.push({ commit: 'c' + i, subject: '提交 ' + i });
const h = await harness({
status: statusOf({ updateAvailable: false, behind: 0, incoming: [], incomingTotal: 0 }),
responses: {
'POST /api/system/update/check': [{
status: 200,
body: { status: statusOf({ updateAvailable: true, behind: 42, incomingTotal: 42, incoming: many }) },
}],
},
});
await h.api.checkForUpdates();
await h.flush();
const html = h.html();
assert.match(html, /落后 42 个提交/, 'the headline number is the total, not the length of the page');
assert.match(html, /共 42 个新提交/);
assert.match(html, /只列出前 20 个/);
assert.match(html, /提交 19/);
});
test('local edits, a diverged tree and a clean-but-unchecked tree each block the button', async () => {
const cases = [
{
patch: { blockingChanges: [{ path: 'internal/foo.go', status: 'modified', protected: false }] },
copy: /有 1 个产品源码文件被本地改过/,
path: /internal\/foo\.go/,
},
{
patch: { diverged: true, ahead: 4, behind: 2, blockingChanges: [] },
copy: /本地领先 4 个提交/,
path: null,
},
{
patch: { updateAvailable: false, behind: 0, blockingChanges: [] },
copy: /远端没有比本机更新的提交/,
path: null,
},
];
for (const c of cases) {
const h = await harness({ status: statusOf(c.patch) });
const html = h.html();
assert.match(html, /<button class="btn-primary update-apply-btn" disabled[^>]*onclick="startUpdateApply\(\)">/,
'the apply button must be disabled for: ' + c.copy);
assert.match(html, c.copy, 'a disabled button must say why');
assert.match(html, /data-require-permission="update:apply"/);
if (c.path) assert.match(html, c.path, 'the blocking file names must be listed, not just counted');
// Disabled in the markup and refused in code: the two must agree.
h.calls.length = 0;
h.toasts.length = 0;
h.fire('startUpdateApply');
await h.flush();
assert.deepEqual(h.calls, [], 'a blocked apply must never reach the server: ' + c.copy);
assert.equal(h.toasts.length, 1, 'the refusal must be said out loud instead of failing silent');
assert.equal(h.toasts[0].type, 'error');
}
const busy = await harness({ status: statusOf(), job: runningJob });
assert.match(busy.html(), /<button class="btn-primary update-apply-btn" disabled/);
assert.match(busy.html(), /已有更新在进行中/);
assert.match(busy.html(), /<button class="btn-secondary update-rollback-btn" disabled/);
assert.deepEqual(busy.calls.map(c => c.method + ' ' + c.url), ['GET /api/system/update']);
assert.equal(busy.liveTimers().length, 1, 'opening onto a running job resumes the progress read');
});
test('applying posts {"restart":false} and then reads the job every 1.5 seconds', async () => {
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 } },
],
'GET /api/system/update': [
{ status: 200, body: envelope(statusOf(), null, true) },
{
status: 200,
body: envelope(statusOf({
updateAvailable: false, behind: 0, incoming: [], incomingTotal: 0,
commit: 'f9e8d7c',
}), succeededJob, true),
},
],
},
});
assert.match(h.html(), /<button class="btn-primary update-apply-btn" data-require-permission="update:apply" onclick="startUpdateApply\(\)">/,
'a clean tree that is behind must offer the button, undimmed');
h.calls.length = 0;
h.fire('startUpdateApply');
await h.flush();
const apply = h.calls.find(c => c.method === 'POST' && c.url === '/api/system/update/apply');
assert.ok(apply, 'no apply was posted: ' + h.calls.map(c => c.method + ' ' + c.url));
assert.deepEqual(JSON.parse(apply.body), { restart: false },
'the body is one object carrying a restart boolean, not a positional bag');
assert.equal(h.liveTimers().length, 1, 'exactly one poller: ' + JSON.stringify(h.timers.map(t => t.ms)));
assert.equal(h.liveTimers()[0].ms, 1500, 'progress must be read every 1.5 seconds');
// In the browser the poller runs off the timer; here the test drives the armed callback so the
// whole progress path is exercised without waiting on wall-clock time.
h.calls.length = 0;
await h.tick();
await h.flush();
assert.deepEqual(h.calls.map(c => c.method + ' ' + c.url), ['GET /api/system/update/job']);
assert.match(h.html(), /开始拉取 mine\/main/, 'each step line must be rendered as it arrives');
assert.match(h.html(), /进行中/);
assert.equal(h.liveTimers().length, 1);
h.calls.length = 0;
await h.tick();
await h.flush();
assert.equal(h.liveTimers().length, 0, 'the timer must be cleared once the job reaches a final state');
assert.equal(h.vars.getPollTimer(), null);
assert.deepEqual(h.calls.map(c => c.method + ' ' + c.url),
['GET /api/system/update/job', 'GET /api/system/update'],
'once the job ends the install tree is re-read: the binary on disk has changed');
const html = h.html();
assert.match(html, /a1b2c3d/);
assert.match(html, /f9e8d7c/);
assert.match(html, /并入提交数[\s\S]{0,160}?>3</);
assert.match(html, /改动文件数[\s\S]{0,160}?>27</);
assert.match(html, /roles\/我的角色\.md/, 'keptContent must be listed by name');
assert.match(html, /tools\/semgrep\.yaml/);
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');
});
test('a failing job shows the server message with its item list, and never claims success', async () => {
const h = await harness({
responses: {
'GET /api/system/update/job': [{ status: 200, body: { job: failedJob } }],
// GET /api/system/update also carries h.latest(), so the re-read after the job ends
// still has the finished job in it: the console must not lose the failure.
'GET /api/system/update': [
{ status: 200, body: envelope(statusOf(), null, true) },
{ status: 200, body: envelope(statusOf({ updateAvailable: false, behind: 0, incoming: [], incomingTotal: 0 }), failedJob, true) },
],
},
});
await h.api.startUpdateApply();
await h.flush();
const html = h.html();
assert.match(html, /编译失败,二进制保持原版本/);
assert.match(html, /build_failed/);
assert.match(html, /internal\/foo\.go/, 'the failure item list must be shown');
assert.equal(h.liveTimers().length, 0, 'a finished job must not keep polling');
assert.deepEqual(h.toasts.filter(t => t.type === 'success' && /更新完成/.test(t.msg)), [],
'a failed update must not be announced as finished: ' + JSON.stringify(h.toasts));
assert.match(h.toasts[h.toasts.length - 1].msg, /编译失败/);
assert.equal(h.toasts[h.toasts.length - 1].type, 'error');
});
test('the restart tick only appears when the process can actually be restarted', async () => {
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(), /本次启动没有提供重启钩子/);
const on = await harness({
canRestart: true,
// No job to adopt: a running job would (correctly) block the second click, and this test
// is about what the checkbox does to the body, not about the block.
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');
on.calls.length = 0;
on.fire('startUpdateApply');
await on.flush();
assert.deepEqual(JSON.parse(on.calls.find(c => c.url === '/api/system/update/apply').body), { restart: false });
// Tick it through the rendered onchange, exactly as a browser does (the element is already
// checked when change fires), then apply again.
on.vars.setBusy(false);
on.nodes.get('update-restart-choice').checked = true;
on.fire('updateRestartChoiceChanged', { checked: true });
assert.equal(on.vars.getRestartChoice(), true);
assert.match(on.html(), /id="update-restart-choice" checked/, 'the tick must survive the repaint');
on.calls.length = 0;
on.vars.setBusy(false);
on.fire('startUpdateApply');
await on.flush();
assert.deepEqual(JSON.parse(on.calls.find(c => c.url === '/api/system/update/apply').body), { restart: true },
'the restart flag that goes out is the checkbox, not a guess');
});
test('the confirm dialog says what the update will do, and cancelling posts nothing', async () => {
const h = await harness();
h.setConfirm(false);
h.calls.length = 0;
h.toasts.length = 0;
h.fire('startUpdateApply');
await h.flush();
assert.equal(h.confirms.length, 1, 'the operator must be asked before the tree moves');
assert.match(h.confirms[0], /重新编译/, 'the confirm must say the binary gets rebuilt: ' + h.confirms[0]);
assert.match(h.confirms[0], /二进制/);
assert.match(h.confirms[0], /mine\/main/, 'the confirm must name the remote and branch being merged');
assert.match(h.confirms[0], /a1b2c3d/, 'the confirm must name where this machine is now');
assert.doesNotMatch(h.confirms[0], /\{\{/, 'the confirm went out un-interpolated: ' + h.confirms[0]);
assert.deepEqual(h.calls.map(c => c.method + ' ' + c.url), [], 'a cancelled update must not POST');
assert.deepEqual(h.toasts, [], 'a cancelled update must not be reported as anything');
});
test('a second apply is refused with 409 and the running job is adopted', async () => {
const h = await harness({
responses: {
'POST /api/system/update/apply': [{
status: 409,
body: {
error: '已有一次更新在进行中',
job: { id: 'upd-7', state: 'running', started: 'now', steps: [{ phase: 'build', message: '正在编译二进制', at: 'now' }] },
},
}],
},
});
await h.api.startUpdateApply();
await h.flush();
const apply = h.calls.find(c => c.url === '/api/system/update/apply');
assert.equal(apply.method, 'POST');
assert.deepEqual(JSON.parse(apply.body), { restart: false });
assert.match(h.html(), /正在编译二进制/, 'the job the server named must be shown, not swallowed');
assert.match(h.html(), /upd-7/);
assert.match(h.toasts[h.toasts.length - 1].msg, /已有一次更新在进行中/);
assert.equal(h.toasts[h.toasts.length - 1].type, 'error');
assert.deepEqual(h.toasts.filter(t => t.type === 'success'), [], 'a refusal must not toast success');
assert.equal(h.liveTimers().length, 1, 'the adopted job must be watched');
});
test('rollback only shows with a rollback point, asks first, and re-reads the tree', async () => {
const none = await harness({ status: statusOf({ hasRollback: false, rollbackTo: '' }) });
assert.doesNotMatch(none.html(), /update-rollback-btn/);
assert.match(none.html(), /没有可回滚点/);
const h = await harness({ status: statusOf() });
assert.match(h.html(), /<button class="btn-secondary update-rollback-btn" data-require-permission="update:apply" onclick="rollbackUpdate\(\)">/);
assert.match(h.html(), /回滚目标:a1b2c3d/);
h.calls.length = 0;
h.toasts.length = 0;
h.setConfirm(false);
h.fire('rollbackUpdate');
await h.flush();
assert.deepEqual(h.calls, [], 'a cancelled rollback must not POST');
assert.equal(h.confirms.length, 1);
assert.match(h.confirms[0], /a1b2c3d/);
h.setConfirm(true);
h.calls.length = 0;
h.vars.setBusy(false);
h.fire('rollbackUpdate');
await h.flush();
assert.deepEqual(h.calls.map(c => c.method + ' ' + c.url),
['POST /api/system/update/rollback', 'GET /api/system/update'],
'a rollback must be followed by a fresh read of the install tree');
assert.equal(h.calls[0].body, undefined, 'rollback takes no body');
assert.match(h.toasts[h.toasts.length - 1].msg, /已回滚到 a1b2c3d/);
assert.equal(h.toasts[h.toasts.length - 1].type, 'success');
assert.doesNotMatch(h.html(), /已是最新/, 'after a rollback the page has not looked at the remote');
});
test('a refused rollback repeats the server reason and its file list', async () => {
const h = await harness({
responses: {
'POST /api/system/update/rollback': [{
status: 409,
body: { error: 'HEAD 已经移动,不是那次更新留下的位置', reason: 'moved_since_update', items: ['roles/我的角色.md'] },
}],
},
});
await h.api.rollbackUpdate();
await h.flush();
assert.match(h.html(), /回滚失败/);
assert.match(h.html(), /HEAD 已经移动/);
assert.match(h.html(), /roles\/我的角色\.md/);
assert.equal(h.toasts[h.toasts.length - 1].type, 'error');
assert.match(h.toasts[h.toasts.length - 1].msg, /HEAD 已经移动/);
assert.deepEqual(h.toasts.filter(t => t.type === 'success'), []);
});
test('a 202 that carries an error is not mistaken for success', async () => {
// The handler answers "the source went back but the binary could not be rebuilt" with HTTP
// 202 plus an error body (statusForError maps no_toolchain onto 202). Trusting resp.ok alone
// toasts 已回滚 over a rollback that only half happened.
const h = await harness({
responses: {
'POST /api/system/update/rollback': [{
status: 202,
body: { error: '源码已回到更新前,但本机没有 Go 工具链,二进制没换', reason: 'no_toolchain' },
}],
},
});
h.toasts.length = 0;
h.calls.length = 0;
await h.api.rollbackUpdate();
await h.flush();
assert.match(h.html(), /回滚失败/);
assert.match(h.html(), /二进制没换/);
assert.deepEqual(h.toasts.filter(t => t.type === 'success'), [],
'a 2xx carrying an error must never be toasted as success: ' + JSON.stringify(h.toasts));
assert.equal(h.toasts[h.toasts.length - 1].type, 'error');
assert.match(h.toasts[h.toasts.length - 1].msg, /二进制没换/);
assert.deepEqual(h.calls.map(c => c.method + ' ' + c.url), ['POST /api/system/update/rollback'],
'a half-finished rollback must not be followed by a victory lap through the status endpoint');
});
test('leaving the page kills the poller, and reopening arms exactly one', async () => {
const h = await harness();
h.fire('startUpdateApply');
await h.flush();
assert.equal(h.liveTimers().length, 1);
h.calls.length = 0;
h.setActive(false);
await h.tick();
await h.flush();
assert.equal(h.liveTimers().length, 0, 'switching pages must clear the interval');
assert.deepEqual(h.calls, [], 'a page that is not on screen must not keep asking');
assert.equal(h.vars.getPollTimer(), null);
h.setActive(true);
await h.api.loadUpdateConsole();
assert.equal(h.liveTimers().length, 0, 'a clean open arms nothing');
const busy = await harness({ job: { id: 'upd-3', state: 'running', started: 'x', steps: [] } });
assert.equal(busy.liveTimers().length, 1, 'opening onto a running job arms the poller');
await busy.api.loadUpdateConsole();
assert.equal(busy.liveTimers().length, 1, 'reopening must not stack a second poller');
assert.equal(busy.timers.length, 2, 'the old timer was cleared rather than leaked: ' + JSON.stringify(busy.timers.map(t => t.cleared)));
});
test('nothing invented: the only toast channel this page may use is showNotification', () => {
assert.doesNotMatch(source, /showToast/, 'window.showToast does not exist on this page');
assert.match(source, /typeof showNotification === 'function'/);
assert.match(source, /window\.applyRBACToUI === 'function'/,
'optional globals must be probed before they are called');
});
test('server strings cannot become markup, in text or in an attribute', async () => {
const hostile = '危险 <script>alert(1)</script> "quoted" &more';
const h = await harness({
status: statusOf({
root: '/srv/"><img src=x onerror=alert(1)>/x',
subject: hostile,
remoteSubject: hostile,
blockingChanges: [{ path: 'a"><script>steal()</script>.go', status: 'modified "m"', protected: false }],
localChanges: [{ path: 'roles/"><svg onload=alert(1)>.md', status: 'modified', protected: true }],
incoming: [{ commit: 'c"><i>', subject: hostile }],
incomingTotal: 1,
checkError: 'fetch failed on <b>remote</b> "origin"',
}),
});
const html = h.html();
assert.doesNotMatch(html, /<script/, 'a commit subject became a script tag');
assert.doesNotMatch(html, /<img\b|<svg\b|<b>/, 'markup from server data reached the DOM');
assert.match(html, /危险 &lt;script&gt;alert\(1\)&lt;\/script&gt; "quoted"/,
'escapeHtml leaves quotes alone inside text, which is correct and must not be double escaped');
assert.match(html, /&amp;more/, 'the ampersand must be escaped exactly once');
// Attribute integrity: one raw double quote inside a value would close it early and let the
// rest of the string become attributes of the element.
['title', 'class', 'onclick', 'onchange', 'data-require-permission'].forEach(attr => {
const opened = (html.match(new RegExp(attr + '="', 'g')) || []).length;
const closed = (html.match(new RegExp(attr + '="[^"]*"', 'g')) || []).length;
assert.equal(opened, closed, 'a ' + attr + ' attribute was closed by an unescaped quote');
});
// Counting matched attribute pairs is not enough on its own: a value that ends early still
// leaves the open and close counts equal. Two hostile strings have to appear fully escaped
// inside their title attribute - that is the proof escapeAttr, not just escapeHtml, guarded it.
assert.match(html, /title="a&quot;&gt;&lt;script&gt;steal\(\)&lt;\/script&gt;\.go"/,
'the blocking file path went into an attribute without quote escaping');
assert.match(html, /title="\/srv\/&quot;&gt;&lt;img src=x onerror=alert\(1\)&gt;\/x"/,
'the install root went into an attribute without quote escaping');
assert.doesNotMatch(html, /title="[^"]*"[^ >]/, 'a title attribute was closed early by server data');
const titles = html.match(/title="[^"]*"/g) || [];
assert.ok(titles.length >= 8, 'these assertions rely on title attributes being emitted: ' + titles.length);
titles.forEach(t => assert.ok(!/<|>/.test(t), 'a title value still contains markup: ' + t));
// Every emitted handler must still be executable as written: an injection into an attribute
// would break the statement instead.
const found = h.extractHandlers(html);
assert.ok(found.onclick.length && found.onchange.length, 'handlers must still render under hostile data');
found.onclick.concat(found.onchange).forEach(text => {
assert.doesNotMatch(text, /"/, 'handler text carries a raw quote: ' + text);
assert.equal(typeof vm.runInContext('(function(){' + text + '})', h.sandbox), 'function',
'the emitted handler is not valid JS: ' + text);
});
['startUpdateApply', 'checkForUpdates', 'rollbackUpdate', 'updateRestartChoiceChanged']
.forEach(name => assert.ok(found.onclick.concat(found.onchange).some(t => t.startsWith(name + '(')),
name + ' must stay wired'));
});
test('the rendered handlers are executed, and they only hit the documented endpoints', async () => {
const h = await harness();
const found = h.extractHandlers(h.html());
const texts = found.onchange.concat(found.onclick);
assert.ok(texts.length >= 4, 'expected the console to emit handlers, got ' + texts.length);
texts.forEach(t => assert.ok(HANDLES.includes(t.split('(')[0]), 'unknown handler wired into the page: ' + t));
h.calls.length = 0;
h.fire('checkForUpdates');
await h.flush();
assert.equal(h.calls[0].method, 'POST');
assert.equal(h.calls[0].url, '/api/system/update/check');
assert.equal(h.calls[0].body, undefined, 'the check endpoint takes no body');
h.vars.setBusy(false);
h.calls.length = 0;
h.fire('startUpdateApply');
await h.flush();
assert.deepEqual(h.calls.map(c => c.url),
['/api/system/update/apply', '/api/system/update/job'],
'apply posts once, then the progress read goes to the job endpoint');
assert.equal(h.calls[0].method, 'POST');
assert.equal(h.calls[1].method, 'GET');
});
// The page now asks the remote by itself, once, so the operator arrives at an answer rather
// than at a button. These pin the politeness of that: one check per session, only for a tree
// that can be updated, never on top of a running job - and the copy must describe what the
// code actually does.
test('opening the page schedules exactly one deferred remote check', async () => {
const h = await harness({
status: statusOf({ installed: true }),
checkedStatus: statusOf({ behind: 3, incomingTotal: 3, updateAvailable: true }),
});
assert.deepStrictEqual(h.deferredTimers(), [1200], 'one deferred check must be queued');
h.fireDeferred();
await h.flush();
const checks = h.calls.filter(c => c.method === 'POST' && c.url === '/api/system/update/check');
assert.strictEqual(checks.length, 1, 'the deferred timer must ask the remote exactly once');
assert.match(h.html(), /3/, 'the answer must be on screen without another click');
// Re-entering the page in the same session does not go back to the remote again.
h.api.loadUpdateConsole();
await h.flush();
assert.deepStrictEqual(h.deferredTimers(), [], 'the auto check is once per session, not per render');
assert.strictEqual(h.vars.autoCheckDone(), true);
});
test('a tree that cannot be updated is never phoned home for', async () => {
const h = await harness({ status: statusOf({ installed: false }) });
assert.deepStrictEqual(h.deferredTimers(), [], 'no auto check when this is not a git installation');
assert.strictEqual(h.calls.filter(c => c.url === '/api/system/update/check').length, 0);
});
test('an update already running is not disturbed by an auto check', async () => {
const h = await harness({ status: statusOf({ installed: true }), job: runningJob });
assert.deepStrictEqual(h.deferredTimers(), [], 'a running job is already the story on screen');
assert.strictEqual(h.vars.autoCheckDone(), false, 'a skipped check must not spend the session quota');
});
test('the hint describes the automatic check instead of denying any network use', () => {
for (const [label, dict] of [['zh-CN', zh], ['en-US', en], ['ru-RU', ru]]) {
const text = dict.update.notChecked;
assert.ok(text, label + ' lost the notChecked copy');
assert.doesNotMatch(text, /不会联网|makes no network call|no network call|не выходит в сеть|без обращения к сети/,
label + ' still promises the page never touches the network');
assert.match(text, /自动|automatic|automatically|автоматическ/, label + ' must mention the automatic check');
}
});
+648
View File
@@ -0,0 +1,648 @@
// 控制台「一键更新」:这一页动的就是当前正在应答请求的那套安装树本身。
// 三条硬规矩,每一条都是这个仓库已经踩过一次的坑:
// 1) 查不了必须说查不了。checkError 非空时页面写出原因,绝不渲染成「已是最新」——
// 把"没查成"画成"没更新",操作员要等到下一次故障才发现树早就没动了。
// 2) 提示只念真实 HTTP 结果,而且只用平台确实存在的 showNotification;没有就什么都不做。
// 3) 服务端来的字符串进 HTML 一律 escapeHtml,进属性一律 escapeAttr。
const UPDATE_POLL_INTERVAL_MS = 1500;
// withUpdateBusy 按动作挑错误文案的前缀,四个 key 都必须在三份语言里存在。
const UPDATE_FAILURE_KEYS = {
check: 'checkFailed',
apply: 'applyFailed',
rollback: 'rollbackFailed',
load: 'loadFailed',
};
let updateConsoleState = null; // GET /api/system/update 的整包:{status, job, canRestart}
let updateCheck = { done: false, error: '' };
let updateBusy = false; // 页面自己发着的动作请求(检查 / 启动更新 / 回滚)
let updateChecking = false; // 只影响「检查更新」按钮的措辞
let updatePollTimer = null;
let updateRestartChoice = false; // 勾选框要在轮询重绘之后仍然是勾着的
let updateAutoCheckDone = false; // 每个会话只自动查一次远端,不每次重绘都去打扰
function updateT(key, opts) {
const k = 'update.' + key;
return typeof window.t === 'function' ? window.t(k, opts) : k;
}
// scheduleUpdateAutoCheck answers the question the page exists to answer, without making
// somebody press a button to find out whether the button would do anything.
//
// The constraints are what keep it polite: one check per session, only for a tree that is
// actually a git installation, never while a job is already running, and always after the
// first paint - the page's own claim has to reach the screen before the network does. A
// failure is not retried here: the page says so plainly, and the operator presses 检查更新
// when they want to ask again.
function scheduleUpdateAutoCheck() {
if (updateAutoCheckDone) return false;
const status = updateConsoleState && updateConsoleState.status;
if (!status || !status.installed || isUpdateJobRunning()) return false;
updateAutoCheckDone = true;
setTimeout(() => {
if (!isUpdateConsoleActive()) return;
checkForUpdates();
}, 1200);
return true;
}
function isUpdateConsoleActive() {
const page = document.getElementById('page-system-update');
return !!(page && page.classList.contains('active'));
}
function updateStatusOf() {
return (updateConsoleState && updateConsoleState.status) || {};
}
function updateJobOf() {
const job = updateConsoleState && updateConsoleState.job;
return job && typeof job === 'object' ? job : null;
}
function updateCanRestart() {
return !!(updateConsoleState && updateConsoleState.canRestart);
}
function isUpdateJobRunning() {
const job = updateJobOf();
return !!(job && job.state === 'running');
}
function resetUpdateCheck() {
// 一次 GET 拿不到 behind/updateAvailable(它不联网),留着上一次的 done=true
// 就会把"没查"画成"已是最新"。
updateCheck = { done: false, error: '' };
}
// ---------------------------------------------------------------------------
// 请求层
// ---------------------------------------------------------------------------
// 每个调用都要拿到状态码和解析后的体:apply/rollback 的 409 里带着那个正在跑的任务,
// 直接 throw 掉就等于把"已经有一次在跑了,这是它的进度"扔了。
// ok 不等于状态码:rollback 在"源码回了、二进制没回"这种半途情况下是按 202 回答并带着
// error 字段的(handler 里的 statusForError 把 no_toolchain 归到 202),只信 resp.ok
// 就会对着一次没做完的回滚播报成功。
async function runUpdateRequest(method, url, body) {
const resp = await apiFetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
const data = await resp.json().catch(() => ({}));
const refused = !!(data && typeof data.error === 'string' && data.error);
return {
ok: !!resp.ok && !refused,
status: resp.status,
data: data || {},
error: refused ? data.error : ('HTTP ' + resp.status),
};
}
async function fetchUpdateStatus() {
const r = await runUpdateRequest('GET', '/api/system/update');
if (!r.ok) throw new Error(r.error);
updateConsoleState = {
status: r.data.status || {},
job: r.data.job || null,
canRestart: !!r.data.canRestart,
};
return updateConsoleState;
}
// notify 只在平台真的载了 showNotification 时才动。它绝不能成为"操作看起来失败"的原因:
// 请求已经成功了,为了播报一句去调一个页面上不存在的函数,会把成功炸成失败。
function notify(message, type) {
if (typeof showNotification === 'function') {
try {
showNotification(message, type);
} catch (e) {
console.warn('notify', e);
}
}
}
async function withUpdateBusy(fn, label) {
if (updateBusy) return;
updateBusy = true;
try {
await fn();
} catch (err) {
const reason = err && err.message ? err.message : String(err);
const text = updateT(UPDATE_FAILURE_KEYS[label] || 'loadFailed', { reason: reason });
const el = document.getElementById('update-console');
if (el && isUpdateConsoleActive()) {
// 先按当前状态重绘再挂横幅:请求炸在半路上时,画面还停在"正在检查远端…"这种
// 已经不再成立的状态上,光加一条红字是盖不住它的。
renderUpdateConsole();
el.insertAdjacentHTML('afterbegin', '<div class="update-error">' + escapeHtml(text) + '</div>');
}
// 内联横幅会被下一次轮询重绘冲掉,toast 才是留得住的那一份。
notify(text, 'error');
} finally {
updateBusy = false;
}
}
// ---------------------------------------------------------------------------
// 入口与轮询
// ---------------------------------------------------------------------------
async function loadUpdateConsole() {
stopUpdatePolling();
resetUpdateCheck();
updateRestartChoice = false;
const el = document.getElementById('update-console');
if (!el) return;
el.innerHTML = '<div class="empty-state">' + escapeHtml(updateT('loading')) + '</div>';
try {
await fetchUpdateStatus();
} catch (err) {
el.innerHTML = '<div class="empty-state">' +
escapeHtml(updateT(UPDATE_FAILURE_KEYS.load, { reason: err.message })) + '</div>';
return;
}
renderUpdateConsole();
// 开页就有一次在跑的更新(比如另开一个标签页点的),接着看它,别让人以为没在动。
if (isUpdateJobRunning()) startUpdatePolling();
scheduleUpdateAutoCheck();
}
// 只有一个定时器:不清就重新 arm,是"页面开两次之后两条轮询往同一份日志里写"的来路。
function startUpdatePolling() {
stopUpdatePolling();
updatePollTimer = setInterval(pollUpdateJob, UPDATE_POLL_INTERVAL_MS);
}
function stopUpdatePolling() {
if (updatePollTimer) clearInterval(updatePollTimer);
updatePollTimer = null;
}
async function pollUpdateJob() {
if (!isUpdateConsoleActive()) {
// 页面已经切走了。router.js 只在进入时调 initPage,离开时没人喊,
// 所以这里自己停,不留野定时器。
stopUpdatePolling();
return;
}
const r = await runUpdateRequest('GET', '/api/system/update/job');
if (!r.ok) {
stopUpdatePolling();
notify(updateT('jobPollFailed', { reason: r.error }), 'error');
return;
}
const job = r.data && r.data.job ? r.data.job : null;
if (updateConsoleState) updateConsoleState.job = job;
renderUpdateConsole();
if (job && job.state !== 'running') {
stopUpdatePolling();
reportFinishedJob(job);
}
}
// 播报只念服务端写下来的结论:成功念 result 的两个提交号,失败念 failure.message,
// 页面自己不在这里编"更新完成"。
function reportFinishedJob(job) {
const result = job.result || null;
if (job.state === 'succeeded') {
const tail = result && result.toCommit ? ' ' + result.fromCommit + ' → ' + result.toCommit : '';
notify(updateT('jobDoneToast') + tail, 'success');
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');
}
resetUpdateCheck();
// 二进制换过了,本机状态得重读一遍;读不到就照实说读不到。
fetchUpdateStatus().then(renderUpdateConsole).catch(err => {
notify(updateT(UPDATE_FAILURE_KEYS.load, { reason: err.message }), 'error');
});
}
// ---------------------------------------------------------------------------
// 动作
// ---------------------------------------------------------------------------
async function checkForUpdates() {
await withUpdateBusy(async () => {
updateChecking = true;
renderUpdateConsole();
let r;
try {
r = await runUpdateRequest('POST', '/api/system/update/check');
} finally {
// 请求本身炸掉(401、网络断了)也必须把"正在检查"放下来,否则按钮永远灰着。
updateChecking = false;
}
if (!r.ok) {
// HTTP 层的失败同样是"查不了",不能留下一个看起来像"没有更新"的页面。
updateCheck = { done: false, error: r.error };
renderUpdateConsole();
notify(updateT(UPDATE_FAILURE_KEYS.check, { reason: r.error }), 'error');
return;
}
const status = r.data.status || {};
if (updateConsoleState) updateConsoleState.status = status;
updateCheck = { done: true, error: status.checkError || '' };
renderUpdateConsole();
if (updateCheck.error) {
notify(updateT(UPDATE_FAILURE_KEYS.check, { reason: updateCheck.error }), 'error');
} else if (status.updateAvailable) {
notify(updateT('behindCount', { n: updateBehindCount(status) }), 'info');
} else {
notify(updateT('upToDate', { commit: status.commit || '-' }), 'success');
}
}, 'check');
}
function updateBehindCount(status) {
const listed = (status.incoming || []).length;
const total = status.incomingTotal || status.behind || 0;
return total > 0 ? total : listed;
}
// 按钮为什么点不动,得说得出原因;"灰着"不算解释。
function updateApplyBlockers(status, job) {
const blockers = [];
if (!status.installed) blockers.push(updateT('blockedNotGitTree'));
if (job && job.state === 'running') blockers.push(updateT('blockedJobRunning'));
if ((status.blockingChanges || []).length) {
blockers.push(updateT('blockedLocalEdits', { n: status.blockingChanges.length }));
}
if (status.diverged) {
blockers.push(updateT('blockedDiverged', {
ahead: status.ahead || 0,
behind: status.behind || 0,
}));
}
if (!status.updateAvailable) blockers.push(updateT('blockedNoUpdate'));
return blockers;
}
function updateRestartChoiceChanged(checked) {
updateRestartChoice = !!checked;
renderUpdateConsole();
}
// 勾选框是唯一的真相来源;模块里那份只是给轮询重绘用的记忆。
function updateRestartRequested() {
const box = document.getElementById('update-restart-choice');
if (box && typeof box.checked === 'boolean') return box.checked;
return updateRestartChoice;
}
function updateConfirmText(status, restart) {
const base = updateT('confirmApply', {
remote: status.remote || '-',
branch: status.branch || '-',
commit: status.commit || '-',
target: status.remoteCommit || '-',
n: updateBehindCount(status),
});
return base + ' ' + (restart ? updateT('confirmRestartYes') : updateT('confirmRestartNo'));
}
async function startUpdateApply() {
if (updateBusy) return;
const status = updateStatusOf();
const blockers = updateApplyBlockers(status, updateJobOf());
if (blockers.length) {
// 浏览器不会把 disabled 按钮的点击送到这里,走到这儿说明状态已经和画面不一致了,
// 那就照实说为什么不能点,一声不吭更像坏了。
notify(blockers[0], 'error');
return;
}
const restart = updateRestartRequested();
if (!window.confirm(updateConfirmText(status, restart))) return;
await withUpdateBusy(async () => {
const r = await runUpdateRequest('POST', '/api/system/update/apply', { restart: restart });
if (r.status === 409 && r.data.job) {
// 另一次更新真的在跑:把它的进度接下来并接着看,这才是"当前状态"。
if (updateConsoleState) updateConsoleState.job = r.data.job;
renderUpdateConsole();
startUpdatePolling();
}
if (!r.ok) throw new Error(r.error);
resetUpdateCheck();
startUpdatePolling();
notify(updateT('applyAccepted', { id: r.data.job_id || '-' }), 'success');
await pollUpdateJob();
}, 'apply');
}
async function rollbackUpdate() {
if (updateBusy) return;
const status = updateStatusOf();
if (!status.hasRollback) return;
if (isUpdateJobRunning()) {
notify(updateT('blockedJobRunning'), 'error');
return;
}
if (!window.confirm(updateT('confirmRollback', { commit: status.rollbackTo || '-' }))) return;
await withUpdateBusy(async () => {
const r = await runUpdateRequest('POST', '/api/system/update/rollback');
if (!r.ok) {
// 服务端连"哪些文件没回滚"都给了,不能只报一句失败。
const items = r.data && r.data.items && r.data.items.length
? ' · ' + updateT('rollbackItems') + ': ' + r.data.items.join(', ')
: '';
throw new Error(r.error + items);
}
const to = (r.data.result && r.data.result.toCommit) || status.rollbackTo || '-';
resetUpdateCheck();
updateRestartChoice = false;
await fetchUpdateStatus();
renderUpdateConsole();
notify(updateT('rollbackDone', { commit: to }), 'success');
}, 'rollback');
}
// ---------------------------------------------------------------------------
// 渲染
// ---------------------------------------------------------------------------
function updateSection(title, body) {
return '<section class="update-section"><h3 class="update-section-title">' +
escapeHtml(title) + '</h3>' + body + '</section>';
}
// 值既进文本也进 title(长路径要靠 title 看全),所以属性那一份必须走 escapeAttr。
function updateFact(label, value) {
const text = value == null || value === '' ? '-' : String(value);
return '<div class="update-fact"><span>' + escapeHtml(label) + '</span>' +
'<code title="' + escapeAttr(text) + '">' + escapeHtml(text) + '</code></div>';
}
function updateMark(ok, yesKey, noKey, reason) {
const cls = ok ? 'update-chip update-chip-ok' : 'update-chip update-chip-warn';
if (!reason) {
return '<span class="' + cls + '">' + escapeHtml(ok ? updateT(yesKey) : updateT(noKey)) + '</span>';
}
return '<span class="' + cls + '" title="' + escapeAttr(String(reason)) + '">' +
escapeHtml(ok ? updateT(yesKey) : updateT(noKey)) + '</span>';
}
function renderUpdateInstallBody(status) {
const facts = [
updateFact(updateT('root'), status.root),
updateFact(updateT('branch'), status.branch),
updateFact(updateT('remote'), status.remote),
updateFact(updateT('commit'), status.commit),
updateFact(updateT('committedAt'), status.committedAt),
updateFact(updateT('subject'), status.subject),
].join('');
const marks = [
updateMark(!!status.installed, 'gitTree', 'notGitTree'),
updateMark(!!status.canBuild, 'toolchainFound', 'toolchainMissing',
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>';
}
function renderUpdateRemoteBody(status) {
const failure = updateCheck.error || status.checkError || '';
const parts = [];
if (failure) {
// 这条分支就是"不许说谎"的落点:有原因就只写原因,不提"已是最新"。
parts.push('<div class="update-error update-check-failed">' +
escapeHtml(updateT('checkFailed', { reason: failure })) + '</div>');
} else if (!updateCheck.done) {
parts.push('<div class="update-hint">' + escapeHtml(updateT('notChecked')) + '</div>');
} else if (status.updateAvailable) {
parts.push('<div class="update-behind">' +
escapeHtml(updateT('behindCount', { n: updateBehindCount(status) })) + '</div>');
} else {
parts.push('<div class="update-ok">' +
escapeHtml(updateT('upToDate', { commit: status.commit || '-' })) + '</div>');
}
if (status.ahead > 0) {
parts.push('<div class="update-warn">' + escapeHtml(updateT('aheadCount', { n: status.ahead })) + '</div>');
}
if (status.remoteCommit) {
parts.push('<div class="update-facts">' +
updateFact(updateT('remoteHead'), status.remoteCommit) +
updateFact(updateT('remoteSubject'), status.remoteSubject) + '</div>');
}
const incoming = status.incoming || [];
if (incoming.length) {
// incoming 是被截断过的清单,incomingTotal 才是真实数量,两个都得说。
const total = status.incomingTotal || incoming.length;
parts.push('<h4 class="update-sub-title">' + escapeHtml(updateT('incomingTitle')) + '</h4>');
if (total > incoming.length) {
parts.push('<div class="update-hint">' +
escapeHtml(updateT('incomingTruncated', { total: total, shown: incoming.length })) + '</div>');
}
parts.push('<ul class="update-commit-list">' + incoming.map(c =>
'<li><code title="' + escapeAttr(String(c.commit || '-')) + '">' + escapeHtml(String(c.commit || '-')) +
'</code> <span class="update-commit-subject">' + escapeHtml(String(c.subject || '')) + '</span></li>'
).join('') + '</ul>');
}
return parts.join('');
}
function renderUpdateChangesTable(list) {
// 行底色就是"这个文件会不会被更新覆盖"的分区:本机内容绿、产品源码红。
const rows = (list || []).map(c => '<tr class="' + (c.protected ? 'update-row-kept' : 'update-row-blocking') + '">' +
'<td><code title="' + escapeAttr(String(c.path || '')) + '">' + escapeHtml(String(c.path || '-')) + '</code></td>' +
'<td>' + escapeHtml(String(c.status || '-')) + '</td>' +
'<td>' + escapeHtml(c.protected ? updateT('keptFile') : updateT('productSource')) + '</td></tr>').join('');
return '<table class="update-table"><thead><tr>' +
'<th>' + escapeHtml(updateT('colPath')) + '</th>' +
'<th>' + escapeHtml(updateT('colChangeStatus')) + '</th>' +
'<th>' + escapeHtml(updateT('colOwner')) + '</th>' +
'</tr></thead><tbody>' + rows + '</tbody></table>';
}
function renderUpdateRestartChoice() {
if (!updateCanRestart()) {
// 后端没给重启钩子,勾选框就不该出现:勾一个不会被兑现的选项是骗人。
return '<div class="update-hint">' + escapeHtml(updateT('restartNotWired')) + '</div>';
}
const preview = updateRestartChoice ? updateT('confirmRestartYes') : updateT('confirmRestartNo');
return '<label class="update-restart-choice">' +
'<input type="checkbox" id="update-restart-choice"' +
(updateRestartChoice ? ' checked' : '') +
' onchange="updateRestartChoiceChanged(this.checked)">' +
'<span>' + escapeHtml(updateT('restartChoiceLabel')) + '</span></label>' +
'<div class="update-hint update-restart-preview">' + escapeHtml(preview) + '</div>';
}
function renderUpdateApplyBody(status, job) {
const blockers = updateApplyBlockers(status, job);
const blocked = blockers.length > 0;
const parts = [];
if (job && job.state === 'running') {
parts.push('<div class="update-warn">' + escapeHtml(updateT('jobRunningHint')) + '</div>');
}
if (!status.canBuild && status.installed) {
parts.push('<div class="update-warn">' + escapeHtml(updateT('noToolchainWarn')) + '</div>');
}
// Both halves matter: `disabled` for the browser, `data-state-disabled` so the platform's
// permission gate (which writes el.disabled for every [data-require-permission] element)
// does not re-enable the button for a user who holds update:apply. In a real browser that
// write produced an enabled 一键更新 directly under "有 53 个产品源码文件被本地改过".
const stateDisabled = blocked ? ' disabled data-state-disabled="true"' : '';
const checkingDisabled = updateChecking ? ' disabled data-state-disabled="true"' : '';
parts.push('<div class="update-actions">' +
'<button class="btn-primary update-apply-btn"' + stateDisabled +
' data-require-permission="update:apply" onclick="startUpdateApply()">' +
escapeHtml(updateT('applyBtn')) + '</button>' +
'<button class="btn-secondary update-check-btn"' + checkingDisabled +
' data-require-permission="update:apply" onclick="checkForUpdates()">' +
escapeHtml(updateChecking ? updateT('checking') : updateT('checkBtn')) + '</button>' +
'</div>');
parts.push(renderUpdateRestartChoice());
if (blocked) {
parts.push('<ul class="update-blocker-list">' + blockers.map(b =>
'<li class="update-error">' + escapeHtml(b) + '</li>').join('') + '</ul>');
}
const local = status.localChanges || [];
const blocking = status.blockingChanges || [];
// localChanges 通常是 blockingChanges 的超集,但两者万一不一致,阻塞更新的文件绝不能因此
// 不列出来:上面那句"有 N 个文件被本地改过"点名的就是这些得人去处理的文件名。
const seenPaths = Object.create(null);
const listed = local.concat(blocking).filter(c => {
const p = String((c && c.path) || '');
if (!p || seenPaths[p]) return false;
seenPaths[p] = true;
return true;
});
if (listed.length) {
parts.push('<h4 class="update-sub-title">' + escapeHtml(updateT('localChangesTitle')) + '</h4>');
parts.push('<div class="update-hint">' + escapeHtml(updateT('localChangesHint')) + '</div>');
parts.push(renderUpdateChangesTable(listed));
} else {
parts.push('<div class="update-hint">' + escapeHtml(updateT('noLocalChanges')) + '</div>');
}
return parts.join('');
}
function updateStateChip(state) {
const known = { running: 'update-chip-running', succeeded: 'update-chip-ok', failed: 'update-chip-danger' };
const cls = known[state] || 'update-chip';
const label = state === 'running' ? updateT('stateRunning')
: state === 'succeeded' ? updateT('stateSucceeded')
: state === 'failed' ? updateT('stateFailed') : updateT('stateUnknown');
return '<span class="' + cls + '">' + escapeHtml(label) + '</span>';
}
function renderUpdateStep(step) {
return '<li class="update-step">' +
'<code class="update-step-phase">' + escapeHtml(String(step.phase || '-')) + '</code>' +
'<span class="update-step-message">' + escapeHtml(String(step.message || '')) + '</span>' +
'<span class="update-step-at">' + escapeHtml(String(step.at || '')) + '</span></li>';
}
function renderUpdateResult(result) {
if (!result) return '';
const kept = result.keptContent || [];
const facts = [
updateFact(updateT('resultFrom'), result.fromCommit),
updateFact(updateT('resultTo'), result.toCommit),
updateFact(updateT('resultCommits'), result.commits),
updateFact(updateT('resultFiles'), result.filesTouched),
updateFact(updateT('resultDuration'), result.duration),
updateFact(updateT('resultBackup'), result.backupDir),
].join('');
const marks = [
updateMark(!!result.binaryBuilt, 'binarySwapped', 'binaryNotSwapped', result.binaryPath || ''),
updateMark(!!result.needsRestart, 'restartNeeded', 'restartNotNeeded'),
].join('');
const keptList = kept.length
? '<ul class="update-kept-list">' + kept.map(p =>
'<li><code title="' + escapeAttr(String(p)) + '">' + escapeHtml(String(p)) + '</code></li>').join('') + '</ul>'
: '<div class="update-hint">' + escapeHtml(updateT('nothingKept')) + '</div>';
return '<div class="update-result">' +
'<h4 class="update-sub-title">' + escapeHtml(updateT('resultTitle')) + '</h4>' +
'<div class="update-facts">' + facts + '</div><div class="update-marks">' + marks + '</div>' +
'<h4 class="update-sub-title">' + escapeHtml(updateT('keptTitle')) + '</h4>' + keptList +
'</div>';
}
function renderUpdateFailure(failure) {
if (!failure) return '';
const items = failure.items || [];
const reason = failure.reason ? '(' + escapeHtml(String(failure.reason)) + ')' : '';
return '<div class="update-error update-failure">' +
'<div class="update-failure-title">' + escapeHtml(updateT('failureTitle')) + reason + '</div>' +
'<div class="update-failure-message">' +
escapeHtml(String(failure.message || updateT('failureNoMessage'))) + '</div>' +
(items.length
? '<ul class="update-item-list">' + items.map(i =>
'<li><code title="' + escapeAttr(String(i)) + '">' + escapeHtml(String(i)) + '</code></li>').join('') + '</ul>'
: '') +
'</div>';
}
function renderUpdateJobBody(job) {
if (!job) return '<div class="empty-state">' + escapeHtml(updateT('noJob')) + '</div>';
const parts = ['<div class="update-job-head">' + updateStateChip(job.state) +
'<code title="' + escapeAttr(String(job.id || '-')) + '">' + escapeHtml(String(job.id || '-')) + '</code>' +
(job.started ? '<span class="update-job-time">' + escapeHtml(String(job.started)) + '</span>' : '') +
(job.restartRequested ? '<span class="update-chip">' + escapeHtml(updateT('restartRequested')) + '</span>' : '') +
'</div>'];
const steps = job.steps || [];
parts.push(steps.length
? '<ol class="update-step-list">' + steps.map(renderUpdateStep).join('') + '</ol>'
: '<div class="empty-state">' + escapeHtml(updateT('noSteps')) + '</div>');
if (job.state === 'running') {
parts.push('<div class="update-hint">' +
escapeHtml(updateT('pollingHint', { sec: UPDATE_POLL_INTERVAL_MS / 1000 })) + '</div>');
}
if (job.result) parts.push(renderUpdateResult(job.result));
if (job.failure) parts.push(renderUpdateFailure(job.failure));
return parts.join('');
}
function renderUpdateRollbackBody(status) {
if (!status.hasRollback) {
return '<div class="empty-state">' + escapeHtml(updateT('noRollback')) + '</div>';
}
const running = isUpdateJobRunning();
return '<p class="update-hint">' +
escapeHtml(updateT('rollbackTarget', { commit: status.rollbackTo || '-' })) + '</p>' +
'<div class="update-actions"><button class="btn-secondary update-rollback-btn"' +
(running ? ' disabled data-state-disabled="true"' : '') + ' data-require-permission="update:apply" ' +
'onclick="rollbackUpdate()">' + escapeHtml(updateT('rollbackBtn')) + '</button></div>';
}
function renderUpdateConsole() {
const el = document.getElementById('update-console');
if (!el || !updateConsoleState) return;
const status = updateStatusOf();
const job = updateJobOf();
const parts = [];
parts.push(updateSection(updateT('installTitle'), renderUpdateInstallBody(status)));
parts.push(updateSection(updateT('remoteTitle'), renderUpdateRemoteBody(status)));
parts.push(updateSection(updateT('applyTitle'), renderUpdateApplyBody(status, job)));
parts.push(updateSection(updateT('jobTitle'), renderUpdateJobBody(job)));
parts.push(updateSection(updateT('rollbackTitle'), renderUpdateRollbackBody(status)));
el.innerHTML = parts.join('');
if (typeof window.applyRBACToUI === 'function') {
window.applyRBACToUI(el);
}
}
window.loadUpdateConsole = loadUpdateConsole;
window.checkForUpdates = checkForUpdates;
window.startUpdateApply = startUpdateApply;
window.rollbackUpdate = rollbackUpdate;
window.updateRestartChoiceChanged = updateRestartChoiceChanged;
window.stopUpdatePolling = stopUpdatePolling;
window.renderUpdateConsole = renderUpdateConsole;
+23
View File
@@ -445,6 +445,16 @@
<span data-i18n="nav.platformRbac">平台权限</span>
</div>
</div>
<div class="nav-item" data-page="system-update">
<div class="nav-item-content" data-title="一键更新" onclick="switchPage('system-update')" data-i18n="nav.systemUpdate" data-i18n-attr="data-title" data-i18n-skip-text="true">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242"></path>
<path d="M12 12v9"></path>
<path d="m8 17 4 4 4-4"></path>
</svg>
<span data-i18n="nav.systemUpdate">一键更新</span>
</div>
</div>
<div class="nav-item" data-page="settings">
<div class="nav-item-content" data-title="系统设置" onclick="switchPage('settings')" data-i18n="nav.settings" data-i18n-attr="data-title" data-i18n-skip-text="true">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -3572,6 +3582,18 @@
</div>
</div>
<div id="page-system-update" class="page">
<div class="page-header">
<h2 data-i18n="update.title">一键更新</h2>
<div class="page-header-actions">
<button class="btn-secondary" onclick="loadUpdateConsole()" data-i18n="common.refresh">刷新</button>
</div>
</div>
<div class="page-content">
<p class="update-intro" data-i18n="update.intro">这一页改的就是当前正在应答请求的这套安装:拉取它自己跟踪的远端、重新编译、换掉二进制。你本机维护的 roles/skills/tools 与配置不会被覆盖。</p>
<div id="update-console" class="update-console"></div>
</div>
</div>
<!-- 系统设置页面 -->
<div id="page-settings" class="page">
<div class="page-header">
@@ -6945,6 +6967,7 @@
<script src="/static/js/tasks.js"></script>
<script src="/static/js/workflow-package-client.js"></script>
<script src="/static/js/workflows.js"></script>
<script src="/static/js/update.js?v={{.Version}}"></script>
<script src="/static/js/roles.js"></script>
<script src="/static/js/rbac.js"></script>
<script src="/static/js/c2.js"></script>