mirror of
https://github.com/Horizontal42/netsleuth.git
synced 2026-11-10 15:42:10 +03:00
compare: diff identity, latency, speed and findings between two reports
This commit is contained in:
parent
335ff0975e
commit
4e4956ceeb
4 changed files with 408 additions and 0 deletions
140
src/netcheck/compare.py
Normal file
140
src/netcheck/compare.py
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from netcheck.exporter import badge
|
||||
|
||||
|
||||
@dataclass
|
||||
class Change:
|
||||
label: str
|
||||
before: Any = None
|
||||
after: Any = None
|
||||
delta: float | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReportDiff:
|
||||
identity: list[Change] = field(default_factory=list)
|
||||
latency: list[Change] = field(default_factory=list)
|
||||
speed: list[Change] = field(default_factory=list)
|
||||
new_findings: list[dict] = field(default_factory=list)
|
||||
resolved_findings: list[dict] = field(default_factory=list)
|
||||
|
||||
|
||||
def load_report(path: Path) -> dict:
|
||||
return json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _data(report: dict, section: str) -> Any:
|
||||
return (report.get(section) or {}).get("data")
|
||||
|
||||
|
||||
def _geo(report: dict) -> dict:
|
||||
bundle = _data(report, "ip_geo") or {}
|
||||
return (bundle.get("egress_v4") or {}) if isinstance(bundle, dict) else {}
|
||||
|
||||
|
||||
def _delta(before: Any, after: Any) -> float | None:
|
||||
if isinstance(before, (int, float)) and isinstance(after, (int, float)):
|
||||
return round(float(after) - float(before), 3)
|
||||
return None
|
||||
|
||||
|
||||
def identity_changes(before: dict, after: dict) -> list[Change]:
|
||||
a, b = _geo(before), _geo(after)
|
||||
vpn_a = _data(before, "vpn_assessment") or {}
|
||||
vpn_b = _data(after, "vpn_assessment") or {}
|
||||
pairs = [
|
||||
("Egress IP", a.get("ip"), b.get("ip")),
|
||||
("ASN", a.get("asn"), b.get("asn")),
|
||||
("Organisation", a.get("as_name") or a.get("org"), b.get("as_name") or b.get("org")),
|
||||
("Country", a.get("country_code"), b.get("country_code")),
|
||||
("Address type", a.get("ip_type"), b.get("ip_type")),
|
||||
("VPN verdict", vpn_a.get("verdict"), vpn_b.get("verdict")),
|
||||
("VPN confidence", vpn_a.get("confidence"), vpn_b.get("confidence")),
|
||||
]
|
||||
return [Change(label, x, y, _delta(x, y)) for label, x, y in pairs if x != y]
|
||||
|
||||
|
||||
def latency_changes(before: dict, after: dict) -> list[Change]:
|
||||
a = {p.get("label"): p for p in _data(before, "latency") or []}
|
||||
b = {p.get("label"): p for p in _data(after, "latency") or []}
|
||||
changes: list[Change] = []
|
||||
for label in sorted(set(a) | set(b)):
|
||||
pa, pb = a.get(label) or {}, b.get(label) or {}
|
||||
for metric in ("avg_ms", "jitter_ms", "loss_pct"):
|
||||
x, y = pa.get(metric), pb.get(metric)
|
||||
if x != y:
|
||||
changes.append(Change(f"{label} {metric}", x, y, _delta(x, y)))
|
||||
return changes
|
||||
|
||||
|
||||
def speed_changes(before: dict, after: dict) -> list[Change]:
|
||||
a, b = _data(before, "speed") or {}, _data(after, "speed") or {}
|
||||
fields = (
|
||||
("Speedtest method", "method"),
|
||||
("Download Mbps", "download_mbps"),
|
||||
("Upload Mbps", "upload_mbps"),
|
||||
("Bufferbloat down ms", "bufferbloat_down_ms"),
|
||||
("Bufferbloat up ms", "bufferbloat_up_ms"),
|
||||
("Bufferbloat grade", "bufferbloat_grade"),
|
||||
)
|
||||
return [
|
||||
Change(label, a.get(key), b.get(key), _delta(a.get(key), b.get(key)))
|
||||
for label, key in fields
|
||||
if a.get(key) != b.get(key)
|
||||
]
|
||||
|
||||
|
||||
def finding_changes(before: dict, after: dict) -> tuple[list[dict], list[dict]]:
|
||||
a = {f.get("id"): f for f in (before.get("interpretation") or {}).get("findings") or []}
|
||||
b = {f.get("id"): f for f in (after.get("interpretation") or {}).get("findings") or []}
|
||||
return [b[i] for i in b if i not in a], [a[i] for i in a if i not in b]
|
||||
|
||||
|
||||
def diff_reports(before: dict, after: dict) -> ReportDiff:
|
||||
new, resolved = finding_changes(before, after)
|
||||
return ReportDiff(
|
||||
identity=identity_changes(before, after),
|
||||
latency=latency_changes(before, after),
|
||||
speed=speed_changes(before, after),
|
||||
new_findings=new,
|
||||
resolved_findings=resolved,
|
||||
)
|
||||
|
||||
|
||||
def _fmt(value: Any) -> str:
|
||||
if value is None:
|
||||
return "—"
|
||||
return f"{value:g}" if isinstance(value, (int, float)) else str(value)
|
||||
|
||||
|
||||
def _block(title: str, changes: list[Change]) -> list[str]:
|
||||
if not changes:
|
||||
return [f"### {title}", "", "No change.", ""]
|
||||
return (
|
||||
[f"### {title}", "", "| Field | Before | After | Δ |", "|---|---|---|---|"]
|
||||
+ [f"| {c.label} | {_fmt(c.before)} | {_fmt(c.after)} | {_fmt(c.delta)} |" for c in changes]
|
||||
+ [""]
|
||||
)
|
||||
|
||||
|
||||
def render_diff(diff: ReportDiff, emoji: bool = True) -> str:
|
||||
lines = ["# netcheck compare", ""]
|
||||
lines += _block("Identity", diff.identity)
|
||||
lines += _block("Latency", diff.latency)
|
||||
lines += _block("Speed", diff.speed)
|
||||
lines += ["### Findings", ""]
|
||||
if not diff.new_findings and not diff.resolved_findings:
|
||||
lines += ["No findings appeared or cleared.", ""]
|
||||
else:
|
||||
lines += [
|
||||
f"- new: {badge(f.get('severity', 'info'), emoji)} {f.get('title', '')}" for f in diff.new_findings
|
||||
]
|
||||
lines += [f"- resolved: {badge('ok', emoji)} {f.get('title', '')}" for f in diff.resolved_findings]
|
||||
lines += [""]
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
80
tests/fixtures/reports/after.json
vendored
Normal file
80
tests/fixtures/reports/after.json
vendored
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
{
|
||||
"schema_version": 1,
|
||||
"meta": { "run_id": "bbbb2222", "started_at": "2026-08-08T21:40:00Z", "mode": "auto", "target": null },
|
||||
"connection": { "name": "connection", "status": "ok", "data": { "iface_name": "wg0" }, "errors": [], "warnings": [], "duration_ms": 41 },
|
||||
"ip_geo": {
|
||||
"name": "ip_geo",
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"egress_v4": {
|
||||
"ip": "198.51.100.7",
|
||||
"asn": "AS64777",
|
||||
"as_name": "Example Hosting",
|
||||
"org": "Example Hosting BV",
|
||||
"country_code": "DE",
|
||||
"ip_type": "hosting"
|
||||
},
|
||||
"egress_v6": null,
|
||||
"cf_trace": null,
|
||||
"dual_stack_note": null
|
||||
},
|
||||
"errors": [],
|
||||
"warnings": [],
|
||||
"duration_ms": 950
|
||||
},
|
||||
"vpn_assessment": {
|
||||
"name": "vpn_assessment",
|
||||
"status": "ok",
|
||||
"data": { "verdict": "confirmed", "confidence": 0.75, "signals": [], "tunnel_iface": "wg0", "dns_leak": null },
|
||||
"errors": [],
|
||||
"warnings": [],
|
||||
"duration_ms": 310
|
||||
},
|
||||
"bgp": { "name": "bgp", "status": "ok", "data": { "asn": "AS64777", "holder": "Example Hosting BV" }, "errors": [], "warnings": [], "duration_ms": 1300 },
|
||||
"reputation": { "name": "reputation", "status": "ok", "data": { "captcha_risk": "medium", "firehol_hits": [] }, "errors": [], "warnings": [], "duration_ms": 820 },
|
||||
"latency": {
|
||||
"name": "latency",
|
||||
"status": "ok",
|
||||
"data": [
|
||||
{ "label": "cloudflare-dns", "host": "1.1.1.1", "method": "icmp_win", "avg_ms": 46.9, "jitter_ms": 1.9, "loss_pct": 0.0 },
|
||||
{ "label": "quad9-dns", "host": "9.9.9.9", "method": "icmp_win", "avg_ms": 48.0, "jitter_ms": 3.0, "loss_pct": 1.0 }
|
||||
],
|
||||
"errors": [],
|
||||
"warnings": [],
|
||||
"duration_ms": 5400
|
||||
},
|
||||
"path": { "name": "path", "status": "ok", "data": [], "errors": [], "warnings": [], "duration_ms": 4100 },
|
||||
"speed": {
|
||||
"name": "speed",
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"method": "cloudflare",
|
||||
"download_mbps": 96.1,
|
||||
"upload_mbps": 38.0,
|
||||
"bufferbloat_down_ms": 74.0,
|
||||
"bufferbloat_grade": "D"
|
||||
},
|
||||
"errors": [],
|
||||
"warnings": [],
|
||||
"duration_ms": 31000
|
||||
},
|
||||
"interpretation": {
|
||||
"overall_status": "crit",
|
||||
"overall_score": 75,
|
||||
"summary_text": "Bufferbloat under load (downstream): grade D",
|
||||
"findings": [
|
||||
{
|
||||
"id": "speed.bufferbloat_down",
|
||||
"severity": "crit",
|
||||
"title": "Bufferbloat under load (downstream): grade D",
|
||||
"detail": "Latency rose by 74.0 ms while saturating the downstream direction.",
|
||||
"metric": "bufferbloat_down_ms",
|
||||
"value": "D",
|
||||
"threshold": 60.0,
|
||||
"advice": "Enabling SQM/fq_codel on the router is the standard fix."
|
||||
}
|
||||
]
|
||||
},
|
||||
"errors": [],
|
||||
"raw": {}
|
||||
}
|
||||
69
tests/fixtures/reports/before.json
vendored
Normal file
69
tests/fixtures/reports/before.json
vendored
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
{
|
||||
"schema_version": 1,
|
||||
"meta": { "run_id": "aaaa1111", "started_at": "2026-08-08T19:12:00Z", "mode": "auto", "target": null },
|
||||
"connection": { "name": "connection", "status": "ok", "data": { "iface_name": "Wi-Fi 2" }, "errors": [], "warnings": [], "duration_ms": 40 },
|
||||
"ip_geo": {
|
||||
"name": "ip_geo",
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"egress_v4": {
|
||||
"ip": "203.0.113.44",
|
||||
"asn": "AS64500",
|
||||
"as_name": "Example Telecom",
|
||||
"org": "Example Telecom BV",
|
||||
"country_code": "NL",
|
||||
"ip_type": "residential"
|
||||
},
|
||||
"egress_v6": null,
|
||||
"cf_trace": null,
|
||||
"dual_stack_note": null
|
||||
},
|
||||
"errors": [],
|
||||
"warnings": [],
|
||||
"duration_ms": 900
|
||||
},
|
||||
"vpn_assessment": {
|
||||
"name": "vpn_assessment",
|
||||
"status": "ok",
|
||||
"data": { "verdict": "none", "confidence": 0.1, "signals": [], "tunnel_iface": null, "dns_leak": null },
|
||||
"errors": [],
|
||||
"warnings": [],
|
||||
"duration_ms": 300
|
||||
},
|
||||
"bgp": { "name": "bgp", "status": "ok", "data": { "asn": "AS64500", "holder": "Example Telecom BV" }, "errors": [], "warnings": [], "duration_ms": 1200 },
|
||||
"reputation": { "name": "reputation", "status": "ok", "data": { "captcha_risk": "low", "firehol_hits": [] }, "errors": [], "warnings": [], "duration_ms": 800 },
|
||||
"latency": {
|
||||
"name": "latency",
|
||||
"status": "ok",
|
||||
"data": [
|
||||
{ "label": "cloudflare-dns", "host": "1.1.1.1", "method": "icmp_win", "avg_ms": 12.4, "jitter_ms": 1.9, "loss_pct": 0.0 },
|
||||
{ "label": "google-dns", "host": "8.8.8.8", "method": "icmp_win", "avg_ms": 14.0, "jitter_ms": 2.0, "loss_pct": 0.0 }
|
||||
],
|
||||
"errors": [],
|
||||
"warnings": [],
|
||||
"duration_ms": 5200
|
||||
},
|
||||
"path": { "name": "path", "status": "ok", "data": [], "errors": [], "warnings": [], "duration_ms": 4000 },
|
||||
"speed": {
|
||||
"name": "speed",
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"method": "cloudflare",
|
||||
"download_mbps": 284.3,
|
||||
"upload_mbps": 41.7,
|
||||
"bufferbloat_down_ms": 12.0,
|
||||
"bufferbloat_grade": "B"
|
||||
},
|
||||
"errors": [],
|
||||
"warnings": [],
|
||||
"duration_ms": 30000
|
||||
},
|
||||
"interpretation": {
|
||||
"overall_status": "ok",
|
||||
"overall_score": 100,
|
||||
"summary_text": "No problems found on this connection.",
|
||||
"findings": []
|
||||
},
|
||||
"errors": [],
|
||||
"raw": {}
|
||||
}
|
||||
119
tests/test_compare.py
Normal file
119
tests/test_compare.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from netcheck.compare import (
|
||||
Change,
|
||||
diff_reports,
|
||||
finding_changes,
|
||||
identity_changes,
|
||||
latency_changes,
|
||||
load_report,
|
||||
render_diff,
|
||||
speed_changes,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def before(fixtures_dir):
|
||||
return load_report(fixtures_dir / "reports" / "before.json")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def after(fixtures_dir):
|
||||
return load_report(fixtures_dir / "reports" / "after.json")
|
||||
|
||||
|
||||
def by_label(changes: list[Change]) -> dict[str, Change]:
|
||||
return {c.label: c for c in changes}
|
||||
|
||||
|
||||
def test_load_report_reads_a_saved_report(before):
|
||||
assert before["schema_version"] == 1
|
||||
assert before["meta"]["run_id"] == "aaaa1111"
|
||||
|
||||
|
||||
def test_identity_reports_the_egress_and_asn_change(before, after):
|
||||
changes = by_label(identity_changes(before, after))
|
||||
assert changes["Egress IP"].before == "203.0.113.44"
|
||||
assert changes["Egress IP"].after == "198.51.100.7"
|
||||
assert changes["ASN"].before == "AS64500"
|
||||
assert changes["ASN"].after == "AS64777"
|
||||
assert changes["Country"].after == "DE"
|
||||
assert changes["Address type"].after == "hosting"
|
||||
|
||||
|
||||
def test_identity_reports_the_vpn_verdict_and_confidence_delta(before, after):
|
||||
changes = by_label(identity_changes(before, after))
|
||||
assert changes["VPN verdict"].before == "none"
|
||||
assert changes["VPN verdict"].after == "confirmed"
|
||||
assert changes["VPN confidence"].delta == pytest.approx(0.65)
|
||||
|
||||
|
||||
def test_identity_of_a_report_against_itself_is_empty(before):
|
||||
assert identity_changes(before, before) == []
|
||||
|
||||
|
||||
def test_latency_deltas_are_signed_per_host_and_metric(before, after):
|
||||
changes = by_label(latency_changes(before, after))
|
||||
assert changes["cloudflare-dns avg_ms"].delta == pytest.approx(34.5)
|
||||
assert changes["cloudflare-dns avg_ms"].before == 12.4
|
||||
assert "cloudflare-dns jitter_ms" not in changes
|
||||
|
||||
|
||||
def test_a_host_present_in_only_one_report_is_still_reported(before, after):
|
||||
changes = by_label(latency_changes(before, after))
|
||||
assert changes["google-dns avg_ms"].after is None
|
||||
assert changes["quad9-dns avg_ms"].before is None
|
||||
assert changes["quad9-dns avg_ms"].delta is None
|
||||
|
||||
|
||||
def test_latency_of_a_report_against_itself_is_empty(before):
|
||||
assert latency_changes(before, before) == []
|
||||
|
||||
|
||||
def test_speed_deltas_cover_throughput_and_the_bufferbloat_grade(before, after):
|
||||
changes = by_label(speed_changes(before, after))
|
||||
assert changes["Download Mbps"].delta == pytest.approx(-188.2)
|
||||
assert changes["Upload Mbps"].delta == pytest.approx(-3.7)
|
||||
assert changes["Bufferbloat grade"].before == "B"
|
||||
assert changes["Bufferbloat grade"].after == "D"
|
||||
assert "Speedtest method" not in changes
|
||||
|
||||
|
||||
def test_findings_are_split_into_new_and_resolved(before, after):
|
||||
new, resolved = finding_changes(before, after)
|
||||
assert [f["id"] for f in new] == ["speed.bufferbloat_down"]
|
||||
assert resolved == []
|
||||
new, resolved = finding_changes(after, before)
|
||||
assert new == []
|
||||
assert [f["id"] for f in resolved] == ["speed.bufferbloat_down"]
|
||||
|
||||
|
||||
def test_diff_reports_assembles_every_part(before, after):
|
||||
diff = diff_reports(before, after)
|
||||
assert diff.identity
|
||||
assert diff.latency
|
||||
assert diff.speed
|
||||
assert len(diff.new_findings) == 1
|
||||
assert diff.resolved_findings == []
|
||||
|
||||
|
||||
def test_render_diff_shows_before_and_after_values(before, after):
|
||||
text = render_diff(diff_reports(before, after))
|
||||
assert text.startswith("# netcheck compare")
|
||||
assert "203.0.113.44" in text
|
||||
assert "198.51.100.7" in text
|
||||
assert "-188.2" in text
|
||||
assert "Bufferbloat under load" in text
|
||||
|
||||
|
||||
def test_render_diff_of_two_identical_reports_says_so(before):
|
||||
text = render_diff(diff_reports(before, before))
|
||||
assert text.count("No change.") == 3
|
||||
assert "No findings appeared or cleared." in text
|
||||
|
||||
|
||||
def test_render_diff_honours_the_emoji_setting(before, after):
|
||||
assert "🔴" in render_diff(diff_reports(before, after), emoji=True)
|
||||
assert "[crit]" in render_diff(diff_reports(before, after), emoji=False)
|
||||
Loading…
Add table
Reference in a new issue