Files
lobe-sandbox-backend/orchestrator/src/admin-ui.ts
2026-04-20 00:31:34 +08:00

311 lines
13 KiB
TypeScript

// Manus-style 沙箱管理 UI(两张页面:Dashboard + Per-user terminal view)
// 全部内嵌,靠 Alpine.js + Tailwind CDN + xterm.js,零构建。
export const DASHBOARD_HTML = String.raw`<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Lobe Sandbox · 管理台</title>
<script src="https://cdn.tailwindcss.com"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<style>
body { background: #0a0e1a; color: #e5e7eb; font-family: ui-sans-serif, system-ui, sans-serif; }
.card { background: linear-gradient(180deg, #141b2d 0%, #0f1522 100%); border: 1px solid #1f2937; }
.state-RUNNING { background: #10b981; }
.state-STOPPED { background: #6b7280; }
.state-MISSING { background: #ef4444; }
</style>
</head>
<body x-data="dashboard()" x-init="load()">
<div class="max-w-6xl mx-auto px-6 py-8">
<div class="flex items-center justify-between mb-8">
<div>
<h1 class="text-2xl font-bold text-white">Lobe Sandbox</h1>
<p class="text-sm text-gray-400 mt-1" x-text="users.length + ' 个用户沙箱 · 运行中 ' + runningCount"></p>
</div>
<button @click="load()" class="text-xs text-gray-400 hover:text-white">⟳</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<template x-for="u in users" :key="u.name">
<a :href="'user/' + u.userId + location.search"
class="card rounded-lg p-4 hover:border-emerald-500 block">
<div class="flex items-center justify-between mb-2">
<span class="font-mono text-xs text-gray-400" x-text="u.userId.slice(0, 30)"></span>
<span class="inline-flex items-center gap-1.5 text-xs text-white px-2 py-0.5 rounded-full"
:class="'state-' + u.state"
x-text="u.state"></span>
</div>
</a>
</template>
<div x-show="users.length === 0" class="col-span-full text-center py-20 text-gray-500">还没有沙箱</div>
</div>
</div>
<script>
function dashboard() {
return {
users: [],
get runningCount() { return this.users.filter(u => u.state === 'RUNNING').length; },
get token() { return new URLSearchParams(location.search).get('token') ?? ''; },
async load() {
const r = await fetch('api/users?token=' + encodeURIComponent(this.token));
if (!r.ok) { document.body.innerHTML = '<div class="p-10 text-red-400">Unauthorized — add ?token=... to URL</div>'; return; }
const { users } = await r.json();
this.users = users;
},
}
}
</script>
</body>
</html>`;
export const USER_VIEW_HTML = String.raw`<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>sandbox</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css" />
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.js"></script>
<style>
html, body { margin: 0; padding: 0; height: 100%; background: #000; overflow: hidden; }
#term { width: 100%; height: 100%; }
.xterm { padding: 8px; height: 100%; }
.xterm-cursor-layer { display: none !important; }
#ind { position: fixed; top: 6px; right: 10px; font-family: monospace; font-size: 11px; color: #4ade80; z-index: 10; }
#ind.off { color: #6b7280; }
#filebtn { position: fixed; bottom: 10px; right: 10px; background: rgba(30,40,60,0.6); color: #9ca3af; border: 1px solid #374151; padding: 4px 10px; border-radius: 6px; font-size: 11px; cursor: pointer; z-index: 10; font-family: monospace; }
#filebtn:hover { color: #fff; border-color: #4ade80; }
#modal { position: fixed; inset: 0; background: rgba(0,0,0,0.85); display: none; align-items: center; justify-content: center; z-index: 20; }
#modal.on { display: flex; }
#modalbox { background: #0a0e1a; border: 1px solid #374151; border-radius: 8px; width: 85vw; height: 85vh; display: flex; flex-direction: column; font-family: monospace; }
#modalhead { padding: 8px 12px; border-bottom: 1px solid #1f2937; font-size: 12px; color: #9ca3af; display: flex; justify-content: space-between; align-items: center; }
#modalhead .close { cursor: pointer; color: #6b7280; }
#modalhead .close:hover { color: #fff; }
#modalbody { flex: 1; overflow: auto; padding: 12px; color: #d1d5db; font-size: 12px; }
#modalbody pre { margin: 0; white-space: pre-wrap; word-break: break-all; }
#modalbody iframe { width: 100%; height: 100%; border: 0; background: #fff; border-radius: 4px; }
#modalbody img { max-width: 100%; border-radius: 4px; }
#filelist { position: absolute; top: 40px; right: 0; width: 280px; max-height: calc(100vh - 80px); overflow-y: auto; background: rgba(15,21,34,0.95); border: 1px solid #374151; border-radius: 8px; padding: 6px; display: none; z-index: 15; }
#filelist.on { display: block; }
#filelist .item { padding: 6px 10px; font-size: 12px; color: #d1d5db; cursor: pointer; border-radius: 4px; display: flex; justify-content: space-between; font-family: monospace; }
#filelist .item:hover { background: #1f2937; }
#filelist .item .size { color: #6b7280; font-size: 10px; }
</style>
</head>
<body>
<div id="term"></div>
<div id="ind" class="off">⬤ offline</div>
<button id="filebtn">📁 files</button>
<div id="filelist"></div>
<div id="modal"><div id="modalbox">
<div id="modalhead">
<span id="modalpath">file</span>
<span class="close" onclick="closeModal()">✕</span>
</div>
<div id="modalbody"></div>
</div></div>
<script>
(function(){
const path = location.pathname;
const m = path.match(/\/admin\/user\/([^\/]+)/);
const userId = m ? decodeURIComponent(m[1]) : '';
const token = new URLSearchParams(location.search).get('token') || '';
const qs = '?token=' + encodeURIComponent(token);
const api = '../api';
// xterm
const term = new Terminal({
cursorBlink: false,
disableStdin: true,
fontSize: 13,
fontFamily: '"SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
theme: {
background: '#000000',
foreground: '#d0d0d0',
cursor: 'transparent',
black: '#1a1a1a',
red: '#e06c75',
green: '#98c379',
yellow: '#e5c07b',
blue: '#61afef',
magenta: '#c678dd',
cyan: '#56b6c2',
white: '#d0d0d0',
brightBlack: '#5c6370',
},
scrollback: 5000,
});
const fit = new FitAddon.FitAddon();
term.loadAddon(fit);
term.open(document.getElementById('term'));
fit.fit();
window.addEventListener('resize', () => fit.fit());
const HOST = 'sb-' + userId.slice(0,12);
const PROMPT = '\x1b[32msandbox@' + HOST + '\x1b[0m:\x1b[34m~\x1b[0m$ ';
term.write(PROMPT);
// 文件 modal
const modal = document.getElementById('modal');
const modalpath = document.getElementById('modalpath');
const modalbody = document.getElementById('modalbody');
window.closeModal = () => modal.classList.remove('on');
async function openFile(p) {
modalpath.textContent = p;
modalbody.innerHTML = '<div style="color:#6b7280">loading...</div>';
modal.classList.add('on');
const ext = p.slice(p.lastIndexOf('.')).toLowerCase();
const url = api + '/file?userId=' + encodeURIComponent(userId) + '&path=' + encodeURIComponent(p) + '&token=' + encodeURIComponent(token);
if (['.png','.jpg','.jpeg','.gif','.webp','.svg'].includes(ext)) {
modalbody.innerHTML = '<img src="' + url + '"/>';
return;
}
const r = await fetch(url);
if (!r.ok) {
modalbody.innerHTML = '<div style="color:#ef4444">加载失败: ' + r.status + '</div>';
return;
}
const content = await r.text();
if (['.html','.htm'].includes(ext)) {
modalbody.innerHTML = '';
const iframe = document.createElement('iframe');
iframe.srcdoc = content;
iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms allow-pointer-lock allow-popups allow-modals');
modalbody.appendChild(iframe);
} else {
const pre = document.createElement('pre');
pre.textContent = content;
modalbody.innerHTML = '';
modalbody.appendChild(pre);
}
}
// 文件列表下拉
const filebtn = document.getElementById('filebtn');
const filelist = document.getElementById('filelist');
let files = [];
async function loadFiles() {
try {
const r = await fetch(api + '/files?userId=' + encodeURIComponent(userId) + '&token=' + encodeURIComponent(token));
const d = await r.json();
files = (d.files || []).sort((a,b) => b.mtime - a.mtime);
renderFileList();
} catch (e) {}
}
function renderFileList() {
filelist.innerHTML = files.length === 0
? '<div class="item" style="color:#6b7280">no files yet</div>'
: files.slice(0, 50).map(f => {
const short = f.path.replace(/^\/workspace\//, '');
const sz = f.size > 1e6 ? (f.size/1e6).toFixed(1)+'M' : f.size > 1e3 ? (f.size/1e3).toFixed(1)+'K' : f.size+'B';
return '<div class="item" data-path="' + f.path + '"><span>' + short + '</span><span class="size">' + sz + '</span></div>';
}).join('');
filelist.querySelectorAll('.item[data-path]').forEach(el => {
el.onclick = () => { openFile(el.getAttribute('data-path')); filelist.classList.remove('on'); };
});
}
filebtn.onclick = () => {
filelist.classList.toggle('on');
if (filelist.classList.contains('on')) loadFiles();
};
// 事件 → 终端输出,模仿真 shell 会话
const seen = new Set();
let reloadTimer;
function esc(s) { return String(s ?? '').replace(/\x1b/g, ''); }
function writeBlock(cmd, outStdout, outStderr, opts) {
opts = opts || {};
// 擦除当前 prompt 行末尾光标,把命令打出来
term.write(cmd + '\r\n');
if (outStdout) {
const lines = outStdout.split('\n');
lines.forEach((l, i) => {
if (i === lines.length - 1 && l === '') return;
term.writeln(esc(l));
});
}
if (outStderr) {
outStderr.split('\n').forEach(l => {
if (l) term.writeln('\x1b[31m' + esc(l) + '\x1b[0m');
});
}
term.write(PROMPT);
}
function renderEvent(e) {
if (seen.has(e.id)) return;
seen.add(e.id);
const p = e.params || {};
const r = e.result || {};
switch (e.toolName) {
case 'executeCode': {
const lang = p.language || 'python';
const code = String(p.code || '');
const codeInline = code.replace(/\n/g, '\\n');
const runner = lang === 'python' ? 'python3 -c' : lang === 'typescript' ? 'bun -e' : 'node -e';
writeBlock(runner + " \x1b[90m'" + codeInline.slice(0, 200) + (codeInline.length > 200 ? '...' : '') + "'\x1b[0m", r.stdout, r.stderr);
break;
}
case 'runCommand':
writeBlock(String(p.command || ''), r.stdout, r.stderr);
break;
case 'writeLocalFile': {
const path = p.filePath || '?';
const len = (p.content || '').length;
writeBlock("\x1b[36mcat > " + path + "\x1b[0m \x1b[90m# " + len + " bytes\x1b[0m", '', '');
break;
}
case 'editLocalFile':
writeBlock("\x1b[36msed -i ...\x1b[0m " + (p.filePath || ''), '', '');
break;
case 'readLocalFile':
writeBlock("cat " + (p.filePath || ''), r.content ? r.content.slice(0, 1000) : '', '');
break;
case 'listLocalFiles':
writeBlock("ls -la " + (p.directoryPath || ''), r.output || '', '');
break;
case 'grepContent':
writeBlock("rg -n '" + (p.pattern || '') + "' " + (p.directoryPath || ''), (r.matches || '').toString().slice(0, 500), '');
break;
case 'globLocalFiles':
writeBlock("find . -name '" + (p.pattern || '') + "'", (r.matches || []).join('\n'), '');
break;
case 'moveLocalFiles':
writeBlock("mv " + (p.sources || []).join(' ') + ' ' + (p.destination || ''), '', '');
break;
case 'renameLocalFile':
writeBlock("mv " + (p.oldPath || '') + ' ' + (p.newPath || ''), '', '');
break;
case 'searchLocalFiles':
writeBlock("fd '" + (p.pattern || '') + "'", (r.matches || []).join('\n'), '');
break;
default:
writeBlock("\x1b[35m# " + e.toolName + "\x1b[0m", e.summary || '', '');
}
clearTimeout(reloadTimer);
reloadTimer = setTimeout(loadFiles, 800);
}
// SSE
const ind = document.getElementById('ind');
function connect() {
const es = new EventSource(api + '/events?userId=' + encodeURIComponent(userId) + '&token=' + encodeURIComponent(token));
es.addEventListener('open', () => { ind.className = ''; ind.textContent = '⬤ live'; });
es.addEventListener('error', () => { ind.className = 'off'; ind.textContent = '⬤ offline'; });
es.addEventListener('event', (ev) => {
try { renderEvent(JSON.parse(ev.data)); } catch {}
});
}
connect();
// 初始加载一次文件列表
loadFiles();
})();
</script>
</body>
</html>`;