Improved Stats page look & feel

Added Bandwidth page
This commit is contained in:
defnax 2026-09-10 20:55:41 +02:00
parent add4a3ab80
commit f99f3c28c9
4 changed files with 1186 additions and 53 deletions

View File

@ -1,40 +1,706 @@
.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; }
// ---------------------------------------------------------------------------
// Statistics page two-pane master-detail layout
// Mirrors the pattern used in Chat, Network, and People pages.
// ---------------------------------------------------------------------------
@use '../abstracts' as *;
// Container: full-height flex row
.statistics-container {
display: flex;
height: 100%;
width: 100%;
overflow: hidden;
background-color: #f1f5f9;
}
// Left pane: sidebar with header card + navigation
.statistics-left-pane {
width: 280px;
min-width: 260px;
max-width: 320px;
border-right: 1px solid #cbd5e1;
display: flex;
flex-direction: column;
background: #ffffff;
box-shadow: 2px 0 5px rgba(0, 0, 0, 0.05);
overflow-y: auto;
}
// Header card (top of left pane)
.statistics-header-card {
padding: 1rem 1.25rem;
border-bottom: 1px solid #e2e8f0;
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
&__title {
display: flex;
align-items: center;
gap: 0.75rem;
> i {
font-size: 1.4rem;
color: #0788cb;
}
h1 {
font-size: 1.15rem;
font-weight: 700;
color: #1e293b;
margin: 0;
}
p {
margin: 0;
font-size: 0.8rem;
color: #64748b;
}
}
}
// Navigation list
.statistics-nav {
display: flex;
flex-direction: column;
padding: 0.5rem 0;
flex: 1;
}
.statistics-nav-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.7rem 1.25rem;
cursor: pointer;
color: #334155;
border-left: 3px solid transparent;
background: none;
box-shadow: none;
border-radius: 0;
font-size: 0.9rem;
font-weight: 500;
text-align: left;
width: 100%;
white-space: nowrap;
transition: background-color 0.15s ease, color 0.15s ease;
&:active {
box-shadow: none;
}
i {
width: 1.1rem;
text-align: center;
color: #64748b;
font-size: 0.95rem;
flex-shrink: 0;
}
&:hover:not(.active) {
background-color: #f1f5f9;
}
&.active {
background-color: #e0f2fe;
color: #0369a1;
font-weight: 600;
border-left-color: #0284c7;
i {
color: #0284c7;
}
}
}
// Mobile tab bar (hidden on desktop)
.statistics-mobile-tabs {
display: none;
}
// Right pane: content area
.statistics-right-pane {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
padding: 1.5rem;
background-color: #f8fafc;
}
// Content header
.statistics-content-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.25rem;
&__title {
h2 {
font-size: 1.5rem;
font-weight: 800;
color: #1e293b;
margin: 0 0 0.25rem;
}
p {
margin: 0;
font-size: 0.9rem;
color: #64748b;
}
}
.statistics-refresh-btn {
display: inline-flex;
align-items: center;
justify-content: center;
// Desktop mode: text only (no icon) for a more compact appearance
i {
display: none;
}
.btn-text {
display: inline;
}
}
}
// Traffic panel cards
.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 #e2e8f0;
border-radius: 0.5rem;
background: #ffffff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
overflow: hidden;
}
.traffic-panel__heading {
display: flex;
align-items: flex-start;
gap: 0.6rem;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid #f1f5f9;
> i {
color: #0788cb;
font-size: 1.05rem;
margin-top: 0.15rem;
flex-shrink: 0;
}
h3 {
margin: 0 0 0.2rem;
font-size: 1.1rem;
font-weight: 700;
color: #334155;
}
p {
margin: 0;
font-size: 0.82rem;
color: #64748b;
}
}
// Pie/Donut chart
.traffic-pie {
display: grid;
grid-template-columns: minmax(10rem, 42%) 1fr;
align-items: center;
gap: 1.25rem;
margin: 1rem 0;
svg {
width: 100%;
max-height: 16rem;
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 0.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;
}
// Legend
.traffic-legend {
min-width: 0;
max-height: 16rem;
overflow-y: auto;
}
.traffic-legend__item {
display: grid;
grid-template-columns: 0.7rem minmax(0, 1fr) auto;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0;
font-size: 0.84rem;
}
.traffic-legend__swatch {
width: 0.7rem;
height: 0.7rem;
border-radius: 50%;
}
.traffic-legend__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
// Data table
.traffic-table-wrap {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
width: 100%;
}
.traffic-table {
width: 100%;
font-size: 0.85rem;
border-collapse: collapse;
th, td {
padding: 0.55rem 0.45rem;
text-align: right;
border-bottom: 1px solid #f1f5f9;
white-space: nowrap;
}
th {
font-weight: 600;
color: #475569;
border-bottom: 2px solid #e2e8f0;
}
th:first-child, td:first-child {
text-align: left;
white-space: normal;
}
tbody tr:hover {
background-color: #f8fafc;
}
}
// Empty state
.traffic-empty {
display: grid;
place-items: center;
gap: 0.5rem;
min-height: 14rem;
color: #94a3b8;
text-align: center;
i {
font-size: 3rem;
}
}
// Error banner
.statistics-error {
display: flex;
gap: 0.5rem;
align-items: center;
margin-bottom: 1rem;
padding: 0.8rem 1rem;
color: #991b1b;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 0.5rem;
}
// Footer note
.statistics-note {
margin: 1rem 0 0;
color: #64748b;
font-size: 0.8rem;
text-align: right;
}
// Placeholder section (Coming soon)
.statistics-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
min-height: 50vh;
text-align: center;
color: #94a3b8;
i {
font-size: 4rem;
color: #cbd5e1;
}
h3 {
font-size: 1.25rem;
font-weight: 700;
color: #64748b;
margin: 0;
}
p {
font-size: 0.95rem;
margin: 0;
}
}
// Bandwidth section
.bandwidth-view {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.bandwidth-summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1rem;
}
.bandwidth-stat-card {
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
padding: 1rem 1.25rem;
display: flex;
align-items: center;
gap: 1rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
&__icon {
width: 44px;
height: 44px;
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
flex-shrink: 0;
&--in {
background: #e0f2fe;
color: #0284c7;
}
&--out {
background: #dcfce7;
color: #16a34a;
}
&--queue {
background: #fef3c7;
color: #d97706;
}
&--session {
background: #f3e8ff;
color: #7e22ce;
}
}
&__body {
min-width: 0;
}
&__value {
font-size: 1.15rem;
font-weight: 700;
color: #1e293b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__label {
font-size: 0.8rem;
color: #64748b;
margin-top: 0.15rem;
white-space: nowrap;
}
}
.bandwidth-panel {
width: 100%;
}
.bandwidth-table {
font-size: 0.82rem;
min-width: 1050px;
width: 100%;
border-collapse: separate;
border-spacing: 0;
th, td {
padding: 0.6rem 0.65rem;
white-space: nowrap !important;
}
th:first-child, td:first-child {
text-align: left;
white-space: nowrap !important;
position: sticky;
left: 0;
z-index: 2;
background: #ffffff;
box-shadow: 2px 0 5px rgba(0, 0, 0, 0.06);
}
th:first-child {
background: #ffffff;
z-index: 3;
}
tbody tr:hover td:first-child {
background: #f8fafc;
}
.bandwidth-peer-name {
font-weight: 600;
color: #1e293b;
display: inline-block;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
}
.bandwidth-peerid-cell {
font-family: monospace;
font-size: 0.78rem;
color: #64748b;
}
.bandwidth-totals-row {
background-color: #f1f5f9;
font-weight: 600;
td {
border-bottom: 2px solid #cbd5e1;
}
td:first-child {
background: #f1f5f9;
}
&:hover {
background-color: #e8eef3;
td:first-child {
background: #e8eef3;
}
}
}
}
.bandwidth-drain-badge {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
text-align: center;
white-space: nowrap;
background: #f1f5f9;
color: #475569;
&--warning {
background: #fef3c7;
color: #b45309;
font-weight: 700;
}
&--critical {
background: #fee2e2;
color: #dc2626;
font-weight: 700;
}
}
// Responsive: medium screens (stack traffic cards & 2-column stats)
@media (max-width: 1200px) {
.bandwidth-summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 1100px) {
.statistics-grid {
grid-template-columns: 1fr;
}
}
// Responsive: phone mode
@media (max-width: $bp-mobile), (max-width: $bp-narrow) and (max-height: $bp-short) {
// Hide left sidebar on mobile
.statistics-left-pane {
display: none;
}
// Show mobile tab bar
.statistics-mobile-tabs {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: #ffffff;
border-bottom: 1px solid #e2e8f0;
overflow: hidden;
flex-shrink: 0;
}
.statistics-mobile-tabs__list {
display: flex;
gap: 0.25rem;
overflow-x: auto;
flex: 1;
-webkit-overflow-scrolling: touch;
&::-webkit-scrollbar {
display: none;
}
}
.statistics-mobile-tab {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.75rem;
border-radius: 9999px;
font-size: 0.8rem;
font-weight: 500;
white-space: nowrap;
color: #64748b;
background: none;
border: 1px solid transparent;
box-shadow: none;
cursor: pointer;
flex-shrink: 0;
&:active {
box-shadow: none;
}
i {
font-size: 0.75rem;
}
&.active {
background-color: #e0f2fe;
color: #0369a1;
font-weight: 600;
border-color: #bae6fd;
}
&:hover:not(.active) {
background-color: #f1f5f9;
}
}
.statistics-mobile-refresh {
width: 32px;
height: 32px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
flex-shrink: 0;
font-size: 0.85rem;
}
// Container becomes column for mobile
.statistics-container {
flex-direction: column;
}
// Right pane takes full width
.statistics-right-pane {
padding: 0.75rem;
}
.statistics-content-header {
margin-bottom: 0.75rem;
&__title h2 {
font-size: 1.25rem;
}
// Hide desktop refresh button on mobile (the mobile tab bar has its own icon button)
.statistics-refresh-btn {
display: none;
}
}
// Stack cards
.statistics-grid {
grid-template-columns: 1fr;
}
.traffic-panel {
padding: 0.8rem;
}
// Stack chart and legend vertically on phone
.traffic-pie {
grid-template-columns: 1fr;
svg {
max-height: 14rem;
}
}
// Bandwidth mobile
.bandwidth-summary-grid {
grid-template-columns: 1fr;
gap: 0.65rem;
}
.bandwidth-stat-card {
padding: 0.75rem 1rem;
}
.bandwidth-panel {
padding: 0.8rem 0.5rem;
}
}

View File

@ -0,0 +1,360 @@
const m = require('mithril');
const rs = require('rswebui');
const NetworkData = require('network/network_data');
function idString(value) {
if (!value) return '';
if (typeof value === 'string') return value;
return rs.idToHex(value);
}
const formatBytes = rs.formatBytes;
function friendNamesFromCache() {
const names = {};
Object.values(NetworkData.gpgDetails || {}).forEach((profile) => {
(profile.locations || []).forEach((location) => {
const id = idString(location.id);
if (id) names[id] = profile.name || location.name || id;
});
});
return names;
}
function number64(value) {
if (!value) return 0;
if (typeof value === 'object') return Number(value.xstr64 || value.xint64) || 0;
return Number(value) || 0;
}
function parseRates(raw) {
if (!raw) return {};
return {
rateIn: Number(raw.mRateIn !== undefined ? raw.mRateIn : raw.rateIn) || 0,
rateMaxIn: Number(raw.mRateMaxIn !== undefined ? raw.mRateMaxIn : raw.rateMaxIn) || 0,
allocIn: Number(raw.mAllocIn !== undefined ? raw.mAllocIn : raw.allocIn) || 0,
rateOut: Number(raw.mRateOut !== undefined ? raw.mRateOut : raw.rateOut) || 0,
rateMaxOut: Number(raw.mRateMaxOut !== undefined ? raw.mRateMaxOut : raw.rateMaxOut) || 0,
allowedOut: Number(raw.mAllowedOut !== undefined ? raw.mAllowedOut : raw.allowedOut) || 0,
queueIn: Number(raw.mQueueIn !== undefined ? raw.mQueueIn : raw.queueIn) || 0,
queueOut: Number(raw.mQueueOut !== undefined ? raw.mQueueOut : raw.queueOut) || 0,
queueOutBytes: number64(raw.mQueueOutBytes !== undefined ? raw.mQueueOutBytes : raw.queueOutBytes),
totalIn: number64(raw.mTotalIn !== undefined ? raw.mTotalIn : raw.totalIn),
totalOut: number64(raw.mTotalOut !== undefined ? raw.mTotalOut : raw.totalOut),
};
}
function computeDrain(queueOutBytes, rateOut) {
const effectiveSpeed = Math.max(rateOut, 1.0);
return queueOutBytes / (effectiveSpeed * 1024.0);
}
function formatRate(rate) {
if (!rate || rate <= 0) return '0.0';
return rate.toFixed(1);
}
const COLORS = ['#0788cb', '#10b981', '#f59e0b', '#8b5cf6', '#ef4444', '#06b6d4', '#ec4899', '#84cc16', '#64748b', '#f97316'];
function DonutChart() {
return {
view(vnode) {
const rows = (vnode.attrs.rows || []).filter((r) => r.value > 0);
const total = vnode.attrs.total !== undefined ? vnode.attrs.total : rows.reduce((s, r) => s + r.value, 0);
const caption = vnode.attrs.caption || 'total';
let offset = 0;
if (!rows.length && !total) {
return m('.traffic-empty', [
m('i.fas.fa-chart-pie'),
m('p', 'No session data recorded yet.'),
]);
}
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'),
m('g[transform="rotate(-90 60 60)"]', rows.map((row, index) => {
const length = total ? (row.value / 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.value)}`));
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', caption),
]),
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.value / total) * 100).toFixed(1) : 0}%`),
])
)),
]);
},
};
}
function DrainBadge(drainSec) {
let badgeClass = 'bandwidth-drain-badge';
if (drainSec >= 60) {
badgeClass += ' bandwidth-drain-badge--critical';
} else if (drainSec >= 30) {
badgeClass += ' bandwidth-drain-badge--warning';
}
const display = Math.round(drainSec);
return m('span', { class: badgeClass }, `${display} s`);
}
module.exports = {
totalRates: null,
peerRates: [],
loading: false,
error: '',
timer: null,
async load() {
if (this.loading) return;
this.loading = true;
try {
const [totalRes, allRes] = await Promise.all([
rs.rsJsonApiRequest('/rsConfig/getTotalBandwidthRates'),
rs.rsJsonApiRequest('/rsConfig/getAllBandwidthRates'),
]);
const totalBody = totalRes.body || totalRes || {};
const allBody = allRes.body || allRes || {};
if (totalRes.status === 200 && totalBody.retval) {
const rawTotals = totalBody.rates || {};
this.totalRates = parseRates(rawTotals);
this.totalRates.drain = computeDrain(this.totalRates.queueOutBytes, this.totalRates.rateOut);
this.error = '';
} else {
this.totalRates = null;
}
if (allRes.status === 200 && allBody.retval) {
const friendNames = friendNamesFromCache();
const rawMap = allBody.ratemap || {};
const entries = Array.isArray(rawMap)
? rawMap
: Object.entries(rawMap).map(([key, value]) => ({ key, value }));
this.peerRates = entries.map((entry) => {
const id = idString(entry.key);
const rates = parseRates(entry.value);
const drain = computeDrain(rates.queueOutBytes, rates.rateOut);
const name = friendNames[id] || (id ? `Peer ${id.slice(0, 8)}` : 'Unknown peer');
return {
id,
name,
...rates,
drain,
};
}).sort((a, b) => b.rateIn + b.rateOut - (a.rateIn + a.rateOut));
} else {
this.peerRates = [];
}
} catch (err) {
this.error = 'Failed to load bandwidth statistics from RetroShare Core.';
} finally {
this.loading = false;
m.redraw();
}
},
oninit() {
this.totalRates = null;
this.peerRates = [];
this.error = '';
this.load();
this.timer = setInterval(() => this.load(), 5000);
},
onremove() {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
},
view() {
const totals = this.totalRates;
const peers = this.peerRates;
return m('.bandwidth-view', [
this.error && m('.statistics-error', [m('i.fas.fa-exclamation-triangle'), this.error]),
// ── Top summary cards ──
totals && m('.bandwidth-summary-grid', [
m('.bandwidth-stat-card', [
m('.bandwidth-stat-card__icon.bandwidth-stat-card__icon--in', m('i.fas.fa-arrow-down')),
m('.bandwidth-stat-card__body', [
m('.bandwidth-stat-card__value', `${formatRate(totals.rateIn)} kB/s`),
m('.bandwidth-stat-card__label', 'Download speed'),
]),
]),
m('.bandwidth-stat-card', [
m('.bandwidth-stat-card__icon.bandwidth-stat-card__icon--out', m('i.fas.fa-arrow-up')),
m('.bandwidth-stat-card__body', [
m('.bandwidth-stat-card__value', `${formatRate(totals.rateOut)} kB/s`),
m('.bandwidth-stat-card__label', 'Upload speed'),
]),
]),
m('.bandwidth-stat-card', [
m('.bandwidth-stat-card__icon.bandwidth-stat-card__icon--queue', m('i.fas.fa-layer-group')),
m('.bandwidth-stat-card__body', [
m('.bandwidth-stat-card__value', formatBytes(totals.queueOutBytes)),
m('.bandwidth-stat-card__label', `Queue (${totals.queueOut} pkts / ${Math.round(totals.drain)}s drain)`),
]),
]),
m('.bandwidth-stat-card', [
m('.bandwidth-stat-card__icon.bandwidth-stat-card__icon--session', m('i.fas.fa-exchange-alt')),
m('.bandwidth-stat-card__body', [
m('.bandwidth-stat-card__value', `${formatBytes(totals.totalIn)} / ${formatBytes(totals.totalOut)}`),
m('.bandwidth-stat-card__label', 'Session In / Out'),
]),
]),
]),
// ── Session distribution charts by friends ──
(() => {
const inRows = peers
.map((p) => ({ label: p.name, value: p.totalIn }))
.filter((r) => r.value > 0)
.sort((a, b) => b.value - a.value);
const peerInSum = inRows.reduce((sum, r) => sum + r.value, 0);
const pastIn = totals ? Math.max(0, totals.totalIn - peerInSum) : 0;
if (pastIn > 0) {
inRows.push({ label: 'Disconnected peers', value: pastIn });
}
const outRows = peers
.map((p) => ({ label: p.name, value: p.totalOut }))
.filter((r) => r.value > 0)
.sort((a, b) => b.value - a.value);
const peerOutSum = outRows.reduce((sum, r) => sum + r.value, 0);
const pastOut = totals ? Math.max(0, totals.totalOut - peerOutSum) : 0;
if (pastOut > 0) {
outRows.push({ label: 'Disconnected peers', value: pastOut });
}
return m('.statistics-grid', [
m('section.traffic-panel', [
m('.traffic-panel__heading', [
m('i.fas.fa-arrow-down'),
m('div', [
m('h3', 'Session Received by friend'),
m('p', 'Incoming data transferred per friend during this session.'),
]),
]),
m(DonutChart, {
rows: inRows,
total: totals ? totals.totalIn : peerInSum,
label: 'Session received distribution',
caption: 'session in',
}),
]),
m('section.traffic-panel', [
m('.traffic-panel__heading', [
m('i.fas.fa-arrow-up'),
m('div', [
m('h3', 'Session Sent by friend'),
m('p', 'Outgoing data transferred per friend during this session.'),
]),
]),
m(DonutChart, {
rows: outRows,
total: totals ? totals.totalOut : peerOutSum,
label: 'Session sent distribution',
caption: 'session out',
}),
]),
]);
})(),
// ── Bandwidth detailed rates table ──
m('section.traffic-panel.bandwidth-panel', [
m('.traffic-panel__heading', [
m('i.fas.fa-tachometer-alt'),
m('div', [
m('h3', 'Bandwidth Control Rates'),
m('p', 'Real-time throughput, allocation limits, output queues, and estimated drain time per peer.'),
]),
]),
m('.traffic-table-wrap.bandwidth-table-wrap', [
m('table.traffic-table.bandwidth-table', [
m('thead', [
m('tr', [
m('th', 'Peer'),
m('th', 'Peer ID'),
m('th', { title: 'Current real-time download speed' }, 'In Rate (kB/s)'),
m('th', { title: 'Total data received this session' }, 'Session In'),
m('th', { title: 'Maximum download speed allocated to this peer' }, 'In Max (kB/s)'),
m('th', { title: 'Incoming data packets waiting to be processed' }, 'In Queue'),
m('th', { title: 'Current real-time upload speed' }, 'Out Rate (kB/s)'),
m('th', { title: 'Total data sent this session' }, 'Session Out'),
m('th', { title: 'Maximum upload speed allocated to this peer' }, 'Out Max (kB/s)'),
m('th', { title: 'Upload limit requested by remote peer' }, 'Out Allowed (kB/s)'),
m('th', { title: 'Outgoing data packets waiting to be sent' }, 'Out Queue'),
m('th', { title: 'Total size buffered in output queue' }, 'Queue Size'),
m('th', { title: 'Estimated time to empty output queue at current speed' }, 'Drain'),
]),
]),
m('tbody', [
// Pinned Totals row at top (same as Qt BwCtrlWindow)
totals && m('tr.bandwidth-totals-row', [
m('td', m('strong', 'Totals')),
m('td.bandwidth-peerid-cell', '—'),
m('td', m('strong', formatRate(totals.rateIn))),
m('td', m('strong', formatBytes(totals.totalIn))),
m('td', formatRate(totals.rateMaxIn)),
m('td', totals.queueIn.toLocaleString()),
m('td', m('strong', formatRate(totals.rateOut))),
m('td', m('strong', formatBytes(totals.totalOut))),
m('td', formatRate(totals.rateMaxOut)),
m('td', '—'),
m('td', totals.queueOut.toLocaleString()),
m('td', formatBytes(totals.queueOutBytes)),
m('td', DrainBadge(totals.drain)),
]),
// Peer rows
peers.length
? peers.map((p) =>
m('tr', [
m('td', { title: p.id }, m('span.bandwidth-peer-name', p.name)),
m('td.bandwidth-peerid-cell', { title: p.id }, `${p.id.slice(0, 8)}`),
m('td', formatRate(p.rateIn)),
m('td', formatBytes(p.totalIn)),
m('td', p.rateMaxIn > 0 ? formatRate(p.rateMaxIn) : '—'),
m('td', p.queueIn.toLocaleString()),
m('td', formatRate(p.rateOut)),
m('td', formatBytes(p.totalOut)),
m('td', p.rateMaxOut > 0 ? formatRate(p.rateMaxOut) : '—'),
m('td', p.allowedOut > 0 ? formatRate(p.allowedOut) : '—'),
m('td', p.queueOut.toLocaleString()),
m('td', formatBytes(p.queueOutBytes)),
m('td', DrainBadge(p.drain)),
])
)
: (!totals
? m('tr', [m('td[colspan=13]', m('.traffic-empty', [m('i.fas.fa-tachometer-alt'), m('p', 'No bandwidth data available.')]))])
: null),
]),
]),
]),
]),
m('p.statistics-note', 'Bandwidth rates reflect current peer socket transfer states and refresh every 5 seconds.'),
]);
},
};

View File

@ -1,6 +1,7 @@
const m = require('mithril');
const rs = require('rswebui');
const NetworkData = require('network/network_data');
const Bandwidth = require('statistics/bandwidth');
const COLORS = ['#0788cb', '#10b981', '#f59e0b', '#8b5cf6', '#ef4444', '#06b6d4', '#ec4899', '#84cc16', '#64748b', '#f97316'];
const SERVICE_NAMES = {
@ -13,6 +14,7 @@ const SERVICE_NAMES = {
0x0200: 'Network exchange', 0x0211: 'Identities', 0x0215: 'Forums',
0x0216: 'Boards', 0x0217: 'Channels', 0x0218: 'Circles', 0x0219: 'Reputation',
0x0220: 'GXS recognition', 0x0230: 'GXS mail', 0x0240: 'JSON API',
0x1011: 'RTT',
};
function idString(value) {
@ -130,7 +132,10 @@ function TrafficPanel() {
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)])]),
m('.traffic-panel__heading', [
m('i.fas.' + (vnode.attrs.icon || 'fa-chart-pie')),
m('div', [m('h3', 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', [
@ -146,8 +151,28 @@ function TrafficPanel() {
};
}
// Navigation sections — Traffic and Bandwidth are implemented
const NAV_SECTIONS = [
{ id: 'traffic', label: 'Traffic', icon: 'fa-chart-pie', description: 'Live traffic distribution reported by RetroShare Core.' },
{ id: 'bandwidth', label: 'Bandwidth', icon: 'fa-tachometer-alt', description: 'Real-time bandwidth rates and peer throughput.' },
];
function PlaceholderSection() {
return {
view(vnode) {
const section = vnode.attrs.section;
return m('.statistics-placeholder', [
m('i.fas.' + section.icon),
m('h3', section.label),
m('p', 'Coming soon — this section is not yet implemented.'),
]);
},
};
}
module.exports = {
oninit(vnode) {
vnode.state.activeSection = 'traffic';
vnode.state.incoming = [];
vnode.state.outgoing = [];
vnode.state.error = '';
@ -201,10 +226,23 @@ module.exports = {
vnode.state.friendsRefreshedAt = 0;
vnode.state.loading = false;
vnode.state.load();
vnode.state.timer = setInterval(vnode.state.load, 5000);
vnode.state.timer = setInterval(() => {
if (vnode.state.activeSection === 'traffic') {
vnode.state.load();
}
}, 5000);
},
onremove(vnode) { clearInterval(vnode.state.timer); },
view(vnode) {
const activeSection = NAV_SECTIONS.find((s) => s.id === vnode.state.activeSection) || NAV_SECTIONS[0];
const switchSection = (id) => {
vnode.state.activeSection = id;
if (id === 'traffic') {
vnode.state.load();
}
};
const serviceLabel = (value) => {
const id = Number(value) || 0;
return SERVICE_NAMES[id] || `Service 0x${id.toString(16).padStart(4, '0')}`;
@ -222,19 +260,88 @@ module.exports = {
? 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']),
// Refresh handler based on active section
const isBandwidth = activeSection.id === 'bandwidth';
const isLoading = isBandwidth ? Bandwidth.loading : vnode.state.loading;
const handleRefresh = () => {
if (isBandwidth) {
Bandwidth.load();
} else {
vnode.state.load();
}
};
// Build the content for the active section
let sectionContent;
if (activeSection.id === 'traffic') {
sectionContent = [
vnode.state.error && m('.statistics-error', [m('i.fas.fa-exclamation-triangle'), vnode.state.error]),
m('.statistics-grid', [
m(TrafficPanel, { title: 'By service', icon: 'fa-layer-group', description: 'Which RetroShare services use the most bandwidth.', column: 'Service', rows: serviceRows }),
m(TrafficPanel, { title: 'By friend', icon: 'fa-user-friends', 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.'),
];
} else if (activeSection.id === 'bandwidth') {
sectionContent = m(Bandwidth);
} else {
sectionContent = m(PlaceholderSection, { section: activeSection });
}
return m('.statistics-container', [
// ── Left pane: header card + navigation ──
m('.statistics-left-pane', [
m('.statistics-header-card', [
m('.statistics-header-card__title', [
m('i.fas.fa-chart-pie'),
m('div', [m('h1', 'Statistics'), m('p', 'Traffic & routing stats')]),
]),
]),
m('nav.statistics-nav', NAV_SECTIONS.map((section) =>
m('button.statistics-nav-item[type=button]', {
class: activeSection.id === section.id ? 'active' : '',
onclick: () => switchSection(section.id),
title: section.description,
}, [m('i.fas.' + section.icon), m('span', section.label)])
)),
]),
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 }),
// ── Mobile tab bar (visible only on small screens) ──
m('.statistics-mobile-tabs', [
m('.statistics-mobile-tabs__list', NAV_SECTIONS.map((section) =>
m('button.statistics-mobile-tab[type=button]', {
class: activeSection.id === section.id ? 'active' : '',
onclick: () => switchSection(section.id),
}, [m('i.fas.' + section.icon), m('span', section.label)])
)),
m('button.statistics-mobile-refresh[type=button]', {
disabled: isLoading,
onclick: handleRefresh,
title: 'Refresh',
}, m('i.fas.fa-sync-alt')),
]),
// ── Right pane: section content ──
m('.statistics-right-pane', [
m('.statistics-content-header', [
m('.statistics-content-header__title', [
m('h2', activeSection.label),
m('p', activeSection.description),
]),
m('button.statistics-refresh-btn[type=button]', {
disabled: isLoading,
onclick: handleRefresh,
title: 'Refresh statistics',
}, [
m('i.fas.fa-sync-alt'),
m('span.btn-text', 'Refresh'),
]),
]),
sectionContent,
]),
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