311 lines
13 KiB
TypeScript
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>`;
|