fix: validate workflows and preserve execution outcomes

This commit is contained in:
Ed1s0nZ committed 2026-10-06 02:32:32 +08:00
1 parent bdefa535b0
commit 8550325dfe
16 files changed
+494 -35

No files matched your search

+6
View File
@@ -39375,6 +39375,12 @@ html[data-theme="dark"] .form-group select {
background: var(--bg-secondary);
}
.workflow-status-toggle.is-invalid {
color: var(--error-color, #dc3545);
border-color: currentColor;
background: var(--bg-secondary);
}
.workflow-status-toggle:hover {
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.08);
}
+1
View File
@@ -3778,6 +3778,7 @@
"processLibrary": "Workflows",
"nodeLibrary": "Node library",
"emptyList": "No workflows yet",
"statusInvalid": "Invalid workflow",
"statusEnabled": "Enabled",
"statusDisabled": "Disabled",
"metaId": "ID",
+1
View File
@@ -3790,6 +3790,7 @@
"processLibrary": "Сценарии",
"nodeLibrary": "Библиотека узлов",
"emptyList": "Сценариев пока нет",
"statusInvalid": "Ошибка проверки",
"statusEnabled": "Включён",
"statusDisabled": "Выключен",
"metaId": "ID",
+1
View File
@@ -3766,6 +3766,7 @@
"processLibrary": "工作流列表",
"nodeLibrary": "节点库",
"emptyList": "暂无工作流",
"statusInvalid": "校验未通过",
"statusEnabled": "启用",
"statusDisabled": "禁用",
"metaId": "ID",
+35
View File
@@ -0,0 +1,35 @@
const fs=require('node:fs');
const vm=require('node:vm');
const test=require('node:test');
const assert=require('node:assert/strict');
const source=fs.readFileSync('web/static/js/audit.js','utf8');
test('reset clears the keyword and requests an unfiltered first page',() => {
const fields={
'audit-filter-category':{value:'auth'},'audit-filter-action':{value:'login',disabled:false},
'audit-filter-result':{value:'failure'},'audit-filter-q':{value:'does-not-exist'},
'audit-filter-since':{value:'2026-09-01T00:00'},'audit-filter-until':{value:'2026-09-02T00:00'}
};
let requested,cleared=false;
const ctx={auditLogsPage:4,auditLogsPageSize:20,URLSearchParams,
document:{getElementById:id => fields[id]},
window:{AuditDatetimePicker:{clearAll(){cleared=true;fields['audit-filter-since'].value='';fields['audit-filter-until'].value='';}}},
getAuditFilterDatetimeValue:id => fields[id].value,auditDatetimeLocalToRFC3339:x => x,
rebuildAuditActionSelect(){fields['audit-filter-action'].value='';fields['audit-filter-action'].disabled=true;},
syncAuditCustomSelect(){},loadAuditLogs(){requested=ctx.buildAuditQueryParams(false);}};
vm.createContext(ctx);
for (const [startMarker,endMarker] of [
['function buildAuditQueryParams(','async function loadAuditSummary('],
['function filterAuditLogs(','function applyAuditTimePreset(']
]) {
const start=source.indexOf(startMarker);
vm.runInContext(source.slice(start,source.indexOf(endMarker,start)),ctx);
}
ctx.resetAuditLogFilters();
const params=new URLSearchParams(requested);
assert.equal(fields['audit-filter-q'].value,'');
assert.equal(params.get('page'),'1');
for (const key of ['q','category','action','result','since','until']) assert.equal(params.has(key),false);
assert.equal(cleared,true);
const html=fs.readFileSync('web/templates/index.html','utf8');
assert.match(html,/<button[^>]*onclick="resetAuditLogFilters\(\)"[^>]*data-i18n="settingsAudit.resetBtn"/);
});
+37
View File
@@ -0,0 +1,37 @@
const fs=require('node:fs');
const vm=require('node:vm');
const test=require('node:test');
const assert=require('node:assert/strict');
const source=fs.readFileSync('web/static/js/workflows.js','utf8');
function harness(workflows,cy=null,errors=[]) {
const list={innerHTML:''};
const ctx={workflows,cy,currentWorkflowId:'selected',
document:{getElementById:() => list},esc:x => String(x||''),_t:x => x,WORKFLOW_EDIT_ICON:'edit',
elementsToGraph:() => ({}),validateWorkflowGraph:() => errors};
vm.createContext(ctx);
let start=source.indexOf(' function workflowValidationError(');
vm.runInContext(source.slice(start,source.indexOf(' function updateWorkflowCanvasTitle(',start)),ctx);
start=source.indexOf(' function renderWorkflowList(');
vm.runInContext(source.slice(start,source.indexOf(' function nextNodeId(',start)),ctx);
return {ctx,list};
}
test('invalid saved graph is labeled as invalid and never gets a green enabled badge',() => {
const h=harness([{id:'invalid',name:'Invalid',enabled:true,validation_error:'missing start'}]);
h.ctx.renderWorkflowList();
assert.match(h.list.innerHTML,/workflows.statusInvalid/);
assert.match(h.list.innerHTML,/is-invalid/);
assert.doesNotMatch(h.list.innerHTML,/is-enabled/);
assert.match(h.list.innerHTML,/title="missing start"/);
});
test('a selected invalid draft updates the list status',() => {
const h=harness([{id:'selected',name:'Draft',enabled:true}],{},['missing output']);
h.ctx.renderWorkflowList();
assert.match(h.list.innerHTML,/workflows.statusInvalid/);
});
test('valid enabled and disabled definitions retain their configured status',() => {
const h=harness([{id:'valid',enabled:true},{id:'off',enabled:false}]);
h.ctx.renderWorkflowList();
assert.match(h.list.innerHTML,/workflows.statusEnabled/);
assert.match(h.list.innerHTML,/workflows.statusDisabled/);
assert.doesNotMatch(h.list.innerHTML,/statusInvalid/);
});
+22 -4
View File
@@ -350,6 +350,10 @@
}
});
cy.on('add remove', updateEmptyState);
cy.on('add remove data', () => {
updateWorkflowCanvasTitle();
renderWorkflowList();
});
document.addEventListener('keydown', event => {
const active = document.activeElement;
const editing = active && ['INPUT', 'TEXTAREA', 'SELECT'].includes(active.tagName);
@@ -778,6 +782,19 @@
};
}
function workflowValidationError(wf) {
if (cy && wf && wf.id === currentWorkflowId) {
const errors = validateWorkflowGraph(elementsToGraph());
if (errors.length) return errors.join(';');
}
return wf && wf.validation_error || '';
}
function workflowStatusKey(wf, enabled) {
return workflowValidationError(wf) ? 'workflows.statusInvalid'
: (enabled ? 'workflows.statusEnabled' : 'workflows.statusDisabled');
}
function updateWorkflowCanvasTitle() {
const titleEl = document.getElementById('workflow-canvas-title');
const subtitleEl = document.getElementById('workflow-canvas-subtitle');
@@ -795,7 +812,7 @@
const parts = [];
if (meta.id) parts.push(meta.id);
if (wf && wf.version) parts.push(`v${wf.version}`);
parts.push(meta.enabled ? _t('workflows.statusEnabled') : _t('workflows.statusDisabled'));
parts.push(_t(workflowStatusKey(wf || {id: currentWorkflowId}, meta.enabled)));
subtitleEl.textContent = parts.join(' · ');
subtitleEl.hidden = !parts.length;
}
@@ -848,10 +865,11 @@
list.innerHTML = workflows.map(wf => {
const encodedId = encodeURIComponent(wf.id);
const isActive = wf.id === currentWorkflowId;
const toggleTitle = esc(_t('workflows.toggleEnabled'));
const validationError = workflowValidationError(wf);
const toggleTitle = esc(validationError || _t('workflows.toggleEnabled'));
const editTitle = esc(_t('workflows.editMeta'));
const enabled = wf.enabled !== false;
const statusText = esc(_t(enabled ? 'workflows.statusEnabled' : 'workflows.statusDisabled'));
const statusText = esc(_t(workflowStatusKey(wf, enabled)));
return `
<div class="workflow-list-item ${isActive ? 'is-active' : ''}">
<button type="button" class="workflow-list-main" onclick="selectWorkflow(decodeURIComponent('${encodedId}'))">
@@ -859,7 +877,7 @@
<span class="workflow-list-meta">${esc(wf.id)} · v${wf.version || 1}</span>
</button>
<div class="workflow-list-actions">
<button type="button" class="workflow-status-toggle ${enabled ? 'is-enabled' : 'is-disabled'}" title="${toggleTitle}" aria-label="${toggleTitle}" aria-pressed="${enabled ? 'true' : 'false'}" onclick="event.stopPropagation(); toggleWorkflowEnabled(decodeURIComponent('${encodedId}'), ${enabled ? 'false' : 'true'})">
<button type="button" class="workflow-status-toggle ${validationError ? 'is-invalid' : (enabled ? 'is-enabled' : 'is-disabled')}" title="${toggleTitle}" aria-label="${toggleTitle}" aria-pressed="${enabled ? 'true' : 'false'}" onclick="event.stopPropagation(); toggleWorkflowEnabled(decodeURIComponent('${encodedId}'), ${enabled ? 'false' : 'true'})">
<span class="workflow-status-dot" aria-hidden="true"></span>
<span>${statusText}</span>
</button>