Files
xas-test-library/console/static/runs.js
T

185 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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));
}