aktueller stand

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

View File

@@ -11,7 +11,8 @@
"express": "^4.18.2", "express": "^4.18.2",
"cors": "^2.8.5", "cors": "^2.8.5",
"better-sqlite3": "^9.2.2", "better-sqlite3": "^9.2.2",
"uuid": "^9.0.1" "uuid": "^9.0.1",
"nodemailer": "^6.9.14"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.0.2" "nodemon": "^3.0.2"

View File

@@ -30,6 +30,9 @@ const DAILY_BOOKMARK_TITLE_MAX_LENGTH = 160;
const DAILY_BOOKMARK_URL_MAX_LENGTH = 800; const DAILY_BOOKMARK_URL_MAX_LENGTH = 800;
const DAILY_BOOKMARK_NOTES_MAX_LENGTH = 800; const DAILY_BOOKMARK_NOTES_MAX_LENGTH = 800;
const DAILY_BOOKMARK_MARKER_MAX_LENGTH = 120; const DAILY_BOOKMARK_MARKER_MAX_LENGTH = 120;
const AUTOMATION_TYPE_REQUEST = 'request';
const AUTOMATION_TYPE_EMAIL = 'email';
const AUTOMATION_TYPE_FLOW = 'flow';
const AUTOMATION_MAX_NAME_LENGTH = 160; const AUTOMATION_MAX_NAME_LENGTH = 160;
const AUTOMATION_MAX_URL_LENGTH = 2000; const AUTOMATION_MAX_URL_LENGTH = 2000;
const AUTOMATION_MAX_BODY_LENGTH = 12000; const AUTOMATION_MAX_BODY_LENGTH = 12000;
@@ -40,6 +43,9 @@ const AUTOMATION_DEFAULT_INTERVAL_MINUTES = 60;
const AUTOMATION_MAX_JITTER_MINUTES = 120; const AUTOMATION_MAX_JITTER_MINUTES = 120;
const AUTOMATION_MAX_RESPONSE_PREVIEW = 4000; const AUTOMATION_MAX_RESPONSE_PREVIEW = 4000;
const AUTOMATION_WORKER_INTERVAL_MS = 30000; const AUTOMATION_WORKER_INTERVAL_MS = 30000;
const AUTOMATION_MAX_STEPS = 3;
const AUTOMATION_MAX_EMAIL_TO_LENGTH = 320;
const AUTOMATION_MAX_EMAIL_SUBJECT_LENGTH = 320;
const SPORTS_SCORING_DEFAULTS = { const SPORTS_SCORING_DEFAULTS = {
enabled: 1, enabled: 1,
threshold: 5, threshold: 5,
@@ -90,6 +96,54 @@ if (!fs.existsSync(screenshotDir)) {
fs.mkdirSync(screenshotDir, { recursive: true }); fs.mkdirSync(screenshotDir, { recursive: true });
} }
const automationConfigPath = path.join(__dirname, 'data', 'automation-config.json');
const defaultAutomationConfig = {
smtp: {
host: '',
port: 587,
secure: false,
user: '',
pass: '',
from: ''
}
};
function ensureAutomationConfigFile() {
try {
if (!fs.existsSync(automationConfigPath)) {
const dir = path.dirname(automationConfigPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(automationConfigPath, JSON.stringify(defaultAutomationConfig, null, 2), 'utf8');
console.log('Automation-Config angelegt unter', automationConfigPath);
}
} catch (error) {
console.warn('Konnte Automation-Config nicht erstellen:', error.message);
}
}
ensureAutomationConfigFile();
function loadAutomationConfig() {
try {
const raw = fs.readFileSync(automationConfigPath, 'utf8');
const parsed = JSON.parse(raw);
return parsed && typeof parsed === 'object' ? parsed : { ...defaultAutomationConfig };
} catch (error) {
return { ...defaultAutomationConfig };
}
}
const automationConfig = loadAutomationConfig();
let nodemailer = null;
try {
nodemailer = require('nodemailer');
} catch (error) {
nodemailer = null;
}
// Middleware - Enhanced CORS for extension // Middleware - Enhanced CORS for extension
app.use(cors({ app.use(cors({
origin: (origin, callback) => { origin: (origin, callback) => {
@@ -1292,10 +1346,15 @@ db.exec(`
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
description TEXT, description TEXT,
type TEXT NOT NULL DEFAULT 'request',
method TEXT NOT NULL DEFAULT 'GET', method TEXT NOT NULL DEFAULT 'GET',
url_template TEXT NOT NULL, url_template TEXT,
headers_json TEXT, headers_json TEXT,
body_template TEXT, body_template TEXT,
email_to TEXT,
email_subject_template TEXT,
email_body_template TEXT,
steps_json TEXT,
interval_minutes INTEGER NOT NULL DEFAULT ${AUTOMATION_DEFAULT_INTERVAL_MINUTES}, interval_minutes INTEGER NOT NULL DEFAULT ${AUTOMATION_DEFAULT_INTERVAL_MINUTES},
jitter_minutes INTEGER DEFAULT 0, jitter_minutes INTEGER DEFAULT 0,
start_at DATETIME, start_at DATETIME,
@@ -1311,6 +1370,12 @@ db.exec(`
); );
`); `);
ensureColumn('automation_requests', 'type', 'type TEXT NOT NULL DEFAULT \'request\'');
ensureColumn('automation_requests', 'email_to', 'email_to TEXT');
ensureColumn('automation_requests', 'email_subject_template', 'email_subject_template TEXT');
ensureColumn('automation_requests', 'email_body_template', 'email_body_template TEXT');
ensureColumn('automation_requests', 'steps_json', 'steps_json TEXT');
db.exec(` db.exec(`
CREATE INDEX IF NOT EXISTS idx_automation_requests_next_run CREATE INDEX IF NOT EXISTS idx_automation_requests_next_run
ON automation_requests(next_run_at); ON automation_requests(next_run_at);
@@ -1342,10 +1407,15 @@ const listAutomationRequestsStmt = db.prepare(`
id, id,
name, name,
description, description,
type,
method, method,
url_template, url_template,
headers_json, headers_json,
body_template, body_template,
email_to,
email_subject_template,
email_body_template,
steps_json,
interval_minutes, interval_minutes,
jitter_minutes, jitter_minutes,
start_at, start_at,
@@ -1356,6 +1426,11 @@ const listAutomationRequestsStmt = db.prepare(`
last_status_code, last_status_code,
last_error, last_error,
next_run_at, next_run_at,
(
SELECT COUNT(1)
FROM automation_request_runs r
WHERE r.request_id = automation_requests.id
) AS runs_count,
created_at, created_at,
updated_at updated_at
FROM automation_requests FROM automation_requests
@@ -1367,10 +1442,15 @@ const getAutomationRequestStmt = db.prepare(`
id, id,
name, name,
description, description,
type,
method, method,
url_template, url_template,
headers_json, headers_json,
body_template, body_template,
email_to,
email_subject_template,
email_body_template,
steps_json,
interval_minutes, interval_minutes,
jitter_minutes, jitter_minutes,
start_at, start_at,
@@ -1389,11 +1469,13 @@ const getAutomationRequestStmt = db.prepare(`
const insertAutomationRequestStmt = db.prepare(` const insertAutomationRequestStmt = db.prepare(`
INSERT INTO automation_requests ( INSERT INTO automation_requests (
id, name, description, method, url_template, headers_json, body_template, id, name, description, type, method, url_template, headers_json, body_template,
email_to, email_subject_template, email_body_template, steps_json,
interval_minutes, jitter_minutes, start_at, run_until, active, last_run_at, interval_minutes, jitter_minutes, start_at, run_until, active, last_run_at,
last_status, last_status_code, last_error, next_run_at last_status, last_status_code, last_error, next_run_at
) VALUES ( ) VALUES (
@id, @name, @description, @method, @url_template, @headers_json, @body_template, @id, @name, @description, @type, @method, @url_template, @headers_json, @body_template,
@email_to, @email_subject_template, @email_body_template, @steps_json,
@interval_minutes, @jitter_minutes, @start_at, @run_until, @active, @last_run_at, @interval_minutes, @jitter_minutes, @start_at, @run_until, @active, @last_run_at,
@last_status, @last_status_code, @last_error, @next_run_at @last_status, @last_status_code, @last_error, @next_run_at
) )
@@ -1403,10 +1485,15 @@ const updateAutomationRequestStmt = db.prepare(`
UPDATE automation_requests UPDATE automation_requests
SET name = @name, SET name = @name,
description = @description, description = @description,
type = @type,
method = @method, method = @method,
url_template = @url_template, url_template = @url_template,
headers_json = @headers_json, headers_json = @headers_json,
body_template = @body_template, body_template = @body_template,
email_to = @email_to,
email_subject_template = @email_subject_template,
email_body_template = @email_body_template,
steps_json = @steps_json,
interval_minutes = @interval_minutes, interval_minutes = @interval_minutes,
jitter_minutes = @jitter_minutes, jitter_minutes = @jitter_minutes,
start_at = @start_at, start_at = @start_at,
@@ -1486,7 +1573,6 @@ ensureColumn('ai_credentials', 'usage_24h_count', 'usage_24h_count INTEGER DEFAU
ensureColumn('ai_credentials', 'usage_24h_reset_at', 'usage_24h_reset_at DATETIME'); ensureColumn('ai_credentials', 'usage_24h_reset_at', 'usage_24h_reset_at DATETIME');
ensureColumn('search_seen_posts', 'manually_hidden', 'manually_hidden INTEGER NOT NULL DEFAULT 0'); ensureColumn('search_seen_posts', 'manually_hidden', 'manually_hidden INTEGER NOT NULL DEFAULT 0');
ensureColumn('search_seen_posts', 'sports_auto_hidden', 'sports_auto_hidden INTEGER NOT NULL DEFAULT 0'); ensureColumn('search_seen_posts', 'sports_auto_hidden', 'sports_auto_hidden INTEGER NOT NULL DEFAULT 0');
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS ai_usage_events ( CREATE TABLE IF NOT EXISTS ai_usage_events (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -1927,6 +2013,14 @@ function renderAutomationTemplate(template, context = {}) {
return shifted.toISOString().slice(0, 10); return shifted.toISOString().slice(0, 10);
} }
const dayOffsetMatch = key.match(/^day([+-]\d+)?$/);
if (dayOffsetMatch) {
const offset = dayOffsetMatch[1] ? parseInt(dayOffsetMatch[1], 10) : 0;
const shifted = new Date(baseDate);
shifted.setDate(baseDate.getDate() + (Number.isNaN(offset) ? 0 : offset));
return String(shifted.getDate()).padStart(2, '0');
}
switch (key) { switch (key) {
case 'today': case 'today':
case 'date': case 'date':
@@ -2082,11 +2176,24 @@ function serializeAutomationRequest(row) {
return { return {
id: row.id, id: row.id,
name: row.name, name: row.name,
type: row.type || AUTOMATION_TYPE_REQUEST,
description: row.description || '', description: row.description || '',
method: row.method || 'GET', method: row.method || 'GET',
url_template: row.url_template, url_template: row.url_template,
headers, headers,
body_template: row.body_template || '', body_template: row.body_template || '',
email_to: row.email_to || '',
email_subject_template: row.email_subject_template || '',
email_body_template: row.email_body_template || '',
steps: row.steps_json ? (() => {
try {
const parsed = JSON.parse(row.steps_json);
return Array.isArray(parsed) ? parsed : [];
} catch (error) {
return [];
}
})() : [],
runs_count: row.runs_count || 0,
interval_minutes: clampAutomationIntervalMinutes(row.interval_minutes), interval_minutes: clampAutomationIntervalMinutes(row.interval_minutes),
jitter_minutes: clampAutomationJitterMinutes(row.jitter_minutes || 0), jitter_minutes: clampAutomationJitterMinutes(row.jitter_minutes || 0),
start_at: row.start_at ? ensureIsoDate(row.start_at) : null, start_at: row.start_at ? ensureIsoDate(row.start_at) : null,
@@ -2124,6 +2231,14 @@ function normalizeAutomationPayload(payload, existing = {}) {
const errors = []; const errors = [];
const normalized = {}; const normalized = {};
const rawType = typeof payload.type === 'string'
? payload.type.trim().toLowerCase()
: (existing.type || AUTOMATION_TYPE_REQUEST);
const type = [AUTOMATION_TYPE_REQUEST, AUTOMATION_TYPE_EMAIL, AUTOMATION_TYPE_FLOW].includes(rawType)
? rawType
: AUTOMATION_TYPE_REQUEST;
normalized.type = type;
const nameSource = typeof payload.name === 'string' const nameSource = typeof payload.name === 'string'
? payload.name ? payload.name
: existing.name || ''; : existing.name || '';
@@ -2152,11 +2267,17 @@ function normalizeAutomationPayload(payload, existing = {}) {
const rawUrl = typeof payload.url_template === 'string' const rawUrl = typeof payload.url_template === 'string'
? payload.url_template ? payload.url_template
: (typeof payload.url === 'string' ? payload.url : existing.url_template || ''); : (typeof payload.url === 'string' ? payload.url : existing.url_template || '');
const urlTemplate = rawUrl.trim(); const urlTemplate = (rawUrl || '').trim();
if (!urlTemplate) {
if (type === AUTOMATION_TYPE_REQUEST || type === AUTOMATION_TYPE_FLOW) {
if (!urlTemplate && type === AUTOMATION_TYPE_REQUEST) {
errors.push('URL-Template fehlt'); errors.push('URL-Template fehlt');
}
normalized.url_template = urlTemplate
? truncateString(urlTemplate, AUTOMATION_MAX_URL_LENGTH)
: null;
} else { } else {
normalized.url_template = truncateString(urlTemplate, AUTOMATION_MAX_URL_LENGTH); normalized.url_template = null;
} }
const headersInput = payload.headers const headersInput = payload.headers
@@ -2184,6 +2305,82 @@ function normalizeAutomationPayload(payload, existing = {}) {
: ''; : '';
} }
if (type === AUTOMATION_TYPE_EMAIL) {
const toValue = (payload.email_to || payload.to || existing.email_to || '').trim();
if (!toValue) {
errors.push('E-Mail Empfänger fehlt');
} else {
normalized.email_to = truncateString(toValue, AUTOMATION_MAX_EMAIL_TO_LENGTH);
}
const subjectValue = (payload.email_subject_template || payload.subject || existing.email_subject_template || '').trim();
if (!subjectValue) {
errors.push('E-Mail Betreff fehlt');
} else {
normalized.email_subject_template = truncateString(subjectValue, AUTOMATION_MAX_EMAIL_SUBJECT_LENGTH);
}
const bodyValue = typeof payload.email_body_template === 'string'
? payload.email_body_template
: (typeof payload.body_template === 'string' ? payload.body_template : existing.email_body_template || '');
if (!bodyValue || !String(bodyValue).trim()) {
errors.push('E-Mail Body fehlt');
} else {
normalized.email_body_template = truncateString(String(bodyValue), AUTOMATION_MAX_BODY_LENGTH);
}
normalized.steps_json = null;
normalized.url_template = '';
normalized.headers_json = null;
normalized.body_template = null;
} else if (type === AUTOMATION_TYPE_FLOW) {
const stepsInput = Array.isArray(payload.steps)
? payload.steps
: (typeof payload.steps_json === 'string' ? (() => {
try { return JSON.parse(payload.steps_json); } catch (err) { return []; }
})() : []);
const steps = [];
for (const rawStep of stepsInput) {
if (!rawStep || typeof rawStep !== 'object') continue;
const stepUrl = typeof rawStep.url === 'string' ? rawStep.url.trim() : '';
if (!stepUrl) continue;
const stepMethod = typeof rawStep.method === 'string'
? rawStep.method.toUpperCase()
: 'GET';
const allowed = allowedMethods.includes(stepMethod) ? stepMethod : 'GET';
const stepHeaders = normalizeAutomationHeaders(rawStep.headers || rawStep.headers_json || {});
const stepHeadersSerialized = serializeAutomationHeaders(stepHeaders);
steps.push({
method: allowed,
url: truncateString(stepUrl, AUTOMATION_MAX_URL_LENGTH),
headers: stepHeadersSerialized ? JSON.parse(stepHeadersSerialized) : {},
body: typeof rawStep.body === 'string'
? truncateString(rawStep.body, AUTOMATION_MAX_BODY_LENGTH)
: ''
});
if (steps.length >= AUTOMATION_MAX_STEPS) break;
}
if (!steps.length) {
errors.push('Mindestens ein Schritt mit URL ist erforderlich');
} else {
try {
const serializedSteps = JSON.stringify(steps);
normalized.steps_json = serializedSteps;
} catch (error) {
errors.push('Schritte konnten nicht gespeichert werden');
}
}
normalized.email_to = null;
normalized.email_subject_template = null;
normalized.email_body_template = null;
normalized.url_template = normalized.url_template || '';
} else {
normalized.email_to = null;
normalized.email_subject_template = null;
normalized.email_body_template = null;
normalized.steps_json = null;
}
const scheduleType = typeof payload.schedule_type === 'string' const scheduleType = typeof payload.schedule_type === 'string'
? payload.schedule_type ? payload.schedule_type
: payload.interval_type; : payload.interval_type;
@@ -3173,6 +3370,14 @@ function broadcastPostDeletion(postId, options = {}) {
broadcastSseEvent(payload); broadcastSseEvent(payload);
} }
function broadcastAutomationEvent(eventType, payload = {}) {
if (!eventType) {
return;
}
const data = { type: eventType, ...payload };
broadcastSseEvent(data);
}
const automationRunningRequests = new Set(); const automationRunningRequests = new Set();
let automationWorkerTimer = null; let automationWorkerTimer = null;
let automationWorkerBusy = false; let automationWorkerBusy = false;
@@ -3196,19 +3401,26 @@ async function executeAutomationRequest(request, options = {}) {
automationRunningRequests.add(current.id); automationRunningRequests.add(current.id);
const context = buildAutomationTemplateContext(new Date()); 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(); const startedAt = new Date();
let status = 'success'; let status = 'success';
let statusCode = null; let statusCode = null;
let responseText = ''; let responseText = '';
let errorMessage = null; let errorMessage = null;
const stepResults = [];
async function executeHttpStep(step, stepIndex, inheritedContext) {
const method = (step.method || 'GET').toUpperCase();
const url = renderAutomationTemplate(step.url_template || step.url, inheritedContext);
const headers = renderAutomationHeaders(step.headers_json || serializeAutomationHeaders(step.headers || {}), inheritedContext);
const shouldSendBody = !['GET', 'HEAD'].includes(method);
const body = shouldSendBody && (step.body_template || step.body)
? renderAutomationTemplate(step.body_template || step.body, inheritedContext)
: null;
let localStatus = 'success';
let localStatusCode = null;
let localResponseText = '';
let localError = null;
try { try {
const response = await fetch(url, { const response = await fetch(url, {
@@ -3217,15 +3429,159 @@ async function executeAutomationRequest(request, options = {}) {
body: shouldSendBody && body !== null ? body : undefined, body: shouldSendBody && body !== null ? body : undefined,
redirect: 'follow' redirect: 'follow'
}); });
statusCode = response.status; localStatusCode = response.status;
try { try {
responseText = await response.text(); localResponseText = await response.text();
} catch (error) { } catch (error) {
responseText = ''; localResponseText = '';
} }
if (!response.ok) { if (!response.ok) {
localStatus = 'error';
localError = `HTTP ${response.status}`;
}
} catch (error) {
localStatus = 'error';
localError = error.message || 'Unbekannter Fehler';
}
let parsedJson = null;
if (localResponseText) {
try {
parsedJson = JSON.parse(localResponseText);
} catch (error) {
parsedJson = null;
}
}
const resultContext = {
...inheritedContext,
[`step${stepIndex}_text`]: localResponseText,
[`step${stepIndex}_status`]: localStatus,
[`step${stepIndex}_status_code`]: localStatusCode,
[`step${stepIndex}_json`]: parsedJson || null
};
return {
status: localStatus,
statusCode: localStatusCode,
responseText: localResponseText,
error: localError,
context: resultContext
};
}
async function executeEmailStep(stepContext) {
if (!nodemailer) {
throw new Error('E-Mail Versand nicht verfügbar (nodemailer fehlt)');
}
const {
SMTP_HOST,
SMTP_PORT,
SMTP_USER,
SMTP_PASS,
SMTP_SECURE,
SMTP_FROM
} = process.env;
const configSmtp = (automationConfig && automationConfig.smtp) || {};
const host = SMTP_HOST || configSmtp.host;
if (!host) {
throw new Error('SMTP_HOST nicht gesetzt und keine Konfiguration in automation-config.json');
}
const port = SMTP_PORT ? Number(SMTP_PORT) : (configSmtp.port || 587);
const secure = SMTP_SECURE === 'true' || SMTP_SECURE === '1'
? true
: (typeof configSmtp.secure === 'boolean' ? configSmtp.secure : port === 465);
const user = SMTP_USER || configSmtp.user || '';
const pass = SMTP_PASS || configSmtp.pass || '';
const from = SMTP_FROM || configSmtp.from || user || 'automation@example.com';
const transporter = nodemailer.createTransport({
host,
port,
secure,
auth: user ? { user, pass } : undefined
});
const to = renderAutomationTemplate(current.email_to || '', stepContext);
const subject = renderAutomationTemplate(current.email_subject_template || '', stepContext);
const body = renderAutomationTemplate(current.email_body_template || '', stepContext);
const bodyHtml = body ? body.replace(/\n/g, '<br>') : '';
if (!to || !subject || !body) {
throw new Error('E-Mail Felder unvollständig');
}
const info = await transporter.sendMail({
from,
to,
subject,
text: body,
html: bodyHtml || body
});
return {
status: 'success',
statusCode: info && info.accepted ? 200 : null,
responseText: info ? JSON.stringify({ messageId: info.messageId }) : '',
error: null,
context: { ...stepContext }
};
}
try {
if (current.type === AUTOMATION_TYPE_EMAIL) {
const emailResult = await executeEmailStep(context);
status = emailResult.status;
statusCode = emailResult.statusCode;
responseText = emailResult.responseText;
errorMessage = emailResult.error;
} else if (current.type === AUTOMATION_TYPE_FLOW) {
let flowContext = { ...context };
const steps = current.steps_json ? (() => {
try {
const parsed = JSON.parse(current.steps_json);
return Array.isArray(parsed) ? parsed : [];
} catch (error) {
return [];
}
})() : [];
let stepIndex = 1;
for (const step of steps) {
const stepResult = await executeHttpStep({
method: step.method || step.http_method || 'GET',
url_template: step.url_template || step.url,
headers_json: serializeAutomationHeaders(step.headers || {}),
body_template: step.body_template || step.body
}, stepIndex, flowContext);
stepResults.push(stepResult);
flowContext = { ...flowContext, ...stepResult.context };
if (stepResult.status === 'error') {
status = 'error'; status = 'error';
errorMessage = `HTTP ${response.status}`; errorMessage = stepResult.error;
statusCode = stepResult.statusCode;
responseText = stepResult.responseText;
break;
} else {
statusCode = stepResult.statusCode;
responseText = stepResult.responseText;
}
stepIndex += 1;
}
} else {
const result = await executeHttpStep({
method: current.method,
url_template: current.url_template,
headers_json: current.headers_json,
body_template: current.body_template
}, 1, context);
status = result.status;
statusCode = result.statusCode;
responseText = result.responseText;
errorMessage = result.error;
stepResults.push(result);
} }
} catch (error) { } catch (error) {
status = 'error'; status = 'error';
@@ -3259,6 +3615,15 @@ async function executeAutomationRequest(request, options = {}) {
) )
: null; : null;
broadcastAutomationEvent('automation-run', {
request_id: current.id,
status,
status_code: statusCode,
next_run_at: nextRunAt,
last_run_at: runRecord.started_at,
runs_count: (current.runs_count || 0) + 1
});
try { try {
updateAutomationRequestStmt.run({ updateAutomationRequestStmt.run({
...current, ...current,

View File

@@ -17,6 +17,7 @@ COPY dashboard.js /usr/share/nginx/html/
COPY settings.js /usr/share/nginx/html/ COPY settings.js /usr/share/nginx/html/
COPY daily-bookmarks.js /usr/share/nginx/html/ COPY daily-bookmarks.js /usr/share/nginx/html/
COPY automation.js /usr/share/nginx/html/ COPY automation.js /usr/share/nginx/html/
COPY vendor /usr/share/nginx/html/vendor/
COPY assets /usr/share/nginx/html/assets/ COPY assets /usr/share/nginx/html/assets/
EXPOSE 80 EXPOSE 80

View File

@@ -28,7 +28,7 @@ body {
} }
.auto-shell { .auto-shell {
max-width: 1300px; max-width: 1500px;
margin: 0 auto; margin: 0 auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -177,6 +177,9 @@ body {
.field.inline { .field.inline {
min-width: 0; min-width: 0;
} }
.field[data-section] {
display: grid;
}
label { label {
font-weight: 600; font-weight: 600;
@@ -257,6 +260,7 @@ small {
text-align: left; text-align: left;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
font-size: 14px; font-size: 14px;
white-space: nowrap;
} }
.auto-table th { .auto-table th {
@@ -264,6 +268,48 @@ small {
font-weight: 600; font-weight: 600;
} }
.auto-table th[data-sort-column="runs"],
.auto-table td.runs-count {
width: 1%;
white-space: nowrap;
text-align: right;
}
.auto-table .sort-indicator {
display: inline-block;
margin-left: 6px;
width: 10px;
height: 10px;
border: 5px solid transparent;
border-bottom: 0;
border-left: 0;
transform: rotate(45deg);
opacity: 0.35;
}
.auto-table th.sort-asc .sort-indicator {
border-top: 6px solid var(--accent-2);
transform: rotate(225deg);
opacity: 0.9;
}
.auto-table th.sort-desc .sort-indicator {
border-top: 6px solid var(--accent-2);
transform: rotate(45deg);
opacity: 0.9;
}
.table-filter-row input,
.table-filter-row select {
width: 100%;
border-radius: 8px;
border: 1px solid var(--border);
padding: 8px 10px;
background: #0c1427;
color: var(--text);
font-size: 13px;
}
.auto-table tr.is-selected { .auto-table tr.is-selected {
background: rgba(14, 165, 233, 0.08); background: rgba(14, 165, 233, 0.08);
} }
@@ -307,7 +353,34 @@ small {
.row-actions { .row-actions {
display: flex; display: flex;
gap: 6px; gap: 6px;
flex-wrap: wrap; flex-wrap: nowrap;
}
.hidden-value {
display: none;
}
.icon-btn {
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.04);
color: var(--text);
border-radius: 8px;
padding: 6px 8px;
cursor: pointer;
font-size: 14px;
line-height: 1;
transition: transform 0.15s ease, opacity 0.15s ease, border-color 0.15s ease;
}
.icon-btn:hover {
transform: translateY(-1px);
opacity: 0.95;
border-color: rgba(255, 255, 255, 0.2);
}
.icon-btn.danger {
color: #ef4444;
border-color: rgba(239, 68, 68, 0.35);
} }
.primary-btn, .primary-btn,
@@ -355,6 +428,15 @@ small {
margin-bottom: 8px; margin-bottom: 8px;
} }
.filter-input {
border-radius: 10px;
border: 1px solid var(--border);
padding: 8px 10px;
background: #0c1427;
color: var(--text);
min-width: 200px;
}
.runs-list { .runs-list {
list-style: none; list-style: none;
padding: 0; padding: 0;
@@ -551,6 +633,32 @@ small {
font-size: 13px; font-size: 13px;
} }
.placeholder-table {
width: 100%;
border-collapse: collapse;
margin-top: 6px;
}
.placeholder-table td {
padding: 6px 8px;
border-bottom: 1px solid var(--border);
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
color: var(--text);
vertical-align: top;
}
.placeholder-table td.placeholder-key {
width: 30%;
color: var(--muted);
}
.placeholder-hint {
color: var(--muted);
font-size: 12px;
margin: 6px 0 0;
}
@media (max-width: 1050px) { @media (max-width: 1050px) {
.auto-grid { .auto-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;

View File

@@ -18,15 +18,8 @@
<a class="back-link" href="index.html">← Zurück zur App</a> <a class="back-link" href="index.html">← Zurück zur App</a>
<p class="eyebrow">Automatisierte Requests</p> <p class="eyebrow">Automatisierte Requests</p>
<h1>Request Automationen</h1> <h1>Request Automationen</h1>
<p class="subline">Plane HTTP-Requests mit Zeitplan, Varianz und Variablen-Templates.</p>
<div class="hero-pills">
<span class="pill">Jitter & Intervalle</span>
<span class="pill">Import aus cURL / fetch</span>
<span class="pill">Platzhalter {{date}} {{uuid}}</span>
</div>
</div> </div>
<div class="hero-actions"> <div class="hero-actions">
<button class="ghost-btn" id="runSelectedBtn" type="button">▶ Ausgewählte jetzt ausführen</button>
<button class="ghost-btn" id="openImportBtn" type="button">📥 Vorlage importieren</button> <button class="ghost-btn" id="openImportBtn" type="button">📥 Vorlage importieren</button>
<button class="primary-btn" id="newAutomationBtn" type="button">+ Neue Automation</button> <button class="primary-btn" id="newAutomationBtn" type="button">+ Neue Automation</button>
</div> </div>
@@ -42,22 +35,38 @@
<h2>Automationen</h2> <h2>Automationen</h2>
</div> </div>
<div class="panel-actions"> <div class="panel-actions">
<input id="tableFilterInput" class="filter-input" type="search" placeholder="Filtern nach Name/Typ…" />
<button class="ghost-btn" id="refreshBtn" type="button">Aktualisieren</button> <button class="ghost-btn" id="refreshBtn" type="button">Aktualisieren</button>
</div> </div>
</div> </div>
<div id="listStatus" class="list-status" aria-live="polite"></div> <div id="listStatus" class="list-status" aria-live="polite"></div>
<div class="table-wrap"> <div class="table-wrap" id="automationTable">
<table class="auto-table"> <table class="auto-table">
<thead> <thead>
<tr> <tr>
<th>Name</th> <th data-sort-column="name">Name<span class="sort-indicator"></span></th>
<th>Nächster Lauf</th> <th data-sort-column="next">Nächster Lauf<span class="sort-indicator"></span></th>
<th>Letzter Lauf</th> <th data-sort-column="last">Letzter Lauf<span class="sort-indicator"></span></th>
<th>Status</th> <th data-sort-column="status">Status<span class="sort-indicator"></span></th>
<th data-sort-column="runs">#Läufe<span class="sort-indicator"></span></th>
<th>Aktionen</th> <th>Aktionen</th>
</tr> </tr>
<tr class="table-filter-row">
<th><input id="filterName" type="search" placeholder="Name/Typ/E-Mail/URL"></th>
<th><input id="filterNext" type="search" placeholder="z.B. heute"></th>
<th><input id="filterLast" type="search" placeholder="z.B. HTTP 200"></th>
<th>
<select id="filterStatus">
<option value="">Alle</option>
<option value="success">OK</option>
<option value="error">Fehler</option>
</select>
</th>
<th><input id="filterRuns" type="number" min="0" placeholder="≥"></th>
<th></th>
</tr>
</thead> </thead>
<tbody id="requestTableBody"></tbody> <tbody id="requestTableBody" class="list"></tbody>
</table> </table>
</div> </div>
</section> </section>
@@ -87,6 +96,14 @@
<button class="ghost-btn" id="modalCloseBtn" type="button">×</button> <button class="ghost-btn" id="modalCloseBtn" type="button">×</button>
</div> </div>
<form id="automationForm" class="form-grid" novalidate> <form id="automationForm" class="form-grid" novalidate>
<div class="field">
<label for="typeSelect">Typ</label>
<select id="typeSelect">
<option value="request">HTTP Request</option>
<option value="email">E-Mail</option>
<option value="flow">Flow (bis 3 Schritte)</option>
</select>
</div>
<div class="field"> <div class="field">
<label for="nameInput">Name *</label> <label for="nameInput">Name *</label>
<input id="nameInput" type="text" placeholder="API Ping (stündlich)" required maxlength="160"> <input id="nameInput" type="text" placeholder="API Ping (stündlich)" required maxlength="160">
@@ -95,11 +112,11 @@
<label for="descriptionInput">Notizen</label> <label for="descriptionInput">Notizen</label>
<textarea id="descriptionInput" rows="2" placeholder="Kurzbeschreibung oder Zweck"></textarea> <textarea id="descriptionInput" rows="2" placeholder="Kurzbeschreibung oder Zweck"></textarea>
</div> </div>
<div class="field"> <div class="field" data-section="http">
<label for="urlInput">URL-Template *</label> <label for="urlInput">URL-Template *</label>
<input id="urlInput" type="url" placeholder="https://api.example.com/{{date}}/trigger" required> <input id="urlInput" type="url" placeholder="https://api.example.com/{{date}}/trigger" required>
</div> </div>
<div class="field inline"> <div class="field inline" data-section="http">
<label for="methodSelect">Methode</label> <label for="methodSelect">Methode</label>
<select id="methodSelect"> <select id="methodSelect">
<option value="GET">GET</option> <option value="GET">GET</option>
@@ -117,14 +134,76 @@
<span class="switch-label">Plan aktiv</span> <span class="switch-label">Plan aktiv</span>
</label> </label>
</div> </div>
<div class="field"> <div class="field" data-section="http">
<label for="headersInput">Headers (Key: Value pro Zeile oder JSON)</label> <label for="headersInput">Headers (Key: Value pro Zeile oder JSON)</label>
<textarea id="headersInput" rows="3" placeholder="Authorization: Bearer {{token}}\nContent-Type: application/json"></textarea> <textarea id="headersInput" rows="3" placeholder="Authorization: Bearer {{token}}\nContent-Type: application/json"></textarea>
</div> </div>
<div class="field"> <div class="field" data-section="http">
<label for="bodyInput">Body (optional, Templates möglich)</label> <label for="bodyInput">Body (optional, Templates möglich)</label>
<textarea id="bodyInput" rows="5" placeholder='{"date":"{{date}}","id":"{{uuid}}"}'></textarea> <textarea id="bodyInput" rows="5" placeholder='{"date":"{{date}}","id":"{{uuid}}"}'></textarea>
</div> </div>
<div class="field full" data-section="email">
<label for="emailToInput">E-Mail Empfänger *</label>
<input id="emailToInput" type="text" placeholder="max@example.com, lisa@example.com">
</div>
<div class="field full" data-section="email">
<label for="emailSubjectInput">Betreff *</label>
<input id="emailSubjectInput" type="text" placeholder="Status Update {{date}}">
</div>
<div class="field full" data-section="email">
<label for="emailBodyInput">Body *</label>
<textarea id="emailBodyInput" rows="6" placeholder="Hallo,\nheutiger Status: {{uuid}}"></textarea>
</div>
<div class="field full" data-section="flow">
<div class="template-hint">
<p class="template-title">Flow Schritte</p>
<p class="template-copy">Max. 3 Schritte, Kontext steht als {{step1_json}}, {{step1_text}}, {{step1_status_code}} usw. im nächsten Schritt zur Verfügung.</p>
</div>
</div>
<div class="field" data-section="flow">
<label for="flowStep1Url">Step 1 URL *</label>
<input id="flowStep1Url" type="url" placeholder="https://api.example.com/first">
</div>
<div class="field inline" data-section="flow">
<label for="flowStep1Method">Step 1 Methode</label>
<select id="flowStep1Method">
<option value="GET">GET</option>
<option value="POST">POST</option>
<option value="PUT">PUT</option>
<option value="PATCH">PATCH</option>
<option value="DELETE">DELETE</option>
</select>
</div>
<div class="field" data-section="flow">
<label for="flowStep1Headers">Step 1 Headers</label>
<textarea id="flowStep1Headers" rows="2" placeholder="Authorization: Bearer {{token}}"></textarea>
</div>
<div class="field" data-section="flow">
<label for="flowStep1Body">Step 1 Body</label>
<textarea id="flowStep1Body" rows="3" placeholder='{"id":"{{uuid}}"}'></textarea>
</div>
<div class="field" data-section="flow">
<label for="flowStep2Url">Step 2 URL (optional)</label>
<input id="flowStep2Url" type="url" placeholder="https://api.example.com/second">
</div>
<div class="field inline" data-section="flow">
<label for="flowStep2Method">Step 2 Methode</label>
<select id="flowStep2Method">
<option value="GET">GET</option>
<option value="POST">POST</option>
<option value="PUT">PUT</option>
<option value="PATCH">PATCH</option>
<option value="DELETE">DELETE</option>
</select>
</div>
<div class="field" data-section="flow">
<label for="flowStep2Headers">Step 2 Headers</label>
<textarea id="flowStep2Headers" rows="2" placeholder="Authorization: Bearer {{token}}"></textarea>
</div>
<div class="field" data-section="flow">
<label for="flowStep2Body">Step 2 Body</label>
<textarea id="flowStep2Body" rows="3" placeholder='{"fromStep1":"{{step1_json.id}}"}'></textarea>
</div>
<div class="field inline"> <div class="field inline">
<label for="intervalPreset">Intervall</label> <label for="intervalPreset">Intervall</label>
<select id="intervalPreset"> <select id="intervalPreset">
@@ -153,7 +232,10 @@
<div class="field full"> <div class="field full">
<div class="template-hint"> <div class="template-hint">
<p class="template-title">Platzhalter</p> <p class="template-title">Platzhalter</p>
<p class="template-copy">{{date}}, {{datetime}}, {{uuid}}, {{timestamp}}, {{weekday}}, {{day}}, {{month}}, {{year}}, {{date+1}}</p> <table class="placeholder-table">
<tbody id="placeholderTableBody"></tbody>
</table>
<p class="placeholder-hint">Beispiele beziehen sich auf den aktuellen Zeitpunkt.</p>
</div> </div>
</div> </div>
<div class="field full"> <div class="field full">
@@ -212,6 +294,7 @@
</div> </div>
</div> </div>
<script src="vendor/list.min.js"></script>
<script src="automation.js"></script> <script src="automation.js"></script>
</body> </body>
</html> </html>

View File

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

1
web/vendor/list.min.js vendored Normal file
View File

@@ -0,0 +1 @@
!function(t){if(typeof window!=="undefined"){t(window)}else if(typeof global!=="undefined"){t(global)}}(function(window){function getValue(row,name,attr){var el=row.querySelector("."+name);if(!el){return""}if(attr){return el.getAttribute(attr)||""}return el.textContent||el.innerText||""}function normalizeOrder(order){return order==="desc"?"desc":"asc"}function List(container,options){if(!(this instanceof List))return new List(container,options);this.container=typeof container==="string"?document.querySelector(container):container;if(!this.container){throw new Error("List container not found")}this.listClass=options&&options.listClass?options.listClass:"list";this.valueNames=options&&options.valueNames?options.valueNames:[];this.listEl=this.container.querySelector("."+this.listClass);if(!this.listEl){throw new Error("List element not found")}this.items=Array.from(this.listEl.children);this.filteredItems=this.items.slice();this.sortOrder="asc";this.sortKey=null}List.prototype.filter=function(fn){this.filteredItems=[];for(var i=0;i<this.items.length;i++){var row=this.items[i];var values=this._buildValues(row);if(fn({values:function(){return values}})){this.filteredItems.push(row)}}this._render();return this};List.prototype.sort=function(key,opts){var order=normalizeOrder(opts&&opts.order);this.sortKey=key;this.sortOrder=order;var self=this;var kv=this._resolveKeySpec(key);this.filteredItems.sort(function(a,b){var va=getValue(a,kv.name,kv.attr);var vb=getValue(b,kv.name,kv.attr);var na=Number(va),nb=Number(vb);var cmp;if(!isNaN(na)&&!isNaN(nb)){cmp=na-nb}else{cmp=String(va).localeCompare(String(vb))}return order==="desc"?-cmp:cmp});this._render();return this};List.prototype._resolveKeySpec=function(key){for(var i=0;i<this.valueNames.length;i++){var spec=this.valueNames[i];if(typeof spec==="string"&&spec===key){return {name:key,attr:null}}if(spec&&spec.name===key){return {name:spec.name,attr:spec.attr||null}}}return {name:key,attr:null}};List.prototype._buildValues=function(row){var out={};for(var i=0;i<this.valueNames.length;i++){var spec=this.valueNames[i],name=typeof spec==="string"?spec:spec.name,attr=spec.attr||null;out[name]=getValue(row,name,attr)}return out};List.prototype._render=function(){var parent=this.listEl;parent.innerHTML="";for(var i=0;i<this.filteredItems.length;i++){parent.appendChild(this.filteredItems[i])}};window.List=List});