From 4ceb33da91d957e698b34aef21cf3d3ec7dce3db Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 14:44:07 +0900 Subject: [PATCH] Filter dropdowns, client-to-server flow paths, overview cleanup, 8 colours - Device / Client / Server / Service dropdowns above every page; they list the busiest values of the range (given the other filters) and take typed values - Flow paths: third mode, client -> service -> server - Overview: drop the "remote location" donut and the vs-previous column - Categorical palette extended to the 8 validated colours; a ninth series folds into Other instead of recycling a colour Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XEUxFQp8ivofZLm1o6KiXm --- internal/api/handlers.go | 31 +++++----- internal/store/query.go | 39 +++++++----- internal/web/static/app.css | 10 +++- internal/web/static/app.js | 100 ++++++++++++++++++++++--------- internal/web/static/i18n/ar.json | 5 +- internal/web/static/i18n/bn.json | 5 +- internal/web/static/i18n/en.json | 5 +- internal/web/static/i18n/es.json | 5 +- internal/web/static/i18n/fr.json | 5 +- internal/web/static/i18n/hi.json | 5 +- internal/web/static/i18n/id.json | 5 +- internal/web/static/i18n/ja.json | 5 +- internal/web/static/i18n/ko.json | 5 +- internal/web/static/i18n/pt.json | 5 +- internal/web/static/i18n/ru.json | 5 +- internal/web/static/i18n/ur.json | 5 +- internal/web/static/i18n/zh.json | 5 +- internal/web/static/index.html | 1 + 18 files changed, 174 insertions(+), 72 deletions(-) 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'), ` - ${cl.map((r, i) => { const b = +r.extra; const ch = b > 0 ? (r.wire - b) / b : null; return ``; }).join('') || ``}
${t('col.client')}${t('col.traffic')}${t('col.share')}${t(basis === 'week' ? 'col.vs_week' : 'col.vs_prev')}
${i + 1}${ipCell(r.key)}${fmtBytes(r.wire)}${bar(r.wire, cmax)}${ch == null ? `${t('ov.mover_new')}` : fmtPct(ch, 0)}
${t('empty.nodata')}
`)} + ${panel('c6', t('ov.dir'), '', '
')} + ${panel('c6', t('ov.proto'), '', '
')} + ${panel('c6', t('ov.top_clients'), t('ov.top_clients_sub'), ` + ${cl.map((r, i) => ``).join('') || ``}
${t('col.client')}${t('col.traffic')}${t('col.share')}
${i + 1}${ipCell(r.key)}${fmtBytes(r.wire)}${bar(r.wire, cmax)}
${t('empty.nodata')}
`)} ${panel('c6', t('ov.top_services'), t('ov.top_services_sub'), ` ${sv.map((r, i) => ``).join('') || ``}
${t('col.server')}${t('col.service')}${t('col.traffic')}${t('col.share')}
${i + 1}${ipCell(r.key)}${V('port', r.key2, r.key2)} ${esc(r.extra)}${fmtBytes(r.wire)}${bar(r.wire, smax)}
${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')} -
-
`; +
${['host', 'conv', 'segment'].map(m => ``).join('')}
+
`; 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 = ``; + 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 ? color(i, n.k) : 'var(--ink-2)'; yy += n.h + pad; }); }); + let s = ``; const link = (A, B, v, col, lab) => { const w = v * k, y0 = A.y + A.o + w / 2, y1 = B.y + B.i + w / 2; A.o += w; B.i += w; const x0 = rtl ? A.x : A.x + nodeW, x1 = rtl ? B.x + nodeW : B.x, mx = (x0 + x1) / 2; @@ -861,7 +904,7 @@ views.detail = async (el) => { const title = isIP ? (names.get(det.v) ? `${esc(names.get(det.v))} ${esc(det.v)}` : esc(det.v)) : esc(det.v); $('#title').textContent = t('nav.detail') + ': ' + (isIP ? (names.get(det.v) || det.v) : det.v); const tot = d.totals, 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(d.basis === 'week' ? 'last_week' : 'prev_period'), color: 'var(--base)', dash: true, data: d.baseline}] : []; const table = (rows, heads, cell) => { const m = Math.max(1, ...rows.map(r => r.wire)); return `${heads.map(h => ``).join('')} ${rows.map((r, i) => `${cell(r).map(c => ``).join('')}`).join('') || ``}
${t(h)}${t('col.traffic')}${t('col.share')}
${i + 1}${c}${fmtBytes(r.wire)}${bar(r.wire, m)}
${t('empty.nodata')}
`; }; @@ -893,6 +936,7 @@ views.detail = async (el) => { // ------------------------------------------------------------ shell async function render(push) { renderFilters(); + renderFbar(); writeHash(push); $('#title').textContent = t('nav.' + state.v); // the detail view refines it document.title = t('nav.' + state.v) + ' · traffic66'; diff --git a/internal/web/static/i18n/ar.json b/internal/web/static/i18n/ar.json index 084c408..260f88f 100644 --- a/internal/web/static/i18n/ar.json +++ b/internal/web/static/i18n/ar.json @@ -316,5 +316,8 @@ "logo.upload": "رفع شعار…", "logo.reset": "العودة إلى الشعار المدمج", "logo.hint": "PNG أو SVG أو JPEG أو WebP أو GIF، حتى 1 MB. الحجم الأمثل 272 × 92 بكسل؛ تُعدَّل الأحجام الأخرى لتناسب المكان.", -"logo.saved": "تم حفظ الشعار" +"logo.saved": "تم حفظ الشعار", +"sankey.title_conv": "العميل ← الخدمة ← الخادم", +"sankey.by_conv": "العميل ← الخادم", +"fbar.any": "الكل" } diff --git a/internal/web/static/i18n/bn.json b/internal/web/static/i18n/bn.json index 8b7dff0..bef28f8 100644 --- a/internal/web/static/i18n/bn.json +++ b/internal/web/static/i18n/bn.json @@ -316,5 +316,8 @@ "logo.upload": "লোগো আপলোড করুন…", "logo.reset": "বিল্ট-ইন লোগোতে ফিরুন", "logo.hint": "PNG, SVG, JPEG, WebP বা GIF, সর্বোচ্চ 1 MB। সবচেয়ে ভালো আকার 272 × 92 পিক্সেল; অন্য আকার মানিয়ে নেওয়া হয়।", -"logo.saved": "লোগো সংরক্ষিত হয়েছে" +"logo.saved": "লোগো সংরক্ষিত হয়েছে", +"sankey.title_conv": "ক্লায়েন্ট → সেবা → সার্ভার", +"sankey.by_conv": "ক্লায়েন্ট → সার্ভার", +"fbar.any": "সব" } diff --git a/internal/web/static/i18n/en.json b/internal/web/static/i18n/en.json index 43dfe96..0d2dd68 100644 --- a/internal/web/static/i18n/en.json +++ b/internal/web/static/i18n/en.json @@ -316,5 +316,8 @@ "logo.upload": "Upload a logo…", "logo.reset": "Use the built-in logo", "logo.hint": "PNG, SVG, JPEG, WebP or GIF, up to 1 MB. Best at 272 × 92 pixels; other sizes are scaled to fit.", -"logo.saved": "Logo saved" +"logo.saved": "Logo saved", +"sankey.title_conv": "Client → service → server", +"sankey.by_conv": "Client → server", +"fbar.any": "Any" } diff --git a/internal/web/static/i18n/es.json b/internal/web/static/i18n/es.json index ded73b9..1037ceb 100644 --- a/internal/web/static/i18n/es.json +++ b/internal/web/static/i18n/es.json @@ -316,5 +316,8 @@ "logo.upload": "Subir un logotipo…", "logo.reset": "Usar el logotipo integrado", "logo.hint": "PNG, SVG, JPEG, WebP o GIF, hasta 1 MB. Lo ideal es 272 × 92 píxeles; otros tamaños se ajustan.", -"logo.saved": "Logotipo guardado" +"logo.saved": "Logotipo guardado", +"sankey.title_conv": "Cliente → servicio → servidor", +"sankey.by_conv": "Cliente → servidor", +"fbar.any": "Todos" } diff --git a/internal/web/static/i18n/fr.json b/internal/web/static/i18n/fr.json index d7df4bc..c9fc224 100644 --- a/internal/web/static/i18n/fr.json +++ b/internal/web/static/i18n/fr.json @@ -316,5 +316,8 @@ "logo.upload": "Importer un logo…", "logo.reset": "Revenir au logo intégré", "logo.hint": "PNG, SVG, JPEG, WebP ou GIF, 1 Mo au plus. Idéalement 272 × 92 pixels ; les autres tailles sont ajustées.", -"logo.saved": "Logo enregistré" +"logo.saved": "Logo enregistré", +"sankey.title_conv": "Client → service → serveur", +"sankey.by_conv": "Client → serveur", +"fbar.any": "Tous" } diff --git a/internal/web/static/i18n/hi.json b/internal/web/static/i18n/hi.json index bfc6621..b131f7f 100644 --- a/internal/web/static/i18n/hi.json +++ b/internal/web/static/i18n/hi.json @@ -316,5 +316,8 @@ "logo.upload": "लोगो अपलोड करें…", "logo.reset": "बिल्ट-इन लोगो वापस लाएँ", "logo.hint": "PNG, SVG, JPEG, WebP या GIF, अधिकतम 1 MB। सबसे अच्छा आकार 272 × 92 पिक्सेल है; बाक़ी आकार फ़िट करने के लिए छोटे-बड़े किए जाते हैं।", -"logo.saved": "लोगो सहेजा गया" +"logo.saved": "लोगो सहेजा गया", +"sankey.title_conv": "क्लाइंट → सेवा → सर्वर", +"sankey.by_conv": "क्लाइंट → सर्वर", +"fbar.any": "सभी" } diff --git a/internal/web/static/i18n/id.json b/internal/web/static/i18n/id.json index ef80f88..0dad220 100644 --- a/internal/web/static/i18n/id.json +++ b/internal/web/static/i18n/id.json @@ -316,5 +316,8 @@ "logo.upload": "Unggah logo…", "logo.reset": "Pakai logo bawaan", "logo.hint": "PNG, SVG, JPEG, WebP, atau GIF, maks. 1 MB. Paling baik 272 × 92 piksel; ukuran lain disesuaikan.", -"logo.saved": "Logo disimpan" +"logo.saved": "Logo disimpan", +"sankey.title_conv": "Klien → layanan → server", +"sankey.by_conv": "Klien → server", +"fbar.any": "Semua" } diff --git a/internal/web/static/i18n/ja.json b/internal/web/static/i18n/ja.json index 659e13d..e14aa42 100644 --- a/internal/web/static/i18n/ja.json +++ b/internal/web/static/i18n/ja.json @@ -316,5 +316,8 @@ "logo.upload": "ロゴをアップロード…", "logo.reset": "内蔵ロゴに戻す", "logo.hint": "PNG、SVG、JPEG、WebP、GIF(1 MB まで)。推奨サイズは 272 × 92 ピクセルで、他のサイズは自動で縮小・拡大されます。", -"logo.saved": "ロゴを保存しました" +"logo.saved": "ロゴを保存しました", +"sankey.title_conv": "クライアント → サービス → サーバー", +"sankey.by_conv": "クライアント → サーバー", +"fbar.any": "すべて" } diff --git a/internal/web/static/i18n/ko.json b/internal/web/static/i18n/ko.json index 535b481..40204b6 100644 --- a/internal/web/static/i18n/ko.json +++ b/internal/web/static/i18n/ko.json @@ -316,5 +316,8 @@ "logo.upload": "로고 업로드…", "logo.reset": "기본 로고로 되돌리기", "logo.hint": "PNG, SVG, JPEG, WebP 또는 GIF, 최대 1 MB. 272 × 92 픽셀이 가장 좋으며 다른 크기는 맞춰서 조정됩니다.", -"logo.saved": "로고를 저장했습니다" +"logo.saved": "로고를 저장했습니다", +"sankey.title_conv": "클라이언트 → 서비스 → 서버", +"sankey.by_conv": "클라이언트 → 서버", +"fbar.any": "전체" } diff --git a/internal/web/static/i18n/pt.json b/internal/web/static/i18n/pt.json index 1bf206f..6496d9d 100644 --- a/internal/web/static/i18n/pt.json +++ b/internal/web/static/i18n/pt.json @@ -316,5 +316,8 @@ "logo.upload": "Enviar um logotipo…", "logo.reset": "Usar o logotipo embutido", "logo.hint": "PNG, SVG, JPEG, WebP ou GIF, até 1 MB. O ideal é 272 × 92 pixels; outros tamanhos são ajustados.", -"logo.saved": "Logotipo salvo" +"logo.saved": "Logotipo salvo", +"sankey.title_conv": "Cliente → serviço → servidor", +"sankey.by_conv": "Cliente → servidor", +"fbar.any": "Todos" } diff --git a/internal/web/static/i18n/ru.json b/internal/web/static/i18n/ru.json index be5c705..b99187f 100644 --- a/internal/web/static/i18n/ru.json +++ b/internal/web/static/i18n/ru.json @@ -316,5 +316,8 @@ "logo.upload": "Загрузить логотип…", "logo.reset": "Вернуть встроенный логотип", "logo.hint": "PNG, SVG, JPEG, WebP или GIF, до 1 МБ. Лучше всего 272 × 92 пикселя; другие размеры масштабируются.", -"logo.saved": "Логотип сохранён" +"logo.saved": "Логотип сохранён", +"sankey.title_conv": "Клиент → сервис → сервер", +"sankey.by_conv": "Клиент → сервер", +"fbar.any": "Все" } diff --git a/internal/web/static/i18n/ur.json b/internal/web/static/i18n/ur.json index b604f5a..9150a3b 100644 --- a/internal/web/static/i18n/ur.json +++ b/internal/web/static/i18n/ur.json @@ -316,5 +316,8 @@ "logo.upload": "لوگو اپ لوڈ کریں…", "logo.reset": "بلٹ اِن لوگو پر واپس جائیں", "logo.hint": "PNG، SVG، JPEG، WebP یا GIF، زیادہ سے زیادہ 1 MB۔ بہترین سائز 272 × 92 پکسل ہے؛ دوسرے سائز فٹ کرنے کے لیے بدلے جاتے ہیں۔", -"logo.saved": "لوگو محفوظ ہو گیا" +"logo.saved": "لوگو محفوظ ہو گیا", +"sankey.title_conv": "کلائنٹ ← سروس ← سرور", +"sankey.by_conv": "کلائنٹ ← سرور", +"fbar.any": "سب" } diff --git a/internal/web/static/i18n/zh.json b/internal/web/static/i18n/zh.json index 371570b..32fd9e6 100644 --- a/internal/web/static/i18n/zh.json +++ b/internal/web/static/i18n/zh.json @@ -316,5 +316,8 @@ "logo.upload": "上传 Logo…", "logo.reset": "恢复内置 Logo", "logo.hint": "PNG、SVG、JPEG、WebP 或 GIF,最大 1 MB。最佳尺寸 272 × 92 像素,其他尺寸会自动缩放。", -"logo.saved": "Logo 已保存" +"logo.saved": "Logo 已保存", +"sankey.title_conv": "客户端 → 服务 → 服务器", +"sankey.by_conv": "客户端 → 服务器", +"fbar.any": "全部" } diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 3af45f1..6da8034 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -42,6 +42,7 @@

+