mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-16 20:47:36 +08:00
Fixes: - Stop force-appending /v1 to API URLs (breaks Volcengine /v3 etc) - SSH upgrade: --unset-all + --add for 4 git insteadOf rules - Feishu: builtin detection, overlay→modal fix, select field, plugin version persistence - Docker: HTML response detection, Web mode guidance - Chat: runId dedup prevents duplicate messages - Cron: RPC params name→id - Channels: Gateway reload async (instant UI response), toggle cache invalidation - Linux: auto sudo for non-root npm installs (libc geteuid) - Control UI: dynamic hostname + auth token for remote access - npm: mirror fallback (npmmirror→npmjs.org) - QQBot: native binding friendly error message - Error diagnosis: SSH vs Git-not-installed, native binding detection Features: - About page: company info (武汉晴辰天下网络科技有限公司) - model-presets.js: shared module for models.js + assistant.js - Feishu: dual plugin support (builtin vs official @larksuiteoapi) - Assistant: provider preset quick-fill buttons Improvements: - Website: dynamic download links from latest.json + claw.qt.cool proxy - Linux deploy docs: upgrade guide, Gitee mirror, sudo notes - linux-deploy.sh: Gitee fallback + sudo npm + mirror retry
438 lines
19 KiB
JavaScript
438 lines
19 KiB
JavaScript
/**
|
||
* 仪表盘页面
|
||
*/
|
||
import { api } from '../lib/tauri-api.js'
|
||
import { toast } from '../components/toast.js'
|
||
import { onGatewayChange } from '../lib/app-state.js'
|
||
import { navigate } from '../router.js'
|
||
|
||
let _unsubGw = null
|
||
|
||
export async function render() {
|
||
const page = document.createElement('div')
|
||
page.className = 'page'
|
||
|
||
page.innerHTML = `
|
||
<div class="page-header">
|
||
<h1 class="page-title">仪表盘</h1>
|
||
<p class="page-desc">OpenClaw 运行状态概览</p>
|
||
</div>
|
||
<div class="stat-cards" id="stat-cards">
|
||
<div class="stat-card loading-placeholder"></div>
|
||
<div class="stat-card loading-placeholder"></div>
|
||
<div class="stat-card loading-placeholder"></div>
|
||
<div class="stat-card loading-placeholder"></div>
|
||
<div class="stat-card loading-placeholder"></div>
|
||
<div class="stat-card loading-placeholder"></div>
|
||
</div>
|
||
<div id="dashboard-overview-container"></div>
|
||
<div class="quick-actions">
|
||
<button class="btn btn-secondary" id="btn-restart-gw">重启 Gateway</button>
|
||
<button class="btn btn-secondary" id="btn-check-update">检查更新</button>
|
||
<button class="btn btn-secondary" id="btn-create-backup">创建备份</button>
|
||
</div>
|
||
<div class="config-section">
|
||
<div class="config-section-title">最近日志</div>
|
||
<div class="log-viewer" id="recent-logs" style="max-height:300px"></div>
|
||
</div>
|
||
`
|
||
|
||
// 绑定事件(只绑一次)
|
||
bindActions(page)
|
||
|
||
// 异步加载数据
|
||
loadDashboardData(page)
|
||
|
||
// 监听 Gateway 状态变化,自动刷新仪表盘
|
||
if (_unsubGw) _unsubGw()
|
||
_unsubGw = onGatewayChange(() => {
|
||
loadDashboardData(page)
|
||
})
|
||
|
||
return page
|
||
}
|
||
|
||
export function cleanup() {
|
||
if (_unsubGw) { _unsubGw(); _unsubGw = null }
|
||
}
|
||
|
||
async function loadDashboardData(page) {
|
||
// 分波加载:关键数据先渲染,次要数据后填充,减少白屏等待
|
||
const coreP = Promise.allSettled([
|
||
api.getServicesStatus(),
|
||
api.getVersionInfo(),
|
||
api.readOpenclawConfig(),
|
||
])
|
||
const secondaryP = Promise.allSettled([
|
||
api.listAgents(),
|
||
api.readMcpConfig(),
|
||
api.listBackups(),
|
||
])
|
||
const logsP = api.readLogTail('gateway', 20).catch(() => '')
|
||
|
||
// 第一波:服务状态 + 版本 + 配置 → 立即渲染统计卡片
|
||
const [servicesRes, versionRes, configRes] = await coreP
|
||
const services = servicesRes.status === 'fulfilled' ? servicesRes.value : []
|
||
const version = versionRes.status === 'fulfilled' ? versionRes.value : {}
|
||
const config = configRes.status === 'fulfilled' ? configRes.value : null
|
||
if (servicesRes.status === 'rejected') toast('服务状态加载失败', 'error')
|
||
if (versionRes.status === 'rejected') toast('版本信息加载失败', 'error')
|
||
|
||
// 自愈:补全关键默认值
|
||
if (config) {
|
||
let patched = false
|
||
if (!config.gateway) config.gateway = {}
|
||
if (!config.gateway.mode) { config.gateway.mode = 'local'; patched = true }
|
||
// 修复旧版错误:mode 不应在顶层(OpenClaw 不认识)
|
||
if (config.mode) { delete config.mode; patched = true }
|
||
if (!config.tools || config.tools.profile !== 'full') {
|
||
config.tools = { profile: 'full', sessions: { visibility: 'all' }, ...(config.tools || {}) }
|
||
config.tools.profile = 'full'
|
||
if (!config.tools.sessions) config.tools.sessions = {}
|
||
config.tools.sessions.visibility = 'all'
|
||
patched = true
|
||
}
|
||
if (patched) api.writeOpenclawConfig(config).catch(() => {})
|
||
}
|
||
|
||
renderStatCards(page, services, version, [], config)
|
||
|
||
// 第二波:Agent、MCP、备份 → 更新卡片 + 渲染总览
|
||
const [agentsRes, mcpRes, backupsRes] = await secondaryP
|
||
const agents = agentsRes.status === 'fulfilled' ? agentsRes.value : []
|
||
const mcpConfig = mcpRes.status === 'fulfilled' ? mcpRes.value : null
|
||
const backups = backupsRes.status === 'fulfilled' ? backupsRes.value : []
|
||
|
||
renderStatCards(page, services, version, agents, config)
|
||
renderOverview(page, services, mcpConfig, backups, config, agents)
|
||
|
||
// 第三波:日志(最低优先级)
|
||
const logs = await logsP
|
||
renderLogs(page, logs)
|
||
}
|
||
|
||
function renderStatCards(page, services, version, agents, config) {
|
||
const cardsEl = page.querySelector('#stat-cards')
|
||
const gw = services.find(s => s.label === 'ai.openclaw.gateway')
|
||
const runningCount = services.filter(s => s.running).length
|
||
|
||
const defaultAgent = agents.find(a => a.id === 'main')?.name || 'main'
|
||
const modelCount = config?.models?.providers ? Object.values(config.models.providers).reduce((acc, p) => acc + (p.models?.length || 0), 0) : 0
|
||
const providerCount = config?.models?.providers ? Object.keys(config.models.providers).length : 0
|
||
|
||
cardsEl.innerHTML = `
|
||
<div class="stat-card">
|
||
<div class="stat-card-header">
|
||
<span class="stat-card-label">Gateway</span>
|
||
<span class="status-dot ${gw?.running ? 'running' : 'stopped'}"></span>
|
||
</div>
|
||
<div class="stat-card-value">${gw?.running ? '运行中' : '已停止'}</div>
|
||
<div class="stat-card-meta">${gw?.pid ? 'PID: ' + gw.pid : (gw?.running ? '端口检测' : '未启动')}</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-card-header">
|
||
<span class="stat-card-label">版本 · ${version.source === 'official' ? '官方' : '汉化'}</span>
|
||
</div>
|
||
<div class="stat-card-value">${version.current || '未知'}</div>
|
||
<div class="stat-card-meta">${version.update_available ? '有新版本: ' + version.latest : '已是最新'}</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-card-header">
|
||
<span class="stat-card-label">Agent 舰队</span>
|
||
</div>
|
||
<div class="stat-card-value">${agents.length} 个</div>
|
||
<div class="stat-card-meta">默认: ${defaultAgent}</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-card-header">
|
||
<span class="stat-card-label">模型池</span>
|
||
</div>
|
||
<div class="stat-card-value">${modelCount} 个</div>
|
||
<div class="stat-card-meta">基于 ${providerCount} 个渠道商</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-card-header">
|
||
<span class="stat-card-label">基础服务</span>
|
||
</div>
|
||
<div class="stat-card-value">${runningCount}/${services.length}</div>
|
||
<div class="stat-card-meta">存活率 ${services.length ? Math.round(runningCount / services.length * 100) : 0}%</div>
|
||
</div>
|
||
<div class="stat-card stat-card-clickable" id="card-control-ui" title="打开 OpenClaw 原生控制面板">
|
||
<div class="stat-card-header">
|
||
<span class="stat-card-label">Control UI</span>
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="opacity:0.5"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||
</div>
|
||
<div class="stat-card-value" style="font-size:var(--font-size-sm)">OpenClaw 原生面板</div>
|
||
<div class="stat-card-meta">${gw?.running ? '点击打开浏览器' : 'Gateway 未运行'}</div>
|
||
</div>
|
||
`
|
||
}
|
||
|
||
function renderOverview(page, services, mcpConfig, backups, config, agents) {
|
||
const containerEl = page.querySelector('#dashboard-overview-container')
|
||
const gw = services.find(s => s.label === 'ai.openclaw.gateway')
|
||
const mcpCount = mcpConfig?.mcpServers ? Object.keys(mcpConfig.mcpServers).length : 0
|
||
|
||
const formatDate = (timestamp) => {
|
||
if (!timestamp) return '——'
|
||
const d = new Date(timestamp * 1000)
|
||
const mon = d.getMonth() + 1
|
||
const day = d.getDate()
|
||
const hr = d.getHours().toString().padStart(2, '0')
|
||
const min = d.getMinutes().toString().padStart(2, '0')
|
||
return mon + '-' + day + ' ' + hr + ':' + min
|
||
}
|
||
|
||
const latestBackup = backups.length > 0 ? backups.sort((a,b) => b.created_at - a.created_at)[0] : null
|
||
const lastUpdate = config?.meta?.lastTouchedVersion || '未知'
|
||
|
||
const gwPort = config?.gateway?.port || 18789
|
||
const primaryModel = config?.agents?.defaults?.model?.primary || '未设置'
|
||
|
||
containerEl.innerHTML = `
|
||
<div class="dashboard-overview">
|
||
<div class="overview-grid">
|
||
<div class="overview-card" data-nav="/gateway">
|
||
<div class="overview-card-icon" style="color:${gw?.running ? 'var(--success)' : 'var(--error)'}">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||
</div>
|
||
<div class="overview-card-body">
|
||
<div class="overview-card-title">Gateway</div>
|
||
<div class="overview-card-value" style="color:${gw?.running ? 'var(--success)' : 'var(--error)'}">${gw?.running ? '运行中' : '已停止'}</div>
|
||
<div class="overview-card-meta">端口 ${gwPort} ${gw?.pid ? '· PID ' + gw.pid : ''}</div>
|
||
</div>
|
||
<div class="overview-card-actions">
|
||
${gw?.running
|
||
? '<button class="btn btn-danger btn-xs" data-action="stop-gw">停止</button><button class="btn btn-secondary btn-xs" data-action="restart-gw">重启</button>'
|
||
: '<button class="btn btn-primary btn-xs" data-action="start-gw">启动</button>'
|
||
}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="overview-card" data-nav="/models">
|
||
<div class="overview-card-icon" style="color:var(--accent)">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"/></svg>
|
||
</div>
|
||
<div class="overview-card-body">
|
||
<div class="overview-card-title">主模型</div>
|
||
<div class="overview-card-value" style="font-size:var(--font-size-sm)">${primaryModel}</div>
|
||
<div class="overview-card-meta">并发上限 ${config?.agents?.defaults?.maxConcurrent || 4}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="overview-card" data-nav="/skills">
|
||
<div class="overview-card-icon" style="color:var(--warning)">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>
|
||
</div>
|
||
<div class="overview-card-body">
|
||
<div class="overview-card-title">MCP 工具</div>
|
||
<div class="overview-card-value">${mcpCount} 个</div>
|
||
<div class="overview-card-meta">已挂载扩展</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="overview-card" data-nav="/services">
|
||
<div class="overview-card-icon" style="color:var(--text-tertiary)">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||
</div>
|
||
<div class="overview-card-body">
|
||
<div class="overview-card-title">最近备份</div>
|
||
<div class="overview-card-value" style="font-size:var(--font-size-sm)">${latestBackup ? formatDate(latestBackup.created_at) : '从无备份'}</div>
|
||
<div class="overview-card-meta">${backups.length} 个备份文件</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="overview-card" data-nav="/agents">
|
||
<div class="overview-card-icon" style="color:var(--success)">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/></svg>
|
||
</div>
|
||
<div class="overview-card-body">
|
||
<div class="overview-card-title">Agent 舰队</div>
|
||
<div class="overview-card-value">${agents.length} 个</div>
|
||
<div class="overview-card-meta">${agents.filter(a => a.workspace).length} 个独立工作区</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="overview-card">
|
||
<div class="overview-card-icon" style="color:var(--text-tertiary)">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||
</div>
|
||
<div class="overview-card-body">
|
||
<div class="overview-card-title">配置版本</div>
|
||
<div class="overview-card-value" style="font-size:var(--font-size-sm)">${lastUpdate}</div>
|
||
<div class="overview-card-meta">openclaw.json</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`
|
||
|
||
// 概览卡片点击导航
|
||
containerEl.querySelectorAll('[data-nav]').forEach(card => {
|
||
card.style.cursor = 'pointer'
|
||
card.addEventListener('click', (e) => {
|
||
if (e.target.closest('button')) return
|
||
navigate(card.dataset.nav)
|
||
})
|
||
})
|
||
}
|
||
|
||
function renderLogs(page, logs) {
|
||
const logsEl = page.querySelector('#recent-logs')
|
||
if (!logs) {
|
||
logsEl.innerHTML = '<div style="color:var(--text-tertiary);padding:12px">暂无日志</div>'
|
||
return
|
||
}
|
||
const lines = logs.trim().split('\n')
|
||
logsEl.innerHTML = lines.map(l => `<div class="log-line">${escapeHtml(l)}</div>`).join('')
|
||
logsEl.scrollTop = logsEl.scrollHeight
|
||
}
|
||
|
||
function bindActions(page) {
|
||
const btnRestart = page.querySelector('#btn-restart-gw')
|
||
const btnUpdate = page.querySelector('#btn-check-update')
|
||
const btnCreateBackup = page.querySelector('#btn-create-backup')
|
||
|
||
// Control UI 卡片点击 → 打开 OpenClaw 原生面板(用事件委托,因为卡片是动态渲染的)
|
||
page.addEventListener('click', async (e) => {
|
||
const card = e.target.closest('#card-control-ui')
|
||
if (!card) return
|
||
if (e.target.closest('button')) return
|
||
try {
|
||
const config = await api.readOpenclawConfig()
|
||
const port = config?.gateway?.port || 18789
|
||
// 远程部署时使用当前浏览器域名/IP,桌面版用 127.0.0.1
|
||
const host = window.__TAURI_INTERNALS__ ? '127.0.0.1' : (location.hostname || '127.0.0.1')
|
||
const proto = location.protocol === 'https:' ? 'https' : 'http'
|
||
let url = `${proto}://${host}:${port}`
|
||
// 如果 Gateway 配置了 token 鉴权,附加到 URL 方便直接访问
|
||
const authToken = config?.gateway?.auth?.token
|
||
if (authToken) url += `?token=${encodeURIComponent(authToken)}`
|
||
// 尝试多种方式打开浏览器
|
||
if (window.__TAURI_INTERNALS__) {
|
||
try {
|
||
const { open } = await import('@tauri-apps/plugin-shell')
|
||
await open(url)
|
||
} catch {
|
||
window.open(url, '_blank')
|
||
}
|
||
} else {
|
||
window.open(url, '_blank')
|
||
}
|
||
} catch (e2) {
|
||
toast('打开 Control UI 失败: ' + (e2.message || e2), 'error')
|
||
}
|
||
})
|
||
|
||
// 概览区域的 Gateway 启动/停止/重启 + ClawApp 导航
|
||
page.addEventListener('click', async (e) => {
|
||
const actionBtn = e.target.closest('[data-action]')
|
||
if (!actionBtn) return
|
||
const action = actionBtn.dataset.action
|
||
|
||
if (action === 'start-gw') {
|
||
actionBtn.disabled = true; actionBtn.textContent = '启动中...'
|
||
try {
|
||
await api.startService('ai.openclaw.gateway')
|
||
toast('Gateway 启动指令已发送', 'success')
|
||
setTimeout(() => loadDashboardData(page), 2000)
|
||
} catch (err) { toast('启动失败: ' + err, 'error') }
|
||
finally { actionBtn.disabled = false; actionBtn.textContent = '启动' }
|
||
}
|
||
if (action === 'stop-gw') {
|
||
actionBtn.disabled = true; actionBtn.textContent = '停止中...'
|
||
try {
|
||
await api.stopService('ai.openclaw.gateway')
|
||
toast('Gateway 已停止', 'success')
|
||
setTimeout(() => loadDashboardData(page), 1500)
|
||
} catch (err) { toast('停止失败: ' + err, 'error') }
|
||
finally { actionBtn.disabled = false; actionBtn.textContent = '停止' }
|
||
}
|
||
if (action === 'restart-gw') {
|
||
actionBtn.disabled = true; actionBtn.textContent = '重启中...'
|
||
try {
|
||
await api.restartService('ai.openclaw.gateway')
|
||
toast('Gateway 重启指令已发送', 'success')
|
||
setTimeout(() => loadDashboardData(page), 3000)
|
||
} catch (err) { toast('重启失败: ' + err, 'error') }
|
||
finally { actionBtn.disabled = false; actionBtn.textContent = '重启' }
|
||
}
|
||
})
|
||
|
||
btnRestart?.addEventListener('click', async () => {
|
||
btnRestart.disabled = true
|
||
btnRestart.classList.add('btn-loading')
|
||
btnRestart.textContent = '重启中...'
|
||
try {
|
||
await api.restartService('ai.openclaw.gateway')
|
||
} catch (e) {
|
||
toast('重启失败: ' + e, 'error')
|
||
btnRestart.disabled = false
|
||
btnRestart.classList.remove('btn-loading')
|
||
btnRestart.textContent = '重启 Gateway'
|
||
return
|
||
}
|
||
// 轮询等待实际重启完成
|
||
const t0 = Date.now()
|
||
while (Date.now() - t0 < 30000) {
|
||
try {
|
||
const s = await api.getServicesStatus()
|
||
const gw = s?.find?.(x => x.label === 'ai.openclaw.gateway') || s?.[0]
|
||
if (gw?.running) {
|
||
toast(`Gateway 已重启 (PID: ${gw.pid})`, 'success')
|
||
btnRestart.disabled = false
|
||
btnRestart.classList.remove('btn-loading')
|
||
btnRestart.textContent = '重启 Gateway'
|
||
loadDashboardData(page)
|
||
return
|
||
}
|
||
} catch {}
|
||
const sec = Math.floor((Date.now() - t0) / 1000)
|
||
btnRestart.textContent = `重启中... ${sec}s`
|
||
await new Promise(r => setTimeout(r, 1500))
|
||
}
|
||
toast('重启超时,Gateway 可能仍在启动中', 'warning')
|
||
btnRestart.disabled = false
|
||
btnRestart.classList.remove('btn-loading')
|
||
btnRestart.textContent = '重启 Gateway'
|
||
loadDashboardData(page)
|
||
})
|
||
|
||
btnUpdate?.addEventListener('click', async () => {
|
||
btnUpdate.disabled = true
|
||
btnUpdate.textContent = '检查中...'
|
||
try {
|
||
const info = await api.getVersionInfo()
|
||
if (info.update_available) {
|
||
toast(`发现新版本: ${info.latest}`, 'info')
|
||
} else {
|
||
toast('已是最新版本', 'success')
|
||
}
|
||
} catch (e) {
|
||
toast('检查更新失败: ' + e, 'error')
|
||
} finally {
|
||
btnUpdate.disabled = false
|
||
btnUpdate.textContent = '检查更新'
|
||
}
|
||
})
|
||
|
||
btnCreateBackup?.addEventListener('click', async () => {
|
||
btnCreateBackup.disabled = true
|
||
btnCreateBackup.innerHTML = '备份中...'
|
||
try {
|
||
const res = await api.createBackup()
|
||
toast(`已备份: ${res.name}`, 'success')
|
||
setTimeout(() => loadDashboardData(page), 500)
|
||
} catch (e) {
|
||
toast('备份失败: ' + e, 'error')
|
||
} finally {
|
||
btnCreateBackup.disabled = false
|
||
btnCreateBackup.textContent = '创建备份'
|
||
}
|
||
})
|
||
}
|
||
|
||
function escapeHtml(str) {
|
||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
}
|