From 617670ad96afab4da9aa357a80d591fdb3604e6b Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Fri, 5 Jun 2026 12:54:41 +0200 Subject: [PATCH] sync with BE requested by AI Auto Agent --- docs/OPUS_SPEEX_VALIDATION.md | 149 +++++++ docs/README.md | 2 + docs/REMOTE_ACCESS_VALIDATION.md | 23 ++ .../backend/public/assets/css/app.css | 8 + .../backend/public/assets/i18n/en.json | 5 +- .../backend/public/assets/i18n/ru.json | 4 +- .../backend/public/assets/js/graphs.js | 363 +++++++++++++++--- .../backend/scripts/ra_udp_debug.sh | 129 +++++++ .../crash_reporter/backend/views/layout.php | 21 +- .../views/partials/console_quick_links.php | 24 +- scripts/validate_opus_speex.sh | 12 + 11 files changed, 650 insertions(+), 90 deletions(-) create mode 100644 docs/OPUS_SPEEX_VALIDATION.md create mode 100755 examples/crash_reporter/backend/scripts/ra_udp_debug.sh create mode 100755 scripts/validate_opus_speex.sh diff --git a/docs/OPUS_SPEEX_VALIDATION.md b/docs/OPUS_SPEEX_VALIDATION.md new file mode 100644 index 0000000..259425c --- /dev/null +++ b/docs/OPUS_SPEEX_VALIDATION.md @@ -0,0 +1,149 @@ +# Opus / Speex / stream protection — validation checklist (task 3) + +E2E validation guide for **audio codecs** and **UDP stream protection** (FEC, NACK, negotiation). Code is on **`next`**; this doc is the operator checklist. + +**See also:** [ndk/README.md](../ndk/README.md), [ROADMAP.md](ROADMAP.md), [AV_QUALITY_QA_SESSION.md](AV_QUALITY_QA_SESSION.md). + +## Table of contents + + +- [Prerequisites](#prerequisites) +- [Automated baseline (CI / dev machine)](#automated-baseline-ci-dev-machine) +- [User settings (both peers)](#user-settings-both-peers) +- [Developer settings](#developer-settings) +- [E2E matrix — run on two devices](#e2e-matrix-run-on-two-devices) +- [What to capture per run](#what-to-capture-per-run) +- [Pass criteria](#pass-criteria) +- [Known limits (next)](#known-limits-next) + + +--- + +## Prerequisites + +- Two Android devices (or sender + receiver) on **same LAN** for first pass; repeat subset on lossy/WAN if possible. +- Debug builds with NDK codecs built if testing native Opus/Speex probes: + +```bash +./scripts/build-native-codecs.sh arm64-v8a # optional; see ndk/README.md +./gradlew :app:assembleDebug +``` + +--- + +## Automated baseline (CI / dev machine) + +Run before field soak: + +```bash +./gradlew :app:testDebugUnitTest --tests 'com.foxx.androidcast.network.transport.StreamProtectionNegotiatorTest' +./gradlew :app:testDebugUnitTest --tests 'com.foxx.androidcast.media.AudioNegotiatorTest' +./gradlew :app:testDebugUnitTest --tests 'com.foxx.androidcast.media.codec.*' +``` + +Or: + +```bash +bash scripts/validate_opus_speex.sh +``` + +Expect **BUILD SUCCESSFUL** and all tests green. + +--- + +## User settings (both peers) + +**Global settings** (main UI): + +| Control | Location | Notes | +|---------|----------|--------| +| Transport | Sender / receiver | Use **UDP** for FEC/NACK tests | +| Stream protection (UDP) | Global settings | Default **None**; try FEC 3/4, NACK, FEC+NACK | +| Audio codec | Global settings | **AUTO**, **Opus**, **Speex**, AAC baseline | + +After changing protection, **start a new session** — negotiation runs at handshake (`CastSession` / `StreamProtectionNegotiator`). + +--- + +## Developer settings + +Enable **Developer options** on both devices if testing passthrough / debug audio: + +| Control | Purpose | +|---------|---------| +| Passthrough / debug audio | `PASSTHROUGH_DEBUG` path | +| Codec overrides | Align with `CodecCatalog` / `PassthroughCodecPolicy` | +| Diagnostics export | Session stats + negotiated mime + protection string | + +Check **session diagnostics** after connect: protection line should match `PassthroughCodecPolicy.protectionDescription()`. + +--- + +## E2E matrix — run on two devices + +Mark each cell **PASS / FAIL / SKIP** in a ticket tagged `20260604`. + +### A. Handshake and negotiation + +| # | Sender protection | Receiver capability | Audio | Expected | +|---|-------------------|---------------------|-------|----------| +| A1 | None | Any | AUTO | No FEC/NACK; stable video+audio | +| A2 | FEC 3/4 | FEC 3/4 | AUTO | Agreed FEC 3/4; loss recovery under artificial drop | +| A3 | NACK | NACK | AUTO | Retransmit under single-packet loss | +| A4 | FEC+NACK | FEC+NACK | AUTO | Combined behavior; no deadlock | +| A5 | FEC 3/4 | NONE only | AUTO | Prompt or downgrade per `StreamProtectionNegotiator` | + +### B. Audio codec + +| # | Sender audio | Receiver audio | Expected | +|---|--------------|----------------|----------| +| B1 | AUTO | AUTO | AAC or negotiated fallback | +| B2 | Opus | Opus | Opus native if `.so` present; else stub/fallback per `AudioNegotiator` | +| B3 | Speex | Speex | Speex native if built; else fallback | +| B4 | Opus | AUTO (no Opus) | Fallback — no silent failure | + +### C. Controls (user vs developer) + +| # | Check | +|---|--------| +| C1 | User can change transport UDP ↔ TCP without crash | +| C2 | User protection change applies on **next** session | +| C3 | Developer diagnostics show negotiated video mime + audio + protection | +| C4 | Receiver UI shows aligned protection label after handshake | + +### D. Stress (optional) + +| # | Check | +|---|--------| +| D1 | Background/rotate sender — session recovers or fails cleanly | +| D2 | ~5 min soak with FEC on — no memory runaway (logcat) | +| D3 | Switch Wi‑Fi ↔ mobile data mid-cast — defined behavior (fail or reconnect) | + +--- + +## What to capture per run + +1. **Diagnostics** export from both devices (or logcat filter `androidcast|CastSession|StreamProtection|AudioNegotiator`). +2. Negotiated lines: `negotiatedVideoMime`, `audioCodec`, `streamProtection`, `wantsNack`. +3. Subjective: audio dropouts, video freezes, recovery after packet loss (FEC/NACK test: weak Wi‑Fi or `tc netem` on Linux AP if available). + +--- + +## Pass criteria + +- All **automated** tests green (`validate_opus_speex.sh`). +- Matrix **A1–A4** and **B1–B2** pass on reference hardware. +- No unexplained **silent** fallback (user sees toast/prompt when negotiation downgrades). +- Diagnostics string matches actual wire behavior for UDP protection. + +--- + +## Known limits (next) + +| Item | Status | +|------|--------| +| Opus / Speex native encode | Probe + bridge; full native path per [ndk/README.md](../ndk/README.md) | +| FEC 3/4 + RS | Per-shard wire (`FecShardWire`); v1 monolithic still accepted | +| TCP transport | Protection spinners disabled / N/A for TCP | + +File issues under Tickets → tag **`20260604`**, component Opus/Speex. diff --git a/docs/README.md b/docs/README.md index b7552dd..8bae545 100644 --- a/docs/README.md +++ b/docs/README.md @@ -35,6 +35,7 @@ Click a link to open a guide. Section links jump within the document (GitHub, Cu | [GRAFANA_vs_others_graphvis_pivot.md](GRAFANA_vs_others_graphvis_pivot.md) | Custom graphs vs Grafana decision memo. | | [INFRA.md](INFRA.md) | Production topology: Gentoo FE → Alpine BE, ports, nginx, sshfs. | | [OPEN_API.md](OPEN_API.md) | Crash ingest API design, triage tags, heuristics. | +| [OPUS_SPEEX_VALIDATION.md](OPUS_SPEEX_VALIDATION.md) | Opus/Speex + UDP stream protection E2E checklist. | | [OTA.md](OTA.md) | OTA channel layout v0, manifests, MQTT, publish flow. | | [PRO_AAR.md](PRO_AAR.md) | cast-core / cast-pro AAR split and Play Billing. | | [REMOTE_ACCESS_IMPL.md](REMOTE_ACCESS_IMPL.md) | WireGuard v1 impl + deploy | @@ -77,6 +78,7 @@ Click a link to open a guide. Section links jump within the document (GitHub, Cu - [PRO_AAR.md](PRO_AAR.md) — Pro AAR split - [USB_HDMI_CAST.md](USB_HDMI_CAST.md) — HDMI / USB-tether - [AV_QUALITY_QA_SESSION.md](AV_QUALITY_QA_SESSION.md) — AV enhancement Q&A +- [OPUS_SPEEX_VALIDATION.md](OPUS_SPEEX_VALIDATION.md) — codec + FEC/NACK field matrix ([§E2E matrix](OPUS_SPEEX_VALIDATION.md#e2e-matrix-run-on-two-devices)) --- diff --git a/docs/REMOTE_ACCESS_VALIDATION.md b/docs/REMOTE_ACCESS_VALIDATION.md index 542b0f3..1e717a5 100644 --- a/docs/REMOTE_ACCESS_VALIDATION.md +++ b/docs/REMOTE_ACCESS_VALIDATION.md @@ -11,6 +11,7 @@ Companion to [REMOTE_ACCESS_IMPL.md](REMOTE_ACCESS_IMPL.md). Use this when verif - [Scripts](#scripts) - [Quick start (local orchestration)](#quick-start-local-orchestration) - [Production / staging BE](#production--staging-be) +- [UDP / WireGuard data plane (`ra_udp_debug.sh`)](#udp--wireguard-data-plane-ra_udp_debugsh) - [Optional: bring up WireGuard on Linux](#optional-bring-up-wireguard-on-linux) - [Mobile vs CLI checklist](#mobile-vs-cli-checklist) - [Troubleshooting](#troubleshooting) @@ -47,6 +48,7 @@ All under `examples/crash_reporter/backend/scripts/`: | `ra_e2e_cli.sh` | Full flow: disable → wait → whitelist → open → connect → close → disable | | `test_remote_access_api.sh` | Minimal API smoke (grep, no jq) | | `verify_remote_access_prod.sh` | BE preflight: config, wg tools, DB tables, optional smoke | +| `ra_udp_debug.sh` | DNS + UDP port + HTTP smoke + connect + `wg-quick` + ping BE (`10.66.66.1`) | ```bash cd examples/crash_reporter/backend @@ -110,6 +112,27 @@ RA_E2E=1 BASE="$CRASHES_BASE" ./scripts/verify_remote_access_prod.sh --- +## UDP / WireGuard data plane (`ra_udp_debug.sh`) + +After HTTP E2E is green, run from a Linux laptop with `curl`, `jq`, `dig`, and optional `wireguard-tools`: + +```bash +cd examples/crash_reporter/backend +export CRASHES_BASE=https://apps.f0xx.org/app/androidcast_project/crashes +export ADMIN_USER=admin ADMIN_PASS='…' + +./scripts/ra_udp_debug.sh # DNS, UDP nc, HTTP smoke, connect, wg conf +./scripts/ra_udp_debug.sh --apply # also wg-quick up + ping 10.66.66.1 + teardown +``` + +Steps: CNAME check (`ra.apps.f0xx.org` → same A as `apps.f0xx.org`) → UDP `51820` → `test_remote_access_api.sh` → whitelist + open + poll connect → write `/tmp/ra-udp-.conf`. With `--apply`, brings tunnel up and pings the BE WG address. + +Skip HTTP re-check when control plane was just verified: `./scripts/ra_udp_debug.sh --skip-http --apply`. + +See also [20260602_REVERSE_SSH_proposals_summary.md — Appendix 1](20260602_REVERSE_SSH_proposals_summary.md) (Linux laptop WG test). + +--- + ## Optional: bring up WireGuard on Linux After `poll --until connect` returns credentials: diff --git a/examples/crash_reporter/backend/public/assets/css/app.css b/examples/crash_reporter/backend/public/assets/css/app.css index 45309c3..c0b5dce 100644 --- a/examples/crash_reporter/backend/public/assets/css/app.css +++ b/examples/crash_reporter/backend/public/assets/css/app.css @@ -1459,11 +1459,19 @@ a:hover { text-decoration: underline; } border-bottom: 1px solid var(--border, rgba(255, 255, 255, 0.1)); flex-shrink: 0; } +.graph-detail-header-text { + flex: 1; + min-width: 0; +} .graph-detail-header h2 { margin: 0; font-size: 1.15rem; font-weight: 600; } +.graph-detail-stats { + margin: 4px 0 0; + font-size: 0.88rem; +} .graph-detail-canvas-wrap { position: relative; flex: 1; diff --git a/examples/crash_reporter/backend/public/assets/i18n/en.json b/examples/crash_reporter/backend/public/assets/i18n/en.json index b0b3fbe..335d94b 100644 --- a/examples/crash_reporter/backend/public/assets/i18n/en.json +++ b/examples/crash_reporter/backend/public/assets/i18n/en.json @@ -12,10 +12,7 @@ "lang.en": "English", "lang.ru": "Russian", "home.title": "Issues console", - "home.intro": "Anonymous Android Cast crash ingest is active. Use Reports to analyze grouped fingerprints.", - "home.upload_api": "Upload API:", - "home.schema": "Schema:", - "home.ticket_upload_api": "Ticket upload API:", + "home.intro": "Crash ingest and operator tools. Open Issues for grouped fingerprints, or use the links below.", "reports.title": "Issues", "reports.by_time": "By time", "reports.grouped": "Grouped", diff --git a/examples/crash_reporter/backend/public/assets/i18n/ru.json b/examples/crash_reporter/backend/public/assets/i18n/ru.json index 78c3469..57f1a38 100644 --- a/examples/crash_reporter/backend/public/assets/i18n/ru.json +++ b/examples/crash_reporter/backend/public/assets/i18n/ru.json @@ -15,9 +15,7 @@ "lang.en": "English", "lang.ru": "Русский", "home.title": "Консоль сбоев", - "home.intro": "Приём анонимных отчётов Android Cast активен. Откройте «Отчёты» для анализа сгруппированных отпечатков.", - "home.upload_api": "API загрузки:", - "home.schema": "Схема:", + "home.intro": "Приём сбоев и операторские инструменты. Откройте Issues для сгруппированных отпечатков или ссылки ниже.", "reports.title": "Issues", "reports.by_time": "По времени", "reports.grouped": "Группы", diff --git a/examples/crash_reporter/backend/public/assets/js/graphs.js b/examples/crash_reporter/backend/public/assets/js/graphs.js index fafaf7d..3f03ea4 100644 --- a/examples/crash_reporter/backend/public/assets/js/graphs.js +++ b/examples/crash_reporter/backend/public/assets/js/graphs.js @@ -76,17 +76,19 @@ if (y > maxY) maxY = y; }); if (maxY <= 0) maxY = 1; - const padL = interactive ? 52 : 36; - const padR = interactive ? 120 : 12; - const padT = 16; - const padB = 32; + const padL = interactive ? 56 : 36; + const padR = interactive ? 148 : 12; + const padT = interactive ? 24 : 16; + const padB = interactive ? 40 : 32; const plotW = w - padL - padR; const plotH = h - padT - padB; + const lineColor = color || '#3d8bfd'; + const title = (opts && opts.title) || ''; ctx.strokeStyle = 'rgba(120,140,170,0.45)'; ctx.lineWidth = 1; ctx.fillStyle = '#94a3b8'; - ctx.font = '10px system-ui, sans-serif'; + ctx.font = interactive ? '11px system-ui, sans-serif' : '10px system-ui, sans-serif'; for (let i = 0; i <= 4; i++) { const y = padT + (plotH * i) / 4; ctx.beginPath(); @@ -95,18 +97,18 @@ ctx.stroke(); if (interactive) { const tickVal = Math.round((maxY * (4 - i)) / 4); - ctx.fillText(String(tickVal), 4, y + 4); + ctx.fillText(String(tickVal), 8, y + 4); } } const plotPoints = points.map((p, idx) => { const x = padL + (plotW * idx) / Math.max(1, points.length - 1); const y = padT + plotH - (plotH * Number(p.y || 0)) / maxY; - return { x, y, raw: p }; + return { x, y, raw: p, idx }; }); - ctx.strokeStyle = color || '#3d8bfd'; - ctx.lineWidth = 2; + ctx.strokeStyle = lineColor; + ctx.lineWidth = interactive ? 2.5 : 2; ctx.beginPath(); plotPoints.forEach((pt, idx) => { if (idx === 0) ctx.moveTo(pt.x, pt.y); @@ -117,62 +119,180 @@ if (interactive) { plotPoints.forEach((pt) => { ctx.beginPath(); - ctx.fillStyle = color || '#3d8bfd'; - ctx.arc(pt.x, pt.y, 4, 0, Math.PI * 2); + ctx.fillStyle = lineColor; + ctx.arc(pt.x, pt.y, 5, 0, Math.PI * 2); ctx.fill(); }); + if (title) { + const lx = w - padR + 12; + ctx.fillStyle = lineColor; + ctx.fillRect(lx, padT, 12, 12); + ctx.fillStyle = '#e2e8f0'; + ctx.font = '12px system-ui, sans-serif'; + const legend = title.length > 18 ? title.slice(0, 17) + '…' : title; + ctx.fillText(legend, lx + 18, padT + 10); + ctx.fillStyle = '#94a3b8'; + ctx.font = '10px system-ui, sans-serif'; + ctx.fillText('Y: value · X: date', lx, padT + 28); + } + const xTicks = Math.min(6, points.length); + for (let t = 0; t < xTicks; t++) { + const idx = xTicks === 1 ? 0 : Math.round((t * (points.length - 1)) / (xTicks - 1)); + const pt = plotPoints[idx]; + const label = String(points[idx].x || ''); + ctx.fillStyle = '#94a3b8'; + ctx.font = '10px system-ui, sans-serif'; + ctx.fillText(label, Math.max(padL, pt.x - 20), h - 12); + } + } else { + ctx.fillStyle = '#94a3b8'; + ctx.font = '10px system-ui, sans-serif'; + if (points.length) { + ctx.fillText(String(points[0].x || ''), padL, h - 8); + const last = points[points.length - 1]; + ctx.fillText(String(last.x || ''), padL + plotW - 48, h - 8); + } } - ctx.fillStyle = '#94a3b8'; - ctx.font = '10px system-ui, sans-serif'; - if (points.length) { - ctx.fillText(String(points[0].x || ''), padL, h - 8); - const last = points[points.length - 1]; - ctx.fillText(String(last.x || ''), padL + plotW - 48, h - 8); - } - - return { points: plotPoints, padL, padT, plotW, plotH, maxY, w, h }; + return { + points: plotPoints, + padL, + padT, + padR, + plotW, + plotH, + maxY, + w, + h, + series: points, + color: lineColor, + title, + }; } - function drawPie(canvas, rows) { - if (!canvas) return; + function detailCanvasSize() { + const wrap = el('graph-detail-canvas-wrap'); + const maxW = wrap ? wrap.clientWidth - 8 : window.innerWidth - 48; + return { + w: Math.max(640, Math.min(1280, maxW)), + h: Math.max(380, window.innerHeight - 200), + }; + } + + function redrawDetailChart(highlightIdx) { + const state = detailState; + if (!state || state.type !== 'line' || !state.canvas) return; + const canvas = state.canvas; + const meta = drawLineChart(canvas, state.series, state.def.color, { + interactive: true, + title: state.def.title, + }); + if (meta && highlightIdx != null && meta.points[highlightIdx]) { + const ctx = canvas.getContext('2d'); + const pt = meta.points[highlightIdx]; + ctx.strokeStyle = 'rgba(148,163,184,0.55)'; + ctx.lineWidth = 1; + ctx.setLineDash([4, 4]); + ctx.beginPath(); + ctx.moveTo(pt.x, meta.padT); + ctx.lineTo(pt.x, meta.h - meta.padT); + ctx.stroke(); + ctx.setLineDash([]); + ctx.beginPath(); + ctx.fillStyle = state.def.color || '#3d8bfd'; + ctx.strokeStyle = '#fff'; + ctx.lineWidth = 2; + ctx.arc(pt.x, pt.y, 7, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + } + detailState.meta = meta; + } + + function drawPie(canvas, rows, opts) { + if (!canvas) return null; + const interactive = opts && opts.interactive; + const highlightIdx = opts && opts.highlightIdx; const ctx = canvas.getContext('2d'); - if (!ctx) return; + if (!ctx) return null; const w = canvas.width; const h = canvas.height; ctx.clearRect(0, 0, w, h); + if (interactive) { + ctx.fillStyle = 'rgba(90,110,140,0.12)'; + ctx.fillRect(0, 0, w, h); + } const data = Array.isArray(rows) ? rows.filter((r) => Number(r.value) > 0) : []; if (!data.length) { ctx.fillStyle = '#94a3b8'; ctx.font = '12px system-ui, sans-serif'; ctx.fillText('No data', 24, h / 2); - return; + return { slices: [], cx: 0, cy: 0, r: 0, data: [], total: 0, w, h }; } const total = data.reduce((s, r) => s + Number(r.value || 0), 0); - const cx = w * 0.35; + const cx = w * (interactive ? 0.38 : 0.35); const cy = h * 0.5; - const r = Math.min(w, h) * 0.32; + const r = Math.min(w, h) * (interactive ? 0.36 : 0.32); let angle = -Math.PI / 2; + const slices = []; data.forEach((row, i) => { const slice = (Number(row.value) / total) * Math.PI * 2; + const start = angle; + const end = angle + slice; + slices.push({ start, end, idx: i, row, color: COLORS[i % COLORS.length] }); ctx.beginPath(); ctx.moveTo(cx, cy); ctx.fillStyle = COLORS[i % COLORS.length]; - ctx.arc(cx, cy, r, angle, angle + slice); + if (interactive && highlightIdx === i) { + ctx.shadowColor = 'rgba(0,0,0,0.35)'; + ctx.shadowBlur = 10; + } else { + ctx.shadowBlur = 0; + } + ctx.arc(cx, cy, r, start, end); ctx.closePath(); ctx.fill(); - angle += slice; + ctx.shadowBlur = 0; + angle = end; }); - let ly = 16; + const legendX = w * (interactive ? 0.58 : 0.62); + let ly = interactive ? 28 : 16; data.forEach((row, i) => { - ctx.fillStyle = COLORS[i % COLORS.length]; - ctx.fillRect(w * 0.62, ly, 10, 10); - ctx.fillStyle = '#cbd5e1'; - ctx.font = '11px system-ui, sans-serif'; + const color = COLORS[i % COLORS.length]; + ctx.fillStyle = color; + ctx.fillRect(legendX, ly, interactive ? 14 : 10, interactive ? 14 : 10); + ctx.fillStyle = highlightIdx === i && interactive ? '#f8fafc' : '#cbd5e1'; + ctx.font = interactive ? '13px system-ui, sans-serif' : '11px system-ui, sans-serif'; const pct = total > 0 ? Math.round((100 * Number(row.value)) / total) : 0; - ctx.fillText(String(row.label || '') + ' (' + pct + '%)', w * 0.62 + 16, ly + 9); - ly += 18; + const label = String(row.label || '') + ' (' + pct + '%)'; + ctx.fillText(label, legendX + (interactive ? 22 : 16), ly + (interactive ? 12 : 9)); + ly += interactive ? 22 : 18; }); + return { slices, cx, cy, r, data, total, w, h }; + } + + function redrawDetailPie(highlightIdx) { + const state = detailState; + if (!state || state.type !== 'pie' || !state.canvas) return; + const meta = drawPie(state.canvas, state.series, { interactive: true, highlightIdx: highlightIdx }); + detailState.meta = meta; + } + + function pieSliceAt(meta, mx, my) { + if (!meta || !meta.slices.length || meta.total <= 0) return -1; + const dx = mx - meta.cx; + const dy = my - meta.cy; + if (Math.sqrt(dx * dx + dy * dy) > meta.r) return -1; + let rel = Math.atan2(dy, dx) + Math.PI / 2; + if (rel < 0) rel += Math.PI * 2; + if (rel >= Math.PI * 2) rel -= Math.PI * 2; + let acc = 0; + for (let i = 0; i < meta.data.length; i++) { + const slice = (Number(meta.data[i].value) / meta.total) * Math.PI * 2; + if (rel >= acc && rel < acc + slice) return i; + acc += slice; + } + return -1; } function renderBreakdownList(target, rows) { @@ -286,12 +406,34 @@ }); } + function detachDetailResize() { + if (detailState && detailState.resizeHandler) { + window.removeEventListener('resize', detailState.resizeHandler); + } + } + + function attachDetailResize(canvas, redrawFn) { + detachDetailResize(); + const handler = function () { + if (!detailState || detailState.canvas !== canvas) return; + const size = detailCanvasSize(); + canvas.width = size.w; + canvas.height = size.h; + redrawFn(null); + }; + if (detailState) detailState.resizeHandler = handler; + window.addEventListener('resize', handler, { passive: true }); + } + function closeGraphDetail() { + detachDetailResize(); const overlay = el('graph-detail-overlay'); if (overlay) overlay.hidden = true; detailState = null; const tip = el('graph-detail-tooltip'); if (tip) tip.hidden = true; + const statsEl = el('graph-detail-stats'); + if (statsEl) statsEl.textContent = ''; } function openGraphDetail(card) { @@ -320,17 +462,46 @@ if (def.type === 'line' && canvasEl) { canvasEl.hidden = false; - canvasEl.width = 960; - canvasEl.height = 420; - const meta = drawLineChart(canvasEl, fieldVal, def.color, { interactive: true }); - detailState = { type: 'line', meta, def }; - attachLineHover(canvasEl, meta, def); + const size = detailCanvasSize(); + canvasEl.width = size.w; + canvasEl.height = size.h; + detailState = { + type: 'line', + def, + series: fieldVal, + canvas: canvasEl, + meta: null, + }; + redrawDetailChart(null); + attachLineHover(canvasEl, def); + attachDetailResize(canvasEl, redrawDetailChart); + const statsEl = el('graph-detail-stats'); + if (statsEl) { + statsEl.textContent = + (fieldVal && fieldVal.length ? fieldVal.length + ' points · ' : '') + + 'Move pointer over chart for values'; + } } else if (def.type === 'pie' && canvasEl) { canvasEl.hidden = false; - canvasEl.width = 720; - canvasEl.height = 400; - drawPie(canvasEl, fieldVal); - detailState = { type: 'pie' }; + const size = detailCanvasSize(); + canvasEl.width = size.w; + canvasEl.height = size.h; + detailState = { + type: 'pie', + def, + series: fieldVal, + canvas: canvasEl, + meta: null, + }; + redrawDetailPie(null); + attachPieHover(canvasEl, def); + attachDetailResize(canvasEl, redrawDetailPie); + const statsEl = el('graph-detail-stats'); + if (statsEl) { + const n = Array.isArray(fieldVal) ? fieldVal.filter((r) => Number(r.value) > 0).length : 0; + statsEl.textContent = + (n ? n + ' segments · ' : '') + 'Hover slices for value breakdown'; + } } else if (def.type === 'breakdown' || def.type === 'toplist') { const wrap = document.createElement('div'); wrap.className = 'graph-detail-table graph-breakdown'; @@ -360,43 +531,113 @@ overlay.hidden = false; } - function attachLineHover(canvas, meta, def) { + function valueUnit(def) { + if (!def || !def.title) return ''; + if (def.title.indexOf('kbps') >= 0) return ' kbps'; + if (def.title.indexOf('duration') >= 0) return ' s'; + return ''; + } + + function attachLineHover(canvas, def) { const tip = el('graph-detail-tooltip'); + const statsEl = el('graph-detail-stats'); const wrap = canvas && canvas.closest('.graph-detail-canvas-wrap'); - if (!tip || !wrap || !meta || !meta.points.length) return; + if (!wrap) return; canvas.onmousemove = function (ev) { + const meta = detailState && detailState.meta; + if (!meta || !meta.points.length) return; const rect = canvas.getBoundingClientRect(); const scaleX = canvas.width / rect.width; const x = (ev.clientX - rect.left) * scaleX; let best = null; let bestD = Infinity; - meta.points.forEach((pt) => { + let bestIdx = -1; + meta.points.forEach((pt, idx) => { const d = Math.abs(pt.x - x); if (d < bestD) { bestD = d; best = pt; + bestIdx = idx; } }); - if (!best || bestD > 28) { - tip.hidden = true; + if (!best || bestD > 32) { + if (tip) tip.hidden = true; + redrawDetailChart(null); return; } + redrawDetailChart(bestIdx); const raw = best.raw || {}; - tip.hidden = false; - const unit = - def && def.title && def.title.indexOf('kbps') >= 0 - ? ' kbps' - : def && def.title && def.title.indexOf('duration') >= 0 - ? ' s' - : ''; - tip.textContent = String(raw.x || '') + ': ' + Number(raw.y || 0) + unit; - const wrapRect = wrap.getBoundingClientRect(); - tip.style.left = ev.clientX - wrapRect.left + 12 + 'px'; - tip.style.top = ev.clientY - wrapRect.top - 8 + 'px'; + const unit = valueUnit(def); + const label = String(raw.x || '') + ' → ' + Number(raw.y || 0) + unit; + if (tip) { + tip.hidden = false; + tip.textContent = label; + const wrapRect = wrap.getBoundingClientRect(); + tip.style.left = ev.clientX - wrapRect.left + 14 + 'px'; + tip.style.top = ev.clientY - wrapRect.top - 10 + 'px'; + } + if (statsEl) { + statsEl.textContent = def.title + ' · ' + label; + } }; canvas.onmouseleave = function () { - tip.hidden = true; + if (tip) tip.hidden = true; + redrawDetailChart(null); + if (statsEl && detailState && detailState.series) { + statsEl.textContent = + detailState.series.length + ' points · Move pointer over chart for values'; + } + }; + } + + function attachPieHover(canvas, def) { + const tip = el('graph-detail-tooltip'); + const statsEl = el('graph-detail-stats'); + const wrap = canvas && canvas.closest('.graph-detail-canvas-wrap'); + if (!wrap) return; + + canvas.onmousemove = function (ev) { + const meta = detailState && detailState.meta; + if (!meta || !meta.slices.length) return; + const rect = canvas.getBoundingClientRect(); + const scaleX = canvas.width / rect.width; + const scaleY = canvas.height / rect.height; + const mx = (ev.clientX - rect.left) * scaleX; + const my = (ev.clientY - rect.top) * scaleY; + const idx = pieSliceAt(meta, mx, my); + if (idx < 0) { + if (tip) tip.hidden = true; + redrawDetailPie(null); + if (statsEl) { + statsEl.textContent = + meta.data.length + ' segments · Hover slices for value breakdown'; + } + return; + } + redrawDetailPie(idx); + const row = meta.data[idx] || {}; + const pct = meta.total > 0 ? Math.round((100 * Number(row.value)) / meta.total) : 0; + const label = + String(row.label || '') + ': ' + Number(row.value || 0) + ' (' + pct + '%)'; + if (tip) { + tip.hidden = false; + tip.textContent = label; + const wrapRect = wrap.getBoundingClientRect(); + tip.style.left = ev.clientX - wrapRect.left + 14 + 'px'; + tip.style.top = ev.clientY - wrapRect.top - 10 + 'px'; + } + if (statsEl) { + statsEl.textContent = def.title + ' · ' + label; + } + }; + canvas.onmouseleave = function () { + if (tip) tip.hidden = true; + redrawDetailPie(null); + if (statsEl && detailState && detailState.series) { + const n = detailState.series.filter((r) => Number(r.value) > 0).length; + statsEl.textContent = n + ' segments · Hover slices for value breakdown'; + } }; } diff --git a/examples/crash_reporter/backend/scripts/ra_udp_debug.sh b/examples/crash_reporter/backend/scripts/ra_udp_debug.sh new file mode 100755 index 0000000..52e0817 --- /dev/null +++ b/examples/crash_reporter/backend/scripts/ra_udp_debug.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# UDP / WireGuard data-plane debug from a Linux laptop (task 4). +# HTTP control plane must work first (ra_e2e_cli.sh). See docs/REMOTE_ACCESS_VALIDATION.md +# and proposals Appendix 1. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck source=ra_lib.sh +source "$ROOT/scripts/ra_lib.sh" + +WG_HOST="${WG_HOST:-ra.apps.f0xx.org}" +WG_PORT="${WG_PORT:-51820}" +BE_WG_IP="${BE_WG_IP:-10.66.66.1}" +DEVICE_ID="${RA_DEVICE_ID:-ra-udp-$(hostname -s | tr ' ' '-')}" +CONF="/tmp/ra-udp-${DEVICE_ID}.conf" +APPLY="${RA_UDP_APPLY:-0}" +SKIP_HTTP="${RA_UDP_SKIP_HTTP:-0}" + +usage() { + cat <&2; usage; exit 2 ;; + esac +done + +export RA_DEVICE_ID="$DEVICE_ID" +export RA_RANDOM_FILE="/tmp/androidcast-ra-${DEVICE_ID}.random" + +note() { echo "== $*"; } +ok() { echo "OK $*"; } +warn() { echo "WARN $*"; } +fail() { echo "FAIL $*"; exit 1; } + +note "RA UDP debug — device_id=$DEVICE_ID" +note "BASE=$(ra_base)" +note "WG endpoint ${WG_HOST}:${WG_PORT}" + +note "1. DNS (CNAME chain)" +APPS_IP="$(dig +short apps.f0xx.org A 2>/dev/null | head -1)" +RA_IP="$(dig +short "$WG_HOST" A 2>/dev/null | head -1)" +echo "apps.f0xx.org A: ${APPS_IP:-?}" +echo "${WG_HOST} A: ${RA_IP:-?}" +if [[ -n "$APPS_IP" && -n "$RA_IP" && "$APPS_IP" == "$RA_IP" ]]; then + ok "ra host resolves same as apps (CNAME OK)" +else + warn "A records differ or missing — check CNAME/DNS" +fi + +note "2. UDP reachability" +if command -v nc >/dev/null 2>&1; then + if nc -zvu -w 3 "$WG_HOST" "$WG_PORT" 2>&1 | grep -qiE 'succeeded|open'; then + ok "UDP ${WG_PORT} appears open (nc)" + else + warn "UDP ${WG_PORT} not confirmed open — FE DNAT / firewall?" + fi +else + warn "nc not found — skip UDP probe" +fi + +if [[ "$SKIP_HTTP" -eq 0 ]]; then + note "3. HTTP control plane (quick)" + if CRASHES_BASE="$(ra_base)" bash "$ROOT/scripts/test_remote_access_api.sh" >/dev/null 2>&1; then + ok "test_remote_access_api.sh" + else + warn "API smoke failed — fix HTTP before UDP" + fi +else + note "3. HTTP skipped" +fi + +note "4. Operator: whitelist + open session (if not already)" +COOKIE_JAR="$(mktemp)" +trap 'rm -f "$COOKIE_JAR"; sudo wg-quick down "$CONF" 2>/dev/null || true' EXIT +ra_admin_login "$COOKIE_JAR" +ra_admin_post "$COOKIE_JAR" whitelist "{\"device_id\":\"$DEVICE_ID\",\"whitelisted\":true}" >/dev/null +ra_admin_post "$COOKIE_JAR" open_session "{\"device_id\":\"$DEVICE_ID\"}" >/dev/null +ok "whitelist + open_session" + +note "5. Device poll → connect (may wait min_poll_interval_s)" +export RA_DEVICE_ID="$DEVICE_ID" +bash "$ROOT/scripts/ra_device_sim.sh" poll --until connect --device-id "$DEVICE_ID" --base "$(ra_base)" > /tmp/ra-udp-connect.json +grep -q '"action":"connect"' /tmp/ra-udp-connect.json || fail "no connect in response" +ok "heartbeat connect" + +note "6. Build wg-quick config" +RESP="$(cat /tmp/ra-udp-connect.json)" +ra_wg_quick_from_connect "$RESP" >"$CONF" +echo "--- $CONF ---" +sed 's/PrivateKey = .*/PrivateKey = /' "$CONF" +ok "wrote $CONF" + +if [[ "$APPLY" -eq 1 ]]; then + command -v wg-quick >/dev/null 2>&1 || fail "wg-quick not installed" + note "7. Bring up tunnel" + sudo wg-quick up "$CONF" + sudo wg show + note "8. Ping BE wg (${BE_WG_IP})" + if ping -c 3 -W 2 "$BE_WG_IP"; then + ok "ping ${BE_WG_IP}" + else + warn "ping failed — routing or AllowedIPs (expected 10.66.66.1/32)" + fi + note "9. Teardown" + sudo wg-quick down "$CONF" + ok "wg-quick down" +else + note "7–9. Skipped tunnel (pass --apply or RA_UDP_APPLY=1 to test handshake)" + echo " sudo wg-quick up $CONF && ping -c3 $BE_WG_IP && sudo wg-quick down $CONF" +fi + +note "UDP debug done" diff --git a/examples/crash_reporter/backend/views/layout.php b/examples/crash_reporter/backend/views/layout.php index 0c93728..a8ee79d 100644 --- a/examples/crash_reporter/backend/views/layout.php +++ b/examples/crash_reporter/backend/views/layout.php @@ -155,19 +155,9 @@
-

Crash console

-

Anonymous Android Cast crash ingest is active. Use Reports to analyze grouped fingerprints.

-
    -
  • Upload API: /api/upload.php
  • -
  • Schema: schema_version: 1
  • -
  • Ticket upload API: /api/ticket_upload.php
  • - -
  • Remote access dashboard: Remote access · API /api/remote_access.php
  • - - -
  • Access control: Users & roles · API /api/rbac.php
  • - -
+

Issues console

+

Crash ingest and operator tools. Open Issues for grouped fingerprints, or use the links below.

+ @@ -362,7 +352,10 @@