mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-10-20 22:41:23 +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
@@ -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