Geplante Requests
+Automationen
+| Name | +Nächster Lauf | +Letzter Lauf | +Status | +Aktionen | +
|---|
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 @@ + + +
+ + +Automatisierte Requests
+Plane HTTP-Requests mit Zeitplan, Varianz und Variablen-Templates.
+Geplante Requests
+| Name | +Nächster Lauf | +Letzter Lauf | +Status | +Aktionen | +
|---|
Verlauf
+Letzte Läufe der ausgewählten Automation.
+Neue Automation
+Import
+Füge hier "Copy as cURL", "Copy as fetch" oder Powershell ein. Header, Methode, Body und URL werden übernommen.
+ +