💋 Sync 2026-10-04 04:16:09

This commit is contained in:
github-actions[bot] committed 2026-10-04 04:16:09 +08:00
1 parent f6b2df73ec
commit f62992fd80
745 files changed
+212416 -1173

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,679 @@
'use strict';
'require view';
'require dom';
'require poll';
'require view.appflow.common as appflow';
/*
* AppFlow: Overview (live).
*
* DESIGN.md §4 item 1. The live view: total
* throughput, the applications currently moving traffic, the category split
* and the busiest devices, refreshed every 5 s from ubus appflow.summary /
* appflow.devices / appflow.status.
*
* The chart window is kept client-side only: SLOTS samples at INTERVAL
* seconds, i.e. a rolling five minutes that starts over on page load. The
* daemon is not asked to remember anything for it.
*/
var INTERVAL = 5,
SLOTS = 60,
TOP_APPS = 8,
TOP_DEVICES = 8,
TOP_CATEGORIES = 6,
/* How many application rows to ask the daemon for. The table displays
* TOP_APPS of them; the rest are the filter's raw material, so filtering
* does not fall off the top-N cliff the moment one heavy flow pushes what
* you are looking for out of the visible rows. */
FETCH_LIMIT = 50;
function head(cells) {
return E('tr', { 'class': 'tr table-titles' }, cells.map(function(c) {
return E('th', { 'class': 'th' + (c[1] ? ' ' + c[1] : '') }, [ c[0] ]);
}));
}
return view.extend({
samples: [],
fails: 0,
painted: false,
fetch: function() {
var self = this;
/* Capture the summary rejection (rather than L.resolveDefault-ing it to
* null) so paint can tell a stopped daemon from an ACL denial. */
return Promise.all([
/* FETCH_LIMIT, not the daemon's top_n. Filtering a top-10 list to
* "AI" returns nothing as soon as one streaming session pushes AI
* out of the top 10, which looks broken and is worse than no
* filter. The table still displays TOP_APPS rows; the extra rows
* exist so there is something to filter. */
appflow.rpc.summary(FETCH_LIMIT).then(function(v) { return { v: v }; },
function(e) { return { e: e }; }),
/* When a device is selected the application list comes from the
* flow table scoped to it, rather than from the global aggregate. */
L.resolveDefault(self.deviceFilter
? appflow.rpc.apps('', FETCH_LIMIT, self.deviceFilter.key)
: null, null),
/* The poll has to carry the filter too. A side call that filtered the
* device card was painted over by the very next poll five seconds
* later, so the card flickered back to unfiltered and the feature
* looked dead. Filtering is view state, so every fetch respects it
* rather than one special path doing so. */
L.resolveDefault((self.filteredKeys && self.filteredKeys.length)
? appflow.rpc.devices('', TOP_DEVICES, self.filteredKeys)
: appflow.rpc.devices(), null),
L.resolveDefault(appflow.rpc.status(), null)
]).then(function(r) {
return { summary: r[0].v || null, err: r[0].e,
devApps: r[1], devices: r[2], status: r[3] };
});
},
load: function() {
/* the icon pack is optional; loadIcons() resolves either way */
return Promise.all([ appflow.loadIcons(), this.fetch() ])
.then(function(r) { return r[1]; });
},
/* ------------------------------------------------------------ render */
render: function(data) {
appflow.injectCSS();
var self = this;
/* Live filter state. Held on the view rather than read from the DOM so
* that a poll repaint cannot lose what the user typed. */
this.filter = this.filter || '';
/* The selected device, or null. Separate from the text filter because
* they are different kinds of thing: the text is a substring anyone can
* edit, a device is one exact record. */
this.deviceFilter = this.deviceFilter || null;
this.devFilter = this.devFilter || '';
this.devFilterInput = E('input', {
'type': 'text',
'class': 'cbi-input-text',
'placeholder': _('Filter by device name, MAC or IP'),
'aria-label': _('Filter by device name, MAC or IP'),
'value': this.devFilter,
'input': function(ev) {
self.devFilter = ev.target.value || '';
if (self.lastDevices)
self.paintDevices(self.lastDevices, self.lastSummary);
}
});
this.filterInput = E('input', {
'type': 'text',
'class': 'cbi-input-text',
'placeholder': _('Filter by application or category'),
'aria-label': _('Filter by application or category'),
'value': this.filter,
'input': function(ev) {
self.filter = ev.target.value || '';
if (self.lastSummary) {
self.paintApps(self.lastSummary);
self.refreshDevices();
}
}
});
this.nodes = {
strip: E('div'),
window: E('span'),
kpi: E('div', { 'class': 'af-kpi' }),
chart: E('div'),
apps: E('div'),
appsAux: E('span', {}, [ _('by current rate') ]),
/* Shows which device is selected, with a way out. A filter you
* cannot see is a filter you cannot clear. */
devChip: E('div', { 'class': 'af-chipbar' }),
cats: E('div'),
/* Its own node so the caption can change when a filter is applied:
* filtered figures come from flows in progress, not cumulative
* totals, and the heading must not keep promising the latter. */
/* No af-cardaux class: card() wraps whatever it is given in one, and
* passing a second nested one produced the caption twice. */
devsAux: E('span', {}, [ _('top, by cumulative bytes') ]),
devs: E('div')
};
var n = this.nodes;
this.liveNode = E('div', {}, [
n.strip,
appflow.card(_('Live throughput'), [
n.window,
_('%d minute chart, %d second interval').format(SLOTS * INTERVAL / 60, INTERVAL)
], E('div', {}, [ n.kpi, n.chart ])),
E('div', { 'class': 'af-grid af-split' }, [
/* card() puts `body` in as a single child, so an ARRAY here lands
* as a nested array that E() does not flatten and nothing after
* the first element renders. Wrap it in one element. Found by
* looking at the live page: the clickable categories worked and
* the filter box was simply absent from the DOM. */
appflow.card(_('Top applications'), n.appsAux, E('div', {}, [
E('div', { 'class': 'af-filter' }, [
this.filterInput,
E('button', {
'class': 'cbi-button',
'click': L.bind(function(ev) {
ev.preventDefault();
this.setFilter('');
}, this)
}, [ _('Clear') ])
]),
/* SAY WHERE THE CATEGORIES ARE, AND SAY WHAT CLEAR DOES.
*
* This read "Click a category below", and the Categories
* panel is beside this card, not below it. A user who looks
* where the text points and finds nothing concludes the
* feature is missing. "Below" was written without checking
* the layout.
*
* It names the panel rather than a direction, because the
* cards wrap underneath each other on a narrow window and
* "to the right" would then be wrong in the other
* direction. */
n.devChip,
E('div', { 'class': 'af-filter-note' }, [
_('Start typing an application or category name to show only that traffic, or click a category in the Categories panel. Prefix a word with a minus to exclude it instead, for example -netflix. Press Clear to see all traffic again.')
]),
n.apps
])),
appflow.card(_('Categories'), [ _('top, by cumulative bytes') ], n.cats)
]),
appflow.card(_('Top devices'), n.devsAux, E('div', {}, [
E('div', { 'class': 'af-filter' }, [
this.devFilterInput,
E('button', {
'class': 'cbi-button',
'click': L.bind(function(ev) {
ev.preventDefault();
this.setDevFilter('');
}, this)
}, [ _('Clear') ])
]),
E('div', { 'class': 'af-filter-note' }, [
_('Start typing a device name, MAC address or IP to find it. Click a device to see what it is doing in the applications list above. Press Clear to see all devices again.')
]),
n.devs
]))
]);
this.downNode = E('div', { 'style': 'display:none' }, [ appflow.notRunning() ]);
this.paint(data);
poll.add(L.bind(function() {
return this.fetch().then(L.bind(this.paint, this));
}, this), INTERVAL);
return E([], [
E('h2', {}, [ _('AppFlow') ]),
E('div', { 'class': 'cbi-map-descr' }, [
_('Live per-application traffic, classified on the router by the Netify Agent. Nothing on this page leaves the device.')
]),
this.liveNode,
this.downNode
]);
},
/* ------------------------------------------------------------- paint */
paint: function(data) {
var summary = data ? data.summary : null,
status = data ? data.status : null;
if (summary == null) {
this.fails++;
/* One miss on a live page is usually a blip; only fall back to the
* "not running" screen once it is clearly not coming back. */
if (this.fails >= (this.painted ? 2 : 1))
return this.showDown(data ? data.err : null);
return;
}
this.fails = 0;
this.painted = true;
this.liveNode.style.display = '';
this.downNode.style.display = 'none';
/* live aggregates carry byte and rate counters in one object; a future
* split into totals/rates is handled by normRates */
var totals = appflow.normTotals(summary.totals || summary),
rates = summary.rates ? appflow.normRates(summary.rates) : totals;
/* Stamped so a gap can be measured. The chart still places points by
* INDEX, so the "-5 min" label remains an assumption that every sample
* is INTERVAL apart -- that is a real limitation and is not fixed here.
* What the stamp does fix is the recovery below: without it there was no
* way to tell a 10-second blip from an hour offline, so showDown()
* cleared unconditionally. */
var nowMs = Date.now(),
last = this.samples.length ? this.samples[this.samples.length - 1].t : 0;
/* Coming back from a gap longer than the whole window: the old samples
* would be drawn adjacent to the new ones and read as continuous, which
* is a worse lie than an empty chart. Start over. */
if (last && (nowMs - last) > SLOTS * INTERVAL * 1000)
this.samples = [];
this.samples.push({ t: nowMs, dl: rates.rdl, ul: rates.rul });
while (this.samples.length > SLOTS)
this.samples.shift();
var stamp = summary.generated_at || (status ? status.generated_at : 0);
dom.content(this.nodes.strip, appflow.statusStrip(status || summary, [
stamp ? E('span', { 'class': 'af-pill' }, [
_('Updated %s').format(appflow.fmtAgo(stamp))
]) : null
]));
dom.content(this.nodes.window, [ summary.window
? _('%d s rate window').format(appflow.num(summary, [ 'window' ]))
: '' ]);
this.paintThroughput(totals, rates);
/* When a device is selected the application rows come from the
* flow-scoped reply rather than the global aggregate. */
this.paintApps(summary, data.devApps);
this.paintCategories(summary);
this.paintDevices(data.devices, summary);
this.paintChip();
},
showDown: function(err) {
/* Deliberately NOT clearing this.samples. showDown fires on the SECOND
* consecutive miss, so roughly ten seconds of unavailability used to
* destroy five minutes of history -- and appflowd is restarted by an
* ordinary config change, so that was a hair trigger. paint() now drops
* the buffer only when the gap exceeds the chart's own window, which is
* the case where redrawing it really would mislead. Raised by a
* code-review panel, 2026-08-25. */
dom.content(this.downNode, appflow.notRunning(appflow.classifyFail(err), err));
this.liveNode.style.display = 'none';
this.downNode.style.display = '';
},
/* ---------------------------------------------------------- sections */
paintThroughput: function(totals, rates) {
dom.content(this.nodes.kpi, [
appflow.kpi(_('Download'), appflow.fmtRate(rates.rdl), 'af-dlc'),
appflow.kpi(_('Upload'), appflow.fmtRate(rates.rul), 'af-ulc'),
appflow.kpi(_('Downloaded'), appflow.fmtBytes(totals.dl)),
appflow.kpi(_('Uploaded'), appflow.fmtBytes(totals.ul))
]);
if (this.samples.length < 2) {
dom.content(this.nodes.chart, appflow.placeholder(
_('Collecting samples…')));
return;
}
dom.content(this.nodes.chart, [
appflow.lineChart([
{ values: this.samples.map(function(s) { return s.dl; }),
cls: 'af-dl', fill: true },
{ values: this.samples.map(function(s) { return s.ul; }),
cls: 'af-ul', fill: true }
], {
slots: SLOTS,
height: 170,
fmt: function(v) { return appflow.fmtRate(v); },
xlabels: [ _('-%d min').format(SLOTS * INTERVAL / 60), _('now') ]
}),
appflow.swatches(appflow.fmtRate(this.samples[this.samples.length - 1].dl),
appflow.fmtRate(this.samples[this.samples.length - 1].ul))
]);
},
/* Set the filter box and repaint. Used by the category legend, so clicking
* a category is exactly equivalent to typing its name: the user can see
* what was applied, edit it, or clear it. One mechanism, not two. */
setDevFilter: function(text) {
this.devFilter = text || '';
if (this.devFilterInput)
this.devFilterInput.value = this.devFilter;
/* Clearing the box also drops the application scope, because the two
* were set by one click and leaving half of it behind is how a user
* ends up looking at a filtered list with nothing saying why. */
if (!this.devFilter && this.deviceFilter)
return this.setDevice(null);
if (this.lastDevices)
this.paintDevices(this.lastDevices, this.lastSummary);
},
/* Select a device, or pass null to clear. Repaints from the live data we
* already hold, then refetches so the application list is scoped. */
setDevice: function(key, name) {
this.deviceFilter = key ? { key: key, name: name || key } : null;
/* Fill the device box too, so the list narrows to what was selected.
* Clicking a device used to scope the application list while the device
* list carried on showing everything, so the thing just selected was
* not visibly selected anywhere. */
this.devFilter = key ? (name || key) : '';
if (this.devFilterInput)
this.devFilterInput.value = this.devFilter;
this.paintChip();
this.refetch();
},
paintChip: function() {
var self = this, d = this.deviceFilter;
if (!this.nodes || !this.nodes.devChip)
return;
dom.content(this.nodes.devChip, d ? [
E('span', { 'class': 'af-chip' }, [
E('span', {}, [ _('Device: %s').format(d.name) ]),
E('a', {
'href': '#',
'class': 'af-chip-x',
'title': _('Show all devices'),
'click': function(ev) { ev.preventDefault(); self.setDevice(null); }
}, [ '\u00d7' ])
])
] : []);
},
/* One refetch path for both filters, so a repaint cannot lose either. */
refetch: function() {
var self = this;
return this.fetch().then(function(d) { self.paint(d); });
},
setFilter: function(text) {
this.filter = text || '';
if (this.filterInput)
this.filterInput.value = this.filter;
if (this.lastSummary) {
this.paintApps(this.lastSummary);
this.refreshDevices();
}
},
/* Ask the daemon for device totals restricted to the filtered applications.
* paintApps() must have run first, because it is what computes which keys
* survived.
*
* Failure is deliberately quiet: the device card keeps showing the
* unfiltered view rather than emptying itself, because a filter that blanks
* an unrelated card looks like a crash. */
refreshDevices: function() {
var self = this,
keys = this.filteredKeys;
var call = (keys && keys.length)
/* '' and not null for `sort`. The ubus policy types it as a string, and
* a null fails the policy with code 2 (Invalid argument) rather than
* being treated as absent. resolveDefault below then swallowed the
* rejection and the card silently kept its unfiltered contents, which
* is how this bug hid: the feature looked implemented and did
* nothing. */
? appflow.rpc.devices('', TOP_DEVICES, keys)
: appflow.rpc.devices();
return L.resolveDefault(call, null).then(function(d) {
if (d && self.lastSummary)
self.paintDevices(d, self.lastSummary);
});
},
paintApps: function(summary, devApps) {
var self = this;
this.lastSummary = summary;
if (devApps !== undefined)
this.lastDevApps = devApps;
/* Device-scoped rows when a device is selected, otherwise the global
* aggregate. Both go through the same normalisation and the same text
* filter below, so the two filters compose. */
var scoped = this.deviceFilter ? (this.lastDevApps || null) : null;
if (this.nodes && this.nodes.appsAux)
dom.content(this.nodes.appsAux, [ scoped
? _('on %s, from flows in progress').format(this.deviceFilter.name)
: _('by current rate') ]);
var terms = appflow.parseFilter(this.filter);
/* Filter BEFORE the top-N slice, or the filter only ever searches the
* ten rows that were already visible. */
var all = appflow.toList(scoped
? (scoped.apps || [])
: (summary.top_apps || summary.apps))
.map(function(a) { return appflow.normApp(a); })
.filter(function(a) {
return appflow.matchFilter(terms,
[ a.label, a.key, appflow.catLabel(a.cat) ]);
})
/* Scoped rows have no rate, so sorting by it would order them all
* equal and the top-N slice would be arbitrary. */
.sort(scoped
? function(a, b) { return b.total - a.total; }
: function(a, b) { return b.rate - a.rate; });
/* Every key that survived the filter, not just the displayed rows: the
* device query should account for all matching traffic, not only the
* top eight of it. */
this.filteredKeys = terms.length
? all.map(function(a) { return a.key; }).filter(function(k) { return k; })
: null;
var apps = all.slice(0, TOP_APPS);
if (!apps.length) {
dom.content(this.nodes.apps, appflow.placeholder(terms.length
? _('No application matches that filter.')
: _('No application traffic in the current window.')));
return;
}
/* UNITS CHANGE WITH THE SOURCE, AND SO MUST THE HEADINGS.
*
* The global aggregate carries rate rings, so the unscoped table shows
* rates. The device-scoped rows are summed from the live flow table,
* which holds cumulative per-flow counters and no rate ring, so there
* is no rate to show. Rendering them through fmtRate produced a table
* of zeroes: every value present, every one formatted from a field that
* does not exist.
*
* Computing a per-device-per-application rate would need a delta ring
* per pair, which is the new state this whole approach avoids. Showing
* bytes is the honest answer, and the column headings say which. */
var byBytes = !!scoped,
fmt = byBytes ? appflow.fmtBytes : appflow.fmtRate,
val = byBytes
? function(a) { return { dl: a.dl, ul: a.ul }; }
: function(a) { return { dl: a.rdl, ul: a.rul }; };
var peak = byBytes
? (apps[0].total || 1)
: (apps[0].rate || 1);
dom.content(this.nodes.apps, E('table', { 'class': 'table af-table' }, [
head([
[ _('Application') ],
[ '' ],
[ byBytes ? _('Downloaded') : _('Download'), 'af-num' ],
[ byBytes ? _('Uploaded') : _('Upload'), 'af-num' ]
])
].concat(apps.map(function(a) {
var v = val(a);
return E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td' }, [ appflow.appCell(a, null, function(x) {
self.setFilter(x);
}) ]),
E('td', { 'class': 'td', 'style': 'width:26%' }, [
appflow.bar(v.dl, v.ul, peak, fmt)
]),
E('td', { 'class': 'td af-num' }, [ fmt(v.dl) ]),
E('td', { 'class': 'td af-num' }, [ fmt(v.ul) ])
]);
}))));
},
paintCategories: function(summary) {
var cats = appflow.toList(summary.top_categories || summary.categories, 'category')
.map(function(c) {
var t = appflow.normTotals(c);
return {
label: appflow.catLabel(appflow.str(c,
[ 'category', 'category_name', 'cat', 'key', 'name', 'label' ])),
key: appflow.str(c,
[ 'category', 'category_name', 'cat', 'key', 'name', 'label' ]),
/* Cumulative bytes ONLY. This used to be `t.total || t.rate`,
* so a category carrying a rate but a zero/absent byte total
* contributed a RATE into a sum of byte totals, which the
* doughnut centre then printed through fmtBytes under a card
* caption reading "top, by cumulative bytes". The fallback
* looked like belt-and-braces and was actually a silent unit
* mix; a row with no bytes is filtered out below, which is
* the honest outcome. Found by a code-review panel. */
value: t.total
};
})
.filter(function(c) { return c.value > 0; })
.sort(function(a, b) { return b.value - a.value; });
if (!cats.length) {
dom.content(this.nodes.cats, appflow.placeholder(_('No categories yet.')));
return;
}
var slices = cats.slice(0, TOP_CATEGORIES).map(function(c) {
/* `pick` is what clicking this slice types into the filter box. The
* LABEL, not the key, because the filter matches on the rendered
* category label and the user can then read and edit it. */
return { label: c.label, value: c.value, pick: c.label,
color: appflow.color(c.key) };
});
if (cats.length > TOP_CATEGORIES) {
var rest = cats.slice(TOP_CATEGORIES).reduce(function(a, c) {
return a + c.value;
}, 0);
if (rest > 0)
slices.push({ label: _('Other'), value: rest, color: appflow.color('other') });
}
var self = this;
dom.content(this.nodes.cats, appflow.donut(slices, {
centerLabel: _('total'),
/* "Other" is this view's roll-up of everything past the top N, not
* a category anything is tagged with, so filtering by it would
* match nothing. It is left unclickable rather than offered and
* broken. */
onPick: function(pick) {
if (pick !== _('Other'))
self.setFilter(pick);
}
}));
},
paintDevices: function(devices, summary) {
var self = this;
/* The daemon says whether it answered from the live flow table. When it
* did, these are the bytes currently-tracked flows account for, NOT
* lifetime device totals, and the caption has to say so. */
var fromFlows = !!(devices && devices.from_flows);
if (this.nodes.devsAux)
dom.content(this.nodes.devsAux, [ fromFlows
? _('matching the filter, from flows in progress')
: _('by cumulative bytes') ]);
var list = appflow.toList(
(devices && (devices.devices || devices.clients)) || devices || summary.top_devices,
'mac');
this.lastDevices = devices;
var devTerms = appflow.parseFilter(this.devFilter);
var devs = list
.map(function(d) { return appflow.normDevice(d); })
.filter(function(d) { return d.total > 0 || d.rate > 0; })
/* Same matcher as the application filter, so -exclude works here
* too and the two boxes behave identically. */
.filter(function(d) {
return appflow.matchFilter(devTerms, [ d.name, d.mac, d.ip, d.key ]);
})
/* Cumulative bytes first, rate only as the tie-break. These were the
* other way round until 2026-08-25, which disagreed with this card's
* own caption ("top, by cumulative bytes") and with the bar beside
* each row, which is scaled against max(total) below. A table
* ordered by rate and drawn against a cumulative scale produces bars
* that do not shorten down the column, directly under a Top
* applications table that does -- both visible in one screenshot.
*
* It also chose the wrong rows: most devices sit at rate 0 at any
* instant, so a phone doing a 2 KB/s keepalive outranked a TV that
* pulled 12 GB an hour ago, and with enough devices trickling the TV
* left the card entirely. Found by a security/first-look review of
* this file, which had never been reviewed by anyone. */
.sort(function(a, b) { return (b.total - a.total) || (b.rate - a.rate); })
.slice(0, TOP_DEVICES);
if (!devs.length) {
dom.content(this.nodes.devs, appflow.placeholder(devTerms.length
? _('No device matches that filter.')
: _('No active devices.')));
return;
}
var peak = devs.reduce(function(m, d) {
return Math.max(m, d.total);
}, 0) || 1;
dom.content(this.nodes.devs, E('table', { 'class': 'table af-table' }, [
head([
[ _('Device') ],
[ '' ],
[ _('Current rate'), 'af-num' ],
[ _('Downloaded'), 'af-num' ],
[ _('Uploaded'), 'af-num' ]
])
].concat(devs.map(function(d) {
return E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td', 'style': 'width:32%' }, [
appflow.deviceCell(d, function(dev) { self.setDevice(dev.key, dev.name); })
]),
E('td', { 'class': 'td', 'style': 'width:26%' }, [
appflow.bar(d.dl, d.ul, peak)
]),
E('td', { 'class': 'td af-num' }, [ appflow.fmtRate(d.rate) ]),
E('td', { 'class': 'td af-num' }, [ appflow.fmtBytes(d.dl) ]),
E('td', { 'class': 'td af-num' }, [ appflow.fmtBytes(d.ul) ])
]);
}))));
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});
@@ -0,0 +1,577 @@
'use strict';
'require view';
'require dom';
'require poll';
'require ui';
'require view.appflow.common as appflow';
/*
* AppFlow: Statistics (past hour).
*
* DESIGN.md §4 item 2: the range view, served from the daemon's
* in-RAM 12 x 5-minute buckets via ubus appflow.stats {range:"hour"}. Layout,
* column set, sort order, the synthetic "All traffic" row, the search box, the
* per-app detail drawer and the 15 s poll follow the conventions of the
* "Flow Control -> Data Statistics" screen.
*
* Past day / past week are phase 2 (they need persistence). The tabs are shown
* disabled rather than hidden, so the seam is visible instead of pretended away.
*/
var INTERVAL = 15,
TOP_N = 10,
MAX_ROWS = 200,
RANGE = 'hour';
var COLUMNS = [
{ key: 'label', title: _('Application'), cls: '' },
{ key: 'dl', title: _('Download'), cls: 'af-num' },
{ key: 'ul', title: _('Upload'), cls: 'af-num' },
{ key: 'total', title: _('Total'), cls: 'af-num' }
];
return view.extend({
sortKey: 'total',
sortDir: -1,
filter: '',
fails: 0,
painted: false,
stats: null,
fetch: function() {
/* Keep the rejection (via this.lastErr) instead of L.resolveDefault-ing
* it away, so showDown can tell a stopped daemon from an ACL denial. */
var self = this;
return appflow.rpc.stats(RANGE).then(
function(v) { self.lastErr = null; return v; },
function(e) { self.lastErr = e; return null; }
);
},
load: function() {
/* the icon pack is optional; loadIcons() resolves either way */
return Promise.all([ appflow.loadIcons(), this.fetch() ])
.then(function(r) { return r[1]; });
},
/* ------------------------------------------------------------ render */
render: function(stats) {
appflow.injectCSS();
this.nodes = {
strip: E('div'),
top: E('div'),
rows: E('div'),
count: E('span', { 'class': 'af-sub' })
};
var tip = _('Requires history storage (planned)');
this.searchInput = E('input', {
'type': 'text',
'class': 'cbi-input-text',
/* It has always matched the CATEGORY label as well as the app name.
* Saying "Search app name" meant nobody could discover that, which
* is closer to a defect than a wording nit: the capability shipped
* and the label concealed it. */
'placeholder': _('Filter by application or category'),
'aria-label': _('Filter by application or category'),
'input': L.bind(function(ev) {
this.filter = (ev.target.value || '').toLowerCase();
this.paintTable();
}, this)
});
this.clearButton = E('button', {
'class': 'cbi-button cbi-button-negative',
'click': L.bind(this.handleClear, this)
}, [ _('Clear') ]);
this.liveNode = E('div', {}, [
E('ul', { 'class': 'cbi-tabmenu af-ranges' }, [
E('li', { 'class': 'cbi-tab' }, [
E('a', { 'href': '#', 'click': function(ev) { ev.preventDefault(); } },
[ _('Past hour') ])
]),
E('li', { 'class': 'cbi-tab-disabled af-disabled', 'title': tip }, [
E('a', { 'title': tip, 'click': function(ev) { ev.preventDefault(); } },
[ _('Past day') ])
]),
E('li', { 'class': 'cbi-tab-disabled af-disabled', 'title': tip }, [
E('a', { 'title': tip, 'click': function(ev) { ev.preventDefault(); } },
[ _('Past week') ])
])
]),
this.nodes.strip,
appflow.card(_('Top 10 apps by bandwidth usage'),
[ _('last 60 minutes') ], this.nodes.top),
appflow.card(_('App traffic statistics'), null, E('div', {}, [
E('div', { 'class': 'af-toolbar' }, [
this.searchInput,
this.nodes.count,
E('div', { 'class': 'af-spacer' }),
this.clearButton
]),
this.nodes.rows
]))
]);
this.downNode = E('div', { 'style': 'display:none' }, [ appflow.notRunning() ]);
this.paint(stats);
poll.add(L.bind(function() {
return this.fetch().then(L.bind(this.paint, this));
}, this), INTERVAL);
return E([], [
E('h2', {}, [ _('AppFlow') ]),
E('div', { 'class': 'cbi-map-descr' }, [
_('Per-application totals for the last hour, aggregated on the router from the Netify Agent\'s classification. Select a row for the per-device breakdown.')
]),
this.liveNode,
this.downNode
]);
},
/* ------------------------------------------------------------- paint */
paint: function(stats) {
if (stats == null) {
this.fails++;
if (this.fails >= (this.painted ? 2 : 1))
return this.showDown();
return;
}
this.fails = 0;
this.painted = true;
this.stats = stats;
this.liveNode.style.display = '';
this.downNode.style.display = 'none';
this.clearButton.disabled = false;
dom.content(this.nodes.strip, appflow.statusStrip(stats, [
stats.generated_at ? E('span', { 'class': 'af-pill' }, [
_('Updated %s').format(appflow.fmtAgo(stats.generated_at))
]) : null,
appflow.pill(_('12 buckets of 5 min'))
]));
this.paintTop();
this.paintTable();
},
showDown: function() {
this.stats = null;
dom.content(this.downNode, appflow.notRunning(appflow.classifyFail(this.lastErr), this.lastErr));
this.liveNode.style.display = 'none';
this.downNode.style.display = '';
},
/* Applications, normalised and sorted; "All traffic" is kept separate. */
appList: function() {
var stats = this.stats || {};
return appflow.toList(stats.applications || stats.apps)
.map(function(a) { return appflow.normApp(a); })
.filter(function(a) {
/* the daemon may already include the synthetic row */
return a.key != '-1' && a.key != 'all_traffic';
});
},
allRow: function(apps) {
var stats = this.stats || {};
if (stats.all != null)
return appflow.normTotals(stats.all);
return apps.reduce(function(a, x) {
a.dl += x.dl;
a.ul += x.ul;
a.total += x.total;
return a;
}, { dl: 0, ul: 0, total: 0 });
},
/* ---------------------------------------------------------- top ten */
paintTop: function() {
var stats = this.stats || {},
top = appflow.toList(stats.top_apps);
if (!top.length)
top = this.appList().sort(function(a, b) { return b.total - a.total; });
else
top = top.map(function(a) { return appflow.normApp(a); })
.sort(function(a, b) { return b.total - a.total; });
top = top.slice(0, TOP_N);
if (!top.length) {
dom.content(this.nodes.top, appflow.placeholder(
_('No application traffic recorded in the last hour.')));
return;
}
var peak = appflow.num(stats, [ 'max_bytes' ], 0) || top[0].total || 1,
self = this;
dom.content(this.nodes.top, [
E('table', { 'class': 'table af-table' }, top.map(function(a) {
return E('tr', {
'class': 'tr af-row',
'title': _('Show per-device details'),
'click': L.bind(self.handleDetail, self, a)
}, [
E('td', { 'class': 'td', 'style': 'width:28%' }, [ appflow.appCell(a) ]),
E('td', { 'class': 'td' }, [ appflow.bar(a.dl, a.ul, peak) ]),
E('td', { 'class': 'td af-num', 'style': 'width:14%' }, [
appflow.fmtBytes(a.total)
])
]);
})),
appflow.swatches(
appflow.fmtBytes(top.reduce(function(s, a) { return s + a.dl; }, 0)),
appflow.fmtBytes(top.reduce(function(s, a) { return s + a.ul; }, 0)))
]);
},
/* ------------------------------------------------------- main table */
handleSort: function(key) {
if (this.sortKey == key)
this.sortDir = -this.sortDir;
else {
this.sortKey = key;
this.sortDir = (key == 'label') ? 1 : -1;
}
this.paintTable();
},
paintTable: function() {
var self = this,
apps = this.appList(),
all = this.allRow(apps),
total = apps.length;
/* Shared with the overview via common.js, so the two pages cannot drift
* and the logic is reachable by a Node test. Adds exclusion: a term
* prefixed with a minus removes matching rows, which is what you want
* when one heavy application is burying everything else. */
var terms = appflow.parseFilter(this.filter);
if (terms.length)
apps = apps.filter(function(a) {
return appflow.matchFilter(terms,
[ a.label, a.key, appflow.catLabel(a.cat) ]);
});
apps.sort(function(a, b) {
var k = self.sortKey;
if (k == 'label')
return self.sortDir * a.label.localeCompare(b.label);
return self.sortDir * ((a[k] - b[k]) || (a.total - b.total));
});
dom.content(this.nodes.count, [ this.filter.length
? _('%d of %d applications').format(apps.length, total)
: _('%d applications').format(total) ]);
var truncated = apps.length > MAX_ROWS;
if (truncated)
apps = apps.slice(0, MAX_ROWS);
var header = E('tr', { 'class': 'tr table-titles' }, COLUMNS.map(function(c) {
return E('th', {
'class': 'th af-sortable ' + c.cls,
'title': _('Sort by %s').format(c.title),
'click': L.bind(self.handleSort, self, c.key)
}, [
c.title,
(self.sortKey == c.key)
? E('span', { 'class': 'af-caret' }, [ self.sortDir < 0 ? '▼' : '▲' ])
: ''
]);
}));
var rows = [ header ];
/* Synthetic "All traffic" summary row, always pinned to the top. */
rows.push(E('tr', { 'class': 'tr af-all' }, [
E('td', { 'class': 'td' }, [
E('span', {}, [ _('All traffic') ]),
E('span', { 'class': 'af-sub' }, [ _('sum of all applications') ])
]),
E('td', { 'class': 'td af-num' }, [ appflow.fmtBytes(all.dl) ]),
E('td', { 'class': 'td af-num' }, [ appflow.fmtBytes(all.ul) ]),
E('td', { 'class': 'td af-num' }, [ appflow.fmtBytes(all.total) ])
]));
if (!apps.length)
rows.push(E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td', 'colspan': String(COLUMNS.length) }, [
appflow.placeholder(this.filter.length
? _('No application matches "%s".').format(this.filter)
: _('No application traffic recorded in the last hour.'))
])
]));
apps.forEach(function(a) {
/* Keyboard-reachable, not mouse-only. The per-device drawer is the
* feature this whole view exists for and until 2026-08-25 a `click`
* handler was the only way in: no tabindex, no role, no key
* handler anywhere in this package. role=button plus tabindex puts
* the row in the tab order and makes it announce as activatable;
* Enter and Space are the two keys a button is required to answer.
* Raised by a code-review panel. */
rows.push(E('tr', {
'class': 'tr af-row',
'title': _('Show per-device details'),
'role': 'button',
'tabindex': '0',
'aria-label': _('Show per-device details for %s').format(a.label || a.key),
'keydown': L.bind(function(app, ev) {
if (ev.key !== 'Enter' && ev.key !== ' ' && ev.key !== 'Spacebar')
return;
ev.preventDefault(); /* Space would scroll the page */
self.handleDetail(app, ev);
}, self, a),
'click': L.bind(self.handleDetail, self, a)
}, [
E('td', { 'class': 'td' }, [ appflow.appCell(a) ]),
E('td', { 'class': 'td af-num' }, [ appflow.fmtBytes(a.dl) ]),
E('td', { 'class': 'td af-num' }, [ appflow.fmtBytes(a.ul) ]),
E('td', { 'class': 'td af-num' }, [ appflow.fmtBytes(a.total) ])
]));
});
dom.content(this.nodes.rows, [
E('table', { 'class': 'table af-table' }, rows),
truncated ? E('div', { 'class': 'af-placeholder' }, [
_('Showing the first %d applications; narrow the search to see the rest.')
.format(MAX_ROWS)
]) : ''
]);
},
/* ------------------------------------------------------ detail drawer */
handleDetail: function(app, ev) {
if (ev)
ev.stopPropagation();
/* Mark which app this drawer is for. A late detail() result only renders
* if it still matches; today the spinner overlay blocks a second row
* click while one is in flight, but that is an accident of the overlay
* being modal, so the guard should not depend on it. */
this.detailKey = app.key;
// Title must be a node array, never a bare string: LuCI's showModal
// routes a bare-string title through innerHTML, and app.label aliases
// to attacker-influenced fields (DHCP hostname, TLS SNI) one edit away.
ui.showModal([ app.label ], [
E('p', { 'class': 'spinning' }, [ _('Collecting data…') ])
], 'cbi-modal');
appflow.rpc.detail(app.key).then(L.bind(function(detail) {
this.renderDetail(app, detail);
}, this)).catch(L.bind(function(err) {
this.renderDetail(app, null, err);
}, this));
},
renderDetail: function(app, detail, err) {
/* the drawer may have been dismissed while the call was in flight */
if (!document.body.classList.contains('modal-overlay-active'))
return;
/* or replaced by a drawer for a different app: a late result for the
* previously-open app must not overwrite the one now showing */
if (this.detailKey !== app.key)
return;
var d = detail || {},
totals = appflow.normTotals(d.metrics || d.totals || d),
/* Do not re-normalise app.series: normApp() already did. The alias
* fix in common.js makes that harmless now, but normalising once is
* the actual intent and it keeps the fallback readable. */
series = appflow.normSeries(d.time_series || d.series),
devices = appflow.toList(d.devices || d.clients || d.mac_addresses, 'mac')
.map(function(x) { return appflow.normDevice(x); })
.filter(function(x) { return x.total > 0; })
.sort(function(a, b) { return b.total - a.total; }),
hosts = appflow.toList(d.hostnames || d.hosts, 'hostname')
.map(function(h) {
var t = appflow.normTotals(h);
return {
name: appflow.str(h, [ 'hostname', 'host', 'name', 'domain' ], '?'),
total: t.total
};
})
.filter(function(h) { return h.total > 0; })
.sort(function(a, b) { return b.total - a.total; })
.slice(0, 8);
if (!totals.total)
totals = { dl: app.dl, ul: app.ul, total: app.total };
var body = [
E('div', { 'class': 'af-modal-head' }, [
appflow.tile(app.label, app.cat, app.tag),
E('div', {}, [
E('h4', { 'style': 'display:block;text-align:start;padding:0;margin:0' }, [ app.label ]),
app.cat ? E('span', {
'class': 'af-badge',
'style': 'background:' + appflow.color(app.cat)
}, [ appflow.catLabel(app.cat) ]) : E('span', { 'class': 'af-sub' }, [
_('Unclassified')
])
])
]),
E('div', { 'class': 'af-kpi' }, [
appflow.kpi(_('Download'), appflow.fmtBytes(totals.dl), 'af-dlc'),
appflow.kpi(_('Upload'), appflow.fmtBytes(totals.ul), 'af-ulc'),
appflow.kpi(_('Total'), appflow.fmtBytes(totals.total)),
appflow.kpi(_('Devices'), String(devices.length))
])
];
if (err)
body.push(E('div', { 'class': 'alert-message warning' }, [
_('The daemon could not return details for this application: %s')
.format(String(err.message || err))
]));
else if (detail && detail.found === false)
body.push(E('div', { 'class': 'alert-message warning' }, [
_('This application is no longer in the daemon\'s aggregate table; it may have been pruned since the table was last refreshed.')
]));
body.push(E('h4', {}, [ _('Data usage') ]));
if (series.length)
body.push(appflow.columnChart(series, {
height: 150,
/* The axis is labelled -60 min, so it must span the daemon's full
* trailing hour regardless of how many buckets have accumulated
* so far. That hour is TWELVE five-minute buckets
* (appflowd: SERIES_SLOTS=12, SERIES_MS=300000), not sixty --
* passing 60 here would squeeze a complete hour into the last
* fifth of the chart, which is a worse lie than the one this
* fixes. If SERIES_SLOTS changes, this changes with it. */
slots: 12,
xlabels: [ _('-%d min').format(60), _('now') ]
}));
else
body.push(appflow.placeholder(_('No time series available for this application.')));
body.push(E('h4', {}, [ _('Devices') ]));
if (devices.length) {
var rows = [ E('tr', { 'class': 'tr table-titles' }, [
E('th', { 'class': 'th' }, [ _('Client') ]),
E('th', { 'class': 'th' }, [ _('Last seen') ]),
E('th', { 'class': 'th af-num' }, [ _('Download') ]),
E('th', { 'class': 'th af-num' }, [ _('Upload') ]),
E('th', { 'class': 'th af-num' }, [ _('Share') ])
]) ];
devices.forEach(function(dev) {
rows.push(E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td' }, [ appflow.deviceCell(dev) ]),
E('td', { 'class': 'td af-sub' }, [ appflow.fmtAgo(dev.seen) ]),
E('td', { 'class': 'td af-num' }, [ appflow.fmtBytes(dev.dl) ]),
E('td', { 'class': 'td af-num' }, [ appflow.fmtBytes(dev.ul) ]),
E('td', { 'class': 'td af-num' }, [
appflow.fmtPercent(dev.total, totals.total)
])
]));
});
body.push(E('table', { 'class': 'table af-table' }, rows));
}
else {
body.push(appflow.placeholder(
_('No per-device breakdown available for this application.')));
}
if (hosts.length) {
body.push(E('h4', {}, [ _('Hosts') ]));
body.push(E('ul', { 'class': 'af-legend' }, hosts.map(function(h) {
return E('li', {}, [
E('span', { 'class': 'af-legend-lbl af-mono', 'title': h.name }, [ h.name ]),
E('span', { 'class': 'af-legend-val af-num' }, [
appflow.fmtBytes(h.total)
])
]);
})));
}
body.push(E('div', { 'class': 'af-btnrow' }, [
E('button', {
'class': 'btn cbi-button cbi-button-neutral',
'click': ui.hideModal
}, [ _('Close') ])
]));
/* Node array, not a bare string -- the same rule stated at the top of
* handleDetail() and, until 2026-08-26, not followed here. LuCI routes a
* bare-string title through innerHTML, and `_()` returns whatever the
* active catalog says. With the English catalog that is a constant in
* this file; the moment a translation is accepted it is third-party
* content, which is exactly what the pending zh_Hans PR proposes. Raised
* by a review panel over that PR, which was careful to note the submitted
* Chinese contains no markup: this is closing the trust distinction, not
* fixing an exploit. */
ui.showModal([ _('Details') ], body, 'cbi-modal');
},
/* -------------------------------------------------------------- clear */
handleClear: function() {
ui.showModal([ _('Clear statistics') ], [
E('p', {}, [
_('This resets the per-application, per-device and per-category counters and time buckets. Live flow tracking continues uninterrupted, so the figures on this page simply start again from zero.')
]),
E('div', { 'class': 'af-btnrow' }, [
E('button', {
'class': 'btn cbi-button cbi-button-neutral',
'click': ui.hideModal
}, [ _('Cancel') ]),
E('button', {
'class': 'btn cbi-button cbi-button-negative important',
'click': ui.createHandlerFn(this, 'handleClearConfirm')
}, [ _('Clear') ])
])
]);
},
handleClearConfirm: function() {
return appflow.rpc.reset().then(L.bind(function() {
ui.hideModal();
ui.addTimeLimitedNotification(null,
E('p', {}, [ _('AppFlow statistics cleared.') ]), 4000, 'info');
return this.fetch().then(L.bind(this.paint, this));
}, this), function(err) {
ui.hideModal();
ui.addNotification(null, E('p', {}, [
_('Could not clear the statistics: %s').format(String(err.message || err))
]), 'error');
});
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});