Skip to content

Commit 6a61478

Browse files
committed
feat(nodes): add operational health badge to node detail and list views
- Introduce NodeHealthBadge widget showing health state (fresh/stale/offline/unknown) based on last heard timestamp - Display the badge on node detail screen (as a pill) and on node list cards - Add translations for health labels in English, German, and Spanish - Only shown for nodes not owned by the user; secondary to the existing online/presence indicator
1 parent d086fa6 commit 6a61478

26 files changed

Lines changed: 543 additions & 7 deletions

lib/core/health/node_health.dart

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
// SPDX-FileCopyrightText: 2025-2026 gotnull (developer@socialmesh.app)
3+
import '../../models/presence_confidence.dart';
4+
import '../../utils/timestamp_validation.dart';
5+
6+
/// Operational health of a node for SiteOps visibility.
7+
///
8+
/// This is a coarse, protocol-agnostic projection used by site operators to
9+
/// answer "is this node reporting, overdue, presumed down, or never seen?".
10+
/// It is deliberately distinct from the social [PresenceConfidence] tiers
11+
/// (active / fading / stale / unknown) and does not change any presence
12+
/// behaviour, labels, chips, filters, or sorting.
13+
enum NodeHealthState {
14+
/// Reporting normally — heard within [PresenceThresholds.freshWindow].
15+
fresh,
16+
17+
/// Overdue — heard within the 2 h online window but past the fresh window.
18+
stale,
19+
20+
/// Presumed down — last heard beyond the 2 h online window.
21+
offline,
22+
23+
/// Never heard, or an implausible / out-of-order timestamp.
24+
unknown,
25+
}
26+
27+
/// Classify a node's operational health from its most recent activity
28+
/// timestamp ([lastActivity]) relative to [now].
29+
///
30+
/// - [NodeHealthState.unknown] when [lastActivity] is null, implausible
31+
/// (per [TimestampValidation.isPlausible]), or yields a negative age.
32+
/// - [NodeHealthState.fresh] when age <= [PresenceThresholds.freshWindow]
33+
/// (15 minutes).
34+
/// - [NodeHealthState.stale] when age <= [PresenceThresholds.onlineWindow]
35+
/// (2 hours).
36+
/// - [NodeHealthState.offline] otherwise.
37+
///
38+
/// The offline boundary reuses [PresenceThresholds.onlineWindow] so this
39+
/// projection stays consistent with the existing online/offline line.
40+
NodeHealthState classifyHealth({
41+
required DateTime? lastActivity,
42+
required DateTime now,
43+
}) {
44+
if (lastActivity == null) return NodeHealthState.unknown;
45+
if (!TimestampValidation.isPlausible(lastActivity, referenceTime: now)) {
46+
return NodeHealthState.unknown;
47+
}
48+
final age = now.difference(lastActivity);
49+
if (age.isNegative) return NodeHealthState.unknown;
50+
if (age <= PresenceThresholds.freshWindow) return NodeHealthState.fresh;
51+
if (age <= PresenceThresholds.onlineWindow) return NodeHealthState.stale;
52+
return NodeHealthState.offline;
53+
}

lib/features/nodes/node_detail_screen.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import '../telemetry/position_log_screen.dart';
5454
import '../telemetry/traceroute_log_screen.dart';
5555
import 'node_actions.dart';
5656
import 'widgets/fixed_position_sheet.dart';
57+
import 'widgets/node_health_badge.dart';
5758

5859
/// Navigates to the node detail screen. Can be called from any screen.
5960
void showNodeDetails(BuildContext context, MeshNode node, bool isMyNode) {
@@ -783,6 +784,13 @@ class _NodeDetailScreenState extends ConsumerState<NodeDetailScreen>
783784
label: context.l10n.nodeDetailFavoriteBadge,
784785
color: AppTheme.warningYellow,
785786
),
787+
// SiteOps operational health (secondary; does not replace the
788+
// presence/online indicator).
789+
if (!isMyNode)
790+
_BadgePill(
791+
label: NodeHealthBadge.labelFor(context, node.healthState),
792+
color: NodeHealthBadge.colorFor(node.healthState),
793+
),
786794
],
787795
),
788796
const SizedBox(height: AppTheme.spacing16),

lib/features/nodes/nodes_screen.dart

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import '../navigation/main_shell.dart';
4040
import 'node_detail_screen.dart';
4141
import 'node_quick_actions_sheet.dart';
4242
import 'role_filter.dart';
43+
import 'widgets/node_health_badge.dart';
4344
import 'widgets/nodes_legend_sheet.dart';
4445

4546
class NodesScreen extends ConsumerStatefulWidget {
@@ -1515,6 +1516,12 @@ class _NodeCard extends ConsumerWidget {
15151516
),
15161517
],
15171518
),
1519+
// SiteOps operational health badge (secondary to the presence
1520+
// label above; does not replace it).
1521+
if (!isMyNode) ...[
1522+
SizedBox(height: AppTheme.spacing6),
1523+
NodeHealthBadge(state: node.healthState),
1524+
],
15181525
SizedBox(height: AppTheme.spacing4),
15191526
// Last heard
15201527
if (TimestampValidation.isPlausible(node.lastHeard)) ...[
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
// SPDX-FileCopyrightText: 2025-2026 gotnull (developer@socialmesh.app)
3+
import 'package:flutter/material.dart';
4+
5+
import '../../../core/health/node_health.dart';
6+
import '../../../core/l10n/l10n_extension.dart';
7+
import '../../../core/theme.dart';
8+
9+
/// Small, visually-secondary operational health badge for SiteOps node
10+
/// visibility (fresh / stale / offline / unknown). Coexists with — and does
11+
/// not replace — the social presence label.
12+
class NodeHealthBadge extends StatelessWidget {
13+
const NodeHealthBadge({super.key, required this.state});
14+
15+
final NodeHealthState state;
16+
17+
@override
18+
Widget build(BuildContext context) {
19+
final color = colorFor(state);
20+
return Container(
21+
padding: const EdgeInsets.symmetric(
22+
horizontal: AppTheme.spacing8,
23+
vertical: AppTheme.spacing2,
24+
),
25+
decoration: BoxDecoration(
26+
color: color.withValues(alpha: 0.12),
27+
borderRadius: BorderRadius.circular(AppTheme.radius6),
28+
border: Border.all(color: color.withValues(alpha: 0.5)),
29+
),
30+
child: Text(
31+
labelFor(context, state),
32+
style: Theme.of(context).textTheme.labelSmall?.copyWith(
33+
color: color,
34+
fontWeight: FontWeight.w600,
35+
letterSpacing: 0.3,
36+
),
37+
),
38+
);
39+
}
40+
41+
/// Canonical state -> color mapping, shared with other health surfaces
42+
/// (e.g. the node-detail badges row) so the mapping has one home.
43+
static Color colorFor(NodeHealthState state) {
44+
switch (state) {
45+
case NodeHealthState.fresh:
46+
return SemanticColors.success;
47+
case NodeHealthState.stale:
48+
return SemanticColors.warning;
49+
case NodeHealthState.offline:
50+
return SemanticColors.error;
51+
case NodeHealthState.unknown:
52+
return SemanticColors.muted;
53+
}
54+
}
55+
56+
/// Canonical state -> operational label mapping (localized).
57+
static String labelFor(BuildContext context, NodeHealthState state) {
58+
switch (state) {
59+
case NodeHealthState.fresh:
60+
return context.l10n.nodeHealthFresh;
61+
case NodeHealthState.stale:
62+
return context.l10n.nodeHealthStale;
63+
case NodeHealthState.offline:
64+
return context.l10n.nodeHealthOffline;
65+
case NodeHealthState.unknown:
66+
return context.l10n.nodeHealthUnknown;
67+
}
68+
}
69+
}

lib/l10n/app_de.arb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13297,5 +13297,9 @@
1329713297
"fixedPositionSheetUsePhoneLocation": "Standort meines Telefons verwenden",
1329813298
"fixedPositionSheetGettingPhoneLocation": "Telefonstandort wird ermittelt…",
1329913299
"fixedPositionSheetSubmit": "Position festlegen",
13300-
"fixedPositionSheetInvalid": "Gib eine gültige Breite (-90 bis 90) und Länge (-180 bis 180) ein."
13300+
"fixedPositionSheetInvalid": "Gib eine gültige Breite (-90 bis 90) und Länge (-180 bis 180) ein.",
13301+
"nodeHealthFresh": "Aktuell",
13302+
"nodeHealthStale": "Veraltet",
13303+
"nodeHealthOffline": "Offline",
13304+
"nodeHealthUnknown": "Unbekannt"
1330113305
}

lib/l10n/app_en.arb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59459,5 +59459,21 @@
5945959459
"fixedPositionSheetInvalid": "Enter a valid latitude (-90 to 90) and longitude (-180 to 180).",
5946059460
"@fixedPositionSheetInvalid": {
5946159461
"description": "Validation error shown when entered coordinates are out of range."
59462+
},
59463+
"nodeHealthFresh": "Fresh",
59464+
"@nodeHealthFresh": {
59465+
"description": "Operational node-health badge: node reporting normally (heard within 15 minutes)."
59466+
},
59467+
"nodeHealthStale": "Stale",
59468+
"@nodeHealthStale": {
59469+
"description": "Operational node-health badge: node overdue (heard within 2 hours but past the fresh window)."
59470+
},
59471+
"nodeHealthOffline": "Offline",
59472+
"@nodeHealthOffline": {
59473+
"description": "Operational node-health badge: node presumed down (not heard within 2 hours)."
59474+
},
59475+
"nodeHealthUnknown": "Unknown",
59476+
"@nodeHealthUnknown": {
59477+
"description": "Operational node-health badge: node never heard or has an implausible timestamp."
5946259478
}
5946359479
}

lib/l10n/app_es.arb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13297,5 +13297,9 @@
1329713297
"fixedPositionSheetUsePhoneLocation": "Usar la ubicación de mi teléfono",
1329813298
"fixedPositionSheetGettingPhoneLocation": "Obteniendo la ubicación del teléfono…",
1329913299
"fixedPositionSheetSubmit": "Establecer posición",
13300-
"fixedPositionSheetInvalid": "Introduce una latitud válida (-90 a 90) y una longitud válida (-180 a 180)."
13300+
"fixedPositionSheetInvalid": "Introduce una latitud válida (-90 a 90) y una longitud válida (-180 a 180).",
13301+
"nodeHealthFresh": "Reciente",
13302+
"nodeHealthStale": "Desactualizado",
13303+
"nodeHealthOffline": "Sin conexión",
13304+
"nodeHealthUnknown": "Desconocido"
1330113305
}

lib/l10n/app_fr.arb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13297,5 +13297,9 @@
1329713297
"fixedPositionSheetUsePhoneLocation": "Utiliser la position de mon téléphone",
1329813298
"fixedPositionSheetGettingPhoneLocation": "Récupération de la position du téléphone…",
1329913299
"fixedPositionSheetSubmit": "Définir la position",
13300-
"fixedPositionSheetInvalid": "Saisissez une latitude valide (-90 à 90) et une longitude valide (-180 à 180)."
13300+
"fixedPositionSheetInvalid": "Saisissez une latitude valide (-90 à 90) et une longitude valide (-180 à 180).",
13301+
"nodeHealthFresh": "Récent",
13302+
"nodeHealthStale": "Obsolète",
13303+
"nodeHealthOffline": "Hors ligne",
13304+
"nodeHealthUnknown": "Inconnu"
1330113305
}

lib/l10n/app_it.arb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46367,5 +46367,9 @@
4636746367
"fixedPositionSheetUsePhoneLocation": "Usa la posizione del telefono",
4636846368
"fixedPositionSheetGettingPhoneLocation": "Recupero posizione del telefono…",
4636946369
"fixedPositionSheetSubmit": "Imposta posizione",
46370-
"fixedPositionSheetInvalid": "Inserisci una latitudine valida (da -90 a 90) e una longitudine valida (da -180 a 180)."
46370+
"fixedPositionSheetInvalid": "Inserisci una latitudine valida (da -90 a 90) e una longitudine valida (da -180 a 180).",
46371+
"nodeHealthFresh": "Recente",
46372+
"nodeHealthStale": "Obsoleto",
46373+
"nodeHealthOffline": "Offline",
46374+
"nodeHealthUnknown": "Sconosciuto"
4637146375
}

lib/l10n/app_localizations.dart

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77812,6 +77812,30 @@ abstract class AppLocalizations {
7781277812
/// In en, this message translates to:
7781377813
/// **'Enter a valid latitude (-90 to 90) and longitude (-180 to 180).'**
7781477814
String get fixedPositionSheetInvalid;
77815+
77816+
/// Operational node-health badge: node reporting normally (heard within 15 minutes).
77817+
///
77818+
/// In en, this message translates to:
77819+
/// **'Fresh'**
77820+
String get nodeHealthFresh;
77821+
77822+
/// Operational node-health badge: node overdue (heard within 2 hours but past the fresh window).
77823+
///
77824+
/// In en, this message translates to:
77825+
/// **'Stale'**
77826+
String get nodeHealthStale;
77827+
77828+
/// Operational node-health badge: node presumed down (not heard within 2 hours).
77829+
///
77830+
/// In en, this message translates to:
77831+
/// **'Offline'**
77832+
String get nodeHealthOffline;
77833+
77834+
/// Operational node-health badge: node never heard or has an implausible timestamp.
77835+
///
77836+
/// In en, this message translates to:
77837+
/// **'Unknown'**
77838+
String get nodeHealthUnknown;
7781577839
}
7781677840

7781777841
class _AppLocalizationsDelegate

0 commit comments

Comments
 (0)