Skip to content

Commit ee51f15

Browse files
itsDNNSclaude
andcommitted
Persistent sidebar, tariff display, and connection speed from FritzBox
Sidebar is now always visible with a collapse toggle instead of an overlay. Gear icon removed from topbar. Dashboard shows ISP name and max down/up speeds fetched from FritzBox netMoni API. Fixed ISP Other field alignment in settings. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 21b71e4 commit ee51f15

File tree

8 files changed

+118
-52
lines changed

8 files changed

+118
-52
lines changed

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,19 @@ All notable changes to this project will be documented in this file.
44

55
Versioning: `YYYY-MM-DD.N` (date + sequential build number per day)
66

7+
## [2026-02-09.9]
8+
9+
### Added
10+
- **Tariff display**: ISP name and max down/up speeds (from FritzBox) shown on dashboard
11+
- **Connection info API**: Fetches max downstream/upstream speeds from FritzBox `netMoni` page
12+
13+
### Changed
14+
- **Sidebar always visible**: Persistent left panel with collapse toggle instead of overlay
15+
- **Gear icon removed**: Settings accessible via sidebar only
16+
17+
### Fixed
18+
- **ISP "Other" field alignment**: Manual input field now properly aligned in grid layout
19+
720
## [2026-02-09.8]
821

922
### Changed

app/fritzbox.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,34 @@ def get_device_info(url: str, sid: str) -> dict:
9090
}
9191
except Exception:
9292
return {"model": "FRITZ!Box", "sw_version": ""}
93+
94+
95+
def get_connection_info(url: str, sid: str) -> dict:
96+
"""Get internet connection info (speeds, type) from netMoni page."""
97+
try:
98+
r = requests.post(
99+
f"{url}/data.lua",
100+
data={
101+
"xhr": 1,
102+
"sid": sid,
103+
"lang": "de",
104+
"page": "netMoni",
105+
"xhrId": "all",
106+
"no_sidrenew": "",
107+
},
108+
timeout=10,
109+
)
110+
r.raise_for_status()
111+
data = r.json().get("data", {})
112+
conns = data.get("connections", [])
113+
if not conns:
114+
return {}
115+
conn = conns[0]
116+
return {
117+
"max_downstream_kbps": conn.get("downstream", 0),
118+
"max_upstream_kbps": conn.get("upstream", 0),
119+
"connection_type": conn.get("medium", ""),
120+
}
121+
except Exception as e:
122+
log.warning("Failed to get connection info: %s", e)
123+
return {}

app/i18n.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,13 +119,16 @@
119119
# Language
120120
"language": "Language",
121121

122-
# ISP
122+
# ISP / Connection
123123
"isp_name": "Internet Provider",
124124
"isp_hint": "Shown in LLM export report",
125125
"isp_other": "Other",
126126
"isp_other_placeholder": "Enter provider name",
127127
"isp_select": "Select provider",
128128
"isp_options": ["Vodafone", "PYUR", "eazy", "O2", "1&1", "Telekom", "NetCologne", "M-net", "Wilhelm.tel"],
129+
"tariff": "Tariff",
130+
"max_downstream": "Max Downstream",
131+
"max_upstream": "Max Upstream",
129132

130133
# Export
131134
"export_llm": "Export for LLM",
@@ -248,6 +251,9 @@
248251
"isp_other_placeholder": "Anbietername eingeben",
249252
"isp_select": "Anbieter waehlen",
250253
"isp_options": ["Vodafone", "PYUR", "eazy", "O2", "1&1", "Telekom", "NetCologne", "M-net", "Wilhelm.tel"],
254+
"tariff": "Tarif",
255+
"max_downstream": "Max Downstream",
256+
"max_upstream": "Max Upstream",
251257

252258
"export_llm": "Export fuer LLM",
253259
"export_title": "LLM-Analyse Export",

app/main.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ def polling_loop(config_mgr, storage, stop_event):
5454

5555
sid = None
5656
device_info = None
57+
connection_info = None
5758
discovery_published = False
5859

5960
while not stop_event.is_set():
@@ -66,6 +67,14 @@ def polling_loop(config_mgr, storage, stop_event):
6667
device_info = fritzbox.get_device_info(config["fritz_url"], sid)
6768
log.info("FritzBox model: %s (%s)", device_info["model"], device_info["sw_version"])
6869

70+
if connection_info is None:
71+
connection_info = fritzbox.get_connection_info(config["fritz_url"], sid)
72+
if connection_info:
73+
ds = connection_info.get("max_downstream_kbps", 0) // 1000
74+
us = connection_info.get("max_upstream_kbps", 0) // 1000
75+
log.info("Connection: %d/%d Mbit/s (%s)", ds, us, connection_info.get("connection_type", ""))
76+
web.update_state(connection_info=connection_info)
77+
6978
data = fritzbox.get_docsis_data(config["fritz_url"], sid)
7079
analysis = analyzer.analyze(data)
7180

app/templates/index.html

Lines changed: 41 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -72,38 +72,32 @@
7272
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
7373
}
7474
.topbar-meta { font-size: 0.8em; color: var(--muted); white-space: nowrap; }
75-
.settings-link {
76-
color: var(--muted); text-decoration: none;
77-
font-size: 1.2em; padding: 4px; line-height: 1;
78-
}
79-
.settings-link:hover { color: var(--accent); }
80-
.settings-link svg { width: 20px; height: 20px; vertical-align: middle; }
75+
76+
/* ── Layout ── */
77+
.app-layout { display: flex; min-height: 100vh; }
8178

8279
/* ── Sidebar ── */
83-
.sidebar-overlay {
84-
position: fixed; inset: 0; background: var(--overlay);
85-
z-index: 90; opacity: 0; pointer-events: none; transition: opacity 0.25s;
86-
}
87-
.sidebar-overlay.open { opacity: 1; pointer-events: auto; }
8880
.sidebar {
89-
position: fixed; top: 0; left: 0; bottom: 0; width: 260px;
90-
background: var(--surface); z-index: 100;
91-
transform: translateX(-100%); transition: transform 0.25s ease;
81+
width: 240px; min-width: 240px;
82+
background: var(--surface);
9283
display: flex; flex-direction: column;
9384
border-right: 1px solid var(--input-border);
85+
transition: width 0.25s ease, min-width 0.25s ease;
86+
overflow: hidden;
9487
}
95-
.sidebar.open { transform: translateX(0); }
88+
.sidebar.collapsed { width: 0; min-width: 0; }
9689
.sidebar-content { flex: 1; overflow-y: auto; }
9790
.sidebar-header {
9891
display: flex; justify-content: space-between; align-items: center;
99-
padding: 16px 20px; border-bottom: 1px solid var(--input-border);
100-
font-weight: bold; color: var(--accent);
92+
padding: 14px 20px; border-bottom: 1px solid var(--input-border);
93+
font-weight: bold; color: var(--accent); white-space: nowrap;
10194
}
102-
.sidebar-close {
95+
.sidebar-toggle {
10396
background: none; border: none; color: var(--muted);
104-
font-size: 1.4em; cursor: pointer; line-height: 1;
97+
font-size: 1.2em; cursor: pointer; line-height: 1; padding: 2px 4px;
10598
}
106-
.sidebar-close:hover { color: var(--text); }
99+
.sidebar-toggle:hover { color: var(--accent); }
100+
.app-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
107101
.sidebar-link {
108102
display: flex; align-items: center; gap: 10px;
109103
padding: 12px 20px; color: var(--text); text-decoration: none;
@@ -309,7 +303,11 @@
309303
.btn-muted:hover { background: var(--accent); color: #000; }
310304

311305
/* ── Responsive ── */
312-
@media (max-width: 900px) { .charts-grid { grid-template-columns: 1fr; } }
306+
@media (max-width: 900px) {
307+
.charts-grid { grid-template-columns: 1fr; }
308+
.sidebar { width: 0; min-width: 0; }
309+
.sidebar.collapsed { width: 0; min-width: 0; }
310+
}
313311
@media (max-width: 600px) {
314312
body { font-size: 14px; }
315313
.topbar { gap: 6px; padding: 8px 10px; }
@@ -322,15 +320,13 @@
322320
</style>
323321
</head>
324322
<body>
325-
326-
<!-- Sidebar Overlay -->
327-
<div class="sidebar-overlay" id="sidebar-overlay"></div>
323+
<div class="app-layout">
328324

329325
<!-- Sidebar -->
330326
<nav class="sidebar" id="sidebar">
331327
<div class="sidebar-header">
332-
<span>{{ t.nav }}</span>
333-
<button class="sidebar-close" id="sidebar-close">&times;</button>
328+
<span>DOCSIS Monitor</span>
329+
<button class="sidebar-toggle" id="sidebar-collapse" title="Collapse">&#10094;</button>
334330
</div>
335331
<div class="sidebar-content">
336332
<a class="sidebar-link active" data-view="live">
@@ -368,9 +364,12 @@
368364
</div>
369365
</nav>
370366

367+
<!-- Main Area -->
368+
<div class="app-main">
369+
371370
<!-- Topbar -->
372371
<div class="topbar">
373-
<button class="hamburger-btn" id="hamburger">&#9776;</button>
372+
<button class="hamburger-btn" id="hamburger" title="Menu">&#9776;</button>
374373
<div class="date-nav" id="date-nav">
375374
<button id="date-prev">&#8249;</button>
376375
<span class="date-label" id="date-label" title="{{ t.open_calendar }}">{{ t.live }}</span>
@@ -380,12 +379,6 @@
380379
<span class="topbar-meta" id="topbar-meta">
381380
{% if last_update %}{{ t.last_update }}: {{ last_update }} | {{ poll_interval }}s{% endif %}
382381
</span>
383-
<a href="/settings" class="settings-link" title="{{ t.settings }}">
384-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
385-
<circle cx="12" cy="12" r="3"></circle>
386-
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
387-
</svg>
388-
</a>
389382
</div>
390383

391384
<!-- Calendar Popup -->
@@ -433,6 +426,12 @@
433426
{% endif %}
434427

435428
<div class="summary-grid">
429+
{% if isp_name or connection_info %}
430+
<div class="summary-card">
431+
<div class="label">{{ t.tariff }}</div>
432+
<div class="value">{% if isp_name %}{{ isp_name }}{% endif %}{% if connection_info.max_downstream_kbps %} {{ connection_info.max_downstream_kbps // 1000 }}/{{ connection_info.max_upstream_kbps // 1000 }} Mbit/s{% endif %}</div>
433+
</div>
434+
{% endif %}
436435
<div class="summary-card">
437436
<div class="label">{{ t.ds_channels }}</div>
438437
<div class="value">{{ s.ds_total }}</div>
@@ -553,6 +552,8 @@ <h2 class="section-title">{{ t.upstream }} ({{ us|length }} {{ t.channels }})</h
553552
</div>
554553
</div>
555554
</div><!-- /main-content -->
555+
</div><!-- /app-main -->
556+
</div><!-- /app-layout -->
556557

557558
<!-- Export Modal -->
558559
<div class="modal-overlay" id="export-modal">
@@ -641,21 +642,19 @@ <h2>{{ t.export_title }}</h2>
641642

642643
/* ── Sidebar ── */
643644
var sidebar = document.getElementById('sidebar');
644-
var overlay = document.getElementById('sidebar-overlay');
645645

646-
function toggleSidebar() {
647-
sidebar.classList.toggle('open');
648-
overlay.classList.toggle('open');
649-
}
650-
document.getElementById('hamburger').addEventListener('click', toggleSidebar);
651-
document.getElementById('sidebar-close').addEventListener('click', toggleSidebar);
652-
overlay.addEventListener('click', toggleSidebar);
646+
function collapseSidebar() { sidebar.classList.add('collapsed'); }
647+
function expandSidebar() { sidebar.classList.remove('collapsed'); }
648+
649+
document.getElementById('sidebar-collapse').addEventListener('click', collapseSidebar);
650+
document.getElementById('hamburger').addEventListener('click', function() {
651+
sidebar.classList.toggle('collapsed');
652+
});
653653

654654
var sidebarLinks = document.querySelectorAll('.sidebar-link[data-view]');
655655
sidebarLinks.forEach(function(link) {
656656
link.addEventListener('click', function() {
657657
switchView(this.getAttribute('data-view'));
658-
toggleSidebar();
659658
});
660659
});
661660

@@ -903,10 +902,6 @@ <h2>{{ t.export_title }}</h2>
903902
var T = {{ t|tojson }};
904903
var modal = document.getElementById('export-modal');
905904
var textarea = document.getElementById('export-text');
906-
var sidebar = document.getElementById('sidebar');
907-
var overlay = document.getElementById('sidebar-overlay');
908-
sidebar.classList.remove('open');
909-
overlay.classList.remove('open');
910905
textarea.value = T.export_no_data;
911906
modal.classList.add('open');
912907
fetch('/api/export')

app/templates/settings.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,7 @@ <h2>{{ t.general }}</h2>
220220
</select>
221221
<span class="hint">{{ t.isp_hint }}</span>
222222
</div>
223-
<div class="form-row" id="isp-other-row" style="display:{% if config.isp_name and config.isp_name not in t.isp_options %}block{% else %}none{% endif %}">
223+
<div class="form-row" id="isp-other-row" style="display:{% if config.isp_name and config.isp_name not in t.isp_options %}flex{% else %}none{% endif %}">
224224
<label for="isp_other_input">{{ t.isp_other }}</label>
225225
<input type="text" id="isp_other_input" value="{% if config.isp_name and config.isp_name not in t.isp_options %}{{ config.isp_name }}{% endif %}" placeholder="{{ t.isp_other_placeholder }}">
226226
</div>
@@ -274,7 +274,7 @@ <h2>{{ t.general }}</h2>
274274
function onIspChange() {
275275
var sel = document.getElementById('isp_select');
276276
var row = document.getElementById('isp-other-row');
277-
row.style.display = sel.value === '__other__' ? 'block' : 'none';
277+
row.style.display = sel.value === '__other__' ? 'flex' : 'none';
278278
}
279279

280280
function showToast(msg, ok) {

app/templates/setup.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ <h2><span class="step-num">2</span>{{ t.general }}</h2>
230230
</select>
231231
<span class="hint">{{ t.isp_hint }}</span>
232232
</div>
233-
<div class="form-row" id="isp-other-row" style="display:{% if config.isp_name and config.isp_name not in t.isp_options %}block{% else %}none{% endif %}">
233+
<div class="form-row" id="isp-other-row" style="display:{% if config.isp_name and config.isp_name not in t.isp_options %}flex{% else %}none{% endif %}">
234234
<label for="isp_other_input">{{ t.isp_other }}</label>
235235
<input type="text" id="isp_other_input" value="{% if config.isp_name and config.isp_name not in t.isp_options %}{{ config.isp_name }}{% endif %}" placeholder="{{ t.isp_other_placeholder }}">
236236
</div>
@@ -301,7 +301,7 @@ <h2>{{ t.mqtt_broker }}</h2>
301301
function onIspChange() {
302302
var sel = document.getElementById('isp_select');
303303
var row = document.getElementById('isp-other-row');
304-
row.style.display = sel.value === '__other__' ? 'block' : 'none';
304+
row.style.display = sel.value === '__other__' ? 'flex' : 'none';
305305
}
306306

307307
function toggleAdvanced() {

app/web.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ def _get_lang():
4646
"last_update": None,
4747
"poll_interval": 300,
4848
"error": None,
49+
"connection_info": None,
4950
}
5051

5152
_storage = None
@@ -66,7 +67,7 @@ def init_config(config_manager, on_config_changed=None):
6667
_on_config_changed = on_config_changed
6768

6869

69-
def update_state(analysis=None, error=None, poll_interval=None):
70+
def update_state(analysis=None, error=None, poll_interval=None, connection_info=None):
7071
"""Update the shared web state from the main loop."""
7172
if analysis is not None:
7273
_state["analysis"] = analysis
@@ -76,6 +77,8 @@ def update_state(analysis=None, error=None, poll_interval=None):
7677
_state["error"] = str(error)
7778
if poll_interval is not None:
7879
_state["poll_interval"] = poll_interval
80+
if connection_info is not None:
81+
_state["connection_info"] = connection_info
7982

8083

8184
@app.route("/")
@@ -87,6 +90,9 @@ def index():
8790
lang = _get_lang()
8891
t = get_translations(lang)
8992

93+
isp_name = _config_manager.get("isp_name", "") if _config_manager else ""
94+
conn_info = _state.get("connection_info") or {}
95+
9096
ts = request.args.get("t")
9197
if ts and _storage:
9298
snapshot = _storage.get_snapshot(ts)
@@ -100,6 +106,7 @@ def index():
100106
historical=True,
101107
snapshot_ts=ts,
102108
theme=theme,
109+
isp_name=isp_name, connection_info=conn_info,
103110
t=t, lang=lang, languages=LANGUAGES,
104111
)
105112
return render_template(
@@ -111,6 +118,7 @@ def index():
111118
historical=False,
112119
snapshot_ts=None,
113120
theme=theme,
121+
isp_name=isp_name, connection_info=conn_info,
114122
t=t, lang=lang, languages=LANGUAGES,
115123
)
116124

@@ -267,6 +275,9 @@ def api_export():
267275
ts = _state.get("last_update", "unknown")
268276

269277
isp = _config_manager.get("isp_name", "") if _config_manager else ""
278+
conn = _state.get("connection_info") or {}
279+
ds_mbps = conn.get("max_downstream_kbps", 0) // 1000 if conn else 0
280+
us_mbps = conn.get("max_upstream_kbps", 0) // 1000 if conn else 0
270281

271282
lines = [
272283
"# DOCSIS Cable Connection – Status Report",
@@ -278,6 +289,7 @@ def api_export():
278289
"",
279290
"## Overview",
280291
f"- **ISP**: {isp}" if isp else None,
292+
f"- **Tariff**: {ds_mbps}/{us_mbps} Mbit/s (Down/Up)" if ds_mbps else None,
281293
f"- **Health**: {s.get('health', 'Unknown')}",
282294
f"- **Details**: {s.get('health_details', '')}",
283295
f"- **Timestamp**: {ts}",

0 commit comments

Comments
 (0)