diff --git a/README.md b/README.md index ad3385f0..c1fc2cfc 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/README_CN.md b/README_CN.md index 5518897c..5ede15d5 100644 --- a/README_CN.md +++ b/README_CN.md @@ -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` 字段后重启服务。 diff --git a/docs/en-US/deployment.md b/docs/en-US/deployment.md index 4a324e5d..7f16fd46 100644 --- a/docs/en-US/deployment.md +++ b/docs/en-US/deployment.md @@ -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//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: diff --git a/docs/en-US/developer-guide.md b/docs/en-US/developer-guide.md index 7f7d5de3..0adf87d2 100644 --- a/docs/en-US/developer-guide.md +++ b/docs/en-US/developer-guide.md @@ -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//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 | diff --git a/docs/zh-CN/deployment.md b/docs/zh-CN/deployment.md index 5cd929d5..d227b9bd 100644 --- a/docs/zh-CN/deployment.md +++ b/docs/zh-CN/deployment.md @@ -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`(还没有二进制时先 diff --git a/docs/zh-CN/developer-guide.md b/docs/zh-CN/developer-guide.md index e7fe0932..2ba69d4c 100644 --- a/docs/zh-CN/developer-guide.md +++ b/docs/zh-CN/developer-guide.md @@ -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 | 含义与处理 | diff --git a/web/static/css/style.css b/web/static/css/style.css index adcdf321..f78c8ee1 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -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; +} diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index e1b0ebd1..4e6af6ad 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -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//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": { diff --git a/web/static/i18n/ru-RU.json b/web/static/i18n/ru-RU.json index bde1c7e0..dfcba373 100644 --- a/web/static/i18n/ru-RU.json +++ b/web/static/i18n/ru-RU.json @@ -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": { diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 6128c48a..59712d2e 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -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": { diff --git a/web/static/js/update-ui.test.cjs b/web/static/js/update-ui.test.cjs index 172046c4..e0d9013e 100644 --- a/web/static/js/update-ui.test.cjs +++ b/web/static/js/update-ui.test.cjs @@ -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'); +}); diff --git a/web/static/js/update.js b/web/static/js/update.js index 0e656342..5e2f717f 100644 --- a/web/static/js/update.js +++ b/web/static/js/update.js @@ -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)) + ''; } +function renderUpdateSourceBody(status) { + const src = updateSourceOf(); + const parts = []; + parts.push('
' + escapeHtml(src.configured ? updateT('sourceFromConfig') : updateT('sourceFromTracked')) + '
'); + if (status.remoteUrl) { + parts.push('
' + updateFact(updateT('sourceUrl'), status.remoteUrl) + '
'); + } + if (status.remote) { + parts.push('
' + updateFact(updateT('sourceRemote'), status.remote) + '
'); + } + if (status.branch) { + parts.push('
' + updateFact(updateT('sourceBranch'), status.branch) + '
'); + } + // 手输错误属于页面自己的状态,重绘(轮询)不能把它冲掉。 + const disabled = updateBusy ? ' disabled data-state-disabled="true"' : ''; + parts.push('
' + + '' + + '' + + '' + + '
'); + if (updateSourceError) { + parts.push('
' + escapeHtml(updateSourceError) + '
'); + } + if (!status.installed) { + parts.push(renderAdoptBody()); + } + return parts.join(''); +} + +function renderAdoptBody() { + const typedUrl = updateSourceField('remoteUrl') || updateSourceField('remote'); + if (!updateSourceOf().configured && !typedUrl) { + return '
' + escapeHtml(updateT('adoptNeedsSource')) + '
'; + } + const parts = []; + parts.push('
' + escapeHtml(updateT('adoptHint')) + '
'); + const disabled = updateBusy ? ' disabled data-state-disabled="true"' : ''; + if (!updateAdoptPlan) { + parts.push('
'); + return parts.join(''); + } + const plan = updateAdoptPlan; + parts.push('
' + + 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) + '
'); + const list = (items, title) => items && items.length + ? '

' + escapeHtml(title) + '

    ' + + items.map(p => '
  • ' + escapeHtml(String(p)) + '
  • ').join('') + '
' + : ''; + parts.push(list(plan.overwritten, updateT('adoptOverwrittenTitle'))); + parts.push(list(plan.protected, updateT('adoptProtectedTitle'))); + parts.push('
' + escapeHtml(updateT('adoptBackupNote')) + '
'); + parts.push(renderUpdateRestartChoice()); + parts.push('
'); + return parts.join(''); +} + function renderUpdateInstallBody(status) { const facts = [ updateFact(updateT('root'), status.root), @@ -566,9 +727,19 @@ function renderUpdateResult(result) { ? '
    ' + kept.map(p => '
  • ' + escapeHtml(String(p)) + '
  • ').join('') + '
' : '
' + escapeHtml(updateT('nothingKept')) + '
'; + const replaced = result.overwritten || []; + const replacedList = replaced.length + ? '

' + escapeHtml(updateT('resultOverwrittenTitle')) + '

' + + '
    ' + replaced.map(p => + '
  • ' + escapeHtml(String(p)) + '
  • ').join('') + '
' + : ''; + const adoptedNote = result.adopted + ? '
' + escapeHtml(updateT('adoptedNoPreviousCommit')) + '
' + : ''; return '
' + '

' + escapeHtml(updateT('resultTitle')) + '

' + '
' + facts + '
' + marks + '
' + + adoptedNote + replacedList + '

' + escapeHtml(updateT('keptTitle')) + '

' + keptList + '
'; } @@ -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;