Sortable peers table, shorter connection history
This commit is contained in:
@@ -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; }
|
||||||
|
|||||||
@@ -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.')),
|
||||||
|
|||||||
Reference in New Issue
Block a user