新增: - 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> ADMIN_TOKEN 在 /etc/lobe-sandbox/orchestrator.env Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
163 lines
5.3 KiB
TypeScript
163 lines
5.3 KiB
TypeScript
import { Hono } from 'hono';
|
|
import { streamSSE } from 'hono/streaming';
|
|
import { env } from './env.ts';
|
|
import { getRecentEvents, onEvent } from './events.ts';
|
|
import { containerName, incus } from './incus.ts';
|
|
|
|
export const admin = new Hono();
|
|
|
|
// 鉴权中间件 — token 走 query(首屏 iframe/跳转友好)或 header
|
|
admin.use('*', async (c, next) => {
|
|
if (!env.adminToken) return c.text('Admin UI disabled: set ADMIN_TOKEN env', 503);
|
|
const token = c.req.query('token') ?? c.req.header('Admin-Token');
|
|
if (token !== env.adminToken) return c.text('Unauthorized', 401);
|
|
await next();
|
|
});
|
|
|
|
// 全局用户列表
|
|
admin.get('/users', async (c) => {
|
|
const r = await Bun.spawn(
|
|
['incus', 'list', '--project', env.incus.project, '--format', 'json'],
|
|
{ stdout: 'pipe' },
|
|
);
|
|
const raw = await new Response(r.stdout).text();
|
|
await r.exited;
|
|
const instances = JSON.parse(raw || '[]') as any[];
|
|
const users = instances.map((inst) => ({
|
|
name: inst.name as string,
|
|
userId: inst.name.replace(new RegExp(`^${env.incus.prefix}`), ''),
|
|
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,
|
|
disk: inst.state?.disk?.root?.usage ?? 0,
|
|
cpuSeconds: inst.state?.cpu?.usage ?? 0,
|
|
lastUsedAt: inst.last_used_at as string,
|
|
}));
|
|
return c.json({ users });
|
|
});
|
|
|
|
// 单用户 detail
|
|
admin.get('/user/:userId', async (c) => {
|
|
const userId = c.req.param('userId');
|
|
const name = containerName(userId);
|
|
const s = await incus.state(name);
|
|
return c.json({ userId, containerName: name, state: s });
|
|
});
|
|
|
|
// 文件树(递归平铺)
|
|
admin.get('/files', async (c) => {
|
|
const userId = c.req.query('userId');
|
|
if (!userId) return c.json({ error: 'userId required' }, 400);
|
|
const name = containerName(userId);
|
|
const s = await incus.state(name);
|
|
if (s === 'MISSING') return c.json({ files: [], error: 'no sandbox' });
|
|
if (s === 'STOPPED') return c.json({ files: [], note: 'sandbox stopped — start to view files' });
|
|
// 递归 + 安全限制:最多 500 个文件,排除 venv 里的包
|
|
const r = await incus.exec(
|
|
name,
|
|
[
|
|
'bash',
|
|
'-c',
|
|
`find /workspace -type f -not -path '*/.venv/lib/*' -not -path '*/node_modules/*' -printf '%T@\\t%s\\t%p\\n' 2>/dev/null | head -500`,
|
|
],
|
|
{ user: 1000 },
|
|
);
|
|
const files = r.stdout
|
|
.split('\n')
|
|
.filter(Boolean)
|
|
.map((line) => {
|
|
const [ts, size, path] = line.split('\t');
|
|
return {
|
|
path,
|
|
size: Number(size),
|
|
mtime: Math.floor(Number(ts) * 1000),
|
|
};
|
|
});
|
|
return c.json({ files });
|
|
});
|
|
|
|
// 文件内容(小文件 text / 大文件截断)
|
|
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);
|
|
const name = containerName(userId);
|
|
// 安全:限制 /workspace 下,最大 2MB
|
|
if (!path.startsWith('/workspace')) return c.json({ error: 'path must be under /workspace' }, 403);
|
|
try {
|
|
const bytes = await incus.pull(name, path);
|
|
if (bytes.byteLength > 2 * 1024 * 1024) {
|
|
return c.json({ error: 'file too large (>2MB), download only' }, 413);
|
|
}
|
|
const ext = path.slice(path.lastIndexOf('.')).toLowerCase();
|
|
const isImage = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'].includes(ext);
|
|
if (isImage) {
|
|
return new Response(bytes, {
|
|
headers: { 'Content-Type': mimeFor(ext) },
|
|
});
|
|
}
|
|
// 文本返回 — 让前端自己渲染
|
|
return new Response(bytes, {
|
|
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
});
|
|
} catch (e) {
|
|
return c.json({ error: (e as Error).message }, 500);
|
|
}
|
|
});
|
|
|
|
// 手动 start/stop
|
|
admin.post('/user/:userId/start', async (c) => {
|
|
try {
|
|
await incus.start(containerName(c.req.param('userId')));
|
|
return c.json({ ok: true });
|
|
} catch (e) {
|
|
return c.json({ error: (e as Error).message }, 500);
|
|
}
|
|
});
|
|
admin.post('/user/:userId/stop', async (c) => {
|
|
try {
|
|
await incus.stop(containerName(c.req.param('userId')));
|
|
return c.json({ ok: true });
|
|
} catch (e) {
|
|
return c.json({ error: (e as Error).message }, 500);
|
|
}
|
|
});
|
|
|
|
// SSE 实时事件流
|
|
admin.get('/events', (c) => {
|
|
const userId = c.req.query('userId') ?? undefined;
|
|
return streamSSE(c, async (stream) => {
|
|
// 回放最近事件
|
|
for (const e of getRecentEvents(userId)) {
|
|
await stream.writeSSE({ data: JSON.stringify(e), event: 'event' });
|
|
}
|
|
let writing: Promise<unknown> = Promise.resolve();
|
|
const off = onEvent((e) => {
|
|
if (userId && e.userId !== userId) return;
|
|
writing = writing.then(() =>
|
|
stream.writeSSE({ data: JSON.stringify(e), event: 'event' }).catch(() => {}),
|
|
);
|
|
});
|
|
try {
|
|
// keepalive
|
|
while (!stream.aborted) {
|
|
await stream.sleep(30_000);
|
|
await stream.writeSSE({ data: String(Date.now()), event: 'ping' }).catch(() => {});
|
|
}
|
|
} finally {
|
|
off();
|
|
}
|
|
});
|
|
});
|
|
|
|
const mimeFor = (ext: string): string =>
|
|
({
|
|
'.gif': 'image/gif',
|
|
'.jpeg': 'image/jpeg',
|
|
'.jpg': 'image/jpeg',
|
|
'.png': 'image/png',
|
|
'.svg': 'image/svg+xml',
|
|
'.webp': 'image/webp',
|
|
})[ext] ?? 'application/octet-stream';
|