mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-06-08 17:20:01 +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:
277
src/pages/route-map.js
Normal file
277
src/pages/route-map.js
Normal file
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* 路由地图 — Channel → Binding → Agent 全局拓扑可视化
|
||||
*/
|
||||
import { api } from '../lib/tauri-api.js'
|
||||
import { toast } from '../components/toast.js'
|
||||
import { navigate } from '../router.js'
|
||||
import { t } from '../lib/i18n.js'
|
||||
|
||||
const CHANNEL_COLORS = {
|
||||
qqbot: '#22d3ee', qq: '#22d3ee', telegram: '#3b82f6', discord: '#818cf8',
|
||||
slack: '#f59e0b', feishu: '#6366f1', dingtalk: '#3b82f6', weixin: '#22c55e',
|
||||
wechat: '#22c55e', webchat: '#a78bfa', whatsapp: '#22c55e', signal: '#60a5fa',
|
||||
line: '#22c55e', teams: '#6366f1', matrix: '#f472b6', irc: '#94a3b8',
|
||||
}
|
||||
|
||||
const NODE_W = 180, NODE_H = 56, COL_GAP = 260, ROW_GAP = 16, PAD_TOP = 80, PAD_LEFT = 40
|
||||
|
||||
function escAttr(s) { return String(s || '').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('routeMap.title')}</h1>
|
||||
<div class="page-actions" style="display:flex;align-items:center;gap:var(--space-sm)">
|
||||
<span style="font-size:var(--font-size-xs);color:var(--text-tertiary)">${t('routeMap.clickToNavigate')}</span>
|
||||
<button class="btn btn-sm btn-secondary" id="rm-refresh">${t('routeMap.refresh')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="form-hint" style="margin-bottom:var(--space-md)">${t('routeMap.subtitle')}</p>
|
||||
<div id="rm-stats" class="route-map-stats"></div>
|
||||
<div id="rm-canvas" class="route-map-canvas">
|
||||
<div class="stat-card loading-placeholder" style="height:300px"></div>
|
||||
</div>
|
||||
`
|
||||
|
||||
async function loadAndRender() {
|
||||
const canvas = page.querySelector('#rm-canvas')
|
||||
const statsEl = page.querySelector('#rm-stats')
|
||||
canvas.innerHTML = '<div class="stat-card loading-placeholder" style="height:300px;display:flex;align-items:center;justify-content:center;color:var(--text-tertiary)">' + t('routeMap.loading') + '</div>'
|
||||
|
||||
try {
|
||||
const [agentsRaw, bindingsRaw, platformsRaw] = await Promise.all([
|
||||
api.listAgents(),
|
||||
api.listAllBindings().catch(() => []),
|
||||
api.listConfiguredPlatforms().catch(() => []),
|
||||
])
|
||||
|
||||
const agents = Array.isArray(agentsRaw) ? agentsRaw : (agentsRaw?.agents || [])
|
||||
const bindings = Array.isArray(bindingsRaw) ? bindingsRaw : (bindingsRaw?.bindings || [])
|
||||
const platforms = Array.isArray(platformsRaw) ? platformsRaw : []
|
||||
|
||||
if (agents.length === 0 && platforms.length === 0) {
|
||||
canvas.innerHTML = `<div class="stat-card" style="padding:var(--space-xl);text-align:center;color:var(--text-tertiary)">${t('routeMap.noData')}</div>`
|
||||
statsEl.innerHTML = ''
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch agent details to get sub-agent relationships
|
||||
const agentDetails = await Promise.all(
|
||||
agents.map(a => api.getAgentDetail(a.id || a.name || 'main').catch(() => null))
|
||||
)
|
||||
// Merge detail data into agents
|
||||
for (let i = 0; i < agents.length; i++) {
|
||||
if (agentDetails[i]) {
|
||||
agents[i] = { ...agents[i], ...agentDetails[i] }
|
||||
}
|
||||
}
|
||||
|
||||
// Stats bar
|
||||
const subAgentCount = agents.filter(a => {
|
||||
const allow = a.tools?.agentToAgent?.allow
|
||||
return Array.isArray(allow) && allow.length > 0
|
||||
}).length
|
||||
statsEl.innerHTML = `
|
||||
<div class="route-map-stat"><span class="route-map-stat-num">${agents.length}</span><span class="route-map-stat-label">${t('routeMap.statsAgents')}</span></div>
|
||||
<div class="route-map-stat"><span class="route-map-stat-num">${platforms.length}</span><span class="route-map-stat-label">${t('routeMap.statsChannels')}</span></div>
|
||||
<div class="route-map-stat"><span class="route-map-stat-num">${bindings.length}</span><span class="route-map-stat-label">${t('routeMap.statsBindings')}</span></div>
|
||||
${subAgentCount ? `<div class="route-map-stat"><span class="route-map-stat-num">${subAgentCount}</span><span class="route-map-stat-label">${t('routeMap.subAgentRelations')}</span></div>` : ''}
|
||||
`
|
||||
|
||||
renderTopology(canvas, agents, bindings, platforms)
|
||||
} catch (e) {
|
||||
canvas.innerHTML = `<div class="stat-card" style="padding:var(--space-lg);color:var(--text-danger)">${escAttr(e.message || e)}</div>`
|
||||
}
|
||||
}
|
||||
|
||||
page.querySelector('#rm-refresh').onclick = () => loadAndRender()
|
||||
setTimeout(loadAndRender, 0)
|
||||
return page
|
||||
}
|
||||
|
||||
function renderTopology(container, agents, bindings, platforms) {
|
||||
// Build channel nodes (left column)
|
||||
const channelNodes = platforms.map((p, i) => {
|
||||
const id = p.platform || p.id || p.channel || `ch-${i}`
|
||||
const label = p.label || p.platform || id
|
||||
const enabled = p.enabled !== false
|
||||
return { id, label, enabled, color: CHANNEL_COLORS[id.toLowerCase()] || '#94a3b8', type: 'channel', originalIndex: i }
|
||||
})
|
||||
|
||||
// Build agent nodes (right column)
|
||||
const defaultAgentId = agents.find(a => a.default || a.isDefault)?.id || agents[0]?.id || 'main'
|
||||
const agentNodes = agents.map((a, i) => {
|
||||
const id = a.id || a.name || `agent-${i}`
|
||||
const identity = a.identity || {}
|
||||
const emoji = identity.emoji || '🤖'
|
||||
const label = identity.name || id
|
||||
const isDefault = a.default || a.isDefault || id === defaultAgentId
|
||||
return { id, label, emoji, isDefault, type: 'agent', originalIndex: i }
|
||||
})
|
||||
|
||||
// Build edges from bindings
|
||||
const edges = []
|
||||
for (const b of bindings) {
|
||||
const agentId = b.agentId || b.agent || ''
|
||||
const channel = b.match?.channel || b.channel || ''
|
||||
const enabled = b.enabled !== false
|
||||
const peer = b.match?.peer
|
||||
const accountId = b.match?.accountId
|
||||
let hint = ''
|
||||
if (peer) hint = t('routeMap.peer')
|
||||
else if (accountId) hint = `${t('routeMap.account')}: ${accountId}`
|
||||
edges.push({ from: channel, to: agentId, enabled, hint, channel, agentId })
|
||||
}
|
||||
|
||||
// Add implicit default agent edges for channels without bindings
|
||||
const boundChannels = new Set(edges.map(e => e.from))
|
||||
for (const ch of channelNodes) {
|
||||
if (!boundChannels.has(ch.id) && ch.enabled) {
|
||||
edges.push({ from: ch.id, to: defaultAgentId, enabled: true, hint: t('routeMap.defaultAgent'), channel: ch.id, agentId: defaultAgentId, implicit: true })
|
||||
}
|
||||
}
|
||||
|
||||
// Layout: 3 columns — Channels | gap | Agents
|
||||
const leftCount = Math.max(channelNodes.length, 1)
|
||||
const rightCount = Math.max(agentNodes.length, 1)
|
||||
const maxRows = Math.max(leftCount, rightCount)
|
||||
const svgW = PAD_LEFT * 2 + NODE_W * 2 + COL_GAP
|
||||
const svgH = PAD_TOP + maxRows * (NODE_H + ROW_GAP) + 40
|
||||
|
||||
// Position nodes
|
||||
channelNodes.forEach((n, i) => {
|
||||
n.x = PAD_LEFT
|
||||
n.y = PAD_TOP + i * (NODE_H + ROW_GAP) + (maxRows - leftCount) * (NODE_H + ROW_GAP) / 2
|
||||
})
|
||||
agentNodes.forEach((n, i) => {
|
||||
n.x = PAD_LEFT + NODE_W + COL_GAP
|
||||
n.y = PAD_TOP + i * (NODE_H + ROW_GAP) + (maxRows - rightCount) * (NODE_H + ROW_GAP) / 2
|
||||
})
|
||||
|
||||
// Build agent-to-agent edges from tools.agentToAgent.allow
|
||||
const a2aEdges = []
|
||||
for (const a of agents) {
|
||||
const allow = a.tools?.agentToAgent?.allow
|
||||
if (!Array.isArray(allow) || a.tools?.agentToAgent?.enabled === false) continue
|
||||
const fromId = a.id || a.name || 'main'
|
||||
for (const targetId of allow) {
|
||||
if (targetId && targetId !== fromId) {
|
||||
a2aEdges.push({ from: fromId, to: targetId })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build node lookup
|
||||
const nodeMap = {}
|
||||
for (const n of [...channelNodes, ...agentNodes]) nodeMap[n.id] = n
|
||||
|
||||
// Extra height for legend if we have a2a edges
|
||||
const legendH = a2aEdges.length > 0 ? 60 : 20
|
||||
|
||||
// Render SVG
|
||||
let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${svgW}" height="${svgH + legendH}" viewBox="0 0 ${svgW} ${svgH + legendH}" class="route-map-svg">`
|
||||
|
||||
// Column headers
|
||||
svg += `<text x="${PAD_LEFT + NODE_W / 2}" y="30" text-anchor="middle" class="route-map-col-label">${t('routeMap.channels')}</text>`
|
||||
svg += `<text x="${PAD_LEFT + NODE_W + COL_GAP + NODE_W / 2}" y="30" text-anchor="middle" class="route-map-col-label">${t('routeMap.agents')}</text>`
|
||||
svg += `<text x="${PAD_LEFT + NODE_W + COL_GAP / 2}" y="30" text-anchor="middle" class="route-map-col-label" style="font-size:11px;opacity:0.5">${t('routeMap.bindings')}</text>`
|
||||
|
||||
// Draw edges
|
||||
for (const e of edges) {
|
||||
const src = nodeMap[e.from]
|
||||
const dst = nodeMap[e.to]
|
||||
if (!src || !dst) continue
|
||||
const x1 = src.x + NODE_W
|
||||
const y1 = src.y + NODE_H / 2
|
||||
const x2 = dst.x
|
||||
const y2 = dst.y + NODE_H / 2
|
||||
const cx1 = x1 + COL_GAP * 0.35
|
||||
const cx2 = x2 - COL_GAP * 0.35
|
||||
const opacity = e.enabled ? 0.7 : 0.25
|
||||
const dash = e.implicit ? '6,4' : 'none'
|
||||
const color = e.enabled ? (src.color || '#6366f1') : '#94a3b8'
|
||||
svg += `<path d="M${x1},${y1} C${cx1},${y1} ${cx2},${y2} ${x2},${y2}" fill="none" stroke="${color}" stroke-width="2" stroke-opacity="${opacity}" stroke-dasharray="${dash}"/>`
|
||||
// Arrow
|
||||
svg += `<circle cx="${x2 - 3}" cy="${y2}" r="3" fill="${color}" fill-opacity="${opacity}"/>`
|
||||
// Edge label
|
||||
if (e.hint) {
|
||||
const mx = (x1 + x2) / 2
|
||||
const my = (y1 + y2) / 2
|
||||
svg += `<text x="${mx}" y="${my - 6}" text-anchor="middle" class="route-map-edge-label">${escAttr(e.hint)}</text>`
|
||||
}
|
||||
}
|
||||
|
||||
// Draw channel nodes
|
||||
for (const n of channelNodes) {
|
||||
const opacity = n.enabled ? 1 : 0.45
|
||||
svg += `<g class="route-map-node" data-nav="channels" style="cursor:pointer;opacity:${opacity}">
|
||||
<rect x="${n.x}" y="${n.y}" width="${NODE_W}" height="${NODE_H}" rx="10" class="route-map-card" style="stroke:${n.color}"/>
|
||||
<circle cx="${n.x + 22}" cy="${n.y + NODE_H / 2}" r="8" fill="${n.color}" fill-opacity="0.15"/>
|
||||
<text x="${n.x + 22}" y="${n.y + NODE_H / 2 + 1}" text-anchor="middle" class="route-map-node-emoji" style="font-size:10px">${n.enabled ? '📡' : '⏸'}</text>
|
||||
<text x="${n.x + 40}" y="${n.y + NODE_H / 2 - 4}" class="route-map-node-label">${escAttr(n.label)}</text>
|
||||
<text x="${n.x + 40}" y="${n.y + NODE_H / 2 + 12}" class="route-map-node-sub">${n.enabled ? t('routeMap.enabled') : t('routeMap.disabled')}</text>
|
||||
</g>`
|
||||
}
|
||||
|
||||
// Draw agent nodes
|
||||
for (const n of agentNodes) {
|
||||
svg += `<g class="route-map-node" data-nav="agents" style="cursor:pointer">
|
||||
<rect x="${n.x}" y="${n.y}" width="${NODE_W}" height="${NODE_H}" rx="10" class="route-map-card ${n.isDefault ? 'route-map-card-default' : ''}"/>
|
||||
<text x="${n.x + 22}" y="${n.y + NODE_H / 2 + 5}" text-anchor="middle" style="font-size:16px">${n.emoji}</text>
|
||||
<text x="${n.x + 40}" y="${n.y + NODE_H / 2 - 4}" class="route-map-node-label">${escAttr(n.label)}</text>
|
||||
<text x="${n.x + 40}" y="${n.y + NODE_H / 2 + 12}" class="route-map-node-sub">${n.isDefault ? '⭐ ' + t('routeMap.defaultAgent') : n.id}</text>
|
||||
</g>`
|
||||
}
|
||||
|
||||
// Draw agent-to-agent sub-agent edges (amber dashed, curved right of agent column)
|
||||
for (let i = 0; i < a2aEdges.length; i++) {
|
||||
const e = a2aEdges[i]
|
||||
const src = nodeMap[e.from]
|
||||
const dst = nodeMap[e.to]
|
||||
if (!src || !dst) continue
|
||||
const x1 = src.x + NODE_W
|
||||
const y1 = src.y + NODE_H / 2
|
||||
const x2 = dst.x + NODE_W
|
||||
const y2 = dst.y + NODE_H / 2
|
||||
const bulge = 40 + i * 12
|
||||
const cx = Math.max(x1, x2) + bulge
|
||||
svg += `<path d="M${x1},${y1} Q${cx},${(y1 + y2) / 2} ${x2},${y2}" fill="none" stroke="#f59e0b" stroke-width="1.5" stroke-dasharray="4,3" stroke-opacity="0.7"/>`
|
||||
svg += `<circle cx="${x2}" cy="${y2}" r="2.5" fill="#f59e0b" fill-opacity="0.7"/>`
|
||||
const mx = cx - 4
|
||||
const my = (y1 + y2) / 2
|
||||
svg += `<text x="${mx}" y="${my - 4}" text-anchor="end" class="route-map-edge-label" style="fill:#f59e0b">${t('routeMap.subAgentCall')}</text>`
|
||||
}
|
||||
|
||||
// Legend
|
||||
const ly = svgH + (a2aEdges.length > 0 ? 10 : 0)
|
||||
svg += `<g class="route-map-legend">`
|
||||
let lx = PAD_LEFT
|
||||
// Solid line = explicit binding
|
||||
svg += `<line x1="${lx}" y1="${ly}" x2="${lx + 24}" y2="${ly}" stroke="var(--accent)" stroke-width="2"/>`
|
||||
svg += `<text x="${lx + 30}" y="${ly + 4}" class="route-map-edge-label" style="font-size:10px;fill:var(--text-secondary)">${t('routeMap.legendBinding')}</text>`
|
||||
lx += 110
|
||||
// Dashed line = default route
|
||||
svg += `<line x1="${lx}" y1="${ly}" x2="${lx + 24}" y2="${ly}" stroke="#94a3b8" stroke-width="2" stroke-dasharray="6,4"/>`
|
||||
svg += `<text x="${lx + 30}" y="${ly + 4}" class="route-map-edge-label" style="font-size:10px;fill:var(--text-secondary)">${t('routeMap.legendDefault')}</text>`
|
||||
if (a2aEdges.length > 0) {
|
||||
lx += 110
|
||||
svg += `<line x1="${lx}" y1="${ly}" x2="${lx + 24}" y2="${ly}" stroke="#f59e0b" stroke-width="1.5" stroke-dasharray="4,3"/>`
|
||||
svg += `<text x="${lx + 30}" y="${ly + 4}" class="route-map-edge-label" style="font-size:10px;fill:#f59e0b">${t('routeMap.subAgentCall')}</text>`
|
||||
}
|
||||
svg += `</g>`
|
||||
|
||||
svg += '</svg>'
|
||||
|
||||
container.innerHTML = `<div class="route-map-scroll">${svg}</div>`
|
||||
|
||||
// Click to navigate
|
||||
container.querySelectorAll('.route-map-node').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
const target = el.dataset.nav
|
||||
if (target) navigate('/' + target)
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user