Merge branch 'feature/traffic-analytics'
This commit is contained in:
commit
5fdeff012c
10 files changed
+649
-6
No files matched your search
+62
-1
@@ -634,7 +634,7 @@ views.ports = async () => {
|
||||
S.headerAction = ['Add resource', () => portForm()];
|
||||
const q = h('input', {type:'search', placeholder:'Search resources…', oninput:() => { const t = q.value.toLowerCase(); table.querySelectorAll('tbody tr').forEach(tr => { tr.style.display = tr.textContent.toLowerCase().includes(t) ? '' : 'none'; }); }});
|
||||
return h('div', {}, unsetDash ? h('div', {class:'notice'}, 'Some ports have a login in front of them, but the dashboard URL isn\'t set. Visitors are sent back to this address\'s login page, so set it under Server → Settings if people reach Hammerhead by a different address.') : null,
|
||||
tagBar('ports', tags), rows.length > 5 ? h('div', {class:'toolbar'}, q) : null, table);
|
||||
tagBar('ports', tags), rows.length ? h('div', {class:'toolbar'}, h('button', {class:'ghost', onclick:() => trafficDialog('all', 'All resources')}, 'Traffic overview')) : null, rows.length > 5 ? h('div', {class:'toolbar'}, q) : null, table);
|
||||
};
|
||||
|
||||
function spark(series) {
|
||||
@@ -648,6 +648,57 @@ function spark(series) {
|
||||
return h('div', {title:'traffic per hour, last 24 hours'}, svg);
|
||||
}
|
||||
|
||||
// Traffic chart for one resource (or "all"): stacked bars per time bucket.
|
||||
function trafficChart(d) {
|
||||
const ns = 'http://www.w3.org/2000/svg', W = 640, H = 170, L = 52, B = 22, T = 8, n = d.points.length;
|
||||
const mk = (tag, attrs, text) => { const e = document.createElementNS(ns, tag); for (const k in attrs) e.setAttribute(k, attrs[k]); if (text != null) e.textContent = text; return e; };
|
||||
const per = d.bucketSeconds >= 3600 ? 'hour' : 'minute';
|
||||
const svg = mk('svg', {viewBox:`0 0 ${W} ${H}`, width:'100%', role:'img', 'aria-label':`Traffic per ${per}: ${bytes(d.total.bytesOut)} out and ${bytes(d.total.bytesIn)} in`});
|
||||
const max = Math.max(d.peakBucket, 1), plotH = H - B - T, bw = (W - L) / n;
|
||||
for (const f of [0, .5, 1]) {
|
||||
const y = T + plotH * (1 - f);
|
||||
svg.append(mk('line', {x1:L, x2:W, y1:y, y2:y, stroke:'var(--border)', 'stroke-width':1}), mk('text', {x:L - 6, y:y + 4, 'text-anchor':'end', fill:'var(--text-faint)', 'font-size':11}, bytes(max * f)));
|
||||
}
|
||||
d.points.forEach((p, i) => {
|
||||
const hOut = p.bytesOut / max * plotH, hIn = p.bytesIn / max * plotH, x = L + i * bw, g = mk('g', {});
|
||||
g.append(mk('title', {}, `${new Date(p.t * 1000).toLocaleString(undefined, {month:'short', day:'numeric', hour:'numeric', minute:per === 'minute' ? '2-digit' : undefined})}: ${bytes(p.bytesOut)} out, ${bytes(p.bytesIn)} in, ${p.conns} conn`),
|
||||
mk('rect', {x:x + .5, y:T + plotH - hOut, width:Math.max(bw - 1, .5), height:hOut, fill:'var(--accent)'}),
|
||||
mk('rect', {x:x + .5, y:T + plotH - hOut - hIn, width:Math.max(bw - 1, .5), height:hIn, fill:'var(--text-faint)'}));
|
||||
svg.append(g);
|
||||
});
|
||||
for (const i of [0, Math.floor(n / 2), n - 1]) {
|
||||
const t = new Date(d.points[i].t * 1000);
|
||||
svg.append(mk('text', {x:i === n - 1 ? W : L + i * bw, y:H - 6, 'text-anchor':i === 0 ? 'start' : i === n - 1 ? 'end' : 'middle', fill:'var(--text-faint)', 'font-size':11},
|
||||
per === 'minute' || n <= 24 ? t.toLocaleTimeString(undefined, {hour:'numeric', minute:'2-digit'}) : t.toLocaleDateString(undefined, {month:'short', day:'numeric'})));
|
||||
}
|
||||
return svg;
|
||||
}
|
||||
|
||||
// A dialog with the traffic chart, totals and busiest visitors of one resource ("all" = every resource).
|
||||
function trafficDialog(id, name) {
|
||||
const body = h('div', {class:'stack'}, h('p', {class:'dim'}, 'Loading…'));
|
||||
const close = modal('Traffic: ' + name, body, {wide:true});
|
||||
const load = async r => {
|
||||
const d = await api('GET', O(`/traffic/${id}?range=${r}`));
|
||||
const per = d.bucketSeconds >= 3600 ? 'hour' : 'minute';
|
||||
const ranges = [['1h', 'Hour'], ['6h', '6 hours'], ['24h', 'Day'], ['7d', 'Week'], ['30d', 'Month']];
|
||||
const covered = d.sourcesHours >= 48 ? Math.round(d.sourcesHours / 24) + ' days' : d.sourcesHours === 1 ? 'hour' : d.sourcesHours + ' hours';
|
||||
body.replaceChildren(
|
||||
h('div', {class:'tabs2'}, ranges.map(([k, l]) => h('button', {class:k === r ? 'on' : '', onclick:() => load(k).catch(e => toast(e.message))}, l))),
|
||||
d.total.bytesIn + d.total.bytesOut ? h('div', {}, trafficChart(d),
|
||||
h('div', {class:'small dim'}, h('span', {style:'color:var(--accent)'}, '■ '), 'To visitors (out) ', h('span', {style:'margin-left:12px;color:var(--text-faint)'}, '■ '), 'From visitors (in) · per ' + per)) :
|
||||
emptyState('ports', 'No traffic in this period', 'Traffic appears here a few seconds after it happens.'),
|
||||
h('div', {class:'small'}, `${d.total.conns} connections or requests, ${bytes(d.total.bytesOut)} out, ${bytes(d.total.bytesIn)} in.`),
|
||||
h('h3', {}, 'Busiest visitors'),
|
||||
d.sources.length ? h('div', {class:'scroll'}, h('table', {},
|
||||
h('thead', {}, h('tr', {}, ['Address', 'Connections', 'Out', 'In'].map(x => h('th', {}, x)))),
|
||||
h('tbody', {}, d.sources.map(s => h('tr', {}, h('td', {class:'mono'}, s.ip), h('td', {}, s.conns), h('td', {}, bytes(s.bytesOut)), h('td', {}, bytes(s.bytesIn))))))) : h('p', {class:'dim small'}, 'None named yet.'),
|
||||
h('p', {class:'dim small'}, `Counted over the last ${covered} (visitor addresses are kept for 7 days). Each report names the busiest ten per resource, so very small visitors may be missing.`),
|
||||
h('div', {class:'actions form-actions'}, h('button', {class:'btn', onclick:() => close()}, 'Close')));
|
||||
};
|
||||
load('24h').catch(e => { close(); toast(e.message); });
|
||||
}
|
||||
|
||||
const portText = m => m.portEnd > m.port ? m.port + '-' + m.portEnd : String(m.port);
|
||||
|
||||
function mappingRow(m, st, series) {
|
||||
@@ -678,6 +729,7 @@ function mappingRow(m, st, series) {
|
||||
})),
|
||||
h('td', {class:'small dim'}, m.total && m.total.conns ? [`${m.total.conns} conn`, h('br'), `${bytes(m.total.bytesIn)} in, ${bytes(m.total.bytesOut)} out`] : 'None yet', spark(series)),
|
||||
h('td', {}, h('div', {class:'actions'},
|
||||
m.redirectTo ? null : h('button', {class:'ghost', onclick:() => trafficDialog(m.id, m.name)}, 'Traffic'),
|
||||
h('button', {class:'ghost', onclick:() => m.redirectTo ? redirectForm(m) : portForm(m)}, 'Edit'),
|
||||
h('button', {class:'ghost', onclick: act(async () => { await api('PUT', O('/mappings/' + m.id), {...m, enabled:!m.enabled}); refresh(); })}, m.enabled ? 'Pause' : 'Enable'),
|
||||
web && prot ? h('button', {class:'ghost', onclick:() => shareDialog(m)}, 'Share') : null,
|
||||
@@ -1210,6 +1262,14 @@ function channelForm(base, existing, trusted) {
|
||||
h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => close()}, 'Cancel'), h('button', {class:'btn', onclick:save}, 'Save'))), {sticky:true});
|
||||
}
|
||||
|
||||
function trafficAlertCard(base, settings) {
|
||||
const spike = h('input', {type:'number', min:0, max:1000, value:settings.spikeFactor || 0}), daily = h('input', {type:'number', min:0, value:settings.dailyMb || 0});
|
||||
return card('Traffic', h('p', {class:'dim'}, 'Be told when a port suddenly carries far more than it usually does, or more than you expect in a day. These apply to every port in this organization. Leave them at 0 to switch them off.'),
|
||||
h('div', {class:'grid'}, field('Spike: times the usual traffic', spike, 'Judged on the last 5 minutes against the previous hours, once a port has an hour of history and carried over 10 MB in those minutes. Try 5.'),
|
||||
field('Most traffic a port may carry in a day (MB)', daily, 'Counted per port from midnight UTC, in and out together.'),
|
||||
h('button', {class:'ghost', onclick: act(async () => { await api('PUT', base + '/traffic', {spikeFactor:+spike.value, dailyMb:+daily.value}); toast('Traffic alerts saved'); })}, 'Save traffic alerts')));
|
||||
}
|
||||
|
||||
async function alertsView(base, scoped, trusted) {
|
||||
const d = await api('GET', base);
|
||||
const channels = h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Type', 'Sends to', ''].map(x => h('th', {}, x)))),
|
||||
@@ -1230,6 +1290,7 @@ async function alertsView(base, scoped, trusted) {
|
||||
scoped ? card('Timing', h('p', {class:'dim'}, 'A problem has to last this long before you are told, so a restart or a brief blip stays quiet. You get one message when it starts and one when it clears.'),
|
||||
h('div', {class:'grid'}, field('Wait this many minutes first', debounce), field('Remind me every (hours)', repeat, 'Use 0 to send no reminders'),
|
||||
h('button', {class:'ghost', onclick: act(async () => { await api('PUT', base + '/settings', {debounceMin:+debounce.value, repeatHours:+repeat.value}); toast('Alert timing saved'); })}, 'Save timing'))) : null,
|
||||
scoped ? trafficAlertCard(base, d.settings) : null,
|
||||
card('History', d.history.length ? h('table', {}, h('tbody', {}, d.history.map(e => h('tr', {}, h('td', {class:'dim small'}, fmtTime(e.time)),
|
||||
h('td', {}, h('span', {class:'tag'}, e.status)), h('td', {}, e.message))))) : emptyState('alerts', 'No alerts yet', 'Alerts that fired and recovered are listed here.')));
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user