mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-06 20:02:49 +08:00
feat: new pages + dashboard enhancements + backend improvements
New pages: - Plugin Hub: grid cards, search, install/toggle/enable plugins - Route Map: SVG visualization of channels→agents bindings with legends - Diagnose: gateway connectivity diagnosis with step-by-step checks Dashboard enhancements: - WebSocket status indicator (connected/handshaking/reconnecting/disconnected) - Connected channels overview with platform icons - Colored log level badges (ERROR/WARN/INFO/DEBUG) with timestamps - Channels data loading in dashboard secondary fetch Splash screen: - Multi-stage boot detection (JS not loaded vs boot slow vs timeout) - 15s: WebView2/resource load failure - 20s: "initializing..." hint with elapsed counter - 90s: true timeout error Backend (Rust): - diagnose.rs: gateway connectivity diagnosis command - messaging.rs: plugin management commands - service.rs: improvements - lib.rs: register new commands Frontend libs: - feature-gates.js: feature flag system - ws-client.js: reconnect state tracking - tauri-api.js: new API bindings - model-presets.js: provider fixes - Remove gateway-guardian-policy.js (unused) Dev API (scripts/dev-api.js): - list_all_plugins, toggle_plugin, install_plugin handlers - probe_gateway_port, diagnose_gateway_connection handlers i18n: dashboard, sidebar, diagnose, extensions, routeMap locale modules CSS: plugin-hub cards, route-map SVG styles
This commit is contained in:
@@ -3373,6 +3373,79 @@ const handlers = {
|
||||
}
|
||||
},
|
||||
|
||||
list_all_plugins() {
|
||||
const cfg = readOpenclawConfigOptional()
|
||||
const entries = cfg.plugins?.entries || {}
|
||||
const allowArr = cfg.plugins?.allow || []
|
||||
const extDir = path.join(OPENCLAW_DIR, 'extensions')
|
||||
const plugins = []
|
||||
const seen = new Set()
|
||||
|
||||
// Scan extensions directory
|
||||
if (fs.existsSync(extDir)) {
|
||||
for (const name of fs.readdirSync(extDir)) {
|
||||
if (name.startsWith('.')) continue
|
||||
const p = path.join(extDir, name)
|
||||
if (!fs.statSync(p).isDirectory()) continue
|
||||
const hasMarker = fs.existsSync(path.join(p, 'package.json')) || fs.existsSync(path.join(p, 'plugin.ts')) || fs.existsSync(path.join(p, 'index.js'))
|
||||
if (!hasMarker) continue
|
||||
seen.add(name)
|
||||
const entryCfg = entries[name]
|
||||
const enabled = !!entryCfg?.enabled
|
||||
const allowed = allowArr.includes(name)
|
||||
let version = null, description = null
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(p, 'package.json'), 'utf8'))
|
||||
version = pkg.version || null
|
||||
description = pkg.description || null
|
||||
} catch {}
|
||||
plugins.push({ id: name, installed: true, builtin: false, enabled, allowed, version, description, config: entryCfg?.config || null })
|
||||
}
|
||||
}
|
||||
|
||||
// Include entries from config not found in extensions dir
|
||||
for (const [pid, val] of Object.entries(entries)) {
|
||||
if (seen.has(pid)) continue
|
||||
seen.add(pid)
|
||||
plugins.push({ id: pid, installed: false, builtin: false, enabled: !!val?.enabled, allowed: allowArr.includes(pid), version: null, description: null, config: val?.config || null })
|
||||
}
|
||||
|
||||
plugins.sort((a, b) => (b.enabled ? 1 : 0) - (a.enabled ? 1 : 0) || a.id.localeCompare(b.id))
|
||||
return { plugins }
|
||||
},
|
||||
|
||||
toggle_plugin({ pluginId, enabled }) {
|
||||
if (!pluginId || !pluginId.trim()) throw new Error('pluginId 不能为空')
|
||||
const pid = pluginId.trim()
|
||||
const cfg = readOpenclawConfigOptional()
|
||||
if (!cfg.plugins) cfg.plugins = {}
|
||||
if (!cfg.plugins.entries) cfg.plugins.entries = {}
|
||||
if (!cfg.plugins.allow) cfg.plugins.allow = []
|
||||
|
||||
if (enabled) {
|
||||
if (!cfg.plugins.allow.includes(pid)) cfg.plugins.allow.push(pid)
|
||||
if (!cfg.plugins.entries[pid]) cfg.plugins.entries[pid] = {}
|
||||
cfg.plugins.entries[pid].enabled = true
|
||||
} else {
|
||||
cfg.plugins.allow = cfg.plugins.allow.filter(v => v !== pid)
|
||||
if (cfg.plugins.entries[pid]) cfg.plugins.entries[pid].enabled = false
|
||||
}
|
||||
|
||||
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), 'utf8')
|
||||
return { ok: true, enabled, pluginId: pid }
|
||||
},
|
||||
|
||||
install_plugin({ packageName }) {
|
||||
if (!packageName || !packageName.trim()) throw new Error('包名不能为空')
|
||||
const spec = packageName.trim()
|
||||
try {
|
||||
execOpenclawSync(['plugins', 'install', spec], { timeout: 120000, cwd: homedir(), windowsHide: true }, `插件 ${spec} 安装失败`)
|
||||
return { ok: true, output: '安装成功' }
|
||||
} catch (e) {
|
||||
throw new Error(`插件安装失败: ${e.message || e}`)
|
||||
}
|
||||
},
|
||||
|
||||
get_channel_plugin_status({ pluginId }) {
|
||||
if (!pluginId || !pluginId.trim()) throw new Error('pluginId 不能为空')
|
||||
const pid = pluginId.trim()
|
||||
@@ -4480,6 +4553,129 @@ const handlers = {
|
||||
}
|
||||
},
|
||||
|
||||
async probe_gateway_port() {
|
||||
const port = readGatewayPort()
|
||||
return new Promise(resolve => {
|
||||
const net = require('net')
|
||||
const sock = net.createConnection({ host: '127.0.0.1', port, timeout: 3000 })
|
||||
sock.on('connect', () => { sock.destroy(); resolve(true) })
|
||||
sock.on('error', () => resolve(false))
|
||||
sock.on('timeout', () => { sock.destroy(); resolve(false) })
|
||||
})
|
||||
},
|
||||
|
||||
async diagnose_gateway_connection() {
|
||||
const steps = []
|
||||
const ocDir = openclawDir()
|
||||
const configPath = path.join(ocDir, 'openclaw.json')
|
||||
const port = readGatewayPort()
|
||||
|
||||
// 1. 配置文件
|
||||
const t1 = Date.now()
|
||||
try {
|
||||
const content = fs.readFileSync(configPath, 'utf-8')
|
||||
const val = JSON.parse(content)
|
||||
steps.push({ name: 'config', ok: !!val.gateway, message: val.gateway ? '配置文件有效,含 gateway 配置' : '配置文件缺少 gateway 段', durationMs: Date.now() - t1 })
|
||||
} catch (e) {
|
||||
steps.push({ name: 'config', ok: false, message: `配置文件异常: ${e.message}`, durationMs: Date.now() - t1 })
|
||||
}
|
||||
|
||||
// 2. 设备密钥
|
||||
const t2 = Date.now()
|
||||
const keyPath = path.join(ocDir, 'clawpanel-device-key.json')
|
||||
const keyExists = fs.existsSync(keyPath)
|
||||
steps.push({ name: 'device_key', ok: keyExists, message: keyExists ? '设备密钥存在' : '设备密钥不存在', durationMs: Date.now() - t2 })
|
||||
|
||||
// 3. allowedOrigins
|
||||
const t3 = Date.now()
|
||||
try {
|
||||
const val = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
|
||||
const origins = val?.gateway?.controlUi?.allowedOrigins
|
||||
if (Array.isArray(origins) && origins.length > 0) {
|
||||
steps.push({ name: 'allowed_origins', ok: true, message: `allowedOrigins: ${JSON.stringify(origins)}`, durationMs: Date.now() - t3 })
|
||||
} else {
|
||||
steps.push({ name: 'allowed_origins', ok: false, message: '未配置 allowedOrigins', durationMs: Date.now() - t3 })
|
||||
}
|
||||
} catch {
|
||||
steps.push({ name: 'allowed_origins', ok: false, message: '配置文件不可读', durationMs: Date.now() - t3 })
|
||||
}
|
||||
|
||||
// 4. TCP 端口
|
||||
const t4 = Date.now()
|
||||
const tcpOk = await new Promise(resolve => {
|
||||
const net = require('net')
|
||||
const sock = net.createConnection({ host: '127.0.0.1', port, timeout: 3000 })
|
||||
sock.on('connect', () => { sock.destroy(); resolve(true) })
|
||||
sock.on('error', () => resolve(false))
|
||||
sock.on('timeout', () => { sock.destroy(); resolve(false) })
|
||||
})
|
||||
steps.push({ name: 'tcp_port', ok: tcpOk, message: tcpOk ? `端口 ${port} 可达` : `端口 ${port} 不可达`, durationMs: Date.now() - t4 })
|
||||
|
||||
// 5. HTTP /health
|
||||
const t5 = Date.now()
|
||||
let httpOk = false
|
||||
let httpMsg = ''
|
||||
try {
|
||||
const resp = await fetch(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(5000) })
|
||||
httpOk = resp.ok
|
||||
httpMsg = `HTTP /health 返回 ${resp.status}`
|
||||
} catch (e) {
|
||||
httpMsg = `HTTP /health 请求失败: ${e.message}`
|
||||
}
|
||||
steps.push({ name: 'http_health', ok: httpOk, message: httpMsg, durationMs: Date.now() - t5 })
|
||||
|
||||
// 6. 错误日志
|
||||
const t6 = Date.now()
|
||||
const errLogPath = path.join(ocDir, 'logs', 'gateway.err.log')
|
||||
if (fs.existsSync(errLogPath)) {
|
||||
const stat = fs.statSync(errLogPath)
|
||||
if (stat.size === 0) {
|
||||
steps.push({ name: 'err_log', ok: true, message: '错误日志为空(正常)', durationMs: Date.now() - t6 })
|
||||
} else {
|
||||
const buf = Buffer.alloc(Math.min(1024, stat.size))
|
||||
const fd = fs.openSync(errLogPath, 'r')
|
||||
fs.readSync(fd, buf, 0, buf.length, Math.max(0, stat.size - buf.length))
|
||||
fs.closeSync(fd)
|
||||
const tail = buf.toString('utf-8').toLowerCase()
|
||||
const hasFatal = tail.includes('fatal') || tail.includes('eaddrinuse') || tail.includes('config invalid')
|
||||
steps.push({ name: 'err_log', ok: !hasFatal, message: hasFatal ? `错误日志含关键错误 (${stat.size} bytes)` : `错误日志存在但无致命错误 (${stat.size} bytes)`, durationMs: Date.now() - t6 })
|
||||
}
|
||||
} else {
|
||||
steps.push({ name: 'err_log', ok: true, message: '无错误日志(正常)', durationMs: Date.now() - t6 })
|
||||
}
|
||||
|
||||
// env
|
||||
let authMode = 'none'
|
||||
try {
|
||||
const val = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
|
||||
const auth = val?.gateway?.auth
|
||||
if (auth?.token) authMode = 'token'
|
||||
else if (auth?.password) authMode = 'password'
|
||||
} catch {}
|
||||
let errLogExcerpt = ''
|
||||
try {
|
||||
const buf = fs.readFileSync(errLogPath)
|
||||
errLogExcerpt = buf.slice(Math.max(0, buf.length - 2048)).toString('utf-8')
|
||||
} catch {}
|
||||
|
||||
const overallOk = steps.every(s => s.ok)
|
||||
const failed = steps.filter(s => !s.ok).map(s => s.name)
|
||||
return {
|
||||
steps,
|
||||
env: {
|
||||
openclawDir: ocDir,
|
||||
configExists: fs.existsSync(configPath),
|
||||
port,
|
||||
authMode,
|
||||
deviceKeyExists: keyExists,
|
||||
gatewayOwner: null,
|
||||
errLogExcerpt,
|
||||
},
|
||||
overallOk,
|
||||
summary: overallOk ? '所有检查项通过' : `以下检查未通过: ${failed.join(', ')}`,
|
||||
}
|
||||
},
|
||||
|
||||
guardian_status() {
|
||||
// Web 模式没有 Guardian 守护进程
|
||||
return { enabled: false, giveUp: false }
|
||||
|
||||
Reference in New Issue
Block a user