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

185 lines
12 KiB
JavaScript
Raw Normal View History

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));
}