diff --git a/lib/core/utils/refresh_interval.dart b/lib/core/utils/refresh_interval.dart new file mode 100644 index 000000000..00cbf3b4f --- /dev/null +++ b/lib/core/utils/refresh_interval.dart @@ -0,0 +1,15 @@ +import 'package:server_box/data/res/default.dart'; +import 'package:server_box/data/res/store.dart'; + +int? normalizeServerStatusRefreshSeconds(int seconds) { + if (seconds == 0) return null; + if (seconds <= 1 || seconds > 10) return Defaults.updateInterval; + return seconds; +} + +Duration? serverStatusRefreshInterval() { + final seconds = normalizeServerStatusRefreshSeconds( + Stores.setting.serverStatusUpdateInterval.fetch(), + ); + return seconds == null ? null : Duration(seconds: seconds); +} diff --git a/lib/core/utils/version.dart b/lib/core/utils/version.dart new file mode 100644 index 000000000..ea264a572 --- /dev/null +++ b/lib/core/utils/version.dart @@ -0,0 +1,25 @@ +List? parseVersionParts(String raw) { + final match = RegExp(r'(\d+)(?:\.(\d+))?(?:\.(\d+))?').firstMatch(raw); + if (match == null) return null; + final major = int.tryParse(match.group(1)!); + if (major == null) return null; + return [ + major, + int.tryParse(match.group(2) ?? '') ?? 0, + int.tryParse(match.group(3) ?? '') ?? 0, + ]; +} + +bool isVersionLessThan(String raw, List minimum) { + final version = parseVersionParts(raw); + if (version == null) return false; + + for (var i = 0; i < minimum.length; i++) { + final currentPart = i < version.length ? version[i] : 0; + final minimumPart = minimum[i]; + if (currentPart != minimumPart) { + return currentPart < minimumPart; + } + } + return false; +} diff --git a/lib/data/provider/server/all.dart b/lib/data/provider/server/all.dart index 299af2f47..e9c945be6 100644 --- a/lib/data/provider/server/all.dart +++ b/lib/data/provider/server/all.dart @@ -4,6 +4,7 @@ import 'package:fl_lib/fl_lib.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:server_box/core/sync.dart'; +import 'package:server_box/core/utils/refresh_interval.dart'; import 'package:server_box/core/utils/sudo_password.dart'; import 'package:server_box/data/model/server/server.dart'; import 'package:server_box/data/model/server/server_private_info.dart'; @@ -65,8 +66,16 @@ class ServersNotifier extends _$ServersNotifier { final newTags = _calculateTags(newServers); - return stateOrNull?.copyWith(servers: newServers, serverOrder: newServerOrder, tags: newTags) ?? - ServersState(servers: newServers, serverOrder: newServerOrder, tags: newTags); + return stateOrNull?.copyWith( + servers: newServers, + serverOrder: newServerOrder, + tags: newTags, + ) ?? + ServersState( + servers: newServers, + serverOrder: newServerOrder, + tags: newTags, + ); } Set _calculateTags(Map servers) { @@ -85,7 +94,11 @@ class ServersNotifier extends _$ServersNotifier { try { await SudoPassword.clearOverride(id); } catch (e, s) { - Loggers.app.warning('Failed to clear sudo password override for server $id', e, s); + Loggers.app.warning( + 'Failed to clear sudo password override for server $id', + e, + s, + ); } } @@ -106,7 +119,9 @@ class ServersNotifier extends _$ServersNotifier { /// [onlyFailed] only refresh failed servers Future refresh({Spi? spi, bool onlyFailed = false}) async { if (spi != null) { - final newManualDisconnected = Set.from(state.manualDisconnectedIds)..remove(spi.id); + final newManualDisconnected = Set.from( + state.manualDisconnectedIds, + )..remove(spi.id); state = state.copyWith(manualDisconnectedIds: newManualDisconnected); final serverNotifier = ref.read(serverProvider(spi.id).notifier); await serverNotifier.refresh(); @@ -125,11 +140,15 @@ class ServersNotifier extends _$ServersNotifier { final serverState = ref.read(serverProvider(serverId)); if (onlyFailed) { - if (serverState.conn != ServerConn.failed) continue; + if (serverState.conn != ServerConn.failed) { + continue; + } idsToResetLimiter.add(serverId); } - if (serverState.conn == ServerConn.disconnected && !spi.autoConnect) continue; + if (serverState.conn == ServerConn.disconnected && !spi.autoConnect) { + continue; + } serversToRefresh.add(entry); } @@ -145,12 +164,16 @@ class ServersNotifier extends _$ServersNotifier { } Future startAutoRefresh() async { - var duration = Stores.setting.serverStatusUpdateInterval.fetch(); stopAutoRefresh(); - if (duration == 0) return; - if (duration <= 1 || duration > 10) { - Loggers.app.warning('Invalid duration: $duration, use default 3'); - duration = 3; + final rawDuration = Stores.setting.serverStatusUpdateInterval.fetch(); + final duration = normalizeServerStatusRefreshSeconds(rawDuration); + if (duration == null) { + return; + } + if (duration != rawDuration) { + Loggers.app.warning( + 'Invalid duration: $rawDuration, use default $duration', + ); } final timer = Timer.periodic(Duration(seconds: duration), (_) async { await refresh(); @@ -175,7 +198,10 @@ class ServersNotifier extends _$ServersNotifier { // Update SSH session status to disconnected final sessionId = 'ssh_$serverId'; - TermSessionManager.updateStatus(sessionId, TermSessionStatus.disconnected); + TermSessionManager.updateStatus( + sessionId, + TermSessionStatus.disconnected, + ); } //TryLimiter.clear(); } @@ -200,7 +226,8 @@ class ServersNotifier extends _$ServersNotifier { final serverNotifier = ref.read(serverProvider(id).notifier); serverNotifier.closeConnection(); - final newManualDisconnected = Set.from(state.manualDisconnectedIds)..add(id); + final newManualDisconnected = Set.from(state.manualDisconnectedIds) + ..add(id); state = state.copyWith(manualDisconnectedIds: newManualDisconnected); // Remove SSH session when server is manually closed @@ -216,7 +243,8 @@ class ServersNotifier extends _$ServersNotifier { final newOrder = List.from(state.serverOrder)..add(spi.id); final newTags = _calculateTags(newServers); - final newManualDisconnected = Set.from(state.manualDisconnectedIds)..remove(spi.id); + final newManualDisconnected = Set.from(state.manualDisconnectedIds) + ..remove(spi.id); state = state.copyWith( servers: newServers, @@ -237,7 +265,8 @@ class ServersNotifier extends _$ServersNotifier { final newOrder = List.from(state.serverOrder)..remove(id); final newTags = _calculateTags(newServers); - final newManualDisconnected = Set.from(state.manualDisconnectedIds)..remove(id); + final newManualDisconnected = Set.from(state.manualDisconnectedIds) + ..remove(id); state = state.copyWith( servers: newServers, @@ -329,7 +358,9 @@ class ServersNotifier extends _$ServersNotifier { final newServers = Map.from(state.servers); final newOrder = List.from(state.serverOrder); - final newManualDisconnected = Set.from(state.manualDisconnectedIds); + final newManualDisconnected = Set.from( + state.manualDisconnectedIds, + ); if (newSpi.id != old.id) { newServers[newSpi.id] = newSpi; diff --git a/lib/view/page/container/actions.dart b/lib/view/page/container/actions.dart index cf51a45ce..ae8c39cea 100644 --- a/lib/view/page/container/actions.dart +++ b/lib/view/page/container/actions.dart @@ -7,6 +7,11 @@ extension on _ContainerPageState { /// Watch the current state of the container. ContainerState get _containerState => ref.watch(_provider); + String _errorMessage(String? message) { + final trimmed = message?.trim(); + return trimmed?.isNotEmpty == true ? trimmed! : libL10n.fail; + } + Future _showAddFAB() async { final imageCtrl = TextEditingController(); final nameCtrl = TextEditingController(); @@ -44,7 +49,11 @@ extension on _ContainerPageState { onTap: () async { context.pop(); await _showAddCmdPreview( - _buildAddCmd(imageCtrl.text.trim(), nameCtrl.text.trim(), argsCtrl.text.trim()), + _buildAddCmd( + imageCtrl.text.trim(), + nameCtrl.text.trim(), + argsCtrl.text.trim(), + ), ); }, ).toList, @@ -65,7 +74,10 @@ extension on _ContainerPageState { final (result, err) = await context.showLoadingDialog(fn: onConfirm); if (err != null || result != null) { final e = result?.message ?? err?.toString(); - context.showRoundDialog(title: libL10n.error, child: Text(e.toString())); + context.showRoundDialog( + title: libL10n.error, + child: Text(_errorMessage(e)), + ); } else { context.showSnackBar(libL10n.success); } @@ -85,10 +97,15 @@ extension on _ContainerPageState { onPressed: () async { context.pop(); - final (result, err) = await context.showLoadingDialog(fn: () => _containerNotifier.run(cmd)); + final (result, err) = await context.showLoadingDialog( + fn: () => _containerNotifier.run(cmd), + ); if (err != null || result != null) { final e = result?.message ?? err?.toString(); - context.showRoundDialog(title: libL10n.error, child: Text(e.toString())); + context.showRoundDialog( + title: libL10n.error, + child: Text(_errorMessage(e)), + ); } }, child: Text(libL10n.run), @@ -123,13 +140,15 @@ extension on _ContainerPageState { void _showImageRmDialog(ContainerImg e) { context.showRoundDialog( title: libL10n.attention, - child: Text(libL10n.askContinue('${libL10n.delete} Image(${e.repository})')), + child: Text( + libL10n.askContinue('${libL10n.delete} Image(${e.repository})'), + ), actions: Btn.ok( onTap: () async { context.pop(); final result = await _containerNotifier.run('rmi ${e.id} -f'); if (result != null) { - context.showSnackBar(result.message ?? 'null'); + context.showSnackBar(_errorMessage(result.message)); } }, red: true, @@ -149,7 +168,9 @@ extension on _ContainerPageState { final imageRef = '$repo:$tag'; context.showRoundDialog( title: libL10n.attention, - child: Text(libL10n.askContinue('${l10n.pull} ${l10n.image}($imageRef)')), + child: Text( + libL10n.askContinue('${l10n.pull} ${l10n.image}($imageRef)'), + ), actions: Btn.ok( onTap: () async { context.pop(); @@ -160,7 +181,10 @@ extension on _ContainerPageState { ); if (err != null || result != null) { final e = result?.message ?? err?.toString(); - context.showRoundDialog(title: libL10n.error, child: Text(e ?? 'null')); + context.showRoundDialog( + title: libL10n.error, + child: Text(_errorMessage(e)), + ); } }, ).toList, @@ -186,13 +210,21 @@ extension on _ContainerPageState { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Text(libL10n.askContinue('${libL10n.delete} Container(${dItem.name})')), + Text( + libL10n.askContinue( + '${libL10n.delete} Container(${dItem.name})', + ), + ), UIs.height13, Row( children: [ StatefulBuilder( builder: (_, setState) { - return Checkbox(value: force, onChanged: (val) => setState(() => force = val ?? false)); + return Checkbox( + value: force, + onChanged: (val) => + setState(() => force = val ?? false), + ); }, ), Text(libL10n.force), @@ -209,31 +241,49 @@ extension on _ContainerPageState { ); if (err != null || result != null) { final e = result?.message ?? err?.toString(); - context.showRoundDialog(title: libL10n.error, child: Text(e ?? 'null')); + context.showRoundDialog( + title: libL10n.error, + child: Text(_errorMessage(e)), + ); } }, ).toList, ); break; case ContainerMenu.start: - final (result, err) = await context.showLoadingDialog(fn: () => _containerNotifier.start(id)); + final (result, err) = await context.showLoadingDialog( + fn: () => _containerNotifier.start(id), + ); if (err != null || result != null) { final e = result?.message ?? err?.toString(); - context.showRoundDialog(title: libL10n.error, child: Text(e ?? 'null')); + context.showRoundDialog( + title: libL10n.error, + child: Text(_errorMessage(e)), + ); } break; case ContainerMenu.stop: - final (result, err) = await context.showLoadingDialog(fn: () => _containerNotifier.stop(id)); + final (result, err) = await context.showLoadingDialog( + fn: () => _containerNotifier.stop(id), + ); if (err != null || result != null) { final e = result?.message ?? err?.toString(); - context.showRoundDialog(title: libL10n.error, child: Text(e ?? 'null')); + context.showRoundDialog( + title: libL10n.error, + child: Text(_errorMessage(e)), + ); } break; case ContainerMenu.restart: - final (result, err) = await context.showLoadingDialog(fn: () => _containerNotifier.restart(id)); + final (result, err) = await context.showLoadingDialog( + fn: () => _containerNotifier.restart(id), + ); if (err != null || result != null) { final e = result?.message ?? err?.toString(); - context.showRoundDialog(title: libL10n.error, child: Text(e ?? 'null')); + context.showRoundDialog( + title: libL10n.error, + child: Text(_errorMessage(e)), + ); } break; case ContainerMenu.logs: @@ -262,14 +312,17 @@ extension on _ContainerPageState { } void _initAutoRefresh() { - if (Stores.setting.containerAutoRefresh.fetch()) { - Timer.periodic(Duration(seconds: Stores.setting.serverStatusUpdateInterval.fetch()), (timer) { - if (mounted) { - _containerNotifier.refresh(isAuto: true); - } else { - timer.cancel(); - } - }); - } + _autoRefreshTimer?.cancel(); + _autoRefreshTimer = null; + if (!Stores.setting.containerAutoRefresh.fetch()) return; + final duration = serverStatusRefreshInterval(); + if (duration == null) return; + _autoRefreshTimer = Timer.periodic(duration, (timer) { + if (mounted) { + _containerNotifier.refresh(isAuto: true); + } else { + timer.cancel(); + } + }); } } diff --git a/lib/view/page/container/container.dart b/lib/view/page/container/container.dart index b46c1fc07..3e1ba0c34 100644 --- a/lib/view/page/container/container.dart +++ b/lib/view/page/container/container.dart @@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icons_plus/icons_plus.dart'; import 'package:server_box/core/extension/context/locale.dart'; import 'package:server_box/core/route.dart'; +import 'package:server_box/core/utils/refresh_interval.dart'; import 'package:server_box/data/model/app/error.dart'; import 'package:server_box/data/model/app/menu/base.dart'; import 'package:server_box/data/model/app/menu/container.dart'; @@ -34,10 +35,12 @@ class ContainerPage extends ConsumerStatefulWidget { class _ContainerPageState extends ConsumerState { final _textController = TextEditingController(); late final ContainerNotifierProvider _provider; + Timer? _autoRefreshTimer; @override void dispose() { super.dispose(); + _autoRefreshTimer?.cancel(); _textController.dispose(); } @@ -71,7 +74,8 @@ class _ContainerPageState extends ConsumerState { title: TwoLineText(up: libL10n.container, down: widget.args.spi.name), actions: [ IconButton( - onPressed: () => context.showLoadingDialog(fn: () => _containerNotifier.refresh()), + onPressed: () => + context.showLoadingDialog(fn: () => _containerNotifier.refresh()), icon: const Icon(Icons.refresh), ), ], @@ -79,7 +83,10 @@ class _ContainerPageState extends ConsumerState { } Widget _buildFAB() { - return FloatingActionButton(onPressed: () async => await _showAddFAB(), child: const Icon(Icons.add)); + return FloatingActionButton( + onPressed: () async => await _showAddFAB(), + child: const Icon(Icons.add), + ); } Widget _buildMain() { @@ -138,7 +145,10 @@ class _ContainerPageState extends ConsumerState { return ExpandTile( leading: const Icon(MingCute.clapperboard_line), title: Text(l10n.imagesList), - subtitle: Text(l10n.dockerImagesFmt(containerState.images?.length ?? 'null'), style: UIs.textGrey), + subtitle: Text( + l10n.dockerImagesFmt(containerState.images?.length ?? 'null'), + style: UIs.textGrey, + ), initiallyExpanded: (containerState.images?.length ?? 0) <= 3, children: containerState.images?.map(_buildImageItem).toList() ?? [], ).cardx; @@ -151,9 +161,14 @@ class _ContainerPageState extends ConsumerState { final reg = repoSplited?.join('/'); return ListTile( title: Text(title ?? l10n.unknown, style: UIs.text15), - subtitle: Text('${reg ?? ''} - ${e.tag} - ${e.sizeMB}', style: UIs.text13Grey), + subtitle: Text( + '${reg ?? ''} - ${e.tag} - ${e.sizeMB}', + style: UIs.text13Grey, + ), trailing: PopupMenu( - items: ImageMenu.items.map((e) => PopMenu.build(e, e.icon, e.toStr)).toList(), + items: ImageMenu.items + .map((e) => PopMenu.build(e, e.icon, e.toStr)) + .toList(), onSelected: (item) => _onTapImageMenu(item, e), ), ); @@ -179,7 +194,10 @@ class _ContainerPageState extends ConsumerState { padding: const EdgeInsets.all(17), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [Text(containerState.type.name.capitalize), Text(containerState.version ?? l10n.unknown)], + children: [ + Text(containerState.type.name.capitalize), + Text(containerState.version ?? l10n.unknown), + ], ), ), ); @@ -240,16 +258,36 @@ class _ContainerPageState extends ConsumerState { UIs.height13, Row( children: [ - _buildPsItemStatsItem('CPU', item.cpu, Icons.memory, width: width), + _buildPsItemStatsItem( + 'CPU', + item.cpu, + Icons.memory, + width: width, + ), UIs.width13, - _buildPsItemStatsItem('Net', item.net, Icons.network_cell, width: width), + _buildPsItemStatsItem( + 'Net', + item.net, + Icons.network_cell, + width: width, + ), ], ), Row( children: [ - _buildPsItemStatsItem('Mem', item.mem, Icons.settings_input_component, width: width), + _buildPsItemStatsItem( + 'Mem', + item.mem, + Icons.settings_input_component, + width: width, + ), UIs.width13, - _buildPsItemStatsItem('Disk', item.disk, Icons.storage, width: width), + _buildPsItemStatsItem( + 'Disk', + item.disk, + Icons.storage, + width: width, + ), ], ), ], @@ -258,7 +296,12 @@ class _ContainerPageState extends ConsumerState { ); } - Widget _buildPsItemStatsItem(String title, String? value, IconData icon, {required double width}) { + Widget _buildPsItemStatsItem( + String title, + String? value, + IconData icon, { + required double width, + }) { return SizedBox( width: width, child: Column( @@ -284,7 +327,9 @@ class _ContainerPageState extends ConsumerState { Widget _buildMoreBtn(ContainerPs dItem) { return PopupMenu( - items: ContainerMenu.items(dItem.status.isRunning).map((e) => PopMenu.build(e, e.icon, e.toStr)).toList(), + items: ContainerMenu.items( + dItem.status.isRunning, + ).map((e) => PopMenu.build(e, e.icon, e.toStr)).toList(), onSelected: (item) => _onTapMoreBtn(item, dItem), ); } @@ -342,11 +387,16 @@ class _ContainerPageState extends ConsumerState { leading: const Icon(Icons.settings), title: Text(libL10n.setting), initiallyExpanded: containerState.error != null, - children: _SettingsMenuItems.values.map((item) => _buildSettingTile(item, containerState)).toList(), + children: _SettingsMenuItems.values + .map((item) => _buildSettingTile(item, containerState)) + .toList(), ).cardx; } - Widget _buildSettingTile(_SettingsMenuItems item, ContainerState containerState) { + Widget _buildSettingTile( + _SettingsMenuItems item, + ContainerState containerState, + ) { final String title; switch (item) { case _SettingsMenuItems.editDockerHost: @@ -368,7 +418,9 @@ class _ContainerPageState extends ConsumerState { ref .read(_provider.notifier) .setType( - containerState.type == ContainerType.docker ? ContainerType.podman : ContainerType.docker, + containerState.type == ContainerType.docker + ? ContainerType.podman + : ContainerType.docker, ); break; } diff --git a/lib/view/page/process.dart b/lib/view/page/process.dart index c2e1d5bdc..673eb256f 100644 --- a/lib/view/page/process.dart +++ b/lib/view/page/process.dart @@ -5,10 +5,10 @@ import 'package:fl_lib/fl_lib.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:server_box/core/route.dart'; +import 'package:server_box/core/utils/refresh_interval.dart'; import 'package:server_box/data/model/app/scripts/shell_func.dart'; import 'package:server_box/data/model/server/proc.dart'; import 'package:server_box/data/provider/server/single.dart'; -import 'package:server_box/data/res/store.dart'; class ProcessPage extends ConsumerStatefulWidget { final SpiRequiredArgs args; @@ -22,7 +22,7 @@ class ProcessPage extends ConsumerStatefulWidget { } class _ProcessPageState extends ConsumerState { - late Timer _timer; + Timer? _timer; late MediaQueryData _media; SSHClient? _client; @@ -41,7 +41,7 @@ class _ProcessPageState extends ConsumerState { @override void dispose() { super.dispose(); - _timer.cancel(); + _timer?.cancel(); } @override @@ -49,8 +49,11 @@ class _ProcessPageState extends ConsumerState { super.initState(); final serverState = ref.read(_provider); _client = serverState.client; - final duration = Duration(seconds: Stores.setting.serverStatusUpdateInterval.fetch()); - _timer = Timer.periodic(duration, (_) => _refresh()); + _refresh(); + final duration = serverStatusRefreshInterval(); + if (duration != null) { + _timer = Timer.periodic(duration, (_) => _refresh()); + } } @override @@ -64,7 +67,13 @@ class _ProcessPageState extends ConsumerState { final serverState = ref.read(_provider); final systemType = serverState.status.system; final result = await _client - ?.run(ShellFunc.process.exec(widget.args.spi.id, systemType: systemType, customDir: null)) + ?.run( + ShellFunc.process.exec( + widget.args.spi.id, + systemType: systemType, + customDir: null, + ), + ) .string; if (result == null || result.isEmpty) { context.showSnackBar(libL10n.empty); @@ -73,9 +82,13 @@ class _ProcessPageState extends ConsumerState { _result = PsResult.parse(result, sort: _procSortMode); if (!_checkedIncompleteData) { - final isAnyProcDataNotComplete = _result.procs.any((e) => e.cpu == null || e.mem == null); + final isAnyProcDataNotComplete = _result.procs.any( + (e) => e.cpu == null || e.mem == null, + ); if (isAnyProcDataNotComplete) { - _sortModes.removeWhere((e) => e == ProcSortMode.cpu || e == ProcSortMode.mem); + _sortModes.removeWhere( + (e) => e == ProcSortMode.cpu || e == ProcSortMode.mem, + ); } _checkedIncompleteData = true; } @@ -94,7 +107,9 @@ class _ProcessPageState extends ConsumerState { }, icon: const Icon(Icons.sort), initialValue: _procSortMode, - itemBuilder: (_) => _sortModes.map((e) => PopupMenuItem(value: e, child: Text(e.name))).toList(), + itemBuilder: (_) => _sortModes + .map((e) => PopupMenuItem(value: e, child: Text(e.name))) + .toList(), ), ]; if (_result.error != null) { @@ -104,7 +119,12 @@ class _ProcessPageState extends ConsumerState { onPressed: () => context.showRoundDialog( title: libL10n.error, child: SingleChildScrollView(child: Text(_result.error!)), - actions: [TextButton(onPressed: () => Pfs.copy(_result.error!), child: Text(libL10n.copy))], + actions: [ + TextButton( + onPressed: () => Pfs.copy(_result.error!), + child: Text(libL10n.copy), + ), + ], ), ), ); @@ -138,7 +158,12 @@ class _ProcessPageState extends ConsumerState { child: ListTile( leading: SizedBox(width: _media.size.width / 6, child: leading), title: Text(proc.binary), - subtitle: Text(proc.command, style: UIs.textGrey, maxLines: 3, overflow: TextOverflow.fade), + subtitle: Text( + proc.command, + style: UIs.textGrey, + maxLines: 3, + overflow: TextOverflow.fade, + ), trailing: _buildItemTrail(proc), ), ); @@ -148,16 +173,22 @@ class _ProcessPageState extends ConsumerState { return Row( mainAxisSize: MainAxisSize.min, children: [ - if (proc.cpu != null) TwoLineText(up: proc.cpu!.toStringAsFixed(1), down: 'cpu'), + if (proc.cpu != null) + TwoLineText(up: proc.cpu!.toStringAsFixed(1), down: 'cpu'), if (proc.cpu != null && proc.mem != null) UIs.width13, - if (proc.mem != null) TwoLineText(up: proc.mem!.toStringAsFixed(1), down: 'mem'), + if (proc.mem != null) + TwoLineText(up: proc.mem!.toStringAsFixed(1), down: 'mem'), if (proc.cpu != null || proc.mem != null) UIs.width13, IconButton( icon: const Icon(Icons.stop), onPressed: () { context.showRoundDialog( title: libL10n.attention, - child: Text(libL10n.askContinue('${libL10n.stop} ${libL10n.process}(${proc.pid})')), + child: Text( + libL10n.askContinue( + '${libL10n.stop} ${libL10n.process}(${proc.pid})', + ), + ), actions: [ Btn.cancel(), Btn.ok( diff --git a/lib/view/page/pve.dart b/lib/view/page/pve.dart index 2bf1a4341..8c21b2997 100644 --- a/lib/view/page/pve.dart +++ b/lib/view/page/pve.dart @@ -4,11 +4,12 @@ import 'package:fl_lib/fl_lib.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:server_box/core/extension/context/locale.dart'; +import 'package:server_box/core/utils/refresh_interval.dart'; +import 'package:server_box/core/utils/version.dart'; import 'package:server_box/data/model/app/error.dart'; import 'package:server_box/data/model/server/pve.dart'; import 'package:server_box/data/model/server/server_private_info.dart'; import 'package:server_box/data/provider/pve.dart'; -import 'package:server_box/data/res/store.dart'; import 'package:server_box/view/widget/percent_circle.dart'; final class PvePageArgs { @@ -25,7 +26,10 @@ final class PvePage extends ConsumerStatefulWidget { @override ConsumerState createState() => _PvePageState(); - static const route = AppRouteArg(page: PvePage.new, path: '/pve'); + static const route = AppRouteArg( + page: PvePage.new, + path: '/pve', + ); } const _kHorziPadding = 11.0; @@ -69,7 +73,9 @@ final class _PvePageState extends ConsumerState { if (error.type == PveErrType.needTfa && !_isPromptingForTfa && error.message != _lastHandledTfaMessage) { - WidgetsBinding.instance.addPostFrameCallback((_) => _promptForTfa(error)); + WidgetsBinding.instance.addPostFrameCallback( + (_) => _promptForTfa(error), + ); } } @@ -109,7 +115,10 @@ final class _PvePageState extends ConsumerState { PveResType? lastType; return ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: _kHorziPadding, vertical: 7), + padding: const EdgeInsets.symmetric( + horizontal: _kHorziPadding, + vertical: 7, + ), itemCount: data.length * 2, itemBuilder: (context, index) { final item = data[index ~/ 2]; @@ -131,7 +140,10 @@ final class _PvePageState extends ConsumerState { alignment: Alignment.center, child: Text( type.toStr, - style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.grey), + style: const TextStyle( + fontWeight: FontWeight.bold, + color: Colors.grey, + ), textAlign: TextAlign.start, ), ), @@ -195,11 +207,18 @@ final class _PvePageState extends ConsumerState { UIs.width7, const Text('CPU', style: UIs.text12Grey), const Spacer(), - Text('${(item.cpu * 100).toStringAsFixed(1)} %', style: UIs.text12Grey), + Text( + '${(item.cpu * 100).toStringAsFixed(1)} %', + style: UIs.text12Grey, + ), ], ), const SizedBox(height: 3), - LinearProgressIndicator(value: item.cpu / item.maxcpu, minHeight: 7, valueColor: valueAnim), + LinearProgressIndicator( + value: item.cpu / item.maxcpu, + minHeight: 7, + valueColor: valueAnim, + ), UIs.height7, Row( children: [ @@ -207,11 +226,18 @@ final class _PvePageState extends ConsumerState { UIs.width7, const Text('RAM', style: UIs.text12Grey), const Spacer(), - Text('${item.mem.bytes2Str} / ${item.maxmem.bytes2Str}', style: UIs.text12Grey), + Text( + '${item.mem.bytes2Str} / ${item.maxmem.bytes2Str}', + style: UIs.text12Grey, + ), ], ), const SizedBox(height: 3), - LinearProgressIndicator(value: item.mem / item.maxmem, minHeight: 7, valueColor: valueAnim), + LinearProgressIndicator( + value: item.mem / item.maxmem, + minHeight: 7, + valueColor: valueAnim, + ), ], ), ).cardx; @@ -265,9 +291,17 @@ final class _PvePageState extends ConsumerState { mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ - Text('↓:\n${item.netin.bytes2Str}', style: UIs.text11Grey, textAlign: TextAlign.center), + Text( + '↓:\n${item.netin.bytes2Str}', + style: UIs.text11Grey, + textAlign: TextAlign.center, + ), const SizedBox(height: 3), - Text('↑:\n${item.netout.bytes2Str}', style: UIs.text11Grey, textAlign: TextAlign.center), + Text( + '↑:\n${item.netout.bytes2Str}', + style: UIs.text11Grey, + textAlign: TextAlign.center, + ), ], ), ], @@ -325,9 +359,17 @@ final class _PvePageState extends ConsumerState { mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ - Text('↓:\n${item.netin.bytes2Str}', style: UIs.text11Grey, textAlign: TextAlign.center), + Text( + '↓:\n${item.netin.bytes2Str}', + style: UIs.text11Grey, + textAlign: TextAlign.center, + ), const SizedBox(height: 3), - Text('↑:\n${item.netout.bytes2Str}', style: UIs.text11Grey, textAlign: TextAlign.center), + Text( + '↑:\n${item.netout.bytes2Str}', + style: UIs.text11Grey, + textAlign: TextAlign.center, + ), ], ), ], @@ -360,7 +402,10 @@ final class _PvePageState extends ConsumerState { } Widget _buildSdn(PveSdn item) { - return ListTile(title: Text(_wrapNodeName(item)), trailing: Text(item.summary)).cardx; + return ListTile( + title: Text(_wrapNodeName(item)), + trailing: Text(item.summary), + ).cardx; } Widget _buildCtrlBtns(PveCtrlIface item) { @@ -368,7 +413,11 @@ final class _PvePageState extends ConsumerState { if (!item.available) { return Btn.icon( icon: const Icon(Icons.play_arrow, color: Colors.grey), - onTap: () => _onCtrl(libL10n.start, item, () => _notifier.start(item.node, item.id)), + onTap: () => _onCtrl( + libL10n.start, + item, + () => _notifier.start(item.node, item.id), + ), ); } return Row( @@ -376,17 +425,29 @@ final class _PvePageState extends ConsumerState { Btn.icon( icon: const Icon(Icons.stop, color: Colors.grey, size: 20), padding: pad, - onTap: () => _onCtrl(libL10n.stop, item, () => _notifier.stop(item.node, item.id)), + onTap: () => _onCtrl( + libL10n.stop, + item, + () => _notifier.stop(item.node, item.id), + ), ), Btn.icon( icon: const Icon(Icons.refresh, color: Colors.grey, size: 20), padding: pad, - onTap: () => _onCtrl(libL10n.reboot, item, () => _notifier.reboot(item.node, item.id)), + onTap: () => _onCtrl( + libL10n.reboot, + item, + () => _notifier.reboot(item.node, item.id), + ), ), Btn.icon( icon: const Icon(Icons.power_off, color: Colors.grey, size: 20), padding: pad, - onTap: () => _onCtrl(libL10n.shutdown, item, () => _notifier.shutdown(item.node, item.id)), + onTap: () => _onCtrl( + libL10n.shutdown, + item, + () => _notifier.shutdown(item.node, item.id), + ), ), ], ); @@ -449,7 +510,11 @@ extension on _PvePageState { } } - void _onCtrl(String action, PveCtrlIface item, Future Function() func) async { + void _onCtrl( + String action, + PveCtrlIface item, + Future Function() func, + ) async { final sure = await context.showRoundDialog( title: libL10n.attention, child: Text(libL10n.askContinue('$action ${item.id}')), @@ -476,7 +541,9 @@ extension on _PvePageState { void _initRefreshTimer() { _timer?.cancel(); - _timer = Timer.periodic(Duration(seconds: Stores.setting.serverStatusUpdateInterval.fetch()), (_) { + final duration = serverStatusRefreshInterval(); + if (duration == null) return; + _timer = Timer.periodic(duration, (_) { if (mounted) { _notifier.list(); } @@ -488,7 +555,8 @@ extension on _PvePageState { while (mounted) { final pveState = ref.read(_provider); if (pveState.isConnected) { - if (pveState.release != null && pveState.release!.compareTo('8.0') < 0) { + final release = pveState.release; + if (release != null && isVersionLessThan(release, const [8, 0])) { if (mounted) { context.showSnackBar(l10n.pveVersionLow); } diff --git a/test/refresh_interval_test.dart b/test/refresh_interval_test.dart new file mode 100644 index 000000000..4fc685805 --- /dev/null +++ b/test/refresh_interval_test.dart @@ -0,0 +1,22 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:server_box/core/utils/refresh_interval.dart'; +import 'package:server_box/data/res/default.dart'; + +void main() { + group('normalizeServerStatusRefreshSeconds', () { + test('keeps manual refresh disabled', () { + expect(normalizeServerStatusRefreshSeconds(0), isNull); + }); + + test('keeps valid automatic refresh values', () { + expect(normalizeServerStatusRefreshSeconds(2), 2); + expect(normalizeServerStatusRefreshSeconds(10), 10); + }); + + test('falls back for invalid automatic refresh values', () { + expect(normalizeServerStatusRefreshSeconds(1), Defaults.updateInterval); + expect(normalizeServerStatusRefreshSeconds(-1), Defaults.updateInterval); + expect(normalizeServerStatusRefreshSeconds(11), Defaults.updateInterval); + }); + }); +} diff --git a/test/version_test.dart b/test/version_test.dart new file mode 100644 index 000000000..b827265af --- /dev/null +++ b/test/version_test.dart @@ -0,0 +1,34 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:server_box/core/utils/version.dart'; + +void main() { + group('parseVersionParts', () { + test('parses numeric version parts', () { + expect(parseVersionParts('7.4'), [7, 4, 0]); + expect(parseVersionParts('8.1.2'), [8, 1, 2]); + expect(parseVersionParts('pve-manager/10.0-1'), [10, 0, 0]); + }); + + test('returns null for malformed input without digits', () { + expect(parseVersionParts('unknown'), isNull); + expect(parseVersionParts(''), isNull); + }); + + test('returns null for oversized numeric parts', () { + expect(parseVersionParts('999999999999999999999999999999.0'), isNull); + }); + }); + + group('isVersionLessThan', () { + test('compares major and minor numerically', () { + expect(isVersionLessThan('7.4', const [8, 0]), isTrue); + expect(isVersionLessThan('8.0', const [8, 0]), isFalse); + expect(isVersionLessThan('8.1', const [8, 0]), isFalse); + expect(isVersionLessThan('10.0', const [8, 0]), isFalse); + }); + + test('does not flag malformed versions as older', () { + expect(isVersionLessThan('unknown', const [8, 0]), isFalse); + }); + }); +}