mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-31 17:14:06 +00:00
Add per-node analytics page with charts, stats, and heatmap
- New route: #/nodes/:pubkey/analytics with Chart.js v4 visualizations - Activity timeline (bar), SNR trend (line), packet type breakdown (doughnut) - Observer coverage (horizontal bar), hop distribution (bar) - Uptime heatmap (7x24 CSS grid, GitHub-style) - Peer interactions table with links to node details - Stat cards: availability, signal grade, packets/day, relay %, silence - Time range selector: 24h / 7d / 30d / All - Server: GET /api/nodes/:pubkey/analytics with full aggregation in SQLite - Analytics button added to both sidebar and full-screen node views
This commit is contained in:
@@ -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 `<script>` tag in `index.html` (with cache buster)
|
||||
- Chart.js is ~70KB gzipped, handles all chart types needed
|
||||
|
||||
### Chart Specifications
|
||||
|
||||
1. **Activity Timeline** (bar chart, full width)
|
||||
- X: time buckets (hourly for ≤3d, daily for >3d)
|
||||
- Y: packet count
|
||||
- Color: role color with 50% opacity
|
||||
- Tooltip: exact count + timestamp
|
||||
|
||||
2. **SNR Trend** (line chart, half width)
|
||||
- One line per observer (different colors)
|
||||
- X: timestamp, Y: SNR (dB)
|
||||
- Include a horizontal reference line at 0 dB
|
||||
- Legend shows observer names
|
||||
|
||||
3. **Packet Type Breakdown** (doughnut chart, half width)
|
||||
- Segments: Advert, Channel Msg, DM, ACK, Request, Response, etc.
|
||||
- Colors: match existing PAYLOAD badge colors
|
||||
- Center text: total count
|
||||
|
||||
4. **Observer Coverage** (horizontal bar chart, half width)
|
||||
- Bars: one per observer, length = packet count
|
||||
- Color intensity mapped to avg SNR (brighter = better signal)
|
||||
- Labels: observer name + avg SNR
|
||||
|
||||
5. **Hop Distribution** (bar chart, half width)
|
||||
- X: hop count (1, 2, 3, 4+)
|
||||
- Y: packet count
|
||||
- Simple, clean
|
||||
|
||||
6. **Uptime Heatmap** (custom canvas/div grid, full width)
|
||||
- 7 rows (Sun–Sat) × 24 columns (hours)
|
||||
- Cell color intensity = packet count for that slot
|
||||
- Tooltip: "Monday 14:00 — 12 packets"
|
||||
- Use CSS grid with inline background colors (no chart.js needed)
|
||||
|
||||
7. **Peer Interactions** (table/list, full width)
|
||||
- Ranked by message count
|
||||
- Columns: peer name, messages, last contact
|
||||
- Peer name links to their node detail page
|
||||
|
||||
### Stat Cards
|
||||
- Use CSS grid, 3 columns on desktop, 2 on tablet, 1 on mobile
|
||||
- Each card: label (small, muted), value (large, bold), optional trend arrow
|
||||
- Signal grade uses color coding: A=green, B=blue, C=yellow, D=red
|
||||
|
||||
### CSS (add to `style.css`)
|
||||
```css
|
||||
.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; }
|
||||
.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); }
|
||||
@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; } }
|
||||
```
|
||||
|
||||
### Dark Mode
|
||||
All colors use CSS variables. Chart.js text/grid colors should reference `--text-muted` and `--border`. Set via:
|
||||
```js
|
||||
Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--text-muted').trim();
|
||||
Chart.defaults.borderColor = getComputedStyle(document.documentElement).getPropertyValue('--border').trim();
|
||||
```
|
||||
|
||||
## Files to Modify
|
||||
|
||||
1. **`db.js`** — Add `getNodeAnalytics(pubkey, days)` function
|
||||
2. **`server.js`** — Add `GET /api/nodes/:pubkey/analytics` route
|
||||
3. **`public/node-analytics.js`** — New file, full page implementation
|
||||
4. **`public/style.css`** — Add analytics CSS classes
|
||||
5. **`public/index.html`** — Add Chart.js CDN script + `node-analytics.js` script tag (with cache buster)
|
||||
6. **`public/app.js`** — Add route for `#/nodes/:pubkey/analytics` in the router
|
||||
7. **`public/nodes.js`** — Add "📊 Analytics" button to sidebar and full-screen detail views
|
||||
|
||||
## Constraints — DO NOT TOUCH
|
||||
|
||||
These files/behaviors have been manually tuned. Do not modify unless explicitly part of the plan:
|
||||
|
||||
1. **`public/map.js`** — Map markers, disambiguation logic, route drawing. OFF LIMITS.
|
||||
2. **`public/packets.js`** — Panel resize, VCR replay logic. OFF LIMITS.
|
||||
3. **`public/app.js` `makeColumnsResizable()`** (line ~463) — Column resize steals proportionally from all right columns with 50px minimum. Do not change.
|
||||
4. **Existing node detail rendering in `nodes.js`** — Only ADD the analytics button. Do not reorganize, rename, or restructure existing sections.
|
||||
5. **Cache busters** — When modifying `index.html`, bump cache busters on ALL changed files using `?v=TIMESTAMP`.
|
||||
6. **`escapeHtml` and `timeAgo`** — Globals defined in `app.js`. Do not redefine them anywhere.
|
||||
7. **Router in `app.js`** — Follow existing pattern exactly when adding the analytics route.
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Add CSS to `style.css`
|
||||
2. Add Chart.js to `index.html`
|
||||
3. Add `getNodeAnalytics()` to `db.js`
|
||||
4. Add API route to `server.js`
|
||||
5. Create `node-analytics.js`
|
||||
6. Register route in `app.js`
|
||||
7. Add analytics button to `nodes.js` (sidebar + full-screen)
|
||||
8. Add `node-analytics.js` script tag to `index.html` with cache buster
|
||||
9. Bump all modified file cache busters
|
||||
10. Test: `node -c` on all JS files, verify no syntax errors
|
||||
|
||||
## Testing
|
||||
|
||||
After implementation:
|
||||
- Navigate to any node → click Analytics → page loads with charts
|
||||
- Switch time ranges → data reloads
|
||||
- Dark mode → charts readable
|
||||
- Mobile → responsive layout
|
||||
- Direct URL → page loads correctly
|
||||
- Back button → returns to node detail
|
||||
@@ -345,4 +345,152 @@ function getNodeHealth(pubkey) {
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { db, insertPacket, insertPath, upsertNode, upsertObserver, getPackets, getPacket, getNodes, getNode, getObservers, getStats, seed, searchNodes, getNodeHealth };
|
||||
function getNodeAnalytics(pubkey, days) {
|
||||
const node = stmts.getNode.get(pubkey);
|
||||
if (!node) return null;
|
||||
|
||||
const now = new Date();
|
||||
const from = new Date(now.getTime() - days * 86400000);
|
||||
const fromISO = from.toISOString();
|
||||
const toISO = now.toISOString();
|
||||
|
||||
const keyPattern = `%${pubkey}%`;
|
||||
const namePattern = node.name ? `%${node.name.replace(/[%_]/g, '')}%` : null;
|
||||
const whereClause = namePattern
|
||||
? `(decoded_json LIKE @keyPattern OR decoded_json LIKE @namePattern)`
|
||||
: `decoded_json LIKE @keyPattern`;
|
||||
const timeWhere = `${whereClause} AND timestamp > @fromISO`;
|
||||
const params = namePattern ? { keyPattern, namePattern, fromISO } : { keyPattern, fromISO };
|
||||
|
||||
// Activity timeline
|
||||
const activityTimeline = db.prepare(`
|
||||
SELECT strftime('%Y-%m-%dT%H:00:00Z', timestamp) as bucket, COUNT(*) as count
|
||||
FROM packets WHERE ${timeWhere} GROUP BY bucket ORDER BY bucket
|
||||
`).all(params);
|
||||
|
||||
// SNR trend
|
||||
const snrTrend = db.prepare(`
|
||||
SELECT timestamp, snr, rssi, observer_id, observer_name
|
||||
FROM packets WHERE ${timeWhere} AND snr IS NOT NULL ORDER BY timestamp
|
||||
`).all(params);
|
||||
|
||||
// Packet type breakdown
|
||||
const packetTypeBreakdown = db.prepare(`
|
||||
SELECT payload_type, COUNT(*) as count FROM packets WHERE ${timeWhere} GROUP BY payload_type
|
||||
`).all(params);
|
||||
|
||||
// Observer coverage
|
||||
const observerCoverage = db.prepare(`
|
||||
SELECT observer_id, observer_name, COUNT(*) as packetCount,
|
||||
AVG(snr) as avgSnr, AVG(rssi) as avgRssi, MIN(timestamp) as firstSeen, MAX(timestamp) as lastSeen
|
||||
FROM packets WHERE ${timeWhere} AND observer_id IS NOT NULL
|
||||
GROUP BY observer_id ORDER BY packetCount DESC
|
||||
`).all(params);
|
||||
|
||||
// Hop distribution
|
||||
const pathRows = db.prepare(`
|
||||
SELECT path_json FROM packets WHERE ${timeWhere} AND path_json IS NOT NULL
|
||||
`).all(params);
|
||||
|
||||
const hopCounts = {};
|
||||
let totalWithPath = 0, relayedCount = 0;
|
||||
for (const row of pathRows) {
|
||||
try {
|
||||
const hops = JSON.parse(row.path_json);
|
||||
if (Array.isArray(hops)) {
|
||||
const h = hops.length;
|
||||
const key = h >= 4 ? '4+' : String(h);
|
||||
hopCounts[key] = (hopCounts[key] || 0) + 1;
|
||||
totalWithPath++;
|
||||
if (h > 1) relayedCount++;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
const hopDistribution = Object.entries(hopCounts).map(([hops, count]) => ({ hops, count }))
|
||||
.sort((a, b) => a.hops.localeCompare(b.hops, undefined, { numeric: true }));
|
||||
|
||||
// Peer interactions from decoded_json
|
||||
const decodedRows = db.prepare(`
|
||||
SELECT decoded_json, timestamp FROM packets WHERE ${timeWhere} AND decoded_json IS NOT NULL
|
||||
`).all(params);
|
||||
|
||||
const peerMap = {};
|
||||
for (const row of decodedRows) {
|
||||
try {
|
||||
const d = JSON.parse(row.decoded_json);
|
||||
// Look for sender/recipient pubkeys that aren't this node
|
||||
const candidates = [];
|
||||
if (d.sender_key && d.sender_key !== pubkey) candidates.push({ key: d.sender_key, name: d.sender_name || d.sender_short_name });
|
||||
if (d.recipient_key && d.recipient_key !== pubkey) candidates.push({ key: d.recipient_key, name: d.recipient_name || d.recipient_short_name });
|
||||
if (d.pubkey && d.pubkey !== pubkey) candidates.push({ key: d.pubkey, name: d.name });
|
||||
for (const c of candidates) {
|
||||
if (!c.key) continue;
|
||||
if (!peerMap[c.key]) peerMap[c.key] = { peer_key: c.key, peer_name: c.name || c.key.slice(0, 12), messageCount: 0, lastContact: row.timestamp };
|
||||
peerMap[c.key].messageCount++;
|
||||
if (row.timestamp > peerMap[c.key].lastContact) peerMap[c.key].lastContact = row.timestamp;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
const peerInteractions = Object.values(peerMap).sort((a, b) => b.messageCount - a.messageCount).slice(0, 20);
|
||||
|
||||
// Uptime heatmap
|
||||
const uptimeHeatmap = db.prepare(`
|
||||
SELECT CAST(strftime('%w', timestamp) AS INTEGER) as dayOfWeek,
|
||||
CAST(strftime('%H', timestamp) AS INTEGER) as hour, COUNT(*) as count
|
||||
FROM packets WHERE ${timeWhere} GROUP BY dayOfWeek, hour
|
||||
`).all(params);
|
||||
|
||||
// Computed stats
|
||||
const totalPackets = db.prepare(`SELECT COUNT(*) as count FROM packets WHERE ${timeWhere}`).get(params).count;
|
||||
const uniqueObservers = observerCoverage.length;
|
||||
const uniquePeers = peerInteractions.length;
|
||||
const avgPacketsPerDay = days > 0 ? Math.round(totalPackets / days * 10) / 10 : totalPackets;
|
||||
|
||||
// Availability: distinct hours with packets / total hours
|
||||
const distinctHours = activityTimeline.length;
|
||||
const totalHours = days * 24;
|
||||
const availabilityPct = totalHours > 0 ? Math.round(distinctHours / totalHours * 1000) / 10 : 0;
|
||||
|
||||
// Longest silence
|
||||
const timestamps = db.prepare(`
|
||||
SELECT timestamp FROM packets WHERE ${timeWhere} ORDER BY timestamp
|
||||
`).all(params).map(r => new Date(r.timestamp).getTime());
|
||||
|
||||
let longestSilenceMs = 0, longestSilenceStart = null;
|
||||
for (let i = 1; i < timestamps.length; i++) {
|
||||
const gap = timestamps[i] - timestamps[i - 1];
|
||||
if (gap > longestSilenceMs) { longestSilenceMs = gap; longestSilenceStart = new Date(timestamps[i - 1]).toISOString(); }
|
||||
}
|
||||
|
||||
// Signal grade
|
||||
const snrValues = snrTrend.map(r => r.snr);
|
||||
const snrMean = snrValues.length > 0 ? snrValues.reduce((a, b) => a + b, 0) / snrValues.length : 0;
|
||||
const snrStdDev = snrValues.length > 1 ? Math.sqrt(snrValues.reduce((s, v) => s + (v - snrMean) ** 2, 0) / snrValues.length) : 0;
|
||||
let signalGrade = 'D';
|
||||
if (snrMean > 15 && snrStdDev < 2) signalGrade = 'A';
|
||||
else if (snrMean > 15) signalGrade = 'A-';
|
||||
else if (snrMean > 12 && snrStdDev < 3) signalGrade = 'B+';
|
||||
else if (snrMean > 8) signalGrade = 'B';
|
||||
else if (snrMean > 3) signalGrade = 'C';
|
||||
|
||||
const relayPct = totalWithPath > 0 ? Math.round(relayedCount / totalWithPath * 1000) / 10 : 0;
|
||||
|
||||
return {
|
||||
node,
|
||||
timeRange: { from: fromISO, to: toISO, days },
|
||||
activityTimeline,
|
||||
snrTrend,
|
||||
packetTypeBreakdown,
|
||||
observerCoverage,
|
||||
hopDistribution,
|
||||
peerInteractions,
|
||||
uptimeHeatmap,
|
||||
computedStats: {
|
||||
availabilityPct, longestSilenceMs, longestSilenceStart, signalGrade,
|
||||
snrMean: Math.round(snrMean * 10) / 10, snrStdDev: Math.round(snrStdDev * 10) / 10,
|
||||
relayPct, totalPackets, uniqueObservers, uniquePeers, avgPacketsPerDay
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { db, insertPacket, insertPath, upsertNode, upsertObserver, getPackets, getPacket, getNodes, getNode, getObservers, getStats, seed, searchNodes, getNodeHealth, getNodeAnalytics };
|
||||
|
||||
@@ -200,6 +200,11 @@ function navigate() {
|
||||
routeParam = decodeURIComponent(route.substring(slashIdx + 1));
|
||||
}
|
||||
|
||||
// Special route: nodes/PUBKEY/analytics → node-analytics page
|
||||
if (basePage === 'nodes' && routeParam && routeParam.endsWith('/analytics')) {
|
||||
basePage = 'node-analytics';
|
||||
}
|
||||
|
||||
// Update nav active state
|
||||
document.querySelectorAll('.nav-link[data-route]').forEach(el => {
|
||||
el.classList.toggle('active', el.dataset.route === basePage);
|
||||
|
||||
+14
-12
@@ -20,9 +20,9 @@
|
||||
<meta name="twitter:title" content="MeshCore Analyzer">
|
||||
<meta name="twitter:description" content="Real-time MeshCore LoRa mesh network analyzer — live packet visualization, node tracking, channel decryption, and route analysis.">
|
||||
<meta name="twitter:image" content="https://raw.githubusercontent.com/Kpa-clawbot/meshcore-analyzer/master/public/og-image.png">
|
||||
<link rel="stylesheet" href="style.css?v=1773958048">
|
||||
<link rel="stylesheet" href="style.css?v=1774079160">
|
||||
<link rel="stylesheet" href="home.css">
|
||||
<link rel="stylesheet" href="live.css?v=1773958048">
|
||||
<link rel="stylesheet" href="live.css?v=1774079160">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||
crossorigin="anonymous">
|
||||
@@ -30,6 +30,7 @@
|
||||
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
|
||||
<script src="https://unpkg.com/chart.js@4/dist/chart.umd.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#app">Skip to content</a>
|
||||
@@ -75,15 +76,16 @@
|
||||
<main id="app" role="main"></main>
|
||||
|
||||
<script src="vendor/qrcode.js"></script>
|
||||
<script src="app.js?v=1773958048"></script>
|
||||
<script src="home.js?v=1773958048"></script>
|
||||
<script src="packets.js?v=1773958048"></script>
|
||||
<script src="map.js?v=1773958048" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="channels.js?v=1773958048" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="nodes.js?v=1773958914" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="traces.js?v=1773958048" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="analytics.js?v=1773958048" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="live.js?v=1773958048" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observers.js?v=1773958048" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="app.js?v=1774079160"></script>
|
||||
<script src="home.js?v=1774079160"></script>
|
||||
<script src="packets.js?v=1774079160"></script>
|
||||
<script src="map.js?v=1774079160" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="channels.js?v=1774079160" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="nodes.js?v=1774079160" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="traces.js?v=1774079160" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="analytics.js?v=1774079160" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="live.js?v=1774079160" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="observers.js?v=1774079160" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
<script src="node-analytics.js?v=1774079160" onerror="console.error('Failed to load:', this.src)"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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 = '<div style="padding:40px;text-align:center;color:var(--text-muted)">Loading analytics…</div>';
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = await api('/nodes/' + encodeURIComponent(pubkey) + '/analytics?days=' + days);
|
||||
} catch (e) {
|
||||
container.innerHTML = '<div style="padding:40px;text-align:center;color:#ff6b6b">Failed to load analytics: ' + escapeHtml(e.message) + '</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const n = data.node;
|
||||
const s = data.computedStats;
|
||||
const nodeName = escapeHtml(n.name || n.public_key.slice(0, 12));
|
||||
|
||||
container.innerHTML = `
|
||||
<div style="max-width:1100px;margin:0 auto;padding:20px">
|
||||
<div style="margin-bottom:16px">
|
||||
<a href="#/nodes/${encodeURIComponent(n.public_key)}" style="color:var(--accent);text-decoration:none;font-size:14px">← Back to ${nodeName}</a>
|
||||
<h2 style="margin:8px 0 4px">📊 ${nodeName} — Analytics</h2>
|
||||
<div style="color:var(--text-muted);font-size:12px">${n.role || 'Unknown role'} · ${s.totalPackets} packets in ${days}d window</div>
|
||||
</div>
|
||||
|
||||
<div class="analytics-time-range" id="timeRangeBtns">
|
||||
<button data-days="1" ${days===1?'class="active"':''}>24h</button>
|
||||
<button data-days="7" ${days===7?'class="active"':''}>7d</button>
|
||||
<button data-days="30" ${days===30?'class="active"':''}>30d</button>
|
||||
<button data-days="365" ${days===365?'class="active"':''}>All</button>
|
||||
</div>
|
||||
|
||||
<div class="analytics-stats">
|
||||
<div class="analytics-stat-card">
|
||||
<div class="analytics-stat-label">Availability</div>
|
||||
<div class="analytics-stat-value">${s.availabilityPct}%</div>
|
||||
</div>
|
||||
<div class="analytics-stat-card">
|
||||
<div class="analytics-stat-label">Signal Grade</div>
|
||||
<div class="analytics-stat-value" style="color:${GRADE_COLORS[s.signalGrade]||'var(--text)'}">${s.signalGrade}</div>
|
||||
</div>
|
||||
<div class="analytics-stat-card">
|
||||
<div class="analytics-stat-label">Packets / Day</div>
|
||||
<div class="analytics-stat-value">${s.avgPacketsPerDay}</div>
|
||||
</div>
|
||||
<div class="analytics-stat-card">
|
||||
<div class="analytics-stat-label">Observers</div>
|
||||
<div class="analytics-stat-value">${s.uniqueObservers}</div>
|
||||
</div>
|
||||
<div class="analytics-stat-card">
|
||||
<div class="analytics-stat-label">Relay %</div>
|
||||
<div class="analytics-stat-value">${s.relayPct}%</div>
|
||||
</div>
|
||||
<div class="analytics-stat-card">
|
||||
<div class="analytics-stat-label">Longest Silence</div>
|
||||
<div class="analytics-stat-value" style="font-size:22px">${formatSilence(s.longestSilenceMs)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="analytics-charts">
|
||||
<div class="analytics-chart-card full">
|
||||
<h4>Activity Timeline</h4>
|
||||
<canvas id="activityChart"></canvas>
|
||||
</div>
|
||||
<div class="analytics-chart-card">
|
||||
<h4>SNR Trend</h4>
|
||||
<canvas id="snrChart"></canvas>
|
||||
</div>
|
||||
<div class="analytics-chart-card">
|
||||
<h4>Packet Types</h4>
|
||||
<canvas id="packetTypeChart"></canvas>
|
||||
</div>
|
||||
<div class="analytics-chart-card">
|
||||
<h4>Observer Coverage</h4>
|
||||
<canvas id="observerChart"></canvas>
|
||||
</div>
|
||||
<div class="analytics-chart-card">
|
||||
<h4>Hop Distribution</h4>
|
||||
<canvas id="hopChart"></canvas>
|
||||
</div>
|
||||
<div class="analytics-chart-card full">
|
||||
<h4>Uptime Heatmap</h4>
|
||||
<div id="heatmapGrid" class="analytics-heatmap"></div>
|
||||
</div>
|
||||
${data.peerInteractions.length ? `<div class="analytics-chart-card full">
|
||||
<h4>Peer Interactions</h4>
|
||||
<table class="analytics-peer-table">
|
||||
<thead><tr><th>Peer</th><th>Messages</th><th>Last Contact</th></tr></thead>
|
||||
<tbody>${data.peerInteractions.map(p => `<tr>
|
||||
<td><a href="#/nodes/${encodeURIComponent(p.peer_key)}" style="color:var(--accent)">${escapeHtml(p.peer_name)}</a></td>
|
||||
<td>${p.messageCount}</td>
|
||||
<td>${timeAgo(p.lastContact)}</td>
|
||||
</tr>`).join('')}</tbody>
|
||||
</table>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// 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 = '<div class="analytics-heatmap-label"></div>';
|
||||
for (let h = 0; h < 24; h++) {
|
||||
grid.innerHTML += `<div class="analytics-heatmap-label" style="justify-content:center;font-size:9px">${h}</div>`;
|
||||
}
|
||||
// Day rows
|
||||
for (let d = 0; d < 7; d++) {
|
||||
grid.innerHTML += `<div class="analytics-heatmap-label">${DAY_NAMES[d]}</div>`;
|
||||
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 += `<div class="analytics-heatmap-cell" style="background:${bg}" title="${DAY_NAMES[d]} ${h}:00 — ${count} packets"></div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function init(container, routeParam) {
|
||||
// routeParam is "PUBKEY/analytics"
|
||||
if (!routeParam || !routeParam.endsWith('/analytics')) {
|
||||
container.innerHTML = '<div style="padding:40px;text-align:center">Invalid analytics URL</div>';
|
||||
return;
|
||||
}
|
||||
const pubkey = routeParam.slice(0, -'/analytics'.length);
|
||||
loadAnalytics(container, pubkey, 7);
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
destroyCharts();
|
||||
currentPubkey = null;
|
||||
}
|
||||
|
||||
registerPage('node-analytics', { init, destroy });
|
||||
})();
|
||||
@@ -160,6 +160,7 @@
|
||||
|
||||
<div style="text-align:center;padding:16px">
|
||||
<button class="btn-primary" id="copyUrlBtn">📋 Copy URL</button>
|
||||
<a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="btn-primary" style="display:inline-block;margin-left:8px;text-decoration:none">📊 Analytics</a>
|
||||
</div>`;
|
||||
|
||||
// Map
|
||||
@@ -428,6 +429,7 @@
|
||||
|
||||
<div style="text-align:center;margin-bottom:16px">
|
||||
<button class="btn-primary" id="copyUrlBtn">📋 Copy URL</button>
|
||||
<a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="btn-primary" style="display:inline-block;margin-left:8px;text-decoration:none">📊 Analytics</a>
|
||||
</div>
|
||||
|
||||
<div class="node-detail-section">
|
||||
|
||||
@@ -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; } }
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user