Files
dsh-web/web/src/Images.tsx
T

125 lines
9.9 KiB
TypeScript

import { useState, type FormEvent } from 'react';
import { api, normalBuildAllowed, 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);
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><BuildProvenance build={logs.data}/>{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 [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 () => {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}{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>
<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>}<BuildProvenance build={b}/></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>
</>;
}