added the stats page

This commit is contained in:
defnax 2026-08-22 22:14:53 +02:00
parent 9a8870bfb2
commit ae31ed5842
5 changed files with 259 additions and 1 deletions

View File

@ -12,6 +12,7 @@ const channels = require('channels/channels');
const forums = require('forums/forums');
const boards = require('boards/boards');
const config = require('config/config_resolver');
const statistics = require('statistics/statistics');
const statusbar = require('statusbar');
const navIcon = {
@ -25,6 +26,7 @@ const navIcon = {
forums: 'i.fas.fa-bullhorn.sidenav-icon',
boards: 'i.fas.fa-globe.sidenav-icon',
config: 'i.fas.fa-cogs.sidenav-icon',
statistics: 'i.fas.fa-chart-pie.sidenav-icon',
};
const navbar = () => {
@ -174,6 +176,7 @@ const mobileMoreLinks = {
forums: '/forums/MyForums',
boards: '/boards/MyBoards',
config: '/config/network',
statistics: '/statistics',
};
const MobileStatus = () => {
@ -300,6 +303,7 @@ const Layout = () => {
channels: '/channels/MyChannels',
forums: '/forums/MyForums',
boards: '/boards/MyBoards',
statistics: '/statistics',
config: '/config/network',
},
}),
@ -391,6 +395,9 @@ m.route(document.getElementById('main'), '/', {
'/config/:tab': {
render: (v) => m(Layout, m(config, v.attrs)),
},
'/statistics': {
render: () => m(Layout, m(statistics)),
},
});
// v51 architectural fix: ensure event queue starts on direct route refresh

View File

@ -9,3 +9,4 @@
@forward "forums";
@forward "board";
@forward "config";
@forward "statistics";

View File

@ -0,0 +1,40 @@
.statistics-page { max-width: 1500px; margin: 0 auto; padding: 1.5rem; color: #1e293b; }
.statistics-header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 1.25rem; }
.statistics-header h1 { display: flex; align-items: center; gap: .65rem; margin: 0 0 .25rem; font-size: 1.7rem; }
.statistics-header h1 i { color: #0788cb; }
.statistics-header p, .traffic-panel__heading p { margin: 0; color: #64748b; }
.statistics-header button { display: inline-flex; align-items: center; gap: .45rem; white-space: nowrap; }
.statistics-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1.25rem; align-items: start; }
.traffic-panel { min-width: 0; padding: 1.25rem; border: 1px solid #dbe4ec; border-radius: .75rem; background: #fff; box-shadow: 0 3px 14px rgba(15, 23, 42, .06); }
.traffic-panel__heading h2 { margin: 0 0 .2rem; font-size: 1.2rem; }
.traffic-pie { display: grid; grid-template-columns: minmax(13rem, 45%) 1fr; align-items: center; gap: 1.25rem; margin: 1.25rem 0; }
.traffic-pie svg { width: 100%; max-height: 18rem; transform: rotate(-90deg); }
.traffic-pie__track, .traffic-pie__segment { fill: none; stroke-width: 18; }
.traffic-pie__track { stroke: #e8eef3; }
.traffic-pie__segment { transition: stroke-dasharray .25s ease; }
.traffic-pie__value, .traffic-pie__caption { transform: rotate(90deg); transform-origin: 60px 60px; fill: #1e293b; }
.traffic-pie__value { font-size: 9px; font-weight: 700; }
.traffic-pie__caption { font-size: 5px; fill: #64748b; }
.traffic-legend { min-width: 0; max-height: 16rem; overflow-y: auto; }
.traffic-legend__item { display: grid; grid-template-columns: .7rem minmax(0, 1fr) auto; align-items: center; gap: .5rem; padding: .35rem 0; font-size: .84rem; }
.traffic-legend__swatch { width: .7rem; height: .7rem; border-radius: 50%; }
.traffic-legend__name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.traffic-table-wrap { overflow-x: auto; }
.traffic-table { width: 100%; font-size: .85rem; }
.traffic-table th, .traffic-table td { padding: .55rem .45rem; text-align: right; border-bottom: 1px solid #e8eef3; white-space: nowrap; }
.traffic-table th:first-child, .traffic-table td:first-child { text-align: left; white-space: normal; }
.traffic-empty { display: grid; place-items: center; gap: .5rem; min-height: 18rem; color: #94a3b8; text-align: center; }
.traffic-empty i { font-size: 3rem; }
.statistics-error { display: flex; gap: .5rem; align-items: center; margin-bottom: 1rem; padding: .8rem 1rem; color: #991b1b; background: #fef2f2; border: 1px solid #fecaca; border-radius: .5rem; }
.statistics-note { margin: 1rem 0 0; color: #64748b; font-size: .8rem; text-align: right; }
@media (max-width: 1050px) { .statistics-grid { grid-template-columns: 1fr; } }
@media (max-width: 600px) {
.statistics-page { padding: .75rem; }
.statistics-header { align-items: flex-start; }
.statistics-header h1 { font-size: 1.35rem; }
.statistics-header button { padding: .5rem; font-size: 0; }
.statistics-header button i { font-size: 1rem; }
.traffic-panel { padding: .8rem; }
.traffic-pie { grid-template-columns: 1fr; }
.traffic-pie svg { max-height: 14rem; }
}

View File

@ -0,0 +1,210 @@
const m = require('mithril');
const rs = require('rswebui');
const COLORS = ['#0788cb', '#10b981', '#f59e0b', '#8b5cf6', '#ef4444', '#06b6d4', '#ec4899', '#84cc16', '#64748b', '#f97316'];
const SERVICE_NAMES = {
0x0001: 'File index', 0x0011: 'Discovery', 0x0012: 'Chat', 0x0013: 'Messages',
0x0014: 'Turtle routing', 0x0015: 'Tunnel', 0x0016: 'Heartbeat', 0x0017: 'File transfer',
0x0018: 'Generic routing', 0x0019: 'File database', 0x0020: 'Service info',
0x0021: 'Bandwidth control', 0x0022: 'Mail', 0x0023: 'Direct mail',
0x0024: 'Distant mail', 0x0026: 'Service control', 0x0027: 'Distant chat',
0x0028: 'GXS tunnel', 0x0101: 'Ban list', 0x0102: 'Status', 0x0103: 'Friend server',
0x0200: 'Network exchange', 0x0211: 'Identities', 0x0215: 'Forums',
0x0216: 'Boards', 0x0217: 'Channels', 0x0218: 'Circles', 0x0219: 'Reputation',
0x0220: 'GXS recognition', 0x0230: 'GXS mail', 0x0240: 'JSON API',
};
function idString(value) {
if (!value) return '';
if (typeof value === 'string') return value;
return rs.idToHex(value);
}
function formatBytes(bytes) {
const value = Number(bytes) || 0;
if (!value) return '0 B';
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
const unit = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
return `${(value / (1024 ** unit)).toFixed(unit ? 1 : 0)} ${units[unit]}`;
}
// RetroShare wraps uint64_t values as { xint64, xstr64 } because JSON numbers
// cannot safely represent every 64-bit integer. Prefer the decimal string so a
// large cumulative byte count is not truncated before it reaches this page.
function number64(value) {
if (!value) return 0;
if (typeof value === 'object') return Number(value.xstr64 || value.xint64) || 0;
return Number(value) || 0;
}
function cumulativeRows(entries, labelFor) {
return (Array.isArray(entries) ? entries : []).map((entry) => {
const stats = entry.value || {};
const incoming = number64(stats.bytesIn);
const outgoing = number64(stats.bytesOut);
const key = typeof entry.key === 'number' ? String(entry.key) : idString(entry.key) || String(entry.key);
return {
key,
label: labelFor(entry.key),
incoming,
outgoing,
total: incoming + outgoing,
count: (Number(stats.countIn) || 0) + (Number(stats.countOut) || 0),
firstSeen: number64(stats.firstSeen),
lastSeen: number64(stats.lastSeen),
};
}).sort((a, b) => b.total - a.total);
}
function aggregate(incoming, outgoing, keyFor, labelFor) {
const rows = new Map();
const add = (clue, direction) => {
const key = keyFor(clue);
const row = rows.get(key) || { key, label: labelFor(clue, key), incoming: 0, outgoing: 0, count: 0 };
row[direction] += Number(clue.size) || 0;
row.count += Number(clue.count) || 0;
rows.set(key, row);
};
incoming.forEach((clue) => add(clue, 'incoming'));
outgoing.forEach((clue) => add(clue, 'outgoing'));
return Array.from(rows.values()).map((row) => ({ ...row, total: row.incoming + row.outgoing }))
.sort((a, b) => b.total - a.total);
}
function PieChart() {
return {
view(vnode) {
const rows = vnode.attrs.rows.filter((row) => row.total > 0);
const total = rows.reduce((sum, row) => sum + row.total, 0);
let offset = 0;
return m('.traffic-pie', [
m('svg[viewBox="0 0 120 120"][role=img]', { 'aria-label': vnode.attrs.label }, [
m('circle[cx=60][cy=60][r=44].traffic-pie__track'),
rows.map((row, index) => {
const length = total ? (row.total / total) * 276.46 : 0;
const segment = m('circle[cx=60][cy=60][r=44].traffic-pie__segment', {
stroke: COLORS[index % COLORS.length],
'stroke-dasharray': `${length} ${276.46 - length}`,
'stroke-dashoffset': -offset,
}, m('title', `${row.label}: ${formatBytes(row.total)}`));
offset += length;
return segment;
}),
m('text[x=60][y=57][text-anchor=middle].traffic-pie__value', formatBytes(total)),
m('text[x=60][y=70][text-anchor=middle].traffic-pie__caption', 'total traffic'),
]),
m('.traffic-legend', rows.map((row, index) =>
m('.traffic-legend__item', [
m('span.traffic-legend__swatch', { style: { backgroundColor: COLORS[index % COLORS.length] } }),
m('span.traffic-legend__name', row.label),
m('strong', `${total ? ((row.total / total) * 100).toFixed(1) : 0}%`),
])
)),
]);
},
};
}
function TrafficPanel() {
return {
view(vnode) {
const rows = vnode.attrs.rows;
return m('section.traffic-panel', [
m('.traffic-panel__heading', [m('div', [m('h2', vnode.attrs.title), m('p', vnode.attrs.description)])]),
rows.length
? [m(PieChart, { rows, label: `${vnode.attrs.title} traffic distribution` }),
m('.traffic-table-wrap', m('table.traffic-table', [
m('thead', m('tr', [m('th', vnode.attrs.column), m('th', 'Incoming'), m('th', 'Outgoing'), m('th', 'Total'), m('th', 'Packets')])),
m('tbody', rows.map((row) => m('tr', [
m('td', row.label), m('td', formatBytes(row.incoming)), m('td', formatBytes(row.outgoing)),
m('td', m('strong', formatBytes(row.total))), m('td', row.count.toLocaleString()),
]))),
]))]
: m('.traffic-empty', [m('i.fas.fa-chart-pie'), m('p', 'No traffic has been recorded in the current tracking window.')]),
]);
},
};
}
module.exports = {
oninit(vnode) {
vnode.state.incoming = [];
vnode.state.outgoing = [];
vnode.state.friendNames = {};
vnode.state.loading = true;
vnode.state.error = '';
vnode.state.cumulativeServices = null;
vnode.state.cumulativePeers = null;
vnode.state.load = async () => {
const [serviceResponse, peerResponse] = await Promise.all([
rs.rsJsonApiRequest('/rsConfig/getCumulativeTrafficByService'),
rs.rsJsonApiRequest('/rsConfig/getCumulativeTrafficByPeer'),
]);
const serviceBody = serviceResponse.body || {};
const peerBody = peerResponse.body || {};
if (serviceResponse.status === 200 && peerResponse.status === 200 && serviceBody.retval && peerBody.retval) {
vnode.state.cumulativeServices = serviceBody.stats || [];
vnode.state.cumulativePeers = peerBody.stats || [];
vnode.state.error = '';
} else {
// Older cores do not expose the cumulative API. Retain the live-window
// view as a compatibility fallback instead of leaving the page empty.
const response = await rs.rsJsonApiRequest('/rsConfig/getTrafficInfo');
const body = response.body || {};
if (response.status === 200 && body.retval) {
vnode.state.incoming = Array.isArray(body.in_lst) ? body.in_lst : [];
vnode.state.outgoing = Array.isArray(body.out_lst) ? body.out_lst : [];
vnode.state.error = 'This Core only provides the current traffic window; cumulative totals require the newer traffic statistics API.';
} else {
vnode.state.error = 'Traffic statistics are not available from this RetroShare Core.';
}
}
vnode.state.loading = false;
};
vnode.state.loadFriends = async () => {
const listResponse = await rs.rsJsonApiRequest('/rsPeers/getFriendList');
const ids = (listResponse.body && listResponse.body.sslIds) || [];
await Promise.all(ids.map(async (sslId) => {
const detailResponse = await rs.rsJsonApiRequest('/rsPeers/getPeerDetails', { sslId });
const detail = detailResponse.body && detailResponse.body.det;
if (detail) vnode.state.friendNames[idString(detail.id || sslId)] = detail.name || detail.location || idString(sslId);
}));
};
vnode.state.load();
vnode.state.loadFriends();
vnode.state.timer = setInterval(vnode.state.load, 5000);
},
onremove(vnode) { clearInterval(vnode.state.timer); },
view(vnode) {
const serviceLabel = (value) => {
const id = Number(value) || 0;
return SERVICE_NAMES[id] || `Service 0x${id.toString(16).padStart(4, '0')}`;
};
const friendLabel = (value) => {
const id = idString(value) || 'unknown';
return vnode.state.friendNames[id] || (id === 'unknown' ? 'Unknown peer' : `Peer ${id.slice(0, 8)}`);
};
const serviceRows = vnode.state.cumulativeServices
? cumulativeRows(vnode.state.cumulativeServices, serviceLabel)
: aggregate(vnode.state.incoming, vnode.state.outgoing,
(clue) => Number(clue.service_id) || 0, (_clue, id) => serviceLabel(id));
const friendRows = vnode.state.cumulativePeers
? cumulativeRows(vnode.state.cumulativePeers, friendLabel)
: aggregate(vnode.state.incoming, vnode.state.outgoing,
(clue) => idString(clue.peer_id) || 'unknown', (_clue, id) => friendLabel(id));
return m('.statistics-page', [
m('.statistics-header', [
m('div', [m('h1', [m('i.fas.fa-chart-pie'), ' Traffic statistics']), m('p', 'Live traffic distribution reported by RetroShare Core.')]),
m('button[type=button]', { disabled: vnode.state.loading, onclick: vnode.state.load }, [m('i.fas.fa-sync-alt'), ' Refresh']),
]),
vnode.state.error && m('.statistics-error', [m('i.fas.fa-exclamation-triangle'), vnode.state.error]),
m('.statistics-grid', [
m(TrafficPanel, { title: 'By service', description: 'Which RetroShare services use the most bandwidth.', column: 'Service', rows: serviceRows }),
m(TrafficPanel, { title: 'By friend', description: 'Traffic exchanged with each friend location.', column: 'Friend', rows: friendRows }),
]),
m('p.statistics-note', vnode.state.cumulativeServices
? 'Cumulative values are retained by the Core and refresh every 5 seconds.'
: 'Values cover the current traffic window and refresh every 5 seconds.'),
]);
},
};

File diff suppressed because one or more lines are too long