Files
op-packages/luci-app-overview-manager/docs/ARCHITECTURE.md
T

10 KiB

Implementation notes

Load-bearing facts about LuCI and OpenWrt that this package depends on, why the code is shaped the way it is, and how to re-verify each claim. None of it is derivable from this repository alone: the upstream behaviour lives in luci-base, luci-mod-status and the active theme.

Upstream references are paths inside openwrt/luci.

The Overview page rebuilds itself every poll cycle

luci-mod-status/htdocs/luci-static/resources/view/status/index.js builds one .cbi-section per include once, in render(), and then on every poll cycle calls each include's load() and render() again and replaces the section content through dom.content. The interval is luci.main.pollinterval, default 5 seconds.

Two consequences drive the design of luci/overview.js:

  1. Never move a section unless the order actually changed. Re-appending a node re-inserts it, and re-insertion restarts its CSS animations. The theme puts fade-in on every revealed section — .fade-in { animation: fade-in .4s ease } with opacity 0 → 1 in themes/luci-theme-bootstrap/htdocs/luci-static/bootstrap/cascade.css — so re-appending all sections on each cycle makes the whole page flash every five seconds. applyLayout() therefore compares the wanted order against the live DOM order and touches nothing when they match.

  2. Never clear a visibility class and re-add it. Dropping overview-manager-hidden from every section and then re-adding it exposes every hidden widget for a frame. Use classList.toggle(name, force), which only mutates when the state really changes.

scripts/test-overview-ui.js pins both: it counts appendChild calls and class mutations across a second identical render and fails if either is non-zero. Reverting either property makes it fail, which is worth re-checking if the test is ever refactored.

dom.content (in luci.js) removes all children and appends the new ones in one synchronous task, so replacing our own container each cycle cannot paint an intermediate state. Only node moves and class churn are visible.

load() caches its result for the lifetime of the page. The saved layout cannot change while the Overview page stays open, and the alternative is two helper process spawns every five seconds forever. A failed read is deliberately not cached, so a transient rpcd error retries on the next cycle instead of pinning an empty widget list for the session.

Widget-to-section mapping is positional and order-sensitive

There is no attribute tying a rendered section to the include that produced it, so the mapping is by index: the n-th .cbi-section belongs to the n-th include. data-overview-widget is stamped on first use and reused afterwards.

index.js requires its includes as view.status.include.<name> and sorts those module names, so the .js suffix takes no part in the ordering. A file-name sort is not the same sort: - is 0x2D and . is 0x2E, so plain file names order 20_x-y.js before 20_x.js, while module names order 20_x before 20_x-y. One such pair shifts every later widget onto the wrong section, and the visible effect is that the wrong card is hidden or moved.

common.compareWidgets exists for exactly this and must stay code-unit based. localeCompare would reorder per browser locale and reintroduce the bug in a locale-dependent way.

Two further constraints follow from the same mechanism:

  • The include count must equal the section count, or applyLayout() gives up after eight retries and silently does nothing. index.js filters fs.list entries by type == 'file', so an include installed as a symlink would be skipped there while the shell helper's [ -f ] follows it and counts it. Nothing in the tree creates such a symlink today; if the layout ever stops applying on a specific device, check this first.
  • title must be non-empty and stable. index.js uses the include title as the localStorage key for its own per-browser Hide button (includes[i].id = title), so an empty title claims the empty key and collides with any other untitled include. It is deliberately not wrapped in _(): a translated title would change that key whenever the interface language changes.

Client-side translation

The chain is not obvious and is easy to get subtly wrong.

  • The global _() is defined in luci-base/htdocs/luci-static/resources/cbi.js, not in luci.js. It is TR[sfh(trimws(s))] || s.
  • window.TR is emitted by admin/translations/<lang> (luci-base/ucode/controller/admin/index.uc), which the theme header loads on every page, including the login page. It is a flat map of eight-hex-digit hashes to strings, merged from every *.<lang>.lmo in /usr/lib/lua/luci/i18n.
  • The lookup key is the hash of the whitespace-normalised msgid: trimws in cbi.js trims and collapses runs of space, tab and newline. po2lmo hashes the raw msgid from the catalog. Therefore msgids must already be normalised — one line, single spaces, no leading or trailing whitespace — or the runtime lookup misses and the string silently stays English.
  • Because all catalogs share one namespace in an unspecified merge order, a string that luci-base also translates must either be left out or given the identical translation. Show, Move up and Move down are in that category and use the exact luci-base wording so the collision cannot matter.

scripts/test-po2lmo.py enforces the normalisation rule, the catalog-to-source correspondence in both directions, and round-trips every translation through the real hash and a minimal LMO reader.

Registering the language is mandatory

Shipping a catalog is not enough. determine_request_language() in luci-base/ucode/dispatcher.uc only accepts a language that exists in luci.languages; with luci.main.lang=auto and no matching entry it falls through to en. A router with no luci-i18n-* package installed has an empty luci.languages, so the catalog would never be selected.

openwrt/files/etc/uci-defaults/luci-app-overview-manager adds the entry, the same way luci.mk generates it for stock luci-i18n-* packages. Because this package defines its own postinst, the default handler that drains /etc/uci-defaults does not run, so postinst invokes the script itself and removes it on success; otherwise the language would only appear after the next reboot.

Verify on a device:

wget -qO- 'http://127.0.0.1/cgi-bin/luci/admin/translations/ru' | head -c 400
uci show luci.languages

and that content negotiation resolves as expected:

curl -s -H 'Accept-Language: ru' http://<router>/cgi-bin/luci/ | grep -o '<html lang="[^"]*"'

scripts/po2lmo.py must stay byte-compatible

It reimplements luci-base/src/po2lmo.c. Format details that are easy to lose: values are written first, each padded to a four-byte boundary; the index of 16-byte big-endian records follows, sorted by key hash; the file ends with the total size of the value blob. The val_id field does not hold a hash, it holds the plural count. Entries whose key and value hash identically are omitted so the reader falls back to the source string. extract_string unescapes only \" and \\ — \n stays two literal characters, which is what makes the header's Plural-Forms parsing work. An empty catalog produces no file at all.

To re-verify after any change, build the reference tool and diff its output. The reader needs a generated parser header that is not required for writing, so an empty stub is enough:

git clone --depth 1 --filter=blob:none --no-checkout https://github.com/openwrt/luci
cd luci && git sparse-checkout set modules/luci-base && git checkout master
cd .. && mkdir -p oracle/lib
cp luci/modules/luci-base/src/po2lmo.c oracle/
sed -n '1,72p' luci/modules/luci-base/src/lib/lmo.c > oracle/lib/sfh.c
cp luci/modules/luci-base/src/lib/lmo.h oracle/lib/
: > oracle/lib/plural_formula.h
cc -O1 -Ioracle/lib -o oracle/po2lmo oracle/po2lmo.c oracle/lib/sfh.c

PO2LMO_REFERENCE=$PWD/oracle/po2lmo python3 scripts/test-po2lmo.py

The same binary can be diffed against every catalog in luci-base/po/*/*.po as a broader check; all of them currently match byte for byte.

Static resources are cached without a cache-buster

L.require builds module URLs as <base>/<path>.js plus ?v=<env.resource_version> only when that field is set, and the OpenWrt 25.12 page template does not set it — it versions luci.js alone. uhttpd serves the files with ETag and Last-Modified but no Cache-Control, so browsers apply heuristic freshness and can reuse a stale copy for a long time.

Practical effect: after changing anything under /www/luci-static/resources/, a normal reload may keep running the old code. A reload that bypasses the cache is required — in Safari that is Cmd+Option+R ("Reload Page From Origin"); Cmd+Shift+R does nothing there. Confirm which code is live by reading the section titles, since a stale include renders its section heading differently:

[...document.querySelectorAll('.cbi-section')].map(s => s.querySelector('h3')?.textContent.trim())

Shell that ships to the router runs under BusyBox

The test suite runs on GNU coreutils, which silently accepts constructs a stock OpenWrt image does not have: sort -o, base64, grep -P and find -printf among them. scripts/check-busybox-compat.sh scans the staged package tree rather than the sources, so the checked set cannot drift from what is installed; it also runs busybox ash -n on each script when a busybox binary is present, which no pattern list can replace.

Four files reach the router: CONTROL/postinst, CONTROL/prerm, etc/uci-defaults/luci-app-overview-manager and usr/libexec/overview-manager.

Working against a real device

  • OpenWrt has no sftp-server, so scp fails. Stream instead: ssh <host> 'cat > /path' < localfile.

  • apk-tools on the device has no mkpkg, and OpenWrt 25.12 cannot install an .ipk. Building a package needs the SDK; for a quick check, deploy the payload files and compare checksums.

  • ACL changes need /etc/init.d/rpcd reload, which drops active LuCI sessions.

  • Name the package in every transaction. A blanket upgrade is never appropriate on a live router; scripts/check-apk-trust.sh fails the build if one appears in a script or in the documentation.

  • Verify a published package with the device's own apk and the tracked public key, and confirm it is rejected without it:

    apk verify --keys-dir <dir-with-public-key> <package>.apk