mirror of
https://github.com/lollipopkit/flutter_server_box.git
synced 2025-12-17 07:14:28 +01:00
fix: Unable to obtain Windows server information (#963)
* fix: FormatException: Unexpected extension byte (at offset 8) error * fix: PowerShell script error repair, Windows data parsing repair * fix: Unable to obtain network card information * fix: Unable to obtain system startup time * fix conversation as resolved.
This commit is contained in:
@@ -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,98 @@ extension SSHClientX on SSHClient {
|
||||
);
|
||||
return ret.$2;
|
||||
}
|
||||
|
||||
/// Runs a command and decodes output safely with encoding fallback
|
||||
///
|
||||
/// [systemType] - The system type (affects encoding choice)
|
||||
/// Runs a command and safely decodes the result
|
||||
Future<String> 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 {
|
||||
return SSHDecoder.decode(
|
||||
result,
|
||||
isWindows: systemType == SystemType.windows,
|
||||
context: context,
|
||||
);
|
||||
} on FormatException catch (e) {
|
||||
throw Exception(
|
||||
'Failed to decode command output${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<void>();
|
||||
final stderrDone = Completer<void>();
|
||||
|
||||
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();
|
||||
|
||||
// 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',
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
66
lib/data/helper/ssh_decoder.dart
Normal file
66
lib/data/helper/ssh_decoder.dart
Normal file
@@ -0,0 +1,66 @@
|
||||
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)
|
||||
/// - 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 (<28>)
|
||||
static String decode(
|
||||
List<int> 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
|
||||
// For non-Windows systems, always use UTF-8 result
|
||||
if (!result.contains('<EFBFBD>') || !isWindows) {
|
||||
return result;
|
||||
}
|
||||
// For Windows with replacement chars, log and try GBK fallback
|
||||
if (isWindows && result.contains('<EFBFBD>')) {
|
||||
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');
|
||||
}
|
||||
|
||||
// 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<int> 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -166,25 +166,34 @@ 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(
|
||||
'Get-WmiObject -Class Win32_Processor | '
|
||||
'Select-Object Name, LoadPercentage | ConvertTo-Json',
|
||||
'Select-Object Name, LoadPercentage, NumberOfCores, NumberOfLogicalProcessors | ConvertTo-Json',
|
||||
),
|
||||
|
||||
/// 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; if ($up.Days -gt 0) { "$($up.Days) days, $($up.Hours):$($up.Minutes.ToString('00'))" } else { "$($up.Hours):$($up.Minutes.ToString('00'))" }""",
|
||||
),
|
||||
uptime('(Get-CimInstance -ClassName Win32_OperatingSystem).LastBootUpTime'),
|
||||
conn('(netstat -an | findstr ESTABLISHED | Measure-Object -Line).Count'),
|
||||
disk(
|
||||
'Get-WmiObject -Class Win32_LogicalDisk | '
|
||||
@@ -213,19 +222,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 | '
|
||||
@@ -287,7 +296,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;
|
||||
|
||||
@@ -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<String, String> parseScriptOutput(String raw) {
|
||||
final result = <String, String>{};
|
||||
@@ -102,6 +105,7 @@ exec 2>/dev/null
|
||||
# DO NOT delete this file while app is running
|
||||
|
||||
\$ErrorActionPreference = "SilentlyContinue"
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
''';
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import 'package:server_box/data/res/status.dart';
|
||||
/// Capacity of the FIFO queue
|
||||
const _kCap = 30;
|
||||
|
||||
class Cpus extends TimeSeq<List<SingleCpuCore>> {
|
||||
class Cpus extends TimeSeq<SingleCpuCore> {
|
||||
Cpus(super.init1, super.init2);
|
||||
|
||||
final Map<String, int> brand = {};
|
||||
@@ -14,13 +14,20 @@ class Cpus extends TimeSeq<List<SingleCpuCore>> {
|
||||
@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}) {
|
||||
|
||||
@@ -280,7 +280,7 @@ class Disk with EquatableMixin {
|
||||
];
|
||||
}
|
||||
|
||||
class DiskIO extends TimeSeq<List<DiskIOPiece>> {
|
||||
class DiskIO extends TimeSeq<DiskIOPiece> {
|
||||
DiskIO(super.init1, super.init2);
|
||||
|
||||
@override
|
||||
|
||||
@@ -18,7 +18,7 @@ class NetSpeedPart extends TimeSeqIface<NetSpeedPart> {
|
||||
|
||||
typedef CachedNetVals = ({String sizeIn, String sizeOut, String speedIn, String speedOut});
|
||||
|
||||
class NetSpeed extends TimeSeq<List<NetSpeedPart>> {
|
||||
class NetSpeed extends TimeSeq<NetSpeedPart> {
|
||||
NetSpeed(super.init1, super.init2);
|
||||
|
||||
@override
|
||||
|
||||
@@ -378,18 +378,27 @@ void _parseWindowsCpuData(ServerStatusUpdateReq req, Map<String, String> 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 = <String>{};
|
||||
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);
|
||||
}
|
||||
@@ -427,8 +436,11 @@ void _parseWindowsDiskData(ServerStatusUpdateReq req, Map<String, String> parsed
|
||||
/// Parse Windows uptime data
|
||||
void _parseWindowsUptimeData(ServerStatusUpdateReq req, Map<String, String> 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) {
|
||||
@@ -541,38 +553,36 @@ List<NetSpeedPart> _parseWindowsNetwork(String raw, int currentTime) {
|
||||
final dynamic jsonData = json.decode(raw);
|
||||
final List<NetSpeedPart> 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<String, double> interfaceRx = {};
|
||||
final Map<String, double> 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 rxDelta = rx2 - rx1;
|
||||
final txDelta = tx2 - tx1;
|
||||
if (rxDelta < 0 || txDelta < 0) continue;
|
||||
final rxSpeed = rxDelta / timeDelta;
|
||||
final txSpeed = txDelta / timeDelta;
|
||||
netParts.add(
|
||||
NetSpeedPart(interfaceName, BigInt.from(rx.toInt()), BigInt.from(tx.toInt()), currentTime),
|
||||
NetSpeedPart(name, BigInt.from(rxSpeed.toInt()), BigInt.from(txSpeed.toInt()), currentTime),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -584,53 +594,45 @@ List<NetSpeedPart> _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<DiskIOPiece> _parseWindowsDiskIO(String raw, int currentTime) {
|
||||
try {
|
||||
final dynamic jsonData = json.decode(raw);
|
||||
final List<DiskIOPiece> 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<String, double> diskReads = {};
|
||||
final Map<String, double> 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 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();
|
||||
|
||||
diskParts.add(
|
||||
DiskIOPiece(
|
||||
dev: diskName,
|
||||
dev: name,
|
||||
sectorsRead: sectorsRead,
|
||||
sectorsWrite: sectorsWrite,
|
||||
time: currentTime,
|
||||
@@ -646,13 +648,6 @@ List<DiskIOPiece> _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
|
||||
|
||||
@@ -37,27 +37,39 @@ class Fifo<T> extends ListBase<T> {
|
||||
}
|
||||
}
|
||||
|
||||
abstract class TimeSeq<T extends List<TimeSeqIface>> extends Fifo<T> {
|
||||
abstract class TimeSeq<T extends TimeSeqIface<T>> extends Fifo<List<T>> {
|
||||
/// 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<T> init1, List<T> init2, {super.capacity}) : super(list: [init1, init2]);
|
||||
|
||||
T get pre {
|
||||
List<T> get pre {
|
||||
return _list[length - 2];
|
||||
}
|
||||
|
||||
T get now {
|
||||
List<T> get now {
|
||||
return _list[length - 1];
|
||||
}
|
||||
|
||||
void onUpdate();
|
||||
|
||||
void update(T new_) {
|
||||
void update(List<T> new_) {
|
||||
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 previous = pre.toList(growable: false);
|
||||
final remaining = previous.toList(growable: true);
|
||||
final aligned = <T>[];
|
||||
|
||||
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();
|
||||
|
||||
@@ -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<SingleCpuCore> 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<SingleCpuCore> 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<SingleCpuCore> 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;
|
||||
// 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.
|
||||
// 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.
|
||||
// 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
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return cpus;
|
||||
} catch (e) {
|
||||
return [];
|
||||
// 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 WindowsCpuResult(cpus, totalCoreCount);
|
||||
} catch (e, s) {
|
||||
Loggers.app.warning('Windows CPU parsing failed: $e', s);
|
||||
return WindowsCpuResult([], 1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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('<EFBFBD>')) {
|
||||
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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user