/**
* 服务管理页面
* 服务启停 + 更新检测 + 配置备份管理
*/
import { api } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
import { showConfirm, showModal, showUpgradeModal } from '../components/modal.js'
import { isMacPlatform, isInDocker, setUpgrading, setUserStopped, resetAutoRestart } from '../lib/app-state.js'
import { isForeignGatewayError, isForeignGatewayService, maybeShowForeignGatewayBindingPrompt, showGatewayConflictGuidance } from '../lib/gateway-ownership.js'
import { diagnoseInstallError } from '../lib/error-diagnosis.js'
import { icon, statusIcon } from '../lib/icons.js'
import { t } from '../lib/i18n.js'
// HTML 转义,防止 XSS
function escapeHtml(str) {
if (!str) return ''
return String(str)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
}
export async function render() {
const page = document.createElement('div')
page.className = 'page'
page.innerHTML = `
${t('services.dockerManager')}
${t('services.dockerManagerHint')}
${t('services.configEditor')}
${t('services.configEditorHint')}
${t('services.configCalibration')}
${t('services.configCalibrationHint')}
${t('services.calibrateInheritHint')}
${t('services.calibrateResetHint')}
${t('services.configBackup')}
${t('services.configBackupHint')}
`
bindEvents(page)
loadAll(page)
return page
}
async function loadAll(page) {
const tasks = [loadVersion(page), loadServices(page), loadDockerManager(page), loadBackups(page), loadConfigEditor(page)]
await Promise.all(tasks)
}
// ===== 版本检测 =====
// 后端检测到的当前安装源
let detectedSource = 'chinese'
let lastVersionInfo = null
async function loadVersion(page) {
const bar = page.querySelector('#version-bar')
try {
const [info, panelConfig] = await Promise.all([
api.getVersionInfo(),
api.readPanelConfig().catch(() => ({})),
])
lastVersionInfo = info
detectedSource = info.source || 'chinese'
const ver = info.current || t('common.unknown')
const hasRecommended = !!info.recommended
const aheadOfRecommended = !!info.current && hasRecommended && !!info.ahead_of_recommended
const driftFromRecommended = !!info.current && hasRecommended && !info.is_recommended && !aheadOfRecommended
const isChinese = detectedSource === 'chinese'
const sourceTag = isChinese ? t('services.chineseEdition') : t('services.officialEdition')
const switchLabel = isChinese ? t('services.switchToOfficial') : t('services.switchToChinese')
const switchTarget = isChinese ? 'official' : 'chinese'
const dockerImage = (panelConfig?.dockerDefaultImage || '').trim() || 'ghcr.io/qingchencloud/openclaw'
const policyNote = aheadOfRecommended
? t('services.policyAhead', { ver, recommended: info.recommended })
: t('services.policyDefault')
if (isInDocker()) {
bar.innerHTML = `
${ver}
${info.latest_update_available ? t('services.latestUpstream', { version: info.latest }) + '(' + t('services.pullNewImage') + ')' : t('services.currentImageVer')}
${info.latest_update_available ? `
${escapeHtml(`docker pull ${dockerImage}:latest`)}
` : ''}
`
} else {
bar.innerHTML = `
${ver}
${hasRecommended
? (aheadOfRecommended ? t('services.aheadOfRecommended', { version: info.recommended }) : driftFromRecommended ? t('services.recommendedStable', { version: info.recommended }) : t('services.alignedRecommended', { version: info.recommended }))
: t('services.noRecommended')}
${info.latest_update_available && info.latest ? ' · ' + t('services.latestUpstream', { version: info.latest }) : ''}
${aheadOfRecommended ? `` : driftFromRecommended ? `` : ''}
${policyNote}
`
}
} catch (e) {
bar.innerHTML = `${t('services.versionLoadFailed')}
`
}
}
function configuredDockerImage(panelConfig) {
return (panelConfig?.dockerDefaultImage || '').trim() || 'ghcr.io/qingchencloud/openclaw'
}
function formatDockerBytes(bytes) {
const value = Number(bytes || 0)
if (!Number.isFinite(value) || value <= 0) return '0 B'
if (value >= 1024 * 1024 * 1024) return `${(value / (1024 * 1024 * 1024)).toFixed(1)} GB`
if (value >= 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(1)} MB`
if (value >= 1024) return `${(value / 1024).toFixed(1)} KB`
return `${value} B`
}
function parseOptionalPort(value) {
const raw = String(value || '').trim()
if (!raw) return null
const num = Number(raw)
if (!Number.isInteger(num) || num < 1 || num > 65535) throw new Error(t('services.invalidPort', { value: raw }))
return num
}
async function hasDockerManagerBackend() {
try {
const resp = await fetch('/__api/health', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
})
const ct = (resp.headers.get('content-type') || '').toLowerCase()
return resp.ok && !ct.includes('text/html') && !ct.includes('text/plain')
} catch {
return false
}
}
async function loadDockerManager(page) {
const bar = page.querySelector('#docker-manager-bar')
if (!bar) return
const backendReady = await hasDockerManagerBackend()
if (!backendReady) {
bar.innerHTML = `${t('services.dockerManagerUnavailable')}
`
return
}
try {
const [overview, panelConfig] = await Promise.all([
api.dockerClusterOverview(),
api.readPanelConfig().catch(() => ({})),
])
const totalNodes = overview.length
const onlineNodes = overview.filter(node => node.online).length
const totalContainers = overview.reduce((sum, node) => sum + (node.containers?.length || 0), 0)
const runningContainers = overview.reduce((sum, node) => sum + (node.containers?.filter?.(ct => ct.state === 'running').length || 0), 0)
bar.innerHTML = `
${t('services.dockerManager')}
${onlineNodes}/${totalNodes} ${t('services.dockerOnline')} · ${runningContainers}/${totalContainers} ${t('services.dockerContainersLabel')}
${overview.map(node => {
const containers = node.containers || []
const nodeMeta = node.online
? `${escapeHtml(node.endpoint || '')} · Docker ${escapeHtml(node.dockerVersion || t('common.unknown'))} · ${formatDockerBytes(node.memory)} · CPU ${node.cpus || 0}`
: `${escapeHtml(node.endpoint || '')} · ${escapeHtml(node.error || t('services.dockerOffline'))}`
return `
${escapeHtml(node.name)}${node.id === 'local' ? ` ${t('services.dockerLocalNode')}` : ''}
${nodeMeta}
${node.online ? `${t('services.dockerContainersLabel')}: ${node.runningContainers || 0}/${node.totalContainers || containers.length}` : t('services.dockerOffline')}
${node.id !== 'local' ? `` : ''}
${containers.length ? containers.map(ct => `
${escapeHtml(ct.name)}
${escapeHtml(ct.image)} · ${escapeHtml(ct.status || ct.state || t('common.unknown'))}${ct.ports ? ` · ${escapeHtml(ct.ports)}` : ''}
${ct.state === 'running'
? `
`
: ``}
`).join('') : `
${t('services.dockerNoContainers')}
`}
`
}).join('')}
${t('services.dockerDefaultImageHint')} ${escapeHtml(configuredDockerImage(panelConfig))}
`
} catch (e) {
bar.innerHTML = `${t('services.dockerManagerLoadFailed')}: ${escapeHtml(e?.message || e)}
`
}
}
// ===== 服务列表 =====
async function loadServices(page) {
const container = page.querySelector('#services-list')
try {
const services = await api.getServicesStatus()
renderServices(container, services)
const gw = services?.find?.(s => s.label === 'ai.openclaw.gateway') || services?.[0] || null
if (gw) {
maybeShowForeignGatewayBindingPrompt({
service: gw,
onRefresh: () => loadServices(page),
}).catch(() => {})
}
} catch (e) {
container.innerHTML = `${t('services.serviceLoadFailed')}: ${escapeHtml(String(e))}
`
}
}
async function openDockerAddNode(page) {
showModal({
title: t('services.dockerAddNode'),
fields: [
{ name: 'name', label: t('services.dockerNodeName'), value: '', placeholder: 'docker-node-1' },
{ name: 'endpoint', label: t('services.dockerNodeEndpoint'), value: '', placeholder: 'tcp://192.168.1.20:2375' },
],
onConfirm: async ({ name, endpoint }) => {
try {
await api.dockerAddNode((name || '').trim(), (endpoint || '').trim())
toast(t('services.dockerNodeAdded'), 'success')
await loadDockerManager(page)
} catch (e) {
toast(e?.message || e, 'error')
}
},
})
}
async function openDockerPullImage(page) {
const [nodes, panelConfig] = await Promise.all([
api.dockerListNodes(),
api.readPanelConfig().catch(() => ({})),
])
showModal({
title: t('services.dockerPullTitle'),
fields: [
{ name: 'nodeId', type: 'select', label: t('services.dockerNodeName'), value: nodes[0]?.id || 'local', options: nodes.map(node => ({ value: node.id, label: node.name })) },
{ name: 'image', label: t('services.dockerImageLabel'), value: configuredDockerImage(panelConfig), hint: t('services.dockerDefaultImageHint') },
{ name: 'tag', label: t('services.dockerTagLabel'), value: 'latest' },
],
onConfirm: async ({ nodeId, image, tag }) => {
const requestId = `pull-${Date.now()}`
const modal = showUpgradeModal(t('services.dockerPullTitle'))
let lastMessage = ''
const timer = setInterval(async () => {
try {
const status = await api.dockerPullStatus(requestId)
if (Number.isFinite(status?.percent)) modal.setProgress(status.percent)
if (status?.message && status.message !== lastMessage) {
lastMessage = status.message
modal.appendLog(status.message)
}
} catch {}
}, 800)
try {
const result = await api.dockerPullImage({
nodeId: nodeId || null,
image: (image || '').trim() || configuredDockerImage(panelConfig),
tag: (tag || '').trim() || 'latest',
requestId,
})
clearInterval(timer)
modal.setProgress(100)
if (result?.message) modal.appendLog(result.message)
modal.setDone(t('services.dockerPullDone'))
toast(t('services.dockerPullDone'), 'success')
await loadDockerManager(page)
} catch (e) {
clearInterval(timer)
modal.appendLog(e?.message || String(e))
modal.setError(e?.message || String(e))
toast(e?.message || e, 'error')
}
},
})
}
async function openDockerCreateContainer(page) {
const [nodes, panelConfig] = await Promise.all([
api.dockerListNodes(),
api.readPanelConfig().catch(() => ({})),
])
showModal({
title: t('services.dockerCreateTitle'),
fields: [
{ name: 'nodeId', type: 'select', label: t('services.dockerNodeName'), value: nodes[0]?.id || 'local', options: nodes.map(node => ({ value: node.id, label: node.name })) },
{ name: 'name', label: t('services.dockerContainerNameLabel'), value: '', placeholder: 'openclaw-worker-1' },
{ name: 'image', label: t('services.dockerImageLabel'), value: configuredDockerImage(panelConfig), hint: t('services.dockerDefaultImageHint') },
{ name: 'tag', label: t('services.dockerTagLabel'), value: 'latest' },
{ name: 'panelPort', label: t('services.dockerPanelPortLabel'), value: '1420', hint: t('services.dockerPortOptionalHint') },
{ name: 'gatewayPort', label: t('services.dockerGatewayPortLabel'), value: '18789', hint: t('services.dockerPortOptionalHint') },
{ name: 'volume', type: 'checkbox', label: t('services.dockerUseVolume'), value: true },
],
onConfirm: async ({ nodeId, name, image, tag, panelPort, gatewayPort, volume }) => {
try {
await api.dockerCreateContainer({
nodeId: nodeId || null,
name: (name || '').trim() || undefined,
image: (image || '').trim() || configuredDockerImage(panelConfig),
tag: (tag || '').trim() || 'latest',
panelPort: parseOptionalPort(panelPort),
gatewayPort: parseOptionalPort(gatewayPort),
volume: !!volume,
})
toast(t('services.dockerContainerCreated'), 'success')
await loadDockerManager(page)
} catch (e) {
toast(e?.message || e, 'error')
}
},
})
}
async function handleDockerRemoveNode(btn, page) {
const name = btn.dataset.name || btn.dataset.nodeId || ''
const yes = await showConfirm(t('services.dockerRemoveNodeConfirm', { name }))
if (!yes) return
await api.dockerRemoveNode(btn.dataset.nodeId)
toast(t('services.dockerNodeRemoved'), 'success')
await loadDockerManager(page)
}
async function handleDockerContainerAction(action, btn, page) {
const nodeId = btn.dataset.nodeId || null
const containerId = btn.dataset.containerId
const name = btn.dataset.name || containerId
if (!containerId) throw new Error(t('services.missingContainerId'))
if (action === 'docker-remove-container') {
const yes = await showConfirm(t('services.dockerRemoveContainerConfirm', { name }))
if (!yes) return
await api.dockerRemoveContainer(nodeId, containerId, btn.dataset.running === '1')
toast(t('services.dockerContainerRemoved'), 'success')
await loadDockerManager(page)
return
}
const label = {
'docker-start-container': t('services.start'),
'docker-stop-container': t('services.stop'),
'docker-restart-container': t('services.restart'),
}[action]
const fn = {
'docker-start-container': api.dockerStartContainer,
'docker-stop-container': api.dockerStopContainer,
'docker-restart-container': api.dockerRestartContainer,
}[action]
await fn(nodeId, containerId)
toast(t('services.actionDone', { label: name, action: label }), 'success')
await loadDockerManager(page)
}
async function openGatewayConflict(page, error = null) {
const services = await api.getServicesStatus().catch(() => [])
const gw = services?.find?.(s => s.label === 'ai.openclaw.gateway') || services?.[0] || null
await showGatewayConflictGuidance({
error,
service: gw,
onRefresh: async () => {
await loadVersion(page)
await loadServices(page)
},
})
}
function renderServices(container, services) {
const gw = services.find(s => s.label === 'ai.openclaw.gateway')
let html = ''
if (gw) {
// 检测 CLI 是否安装
const cliMissing = gw.cli_installed === false
const foreignGateway = !cliMissing && isForeignGatewayService(gw)
const foreignPidText = gw.pid ? ` (PID: ${gw.pid})` : ''
html += `
${gw.label}
${cliMissing
? t('services.cliNotInstalled')
: foreignGateway
? t('services.foreignGatewayDesc', { pid: foreignPidText, settings: t('sidebar.settings') })
: (gw.description || '') + (gw.pid ? ' (PID: ' + gw.pid + ')' : '')
}
${cliMissing
? `
${t('services.installCliHint')}
npm install -g @qingchencloud/openclaw-zh
`
: foreignGateway
? `
${t('services.foreignGatewayHint')}
`
: gw.running
? `
${isMacPlatform() ? `
` : ''}`
: `
${isMacPlatform() ? `
` : ''}`
}
`
} else {
html += `
ai.openclaw.gateway
${t('services.gwNotInstalled')}
`
}
container.innerHTML = html
}
// ===== 备份管理 =====
async function loadBackups(page) {
const list = page.querySelector('#backup-list')
try {
const backups = await api.listBackups()
renderBackups(list, backups)
} catch (e) {
list.innerHTML = `${t('services.backupLoadFailed')}: ${e}
`
}
}
function renderBackups(container, backups) {
if (!backups || !backups.length) {
container.innerHTML = `${t('services.noBackup')}
`
return
}
container.innerHTML = backups.map(b => {
const date = b.created_at ? new Date(b.created_at * 1000).toLocaleString() : t('common.unknown')
const size = b.size ? (b.size / 1024).toFixed(1) + ' KB' : ''
return `
${b.name}
${date}${size ? ' · ' + size : ''}
`
}).join('')
}
// ===== 事件绑定(事件委托) =====
function bindEvents(page) {
page.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-action]')
if (!btn) return
const action = btn.dataset.action
btn.disabled = true
try {
switch (action) {
case 'start':
case 'stop':
case 'restart':
await handleServiceAction(action, btn.dataset.label, page)
break
case 'save-config':
await handleSaveConfig(page, true)
break
case 'save-config-only':
await handleSaveConfig(page, false)
break
case 'reload-config':
await loadConfigEditor(page)
break
case 'calibrate-config-inherit':
await handleCalibrateConfig(page, 'inherit')
break
case 'calibrate-config-reset':
await handleCalibrateConfig(page, 'reset')
break
case 'create-backup':
await handleCreateBackup(page)
break
case 'restore-backup':
await handleRestoreBackup(btn.dataset.name, page)
break
case 'delete-backup':
await handleDeleteBackup(btn.dataset.name, page)
break
case 'upgrade':
await handleUpgrade(btn, page)
break
case 'switch-source':
await handleSwitchSource(btn.dataset.source, page)
break
case 'install-gateway':
await handleInstallGateway(btn, page)
break
case 'uninstall-gateway':
await handleUninstallGateway(btn, page)
break
case 'refresh-services':
await loadServices(page)
break
case 'resolve-foreign-gateway':
await openGatewayConflict(page)
break
case 'docker-refresh':
await loadDockerManager(page)
break
case 'docker-add-node':
await openDockerAddNode(page)
break
case 'docker-pull-image':
await openDockerPullImage(page)
break
case 'docker-create-container':
await openDockerCreateContainer(page)
break
case 'docker-remove-node':
await handleDockerRemoveNode(btn, page)
break
case 'docker-start-container':
case 'docker-stop-container':
case 'docker-restart-container':
case 'docker-remove-container':
await handleDockerContainerAction(action, btn, page)
break
}
} catch (e) {
toast(e.toString(), 'error')
} finally {
btn.disabled = false
}
})
}
// ===== 服务操作 =====
const ACTION_LABELS = { start: t('services.start'), stop: t('services.stop'), restart: t('services.restart') }
const POLL_INTERVAL = 1500 // 轮询间隔 ms
const POLL_TIMEOUT = 30000 // 最长等待 30s
async function handleServiceAction(action, label, page) {
const fn = { start: api.startService, stop: api.stopService, restart: api.restartService }[action]
const actionLabel = ACTION_LABELS[action]
const expectRunning = action !== 'stop'
// 通知守护模块:用户主动操作
if (action === 'stop') setUserStopped(true)
if (action === 'start') resetAutoRestart()
// 找到触发按钮所在的 service-card,替换按钮区域为加载状态
const card = page.querySelector(`.service-card[data-label="${label}"]`)
const actionsEl = card?.querySelector('.service-actions')
const origHtml = actionsEl?.innerHTML || ''
let cancelled = false
if (actionsEl) {
actionsEl.innerHTML = `
${t('services.actionProgress', { action: actionLabel })}
`
const cancelBtn = actionsEl.querySelector('.service-cancel-btn')
if (cancelBtn) {
cancelBtn.addEventListener('click', () => { cancelled = true })
}
}
// 更新状态点为加载中
const dot = card?.querySelector('.status-dot')
if (dot) { dot.className = 'status-dot loading' }
try {
await fn(label)
} catch (e) {
if (isForeignGatewayError(e)) {
await openGatewayConflict(page, e)
} else {
toast(t('services.actionCmdFailed', { action: actionLabel, error: e.message || e }), 'error')
}
if (actionsEl) actionsEl.innerHTML = origHtml
if (dot) dot.className = 'status-dot stopped'
return
}
// 轮询等待实际状态变化
const startTime = Date.now()
let showedCancel = false
const loadingText = actionsEl?.querySelector('.service-loading-text')
const cancelBtn = actionsEl?.querySelector('.service-cancel-btn')
while (!cancelled) {
const elapsed = Date.now() - startTime
// 5 秒后显示取消按钮
if (!showedCancel && elapsed > 5000 && cancelBtn) {
cancelBtn.style.display = ''
showedCancel = true
}
// 更新等待时间
if (loadingText) {
const sec = Math.floor(elapsed / 1000)
loadingText.textContent = t('services.actionProgressSec', { action: actionLabel, sec })
}
// 超时
if (elapsed > POLL_TIMEOUT) {
toast(t('services.actionTimeout', { action: actionLabel }), 'warning')
break
}
// 检查实际状态
try {
const services = await api.getServicesStatus()
const svc = services?.find?.(s => s.label === label) || services?.[0]
if (svc && svc.running === expectRunning) {
toast(t('services.actionDone', { label, action: actionLabel }) + (svc.pid ? ' (PID: ' + svc.pid + ')' : ''), 'success')
await loadServices(page)
return
}
} catch {}
await new Promise(r => setTimeout(r, POLL_INTERVAL))
}
if (cancelled) {
toast(t('services.cancelled'), 'info')
}
await loadServices(page)
}
// ===== 备份操作 =====
async function handleCreateBackup(page) {
const result = await api.createBackup()
toast(t('services.backupCreated', { name: result.name }), 'success')
await loadBackups(page)
}
async function handleRestoreBackup(name, page) {
const yes = await showConfirm(t('services.restoreConfirm', { name }))
if (!yes) return
await api.restoreBackup(name)
toast(t('services.restored'), 'success')
await loadBackups(page)
}
async function handleDeleteBackup(name, page) {
const yes = await showConfirm(t('services.deleteConfirm', { name }))
if (!yes) return
await api.deleteBackup(name)
toast(t('services.backupDeleted'), 'success')
await loadBackups(page)
}
function calibrationSourceLabel(source) {
if (source === 'backup') return t('services.calibrationSourceBackup')
if (source === 'current') return t('services.calibrationSourceCurrent')
return t('services.calibrationSourceEmpty')
}
async function handleCalibrateConfig(page, mode) {
const yes = await showConfirm(mode === 'reset'
? t('services.calibrateResetConfirm')
: t('services.calibrateInheritConfirm'))
if (!yes) return
const status = page.querySelector('#config-calibration-status')
if (status) status.innerHTML = `${t('services.calibrating')}`
const result = await api.calibrateOpenclawConfig(mode)
const summary = t('services.calibrationSummary', {
mode: mode === 'reset' ? t('services.calibrateReset') : t('services.calibrateInherit'),
source: calibrationSourceLabel(result?.source),
count: String(result?.inheritedKeys?.length || 0),
})
const warnings = Array.isArray(result?.warnings) ? result.warnings.filter(Boolean) : []
if (status) status.innerHTML = `${escapeHtml(summary)}${warnings.length ? `
${escapeHtml(warnings.join(';'))}` : ''}`
toast(t('services.calibrationDone') + ' · ' + summary, 'success')
if (warnings.length) toast(warnings.join(';'), 'warning')
await Promise.all([
loadConfigEditor(page),
loadBackups(page),
loadServices(page),
])
}
// ===== 配置文件编辑器 =====
let _configOriginal = ''
async function loadConfigEditor(page) {
const section = page.querySelector('#config-editor-section')
const area = page.querySelector('#config-editor-area')
const status = page.querySelector('#config-editor-status')
const btnSave = page.querySelector('[data-action="save-config"]')
const btnSaveOnly = page.querySelector('[data-action="save-config-only"]')
try {
const config = await api.readOpenclawConfig()
const json = JSON.stringify(config, null, 2)
_configOriginal = json
area.value = json
area.disabled = false
btnSave.disabled = false
btnSaveOnly.disabled = false
section.style.display = ''
status.innerHTML = `${t('services.configLoaded')} · ${(json.length / 1024).toFixed(1)} KB`
// 实时检测 JSON 语法
area.oninput = () => {
try {
JSON.parse(area.value)
const changed = area.value !== _configOriginal
status.innerHTML = changed
? `● ${t('services.configUnsaved')}`
: `${t('services.configNoChange')}`
btnSave.disabled = !changed
btnSaveOnly.disabled = !changed
} catch (e) {
status.innerHTML = `${t('services.configJsonError')}: ${e.message.split(' at ')[0]}`
btnSave.disabled = true
btnSaveOnly.disabled = true
}
}
} catch {
// openclaw.json 不存在,隐藏编辑器
section.style.display = 'none'
}
}
async function handleSaveConfig(page, restart) {
const area = page.querySelector('#config-editor-area')
const status = page.querySelector('#config-editor-status')
let config
try {
config = JSON.parse(area.value)
} catch (e) {
toast(t('services.configSaveJsonError'), 'error')
return
}
status.innerHTML = `${t('services.autoBackingUp')}`
try {
// 保存前自动备份
await api.createBackup()
} catch (e) {
const yes = await showConfirm(t('services.autoBackupFailed') + ': ' + e + '\n\n' + t('services.continueWithoutBackup'))
if (!yes) return
}
status.innerHTML = `${t('services.saving')}`
try {
await api.writeOpenclawConfig(config)
_configOriginal = area.value
toast(restart ? t('services.configSavedRestarting') : t('services.configSaved'), 'success')
status.innerHTML = `${t('services.configSaved')}`
page.querySelector('[data-action="save-config"]').disabled = true
page.querySelector('[data-action="save-config-only"]').disabled = true
if (restart) {
try {
await api.restartGateway()
toast(t('services.gwRestarted'), 'success')
} catch (e) {
toast(t('services.configSavedGwFailed') + ': ' + e, 'warning')
}
await loadServices(page)
}
await loadBackups(page)
} catch (e) {
toast(t('common.saveFailed') + ': ' + e, 'error')
status.innerHTML = `${t('common.saveFailed')}: ${e}`
}
}
// ===== 升级操作 =====
async function doUpgradeWithModal(source, page, version = null, method = 'auto') {
const modal = showUpgradeModal(t('services.upgradeTitle'))
let unlistenLog, unlistenProgress, unlistenDone, unlistenError
setUpgrading(true)
// 清理所有监听
const cleanup = () => {
setUpgrading(false)
unlistenLog?.()
unlistenProgress?.()
unlistenDone?.()
unlistenError?.()
}
try {
if (window.__TAURI_INTERNALS__) {
const { listen } = await import('@tauri-apps/api/event')
unlistenLog = await listen('upgrade-log', (e) => modal.appendLog(e.payload))
unlistenProgress = await listen('upgrade-progress', (e) => modal.setProgress(e.payload))
// 后台任务完成事件
unlistenDone = await listen('upgrade-done', (e) => {
cleanup()
modal.setDone(typeof e.payload === 'string' ? e.payload : t('services.taskDone'))
loadVersion(page)
})
// 后台任务失败事件
unlistenError = await listen('upgrade-error', (e) => {
cleanup()
const errStr = String(e.payload || t('common.error'))
modal.appendLog(errStr)
const fullLog = modal.getLogText() + '\n' + errStr
const diagnosis = diagnoseInstallError(fullLog)
modal.setError(diagnosis.title)
if (diagnosis.hint) modal.appendLog('')
if (diagnosis.hint) modal.appendHtmlLog(`${statusIcon('info', 14)} ${diagnosis.hint}`)
if (diagnosis.command) modal.appendHtmlLog(`${icon('clipboard', 14)} ${diagnosis.command}`)
if (window.__openAIDrawerWithError) {
window.__openAIDrawerWithError({ title: diagnosis.title, error: fullLog, scene: t('services.upgradeScene'), hint: diagnosis.hint })
}
})
// 发起后台任务(立即返回)
await api.upgradeOpenclaw(source, version, method)
modal.appendLog(t('services.taskStarted'))
} else {
// Web 模式:仍然同步等待(dev-api 后端没有 spawn)
modal.appendLog(t('services.webModeNoLog'))
const msg = await api.upgradeOpenclaw(source, version, method)
modal.setDone(typeof msg === 'string' ? msg : (msg?.message || t('services.upgradeDone')))
await loadVersion(page)
cleanup()
}
} catch (e) {
cleanup()
const errStr = String(e)
modal.appendLog(errStr)
const fullLog = modal.getLogText() + '\n' + errStr
const diagnosis = diagnoseInstallError(fullLog)
modal.setError(diagnosis.title)
}
}
async function handleUpgrade(btn, page) {
const sourceLabel = detectedSource === 'official' ? t('services.officialEdition') : t('services.chineseEdition')
const recommended = lastVersionInfo?.recommended
const yes = await showConfirm(t('services.upgradeConfirm', { source: sourceLabel, version: recommended ? `(${recommended})` : '' }))
if (!yes) return
await doUpgradeWithModal(detectedSource, page, recommended || null)
}
async function handleSwitchSource(target, page) {
const targetLabel = target === 'official' ? t('services.officialEdition') : t('services.chineseEdition')
const recommended = target === 'official'
? (lastVersionInfo?.source === 'official' ? lastVersionInfo?.recommended : null)
: (lastVersionInfo?.source === 'chinese' ? lastVersionInfo?.recommended : null)
const yes = await showConfirm(t('services.switchSourceConfirm', { target: targetLabel, version: recommended ? `(${recommended})` : '' }))
if (!yes) return
await doUpgradeWithModal(target, page, null)
}
// ===== Gateway 安装/卸载 =====
async function handleInstallGateway(btn, page) {
btn.classList.add('btn-loading')
btn.textContent = t('services.installing')
try {
await api.installGateway()
toast(t('services.gwInstalled'), 'success')
await loadServices(page)
} catch (e) {
toast(t('services.installFailed') + ': ' + e, 'error')
btn.classList.remove('btn-loading')
btn.textContent = t('services.install')
}
}
async function handleUninstallGateway(btn, page) {
const yes = await showConfirm(t('services.uninstallConfirm'))
if (!yes) return
btn.classList.add('btn-loading')
btn.textContent = t('services.uninstalling')
try {
await api.uninstallGateway()
toast(t('services.gwUninstalled'), 'success')
await loadServices(page)
} catch (e) {
toast(t('services.uninstallFailed') + ': ' + e, 'error')
btn.classList.remove('btn-loading')
btn.textContent = t('services.uninstall')
}
}