Merge pull request 'Show the layered Python environment in admin'

Reviewed against the layered environment plan. Required CI on the current head passed where this repository runs CI.
This commit was merged in pull request #92.
This commit is contained in:
2026-10-15 14:24:46 +00:00
3 changed files with 121 additions and 0 deletions
+21
View File
@@ -15,6 +15,16 @@ const BASE = ''
* 自身命令面 fail-closed。 */
export interface AdminZoneSection { id: string; title: string; icon?: string; desc?: string; order?: number }
export interface ExecEnvView {
configured: boolean
root?: string
python?: string
base?: string
layers?: string[]
requests?: { id: string; conversationId: string; owner: string; spec: string; reason: string; status: string; layerId: string; detail: string; approver: string }[]
bindings?: { conversationId: string; baseId: string; extensions: string[] | null; isolated: string }[]
}
async function checked(res: Response): Promise<Response> {
if (!res.ok) {
const text = await res.text()
@@ -79,6 +89,17 @@ export const api = {
adminIssueLoginKey: (owner: string, ttlMinutes?: number) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ purpose: 'recover', ...(ttlMinutes ? { ttlMinutes } : {}) }) }).then(r => json<{ id: string; owner: string; purpose: string; status: string; expiresAt: string; key: string }>(r)),
adminListLoginKeys: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys`).then(r => json<{ keys: { id: string; owner: string; purpose: string; status: string; expiresAt: string; createdAt: string; createdBy?: string }[] }>(r)).then(d => d.keys ?? []),
adminRevokeLoginKey: (owner: string, id: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/login-keys/${encodeURIComponent(id)}`, { method: 'DELETE' }).then(r => json<{ ok: boolean }>(r)),
adminExecEnv: () => auth.fetch(`${BASE}/api/admin/exec-env`).then(r => json<ExecEnvView>(r)),
adminExecEnvDecision: (id: string, allow: boolean, isolate: boolean) => auth.fetch(`${BASE}/api/admin/exec-env/requests/${encodeURIComponent(id)}/decision`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ allow, isolate }) }).then(r => json<{ status: string; detail?: string; layerId?: string }>(r)),
adminExecEnvRebase: () => auth.fetch(`${BASE}/api/admin/exec-env/rebase`, { method: 'POST' }).then(r => json<{ base: string }>(r)),
adminExecEnvGC: () => auth.fetch(`${BASE}/api/admin/exec-env/gc`, { method: 'POST' }).then(r => json<{ removed: string[] | null }>(r)),
adminExecEnvWheel: (file: File, conversationId: string, reason: string) => {
const body = new FormData()
body.set('wheel', file)
body.set('conversationId', conversationId)
body.set('reason', reason)
return auth.fetch(`${BASE}/api/admin/exec-env/wheels`, { method: 'POST', body }).then(r => json<{ file: string; outcome: { status: string; requestId?: string; detail?: string } }>(r))
},
createConversation: (projectId: string, title?: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: title ?? '' }) }).then(r => json<Conversation>(r)),
deleteConversation: (id: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'DELETE' }),
setConversationProfile: (id: string, profileId: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profileId }) }).then(r => json<Conversation>(r)),
+2
View File
@@ -8,6 +8,7 @@ import { useState } from 'react'
import { store, useStore } from '../store'
import { EmptyState, Icon, type IconName } from '@ui'
import { UsersPanel, ProfilesSection, PluginsSection, PluginWorkspacesPanel, SystemSettingsPanel } from './adminSections'
import { ExecEnvPanel } from './ExecEnvPanel'
import { RdmAuditWorkspace } from './RdmAuditWorkspace'
import { RdmRolesPanel } from './adminRdmSections'
import type { AdminZoneSection } from '../api'
@@ -34,6 +35,7 @@ const SECTIONS: AdminSectionDef[] = [
// P2 受管:模型/密钥/Tavily/遥测/运行基础设施的唯一写入面(Backend PR 188 系统设置 API)。
{ key: 'system-settings', label: '模型与服务配置', icon: 'settings', desc: '系统受管项:模型、密钥、Tavily、遥测与运行基础设施(仅管理员)', render: () => <SystemSettingsPanel /> },
{ key: 'plugins', label: '插件管理', icon: 'puzzle', desc: '已装载插件清单与热重载(装卸不影响核心)', render: () => <PluginsSection /> },
{ key: 'python-env', label: 'Python 环境', icon: 'package', desc: '基础层、扩展申请、回收和 wheel 上传(仅管理员)', render: () => <ExecEnvPanel /> },
{ key: 'workspaces', label: '插件工作区', icon: 'grid', desc: '插件声明面板与服务端声明工作区', render: () => <PluginWorkspacesPanel /> },
]
+98
View File
@@ -0,0 +1,98 @@
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>
)
}