feat: initialize XAS test console with native execution
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
import {api, el, note, heading, errorBox, pageContext, bytes, date} from './ui.js';
|
||||
import {scriptList, scriptDetail} from './scripts.js';
|
||||
import {runList, runDetail} from './runs.js';
|
||||
|
||||
const main = document.querySelector('#main');
|
||||
let context;
|
||||
let summaryBusy = false;
|
||||
|
||||
async function summary() {
|
||||
if (summaryBusy || document.visibilityState !== 'visible') return;
|
||||
summaryBusy = true;
|
||||
const health = document.querySelector('#worker-status');
|
||||
const notice = document.querySelector('#global-notice');
|
||||
try {
|
||||
const data = await api('/api/summary');
|
||||
document.querySelector('#nav-count').textContent = data.scripts;
|
||||
document.querySelector('#summary').replaceChildren(...[
|
||||
['测试项目', data.scripts, '按测试行为统计', ''], ['可运行', data.ready, '业务测试已就绪', 'accent'],
|
||||
['进行中', data.active, '排队 / 执行', ''], ['运行记录', data.runs, `归档 ${bytes(data.storage.run_bytes)}`, '']
|
||||
].map(([label, value, hint, style]) => el('div', {class: `stat ${style}`}, el('span', {class: 'stat-label'}, label), el('strong', {class: 'stat-value'}, value), el('span', {class: 'stat-foot'}, hint))));
|
||||
health.replaceChildren(el('span', {class: `worker-light ${data.worker.online ? '' : 'offline'}`, 'aria-hidden': 'true'}), data.worker.online ? '执行器在线 · 单任务队列' : '执行器离线');
|
||||
health.title = `最近心跳:${date(data.worker.last_seen)}`;
|
||||
notice.hidden = data.worker.online;
|
||||
notice.textContent = data.worker.online ? '' : `执行器当前离线,排队任务需要等待执行器恢复。最近心跳:${date(data.worker.last_seen)}。`;
|
||||
} catch (error) {
|
||||
health.replaceChildren(el('span', {class: 'worker-light offline', 'aria-hidden': 'true'}), '连接状态未知');
|
||||
notice.hidden = false;
|
||||
notice.textContent = `工作台状态更新失败:${error.message} 显示的数据可能已过期。`;
|
||||
} finally { summaryBusy = false; }
|
||||
}
|
||||
|
||||
async function route() {
|
||||
context?.dispose();
|
||||
context = pageContext(main);
|
||||
main.setAttribute('aria-busy', 'true');
|
||||
const value = location.hash.slice(1) || '/scripts';
|
||||
const [path, query = ''] = value.split('?');
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
const current = segments[0] || 'scripts';
|
||||
document.querySelectorAll('[data-nav]').forEach(nav => {
|
||||
if (nav.dataset.nav === current) nav.setAttribute('aria-current', 'page');
|
||||
else nav.removeAttribute('aria-current');
|
||||
});
|
||||
document.title = `${current === 'runs' ? '运行记录' : '测试项目'} · XAS 测试控制台`;
|
||||
const ctx = context;
|
||||
summary();
|
||||
try {
|
||||
if (segments.length > 2) throw new Error('页面地址无效。');
|
||||
const id = segments[1] ? decodeURIComponent(segments[1]) : null;
|
||||
if (current === 'scripts') await (id ? scriptDetail(ctx, id) : scriptList(ctx));
|
||||
else if (current === 'runs') await (id ? runDetail(ctx, id) : runList(ctx, new URLSearchParams(query).get('script_id') || ''));
|
||||
else ctx.show(heading('找不到此页面', '请选择左侧的测试项目或运行记录。'), note('页面地址不存在。'));
|
||||
} catch (error) { if (ctx.alive) ctx.show(errorBox(error, route)); }
|
||||
}
|
||||
|
||||
document.querySelector('.skip-link').addEventListener('click', event => { event.preventDefault(); main.focus(); });
|
||||
window.addEventListener('hashchange', route);
|
||||
window.addEventListener('pagehide', () => context?.dispose());
|
||||
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') summary(); });
|
||||
setInterval(summary, 15000);
|
||||
route();
|
||||
@@ -0,0 +1,29 @@
|
||||
export function parseLines(value) {
|
||||
return value.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
export function parseArgs(value) {
|
||||
if (!value.trim().startsWith('[')) return parseLines(value);
|
||||
let args;
|
||||
try { args = JSON.parse(value); }
|
||||
catch { throw new Error('参数 JSON 格式不正确,请填写字符串数组。'); }
|
||||
if (!Array.isArray(args) || args.some(arg => typeof arg !== 'string')) {
|
||||
throw new Error('参数必须是 JSON 字符串数组,或每行一个参数。');
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
export function appendLog(previous, incoming, limit = 240000) {
|
||||
const all = previous + incoming;
|
||||
return {text: all.slice(-limit), trimmed: all.length > limit};
|
||||
}
|
||||
|
||||
export function requestId() {
|
||||
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
|
||||
// LAN HTTP can lack randomUUID; getRandomValues remains available there.
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
||||
bytes[6] = (bytes[6] & 15) | 64;
|
||||
bytes[8] = (bytes[8] & 63) | 128;
|
||||
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light">
|
||||
<meta name="theme-color" content="#14231e">
|
||||
<title>XAS · 测试控制台</title>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
<script type="module" src="/static/app.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main">跳转到内容</a>
|
||||
<aside class="sidebar">
|
||||
<a class="brand" href="#/scripts" aria-label="XAS 测试控制台首页">
|
||||
<span class="brand-mark" aria-hidden="true">X</span>
|
||||
<span><strong>XAS</strong><small>TEST CONSOLE</small></span>
|
||||
</a>
|
||||
<div class="sidebar-label">工作空间</div>
|
||||
<nav aria-label="主导航">
|
||||
<a href="#/scripts" data-nav="scripts"><span aria-hidden="true">▦</span>测试项目<span class="nav-count" id="nav-count">—</span></a>
|
||||
<a href="#/runs" data-nav="runs"><span aria-hidden="true">↗</span>运行记录</a>
|
||||
</nav>
|
||||
<div class="sidebar-note"><span class="dot"></span>公司内网 · 共享工作台<p>从脚本到结果,每次运行都有迹可循。</p></div>
|
||||
<div class="sidebar-bottom">新爱算 <span>测试与验证</span></div>
|
||||
</aside>
|
||||
<div class="workspace">
|
||||
<header class="topbar"><div><span class="eyebrow">ENGINEERING /</span><span>测试控制台</span></div><div id="worker-status" role="status">正在连接执行器…</div></header>
|
||||
<div id="summary" class="summary" aria-label="工作台概览"></div>
|
||||
<div id="global-notice" class="global-notice" role="status" hidden></div>
|
||||
<main id="main" tabindex="-1" aria-busy="true"><div class="loading">正在加载工作台…</div></main>
|
||||
<footer class="page-footer"><span>脚本版本可追溯 · 运行结果独立归档</span><span>时间按本地时区显示</span></footer>
|
||||
</div>
|
||||
<div id="toast" class="toast" role="status" hidden></div>
|
||||
<noscript><p class="noscript">请启用 JavaScript 以使用测试控制台。</p></noscript>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,187 @@
|
||||
import {api, el, button, badge, note, loading, field, input, select, kv, errorBox, dialog, scriptUrl, runUrl} from './ui.js';
|
||||
|
||||
export const isNative = record => record.execution?.mode === 'native';
|
||||
export function maxTimeout(script) {
|
||||
if (!isNative(script)) return 7200;
|
||||
const limit = Number(script.execution.max_timeout_seconds);
|
||||
return Number.isSafeInteger(limit) && limit >= 5 ? Math.min(limit, 259200) : 259200;
|
||||
}
|
||||
|
||||
export function nativeOptions(parameters, values) {
|
||||
const entries = [];
|
||||
for (const parameter of parameters) {
|
||||
const {key, label, type, required, min, max} = parameter;
|
||||
const raw = values[key];
|
||||
const blank = raw === undefined || raw === null || String(raw).trim() === '';
|
||||
if (required && blank) throw new Error(`请填写${label}。`);
|
||||
if (!['integer', 'number', 'select', 'text'].includes(type)) throw new Error(`${label}的参数类型不支持。`);
|
||||
if (blank && type !== 'text') continue;
|
||||
let value = raw ?? '';
|
||||
if (type === 'integer' || type === 'number') {
|
||||
value = Number(raw);
|
||||
if (!Number.isFinite(value)) throw new Error(`${label}必须是有效数字。`);
|
||||
if (type === 'integer' && !Number.isSafeInteger(value)) throw new Error(`${label}必须是整数。`);
|
||||
if ((min !== undefined && value < min) || (max !== undefined && value > max)) throw new Error(`${label}超出允许范围。`);
|
||||
} else if (type === 'select') {
|
||||
const option = (parameter.options || []).find(item => String(item.value) === String(raw));
|
||||
if (!option) throw new Error(`请选择有效的${label}。`);
|
||||
value = option.value;
|
||||
} else value = String(value);
|
||||
entries.push([key, value]);
|
||||
}
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
|
||||
export function nativeParameterForm(script) {
|
||||
const parameters = script.execution.parameters || [];
|
||||
const saved = script.config.native_options || {};
|
||||
const controls = new Map();
|
||||
const node = el('div', {class: 'native-parameters'});
|
||||
for (const parameter of parameters) {
|
||||
const value = Object.hasOwn(saved, parameter.key) ? saved[parameter.key] : parameter.default;
|
||||
let control;
|
||||
if (parameter.type === 'select') {
|
||||
const options = (parameter.options || []).map(item => [item.value, item.label]);
|
||||
if (value === undefined || value === null || value === '' || !options.some(([id]) => String(id) === String(value))) options.unshift(['', '请选择…']);
|
||||
control = select(parameter.label, options, value ?? '');
|
||||
} else {
|
||||
const numeric = ['integer', 'number'].includes(parameter.type);
|
||||
control = input({type: numeric ? 'number' : 'text', value: value ?? '',
|
||||
min: numeric ? parameter.min : undefined, max: numeric ? parameter.max : undefined,
|
||||
step: parameter.type === 'integer' ? '1' : numeric ? 'any' : undefined,
|
||||
autocomplete: 'off', spellcheck: 'false'});
|
||||
}
|
||||
if (parameter.required) control.required = true;
|
||||
controls.set(parameter.key, control);
|
||||
const bounds = parameter.min !== undefined || parameter.max !== undefined
|
||||
? `允许范围:${parameter.min ?? '不限'} 至 ${parameter.max ?? '不限'}。` : '';
|
||||
node.append(field(parameter.label, control, [parameter.help, bounds].filter(Boolean).join(' ')));
|
||||
}
|
||||
if (!parameters.length) node.append(note('此原环境方案使用固定参数。'));
|
||||
return {node, read: () => nativeOptions(parameters, Object.fromEntries(Array.from(controls, ([key, control]) => [key, control.value])))};
|
||||
}
|
||||
|
||||
function impactView(impact) {
|
||||
if (!impact) return null;
|
||||
return el('div', {class: `native-impact ${impact.requires_confirmation ? 'attention' : ''}`},
|
||||
el('h3', {}, impact.title || '运行影响'),
|
||||
(impact.details || []).length ? el('ul', {}, impact.details.map(detail => el('li', {}, detail))) : null,
|
||||
el('p', {class: 'muted'}, impact.requires_confirmation ? '每次启动前须确认本次影响。' : '普通测试,无额外影响确认。'));
|
||||
}
|
||||
|
||||
export function nativeScriptInfo(script) {
|
||||
if (!isNative(script)) return null;
|
||||
const execution = script.execution;
|
||||
return el('section', {class: 'panel native-plan'}, el('div', {class: 'panel-header'}, el('h2', {}, '原环境执行方案'), badge('running', '原环境')),
|
||||
kv([['运行账号', execution.user], ['原工作目录', execution.cwd], ['解释器', execution.interpreter], ['方案编号', execution.profile_id],
|
||||
['交互方式', execution.interactive ? '原菜单 · 可发送菜单选项' : '自动执行']]),
|
||||
execution.description ? el('p', {class: 'native-description'}, execution.description) : null,
|
||||
impactView(execution.impact), note('启动方案已接入,未完成本轮业务验收。', 'info'));
|
||||
}
|
||||
|
||||
export async function preflightNativeRun(ctx, script) {
|
||||
const content = el('div', {}, loading('正在只读预检原环境…'));
|
||||
const panel = dialog('原环境运行预检', content);
|
||||
panel.classList.add('native-dialog');
|
||||
let plan;
|
||||
try {
|
||||
plan = await api(`${scriptUrl(script.id)}/preflight`, {method: 'POST', body: {}}, ctx.signal);
|
||||
if (!ctx.alive || !panel.open) return null;
|
||||
const command = el('pre', {class: 'native-command mono', tabindex: '0', 'aria-label': '本次启动命令'}, plan.command_preview || '未生成启动命令');
|
||||
content.replaceChildren(...[note(plan.ready ? '只读预检通过,原脚本尚未执行。' : '预检未通过,本次不会执行。', plan.ready ? 'success' : 'danger'),
|
||||
kv([['执行模式', plan.mode === 'native' ? '原环境' : plan.mode], ['运行账号', plan.user], ['原工作目录', plan.cwd]]),
|
||||
el('h3', {class: 'native-subtitle'}, '本次启动命令'), command,
|
||||
el('ul', {class: 'preflight-checks'}, (plan.checks || []).map(check => el('li', {},
|
||||
badge(check.ok ? 'ready' : 'failed', check.ok ? '通过' : '未通过'), el('div', {}, el('strong', {}, check.name), el('p', {}, check.message || ''))))),
|
||||
impactView(plan.impact),
|
||||
(plan.output_paths || []).length ? el('details', {class: 'disclosure'}, el('summary', {}, '查看原输出规则'), el('pre', {}, plan.output_paths.join('\n'))) : null,
|
||||
...(plan.notes || []).map(text => note(text, 'info'))].filter(node => node !== null));
|
||||
if (!plan.ready) return null;
|
||||
if (plan.mode !== 'native' || typeof plan.plan_token !== 'string' || !plan.plan_token) throw new Error('预检未返回有效的原环境计划,请关闭窗口后重新预检。');
|
||||
if (!plan.impact?.requires_confirmation) {
|
||||
content.append(note('正在创建本次运行记录…', 'info'));
|
||||
return {payload: {plan_token: plan.plan_token}, close: () => panel.close()};
|
||||
}
|
||||
} catch (error) {
|
||||
if (ctx.alive && panel.open) content.replaceChildren(errorBox(error), note('本次没有提交运行。关闭窗口后可重新预检。'));
|
||||
return null;
|
||||
}
|
||||
return new Promise(resolve => {
|
||||
let acknowledged = false;
|
||||
const confirm = button('确认影响并开始本次运行', () => {
|
||||
if (!ctx.alive || !panel.open || acknowledged) return;
|
||||
acknowledged = true;
|
||||
confirm.disabled = true;
|
||||
panel.close();
|
||||
resolve({payload: {plan_token: plan.plan_token, impact_acknowledged: true}, close: () => {}});
|
||||
}, 'primary');
|
||||
panel.addEventListener('close', () => { if (!acknowledged) resolve(null); }, {once: true});
|
||||
content.append(note('确认仅适用于上面显示的源码、参数和方案;每次重新启动都会重新预检。'),
|
||||
el('div', {class: 'form-footer'}, button('暂不运行', () => panel.close()), confirm));
|
||||
});
|
||||
}
|
||||
|
||||
export function validateMenuInput(text) {
|
||||
if (text.includes('\0')) throw new Error('菜单输入不能包含 NUL 字符。');
|
||||
if (new TextEncoder().encode(text).length > 1024) throw new Error('单次菜单输入不能超过 1024 UTF-8 字节。');
|
||||
if (!/^(?:[0-9 ,:./+\-]*|[yYnNqQ])$/.test(text)) throw new Error('仅支持数字选项、数字列表或 y/n/q;不接受命令、控制键或凭据。');
|
||||
}
|
||||
|
||||
export function nativeRunPanel(ctx, initialRun) {
|
||||
let current = initialRun;
|
||||
let blocked = false;
|
||||
let sending = false;
|
||||
let executionKey = '';
|
||||
const details = el('div');
|
||||
const control = input({type: 'text', autocomplete: 'off', spellcheck: 'false', maxlength: '1024', placeholder: '输入原菜单选项后发送'});
|
||||
const feedback = el('div', {'aria-live': 'polite'});
|
||||
const submit = el('button', {type: 'submit', class: 'button primary'}, '发送菜单选项');
|
||||
const menu = el('form', {class: 'native-menu', autocomplete: 'off', hidden: true},
|
||||
el('h3', {}, '原菜单输入'), note('仅支持数字选项、数字列表或 y/n/q,不接受命令和控制键。不要输入密码或令牌;发送后清空,不保存输入历史。'),
|
||||
field('菜单选项', control, '留空发送回车以接受原菜单默认值;单次最多 1024 UTF-8 字节。'),
|
||||
feedback, el('div', {class: 'form-footer'}, submit));
|
||||
const node = el('section', {class: 'panel native-plan'}, details, menu);
|
||||
const eligible = () => isNative(current) && current.execution.interactive && current.state === 'running';
|
||||
function update(run, options = {}) {
|
||||
current = run;
|
||||
blocked = Boolean(options.blocked);
|
||||
const execution = run.execution || {mode: 'container'};
|
||||
const key = JSON.stringify(execution);
|
||||
if (key !== executionKey) {
|
||||
executionKey = key;
|
||||
details.replaceChildren(el('div', {class: 'panel-header'}, el('h2', {}, '执行环境'), badge('running', isNative(run) ? '原环境' : '隔离容器')));
|
||||
if (isNative(run)) details.append(...[
|
||||
kv([['运行账号', execution.user], ['原工作目录', execution.cwd], ['方案编号', execution.profile_id],
|
||||
['影响确认', execution.impact?.requires_confirmation ? execution.impact_acknowledged ? '已确认本次影响' : '未记录确认' : '无需额外确认']]),
|
||||
el('h3', {class: 'native-subtitle'}, '实际启动命令'), el('pre', {class: 'native-command mono', tabindex: '0'}, execution.command_preview || '尚未记录启动命令'),
|
||||
impactView(execution.impact),
|
||||
execution.verification ? el('details', {class: 'disclosure'}, el('summary', {}, '查看执行核对信息'), el('pre', {}, JSON.stringify(execution.verification, null, 2))) : null].filter(node => node !== null));
|
||||
else details.append(el('p', {class: 'muted'}, '本次运行使用隔离容器。'));
|
||||
}
|
||||
menu.hidden = !eligible();
|
||||
if (!eligible()) control.value = '';
|
||||
control.disabled = blocked || sending || !eligible();
|
||||
submit.disabled = control.disabled;
|
||||
}
|
||||
menu.addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
if (!ctx.alive || sending || blocked || !eligible()) return;
|
||||
let text = control.value;
|
||||
control.value = '';
|
||||
feedback.replaceChildren();
|
||||
try { validateMenuInput(text); }
|
||||
catch (error) { feedback.replaceChildren(errorBox(error)); return; }
|
||||
sending = true;
|
||||
update(current, {blocked});
|
||||
try {
|
||||
const response = api(`${runUrl(current.id)}/input`, {method: 'POST', body: {text}}, ctx.signal);
|
||||
text = '';
|
||||
const result = await response;
|
||||
if (ctx.alive) feedback.replaceChildren(note(result.accepted === true ? '输入已送达本次运行。' : '输入结果未确认,请先核对当前状态和日志。', result.accepted === true ? 'success' : ''));
|
||||
} catch {
|
||||
if (ctx.alive) feedback.replaceChildren(note('输入未送达或结果尚未确认。请核对运行状态和最新日志,再决定是否重新输入。', 'danger'));
|
||||
} finally { text = ''; sending = false; if (ctx.alive) update(current, {blocked}); }
|
||||
});
|
||||
update(initialRun);
|
||||
return {node, update};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import {api, el, select, input, loading, note, errorBox, link, scriptUrl} from './ui.js';
|
||||
|
||||
const roles = {entry: '执行入口', support: '配套文件', self_test: '套件内部验证', variant: '相关实现 / 场景'};
|
||||
|
||||
export function relatedFiles(ctx, script) {
|
||||
const content = el('div', {class: 'related-files'});
|
||||
const panel = el('details', {class: 'panel disclosure'},
|
||||
el('summary', {}, `相关文件 · ${script.file_count || 1} 个`), content);
|
||||
let loaded = false;
|
||||
let busy = false;
|
||||
async function load() {
|
||||
if (busy || loaded || !panel.open) return;
|
||||
busy = true;
|
||||
content.replaceChildren(loading('正在读取项目文件…'));
|
||||
try {
|
||||
const {items} = await api(`${scriptUrl(script.id)}/files`, {}, ctx.signal);
|
||||
if (!ctx.alive) return;
|
||||
const search = input({type: 'search', placeholder: '按文件名或路径查找', 'aria-label': '搜索相关文件'});
|
||||
const choice = select('查看相关文件', []);
|
||||
const info = el('p', {class: 'source-info mono'});
|
||||
const feedback = el('div');
|
||||
const download = link('下载此文件', '', 'button small');
|
||||
const code = el('pre', {class: 'code', tabindex: '0', 'aria-label': '相关文件源代码,只读'});
|
||||
content.replaceChildren(note('这里展示测试入口、配套模块和相关实现。查看文件不会改变本次测试的执行入口。'),
|
||||
search, choice, info, download, feedback, code);
|
||||
let generation = 0;
|
||||
async function show() {
|
||||
const ticket = ++generation;
|
||||
const file = items.find(item => item.id === choice.value);
|
||||
download.hidden = true;
|
||||
feedback.replaceChildren();
|
||||
info.textContent = file ? `${roles[file.role] || '配套文件'} · ${file.source_path}` : '';
|
||||
code.textContent = file ? '正在读取…' : '没有匹配的文件。';
|
||||
if (!file) return;
|
||||
try {
|
||||
const data = await api(`${scriptUrl(file.id)}/code`, {}, ctx.signal);
|
||||
if (!ctx.alive || ticket !== generation) return;
|
||||
code.textContent = data.code || '(空文件)';
|
||||
download.href = `${scriptUrl(file.id)}/download`;
|
||||
download.hidden = Boolean(data.redacted);
|
||||
if (data.redacted) feedback.append(note('疑似凭据已隐藏,该文件不可下载。'));
|
||||
} catch (error) {
|
||||
if (ctx.alive && ticket === generation) { code.textContent = ''; feedback.replaceChildren(errorBox(error, show)); }
|
||||
}
|
||||
}
|
||||
function filter() {
|
||||
const term = search.value.toLowerCase();
|
||||
const selected = choice.value;
|
||||
const matching = items.filter(file => (file.name + file.source_path).toLowerCase().includes(term));
|
||||
choice.replaceChildren(...matching.map(file => el('option', {value: file.id}, `${roles[file.role]} · ${file.relative_path}`)));
|
||||
if (matching.some(file => file.id === selected)) choice.value = selected;
|
||||
show();
|
||||
}
|
||||
choice.addEventListener('change', show);
|
||||
search.addEventListener('input', filter);
|
||||
loaded = true;
|
||||
filter();
|
||||
} catch (error) { if (ctx.alive) content.replaceChildren(errorBox(error, load)); }
|
||||
finally { busy = false; }
|
||||
}
|
||||
panel.addEventListener('toggle', load);
|
||||
return panel;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
@charset "UTF-8";
|
||||
:root{font-family:Inter,"Segoe UI","Microsoft YaHei",sans-serif;color:#22332c;background:#f4f6f3;font-synthesis:none;--ink:#22332c;--muted:#748079;--line:#e3e8e1;--green:#237349;--light:#ecf4ec;--danger:#b43d35;--radius:12px}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;font-size:14px;line-height:1.55}
|
||||
button,input,textarea,select{font:inherit}
|
||||
button,a,input,textarea,select{touch-action:manipulation}
|
||||
a{color:var(--green);text-decoration:none}
|
||||
a:hover{text-decoration:underline}
|
||||
button{cursor:pointer}
|
||||
button:disabled{opacity:.5;cursor:not-allowed}
|
||||
:focus-visible{outline:3px solid #73b48a;outline-offset:3px}
|
||||
[hidden]{display:none!important}
|
||||
h1,h2,h3,p{margin:0}
|
||||
h1{font-size:28px;letter-spacing:-.8px;line-height:1.25;font-weight:650;overflow-wrap:anywhere}
|
||||
h2{font-size:17px;font-weight:650}
|
||||
h3{font-size:15px;font-weight:600}
|
||||
.sidebar{position:fixed;inset:0 auto 0 0;width:220px;background:#14231e;color:#b4c0b8;padding:31px 20px;display:flex;flex-direction:column;z-index:5}
|
||||
.brand{display:flex;align-items:center;gap:12px;color:#f6faf4;padding:0 12px;text-decoration:none!important;margin-bottom:60px}
|
||||
.brand-mark{display:grid;place-items:center;background:#b9db91;color:#173522;width:37px;height:39px;border-radius:10px;font-size:25px;font-weight:800;transform:rotate(-5deg)}
|
||||
.brand strong{font-size:25px;letter-spacing:3px;line-height:1;display:block}
|
||||
.brand small{font-size:9px;letter-spacing:1.6px;color:#a2b5a7;display:block;margin-top:5px}
|
||||
.sidebar-label{font-size:11px;padding:0 15px;margin-bottom:12px;color:#7e9487;letter-spacing:2px}
|
||||
nav{display:grid;gap:8px}
|
||||
nav a{border:1px solid transparent;display:flex;align-items:center;gap:13px;color:#bac8bf;padding:12px 14px;border-radius:8px;font-weight:550}
|
||||
nav a:hover{background:#1e342a;text-decoration:none}
|
||||
nav a[aria-current=page]{background:#2b4334;color:#d4edb1;border-color:#3b5642}
|
||||
nav a>span:first-child{font-size:20px;line-height:1;opacity:.85}
|
||||
.nav-count{font-size:11px;border-radius:4px;background:#3e5844;min-width:24px;padding:0 5px;text-align:center;margin-left:auto}
|
||||
.sidebar-note{font-size:11px;border-top:1px solid #304034;padding:21px 12px 0;margin-top:auto;color:#becdbf}
|
||||
.sidebar-note p{font-size:11px;color:#7d9383;margin-top:9px;line-height:1.9}
|
||||
.dot{display:inline-block;width:6px;height:6px;border-radius:50%;background:#adc888;margin-right:7px;vertical-align:2px}
|
||||
.sidebar-bottom{padding:29px 12px 0;color:#879e8d;font-size:12px}
|
||||
.sidebar-bottom span{font-size:10px;margin-left:7px;color:#617a69}
|
||||
.workspace{margin-left:220px;min-height:100vh;display:flex;flex-direction:column}
|
||||
.topbar{height:73px;border-bottom:1px solid var(--line);background:#fbfcf9;display:flex;align-items:center;justify-content:space-between;padding:0 38px;font-size:12px}
|
||||
.topbar>div:first-child{display:flex;align-items:center;gap:14px}
|
||||
.eyebrow{font-size:10px;letter-spacing:1.5px;color:#98a298}
|
||||
#worker-status{display:flex;align-items:center;gap:8px;color:#748477}
|
||||
.worker-light{width:7px;height:7px;display:inline-block;border-radius:100%;background:#2e955d}
|
||||
.worker-light.offline{background:#cd8b36}
|
||||
.summary{padding:26px 38px 0;display:grid;grid-template-columns:repeat(4,1fr);gap:16px}
|
||||
.stat{background:#fff;border:1px solid var(--line);border-radius:var(--radius);padding:16px 20px;position:relative}
|
||||
.stat-label{font-size:12px;color:var(--muted)}
|
||||
.stat-value{font-size:27px;font-weight:620;letter-spacing:-1px;line-height:1.2;margin-top:6px;display:block}
|
||||
.stat-foot{font-size:10px;color:#859286;position:absolute;bottom:19px;right:18px}
|
||||
.stat.accent{background:#edf4e7;border-color:#dce9d3}
|
||||
.stat.accent .stat-value{color:#477129}
|
||||
main{padding:30px 38px 36px;flex:1;min-width:0}
|
||||
.page-heading{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-bottom:26px}
|
||||
.page-heading p{color:var(--muted);font-size:12px;margin-top:9px;max-width:650px}
|
||||
.actions{display:flex;gap:9px;align-items:center;flex-wrap:wrap}
|
||||
.button{border:1px solid #dbe3d8;background:#fff;color:var(--ink);border-radius:7px;padding:9px 15px;min-height:38px;display:inline-flex;justify-content:center;align-items:center;gap:7px;white-space:nowrap;font-size:12px;font-weight:550;text-decoration:none!important;line-height:1.4}
|
||||
.button:hover:enabled{background:#f1f5ed;border-color:#b8cbb8}
|
||||
.button.primary{background:#2b703d;border-color:#2b703d;color:white;box-shadow:0 2px 3px #234d2710}
|
||||
.button.primary:hover:enabled{background:#235e32}
|
||||
.button.danger{color:#a64236;border-color:#e8c7bf}
|
||||
.button.small{padding:5px 10px;min-height:29px;font-size:11px}
|
||||
.button.ghost{border-color:transparent;background:transparent;color:#618063}
|
||||
.button.detail-link{min-height:46px;min-width:112px;padding:11px 18px;font-size:14px;font-weight:600;color:var(--green);border-color:#bed5c4;background:#f2f8f2}
|
||||
.button.detail-link:hover,.button.back-button:hover{background:#e6f1e7;border-color:#79a586}
|
||||
.button.back-button{min-height:44px;padding:10px 17px;font-size:14px;border-color:#b8cdbb;color:var(--green);box-shadow:0 2px 8px #1839230c}
|
||||
.detail-navigation{position:sticky;top:12px;z-index:3;display:flex;width:fit-content;margin-bottom:20px}
|
||||
.filters{display:flex;gap:11px;align-items:center;flex-wrap:wrap;background:#fff;border:1px solid var(--line);padding:15px 17px;border-radius:var(--radius);margin-bottom:22px}
|
||||
.input{border:1px solid #dce3d9;border-radius:7px;padding:9px 11px;background:#fff;color:var(--ink);min-height:38px;min-width:0;width:100%;font-size:12px;transition:border-color .15s}
|
||||
.input:hover{border-color:#acbfaa}
|
||||
.input:focus{border-color:#55905e}
|
||||
.input::placeholder{color:#9aa69a}
|
||||
textarea.input{resize:vertical;line-height:1.7}
|
||||
.search{flex:1;min-width:200px;position:relative}
|
||||
.search .input{padding-left:34px;background:#f8faf6}
|
||||
.search:before{content:'⌕';position:absolute;left:13px;top:6px;font-size:20px;color:#8b9c89}
|
||||
.filters select{width:auto;max-width:220px}
|
||||
.section-title{display:flex;justify-content:space-between;align-items:center;gap:16px;margin:23px 0 13px;font-size:12px}
|
||||
.section-title h2{font-size:14px;overflow-wrap:anywhere}
|
||||
.section-title .count{font-size:11px;font-weight:400;color:#8c9b8b;margin-left:9px}
|
||||
.muted{color:var(--muted);font-size:12px}
|
||||
.catalog-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(275px,1fr));gap:16px}
|
||||
.script-card{min-width:0;background:#fff;border:1px solid var(--line);border-radius:var(--radius);padding:19px 20px 16px;display:flex;flex-direction:column;transition:box-shadow .15s,border-color .15s;text-decoration:none!important;color:inherit}
|
||||
.script-card:hover{border-color:#b5caae;box-shadow:0 5px 18px #233c2910}
|
||||
.card-top{display:flex;justify-content:space-between;gap:14px;align-items:center;margin-bottom:16px}
|
||||
.language{font-family:Consolas,monospace;font-size:11px;font-weight:bold;padding:5px 8px;border-radius:6px;color:#557b34;background:#f0f5e6;letter-spacing:.4px}
|
||||
.language.shell{background:#eef1f7;color:#607087}
|
||||
.script-card h3{color:#263c2b;font-size:15px;overflow-wrap:anywhere}
|
||||
.script-description{color:#7a877b;line-height:1.8;font-size:12px;margin:9px 0 14px;min-height:43px;overflow-wrap:anywhere}
|
||||
.card-source{color:#93a090;font-family:Consolas,monospace;font-size:10px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-bottom:14px}
|
||||
.card-bottom{border-top:1px solid #ecf0e8;padding-top:12px;display:flex;flex-wrap:wrap;gap:12px;justify-content:space-between;align-items:center;margin-top:auto;font-size:10px;color:#879481}
|
||||
.card-bottom .detail-link{width:100%}.script-card:hover .detail-link{background:#e6f1e7;border-color:#79a586}
|
||||
.card-reason{color:#9c7737;font-size:11px;line-height:1.7;margin:-1px 0 14px}
|
||||
.badge{font-size:10px;white-space:nowrap;display:inline-flex;align-items:center;border-radius:5px;padding:3px 7px;line-height:1.5;background:#eef0ed;color:#69786a;font-weight:500}
|
||||
.badge:before{content:'';width:5px;height:5px;border-radius:50%;background:currentColor;margin-right:5px}
|
||||
.badge.ready,.badge.succeeded{color:#458344;background:#edf6e9}
|
||||
.badge.running{color:#3472a3;background:#eaf3fa}
|
||||
.badge.queued,.badge.needs_configuration,.badge.timed_out{color:#a17a26;background:#fcf4df}
|
||||
.badge.failed,.badge.missing,.badge.interrupted{color:#b55248;background:#fbeeea}
|
||||
.badge.synthetic{color:#7f648e;background:#f2edf7}
|
||||
.badge.disabled,.badge.cancelled{color:#7b867a;background:#f0f2ee}
|
||||
.panel{border:1px solid var(--line);border-radius:var(--radius);background:white;padding:22px;margin-bottom:20px;min-width:0}
|
||||
.panel-header{display:flex;align-items:center;justify-content:space-between;gap:15px;margin-bottom:20px}
|
||||
.detail-grid{display:grid;grid-template-columns:minmax(0,1.55fr) minmax(310px,1fr);gap:22px;align-items:start}
|
||||
.detail-grid>.panel{margin-bottom:0}
|
||||
.breadcrumbs{display:flex;flex-wrap:wrap;align-items:center;gap:10px;font-size:11px;color:#8d9c89;margin:-4px 0 17px;overflow-wrap:anywhere}
|
||||
.metadata{display:grid;grid-template-columns:90px minmax(0,1fr);column-gap:16px;row-gap:10px;font-size:11px;margin:0}
|
||||
.metadata dt{color:#8c9a87}
|
||||
.metadata dd{margin:0;overflow-wrap:anywhere;color:#475840}
|
||||
.metadata dd.mono,.mono{font-family:"Cascadia Code",Consolas,monospace}
|
||||
.source-header{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin:18px 0 11px}
|
||||
.source-header select{width:auto;min-width:160px;flex:1}
|
||||
.source-info{font-size:10px;color:#82917d;overflow-wrap:anywhere;line-height:1.9;margin:12px 0}
|
||||
pre{white-space:pre;tab-size:4;overflow:auto;margin:0;font-family:"Cascadia Code",Consolas,monospace;font-size:12px;line-height:1.8}
|
||||
.code{background:#f6f8f3;border:1px solid #e4eadf;border-radius:8px;min-height:240px;max-height:580px;padding:18px;color:#425637}
|
||||
.code[tabindex]:focus{outline-offset:2px}
|
||||
.field{display:flex;flex-direction:column;gap:7px;margin-bottom:17px}
|
||||
.field-label{font-size:12px;font-weight:550}
|
||||
.field small{color:#8b9885;font-size:10px;line-height:1.75}
|
||||
.field-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}
|
||||
.checkbox{display:flex;gap:9px;align-items:center;margin:18px 0;font-size:12px}
|
||||
.checkbox input{accent-color:#477e38;width:15px;height:15px;min-height:auto}
|
||||
.form-footer{border-top:1px solid #e9eee5;padding-top:17px;display:flex;align-items:center;justify-content:flex-end;gap:10px}
|
||||
.notice{padding:12px 14px;border-radius:8px;border:1px solid #e5e8d4;background:#f7f8ed;color:#7c8055;font-size:12px;margin-bottom:17px;line-height:1.8;display:flex;align-items:center;gap:13px;justify-content:space-between;overflow-wrap:anywhere}
|
||||
.notice.danger{background:#fff1ee;border-color:#f0d7d0;color:#a34c3d}
|
||||
.notice.success{background:#eff7e9;border-color:#dfebd4;color:#56783d}
|
||||
.notice.info{background:#edf3f6;border-color:#dae5eb;color:#647f8d}
|
||||
.global-notice{margin:18px 38px 0;padding:12px 15px;border:1px solid #ebdac1;background:#fcf5e7;color:#8c744b;border-radius:8px;font-size:12px}
|
||||
.loading,.empty{padding:65px 25px;text-align:center;color:#819078;grid-column:1/-1}
|
||||
.loading:before{content:'';display:inline-block;border:2px solid #d7e4cf;border-top-color:#618d51;border-radius:50%;width:14px;height:14px;margin-right:10px;vertical-align:-3px;animation:spin .9s linear infinite}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
.empty-icon{font-size:33px;color:#a5b49a;display:block;margin-bottom:8px}
|
||||
.empty p{font-size:12px;margin-top:8px;color:#9ca795}
|
||||
.table-wrap{overflow:auto;border:1px solid var(--line);border-radius:var(--radius);background:white}
|
||||
table{border-collapse:collapse;width:100%;text-align:left;white-space:nowrap;font-size:12px}
|
||||
th{color:#93a087;font-size:10px;font-weight:500;background:#f9fbf6;padding:13px 18px}
|
||||
td{padding:16px 18px;border-top:1px solid #edf0e8;color:#6c7c60}
|
||||
td:first-child{color:#354b2c;font-weight:550;max-width:360px;white-space:normal;overflow-wrap:anywhere}
|
||||
td .badge{margin-right:6px}
|
||||
tbody tr:hover{background:#fcfdf9}
|
||||
.run-row{position:relative;cursor:pointer}.run-row:hover,.run-row:focus-within{background:#f1f7ef}.run-row:focus-within{box-shadow:inset 0 0 0 2px #73b48a}
|
||||
.run-name{font-size:13px;color:#354b2c}.run-open:after{content:'';position:absolute;inset:0}
|
||||
.run-summary{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:15px;margin-bottom:20px}
|
||||
.run-summary>div{border-right:1px solid var(--line);padding-right:15px}
|
||||
.run-summary>div:last-child{border-right:0}
|
||||
.run-summary small{display:block;color:#94a18b;font-size:10px;margin-bottom:7px}
|
||||
.run-summary strong{font-size:12px;font-weight:550;overflow-wrap:anywhere}
|
||||
.log-panel{padding:0;overflow:hidden}
|
||||
.log-toolbar{padding:15px 19px;display:flex;justify-content:space-between;align-items:center;gap:16px;border-bottom:1px solid #e0e6d9}
|
||||
.log-toolbar h2{font-size:14px}
|
||||
.log{background:#1b281f;color:#cedcbd;padding:22px;min-height:320px;max-height:590px;font-size:12px;line-height:1.8;white-space:pre-wrap;overflow-wrap:anywhere}
|
||||
.log-hint{font-size:10px;color:#8b9b80;padding:10px 19px;border-top:1px solid #e8eddf;display:flex;justify-content:space-between;gap:12px}
|
||||
.log-toolbar label{font-size:11px;display:flex;align-items:center;gap:6px;color:#879779}
|
||||
.artifact-list{display:grid;gap:9px}
|
||||
.artifact{display:flex;justify-content:space-between;align-items:center;padding:13px 15px;border:1px solid #e6ecde;border-radius:8px;gap:16px}
|
||||
.artifact-name{font-size:12px;overflow-wrap:anywhere}
|
||||
.artifact-name small{display:block;font-size:10px;color:#9aa68f;margin-top:3px}
|
||||
.disclosure{margin-top:17px}
|
||||
.disclosure summary{cursor:pointer;font-size:12px;color:#71845f;padding:8px 0}
|
||||
.disclosure pre{max-height:300px;background:#f5f8ef;border-radius:7px;padding:16px;font-size:11px;white-space:pre-wrap;overflow-wrap:anywhere}
|
||||
.dialog{border:1px solid #dce5d0;border-radius:14px;padding:25px;width:min(580px,calc(100% - 30px));max-height:90vh;color:var(--ink);box-shadow:0 20px 80px #17250d35}
|
||||
.dialog::backdrop{background:#15201070;backdrop-filter:blur(2px)}
|
||||
.related-files{display:grid;gap:12px;margin-top:18px}.related-files .code{max-height:420px}.related-files>.button{justify-self:start}
|
||||
.native-plan .notice:last-child{margin-bottom:0}.native-description{font-size:12px;color:#748079;margin-top:17px;line-height:1.8}
|
||||
.native-impact{margin:18px 0;padding:14px 16px;background:#f5f8f0;border:1px solid #e4eada;border-radius:8px}.native-impact.attention{background:#fdf6e7;border-color:#ecdcba}
|
||||
.native-impact ul{padding-left:20px;margin:9px 0;font-size:12px;line-height:1.9}.native-impact h3{font-size:13px}.native-impact .muted{font-size:11px}
|
||||
.native-command{margin:12px 0;padding:15px;background:#f2f6ee;border:1px solid #dfe7d7;border-radius:8px;white-space:pre-wrap;overflow-wrap:anywhere;max-height:280px;font-size:11px}
|
||||
.native-subtitle{margin-top:20px;font-size:12px}.native-dialog{width:min(720px,calc(100% - 30px))}
|
||||
.preflight-checks{padding:0;margin:15px 0;list-style:none;display:grid;gap:10px}.preflight-checks li{display:flex;align-items:flex-start;gap:10px;font-size:12px}.preflight-checks p{font-size:11px;color:#7f8c74;margin-top:3px;overflow-wrap:anywhere}
|
||||
.native-menu{border-top:1px solid var(--line);margin-top:20px;padding-top:20px}.native-menu h3{margin-bottom:12px}.native-parameters{border-top:1px solid var(--line);padding-top:17px}.input[readonly]{background:#f6f8f3;color:#73816a}
|
||||
.dialog-header{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-bottom:23px}
|
||||
.dialog .code{max-height:55vh;white-space:pre-wrap;overflow-wrap:anywhere}
|
||||
.page-footer{display:flex;justify-content:space-between;gap:20px;padding:17px 38px;border-top:1px solid var(--line);color:#a0ac94;font-size:10px}
|
||||
.toast{position:fixed;bottom:26px;left:calc(50% + 110px);transform:translateX(-50%);z-index:20;background:#263e21;color:#e0edcf;padding:13px 22px;border-radius:9px;font-size:12px;box-shadow:0 5px 25px #19311425;max-width:calc(100% - 40px)}
|
||||
.skip-link{position:fixed;top:-100px;left:15px;padding:10px;background:white;z-index:100}
|
||||
.skip-link:focus{top:10px}
|
||||
.noscript{position:fixed;top:80px;left:240px;background:#fff3df;padding:20px}
|
||||
@media(min-width:1600px){main,.summary{padding-left:55px;padding-right:55px}.catalog-grid{grid-template-columns:repeat(3,1fr)}}
|
||||
@media(max-width:1100px){.sidebar{width:184px;padding:29px 14px}.workspace{margin-left:184px}.topbar{padding:0 25px}main{padding:26px 25px}.summary{padding:22px 25px 0;gap:10px}.stat{padding:14px}.stat-foot{display:none}.detail-grid{grid-template-columns:1fr}.toast{left:calc(50% + 92px)}.global-notice{margin-left:25px;margin-right:25px}}
|
||||
@media(max-width:720px){.sidebar{position:static;width:auto;padding:16px 19px;flex-direction:row;align-items:center;gap:20px}.brand{padding:0;margin:0;gap:8px}.brand strong{font-size:20px}.brand small{font-size:7px}.brand-mark{width:30px;height:33px;font-size:21px}.sidebar-label,.sidebar-note,.sidebar-bottom{display:none}nav{display:flex;gap:5px;margin-left:auto}nav a{padding:8px 10px;font-size:12px;gap:6px}nav a>span:first-child,.nav-count{display:none}.workspace{margin:0}.topbar{height:48px;padding:0 20px;font-size:10px}.eyebrow{display:none}.summary{padding:19px 18px 0;grid-template-columns:repeat(2,1fr);gap:9px}.stat{padding:12px 15px}.stat-label{font-size:10px}.stat-value{font-size:23px}.stat-foot{display:block;font-size:9px;right:12px;bottom:15px}main{padding:24px 18px}.page-heading{align-items:flex-start;flex-direction:column;gap:16px;margin-bottom:20px}.page-heading h1{font-size:24px}.page-heading p{font-size:11px}.page-heading>.actions{width:100%}.filters{gap:8px;padding:12px}.search{flex-basis:100%}.filters select{flex:1;max-width:none;width:calc(50% - 8px)}.catalog-grid{grid-template-columns:1fr}.panel{padding:17px}.log-panel{padding:0}.run-summary{grid-template-columns:1fr 1fr;row-gap:20px}.run-summary>div:nth-child(2){border:0}.log-toolbar{align-items:flex-start;flex-direction:column;gap:10px}.log-toolbar .actions{width:100%;justify-content:space-between}.log{padding:17px;font-size:11px}.log-hint{flex-direction:column;gap:3px}.artifact{align-items:flex-start;flex-direction:column}.artifact>.actions{align-self:flex-end}.field-grid{grid-template-columns:1fr;gap:0}.page-footer{padding:15px 18px;font-size:9px;flex-direction:column;gap:4px}.toast{left:50%;width:max-content}.global-notice{margin:15px 18px 0}.noscript{left:20px;right:20px}.metadata{grid-template-columns:75px minmax(0,1fr);gap:9px}.source-header{align-items:stretch}}
|
||||
@media(prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
|
||||
@@ -0,0 +1,125 @@
|
||||
export const stateNames = {
|
||||
queued: '排队中', running: '运行中', succeeded: '进程成功', failed: '失败',
|
||||
cancelled: '已取消', timed_out: '已超时', interrupted: '已中断',
|
||||
ready: '可运行', needs_configuration: '待配置', missing: '源文件缺失'
|
||||
};
|
||||
export const active = run => ['queued', 'running'].includes(run.state);
|
||||
export const enc = value => encodeURIComponent(value);
|
||||
export const scriptUrl = id => `/api/scripts/${enc(id)}`;
|
||||
export const runUrl = id => `/api/runs/${enc(id)}`;
|
||||
export const date = value => value ? new Date(value).toLocaleString('zh-CN', {hour12: false}) : '—';
|
||||
export function bytes(value) {
|
||||
if (!Number.isFinite(Number(value))) return '—';
|
||||
const size = Number(value);
|
||||
if (size < 1024) return `${size} B`;
|
||||
if (size < 1048576) return `${(size / 1024).toFixed(1)} KB`;
|
||||
return `${(size / 1048576).toFixed(1)} MB`;
|
||||
}
|
||||
export function el(tag, attributes = {}, ...children) {
|
||||
const node = document.createElement(tag);
|
||||
for (const [key, value] of Object.entries(attributes)) {
|
||||
if (key === 'class') node.className = value;
|
||||
else if (key.startsWith('on')) node.addEventListener(key.slice(2), value);
|
||||
else if (key === 'text') node.textContent = value;
|
||||
else if (['checked', 'disabled', 'hidden', 'selected', 'value'].includes(key)) node[key] = value;
|
||||
else if (value !== undefined && value !== null) node.setAttribute(key, value);
|
||||
}
|
||||
for (const child of children.flat(Infinity)) {
|
||||
if (child !== null && child !== undefined) node.append(child instanceof Node ? child : document.createTextNode(String(child)));
|
||||
}
|
||||
return node;
|
||||
}
|
||||
export const button = (text, onClick, style = 'secondary') => el('button', {type: 'button', class: `button ${style}`, onclick: onClick}, text);
|
||||
export const link = (text, href, className = '') => el('a', {href, class: className}, text);
|
||||
export const badge = (state, text = stateNames[state] || state) => el('span', {class: `badge ${state}`}, text);
|
||||
export const note = (text, tone = '') => el('div', {class: `notice ${tone}`}, text);
|
||||
export const empty = (title, detail) => el('div', {class: 'empty'}, el('span', {class: 'empty-icon', 'aria-hidden': 'true'}, '▤'), el('h3', {}, title), el('p', {}, detail));
|
||||
export const loading = (text = '正在加载…') => el('div', {class: 'loading', role: 'status'}, text);
|
||||
let fieldCounter = 0;
|
||||
export function field(text, control, hint = '') {
|
||||
const hintId = `field-hint-${++fieldCounter}`;
|
||||
control.setAttribute('aria-label', text);
|
||||
if (hint) control.setAttribute('aria-describedby', hintId);
|
||||
return el('label', {class: 'field'}, el('span', {class: 'field-label'}, text), control, hint ? el('small', {id: hintId}, hint) : null);
|
||||
}
|
||||
export const input = (attributes = {}) => el('input', {class: 'input', ...attributes});
|
||||
export const textarea = (attributes = {}) => el('textarea', {class: 'input', rows: 3, ...attributes});
|
||||
export const select = (label, options, value = '') => el('select', {class: 'input', 'aria-label': label}, options.map(([id, text]) => el('option', {value: id, selected: String(id) === String(value)}, text)));
|
||||
export function heading(title, subtitle, actions = []) {
|
||||
return el('div', {class: 'page-heading'}, el('div', {}, el('h1', {}, title), el('p', {}, subtitle)), el('div', {class: 'actions'}, actions));
|
||||
}
|
||||
export function kv(items) {
|
||||
return el('dl', {class: 'metadata'}, items.map(([label, value]) => [el('dt', {}, label), el('dd', {}, value ?? '—')]));
|
||||
}
|
||||
export function errorBox(error, retry) {
|
||||
return el('div', {class: 'notice danger', role: 'alert'}, el('span', {}, error.message || '请求失败,请稍后重试。'), retry ? button('重试', retry, 'small') : null);
|
||||
}
|
||||
export async function api(path, options = {}, signal) {
|
||||
const controller = new AbortController();
|
||||
const abort = () => controller.abort();
|
||||
if (signal?.aborted) controller.abort();
|
||||
else signal?.addEventListener('abort', abort, {once: true});
|
||||
const timer = setTimeout(abort, options.body instanceof FormData ? 120000 : 20000);
|
||||
const headers = {...options.headers};
|
||||
if (options.method && options.method !== 'GET') headers['X-XAS-Console'] = '1';
|
||||
let body = options.body;
|
||||
if (body && !(body instanceof FormData)) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
body = JSON.stringify(body);
|
||||
}
|
||||
try {
|
||||
const response = await fetch(path, {...options, body, headers, signal: controller.signal, credentials: 'same-origin'});
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new Error(typeof data?.detail === 'string' ? data.detail : `请求未完成(HTTP ${response.status})。`);
|
||||
if (data === null) throw new Error('服务器返回了无法读取的数据。');
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw error;
|
||||
if (error.name === 'AbortError') throw new Error('请求超时,操作结果尚未确认。请先核对记录;运行请求重试会复用原编号。');
|
||||
if (error instanceof TypeError) throw new Error('无法连接控制台,请检查网络后重试。');
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', abort);
|
||||
}
|
||||
}
|
||||
let toastTimer;
|
||||
export function toast(text) {
|
||||
const target = document.querySelector('#toast');
|
||||
target.textContent = text;
|
||||
target.hidden = false;
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => { target.hidden = true; }, 5000);
|
||||
}
|
||||
export function dialog(title, content) {
|
||||
const close = button('关闭', () => panel.close(), 'small');
|
||||
close.setAttribute('aria-label', '关闭对话框');
|
||||
const panel = el('dialog', {class: 'dialog', 'aria-label': title}, el('div', {class: 'dialog-header'}, el('h2', {}, title), close), content);
|
||||
panel.addEventListener('close', () => panel.remove(), {once: true});
|
||||
document.body.append(panel);
|
||||
panel.showModal();
|
||||
return panel;
|
||||
}
|
||||
export function pageContext(main) {
|
||||
const controller = new AbortController();
|
||||
const timers = new Set();
|
||||
const context = {
|
||||
main, signal: controller.signal,
|
||||
get alive() { return !controller.signal.aborted; },
|
||||
show(...nodes) { if (context.alive) { main.replaceChildren(...nodes.filter(node => node !== null && node !== undefined)); main.setAttribute('aria-busy', 'false'); } },
|
||||
async repeat(callback, interval = 2500) {
|
||||
if (!context.alive) return;
|
||||
if (document.visibilityState === 'visible' && await callback() === false) return;
|
||||
if (context.alive) {
|
||||
const timer = setTimeout(() => { timers.delete(timer); context.repeat(callback, interval); }, interval);
|
||||
timers.add(timer);
|
||||
}
|
||||
},
|
||||
dispose() {
|
||||
controller.abort();
|
||||
timers.forEach(clearTimeout);
|
||||
document.querySelectorAll('dialog').forEach(panel => panel.close());
|
||||
}
|
||||
};
|
||||
return context;
|
||||
}
|
||||
Reference in New Issue
Block a user