Skip to content

Commit 9c70ec4

Browse files
authored
Merge pull request #243 from itsDNNS/feat/237-smart-capture-ui-surfaces
feat: Smart Capture UI surfaces in settings, events, speedtest, and correlation
2 parents 083e1ae + db08dd3 commit 9c70ec4

30 files changed

+859
-36
lines changed

app/blueprints/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ def register_blueprints(app):
1010
from .modules_bp import modules_bp
1111
from .metrics_bp import metrics_bp
1212
from .segment_bp import segment_bp
13+
from .smart_capture_bp import smart_capture_bp
1314

1415
app.register_blueprint(config_bp)
1516
app.register_blueprint(polling_bp)
@@ -19,3 +20,4 @@ def register_blueprints(app):
1920
app.register_blueprint(modules_bp)
2021
app.register_blueprint(metrics_bp)
2122
app.register_blueprint(segment_bp)
23+
app.register_blueprint(smart_capture_bp)

app/blueprints/analysis_bp.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ def api_correlation():
200200

201201
sources_param = request.args.get("sources", "")
202202
if sources_param:
203-
valid = {"modem", "speedtest", "events", "bnetz"}
203+
valid = {"modem", "speedtest", "events", "bnetz", "capture"}
204204
sources = valid & set(s.strip() for s in sources_param.split(","))
205205
if not sources:
206206
sources = valid

app/blueprints/smart_capture_bp.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
"""Smart Capture API endpoints."""
2+
3+
from flask import Blueprint, jsonify, request
4+
5+
from ..web import require_auth, get_storage
6+
7+
smart_capture_bp = Blueprint("smart_capture", __name__)
8+
9+
10+
@smart_capture_bp.route("/api/smart-capture/executions")
11+
@require_auth
12+
def api_smart_capture_executions():
13+
"""Return Smart Capture execution history."""
14+
_storage = get_storage()
15+
if not _storage:
16+
return jsonify({"executions": []})
17+
limit = request.args.get("limit", 50, type=int)
18+
offset = request.args.get("offset", 0, type=int)
19+
status = request.args.get("status", None)
20+
executions = _storage.get_executions(limit=limit, offset=offset, status=status)
21+
return jsonify({"executions": executions})

app/config.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@
5454
"sc_max_actions_per_hour": 4,
5555
"sc_flapping_window": 3600,
5656
"sc_flapping_threshold": 3,
57+
"sc_trigger_modulation": True,
58+
"sc_trigger_snr": False,
59+
"sc_trigger_error_spike": False,
60+
"sc_trigger_health": False,
5761
}
5862

5963
ENV_MAP = {
@@ -100,6 +104,10 @@
100104
"sc_max_actions_per_hour": "SC_MAX_ACTIONS_PER_HOUR",
101105
"sc_flapping_window": "SC_FLAPPING_WINDOW",
102106
"sc_flapping_threshold": "SC_FLAPPING_THRESHOLD",
107+
"sc_trigger_modulation": "SC_TRIGGER_MODULATION",
108+
"sc_trigger_snr": "SC_TRIGGER_SNR",
109+
"sc_trigger_error_spike": "SC_TRIGGER_ERROR_SPIKE",
110+
"sc_trigger_health": "SC_TRIGGER_HEALTH",
103111
}
104112

105113
# Deprecated env vars (FRITZ_* -> MODEM_*) - checked as fallback
@@ -119,7 +127,8 @@
119127
INT_KEYS = {"poll_interval", "web_port", "history_days", "notify_cooldown", "health_hysteresis",
120128
"sc_global_cooldown", "sc_trigger_cooldown", "sc_max_actions_per_hour",
121129
"sc_flapping_window", "sc_flapping_threshold"}
122-
BOOL_KEYS = {"demo_mode", "gaming_quality_enabled", "segment_utilization_enabled", "sc_enabled"}
130+
BOOL_KEYS = {"demo_mode", "gaming_quality_enabled", "segment_utilization_enabled", "sc_enabled",
131+
"sc_trigger_modulation", "sc_trigger_snr", "sc_trigger_error_spike", "sc_trigger_health"}
123132

124133
URL_KEYS = {"modem_url", "bqm_url", "speedtest_tracker_url", "notify_webhook_url"}
125134
_ALLOWED_URL_SCHEMES = {"http", "https"}

app/i18n/de.json

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -790,5 +790,40 @@
790790
"seg_unavailable": "Konfiguration nicht verfügbar.",
791791
"seg_correlation_ds": "Segment DS Last (%)",
792792
"seg_correlation_us": "Segment US Last (%)",
793-
"sidebar_analysis": "Analyse"
794-
}
793+
"sidebar_analysis": "Analyse",
794+
"smart_capture": "Smart Capture",
795+
"sc_enable": "Smart Capture aktivieren",
796+
"sc_enable_desc": "Automatisch Messungen starten, wenn Signalverschlechterungen erkannt werden",
797+
"sc_triggers": "Trigger-Ereignisse",
798+
"sc_triggers_desc": "Welche Signalverschlechterungen sollen eine Messung auslösen",
799+
"sc_trigger_modulation": "Modulationsverschlechterung",
800+
"sc_trigger_modulation_desc": "Wird ausgelöst wenn der QAM-Pegel auf einem Kanal sinkt (z.B. 256QAM auf 64QAM). Nur Verschlechterungen mit Warnung oder Kritisch.",
801+
"sc_trigger_snr": "SNR-Verschlechterung",
802+
"sc_trigger_snr_desc": "Wird ausgelöst wenn das Signal-Rausch-Verhältnis unter den Warnschwellwert fällt.",
803+
"sc_trigger_error_spike": "Fehler-Spitze",
804+
"sc_trigger_error_spike_desc": "Wird ausgelöst wenn unkorrigierbare Fehler zwischen Abfragen stark ansteigen.",
805+
"sc_trigger_health": "Gesundheitsänderung",
806+
"sc_trigger_health_desc": "Wird ausgelöst wenn die Verbindungsqualität auf Grenzwertig oder Kritisch absinkt.",
807+
"sc_guardrails": "Schutzmechanismen",
808+
"sc_guardrails_desc": "Ratenbegrenzung um übermäßige Test-Auslöser zu verhindern",
809+
"sc_global_cooldown": "Globale Abklingzeit (Sekunden)",
810+
"sc_trigger_cooldown_label": "Pro-Trigger Abklingzeit (Sekunden)",
811+
"sc_max_actions_per_hour": "Max. Aktionen pro Stunde",
812+
"sc_history": "Ausführungsverlauf",
813+
"sc_history_empty": "Noch keine Ausführungen. Smart Capture protokolliert Aktivitäten hier sobald es aktiviert ist.",
814+
"sc_history_error": "Ausführungsverlauf konnte nicht geladen werden",
815+
"sc_status_pending": "Ausstehend",
816+
"sc_status_fired": "Ausgelöst",
817+
"sc_status_completed": "Abgeschlossen",
818+
"sc_status_suppressed": "Unterdrückt",
819+
"sc_status_expired": "Abgelaufen",
820+
"sc_col_timestamp": "Zeitstempel",
821+
"sc_col_trigger": "Trigger",
822+
"sc_col_status": "Status",
823+
"sc_col_details": "Details",
824+
"sc_action_capture": "Speedtest ausgelöst",
825+
"sc_no_adapter_warning": "Kein Aktionsadapter konfiguriert. Aktiviere Speedtest Tracker um Smart Capture zu nutzen.",
826+
"sc_badge_label": "Smart Capture",
827+
"correlation_source_capture": "Capture",
828+
"event_type_smart_capture_triggered": "Smart Capture"
829+
}

app/i18n/en.json

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -790,5 +790,40 @@
790790
"seg_unavailable": "Configuration unavailable.",
791791
"seg_correlation_ds": "Segment DS Load (%)",
792792
"seg_correlation_us": "Segment US Load (%)",
793-
"sidebar_analysis": "Analysis"
794-
}
793+
"sidebar_analysis": "Analysis",
794+
"smart_capture": "Smart Capture",
795+
"sc_enable": "Enable Smart Capture",
796+
"sc_enable_desc": "Automatically trigger measurements when signal degradation is detected",
797+
"sc_triggers": "Trigger Events",
798+
"sc_triggers_desc": "Choose which signal degradation events should trigger a measurement",
799+
"sc_trigger_modulation": "Modulation downgrade",
800+
"sc_trigger_modulation_desc": "Triggers when QAM level drops on any channel (e.g. 256QAM to 64QAM). Only downgrades with severity warning or critical.",
801+
"sc_trigger_snr": "SNR degradation",
802+
"sc_trigger_snr_desc": "Triggers when signal-to-noise ratio drops below warning threshold.",
803+
"sc_trigger_error_spike": "Error spike",
804+
"sc_trigger_error_spike_desc": "Triggers when uncorrectable errors jump significantly between polls.",
805+
"sc_trigger_health": "Health change",
806+
"sc_trigger_health_desc": "Triggers when connection health degrades to marginal or critical.",
807+
"sc_guardrails": "Guardrails",
808+
"sc_guardrails_desc": "Rate limiting to prevent excessive test triggers",
809+
"sc_global_cooldown": "Global cooldown (seconds)",
810+
"sc_trigger_cooldown_label": "Per-trigger cooldown (seconds)",
811+
"sc_max_actions_per_hour": "Max actions per hour",
812+
"sc_history": "Execution History",
813+
"sc_history_empty": "No executions yet. Smart Capture will log activity here once enabled.",
814+
"sc_history_error": "Failed to load execution history",
815+
"sc_status_pending": "Pending",
816+
"sc_status_fired": "Fired",
817+
"sc_status_completed": "Completed",
818+
"sc_status_suppressed": "Suppressed",
819+
"sc_status_expired": "Expired",
820+
"sc_col_timestamp": "Timestamp",
821+
"sc_col_trigger": "Trigger",
822+
"sc_col_status": "Status",
823+
"sc_col_details": "Details",
824+
"sc_action_capture": "Speedtest triggered",
825+
"sc_no_adapter_warning": "No action adapter configured. Enable Speedtest Tracker to use Smart Capture.",
826+
"sc_badge_label": "Smart Capture",
827+
"correlation_source_capture": "Capture",
828+
"event_type_smart_capture_triggered": "Smart Capture"
829+
}

app/i18n/es.json

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -785,5 +785,40 @@
785785
"seg_unavailable": "Configuracion no disponible.",
786786
"seg_correlation_ds": "Carga segmento DS (%)",
787787
"seg_correlation_us": "Carga segmento US (%)",
788-
"sidebar_analysis": "Analisis"
789-
}
788+
"sidebar_analysis": "Analisis",
789+
"smart_capture": "Smart Capture",
790+
"sc_enable": "Activar Smart Capture",
791+
"sc_enable_desc": "Activar automaticamente mediciones cuando se detecta degradacion de senal",
792+
"sc_triggers": "Eventos de activacion",
793+
"sc_triggers_desc": "Elegir que eventos de degradacion de senal deben activar una medicion",
794+
"sc_trigger_modulation": "Degradacion de modulacion",
795+
"sc_trigger_modulation_desc": "Se activa cuando el nivel QAM baja en cualquier canal (ej. 256QAM a 64QAM). Solo degradaciones con severidad advertencia o critica.",
796+
"sc_trigger_snr": "Degradacion SNR",
797+
"sc_trigger_snr_desc": "Se activa cuando la relacion senal/ruido cae por debajo del umbral de advertencia.",
798+
"sc_trigger_error_spike": "Pico de errores",
799+
"sc_trigger_error_spike_desc": "Se activa cuando los errores incorregibles aumentan significativamente entre sondeos.",
800+
"sc_trigger_health": "Cambio de salud",
801+
"sc_trigger_health_desc": "Se activa cuando la salud de la conexion se degrada a marginal o critica.",
802+
"sc_guardrails": "Protecciones",
803+
"sc_guardrails_desc": "Limitacion de velocidad para evitar activaciones excesivas de pruebas",
804+
"sc_global_cooldown": "Enfriamiento global (segundos)",
805+
"sc_trigger_cooldown_label": "Enfriamiento por activador (segundos)",
806+
"sc_max_actions_per_hour": "Acciones max por hora",
807+
"sc_history": "Historial de ejecuciones",
808+
"sc_history_empty": "Sin ejecuciones aun. Smart Capture registrara la actividad aqui una vez activado.",
809+
"sc_history_error": "Error al cargar el historial de ejecuciones",
810+
"sc_status_pending": "Pendiente",
811+
"sc_status_fired": "Activado",
812+
"sc_status_completed": "Completado",
813+
"sc_status_suppressed": "Suprimido",
814+
"sc_status_expired": "Expirado",
815+
"sc_col_timestamp": "Marca de tiempo",
816+
"sc_col_trigger": "Activador",
817+
"sc_col_status": "Estado",
818+
"sc_col_details": "Detalles",
819+
"sc_action_capture": "Speedtest activado",
820+
"sc_no_adapter_warning": "Ningun adaptador de accion configurado. Active Speedtest Tracker para usar Smart Capture.",
821+
"sc_badge_label": "Smart Capture",
822+
"correlation_source_capture": "Capture",
823+
"event_type_smart_capture_triggered": "Smart Capture"
824+
}

app/i18n/fr.json

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -782,5 +782,40 @@
782782
"seg_unavailable": "Configuration non disponible.",
783783
"seg_correlation_ds": "Charge segment DS (%)",
784784
"seg_correlation_us": "Charge segment US (%)",
785-
"sidebar_analysis": "Analyse"
786-
}
785+
"sidebar_analysis": "Analyse",
786+
"smart_capture": "Smart Capture",
787+
"sc_enable": "Activer Smart Capture",
788+
"sc_enable_desc": "Declencher automatiquement des mesures lorsque des degradations du signal sont detectees",
789+
"sc_triggers": "Evenements declencheurs",
790+
"sc_triggers_desc": "Choisir quels evenements de degradation du signal doivent declencher une mesure",
791+
"sc_trigger_modulation": "Degradation de modulation",
792+
"sc_trigger_modulation_desc": "Se declenche lorsque le niveau QAM baisse sur un canal (ex. 256QAM vers 64QAM). Uniquement les degradations avec severite avertissement ou critique.",
793+
"sc_trigger_snr": "Degradation SNR",
794+
"sc_trigger_snr_desc": "Se declenche lorsque le rapport signal/bruit descend sous le seuil d'avertissement.",
795+
"sc_trigger_error_spike": "Pic d'erreurs",
796+
"sc_trigger_error_spike_desc": "Se declenche lorsque les erreurs non corrigeables augmentent significativement entre les interrogations.",
797+
"sc_trigger_health": "Changement de sante",
798+
"sc_trigger_health_desc": "Se declenche lorsque la sante de la connexion se degrade vers marginal ou critique.",
799+
"sc_guardrails": "Garde-fous",
800+
"sc_guardrails_desc": "Limitation du debit pour eviter les declenchements excessifs de tests",
801+
"sc_global_cooldown": "Temps de refroidissement global (secondes)",
802+
"sc_trigger_cooldown_label": "Temps de refroidissement par declencheur (secondes)",
803+
"sc_max_actions_per_hour": "Actions max par heure",
804+
"sc_history": "Historique des executions",
805+
"sc_history_empty": "Aucune execution pour le moment. Smart Capture enregistrera les activites ici une fois active.",
806+
"sc_history_error": "Echec du chargement de l'historique des executions",
807+
"sc_status_pending": "En attente",
808+
"sc_status_fired": "Declenche",
809+
"sc_status_completed": "Termine",
810+
"sc_status_suppressed": "Supprime",
811+
"sc_status_expired": "Expire",
812+
"sc_col_timestamp": "Horodatage",
813+
"sc_col_trigger": "Declencheur",
814+
"sc_col_status": "Statut",
815+
"sc_col_details": "Details",
816+
"sc_action_capture": "Speedtest declenche",
817+
"sc_no_adapter_warning": "Aucun adaptateur d'action configure. Activez Speedtest Tracker pour utiliser Smart Capture.",
818+
"sc_badge_label": "Smart Capture",
819+
"correlation_source_capture": "Capture",
820+
"event_type_smart_capture_triggered": "Smart Capture"
821+
}

app/main.py

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -106,19 +106,27 @@ def polling_loop(config_mgr, storage, stop_event):
106106
notifier = NotificationDispatcher(config_mgr)
107107
log.info("Notifications: webhook configured")
108108

109-
# Smart Capture (optional)
110-
smart_capture = None
111-
if config_mgr.get("sc_enabled", False):
112-
from .smart_capture import SmartCaptureEngine, Trigger
113-
smart_capture = SmartCaptureEngine(storage, config_mgr)
114-
# Default trigger: modulation downgrades (v1 scope)
115-
smart_capture.register_trigger(Trigger(
116-
event_type="modulation_change",
117-
action_type="capture",
118-
min_severity="warning",
119-
require_details={"direction": "downgrade"},
120-
))
121-
log.info("Smart Capture: enabled with %d trigger(s)", len(smart_capture.triggers))
109+
# Smart Capture (always instantiated — _is_enabled() gates at runtime)
110+
from .smart_capture import SmartCaptureEngine, Trigger
111+
smart_capture = SmartCaptureEngine(storage, config_mgr)
112+
smart_capture.register_trigger(Trigger(
113+
event_type="modulation_change", action_type="capture",
114+
config_key="sc_trigger_modulation",
115+
min_severity="warning", require_details={"direction": "downgrade"},
116+
))
117+
smart_capture.register_trigger(Trigger(
118+
event_type="snr_change", action_type="capture",
119+
config_key="sc_trigger_snr", min_severity="warning",
120+
))
121+
smart_capture.register_trigger(Trigger(
122+
event_type="error_spike", action_type="capture",
123+
config_key="sc_trigger_error_spike",
124+
))
125+
smart_capture.register_trigger(Trigger(
126+
event_type="health_change", action_type="capture",
127+
config_key="sc_trigger_health", min_severity="warning",
128+
))
129+
log.info("Smart Capture: registered %d trigger(s)", len(smart_capture.triggers))
122130

123131
web.update_state(poll_interval=config["poll_interval"])
124132

@@ -128,8 +136,8 @@ def polling_loop(config_mgr, storage, stop_event):
128136
notifier=notifier, smart_capture=smart_capture,
129137
)
130138

131-
# Wire STT adapter if Smart Capture enabled, STT configured, and not demo mode
132-
if smart_capture and config_mgr.is_speedtest_configured() and not config_mgr.is_demo_mode():
139+
# Wire STT adapter if STT configured and not demo mode
140+
if config_mgr.is_speedtest_configured() and not config_mgr.is_demo_mode():
133141
from .smart_capture.adapters.speedtest import SpeedtestAdapter
134142
stt_adapter = SpeedtestAdapter(storage, config_mgr)
135143
smart_capture.register_adapter("capture", stt_adapter)
@@ -245,7 +253,7 @@ def _run_collector(collector):
245253
future.cancel()
246254

247255
# ── Smart Capture expiry check (every 60s) ──
248-
if smart_capture and smart_capture.adapter_action_types:
256+
if smart_capture:
249257
if not hasattr(polling_loop, '_sc_expiry_counter'):
250258
polling_loop._sc_expiry_counter = 0
251259
polling_loop._sc_expiry_counter += 1
@@ -258,6 +266,11 @@ def _run_collector(collector):
258266
if expired:
259267
log.info("Smart Capture: expired %d stale %s executions",
260268
expired, action_type)
269+
# Expire orphaned PENDING executions (no adapter registered)
270+
pending_expired = storage.expire_stale_pending(cutoff)
271+
if pending_expired:
272+
log.info("Smart Capture: expired %d orphaned pending executions",
273+
pending_expired)
261274

262275
stop_event.wait(1)
263276
finally:

app/modules/speedtest/routes.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,23 @@ def _enrich_speedtest(result):
6262
return result
6363

6464

65+
def _annotate_smart_capture(results, db_path):
66+
"""Annotate speedtest results with smart_capture flag."""
67+
try:
68+
import sqlite3
69+
with sqlite3.connect(db_path) as conn:
70+
rows = conn.execute(
71+
"SELECT linked_result_id FROM smart_capture_executions "
72+
"WHERE status = 'completed' AND linked_result_id IS NOT NULL"
73+
).fetchall()
74+
sc_ids = {r[0] for r in rows}
75+
for r in results:
76+
r["smart_capture"] = r.get("id") in sc_ids
77+
except Exception:
78+
for r in results:
79+
r["smart_capture"] = False
80+
81+
6582
def _get_lang():
6683
from flask import request as req
6784
return req.cookies.get("lang", "en")
@@ -80,7 +97,9 @@ def api_speedtest():
8097
# Demo mode: return seeded data without external API call
8198
if _config_manager.is_demo_mode() and ss:
8299
results = ss.get_speedtest_results(limit=count)
83-
return jsonify([_enrich_speedtest(r) for r in results])
100+
enriched = [_enrich_speedtest(r) for r in results]
101+
_annotate_smart_capture(enriched, ss.db_path)
102+
return jsonify(enriched)
84103
# Delta fetch: get new results from STT API and cache them
85104
if ss:
86105
try:
@@ -101,7 +120,9 @@ def api_speedtest():
101120
except Exception as e:
102121
log.warning("Speedtest delta fetch failed: %s", e)
103122
results = ss.get_speedtest_results(limit=count)
104-
return jsonify([_enrich_speedtest(r) for r in results])
123+
enriched = [_enrich_speedtest(r) for r in results]
124+
_annotate_smart_capture(enriched, ss.db_path)
125+
return jsonify(enriched)
105126
# Fallback: no storage, fetch directly
106127
from .client import SpeedtestClient
107128
client = SpeedtestClient(

0 commit comments

Comments
 (0)