From 154b863f78c81f55ba8fc23617b88e2bdf3799c5 Mon Sep 17 00:00:00 2001 From: Tao Date: Fri, 27 Feb 2026 18:05:24 +1000 Subject: [PATCH] Dashboard redesign --- DEVLOG.md | 35 +++++++- openclaw-manager.js | 193 +++++++++++++++++++++++++++++++++----------- 2 files changed, 182 insertions(+), 46 deletions(-) diff --git a/DEVLOG.md b/DEVLOG.md index 923f3c0..3701fba 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -1,7 +1,40 @@ # OpenClaw Manager — 开发日志 > 最后更新:2026-02-27 -> 当前版本:v0.6.6 +> 当前版本:v0.7.0 + +--- + +## v0.7.0 更新日志(2026-02-27) + +### New Features + +**Dashboard Redesign — Circular Gauge System Health** +- CPU%, RAM%, DISK% displayed as SVG circular gauges with colour-coded arcs (green < 70%, amber < 90%, red ≥ 90%) +- System info card: hostname, OS, Node.js, CPU model, cores, uptime, load average (1/5/15 min) +- Agent overview: separate main agent count and sub-agent count (plus total) +- Auto-refresh toggle: when enabled, dashboard polls `/api/dashboard` every 10 seconds +- Backend now measures CPU usage via `os.cpus()` delta sampling (200ms interval) and returns `loadAvg` from `os.loadavg()` + +**Sub-Agent Creation Flow — Step-by-Step Guide Rewrite** +- Expanded from 3 steps to 5 detailed steps covering the full BotFather → Telegram Group → OCM workflow +- Step 1: Create new Bot via `/newbot` in BotFather +- Step 2: Disable Group Privacy via `/mybots` → Bot Settings → Group Privacy → Turn off +- Step 3: Create Telegram group, add Bot (with security warning) +- Step 4: Get Group ID from gateway logs +- Step 5: Fill in the form +- New "Your Telegram User ID" input field — auto-writes to `channels.telegram.allowFrom` whitelist on creation +- Security warning banner: "Do NOT add other people to this group" with explanation about API cost risks + +**Setup Page — English Localization** +- All Chinese text in the initial directory selection page translated to English +- Includes: title, description, labels, placeholders, error messages, button text + +### Technical Notes + +- **CPU usage measurement:** Two `os.cpus()` snapshots 200ms apart, calculating idle-to-total ratio across all cores +- **Gauge rendering:** Pure SVG arcs with `stroke-dasharray` animation, no external libraries. 270° arc (gap at bottom), colour transitions via `gaugeColor()` function +- **allowFrom auto-config:** `POST /api/agents` now accepts optional `telegramUserId` field (numeric string), appends to `channels.telegram.allowFrom[]` if not already present --- diff --git a/openclaw-manager.js b/openclaw-manager.js index 3383b77..313e647 100644 --- a/openclaw-manager.js +++ b/openclaw-manager.js @@ -1,6 +1,6 @@ #!/usr/bin/env node // ================================================================ -// OpenClaw Manager v0.6.5 +// OpenClaw Manager v0.7.0 // 跨平台本地管理工具 (Windows / macOS / Linux) // // 用法: @@ -24,7 +24,7 @@ const SCRIPT_DIR = __dirname; const MANAGER_CONFIG = path.join(SCRIPT_DIR, 'manager-config.json'); let PORT = 3333; let HOST = '0.0.0.0'; -const APP_VERSION = '0.6.6'; +const APP_VERSION = '0.7.0'; // --port 参数 const portIdx = process.argv.indexOf('--port'); if (portIdx !== -1 && process.argv[portIdx + 1]) PORT = parseInt(process.argv[portIdx + 1]) || 3333; @@ -456,7 +456,7 @@ async function handleApi(req, res, urlObj, body) { // POST /api/agents — create sub-agent (shares parent bot) if (method === 'POST' && pathname === '/api/agents') { - const { agentId, displayName, groupId, workspaceFolder, model, purpose, personality, initialMemory, parentAgentId } = body; + const { agentId, displayName, groupId, workspaceFolder, model, purpose, personality, initialMemory, parentAgentId, telegramUserId } = body; if (!agentId || !/^[a-zA-Z0-9_-]+$/.test(agentId)) { res.writeHead(400); res.end(JSON.stringify({ error: 'Agent ID must contain only alphanumeric characters, underscores, or dashes' })); return; } @@ -495,6 +495,14 @@ async function handleApi(req, res, urlObj, body) { if (!cfg.channels.telegram) cfg.channels.telegram = {}; if (!cfg.channels.telegram.groups) cfg.channels.telegram.groups = {}; cfg.channels.telegram.groups[gid] = { requireMention: false }; + // Add telegramUserId to allowFrom whitelist if provided + if (telegramUserId && /^\d+$/.test(String(telegramUserId).trim())) { + const uid = parseInt(String(telegramUserId).trim()); + if (!cfg.channels.telegram.allowFrom) cfg.channels.telegram.allowFrom = []; + if (!cfg.channels.telegram.allowFrom.includes(uid)) { + cfg.channels.telegram.allowFrom.push(uid); + } + } const bakPath = await writeConfig(cfg, 'create'); await fsp.mkdir(wsPath, { recursive: true }); await fsp.mkdir(path.join(wsPath, 'memory'), { recursive: true }); @@ -1247,6 +1255,25 @@ async function handleApi(req, res, urlObj, body) { const cpus = os.cpus(); const cpuModel = cpus.length ? cpus[0].model.trim() : 'Unknown'; const cpuCores = cpus.length; + const loadAvg = os.loadavg(); // [1min, 5min, 15min] + + // CPU usage % (snapshot via /proc/stat or fallback to loadavg) + let cpuPercent = null; + try { + // Quick estimate: sum idle vs total across all cores from a snapshot + const c1 = os.cpus(); + await new Promise(r => setTimeout(r, 200)); + const c2 = os.cpus(); + let idleDiff = 0, totalDiff = 0; + for (let i = 0; i < c2.length; i++) { + const t1 = c1[i].times, t2 = c2[i].times; + const total1 = t1.user + t1.nice + t1.sys + t1.idle + t1.irq; + const total2 = t2.user + t2.nice + t2.sys + t2.idle + t2.irq; + idleDiff += (t2.idle - t1.idle); + totalDiff += (total2 - total1); + } + cpuPercent = totalDiff > 0 ? Math.round((1 - idleDiff / totalDiff) * 100) : 0; + } catch (_) {} // Disk usage (best-effort, works on macOS/Linux) let diskTotal = 0, diskUsed = 0, diskFree = 0; @@ -1295,9 +1322,21 @@ async function handleApi(req, res, urlObj, body) { gatewayPing = code > 0 && code < 500; } catch (_) {} - // Agent count & last activity - let agentCount = 0; + // Agent count (main vs sub) & last activity + let agentCount = 0, mainAgentCount = 0, subAgentCount = 0; let lastActivity = null; + try { + // Count main vs sub from config + if (cfg && cfg.agents && cfg.agents.list && cfg.bindings) { + const bindings = cfg.bindings || []; + const accounts = cfg.channels?.telegram?.accounts || {}; + cfg.agents.list.forEach(a => { + const isMain = a.id === 'main'; + const botBinding = bindings.find(b => b.agentId === a.id && b.match?.accountId && !b.match?.peer); + if (isMain || botBinding) mainAgentCount++; else subAgentCount++; + }); + } + } catch (_) {} try { const sessionsBase = path.join(OPENCLAW_DIR, 'agents'); const agentDirs = await fsp.readdir(sessionsBase); @@ -1322,9 +1361,9 @@ async function handleApi(req, res, urlObj, body) { res.writeHead(200); res.end(JSON.stringify({ ok: true, - system: { hostname, platform, nodeVer, cpuModel, cpuCores, uptime: sysUptime, totalMem, freeMem, diskTotal, diskUsed, diskFree, dirSize }, + system: { hostname, platform, nodeVer, cpuModel, cpuCores, uptime: sysUptime, totalMem, freeMem, diskTotal, diskUsed, diskFree, dirSize, cpuPercent, loadAvg }, gateway: { status: gatewayRunning, pid: gatewayPid, port: gatewayPort, ping: gatewayPing }, - agents: { count: agentCount, lastActivity: lastActivity ? lastActivity.toISOString() : null }, + agents: { count: agentCount, mainCount: mainAgentCount, subCount: subAgentCount, lastActivity: lastActivity ? lastActivity.toISOString() : null }, ocmVersion: APP_VERSION, serverTime: now, })); @@ -1383,12 +1422,12 @@ async function handleApi(req, res, urlObj, body) { } res.writeHead(404); - res.end(JSON.stringify({ error: '未知 API 路径: ' + pathname })); + res.end(JSON.stringify({ error: 'Unknown API path: ' + pathname })); } // ── 安装向导 HTML ────────────────────────────────────────────── const SETUP_HTML = ` -OpenClaw Manager - 初始设置 +OpenClaw Manager - Setup

🦀 OpenClaw Manager

-

首次运行,请指定你的 OpenClaw 数据目录(包含 openclaw.json 的文件夹)。

- - -
常见位置:
macOS / Linux:~/.openclaw
Windows:C:\\Users\\yourname\\.openclaw
+

First time setup — please specify your OpenClaw data directory (the folder containing openclaw.json).

+ + +
Common locations:
macOS / Linux: ~/.openclaw
Windows: C:\\Users\\yourname\\.openclaw
- +
`; @@ -1558,7 +1597,21 @@ select option { background:var(--surface); } .pw-toggle { position:absolute; right:10px; top:50%; transform:translateY(-50%); background:none; border:none; color:var(--muted); cursor:pointer; font-size:14px; padding:0; } /* ── Dashboard ── */ -.dash-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(300px,1fr)); gap:16px; } +.dash-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:20px; } +.dash-header h2 { font-size:18px; font-weight:600; color:var(--text); margin:0; } +.dash-auto-refresh { display:flex; align-items:center; gap:8px; font-size:12px; color:var(--muted); } +.dash-auto-refresh label { cursor:pointer; display:flex; align-items:center; gap:6px; } +.dash-toggle { position:relative; width:36px; height:20px; } +.dash-toggle input { opacity:0; width:0; height:0; } +.dash-toggle .slider { position:absolute; cursor:pointer; top:0; left:0; right:0; bottom:0; background:var(--border); border-radius:10px; transition:.3s; } +.dash-toggle .slider:before { position:absolute; content:""; height:14px; width:14px; left:3px; bottom:3px; background:#999; border-radius:50%; transition:.3s; } +.dash-toggle input:checked + .slider { background:var(--accent); } +.dash-toggle input:checked + .slider:before { transform:translateX(16px); background:#fff; } +.dash-gauges { display:flex; gap:20px; justify-content:center; flex-wrap:wrap; margin-bottom:24px; } +.dash-gauge-card { background:var(--surface); border:1px solid var(--border); border-radius:14px; padding:20px 24px; display:flex; flex-direction:column; align-items:center; min-width:140px; } +.dash-gauge-svg { width:110px; height:110px; } +.dash-gauge-label { font-size:12px; color:var(--muted); margin-top:8px; font-weight:500; } +.dash-sections { display:grid; grid-template-columns:repeat(auto-fit,minmax(300px,1fr)); gap:16px; } .dash-card { padding:20px; } .dash-card h3 { font-size:14px; font-weight:600; margin-bottom:14px; color:var(--text); } .dash-row { display:flex; justify-content:space-between; align-items:center; padding:6px 0; border-bottom:1px solid var(--border); font-size:12px; } @@ -1569,10 +1622,6 @@ select option { background:var(--surface); } .dash-indicator.running { background:#22c55e; box-shadow:0 0 6px rgba(34,197,94,.5); } .dash-indicator.stopped { background:#ef4444; box-shadow:0 0 6px rgba(239,68,68,.5); } .dash-indicator.unknown { background:#f59e0b; } -.dash-bar-wrap { width:100%; height:6px; background:var(--border); border-radius:3px; margin-top:4px; } -.dash-bar { height:100%; border-radius:3px; background:var(--accent); transition:width .3s; } -.dash-bar.warn { background:#f59e0b; } -.dash-bar.danger { background:#ef4444; } /* ── Modal ── */ .backdrop { position:fixed; inset:0; background:rgba(0,0,0,.72); z-index:100; display:none; align-items:center; justify-content:center; } @@ -1736,9 +1785,17 @@ const MAIN_HTML_BODY = String.raw`
-
+
+

Dashboard

+
+ + Auto-refresh +
+
+
Loading...
+
-

🖥️ System

+

🖥️ System Info

Loading...
@@ -2167,9 +2224,13 @@ const I18N = { 'guide.agent.s2':'Send /newbot and follow prompts to name your Bot', 'guide.agent.s3':'Copy the Bot Token from BotFather and paste below', 'guide.agent.s4':'Send /setprivacy → select your Bot → click Disable (allow Bot to read group messages)', - 'guide.sub.s1':'Add the Bot to your target Telegram group', - 'guide.sub.s2':'Send a message in the group, find peer.id (negative number) in gateway logs', - 'guide.sub.s3':'Fill in the Group ID and Agent config below', + 'guide.sub.s1':'在 BotFather 发送 /newbot 创建新 Bot,获取 Bot Token', + 'guide.sub.s2':'在 BotFather 发送 /mybots → 选择 Bot → Bot SettingsGroup PrivacyTurn off', + 'guide.sub.s3':'在 Telegram 创建新群组,将 Bot 加入群组(不要加其他人)', + 'guide.sub.s4':'在群内发一条消息,从 gateway 日志中找到 peer.id(负数)', + 'guide.sub.s5':'填写下方表单创建 Sub-Agent', + 'guide.sub.warn':'⚠️ 安全提示:请勿将其他人加入此群组,只有你和 Bot 应在群内。否则其他人也能与 Bot 对话并产生 API 费用。', + 'wiz.telegramId':'你的 Telegram User ID','wiz.telegramIdHint':'💡 可通过 @userinfobot 获取,填写后自动配置 allowFrom 白名单','wiz.telegramIdPh':'例如: 123456789', 'agents.empty':'暂无 Agent','agents.main':'主 Agent','agents.bound':'已绑群', 'agents.saveModel':'保存模型','agents.viewFiles':'查看文件', 'agents.defaultModel':'使用全局默认','agents.custom':'自定义','agents.noModel':'默认', @@ -2274,9 +2335,13 @@ const I18N = { 'guide.agent.s2':'Send /newbot and follow prompts to name your Bot', 'guide.agent.s3':'Copy the Bot Token from BotFather and paste below', 'guide.agent.s4':'Send /setprivacy → select your Bot → click Disable (allow Bot to read group messages)', - 'guide.sub.s1':'Add the Bot to your target Telegram group', - 'guide.sub.s2':'Send a message in the group, find peer.id (negative number) in gateway logs', - 'guide.sub.s3':'Fill in the Group ID and Agent config below', + 'guide.sub.s1':'Send /newbot to BotFather to create a new Bot and get the Bot Token', + 'guide.sub.s2':'Send /mybots to BotFather → select your Bot → Bot SettingsGroup PrivacyTurn off', + 'guide.sub.s3':'Create a new Telegram group, add the Bot to the group (do NOT add anyone else)', + 'guide.sub.s4':'Send a message in the group, find peer.id (negative number) in gateway logs', + 'guide.sub.s5':'Fill in the form below to create the Sub-Agent', + 'guide.sub.warn':'⚠️ Security: Do NOT add other people to this group. Only you and the Bot should be in the group. Otherwise others can chat with the Bot and incur API costs.', + 'wiz.telegramId':'Your Telegram User ID','wiz.telegramIdHint':'💡 Get it from @userinfobot — auto-configures allowFrom whitelist','wiz.telegramIdPh':'e.g. 123456789', 'agents.empty':'No Agents','agents.main':'Main Agent','agents.bound':'Bound', 'agents.saveModel':'Save Model','agents.viewFiles':'View Files', 'agents.defaultModel':'Use Global Default','agents.custom':'custom','agents.noModel':'Default', @@ -2414,24 +2479,56 @@ async function loadAll(){ await Promise.all([loadAgents(), loadModels(), loadCha // ── Dashboard ───────────────────────────────────────────────── let dashLoaded=false; +let dashRefreshTimer=null; function fmtBytes(b){if(!b||b<=0)return '—';const u=['B','KB','MB','GB','TB'];let i=0;while(b>=1024&&i0?1:0)+' '+u[i];} function fmtUptime(s){const d=Math.floor(s/86400);const h=Math.floor((s%86400)/3600);const m=Math.floor((s%3600)/60);if(d>0)return d+'d '+h+'h '+m+'m';if(h>0)return h+'h '+m+'m';return m+'m';} function dashRow(label,val){return '
'+esc(label)+''+val+'
';} -function dashBar(pct){const cls=pct>90?'danger':pct>70?'warn':'';return '
';} +function gaugeColor(pct){if(pct>90)return '#ef4444';if(pct>70)return '#f59e0b';return '#22c55e';} +function buildGaugeSVG(pct,label,sub,color){ + const r=46,cx=55,cy=55,sw=8; + const circ=2*Math.PI*r; + const gap=circ*0.25; + const arc=circ-gap; + const filled=arc*(Math.min(pct,100)/100); + const rot=135; + if(!color)color=gaugeColor(pct); + return '
'+ + ''+ + ''+ + ''+ + ''+Math.round(pct)+'%'+ + ''+esc(sub)+''+ + ''+ + '
'+esc(label)+'
'; +} +function toggleDashRefresh(on){ + if(dashRefreshTimer){clearInterval(dashRefreshTimer);dashRefreshTimer=null;} + if(on){dashRefreshTimer=setInterval(loadDashboard,10000);} +} async function loadDashboard(){ try{ const r=await api('GET','/api/dashboard'); if(!r.ok)return; const s=r.system, g=r.gateway, a=r.agents; - // System card const memPct=s.totalMem?((s.totalMem-s.freeMem)/s.totalMem*100):0; + const diskPct=s.diskTotal?(s.diskUsed/s.diskTotal*100):0; + const cpuPct=(s.cpuPercent!==null&&s.cpuPercent!==undefined)?s.cpuPercent:0; + // Gauges + const memUsed=fmtBytes(s.totalMem-s.freeMem)+' / '+fmtBytes(s.totalMem); + const diskUsed=fmtBytes(s.diskUsed)+' / '+fmtBytes(s.diskTotal); + let gaugeHtml=buildGaugeSVG(cpuPct,'CPU',s.cpuCores+' cores'); + gaugeHtml+=buildGaugeSVG(memPct,'RAM',memUsed); + gaugeHtml+=buildGaugeSVG(diskPct,'DISK',diskUsed); + document.getElementById('dashGauges').innerHTML=gaugeHtml; + // System info card + const loadStr=s.loadAvg?s.loadAvg.map(function(v){return v.toFixed(2);}).join(' / '):'—'; let sysHtml=dashRow('Hostname',esc(s.hostname)); sysHtml+=dashRow('OS',esc(s.platform)); sysHtml+=dashRow('Node.js',esc(s.nodeVer)); - sysHtml+=dashRow('CPU',esc(s.cpuModel)+' ('+s.cpuCores+' cores)'); + sysHtml+=dashRow('CPU',esc(s.cpuModel)); + sysHtml+=dashRow('Cores',String(s.cpuCores)); sysHtml+=dashRow('Uptime',fmtUptime(s.uptime)); - sysHtml+=dashRow('Memory',fmtBytes(s.totalMem-s.freeMem)+' / '+fmtBytes(s.totalMem)+' ('+memPct.toFixed(0)+'%)'); - sysHtml+=dashBar(memPct); + sysHtml+=dashRow('Load Avg (1/5/15m)',loadStr); document.getElementById('dashSysItems').innerHTML=sysHtml; // Gateway card const statusIcon=''; @@ -2442,7 +2539,9 @@ async function loadDashboard(){ gwHtml+=dashRow('HTTP Ping',g.ping?'✓ Reachable':'✗ Unreachable'); document.getElementById('dashGwItems').innerHTML=gwHtml; // Agents card - let agHtml=dashRow('Total Agents',String(a.count)); + let agHtml=dashRow('Main Agents',String(a.mainCount||0)); + agHtml+=dashRow('Sub-Agents',String(a.subCount||0)); + agHtml+=dashRow('Total',String(a.count)); if(a.lastActivity){ const d=new Date(a.lastActivity); agHtml+=dashRow('Last Activity',d.toLocaleString('en-AU',{timeZone:'Australia/Brisbane',hour12:false})); @@ -2453,13 +2552,8 @@ async function loadDashboard(){ agHtml+=dashRow('Server Time',esc(r.serverTime)); document.getElementById('dashAgentItems').innerHTML=agHtml; // Storage card - let stHtml=dashRow('OpenClaw Dir Size',fmtBytes(s.dirSize)); - if(s.diskTotal>0){ - const diskPct=s.diskUsed/s.diskTotal*100; - stHtml+=dashRow('Disk',fmtBytes(s.diskUsed)+' / '+fmtBytes(s.diskTotal)+' ('+diskPct.toFixed(0)+'%)'); - stHtml+=dashBar(diskPct); - stHtml+=dashRow('Disk Free',fmtBytes(s.diskFree)); - } + let stHtml=dashRow('OpenClaw Dir',fmtBytes(s.dirSize)); + stHtml+=dashRow('Disk Free',fmtBytes(s.diskFree)); document.getElementById('dashStorageItems').innerHTML=stHtml; dashLoaded=true; }catch(e){console.error('Dashboard load error:',e);} @@ -2581,12 +2675,19 @@ function buildAddSubForm() { '
  • ' + t('guide.sub.s1') + '
  • ' + '
  • ' + t('guide.sub.s2') + '
  • ' + '
  • ' + t('guide.sub.s3') + '
  • ' + - '' + + '
  • ' + t('guide.sub.s4') + '
  • ' + + '
  • ' + t('guide.sub.s5') + '
  • ' + + '' + + '
    ' + t('guide.sub.warn') + '
    ' + + '' + '
    ' + '
    ' + '
    ' + '' + '' + t('wiz.groupHint') + '
    ' + + '
    ' + + '' + + '' + t('wiz.telegramIdHint') + '
    ' + '
    ' + '
    ' + '
    ' + @@ -2636,6 +2737,7 @@ async function submitAddAgent() { async function submitAddSub() { const parentAgentId = document.getElementById('fs-parent').value.trim(); const groupId = document.getElementById('fs-gid').value.trim(); + const telegramUserId = (document.getElementById('fs-tgid')?.value||'').trim(); const agentId = document.getElementById('fs-aid').value.trim(); const displayName = document.getElementById('fs-name').value.trim(); const model = document.getElementById('fs-model').value; @@ -2646,8 +2748,9 @@ async function submitAddSub() { if (!groupId) { toast(t('wiz.errGroupId'), 'err'); return; } if (!agentId) { toast(t('wiz.errAgentId'), 'err'); return; } if (!/^[a-zA-Z0-9_-]+$/.test(agentId)) { toast(t('wiz.errIdFormat'), 'err'); return; } + if (telegramUserId && !/^\\d+$/.test(telegramUserId)) { toast('Telegram User ID must be a number', 'err'); return; } try { - const r = await api('POST', '/api/agents', { parentAgentId, agentId, displayName: displayName || agentId, groupId, workspaceFolder: agentId, model: model === '__default__' ? '' : model, purpose, personality, initialMemory }); + const r = await api('POST', '/api/agents', { parentAgentId, agentId, displayName: displayName || agentId, groupId, workspaceFolder: agentId, model: model === '__default__' ? '' : model, purpose, personality, initialMemory, telegramUserId }); if (r.error) { toast(r.error, 'err'); return; } toast(t('wiz.created'), 'ok'); document.getElementById('addFormArea').innerHTML = '';