HH-738: add constrained browser container control (#4)
This commit is contained in:
@@ -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>)
|
||||
@@ -0,0 +1,93 @@
|
||||
:root {
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: #111827;
|
||||
background: #ffffff;
|
||||
font-synthesis: none;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-width: 320px; min-height: 100vh; }
|
||||
button, input { font: inherit; }
|
||||
button:focus-visible, input:focus-visible, a:focus-visible { outline: 3px solid #93c5fd; outline-offset: 2px; }
|
||||
.shell { min-height: 100vh; display: grid; grid-template-columns: 256px 1fr; }
|
||||
.sidebar { padding: 30px 12px; color: #fff; background: #061a38; }
|
||||
.brand { display: block; margin: 0 12px 38px; color: inherit; font-size: 28px; font-weight: 760; text-decoration: none; letter-spacing: -1px; }
|
||||
.sidebar nav { display: grid; gap: 12px; }
|
||||
.nav-item { display: flex; align-items: center; gap: 14px; min-height: 60px; padding: 0 18px; border-radius: 8px; color: #a9b7cc; font-size: 17px; font-weight: 650; text-decoration: none; }
|
||||
.nav-item.active { color: #fff; background: #0866ef; }
|
||||
.nav-item.disabled { opacity: .7; }
|
||||
.icon { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; flex: 0 0 auto; }
|
||||
main { width: min(100%, 1280px); padding: 44px 36px 72px; }
|
||||
header { margin-bottom: 30px; }
|
||||
h1 { margin: 0 0 10px; font-size: 40px; line-height: 1.15; letter-spacing: -1.4px; }
|
||||
header p { margin: 0; color: #667085; font-size: 18px; }
|
||||
.create-form { display: grid; grid-template-columns: minmax(220px, 1fr) minmax(220px, 1fr) 210px; gap: 28px; align-items: end; padding: 30px; border: 1px solid #d7dde7; border-radius: 9px; box-shadow: 0 4px 14px rgb(16 24 40 / 6%); }
|
||||
label { display: grid; gap: 10px; font-size: 15px; font-weight: 650; }
|
||||
input { width: 100%; height: 52px; padding: 0 14px; border: 1px solid #c7cfdb; border-radius: 7px; color: #111827; background: #fff; font-size: 16px; }
|
||||
input::placeholder { color: #929daf; }
|
||||
.button { display: inline-flex; align-items: center; justify-content: center; gap: 7px; height: 44px; padding: 0 14px; border: 1px solid; border-radius: 7px; background: #fff; font-size: 14px; font-weight: 650; cursor: pointer; }
|
||||
.button .icon { width: 17px; height: 17px; }
|
||||
.button:disabled { cursor: not-allowed; opacity: .4; }
|
||||
.button-primary { height: 52px; border-color: #0866ef; color: #fff; background: #0866ef; font-size: 16px; }
|
||||
.button-outline { border-color: #0866ef; color: #0866ef; }
|
||||
.button-neutral { border-color: #aab4c3; color: #344054; }
|
||||
.button-danger { border-color: #ef4444; color: #dc2626; }
|
||||
.runtime-list { margin-top: 42px; overflow: hidden; border: 1px solid #d7dde7; border-radius: 9px; }
|
||||
table { width: 100%; border-collapse: collapse; table-layout: fixed; }
|
||||
th, td { padding: 23px 20px; border-bottom: 1px solid #d7dde7; text-align: left; vertical-align: middle; }
|
||||
th { color: #1f2937; font-size: 14px; font-weight: 700; }
|
||||
th:nth-child(1) { width: 19%; } th:nth-child(2) { width: 12%; } th:nth-child(3) { width: 17%; } th:nth-child(4) { width: 22%; } th:nth-child(5) { width: 30%; }
|
||||
tbody tr:last-child td { border-bottom: 0; }
|
||||
td strong, td small { display: block; }
|
||||
td small { margin-top: 6px; color: #7a8699; }
|
||||
code { color: #475467; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; overflow-wrap: anywhere; }
|
||||
.state { display: inline-flex; align-items: center; gap: 7px; font-weight: 650; }
|
||||
.state::before { width: 8px; height: 8px; border-radius: 50%; background: #6b7280; content: ""; }
|
||||
.state-running { color: #079455; } .state-running::before { background: #12b76a; }
|
||||
.state-exited { color: #d97706; } .state-exited::before { background: #f59e0b; }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.empty { margin: 0; padding: 52px 20px; color: #667085; text-align: center; }
|
||||
.error { margin: 20px 0 0; padding: 14px 16px; border: 1px solid #fecaca; border-radius: 7px; color: #b42318; background: #fef2f2; }
|
||||
.security-note { display: flex; align-items: center; gap: 12px; margin: 32px 0 0; padding: 19px 24px; border: 1px solid #bfdbfe; border-radius: 8px; color: #243b5a; background: #f8fbff; }
|
||||
.security-note .icon { color: #164e85; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.shell { grid-template-columns: 82px 1fr; }
|
||||
.brand { margin: 4px 8px 38px; font-size: 0; }
|
||||
.brand::before { font-size: 22px; content: "CH"; }
|
||||
.nav-item { justify-content: center; padding: 0; font-size: 0; }
|
||||
.create-form { grid-template-columns: 1fr 1fr; }
|
||||
.button-primary { grid-column: 1 / -1; }
|
||||
th:nth-child(4), td:nth-child(4) { display: none; }
|
||||
th:nth-child(1) { width: 24%; } th:nth-child(2) { width: 18%; } th:nth-child(3) { width: 25%; } th:nth-child(5) { width: 33%; }
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.shell { display: block; }
|
||||
.sidebar { display: flex; align-items: center; justify-content: space-between; padding: 12px 18px; }
|
||||
.brand { margin: 0; font-size: 20px; }
|
||||
.brand::before { content: none; }
|
||||
.sidebar nav { display: flex; }
|
||||
.nav-item { min-height: 44px; padding: 0 14px; font-size: 14px; }
|
||||
.nav-item.disabled { display: none; }
|
||||
main { padding: 30px 18px 48px; }
|
||||
h1 { font-size: 32px; }
|
||||
.create-form { grid-template-columns: 1fr; padding: 22px; gap: 18px; }
|
||||
.button-primary { grid-column: auto; }
|
||||
.runtime-list { overflow: visible; border: 0; }
|
||||
thead { display: none; }
|
||||
tbody { display: grid; gap: 14px; }
|
||||
tr { display: grid; gap: 12px; padding: 20px; border: 1px solid #d7dde7; border-radius: 8px; }
|
||||
td, tbody tr:last-child td { display: grid; grid-template-columns: 90px 1fr; padding: 0; border: 0; }
|
||||
td::before { color: #667085; font-size: 13px; content: attr(data-label); }
|
||||
td:nth-child(4) { display: none; }
|
||||
.actions { display: flex; padding-top: 8px; }
|
||||
.actions::before { display: none; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.button { transition: background-color .15s ease, border-color .15s ease, opacity .15s ease; }
|
||||
.button:not(:disabled):hover { background: #eff6ff; }
|
||||
.button-primary:not(:disabled):hover { background: #0759d4; }
|
||||
}
|
||||
Reference in New Issue
Block a user