技术栈切换(按 AGENTS.md 批准栈):react-admin/MUI → Refine v5 + shadcn/ui
(Tailwind CSS v4),Vite 8 + React 19 + react-router v7 hash 路由。
- 全部 9 个页面重写:AccountsPage/BrowsersPage(BrowserList/CreatePage/Detail)/
BrowserImagesPage/GatewaysPage/NetworkExitsPage/TasksPage/TracePages/
DraftPage/AuditPage;自建 lib/ui.jsx 组件库与 Layout(侧导航 + Outlet)
- 简单创建/编辑改 modal(账号/网关/镜像/出口),环境创建走独立页
/browsers/new;列表按信息密度选表格或移动卡片双形态
- 图标统一 RemixIcon;领域动作保持显式动词(start/stop/upgrade/recycle
等,不伪装成 CRUD update)
- LoginPage:Basic 凭证存 localStorage;401 清凭证并跳 #/login;
AuthGate 改为渲染期读 localStorage(react-router v7 的 pushState 不触发
hashchange,事件订阅会在导航后拿到过期状态导致登录后卡死)
- dataProvider 适配 Refine v5 契约:所有方法收单个参数对象
({resource, pagination, filters, meta, variables}),create/update/
delete/deleteOne 语义对齐
- Modal 关闭时焦点还给触发元素;Select 保持原生 button/combobox 语义
- e2e 补登录前置;账号创建流程同步 modal 交互
验证:npm test 65/65 通过;覆盖率 71.5% stmts / 76.3% lines(门槛 65%);
vite build 成功;playwright e2e 9/9 通过。
385 lines
23 KiB
React
385 lines
23 KiB
React
import { useEffect, useState } from 'react'
|
||
import { Link, useNavigate, useParams } from 'react-router'
|
||
import { useDataProvider, useList, useOne } from '@refinedev/core'
|
||
import {
|
||
Alert, Button, Card, CardContent, Copyable, DetailList, Field, Input, Modal, PageHeader,
|
||
PageState, Select, StatusPill, Table,
|
||
} from './lib/ui.jsx'
|
||
import { useTitle } from './lib/hooks.js'
|
||
|
||
const aliasPattern = /^[a-z0-9][a-z0-9-]{0,31}$/
|
||
const platformOptions = ['windows', 'linux', 'macos'].map(value => ({ value, label: value }))
|
||
const brandOptions = ['Chrome', 'Edge', 'Opera', 'Vivaldi'].map(value => ({ value, label: value }))
|
||
const spoofingFields = ['font', 'audio', 'canvas', 'clientrects', 'gpu']
|
||
|
||
const stateText = { created: '已创建', running: '运行中', exited: '已停止', restarting: '重启中', paused: '已暂停', missing: '未部署' }
|
||
const blockText = {
|
||
account_revoked: '授权已撤销', account_paused: '账号已暂停', binding_missing: '绑定缺失',
|
||
network_exit_missing: '固定出口缺失', network_exit_unhealthy: '固定出口不健康',
|
||
runtime_stop_pending: '停止结果待确认', runtime_missing: '运行实例缺失',
|
||
}
|
||
|
||
const scheduleBlock = runtime => runtime.schedule_status === 'ready'
|
||
? ''
|
||
: blockText[runtime.schedule_block_reason] || runtime.schedule_block_reason || '不可调度'
|
||
|
||
const createInitial = {
|
||
name: '', alias: '', gateway: '', image_version: '', account_id: '', network_exit_id: '', seed: '1000',
|
||
platform: '', platform_version: '', brand: '', brand_version: '', hardware_concurrency: '', lang: '', accept_lang: '', timezone: '', disable_spoofing: [],
|
||
}
|
||
|
||
// 高级字段空值不提交,收敛到 seed 驱动。
|
||
function fingerprintPayload(form) {
|
||
const payload = { seed: Number(form.seed) }
|
||
const advanced = { platform: form.platform, platform_version: form.platform_version, brand: form.brand, brand_version: form.brand_version, lang: form.lang, accept_lang: form.accept_lang, timezone: form.timezone }
|
||
for (const [key, value] of Object.entries(advanced)) if (value !== '') payload[key] = value
|
||
if (form.hardware_concurrency !== '') payload.hardware_concurrency = Number(form.hardware_concurrency)
|
||
if (form.disable_spoofing.length > 0) payload.disable_spoofing = form.disable_spoofing.join(',')
|
||
return payload
|
||
}
|
||
|
||
function StatePill({ state }) {
|
||
const known = Object.hasOwn(stateText, state)
|
||
return <StatusPill tone={state === 'running' ? 'success' : state === 'exited' ? 'warning' : 'neutral'} label={known ? stateText[state] : '未知状态'} sub={known ? undefined : state} />
|
||
}
|
||
|
||
function RuntimeActions({ runtime, busy, onAction }) {
|
||
const running = runtime.state === 'running'
|
||
const block = runtime.schedule_block_reason === 'runtime_missing' ? '' : scheduleBlock(runtime)
|
||
const startLabel = `启动 ${runtime.name}${block ? `(${block})` : ''}`
|
||
return (
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button size="sm" icon="ri-play-line" aria-label={startLabel} disabled={busy || running || block !== ''} onClick={() => onAction(runtime, 'start')}>启动</Button>
|
||
<Button size="sm" icon="ri-stop-line" aria-label={`停止 ${runtime.name}`} disabled={busy || !running} onClick={() => onAction(runtime, 'stop')}>停止</Button>
|
||
<Button size="sm" icon="ri-arrow-up-circle-line" aria-label={`升级 ${runtime.name}`} disabled={busy} onClick={() => onAction(runtime, 'upgrade')}>升级</Button>
|
||
<Button size="sm" variant="danger" icon="ri-delete-bin-line" aria-label={`回收 ${runtime.name}`} disabled={busy} onClick={() => onAction(runtime, 'recycle')}>回收</Button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function SchedulePill({ runtime }) {
|
||
const block = scheduleBlock(runtime)
|
||
return <StatusPill tone={block ? 'warning' : 'success'} label={block || '可调度'} />
|
||
}
|
||
|
||
function UpgradeDialog({ target, images, busy, onClose, onConfirm }) {
|
||
const [version, setVersion] = useState('')
|
||
const enabled = images.filter(image => image.enabled)
|
||
useEffect(() => { setVersion('') }, [target])
|
||
return (
|
||
<Modal open={target !== null} onClose={busy ? undefined : onClose} title={`升级 ${target ? `${target.name}(${target.alias})` : ''}`} size="sm" labelledBy="upgrade-title"
|
||
footer={<>
|
||
<Button onClick={onClose} disabled={busy}>取消</Button>
|
||
<Button variant="primary" busy={busy} busyText="升级中…" disabled={version === '' || version === target?.image_version} onClick={() => onConfirm(version)}>确认升级</Button>
|
||
</>}>
|
||
<div className="space-y-4">
|
||
<p className="text-sm text-muted">容器将按新镜像重建,Profile 数据卷与指纹参数保持不变。</p>
|
||
<Field id="upgrade-version" label="目标镜像版本" required>
|
||
<Select id="upgrade-version" value={version} onChange={event => setVersion(event.target.value)}
|
||
options={enabled.map(image => ({ value: image.version, label: `${image.version}${image.version === target?.image_version ? '(当前)' : ''}` }))}
|
||
placeholder="选择版本" />
|
||
</Field>
|
||
</div>
|
||
</Modal>
|
||
)
|
||
}
|
||
|
||
function RuntimeTable({ runtimes, busy, onAction, onRowClick }) {
|
||
return (
|
||
<div className="hidden overflow-x-auto rounded-lg border border-hairline bg-white md:block">
|
||
<table className="w-full min-w-[860px] table-fixed text-sm">
|
||
<thead>
|
||
<tr className="border-b border-hairline text-left text-xs text-muted">
|
||
<th className="w-[19%] px-4 py-2.5 font-medium">环境</th>
|
||
<th className="w-[12%] px-4 py-2.5 font-medium">状态</th>
|
||
<th className="w-[23%] px-4 py-2.5 font-medium">固定绑定</th>
|
||
<th className="w-[16%] px-4 py-2.5 font-medium">CDP</th>
|
||
<th className="w-[30%] px-4 py-2.5 font-medium">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{runtimes.map(runtime => (
|
||
<tr key={runtime.alias} className="border-b border-hairline last:border-0">
|
||
<td className="cursor-pointer px-4 py-3" onClick={() => onRowClick(runtime)}>
|
||
<p className="font-semibold">{runtime.name}</p>
|
||
<p className="anywhere text-xs text-muted">{runtime.alias} · seed {runtime.fingerprint?.seed}</p>
|
||
</td>
|
||
<td className="px-4 py-3"><StatePill state={runtime.state} /></td>
|
||
<td className="px-4 py-3">
|
||
<p className="text-sm">{runtime.account_id || '未绑定账号'}</p>
|
||
<p className="anywhere text-xs text-muted">{runtime.network_exit_id || '当前机器直连'} · {runtime.image_version}</p>
|
||
<div className="mt-1"><SchedulePill runtime={runtime} /></div>
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
{runtime.endpoint ? <Copyable value={runtime.endpoint} /> : <span className="text-sm text-muted">—</span>}
|
||
</td>
|
||
<td className="px-4 py-3"><RuntimeActions runtime={runtime} busy={busy === runtime.alias} onAction={onAction} /></td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function RuntimeCards({ runtimes, busy, onAction }) {
|
||
return (
|
||
<div className="space-y-4 md:hidden">
|
||
{runtimes.map(runtime => (
|
||
<Card key={runtime.alias}>
|
||
<CardContent className="space-y-3">
|
||
<div>
|
||
<p className="font-semibold">{runtime.name}</p>
|
||
<p className="anywhere text-xs text-muted">{runtime.alias} · {runtime.image_version} · seed {runtime.fingerprint?.seed}</p>
|
||
</div>
|
||
<StatePill state={runtime.state} />
|
||
<p className="text-sm">账号:{runtime.account_id || '未绑定'} · 网络出口:{runtime.network_exit_id || '当前机器直连'}</p>
|
||
<SchedulePill runtime={runtime} />
|
||
{runtime.endpoint ? <Copyable value={runtime.endpoint} /> : null}
|
||
<RuntimeActions runtime={runtime} busy={busy === runtime.alias} onAction={onAction} />
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function BrowserList() {
|
||
const navigate = useNavigate()
|
||
const dataProvider = useDataProvider()('default')
|
||
const [busy, setBusy] = useState('')
|
||
const [actionError, setActionError] = useState('')
|
||
const [upgradeTarget, setUpgradeTarget] = useState(null)
|
||
const { result, query } = useList({ resource: 'browsers', queryOptions: { refetchInterval: 3000 } })
|
||
const error = query.error
|
||
const isPending = query.isPending
|
||
const runtimes = result.data ?? []
|
||
const { result: imageResult, query: imagesQuery } = useList({ resource: 'browser-images' })
|
||
const images = imageResult.data ?? []
|
||
useTitle('CreatorHub · 运行环境')
|
||
|
||
async function handleAction(runtime, action) {
|
||
if (action === 'recycle' && !window.confirm(`回收 ${runtime.name}(${runtime.alias})?容器将被删除,Profile 数据卷保留。`)) return
|
||
if (action === 'upgrade') { setUpgradeTarget(runtime); return }
|
||
await runAction(runtime, action)
|
||
}
|
||
|
||
async function runAction(runtime, action, data) {
|
||
setBusy(runtime.alias)
|
||
setActionError('')
|
||
try {
|
||
await dataProvider.browserAction(runtime.alias, action, data)
|
||
await query.refetch()
|
||
} catch (reason) {
|
||
setActionError(reason.message)
|
||
} finally {
|
||
setBusy('')
|
||
}
|
||
}
|
||
|
||
async function upgrade(version) {
|
||
await runAction(upgradeTarget, 'upgrade', { version })
|
||
setUpgradeTarget(null)
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<PageHeader icon="ri-server-line" title="运行环境" description="启动、停止、升级并回收隔离的指纹浏览器">
|
||
<Button variant="primary" icon="ri-add-line" onClick={() => navigate('/browsers/new')}>创建环境</Button>
|
||
</PageHeader>
|
||
{actionError ? (
|
||
<Alert variant="destructive" className="mb-4">
|
||
{actionError}
|
||
<Button size="sm" onClick={() => query.refetch()}>重试</Button>
|
||
</Alert>
|
||
) : null}
|
||
<PageState pending={isPending} error={error} empty={runtimes.length === 0} emptyText="暂无运行环境,创建第一个隔离 Profile。">
|
||
<RuntimeTable runtimes={runtimes} busy={busy} onAction={handleAction} onRowClick={runtime => navigate(`/browsers/${encodeURIComponent(runtime.alias)}`)} />
|
||
<RuntimeCards runtimes={runtimes} busy={busy} onAction={handleAction} />
|
||
</PageState>
|
||
<UpgradeDialog target={upgradeTarget} images={images} busy={busy === upgradeTarget?.alias} onClose={() => setUpgradeTarget(null)} onConfirm={upgrade} />
|
||
<p className="mt-6 flex items-center gap-2 text-sm text-muted"><i className="ri-shield-check-line text-primary" aria-hidden="true" />Docker socket 仅由受限网关访问,镜像与参数由平台下发</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function BrowserCreatePage() {
|
||
const navigate = useNavigate()
|
||
const dataProvider = useDataProvider()('default')
|
||
const [form, setForm] = useState(createInitial)
|
||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||
const [submitBusy, setSubmitBusy] = useState(false)
|
||
const [submitError, setSubmitError] = useState('')
|
||
const { query: runtimesQuery } = useList({ resource: 'browsers' })
|
||
const { result: gatewayResult } = useList({ resource: 'gateways' })
|
||
const gateways = gatewayResult.data ?? []
|
||
const { result: imageResult } = useList({ resource: 'browser-images' })
|
||
const images = imageResult.data ?? []
|
||
const { result: accountResult } = useList({ resource: 'accounts' })
|
||
const accounts = accountResult.data ?? []
|
||
const { result: exitResult } = useList({ resource: 'network-exits' })
|
||
const networkExits = exitResult.data ?? []
|
||
useTitle('CreatorHub · 创建运行环境')
|
||
|
||
const enabledImages = images.filter(image => image.enabled)
|
||
const pausedAccounts = accounts.filter(account => account.authorization_status === 'authorized' && account.runtime_status === 'paused')
|
||
const healthyExits = networkExits.filter(item => item.health_status === 'healthy')
|
||
const update = (key, value) => setForm(current => ({ ...current, [key]: value }))
|
||
const toggleSpoofing = field => setForm(current => ({ ...current, disable_spoofing: current.disable_spoofing.includes(field) ? current.disable_spoofing.filter(item => item !== field) : [...current.disable_spoofing, field] }))
|
||
|
||
// 选中项为空且列表就绪时,默认选中第一个可选项,减少必填噪音。
|
||
useEffect(() => { if (form.gateway === '' && gateways.length > 0) update('gateway', gateways[0].name) }, [gateways, form.gateway])
|
||
useEffect(() => { if (form.image_version === '' && enabledImages.length > 0) update('image_version', enabledImages[0].version) }, [enabledImages, form.image_version])
|
||
useEffect(() => { if (form.account_id === '' && pausedAccounts[0]?.id) update('account_id', pausedAccounts[0].id) }, [pausedAccounts, form.account_id])
|
||
|
||
const seed = Number(form.seed)
|
||
const valid = form.name.trim() !== '' && aliasPattern.test(form.alias) && form.gateway !== '' && form.image_version !== '' && form.account_id !== '' && Number.isInteger(seed) && seed >= 1 && seed <= 2147483647
|
||
|
||
async function submit(event) {
|
||
event.preventDefault()
|
||
if (!valid) return
|
||
setSubmitBusy(true)
|
||
setSubmitError('')
|
||
try {
|
||
await dataProvider.create({
|
||
resource: 'browsers',
|
||
variables: {
|
||
alias: form.alias, name: form.name.trim(), gateway: form.gateway, image_version: form.image_version,
|
||
account_id: form.account_id, network_exit_id: form.network_exit_id, fingerprint: fingerprintPayload(form),
|
||
},
|
||
})
|
||
await runtimesQuery.refetch()
|
||
navigate('/browsers')
|
||
} catch (reason) {
|
||
setSubmitError(reason.status === 409 ? '冲突(409):账号或运行环境已有活动绑定;表单内容已保留。' : reason.status === 503 ? `环境不可用(503):${reason.message}` : reason.message)
|
||
} finally {
|
||
setSubmitBusy(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="max-w-[960px]">
|
||
<PageHeader title="创建运行环境" description="为已授权且暂停的账号绑定网关、镜像、指纹与网络出口">
|
||
<Button icon="ri-arrow-left-line" onClick={() => navigate('/browsers')}>返回运行环境</Button>
|
||
</PageHeader>
|
||
{submitError ? <Alert variant="destructive" className="mb-4">{submitError}</Alert> : null}
|
||
<Card>
|
||
<CardContent>
|
||
<form onSubmit={submit} noValidate className="space-y-6">
|
||
<div className="grid gap-5 sm:grid-cols-2">
|
||
<Field id="env-name" label="环境名称" required helper="展示名称,支持中文,如:店铺一号">
|
||
<Input id="env-name" required maxLength={64} value={form.name} onChange={event => update('name', event.target.value)} />
|
||
</Field>
|
||
<Field id="env-alias" label="别名(容器名)" required error={form.alias !== '' && !aliasPattern.test(form.alias) ? '小写字母、数字与中划线,最长 32 字符' : undefined} helper="用于容器名、数据卷与 API 路径">
|
||
<Input id="env-alias" required pattern="[a-z0-9][a-z0-9-]{0,31}" value={form.alias} onChange={event => update('alias', event.target.value)} placeholder="如:shop-01" />
|
||
</Field>
|
||
<Field id="env-gateway" label="网关" required error={gateways.length === 0 ? '请先在「网关管理」注册网关' : undefined} helper="容器调度到该网关">
|
||
<Select id="env-gateway" value={form.gateway} onChange={event => update('gateway', event.target.value)} options={gateways.map(item => ({ value: item.name, label: item.name }))} placeholder="选择网关" disabled={gateways.length === 0} />
|
||
</Field>
|
||
<Field id="env-version" label="镜像版本" required error={enabledImages.length === 0 ? '请先在「镜像版本」添加并启用' : undefined} helper="环境使用的浏览器镜像">
|
||
<Select id="env-version" value={form.image_version} onChange={event => update('image_version', event.target.value)} options={enabledImages.map(item => ({ value: item.version, label: item.version }))} placeholder="选择版本" disabled={enabledImages.length === 0} />
|
||
</Field>
|
||
<Field id="env-seed" label="Fingerprint Seed" required helper="1..2147483647,派生指纹参数">
|
||
<Input id="env-seed" type="number" min={1} max={2147483647} required value={form.seed} onChange={event => update('seed', event.target.value)} />
|
||
</Field>
|
||
<Field id="env-account" label="社媒账号" required error={pausedAccounts.length === 0 ? '请先准备已授权且暂停的账号' : undefined} helper="绑定后该账号固定使用此环境">
|
||
<Select id="env-account" value={form.account_id} onChange={event => update('account_id', event.target.value)} options={pausedAccounts.map(item => ({ value: item.id, label: `${item.id} · ${item.platform}` }))} placeholder="选择账号" disabled={pausedAccounts.length === 0} />
|
||
</Field>
|
||
<Field id="env-exit" label="网络出口" helper="留空时使用当前机器网络出口直连">
|
||
<Select id="env-exit" value={form.network_exit_id} onChange={event => update('network_exit_id', event.target.value)}
|
||
options={healthyExits.map(item => ({ value: item.id, label: `${item.id} · ${item.protocol}://${item.host}:${item.port}` }))}
|
||
placeholder="当前机器直连" emptyOption={{ value: '', label: '当前机器直连' }} />
|
||
</Field>
|
||
</div>
|
||
<div>
|
||
<button type="button" onClick={() => setAdvancedOpen(current => !current)} className="flex items-center gap-1.5 text-sm font-medium text-primary hover:underline">
|
||
高级指纹参数{advancedOpen ? '(收起)' : '(展开)'}
|
||
<i className={advancedOpen ? 'ri-arrow-up-s-line' : 'ri-arrow-down-s-line'} aria-hidden="true" />
|
||
</button>
|
||
{advancedOpen ? (
|
||
<div className="mt-4 grid gap-5 border-t border-hairline pt-4 sm:grid-cols-3">
|
||
<Field id="env-platform" label="操作系统 platform">
|
||
<Select id="env-platform" value={form.platform} onChange={event => update('platform', event.target.value)} options={platformOptions} emptyOption={{ value: '', label: '默认' }} placeholder="默认" />
|
||
</Field>
|
||
<Field id="env-platform-version" label="系统版本 platform-version" helper="如 11.0.0">
|
||
<Input id="env-platform-version" value={form.platform_version} onChange={event => update('platform_version', event.target.value)} />
|
||
</Field>
|
||
<Field id="env-brand" label="浏览器品牌 brand">
|
||
<Select id="env-brand" value={form.brand} onChange={event => update('brand', event.target.value)} options={brandOptions} emptyOption={{ value: '', label: '默认 Chromium' }} placeholder="默认 Chromium" />
|
||
</Field>
|
||
<Field id="env-brand-version" label="品牌版本 brand-version" helper="如 132.0.6834.159">
|
||
<Input id="env-brand-version" value={form.brand_version} onChange={event => update('brand_version', event.target.value)} />
|
||
</Field>
|
||
<Field id="env-concurrency" label="CPU 核心数 hardware-concurrency" helper="留空由种子生成">
|
||
<Input id="env-concurrency" type="number" min={1} max={128} value={form.hardware_concurrency} onChange={event => update('hardware_concurrency', event.target.value)} />
|
||
</Field>
|
||
<Field id="env-lang" label="语言 lang" helper="如 zh-CN">
|
||
<Input id="env-lang" value={form.lang} onChange={event => update('lang', event.target.value)} />
|
||
</Field>
|
||
<Field id="env-accept-lang" label="接受语言 accept-lang" helper="如 zh-CN,en-US">
|
||
<Input id="env-accept-lang" value={form.accept_lang} onChange={event => update('accept_lang', event.target.value)} />
|
||
</Field>
|
||
<Field id="env-timezone" label="时区 timezone" helper="如 Asia/Shanghai">
|
||
<Input id="env-timezone" value={form.timezone} onChange={event => update('timezone', event.target.value)} />
|
||
</Field>
|
||
<div className="space-y-1.5">
|
||
<p className="text-sm font-medium">禁用指纹伪装 disable-spoofing</p>
|
||
<div className="flex flex-wrap gap-x-4 gap-y-2">
|
||
{spoofingFields.map(field => (
|
||
<label key={field} className="flex cursor-pointer items-center gap-1.5 text-sm">
|
||
<input type="checkbox" checked={form.disable_spoofing.includes(field)} onChange={() => toggleSpoofing(field)} aria-label={`禁用伪装 ${field}`} className="size-4 accent-primary" />
|
||
{field}
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
<div className="flex gap-3">
|
||
<Button variant="primary" type="submit" busy={submitBusy} busyText="创建中…" disabled={!valid}>创建环境</Button>
|
||
<Button type="button" onClick={() => navigate('/browsers')}>取消</Button>
|
||
</div>
|
||
</form>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function BrowserDetail() {
|
||
const { id } = useParams()
|
||
const { result, error, isPending } = useOne({ resource: 'browsers', id, queryOptions: { retry: false } })
|
||
const runtime = result
|
||
useTitle('CreatorHub · 环境详情')
|
||
if (isPending) return <PageState pending />
|
||
if (error || !runtime) return <Alert variant="destructive">{error?.message || '运行环境不存在'}</Alert>
|
||
return (
|
||
<div>
|
||
<PageHeader title="运行环境详情" description={`${runtime.name}(${runtime.alias})`}>
|
||
<Button icon="ri-arrow-left-line" as={Link} to="/browsers">返回运行环境</Button>
|
||
</PageHeader>
|
||
<Card>
|
||
<CardContent className="space-y-4">
|
||
<StatePill state={runtime.state} />
|
||
<DetailList rows={[
|
||
['账号与镜像', <span className="anywhere">{runtime.account_id} · {runtime.image_version}</span>],
|
||
['固定出口', <span className="anywhere">{runtime.network_exit?.id || '未绑定'} · 健康:{runtime.network_exit?.health_status || '未知'}</span>],
|
||
['绑定版本 / 运行实例', <span className="anywhere">{runtime.binding_version} · {runtime.runtime_instance_id || '无'}</span>],
|
||
]} />
|
||
{runtime.endpoint ? (
|
||
<div className="flex min-w-0 flex-col gap-0.5 text-sm">
|
||
<dt className="text-muted">CDP</dt>
|
||
<dd><Copyable value={runtime.endpoint} /></dd>
|
||
</div>
|
||
) : null}
|
||
<div className="flex flex-wrap gap-3">
|
||
<Button as={Link} to={`/accounts/${encodeURIComponent(runtime.account_id)}`}>查看账号</Button>
|
||
{runtime.network_exit?.id ? <Button as={Link} to={`/network-exits/${encodeURIComponent(runtime.network_exit.id)}`}>查看出口</Button> : null}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|