HH-738: add constrained browser container control (#4)

This commit is contained in:
2026-08-27 18:07:34 +08:00
parent 8aa194b264
commit 4b054d6f04
16 changed files with 1925 additions and 1 deletions
+149
View File
@@ -0,0 +1,149 @@
import { StrictMode, useEffect, useState } from 'react'
import { createRoot } from 'react-dom/client'
import './styles.css'
const statusLabels = {
created: '已创建',
running: '运行中',
exited: '已停止',
restarting: '重启中',
paused: '已暂停',
}
async function api(path = '', options) {
const response = await fetch(`/api/browsers${path}`, options)
if (!response.ok) {
const body = await response.json().catch(() => ({}))
throw new Error(body.error || `请求失败 (${response.status})`)
}
return response.status === 204 ? null : response.json()
}
function Icon({ name }) {
const paths = {
runtime: <><rect x="3" y="4" width="18" height="16" rx="2"/><path d="m8 9 3 3-3 3M13 15h3"/></>,
audit: <><path d="M9 5H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-3"/><rect x="9" y="3" width="6" height="4" rx="1"/></>,
shield: <path d="M12 3 5 6v5c0 4.6 2.9 8.1 7 10 4.1-1.9 7-5.4 7-10V6l-7-3Z"/>,
play: <path d="m9 7 8 5-8 5V7Z"/>,
stop: <rect x="7" y="7" width="10" height="10" rx="1"/>,
trash: <><path d="M4 7h16M9 7V4h6v3M7 7l1 14h8l1-14M10 11v6M14 11v6"/></>,
}
return <svg aria-hidden="true" viewBox="0 0 24 24" className="icon">{paths[name]}</svg>
}
function RuntimeRow({ runtime, busy, onAction }) {
const running = runtime.state === 'running'
return (
<tr>
<td data-label="环境"><strong>{runtime.name}</strong><small>{runtime.status}</small></td>
<td data-label="状态"><span className={`state state-${runtime.state}`}>{statusLabels[runtime.state] || runtime.state}</span></td>
<td data-label="Profile"><code>{runtime.profile}</code></td>
<td data-label="CDP"><code>{runtime.endpoint}</code></td>
<td data-label="操作" className="actions">
<button className="button button-outline" disabled={busy || running} onClick={() => onAction(runtime.name, 'start')}><Icon name="play" />启动</button>
<button className="button button-neutral" disabled={busy || !running} onClick={() => onAction(runtime.name, 'stop')}><Icon name="stop" />停止</button>
<button className="button button-danger" disabled={busy} onClick={() => onAction(runtime.name, 'delete')}><Icon name="trash" />回收</button>
</td>
</tr>
)
}
function App() {
const [runtimes, setRuntimes] = useState([])
const [name, setName] = useState('')
const [seed, setSeed] = useState('1000')
const [busy, setBusy] = useState('')
const [error, setError] = useState('')
async function refresh(signal) {
try {
setRuntimes(await api('', { signal }))
setError('')
} catch (reason) {
if (reason.name !== 'AbortError') setError(reason.message)
}
}
useEffect(() => {
const controller = new AbortController()
refresh(controller.signal)
const timer = window.setInterval(() => refresh(controller.signal), 3000)
return () => {
window.clearInterval(timer)
controller.abort()
}
}, [])
async function createRuntime(event) {
event.preventDefault()
setBusy('create')
setError('')
try {
await api('', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, seed: Number(seed) }),
})
setName('')
await refresh()
} catch (reason) {
setError(reason.message)
} finally {
setBusy('')
}
}
async function act(runtimeName, action) {
if (action === 'delete' && !window.confirm(`回收 ${runtimeName}?Profile 数据卷将保留。`)) return
setBusy(runtimeName)
setError('')
try {
await api(`/${runtimeName}${action === 'delete' ? '' : `/${action}`}`, { method: action === 'delete' ? 'DELETE' : 'POST' })
await refresh()
} catch (reason) {
setError(reason.message)
} finally {
setBusy('')
}
}
return (
<div className="shell">
<aside className="sidebar">
<a className="brand" href="/">CreatorHub</a>
<nav aria-label="主导航">
<a className="nav-item active" href="/"><Icon name="runtime" />运行环境</a>
<span className="nav-item disabled"><Icon name="audit" />审计记录</span>
</nav>
</aside>
<main>
<header>
<h1>运行环境</h1>
<p>启动、停止并回收隔离的浏览器 Profile</p>
</header>
<form className="create-form" onSubmit={createRuntime}>
<label>环境名称<input required pattern="[a-z0-9](?:[a-z0-9]|-){0,31}" value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:account-a" /></label>
<label>Fingerprint Seed<input required min="1" max="2147483647" type="number" value={seed} onChange={(event) => setSeed(event.target.value)} /></label>
<button className="button button-primary" disabled={busy === 'create'}>{busy === 'create' ? '创建中…' : '创建环境'}</button>
</form>
<section className="runtime-list" aria-labelledby="runtime-list-title">
<h2 id="runtime-list-title" className="sr-only">浏览器运行环境</h2>
<table>
<thead><tr><th>环境</th><th>状态</th><th>Profile</th><th>CDP</th><th>操作</th></tr></thead>
<tbody>
{runtimes.map((runtime) => <RuntimeRow key={runtime.id} runtime={runtime} busy={busy === runtime.name} onAction={act} />)}
</tbody>
</table>
{runtimes.length === 0 ? <p className="empty">暂无运行环境,创建第一个隔离 Profile。</p> : null}
</section>
{error ? <p className="error" role="alert">{error}</p> : null}
<p className="security-note"><Icon name="shield" />Docker socket 仅由受限网关访问</p>
</main>
</div>
)
}
createRoot(document.getElementById('root')).render(<StrictMode><App /></StrictMode>)