Why: Approving an extra package, rebuilding the base layer, reclaiming unused layers, and uploading a wheel need an admin surface that uses the existing admin controls. Evidence: SELF tsc of the new panel reported no errors in frontend sources. UI package type errors come from missing node_modules in this worktree, not from this panel. Impact: Administrators get a Python 环境 section. Other admin sections are unchanged. Refs: .task/feature-layered-python-env.md Co-authored-by: Cursor <cursoragent@cursor.com>
99 lines
5.1 KiB
TypeScript
99 lines
5.1 KiB
TypeScript
import { useEffect, useState } from 'react'
|
||
import { api, type ExecEnvView } from '../api'
|
||
import { Btn, EmptyState, Icon, SettingRow, SettingsGroup } from '@ui'
|
||
|
||
export function ExecEnvPanel() {
|
||
const [view, setView] = useState<ExecEnvView | null>(null)
|
||
const [err, setErr] = useState('')
|
||
const [notice, setNotice] = useState('')
|
||
const [busy, setBusy] = useState(false)
|
||
const [conv, setConv] = useState('')
|
||
const [reason, setReason] = useState('')
|
||
const [wheel, setWheel] = useState<File | null>(null)
|
||
|
||
const refresh = async () => {
|
||
try { setView(await api.adminExecEnv()) } catch (e) { setErr(String(e)) }
|
||
}
|
||
useEffect(() => { refresh() }, [])
|
||
|
||
const run = async (work: () => Promise<string>) => {
|
||
setErr(''); setNotice(''); setBusy(true)
|
||
try { setNotice(await work()); await refresh() } catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||
}
|
||
|
||
if (!view) return <EmptyState icon="package">加载 Python 环境…</EmptyState>
|
||
if (!view.configured) return <EmptyState icon="package">这台服务没有配置分层 Python 环境根</EmptyState>
|
||
|
||
return (
|
||
<div>
|
||
<SettingsGroup title="当前环境">
|
||
<SettingRow label="环境根" desc={view.root || '未设置'}><span /></SettingRow>
|
||
<SettingRow label="解释器" desc={view.python || '尚未安装托管解释器'}><span /></SettingRow>
|
||
<SettingRow label="基础层" desc={view.base || '尚未建立'}><span /></SettingRow>
|
||
<SettingRow label="层目录" desc={(view.layers ?? []).join('、') || '无'}>
|
||
<Btn variant="ghost" icon="undo" disabled={busy} onClick={() => run(async () => {
|
||
const out = await api.adminExecEnvRebase()
|
||
return `基础层已切换到 ${out.base}。已有会话仍用原来的绑定。`
|
||
})}>重建基础层</Btn>
|
||
</SettingRow>
|
||
</SettingsGroup>
|
||
<SettingsGroup title="申请">
|
||
{(view.requests ?? []).length === 0 && <EmptyState icon="inbox">还没有扩展包申请</EmptyState>}
|
||
{(view.requests ?? []).map(req => (
|
||
<SettingRow key={req.id} label={req.spec || req.id} desc={`${req.status} · ${req.conversationId} · ${req.detail}`}>
|
||
{req.status === 'pending_approval' && (
|
||
<span className="ad-row">
|
||
<Btn variant="primary" icon="check" disabled={busy} onClick={() => run(async () => {
|
||
const out = await api.adminExecEnvDecision(req.id, true, false)
|
||
return out.detail || out.status
|
||
})}>批准</Btn>
|
||
<Btn variant="ghost" disabled={busy} onClick={() => run(async () => {
|
||
const out = await api.adminExecEnvDecision(req.id, true, true)
|
||
return out.detail || '已用隔离层'
|
||
})}>隔离层</Btn>
|
||
<Btn variant="ghost" icon="x" disabled={busy} onClick={() => run(async () => {
|
||
const out = await api.adminExecEnvDecision(req.id, false, false)
|
||
return out.detail || '已拒绝'
|
||
})}>拒绝</Btn>
|
||
</span>
|
||
)}
|
||
</SettingRow>
|
||
))}
|
||
</SettingsGroup>
|
||
<SettingsGroup title="会话绑定">
|
||
{(view.bindings ?? []).map(b => (
|
||
<SettingRow key={b.conversationId} label={b.conversationId} desc={b.isolated ? `隔离 ${b.isolated}` : (b.extensions ?? []).join('、') || '只用基础层'}>
|
||
<span />
|
||
</SettingRow>
|
||
))}
|
||
<SettingRow label="回收" desc="删掉 7 天内没有会话引用、也不是当前基础层的目录">
|
||
<Btn variant="ghost" icon="trash" disabled={busy} onClick={() => run(async () => {
|
||
const out = await api.adminExecEnvGC()
|
||
const n = out.removed?.length ?? 0
|
||
return n === 0 ? '没有可回收的层' : `已回收 ${n} 层`
|
||
})}>回收未引用层</Btn>
|
||
</SettingRow>
|
||
</SettingsGroup>
|
||
<SettingsGroup title="上传 wheel">
|
||
<SettingRow label="会话" desc="这个 wheel 只挂到指定会话,并进入审批">
|
||
<input className="lg-input" value={conv} onChange={e => setConv(e.target.value)} placeholder="会话 ID" />
|
||
</SettingRow>
|
||
<SettingRow label="理由" desc="写给审批记录">
|
||
<input className="lg-input" value={reason} onChange={e => setReason(e.target.value)} />
|
||
</SettingRow>
|
||
<SettingRow label="文件" desc="只接受预编译 wheel,不执行里面的脚本">
|
||
<input type="file" accept=".whl" onChange={e => setWheel(e.target.files?.[0] ?? null)} />
|
||
</SettingRow>
|
||
<div className="ad-row">
|
||
<Btn variant="primary" icon="download" disabled={busy || !wheel || !conv.trim()} onClick={() => run(async () => {
|
||
const out = await api.adminExecEnvWheel(wheel as File, conv.trim(), reason.trim())
|
||
return `${out.file}:${out.outcome.status}`
|
||
})}>提交审批</Btn>
|
||
</div>
|
||
</SettingsGroup>
|
||
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||
</div>
|
||
)
|
||
}
|