From d3e8c0adc7d6504b75d2684c6f9ee67cf42796e3 Mon Sep 17 00:00:00 2001 From: Claude Dev Date: Fri, 3 Apr 2026 13:14:34 +0200 Subject: [PATCH] feat: Slot-basierter Auto-Refresh mit konfigurierbarer Startzeit Auto-Refresh nutzt jetzt eine feste Anker-Uhrzeit (refresh_start_time) statt reinem Intervall-basiertem Driften. Verpasste Slots werden max. 1x aufgeholt. Bestehendes Intervall-Verhalten bleibt als Fallback erhalten (ohne Startzeit). Migration: Bestehende Auto-Lagen erhalten 07:00 als Startzeit. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/database.py | 7 ++++ src/main.py | 73 +++++++++++++++++++++++++++++++-------- src/models.py | 3 ++ src/routers/incidents.py | 7 ++-- src/static/dashboard.html | 4 +++ src/static/js/app.js | 19 ++++++++-- 6 files changed, 93 insertions(+), 20 deletions(-) diff --git a/src/database.py b/src/database.py index db6b1a8..a22891b 100644 --- a/src/database.py +++ b/src/database.py @@ -68,6 +68,7 @@ CREATE TABLE IF NOT EXISTS incidents ( type TEXT DEFAULT 'adhoc', refresh_mode TEXT DEFAULT 'manual', refresh_interval INTEGER DEFAULT 15, + refresh_start_time TEXT, retention_days INTEGER DEFAULT 0, visibility TEXT DEFAULT 'public', summary TEXT, @@ -362,6 +363,12 @@ async def init_db(): await db.commit() logger.info("Migration: tenant_id zu incidents hinzugefuegt") + if "refresh_start_time" not in columns: + await db.execute("ALTER TABLE incidents ADD COLUMN refresh_start_time TEXT") + await db.execute("UPDATE incidents SET refresh_start_time = '07:00' WHERE refresh_mode = 'auto'") + await db.commit() + logger.info("Migration: refresh_start_time zu incidents hinzugefuegt (bestehende Auto-Lagen auf 07:00)") + # Migration: Token-Spalten fuer refresh_log cursor = await db.execute("PRAGMA table_info(refresh_log)") rl_columns = [row[1] for row in await cursor.fetchall()] diff --git a/src/main.py b/src/main.py index cc76ddc..f930a07 100644 --- a/src/main.py +++ b/src/main.py @@ -5,7 +5,7 @@ import logging import os import sys from contextlib import asynccontextmanager -from datetime import datetime +from datetime import datetime, timedelta from typing import Dict from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends, Request, Response @@ -107,11 +107,11 @@ scheduler = AsyncIOScheduler() async def check_auto_refresh(): - """Prüft welche Lagen einen Auto-Refresh brauchen.""" + """Prüft welche Lagen einen Auto-Refresh brauchen (Slot-basiert).""" db = await get_db() try: cursor = await db.execute( - "SELECT id, refresh_interval FROM incidents WHERE status = 'active' AND refresh_mode = 'auto'" + "SELECT id, refresh_interval, refresh_start_time FROM incidents WHERE status = 'active' AND refresh_mode = 'auto'" ) incidents = await cursor.fetchall() @@ -120,18 +120,72 @@ async def check_auto_refresh(): for incident in incidents: incident_id = incident["id"] interval = incident["refresh_interval"] + start_time_str = incident["refresh_start_time"] - # Letzten abgeschlossenen Refresh prüfen (egal ob auto oder manual) + # Letzten abgeschlossenen oder laufenden Refresh pruefen cursor = await db.execute( - "SELECT started_at FROM refresh_log WHERE incident_id = ? AND status = 'completed' ORDER BY id DESC LIMIT 1", + "SELECT started_at, status FROM refresh_log WHERE incident_id = ? AND status IN ('completed', 'running') ORDER BY id DESC LIMIT 1", (incident_id,), ) last_refresh = await cursor.fetchone() + # Laufenden Refresh ueberspringen + if last_refresh and last_refresh["status"] == "running": + logger.debug(f"Auto-Refresh Lage {incident_id}: uebersprungen (laeuft bereits)") + continue + should_refresh = False + if not last_refresh: + # Noch nie gelaufen -> sofort starten should_refresh = True + logger.info(f"Auto-Refresh Lage {incident_id}: erster Refresh") + elif start_time_str: + # Slot-basierte Logik: Naechsten faelligen Slot berechnen + try: + start_h, start_m = map(int, start_time_str.split(":")) + except (ValueError, AttributeError): + logger.warning(f"Auto-Refresh Lage {incident_id}: ungueltiges Startzeit-Format '{start_time_str}'") + continue + + last_time = datetime.fromisoformat(last_refresh["started_at"]) + if last_time.tzinfo is None: + last_time = last_time.replace(tzinfo=TIMEZONE) + else: + last_time = last_time.astimezone(TIMEZONE) + + # Anker: heute um start_time + anchor_today = now.replace(hour=start_h, minute=start_m, second=0, microsecond=0) + interval_td = timedelta(minutes=interval) + + if interval >= 1440: + # Taeglicher oder laengerer Rhythmus + days_interval = interval // 1440 + # Letzter Slot der <= now ist + current_slot = anchor_today + if current_slot > now: + current_slot -= timedelta(days=days_interval) + # Sicherheitsschleife: weiter zurueck falls noetig + while current_slot > now: + current_slot -= timedelta(days=days_interval) + else: + # Untertaegig: Slots ab Anker im Intervall-Takt + # Anker zurueck bis vor last_refresh + ref_anchor = anchor_today + while ref_anchor > last_time: + ref_anchor -= interval_td + # Von dort vorwaerts bis zum letzten Slot <= now + current_slot = ref_anchor + while current_slot + interval_td <= now: + current_slot += interval_td + + if current_slot > last_time: + should_refresh = True + logger.info(f"Auto-Refresh Lage {incident_id}: Slot {current_slot.strftime('%H:%M')} faellig (letzter Refresh: {last_time.strftime('%Y-%m-%d %H:%M')})") + else: + logger.debug(f"Auto-Refresh Lage {incident_id}: kein faelliger Slot (letzter: {current_slot.strftime('%H:%M')})") else: + # Fallback: altes Intervall-Verhalten (kein start_time gesetzt) last_time = datetime.fromisoformat(last_refresh["started_at"]) if last_time.tzinfo is None: last_time = last_time.replace(tzinfo=TIMEZONE) @@ -145,15 +199,6 @@ async def check_auto_refresh(): logger.debug(f"Auto-Refresh Lage {incident_id}: {elapsed:.1f}/{interval} Min — noch nicht faellig") if should_refresh: - # Prüfen ob bereits ein laufender Refresh existiert - running_cursor = await db.execute( - "SELECT id FROM refresh_log WHERE incident_id = ? AND status = 'running' LIMIT 1", - (incident_id,), - ) - if await running_cursor.fetchone(): - logger.debug(f"Auto-Refresh Lage {incident_id}: uebersprungen (laeuft bereits)") - continue - await orchestrator.enqueue_refresh(incident_id, trigger_type="auto") except Exception as e: diff --git a/src/models.py b/src/models.py index d162f11..176fb86 100644 --- a/src/models.py +++ b/src/models.py @@ -49,6 +49,7 @@ class IncidentCreate(BaseModel): type: str = Field(default="adhoc", pattern="^(adhoc|research)$") refresh_mode: str = Field(default="manual", pattern="^(manual|auto)$") refresh_interval: int = Field(default=15, ge=10, le=10080) + refresh_start_time: Optional[str] = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$") retention_days: int = Field(default=0, ge=0, le=999) international_sources: bool = True include_telegram: bool = False @@ -62,6 +63,7 @@ class IncidentUpdate(BaseModel): status: Optional[str] = Field(default=None, pattern="^(active|archived)$") refresh_mode: Optional[str] = Field(default=None, pattern="^(manual|auto)$") refresh_interval: Optional[int] = Field(default=None, ge=10, le=10080) + refresh_start_time: Optional[str] = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$") retention_days: Optional[int] = Field(default=None, ge=0, le=999) international_sources: Optional[bool] = None include_telegram: Optional[bool] = None @@ -82,6 +84,7 @@ class IncidentResponse(BaseModel): status: str refresh_mode: str refresh_interval: int + refresh_start_time: Optional[str] = None retention_days: int visibility: str = "public" summary: Optional[str] diff --git a/src/routers/incidents.py b/src/routers/incidents.py index 9803acc..2353321 100644 --- a/src/routers/incidents.py +++ b/src/routers/incidents.py @@ -21,7 +21,7 @@ router = APIRouter(prefix="/api/incidents", tags=["incidents"]) INCIDENT_UPDATE_COLUMNS = { "title", "description", "type", "status", "refresh_mode", - "refresh_interval", "retention_days", "international_sources", "include_telegram", "visibility", + "refresh_interval", "refresh_start_time", "retention_days", "international_sources", "include_telegram", "visibility", } @@ -107,15 +107,16 @@ async def create_incident( now = datetime.now(TIMEZONE).strftime('%Y-%m-%d %H:%M:%S') cursor = await db.execute( """INSERT INTO incidents (title, description, type, refresh_mode, refresh_interval, - retention_days, international_sources, include_telegram, visibility, + refresh_start_time, retention_days, international_sources, include_telegram, visibility, tenant_id, created_by, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( data.title, data.description, data.type, data.refresh_mode, data.refresh_interval, + data.refresh_start_time, data.retention_days, 1 if data.international_sources else 0, 1 if data.include_telegram else 0, diff --git a/src/static/dashboard.html b/src/static/dashboard.html index 3acf07a..410ae8a 100644 --- a/src/static/dashboard.html +++ b/src/static/dashboard.html @@ -398,6 +398,10 @@ +
+ + +
diff --git a/src/static/js/app.js b/src/static/js/app.js index e3e15b7..b66880f 100644 --- a/src/static/js/app.js +++ b/src/static/js/app.js @@ -821,9 +821,16 @@ const App = { : ''; } - { const _e = document.getElementById('meta-refresh-mode'); if (_e) _e.textContent = incident.refresh_mode === 'auto' - ? `Auto alle ${App._formatInterval(incident.refresh_interval)}` - : 'Manuell'; } + { const _e = document.getElementById('meta-refresh-mode'); if (_e) { + if (incident.refresh_mode === 'auto' && incident.refresh_start_time) { + const intervalText = App._formatInterval(incident.refresh_interval); + _e.textContent = `Auto alle ${intervalText} ab ${incident.refresh_start_time} Uhr`; + } else if (incident.refresh_mode === 'auto') { + _e.textContent = `Auto alle ${App._formatInterval(incident.refresh_interval)}`; + } else { + _e.textContent = 'Manuell'; + } + } } // International-Badge const intlBadge = document.getElementById('intl-badge'); @@ -1517,6 +1524,9 @@ const App = { type: document.getElementById('inc-type').value, refresh_mode: document.getElementById('inc-refresh-mode').value, refresh_interval: interval, + refresh_start_time: document.getElementById('inc-refresh-mode').value === 'auto' + ? document.getElementById('inc-refresh-starttime').value || null + : null, retention_days: parseInt(document.getElementById('inc-retention').value) || 0, international_sources: document.getElementById('inc-international').checked, include_telegram: document.getElementById('inc-telegram').checked, @@ -1914,6 +1924,7 @@ async handleRefresh() { { const _e = document.getElementById('inc-type'); if (_e) _e.value = incident.type || 'adhoc'; } { const _e = document.getElementById('inc-refresh-mode'); if (_e) _e.value = incident.refresh_mode; } App._setIntervalFields(incident.refresh_interval); + { const _e = document.getElementById('inc-refresh-starttime'); if (_e) _e.value = incident.refresh_start_time || '07:00'; } { const _e = document.getElementById('inc-retention'); if (_e) _e.value = incident.retention_days; } { const _e = document.getElementById('inc-international'); if (_e) _e.checked = incident.international_sources !== false && incident.international_sources !== 0; } { const _e = document.getElementById('inc-telegram'); if (_e) _e.checked = !!incident.include_telegram; } @@ -3214,7 +3225,9 @@ function buildDetailedSourceOverview() { function toggleRefreshInterval() { const mode = document.getElementById('inc-refresh-mode').value; const field = document.getElementById('refresh-interval-field'); + const startField = document.getElementById('refresh-starttime-field'); field.classList.toggle('visible', mode === 'auto'); + if (startField) startField.classList.toggle('visible', mode === 'auto'); } function updateIntervalMin() {