feat: Bring back completely re-optimized "Connection Stats" feature (#1090)

* feat (Connection Statistics): Restored the server connection statistics feature

* perf(store): Optimize data storage performance and implement caching mechanisms

- Implement caching mechanisms in SnippetStore and ServerStore to reduce redundant loading
- Refactor ConnectionStatsStore to use indexes and optimize query performance
- Adopt a more efficient approach when cleaning up expired records
- Add a maximum record limit to prevent data bloat

* perf(store): Optimize data storage performance and add a caching mechanism

Add a caching mechanism to PrivateKeyStore to reduce redundant loading

Make the cleanup and index rebuilding of ConnectionStatsStore asynchronous

Add database compression and size statistics
Display database size in the interface and optimize compression operations

* fix (Cache): Fixed cache invalidation and join statistics issues

- Added a cache invalidation call to the reload method
- Fixed an error in the calculation of join statistics timestamps
- Optimized the cache index rebuild logic
- Added tooltips and click effects for join statistics

* refactor(connection_stats): Convert file operations from synchronous to asynchronous and optimize record cleanup logic

Convert the database size retrieval method from synchronous to asynchronous to prevent UI blocking

Optimize server record cleanup logic by directly deleting redundant records instead of rebuilding indexes

* fix(connection_stats): Fixed an initialization issue when the index database is empty

During Stores initialization, the code now checks whether `connectionStats.indexDbKeys` is empty; if so, it calls `rebuildIndexAndCompact` to rebuild and compact the database. Additionally, the implementation of the `_pruneExcessRecords` method has been optimized to use tuples instead of temporary lists, thereby improving performance. A `mounted` check has been added at the UI layer to prevent state update issues during asynchronous operations.

* fix(server): Improved error string matching logic to more accurately identify connection issues

Error strings are now uniformly converted to lowercase for comparison, and matching criteria have been expanded to cover a wider range of error scenarios, including timeouts, authentication failures, and network errors

* fix(PrivateKeyStore): Fixed an issue where the cache state was not updated when clearing the cache

When clearing the private key store, ensure that the internal cache state is updated simultaneously to maintain consistency

* refactor(store): Add close methods and clean up subscription logic

Add close methods to PrivateKeyStore, SnippetStore, and ServerStore to unsubscribe

Unify cache cleanup logic to prevent memory leaks

* fix(store): Add a cache update suppression mechanism to prevent circular updates

Add an _suppressWatch flag to multiple Store classes to suppress cache invalidation during internal operations

Add a _putWithoutInvalidatingCache method to prevent recursive watchers from being triggered during data updates

* refactor(store): Improve caching and state management using try-finally

In PrivateKeyStore, ServerStore, and SnippetStore:
1. Remove redundant close methods
2. Use try-finally to ensure the _suppressWatch state is reset correctly
3. Optimize cache invalidation logic
4. Standardize transaction handling for update operations

* refactor(store): Optimize data storage operations and fix potential issues

- Ensure the safety and consistency of list operations in ConnectionStatsStore
- Replace direct calls to `box.put` with the `set` method in SnippetStore and ServerStore
- Extract decoding logic for PrivateKeyStore into a separate method
- Add logic to update server-hopping relationships

* fix: Fixed an issue where asynchronous operations were not being waited on and optimized storage operations

Fixed several issues where asynchronous operations were not being waited on to ensure data consistency

Added the _suppressWatch control to ServerStore and PrivateKeyStore

Optimized index management in ConnectionStatsStore to maintain record order

Added a new GitHub participant

* fix: Fixed potential state issues and memory leaks in asynchronous operations

Fixed potential state issues that could occur on the server edit page after a delete operation; added a mounted check

Changed the statistics clearing operation in connection_stats to run asynchronously

Optimized asynchronous operations in PrivateKeyStore and fixed potential memory leaks

* refactor(store): Convert asynchronous methods to synchronous ones to simplify the code

Fixed an issue where asynchronous operations were not handled correctly on the connection statistics page

* fix: Added mounted check and error handling for connection logs

Added a mounted check in _ConnectionStatsPageState to prevent the state from being updated after the component is unmounted

Added a try-catch block for connection logs in ServerNotifier to catch and log potential storage exceptions
This commit is contained in:
GT610
2026-03-27 17:14:07 +08:00
committed by GitHub
parent fa4ac00ced
commit c0f98e41c8
19 changed files with 880 additions and 174 deletions

View File

@@ -20,6 +20,7 @@ class PrivateKeyNotifier extends _$PrivateKeyNotifier {
} }
void reload() { void reload() {
Stores.key.invalidateCache();
final newState = _load(); final newState = _load();
if (newState == state) return; if (newState == state) return;
state = newState; state = newState;

View File

@@ -33,6 +33,7 @@ class ServersNotifier extends _$ServersNotifier {
} }
Future<void> reload() async { Future<void> reload() async {
Stores.server.invalidateCache();
final newState = _load(); final newState = _load();
if (newState == state) return; if (newState == state) return;
state = newState; state = newState;
@@ -213,7 +214,7 @@ class ServersNotifier extends _$ServersNotifier {
bakSync.sync(milliDelay: 1000); bakSync.sync(milliDelay: 1000);
} }
void delServer(String id) { Future<void> delServer(String id) async {
final newServers = Map<String, Spi>.from(state.servers); final newServers = Map<String, Spi>.from(state.servers);
newServers.remove(id); newServers.remove(id);
@@ -225,6 +226,8 @@ class ServersNotifier extends _$ServersNotifier {
Stores.setting.serverOrder.put(newOrder); Stores.setting.serverOrder.put(newOrder);
Stores.server.delete(id); Stores.server.delete(id);
await Stores.connectionStats.clearServerStats(id);
// Remove SSH session when server is deleted // Remove SSH session when server is deleted
final sessionId = 'ssh_$id'; final sessionId = 'ssh_$id';
TermSessionManager.remove(sessionId); TermSessionManager.remove(sessionId);
@@ -232,7 +235,7 @@ class ServersNotifier extends _$ServersNotifier {
bakSync.sync(milliDelay: 1000); bakSync.sync(milliDelay: 1000);
} }
void deleteAll() { Future<void> deleteAll() async {
// Remove all SSH sessions before clearing servers // Remove all SSH sessions before clearing servers
for (final id in state.servers.keys) { for (final id in state.servers.keys) {
final sessionId = 'ssh_$id'; final sessionId = 'ssh_$id';
@@ -243,6 +246,7 @@ class ServersNotifier extends _$ServersNotifier {
Stores.setting.serverOrder.put([]); Stores.setting.serverOrder.put([]);
Stores.server.clear(); Stores.server.clear();
await Stores.connectionStats.clearAll();
bakSync.sync(milliDelay: 1000); bakSync.sync(milliDelay: 1000);
} }

View File

@@ -41,7 +41,7 @@ final class ServersNotifierProvider
} }
} }
String _$serversNotifierHash() => r'277d1b219235f14bcc1b82a1e16260c2f28decdb'; String _$serversNotifierHash() => r'c90c2d8ce73a63f926bcf9679a84ae150c9d4808';
abstract class _$ServersNotifier extends $Notifier<ServersState> { abstract class _$ServersNotifier extends $Notifier<ServersState> {
ServersState build(); ServersState build();

View File

@@ -13,6 +13,7 @@ import 'package:server_box/data/helper/system_detector.dart';
import 'package:server_box/data/model/app/error.dart'; import 'package:server_box/data/model/app/error.dart';
import 'package:server_box/data/model/app/scripts/script_consts.dart'; import 'package:server_box/data/model/app/scripts/script_consts.dart';
import 'package:server_box/data/model/app/scripts/shell_func.dart'; import 'package:server_box/data/model/app/scripts/shell_func.dart';
import 'package:server_box/data/model/server/connection_stat.dart';
import 'package:server_box/data/model/server/server.dart'; import 'package:server_box/data/model/server/server.dart';
import 'package:server_box/data/model/server/server_private_info.dart'; import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/model/server/server_status_update_req.dart'; import 'package:server_box/data/model/server/server_status_update_req.dart';
@@ -123,8 +124,8 @@ class ServerNotifier extends _$ServerNotifier {
} }
} }
try {
final time1 = DateTime.now(); final time1 = DateTime.now();
try {
final client = await genClient( final client = await genClient(
spi, spi,
timeout: Duration(seconds: Stores.setting.timeout.fetch()), timeout: Duration(seconds: Stores.setting.timeout.fetch()),
@@ -140,6 +141,18 @@ class ServerNotifier extends _$ServerNotifier {
Loggers.app.info('Jump to ${spi.name} in $spentTime ms.'); Loggers.app.info('Jump to ${spi.name} in $spentTime ms.');
} }
try {
await Stores.connectionStats.recordConnection(ConnectionStat(
serverId: spi.id,
serverName: spi.name,
timestamp: time1,
result: ConnectionResult.success,
durationMs: spentTime,
));
} catch (e) {
Loggers.app.warning('Failed to record connection success', e);
}
final sessionId = 'ssh_${spi.id}'; final sessionId = 'ssh_${spi.id}';
TermSessionManager.add( TermSessionManager.add(
id: sessionId, id: sessionId,
@@ -152,6 +165,33 @@ class ServerNotifier extends _$ServerNotifier {
} catch (e) { } catch (e) {
TryLimiter.inc(sid); TryLimiter.inc(sid);
final durationMs = DateTime.now().difference(time1).inMilliseconds;
ConnectionResult failureResult;
final errStr = e.toString().toLowerCase();
if (errStr.contains('timed out') || errStr.contains('timeout')) {
failureResult = ConnectionResult.timeout;
} else if (errStr.contains('auth') || errStr.contains('authentication') || errStr.contains('permission denied') || errStr.contains('access denied')) {
failureResult = ConnectionResult.authFailed;
} else if (errStr.contains('connection refused') || errStr.contains('no route to host') || errStr.contains('network') || errStr.contains('socket')) {
failureResult = ConnectionResult.networkError;
} else {
failureResult = ConnectionResult.unknownError;
}
try {
await Stores.connectionStats.recordConnection(ConnectionStat(
serverId: spi.id,
serverName: spi.name,
timestamp: time1,
result: failureResult,
errorMessage: e.toString(),
durationMs: durationMs,
));
} catch (recErr) {
Loggers.app.warning('Failed to record connection failure', recErr);
}
final newStatus = state.status..err = SSHErr(type: SSHErrType.connect, message: e.toString()); final newStatus = state.status..err = SSHErr(type: SSHErrType.connect, message: e.toString());
updateStatus(newStatus); updateStatus(newStatus);
updateConnection(ServerConn.failed); updateConnection(ServerConn.failed);

View File

@@ -58,7 +58,7 @@ final class ServerNotifierProvider
} }
} }
String _$serverNotifierHash() => r'1bda6d0a9688ab843cf30803dafe3400379dc5c3'; String _$serverNotifierHash() => r'04b1beef4d96242fd10d5b523c6f5f17eb774bae';
final class ServerNotifierFamily extends $Family final class ServerNotifierFamily extends $Family
with with

View File

@@ -24,6 +24,7 @@ class SnippetNotifier extends _$SnippetNotifier {
} }
void reload() { void reload() {
Stores.snippet.invalidateCache();
final newState = _load(); final newState = _load();
if (newState == state) return; if (newState == state) return;
state = newState; state = newState;

View File

@@ -153,7 +153,8 @@ abstract final class GithubIds {
'aliferne', 'aliferne',
'canronglan', 'canronglan',
'nickgirga', 'nickgirga',
'xxnuo' 'xxnuo',
'sunnysu0608',
}; };
} }

View File

@@ -45,6 +45,10 @@ abstract final class Stores {
getIt.registerLazySingleton<PortForwardStore>(() => PortForwardStore.instance); getIt.registerLazySingleton<PortForwardStore>(() => PortForwardStore.instance);
await Future.wait(_allBackup.map((store) => store.init())); await Future.wait(_allBackup.map((store) => store.init()));
if (connectionStats.indexDbKeys.isEmpty) {
await connectionStats.rebuildIndexAndCompact();
}
} }
static int get lastModTime { static int get lastModTime {

View File

@@ -1,4 +1,7 @@
import 'dart:io';
import 'package:fl_lib/fl_lib.dart'; import 'package:fl_lib/fl_lib.dart';
import 'package:hive_ce/hive.dart';
import 'package:server_box/data/model/server/connection_stat.dart'; import 'package:server_box/data/model/server/connection_stat.dart';
class ConnectionStatsStore extends HiveStore { class ConnectionStatsStore extends HiveStore {
@@ -6,40 +9,120 @@ class ConnectionStatsStore extends HiveStore {
static final instance = ConnectionStatsStore._(); static final instance = ConnectionStatsStore._();
// Record a connection attempt static const _indexBoxName = 'conn_stats_index';
void recordConnection(ConnectionStat stat) { static const _maxRecordsPerServer = 100;
final key = '${stat.serverId}_${ShortId.generate()}';
set(key, stat); late final Box<dynamic> _indexBox;
_cleanOldRecords(stat.serverId);
@override
Future<void> init() async {
await super.init();
_indexBox = await Hive.openBox(
_indexBoxName,
path: box.path?.substring(0, box.path!.lastIndexOf(Pfs.seperator)),
);
} }
// Clean records older than 30 days for a specific server Future<void> rebuildIndexAndCompact() async {
void _cleanOldRecords(String serverId) { await _cleanAllOldAndRebuildIndex();
await _compactIfNeeded();
}
Future<void> _rebuildIndexCore() async {
final cutoffTime = DateTime.now().subtract(const Duration(days: 30)); final cutoffTime = DateTime.now().subtract(const Duration(days: 30));
final allKeys = keys().toList(); final serverIdToKeys = <String, List<String>>{};
final keysToDelete = <String>[];
for (final key in allKeys) { for (final key in keys().toList()) {
if (key.startsWith(serverId)) { final stat = get<ConnectionStat>(key);
final parts = key.split('_'); if (stat == null) continue;
if (parts.length >= 2) {
final timestamp = int.tryParse(parts.last);
if (timestamp != null) {
final recordTime = DateTime.fromMillisecondsSinceEpoch(timestamp);
if (recordTime.isBefore(cutoffTime)) {
keysToDelete.add(key);
}
}
}
}
}
for (final key in keysToDelete) { if (stat.timestamp.isBefore(cutoffTime)) {
remove(key); remove(key);
continue;
}
final serverId = stat.serverId;
serverIdToKeys.putIfAbsent(serverId, () => []).add(key);
}
final idxKeysToDelete = _indexBox.keys.where((k) => k.toString().startsWith('idx_')).toList();
for (final k in idxKeysToDelete) {
await _indexBox.delete(k);
}
for (final entry in serverIdToKeys.entries) {
final keys = entry.value;
if (keys.length > _maxRecordsPerServer) {
final keyStatPairs = <(String, ConnectionStat)>[];
for (final key in keys) {
final stat = get<ConnectionStat>(key);
if (stat != null) keyStatPairs.add((key, stat));
}
keyStatPairs.sort((a, b) => b.$2.timestamp.compareTo(a.$2.timestamp));
final toKeep = keyStatPairs.take(_maxRecordsPerServer).map((p) => p.$1).toList().reversed.toList();
final toRemove = keyStatPairs.skip(_maxRecordsPerServer);
for (final pair in toRemove) {
remove(pair.$1);
}
await _indexBox.put('idx_${entry.key}', toKeep);
} else {
await _indexBox.put('idx_${entry.key}', keys);
}
} }
} }
// Get connection stats for a specific server Future<void> _cleanAllOldAndRebuildIndex() async {
await _rebuildIndexCore();
}
Future<void> _compactIfNeeded() async {
try {
await box.compact();
await _indexBox.compact();
} catch (e, st) {
Loggers.app.warning('Auto compact failed during init', e, st);
}
}
Future<void> _updateIndex(String serverId, String recordKey) async {
final indexKey = 'idx_$serverId';
final keys = (_indexBox.get(indexKey) as List?)?.cast<String>().toList() ?? [];
if (!keys.contains(recordKey)) {
keys.add(recordKey);
if (keys.length > _maxRecordsPerServer) {
await _pruneExcessRecords(serverId, keys);
}
await _indexBox.put(indexKey, keys);
}
}
Future<void> _pruneExcessRecords(String serverId, List<String> keys) async {
if (keys.length <= _maxRecordsPerServer) return;
final keyStatPairs = <(String, ConnectionStat)>[];
for (final key in keys) {
final stat = get<ConnectionStat>(key);
if (stat != null) {
keyStatPairs.add((key, stat));
}
}
keyStatPairs.sort((a, b) => b.$2.timestamp.compareTo(a.$2.timestamp));
final toRemove = keyStatPairs.skip(_maxRecordsPerServer);
for (final pair in toRemove) {
remove(pair.$1);
keys.remove(pair.$1);
}
}
Future<void> recordConnection(ConnectionStat stat) async {
final key = '${stat.serverId}_${stat.timestamp.millisecondsSinceEpoch}';
set(key, stat);
await _updateIndex(stat.serverId, key);
}
ServerConnectionStats getServerStats(String serverId, String serverName) { ServerConnectionStats getServerStats(String serverId, String serverName) {
final allStats = getConnectionHistory(serverId); final allStats = getConnectionHistory(serverId);
@@ -82,7 +165,6 @@ class ConnectionStatsStore extends HiveStore {
lastFailureTime = failureTimes.first; lastFailureTime = failureTimes.first;
} }
// Get recent connections (last 20)
final recentConnections = allStats.take(20).toList(); final recentConnections = allStats.take(20).toList();
return ServerConnectionStats( return ServerConnectionStats(
@@ -98,76 +180,46 @@ class ConnectionStatsStore extends HiveStore {
); );
} }
// Get connection history for a specific server
List<ConnectionStat> getConnectionHistory(String serverId) { List<ConnectionStat> getConnectionHistory(String serverId) {
final allKeys = keys().where((key) => key.startsWith(serverId)).toList(); final indexKey = 'idx_$serverId';
final stats = <ConnectionStat>[]; final keys = (_indexBox.get(indexKey) as List?)?.cast<String>() ?? [];
for (final key in allKeys) { final stats = <ConnectionStat>[];
final stat = get<ConnectionStat>( for (final key in keys) {
key, final stat = get<ConnectionStat>(key);
fromObj: (val) {
if (val is ConnectionStat) return val;
if (val is Map<dynamic, dynamic>) {
final map = val.toStrDynMap;
if (map == null) return null;
try {
return ConnectionStat.fromJson(map as Map<String, dynamic>);
} catch (e) {
dprint('Parsing ConnectionStat from JSON', e);
}
}
return null;
},
);
if (stat != null) { if (stat != null) {
stats.add(stat); stats.add(stat);
} }
} }
// Sort by timestamp, newest first
stats.sort((a, b) => b.timestamp.compareTo(a.timestamp)); stats.sort((a, b) => b.timestamp.compareTo(a.timestamp));
return stats; return stats;
} }
// Get all servers' stats
List<ServerConnectionStats> getAllServerStats() { List<ServerConnectionStats> getAllServerStats() {
final serverIds = <String>{}; final indexKeys = _indexBox.keys
final serverNames = <String, String>{}; .where((k) => k is String && k.startsWith('idx_'))
.cast<String>()
// Get all unique server IDs .toList();
for (final key in keys()) {
final parts = key.split('_');
if (parts.length >= 2) {
final serverId = parts[0];
serverIds.add(serverId);
// Try to get server name from the stored stat
final stat = get<ConnectionStat>(
key,
fromObj: (val) {
if (val is ConnectionStat) return val;
if (val is Map<dynamic, dynamic>) {
final map = val.toStrDynMap;
if (map == null) return null;
try {
return ConnectionStat.fromJson(map as Map<String, dynamic>);
} catch (e) {
dprint('Parsing ConnectionStat from JSON', e);
}
}
return null;
},
);
if (stat != null) {
serverNames[serverId] = stat.serverName;
}
}
}
final allStats = <ServerConnectionStats>[]; final allStats = <ServerConnectionStats>[];
for (final serverId in serverIds) { for (final indexKey in indexKeys) {
final serverName = serverNames[serverId] ?? serverId; final serverId = indexKey.substring(4);
final keys = (_indexBox.get(indexKey) as List?)?.cast<String>() ?? [];
if (keys.isEmpty) continue;
String? serverName;
for (final key in keys.reversed) {
final stat = get<ConnectionStat>(key);
if (stat != null) {
serverName = stat.serverName;
break;
}
}
if (serverName == null) continue;
final stats = getServerStats(serverId, serverName); final stats = getServerStats(serverId, serverName);
allStats.add(stats); allStats.add(stats);
} }
@@ -175,30 +227,50 @@ class ConnectionStatsStore extends HiveStore {
return allStats; return allStats;
} }
// Clear all connection stats Future<void> clearAll() async {
void clearAll() { await box.clear();
box.clear(); await _indexBox.clear();
} }
// Clear stats for a specific server Future<void> clearServerStats(String serverId) async {
void clearServerStats(String serverId) { final indexKey = 'idx_$serverId';
final keysToDelete = keys().where((key) { final keys = (_indexBox.get(indexKey) as List?)?.cast<String>() ?? [];
if (key == serverId) return true;
return key.startsWith('${serverId}_'); for (final key in keys) {
}).toList();
for (final key in keysToDelete) {
remove(key); remove(key);
} }
await _indexBox.delete(indexKey);
} }
Future<void> compact() async { Future<void> compact() async {
Loggers.app.info('Start compacting connection_stats database...'); Loggers.app.info('Start compacting connection_stats database...');
try { try {
await box.compact(); await box.compact();
await _indexBox.compact();
Loggers.app.info('Finished compacting connection_stats database'); Loggers.app.info('Finished compacting connection_stats database');
} catch (e, st) { } catch (e, st) {
Loggers.app.warning('Failed compacting connection_stats database', e, st); Loggers.app.warning('Failed compacting connection_stats database', e, st);
rethrow; rethrow;
} }
} }
String? get dbPath => box.path;
String? get indexDbPath => _indexBox.path;
Iterable<dynamic> get indexDbKeys => _indexBox.keys.where((k) => k.toString().startsWith('idx_'));
Future<int> dbSizeAsync() async {
final path = dbPath;
if (path == null) return 0;
final file = File(path);
return await file.exists() ? await file.length() : 0;
}
Future<int> indexDbSizeAsync() async {
final path = indexDbPath;
if (path == null) return 0;
final file = File(path);
return await file.exists() ? await file.length() : 0;
}
} }

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:fl_lib/fl_lib.dart'; import 'package:fl_lib/fl_lib.dart';
import 'package:server_box/data/model/server/private_key_info.dart'; import 'package:server_box/data/model/server/private_key_info.dart';
@@ -7,44 +9,112 @@ class PrivateKeyStore extends HiveStore {
static final instance = PrivateKeyStore._(); static final instance = PrivateKeyStore._();
List<PrivateKeyInfo>? _cache;
StreamSubscription<dynamic>? _boxWatchSub;
bool _suppressWatch = false;
@override
Future<void> init() async {
await super.init();
await _boxWatchSub?.cancel();
_boxWatchSub = box.watch().listen((_) {
if (!_suppressWatch) {
_cache = null;
}
});
}
@override
bool clear({bool? updateLastUpdateTsOnClear}) {
_suppressWatch = true;
try {
_cache = null;
return super.clear(updateLastUpdateTsOnClear: updateLastUpdateTsOnClear);
} finally {
_suppressWatch = false;
}
}
void invalidateCache() {
_cache = null;
}
void put(PrivateKeyInfo info) { void put(PrivateKeyInfo info) {
_suppressWatch = true;
try {
set(info.id, info); set(info.id, info);
_cache = null;
} finally {
_suppressWatch = false;
}
}
void _putWithoutInvalidatingCache(PrivateKeyInfo info) {
_suppressWatch = true;
try {
box.put(info.id, info);
} finally {
_suppressWatch = false;
}
} }
List<PrivateKeyInfo> fetch() { List<PrivateKeyInfo> fetch() {
return List<PrivateKeyInfo>.from(_cache ??= _loadAll());
}
List<PrivateKeyInfo> _loadAll() {
final ps = <PrivateKeyInfo>[]; final ps = <PrivateKeyInfo>[];
final toPersist = <PrivateKeyInfo>[];
for (final key in keys()) { for (final key in keys()) {
final s = get<PrivateKeyInfo>( final s = get<PrivateKeyInfo>(
key, key,
fromObj: (val) { fromObj: (val) => _decodePrivateKeyInfo(val, toPersist: toPersist),
);
if (s != null) {
ps.add(s);
}
}
for (final pki in toPersist) {
_putWithoutInvalidatingCache(pki);
}
return ps;
}
PrivateKeyInfo? _decodePrivateKeyInfo(dynamic val, {List<PrivateKeyInfo>? toPersist}) {
if (val is PrivateKeyInfo) return val; if (val is PrivateKeyInfo) return val;
if (val is Map<dynamic, dynamic>) { if (val is Map<dynamic, dynamic>) {
final map = val.toStrDynMap; final map = val.toStrDynMap;
if (map == null) return null; if (map == null) return null;
try { try {
final pki = PrivateKeyInfo.fromJson(map as Map<String, dynamic>); final pki = PrivateKeyInfo.fromJson(map as Map<String, dynamic>);
put(pki); if (toPersist != null) {
toPersist.add(pki);
}
return pki; return pki;
} catch (e) { } catch (e) {
dprint('Parsing PrivateKeyInfo from JSON', e); dprint('Parsing PrivateKeyInfo from JSON', e);
} }
} }
return null; return null;
},
);
if (s != null) {
ps.add(s);
}
}
return ps;
} }
PrivateKeyInfo? fetchOne(String? id) { PrivateKeyInfo? fetchOne(String? id) {
if (id == null) return null; if (id == null) return null;
return box.get(id); if (_cache != null) {
for (final pki in _cache!) {
if (pki.id == id) return pki;
}
}
return _decodePrivateKeyInfo(box.get(id));
} }
void delete(PrivateKeyInfo s) { void delete(PrivateKeyInfo s) {
_suppressWatch = true;
try {
remove(s.id); remove(s.id);
_cache = null;
} finally {
_suppressWatch = false;
}
} }
} }

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:fl_lib/fl_lib.dart'; import 'package:fl_lib/fl_lib.dart';
import 'package:server_box/data/model/server/server_private_info.dart'; import 'package:server_box/data/model/server/server_private_info.dart';
@@ -10,11 +12,60 @@ class ServerStore extends HiveStore {
static final instance = ServerStore._(); static final instance = ServerStore._();
List<Spi>? _cache;
StreamSubscription<dynamic>? _boxWatchSub;
bool _suppressWatch = false;
@override
Future<void> init() async {
await super.init();
_boxWatchSub?.cancel();
_boxWatchSub = box.watch().listen((_) {
if (!_suppressWatch) {
_cache = null;
}
});
}
@override
bool clear({bool? updateLastUpdateTsOnClear}) {
_suppressWatch = true;
try {
_cache = null;
return super.clear(updateLastUpdateTsOnClear: updateLastUpdateTsOnClear);
} finally {
_suppressWatch = false;
}
}
void invalidateCache() {
_cache = null;
}
void put(Spi info) { void put(Spi info) {
_suppressWatch = true;
try {
set(info.id, info); set(info.id, info);
_cache = null;
} finally {
_suppressWatch = false;
}
}
void _putWithoutInvalidatingCache(Spi info) {
_suppressWatch = true;
try {
box.put(info.id, info);
} finally {
_suppressWatch = false;
}
} }
List<Spi> fetch() { List<Spi> fetch() {
return List<Spi>.from(_cache ??= _loadAll());
}
List<Spi> _loadAll() {
final List<Spi> ss = []; final List<Spi> ss = [];
for (final id in keys()) { for (final id in keys()) {
final s = get<Spi>( final s = get<Spi>(
@@ -26,7 +77,7 @@ class ServerStore extends HiveStore {
if (map == null) return null; if (map == null) return null;
try { try {
final spi = Spi.fromJson(map as Map<String, dynamic>); final spi = Spi.fromJson(map as Map<String, dynamic>);
put(spi); _putWithoutInvalidatingCache(spi);
return spi; return spi;
} catch (e) { } catch (e) {
dprint('Parsing Spi from JSON', e); dprint('Parsing Spi from JSON', e);
@@ -43,15 +94,27 @@ class ServerStore extends HiveStore {
} }
void delete(String id) { void delete(String id) {
_suppressWatch = true;
try {
remove(id); remove(id);
_cache = null;
} finally {
_suppressWatch = false;
}
} }
void update(Spi old, Spi newInfo) { void update(Spi old, Spi newInfo) {
if (!have(old)) { if (!have(old)) {
throw Exception('Old spi: $old not found'); throw Exception('Old spi: $old not found');
} }
delete(old.id); _suppressWatch = true;
put(newInfo); try {
remove(old.id);
set(newInfo.id, newInfo);
_cache = null;
} finally {
_suppressWatch = false;
}
} }
bool have(Spi s) => get(s.id) != null; bool have(Spi s) => get(s.id) != null;
@@ -60,12 +123,9 @@ class ServerStore extends HiveStore {
final ss = fetch(); final ss = fetch();
final idMap = <String, String>{}; final idMap = <String, String>{};
// Collect all old to new ID mappings
for (final s in ss) { for (final s in ss) {
final newId = s.migrateId(); final newId = s.migrateId();
if (newId == null) continue; if (newId == null) continue;
// Use s.oldId as the key, because s.id would be empty for a server being migrated.
// s.oldId represents the identifier used before migration.
idMap[s.oldId] = newId; idMap[s.oldId] = newId;
} }
@@ -74,23 +134,19 @@ class ServerStore extends HiveStore {
final container = ContainerStore.instance; final container = ContainerStore.instance;
bool srvOrderChanged = false; bool srvOrderChanged = false;
// Update all references to the servers
for (final e in idMap.entries) { for (final e in idMap.entries) {
final oldId = e.key; final oldId = e.key;
final newId = e.value; final newId = e.value;
// Replace ids in ordering settings.
final srvIdx = srvOrder.indexOf(oldId); final srvIdx = srvOrder.indexOf(oldId);
if (srvIdx != -1) { if (srvIdx != -1) {
srvOrder[srvIdx] = newId; srvOrder[srvIdx] = newId;
srvOrderChanged = true; srvOrderChanged = true;
} }
// Replace ids in jump server settings.
final spi = get<Spi>(newId); final spi = get<Spi>(newId);
if (spi != null) { if (spi != null) {
final jumpId = spi.jumpId; // This could be an oldId. final jumpId = spi.jumpId;
// Check if this jumpId corresponds to a server that was also migrated.
if (jumpId != null && idMap.containsKey(jumpId)) { if (jumpId != null && idMap.containsKey(jumpId)) {
final newJumpId = idMap[jumpId]; final newJumpId = idMap[jumpId];
if (spi.jumpId != newJumpId) { if (spi.jumpId != newJumpId) {
@@ -100,7 +156,6 @@ class ServerStore extends HiveStore {
} }
} }
// Replace ids in [Snippet]
for (final snippet in snippets) { for (final snippet in snippets) {
final autoRunsOn = snippet.autoRunOn; final autoRunsOn = snippet.autoRunOn;
final idx = autoRunsOn?.indexOf(oldId); final idx = autoRunsOn?.indexOf(oldId);
@@ -112,7 +167,6 @@ class ServerStore extends HiveStore {
} }
} }
// Replace ids in [Container]
final dockerHost = container.fetch(oldId); final dockerHost = container.fetch(oldId);
if (dockerHost != null) { if (dockerHost != null) {
container.remove(oldId); container.remove(oldId);
@@ -120,6 +174,14 @@ class ServerStore extends HiveStore {
} }
} }
for (final spi in ss) {
if (spi.jumpId != null && idMap.containsKey(spi.jumpId)) {
final newJumpId = idMap[spi.jumpId]!;
final newSpi = spi.copyWith(jumpId: newJumpId);
update(spi, newSpi);
}
}
if (srvOrderChanged) { if (srvOrderChanged) {
SettingStore.instance.serverOrder.put(srvOrder); SettingStore.instance.serverOrder.put(srvOrder);
} }

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:fl_lib/fl_lib.dart'; import 'package:fl_lib/fl_lib.dart';
import 'package:server_box/data/model/server/snippet.dart'; import 'package:server_box/data/model/server/snippet.dart';
@@ -7,11 +9,60 @@ class SnippetStore extends HiveStore {
static final instance = SnippetStore._(); static final instance = SnippetStore._();
List<Snippet>? _cache;
StreamSubscription<dynamic>? _boxWatchSub;
bool _suppressWatch = false;
@override
Future<void> init() async {
await super.init();
_boxWatchSub?.cancel();
_boxWatchSub = box.watch().listen((_) {
if (!_suppressWatch) {
_cache = null;
}
});
}
@override
bool clear({bool? updateLastUpdateTsOnClear}) {
_suppressWatch = true;
try {
_cache = null;
return super.clear(updateLastUpdateTsOnClear: updateLastUpdateTsOnClear);
} finally {
_suppressWatch = false;
}
}
void invalidateCache() {
_cache = null;
}
void put(Snippet snippet) { void put(Snippet snippet) {
_suppressWatch = true;
try {
set(snippet.name, snippet); set(snippet.name, snippet);
_cache = null;
} finally {
_suppressWatch = false;
}
}
void _putWithoutInvalidatingCache(Snippet snippet) {
_suppressWatch = true;
try {
box.put(snippet.name, snippet);
} finally {
_suppressWatch = false;
}
} }
List<Snippet> fetch() { List<Snippet> fetch() {
return List<Snippet>.from(_cache ??= _loadAll());
}
List<Snippet> _loadAll() {
final ss = <Snippet>{}; final ss = <Snippet>{};
for (final key in keys()) { for (final key in keys()) {
final s = get<Snippet>( final s = get<Snippet>(
@@ -23,7 +74,7 @@ class SnippetStore extends HiveStore {
if (map == null) return null; if (map == null) return null;
try { try {
final snippet = Snippet.fromJson(map as Map<String, dynamic>); final snippet = Snippet.fromJson(map as Map<String, dynamic>);
put(snippet); _putWithoutInvalidatingCache(snippet);
return snippet; return snippet;
} catch (e) { } catch (e) {
dprint('Parsing Snippet from JSON', e); dprint('Parsing Snippet from JSON', e);
@@ -40,15 +91,27 @@ class SnippetStore extends HiveStore {
} }
void delete(Snippet s) { void delete(Snippet s) {
_suppressWatch = true;
try {
remove(s.name); remove(s.name);
_cache = null;
} finally {
_suppressWatch = false;
}
} }
void update(Snippet old, Snippet newInfo) { void update(Snippet old, Snippet newInfo) {
if (!have(old)) { if (!have(old)) {
throw Exception('Old snippet: $old not found'); throw Exception('Old snippet: $old not found');
} }
delete(old); _suppressWatch = true;
put(newInfo); try {
remove(old.name);
set(newInfo.name, newInfo);
_cache = null;
} finally {
_suppressWatch = false;
}
} }
bool have(Snippet s) => get(s.name) != null; bool have(Snippet s) => get(s.name) != null;

View File

@@ -0,0 +1,363 @@
// ignore_for_file: invalid_use_of_protected_member
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart';
import 'package:server_box/core/extension/context/locale.dart';
import 'package:server_box/data/model/server/connection_stat.dart';
import 'package:server_box/data/res/store.dart';
class ConnectionStatsPage extends StatefulWidget {
const ConnectionStatsPage({super.key});
static const route = AppRouteNoArg(page: ConnectionStatsPage.new, path: '/server/conn_stats');
@override
State<ConnectionStatsPage> createState() => _ConnectionStatsPageState();
}
class _ConnectionStatsPageState extends State<ConnectionStatsPage> {
List<ServerConnectionStats> _serverStats = [];
bool _isLoading = true;
bool _isCompacting = false;
@override
void initState() {
super.initState();
_loadStats();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: CustomAppBar(
title: Text(l10n.connectionStats),
actions: [
IconButton(onPressed: _loadStats, icon: const Icon(Icons.refresh), tooltip: libL10n.refresh),
IconButton(
onPressed: _showClearAllDialog,
icon: const Icon(Icons.clear_all, color: Colors.red),
tooltip: libL10n.clear,
),
IconButton(
onPressed: _isCompacting ? null : _showCompactDialog,
icon: _isCompacting
? SizedLoading.small
: const Icon(Icons.compress),
tooltip: l10n.compactDatabase,
),
],
),
body: _buildBody(),
);
}
}
extension _Builds on _ConnectionStatsPageState {
Widget _buildBody() {
if (_isLoading) {
return const Center(child: SizedLoading.large);
}
if (_serverStats.isEmpty) {
return Center(child: Text(l10n.noConnectionStatsData));
}
return ListView.builder(
cacheExtent: 200,
itemCount: _serverStats.length,
itemBuilder: (context, index) {
final stats = _serverStats[index];
return RepaintBoundary(
child: _buildServerStatsCard(stats),
);
},
);
}
Widget _buildServerStatsCard(ServerConnectionStats stats) {
final successRate = stats.totalAttempts == 0 ? libL10n.notAvailable : '${(stats.successRate * 100).toStringAsFixed(1)}%';
final lastSuccessTime = stats.lastSuccessTime;
final lastFailureTime = stats.lastFailureTime;
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
stats.serverName,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
Text(
'${libL10n.success}: $successRate',
style: TextStyle(
fontSize: 16,
color: stats.successRate >= 0.8
? Colors.green
: stats.successRate >= 0.5
? Colors.orange
: Colors.red,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildStatItem(libL10n.totalAttempts, stats.totalAttempts.toString(), Icons.all_inclusive),
_buildStatItem(
libL10n.success,
stats.successCount.toString(),
Icons.check_circle,
Colors.green,
),
_buildStatItem(libL10n.fail, stats.failureCount.toString(), Icons.error, Colors.red),
],
),
if (lastSuccessTime != null || lastFailureTime != null) ...[
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 8),
if (lastSuccessTime != null)
_buildTimeItem(l10n.lastSuccess, lastSuccessTime, Icons.check_circle, Colors.green),
if (lastFailureTime != null)
_buildTimeItem(l10n.lastFailure, lastFailureTime, Icons.error, Colors.red),
],
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(l10n.recentConnections, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
TextButton(onPressed: () => _showServerDetailsDialog(stats), child: Text(l10n.viewDetails)),
],
),
const SizedBox(height: 8),
...stats.recentConnections.take(3).map(_buildConnectionItem),
],
),
).cardx;
}
Widget _buildStatItem(String label, String value, IconData icon, [Color? color]) {
return Column(
children: [
Icon(icon, size: 24, color: color ?? Colors.grey),
const SizedBox(height: 4),
Text(
value,
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: color),
),
Text(label, style: TextStyle(fontSize: 12, color: Colors.grey[600])),
],
);
}
Widget _buildTimeItem(String label, DateTime time, IconData icon, Color color) {
final timeStr = time.simple();
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Icon(icon, size: 16, color: color),
UIs.width7,
Text('$label: ', style: TextStyle(fontSize: 12, color: Colors.grey[600])),
Text(timeStr, style: const TextStyle(fontSize: 12)),
],
),
);
}
Widget _buildConnectionItem(ConnectionStat stat) {
final timeStr = stat.timestamp.simple();
final isSuccess = stat.result.isSuccess;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
Icon(
isSuccess ? Icons.check_circle : Icons.error,
size: 16,
color: isSuccess ? Colors.green : Colors.red,
),
UIs.width7,
Text(timeStr, style: const TextStyle(fontSize: 12)),
UIs.width7,
Expanded(
child: Text(
isSuccess ? '${libL10n.success} (${stat.durationMs}ms)' : stat.result.displayName,
style: TextStyle(fontSize: 12, color: isSuccess ? Colors.green : Colors.red),
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
}
extension _Actions on _ConnectionStatsPageState {
Future<void> _loadStats() async {
if (!mounted) return;
setState(() {
_isLoading = true;
});
await Future.delayed(Duration.zero);
if (!mounted) return;
final stats = Stores.connectionStats.getAllServerStats();
if (!mounted) return;
setState(() {
_serverStats = stats;
_isLoading = false;
});
}
Future<void> _showCompactDialog() async {
final oldSize = await Stores.connectionStats.dbSizeAsync();
if (!mounted) return;
final oldIndexSize = await Stores.connectionStats.indexDbSizeAsync();
if (!mounted) return;
final totalSize = oldSize + oldIndexSize;
final sizeStr = _formatSize(totalSize);
context.showRoundDialog(
title: l10n.compactDatabase,
child: Text(l10n.compactDatabaseContent(sizeStr)),
actions: [
TextButton(onPressed: context.pop, child: Text(libL10n.cancel)),
TextButton(
onPressed: () async {
context.pop();
setState(() => _isCompacting = true);
try {
await Stores.connectionStats.compact();
final newSize = await Stores.connectionStats.dbSizeAsync();
final newIndexSize = await Stores.connectionStats.indexDbSizeAsync();
final newTotalSize = newSize + newIndexSize;
final newSizeStr = _formatSize(newTotalSize);
if (mounted) {
setState(() => _isCompacting = false);
context.showSnackBar('${libL10n.success}: $sizeStr -> $newSizeStr');
}
} catch (e) {
if (mounted) {
setState(() => _isCompacting = false);
context.showSnackBar('${libL10n.error}: $e');
}
}
},
child: Text(libL10n.confirm),
),
],
);
}
void _showServerDetailsDialog(ServerConnectionStats stats) {
context.showRoundDialog(
title: '${stats.serverName} - ${l10n.connectionDetails}',
child: SizedBox(
width: double.maxFinite,
height: MediaQuery.sizeOf(context).height * 0.7,
child: ListView.separated(
itemCount: stats.recentConnections.length,
separatorBuilder: (context, index) => const Divider(),
itemBuilder: (context, index) {
final stat = stats.recentConnections[index];
final timeStr = stat.timestamp.simple();
final isSuccess = stat.result.isSuccess;
return ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(
isSuccess ? Icons.check_circle : Icons.error,
color: isSuccess ? Colors.green : Colors.red,
),
title: Text(timeStr),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isSuccess
? '${libL10n.success} (${stat.durationMs}ms)'
: '${libL10n.fail}: ${stat.result.displayName}',
style: TextStyle(color: isSuccess ? Colors.green : Colors.red),
),
if (!isSuccess && stat.errorMessage.isNotEmpty)
Text(
stat.errorMessage,
style: const TextStyle(fontSize: 11, color: Colors.grey),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
);
},
),
),
actions: [
TextButton(onPressed: context.pop, child: Text(libL10n.close)),
TextButton(
onPressed: () {
context.pop();
_showClearServerStatsDialog(stats);
},
child: Text(l10n.clearThisServerStats, style: TextStyle(color: Colors.red)),
),
],
);
}
void _showClearAllDialog() {
context.showRoundDialog(
title: l10n.clearAllStatsTitle,
child: Text(l10n.clearAllStatsContent),
actions: [
TextButton(onPressed: context.pop, child: Text(libL10n.cancel)),
CountDownBtn(
onTap: () async {
context.pop();
await Stores.connectionStats.clearAll();
_loadStats();
},
text: libL10n.ok,
afterColor: Colors.red,
),
],
);
}
void _showClearServerStatsDialog(ServerConnectionStats stats) {
context.showRoundDialog(
title: l10n.clearServerStatsTitle(stats.serverName),
child: Text(l10n.clearServerStatsContent(stats.serverName)),
actions: [
TextButton(onPressed: context.pop, child: Text(libL10n.cancel)),
CountDownBtn(
onTap: () async {
context.pop();
await Stores.connectionStats.clearServerStats(stats.serverId);
_loadStats();
},
text: libL10n.ok,
afterColor: Colors.red,
),
],
);
}
}
extension _Utils on _ConnectionStatsPageState {
String _formatSize(int bytes) {
if (bytes < 1000) return '$bytes B';
if (bytes < 1000 * 1000) return '${(bytes / 1000).toStringAsFixed(1)} KB';
return '${(bytes / (1000 * 1000)).toStringAsFixed(1)} MB';
}
}

View File

@@ -434,7 +434,8 @@ extension _Widgets on _ServerEditPageState {
actions: Btn.ok( actions: Btn.ok(
onTap: () async { onTap: () async {
context.pop(); context.pop();
ref.read(serversProvider.notifier).delServer(spi!.id); await ref.read(serversProvider.notifier).delServer(spi!.id);
if (!mounted) return;
context.pop(true); context.pop(true);
}, },
red: true, red: true,

View File

@@ -21,6 +21,7 @@ import 'package:server_box/data/provider/server/all.dart';
import 'package:server_box/data/provider/server/single.dart'; import 'package:server_box/data/provider/server/single.dart';
import 'package:server_box/data/res/build_data.dart'; import 'package:server_box/data/res/build_data.dart';
import 'package:server_box/data/res/store.dart'; import 'package:server_box/data/res/store.dart';
import 'package:server_box/view/page/server/connection_stats.dart';
import 'package:server_box/view/page/server/detail/view.dart'; import 'package:server_box/view/page/server/detail/view.dart';
import 'package:server_box/view/page/server/edit/edit.dart'; import 'package:server_box/view/page/server/edit/edit.dart';
import 'package:server_box/view/page/setting/entry.dart'; import 'package:server_box/view/page/setting/entry.dart';

View File

@@ -42,7 +42,16 @@ final class _TopBar extends ConsumerWidget implements PreferredSizeWidget {
} }
final total = order.length; final total = order.length;
final connectionText = '$connected/$total ${context.libL10n.conn}'; final connectionText = '$connected/$total ${context.libL10n.conn}';
leading = Text(connectionText, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)); leading = MouseRegion(
cursor: SystemMouseCursors.click,
child: Tooltip(
message: context.l10n.connectionStats,
child: InkWell(
onTap: () => ConnectionStatsPage.route.go(context),
child: Text(connectionText, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
),
),
);
} }
return Padding( return Padding(

View File

@@ -9,6 +9,7 @@ extension _Server on _AppSettingsPageState {
_buildNetViewType(), _buildNetViewType(),
_buildServerSeq(), _buildServerSeq(),
_buildServerDetailCardSeq(), _buildServerDetailCardSeq(),
_buildConnectionStats(),
_buildDeleteServers(), _buildDeleteServers(),
_buildCpuView(), _buildCpuView(),
_buildServerMore(), _buildServerMore(),
@@ -38,6 +39,18 @@ extension _Server on _AppSettingsPageState {
); );
} }
Widget _buildConnectionStats() {
return ListTile(
leading: const Icon(Icons.analytics, size: _kIconSize),
title: Text(l10n.connectionStats),
subtitle: Text(l10n.connectionStatsDesc),
trailing: const Icon(Icons.keyboard_arrow_right),
onTap: () {
ConnectionStatsPage.route.go(context);
},
);
}
Widget _buildDeleteServers() { Widget _buildDeleteServers() {
return ListTile( return ListTile(
title: Text(l10n.deleteServers), title: Text(l10n.deleteServers),

View File

@@ -23,6 +23,7 @@ import 'package:server_box/data/store/setting.dart';
import 'package:server_box/generated/l10n/l10n.dart'; import 'package:server_box/generated/l10n/l10n.dart';
import 'package:server_box/view/page/backup.dart'; import 'package:server_box/view/page/backup.dart';
import 'package:server_box/view/page/private_key/list.dart'; import 'package:server_box/view/page/private_key/list.dart';
import 'package:server_box/view/page/server/connection_stats.dart';
import 'package:server_box/view/page/server/discovery/discovery.dart'; import 'package:server_box/view/page/server/discovery/discovery.dart';
import 'package:server_box/view/page/setting/entries/home_tabs.dart'; import 'package:server_box/view/page/setting/entries/home_tabs.dart';
import 'package:server_box/view/page/setting/platform/ios.dart'; import 'package:server_box/view/page/setting/platform/ios.dart';

View File

@@ -61,10 +61,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: async name: async
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.13.0" version: "2.13.1"
boolean_selector: boolean_selector:
dependency: transitive dependency: transitive
description: description:
@@ -117,10 +117,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: built_value name: built_value
sha256: "6ae8a6435a8c6520c7077b107e77f1fb4ba7009633259a4d49a8afd8e7efc5e9" sha256: "0730c18c770d05636a8f945c32a4d7d81cb6e0f0148c8db4ad12e7748f7e49af"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.12.4" version: "8.12.5"
camera: camera:
dependency: transitive dependency: transitive
description: description:
@@ -508,10 +508,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: flutter_plugin_android_lifecycle name: flutter_plugin_android_lifecycle
sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1 sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.33" version: "2.0.34"
flutter_riverpod: flutter_riverpod:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1030,10 +1030,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: path_provider_android name: path_provider_android
sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e sha256: "149441ca6e4f38193b2e004c0ca6376a3d11f51fa5a77552d8bd4d2b0c0912ba"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.22" version: "2.2.23"
path_provider_foundation: path_provider_foundation:
dependency: transitive dependency: transitive
description: description:
@@ -1309,18 +1309,18 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: shared_preferences name: shared_preferences
sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.5.4" version: "2.5.5"
shared_preferences_android: shared_preferences_android:
dependency: transitive dependency: transitive
description: description:
name: shared_preferences_android name: shared_preferences_android
sha256: "8374d6200ab33ac99031a852eba4c8eb2170c4bf20778b3e2c9eccb45384fb41" sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.21" version: "2.4.23"
shared_preferences_foundation: shared_preferences_foundation:
dependency: transitive dependency: transitive
description: description:
@@ -1341,10 +1341,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: shared_preferences_platform_interface name: shared_preferences_platform_interface
sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.1" version: "2.4.2"
shared_preferences_web: shared_preferences_web:
dependency: transitive dependency: transitive
description: description:
@@ -1546,10 +1546,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_android name: url_launcher_android
sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.3.28" version: "6.3.29"
url_launcher_ios: url_launcher_ios:
dependency: transitive dependency: transitive
description: description: