1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 04:38:53 +03:00

sync with BE requested by AI Auto Agent

This commit is contained in:
Anton Afanasyeu
2026-06-05 12:54:41 +02:00
parent c5857e65f6
commit 617670ad96
11 changed files with 650 additions and 90 deletions

View File

@@ -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;

View File

@@ -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",

View File

@@ -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": "Группы",

View File

@@ -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';
}
};
}

View File

@@ -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 <<EOF
Usage: $0 [--apply] [--device-id ID] [--skip-http]
Env:
CRASHES_BASE, ADMIN_USER, ADMIN_PASS — operator API
RA_DEVICE_ID, WG_HOST (default ra.apps.f0xx.org), WG_PORT (51820)
BE_WG_IP (10.66.66.1) — ping target on tunnel
RA_UDP_APPLY=1 — same as --apply (wg-quick up)
Steps: DNS → UDP port → optional HTTP E2E → connect JSON → wg conf → optional tunnel → ping
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--apply) APPLY=1; shift ;;
--device-id) DEVICE_ID="$2"; shift 2 ;;
--skip-http) SKIP_HTTP=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown: $1" >&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 = <redacted>/' "$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 "79. 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"

View File

@@ -155,19 +155,9 @@
</nav>
<main class="main-pane">
<?php if (($view ?? 'home') === 'home'): ?>
<h1 data-i18n="home.title">Crash console</h1>
<p data-i18n="home.intro">Anonymous Android Cast crash ingest is active. Use <strong>Reports</strong> to analyze grouped fingerprints.</p>
<ul>
<li><span data-i18n="home.upload_api">Upload API:</span> <code><?= h(Auth::basePath()) ?>/api/upload.php</code></li>
<li><span data-i18n="home.schema">Schema:</span> <code>schema_version: 1</code></li>
<li><span data-i18n="home.ticket_upload_api">Ticket upload API:</span> <code><?= h(Auth::basePath()) ?>/api/ticket_upload.php</code></li>
<?php if (Rbac::can('remote_access_view')): ?>
<li>Remote access dashboard: <a href="<?= h(Auth::basePath()) ?>/?view=remote_access">Remote access</a> · API <code><?= h(Auth::basePath()) ?>/api/remote_access.php</code></li>
<?php endif; ?>
<?php if (Rbac::canManageRbac()): ?>
<li>Access control: <a href="<?= h(Auth::basePath()) ?>/?view=rbac">Users &amp; roles</a> · API <code><?= h(Auth::basePath()) ?>/api/rbac.php</code></li>
<?php endif; ?>
</ul>
<h1 data-i18n="home.title">Issues console</h1>
<p data-i18n="home.intro">Crash ingest and operator tools. Open <strong>Issues</strong> for grouped fingerprints, or use the links below.</p>
<?php $skip_home_link = true; require __DIR__ . '/partials/console_quick_links.php'; ?>
<?php elseif (($view ?? '') === 'report' && !empty($report)): ?>
<?php require __DIR__ . '/report_detail.php'; ?>
<?php elseif (($view ?? '') === 'ticket' && !empty($ticket)): ?>
@@ -362,7 +352,10 @@
<div id="graph-detail-overlay" class="graph-detail-overlay" hidden>
<div class="graph-detail-panel" role="dialog" aria-modal="true" aria-labelledby="graph-detail-title">
<header class="graph-detail-header">
<h2 id="graph-detail-title">Graph</h2>
<div class="graph-detail-header-text">
<h2 id="graph-detail-title">Graph</h2>
<p id="graph-detail-stats" class="graph-detail-stats muted" aria-live="polite"></p>
</div>
<button type="button" class="btn graph-detail-close" id="graph-detail-close" aria-label="Close full view">Close</button>
</header>
<div class="graph-detail-canvas-wrap">

View File

@@ -3,18 +3,26 @@ declare(strict_types=1);
/**
* Horizontal service links with the same icons as the left nav (8px gap).
* @var string|null $extra_class optional class on <nav>
* @var bool $skip_home_link omit "Console home" (use on home view)
*/
$bp = Auth::basePath();
$proj = '/app/androidcast_project';
$navClass = 'console-quick-links graphs-quick-links' . (isset($extra_class) ? ' ' . $extra_class : '');
$items = [
['Console home', $bp . '/?view=home', 'nav-icon--home'],
['Issues', $bp . '/?view=reports', 'nav-icon--reports'],
['Tickets', $bp . '/?view=tickets', 'nav-icon--tickets'],
['Graphs', $proj . '/graphs/', 'nav-icon--graphs'],
['Builder', $proj . '/build/', 'nav-icon--builder'],
['Hub', $proj . '/', 'nav-icon--home'],
];
$items = [];
if (empty($skip_home_link)) {
$items[] = ['Console home', $bp . '/?view=home', 'nav-icon--home'];
}
$items[] = ['Issues', $bp . '/?view=reports', 'nav-icon--reports'];
$items[] = ['Tickets', $bp . '/?view=tickets', 'nav-icon--tickets'];
if (Rbac::can('remote_access_view')) {
$items[] = ['Remote access', $bp . '/?view=remote_access', 'nav-icon--remote'];
}
if (Rbac::canManageRbac()) {
$items[] = ['Access control', $bp . '/?view=rbac', 'nav-icon--tickets'];
}
$items[] = ['Graphs', $proj . '/graphs/', 'nav-icon--graphs'];
$items[] = ['Builder', $proj . '/build/', 'nav-icon--builder'];
$items[] = ['Hub', $proj . '/', 'nav-icon--home'];
?>
<nav class="<?= h($navClass) ?>" aria-label="Related services">
<?php foreach ($items as [$label, $href, $icon]): ?>