diff --git a/backend/package.json b/backend/package.json
index 1ba2b61..4459b4c 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -11,9 +11,10 @@
"express": "^4.18.2",
"cors": "^2.8.5",
"better-sqlite3": "^9.2.2",
- "uuid": "^9.0.1"
+ "uuid": "^9.0.1",
+ "nodemailer": "^6.9.14"
},
"devDependencies": {
"nodemon": "^3.0.2"
}
-}
\ No newline at end of file
+}
diff --git a/backend/server.js b/backend/server.js
index 1f5684a..2c4b588 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -30,6 +30,9 @@ 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_TYPE_REQUEST = 'request';
+const AUTOMATION_TYPE_EMAIL = 'email';
+const AUTOMATION_TYPE_FLOW = 'flow';
const AUTOMATION_MAX_NAME_LENGTH = 160;
const AUTOMATION_MAX_URL_LENGTH = 2000;
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_RESPONSE_PREVIEW = 4000;
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 = {
enabled: 1,
threshold: 5,
@@ -90,6 +96,54 @@ if (!fs.existsSync(screenshotDir)) {
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
app.use(cors({
origin: (origin, callback) => {
@@ -1292,10 +1346,15 @@ db.exec(`
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
+ type TEXT NOT NULL DEFAULT 'request',
method TEXT NOT NULL DEFAULT 'GET',
- url_template TEXT NOT NULL,
+ url_template TEXT,
headers_json 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},
jitter_minutes INTEGER DEFAULT 0,
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(`
CREATE INDEX IF NOT EXISTS idx_automation_requests_next_run
ON automation_requests(next_run_at);
@@ -1342,10 +1407,15 @@ const listAutomationRequestsStmt = db.prepare(`
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,
@@ -1356,6 +1426,11 @@ const listAutomationRequestsStmt = db.prepare(`
last_status_code,
last_error,
next_run_at,
+ (
+ SELECT COUNT(1)
+ FROM automation_request_runs r
+ WHERE r.request_id = automation_requests.id
+ ) AS runs_count,
created_at,
updated_at
FROM automation_requests
@@ -1367,10 +1442,15 @@ const getAutomationRequestStmt = db.prepare(`
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,
@@ -1389,11 +1469,13 @@ const getAutomationRequestStmt = db.prepare(`
const insertAutomationRequestStmt = db.prepare(`
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,
last_status, last_status_code, last_error, next_run_at
) 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,
@last_status, @last_status_code, @last_error, @next_run_at
)
@@ -1403,10 +1485,15 @@ const updateAutomationRequestStmt = db.prepare(`
UPDATE automation_requests
SET name = @name,
description = @description,
+ type = @type,
method = @method,
url_template = @url_template,
headers_json = @headers_json,
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,
jitter_minutes = @jitter_minutes,
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('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');
-
db.exec(`
CREATE TABLE IF NOT EXISTS ai_usage_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -1927,6 +2013,14 @@ function renderAutomationTemplate(template, context = {}) {
return shifted.toISOString().slice(0, 10);
}
+ const dayOffsetMatch = key.match(/^day([+-]\d+)?$/);
+ if (dayOffsetMatch) {
+ const offset = dayOffsetMatch[1] ? parseInt(dayOffsetMatch[1], 10) : 0;
+ const shifted = new Date(baseDate);
+ shifted.setDate(baseDate.getDate() + (Number.isNaN(offset) ? 0 : offset));
+ return String(shifted.getDate()).padStart(2, '0');
+ }
+
switch (key) {
case 'today':
case 'date':
@@ -2082,11 +2176,24 @@ function serializeAutomationRequest(row) {
return {
id: row.id,
name: row.name,
+ type: row.type || AUTOMATION_TYPE_REQUEST,
description: row.description || '',
method: row.method || 'GET',
url_template: row.url_template,
headers,
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),
jitter_minutes: clampAutomationJitterMinutes(row.jitter_minutes || 0),
start_at: row.start_at ? ensureIsoDate(row.start_at) : null,
@@ -2124,6 +2231,14 @@ function normalizeAutomationPayload(payload, existing = {}) {
const errors = [];
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'
? payload.name
: existing.name || '';
@@ -2152,11 +2267,17 @@ function normalizeAutomationPayload(payload, existing = {}) {
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');
+ const urlTemplate = (rawUrl || '').trim();
+
+ if (type === AUTOMATION_TYPE_REQUEST || type === AUTOMATION_TYPE_FLOW) {
+ if (!urlTemplate && type === AUTOMATION_TYPE_REQUEST) {
+ errors.push('URL-Template fehlt');
+ }
+ normalized.url_template = urlTemplate
+ ? truncateString(urlTemplate, AUTOMATION_MAX_URL_LENGTH)
+ : null;
} else {
- normalized.url_template = truncateString(urlTemplate, AUTOMATION_MAX_URL_LENGTH);
+ normalized.url_template = null;
}
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'
? payload.schedule_type
: payload.interval_type;
@@ -3173,6 +3370,14 @@ function broadcastPostDeletion(postId, options = {}) {
broadcastSseEvent(payload);
}
+function broadcastAutomationEvent(eventType, payload = {}) {
+ if (!eventType) {
+ return;
+ }
+ const data = { type: eventType, ...payload };
+ broadcastSseEvent(data);
+}
+
const automationRunningRequests = new Set();
let automationWorkerTimer = null;
let automationWorkerBusy = false;
@@ -3196,36 +3401,187 @@ async function executeAutomationRequest(request, options = {}) {
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;
+ 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 {
+ const response = await fetch(url, {
+ method,
+ headers,
+ body: shouldSendBody && body !== null ? body : undefined,
+ redirect: 'follow'
+ });
+ localStatusCode = response.status;
+ try {
+ localResponseText = await response.text();
+ } catch (error) {
+ localResponseText = '';
+ }
+ 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, '
') : '';
+
+ 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 {
- 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}`;
+ 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';
+ 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) {
status = 'error';
@@ -3259,6 +3615,15 @@ async function executeAutomationRequest(request, options = {}) {
)
: 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 {
updateAutomationRequestStmt.run({
...current,
diff --git a/web/Dockerfile b/web/Dockerfile
index f63986d..7c869cb 100644
--- a/web/Dockerfile
+++ b/web/Dockerfile
@@ -17,6 +17,7 @@ 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 vendor /usr/share/nginx/html/vendor/
COPY assets /usr/share/nginx/html/assets/
EXPOSE 80
diff --git a/web/automation.css b/web/automation.css
index d9b4bf2..e0ed921 100644
--- a/web/automation.css
+++ b/web/automation.css
@@ -28,7 +28,7 @@ body {
}
.auto-shell {
- max-width: 1300px;
+ max-width: 1500px;
margin: 0 auto;
display: flex;
flex-direction: column;
@@ -177,6 +177,9 @@ body {
.field.inline {
min-width: 0;
}
+.field[data-section] {
+ display: grid;
+}
label {
font-weight: 600;
@@ -257,6 +260,7 @@ small {
text-align: left;
border-bottom: 1px solid var(--border);
font-size: 14px;
+ white-space: nowrap;
}
.auto-table th {
@@ -264,6 +268,48 @@ small {
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 {
background: rgba(14, 165, 233, 0.08);
}
@@ -307,7 +353,34 @@ small {
.row-actions {
display: flex;
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,
@@ -355,6 +428,15 @@ small {
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 {
list-style: none;
padding: 0;
@@ -551,6 +633,32 @@ small {
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) {
.auto-grid {
grid-template-columns: 1fr;
diff --git a/web/automation.html b/web/automation.html
index ae1a535..484efd1 100644
--- a/web/automation.html
+++ b/web/automation.html
@@ -18,15 +18,8 @@
← Zurück zur App
Automatisierte Requests
Plane HTTP-Requests mit Zeitplan, Varianz und Variablen-Templates.
-| Name | -Nächster Lauf | -Letzter Lauf | -Status | +Name | +Nächster Lauf | +Letzter Lauf | +Status | +#Läufe | Aktionen |
|---|---|---|---|---|---|---|---|---|---|
| + | + | + | + + | ++ | + |