10 KiB
Contributing to luci-app-appflow
Contributions are welcome, including bug reports, and especially the two hardware tests listed under Where help is most useful below.
Reporting a problem
Useful reports include:
- Device and architecture, OpenWrt release, and
netifydversion (apk info netifydoropkg info netifyd). - What the dashboard showed versus what you expected.
logread -e appflowdoutput covering the period.ubus call appflow status, which reports the daemon's own counters. The useful ones areflows.seen,flows.late,flows.shed,flows.purged,flows.no_total,bytes.leakedandaggregates.refused; between them they distinguish most classes of problem immediately. Do not readflows.dropped: it is a last-resort backstop that no input can move, because the cap-pressure prune always frees space before it is reached.flows.shedis the counter that says the flow table is too small.
If the complaint is about where bytes were attributed, read docs/DESIGN.md 2.5 first. Some attribution behaviour is measured, understood and documented rather than accidental, and knowing which case you are looking at saves everyone time.
Building
This repository is a single LuCI package, with its Makefile at the root and
icons/ as a second package beside it. That is the same layout upstream
openwrt/luci uses under applications/, and it drops straight into an
OpenWrt SDK tree's package/ directory, which the build system scans
recursively.
It is not a feed. A feed is a directory containing package directories,
and adding this repo directly with src-link produces an empty feed index.
The CI workflow stages the packages into a temporary feed directory for
exactly this reason; see .github/workflows/build.yml.
# from an OpenWrt SDK tree matching your target release
./scripts/feeds update -a
./scripts/feeds install -a
git clone https://github.com/VolanticSystems/luci-app-appflow.git \
package/luci-app-appflow
make package/luci-app-appflow/compile V=s
make package/luci-app-appflow-icons/compile V=s
CI builds both packages against the OpenWrt SDK on every push and pull request, so a build break shows up without anyone having to reproduce it locally.
Testing a change on a device
The daemon is a single ucode script and the views are plain JS, so the edit/test loop does not require a rebuild:
# syntax-check the daemon before installing it
ucode -c /usr/sbin/appflowd
# after replacing a view under /www/luci-static/resources/view/appflow/
rm -f /tmp/luci-indexcache.*
/etc/init.d/rpcd restart && /etc/init.d/uhttpd restart
Please confirm a change on real hardware before sending it. This package talks to a DPI engine whose behaviour is version-specific, and several plausible-looking assumptions about netifyd have already turned out to be wrong when measured (documented in DESIGN 2.4 and 2.5).
Then run the suites. The JavaScript one needs only Node and runs anywhere; the two shell suites need a sandbox router and must be run one at a time.
node tests/frontend-suite.js # 59 checks, no router needed
sh tests/protocol-suite.sh # 104 checks, sandbox router, no traffic
sh tests/hardware-suite.sh # 15 checks, sandbox router, real traffic
protocol-suite.sh takes a group name: conservation, protocol, bounds,
attribution, hostile, lifecycle, acl, ai, hostmap, drilldown, or all
(the default).
hardware-suite.sh skips its wire-ratio check when netifyd does not
capture every default-route interface, and says which one. That check
compares the test client's own veth counter against what appflow counted,
so it silently assumes netifyd watches the path the traffic takes. On a
router with a second uplink or a VPN outside netifyd's capture list the
ratio drops and reads exactly like an attribution defect. It is not one,
and it cost real time on 2026-08-27 before the precondition was made
explicit.
frontend-suite.js loads the REAL common.js under Node through
tests/luci-module.js, a small vm.Script loader that supplies LuCI's
runtime (_(), E(), uci) and nothing else. No logic from the product is
copied into it. Every check in its first three groups is a regression test for
a defect that was real, shipped and fixed: a status pill that read "connected"
from a payload carrying no evidence either way, a colour lookup that a device
named constructor could turn into undefined, and a normaliser that was not
idempotent. All three were found by people reading the code, because until
2026-08-27 no test could run any of it.
protocol-suite.sh repoints appflow.socket_path at a socket it controls and
feeds the real daemon a chosen event stream, so the byte arithmetic is checked
against hand-computed totals rather than a tolerance band, and the error paths
a real agent never produces get exercised. hardware-suite.sh drives real
traffic and compares against the client interface's own counter in
/sys/class/net, which nothing in this daemon can influence. Neither replaces
the other.
Both restore what they change on exit, including on interrupt and on a dropped SSH session, and both take an exclusive lock: they mutate global router state and running two at once corrupts each other's fixtures.
If you add a check, write the sabotage first. Every check in these files
carries a SABOTAGE: comment naming the smallest edit to the product that
turns it red, and the comment was written before the assertion. That ordering
is the point: "what does this test check" answers itself in the author's own
words, which is how a test that cannot fail gets written and then read a dozen
times without anyone noticing. If you cannot construct a red state, you have
found something more interesting than a test.
Code style
Match the surrounding code. Specifically:
- ucode, not shell, for daemon logic. Note ucode's integer division and the requirement to declare functions before use.
- No build step for the frontend. The views are hand-written JS using
LuCI's own
form,ui,rpc,pollanddommodules, and the charts are hand-drawn inline SVG. Please do not introduce a bundler, a framework, or a charting dependency. - Keep
appflowd's accounting funnelled through the singleaccount()choke point. Byte conservation is a verified property of this package and it is easy to break by accounting somewhere else. - SPDX headers on new files, Apache-2.0.
Commits
Prefix the subject with the package name and keep it lowercase after the colon, matching OpenWrt convention:
luci-app-appflow: fix device table sort on equal byte counts
If you would like the change to be portable upstream to openwrt/luci later,
add a Signed-off-by: line with your real name, which is what upstream's DCO
requires.
Translations
The string catalogue lives in po/templates/appflow.pot. It is generated from
the source, not written by hand: every _('...') call in
htdocs/luci-static/resources/view/appflow/*.js, plus the title and
description values in root/usr/share/luci/menu.d/*.json and
root/usr/share/rpcd/acl.d/*.json. That file selection and those keywords are
LuCI's, from build/i18n-scan.pl in the openwrt/luci tree.
To add a language, create po/<lang>/appflow.po from the template and
translate the msgstr lines. Nothing in Makefile needs to change.
luci.mk discovers languages by globbing po/*:
LUCI_LANGUAGES := $(sort $(filter-out templates,$(notdir $(wildcard ${CURDIR}/po/*))))
and generates a luci-i18n-appflow-<lang> package for each one, so a new
directory is the whole of the work. The one-package-per-language split is
deliberate upstream policy, not an oversight: a router with 8 MB of flash
should not carry forty translations to use one. A combined "all languages"
package was proposed and
rejected, because translations
depend on applications rather than the reverse and no package metadata can
express that.
Two things worth knowing before you start:
- Keep the
%sand%dspecifiers, in a working order. Several strings interpolate values, and a catalogue that reorders or drops one produces a broken sentence at best. - Category names are not in the template, and that is not a bug. They
arrive at runtime from netifyd's own
netify-categories.json, so they exist in neither the JavaScript nor the daemon as literals and no scanner can find them. Translating them needs a source-side change first; open an issue rather than adding orphanmsgidentries, which the next regeneration would delete.
If this package is ever accepted into openwrt/luci, translations arrive
through Hosted Weblate instead
and this section stops being the interesting path.
Where help is most useful
Two open items need hardware the author does not have. Both are documented in docs/DESIGN.md 8, with the exact observation that would settle them:
- NAT dual-capture attribution. Needs a real client routed through the
device so netifyd produces both an internal (
br-lan) and an external (wan) capture of the same flow. The deciding question is what netifyd 4.4.7 puts inlocal_mac/other_macwhenip_natis true, which determines whether the current rule double-counts or drops. - Late re-point stranding. If netifyd first reports a flow unclassified
and identifies it only later, bytes counted before that point stay under
"Unknown". This has not been observed in the wild and its magnitude is
unmeasured. A socket capture correlating per-digest
flow/flow_stats/ detection-update ordering would settle it.
Reports of either, even without a patch, are genuinely valuable.
A note for translators: argument order
LuCI's format() substitutes positionally in order, so a translation that needs
the arguments in a different order than English must say so explicitly. Use
%1$d, %2$s and so on rather than bare %d/%s:
msgid "%d of %d applications" call site passes (shown, total)
wrong "共 %d 个应用中的 %d 个" renders 143 of a total of 7
right "共 %2$d 个应用中的 %1$d 个"
This class of bug passes every check either side can run. Argument order is a property of the relationship between the string and its call site, and the call site is in another file, so a specifier count matches, a markup sweep finds nothing, and the build is green. Found in the zh_Hans catalogue in PR #1, where it turned "7 of 143" into "143 of a total of 7".