From 22aa7186d04d0232ce23f495415366426433da6a Mon Sep 17 00:00:00 2001 From: lxdklp Date: Thu, 6 Nov 2025 17:25:19 +0800 Subject: [PATCH 1/5] fix: FormatException: Unexpected extension byte (at offset 8) error --- lib/core/extension/ssh_client.dart | 75 ++++++++++++++++++++++++++++++ lib/data/helper/ssh_decoder.dart | 57 +++++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 lib/data/helper/ssh_decoder.dart diff --git a/lib/core/extension/ssh_client.dart b/lib/core/extension/ssh_client.dart index e126b234a..114bf0c87 100644 --- a/lib/core/extension/ssh_client.dart +++ b/lib/core/extension/ssh_client.dart @@ -4,6 +4,7 @@ import 'dart:typed_data'; import 'package:dartssh2/dartssh2.dart'; import 'package:fl_lib/fl_lib.dart'; import 'package:flutter/widgets.dart'; +import 'package:server_box/data/helper/ssh_decoder.dart'; import 'package:server_box/data/model/server/system.dart'; import 'package:server_box/data/res/misc.dart'; @@ -170,4 +171,78 @@ extension SSHClientX on SSHClient { ); return ret.$2; } + + /// Runs a command and decodes output safely with encoding fallback + /// + /// [systemType] - The system type (affects encoding choice) + /// [context] - Optional context for debugging + Future runSafe( + String command, { + SystemType? systemType, + String? context, + }) async { + try { + final result = await run(command); + return SSHDecoder.decode( + result, + isWindows: systemType == SystemType.windows, + context: context, + ); + } catch (e) { + throw Exception('Failed to run command${context != null ? " [$context]" : ""}: $e'); + } + } + + /// Executes a command with stdin and safely decodes stdout/stderr + Future<(String stdout, String stderr)> execSafe( + void Function(SSHSession session) callback, { + required String entry, + SystemType? systemType, + String? context, + }) async { + final stdoutBuilder = BytesBuilder(copy: false); + final stderrBuilder = BytesBuilder(copy: false); + final stdoutDone = Completer(); + final stderrDone = Completer(); + + final session = await execute(entry); + + session.stdout.listen( + (e) { + stdoutBuilder.add(e); + }, + onDone: stdoutDone.complete, + onError: stdoutDone.completeError, + ); + + session.stderr.listen( + (e) { + stderrBuilder.add(e); + }, + onDone: stderrDone.complete, + onError: stderrDone.completeError, + ); + + callback(session); + + await stdoutDone.future; + await stderrDone.future; + + final stdoutBytes = stdoutBuilder.takeBytes(); + final stderrBytes = stderrBuilder.takeBytes(); + + final stdout = SSHDecoder.decode( + stdoutBytes, + isWindows: systemType == SystemType.windows, + context: context != null ? '$context (stdout)' : 'stdout', + ); + + final stderr = SSHDecoder.decode( + stderrBytes, + isWindows: systemType == SystemType.windows, + context: context != null ? '$context (stderr)' : 'stderr', + ); + + return (stdout, stderr); + } } diff --git a/lib/data/helper/ssh_decoder.dart b/lib/data/helper/ssh_decoder.dart new file mode 100644 index 000000000..e3ea16dcb --- /dev/null +++ b/lib/data/helper/ssh_decoder.dart @@ -0,0 +1,57 @@ +import 'dart:convert'; + +import 'package:fl_lib/fl_lib.dart'; +import 'package:flutter_gbk2utf8/flutter_gbk2utf8.dart'; + +/// Utility class for decoding SSH command output with encoding fallback +class SSHDecoder { + /// Decodes bytes to string with multiple encoding fallback strategies + /// + /// Tries in order: + /// 1. UTF-8 (with allowMalformed for lenient parsing) + /// 2. GBK (for Windows Chinese systems) + static String decode( + List bytes, { + bool isWindows = false, + String? context, + }) { + if (bytes.isEmpty) return ''; + + // Try UTF-8 first with allowMalformed + try { + final result = utf8.decode(bytes, allowMalformed: true); + // Check if there are replacement characters indicating decode failure + if (!result.contains('�') || !isWindows) { + return result; + } + } catch (e) { + final contextInfo = context != null ? ' [$context]' : ''; + Loggers.app.warning('UTF-8 decode failed$contextInfo: $e'); + } + + // For Windows or when UTF-8 has replacement chars, try GBK + try { + return gbk.decode(bytes); + } catch (e) { + final contextInfo = context != null ? ' [$context]' : ''; + Loggers.app.warning('GBK decode failed$contextInfo: $e'); + // Return empty string if all decoding attempts fail + return ''; + } + } + + /// Encodes string to bytes for SSH command input + /// + /// Uses GBK for Windows, UTF-8 for others + static List encode(String text, {bool isWindows = false}) { + if (isWindows) { + try { + return gbk.encode(text); + } catch (e) { + Loggers.app.warning('GBK encode failed: $e, falling back to UTF-8'); + return utf8.encode(text); + } + } + return utf8.encode(text); + } +} From 6f0874e1e5ec3ab321d2e47d7642227d4320262e Mon Sep 17 00:00:00 2001 From: lxdklp Date: Sat, 8 Nov 2025 18:39:03 +0800 Subject: [PATCH 2/5] fix: PowerShell script error repair, Windows data parsing repair --- lib/core/extension/ssh_client.dart | 48 ++++-- lib/data/helper/system_detector.dart | 16 +- lib/data/model/app/scripts/cmd_types.dart | 4 +- lib/data/model/app/scripts/script_consts.dart | 3 + lib/data/model/server/cpu.dart | 9 +- .../server/server_status_update_req.dart | 29 ++-- lib/data/model/server/time_seq.dart | 14 +- lib/data/model/server/windows_parser.dart | 149 ++++++++++++------ lib/data/provider/server/single.dart | 73 ++++----- 9 files changed, 223 insertions(+), 122 deletions(-) diff --git a/lib/core/extension/ssh_client.dart b/lib/core/extension/ssh_client.dart index 114bf0c87..7b7210206 100644 --- a/lib/core/extension/ssh_client.dart +++ b/lib/core/extension/ssh_client.dart @@ -175,21 +175,26 @@ extension SSHClientX on SSHClient { /// Runs a command and decodes output safely with encoding fallback /// /// [systemType] - The system type (affects encoding choice) - /// [context] - Optional context for debugging + /// Runs a command and safely decodes the result Future runSafe( String command, { SystemType? systemType, String? context, }) async { + // Let SSH errors propagate with their original type (e.g., SSHError subclasses) + final result = await run(command); + + // Only catch decoding failures and add context try { - final result = await run(command); return SSHDecoder.decode( result, isWindows: systemType == SystemType.windows, context: context, ); - } catch (e) { - throw Exception('Failed to run command${context != null ? " [$context]" : ""}: $e'); + } on FormatException catch (e) { + throw Exception( + 'Failed to decode command output${context != null ? " [$context]" : ""}: $e', + ); } } @@ -231,17 +236,32 @@ extension SSHClientX on SSHClient { final stdoutBytes = stdoutBuilder.takeBytes(); final stderrBytes = stderrBuilder.takeBytes(); - final stdout = SSHDecoder.decode( - stdoutBytes, - isWindows: systemType == SystemType.windows, - context: context != null ? '$context (stdout)' : 'stdout', - ); + // Only catch decoding failures, let other errors propagate + String stdout; + try { + stdout = SSHDecoder.decode( + stdoutBytes, + isWindows: systemType == SystemType.windows, + context: context != null ? '$context (stdout)' : 'stdout', + ); + } on FormatException catch (e) { + throw Exception( + 'Failed to decode stdout${context != null ? " [$context]" : ""}: $e', + ); + } - final stderr = SSHDecoder.decode( - stderrBytes, - isWindows: systemType == SystemType.windows, - context: context != null ? '$context (stderr)' : 'stderr', - ); + String stderr; + try { + stderr = SSHDecoder.decode( + stderrBytes, + isWindows: systemType == SystemType.windows, + context: context != null ? '$context (stderr)' : 'stderr', + ); + } on FormatException catch (e) { + throw Exception( + 'Failed to decode stderr${context != null ? " [$context]" : ""}: $e', + ); + } return (stdout, stderr); } diff --git a/lib/data/helper/system_detector.dart b/lib/data/helper/system_detector.dart index 15bf1bcec..21af8516b 100644 --- a/lib/data/helper/system_detector.dart +++ b/lib/data/helper/system_detector.dart @@ -1,5 +1,6 @@ import 'package:dartssh2/dartssh2.dart'; import 'package:fl_lib/fl_lib.dart'; +import 'package:server_box/core/extension/ssh_client.dart'; import 'package:server_box/data/model/server/server_private_info.dart'; import 'package:server_box/data/model/server/system.dart'; @@ -23,7 +24,10 @@ class SystemDetector { try { // Try to detect Unix/Linux/BSD systems first (more reliable and doesn't create files) - final unixResult = await client.run('uname -a 2>/dev/null').string; + final unixResult = await client.runSafe( + 'uname -a 2>/dev/null', + context: 'uname detection for ${spi.oldId}', + ); if (unixResult.contains('Linux')) { detectedSystemType = SystemType.linux; dprint('Detected Linux system type for ${spi.oldId}'); @@ -35,15 +39,19 @@ class SystemDetector { } // If uname fails, try to detect Windows systems - final powershellResult = await client.run('ver 2>nul').string; + final powershellResult = await client.runSafe( + 'ver 2>nul', + systemType: SystemType.windows, + context: 'ver detection for ${spi.oldId}', + ); if (powershellResult.isNotEmpty && (powershellResult.contains('Windows') || powershellResult.contains('NT'))) { detectedSystemType = SystemType.windows; dprint('Detected Windows system type for ${spi.oldId}'); return detectedSystemType; } - } catch (e) { - Loggers.app.warning('System detection failed for ${spi.oldId}: $e'); + } catch (e, stackTrace) { + Loggers.app.warning('System detection failed for ${spi.oldId}: $e\n$stackTrace'); } // Default fallback diff --git a/lib/data/model/app/scripts/cmd_types.dart b/lib/data/model/app/scripts/cmd_types.dart index 4bf6f3112..7a024bbe7 100644 --- a/lib/data/model/app/scripts/cmd_types.dart +++ b/lib/data/model/app/scripts/cmd_types.dart @@ -182,7 +182,7 @@ enum WindowsStatusCmdType implements ShellCmdType { sys('(Get-ComputerInfo).OsName'), cpu( 'Get-WmiObject -Class Win32_Processor | ' - 'Select-Object Name, LoadPercentage | ConvertTo-Json', + 'Select-Object Name, LoadPercentage, NumberOfCores, NumberOfLogicalProcessors | ConvertTo-Json', ), uptime('(Get-CimInstance -ClassName Win32_OperatingSystem).LastBootUpTime'), conn('(netstat -an | findstr ESTABLISHED | Measure-Object -Line).Count'), @@ -287,7 +287,7 @@ enum WindowsStatusCmdType implements ShellCmdType { String get separator => ScriptConstants.getCmdSeparator(name); @override - String get divider => ScriptConstants.getCmdDivider(name); + String get divider => ScriptConstants.getWindowsCmdDivider(name); @override CmdTypeSys get sysType => CmdTypeSys.windows; diff --git a/lib/data/model/app/scripts/script_consts.dart b/lib/data/model/app/scripts/script_consts.dart index a8f920f84..dac8fd7ff 100644 --- a/lib/data/model/app/scripts/script_consts.dart +++ b/lib/data/model/app/scripts/script_consts.dart @@ -29,6 +29,9 @@ class ScriptConstants { /// Generate command-specific divider static String getCmdDivider(String cmdName) => '\necho ${getCmdSeparator(cmdName)}\n\t'; + /// Generate command-specific divider for Windows PowerShell + static String getWindowsCmdDivider(String cmdName) => '\n Write-Host "${getCmdSeparator(cmdName)}"\n '; + /// Parse script output into command-specific map static Map parseScriptOutput(String raw) { final result = {}; diff --git a/lib/data/model/server/cpu.dart b/lib/data/model/server/cpu.dart index ce48381bd..40e0375c8 100644 --- a/lib/data/model/server/cpu.dart +++ b/lib/data/model/server/cpu.dart @@ -14,13 +14,20 @@ class Cpus extends TimeSeq> { @override void onUpdate() { _coresCount = now.length; + if (pre.isEmpty || now.isEmpty || pre.length != now.length) { + _totalDelta = 0; + _user = 0; + _sys = 0; + _iowait = 0; + _idle = 0; + return; + } _totalDelta = now[0].total - pre[0].total; _user = _getUser(); _sys = _getSys(); _iowait = _getIowait(); _idle = _getIdle(); _updateSpots(); - //_updateRange(); } double usedPercent({int coreIdx = 0}) { diff --git a/lib/data/model/server/server_status_update_req.dart b/lib/data/model/server/server_status_update_req.dart index 9e40d2119..3d0f126e9 100644 --- a/lib/data/model/server/server_status_update_req.dart +++ b/lib/data/model/server/server_status_update_req.dart @@ -378,18 +378,27 @@ void _parseWindowsCpuData(ServerStatusUpdateReq req, Map parsedO // Windows CPU parsing - JSON format from PowerShell final cpuRaw = WindowsStatusCmdType.cpu.findInMap(parsedOutput); if (cpuRaw.isNotEmpty && cpuRaw != 'null' && !cpuRaw.contains('error') && !cpuRaw.contains('Exception')) { - final cpus = WindowsParser.parseCpu(cpuRaw, req.ss); - if (cpus.isNotEmpty) { - req.ss.cpu.update(cpus); + final cpuResult = WindowsParser.parseCpu(cpuRaw, req.ss); + if (cpuResult.cores.isNotEmpty) { + req.ss.cpu.update(cpuResult.cores); + final brandRaw = WindowsStatusCmdType.cpuBrand.findInMap(parsedOutput); + if (brandRaw.isNotEmpty && brandRaw != 'null') { + req.ss.cpu.brand.clear(); + final brandLines = brandRaw.trim().split('\n'); + final uniqueBrands = {}; + for (final line in brandLines) { + final trimmedLine = line.trim(); + if (trimmedLine.isNotEmpty) { + uniqueBrands.add(trimmedLine); + } + } + if (uniqueBrands.isNotEmpty) { + final brandName = uniqueBrands.first; + req.ss.cpu.brand[brandName] = cpuResult.coreCount; + } + } } } - - // Windows CPU brand parsing - final brandRaw = WindowsStatusCmdType.cpuBrand.findInMap(parsedOutput); - if (brandRaw.isNotEmpty && brandRaw != 'null') { - req.ss.cpu.brand.clear(); - req.ss.cpu.brand[brandRaw.trim()] = 1; - } } catch (e, s) { Loggers.app.warning('Windows CPU parsing failed: $e', s); } diff --git a/lib/data/model/server/time_seq.dart b/lib/data/model/server/time_seq.dart index b36c782dc..4a564d624 100644 --- a/lib/data/model/server/time_seq.dart +++ b/lib/data/model/server/time_seq.dart @@ -56,8 +56,18 @@ abstract class TimeSeq> extends Fifo { add(new_); if (pre.length != now.length) { - pre.removeWhere((e) => now.any((el) => e.same(el))); - pre.addAll(now.where((e) => pre.every((el) => !e.same(el)))); + final sizeDiff = (pre.length - now.length).abs(); + final isSignificantChange = sizeDiff > 1; + if (isSignificantChange) { + // Replace the pre entry with a new empty list instead of clearing it + // to avoid mutating the historical FIFO data + _list[length - 2] = [] as T; + } else { + final newPre = List.from(pre); + newPre.removeWhere((e) => now.any((el) => e.same(el))); + newPre.addAll(now.where((e) => newPre.every((el) => !e.same(el)))); + _list[length - 2] = newPre as T; + } } onUpdate(); diff --git a/lib/data/model/server/windows_parser.dart b/lib/data/model/server/windows_parser.dart index 379d9b57a..727abfab4 100644 --- a/lib/data/model/server/windows_parser.dart +++ b/lib/data/model/server/windows_parser.dart @@ -7,6 +7,13 @@ import 'package:server_box/data/model/server/disk.dart'; import 'package:server_box/data/model/server/memory.dart'; import 'package:server_box/data/model/server/server.dart'; +/// Windows CPU parse result +class WindowsCpuResult { + final List cores; + final int coreCount; + const WindowsCpuResult(this.cores, this.coreCount); +} + /// Windows-specific status parsing utilities /// /// This module handles parsing of Windows PowerShell command outputs @@ -94,30 +101,75 @@ class WindowsParser { } /// Parse Windows CPU information from PowerShell output - static List parseCpu(String raw, ServerStatus serverStatus) { + /// Returns WindowsCpuResult containing CPU cores and total core count + static WindowsCpuResult parseCpu(String raw, ServerStatus serverStatus) { try { final dynamic jsonData = json.decode(raw); final List cpus = []; + int totalCoreCount = 1; if (jsonData is List) { - for (int i = 0; i < jsonData.length; i++) { - final cpu = jsonData[i]; - final loadPercentage = cpu['LoadPercentage'] ?? 0; - final usage = loadPercentage as int; + // Multiple physical processors + totalCoreCount = 0; // Reset to sum up + var logicalProcessorOffset = 0; + final prevCpus = serverStatus.cpu.now; + for (int procIdx = 0; procIdx < jsonData.length; procIdx++) { + final processor = jsonData[procIdx]; + final loadPercentage = (processor['LoadPercentage'] as num?) ?? 0; + final numberOfCores = (processor['NumberOfCores'] as int?) ?? 1; + final numberOfLogicalProcessors = (processor['NumberOfLogicalProcessors'] as int?) ?? numberOfCores; + totalCoreCount += numberOfCores; + final usage = loadPercentage.toInt(); final idle = 100 - usage; - // Get previous CPU data to calculate cumulative values - final prevCpus = serverStatus.cpu.now; - final prevCpu = i < prevCpus.length ? prevCpus[i] : null; - - // LIMITATION: Windows CPU counters approach - // PowerShell provides LoadPercentage as instantaneous percentage, not cumulative time. - // We simulate cumulative counters by adding current percentages to previous totals. - // This approach has limitations: - // 1. Not as accurate as true cumulative time counters (Linux /proc/stat) - // 2. May drift over time with variable polling intervals - // 3. Results depend on consistent polling frequency - // However, this allows compatibility with existing delta-based CPU calculation logic. + // Create a SingleCpuCore entry for each logical processor + // Windows only reports overall CPU load, so we distribute it evenly + for (int i = 0; i < numberOfLogicalProcessors; i++) { + final coreId = logicalProcessorOffset + i; + // Skip summary entry at index 0 when looking up previous samples + final prevIndex = coreId + 1; + final prevCpu = prevIndex < prevCpus.length ? prevCpus[prevIndex] : null; + + // LIMITATION: Windows CPU counters approach + // PowerShell provides LoadPercentage as instantaneous percentage, not cumulative time. + // We simulate cumulative counters by adding current percentages to previous totals. + // Additionally, Windows only provides overall CPU load, not per-core load. + // We distribute the load evenly across all logical processors. + final newUser = (prevCpu?.user ?? 0) + usage; + final newIdle = (prevCpu?.idle ?? 0) + idle; + + cpus.add( + SingleCpuCore( + 'cpu$coreId', + newUser, // cumulative user time + 0, // sys (not available) + 0, // nice (not available) + newIdle, // cumulative idle time + 0, // iowait (not available) + 0, // irq (not available) + 0, // softirq (not available) + ), + ); + } + logicalProcessorOffset += numberOfLogicalProcessors; + } + } else if (jsonData is Map) { + // Single physical processor + final loadPercentage = (jsonData['LoadPercentage'] as num?) ?? 0; + final numberOfCores = (jsonData['NumberOfCores'] as int?) ?? 1; + final numberOfLogicalProcessors = (jsonData['NumberOfLogicalProcessors'] as int?) ?? numberOfCores; + totalCoreCount = numberOfCores; + final usage = loadPercentage.toInt(); + final idle = 100 - usage; + + // Create a SingleCpuCore entry for each logical processor + final prevCpus = serverStatus.cpu.now; + for (int i = 0; i < numberOfLogicalProcessors; i++) { + // Skip summary entry at index 0 when looking up previous samples + final prevIndex = i + 1; + final prevCpu = prevIndex < prevCpus.length ? prevCpus[prevIndex] : null; + + // LIMITATION: See comment above for Windows CPU counter limitations final newUser = (prevCpu?.user ?? 0) + usage; final newIdle = (prevCpu?.idle ?? 0) + idle; @@ -125,46 +177,43 @@ class WindowsParser { SingleCpuCore( 'cpu$i', newUser, // cumulative user time - 0, // sys (not available) - 0, // nice (not available) + 0, // sys + 0, // nice newIdle, // cumulative idle time - 0, // iowait (not available) - 0, // irq (not available) - 0, // softirq (not available) + 0, // iowait + 0, // irq + 0, // softirq ), ); } - } else if (jsonData is Map) { - // Single CPU core - final loadPercentage = jsonData['LoadPercentage'] ?? 0; - final usage = loadPercentage as int; - final idle = 100 - usage; + } - // Get previous CPU data to calculate cumulative values - final prevCpus = serverStatus.cpu.now; - final prevCpu = prevCpus.isNotEmpty ? prevCpus[0] : null; - - // LIMITATION: See comment above for Windows CPU counter limitations - final newUser = (prevCpu?.user ?? 0) + usage; - final newIdle = (prevCpu?.idle ?? 0) + idle; - - cpus.add( - SingleCpuCore( - 'cpu0', - newUser, // cumulative user time - 0, // sys - 0, // nice - newIdle, // cumulative idle time - 0, // iowait - 0, // irq - 0, // softirq - ), - ); + // Add a summary entry at the beginning (like Linux 'cpu' line) + // This is the aggregate of all logical processors + if (cpus.isNotEmpty) { + int totalUser = 0; + int totalIdle = 0; + for (final core in cpus) { + totalUser += core.user; + totalIdle += core.idle; + } + // Insert at the beginning with ID 'cpu' (matching Linux format) + cpus.insert(0, SingleCpuCore( + 'cpu', // Summary entry, like Linux + totalUser, + 0, + 0, + totalIdle, + 0, + 0, + 0, + )); } - return cpus; - } catch (e) { - return []; + return WindowsCpuResult(cpus, totalCoreCount); + } catch (e, s) { + Loggers.app.warning('Windows CPU parsing failed: $e', s); + return WindowsCpuResult([], 1); } } diff --git a/lib/data/provider/server/single.dart b/lib/data/provider/server/single.dart index 4298a82e2..77a255fd3 100644 --- a/lib/data/provider/server/single.dart +++ b/lib/data/provider/server/single.dart @@ -1,15 +1,14 @@ import 'dart:async'; -import 'dart:convert'; import 'package:computer/computer.dart'; import 'package:dartssh2/dartssh2.dart'; import 'package:fl_lib/fl_lib.dart'; -import 'package:flutter_gbk2utf8/flutter_gbk2utf8.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:server_box/core/extension/ssh_client.dart'; import 'package:server_box/core/utils/server.dart'; import 'package:server_box/core/utils/ssh_auth.dart'; +import 'package:server_box/data/helper/ssh_decoder.dart'; 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/scripts/script_consts.dart'; @@ -213,7 +212,9 @@ class ServerNotifier extends _$ServerNotifier { final newStatus = state.status..system = detectedSystemType; updateStatus(newStatus); - final (_, writeScriptResult) = await state.client!.exec( + Loggers.app.info('Writing script for ${spi.name} (${detectedSystemType.name})'); + + final (stdoutResult, writeScriptResult) = await state.client!.execSafe( (session) async { final scriptRaw = ShellFuncManager.allScript( spi.custom?.cmds, @@ -228,10 +229,22 @@ class ServerNotifier extends _$ServerNotifier { systemType: detectedSystemType, customDir: spi.custom?.scriptDir, ), + systemType: detectedSystemType, + context: 'WriteScript<${spi.name}>', ); - if (writeScriptResult.isNotEmpty && detectedSystemType != SystemType.windows) { - ShellFuncManager.switchScriptDir(spi.id, systemType: detectedSystemType); - throw writeScriptResult; + + if (stdoutResult.isNotEmpty) { + Loggers.app.info('Script write stdout for ${spi.name}: $stdoutResult'); + } + + if (writeScriptResult.isNotEmpty) { + Loggers.app.warning('Script write stderr for ${spi.name}: $writeScriptResult'); + if (detectedSystemType != SystemType.windows) { + ShellFuncManager.switchScriptDir(spi.id, systemType: detectedSystemType); + throw writeScriptResult; + } + } else { + Loggers.app.info('Script written successfully for ${spi.name}'); } } on SSHAuthAbortError catch (e) { TryLimiter.inc(sid); @@ -278,43 +291,25 @@ class ServerNotifier extends _$ServerNotifier { String? raw; try { - final execResult = await state.client?.run( - ShellFunc.status.exec(spi.id, systemType: state.status.system, customDir: spi.custom?.scriptDir), - ); + final statusCmd = ShellFunc.status.exec(spi.id, systemType: state.status.system, customDir: spi.custom?.scriptDir); + Loggers.app.info('Running status command for ${spi.name} (${state.status.system.name}): $statusCmd'); + final execResult = await state.client?.run(statusCmd); if (execResult != null) { - String? rawStr; - bool needGbk = false; - try { - rawStr = utf8.decode(execResult, allowMalformed: true); - // If there are unparseable characters, try fallback to GBK decoding - if (rawStr.contains('�')) { - Loggers.app.warning('UTF8 decoding failed, use GBK decoding'); - needGbk = true; - } - } catch (e) { - Loggers.app.warning('UTF8 decoding failed, use GBK decoding', e); - needGbk = true; - } - if (needGbk) { - try { - rawStr = gbk.decode(execResult); - } catch (e2) { - Loggers.app.warning('GBK decoding failed', e2); - rawStr = null; - } - } - if (rawStr == null) { - Loggers.app.warning('Decoding failed, execResult: $execResult'); - } - raw = rawStr; + raw = SSHDecoder.decode( + execResult, + isWindows: state.status.system == SystemType.windows, + context: 'GetStatus<${spi.name}>', + ); + Loggers.app.info('Status response length for ${spi.name}: ${raw.length} bytes'); } else { - raw = execResult.toString(); + raw = ''; + Loggers.app.warning('No status result from ${spi.name}'); } - if (raw == null || raw.isEmpty) { + if (raw.isEmpty) { TryLimiter.inc(sid); final newStatus = state.status - ..err = SSHErr(type: SSHErrType.segements, message: 'decode or split failed, raw:\n$raw'); + ..err = SSHErr(type: SSHErrType.segements, message: 'Empty response from server'); updateStatus(newStatus); updateConnection(ServerConn.failed); @@ -324,7 +319,7 @@ class ServerNotifier extends _$ServerNotifier { } segments = raw.split(ScriptConstants.separator).map((e) => e.trim()).toList(); - if (raw.isEmpty || segments.isEmpty) { + if (segments.isEmpty) { if (Stores.setting.keepStatusWhenErr.fetch()) { // Keep previous server status when error occurs if (state.conn != ServerConn.failed && state.status.more.isNotEmpty) { @@ -333,7 +328,7 @@ class ServerNotifier extends _$ServerNotifier { } TryLimiter.inc(sid); final newStatus = state.status - ..err = SSHErr(type: SSHErrType.segements, message: 'Seperate segments failed, raw:\n$raw'); + ..err = SSHErr(type: SSHErrType.segements, message: 'Separate segments failed, raw:\n$raw'); updateStatus(newStatus); updateConnection(ServerConn.failed); From 4c6d764f52da6f3f7e14002eccc922ecd402d524 Mon Sep 17 00:00:00 2001 From: lxdklp Date: Sat, 8 Nov 2025 20:17:55 +0800 Subject: [PATCH 3/5] fix: Unable to obtain network card information --- lib/data/model/app/scripts/cmd_types.dart | 37 +++-- .../server/server_status_update_req.dart | 131 ++++++++---------- 2 files changed, 72 insertions(+), 96 deletions(-) diff --git a/lib/data/model/app/scripts/cmd_types.dart b/lib/data/model/app/scripts/cmd_types.dart index 7a024bbe7..a1479759b 100644 --- a/lib/data/model/app/scripts/cmd_types.dart +++ b/lib/data/model/app/scripts/cmd_types.dart @@ -166,18 +166,17 @@ enum WindowsStatusCmdType implements ShellCmdType { echo('echo ${SystemType.windowsSign}'), time('[DateTimeOffset]::UtcNow.ToUnixTimeSeconds()'), - /// Get network interface statistics using Windows Performance Counters + /// Get network interface statistics using WMI /// - /// Uses Get-Counter to collect network I/O metrics from all network interfaces: - /// - Collects bytes received and sent per second for all network interfaces + /// Uses WMI Win32_PerfRawData_Tcpip_NetworkInterface for cross-language compatibility: /// - Takes 2 samples with 1 second interval to calculate rates - /// - Outputs results in JSON format for easy parsing - /// - Counter paths use double backslashes to escape PowerShell string literals net( - r'Get-Counter -Counter ' - r'"\\NetworkInterface(*)\\Bytes Received/sec", ' - r'"\\NetworkInterface(*)\\Bytes Sent/sec" ' - r'-SampleInterval 1 -MaxSamples 2 | ConvertTo-Json', + r'$s1 = @(Get-WmiObject Win32_PerfRawData_Tcpip_NetworkInterface | ' + r'Select-Object Name, BytesReceivedPersec, BytesSentPersec, Timestamp_Sys100NS); ' + r'Start-Sleep -Seconds 1; ' + r'$s2 = @(Get-WmiObject Win32_PerfRawData_Tcpip_NetworkInterface | ' + r'Select-Object Name, BytesReceivedPersec, BytesSentPersec, Timestamp_Sys100NS); ' + r'@($s1, $s2) | ConvertTo-Json -Depth 5', ), sys('(Get-ComputerInfo).OsName'), cpu( @@ -213,19 +212,19 @@ enum WindowsStatusCmdType implements ShellCmdType { ), host(r'Write-Output $env:COMPUTERNAME'), - /// Get disk I/O statistics using Windows Performance Counters + /// Get disk I/O statistics using WMI /// - /// Uses Get-Counter to collect disk I/O metrics from all physical disks: + /// Uses WMI Win32_PerfRawData_PerfDisk_PhysicalDisk: /// - Monitors read and write bytes per second for all physical disks - /// - Takes 2 samples with 1 second interval to calculate I/O rates - /// - Physical disk counters provide hardware-level I/O statistics - /// - Outputs results in JSON format for parsing - /// - Counter names use wildcard (*) to capture all disk instances + /// - Takes 2 samples with 1 second interval to calculate rates + /// - DiskReadBytesPersec and DiskWriteBytesPersec are cumulative counters diskio( - r'Get-Counter -Counter ' - r'"\\PhysicalDisk(*)\\Disk Read Bytes/sec", ' - r'"\\PhysicalDisk(*)\\Disk Write Bytes/sec" ' - r'-SampleInterval 1 -MaxSamples 2 | ConvertTo-Json', + r'$s1 = @(Get-WmiObject Win32_PerfRawData_PerfDisk_PhysicalDisk | ' + r'Select-Object Name, DiskReadBytesPersec, DiskWriteBytesPersec, Timestamp_Sys100NS); ' + r'Start-Sleep -Seconds 1; ' + r'$s2 = @(Get-WmiObject Win32_PerfRawData_PerfDisk_PhysicalDisk | ' + r'Select-Object Name, DiskReadBytesPersec, DiskWriteBytesPersec, Timestamp_Sys100NS); ' + r'@($s1, $s2) | ConvertTo-Json -Depth 5', ), battery( 'Get-WmiObject -Class Win32_Battery | ' diff --git a/lib/data/model/server/server_status_update_req.dart b/lib/data/model/server/server_status_update_req.dart index 3d0f126e9..98dcbe693 100644 --- a/lib/data/model/server/server_status_update_req.dart +++ b/lib/data/model/server/server_status_update_req.dart @@ -550,38 +550,33 @@ List _parseWindowsNetwork(String raw, int currentTime) { final dynamic jsonData = json.decode(raw); final List netParts = []; - // PowerShell Get-Counter returns a structure with CounterSamples - if (jsonData is Map && jsonData.containsKey('CounterSamples')) { - final samples = jsonData['CounterSamples'] as List?; - if (samples != null && samples.length >= 2) { - // We need 2 samples to calculate speed (interval between them) - final Map interfaceRx = {}; - final Map interfaceTx = {}; - - for (final sample in samples) { - final path = sample['Path']?.toString() ?? ''; - final cookedValue = sample['CookedValue'] as num? ?? 0; - - if (path.contains('Bytes Received/sec')) { - final interfaceName = _extractInterfaceName(path); - if (interfaceName.isNotEmpty) { - interfaceRx[interfaceName] = cookedValue.toDouble(); - } - } else if (path.contains('Bytes Sent/sec')) { - final interfaceName = _extractInterfaceName(path); - if (interfaceName.isNotEmpty) { - interfaceTx[interfaceName] = cookedValue.toDouble(); - } - } - } - - // Create NetSpeedPart for each interface - for (final interfaceName in interfaceRx.keys) { - final rx = interfaceRx[interfaceName] ?? 0; - final tx = interfaceTx[interfaceName] ?? 0; - + if (jsonData is List && jsonData.length >= 2) { + var sample1 = jsonData[jsonData.length - 2]; + var sample2 = jsonData[jsonData.length - 1]; + if (sample1 is Map && sample1.containsKey('value')) { + sample1 = sample1['value']; + } + if (sample2 is Map && sample2.containsKey('value')) { + sample2 = sample2['value']; + } + if (sample1 is List && sample2 is List && sample1.length == sample2.length) { + for (int i = 0; i < sample1.length; i++) { + final s1 = sample1[i]; + final s2 = sample2[i]; + final name = s1['Name']?.toString() ?? ''; + if (name.isEmpty || name == '_Total') continue; + final rx1 = (s1['BytesReceivedPersec'] as num?)?.toDouble() ?? 0; + final rx2 = (s2['BytesReceivedPersec'] as num?)?.toDouble() ?? 0; + final tx1 = (s1['BytesSentPersec'] as num?)?.toDouble() ?? 0; + final tx2 = (s2['BytesSentPersec'] as num?)?.toDouble() ?? 0; + final time1 = (s1['Timestamp_Sys100NS'] as num?)?.toDouble() ?? 0; + final time2 = (s2['Timestamp_Sys100NS'] as num?)?.toDouble() ?? 0; + final timeDelta = (time2 - time1) / 10000000; + if (timeDelta <= 0) continue; + final rxSpeed = ((rx2 - rx1) / timeDelta).abs(); + final txSpeed = ((tx2 - tx1) / timeDelta).abs(); netParts.add( - NetSpeedPart(interfaceName, BigInt.from(rx.toInt()), BigInt.from(tx.toInt()), currentTime), + NetSpeedPart(name, BigInt.from(rxSpeed.toInt()), BigInt.from(txSpeed.toInt()), currentTime), ); } } @@ -593,53 +588,42 @@ List _parseWindowsNetwork(String raw, int currentTime) { } } -String _extractInterfaceName(String path) { - // Extract interface name from path like - // "\\Computer\\NetworkInterface(Interface Name)\\..." - final match = RegExp(r'\\NetworkInterface\(([^)]+)\)\\').firstMatch(path); - return match?.group(1) ?? ''; -} - List _parseWindowsDiskIO(String raw, int currentTime) { try { final dynamic jsonData = json.decode(raw); final List diskParts = []; - // PowerShell Get-Counter returns a structure with CounterSamples - if (jsonData is Map && jsonData.containsKey('CounterSamples')) { - final samples = jsonData['CounterSamples'] as List?; - if (samples != null) { - final Map diskReads = {}; - final Map diskWrites = {}; - - for (final sample in samples) { - final path = sample['Path']?.toString() ?? ''; - final cookedValue = sample['CookedValue'] as num? ?? 0; - - if (path.contains('Disk Read Bytes/sec')) { - final diskName = _extractDiskName(path); - if (diskName.isNotEmpty) { - diskReads[diskName] = cookedValue.toDouble(); - } - } else if (path.contains('Disk Write Bytes/sec')) { - final diskName = _extractDiskName(path); - if (diskName.isNotEmpty) { - diskWrites[diskName] = cookedValue.toDouble(); - } - } - } - - // Create DiskIOPiece for each disk - convert bytes to sectors - // (assuming 512 bytes per sector) - for (final diskName in diskReads.keys) { - final readBytes = diskReads[diskName] ?? 0; - final writeBytes = diskWrites[diskName] ?? 0; - final sectorsRead = (readBytes / 512).round(); - final sectorsWrite = (writeBytes / 512).round(); + if (jsonData is List && jsonData.length >= 2) { + var sample1 = jsonData[jsonData.length - 2]; + var sample2 = jsonData[jsonData.length - 1]; + if (sample1 is Map && sample1.containsKey('value')) { + sample1 = sample1['value']; + } + if (sample2 is Map && sample2.containsKey('value')) { + sample2 = sample2['value']; + } + if (sample1 is List && sample2 is List && sample1.length == sample2.length) { + for (int i = 0; i < sample1.length; i++) { + final s1 = sample1[i]; + final s2 = sample2[i]; + final name = s1['Name']?.toString() ?? ''; + if (name.isEmpty || name == '_Total') continue; + final read1 = (s1['DiskReadBytesPersec'] as num?)?.toDouble() ?? 0; + final read2 = (s2['DiskReadBytesPersec'] as num?)?.toDouble() ?? 0; + final write1 = (s1['DiskWriteBytesPersec'] as num?)?.toDouble() ?? 0; + final write2 = (s2['DiskWriteBytesPersec'] as num?)?.toDouble() ?? 0; + final time1 = (s1['Timestamp_Sys100NS'] as num?)?.toDouble() ?? 0; + final time2 = (s2['Timestamp_Sys100NS'] as num?)?.toDouble() ?? 0; + final timeDelta = (time2 - time1) / 10000000; + if (timeDelta <= 0) continue; + final readSpeed = ((read2 - read1) / timeDelta).abs(); + final writeSpeed = ((write2 - write1) / timeDelta).abs(); + final sectorsRead = (readSpeed / 512).round(); + final sectorsWrite = (writeSpeed / 512).round(); diskParts.add( DiskIOPiece( - dev: diskName, + dev: name, sectorsRead: sectorsRead, sectorsWrite: sectorsWrite, time: currentTime, @@ -655,13 +639,6 @@ List _parseWindowsDiskIO(String raw, int currentTime) { } } -String _extractDiskName(String path) { - // Extract disk name from path like - // "\\Computer\\PhysicalDisk(Disk Name)\\..." - final match = RegExp(r'\\PhysicalDisk\(([^)]+)\)\\').firstMatch(path); - return match?.group(1) ?? ''; -} - void _parseWindowsTemperatures(Temperatures temps, String raw) { try { // Handle error output From c7ea1c88ca60a7e10f15e8dbe282acfb4753a7d8 Mon Sep 17 00:00:00 2001 From: lxdklp Date: Sat, 8 Nov 2025 20:35:24 +0800 Subject: [PATCH 4/5] fix: Unable to obtain system startup time --- lib/data/helper/ssh_decoder.dart | 9 +++++++++ lib/data/model/app/scripts/cmd_types.dart | 14 +++++++++++++- lib/data/model/app/scripts/script_consts.dart | 1 + .../model/server/server_status_update_req.dart | 7 +++++-- 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/lib/data/helper/ssh_decoder.dart b/lib/data/helper/ssh_decoder.dart index e3ea16dcb..a54af0add 100644 --- a/lib/data/helper/ssh_decoder.dart +++ b/lib/data/helper/ssh_decoder.dart @@ -9,7 +9,10 @@ class SSHDecoder { /// /// Tries in order: /// 1. UTF-8 (with allowMalformed for lenient parsing) + /// - Windows PowerShell scripts now set UTF-8 output encoding by default /// 2. GBK (for Windows Chinese systems) + /// - In some cases, Windows will still revert to GBK. + /// - Only attempted if UTF-8 produces replacement characters (�) static String decode( List bytes, { bool isWindows = false, @@ -21,9 +24,15 @@ class SSHDecoder { try { final result = utf8.decode(bytes, allowMalformed: true); // Check if there are replacement characters indicating decode failure + // For non-Windows systems, always use UTF-8 result if (!result.contains('�') || !isWindows) { return result; } + // For Windows with replacement chars, log and try GBK fallback + if (isWindows && result.contains('�')) { + final contextInfo = context != null ? ' [$context]' : ''; + Loggers.app.info('UTF-8 decode has replacement chars$contextInfo, trying GBK fallback'); + } } catch (e) { final contextInfo = context != null ? ' [$context]' : ''; Loggers.app.warning('UTF-8 decode failed$contextInfo: $e'); diff --git a/lib/data/model/app/scripts/cmd_types.dart b/lib/data/model/app/scripts/cmd_types.dart index a1479759b..0b6274d4e 100644 --- a/lib/data/model/app/scripts/cmd_types.dart +++ b/lib/data/model/app/scripts/cmd_types.dart @@ -183,7 +183,19 @@ enum WindowsStatusCmdType implements ShellCmdType { 'Get-WmiObject -Class Win32_Processor | ' 'Select-Object Name, LoadPercentage, NumberOfCores, NumberOfLogicalProcessors | ConvertTo-Json', ), - uptime('(Get-CimInstance -ClassName Win32_OperatingSystem).LastBootUpTime'), + + /// Get system uptime by calculating time since last boot + /// + /// Calculates uptime directly in PowerShell to avoid date format parsing issues: + /// - Gets LastBootUpTime from Win32_OperatingSystem + /// - Calculates difference from current time + /// - Returns pre-formatted string: "X days, H:MM" or "H:MM" (if less than 1 day) + /// - Uses ToString('00') for zero-padding to avoid quote escaping issues + uptime( + r'$up = (Get-Date) - (Get-CimInstance Win32_OperatingSystem).LastBootUpTime; ' + r'if ($up.Days -gt 0) { "$($up.Days) days, $($up.Hours):$($up.Minutes.ToString(''00''))" } ' + r'else { "$($up.Hours):$($up.Minutes.ToString(''00''))" }', + ), conn('(netstat -an | findstr ESTABLISHED | Measure-Object -Line).Count'), disk( 'Get-WmiObject -Class Win32_LogicalDisk | ' diff --git a/lib/data/model/app/scripts/script_consts.dart b/lib/data/model/app/scripts/script_consts.dart index dac8fd7ff..ac5f20799 100644 --- a/lib/data/model/app/scripts/script_consts.dart +++ b/lib/data/model/app/scripts/script_consts.dart @@ -105,6 +105,7 @@ exec 2>/dev/null # DO NOT delete this file while app is running \$ErrorActionPreference = "SilentlyContinue" +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 '''; } diff --git a/lib/data/model/server/server_status_update_req.dart b/lib/data/model/server/server_status_update_req.dart index 98dcbe693..dd4c3d398 100644 --- a/lib/data/model/server/server_status_update_req.dart +++ b/lib/data/model/server/server_status_update_req.dart @@ -436,8 +436,11 @@ void _parseWindowsDiskData(ServerStatusUpdateReq req, Map parsed /// Parse Windows uptime data void _parseWindowsUptimeData(ServerStatusUpdateReq req, Map parsedOutput) { try { - final uptime = WindowsParser.parseUpTime(WindowsStatusCmdType.uptime.findInMap(parsedOutput)); - if (uptime != null) { + final uptimeRaw = WindowsStatusCmdType.uptime.findInMap(parsedOutput); + if (uptimeRaw.isNotEmpty && uptimeRaw != 'null') { + // PowerShell now returns pre-formatted uptime string (e.g., "28 days, 5:00" or "5:00") + // No parsing needed - use it directly + final uptime = uptimeRaw.trim(); req.ss.more[StatusCmdType.uptime] = uptime; } } catch (e, s) { From 13bfd3d7ba3f914a71341af13e6be0152cbcc3aa Mon Sep 17 00:00:00 2001 From: lxdklp Date: Sat, 8 Nov 2025 21:22:46 +0800 Subject: [PATCH 5/5] fix conversation as resolved. --- lib/data/model/app/scripts/cmd_types.dart | 4 +-- lib/data/model/server/cpu.dart | 2 +- lib/data/model/server/disk.dart | 2 +- lib/data/model/server/net_speed.dart | 2 +- .../server/server_status_update_req.dart | 14 +++++--- lib/data/model/server/time_seq.dart | 34 ++++++++++--------- 6 files changed, 32 insertions(+), 26 deletions(-) diff --git a/lib/data/model/app/scripts/cmd_types.dart b/lib/data/model/app/scripts/cmd_types.dart index 0b6274d4e..ec78c5799 100644 --- a/lib/data/model/app/scripts/cmd_types.dart +++ b/lib/data/model/app/scripts/cmd_types.dart @@ -192,9 +192,7 @@ enum WindowsStatusCmdType implements ShellCmdType { /// - Returns pre-formatted string: "X days, H:MM" or "H:MM" (if less than 1 day) /// - Uses ToString('00') for zero-padding to avoid quote escaping issues uptime( - r'$up = (Get-Date) - (Get-CimInstance Win32_OperatingSystem).LastBootUpTime; ' - r'if ($up.Days -gt 0) { "$($up.Days) days, $($up.Hours):$($up.Minutes.ToString(''00''))" } ' - r'else { "$($up.Hours):$($up.Minutes.ToString(''00''))" }', + r"""$up = (Get-Date) - (Get-CimInstance Win32_OperatingSystem).LastBootUpTime; if ($up.Days -gt 0) { "$($up.Days) days, $($up.Hours):$($up.Minutes.ToString('00'))" } else { "$($up.Hours):$($up.Minutes.ToString('00'))" }""", ), conn('(netstat -an | findstr ESTABLISHED | Measure-Object -Line).Count'), disk( diff --git a/lib/data/model/server/cpu.dart b/lib/data/model/server/cpu.dart index 40e0375c8..1e8c8e122 100644 --- a/lib/data/model/server/cpu.dart +++ b/lib/data/model/server/cpu.dart @@ -6,7 +6,7 @@ import 'package:server_box/data/res/status.dart'; /// Capacity of the FIFO queue const _kCap = 30; -class Cpus extends TimeSeq> { +class Cpus extends TimeSeq { Cpus(super.init1, super.init2); final Map brand = {}; diff --git a/lib/data/model/server/disk.dart b/lib/data/model/server/disk.dart index 3a34527d3..94448dfc5 100644 --- a/lib/data/model/server/disk.dart +++ b/lib/data/model/server/disk.dart @@ -280,7 +280,7 @@ class Disk with EquatableMixin { ]; } -class DiskIO extends TimeSeq> { +class DiskIO extends TimeSeq { DiskIO(super.init1, super.init2); @override diff --git a/lib/data/model/server/net_speed.dart b/lib/data/model/server/net_speed.dart index 09407e600..854e5ce8a 100644 --- a/lib/data/model/server/net_speed.dart +++ b/lib/data/model/server/net_speed.dart @@ -18,7 +18,7 @@ class NetSpeedPart extends TimeSeqIface { typedef CachedNetVals = ({String sizeIn, String sizeOut, String speedIn, String speedOut}); -class NetSpeed extends TimeSeq> { +class NetSpeed extends TimeSeq { NetSpeed(super.init1, super.init2); @override diff --git a/lib/data/model/server/server_status_update_req.dart b/lib/data/model/server/server_status_update_req.dart index dd4c3d398..036c71d55 100644 --- a/lib/data/model/server/server_status_update_req.dart +++ b/lib/data/model/server/server_status_update_req.dart @@ -576,8 +576,11 @@ List _parseWindowsNetwork(String raw, int currentTime) { final time2 = (s2['Timestamp_Sys100NS'] as num?)?.toDouble() ?? 0; final timeDelta = (time2 - time1) / 10000000; if (timeDelta <= 0) continue; - final rxSpeed = ((rx2 - rx1) / timeDelta).abs(); - final txSpeed = ((tx2 - tx1) / timeDelta).abs(); + final rxDelta = rx2 - rx1; + final txDelta = tx2 - tx1; + if (rxDelta < 0 || txDelta < 0) continue; + final rxSpeed = rxDelta / timeDelta; + final txSpeed = txDelta / timeDelta; netParts.add( NetSpeedPart(name, BigInt.from(rxSpeed.toInt()), BigInt.from(txSpeed.toInt()), currentTime), ); @@ -619,8 +622,11 @@ List _parseWindowsDiskIO(String raw, int currentTime) { final time2 = (s2['Timestamp_Sys100NS'] as num?)?.toDouble() ?? 0; final timeDelta = (time2 - time1) / 10000000; if (timeDelta <= 0) continue; - final readSpeed = ((read2 - read1) / timeDelta).abs(); - final writeSpeed = ((write2 - write1) / timeDelta).abs(); + final readDelta = read2 - read1; + final writeDelta = write2 - write1; + if (readDelta < 0 || writeDelta < 0) continue; + final readSpeed = readDelta / timeDelta; + final writeSpeed = writeDelta / timeDelta; final sectorsRead = (readSpeed / 512).round(); final sectorsWrite = (writeSpeed / 512).round(); diff --git a/lib/data/model/server/time_seq.dart b/lib/data/model/server/time_seq.dart index 4a564d624..42dbc394a 100644 --- a/lib/data/model/server/time_seq.dart +++ b/lib/data/model/server/time_seq.dart @@ -37,37 +37,39 @@ class Fifo extends ListBase { } } -abstract class TimeSeq> extends Fifo { +abstract class TimeSeq> extends Fifo> { /// Due to the design, at least two elements are required, otherwise [pre] / /// [now] will throw. - TimeSeq(T init1, T init2, {super.capacity}) : super(list: [init1, init2]); + TimeSeq(List init1, List init2, {super.capacity}) : super(list: [init1, init2]); - T get pre { + List get pre { return _list[length - 2]; } - T get now { + List get now { return _list[length - 1]; } void onUpdate(); - void update(T new_) { + void update(List new_) { add(new_); if (pre.length != now.length) { - final sizeDiff = (pre.length - now.length).abs(); - final isSignificantChange = sizeDiff > 1; - if (isSignificantChange) { - // Replace the pre entry with a new empty list instead of clearing it - // to avoid mutating the historical FIFO data - _list[length - 2] = [] as T; - } else { - final newPre = List.from(pre); - newPre.removeWhere((e) => now.any((el) => e.same(el))); - newPre.addAll(now.where((e) => newPre.every((el) => !e.same(el)))); - _list[length - 2] = newPre as T; + final previous = pre.toList(growable: false); + final remaining = previous.toList(growable: true); + final aligned = []; + + for (final current in now) { + final matchIndex = remaining.indexWhere((item) => item.same(current)); + if (matchIndex >= 0) { + aligned.add(remaining.removeAt(matchIndex)); + } else { + aligned.add(current); + } } + + _list[length - 2] = aligned; } onUpdate();