mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-10 17:42:49 +08:00
1005 lines
43 KiB
JavaScript
1005 lines
43 KiB
JavaScript
/**
|
||
* 服务管理页面
|
||
* 服务启停 + 更新检测 + 配置备份管理
|
||
*/
|
||
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, '>')
|
||
.replace(/"/g, '"')
|
||
}
|
||
|
||
export async function render() {
|
||
const page = document.createElement('div')
|
||
page.className = 'page'
|
||
|
||
page.innerHTML = `
|
||
<div class="page-header">
|
||
<h1 class="page-title">${t('services.title')}</h1>
|
||
<p class="page-desc">${t('services.desc')}</p>
|
||
</div>
|
||
<div id="version-bar"><div class="stat-card loading-placeholder" style="height:80px;margin-bottom:var(--space-lg)"></div></div>
|
||
<div id="services-list"><div class="stat-card loading-placeholder" style="height:64px"></div></div>
|
||
<div class="config-section" id="docker-manager-section">
|
||
<div class="config-section-title">${t('services.dockerManager')}</div>
|
||
<div class="form-hint" style="margin-bottom:var(--space-sm)">${t('services.dockerManagerHint')}</div>
|
||
<div id="docker-manager-bar"><div class="stat-card loading-placeholder" style="height:96px"></div></div>
|
||
</div>
|
||
<div class="config-section" id="config-editor-section" style="display:none">
|
||
<div class="config-section-title">${t('services.configEditor')}</div>
|
||
<div class="form-hint" style="margin-bottom:var(--space-sm)">${t('services.configEditorHint')}</div>
|
||
<div style="display:flex;gap:8px;margin-bottom:var(--space-sm)">
|
||
<button class="btn btn-primary btn-sm" data-action="save-config" disabled>${t('services.saveAndRestart')}</button>
|
||
<button class="btn btn-secondary btn-sm" data-action="save-config-only" disabled>${t('services.saveOnly')}</button>
|
||
<button class="btn btn-secondary btn-sm" data-action="reload-config">${t('services.reloadConfig')}</button>
|
||
</div>
|
||
<div id="config-editor-status" style="font-size:var(--font-size-xs);margin-bottom:6px;min-height:18px"></div>
|
||
<textarea id="config-editor-area" class="form-input" style="font-family:var(--font-mono);font-size:12px;min-height:320px;resize:vertical;tab-size:2;white-space:pre;overflow-x:auto" spellcheck="false" disabled></textarea>
|
||
</div>
|
||
<div class="config-section" id="config-calibration-section">
|
||
<div class="config-section-title">${t('services.configCalibration')}</div>
|
||
<div class="form-hint" style="margin-bottom:var(--space-sm)">${t('services.configCalibrationHint')}</div>
|
||
<div style="display:flex;gap:var(--space-sm);flex-wrap:wrap;margin-bottom:var(--space-sm)">
|
||
<button class="btn btn-primary btn-sm" data-action="calibrate-config-inherit">${t('services.calibrateInherit')}</button>
|
||
<button class="btn btn-secondary btn-sm" data-action="calibrate-config-reset">${t('services.calibrateReset')}</button>
|
||
</div>
|
||
<div style="display:grid;gap:8px;margin-bottom:var(--space-sm)">
|
||
<div class="setup-inline-note">${t('services.calibrateInheritHint')}</div>
|
||
<div class="setup-inline-note">${t('services.calibrateResetHint')}</div>
|
||
</div>
|
||
<div id="config-calibration-status" style="font-size:var(--font-size-xs);min-height:18px;color:var(--text-tertiary)"></div>
|
||
</div>
|
||
<div class="config-section" id="backup-section">
|
||
<div class="config-section-title">${t('services.configBackup')}</div>
|
||
<div class="form-hint" style="margin-bottom:var(--space-sm)">${t('services.configBackupHint')}</div>
|
||
<div id="backup-actions" style="margin-bottom:var(--space-md)">
|
||
<button class="btn btn-primary btn-sm" data-action="create-backup">${t('services.createBackup')}</button>
|
||
</div>
|
||
<div id="backup-list"><div class="stat-card loading-placeholder" style="height:48px"></div></div>
|
||
</div>
|
||
`
|
||
|
||
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 = `
|
||
<div class="stat-cards" style="margin-bottom:var(--space-lg)">
|
||
<div class="stat-card">
|
||
<div class="stat-card-header">
|
||
<span class="stat-card-label">${t('services.currentVersion')} · <span style="color:var(--accent)">${t('services.dockerDeploy')}</span></span>
|
||
</div>
|
||
<div class="stat-card-value">${ver}</div>
|
||
<div class="stat-card-meta">${info.latest_update_available ? t('services.latestUpstream', { version: info.latest }) + '(' + t('services.pullNewImage') + ')' : t('services.currentImageVer')}</div>
|
||
${info.latest_update_available ? `<div style="margin-top:var(--space-sm)">
|
||
<code style="font-size:var(--font-size-xs);background:var(--bg-tertiary);padding:4px 8px;border-radius:4px;user-select:all">${escapeHtml(`docker pull ${dockerImage}:latest`)}</code>
|
||
</div>` : ''}
|
||
</div>
|
||
</div>
|
||
`
|
||
} else {
|
||
bar.innerHTML = `
|
||
<div class="stat-cards" style="margin-bottom:var(--space-lg)">
|
||
<div class="stat-card">
|
||
<div class="stat-card-header">
|
||
<span class="stat-card-label">${t('services.currentVersion')} · <span style="color:var(--accent)">${sourceTag}</span></span>
|
||
</div>
|
||
<div class="stat-card-value">${ver}</div>
|
||
<div class="stat-card-meta">
|
||
${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 }) : ''}
|
||
</div>
|
||
<div style="display:flex;gap:var(--space-sm);margin-top:var(--space-sm);flex-wrap:wrap">
|
||
${aheadOfRecommended ? `<button class="btn btn-primary btn-sm" data-action="upgrade">${t('services.rollbackToRecommended')}</button>` : driftFromRecommended ? `<button class="btn btn-primary btn-sm" data-action="upgrade">${t('services.switchToRecommended')}</button>` : ''}
|
||
<button class="btn btn-secondary btn-sm" data-action="switch-source" data-source="${switchTarget}">${switchLabel}</button>
|
||
</div>
|
||
<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary);line-height:1.6">
|
||
${policyNote}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`
|
||
}
|
||
} catch (e) {
|
||
bar.innerHTML = `<div class="stat-card" style="margin-bottom:var(--space-lg)"><div class="stat-card-label">${t('services.versionLoadFailed')}</div></div>`
|
||
}
|
||
}
|
||
|
||
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 = `<div class="stat-card"><div class="stat-card-meta">${t('services.dockerManagerUnavailable')}</div></div>`
|
||
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 = `
|
||
<div style="display:flex;justify-content:space-between;align-items:center;gap:var(--space-sm);flex-wrap:wrap;margin-bottom:var(--space-md)">
|
||
<div class="stat-card" style="padding:12px 16px;min-width:260px">
|
||
<div class="stat-card-label">${t('services.dockerManager')}</div>
|
||
<div class="stat-card-meta">${onlineNodes}/${totalNodes} ${t('services.dockerOnline')} · ${runningContainers}/${totalContainers} ${t('services.dockerContainersLabel')}</div>
|
||
</div>
|
||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||
<button class="btn btn-secondary btn-sm" data-action="docker-refresh">${t('services.dockerRefresh')}</button>
|
||
<button class="btn btn-secondary btn-sm" data-action="docker-add-node">${t('services.dockerAddNode')}</button>
|
||
<button class="btn btn-secondary btn-sm" data-action="docker-pull-image">${t('services.dockerPullAction')}</button>
|
||
<button class="btn btn-primary btn-sm" data-action="docker-create-container">${t('services.dockerCreateContainer')}</button>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;gap:var(--space-md)">
|
||
${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 `
|
||
<div class="service-card" data-docker-node="${escapeHtml(node.id)}" style="display:block">
|
||
<div style="display:flex;justify-content:space-between;gap:var(--space-sm);align-items:flex-start;flex-wrap:wrap">
|
||
<div class="service-info">
|
||
<span class="status-dot ${node.online ? 'running' : 'stopped'}"></span>
|
||
<div>
|
||
<div class="service-name">${escapeHtml(node.name)}${node.id === 'local' ? ` <span class="clawhub-badge" style="margin-left:6px;background:rgba(99,102,241,0.14);color:#6366f1">${t('services.dockerLocalNode')}</span>` : ''}</div>
|
||
<div class="service-desc">${nodeMeta}</div>
|
||
<div class="service-desc">${node.online ? `${t('services.dockerContainersLabel')}: ${node.runningContainers || 0}/${node.totalContainers || containers.length}` : t('services.dockerOffline')}</div>
|
||
</div>
|
||
</div>
|
||
<div class="service-actions">
|
||
${node.id !== 'local' ? `<button class="btn btn-danger btn-sm" data-action="docker-remove-node" data-node-id="${escapeHtml(node.id)}" data-name="${escapeHtml(node.name)}">${t('common.delete')}</button>` : ''}
|
||
</div>
|
||
</div>
|
||
<div style="margin-top:var(--space-sm);display:flex;flex-direction:column;gap:8px">
|
||
${containers.length ? containers.map(ct => `
|
||
<div class="service-card" style="background:var(--bg-secondary);border:1px solid var(--border-primary)">
|
||
<div class="service-info">
|
||
<span class="status-dot ${ct.state === 'running' ? 'running' : 'stopped'}"></span>
|
||
<div>
|
||
<div class="service-name">${escapeHtml(ct.name)}</div>
|
||
<div class="service-desc">${escapeHtml(ct.image)} · ${escapeHtml(ct.status || ct.state || t('common.unknown'))}${ct.ports ? ` · ${escapeHtml(ct.ports)}` : ''}</div>
|
||
</div>
|
||
</div>
|
||
<div class="service-actions">
|
||
${ct.state === 'running'
|
||
? `<button class="btn btn-secondary btn-sm" data-action="docker-restart-container" data-node-id="${escapeHtml(node.id)}" data-container-id="${escapeHtml(ct.id)}" data-name="${escapeHtml(ct.name)}">${t('services.restart')}</button>
|
||
<button class="btn btn-secondary btn-sm" data-action="docker-stop-container" data-node-id="${escapeHtml(node.id)}" data-container-id="${escapeHtml(ct.id)}" data-name="${escapeHtml(ct.name)}">${t('services.stop')}</button>`
|
||
: `<button class="btn btn-primary btn-sm" data-action="docker-start-container" data-node-id="${escapeHtml(node.id)}" data-container-id="${escapeHtml(ct.id)}" data-name="${escapeHtml(ct.name)}">${t('services.start')}</button>`}
|
||
<button class="btn btn-danger btn-sm" data-action="docker-remove-container" data-node-id="${escapeHtml(node.id)}" data-container-id="${escapeHtml(ct.id)}" data-name="${escapeHtml(ct.name)}" data-running="${ct.state === 'running' ? '1' : ''}">${t('common.delete')}</button>
|
||
</div>
|
||
</div>
|
||
`).join('') : `<div class="form-hint" style="padding:4px 0">${t('services.dockerNoContainers')}</div>`}
|
||
</div>
|
||
</div>
|
||
`
|
||
}).join('')}
|
||
</div>
|
||
<div class="form-hint" style="margin-top:var(--space-sm)">${t('services.dockerDefaultImageHint')} <code>${escapeHtml(configuredDockerImage(panelConfig))}</code></div>
|
||
`
|
||
} catch (e) {
|
||
bar.innerHTML = `<div class="stat-card"><div class="stat-card-meta" style="color:var(--error)">${t('services.dockerManagerLoadFailed')}: ${escapeHtml(e?.message || e)}</div></div>`
|
||
}
|
||
}
|
||
|
||
// ===== 服务列表 =====
|
||
|
||
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 = `<div style="color:var(--error)">${t('services.serviceLoadFailed')}: ${escapeHtml(String(e))}</div>`
|
||
}
|
||
}
|
||
|
||
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 += `
|
||
<div class="service-card" data-label="${gw.label}">
|
||
<div class="service-info">
|
||
<span class="status-dot ${cliMissing ? 'stopped' : gw.running ? 'running' : 'stopped'}"></span>
|
||
<div>
|
||
<div class="service-name">${gw.label}</div>
|
||
<div class="service-desc">${cliMissing
|
||
? t('services.cliNotInstalled')
|
||
: foreignGateway
|
||
? t('services.foreignGatewayDesc', { pid: foreignPidText, settings: t('sidebar.settings') })
|
||
: (gw.description || '') + (gw.pid ? ' (PID: ' + gw.pid + ')' : '')
|
||
}</div>
|
||
</div>
|
||
</div>
|
||
<div class="service-actions">
|
||
${cliMissing
|
||
? `<div style="display:flex;flex-direction:column;gap:var(--space-xs);align-items:flex-end">
|
||
<div style="color:var(--text-tertiary);font-size:var(--font-size-xs)">${t('services.installCliHint')}</div>
|
||
<code style="font-size:var(--font-size-xs);background:var(--bg-tertiary);padding:2px 8px;border-radius:4px;user-select:all">npm install -g @qingchencloud/openclaw-zh</code>
|
||
<button class="btn btn-secondary btn-sm" data-action="refresh-services" style="margin-top:4px">${t('services.refreshStatus')}</button>
|
||
</div>`
|
||
: foreignGateway
|
||
? `<div style="display:flex;flex-direction:column;gap:var(--space-xs);align-items:flex-end">
|
||
<div style="color:var(--warning);font-size:var(--font-size-xs);max-width:320px;text-align:right">${t('services.foreignGatewayHint')}</div>
|
||
<div style="display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-end">
|
||
<button class="btn btn-secondary btn-sm" data-action="resolve-foreign-gateway">${t('dashboard.viewGuidance')}</button>
|
||
<button class="btn btn-secondary btn-sm" data-action="refresh-services">${t('services.refreshStatus')}</button>
|
||
</div>
|
||
</div>`
|
||
: gw.running
|
||
? `<button class="btn btn-secondary btn-sm" data-action="restart" data-label="${gw.label}">${t('services.restart')}</button>
|
||
<button class="btn btn-danger btn-sm" data-action="stop" data-label="${gw.label}">${t('services.stop')}</button>
|
||
${isMacPlatform() ? `<button class="btn btn-danger btn-sm" data-action="uninstall-gateway">${t('services.uninstall')}</button>` : ''}`
|
||
: `<button class="btn btn-primary btn-sm" data-action="start" data-label="${gw.label}">${t('services.start')}</button>
|
||
${isMacPlatform() ? `<button class="btn btn-primary btn-sm" data-action="install-gateway">${t('services.install')}</button><button class="btn btn-danger btn-sm" data-action="uninstall-gateway">${t('services.uninstall')}</button>` : ''}`
|
||
}
|
||
</div>
|
||
</div>`
|
||
} else {
|
||
html += `
|
||
<div class="service-card">
|
||
<div class="service-info">
|
||
<span class="status-dot stopped"></span>
|
||
<div>
|
||
<div class="service-name">ai.openclaw.gateway</div>
|
||
<div class="service-desc">${t('services.gwNotInstalled')}</div>
|
||
</div>
|
||
</div>
|
||
<div class="service-actions">
|
||
<button class="btn btn-primary btn-sm" data-action="install-gateway">${t('services.install')}</button>
|
||
</div>
|
||
</div>`
|
||
}
|
||
|
||
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 = `<div style="color:var(--error)">${t('services.backupLoadFailed')}: ${e}</div>`
|
||
}
|
||
}
|
||
|
||
function renderBackups(container, backups) {
|
||
if (!backups || !backups.length) {
|
||
container.innerHTML = `<div style="color:var(--text-tertiary);padding:var(--space-md) 0">${t('services.noBackup')}</div>`
|
||
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 `
|
||
<div class="service-card" data-backup="${b.name}">
|
||
<div class="service-info">
|
||
<div>
|
||
<div class="service-name">${b.name}</div>
|
||
<div class="service-desc">${date}${size ? ' · ' + size : ''}</div>
|
||
</div>
|
||
</div>
|
||
<div class="service-actions">
|
||
<button class="btn btn-primary btn-sm" data-action="restore-backup" data-name="${b.name}">${t('services.restore')}</button>
|
||
<button class="btn btn-danger btn-sm" data-action="delete-backup" data-name="${b.name}">${t('common.delete')}</button>
|
||
</div>
|
||
</div>`
|
||
}).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 = `
|
||
<div class="service-loading">
|
||
<div class="service-spinner"></div>
|
||
<span class="service-loading-text">${t('services.actionProgress', { action: actionLabel })}</span>
|
||
<button class="btn btn-sm btn-ghost service-cancel-btn" style="display:none">${t('services.cancelWait')}</button>
|
||
</div>`
|
||
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 = `<span style="color:var(--text-tertiary)">${t('services.calibrating')}</span>`
|
||
|
||
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 = `<span style="color:var(--success)">${escapeHtml(summary)}</span>${warnings.length ? `<br><span style="color:var(--warning)">${escapeHtml(warnings.join(';'))}</span>` : ''}`
|
||
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 = `<span style="color:var(--text-tertiary)">${t('services.configLoaded')} · ${(json.length / 1024).toFixed(1)} KB</span>`
|
||
|
||
// 实时检测 JSON 语法
|
||
area.oninput = () => {
|
||
try {
|
||
JSON.parse(area.value)
|
||
const changed = area.value !== _configOriginal
|
||
status.innerHTML = changed
|
||
? `<span style="color:var(--warning)">● ${t('services.configUnsaved')}</span>`
|
||
: `<span style="color:var(--text-tertiary)">${t('services.configNoChange')}</span>`
|
||
btnSave.disabled = !changed
|
||
btnSaveOnly.disabled = !changed
|
||
} catch (e) {
|
||
status.innerHTML = `<span style="color:var(--error)">${t('services.configJsonError')}: ${e.message.split(' at ')[0]}</span>`
|
||
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 = `<span style="color:var(--text-tertiary)">${t('services.autoBackingUp')}</span>`
|
||
|
||
try {
|
||
// 保存前自动备份
|
||
await api.createBackup()
|
||
} catch (e) {
|
||
const yes = await showConfirm(t('services.autoBackupFailed') + ': ' + e + '\n\n' + t('services.continueWithoutBackup'))
|
||
if (!yes) return
|
||
}
|
||
|
||
status.innerHTML = `<span style="color:var(--text-tertiary)">${t('services.saving')}</span>`
|
||
|
||
try {
|
||
await api.writeOpenclawConfig(config)
|
||
_configOriginal = area.value
|
||
toast(restart ? t('services.configSavedRestarting') : t('services.configSaved'), 'success')
|
||
status.innerHTML = `<span style="color:var(--success)">${t('services.configSaved')}</span>`
|
||
|
||
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 = `<span style="color:var(--error)">${t('common.saveFailed')}: ${e}</span>`
|
||
}
|
||
}
|
||
|
||
// ===== 升级操作 =====
|
||
|
||
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')
|
||
}
|
||
}
|