fix(dashboard/detail): unify IPv6 link-local display with a scope badge#1146
Conversation
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>
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? |
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>
AustinChangLinksys
left a comment
There was a problem hiding this comment.
🤖 Automated Review — Round 1 · 70c703d..ca7c988 (full)
Verdict: 💬 Self-review (comment only) — No Critical issues found; review provided as reference for blind-spot coverage. GitHub prohibits self-approval; verdict is permanently COMMENT regardless of finding severity.
| Conf. | Where | Issue (one-liner) | |
|---|---|---|---|
| 🟢 High | usp_device_detail_view.dart:791 |
[both reviewers] Expanded rows in _buildIpv6Section iterate via plain DetailCopyableText — secondary link-local addresses shown without any scope badge; only addresses.first drives the section-level icon |
|
| 🟢 High | list_blocks.dart:182 |
Ipv6ScopeBadge defined inside generic layout module — domain-specific IPv6 widget violates single-responsibility of list_blocks.dart |
|
| 🟢 High | list_blocks.dart:192 |
Ipv6ScopeBadge uses bare Icon(Icons.public_off) bypassing AppIcon ui_kit wrapper (same file uses AppIcon.font at line 270) |
|
| 🟢 High | usp_node_detail_view.dart:260 |
View re-calls preferGlobalIpv6First(wanIpv6Addresses) on data the service layer already sorted — double-sort, breaks single-source-of-order principle |
|
| 🟡 Med | list_blocks.dart:161 |
InfoGridItem.labelTrailing: Widget? embeds a render object in a pure-data DTO — breaks serialization/equality semantics, may block const usage |
|
| 🟡 Med | usp_lan_info_card.dart:119 |
[both reviewers] Hardcoded 'Enabled' string not localized — same file uses loc(context).enabled/.disabled pattern elsewhere |
|
| 💡 | 🟢 High | ipv6_address.dart:80 |
Doc comment says link-local is "never a meaningful address to surface in the UI" — directly contradicts this PR's intent (show with badge) |
| 💡 | 🟢 High | test/core/utils/ipv6_address_test.dart |
New public isLinkLocalIpv6() function lacks direct unit tests; fe80–febf boundary, zone-id, ULA non-classification not explicitly exercised |
| 💡 | 🟡 Med | usp_network_status_card.dart:109, usp_lan_info_card.dart:114 |
View layer calls isLinkLocalIpv6() directly — classification logic should live in service/UIModel, not in build() |
| 💡 | 🟡 Med | list_blocks.dart, detail_widgets.dart |
No widget tests for new Ipv6ScopeBadge, DetailCopyableTile.leading, or InfoGridItem.labelTrailing conditional rendering paths |
| 💡 | 🟡 Med | usp_device_detail_view.dart:734 |
_buildIpv6Section lacks assert(rawAddresses.isNotEmpty) — empty list silently renders blank; caller guards exist but function contract is undocumented |
| 💡 | ⚪ Low | lan_data_provider_test.dart:117 |
Comment describes febf::1 as "top of fe80::/10" — imprecise; fe80::/10 ends at febf:ffff:…:ffff |
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
W1 — Expanded rows in _buildIpv6Section missing per-address badge 🟢 (both reviewers)
lib/page/devices/views/usp_device_detail_view.dart:734–795
// Lines 734–735: only first address drives section icon
final leadingIsLinkLocal =
addresses.isNotEmpty && isLinkLocalIpv6(addresses.first);
// Lines 740–743: section-level icon only
if (leadingIsLinkLocal)
const Ipv6ScopeBadge(size: 16)
else
Icon(Icons.language, size: 16, color: colorScheme.onSurfaceVariant),
// Lines ~791-795: expanded rows — no per-address badge
...addresses.take(displayCount).map(
(addr) => Padding(
padding: const EdgeInsets.only(top: AppSpacing.xxs),
child: DetailCopyableText(text: addr), // no badge for link-local
),
),When it breaks: Input [fe80::a, 2001:db8::1, fe80::b] → after preferGlobalIpv6First → [2001:db8::1, fe80::a, fe80::b]. Collapsed: leading = Icons.language (correct). Expanded: fe80::a and fe80::b show as plain text with no badge. Contrast with usp_node_detail_view.dart:246 which correctly calls isLinkLocalIpv6(ipv6) per-address in its for loop.
Fix: Replace the map() section with per-address badge logic matching usp_node_detail_view.dart:
...addresses.take(displayCount).map((addr) => Padding(
padding: const EdgeInsets.only(top: AppSpacing.xxs),
child: Row(children: [
if (isLinkLocalIpv6(addr)) ...[const Ipv6ScopeBadge(size: 12), AppGap.xs()],
Flexible(child: DetailCopyableText(text: addr)),
]),
)),W2 — Ipv6ScopeBadge in wrong module (SRP violation) 🟢
lib/page/_shared/components/layout_blocks/list_blocks.dart:182
list_blocks.dart is a generic layout/UI-kit wrapper. Adding an IPv6-protocol-specific badge widget here violates single responsibility. The file already uses AppIcon.font() pattern (line 270), confirming it should remain ui_kit-aligned.
Fix: Extract to lib/page/_shared/components/ipv6_scope_badge.dart; inject via Widget? labelTrailing without defining domain badges in list_blocks.dart.
W3 — Bare Icon(Icons.public_off) bypasses AppIcon wrapper 🟢
lib/page/_shared/components/layout_blocks/list_blocks.dart:192–196
child: Icon(
Icons.public_off,
size: size ?? BlockConstants.iconSm,
color: colorScheme.onSurfaceVariant,
),Architecture rule: "UI must use ui_kit_library, not custom widgets." Same file line 270 uses AppIcon.font(...). Bare Icon may skip semantic label logic or theming applied by AppIcon.
Fix: AppIcon.font(Icons.public_off, size: size ?? BlockConstants.iconSm, color: colorScheme.onSurfaceVariant)
W4 — Double-sort in usp_node_detail_view.dart 🟢
lib/page/topology/views/usp_node_detail_view.dart:260
wanIpv6Addresses originates from UspWanDataService.fetch() which already calls preferGlobalIpv6First() before populating the model. The View re-calls it on line 260, creating: (1) redundant O(n log n) on every rebuild; (2) "who owns the sort?" ambiguity; (3) future service sort changes masked by View's defensive re-sort.
Fix: Remove preferGlobalIpv6First(...) wrap in View; document UIModel contract that ipv6Addresses is pre-sorted.
W5 — InfoGridItem.labelTrailing: Widget? mixes data and render 🟡
lib/page/_shared/components/layout_blocks/list_blocks.dart:161
InfoGridItem is a pure data holder. Widget? labelTrailing embeds a live Flutter object with BuildContext lifecycle, breaking const-friendliness and any serialization path (e.g. PDF export).
Fix: Use badgeType: Ipv6BadgeType? enum and let _InfoGridTile render the badge. Or use a Widget? Function(BuildContext)? builder.
W6 — Hardcoded 'Enabled' not localized 🟡 (both reviewers)
lib/page/local_network/cards/usp_lan_info_card.dart:119
InfoGridItem(label: 'IPv6', value: 'Enabled'),Same file line 61 uses loc(context).enabled/.disabled. This PR already adds localization keys for 26 locales.
Fix: value: loc(context).enabled
✅ What looks good
- Core logic correct:
isLinkLocalIpv6()delegates toclassifyIpv6Scope()using thefe80::/10mask fromipv6_ranges.dart;febf::1edge case correctly classified. - Service layer clean: Both
UspWanDataServiceandUspLanDataServiceconsistently keep all addresses (including link-local) and applypreferGlobalIpv6First— unified behavior, no more silent filtering. - Test suite updated: Tests correctly revised to reflect new "keep link-local, badge it" semantics; new link-local-only WAN test added.
- USP error handling unchanged:
catch (e)blocks in both services still return empty lists correctly; no new USP response-parsing paths introduced. - Localization complete:
ipv6ScopeLinkLocalARB key added to all 26 locale files. usp_node_detail_view.dartpattern correct: Per-addressisLinkLocalIpv6check inforloop is the right approach —usp_device_detail_view.dartshould mirror it.- No security issues: No hardcoded credentials/tokens, no injection surfaces, no permission bypasses.
Cross-reviewed by two independent agents (security+correctness / architecture+maintainability). Automated — please sanity-check before merge.
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 (the substantive warnings were already raised in the author's self-review; see below)
- 🟢 Nit —
usp_network_status_card.dart:107— the WAN card carries the same hardcoded'Enabled'string as the LAN card flagged in the self-review's W6; if the LAN one is localized toloc(context).enabled, fix both cards together for consistency (pre-existing, not introduced here).
Already raised (skipped to avoid duplication)
- Expanded rows in
_buildIpv6Sectionshow secondary link-local addresses without a per-address badge (usp_device_detail_view.dartexpanded map vs. the per-address pattern inusp_node_detail_view.dart) — AustinChangLinksys W1, still open (a consistency gap; the addresses are still shown, so Warning not Critical) Ipv6ScopeBadgeplaced in the genericlist_blocks.dart(SRP) — AustinChangLinksys W2, still openIpv6ScopeBadgeuses bareIcon(Icons.public_off)instead ofAppIcon.font— AustinChangLinksys W3, still open- Double-sort in
usp_node_detail_view.dart:252(preferGlobalIpv6Firstre-applied on already-sorted WAN data) — AustinChangLinksys W4, still open (idempotent; harmless but redundant) InfoGridItem.labelTrailing: Widget?embeds a render object in a data holder — AustinChangLinksys W5, still open- Hardcoded
'Enabled'inusp_lan_info_card.dartnot localized — AustinChangLinksys W6, still open - Doc for
isLinkLocalIpv6says link-local is "never a meaningful address to surface in the UI", contradicting this PR's show-with-badge intent — AustinChangLinksys suggestion, still open - No direct unit tests for the new public
isLinkLocalIpv6(); no widget tests forIpv6ScopeBadge/DetailCopyableTile.leading/InfoGridItem.labelTrailing— AustinChangLinksys suggestions, acknowledged - View-layer calls
isLinkLocalIpv6()inbuild()rather than in service/UIModel;_buildIpv6Sectionlacks an empty-list assert;febf::1comment imprecision — AustinChangLinksys suggestions, acknowledged
Verified
Ipv6ScopeBadgeis defined inlist_blocks.dartand re-exported via thelayout_blocks.dartbarrel; all four consumers (usp_network_status_card,usp_lan_info_card,usp_device_detail_view,usp_node_detail_view) import that barrel anddetail_widgets.dart, so the symbol resolves — no missing import / compile break.- The unified rule holds end-to-end: both
UspWanDataServiceandUspLanDataServicenow keep every address and applypreferGlobalIpv6First; the LAN service correctly drops the old private_isLinkLocalIpv6filter helper..firstreads in both cards are guarded byisNotEmpty, so noRangeError. - The behavior change (link-local-only WAN/LAN now shows the address with a badge instead of
-) is intentional and supersedes #1138/#1139 as documented; the WAN/LAN service tests andlan_data_provider_testwere correctly rewritten to the keep-and-reorder semantics (link-local sinks to the end,fec0::1still not link-local, zone-index handled). ipv6ScopeLinkLocalis added and translated across all 26 ARB locales (ar, da, de, el, en, es, es_ar, fi, fr, fr_ca, id, it, ja, ko, nb, nl, pl, pt, pt_pt, ru, sv, th, tr, vi, zh, zh_TW) — consistent, no dangling key.- Golden coverage adds both a global (no badge) and a link-local (badge) fixture for each of the four views; PNG baselines remain gitignored per project convention, only
.dartfixtures/state committed.
Summary
Unifies how an IPv6 link-local (
fe80::/10) address is presented acrossevery view that surfaces an IPv6 address. A link-local address is now always
shown but tagged with a scope badge (
public_officon + tooltip) rather thanhidden or dropped.
Data services keep every address and reorder so a globally routable address
is preferred as the representative value; the UI marks link-local instead of
filtering it. One consistent rule everywhere:
Behavior change (supersedes prior fixes)
This replaces the earlier filter /
-behavior shipped in:-when none global")A WAN/LAN with no global/ULA prefix (e.g. an upstream that assigns no IPv6
prefix — observed on M60TB) now shows its link-local address with the badge
instead of a bare
-. Reviewers: this is intentional, not a regression of#1138/#1129.
Views covered
IPv6IPv6IPv6 AddressLAN IPv6/WAN IPv6The Detail views swap the row's leading icon for the badge (avoids a
duplicate globe icon); the Dashboard cards tag the label since
InfoGridItemhas no leading icon.
Changes
ipv6_address.dart: add publicisLinkLocalIpv6()(shared classifier; singlesource of truth in
ipv6_ranges.dart)preferGlobalIpv6First(); removedthe LAN private link-local helper
Ipv6ScopeBadge(moved tolist_blocks.dart, shared by cards + detail),InfoGridItem.labelTrailing,DetailCopyableTile.leadingipv6ScopeLinkLocalstring added across all 26 locales (translated)(badge) IPv6 case in each of the four views (see below)
Screenshot test coverage
Each view now generates screenshots for both scope cases, confirming the badge
renders only in the link-local state:
online_dhcp(existing)link_local_ipv6(new)dhcp_enabled(existing, ULA)link_local_ipv6(new)global_ipv6(new)link_local_ipv6(new)global_ipv6(new)link_local_ipv6(new)Note: golden baselines are screenshot-generation tests — the PNG baselines are
gitignored and regenerated by the suite, so only the
.dartstate/fixturechanges are committed. This pass also refreshes the
node_detailslave_with_devicesscreenshot, whose on-disk baseline predated the backhaulcard.
Verification
dart format: cleanflutter analyze: 0 error / 0 warning on changed files./run_tests.sh: 3313 tests passedRefs #1128 #1129