inventory_store.dart 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  1. import 'dart:async';
  2. import 'package:flutter/foundation.dart';
  3. import '../data/api_client.dart';
  4. import '../data/settings_store.dart';
  5. import '../models/inventory.dart';
  6. /// Maintains a durable local inventory and retries queued changes when the
  7. /// configured server becomes reachable again.
  8. class InventoryStore extends ChangeNotifier {
  9. InventoryStore(this.api, {SettingsStore? settings})
  10. : settings = settings ?? SettingsStore();
  11. final RackPeekApi? api;
  12. final SettingsStore settings;
  13. InventoryDocument? document;
  14. Map<String, dynamic>? summary;
  15. Object? loadError;
  16. Object? connectionError;
  17. bool busy = false;
  18. bool isOnline = false;
  19. bool _restored = false;
  20. Future<void>? _restoring;
  21. bool _syncing = false;
  22. Timer? _retryTimer;
  23. final List<Map<String, dynamic>> _pendingChanges = [];
  24. bool get loaded => document != null;
  25. bool get isLocalMode => api == null;
  26. int get pendingChangeCount => _pendingChanges.length;
  27. bool get hasPendingChanges => _pendingChanges.isNotEmpty;
  28. Future<void> refresh() async {
  29. loadError = null;
  30. await _restoreLocalState();
  31. if (api == null) {
  32. document ??= InventoryDocument.fromJson({
  33. 'version': 5,
  34. 'resources': <dynamic>[],
  35. 'connections': <dynamic>[],
  36. });
  37. summary = _buildSummary(document!);
  38. isOnline = false;
  39. notifyListeners();
  40. return;
  41. }
  42. try {
  43. await _syncPendingChanges();
  44. document = await api!.getDocument();
  45. summary = await api!.getSummary();
  46. isOnline = true;
  47. connectionError = null;
  48. await _persistLocalState();
  49. } catch (error) {
  50. isOnline = false;
  51. connectionError = error;
  52. document ??= InventoryDocument.fromJson({
  53. 'version': 5,
  54. 'resources': <dynamic>[],
  55. 'connections': <dynamic>[],
  56. });
  57. summary = _buildSummary(document!);
  58. loadError = null;
  59. _scheduleRetry();
  60. }
  61. notifyListeners();
  62. }
  63. Future<void> applyPatch(Map<String, dynamic> patch) async {
  64. await _restoreLocalState();
  65. document ??= InventoryDocument.fromJson({
  66. 'version': 5,
  67. 'resources': <dynamic>[],
  68. 'connections': <dynamic>[],
  69. });
  70. document = document!.withResourcePatch(patch);
  71. _pendingChanges.add(_copyMap(patch));
  72. summary = _buildSummary(document!);
  73. busy = true;
  74. await _persistLocalState();
  75. _scheduleRetry();
  76. notifyListeners();
  77. try {
  78. await _syncPendingChanges();
  79. } catch (error) {
  80. connectionError = error;
  81. isOnline = false;
  82. } finally {
  83. busy = false;
  84. notifyListeners();
  85. }
  86. }
  87. Future<void> addResource(Map<String, dynamic> resource) =>
  88. applyPatch(resource);
  89. Future<void> _restoreLocalState() {
  90. if (_restored) return Future<void>.value();
  91. return _restoring ??= _loadLocalState();
  92. }
  93. Future<void> _loadLocalState() async {
  94. final state = await settings.loadOfflineState();
  95. _restored = true;
  96. if (state == null) return;
  97. final savedDocument = state['document'];
  98. if (savedDocument is Map<String, dynamic>) {
  99. document = InventoryDocument.fromJson(savedDocument);
  100. summary = _buildSummary(document!);
  101. }
  102. final pending = state['pendingChanges'];
  103. if (pending is List) {
  104. _pendingChanges.addAll(
  105. pending.whereType<Map>().map((change) => _copyMap(change)),
  106. );
  107. }
  108. if (_pendingChanges.isNotEmpty) _scheduleRetry();
  109. }
  110. Future<void> _persistLocalState() => settings.saveOfflineState({
  111. 'document': document?.toJson(),
  112. 'pendingChanges': _pendingChanges,
  113. });
  114. Future<void> _syncPendingChanges() async {
  115. if (api == null || _syncing || _pendingChanges.isEmpty) return;
  116. _syncing = true;
  117. try {
  118. while (_pendingChanges.isNotEmpty) {
  119. await api!.upsert({
  120. 'version': document?.version ?? 5,
  121. 'resources': [_pendingChanges.first],
  122. });
  123. _pendingChanges.removeAt(0);
  124. await _persistLocalState();
  125. }
  126. isOnline = true;
  127. connectionError = null;
  128. _retryTimer?.cancel();
  129. _retryTimer = null;
  130. } catch (error) {
  131. isOnline = false;
  132. connectionError = error;
  133. _scheduleRetry();
  134. rethrow;
  135. } finally {
  136. _syncing = false;
  137. }
  138. }
  139. void _scheduleRetry() {
  140. if (api == null || _retryTimer != null) return;
  141. _retryTimer = Timer.periodic(const Duration(seconds: 20), (_) async {
  142. if (_pendingChanges.isEmpty) {
  143. _retryTimer?.cancel();
  144. _retryTimer = null;
  145. return;
  146. }
  147. try {
  148. await _syncPendingChanges();
  149. notifyListeners();
  150. } on ApiException {
  151. notifyListeners();
  152. }
  153. });
  154. }
  155. @override
  156. void dispose() {
  157. _retryTimer?.cancel();
  158. super.dispose();
  159. }
  160. static Map<String, dynamic> _copyMap(Map source) => Map<String, dynamic>.from(
  161. source.map((key, value) => MapEntry('$key', value)),
  162. );
  163. static Map<String, dynamic> _buildSummary(InventoryDocument document) {
  164. final hardware = document.resources
  165. .where((resource) => resource.isHardware)
  166. .toList();
  167. final systems = document.resources
  168. .where((resource) => resource.kind == 'System')
  169. .toList();
  170. final services = document.resources
  171. .where((resource) => resource.kind == 'Service')
  172. .toList();
  173. final countBy = <String, int>{};
  174. for (final resource in hardware) {
  175. countBy.update(resource.kind, (count) => count + 1, ifAbsent: () => 1);
  176. }
  177. final systemsByType = <String, int>{};
  178. final systemsByOs = <String, int>{};
  179. for (final system in systems) {
  180. final type = system.type;
  181. if (type != null && type.isNotEmpty) {
  182. systemsByType.update(type, (count) => count + 1, ifAbsent: () => 1);
  183. }
  184. final os = system.os;
  185. if (os != null && os.isNotEmpty) {
  186. systemsByOs.update(os, (count) => count + 1, ifAbsent: () => 1);
  187. }
  188. }
  189. final tags = <String, int>{};
  190. final labels = <String, int>{};
  191. for (final resource in document.resources) {
  192. for (final tag in resource.tags) {
  193. tags.update(tag, (count) => count + 1, ifAbsent: () => 1);
  194. }
  195. for (final key in resource.labels.keys) {
  196. labels.update(key, (count) => count + 1, ifAbsent: () => 1);
  197. }
  198. }
  199. final addresses = <String>{};
  200. for (final service in services) {
  201. final primaryIp = service.network?.ip;
  202. if (primaryIp != null && primaryIp.isNotEmpty) addresses.add(primaryIp);
  203. for (final endpoint in service.endpoints) {
  204. final endpointIp = endpoint.ip;
  205. if (endpointIp != null && endpointIp.isNotEmpty) {
  206. addresses.add(endpointIp);
  207. }
  208. }
  209. }
  210. return {
  211. 'hardware': {'totalHardware': hardware.length, 'hardwareByKind': countBy},
  212. 'systems': {
  213. 'totalSystems': systems.length,
  214. 'systemsByType': systemsByType,
  215. 'systemsByOs': systemsByOs,
  216. },
  217. 'services': {
  218. 'totalServices': services.length,
  219. 'totalIpAddresses': addresses.length,
  220. },
  221. 'tags': tags,
  222. 'labels': labels,
  223. };
  224. }
  225. }