Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 72 additions & 3 deletions app/blueprints/segment_bp.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ def _get_storage():
RANGE_HOURS = {"24h": 24, "7d": 168, "30d": 720, "all": 0}


def _normalize_range_key(raw, default):
"""Return ``raw`` if it's a recognized range key, otherwise ``default``."""
if raw in RANGE_HOURS:
return raw
return default


@segment_bp.route("/api/fritzbox/segment-utilization")
@require_auth
def api_segment_utilization():
Expand All @@ -50,8 +57,8 @@ def api_segment_utilization():
if not storage:
return jsonify({"error": "Storage unavailable"}), 503

range_key = request.args.get("range", "24h")
hours = RANGE_HOURS.get(range_key, 24)
range_key = _normalize_range_key(request.args.get("range"), "24h")
hours = RANGE_HOURS[range_key]

if hours > 0:
start = (datetime.now(timezone.utc) - timedelta(hours=hours)).strftime("%Y-%m-%dT%H:%M:%SZ")
Expand All @@ -66,10 +73,72 @@ def api_segment_utilization():
})


def _clamp_int(value, default, lo, hi):
"""Parse an int-ish query param and clamp it to [lo, hi]. Falls back
to ``default`` on parse errors or None."""
if value is None or value == "":
return default
try:
parsed = int(value)
except (TypeError, ValueError):
return default
if parsed < lo:
return lo
if parsed > hi:
return hi
return parsed


@segment_bp.route("/api/fritzbox/segment-utilization/events")
@require_auth
def api_segment_utilization_events():
"""Return detected segment saturation events for the requested range."""
config = get_config_manager()
t = get_translations(_get_lang())
if not config:
return jsonify({"error": t.get("seg_unavailable", "Configuration unavailable.")}), 503
if config.get("modem_type") != "fritzbox":
return jsonify({"error": t.get("seg_unsupported_driver", "This view is only available for FRITZ!Box cable devices.")}), 400
if not config.is_segment_utilization_enabled():
return jsonify({"error": t.get("seg_disabled", "Segment utilization is disabled in Settings.")}), 400

storage = _get_storage()
if not storage:
return jsonify({"error": "Storage unavailable"}), 503

threshold = _clamp_int(request.args.get("threshold"), default=80, lo=1, hi=100)
min_minutes = _clamp_int(request.args.get("min_minutes"), default=3, lo=1, hi=1440)

range_key = _normalize_range_key(request.args.get("range"), "7d")
hours = RANGE_HOURS[range_key]
if hours > 0:
start = (datetime.now(timezone.utc) - timedelta(hours=hours)).strftime("%Y-%m-%dT%H:%M:%SZ")
else:
start = "2000-01-01T00:00:00Z"
end = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")

events = storage.get_events(start, end, threshold=threshold, min_minutes=min_minutes)
return jsonify({
"events": events,
"threshold": threshold,
"min_minutes": min_minutes,
"range": range_key,
})


@segment_bp.route("/api/fritzbox/segment-utilization/range")
@require_auth
def api_segment_utilization_range():
"""Return segment data for a time range (used by correlation graph)."""
"""Return segment data for a time range (used by correlation graph).

Returns an empty list — rather than an error — when configuration is
unavailable, the driver is unsupported, or the feature is disabled.
This matches ``/api/weather/range`` so the correlation view degrades
gracefully when optional data sources are absent.
"""
config = get_config_manager()
if not config or config.get("modem_type") != "fritzbox" or not config.is_segment_utilization_enabled():
return jsonify([])
storage = _get_storage()
if not storage:
return jsonify([])
Expand Down
19 changes: 19 additions & 0 deletions app/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -795,6 +795,25 @@
"seg_unavailable": "Konfiguration nicht verfügbar.",
"seg_correlation_ds": "Segment DS Last (%)",
"seg_correlation_us": "Segment US Last (%)",
"seg_events_title": "Segment-Auslastungsereignisse",
"seg_events_subtitle": "Zeiträume, in denen Downstream- oder Upstream-Last auf oder über dem Schwellwert lag.",
"seg_events_meta": "Schwellwert {th}% für {min}+ Min",
"seg_events_loading": "Auslastungsereignisse werden geladen...",
"seg_events_empty": "Keine Auslastungsereignisse in diesem Zeitraum gefunden.",
"seg_events_error": "Auslastungsereignisse konnten nicht geladen werden.",
"seg_events_direction_ds": "Downstream",
"seg_events_direction_us": "Upstream",
"seg_events_peak_total": "Peak Gesamt",
"seg_events_peak_own": "Peak Eigen",
"seg_events_peak_neighbor": "Peak Nachbarlast",
"seg_events_confidence": "Konfidenz",
"seg_events_confidence_high": "hoch",
"seg_events_confidence_medium": "mittel",
"seg_events_confidence_low": "niedrig",
"seg_events_correlate": "In Korrelation öffnen",
"seg_events_correlate_out_of_range": "Die Korrelationsansicht unterstützt bis zu 7 Tage.",
"seg_events_min_short": "Min",
"seg_events_h_short": "Std",
"sidebar_analysis": "Analyse",
"smart_capture": "Smart Capture",
"sc_enable": "Smart Capture aktivieren",
Expand Down
19 changes: 19 additions & 0 deletions app/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -795,6 +795,25 @@
"seg_unavailable": "Configuration unavailable.",
"seg_correlation_ds": "Segment DS Load (%)",
"seg_correlation_us": "Segment US Load (%)",
"seg_events_title": "Segment Saturation Events",
"seg_events_subtitle": "Periods where downstream or upstream load stayed at or above the threshold.",
"seg_events_meta": "Threshold {th}% for {min}+ min",
"seg_events_loading": "Loading saturation events...",
"seg_events_empty": "No saturation events detected in this range.",
"seg_events_error": "Could not load saturation events.",
"seg_events_direction_ds": "Downstream",
"seg_events_direction_us": "Upstream",
"seg_events_peak_total": "Peak total",
"seg_events_peak_own": "Peak own",
"seg_events_peak_neighbor": "Peak neighbor load",
"seg_events_confidence": "Confidence",
"seg_events_confidence_high": "high",
"seg_events_confidence_medium": "medium",
"seg_events_confidence_low": "low",
"seg_events_correlate": "Open in correlation",
"seg_events_correlate_out_of_range": "Correlation view supports up to 7 days.",
"seg_events_min_short": "min",
"seg_events_h_short": "h",
"sidebar_analysis": "Analysis",
"smart_capture": "Smart Capture",
"sc_enable": "Enable Smart Capture",
Expand Down
19 changes: 19 additions & 0 deletions app/i18n/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -790,6 +790,25 @@
"seg_unavailable": "Configuracion no disponible.",
"seg_correlation_ds": "Carga segmento DS (%)",
"seg_correlation_us": "Carga segmento US (%)",
"seg_events_title": "Eventos de saturacion del segmento",
"seg_events_subtitle": "Periodos en los que la carga downstream o upstream se mantuvo en el umbral o por encima.",
"seg_events_meta": "Umbral {th}% durante {min}+ min",
"seg_events_loading": "Cargando eventos de saturacion...",
"seg_events_empty": "No se detectaron eventos de saturacion en este rango.",
"seg_events_error": "No se pudieron cargar los eventos de saturacion.",
"seg_events_direction_ds": "Downstream",
"seg_events_direction_us": "Upstream",
"seg_events_peak_total": "Pico total",
"seg_events_peak_own": "Pico propio",
"seg_events_peak_neighbor": "Pico carga vecina",
"seg_events_confidence": "Confianza",
"seg_events_confidence_high": "alta",
"seg_events_confidence_medium": "media",
"seg_events_confidence_low": "baja",
"seg_events_correlate": "Abrir en correlacion",
"seg_events_correlate_out_of_range": "La vista de correlacion admite hasta 7 dias.",
"seg_events_min_short": "min",
"seg_events_h_short": "h",
"sidebar_analysis": "Analisis",
"smart_capture": "Smart Capture",
"sc_enable": "Activar Smart Capture",
Expand Down
19 changes: 19 additions & 0 deletions app/i18n/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -787,6 +787,25 @@
"seg_unavailable": "Configuration non disponible.",
"seg_correlation_ds": "Charge segment DS (%)",
"seg_correlation_us": "Charge segment US (%)",
"seg_events_title": "Evenements de saturation du segment",
"seg_events_subtitle": "Periodes ou la charge downstream ou upstream est restee au-dessus du seuil.",
"seg_events_meta": "Seuil {th}% pendant {min}+ min",
"seg_events_loading": "Chargement des evenements de saturation...",
"seg_events_empty": "Aucun evenement de saturation detecte dans cette plage.",
"seg_events_error": "Impossible de charger les evenements de saturation.",
"seg_events_direction_ds": "Downstream",
"seg_events_direction_us": "Upstream",
"seg_events_peak_total": "Pic total",
"seg_events_peak_own": "Pic propre",
"seg_events_peak_neighbor": "Pic charge voisine",
"seg_events_confidence": "Confiance",
"seg_events_confidence_high": "elevee",
"seg_events_confidence_medium": "moyenne",
"seg_events_confidence_low": "faible",
"seg_events_correlate": "Ouvrir dans la correlation",
"seg_events_correlate_out_of_range": "La vue de correlation prend en charge jusqu'a 7 jours.",
"seg_events_min_short": "min",
"seg_events_h_short": "h",
"sidebar_analysis": "Analyse",
"smart_capture": "Smart Capture",
"sc_enable": "Activer Smart Capture",
Expand Down
19 changes: 19 additions & 0 deletions app/i18n/template.json
Original file line number Diff line number Diff line change
Expand Up @@ -734,6 +734,25 @@
"dashboard_features": "",
"dashboard_features_desc": "",
"seg_disabled": "",
"seg_events_title": "",
"seg_events_subtitle": "",
"seg_events_meta": "",
"seg_events_loading": "",
"seg_events_empty": "",
"seg_events_error": "",
"seg_events_direction_ds": "",
"seg_events_direction_us": "",
"seg_events_peak_total": "",
"seg_events_peak_own": "",
"seg_events_peak_neighbor": "",
"seg_events_confidence": "",
"seg_events_confidence_high": "",
"seg_events_confidence_medium": "",
"seg_events_confidence_low": "",
"seg_events_correlate": "",
"seg_events_correlate_out_of_range": "",
"seg_events_min_short": "",
"seg_events_h_short": "",
"sidebar_analysis": "",
"font_family": "",
"font_family_desc": "",
Expand Down
139 changes: 139 additions & 0 deletions app/static/css/segment-utilization.css
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,142 @@
font-size: 0.85em;
}

/* Events Widget */
.fritz-cable-events {
padding: 18px;
margin-bottom: 20px;
}

.fritz-cable-events-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 4px;
}

.fritz-cable-events-meta {
font-size: 0.8em;
color: var(--text-secondary, #888);
}

.fritz-cable-events-subtitle {
font-size: 0.85em;
color: var(--text-secondary, #888);
margin-bottom: 12px;
}

.fritz-cable-events-status {
padding: 16px 0;
color: var(--text-secondary, #888);
font-size: 0.9em;
}

.fritz-cable-events-status.is-error {
color: var(--danger, #ef4444);
}

.fritz-cable-events-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 10px;
}

.fritz-cable-event {
padding: 12px 14px;
border: 1px solid var(--border, rgba(255,255,255,0.08));
border-radius: 10px;
background: var(--surface-2, rgba(255,255,255,0.02));
display: flex;
flex-direction: column;
gap: 6px;
}

.fritz-cable-event-header {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}

.fritz-cable-event-direction {
font-weight: 600;
font-size: 0.8em;
letter-spacing: 0.04em;
text-transform: uppercase;
padding: 2px 8px;
border-radius: 999px;
background: rgba(168, 85, 247, 0.15);
color: rgba(168, 85, 247, 1);
}

.fritz-cable-event-direction.is-upstream {
background: rgba(99, 102, 241, 0.15);
color: rgba(129, 140, 248, 1);
}

.fritz-cable-event-time {
font-size: 0.9em;
color: var(--text-primary, var(--text));
}

.fritz-cable-event-duration {
margin-left: auto;
font-size: 0.85em;
color: var(--text-secondary, #888);
}

.fritz-cable-event-stats {
display: flex;
flex-wrap: wrap;
gap: 4px 16px;
font-size: 0.85em;
color: var(--text-primary, var(--text));
}

.fritz-cable-event-stats em {
font-style: normal;
color: var(--text-secondary, #888);
margin-right: 4px;
}

.fritz-cable-event-actions {
display: flex;
justify-content: flex-end;
}

.fritz-cable-event-link {
font-size: 0.85em;
color: var(--accent, #6366f1);
text-decoration: none;
background: transparent;
border: none;
padding: 0;
cursor: pointer;
font: inherit;
line-height: inherit;
}

.fritz-cable-event-link:hover {
text-decoration: underline;
}

.fritz-cable-event-link:focus-visible {
outline: 2px solid var(--accent, #6366f1);
outline-offset: 2px;
border-radius: 2px;
}

.fritz-cable-event-note {
font-size: 0.85em;
color: var(--muted, #888);
font-style: italic;
}

/* Responsive */
@media (max-width: 768px) {
.fritz-cable-kpis {
Expand All @@ -128,4 +264,7 @@
.fritz-cable-header {
flex-direction: column;
}
.fritz-cable-event-duration {
margin-left: 0;
}
}
Loading
Loading