mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-08-16 19:40:12 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94f40c9162 | ||
|
|
c1fa245a6a | ||
|
|
92b203cbbc | ||
|
|
7bd265fe1a |
@@ -12,13 +12,20 @@ import the WireGuard profile, verify the tunnel, then open the live
|
||||
4. **Verify** — polls `/wg/r3-check` → "Tunnel R3 actif ✓".
|
||||
5. **Live metrics** — opens `/social/me` (cartographie sociale).
|
||||
|
||||
## Root path — fully-automated silent onboarding (#538, #551)
|
||||
When the device is **rooted**, the app runs the whole onboarding with **zero
|
||||
taps**: on launch it auto-detects root and, if this cabine host hasn't been
|
||||
onboarded yet, starts the silent sequence automatically (`RootAuto` step,
|
||||
streaming log). The **⚡ Installation automatique (root)** button stays for
|
||||
re-runs. The "already onboarded" flag is persisted per host (SharedPreferences)
|
||||
so reopening the app doesn't redo it. Steps:
|
||||
## Root path — real zero-tap, fully automated (#538, #551, #558)
|
||||
On a **rooted** device the app onboards with **zero taps**, two ways:
|
||||
|
||||
- **On launch** — auto-detects root and runs the silent sequence immediately
|
||||
every launch (no gate), retrying reachability while WiFi/tunnel settle.
|
||||
- **On boot** — a `BOOT_COMPLETED` receiver starts a short foreground service
|
||||
(`OnboardService`) that runs the same silent sequence **without opening the
|
||||
app**, then stops. After one reboot the device self-onboards.
|
||||
|
||||
The **⚡ Installation automatique (root)** button remains as a manual
|
||||
re-trigger. Two interactions are **mandated by Android and unavoidable** for
|
||||
any app: the sideload install confirm ("install unknown apps") and the
|
||||
first-time superuser (Magisk/su) grant prompt. Everything after those is
|
||||
zero-tap. Steps:
|
||||
|
||||
1. **System CA install** — downloads `/wg/ca.pem`, computes the OpenSSL
|
||||
`subject_hash_old` in pure Kotlin, and bind-mounts a populated copy of
|
||||
|
||||
@@ -12,8 +12,8 @@ android {
|
||||
applicationId = "in.secubox.toolbox"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 2
|
||||
versionName = "0.2.0"
|
||||
versionCode = 3
|
||||
versionName = "0.3.0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<!-- #558 full-auto: run the silent onboarding on device boot, no app open. -->
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<!-- Query the WireGuard app so we can hand it the generated profile. -->
|
||||
<queries>
|
||||
<package android:name="com.wireguard.android" />
|
||||
@@ -26,6 +31,26 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- #558 : boot-completed → start the onboarding foreground service
|
||||
so a rooted device self-onboards with zero taps after a reboot. -->
|
||||
<receiver
|
||||
android:name=".BootReceiver"
|
||||
android:exported="true"
|
||||
android:enabled="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<service
|
||||
android:name=".OnboardService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="Silent R3 onboarding on a rooted, operator-owned cabine device" />
|
||||
</service>
|
||||
|
||||
<!-- FileProvider to share the downloaded CA + WG .conf with the
|
||||
system cert installer / the WireGuard app. -->
|
||||
<provider
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
// Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
//
|
||||
// #558 — boot-completed → kick the onboarding foreground service so a
|
||||
// rooted, operator-owned cabine device self-onboards with zero taps after
|
||||
// a reboot (no need to open the app).
|
||||
package `in`.secubox.toolbox
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
class BootReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent?) {
|
||||
val a = intent?.action ?: return
|
||||
if (a == Intent.ACTION_BOOT_COMPLETED || a == Intent.ACTION_LOCKED_BOOT_COMPLETED) {
|
||||
ContextCompat.startForegroundService(
|
||||
context, Intent(context, OnboardService::class.java),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,12 +70,20 @@ fun OnboardApp() {
|
||||
var autoTried by remember { mutableStateOf(false) }
|
||||
|
||||
// The whole root-mode silent run, reused by the ⚡ button AND the
|
||||
// zero-tap auto-launch (#551). Persists an onboarded flag per host on
|
||||
// success so reopening the app doesn't redo it.
|
||||
// zero-tap auto-launch (#551/#558). NO onboarded gate — it auto-runs
|
||||
// every launch (idempotent: re-asserts CA + WG). Reachability is
|
||||
// RETRIED so a WiFi/tunnel race at launch doesn't kill the auto-run.
|
||||
val runRootAuto: () -> Unit = {
|
||||
busy = true; status = ""; rootLog.clear()
|
||||
scope.launch {
|
||||
val ok = withContext(Dispatchers.IO) { api.reachable() }
|
||||
// poll reachability up to ~9 s (network may still be settling)
|
||||
var ok = false
|
||||
for (attempt in 1..6) {
|
||||
ok = withContext(Dispatchers.IO) { api.reachable() }
|
||||
if (ok) break
|
||||
status = "Recherche de la borne… ($attempt)"
|
||||
kotlinx.coroutines.delay(1500)
|
||||
}
|
||||
if (!ok) {
|
||||
busy = false; status = "Borne injoignable — vérifie le réseau."
|
||||
} else {
|
||||
@@ -86,7 +94,6 @@ fun OnboardApp() {
|
||||
}
|
||||
busy = false
|
||||
onTunnel = out.verified
|
||||
if (out.verified) prefs.edit().putBoolean("onboarded:$host", true).apply()
|
||||
when {
|
||||
out.verified -> step = Step.Done
|
||||
out.wgViaApp -> { step = Step.ImportProfile
|
||||
@@ -100,12 +107,13 @@ fun OnboardApp() {
|
||||
|
||||
// Detect root once, off the main thread.
|
||||
LaunchedEffect(Unit) { rootAvail = withContext(Dispatchers.IO) { RootShell.available() } }
|
||||
// Zero-tap (#551): on a rooted device, auto-run the silent onboarding
|
||||
// once on launch — unless this host was already onboarded.
|
||||
// Zero-tap (#558): on a rooted device, auto-run the silent onboarding
|
||||
// on every launch — no gate. (Boot-time auto-run is handled by
|
||||
// BootReceiver + OnboardService so it runs without opening the app.)
|
||||
LaunchedEffect(rootAvail) {
|
||||
if (rootAvail && !autoTried && step == Step.Discover) {
|
||||
autoTried = true
|
||||
if (!prefs.getBoolean("onboarded:$host", false)) runRootAuto()
|
||||
runRootAuto()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
// Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
//
|
||||
// #558 — full-auto onboarding service. Started on boot (BootReceiver). On a
|
||||
// rooted device it runs the silent R3 onboarding (system CA + native WG +
|
||||
// verify) with zero taps, retrying reachability while the network settles,
|
||||
// then stops itself. Non-root / unreachable → it just stops (the launcher
|
||||
// activity remains the manual path).
|
||||
package `in`.secubox.toolbox
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class OnboardService : Service() {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val CHAN = "sbx-onboard"
|
||||
private val NID = 4201
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
startForeground(NID, buildNotification())
|
||||
scope.launch {
|
||||
try { runOnce() } finally { stopSelf() }
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private suspend fun runOnce() {
|
||||
// root is the precondition for the silent path; bail quietly otherwise.
|
||||
if (!RootShell.available()) return
|
||||
val host = getSharedPreferences("secubox-toolbox", Context.MODE_PRIVATE)
|
||||
.getString("host", null) ?: "kbin.gk2.secubox.in"
|
||||
val api = ToolboxApi(host)
|
||||
// network may still be coming up after boot — retry ~30 s.
|
||||
var ok = false
|
||||
for (i in 1..15) {
|
||||
ok = api.reachable()
|
||||
if (ok) break
|
||||
kotlinx.coroutines.delay(2000)
|
||||
}
|
||||
if (!ok) return
|
||||
RootOnboard(api, cacheDir).runSilent { /* headless: no UI log */ }
|
||||
}
|
||||
|
||||
private fun buildNotification(): Notification {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val nm = getSystemService(NotificationManager::class.java)
|
||||
nm?.createNotificationChannel(
|
||||
NotificationChannel(CHAN, "SecuBox onboarding",
|
||||
NotificationManager.IMPORTANCE_LOW),
|
||||
)
|
||||
}
|
||||
val b = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
Notification.Builder(this, CHAN) else @Suppress("DEPRECATION") Notification.Builder(this)
|
||||
return b.setContentTitle("VILLAGE3B")
|
||||
.setContentText("Activation R3 automatique…")
|
||||
.setSmallIcon(android.R.drawable.stat_sys_download)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
scope.coroutineContext[kotlinx.coroutines.Job]?.cancel()
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,21 @@
|
||||
secubox-toolbox (2.6.18-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
* Cartographie donut-bubble view + geography rollups (#553).
|
||||
- social.py fetch_graph: nodes gain country_iso / country_flag /
|
||||
continent / tier ; new by_country, by_continent (flags + per-tier
|
||||
breakdown) and by_tier aggregations (additive, read-time). stats
|
||||
gains total_countries / total_continents. IP/ASN intentionally
|
||||
NOT used as a graph dimension. Helpers _flag_emoji / _continent_of
|
||||
/ _tracker_tier (no GeoIP/publicsuffix dep).
|
||||
- social.js: new "🍩 Donuts" view (default, ⇄ "👁️ Œil" toggle) —
|
||||
a d3.pack of continent backdrop bubbles, each country a donut sized
|
||||
by tracking impact (hits), ring split by severity tier, flag in the
|
||||
hole ; tier legend ; click → country summary in the detail panel.
|
||||
Eye force-graph kept as one-click fallback ; node detail now fills
|
||||
the country flag.
|
||||
|
||||
-- Gerald KERMA <devel@cybermind.fr> Sat, 13 Jun 2026 14:30:00 +0200
|
||||
|
||||
secubox-toolbox (2.6.17-1~bookworm1) bookworm; urgency=medium
|
||||
|
||||
* Social correlation: domain-rollup + history + target↔tracker (#549).
|
||||
|
||||
@@ -708,6 +708,58 @@ def _registrable_domain(host: str) -> str:
|
||||
return last_two
|
||||
|
||||
|
||||
# ── geography helpers (#553) : flag + continent rollup, no IP/ASN ──
|
||||
def _flag_emoji(iso2: Optional[str]) -> str:
|
||||
"""ISO-3166 alpha-2 → flag emoji (regional indicator pair). '' if unknown."""
|
||||
if not iso2 or len(iso2) != 2 or not iso2.isalpha():
|
||||
return ""
|
||||
base = 0x1F1E6
|
||||
return "".join(chr(base + (ord(ch) - ord("A"))) for ch in iso2.upper())
|
||||
|
||||
|
||||
# Compact ISO-3166 alpha-2 → continent map (covers the countries a French
|
||||
# civic kiosk realistically surfaces ; unknowns fall back to "Autre").
|
||||
_CONTINENT = {
|
||||
# Europe
|
||||
"FR": "Europe", "DE": "Europe", "GB": "Europe", "UK": "Europe", "IE": "Europe",
|
||||
"NL": "Europe", "BE": "Europe", "LU": "Europe", "ES": "Europe", "PT": "Europe",
|
||||
"IT": "Europe", "CH": "Europe", "AT": "Europe", "SE": "Europe", "NO": "Europe",
|
||||
"FI": "Europe", "DK": "Europe", "PL": "Europe", "CZ": "Europe", "RO": "Europe",
|
||||
"BG": "Europe", "GR": "Europe", "HU": "Europe", "SK": "Europe", "HR": "Europe",
|
||||
"RS": "Europe", "UA": "Europe", "RU": "Europe", "IS": "Europe", "EE": "Europe",
|
||||
"LV": "Europe", "LT": "Europe", "SI": "Europe",
|
||||
# Americas
|
||||
"US": "Amériques", "CA": "Amériques", "MX": "Amériques", "BR": "Amériques",
|
||||
"AR": "Amériques", "CL": "Amériques", "CO": "Amériques", "PE": "Amériques",
|
||||
# Asia
|
||||
"CN": "Asie", "JP": "Asie", "KR": "Asie", "IN": "Asie", "SG": "Asie",
|
||||
"HK": "Asie", "TW": "Asie", "TH": "Asie", "ID": "Asie", "VN": "Asie",
|
||||
"IL": "Asie", "TR": "Asie", "AE": "Asie", "SA": "Asie",
|
||||
# Africa
|
||||
"ZA": "Afrique", "EG": "Afrique", "MA": "Afrique", "DZ": "Afrique",
|
||||
"TN": "Afrique", "NG": "Afrique", "KE": "Afrique", "SN": "Afrique",
|
||||
# Oceania
|
||||
"AU": "Océanie", "NZ": "Océanie",
|
||||
}
|
||||
|
||||
|
||||
def _continent_of(iso2: Optional[str]) -> str:
|
||||
if not iso2:
|
||||
return "Inconnu"
|
||||
return _CONTINENT.get(iso2.upper(), "Autre")
|
||||
|
||||
|
||||
def _tracker_tier(opgrade, antibot, cdn) -> str:
|
||||
"""Severity tier used for the donut breakdown (highest wins)."""
|
||||
if opgrade:
|
||||
return "opgrade"
|
||||
if antibot:
|
||||
return "antibot"
|
||||
if cdn:
|
||||
return "cdn"
|
||||
return "other"
|
||||
|
||||
|
||||
def fetch_graph(mac_hash: str, since_seconds: int = 86400) -> Dict:
|
||||
"""Return the per-client graph JSON contract.
|
||||
|
||||
@@ -727,8 +779,8 @@ def fetch_graph(mac_hash: str, since_seconds: int = 86400) -> Dict:
|
||||
# can colour/label by edge-network vendor.
|
||||
for r in c.execute(
|
||||
"SELECT n.tracker_domain, n.hits, n.first_seen, n.last_seen, "
|
||||
"n.sites_jsonl, m.cdn_vendor, m.cache_status, m.antibot_vendor, "
|
||||
"m.opgrade_vendor, m.opgrade_category "
|
||||
"n.sites_jsonl, n.country_iso, m.cdn_vendor, m.cache_status, "
|
||||
"m.antibot_vendor, m.opgrade_vendor, m.opgrade_category "
|
||||
"FROM social_nodes n "
|
||||
"LEFT JOIN social_host_meta m ON m.tracker_domain = n.tracker_domain "
|
||||
"WHERE n.client_mac_hash = ? AND n.last_seen >= ? "
|
||||
@@ -739,6 +791,7 @@ def fetch_graph(mac_hash: str, since_seconds: int = 86400) -> Dict:
|
||||
sites = json.loads(r["sites_jsonl"])
|
||||
except Exception:
|
||||
sites = []
|
||||
cc = (r["country_iso"] or "").upper() or None
|
||||
out["nodes"].append(
|
||||
{
|
||||
"id": r["tracker_domain"],
|
||||
@@ -748,6 +801,11 @@ def fetch_graph(mac_hash: str, since_seconds: int = 86400) -> Dict:
|
||||
"sites": sites,
|
||||
"first_seen": r["first_seen"],
|
||||
"last_seen": r["last_seen"],
|
||||
"country_iso": cc,
|
||||
"country_flag": _flag_emoji(cc),
|
||||
"continent": _continent_of(cc),
|
||||
"tier": _tracker_tier(r["opgrade_vendor"], r["antibot_vendor"],
|
||||
r["cdn_vendor"]),
|
||||
"cdn_vendor": r["cdn_vendor"],
|
||||
"cache_status": r["cache_status"],
|
||||
"antibot_vendor": r["antibot_vendor"],
|
||||
@@ -871,10 +929,52 @@ def fetch_graph(mac_hash: str, since_seconds: int = 86400) -> Dict:
|
||||
})
|
||||
out["history"] = history
|
||||
|
||||
# (d) #553 geography + tier rollups for the donut-bubble view.
|
||||
_TIERS = ("opgrade", "antibot", "cdn", "other")
|
||||
tier_tot = {t: 0 for t in _TIERS}
|
||||
_ctry: Dict[str, dict] = {}
|
||||
_cont: Dict[str, dict] = {}
|
||||
for n in out["nodes"]:
|
||||
tier = n["tier"]; hits = n["hits"] or 0
|
||||
tier_tot[tier] += hits
|
||||
cc = n["country_iso"] or "??"
|
||||
ct = _ctry.setdefault(cc, {
|
||||
"country_iso": None if cc == "??" else cc,
|
||||
"flag": n["country_flag"], "continent": n["continent"],
|
||||
"tracker_count": 0, "hits": 0,
|
||||
"tiers": {t: 0 for t in _TIERS}, "_sites": set(),
|
||||
})
|
||||
ct["tracker_count"] += 1; ct["hits"] += hits
|
||||
ct["tiers"][tier] += hits; ct["_sites"].update(n["sites"])
|
||||
co = _cont.setdefault(n["continent"], {
|
||||
"continent": n["continent"], "country_count_set": set(),
|
||||
"tracker_count": 0, "hits": 0, "tiers": {t: 0 for t in _TIERS},
|
||||
})
|
||||
co["country_count_set"].add(cc)
|
||||
co["tracker_count"] += 1; co["hits"] += hits
|
||||
co["tiers"][tier] += hits
|
||||
by_country = sorted(
|
||||
({"country_iso": v["country_iso"], "flag": v["flag"],
|
||||
"continent": v["continent"], "tracker_count": v["tracker_count"],
|
||||
"hits": v["hits"], "sites_count": len(v["_sites"]),
|
||||
"tiers": v["tiers"]} for v in _ctry.values()),
|
||||
key=lambda x: -x["hits"])
|
||||
by_continent = sorted(
|
||||
({"continent": v["continent"],
|
||||
"country_count": len(v["country_count_set"]),
|
||||
"tracker_count": v["tracker_count"], "hits": v["hits"],
|
||||
"tiers": v["tiers"]} for v in _cont.values()),
|
||||
key=lambda x: -x["hits"])
|
||||
out["by_country"] = by_country
|
||||
out["by_continent"] = by_continent
|
||||
out["by_tier"] = tier_tot
|
||||
|
||||
out["stats"] = {
|
||||
"total_trackers": (stats_row["total_trackers"] or 0) if stats_row else 0,
|
||||
"total_sites": sites_count,
|
||||
"total_domains": len(by_domain),
|
||||
"total_countries": len([c for c in by_country if c["country_iso"]]),
|
||||
"total_continents": len([c for c in by_continent if c["continent"] not in ("Inconnu",)]),
|
||||
"first_seen": stats_row["first_seen"] if stats_row else None,
|
||||
"last_seen": stats_row["last_seen"] if stats_row else None,
|
||||
"antibot_sites": len({a["src_site"] for a in antibot}),
|
||||
|
||||
@@ -70,6 +70,54 @@
|
||||
|
||||
// ─── graph state ───
|
||||
let simulation = null;
|
||||
let lastGraph = null;
|
||||
let view = 'donuts'; // #553 : 'donuts' (default) | 'eye'
|
||||
|
||||
// Severity-tier palette shared by the donut view + legend.
|
||||
const TIER = {
|
||||
opgrade: { c: 'var(--void-purple)', label: '📡 opérateur' },
|
||||
antibot: { c: 'var(--cinnabar)', label: '🤖 anti-bot' },
|
||||
cdn: { c: 'var(--cyber-cyan)', label: '☁ CDN' },
|
||||
other: { c: 'var(--gold-hermetic)', label: '• autre' },
|
||||
};
|
||||
const TIER_ORDER = ['opgrade', 'antibot', 'cdn', 'other'];
|
||||
|
||||
function draw(graph) {
|
||||
if (!graph) return;
|
||||
if (view === 'donuts') renderDonuts(graph);
|
||||
else render(graph);
|
||||
}
|
||||
|
||||
// Inject the view toggle (🍩 donuts ⇄ 👁️ œil) once, above the svg.
|
||||
function ensureToggle() {
|
||||
if (document.getElementById('view-toggle') || !svgEl) return;
|
||||
const bar = document.createElement('div');
|
||||
bar.id = 'view-toggle';
|
||||
bar.style.cssText = 'display:flex;gap:.4rem;justify-content:center;margin:.4rem 0';
|
||||
const mk = (id, txt) => {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button'; b.dataset.view = id; b.textContent = txt;
|
||||
b.style.cssText = 'cursor:pointer;border:1px solid var(--void-purple,#6e40c9);'
|
||||
+ 'background:transparent;color:var(--text-primary,#e8e6d9);border-radius:6px;'
|
||||
+ 'padding:.35rem .8rem;font:inherit';
|
||||
b.addEventListener('click', () => {
|
||||
view = id; syncToggle();
|
||||
draw(lastGraph);
|
||||
});
|
||||
return b;
|
||||
};
|
||||
bar.appendChild(mk('donuts', '🍩 Donuts'));
|
||||
bar.appendChild(mk('eye', '👁️ Œil'));
|
||||
svgEl.parentNode.insertBefore(bar, svgEl);
|
||||
syncToggle();
|
||||
}
|
||||
function syncToggle() {
|
||||
document.querySelectorAll('#view-toggle button').forEach((b) => {
|
||||
const on = b.dataset.view === view;
|
||||
b.style.background = on ? 'var(--void-purple,#6e40c9)' : 'transparent';
|
||||
b.style.color = on ? '#0a0a0f' : 'var(--text-primary,#e8e6d9)';
|
||||
});
|
||||
}
|
||||
|
||||
function svgSize() {
|
||||
// Measure actual rendered size so the force center scales with the
|
||||
@@ -130,6 +178,8 @@
|
||||
sites: n.sites,
|
||||
first_seen: n.first_seen,
|
||||
last_seen: n.last_seen,
|
||||
country_iso: n.country_iso || null,
|
||||
country_flag: n.country_flag || '',
|
||||
cdn_vendor: n.cdn_vendor || null,
|
||||
cache_status: n.cache_status || null,
|
||||
antibot_vendor: n.antibot_vendor || null,
|
||||
@@ -339,11 +389,118 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ─── #553 donut-bubble view ───
|
||||
// Continents are faint backdrop bubbles ; each country is a donut sized
|
||||
// by tracking impact (hits), its ring split by severity tier, with the
|
||||
// flag in the centre. No IP/ASN — geography + impact + tier only.
|
||||
function renderDonuts(graph) {
|
||||
clearGraph();
|
||||
const { W, H } = svgSize();
|
||||
svg.attr('viewBox', `0 0 ${W} ${H}`);
|
||||
bind('total_trackers', graph.stats.total_trackers || 0);
|
||||
bind('total_sites', graph.stats.total_sites || 0);
|
||||
updateAntibotTile(graph.stats.antibot_sites || 0, graph.stats.antibot_vendors || []);
|
||||
updateOpgradeTile(graph.stats.opgrade_sites || 0, graph.stats.opgrade_vendors || []);
|
||||
|
||||
const countries = graph.by_country || [];
|
||||
if (!countries.length) return;
|
||||
|
||||
// hierarchy : root → continent → country(leaf, value=hits)
|
||||
const byCont = new Map();
|
||||
for (const c of countries) {
|
||||
const k = c.continent || 'Autre';
|
||||
if (!byCont.has(k)) byCont.set(k, []);
|
||||
byCont.get(k).push(c);
|
||||
}
|
||||
const root = {
|
||||
name: 'root',
|
||||
children: [...byCont.entries()].map(([cont, list]) => ({
|
||||
name: cont, continent: true,
|
||||
children: list.map(c => ({
|
||||
leaf: true, name: c.country_iso || '??', flag: c.flag || '🏴',
|
||||
value: Math.max(c.hits || 0, 1), tiers: c.tiers || {},
|
||||
tracker_count: c.tracker_count || 0,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
const h = d3.hierarchy(root).sum(d => d.value || 0)
|
||||
.sort((a, b) => (b.value || 0) - (a.value || 0));
|
||||
d3.pack().size([W, H]).padding(d => d.depth === 1 ? 14 : 4)(h);
|
||||
|
||||
const content = svg.append('g').attr('class', 'content');
|
||||
|
||||
// continent backdrop bubbles + labels
|
||||
content.append('g').selectAll('circle.cont')
|
||||
.data(h.descendants().filter(d => d.depth === 1)).join('circle')
|
||||
.attr('class', 'cont')
|
||||
.attr('cx', d => d.x).attr('cy', d => d.y).attr('r', d => d.r)
|
||||
.attr('fill', 'rgba(110,64,201,0.06)')
|
||||
.attr('stroke', 'rgba(110,64,201,0.45)').attr('stroke-dasharray', '3,3');
|
||||
content.append('g').selectAll('text.cont')
|
||||
.data(h.descendants().filter(d => d.depth === 1)).join('text')
|
||||
.attr('class', 'cont').attr('x', d => d.x).attr('y', d => d.y - d.r + 14)
|
||||
.attr('text-anchor', 'middle').attr('fill', 'var(--void-purple,#9e76ff)')
|
||||
.attr('font-size', 12).attr('font-weight', 'bold')
|
||||
.text(d => '🌍 ' + d.data.name);
|
||||
|
||||
// country donut bubbles
|
||||
const pie = d3.pie().sort(null).value(d => d[1]);
|
||||
const leaves = content.append('g').selectAll('g.country')
|
||||
.data(h.leaves()).join('g')
|
||||
.attr('class', 'country node')
|
||||
.attr('transform', d => `translate(${d.x},${d.y})`)
|
||||
.style('cursor', 'pointer')
|
||||
.on('click', (ev, d) => focusCountry(d.data));
|
||||
leaves.each(function (d) {
|
||||
const g = d3.select(this);
|
||||
const r = d.r;
|
||||
const inner = Math.max(r * 0.5, r - 7);
|
||||
const arc = d3.arc().innerRadius(inner).outerRadius(r);
|
||||
const data = TIER_ORDER.map(k => [k, (d.data.tiers && d.data.tiers[k]) || 0])
|
||||
.filter(e => e[1] > 0);
|
||||
const slices = data.length ? pie(data) : pie([['other', 1]]);
|
||||
g.selectAll('path').data(slices).join('path')
|
||||
.attr('d', arc)
|
||||
.attr('fill', s => (TIER[s.data[0]] || TIER.other).c)
|
||||
.attr('stroke', '#0a0a0f').attr('stroke-width', 0.6);
|
||||
// flag (or ISO) in the hole
|
||||
g.append('text').attr('text-anchor', 'middle').attr('dy', '.35em')
|
||||
.attr('font-size', Math.max(9, Math.min(inner * 1.1, 26)))
|
||||
.text(d.data.flag || d.data.name);
|
||||
});
|
||||
|
||||
// tier legend (bottom-left)
|
||||
const lg = content.append('g').attr('transform', `translate(12,${H - 12 - TIER_ORDER.length * 16})`);
|
||||
TIER_ORDER.forEach((k, i) => {
|
||||
const row = lg.append('g').attr('transform', `translate(0,${i * 16})`);
|
||||
row.append('rect').attr('width', 10).attr('height', 10).attr('rx', 2)
|
||||
.attr('fill', TIER[k].c);
|
||||
row.append('text').attr('x', 15).attr('y', 9).attr('font-size', 10)
|
||||
.attr('fill', 'var(--text-muted,#6b6b7a)').text(TIER[k].label);
|
||||
});
|
||||
}
|
||||
|
||||
// Donut country click → reuse the detail panel with a country summary.
|
||||
function focusCountry(c) {
|
||||
if (!ndEl) return;
|
||||
bind('nd_domain', (c.flag || '') + ' ' + (c.name || '?'));
|
||||
bind('nd_country', (c.flag || '') + ' ' + (c.name || '—'));
|
||||
bind('nd_asn', '—');
|
||||
const tier = TIER_ORDER.filter(k => (c.tiers || {})[k])
|
||||
.map(k => `${TIER[k].label}:${c.tiers[k]}`).join(' · ') || '—';
|
||||
bind('nd_cdn', tier);
|
||||
bind('nd_antibot', (c.tiers && c.tiers.antibot) ? '🤖 ' + c.tiers.antibot : '—');
|
||||
bind('nd_opgrade', (c.tiers && c.tiers.opgrade) ? '📡 ' + c.tiers.opgrade : '—');
|
||||
bind('nd_sites', (c.tracker_count || 0) + ' traceurs');
|
||||
bind('nd_first_seen', '—'); bind('nd_last_seen', '—');
|
||||
ndEl.hidden = false;
|
||||
}
|
||||
|
||||
// ─── focus / detail panel ───
|
||||
function focusNode(node, linkSel) {
|
||||
if (node.kind !== 'tracker') { ndEl.hidden = true; return; }
|
||||
bind('nd_domain', node.label);
|
||||
bind('nd_country', '—'); // Phase C dependency (GeoIP)
|
||||
bind('nd_country', node.country_flag ? (node.country_flag + ' ' + (node.country_iso || '')) : '—');
|
||||
bind('nd_asn', '—');
|
||||
bind('nd_cdn', node.cdn_vendor ? (node.cdn_vendor + (node.cache_status ? ' · ' + node.cache_status : '')) : '—');
|
||||
bind('nd_antibot', node.antibot_vendor ? ('🤖 ' + node.antibot_vendor) : '—');
|
||||
@@ -413,7 +570,9 @@
|
||||
const r = await fetch(`/social/graph/${encodeURIComponent(token)}?since=86400`);
|
||||
if (!r.ok) throw new Error('http ' + r.status);
|
||||
const g = await r.json();
|
||||
render(g);
|
||||
lastGraph = g;
|
||||
ensureToggle();
|
||||
draw(g);
|
||||
} catch (e) {
|
||||
console.error('[social] fetch failed', e);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user