Files
op-packages/luci-app-appflow/CONTRIBUTING.md
T

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 netifyd version (apk info netifyd or opkg info netifyd).
  • What the dashboard showed versus what you expected.
  • logread -e appflowd output covering the period.
  • ubus call appflow status, which reports the daemon's own counters. The useful ones are flows.seen, flows.late, flows.shed, flows.purged, flows.no_total, bytes.leaked and aggregates.refused; between them they distinguish most classes of problem immediately. Do not read flows.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.shed is 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, poll and dom modules, 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 single account() 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 %s and %d specifiers, 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 orphan msgid entries, 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:

  1. 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 in local_mac / other_mac when ip_nat is true, which determines whether the current rule double-counts or drops.
  2. 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".