mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-06-29 07:08:34 +00:00
Compare commits
4 Commits
9d1c227faf
...
b5764cb52c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5764cb52c | ||
| e9b20cdd44 | |||
|
|
28b1c3e91e | ||
| f4ac537c5a |
|
|
@ -78,6 +78,32 @@ ul{list-style:none;padding-left:.2rem}li{padding:.12rem 0;font-size:.82rem}li::b
|
||||||
.tabs button:hover{color:var(--text)}
|
.tabs button:hover{color:var(--text)}
|
||||||
.tab-pane{display:none}
|
.tab-pane{display:none}
|
||||||
.tab-pane.active{display:block}
|
.tab-pane.active{display:block}
|
||||||
|
/* ── #707 netrunner character sheet ── */
|
||||||
|
.nr{border:1px solid #00d4ff;background:linear-gradient(135deg,rgba(0,212,255,.06),rgba(110,64,201,.06));border-radius:12px;padding:1rem;margin-bottom:1.1rem;position:relative;overflow:hidden}
|
||||||
|
.nr::before{content:"";position:absolute;inset:0;background:repeating-linear-gradient(0deg,transparent,transparent 3px,rgba(0,212,255,.025) 3px,rgba(0,212,255,.025) 4px);pointer-events:none}
|
||||||
|
.nr>*{position:relative}
|
||||||
|
.nr-head{display:flex;gap:1rem;align-items:center;flex-wrap:wrap}
|
||||||
|
.nr-ava{font-size:2.6rem;filter:drop-shadow(0 0 7px #00d4ff)}
|
||||||
|
.nr-id{flex:1;min-width:160px}
|
||||||
|
.nr-name{font-family:ui-monospace,monospace;font-size:1.15rem;color:#00d4ff;letter-spacing:.06em;text-shadow:0 0 8px rgba(0,212,255,.5)}
|
||||||
|
.nr-class{font-size:.8rem;color:var(--violet)}
|
||||||
|
.nr-bars{min-width:170px;flex:1}
|
||||||
|
.nr-bar{font-size:.7rem;color:var(--dim);margin:.18rem 0}
|
||||||
|
.nr-bar .t{height:.6rem;background:#0d0f15;border-radius:99px;overflow:hidden;margin-top:.12rem}
|
||||||
|
.nr-bar .f{display:block;height:100%;border-radius:99px}
|
||||||
|
.nr-grid{display:grid;grid-template-columns:1fr 1fr;gap:.8rem;margin-top:.9rem}
|
||||||
|
@media(max-width:560px){.nr-grid{grid-template-columns:1fr}}
|
||||||
|
.nr-box{border:1px solid var(--line);border-radius:8px;padding:.6rem .7rem;background:rgba(0,0,0,.22)}
|
||||||
|
.nr-box h3{font-size:.7rem;color:var(--violet);text-transform:uppercase;letter-spacing:.12em;margin-bottom:.4rem}
|
||||||
|
.nr-attr{display:flex;align-items:center;gap:.4rem;font-size:.82rem;margin:.28rem 0;flex-wrap:wrap}
|
||||||
|
.nr-attr .nm{width:6.2rem;color:var(--text)}
|
||||||
|
.nr-attr .pips{letter-spacing:.12em;color:#00ff41;text-shadow:0 0 5px rgba(0,255,65,.4)}
|
||||||
|
.nr-attr .vv{margin-left:auto;font-family:ui-monospace,monospace;color:#00d4ff;font-weight:700}
|
||||||
|
.nr-attr .nt{flex-basis:100%;font-size:.64rem;color:var(--dim);padding-left:6.6rem;margin-top:-.2rem}
|
||||||
|
.nr-inv{display:flex;flex-wrap:wrap;gap:.4rem}
|
||||||
|
.nr-chip{font-size:.74rem;padding:.25rem .55rem;border-radius:99px;border:1px solid var(--line)}
|
||||||
|
.nr-chip.on{border-color:#00ff41;color:#00ff41;box-shadow:0 0 8px rgba(0,255,65,.15)}
|
||||||
|
.nr-chip.off{color:var(--dim);opacity:.55;text-decoration:line-through}
|
||||||
</style></head>
|
</style></head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
|
@ -119,6 +145,63 @@ ul{list-style:none;padding-left:.2rem}li{padding:.12rem 0;font-size:.82rem}li::b
|
||||||
<h1>👁️ VILLAGE3B <span style="font-size:.8rem;color:var(--dim);font-weight:400">· mon rapport</span></h1>
|
<h1>👁️ VILLAGE3B <span style="font-size:.8rem;color:var(--dim);font-weight:400">· mon rapport</span></h1>
|
||||||
<p class="sub">Diagnostic live de ce que ton appareil envoie sur le réseau · anonyme · se rafraîchit tout seul</p>
|
<p class="sub">Diagnostic live de ce que ton appareil envoie sur le réseau · anonyme · se rafraîchit tout seul</p>
|
||||||
|
|
||||||
|
{# ── #707 NETRUNNER CHARACTER SHEET ── #}
|
||||||
|
{% set p = persona or {} %}
|
||||||
|
{% if p %}
|
||||||
|
<div class="nr">
|
||||||
|
<div class="nr-head">
|
||||||
|
<div class="nr-ava">{{ p.emoji|default('🧑💻') }}</div>
|
||||||
|
<div class="nr-id">
|
||||||
|
<div class="nr-name">{{ p.tag }}</div>
|
||||||
|
<div class="nr-class">⟁ Classe <b style="color:var(--text)">{{ p.klass }}</b> · Niveau <b style="color:#00d4ff">{{ p.level }}</b> · {{ p.align }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="nr-bars">
|
||||||
|
<div class="nr-bar">🧬 ICE / intégrité <b style="color:#00ff41">{{ p.hp }}</b>/100
|
||||||
|
<span class="t"><span class="f" style="width:{{ p.hp }}%;background:linear-gradient(90deg,#00ff41,#00d4ff)"></span></span></div>
|
||||||
|
<div class="nr-bar">☣️ Exposition <b style="color:var(--amber)">{{ p.exposure }}</b>/100
|
||||||
|
<span class="t"><span class="f" style="width:{{ p.exposure }}%;background:linear-gradient(90deg,var(--amber),var(--red))"></span></span></div>
|
||||||
|
<div class="nr-bar">✦ XP <b style="color:#00d4ff">{{ "{:,}".format(p.xp) }}</b> Ko échangés sur 7 j</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="nr-grid">
|
||||||
|
<div class="nr-box">
|
||||||
|
<h3>⚡ Caractéristiques</h3>
|
||||||
|
{% for a in p.attrs %}
|
||||||
|
<div class="nr-attr"><span>{{ a.icon }}</span><span class="nm">{{ a.name }}</span>
|
||||||
|
<span class="pips">{{ '●' * a.pips }}{{ '○' * (6 - a.pips) }}</span><span class="vv">{{ a.v }}</span>
|
||||||
|
{% if a.note %}<span class="nt">{{ a.note }}</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="nr-box">
|
||||||
|
<h3>🎒 Inventaire · protections</h3>
|
||||||
|
<div class="nr-inv">
|
||||||
|
{% for it in p.inventory %}
|
||||||
|
<span class="nr-chip {{ 'on' if it.on else 'off' }}">{{ it.icon }} {{ it.name }} {{ '✓' if it.on else '✗' }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<h3 style="margin-top:.75rem">🐉 Bestiaire · qui te traque</h3>
|
||||||
|
{% if ch.trackers %}
|
||||||
|
<div style="font-size:.78rem">
|
||||||
|
{% for t in ch.trackers[:4] %}
|
||||||
|
<div>{{ ['👹','🧛','👺','🦠','🕷️','👾'][loop.index0 % 6] }} {{ t.label[:18] }} <b style="color:var(--violet)">×{{ t.count }}</b></div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}<div class="empty">Aucun ennemi repéré 🎉</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% set dme = (dpi_exfil or {}).me or {} %}
|
||||||
|
<div class="nr-box" style="margin-top:.8rem">
|
||||||
|
<h3>⚔️ Quêtes en cours · menaces</h3>
|
||||||
|
{% if dme.alerts %}
|
||||||
|
{% for q in dme.alerts[:5] %}
|
||||||
|
<div style="font-size:.8rem">🗡️ <b style="color:var(--amber)">{{ (q.label or q.kind or '?')|upper }}</b> — {{ q.service or q.dst or '' }} <span style="color:var(--dim)">{{ q.detail or '' }}</span></div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}<div class="empty" style="color:var(--phos)">✓ Aucune menace active — zone sûre, runner.</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{# ── TABS (#699) : Pistage / DPI-Exfil / Overall ── #}
|
{# ── TABS (#699) : Pistage / DPI-Exfil / Overall ── #}
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<button class="active" data-tab="pistage">🍪 Pistage</button>
|
<button class="active" data-tab="pistage">🍪 Pistage</button>
|
||||||
|
|
|
||||||
|
|
@ -2523,6 +2523,113 @@ def _build_pdf_donuts(mac_hash: str | None, data: dict) -> list:
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _ua_class(ua: str) -> tuple:
|
||||||
|
"""(emoji, label) device class from a User-Agent. Order matters: Android UAs
|
||||||
|
also contain 'linux', iPad desktop-mode contains 'macintosh'."""
|
||||||
|
u = (ua or "").lower()
|
||||||
|
if "android" in u:
|
||||||
|
return ("🤖", "Android")
|
||||||
|
if "iphone" in u or "ipad" in u or "ipod" in u:
|
||||||
|
return ("📱", "iPhone/iPad")
|
||||||
|
if "cros" in u:
|
||||||
|
return ("💻", "ChromeOS")
|
||||||
|
if "windows" in u:
|
||||||
|
return ("🪟", "PC Windows")
|
||||||
|
if "macintosh" in u or "mac os" in u:
|
||||||
|
return ("💻", "Mac")
|
||||||
|
if "linux" in u or "x11" in u or "ubuntu" in u or "fedora" in u:
|
||||||
|
return ("🐧", "Ordinateur Linux")
|
||||||
|
return ("🧑💻", "Runner")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_wg_r3_peer(mac_hash: str) -> bool:
|
||||||
|
"""True if mac_hash == sha256(wg_pubkey)[:16] of a wg-toolbox peer → the
|
||||||
|
device is physically on the R3 tunnel, regardless of the stored client-level
|
||||||
|
preference (which can lag at r1)."""
|
||||||
|
import json
|
||||||
|
import hashlib
|
||||||
|
try:
|
||||||
|
doc = json.loads(Path("/var/lib/secubox/toolbox/wg-peers.json").read_text())
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
for pk in (doc.get("peers") or {}):
|
||||||
|
if hashlib.sha256(pk.encode()).hexdigest()[:16] == mac_hash:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# #707 — Cyberpunk-Netrunner character sheet. Maps the LIVE telemetry (exposure,
|
||||||
|
# trackers, DPI cumulative, ads blocked, active protections) onto RPG stats.
|
||||||
|
def _persona_sheet(mac_hash: str, current_level: str, gs: dict,
|
||||||
|
exposure: int, dpi_e: dict, device_type: str = "",
|
||||||
|
ua: str = "") -> dict:
|
||||||
|
me = (dpi_e or {}).get("me") or {}
|
||||||
|
emoji, klass = _ua_class(ua) # live device from the request's User-Agent
|
||||||
|
try:
|
||||||
|
ads_total = int((store.ad_client_stats(mac_hash, hours=7 * 24, top=1) or {}).get("total", 0) or 0)
|
||||||
|
except Exception:
|
||||||
|
ads_total = 0
|
||||||
|
try:
|
||||||
|
from .filters import get_filters as _gf
|
||||||
|
tor_on = bool((_gf() or {}).get("tor_mode"))
|
||||||
|
except Exception:
|
||||||
|
tor_on = False
|
||||||
|
|
||||||
|
level = (current_level or "r1").lower()
|
||||||
|
if _is_wg_r3_peer(mac_hash): # on the R3 tunnel → effective R3 regardless of stored pref
|
||||||
|
level = "r3"
|
||||||
|
lvl_bonus = {"r0": 0, "r1": 1, "r2": 3, "r3": 6}.get(level, 1)
|
||||||
|
inventory = [
|
||||||
|
{"icon": "🧅", "name": "Tunnel Tor", "on": tor_on},
|
||||||
|
{"icon": "🔒", "name": "Cert MITM", "on": level in ("r2", "r3")},
|
||||||
|
{"icon": "🛰️", "name": "WireGuard R3", "on": level == "r3"},
|
||||||
|
{"icon": "🚫", "name": "Ad-blocker", "on": level in ("r1", "r2", "r3")},
|
||||||
|
]
|
||||||
|
n_prot = sum(1 for p in inventory if p["on"])
|
||||||
|
|
||||||
|
trackers = int(gs.get("total_trackers", 0) or 0)
|
||||||
|
n_cats = len(me.get("categories") or [])
|
||||||
|
n_protos = len(me.get("protocols") or [])
|
||||||
|
flows = int(me.get("flows", 0) or 0)
|
||||||
|
data_kb = int(((me.get("up", 0) or 0) + (me.get("down", 0) or 0)) / 1024)
|
||||||
|
|
||||||
|
def _clamp(v):
|
||||||
|
return max(3, min(20, int(v)))
|
||||||
|
|
||||||
|
defense = _clamp(6 + n_prot * 2 + lvl_bonus)
|
||||||
|
discretion = _clamp(20 - min(16, trackers // 2) - (0 if tor_on else 2))
|
||||||
|
riposte = _clamp(4 + int(ads_total ** 0.38)) if ads_total > 0 else 4
|
||||||
|
intel = _clamp(4 + n_cats * 2 + n_protos)
|
||||||
|
|
||||||
|
def _attr(icon, name, v, note=""):
|
||||||
|
return {"icon": icon, "name": name, "v": v,
|
||||||
|
"pips": max(0, min(6, round(v / 20 * 6))), "note": note}
|
||||||
|
|
||||||
|
hp = max(0, 100 - int(exposure or 0))
|
||||||
|
align = ("🛡️ Protégé" if exposure < 30 else
|
||||||
|
"⚖️ Exposé" if exposure < 70 else "☣️ Vulnérable")
|
||||||
|
return {
|
||||||
|
"tag": f"VILLAGE3B·#{mac_hash[:4].upper()}",
|
||||||
|
"id": mac_hash[:8],
|
||||||
|
"klass": klass,
|
||||||
|
"emoji": emoji,
|
||||||
|
"level": level.upper(),
|
||||||
|
"exposure": int(exposure or 0),
|
||||||
|
"hp": hp,
|
||||||
|
"xp": data_kb,
|
||||||
|
"align": align,
|
||||||
|
"attrs": [
|
||||||
|
_attr("🛡️", "DÉFENSE", defense),
|
||||||
|
_attr("👁️", "DISCRÉTION", discretion),
|
||||||
|
_attr("⚔️", "RIPOSTE", riposte, f"{ads_total} pubs tuées"),
|
||||||
|
_attr("🧠", "INTEL", intel, f"{n_cats} cat · {flows} flux"),
|
||||||
|
],
|
||||||
|
"inventory": inventory,
|
||||||
|
"ads_total": ads_total,
|
||||||
|
"flows": flows,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# NOTE: route order matters in FastAPI — specific routes (/report/me,
|
# NOTE: route order matters in FastAPI — specific routes (/report/me,
|
||||||
# /report/me/html) MUST be declared BEFORE the catch-all /report/{token},
|
# /report/me/html) MUST be declared BEFORE the catch-all /report/{token},
|
||||||
# otherwise FastAPI matches /report/me with token="me" and returns 404.
|
# otherwise FastAPI matches /report/me with token="me" and returns 404.
|
||||||
|
|
@ -2570,15 +2677,20 @@ async def report_me_html(request: Request) -> HTMLResponse:
|
||||||
wg_enabled = Path("/etc/secubox/toolbox/wg/server.pubkey").exists()
|
wg_enabled = Path("/etc/secubox/toolbox/wg/server.pubkey").exists()
|
||||||
# Phase 6.D (#497) : cumulative anonymous stats for visual context
|
# Phase 6.D (#497) : cumulative anonymous stats for visual context
|
||||||
cumulative = _cumulative_stats()
|
cumulative = _cumulative_stats()
|
||||||
|
_level = store.get_client_level(mac_hash) if mac_hash else "r1"
|
||||||
|
_dpi_e = _dpi_stats(mac_hash)
|
||||||
html = _env.get_template("report-live.html.j2").render(
|
html = _env.get_template("report-live.html.j2").render(
|
||||||
mac_hash=mac_hash, ip=ip,
|
mac_hash=mac_hash, ip=ip,
|
||||||
request_args=dict(request.query_params),
|
request_args=dict(request.query_params),
|
||||||
current_level=store.get_client_level(mac_hash) if mac_hash else "r1",
|
current_level=_level,
|
||||||
wg_enabled=wg_enabled,
|
wg_enabled=wg_enabled,
|
||||||
cumulative=cumulative,
|
cumulative=cumulative,
|
||||||
graph=graph, graph_stats=gs, exposure_score=exposure_score,
|
graph=graph, graph_stats=gs, exposure_score=exposure_score,
|
||||||
charts=_build_report_charts(graph),
|
charts=_build_report_charts(graph),
|
||||||
dpi_exfil=_dpi_stats(mac_hash),
|
dpi_exfil=_dpi_e,
|
||||||
|
persona=_persona_sheet(mac_hash, _level, gs, exposure_score, _dpi_e,
|
||||||
|
session.get("device_type", ""),
|
||||||
|
request.headers.get("user-agent", "")),
|
||||||
**session,
|
**session,
|
||||||
)
|
)
|
||||||
return HTMLResponse(html, headers={
|
return HTMLResponse(html, headers={
|
||||||
|
|
@ -2608,6 +2720,23 @@ async def report_me(request: Request) -> Response:
|
||||||
data = reports.build_report_data(mac_hash, session)
|
data = reports.build_report_data(mac_hash, session)
|
||||||
data["dpi_exfil"] = _dpi_stats(mac_hash) # #701 — DPI parity with the HTML report
|
data["dpi_exfil"] = _dpi_stats(mac_hash) # #701 — DPI parity with the HTML report
|
||||||
data["pdf_donuts"] = _build_pdf_donuts(mac_hash, data) # #703 — visual donuts
|
data["pdf_donuts"] = _build_pdf_donuts(mac_hash, data) # #703 — visual donuts
|
||||||
|
# #707 — Netrunner persona sheet (live graph + DPI + ads + request UA)
|
||||||
|
try:
|
||||||
|
from . import social as _social
|
||||||
|
_graph = _social.fetch_graph(mac_hash, since_seconds=7 * 86400)
|
||||||
|
except Exception:
|
||||||
|
_graph = {"stats": {}, "nodes": [], "by_country": []}
|
||||||
|
_gs = _graph.get("stats") or {}
|
||||||
|
_exp = min(100, int((_gs.get("total_trackers", 0) or 0) * 1.5
|
||||||
|
+ (_gs.get("opgrade_sites", 0) or 0) * 12
|
||||||
|
+ (_gs.get("antibot_sites", 0) or 0) * 8))
|
||||||
|
_lvl = store.get_client_level(mac_hash) if mac_hash else "r1"
|
||||||
|
data["persona"] = _persona_sheet(mac_hash, _lvl, _gs, _exp, data["dpi_exfil"],
|
||||||
|
data.get("device_type", ""),
|
||||||
|
request.headers.get("user-agent", ""))
|
||||||
|
data["bestiary"] = (_build_report_charts(_graph).get("trackers") or [])[:5]
|
||||||
|
data["carto_nodes"] = _graph.get("nodes") or [] # #709 carto + tables
|
||||||
|
data["carto_country"] = _graph.get("by_country") or []
|
||||||
pdf_bytes = reports.render_pdf(data)
|
pdf_bytes = reports.render_pdf(data)
|
||||||
fname = f"gondwana-toolbox-{mac_hash[:8]}.pdf"
|
fname = f"gondwana-toolbox-{mac_hash[:8]}.pdf"
|
||||||
return Response(
|
return Response(
|
||||||
|
|
|
||||||
|
|
@ -143,7 +143,10 @@ def render_pdf(report: dict) -> bytes:
|
||||||
pdf.cell(0, 5, "Rapport d'analyse de session — Cabine numérique VILLAGE3B", ln=True, align="C")
|
pdf.cell(0, 5, "Rapport d'analyse de session — Cabine numérique VILLAGE3B", ln=True, align="C")
|
||||||
pdf.ln(3)
|
pdf.ln(3)
|
||||||
|
|
||||||
# ── HERO DASHBOARD : big global numbers ──
|
# ── HERO : Netrunner persona sheet (#707), falls back to the old dashboard ──
|
||||||
|
if report.get("persona"):
|
||||||
|
_persona_block(pdf, family, report)
|
||||||
|
else:
|
||||||
_dashboard_hero(pdf, family, report)
|
_dashboard_hero(pdf, family, report)
|
||||||
|
|
||||||
# Anonymous ID
|
# Anonymous ID
|
||||||
|
|
@ -221,6 +224,30 @@ def render_pdf(report: dict) -> bytes:
|
||||||
# ── DPI device donut charts (mitm/certs/ads/dpi) — #703 ──
|
# ── DPI device donut charts (mitm/certs/ads/dpi) — #703 ──
|
||||||
_pdf_donut_grid(pdf, report.get("pdf_donuts") or [])
|
_pdf_donut_grid(pdf, report.get("pdf_donuts") or [])
|
||||||
|
|
||||||
|
# ── #709 carto network map + emoji data tables ──
|
||||||
|
carto = report.get("carto_nodes") or []
|
||||||
|
_carto_graph(pdf, family, carto)
|
||||||
|
if carto:
|
||||||
|
_emoji_table(pdf, family, "🍪 TRACEURS — qui te suit",
|
||||||
|
[("Pays", 0.12), ("Domaine", 0.46), ("Hits", 0.14), ("Sites", 0.28)],
|
||||||
|
[[n.get("country_flag", "🏴"), n.get("domain", "?"),
|
||||||
|
n.get("hits", 0), f"{n.get('sites_count', 0)} sites"]
|
||||||
|
for n in sorted(carto, key=lambda x: x.get("hits", 0), reverse=True)[:12]])
|
||||||
|
by_country = report.get("carto_country") or []
|
||||||
|
if by_country:
|
||||||
|
_emoji_table(pdf, family, "🌍 PAYS — destinations du pistage",
|
||||||
|
[("Flag", 0.12), ("Pays", 0.30), ("Traceurs", 0.28), ("Hits", 0.30)],
|
||||||
|
[[c.get("flag", "🏴"), c.get("country_iso", "?"),
|
||||||
|
c.get("tracker_count", 0), c.get("hits", 0)]
|
||||||
|
for c in by_country[:10]])
|
||||||
|
dme = (report.get("dpi_exfil") or {}).get("me") or {}
|
||||||
|
dests = dme.get("destinations") or []
|
||||||
|
if dests:
|
||||||
|
_emoji_table(pdf, family, "🛰️ DPI — top destinations (envoi)",
|
||||||
|
[("Cat", 0.12), ("Service / hôte", 0.58), ("Part", 0.30)],
|
||||||
|
[[d.get("emoji", "🌐"), d.get("label", "?"), f"{d.get('pct', 0)}%"]
|
||||||
|
for d in dests[:10]])
|
||||||
|
|
||||||
# ── DPI / EXFILTRATION (R3 per-device + overall) — #701 (parity with HTML) ──
|
# ── DPI / EXFILTRATION (R3 per-device + overall) — #701 (parity with HTML) ──
|
||||||
dexf = report.get("dpi_exfil") or {}
|
dexf = report.get("dpi_exfil") or {}
|
||||||
dme = dexf.get("me") or {}
|
dme = dexf.get("me") or {}
|
||||||
|
|
@ -488,6 +515,59 @@ def _widget(pdf, family: str, x: float, y: float, w: float, h: float,
|
||||||
pdf.cell(w, 3, _safe(label), ln=False, align="C")
|
pdf.cell(w, 3, _safe(label), ln=False, align="C")
|
||||||
|
|
||||||
|
|
||||||
|
def _persona_bar(pdf, family: str, label: str, pct: int, col: tuple) -> None:
|
||||||
|
"""A labelled horizontal progress bar (ICE / exposition)."""
|
||||||
|
pct = max(0, min(100, int(pct or 0)))
|
||||||
|
pdf.set_x(pdf.l_margin)
|
||||||
|
pdf.set_font(family, "", 8)
|
||||||
|
pdf.set_text_color(150, 150, 150)
|
||||||
|
pdf.cell(40, 4, _safe(f"{label} {pct}/100"), ln=True)
|
||||||
|
x, y, w = pdf.l_margin, pdf.get_y(), _page_w(pdf)
|
||||||
|
pdf.set_fill_color(20, 22, 28)
|
||||||
|
pdf.rect(x, y, w, 2.6, style="F")
|
||||||
|
pdf.set_fill_color(*col)
|
||||||
|
pdf.rect(x, y, w * pct / 100.0, 2.6, style="F")
|
||||||
|
pdf.set_y(y + 4)
|
||||||
|
|
||||||
|
|
||||||
|
def _persona_block(pdf, family: str, report: dict) -> None:
|
||||||
|
"""#707 — Cyberpunk-Netrunner character sheet header for the PDF."""
|
||||||
|
p = report.get("persona") or {}
|
||||||
|
pdf.set_font(family, "B", 12)
|
||||||
|
pdf.set_text_color(0, 212, 255)
|
||||||
|
pdf.cell(0, 6, _safe("🎮 FICHE NETRUNNER"), ln=True)
|
||||||
|
pdf.set_font(family, "B", 11)
|
||||||
|
pdf.set_text_color(0, 212, 255)
|
||||||
|
pdf.cell(0, 6, _safe(f"{p.get('emoji','')} {p.get('tag','?')}"), ln=True)
|
||||||
|
pdf.set_font(family, "", 9)
|
||||||
|
pdf.set_text_color(150, 120, 230)
|
||||||
|
pdf.cell(0, 5, _safe(f"Classe {p.get('klass','?')} · Niveau {p.get('level','?')} · {p.get('align','')}"), ln=True)
|
||||||
|
pdf.ln(1)
|
||||||
|
_persona_bar(pdf, family, "ICE / integrite", p.get("hp", 0), (0, 255, 65))
|
||||||
|
_persona_bar(pdf, family, "Exposition", p.get("exposure", 0), (255, 179, 71))
|
||||||
|
pdf.set_font(family, "", 8)
|
||||||
|
pdf.set_text_color(120, 120, 120)
|
||||||
|
pdf.cell(0, 4, _safe(f"XP {p.get('xp',0):,} Ko echanges (7j)"), ln=True)
|
||||||
|
pdf.ln(1)
|
||||||
|
# 4 attribute widgets
|
||||||
|
y = pdf.get_y()
|
||||||
|
bw = (_page_w(pdf) - 6) / 4
|
||||||
|
bh = 17
|
||||||
|
for i, a in enumerate((p.get("attrs") or [])[:4]):
|
||||||
|
x = pdf.l_margin + i * (bw + 2)
|
||||||
|
_widget(pdf, family, x, y, bw, bh, a.get("icon", "?"),
|
||||||
|
str(a.get("v", 0)), a.get("name", "")[:10], (15, 30, 40), fg=(0, 212, 255))
|
||||||
|
pdf.set_y(y + bh + 2)
|
||||||
|
# inventory + bestiary
|
||||||
|
_kv(pdf, "Inventaire",
|
||||||
|
" ".join(f"{it.get('name','')} {'OK' if it.get('on') else 'x'}" for it in (p.get("inventory") or [])))
|
||||||
|
best = report.get("bestiary") or []
|
||||||
|
if best:
|
||||||
|
_kv(pdf, "Bestiaire",
|
||||||
|
" · ".join(f"{b.get('label','?')[:14]} x{b.get('count',0)}" for b in best[:4]))
|
||||||
|
pdf.ln(2)
|
||||||
|
|
||||||
|
|
||||||
def _dashboard_hero(pdf, family: str, report: dict) -> None:
|
def _dashboard_hero(pdf, family: str, report: dict) -> None:
|
||||||
"""Phase 3 (#492) : 3-row widget dashboard, banner-style, with aggregations.
|
"""Phase 3 (#492) : 3-row widget dashboard, banner-style, with aggregations.
|
||||||
|
|
||||||
|
|
@ -718,6 +798,70 @@ def _pdf_donut_grid(pdf, donuts: list) -> None:
|
||||||
pdf.set_y(y0 + rows * row_h + 2)
|
pdf.set_y(y0 + rows * row_h + 2)
|
||||||
|
|
||||||
|
|
||||||
|
# #709 — radial "carto" network map (TOI hub → top trackers) for the PDF.
|
||||||
|
def _carto_graph(pdf, family: str, nodes: list) -> None:
|
||||||
|
import math
|
||||||
|
nodes = sorted([n for n in (nodes or []) if n.get("hits")],
|
||||||
|
key=lambda n: n.get("hits", 0), reverse=True)[:8]
|
||||||
|
if not nodes:
|
||||||
|
return
|
||||||
|
_section(pdf, "🗺️ CARTO — qui te piste (carte du réseau)")
|
||||||
|
cx = pdf.l_margin + _page_w(pdf) / 2.0
|
||||||
|
cy = pdf.get_y() + 34
|
||||||
|
R = 27.0
|
||||||
|
maxh = max(n.get("hits", 1) for n in nodes) or 1
|
||||||
|
pdf.set_draw_color(70, 90, 120)
|
||||||
|
pdf.set_line_width(0.2)
|
||||||
|
placed = []
|
||||||
|
for i, n in enumerate(nodes):
|
||||||
|
ang = math.radians(-90 + i * 360.0 / len(nodes))
|
||||||
|
x, y = cx + R * math.cos(ang), cy + R * math.sin(ang)
|
||||||
|
pdf.line(cx, cy, x, y)
|
||||||
|
placed.append((x, y, n))
|
||||||
|
for (x, y, n) in placed:
|
||||||
|
r = 1.6 + 3.2 * (n.get("hits", 1) / maxh)
|
||||||
|
pdf.set_fill_color(255, 80, 110)
|
||||||
|
pdf.ellipse(x - r, y - r, 2 * r, 2 * r, style="F")
|
||||||
|
lbl = f"{n.get('country_flag','')} {(n.get('domain','') or '?')[:12]}"
|
||||||
|
pdf.set_font(family, "", 6)
|
||||||
|
pdf.set_text_color(90, 90, 90)
|
||||||
|
pdf.set_xy(x - 17, (y + r + 0.5) if y >= cy else (y - r - 3.5))
|
||||||
|
pdf.cell(34, 3, _safe(lbl), align="C")
|
||||||
|
pdf.set_fill_color(0, 212, 255)
|
||||||
|
pdf.ellipse(cx - 4.5, cy - 4.5, 9, 9, style="F")
|
||||||
|
pdf.set_xy(cx - 9, cy - 1.6)
|
||||||
|
pdf.set_font(family, "B", 6)
|
||||||
|
pdf.set_text_color(10, 10, 15)
|
||||||
|
pdf.cell(18, 3, "TOI", align="C")
|
||||||
|
pdf.set_text_color(0)
|
||||||
|
pdf.set_y(cy + R + 7)
|
||||||
|
|
||||||
|
|
||||||
|
# #709 — generic emoji data table. cols = [(header, width_fraction), ...]
|
||||||
|
def _emoji_table(pdf, family: str, title: str, cols: list, rows: list) -> None:
|
||||||
|
if not rows:
|
||||||
|
return
|
||||||
|
_section(pdf, title)
|
||||||
|
w = _page_w(pdf)
|
||||||
|
widths = [w * f for (_, f) in cols]
|
||||||
|
pdf.set_font(family, "B", 8)
|
||||||
|
pdf.set_fill_color(16, 22, 30)
|
||||||
|
pdf.set_text_color(0, 212, 255)
|
||||||
|
pdf.set_x(pdf.l_margin)
|
||||||
|
for (h, _), cw in zip(cols, widths):
|
||||||
|
pdf.cell(cw, 5.5, _safe(h), border=0, fill=True)
|
||||||
|
pdf.ln()
|
||||||
|
pdf.set_font(family, "", 8)
|
||||||
|
pdf.set_text_color(35, 35, 35)
|
||||||
|
for r in rows:
|
||||||
|
pdf.set_x(pdf.l_margin)
|
||||||
|
for val, cw in zip(r, widths):
|
||||||
|
cap = max(6, int(cw / 1.7))
|
||||||
|
pdf.cell(cw, 5, _safe(str(val))[:cap], border="B")
|
||||||
|
pdf.ln()
|
||||||
|
pdf.ln(2)
|
||||||
|
|
||||||
|
|
||||||
def _render_text_fallback(report: dict) -> str:
|
def _render_text_fallback(report: dict) -> str:
|
||||||
"""Plain text fallback when fpdf2 isn't installed."""
|
"""Plain text fallback when fpdf2 isn't installed."""
|
||||||
lines = [
|
lines = [
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user