fix(dashboard): surface WAN global IPv6 instead of link-local fe80:: (#1128)#1139
Conversation
…#1128) The WAN Network Status widget displays ipv6Addresses.first. TR-181 returns Device.IP.Interface.2.IPv6Address in instance order, where instance 1 is the link-local fe80::/10 address, so the dashboard showed a non-routable fe80:: address instead of the global unicast WAN IPv6. Add lib/core/utils/ipv6_address.dart to classify IPv6 scope (global / ULA / link-local / other) and reorder addresses so global unicast surfaces first (stable within scope, preserving instance order as tie-breaker). Apply in UspWanDataService so ipv6Addresses.first is the routable address. Refs #1128
AustinChangLinksys
left a comment
There was a problem hiding this comment.
🤖 Automated Review — Round 1 · f2af7bb..bbcd8c0 (full)
Verdict: 💬 COMMENT — GitHub API rejected APPROVE (same author account on agent-team PR). No Critical issues found; APPROVE intent. Please review Warnings before merging.
| Conf. | Where | Issue (one-liner) | |
|---|---|---|---|
| 🟢High | lib/core/utils/ipv6_address.dart:18–21 |
classifyIpv6Scope range logic duplicates IPv6WithReservedRule in rules.dart:273–276 (DRY violation) |
|
| 🟢High | lib/core/utils/ipv6_address.dart:44–72 |
_firstTwoBytes parsing logic duplicates _parseIpv6ToBytes in rules.dart:289–385 |
|
| 🟡Med | lib/core/utils/ipv6_address.dart:19 |
3FFE::/16 (6bone deprecated, RFC 3701) classified as global — inconsistent with rules.dart:261–263 |
|
| 🟢High | lib/core/utils/ipv6_address.dart:88 |
RegExp object re-allocated on every _firstTwoBytes call — Dart anti-pattern |
|
| 🟡Med | lib/core/utils/ipv6_address.dart:94–100 |
Single leading-colon input (e.g. :ffff::1) silently treated as first-hextet 0 → Ipv6Scope.other |
|
| 💡 | 🟢High | lib/core/utils/ipv6_address.dart:72–80 |
Sort comparator calls classifyIpv6Scope redundantly per comparison pair |
| 💡 | 🟢High | lib/core/utils/ipv6_address.dart:26–27 |
isGlobalUnicastIpv6 has no callers — consider @visibleForTesting or private |
| 💡 | 🟢High | lib/core/utils/ipv6_address.dart:1–27 |
Public APIs lack dartdoc and RFC references (RFC 4291, RFC 4193) |
| 💡 | 🟢High | test/page/internet_settings/services/usp_wan_data_service_test.dart |
Missing "all link-local, no global" edge case integration test |
| 💡 | 🟡Med | lib/page/internet_settings/services/usp_wan_data_service.dart:100–102 |
Catch-all hides USP _Error error type distinction (pre-existing) |
| 💡 | 🟡Med | test/core/utils/ipv6_address_test.dart:43–48 |
Missing fec0:: (deprecated site-local) and :: (unspecified) boundary tests |
Confidence: 🟢High = code-verified · 🟡Med = located + reasoned, not fully confirmed · ⚪Low = speculative, please double-check.
Items marked [both reviewers] were independently flagged by two agents → higher confidence.
⚠️ Warning Details
W-1 — DRY violation: classifyIpv6Scope range logic duplicates IPv6WithReservedRule (Confidence: 🟢High)
lib/core/utils/ipv6_address.dart:18–21:
if (firstByte >= 0x20 && firstByte <= 0x3F) return Ipv6Scope.global;
if (firstByte == 0xFE && (secondByte & 0xC0) == 0x80) return Ipv6Scope.linkLocal;
if (firstByte == 0xFC || firstByte == 0xFD) return Ipv6Scope.uniqueLocal;These three lines are verbatim identical to lib/validator_rules/rules.dart:273–276 inside IPv6WithReservedRule. Any future RFC update requires patching both files independently.
Fix: Extract the scope classification into a shared static helper in rules.dart (or a new core/utils/ module), then call it from both sites.
W-2 — DRY violation: _firstTwoBytes parsing logic duplicates _parseIpv6ToBytes (Confidence: 🟢High)
lib/core/utils/ipv6_address.dart:44–72 vs lib/validator_rules/rules.dart:289–385: Both implement (1) multi-:: detection, (2) [0-9a-fA-F:] char-set validation, (3) :: prefix handling. The regex literal r'^[0-9a-fA-F:]+$' at ipv6_address.dart:50 is character-for-character identical to rules.dart:294.
Fix: Refactor to share a _parseIpv6FirstHextet(String) → int? static utility.
W-3 — 3FFE::/16 (6bone deprecated) classified as Ipv6Scope.global (Confidence: 🟡Med)
lib/core/utils/ipv6_address.dart:19:
if (firstByte >= 0x20 && firstByte <= 0x3F) return Ipv6Scope.global;3FFE:: has firstByte = 0x3F, secondByte = 0xFE, falls into this range → classified as global. However lib/validator_rules/rules.dart:261–263 explicitly excludes 3FFE::/16 (RFC 3701 deprecated 6bone). If a router firmware returns 3FFE:: addresses, preferGlobalIpv6First would rank them as best-available.
Fix: Add before the global check:
if (firstByte == 0x3F && secondByte == 0xFE) return Ipv6Scope.other; // 6bone deprecated (RFC 3701)W-4 — RegExp object re-allocated on every _firstTwoBytes call (Confidence: 🟢High)
lib/core/utils/ipv6_address.dart:88:
if (!RegExp(r'^[0-9a-fA-F:]+$').hasMatch(s)) return null;Fix: Hoist to a top-level constant: final _ipv6CharsRegExp = RegExp(r'^[0-9a-fA-F:]+$');
W-5 — Single leading-colon silently treated as hextet 0 (Confidence: 🟡Med)
lib/core/utils/ipv6_address.dart:94–100: Input :ffff::1 passes regex and :: checks, then colon=0 → substring(0,0)="" → silently becomes "0" → classified Ipv6Scope.other. Behaviour is safe (misformed USP response gets deprioritised) but hides protocol anomalies.
Fix: Add if (s.startsWith(':') && !s.startsWith('::')) return null; before the startsWith('::') branch.
✅ What looks good
- RFC correctness (High):
fe80::/10bitmask(secondByte & 0xC0) == 0x80is precise;febf::1boundary test confirms coverage. - Stable sort (High): Decorate-sort-undecorate pattern correctly maintains TR-181 instance order within each scope tier.
- Pure function (High):
classifyIpv6Scope/preferGlobalIpv6Firstare stateless, no I/O, no race-condition surface. - UI guard (High):
usp_network_status_card.darthasif (wan.ipv6Addresses.isNotEmpty)before.first— noRangeErrorrisk. - Zone ID & prefix-length stripping (High):
split('%').first.split('/').firsthandlesfe80::1%eth0and2401:e180::1/64; tests confirm. - Test coverage (High):
ipv6_address_test.dartcovers the real-world #1128 diagnostic-log case, boundary values, and stability.
Cross-reviewed by two independent agents (security+correctness / architecture+maintainability). Automated — please sanity-check before merge.
AustinChangLinksys
left a comment
There was a problem hiding this comment.
🤖 Automated Review — Round 1 · f2af7bb..bbcd8c0 (full)
Verdict: 💬 COMMENT — dispatch-chain PR; no Critical issues found, APPROVE intent. Posting as COMMENT because GitHub API blocks same-account self-approve.
| Conf. | Where | Issue (one-liner) | |
|---|---|---|---|
| High | lib/core/utils/ipv6_address.dart:1-55 vs lib/validator_rules/rules.dart:273-285 |
[both reviewers] DRY violation — 2000::/3, fe80::/10, fc00::/7 byte-range constants duplicated; 5F00::/8 divergence already present |
|
| High | lib/core/utils/ipv6_address.dart:50 |
3FFE::/16 (6bone deprecated, RFC 3701) classified as Ipv6Scope.global; rules.dart:261-263 explicitly excludes it |
|
| High | usp_lan_data_service.dart vs usp_wan_data_service.dart |
[both reviewers] Parallel LAN IPv6 path uses filter strategy (PR #1138) while WAN uses reorder — two diverging implementations for the same root cause | |
| Med | lib/core/utils/ipv6_address.dart:111-113 |
_firstTwoBytes accepts colon-free bare hex tokens (e.g. "2001") — silently misclassifies non-address input as IPv6 |
|
| 💡 | High | lib/core/utils/ipv6_address.dart:76-83 |
[both reviewers] classifyIpv6Scope called twice per comparator invocation — O(2n log n) classifications; pre-compute scopes before sorting |
| 💡 | High | test/page/internet_settings/services/usp_wan_data_service_test.dart |
Integration test only exercises the fallback Interface.2 path — no test covers reorder + Alias-resolved non-default instance |
| 💡 | Med | lib/core/utils/ipv6_address.dart vs lib/validator_rules/rules.dart:273 |
No cross-reference comment linking the two IPv6 classification sites |
| 💡 | Med | test/core/utils/ipv6_address_test.dart |
Missing IPv4-mapped (::ffff:203.0.113.1) and 6to4 (2002:cb00:7100::1) edge-case tests |
Confidence: High = code-verified · Med = located + reasoned, not fully confirmed · Low = speculative, please double-check.
Items marked [both reviewers] were independently flagged by two agents -> higher confidence.
⚠️ Warning Details
W-1 — [both reviewers] DRY violation: scope byte-range constants duplicated across two modules (Confidence: High)
lib/core/utils/ipv6_address.dart:50-57 (new) vs lib/validator_rules/rules.dart:273-276 (existing):
// ipv6_address.dart
if (firstByte >= 0x20 && firstByte <= 0x3F) return Ipv6Scope.global;
if (firstByte == 0xFE && (secondByte & 0xC0) == 0x80) return Ipv6Scope.linkLocal;
if (firstByte == 0xFC || firstByte == 0xFD) return Ipv6Scope.uniqueLocal;// rules.dart:273-276 — IPv6WithReservedRule.validate()
final isGlobalUnicast = firstByte >= 0x20 && firstByte <= 0x3F;
final isLinkLocal = firstByte == 0xFE && (rawAddress[1] & 0xC0) == 0x80;
final isULA = firstByte == 0xFC || firstByte == 0xFD;Both files independently hard-code the three identical byte-range constants. A concrete divergence already exists: rules.dart:267-269 excludes 5F00::/8 (deprecated) but ipv6_address.dart does not.
Fix: Delegate IPv6WithReservedRule's three scope checks to classifyIpv6Scope(). This is a valid import direction (validator layer -> core/utils). At minimum add a cross-reference comment.
W-2 — 3FFE::/16 (6bone deprecated) classified as Ipv6Scope.global (Confidence: High)
lib/core/utils/ipv6_address.dart:50:
if (firstByte >= 0x20 && firstByte <= 0x3F) return Ipv6Scope.global;3FFE:: has firstByte = 0x3F, secondByte = 0xFE — falls in 2000::/3 range — classified as global. But rules.dart:261-263 explicitly excludes 3FFE::/16 (RFC 3701 deprecated 6bone). If a router firmware returns a 3FFE:: address, preferGlobalIpv6First would rank it as best-available WAN address.
Fix: Add before the global check:
if (firstByte == 0x3F && secondByte == 0xFE) return Ipv6Scope.other; // 6bone deprecated (RFC 3701)W-3 — [both reviewers] Parallel LAN IPv6 path not updated; inconsistent strategy between sibling PRs (Confidence: High)
PR #1138 (gate/feat-1129, usp_lan_data_service.dart) uses a filter (drop link-local, never return it). This PR uses reorder (keep all, sort global first). Both fix the same root cause (#1128/#1129) with contradictory algorithms on symmetrical data paths.
usp_lan_info_card.dart:107 calls info.ipv6Addresses.first — same .first pattern as the WAN card that triggered this PR — but the LAN-side service does not call preferGlobalIpv6First.
Fix: Decide on one canonical strategy. Recommend reorder (not filter) to preserve all addresses in the detail view. Apply preferGlobalIpv6First() consistently in both services.
W-4 — _firstTwoBytes accepts colon-free bare hex tokens (Confidence: Med)
lib/core/utils/ipv6_address.dart:111-113:
final colon = s.indexOf(':');
firstHextet = colon == -1 ? s : s.substring(0, colon);
if (firstHextet.isEmpty) firstHextet = '0';Input "2001" (no colon) passes through — colon == -1 — entire string used as hextet — classified as Ipv6Scope.global. Not currently reachable through USP data (TR-181 mandates valid IPv6), but violates the documented contract.
Fix: if (!s.contains(':')) return null;
✅ What looks good
- RFC 4291 bitmasks correct (High):
2000::/3(0x20-0x3F),fe80::/10(correct bitmask coveringfebf::1boundary),fc00::/7— all verified. - Stable sort implementation (High): Decorate-sort-undecorate pattern correctly sidesteps Dart's non-stable
List.sort. TR-181 instance order preserved as tie-breaker. - Zone-ID and prefix-length stripping (High):
split('%').first.split('/').firsthandlesfe80::1%eth0and2401:e180::1/64. - Multiple
::rejection (High):indexOf('::') != lastIndexOf('::')correctly rejects malformed double-compression. - Error isolation (High):
preferGlobalIpv6Firstcalled insidetry/catch— safe fallback to empty list. - UI guard (High):
if (wan.ipv6Addresses.isNotEmpty)in place before.first. - Test coverage (High): 10 cases across all three public functions; covers real-world #1128 diagnostic-log sequence, boundary values, zone-ID/CIDR suffixes. All 18 tests pass.
Cross-reviewed by two independent agents (security+correctness / architecture+maintainability). Automated — please sanity-check before merge.
…d (review W-1, #1128) Resolves review warning W-1 on PR #1139: - Extract the IPv6 high-order byte-range checks into a single source of truth, lib/core/utils/ipv6_ranges.dart, and have both call sites (classifyIpv6Scope in ipv6_address.dart and IPv6WithReservedRule in validator_rules/rules.dart) import from it, eliminating the duplicated magic constants. - Add the 3FFE::/16 (6bone, RFC 3701) and 5F00::/12..7F reserved-range exclusions to classifyIpv6Scope, matching the guards already in IPv6WithReservedRule. Previously 3FFE:: was misclassified as global unicast by the new helper. - Add regression coverage for the reserved-range exclusions. No behavior change to rules.dart (49/49 validator tests green); ipv6_address tests 11/11 green.
🔧 Review-fix applied — W-1 (commit
|
AustinChangLinksys
left a comment
There was a problem hiding this comment.
🤖 Automated Review — Round 2 · bbcd8c0..cdbd2e5 (incremental)
Verdict: 💬 COMMENT — dispatch-chain PR; 0 Critical. Posting as COMMENT because GitHub API blocks same-account self-approve. All prior-round Critical/Warning findings resolved or accepted; one prior-round Suggestion still open.
| Conf. | Where | Issue (one-liner) | |
|---|---|---|---|
| 🟢High | lib/core/utils/ipv6_address.dart:85–88 |
[single reviewer] preferGlobalIpv6First sort comparator calls classifyIpv6Scope twice per comparison — O(2n log n) classifications; pre-compute scopes before sort (prior-round Suggestion, still unresolved) |
|
| 💡 | 🟡Med | lib/core/utils/ipv6_ranges.dart:31–36 |
[single reviewer] isReservedGlobalByte doc comment does not mention that 0x40–0x5E (4000::/3) also falls outside global-unicast range and reaches other via fallthrough — may mislead future readers |
| 💡 | 🟢High | test/core/utils/ |
[single reviewer] No ipv6_ranges_test.dart — new SSOT module ipv6_ranges.dart lacks direct predicate unit tests; only tested indirectly via ipv6_address_test.dart |
Confidence: 🟢High = code-verified · 🟡Med = located + reasoned, not fully confirmed · ⚪Low = speculative, please double-check.
Items marked [both reviewers] were independently flagged by two agents → higher confidence.
⚠️ Warning Details
W-1 — [single reviewer] preferGlobalIpv6First comparator calls classifyIpv6Scope twice per comparison — O(2n log n) (Confidence: 🟢High)
lib/core/utils/ipv6_address.dart:85–88 (prior-round Suggestion promoted to Warning given persistence):
indexed.sort((a, b) {
final aScope = classifyIpv6Scope(a.value); // ← re-classifies on every compare
final bScope = classifyIpv6Scope(b.value); // ← re-classifies on every compare
...
});With n addresses, sorting requires O(n log n) comparisons; each comparison calls classifyIpv6Scope twice (string parsing + byte classification). Practical impact is low (n ≤ ~10 for any interface's IPv6 list), but this is an avoidable allocation pattern.
Fix: Pre-compute scopes once before the sort:
List<String> preferGlobalIpv6First(Iterable<String> addresses) {
final list = addresses.toList();
final scored = [
for (var i = 0; i < list.length; i++)
(idx: i, scope: classifyIpv6Scope(list[i]).preference, addr: list[i]),
];
scored.sort((a, b) {
final s = a.scope.compareTo(b.scope);
return s != 0 ? s : a.idx.compareTo(b.idx);
});
return [for (final e in scored) e.addr];
}✅ Prior-round findings status
-
W-1 DRY violation + 5F00::/8 divergence: ✅ Fixed (both reviewers confirmed)
- New
lib/core/utils/ipv6_ranges.dartextracts all byte-range predicates as SSOT - Both
ipv6_address.dartandrules.dartimport from it — no inline magic constants remain is6boneBytesguard placed beforeisGlobalUnicastByteinclassifyIpv6Scope—3FFE::/16now correctly →Ipv6Scope.other- Full 65,536-combination semantic equivalence verified (0 mismatches vs pre-patch)
- Architecture direction confirmed:
validator_rules/→core/utils/is valid, no circular dependency
- New
-
W-2
3FFE::/16misclassification: ✅ Fixed as side-effect of W-1classifyIpv6Scope('3ffe::1')→Ipv6Scope.other(wasglobal)- New regression test added:
'3ffe::1','3fff::1'boundary verified
-
W-3 LAN/WAN inconsistency (cross-PR tech debt): ✅ Accepted — comment-only
- No new evidence in this diff; prior disposition stands
-
W-4 bare-hex token
'3ffe'mitigated: ✅ Accepted — comment-only- With W-1 fix:
_firstTwoBytes('3ffe')→ bytes (0x3F,0xFE) →is6boneBytes()→other(no longer misclassified)
- With W-1 fix:
-
Architecture compliance confirmed (High):
core/utils/placement correct;validator_rules/import direction validipv6_ranges.dartfile header documents both call sites — prior cross-reference concern resolved
Cross-reviewed by two independent agents (security+correctness / architecture+maintainability). Automated — please sanity-check before merge.
Qodo reviews are paused for this user.Troubleshooting steps vary by plan Learn more → On a Teams plan? Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center? |
PeterJhongLinksys
left a comment
There was a problem hiding this comment.
Automated PR Review
Verdict: 🟢 Approve — no unresolved critical issues
Findings
- 🔴 Critical — none
- 🟡 Warning — none new (all substantive concerns were already raised in earlier rounds; see below)
- 🟢 Nit — none new
Already raised (skipped to avoid duplication)
- DRY: byte-range constants duplicated between
ipv6_address.dartandrules.dart— automated Round 1, fixed (extracted tolib/core/utils/ipv6_ranges.dart, both sites import it). 3FFE::/16(6bone) classified asglobal— automated Round 1, fixed (is6boneBytesguard now returnsIpv6Scope.other; regression test added)._firstTwoBytesparser duplicates_parseIpv6ToBytesinrules.dart— automated Round 1, still open (only the byte-range constants were shared, not the parser). Warning-level, non-blocking.RegExpre-allocated per_firstTwoBytescall (ipv6_address.dart:88) — automated Round 1, still open. Nit/perf, non-blocking.- Comparator calls
classifyIpv6Scopetwice per compare, O(2n log n) (ipv6_address.dartsort) — automated Round 1/2, still open. n<=~10, negligible; non-blocking. - Single leading-colon / bare-hex token tolerated by
_firstTwoBytes— automated Round 1, acknowledged; W-1 fix makes the practical mis-surfacing impact moot (falls toother). - LAN vs WAN strategy divergence (filter in #1138 vs reorder here) — automated Round 1, acknowledged as cross-PR tech debt for a follow-up unification story.
- Missing edge tests (all-link-local integration case,
ipv6_ranges_test.dart, IPv4-mapped/6to4,fec0::) — automated Round 1/2, acknowledged, non-blocking. isReservedGlobalBytedoc omits0x40–0x5Efallthrough note — automated Round 2, acknowledged nit.
Verified
- Scope precedence in
classifyIpv6Scope(lib/core/utils/ipv6_address.dart) is correct: reserved/6bone excluded before the2000::/3test, sofe80::…→linkLocal,2401:…→global,3ffe::1→other,fc00::→uniqueLocal. preferGlobalIpv6Firstis a stable decorate-sort: multiple global addresses retain TR-181 instance order deterministically, and a link-local-only list is left untouched — the card still shows it via theif (wan.ipv6Addresses.isNotEmpty)guard atusp_network_status_card.dart:100, so noRangeErrorand no blank-when-only-link-local regression.- Fix lives at the L1 Service seam (
usp_wan_data_service.dart:100-104), not the UI — tier isolation preserved (constitution Art. IV/VI). All order-sensitive consumers ofwan.ipv6Addressesbenefit; the only strictly order-dependent read is.first(the card + AI section), while the PDFjoin(', ')is order-insensitive, so no downstream regression. - No new UI widgets introduced (existing
InfoGrid/InfoGridItem), so the UI-Kit-first policy is not triggered. - Unit + service tests cover the exact #1128 4-address diagnostic sequence and assert
.firstis the global address.
…ge (#1146) * fix(dashboard/detail): unify IPv6 link-local display with a scope badge Across every view that surfaces an IPv6 address (Dashboard Network Status & LAN Information cards, Device Detail, Node Detail), a link-local (fe80::/10) address is now always shown but tagged with an Ipv6ScopeBadge (public_off icon + tooltip) rather than hidden or dropped. Data services keep every address and reorder so a globally routable one is preferred as the representative value; the UI marks link-local instead of filtering it. This supersedes the earlier filter/`-` behavior from #1128 (#1139) and #1129 (#1138): a WAN/LAN with no global/ULA prefix now shows its link-local address with the badge instead of a bare "-". - ipv6_address.dart: add public isLinkLocalIpv6() (shared classifier) - WAN/LAN data services: keep all addresses + preferGlobalIpv6First() - Ipv6ScopeBadge + InfoGridItem.labelTrailing + DetailCopyableTile.leading - Detail views swap the leading icon for the badge; cards tag the label - add ipv6ScopeLinkLocal string across all 26 locales - update WAN/LAN service tests for the keep-and-reorder behavior Refs #1128 #1129 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(golden): add IPv6 link-local/global screenshot states Screenshot-test coverage for the IPv6 scope badge across all four views that surface an IPv6 address. Each view now generates screenshots for both a global (no badge) and a link-local (badge) state, confirming the badge renders in the correct state: - dashboard cards (WAN Network Status, LAN Information): add link_local_ipv6 state (existing online_dhcp/dhcp_enabled already cover global/ULA) - Device Detail: add global_ipv6 + link_local_ipv6 states - Node Detail: add global_ipv6 + link_local_ipv6 states Baselines are regenerated by the screenshot suite (gitignored, not committed). This also refreshes the node_detail slave_with_devices screenshot, whose on-disk baseline predated the backhaul card. Refs #1128 #1129 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Summary
Fixes the Dashboard Network Status widget so it surfaces the WAN global IPv6 address instead of the non-routable link-local address (
fe80::…, scope link).Refs #1128.Root cause
The WAN Network Status widget renders
wan.ipv6Addresses.first.Device.IP.Interface.2.IPv6Addressis returned by the backend in TR-181 instance order, with the link-localfe80::/10address as instance 1 (confirmed in the #1128 diagnostic log, lines 721-724).UspWanDataServicecollected the addresses in that raw instance order and only filteredwhere(isNotEmpty)— never by scope — so.firstwas the non-routablefe80::and that is what the dashboard displayed, even though the interface also carries global unicast addresses (.2/.3/.4).Fix
lib/core/utils/ipv6_address.dart):Ipv6Scopeenum +classifyIpv6Scope/isGlobalUnicastIpv6/preferGlobalIpv6First(stable sort that floats global unicast to the front while preserving relative order of the rest), plus a_firstTwoByteshextet parser that strips any zone index.UspWanDataServiceappliespreferGlobalIpv6Firsttoipv6Addressesbefore returning, so global unicast surfaces first for the card, PDF report, and AI section consistently. Fix lives at the L1 Service seam, not the UI layer.Tier isolation preserved: ordering logic lives in the Service, view unchanged in structure.
Acceptance (issue #1128)
preferGlobalIpv6Firstfloats global unicast to.firstChanged files (git diff --stat vs dev-2.6.0)
Tests (real output)
test/core/utils/ipv6_address_test.dart— 10 unit tests covering all scopes, edge cases, stable sort.test/page/internet_settings/services/usp_wan_data_service_test.dart— integration test reproducing the exact 4-address sequence from the diagnostic log and asserting.firstis the global address, plus the gateway-failure path.flutter analyzeclean.dart formatclean.Notes
gate/feat-1128but exited before opening the draft PR; code + branch + verify were all already complete.