refactor!: separate runtime releases from panel source

This commit is contained in:
2026-10-14 15:05:10 +08:00
parent 20635ffaec
commit a2dd6ee27d
51 changed files with 221 additions and 2017 deletions
+12 -24
View File
@@ -1,15 +1,10 @@
import { useEffect, useRef, useState, type FormEvent } from 'react';
import { api, normalBuildAllowed, selectableImage, type Image, type Instance } from './api';
import { message, ResourceState, useAction, useResource } from './ui';
import { api, selectableImage, type Instance } from './api';
import { ResourceState, useAction, useResource } from './ui';
export function CreateInstance({close, refresh}: {close: () => void; refresh: () => Promise<void>}) {
const compatibility = useResource(api.compatibility);
const images = useResource(api.images), action = useAction();
const [mode, setMode] = useState('local'), [progress, setProgress] = useState('');
const [version, setVersion] = useState('');
const [pollError, setPollError] = useState('');
const [intents, setIntents] = useState<{id:string; name:string; version:string; status:string; error:string}[]>([]);
useEffect(() => {let live=true; const load=()=>api.request<typeof intents>('/intents').then(v=>{if(live){setIntents(v);setPollError('');}}).catch(e=>{if(live)setPollError(message(e));}); void load(); const timer=window.setInterval(load,2000); return ()=>{live=false;window.clearInterval(timer);};}, []);
const [progress, setProgress] = useState('');
const active = useRef(true);
useEffect(() => {active.current = true; return () => {active.current = false;};}, []);
const available = images.data?.filter(selectableImage) || [];
@@ -17,21 +12,7 @@ export function CreateInstance({close, refresh}: {close: () => void; refresh: ()
e.preventDefault();
const form = new FormData(e.currentTarget), name = String(form.get('name'));
await action.run(async () => {
let image: Image | undefined = available.find(i => (i.image_id || i.id) === form.get('image'));
if (mode === 'latest') {
if (!version) {
const latest = await api.request<{version:string; build_allowed:boolean}>('/latest');
if (!latest.version) throw new Error('服务器未返回精确版本。');
setVersion(latest.version);
if (!latest.build_allowed || !normalBuildAllowed(await api.compatibility(), latest.version)) throw new Error('此最新版不支持普通构建。管理员可前往设置 → 镜像与构建进行实验构建,但实验镜像不能直接用于创建。');
return;
}
if (!normalBuildAllowed(await api.compatibility(), version)) throw new Error('此版本不支持普通构建;实验镜像不能直接用于创建。');
const intent = await api.request<{id:string}>('/intents', 'POST', {name, version});
if (!intent.id) throw new Error('未收到意图确认,请刷新核实,勿重复提交。');
setIntents(await api.request<typeof intents>('/intents'));
await refresh(); return;
}
const image = available.find(i => (i.image_id || i.id) === form.get('image'));
if (!active.current) return;
if (!image) throw new Error('请选择可用的本地镜像。');
setProgress('正在创建实例,请勿重复提交…');
@@ -41,5 +22,12 @@ export function CreateInstance({close, refresh}: {close: () => void; refresh: ()
});
if (active.current) setProgress('');
}
return <form className="inline-form" onSubmit={submit}><h3>创建独立实例</h3>{action.feedback}<p className="callout">确认后服务器持久预留一个名额(每用户最多两个)。刷新或关闭不会取消自动创建;失败释放名额,不回退其他版本。取消等待不会取消共享构建,创建开始后不可撤回。</p><fieldset disabled={action.busy}><label>实例名称<input name="name" required maxLength={64}/></label><label>创建方式<select value={mode} onChange={e => {setMode(e.target.value); setVersion('');}}><option value="local">选择本地镜像</option><option value="latest">构建最新版并创建</option></select></label>{mode === 'local' && <><ResourceState resource={images}/><label>本地镜像<select name="image" required defaultValue=""><option value="" disabled>选择已构建镜像</option>{available.map(i => <option key={i.image_id || i.id} value={i.image_id || i.id}>{i.version} · {i.tag || i.image_id || i.id}</option>)}</select></label>{!images.loading && !available.length && <p>暂无已准入的可用镜像。历史或实验镜像不可用于创建。</p>}</>}{mode === 'latest' && <ResourceState resource={compatibility}/>} {mode === 'latest' && version && compatibility.data && !normalBuildAllowed(compatibility.data, version) && <p className="alert">此最新版不支持普通构建。管理员可前往设置 → 镜像与构建实验构建;实验镜像不能直接用于创建。</p>}{mode === 'latest' && version && compatibility.data && normalBuildAllowed(compatibility.data, version) && <p>将锁定精确版本 <strong>{version}</strong>,确认后预留名额并构建创建。</p>}<button className="primary" disabled={mode === 'local' ? !available.length : !!version && (!compatibility.data || !normalBuildAllowed(compatibility.data, version))}>{mode === 'local' ? '确认创建' : version ? '确认版本并预留创建' : '查询最新版'}</button></fieldset>{progress && <p role="status">{progress}</p>}{pollError && <p className="muted" role="alert">意图刷新失败:{pollError}</p>}<ul>{intents.map(i => <li key={i.id}>{i.name} · {i.version} · {i.status} {i.error}{i.status === 'waiting' && <button type="button" disabled={action.busy} onClick={() => void action.run(async () => {await api.request('/intents/' + i.id, 'DELETE'); setIntents(await api.request<typeof intents>('/intents')); await refresh();})}>取消等待并释放名额</button>}{i.status === 'success' && <button type="button" onClick={() => void refresh()}>刷新实例列表</button>}</li>)}</ul><button type="button" disabled={action.busy} onClick={close}>关闭(保留已提交意图)</button></form>;
return <form className="inline-form" onSubmit={submit}><h3>创建独立实例</h3>{action.feedback}
<p className="callout">请选择已验证并准入的本地镜像。创建使用所选镜像的精确 ID,不会自动构建或切换版本。</p>
<fieldset disabled={action.busy}><label>实例名称<input name="name" required maxLength={64}/></label>
<ResourceState resource={images}/><label>本地镜像<select name="image" required defaultValue=""><option value="" disabled>选择已验证镜像</option>{available.map(i => <option key={i.image_id || i.id} value={i.image_id || i.id}>{i.version} · {i.tag || i.image_id || i.id}</option>)}</select></label>
{!images.loading && !available.length && <p>暂无已准入的可用镜像。请联系管理员构建并验证发布镜像。</p>}
<button className="primary" disabled={!available.length}>确认创建</button>
</fieldset>{progress && <p role="status">{progress}</p>}<button type="button" disabled={action.busy} onClick={close}>关闭</button>
</form>;
}
+27 -21
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
const state = vi.hoisted(() => ({slots: [] as any[], cursor: 0}));
const state = vi.hoisted(() => ({slots: [] as any[], cursor: 0, emptyImages: false}));
vi.mock('react', async original => ({
...await original<typeof import('react')>(),
useState(value: unknown) {const i = state.cursor++; if (!(i in state.slots)) state.slots[i] = value; return [state.slots[i], (next: unknown) => {state.slots[i] = next;}];},
@@ -8,22 +8,21 @@ vi.mock('react', async original => ({
}));
vi.mock('./ui', async original => ({
...await original<typeof import('./ui')>(),
useResource: (load: unknown) => ({data: load === api.compatibility ? policy : load === api.images ? [{id:'ok', version:'1.2.3', status:'available', selectable:true}, {id:'exp', version:'9.9.9', status:'experimental', selectable:false}] : [], loading:false, refresh:vi.fn(async () => {})}),
useResource: (load: unknown) => ({data: load === api.images ? state.emptyImages ? [] : [{id:'ok', version:'1.2.3', status:'available', selectable:true}, {id:'exp', version:'9.9.9', status:'experimental', selectable:false}] : [], loading:false, refresh:vi.fn(async () => {})}),
}));
import { api, type Compatibility } from './api';
import { api } from './api';
import { Images } from './Images';
import { ReleasePanel } from './ReleasePanel';
import { CreateInstance } from './CreateInstance';
vi.mock('./ProfileEditor', () => ({ProfileEditor: () => null}));
import { Instances } from './Instances';
const policy: Compatibility = {recipe_rev:'7', platform:'linux/amd64', entries:[], unknown:{state:'existing_only', build_allowed:false, reason:'unknown'}, experimental_admin_only:true};
function nodes(root: any, predicate: (node: any) => boolean): any[] {
if (Array.isArray(root)) return root.flatMap(child => nodes(child, predicate));
if (!root || typeof root !== 'object') return [];
return [...(predicate(root) ? [root] : []), ...nodes(root.props?.children, predicate)];
}
function render(admin = false) {state.cursor = 0; return Images({admin});}
beforeEach(() => {state.slots = []; state.cursor = 0; vi.stubGlobal('window', {confirm:vi.fn(() => false)});});
beforeEach(() => {state.slots = []; state.cursor = 0; state.emptyImages = false; vi.stubGlobal('window', {confirm:vi.fn(() => false)});});
afterEach(() => {vi.restoreAllMocks(); vi.unstubAllGlobals();});
it('hides experimental and admission controls from ordinary users', () => {
const tree = render();
@@ -33,12 +32,10 @@ it('hides experimental and admission controls from ordinary users', () => {
expect(nodes(tree, n => n.type === 'button' && n.props.children === '准入管理')).toHaveLength(0);
expect(nodes(render(true), n => n.type === 'button' && n.props.children === '准入管理')).toHaveLength(2);
});
it('canceled build confirmation sends zero requests', () => {
const request = vi.spyOn(api, 'startBuild');
const check = vi.spyOn(api, 'compatibility');
nodes(render(true), n => n.type === 'form')[0].props.onSubmit({preventDefault:vi.fn()});
expect(window.confirm).toHaveBeenCalledOnce();
expect(request).not.toHaveBeenCalled(); expect(check).not.toHaveBeenCalled();
it.each([false, true])('has no legacy build controls (admin=%s)', admin => {
const tree = render(admin);
expect(nodes(tree, n => n.type === 'form' || n.type === 'input')).toHaveLength(0);
expect(JSON.stringify(tree)).not.toMatch(/查询 latest|精确 DSH 版本|提交实验构建/);
});
it('requires evidence and confirmation; cancel sends no admission mutation', () => {
const request = vi.spyOn(api, 'admitImage');
@@ -53,17 +50,26 @@ it('requires evidence and confirmation; cancel sends no admission mutation', ()
form.props.onSubmit({preventDefault:vi.fn()});
expect(window.confirm).toHaveBeenCalledOnce(); expect(request).not.toHaveBeenCalled();
});
it('unknown latest never reserves an intent, including a second submit', async () => {
const request = vi.spyOn(api, 'request').mockResolvedValue({version:'9.9.9', build_allowed:false});
vi.spyOn(api, 'compatibility').mockResolvedValue(policy);
vi.stubGlobal('FormData', class {get() {return 'name';}});
const renderCreate = () => {state.cursor = 0; return CreateInstance({close:vi.fn(), refresh:vi.fn(async () => {})});};
let form = renderCreate();
it('creates only the selected verified exact image without build or intent requests', async () => {
const request = vi.spyOn(api, 'request').mockResolvedValue({id:'created'});
vi.stubGlobal('FormData', class {get(key: string) {return key === 'image' ? 'ok' : 'name';}});
const close = vi.fn(), refresh = vi.fn(async () => {});
const form = CreateInstance({close, refresh});
expect(nodes(form, n => n.type === 'select')).toHaveLength(1);
expect(nodes(form, n => n.type === 'option' && n.props.value === 'exp')).toHaveLength(0);
nodes(form, n => n.type === 'select')[0].props.onChange({target:{value:'latest'}});
form = renderCreate(); await form.props.onSubmit({preventDefault:vi.fn(), currentTarget:{}});
form = renderCreate(); await form.props.onSubmit({preventDefault:vi.fn(), currentTarget:{}});
expect(request.mock.calls).toEqual([['/latest']]);
await form.props.onSubmit({preventDefault:vi.fn(), currentTarget:{}});
expect(request.mock.calls).toEqual([['/instances', 'POST', {name:'name', image_id:'ok', version:'1.2.3'}]]);
expect(refresh).toHaveBeenCalledOnce(); expect(close).toHaveBeenCalledOnce();
});
it('directs users to an administrator and disables creation when no verified image exists', async () => {
state.emptyImages = true;
const request = vi.spyOn(api, 'request');
vi.stubGlobal('FormData', class {get() {return 'exp';}});
const form = CreateInstance({close:vi.fn(), refresh:vi.fn(async () => {})});
expect(nodes(form, n => n.type === 'button' && n.props.children === '确认创建')[0].props.disabled).toBe(true);
expect(JSON.stringify(form)).toContain('请联系管理员');
await form.props.onSubmit({preventDefault:vi.fn(), currentTarget:{}});
expect(request).not.toHaveBeenCalled();
});
it('admission rejects blank/unconfirmed evidence, resets consent on edits, and submits once', async () => {
const request = vi.spyOn(api, 'admitImage').mockResolvedValue({});
+8 -25
View File
@@ -1,5 +1,5 @@
import { useState, type FormEvent } from 'react';
import { api, normalBuildAllowed, type AdmissionState, type Build } from './api';
import { useState } from 'react';
import { api, type AdmissionState, type Build } from './api';
import { Panel, ResourceState, Empty, Status, date, useAction, useResource } from './ui';
import { BuildProvenance, ReleasePanel } from './ReleasePanel';
export const activeBuild = (build: Build) => ['queued', 'running', 'validating'].includes(build.status);
@@ -25,7 +25,7 @@ function Admission({imageId, refresh}: {imageId:string; refresh:() => Promise<vo
<option value="blocked">禁止使用</option>
</select>
</label>
<p className="callout">构建成功不等于通过验收。请记录启动、登录、终端、文件和配置等实际兼容性验收结果及可追溯依据,不可仅填写“构建成功”。单镜像准入不扩大普通构建版本白名单;未知版本仅可选择已准入的本地镜像 ID 创建或升级。</p>
<p className="callout">构建成功不等于通过验收。请记录启动、登录、终端、文件和配置等实际兼容性验收结果及可追溯依据,不可仅填写“构建成功”。创建或升级须选择已准入的本地镜像 ID。</p>
<label>验收依据<textarea required aria-invalid={evidenceBytes > 4096} value={evidence} onChange={e => {setEvidence(e.target.value); setConfirmed(false);}}/>
</label>
<p className={evidenceBytes > 4096 ? 'alert' : 'muted'} role={evidenceBytes > 4096 ? 'alert' : undefined}>验收依据:{evidenceBytes} / 4096 UTF-8 字节(去除首尾空白后);中文通常占 3 字节,表情通常占 4 字节。{evidenceBytes > 4096 && '已超出字节上限,请缩短依据。'}</p>
@@ -35,33 +35,16 @@ function Admission({imageId, refresh}: {imageId:string; refresh:() => Promise<vo
</form>;
}
export function Images({admin = false}: {admin?:boolean}) {
const [experimental, setExperimental] = useState(false), [admissionId, setAdmissionId] = useState<string>();
const [admissionId, setAdmissionId] = useState<string>();
const [page, setPage] = useState(0);
const compatibility = useResource(api.compatibility);
const images = useResource(api.images), builds = useResource(() => api.builds(21, page * 20), String(page), 5000, rows => rows.slice(0, 20).some(activeBuild)); const action = useAction();
const [latest, setLatest] = useState<{version:string; checked_at?:string}>(), [version,setVersion] = useState(''), [selected,setSelected] = useState<string>();
function build(e: FormEvent) {e.preventDefault(); void action.run(async () => {if (!(admin && experimental) && !normalBuildAllowed(await api.compatibility(), version)) throw new Error('此版本不支持普通构建,请管理员选择实验构建;实验构建不会自动准入。'); await api.startBuild(version, admin && experimental); if (page === 0) await builds.refresh(); else setPage(0);}, '构建任务已提交,请查看任务的实际状态。', (experimental ? '实验构建不会自动准入或用于创建。确认实验构建精确版本 ' : '确认在本机手动构建精确版本 ') + version + '?构建会消耗磁盘、网络与 CPU,不会自动升级实例。');}
const [selected, setSelected] = useState<string>();
const rows = builds.data?.slice(0, 20);
function changePage(next: number) { setSelected(undefined); setPage(next); }
return <>{action.feedback}{admin && <ReleasePanel onBuilt={async () => {if (page === 0) await builds.refresh(); else changePage(0);}}/>}<Panel title="查询与手动构建">
<ResourceState resource={compatibility}/>{version && compatibility.data && !normalBuildAllowed(compatibility.data, version) && <p className="alert">此版本不支持普通构建。仅管理员可实验构建,不会自动准入。</p>}<div className="split">
<div>
<p className="muted">从 npm 查询 dist-tags.latest,仅查询,不自动构建。</p>
<button disabled={action.busy} onClick={() => action.run(async () => {const result = await api.request<{version:string; checked_at?:string}>('/latest'); setLatest(result); setVersion(result.version);}, '最新版本查询完成')}>查询 latest</button>{latest && <p>
<strong>{latest.version}</strong> <small>查询时间 {date(latest.checked_at)}</small>
</p>}</div>
<form className="build-form" onSubmit={build}>
<label>精确 DSH 版本<input required value={version} onChange={e => setVersion(e.target.value)} placeholder="查询 latest,或填写精确版本" pattern="[0-9]+\.[0-9]+\.[0-9]+.*" />
</label>
<>{admin && <label>
<input type="checkbox" checked={experimental} onChange={e => setExperimental(e.target.checked)}/>管理员实验构建(不自动准入)</label>}{experimental && <p className="alert" role="alert">实验镜像:构建成功后仍不能创建或升级实例,必须另行验收并显式准入。</p>}<button className="primary" disabled={action.busy || !version}>{experimental ? '提交实验构建' : '手动构建'}</button>
</>
</form>
</div>
<p className="callout">镜像仅保存在本机。构建成功不等于通过验收或自动准入。仅 available 且允许选择的镜像可创建或升级;existing_only 仅允许存量启停。blocked 禁止新建、升级及新的启动/重启,但不会自动停止已运行实例或断开连接。</p>
</Panel>
return <>{action.feedback}{admin && <ReleasePanel onBuilt={async () => {if (page === 0) await builds.refresh(); else changePage(0);}}/>}
<Panel title="本地镜像" action={<button disabled={images.loading} onClick={images.refresh}>刷新</button>}>
<ResourceState resource={images}/>{images.data?.length === 0 && <Empty>本机暂无托管镜像,请先手动构建。</Empty>}{!!images.data?.length && <div className="table-scroll">
<p className="callout">镜像仅保存在本机。仅已验证且允许选择的 available 镜像可创建或升级;existing_only 仅允许存量启停。blocked 禁止新建、升级及新的启动/重启,但不会自动停止已运行实例或断开连接。</p>
<ResourceState resource={images}/>{images.data?.length === 0 && <Empty>本机暂无托管镜像。请联系管理员构建并验证发布镜像。</Empty>}{!!images.data?.length && <div className="table-scroll">
<table>
<thead>
<tr>
+1 -1
View File
@@ -41,7 +41,7 @@ export function ReleasePanel({onBuilt}: {onBuilt: () => Promise<void>}) {
`确认手动构建源码发布 ${current.version}?\n仓库:${current.repository}\n分支:${current.branch}\n提交:${current.commit}\n部署协议:${current.recipe_rev}\n平台:${current.platforms.join(', ')}\n构建会消耗磁盘、网络与 CPU;不会自动升级实例,构建成功不等于通过验收。`);
}
return <Panel title="源码发布(管理员)" action={<button disabled={release.loading || action.busy} onClick={release.refresh}>刷新源码发布</button>}>
<p className="muted">进入页面时查询,此后每 5 分钟检查一次。仅检查,不自动构建或升级;与 npm latest / 实验构建相互独立。</p>
<p className="muted">进入页面时查询,此后每 5 分钟检查一次。仅检查,不自动构建或升级。新构建由管理员在此提交,创建或升级须另行选择已验证的镜像。</p>
<ResourceState resource={release}/>{associated && <ResourceState resource={task}/>} {action.feedback}
{current && <>
<h3>{current.version}</h3>
-5
View File
@@ -9,9 +9,6 @@ export interface Image { id: string; image_id?: string; version: string; tag?: s
export interface Build { id: string; version: string; status: string; experimental?: boolean; image_id?: string; logs?: string; error?: string; created_at: string; release_id?: string; source_commit?: string; source_repository?: string }
export interface Release { release_id: string; version: string; commit: string; repository: string; branch: string; recipe_rev: string; platforms: string[]; compatible: boolean; reason: string; build?: Build }
export const selectableImage = (image: Image) => image.status === 'available' && image.selectable === true;
export interface CompatibilityRule { state: string; build_allowed: boolean; reason: string }
export interface Compatibility { recipe_rev: string; platform: string; entries: (CompatibilityRule & {version:string; recipe_rev:string; platform:string})[]; unknown: CompatibilityRule; experimental_admin_only: boolean }
export const normalBuildAllowed = (policy: Compatibility, version: string) => policy.entries.some(entry => entry.version === version && entry.recipe_rev === policy.recipe_rev && entry.platform === policy.platform && entry.build_allowed === true);
export interface Entry { name: string; path?: string; is_dir: boolean; size: number; mod_time: string }
export interface Usage { used_bytes: number; quota_bytes: number }
export interface Backup { id: string; created_at: string; size: number }
@@ -84,10 +81,8 @@ export function createApi(fetcher: typeof fetch = fetch) {
async logout() { await request('/logout', 'POST'); csrf = ''; },
instances: () => request<Instance[]>('/instances'),
images: () => request<Image[]>('/images'),
compatibility: () => request<Compatibility>('/compatibility'),
latestRelease: () => request<Release>('/releases/latest'),
buildRelease: (commit: string) => request<Build>('/releases/build', 'POST', {commit}),
startBuild: (version: string, experimental = false) => request<Build>('/builds', 'POST', {version, experimental}),
admitImage: (image_id: string, state: AdmissionState, evidence: string) => request('/images/admission', 'POST', {image_id, state, evidence}),
builds: (limit = 20, offset = 0) => request<Build[]>(`/builds?limit=${limit}&offset=${offset}`),
build: (id: string) => request<Build>('/builds/' + encodeURIComponent(id)),
+2 -19
View File
@@ -1,30 +1,13 @@
import { expect, it, vi } from 'vitest';
import { createApi, normalBuildAllowed, selectableImage, type AdmissionState, type Compatibility } from './api';
import { createApi, selectableImage, type AdmissionState } from './api';
it('reads the compatibility contract and accepts only explicit current tuples', async () => {
const policy: Compatibility = {recipe_rev:'7', platform:'linux/amd64', entries:[{version:'1.2.3', recipe_rev:'7', platform:'linux/amd64', state:'recommended', build_allowed:true, reason:'tested'}], unknown:{state:'existing_only', build_allowed:false, reason:'unknown'}, experimental_admin_only:true};
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(new Response(JSON.stringify(policy)));
const result = await createApi(fetcher).compatibility();
expect(fetcher.mock.calls[0][0]).toBe('/api/compatibility');
expect(result).toEqual(policy);
expect(normalBuildAllowed(result, '1.2.3')).toBe(true);
expect(normalBuildAllowed(result, '9.9.9')).toBe(false);
expect(normalBuildAllowed({...result, recipe_rev:'8'}, '1.2.3')).toBe(false);
expect(normalBuildAllowed({...result, platform:'linux/arm64'}, '1.2.3')).toBe(false);
expect(normalBuildAllowed({...result, entries:result.entries.map(entry => ({...entry, build_allowed:false}))}, '1.2.3')).toBe(false);
});
it('sends exact experimental build and evidence-bearing admission contracts', async () => {
it('sends exact-image evidence-bearing admission contracts', async () => {
const fetcher = vi.fn<typeof fetch>().mockImplementation(async () => new Response('{}'));
const api = createApi(fetcher);
await api.startBuild('1.2.3');
await api.startBuild('9.9.9', true);
for (const state of ['recommended', 'compatible', 'existing_only', 'blocked'] as AdmissionState[]) {
await api.admitImage('sha256:a/b', state, '终端、文件与配置验收记录 #42');
}
expect(fetcher.mock.calls.map(([url, options]) => [url, options?.method, JSON.parse(options?.body as string)])).toEqual([
['/api/builds', 'POST', {version:'1.2.3', experimental:false}],
['/api/builds', 'POST', {version:'9.9.9', experimental:true}],
...['recommended', 'compatible', 'existing_only', 'blocked'].map(state => ['/api/images/admission', 'POST', {image_id:'sha256:a/b', state, evidence:'终端、文件与配置验收记录 #42'}]),
]);
});