diff --git a/app/lib/apps/notifications/notifications_user_data.dart b/app/lib/apps/notifications/notifications_user_data.dart index 944e76b5..b19637e1 100644 --- a/app/lib/apps/notifications/notifications_user_data.dart +++ b/app/lib/apps/notifications/notifications_user_data.dart @@ -1,6 +1,7 @@ import 'package:shared_preferences/shared_preferences.dart'; const String nodeStatusNotificationEnabledKey = 'nodeStatusNotificationEnabled'; +const String nodeWorkloadNotificationEnabledKey = 'nodeWorkloadNotificationEnabled'; const String _contractNotificationsEnabledKey = 'contract_notifications_enabled'; @@ -29,3 +30,13 @@ Future setContractNotificationEnabled(bool enabled) async { final prefs = await SharedPreferences.getInstance(); await prefs.setBool(_contractNotificationsEnabledKey, enabled); } + +Future isWorkloadNotificationEnabled() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(nodeWorkloadNotificationEnabledKey) ?? true; +} + +Future setWorkloadNotificationEnabled(bool enabled) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(nodeWorkloadNotificationEnabledKey, enabled); +} diff --git a/app/lib/models/wallet.dart b/app/lib/models/wallet.dart index 28eaaec3..79327541 100644 --- a/app/lib/models/wallet.dart +++ b/app/lib/models/wallet.dart @@ -17,6 +17,7 @@ class Wallet { required this.tfchainBalance, required this.type, required this.verificationStatus, + required this.twinId, }); String name; final String stellarSecret; @@ -27,6 +28,7 @@ class Wallet { String tfchainBalance; final WalletType type; VerificationState verificationStatus; + int twinId = 0; } class PkidWallet { diff --git a/app/lib/screens/notifications_screen.dart b/app/lib/screens/notifications_screen.dart index 93e2ad24..d679147b 100644 --- a/app/lib/screens/notifications_screen.dart +++ b/app/lib/screens/notifications_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:threebotlogin/apps/notifications/notifications_user_data.dart'; +import 'package:threebotlogin/services/nodes_check_service.dart'; import 'package:threebotlogin/widgets/layout_drawer.dart'; class NotificationsScreen extends StatefulWidget { @@ -13,6 +14,7 @@ class _NotificationsScreenState extends State { bool loading = true; late bool _nodeStatusNotificationEnabled; late bool _contractNotificationsEnabled; + late bool _workloadNotificationEnabled; @override void initState() { @@ -22,13 +24,16 @@ class _NotificationsScreenState extends State { void _loadNotificationPreferences() async { final bool nodeEnabled = await isNodeStatusNotificationEnabled(); - final bool contractEnabled = + final bool contractEnabled = await isContractNotificationEnabled(); + final bool nodeWorkloadStatusEnabled = await isContractNotificationEnabled(); setState(() { _nodeStatusNotificationEnabled = nodeEnabled; _contractNotificationsEnabled = contractEnabled; + _workloadNotificationEnabled = nodeWorkloadStatusEnabled; loading = false; }); + await NodeCheckService.pingWorkloadNodes(); } @override @@ -85,6 +90,18 @@ class _NotificationsScreenState extends State { }, secondary: const Icon(Icons.description), ), + SwitchListTile( + title: + const Text('Enable workload node status notifications'), + value: _workloadNotificationEnabled, + onChanged: (bool newValue) { + setState(() { + _workloadNotificationEnabled = newValue; + }); + setWorkloadNotificationEnabled(newValue); + }, + secondary: const Icon(Icons.monitor_heart), + ), ], ), ); diff --git a/app/lib/services/background_service.dart b/app/lib/services/background_service.dart index 5519c8dc..698455bb 100644 --- a/app/lib/services/background_service.dart +++ b/app/lib/services/background_service.dart @@ -24,10 +24,11 @@ void backgroundFetchHeadlessTask(HeadlessTask task) async { logger.i( '[BackgroundFetch] Headless Task: $taskId started. Time: ${DateTime.now()}'); - // Run contract and node checks concurrently + // Run checks concurrently await Future.wait([ _checkContractsAndNotify(container, taskId), - _checkNodesAndNotify(taskId), + _checkMyNodesAndNotify(taskId), + _checkWorkloadNodesAndNotify(taskId), ]); } catch (e, stack) { logger.e('[BackgroundFetch] Error during task $taskId: $e', @@ -76,7 +77,7 @@ Future _checkContractsAndNotify( } } -Future _checkNodesAndNotify(String taskId) async { +Future _checkMyNodesAndNotify(String taskId) async { try { final bool nodeNotificationsEnabled = await isNodeStatusNotificationEnabled(); @@ -85,14 +86,14 @@ Future _checkNodesAndNotify(String taskId) async { if (!nodeNotificationsEnabled) { logger.i( - '[NodesCheck] Node notifications are disabled by user setting. Exiting _checkNodesAndNotify for task $taskId.'); + '[NodesCheck] Node notifications are disabled by user setting. Exiting _checkMyNodesAndNotify for task $taskId.'); return; } - final offlineNodes = await NodeCheckService.pingNodesInBackground(); + final offlineNodes = await NodeCheckService.pingMyNodes(); if (offlineNodes.isEmpty) { logger.i( - '[NodesCheck] No raw offline nodes found from pingNodesInBackground(). Exiting _checkNodesAndNotify for task $taskId.'); + '[NodesCheck] No raw offline nodes found from pingMyNodes(). Exiting _checkMyNodesAndNotify for task $taskId.'); return; } logger.i( @@ -143,6 +144,58 @@ Future _checkNodesAndNotify(String taskId) async { } } +Future _checkWorkloadNodesAndNotify(String taskId) async { + try { + final bool nodeNotificationsEnabled = await isWorkloadNotificationEnabled(); + logger.i( + '[NodesCheck] Workload Node Notifications Enabled: $nodeNotificationsEnabled for task $taskId'); + + if (!nodeNotificationsEnabled) { + logger.i( + '[NodesCheck] Node notifications are disabled by user setting. Exiting _checkWorkloadNodesAndNotify for task $taskId.'); + return; + } + + final offlineNodeIds = await NodeCheckService.pingWorkloadNodes(); + if (offlineNodeIds.isEmpty) { + logger.i( + '[NodesCheck] No workload offline nodes found from pingWorkloadNodes(). Exiting _checkWorkloadNodesAndNotify for task $taskId.'); + return; + } + logger.i( + '[NodesCheck] Found ${offlineNodeIds.length} workload offline nodes for task $taskId.'); + + final StringBuffer bodyBuffer = StringBuffer(); + final List nodesToNotify = []; + + // Fetch node details in parallel + final nodes = await Future.wait( + offlineNodeIds.map((id) => NodeCheckService.fetchNodeStatus(id))); + + for (final node in nodes) { + nodesToNotify.add(node.nodeId); + bodyBuffer.writeln('You have workloads on offline Node ${node.nodeId}'); + } + + if (nodesToNotify.isEmpty) return; + + await NotificationService().showNotification( + id: 'offline_workload_nodes_alert', + title: nodesToNotify.length == 1 + ? 'Workload Node Alert 🚨' + : '${nodesToNotify.length} Workload Nodes Offline 🚨', + body: bodyBuffer.toString().trim(), + groupKey: 'offline_workload_nodes', + ); + } catch (e, stack) { + logger.e( + '[WorkloadNodesCheck] Error in workload node check for task $taskId: $e', + error: e, + stackTrace: stack); + rethrow; + } +} + Duration _getCheckInterval(Duration downtime) { if (downtime < const Duration(hours: 1)) { return const Duration(minutes: 15); // 0-1 hour: check every 15 min diff --git a/app/lib/services/contract_check_service.dart b/app/lib/services/contract_check_service.dart index 416a1160..c06398c5 100644 --- a/app/lib/services/contract_check_service.dart +++ b/app/lib/services/contract_check_service.dart @@ -29,7 +29,7 @@ class ContractCheckService { final twinId = await getTwinId(w.tfchainSecret); if (twinId != 0) { List contracts = - await getGracePeriodContractsByTwinId(twinId); + await getContracts(twinId, [ContractState.GracePeriod]); allContracts.addAll(contracts); } } diff --git a/app/lib/services/gridproxy_service.dart b/app/lib/services/gridproxy_service.dart index f6626457..d4d808fc 100644 --- a/app/lib/services/gridproxy_service.dart +++ b/app/lib/services/gridproxy_service.dart @@ -67,15 +67,28 @@ Future isFarmNameAvailable(String name) async { } } -Future> getGracePeriodContractsByTwinId(int twinId) async { +Future> getContracts( + int twinId, List state) async { try { initializeReflectable(); final gridproxyUrl = Globals().gridproxyUrl; GridProxyClient client = GridProxyClient(gridproxyUrl); - final contracts = - await client.contracts.list(ContractInfoQueryParams(twin_id: twinId, state: ContractState.GracePeriod)); + final contracts = await client.contracts.list(ContractInfoQueryParams( + twin_id: twinId, state: state, type: ContractTypes.node)); return contracts; } catch (e) { throw Exception('Failed to get contracts due to $e'); } } + +Future getNodeById(int id) async { + try { + initializeReflectable(); + final gridproxyUrl = Globals().gridproxyUrl; + GridProxyClient client = GridProxyClient(gridproxyUrl); + final node = await client.nodes.getById(nodeID: id); + return node; + } catch (e) { + throw Exception('Failed to get node due to $e'); + } +} diff --git a/app/lib/services/nodes_check_service.dart b/app/lib/services/nodes_check_service.dart index ca618d92..44900eb1 100644 --- a/app/lib/services/nodes_check_service.dart +++ b/app/lib/services/nodes_check_service.dart @@ -1,33 +1,25 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gridproxy_client/models/contracts.dart'; import 'package:threebotlogin/helpers/logger.dart'; import 'package:threebotlogin/models/farm.dart'; import 'package:threebotlogin/models/wallet.dart'; import 'package:threebotlogin/providers/wallets_provider.dart'; import 'package:threebotlogin/services/gridproxy_service.dart'; -import 'package:threebotlogin/services/tfchain_service.dart'; class NodeCheckService { - static Future> pingNodesInBackground() async { + static Future> pingMyNodes() async { final container = ProviderContainer(); try { - final walletsNotifierInstance = container.read(walletsNotifier.notifier); - - await walletsNotifierInstance.waitUntilListed(); - + await container.read(walletsNotifier.notifier).waitUntilListed(); final List wallets = container.read(walletsNotifier); - final Map twinIdWallets = {}; - for (final wallet in wallets) { - final twinId = await getTwinId(wallet.tfchainSecret); - if (twinId != 0) { - twinIdWallets[twinId] = wallet; - } - } - final farmsList = await getFarmsByTwinIds(twinIdWallets.keys.toList()); + if (wallets.isEmpty) return []; + + final twinIds = wallets.map((w) => w.twinId).toList(); + final farmsList = await getFarmsByTwinIds(twinIds); final allNodes = []; for (final farm in farmsList) { final nodesData = await getNodesByFarmId(farm.farmID); - final nodes = nodesData .map((node) => Node( nodeId: node.nodeId, @@ -41,11 +33,36 @@ class NodeCheckService { .toList(); allNodes.addAll(nodes); } - final offlineNodes = - allNodes.where((n) => n.status != NodeStatus.Up).toList(); + final offlineNodes = + allNodes.where((n) => n.status == NodeStatus.Down).toList(); return offlineNodes; + } catch (e) { + logger.e('[NodeCheckService] Error: $e'); + return []; + } finally { + container.dispose(); + } + } + + static Future> pingWorkloadNodes() async { + final container = ProviderContainer(); + try { + await container.read(walletsNotifier.notifier).waitUntilListed(); + final wallets = container.read(walletsNotifier); + + // Get unique node IDs from all wallet contracts + final nodeIds = (await Future.wait(wallets.map((w) => getContracts( + w.twinId, [ContractState.Created, ContractState.GracePeriod])))) + .expand((contracts) => contracts) + .map((c) => c.nodeId) + .whereType() + .toSet() + .toList(); + if (nodeIds.isEmpty) return []; + + return nodeIds.isEmpty ? [] : await _getOfflineNodes(nodeIds); } catch (e) { logger.e('[NodeCheckService] Error: $e'); return []; @@ -53,4 +70,24 @@ class NodeCheckService { container.dispose(); } } + + static Future> _getOfflineNodes(List nodeIds) async { + final nodes = await Future.wait(nodeIds.map(fetchNodeStatus)); + return nodes + .where((n) => n.status == NodeStatus.Down) + .map((n) => n.nodeId) + .toList(); + } + + static Future fetchNodeStatus(int nodeId) async { + final nodeData = await getNodeById(nodeId); + return Node( + nodeId: nodeId, + status: NodeStatus.values.firstWhere( + (e) => + e.toString().split('.').last.toLowerCase() == + nodeData.status.toLowerCase(), + ), + ); + } } diff --git a/app/lib/services/notification_service.dart b/app/lib/services/notification_service.dart index e9d827fe..65d2db61 100644 --- a/app/lib/services/notification_service.dart +++ b/app/lib/services/notification_service.dart @@ -39,9 +39,10 @@ class NotificationService { initializationSettings, onDidReceiveNotificationResponse: (NotificationResponse notificationResponse) async { - if (notificationResponse.payload != null) { - // Ensure the app is brought to foreground - await _flutterLocalNotificationsPlugin.cancelAll(); + if (notificationResponse.payload != null && + notificationResponse.id != null) { + await _flutterLocalNotificationsPlugin + .cancel(notificationResponse.id!); NotificationService._handleNotificationTapStatic( notificationResponse.payload!); } @@ -112,28 +113,17 @@ class NotificationService { // Wait for app to be in foreground if needed await Future.delayed(const Duration(milliseconds: 500)); - if (navigatorKey.currentContext == null) { - logger.w( - '[NotificationService Static] Context is null, retrying in 1 second...'); - await Future.delayed(const Duration(seconds: 1)); - } - if (navigatorKey.currentContext != null) { - if (groupKey == 'contract_alerts') { + if (groupKey == 'contract_alerts' || + groupKey == 'offline_nodes' || + groupKey == 'offline_workload_nodes') { await NotificationService.showContractAlertDialog( title: title, body: body, ); - } else if (groupKey == 'offline_nodes') { - await NotificationService.showNodeAlertDialog( - title: title, - body: body, - ); } - } else { - logger.w( - '[NotificationService Static] Could not get valid context after retry'); } + return; } catch (e, stack) { logger.e( '[NotificationService Static] Error handling notification tap: $e', diff --git a/app/lib/services/wallet_service.dart b/app/lib/services/wallet_service.dart index eca977a5..ca4ba1c3 100644 --- a/app/lib/services/wallet_service.dart +++ b/app/lib/services/wallet_service.dart @@ -125,6 +125,7 @@ Future loadWallet(String walletName, String walletSeed, final kycVerified = await getVerificationStatus( address: tfchainClient.keypair!.address, idenfyServiceUrl: idenfyServiceUrl); + final twinId = await TFChainService.getTwinIdByClient(tfchainClient); final wallet = Wallet( name: walletName, stellarSecret: stellarClient.secretSeed, @@ -135,6 +136,7 @@ Future loadWallet(String walletName, String walletSeed, tfchainBalance: tfchainBalance, type: walletType, verificationStatus: kycVerified.status, + twinId: twinId, ); return wallet; } diff --git a/app/pubspec.lock b/app/pubspec.lock index d19ae381..8e417a2c 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -835,7 +835,7 @@ packages: description: path: "packages/gridproxy_client" ref: development - resolved-ref: "4ef4d3bc2550017d987f27fd8c2264854c5cf683" + resolved-ref: a2e6e9d8a560d93474c77edacb0b8e8f04187cef url: "https://github.com/threefoldtech/tfgrid-sdk-dart" source: git version: "1.0.0"