mirror of
https://github.com/Horizontal42/netsleuth.git
synced 2026-11-10 20:22:03 +03:00
interpret: russian title/detail/advice alongside english findings
This commit is contained in:
parent
04cf37ef7e
commit
db407595d6
2 changed files with 148 additions and 3 deletions
|
|
@ -54,6 +54,9 @@ def latency_findings(pings: list[PingResult], t: Thresholds) -> list[Finding]:
|
||||||
value=0,
|
value=0,
|
||||||
threshold=1,
|
threshold=1,
|
||||||
advice="If every reference host is unreachable the link is down or filtering the probe method.",
|
advice="If every reference host is unreachable the link is down or filtering the probe method.",
|
||||||
|
title_ru=f"{p.host} не отвечает",
|
||||||
|
detail_ru=f"{p.sent} проб отправлено через {p.method}, ни одна не вернулась.",
|
||||||
|
advice_ru="Если все эталонные хосты недоступны, значит канал не работает или фильтрует метод пробы.",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
@ -69,6 +72,9 @@ def latency_findings(pings: list[PingResult], t: Thresholds) -> list[Finding]:
|
||||||
value=p.avg_ms,
|
value=p.avg_ms,
|
||||||
threshold=t.latency_ms.warn,
|
threshold=t.latency_ms.warn,
|
||||||
advice="Check for a saturated uplink, a distant route, or a congested Wi-Fi link.",
|
advice="Check for a saturated uplink, a distant route, or a congested Wi-Fi link.",
|
||||||
|
title_ru=f"Задержка до {p.host} выше целевой",
|
||||||
|
detail_ru=f"Средняя {p.avg_ms} мс за {p.received} проб через {p.method}.",
|
||||||
|
advice_ru="Проверьте, не насыщен ли аплинк, не слишком ли далёкий маршрут или не перегружен ли Wi-Fi.",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
severity = severity_for(p.jitter_ms, t.jitter_ms)
|
severity = severity_for(p.jitter_ms, t.jitter_ms)
|
||||||
|
|
@ -83,11 +89,15 @@ def latency_findings(pings: list[PingResult], t: Thresholds) -> list[Finding]:
|
||||||
value=p.jitter_ms,
|
value=p.jitter_ms,
|
||||||
threshold=t.jitter_ms.warn,
|
threshold=t.jitter_ms.warn,
|
||||||
advice="Unstable latency hurts calls and games more than raw latency does.",
|
advice="Unstable latency hurts calls and games more than raw latency does.",
|
||||||
|
title_ru=f"Джиттер до {p.host} выше целевого",
|
||||||
|
detail_ru=f"Джиттер {p.jitter_ms} мс за {p.received} проб через {p.method}.",
|
||||||
|
advice_ru="Нестабильная задержка вредит звонкам и играм сильнее, чем сама по себе высокая задержка.",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
severity = severity_for(p.loss_pct, t.loss_pct)
|
severity = severity_for(p.loss_pct, t.loss_pct)
|
||||||
if severity not in ("ok", "info"):
|
if severity not in ("ok", "info"):
|
||||||
kind = "connection failures" if p.method == "tcp" else "packet loss"
|
kind = "connection failures" if p.method == "tcp" else "packet loss"
|
||||||
|
kind_ru = "неудачных подключений" if p.method == "tcp" else "потерь пакетов"
|
||||||
findings.append(
|
findings.append(
|
||||||
Finding(
|
Finding(
|
||||||
id=f"latency.loss.{p.label}",
|
id=f"latency.loss.{p.label}",
|
||||||
|
|
@ -98,6 +108,9 @@ def latency_findings(pings: list[PingResult], t: Thresholds) -> list[Finding]:
|
||||||
value=p.loss_pct,
|
value=p.loss_pct,
|
||||||
threshold=t.loss_pct.warn,
|
threshold=t.loss_pct.warn,
|
||||||
advice="Sustained loss on every host points at the local link or the first upstream hop.",
|
advice="Sustained loss on every host points at the local link or the first upstream hop.",
|
||||||
|
title_ru=f"Потери до {p.host}",
|
||||||
|
detail_ru=f"{p.loss_pct}% {kind_ru} за {p.sent} проб через {p.method}.",
|
||||||
|
advice_ru="Устойчивые потери на всех хостах указывают на локальный канал или первый аплинк.",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return findings
|
return findings
|
||||||
|
|
@ -112,6 +125,9 @@ def path_findings(trace: TraceResult) -> list[Finding]:
|
||||||
title="No path data",
|
title="No path data",
|
||||||
detail=f"The traceroute to {trace.target} returned no hops (backend {trace.backend}).",
|
detail=f"The traceroute to {trace.target} returned no hops (backend {trace.backend}).",
|
||||||
advice="ICMP may be filtered end to end; try --tcp-trace.",
|
advice="ICMP may be filtered end to end; try --tcp-trace.",
|
||||||
|
title_ru="Нет данных о маршруте",
|
||||||
|
detail_ru=f"Трассировка до {trace.target} не вернула ни одного хопа (бэкенд {trace.backend}).",
|
||||||
|
advice_ru="ICMP может быть отфильтрован на всём пути; попробуйте --tcp-trace.",
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
findings: list[Finding] = []
|
findings: list[Finding] = []
|
||||||
|
|
@ -130,6 +146,9 @@ def path_findings(trace: TraceResult) -> list[Finding]:
|
||||||
value=hop.loss_pct,
|
value=hop.loss_pct,
|
||||||
threshold=20.0,
|
threshold=20.0,
|
||||||
advice="This hop and everything after it share the problem; the hop before it is the last clean one.",
|
advice="This hop and everything after it share the problem; the hop before it is the last clean one.",
|
||||||
|
title_ru="Устойчивые потери начинаются в середине маршрута",
|
||||||
|
detail_ru=f"Потери появляются на хопе {hop.ttl} ({hop.ip or 'нет ответа'}) на уровне {hop.loss_pct}% и продолжаются дальше.",
|
||||||
|
advice_ru="Этот хоп и всё, что после него, делят одну проблему; хоп перед ним — последний чистый.",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
|
|
@ -141,6 +160,9 @@ def path_findings(trace: TraceResult) -> list[Finding]:
|
||||||
title="Path did not reach the target",
|
title="Path did not reach the target",
|
||||||
detail=f"The trace to {trace.target} stopped at hop {trace.hops[-1].ttl}.",
|
detail=f"The trace to {trace.target} stopped at hop {trace.hops[-1].ttl}.",
|
||||||
advice="Many networks drop the final ICMP reply; this alone is not a fault.",
|
advice="Many networks drop the final ICMP reply; this alone is not a fault.",
|
||||||
|
title_ru="Маршрут не достиг цели",
|
||||||
|
detail_ru=f"Трассировка до {trace.target} остановилась на хопе {trace.hops[-1].ttl}.",
|
||||||
|
advice_ru="Многие сети отбрасывают финальный ответ ICMP; само по себе это не является неисправностью.",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return findings
|
return findings
|
||||||
|
|
@ -256,6 +278,15 @@ _CONSEQUENCES = {
|
||||||
"F": "A single download makes a call unusable; this is queue bloat, not a slow line.",
|
"F": "A single download makes a call unusable; this is queue bloat, not a slow line.",
|
||||||
"?": "Not measured — the speedtest tier that measures it did not run.",
|
"?": "Not measured — the speedtest tier that measures it did not run.",
|
||||||
}
|
}
|
||||||
|
_CONSEQUENCES_RU = {
|
||||||
|
"A": "Видеозвонки и игры остаются плавными, даже когда канал полностью загружен.",
|
||||||
|
"B": "Почти незаметно; большая отдача может слегка споткнуть видеозвонок.",
|
||||||
|
"C": "Звонки и игры начинают дёргаться, как только что-то ещё скачивается.",
|
||||||
|
"D": "Любая крупная передача заставляет звонки заикаться, а страницы — зависать.",
|
||||||
|
"E": "Соединение кажется сломанным, пока оно занято, хотя пропускная способность в порядке.",
|
||||||
|
"F": "Одна закачка делает звонок непригодным; это раздутие очереди, а не медленный канал.",
|
||||||
|
"?": "Не измерено — уровень speedtest-каскада, который это измеряет, не запускался.",
|
||||||
|
}
|
||||||
_GRADE_SEVERITY = {"A": "ok", "B": "ok", "C": "warn", "D": "crit", "E": "crit", "F": "crit", "?": "info"}
|
_GRADE_SEVERITY = {"A": "ok", "B": "ok", "C": "warn", "D": "crit", "E": "crit", "F": "crit", "?": "info"}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -269,8 +300,9 @@ def grade_bufferbloat(delta_ms: float | None, bands: BufferbloatBands) -> str:
|
||||||
return "F"
|
return "F"
|
||||||
|
|
||||||
|
|
||||||
def bufferbloat_consequence(grade: str) -> str:
|
def bufferbloat_consequence(grade: str, lang: str = "en") -> str:
|
||||||
return _CONSEQUENCES.get(grade, _CONSEQUENCES["?"])
|
table = _CONSEQUENCES_RU if lang == "ru" else _CONSEQUENCES
|
||||||
|
return table.get(grade, table["?"])
|
||||||
|
|
||||||
|
|
||||||
def speed_findings(speed: SpeedResult, bands: BufferbloatBands) -> list[Finding]:
|
def speed_findings(speed: SpeedResult, bands: BufferbloatBands) -> list[Finding]:
|
||||||
|
|
@ -283,10 +315,16 @@ def speed_findings(speed: SpeedResult, bands: BufferbloatBands) -> list[Finding]
|
||||||
title="No bandwidth measurement",
|
title="No bandwidth measurement",
|
||||||
detail=f"Every speedtest tier failed or was disabled (tried: {tried}).",
|
detail=f"Every speedtest tier failed or was disabled (tried: {tried}).",
|
||||||
advice="Install the Ookla speedtest binary, or rerun without --quick.",
|
advice="Install the Ookla speedtest binary, or rerun without --quick.",
|
||||||
|
title_ru="Нет замера скорости",
|
||||||
|
detail_ru=f"Все уровни speedtest-каскада завершились неудачей или были отключены (пробовали: {tried}).",
|
||||||
|
advice_ru="Установите бинарник Ookla speedtest или перезапустите без --quick.",
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
findings: list[Finding] = []
|
findings: list[Finding] = []
|
||||||
for direction, delta in (("down", speed.bufferbloat_down_ms), ("up", speed.bufferbloat_up_ms)):
|
for direction, direction_ru, delta in (
|
||||||
|
("down", "приём", speed.bufferbloat_down_ms),
|
||||||
|
("up", "передача", speed.bufferbloat_up_ms),
|
||||||
|
):
|
||||||
grade = grade_bufferbloat(delta, bands)
|
grade = grade_bufferbloat(delta, bands)
|
||||||
severity = _GRADE_SEVERITY[grade]
|
severity = _GRADE_SEVERITY[grade]
|
||||||
if severity in ("ok", "info"):
|
if severity in ("ok", "info"):
|
||||||
|
|
@ -302,6 +340,10 @@ def speed_findings(speed: SpeedResult, bands: BufferbloatBands) -> list[Finding]
|
||||||
threshold=bands.c,
|
threshold=bands.c,
|
||||||
advice=bufferbloat_consequence(grade)
|
advice=bufferbloat_consequence(grade)
|
||||||
+ " Enabling SQM/fq_codel on the router is the standard fix.",
|
+ " Enabling SQM/fq_codel on the router is the standard fix.",
|
||||||
|
title_ru=f"Bufferbloat под нагрузкой ({direction_ru}): оценка {grade}",
|
||||||
|
detail_ru=f"Задержка выросла на {delta} мс при насыщении канала на {direction_ru}.",
|
||||||
|
advice_ru=bufferbloat_consequence(grade, lang="ru")
|
||||||
|
+ " Включение SQM/fq_codel на роутере — стандартное решение.",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return findings
|
return findings
|
||||||
|
|
|
||||||
|
|
@ -391,3 +391,106 @@ def test_overall_score_never_goes_below_zero():
|
||||||
status, score, _ = overall_verdict(findings)
|
status, score, _ = overall_verdict(findings)
|
||||||
assert status == "crit"
|
assert status == "crit"
|
||||||
assert score == 0
|
assert score == 0
|
||||||
|
|
||||||
|
|
||||||
|
_CYRILLIC = set("абвгдеёжзийклмнопрстуфхцчшщъыьэюя")
|
||||||
|
|
||||||
|
|
||||||
|
def has_cyrillic(text: str) -> bool:
|
||||||
|
return any(ch in _CYRILLIC for ch in text.lower())
|
||||||
|
|
||||||
|
|
||||||
|
def test_latency_findings_carry_russian_siblings():
|
||||||
|
findings = latency_findings(
|
||||||
|
[
|
||||||
|
ping(received=0, loss_pct=100.0, avg_ms=None, min_ms=None, max_ms=None, jitter_ms=None),
|
||||||
|
ping(avg_ms=130.0, jitter_ms=25.0, loss_pct=5.0, received=19),
|
||||||
|
],
|
||||||
|
thresholds_fixture(),
|
||||||
|
)
|
||||||
|
assert findings
|
||||||
|
for f in findings:
|
||||||
|
assert f.title_ru and has_cyrillic(f.title_ru)
|
||||||
|
assert f.detail_ru and has_cyrillic(f.detail_ru)
|
||||||
|
if f.advice:
|
||||||
|
assert f.advice_ru and has_cyrillic(f.advice_ru)
|
||||||
|
|
||||||
|
|
||||||
|
def test_path_findings_carry_russian_siblings_for_every_case():
|
||||||
|
no_hops = TraceResult(target="1.1.1.1", resolved_ip="1.1.1.1", backend="icmplib", hops=[])
|
||||||
|
findings = path_findings(no_hops)
|
||||||
|
assert findings[0].title_ru and has_cyrillic(findings[0].title_ru)
|
||||||
|
assert findings[0].detail_ru and has_cyrillic(findings[0].detail_ru)
|
||||||
|
assert findings[0].advice_ru and has_cyrillic(findings[0].advice_ru)
|
||||||
|
|
||||||
|
loss_jump_trace = TraceResult(
|
||||||
|
target="1.1.1.1",
|
||||||
|
resolved_ip="1.1.1.1",
|
||||||
|
backend="mtr_json",
|
||||||
|
hops=[
|
||||||
|
TraceHop(ttl=1, ip="192.168.1.1", loss_pct=0.0, avg_ms=1.0),
|
||||||
|
TraceHop(ttl=2, ip="10.64.0.1", loss_pct=0.0, avg_ms=9.0),
|
||||||
|
TraceHop(ttl=3, ip="198.51.100.7", loss_pct=60.0, avg_ms=40.0),
|
||||||
|
TraceHop(ttl=4, ip="1.1.1.1", loss_pct=55.0, avg_ms=41.0),
|
||||||
|
],
|
||||||
|
completed=True,
|
||||||
|
)
|
||||||
|
loss_findings = path_findings(loss_jump_trace)
|
||||||
|
loss = [f for f in loss_findings if f.id == "path.loss_jump"][0]
|
||||||
|
assert loss.title_ru and has_cyrillic(loss.title_ru)
|
||||||
|
assert loss.detail_ru and has_cyrillic(loss.detail_ru)
|
||||||
|
assert loss.advice_ru and has_cyrillic(loss.advice_ru)
|
||||||
|
|
||||||
|
incomplete_trace = TraceResult(
|
||||||
|
target="1.1.1.1",
|
||||||
|
resolved_ip="1.1.1.1",
|
||||||
|
backend="mtr_json",
|
||||||
|
hops=[TraceHop(ttl=1, ip="192.168.1.1", loss_pct=0.0, avg_ms=1.0)],
|
||||||
|
completed=False,
|
||||||
|
)
|
||||||
|
incomplete = path_findings(incomplete_trace)
|
||||||
|
incomplete_finding = [f for f in incomplete if f.id == "path.incomplete"][0]
|
||||||
|
assert incomplete_finding.title_ru and has_cyrillic(incomplete_finding.title_ru)
|
||||||
|
assert incomplete_finding.detail_ru and has_cyrillic(incomplete_finding.detail_ru)
|
||||||
|
assert incomplete_finding.advice_ru and has_cyrillic(incomplete_finding.advice_ru)
|
||||||
|
|
||||||
|
|
||||||
|
def thresholds_fixture() -> Thresholds:
|
||||||
|
return Thresholds()
|
||||||
|
|
||||||
|
|
||||||
|
def test_speed_findings_carry_russian_siblings():
|
||||||
|
unavailable = speed_findings(SpeedResult(method="none"), BufferbloatBands())
|
||||||
|
assert unavailable[0].title_ru and has_cyrillic(unavailable[0].title_ru)
|
||||||
|
assert unavailable[0].detail_ru and has_cyrillic(unavailable[0].detail_ru)
|
||||||
|
assert unavailable[0].advice_ru and has_cyrillic(unavailable[0].advice_ru)
|
||||||
|
|
||||||
|
speed = SpeedResult(
|
||||||
|
method="cloudflare",
|
||||||
|
download_mbps=300.0,
|
||||||
|
upload_mbps=40.0,
|
||||||
|
bufferbloat_down_ms=150.0,
|
||||||
|
bufferbloat_up_ms=150.0,
|
||||||
|
)
|
||||||
|
findings = speed_findings(speed, BufferbloatBands())
|
||||||
|
assert len(findings) == 2
|
||||||
|
for f in findings:
|
||||||
|
assert f.title_ru and has_cyrillic(f.title_ru)
|
||||||
|
assert f.detail_ru and has_cyrillic(f.detail_ru)
|
||||||
|
assert f.advice_ru and has_cyrillic(f.advice_ru)
|
||||||
|
down = [f for f in findings if f.id == "speed.bufferbloat_down"][0]
|
||||||
|
up = [f for f in findings if f.id == "speed.bufferbloat_up"][0]
|
||||||
|
assert "приём" in down.title_ru
|
||||||
|
assert "передача" in up.title_ru
|
||||||
|
|
||||||
|
|
||||||
|
def test_bufferbloat_consequence_russian_covers_every_grade():
|
||||||
|
for grade in "ABCDEF?":
|
||||||
|
text = bufferbloat_consequence(grade, lang="ru")
|
||||||
|
assert text and has_cyrillic(text)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bufferbloat_consequence_default_english_is_unchanged():
|
||||||
|
for grade in "ABCDEF?":
|
||||||
|
assert bufferbloat_consequence(grade) == bufferbloat_consequence(grade, lang="en")
|
||||||
|
assert "call" in bufferbloat_consequence("F").lower()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue