mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-10-19 05:57:39 +02:00
feat: give the update source a page, and the adopt flow its preview
The console page grows an update-source section: what is in effect and where it comes from (config.yaml or the tree's own tracking), an edit form for the three fields, and - for a directory that is not a git installation - the adopt entry. Adopt is preview-first: the plan lists what would be replaced and what would be kept, the confirm dialog carries the counts, and the restart tick is the same one an update uses. A refused save shows the server's own sentence (a remote name and an address together, an ext:: address) instead of a generic failure, and the typed values survive the failed save and the polling redraws. zh/EN/RU dictionaries carry the 27 new keys; the console page's contract tests grow to 29 (source rendering and save body, the refusal path, adopt gating by installation kind, preview-then-confirm); docs/README describe choosing a source and connecting an unpacked installation.
This commit is contained in:
1 parent
acd84cd525
commit
27c1ff6573
12 files changed
+461
-5
No files matched your search
@@ -281,6 +281,11 @@ above; only a non-git (tarball) installation falls back to the old "download a G
|
||||
rsync it in" path, where the source repository comes from `--repo owner/name` or `GITHUB_REPO` and the
|
||||
built-in default applies only when neither is given (with a warning naming the repository).
|
||||
|
||||
The update source is one setting, in the `update` section of `config.yaml` or right on the console
|
||||
page - the official repository, your own fork, or somebody else's fork. An installation unpacked from
|
||||
a Release archive (no git) can be previewed and connected from the same page and updates in one click
|
||||
from then on.
|
||||
|
||||
**CyberStrikeAI upgrade script:**
|
||||
1. (First time) enable the script: `chmod +x upgrade.sh`
|
||||
2. Upgrade with: `./upgrade.sh` (optional flags: `--check`, `--tag vX.Y.Z`, `--repo owner/name`, `--no-venv`, `--yes`). Local `tools/`, `roles/`, `skills/` and `agents/` are always preserved.
|
||||
|
||||
@@ -277,6 +277,9 @@ go build -o cyberstrike-ai ./cmd/server
|
||||
才回落到"下载 GitHub Release 包 + rsync"的老路径,此时源码仓库由 `--repo owner/name` 或 `GITHUB_REPO`
|
||||
决定(都不给才用内置默认值,并会警告说明代码来自哪个仓库)。
|
||||
|
||||
更新源可在 `config.yaml` 的 `update` 段或控制台「一键更新」页指定(跟随官方仓库、自己的二开或别人的二开
|
||||
都只是一处配置);解压安装(目录里没有 git)也能在同一页「预览并接入」后进入一键更新。
|
||||
|
||||
1. (首次使用)启用脚本:`chmod +x upgrade.sh`
|
||||
2. 升级:`./upgrade.sh`(可选参数:`--check`、`--tag vX.Y.Z`、`--repo owner/name`、`--no-venv`、`--yes`)。本地的 `tools/`、`roles/`、`skills/`、`agents/` 会始终保留不被覆盖。
|
||||
3. 脚本会备份你的 `config.yaml` 和 `data/`,升级代码,更新 `config.yaml` 的 `version` 字段后重启服务。
|
||||
|
||||
@@ -86,6 +86,15 @@ kept. For a fork this is the only path that does not overwrite you with somebody
|
||||
source is modified locally or the branch has diverged it refuses and says why instead of forcing.
|
||||
See [the developer guide](developer-guide.md) for the full semantics.
|
||||
|
||||
The source is chosen either in the `update` section of `config.yaml` (`remote` or `remote_url`, plus
|
||||
an optional `branch`) or right on the console page: the official repository, your own fork, or
|
||||
somebody else's second-development repository are all just this one setting; unset, it follows the
|
||||
remote this directory already tracks. An installation unpacked from a Release archive (no `.git`)
|
||||
can be connected from the same page: the preview lists the files the target would replace and the
|
||||
operator content that is kept, replaced files are backed up under
|
||||
`.update-backup/<timestamp>/overwritten/`, and confirming turns the directory into a normal
|
||||
installation with one-click updates.
|
||||
|
||||
`upgrade.sh` still works and is now a thin shell over that implementation, with two paths decided by
|
||||
the installation kind:
|
||||
|
||||
|
||||
@@ -65,6 +65,35 @@ first remote when none of those names exists), and the branch defaults to what t
|
||||
shell over the same implementation: when this directory is a git work tree it just calls
|
||||
`./cyberstrike-ai -update`.
|
||||
|
||||
### Choosing the source (the update section of config.yaml)
|
||||
|
||||
Unset, the rules above apply; set, the explicit choice wins:
|
||||
|
||||
```yaml
|
||||
update:
|
||||
remote: origin # an existing remote name (mine/origin/upstream/anything), or remote_url instead
|
||||
# remote_url: https://github.com/AIPentest/CyberStrikeAI.git
|
||||
branch: main # optional; defaults to @{upstream} / the current branch
|
||||
```
|
||||
|
||||
The console page edits and saves all three. Saving validates: remote and branch names go through the
|
||||
same allowlist an update uses, and the address allowlist is https/http/ssh/git/file plus local
|
||||
absolute paths - git's `ext::` transport executes commands and is rejected outright. Following the
|
||||
official repository, your own fork, or somebody else's second-development repository is exactly the
|
||||
choice this section expresses.
|
||||
|
||||
### Connecting a non-git installation (the tarball kind)
|
||||
|
||||
An installation that started as an unpacked Release archive has no `.git`; once a source is saved it
|
||||
can be connected from the same page: the preview fetches in a throwaway repository and first lists
|
||||
**the local files the target would replace** and **the operator content that will be kept**; on
|
||||
confirm the directory becomes a git work tree (`git init`, origin added, the target branch checked
|
||||
out), every replaced file is kept under `.update-backup/<timestamp>/overwritten/`, operator content
|
||||
is stashed and restored as usual, and the binary is rebuilt. From then on it is a normal
|
||||
installation with one-click update and rollback - but note there is no git history before the
|
||||
connection, so the connection itself has no earlier commit to roll back to; the first real update
|
||||
writes the first rollback point.
|
||||
|
||||
### The four refusals
|
||||
|
||||
| situation | reason | meaning and what to do |
|
||||
|
||||
@@ -186,6 +186,12 @@ knowledge_base/ data/ config.yaml` 等运维者内容先暂存再放回,结果
|
||||
跑 fork 时这是唯一不会"用别人的代码覆盖自己"的路径;本地源码有改动或分支已分叉时它会拒绝并说明原因,
|
||||
而不是硬来。细节见 [开发者指南](developer-guide.md) 的「一键更新」一节。
|
||||
|
||||
更新源可以在 `config.yaml` 的 `update` 段指定(`remote` 或 `remote_url`,可带 `branch`),也可以直接在
|
||||
控制台「一键更新」页填写保存:想跟随官方仓库、自己的二开或别人的二开,改的都是这一处;不配置时跟随
|
||||
本目录自己跟踪的远端。**用 Release 包解压安装、目录里没有 git 的**,配好地址后在同一页点「预览并接入」:
|
||||
预览先列出会被目标版本替换的文件与会保留的运维者内容,被替换的都会备份在
|
||||
`.update-backup/<时间戳>/overwritten/`,确认后目录成为可一键更新的正常安装。
|
||||
|
||||
`upgrade.sh` 仍然可用,它现在只是这条实现的薄壳,并按安装形态分两条路:
|
||||
|
||||
- **本目录是 git 工作树**:直接调用 `./cyberstrike-ai -update`(还没有二进制时先
|
||||
|
||||
@@ -67,6 +67,30 @@ go run ./cmd/server --config config.yaml
|
||||
remote 里取(都不在时取第一个 remote),分支默认取当前分支 `@{upstream}` 指向的名字(没有 upstream 时用当前分支名)。
|
||||
`upgrade.sh` 现在只是这条实现的薄壳:本目录是 git 工作树时它直接调 `./cyberstrike-ai -update`。
|
||||
|
||||
### 选择更新源(config.yaml 的 update 段)
|
||||
|
||||
不配置时按上面的规则跟随本树;配置后显式来源优先:
|
||||
|
||||
```yaml
|
||||
update:
|
||||
remote: origin # 已有远端名(mine/origin/upstream/任意名),与 remote_url 二选一
|
||||
# remote_url: https://github.com/AIPentest/CyberStrikeAI.git
|
||||
branch: main # 可选;留空按 upstream/当前分支推断
|
||||
```
|
||||
|
||||
控制台的「一键更新」页可以直接编辑并保存这三项。服务端保存时校验:远端名与分支名走与更新同一套白名单,
|
||||
地址只允许 https/http/ssh/git/file:// 与本机绝对路径——git 的 `ext::` 传输会执行命令,一律拒绝。
|
||||
想让这套安装跟随官方仓库、自己的二开或别人的二开,改的都是这一处。
|
||||
|
||||
### 接入非 git 安装(解压/打包装的那类)
|
||||
|
||||
一开始用 Release 包解压安装、目录里没有 `.git` 的,配好上面的地址后可在同一页执行「预览并接入」:
|
||||
预览在临时仓库里 fetch,先列出**会被目标版本替换的本机文件**与**会保留的运维者内容**;确认后目录接入
|
||||
成为 git 工作树(`git init` + 添加 origin + 落地目标分支),被替换的文件全部留底在
|
||||
`.update-backup/<时间戳>/overwritten/`,运维者内容照旧先暂存再放回,随后重编译二进制。
|
||||
接入后它就是正常安装,一键更新与回滚都可用了;注意接入前没有 git 历史,因此「接入」本身没有可回滚的
|
||||
上一提交,第一个回滚点由接入后的第一次更新写下。
|
||||
|
||||
### 四种拒绝场景
|
||||
|
||||
| 场景 | reason | 含义与处理 |
|
||||
|
||||
@@ -47005,3 +47005,39 @@ html[data-theme="dark"] { --tool-blocked-color: #f4bc62; }
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* 更新源区块:一行一个输入(label 上、输入下),与平台的设置表单同构。 */
|
||||
.update-source-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.update-source-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.update-source-input {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.update-source-url,
|
||||
.update-source-remote,
|
||||
.update-source-branch {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
|
||||
.update-source-save-btn,
|
||||
.update-adopt-preview-btn,
|
||||
.update-adopt-confirm-btn {
|
||||
align-self: flex-start;
|
||||
}
|
||||
@@ -171,6 +171,34 @@
|
||||
"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}}",
|
||||
"sourceTitle": "Update source",
|
||||
"sourceFromConfig": "Updates follow the source configured in config.yaml.",
|
||||
"sourceFromTracked": "No source configured: updates follow the remote this directory already tracks.",
|
||||
"sourceUrl": "Remote address",
|
||||
"sourceRemote": "Remote name",
|
||||
"sourceBranch": "Branch",
|
||||
"sourceUrlLabel": "Repository address (or an existing remote name instead)",
|
||||
"sourceRemoteLabel": "Existing remote name",
|
||||
"sourceBranchLabel": "Branch (optional)",
|
||||
"sourceSaveBtn": "Save source",
|
||||
"sourceSaveOk": "Update source saved",
|
||||
"sourceSaveFailed": "Failed to save the update source: {{reason}}",
|
||||
"adoptHint": "This directory is not a git work tree yet. Connecting it to the source above turns it into a normal installation that updates in one click.",
|
||||
"adoptNeedsSource": "Fill in a repository address above and save it first; then this directory can be connected.",
|
||||
"adoptPreviewBtn": "Preview and connect",
|
||||
"adoptIncoming": "Files in the target branch",
|
||||
"adoptOverwrittenTotal": "Will be replaced by the target",
|
||||
"adoptProtectedTotal": "Operator content (kept)",
|
||||
"adoptCommit": "Target commit",
|
||||
"adoptOverwrittenTitle": "Local files that will be replaced",
|
||||
"adoptProtectedTitle": "Operator content that will be kept",
|
||||
"adoptBackupNote": "Every replaced file is kept under .update-backup/<timestamp>/overwritten/ and can be restored from there.",
|
||||
"adoptConfirmBtn": "Connect",
|
||||
"adoptConfirmText": "Connecting replaces {{overwritten}} local files with the target versions (of {{incoming}} files in total; {{protectedFiles}} operator files are kept). Continue?",
|
||||
"adoptAccepted": "Connecting started (job {{id}}); progress refreshes every 1.5 seconds.",
|
||||
"adoptFailed": "Connecting failed: {{reason}}",
|
||||
"adoptedNoPreviousCommit": "This is an adoption result: there was no git history before it, so there is no earlier commit to roll back to; the next update writes the first rollback point.",
|
||||
"resultOverwrittenTitle": "Local files that were replaced (copies under overwritten/)",
|
||||
"rollbackItems": "Files that were not rolled back"
|
||||
},
|
||||
"nav": {
|
||||
|
||||
@@ -171,6 +171,34 @@
|
||||
"rollbackBtn": "Откатить обновление",
|
||||
"confirmRollback": "Откатиться до {{commit}}? Исходники вернутся к состоянию до того обновления, а прежний бинарный файл будет восстановлен.",
|
||||
"rollbackDone": "Откат выполнен до {{commit}}",
|
||||
"sourceTitle": "Источник обновления",
|
||||
"sourceFromConfig": "Обновления следуют источнику, настроенному в config.yaml.",
|
||||
"sourceFromTracked": "Источник не настроен: обновления следуют удалённому репозиторию, который отслеживает этот каталог.",
|
||||
"sourceUrl": "Адрес репозитория",
|
||||
"sourceRemote": "Имя удалённого репозитория",
|
||||
"sourceBranch": "Ветка",
|
||||
"sourceUrlLabel": "Адрес репозитория (или имя существующего удалённого репозитория)",
|
||||
"sourceRemoteLabel": "Имя существующего удалённого репозитория",
|
||||
"sourceBranchLabel": "Ветка (необязательно)",
|
||||
"sourceSaveBtn": "Сохранить источник",
|
||||
"sourceSaveOk": "Источник обновления сохранён",
|
||||
"sourceSaveFailed": "Не удалось сохранить источник обновления: {{reason}}",
|
||||
"adoptHint": "Этот каталог ещё не git-рабочее дерево. После подключения к источнику выше он станет обычной установкой с обновлением в один клик.",
|
||||
"adoptNeedsSource": "Сначала укажите адрес репозитория выше и сохраните его — затем каталог можно подключить.",
|
||||
"adoptPreviewBtn": "Предпросмотр и подключение",
|
||||
"adoptIncoming": "Файлов в целевой ветке",
|
||||
"adoptOverwrittenTotal": "Будут заменены целевой версией",
|
||||
"adoptProtectedTotal": "Содержимое оператора (сохраняется)",
|
||||
"adoptCommit": "Целевой коммит",
|
||||
"adoptOverwrittenTitle": "Локальные файлы, которые будут заменены",
|
||||
"adoptProtectedTitle": "Содержимое оператора, которое сохранится",
|
||||
"adoptBackupNote": "Все заменённые файлы сохраняются в .update-backup/<отметка времени>/overwritten/ и могут быть восстановлены оттуда.",
|
||||
"adoptConfirmBtn": "Подключить",
|
||||
"adoptConfirmText": "Подключение заменит {{overwritten}} локальных файлов целевыми версиями (всего {{incoming}} файлов; {{protectedFiles}} файлов оператора сохраняются). Продолжить?",
|
||||
"adoptAccepted": "Подключение запущено (задача {{id}}); ход обновляется каждые 1,5 секунды.",
|
||||
"adoptFailed": "Не удалось подключить: {{reason}}",
|
||||
"adoptedNoPreviousCommit": "Это результат подключения: до него git-истории не было, поэтому откатываться не к чему; первый откат запишет следующее обновление.",
|
||||
"resultOverwrittenTitle": "Заменённые локальные файлы (копии в overwritten/)",
|
||||
"rollbackItems": "Файлы, которые не были откачены"
|
||||
},
|
||||
"nav": {
|
||||
|
||||
@@ -171,6 +171,34 @@
|
||||
"rollbackBtn": "回滚到更新前",
|
||||
"confirmRollback": "确定回滚到 {{commit}} 吗?源码回到那次更新之前,二进制用回备份的那一份。",
|
||||
"rollbackDone": "已回滚到 {{commit}}",
|
||||
"sourceTitle": "更新源",
|
||||
"sourceFromConfig": "更新源来自配置(config.yaml 的 update 段)。",
|
||||
"sourceFromTracked": "未配置更新源:跟随本目录自己跟踪的远端。",
|
||||
"sourceUrl": "远端地址",
|
||||
"sourceRemote": "远端名",
|
||||
"sourceBranch": "分支",
|
||||
"sourceUrlLabel": "仓库地址(与下方的已有远端名二选一)",
|
||||
"sourceRemoteLabel": "已有远端名",
|
||||
"sourceBranchLabel": "分支(可留空)",
|
||||
"sourceSaveBtn": "保存更新源",
|
||||
"sourceSaveOk": "更新源已保存",
|
||||
"sourceSaveFailed": "保存更新源失败:{{reason}}",
|
||||
"adoptHint": "这个目录还不是 git 工作树。接入上面的更新源后,它就成为可一键更新的正常安装。",
|
||||
"adoptNeedsSource": "先在上面填入仓库地址并保存,然后才能接入。",
|
||||
"adoptPreviewBtn": "预览并接入",
|
||||
"adoptIncoming": "目标分支文件数",
|
||||
"adoptOverwrittenTotal": "将被目标版本替换",
|
||||
"adoptProtectedTotal": "运维者内容(保留)",
|
||||
"adoptCommit": "目标提交",
|
||||
"adoptOverwrittenTitle": "将被替换的本机文件",
|
||||
"adoptProtectedTitle": "会保留的运维者内容",
|
||||
"adoptBackupNote": "被替换的文件全部备份在 .update-backup/<时间戳>/overwritten/ 下,可从那里找回。",
|
||||
"adoptConfirmBtn": "确认接入",
|
||||
"adoptConfirmText": "接入会把 {{overwritten}} 个本机文件替换成目标版本(共 {{incoming}} 个文件;{{protectedFiles}} 个运维者文件保留)。确定继续?",
|
||||
"adoptAccepted": "接入已开始(任务 {{id}}),进度每 1.5 秒刷新。",
|
||||
"adoptFailed": "接入失败:{{reason}}",
|
||||
"adoptedNoPreviousCommit": "这是「接入」结果:接入前没有 git 历史,因此没有上一提交可回滚;下一次更新会写下第一个回滚点。",
|
||||
"resultOverwrittenTitle": "被替换的本机文件(备份在 overwritten/ 下)",
|
||||
"rollbackItems": "未能回滚的文件"
|
||||
},
|
||||
"nav": {
|
||||
|
||||
@@ -13,7 +13,24 @@ 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'];
|
||||
'updateRestartChoiceChanged', 'loadUpdateConsole',
|
||||
'saveUpdateSource', 'previewAdoptSource', 'confirmAdoptSource'];
|
||||
|
||||
// The envelope shape GET /api/system/update answers with, plus what the source endpoints return.
|
||||
const emptySource = { remote: '', remoteUrl: '', branch: '', configured: false };
|
||||
const configuredSource = { remote: '', remoteUrl: 'https://github.com/Sycun/CyberStrikeAI.git', branch: 'main', configured: true };
|
||||
const adoptPlan = {
|
||||
root: '/srv/csai',
|
||||
source: 'https://github.com/Sycun/CyberStrikeAI.git',
|
||||
branch: 'main',
|
||||
commit: 'abc1234',
|
||||
subject: 'target head',
|
||||
incoming: 42,
|
||||
overwrittenTotal: 2,
|
||||
overwritten: ['web/static/js/a.js', 'web/static/js/b.js'],
|
||||
protectedTotal: 1,
|
||||
protected: ['roles/我的角色.yaml'],
|
||||
};
|
||||
|
||||
function flatKeys(obj, prefix, out) {
|
||||
Object.keys(obj).forEach(k => {
|
||||
@@ -64,9 +81,14 @@ 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 };
|
||||
// The shape is the endpoint's own: {status, job, canRestart, source}. job is null until an update ran.
|
||||
function envelope(status, job, canRestart, source) {
|
||||
return {
|
||||
status: status || statusOf(),
|
||||
job: job === undefined ? null : job,
|
||||
canRestart: canRestart !== false,
|
||||
source: source || emptySource,
|
||||
};
|
||||
}
|
||||
|
||||
const runningJob = {
|
||||
@@ -148,11 +170,13 @@ function harness(options) {
|
||||
}
|
||||
|
||||
const responses = Object.assign({
|
||||
'GET /api/system/update': [{ status: 200, body: envelope(opts.status, opts.job, opts.canRestart) }],
|
||||
'GET /api/system/update': [{ status: 200, body: envelope(opts.status, opts.job, opts.canRestart, opts.source) }],
|
||||
'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' } } }],
|
||||
'POST /api/system/update/source': [{ status: 200, body: { source: opts.source || emptySource } }],
|
||||
'POST /api/system/update/adopt': [{ status: 200, body: { plan: adoptPlan } }],
|
||||
}, opts.responses || {});
|
||||
|
||||
const sandbox = {
|
||||
@@ -982,3 +1006,63 @@ test('the hint describes the automatic check instead of denying any network use'
|
||||
assert.match(text, /自动|automatic|automatically|автоматическ/, label + ' must mention the automatic check');
|
||||
}
|
||||
});
|
||||
|
||||
test('the source section shows what is in effect, and saving posts all three fields', async () => {
|
||||
const h = await harness({ status: statusOf({ installed: true }), source: configuredSource });
|
||||
assert.match(h.html(), /https:\/\/github\.com\/Sycun\/CyberStrikeAI\.git/,
|
||||
'the configured address must be visible');
|
||||
assert.match(h.html(), /update-source-save-btn/);
|
||||
|
||||
h.runHandler("updateSourceFieldChanged('branch', 'release')");
|
||||
await h.fire('saveUpdateSource');
|
||||
await h.flush();
|
||||
const save = h.calls.find(c => c.url === '/api/system/update/source');
|
||||
assert.ok(save, 'saving must hit the source endpoint');
|
||||
assert.deepEqual(JSON.parse(save.body), {
|
||||
remote: '', remoteUrl: 'https://github.com/Sycun/CyberStrikeAI.git', branch: 'release',
|
||||
});
|
||||
assert.ok(h.toasts.some(t => t.type === 'success'), 'a saved source must be confirmed');
|
||||
});
|
||||
|
||||
test('a refused save shows the server reason instead of a generic failure', async () => {
|
||||
const refusal = '远端名与远端地址二选一:要么指名已有远端,要么直接给地址';
|
||||
const h = await harness({
|
||||
status: statusOf({ installed: true }),
|
||||
source: configuredSource,
|
||||
responses: {
|
||||
'POST /api/system/update/source': [{ status: 400, body: { error: refusal } }],
|
||||
},
|
||||
});
|
||||
h.runHandler("updateSourceFieldChanged('remote', 'origin')");
|
||||
await h.fire('saveUpdateSource');
|
||||
await h.flush();
|
||||
assert.match(h.html(), /二选一/, 'the refusal must be on screen');
|
||||
assert.ok(h.toasts.some(t => t.type === 'error' && t.msg.includes('二选一')), 'and in a toast');
|
||||
assert.match(h.html(), /origin/, 'the typed value must survive the failed save');
|
||||
});
|
||||
|
||||
test('the adopt entry appears only for a directory that is not a git installation', async () => {
|
||||
const notGit = await harness({ status: statusOf({ installed: false }), source: configuredSource });
|
||||
assert.match(notGit.html(), /previewAdoptSource\(\)/, 'a configured non-git tree must offer connecting');
|
||||
|
||||
const gitTree = await harness({ status: statusOf({ installed: true }), source: configuredSource });
|
||||
assert.doesNotMatch(gitTree.html(), /previewAdoptSource\(\)/, 'a git installation updates, it does not adopt');
|
||||
});
|
||||
|
||||
test('adopt previews first, and confirming carries the counts into the dialog', async () => {
|
||||
const h = await harness({ status: statusOf({ installed: false }), source: configuredSource });
|
||||
await h.fire('previewAdoptSource');
|
||||
await h.flush();
|
||||
assert.match(h.html(), /abc1234/, 'the target commit must be on screen');
|
||||
assert.match(h.html(), /web\/static\/js\/a\.js/, 'the files to be replaced must be listed');
|
||||
assert.match(h.html(), /roles\/我的角色\.yaml/, 'and the operator content that is kept');
|
||||
|
||||
await h.fire('confirmAdoptSource');
|
||||
await h.flush();
|
||||
assert.strictEqual(h.confirms.length, 1, 'connecting must ask first');
|
||||
assert.match(h.confirms[0], /42/, 'the dialog counts what will happen');
|
||||
const adopt = h.calls.find(c => c.url === '/api/system/update/adopt' && c.method === 'POST' && c.body && c.body.includes('"confirm":true'));
|
||||
assert.ok(adopt, 'confirming must post to adopt');
|
||||
assert.deepEqual(JSON.parse(adopt.body), { confirm: true, restart: false });
|
||||
assert.ok(h.vars.getPollTimer(), 'the job it started must be polled');
|
||||
});
|
||||
@@ -12,6 +12,8 @@ const UPDATE_FAILURE_KEYS = {
|
||||
apply: 'applyFailed',
|
||||
rollback: 'rollbackFailed',
|
||||
load: 'loadFailed',
|
||||
source: 'sourceSaveFailed',
|
||||
adopt: 'adoptFailed',
|
||||
};
|
||||
|
||||
let updateConsoleState = null; // GET /api/system/update 的整包:{status, job, canRestart}
|
||||
@@ -21,12 +23,33 @@ let updateChecking = false; // 只影响「检查更新」按钮的措辞
|
||||
let updatePollTimer = null;
|
||||
let updateRestartChoice = false; // 勾选框要在轮询重绘之后仍然是勾着的
|
||||
let updateAutoCheckDone = false; // 每个会话只自动查一次远端,不每次重绘都去打扰
|
||||
let updateSourceDraft = null; // 编辑中的更新源;null=还贴着服务端保存的值
|
||||
let updateSourceError = ''; // 最近一次保存/接入失败的原因,画在区块里
|
||||
let updateAdoptPlan = null; // 预览回来的接入计划;确认后清掉
|
||||
|
||||
function updateT(key, opts) {
|
||||
const k = 'update.' + key;
|
||||
return typeof window.t === 'function' ? window.t(k, opts) : k;
|
||||
}
|
||||
|
||||
function updateSourceOf() {
|
||||
return (updateConsoleState && updateConsoleState.source) || { remote: '', remoteUrl: '', branch: '', configured: false };
|
||||
}
|
||||
|
||||
// 草稿优先:轮询重绘不许把正在输入的地址冲掉;保存成功后草稿丢弃、回到服务端的值。
|
||||
function updateSourceField(field) {
|
||||
if (updateSourceDraft && typeof updateSourceDraft[field] === 'string') return updateSourceDraft[field];
|
||||
return updateSourceOf()[field] || '';
|
||||
}
|
||||
|
||||
function updateSourceFieldChanged(field, value) {
|
||||
if (!updateSourceDraft) {
|
||||
const s = updateSourceOf();
|
||||
updateSourceDraft = { remote: s.remote || '', remoteUrl: s.remoteUrl || '', branch: s.branch || '' };
|
||||
}
|
||||
updateSourceDraft[field] = String(value == null ? '' : value);
|
||||
}
|
||||
|
||||
// scheduleUpdateAutoCheck answers the question the page exists to answer, without making
|
||||
// somebody press a button to find out whether the button would do anything.
|
||||
//
|
||||
@@ -108,6 +131,7 @@ async function fetchUpdateStatus() {
|
||||
status: r.data.status || {},
|
||||
job: r.data.job || null,
|
||||
canRestart: !!r.data.canRestart,
|
||||
source: r.data.source || { remote: '', remoteUrl: '', branch: '', configured: false },
|
||||
};
|
||||
return updateConsoleState;
|
||||
}
|
||||
@@ -362,6 +386,71 @@ async function rollbackUpdate() {
|
||||
}, 'rollback');
|
||||
}
|
||||
|
||||
// 更新源:服务端负责校验与写入(config.yaml 的 update 段),这里只做搬运与说明。
|
||||
async function saveUpdateSource() {
|
||||
if (updateBusy) return;
|
||||
const body = {
|
||||
remote: updateSourceField('remote').trim(),
|
||||
remoteUrl: updateSourceField('remoteUrl').trim(),
|
||||
branch: updateSourceField('branch').trim(),
|
||||
};
|
||||
updateSourceError = '';
|
||||
await withUpdateBusy(async () => {
|
||||
const r = await runUpdateRequest('POST', '/api/system/update/source', body);
|
||||
if (!r.ok) {
|
||||
// 服务端的拒绝文案(二选一、地址不合法……)就是要展示的那句,不在这里另编。
|
||||
updateSourceError = updateT('sourceSaveFailed', { reason: r.error });
|
||||
renderUpdateConsole();
|
||||
notify(updateSourceError, 'error');
|
||||
return;
|
||||
}
|
||||
updateSourceDraft = null;
|
||||
if (updateConsoleState) updateConsoleState.source = r.data.source || updateConsoleState.source;
|
||||
resetUpdateCheck();
|
||||
await fetchUpdateStatus();
|
||||
renderUpdateConsole();
|
||||
notify(updateT('sourceSaveOk'), 'success');
|
||||
}, 'source');
|
||||
}
|
||||
|
||||
// 预览无副作用(服务端在临时仓库里 fetch),所以可以先点、先看清单再决定。
|
||||
async function previewAdoptSource() {
|
||||
if (updateBusy) return;
|
||||
updateSourceError = '';
|
||||
await withUpdateBusy(async () => {
|
||||
const r = await runUpdateRequest('POST', '/api/system/update/adopt', { confirm: false });
|
||||
if (!r.ok) {
|
||||
updateSourceError = updateT('adoptFailed', { reason: r.error });
|
||||
renderUpdateConsole();
|
||||
notify(updateSourceError, 'error');
|
||||
return;
|
||||
}
|
||||
updateAdoptPlan = r.data.plan || null;
|
||||
renderUpdateConsole();
|
||||
}, 'adopt');
|
||||
}
|
||||
|
||||
async function confirmAdoptSource() {
|
||||
if (updateBusy || !updateAdoptPlan) return;
|
||||
const restart = updateRestartRequested();
|
||||
const text = updateT('adoptConfirmText', {
|
||||
incoming: updateAdoptPlan.incoming,
|
||||
overwritten: updateAdoptPlan.overwrittenTotal,
|
||||
protectedFiles: updateAdoptPlan.protectedTotal,
|
||||
});
|
||||
if (!window.confirm(text)) return;
|
||||
|
||||
await withUpdateBusy(async () => {
|
||||
const r = await runUpdateRequest('POST', '/api/system/update/adopt', { confirm: true, restart: restart });
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
updateAdoptPlan = null;
|
||||
resetUpdateCheck();
|
||||
startUpdatePolling();
|
||||
notify(updateT('adoptAccepted', { id: r.data.job_id || '-' }), 'success');
|
||||
await pollUpdateJob();
|
||||
}, 'adopt');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 渲染
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -387,6 +476,78 @@ function updateMark(ok, yesKey, noKey, reason) {
|
||||
escapeHtml(ok ? updateT(yesKey) : updateT(noKey)) + '</span>';
|
||||
}
|
||||
|
||||
function renderUpdateSourceBody(status) {
|
||||
const src = updateSourceOf();
|
||||
const parts = [];
|
||||
parts.push('<div class="update-hint">' + escapeHtml(src.configured ? updateT('sourceFromConfig') : updateT('sourceFromTracked')) + '</div>');
|
||||
if (status.remoteUrl) {
|
||||
parts.push('<div class="update-facts">' + updateFact(updateT('sourceUrl'), status.remoteUrl) + '</div>');
|
||||
}
|
||||
if (status.remote) {
|
||||
parts.push('<div class="update-facts">' + updateFact(updateT('sourceRemote'), status.remote) + '</div>');
|
||||
}
|
||||
if (status.branch) {
|
||||
parts.push('<div class="update-facts">' + updateFact(updateT('sourceBranch'), status.branch) + '</div>');
|
||||
}
|
||||
// 手输错误属于页面自己的状态,重绘(轮询)不能把它冲掉。
|
||||
const disabled = updateBusy ? ' disabled data-state-disabled="true"' : '';
|
||||
parts.push('<div class="update-source-form">' +
|
||||
'<label>' + escapeHtml(updateT('sourceUrlLabel')) +
|
||||
'<input type="text" class="update-source-input update-source-url" value="' + escapeAttr(updateSourceField('remoteUrl')) +
|
||||
'" placeholder="https://github.com/owner/repo.git" oninput="updateSourceFieldChanged(\'remoteUrl\', this.value)"></label>' +
|
||||
'<label>' + escapeHtml(updateT('sourceRemoteLabel')) +
|
||||
'<input type="text" class="update-source-input update-source-remote" value="' + escapeAttr(updateSourceField('remote')) +
|
||||
'" placeholder="origin" oninput="updateSourceFieldChanged(\'remote\', this.value)"></label>' +
|
||||
'<label>' + escapeHtml(updateT('sourceBranchLabel')) +
|
||||
'<input type="text" class="update-source-input update-source-branch" value="' + escapeAttr(updateSourceField('branch')) +
|
||||
'" placeholder="main" oninput="updateSourceFieldChanged(\'branch\', this.value)"></label>' +
|
||||
'<div class="update-actions"><button class="btn-secondary update-source-save-btn"' + disabled +
|
||||
' data-require-permission="update:apply" onclick="saveUpdateSource()">' +
|
||||
escapeHtml(updateT('sourceSaveBtn')) + '</button></div></div>');
|
||||
if (updateSourceError) {
|
||||
parts.push('<div class="update-error">' + escapeHtml(updateSourceError) + '</div>');
|
||||
}
|
||||
if (!status.installed) {
|
||||
parts.push(renderAdoptBody());
|
||||
}
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
function renderAdoptBody() {
|
||||
const typedUrl = updateSourceField('remoteUrl') || updateSourceField('remote');
|
||||
if (!updateSourceOf().configured && !typedUrl) {
|
||||
return '<div class="update-hint">' + escapeHtml(updateT('adoptNeedsSource')) + '</div>';
|
||||
}
|
||||
const parts = [];
|
||||
parts.push('<div class="update-hint">' + escapeHtml(updateT('adoptHint')) + '</div>');
|
||||
const disabled = updateBusy ? ' disabled data-state-disabled="true"' : '';
|
||||
if (!updateAdoptPlan) {
|
||||
parts.push('<div class="update-actions"><button class="btn-primary update-adopt-preview-btn"' + disabled +
|
||||
' data-require-permission="update:apply" onclick="previewAdoptSource()">' +
|
||||
escapeHtml(updateT('adoptPreviewBtn')) + '</button></div>');
|
||||
return parts.join('');
|
||||
}
|
||||
const plan = updateAdoptPlan;
|
||||
parts.push('<div class="update-facts">' +
|
||||
updateFact(updateT('adoptIncoming'), plan.incoming) +
|
||||
updateFact(updateT('adoptOverwrittenTotal'), plan.overwrittenTotal) +
|
||||
updateFact(updateT('adoptProtectedTotal'), plan.protectedTotal) +
|
||||
updateFact(updateT('sourceBranch'), plan.branch) +
|
||||
updateFact(updateT('adoptCommit'), plan.commit) + '</div>');
|
||||
const list = (items, title) => items && items.length
|
||||
? '<h4 class="update-sub-title">' + escapeHtml(title) + '</h4><ul class="update-commit-list">' +
|
||||
items.map(p => '<li><code title="' + escapeAttr(String(p)) + '">' + escapeHtml(String(p)) + '</code></li>').join('') + '</ul>'
|
||||
: '';
|
||||
parts.push(list(plan.overwritten, updateT('adoptOverwrittenTitle')));
|
||||
parts.push(list(plan.protected, updateT('adoptProtectedTitle')));
|
||||
parts.push('<div class="update-hint">' + escapeHtml(updateT('adoptBackupNote')) + '</div>');
|
||||
parts.push(renderUpdateRestartChoice());
|
||||
parts.push('<div class="update-actions"><button class="btn-primary update-adopt-confirm-btn"' + disabled +
|
||||
' data-require-permission="update:apply" onclick="confirmAdoptSource()">' +
|
||||
escapeHtml(updateT('adoptConfirmBtn')) + '</button></div>');
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
function renderUpdateInstallBody(status) {
|
||||
const facts = [
|
||||
updateFact(updateT('root'), status.root),
|
||||
@@ -566,9 +727,19 @@ function renderUpdateResult(result) {
|
||||
? '<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>';
|
||||
const replaced = result.overwritten || [];
|
||||
const replacedList = replaced.length
|
||||
? '<h4 class="update-sub-title">' + escapeHtml(updateT('resultOverwrittenTitle')) + '</h4>' +
|
||||
'<ul class="update-kept-list">' + replaced.map(p =>
|
||||
'<li><code title="' + escapeAttr(String(p)) + '">' + escapeHtml(String(p)) + '</code></li>').join('') + '</ul>'
|
||||
: '';
|
||||
const adoptedNote = result.adopted
|
||||
? '<div class="update-hint">' + escapeHtml(updateT('adoptedNoPreviousCommit')) + '</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>' +
|
||||
adoptedNote + replacedList +
|
||||
'<h4 class="update-sub-title">' + escapeHtml(updateT('keptTitle')) + '</h4>' + keptList +
|
||||
'</div>';
|
||||
}
|
||||
@@ -628,6 +799,7 @@ function renderUpdateConsole() {
|
||||
|
||||
const parts = [];
|
||||
parts.push(updateSection(updateT('installTitle'), renderUpdateInstallBody(status)));
|
||||
parts.push(updateSection(updateT('sourceTitle'), renderUpdateSourceBody(status)));
|
||||
parts.push(updateSection(updateT('remoteTitle'), renderUpdateRemoteBody(status)));
|
||||
parts.push(updateSection(updateT('applyTitle'), renderUpdateApplyBody(status, job)));
|
||||
parts.push(updateSection(updateT('jobTitle'), renderUpdateJobBody(job)));
|
||||
@@ -646,3 +818,7 @@ window.rollbackUpdate = rollbackUpdate;
|
||||
window.updateRestartChoiceChanged = updateRestartChoiceChanged;
|
||||
window.stopUpdatePolling = stopUpdatePolling;
|
||||
window.renderUpdateConsole = renderUpdateConsole;
|
||||
window.saveUpdateSource = saveUpdateSource;
|
||||
window.previewAdoptSource = previewAdoptSource;
|
||||
window.confirmAdoptSource = confirmAdoptSource;
|
||||
window.updateSourceFieldChanged = updateSourceFieldChanged;
|
||||
Reference in new issue
Block a user