Harden image admission, resource recovery and session renewal

Add exact-image compatibility policy, experimental admission, paginated build history and shared reauthentication checks. Preserve uncertain Docker creation records, bound frontend resource waits and renew runtime sessions proactively.

Lazy-load and split Monaco, add regression and opt-in live Docker fault tests, and record full race and real-browser acceptance results.
This commit is contained in:
2026-10-14 13:41:54 +08:00
parent 66abb76318
commit 9a71772771
62 changed files with 4679 additions and 332 deletions
+18
View File
@@ -13,6 +13,7 @@
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/node": "^26.6.5",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
@@ -1281,6 +1282,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "26.6.5",
"resolved": "https://registry.npmmirror.com/@types/node/-/node-26.6.5.tgz",
"integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.9.0"
}
},
"node_modules/@types/react": {
"version": "19.3.0",
"resolved": "https://registry.npmmirror.com/@types/react/-/react-19.3.0.tgz",
@@ -1900,6 +1911,13 @@
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "8.9.0",
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-8.9.0.tgz",
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
"dev": true,
"license": "MIT"
},
"node_modules/update-browserslist-db": {
"version": "1.3.4",
"resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz",
+1
View File
@@ -15,6 +15,7 @@
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/node": "^26.6.5",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
+9 -5
View File
@@ -1,8 +1,9 @@
import { useEffect, useRef, useState, type FormEvent } from 'react';
import { api, type Image, type Instance } from './api';
import { api, normalBuildAllowed, selectableImage, type Image, type Instance } from './api';
import { message, 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('');
@@ -11,7 +12,7 @@ export function CreateInstance({close, refresh}: {close: () => void; refresh: ()
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 active = useRef(true);
useEffect(() => {active.current = true; return () => {active.current = false;};}, []);
const available = images.data?.filter(i => !i.status || i.status === 'available') || [];
const available = images.data?.filter(selectableImage) || [];
async function submit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const form = new FormData(e.currentTarget), name = String(form.get('name'));
@@ -19,10 +20,13 @@ export function CreateInstance({close, refresh}: {close: () => void; refresh: ()
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}>('/latest');
const latest = await api.request<{version:string; build_allowed:boolean}>('/latest');
if (!latest.version) throw new Error('服务器未返回精确版本。');
setVersion(latest.version); return;
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'));
@@ -37,5 +41,5 @@ 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' && version && <p>将锁定精确版本 <strong>{version}</strong>,确认后预留名额并构建创建。</p>}<button className="primary" disabled={mode === 'local' && !available.length}>{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">确认后服务器持久预留一个名额(每用户最多两个)。刷新或关闭不会取消自动创建;失败释放名额,不回退其他版本。取消等待不会取消共享构建,创建开始后不可撤回。</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>;
}
+127
View File
@@ -0,0 +1,127 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
const state = vi.hoisted(() => ({slots: [] as any[], cursor: 0}));
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;}];},
useRef(value: unknown) {const i = state.cursor++; return state.slots[i] ??= {current:value};},
useEffect() {},
}));
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 () => {})}),
}));
import { api, type Compatibility } from './api';
import { Images } from './Images';
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)});});
afterEach(() => {vi.restoreAllMocks(); vi.unstubAllGlobals();});
it('hides experimental and admission controls from ordinary users', () => {
const tree = render();
expect(nodes(tree, n => n.type === 'input' && n.props.type === 'checkbox')).toHaveLength(0);
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('requires evidence and confirmation; cancel sends no admission mutation', () => {
const request = vi.spyOn(api, 'admitImage');
nodes(render(true), n => n.type === 'button' && n.props.children === '准入管理')[0].props.onClick();
const child = nodes(render(true), n => typeof n.type === 'function' && n.props.imageId)[0];
state.slots = []; state.cursor = 0;
let form = child.type(child.props);
expect(nodes(form, n => n.type === 'button')[0].props.disabled).toBe(true);
nodes(form, n => n.type === 'textarea')[0].props.onChange({target:{value:'实际验收记录'}});
nodes(form, n => n.type === 'input')[0].props.onChange({target:{checked:true}});
state.cursor = 0; form = child.type(child.props);
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();
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']]);
});
it('admission rejects blank/unconfirmed evidence, resets consent on edits, and submits once', async () => {
const request = vi.spyOn(api, 'admitImage').mockResolvedValue({});
nodes(render(true), n => n.type === 'button' && n.props.children === '准入管理')[0].props.onClick();
const child = nodes(render(true), n => typeof n.type === 'function' && n.props.imageId)[0];
state.slots = [];
const renderAdmission = () => {state.cursor = 0; return child.type(child.props);};
const submit = () => renderAdmission().props.onSubmit({preventDefault:vi.fn()});
submit(); expect(request).not.toHaveBeenCalled();
nodes(renderAdmission(), n => n.type === 'input')[0].props.onChange({target:{checked:true}});
submit(); expect(request).not.toHaveBeenCalled();
nodes(renderAdmission(), n => n.type === 'textarea')[0].props.onChange({target:{value:'验收记录 #42'}});
expect(nodes(renderAdmission(), n => n.type === 'input')[0].props.checked).toBe(false);
submit(); expect(request).not.toHaveBeenCalled();
nodes(renderAdmission(), n => n.type === 'input')[0].props.onChange({target:{checked:true}});
nodes(renderAdmission(), n => n.type === 'select')[0].props.onChange({target:{value:'recommended'}});
expect(nodes(renderAdmission(), n => n.type === 'input')[0].props.checked).toBe(false);
nodes(renderAdmission(), n => n.type === 'input')[0].props.onChange({target:{checked:true}});
vi.mocked(window.confirm).mockReturnValue(true);
submit(); submit();
await Promise.resolve();
expect(request).toHaveBeenCalledExactlyOnceWith('ok', 'recommended', '验收记录 #42');
});
it.each([
['a'.repeat(4096), 4096, true],
['a'.repeat(4097), 4097, false],
['中'.repeat(1400), 4200, false],
['中'.repeat(1365), 4095, true],
['中'.repeat(1365) + 'a', 4096, true],
['中'.repeat(1365) + 'ab', 4097, false],
['😀'.repeat(1024), 4096, true],
['😀'.repeat(1024) + 'a', 4097, false],
[' ' + 'é'.repeat(2048) + '\n', 4096, true],
] as const)('checks UTF-8 evidence bytes (case %#)', async (evidence, bytes, valid) => {
const request = vi.spyOn(api, 'admitImage').mockResolvedValue({});
vi.mocked(window.confirm).mockReturnValue(true);
nodes(render(true), n => n.type === 'button' && n.props.children === '准入管理')[0].props.onClick();
const child = nodes(render(true), n => typeof n.type === 'function' && n.props.imageId)[0];
state.slots = [];
const renderAdmission = () => {state.cursor = 0; return child.type(child.props);};
nodes(renderAdmission(), n => n.type === 'textarea')[0].props.onChange({target:{value:evidence}});
nodes(renderAdmission(), n => n.type === 'input')[0].props.onChange({target:{checked:true}});
const form = renderAdmission();
expect(nodes(form, n => n.type === 'textarea')[0].props.maxLength).toBeUndefined();
expect(nodes(form, n => n.type === 'textarea')[0].props['aria-invalid']).toBe(!valid);
expect(nodes(form, n => n.type === 'p' && Array.isArray(n.props.children) && n.props.children.includes(bytes))).toHaveLength(1);
expect(nodes(form, n => n.type === 'button')[0].props.disabled).toBe(!valid);
form.props.onSubmit({preventDefault:vi.fn()});
await Promise.resolve();
if (valid) expect(request).toHaveBeenCalledExactlyOnceWith('ok', 'compatible', evidence.trim());
else {expect(request).not.toHaveBeenCalled(); expect(window.confirm).not.toHaveBeenCalled();}
});
it('upgrade selector excludes experimental and unadmitted images', () => {
state.cursor = 0; Instances();
state.slots = [];
// Instances keeps the selected detail after its action state and creating flag.
state.slots[5] = {id:'instance', name:'test', version:'1', status:'stopped'};
state.cursor = 0;
const detail = nodes(Instances(), n => typeof n.type === 'function' && n.props.instance)[0];
state.slots = ['upgrade']; state.cursor = 0;
const tree = detail.type(detail.props);
expect(nodes(tree, n => n.type === 'option' && n.props.value === 'ok')).toHaveLength(1);
expect(nodes(tree, n => n.type === 'option' && n.props.value === 'exp')).toHaveLength(0);
});
+118 -5
View File
@@ -1,10 +1,123 @@
import { useState, type FormEvent } from 'react';
import { api } from './api';
import { api, normalBuildAllowed, type AdmissionState, type Build } from './api';
import { Panel, ResourceState, Empty, Status, date, useAction, useResource } from './ui';
export const activeBuild = (build: Build) => ['queued', 'running', 'validating'].includes(build.status);
function BuildLogs({id, close}: {id: string; close: () => void}) {
const logs = useResource(() => api.build(id), id, 5000, activeBuild);
return <div>
<h3>构建日志</h3>
<button onClick={close}>关闭日志</button>
<ResourceState resource={logs}/>{logs.data && <>
<p>{logs.data.experimental && <strong className="alert">实验构建 · 不自动准入</strong>}{logs.data.version} · <Status value={logs.data.status}/>
</p>{logs.data.error && <p className="alert">{logs.data.error}</p>}<pre className="logs">{logs.data.logs || '任务尚未输出日志'}</pre>
</>}</div>;
}
function Admission({imageId, refresh}: {imageId:string; refresh:() => Promise<void>}) {
const [state, setState] = useState<AdmissionState>('compatible'), [evidence, setEvidence] = useState(''), [confirmed, setConfirmed] = useState(false);
const action = useAction();
const submittedEvidence = evidence.trim(), evidenceBytes = new TextEncoder().encode(submittedEvidence).length;
const evidenceInvalid = evidenceBytes === 0 || evidenceBytes > 4096;
return <form onSubmit={e => {e.preventDefault(); if (!confirmed || evidenceInvalid) return; void action.run(async () => {await api.admitImage(imageId, state, submittedEvidence); setEvidence(''); setConfirmed(false); await refresh();}, '镜像准入策略已更新', '确认依据实际验收结果修改此镜像准入策略?');}}>{action.feedback}<label>准入策略<select value={state} onChange={e => {setState(e.target.value as AdmissionState); setConfirmed(false);}}>
<option value="recommended">推荐</option>
<option value="compatible">兼容</option>
<option value="existing_only">仅存量</option>
<option value="blocked">禁止使用</option>
</select>
</label>
<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>
<label>
<input type="checkbox" checked={confirmed} onChange={e => setConfirmed(e.target.checked)}/>我已核验依据;禁止使用还会阻止启动/重启,但不自动停机</label>
<button disabled={action.busy || !confirmed || evidenceInvalid}>确认更新准入</button>
</form>;
}
export function Images({admin = false}: {admin?:boolean}) {
const images = useResource(api.images), builds = useResource(api.builds, '', 5000); const action = useAction();
const [experimental, setExperimental] = useState(false), [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 () => {await api.request('/builds','POST',{version}); await builds.refresh();}, '构建任务已提交,请查看任务的实际状态。', '确认在本机手动构建精确版本 ' + version + '?构建会消耗磁盘、网络与 CPU,不会自动升级实例。');}
const current = builds.data?.find(b => b.id === selected);
return <>{action.feedback}<Panel title="查询与手动构建"><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><button className="primary" disabled={action.busy || !version}>手动构建</button></form></div><p className="callout">镜像仅保存在本机。构建成功不等于实例升级;创建或升级前请检查可用状态。</p></Panel><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"><table><thead><tr><th>DSH 版本</th><th>镜像标识</th><th>标签</th><th>状态</th><th>使用/操作</th></tr></thead><tbody>{images.data.map(i => <tr key={i.image_id || i.id}><td><strong>{i.version}</strong></td><td className="mono wrap">{i.image_id || i.id}</td><td>{i.tag || '—'}</td><td><Status value={i.status}/></td><td>{i.usage_count ?? 0} 个实例 {admin && <button className="danger" disabled={action.busy || !!i.usage_count} onClick={() => action.run(async () => {await api.request('/images?image_id=' + encodeURIComponent(i.image_id || i.id), 'DELETE'); await images.refresh();}, '镜像已删除', '确认删除这个本地镜像?服务器将检查所有引用,不会强制删除。')}>清理镜像</button>}</td></tr>)}</tbody></table></div>}</Panel><Panel title="构建任务" action={<span className="muted">每 5 秒自动刷新</span>}><ResourceState resource={builds}/>{builds.data?.length === 0 && <Empty>没有构建任务记录</Empty>}{!!builds.data?.length && <div className="table-scroll"><table><thead><tr><th>任务</th><th>版本</th><th>状态</th><th>提交时间</th><th>日志</th></tr></thead><tbody>{builds.data.map(b => <tr key={b.id}><td className="mono">{b.id}</td><td>{b.version}</td><td><Status value={b.status}/></td><td>{date(b.created_at)}</td><td><button onClick={() => setSelected(b.id)}>查看日志</button></td></tr>)}</tbody></table></div>}{current && <div><h3>{current.version} · 构建日志</h3>{current.error && <p className="alert">{current.error}</p>}<pre className="logs">{current.logs || '任务尚未输出日志'}</pre></div>}</Panel></>;
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 rows = builds.data?.slice(0, 20);
function changePage(next: number) { setSelected(undefined); setPage(next); }
return <>{action.feedback}<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>
<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">
<table>
<thead>
<tr>
<th>DSH 版本</th>
<th>镜像标识</th>
<th>标签</th>
<th>状态</th>
<th>使用/操作</th>
</tr>
</thead>
<tbody>{images.data.map(i => <tr key={i.image_id || i.id}>
<td>
<strong>{i.version}</strong>
</td>
<td className="mono wrap">{i.image_id || i.id}</td>
<td>{i.tag || '—'}</td>
<td>
<Status value={i.status}/>
<p>{({recommended:'推荐',compatible:'兼容',existing_only:'仅存量',blocked:'禁止使用'} as Record<string,string>)[i.admission || ''] || '未验证'}</p>
<small>{i.admission_reason || '无准入依据'}</small>
<p>{i.selectable && i.status === 'available' ? '可创建 / 升级' : '不可创建 / 升级'}</p>
</td>
<td>{i.usage_count ?? 0} 个实例 {admin && <button onClick={() => setAdmissionId(admissionId === (i.image_id || i.id) ? undefined : (i.image_id || i.id))}>准入管理</button>}{admissionId === (i.image_id || i.id) && admin && <Admission key={admissionId} imageId={admissionId} refresh={images.refresh}/>} {admin && <button className="danger" disabled={action.busy || !!i.usage_count} onClick={() => action.run(async () => {await api.request('/images?image_id=' + encodeURIComponent(i.image_id || i.id), 'DELETE'); await images.refresh();}, '镜像已删除', '确认删除这个本地镜像?服务器将检查所有引用,不会强制删除。')}>清理镜像</button>}</td>
</tr>)}</tbody>
</table>
</div>}</Panel>
<Panel title="构建任务" action={<>
<span className="muted">当前页有活跃任务时每 5 秒刷新</span>
<button disabled={builds.loading} onClick={builds.refresh}>刷新任务</button>
</>}>
<ResourceState resource={builds}/>{builds.data?.length === 0 && <Empty>没有构建任务记录</Empty>}{!!builds.data?.length && <div className="table-scroll">
<table>
<thead>
<tr>
<th>任务</th>
<th>版本</th>
<th>状态</th>
<th>提交时间</th>
<th>日志</th>
</tr>
</thead>
<tbody>{rows!.map(b => <tr key={b.id}>
<td className="mono">{b.id}</td>
<td>{b.version}{b.experimental && <strong className="alert">实验构建 · 不自动准入</strong>}</td>
<td>
<Status value={b.status}/>
</td>
<td>{date(b.created_at)}</td>
<td>
<button onClick={() => setSelected(b.id)}>查看日志</button>
</td>
</tr>)}</tbody>
</table>
</div>}<div className="actions">
<button disabled={page === 0 || builds.loading} onClick={() => changePage(page - 1)}>上一页</button>
<span>第 {page + 1} 页</span>
<button disabled={builds.loading || !builds.data || builds.data.length <= 20} onClick={() => changePage(page + 1)}>下一页</button>
</div>{selected && <BuildLogs key={selected} id={selected} close={() => setSelected(undefined)}/>}</Panel>
</>;
}
+3 -3
View File
@@ -2,7 +2,7 @@ import { useState } from 'react';
import { ProfileEditor } from './ProfileEditor';
import { BackupPanel } from './BackupPanel';
import { CreateInstance } from './CreateInstance';
import { api, instancePath, type Instance } from './api';
import { api, selectableImage, instancePath, type Instance } from './api';
import { Panel, ResourceState, Empty, Status, bytes, date, useAction, useResource } from './ui';
export function Instances() {
const instances = useResource(api.instances, '', 10000), usage = useResource(api.usage);
@@ -15,11 +15,11 @@ function InstanceDetail({instance, close, refresh}: {instance: Instance; close:
const base = instancePath(instance.id);
const stopped = ['stopped', 'exited', 'created'].includes(instance.status);
const images = useResource(api.images);
const available = images.data?.filter(i => !i.status || i.status === 'available') || [];
const available = images.data?.filter(selectableImage) || [];
const [upgradeImage, setUpgradeImage] = useState('');
const [upgradePassword, setUpgradePassword] = useState('');
const profile = useResource(() => api.request<{content:string}>(base + '/profile'));
const preview = useResource(() => api.request<{bundles:unknown;config:string;preview_error?:string}>(base + '/profile-preview'));
const logs = useResource(() => api.request<{logs:string}>(base + '/logs'));
return <Panel title={instance.name + ' · 实例管理'} action={<button onClick={close}>关闭</button>}><div className="tabs">{[['profile','Profile 配置'],['preview','配置预览'],['backups','备份与恢复'],['upgrade','版本升级'],['logs','运行日志']].map(([id,label]) => <button className={tab === id ? 'active' : ''} onClick={() => setTab(id)} key={id}>{label}</button>)}</div>{action.feedback}{tab === 'profile' && <div className="callout">Profile 写入前需先停止实例。当前状态:<Status value={instance.status}/>{!stopped && <button disabled={action.busy} onClick={() => action.run(async () => {await api.request(base + '/stop','POST'); await refresh();}, '停止请求已完成,请确认实例状态。', '停止将中断当前连接,是否继续?')}>停止实例</button>}</div>}{tab === 'profile' && <><p className="muted">profiles/web/cordis.patch.yml · 停止实例后保存,保存后自动启动验证。密钥原文不在此展示。</p><ResourceState resource={profile} />{profile.data && <><label>YAML 配置<ProfileEditor value={content ?? profile.data.content} onChange={setContent} readOnly={action.busy}/></label><div className="actions"><button className="primary" disabled={action.busy || !stopped || content === undefined} onClick={() => action.run(async () => {await api.request(base + '/profile','PUT',{content,restart:true}); await profile.refresh(); setContent(undefined); await refresh();}, '配置已保存并启动验证。', '确认覆盖当前 Profile 配置?')}>保存并启动</button><button disabled={action.busy} onClick={() => action.run(async () => {await api.request(base + '/restart','POST'); await refresh();}, '实例已重启', '重启将中断当前连接,是否继续?')}>重启实例</button></div></>}</>}{tab === 'preview' && <><button onClick={preview.refresh}>刷新预览</button><ResourceState resource={preview}/>{preview.data && <><h3>Bundle 清单</h3><pre>{JSON.stringify(preview.data.bundles,null,2)}</pre>{preview.data.preview_error && <p role="status">{preview.data.preview_error}</p>}<ProfileEditor value={preview.data.config} readOnly label="配置预览(字符串已脱敏)"/></>}</>}{tab === 'backups' && <BackupPanel instance={instance} refresh={async () => {await profile.refresh(); setContent(undefined); await refresh();}}/>}{tab === 'upgrade' && <form className="inline-form" onSubmit={e => {e.preventDefault(); if (!available.some(i => (i.image_id || i.id) === upgradeImage)) return; void action.run(async () => {try {await api.request(base + '/upgrade','POST',{image_id:upgradeImage,password:upgradePassword}); await refresh();} finally {setUpgradePassword('');}}, '实例升级请求已完成。', '确认使用所选本地镜像升级?建议先备份配置。');}}><p>当前版本:{instance.version}</p><label>当前登录密码(加密升级前备份)<input type="password" required autoComplete="current-password" value={upgradePassword} onChange={e => setUpgradePassword(e.target.value)}/></label><ResourceState resource={images}/><label>本地镜像<select required value={upgradeImage} onChange={e => setUpgradeImage(e.target.value)}><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><button className="primary" disabled={action.busy || !upgradeImage}>升级实例</button></form>}{tab === 'logs' && <><button onClick={logs.refresh} disabled={logs.loading}>刷新日志</button><ResourceState resource={logs}/><pre className="logs">{logs.data?.logs || '暂无日志'}</pre></>}</Panel>;
return <Panel title={instance.name + ' · 实例管理'} action={<button onClick={close}>关闭</button>}><div className="tabs">{[['profile','Profile 配置'],['preview','配置预览'],['backups','备份与恢复'],['upgrade','版本升级'],['logs','运行日志']].map(([id,label]) => <button className={tab === id ? 'active' : ''} onClick={() => setTab(id)} key={id}>{label}</button>)}</div>{action.feedback}{tab === 'profile' && <div className="callout">Profile 写入前需先停止实例。当前状态:<Status value={instance.status}/>{!stopped && <button disabled={action.busy} onClick={() => action.run(async () => {await api.request(base + '/stop','POST'); await refresh();}, '停止请求已完成,请确认实例状态。', '停止将中断当前连接,是否继续?')}>停止实例</button>}</div>}{tab === 'profile' && <><p className="muted">profiles/web/cordis.patch.yml · 停止实例后保存,保存后自动启动验证。密钥原文不在此展示。</p><ResourceState resource={profile} />{profile.data && <><label>YAML 配置<ProfileEditor value={content ?? profile.data.content} onChange={setContent} readOnly={action.busy}/></label><div className="actions"><button className="primary" disabled={action.busy || !stopped || content === undefined} onClick={() => action.run(async () => {await api.request(base + '/profile','PUT',{content,restart:true}); await profile.refresh(); setContent(undefined); await refresh();}, '配置已保存并启动验证。', '确认覆盖当前 Profile 配置?')}>保存并启动</button><button disabled={action.busy} onClick={() => action.run(async () => {await api.request(base + '/restart','POST'); await refresh();}, '实例已重启', '重启将中断当前连接,是否继续?')}>重启实例</button></div></>}</>}{tab === 'preview' && <><button onClick={preview.refresh}>刷新预览</button><ResourceState resource={preview}/>{preview.data && <><h3>Bundle 清单</h3><pre>{JSON.stringify(preview.data.bundles,null,2)}</pre>{preview.data.preview_error && <p role="status">{preview.data.preview_error}</p>}<ProfileEditor value={preview.data.config} readOnly label="配置预览(字符串已脱敏)"/></>}</>}{tab === 'backups' && <BackupPanel instance={instance} refresh={async () => {await profile.refresh(); setContent(undefined); await refresh();}}/>}{tab === 'upgrade' && <form className="inline-form" onSubmit={e => {e.preventDefault(); if (!available.some(i => (i.image_id || i.id) === upgradeImage)) return; void action.run(async () => {try {await api.request(base + '/upgrade','POST',{image_id:upgradeImage,password:upgradePassword}); await refresh();} finally {setUpgradePassword('');}}, '实例升级请求已完成。', '确认使用所选本地镜像升级?建议先备份配置。');}}><p>当前版本:{instance.version}</p><p className="callout">仅已准入且本地可用的镜像可用于升级。历史镜像仅允许存量实例运行,不可用于新建或升级。</p><label>当前登录密码(加密升级前备份)<input type="password" required autoComplete="current-password" value={upgradePassword} onChange={e => setUpgradePassword(e.target.value)}/></label><ResourceState resource={images}/><label>本地镜像<select required value={upgradeImage} onChange={e => setUpgradeImage(e.target.value)}><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><button className="primary" disabled={action.busy || !upgradeImage}>升级实例</button></form>}{tab === 'logs' && <><button onClick={logs.refresh} disabled={logs.loading}>刷新日志</button><ResourceState resource={logs}/><pre className="logs">{logs.data?.logs || '暂无日志'}</pre></>}</Panel>;
}
+48
View File
@@ -0,0 +1,48 @@
/// <reference types="vite/client" />
import { useEffect, useRef } from 'react';
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import 'monaco-editor/esm/vs/basic-languages/yaml/yaml.contribution';
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
// Vite emits this worker locally; no AMD loader or CDN is involved.
self.MonacoEnvironment = { getWorker: () => new EditorWorker() };
export interface ProfileEditorProps {
value: string;
onChange?: (value: string) => void;
readOnly?: boolean;
label?: string;
}
export default function MonacoProfileEditor({ value, onChange, readOnly = false, label = 'YAML 配置' }: ProfileEditorProps) {
const host = useRef<HTMLDivElement>(null);
const editor = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
const change = useRef(onChange);
change.current = onChange;
const initialValue = useRef(value);
const updating = useRef(false);
useEffect(() => {
if (!host.current) return;
const model = monaco.editor.createModel(initialValue.current, 'yaml');
const instance = monaco.editor.create(host.current, {
model, automaticLayout: true, minimap: { enabled: false },
scrollBeyondLastLine: false, tabSize: 2, insertSpaces: true,
wordWrap: 'on', ariaLabel: label, readOnly,
});
editor.current = instance;
const listener = instance.onDidChangeModelContent(() => {
if (!updating.current) change.current?.(instance.getValue());
});
return () => { listener.dispose(); instance.dispose(); model.dispose(); editor.current = null; };
}, []);
useEffect(() => {
const instance = editor.current;
if (instance && instance.getValue() !== value) {
updating.current = true;
instance.setValue(value);
updating.current = false;
}
}, [value]);
useEffect(() => { editor.current?.updateOptions({ readOnly, ariaLabel: label }); }, [readOnly, label]);
return <div role="group" aria-label={label} ref={host} style={{ height: 420, minWidth: 0, border: '1px solid var(--border, #d5dbe5)', borderRadius: 8, overflow: 'hidden' }} />;
}
+17
View File
@@ -0,0 +1,17 @@
import { afterEach, expect, it, vi } from 'vitest';
import { renderToStaticMarkup } from 'react-dom/server';
vi.mock('monaco-editor/esm/vs/editor/editor.api', () => ({editor: {}}));
vi.mock('monaco-editor/esm/vs/basic-languages/yaml/yaml.contribution', () => ({}));
vi.mock('monaco-editor/esm/vs/editor/editor.worker?worker', () => ({default: class {}}));
afterEach(() => vi.unstubAllGlobals());
it('distinguishes mandatory profile-page stopping from unrestricted general file management', async () => {
vi.stubGlobal('self', {});
const { ProfileEditor } = await import('./ProfileEditor');
const markup = renderToStaticMarkup(<ProfileEditor value="name: test" label="测试配置" />);
expect(markup).toContain('本页面保存必须先停止实例');
expect(markup).toContain('通用文件管理不受此流程强制约束');
expect(markup).toContain('不承诺凭据仅写入');
expect(markup).not.toContain('这里不强制停机');
expect(markup).toContain('role="group" aria-label="测试配置"');
});
+7 -45
View File
@@ -1,48 +1,10 @@
/// <reference types="vite/client" />
import { useEffect, useRef } from 'react';
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import 'monaco-editor/esm/vs/basic-languages/yaml/yaml.contribution';
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
import { lazy, Suspense } from 'react';
import { homeAccessNotice } from './fileWarnings';
import type { ProfileEditorProps } from './MonacoProfileEditor';
// Vite emits this worker locally; no AMD loader or CDN is involved.
self.MonacoEnvironment = { getWorker: () => new EditorWorker() };
export type { ProfileEditorProps } from './MonacoProfileEditor';
const MonacoProfileEditor = lazy(() => import('./MonacoProfileEditor'));
export interface ProfileEditorProps {
value: string;
onChange?: (value: string) => void;
readOnly?: boolean;
label?: string;
}
export function ProfileEditor({ value, onChange, readOnly = false, label = 'YAML 配置' }: ProfileEditorProps) {
const host = useRef<HTMLDivElement>(null);
const editor = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
const change = useRef(onChange);
change.current = onChange;
const initialValue = useRef(value);
const updating = useRef(false);
useEffect(() => {
if (!host.current) return;
const model = monaco.editor.createModel(initialValue.current, 'yaml');
const instance = monaco.editor.create(host.current, {
model, automaticLayout: true, minimap: { enabled: false },
scrollBeyondLastLine: false, tabSize: 2, insertSpaces: true,
wordWrap: 'on', ariaLabel: label, readOnly,
});
editor.current = instance;
const listener = instance.onDidChangeModelContent(() => {
if (!updating.current) change.current?.(instance.getValue());
});
return () => { listener.dispose(); instance.dispose(); model.dispose(); editor.current = null; };
}, []);
useEffect(() => {
const instance = editor.current;
if (instance && instance.getValue() !== value) {
updating.current = true;
instance.setValue(value);
updating.current = false;
}
}, [value]);
useEffect(() => { editor.current?.updateOptions({ readOnly, ariaLabel: label }); }, [readOnly, label]);
return <div role="group" aria-label={label} ref={host} style={{ height: 420, minWidth: 0, border: '1px solid var(--border, #d5dbe5)', borderRadius: 8, overflow: 'hidden' }} />;
export function ProfileEditor(props: ProfileEditorProps) {
return <><p className="muted">{homeAccessNotice} 本页面保存必须先停止实例,建议修改前备份;通用文件管理不受此流程强制约束。</p><Suspense fallback={<div role="group" aria-label={props.label ?? 'YAML 配置'} style={{ height: 420 }}><p role="status">正在加载编辑器…</p></div>}><MonacoProfileEditor {...props}/></Suspense></>;
}
+84
View File
@@ -0,0 +1,84 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
const state = vi.hoisted(() => ({slots: [] as any[], cursor: 0, entries: [] as any[]}));
vi.mock('react', async importOriginal => ({
...await importOriginal<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;}]; },
useRef(value: unknown) {const i = state.cursor++; return state.slots[i] ??= {current: value};},
}));
vi.mock('./ui', async importOriginal => ({
...await importOriginal<typeof import('./ui')>(),
useResource: () => ({data: state.entries, refresh: vi.fn(async () => {}), loading: false, error: ''}),
}));
vi.mock('./api', () => ({api: {download: vi.fn(() => '/download-test'), upload: vi.fn(async () => {}), rename: vi.fn(async () => {}), delete: vi.fn(async () => {})}}));
import { Workspace } from './Workspace';
import { api } from './api';
function render() {
state.cursor = 0;
const scope = Workspace({runtime: {instances: [{id: 'one', name: 'Running instance', status: 'running'}], selectedId: 'one'} as any}) as any;
const files = scope.type(scope.props);
return files.type(files.props);
}
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)];
}
beforeEach(() => {
state.slots = []; state.cursor = 0; state.entries = [{name: '.credentials.yaml', is_dir: false}];
vi.clearAllMocks(); vi.stubGlobal('window', {confirm: vi.fn(() => false)});
});
afterEach(() => vi.unstubAllGlobals());
it.each([false, true])('cancel blocks download navigation and acceptance preserves owner access (directory=%s)', is_dir => {
state.entries = [{name: is_dir ? 'photos' : 'renamed.txt', is_dir}];
const link = nodes(render(), n => n.type === 'a')[0];
for (const handler of ['onClick', 'onAuxClick', 'onContextMenu']) {
const event = {preventDefault: vi.fn(), button: 1};
link.props[handler](event);
expect(event.preventDefault).toHaveBeenCalledOnce();
vi.mocked(window.confirm).mockReturnValueOnce(true);
const accepted = {preventDefault: vi.fn(), button: 1};
link.props[handler](accepted);
expect(accepted.preventDefault).not.toHaveBeenCalled();
}
expect(link.props.href).toBe('/download-test');
expect(window.confirm).toHaveBeenCalledTimes(6);
expect(window.confirm).toHaveBeenCalledWith(expect.stringContaining(is_dir ? 'ZIP' : '凭据'));
});
it('does not double-prompt auxiliary right-click after the context menu', () => {
nodes(render(), n => n.type === 'a')[0].props.onAuxClick({button: 2, preventDefault: vi.fn()});
expect(window.confirm).not.toHaveBeenCalled();
});
it('canceled upload sends no request, resets input, and accepted upload needs no stop', async () => {
const input = nodes(render(), n => n.type === 'input' && n.props.type === 'file')[0];
const file = {name: '.credentials.yaml'};
const event = {target: {files: [file], value: 'chosen'}};
input.props.onChange(event);
expect(api.upload).not.toHaveBeenCalled();
expect(event.target.value).toBe('');
vi.mocked(window.confirm).mockReturnValue(true);
input.props.onChange(event);
await Promise.resolve();
expect(api.upload).toHaveBeenCalledExactlyOnceWith('.', file, 'one');
expect(window.confirm).toHaveBeenCalledWith(expect.stringContaining('备份'));
});
it('canceled delete sends no request; accepted delete retains full-home access', async () => {
const button = nodes(render(), n => n.type === 'button' && n.props.children === '删除')[0];
await button.props.onClick();
expect(api.delete).not.toHaveBeenCalled();
vi.mocked(window.confirm).mockReturnValue(true);
await button.props.onClick();
expect(api.delete).toHaveBeenCalledExactlyOnceWith('.credentials.yaml', 'one');
});
it('rename cancellation preserves the draft without writing; accepting writes once', async () => {
nodes(render(), n => n.type === 'button' && n.props.children === '重命名')[0].props.onClick();
const form = nodes(render(), n => n.type === 'form')[1];
form.props.onSubmit({preventDefault: vi.fn()});
expect(api.rename).not.toHaveBeenCalled();
expect(nodes(render(), n => n.type === 'form')).toHaveLength(2);
vi.mocked(window.confirm).mockReturnValue(true);
form.props.onSubmit({preventDefault: vi.fn()});
await Promise.resolve();
expect(api.rename).toHaveBeenCalledExactlyOnceWith('.credentials.yaml', '.credentials.yaml', 'one');
});
+2 -1
View File
@@ -1,5 +1,6 @@
import { useState, type FormEvent, type ReactNode } from 'react';
import { api } from './api';
import { confirmDownload, downloadWarning, homeAccessNotice, mutationWarning } from './fileWarnings';
import { Alert, Panel, ResourceState, Empty, bytes, date, useAction, useResource } from './ui';
import { RuntimeSelector } from './RuntimeSelector';
import type { useRuntimeInstances } from './RuntimeWorkspace';
@@ -29,5 +30,5 @@ function WorkspaceFiles({instanceId, instanceName, controls, path, setPath}: Fil
const join = (name: string) => joinFilePath(path, name);
const refresh = async () => {await Promise.all([tree.refresh(), usage.refresh()]);};
function mkdir(e: FormEvent) {e.preventDefault(); void action.run(async () => {await api.mkdir(join(newName), instanceId); setNewName(''); await refresh();}, '目录已创建');}
return <>{controls(action.busy)}<div className="workspace-banner"><div><p className="eyebrow">INSTANCE FILES</p><h2>实例文件</h2><p>{'当前实例:' + instanceName}</p><p>实例数据目录为 /workspace(模板 7);历史镜像的路径可能不同。</p><p>这里管理文件,不会切换 DSH 会话的工作目录。包含配置和密钥,请谨慎操作。</p></div><div><p>账号总用量</p><strong>{bytes(usage.data?.used_bytes)}</strong><p>账号配额:{bytes(usage.data?.quota_bytes)}</p></div></div><ResourceState resource={usage}/>{action.feedback}<Panel title="文件浏览器" action={<button onClick={refresh} disabled={tree.loading || action.busy}>刷新</button>}><p className="muted">当前完整路径:<code>{displayFilePath('instance', path)}</code></p><div className="file-toolbar"><div className="breadcrumbs"><button onClick={() => go('.')} disabled={path === '.' || action.busy}>{instanceId ? '实例文件' : '历史共享目录'}</button>{path !== '.' && path.split('/').map((part,index,parts) => <span key={index}> / <button disabled={action.busy} onClick={() => go(parts.slice(0,index+1).join('/'))}>{part}</button></span>)}</div><label className={'button upload ' + (action.busy ? 'disabled' : '')}>↑ 上传文件<input type="file" disabled={action.busy} onChange={e => {const file = e.target.files?.[0]; if(file) void action.run(async () => {await api.upload(path,file,instanceId); await refresh();}, '文件已上传'); e.target.value = '';}}/></label></div><form className="compact-form" onSubmit={mkdir}><label className="sr-only" htmlFor="new-folder">新目录名称</label><input disabled={action.busy} id="new-folder" value={newName} onChange={e => setNewName(e.target.value)} placeholder="新目录名称" required pattern="[^/\\]+"/><button disabled={action.busy}>创建目录</button></form>{rename && <form className="compact-form" onSubmit={e => {e.preventDefault(); void action.run(async () => {await api.rename(rename.path,join(rename.name),instanceId); setRename(undefined); await refresh();}, '重命名已完成', '确认重命名此文件或目录?');}}><label>新名称<input disabled={action.busy} required value={rename.name} pattern="[^/\\]+" onChange={e => setRename({...rename,name:e.target.value})}/></label><button disabled={action.busy}>确认重命名</button><button type="button" disabled={action.busy} onClick={() => setRename(undefined)}>取消</button></form>}<ResourceState resource={tree}/>{tree.data?.length === 0 && <Empty>这个目录还是空的。上传文件或创建目录开始使用。</Empty>}{!!tree.data?.length && <div className="table-scroll"><table><thead><tr><th>名称</th><th>大小</th><th>修改时间</th><th>操作</th></tr></thead><tbody>{tree.data.map(entry => {const target = entry.path || join(entry.name); return <tr key={entry.name}><td>{entry.is_dir ? <button className="file-link" disabled={action.busy} onClick={() => go(target)}>▰ {entry.name}</button> : <span>▤ {entry.name}</span>}</td><td>{entry.is_dir ? '目录' : bytes(entry.size)}</td><td>{date(entry.mod_time)}</td><td><div className="actions"><a className="button" href={api.download(target,instanceId)}>下载</a><button disabled={action.busy} onClick={() => setRename({path:target,name:entry.name})}>重命名</button><button className="danger" disabled={action.busy} onClick={() => action.run(async () => {await api.delete(target,instanceId); await refresh();}, '已删除', '确认永久删除「' + entry.name + '」' + (entry.is_dir ? '(仅允许空目录)' : '') + '?可能包含敏感配置,删除无法撤销。')}>删除</button></div></td></tr>;})}</tbody></table></div>}</Panel></>;
return <>{controls(action.busy)}<div className="workspace-banner"><div><p className="eyebrow">INSTANCE FILES</p><h2>实例文件</h2><p>{'当前实例:' + instanceName}</p><p>实例数据目录为 /workspace(模板 7);历史镜像的路径可能不同。</p><p>这里管理文件,不会切换 DSH 会话的工作目录。包含配置和密钥,请谨慎操作。</p><p>{homeAccessNotice}</p><p className="muted">下载确认及文件名识别仅为操作提醒,不是权限边界;右键打开下载链接等方式可能绕过提示。</p></div><div><p>账号总用量</p><strong>{bytes(usage.data?.used_bytes)}</strong><p>账号配额:{bytes(usage.data?.quota_bytes)}</p></div></div><ResourceState resource={usage}/>{action.feedback}<Panel title="文件浏览器" action={<button onClick={refresh} disabled={tree.loading || action.busy}>刷新</button>}><p className="muted">当前完整路径:<code>{displayFilePath('instance', path)}</code></p><div className="file-toolbar"><div className="breadcrumbs"><button onClick={() => go('.')} disabled={path === '.' || action.busy}>{instanceId ? '实例文件' : '历史共享目录'}</button>{path !== '.' && path.split('/').map((part,index,parts) => <span key={index}> / <button disabled={action.busy} onClick={() => go(parts.slice(0,index+1).join('/'))}>{part}</button></span>)}</div><label className={'button upload ' + (action.busy ? 'disabled' : '')}>↑ 上传文件<input type="file" disabled={action.busy} onChange={e => {const file = e.target.files?.[0]; if(file) void action.run(async () => {await api.upload(path,file,instanceId); await refresh();}, '文件已上传', mutationWarning('upload', join(file.name))); e.target.value = '';}}/></label></div><form className="compact-form" onSubmit={mkdir}><label className="sr-only" htmlFor="new-folder">新目录名称</label><input disabled={action.busy} id="new-folder" value={newName} onChange={e => setNewName(e.target.value)} placeholder="新目录名称" required pattern="[^/\\]+"/><button disabled={action.busy}>创建目录</button></form>{rename && <form className="compact-form" onSubmit={e => {e.preventDefault(); void action.run(async () => {await api.rename(rename.path,join(rename.name),instanceId); setRename(undefined); await refresh();}, '重命名已完成', mutationWarning('rename', rename.path, join(rename.name)));}}><label>新名称<input disabled={action.busy} required value={rename.name} pattern="[^/\\]+" onChange={e => setRename({...rename,name:e.target.value})}/></label><button disabled={action.busy}>确认重命名</button><button type="button" disabled={action.busy} onClick={() => setRename(undefined)}>取消</button></form>}<ResourceState resource={tree}/>{tree.data?.length === 0 && <Empty>这个目录还是空的。上传文件或创建目录开始使用。</Empty>}{!!tree.data?.length && <div className="table-scroll"><table><thead><tr><th>名称</th><th>大小</th><th>修改时间</th><th>操作</th></tr></thead><tbody>{tree.data.map(entry => {const target = entry.path || join(entry.name); return <tr key={entry.name}><td>{entry.is_dir ? <button className="file-link" disabled={action.busy} onClick={() => go(target)}>▰ {entry.name}</button> : <span>▤ {entry.name}</span>}</td><td>{entry.is_dir ? '目录' : bytes(entry.size)}</td><td>{date(entry.mod_time)}</td><td><div className="actions"><a className="button" href={api.download(target,instanceId)} title={downloadWarning(target, entry.is_dir)} onClick={e => confirmDownload(e, target, entry.is_dir)} onAuxClick={e => {if (e.button === 1) confirmDownload(e, target, entry.is_dir);}} onContextMenu={e => confirmDownload(e, target, entry.is_dir)}>下载</a><button disabled={action.busy} onClick={() => setRename({path:target,name:entry.name})}>重命名</button><button className="danger" disabled={action.busy} onClick={() => action.run(async () => {await api.delete(target,instanceId); await refresh();}, '已删除', mutationWarning('delete', target))}>删除</button></div></td></tr>;})}</tbody></table></div>}</Panel></>;
}
+49 -8
View File
@@ -4,8 +4,13 @@ export class ApiError extends Error {
export interface User { id: string; username: string; admin: boolean; quota_bytes: number; created_at?: string }
export interface Session { user: User; csrf_token: string }
export interface Instance { id: string; name: string; status: string; image_id: string; version: string; created_at: string }
export interface Image { id: string; image_id?: string; version: string; tag?: string; status?: string; usage_count?: number; recipe_rev?: string; platform?: string; created_at?: string }
export interface Build { id: string; version: string; status: string; image_id?: string; logs?: string; error?: string; created_at: string }
export type AdmissionState = 'recommended' | 'compatible' | 'existing_only' | 'blocked';
export interface Image { id: string; image_id?: string; version: string; tag?: string; status?: string; admission?: string; admission_reason?: string; selectable?: boolean; usage_count?: number; recipe_rev?: string; platform?: string; created_at?: string }
export interface Build { id: string; version: string; status: string; experimental?: boolean; image_id?: string; logs?: string; error?: string; created_at: string }
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 }
@@ -14,20 +19,52 @@ export function createApi(fetcher: typeof fetch = fetch) {
let csrf = '';
let refreshing: Promise<Session> | undefined;
const csrfCookie = () => typeof document === 'undefined' ? '' : document.cookie.split('; ').find(c => c.startsWith('dsh_csrf='))?.slice(9) || '';
const currentCSRF = () => typeof document === 'undefined' ? csrf : csrfCookie();
async function recoverSession() {
const recover = async (checkFirst: boolean) => {
if (checkFirst) {
try { return await request<Session>('/me', 'GET', undefined, true); }
catch (e) { if (!(e instanceof ApiError) || e.status !== 401) throw e; }
}
try { return await request<Session>('/refresh', 'POST', undefined, true); }
catch (failure) {
// Another page may have rotated the shared cookies while this request
// was in flight; keep rechecking while a slow sibling tab settles.
const retries = [0, 100, 250, 500, 1000, 2000];
for (const delay of retries) {
if (delay) await new Promise(resolve => setTimeout(resolve, delay));
try { return await request<Session>('/me', 'GET', undefined, true); }
catch (e) {
if (!(e instanceof ApiError) || e.status !== 401) throw e;
if (delay === retries[retries.length - 1]) throw failure instanceof ApiError && failure.status === 403 ? e : failure;
}
}
throw new ApiError(401, '登录已失效,请重新登录。');
}
};
return typeof navigator !== 'undefined' && navigator.locks
? navigator.locks.request('dsh-session-refresh', () => recover(true))
: recover(false);
}
async function request<T>(path: string, method = 'GET', data?: unknown, retried = false): Promise<T> {
const headers = new Headers();
if (method !== 'GET' && (csrf || csrfCookie())) headers.set('X-CSRF-Token', csrf || csrfCookie());
const token = currentCSRF();
if (method !== 'GET' && token) headers.set('X-CSRF-Token', token);
const multipart = data instanceof FormData;
if (data !== undefined && !multipart) headers.set('Content-Type', 'application/json');
let response: Response;
try { response = await fetcher('/api' + path, { method, credentials: 'same-origin', headers, body: data === undefined ? undefined : multipart ? data : JSON.stringify(data) }); }
catch { throw new ApiError(0, '无法连接服务器,请检查网络或后端服务。'); }
if (response.status === 401 && !retried && path !== '/login' && path !== '/refresh' && (csrf || csrfCookie())) {
if (response.status === 401 && !retried && path !== '/login' && path !== '/refresh' && currentCSRF()) {
try {
refreshing ??= request<Session>('/refresh', 'POST', undefined, true).finally(() => { refreshing = undefined; });
refreshing ??= recoverSession().finally(() => { refreshing = undefined; });
const session = await refreshing; csrf = session.csrf_token;
return request<T>(path, method, data, true);
} catch { /* The original response determines session expiration handling. */ }
} catch (e) {
if (!(e instanceof ApiError) || e.status !== 401) throw e;
if (path !== '/me' && typeof window !== 'undefined') window.dispatchEvent(new Event('session-expired'));
throw e;
}
return request<T>(path, method, data, true);
}
const text = await response.text();
let payload: unknown;
@@ -46,7 +83,11 @@ export function createApi(fetcher: typeof fetch = fetch) {
async logout() { await request('/logout', 'POST'); csrf = ''; },
instances: () => request<Instance[]>('/instances'),
images: () => request<Image[]>('/images'),
builds: () => request<Build[]>('/builds'),
compatibility: () => request<Compatibility>('/compatibility'),
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)),
usage: (instance?: string) => request<Usage>('/ws/usage' + (instance ? '?instance=' + encodeURIComponent(instance) : '')),
tree: (path: string, instance?: string) => request<Entry[]>('/ws/tree?path=' + encodeURIComponent(path) + (instance ? '&instance=' + encodeURIComponent(instance) : '')),
download: (path: string, instance?: string) => '/api/ws/download?path=' + encodeURIComponent(path) + (instance ? '&instance=' + encodeURIComponent(instance) : ''),
+81 -1
View File
@@ -1,6 +1,8 @@
import { it, expect, vi } from 'vitest';
import { it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createApi } from './api';
const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), {status});
beforeEach(() => vi.stubGlobal('navigator', {}));
afterEach(() => vi.unstubAllGlobals());
it('refreshes once after access expiry, retaining CSRF and retrying the request', async () => {
const fetcher = vi.fn<typeof fetch>()
.mockResolvedValueOnce(json({user:{id:'u'},csrf_token:'csrf'}))
@@ -40,3 +42,81 @@ it('shares one refresh across concurrent expired requests', async () => {
expect(await Promise.all([first,second])).toEqual([[],[]]);
expect(fetcher.mock.calls.filter(c=>c[0]==='/api/refresh')).toHaveLength(1);
});
it.each([401, 403])('rechecks the shared session after refresh fails with %s', async status => {
const dispatchEvent = vi.fn(); vi.stubGlobal('window', {dispatchEvent});
const fetcher = vi.fn<typeof fetch>()
.mockResolvedValueOnce(json({csrf_token:'old'}))
.mockResolvedValueOnce(json({},401))
.mockResolvedValueOnce(json({},status))
.mockResolvedValueOnce(json({csrf_token:'new'}))
.mockResolvedValueOnce(json([]));
const api = createApi(fetcher); await api.me();
expect(await api.instances()).toEqual([]);
expect(fetcher.mock.calls.map(c => c[0])).toEqual(['/api/me','/api/instances','/api/refresh','/api/me','/api/instances']);
expect(dispatchEvent).not.toHaveBeenCalled();
});
it.each([401,403])('does not restore a revoked session after logout/password change (%s)', async status => {
const dispatchEvent = vi.fn(); vi.stubGlobal('window', {dispatchEvent});
const fetcher = vi.fn<typeof fetch>().mockResolvedValueOnce(json({csrf_token:'old'})).mockImplementation(async path => json({},path === '/api/refresh' ? status : 401));
const api = createApi(fetcher); await api.me();
await expect(api.instances()).rejects.toMatchObject({status:401});
expect(dispatchEvent).toHaveBeenCalledOnce();
expect(fetcher).toHaveBeenCalledTimes(9);
});
it('does not mistake a failed session recheck for logout', async () => {
const dispatchEvent = vi.fn(); vi.stubGlobal('window', {dispatchEvent});
const fetcher = vi.fn<typeof fetch>().mockResolvedValueOnce(json({csrf_token:'old'})).mockResolvedValueOnce(json({},401)).mockResolvedValueOnce(json({},401)).mockRejectedValue(new Error('offline'));
const api = createApi(fetcher); await api.me();
await expect(api.instances()).rejects.toMatchObject({status:0});
expect(dispatchEvent).not.toHaveBeenCalled();
});
it('uses current cookie rather than a cached CSRF after another tab logs in or out', async () => {
const document = {cookie:'dsh_csrf=first'}; vi.stubGlobal('document', document);
const fetcher = vi.fn<typeof fetch>().mockImplementation(async () => json({csrf_token:'cached'}));
const api = createApi(fetcher); await api.me();
document.cookie = 'dsh_csrf=second'; await api.login('user','password');
expect(new Headers(fetcher.mock.calls[1][1]?.headers).get('X-CSRF-Token')).toBe('second');
document.cookie = ''; await api.login('user','password');
expect(new Headers(fetcher.mock.calls[2][1]?.headers).has('X-CSRF-Token')).toBe(false);
});
it('coordinates independent clients with the origin-wide refresh lock', async () => {
let queue = Promise.resolve<unknown>(undefined);
const lock = vi.fn((_name: string, run: () => Promise<unknown>) => { const pending = queue.then(run); queue = pending.catch(() => {}); return pending; });
vi.stubGlobal('navigator', {locks:{request:lock}});
vi.stubGlobal('document', {cookie:'dsh_csrf=shared'});
let valid = false;
const fetcher = vi.fn<typeof fetch>().mockImplementation(async path => {
if (path === '/api/refresh') { valid = true; return json({csrf_token:'shared'}); }
if (path === '/api/me') return valid ? json({csrf_token:'shared'}) : json({},401);
return valid ? json([]) : json({},401);
});
expect(await Promise.all([createApi(fetcher).instances(), createApi(fetcher).images()])).toEqual([[],[]]);
expect(fetcher.mock.calls.filter(c => c[0] === '/api/refresh')).toHaveLength(1);
expect(lock.mock.calls.every(c => c[0] === 'dsh-session-refresh')).toBe(true);
});
it('waits for delayed winning cookies without Web Locks and never refreshes twice', async () => {
vi.useFakeTimers();
try {
const fetcher = vi.fn<typeof fetch>().mockResolvedValueOnce(json({csrf_token:'old'})).mockResolvedValueOnce(json({},401)).mockResolvedValueOnce(json({},401)).mockResolvedValueOnce(json({},401)).mockResolvedValueOnce(json({csrf_token:'winner'})).mockResolvedValueOnce(json([]));
const api = createApi(fetcher); await api.me();
const pending = api.instances(); await vi.advanceTimersByTimeAsync(100);
expect(await pending).toEqual([]);
expect(fetcher.mock.calls.filter(c => c[0] === '/api/refresh')).toHaveLength(1);
} finally { vi.useRealTimers(); }
});
it.each([409, 500, 0])('preserves refresh failure %s when access remains expired beyond the recheck window', async status => {
vi.useFakeTimers();
const dispatchEvent = vi.fn(); vi.stubGlobal('window', {dispatchEvent});
try {
const fetcher = vi.fn<typeof fetch>().mockImplementation(async path => {
if (path === '/api/login') return json({csrf_token:'old'});
if (path === '/api/refresh') { if (!status) throw new Error('offline'); return json({error:'refresh_conflict'},status); }
return json({},401);
});
const api = createApi(fetcher); await api.login('u','password');
const pending = expect(api.instances()).rejects.toMatchObject({status});
await vi.advanceTimersByTimeAsync(4000); await pending;
expect(dispatchEvent).not.toHaveBeenCalled();
expect(fetcher.mock.calls.filter(c => c[0] === '/api/refresh')).toHaveLength(1);
} finally { vi.useRealTimers(); }
});
+13
View File
@@ -0,0 +1,13 @@
import { expect, it, vi } from 'vitest';
import { createApi } from './api';
import { activeBuild } from './Images';
it('requests explicit pages independently of on-demand encoded log details', async () => {
const fetcher = vi.fn<typeof fetch>().mockImplementation(async () => new Response('[]'));
const api = createApi(fetcher);
await api.builds(21, 0); await api.builds(21, 20);
expect(fetcher.mock.calls.map(c => c[0])).toEqual(['/api/builds?limit=21&offset=0','/api/builds?limit=21&offset=20']);
await api.build('a/b'); expect(fetcher.mock.calls[2][0]).toBe('/api/builds/a%2Fb');
});
it.each(['queued','running','validating','success','failed'])('polls only active build status %s', status => {
expect(activeBuild({id:'b', version:'1', created_at:'', status})).toBe(['queued','running','validating'].includes(status));
});
+30
View File
@@ -0,0 +1,30 @@
import { expect, it, vi } from 'vitest';
import { build } from 'vite';
it('keeps Monaco lazy, production chunks below 500 kB, and static imports acyclic', async () => {
vi.stubEnv('NODE_ENV', 'production');
const result = await build({ build: { write: false }, logLevel: 'silent' }).finally(() => vi.unstubAllEnvs());
if (!('output' in result)) throw new Error('Expected a single application build');
const chunks = result.output.filter(asset => asset.type === 'chunk');
const byName = new Map(chunks.map(chunk => [chunk.fileName, chunk]));
const visited = new Set<string>();
function visit(name: string, ancestors: string[] = []) {
expect(ancestors, `Static chunk cycle: ${[...ancestors, name].join(' -> ')}`).not.toContain(name);
if (visited.has(name)) return;
const chunk = byName.get(name);
if (!chunk) return;
chunk.imports.forEach(dependency => visit(dependency, [...ancestors, name]));
visited.add(name);
}
for (const chunk of chunks) {
expect(Buffer.byteLength(chunk.code), chunk.fileName).toBeLessThanOrEqual(500_000);
visit(chunk.fileName);
}
const entry = chunks.find(chunk => chunk.isEntry)!;
visited.clear();
visit(entry.fileName);
for (const name of visited) {
expect(Object.keys(byName.get(name)!.modules).some(id => id.includes('/monaco-editor/'))).toBe(false);
}
expect(chunks.some(chunk => chunk.isDynamicEntry && chunk.name === 'MonacoProfileEditor')).toBe(true);
}, 60_000);
+26
View File
@@ -0,0 +1,26 @@
import { expect, it } from 'vitest';
import { downloadWarning, isSensitiveFile, mutationWarning } from './fileWarnings';
it('accurately explains that upload rejects existing names rather than overwriting', () => {
const warning = mutationWarning('upload', '.credentials.yaml');
expect(warning).toContain('同名文件不会覆盖,服务器将拒绝上传');
expect(warning).not.toContain('同名文件将被覆盖');
});
it.each(['.credentials.yaml', 'dsh-ssh.json', 'profiles/default.yaml', 'profiles', 'home/cordis.yml', 'cordis.config.ts', 'patch.yaml', 'package.json', 'pnpm-workspace.yaml', 'workspace.json', '.ssh/id_ed25519', '.env.local'])('recognizes common credential/config hint %s', path => {
expect(isSensitiveFile(path)).toBe(true);
});
it('warns on renamed files and arbitrary directory ZIPs without treating recognition as security', () => {
expect(isSensitiveFile('notes.txt')).toBe(false);
expect(downloadWarning('notes.txt', false)).toContain('凭据');
expect(downloadWarning('photos', true)).toContain('任意目录');
expect(downloadWarning('photos', true)).toContain('ZIP');
});
it('checks both rename paths and recommends stopping without requiring it', () => {
for (const paths of [['notes', '.credentials.yaml'], ['profiles/default.yaml', 'notes']]) {
const warning = mutationWarning('rename', paths[0], paths[1]);
expect(warning).toContain('此路径可能涉及凭据');
expect(warning).toContain('备份');
expect(warning).toContain('不强制停机');
}
});
+28
View File
@@ -0,0 +1,28 @@
export const homeAccessNotice = '所有者可完整管理实例 home,包括读取、下载凭据;不承诺凭据仅写入(write-only)。专用 profile 页面提供受控编辑流程,不是强制安全边界;通用文件上传、重命名或删除可绕过该流程的 YAML 校验和停机要求。';
const configMutationWarning = '修改配置可能影响实例运行。建议先停止实例并备份,再继续;这里不强制停机。';
// Filename hints are UX only: renamed secrets cannot be reliably identified.
export function isSensitiveFile(path: string): boolean {
const parts = path.toLowerCase().split('/');
const name = parts.at(-1) || '';
return parts.some(part => ['profiles', '.ssh', '.config', '.cordis'].includes(part))
|| /^(?:\.?credentials(?:\..+)?|dsh-ssh\.json|\.env(?:\..+)?|id_(?:rsa|ed25519|ecdsa)(?:\.pub)?)$/.test(name)
|| /^(?:\.?cordis|patch|package|(?:pnpm-)?workspace)(?:[.-].+)?$/.test(name)
|| /\.(?:pem|key|p12|pfx)$/.test(name);
}
export function downloadWarning(path: string, isDirectory: boolean): string {
if (isDirectory) return '确认下载目录「' + path + '」的 ZIP?任意目录都可能包含凭据和敏感配置,改名后也无法可靠识别。请妥善保管,勿公开分享。';
return '确认下载「' + path + '」?' + (isSensitiveFile(path) ? '此文件可能包含凭据或运行配置,请妥善保管,勿公开分享。' : '文件可能含凭据(改名无法可靠识别),请妥善保管。');
}
export function confirmDownload(event: {preventDefault: () => void}, path: string, isDirectory: boolean): void {
if (!window.confirm(downloadWarning(path, isDirectory))) event.preventDefault();
}
export function mutationWarning(operation: 'upload' | 'rename' | 'delete', path: string, destination?: string): string {
const question = operation === 'upload' ? '确认上传「' + path + '」?同名文件不会覆盖,服务器将拒绝上传。'
: operation === 'rename' ? '确认将「' + path + '」重命名为「' + destination + '」?'
: '确认永久删除「' + path + '」?目录仅允许删除空目录,删除无法撤销。';
return question + (isSensitiveFile(path) || (destination && isSensitiveFile(destination)) ? '此路径可能涉及凭据或敏感配置。' : '文件或目录也可能包含改名后的敏感配置。') + configMutationWarning;
}
+40
View File
@@ -0,0 +1,40 @@
import { expect, it, vi } from 'vitest';
import { createApi, normalBuildAllowed, selectableImage, type AdmissionState, type Compatibility } 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 () => {
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'}]),
]);
});
it.each([undefined, 'experimental', 'existing_only', 'blocked', 'unverified', 'missing'])('never selects status %s even with selectable=true', status => {
expect(selectableImage({id:'i', version:'1', status, selectable:true})).toBe(false);
});
it('requires explicit availability and selection permission rather than inferring old-image compatibility', () => {
expect(selectableImage({id:'old', version:'1'})).toBe(false);
expect(selectableImage({id:'i', version:'1', status:'available'})).toBe(false);
expect(selectableImage({id:'i', version:'1', status:'available', selectable:false})).toBe(false);
expect(selectableImage({id:'i', version:'1', status:'available', selectable:true})).toBe(true);
});
+75
View File
@@ -0,0 +1,75 @@
import { readFileSync } from 'node:fs';
import { runInNewContext } from 'node:vm';
import { expect, it, vi } from 'vitest';
const source = readFileSync(new URL('../../internal/api/runtime_session.go', import.meta.url), 'utf8').split('const runtimeSessionJS = `')[1].split('`')[0];
it.each([401, 403, 409, 500])('runtime only reloads a confirmed revoked session (%s)', async status => {
const reload = vi.fn();
let renew: (() => void) | undefined;
const fetch = vi.fn(async (path: string) => new Response('{}', {status: path === '/api/refresh' ? status : 401}));
runInNewContext(source, {
location:{pathname:'/',reload}, navigator:{}, fetch,
document:{cookie:'dsh_csrf=csrf',addEventListener() {}},
window:{addEventListener(_name: string, handler: () => void) {renew = handler;}},
setInterval() {}, setTimeout(resolve: () => void) {resolve();},
});
await renew?.();
expect(reload).toHaveBeenCalledTimes(status === 401 || status === 403 ? 1 : 0);
expect(fetch.mock.calls.filter(c => c[0] === '/api/refresh')).toHaveLength(1);
expect(fetch.mock.calls.filter(c => c[0] === '/api/me')).toHaveLength(status === 500 ? 0 : 4);
});
it.each([false, true])('renews before access expiry, with Web Locks=%s', async withLocks => {
let now = 0, expires = 300_000;
let tick!: () => Promise<void>;
const locks = {request: vi.fn(async (_name: string, run: () => Promise<void>) => run())};
const fetch = vi.fn(async (path: string) => {
if (path === '/api/refresh') expires = now + 300_000;
return new Response('{}', {status: now < expires ? 200 : 401});
});
runInNewContext(source, {
location: {pathname: '/', reload: vi.fn()}, navigator: withLocks ? {locks} : {}, fetch,
document: {cookie: 'dsh_csrf=csrf', addEventListener() {}},
window: {addEventListener() {}},
setInterval(fn: typeof tick, interval: number) {tick = fn; expect(interval).toBe(240_000);},
setTimeout,
});
for (now = 240_000; now <= 960_000; now += 240_000) {
expect(now).toBeLessThan(expires);
await tick();
expect(expires).toBe(now + 300_000);
}
expect(fetch.mock.calls.map(c => c[0])).toEqual(Array(4).fill('/api/refresh'));
expect(locks.request).toHaveBeenCalledTimes(withLocks ? 4 : 0);
});
it('coalesces renewal events and keeps a session recovered by a sibling page', async () => {
let tick!: () => Promise<void>, finish!: (response: Response) => void;
const reload = vi.fn();
const fetch = vi.fn((path: string) => path === '/api/refresh'
? new Promise<Response>(resolve => {finish = resolve;})
: Promise.resolve(new Response('{}', {status: 200})));
runInNewContext(source, {
location: {pathname: '/', reload}, navigator: {}, fetch,
document: {cookie: 'dsh_csrf=csrf', addEventListener() {}},
window: {addEventListener() {}}, setInterval(fn: typeof tick) {tick = fn;}, setTimeout,
});
const first = tick(), second = tick();
expect(fetch).toHaveBeenCalledOnce();
finish(new Response('{}', {status: 409}));
await Promise.all([first, second]);
expect(fetch.mock.calls.map(c => c[0])).toEqual(['/api/refresh', '/api/me']);
expect(reload).not.toHaveBeenCalled();
});
it('reads the latest CSRF cookie after waiting for the refresh lock', async () => {
let tick!: () => Promise<void>, enter!: () => void;
const document = {cookie: 'dsh_csrf=old', addEventListener() {}};
const fetch = vi.fn(async () => new Response('{}'));
const locks = {request: (_name: string, run: () => Promise<void>) => new Promise<void>((resolve, reject) => {enter = () => {run().then(resolve, reject);};})};
runInNewContext(source, {
location: {pathname: '/', reload: vi.fn()}, navigator: {locks}, document, fetch,
window: {addEventListener() {}}, setInterval(fn: typeof tick) {tick = fn;}, setTimeout,
});
const pending = tick();
expect(fetch).not.toHaveBeenCalled();
document.cookie = 'dsh_csrf=new';
enter(); await pending;
expect(fetch).toHaveBeenCalledWith('/api/refresh', expect.objectContaining({headers: {'X-CSRF-Token': 'new'}}));
});
+116
View File
@@ -0,0 +1,116 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
const hooks = vi.hoisted(() => ({slots: [] as any[], cursor: 0, effects: [] as (() => void)[]}));
vi.mock('react', () => ({
useRef(value: unknown) { const i = hooks.cursor++; return hooks.slots[i] ??= {current:value}; },
useState(value: unknown) { const i = hooks.cursor++; if (!(i in hooks.slots)) hooks.slots[i] = value; return [hooks.slots[i], (v: unknown) => { hooks.slots[i] = v; }]; },
useCallback(fn: unknown, deps: unknown[]) {
const i = hooks.cursor++, previous = hooks.slots[i];
if (!previous || deps.some((v, n) => v !== previous.deps[n])) hooks.slots[i] = {deps, fn};
return hooks.slots[i].fn;
},
useEffect(fn: () => (() => void) | undefined, deps: unknown[]) {
const i = hooks.cursor++, previous = hooks.slots[i];
if (!previous || deps.some((v, n) => v !== previous.deps[n])) hooks.effects.push(() => { previous?.cleanup?.(); hooks.slots[i] = {deps, cleanup:fn()}; });
},
}));
import { useResource } from './ui';
const render = <T,>(load: () => Promise<T>, key = '', poll?: (data: T) => boolean, interval = 10, timeout = 60_000) => {
hooks.cursor = 0; const result = useResource(load, key, interval, poll, timeout);
hooks.effects.splice(0).forEach(effect => effect()); return result;
};
beforeEach(() => { hooks.slots = []; hooks.cursor = 0; hooks.effects = []; vi.useFakeTimers(); vi.stubGlobal('window', {setInterval, clearInterval}); });
afterEach(() => { hooks.slots.forEach(s => s?.cleanup?.()); vi.useRealTimers(); vi.unstubAllGlobals(); });
it('accepts slow results without overlapping polling', async () => {
let resolve!: (value: string) => void;
const load = vi.fn(() => new Promise<string>(r => { resolve = r; }));
render(load);
await vi.advanceTimersByTimeAsync(50);
expect(load).toHaveBeenCalledOnce();
resolve('slow result'); await Promise.resolve(); await Promise.resolve();
expect(render(load).data).toBe('slow result');
await vi.advanceTimersByTimeAsync(10); expect(load).toHaveBeenCalledTimes(2);
});
it('ignores an old key result while allowing the new key to load', async () => {
let resolve!: (value: string) => void;
const old = () => new Promise<string>(r => {resolve = r;});
const first = render(old, 'old'); const pending = first.refresh();
const next = vi.fn(async () => 'new result'); render(next, 'new');
await Promise.resolve(); await Promise.resolve();
resolve('stale'); await pending;
expect(render(next, 'new').data).toBe('new result');
});
it('stops polling terminal data but still allows manual refresh', async () => {
const load = vi.fn(async () => 'done'); const poll = () => false;
const resource = render(load, '', poll); await Promise.resolve(); await Promise.resolve();
await vi.advanceTimersByTimeAsync(50); expect(load).toHaveBeenCalledOnce();
await resource.refresh(); expect(load).toHaveBeenCalledTimes(2);
});
it('queues a post-mutation refresh after an old in-flight empty list', async () => {
let resolve!: (value: string[]) => void;
const load = vi.fn<() => Promise<string[]>>().mockImplementationOnce(() => new Promise(r => {resolve = r;})).mockResolvedValue(['new build']);
const resource = render(load, '', () => false);
const refreshed = resource.refresh(); expect(load).toHaveBeenCalledOnce();
resolve([]); await refreshed;
expect(load).toHaveBeenCalledTimes(2);
expect(render(load).data).toEqual(['new build']);
});
it('escapes a hung request so polling continues', async () => {
let hung = true;
const load = vi.fn(() => hung ? new Promise<string>(() => {}) : Promise.resolve('recovered'));
render(load, '', () => false);
expect(load).toHaveBeenCalledOnce();
await vi.advanceTimersByTimeAsync(20);
expect(load).toHaveBeenCalledOnce(); // no overlapping polls while hung
hung = false;
await vi.advanceTimersByTimeAsync(120_000);
expect(load).toHaveBeenCalledTimes(2); // escape re-armed the poller once
expect(render(load).data).toBe('recovered');
});
it('settles a refresh waiting on a hung request and performs the post-mutation read', async () => {
const load = vi.fn<() => Promise<string>>().mockImplementationOnce(() => new Promise(() => {})).mockResolvedValue('updated');
const resource = render(load, '', undefined, 0);
const finished = vi.fn();
const pending = resource.refresh().then(finished);
await vi.advanceTimersByTimeAsync(60_000);
await pending;
expect(finished).toHaveBeenCalledOnce();
expect(load).toHaveBeenCalledTimes(2);
expect(render(load, '', undefined, 0)).toMatchObject({data: 'updated', loading: false, error: ''});
});
it('releases refresh waiters even when the follow-up request also hangs', async () => {
const load = vi.fn(() => new Promise<string>(() => {}));
const resource = render(load, '', undefined, 0);
const finished = vi.fn();
const pending = resource.refresh().then(finished);
await vi.advanceTimersByTimeAsync(120_000);
await pending;
expect(finished).toHaveBeenCalledOnce();
expect(load).toHaveBeenCalledTimes(2);
expect(render(load, '', undefined, 0)).toMatchObject({loading: false, error: '请求超时,请重试。'});
});
it.each(['resolve', 'reject'])('shows a timeout without polling and ignores late %s', async outcome => {
let resolve!: (value: string) => void, reject!: (error: Error) => void;
const load = vi.fn<() => Promise<string>>().mockImplementationOnce(() => new Promise((yes, no) => {resolve = yes; reject = no;})).mockResolvedValue('fresh');
render(load, '', undefined, 0);
await vi.advanceTimersByTimeAsync(60_000);
let resource = render(load, '', undefined, 0);
expect(resource).toMatchObject({loading: false, error: '请求超时,请重试。', data: undefined});
await resource.refresh();
if (outcome === 'resolve') resolve('stale'); else reject(new Error('late failure'));
await vi.advanceTimersByTimeAsync(0);
resource = render(load, '', undefined, 0);
expect(resource).toMatchObject({loading: false, error: '', data: 'fresh'});
});
it('uses a changed timeout and isolates the previous load', async () => {
let resolve!: (value: string) => void;
const load = vi.fn<() => Promise<string>>().mockImplementationOnce(() => new Promise(r => {resolve = r;})).mockImplementationOnce(() => new Promise(() => {})).mockResolvedValue('fresh');
render(load, '', undefined, 0);
render(load, '', undefined, 0, 100);
await vi.advanceTimersByTimeAsync(100);
const resource = render(load, '', undefined, 0, 100);
expect(resource).toMatchObject({loading: false, error: '请求超时,请重试。'});
resolve('stale');
await resource.refresh();
expect(load).toHaveBeenCalledTimes(3);
expect(render(load, '', undefined, 0, 100)).toMatchObject({data: 'fresh', error: '', loading: false});
});
+45 -8
View File
@@ -2,20 +2,57 @@ import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
export const message = (error: unknown) => error instanceof Error ? error.message : '操作失败,请重试。';
export const bytes = (n?: number) => n === undefined ? '—' : n < 1024 ? n + ' B' : n < 1048576 ? (n / 1024).toFixed(1) + ' KiB' : n < 1073741824 ? (n / 1048576).toFixed(1) + ' MiB' : (n / 1073741824).toFixed(2) + ' GiB';
export const date = (s?: string) => s ? new Date(s).toLocaleString('zh-CN') : '—';
export function useResource<T>(load: () => Promise<T>, key = '', interval = 0) {
export function useResource<T>(load: () => Promise<T>, key = '', interval = 0, shouldPoll: (data: T) => boolean = () => true, timeoutMs = 60_000) {
const loader = useRef(load); loader.current = load;
const polling = useRef(shouldPoll); polling.current = shouldPoll;
const latest = useRef<T>(undefined);
const [data, setData] = useState<T>();
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
const generation = useRef(0);
const refresh = useCallback(async () => {
const current = ++generation.current;
const inFlight = useRef<Promise<void> | undefined>(undefined);
const loadSeq = useRef(0);
const loadNow = useCallback(() => {
if (inFlight.current) return inFlight.current;
const current = generation.current;
const seq = ++loadSeq.current;
setLoading(true); setError('');
try { const value = await loader.current(); if (current === generation.current) setData(value); }
catch (e) { if (current === generation.current) setError(message(e)); }
finally { if (current === generation.current) setLoading(false); }
}, []);
useEffect(() => { setData(undefined); void refresh(); const timer = interval ? window.setInterval(refresh, interval) : undefined; return () => { generation.current++; window.clearInterval(timer); }; }, [refresh, key, interval]);
// Bound the promise that refresh callers await, not just its reference.
// Only the winning result may update state; a timed-out loader can still
// settle later, but cannot overwrite data or errors from a newer load.
const record: {promise?: Promise<void>, escape?: ReturnType<typeof setTimeout>} = {};
const pending = (async () => {
try {
const timeout = new Promise<never>((_, reject) => {
record.escape = setTimeout(() => reject(new Error('请求超时,请重试。')), timeoutMs);
});
const value = await Promise.race([loader.current(), timeout]);
if (current === generation.current && seq === loadSeq.current) { latest.current = value; setData(value); }
}
catch (e) { if (current === generation.current && seq === loadSeq.current) setError(message(e)); }
finally { if (record.escape !== undefined) clearTimeout(record.escape); if (current === generation.current && seq === loadSeq.current) setLoading(false); if (inFlight.current === record.promise) inFlight.current = undefined; }
})();
record.promise = pending;
inFlight.current = pending;
void pending.then(() => { if (inFlight.current === pending) inFlight.current = undefined; });
return pending;
}, [timeoutMs]);
const refresh = useCallback(async () => {
const current = generation.current;
if (inFlight.current) await inFlight.current;
if (current === generation.current) await loadNow();
}, [loadNow]);
useEffect(() => {
setData(undefined); latest.current = undefined; void loadNow();
return () => { generation.current++; inFlight.current = undefined; };
}, [loadNow, key]);
useEffect(() => {
if (!interval) return;
const timer = window.setInterval(() => {
if (latest.current === undefined || polling.current(latest.current)) void loadNow();
}, interval);
return () => window.clearInterval(timer);
}, [loadNow, key, interval]);
return {data, error, loading, refresh};
}
export function Alert({children}: {children: ReactNode}) { return <div className="alert" role="alert">{children}</div>; }
+27 -1
View File
@@ -1,3 +1,29 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({ plugins: [react()], server: { host: "127.0.0.1", proxy: { "/api": "http://127.0.0.1:8088", "/dsh": { target: "http://127.0.0.1:8088", ws: true } } } });
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: {
output: {
// Keep shared Vite helpers out of Monaco chunks so the entry stays lazy.
onlyExplicitManualChunks: true,
manualChunks(id) {
const path = id.split("/monaco-editor/esm/vs/")[1];
if (!path || path.includes("?worker")) return;
const parts = path.split("/");
if (parts[0] === "base") return `monaco-base-${parts[1]}`;
if (path.startsWith("editor/common/core/")) return "monaco-editor-core";
if (path.startsWith("editor/common/diff/")) return "monaco-editor-diff";
if (path.startsWith("editor/common/config/")) return "monaco-editor-config";
if (path.startsWith("editor/common/viewLayout/") || path.startsWith("editor/common/viewModel/")) return "monaco-editor-view-model";
if (path.startsWith("editor/browser/config/")) return "monaco-editor-browser-config";
// Multi-diff imports diff widgets; splitting them creates a chunk cycle.
if (path.startsWith("editor/browser/widget/diffEditor/") || path.startsWith("editor/browser/widget/multiDiffEditor/")) return "monaco-editor-diff-widget";
if (parts[0] === "editor") return `monaco-editor-${parts[1]}`;
return `monaco-${parts[0]}`;
},
},
},
},
server: { host: "127.0.0.1", proxy: { "/api": "http://127.0.0.1:8088", "/dsh": { target: "http://127.0.0.1:8088", ws: true } } },
});