Skip to content

Add workload node notification #1033

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: development_grace_period_contracts
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions app/lib/apps/notifications/notifications_user_data.dart
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -29,3 +30,13 @@ Future<void> setContractNotificationEnabled(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_contractNotificationsEnabledKey, enabled);
}

Future<bool> isWorkloadNotificationEnabled() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(nodeWorkloadNotificationEnabledKey) ?? true;
}

Future<void> setWorkloadNotificationEnabled(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(nodeWorkloadNotificationEnabledKey, enabled);
}
2 changes: 2 additions & 0 deletions app/lib/models/wallet.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class Wallet {
required this.tfchainBalance,
required this.type,
required this.verificationStatus,
required this.twinId,
});
String name;
final String stellarSecret;
Expand All @@ -27,6 +28,7 @@ class Wallet {
String tfchainBalance;
final WalletType type;
VerificationState verificationStatus;
int twinId = 0;
}

class PkidWallet {
Expand Down
19 changes: 18 additions & 1 deletion app/lib/screens/notifications_screen.dart
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -13,6 +14,7 @@ class _NotificationsScreenState extends State<NotificationsScreen> {
bool loading = true;
late bool _nodeStatusNotificationEnabled;
late bool _contractNotificationsEnabled;
late bool _workloadNotificationEnabled;

@override
void initState() {
Expand All @@ -22,13 +24,16 @@ class _NotificationsScreenState extends State<NotificationsScreen> {

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
Expand Down Expand Up @@ -85,6 +90,18 @@ class _NotificationsScreenState extends State<NotificationsScreen> {
},
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),
),
],
),
);
Expand Down
65 changes: 59 additions & 6 deletions app/lib/services/background_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -76,7 +77,7 @@ Future<void> _checkContractsAndNotify(
}
}

Future<void> _checkNodesAndNotify(String taskId) async {
Future<void> _checkMyNodesAndNotify(String taskId) async {
try {
final bool nodeNotificationsEnabled =
await isNodeStatusNotificationEnabled();
Expand All @@ -85,14 +86,14 @@ Future<void> _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(
Expand Down Expand Up @@ -143,6 +144,58 @@ Future<void> _checkNodesAndNotify(String taskId) async {
}
}

Future<void> _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<int> 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
Expand Down
2 changes: 1 addition & 1 deletion app/lib/services/contract_check_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ class ContractCheckService {
final twinId = await getTwinId(w.tfchainSecret);
if (twinId != 0) {
List<ContractInfo> contracts =
await getGracePeriodContractsByTwinId(twinId);
await getContracts(twinId, [ContractState.GracePeriod]);
allContracts.addAll(contracts);
}
}
Expand Down
19 changes: 16 additions & 3 deletions app/lib/services/gridproxy_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -67,15 +67,28 @@ Future<bool> isFarmNameAvailable(String name) async {
}
}

Future<List<ContractInfo>> getGracePeriodContractsByTwinId(int twinId) async {
Future<List<ContractInfo>> getContracts(
int twinId, List<ContractState> 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<Node> 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');
}
}
71 changes: 54 additions & 17 deletions app/lib/services/nodes_check_service.dart
Original file line number Diff line number Diff line change
@@ -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<List<Node>> pingNodesInBackground() async {
static Future<List<Node>> pingMyNodes() async {
final container = ProviderContainer();
try {
final walletsNotifierInstance = container.read(walletsNotifier.notifier);

await walletsNotifierInstance.waitUntilListed();

await container.read(walletsNotifier.notifier).waitUntilListed();
final List<Wallet> wallets = container.read(walletsNotifier);

final Map<int, Wallet> 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 = <Node>[];
for (final farm in farmsList) {
final nodesData = await getNodesByFarmId(farm.farmID);

final nodes = nodesData
.map((node) => Node(
nodeId: node.nodeId,
Expand All @@ -41,16 +33,61 @@ 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<List<int>> 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<int>()
.toSet()
.toList();

if (nodeIds.isEmpty) return [];

return nodeIds.isEmpty ? [] : await _getOfflineNodes(nodeIds);
} catch (e) {
logger.e('[NodeCheckService] Error: $e');
return [];
} finally {
container.dispose();
}
}

static Future<List<int>> _getOfflineNodes(List<int> 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<Node> 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(),
),
);
}
}
26 changes: 8 additions & 18 deletions app/lib/services/notification_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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!);
}
Expand Down Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions app/lib/services/wallet_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ Future<Wallet> 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,
Expand All @@ -135,6 +136,7 @@ Future<Wallet> loadWallet(String walletName, String walletSeed,
tfchainBalance: tfchainBalance,
type: walletType,
verificationStatus: kycVerified.status,
twinId: twinId,
);
return wallet;
}
Expand Down
Loading