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) <noreply@anthropic.com>
Dieser Commit ist enthalten in:
Claude Dev
2026-04-03 13:14:34 +02:00
Ursprung 68c6666d87
Commit d3e8c0adc7
6 geänderte Dateien mit 93 neuen und 20 gelöschten Zeilen

Datei anzeigen

@@ -68,6 +68,7 @@ CREATE TABLE IF NOT EXISTS incidents (
type TEXT DEFAULT 'adhoc', type TEXT DEFAULT 'adhoc',
refresh_mode TEXT DEFAULT 'manual', refresh_mode TEXT DEFAULT 'manual',
refresh_interval INTEGER DEFAULT 15, refresh_interval INTEGER DEFAULT 15,
refresh_start_time TEXT,
retention_days INTEGER DEFAULT 0, retention_days INTEGER DEFAULT 0,
visibility TEXT DEFAULT 'public', visibility TEXT DEFAULT 'public',
summary TEXT, summary TEXT,
@@ -362,6 +363,12 @@ async def init_db():
await db.commit() await db.commit()
logger.info("Migration: tenant_id zu incidents hinzugefuegt") 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 # Migration: Token-Spalten fuer refresh_log
cursor = await db.execute("PRAGMA table_info(refresh_log)") cursor = await db.execute("PRAGMA table_info(refresh_log)")
rl_columns = [row[1] for row in await cursor.fetchall()] rl_columns = [row[1] for row in await cursor.fetchall()]

Datei anzeigen

@@ -5,7 +5,7 @@ import logging
import os import os
import sys import sys
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime from datetime import datetime, timedelta
from typing import Dict from typing import Dict
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends, Request, Response from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends, Request, Response
@@ -107,11 +107,11 @@ scheduler = AsyncIOScheduler()
async def check_auto_refresh(): 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() db = await get_db()
try: try:
cursor = await db.execute( 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() incidents = await cursor.fetchall()
@@ -120,18 +120,72 @@ async def check_auto_refresh():
for incident in incidents: for incident in incidents:
incident_id = incident["id"] incident_id = incident["id"]
interval = incident["refresh_interval"] 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( 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,), (incident_id,),
) )
last_refresh = await cursor.fetchone() 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 should_refresh = False
if not last_refresh: if not last_refresh:
# Noch nie gelaufen -> sofort starten
should_refresh = True 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: 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"]) last_time = datetime.fromisoformat(last_refresh["started_at"])
if last_time.tzinfo is None: if last_time.tzinfo is None:
last_time = last_time.replace(tzinfo=TIMEZONE) 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") logger.debug(f"Auto-Refresh Lage {incident_id}: {elapsed:.1f}/{interval} Min — noch nicht faellig")
if should_refresh: 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") await orchestrator.enqueue_refresh(incident_id, trigger_type="auto")
except Exception as e: except Exception as e:

Datei anzeigen

@@ -49,6 +49,7 @@ class IncidentCreate(BaseModel):
type: str = Field(default="adhoc", pattern="^(adhoc|research)$") type: str = Field(default="adhoc", pattern="^(adhoc|research)$")
refresh_mode: str = Field(default="manual", pattern="^(manual|auto)$") refresh_mode: str = Field(default="manual", pattern="^(manual|auto)$")
refresh_interval: int = Field(default=15, ge=10, le=10080) 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) retention_days: int = Field(default=0, ge=0, le=999)
international_sources: bool = True international_sources: bool = True
include_telegram: bool = False include_telegram: bool = False
@@ -62,6 +63,7 @@ class IncidentUpdate(BaseModel):
status: Optional[str] = Field(default=None, pattern="^(active|archived)$") status: Optional[str] = Field(default=None, pattern="^(active|archived)$")
refresh_mode: Optional[str] = Field(default=None, pattern="^(manual|auto)$") refresh_mode: Optional[str] = Field(default=None, pattern="^(manual|auto)$")
refresh_interval: Optional[int] = Field(default=None, ge=10, le=10080) 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) retention_days: Optional[int] = Field(default=None, ge=0, le=999)
international_sources: Optional[bool] = None international_sources: Optional[bool] = None
include_telegram: Optional[bool] = None include_telegram: Optional[bool] = None
@@ -82,6 +84,7 @@ class IncidentResponse(BaseModel):
status: str status: str
refresh_mode: str refresh_mode: str
refresh_interval: int refresh_interval: int
refresh_start_time: Optional[str] = None
retention_days: int retention_days: int
visibility: str = "public" visibility: str = "public"
summary: Optional[str] summary: Optional[str]

Datei anzeigen

@@ -21,7 +21,7 @@ router = APIRouter(prefix="/api/incidents", tags=["incidents"])
INCIDENT_UPDATE_COLUMNS = { INCIDENT_UPDATE_COLUMNS = {
"title", "description", "type", "status", "refresh_mode", "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') now = datetime.now(TIMEZONE).strftime('%Y-%m-%d %H:%M:%S')
cursor = await db.execute( cursor = await db.execute(
"""INSERT INTO incidents (title, description, type, refresh_mode, refresh_interval, """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) tenant_id, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
( (
data.title, data.title,
data.description, data.description,
data.type, data.type,
data.refresh_mode, data.refresh_mode,
data.refresh_interval, data.refresh_interval,
data.refresh_start_time,
data.retention_days, data.retention_days,
1 if data.international_sources else 0, 1 if data.international_sources else 0,
1 if data.include_telegram else 0, 1 if data.include_telegram else 0,

Datei anzeigen

@@ -398,6 +398,10 @@
</select> </select>
</div> </div>
</div> </div>
<div class="form-group conditional-field" id="refresh-starttime-field">
<label for="inc-refresh-starttime">Erste Aktualisierung um <span class="info-icon tooltip-below" data-tooltip="Legt den Startzeitpunkt fest. Danach wird im eingestellten Intervall aktualisiert."><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg></span></label>
<input type="time" id="inc-refresh-starttime" value="07:00" required>
</div>
<div class="form-group"> <div class="form-group">
<label for="inc-retention">Aufbewahrung (Tage) <span class="info-icon tooltip-below" data-tooltip="Nach Ablauf wird die Lage automatisch archiviert. 0 = unbegrenzt aufbewahren."><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg></span></label> <label for="inc-retention">Aufbewahrung (Tage) <span class="info-icon tooltip-below" data-tooltip="Nach Ablauf wird die Lage automatisch archiviert. 0 = unbegrenzt aufbewahren."><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg></span></label>
<input type="number" id="inc-retention" min="0" max="999" value="30" placeholder="0 = Unbegrenzt"> <input type="number" id="inc-retention" min="0" max="999" value="30" placeholder="0 = Unbegrenzt">

Datei anzeigen

@@ -821,9 +821,16 @@ const App = {
: ''; : '';
} }
{ const _e = document.getElementById('meta-refresh-mode'); if (_e) _e.textContent = incident.refresh_mode === 'auto' { const _e = document.getElementById('meta-refresh-mode'); if (_e) {
? `Auto alle ${App._formatInterval(incident.refresh_interval)}` if (incident.refresh_mode === 'auto' && incident.refresh_start_time) {
: 'Manuell'; } 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 // International-Badge
const intlBadge = document.getElementById('intl-badge'); const intlBadge = document.getElementById('intl-badge');
@@ -1517,6 +1524,9 @@ const App = {
type: document.getElementById('inc-type').value, type: document.getElementById('inc-type').value,
refresh_mode: document.getElementById('inc-refresh-mode').value, refresh_mode: document.getElementById('inc-refresh-mode').value,
refresh_interval: interval, 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, retention_days: parseInt(document.getElementById('inc-retention').value) || 0,
international_sources: document.getElementById('inc-international').checked, international_sources: document.getElementById('inc-international').checked,
include_telegram: document.getElementById('inc-telegram').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-type'); if (_e) _e.value = incident.type || 'adhoc'; }
{ const _e = document.getElementById('inc-refresh-mode'); if (_e) _e.value = incident.refresh_mode; } { const _e = document.getElementById('inc-refresh-mode'); if (_e) _e.value = incident.refresh_mode; }
App._setIntervalFields(incident.refresh_interval); 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-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-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; } { const _e = document.getElementById('inc-telegram'); if (_e) _e.checked = !!incident.include_telegram; }
@@ -3214,7 +3225,9 @@ function buildDetailedSourceOverview() {
function toggleRefreshInterval() { function toggleRefreshInterval() {
const mode = document.getElementById('inc-refresh-mode').value; const mode = document.getElementById('inc-refresh-mode').value;
const field = document.getElementById('refresh-interval-field'); const field = document.getElementById('refresh-interval-field');
const startField = document.getElementById('refresh-starttime-field');
field.classList.toggle('visible', mode === 'auto'); field.classList.toggle('visible', mode === 'auto');
if (startField) startField.classList.toggle('visible', mode === 'auto');
} }
function updateIntervalMin() { function updateIntervalMin() {