Files
xas-test-library/console/static/native-ui.js
T

188 lines
11 KiB
JavaScript
Raw Normal View History

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