diff --git a/backend/server.js b/backend/server.js index 5ffbbfb..1f5684a 100644 --- a/backend/server.js +++ b/backend/server.js @@ -30,6 +30,16 @@ const DAILY_BOOKMARK_TITLE_MAX_LENGTH = 160; const DAILY_BOOKMARK_URL_MAX_LENGTH = 800; const DAILY_BOOKMARK_NOTES_MAX_LENGTH = 800; const DAILY_BOOKMARK_MARKER_MAX_LENGTH = 120; +const AUTOMATION_MAX_NAME_LENGTH = 160; +const AUTOMATION_MAX_URL_LENGTH = 2000; +const AUTOMATION_MAX_BODY_LENGTH = 12000; +const AUTOMATION_MAX_HEADERS_LENGTH = 6000; +const AUTOMATION_MIN_INTERVAL_MINUTES = 5; +const AUTOMATION_MAX_INTERVAL_MINUTES = 60 * 24 * 14; // 2 Wochen +const AUTOMATION_DEFAULT_INTERVAL_MINUTES = 60; +const AUTOMATION_MAX_JITTER_MINUTES = 120; +const AUTOMATION_MAX_RESPONSE_PREVIEW = 4000; +const AUTOMATION_WORKER_INTERVAL_MS = 30000; const SPORTS_SCORING_DEFAULTS = { enabled: 1, threshold: 5, @@ -1277,6 +1287,178 @@ const deleteDailyBookmarkCheckStmt = db.prepare(` AND day_key = @dayKey `); +db.exec(` + CREATE TABLE IF NOT EXISTS automation_requests ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + method TEXT NOT NULL DEFAULT 'GET', + url_template TEXT NOT NULL, + headers_json TEXT, + body_template TEXT, + interval_minutes INTEGER NOT NULL DEFAULT ${AUTOMATION_DEFAULT_INTERVAL_MINUTES}, + jitter_minutes INTEGER DEFAULT 0, + start_at DATETIME, + run_until DATETIME, + active INTEGER NOT NULL DEFAULT 1, + last_run_at DATETIME, + last_status TEXT, + last_status_code INTEGER, + last_error TEXT, + next_run_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); +`); + +db.exec(` + CREATE INDEX IF NOT EXISTS idx_automation_requests_next_run + ON automation_requests(next_run_at); +`); + +db.exec(` + CREATE TABLE IF NOT EXISTS automation_request_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + request_id TEXT NOT NULL, + trigger TEXT DEFAULT 'schedule', + started_at DATETIME DEFAULT CURRENT_TIMESTAMP, + completed_at DATETIME, + status TEXT, + status_code INTEGER, + error TEXT, + response_body TEXT, + duration_ms INTEGER, + FOREIGN KEY (request_id) REFERENCES automation_requests(id) ON DELETE CASCADE + ); +`); + +db.exec(` + CREATE INDEX IF NOT EXISTS idx_automation_request_runs_request + ON automation_request_runs(request_id, started_at DESC); +`); + +const listAutomationRequestsStmt = db.prepare(` + SELECT + id, + name, + description, + method, + url_template, + headers_json, + body_template, + interval_minutes, + jitter_minutes, + start_at, + run_until, + active, + last_run_at, + last_status, + last_status_code, + last_error, + next_run_at, + created_at, + updated_at + FROM automation_requests + ORDER BY datetime(updated_at) DESC, datetime(created_at) DESC, name COLLATE NOCASE +`); + +const getAutomationRequestStmt = db.prepare(` + SELECT + id, + name, + description, + method, + url_template, + headers_json, + body_template, + interval_minutes, + jitter_minutes, + start_at, + run_until, + active, + last_run_at, + last_status, + last_status_code, + last_error, + next_run_at, + created_at, + updated_at + FROM automation_requests + WHERE id = ? +`); + +const insertAutomationRequestStmt = db.prepare(` + INSERT INTO automation_requests ( + id, name, description, method, url_template, headers_json, body_template, + interval_minutes, jitter_minutes, start_at, run_until, active, last_run_at, + last_status, last_status_code, last_error, next_run_at + ) VALUES ( + @id, @name, @description, @method, @url_template, @headers_json, @body_template, + @interval_minutes, @jitter_minutes, @start_at, @run_until, @active, @last_run_at, + @last_status, @last_status_code, @last_error, @next_run_at + ) +`); + +const updateAutomationRequestStmt = db.prepare(` + UPDATE automation_requests + SET name = @name, + description = @description, + method = @method, + url_template = @url_template, + headers_json = @headers_json, + body_template = @body_template, + interval_minutes = @interval_minutes, + jitter_minutes = @jitter_minutes, + start_at = @start_at, + run_until = @run_until, + active = @active, + last_run_at = @last_run_at, + last_status = @last_status, + last_status_code = @last_status_code, + last_error = @last_error, + next_run_at = @next_run_at, + updated_at = CURRENT_TIMESTAMP + WHERE id = @id +`); + +const deleteAutomationRequestStmt = db.prepare('DELETE FROM automation_requests WHERE id = ?'); + +const listAutomationRunsStmt = db.prepare(` + SELECT + id, + request_id, + trigger, + started_at, + completed_at, + status, + status_code, + error, + response_body, + duration_ms + FROM automation_request_runs + WHERE request_id = @requestId + ORDER BY datetime(started_at) DESC + LIMIT @limit +`); + +const insertAutomationRunStmt = db.prepare(` + INSERT INTO automation_request_runs ( + request_id, trigger, started_at, completed_at, status, status_code, error, response_body, duration_ms + ) VALUES ( + @request_id, @trigger, @started_at, @completed_at, @status, @status_code, @error, @response_body, @duration_ms + ) +`); + +const listDueAutomationRequestsStmt = db.prepare(` + SELECT * + FROM automation_requests + WHERE active = 1 + AND next_run_at IS NOT NULL + AND datetime(next_run_at) <= datetime(@now) + ORDER BY datetime(next_run_at) ASC + LIMIT 10 +`); + ensureColumn('posts', 'checked_count', 'checked_count INTEGER DEFAULT 0'); ensureColumn('posts', 'screenshot_path', 'screenshot_path TEXT'); ensureColumn('posts', 'created_by_profile', 'created_by_profile INTEGER'); @@ -1613,6 +1795,424 @@ function determineAutoDisable(error) { }; } +function parseAutomationDate(input) { + if (!input && input !== 0) { + return null; + } + if (input === 'now') { + return new Date().toISOString(); + } + if (input instanceof Date) { + const time = input.getTime(); + return Number.isNaN(time) ? null : input.toISOString(); + } + const date = new Date(input); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); +} + +function clampAutomationIntervalMinutes(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric <= 0) { + return AUTOMATION_DEFAULT_INTERVAL_MINUTES; + } + return Math.max( + AUTOMATION_MIN_INTERVAL_MINUTES, + Math.min(AUTOMATION_MAX_INTERVAL_MINUTES, Math.round(numeric)) + ); +} + +function clampAutomationJitterMinutes(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric < 0) { + return 0; + } + return Math.min(AUTOMATION_MAX_JITTER_MINUTES, Math.round(numeric)); +} + +function normalizeAutomationHeaders(raw) { + if (!raw) { + return {}; + } + + let source = raw; + if (typeof raw === 'string') { + const trimmed = raw.trim(); + if (!trimmed) { + return {}; + } + try { + source = JSON.parse(trimmed); + } catch (error) { + source = trimmed.split('\n').reduce((acc, line) => { + const idx = line.indexOf(':'); + if (idx === -1) { + return acc; + } + const key = line.slice(0, idx).trim(); + const value = line.slice(idx + 1).trim(); + if (key) { + acc[key] = value; + } + return acc; + }, {}); + } + } + + if (!source || typeof source !== 'object') { + return {}; + } + + const headers = {}; + for (const [key, value] of Object.entries(source)) { + if (!key) { + continue; + } + const normalizedKey = String(key).trim(); + if (!normalizedKey) { + continue; + } + let normalizedValue; + if (value === undefined || value === null) { + normalizedValue = ''; + } else if (typeof value === 'string') { + normalizedValue = value.trim(); + } else if (typeof value === 'number' || typeof value === 'boolean') { + normalizedValue = String(value); + } else { + try { + normalizedValue = JSON.stringify(value); + } catch (error) { + normalizedValue = ''; + } + } + headers[normalizedKey] = truncateString(normalizedValue, 1000); + } + + return headers; +} + +function serializeAutomationHeaders(headers) { + if (!headers || typeof headers !== 'object' || !Object.keys(headers).length) { + return null; + } + try { + const serialized = JSON.stringify(headers); + return serialized.length > AUTOMATION_MAX_HEADERS_LENGTH ? null : serialized; + } catch (error) { + return null; + } +} + +function renderAutomationTemplate(template, context = {}) { + if (typeof template !== 'string') { + return ''; + } + const baseDate = context.now instanceof Date ? context.now : new Date(); + + return template.replace(/\{\{\s*([^}\s]+)\s*\}\}/g, (_, keyRaw) => { + const key = String(keyRaw || '').trim(); + if (!key) { + return ''; + } + + if (key === 'uuid') { + return uuidv4(); + } + + 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); + } + + 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 buildAutomationTemplateContext(baseDate = new Date()) { + const now = baseDate instanceof Date ? baseDate : 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 renderAutomationHeaders(headersJson, context) { + if (!headersJson) { + return {}; + } + let parsed = {}; + try { + parsed = JSON.parse(headersJson); + } catch (error) { + parsed = {}; + } + if (!parsed || typeof parsed !== 'object') { + return {}; + } + const rendered = {}; + for (const [key, value] of Object.entries(parsed)) { + const renderedKey = renderAutomationTemplate(key, context).trim(); + if (!renderedKey) { + continue; + } + const renderedValue = renderAutomationTemplate( + value === undefined || value === null ? '' : String(value), + context + ); + rendered[renderedKey] = renderedValue; + } + return rendered; +} + +function computeNextAutomationRun(request, options = {}) { + if (!request) { + return null; + } + const now = options.fromDate instanceof Date ? options.fromDate : new Date(); + const intervalMinutes = clampAutomationIntervalMinutes( + request.interval_minutes || AUTOMATION_DEFAULT_INTERVAL_MINUTES + ); + const jitterMinutes = clampAutomationJitterMinutes(request.jitter_minutes || 0); + const lastRun = request.last_run_at ? new Date(request.last_run_at) : null; + const startAt = request.start_at ? new Date(request.start_at) : null; + + let base = lastRun + ? new Date(lastRun.getTime() + intervalMinutes * 60000) + : (startAt ? new Date(startAt) : new Date(now.getTime() + intervalMinutes * 60000)); + + if (base < now) { + base = new Date(now.getTime() + intervalMinutes * 60000); + } + + const jitterMs = jitterMinutes > 0 + ? Math.floor(Math.random() * ((jitterMinutes * 60000) + 1)) + : 0; + const candidate = new Date(base.getTime() + jitterMs); + + if (request.run_until) { + const until = new Date(request.run_until); + if (!Number.isNaN(until.getTime()) && candidate > until) { + return null; + } + } + + return candidate.toISOString(); +} + +function ensureNextRunForRequest(request, options = {}) { + if (!request || !request.id || !request.active) { + return null; + } + const existingNext = request.next_run_at; + if (existingNext) { + return existingNext; + } + const next = computeNextAutomationRun(request, options); + if (!next) { + return null; + } + try { + updateAutomationRequestStmt.run({ + ...request, + next_run_at: next + }); + } catch (error) { + console.warn(`Failed to persist next_run_at for ${request.id}:`, error.message); + } + return next; +} + +function serializeAutomationRequest(row) { + if (!row) { + return null; + } + let headers = {}; + if (row.headers_json) { + try { + const parsed = JSON.parse(row.headers_json); + if (parsed && typeof parsed === 'object') { + headers = parsed; + } + } catch (error) { + headers = {}; + } + } + return { + id: row.id, + name: row.name, + description: row.description || '', + method: row.method || 'GET', + url_template: row.url_template, + headers, + body_template: row.body_template || '', + interval_minutes: clampAutomationIntervalMinutes(row.interval_minutes), + jitter_minutes: clampAutomationJitterMinutes(row.jitter_minutes || 0), + start_at: row.start_at ? ensureIsoDate(row.start_at) : null, + run_until: row.run_until ? ensureIsoDate(row.run_until) : null, + active: row.active ? 1 : 0, + last_run_at: row.last_run_at ? ensureIsoDate(row.last_run_at) : null, + last_status: row.last_status || null, + last_status_code: row.last_status_code || null, + last_error: row.last_error || null, + next_run_at: row.next_run_at ? ensureIsoDate(row.next_run_at) : null, + created_at: row.created_at ? ensureIsoDate(row.created_at) : null, + updated_at: row.updated_at ? ensureIsoDate(row.updated_at) : null + }; +} + +function serializeAutomationRun(row) { + if (!row) { + return null; + } + return { + id: row.id, + request_id: row.request_id, + trigger: row.trigger || 'schedule', + started_at: row.started_at ? ensureIsoDate(row.started_at) : null, + completed_at: row.completed_at ? ensureIsoDate(row.completed_at) : null, + status: row.status || null, + status_code: row.status_code || null, + error: row.error || null, + response_body: row.response_body || '', + duration_ms: row.duration_ms || null + }; +} + +function normalizeAutomationPayload(payload, existing = {}) { + const errors = []; + const normalized = {}; + + const nameSource = typeof payload.name === 'string' + ? payload.name + : existing.name || ''; + const name = nameSource.trim(); + if (!name) { + errors.push('Name ist erforderlich'); + } else { + normalized.name = truncateString(name, AUTOMATION_MAX_NAME_LENGTH); + } + + const descriptionSource = typeof payload.description === 'string' + ? payload.description.trim() + : (existing.description || ''); + normalized.description = truncateString(descriptionSource || '', 800); + + const rawMethod = typeof payload.method === 'string' + ? payload.method.trim().toUpperCase() + : (existing.method || 'GET'); + const allowedMethods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']; + if (!allowedMethods.includes(rawMethod)) { + errors.push('Ungültige HTTP-Methode'); + } else { + normalized.method = rawMethod; + } + + const rawUrl = typeof payload.url_template === 'string' + ? payload.url_template + : (typeof payload.url === 'string' ? payload.url : existing.url_template || ''); + const urlTemplate = rawUrl.trim(); + if (!urlTemplate) { + errors.push('URL-Template fehlt'); + } else { + normalized.url_template = truncateString(urlTemplate, AUTOMATION_MAX_URL_LENGTH); + } + + const headersInput = payload.headers + || payload.headers_json + || payload.headersText + || null; + if (headersInput) { + const parsedHeaders = normalizeAutomationHeaders(headersInput); + const serializedHeaders = serializeAutomationHeaders(parsedHeaders); + if (serializedHeaders === null && Object.keys(parsedHeaders).length) { + errors.push('Headers sind zu groß oder ungültig'); + } + normalized.headers_json = serializedHeaders; + } else { + normalized.headers_json = existing.headers_json || null; + } + + if (typeof payload.body_template === 'string') { + normalized.body_template = truncateString(payload.body_template, AUTOMATION_MAX_BODY_LENGTH); + } else if (typeof payload.body === 'string') { + normalized.body_template = truncateString(payload.body, AUTOMATION_MAX_BODY_LENGTH); + } else { + normalized.body_template = typeof existing.body_template === 'string' + ? existing.body_template + : ''; + } + + const scheduleType = typeof payload.schedule_type === 'string' + ? payload.schedule_type + : payload.interval_type; + + let intervalMinutes; + if (scheduleType === 'daily') { + intervalMinutes = 24 * 60; + } else if (scheduleType === 'hourly') { + intervalMinutes = 60; + } else { + intervalMinutes = payload.interval_minutes + ?? payload.every_minutes + ?? existing.interval_minutes + ?? AUTOMATION_DEFAULT_INTERVAL_MINUTES; + } + normalized.interval_minutes = clampAutomationIntervalMinutes(intervalMinutes); + normalized.jitter_minutes = clampAutomationJitterMinutes( + payload.jitter_minutes ?? payload.variance_minutes ?? existing.jitter_minutes ?? 0 + ); + + normalized.start_at = parseAutomationDate(payload.start_at) || parseAutomationDate(existing.start_at); + normalized.run_until = parseAutomationDate(payload.run_until) || null; + normalized.active = payload.active === undefined || payload.active === null + ? (existing.active ? 1 : 0) + : (payload.active ? 1 : 0); + + return { data: normalized, errors }; +} + function recordAIUsageEvent(credentialId, eventType, options = {}) { if (!credentialId || !eventType) { return; @@ -2573,6 +3173,313 @@ function broadcastPostDeletion(postId, options = {}) { broadcastSseEvent(payload); } +const automationRunningRequests = new Set(); +let automationWorkerTimer = null; +let automationWorkerBusy = false; + +async function executeAutomationRequest(request, options = {}) { + if (!request || !request.id) { + return null; + } + + const trigger = options.trigger || 'schedule'; + const allowInactive = !!options.allowInactive; + const current = getAutomationRequestStmt.get(request.id); + if (!current || (!current.active && !allowInactive)) { + return null; + } + + if (automationRunningRequests.has(current.id)) { + return null; + } + + automationRunningRequests.add(current.id); + + const context = buildAutomationTemplateContext(new Date()); + const method = (current.method || 'GET').toUpperCase(); + const url = renderAutomationTemplate(current.url_template, context); + const headers = renderAutomationHeaders(current.headers_json, context); + const shouldSendBody = !['GET', 'HEAD'].includes(method); + const body = shouldSendBody && current.body_template + ? renderAutomationTemplate(current.body_template, context) + : null; + + const startedAt = new Date(); + let status = 'success'; + let statusCode = null; + let responseText = ''; + let errorMessage = null; + + try { + const response = await fetch(url, { + method, + headers, + body: shouldSendBody && body !== null ? body : undefined, + redirect: 'follow' + }); + statusCode = response.status; + try { + responseText = await response.text(); + } catch (error) { + responseText = ''; + } + if (!response.ok) { + status = 'error'; + errorMessage = `HTTP ${response.status}`; + } + } catch (error) { + status = 'error'; + errorMessage = error.message || 'Unbekannter Fehler'; + } + + const completedAt = new Date(); + + const runRecord = { + request_id: current.id, + trigger, + started_at: startedAt.toISOString(), + completed_at: completedAt.toISOString(), + status, + status_code: statusCode, + error: truncateString(errorMessage, 900), + response_body: truncateString(responseText, AUTOMATION_MAX_RESPONSE_PREVIEW), + duration_ms: Math.max(0, completedAt.getTime() - startedAt.getTime()) + }; + + try { + insertAutomationRunStmt.run(runRecord); + } catch (error) { + console.warn('Failed to persist automation run:', error.message); + } + + const nextRunAt = current.active + ? computeNextAutomationRun( + { ...current, last_run_at: runRecord.started_at }, + { fromDate: completedAt } + ) + : null; + + try { + updateAutomationRequestStmt.run({ + ...current, + last_run_at: runRecord.started_at, + last_status: status, + last_status_code: statusCode, + last_error: runRecord.error, + next_run_at: nextRunAt + }); + } catch (error) { + console.warn(`Failed to update automation request ${current.id}:`, error.message); + } finally { + automationRunningRequests.delete(current.id); + } + + return runRecord; +} + +async function processAutomationQueue() { + if (automationWorkerBusy) { + return; + } + + automationWorkerBusy = true; + + try { + const nowIso = new Date().toISOString(); + const dueRequests = listDueAutomationRequestsStmt.all({ now: nowIso }) || []; + for (const item of dueRequests) { + await executeAutomationRequest(item, { trigger: 'schedule' }); + } + } catch (error) { + console.error('Automation worker failed:', error); + } finally { + automationWorkerBusy = false; + } +} + +function startAutomationWorker() { + if (automationWorkerTimer) { + return; + } + + automationWorkerTimer = setInterval(() => { + processAutomationQueue().catch((error) => { + console.error('Automation queue tick failed:', error); + }); + }, AUTOMATION_WORKER_INTERVAL_MS); + + processAutomationQueue().catch((error) => { + console.error('Automation initial run failed:', error); + }); +} + +app.get('/api/automation/requests', (req, res) => { + try { + const rows = listAutomationRequestsStmt.all(); + const hydrated = rows.map((row) => { + ensureNextRunForRequest(row, { fromDate: new Date() }); + return serializeAutomationRequest(row); + }); + res.json(hydrated); + } catch (error) { + console.error('Failed to load automation requests:', error); + res.status(500).json({ error: 'Automationen konnten nicht geladen werden' }); + } +}); + +app.get('/api/automation/requests/:requestId', (req, res) => { + const { requestId } = req.params; + try { + const row = getAutomationRequestStmt.get(requestId); + if (!row) { + return res.status(404).json({ error: 'Automation nicht gefunden' }); + } + + const payload = serializeAutomationRequest(row); + ensureNextRunForRequest(row, { fromDate: new Date() }); + if (req.query && req.query.includeRuns) { + const limit = 10; + const runs = listAutomationRunsStmt.all({ requestId, limit }).map(serializeAutomationRun); + payload.runs = runs; + } + + res.json(payload); + } catch (error) { + console.error('Failed to load automation request:', error); + res.status(500).json({ error: 'Automation konnte nicht geladen werden' }); + } +}); + +app.get('/api/automation/requests/:requestId/runs', (req, res) => { + const { requestId } = req.params; + const limit = Math.max(1, Math.min(200, parseInt(req.query?.limit, 10) || 30)); + + try { + const rows = listAutomationRunsStmt.all({ requestId, limit }); + res.json(rows.map(serializeAutomationRun)); + } catch (error) { + console.error('Failed to load automation runs:', error); + res.status(500).json({ error: 'Runs konnten nicht geladen werden' }); + } +}); + +app.post('/api/automation/requests', (req, res) => { + const payload = req.body || {}; + const { data, errors } = normalizeAutomationPayload(payload, {}); + + if (errors.length) { + return res.status(400).json({ error: errors[0], details: errors }); + } + + try { + const id = uuidv4(); + let nextRunAt = data.active + ? computeNextAutomationRun({ ...data, last_run_at: null }, { fromDate: new Date() }) + : null; + + insertAutomationRequestStmt.run({ + ...data, + id, + last_run_at: null, + last_status: null, + last_status_code: null, + last_error: null, + next_run_at: nextRunAt + }); + + let saved = getAutomationRequestStmt.get(id); + if (data.active && (!saved || !saved.next_run_at)) { + const ensured = ensureNextRunForRequest(saved || data, { fromDate: new Date() }); + if (ensured) { + saved = getAutomationRequestStmt.get(id); + } + } + + res.status(201).json(serializeAutomationRequest(saved)); + } catch (error) { + console.error('Failed to create automation:', error); + res.status(500).json({ error: 'Automation konnte nicht erstellt werden' }); + } +}); + +app.put('/api/automation/requests/:requestId', (req, res) => { + const { requestId } = req.params; + const existing = getAutomationRequestStmt.get(requestId); + if (!existing) { + return res.status(404).json({ error: 'Automation nicht gefunden' }); + } + + const { data, errors } = normalizeAutomationPayload(req.body || {}, existing); + if (errors.length) { + return res.status(400).json({ error: errors[0], details: errors }); + } + + const merged = { + ...existing, + ...data, + last_run_at: existing.last_run_at, + last_status: existing.last_status, + last_status_code: existing.last_status_code, + last_error: existing.last_error + }; + + merged.next_run_at = merged.active + ? computeNextAutomationRun(merged, { fromDate: new Date() }) + : null; + + try { + updateAutomationRequestStmt.run(merged); + let saved = getAutomationRequestStmt.get(requestId); + if (merged.active && (!saved || !saved.next_run_at)) { + const ensured = ensureNextRunForRequest(saved || merged, { fromDate: new Date() }); + if (ensured) { + saved = getAutomationRequestStmt.get(requestId); + } + } + res.json(serializeAutomationRequest(saved)); + } catch (error) { + console.error('Failed to update automation:', error); + res.status(500).json({ error: 'Automation konnte nicht aktualisiert werden' }); + } +}); + +app.post('/api/automation/requests/:requestId/run', async (req, res) => { + const { requestId } = req.params; + const existing = getAutomationRequestStmt.get(requestId); + if (!existing) { + return res.status(404).json({ error: 'Automation nicht gefunden' }); + } + + try { + const run = await executeAutomationRequest(existing, { + trigger: 'manual', + allowInactive: true + }); + const refreshed = getAutomationRequestStmt.get(requestId); + res.json({ + request: serializeAutomationRequest(refreshed), + run: serializeAutomationRun(run) + }); + } catch (error) { + console.error('Failed to trigger automation run:', error); + res.status(500).json({ error: 'Automation-Run konnte nicht gestartet werden' }); + } +}); + +app.delete('/api/automation/requests/:requestId', (req, res) => { + const { requestId } = req.params; + try { + const result = deleteAutomationRequestStmt.run(requestId); + if (!result.changes) { + return res.status(404).json({ error: 'Automation nicht gefunden' }); + } + res.status(204).send(); + } catch (error) { + console.error('Failed to delete automation:', error); + res.status(500).json({ error: 'Automation konnte nicht gelöscht werden' }); + } +}); + app.get('/api/bookmarks', (req, res) => { try { const rows = listBookmarksStmt.all(); @@ -4726,6 +5633,8 @@ app.get('/health', (req, res) => { res.json({ status: 'ok' }); }); +startAutomationWorker(); + app.listen(PORT, '0.0.0.0', () => { console.log(`Server running on port ${PORT}`); }); diff --git a/web/Dockerfile b/web/Dockerfile index 756c9c5..f63986d 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -6,14 +6,17 @@ COPY dashboard.html /usr/share/nginx/html/ COPY settings.html /usr/share/nginx/html/ COPY bookmarks.html /usr/share/nginx/html/ COPY daily-bookmarks.html /usr/share/nginx/html/ +COPY automation.html /usr/share/nginx/html/ COPY style.css /usr/share/nginx/html/ COPY dashboard.css /usr/share/nginx/html/ COPY settings.css /usr/share/nginx/html/ COPY daily-bookmarks.css /usr/share/nginx/html/ +COPY automation.css /usr/share/nginx/html/ COPY app.js /usr/share/nginx/html/ COPY dashboard.js /usr/share/nginx/html/ COPY settings.js /usr/share/nginx/html/ COPY daily-bookmarks.js /usr/share/nginx/html/ +COPY automation.js /usr/share/nginx/html/ COPY assets /usr/share/nginx/html/assets/ EXPOSE 80 diff --git a/web/automation.css b/web/automation.css new file mode 100644 index 0000000..d9b4bf2 --- /dev/null +++ b/web/automation.css @@ -0,0 +1,583 @@ +:root { + --bg: #0b1020; + --card: #0f172a; + --card-soft: #131c35; + --border: rgba(255, 255, 255, 0.08); + --muted: #94a3b8; + --text: #e2e8f0; + --accent: #10b981; + --accent-2: #0ea5e9; + --danger: #ef4444; + --success: #22c55e; + --shadow: 0 20px 60px rgba(0, 0, 0, 0.25); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: 'Manrope', 'Inter', system-ui, -apple-system, sans-serif; + background: radial-gradient(120% 120% at 10% 20%, rgba(16, 185, 129, 0.12), transparent 40%), + radial-gradient(120% 120% at 80% 0%, rgba(14, 165, 233, 0.12), transparent 40%), + var(--bg); + color: var(--text); + min-height: 100vh; + padding: 32px 18px 48px; +} + +.auto-shell { + max-width: 1300px; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 18px; +} + +.auto-hero { + background: linear-gradient(135deg, rgba(16, 185, 129, 0.12), rgba(14, 165, 233, 0.08)), + var(--card); + border: 1px solid var(--border); + border-radius: 18px; + padding: 22px 22px 18px; + box-shadow: var(--shadow); +} + +.hero-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} + +.hero-text h1 { + margin: 4px 0; + font-size: 28px; + letter-spacing: -0.02em; +} + +.subline { + margin: 6px 0 10px; + color: var(--muted); +} + +.eyebrow { + font-size: 13px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--accent-2); + margin: 0; +} + +.hero-pills { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.pill { + background: rgba(255, 255, 255, 0.04); + color: var(--text); + border: 1px solid var(--border); + border-radius: 999px; + padding: 8px 12px; + font-size: 13px; +} + +.hero-actions { + display: flex; + gap: 10px; + align-items: center; +} + +.back-link { + color: var(--muted); + text-decoration: none; + font-size: 14px; +} + +.hero-stats { + margin-top: 14px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; +} + +.stat { + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--border); + border-radius: 12px; + padding: 12px; +} + +.stat-label { + color: var(--muted); + font-size: 13px; +} + +.stat-value { + font-size: 22px; + font-weight: 700; +} + +.auto-grid { + display: grid; + grid-template-columns: 1fr; + gap: 18px; +} + +.panel { + background: var(--card); + border: 1px solid var(--border); + border-radius: 16px; + padding: 18px; + box-shadow: var(--shadow); +} + +.panel-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 12px; +} + +.panel-eyebrow { + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 12px; + color: var(--muted); + margin: 0; +} + +.panel-actions { + display: flex; + gap: 8px; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.field.full { + grid-column: 1 / -1; +} + +.field.inline { + min-width: 0; +} + +label { + font-weight: 600; + color: var(--text); +} + +input, +textarea, +select { + width: 100%; + border-radius: 10px; + border: 1px solid var(--border); + padding: 10px 12px; + background: #0c1427; + color: var(--text); + font-family: inherit; + font-size: 14px; + outline: none; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +input:focus, +textarea:focus, +select:focus { + border-color: var(--accent-2); + box-shadow: 0 0 0 2px rgba(14, 165, 233, 0.15); +} + +textarea { + resize: vertical; +} + +small { + color: var(--muted); +} + +.template-hint { + border: 1px dashed var(--border); + background: rgba(255, 255, 255, 0.02); + border-radius: 12px; + padding: 10px 12px; + font-family: 'JetBrains Mono', monospace; + color: var(--muted); +} + +.template-title { + margin: 0 0 6px; + color: var(--text); + font-weight: 600; +} + +.template-copy { + margin: 0; + word-break: break-all; +} + +.form-status { + min-height: 22px; + color: var(--muted); +} + +.form-status.error { + color: var(--danger); +} + +.form-status.success { + color: var(--success); +} + +.auto-table { + width: 100%; + border-collapse: collapse; +} + +.auto-table th, +.auto-table td { + padding: 10px 8px; + text-align: left; + border-bottom: 1px solid var(--border); + font-size: 14px; +} + +.auto-table th { + color: var(--muted); + font-weight: 600; +} + +.auto-table tr.is-selected { + background: rgba(14, 165, 233, 0.08); +} + +.row-title { + font-weight: 700; + color: var(--text); +} + +.row-sub { + color: var(--muted); + font-size: 13px; + word-break: break-all; +} + +.table-wrap { + overflow-x: auto; +} + +.badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border-radius: 999px; + font-size: 12px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--border); +} + +.badge.success { + color: var(--success); + border-color: rgba(34, 197, 94, 0.35); +} + +.badge.error { + color: var(--danger); + border-color: rgba(239, 68, 68, 0.35); +} + +.row-actions { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.primary-btn, +.secondary-btn, +.ghost-btn { + border: 1px solid transparent; + border-radius: 10px; + padding: 10px 14px; + font-weight: 700; + cursor: pointer; + font-size: 14px; + transition: transform 0.15s ease, box-shadow 0.15s ease, opacity 0.15s ease; +} + +.primary-btn { + background: linear-gradient(135deg, var(--accent), var(--accent-2)); + color: #0b1020; + box-shadow: 0 10px 30px rgba(14, 165, 233, 0.35); +} + +.secondary-btn { + background: rgba(255, 255, 255, 0.06); + color: var(--text); + border-color: var(--border); +} + +.ghost-btn { + background: transparent; + color: var(--text); + border-color: var(--border); +} + +.primary-btn:hover, +.secondary-btn:hover, +.ghost-btn:hover { + transform: translateY(-1px); + opacity: 0.95; +} + +.list-status, +.runs-status, +.import-status { + min-height: 20px; + color: var(--muted); + margin-bottom: 8px; +} + +.runs-list { + list-style: none; + padding: 0; + margin: 0; + display: grid; + gap: 10px; +} + +.run-item { + border: 1px solid var(--border); + border-radius: 12px; + padding: 12px; + background: #0c1427; +} + +.run-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + flex-wrap: wrap; +} + +.run-meta { + display: flex; + gap: 8px; + align-items: center; + color: var(--muted); +} + +.run-body { + margin: 8px 0 0; + color: var(--text); + font-family: 'JetBrains Mono', monospace; + font-size: 13px; + white-space: pre-wrap; + word-break: break-all; +} + +.runs-hint { + color: var(--muted); + margin: 0; +} + +.import-panel textarea { + width: 100%; +} + +.import-hint { + color: var(--muted); + margin: 0 0 8px; +} + +.switch { + display: inline-flex; + align-items: center; + gap: 8px; + cursor: pointer; +} + +.switch input { + display: none; +} + +.switch-slider { + width: 42px; + height: 22px; + background: rgba(255, 255, 255, 0.2); + border-radius: 999px; + position: relative; + transition: background 0.2s ease; +} + +.switch-slider::after { + content: ''; + width: 18px; + height: 18px; + background: #fff; + border-radius: 50%; + position: absolute; + top: 2px; + left: 2px; + transition: transform 0.2s ease; +} + +.switch input:checked + .switch-slider { + background: var(--accent); +} + +.switch input:checked + .switch-slider::after { + transform: translateX(20px); +} + +.switch-label { + color: var(--muted); + font-weight: 600; +} + +.modal { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.45); + backdrop-filter: blur(2px); + z-index: 900; +} + +.modal__backdrop { + position: absolute; + inset: 0; +} + +.modal__content { + position: relative; + background: var(--card); + border: 1px solid var(--border); + border-radius: 16px; + padding: 18px; + width: min(1100px, 96vw); + max-height: 92vh; + overflow-y: auto; + box-shadow: var(--shadow); +} + +.modal__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} + +.modal-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.modal[hidden] { + display: none; +} + +.preview-panel { + border: 1px solid var(--border); + border-radius: 12px; + padding: 12px; + background: rgba(255, 255, 255, 0.02); +} + +.preview-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 8px; +} + +.preview-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 10px; +} + +.preview-block { + border: 1px dashed var(--border); + border-radius: 10px; + padding: 8px; + background: #0c1427; +} + +.preview-label { + margin: 0 0 4px; + color: var(--muted); + font-size: 13px; +} + +.preview-value { + margin: 0; + color: var(--text); + font-family: 'JetBrains Mono', monospace; + font-size: 13px; + white-space: pre-wrap; + word-break: break-all; + max-height: 180px; + overflow: auto; +} + +.preview-hint { + color: var(--muted); + margin: 8px 0 0; + font-size: 13px; +} + +@media (max-width: 1050px) { + .auto-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 720px) { + body { + padding: 16px 12px 32px; + } + + .panel-header { + flex-direction: column; + align-items: flex-start; + } + + .panel-actions { + width: 100%; + justify-content: flex-start; + } + + .form-grid { + grid-template-columns: 1fr; + } + + .hero-head { + flex-direction: column; + align-items: flex-start; + } +} diff --git a/web/automation.html b/web/automation.html new file mode 100644 index 0000000..ae1a535 --- /dev/null +++ b/web/automation.html @@ -0,0 +1,217 @@ + + + + + + Automationen – Post Tracker + + + + + + + +
+
+
+
+ ← Zurück zur App +

Automatisierte Requests

+

Request Automationen

+

Plane HTTP-Requests mit Zeitplan, Varianz und Variablen-Templates.

+
+ Jitter & Intervalle + Import aus cURL / fetch + Platzhalter {{date}} {{uuid}} +
+
+
+ + + +
+
+
+
+ +
+
+
+
+

Geplante Requests

+

Automationen

+
+
+ +
+
+
+
+ + + + + + + + + + + +
NameNächster LaufLetzter LaufStatusAktionen
+
+
+
+ +
+
+
+

Verlauf

+

Run-Historie

+
+

Letzte Läufe der ausgewählten Automation.

+
+
+ +
+
+ + + + + + + + diff --git a/web/automation.js b/web/automation.js new file mode 100644 index 0000000..6634326 --- /dev/null +++ b/web/automation.js @@ -0,0 +1,831 @@ +(function () { + 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; + + 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 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 requestTableBody = document.getElementById('requestTableBody'); + const listStatus = document.getElementById('listStatus'); + 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 runSelectedBtn = document.getElementById('runSelectedBtn'); + 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'); + + 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 (!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); + } + + 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 buildPayloadFromForm() { + return { + 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, + jitter_minutes: Math.max(0, parseInt(jitterInput.value, 10) || 0), + start_at: parseDateInput(startAtInput.value), + run_until: parseDateInput(runUntilInput.value), + active: activeToggle.checked + }; + } + + 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 context = buildTemplateContext(); + 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() { + 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 = ` +
+
Aktive Automationen
+
${active.length}/${state.requests.length}
+
+
+
Nächster Lauf
+
${nextRun ? formatRelative(nextRun.next_run_at) : '—'}
+
+
+
Letzter Status
+
${lastRun ? (lastRun.last_status || '—') : '—'}
+
+ `; + } + + 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 = 'Keine Automationen vorhanden.'; + return; + } + + const rows = sorted.map((req) => { + const isSelected = state.selectedId === req.id; + const statusBadge = req.last_status === 'success' + ? 'OK' + : req.last_status === 'error' + ? 'Fehler' + : ''; + + return ` + + +
${req.name}
+
${req.method || 'GET'} · ${req.url_template}
+ + ${formatRelative(req.next_run_at)}
${formatDateTime(req.next_run_at)} + ${formatRelative(req.last_run_at)}
${req.last_status_code || '—'} + ${statusBadge} + +
+ + + + +
+ + + `; + }); + + requestTableBody.innerHTML = rows.join(''); + + if (runSelectedBtn) { + runSelectedBtn.disabled = !state.selectedId; + } + } + + function renderRuns() { + if (!runsList) return; + runsList.innerHTML = ''; + if (!state.runs.length) { + runsList.innerHTML = '
  • Noch keine Läufe.
  • '; + return; + } + + runsList.innerHTML = state.runs.map((run) => { + const badge = run.status === 'success' + ? 'OK' + : run.status === 'error' + ? 'Fehler' + : 'Offen'; + return ` +
  • +
    +
    + ${badge} + ${formatDateTime(run.started_at)} + Code: ${run.status_code ?? '—'} + Dauer: ${run.duration_ms ? `${run.duration_ms} ms` : '—'} +
    +
    + ${run.error ? `
    Fehler: ${run.error}
    ` : ''} + ${run.response_body ? `
    ${run.response_body}
    ` : ''} +
  • + `; + }).join(''); + } + + function resetForm() { + form.reset(); + editingId = null; + 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 = ''; + formModeLabel.textContent = 'Neue Automation'; + setStatus(formStatus, ''); + } + + 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 (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); + applyPresetDisabling(); + refreshPreview(); + } + + async function handleSubmit(event) { + event.preventDefault(); + if (state.saving) return; + const payload = buildPayloadFromForm(); + if (!payload.name || !payload.url_template) { + setStatus(formStatus, 'Name und URL sind Pflichtfelder.', '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 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 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 init() { + applyPresetDisabling(); + resetForm(); + loadRequests(); + + form.addEventListener('submit', handleSubmit); + intervalPreset.addEventListener('change', applyPresetDisabling); + resetFormBtn.addEventListener('click', () => { + resetForm(); + nameInput.focus(); + }); + 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) => { + el?.addEventListener('input', refreshPreview); + }); + 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(); + } + } + }); + } + + init(); +})(); diff --git a/web/index.html b/web/index.html index 07e464b..affadea 100644 --- a/web/index.html +++ b/web/index.html @@ -24,6 +24,7 @@ 📊 Dashboard ⚙️ Einstellungen 🔖 Bookmarks + ⚡️ Automationen ✅ Daily Bookmarks