Sortable peers table, shorter connection history

This commit is contained in:
Daniel Redetzke
2026-10-04 20:53:56 +03:00
parent 82228faeba
commit 542ee98a85
2 changed files with 67 additions and 11 deletions
+4
View File
@@ -136,6 +136,10 @@ h1 { margin: 0; font-size: 26px; font-weight: 600; letter-spacing: -0.01em; over
table { width: 100%; border-collapse: collapse; min-width: 720px; } table { width: 100%; border-collapse: collapse; min-width: 720px; }
table.narrow { min-width: 520px; } table.narrow { min-width: 520px; }
th { text-align: left; font-size: 12px; font-weight: 600; color: var(--ink-2); padding: 10px 12px; border-bottom: 1px solid var(--line); white-space: nowrap; } th { text-align: left; font-size: 12px; font-weight: 600; color: var(--ink-2); padding: 10px 12px; border-bottom: 1px solid var(--line); white-space: nowrap; }
th .sort { display: inline-flex; align-items: center; gap: 4px; background: none; border: 0; padding: 0; font: inherit; color: inherit; cursor: pointer; }
th .sort:hover, th .sort.on { color: var(--ink); }
th .sort .arrow { font-size: 11px; opacity: 0.35; }
th .sort:hover .arrow, th .sort.on .arrow { opacity: 1; }
td { padding: 12px; border-bottom: 1px solid var(--line-2); vertical-align: middle; } td { padding: 12px; border-bottom: 1px solid var(--line-2); vertical-align: middle; }
tr:last-child td { border-bottom: 0; } tr:last-child td { border-bottom: 0; }
.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } .num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
+63 -11
View File
@@ -740,9 +740,50 @@
// ---------- peers ---------- // ---------- peers ----------
// PEER_SORT holds the sort keys of the peers table. Each returns a value
// where smaller sorts first; null always sorts last. Numbers start
// descending, text ascending.
const STATE_ORDER = ['online', 'offline', 'never', 'setup', 'nokey', 'disabled'];
const ipNum = (ip) => ip.split('.').reduce((n, o) => n * 256 + Number(o), 0);
const PEER_SORT = {
name: { label: 'Name', key: (p) => p.name.toLowerCase() },
address: { label: 'Address', key: (p) => ipNum(p.ipv4) },
status: { label: 'Status', key: (p) => STATE_ORDER.indexOf(peerState(p).key) * 1e13 - (p.stats.lastHandshake ? Date.parse(p.stats.lastHandshake) : 0) },
endpoint: { label: 'Endpoint', key: (p) => p.stats.endpoint ? ((p.stats.location && p.stats.location.country) || '~') + ' ' + p.stats.endpoint : null },
latency: { label: 'Latency', num: true, asc: true, key: (p) => { const st = latState(p); return st && st.ms != null ? st.ms : null; } },
down: { label: 'Download, 30 d', num: true, key: (p) => p.stats.down30d },
up: { label: 'Upload, 30 d', num: true, key: (p) => p.stats.up30d },
enabled: { label: 'Enabled', key: (p) => (p.enabled ? 0 : 1) },
};
let peerSort = { by: null, desc: false }; // kept while the app is open
async function viewPeers(wrap) { async function viewPeers(wrap) {
let q = '', filter = 'all', data = await api('GET', '/peers'); let q = '', filter = 'all', data = await api('GET', '/peers');
const tbody = h('tbody'); const tbody = h('tbody');
const headRow = h('tr');
const sortBy = (k) => {
const c = PEER_SORT[k];
peerSort = peerSort.by === k ? { by: k, desc: !peerSort.desc } : { by: k, desc: c.num && !c.asc };
drawHead();
drawRows();
};
const drawHead = () => headRow.replaceChildren(
...Object.entries(PEER_SORT).map(([k, c]) => {
const on = peerSort.by === k;
return h('th', { class: c.num ? 'num' : null, 'aria-sort': on ? (peerSort.desc ? 'descending' : 'ascending') : 'none' },
h('button', { type: 'button', class: on ? 'sort on' : 'sort', onClick: () => sortBy(k) }, c.label,
h('span', { class: 'arrow', 'aria-hidden': 'true' }, on ? (peerSort.desc ? '↓' : '↑') : '↕')));
}),
h('th', null, h('span', { class: 'sr' }, 'Actions')));
const sorted = (rows) => {
if (!peerSort.by) return rows;
const key = PEER_SORT[peerSort.by].key, dir = peerSort.desc ? -1 : 1;
return rows.map((p) => [p, key(p)]).sort(([a, ka], [b, kb]) => {
if (ka == null || kb == null) return ka == null && kb == null ? 0 : ka == null ? 1 : -1;
const c = typeof ka === 'string' ? ka.localeCompare(kb) : ka - kb;
return c * dir || a.name.localeCompare(b.name);
}).map(([p]) => p);
};
const empty = h('p', { class: 'empty', hidden: true }, 'No peers match this filter.'); const empty = h('p', { class: 'empty', hidden: true }, 'No peers match this filter.');
const sub = h('p', { class: 'sub' }); const sub = h('p', { class: 'sub' });
const pills = h('div', { class: 'pills', role: 'group', 'aria-label': 'Status filter' }); const pills = h('div', { class: 'pills', role: 'group', 'aria-label': 'Status filter' });
@@ -767,7 +808,7 @@
const keep = filter === 'all' || filter === st || (filter === 'offline' && ['offline', 'never', 'setup', 'nokey'].includes(st)); const keep = filter === 'all' || filter === st || (filter === 'offline' && ['offline', 'never', 'setup', 'nokey'].includes(st));
return hit && keep; return hit && keep;
}); });
tbody.replaceChildren(...rows.map((p) => h('tr', null, tbody.replaceChildren(...sorted(rows).map((p) => h('tr', null,
h('td', null, h('a', { href: '#/peers/' + p.id }, h('strong', null, p.name)), p.note ? h('div', { class: 'note' }, p.note) : null), h('td', null, h('a', { href: '#/peers/' + p.id }, h('strong', null, p.name)), p.note ? h('div', { class: 'note' }, p.note) : null),
h('td', { class: 'mono' }, p.ipv4), h('td', { class: 'mono' }, p.ipv4),
h('td', null, badge(peerState(p))), h('td', null, badge(peerState(p))),
@@ -782,6 +823,7 @@
}; };
drawPills(); drawPills();
drawHead();
drawRows(); drawRows();
fill(wrap, fill(wrap,
h('div', { class: 'head' }, h('div', { class: 'head' },
@@ -792,8 +834,7 @@
h('input', { id: 'q', type: 'search', placeholder: 'Search name, address or note', style: { flex: '1 1 260px', maxWidth: '360px' }, onInput: (e) => { q = e.target.value.toLowerCase(); drawRows(); } }), h('input', { id: 'q', type: 'search', placeholder: 'Search name, address or note', style: { flex: '1 1 260px', maxWidth: '360px' }, onInput: (e) => { q = e.target.value.toLowerCase(); drawRows(); } }),
pills), pills),
h('section', { class: 'card flush' }, h('div', { class: 'tbl' }, h('table', null, h('section', { class: 'card flush' }, h('div', { class: 'tbl' }, h('table', null,
h('thead', null, h('tr', null, ['Name', 'Address', 'Status', 'Endpoint'].map((t) => h('th', null, t)), h('thead', null, headRow),
h('th', { class: 'num' }, 'Latency'), h('th', { class: 'num' }, 'Download, 30 d'), h('th', { class: 'num' }, 'Upload, 30 d'), h('th', null, 'Enabled'), h('th', null, h('span', { class: 'sr' }, 'Actions')))),
tbody), empty)), tbody), empty)),
h('p', { class: 'muted', style: { margin: '0', fontSize: '13px' } }, 'Online means a handshake in the last 3 minutes. Latency is the round trip from the server through the tunnel to the device and back, median of the last 5 minutes; turn it on in a peer\'s settings. Download and Upload are measured from the peer\'s side. Changes apply live without disconnecting other peers.')); h('p', { class: 'muted', style: { margin: '0', fontSize: '13px' } }, 'Online means a handshake in the last 3 minutes. Latency is the round trip from the server through the tunnel to the device and back, median of the last 5 minutes; turn it on in a peer\'s settings. Download and Upload are measured from the peer\'s side. Changes apply live without disconnecting other peers.'));
every(15000, async () => { try { data = await api('GET', '/peers'); drawRows(); } catch { /* keep last */ } }); every(15000, async () => { try { data = await api('GET', '/peers'); drawRows(); } catch { /* keep last */ } });
@@ -937,8 +978,24 @@
// ---------- peer detail ---------- // ---------- peer detail ----------
async function viewPeer(wrap, id) { async function viewPeer(wrap, id) {
const [p, srv, sess] = await Promise.all([api('GET', '/peers/' + id), api('GET', '/server'), api('GET', '/peers/' + id + '/sessions?limit=50')]); const [p, srv, sess] = await Promise.all([api('GET', '/peers/' + id), api('GET', '/server'), api('GET', '/peers/' + id + '/sessions?limit=100')]);
const sessions = sess.sessions; const sessions = sess.sessions;
// The history shows the newest rows; the rest open on request.
const SHORT = 8;
let allSessions = false;
const sessBody = h('tbody');
const sessMore = h('button', { type: 'button', class: 'btn small', onClick: () => { allSessions = !allSessions; drawSessions(); } });
const drawSessions = () => {
sessBody.replaceChildren(...(allSessions ? sessions : sessions.slice(0, SHORT)).map((se) => h('tr', null,
h('td', null, fmtStamp(se.start)),
h('td', null, se.open ? [h('span', { class: 'badge' }, h('span', { class: 'dot ok' }), 'Online now'), ' ', fmtDuration(se.seconds)] : fmtDuration(se.seconds)),
h('td', null, fmtLocation(se.geo) || h('span', { class: 'muted' }, 'Unknown')),
h('td', { class: 'mono muted' }, se.ip),
h('td', { class: 'num' }, fmtBytes(se.down)),
h('td', { class: 'num' }, fmtBytes(se.up)))));
sessMore.textContent = allSessions ? 'Show fewer' : 'Show all ' + sessions.length;
};
drawSessions();
let range = '7d'; let range = '7d';
const st = peerState(p); const st = peerState(p);
const traffic = h('div'); const traffic = h('div');
@@ -1111,14 +1168,9 @@
sessions.length ? h('div', { class: 'tbl' }, h('table', null, sessions.length ? h('div', { class: 'tbl' }, h('table', null,
h('thead', null, h('tr', null, h('th', null, 'Started'), h('th', null, 'Duration'), h('th', null, 'From'), h('th', null, 'Address'), h('thead', null, h('tr', null, h('th', null, 'Started'), h('th', null, 'Duration'), h('th', null, 'From'), h('th', null, 'Address'),
h('th', { class: 'num' }, 'Download'), h('th', { class: 'num' }, 'Upload'))), h('th', { class: 'num' }, 'Download'), h('th', { class: 'num' }, 'Upload'))),
h('tbody', null, sessions.map((se) => h('tr', null, sessBody))
h('td', null, fmtStamp(se.start)),
h('td', null, se.open ? [h('span', { class: 'badge' }, h('span', { class: 'dot ok' }), 'Online now'), ' ', fmtDuration(se.seconds)] : fmtDuration(se.seconds)),
h('td', null, fmtLocation(se.geo) || h('span', { class: 'muted' }, 'Unknown')),
h('td', { class: 'mono muted' }, se.ip),
h('td', { class: 'num' }, fmtBytes(se.down)),
h('td', { class: 'num' }, fmtBytes(se.up)))))))
: h('p', { class: 'empty' }, 'No connections recorded yet.'), : h('p', { class: 'empty' }, 'No connections recorded yet.'),
sessions.length > SHORT ? h('div', { style: { margin: '8px 12px 0' } }, sessMore) : null,
h('p', { class: 'hint', style: { margin: '4px 12px 12px' } }, 'Country and network: ', h('p', { class: 'hint', style: { margin: '4px 12px 12px' } }, 'Country and network: ',
h('a', { href: 'https://db-ip.com', target: '_blank', rel: 'noopener' }, 'IP Geolocation by DB-IP'), h('a', { href: 'https://db-ip.com', target: '_blank', rel: 'noopener' }, 'IP Geolocation by DB-IP'),
'. Kept as long as the daily traffic history.')), '. Kept as long as the daily traffic history.')),