mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-27 00:12:51 +08:00
1464 lines
56 KiB
JavaScript
1464 lines
56 KiB
JavaScript
'use strict';
|
|
'require baseclass';
|
|
'require request';
|
|
'require rpc';
|
|
|
|
/*
|
|
* AppFlow: shared frontend helpers.
|
|
*
|
|
* Everything the two views have in common lives here: the ubus bindings from
|
|
* DESIGN.md §5, a thin normalisation layer over the daemon payloads, byte/rate
|
|
* formatting, the category colour scheme and the SVG chart primitives.
|
|
*
|
|
* The charts are hand-drawn SVG on purpose (see docs/frontend.md): SVG reads
|
|
* the theme's CSS custom properties directly, so identical markup stays legible
|
|
* on the light and dark bootstrap themes with no JS colour probing.
|
|
*/
|
|
|
|
/* ------------------------------------------------------------------ ubus */
|
|
|
|
/* No `expect` filter: the daemon's exact envelope is still settling and an
|
|
* expect type mismatch would silently swallow a valid reply. `reject: true`
|
|
* makes a non-zero ubus status reject (4 = object not found, i.e. appflowd is
|
|
* down; 6 = permission denied, i.e. an ACL gap), which is what the views key
|
|
* their "not running" state off. */
|
|
function declare(method, params) {
|
|
return rpc.declare({
|
|
object: 'appflow',
|
|
method: method,
|
|
params: params,
|
|
reject: true
|
|
});
|
|
}
|
|
|
|
/* §5 also defines `apps` and `flows`.
|
|
*
|
|
* `apps` IS bound as of 2026-08-28, because the overview grew a device
|
|
* drill-down: clicking a device asks what that device is doing, and the answer
|
|
* is `apps {device}`. Bound here AND granted in the ACL, in that order, exactly
|
|
* as the note below required.
|
|
*
|
|
* That call returns an AGGREGATE per (device, application) computed from the
|
|
* live flow table. It is more revealing than either list alone, since it says
|
|
* which device is using which application, and it is still far short of
|
|
* `flows`: no remote address, no port, no per-connection record. `flows`
|
|
* remains unbound and ungranted.
|
|
*
|
|
* The original note, which still governs `flows`: A security audit pointed out that this comment was the
|
|
* evidence: the frontend had been deliberately narrowed and the boundary file
|
|
* had not, so an account restricted to this package alone could still call
|
|
* `flows` directly through /cgi-bin/luci/admin/ubus. That one matters more than
|
|
* the others, because every method the UI does use returns an AGGREGATE, while
|
|
* `flows` is the live per-connection table -- which local device is talking to
|
|
* which remote endpoint, right now. If a view grows to need either, bind it
|
|
* here AND add it back to the ACL, in that order.
|
|
* `params` is positional: callStats('hour'), callDetail('a10465'). */
|
|
var callStatus = declare('status'),
|
|
callSummary = declare('summary'),
|
|
/* positional: devices(sort, limit, apps). `apps` restricts the answer to
|
|
* the devices carrying those application keys, which is how the overview
|
|
* answers "who is generating this traffic" once the user has filtered. */
|
|
callDevices = declare('devices', [ 'sort', 'limit', 'apps' ]),
|
|
/* positional: apps(sort, limit, device). `device` scopes the answer to
|
|
* one device, which is the overview's drill-down. */
|
|
callApps = declare('apps', [ 'sort', 'limit', 'device' ]),
|
|
callStats = declare('stats', [ 'range' ]),
|
|
callDetail = declare('app_detail', [ 'app' ]),
|
|
callReset = declare('reset');
|
|
|
|
/* ------------------------------------------------------------- constants */
|
|
|
|
var STYLE_ID = 'appflow-style';
|
|
|
|
/*
|
|
* Optional icon pack (DESIGN §9 soft coupling). The sibling package
|
|
* luci-app-appflow-icons installs a manifest plus SVGs under
|
|
* /luci-static/resources/appflow-icons/. Nothing here depends on it: the
|
|
* manifest is probed once per page load and, when it is absent, every tile
|
|
* stays a letter tile for the rest of the session.
|
|
*
|
|
* iconMap === null probe has not run yet
|
|
* iconMap === {} pack absent or unreadable, do not ask again
|
|
*/
|
|
var ICON_BASE = L.resource('appflow-icons'),
|
|
ICON_FILE = /^[A-Za-z0-9._-]+\.svg$/,
|
|
iconMap = null,
|
|
iconProbe = null;
|
|
|
|
/* Mid-luminance hues: white tile text is legible on every entry, and each
|
|
* colour keeps enough contrast against both the white and the #222 page. */
|
|
var PALETTE = [
|
|
'hsl(210,58%,46%)', 'hsl(150,50%,36%)', 'hsl(27,70%,46%)', 'hsl(280,40%,52%)',
|
|
'hsl(340,52%,50%)', 'hsl(190,55%,38%)', 'hsl(96,40%,38%)', 'hsl(256,44%,56%)',
|
|
'hsl(12,60%,50%)', 'hsl(172,45%,34%)', 'hsl(44,58%,40%)', 'hsl(318,36%,46%)',
|
|
'hsl(224,42%,58%)', 'hsl(84,38%,32%)', 'hsl(0,52%,46%)', 'hsl(300,30%,42%)',
|
|
'hsl(200,60%,32%)', 'hsl(62,42%,34%)'
|
|
];
|
|
|
|
/*
|
|
* Hashing a category name into PALETTE is fine for a tile, but the doughnut
|
|
* legend uses the same colours as a key, and with ~32 netify category tags in
|
|
* 18 buckets collisions are common enough to be seen (web and social-media
|
|
* collided on the first live run). The tags that actually show up get a fixed
|
|
* slot so the legend is unambiguous; anything outside this list still hashes.
|
|
*/
|
|
var CATEGORY_SLOT = {
|
|
'web': 0, 'os-software-updates': 1, 'networking': 2,
|
|
'infrastructure': 3, 'social-media': 4, 'messaging': 5,
|
|
'cdn': 6, 'technology': 7, 'shopping': 8,
|
|
'financial': 9, 'business': 10, 'games': 11,
|
|
'file-sharing': 12, 'mail': 13, 'streaming-media': 14,
|
|
'advertiser': 15, 'voip': 16, 'news': 17
|
|
};
|
|
|
|
/* 'unclassified' is a real netify category; 'other' is our own roll-up of the
|
|
* slices past the top N. They are different things, so they get different
|
|
* greys rather than colliding in the doughnut legend. */
|
|
var NEUTRAL = 'hsl(0,0%,50%)',
|
|
NEUTRAL_ROLLUP = 'hsl(0,0%,72%)';
|
|
|
|
/*
|
|
* EVERY category tag, not only the ones whose title-case reads badly.
|
|
*
|
|
* Until 1.1.1 this table held twelve entries, the ones where naive title-case
|
|
* produced the wrong English ('cdn' -> 'Cdn'). Everything else fell through to
|
|
* the title-caser at the end of catLabel(), which is string manipulation and
|
|
* not translation: 'web' became 'Web' by an algorithm, in English, in every
|
|
* language, for ever. `_()` never saw those strings, so the extractor never
|
|
* put them in the catalogue and no translator could reach them. Reported by
|
|
* @ntbowen against 1.1.0.
|
|
*
|
|
* The keys are the union of application_tag_index and protocol_tag_index from
|
|
* netifyd 4.4.7's /etc/netify.d/netify-categories.json, read off the bench,
|
|
* plus appflow's own four. A tag outside this list still title-cases and is
|
|
* still untranslated, which is a graceful degradation rather than a failure;
|
|
* frontend-suite.js holds its own copy of the list and goes red when one is
|
|
* missing, so a netifyd update that adds a tag is a visible event.
|
|
*
|
|
* KEEP THE ENGLISH EXACTLY AS THE TITLE-CASER PRODUCED IT where it was already
|
|
* right. These strings are shipped and screenshotted; this change is about how
|
|
* a label is produced, not what it says.
|
|
*/
|
|
var CATEGORY_LABELS = {
|
|
/* netifyd, application_tag_index */
|
|
'adult': _('Adult'),
|
|
'advertiser': _('Advertiser'),
|
|
'business': _('Business'),
|
|
'cdn': _('CDN'),
|
|
'cybersecurity': _('Cybersecurity'),
|
|
'device-iot': _('IoT Devices'),
|
|
'education': _('Education'),
|
|
'entertainment': _('Entertainment'),
|
|
'file-sharing': _('File Sharing'),
|
|
'financial': _('Financial'),
|
|
'gambling': _('Gambling'),
|
|
'games': _('Games'),
|
|
'government': _('Government'),
|
|
'hosting': _('Hosting'),
|
|
'mail': _('Mail'),
|
|
'malware': _('Malware'),
|
|
'messaging': _('Messaging'),
|
|
'news': _('News'),
|
|
'os-software-updates': _('OS & Software Updates'),
|
|
'portal': _('Portal'),
|
|
'recreation': _('Recreation'),
|
|
'reference': _('Reference'),
|
|
'remote-desktop': _('Remote Desktop'),
|
|
'shopping': _('Shopping'),
|
|
'social-media': _('Social Media'),
|
|
'sports': _('Sports'),
|
|
'streaming-media': _('Streaming Media'),
|
|
'technology': _('Technology'),
|
|
'telco': _('Telco'),
|
|
'unclassified': _('Unclassified'),
|
|
'voip': _('VoIP'),
|
|
'vpn-and-proxy': _('VPN & Proxy'),
|
|
|
|
/* netifyd, protocol_tag_index: the tags the application index does not
|
|
* also carry. A flow classified by protocol rather than by application
|
|
* reaches the browser through exactly the same field. */
|
|
'authentication': _('Authentication'),
|
|
'database': _('Database'),
|
|
'file-server': _('File Server'),
|
|
'infrastructure': _('Infrastructure'),
|
|
'media': _('Media'),
|
|
'media-provider': _('Media Provider'),
|
|
'networking': _('Networking'),
|
|
'printing': _('Printing'),
|
|
'proxy': _('Proxy'),
|
|
'vpn': _('VPN'),
|
|
'web': _('Web'),
|
|
|
|
/* appflow's own, from the AI breakout. The daemon sends these as slugs
|
|
* for exactly this reason; see AI_HOSTS in appflowd. */
|
|
'ai-assistants': _('AI assistants'),
|
|
'ai-developer': _('AI developer'),
|
|
'ai-infrastructure': _('AI infrastructure'),
|
|
'ai-media': _('AI media')
|
|
};
|
|
|
|
/*
|
|
* appflowd collapses unattributed traffic into three synthetic pseudo-devices
|
|
* and sends their display name already rendered in English, because a daemon
|
|
* has no idea what language a browser wants. It also sends the stable key
|
|
* behind that name, so the key is what we translate on: keying on the English
|
|
* would mean a daemon-side wording change silently reverting the translation.
|
|
* Every real device keeps the name the daemon resolved from its DHCP lease.
|
|
* Reported by @ntbowen against 1.1.0, and this is his fix.
|
|
*/
|
|
var DEVICE_LABELS = {
|
|
'router': _('Router'),
|
|
'unknown': _('Unknown'),
|
|
'multicast': _('Multicast / Broadcast')
|
|
};
|
|
|
|
/* Field-name aliases.
|
|
*
|
|
* appflowd speaks two dialects: the live aggregates (summary / apps / devices)
|
|
* use bytes_down / bytes_up / bytes_total / rate_down / rate_up / rate_total,
|
|
* while the range views (stats / app_detail) use download / upload / total.
|
|
* Every read goes through these lists, so the views never care which one they
|
|
* are looking at and a future rename costs one edit here.
|
|
*
|
|
* Note that `tag` is deliberately NOT a category alias: on the live aggregates
|
|
* it carries the raw detection tag ("netify.openwrt"), not a category. */
|
|
/* `dl` and `ul` are this module's OWN output names, listed here so the
|
|
* normalisers are idempotent. Without them normSeries() was destructive on a
|
|
* second pass: it reads components through these lists and the total through
|
|
* A_TOT, so re-normalising already-normalised data yielded
|
|
* { t: <real>, dl: 0, ul: 0, total: <real> }
|
|
* -- a total that contradicts its own parts. statistics.js:380 does exactly
|
|
* that when it falls through to `app.series`, which normApp() has already
|
|
* normalised, and columnChart reads only p.dl/p.ul, so it drew an EMPTY framed
|
|
* plot under a KPI row reporting megabytes. Worse, series.length was still 12,
|
|
* so the honest "No time series available" branch never ran.
|
|
* Found by a code-review panel, 2026-08-25. No daemon field is named dl or ul,
|
|
* so the aliases are inert against real payloads. */
|
|
var A_DL = [ 'download', 'bytes_down', 'rx_bytes', 'download_bytes', 'total_download', 'dl' ],
|
|
A_UL = [ 'upload', 'bytes_up', 'tx_bytes', 'upload_bytes', 'total_upload', 'ul' ],
|
|
A_TOT = [ 'total', 'bytes_total', 'total_bytes', 'total_traffic' ],
|
|
A_RDL = [ 'rate_down', 'rx_rate', 'download_rate', 'down_rate' ],
|
|
A_RUL = [ 'rate_up', 'tx_rate', 'upload_rate', 'up_rate' ],
|
|
A_RATE = [ 'rate_total', 'rate', 'total_rate' ],
|
|
A_KEY = [ 'key', 'app', 'application', 'application_id', 'app_identifier', 'id' ],
|
|
A_LBL = [ 'label', 'name', 'application_name', 'app_name', 'app', 'application' ],
|
|
A_CAT = [ 'category', 'category_name', 'cat' ],
|
|
A_HOST = [ 'host', 'hostname', 'host_server_name', 'domain' ],
|
|
A_MAC = [ 'mac', 'local_mac', 'mac_address', 'macaddr' ],
|
|
A_IP = [ 'ip', 'local_ip', 'ipaddr', 'address' ],
|
|
A_DNAM = [ 'name', 'hostname', 'label', 'alias', 'device' ],
|
|
A_SEEN = [ 'last_seen', 'last_seen_at', 'last_active', 'last_active_time', 'seen' ],
|
|
A_TIME = [ 'ts', 't', 'time', 'timestamp', 'interval_start', 'start' ],
|
|
A_NFLOW = [ 'flows', 'active_flows', 'flow_count' ],
|
|
/* Raw netify detection tag: named 'tag' on the live aggregates and on
|
|
* app_detail, carried in 'app' on the range views. Icon lookups key off
|
|
* this, never off the display label. */
|
|
A_TAG = [ 'tag', 'app' ];
|
|
|
|
/* ------------------------------------------------------------------- css */
|
|
|
|
var CSS = '\
|
|
.af-grid{display:grid;grid-template-columns:1fr;gap:0 1em}\
|
|
@media (min-width:920px){.af-grid.af-split{grid-template-columns:minmax(0,1.9fr) minmax(270px,1fr)}}\
|
|
.af-card{border:1px solid var(--border-color-medium);border-radius:4px;\
|
|
background:var(--background-color-high);padding:.8em 1em 1em;margin:0 0 1em}\
|
|
.af-cardhead{display:flex;align-items:baseline;justify-content:space-between;\
|
|
gap:.75em;flex-wrap:wrap;margin:0 0 .8em}\
|
|
.af-cardhead h3{margin:0;padding:0;font-size:15px;font-weight:700;border:none}\
|
|
.af-cardaux{display:flex;align-items:center;gap:.7em;flex-wrap:wrap;\
|
|
font-size:11px;color:var(--text-color-medium)}\
|
|
.af-sub{color:var(--text-color-medium);font-size:11px;line-height:14px;display:block}\
|
|
.af-app .af-sub,.af-legend .af-legend-lbl,.af-applabel{overflow:hidden;\
|
|
text-overflow:ellipsis;white-space:nowrap}\
|
|
.af-empty .af-sub{font-size:12px;line-height:16px}\
|
|
.af-mono{font-family:var(--font-mono);font-size:10.5px}\
|
|
.af-num{text-align:right;white-space:nowrap;font-variant-numeric:tabular-nums;\
|
|
font-feature-settings:"tnum" 1}\
|
|
.af-strip{display:flex;flex-wrap:wrap;gap:.4em;margin:0 0 1em}\
|
|
.af-pill{display:inline-flex;align-items:center;gap:.45em;font-size:11px;\
|
|
line-height:1;padding:.45em .75em;border-radius:11px;\
|
|
border:1px solid var(--border-color-medium);color:var(--text-color-medium);\
|
|
background:var(--background-color-low)}\
|
|
.af-pill > i{width:7px;height:7px;border-radius:50%;background:var(--text-color-low)}\
|
|
.af-pill.af-ok > i{background:var(--success-color-high,hsl(150,50%,36%))}\
|
|
.af-pill.af-warn{color:var(--error-color-high);border-color:var(--error-color-high)}\
|
|
.af-pill.af-warn > i{background:var(--error-color-high)}\
|
|
.af-pill.af-unknown > i{background:var(--text-color-low);opacity:.55}\
|
|
.af-tile{display:inline-flex;align-items:center;justify-content:center;\
|
|
width:26px;height:26px;flex:0 0 26px;border-radius:5px;color:#fff;\
|
|
font-weight:600;font-size:12px;text-shadow:0 1px 1px rgba(0,0,0,.25)}\
|
|
.af-tile.af-tile-icon{font-size:0}\
|
|
.af-tile-img{width:64%;height:64%;object-fit:contain;display:block}\
|
|
.af-tile-img.af-mono{filter:brightness(0) invert(1)}\
|
|
.af-app{display:flex;align-items:flex-start;gap:.6em;min-width:0}\
|
|
.af-appname{min-width:0;overflow:hidden}\
|
|
.af-applabel{display:block;overflow:hidden;text-overflow:ellipsis;\
|
|
white-space:nowrap;line-height:16px}\
|
|
.af-bar{position:relative;display:flex;height:8px;min-width:56px;border-radius:4px;\
|
|
background:var(--background-color-medium);overflow:hidden;\
|
|
box-shadow:inset 0 0 0 1px var(--border-color-low)}\
|
|
.af-bar > i{display:block;height:100%;transition:width .3s ease-out}\
|
|
.af-bar > i:last-child{border-top-right-radius:4px;border-bottom-right-radius:4px}\
|
|
i.af-dl{background:var(--primary-color-high,hsl(210,58%,46%))}\
|
|
i.af-ul{background:var(--success-color-high,hsl(150,50%,36%))}\
|
|
.af-kpi{display:flex;flex-wrap:wrap;gap:1.4em 2.4em;margin:0 0 1.1em}\
|
|
.af-kpi-lbl{font-size:10px;text-transform:uppercase;letter-spacing:.08em;\
|
|
color:var(--text-color-medium);margin-bottom:.2em}\
|
|
.af-kpi-val{font-size:21px;line-height:1.1;font-weight:600;\
|
|
font-variant-numeric:tabular-nums;font-feature-settings:"tnum" 1}\
|
|
.af-kpi-val.af-dlc{color:var(--primary-color-high,hsl(210,58%,46%))}\
|
|
.af-kpi-val.af-ulc{color:var(--success-color-high,hsl(150,50%,36%))}\
|
|
.af-plot{position:relative;padding-left:64px}\
|
|
.af-plot > svg{display:block;width:100%;height:100%;overflow:visible}\
|
|
.af-grid-line{stroke:var(--border-color-medium);stroke-width:1;opacity:.7}\
|
|
.af-line{fill:none;stroke-width:2;stroke-linejoin:round;stroke-linecap:round}\
|
|
.af-line.af-dl{stroke:var(--primary-color-high,hsl(210,58%,46%))}\
|
|
.af-line.af-ul{stroke:var(--success-color-high,hsl(150,50%,36%))}\
|
|
.af-area{stroke:none}\
|
|
.af-area.af-dl{fill:var(--primary-color-high,hsl(210,58%,46%));fill-opacity:.16}\
|
|
.af-area.af-ul{fill:var(--success-color-high,hsl(150,50%,36%));fill-opacity:.12}\
|
|
rect.af-col.af-dl{fill:var(--primary-color-high,hsl(210,58%,46%))}\
|
|
rect.af-col.af-ul{fill:var(--success-color-high,hsl(150,50%,36%))}\
|
|
.af-yaxis{position:absolute;left:0;top:0;bottom:0;width:58px;pointer-events:none}\
|
|
.af-ylbl{position:absolute;right:0;transform:translateY(-50%);font-size:10px;\
|
|
color:var(--text-color-medium);font-variant-numeric:tabular-nums;white-space:nowrap}\
|
|
.af-xaxis{display:flex;justify-content:space-between;padding-left:64px;\
|
|
font-size:10px;color:var(--text-color-medium);margin-top:.8em}\
|
|
.af-key{display:flex;gap:1.5em;flex-wrap:wrap;margin-top:.7em;font-size:11px}\
|
|
.af-key > span{display:inline-flex;align-items:center;gap:.4em}\
|
|
.af-key strong{font-size:12px}\
|
|
.af-dot{display:inline-block;width:9px;height:9px;border-radius:2px;flex:0 0 9px}\
|
|
.af-donut-wrap{display:flex;align-items:center;gap:1.2em;flex-wrap:wrap}\
|
|
.af-pick{cursor:pointer;border-radius:3px}\n.af-pick:hover,.af-pick:focus{background:var(--background-color-medium);outline:none}\n.af-chipbar{margin:0 0 .5em}\n.af-chip{display:inline-flex;align-items:center;gap:.5em;padding:.15em .6em;border-radius:12px;background:var(--background-color-medium);font-size:12px}\n.af-chip-x{text-decoration:none;font-weight:bold;opacity:.7}\n.af-chip-x:hover{opacity:1}\
|
|
.af-pick:hover,.af-pick:focus{background:var(--background-color-medium);outline:none}\
|
|
.af-filter{display:flex;gap:.5em;align-items:center;margin:0 0 .6em}\
|
|
.af-filter input{flex:1 1 auto;min-width:8em}\
|
|
.af-filter-note{font-size:11px;opacity:.75}\
|
|
.af-donut{position:relative;width:128px;height:128px;flex:0 0 128px}\
|
|
.af-donut > svg{display:block;width:100%;height:100%}\
|
|
.af-donut-track{stroke:var(--background-color-medium)}\
|
|
.af-donut-mid{position:absolute;left:0;right:0;top:0;bottom:0;display:flex;\
|
|
flex-direction:column;align-items:center;justify-content:center;\
|
|
text-align:center;pointer-events:none}\
|
|
.af-donut-mid strong{font-size:14px;font-variant-numeric:tabular-nums}\
|
|
.af-donut-mid span{font-size:9px;color:var(--text-color-medium);\
|
|
text-transform:uppercase;letter-spacing:.07em}\
|
|
.af-legend{list-style:none;margin:0;padding:0;flex:1 1 145px;min-width:0}\
|
|
.af-legend > li{display:flex;align-items:center;gap:.5em;padding:.22em 0;font-size:12px}\
|
|
.af-legend-lbl{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;\
|
|
white-space:nowrap}\
|
|
.af-legend-val{flex:0 0 auto;color:var(--text-color-medium);font-size:11px}\
|
|
.af-table{margin-bottom:0}\
|
|
.af-table .th,.af-table .td{text-align:start;padding:7px 10px}\
|
|
.af-table .tr.af-row{cursor:pointer}\
|
|
.af-table .tr.af-row:hover .td{background:var(--background-color-low)}\
|
|
.af-table .tr.af-all .td{background:var(--background-color-medium);font-weight:600}\
|
|
.af-table .th.af-num,.af-table .td.af-num{text-align:right}\
|
|
.af-table .th.af-sortable{cursor:pointer;user-select:none;white-space:nowrap}\
|
|
.af-table .th.af-sortable:hover{color:var(--primary-color-high,hsl(210,58%,46%))}\
|
|
.af-caret{font-size:9px;margin-left:.3em;opacity:.6}\
|
|
.af-placeholder{padding:1.7em 0;text-align:center;color:var(--text-color-medium);\
|
|
font-size:12px}\
|
|
.af-empty{text-align:center;padding:2.2em 1em}\
|
|
.af-empty svg{width:34px;height:34px;fill:var(--warn-color-high);margin-bottom:.3em}\
|
|
.af-empty h3{margin:0 0 .5em;border:none;font-size:16px}\
|
|
.af-empty p{max-width:52em;margin:0 auto .8em;color:var(--text-color-medium)}\
|
|
.af-cmd{display:inline-block;text-align:left;margin:0 auto .9em;padding:.6em .9em;\
|
|
border:1px solid var(--border-color-medium);border-radius:3px;\
|
|
background:var(--background-color-low);font-family:var(--font-mono);font-size:12px}\
|
|
.af-toolbar{display:flex;align-items:center;gap:.6em;flex-wrap:wrap;margin:0 0 .9em}\
|
|
.af-toolbar input[type="text"]{width:235px;max-width:100%}\
|
|
.af-spacer{flex:1 1 auto}\
|
|
.af-ranges{margin-bottom:1.2em}\
|
|
.af-ranges > li.af-disabled,.af-ranges > li.af-disabled > a{cursor:not-allowed}\
|
|
.af-modal-head{display:flex;align-items:center;gap:.75em;margin:0 0 1em}\
|
|
.af-modal-head .af-tile{width:34px;height:34px;flex-basis:34px;font-size:15px;\
|
|
border-radius:6px}\
|
|
.af-modal-head h4{margin:0;font-size:16px;line-height:1.2;text-align:start}\
|
|
.af-modal-head > div{text-align:start}\
|
|
.af-badge{display:inline-block;font-size:10px;letter-spacing:.03em;padding:.2em .55em;\
|
|
border-radius:9px;color:#fff;text-shadow:0 1px 1px rgba(0,0,0,.25)}\
|
|
.af-btnrow{display:flex;justify-content:flex-end;flex-wrap:wrap;gap:.5em;margin-top:.8em}\
|
|
.af-tag{display:inline-block;margin-left:.45em;padding:0 .4em;border-radius:3px;\
|
|
font-size:9px;line-height:14px;text-transform:uppercase;letter-spacing:.05em;\
|
|
color:var(--text-color-medium);border:1px solid var(--border-color-high);\
|
|
vertical-align:1px}\
|
|
.modal h4{margin:1.1em 0 .5em;font-size:13px;text-transform:uppercase;\
|
|
letter-spacing:.06em;color:var(--text-color-medium);border:none;text-align:start}\
|
|
.modal .af-modal-head h4{margin:0;font-size:16px;text-transform:none;letter-spacing:0;\
|
|
color:var(--text-color-high);text-align:start}\
|
|
';
|
|
|
|
/* --------------------------------------------------------------- exports */
|
|
|
|
return baseclass.extend({
|
|
rpc: {
|
|
status: callStatus,
|
|
summary: callSummary,
|
|
devices: callDevices,
|
|
apps: callApps,
|
|
stats: callStats,
|
|
detail: callDetail,
|
|
reset: callReset
|
|
},
|
|
|
|
/* ------------------------------------------------------ value picking */
|
|
|
|
num: function(obj, names, dflt) {
|
|
if (obj != null)
|
|
for (var i = 0; i < names.length; i++) {
|
|
var v = obj[names[i]];
|
|
|
|
if (v != null && v !== '' && typeof v != 'object' && !isNaN(+v))
|
|
return +v;
|
|
}
|
|
|
|
return (dflt != null) ? dflt : 0;
|
|
},
|
|
|
|
str: function(obj, names, dflt) {
|
|
if (obj != null)
|
|
for (var i = 0; i < names.length; i++) {
|
|
var v = obj[names[i]];
|
|
|
|
if (v != null && v !== '' && typeof v != 'object')
|
|
return String(v);
|
|
}
|
|
|
|
return (dflt != null) ? dflt : '';
|
|
},
|
|
|
|
/* A ubus payload may hand back either an array or a map keyed by id;
|
|
* accept both and always yield an array. */
|
|
toList: function(value, keyField) {
|
|
if (Array.isArray(value))
|
|
return value.filter(function(v) { return v != null; });
|
|
|
|
if (!value || typeof value != 'object')
|
|
return [];
|
|
|
|
return Object.keys(value).map(function(k) {
|
|
var row = (value[k] && typeof value[k] == 'object') ? value[k] : {},
|
|
out = {};
|
|
|
|
for (var f in row)
|
|
out[f] = row[f];
|
|
|
|
if (keyField && out[keyField] == null)
|
|
out[keyField] = k;
|
|
|
|
return out;
|
|
});
|
|
},
|
|
|
|
/* -------------------------------------------------------- normalisers */
|
|
|
|
/* Byte and rate counters, wherever they sit in the payload. */
|
|
normTotals: function(raw) {
|
|
var dl = this.num(raw, A_DL),
|
|
ul = this.num(raw, A_UL),
|
|
tot = this.num(raw, A_TOT, -1),
|
|
rdl = this.num(raw, A_RDL),
|
|
rul = this.num(raw, A_RUL),
|
|
rate = this.num(raw, A_RATE, -1);
|
|
|
|
return {
|
|
dl: dl,
|
|
ul: ul,
|
|
total: (tot >= 0) ? tot : dl + ul,
|
|
rdl: rdl,
|
|
rul: rul,
|
|
rate: (rate >= 0) ? rate : rdl + rul,
|
|
flows: this.num(raw, A_NFLOW)
|
|
};
|
|
},
|
|
|
|
/* A dedicated rate object may name its members download/upload rather than
|
|
* rx_rate/tx_rate; fold both spellings into rdl/rul. */
|
|
/* A `rates` envelope may name its fields either way: rate_down/rx_rate, or
|
|
* the byte-ish download/bytes_down, which inside a rates object still mean
|
|
* a RATE. So a fallback between the two lists is intended.
|
|
*
|
|
* What is not intended is `t.rdl || t.dl`, which is what this used to be.
|
|
* `||` cannot tell "the rate field is absent" from "the rate is legitimately
|
|
* zero", so an idle link carrying rate_down: 0 alongside a cumulative
|
|
* bytes_down fell through to the byte counter and the Download KPI read
|
|
* something like "12.4 GB/s" while nothing moved. Read with a -1 sentinel
|
|
* and test presence instead, the way normTotals already does for its own
|
|
* totals. Raised by a code-review panel, 2026-08-25.
|
|
*
|
|
* Latent rather than live: the only caller is guarded by `summary.rates ?`,
|
|
* and this daemon puts byte and rate counters together in `totals` and sends
|
|
* no `rates` object at all. This is the forward-looking path the comment in
|
|
* overview.js promises, so it should be correct before it is ever used. */
|
|
normRates: function(raw) {
|
|
var rdl = this.num(raw, A_RDL, -1),
|
|
rul = this.num(raw, A_RUL, -1),
|
|
rate = this.num(raw, A_RATE, -1),
|
|
t = this.normTotals(raw);
|
|
|
|
return {
|
|
rdl: (rdl >= 0) ? rdl : t.dl,
|
|
rul: (rul >= 0) ? rul : t.ul,
|
|
rate: (rate >= 0) ? rate : t.total
|
|
};
|
|
},
|
|
|
|
normApp: function(raw) {
|
|
var t = this.normTotals(raw),
|
|
label = this.appLabel(this.str(raw, A_LBL, _('Unknown')));
|
|
|
|
t.key = this.str(raw, A_KEY, label);
|
|
t.label = label;
|
|
t.cat = this.str(raw, A_CAT);
|
|
t.host = this.str(raw, A_HOST);
|
|
t.tag = this.str(raw, A_TAG);
|
|
t.series = this.normSeries(raw ? (raw.time_series || raw.series) : null);
|
|
|
|
return t;
|
|
},
|
|
|
|
normDevice: function(raw) {
|
|
var t = this.normTotals(raw),
|
|
key = this.str(raw, A_KEY),
|
|
mac = this.str(raw, A_MAC),
|
|
ip = this.str(raw, A_IP);
|
|
|
|
/* The daemon's own device key, carried through because the overview
|
|
* drills into a device by it. It was dropped here, so dev.key was
|
|
* undefined at the call site and the drill-down silently did nothing:
|
|
* no error, no effect, a feature that looked wired and was not.
|
|
*
|
|
* Falls back to mac/ip so a row always has something to identify it,
|
|
* but the daemon's key is preferred: it is what the pseudo-devices
|
|
* (router, multicast, unknown) are addressed by, and those have no
|
|
* MAC at all. */
|
|
t.key = this.str(raw, [ 'key', 'device', 'id' ], mac || ip || '');
|
|
t.mac = mac.toUpperCase();
|
|
t.ip = ip;
|
|
|
|
/* The three synthetic pseudo-devices are named on the KEY, not on the
|
|
* name the daemon sent: that name is English and pre-rendered, and
|
|
* matching it as a string would mean any daemon-side rewording
|
|
* silently dropped back to English. Every real device keeps the name
|
|
* resolved from its DHCP lease.
|
|
*
|
|
* REUSE t.key, DO NOT RE-DERIVE IT. The contributor's patch added its
|
|
* own `key = this.str(raw, A_KEY)` here, and A_KEY is the APPLICATION
|
|
* alias list: it reaches for 'app' and 'application' before 'id', so a
|
|
* device row carrying either field would have been named from an
|
|
* application identifier. Two key derivations in one function that
|
|
* disagree is the bug; the device key is already sitting in t.key,
|
|
* three lines up, derived from the device alias list.
|
|
*
|
|
* hasOwnProperty for the reason given in catLabel(): a bare lookup on
|
|
* '__proto__' or 'constructor' returns a truthy object, and t.name is
|
|
* assumed to be a string by the .toUpperCase() on the next line. */
|
|
t.name = Object.prototype.hasOwnProperty.call(DEVICE_LABELS, t.key)
|
|
? DEVICE_LABELS[t.key]
|
|
: this.str(raw, A_DNAM, ip || mac || _('Unknown device'));
|
|
t.seen = this.num(raw, A_SEEN, 0);
|
|
t.isRouter = !!(raw && raw.is_router);
|
|
|
|
/* the daemon falls back to the MAC when no lease name is known; do not
|
|
* then print the same string twice in the cell */
|
|
t.sub = (t.name.toUpperCase() == t.mac) ? t.ip : (t.mac || t.ip);
|
|
|
|
return t;
|
|
},
|
|
|
|
/* Secondary line under an application name: the server hostname when the
|
|
* payload carries one, otherwise the category, otherwise the flow count. */
|
|
subLabel: function(app) {
|
|
if (app.host)
|
|
return app.host;
|
|
|
|
if (app.cat)
|
|
return this.catLabel(app.cat);
|
|
|
|
if (app.flows)
|
|
return this.fmtFlows(app.flows);
|
|
|
|
return '';
|
|
},
|
|
|
|
fmtFlows: function(count) {
|
|
var n = Math.max(0, Math.round(+count || 0));
|
|
|
|
return (n == 1) ? _('1 flow') : _('%d flows').format(n);
|
|
},
|
|
|
|
normSeries: function(raw) {
|
|
var self = this;
|
|
|
|
return this.toList(raw).map(function(p) {
|
|
var dl = self.num(p, A_DL),
|
|
ul = self.num(p, A_UL),
|
|
tot = self.num(p, A_TOT, -1);
|
|
|
|
return {
|
|
t: self.num(p, A_TIME, 0),
|
|
dl: dl,
|
|
ul: ul,
|
|
total: (tot >= 0) ? tot : dl + ul
|
|
};
|
|
});
|
|
},
|
|
|
|
/* --------------------------------------------------------- formatting */
|
|
|
|
/* LuCI's %m specifier only engages above the divisor and prints the raw
|
|
* float below it, so anything under 1 KiB is rounded here instead. */
|
|
fmtBytes: function(bytes) {
|
|
var n = +bytes;
|
|
|
|
if (!isFinite(n) || n < 0)
|
|
n = 0;
|
|
|
|
return (n > 1024) ? '%1024.2mB'.format(n) : '%d B'.format(Math.round(n));
|
|
},
|
|
|
|
fmtRate: function(bps) {
|
|
var n = +bps;
|
|
|
|
if (!isFinite(n) || n < 0)
|
|
n = 0;
|
|
|
|
return (n > 1024) ? '%1024.2mB/s'.format(n) : '%d B/s'.format(Math.round(n));
|
|
},
|
|
|
|
fmtPercent: function(part, whole) {
|
|
if (!whole)
|
|
return '0.0%';
|
|
|
|
/* Clamp at 100: the app total and the per-device totals come from rings
|
|
* of different bucket granularity over the same trailing hour, so at the
|
|
* window edge a single device's total can round just above the app total.
|
|
* A share over 100% reads as a bug even when the few-byte cause is benign. */
|
|
var pct = 100 * part / whole;
|
|
|
|
return '%.1f%%'.format(pct > 100 ? 100 : pct);
|
|
},
|
|
|
|
fmtAgo: function(stamp) {
|
|
var ts = +stamp;
|
|
|
|
if (!isFinite(ts) || ts <= 0)
|
|
return '-';
|
|
|
|
/* accept both second and millisecond epochs */
|
|
if (ts < 1e11)
|
|
ts *= 1000;
|
|
|
|
var d = Math.round((Date.now() - ts) / 1000);
|
|
|
|
if (d < 0)
|
|
d = 0;
|
|
|
|
if (d < 15)
|
|
return _('just now');
|
|
|
|
if (d < 60)
|
|
return _('%d s ago').format(d);
|
|
|
|
if (d < 3600)
|
|
return _('%d min ago').format(Math.floor(d / 60));
|
|
|
|
if (d < 86400)
|
|
return _('%d h ago').format(Math.floor(d / 3600));
|
|
|
|
return _('%d d ago').format(Math.floor(d / 86400));
|
|
},
|
|
|
|
fmtUptime: function(seconds) {
|
|
return '%t'.format(Math.max(0, Math.floor(+seconds || 0)));
|
|
},
|
|
|
|
/* "netify.google-ads" -> "Google Ads"; already-pretty names pass through. */
|
|
appLabel: function(name) {
|
|
var s = String(name || '').replace(/^netify\./, '');
|
|
|
|
if (!s.length)
|
|
return _('Unknown');
|
|
|
|
if (/^[a-z0-9]+([-_.][a-z0-9]+)*$/.test(s))
|
|
s = s.split(/[-_.]/).map(function(w) {
|
|
return w.charAt(0).toUpperCase() + w.substr(1);
|
|
}).join(' ');
|
|
|
|
return s;
|
|
},
|
|
|
|
catLabel: function(cat) {
|
|
var raw = String(cat || '');
|
|
|
|
if (!raw.length)
|
|
return _('Unclassified');
|
|
|
|
/* THE TABLE IS CONSULTED FIRST, ahead of the pass-through below.
|
|
*
|
|
* Until 1.1.1 the pass-through came first, so nothing after it could
|
|
* ever be reached for a value carrying a capital. That is precisely
|
|
* how appflow's own four AI categories became permanently English in
|
|
* every language: the daemon sent "AI assistants", the pass-through
|
|
* returned it verbatim, and the lookup that would have translated it
|
|
* was three lines too late to run. The daemon now sends slugs, and
|
|
* this ordering means a future daemon-side display string can still be
|
|
* translated by adding one key rather than by changing the wire.
|
|
*
|
|
* hasOwnProperty, not a truthiness test: a plain object literal
|
|
* inherits Object.prototype, so CATEGORY_LABELS['__proto__'] and
|
|
* ['constructor'] are truthy OBJECTS and would be returned as if they
|
|
* were labels. color() below already guards its table this way. */
|
|
var c = raw.toLowerCase();
|
|
|
|
if (Object.prototype.hasOwnProperty.call(CATEGORY_LABELS, c))
|
|
return CATEGORY_LABELS[c];
|
|
|
|
/* Already-cased categories pass through untouched, exactly as
|
|
* appLabel() treats already-pretty application names.
|
|
*
|
|
* netifyd's own categories are lowercase slugs ("web", "networking"),
|
|
* so lowercasing them first is harmless. A capitalised category that
|
|
* is NOT in the table must survive as written: the title-case split is
|
|
* on [-_] and a space is neither, so "AI assistants" would otherwise
|
|
* come back as "Ai assistants". */
|
|
if (/[A-Z]/.test(raw))
|
|
return raw;
|
|
|
|
return c.split(/[-_]/).map(function(w) {
|
|
return w.charAt(0).toUpperCase() + w.substr(1);
|
|
}).join(' ');
|
|
},
|
|
|
|
/* ------------------------------------------------------------ colours */
|
|
|
|
/* Stable hash of the category, so every app in a category shares its tile
|
|
* colour and the doughnut legend reads as one colour system. */
|
|
color: function(key) {
|
|
var s = String(key == null ? '' : key).toLowerCase();
|
|
|
|
if (s == 'other')
|
|
return NEUTRAL_ROLLUP;
|
|
|
|
if (!s.length || s == 'unclassified' || s == 'unknown')
|
|
return NEUTRAL;
|
|
|
|
/* hasOwnProperty, not a bare lookup. `s` is attacker-influenced -- it
|
|
* comes from a category tag, an app label, or a DHCP hostname -- and a
|
|
* plain object literal inherits Object.prototype, so CATEGORY_SLOT
|
|
* ['constructor'], ['__proto__'] and ['toString'] all pass an
|
|
* `!= null` test and index PALETTE with a function, yielding undefined.
|
|
* A device named `constructor` then rendered style="background:undefined",
|
|
* which the browser drops, leaving white tile text on a transparent
|
|
* tile; in the doughnut the slice silently did not draw while its legend
|
|
* still claimed a percentage. Found by a code-review panel, 2026-08-25. */
|
|
if (Object.prototype.hasOwnProperty.call(CATEGORY_SLOT, s))
|
|
return PALETTE[CATEGORY_SLOT[s]];
|
|
|
|
var h = 0;
|
|
|
|
for (var i = 0; i < s.length; i++)
|
|
h = ((h << 5) - h + s.charCodeAt(i)) | 0;
|
|
|
|
return PALETTE[Math.abs(h) % PALETTE.length];
|
|
},
|
|
|
|
/* --------------------------------------------------------- primitives */
|
|
|
|
/*
|
|
* Probe the optional icon pack. Resolves either way and never rejects, so
|
|
* callers can fold it into their load() without a catch: a missing pack is
|
|
* the normal case, not an error.
|
|
*/
|
|
loadIcons: function() {
|
|
if (iconProbe != null)
|
|
return iconProbe;
|
|
|
|
iconProbe = request.get(ICON_BASE + '/manifest.json', { cache: true })
|
|
.then(function(res) {
|
|
var map = res.ok ? res.json() : null;
|
|
|
|
iconMap = (map != null && typeof map == 'object') ? map : {};
|
|
})
|
|
.catch(function() {
|
|
iconMap = {};
|
|
});
|
|
|
|
return iconProbe;
|
|
},
|
|
|
|
/*
|
|
* Resolve a netify tag to an icon. simple-icons ship as fill-less glyphs
|
|
* that would render black on a coloured tile, so they are flagged for the
|
|
* white-out filter; full-colour logos are left alone.
|
|
*/
|
|
iconFor: function(tag) {
|
|
var t = String(tag || '');
|
|
|
|
if (iconMap == null || !t.length)
|
|
return null;
|
|
|
|
/* Same prototype hazard as color() above: `t` is a daemon-supplied tag. */
|
|
var e = Object.prototype.hasOwnProperty.call(iconMap, t) ? iconMap[t] : null;
|
|
|
|
if (e == null || typeof e.file != 'string' || !ICON_FILE.test(e.file))
|
|
return null;
|
|
|
|
return {
|
|
url: ICON_BASE + '/svg/' + e.file,
|
|
mono: (e.source == 'simple-icons')
|
|
};
|
|
},
|
|
|
|
tile: function(label, category, tag) {
|
|
/* STRIP ON A UNICODE CLASS, NOT ON ASCII. This used to be
|
|
* /^[^0-9A-Za-z]+/, which eats every leading character that is not an
|
|
* ASCII letter or digit. A name written in any non-Latin script is
|
|
* entirely non-ASCII, so the strip consumed the whole string, `s` came
|
|
* out empty and every such device and application rendered a '?' tile.
|
|
*
|
|
* That was not a translation bug: it hit any LAN client whose DHCP
|
|
* hostname was Chinese, Japanese, Korean, Russian, Greek, Arabic or
|
|
* Hebrew, on every release. Accepting the zh_Hans catalogue would have
|
|
* made it universal rather than occasional, which is how it was found.
|
|
*
|
|
* \p{L} and \p{N} need the /u flag. toUpperCase() is a no-op on
|
|
* scripts without case, which is correct rather than merely harmless. */
|
|
var s = String(label || '').replace(/^[^\p{L}\p{N}]+/u, ''),
|
|
ch = s.length ? s.charAt(0).toUpperCase() : '?',
|
|
icon = this.iconFor(tag),
|
|
node = E('span', {
|
|
'class': 'af-tile',
|
|
'style': 'background:' + this.color(category || label),
|
|
'aria-hidden': 'true'
|
|
}, [ ch ]);
|
|
|
|
if (icon == null)
|
|
return node;
|
|
|
|
/* The letter stays in the DOM behind font-size:0 and is only hidden once
|
|
* the icon has actually decoded, so a cold cache shows a letter that
|
|
* swaps to the logo rather than an empty coloured square, and a manifest
|
|
* entry pointing at a file that is not installed keeps the letter. */
|
|
var img = E('img', {
|
|
'class': 'af-tile-img' + (icon.mono ? ' af-mono' : ''),
|
|
'src': icon.url,
|
|
'alt': '',
|
|
'load': function(ev) {
|
|
if (ev.target.parentNode)
|
|
ev.target.parentNode.classList.add('af-tile-icon');
|
|
},
|
|
'error': function(ev) {
|
|
var host = ev.target.parentNode;
|
|
|
|
if (host) {
|
|
host.removeChild(ev.target);
|
|
host.classList.remove('af-tile-icon');
|
|
}
|
|
}
|
|
});
|
|
|
|
node.appendChild(img);
|
|
|
|
/* already in cache: hide the letter now, before the first paint */
|
|
if (img.complete && img.naturalWidth > 0)
|
|
node.classList.add('af-tile-icon');
|
|
|
|
return node;
|
|
},
|
|
|
|
/* `onPick` makes the application name and its category sub-label clickable,
|
|
* each filtering to itself. Everything visible that names a thing should be
|
|
* a way to see only that thing; a label that looks like a link and is not
|
|
* is worse than plain text. */
|
|
appCell: function(app, sub, onPick) {
|
|
var second = (sub != null) ? sub : this.subLabel(app),
|
|
pick = (typeof onPick === 'function') ? onPick : null;
|
|
|
|
var mk = function(cls, text, value) {
|
|
var a = { 'class': cls, 'title': text };
|
|
|
|
if (pick && value) {
|
|
a['class'] += ' af-pick';
|
|
a.title = _('Show only %s').format(value);
|
|
a.click = function(ev) { ev.preventDefault(); pick(value); };
|
|
}
|
|
|
|
return E('span', a, [ text ]);
|
|
};
|
|
|
|
/* Colour seed: the category when the payload carries one, so every app
|
|
* in a category shares a tile colour; the stable app key otherwise. */
|
|
return E('div', { 'class': 'af-app' }, [
|
|
this.tile(app.label, app.cat || app.key || app.label, app.tag),
|
|
E('div', { 'class': 'af-appname' }, [
|
|
mk('af-applabel', app.label, app.label),
|
|
second ? mk('af-sub', second, this.catLabel(app.cat) === second ? second : null) : ''
|
|
])
|
|
]);
|
|
},
|
|
|
|
deviceCell: function(dev, onPick) {
|
|
var pick = (typeof onPick === 'function') ? onPick : null,
|
|
attrs = { 'class': 'af-app' };
|
|
|
|
if (pick) {
|
|
attrs['class'] += ' af-pick';
|
|
attrs.title = _('Show what %s is doing').format(dev.name);
|
|
attrs.click = function(ev) { ev.preventDefault(); pick(dev); };
|
|
}
|
|
|
|
return E('div', attrs, [
|
|
this.tile(dev.name, dev.mac || dev.name),
|
|
E('div', { 'class': 'af-appname' }, [
|
|
E('span', { 'class': 'af-applabel', 'title': dev.name }, [
|
|
dev.name,
|
|
dev.isRouter ? E('span', { 'class': 'af-tag' }, [ _('router') ]) : ''
|
|
]),
|
|
dev.sub ? E('span', { 'class': 'af-sub af-mono' }, [ dev.sub ]) : ''
|
|
])
|
|
]);
|
|
},
|
|
|
|
/* Two-tone download/upload bar, scaled against `max`. */
|
|
/* `fmt` names the unit the caller is passing, because two of the three
|
|
* callers pass cumulative bytes and one passes rates. The tooltip formatted
|
|
* with fmtBytes unconditionally until 2026-08-25, so on the Overview's Top
|
|
* applications table the numeric cells read "1.20 MB/s" while hovering the
|
|
* bar in the same row read "Download: 1.20 MB" -- the same quantity stated
|
|
* twice, in two units, one of them wrong, and the wrong one being the one
|
|
* that looks cumulative. Fixed here rather than at the call site so the next
|
|
* caller cannot step on it, and so the translated string stops having to
|
|
* serve two unit systems at once. */
|
|
bar: function(dl, ul, max, fmt) {
|
|
var m = (max > 0) ? max : 1,
|
|
w1 = Math.max(0, Math.min(100, 100 * dl / m)),
|
|
w2 = Math.max(0, Math.min(100 - w1, 100 * ul / m)),
|
|
f = (typeof fmt === 'function') ? fmt : this.fmtBytes;
|
|
|
|
return E('div', {
|
|
'class': 'af-bar',
|
|
'title': _('Download: %s, upload: %s').format(f.call(this, dl), f.call(this, ul))
|
|
}, [
|
|
E('i', { 'class': 'af-dl', 'style': 'width:%.2f%%'.format(w1) }),
|
|
E('i', { 'class': 'af-ul', 'style': 'width:%.2f%%'.format(w2) })
|
|
]);
|
|
},
|
|
|
|
kpi: function(label, value, cls) {
|
|
return E('div', {}, [
|
|
E('div', { 'class': 'af-kpi-lbl' }, [ label ]),
|
|
E('div', { 'class': 'af-kpi-val' + (cls ? ' ' + cls : '') }, [ value ])
|
|
]);
|
|
},
|
|
|
|
card: function(title, aux, body, cls) {
|
|
return E('div', { 'class': 'cbi-section af-card' + (cls ? ' ' + cls : '') }, [
|
|
E('div', { 'class': 'af-cardhead' }, [
|
|
E('h3', {}, [ title ]),
|
|
aux ? E('div', { 'class': 'af-cardaux' }, aux) : ''
|
|
]),
|
|
body
|
|
]);
|
|
},
|
|
|
|
placeholder: function(text) {
|
|
return E('div', { 'class': 'af-placeholder' }, [ text ]);
|
|
},
|
|
|
|
/* ------------------------------------------------------------- charts */
|
|
|
|
/* Round a peak up to a 1/2/5 * 10^n boundary so axis labels stay tidy. */
|
|
niceMax: function(value) {
|
|
var v = +value;
|
|
|
|
if (!isFinite(v) || v <= 0)
|
|
return 1;
|
|
|
|
var exp = Math.floor(Math.log(v) / Math.LN10),
|
|
base = Math.pow(10, exp),
|
|
frac = v / base,
|
|
step = (frac <= 1) ? 1 : (frac <= 2) ? 2 : (frac <= 5) ? 5 : 10;
|
|
|
|
return step * base;
|
|
},
|
|
|
|
svg: function(tag, attrs, children) {
|
|
var node = document.createElementNS('http://www.w3.org/2000/svg', tag);
|
|
|
|
for (var k in (attrs || {}))
|
|
if (attrs[k] != null)
|
|
node.setAttribute(k, attrs[k]);
|
|
|
|
(Array.isArray(children) ? children : (children != null ? [ children ] : []))
|
|
.forEach(function(c) {
|
|
node.appendChild((typeof c == 'string')
|
|
? document.createTextNode(c) : c);
|
|
});
|
|
|
|
return node;
|
|
},
|
|
|
|
yAxis: function(peak, fmt) {
|
|
return E('div', { 'class': 'af-yaxis' }, [ 1, 0.5, 0 ].map(function(f) {
|
|
return E('span', {
|
|
'class': 'af-ylbl',
|
|
'style': 'top:%.1f%%'.format(100 * (1 - f))
|
|
}, [ (f > 0) ? fmt(peak * f) : '0' ]);
|
|
}));
|
|
},
|
|
|
|
xAxis: function(labels) {
|
|
if (!labels || !labels.length)
|
|
return '';
|
|
|
|
return E('div', { 'class': 'af-xaxis' }, [
|
|
E('span', {}, [ labels[0] ]),
|
|
E('span', {}, [ labels[1] || '' ])
|
|
]);
|
|
},
|
|
|
|
gridLines: function(w, h) {
|
|
var out = [];
|
|
|
|
for (var g = 0; g <= 4; g++)
|
|
out.push(this.svg('line', {
|
|
'class': 'af-grid-line',
|
|
'x1': 0, 'x2': w,
|
|
'y1': (h * g / 4).toFixed(1), 'y2': (h * g / 4).toFixed(1),
|
|
'vector-effect': 'non-scaling-stroke'
|
|
}));
|
|
|
|
return out;
|
|
},
|
|
|
|
/*
|
|
* Rolling line chart.
|
|
* series: [ { values: [...], cls: 'af-dl'|'af-ul', fill: bool } ]
|
|
* opts: { slots, height, fmt, xlabels: [ left, right ] }
|
|
*
|
|
* Drawn in a 1000x300 user space stretched to the container width. Strokes
|
|
* carry vector-effect="non-scaling-stroke" so they stay 2px whatever the
|
|
* horizontal scale, and every label is real HTML so it never distorts.
|
|
*/
|
|
lineChart: function(series, opts) {
|
|
opts = opts || {};
|
|
|
|
var self = this,
|
|
W = 1000, H = 300,
|
|
slots = Math.max(2, opts.slots || 60),
|
|
fmt = opts.fmt || function(v) { return self.fmtRate(v); },
|
|
peak = 0;
|
|
|
|
series.forEach(function(s) {
|
|
(s.values || []).forEach(function(v) {
|
|
if (v > peak)
|
|
peak = v;
|
|
});
|
|
});
|
|
|
|
peak = this.niceMax(peak * 1.05);
|
|
|
|
var kids = this.gridLines(W, H);
|
|
|
|
series.forEach(function(s) {
|
|
var vals = s.values || [],
|
|
n = vals.length;
|
|
|
|
if (n < 1)
|
|
return;
|
|
|
|
var pts = vals.map(function(v, i) {
|
|
var x = W * (slots - n + i) / (slots - 1),
|
|
y = H - H * Math.max(0, Math.min(1, v / peak));
|
|
|
|
return x.toFixed(2) + ',' + y.toFixed(2);
|
|
});
|
|
|
|
if (s.fill && n > 1)
|
|
kids.push(self.svg('path', {
|
|
'class': 'af-area ' + (s.cls || ''),
|
|
'd': 'M' + pts[0].split(',')[0] + ',' + H +
|
|
' L' + pts.join(' L') +
|
|
' L' + W + ',' + H + ' Z'
|
|
}));
|
|
|
|
kids.push(self.svg('path', {
|
|
'class': 'af-line ' + (s.cls || ''),
|
|
'vector-effect': 'non-scaling-stroke',
|
|
'd': 'M' + pts.join(' L')
|
|
}));
|
|
});
|
|
|
|
return E('div', { 'class': 'af-chart' }, [
|
|
E('div', {
|
|
'class': 'af-plot',
|
|
'style': 'height:%dpx'.format(opts.height || 168)
|
|
}, [
|
|
/* role=img with no accessible name announces as an unlabelled
|
|
* image, which is worse than leaving the role off entirely.
|
|
* Callers may pass opts.label; there is always a fallback. */
|
|
this.svg('svg', {
|
|
'viewBox': '0 0 %d %d'.format(W, H),
|
|
'preserveAspectRatio': 'none',
|
|
'role': 'img',
|
|
'aria-label': opts.label || _('Traffic over time')
|
|
}, kids),
|
|
this.yAxis(peak, fmt)
|
|
]),
|
|
this.xAxis(opts.xlabels)
|
|
]);
|
|
},
|
|
|
|
/*
|
|
* Stacked download/upload column chart for a short bucket series
|
|
* (the 12 x 5-minute points of the "past hour" range).
|
|
*/
|
|
columnChart: function(points, opts) {
|
|
opts = opts || {};
|
|
|
|
/* `slots` is the width of the axis the CALLER labels, in buckets. A
|
|
* partial series is drawn right-aligned against it, so three buckets
|
|
* occupy the last three of sixty rather than being stretched across the
|
|
* whole width beneath a label reading "-60 min".
|
|
*
|
|
* That stretch was the behaviour until 2026-08-25, and it was not an
|
|
* edge case: this daemon keeps nothing across a restart by design, so
|
|
* every boot, every `uci commit appflow`, and every fresh install spent
|
|
* its first hour showing a chart that asserted an hour of history it did
|
|
* not have. lineChart already did this correctly for the same problem,
|
|
* which is what made it a defect rather than a decision. Omit `slots`
|
|
* and the old full-width behaviour is kept, for any caller that really
|
|
* is showing a complete series. */
|
|
var self = this,
|
|
W = 1000, H = 240,
|
|
n = Math.max(1, points.length),
|
|
slots = Math.max(n, opts.slots || 0),
|
|
peak = 0;
|
|
|
|
points.forEach(function(p) {
|
|
if (p.dl + p.ul > peak)
|
|
peak = p.dl + p.ul;
|
|
});
|
|
|
|
peak = this.niceMax(peak * 1.05);
|
|
|
|
var kids = this.gridLines(W, H),
|
|
slot = W / slots,
|
|
bw = slot * 0.6,
|
|
pad = (slot - bw) / 2,
|
|
off = (slots - n) * slot; /* right-align a partial series */
|
|
|
|
points.forEach(function(p, i) {
|
|
var hdl = H * Math.max(0, Math.min(1, p.dl / peak)),
|
|
hul = H * Math.max(0, Math.min(1, p.ul / peak)),
|
|
x = off + i * slot + pad,
|
|
when = p.t
|
|
? new Date(p.t < 1e11 ? p.t * 1000 : p.t)
|
|
.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
: '',
|
|
tip = '%s\n%s'.format(when,
|
|
_('Download: %s, upload: %s')
|
|
.format(self.fmtBytes(p.dl), self.fmtBytes(p.ul)));
|
|
|
|
if (hdl > 0.4)
|
|
kids.push(self.svg('rect', {
|
|
'class': 'af-col af-dl',
|
|
'x': x.toFixed(2), 'y': (H - hdl).toFixed(2),
|
|
'width': bw.toFixed(2), 'height': hdl.toFixed(2)
|
|
}, [ self.svg('title', {}, [ tip ]) ]));
|
|
|
|
if (hul > 0.4)
|
|
kids.push(self.svg('rect', {
|
|
'class': 'af-col af-ul',
|
|
'x': x.toFixed(2), 'y': (H - hdl - hul).toFixed(2),
|
|
'width': bw.toFixed(2), 'height': hul.toFixed(2)
|
|
}, [ self.svg('title', {}, [ tip ]) ]));
|
|
});
|
|
|
|
return E('div', { 'class': 'af-chart' }, [
|
|
E('div', {
|
|
'class': 'af-plot',
|
|
'style': 'height:%dpx'.format(opts.height || 150)
|
|
}, [
|
|
this.svg('svg', {
|
|
'viewBox': '0 0 %d %d'.format(W, H),
|
|
'preserveAspectRatio': 'none',
|
|
'role': 'img',
|
|
'aria-label': opts.label || _('Traffic per interval')
|
|
}, kids),
|
|
this.yAxis(peak, function(v) { return self.fmtBytes(v); })
|
|
]),
|
|
this.xAxis(opts.xlabels)
|
|
]);
|
|
},
|
|
|
|
/*
|
|
* Doughnut. Pure SVG using stroke-dasharray on an r=15.91549 circle
|
|
* (circumference 100), so each slice length is literally its percentage.
|
|
*/
|
|
/* ------------------------------------------------------------ filter */
|
|
|
|
/* Parse a filter string into terms.
|
|
*
|
|
* Space separated, and EVERY term must pass, so terms narrow rather than
|
|
* widen. A leading minus excludes:
|
|
*
|
|
* ai only rows matching "ai"
|
|
* -netflix everything except Netflix
|
|
* ai -claude AI rows that are not Claude
|
|
*
|
|
* Exclusion exists because the question that prompted this was "one
|
|
* streaming session is burying everything else", and an include-only
|
|
* filter cannot express it: you would have to know in advance what you
|
|
* wanted to keep.
|
|
*
|
|
* A bare "-" is dropped rather than treated as excluding everything, which
|
|
* is what a half-typed term looks like.
|
|
*/
|
|
parseFilter: function(text) {
|
|
return String(text == null ? '' : text).toLowerCase()
|
|
.split(/\s+/)
|
|
.filter(function(t) { return t.length > 0; })
|
|
.map(function(t) {
|
|
return (t.charAt(0) === '-')
|
|
? { neg: true, term: t.slice(1) }
|
|
: { neg: false, term: t };
|
|
})
|
|
.filter(function(t) { return t.term.length > 0; });
|
|
},
|
|
|
|
/* Does a row pass? `fields` are the strings the user could reasonably be
|
|
* typing at: an application label, its key, its category.
|
|
*
|
|
* An empty term list passes everything, which is the unfiltered case and
|
|
* must not be special-cased anywhere else. */
|
|
matchFilter: function(terms, fields) {
|
|
var hay = (fields || [])
|
|
.filter(function(f) { return f != null && f !== ''; })
|
|
.join(' ').toLowerCase();
|
|
|
|
return (terms || []).every(function(t) {
|
|
var hit = hay.indexOf(t.term) >= 0;
|
|
|
|
return t.neg ? !hit : hit;
|
|
});
|
|
},
|
|
|
|
donut: function(slices, opts) {
|
|
opts = opts || {};
|
|
|
|
var self = this,
|
|
total = slices.reduce(function(a, s) { return a + Math.max(0, s.value); }, 0),
|
|
/* An SVG circle's stroke starts at 3 o'clock, so a dashoffset of a
|
|
* quarter of the circumference (r is 15.91549, so C is ~100) starts
|
|
* the first slice at 12 o'clock. This is the ONLY rotation now:
|
|
* `.af-donut > svg` also carried transform:rotate(-90deg) until
|
|
* 2026-08-25, the two stacked, and the first slice actually began at
|
|
* 9 o'clock -- so this comment asserted something the render did not
|
|
* do. Found by a code-review panel. If you re-add a CSS rotation,
|
|
* take this offset out. */
|
|
off = 25,
|
|
ring = [ this.svg('circle', {
|
|
'class': 'af-donut-track',
|
|
'cx': 21, 'cy': 21, 'r': 15.91549,
|
|
'fill': 'none', 'stroke-width': 5.2
|
|
}) ];
|
|
|
|
if (total > 0)
|
|
slices.forEach(function(s) {
|
|
var pct = 100 * Math.max(0, s.value) / total;
|
|
|
|
if (pct <= 0)
|
|
return;
|
|
|
|
ring.push(self.svg('circle', {
|
|
'cx': 21, 'cy': 21, 'r': 15.91549,
|
|
'fill': 'none',
|
|
'stroke': s.color,
|
|
'stroke-width': 5.2,
|
|
'stroke-dasharray': '%.3f %.3f'.format(pct, 100 - pct),
|
|
'stroke-dashoffset': off.toFixed(3)
|
|
}, [ self.svg('title', {}, [ '%s: %s (%s)'.format(s.label,
|
|
self.fmtBytes(s.value), self.fmtPercent(s.value, total)) ]) ]));
|
|
|
|
off -= pct;
|
|
});
|
|
|
|
/* The legend is the category list, and when the caller supplies
|
|
* opts.onPick it is also how you drill into one. Clicking a category
|
|
* was the gesture a user reached for first and it did nothing, which
|
|
* is the whole reason this argument exists. */
|
|
var legend = slices.map(function(s) {
|
|
var attrs = { 'class': 'af-legend-row' };
|
|
|
|
if (typeof opts.onPick === 'function' && s.pick != null) {
|
|
attrs['class'] += ' af-pick';
|
|
attrs.tabindex = '0';
|
|
attrs.role = 'button';
|
|
attrs.title = _('Show only %s').format(s.label);
|
|
attrs.click = function(ev) { ev.preventDefault(); opts.onPick(s.pick); };
|
|
/* Keyboard parity: a click target that only responds to a mouse
|
|
* is not a control, it is a trap for anyone using a keyboard. */
|
|
attrs.keydown = function(ev) {
|
|
if (ev.key === 'Enter' || ev.key === ' ') {
|
|
ev.preventDefault();
|
|
opts.onPick(s.pick);
|
|
}
|
|
};
|
|
}
|
|
|
|
return E('li', attrs, [
|
|
E('span', { 'class': 'af-dot', 'style': 'background:' + s.color }),
|
|
E('span', { 'class': 'af-legend-lbl', 'title': s.label }, [ s.label ]),
|
|
E('span', { 'class': 'af-legend-val af-num' }, [
|
|
self.fmtPercent(s.value, total)
|
|
])
|
|
]);
|
|
});
|
|
|
|
return E('div', { 'class': 'af-donut-wrap' }, [
|
|
E('div', { 'class': 'af-donut' }, [
|
|
this.svg('svg', { 'viewBox': '0 0 42 42', 'role': 'img',
|
|
'aria-label': _('Share of traffic by category') }, ring),
|
|
E('div', { 'class': 'af-donut-mid' }, [
|
|
E('strong', {}, [ opts.center || this.fmtBytes(total) ]),
|
|
E('span', {}, [ opts.centerLabel || _('total') ])
|
|
])
|
|
]),
|
|
E('ul', { 'class': 'af-legend' }, legend.length ? legend
|
|
: [ E('li', { 'class': 'af-sub' }, [ _('No data') ]) ])
|
|
]);
|
|
},
|
|
|
|
/* Download/upload key shown under the charts. */
|
|
swatches: function(dlText, ulText) {
|
|
return E('div', { 'class': 'af-key' }, [
|
|
E('span', {}, [
|
|
E('i', { 'class': 'af-dot af-dl' }),
|
|
E('span', { 'class': 'af-sub' }, [ _('Download') ]),
|
|
E('strong', { 'class': 'af-num' }, [ dlText ])
|
|
]),
|
|
E('span', {}, [
|
|
E('i', { 'class': 'af-dot af-ul' }),
|
|
E('span', { 'class': 'af-sub' }, [ _('Upload') ]),
|
|
E('strong', { 'class': 'af-num' }, [ ulText ])
|
|
])
|
|
]);
|
|
},
|
|
|
|
/* ------------------------------------------------------- empty states */
|
|
|
|
/* Classify an rpc.declare(reject:true) rejection. ubus status 6 is permission
|
|
* denied, which means appflowd IS up and the LuCI session lacks the ACL, a
|
|
* different problem with a different fix than a stopped daemon (status 4,
|
|
* object not found). LuCI encodes the rejection differently across versions
|
|
* (a bare number, an Error, or a status string), so probe all three. */
|
|
classifyFail: function(err) {
|
|
if (err === 6 || err === '6')
|
|
return 'denied';
|
|
var s = (err && typeof err === 'object') ? (err.message || '') : String(err == null ? '' : err);
|
|
return (/\b6\b/.test(s) || /permission|denied|access/i.test(s)) ? 'denied' : 'down';
|
|
},
|
|
|
|
notRunning: function(kind, detail) {
|
|
var denied = (kind === 'denied');
|
|
|
|
return E('div', { 'class': 'cbi-section af-card af-empty' }, [
|
|
/* Decorative: the <h3> directly below says the same thing in words,
|
|
* so this triangle is hidden from assistive tech rather than given
|
|
* a name that would be read out twice. */
|
|
this.svg('svg', { 'viewBox': '0 0 24 24', 'aria-hidden': 'true' }, [
|
|
this.svg('path', {
|
|
'd': 'M12 2 1 21h22L12 2zm0 5 7.5 12.9h-15L12 7zm-1 4v5h2v-5h-2zm0 6v2h2v-2h-2z'
|
|
})
|
|
]),
|
|
E('h3', {}, [ denied ? _('AppFlow: access denied') : _('appflowd is not running') ]),
|
|
E('p', {}, [ denied
|
|
? _('appflowd is running, but this LuCI session was refused access to it over ubus (permission denied). This is an ACL problem, not a stopped service: confirm the luci-app-appflow ACL is installed and reload rpcd.')
|
|
: _('The AppFlow collector could not be reached over ubus, so there is no traffic data to show. Start the service and this page picks it up automatically.')
|
|
]),
|
|
E('pre', { 'class': 'af-cmd' }, [ denied
|
|
? '/etc/init.d/rpcd restart'
|
|
: '/etc/init.d/appflowd enable\n/etc/init.d/appflowd start'
|
|
]),
|
|
denied ? '' : E('p', { 'class': 'af-sub' }, [
|
|
_('AppFlow also needs the Netify Agent running (%s), since that is where the per-application classification comes from.').format('/etc/init.d/netifyd status')
|
|
]),
|
|
detail ? E('p', { 'class': 'af-sub af-mono' }, [ String(detail) ]) : ''
|
|
]);
|
|
},
|
|
|
|
pill: function(text, cls) {
|
|
return E('span', { 'class': 'af-pill' + (cls ? ' ' + cls : '') }, [
|
|
cls ? E('i', {}) : '', text
|
|
]);
|
|
},
|
|
|
|
/*
|
|
* Thin status strip above the cards. `status` is either the full
|
|
* appflow.status payload (Overview) or any response envelope, which always
|
|
* carries generated_at + agent_connected per §5.
|
|
*/
|
|
statusStrip: function(status, extra) {
|
|
var s = status || {},
|
|
sock = s.socket || {},
|
|
flows = s.flows || {},
|
|
counts = s.aggregates || s.counts || {},
|
|
agent = s.agent || {},
|
|
/* TRI-STATE, deliberately. This used to be
|
|
* connected = (s.agent_connected !== false) && (sock.connected !== false)
|
|
* which reads an ABSENT field as healthy, because `undefined !== false`
|
|
* is true. Overview passes `status || summary`, so whenever the status
|
|
* call fails this function is handed the summary payload instead, that
|
|
* payload carries neither `agent_connected` nor `socket.connected`, and
|
|
* the strip rendered a single green "Netify agent connected" pill.
|
|
*
|
|
* It failed GREEN on the one indicator whose whole job is to tell the
|
|
* operator the collector is down, which is also the most common reason
|
|
* the page is empty. Absent evidence is not positive evidence; unknown
|
|
* is its own state and is drawn neutral. Found by a code-review panel,
|
|
* 2026-08-25. */
|
|
known = (s.agent_connected != null) || (sock.connected != null),
|
|
connected = known && (s.agent_connected !== false) && (sock.connected !== false),
|
|
bits = [ known
|
|
? this.pill(connected
|
|
? _('Netify agent connected')
|
|
: _('Netify agent disconnected'), connected ? 'af-ok' : 'af-warn')
|
|
: this.pill(_('Collector state unknown'), 'af-unknown') ];
|
|
|
|
if (flows.tracked != null)
|
|
bits.push(this.pill(_('Flows tracked: %d').format(this.num(flows, [ 'tracked' ]))));
|
|
|
|
if (counts.apps != null || counts.devices != null)
|
|
bits.push(this.pill(_('%d apps, %d devices').format(
|
|
this.num(counts, [ 'apps' ]), this.num(counts, [ 'devices' ]))));
|
|
|
|
if (s.uptime != null)
|
|
bits.push(this.pill(_('Collector uptime: %s').format(this.fmtUptime(s.uptime))));
|
|
|
|
if (agent.version)
|
|
bits.push(this.pill(_('netifyd %s').format(String(agent.version))));
|
|
|
|
(extra || []).forEach(function(e) {
|
|
if (e)
|
|
bits.push(e);
|
|
});
|
|
|
|
return E('div', { 'class': 'af-strip' }, bits);
|
|
},
|
|
|
|
/* ---------------------------------------------------------------- css */
|
|
|
|
injectCSS: function() {
|
|
if (document.getElementById(STYLE_ID))
|
|
return;
|
|
|
|
document.head.appendChild(E('style', {
|
|
'id': STYLE_ID,
|
|
'type': 'text/css'
|
|
}, [ CSS ]));
|
|
}
|
|
});
|