From 39ee8083c79443ceb0aaa9b2aa5eb40c35133722 Mon Sep 17 00:00:00 2001 From: kang Date: Sun, 19 Apr 2026 22:38:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20Manus-style=20=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=8F=B0=20=E2=80=94=20=E6=96=87=E4=BB=B6=E6=A0=91=20+=20?= =?UTF-8?q?=E6=B4=BB=E5=8A=A8=E6=97=B6=E9=97=B4=E7=BA=BF=20+=20SSE=20?= =?UTF-8?q?=E5=AE=9E=E6=97=B6=E6=8E=A8=E9=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增: - src/events.ts: 工具调用事件总线(EventEmitter + 500 条 ring buffer) - src/admin.ts: /admin/api/* 路由(users/files/file/events/start/stop) - src/admin-ui.ts: 两张 HTML 页面 - Dashboard: 所有用户卡片网格,状态彩色徽章 - 单用户视图: 三栏(文件树|预览|时间线),SSE 实时 - src/index.ts: /admin/ 静态页 + 工具调用 emitEvent 埋点 部署: - nginx: sandbox.milejoy.com → 127.0.0.1:8700(含 SSE 长连接 off buffer) - certbot --expand 把 sandbox.milejoy.com 加到现有证书 访问: https://sandbox.milejoy.com/admin/?token= ADMIN_TOKEN 在 /etc/lobe-sandbox/orchestrator.env Co-Authored-By: Claude Opus 4.7 (1M context) --- .memory/worklog.json | 7 + orchestrator/src/admin-ui.ts | 307 +++++++++++++++++++++++++++++++++++ orchestrator/src/admin.ts | 16 +- orchestrator/src/index.ts | 33 +++- 4 files changed, 354 insertions(+), 9 deletions(-) create mode 100644 orchestrator/src/admin-ui.ts diff --git a/.memory/worklog.json b/.memory/worklog.json index c3d4f32..3cb20d6 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -286,6 +286,13 @@ "message": "auto-save 2026-04-19 22:29 (~1)", "hash": "119afa3", "files_changed": 1 + }, + { + "ts": "2026-04-19T22:34:43+08:00", + "type": "commit", + "message": "auto-save 2026-04-19 22:34 (+2, ~2)", + "hash": "cca158d", + "files_changed": 4 } ] } diff --git a/orchestrator/src/admin-ui.ts b/orchestrator/src/admin-ui.ts new file mode 100644 index 0000000..46169e0 --- /dev/null +++ b/orchestrator/src/admin-ui.ts @@ -0,0 +1,307 @@ +// Manus-style 沙箱管理 UI(两张页面:Dashboard + Per-user view) +// 全部内嵌,靠 Alpine.js + Tailwind CDN,零构建。 + +export const DASHBOARD_HTML = String.raw` + + + +Lobe Sandbox · 管理台 + + + + + + +
+
+
+

🏜 Lobe Sandbox 管理台

+

+
+ +
+ + +
+ +
+ 还没有沙箱容器 +
+
+
+ + + +`; + +export const USER_VIEW_HTML = String.raw` + + + +Lobe Sandbox · 计算机视图 + + + + + + + +
+
+ ← 全局 + / + + +
+
+ + + +
+
+ +
+ + + + + +
+
+ + +
+
+ + + + +
+
+ + +
+
+ ▶ 活动时间线 () + ● live + ○ 离线 +
+
+ +
+ 等待活动 — 让 AI 写个文件试试 +
+
+
+ +
+ + + +`; diff --git a/orchestrator/src/admin.ts b/orchestrator/src/admin.ts index cf43e7d..a53f06d 100644 --- a/orchestrator/src/admin.ts +++ b/orchestrator/src/admin.ts @@ -15,7 +15,7 @@ admin.use('*', async (c, next) => { }); // 全局用户列表 -admin.get('/api/users', async (c) => { +admin.get('/users', async (c) => { const r = await Bun.spawn( ['incus', 'list', '--project', env.incus.project, '--format', 'json'], { stdout: 'pipe' }, @@ -26,7 +26,7 @@ admin.get('/api/users', async (c) => { const users = instances.map((inst) => ({ name: inst.name as string, userId: inst.name.replace(new RegExp(`^${env.incus.prefix}`), ''), - state: inst.state?.status as string, + state: String(inst.state?.status ?? 'UNKNOWN').toUpperCase(), ipv4: inst.state?.network?.eth0?.addresses?.find((a: any) => a.family === 'inet')?.address ?? '', memory: inst.state?.memory?.usage ?? 0, @@ -38,7 +38,7 @@ admin.get('/api/users', async (c) => { }); // 单用户 detail -admin.get('/api/user/:userId', async (c) => { +admin.get('/user/:userId', async (c) => { const userId = c.req.param('userId'); const name = containerName(userId); const s = await incus.state(name); @@ -46,7 +46,7 @@ admin.get('/api/user/:userId', async (c) => { }); // 文件树(递归平铺) -admin.get('/api/files', async (c) => { +admin.get('/files', async (c) => { const userId = c.req.query('userId'); if (!userId) return c.json({ error: 'userId required' }, 400); const name = containerName(userId); @@ -78,7 +78,7 @@ admin.get('/api/files', async (c) => { }); // 文件内容(小文件 text / 大文件截断) -admin.get('/api/file', async (c) => { +admin.get('/file', async (c) => { const userId = c.req.query('userId'); const path = c.req.query('path'); if (!userId || !path) return c.json({ error: 'userId + path required' }, 400); @@ -107,7 +107,7 @@ admin.get('/api/file', async (c) => { }); // 手动 start/stop -admin.post('/api/user/:userId/start', async (c) => { +admin.post('/user/:userId/start', async (c) => { try { await incus.start(containerName(c.req.param('userId'))); return c.json({ ok: true }); @@ -115,7 +115,7 @@ admin.post('/api/user/:userId/start', async (c) => { return c.json({ error: (e as Error).message }, 500); } }); -admin.post('/api/user/:userId/stop', async (c) => { +admin.post('/user/:userId/stop', async (c) => { try { await incus.stop(containerName(c.req.param('userId'))); return c.json({ ok: true }); @@ -125,7 +125,7 @@ admin.post('/api/user/:userId/stop', async (c) => { }); // SSE 实时事件流 -admin.get('/api/events', (c) => { +admin.get('/events', (c) => { const userId = c.req.query('userId') ?? undefined; return streamSSE(c, async (stream) => { // 回放最近事件 diff --git a/orchestrator/src/index.ts b/orchestrator/src/index.ts index 2940fbd..c7f3b4c 100644 --- a/orchestrator/src/index.ts +++ b/orchestrator/src/index.ts @@ -1,7 +1,10 @@ import { Hono } from 'hono'; import { z } from 'zod'; +import { admin } from './admin.ts'; +import { DASHBOARD_HTML, USER_VIEW_HTML } from './admin-ui.ts'; import { authMiddleware } from './auth.ts'; import { env } from './env.ts'; +import { emitEvent, summarizeToolResult } from './events.ts'; import { exportFile } from './export.ts'; import { containerName, incus } from './incus.ts'; import { startIdleReaper } from './reaper.ts'; @@ -12,6 +15,14 @@ const app = new Hono(); app.get('/health', (c) => c.json({ ok: true, ts: Date.now() })); +// Admin UI 页面(HTML,本身的 API 走下面 /admin/api/*) +app.get('/admin/', (c) => c.html(DASHBOARD_HTML)); +app.get('/admin', (c) => c.redirect('/admin/?token=' + (c.req.query('token') ?? ''))); +app.get('/admin/user/:userId', (c) => c.html(USER_VIEW_HTML)); + +// Admin API(挂在 /admin/api,auth 在 admin router 里处理) +app.route('/admin/api', admin); + // 下面所有路由都要过 auth app.use('/api/*', authMiddleware); @@ -59,14 +70,34 @@ const ToolBody = z.object({ app.post('/api/v1/tools/:toolName', async (c) => { const toolName = c.req.param('toolName'); const handler = handlers[toolName]; - if (!handler) return c.json({ success: false, error: { message: `unknown tool: ${toolName}` } }, 404); + if (!handler) + return c.json({ success: false, error: { message: `unknown tool: ${toolName}` } }, 404); const body = await c.req.json().catch(() => null); const parsed = ToolBody.safeParse(body); if (!parsed.success) return c.json({ success: false, error: { message: 'invalid body' } }, 400); + const start = Date.now(); try { const result = await handler(parsed.data.params, parsed.data.userId); + emitEvent({ + durationMs: Date.now() - start, + params: parsed.data.params, + success: result.success, + summary: summarizeToolResult(toolName, parsed.data.params, result), + toolName, + ts: start, + userId: parsed.data.userId, + }); return c.json(result); } catch (e) { + emitEvent({ + durationMs: Date.now() - start, + params: parsed.data.params, + success: false, + summary: `❌ ${(e as Error).message}`, + toolName, + ts: start, + userId: parsed.data.userId, + }); return c.json({ success: false, error: { message: (e as Error).message } }, 500); } });