188 lines
11 KiB
JavaScript
188 lines
11 KiB
JavaScript
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(execution.verification || '启动方案已接入,未完成本轮业务验收。', '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};
|
|
}
|