aktueller stand

This commit is contained in:
2025-12-15 16:12:38 +01:00
parent 6c83e4a7ee
commit b71d99b048
7 changed files with 1020 additions and 89 deletions

View File

@@ -38,9 +38,29 @@
const formModal = document.getElementById('formModal');
const modalBackdrop = document.getElementById('formModalBackdrop');
const modalCloseBtn = document.getElementById('modalCloseBtn');
const typeSelect = document.getElementById('typeSelect');
const emailSection = document.querySelector('[data-section="email"]');
const flowSection = document.querySelector('[data-section="flow"]');
const httpSection = document.querySelector('[data-section="http"]');
const emailToInput = document.getElementById('emailToInput');
const emailSubjectInput = document.getElementById('emailSubjectInput');
const emailBodyInput = document.getElementById('emailBodyInput');
const flowStep1Url = document.getElementById('flowStep1Url');
const flowStep1Method = document.getElementById('flowStep1Method');
const flowStep1Headers = document.getElementById('flowStep1Headers');
const flowStep1Body = document.getElementById('flowStep1Body');
const flowStep2Url = document.getElementById('flowStep2Url');
const flowStep2Method = document.getElementById('flowStep2Method');
const flowStep2Headers = document.getElementById('flowStep2Headers');
const flowStep2Body = document.getElementById('flowStep2Body');
const requestTableBody = document.getElementById('requestTableBody');
const listStatus = document.getElementById('listStatus');
const filterName = document.getElementById('filterName');
const filterNext = document.getElementById('filterNext');
const filterLast = document.getElementById('filterLast');
const filterStatus = document.getElementById('filterStatus');
const filterRuns = document.getElementById('filterRuns');
const runsList = document.getElementById('runsList');
const runsStatus = document.getElementById('runsStatus');
const heroStats = document.getElementById('heroStats');
@@ -49,7 +69,6 @@
const resetFormBtn = document.getElementById('resetFormBtn');
const refreshBtn = document.getElementById('refreshBtn');
const newAutomationBtn = document.getElementById('newAutomationBtn');
const runSelectedBtn = document.getElementById('runSelectedBtn');
const modalTitle = document.getElementById('modalTitle');
const importInput = document.getElementById('importInput');
const importStatus = document.getElementById('importStatus');
@@ -62,6 +81,11 @@
const previewHeaders = document.getElementById('previewHeaders');
const previewBody = document.getElementById('previewBody');
const refreshPreviewBtn = document.getElementById('refreshPreviewBtn');
const placeholderTableBody = document.getElementById('placeholderTableBody');
let sortState = { key: 'next', dir: 'asc' };
let listInstance = null;
let sse = null;
function toDateTimeLocal(value) {
if (!value) return '';
@@ -221,6 +245,14 @@
return shifted.toISOString().slice(0, 10);
}
const dayOffsetMatch = key.match(/^day([+-]\d+)?$/);
if (dayOffsetMatch) {
const offset = dayOffsetMatch[1] ? parseInt(dayOffsetMatch[1], 10) : 0;
const shifted = new Date(baseDate);
shifted.setDate(baseDate.getDate() + (Number.isNaN(offset) ? 0 : offset));
return String(shifted.getDate()).padStart(2, '0');
}
switch (key) {
case 'today':
case 'date':
@@ -267,23 +299,91 @@
return rendered;
}
function renderPlaceholderTable(context) {
if (!placeholderTableBody) return;
const rows = [
{ key: '{{date}}', value: context.date },
{ key: '{{date+1}}', value: renderTemplate('{{date+1}}', context) },
{ key: '{{day}}', value: context.day },
{ key: '{{day-1}}', value: renderTemplate('{{day-1}}', context) },
{ key: '{{datetime}}', value: context.datetime },
{ key: '{{iso}}', value: context.iso },
{ key: '{{timestamp}}', value: context.timestamp },
{ key: '{{uuid}}', value: renderTemplate('{{uuid}}', context) },
{ key: '{{year}}', value: context.year },
{ key: '{{month}}', value: context.month },
{ key: '{{day}}', value: context.day },
{ key: '{{hour}}', value: context.hour },
{ key: '{{minute}}', value: context.minute },
{ key: '{{weekday}}', value: context.weekday },
{ key: '{{weekday_short}}', value: context.weekday_short }
];
placeholderTableBody.innerHTML = rows.map((row) => `
<tr>
<td class="placeholder-key">${row.key}</td>
<td>${row.value}</td>
</tr>
`).join('');
}
function buildPayloadFromForm() {
return {
const type = typeSelect ? typeSelect.value : 'request';
const base = {
type,
name: nameInput.value.trim(),
description: descriptionInput.value.trim(),
method: methodSelect.value,
url_template: urlInput.value.trim(),
headers: parseHeadersInput(headersInput.value),
body_template: bodyInput.value,
schedule_type: intervalPreset.value !== 'custom' ? intervalPreset.value : undefined,
interval_minutes: intervalPreset.value === 'custom'
? Math.max(5, parseInt(intervalMinutesInput.value, 10) || DEFAULT_INTERVAL_MINUTES)
: undefined,
schedule_type: intervalPreset.value !== 'custom' ? intervalPreset.value : undefined,
jitter_minutes: Math.max(0, parseInt(jitterInput.value, 10) || 0),
start_at: parseDateInput(startAtInput.value),
run_until: parseDateInput(runUntilInput.value),
active: activeToggle.checked
};
if (type === 'email') {
return {
...base,
email_to: emailToInput?.value.trim(),
email_subject_template: emailSubjectInput?.value.trim(),
email_body_template: emailBodyInput?.value
};
}
if (type === 'flow') {
const steps = [];
const step1Url = flowStep1Url?.value.trim();
if (step1Url) {
steps.push({
method: flowStep1Method?.value || 'GET',
url: step1Url,
headers: parseHeadersInput(flowStep1Headers?.value || ''),
body: flowStep1Body?.value || ''
});
}
const step2Url = flowStep2Url?.value.trim();
if (step2Url) {
steps.push({
method: flowStep2Method?.value || 'GET',
url: step2Url,
headers: parseHeadersInput(flowStep2Headers?.value || ''),
body: flowStep2Body?.value || ''
});
}
return {
...base,
steps
};
}
return {
...base,
method: methodSelect.value,
url_template: urlInput.value.trim(),
headers: parseHeadersInput(headersInput.value),
body_template: bodyInput.value
};
}
function applyPresetDisabling() {
@@ -297,7 +397,51 @@
function refreshPreview() {
if (!previewUrl || !previewHeaders || !previewBody) return;
const type = typeSelect ? typeSelect.value : 'request';
const context = buildTemplateContext();
renderPlaceholderTable(context);
if (type === 'email') {
const renderedTo = renderTemplate(emailToInput?.value || '', context);
const renderedSubject = renderTemplate(emailSubjectInput?.value || '', context);
const renderedEmailBody = renderTemplate(emailBodyInput?.value || '', context);
previewUrl.textContent = `To: ${renderedTo || '—'}\nSubject: ${renderedSubject || '—'}`;
previewHeaders.textContent = '—';
previewBody.textContent = renderedEmailBody || '—';
return;
}
if (type === 'flow') {
const steps = [];
if (flowStep1Url?.value) {
steps.push({
label: 'Step 1',
url: renderTemplate(flowStep1Url.value, context),
method: flowStep1Method?.value || 'GET',
headers: renderHeaderTemplates(flowStep1Headers?.value || '', context),
body: renderTemplate(flowStep1Body?.value || '', context)
});
}
if (flowStep2Url?.value) {
steps.push({
label: 'Step 2',
url: renderTemplate(flowStep2Url.value, context),
method: flowStep2Method?.value || 'GET',
headers: renderHeaderTemplates(flowStep2Headers?.value || '', context),
body: renderTemplate(flowStep2Body?.value || '', context)
});
}
previewUrl.textContent = steps.length
? steps.map((s) => `${s.label}: ${s.method} ${s.url || '—'}`).join('\n')
: '—';
previewHeaders.textContent = steps.length
? steps.map((s) => `${s.label} Headers:\n${stringifyHeaders(s.headers) || '—'}`).join('\n\n')
: '—';
previewBody.textContent = steps.length
? steps.map((s) => `${s.label} Body:\n${s.body || '—'}`).join('\n\n')
: '—';
return;
}
const renderedUrl = renderTemplate(urlInput.value || '', context);
const headersObj = renderHeaderTemplates(headersInput.value || '', context);
const renderedBody = renderTemplate(bodyInput.value || '', context);
@@ -310,7 +454,9 @@
}
async function loadRequests() {
setStatus(listStatus, 'Lade Automationen...');
if (!state.loading) {
setStatus(listStatus, 'Lade Automationen...');
}
state.loading = true;
try {
const data = await apiFetchJSON('/automation/requests');
@@ -366,40 +512,61 @@
function renderRequests() {
if (!requestTableBody) return;
requestTableBody.innerHTML = '';
const sorted = [...state.requests].sort((a, b) => {
const aNext = a.next_run_at ? new Date(a.next_run_at).getTime() : Infinity;
const bNext = b.next_run_at ? new Date(b.next_run_at).getTime() : Infinity;
return aNext - bNext;
});
if (!sorted.length) {
requestTableBody.innerHTML = '<tr><td colspan="5">Keine Automationen vorhanden.</td></tr>';
if (!state.requests.length) {
requestTableBody.innerHTML = '<tr><td colspan="6">Keine Automationen vorhanden.</td></tr>';
listInstance = null;
return;
}
const rows = sorted.map((req) => {
const rows = state.requests.map((req) => {
const isSelected = state.selectedId === req.id;
const nextSort = req.next_run_at ? new Date(req.next_run_at).getTime() : Number.MAX_SAFE_INTEGER;
const lastSort = req.last_run_at ? new Date(req.last_run_at).getTime() : -Number.MAX_SAFE_INTEGER;
const runsCount = Array.isArray(req.runs) ? req.runs.length : (req.runs_count || req.runsCount || 0);
const statusBadge = req.last_status === 'success'
? '<span class="badge success">OK</span>'
: req.last_status === 'error'
? '<span class="badge error">Fehler</span>'
: '<span class="badge">—</span>';
let subline = '';
if (req.type === 'email') {
subline = `E-Mail → ${req.email_to || '—'}`;
} else if (req.type === 'flow') {
const stepCount = Array.isArray(req.steps) ? req.steps.length : 0;
subline = `Flow (${stepCount || '0'} Schritte)`;
} else {
subline = `${req.method || 'GET'} · ${req.url_template || '—'}`;
}
return `
<tr data-id="${req.id}" class="${isSelected ? 'is-selected' : ''}">
<td>
<div class="row-title">${req.name}</div>
<div class="row-sub">${req.method || 'GET'} · ${req.url_template}</div>
<td data-sort="${req.name || ''}">
<div class="row-title name">${req.name}</div>
<div class="row-sub">${subline}</div>
<span class="type hidden-value">${req.type || ''}</span>
<span class="email hidden-value">${req.email_to || ''}</span>
<span class="url hidden-value">${req.url_template || ''}</span>
<span class="steps hidden-value">${req.steps && req.steps.map((s) => s.url || s.url_template || '').join(' | ')}</span>
</td>
<td>${formatRelative(req.next_run_at)}<br><small>${formatDateTime(req.next_run_at)}</small></td>
<td>${formatRelative(req.last_run_at)}<br><small>${req.last_status_code || '—'}</small></td>
<td>${statusBadge}</td>
<td>
<div class="row-actions">
<button class="secondary-btn" data-action="edit" data-id="${req.id}" type="button">Bearbeiten</button>
<button class="ghost-btn" data-action="run" data-id="${req.id}" type="button">Run</button>
<button class="ghost-btn" data-action="toggle" data-id="${req.id}" type="button">${req.active ? 'Pause' : 'Aktivieren'}</button>
<button class="ghost-btn" data-action="delete" data-id="${req.id}" type="button">Löschen</button>
<td data-sort="${nextSort}">
<span class="next" data-sort="${nextSort}">${formatRelative(req.next_run_at)}</span>
<br><small>${formatDateTime(req.next_run_at)}</small>
</td>
<td data-sort="${lastSort}">
<span class="last" data-sort="${lastSort}">${formatRelative(req.last_run_at)}</span>
<br><small>${req.last_status_code || '—'}</small>
</td>
<td data-sort="${req.last_status || ''}"><span class="status" data-sort="${req.last_status || ''}">${statusBadge}</span></td>
<td data-sort="${runsCount}" class="runs-count">
<span class="runs hidden-value" data-sort="${runsCount}">${runsCount}</span>
${runsCount}
</td>
<td>
<div class="row-actions">
<button class="icon-btn" title="Bearbeiten" data-action="edit" data-id="${req.id}" type="button">✏️</button>
<button class="icon-btn" title="Run" data-action="run" data-id="${req.id}" type="button">▶️</button>
<button class="icon-btn" title="${req.active ? 'Pause' : 'Aktivieren'}" data-action="toggle" data-id="${req.id}" type="button">${req.active ? '⏸' : '▶'}</button>
<button class="icon-btn danger" title="Löschen" data-action="delete" data-id="${req.id}" type="button">🗑️</button>
</div>
</td>
</tr>
@@ -408,9 +575,7 @@
requestTableBody.innerHTML = rows.join('');
if (runSelectedBtn) {
runSelectedBtn.disabled = !state.selectedId;
}
initListInstance();
}
function renderRuns() {
@@ -447,6 +612,7 @@
function resetForm() {
form.reset();
editingId = null;
if (typeSelect) typeSelect.value = 'request';
intervalPreset.value = 'hourly';
intervalMinutesInput.value = DEFAULT_INTERVAL_MINUTES;
applyPresetDisabling();
@@ -457,6 +623,15 @@
runUntilInput.value = '';
formModeLabel.textContent = 'Neue Automation';
setStatus(formStatus, '');
if (emailToInput) emailToInput.value = '';
if (emailSubjectInput) emailSubjectInput.value = '';
if (emailBodyInput) emailBodyInput.value = '';
[flowStep1Url, flowStep1Headers, flowStep1Body, flowStep2Url, flowStep2Headers, flowStep2Body]
.forEach((el) => { if (el) el.value = ''; });
if (flowStep1Method) flowStep1Method.value = 'GET';
if (flowStep2Method) flowStep2Method.value = 'GET';
applyTypeVisibility();
refreshPreview();
}
function fillForm(request) {
@@ -470,6 +645,30 @@
headersInput.value = stringifyHeaders(request.headers);
bodyInput.value = request.body_template || '';
activeToggle.checked = !!request.active;
if (typeSelect) {
typeSelect.value = request.type || 'request';
applyTypeVisibility();
}
if (request.type === 'email') {
emailToInput.value = request.email_to || '';
emailSubjectInput.value = request.email_subject_template || '';
emailBodyInput.value = request.email_body_template || '';
} else if (request.type === 'flow') {
const steps = Array.isArray(request.steps) ? request.steps : [];
const [s1, s2] = steps;
if (s1) {
flowStep1Url.value = s1.url || s1.url_template || '';
flowStep1Method.value = s1.method || s1.http_method || 'GET';
flowStep1Headers.value = stringifyHeaders(s1.headers || {});
flowStep1Body.value = s1.body || s1.body_template || '';
}
if (s2) {
flowStep2Url.value = s2.url || s2.url_template || '';
flowStep2Method.value = s2.method || s2.http_method || 'GET';
flowStep2Headers.value = stringifyHeaders(s2.headers || {});
flowStep2Body.value = s2.body || s2.body_template || '';
}
}
if (request.interval_minutes === 60) {
intervalPreset.value = 'hourly';
@@ -490,10 +689,27 @@
event.preventDefault();
if (state.saving) return;
const payload = buildPayloadFromForm();
if (!payload.name || !payload.url_template) {
setStatus(formStatus, 'Name und URL sind Pflichtfelder.', 'error');
if (!payload.name) {
setStatus(formStatus, 'Name ist ein Pflichtfeld.', 'error');
return;
}
if (payload.type === 'request' && !payload.url_template) {
setStatus(formStatus, 'URL ist ein Pflichtfeld für HTTP-Requests.', 'error');
return;
}
if (payload.type === 'email') {
if (!payload.email_to || !payload.email_subject_template || !payload.email_body_template) {
setStatus(formStatus, 'E-Mail: Empfänger, Betreff und Body sind Pflichtfelder.', 'error');
return;
}
}
if (payload.type === 'flow') {
const hasStep = Array.isArray(payload.steps) && payload.steps.length > 0 && payload.steps[0].url;
if (!hasStep) {
setStatus(formStatus, 'Flow: Mindestens ein Schritt mit URL ist erforderlich.', 'error');
return;
}
}
state.saving = true;
setStatus(formStatus, 'Speichere...');
@@ -716,6 +932,72 @@
closeImportModal();
}
function updateSortIndicators() {
const headers = document.querySelectorAll('[data-sort-column]');
headers.forEach((th) => {
th.classList.remove('sort-asc', 'sort-desc');
const col = th.getAttribute('data-sort-column');
if (col === sortState.key) {
th.classList.add(sortState.dir === 'desc' ? 'sort-desc' : 'sort-asc');
}
});
}
function initListInstance() {
const container = document.getElementById('automationTable');
if (!container) return;
if (listInstance) {
listInstance.remove();
listInstance = null;
}
const options = {
listClass: 'list',
valueNames: [
'name',
'type',
'email',
'url',
'steps',
{ name: 'next', attr: 'data-sort' },
{ name: 'last', attr: 'data-sort' },
{ name: 'status', attr: 'data-sort' },
{ name: 'runs', attr: 'data-sort' }
]
};
listInstance = new List(container, options);
applyFilters();
listInstance.sort(sortState.key, { order: sortState.dir === 'desc' ? 'desc' : 'asc' });
updateSortIndicators();
listInstance.on('updated', updateSortIndicators);
}
function applyFilters() {
if (!listInstance) return;
const searchName = (filterName?.value || '').toLowerCase().trim();
const searchNext = (filterNext?.value || '').toLowerCase().trim();
const searchLast = (filterLast?.value || '').toLowerCase().trim();
const statusValue = (filterStatus?.value || '').toLowerCase().trim();
const runsMin = filterRuns?.value ? Number(filterRuns.value) : null;
listInstance.filter((item) => {
const v = item.values();
const matchName = !searchName || [
v.name || '',
v.type || '',
v.email || '',
v.url || '',
v.steps || ''
].some((p) => String(p).toLowerCase().includes(searchName));
const matchNext = !searchNext || String(v.next || '').toLowerCase().includes(searchNext);
const matchLast = !searchLast || `${v.last || ''}`.toLowerCase().includes(searchLast);
const matchStatus = !statusValue || String(v.status || '').toLowerCase().includes(statusValue);
const matchRuns = runsMin === null || Number(v.runs || 0) >= runsMin;
return matchName && matchNext && matchLast && matchStatus && matchRuns;
});
}
function getSelectedRequest() {
if (!state.selectedId) return null;
return state.requests.find((item) => item.id === state.selectedId) || null;
@@ -759,6 +1041,26 @@
importModal.hidden = true;
}
function applyTypeVisibility() {
const type = typeSelect ? typeSelect.value : 'request';
document.querySelectorAll('[data-section="http"]').forEach((el) => {
el.style.display = type === 'request' ? 'grid' : 'none';
});
document.querySelectorAll('[data-section="email"]').forEach((el) => {
el.style.display = type === 'email' ? 'grid' : 'none';
});
document.querySelectorAll('[data-section="flow"]').forEach((el) => {
el.style.display = type === 'flow' ? 'grid' : 'none';
});
// required toggles
urlInput.required = type === 'request';
methodSelect.required = type === 'request';
emailToInput && (emailToInput.required = type === 'email');
emailSubjectInput && (emailSubjectInput.required = type === 'email');
emailBodyInput && (emailBodyInput.required = type === 'email');
flowStep1Url && (flowStep1Url.required = type === 'flow');
}
function handleTableClick(event) {
const button = event.target.closest('[data-action]');
if (button) {
@@ -790,6 +1092,45 @@
}
}
function handleSseMessage(payload) {
if (!payload || !payload.type) return;
switch (payload.type) {
case 'automation-run':
case 'automation-upsert':
loadRequests();
break;
default:
break;
}
}
function startSse() {
if (sse || typeof EventSource === 'undefined') return;
try {
sse = new EventSource(`${API_URL}/events`, { withCredentials: true });
} catch (error) {
console.warn('Konnte SSE nicht starten:', error);
sse = null;
return;
}
sse.addEventListener('message', (event) => {
if (!event || !event.data) return;
try {
const payload = JSON.parse(event.data);
handleSseMessage(payload);
} catch (error) {
// ignore parse errors
}
});
sse.addEventListener('error', () => {
if (sse) {
sse.close();
sse = null;
}
setTimeout(startSse, 5000);
});
}
function init() {
applyPresetDisabling();
resetForm();
@@ -803,12 +1144,42 @@
});
newAutomationBtn.addEventListener('click', () => openFormModal('create'));
refreshBtn.addEventListener('click', loadRequests);
runSelectedBtn.addEventListener('click', () => runAutomation(state.selectedId));
applyImportBtn?.addEventListener('click', applyImport);
refreshPreviewBtn?.addEventListener('click', refreshPreview);
[urlInput, headersInput, bodyInput].forEach((el) => {
[urlInput, headersInput, bodyInput].forEach((el) => el?.addEventListener('input', refreshPreview));
[emailToInput, emailSubjectInput, emailBodyInput].forEach((el) => el?.addEventListener('input', refreshPreview));
[flowStep1Url, flowStep1Headers, flowStep1Body, flowStep1Method,
flowStep2Url, flowStep2Headers, flowStep2Body, flowStep2Method].forEach((el) => {
el?.addEventListener('input', refreshPreview);
el?.addEventListener('change', refreshPreview);
});
typeSelect?.addEventListener('change', () => {
applyTypeVisibility();
refreshPreview();
});
[filterName, filterNext, filterLast, filterStatus].forEach((el) => {
el?.addEventListener('input', applyFilters);
el?.addEventListener('change', applyFilters);
});
filterRuns?.addEventListener('input', applyFilters);
filterRuns?.addEventListener('change', applyFilters);
document.querySelectorAll('[data-sort-column]').forEach((th) => {
th.addEventListener('click', () => {
const col = th.getAttribute('data-sort-column');
if (!col) return;
if (sortState.key === col) {
sortState.dir = sortState.dir === 'asc' ? 'desc' : 'asc';
} else {
sortState.key = col;
sortState.dir = 'asc';
}
if (listInstance) {
listInstance.sort(col, { order: sortState.dir });
}
updateSortIndicators();
});
});
applyTypeVisibility();
openImportBtn?.addEventListener('click', openImportModal);
importCloseBtn?.addEventListener('click', closeImportModal);
importModalBackdrop?.addEventListener('click', closeImportModal);
@@ -825,6 +1196,7 @@
}
}
});
startSse();
}
init();