276 lines
19 KiB
JavaScript
276 lines
19 KiB
JavaScript
|
|
import {api, el, button, link, badge, note, empty, loading, field, input, textarea, select, heading, kv, errorBox, toast, dialog, date, enc, scriptUrl} from './ui.js';
|
|||
|
|
import {parseArgs, parseLines, requestId} from './core.js';
|
|||
|
|
import {runTable} from './runs.js';
|
|||
|
|
import {relatedFiles} from './related-files.js';
|
|||
|
|
import {isNative, maxTimeout, nativeParameterForm, nativeScriptInfo, preflightNativeRun} from './native-ui.js';
|
|||
|
|
|
|||
|
|
const pendingRuns = new Map();
|
|||
|
|
function pendingRequest(id) {
|
|||
|
|
if (pendingRuns.has(id)) return pendingRuns.get(id);
|
|||
|
|
let value;
|
|||
|
|
try { value = sessionStorage.getItem(`xas.pending-run.${id}`); } catch { /* Private browser settings can disable storage. */ }
|
|||
|
|
if (!value || !/^[0-9a-f-]{36}$/.test(value)) value = requestId();
|
|||
|
|
pendingRuns.set(id, value);
|
|||
|
|
try { sessionStorage.setItem(`xas.pending-run.${id}`, value); } catch { /* The in-memory request still supports safe retry. */ }
|
|||
|
|
return value;
|
|||
|
|
}
|
|||
|
|
function clearRequest(id) {
|
|||
|
|
pendingRuns.delete(id);
|
|||
|
|
try { sessionStorage.removeItem(`xas.pending-run.${id}`); } catch { /* Storage may be unavailable. */ }
|
|||
|
|
}
|
|||
|
|
const scriptState = script => !script.enabled && script.status === 'ready' ? 'disabled' : script.status;
|
|||
|
|
const scriptBadge = script => badge(scriptState(script), scriptState(script) === 'disabled' ? '未启用' : undefined);
|
|||
|
|
const sourceLabel = script => script.kind === 'managed' ? '托管脚本' : '外部原文件';
|
|||
|
|
const profileLabel = script => isNative(script) ? '服务器原环境' : script.config.profile === 'legacy_smoke' ? '旧版 Smoke 兼容环境' : '标准运行环境';
|
|||
|
|
const typeLabel = script => script.category === 'example' ? '验收示例' : script.test_kind === 'suite' ? '测试套件' : '单项测试';
|
|||
|
|
const groupLabels = new Map([
|
|||
|
|
['uploaded', '上传的脚本'], ['company-gpu-suite', '公司 GPU 测试套件'],
|
|||
|
|
['dsv41-trial-20261010', 'DeepSeek 试验脚本(10/10)'], ['model-trials-20261011', '模型试验脚本(10/11)']
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
export async function scriptList(ctx) {
|
|||
|
|
ctx.show(heading('测试项目', '按实际测试行为管理,一项对应一次完整测试。'), loading());
|
|||
|
|
try {
|
|||
|
|
const {items} = await api('/api/scripts?include_examples=true', {}, ctx.signal);
|
|||
|
|
if (!ctx.alive) return;
|
|||
|
|
const search = input({type: 'search', placeholder: '搜索测试名称、用途或入口', 'aria-label': '搜索测试'});
|
|||
|
|
const group = select('来源目录', [['', '全部来源'], ...Array.from(new Set(items.map(item => item.source_group || '未分组'))).sort().map(name => [name, groupLabels.get(name) || name])]);
|
|||
|
|
const status = select('脚本状态', [['', '全部状态'], ['ready', '可运行'], ['needs_configuration', '待配置'], ['missing', '源文件缺失'], ['disabled', '未启用']]);
|
|||
|
|
const kind = select('测试类型', [['', '全部类型'], ['suite', '测试套件'], ['test', '单项测试']]);
|
|||
|
|
const purpose = select('项目用途', [['test', '业务测试'], ['example', '验收示例'], ['', '全部项目']], 'test');
|
|||
|
|
const results = el('div');
|
|||
|
|
const count = el('span', {class: 'muted', role: 'status'});
|
|||
|
|
const discover = button('↻ 刷新来源', async () => {
|
|||
|
|
discover.disabled = true;
|
|||
|
|
try {
|
|||
|
|
const result = await api('/api/discover', {method: 'POST'}, ctx.signal);
|
|||
|
|
if (ctx.alive) {
|
|||
|
|
toast(`目录已刷新:${result.tests} 项测试。内部文件更新不会自动新增测试项目。`);
|
|||
|
|
await scriptList(ctx);
|
|||
|
|
}
|
|||
|
|
} catch (error) { if (ctx.alive) { toast(error.message); discover.disabled = false; } }
|
|||
|
|
});
|
|||
|
|
ctx.show(heading('测试项目', '一项对应一次测试;套件内部的多个脚本统一在详情中查看。', [discover, button('+ 新增测试', () => uploadDialog(ctx), 'primary')]),
|
|||
|
|
el('div', {class: 'filters'}, el('div', {class: 'search'}, search), purpose, kind, status, group),
|
|||
|
|
el('div', {class: 'section-title'}, el('span', {class: 'muted'}, '按测试行为组织 · 验收示例默认隐藏'), count), results);
|
|||
|
|
const render = () => {
|
|||
|
|
const term = search.value.trim().toLowerCase();
|
|||
|
|
const filtered = items.filter(item => (!group.value || (item.source_group || '未分组') === group.value)
|
|||
|
|
&& (!status.value || scriptState(item) === status.value) && (!kind.value || item.test_kind === kind.value)
|
|||
|
|
&& (!purpose.value || item.category === purpose.value)
|
|||
|
|
&& (!term || [item.name, item.description, item.source_path].some(value => (value || '').toLowerCase().includes(term))));
|
|||
|
|
count.textContent = `${filtered.length} 项测试`;
|
|||
|
|
results.replaceChildren();
|
|||
|
|
if (!filtered.length) { results.append(empty(items.length ? '没有匹配的测试项目' : '从第一个测试开始', items.length ? '调整关键词或筛选条件。' : '上传一个测试的入口脚本,创建测试项目。')); return; }
|
|||
|
|
const groups = new Map();
|
|||
|
|
for (const item of filtered) {
|
|||
|
|
const name = item.category === 'example' ? '控制台验收示例' : item.test_kind === 'suite' ? '测试套件' : '单项测试';
|
|||
|
|
if (!groups.has(name)) groups.set(name, []);
|
|||
|
|
groups.get(name).push(item);
|
|||
|
|
}
|
|||
|
|
for (const [name, scripts] of groups) results.append(el('section', {},
|
|||
|
|
el('div', {class: 'section-title'}, el('h2', {}, name, el('span', {class: 'count'}, `${scripts.length} 项`))),
|
|||
|
|
el('div', {class: 'catalog-grid'}, scripts.map(scriptCard))));
|
|||
|
|
};
|
|||
|
|
[search, group, status, kind, purpose].forEach(control => control.addEventListener('input', render));
|
|||
|
|
render();
|
|||
|
|
} catch (error) { if (ctx.alive) ctx.show(heading('测试项目', '查看、配置与运行测试。'), errorBox(error, () => scriptList(ctx))); }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function scriptCard(script) {
|
|||
|
|
return el('a', {class: 'script-card', href: `#/scripts/${enc(script.id)}`, 'aria-label': script.name},
|
|||
|
|
el('div', {class: 'card-top'}, el('span', {class: `language ${script.language}`}, typeLabel(script)), scriptBadge(script)),
|
|||
|
|
el('h3', {}, script.name),
|
|||
|
|
el('p', {class: 'script-description'}, script.description || '尚未填写说明。'),
|
|||
|
|
el('p', {class: 'card-source', title: script.source_path}, script.source_path),
|
|||
|
|
script.reason ? el('p', {class: 'card-reason'}, script.reason) : null,
|
|||
|
|
el('div', {class: 'card-bottom'}, el('span', {}, `${script.file_count} 个相关文件 · v${script.version}`), el('span', {class: 'button detail-link'}, '查看项目详情 →')));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function uploadDialog(ctx, script) {
|
|||
|
|
const file = input({type: 'file', accept: '.py,.sh', required: '', name: 'file'});
|
|||
|
|
const name = input({name: 'name', required: '', maxlength: 120, placeholder: '例如:模型接口连通性检查'});
|
|||
|
|
const description = textarea({name: 'description', placeholder: '说明测试目标、运行条件和输出结果'});
|
|||
|
|
const feedback = el('div', {'aria-live': 'polite'});
|
|||
|
|
const submit = el('button', {class: 'button primary', type: 'submit'}, script ? '上传新版本' : '创建脚本');
|
|||
|
|
const form = el('form', {}, note(script ? '新版本单独保存,不覆盖历史源码。后续运行使用最新版本,已有运行记录保持原版本。' : '支持 Python 和 Shell。脚本代码只读;后续修改请上传新版本。'),
|
|||
|
|
field('脚本文件', file, '选择 .py 或 .sh 文件。'),
|
|||
|
|
script ? null : field('脚本名称', name), script ? null : field('说明', description), feedback,
|
|||
|
|
el('div', {class: 'form-footer'}, submit));
|
|||
|
|
const panel = dialog(script ? `上传新版本 · ${script.name}` : '上传新脚本', form);
|
|||
|
|
form.addEventListener('submit', async event => {
|
|||
|
|
event.preventDefault();
|
|||
|
|
if (submit.disabled || !form.reportValidity()) return;
|
|||
|
|
const data = new FormData(form);
|
|||
|
|
submit.disabled = true;
|
|||
|
|
submit.textContent = '正在上传…';
|
|||
|
|
feedback.replaceChildren();
|
|||
|
|
try {
|
|||
|
|
const saved = await api(script ? `${scriptUrl(script.id)}/versions` : '/api/scripts/upload', {method: 'POST', body: data}, ctx.signal);
|
|||
|
|
if (!ctx.alive) return;
|
|||
|
|
panel.close();
|
|||
|
|
toast(script ? '新版本已保存。' : '脚本已创建。');
|
|||
|
|
if (location.hash === `#/scripts/${enc(saved.id)}`) await scriptDetail(ctx, saved.id);
|
|||
|
|
else location.hash = `/scripts/${enc(saved.id)}`;
|
|||
|
|
} catch (error) {
|
|||
|
|
if (ctx.alive) feedback.replaceChildren(errorBox(error), note('如果连接中断,请先关闭窗口并刷新脚本库,核对是否已保存,再决定重新上传。'));
|
|||
|
|
} finally { submit.disabled = false; submit.textContent = script ? '上传新版本' : '创建脚本'; }
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export async function scriptDetail(ctx, id) {
|
|||
|
|
const navigation = () => el('div', {class: 'detail-navigation'}, link('← 返回测试项目', '#/scripts', 'button back-button'));
|
|||
|
|
ctx.show(navigation(), loading('正在读取脚本与版本…'));
|
|||
|
|
try {
|
|||
|
|
const script = await api(scriptUrl(id), {}, ctx.signal);
|
|||
|
|
if (!ctx.alive) return;
|
|||
|
|
if (!script.is_test) {
|
|||
|
|
const source = el('section', {class: 'panel'});
|
|||
|
|
ctx.show(navigation(), heading(script.name, '这是测试内部的源码文件。'), note('请从测试项目的执行入口配置和运行。'), source);
|
|||
|
|
sourcePanel(ctx, source, script);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
let dirty = false;
|
|||
|
|
const feedback = el('div', {'aria-live': 'polite'});
|
|||
|
|
const start = button('▶ 开始运行', async () => {
|
|||
|
|
if (start.disabled) return;
|
|||
|
|
start.disabled = true;
|
|||
|
|
start.textContent = '正在提交…';
|
|||
|
|
feedback.replaceChildren();
|
|||
|
|
let prepared;
|
|||
|
|
try {
|
|||
|
|
if (isNative(script)) {
|
|||
|
|
start.textContent = '正在预检…';
|
|||
|
|
prepared = await preflightNativeRun(ctx, script);
|
|||
|
|
if (!prepared || !ctx.alive || dirty) return;
|
|||
|
|
start.textContent = '正在提交…';
|
|||
|
|
}
|
|||
|
|
const run = await api(`${scriptUrl(id)}/runs`, {method: 'POST', body: {request_id: pendingRequest(id), ...prepared?.payload}}, ctx.signal);
|
|||
|
|
clearRequest(id);
|
|||
|
|
if (ctx.alive) location.hash = `/runs/${enc(run.id)}`;
|
|||
|
|
} catch (error) {
|
|||
|
|
if (ctx.alive) feedback.replaceChildren(errorBox(error), note(isNative(script)
|
|||
|
|
? '再次点击会重新预检并重新确认影响,同时复用本次请求编号核对原记录,避免重复排队。'
|
|||
|
|
: '再次点击会复用本次请求编号,核对原运行记录,避免重复排队。'));
|
|||
|
|
} finally {
|
|||
|
|
prepared?.close();
|
|||
|
|
if (ctx.alive) { start.disabled = dirty || !script.enabled || script.status !== 'ready'; start.textContent = '▶ 开始运行'; }
|
|||
|
|
}
|
|||
|
|
}, 'primary');
|
|||
|
|
start.disabled = !script.enabled || script.status !== 'ready';
|
|||
|
|
const actions = [link('运行记录', `#/runs?script_id=${enc(id)}`, 'button')];
|
|||
|
|
if (script.kind === 'managed') actions.push(button('上传新版本', () => uploadDialog(ctx, script)));
|
|||
|
|
actions.push(start);
|
|||
|
|
const source = el('section', {class: 'panel'});
|
|||
|
|
const history = el('section', {class: 'panel'}, el('div', {class: 'panel-header'}, el('h2', {}, '近期运行')), loading());
|
|||
|
|
ctx.show(navigation(), el('div', {class: 'breadcrumbs'}, link('测试项目', '#/scripts'), ' / ', script.name),
|
|||
|
|
heading(script.name, script.description || '查看源代码、配置参数并开始一次独立运行。', actions),
|
|||
|
|
feedback,
|
|||
|
|
script.reason ? note(script.reason, script.status === 'missing' ? 'danger' : '') : null,
|
|||
|
|
!script.enabled ? note('当前脚本未启用。确认运行参数和环境要求后,在配置中启用并保存。') : null,
|
|||
|
|
nativeScriptInfo(script),
|
|||
|
|
el('div', {class: 'detail-grid'}, source, configForm(ctx, script, () => {
|
|||
|
|
dirty = true;
|
|||
|
|
start.disabled = true;
|
|||
|
|
start.title = '请先保存修改后的运行配置。';
|
|||
|
|
})),
|
|||
|
|
relatedFiles(ctx, script), el('div', {class: 'section-title'}, el('h2', {}, '执行记录')), history);
|
|||
|
|
sourcePanel(ctx, source, script);
|
|||
|
|
const loadHistory = async () => {
|
|||
|
|
try {
|
|||
|
|
const {items} = await api(`/api/runs?script_id=${enc(id)}&limit=10`, {}, ctx.signal);
|
|||
|
|
if (ctx.alive) history.replaceChildren(items.length ? runTable(items) : empty('还没有运行记录', '首次运行后,可在这里回看状态、日志和结果文件。'));
|
|||
|
|
} catch (error) { if (ctx.alive) history.replaceChildren(errorBox(error, loadHistory)); }
|
|||
|
|
};
|
|||
|
|
await loadHistory();
|
|||
|
|
} catch (error) { if (ctx.alive) ctx.show(navigation(), heading('脚本详情', '读取脚本信息失败。'), errorBox(error, () => scriptDetail(ctx, id))); }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function sourcePanel(ctx, target, script) {
|
|||
|
|
const versions = script.versions?.length ? script.versions : [{version: script.version}];
|
|||
|
|
const version = select('查看源码版本', versions.map(item => [item.version, `版本 ${item.version}${String(item.version) === String(script.version) ? ' · 当前' : ''}`]), script.version);
|
|||
|
|
const download = link('下载源码', '', 'button small');
|
|||
|
|
const code = el('pre', {class: 'code', tabindex: '0', 'aria-label': '脚本源代码,只读'}, '正在读取源码…');
|
|||
|
|
const info = el('div', {class: 'source-info mono'});
|
|||
|
|
const feedback = el('div');
|
|||
|
|
target.append(el('div', {class: 'panel-header'}, el('h2', {}, script.is_test ? '执行入口(代码只读)' : '内部源码(只读)'), scriptBadge(script)),
|
|||
|
|
kv([['来源类型', sourceLabel(script)], ['源文件', script.source_path], ['运行环境', profileLabel(script)], ['更新时间', date(script.updated_at)]]),
|
|||
|
|
el('div', {class: 'source-header'}, version, download), info, feedback, code,
|
|||
|
|
el('p', {class: 'source-info'}, '代码只读。查看历史版本不改变运行版本;“开始运行”使用当前已保存版本与配置。'));
|
|||
|
|
let generation = 0;
|
|||
|
|
const load = async () => {
|
|||
|
|
const ticket = ++generation;
|
|||
|
|
code.textContent = '正在读取源码…';
|
|||
|
|
download.hidden = true;
|
|||
|
|
info.textContent = '';
|
|||
|
|
feedback.replaceChildren();
|
|||
|
|
try {
|
|||
|
|
const data = await api(`${scriptUrl(script.id)}/code?version=${enc(version.value)}`, {}, ctx.signal);
|
|||
|
|
if (!ctx.alive || ticket !== generation) return;
|
|||
|
|
code.textContent = data.code || '(空文件)';
|
|||
|
|
info.textContent = `${data.filename} · v${version.value}\nSHA-256 ${data.sha256}`;
|
|||
|
|
download.href = `${scriptUrl(script.id)}/download?version=${enc(version.value)}`;
|
|||
|
|
download.hidden = Boolean(data.redacted);
|
|||
|
|
if (data.redacted) feedback.append(note('源码中包含敏感内容,预览已脱敏,下载不可用。'));
|
|||
|
|
if (data.truncated) feedback.append(note(data.redacted ? '文件较大,当前仅显示部分已脱敏源码。' : '文件较大,当前仅显示部分源码。可下载完整文件。'));
|
|||
|
|
} catch (error) {
|
|||
|
|
if (ctx.alive && ticket === generation) { code.textContent = ''; feedback.replaceChildren(errorBox(error, load)); }
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
version.addEventListener('change', load);
|
|||
|
|
load();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function configForm(ctx, script, onDirty) {
|
|||
|
|
const config = script.config;
|
|||
|
|
const native = isNative(script);
|
|||
|
|
const limit = maxTimeout(script);
|
|||
|
|
const parameters = native ? nativeParameterForm(script) : null;
|
|||
|
|
const name = input({value: script.name, required: '', maxlength: 120});
|
|||
|
|
const description = textarea({value: script.description || ''});
|
|||
|
|
const args = textarea({value: JSON.stringify(config.args, null, 2), rows: 4, class: 'input mono', spellcheck: 'false'});
|
|||
|
|
const cwd = input({value: native ? script.execution.cwd || config.cwd || '' : config.cwd || '', placeholder: script.kind === 'managed' ? '留空:使用本次运行目录' : '原脚本工作目录'});
|
|||
|
|
const outputs = textarea({value: (config.output_paths || []).join('\n'), class: 'input mono', spellcheck: 'false', placeholder: '每行一个日志或结果路径'});
|
|||
|
|
if (native) [args, cwd, outputs].forEach(control => control.setAttribute('readonly', ''));
|
|||
|
|
const timeout = input({type: 'number', value: config.timeout_seconds, required: '', min: '5', max: String(limit), step: '1'});
|
|||
|
|
const enabled = input({type: 'checkbox', checked: script.enabled});
|
|||
|
|
const feedback = el('div', {'aria-live': 'polite'});
|
|||
|
|
const submit = el('button', {type: 'submit', class: 'button primary'}, '保存配置');
|
|||
|
|
const form = el('form', {class: 'panel'}, el('div', {class: 'panel-header'}, el('h2', {}, '运行配置')),
|
|||
|
|
field('脚本名称', name), field('说明', description), parameters?.node,
|
|||
|
|
field(native ? '原入口参数(只读)' : '参数', args, native ? '参数由原环境方案计算;可调整的选项在上方填写,实际命令以本次预检为准。' : 'JSON 字符串数组,或每行一个参数;不会交给 shell 拼接。'),
|
|||
|
|
field(native ? '原工作目录(只读)' : '工作目录', cwd, native ? '固定使用登记的原目录。' : script.kind === 'managed' ? '托管脚本留空。可通过 XAS_RUN_DIR 获取本次结果目录。' : '必须位于已登记的来源目录内。'),
|
|||
|
|
field(native ? '原输出规则(只读)' : '归档输出路径', outputs, native ? '由固定方案管理;运行前预检显示本次输出规则,保留原脚本的输出行为。' : script.kind === 'managed' ? '托管脚本留空;本次结果目录统一归档,.log 文件的新增内容进入实时日志。' : '登记该来源 logs/ 或 results/ 内已存在的文件或目录,每行一项。.log 新增内容进入实时日志,其他结果结束后归档。'),
|
|||
|
|
field('超时时间(秒)', timeout, `填写 5–${limit} 秒之间的整数。${native ? '原环境方案最长支持 72 小时。' : ''}`), el('label', {class: 'checkbox'}, enabled, '启用此脚本'),
|
|||
|
|
native ? note('复用原账号、解释器、目录与已有配置,不修改原项目文件。实际运行按原脚本逻辑产生输出与影响。')
|
|||
|
|
: note(`${profileLabel(script)}。提供 Python 标准库、Bash 和 curl;额外依赖不会自动安装。`),
|
|||
|
|
!native && script.config.profile === 'legacy_smoke' ? note('兼容配置由服务端管理,只读。代理请求有固定上限,不能在此调整服务或模型。', 'info') : null,
|
|||
|
|
feedback, el('div', {class: 'form-footer'}, submit));
|
|||
|
|
form.addEventListener('input', () => {
|
|||
|
|
onDirty();
|
|||
|
|
feedback.replaceChildren(note('配置有未保存的修改。保存后才能开始运行。'));
|
|||
|
|
});
|
|||
|
|
form.addEventListener('submit', async event => {
|
|||
|
|
event.preventDefault();
|
|||
|
|
if (submit.disabled || !form.reportValidity()) return;
|
|||
|
|
feedback.replaceChildren();
|
|||
|
|
let payload;
|
|||
|
|
try {
|
|||
|
|
const seconds = Number(timeout.value);
|
|||
|
|
if (!Number.isSafeInteger(seconds) || seconds < 5 || seconds > limit) throw new Error(`超时时间必须是 5–${limit} 秒之间的整数。`);
|
|||
|
|
if (!name.value.trim()) throw new Error('请填写脚本名称。');
|
|||
|
|
payload = {name: name.value.trim(), description: description.value, enabled: enabled.checked,
|
|||
|
|
config: native ? {native_options: parameters.read(), timeout_seconds: seconds}
|
|||
|
|
: {args: parseArgs(args.value), cwd: cwd.value.trim(), output_paths: parseLines(outputs.value), timeout_seconds: seconds}};
|
|||
|
|
} catch (error) { feedback.replaceChildren(errorBox(error)); return; }
|
|||
|
|
submit.disabled = true;
|
|||
|
|
submit.textContent = '正在保存…';
|
|||
|
|
try {
|
|||
|
|
await api(scriptUrl(script.id), {method: 'PATCH', body: payload}, ctx.signal);
|
|||
|
|
if (ctx.alive) { toast('配置已保存,后续运行将使用新配置。'); await scriptDetail(ctx, script.id); }
|
|||
|
|
} catch (error) { if (ctx.alive) feedback.replaceChildren(errorBox(error)); }
|
|||
|
|
finally { submit.disabled = false; submit.textContent = '保存配置'; }
|
|||
|
|
});
|
|||
|
|
return form;
|
|||
|
|
}
|