// 控制台「一键更新」:这一页动的就是当前正在应答请求的那套安装树本身。
// 三条硬规矩,每一条都是这个仓库已经踩过一次的坑:
// 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',
source: 'sourceSaveFailed',
adopt: 'adoptFailed',
};
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; // 每个会话只自动查一次远端,不每次重绘都去打扰
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.
//
// 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-settings');
const section = document.getElementById('settings-section-update');
return !!(page && page.classList.contains('active') &&
section && section.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,
source: r.data.source || { remote: '', remoteUrl: '', branch: '', configured: false },
};
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', '
' + escapeHtml(text) + '
');
}
// 内联横幅会被下一次轮询重绘冲掉,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 = '' + escapeHtml(updateT('loading')) + '
';
try {
await fetchUpdateStatus();
} catch (err) {
el.innerHTML = '' +
escapeHtml(updateT(UPDATE_FAILURE_KEYS.load, { reason: err.message })) + '
';
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');
}
// 更新源:服务端负责校验与写入(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');
}
// ---------------------------------------------------------------------------
// 渲染
// ---------------------------------------------------------------------------
function updateSection(title, body) {
return '' +
escapeHtml(title) + ' ' + body + ' ';
}
// 值既进文本也进 title(长路径要靠 title 看全),所以属性那一份必须走 escapeAttr。
function updateFact(label, value) {
const text = value == null || value === '' ? '-' : String(value);
return '' + escapeHtml(label) + ' ' +
'' + escapeHtml(text) + '
';
}
function updateMark(ok, yesKey, noKey, reason) {
const cls = ok ? 'update-chip update-chip-ok' : 'update-chip update-chip-warn';
if (!reason) {
return '' + escapeHtml(ok ? updateT(yesKey) : updateT(noKey)) + ' ';
}
return '' +
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('' +
escapeHtml(updateT('adoptPreviewBtn')) + '
');
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('' +
escapeHtml(updateT('adoptConfirmBtn')) + '
');
return parts.join('');
}
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 '' + facts + '
' + marks + '
';
}
function renderUpdateRemoteBody(status) {
const failure = updateCheck.error || status.checkError || '';
const parts = [];
if (failure) {
// 这条分支就是"不许说谎"的落点:有原因就只写原因,不提"已是最新"。
parts.push('' +
escapeHtml(updateT('checkFailed', { reason: failure })) + '
');
} else if (!updateCheck.done) {
parts.push('' + escapeHtml(updateT('notChecked')) + '
');
} else if (status.updateAvailable) {
parts.push('' +
escapeHtml(updateT('behindCount', { n: updateBehindCount(status) })) + '
');
} else {
parts.push('' +
escapeHtml(updateT('upToDate', { commit: status.commit || '-' })) + '
');
}
if (status.ahead > 0) {
parts.push('' + escapeHtml(updateT('aheadCount', { n: status.ahead })) + '
');
}
if (status.remoteCommit) {
parts.push('' +
updateFact(updateT('remoteHead'), status.remoteCommit) +
updateFact(updateT('remoteSubject'), status.remoteSubject) + '
');
}
const incoming = status.incoming || [];
if (incoming.length) {
// incoming 是被截断过的清单,incomingTotal 才是真实数量,两个都得说。
const total = status.incomingTotal || incoming.length;
parts.push('' + escapeHtml(updateT('incomingTitle')) + ' ');
if (total > incoming.length) {
parts.push('' +
escapeHtml(updateT('incomingTruncated', { total: total, shown: incoming.length })) + '
');
}
parts.push('' + incoming.map(c =>
'' + escapeHtml(String(c.commit || '-')) +
' ' + escapeHtml(String(c.subject || '')) + ' '
).join('') + ' ');
}
return parts.join('');
}
function renderUpdateChangesTable(list) {
// 行底色就是"这个文件会不会被更新覆盖"的分区:本机内容绿、产品源码红。
const rows = (list || []).map(c => '' +
'' + escapeHtml(String(c.path || '-')) + ' ' +
'' + escapeHtml(String(c.status || '-')) + ' ' +
'' + escapeHtml(c.protected ? updateT('keptFile') : updateT('productSource')) + ' ').join('');
return '' +
'' + escapeHtml(updateT('colPath')) + ' ' +
'' + escapeHtml(updateT('colChangeStatus')) + ' ' +
'' + escapeHtml(updateT('colOwner')) + ' ' +
' ' + rows + '
';
}
function renderUpdateRestartChoice() {
if (!updateCanRestart()) {
// 后端没给重启钩子,勾选框就不该出现:勾一个不会被兑现的选项是骗人。
return '' + escapeHtml(updateT('restartNotWired')) + '
';
}
const preview = updateRestartChoice ? updateT('confirmRestartYes') : updateT('confirmRestartNo');
return '' +
' ' +
'' + escapeHtml(updateT('restartChoiceLabel')) + ' ' +
'' + escapeHtml(preview) + '
';
}
function renderUpdateApplyBody(status, job) {
const blockers = updateApplyBlockers(status, job);
const blocked = blockers.length > 0;
const parts = [];
if (job && job.state === 'running') {
parts.push('' + escapeHtml(updateT('jobRunningHint')) + '
');
}
if (!status.canBuild && status.installed) {
parts.push('' + escapeHtml(updateT('noToolchainWarn')) + '
');
}
// 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('' +
'' +
escapeHtml(updateT('applyBtn')) + ' ' +
'' +
escapeHtml(updateChecking ? updateT('checking') : updateT('checkBtn')) + ' ' +
'
');
parts.push(renderUpdateRestartChoice());
if (blocked) {
parts.push('' + blockers.map(b =>
'' + escapeHtml(b) + ' ').join('') + ' ');
}
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('' + escapeHtml(updateT('localChangesTitle')) + ' ');
parts.push('' + escapeHtml(updateT('localChangesHint')) + '
');
parts.push(renderUpdateChangesTable(listed));
} else {
parts.push('' + escapeHtml(updateT('noLocalChanges')) + '
');
}
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 '' + escapeHtml(label) + ' ';
}
function renderUpdateStep(step) {
return '' +
'' + escapeHtml(String(step.phase || '-')) + '' +
'' + escapeHtml(String(step.message || '')) + ' ' +
'' + escapeHtml(String(step.at || '')) + ' ';
}
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
? '' + 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 +
'
';
}
function renderUpdateFailure(failure) {
if (!failure) return '';
const items = failure.items || [];
const reason = failure.reason ? '(' + escapeHtml(String(failure.reason)) + ')' : '';
return '' +
'
' + escapeHtml(updateT('failureTitle')) + reason + '
' +
'
' +
escapeHtml(String(failure.message || updateT('failureNoMessage'))) + '
' +
(items.length
? '
' + items.map(i =>
'' + escapeHtml(String(i)) + ' ').join('') + ' '
: '') +
'
';
}
function renderUpdateJobBody(job) {
if (!job) return '' + escapeHtml(updateT('noJob')) + '
';
const parts = ['' + updateStateChip(job.state) +
'' + escapeHtml(String(job.id || '-')) + '' +
(job.started ? '' + escapeHtml(String(job.started)) + ' ' : '') +
(job.restartRequested ? '' + escapeHtml(updateT('restartRequested')) + ' ' : '') +
'
'];
const steps = job.steps || [];
parts.push(steps.length
? '' + steps.map(renderUpdateStep).join('') + ' '
: '' + escapeHtml(updateT('noSteps')) + '
');
if (job.state === 'running') {
parts.push('' +
escapeHtml(updateT('pollingHint', { sec: UPDATE_POLL_INTERVAL_MS / 1000 })) + '
');
}
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 '' + escapeHtml(updateT('noRollback')) + '
';
}
const running = isUpdateJobRunning();
return '' +
escapeHtml(updateT('rollbackTarget', { commit: status.rollbackTo || '-' })) + '
' +
'' + escapeHtml(updateT('rollbackBtn')) + '
';
}
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('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)));
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;
window.saveUpdateSource = saveUpdateSource;
window.previewAdoptSource = previewAdoptSource;
window.confirmAdoptSource = confirmAdoptSource;
window.updateSourceFieldChanged = updateSourceFieldChanged;