feat: initialize XAS test console with native execution
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
import {api, el, button, link, badge, note, empty, loading, select, heading, kv, errorBox, dialog, date, bytes, enc, runUrl, active} from './ui.js';
|
||||
import {appendLog} from './core.js';
|
||||
import {isNative, nativeRunPanel} from './native-ui.js';
|
||||
|
||||
export function runTable(items) {
|
||||
return el('div', {class: 'table-wrap'}, el('table', {},
|
||||
el('thead', {}, el('tr', {}, ['脚本 / 运行编号', '状态', '版本', '创建时间', '退出码', ''].map(title => el('th', {scope: 'col'}, title)))),
|
||||
el('tbody', {}, items.map(run => el('tr', {class: 'run-row'},
|
||||
el('td', {}, el('span', {class: 'run-name'}, run.script_name), el('div', {class: 'source-info mono'}, run.id)),
|
||||
el('td', {}, badge(run.state), run.synthetic ? badge('synthetic', '模拟验收') : null),
|
||||
el('td', {class: 'mono'}, `v${run.version}`), el('td', {}, date(run.created_at)),
|
||||
el('td', {class: 'mono'}, run.exit_code ?? '—'), el('td', {}, el('a', {
|
||||
href: `#/runs/${enc(run.id)}`, class: 'button detail-link run-open',
|
||||
'aria-label': `查看运行详情:${run.script_name},${run.id}`}, '查看详情 →')))))));
|
||||
}
|
||||
|
||||
export async function runList(ctx, scriptId = '') {
|
||||
const status = select('运行状态', [['', '全部状态'], ['running', '运行中'], ['queued', '排队中'], ['succeeded', '进程成功'], ['failed', '失败'], ['cancelled', '已取消'], ['timed_out', '已超时'], ['interrupted', '已中断']]);
|
||||
const origin = select('记录类型', [['', '全部记录'], ['real', '普通脚本运行'], ['synthetic', '模拟验收']]);
|
||||
const results = el('div', {}, loading('正在读取运行记录…'));
|
||||
const feedback = el('div', {'aria-live': 'polite'});
|
||||
const updated = el('span', {class: 'muted'});
|
||||
let items = [];
|
||||
let busy = false;
|
||||
const refresh = button('↻ 刷新', () => update());
|
||||
ctx.show(heading('运行记录', scriptId ? '查看此脚本最近 100 次运行的状态、日志与结果。' : '查看最近 100 次运行。每次执行使用固定版本和独立结果目录。',
|
||||
[scriptId ? link('查看全部记录', '#/runs', 'button') : null, refresh]),
|
||||
el('div', {class: 'filters'}, status, origin, scriptId ? link('返回脚本', `#/scripts/${enc(scriptId)}`, 'button small') : null),
|
||||
el('div', {class: 'section-title'}, el('span', {class: 'muted'}, '退出码 0 表示进程正常结束;业务结论以脚本报告为准。'), updated), feedback, results);
|
||||
const render = () => {
|
||||
const filtered = items.filter(run => (!status.value || run.state === status.value)
|
||||
&& (!origin.value || Boolean(run.synthetic) === (origin.value === 'synthetic')));
|
||||
results.replaceChildren(filtered.length ? runTable(filtered) : empty('没有符合条件的运行记录', '调整筛选条件,或从脚本详情开始一次运行。'));
|
||||
};
|
||||
async function update() {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
refresh.disabled = true;
|
||||
try {
|
||||
const data = await api(`/api/runs?limit=100${scriptId ? `&script_id=${enc(scriptId)}` : ''}`, {}, ctx.signal);
|
||||
if (!ctx.alive) return;
|
||||
items = data.items;
|
||||
render();
|
||||
feedback.replaceChildren();
|
||||
updated.textContent = `更新于 ${new Date().toLocaleTimeString('zh-CN', {hour12: false})}`;
|
||||
} catch (error) {
|
||||
if (ctx.alive) { feedback.replaceChildren(errorBox(error, update)); if (!items.length) results.replaceChildren(); }
|
||||
} finally { busy = false; refresh.disabled = false; }
|
||||
}
|
||||
[status, origin].forEach(control => control.addEventListener('change', render));
|
||||
ctx.repeat(update, 6000);
|
||||
}
|
||||
|
||||
export async function runDetail(ctx, id) {
|
||||
const navigation = () => el('div', {class: 'detail-navigation'}, link('← 返回运行记录', '#/runs', 'button back-button'));
|
||||
ctx.show(navigation(), loading('正在读取运行状态…'));
|
||||
let run;
|
||||
try { run = await api(runUrl(id), {}, ctx.signal); }
|
||||
catch (error) { if (ctx.alive) ctx.show(navigation(), heading('运行详情', '运行记录读取失败。'), errorBox(error, () => runDetail(ctx, id))); return; }
|
||||
if (!ctx.alive) return;
|
||||
let offset = 0;
|
||||
let logText = '';
|
||||
let retainedTrimmed = false;
|
||||
let serverTruncated = false;
|
||||
let busy = false;
|
||||
let logDone = false;
|
||||
let cancelling = false;
|
||||
let cancelRequested = false;
|
||||
let mutation = 0;
|
||||
let artifactsKey = '';
|
||||
const feedback = el('div', {'aria-live': 'polite'});
|
||||
const execution = nativeRunPanel(ctx, run);
|
||||
const status = el('div', {class: 'actions'});
|
||||
const metadata = el('div', {class: 'run-summary'});
|
||||
const message = el('div');
|
||||
const log = el('pre', {class: 'log', tabindex: '0', 'aria-label': '运行标准输出与错误日志'}, '正在读取日志…');
|
||||
const logStatus = el('span', {}, '等待日志');
|
||||
const logHint = el('span', {}, '标准输出 / 标准错误');
|
||||
const follow = el('input', {type: 'checkbox', checked: true});
|
||||
const files = el('div', {class: 'artifact-list'});
|
||||
const retry = button('立即刷新', () => tick(), 'small');
|
||||
const cancel = button('取消运行', async () => {
|
||||
if (cancelling || !active(run)) return;
|
||||
cancelling = true;
|
||||
execution.update(run, {blocked: true});
|
||||
mutation++;
|
||||
cancel.disabled = true;
|
||||
cancel.textContent = '正在取消…';
|
||||
try {
|
||||
run = await api(`${runUrl(id)}/cancel`, {method: 'POST'}, ctx.signal);
|
||||
cancelRequested = true;
|
||||
if (ctx.alive) { feedback.replaceChildren(); renderRun(); }
|
||||
} catch (error) { if (ctx.alive) feedback.replaceChildren(errorBox(error)); }
|
||||
finally { cancelling = false; if (ctx.alive) { cancel.textContent = '取消运行'; cancel.disabled = !active(run); execution.update(run, {blocked: cancelRequested}); } }
|
||||
}, 'danger');
|
||||
ctx.show(navigation(), el('div', {class: 'breadcrumbs'}, link('运行记录', '#/runs'), ' / ', id),
|
||||
heading(run.script_name, `运行 ${id}`, [link('查看测试项目', `#/scripts/${enc(run.script_id)}`, 'button back-button'), cancel]),
|
||||
feedback, el('section', {class: 'panel'}, el('div', {class: 'panel-header'}, el('h2', {}, '运行概况'), status), metadata, message,
|
||||
kv([['脚本版本', `v${run.version}`], ['源码摘要', run.sha256], ['创建时间', date(run.created_at)], ['请求来源', run.source_ip || '—']]),
|
||||
el('details', {class: 'disclosure'}, el('summary', {}, '查看本次运行配置快照'), el('pre', {}, JSON.stringify(run.config, null, 2)))),
|
||||
execution.node,
|
||||
el('section', {class: 'panel log-panel'}, el('div', {class: 'log-toolbar'}, el('h2', {}, '实时日志'),
|
||||
el('div', {class: 'actions'}, el('label', {}, follow, '跟随最新日志'), retry, link('下载完整日志', `${runUrl(id)}/log/download`, 'button small'))),
|
||||
log, el('div', {class: 'log-hint'}, logStatus, logHint)),
|
||||
el('section', {class: 'panel'}, el('div', {class: 'panel-header'}, el('h2', {}, '结果文件')), files),
|
||||
note(isNative(run) ? '取消与超时仅处理本次原环境任务的本机进程。原脚本通过 Docker/tmux 启动的任务可能仍在运行;请核对收尾记录,不能据此认定原模型已恢复。'
|
||||
: '取消与超时只终止本次执行容器,不能证明外部模型服务已停止推理。'));
|
||||
|
||||
function renderRun() {
|
||||
execution.update(run, {blocked: cancelling || cancelRequested});
|
||||
status.replaceChildren(badge(run.state));
|
||||
if (run.synthetic) status.append(badge('synthetic', '模拟验收'));
|
||||
cancel.hidden = !active(run);
|
||||
cancel.disabled = cancelling || !active(run);
|
||||
metadata.replaceChildren(...[
|
||||
['开始时间', date(run.started_at)], ['结束时间', date(run.finished_at)],
|
||||
['退出码', run.exit_code ?? '—'], ['执行类型', run.synthetic ? '模拟验收' : '脚本测试']
|
||||
].map(([label, value]) => el('div', {}, el('small', {}, label), el('strong', {}, value))));
|
||||
message.replaceChildren();
|
||||
if (run.message) message.append(note(run.message, ['failed', 'timed_out', 'interrupted'].includes(run.state) ? 'danger' : 'info'));
|
||||
if (run.state === 'succeeded') message.append(note('进程正常结束。是否达到测试目标,请查看脚本产生的报告。', 'success'));
|
||||
if (run.synthetic) message.append(note('这是模拟验收记录,不代表真实模型服务或测试业务通过。', 'info'));
|
||||
const key = JSON.stringify([run.state, run.artifacts]);
|
||||
if (key !== artifactsKey) {
|
||||
artifactsKey = key;
|
||||
files.replaceChildren(...((run.artifacts || []).length ? run.artifacts.map(artifact => artifactRow(ctx, run, artifact)) :
|
||||
[empty(active(run) ? '结果文件等待归档' : '本次运行没有归档文件', active(run) ? '执行结束后,登记的结果文件会出现在这里。' : '脚本可能没有生成文件,或输出路径未登记;请查看日志和运行配置。')]));
|
||||
}
|
||||
}
|
||||
|
||||
async function tick() {
|
||||
if (busy || cancelling) return;
|
||||
if (!active(run) && logDone) return false;
|
||||
busy = true;
|
||||
retry.disabled = true;
|
||||
const ticket = mutation;
|
||||
try {
|
||||
const [current, chunk] = await Promise.all([
|
||||
api(runUrl(id), {}, ctx.signal), api(`${runUrl(id)}/log?offset=${offset}`, {}, ctx.signal)
|
||||
]);
|
||||
if (!ctx.alive) return;
|
||||
if (ticket !== mutation) return true;
|
||||
run = current;
|
||||
if (!Number.isSafeInteger(chunk.next_offset) || chunk.next_offset < offset) throw new Error('日志游标异常,请重新打开此运行。');
|
||||
if (chunk.next_offset > offset) {
|
||||
const retained = appendLog(logText, chunk.text);
|
||||
logText = retained.text;
|
||||
retainedTrimmed ||= retained.trimmed;
|
||||
}
|
||||
offset = chunk.next_offset;
|
||||
logDone = chunk.done;
|
||||
serverTruncated ||= chunk.truncated;
|
||||
log.textContent = logText || (active(run) ? '等待脚本输出…' : '本次运行没有标准输出或标准错误。');
|
||||
if (follow.checked) log.scrollTop = log.scrollHeight;
|
||||
logStatus.textContent = active(run) ? (run.state === 'queued' ? '已排队 · 等待执行器' : '持续更新中 · 每 2.5 秒刷新') : logDone ? '日志读取完成' : '正在读取剩余日志…';
|
||||
logHint.textContent = serverTruncated ? '日志达到服务端限制,后续输出可能未保留。' : retainedTrimmed ? '页面仅显示最近 240,000 字符;完整内容请下载日志。' : `已读取 ${bytes(offset)}`;
|
||||
feedback.replaceChildren();
|
||||
renderRun();
|
||||
return active(run) || !logDone;
|
||||
} catch (error) {
|
||||
if (ctx.alive) { execution.update(run, {blocked: true}); feedback.replaceChildren(errorBox(error, tick)); logStatus.textContent = '连接暂时中断,保留已读取日志并继续重试'; }
|
||||
} finally { busy = false; retry.disabled = false; }
|
||||
}
|
||||
follow.addEventListener('change', () => { if (follow.checked) log.scrollTop = log.scrollHeight; });
|
||||
renderRun();
|
||||
ctx.repeat(tick);
|
||||
}
|
||||
|
||||
function artifactRow(ctx, run, artifact) {
|
||||
const url = `${runUrl(run.id)}/artifacts/${enc(artifact.id)}`;
|
||||
const actions = [link('下载', url, 'button small')];
|
||||
if (['text', 'json'].includes(artifact.kind)) actions.unshift(button('预览', async () => {
|
||||
const content = el('div', {}, loading('正在读取文件…'));
|
||||
const panel = dialog(artifact.name, content);
|
||||
try {
|
||||
const preview = await api(`${url}/preview`, {}, ctx.signal);
|
||||
if (!ctx.alive || !panel.open) return;
|
||||
content.replaceChildren(el('pre', {class: 'code', tabindex: '0'}, preview.text || '(空文件)'));
|
||||
if (preview.truncated) content.prepend(note('仅显示文件开头部分,请下载查看完整内容。'));
|
||||
} catch (error) { if (ctx.alive && panel.open) content.replaceChildren(errorBox(error)); }
|
||||
}, 'small'));
|
||||
return el('div', {class: 'artifact'}, el('div', {class: 'artifact-name'}, artifact.name,
|
||||
el('small', {}, `${bytes(artifact.size)} · ${artifact.kind === 'json' ? 'JSON' : artifact.kind === 'text' ? '文本' : '文件'}`)), el('div', {class: 'actions'}, actions));
|
||||
}
|
||||
Reference in New Issue
Block a user