diff --git a/NODE-ANALYTICS-PLAN.md b/NODE-ANALYTICS-PLAN.md new file mode 100644 index 00000000..3f965e2d --- /dev/null +++ b/NODE-ANALYTICS-PLAN.md @@ -0,0 +1,251 @@ +# Node Analytics Page β€” Implementation Plan + +## Overview +A dedicated per-node analytics page (`#/nodes/:pubkey/analytics`) showing charts, breakdowns, and computed stats. Linked from node sidebar and full-screen detail views. + +## Route & Navigation +- **Hash route:** `#/nodes/:pubkey/analytics` +- **Entry points:** + - Sidebar detail: "πŸ“Š Analytics" button next to "πŸ“‹ Copy URL" + - Full-screen detail: same button placement + - Direct URL (shareable) +- **Back navigation:** "← Back to node" link returns to `#/nodes/:pubkey` + +## API Endpoint + +### `GET /api/nodes/:pubkey/analytics?days=7` + +Returns all data needed for the page in a single request. Server computes aggregations in SQLite for efficiency. + +```json +{ + "node": { "public_key": "...", "name": "...", "role": "..." }, + "timeRange": { "from": "ISO", "to": "ISO", "days": 7 }, + "activityTimeline": [ + { "bucket": "2026-03-19T10:00:00Z", "count": 5 } + ], + "snrTrend": [ + { "timestamp": "ISO", "snr": 11.5, "rssi": -44, "observer_id": "...", "observer_name": "..." } + ], + "packetTypeBreakdown": [ + { "payload_type": 4, "label": "Advert", "count": 120 }, + { "payload_type": 5, "label": "Channel Msg", "count": 45 } + ], + "observerCoverage": [ + { "observer_id": "...", "observer_name": "...", "packetCount": 200, "avgSnr": 8.5, "avgRssi": -60, "firstSeen": "ISO", "lastSeen": "ISO" } + ], + "hopDistribution": [ + { "hops": 1, "count": 150 }, + { "hops": 2, "count": 30 } + ], + "peerInteractions": [ + { "peer_key": "...", "peer_name": "...", "messageCount": 15, "lastContact": "ISO" } + ], + "computedStats": { + "availabilityPct": 92.5, + "longestSilenceMs": 14400000, + "longestSilenceStart": "ISO", + "signalGrade": "B+", + "snrMean": 8.2, + "snrStdDev": 3.1, + "relayPct": 22.5, + "totalPackets": 450, + "uniqueObservers": 3, + "uniquePeers": 8, + "avgPacketsPerDay": 64.3 + }, + "uptimeHeatmap": [ + { "dayOfWeek": 0, "hour": 14, "count": 12 } + ] +} +``` + +### Server Implementation (`server.js`) + +Add route handler at `/api/nodes/:pubkey/analytics`. All queries use the same LIKE-based matching as existing `getNodeHealth()`. Key queries: + +1. **activityTimeline** β€” `SELECT strftime('%Y-%m-%dT%H:00:00Z', timestamp) as bucket, COUNT(*) as count FROM packets WHERE ... AND timestamp > ? GROUP BY bucket ORDER BY bucket` +2. **snrTrend** β€” `SELECT timestamp, snr, rssi, observer_id, observer_name FROM packets WHERE ... AND snr IS NOT NULL ORDER BY timestamp` (raw points, chart.js handles rendering) +3. **packetTypeBreakdown** β€” `SELECT payload_type, COUNT(*) as count FROM packets WHERE ... GROUP BY payload_type` +4. **observerCoverage** β€” `SELECT observer_id, observer_name, COUNT(*), AVG(snr), AVG(rssi), MIN(timestamp), MAX(timestamp) FROM packets WHERE ... GROUP BY observer_id ORDER BY COUNT(*) DESC` +5. **hopDistribution** β€” Parse `path_json` in JS, count hop lengths +6. **peerInteractions** β€” Parse `decoded_json`, extract sender/recipient pubkeys and names, aggregate +7. **uptimeHeatmap** β€” `SELECT strftime('%w', timestamp) as dow, strftime('%H', timestamp) as hour, COUNT(*) FROM packets WHERE ... GROUP BY dow, hour` +8. **computedStats** β€” Derived from above data: + - `availabilityPct`: count distinct hours with packets / total hours in range Γ— 100 + - `longestSilenceMs`: iterate timestamps, find max gap + - `signalGrade`: A (snr>15, stddev<2), B (snr>8), C (snr>3), D (snr<=3) + - `relayPct`: packets with hop count > 1 / total with path data Γ— 100 + +Add a helper function `getNodeAnalytics(pubkey, days)` in `db.js` to keep it organized. + +## Frontend + +### New File: `public/node-analytics.js` + +IIFE pattern matching existing pages. Registers with the router for `#/nodes/:pubkey/analytics`. + +### Layout + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ ← Back to SomeNodeName β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Availabilityβ”‚ β”‚ Signal Gradeβ”‚ β”‚ Packets/Dayβ”‚ β”‚ +β”‚ β”‚ 92.5% β”‚ β”‚ B+ β”‚ β”‚ 64.3 β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Observers β”‚ β”‚ Relay % β”‚ β”‚ Longest β”‚ β”‚ +β”‚ β”‚ 3 β”‚ β”‚ 22.5% β”‚ β”‚ Silence 4h β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Activity Timeline (bar chart, hourly) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ SNR Trend (line) β”‚ β”‚ Packet Types (pie) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Observer Coverage β”‚ β”‚ Hop Distribution β”‚ β”‚ +β”‚ β”‚ (horizontal bar) β”‚ β”‚ (bar chart) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Uptime Heatmap (7Γ—24 grid, GitHub-style) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Peer Interactions (ranked list) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Time Range Selector +- Buttons: 24h | 7d | 30d | All +- Default: 7d +- Reloads data via API when changed + +### Chart Library +- **Chart.js v4** from CDN (unpkg): `https://unpkg.com/chart.js@4/dist/chart.umd.min.js` +- Add ` + Skip to content @@ -75,15 +76,16 @@
- - - - - - - - - - + + + + + + + + + + + diff --git a/public/node-analytics.js b/public/node-analytics.js new file mode 100644 index 00000000..4bf90692 --- /dev/null +++ b/public/node-analytics.js @@ -0,0 +1,295 @@ +/* === MeshCore Analyzer β€” node-analytics.js === */ +'use strict'; +(function () { + const PAYLOAD_LABELS = { 0: 'Request', 1: 'Response', 2: 'Direct Msg', 3: 'ACK', 4: 'Advert', 5: 'Channel Msg', 7: 'Anon Req', 8: 'Path', 9: 'Trace', 11: 'Control' }; + const CHART_COLORS = ['#4a9eff', '#ff6b6b', '#51cf66', '#fcc419', '#cc5de8', '#20c997', '#ff922b', '#845ef7', '#f06595', '#339af0']; + const GRADE_COLORS = { A: '#51cf66', 'A-': '#51cf66', 'B+': '#339af0', B: '#339af0', C: '#fcc419', D: '#ff6b6b' }; + const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + + let charts = []; + let currentDays = 7; + let currentPubkey = null; + + function destroyCharts() { + charts.forEach(c => { try { c.destroy(); } catch {} }); + charts = []; + } + + function chartDefaults() { + const style = getComputedStyle(document.documentElement); + Chart.defaults.color = style.getPropertyValue('--text-muted').trim() || '#6b7280'; + Chart.defaults.borderColor = style.getPropertyValue('--border').trim() || '#e2e5ea'; + } + + function formatSilence(ms) { + if (!ms) return 'β€”'; + const h = Math.floor(ms / 3600000); + const m = Math.floor((ms % 3600000) / 60000); + if (h > 24) return Math.floor(h / 24) + 'd ' + (h % 24) + 'h'; + if (h > 0) return h + 'h ' + m + 'm'; + return m + 'm'; + } + + async function loadAnalytics(container, pubkey, days) { + currentPubkey = pubkey; + currentDays = days; + destroyCharts(); + chartDefaults(); + + container.innerHTML = '
Loading analytics…
'; + + let data; + try { + data = await api('/nodes/' + encodeURIComponent(pubkey) + '/analytics?days=' + days); + } catch (e) { + container.innerHTML = '
Failed to load analytics: ' + escapeHtml(e.message) + '
'; + return; + } + + const n = data.node; + const s = data.computedStats; + const nodeName = escapeHtml(n.name || n.public_key.slice(0, 12)); + + container.innerHTML = ` +
+
+ ← Back to ${nodeName} +

πŸ“Š ${nodeName} β€” Analytics

+
${n.role || 'Unknown role'} Β· ${s.totalPackets} packets in ${days}d window
+
+ +
+ + + + +
+ +
+
+
Availability
+
${s.availabilityPct}%
+
+
+
Signal Grade
+
${s.signalGrade}
+
+
+
Packets / Day
+
${s.avgPacketsPerDay}
+
+
+
Observers
+
${s.uniqueObservers}
+
+
+
Relay %
+
${s.relayPct}%
+
+
+
Longest Silence
+
${formatSilence(s.longestSilenceMs)}
+
+
+ +
+
+

Activity Timeline

+ +
+
+

SNR Trend

+ +
+
+

Packet Types

+ +
+
+

Observer Coverage

+ +
+
+

Hop Distribution

+ +
+
+

Uptime Heatmap

+
+
+ ${data.peerInteractions.length ? `
+

Peer Interactions

+ + + ${data.peerInteractions.map(p => ` + + + + `).join('')} +
PeerMessagesLast Contact
${escapeHtml(p.peer_name)}${p.messageCount}${timeAgo(p.lastContact)}
+
` : ''} +
+
`; + + // Time range buttons + container.querySelectorAll('#timeRangeBtns button').forEach(btn => { + btn.addEventListener('click', () => { + const d = Number(btn.dataset.days); + loadAnalytics(container, pubkey, d); + }); + }); + + // Build charts + buildActivityChart(data); + buildSnrChart(data); + buildPacketTypeChart(data); + buildObserverChart(data); + buildHopChart(data); + buildHeatmap(data); + } + + function buildActivityChart(data) { + const ctx = document.getElementById('activityChart'); + if (!ctx) return; + const tl = data.activityTimeline; + const c = new Chart(ctx, { + type: 'bar', + data: { + labels: tl.map(b => { + const d = new Date(b.bucket); + return currentDays <= 3 ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : d.toLocaleDateString([], { month: 'short', day: 'numeric' }); + }), + datasets: [{ label: 'Packets', data: tl.map(b => b.count), backgroundColor: 'rgba(74,158,255,0.5)', borderColor: '#4a9eff', borderWidth: 1 }] + }, + options: { responsive: true, plugins: { legend: { display: false } }, scales: { x: { ticks: { maxTicksAutoSkip: true, maxRotation: 45 } }, y: { beginAtZero: true } } } + }); + charts.push(c); + } + + function buildSnrChart(data) { + const ctx = document.getElementById('snrChart'); + if (!ctx) return; + // Group by observer + const byObs = {}; + data.snrTrend.forEach(p => { + const key = p.observer_id || 'unknown'; + if (!byObs[key]) byObs[key] = { name: p.observer_name || key, points: [] }; + byObs[key].points.push({ x: new Date(p.timestamp), y: p.snr }); + }); + const datasets = Object.values(byObs).map((obs, i) => ({ + label: obs.name, data: obs.points.map(p => p.y), borderColor: CHART_COLORS[i % CHART_COLORS.length], + backgroundColor: 'transparent', pointRadius: 1, borderWidth: 1.5, tension: 0.3 + })); + // Use labels from the observer with most points + const longestObs = Object.values(byObs).sort((a, b) => b.points.length - a.points.length)[0]; + const labels = longestObs ? longestObs.points.map(p => { + const d = p.x; + return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + }) : []; + const c = new Chart(ctx, { + type: 'line', + data: { labels, datasets }, + options: { + responsive: true, + scales: { x: { display: false }, y: { title: { display: true, text: 'SNR (dB)' } } }, + plugins: { legend: { position: 'bottom', labels: { boxWidth: 12, font: { size: 10 } } } } + } + }); + charts.push(c); + } + + function buildPacketTypeChart(data) { + const ctx = document.getElementById('packetTypeChart'); + if (!ctx) return; + const items = data.packetTypeBreakdown; + const c = new Chart(ctx, { + type: 'doughnut', + data: { + labels: items.map(i => PAYLOAD_LABELS[i.payload_type] || 'Type ' + i.payload_type), + datasets: [{ data: items.map(i => i.count), backgroundColor: items.map((_, i) => CHART_COLORS[i % CHART_COLORS.length]) }] + }, + options: { responsive: true, plugins: { legend: { position: 'bottom', labels: { boxWidth: 12, font: { size: 10 } } } } } + }); + charts.push(c); + } + + function buildObserverChart(data) { + const ctx = document.getElementById('observerChart'); + if (!ctx) return; + const obs = data.observerCoverage; + const c = new Chart(ctx, { + type: 'bar', + data: { + labels: obs.map(o => (o.observer_name || o.observer_id || '?').slice(0, 20)), + datasets: [{ label: 'Packets', data: obs.map(o => o.packetCount), backgroundColor: obs.map(o => { + const snr = o.avgSnr || 0; + const alpha = Math.min(1, Math.max(0.3, snr / 20)); + return `rgba(74,158,255,${alpha})`; + }) }] + }, + options: { indexAxis: 'y', responsive: true, plugins: { legend: { display: false } }, scales: { x: { beginAtZero: true } } } + }); + charts.push(c); + } + + function buildHopChart(data) { + const ctx = document.getElementById('hopChart'); + if (!ctx) return; + const hops = data.hopDistribution; + const c = new Chart(ctx, { + type: 'bar', + data: { + labels: hops.map(h => h.hops + ' hop' + (h.hops !== '1' ? 's' : '')), + datasets: [{ label: 'Packets', data: hops.map(h => h.count), backgroundColor: 'rgba(81,207,102,0.6)', borderColor: '#51cf66', borderWidth: 1 }] + }, + options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true } } } + }); + charts.push(c); + } + + function buildHeatmap(data) { + const grid = document.getElementById('heatmapGrid'); + if (!grid) return; + // Build lookup + const lookup = {}; + let maxCount = 1; + data.uptimeHeatmap.forEach(h => { + const key = h.dayOfWeek + '-' + h.hour; + lookup[key] = h.count; + if (h.count > maxCount) maxCount = h.count; + }); + + // Header row + grid.innerHTML = '
'; + for (let h = 0; h < 24; h++) { + grid.innerHTML += `
${h}
`; + } + // Day rows + for (let d = 0; d < 7; d++) { + grid.innerHTML += `
${DAY_NAMES[d]}
`; + for (let h = 0; h < 24; h++) { + const count = lookup[d + '-' + h] || 0; + const intensity = count / maxCount; + const bg = count === 0 ? 'var(--card-bg)' : `rgba(74,158,255,${0.15 + intensity * 0.85})`; + grid.innerHTML += `
`; + } + } + } + + function init(container, routeParam) { + // routeParam is "PUBKEY/analytics" + if (!routeParam || !routeParam.endsWith('/analytics')) { + container.innerHTML = '
Invalid analytics URL
'; + return; + } + const pubkey = routeParam.slice(0, -'/analytics'.length); + loadAnalytics(container, pubkey, 7); + } + + function destroy() { + destroyCharts(); + currentPubkey = null; + } + + registerPage('node-analytics', { init, destroy }); +})(); diff --git a/public/nodes.js b/public/nodes.js index 80f4d0ea..c478b600 100644 --- a/public/nodes.js +++ b/public/nodes.js @@ -160,6 +160,7 @@
+ πŸ“Š Analytics
`; // Map @@ -428,6 +429,7 @@
+ πŸ“Š Analytics
diff --git a/public/style.css b/public/style.css index 4868a639..af7eba5b 100644 --- a/public/style.css +++ b/public/style.css @@ -1348,3 +1348,25 @@ tr[data-hops]:hover { background: rgba(59,130,246,0.1); } } .meshcore-marker { background: none !important; border: none !important; } + +/* === Node Analytics === */ +.analytics-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 24px; } +.analytics-stat-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 16px; text-align: center; } +.analytics-stat-label { font-size: 11px; text-transform: uppercase; letter-spacing: .5px; color: var(--text-muted); margin-bottom: 4px; } +.analytics-stat-value { font-size: 28px; font-weight: 700; } +.analytics-charts { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 24px; } +.analytics-chart-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 16px; } +.analytics-chart-card.full { grid-column: 1 / -1; } +.analytics-chart-card h4 { font-size: 12px; text-transform: uppercase; letter-spacing: .5px; color: var(--text-muted); margin-bottom: 12px; } +.analytics-heatmap { display: grid; grid-template-columns: 40px repeat(24, 1fr); gap: 2px; } +.analytics-heatmap-cell { aspect-ratio: 1; border-radius: 2px; cursor: default; } +.analytics-heatmap-label { font-size: 10px; color: var(--text-muted); display: flex; align-items: center; } +.analytics-time-range { display: flex; gap: 8px; margin-bottom: 16px; } +.analytics-time-range button { padding: 4px 12px; border-radius: 4px; border: 1px solid var(--border); background: var(--card-bg); color: var(--text); cursor: pointer; font-size: 12px; } +.analytics-time-range button.active { background: var(--accent); color: white; border-color: var(--accent); } +.analytics-peer-table { width: 100%; border-collapse: collapse; font-size: 13px; } +.analytics-peer-table th { text-align: left; padding: 6px 8px; border-bottom: 2px solid var(--border); color: var(--text-muted); font-size: 11px; text-transform: uppercase; } +.analytics-peer-table td { padding: 6px 8px; border-bottom: 1px solid var(--border); } +.analytics-peer-table tr:hover td { background: var(--card-bg); } +@media (max-width: 768px) { .analytics-stats { grid-template-columns: repeat(2, 1fr); } .analytics-charts { grid-template-columns: 1fr; } } +@media (max-width: 480px) { .analytics-stats { grid-template-columns: 1fr; } } diff --git a/server.js b/server.js index 59f3e194..58fbcf8b 100644 --- a/server.js +++ b/server.js @@ -1266,6 +1266,13 @@ app.get('/api/nodes/:pubkey/health', (req, res) => { res.json(health); }); +app.get('/api/nodes/:pubkey/analytics', (req, res) => { + const days = Math.min(Math.max(Number(req.query.days) || 7, 1), 365); + const data = db.getNodeAnalytics(req.params.pubkey, days); + if (!data) return res.status(404).json({ error: 'Not found' }); + res.json(data); +}); + // Subpath frequency analysis app.get('/api/analytics/subpaths', (req, res) => { const minLen = Math.max(2, Number(req.query.minLen) || 2);