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