Skip to content

fix(dashboard/detail): unify IPv6 link-local display with a scope badge#1146

Merged
AustinChangLinksys merged 2 commits into
dev-2.6.0from
fix/ipv6-detail-linklocal-v2
Jul 16, 2026
Merged

fix(dashboard/detail): unify IPv6 link-local display with a scope badge#1146
AustinChangLinksys merged 2 commits into
dev-2.6.0from
fix/ipv6-detail-linklocal-v2

Conversation

@AustinChangLinksys

@AustinChangLinksys AustinChangLinksys commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

Summary

Unifies how an IPv6 link-local (fe80::/10) address is presented across
every view that surfaces an IPv6 address. A link-local address is now always
shown but tagged
with a scope badge (public_off icon + tooltip) rather than
hidden 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:

Prefer a global/ULA address; when only a link-local exists, show it — tagged
as link-local — never a bare - or a misleading unmarked fe80::.

Behavior change (supersedes prior fixes)

This replaces the earlier filter / - behavior shipped in:

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

View Field Link-local rendering
Dashboard → Network Status (WAN) IPv6 representative address + badge next to label
Dashboard → LAN Information IPv6 representative address + badge next to label
Device Detail → WiFi Connection IPv6 Address leading icon swapped for badge
Node Detail → Network LAN IPv6 / WAN IPv6 per-row leading icon swapped for badge

The Detail views swap the row's leading icon for the badge (avoids a
duplicate globe icon); the Dashboard cards tag the label since InfoGridItem
has no leading icon.

Changes

  • ipv6_address.dart: add public isLinkLocalIpv6() (shared classifier; single
    source of truth in ipv6_ranges.dart)
  • WAN/LAN data services: keep all addresses + preferGlobalIpv6First(); removed
    the LAN private link-local helper
  • Ipv6ScopeBadge (moved to list_blocks.dart, shared by cards + detail),
    InfoGridItem.labelTrailing, DetailCopyableTile.leading
  • ipv6ScopeLinkLocal string added across all 26 locales (translated)
  • updated WAN/LAN service tests for the keep-and-reorder behavior
  • golden (screenshot) states for both a global (no badge) and a link-local
    (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:

View Global (no badge) Link-local (badge)
Network Status card online_dhcp (existing) link_local_ipv6 (new)
LAN Information card dhcp_enabled (existing, ULA) link_local_ipv6 (new)
Device Detail global_ipv6 (new) link_local_ipv6 (new)
Node Detail 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 .dart state/fixture
changes are committed. This pass also refreshes the node_detail
slave_with_devices screenshot, whose on-disk baseline predated the backhaul
card.

Verification

  • dart format: clean
  • flutter analyze: 0 error / 0 warning on changed files
  • ./run_tests.sh: 3313 tests passed
  • golden screenshot suite (info_cards + device_detail + node_detail): 36 tests passed

Refs #1128 #1129

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-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

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 AustinChangLinksys left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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 to classifyIpv6Scope() using the fe80::/10 mask from ipv6_ranges.dart; febf::1 edge case correctly classified.
  • Service layer clean: Both UspWanDataService and UspLanDataService consistently keep all addresses (including link-local) and apply preferGlobalIpv6First — 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: ipv6ScopeLinkLocal ARB key added to all 26 locale files.
  • usp_node_detail_view.dart pattern correct: Per-address isLinkLocalIpv6 check in for loop is the right approach — usp_device_detail_view.dart should 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 PeterJhongLinksys left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 to loc(context).enabled, fix both cards together for consistency (pre-existing, not introduced here).

Already raised (skipped to avoid duplication)

  • Expanded rows in _buildIpv6Section show secondary link-local addresses without a per-address badge (usp_device_detail_view.dart expanded map vs. the per-address pattern in usp_node_detail_view.dart) — AustinChangLinksys W1, still open (a consistency gap; the addresses are still shown, so Warning not Critical)
  • Ipv6ScopeBadge placed in the generic list_blocks.dart (SRP) — AustinChangLinksys W2, still open
  • Ipv6ScopeBadge uses bare Icon(Icons.public_off) instead of AppIcon.font — AustinChangLinksys W3, still open
  • Double-sort in usp_node_detail_view.dart:252 (preferGlobalIpv6First re-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' in usp_lan_info_card.dart not localized — AustinChangLinksys W6, still open
  • Doc for isLinkLocalIpv6 says 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 for Ipv6ScopeBadge / DetailCopyableTile.leading / InfoGridItem.labelTrailing — AustinChangLinksys suggestions, acknowledged
  • View-layer calls isLinkLocalIpv6() in build() rather than in service/UIModel; _buildIpv6Section lacks an empty-list assert; febf::1 comment imprecision — AustinChangLinksys suggestions, acknowledged

Verified

  • Ipv6ScopeBadge is defined in list_blocks.dart and re-exported via the layout_blocks.dart barrel; all four consumers (usp_network_status_card, usp_lan_info_card, usp_device_detail_view, usp_node_detail_view) import that barrel and detail_widgets.dart, so the symbol resolves — no missing import / compile break.
  • The unified rule holds end-to-end: both UspWanDataService and UspLanDataService now keep every address and apply preferGlobalIpv6First; the LAN service correctly drops the old private _isLinkLocalIpv6 filter helper. .first reads in both cards are guarded by isNotEmpty, so no RangeError.
  • 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 and lan_data_provider_test were correctly rewritten to the keep-and-reorder semantics (link-local sinks to the end, fec0::1 still not link-local, zone-index handled).
  • ipv6ScopeLinkLocal is 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 .dart fixtures/state committed.

@AustinChangLinksys
AustinChangLinksys merged commit b7137ea into dev-2.6.0 Jul 16, 2026
2 checks passed
@AustinChangLinksys
AustinChangLinksys deleted the fix/ipv6-detail-linklocal-v2 branch July 16, 2026 05:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants