From 80390334fb2dbd4371215605a219d0d35e5da654 Mon Sep 17 00:00:00 2001 From: Matthew Grotke Date: Sun, 24 May 2026 01:13:45 -0400 Subject: [PATCH] Development --- docker/routlin-dash/app/view_page.py | 21 ++++-- routlin/status.py | 99 ++++++++++++++++------------ 2 files changed, 75 insertions(+), 45 deletions(-) diff --git a/docker/routlin-dash/app/view_page.py b/docker/routlin-dash/app/view_page.py index 3dc16ac..a7f5e77 100644 --- a/docker/routlin-dash/app/view_page.py +++ b/docker/routlin-dash/app/view_page.py @@ -1495,12 +1495,25 @@ def render_layout(view_id, content_html, tokens): grouped.setdefault(sev, []).append(text) for item in st.get('services', []): if item.get('status') == 'problem': - parts = [] + name = item.get('name', '') + utype = 'timer' if name.endswith('.timer') else 'service' if name.endswith('.service') else 'unit' + exp_parts, act_parts, fix_parts = [], [], [] if not item.get('active_ok'): - parts.append(f"active: {item.get('active')} (expected {item.get('expected_active')})") + exp_parts.append(item.get('expected_active', 'active')) + act_parts.append(item.get('active', 'unknown')) + fix_parts.append('activate') if not item.get('enabled_ok'): - parts.append(f"enabled: {item.get('enabled')} (expected {item.get('expected_enabled')})") - grouped['error'].append(e(f"{item.get('name')}: {', '.join(parts)}")) + exp_parts.append(item.get('expected_enabled', 'enabled')) + act_parts.append(item.get('enabled', 'unknown')) + fix_parts.append('enable') + detail = (f"The {utype} `{name}` is expected to be " + f"{' and '.join(exp_parts)} but is {' and '.join(act_parts)}.") + tip = f"Run `sudo python3 core.py --apply` to {' and '.join(reversed(fix_parts))} it." + sev = item.get('severity', 'error') + text = e(detail) + if tip: + text += f' {e(tip)}' + grouped.setdefault(sev, []).append(text) for sev, items in grouped.items(): if not items: continue diff --git a/routlin/status.py b/routlin/status.py index 98d8244..7873262 100644 --- a/routlin/status.py +++ b/routlin/status.py @@ -152,49 +152,59 @@ def check_services(data): iface = derive_interface(vlan, data) name = _vlan_service_name(vlan, iface) units.append({"id": name, "name": name, - "expected_active": "active", "expected_enabled": "enabled"}) + "expected_active": "active", "expected_enabled": "enabled", + "severity": "error"}) units.append({"id": f"{BLIST_TIMER_NAME}.timer", "name": f"{BLIST_TIMER_NAME}.timer", - "expected_active": "active", "expected_enabled": "enabled"}) + "expected_active": "active", "expected_enabled": "enabled", + "severity": "warning"}) units.append({"id": NAT_SERVICE_NAME, "name": NAT_SERVICE_NAME, "expected_active": "inactive", - "expected_enabled": "enabled"}) + "expected_enabled": "enabled", + "severity": "error"}) units.append({"id": f"{STATUS_TIMER_NAME}.timer", "name": f"{STATUS_TIMER_NAME}.timer", - "expected_active": "active", "expected_enabled": "enabled"}) + "expected_active": "active", "expected_enabled": "enabled", + "severity": "warning"}) if DASHB_QUEUE_FILE.exists(): units.append({"id": f"{DASHB_TIMER_NAME}.timer", "name": f"{DASHB_TIMER_NAME}.timer", - "expected_active": "active", "expected_enabled": "enabled"}) + "expected_active": "active", "expected_enabled": "enabled", + "severity": "error"}) has_ddns = any(p.get("enabled") for p in data.get("ddns", {}).get("providers", [])) exp_ddns_active = "active" if has_ddns else "inactive" exp_ddns_enabled = "enabled" if has_ddns else "not-found" units.append({"id": f"{DDNS_TIMER_NAME}.timer", "name": f"{DDNS_TIMER_NAME}.timer", - "expected_active": exp_ddns_active, "expected_enabled": exp_ddns_enabled}) + "expected_active": exp_ddns_active, "expected_enabled": exp_ddns_enabled, + "severity": "warning"}) exp_fr_active = "active" if _radius_enabled(data) else "inactive" exp_fr_enabled = "enabled" if _radius_enabled(data) else "disabled" units.append({"id": "freeradius", "name": "freeradius", "expected_active": exp_fr_active, - "expected_enabled": exp_fr_enabled}) + "expected_enabled": exp_fr_enabled, + "severity": "error"}) exp_av_active = "active" if _avahi_enabled(data) else "inactive" exp_av_enabled = "enabled" if _avahi_enabled(data) else "disabled" units.append({"id": "avahi-daemon", "name": "avahi-daemon", "expected_active": exp_av_active, - "expected_enabled": exp_av_enabled}) + "expected_enabled": exp_av_enabled, + "severity": "warning"}) units.append({"id": "chrony", "name": "chrony", - "expected_active": "active", "expected_enabled": "enabled"}) + "expected_active": "active", "expected_enabled": "enabled", + "severity": "warning"}) units.append({"id": "systemd-networkd", "name": "systemd-networkd", - "expected_active": "active", "expected_enabled": "enabled"}) + "expected_active": "active", "expected_enabled": "enabled", + "severity": "error"}) for u in units: active, enabled = _sysctl_query(u["id"]) @@ -204,15 +214,16 @@ def check_services(data): enabled_ok = enabled == exp_enabled status = "ok" if (active_ok and enabled_ok) else "problem" results.append({ - "id": u["id"], - "name": u["name"], - "active": active, - "enabled": enabled, - "expected_active": exp_active, + "id": u["id"], + "name": u["name"], + "active": active, + "enabled": enabled, + "expected_active": exp_active, "expected_enabled": exp_enabled, - "active_ok": active_ok, - "enabled_ok": enabled_ok, - "status": status, + "active_ok": active_ok, + "enabled_ok": enabled_ok, + "severity": u.get("severity", "error"), + "status": status, }) return results @@ -235,7 +246,7 @@ def check_configurations(data): if not exists: return _problem(id_, name, severity, f"{path} does not exist.", - suggestion or f"Run sudo python3 core.py --apply to create it.") + suggestion or f"Run `sudo python3 core.py --apply` to create it.") return _ok(id_, name) # --- nftables tables --- @@ -253,7 +264,7 @@ def check_configurations(data): f"nftables table {tbl}", "error", f"nftables table '{tbl}' is missing.", - "Run sudo python3 core.py --apply to rebuild firewall rules.")) + "Run `sudo python3 core.py --apply` to rebuild firewall rules.")) except Exception: results.append(_problem("nft_tables", "nftables tables", "error", "Could not query nftables (nft not available or failed).")) @@ -275,7 +286,7 @@ def check_configurations(data): results.append(_problem( "nft_docker_bridges", "nftables Docker bridge rules", "warning", f"Container bridge(s) {', '.join(missing)} have no nftables forward rules.", - "Run sudo python3 core.py --apply to add the missing rules.")) + "Run `sudo python3 core.py --apply` to add the missing rules.")) else: results.append(_ok("nft_docker_bridges", "nftables Docker bridge rules")) except Exception: @@ -291,11 +302,11 @@ def check_configurations(data): if state is None: results.append(_problem(id_, name, "error", f"Interface {iface} does not exist in /sys/class/net/.", - "Run sudo python3 core.py --apply to configure network interfaces.")) + "Run `sudo python3 core.py --apply` to configure network interfaces.")) elif state != "up": results.append(_problem(id_, name, "error", f"Interface {iface} operstate is '{state}' (expected 'up').", - "Check systemd-networkd: sudo systemctl status systemd-networkd")) + "Check systemd-networkd: `sudo systemctl status systemd-networkd`")) else: results.append(_ok(id_, name)) @@ -308,7 +319,7 @@ def check_configurations(data): if state is None: results.append(_problem(id_, name, "error", f"WireGuard interface {iface} does not exist.", - "Run sudo python3 core.py --apply to bring up WireGuard.")) + "Run `sudo python3 core.py --apply` to bring up WireGuard.")) elif state in ("up", "unknown"): # WireGuard interfaces normally report 'unknown' results.append(_ok(id_, name)) else: @@ -399,7 +410,7 @@ def check_configurations(data): "radius_secret_match", "FreeRADIUS shared secret", "error", "clients.conf secret does not match .radius-secret. " "Access points will reject all authentication requests.", - "Restore .radius-secret from backup, or run sudo python3 core.py --apply " + "Restore .radius-secret from backup, or run `sudo python3 core.py --apply` " "then update the shared secret in your AP controller.")) except OSError: pass # already caught above by file_ok @@ -435,7 +446,7 @@ def check_configurations(data): "avahi_ifaces", "avahi-daemon interface list", "warning", f"avahi-daemon.conf interface list does not match config " f"(missing: {missing or 'none'}, extra: {extra or 'none'}).", - "Run sudo python3 core.py --apply to update.")) + "Run `sudo python3 core.py --apply` to update.")) else: results.append(_ok("avahi_ifaces", "avahi-daemon interface list")) @@ -458,11 +469,11 @@ def check_configurations(data): "resolv_conf", "/etc/resolv.conf", "warning", f"/etc/resolv.conf nameserver(s) {ns_ips} do not include any VLAN gateway. " f"Expected one of: {gateway_ips}.", - "Run sudo python3 core.py --apply to update /etc/resolv.conf.")) + "Run `sudo python3 core.py --apply` to update /etc/resolv.conf.")) except OSError: results.append(_problem("resolv_conf", "/etc/resolv.conf", "warning", "/etc/resolv.conf is not readable.", - "Run sudo python3 core.py --apply.")) + "Run `sudo python3 core.py --apply`.")) # --- chrony.conf --- if CHRONY_CONF_FILE.exists(): @@ -482,7 +493,7 @@ def check_configurations(data): results.append(_problem( "chrony_conf", "/etc/chrony/chrony.conf", "warning", f"chrony.conf is missing allow directives for: {', '.join(missing_subnets)}.", - "Run sudo python3 core.py --apply to update chrony.conf.")) + "Run `sudo python3 core.py --apply` to update chrony.conf.")) else: results.append(_ok("chrony_conf", "/etc/chrony/chrony.conf")) except OSError: @@ -522,7 +533,7 @@ def check_configurations(data): f"DHCP pool for VLAN '{vlan['name']}' is {pct}% full " f"({len(leases)}/{pool_size} leases).", "Expand the pool range in core.json or clean up stale leases " - "with: sudo python3 core.py --reset-leases " + vlan['name'])) + f"with: `sudo python3 core.py --reset-leases {vlan['name']}`")) else: results.append(_ok(f"dhcp_pool_{vlan['name']}", f"DHCP pool ({vlan['name']})", @@ -546,13 +557,13 @@ def check_configurations(data): results.append(_problem( f"blocklist_{h}", f"blocklist ({label})", "warning", f"Merged blocklist file for '{label}' does not exist.", - "Run sudo python3 core.py --update-blocklists to download blocklists.")) + "Run `sudo python3 core.py --update-blocklists` to download blocklists.")) elif now - path.stat().st_mtime > BLOCKLIST_STALE_SECS: age_h = int((now - path.stat().st_mtime) / 3600) results.append(_problem( f"blocklist_{h}", f"blocklist ({label})", "warning", f"Merged blocklist for '{label}' is {age_h}h old (threshold 36h).", - "Run sudo python3 core.py --update-blocklists to refresh.")) + "Run `sudo python3 core.py --update-blocklists` to refresh.")) else: results.append(_ok(f"blocklist_{h}", f"blocklist ({label})")) @@ -634,7 +645,7 @@ def check_logs(data): "FreeRADIUS auth failures", "error", f"FreeRADIUS is rejecting requests from {ap_str} with " f"'Shared secret is incorrect' ({len(failures)} failures in the last hour).", - "Restore .radius-secret from backup and run sudo python3 core.py --apply, " + "Restore .radius-secret from backup and run `sudo python3 core.py --apply`, " "or update the shared secret in your AP controller to match .radius-secret.")) else: results.append(_ok("freeradius_auth_failures", @@ -668,7 +679,7 @@ def check_logs(data): "dnsmasq_errors", "dnsmasq errors", "error", f"{len(err_lines)} dnsmasq error(s) in the last hour: " f"{err_lines[0][:120]}{'...' if len(err_lines) > 1 else ''}", - "Check dnsmasq logs: sudo journalctl -u 'dnsmasq-routlin-*' --since -1h")) + "Check dnsmasq logs: `sudo journalctl -u 'dnsmasq-routlin-*' --since -1h`")) else: results.append(_ok("dnsmasq_errors", "dnsmasq errors")) except Exception: @@ -742,15 +753,21 @@ def print_table(status): svc_problems = [] for svc in status.get("services", []): if svc.get("status") == "problem": - parts = [] + name = svc["name"] + utype = "timer" if name.endswith(".timer") else "service" if name.endswith(".service") else "unit" + exp_parts, act_parts, fix_parts = [], [], [] if not svc.get("active_ok"): - parts.append(f"active: {svc.get('active')} (expected {svc.get('expected_active')})") + exp_parts.append(svc.get("expected_active", "active")) + act_parts.append(svc.get("active", "unknown")) + fix_parts.append("activate") if not svc.get("enabled_ok"): - parts.append(f"enabled: {svc.get('enabled')} (expected {svc.get('expected_enabled')})") - svc_problems.append({ - "severity": "error", - "detail": f"{svc['name']}: {', '.join(parts)}", - }) + exp_parts.append(svc.get("expected_enabled", "enabled")) + act_parts.append(svc.get("enabled", "unknown")) + fix_parts.append("enable") + detail = (f"The {utype} `{name}` is expected to be " + f"{' and '.join(exp_parts)} but is {' and '.join(act_parts)}.") + suggestion = f"Run `sudo python3 core.py --apply` to {' and '.join(reversed(fix_parts))} it." + svc_problems.append({"severity": svc.get("severity", "error"), "detail": detail, "suggestion": suggestion}) problems = svc_problems + [ item for section in ("configurations", "logs")