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:
@@ -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()]
|
||||
|
||||
73
src/main.py
73
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:
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -398,6 +398,10 @@
|
||||
</select>
|
||||
</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">
|
||||
<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">
|
||||
|
||||
@@ -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() {
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren