You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
meshcore-client/lib/screens/topology_debug_screen.dart

314 lines
9.8 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../helpers/snack_bar_builder.dart';
import '../models/contact.dart';
import '../services/mesh_topology_service.dart';
import '../widgets/adaptive_app_bar_title.dart';
/// Read-only diagnostic view of the passively-learned mesh topology (#196 /
/// epic #186). It answers one question on real hardware: is
/// [MeshTopologyService] actually ingesting? Node/edge counts climb as routed
/// packets arrive; stuck at zero while traffic flows means the RX ingestion
/// wiring is dead. Strings are hardcoded EN on purpose — this is a dev-facing
/// diagnostic, deliberately not part of the localized UX surface.
class TopologyDebugScreen extends StatelessWidget {
const TopologyDebugScreen({super.key});
@override
Widget build(BuildContext context) {
final contacts = context.read<MeshCoreConnector>().contacts;
return Consumer<MeshTopologyService>(
builder: (context, topo, _) {
final now = topo.clockSeconds;
final self = topo.self;
final nodes = topo.nodes.toList()
..sort((a, b) => b.lastSeen.compareTo(a.lastSeen));
final edges = topo.edges.toList()
..sort((a, b) => b.lastSeen.compareTo(a.lastSeen));
final hasData = nodes.isNotEmpty || edges.isNotEmpty;
return Scaffold(
appBar: AppBar(
title: const AdaptiveAppBarTitle('Mesh topology'),
centerTitle: true,
actions: [
IconButton(
tooltip: 'Copy dump',
icon: const Icon(Icons.copy),
onPressed: !hasData
? null
: () async {
await Clipboard.setData(
ClipboardData(text: _dump(topo, contacts, now)),
);
if (!context.mounted) return;
showDismissibleSnackBar(
context,
content: const Text('Topology copied'),
);
},
),
],
),
body: SafeArea(
top: false,
child: ListView(
children: [
_summary(context, topo, self, contacts),
const Divider(height: 1),
if (nodes.isEmpty)
_empty(context)
else ...[
_sectionHeader(context, 'Nodes (${nodes.length})'),
for (final n in nodes)
_nodeTile(context, n, self, contacts, now),
const Divider(height: 1),
_sectionHeader(context, 'Edges (${edges.length})'),
for (final e in edges) _edgeTile(context, e, contacts, now),
],
],
),
),
);
},
);
}
Widget _summary(
BuildContext context,
MeshTopologyService topo,
Uint8List? self,
List<Contact> contacts,
) {
final selfLabel = self == null
? 'not set yet — waiting for first packet'
: (_nameOf(self, contacts) == null
? _hex(self)
: '${_nameOf(self, contacts)} (${_hex(self)})');
return Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
_stat(context, '${topo.nodeCount}', 'nodes'),
_stat(context, '${topo.edgeCount}', 'edges'),
_stat(context, '${topo.freshnessSeconds ~/ 86400}d', 'freshness'),
],
),
const SizedBox(height: 10),
Text(
'You: $selfLabel',
style: TextStyle(
fontSize: 12,
fontFamily: 'monospace',
color: Colors.grey[700],
),
),
const SizedBox(height: 6),
Text(
'Passive map of routed-packet paths. Counts climb as traffic '
'arrives; stuck at 0 while messages flow means ingestion is dead.',
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
),
],
),
);
}
Widget _stat(BuildContext context, String value, String label) {
return Expanded(
child: Column(
children: [
Text(
value,
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
),
Text(label, style: TextStyle(fontSize: 11, color: Colors.grey[600])),
],
),
);
}
Widget _sectionHeader(BuildContext context, String text) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(
text,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold),
),
);
}
Widget _nodeTile(
BuildContext context,
TopoNode n,
Uint8List? self,
List<Contact> contacts,
int now,
) {
final hex = _hex(n.prefix);
final isSelf = self != null && hex == _hex(self);
final name = isSelf ? 'You' : _nameOf(n.prefix, contacts);
final snr = n.neighbourSnr;
final meta = <String>[
'seen ×${n.seenCount}',
_age(n.lastSeen, now),
if (snr != null) 'SNR ${snr.toStringAsFixed(1)}',
].join(' · ');
return ListTile(
dense: true,
leading: Icon(
isSelf
? Icons.my_location
: (snr != null ? Icons.cell_tower : Icons.hub_outlined),
size: 18,
color: isSelf
? Colors.blue
: (snr != null ? Colors.green : Colors.grey),
),
title: Text(
name ?? hex,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
),
subtitle: Text(
name != null ? '$hex · $meta' : meta,
style: TextStyle(
fontSize: 11,
fontFamily: 'monospace',
color: Colors.grey[600],
),
),
);
}
Widget _edgeTile(
BuildContext context,
TopoEdge e,
List<Contact> contacts,
int now,
) {
final a = _nameOf(e.a, contacts) ?? _hex(e.a);
final b = _nameOf(e.b, contacts) ?? _hex(e.b);
final failed = e.lastFailed != null;
final meta = <String>[
'×${e.count}',
_age(e.lastSeen, now),
if (failed) 'failed ${_age(e.lastFailed!, now)}',
].join(' · ');
return ListTile(
dense: true,
leading: Icon(
failed ? Icons.link_off : Icons.link,
size: 18,
color: failed ? Colors.orange : Colors.grey,
),
title: Text('$a ~ $b', style: const TextStyle(fontSize: 13)),
subtitle: Text(
meta,
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
),
);
}
Widget _empty(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 48, horizontal: 24),
child: Column(
children: [
Icon(Icons.hub_outlined, size: 56, color: Colors.grey[400]),
const SizedBox(height: 12),
Text(
'No topology learned yet',
style: TextStyle(fontSize: 15, color: Colors.grey[600]),
),
const SizedBox(height: 6),
Text(
'Listening — this fills as routed packets arrive from the mesh. '
'Leave it connected a few minutes.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 12, color: Colors.grey[500]),
),
],
),
);
}
// ── pure helpers (also used by the clipboard dump) ──────────────────────
static String? _nameOf(List<int> prefix, List<Contact> contacts) {
final matches = contacts
.where(
(c) =>
c.publicKey.length >= prefix.length &&
_startsWith(c.publicKey, prefix) &&
(c.type == advTypeRepeater || c.type == advTypeRoom),
)
.toList();
if (matches.isEmpty) return null;
if (matches.length == 1) return matches.first.name;
return matches.map((c) => c.name).join(' | ');
}
static bool _startsWith(List<int> pubkey, List<int> prefix) {
for (var i = 0; i < prefix.length; i++) {
if (pubkey[i] != prefix[i]) return false;
}
return true;
}
static String _hex(List<int> b) =>
b.map((x) => x.toRadixString(16).padLeft(2, '0').toUpperCase()).join();
static String _age(int lastSeen, int now) {
final s = now - lastSeen;
if (s < 0) return 'now';
if (s < 60) return '${s}s ago';
if (s < 3600) return '${s ~/ 60}m ago';
if (s < 86400) return '${s ~/ 3600}h ago';
return '${s ~/ 86400}d ago';
}
static String _dump(
MeshTopologyService topo,
List<Contact> contacts,
int now,
) {
final self = topo.self;
final buf = StringBuffer()
..writeln(
'Mesh topology — ${topo.nodeCount} nodes, ${topo.edgeCount} edges',
)
..writeln('self: ${self == null ? "(unset)" : _hex(self)}')
..writeln('freshness: ${topo.freshnessSeconds ~/ 86400}d')
..writeln('')
..writeln('NODES:');
for (final n
in topo.nodes.toList()
..sort((a, b) => b.lastSeen.compareTo(a.lastSeen))) {
final nm = _nameOf(n.prefix, contacts);
final snr = n.neighbourSnr;
buf.writeln(
' ${_hex(n.prefix)}${nm != null ? " ($nm)" : ""} '
'seen×${n.seenCount} ${_age(n.lastSeen, now)}'
'${snr != null ? " SNR ${snr.toStringAsFixed(1)}" : ""}',
);
}
buf.writeln('EDGES:');
for (final e
in topo.edges.toList()
..sort((a, b) => b.lastSeen.compareTo(a.lastSeen))) {
buf.writeln(
' ${_hex(e.a)} ~ ${_hex(e.b)} ×${e.count} '
'${_age(e.lastSeen, now)}${e.lastFailed != null ? " FAILED" : ""}',
);
}
return buf.toString();
}
}

Powered by TurnKey Linux.