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