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