Files
PostTracker/web/automation.js
2025-12-21 14:21:55 +01:00

1359 lines
48 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(function () {
let active = false;
let initialized = false;
const API_URL = (() => {
if (window.API_URL) return window.API_URL;
try {
return `${window.location.origin}/api`;
} catch (error) {
return 'https://fb.srv.medeba-media.de/api';
}
})();
const DEFAULT_INTERVAL_MINUTES = 60;
const state = {
requests: [],
runs: [],
selectedId: null,
loading: false,
saving: false,
running: false
};
let editingId = null;
let exclusionWindows = [];
const form = document.getElementById('automationForm');
const nameInput = document.getElementById('nameInput');
const descriptionInput = document.getElementById('descriptionInput');
const urlInput = document.getElementById('urlInput');
const methodSelect = document.getElementById('methodSelect');
const headersInput = document.getElementById('headersInput');
const bodyInput = document.getElementById('bodyInput');
const intervalPreset = document.getElementById('intervalPreset');
const intervalMinutesInput = document.getElementById('intervalMinutesInput');
const jitterInput = document.getElementById('jitterInput');
const startAtInput = document.getElementById('startAtInput');
const runUntilInput = document.getElementById('runUntilInput');
const excludeStartInput = document.getElementById('excludeStartInput');
const excludeEndInput = document.getElementById('excludeEndInput');
const addExclusionBtn = document.getElementById('addExclusionBtn');
const exclusionList = document.getElementById('exclusionList');
const activeToggle = document.getElementById('activeToggle');
const formStatus = document.getElementById('formStatus');
const formModeLabel = document.getElementById('formModeLabel');
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 filterRunUntil = document.getElementById('filterRunUntil');
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');
const saveBtn = document.getElementById('saveBtn');
const resetFormBtn = document.getElementById('resetFormBtn');
const refreshBtn = document.getElementById('refreshBtn');
const newAutomationBtn = document.getElementById('newAutomationBtn');
const modalTitle = document.getElementById('modalTitle');
const importInput = document.getElementById('importInput');
const importStatus = document.getElementById('importStatus');
const applyImportBtn = document.getElementById('applyImportBtn');
const openImportBtn = document.getElementById('openImportBtn');
const importModal = document.getElementById('importModal');
const importModalBackdrop = document.getElementById('importModalBackdrop');
const importCloseBtn = document.getElementById('importCloseBtn');
const previewUrl = document.getElementById('previewUrl');
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;
let relativeTimer = null;
function handleUnauthorized(response) {
if (response && response.status === 401) {
if (typeof redirectToLogin === 'function') {
redirectToLogin();
} else {
window.location.href = 'login.html';
}
return true;
}
return false;
}
function toDateTimeLocal(value) {
if (!value) return '';
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return '';
const pad = (num) => String(num).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function parseDateInput(value) {
if (!value) return null;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
async function apiFetchJSON(path, options = {}) {
const opts = {
credentials: 'include',
...options
};
opts.headers = {
'Content-Type': 'application/json',
...(options.headers || {})
};
const response = await fetch(`${API_URL}${path}`, opts);
if (handleUnauthorized(response)) {
throw new Error('Nicht angemeldet');
}
if (!response.ok) {
let message = 'Unbekannter Fehler';
try {
const err = await response.json();
message = err.error || message;
} catch (error) {
// ignore
}
throw new Error(message);
}
if (response.status === 204) {
return null;
}
return response.json();
}
function setStatus(target, message, type = 'info') {
if (!target) return;
target.textContent = message || '';
target.classList.remove('error', 'success');
if (type === 'error') target.classList.add('error');
if (type === 'success') target.classList.add('success');
}
function formatDateTime(value) {
if (!value) return '—';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '—';
return date.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
function formatRelative(value) {
if (!value) return '—';
const date = new Date(value);
const now = new Date();
const diffMs = date.getTime() - now.getTime();
const diffMinutes = Math.round(diffMs / 60000);
if (Number.isNaN(diffMinutes)) return '—';
if (Math.abs(diffMinutes) < 1) return 'jetzt';
if (diffMinutes > 0) {
if (diffMinutes < 60) return `in ${diffMinutes} Min`;
const hours = Math.round(diffMinutes / 60);
if (hours < 48) return `in ${hours} Std`;
const days = Math.round(hours / 24);
return `in ${days} Tagen`;
} else {
const abs = Math.abs(diffMinutes);
if (abs < 60) return `vor ${abs} Min`;
const hours = Math.round(abs / 60);
if (hours < 48) return `vor ${hours} Std`;
const days = Math.round(hours / 24);
return `vor ${days} Tagen`;
}
}
function parseHeadersInput(text) {
const trimmed = (text || '').trim();
if (!trimmed) return {};
if (trimmed.startsWith('{')) {
try {
const parsed = JSON.parse(trimmed);
if (parsed && typeof parsed === 'object') {
return parsed;
}
} catch (error) {
// fall back to line parsing
}
}
const headers = {};
trimmed.split('\n').forEach((line) => {
const idx = line.indexOf(':');
if (idx === -1) return;
const key = line.slice(0, idx).trim();
const value = line.slice(idx + 1).trim();
if (key) {
headers[key] = value;
}
});
return headers;
}
function stringifyHeaders(headers) {
if (!headers || typeof headers !== 'object') return '';
return Object.entries(headers)
.map(([key, value]) => `${key}: ${value}`)
.join('\n');
}
function buildTemplateContext() {
const now = new Date();
return {
now,
date: now.toISOString().slice(0, 10),
today: now.toISOString().slice(0, 10),
iso: now.toISOString(),
datetime: now.toISOString(),
timestamp: now.getTime(),
year: now.getFullYear(),
month: String(now.getMonth() + 1).padStart(2, '0'),
day: String(now.getDate()).padStart(2, '0'),
hour: String(now.getHours()).padStart(2, '0'),
minute: String(now.getMinutes()).padStart(2, '0'),
weekday: now.toLocaleDateString('de-DE', { weekday: 'long' }),
weekday_short: now.toLocaleDateString('de-DE', { weekday: 'short' })
};
}
function renderTemplate(template, context = {}) {
if (typeof template !== 'string') return '';
const baseDate = context.now instanceof Date ? context.now : new Date();
const uuidFn = typeof crypto !== 'undefined' && crypto.randomUUID
? () => crypto.randomUUID()
: () => Math.random().toString(16).slice(2, 10);
return template.replace(/\{\{\s*([^}\s]+)\s*\}\}/g, (_, keyRaw) => {
const key = String(keyRaw || '').trim();
if (!key) return '';
if (key === 'uuid') return uuidFn();
const dateOffsetMatch = key.match(/^date([+-]\d+)?$/);
if (dateOffsetMatch) {
const offset = dateOffsetMatch[1] ? parseInt(dateOffsetMatch[1], 10) : 0;
const shifted = new Date(baseDate);
shifted.setDate(baseDate.getDate() + (Number.isNaN(offset) ? 0 : offset));
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':
return baseDate.toISOString().slice(0, 10);
case 'iso':
case 'now':
case 'datetime':
return baseDate.toISOString();
case 'timestamp':
return String(baseDate.getTime());
case 'year':
return String(baseDate.getFullYear());
case 'month':
return String(baseDate.getMonth() + 1).padStart(2, '0');
case 'day':
return String(baseDate.getDate()).padStart(2, '0');
case 'hour':
return String(baseDate.getHours()).padStart(2, '0');
case 'minute':
return String(baseDate.getMinutes()).padStart(2, '0');
case 'weekday':
return baseDate.toLocaleDateString('de-DE', { weekday: 'long' });
case 'weekday_short':
return baseDate.toLocaleDateString('de-DE', { weekday: 'short' });
default:
return context[key] !== undefined && context[key] !== null
? String(context[key])
: '';
}
});
}
function renderHeaderTemplates(rawHeaders, context) {
const parsed = parseHeadersInput(rawHeaders);
const rendered = {};
Object.entries(parsed).forEach(([key, value]) => {
const renderedKey = renderTemplate(key, context).trim();
if (!renderedKey) return;
rendered[renderedKey] = renderTemplate(
value === undefined || value === null ? '' : String(value),
context
);
});
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() {
const type = typeSelect ? typeSelect.value : 'request';
const base = {
type,
name: nameInput.value.trim(),
description: descriptionInput.value.trim(),
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,
exclusion_windows: exclusionWindows.map((win) => ({ start: win.start, end: win.end }))
};
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 renderExclusionChips() {
if (!exclusionList) return;
if (!exclusionWindows.length) {
exclusionList.innerHTML = '<span class="placeholder-hint">Keine Ausschlusszeiten gesetzt.</span>';
return;
}
exclusionList.innerHTML = exclusionWindows.map((win, idx) => `
<span class="exclusion-chip">
${win.start} ${win.end}
<button type="button" aria-label="Entfernen" data-remove-exclusion="${idx}">×</button>
</span>
`).join('');
exclusionList.querySelectorAll('[data-remove-exclusion]').forEach((btn) => {
btn.addEventListener('click', () => {
const index = Number(btn.getAttribute('data-remove-exclusion'));
exclusionWindows.splice(index, 1);
renderExclusionChips();
});
});
}
function addExclusion() {
const start = excludeStartInput?.value || '';
const end = excludeEndInput?.value || '';
if (!start || !end) {
setStatus(formStatus, 'Bitte Start- und Endzeit angeben.', 'error');
return;
}
if (start >= end) {
setStatus(formStatus, 'Endzeit muss nach der Startzeit liegen.', 'error');
return;
}
exclusionWindows.push({ start, end });
exclusionWindows.sort((a, b) => a.start.localeCompare(b.start));
renderExclusionChips();
setStatus(formStatus, '', 'info');
if (excludeStartInput) excludeStartInput.value = '';
if (excludeEndInput) excludeEndInput.value = '';
}
function applyPresetDisabling() {
if (intervalPreset.value === 'custom') {
intervalMinutesInput.disabled = false;
} else {
intervalMinutesInput.disabled = true;
intervalMinutesInput.value = intervalPreset.value === 'daily' ? 1440 : DEFAULT_INTERVAL_MINUTES;
}
}
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);
previewUrl.textContent = renderedUrl || '—';
previewHeaders.textContent = Object.keys(headersObj).length
? stringifyHeaders(headersObj)
: '—';
previewBody.textContent = renderedBody || '—';
}
async function loadRequests() {
if (!active) return;
if (!state.loading) {
setStatus(listStatus, 'Lade Automationen...');
}
state.loading = true;
try {
const data = await apiFetchJSON('/automation/requests');
state.requests = Array.isArray(data) ? data : [];
renderRequests();
renderHero();
if (state.selectedId) {
const stillExists = state.requests.some((req) => req.id === state.selectedId);
if (!stillExists) {
state.selectedId = null;
state.runs = [];
renderRuns();
}
}
if (!state.selectedId && state.requests.length) {
selectRequest(state.requests[0].id, { focusForm: false });
}
setStatus(listStatus, state.requests.length ? '' : 'Noch keine Automationen angelegt.');
} catch (error) {
console.error(error);
setStatus(listStatus, error.message || 'Automationen konnten nicht geladen werden', 'error');
} finally {
state.loading = false;
}
}
function renderHero() {
if (!heroStats) return;
const active = state.requests.filter((req) => req.active);
const nextRun = active
.filter((req) => req.next_run_at)
.sort((a, b) => new Date(a.next_run_at) - new Date(b.next_run_at))[0];
const lastRun = [...state.requests]
.filter((req) => req.last_run_at)
.sort((a, b) => new Date(b.last_run_at) - new Date(a.last_run_at))[0];
heroStats.innerHTML = `
<div class="stat">
<div class="stat-label">Aktive Automationen</div>
<div class="stat-value">${active.length}/${state.requests.length}</div>
</div>
<div class="stat">
<div class="stat-label">Nächster Lauf</div>
<div class="stat-value">${nextRun ? formatRelative(nextRun.next_run_at) : '—'}</div>
</div>
<div class="stat">
<div class="stat-label">Letzter Status</div>
<div class="stat-value">${lastRun ? (lastRun.last_status || '—') : '—'}</div>
</div>
`;
}
function updateRelativeTimes() {
if (!active) return;
renderHero();
if (!requestTableBody || !state.requests.length) return;
const byId = new Map(state.requests.map((req) => [String(req.id), req]));
requestTableBody.querySelectorAll('tr[data-id]').forEach((row) => {
const req = byId.get(row.dataset.id);
if (!req) return;
const nextEl = row.querySelector('.next');
if (nextEl) nextEl.textContent = formatRelative(req.next_run_at);
const untilEl = row.querySelector('.until');
if (untilEl) untilEl.textContent = req.run_until ? formatRelative(req.run_until) : '—';
const lastEl = row.querySelector('.last');
if (lastEl) lastEl.textContent = formatRelative(req.last_run_at);
});
}
function renderRequests() {
if (!requestTableBody) return;
requestTableBody.innerHTML = '';
if (!state.requests.length) {
requestTableBody.innerHTML = '<tr><td colspan="7">Keine Automationen vorhanden.</td></tr>';
listInstance = null;
return;
}
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 untilSort = req.run_until ? new Date(req.run_until).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 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 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="${untilSort}">
<span class="until" data-sort="${untilSort}">${req.run_until ? formatRelative(req.run_until) : '—'}</span>
<br><small>${formatDateTime(req.run_until)}</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>
`;
});
requestTableBody.innerHTML = rows.join('');
initListInstance();
}
function renderRuns() {
if (!runsList) return;
runsList.innerHTML = '';
if (!state.runs.length) {
runsList.innerHTML = '<li class="run-item">Noch keine Läufe.</li>';
return;
}
runsList.innerHTML = state.runs.map((run) => {
const badge = run.status === 'success'
? '<span class="badge success">OK</span>'
: run.status === 'error'
? '<span class="badge error">Fehler</span>'
: '<span class="badge">Offen</span>';
return `
<li class="run-item">
<div class="run-top">
<div class="run-meta">
${badge}
<span>${formatDateTime(run.started_at)}</span>
<span>Code: ${run.status_code ?? '—'}</span>
<span>Dauer: ${run.duration_ms ? `${run.duration_ms} ms` : '—'}</span>
</div>
</div>
${run.error ? `<div class="run-body">Fehler: ${run.error}</div>` : ''}
${run.response_body ? `<div class="run-body">${run.response_body}</div>` : ''}
</li>
`;
}).join('');
}
function resetForm() {
form.reset();
editingId = null;
if (typeSelect) typeSelect.value = 'request';
intervalPreset.value = 'hourly';
intervalMinutesInput.value = DEFAULT_INTERVAL_MINUTES;
applyPresetDisabling();
const now = new Date();
now.setMinutes(now.getMinutes() + 5);
now.setSeconds(0, 0);
startAtInput.value = toDateTimeLocal(now);
runUntilInput.value = '';
exclusionWindows = [];
renderExclusionChips();
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) {
if (!request) return;
editingId = request.id;
formModeLabel.textContent = `Automation bearbeiten`;
nameInput.value = request.name || '';
descriptionInput.value = request.description || '';
urlInput.value = request.url_template || '';
methodSelect.value = request.method || 'GET';
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';
} else if (request.interval_minutes === 1440) {
intervalPreset.value = 'daily';
} else {
intervalPreset.value = 'custom';
}
intervalMinutesInput.value = request.interval_minutes || DEFAULT_INTERVAL_MINUTES;
jitterInput.value = request.jitter_minutes || 0;
startAtInput.value = toDateTimeLocal(request.start_at);
runUntilInput.value = toDateTimeLocal(request.run_until);
exclusionWindows = Array.isArray(request.exclusion_windows) ? [...request.exclusion_windows] : [];
renderExclusionChips();
applyPresetDisabling();
refreshPreview();
}
async function handleSubmit(event) {
event.preventDefault();
if (state.saving) return;
const payload = buildPayloadFromForm();
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...');
saveBtn.disabled = true;
try {
if (editingId) {
await apiFetchJSON(`/automation/requests/${editingId}`, {
method: 'PUT',
body: JSON.stringify(payload)
});
} else {
await apiFetchJSON('/automation/requests', {
method: 'POST',
body: JSON.stringify(payload)
});
}
setStatus(formStatus, 'Gespeichert.', 'success');
closeFormModal();
await loadRequests();
} catch (error) {
console.error(error);
setStatus(formStatus, error.message || 'Konnte nicht speichern', 'error');
} finally {
state.saving = false;
saveBtn.disabled = false;
}
}
async function loadRuns(requestId) {
if (!requestId) {
state.runs = [];
renderRuns();
return;
}
setStatus(runsStatus, 'Lade Runs...');
try {
const data = await apiFetchJSON(`/automation/requests/${requestId}/runs?limit=50`);
state.runs = Array.isArray(data) ? data : [];
renderRuns();
setStatus(runsStatus, state.runs.length ? '' : 'Keine Läufe vorhanden.');
} catch (error) {
console.error(error);
setStatus(runsStatus, error.message || 'Runs konnten nicht geladen werden', 'error');
}
}
function selectRequest(id, options = {}) {
state.selectedId = id;
const request = state.requests.find((item) => item.id === id);
if (!request) {
state.runs = [];
renderRuns();
return;
}
renderRequests();
loadRuns(id);
if (options.focusForm) {
nameInput.focus();
}
}
async function runAutomation(id) {
if (!id) {
setStatus(formStatus, 'Keine Automation ausgewählt.', 'error');
return;
}
if (state.running) return;
state.running = true;
setStatus(formStatus, 'Starte manuellen Run...');
try {
await apiFetchJSON(`/automation/requests/${id}/run`, { method: 'POST' });
await loadRequests();
await loadRuns(id);
setStatus(formStatus, 'Run gestartet.', 'success');
} catch (error) {
console.error(error);
setStatus(formStatus, error.message || 'Run fehlgeschlagen', 'error');
} finally {
state.running = false;
}
}
async function toggleActive(id) {
const request = state.requests.find((item) => item.id === id);
if (!request) return;
try {
await apiFetchJSON(`/automation/requests/${id}`, {
method: 'PUT',
body: JSON.stringify({ active: !request.active })
});
await loadRequests();
} catch (error) {
console.error(error);
setStatus(listStatus, error.message || 'Konnte Status nicht ändern', 'error');
}
}
async function deleteAutomation(id) {
const request = state.requests.find((item) => item.id === id);
if (!request) return;
if (!confirm(`Automation "${request.name}" wirklich löschen?`)) {
return;
}
try {
await apiFetchJSON(`/automation/requests/${id}`, { method: 'DELETE' });
if (state.selectedId === id) {
state.selectedId = null;
state.runs = [];
renderRuns();
}
await loadRequests();
} catch (error) {
console.error(error);
setStatus(listStatus, error.message || 'Konnte nicht löschen', 'error');
}
}
function parseCurlTemplate(text) {
const result = {};
const urlMatch = text.match(/curl\s+(['"]?)([^'"\s]+)\1/);
if (urlMatch) result.url = urlMatch[2];
const methodMatch = text.match(/-X\s+([A-Z]+)/i) || text.match(/--request\s+([A-Z]+)/i);
if (methodMatch) result.method = methodMatch[1].toUpperCase();
const headers = {};
const headerRegex = /-H\s+(?:(?:"([^"]+)")|(?:'([^']+)'))/gi;
let headerMatch;
while ((headerMatch = headerRegex.exec(text)) !== null) {
const raw = headerMatch[1] || headerMatch[2] || '';
const idx = raw.indexOf(':');
if (idx > -1) {
const key = raw.slice(0, idx).trim();
const value = raw.slice(idx + 1).trim();
if (key) headers[key] = value;
}
}
if (Object.keys(headers).length) result.headers = headers;
const dataMatch = text.match(/--data(?:-raw)?\s+(?:"([^"]*)"|'([^']*)')/i);
if (dataMatch) {
result.body = dataMatch[1] || dataMatch[2] || '';
}
return result;
}
function parseFetchTemplate(text) {
const result = {};
const urlMatch = text.match(/fetch\(\s*['"]([^'"]+)['"]/i);
if (urlMatch) result.url = urlMatch[1];
const optionsMatch = text.match(/fetch\(\s*['"][^'"]+['"]\s*,\s*({[\s\S]*?})\s*\)/i);
if (optionsMatch) {
try {
let optionsText = optionsMatch[1]
.replace(/,\s*\}\s*$/, '}')
.replace(/,\s*\]/g, ']');
const options = JSON.parse(optionsText);
if (options.method) result.method = options.method.toUpperCase();
if (options.headers && typeof options.headers === 'object') result.headers = options.headers;
if (options.body) result.body = options.body;
} catch (error) {
// ignore parse errors
}
}
return result;
}
function parsePowerShellTemplate(text) {
const result = {};
const urlMatch = text.match(/-Uri\s+['"]([^'"]+)['"]/i);
if (urlMatch) result.url = urlMatch[1];
const methodMatch = text.match(/-Method\s+['"]?([A-Z]+)['"]?/i);
if (methodMatch) result.method = methodMatch[1].toUpperCase();
const headersMatch = text.match(/-Headers\s+@?\{([\s\S]*?)\}/i);
if (headersMatch) {
const headersText = headersMatch[1];
const headers = {};
headersText.split(';').forEach((pair) => {
const idx = pair.indexOf('=');
if (idx === -1) return;
const key = pair.slice(0, idx).replace(/['\s]/g, '').trim();
const value = pair.slice(idx + 1).replace(/['\s]/g, '').trim();
if (key) headers[key] = value;
});
if (Object.keys(headers).length) result.headers = headers;
}
const bodyMatch = text.match(/-Body\s+['"]([\s\S]*?)['"]/i);
if (bodyMatch) result.body = bodyMatch[1];
return result;
}
function parseTemplate(raw) {
if (!raw) return null;
const text = raw.trim();
if (text.startsWith('curl')) return parseCurlTemplate(text);
if (text.includes('fetch(')) return parseFetchTemplate(text);
if (/Invoke-WebRequest|Invoke-RestMethod/i.test(text)) return parsePowerShellTemplate(text);
return null;
}
function applyImport() {
if (!importInput) return;
const raw = importInput.value;
if (!raw || !raw.trim()) {
setStatus(importStatus, 'Keine Vorlage eingegeben.', 'error');
return;
}
const parsed = parseTemplate(raw);
if (!parsed || (!parsed.url && !parsed.method && !parsed.headers && !parsed.body)) {
setStatus(importStatus, 'Konnte die Vorlage nicht erkennen.', 'error');
return;
}
// Neues Formular öffnen und mit der Vorlage befüllen
openFormModal('create');
if (parsed.url) urlInput.value = parsed.url;
if (parsed.method) methodSelect.value = parsed.method.toUpperCase();
if (parsed.headers) headersInput.value = stringifyHeaders(parsed.headers);
if (parsed.body) bodyInput.value = parsed.body;
setStatus(importStatus, 'Vorlage übernommen.', 'success');
setStatus(formStatus, 'Vorlage importiert. Prüfen & speichern.', 'success');
refreshPreview();
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;
listInstance = null;
const options = {
listClass: 'list',
valueNames: [
'name',
'type',
'email',
'url',
'steps',
{ name: 'next', attr: 'data-sort' },
{ name: 'runUntil', 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();
}
function applyFilters() {
if (!listInstance) return;
const searchName = (filterName?.value || '').toLowerCase().trim();
const searchNext = (filterNext?.value || '').toLowerCase().trim();
const searchRunUntil = (filterRunUntil?.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 matchRunUntil = !searchRunUntil || String(v.runUntil || '').toLowerCase().includes(searchRunUntil);
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 && matchRunUntil && matchLast && matchStatus && matchRuns;
});
updateSortIndicators();
}
function getSelectedRequest() {
if (!state.selectedId) return null;
return state.requests.find((item) => item.id === state.selectedId) || null;
}
function openFormModal(mode = 'create', request = null, options = {}) {
const skipReset = !!options.skipReset;
if (!skipReset) {
resetForm();
}
if (mode === 'edit' && request) {
fillForm(request);
formModeLabel.textContent = 'Automation bearbeiten';
modalTitle.textContent = 'Automation bearbeiten';
} else {
formModeLabel.textContent = 'Neue Automation';
modalTitle.textContent = 'Neue Automation';
}
formModal.hidden = false;
setTimeout(() => {
nameInput.focus();
}, 10);
refreshPreview();
}
function closeFormModal() {
formModal.hidden = true;
}
function openImportModal() {
if (!importModal) return;
if (importStatus) setStatus(importStatus, '');
importModal.hidden = false;
setTimeout(() => {
importInput?.focus();
}, 10);
}
function closeImportModal() {
if (!importModal) return;
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) {
const { action, id } = button.dataset;
if (!id) return;
switch (action) {
case 'edit':
selectRequest(id);
openFormModal('edit', state.requests.find((item) => item.id === id));
break;
case 'run':
runAutomation(id);
break;
case 'toggle':
toggleActive(id);
break;
case 'delete':
deleteAutomation(id);
break;
default:
break;
}
return;
}
const row = event.target.closest('tr[data-id]');
if (row && row.dataset.id) {
selectRequest(row.dataset.id);
}
}
function handleSseMessage(payload) {
if (!active) return;
if (!payload || !payload.type) return;
const { type, request_id: requestId } = payload;
switch (payload.type) {
case 'automation-run':
loadRequests();
if (requestId && state.selectedId && String(requestId) === String(state.selectedId)) {
loadRuns(state.selectedId);
}
break;
case 'automation-upsert':
loadRequests();
break;
default:
break;
}
}
function startSse() {
if (!active) return;
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 ensureRelativeTimer() {
if (!relativeTimer) {
updateRelativeTimes();
relativeTimer = setInterval(updateRelativeTimes, 60000);
}
}
function cleanup(options = {}) {
const { keepActive = false } = options;
if (!keepActive) {
active = false;
}
if (relativeTimer) {
clearInterval(relativeTimer);
relativeTimer = null;
}
if (sse) {
sse.close();
sse = null;
}
}
function init() {
if (initialized) return;
applyPresetDisabling();
resetForm();
ensureRelativeTimer();
form.addEventListener('submit', handleSubmit);
intervalPreset.addEventListener('change', applyPresetDisabling);
resetFormBtn.addEventListener('click', () => {
resetForm();
nameInput.focus();
});
newAutomationBtn.addEventListener('click', () => openFormModal('create'));
refreshBtn.addEventListener('click', loadRequests);
applyImportBtn?.addEventListener('click', applyImport);
refreshPreviewBtn?.addEventListener('click', refreshPreview);
[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);
});
if (addExclusionBtn) {
addExclusionBtn.addEventListener('click', addExclusion);
}
typeSelect?.addEventListener('change', () => {
applyTypeVisibility();
refreshPreview();
});
[filterName, filterNext, filterRunUntil, 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);
requestTableBody.addEventListener('click', handleTableClick);
modalCloseBtn.addEventListener('click', closeFormModal);
modalBackdrop.addEventListener('click', closeFormModal);
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
if (!formModal.hidden) {
closeFormModal();
}
if (importModal && !importModal.hidden) {
closeImportModal();
}
}
});
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
cleanup({ keepActive: true });
} else {
if (active) {
ensureRelativeTimer();
startSse();
}
}
});
window.addEventListener('beforeunload', cleanup);
window.addEventListener('pagehide', cleanup);
window.addEventListener('unload', cleanup);
initialized = true;
}
function activate() {
init();
active = true;
ensureRelativeTimer();
startSse();
loadRequests();
}
window.AutomationPage = {
activate,
deactivate: cleanup
};
const automationSection = document.querySelector('[data-view="automation"]');
if (automationSection && automationSection.classList.contains('app-view--active')) {
activate();
}
})();