From ca7c988f4e9033ddd30d756e0c82756f2c00a3fd Mon Sep 17 00:00:00 2001 From: Austin Chang Date: Thu, 16 Jul 2026 12:58:23 +0800 Subject: [PATCH 1/2] 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 --- lib/core/utils/ipv6_address.dart | 10 ++++ lib/l10n/app_ar.arb | 1 + lib/l10n/app_da.arb | 1 + lib/l10n/app_de.arb | 1 + lib/l10n/app_el.arb | 1 + lib/l10n/app_en.arb | 1 + lib/l10n/app_es.arb | 1 + lib/l10n/app_es_ar.arb | 1 + lib/l10n/app_fi.arb | 1 + lib/l10n/app_fr.arb | 1 + lib/l10n/app_fr_ca.arb | 1 + lib/l10n/app_id.arb | 1 + lib/l10n/app_it.arb | 1 + lib/l10n/app_ja.arb | 1 + lib/l10n/app_ko.arb | 1 + lib/l10n/app_nb.arb | 1 + lib/l10n/app_nl.arb | 1 + lib/l10n/app_pl.arb | 1 + lib/l10n/app_pt.arb | 1 + lib/l10n/app_pt_pt.arb | 1 + lib/l10n/app_ru.arb | 1 + lib/l10n/app_sv.arb | 1 + lib/l10n/app_th.arb | 1 + lib/l10n/app_tr.arb | 1 + lib/l10n/app_vi.arb | 1 + lib/l10n/app_zh.arb | 1 + lib/l10n/app_zh_TW.arb | 1 + .../_shared/components/detail_widgets.dart | 7 ++- .../components/layout_blocks/list_blocks.dart | 53 +++++++++++++++++-- .../devices/views/usp_device_detail_view.dart | 17 +++++- .../cards/usp_network_status_card.dart | 7 +++ .../services/usp_wan_data_service.dart | 14 ++--- .../cards/usp_lan_info_card.dart | 15 +++--- .../services/usp_lan_data_service.dart | 30 ++++------- .../topology/views/usp_node_detail_view.dart | 19 +++++-- .../services/usp_wan_data_service_test.dart | 24 +++++++-- .../providers/lan_data_provider_test.dart | 30 +++++------ 37 files changed, 191 insertions(+), 61 deletions(-) diff --git a/lib/core/utils/ipv6_address.dart b/lib/core/utils/ipv6_address.dart index 32812b5c4..8e2f5b136 100644 --- a/lib/core/utils/ipv6_address.dart +++ b/lib/core/utils/ipv6_address.dart @@ -72,6 +72,16 @@ Ipv6Scope classifyIpv6Scope(String address) { bool isGlobalUnicastIpv6(String address) => classifyIpv6Scope(address) == Ipv6Scope.global; +/// Whether [address] is an IPv6 link-local address (`fe80::/10`). +/// +/// Link-local addresses are only valid on a single link and are never a +/// meaningful address to surface in the UI. The single source of truth for the +/// `fe80::/10` range lives in `ipv6_ranges.dart`; this mirrors the public shape +/// of [isGlobalUnicastIpv6] so callers can filter without re-implementing the +/// scope test. +bool isLinkLocalIpv6(String address) => + classifyIpv6Scope(address) == Ipv6Scope.linkLocal; + /// Returns [addresses] reordered so the most routable address comes first: /// global unicast, then ULA, then link-local, then anything else. The relative /// order of addresses that share a scope is preserved (stable sort), so the diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 34d1d4943..49a9c7f55 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -136,6 +136,7 @@ "ipv4": "IPv4", "ipv6": "IPV6", "ipv6Address": "عنوان IPv6", + "ipv6ScopeLinkLocal": "الرابط المحلي", "keepAlive": "استمرار النشاط", "l2tpPassthrough": "عبور من خلال L2TP", "labelInterface": "الواجهة", diff --git a/lib/l10n/app_da.arb b/lib/l10n/app_da.arb index 0d488140c..b4a80dcc6 100644 --- a/lib/l10n/app_da.arb +++ b/lib/l10n/app_da.arb @@ -136,6 +136,7 @@ "ipv4": "IPv4", "ipv6": "IPV6", "ipv6Address": "IPv6-adresse", + "ipv6ScopeLinkLocal": "Link-lokal", "keepAlive": "Bevar tilslutningen", "l2tpPassthrough": "L2TP Passthrough", "labelInterface": "Grænseflade", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 323c08433..c481348ea 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -136,6 +136,7 @@ "ipv4": "IPv4", "ipv6": "IPv6", "ipv6Address": "IPv6-Adresse", + "ipv6ScopeLinkLocal": "Link-lokal", "keepAlive": "Verbindung aufrecht halten", "l2tpPassthrough": "L2TP-Passthrough", "labelInterface": "Schnittstelle", diff --git a/lib/l10n/app_el.arb b/lib/l10n/app_el.arb index ea52f75ad..56a42ccd1 100644 --- a/lib/l10n/app_el.arb +++ b/lib/l10n/app_el.arb @@ -136,6 +136,7 @@ "ipv4": "IPV4", "ipv6": "IPv6", "ipv6Address": "Διεύθυνση IPv6", + "ipv6ScopeLinkLocal": "Τοπικό ζεύξης", "keepAlive": "Διατήρηση σε ενεργή κατάσταση", "l2tpPassthrough": "Διέλευση L2TP", "labelInterface": "Διασύνδεση", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ccbc8e446..5f8619f14 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -236,6 +236,7 @@ "description": "Do not translate" }, "ipv6Address": "IPv6 Address", + "ipv6ScopeLinkLocal": "Link-local", "keepAlive": "Keep alive", "l2tpPassthrough": "L2TP Passthrough", "labelInterface": "Interface", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 69379eafb..5e895f366 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -136,6 +136,7 @@ "ipv4": "IPv4", "ipv6": "IPv6", "ipv6Address": "Dirección IPv6", + "ipv6ScopeLinkLocal": "Enlace local", "keepAlive": "Mantener activo", "l2tpPassthrough": "Paso a través de L2TP", "labelInterface": "Interfaz", diff --git a/lib/l10n/app_es_ar.arb b/lib/l10n/app_es_ar.arb index aff20f924..5d903e484 100644 --- a/lib/l10n/app_es_ar.arb +++ b/lib/l10n/app_es_ar.arb @@ -136,6 +136,7 @@ "ipv4": "IPv4", "ipv6": "IPv6", "ipv6Address": "Dirección IPv6", + "ipv6ScopeLinkLocal": "Enlace local", "keepAlive": "Mantener activo", "l2tpPassthrough": "Paso a través de L2TP", "labelInterface": "Interfaz", diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index 0f56eac09..c3e100f87 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -136,6 +136,7 @@ "ipv4": "IPv4", "ipv6": "IPv6", "ipv6Address": "IPv6-osoite", + "ipv6ScopeLinkLocal": "Linkkikohtainen", "keepAlive": "Aina käytössä", "l2tpPassthrough": "L2TP-läpiohjaus", "labelInterface": "Käyttöliittymä", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 5b15f40cf..df807fd85 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -136,6 +136,7 @@ "ipv4": "IPv4", "ipv6": "IPv6", "ipv6Address": "Adresse IPv6", + "ipv6ScopeLinkLocal": "Lien-local", "keepAlive": "Maintenir la connexion", "l2tpPassthrough": "Passthrough L2TP", "labelInterface": "Interface", diff --git a/lib/l10n/app_fr_ca.arb b/lib/l10n/app_fr_ca.arb index b454556ba..27f0e74ab 100644 --- a/lib/l10n/app_fr_ca.arb +++ b/lib/l10n/app_fr_ca.arb @@ -136,6 +136,7 @@ "ipv4": "IPv4", "ipv6": "IPv6", "ipv6Address": "Adresse IPv6", + "ipv6ScopeLinkLocal": "Lien-local", "keepAlive": "Maintenir la connexion", "l2tpPassthrough": "Intercommunication L2TP", "labelInterface": "Interface", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 09b5628d7..0ed0d9b70 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -136,6 +136,7 @@ "ipv4": "IPV4", "ipv6": "IPv6", "ipv6Address": "Alamat IPv6", + "ipv6ScopeLinkLocal": "Link-local", "keepAlive": "Aktifkan terus", "l2tpPassthrough": "L2TP Passthrough", "labelInterface": "Antarmuka", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 88cbe7bbf..58bdc7b09 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -136,6 +136,7 @@ "ipv4": "IPv4", "ipv6": "IPv6", "ipv6Address": "Indirizzo IPv6", + "ipv6ScopeLinkLocal": "Link-local", "keepAlive": "Connessione sempre attiva", "l2tpPassthrough": "Passthrough L2TP", "labelInterface": "Interfaccia", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index f43c7742e..d9b728393 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -136,6 +136,7 @@ "ipv4": "IPv4", "ipv6": "IPv6", "ipv6Address": "IPv6 アドレス", + "ipv6ScopeLinkLocal": "リンクローカル", "keepAlive": "接続を維持", "l2tpPassthrough": "L2TP パススルー", "labelInterface": "インターフェイス", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index bf6a09e2c..01aec103d 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -136,6 +136,7 @@ "ipv4": "IPv4", "ipv6": "IPv6", "ipv6Address": "IPv6 주소", + "ipv6ScopeLinkLocal": "링크-로컬", "keepAlive": "상태 유지", "l2tpPassthrough": "L2TP 패스스루", "labelInterface": "인터페이스", diff --git a/lib/l10n/app_nb.arb b/lib/l10n/app_nb.arb index c90a4c6c8..310fe67a8 100644 --- a/lib/l10n/app_nb.arb +++ b/lib/l10n/app_nb.arb @@ -136,6 +136,7 @@ "ipv4": "IPV4", "ipv6": "IPV6", "ipv6Address": "IPv6-adresse", + "ipv6ScopeLinkLocal": "Lenkelokal", "keepAlive": "Oppretthold", "l2tpPassthrough": "L2TP-passasje", "labelInterface": "Grensesnitt", diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 7ca05f321..03e41cd3f 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -136,6 +136,7 @@ "ipv4": "IPv4", "ipv6": "IPv6", "ipv6Address": "IPv6-adres", + "ipv6ScopeLinkLocal": "Link-lokaal", "keepAlive": "Continu verbinding houden", "l2tpPassthrough": "L2TP-doorvoer", "labelInterface": "Interface", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 39dc7dfda..63d409257 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -136,6 +136,7 @@ "ipv4": "IPv4", "ipv6": "IPv6", "ipv6Address": "Adres IPv6", + "ipv6ScopeLinkLocal": "Link-local", "keepAlive": "Utrzymywanie aktywności", "l2tpPassthrough": "L2TP Passthrough", "labelInterface": "Interfejs", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 4d56a00c3..f8d04937e 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -136,6 +136,7 @@ "ipv4": "IPv4", "ipv6": "IPv6", "ipv6Address": "Endereço IPv6", + "ipv6ScopeLinkLocal": "Link-local", "keepAlive": "Funcionamento", "l2tpPassthrough": "Passagem L2TP", "labelInterface": "Interface", diff --git a/lib/l10n/app_pt_pt.arb b/lib/l10n/app_pt_pt.arb index 0a0523818..7b52cf0a8 100644 --- a/lib/l10n/app_pt_pt.arb +++ b/lib/l10n/app_pt_pt.arb @@ -136,6 +136,7 @@ "ipv4": "IPv4", "ipv6": "IPv6", "ipv6Address": "Endereço IPv6", + "ipv6ScopeLinkLocal": "Link-local", "keepAlive": "Manter ligado", "l2tpPassthrough": "Passagem L2TP", "labelInterface": "Interface", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index c2da913ae..ede68abf4 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -136,6 +136,7 @@ "ipv4": "IPv4", "ipv6": "IPv6", "ipv6Address": "IPv6-адрес", + "ipv6ScopeLinkLocal": "Локальный для канала", "keepAlive": "Проверка активности", "l2tpPassthrough": "L2TP-туннель", "labelInterface": "Интерфейс", diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index 8642fa36f..dde0b73de 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -136,6 +136,7 @@ "ipv4": "IPv4", "ipv6": "IPv6", "ipv6Address": "IPv6-adress", + "ipv6ScopeLinkLocal": "Länklokal", "keepAlive": "Behåll anslutning", "l2tpPassthrough": "L2TP-vidarekoppling", "labelInterface": "Gränssnitt", diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 5eb6a7ba6..8599f1050 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -136,6 +136,7 @@ "ipv4": "IPv4", "ipv6": "IPv6", "ipv6Address": "IPv6 แอดเดรส", + "ipv6ScopeLinkLocal": "ลิงก์-โลคัล", "keepAlive": "คงการเชื่อมต่อ", "l2tpPassthrough": "L2TP Passthrough", "labelInterface": "อินเตอร์เฟซ", diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index aad9497d5..8d7110d06 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -136,6 +136,7 @@ "ipv4": "IPv4", "ipv6": "IPv6", "ipv6Address": "IPv6 Adresi", + "ipv6ScopeLinkLocal": "Bağlantı yerel", "keepAlive": "Bağlı tut", "l2tpPassthrough": "L2TP Geçiş İzni", "labelInterface": "Arabirim", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 240ac0f72..280f71085 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -136,6 +136,7 @@ "ipv4": "IPv4", "ipv6": "IPv6", "ipv6Address": "Địa chỉ IPv6", + "ipv6ScopeLinkLocal": "Link-local", "keepAlive": "Giữ hoạt động", "l2tpPassthrough": "L2TP Truyền qua", "labelInterface": "Giao diện", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 28d223415..540432f2c 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -136,6 +136,7 @@ "ipv4": "IPv4", "ipv6": "IPv6", "ipv6Address": "IPv6地址", + "ipv6ScopeLinkLocal": "链路本地", "keepAlive": "保持活跃", "l2tpPassthrough": "L2TP通道", "labelInterface": "接口", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index cb878d5f5..05a6455ee 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -136,6 +136,7 @@ "ipv4": "IPv4", "ipv6": "IPv6", "ipv6Address": "IPv6 位址", + "ipv6ScopeLinkLocal": "連結本地", "keepAlive": "保持活躍", "l2tpPassthrough": "L2TP 穿透", "labelInterface": "介面", diff --git a/lib/page/_shared/components/detail_widgets.dart b/lib/page/_shared/components/detail_widgets.dart index e104b3d2b..84381f66d 100644 --- a/lib/page/_shared/components/detail_widgets.dart +++ b/lib/page/_shared/components/detail_widgets.dart @@ -183,11 +183,16 @@ class DetailCopyableTile extends StatelessWidget { final String label; final String value; + /// Optional widget that replaces the default leading [Icon] (e.g. an + /// [Ipv6ScopeBadge] carrying its own tooltip/semantics). + final Widget? leading; + const DetailCopyableTile({ super.key, required this.icon, required this.label, required this.value, + this.leading, }); @override @@ -196,7 +201,7 @@ class DetailCopyableTile extends StatelessWidget { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(icon, size: 16, color: colorScheme.onSurfaceVariant), + leading ?? Icon(icon, size: 16, color: colorScheme.onSurfaceVariant), AppGap.sm(), Expanded( child: Column( diff --git a/lib/page/_shared/components/layout_blocks/list_blocks.dart b/lib/page/_shared/components/layout_blocks/list_blocks.dart index bf7e020c1..f27a2879f 100644 --- a/lib/page/_shared/components/layout_blocks/list_blocks.dart +++ b/lib/page/_shared/components/layout_blocks/list_blocks.dart @@ -158,14 +158,46 @@ class InfoGridItem { final bool fullWidth; final bool copyable; + /// Optional widget rendered next to the label (e.g. an [Ipv6ScopeBadge]). + final Widget? labelTrailing; + const InfoGridItem({ required this.label, required this.value, this.fullWidth = false, this.copyable = false, + this.labelTrailing, }); } +/// Icon-only marker for an IPv6 link-local (`fe80::/10`) address. +/// +/// Link-local addresses are only valid on a single link and are not routable. +/// Wherever an IPv6 address is surfaced (dashboard cards and the device/node +/// detail views), a link-local address is shown but tagged with this compact +/// icon (`public_off` — mirroring the routable `public` icon used for WAN +/// addresses) rather than hidden. The tooltip provides discoverability and +/// doubles as the semantic label for screen readers, since the icon alone +/// carries no text. +class Ipv6ScopeBadge extends StatelessWidget { + final double? size; + + const Ipv6ScopeBadge({super.key, this.size}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Tooltip( + message: loc(context).ipv6ScopeLinkLocal, + child: Icon( + Icons.public_off, + size: size ?? BlockConstants.iconSm, + color: colorScheme.onSurfaceVariant, + ), + ); + } +} + class _InfoGridTile extends StatelessWidget { final InfoGridItem item; @@ -179,10 +211,23 @@ class _InfoGridTile extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - AppText.labelSmall( - item.label.toUpperCase(), - color: colorScheme.onSurfaceVariant, - ), + if (item.labelTrailing != null) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + AppText.labelSmall( + item.label.toUpperCase(), + color: colorScheme.onSurfaceVariant, + ), + AppGap.xs(), + item.labelTrailing!, + ], + ) + else + AppText.labelSmall( + item.label.toUpperCase(), + color: colorScheme.onSurfaceVariant, + ), AppGap.xs(), item.copyable ? _CopyableText(text: item.value) diff --git a/lib/page/devices/views/usp_device_detail_view.dart b/lib/page/devices/views/usp_device_detail_view.dart index 3949c2355..f2600c5d3 100644 --- a/lib/page/devices/views/usp_device_detail_view.dart +++ b/lib/page/devices/views/usp_device_detail_view.dart @@ -4,6 +4,7 @@ import 'package:privacy_gui/route/navigation_extensions.dart'; import 'package:privacy_gui/components/ui_kit_page_view.dart'; import 'package:privacy_gui/localization/localization_hook.dart'; import 'package:privacy_gui/page/_shared/utils/device_classifier.dart'; +import 'package:privacy_gui/core/utils/ipv6_address.dart'; import 'package:privacy_gui/core/utils/oui_lookup.dart'; import 'package:privacy_gui/route/constants.dart'; import 'package:privacy_gui/page/_shared/models/client_device.dart' @@ -720,14 +721,26 @@ class _UspDeviceDetailViewState extends ConsumerState { // IPv6 Section // =========================================================================== - Widget _buildIpv6Section(BuildContext context, List addresses) { + Widget _buildIpv6Section(BuildContext context, List rawAddresses) { final colorScheme = Theme.of(context).colorScheme; + // Surface globally routable addresses first so the collapsed view (which + // shows only the first entry) never leads with a link-local address, while + // still enumerating every address when expanded. See #1128/#1129. + final addresses = preferGlobalIpv6First(rawAddresses); final displayCount = _ipv6Expanded ? addresses.length : 1; + // When the representative (first) address is link-local, the leading icon + // itself signals the scope (public_off + tooltip) instead of a duplicate + // trailing badge. See #1128/#1129. + final leadingIsLinkLocal = + addresses.isNotEmpty && isLinkLocalIpv6(addresses.first); return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(Icons.language, size: 16, color: colorScheme.onSurfaceVariant), + if (leadingIsLinkLocal) + const Ipv6ScopeBadge(size: 16) + else + Icon(Icons.language, size: 16, color: colorScheme.onSurfaceVariant), AppGap.sm(), Expanded( child: Column( diff --git a/lib/page/internet_settings/cards/usp_network_status_card.dart b/lib/page/internet_settings/cards/usp_network_status_card.dart index f8c01744e..275a64e3f 100644 --- a/lib/page/internet_settings/cards/usp_network_status_card.dart +++ b/lib/page/internet_settings/cards/usp_network_status_card.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/core/utils/ipv6_address.dart'; import 'package:privacy_gui/localization/localization_hook.dart'; import 'package:privacy_gui/page/_shared/models/wan_status_ui_model.dart'; import 'package:privacy_gui/page/_shared/components/dashboard_card_template.dart'; @@ -102,6 +103,12 @@ class UspNetworkStatusCard extends ConsumerWidget { label: 'IPv6', value: wan.ipv6Addresses.first, copyable: true, + // The representative address prefers global unicast; when only + // a link-local (fe80::/10) address exists it is still shown, + // tagged with a scope badge rather than hidden. See #1128. + labelTrailing: isLinkLocalIpv6(wan.ipv6Addresses.first) + ? const Ipv6ScopeBadge() + : null, ) else if (wan.ipv6Enabled) InfoGridItem(label: 'IPv6', value: 'Enabled'), diff --git a/lib/page/internet_settings/services/usp_wan_data_service.dart b/lib/page/internet_settings/services/usp_wan_data_service.dart index 03a02c05a..4298a1cb1 100644 --- a/lib/page/internet_settings/services/usp_wan_data_service.dart +++ b/lib/page/internet_settings/services/usp_wan_data_service.dart @@ -92,16 +92,18 @@ class UspWanDataService { } } + // TR-181 returns IPv6 addresses in instance order, which frequently puts + // the link-local (fe80::/10) address first. The WAN widget shows a single + // representative address (ipv6Addresses.first), which must prefer the + // globally routable one. We keep every address (including link-local) and + // only reorder so global unicast wins; the UI marks a link-local address + // with a scope badge rather than hiding it, so a WAN with no global/ULA + // prefix still shows its link-local address instead of nothing. + // See linksys/PrivacyGUI#1128. final ipv6Addresses = ipv6.items .map((addr) => addr.ipAddress) .where((ip) => ip.isNotEmpty) .toList(); - - // TR-181 returns IPv6 addresses in instance order, which frequently puts - // the link-local (fe80::/10) address first. The WAN widget shows a single - // representative address (ipv6Addresses.first), which must be the globally - // routable one — not the link-local. Reorder so global unicast wins. - // See linksys/PrivacyGUI#1128. final orderedIpv6Addresses = preferGlobalIpv6First(ipv6Addresses); return (gateway: gateway, ipv6Addresses: orderedIpv6Addresses); diff --git a/lib/page/local_network/cards/usp_lan_info_card.dart b/lib/page/local_network/cards/usp_lan_info_card.dart index f60d8f991..4f803345d 100644 --- a/lib/page/local_network/cards/usp_lan_info_card.dart +++ b/lib/page/local_network/cards/usp_lan_info_card.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/core/utils/ipv6_address.dart'; import 'package:privacy_gui/localization/localization_hook.dart'; import 'package:privacy_gui/page/_shared/models/lan_info_ui_model.dart'; import 'package:privacy_gui/page/_shared/components/layout_blocks.dart'; @@ -106,14 +107,16 @@ class UspLanInfoCard extends ConsumerWidget { label: 'IPv6', value: info.ipv6Addresses.first, copyable: true, + // The representative address prefers global unicast; when + // only a link-local (fe80::/10) address exists it is still + // shown, tagged with a scope badge rather than hidden. + // See #1129. + labelTrailing: isLinkLocalIpv6(info.ipv6Addresses.first) + ? const Ipv6ScopeBadge() + : null, ) else if (info.ipv6Enabled) - // Issue #1129: IPv6 is enabled but no meaningful (global/ULA) - // address is available (e.g. the interface holds only a - // link-local fe80:: address). Render '-' consistent with how - // the DNS field renders when unavailable, not the link-local - // address and not a bare 'Enabled' label. - InfoGridItem(label: 'IPv6', value: '-'), + InfoGridItem(label: 'IPv6', value: 'Enabled'), ], ), ], diff --git a/lib/page/local_network/services/usp_lan_data_service.dart b/lib/page/local_network/services/usp_lan_data_service.dart index 7853bc2bd..ecb6b28a1 100644 --- a/lib/page/local_network/services/usp_lan_data_service.dart +++ b/lib/page/local_network/services/usp_lan_data_service.dart @@ -1,5 +1,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:privacy_gui/core/errors/service_error.dart'; +import 'package:privacy_gui/core/utils/ipv6_address.dart'; import 'package:privacy_gui/core/utils/logger.dart'; import 'package:privacy_gui/core/usp/errors/usp_error.dart'; import 'package:privacy_gui/core/usp/services/usp_client.dart'; @@ -70,32 +71,21 @@ class UspLanDataService { 'Device.IP.Interface.1.IPv6Address.', ]).timeout(const Duration(seconds: 20)); + // Issue #1129: keep every LAN IPv6 address (including link-local) and let + // the UI mark a link-local (fe80::/10) address with a scope badge rather + // than hiding it. Reorder so a globally routable address is preferred as + // the representative value; when the interface holds only a link-local + // address it is still shown, tagged as link-local. Ordering is shared with + // the WAN path via `preferGlobalIpv6First`. final instances = resp.getInstances('Device.IP.Interface.1.IPv6Address.'); - return instances + final addresses = instances .map((i) => i.getString('IPAddress')) - .where((ip) => ip.isNotEmpty && !_isLinkLocalIpv6(ip)) + .where((ip) => ip.isNotEmpty) .toList(); + return preferGlobalIpv6First(addresses); } catch (e) { logger.w('[USP][LanData]: IPv6 addresses fetch failed: $e'); return const []; } } - - /// Returns true if [ip] is an IPv6 link-local address (`fe80::/10`). - /// - /// Issue #1129: when the LAN interface holds only a link-local address - /// (scope link, e.g. `fe80::7612:13ff:fe21:5394`) and no global/ULA prefix, - /// the widget must render empty rather than the link-local address, since a - /// link-local address is only valid on a single link and is not a meaningful - /// LAN IPv6 address. The `fe80::/10` block covers any address whose first - /// hextet, masked with `0xffc0`, equals `0xfe80` (i.e. `fe80`–`febf`). - static bool _isLinkLocalIpv6(String ip) { - // Drop any zone index (e.g. "fe80::1%eth0") before parsing. - final addr = ip.split('%').first.trim(); - final firstHextet = addr.split(':').first; - if (firstHextet.isEmpty) return false; - final value = int.tryParse(firstHextet, radix: 16); - if (value == null) return false; - return (value & 0xffc0) == 0xfe80; - } } diff --git a/lib/page/topology/views/usp_node_detail_view.dart b/lib/page/topology/views/usp_node_detail_view.dart index b6d9a40c3..20c50a022 100644 --- a/lib/page/topology/views/usp_node_detail_view.dart +++ b/lib/page/topology/views/usp_node_detail_view.dart @@ -5,6 +5,7 @@ import 'package:privacy_gui/localization/localization_hook.dart'; import 'package:privacy_gui/route/navigation_extensions.dart'; import 'package:privacy_gui/core/utils/device_image_helper.dart'; import 'package:privacy_gui/core/utils/icon_rules.dart'; +import 'package:privacy_gui/core/utils/ipv6_address.dart'; import 'package:privacy_gui/components/ui_kit_page_view.dart'; import 'package:privacy_gui/route/constants.dart'; import 'package:privacy_gui/page/_shared/components/detail_widgets.dart'; @@ -234,12 +235,17 @@ class UspNodeDetailView extends ConsumerWidget { label: loc(context).lanIp, value: node.ipAddress!, ), - // LAN IPv6 (from Hosts) - for (final ipv6 in node.ipv6Addresses) + // LAN IPv6 (from Hosts) — routable addresses first; a link-local + // address swaps its leading icon for a scope badge (see + // #1128/#1129). + for (final ipv6 in preferGlobalIpv6First(node.ipv6Addresses)) DetailCopyableTile( icon: Icons.language, label: loc(context).lanIpv6, value: ipv6, + leading: isLinkLocalIpv6(ipv6) + ? const Ipv6ScopeBadge(size: 16) + : null, ), // WAN IPv4 (master only) if (wanIp != null && wanIp.isNotEmpty) @@ -248,12 +254,17 @@ class UspNodeDetailView extends ConsumerWidget { label: loc(context).wanIp, value: wanIp, ), - // WAN IPv6 (master only) - for (final ipv6 in wanIpv6Addresses) + // WAN IPv6 (master only) — routable addresses first; a link-local + // address swaps its leading icon for a scope badge (see + // #1128/#1129). + for (final ipv6 in preferGlobalIpv6First(wanIpv6Addresses)) DetailCopyableTile( icon: Icons.public, label: loc(context).wanIpv6, value: ipv6, + leading: isLinkLocalIpv6(ipv6) + ? const Ipv6ScopeBadge(size: 16) + : null, ), ], ), diff --git a/test/page/internet_settings/services/usp_wan_data_service_test.dart b/test/page/internet_settings/services/usp_wan_data_service_test.dart index 41a75c84e..4962db147 100644 --- a/test/page/internet_settings/services/usp_wan_data_service_test.dart +++ b/test/page/internet_settings/services/usp_wan_data_service_test.dart @@ -138,7 +138,8 @@ void main() { expect(result.ipv6Addresses, contains('2001:db8::2')); }); - test('global IPv6 surfaces before link-local (issue #1128)', () async { + test('global IPv6 surfaces first, link-local kept at end (issue #1128)', + () async { // Instance order as reported by the router in the #1128 diagnostic log: // instance 1 is the link-local fe80:: address. stubWanStatus( @@ -153,12 +154,29 @@ void main() { final result = await svc.fetch(); - // The widget shows ipv6Addresses.first, which must now be a global - // unicast address rather than the link-local fe80::. + // The widget shows ipv6Addresses.first, which must be a global unicast + // address. Link-local is not filtered — every address is kept and merely + // reordered so global unicast wins; the UI tags the link-local one with a + // scope badge. So all 4 remain and the link-local sinks to the end. expect(result.ipv6Addresses, hasLength(4)); expect(result.ipv6Addresses.first, '2401:e180:8831:505f::1'); expect(result.ipv6Addresses.last, 'fe80::7612:13ff:fe21:5394'); }); + + test('link-local-only WAN keeps the link-local address (issue #1128)', + () async { + // Real case observed on an M60TB whose upstream assigns no IPv6 prefix: + // the WAN interface (eth0) holds only a scope-link fe80:: address. It is + // still surfaced (tagged with a scope badge by the UI), not hidden. + stubWanStatus( + ipv6Enabled: true, + ipv6Addresses: const ['fe80::7612:13ff:fe21:5502'], + ); + + final result = await svc.fetch(); + + expect(result.ipv6Addresses, ['fe80::7612:13ff:fe21:5502']); + }); }); // --------------------------------------------------------------------------- diff --git a/test/page/local_network/providers/lan_data_provider_test.dart b/test/page/local_network/providers/lan_data_provider_test.dart index d7454f348..b5cc7bce1 100644 --- a/test/page/local_network/providers/lan_data_provider_test.dart +++ b/test/page/local_network/providers/lan_data_provider_test.dart @@ -25,8 +25,8 @@ void main() { }; /// IPv6 response from raw usp.get(). - /// Includes a link-local (fe80::) address that must be filtered out (#1129) - /// and a global address that must be kept. + /// Includes a link-local (fe80::) address (kept but reordered after global, + /// tagged by the UI) and a global address that must be preferred first (#1129). final ipv6Response = { 'Device.IP.Interface.1.IPv6Enable': true, 'Device.IP.Interface.1.IPv6Address.1.IPAddress': 'fe80::1', @@ -67,15 +67,16 @@ void main() { expect(data.model.dnsServers, '8.8.8.8,8.8.4.4'); expect(data.model.hostName, 'LinksysRouter'); expect(data.model.ipv6Enabled, isTrue); - // #1129: link-local fe80:: is filtered out; only the global address remains. - expect(data.model.ipv6Addresses, ['2001:db8::1']); + // #1129: link-local fe80:: is kept but reordered after the global address + // (the UI tags it with a scope badge rather than hiding it). + expect(data.model.ipv6Addresses, ['2001:db8::1', 'fe80::1']); container.dispose(); }); - test('link-local-only IPv6 yields empty addresses (#1129)', () async { + test('link-local-only IPv6 keeps the link-local address (#1129)', () async { // Reproduces the reported case: br-lan holds only a scope-link fe80:: - // address and no global/ULA prefix. The link-local address must NOT be - // surfaced to the widget. + // address and no global/ULA prefix. It is still surfaced (tagged with a + // scope badge by the UI), not hidden. when(() => mockUsp.get(any())).thenAnswer((_) async { final paths = _.positionalArguments[0] as List; if (paths.any((p) => p.toString().contains('IPv6Address'))) { @@ -92,12 +93,11 @@ void main() { final data = await container.read(lanDataProvider.future); expect(data.model.ipv6Enabled, isTrue); - expect(data.model.ipv6Addresses, isEmpty); + expect(data.model.ipv6Addresses, ['fe80::7612:13ff:fe21:5394']); container.dispose(); }); - test('link-local with zone index is filtered, global kept (#1129)', - () async { + test('link-local reordered after global, all kept (#1129)', () async { when(() => mockUsp.get(any())).thenAnswer((_) async { final paths = _.positionalArguments[0] as List; if (paths.any((p) => p.toString().contains('IPv6Address'))) { @@ -115,18 +115,18 @@ void main() { final data = await container.read(lanDataProvider.future); // fe80::1%eth0 (zone index) and febf::1 (top of fe80::/10) are link-local; - // only the global address survives. - expect(data.model.ipv6Addresses, ['2001:db8:abcd::5']); + // all addresses are kept, with the global one surfaced first. + expect(data.model.ipv6Addresses, + ['2001:db8:abcd::5', 'fe80::1%eth0', 'febf::1']); container.dispose(); }); - test('fec0::1 is NOT link-local and must NOT be filtered (#1129)', - () async { + test('fec0::1 is NOT link-local (#1129)', () async { // fec0::1 is the first address just above the fe80::/10 range // (link-local spans fe80::-febf::). It is a deprecated site-local // address (RFC 3513), NOT link-local: // 0xfec0 & 0xffc0 == 0xfec0 != 0xfe80 - // The filter must keep it. This guards the upper boundary of fe80::/10. + // It is kept and, not being link-local, is not tagged as such by the UI. when(() => mockUsp.get(any())).thenAnswer((_) async { final paths = _.positionalArguments[0] as List; if (paths.any((p) => p.toString().contains('IPv6Address'))) { From cc9dae106dc9a53c2993e142b961a6617f8aa22c Mon Sep 17 00:00:00 2001 From: Austin Chang Date: Thu, 16 Jul 2026 13:15:49 +0800 Subject: [PATCH 2/2] 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 --- .../cards/fixtures/cards_test_data.dart | 29 ++++++++++++ .../localizations/usp_info_cards_test.dart | 8 ++++ .../devices/fixtures/devices_test_data.dart | 47 +++++++++++++++++++ .../usp_device_detail_view_test.dart | 8 ++++ .../topology/fixtures/topology_test_data.dart | 31 ++++++++++++ .../usp_node_detail_view_test.dart | 8 ++++ 6 files changed, 131 insertions(+) diff --git a/test/golden_test/page/dashboard/cards/fixtures/cards_test_data.dart b/test/golden_test/page/dashboard/cards/fixtures/cards_test_data.dart index 0943084ce..feb25e5bc 100644 --- a/test/golden_test/page/dashboard/cards/fixtures/cards_test_data.dart +++ b/test/golden_test/page/dashboard/cards/fixtures/cards_test_data.dart @@ -99,8 +99,22 @@ const testWanOffline = WanStatusUIModel( mtu: 1500, ); +// WAN whose upstream assigns no global/ULA prefix — only a link-local +// (fe80::/10) address. The card still shows it, tagged with a scope badge. +const testWanLinkLocalOnly = WanStatusUIModel( + isUp: true, + ipAddress: '10.92.12.87', + subnetMask: '255.255.255.0', + addressingType: 'DHCP', + mtu: 1500, + gateway: '10.92.12.1', + ipv6Enabled: true, + ipv6Addresses: ['fe80::7612:13ff:fe21:5502'], +); + final testWanOnlineData = WanData(model: testWanOnline); final testWanOfflineData = WanData(model: testWanOffline); +final testWanLinkLocalOnlyData = WanData(model: testWanLinkLocalOnly); // --------------------------------------------------------------------------- // LAN Info @@ -118,7 +132,22 @@ const testLanDhcpEnabled = LanInfoUIModel( ipv6Addresses: ['fd00::1'], ); +// LAN whose bridge holds only a link-local (fe80::/10) address — no global/ULA +// prefix. The card still shows it, tagged with a scope badge. +const testLanLinkLocalOnly = LanInfoUIModel( + ipAddress: '192.168.1.1', + subnetMask: '255.255.255.0', + dhcpEnabled: true, + minAddress: '192.168.1.100', + maxAddress: '192.168.1.200', + leaseTimeMinutes: 1440, + dnsServers: '8.8.8.8, 8.8.4.4', + ipv6Enabled: true, + ipv6Addresses: ['fe80::7612:13ff:fe21:5502'], +); + final testLanData = LanData(model: testLanDhcpEnabled); +final testLanLinkLocalOnlyData = LanData(model: testLanLinkLocalOnly); const testLanDhcpDisabled = LanInfoUIModel( ipAddress: '192.168.1.1', diff --git a/test/golden_test/page/dashboard/cards/localizations/usp_info_cards_test.dart b/test/golden_test/page/dashboard/cards/localizations/usp_info_cards_test.dart index 5abb229e4..492b2aedb 100644 --- a/test/golden_test/page/dashboard/cards/localizations/usp_info_cards_test.dart +++ b/test/golden_test/page/dashboard/cards/localizations/usp_info_cards_test.dart @@ -61,6 +61,10 @@ void main() { 'online_dhcp': (overrides) => overrides.addAll( cardOverrides(wanData: testWanOnlineData), ), + // WAN with only a link-local IPv6 — address shown with a scope badge. + 'link_local_ipv6': (overrides) => overrides.addAll( + cardOverrides(wanData: testWanLinkLocalOnlyData), + ), 'offline': (overrides) => overrides.addAll( cardOverrides(wanData: testWanOfflineData), ), @@ -81,6 +85,10 @@ void main() { 'dhcp_enabled': (overrides) => overrides.addAll( cardOverrides(lanData: testLanData), ), + // LAN with only a link-local IPv6 — address shown with a scope badge. + 'link_local_ipv6': (overrides) => overrides.addAll( + cardOverrides(lanData: testLanLinkLocalOnlyData), + ), 'dhcp_disabled': (overrides) => overrides.addAll( cardOverrides(lanData: testLanDisabledData), ), diff --git a/test/golden_test/page/devices/fixtures/devices_test_data.dart b/test/golden_test/page/devices/fixtures/devices_test_data.dart index 67e2570da..b6399af3a 100644 --- a/test/golden_test/page/devices/fixtures/devices_test_data.dart +++ b/test/golden_test/page/devices/fixtures/devices_test_data.dart @@ -122,4 +122,51 @@ DeviceDetailState get offlineDetail => DeviceDetailState( device: offlineDevice, ); +// Device with a global (routable) IPv6 address — shown without a scope badge. +final wifiDeviceGlobalIpv6 = ClientDevice( + mac: 'AA:BB:CC:DD:EE:06', + ip: '192.168.1.106', + hostName: 'Desktop-PC', + isActive: true, + connectionType: ConnectionType.wifi, + wifi: WifiConnectionInfo( + signalStrength: -42, + downlinkRate: 866000, + uplinkRate: 433000, + band: '5GHz', + ssidName: 'MyNetwork', + ), + ipv6Addresses: const ['2401:e180:8801:d79d::5'], + parentNodeId: 'node-1', + parentNodeName: 'Living Room', +); + +// Device whose only IPv6 address is link-local (fe80::/10) — shown with a +// scope badge in place of the leading icon. +final wifiDeviceLinkLocalIpv6 = ClientDevice( + mac: 'AA:BB:CC:DD:EE:07', + ip: '192.168.1.107', + hostName: 'Laptop', + isActive: true, + connectionType: ConnectionType.wifi, + wifi: WifiConnectionInfo( + signalStrength: -42, + downlinkRate: 866000, + uplinkRate: 433000, + band: '5GHz', + ssidName: 'MyNetwork', + ), + ipv6Addresses: const ['fe80::cd3:70da:d0a0:49cf'], + parentNodeId: 'node-1', + parentNodeName: 'Living Room', +); + DeviceDetailState get deviceNotFound => DeviceDetailState.empty(); + +DeviceDetailState get wifiDetailGlobalIpv6 => DeviceDetailState( + device: wifiDeviceGlobalIpv6, + ); + +DeviceDetailState get wifiDetailLinkLocalIpv6 => DeviceDetailState( + device: wifiDeviceLinkLocalIpv6, + ); diff --git a/test/golden_test/page/devices/localizations/usp_device_detail_view_test.dart b/test/golden_test/page/devices/localizations/usp_device_detail_view_test.dart index 6315ae39c..ad0a3d33d 100644 --- a/test/golden_test/page/devices/localizations/usp_device_detail_view_test.dart +++ b/test/golden_test/page/devices/localizations/usp_device_detail_view_test.dart @@ -25,6 +25,14 @@ void main() { 'wired_device': (overrides) => overrides.addAll( deviceDetailOverrides(detail: wiredDetail), ), + // IPv6 global address — rendered without a scope badge. + 'global_ipv6': (overrides) => overrides.addAll( + deviceDetailOverrides(detail: wifiDetailGlobalIpv6), + ), + // IPv6 link-local only — leading icon swapped for a scope badge. + 'link_local_ipv6': (overrides) => overrides.addAll( + deviceDetailOverrides(detail: wifiDetailLinkLocalIpv6), + ), 'offline_device': (overrides) => overrides.addAll( deviceDetailOverrides(detail: offlineDetail), ), diff --git a/test/golden_test/page/topology/fixtures/topology_test_data.dart b/test/golden_test/page/topology/fixtures/topology_test_data.dart index a311620d6..bf54655f9 100644 --- a/test/golden_test/page/topology/fixtures/topology_test_data.dart +++ b/test/golden_test/page/topology/fixtures/topology_test_data.dart @@ -170,4 +170,35 @@ final masterNodeEmptyDevices = UspNodeDetailState( connectedClients: [], ); +// Slave node with a global (routable) LAN IPv6 — shown without a scope badge. +final slaveNodeGlobalIpv6 = UspNodeDetailState( + node: SlaveNode( + deviceId: 'AA:BB:CC:DD:FF:02', + model: 'MX2000', + manufacturer: 'Linksys', + serialNumber: 'DEF789013', + softwareVersion: '1.0.10.200000', + ipv6Addresses: const ['2401:e180:8801:d79d::5'], + connectedClients: _meshSlaveClients, + backhaul: BackhaulInfo(mediaType: 'Wi-Fi', signalStrength: -50), + ), + connectedClients: _meshSlaveClients, +); + +// Slave node whose only LAN IPv6 is link-local (fe80::/10) — shown with a +// scope badge in place of the leading icon. +final slaveNodeLinkLocalIpv6 = UspNodeDetailState( + node: SlaveNode( + deviceId: 'AA:BB:CC:DD:FF:03', + model: 'MX2000', + manufacturer: 'Linksys', + serialNumber: 'DEF789014', + softwareVersion: '1.0.10.200000', + ipv6Addresses: const ['fe80::7612:13ff:fe21:5503'], + connectedClients: _meshSlaveClients, + backhaul: BackhaulInfo(mediaType: 'Wi-Fi', signalStrength: -50), + ), + connectedClients: _meshSlaveClients, +); + const nodeNotFoundState = UspNodeDetailState(); diff --git a/test/golden_test/page/topology/localizations/usp_node_detail_view_test.dart b/test/golden_test/page/topology/localizations/usp_node_detail_view_test.dart index a3d221f35..6e2696b73 100644 --- a/test/golden_test/page/topology/localizations/usp_node_detail_view_test.dart +++ b/test/golden_test/page/topology/localizations/usp_node_detail_view_test.dart @@ -18,6 +18,14 @@ void main() { 'slave_with_devices': (overrides) => overrides.addAll( nodeDetailOverrides(slaveNodeWithDevices), ), + // LAN IPv6 global address — rendered without a scope badge. + 'global_ipv6': (overrides) => overrides.addAll( + nodeDetailOverrides(slaveNodeGlobalIpv6), + ), + // LAN IPv6 link-local only — leading icon swapped for a scope badge. + 'link_local_ipv6': (overrides) => overrides.addAll( + nodeDetailOverrides(slaveNodeLinkLocalIpv6), + ), 'empty_devices': (overrides) => overrides.addAll( nodeDetailOverrides(masterNodeEmptyDevices), ),