From ffcfce2b318e7d05f9e89aaf7d6ab40434b3a982 Mon Sep 17 00:00:00 2001 From: Meik Date: Tue, 16 Dec 2025 20:27:37 +0100 Subject: [PATCH] letzter stand --- backend/server.js | 127 ++++++++++++++++++++++++++++++++++++++++----- web/automation.css | 48 +++++++++++++++++ web/automation.js | 72 +++++++++++++++++++++++-- web/index.html | 37 ++++++++----- 4 files changed, 256 insertions(+), 28 deletions(-) diff --git a/backend/server.js b/backend/server.js index f8cf51a..dc607d3 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1395,11 +1395,12 @@ db.exec(` ); `); -ensureColumn('automation_requests', 'type', 'type TEXT NOT NULL DEFAULT \'request\''); -ensureColumn('automation_requests', 'email_to', 'email_to TEXT'); -ensureColumn('automation_requests', 'email_subject_template', 'email_subject_template TEXT'); -ensureColumn('automation_requests', 'email_body_template', 'email_body_template TEXT'); -ensureColumn('automation_requests', 'steps_json', 'steps_json TEXT'); + ensureColumn('automation_requests', 'type', 'type TEXT NOT NULL DEFAULT \'request\''); + ensureColumn('automation_requests', 'email_to', 'email_to TEXT'); + ensureColumn('automation_requests', 'email_subject_template', 'email_subject_template TEXT'); + ensureColumn('automation_requests', 'email_body_template', 'email_body_template TEXT'); + ensureColumn('automation_requests', 'steps_json', 'steps_json TEXT'); + ensureColumn('automation_requests', 'exclusion_windows_json', 'exclusion_windows_json TEXT'); db.exec(` CREATE INDEX IF NOT EXISTS idx_automation_requests_next_run @@ -1445,6 +1446,7 @@ const listAutomationRequestsStmt = db.prepare(` jitter_minutes, start_at, run_until, + exclusion_windows_json, active, last_run_at, last_status, @@ -1480,6 +1482,7 @@ const getAutomationRequestStmt = db.prepare(` jitter_minutes, start_at, run_until, + exclusion_windows_json, active, last_run_at, last_status, @@ -1496,12 +1499,12 @@ const insertAutomationRequestStmt = db.prepare(` INSERT INTO automation_requests ( id, name, description, type, method, url_template, headers_json, body_template, email_to, email_subject_template, email_body_template, steps_json, - interval_minutes, jitter_minutes, start_at, run_until, active, last_run_at, + interval_minutes, jitter_minutes, start_at, run_until, exclusion_windows_json, active, last_run_at, last_status, last_status_code, last_error, next_run_at ) VALUES ( @id, @name, @description, @type, @method, @url_template, @headers_json, @body_template, @email_to, @email_subject_template, @email_body_template, @steps_json, - @interval_minutes, @jitter_minutes, @start_at, @run_until, @active, @last_run_at, + @interval_minutes, @jitter_minutes, @start_at, @run_until, @exclusion_windows_json, @active, @last_run_at, @last_status, @last_status_code, @last_error, @next_run_at ) `); @@ -1523,6 +1526,7 @@ const updateAutomationRequestStmt = db.prepare(` jitter_minutes = @jitter_minutes, start_at = @start_at, run_until = @run_until, + exclusion_windows_json = @exclusion_windows_json, active = @active, last_run_at = @last_run_at, last_status = @last_status, @@ -1940,6 +1944,78 @@ function clampAutomationJitterMinutes(value) { return Math.min(AUTOMATION_MAX_JITTER_MINUTES, Math.round(numeric)); } +function toMinutesOfDay(value) { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + const match = /^(\d{1,2}):(\d{2})$/.exec(trimmed); + if (!match) return null; + const hours = Number(match[1]); + const minutes = Number(match[2]); + if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null; + return hours * 60 + minutes; +} + +function formatMinutesOfDay(totalMinutes) { + if (typeof totalMinutes !== 'number' || Number.isNaN(totalMinutes)) return null; + const minutes = Math.max(0, Math.min(1439, Math.floor(totalMinutes))); + const h = String(Math.floor(minutes / 60)).padStart(2, '0'); + const m = String(minutes % 60).padStart(2, '0'); + return `${h}:${m}`; +} + +function parseExclusionWindows(raw) { + let source = raw; + if (typeof source === 'string') { + try { + source = JSON.parse(source); + } catch (error) { + source = []; + } + } + if (!Array.isArray(source)) { + return []; + } + + const windows = []; + for (const item of source) { + if (!item || typeof item !== 'object') continue; + const startStr = typeof item.start === 'string' + ? item.start + : (typeof item.from === 'string' ? item.from : ''); + const endStr = typeof item.end === 'string' + ? item.end + : (typeof item.to === 'string' ? item.to : ''); + const startMinutes = toMinutesOfDay(startStr); + const endMinutes = toMinutesOfDay(endStr); + if (startMinutes === null || endMinutes === null) continue; + if (startMinutes >= endMinutes) continue; + windows.push({ + start: formatMinutesOfDay(startMinutes), + end: formatMinutesOfDay(endMinutes), + startMinutes, + endMinutes + }); + } + + windows.sort((a, b) => a.startMinutes - b.startMinutes); + return windows; +} + +function serializeExclusionWindows(raw, existingJson = null) { + if (raw === undefined) { + return existingJson || null; + } + const parsed = parseExclusionWindows(raw); + if (!parsed.length) { + return null; + } + try { + return JSON.stringify(parsed.map((item) => ({ start: item.start, end: item.end }))); + } catch (error) { + return null; + } +} + function normalizeAutomationHeaders(raw) { if (!raw) { return {}; @@ -2136,6 +2212,7 @@ function computeNextAutomationRun(request, options = {}) { 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; + const exclusionWindows = parseExclusionWindows(request.exclusion_windows_json || request.exclusion_windows || []); let base = lastRun ? new Date(lastRun.getTime() + intervalMinutes * 60000) @@ -2148,13 +2225,34 @@ function computeNextAutomationRun(request, options = {}) { const jitterMs = jitterMinutes > 0 ? Math.floor(Math.random() * ((jitterMinutes * 60000) + 1)) : 0; - const candidate = new Date(base.getTime() + jitterMs); + let 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; + const until = request.run_until ? new Date(request.run_until) : null; + const MAX_SHIFT_ITERATIONS = 50; + let shifts = 0; + + while (shifts < MAX_SHIFT_ITERATIONS) { + const minutes = candidate.getHours() * 60 + candidate.getMinutes(); + const hit = exclusionWindows.find((w) => minutes >= w.startMinutes && minutes < w.endMinutes); + if (!hit) { + break; } + const nextAllowed = new Date(candidate); + nextAllowed.setHours(0, 0, 0, 0); + nextAllowed.setMinutes(hit.endMinutes); + if (nextAllowed <= candidate) { + nextAllowed.setDate(nextAllowed.getDate() + 1); + } + candidate = nextAllowed; + shifts += 1; + } + + if (shifts >= MAX_SHIFT_ITERATIONS) { + return null; + } + + if (until && !Number.isNaN(until.getTime()) && candidate > until) { + return null; } return candidate.toISOString(); @@ -2223,6 +2321,7 @@ function serializeAutomationRequest(row) { 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, + exclusion_windows: parseExclusionWindows(row.exclusion_windows_json || row.exclusion_windows || []), active: row.active ? 1 : 0, last_run_at: row.last_run_at ? ensureIsoDate(row.last_run_at) : null, last_status: row.last_status || null, @@ -2428,6 +2527,10 @@ function normalizeAutomationPayload(payload, existing = {}) { normalized.start_at = parseAutomationDate(payload.start_at) || parseAutomationDate(existing.start_at); normalized.run_until = parseAutomationDate(payload.run_until) || null; + normalized.exclusion_windows_json = serializeExclusionWindows( + payload.exclusion_windows ?? payload.exclusions ?? payload.exclude_windows, + existing.exclusion_windows_json || null + ); normalized.active = payload.active === undefined || payload.active === null ? (existing.active ? 1 : 0) : (payload.active ? 1 : 0); diff --git a/web/automation.css b/web/automation.css index 9a0f01c..ed2704f 100644 --- a/web/automation.css +++ b/web/automation.css @@ -546,6 +546,54 @@ font-weight: 600; } +.automation-view .exclusion-controls { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +.automation-view .exclusion-controls input[type="time"] { + width: 140px; +} + +.automation-view .exclusion-sep { + color: var(--automation-muted); + font-size: 13px; +} + +.automation-view .exclusion-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; +} + +.automation-view .exclusion-chip { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid var(--automation-border); + color: var(--automation-text); + font-size: 13px; +} + +.automation-view .exclusion-chip button { + border: none; + background: transparent; + color: var(--automation-muted); + cursor: pointer; + padding: 0; + font-size: 14px; +} + +.automation-view .exclusion-chip button:hover { + color: var(--automation-danger); +} + .automation-view .modal { position: fixed; inset: 0; diff --git a/web/automation.js b/web/automation.js index 4c6dbb6..950012a 100644 --- a/web/automation.js +++ b/web/automation.js @@ -21,6 +21,7 @@ }; let editingId = null; + let exclusionWindows = []; const form = document.getElementById('automationForm'); const nameInput = document.getElementById('nameInput'); @@ -34,6 +35,10 @@ const jitterInput = document.getElementById('jitterInput'); const startAtInput = document.getElementById('startAtInput'); const runUntilInput = document.getElementById('runUntilInput'); + const excludeStartInput = document.getElementById('excludeStartInput'); + const excludeEndInput = document.getElementById('excludeEndInput'); + const addExclusionBtn = document.getElementById('addExclusionBtn'); + const exclusionList = document.getElementById('exclusionList'); const activeToggle = document.getElementById('activeToggle'); const formStatus = document.getElementById('formStatus'); const formModeLabel = document.getElementById('formModeLabel'); @@ -60,6 +65,7 @@ const listStatus = document.getElementById('listStatus'); const filterName = document.getElementById('filterName'); const filterNext = document.getElementById('filterNext'); + const filterRunUntil = document.getElementById('filterRunUntil'); const filterLast = document.getElementById('filterLast'); const filterStatus = document.getElementById('filterStatus'); const filterRuns = document.getElementById('filterRuns'); @@ -342,7 +348,8 @@ jitter_minutes: Math.max(0, parseInt(jitterInput.value, 10) || 0), start_at: parseDateInput(startAtInput.value), run_until: parseDateInput(runUntilInput.value), - active: activeToggle.checked + active: activeToggle.checked, + exclusion_windows: exclusionWindows.map((win) => ({ start: win.start, end: win.end })) }; if (type === 'email') { @@ -389,6 +396,46 @@ }; } + function renderExclusionChips() { + if (!exclusionList) return; + if (!exclusionWindows.length) { + exclusionList.innerHTML = 'Keine Ausschlusszeiten gesetzt.'; + return; + } + exclusionList.innerHTML = exclusionWindows.map((win, idx) => ` + + ${win.start} – ${win.end} + + + `).join(''); + exclusionList.querySelectorAll('[data-remove-exclusion]').forEach((btn) => { + btn.addEventListener('click', () => { + const index = Number(btn.getAttribute('data-remove-exclusion')); + exclusionWindows.splice(index, 1); + renderExclusionChips(); + }); + }); + } + + function addExclusion() { + const start = excludeStartInput?.value || ''; + const end = excludeEndInput?.value || ''; + if (!start || !end) { + setStatus(formStatus, 'Bitte Start- und Endzeit angeben.', 'error'); + return; + } + if (start >= end) { + setStatus(formStatus, 'Endzeit muss nach der Startzeit liegen.', 'error'); + return; + } + exclusionWindows.push({ start, end }); + exclusionWindows.sort((a, b) => a.start.localeCompare(b.start)); + renderExclusionChips(); + setStatus(formStatus, '', 'info'); + if (excludeStartInput) excludeStartInput.value = ''; + if (excludeEndInput) excludeEndInput.value = ''; + } + function applyPresetDisabling() { if (intervalPreset.value === 'custom') { intervalMinutesInput.disabled = false; @@ -523,6 +570,8 @@ if (!req) return; const nextEl = row.querySelector('.next'); if (nextEl) nextEl.textContent = formatRelative(req.next_run_at); + const untilEl = row.querySelector('.until'); + if (untilEl) untilEl.textContent = req.run_until ? formatRelative(req.run_until) : '—'; const lastEl = row.querySelector('.last'); if (lastEl) lastEl.textContent = formatRelative(req.last_run_at); }); @@ -532,7 +581,7 @@ if (!requestTableBody) return; requestTableBody.innerHTML = ''; if (!state.requests.length) { - requestTableBody.innerHTML = 'Keine Automationen vorhanden.'; + requestTableBody.innerHTML = 'Keine Automationen vorhanden.'; listInstance = null; return; } @@ -540,6 +589,7 @@ const rows = state.requests.map((req) => { const isSelected = state.selectedId === req.id; const nextSort = req.next_run_at ? new Date(req.next_run_at).getTime() : Number.MAX_SAFE_INTEGER; + const untilSort = req.run_until ? new Date(req.run_until).getTime() : Number.MAX_SAFE_INTEGER; const lastSort = req.last_run_at ? new Date(req.last_run_at).getTime() : -Number.MAX_SAFE_INTEGER; const runsCount = Array.isArray(req.runs) ? req.runs.length : (req.runs_count || req.runsCount || 0); const statusBadge = req.last_status === 'success' @@ -571,6 +621,10 @@ ${formatRelative(req.next_run_at)}
${formatDateTime(req.next_run_at)} + + ${req.run_until ? formatRelative(req.run_until) : '—'} +
${formatDateTime(req.run_until)} + ${formatRelative(req.last_run_at)}
${req.last_status_code || '—'} @@ -640,6 +694,8 @@ now.setSeconds(0, 0); startAtInput.value = toDateTimeLocal(now); runUntilInput.value = ''; + exclusionWindows = []; + renderExclusionChips(); formModeLabel.textContent = 'Neue Automation'; setStatus(formStatus, ''); if (emailToInput) emailToInput.value = ''; @@ -700,6 +756,8 @@ jitterInput.value = request.jitter_minutes || 0; startAtInput.value = toDateTimeLocal(request.start_at); runUntilInput.value = toDateTimeLocal(request.run_until); + exclusionWindows = Array.isArray(request.exclusion_windows) ? [...request.exclusion_windows] : []; + renderExclusionChips(); applyPresetDisabling(); refreshPreview(); } @@ -975,6 +1033,7 @@ 'url', 'steps', { name: 'next', attr: 'data-sort' }, + { name: 'runUntil', attr: 'data-sort' }, { name: 'last', attr: 'data-sort' }, { name: 'status', attr: 'data-sort' }, { name: 'runs', attr: 'data-sort' } @@ -990,6 +1049,7 @@ if (!listInstance) return; const searchName = (filterName?.value || '').toLowerCase().trim(); const searchNext = (filterNext?.value || '').toLowerCase().trim(); + const searchRunUntil = (filterRunUntil?.value || '').toLowerCase().trim(); const searchLast = (filterLast?.value || '').toLowerCase().trim(); const statusValue = (filterStatus?.value || '').toLowerCase().trim(); const runsMin = filterRuns?.value ? Number(filterRuns.value) : null; @@ -1005,11 +1065,12 @@ ].some((p) => String(p).toLowerCase().includes(searchName)); const matchNext = !searchNext || String(v.next || '').toLowerCase().includes(searchNext); + const matchRunUntil = !searchRunUntil || String(v.runUntil || '').toLowerCase().includes(searchRunUntil); const matchLast = !searchLast || `${v.last || ''}`.toLowerCase().includes(searchLast); const matchStatus = !statusValue || String(v.status || '').toLowerCase().includes(statusValue); const matchRuns = runsMin === null || Number(v.runs || 0) >= runsMin; - return matchName && matchNext && matchLast && matchStatus && matchRuns; + return matchName && matchNext && matchRunUntil && matchLast && matchStatus && matchRuns; }); updateSortIndicators(); } @@ -1200,11 +1261,14 @@ el?.addEventListener('input', refreshPreview); el?.addEventListener('change', refreshPreview); }); + if (addExclusionBtn) { + addExclusionBtn.addEventListener('click', addExclusion); + } typeSelect?.addEventListener('change', () => { applyTypeVisibility(); refreshPreview(); }); - [filterName, filterNext, filterLast, filterStatus].forEach((el) => { + [filterName, filterNext, filterRunUntil, filterLast, filterStatus].forEach((el) => { el?.addEventListener('input', applyFilters); el?.addEventListener('change', applyFilters); }); diff --git a/web/index.html b/web/index.html index 9ff0616..244fbce 100644 --- a/web/index.html +++ b/web/index.html @@ -193,20 +193,22 @@ Name - Nächster Lauf - Letzter Lauf - Status - #Läufe - Aktionen - + Nächster Lauf + Läuft bis + Letzter Lauf + Status + #Läufe + Aktionen + - - - - + + + + @@ -377,6 +379,17 @@ +
+ +
+ + bis + + +
+
+

Beispiel: 00:00–07:00, um nächtliche Ausführungen zu vermeiden.

+

Platzhalter