aktueller stand
This commit is contained in:
@@ -30,6 +30,16 @@ 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_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 = {
|
const SPORTS_SCORING_DEFAULTS = {
|
||||||
enabled: 1,
|
enabled: 1,
|
||||||
threshold: 5,
|
threshold: 5,
|
||||||
@@ -1277,6 +1287,178 @@ const deleteDailyBookmarkCheckStmt = db.prepare(`
|
|||||||
AND day_key = @dayKey
|
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', 'checked_count', 'checked_count INTEGER DEFAULT 0');
|
||||||
ensureColumn('posts', 'screenshot_path', 'screenshot_path TEXT');
|
ensureColumn('posts', 'screenshot_path', 'screenshot_path TEXT');
|
||||||
ensureColumn('posts', 'created_by_profile', 'created_by_profile INTEGER');
|
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 = {}) {
|
function recordAIUsageEvent(credentialId, eventType, options = {}) {
|
||||||
if (!credentialId || !eventType) {
|
if (!credentialId || !eventType) {
|
||||||
return;
|
return;
|
||||||
@@ -2573,6 +3173,313 @@ function broadcastPostDeletion(postId, options = {}) {
|
|||||||
broadcastSseEvent(payload);
|
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) => {
|
app.get('/api/bookmarks', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const rows = listBookmarksStmt.all();
|
const rows = listBookmarksStmt.all();
|
||||||
@@ -4726,6 +5633,8 @@ app.get('/health', (req, res) => {
|
|||||||
res.json({ status: 'ok' });
|
res.json({ status: 'ok' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
startAutomationWorker();
|
||||||
|
|
||||||
app.listen(PORT, '0.0.0.0', () => {
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
console.log(`Server running on port ${PORT}`);
|
console.log(`Server running on port ${PORT}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,14 +6,17 @@ COPY dashboard.html /usr/share/nginx/html/
|
|||||||
COPY settings.html /usr/share/nginx/html/
|
COPY settings.html /usr/share/nginx/html/
|
||||||
COPY bookmarks.html /usr/share/nginx/html/
|
COPY bookmarks.html /usr/share/nginx/html/
|
||||||
COPY daily-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 style.css /usr/share/nginx/html/
|
||||||
COPY dashboard.css /usr/share/nginx/html/
|
COPY dashboard.css /usr/share/nginx/html/
|
||||||
COPY settings.css /usr/share/nginx/html/
|
COPY settings.css /usr/share/nginx/html/
|
||||||
COPY daily-bookmarks.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 app.js /usr/share/nginx/html/
|
||||||
COPY dashboard.js /usr/share/nginx/html/
|
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 assets /usr/share/nginx/html/assets/
|
COPY assets /usr/share/nginx/html/assets/
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|||||||
583
web/automation.css
Normal file
583
web/automation.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
217
web/automation.html
Normal file
217
web/automation.html
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Automationen – Post Tracker</title>
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="assets/app-icon-64.png">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="automation.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="auto-shell">
|
||||||
|
<header class="auto-hero">
|
||||||
|
<div class="hero-head">
|
||||||
|
<div class="hero-text">
|
||||||
|
<a class="back-link" href="index.html">← Zurück zur App</a>
|
||||||
|
<p class="eyebrow">Automatisierte Requests</p>
|
||||||
|
<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 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="primary-btn" id="newAutomationBtn" type="button">+ Neue Automation</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hero-stats" id="heroStats"></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="auto-grid">
|
||||||
|
<section class="panel list-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div>
|
||||||
|
<p class="panel-eyebrow">Geplante Requests</p>
|
||||||
|
<h2>Automationen</h2>
|
||||||
|
</div>
|
||||||
|
<div class="panel-actions">
|
||||||
|
<button class="ghost-btn" id="refreshBtn" type="button">Aktualisieren</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="listStatus" class="list-status" aria-live="polite"></div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="auto-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Nächster Lauf</th>
|
||||||
|
<th>Letzter Lauf</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="requestTableBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<section class="panel runs-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div>
|
||||||
|
<p class="panel-eyebrow">Verlauf</p>
|
||||||
|
<h2>Run-Historie</h2>
|
||||||
|
</div>
|
||||||
|
<p class="runs-hint">Letzte Läufe der ausgewählten Automation.</p>
|
||||||
|
</div>
|
||||||
|
<div id="runsStatus" class="runs-status" aria-live="polite"></div>
|
||||||
|
<ul id="runsList" class="runs-list"></ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="formModal" class="modal" hidden>
|
||||||
|
<div class="modal__backdrop" id="formModalBackdrop"></div>
|
||||||
|
<div class="modal__content" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
|
||||||
|
<div class="modal__header">
|
||||||
|
<div>
|
||||||
|
<p class="panel-eyebrow" id="formModeLabel">Neue Automation</p>
|
||||||
|
<h2 id="modalTitle">Request & Zeitplan</h2>
|
||||||
|
</div>
|
||||||
|
<button class="ghost-btn" id="modalCloseBtn" type="button">×</button>
|
||||||
|
</div>
|
||||||
|
<form id="automationForm" class="form-grid" novalidate>
|
||||||
|
<div class="field">
|
||||||
|
<label for="nameInput">Name *</label>
|
||||||
|
<input id="nameInput" type="text" placeholder="API Ping (stündlich)" required maxlength="160">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="descriptionInput">Notizen</label>
|
||||||
|
<textarea id="descriptionInput" rows="2" placeholder="Kurzbeschreibung oder Zweck"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="urlInput">URL-Template *</label>
|
||||||
|
<input id="urlInput" type="url" placeholder="https://api.example.com/{{date}}/trigger" required>
|
||||||
|
</div>
|
||||||
|
<div class="field inline">
|
||||||
|
<label for="methodSelect">Methode</label>
|
||||||
|
<select id="methodSelect">
|
||||||
|
<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 inline">
|
||||||
|
<label for="activeToggle">Aktiv</label>
|
||||||
|
<label class="switch">
|
||||||
|
<input type="checkbox" id="activeToggle" checked>
|
||||||
|
<span class="switch-slider"></span>
|
||||||
|
<span class="switch-label">Plan aktiv</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="bodyInput">Body (optional, Templates möglich)</label>
|
||||||
|
<textarea id="bodyInput" rows="5" placeholder='{"date":"{{date}}","id":"{{uuid}}"}'></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="field inline">
|
||||||
|
<label for="intervalPreset">Intervall</label>
|
||||||
|
<select id="intervalPreset">
|
||||||
|
<option value="hourly">Jede Stunde</option>
|
||||||
|
<option value="daily">Jeden Tag</option>
|
||||||
|
<option value="custom">Eigene Minuten</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field inline">
|
||||||
|
<label for="intervalMinutesInput">Intervall (Minuten)</label>
|
||||||
|
<input id="intervalMinutesInput" type="number" min="5" max="20160" step="5" value="60">
|
||||||
|
</div>
|
||||||
|
<div class="field inline">
|
||||||
|
<label for="jitterInput">Varianz (Minuten)</label>
|
||||||
|
<input id="jitterInput" type="number" min="0" max="120" step="5" value="10">
|
||||||
|
<small>Auslösung erfolgt zufällig +0…Varianz Min nach dem Intervall.</small>
|
||||||
|
</div>
|
||||||
|
<div class="field inline">
|
||||||
|
<label for="startAtInput">Start ab</label>
|
||||||
|
<input id="startAtInput" type="datetime-local">
|
||||||
|
</div>
|
||||||
|
<div class="field inline">
|
||||||
|
<label for="runUntilInput">Läuft bis</label>
|
||||||
|
<input id="runUntilInput" type="datetime-local">
|
||||||
|
</div>
|
||||||
|
<div class="field full">
|
||||||
|
<div class="template-hint">
|
||||||
|
<p class="template-title">Platzhalter</p>
|
||||||
|
<p class="template-copy">{{date}}, {{datetime}}, {{uuid}}, {{timestamp}}, {{weekday}}, {{day}}, {{month}}, {{year}}, {{date+1}}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field full">
|
||||||
|
<div class="preview-panel">
|
||||||
|
<div class="preview-header">
|
||||||
|
<div>
|
||||||
|
<p class="panel-eyebrow">Vorschau</p>
|
||||||
|
<h3>Aufgelöste Werte</h3>
|
||||||
|
</div>
|
||||||
|
<button class="secondary-btn" type="button" id="refreshPreviewBtn">Aktualisieren</button>
|
||||||
|
</div>
|
||||||
|
<div class="preview-grid">
|
||||||
|
<div class="preview-block">
|
||||||
|
<p class="preview-label">URL</p>
|
||||||
|
<pre id="previewUrl" class="preview-value">—</pre>
|
||||||
|
</div>
|
||||||
|
<div class="preview-block">
|
||||||
|
<p class="preview-label">Headers</p>
|
||||||
|
<pre id="previewHeaders" class="preview-value">—</pre>
|
||||||
|
</div>
|
||||||
|
<div class="preview-block">
|
||||||
|
<p class="preview-label">Body</p>
|
||||||
|
<pre id="previewBody" class="preview-value">—</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="preview-hint">Die Vorschau nutzt die aktuellen Formularwerte und füllt Platzhalter ({{date}}, {{uuid}}, …) mit Beispielwerten.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field full modal-actions">
|
||||||
|
<div id="formStatus" class="form-status" aria-live="polite"></div>
|
||||||
|
<div class="panel-actions">
|
||||||
|
<button class="ghost-btn" id="resetFormBtn" type="button">Zurücksetzen</button>
|
||||||
|
<button class="primary-btn" id="saveBtn" type="submit">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="importModal" class="modal" hidden>
|
||||||
|
<div class="modal__backdrop" id="importModalBackdrop"></div>
|
||||||
|
<div class="modal__content" role="dialog" aria-modal="true" aria-labelledby="importTitle">
|
||||||
|
<div class="modal__header">
|
||||||
|
<div>
|
||||||
|
<p class="panel-eyebrow">Import</p>
|
||||||
|
<h2 id="importTitle">Vorlage einfügen</h2>
|
||||||
|
</div>
|
||||||
|
<button class="ghost-btn" id="importCloseBtn" type="button">×</button>
|
||||||
|
</div>
|
||||||
|
<p class="import-hint">Füge hier "Copy as cURL", "Copy as fetch" oder Powershell ein. Header, Methode, Body und URL werden übernommen.</p>
|
||||||
|
<textarea id="importInput" rows="7" placeholder="curl https://api.example.com -X POST -H 'Authorization: Bearer token' --data '{\"hello\":\"world\"}'"></textarea>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<div id="importStatus" class="import-status" aria-live="polite"></div>
|
||||||
|
<button class="secondary-btn" id="applyImportBtn" type="button">Vorlage übernehmen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="automation.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
831
web/automation.js
Normal file
831
web/automation.js
Normal file
@@ -0,0 +1,831 @@
|
|||||||
|
(function () {
|
||||||
|
const API_URL = (() => {
|
||||||
|
if (window.API_URL) return window.API_URL;
|
||||||
|
try {
|
||||||
|
return `${window.location.origin}/api`;
|
||||||
|
} catch (error) {
|
||||||
|
return 'https://fb.srv.medeba-media.de/api';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
const DEFAULT_INTERVAL_MINUTES = 60;
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
requests: [],
|
||||||
|
runs: [],
|
||||||
|
selectedId: null,
|
||||||
|
loading: false,
|
||||||
|
saving: false,
|
||||||
|
running: false
|
||||||
|
};
|
||||||
|
|
||||||
|
let editingId = null;
|
||||||
|
|
||||||
|
const form = document.getElementById('automationForm');
|
||||||
|
const nameInput = document.getElementById('nameInput');
|
||||||
|
const descriptionInput = document.getElementById('descriptionInput');
|
||||||
|
const urlInput = document.getElementById('urlInput');
|
||||||
|
const methodSelect = document.getElementById('methodSelect');
|
||||||
|
const headersInput = document.getElementById('headersInput');
|
||||||
|
const bodyInput = document.getElementById('bodyInput');
|
||||||
|
const intervalPreset = document.getElementById('intervalPreset');
|
||||||
|
const intervalMinutesInput = document.getElementById('intervalMinutesInput');
|
||||||
|
const jitterInput = document.getElementById('jitterInput');
|
||||||
|
const startAtInput = document.getElementById('startAtInput');
|
||||||
|
const runUntilInput = document.getElementById('runUntilInput');
|
||||||
|
const activeToggle = document.getElementById('activeToggle');
|
||||||
|
const formStatus = document.getElementById('formStatus');
|
||||||
|
const formModeLabel = document.getElementById('formModeLabel');
|
||||||
|
const formModal = document.getElementById('formModal');
|
||||||
|
const modalBackdrop = document.getElementById('formModalBackdrop');
|
||||||
|
const modalCloseBtn = document.getElementById('modalCloseBtn');
|
||||||
|
|
||||||
|
const requestTableBody = document.getElementById('requestTableBody');
|
||||||
|
const listStatus = document.getElementById('listStatus');
|
||||||
|
const runsList = document.getElementById('runsList');
|
||||||
|
const runsStatus = document.getElementById('runsStatus');
|
||||||
|
const heroStats = document.getElementById('heroStats');
|
||||||
|
|
||||||
|
const saveBtn = document.getElementById('saveBtn');
|
||||||
|
const resetFormBtn = document.getElementById('resetFormBtn');
|
||||||
|
const refreshBtn = document.getElementById('refreshBtn');
|
||||||
|
const newAutomationBtn = document.getElementById('newAutomationBtn');
|
||||||
|
const runSelectedBtn = document.getElementById('runSelectedBtn');
|
||||||
|
const modalTitle = document.getElementById('modalTitle');
|
||||||
|
const importInput = document.getElementById('importInput');
|
||||||
|
const importStatus = document.getElementById('importStatus');
|
||||||
|
const applyImportBtn = document.getElementById('applyImportBtn');
|
||||||
|
const openImportBtn = document.getElementById('openImportBtn');
|
||||||
|
const importModal = document.getElementById('importModal');
|
||||||
|
const importModalBackdrop = document.getElementById('importModalBackdrop');
|
||||||
|
const importCloseBtn = document.getElementById('importCloseBtn');
|
||||||
|
const previewUrl = document.getElementById('previewUrl');
|
||||||
|
const previewHeaders = document.getElementById('previewHeaders');
|
||||||
|
const previewBody = document.getElementById('previewBody');
|
||||||
|
const refreshPreviewBtn = document.getElementById('refreshPreviewBtn');
|
||||||
|
|
||||||
|
function toDateTimeLocal(value) {
|
||||||
|
if (!value) return '';
|
||||||
|
const date = value instanceof Date ? value : new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return '';
|
||||||
|
const pad = (num) => String(num).padStart(2, '0');
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDateInput(value) {
|
||||||
|
if (!value) return null;
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiFetchJSON(path, options = {}) {
|
||||||
|
const opts = {
|
||||||
|
credentials: 'include',
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
opts.headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(options.headers || {})
|
||||||
|
};
|
||||||
|
const response = await fetch(`${API_URL}${path}`, opts);
|
||||||
|
if (!response.ok) {
|
||||||
|
let message = 'Unbekannter Fehler';
|
||||||
|
try {
|
||||||
|
const err = await response.json();
|
||||||
|
message = err.error || message;
|
||||||
|
} catch (error) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
if (response.status === 204) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(target, message, type = 'info') {
|
||||||
|
if (!target) return;
|
||||||
|
target.textContent = message || '';
|
||||||
|
target.classList.remove('error', 'success');
|
||||||
|
if (type === 'error') target.classList.add('error');
|
||||||
|
if (type === 'success') target.classList.add('success');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value) {
|
||||||
|
if (!value) return '—';
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return '—';
|
||||||
|
return date.toLocaleString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelative(value) {
|
||||||
|
if (!value) return '—';
|
||||||
|
const date = new Date(value);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = date.getTime() - now.getTime();
|
||||||
|
const diffMinutes = Math.round(diffMs / 60000);
|
||||||
|
if (Number.isNaN(diffMinutes)) return '—';
|
||||||
|
if (Math.abs(diffMinutes) < 1) return 'jetzt';
|
||||||
|
if (diffMinutes > 0) {
|
||||||
|
if (diffMinutes < 60) return `in ${diffMinutes} Min`;
|
||||||
|
const hours = Math.round(diffMinutes / 60);
|
||||||
|
if (hours < 48) return `in ${hours} Std`;
|
||||||
|
const days = Math.round(hours / 24);
|
||||||
|
return `in ${days} Tagen`;
|
||||||
|
} else {
|
||||||
|
const abs = Math.abs(diffMinutes);
|
||||||
|
if (abs < 60) return `vor ${abs} Min`;
|
||||||
|
const hours = Math.round(abs / 60);
|
||||||
|
if (hours < 48) return `vor ${hours} Std`;
|
||||||
|
const days = Math.round(hours / 24);
|
||||||
|
return `vor ${days} Tagen`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHeadersInput(text) {
|
||||||
|
const trimmed = (text || '').trim();
|
||||||
|
if (!trimmed) return {};
|
||||||
|
if (trimmed.startsWith('{')) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed);
|
||||||
|
if (parsed && typeof parsed === 'object') {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// fall back to line parsing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const headers = {};
|
||||||
|
trimmed.split('\n').forEach((line) => {
|
||||||
|
const idx = line.indexOf(':');
|
||||||
|
if (idx === -1) return;
|
||||||
|
const key = line.slice(0, idx).trim();
|
||||||
|
const value = line.slice(idx + 1).trim();
|
||||||
|
if (key) {
|
||||||
|
headers[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifyHeaders(headers) {
|
||||||
|
if (!headers || typeof headers !== 'object') return '';
|
||||||
|
return Object.entries(headers)
|
||||||
|
.map(([key, value]) => `${key}: ${value}`)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTemplateContext() {
|
||||||
|
const now = new Date();
|
||||||
|
return {
|
||||||
|
now,
|
||||||
|
date: now.toISOString().slice(0, 10),
|
||||||
|
today: now.toISOString().slice(0, 10),
|
||||||
|
iso: now.toISOString(),
|
||||||
|
datetime: now.toISOString(),
|
||||||
|
timestamp: now.getTime(),
|
||||||
|
year: now.getFullYear(),
|
||||||
|
month: String(now.getMonth() + 1).padStart(2, '0'),
|
||||||
|
day: String(now.getDate()).padStart(2, '0'),
|
||||||
|
hour: String(now.getHours()).padStart(2, '0'),
|
||||||
|
minute: String(now.getMinutes()).padStart(2, '0'),
|
||||||
|
weekday: now.toLocaleDateString('de-DE', { weekday: 'long' }),
|
||||||
|
weekday_short: now.toLocaleDateString('de-DE', { weekday: 'short' })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTemplate(template, context = {}) {
|
||||||
|
if (typeof template !== 'string') return '';
|
||||||
|
const baseDate = context.now instanceof Date ? context.now : new Date();
|
||||||
|
const uuidFn = typeof crypto !== 'undefined' && crypto.randomUUID
|
||||||
|
? () => crypto.randomUUID()
|
||||||
|
: () => Math.random().toString(16).slice(2, 10);
|
||||||
|
|
||||||
|
return template.replace(/\{\{\s*([^}\s]+)\s*\}\}/g, (_, keyRaw) => {
|
||||||
|
const key = String(keyRaw || '').trim();
|
||||||
|
if (!key) return '';
|
||||||
|
|
||||||
|
if (key === 'uuid') return uuidFn();
|
||||||
|
|
||||||
|
const dateOffsetMatch = key.match(/^date([+-]\d+)?$/);
|
||||||
|
if (dateOffsetMatch) {
|
||||||
|
const offset = dateOffsetMatch[1] ? parseInt(dateOffsetMatch[1], 10) : 0;
|
||||||
|
const shifted = new Date(baseDate);
|
||||||
|
shifted.setDate(baseDate.getDate() + (Number.isNaN(offset) ? 0 : offset));
|
||||||
|
return shifted.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (key) {
|
||||||
|
case 'today':
|
||||||
|
case 'date':
|
||||||
|
return baseDate.toISOString().slice(0, 10);
|
||||||
|
case 'iso':
|
||||||
|
case 'now':
|
||||||
|
case 'datetime':
|
||||||
|
return baseDate.toISOString();
|
||||||
|
case 'timestamp':
|
||||||
|
return String(baseDate.getTime());
|
||||||
|
case 'year':
|
||||||
|
return String(baseDate.getFullYear());
|
||||||
|
case 'month':
|
||||||
|
return String(baseDate.getMonth() + 1).padStart(2, '0');
|
||||||
|
case 'day':
|
||||||
|
return String(baseDate.getDate()).padStart(2, '0');
|
||||||
|
case 'hour':
|
||||||
|
return String(baseDate.getHours()).padStart(2, '0');
|
||||||
|
case 'minute':
|
||||||
|
return String(baseDate.getMinutes()).padStart(2, '0');
|
||||||
|
case 'weekday':
|
||||||
|
return baseDate.toLocaleDateString('de-DE', { weekday: 'long' });
|
||||||
|
case 'weekday_short':
|
||||||
|
return baseDate.toLocaleDateString('de-DE', { weekday: 'short' });
|
||||||
|
default:
|
||||||
|
return context[key] !== undefined && context[key] !== null
|
||||||
|
? String(context[key])
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHeaderTemplates(rawHeaders, context) {
|
||||||
|
const parsed = parseHeadersInput(rawHeaders);
|
||||||
|
const rendered = {};
|
||||||
|
Object.entries(parsed).forEach(([key, value]) => {
|
||||||
|
const renderedKey = renderTemplate(key, context).trim();
|
||||||
|
if (!renderedKey) return;
|
||||||
|
rendered[renderedKey] = renderTemplate(
|
||||||
|
value === undefined || value === null ? '' : String(value),
|
||||||
|
context
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return rendered;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPayloadFromForm() {
|
||||||
|
return {
|
||||||
|
name: nameInput.value.trim(),
|
||||||
|
description: descriptionInput.value.trim(),
|
||||||
|
method: methodSelect.value,
|
||||||
|
url_template: urlInput.value.trim(),
|
||||||
|
headers: parseHeadersInput(headersInput.value),
|
||||||
|
body_template: bodyInput.value,
|
||||||
|
schedule_type: intervalPreset.value !== 'custom' ? intervalPreset.value : undefined,
|
||||||
|
interval_minutes: intervalPreset.value === 'custom'
|
||||||
|
? Math.max(5, parseInt(intervalMinutesInput.value, 10) || DEFAULT_INTERVAL_MINUTES)
|
||||||
|
: undefined,
|
||||||
|
jitter_minutes: Math.max(0, parseInt(jitterInput.value, 10) || 0),
|
||||||
|
start_at: parseDateInput(startAtInput.value),
|
||||||
|
run_until: parseDateInput(runUntilInput.value),
|
||||||
|
active: activeToggle.checked
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPresetDisabling() {
|
||||||
|
if (intervalPreset.value === 'custom') {
|
||||||
|
intervalMinutesInput.disabled = false;
|
||||||
|
} else {
|
||||||
|
intervalMinutesInput.disabled = true;
|
||||||
|
intervalMinutesInput.value = intervalPreset.value === 'daily' ? 1440 : DEFAULT_INTERVAL_MINUTES;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshPreview() {
|
||||||
|
if (!previewUrl || !previewHeaders || !previewBody) return;
|
||||||
|
const context = buildTemplateContext();
|
||||||
|
const renderedUrl = renderTemplate(urlInput.value || '', context);
|
||||||
|
const headersObj = renderHeaderTemplates(headersInput.value || '', context);
|
||||||
|
const renderedBody = renderTemplate(bodyInput.value || '', context);
|
||||||
|
|
||||||
|
previewUrl.textContent = renderedUrl || '—';
|
||||||
|
previewHeaders.textContent = Object.keys(headersObj).length
|
||||||
|
? stringifyHeaders(headersObj)
|
||||||
|
: '—';
|
||||||
|
previewBody.textContent = renderedBody || '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRequests() {
|
||||||
|
setStatus(listStatus, 'Lade Automationen...');
|
||||||
|
state.loading = true;
|
||||||
|
try {
|
||||||
|
const data = await apiFetchJSON('/automation/requests');
|
||||||
|
state.requests = Array.isArray(data) ? data : [];
|
||||||
|
renderRequests();
|
||||||
|
renderHero();
|
||||||
|
if (state.selectedId) {
|
||||||
|
const stillExists = state.requests.some((req) => req.id === state.selectedId);
|
||||||
|
if (!stillExists) {
|
||||||
|
state.selectedId = null;
|
||||||
|
state.runs = [];
|
||||||
|
renderRuns();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!state.selectedId && state.requests.length) {
|
||||||
|
selectRequest(state.requests[0].id, { focusForm: false });
|
||||||
|
}
|
||||||
|
setStatus(listStatus, state.requests.length ? '' : 'Noch keine Automationen angelegt.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
setStatus(listStatus, error.message || 'Automationen konnten nicht geladen werden', 'error');
|
||||||
|
} finally {
|
||||||
|
state.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHero() {
|
||||||
|
if (!heroStats) return;
|
||||||
|
const active = state.requests.filter((req) => req.active);
|
||||||
|
const nextRun = active
|
||||||
|
.filter((req) => req.next_run_at)
|
||||||
|
.sort((a, b) => new Date(a.next_run_at) - new Date(b.next_run_at))[0];
|
||||||
|
const lastRun = [...state.requests]
|
||||||
|
.filter((req) => req.last_run_at)
|
||||||
|
.sort((a, b) => new Date(b.last_run_at) - new Date(a.last_run_at))[0];
|
||||||
|
|
||||||
|
heroStats.innerHTML = `
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">Aktive Automationen</div>
|
||||||
|
<div class="stat-value">${active.length}/${state.requests.length}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">Nächster Lauf</div>
|
||||||
|
<div class="stat-value">${nextRun ? formatRelative(nextRun.next_run_at) : '—'}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">Letzter Status</div>
|
||||||
|
<div class="stat-value">${lastRun ? (lastRun.last_status || '—') : '—'}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRequests() {
|
||||||
|
if (!requestTableBody) return;
|
||||||
|
requestTableBody.innerHTML = '';
|
||||||
|
const sorted = [...state.requests].sort((a, b) => {
|
||||||
|
const aNext = a.next_run_at ? new Date(a.next_run_at).getTime() : Infinity;
|
||||||
|
const bNext = b.next_run_at ? new Date(b.next_run_at).getTime() : Infinity;
|
||||||
|
return aNext - bNext;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!sorted.length) {
|
||||||
|
requestTableBody.innerHTML = '<tr><td colspan="5">Keine Automationen vorhanden.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = sorted.map((req) => {
|
||||||
|
const isSelected = state.selectedId === req.id;
|
||||||
|
const statusBadge = req.last_status === 'success'
|
||||||
|
? '<span class="badge success">OK</span>'
|
||||||
|
: req.last_status === 'error'
|
||||||
|
? '<span class="badge error">Fehler</span>'
|
||||||
|
: '<span class="badge">—</span>';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr data-id="${req.id}" class="${isSelected ? 'is-selected' : ''}">
|
||||||
|
<td>
|
||||||
|
<div class="row-title">${req.name}</div>
|
||||||
|
<div class="row-sub">${req.method || 'GET'} · ${req.url_template}</div>
|
||||||
|
</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>
|
||||||
|
<div class="row-actions">
|
||||||
|
<button class="secondary-btn" data-action="edit" data-id="${req.id}" type="button">Bearbeiten</button>
|
||||||
|
<button class="ghost-btn" data-action="run" data-id="${req.id}" type="button">Run</button>
|
||||||
|
<button class="ghost-btn" data-action="toggle" data-id="${req.id}" type="button">${req.active ? 'Pause' : 'Aktivieren'}</button>
|
||||||
|
<button class="ghost-btn" data-action="delete" data-id="${req.id}" type="button">Löschen</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
requestTableBody.innerHTML = rows.join('');
|
||||||
|
|
||||||
|
if (runSelectedBtn) {
|
||||||
|
runSelectedBtn.disabled = !state.selectedId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRuns() {
|
||||||
|
if (!runsList) return;
|
||||||
|
runsList.innerHTML = '';
|
||||||
|
if (!state.runs.length) {
|
||||||
|
runsList.innerHTML = '<li class="run-item">Noch keine Läufe.</li>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
runsList.innerHTML = state.runs.map((run) => {
|
||||||
|
const badge = run.status === 'success'
|
||||||
|
? '<span class="badge success">OK</span>'
|
||||||
|
: run.status === 'error'
|
||||||
|
? '<span class="badge error">Fehler</span>'
|
||||||
|
: '<span class="badge">Offen</span>';
|
||||||
|
return `
|
||||||
|
<li class="run-item">
|
||||||
|
<div class="run-top">
|
||||||
|
<div class="run-meta">
|
||||||
|
${badge}
|
||||||
|
<span>${formatDateTime(run.started_at)}</span>
|
||||||
|
<span>Code: ${run.status_code ?? '—'}</span>
|
||||||
|
<span>Dauer: ${run.duration_ms ? `${run.duration_ms} ms` : '—'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${run.error ? `<div class="run-body">Fehler: ${run.error}</div>` : ''}
|
||||||
|
${run.response_body ? `<div class="run-body">${run.response_body}</div>` : ''}
|
||||||
|
</li>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
form.reset();
|
||||||
|
editingId = null;
|
||||||
|
intervalPreset.value = 'hourly';
|
||||||
|
intervalMinutesInput.value = DEFAULT_INTERVAL_MINUTES;
|
||||||
|
applyPresetDisabling();
|
||||||
|
const now = new Date();
|
||||||
|
now.setMinutes(now.getMinutes() + 5);
|
||||||
|
now.setSeconds(0, 0);
|
||||||
|
startAtInput.value = toDateTimeLocal(now);
|
||||||
|
runUntilInput.value = '';
|
||||||
|
formModeLabel.textContent = 'Neue Automation';
|
||||||
|
setStatus(formStatus, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillForm(request) {
|
||||||
|
if (!request) return;
|
||||||
|
editingId = request.id;
|
||||||
|
formModeLabel.textContent = `Automation bearbeiten`;
|
||||||
|
nameInput.value = request.name || '';
|
||||||
|
descriptionInput.value = request.description || '';
|
||||||
|
urlInput.value = request.url_template || '';
|
||||||
|
methodSelect.value = request.method || 'GET';
|
||||||
|
headersInput.value = stringifyHeaders(request.headers);
|
||||||
|
bodyInput.value = request.body_template || '';
|
||||||
|
activeToggle.checked = !!request.active;
|
||||||
|
|
||||||
|
if (request.interval_minutes === 60) {
|
||||||
|
intervalPreset.value = 'hourly';
|
||||||
|
} else if (request.interval_minutes === 1440) {
|
||||||
|
intervalPreset.value = 'daily';
|
||||||
|
} else {
|
||||||
|
intervalPreset.value = 'custom';
|
||||||
|
}
|
||||||
|
intervalMinutesInput.value = request.interval_minutes || DEFAULT_INTERVAL_MINUTES;
|
||||||
|
jitterInput.value = request.jitter_minutes || 0;
|
||||||
|
startAtInput.value = toDateTimeLocal(request.start_at);
|
||||||
|
runUntilInput.value = toDateTimeLocal(request.run_until);
|
||||||
|
applyPresetDisabling();
|
||||||
|
refreshPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (state.saving) return;
|
||||||
|
const payload = buildPayloadFromForm();
|
||||||
|
if (!payload.name || !payload.url_template) {
|
||||||
|
setStatus(formStatus, 'Name und URL sind Pflichtfelder.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.saving = true;
|
||||||
|
setStatus(formStatus, 'Speichere...');
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (editingId) {
|
||||||
|
await apiFetchJSON(`/automation/requests/${editingId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await apiFetchJSON('/automation/requests', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setStatus(formStatus, 'Gespeichert.', 'success');
|
||||||
|
closeFormModal();
|
||||||
|
await loadRequests();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
setStatus(formStatus, error.message || 'Konnte nicht speichern', 'error');
|
||||||
|
} finally {
|
||||||
|
state.saving = false;
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRuns(requestId) {
|
||||||
|
if (!requestId) {
|
||||||
|
state.runs = [];
|
||||||
|
renderRuns();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus(runsStatus, 'Lade Runs...');
|
||||||
|
try {
|
||||||
|
const data = await apiFetchJSON(`/automation/requests/${requestId}/runs?limit=50`);
|
||||||
|
state.runs = Array.isArray(data) ? data : [];
|
||||||
|
renderRuns();
|
||||||
|
setStatus(runsStatus, state.runs.length ? '' : 'Keine Läufe vorhanden.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
setStatus(runsStatus, error.message || 'Runs konnten nicht geladen werden', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectRequest(id, options = {}) {
|
||||||
|
state.selectedId = id;
|
||||||
|
const request = state.requests.find((item) => item.id === id);
|
||||||
|
if (!request) {
|
||||||
|
state.runs = [];
|
||||||
|
renderRuns();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderRequests();
|
||||||
|
loadRuns(id);
|
||||||
|
if (options.focusForm) {
|
||||||
|
nameInput.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAutomation(id) {
|
||||||
|
if (!id) {
|
||||||
|
setStatus(formStatus, 'Keine Automation ausgewählt.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.running) return;
|
||||||
|
state.running = true;
|
||||||
|
setStatus(formStatus, 'Starte manuellen Run...');
|
||||||
|
try {
|
||||||
|
await apiFetchJSON(`/automation/requests/${id}/run`, { method: 'POST' });
|
||||||
|
await loadRequests();
|
||||||
|
await loadRuns(id);
|
||||||
|
setStatus(formStatus, 'Run gestartet.', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
setStatus(formStatus, error.message || 'Run fehlgeschlagen', 'error');
|
||||||
|
} finally {
|
||||||
|
state.running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleActive(id) {
|
||||||
|
const request = state.requests.find((item) => item.id === id);
|
||||||
|
if (!request) return;
|
||||||
|
try {
|
||||||
|
await apiFetchJSON(`/automation/requests/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ active: !request.active })
|
||||||
|
});
|
||||||
|
await loadRequests();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
setStatus(listStatus, error.message || 'Konnte Status nicht ändern', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAutomation(id) {
|
||||||
|
const request = state.requests.find((item) => item.id === id);
|
||||||
|
if (!request) return;
|
||||||
|
if (!confirm(`Automation "${request.name}" wirklich löschen?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await apiFetchJSON(`/automation/requests/${id}`, { method: 'DELETE' });
|
||||||
|
if (state.selectedId === id) {
|
||||||
|
state.selectedId = null;
|
||||||
|
state.runs = [];
|
||||||
|
renderRuns();
|
||||||
|
}
|
||||||
|
await loadRequests();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
setStatus(listStatus, error.message || 'Konnte nicht löschen', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCurlTemplate(text) {
|
||||||
|
const result = {};
|
||||||
|
const urlMatch = text.match(/curl\s+(['"]?)([^'"\s]+)\1/);
|
||||||
|
if (urlMatch) result.url = urlMatch[2];
|
||||||
|
const methodMatch = text.match(/-X\s+([A-Z]+)/i) || text.match(/--request\s+([A-Z]+)/i);
|
||||||
|
if (methodMatch) result.method = methodMatch[1].toUpperCase();
|
||||||
|
const headers = {};
|
||||||
|
const headerRegex = /-H\s+(?:(?:"([^"]+)")|(?:'([^']+)'))/gi;
|
||||||
|
let headerMatch;
|
||||||
|
while ((headerMatch = headerRegex.exec(text)) !== null) {
|
||||||
|
const raw = headerMatch[1] || headerMatch[2] || '';
|
||||||
|
const idx = raw.indexOf(':');
|
||||||
|
if (idx > -1) {
|
||||||
|
const key = raw.slice(0, idx).trim();
|
||||||
|
const value = raw.slice(idx + 1).trim();
|
||||||
|
if (key) headers[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(headers).length) result.headers = headers;
|
||||||
|
const dataMatch = text.match(/--data(?:-raw)?\s+(?:"([^"]*)"|'([^']*)')/i);
|
||||||
|
if (dataMatch) {
|
||||||
|
result.body = dataMatch[1] || dataMatch[2] || '';
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFetchTemplate(text) {
|
||||||
|
const result = {};
|
||||||
|
const urlMatch = text.match(/fetch\(\s*['"]([^'"]+)['"]/i);
|
||||||
|
if (urlMatch) result.url = urlMatch[1];
|
||||||
|
const optionsMatch = text.match(/fetch\(\s*['"][^'"]+['"]\s*,\s*({[\s\S]*?})\s*\)/i);
|
||||||
|
if (optionsMatch) {
|
||||||
|
try {
|
||||||
|
let optionsText = optionsMatch[1]
|
||||||
|
.replace(/,\s*\}\s*$/, '}')
|
||||||
|
.replace(/,\s*\]/g, ']');
|
||||||
|
const options = JSON.parse(optionsText);
|
||||||
|
if (options.method) result.method = options.method.toUpperCase();
|
||||||
|
if (options.headers && typeof options.headers === 'object') result.headers = options.headers;
|
||||||
|
if (options.body) result.body = options.body;
|
||||||
|
} catch (error) {
|
||||||
|
// ignore parse errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePowerShellTemplate(text) {
|
||||||
|
const result = {};
|
||||||
|
const urlMatch = text.match(/-Uri\s+['"]([^'"]+)['"]/i);
|
||||||
|
if (urlMatch) result.url = urlMatch[1];
|
||||||
|
const methodMatch = text.match(/-Method\s+['"]?([A-Z]+)['"]?/i);
|
||||||
|
if (methodMatch) result.method = methodMatch[1].toUpperCase();
|
||||||
|
const headersMatch = text.match(/-Headers\s+@?\{([\s\S]*?)\}/i);
|
||||||
|
if (headersMatch) {
|
||||||
|
const headersText = headersMatch[1];
|
||||||
|
const headers = {};
|
||||||
|
headersText.split(';').forEach((pair) => {
|
||||||
|
const idx = pair.indexOf('=');
|
||||||
|
if (idx === -1) return;
|
||||||
|
const key = pair.slice(0, idx).replace(/['\s]/g, '').trim();
|
||||||
|
const value = pair.slice(idx + 1).replace(/['\s]/g, '').trim();
|
||||||
|
if (key) headers[key] = value;
|
||||||
|
});
|
||||||
|
if (Object.keys(headers).length) result.headers = headers;
|
||||||
|
}
|
||||||
|
const bodyMatch = text.match(/-Body\s+['"]([\s\S]*?)['"]/i);
|
||||||
|
if (bodyMatch) result.body = bodyMatch[1];
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTemplate(raw) {
|
||||||
|
if (!raw) return null;
|
||||||
|
const text = raw.trim();
|
||||||
|
if (text.startsWith('curl')) return parseCurlTemplate(text);
|
||||||
|
if (text.includes('fetch(')) return parseFetchTemplate(text);
|
||||||
|
if (/Invoke-WebRequest|Invoke-RestMethod/i.test(text)) return parsePowerShellTemplate(text);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyImport() {
|
||||||
|
if (!importInput) return;
|
||||||
|
const raw = importInput.value;
|
||||||
|
if (!raw || !raw.trim()) {
|
||||||
|
setStatus(importStatus, 'Keine Vorlage eingegeben.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = parseTemplate(raw);
|
||||||
|
if (!parsed || (!parsed.url && !parsed.method && !parsed.headers && !parsed.body)) {
|
||||||
|
setStatus(importStatus, 'Konnte die Vorlage nicht erkennen.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Neues Formular öffnen und mit der Vorlage befüllen
|
||||||
|
openFormModal('create');
|
||||||
|
if (parsed.url) urlInput.value = parsed.url;
|
||||||
|
if (parsed.method) methodSelect.value = parsed.method.toUpperCase();
|
||||||
|
if (parsed.headers) headersInput.value = stringifyHeaders(parsed.headers);
|
||||||
|
if (parsed.body) bodyInput.value = parsed.body;
|
||||||
|
setStatus(importStatus, 'Vorlage übernommen.', 'success');
|
||||||
|
setStatus(formStatus, 'Vorlage importiert. Prüfen & speichern.', 'success');
|
||||||
|
refreshPreview();
|
||||||
|
closeImportModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedRequest() {
|
||||||
|
if (!state.selectedId) return null;
|
||||||
|
return state.requests.find((item) => item.id === state.selectedId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openFormModal(mode = 'create', request = null, options = {}) {
|
||||||
|
const skipReset = !!options.skipReset;
|
||||||
|
if (!skipReset) {
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
if (mode === 'edit' && request) {
|
||||||
|
fillForm(request);
|
||||||
|
formModeLabel.textContent = 'Automation bearbeiten';
|
||||||
|
modalTitle.textContent = 'Automation bearbeiten';
|
||||||
|
} else {
|
||||||
|
formModeLabel.textContent = 'Neue Automation';
|
||||||
|
modalTitle.textContent = 'Neue Automation';
|
||||||
|
}
|
||||||
|
formModal.hidden = false;
|
||||||
|
setTimeout(() => {
|
||||||
|
nameInput.focus();
|
||||||
|
}, 10);
|
||||||
|
refreshPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeFormModal() {
|
||||||
|
formModal.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openImportModal() {
|
||||||
|
if (!importModal) return;
|
||||||
|
if (importStatus) setStatus(importStatus, '');
|
||||||
|
importModal.hidden = false;
|
||||||
|
setTimeout(() => {
|
||||||
|
importInput?.focus();
|
||||||
|
}, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeImportModal() {
|
||||||
|
if (!importModal) return;
|
||||||
|
importModal.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTableClick(event) {
|
||||||
|
const button = event.target.closest('[data-action]');
|
||||||
|
if (button) {
|
||||||
|
const { action, id } = button.dataset;
|
||||||
|
if (!id) return;
|
||||||
|
switch (action) {
|
||||||
|
case 'edit':
|
||||||
|
selectRequest(id);
|
||||||
|
openFormModal('edit', state.requests.find((item) => item.id === id));
|
||||||
|
break;
|
||||||
|
case 'run':
|
||||||
|
runAutomation(id);
|
||||||
|
break;
|
||||||
|
case 'toggle':
|
||||||
|
toggleActive(id);
|
||||||
|
break;
|
||||||
|
case 'delete':
|
||||||
|
deleteAutomation(id);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = event.target.closest('tr[data-id]');
|
||||||
|
if (row && row.dataset.id) {
|
||||||
|
selectRequest(row.dataset.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
applyPresetDisabling();
|
||||||
|
resetForm();
|
||||||
|
loadRequests();
|
||||||
|
|
||||||
|
form.addEventListener('submit', handleSubmit);
|
||||||
|
intervalPreset.addEventListener('change', applyPresetDisabling);
|
||||||
|
resetFormBtn.addEventListener('click', () => {
|
||||||
|
resetForm();
|
||||||
|
nameInput.focus();
|
||||||
|
});
|
||||||
|
newAutomationBtn.addEventListener('click', () => openFormModal('create'));
|
||||||
|
refreshBtn.addEventListener('click', loadRequests);
|
||||||
|
runSelectedBtn.addEventListener('click', () => runAutomation(state.selectedId));
|
||||||
|
applyImportBtn?.addEventListener('click', applyImport);
|
||||||
|
refreshPreviewBtn?.addEventListener('click', refreshPreview);
|
||||||
|
[urlInput, headersInput, bodyInput].forEach((el) => {
|
||||||
|
el?.addEventListener('input', refreshPreview);
|
||||||
|
});
|
||||||
|
openImportBtn?.addEventListener('click', openImportModal);
|
||||||
|
importCloseBtn?.addEventListener('click', closeImportModal);
|
||||||
|
importModalBackdrop?.addEventListener('click', closeImportModal);
|
||||||
|
requestTableBody.addEventListener('click', handleTableClick);
|
||||||
|
modalCloseBtn.addEventListener('click', closeFormModal);
|
||||||
|
modalBackdrop.addEventListener('click', closeFormModal);
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
if (!formModal.hidden) {
|
||||||
|
closeFormModal();
|
||||||
|
}
|
||||||
|
if (importModal && !importModal.hidden) {
|
||||||
|
closeImportModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
|
})();
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
<a class="site-nav__btn" data-view-target="dashboard" href="dashboard.html">📊 Dashboard</a>
|
<a class="site-nav__btn" data-view-target="dashboard" href="dashboard.html">📊 Dashboard</a>
|
||||||
<a class="site-nav__btn" data-view-target="settings" href="settings.html">⚙️ Einstellungen</a>
|
<a class="site-nav__btn" data-view-target="settings" href="settings.html">⚙️ Einstellungen</a>
|
||||||
<a class="site-nav__btn" data-view-target="bookmarks" href="bookmarks.html">🔖 Bookmarks</a>
|
<a class="site-nav__btn" data-view-target="bookmarks" href="bookmarks.html">🔖 Bookmarks</a>
|
||||||
|
<a class="site-nav__btn" href="automation.html">⚡️ Automationen</a>
|
||||||
<a class="site-nav__btn" href="daily-bookmarks.html">✅ Daily Bookmarks</a>
|
<a class="site-nav__btn" href="daily-bookmarks.html">✅ Daily Bookmarks</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user