mirror of
https://github.com/RetroShare/RSNewWebUI.git
synced 2026-09-14 11:05:47 +05:00
added the stats page
This commit is contained in:
parent
9a8870bfb2
commit
ae31ed5842
@ -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
|
||||
|
||||
@ -9,3 +9,4 @@
|
||||
@forward "forums";
|
||||
@forward "board";
|
||||
@forward "config";
|
||||
@forward "statistics";
|
||||
|
||||
40
webui-src/app/scss/pages/_statistics.scss
Normal file
40
webui-src/app/scss/pages/_statistics.scss
Normal 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; }
|
||||
}
|
||||
210
webui-src/app/statistics/statistics.js
Normal file
210
webui-src/app/statistics/statistics.js
Normal 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
Loading…
Reference in New Issue
Block a user