diff --git a/internal/api/handlers.go b/internal/api/handlers.go
index c10fdfd..47c7998 100644
--- a/internal/api/handlers.go
+++ b/internal/api/handlers.go
@@ -126,7 +126,7 @@ func (s *Server) overview(w http.ResponseWriter, r *http.Request) {
out["now_bps"] = float64(nt.Wire) * 8 / 300
}
- se, err := s.Store.SeriesByApp(q, 6)
+ se, err := s.Store.SeriesByApp(q, 8)
if err != nil {
fail(w, err)
return
@@ -238,14 +238,22 @@ func (s *Server) sankey(w http.ResponseWriter, r *http.Request) {
fail(w, err)
return
}
- byHost := r.URL.Query().Get("by") != "segment"
- links, err := s.Store.Sankey(q, byHost, 10)
+ mode := r.URL.Query().Get("by")
+ if mode == "" {
+ mode = "host"
+ }
+ // 8 hosts, clients or servers (the colours there are); 6 applications,
+ // services and countries
+ n0, n2 := 8, 6
+ if mode == "conv" {
+ n2 = 8
+ }
+ links, err := s.Store.Sankey(q, mode, n0, n2)
if err != nil {
fail(w, err)
return
}
- // keep the largest nodes per column (10 hosts or 6 segments, 6
- // applications, 6 countries), fold the rest into "__other__"
+ // fold everything past the largest nodes of each column into "__other__"
top := func(get func(store.SankeyLink) string, n ...int) map[string]bool {
t := map[string]uint64{}
for _, l := range links {
@@ -268,13 +276,10 @@ func (s *Server) sankey(w http.ResponseWriter, r *http.Request) {
}
return m
}
- first := 6
- if byHost {
- first = 10
- }
+ first := n0
ks := top(func(l store.SankeyLink) string { return l.Seg }, first)
ka := top(func(l store.SankeyLink) string { return l.App })
- kc := top(func(l store.SankeyLink) string { return l.CC })
+ kc := top(func(l store.SankeyLink) string { return l.CC }, n2)
fold := func(m map[string]bool, k string) string {
if m[k] {
return k
@@ -299,11 +304,7 @@ func (s *Server) sankey(w http.ResponseWriter, r *http.Request) {
sort.Slice(out, func(i, j int) bool { return out[i].V > out[j].V })
return out
}
- by := "segment"
- if byHost {
- by = "host"
- }
- writeJSON(w, http.StatusOK, map[string]any{"by": by, "seg_app": flat(l1), "app_cc": flat(l2)})
+ writeJSON(w, http.StatusOK, map[string]any{"by": mode, "seg_app": flat(l1), "app_cc": flat(l2)})
}
func (s *Server) records(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/store/query.go b/internal/store/query.go
index 5d8b17d..64e57f2 100644
--- a/internal/store/query.go
+++ b/internal/store/query.go
@@ -637,25 +637,34 @@ type SankeyLink struct {
Wire uint64
}
-// Sankey returns traffic by segment (or, with byHost, by internal host),
-// application and remote country. By host, only the top hosts are kept
-// apart; the rest are summed as "__other__".
-func (s *Store) Sankey(q Query, byHost bool, hosts int) ([]SankeyLink, error) {
+// Sankey modes: the three columns of the flow paths chart.
+var sankeyCols = map[string][3]string{
+ // the internal side of each conversation (the client, or the server of
+ // inbound traffic) → application → remote country
+ "host": {"CASE WHEN dir = 2 THEN server ELSE client END", "app", sankeyCC},
+ "segment": {"segment", "app", sankeyCC},
+ "conv": {"client", portProtoExpr, "server"},
+}
+
+const sankeyCC = `CASE WHEN dir = 3 THEN '__internal__' WHEN peer_cc = '' THEN '__unknown__' ELSE peer_cc END`
+
+// Sankey returns traffic by the mode's three columns. The first and last
+// columns keep their top n0 and n2 values; the rest are "__other__".
+func (s *Store) Sankey(q Query, mode string, n0, n2 int) ([]SankeyLink, error) {
where, args, err := q.where()
if err != nil {
return nil, err
}
- cc := `CASE WHEN dir = 3 THEN '__internal__' WHEN peer_cc = '' THEN '__unknown__' ELSE peer_cc END`
- sqlq := fmt.Sprintf(`SELECT segment, app, %s, sum(wire) FROM %s WHERE %s GROUP BY 1,2,3`, cc, s.Source(q.From, q.To), where)
- if byHost {
- // the internal side of each conversation: the client, or the server
- // of inbound traffic
- sqlq = fmt.Sprintf(`WITH b AS (SELECT CASE WHEN dir = %d THEN server ELSE client END AS h, app, %s AS cc, sum(wire) AS w
- FROM %s WHERE %s GROUP BY 1,2,3),
- t AS (SELECT h FROM b GROUP BY h ORDER BY sum(w) DESC LIMIT %d)
- SELECT CASE WHEN h IN (SELECT h FROM t) THEN h ELSE '__other__' END, app, cc, sum(w) FROM b GROUP BY 1,2,3`,
- DirInbound, cc, s.Source(q.From, q.To), where, hosts)
- }
+ c, ok := sankeyCols[mode]
+ if !ok {
+ return nil, fmt.Errorf("unknown flow paths mode %q", mode)
+ }
+ sqlq := fmt.Sprintf(`WITH b AS (SELECT %s AS c0, %s AS c1, %s AS c2, sum(wire) AS w FROM %s WHERE %s GROUP BY 1,2,3),
+ t0 AS (SELECT c0 FROM b GROUP BY c0 ORDER BY sum(w) DESC LIMIT %d),
+ t2 AS (SELECT c2 FROM b GROUP BY c2 ORDER BY sum(w) DESC LIMIT %d)
+ SELECT CASE WHEN c0 IN (SELECT c0 FROM t0) THEN c0 ELSE '__other__' END,
+ c1, CASE WHEN c2 IN (SELECT c2 FROM t2) THEN c2 ELSE '__other__' END, sum(w)
+ FROM b GROUP BY 1,2,3`, c[0], c[1], c[2], s.Source(q.From, q.To), where, n0, n2)
rows, err := s.DB.Query(sqlq, args...)
if err != nil {
return nil, err
diff --git a/internal/web/static/app.css b/internal/web/static/app.css
index 6c07e7e..f215a7b 100644
--- a/internal/web/static/app.css
+++ b/internal/web/static/app.css
@@ -3,7 +3,7 @@
--bg:#f4f6f9; --surface:#ffffff; --surface-2:#f3f5f8; --line:#e4e8ee; --line-2:#cfd6e0;
--ink:#1b2432; --ink-2:#4c5869; --ink-3:#6f7a8a;
--accent:#2a78d6; --accent-soft:#e8f1fc; --accent-ink:#1c5cab;
- --c1:#2a78d6; --c2:#eb6834; --c3:#1baf7a; --c4:#eda100; --c5:#e87ba4; --c6:#008300; --other:#b9bfc9;
+ --c1:#2a78d6; --c2:#eb6834; --c3:#1baf7a; --c4:#eda100; --c5:#e87ba4; --c6:#008300; --c7:#4a3aa7; --c8:#e34948; --other:#b9bfc9;
--base:#8e98a6;
--good:#0ca30c; --warn:#fab219; --crit:#d03b3b; --warn-ink:#8a5d00;
}
@@ -57,6 +57,14 @@ nav.dash button[aria-current="page"]{background:var(--accent-soft);color:var(--a
.primary{border:0;background:var(--accent);color:#fff;border-radius:7px;padding:8px 14px;cursor:pointer;font-weight:600}
.row2{display:flex;gap:8px;align-items:center;padding:0 20px 10px;min-height:34px;flex-wrap:wrap}
.row2 .hint{color:var(--ink-3);font-size:13px}
+/* filter dropdowns */
+.fbar{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;padding:0 20px 8px}
+.fbar[hidden]{display:none}
+.fbar label{display:grid;gap:2px;font-size:11.5px;color:var(--ink-3);font-weight:600}
+.fbar input{border:1px solid var(--line-2);border-radius:7px;padding:5px 9px;background:var(--surface);font-size:13px;color:var(--ink);min-width:0}
+.fbar input.on{border-color:var(--accent);background:var(--accent-soft)}
+.fbar input::placeholder{color:var(--ink-3);font-weight:400}
+@media (max-width:820px){.fbar{grid-template-columns:repeat(2,minmax(0,1fr));padding-inline:12px}}
.chip{display:inline-flex;align-items:center;gap:4px;background:var(--accent-soft);color:var(--accent-ink);border-radius:14px;padding:2px 4px 2px 11px;font-size:13px}
[dir=rtl] .chip{padding:2px 11px 2px 4px}
.chip.neg{background:#fdecec;color:var(--crit)}
diff --git a/internal/web/static/app.js b/internal/web/static/app.js
index 039a5fd..0e08793 100644
--- a/internal/web/static/app.js
+++ b/internal/web/static/app.js
@@ -69,7 +69,10 @@ const PROTO = {1: 'ICMP', 2: 'IGMP', 4: 'IPIP', 6: 'TCP', 17: 'UDP', 41: 'IPv6',
const proto = p => PROTO[p] || String(p);
const ENCAP = ['', 'GRE', 'IPIP', '6in4', 'IPv6-in-IPv6', 'VXLAN', 'GENEVE', 'MPLS'];
const DIRS = {1: 'outbound', 2: 'inbound', 3: 'internal', 4: 'transit'};
-const COLORS = ['var(--c1)', 'var(--c2)', 'var(--c3)', 'var(--c4)', 'var(--c5)', 'var(--c6)'];
+// categorical colours in fixed order (validated for colour-blind readers); a
+// ninth series is folded into "other", never given a recycled colour
+const COLORS = ['var(--c1)', 'var(--c2)', 'var(--c3)', 'var(--c4)', 'var(--c5)', 'var(--c6)', 'var(--c7)', 'var(--c8)'];
+const color = (i, key) => key === '__other__' || i >= COLORS.length ? OTHER : COLORS[i];
const OTHER = 'var(--other)';
const appLabel = a => a === '__other__' ? t('other') : a;
function fmtTime(ms, span) {
@@ -140,7 +143,7 @@ function readHash() {
if (VIEWS.includes(p.get('v'))) state.v = p.get('v');
if (RANGES.includes(p.get('r'))) state.r = p.get('r');
if (p.get('dim')) state.dim = p.get('dim');
- state.sk = p.get('by') === 'segment' ? 'segment' : 'host';
+ state.sk = ['segment', 'conv'].includes(p.get('by')) ? p.get('by') : 'host';
const dd = p.get('d'); if (dd && dd.includes(':')) state.det = {f: dd.slice(0, dd.indexOf(':')), v: dd.slice(dd.indexOf(':') + 1)};
state.f = (p.get('f') || '').split(',').filter(Boolean).map(s => {
const neg = s[0] === '!'; if (neg) s = s.slice(1);
@@ -151,7 +154,7 @@ function readHash() {
function writeHash(push) {
const p = new URLSearchParams({v: state.v, r: state.r});
if (state.v === 'topn') p.set('dim', state.dim);
- if (state.v === 'sankey' && state.sk === 'segment') p.set('by', 'segment');
+ if (state.v === 'sankey' && state.sk !== 'host') p.set('by', state.sk);
if (state.v === 'detail' && state.det) p.set('d', state.det.f + ':' + state.det.v);
if (state.f.length) p.set('f', state.f.map(x => (x.neg ? '!' : '') + encodeURIComponent(x.f) + ':' + encodeURIComponent(x.v)).join(','));
const h = '#' + p.toString();
@@ -261,6 +264,8 @@ function donut(el, parts, field) {
const pop = $('#pop'); let ctx = null;
const LOOKUP = {
ip: v => 'https://bgp.he.net/ip/' + encodeURIComponent(v),
+ client: v => 'https://bgp.he.net/ip/' + encodeURIComponent(v),
+ server: v => 'https://bgp.he.net/ip/' + encodeURIComponent(v),
asn: v => 'https://bgp.he.net/AS' + encodeURIComponent(v.replace(/^AS/i, '')),
port: v => 'https://www.speedguide.net/port.php?port=' + encodeURIComponent(v.split('/')[0]),
};
@@ -280,9 +285,9 @@ function openPop(c, rect) {
pop.querySelector('button').focus();
}
// what a value's popup offers besides filtering
-const DETAIL = new Set(['ip', 'exporter', 'port']);
-const NAMEABLE = new Set(['ip', 'exporter']);
-function showDetail(f, v) { state.det = {f: f === 'exporter' ? 'ip' : f, v}; go('detail'); }
+const DETAIL = new Set(['ip', 'client', 'server', 'exporter', 'port']);
+const NAMEABLE = new Set(['ip', 'client', 'server', 'exporter']);
+function showDetail(f, v) { state.det = {f: ['exporter', 'client', 'server'].includes(f) ? 'ip' : f, v}; go('detail'); }
// Name an address from anywhere: the inventory line for it is replaced
// (host for an address, device for a flow exporter) and saved at once.
function nameForm() {
@@ -354,6 +359,44 @@ function renderFilters() {
+ (state.f.length > 1 ? `` : '')
: `${t('filters.hint')}`;
}
+// Filter dropdowns: device, client, server, service. Each offers the busiest
+// values of the time range (given the other filters) and also takes typed
+// values; choosing one replaces any filter on that field, emptying it
+// removes the filter.
+const FBAR = [['exporter', 'field.exporter'], ['client', 'field.client'], ['server', 'field.server'], ['port', 'col.service']];
+const FBAR_HIDDEN = new Set(['findings', 'ifaces', 'sources']);
+function renderFbar() {
+ const bar = $('#fbar');
+ bar.hidden = FBAR_HIDDEN.has(state.v);
+ if (bar.hidden) return;
+ bar.innerHTML = FBAR.map(([f, lab]) => {
+ const cur = state.f.find(x => x.f === f && !x.neg);
+ return ``;
+ }).join('');
+ bar.querySelectorAll('input').forEach(inp => {
+ const f = inp.dataset.fb;
+ inp.onfocus = async () => {
+ const dl = $('#dl-' + f);
+ if (dl.dataset.k === state.r + JSON.stringify(state.f)) return;
+ dl.dataset.k = state.r + JSON.stringify(state.f);
+ try {
+ const d = await api('topn', {dim: f, limit: 200}, state.f.filter(x => x.f !== f));
+ const rows = d.rows || [];
+ if (f !== 'port') await lookupNames(rows.map(r => r.key));
+ dl.innerHTML = rows.map(r => ``).join('');
+ } catch (e) {}
+ };
+ const apply = () => {
+ const v = inp.value.trim(), cur = state.f.find(x => x.f === f && !x.neg);
+ if ((cur ? cur.v : '') === v) return;
+ state.f = state.f.filter(x => x.f !== f);
+ if (v) state.f.push({f, v, neg: false});
+ render();
+ };
+ inp.onchange = apply;
+ inp.onkeydown = e => { if (e.key === 'Enter') { e.preventDefault(); apply(); } };
+ });
+}
// search box: guess the field from what was typed
$('#q').addEventListener('keydown', e => {
if (e.key !== 'Enter') return;
@@ -396,11 +439,10 @@ views.overview = async (el) => {
}
const peakTxt = d.peak_at ? t('kpi.peak', {v: fmtBps(d.peak_bps), t: fmtTime(d.peak_at, spanMs())}) : '';
const series = d.series || {times: [], names: [], values: []};
- const areas = series.names.map((n, i) => ({name: appLabel(n), color: n === '__other__' ? OTHER : COLORS[i % 6], data: series.values[i]}));
+ const areas = series.names.map((n, i) => ({name: appLabel(n), color: color(i, n), data: series.values[i]}));
const lines = d.baseline ? [{name: t(basis === 'week' ? 'last_week' : 'prev_period'), color: 'var(--base)', dash: true, data: d.baseline}] : [];
- const dirParts = (d.dir || []).map((p, i) => ({n: t('dir.' + p.key), v: p.wire, c: COLORS[i % 6], val: DIRS[p.key]}));
- const protoParts = (d.proto || []).map((p, i) => ({n: p.key === '__other__' ? t('other') : proto(+p.key), v: p.wire, c: p.key === '__other__' ? OTHER : COLORS[i % 6], val: p.key === '__other__' ? null : p.key}));
- const ccParts = (d.country || []).map((p, i) => ({n: country(p.key), v: p.wire, c: p.key === '__other__' ? OTHER : COLORS[i % 6], val: p.key === '__other__' ? null : p.key}));
+ const dirParts = (d.dir || []).map((p, i) => ({n: t('dir.' + p.key), v: p.wire, c: color(i), val: DIRS[p.key]}));
+ const protoParts = (d.proto || []).map((p, i) => ({n: p.key === '__other__' ? t('other') : proto(+p.key), v: p.wire, c: color(i, p.key), val: p.key === '__other__' ? null : p.key}));
const cl = d.top_clients || [], cmax = Math.max(1, ...cl.map(r => r.wire));
const sv = d.top_services || [], smax = Math.max(1, ...sv.map(r => r.wire));
el.innerHTML = `
@@ -415,11 +457,10 @@ views.overview = async (el) => {
${areas.map((a, i) => `${series.names[i] === '__other__' ? esc(a.name) : V('app', series.names[i], a.name)}`).join('')}${lines.length ? `${esc(lines[0].name)}` : ''}
${fd ? findingsPanel(fd) : ''}
- ${panel('c4', t('ov.dir'), '', '')}
- ${panel('c4', t('ov.proto'), '', '')}
- ${panel('c4', t('ov.country'), '', '')}
- ${panel('c6', t('ov.top_clients'), t('ov.top_clients_sub'), ` | ${t('col.client')} | ${t('col.traffic')} | ${t('col.share')} | ${t(basis === 'week' ? 'col.vs_week' : 'col.vs_prev')} |
- ${cl.map((r, i) => { const b = +r.extra; const ch = b > 0 ? (r.wire - b) / b : null; return `| ${i + 1} | ${ipCell(r.key)} | ${fmtBytes(r.wire)} | ${bar(r.wire, cmax)} | ${ch == null ? `${t('ov.mover_new')}` : fmtPct(ch, 0)} |
`; }).join('') || `| ${t('empty.nodata')} |
`}
`)}
+ ${panel('c6', t('ov.dir'), '', '')}
+ ${panel('c6', t('ov.proto'), '', '')}
+ ${panel('c6', t('ov.top_clients'), t('ov.top_clients_sub'), ` | ${t('col.client')} | ${t('col.traffic')} | ${t('col.share')} |
+ ${cl.map((r, i) => `| ${i + 1} | ${ipCell(r.key)} | ${fmtBytes(r.wire)} | ${bar(r.wire, cmax)} |
`).join('') || `| ${t('empty.nodata')} |
`}
`)}
${panel('c6', t('ov.top_services'), t('ov.top_services_sub'), ` | ${t('col.server')} | ${t('col.service')} | ${t('col.traffic')} | ${t('col.share')} |
${sv.map((r, i) => `| ${i + 1} | ${ipCell(r.key)} | ${V('port', r.key2, r.key2)} ${esc(r.extra)} | ${fmtBytes(r.wire)} | ${bar(r.wire, smax)} |
`).join('') || `| ${t('empty.nodata')} |
`}
`)}
`;
@@ -427,7 +468,6 @@ views.overview = async (el) => {
bindFindings(el);
donut($('#dDir'), dirParts, 'dir');
donut($('#dProto'), protoParts, 'proto');
- donut($('#dCC'), ccParts, 'country');
};
@@ -609,13 +649,16 @@ async function lookupNames(ips) {
} catch (e) {}
}
views.sankey = async (el) => {
- const byHost = state.sk !== 'segment';
- const d = await api('sankey', {by: byHost ? 'host' : 'segment'});
- if (byHost) await lookupNames((d.seg_app || []).map(l => l.S).filter(k => k !== '__other__'));
- const title = t(byHost ? 'sankey.title_host' : 'sankey.title');
+ // host: host → application → country; segment: network → application →
+ // country; conv: client → service → server
+ const mode = ['segment', 'conv'].includes(state.sk) ? state.sk : 'host', byHost = mode === 'host', conv = mode === 'conv';
+ const d = await api('sankey', {by: mode});
+ const ipCols = conv ? [0, 2] : byHost ? [0] : [];
+ await lookupNames([...(d.seg_app || []).map(l => l.S), ...(conv ? (d.app_cc || []).map(l => l.T) : [])].filter(k => k !== '__other__'));
+ const title = t({host: 'sankey.title_host', segment: 'sankey.title', conv: 'sankey.title_conv'}[mode]);
el.innerHTML = `${title}
${t('sankey.hint')}
-
-
`;
+
+ `;
el.querySelectorAll('[data-sk]').forEach(b => b.onclick = () => { state.sk = b.dataset.sk; render(); });
const sEl = $('#chSankey');
if (!(d.seg_app || []).length) { sEl.innerHTML = `${t('empty.nodata')}
`; return; }
@@ -624,16 +667,16 @@ views.sankey = async (el) => {
d.seg_app.forEach(l => { node(0, l.S).out += l.V; node(1, l.T).in += l.V; });
d.app_cc.forEach(l => { node(1, l.S).out += l.V; node(2, l.T).in += l.V; });
cols.forEach(c => c.sort((a, b) => (a.k === '__other__') - (b.k === '__other__') || Math.max(b.in, b.out) - Math.max(a.in, a.out)));
- const label = (c, k) => c === 2 ? country(k) : c === 0 && byHost && k !== '__other__' ? (names.get(k) || k) : appLabel(k);
- const field = [byHost ? 'ip' : 'segment', 'app', 'country'];
+ const label = (c, k) => k === '__other__' ? t('other') : ipCols.includes(c) ? (names.get(k) || k) : c === 2 ? country(k) : appLabel(k);
+ const field = {host: ['ip', 'app', 'country'], segment: ['segment', 'app', 'country'], conv: ['client', 'port', 'server']}[mode];
const draw = () => {
const W = sEl.clientWidth, H = sEl.clientHeight; if (W < 300) return;
const rtl = document.documentElement.dir === 'rtl';
- const nodeW = 12, pad = 14, lw = byHost ? 170 : 118, colX = rtl ? [W - lw - 12, Math.round(W / 2 - 6), 118] : [lw, Math.round(W / 2 - 6), W - 130];
+ const nodeW = 12, pad = 14, lw = mode === 'segment' ? 118 : 170, rw = conv ? 182 : 130, colX = rtl ? [W - lw - 12, Math.round(W / 2 - 6), rw - 12] : [lw, Math.round(W / 2 - 6), W - rw];
const nv = n => Math.max(n.in, n.out);
const k = Math.min(...cols.map(c => (H - pad * (c.length - 1)) / Math.max(1, c.reduce((s, n) => s + nv(n), 0))));
- cols.forEach((c, ci) => { let yy = 0; c.forEach((n, i) => { n.ci = ci; n.x = colX[ci]; n.y = yy; n.h = nv(n) * k; n.o = 0; n.i = 0; n.color = ci === 0 ? (n.k === '__other__' ? OTHER : COLORS[i % 6]) : 'var(--ink-2)'; yy += n.h + pad; }); });
- let s = `