feat: Windows compatibility (#836)

* feat: win compatibility

* fix

* fix: uptime parse

* opt.: linux uptime accuracy

* fix: windows temperature fetching

* opt.

* opt.: powershell exec

* refactor: address PR review feedback and improve code quality

### Major Improvements:
- **Refactored Windows status parsing**: Broke down large `_getWindowsStatus` method into 13 smaller, focused helper methods for better maintainability and readability
- **Extracted system detection logic**: Created dedicated `SystemDetector` helper class to separate OS detection concerns from ServerProvider
- **Improved concurrency handling**: Implemented proper synchronization for server updates using Future-based locks to prevent race conditions

### Bug Fixes:
- **Fixed CPU percentage parsing**: Removed incorrect '*100' multiplication in BSD CPU parsing (values were already percentages)
- **Enhanced memory parsing**: Added validation and error handling to BSD memory fallback parsing with proper logging
- **Improved uptime parsing**: Added support for multiple Windows date formats and robust error handling with validation
- **Fixed division by zero**: Added safety checks in Swap.usedPercent getter

### Code Quality Enhancements:
- **Added comprehensive documentation**: Documented Windows CPU counter limitations and approach
- **Strengthened error handling**: Added detailed logging and validation throughout parsing methods
- **Improved robustness**: Enhanced BSD CPU parsing with percentage validation and warnings
- **Better separation of concerns**: Each parsing method now has single responsibility

### Files Changed:
- `lib/data/helper/system_detector.dart` (new): System detection helper
- `lib/data/model/server/cpu.dart`: Fixed percentage parsing and added validation
- `lib/data/model/server/memory.dart`: Enhanced fallback parsing and division-by-zero protection
- `lib/data/model/server/server_status_update_req.dart`: Refactored into 13 focused parsing methods
- `lib/data/provider/server.dart`: Improved synchronization and extracted system detection

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: parse & shell fn struct

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
lollipopkit🏳️‍⚧️
2025-08-08 16:56:36 +08:00
committed by GitHub
parent 46a12bc844
commit 3a615449e3
103 changed files with 9591 additions and 1906 deletions

View File

@@ -10,7 +10,7 @@ English | [简体中文](README_zh.md)
</div>
<p align="center">
A Flutter project which provide charts to display <a href="https://github.com/lollipopkit/flutter_server_box/issues/43">Linux</a> server status and tools to manage server.
A Flutter project which provides charts to display Linux, Unix and Windows server status and tools to manage servers.
<br>
Especially thanks to <a href="https://github.com/TerminalStudio/dartssh2">dartssh2</a> & <a href="https://github.com/TerminalStudio/xterm.dart">xterm.dart</a>.
</p>
@@ -26,7 +26,7 @@ Especially thanks to <a href="https://github.com/TerminalStudio/dartssh2">dartss
</tr>
</table>
## 📥 Install
## 📥 Installation
|Platform| From|
|--|--|
@@ -36,7 +36,7 @@ Especially thanks to <a href="https://github.com/TerminalStudio/dartssh2">dartss
Please only download pkgs from the source that **you trust**!
## 🔖 Feature
## 🔖 Features
- `Status chart` (CPU, Sensors, GPU...), `SSH` Term, `SFTP`, `Docker & Process & Systemd`, `S.M.A.R.T`...
- Platform specific: `Bio auth``Msg push``Home widget``watchOS App`...
@@ -61,7 +61,7 @@ Before you open an issue, please read the following:
After you read the above, you can open an [issue](https://github.com/lollipopkit/flutter_server_box/issues/new).
## 🧱 Contribution
## 🧱 Contributions
Any positive contribution is welcome.

View File

@@ -10,7 +10,7 @@
</div>
<p align="center">
使用 Flutter 开发的 <a href="https://github.com/lollipopkit/flutter_server_box/issues/43">Linux</a> 服务器工具箱,提供服务器状态图表和管理工具。
使用 Flutter 开发的 Linux, Unix, Windows 服务器工具箱,提供服务器状态图表和管理工具。
<br>
特别感谢 <a href="https://github.com/TerminalStudio/dartssh2">dartssh2</a> & <a href="https://github.com/TerminalStudio/xterm.dart">xterm.dart</a>
</p>

6505
coverage/lcov.info Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -40,17 +40,13 @@ class MyApp extends StatelessWidget {
light: ThemeData(
useMaterial3: true,
colorSchemeSeed: UIs.colorSeed,
appBarTheme: AppBarTheme(
scrolledUnderElevation: 0.0,
),
appBarTheme: AppBarTheme(scrolledUnderElevation: 0.0),
),
dark: ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
colorSchemeSeed: UIs.colorSeed,
appBarTheme: AppBarTheme(
scrolledUnderElevation: 0.0,
),
appBarTheme: AppBarTheme(scrolledUnderElevation: 0.0),
),
);
}
@@ -58,15 +54,8 @@ class MyApp extends StatelessWidget {
Widget _buildDynamicColor(BuildContext context) {
return DynamicColorBuilder(
builder: (light, dark) {
final lightTheme = ThemeData(
useMaterial3: true,
colorScheme: light,
);
final darkTheme = ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
colorScheme: dark,
);
final lightTheme = ThemeData(useMaterial3: true, colorScheme: light);
final darkTheme = ThemeData(useMaterial3: true, brightness: Brightness.dark, colorScheme: dark);
if (context.isDark && dark != null) {
UIs.primaryColor = dark.primary;
} else if (!context.isDark && light != null) {
@@ -78,11 +67,7 @@ class MyApp extends StatelessWidget {
);
}
Widget _buildApp(
BuildContext ctx, {
required ThemeData light,
required ThemeData dark,
}) {
Widget _buildApp(BuildContext ctx, {required ThemeData light, required ThemeData dark}) {
final tMode = Stores.setting.themeMode.fetch();
// Issue #57
final themeMode = switch (tMode) {
@@ -103,10 +88,7 @@ class MyApp extends StatelessWidget {
],
),
locale: locale,
localizationsDelegates: const [
LibLocalizations.delegate,
...AppLocalizations.localizationsDelegates,
],
localizationsDelegates: const [LibLocalizations.delegate, ...AppLocalizations.localizationsDelegates],
supportedLocales: AppLocalizations.supportedLocales,
localeListResolutionCallback: LocaleUtil.resolve,
navigatorObservers: [AppRouteObserver.instance],
@@ -128,10 +110,7 @@ class MyApp extends StatelessWidget {
child = const HomePage();
return VirtualWindowFrame(
title: BuildData.name,
child: child,
);
return VirtualWindowFrame(title: BuildData.name, child: child);
},
),
);

View File

@@ -12,21 +12,9 @@ extension SftpFileX on SftpFileMode {
UnixPerm toUnixPerm() {
return UnixPerm(
user: UnixPermOp(
r: userRead,
w: userWrite,
x: userExecute,
),
group: UnixPermOp(
r: groupRead,
w: groupWrite,
x: groupExecute,
),
other: UnixPermOp(
r: otherRead,
w: otherWrite,
x: otherExecute,
),
user: UnixPermOp(r: userRead, w: userWrite, x: userExecute),
group: UnixPermOp(r: groupRead, w: groupWrite, x: groupExecute),
other: UnixPermOp(r: otherRead, w: otherWrite, x: otherExecute),
);
}
}

View File

@@ -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/model/server/system.dart';
import 'package:server_box/data/res/misc.dart';
@@ -13,6 +14,52 @@ typedef OnStdin = void Function(SSHSession session);
typedef PwdRequestFunc = Future<String?> Function(String? user);
extension SSHClientX on SSHClient {
/// Create a persistent PowerShell session for Windows commands
Future<(SSHSession, String)> execPowerShell(
OnStdin onStdin, {
SSHPtyConfig? pty,
OnStdout? onStdout,
OnStdout? onStderr,
bool stdout = true,
bool stderr = true,
Map<String, String>? env,
}) async {
final session = await execute(
'powershell -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass',
pty: pty,
environment: env,
);
final result = BytesBuilder(copy: false);
final stdoutDone = Completer<void>();
final stderrDone = Completer<void>();
session.stdout.listen(
(e) {
onStdout?.call(e.string, session);
if (stdout) result.add(e);
},
onDone: stdoutDone.complete,
onError: stderrDone.completeError,
);
session.stderr.listen(
(e) {
onStderr?.call(e.string, session);
if (stderr) result.add(e);
},
onDone: stderrDone.complete,
onError: stderrDone.completeError,
);
onStdin(session);
await stdoutDone.future;
await stderrDone.future;
return (session, result.takeBytes().string);
}
Future<(SSHSession, String)> exec(
OnStdin onStdin, {
String? entry,
@@ -22,9 +69,14 @@ extension SSHClientX on SSHClient {
bool stdout = true,
bool stderr = true,
Map<String, String>? env,
SystemType? systemType,
}) async {
final session = await execute(
entry ?? 'cat | sh',
entry ??
switch (systemType) {
SystemType.windows => 'powershell -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass',
_ => 'cat | sh',
},
pty: pty,
environment: env,
);
@@ -81,9 +133,7 @@ extension SSHClientX on SSHClient {
isRequestingPwd = true;
final user = Miscs.pwdRequestWithUserReg.firstMatch(data)?.group(1);
if (context == null) return;
final pwd = context.mounted
? await context.showPwdDialog(title: user, id: id)
: null;
final pwd = context.mounted ? await context.showPwdDialog(title: user, id: id) : null;
if (pwd == null || pwd.isEmpty) {
session.stdin.close();
} else {

View File

@@ -6,10 +6,8 @@ class ChainComparator<T> {
ChainComparator.empty() : this._create(null, (a, b) => 0);
ChainComparator.create() : this._create(null, (a, b) => 0);
static ChainComparator<T> comparing<T, F extends Comparable<F>>(
F Function(T) extractor) {
return ChainComparator._create(
null, (a, b) => extractor(a).compareTo(extractor(b)));
static ChainComparator<T> comparing<T, F extends Comparable<F>>(F Function(T) extractor) {
return ChainComparator._create(null, (a, b) => extractor(a).compareTo(extractor(b)));
}
int compare(T a, T b) {
@@ -26,8 +24,9 @@ class ChainComparator<T> {
}
ChainComparator<T> thenCompareBy<F extends Comparable<F>>(
F Function(T) extractor,
{bool reversed = false}) {
F Function(T) extractor, {
bool reversed = false,
}) {
return ChainComparator._create(
this,
reversed
@@ -36,18 +35,12 @@ class ChainComparator<T> {
);
}
ChainComparator<T> thenWithComparator(Comparator<T> comparator,
{bool reversed = false}) {
return ChainComparator._create(
this,
!reversed ? comparator : (a, b) => comparator(b, a),
);
ChainComparator<T> thenWithComparator(Comparator<T> comparator, {bool reversed = false}) {
return ChainComparator._create(this, !reversed ? comparator : (a, b) => comparator(b, a));
}
ChainComparator<T> thenCompareByReversed<F extends Comparable<F>>(
F Function(T) extractor) {
return ChainComparator._create(
this, (a, b) => -extractor(a).compareTo(extractor(b)));
ChainComparator<T> thenCompareByReversed<F extends Comparable<F>>(F Function(T) extractor) {
return ChainComparator._create(this, (a, b) => -extractor(a).compareTo(extractor(b)));
}
ChainComparator<T> thenTrueFirst(bool Function(T) f) {
@@ -63,8 +56,7 @@ class ChainComparator<T> {
}
class Comparators {
static Comparator<String> compareStringCaseInsensitive(
{bool uppercaseFirst = false}) {
static Comparator<String> compareStringCaseInsensitive({bool uppercaseFirst = false}) {
return (String a, String b) {
final r = a.toLowerCase().compareTo(b.toLowerCase());
if (r != 0) return r;

View File

@@ -24,19 +24,12 @@ String decyptPem(List<String> args) {
return sshKey.first.toPem();
}
enum GenSSHClientStatus {
socket,
key,
pwd,
}
enum GenSSHClientStatus { socket, key, pwd }
String getPrivateKey(String id) {
final pki = Stores.key.fetchOne(id);
if (pki == null) {
throw SSHErr(
type: SSHErrType.noPrivateKey,
message: 'key [$id] not found',
);
throw SSHErr(type: SSHErrType.noPrivateKey, message: 'key [$id] not found');
}
return pki.key;
}
@@ -73,36 +66,21 @@ Future<SSHClient> genClient(
if (spi.jumpId != null) return Stores.server.box.get(spi.jumpId);
}();
if (jumpSpi_ != null) {
final jumpClient = await genClient(
jumpSpi_,
privateKey: jumpPrivateKey,
timeout: timeout,
);
final jumpClient = await genClient(jumpSpi_, privateKey: jumpPrivateKey, timeout: timeout);
return await jumpClient.forwardLocal(
spi.ip,
spi.port,
);
return await jumpClient.forwardLocal(spi.ip, spi.port);
}
// Direct
try {
return await SSHSocket.connect(
spi.ip,
spi.port,
timeout: timeout,
);
return await SSHSocket.connect(spi.ip, spi.port, timeout: timeout);
} catch (e) {
Loggers.app.warning('genClient', e);
if (spi.alterUrl == null) rethrow;
try {
final res = spi.fromStringUrl();
alterUser = res.$2;
return await SSHSocket.connect(
res.$1,
res.$3,
timeout: timeout,
);
return await SSHSocket.connect(res.$1, res.$3, timeout: timeout);
} catch (e) {
Loggers.app.warning('genClient alterUrl', e);
rethrow;

View File

@@ -0,0 +1,57 @@
import 'package:dartssh2/dartssh2.dart';
import 'package:fl_lib/fl_lib.dart';
import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/model/server/system.dart';
/// Helper class for detecting remote system types
class SystemDetector {
/// Detects the system type of a remote server
///
/// First checks if a custom system type is configured in [spi].
/// If not, attempts to detect the system by running commands:
/// 1. 'ver' command to detect Windows
/// 2. 'uname -a' command to detect Linux/BSD/Darwin
///
/// Returns [SystemType.linux] as default if detection fails.
static Future<SystemType> detect(
SSHClient client,
Spi spi,
) async {
// First, check if custom system type is defined
SystemType? detectedSystemType = spi.customSystemType;
if (detectedSystemType != null) {
dprint('Using custom system type ${detectedSystemType.name} for ${spi.oldId}');
return detectedSystemType;
}
try {
// Try to detect Windows systems first (more reliable detection)
final powershellResult = await client.run('ver 2>nul').string;
if (powershellResult.isNotEmpty &&
(powershellResult.contains('Windows') || powershellResult.contains('NT'))) {
detectedSystemType = SystemType.windows;
dprint('Detected Windows system type for ${spi.oldId}');
return detectedSystemType;
}
// Try to detect Unix/Linux/BSD systems
final unixResult = await client.run('uname -a').string;
if (unixResult.contains('Linux')) {
detectedSystemType = SystemType.linux;
dprint('Detected Linux system type for ${spi.oldId}');
return detectedSystemType;
} else if (unixResult.contains('Darwin') || unixResult.contains('BSD')) {
detectedSystemType = SystemType.bsd;
dprint('Detected BSD system type for ${spi.oldId}');
return detectedSystemType;
}
} catch (e) {
Loggers.app.warning('System detection failed for ${spi.oldId}: $e');
}
// Default fallback
detectedSystemType = SystemType.linux;
dprint('Defaulting to Linux system type for ${spi.oldId}');
return detectedSystemType;
}
}

View File

@@ -213,13 +213,10 @@ class Backup implements Mergeable {
_logger.info('Restore success');
}
factory Backup.fromJsonString(String raw) =>
Backup.fromJson(json.decode(_diyDecrypt(raw)));
factory Backup.fromJsonString(String raw) => Backup.fromJson(json.decode(_diyDecrypt(raw)));
}
String _diyEncrypt(String raw) => json.encode(
raw.codeUnits.map((e) => e * 2 + 1).toList(growable: false),
);
String _diyEncrypt(String raw) => json.encode(raw.codeUnits.map((e) => e * 2 + 1).toList(growable: false));
String _diyDecrypt(String raw) {
try {
@@ -234,4 +231,3 @@ String _diyDecrypt(String raw) {
rethrow;
}
}

View File

@@ -1,17 +1,7 @@
import 'package:fl_lib/fl_lib.dart';
import 'package:server_box/core/extension/context/locale.dart';
enum SSHErrType {
unknown,
connect,
auth,
noPrivateKey,
chdir,
segements,
writeScript,
getStatus,
;
}
enum SSHErrType { unknown, connect, auth, noPrivateKey, chdir, segements, writeScript, getStatus }
class SSHErr extends Err<SSHErrType> {
SSHErr({required super.type, super.message});
@@ -45,11 +35,7 @@ class ContainerErr extends Err<ContainerErrType> {
String? get solution => null;
}
enum ICloudErrType {
generic,
notFound,
multipleFiles,
}
enum ICloudErrType { generic, notFound, multipleFiles }
class ICloudErr extends Err<ICloudErrType> {
ICloudErr({required super.type, super.message});
@@ -58,11 +44,7 @@ class ICloudErr extends Err<ICloudErrType> {
String? get solution => null;
}
enum WebdavErrType {
generic,
notFound,
;
}
enum WebdavErrType { generic, notFound }
class WebdavErr extends Err<WebdavErrType> {
WebdavErr({required super.type, super.message});
@@ -71,12 +53,7 @@ class WebdavErr extends Err<WebdavErrType> {
String? get solution => null;
}
enum PveErrType {
unknown,
net,
loginFailed,
;
}
enum PveErrType { unknown, net, loginFailed }
class PveErr extends Err<PveErrType> {
PveErr({required super.type, super.message});

View File

@@ -8,7 +8,7 @@ enum ContainerMenu {
restart,
rm,
logs,
terminal,
terminal
//stats,
;

View File

@@ -12,8 +12,7 @@ enum ServerFuncBtn {
snippet(),
iperf(),
// pve(),
systemd(1058),
;
systemd(1058);
final int? addedVersion;

View File

@@ -26,32 +26,17 @@ enum NetViewType {
try {
switch (this) {
case NetViewType.conn:
return (
'${l10n.conn}:\n${ss.tcp.maxConn}',
'${libL10n.fail}:\n${ss.tcp.fail}',
);
return ('${l10n.conn}:\n${ss.tcp.maxConn}', '${libL10n.fail}:\n${ss.tcp.fail}');
case NetViewType.speed:
if (notSepcifyDev) {
return (
'↓:\n${ss.netSpeed.cachedVals.speedIn}',
'↑:\n${ss.netSpeed.cachedVals.speedOut}',
);
return ('↓:\n${ss.netSpeed.cachedVals.speedIn}', '↑:\n${ss.netSpeed.cachedVals.speedOut}');
}
return (
'↓:\n${ss.netSpeed.speedIn(device: dev)}',
'↑:\n${ss.netSpeed.speedOut(device: dev)}',
);
return ('↓:\n${ss.netSpeed.speedIn(device: dev)}', '↑:\n${ss.netSpeed.speedOut(device: dev)}');
case NetViewType.traffic:
if (notSepcifyDev) {
return (
'↓:\n${ss.netSpeed.cachedVals.sizeIn}',
'↑:\n${ss.netSpeed.cachedVals.sizeOut}',
);
return ('↓:\n${ss.netSpeed.cachedVals.sizeIn}', '↑:\n${ss.netSpeed.cachedVals.sizeOut}');
}
return (
'↓:\n${ss.netSpeed.sizeIn(device: dev)}',
'↑:\n${ss.netSpeed.sizeOut(device: dev)}',
);
return ('↓:\n${ss.netSpeed.sizeIn(device: dev)}', '↑:\n${ss.netSpeed.sizeOut(device: dev)}');
}
} catch (e, s) {
Loggers.app.warning('NetViewType.build', e, s);

View File

@@ -0,0 +1,242 @@
import 'package:server_box/data/model/app/shell_func.dart';
import 'package:server_box/data/res/build_data.dart';
/// Abstract base class for platform-specific script builders
abstract class ScriptBuilder {
const ScriptBuilder();
/// Generate a complete script for all shell functions
String buildScript(Map<String, String>? customCmds);
/// Get the script file name for this platform
String get scriptFileName;
/// Get the command to install the script
String getInstallCommand(String scriptDir, String scriptPath);
/// Get the execution command for a specific function
String getExecCommand(String scriptPath, ShellFunc func);
/// Get custom commands string for this platform
String getCustomCmdsString(
ShellFunc func,
Map<String, String>? customCmds,
);
}
/// Windows PowerShell script builder
class WindowsScriptBuilder extends ScriptBuilder {
const WindowsScriptBuilder();
@override
String get scriptFileName => 'srvboxm_v${BuildData.script}.ps1';
@override
String getInstallCommand(String scriptDir, String scriptPath) {
return 'New-Item -ItemType Directory -Force -Path \'$scriptDir\' | Out-Null; '
'\$content = [System.Console]::In.ReadToEnd(); '
'Set-Content -Path \'$scriptPath\' -Value \$content -Encoding UTF8';
}
@override
String getExecCommand(String scriptPath, ShellFunc func) {
return 'powershell -ExecutionPolicy Bypass -File "$scriptPath" -${func.flag}';
}
@override
String getCustomCmdsString(
ShellFunc func,
Map<String, String>? customCmds,
) {
if (func == ShellFunc.status && customCmds != null && customCmds.isNotEmpty) {
return '\n${customCmds.values.map((cmd) => '\t$cmd').join('\n')}';
}
return '';
}
@override
String buildScript(Map<String, String>? customCmds) {
final sb = StringBuffer();
sb.write('''
# PowerShell script for ServerBox app v1.0.${BuildData.build}
# DO NOT delete this file while app is running
\$ErrorActionPreference = "SilentlyContinue"
''');
// Write each function
for (final func in ShellFunc.values) {
final customCmdsStr = getCustomCmdsString(func, customCmds);
sb.write('''
function ${func.name} {
${_getWindowsCommand(func).split('\n').map((e) => e.isEmpty ? '' : ' $e').join('\n')}$customCmdsStr
}
''');
}
// Write switch case
sb.write('''
switch (\$args[0]) {
''');
for (final func in ShellFunc.values) {
sb.write('''
"-${func.flag}" { ${func.name} }
''');
}
sb.write('''
default { Write-Host "Invalid argument \$(\$args[0])" }
}
''');
return sb.toString();
}
String _getWindowsCommand(ShellFunc func) => switch (func) {
ShellFunc.status => WindowsStatusCmdType.values.map((e) => e.cmd).join(ShellFunc.cmdDivider),
ShellFunc.process => 'Get-Process | Select-Object ProcessName, Id, CPU, WorkingSet | ConvertTo-Json',
ShellFunc.shutdown => 'Stop-Computer -Force',
ShellFunc.reboot => 'Restart-Computer -Force',
ShellFunc.suspend =>
'Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.Application]::SetSuspendState(\'Suspend\', \$false, \$false)',
};
}
/// Unix shell script builder
class UnixScriptBuilder extends ScriptBuilder {
const UnixScriptBuilder();
@override
String get scriptFileName => 'srvboxm_v${BuildData.script}.sh';
@override
String getInstallCommand(String scriptDir, String scriptPath) {
return '''
mkdir -p $scriptDir
cat > $scriptPath
chmod 755 $scriptPath
''';
}
@override
String getExecCommand(String scriptPath, ShellFunc func) {
return 'sh $scriptPath -${func.flag}';
}
@override
String getCustomCmdsString(
ShellFunc func,
Map<String, String>? customCmds,
) {
if (func == ShellFunc.status && customCmds != null && customCmds.isNotEmpty) {
return '${ShellFunc.cmdDivider}\n\t${customCmds.values.join(ShellFunc.cmdDivider)}';
}
return '';
}
@override
String buildScript(Map<String, String>? customCmds) {
final sb = StringBuffer();
sb.write('''
#!/bin/sh
# Script for ServerBox app v1.0.${BuildData.build}
# DO NOT delete this file while app is running
export LANG=en_US.UTF-8
# If macSign & bsdSign are both empty, then it's linux
macSign=\$(uname -a 2>&1 | grep "Darwin")
bsdSign=\$(uname -a 2>&1 | grep "BSD")
# Link /bin/sh to busybox?
isBusybox=\$(ls -l /bin/sh | grep "busybox")
userId=\$(id -u)
exec 2>/dev/null
''');
// Write each function
for (final func in ShellFunc.values) {
final customCmdsStr = getCustomCmdsString(func, customCmds);
sb.write('''
${func.name}() {
${_getUnixCommand(func).split('\n').map((e) => '\t$e').join('\n')}
$customCmdsStr
}
''');
}
// Write switch case
sb.write('case \$1 in\n');
for (final func in ShellFunc.values) {
sb.write('''
'-${func.flag}')
${func.name}
;;
''');
}
sb.write('''
*)
echo "Invalid argument \$1"
;;
esac''');
return sb.toString();
}
String _getUnixCommand(ShellFunc func) {
switch (func) {
case ShellFunc.status:
return '''
if [ "\$macSign" = "" ] && [ "\$bsdSign" = "" ]; then
\t${StatusCmdType.values.map((e) => e.cmd).join(ShellFunc.cmdDivider)}
else
\t${BSDStatusCmdType.values.map((e) => e.cmd).join(ShellFunc.cmdDivider)}
fi''';
case ShellFunc.process:
return '''
if [ "\$macSign" = "" ] && [ "\$bsdSign" = "" ]; then
\tif [ "\$isBusybox" != "" ]; then
\t\tps w
\telse
\t\tps -aux
\tfi
else
\tps -ax
fi
''';
case ShellFunc.shutdown:
return '''
if [ "\$userId" = "0" ]; then
\tshutdown -h now
else
\tsudo -S shutdown -h now
fi''';
case ShellFunc.reboot:
return '''
if [ "\$userId" = "0" ]; then
\treboot
else
\tsudo -S reboot
fi''';
case ShellFunc.suspend:
return '''
if [ "\$userId" = "0" ]; then
\tsystemctl suspend
else
\tsudo -S systemctl suspend
fi''';
}
}
}
/// Factory class to get appropriate script builder for platform
class ScriptBuilderFactory {
const ScriptBuilderFactory._();
static ScriptBuilder getBuilder(bool isWindows) {
return isWindows ? const WindowsScriptBuilder() : const UnixScriptBuilder();
}
}

View File

@@ -1,31 +1,33 @@
import 'package:server_box/core/extension/context/locale.dart';
import 'package:server_box/data/model/app/script_builders.dart';
import 'package:server_box/data/model/server/system.dart';
import 'package:server_box/data/provider/server.dart';
import 'package:server_box/data/res/build_data.dart';
enum ShellFunc {
status,
status('SbStatus'),
//docker,
process,
shutdown,
reboot,
suspend;
process('SbProcess'),
shutdown('SbShutdown'),
reboot('SbReboot'),
suspend('SbSuspend');
final String name;
const ShellFunc(this.name);
static const seperator = 'SrvBoxSep';
/// The suffix `\t` is for formatting
static const cmdDivider = '\necho $seperator\n\t';
/// Cached Linux status commands string
static final _linuxStatusCmds = StatusCmdType.values.map((e) => e.cmd).join(cmdDivider);
/// Cached BSD status commands string
static final _bsdStatusCmds = BSDStatusCmdType.values.map((e) => e.cmd).join(cmdDivider);
/// srvboxm -> ServerBox Mobile
static const scriptFile = 'srvboxm_v${BuildData.script}.sh';
static const scriptFileWindows = 'srvboxm_v${BuildData.script}.ps1';
static const scriptDirHome = '~/.config/server_box';
static const scriptDirTmp = '/tmp/server_box';
static const scriptDirHomeWindows = '%USERPROFILE%/.config/server_box';
static const scriptDirTmpWindows = '%TEMP%/server_box';
static final _scriptDirMap = <String, String>{};
@@ -33,31 +35,38 @@ enum ShellFunc {
///
/// Default is [scriptDirTmp]/[scriptFile], if this path is not accessible,
/// it will be changed to [scriptDirHome]/[scriptFile].
static String getScriptDir(String id) {
static String getScriptDir(String id, {SystemType? systemType}) {
final customScriptDir = ServerProvider.pick(id: id)?.value.spi.custom?.scriptDir;
if (customScriptDir != null) return customScriptDir;
_scriptDirMap[id] ??= scriptDirTmp;
final defaultTmpDir = systemType == SystemType.windows ? scriptDirTmpWindows : scriptDirTmp;
_scriptDirMap[id] ??= defaultTmpDir;
return _scriptDirMap[id]!;
}
static void switchScriptDir(String id) => switch (_scriptDirMap[id]) {
static void switchScriptDir(String id, {SystemType? systemType}) => switch (_scriptDirMap[id]) {
scriptDirTmp => _scriptDirMap[id] = scriptDirHome,
scriptDirTmpWindows => _scriptDirMap[id] = scriptDirHomeWindows,
scriptDirHome => _scriptDirMap[id] = scriptDirTmp,
_ => _scriptDirMap[id] = scriptDirHome,
scriptDirHomeWindows => _scriptDirMap[id] = scriptDirTmpWindows,
_ => _scriptDirMap[id] = systemType == SystemType.windows ? scriptDirHomeWindows : scriptDirHome,
};
static String getScriptPath(String id) {
return '${getScriptDir(id)}/$scriptFile';
static String getScriptPath(String id, {SystemType? systemType}) {
final dir = getScriptDir(id, systemType: systemType);
final fileName = systemType == SystemType.windows ? scriptFileWindows : scriptFile;
final separator = systemType == SystemType.windows ? '\\' : '/';
return '$dir$separator$fileName';
}
static String getInstallShellCmd(String id) {
final scriptDir = getScriptDir(id);
final scriptPath = '$scriptDir/$scriptFile';
return '''
mkdir -p $scriptDir
cat > $scriptPath
chmod 755 $scriptPath
''';
static String getInstallShellCmd(String id, {SystemType? systemType}) {
final scriptDir = getScriptDir(id, systemType: systemType);
final isWindows = systemType == SystemType.windows;
final builder = ScriptBuilderFactory.getBuilder(isWindows);
final separator = isWindows ? '\\' : '/';
final scriptPath = '$scriptDir$separator${builder.scriptFileName}';
return builder.getInstallCommand(scriptDir, scriptPath);
}
String get flag => switch (this) {
@@ -69,120 +78,24 @@ chmod 755 $scriptPath
// ShellFunc.docker=> 'd',
};
String exec(String id) => 'sh ${getScriptPath(id)} -$flag';
String exec(String id, {SystemType? systemType}) {
final scriptPath = getScriptPath(id, systemType: systemType);
final isWindows = systemType == SystemType.windows;
final builder = ScriptBuilderFactory.getBuilder(isWindows);
String get name => switch (this) {
ShellFunc.status => 'status',
ShellFunc.process => 'process',
ShellFunc.shutdown => 'ShutDown',
ShellFunc.reboot => 'Reboot',
ShellFunc.suspend => 'Suspend',
};
String get _cmd => switch (this) {
ShellFunc.status =>
'''
if [ "\$macSign" = "" ] && [ "\$bsdSign" = "" ]; then
\t$_linuxStatusCmds
else
\t$_bsdStatusCmds
fi''',
ShellFunc.process =>
'''
if [ "\$macSign" = "" ] && [ "\$bsdSign" = "" ]; then
\tif [ "\$isBusybox" != "" ]; then
\t\tps w
\telse
\t\tps -aux
\tfi
else
\tps -ax
fi
''',
ShellFunc.shutdown =>
'''
if [ "\$userId" = "0" ]; then
\tshutdown -h now
else
\tsudo -S shutdown -h now
fi''',
ShellFunc.reboot =>
'''
if [ "\$userId" = "0" ]; then
\treboot
else
\tsudo -S reboot
fi''',
ShellFunc.suspend =>
'''
if [ "\$userId" = "0" ]; then
\tsystemctl suspend
else
\tsudo -S systemctl suspend
fi''',
};
static String allScript(Map<String, String>? customCmds) {
final sb = StringBuffer();
sb.write('''
#!/bin/sh
# Script for ServerBox app v1.0.${BuildData.build}
# DO NOT delete this file while app is running
export LANG=en_US.UTF-8
# If macSign & bsdSign are both empty, then it's linux
macSign=\$(uname -a 2>&1 | grep "Darwin")
bsdSign=\$(uname -a 2>&1 | grep "BSD")
# Link /bin/sh to busybox?
isBusybox=\$(ls -l /bin/sh | grep "busybox")
userId=\$(id -u)
exec 2>/dev/null
''');
// Write each func
for (final func in values) {
final customCmdsStr = () {
if (func == ShellFunc.status && customCmds != null && customCmds.isNotEmpty) {
return '$cmdDivider\n\t${customCmds.values.join(cmdDivider)}';
}
return '';
}();
sb.write('''
${func.name}() {
${func._cmd.split('\n').map((e) => '\t$e').join('\n')}
$customCmdsStr
}
''');
return builder.getExecCommand(scriptPath, this);
}
// Write switch case
sb.write('case \$1 in\n');
for (final func in values) {
sb.write('''
'-${func.flag}')
${func.name}
;;
''');
}
sb.write('''
*)
echo "Invalid argument \$1"
;;
esac''');
return sb.toString();
}
}
extension EnumX on Enum {
/// Find out the required segment from [segments]
String find(List<String> segments) {
return segments[index];
/// Generate script based on system type
static String allScript(Map<String, String>? customCmds, {SystemType? systemType}) {
final isWindows = systemType == SystemType.windows;
final builder = ScriptBuilderFactory.getBuilder(isWindows);
return builder.buildScript(customCmds);
}
}
enum StatusCmdType {
@@ -193,7 +106,10 @@ enum StatusCmdType {
cpu._('cat /proc/stat | grep cpu'),
uptime._('uptime'),
conn._('cat /proc/net/snmp'),
disk._('lsblk --bytes --json --output FSTYPE,PATH,NAME,KNAME,MOUNTPOINT,FSSIZE,FSUSED,FSAVAIL,FSUSE%,UUID'),
disk._(
'lsblk --bytes --json --output '
'FSTYPE,PATH,NAME,KNAME,MOUNTPOINT,FSSIZE,FSUSED,FSAVAIL,FSUSE%,UUID',
),
mem._("cat /proc/meminfo | grep -E 'Mem|Swap'"),
tempType._('cat /sys/class/thermal/thermal_zone*/type'),
tempVal._('cat /sys/class/thermal/thermal_zone*/temp'),
@@ -201,7 +117,16 @@ enum StatusCmdType {
diskio._('cat /proc/diskstats'),
battery._('for f in /sys/class/power_supply/*/uevent; do cat "\$f"; echo; done'),
nvidia._('nvidia-smi -q -x'),
amd._('if command -v amd-smi >/dev/null 2>&1; then amd-smi list --json && amd-smi metric --json; elif command -v rocm-smi >/dev/null 2>&1; then rocm-smi --json || rocm-smi --showunique --showuse --showtemp --showfan --showclocks --showmemuse --showpower; elif command -v radeontop >/dev/null 2>&1; then timeout 2s radeontop -d - -l 1 | tail -n +2; else echo "No AMD GPU monitoring tools found"; fi'),
amd._(
'if command -v amd-smi >/dev/null 2>&1; then '
'amd-smi list --json && amd-smi metric --json; '
'elif command -v rocm-smi >/dev/null 2>&1; then '
'rocm-smi --json || rocm-smi --showunique --showuse --showtemp '
'--showfan --showclocks --showmemuse --showpower; '
'elif command -v radeontop >/dev/null 2>&1; then '
'timeout 2s radeontop -d - -l 1 | tail -n +2; '
'else echo "No AMD GPU monitoring tools found"; fi',
),
sensors._('sensors'),
diskSmart._('for d in \$(lsblk -dn -o KNAME); do smartctl -a -j /dev/\$d; echo; done'),
cpuBrand._('cat /proc/cpuinfo | grep "model name"');
@@ -241,3 +166,77 @@ extension StatusCmdTypeX on StatusCmdType {
final val => val.name,
};
}
enum WindowsStatusCmdType {
echo._('echo ${SystemType.windowsSign}'),
time._('[DateTimeOffset]::UtcNow.ToUnixTimeSeconds()'),
net._(
r'Get-Counter -Counter '
r'"\\NetworkInterface(*)\\Bytes Received/sec", '
r'"\\NetworkInterface(*)\\Bytes Sent/sec" '
r'-SampleInterval 1 -MaxSamples 2 | ConvertTo-Json',
),
sys._('(Get-ComputerInfo).OsName'),
cpu._(
'Get-WmiObject -Class Win32_Processor | '
'Select-Object Name, LoadPercentage | ConvertTo-Json',
),
uptime._('(Get-CimInstance -ClassName Win32_OperatingSystem).LastBootUpTime'),
conn._('(netstat -an | findstr ESTABLISHED | Measure-Object -Line).Count'),
disk._(
'Get-WmiObject -Class Win32_LogicalDisk | '
'Select-Object DeviceID, Size, FreeSpace, FileSystem | ConvertTo-Json',
),
mem._(
'Get-WmiObject -Class Win32_OperatingSystem | '
'Select-Object TotalVisibleMemorySize, FreePhysicalMemory | ConvertTo-Json',
),
temp._(
'Get-CimInstance -ClassName MSAcpi_ThermalZoneTemperature '
'-Namespace root/wmi -ErrorAction SilentlyContinue | '
'Select-Object InstanceName, @{Name=\'Temperature\';'
'Expression={[math]::Round((\$_.CurrentTemperature - 2732) / 10, 1)}} | '
'ConvertTo-Json',
),
host._(r'Write-Output $env:COMPUTERNAME'),
diskio._(
r'Get-Counter -Counter '
r'"\\PhysicalDisk(*)\\Disk Read Bytes/sec", '
r'"\\PhysicalDisk(*)\\Disk Write Bytes/sec" '
r'-SampleInterval 1 -MaxSamples 2 | ConvertTo-Json',
),
battery._(
'Get-WmiObject -Class Win32_Battery | '
'Select-Object EstimatedChargeRemaining, BatteryStatus | ConvertTo-Json',
),
nvidia._(
'if (Get-Command nvidia-smi -ErrorAction SilentlyContinue) { '
'nvidia-smi -q -x } else { echo "NVIDIA driver not found" }',
),
amd._(
'if (Get-Command amd-smi -ErrorAction SilentlyContinue) { '
'amd-smi list --json } else { echo "AMD driver not found" }',
),
sensors._(
'Get-CimInstance -ClassName Win32_TemperatureProbe '
'-ErrorAction SilentlyContinue | '
'Select-Object Name, CurrentReading | ConvertTo-Json',
),
diskSmart._(
'Get-PhysicalDisk | Get-StorageReliabilityCounter | '
'Select-Object DeviceId, Temperature, TemperatureMax, Wear, PowerOnHours | '
'ConvertTo-Json',
),
cpuBrand._('(Get-WmiObject -Class Win32_Processor).Name');
final String cmd;
const WindowsStatusCmdType._(this.cmd);
}
extension EnumX on Enum {
/// Find out the required segment from [segments]
String find(List<String> segments) {
return segments[index];
}
}

View File

@@ -12,7 +12,7 @@ enum AppTab {
server,
ssh,
file,
snippet,
snippet
//settings,
;

View File

@@ -24,14 +24,7 @@ final class PodmanImg implements ContainerImg {
final int? size;
final int? containers;
PodmanImg({
this.repository,
this.tag,
this.id,
this.created,
this.size,
this.containers,
});
PodmanImg({this.repository, this.tag, this.id, this.created, this.size, this.containers});
@override
String? get sizeMB => size?.bytes2Str;
@@ -39,8 +32,7 @@ final class PodmanImg implements ContainerImg {
@override
int? get containersCount => containers;
factory PodmanImg.fromRawJson(String str) =>
PodmanImg.fromJson(json.decode(str));
factory PodmanImg.fromRawJson(String str) => PodmanImg.fromJson(json.decode(str));
String toRawJson() => json.encode(toJson());
@@ -87,11 +79,9 @@ final class DockerImg implements ContainerImg {
String? get sizeMB => size;
@override
int? get containersCount =>
containers == 'N/A' ? 0 : int.tryParse(containers);
int? get containersCount => containers == 'N/A' ? 0 : int.tryParse(containers);
factory DockerImg.fromRawJson(String str) =>
DockerImg.fromJson(json.decode(str));
factory DockerImg.fromRawJson(String str) => DockerImg.fromJson(json.decode(str));
String toRawJson() => json.encode(toJson());

View File

@@ -42,15 +42,7 @@ final class PodmanPs implements ContainerPs {
@override
String? disk;
PodmanPs({
this.command,
this.created,
this.exited,
this.id,
this.image,
this.names,
this.startedAt,
});
PodmanPs({this.command, this.created, this.exited, this.id, this.image, this.names, this.startedAt});
@override
String? get name => names?.firstOrNull;
@@ -78,29 +70,22 @@ final class PodmanPs implements ContainerPs {
disk = '${l10n.read} $diskOut / ${l10n.write} $diskIn';
}
factory PodmanPs.fromRawJson(String str) =>
PodmanPs.fromJson(json.decode(str));
factory PodmanPs.fromRawJson(String str) => PodmanPs.fromJson(json.decode(str));
String toRawJson() => json.encode(toJson());
factory PodmanPs.fromJson(Map<String, dynamic> json) => PodmanPs(
command: json['Command'] == null
? []
: List<String>.from(json['Command']!.map((x) => x)),
created:
json['Created'] == null ? null : DateTime.parse(json['Created']),
command: json['Command'] == null ? [] : List<String>.from(json['Command']!.map((x) => x)),
created: json['Created'] == null ? null : DateTime.parse(json['Created']),
exited: json['Exited'],
id: json['Id'],
image: json['Image'],
names: json['Names'] == null
? []
: List<String>.from(json['Names']!.map((x) => x)),
names: json['Names'] == null ? [] : List<String>.from(json['Names']!.map((x) => x)),
startedAt: json['StartedAt'],
);
Map<String, dynamic> toJson() => {
'Command':
command == null ? [] : List<dynamic>.from(command!.map((x) => x)),
'Command': command == null ? [] : List<dynamic>.from(command!.map((x) => x)),
'Created': created?.toIso8601String(),
'Exited': exited,
'Id': id,
@@ -127,12 +112,7 @@ final class DockerPs implements ContainerPs {
@override
String? disk;
DockerPs({
this.id,
this.image,
this.names,
this.state,
});
DockerPs({this.id, this.image, this.names, this.state});
@override
String? get name => names;
@@ -159,11 +139,6 @@ final class DockerPs implements ContainerPs {
/// a049d689e7a1 aria2-pro p3terx/aria2-pro Up 3 weeks
factory DockerPs.parse(String raw) {
final parts = raw.split(Miscs.multiBlankreg);
return DockerPs(
id: parts[0],
state: parts[1],
names: parts[2],
image: parts[3].trim(),
);
return DockerPs(id: parts[0], state: parts[1], names: parts[2], image: parts[3].trim());
}
}

View File

@@ -3,8 +3,7 @@ import 'package:server_box/data/model/container/ps.dart';
enum ContainerType {
docker,
podman,
;
podman;
ContainerPs Function(String str) get ps => switch (this) {
ContainerType.docker => DockerPs.parse,

View File

@@ -62,8 +62,7 @@ enum PkgManager {
case PkgManager.yum:
list = list.sublist(2);
list.removeWhere((element) => element.isEmpty);
final endLine = list.lastIndexWhere(
(element) => element.contains('Obsoleting Packages'));
final endLine = list.lastIndexWhere((element) => element.contains('Obsoleting Packages'));
if (endLine != -1 && list.isNotEmpty) {
list = list.sublist(0, endLine);
}
@@ -71,8 +70,7 @@ enum PkgManager {
case PkgManager.apt:
// avoid other outputs
// such as: [Could not chdir to home directory /home/test: No such file or directory, , WARNING: apt does not have a stable CLI interface. Use with caution in scripts., , Listing...]
final idx =
list.indexWhere((element) => element.contains('[upgradable from:'));
final idx = list.indexWhere((element) => element.contains('[upgradable from:'));
if (idx == -1) {
return [];
}

View File

@@ -19,13 +19,7 @@ class Battery {
final int? cycle;
final String? tech;
const Battery({
required this.status,
this.percent,
this.name,
this.cycle,
this.tech,
});
const Battery({required this.status, this.percent, this.name, this.cycle, this.tech});
factory Battery.fromRaw(String raw) {
final lines = raw.split('\n');
@@ -63,8 +57,7 @@ enum BatteryStatus {
charging,
discharging,
full,
unknown,
;
unknown;
static BatteryStatus parse(String? status) {
switch (status) {

View File

@@ -6,17 +6,11 @@ class Conn {
final int passive;
final int fail;
const Conn({
required this.maxConn,
required this.active,
required this.passive,
required this.fail,
});
const Conn({required this.maxConn, required this.active, required this.passive, required this.fail});
static Conn? parse(String raw) {
final lines = raw.split('\n');
final idx = lines.lastWhere((element) => element.startsWith('Tcp:'),
orElse: () => '');
final idx = lines.lastWhere((element) => element.startsWith('Tcp:'), orElse: () => '');
if (idx != '') {
final vals = idx.split(Miscs.blankReg);
return Conn(

View File

@@ -200,22 +200,98 @@ final class CpuBrand {
}
final _bsdCpuPercentReg = RegExp(r'(\d+\.\d+)%');
final _macCpuPercentReg = RegExp(
r'CPU usage: ([\d.]+)% user, ([\d.]+)% sys, ([\d.]+)% idle');
final _freebsdCpuPercentReg = RegExp(
r'CPU: ([\d.]+)% user, ([\d.]+)% nice, ([\d.]+)% system, '
r'([\d.]+)% interrupt, ([\d.]+)% idle');
/// TODO: Change this implementation to parse cpu status on BSD system
/// Parse CPU status on BSD system with support for different BSD variants
///
/// [raw]:
/// CPU usage: 14.70% user, 12.76% sys, 72.52% idle
/// Supports multiple formats:
/// - macOS: "CPU usage: 14.70% user, 12.76% sys, 72.52% idle"
/// - FreeBSD: "CPU: 5.2% user, 0.0% nice, 3.1% system, 0.1% interrupt, 91.6% idle"
/// - Generic BSD: fallback to percentage extraction
Cpus parseBsdCpu(String raw) {
final percents = _bsdCpuPercentReg
.allMatches(raw)
.map((e) => double.parse(e.group(1) ?? '0') * 100)
.toList();
if (percents.length != 3) return InitStatus.cpus;
final init = InitStatus.cpus;
// Try macOS format first
final macMatch = _macCpuPercentReg.firstMatch(raw);
if (macMatch != null) {
final userPercent = double.parse(macMatch.group(1)!).toInt();
final sysPercent = double.parse(macMatch.group(2)!).toInt();
final idlePercent = double.parse(macMatch.group(3)!).toInt();
init.add([
SingleCpuCore('cpu', percents[0].toInt(), 0, 0,
percents[2].toInt() + percents[1].toInt(), 0, 0, 0),
SingleCpuCore(
'cpu0',
userPercent,
sysPercent,
0, // nice
idlePercent,
0, // iowait
0, // irq
0, // softirq
),
]);
return init;
}
// Try FreeBSD format
final freebsdMatch = _freebsdCpuPercentReg.firstMatch(raw);
if (freebsdMatch != null) {
final userPercent = double.parse(freebsdMatch.group(1)!).toInt();
final nicePercent = double.parse(freebsdMatch.group(2)!).toInt();
final sysPercent = double.parse(freebsdMatch.group(3)!).toInt();
final irqPercent = double.parse(freebsdMatch.group(4)!).toInt();
final idlePercent = double.parse(freebsdMatch.group(5)!).toInt();
init.add([
SingleCpuCore(
'cpu0',
userPercent,
sysPercent,
nicePercent,
idlePercent,
0, // iowait
irqPercent,
0, // softirq
),
]);
return init;
}
// Fallback to generic percentage extraction
final percents = _bsdCpuPercentReg
.allMatches(raw)
.map((e) => double.parse(e.group(1) ?? '0'))
.toList();
if (percents.length >= 3) {
// Validate that percentages are reasonable (0-100 range)
final validPercents = percents.where((p) => p >= 0 && p <= 100).toList();
if (validPercents.length != percents.length) {
Loggers.app.warning('BSD CPU fallback parsing found invalid percentages in: $raw');
}
init.add([
SingleCpuCore(
'cpu0',
percents[0].toInt(), // user
percents.length > 1 ? percents[1].toInt() : 0, // sys
0, // nice
percents.length > 2 ? percents[2].toInt() : 0, // idle
0, // iowait
0, // irq
0, // softirq
),
]);
return init;
} else if (percents.isNotEmpty) {
Loggers.app.warning('BSD CPU fallback parsing found ${percents.length} percentages (expected at least 3) in: $raw');
} else {
Loggers.app.warning('BSD CPU fallback parsing found no percentages in: $raw');
}
return init;
}

View File

@@ -154,8 +154,7 @@ class Disk with EquatableMixin {
}
// Handle common filesystem cases or parent devices with children
if ((fstype != null && _shouldCalc(fstype, mount)) ||
(childDisks.isNotEmpty && path.isNotEmpty)) {
if ((fstype != null && _shouldCalc(fstype, mount)) || (childDisks.isNotEmpty && path.isNotEmpty)) {
final sizeStr = device['fssize']?.toString() ?? '0';
final size = (BigInt.tryParse(sizeStr) ?? BigInt.zero) ~/ BigInt.from(1024);
@@ -221,14 +220,16 @@ class Disk with EquatableMixin {
final fs = vals[0];
final mount = vals[5];
if (!_shouldCalc(fs, mount)) continue;
list.add(Disk(
list.add(
Disk(
path: fs,
mount: mount,
usedPercent: int.parse(vals[4].replaceFirst('%', '')),
used: BigInt.parse(vals[2]) ~/ BigInt.from(1024),
size: BigInt.parse(vals[1]) ~/ BigInt.from(1024),
avail: BigInt.parse(vals[3]) ~/ BigInt.from(1024),
));
),
);
} catch (e) {
continue;
}
@@ -237,8 +238,19 @@ class Disk with EquatableMixin {
}
@override
List<Object?> get props =>
[path, name, kname, fsTyp, mount, usedPercent, used, size, avail, uuid, children];
List<Object?> get props => [
path,
name,
kname,
fsTyp,
mount,
usedPercent,
used,
size,
avail,
uuid,
children,
];
}
class DiskIO extends TimeSeq<List<DiskIOPiece>> {
@@ -314,12 +326,14 @@ class DiskIO extends TimeSeq<List<DiskIOPiece>> {
try {
final dev = vals[2];
if (dev.startsWith('loop')) continue;
items.add(DiskIOPiece(
items.add(
DiskIOPiece(
dev: dev,
sectorsRead: int.parse(vals[5]),
sectorsWrite: int.parse(vals[9]),
time: time,
));
),
);
} catch (e) {
continue;
}
@@ -334,12 +348,7 @@ class DiskIOPiece extends TimeSeqIface<DiskIOPiece> {
final int sectorsWrite;
final int time;
DiskIOPiece({
required this.dev,
required this.sectorsRead,
required this.sectorsWrite,
required this.time,
});
DiskIOPiece({required this.dev, required this.sectorsRead, required this.sectorsWrite, required this.time});
@override
bool same(DiskIOPiece other) => dev == other.dev;
@@ -349,10 +358,7 @@ class DiskUsage {
final BigInt used;
final BigInt size;
DiskUsage({
required this.used,
required this.size,
});
DiskUsage({required this.used, required this.size});
double get usedPercent {
// Avoid division by zero

View File

@@ -12,7 +12,6 @@ enum Dist {
rocky,
deepin,
coreelec,
;
}
extension StringX on String {
@@ -34,6 +33,4 @@ extension StringX on String {
// Special rules
const _wrts = [
'istoreos',
];
const _wrts = ['istoreos'];

View File

@@ -5,11 +5,7 @@ class Memory {
final int free;
final int avail;
const Memory({
required this.total,
required this.free,
required this.avail,
});
const Memory({required this.total, required this.free, required this.avail});
double get availPercent {
if (avail == 0) {
@@ -23,46 +19,99 @@ class Memory {
static Memory parse(String raw) {
final items = raw.split('\n').map((e) => memItemReg.firstMatch(e)).toList();
final total = int.tryParse(items
.firstWhereOrNull((e) => e?.group(1) == 'MemTotal:')
?.group(2) ??
'1') ??
1;
final free = int.tryParse(items
.firstWhereOrNull((e) => e?.group(1) == 'MemFree:')
?.group(2) ??
'0') ??
0;
final available = int.tryParse(items
.firstWhereOrNull((e) => e?.group(1) == 'MemAvailable:')
?.group(2) ??
'0') ??
0;
final total = int.tryParse(
items.firstWhereOrNull((e) => e?.group(1) == 'MemTotal:')
?.group(2) ?? '1') ?? 1;
final free = int.tryParse(
items.firstWhereOrNull((e) => e?.group(1) == 'MemFree:')
?.group(2) ?? '0') ?? 0;
final available = int.tryParse(
items.firstWhereOrNull((e) => e?.group(1) == 'MemAvailable:')
?.group(2) ?? '0') ?? 0;
return Memory(
total: total,
free: free,
avail: available,
);
return Memory(total: total, free: free, avail: available);
}
}
final memItemReg = RegExp(r'([A-Z].+:)\s+([0-9]+) kB');
/// Parse BSD/macOS memory from top output
///
/// Supports formats like:
/// - macOS: "PhysMem: 32G used (1536M wired), 64G unused."
/// - FreeBSD: "Mem: 456M Active, 2918M Inact, 1127M Wired, 187M Cache, 829M Buf, 3535M Free"
Memory parseBsdMemory(String raw) {
// Try macOS format first: "PhysMem: 32G used (1536M wired), 64G unused."
final macMemReg = RegExp(
r'PhysMem:\s*([\d.]+)([KMGT])\s*used.*?,\s*([\d.]+)([KMGT])\s*unused');
final macMatch = macMemReg.firstMatch(raw);
if (macMatch != null) {
final usedAmount = double.parse(macMatch.group(1)!);
final usedUnit = macMatch.group(2)!;
final freeAmount = double.parse(macMatch.group(3)!);
final freeUnit = macMatch.group(4)!;
final usedKB = _convertToKB(usedAmount, usedUnit);
final freeKB = _convertToKB(freeAmount, freeUnit);
return Memory(total: usedKB + freeKB, free: freeKB, avail: freeKB);
}
// Try FreeBSD format: "Mem: 456M Active, 2918M Inact, 1127M Wired, 187M Cache, 829M Buf, 3535M Free"
final freeBsdReg = RegExp(
r'(\d+)([KMGT])\s+(Active|Inact|Wired|Cache|Buf|Free)', caseSensitive: false);
final matches = freeBsdReg.allMatches(raw);
if (matches.isNotEmpty) {
double usedKB = 0;
double freeKB = 0;
for (final match in matches) {
final amount = double.parse(match.group(1)!);
final unit = match.group(2)!;
final keyword = match.group(3)!.toLowerCase();
final kb = _convertToKB(amount, unit);
// Only sum known keywords
if (keyword == 'active' || keyword == 'inact' || keyword == 'wired' || keyword == 'cache' || keyword == 'buf') {
usedKB += kb;
} else if (keyword == 'free') {
freeKB += kb;
}
}
return Memory(total: (usedKB + freeKB).round(), free: freeKB.round(), avail: freeKB.round());
}
// If neither format matches, throw an error to avoid misinterpretation
throw FormatException('Unrecognized BSD/macOS memory format: $raw');
}
/// Convert memory size to KB based on unit
int _convertToKB(double amount, String unit) {
switch (unit.toUpperCase()) {
case 'T':
return (amount * 1024 * 1024 * 1024).round();
case 'G':
return (amount * 1024 * 1024).round();
case 'M':
return (amount * 1024).round();
case 'K':
case '':
return amount.round();
default:
return amount.round();
}
}
class Swap {
final int total;
final int free;
final int cached;
const Swap({
required this.total,
required this.free,
required this.cached,
});
const Swap({required this.total, required this.free, required this.cached});
double get usedPercent => 1 - free / total;
double get usedPercent => total == 0 ? 0.0 : 1 - free / total;
double get freePercent => free / total;
double get freePercent => total == 0 ? 0.0 : free / total;
@override
String toString() {
@@ -72,26 +121,16 @@ class Swap {
static Swap parse(String raw) {
final items = raw.split('\n').map((e) => memItemReg.firstMatch(e)).toList();
final total = int.tryParse(items
.firstWhereOrNull((e) => e?.group(1) == 'SwapTotal:')
?.group(2) ??
'1') ??
0;
final free = int.tryParse(items
.firstWhereOrNull((e) => e?.group(1) == 'SwapFree:')
?.group(2) ??
'1') ??
0;
final cached = int.tryParse(items
.firstWhereOrNull((e) => e?.group(1) == 'SwapCached:')
?.group(2) ??
'0') ??
0;
final total = int.tryParse(
items.firstWhereOrNull((e) => e?.group(1) == 'SwapTotal:')
?.group(2) ?? '1') ?? 0;
final free = int.tryParse(
items.firstWhereOrNull((e) => e?.group(1) == 'SwapFree:')
?.group(2) ?? '1') ?? 0;
final cached = int.tryParse(
items.firstWhereOrNull((e) => e?.group(1) == 'SwapCached:')
?.group(2) ?? '0') ?? 0;
return Swap(
total: total,
free: free,
cached: cached,
);
return Swap(total: total, free: free, cached: cached);
}
}

View File

@@ -16,12 +16,7 @@ class NetSpeedPart extends TimeSeqIface<NetSpeedPart> {
bool same(NetSpeedPart other) => device == other.device;
}
typedef CachedNetVals = ({
String sizeIn,
String sizeOut,
String speedIn,
String speedOut,
});
typedef CachedNetVals = ({String sizeIn, String sizeOut, String speedIn, String speedOut});
class NetSpeed extends TimeSeq<List<NetSpeedPart>> {
NetSpeed(super.init1, super.init2);
@@ -32,20 +27,14 @@ class NetSpeed extends TimeSeq<List<NetSpeedPart>> {
devices.addAll(now.map((e) => e.device).toList());
realIfaces.clear();
realIfaces.addAll(devices
.where((e) => realIfacePrefixs.any((prefix) => e.startsWith(prefix))));
realIfaces.addAll(devices.where((e) => realIfacePrefixs.any((prefix) => e.startsWith(prefix))));
final sizeIn = this.sizeIn();
final sizeOut = this.sizeOut();
final speedIn = this.speedIn();
final speedOut = this.speedOut();
cachedVals = (
sizeIn: sizeIn,
sizeOut: sizeOut,
speedIn: speedIn,
speedOut: speedOut,
);
cachedVals = (sizeIn: sizeIn, sizeOut: sizeOut, speedIn: speedIn, speedOut: speedOut);
}
/// Cached network device list
@@ -58,15 +47,13 @@ class NetSpeed extends TimeSeq<List<NetSpeedPart>> {
/// Cached non-virtual network device prefix
final realIfaces = <String>[];
CachedNetVals cachedVals =
(sizeIn: '0kb', sizeOut: '0kb', speedIn: '0kb/s', speedOut: '0kb/s');
CachedNetVals cachedVals = (sizeIn: '0kb', sizeOut: '0kb', speedIn: '0kb/s', speedOut: '0kb/s');
/// Time diff between [pre] and [now]
BigInt get _timeDiff => BigInt.from(now[0].time - pre[0].time);
double speedInBytes(int i) => (now[i].bytesIn - pre[i].bytesIn) / _timeDiff;
double speedOutBytes(int i) =>
(now[i].bytesOut - pre[i].bytesOut) / _timeDiff;
double speedOutBytes(int i) => (now[i].bytesOut - pre[i].bytesOut) / _timeDiff;
BigInt sizeInBytes(int i) => now[i].bytesIn;
BigInt sizeOutBytes(int i) => now[i].bytesOut;

View File

@@ -35,25 +35,17 @@ class NvidiaSmi {
.firstOrNull
?.innerText;
final power = gpu.findElements('gpu_power_readings').firstOrNull;
final powerDraw =
power?.findElements('power_draw').firstOrNull?.innerText;
final powerLimit =
power?.findElements('current_power_limit').firstOrNull?.innerText;
final powerDraw = power?.findElements('power_draw').firstOrNull?.innerText;
final powerLimit = power?.findElements('current_power_limit').firstOrNull?.innerText;
final memory = gpu.findElements('fb_memory_usage').firstOrNull;
final memoryUsed = memory?.findElements('used').firstOrNull?.innerText;
final memoryTotal = memory?.findElements('total').firstOrNull?.innerText;
final processes = gpu
.findElements('processes')
.firstOrNull
?.findElements('process_info');
final memoryProcesses =
List<NvidiaSmiMemProcess?>.generate(processes?.length ?? 0, (index) {
final processes = gpu.findElements('processes').firstOrNull?.findElements('process_info');
final memoryProcesses = List<NvidiaSmiMemProcess?>.generate(processes?.length ?? 0, (index) {
final process = processes?.elementAt(index);
final pid = process?.findElements('pid').firstOrNull?.innerText;
final name =
process?.findElements('process_name').firstOrNull?.innerText;
final memory =
process?.findElements('used_memory').firstOrNull?.innerText;
final name = process?.findElements('process_name').firstOrNull?.innerText;
final memory = process?.findElements('used_memory').firstOrNull?.innerText;
if (pid != null && name != null && memory != null) {
return NvidiaSmiMemProcess(
int.tryParse(pid) ?? 0,

View File

@@ -1,7 +1,6 @@
final parseFailed = Exception('Parse failed');
final seqReg = RegExp(r'seq=(.+) ttl=(.+) time=(.+) ms');
final packetReg =
RegExp(r'(.+) packets transmitted, (.+) received, (.+)% packet loss');
final packetReg = RegExp(r'(.+) packets transmitted, (.+) received, (.+)% packet loss');
final timeReg = RegExp(r'min/avg/max/mdev = (.+)/(.+)/(.+)/(.+) ms');
final timeAlpineReg = RegExp(r'round-trip min/avg/max = (.+)/(.+)/(.+) ms');
final ipReg = RegExp(r' \((\S+)\)');
@@ -15,17 +14,13 @@ class PingResult {
PingResult.parse(this.serverName, String raw) {
final lines = raw.split('\n');
lines.removeWhere((element) => element.isEmpty);
final statisticIndex =
lines.indexWhere((element) => element.startsWith('---'));
final statisticIndex = lines.indexWhere((element) => element.startsWith('---'));
if (statisticIndex == -1) {
throw parseFailed;
}
final statisticRaw = lines.sublist(statisticIndex + 1);
statistic = PingStatistics.parse(statisticRaw);
results = lines
.sublist(1, statisticIndex)
.map((e) => PingSeqResult.parse(e))
.toList();
results = lines.sublist(1, statisticIndex).map((e) => PingSeqResult.parse(e)).toList();
ip = ipReg.firstMatch(lines[0])?.group(1);
}
}

View File

@@ -8,10 +8,7 @@ class PrivateKeyInfo {
@JsonKey(name: 'private_key')
final String key;
const PrivateKeyInfo({
required this.id,
required this.key,
});
const PrivateKeyInfo({required this.id, required this.key});
factory PrivateKeyInfo.fromJson(Map<String, dynamic> json) => _$PrivateKeyInfoFromJson(json);

View File

@@ -107,10 +107,7 @@ class PsResult {
final List<Proc> procs;
final String? error;
const PsResult({
required this.procs,
this.error,
});
const PsResult({required this.procs, this.error});
factory PsResult.parse(String raw, {ProcSortMode sort = ProcSortMode.cpu}) {
final lines = raw.split('\n').map((e) => e.trim()).toList();
@@ -167,14 +164,7 @@ class PsResult {
}
}
enum ProcSortMode {
cpu,
mem,
pid,
user,
name,
;
}
enum ProcSortMode { cpu, mem, pid, user, name }
extension _StrIndex on List<String> {
int? indexOfOrNull(String val) {

View File

@@ -6,8 +6,7 @@ enum PveResType {
qemu,
node,
storage,
sdn,
;
sdn;
static PveResType? fromString(String type) => switch (type.toLowerCase()) {
'lxc' => PveResType.lxc,
@@ -334,13 +333,7 @@ final class PveSdn extends PveResIface implements PveCtrlIface {
@override
final String status;
PveSdn({
required this.id,
required this.type,
required this.sdn,
required this.node,
required this.status,
});
PveSdn({required this.id, required this.type, required this.sdn, required this.node, required this.status});
static PveSdn fromJson(Map<String, dynamic> json) {
return PveSdn(
@@ -379,8 +372,7 @@ final class PveRes {
bool get onlyOneNode => nodes.length == 1;
int get length =>
qemus.length + lxcs.length + nodes.length + storages.length + sdns.length;
int get length => qemus.length + lxcs.length + nodes.length + storages.length + sdns.length;
PveResIface operator [](int index) {
if (index < nodes.length) {
@@ -432,29 +424,13 @@ final class PveRes {
}
if (old != null) {
qemus.reorder(
order: old.qemus.map((e) => e.id).toList(),
finder: (e, s) => e.id == s);
lxcs.reorder(
order: old.lxcs.map((e) => e.id).toList(),
finder: (e, s) => e.id == s);
nodes.reorder(
order: old.nodes.map((e) => e.id).toList(),
finder: (e, s) => e.id == s);
storages.reorder(
order: old.storages.map((e) => e.id).toList(),
finder: (e, s) => e.id == s);
sdns.reorder(
order: old.sdns.map((e) => e.id).toList(),
finder: (e, s) => e.id == s);
qemus.reorder(order: old.qemus.map((e) => e.id).toList(), finder: (e, s) => e.id == s);
lxcs.reorder(order: old.lxcs.map((e) => e.id).toList(), finder: (e, s) => e.id == s);
nodes.reorder(order: old.nodes.map((e) => e.id).toList(), finder: (e, s) => e.id == s);
storages.reorder(order: old.storages.map((e) => e.id).toList(), finder: (e, s) => e.id == s);
sdns.reorder(order: old.sdns.map((e) => e.id).toList(), finder: (e, s) => e.id == s);
}
return PveRes(
qemus: qemus,
lxcs: lxcs,
nodes: nodes,
storages: storages,
sdns: sdns,
);
return PveRes(qemus: qemus, lxcs: lxcs, nodes: nodes, storages: storages, sdns: sdns);
}
}

View File

@@ -28,11 +28,7 @@ final class SensorItem {
final SensorAdaptor adapter;
final Map<String, String> details;
const SensorItem({
required this.device,
required this.adapter,
required this.details,
});
const SensorItem({required this.device, required this.adapter, required this.details});
String get toMarkdown {
final sb = StringBuffer();
@@ -72,8 +68,7 @@ final class SensorItem {
final len = sensorLines.length;
if (len < 3) continue;
final device = sensorLines.first;
final adapter =
SensorAdaptor.parse(sensorLines[1].split(':').last.trim());
final adapter = SensorAdaptor.parse(sensorLines[1].split(':').last.trim());
final details = <String, String>{};
for (var idx = 2; idx < len; idx++) {
@@ -84,11 +79,7 @@ final class SensorItem {
final value = detailParts[1].trim();
details[key] = value;
}
sensors.add(SensorItem(
device: device,
adapter: adapter,
details: details,
));
sensors.add(SensorItem(device: device, adapter: adapter, details: details));
}
return sensors;

View File

@@ -5,6 +5,7 @@ import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:server_box/data/model/app/error.dart';
import 'package:server_box/data/model/server/custom.dart';
import 'package:server_box/data/model/server/server.dart';
import 'package:server_box/data/model/server/system.dart';
import 'package:server_box/data/model/server/wol_cfg.dart';
import 'package:server_box/data/provider/server.dart';
import 'package:server_box/data/store/server.dart';
@@ -44,6 +45,9 @@ abstract class Spi with _$Spi {
/// It only applies to SSH terminal.
Map<String, String>? envs,
@Default('') @JsonKey(fromJson: Spi.parseId) String id,
/// Custom system type (unix or windows). If set, skip auto-detection.
@JsonKey(includeIfNull: false) SystemType? customSystemType,
}) = _Spi;
factory Spi.fromJson(Map<String, dynamic> json) => _$SpiFromJson(json);
@@ -132,13 +136,12 @@ extension Spix on Spi {
custom: ServerCustom(
pveAddr: 'http://localhost:8006',
pveIgnoreCert: false,
cmds: {
'echo': 'echo hello',
},
cmds: {'echo': 'echo hello'},
preferTempDev: 'nvme-pci-0400',
logoUrl: 'https://example.com/logo.png',
),
id: 'id');
id: 'id',
);
bool get isRoot => user == 'root';
}

View File

@@ -19,7 +19,8 @@ mixin _$Spi {
String get name; String get ip; int get port; String get user; String? get pwd;/// [id] of private key
@JsonKey(name: 'pubKeyId') String? get keyId; List<String>? get tags; String? get alterUrl; bool get autoConnect;/// [id] of the jump server
String? get jumpId; ServerCustom? get custom; WakeOnLanCfg? get wolCfg;/// It only applies to SSH terminal.
Map<String, String>? get envs;@JsonKey(fromJson: Spi.parseId) String get id;
Map<String, String>? get envs;@JsonKey(fromJson: Spi.parseId) String get id;/// Custom system type (unix or windows). If set, skip auto-detection.
@JsonKey(includeIfNull: false) SystemType? get customSystemType;
/// Create a copy of Spi
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@@ -32,12 +33,12 @@ $SpiCopyWith<Spi> get copyWith => _$SpiCopyWithImpl<Spi>(this as Spi, _$identity
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is Spi&&(identical(other.name, name) || other.name == name)&&(identical(other.ip, ip) || other.ip == ip)&&(identical(other.port, port) || other.port == port)&&(identical(other.user, user) || other.user == user)&&(identical(other.pwd, pwd) || other.pwd == pwd)&&(identical(other.keyId, keyId) || other.keyId == keyId)&&const DeepCollectionEquality().equals(other.tags, tags)&&(identical(other.alterUrl, alterUrl) || other.alterUrl == alterUrl)&&(identical(other.autoConnect, autoConnect) || other.autoConnect == autoConnect)&&(identical(other.jumpId, jumpId) || other.jumpId == jumpId)&&(identical(other.custom, custom) || other.custom == custom)&&(identical(other.wolCfg, wolCfg) || other.wolCfg == wolCfg)&&const DeepCollectionEquality().equals(other.envs, envs)&&(identical(other.id, id) || other.id == id));
return identical(this, other) || (other.runtimeType == runtimeType&&other is Spi&&(identical(other.name, name) || other.name == name)&&(identical(other.ip, ip) || other.ip == ip)&&(identical(other.port, port) || other.port == port)&&(identical(other.user, user) || other.user == user)&&(identical(other.pwd, pwd) || other.pwd == pwd)&&(identical(other.keyId, keyId) || other.keyId == keyId)&&const DeepCollectionEquality().equals(other.tags, tags)&&(identical(other.alterUrl, alterUrl) || other.alterUrl == alterUrl)&&(identical(other.autoConnect, autoConnect) || other.autoConnect == autoConnect)&&(identical(other.jumpId, jumpId) || other.jumpId == jumpId)&&(identical(other.custom, custom) || other.custom == custom)&&(identical(other.wolCfg, wolCfg) || other.wolCfg == wolCfg)&&const DeepCollectionEquality().equals(other.envs, envs)&&(identical(other.id, id) || other.id == id)&&(identical(other.customSystemType, customSystemType) || other.customSystemType == customSystemType));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,name,ip,port,user,pwd,keyId,const DeepCollectionEquality().hash(tags),alterUrl,autoConnect,jumpId,custom,wolCfg,const DeepCollectionEquality().hash(envs),id);
int get hashCode => Object.hash(runtimeType,name,ip,port,user,pwd,keyId,const DeepCollectionEquality().hash(tags),alterUrl,autoConnect,jumpId,custom,wolCfg,const DeepCollectionEquality().hash(envs),id,customSystemType);
@@ -48,7 +49,7 @@ abstract mixin class $SpiCopyWith<$Res> {
factory $SpiCopyWith(Spi value, $Res Function(Spi) _then) = _$SpiCopyWithImpl;
@useResult
$Res call({
String name, String ip, int port, String user, String? pwd,@JsonKey(name: 'pubKeyId') String? keyId, List<String>? tags, String? alterUrl, bool autoConnect, String? jumpId, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map<String, String>? envs,@JsonKey(fromJson: Spi.parseId) String id
String name, String ip, int port, String user, String? pwd,@JsonKey(name: 'pubKeyId') String? keyId, List<String>? tags, String? alterUrl, bool autoConnect, String? jumpId, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map<String, String>? envs,@JsonKey(fromJson: Spi.parseId) String id,@JsonKey(includeIfNull: false) SystemType? customSystemType
});
@@ -65,7 +66,7 @@ class _$SpiCopyWithImpl<$Res>
/// Create a copy of Spi
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? name = null,Object? ip = null,Object? port = null,Object? user = null,Object? pwd = freezed,Object? keyId = freezed,Object? tags = freezed,Object? alterUrl = freezed,Object? autoConnect = null,Object? jumpId = freezed,Object? custom = freezed,Object? wolCfg = freezed,Object? envs = freezed,Object? id = null,}) {
@pragma('vm:prefer-inline') @override $Res call({Object? name = null,Object? ip = null,Object? port = null,Object? user = null,Object? pwd = freezed,Object? keyId = freezed,Object? tags = freezed,Object? alterUrl = freezed,Object? autoConnect = null,Object? jumpId = freezed,Object? custom = freezed,Object? wolCfg = freezed,Object? envs = freezed,Object? id = null,Object? customSystemType = freezed,}) {
return _then(_self.copyWith(
name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,ip: null == ip ? _self.ip : ip // ignore: cast_nullable_to_non_nullable
@@ -81,7 +82,8 @@ as String?,custom: freezed == custom ? _self.custom : custom // ignore: cast_nul
as ServerCustom?,wolCfg: freezed == wolCfg ? _self.wolCfg : wolCfg // ignore: cast_nullable_to_non_nullable
as WakeOnLanCfg?,envs: freezed == envs ? _self.envs : envs // ignore: cast_nullable_to_non_nullable
as Map<String, String>?,id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,
as String,customSystemType: freezed == customSystemType ? _self.customSystemType : customSystemType // ignore: cast_nullable_to_non_nullable
as SystemType?,
));
}
@@ -92,7 +94,7 @@ as String,
@JsonSerializable(includeIfNull: false)
class _Spi extends Spi {
const _Spi({required this.name, required this.ip, required this.port, required this.user, this.pwd, @JsonKey(name: 'pubKeyId') this.keyId, final List<String>? tags, this.alterUrl, this.autoConnect = true, this.jumpId, this.custom, this.wolCfg, final Map<String, String>? envs, @JsonKey(fromJson: Spi.parseId) this.id = ''}): _tags = tags,_envs = envs,super._();
const _Spi({required this.name, required this.ip, required this.port, required this.user, this.pwd, @JsonKey(name: 'pubKeyId') this.keyId, final List<String>? tags, this.alterUrl, this.autoConnect = true, this.jumpId, this.custom, this.wolCfg, final Map<String, String>? envs, @JsonKey(fromJson: Spi.parseId) this.id = '', @JsonKey(includeIfNull: false) this.customSystemType}): _tags = tags,_envs = envs,super._();
factory _Spi.fromJson(Map<String, dynamic> json) => _$SpiFromJson(json);
@override final String name;
@@ -129,6 +131,8 @@ class _Spi extends Spi {
}
@override@JsonKey(fromJson: Spi.parseId) final String id;
/// Custom system type (unix or windows). If set, skip auto-detection.
@override@JsonKey(includeIfNull: false) final SystemType? customSystemType;
/// Create a copy of Spi
/// with the given fields replaced by the non-null parameter values.
@@ -143,12 +147,12 @@ Map<String, dynamic> toJson() {
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _Spi&&(identical(other.name, name) || other.name == name)&&(identical(other.ip, ip) || other.ip == ip)&&(identical(other.port, port) || other.port == port)&&(identical(other.user, user) || other.user == user)&&(identical(other.pwd, pwd) || other.pwd == pwd)&&(identical(other.keyId, keyId) || other.keyId == keyId)&&const DeepCollectionEquality().equals(other._tags, _tags)&&(identical(other.alterUrl, alterUrl) || other.alterUrl == alterUrl)&&(identical(other.autoConnect, autoConnect) || other.autoConnect == autoConnect)&&(identical(other.jumpId, jumpId) || other.jumpId == jumpId)&&(identical(other.custom, custom) || other.custom == custom)&&(identical(other.wolCfg, wolCfg) || other.wolCfg == wolCfg)&&const DeepCollectionEquality().equals(other._envs, _envs)&&(identical(other.id, id) || other.id == id));
return identical(this, other) || (other.runtimeType == runtimeType&&other is _Spi&&(identical(other.name, name) || other.name == name)&&(identical(other.ip, ip) || other.ip == ip)&&(identical(other.port, port) || other.port == port)&&(identical(other.user, user) || other.user == user)&&(identical(other.pwd, pwd) || other.pwd == pwd)&&(identical(other.keyId, keyId) || other.keyId == keyId)&&const DeepCollectionEquality().equals(other._tags, _tags)&&(identical(other.alterUrl, alterUrl) || other.alterUrl == alterUrl)&&(identical(other.autoConnect, autoConnect) || other.autoConnect == autoConnect)&&(identical(other.jumpId, jumpId) || other.jumpId == jumpId)&&(identical(other.custom, custom) || other.custom == custom)&&(identical(other.wolCfg, wolCfg) || other.wolCfg == wolCfg)&&const DeepCollectionEquality().equals(other._envs, _envs)&&(identical(other.id, id) || other.id == id)&&(identical(other.customSystemType, customSystemType) || other.customSystemType == customSystemType));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,name,ip,port,user,pwd,keyId,const DeepCollectionEquality().hash(_tags),alterUrl,autoConnect,jumpId,custom,wolCfg,const DeepCollectionEquality().hash(_envs),id);
int get hashCode => Object.hash(runtimeType,name,ip,port,user,pwd,keyId,const DeepCollectionEquality().hash(_tags),alterUrl,autoConnect,jumpId,custom,wolCfg,const DeepCollectionEquality().hash(_envs),id,customSystemType);
@@ -159,7 +163,7 @@ abstract mixin class _$SpiCopyWith<$Res> implements $SpiCopyWith<$Res> {
factory _$SpiCopyWith(_Spi value, $Res Function(_Spi) _then) = __$SpiCopyWithImpl;
@override @useResult
$Res call({
String name, String ip, int port, String user, String? pwd,@JsonKey(name: 'pubKeyId') String? keyId, List<String>? tags, String? alterUrl, bool autoConnect, String? jumpId, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map<String, String>? envs,@JsonKey(fromJson: Spi.parseId) String id
String name, String ip, int port, String user, String? pwd,@JsonKey(name: 'pubKeyId') String? keyId, List<String>? tags, String? alterUrl, bool autoConnect, String? jumpId, ServerCustom? custom, WakeOnLanCfg? wolCfg, Map<String, String>? envs,@JsonKey(fromJson: Spi.parseId) String id,@JsonKey(includeIfNull: false) SystemType? customSystemType
});
@@ -176,7 +180,7 @@ class __$SpiCopyWithImpl<$Res>
/// Create a copy of Spi
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? name = null,Object? ip = null,Object? port = null,Object? user = null,Object? pwd = freezed,Object? keyId = freezed,Object? tags = freezed,Object? alterUrl = freezed,Object? autoConnect = null,Object? jumpId = freezed,Object? custom = freezed,Object? wolCfg = freezed,Object? envs = freezed,Object? id = null,}) {
@override @pragma('vm:prefer-inline') $Res call({Object? name = null,Object? ip = null,Object? port = null,Object? user = null,Object? pwd = freezed,Object? keyId = freezed,Object? tags = freezed,Object? alterUrl = freezed,Object? autoConnect = null,Object? jumpId = freezed,Object? custom = freezed,Object? wolCfg = freezed,Object? envs = freezed,Object? id = null,Object? customSystemType = freezed,}) {
return _then(_Spi(
name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,ip: null == ip ? _self.ip : ip // ignore: cast_nullable_to_non_nullable
@@ -192,7 +196,8 @@ as String?,custom: freezed == custom ? _self.custom : custom // ignore: cast_nul
as ServerCustom?,wolCfg: freezed == wolCfg ? _self.wolCfg : wolCfg // ignore: cast_nullable_to_non_nullable
as WakeOnLanCfg?,envs: freezed == envs ? _self._envs : envs // ignore: cast_nullable_to_non_nullable
as Map<String, String>?,id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,
as String,customSystemType: freezed == customSystemType ? _self.customSystemType : customSystemType // ignore: cast_nullable_to_non_nullable
as SystemType?,
));
}

View File

@@ -27,6 +27,10 @@ _Spi _$SpiFromJson(Map<String, dynamic> json) => _Spi(
(k, e) => MapEntry(k, e as String),
),
id: json['id'] == null ? '' : Spi.parseId(json['id']),
customSystemType: $enumDecodeNullable(
_$SystemTypeEnumMap,
json['customSystemType'],
),
);
Map<String, dynamic> _$SpiToJson(_Spi instance) => <String, dynamic>{
@@ -44,4 +48,12 @@ Map<String, dynamic> _$SpiToJson(_Spi instance) => <String, dynamic>{
if (instance.wolCfg case final value?) 'wolCfg': value,
if (instance.envs case final value?) 'envs': value,
'id': instance.id,
if (_$SystemTypeEnumMap[instance.customSystemType] case final value?)
'customSystemType': value,
};
const _$SystemTypeEnumMap = {
SystemType.linux: 'linux',
SystemType.bsd: 'bsd',
SystemType.windows: 'windows',
};

View File

@@ -1,3 +1,5 @@
import 'dart:convert';
import 'package:fl_lib/fl_lib.dart';
import 'package:server_box/data/model/app/shell_func.dart';
import 'package:server_box/data/model/server/amd.dart';
@@ -12,6 +14,8 @@ import 'package:server_box/data/model/server/nvdia.dart';
import 'package:server_box/data/model/server/sensors.dart';
import 'package:server_box/data/model/server/server.dart';
import 'package:server_box/data/model/server/system.dart';
import 'package:server_box/data/model/server/temp.dart';
import 'package:server_box/data/model/server/windows_parser.dart';
class ServerStatusUpdateReq {
final ServerStatus ss;
@@ -31,6 +35,7 @@ Future<ServerStatus> getStatus(ServerStatusUpdateReq req) async {
return switch (req.system) {
SystemType.linux => _getLinuxStatus(req),
SystemType.bsd => _getBsdStatus(req),
SystemType.windows => _getWindowsStatus(req),
};
}
@@ -39,8 +44,7 @@ Future<ServerStatus> getStatus(ServerStatusUpdateReq req) async {
Future<ServerStatus> _getLinuxStatus(ServerStatusUpdateReq req) async {
final segments = req.segments;
final time =
int.tryParse(StatusCmdType.time.find(segments)) ??
final time = int.tryParse(StatusCmdType.time.find(segments)) ??
DateTime.now().millisecondsSinceEpoch ~/ 1000;
try {
@@ -210,11 +214,11 @@ Future<ServerStatus> _getBsdStatus(ServerStatusUpdateReq req) async {
Loggers.app.warning(e, s);
}
// try {
// req.ss.mem = parseBsdMem(BSDStatusCmdType.mem.find(segments));
// } catch (e, s) {
// Loggers.app.warning(e, s);
// }
try {
req.ss.mem = parseBsdMemory(BSDStatusCmdType.mem.find(segments));
} catch (e, s) {
Loggers.app.warning(e, s);
}
try {
final uptime = _parseUpTime(BSDStatusCmdType.uptime.find(segments));
@@ -235,14 +239,49 @@ Future<ServerStatus> _getBsdStatus(ServerStatusUpdateReq req) async {
// raw:
// 19:39:15 up 61 days, 18:16, 1 user, load average: 0.00, 0.00, 0.00
// 19:39:15 up 1 day, 2:34, 1 user, load average: 0.00, 0.00, 0.00
// 19:39:15 up 2:34, 1 user, load average: 0.00, 0.00, 0.00
// 19:39:15 up 34 min, 1 user, load average: 0.00, 0.00, 0.00
String? _parseUpTime(String raw) {
final splitedUp = raw.split('up ');
if (splitedUp.length == 2) {
final splitedComma = splitedUp[1].split(', ');
final uptimePart = splitedUp[1];
final splitedComma = uptimePart.split(', ');
if (splitedComma.isEmpty) return null;
// Handle different uptime formats
final firstPart = splitedComma[0].trim();
// Case 1: "61 days" or "1 day" - need to get the time part from next segment
if (firstPart.contains('day')) {
if (splitedComma.length >= 2) {
return splitedComma[0];
final timePart = splitedComma[1].trim();
// Check if it's in HH:MM format
if (timePart.contains(':') &&
!timePart.contains('user') &&
!timePart.contains('load')) {
return '$firstPart, $timePart';
}
}
return firstPart;
}
// Case 2: "2:34" (hours:minutes) - already in good format
if (firstPart.contains(':') &&
!firstPart.contains('user') &&
!firstPart.contains('load')) {
return firstPart;
}
// Case 3: "34 min" - already in good format
if (firstPart.contains('min')) {
return firstPart;
}
// Fallback: return first part
return firstPart;
}
return null;
}
@@ -259,3 +298,406 @@ String? _parseHostName(String raw) {
if (raw.contains(ShellFunc.scriptFile)) return null;
return raw;
}
// Windows status parsing implementation
Future<ServerStatus> _getWindowsStatus(ServerStatusUpdateReq req) async {
final segments = req.segments;
final time = int.tryParse(WindowsStatusCmdType.time.find(segments)) ??
DateTime.now().millisecondsSinceEpoch ~/ 1000;
// Parse all different resource types using helper methods
_parseWindowsNetworkData(req, segments, time);
_parseWindowsSystemData(req, segments);
_parseWindowsHostData(req, segments);
_parseWindowsCpuData(req, segments);
_parseWindowsMemoryData(req, segments);
_parseWindowsDiskData(req, segments);
_parseWindowsUptimeData(req, segments);
_parseWindowsDiskIOData(req, segments, time);
_parseWindowsConnectionData(req, segments);
_parseWindowsBatteryData(req, segments);
_parseWindowsTemperatureData(req, segments);
_parseWindowsGpuData(req, segments);
WindowsParser.parseCustomCommands(req.ss, segments, req.customCmds, req.system.segmentsLen);
return req.ss;
}
/// Parse Windows network data
void _parseWindowsNetworkData(ServerStatusUpdateReq req, List<String> segments, int time) {
try {
final netRaw = WindowsStatusCmdType.net.find(segments);
if (netRaw.isNotEmpty &&
netRaw != 'null' &&
!netRaw.contains('network_error') &&
!netRaw.contains('error') &&
!netRaw.contains('Exception')) {
final netParts = _parseWindowsNetwork(netRaw, time);
if (netParts.isNotEmpty) {
req.ss.netSpeed.update(netParts);
}
}
} catch (e, s) {
Loggers.app.warning('Windows network parsing failed: $e', s);
}
}
/// Parse Windows system information
void _parseWindowsSystemData(ServerStatusUpdateReq req, List<String> segments) {
try {
final sys = WindowsStatusCmdType.sys.find(segments);
if (sys.isNotEmpty) {
req.ss.more[StatusCmdType.sys] = sys;
}
} catch (e, s) {
Loggers.app.warning('Windows system parsing failed: $e', s);
}
}
/// Parse Windows host information
void _parseWindowsHostData(ServerStatusUpdateReq req, List<String> segments) {
try {
final host = _parseHostName(WindowsStatusCmdType.host.find(segments));
if (host != null) {
req.ss.more[StatusCmdType.host] = host;
}
} catch (e, s) {
Loggers.app.warning('Windows host parsing failed: $e', s);
}
}
/// Parse Windows CPU data and brand information
void _parseWindowsCpuData(ServerStatusUpdateReq req, List<String> segments) {
try {
// Windows CPU parsing - JSON format from PowerShell
final cpuRaw = WindowsStatusCmdType.cpu.find(segments);
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);
}
}
// Windows CPU brand parsing
final brandRaw = WindowsStatusCmdType.cpuBrand.find(segments);
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);
}
}
/// Parse Windows memory data
void _parseWindowsMemoryData(ServerStatusUpdateReq req, List<String> segments) {
try {
final memRaw = WindowsStatusCmdType.mem.find(segments);
if (memRaw.isNotEmpty &&
memRaw != 'null' &&
!memRaw.contains('error') &&
!memRaw.contains('Exception')) {
final memory = WindowsParser.parseMemory(memRaw);
if (memory != null) {
req.ss.mem = memory;
}
}
} catch (e, s) {
Loggers.app.warning('Windows memory parsing failed: $e', s);
}
}
/// Parse Windows disk data
void _parseWindowsDiskData(ServerStatusUpdateReq req, List<String> segments) {
try {
final diskRaw = WindowsStatusCmdType.disk.find(segments);
if (diskRaw.isNotEmpty && diskRaw != 'null') {
final disks = WindowsParser.parseDisks(diskRaw);
req.ss.disk = disks;
req.ss.diskUsage = DiskUsage.parse(disks);
}
} catch (e, s) {
Loggers.app.warning('Windows disk parsing failed: $e', s);
}
}
/// Parse Windows uptime data
void _parseWindowsUptimeData(ServerStatusUpdateReq req, List<String> segments) {
try {
final uptime = WindowsParser.parseUpTime(WindowsStatusCmdType.uptime.find(segments));
if (uptime != null) {
req.ss.more[StatusCmdType.uptime] = uptime;
}
} catch (e, s) {
Loggers.app.warning('Windows uptime parsing failed: $e', s);
}
}
/// Parse Windows disk I/O data
void _parseWindowsDiskIOData(ServerStatusUpdateReq req, List<String> segments, int time) {
try {
final diskIOraw = WindowsStatusCmdType.diskio.find(segments);
if (diskIOraw.isNotEmpty && diskIOraw != 'null') {
final diskio = _parseWindowsDiskIO(diskIOraw, time);
req.ss.diskIO.update(diskio);
}
} catch (e, s) {
Loggers.app.warning('Windows disk I/O parsing failed: $e', s);
}
}
/// Parse Windows connection data
void _parseWindowsConnectionData(ServerStatusUpdateReq req, List<String> segments) {
try {
final connStr = WindowsStatusCmdType.conn.find(segments);
final connCount = int.tryParse(connStr.trim());
if (connCount != null) {
req.ss.tcp = Conn(maxConn: 0, active: connCount, passive: 0, fail: 0);
}
} catch (e, s) {
Loggers.app.warning('Windows connection parsing failed: $e', s);
}
}
/// Parse Windows battery data
void _parseWindowsBatteryData(ServerStatusUpdateReq req, List<String> segments) {
try {
final batteryRaw = WindowsStatusCmdType.battery.find(segments);
if (batteryRaw.isNotEmpty && batteryRaw != 'null') {
final batteries = _parseWindowsBatteries(batteryRaw);
req.ss.batteries.clear();
if (batteries.isNotEmpty) {
req.ss.batteries.addAll(batteries);
}
}
} catch (e, s) {
Loggers.app.warning('Windows battery parsing failed: $e', s);
}
}
/// Parse Windows temperature data
void _parseWindowsTemperatureData(ServerStatusUpdateReq req, List<String> segments) {
try {
final tempRaw = WindowsStatusCmdType.temp.find(segments);
if (tempRaw.isNotEmpty && tempRaw != 'null') {
_parseWindowsTemperatures(req.ss.temps, tempRaw);
}
} catch (e, s) {
Loggers.app.warning('Windows temperature parsing failed: $e', s);
}
}
/// Parse Windows GPU data (NVIDIA/AMD)
void _parseWindowsGpuData(ServerStatusUpdateReq req, List<String> segments) {
try {
req.ss.nvidia = NvidiaSmi.fromXml(WindowsStatusCmdType.nvidia.find(segments));
} catch (e, s) {
Loggers.app.warning('Windows NVIDIA GPU parsing failed: $e', s);
}
try {
req.ss.amd = AmdSmi.fromJson(WindowsStatusCmdType.amd.find(segments));
} catch (e, s) {
Loggers.app.warning('Windows AMD GPU parsing failed: $e', s);
}
}
List<Battery> _parseWindowsBatteries(String raw) {
try {
final dynamic jsonData = json.decode(raw);
final List<Battery> batteries = [];
final batteryList = jsonData is List ? jsonData : [jsonData];
for (final batteryData in batteryList) {
final chargeRemaining =
batteryData['EstimatedChargeRemaining'] as int? ?? 0;
final batteryStatus = batteryData['BatteryStatus'] as int? ?? 0;
// Windows battery status: 1=Other, 2=Unknown, 3=Full, 4=Low,
// 5=Critical, 6=Charging, 7=ChargingAndLow, 8=ChargingAndCritical,
// 9=Undefined, 10=PartiallyCharged
final isCharging = batteryStatus == 6 ||
batteryStatus == 7 ||
batteryStatus == 8;
batteries.add(
Battery(
name: 'Battery',
percent: chargeRemaining,
status: isCharging
? BatteryStatus.charging
: BatteryStatus.discharging,
),
);
}
return batteries;
} catch (e) {
return [];
}
}
List<NetSpeedPart> _parseWindowsNetwork(String raw, int currentTime) {
try {
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;
netParts.add(
NetSpeedPart(
interfaceName,
BigInt.from(rx.toInt()),
BigInt.from(tx.toInt()),
currentTime,
),
);
}
}
}
return netParts;
} catch (e) {
return [];
}
}
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();
diskParts.add(
DiskIOPiece(
dev: diskName,
sectorsRead: sectorsRead,
sectorsWrite: sectorsWrite,
time: currentTime,
),
);
}
}
}
return diskParts;
} catch (e) {
return [];
}
}
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
if (raw.contains('Error') ||
raw.contains('Exception') ||
raw.contains('The term')) {
return;
}
final dynamic jsonData = json.decode(raw);
final tempList = jsonData is List ? jsonData : [jsonData];
// Create fake type and value strings that the existing parse method can handle
final typeLines = <String>[];
final valueLines = <String>[];
for (int i = 0; i < tempList.length; i++) {
final item = tempList[i];
final typeName = item['InstanceName']?.toString() ?? 'Unknown';
final temperature = item['Temperature'] as num?;
if (temperature != null) {
// Convert to the format expected by the existing parse method
typeLines.add('/sys/class/thermal/thermal_zone$i/$typeName');
// Convert to millicelsius (multiply by 1000)
// as expected by Linux parsing
valueLines.add((temperature * 1000).round().toString());
}
}
if (typeLines.isNotEmpty && valueLines.isNotEmpty) {
temps.parse(typeLines.join('\n'), valueLines.join('\n'));
}
} catch (e) {
// If JSON parsing fails, ignore temperature data
}
}

View File

@@ -35,23 +35,16 @@ extension SnippetX on Snippet {
static final fmtFinder = RegExp(r'\$\{[^{}]+\}');
String fmtWithSpi(Spi spi) {
return script.replaceAllMapped(
fmtFinder,
(match) {
return script.replaceAllMapped(fmtFinder, (match) {
final key = match.group(0);
final func = fmtArgs[key];
if (func != null) return func(spi);
// If not found, return the original content for further processing
return key ?? '';
},
);
});
}
Future<void> runInTerm(
Terminal terminal,
Spi spi, {
bool autoEnter = false,
}) async {
Future<void> runInTerm(Terminal terminal, Spi spi, {bool autoEnter = false}) async {
final argsFmted = fmtWithSpi(spi);
final matches = fmtFinder.allMatches(argsFmted);
@@ -119,11 +112,7 @@ extension SnippetX on Snippet {
if (autoEnter) terminal.keyInput(TerminalKey.enter);
}
Future<void> _doTermKeys(
Terminal terminal,
MapEntry<String, TerminalKey> termKey,
String key,
) async {
Future<void> _doTermKeys(Terminal terminal, MapEntry<String, TerminalKey> termKey, String key) async {
// if (termKey.value == TerminalKey.enter) {
// terminal.keyInput(TerminalKey.enter);
// return;
@@ -140,11 +129,7 @@ extension SnippetX on Snippet {
// `${ctrl+ad}` -> `ctrla + d`
final chars = key.substring(termKey.key.length + 1, key.length - 1);
if (chars.isEmpty) return;
final ok = terminal.charInput(
chars.codeUnitAt(0),
ctrl: ctrlAlt.ctrl,
alt: ctrlAlt.alt,
);
final ok = terminal.charInput(chars.codeUnitAt(0), ctrl: ctrlAlt.ctrl, alt: ctrlAlt.alt);
if (!ok) {
Loggers.app.warning('Failed to input: $key');
}
@@ -166,10 +151,7 @@ extension SnippetX on Snippet {
};
/// r'${ctrl+ad}' -> TerminalKey.control, a, d
static final fmtTermKeys = {
r'${ctrl': TerminalKey.control,
r'${alt': TerminalKey.alt,
};
static final fmtTermKeys = {r'${ctrl': TerminalKey.control, r'${alt': TerminalKey.alt};
}
class SnippetResult {
@@ -177,11 +159,7 @@ class SnippetResult {
final String result;
final Duration time;
SnippetResult({
required this.dest,
required this.result,
required this.time,
});
SnippetResult({required this.dest, required this.result, required this.time});
}
typedef SnippetFuncCtx = ({Terminal term, String raw});
@@ -193,10 +171,7 @@ abstract final class SnippetFuncs {
r'${enter': SnippetFuncs.enter,
};
static const help = {
'sleep': 'Sleep for a few seconds',
'enter': 'Enter a few times',
};
static const help = {'sleep': 'Sleep for a few seconds', 'enter': 'Enter a few times'};
static FutureOr<void> sleep(SnippetFuncCtx ctx) async {
final seconds = int.tryParse(ctx.raw);

View File

@@ -1,21 +1,55 @@
import 'package:fl_lib/fl_lib.dart';
import 'package:server_box/data/model/app/shell_func.dart';
enum SystemType {
linux._(linuxSign),
bsd._(bsdSign),
;
linux(linuxSign),
bsd(bsdSign),
windows(windowsSign);
final String value;
final String? value;
const SystemType._(this.value);
const SystemType([this.value]);
static const linuxSign = '__linux';
static const bsdSign = '__bsd';
static const windowsSign = '__windows';
/// Used for parsing system types from shell output.
///
/// This method looks for specific system signatures in the shell output
/// and returns the corresponding SystemType. If no signature is found,
/// it defaults to Linux but logs the detection failure for debugging.
static SystemType parse(String value) {
// Log the raw value for debugging purposes (truncated to avoid spam)
final truncatedValue = value.length > 100
? '${value.substring(0, 100)}...'
: value;
if (value.contains(windowsSign)) {
Loggers.app.info('System detected as Windows from signature in: $truncatedValue');
return SystemType.windows;
}
if (value.contains(bsdSign)) {
Loggers.app.info('System detected as BSD from signature in: $truncatedValue');
return SystemType.bsd;
}
// Log when falling back to Linux detection
if (value.trim().isEmpty) {
Loggers.app.warning(
'System detection received empty input, defaulting to Linux. '
'This may indicate a script execution issue.'
);
} else if (!value.contains(linuxSign)) {
Loggers.app.warning(
'System detection could not find any known signatures (Windows: $windowsSign, '
'BSD: $bsdSign, Linux: $linuxSign) in output: "$truncatedValue". '
'Defaulting to Linux, but this may cause incorrect parsing.'
);
} else {
Loggers.app.info('System detected as Linux from signature in: $truncatedValue');
}
return SystemType.linux;
}
@@ -27,6 +61,8 @@ enum SystemType {
return StatusCmdType.values.length;
case SystemType.bsd:
return BSDStatusCmdType.values.length;
case SystemType.windows:
return WindowsStatusCmdType.values.length;
}
}
}

View File

@@ -8,8 +8,7 @@ enum SystemdUnitFunc {
reload,
enable,
disable,
status,
;
status;
IconData get icon => switch (this) {
start => Icons.play_arrow,
@@ -26,8 +25,7 @@ enum SystemdUnitType {
service,
socket,
mount,
timer,
;
timer;
static SystemdUnitType? fromString(String? value) {
return values.firstWhereOrNull((e) => e.name == value?.toLowerCase());
@@ -36,8 +34,7 @@ enum SystemdUnitType {
enum SystemdUnitScope {
system,
user,
;
user;
Color? get color => switch (this) {
system => Colors.red,
@@ -57,8 +54,7 @@ enum SystemdUnitState {
inactive,
failed,
activating,
deactivating,
;
deactivating;
static SystemdUnitState? fromString(String? value) {
return values.firstWhereOrNull((e) => e.name == value?.toLowerCase());
@@ -85,10 +81,7 @@ final class SystemdUnit {
required this.state,
});
String getCmd({
required SystemdUnitFunc func,
required bool isRoot,
}) {
String getCmd({required SystemdUnitFunc func, required bool isRoot}) {
final prefix = scope.getCmdPrefix(isRoot);
return '$prefix ${func.name} $name';
}

View File

@@ -40,11 +40,7 @@ class Fifo<T> extends ListBase<T> {
abstract class TimeSeq<T extends List<TimeSeqIface>> extends Fifo<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(T init1, T init2, {super.capacity}) : super(list: [init1, init2]);
T get pre {
return _list[length - 2];

View File

@@ -0,0 +1,258 @@
import 'dart:convert';
import 'package:fl_lib/fl_lib.dart';
import 'package:intl/intl.dart';
import 'package:server_box/data/model/server/cpu.dart';
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-specific status parsing utilities
///
/// This module handles parsing of Windows PowerShell command outputs
/// for server monitoring. It extracts the Windows parsing logic
/// to improve maintainability and readability.
class WindowsParser {
const WindowsParser._();
/// Parse Windows custom commands from segments
static void parseCustomCommands(
ServerStatus serverStatus,
List<String> segments,
Map<String, String> customCmds,
int systemSegmentsLength,
) {
try {
for (int idx = 0; idx < customCmds.length; idx++) {
final key = customCmds.keys.elementAt(idx);
// Ensure we don't go out of bounds when accessing segments
final segmentIndex = idx + systemSegmentsLength;
if (segmentIndex < segments.length) {
final value = segments[segmentIndex];
serverStatus.customCmds[key] = value;
} else {
Loggers.app.warning(
'Windows custom commands: segment index $segmentIndex out of bounds '
'(segments length: ${segments.length}, systemSegmentsLength: $systemSegmentsLength)'
);
}
}
} catch (e, s) {
Loggers.app.warning('Windows custom commands parsing failed: $e', s);
}
}
/// Parse Windows uptime from PowerShell output
static String? parseUpTime(String raw) {
try {
// Clean the input - trim whitespace and get the first non-empty line
final cleanedInput = raw.trim().split('\n')
.where((line) => line.trim().isNotEmpty)
.firstOrNull;
if (cleanedInput == null || cleanedInput.isEmpty) {
Loggers.app.warning('Windows uptime parsing: empty or null input');
return null;
}
// Try multiple date formats to handle different Windows locale/version outputs
final formatters = [
DateFormat('EEEE, MMMM d, yyyy h:mm:ss a', 'en_US'), // Original format
DateFormat('EEEE, MMMM dd, yyyy h:mm:ss a', 'en_US'), // Double-digit day
DateFormat('EEE, MMM d, yyyy h:mm:ss a', 'en_US'), // Shortened format
DateFormat('EEE, MMM dd, yyyy h:mm:ss a', 'en_US'), // Shortened with double-digit day
DateFormat('M/d/yyyy h:mm:ss a', 'en_US'), // Short US format
DateFormat('MM/dd/yyyy h:mm:ss a', 'en_US'), // Short US format with zero padding
DateFormat('d/M/yyyy h:mm:ss a', 'en_US'), // Short European format
DateFormat('dd/MM/yyyy h:mm:ss a', 'en_US'), // Short European format with zero padding
];
DateTime? dateTime;
for (final formatter in formatters) {
dateTime = formatter.tryParseLoose(cleanedInput);
if (dateTime != null) break;
}
if (dateTime == null) {
Loggers.app.warning('Windows uptime parsing: could not parse date format for: $cleanedInput');
return null;
}
final now = DateTime.now();
final uptime = now.difference(dateTime);
// Validate that the uptime is reasonable (not negative, not too far in the future)
if (uptime.isNegative || uptime.inDays > 3650) { // More than 10 years seems unreasonable
Loggers.app.warning('Windows uptime parsing: unreasonable uptime calculated: ${uptime.inDays} days for date: $cleanedInput');
return null;
}
final days = uptime.inDays;
final hours = uptime.inHours % 24;
final minutes = uptime.inMinutes % 60;
if (days > 0) {
return '$days days, $hours:${minutes.toString().padLeft(2, '0')}';
} else {
return '$hours:${minutes.toString().padLeft(2, '0')}';
}
} catch (e, s) {
Loggers.app.warning('Windows uptime parsing failed: $e for input: $raw', s);
return null;
}
}
/// Parse Windows CPU information from PowerShell output
static List<SingleCpuCore> parseCpu(String raw, ServerStatus serverStatus) {
try {
final dynamic jsonData = json.decode(raw);
final List<SingleCpuCore> cpus = [];
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;
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.
final newUser = (prevCpu?.user ?? 0) + usage;
final newIdle = (prevCpu?.idle ?? 0) + idle;
cpus.add(
SingleCpuCore(
'cpu$i',
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)
),
);
}
} 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 [];
}
}
/// Parse Windows memory information from PowerShell output
///
/// NOTE: Windows Win32_OperatingSystem properties TotalVisibleMemorySize
/// and FreePhysicalMemory are returned in KB units.
static Memory? parseMemory(String raw) {
try {
final dynamic jsonData = json.decode(raw);
final data = jsonData is List ? jsonData.first : jsonData;
// Win32_OperatingSystem properties are in KB
final totalKB = data['TotalVisibleMemorySize'] as int? ?? 0;
final freeKB = data['FreePhysicalMemory'] as int? ?? 0;
return Memory(
total: totalKB,
free: freeKB,
avail: freeKB, // Windows doesn't distinguish between free and available
);
} catch (e) {
return null;
}
}
/// Parse Windows disk information from PowerShell output
static List<Disk> parseDisks(String raw) {
try {
final dynamic jsonData = json.decode(raw);
final List<Disk> disks = [];
final diskList = jsonData is List ? jsonData : [jsonData];
for (final diskData in diskList) {
final deviceId = diskData['DeviceID']?.toString() ?? '';
final size =
BigInt.tryParse(diskData['Size']?.toString() ?? '0') ?? BigInt.zero;
final freeSpace =
BigInt.tryParse(diskData['FreeSpace']?.toString() ?? '0') ??
BigInt.zero;
final fileSystem = diskData['FileSystem']?.toString() ?? '';
// Validate all required fields
final hasRequiredFields = deviceId.isNotEmpty &&
size != BigInt.zero &&
freeSpace != BigInt.zero &&
fileSystem.isNotEmpty;
if (!hasRequiredFields) {
Loggers.app.warning('Windows disk parsing: skipping disk with missing required fields. '
'DeviceID: $deviceId, Size: $size, FreeSpace: $freeSpace, FileSystem: $fileSystem');
continue;
}
final sizeKB = size ~/ BigInt.from(1024);
final freeKB = freeSpace ~/ BigInt.from(1024);
final usedKB = sizeKB - freeKB;
final usedPercent = sizeKB > BigInt.zero
? ((usedKB * BigInt.from(100)) ~/ sizeKB).toInt()
: 0;
disks.add(
Disk(
path: deviceId,
fsTyp: fileSystem,
size: sizeKB,
avail: freeKB,
used: usedKB,
usedPercent: usedPercent,
mount: deviceId, // Windows uses drive letters as mount points
),
);
}
return disks;
} catch (e) {
Loggers.app.warning('Windows disk parsing failed: $e');
return [];
}
}
}

View File

@@ -11,11 +11,7 @@ final class WakeOnLanCfg {
final String ip;
final String? pwd;
const WakeOnLanCfg({
required this.mac,
required this.ip,
this.pwd,
});
const WakeOnLanCfg({required this.mac, required this.ip, this.pwd});
(Object?, bool) validate() {
final macValidation = MACAddress.validate(mac);
@@ -39,10 +35,7 @@ final class WakeOnLanCfg {
final mac_ = MACAddress(mac);
final pwd_ = pwd != null ? SecureONPassword(pwd!) : null;
final obj = WakeOnLAN(ip_, mac_, password: pwd_);
return obj.wake(
repeat: 3,
repeatDelay: const Duration(milliseconds: 500),
);
return obj.wake(repeat: 3, repeatDelay: const Duration(milliseconds: 500));
}
factory WakeOnLanCfg.fromJson(Map<String, dynamic> json) => _$WakeOnLanCfgFromJson(json);

View File

@@ -9,12 +9,7 @@ class SftpReq {
Spi? jumpSpi;
String? jumpPrivateKey;
SftpReq(
this.spi,
this.remotePath,
this.localPath,
this.type,
) {
SftpReq(this.spi, this.remotePath, this.localPath, this.type) {
final keyId = spi.keyId;
if (keyId != null) {
privateKey = getPrivateKey(keyId);
@@ -44,15 +39,9 @@ class SftpReqStatus {
Exception? error;
Duration? spentTime;
SftpReqStatus({
required this.req,
required this.notifyListeners,
this.completer,
}) : id = DateTime.now().microsecondsSinceEpoch {
worker = SftpWorker(
onNotify: onNotify,
req: req,
)..init();
SftpReqStatus({required this.req, required this.notifyListeners, this.completer})
: id = DateTime.now().microsecondsSinceEpoch {
worker = SftpWorker(onNotify: onNotify, req: req)..init();
}
@override

View File

@@ -18,10 +18,7 @@ class SftpWorker {
final worker = Worker();
SftpWorker({
required this.onNotify,
required this.req,
});
SftpWorker({required this.onNotify, required this.req});
void _dispose() {
worker.dispose();
@@ -31,11 +28,7 @@ class SftpWorker {
/// the threads
Future<void> init() async {
if (worker.isInitialized) worker.dispose();
await worker.init(
mainMessageHandler,
isolateMessageHandler,
errorHandler: print,
);
await worker.init(mainMessageHandler, isolateMessageHandler, errorHandler: print);
worker.sendMessage(req);
}
@@ -46,11 +39,7 @@ class SftpWorker {
}
/// Handle the messages coming from the main
Future<void> isolateMessageHandler(
dynamic data,
SendPort mainSendPort,
SendErrorFunction sendError,
) async {
Future<void> isolateMessageHandler(dynamic data, SendPort mainSendPort, SendErrorFunction sendError) async {
switch (data) {
case final SftpReq val:
switch (val.type) {
@@ -67,11 +56,7 @@ Future<void> isolateMessageHandler(
}
}
Future<void> _download(
SftpReq req,
SendPort mainSendPort,
SendErrorFunction sendError,
) async {
Future<void> _download(SftpReq req, SendPort mainSendPort, SendErrorFunction sendError) async {
try {
mainSendPort.send(SftpWorkerStatus.preparing);
final watch = Stopwatch()..start();
@@ -127,11 +112,7 @@ Future<void> _download(
}
}
Future<void> _upload(
SftpReq req,
SendPort mainSendPort,
SendErrorFunction sendError,
) async {
Future<void> _upload(SftpReq req, SendPort mainSendPort, SendErrorFunction sendError) async {
try {
mainSendPort.send(SftpWorkerStatus.preparing);
final watch = Stopwatch()..start();
@@ -156,9 +137,7 @@ Future<void> _upload(
// If remote exists, overwrite it
final file = await sftp.open(
req.remotePath,
mode: SftpFileOpenMode.truncate |
SftpFileOpenMode.create |
SftpFileOpenMode.write,
mode: SftpFileOpenMode.truncate | SftpFileOpenMode.create | SftpFileOpenMode.write,
);
final writer = file.write(
localFile,

View File

@@ -51,7 +51,7 @@ enum VirtKey {
f9,
f10,
f11,
f12;
f12,
}
extension VirtKeyX on VirtKey {

View File

@@ -7,9 +7,7 @@ part 'app.freezed.dart';
@freezed
abstract class AppState with _$AppState {
const factory AppState({
@Default(false) bool desktopMode,
}) = _AppState;
const factory AppState({@Default(false) bool desktopMode}) = _AppState;
}
@Riverpod(keepAlive: true)

View File

@@ -12,8 +12,7 @@ import 'package:server_box/data/model/container/ps.dart';
import 'package:server_box/data/model/container/type.dart';
import 'package:server_box/data/res/store.dart';
final _dockerNotFound =
RegExp(r"command not found|Unknown command|Command '\w+' not found");
final _dockerNotFound = RegExp(r"command not found|Unknown command|Command '\w+' not found");
class ContainerProvider extends ChangeNotifier {
final SSHClient? client;
@@ -90,11 +89,7 @@ class ContainerProvider extends ChangeNotifier {
final includeStats = Stores.setting.containerParseStat.fetch();
var raw = '';
final cmd = _wrap(ContainerCmdType.execAll(
type,
sudo: sudo,
includeStats: includeStats,
));
final cmd = _wrap(ContainerCmdType.execAll(type, sudo: sudo, includeStats: includeStats));
final code = await client?.execWithPwd(
cmd,
context: context,
@@ -130,10 +125,7 @@ class ContainerProvider extends ChangeNotifier {
try {
version = json.decode(verRaw)['Client']['Version'];
} catch (e, trace) {
error = ContainerErr(
type: ContainerErrType.invalidVersion,
message: '$e',
);
error = ContainerErr(type: ContainerErrType.invalidVersion, message: '$e');
Loggers.app.warning('Container version failed', e, trace);
} finally {
notifyListeners();
@@ -150,10 +142,7 @@ class ContainerProvider extends ChangeNotifier {
lines.removeWhere((element) => element.isEmpty);
items = lines.map((e) => ContainerPs.fromRaw(e, type)).toList();
} catch (e, trace) {
error = ContainerErr(
type: ContainerErrType.parsePs,
message: '$e',
);
error = ContainerErr(type: ContainerErrType.parsePs, message: '$e');
Loggers.app.warning('Container ps failed', e, trace);
} finally {
notifyListeners();
@@ -173,10 +162,7 @@ class ContainerProvider extends ChangeNotifier {
images = lines.map((e) => ContainerImg.fromRawJson(e, type)).toList();
}
} catch (e, trace) {
error = ContainerErr(
type: ContainerErrType.parseImages,
message: '$e',
);
error = ContainerErr(type: ContainerErrType.parseImages, message: '$e');
Loggers.app.warning('Container images failed', e, trace);
} finally {
notifyListeners();
@@ -199,10 +185,7 @@ class ContainerProvider extends ChangeNotifier {
item.parseStats(statsLine);
}
} catch (e, trace) {
error = ContainerErr(
type: ContainerErrType.parseStats,
message: '$e',
);
error = ContainerErr(type: ContainerErrType.parseStats, message: '$e');
Loggers.app.warning('Parse docker stats: $statsRaw', e, trace);
} finally {
notifyListeners();
@@ -261,10 +244,7 @@ class ContainerProvider extends ChangeNotifier {
notifyListeners();
if (code != 0) {
return ContainerErr(
type: ContainerErrType.unknown,
message: errs.join('\n').trim(),
);
return ContainerErr(type: ContainerErrType.unknown, message: errs.join('\n').trim());
}
if (autoRefresh) await refresh();
return null;
@@ -288,40 +268,32 @@ enum ContainerCmdType {
version,
ps,
stats,
images,
images
// No specific commands needed for prune actions as they are simple
// and don't require splitting output with ShellFunc.seperator
;
String exec(
ContainerType type, {
bool sudo = false,
bool includeStats = false,
}) {
String exec(ContainerType type, {bool sudo = false, bool includeStats = false}) {
final prefix = sudo ? 'sudo -S ${type.name}' : type.name;
return switch (this) {
ContainerCmdType.version => '$prefix version $_jsonFmt',
ContainerCmdType.ps => switch (type) {
/// TODO: Rollback to json format when permformance recovers.
/// Use [_jsonFmt] in Docker will cause the operation to slow down.
ContainerType.docker => '$prefix ps -a --format "table {{printf \\"'
ContainerType.docker =>
'$prefix ps -a --format "table {{printf \\"'
'%-15.15s '
'%-30.30s '
'${"%-50.50s " * 2}\\"'
' .ID .Status .Names .Image}}"',
ContainerType.podman => '$prefix ps -a $_jsonFmt',
},
ContainerCmdType.stats =>
includeStats ? '$prefix stats --no-stream $_jsonFmt' : 'echo PASS',
ContainerCmdType.stats => includeStats ? '$prefix stats --no-stream $_jsonFmt' : 'echo PASS',
ContainerCmdType.images => '$prefix image ls $_jsonFmt',
};
}
static String execAll(
ContainerType type, {
bool sudo = false,
bool includeStats = false,
}) {
static String execAll(ContainerType type, {bool sudo = false, bool includeStats = false}) {
return ContainerCmdType.values
.map((e) => e.exec(type, sudo: sudo, includeStats: includeStats))
.join('\necho ${ShellFunc.seperator}\n');

View File

@@ -86,15 +86,18 @@ final class PveProvider extends ChangeNotifier {
forward.stream.cast<List<int>>().pipe(socket);
socket.cast<List<int>>().pipe(forward.sink);
});
final newUrl = Uri.parse(addr)
.replace(host: 'localhost', port: _localPort)
.toString();
final newUrl = Uri.parse(
addr,
).replace(host: 'localhost', port: _localPort).toString();
debugPrint('Forwarding $newUrl to $addr');
}
}
Future<ConnectionTask<Socket>> cf(
Uri url, String? proxyHost, int? proxyPort) async {
Uri url,
String? proxyHost,
int? proxyPort,
) async {
/* final serverSocket = await ServerSocket.bind(InternetAddress.anyIPv4, 0);
final _localPort = serverSocket.port;
serverSocket.listen((socket) async {
@@ -105,8 +108,11 @@ final class PveProvider extends ChangeNotifier {
});*/
if (url.isScheme('https')) {
return SecureSocket.startConnect('localhost', _localPort,
onBadCertificate: (_) => true);
return SecureSocket.startConnect(
'localhost',
_localPort,
onBadCertificate: (_) => true,
);
} else {
return Socket.startConnect('localhost', _localPort);
}
@@ -119,7 +125,7 @@ final class PveProvider extends ChangeNotifier {
'username': spi.user,
'password': spi.pwd,
'realm': 'pam',
'new-format': '1'
'new-format': '1',
},
options: Options(
headers: {HttpHeaders.contentTypeHeader: Headers.jsonContentType},
@@ -151,8 +157,10 @@ final class PveProvider extends ChangeNotifier {
try {
final resp = await session.get('$addr/api2/json/cluster/resources');
final res = resp.data['data'] as List;
final result =
await Computer.shared.start(PveRes.parse, (res, data.value));
final result = await Computer.shared.start(PveRes.parse, (
res,
data.value,
));
data.value = result;
} catch (e) {
Loggers.app.warning('PVE list failed', e);
@@ -164,29 +172,33 @@ final class PveProvider extends ChangeNotifier {
Future<bool> reboot(String node, String id) async {
await connected.future;
final resp =
await session.post('$addr/api2/json/nodes/$node/$id/status/reboot');
final resp = await session.post(
'$addr/api2/json/nodes/$node/$id/status/reboot',
);
return _isCtrlSuc(resp);
}
Future<bool> start(String node, String id) async {
await connected.future;
final resp =
await session.post('$addr/api2/json/nodes/$node/$id/status/start');
final resp = await session.post(
'$addr/api2/json/nodes/$node/$id/status/start',
);
return _isCtrlSuc(resp);
}
Future<bool> stop(String node, String id) async {
await connected.future;
final resp =
await session.post('$addr/api2/json/nodes/$node/$id/status/stop');
final resp = await session.post(
'$addr/api2/json/nodes/$node/$id/status/stop',
);
return _isCtrlSuc(resp);
}
Future<bool> shutdown(String node, String id) async {
await connected.future;
final resp =
await session.post('$addr/api2/json/nodes/$node/$id/status/shutdown');
final resp = await session.post(
'$addr/api2/json/nodes/$node/$id/status/shutdown',
);
return _isCtrlSuc(resp);
}

View File

@@ -9,6 +9,7 @@ import 'package:server_box/core/extension/ssh_client.dart';
import 'package:server_box/core/sync.dart';
import 'package:server_box/core/utils/server.dart';
import 'package:server_box/core/utils/ssh_auth.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/shell_func.dart';
import 'package:server_box/data/model/server/server.dart';
@@ -32,6 +33,8 @@ class ServerProvider extends Provider {
static final _manualDisconnectedIds = <String>{};
static final _serverIdsUpdating = <String, Future<void>?>{};
@override
Future<void> load() async {
super.load();
@@ -124,11 +127,35 @@ class ServerProvider extends Provider {
return;
}
return await _getData(s.spi);
// Check if already updating, and if so, wait for it to complete
final existingUpdate = _serverIdsUpdating[s.spi.id];
if (existingUpdate != null) {
// Already updating, wait for the existing update to complete
try {
await existingUpdate;
} catch (e) {
// Ignore errors from the existing update, we'll try our own
}
return;
}
// Start a new update operation
final updateFuture = _updateServer(s.spi);
_serverIdsUpdating[s.spi.id] = updateFuture;
try {
await updateFuture;
} finally {
_serverIdsUpdating.remove(s.spi.id);
}
}),
);
}
static Future<void> _updateServer(Spi spi) async {
await _getData(spi);
}
static Future<void> startAutoRefresh() async {
var duration = Stores.setting.serverStatusUpdateInterval.fetch();
stopAutoRefresh();
@@ -305,13 +332,17 @@ class ServerProvider extends Provider {
_setServerState(s, ServerConn.connected);
try {
// Detect system type using helper
final detectedSystemType = await SystemDetector.detect(sv.client!, spi);
sv.status.system = detectedSystemType;
final (_, writeScriptResult) = await sv.client!.exec((session) async {
final scriptRaw = ShellFunc.allScript(spi.custom?.cmds).uint8List;
final scriptRaw = ShellFunc.allScript(spi.custom?.cmds, systemType: detectedSystemType).uint8List;
session.stdin.add(scriptRaw);
session.stdin.close();
}, entry: ShellFunc.getInstallShellCmd(spi.id));
if (writeScriptResult.isNotEmpty) {
ShellFunc.switchScriptDir(spi.id);
}, entry: ShellFunc.getInstallShellCmd(spi.id, systemType: detectedSystemType));
if (writeScriptResult.isNotEmpty && detectedSystemType != SystemType.windows) {
ShellFunc.switchScriptDir(spi.id, systemType: detectedSystemType);
throw writeScriptResult;
}
} on SSHAuthAbortError catch (e) {
@@ -351,7 +382,8 @@ class ServerProvider extends Provider {
String? raw;
try {
raw = await sv.client?.run(ShellFunc.status.exec(spi.id)).string;
raw = await sv.client?.run(ShellFunc.status.exec(spi.id, systemType: sv.status.system)).string;
dprint('Get status from ${spi.name}:\n$raw');
segments = raw?.split(ShellFunc.seperator).map((e) => e.trim()).toList();
if (raw == null || raw.isEmpty || segments == null || segments.isEmpty) {
if (Stores.setting.keepStatusWhenErr.fetch()) {

View File

@@ -44,10 +44,8 @@ final class SystemdProvider {
}
}
final parsedUserUnits =
await _parseUnitObj(userUnits, SystemdUnitScope.user);
final parsedSystemUnits =
await _parseUnitObj(systemUnits, SystemdUnitScope.system);
final parsedUserUnits = await _parseUnitObj(userUnits, SystemdUnitScope.user);
final parsedSystemUnits = await _parseUnitObj(systemUnits, SystemdUnitScope.system);
this.units.value = [...parsedUserUnits, ...parsedSystemUnits];
} catch (e, s) {
Loggers.app.warning('Parse systemd', e, s);
@@ -56,14 +54,10 @@ final class SystemdProvider {
isBusy.value = false;
}
Future<List<SystemdUnit>> _parseUnitObj(
List<String> unitNames,
SystemdUnitScope scope,
) async {
final unitNames_ = unitNames
.map((e) => e.trim().split('/').last.split('.').first)
.toList();
final script = '''
Future<List<SystemdUnit>> _parseUnitObj(List<String> unitNames, SystemdUnitScope scope) async {
final unitNames_ = unitNames.map((e) => e.trim().split('/').last.split('.').first).toList();
final script =
'''
for unit in ${unitNames_.join(' ')}; do
state=\$(systemctl show --no-pager \$unit)
echo -n "${ShellFunc.seperator}\n\$state"
@@ -108,13 +102,9 @@ done
continue;
}
parsedUnits.add(SystemdUnit(
name: name,
type: unitType,
scope: scope,
state: unitState,
description: description,
));
parsedUnits.add(
SystemdUnit(name: name, type: unitType, scope: scope, state: unitState, description: description),
);
}
parsedUnits.sort((a, b) {
@@ -131,7 +121,8 @@ done
return parsedUnits;
}
late final _getUnitsCmd = '''
late final _getUnitsCmd =
'''
get_files() {
unit_type=\$1
base_dir=\$2

View File

@@ -20,8 +20,7 @@ class ContainerStore extends HiveStore {
ContainerType getType([String id = '']) {
final cfg = box.get(_keyConfig + id);
if (cfg != null) {
final type =
ContainerType.values.firstWhereOrNull((e) => e.toString() == cfg);
final type = ContainerType.values.firstWhereOrNull((e) => e.toString() == cfg);
if (type != null) return type;
}

View File

@@ -7,10 +7,8 @@ class _ListHistory {
final String _name;
final Box _box;
_ListHistory({
required Box box,
required String name,
}) : _box = box,
_ListHistory({required Box box, required String name})
: _box = box,
_name = name,
_history = box.get(name, defaultValue: [])!;
@@ -28,10 +26,8 @@ class _MapHistory {
final String _name;
final Box _box;
_MapHistory({
required Box box,
required String name,
}) : _box = box,
_MapHistory({required Box box, required String name})
: _box = box,
_name = name,
_history = box.get(name, defaultValue: <dynamic, dynamic>{})!;
@@ -56,6 +52,5 @@ class HistoryStore extends HiveStore {
late final sshCmds = _ListHistory(box: box, name: 'sshCmds');
/// Notify users that this app will write script to server to works properly
late final writeScriptTipShown =
propertyDefault('writeScriptTipShown', false);
late final writeScriptTipShown = propertyDefault('writeScriptTipShown', false);
}

View File

@@ -15,7 +15,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get acceptBeta => '接受测试版更新推送';
@override
String get addSystemPrivateKeyTip => '当前没有任何私钥,是否添加系统自带的~/.ssh/id_rsa';
String get addSystemPrivateKeyTip => '检测到暂无私钥,是否添加系统默认的私钥~/.ssh/id_rsa';
@override
String get added2List => '已添加至任务列表';
@@ -24,13 +24,13 @@ class AppLocalizationsZh extends AppLocalizations {
String get addr => '地址';
@override
String get alreadyLastDir => '经是最上层目录';
String get alreadyLastDir => '是顶级目录';
@override
String get authFailTip => '认证失败,请检查密码/密钥/主机/用户等是否错误';
String get authFailTip => '认证失败,请检查连接信息是否正确';
@override
String get autoBackupConflict => '只能同时开启一个自动备份';
String get autoBackupConflict => '仅可启用一个自动备份任务';
@override
String get autoConnect => '自动连接';
@@ -42,10 +42,10 @@ class AppLocalizationsZh extends AppLocalizations {
String get autoUpdateHomeWidget => '自动更新桌面小部件';
@override
String get backupTip => '导出数据可以使用密码加密,请妥善保管。';
String get backupTip => '导出数据可通过密码加密,请妥善保管。';
@override
String get backupVersionNotMatch => '备份版本不匹配,无法恢复';
String get backupVersionNotMatch => '备份版本不兼容,无法恢复';
@override
String get backupPassword => '备份密码';
@@ -76,7 +76,7 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get bgRunTip =>
'此开关只代表程序会尝试在后台运行,具体能否后台运行取决于是否开启了权限。原生 Android 请关闭本 App 的“电池优化”MIUI / HyperOS 请修改省电策略为“无限制”。';
'此开关只代表程序会尝试在后台运行,具体能否后台运行取决于是否开启了权限。原生 Android 请关闭本 App 的“电池优化”MIUI / HyperOS 请省电策略为“无限制”。';
@override
String get closeAfterSave => '保存后关闭';
@@ -132,10 +132,10 @@ class AppLocalizationsZh extends AppLocalizations {
String get desktopTerminalTip => '启动 SSH 连接所用的终端模拟器命令';
@override
String get dirEmpty => '请确保文件夹为空';
String get dirEmpty => '请确保目录为空';
@override
String get disconnected => '连接断开';
String get disconnected => '已断开连接';
@override
String get disk => '磁盘';
@@ -160,11 +160,11 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String dockerImagesFmt(Object count) {
return '$count 个镜像';
return '$count 个镜像';
}
@override
String get dockerNotInstalled => 'Docker 未安装';
String get dockerNotInstalled => '未安装 Docker';
@override
String dockerStatusRunningAndStoppedFmt(
@@ -183,7 +183,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get doubleColumnMode => '双列模式';
@override
String get doubleColumnTip => '此选项仅开启功能,实际是否能开启还取决于设备宽度';
String get doubleColumnTip => '此选项仅用于启用该功能,是否生效取决于设备宽度';
@override
String get editVirtKeys => '编辑虚拟按键';
@@ -192,7 +192,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get editor => '编辑器';
@override
String get editorHighlightTip => '目前的代码高亮性能较为糟糕,可选择关闭以改善';
String get editorHighlightTip => '代码高亮功能可能影响性能,可选择关闭。';
@override
String get emulator => '模拟器';
@@ -246,7 +246,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get fullScreenJitter => '全屏模式抖动';
@override
String get fullScreenJitterHelp => '防止烧屏';
String get fullScreenJitterHelp => '用于防止屏幕烧屏';
@override
String get fullScreenTip => '当设备旋转为横屏时,是否开启全屏模式。此选项仅作用于服务器 Tab 页。';
@@ -271,7 +271,7 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String httpFailedWithCode(Object code) {
return '请求失败, 状态码: $code';
return '请求失败状态码: $code';
}
@override
@@ -294,7 +294,7 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get installDockerWithUrl =>
'请先 https://docs.docker.com/engine/install docker';
'请先前往 https://docs.docker.com/engine/install 安装 Docker';
@override
String get invalid => '无效';
@@ -303,7 +303,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get jumpServer => '跳板服务器';
@override
String get keepForeground => '保持应用处于前台!';
String get keepForeground => '将应用保持在前台运行';
@override
String get keepStatusWhenErr => '保留上次的服务器状态';
@@ -344,7 +344,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get maxRetryCount => '服务器尝试重连次数';
@override
String get maxRetryCountEqual0 => '无限重试';
String get maxRetryCountEqual0 => '无限重试';
@override
String get min => '最小';
@@ -409,7 +409,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get openLastPath => '打开上次的路径';
@override
String get openLastPathTip => '不同的服务器会有不同的记录,且记录的是退出时的路径';
String get openLastPathTip => '将为每台服务器记录其最后访问路径';
@override
String get parseContainerStatsTip => 'Docker 解析占用状态较为缓慢';
@@ -462,7 +462,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get pveIgnoreCertTip => '不推荐开启,注意安全隐患!如果你使用的 PVE 默认证书,需要开启该选项';
@override
String get pveLoginFailed => '登录失败。无法使用服务器配置的用户/密码,以 Linux PAM 方式登录';
String get pveLoginFailed => '登录失败。无法使用服务器配置的用户名或密码通过 Linux PAM 方式认证';
@override
String get pveVersionLow => '当前该功能处于测试阶段,仅在 PVE 8+ 上测试过,请谨慎使用';
@@ -544,7 +544,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get sftpRmrDirSummary => '在 SFTP 中使用 `rm -r` 来删除文件夹';
@override
String get sftpSSHConnected => 'SFTP 已连接...';
String get sftpSSHConnected => 'SFTP 已连接';
@override
String get sftpShowFoldersFirst => '文件夹显示在前';
@@ -575,7 +575,7 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String spentTime(Object time) {
return '耗时: $time';
return '耗时$time';
}
@override
@@ -683,7 +683,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get update => '更新';
@override
String get updateIntervalEqual0 => '设置为 0,服务器状态不会自动刷新\n不能计算 CPU 使用情况';
String get updateIntervalEqual0 => '设置为 0 将不自动刷新服务器状态\n无法计算 CPU 使用';
@override
String get updateServerStatusInterval => '服务器状态刷新间隔';
@@ -743,7 +743,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get whenOpenApp => '当打开 App 时';
@override
String get wolTip => '配置 WOL 后,每次连接服务器都会先发送一次 WOL 请求';
String get wolTip => '配置 WOL 后,每次连接服务器时将自动发送唤醒请求';
@override
String get write => '';
@@ -767,7 +767,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
String get acceptBeta => '接受測試版更新推送';
@override
String get addSystemPrivateKeyTip => '目前沒有任何私鑰,是否新增系統原有的 (~/.ssh/id_rsa)';
String get addSystemPrivateKeyTip => '偵測到尚無私鑰,是否要加入系統預設的私鑰(~/.ssh/id_rsa';
@override
String get added2List => '已新增至任務清單';
@@ -776,13 +776,13 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
String get addr => '位址';
@override
String get alreadyLastDir => '經是最上層目錄';
String get alreadyLastDir => '是頂層目錄';
@override
String get authFailTip => '認證失敗,請檢查密碼/金鑰/主機/使用者等是否錯誤。';
String get authFailTip => '認證失敗,請檢查連線資訊是否正確';
@override
String get autoBackupConflict => '只能同時開啓一個自動備份';
String get autoBackupConflict => '僅能啟用一項自動備份任務';
@override
String get autoConnect => '自動連線';
@@ -794,10 +794,10 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
String get autoUpdateHomeWidget => '自動更新桌面小工具';
@override
String get backupTip => '匯出的資料可以使用密碼加密\n請妥善保管。';
String get backupTip => '匯出的資料可透過密碼加密請妥善保管。';
@override
String get backupVersionNotMatch => '備份版本不相,無法還原';
String get backupVersionNotMatch => '備份版本不相,無法還原';
@override
String get backupPassword => '備份密碼';
@@ -828,7 +828,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
@override
String get bgRunTip =>
'此開關代表程式會嘗試背景執行,具體能否在後臺執行取決於是否開啟了權限。 原生 Android 請關閉本 App 的“電池最佳化”,MIUI / HyperOS 請修改省電策略為“無限制';
'此開關代表程式會嘗試背景執行,能否成功取決於系統權限。原生 Android 上,請關閉本應用的「電池最佳化」;在 MIUI / HyperOS 上,請將省電策略調整為「無限制';
@override
String get closeAfterSave => '儲存後關閉';
@@ -884,10 +884,10 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
String get desktopTerminalTip => '啟動 SSH 連線時用於打開終端機模擬器的指令。';
@override
String get dirEmpty => '請確保資料夾為空';
String get dirEmpty => '請確保目錄為空';
@override
String get disconnected => '連線中斷';
String get disconnected => '已中斷連線';
@override
String get disk => '磁碟';
@@ -912,11 +912,11 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
@override
String dockerImagesFmt(Object count) {
return '$count 個映像檔';
return '$count 個映像檔';
}
@override
String get dockerNotInstalled => 'Docker 未安裝';
String get dockerNotInstalled => '未安裝 Docker';
@override
String dockerStatusRunningAndStoppedFmt(
@@ -935,7 +935,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
String get doubleColumnMode => '雙列模式';
@override
String get doubleColumnTip => '此選項僅開啟功能,實際是否能開啟還取決於設備的頻寬';
String get doubleColumnTip => '此選項僅用於啟用此功能,是否生效取決於裝置寬度';
@override
String get editVirtKeys => '編輯虛擬按鍵';
@@ -944,7 +944,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
String get editor => '編輯器';
@override
String get editorHighlightTip => '目前的程式碼標記效能較為糟糕,可選擇關閉以改善';
String get editorHighlightTip => '程式碼高亮功能可能影響效能,可選擇關閉。';
@override
String get emulator => '模擬器';
@@ -998,7 +998,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
String get fullScreenJitter => '全螢幕模式抖動';
@override
String get fullScreenJitterHelp => '防止烙印';
String get fullScreenJitterHelp => '防止螢幕烙印';
@override
String get fullScreenTip => '當設備旋轉為橫向時,是否開啟全螢幕模式?此選項僅適用於伺服器分頁。';
@@ -1023,7 +1023,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
@override
String httpFailedWithCode(Object code) {
return '請求失敗, 狀態碼: $code';
return '請求失敗狀態碼$code';
}
@override
@@ -1046,7 +1046,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
@override
String get installDockerWithUrl =>
'請先 https://docs.docker.com/engine/install docker';
'請先前往 https://docs.docker.com/engine/install 安裝 Docker';
@override
String get invalid => '無效';
@@ -1055,7 +1055,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
String get jumpServer => '跳板伺服器';
@override
String get keepForeground => '保持App處於前端!';
String get keepForeground => 'App 保持在前景執行';
@override
String get keepStatusWhenErr => '保留上次的伺服器狀態';
@@ -1096,7 +1096,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
String get maxRetryCount => '伺服器嘗試重連次數';
@override
String get maxRetryCountEqual0 => '無限重試';
String get maxRetryCountEqual0 => '無限重試';
@override
String get min => '最小';
@@ -1161,7 +1161,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
String get openLastPath => '打開上次的路徑';
@override
String get openLastPathTip => '不同的伺服器會有不同的記錄,且記錄的是退出時的路徑';
String get openLastPathTip => '將為每台伺服器紀錄其最後存取路徑';
@override
String get parseContainerStatsTip => 'Docker 解析消耗狀態較為緩慢';
@@ -1214,7 +1214,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
String get pveIgnoreCertTip => '不建議啟用,請注意安全風險!如果您使用的是 PVE 的預設憑證,則需要啟用此選項。';
@override
String get pveLoginFailed => '失敗。無法使用伺服器配置中的使用者名稱/密碼 Linux PAM 方式登錄';
String get pveLoginFailed => '失敗。無法使用伺服器設定中的使用者名稱密碼透過 Linux PAM 方式認證';
@override
String get pveVersionLow => '此功能目前處於測試階段,僅在 PVE 8+ 上進行過測試。請謹慎使用。';
@@ -1296,7 +1296,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
String get sftpRmrDirSummary => '在 SFTP 中使用 `rm -r` 來刪除檔案夾';
@override
String get sftpSSHConnected => 'SFTP 已連線...';
String get sftpSSHConnected => 'SFTP 已連線';
@override
String get sftpShowFoldersFirst => '資料夾顯示在前';
@@ -1327,7 +1327,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
@override
String spentTime(Object time) {
return '耗時: $time';
return '耗時$time';
}
@override
@@ -1435,7 +1435,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
String get update => '更新';
@override
String get updateIntervalEqual0 => '設定為 0,伺服器狀態不會自動更新。\n且不能計算CPU使用情況';
String get updateIntervalEqual0 => '設定為 0 將不自動刷新伺服器狀態,\n也無法計算 CPU 使用';
@override
String get updateServerStatusInterval => '伺服器狀態更新間隔';
@@ -1495,7 +1495,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
String get whenOpenApp => '當打開 App 時';
@override
String get wolTip => '在配置 WOL(網絡喚醒)後,每次連線伺服器都會先發送一次 WOL 請求';
String get wolTip => '設定 WOL 後,每次連線伺服器時將自動發送喚醒請求';
@override
String get write => '寫入';

View File

@@ -5,6 +5,7 @@ import 'package:server_box/data/model/server/custom.dart';
import 'package:server_box/data/model/server/private_key_info.dart';
import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/model/server/snippet.dart';
import 'package:server_box/data/model/server/system.dart';
import 'package:server_box/data/model/server/wol_cfg.dart';
import 'package:server_box/data/model/ssh/virtual_key.dart';
@@ -17,5 +18,6 @@ import 'package:server_box/data/model/ssh/virtual_key.dart';
AdapterSpec<ServerFuncBtn>(),
AdapterSpec<ServerCustom>(),
AdapterSpec<WakeOnLanCfg>(),
AdapterSpec<SystemType>(),
])
part 'hive_adapters.g.dart';

View File

@@ -111,13 +111,14 @@ class SpiAdapter extends TypeAdapter<Spi> {
wolCfg: fields[11] as WakeOnLanCfg?,
envs: (fields[12] as Map?)?.cast<String, String>(),
id: fields[13] == null ? '' : fields[13] as String,
customSystemType: fields[14] as SystemType?,
);
}
@override
void write(BinaryWriter writer, Spi obj) {
writer
..writeByte(14)
..writeByte(15)
..writeByte(0)
..write(obj.name)
..writeByte(1)
@@ -145,7 +146,9 @@ class SpiAdapter extends TypeAdapter<Spi> {
..writeByte(12)
..write(obj.envs)
..writeByte(13)
..write(obj.id);
..write(obj.id)
..writeByte(14)
..write(obj.customSystemType);
}
@override
@@ -557,3 +560,44 @@ class WakeOnLanCfgAdapter extends TypeAdapter<WakeOnLanCfg> {
runtimeType == other.runtimeType &&
typeId == other.typeId;
}
class SystemTypeAdapter extends TypeAdapter<SystemType> {
@override
final typeId = 9;
@override
SystemType read(BinaryReader reader) {
switch (reader.readByte()) {
case 0:
return SystemType.linux;
case 1:
return SystemType.bsd;
case 2:
return SystemType.windows;
default:
return SystemType.linux;
}
}
@override
void write(BinaryWriter writer, SystemType obj) {
switch (obj) {
case SystemType.linux:
writer.writeByte(0);
case SystemType.bsd:
writer.writeByte(1);
case SystemType.windows:
writer.writeByte(2);
}
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SystemTypeAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -1,7 +1,7 @@
# Generated by Hive CE
# Manual modifications may be necessary for certain migrations
# Check in to version control
nextTypeId: 9
nextTypeId: 10
types:
PrivateKeyInfo:
typeId: 1
@@ -27,7 +27,7 @@ types:
index: 4
Spi:
typeId: 3
nextIndex: 14
nextIndex: 15
fields:
name:
index: 0
@@ -57,6 +57,8 @@ types:
index: 12
id:
index: 13
customSystemType:
index: 14
VirtKey:
typeId: 4
nextIndex: 45
@@ -207,3 +209,13 @@ types:
index: 1
pwd:
index: 2
SystemType:
typeId: 9
nextIndex: 3
fields:
linux:
index: 0
bsd:
index: 1
windows:
index: 2

View File

@@ -13,6 +13,7 @@ extension HiveRegistrar on HiveInterface {
registerAdapter(ServerFuncBtnAdapter());
registerAdapter(SnippetAdapter());
registerAdapter(SpiAdapter());
registerAdapter(SystemTypeAdapter());
registerAdapter(VirtKeyAdapter());
registerAdapter(WakeOnLanCfgAdapter());
}
@@ -26,6 +27,7 @@ extension IsolatedHiveRegistrar on IsolatedHiveInterface {
registerAdapter(ServerFuncBtnAdapter());
registerAdapter(SnippetAdapter());
registerAdapter(SpiAdapter());
registerAdapter(SystemTypeAdapter());
registerAdapter(VirtKeyAdapter());
registerAdapter(WakeOnLanCfgAdapter());
}

View File

@@ -5,9 +5,7 @@ final class _IntroPage extends StatelessWidget {
const _IntroPage(this.pages);
static const _builders = {
1: _buildAppSettings,
};
static const _builders = {1: _buildAppSettings};
@override
Widget build(BuildContext context) {
@@ -20,9 +18,7 @@ final class _IntroPage extends StatelessWidget {
pages: pages_,
onDone: (ctx) {
Stores.setting.introVer.put(BuildData.build);
Navigator.of(ctx).pushReplacement(
MaterialPageRoute(builder: (_) => const HomePage()),
);
Navigator.of(ctx).pushReplacement(MaterialPageRoute(builder: (_) => const HomePage()));
},
),
);
@@ -52,17 +48,12 @@ final class _IntroPage extends StatelessWidget {
RNodes.app.notify();
}
},
trailing: Text(
ctx.localeNativeName,
style: const TextStyle(fontSize: 15, color: Colors.grey),
),
trailing: Text(ctx.localeNativeName, style: const TextStyle(fontSize: 15, color: Colors.grey)),
).cardx,
ListTile(
leading: const Icon(Icons.update),
title: Text(libL10n.checkUpdate),
subtitle: isAndroid
? Text(l10n.fdroidReleaseTip, style: UIs.textGrey)
: null,
subtitle: isAndroid ? Text(l10n.fdroidReleaseTip, style: UIs.textGrey) : null,
trailing: StoreSwitch(prop: _setting.autoCheckAppUpdate),
).cardx,
ListTile(
@@ -87,10 +78,7 @@ final class _IntroPage extends StatelessWidget {
static List<IntroPageBuilder> get builders {
final storedVer = _setting.introVer.fetch();
return _builders.entries
.where((e) => e.key > storedVer)
.map((e) => e.value)
.toList();
return _builders.entries.where((e) => e.key > storedVer).map((e) => e.value).toList();
}
static final _setting = Stores.setting;

View File

@@ -2,17 +2,17 @@
"@@locale": "zh",
"aboutThanks": "感谢以下参与的各位。",
"acceptBeta": "接受测试版更新推送",
"addSystemPrivateKeyTip": "当前没有任何私钥,是否添加系统自带的~/.ssh/id_rsa",
"addSystemPrivateKeyTip": "检测到暂无私钥,是否添加系统默认的私钥~/.ssh/id_rsa",
"added2List": "已添加至任务列表",
"addr": "地址",
"alreadyLastDir": "已经是最上层目录",
"authFailTip": "认证失败,请检查密码/密钥/主机/用户等是否错误",
"autoBackupConflict": "只能同时开启一个自动备份",
"alreadyLastDir": "已是顶级目录",
"authFailTip": "认证失败,请检查连接信息是否正确",
"autoBackupConflict": "仅可启用一个自动备份任务",
"autoConnect": "自动连接",
"autoRun": "自动运行",
"autoUpdateHomeWidget": "自动更新桌面小部件",
"backupTip": "导出数据可以使用密码加密,请妥善保管。",
"backupVersionNotMatch": "备份版本不匹配,无法恢复",
"backupTip": "导出数据可通过密码加密,请妥善保管。",
"backupVersionNotMatch": "备份版本不兼容,无法恢复",
"backupPassword": "备份密码",
"backupPasswordTip": "设置密码以加密备份文件。留空则禁用加密。",
"backupPasswordWrong": "备份密码错误",
@@ -22,7 +22,7 @@
"backupPasswordRemoved": "备份密码已移除",
"battery": "电池",
"bgRun": "后台运行",
"bgRunTip": "此开关只代表程序会尝试在后台运行,具体能否后台运行取决于是否开启了权限。原生 Android 请关闭本 App 的“电池优化”MIUI / HyperOS 请修改省电策略为“无限制”。",
"bgRunTip": "此开关只代表程序会尝试在后台运行,具体能否后台运行取决于是否开启了权限。原生 Android 请关闭本 App 的“电池优化”MIUI / HyperOS 请省电策略为“无限制”。",
"closeAfterSave": "保存后关闭",
"cmd": "命令",
"collapseUITip": "是否默认折叠 UI 中的长列表",
@@ -40,23 +40,23 @@
"decompress": "解压缩",
"deleteServers": "批量删除服务器",
"desktopTerminalTip": "启动 SSH 连接所用的终端模拟器命令",
"dirEmpty": "请确保文件夹为空",
"disconnected": "连接断开",
"dirEmpty": "请确保目录为空",
"disconnected": "已断开连接",
"disk": "磁盘",
"diskHealth": "磁盘健康",
"diskIgnorePath": "忽略的磁盘路径",
"displayCpuIndex": "显示 CPU 索引",
"dl2Local": "下载 {fileName} 到本地?",
"dockerEmptyRunningItems": "没有正在运行的容器。\n这可能是因为\n- Docker 安装用户与 App 内配置的用户名不同\n- 环境变量 DOCKER_HOST 没有被正确读取。可以通过在终端内运行 `echo $DOCKER_HOST` 来获取。",
"dockerImagesFmt": "{count} 个镜像",
"dockerNotInstalled": "Docker 未安装",
"dockerImagesFmt": "{count} 个镜像",
"dockerNotInstalled": "未安装 Docker",
"dockerStatusRunningAndStoppedFmt": "{runningCount} 个正在运行, {stoppedCount} 个已停止",
"dockerStatusRunningFmt": "{count} 个容器正在运行",
"doubleColumnMode": "双列模式",
"doubleColumnTip": "此选项仅开启功能,实际是否能开启还取决于设备宽度",
"doubleColumnTip": "此选项仅用于启用该功能,是否生效取决于设备宽度",
"editVirtKeys": "编辑虚拟按键",
"editor": "编辑器",
"editorHighlightTip": "目前的代码高亮性能较为糟糕,可选择关闭以改善。",
"editorHighlightTip": "代码高亮功能可能影响性能,可选择关闭。",
"emulator": "模拟器",
"encode": "编码",
"envVars": "环境变量",
@@ -73,7 +73,7 @@
"force": "强制",
"fullScreen": "全屏模式",
"fullScreenJitter": "全屏模式抖动",
"fullScreenJitterHelp": "防止烧屏",
"fullScreenJitterHelp": "用于防止屏幕烧屏",
"fullScreenTip": "当设备旋转为横屏时,是否开启全屏模式。此选项仅作用于服务器 Tab 页。",
"goBackQ": "返回?",
"goto": "前往",
@@ -81,17 +81,17 @@
"highlight": "代码高亮",
"homeWidgetUrlConfig": "桌面部件链接配置",
"host": "主机",
"httpFailedWithCode": "请求失败, 状态码: {code}",
"httpFailedWithCode": "请求失败状态码: {code}",
"ignoreCert": "忽略证书",
"image": "镜像",
"imagesList": "镜像列表",
"init": "初始化",
"inner": "内置",
"install": "安装",
"installDockerWithUrl": "请先 https://docs.docker.com/engine/install docker",
"installDockerWithUrl": "请先前往 https://docs.docker.com/engine/install 安装 Docker",
"invalid": "无效",
"jumpServer": "跳板服务器",
"keepForeground": "请保持应用处于前台!",
"keepForeground": "请将应用保持在前台运行",
"keepStatusWhenErr": "保留上次的服务器状态",
"keepStatusWhenErrTip": "仅限于执行脚本出错",
"keyAuth": "密钥认证",
@@ -104,7 +104,7 @@
"manual": "手动",
"max": "最大",
"maxRetryCount": "服务器尝试重连次数",
"maxRetryCountEqual0": "无限重试",
"maxRetryCountEqual0": "无限重试",
"min": "最小",
"mission": "任务",
"more": "更多",
@@ -125,7 +125,7 @@
"onlyOneLine": "仅显示为一行(可滚动)",
"onlyWhenCoreBiggerThan8": "仅当核心数大于 8 时生效",
"openLastPath": "打开上次的路径",
"openLastPathTip": "不同的服务器会有不同的记录,且记录的是退出时的路径",
"openLastPathTip": "将为每台服务器记录其最后访问路径",
"parseContainerStatsTip": "Docker 解析占用状态较为缓慢",
"percentOfSize": "{size} 的 {percent}%",
"permission": "权限",
@@ -142,7 +142,7 @@
"prune": "修剪",
"pushToken": "消息推送 Token",
"pveIgnoreCertTip": "不推荐开启,注意安全隐患!如果你使用的 PVE 默认证书,需要开启该选项",
"pveLoginFailed": "登录失败。无法使用服务器配置的用户/密码,以 Linux PAM 方式登录。",
"pveLoginFailed": "登录失败。无法使用服务器配置的用户名或密码通过 Linux PAM 方式认证。",
"pveVersionLow": "当前该功能处于测试阶段,仅在 PVE 8+ 上测试过,请谨慎使用",
"read": "读",
"reboot": "重启",
@@ -169,7 +169,7 @@
"sftpDlPrepare": "准备连接至服务器...",
"sftpEditorTip": "如果为空, 使用App内置的文件编辑器. 如果有值, 这是用远程服务器的编辑器, 例如 `vim` (建议根据 `EDITOR` 自动获取).",
"sftpRmrDirSummary": "在 SFTP 中使用 `rm -r` 来删除文件夹",
"sftpSSHConnected": "SFTP 已连接...",
"sftpSSHConnected": "SFTP 已连接",
"sftpShowFoldersFirst": "文件夹显示在前",
"showDistLogo": "显示发行版 Logo",
"shutdown": "关机",
@@ -179,7 +179,7 @@
"specifyDev": "指定设备",
"specifyDevTip": "例如网络流量统计默认是所有设备,你可以在这里指定特定的设备",
"speed": "速度",
"spentTime": "耗时: {time}",
"spentTime": "耗时{time}",
"sshTermHelp": "在终端可滚动时,横向拖动可以选中文字。点击键盘按钮可以开启/关闭键盘。文件图标会打开当前路径 SFTP。剪切板按钮会在有选中文字时复制内容在未选中并且剪切板有内容时粘贴内容到终端。代码图标会粘贴代码片段到终端并执行。",
"sshTip": "该功能目前处于测试阶段。\n\n请在 {url} 反馈问题,或者加入我们开发。",
"sshVirtualKeyAutoOff": "虚拟按键自动切换",
@@ -213,7 +213,7 @@
"unknown": "未知",
"unkownConvertMode": "未知转换模式",
"update": "更新",
"updateIntervalEqual0": "设置为 0,服务器状态不会自动刷新。\n且不能计算 CPU 使用情况。",
"updateIntervalEqual0": "设置为 0 将不自动刷新服务器状态。\n且无法计算 CPU 使用。",
"updateServerStatusInterval": "服务器状态刷新间隔",
"upload": "上传",
"upsideDown": "上下交换",
@@ -233,7 +233,7 @@
"watchNotPaired": "没有已配对的 Apple Watch",
"webdavSettingEmpty": "WebDav 设置项为空",
"whenOpenApp": "当打开 App 时",
"wolTip": "配置 WOL 后,每次连接服务器都会先发送一次 WOL 请求",
"wolTip": "配置 WOL 后,每次连接服务器时将自动发送唤醒请求",
"write": "写",
"writeScriptFailTip": "写入脚本失败,可能是没有权限/目录不存在等",
"writeScriptTip": "在连接服务器后,会向 `~/.config/server_box` \n | `/tmp/server_box` 写入脚本来监测系统状态,你可以审查脚本内容。"

View File

@@ -2,17 +2,17 @@
"@@locale": "zh_TW",
"aboutThanks": "感謝以下參與的各位。",
"acceptBeta": "接受測試版更新推送",
"addSystemPrivateKeyTip": "目前沒有任何私鑰,是否新增系統原有的 (~/.ssh/id_rsa)",
"addSystemPrivateKeyTip": "偵測到尚無私鑰,是否要加入系統預設的私鑰(~/.ssh/id_rsa",
"added2List": "已新增至任務清單",
"addr": "位址",
"alreadyLastDir": "已經是最上層目錄",
"authFailTip": "認證失敗,請檢查密碼/金鑰/主機/使用者等是否錯誤。",
"autoBackupConflict": "只能同時開啓一個自動備份",
"alreadyLastDir": "已是頂層目錄",
"authFailTip": "認證失敗,請檢查連線資訊是否正確",
"autoBackupConflict": "僅能啟用一項自動備份任務",
"autoConnect": "自動連線",
"autoRun": "自動執行",
"autoUpdateHomeWidget": "自動更新桌面小工具",
"backupTip": "匯出的資料可以使用密碼加密。 \n請妥善保管。",
"backupVersionNotMatch": "備份版本不相,無法還原",
"backupTip": "匯出的資料可透過密碼加密請妥善保管。",
"backupVersionNotMatch": "備份版本不相,無法還原",
"backupPassword": "備份密碼",
"backupPasswordTip": "設定密碼來加密備份檔案。留空則停用加密。",
"backupPasswordWrong": "備份密碼錯誤",
@@ -22,7 +22,7 @@
"backupPasswordRemoved": "備份密碼已移除",
"battery": "電池",
"bgRun": "背景執行",
"bgRunTip": "此開關代表程式會嘗試背景執行,具體能否在後臺執行取決於是否開啟了權限。 原生 Android 請關閉本 App 的“電池最佳化”,MIUI / HyperOS 請修改省電策略為“無限制。",
"bgRunTip": "此開關代表程式會嘗試背景執行,能否成功取決於系統權限。原生 Android 上,請關閉本應用的「電池最佳化」;在 MIUI / HyperOS 上,請將省電策略調整為「無限制。",
"closeAfterSave": "儲存後關閉",
"cmd": "指令",
"collapseUITip": "是否預設折疊 UI 中存在的長列表",
@@ -40,23 +40,23 @@
"decompress": "解壓縮",
"deleteServers": "大量刪除伺服器",
"desktopTerminalTip": "啟動 SSH 連線時用於打開終端機模擬器的指令。",
"dirEmpty": "請確保資料夾為空",
"disconnected": "連線中斷",
"dirEmpty": "請確保目錄為空",
"disconnected": "已中斷連線",
"disk": "磁碟",
"diskHealth": "磁碟健康",
"diskIgnorePath": "忽略的磁碟路徑",
"displayCpuIndex": "顯示 CPU 索引",
"dl2Local": "下載 {fileName} 到本地?",
"dockerEmptyRunningItems": "沒有正在執行的容器。\n這可能是因為\n- Docker 安裝使用者與 App 內配置的使用者名稱不同\n- 環境變數 DOCKER_HOST 沒有被正確讀取。你可以通過在終端機內執行 `echo $DOCKER_HOST` 來獲取。",
"dockerImagesFmt": "{count} 個映像檔",
"dockerNotInstalled": "Docker 未安裝",
"dockerImagesFmt": "{count} 個映像檔",
"dockerNotInstalled": "未安裝 Docker",
"dockerStatusRunningAndStoppedFmt": "{runningCount} 個正在執行, {stoppedCount} 個已停止",
"dockerStatusRunningFmt": "{count} 個容器正在執行",
"doubleColumnMode": "雙列模式",
"doubleColumnTip": "此選項僅開啟功能,實際是否能開啟還取決於設備的頻寬",
"doubleColumnTip": "此選項僅用於啟用此功能,是否生效取決於裝置寬度",
"editVirtKeys": "編輯虛擬按鍵",
"editor": "編輯器",
"editorHighlightTip": "目前的程式碼標記效能較為糟糕,可選擇關閉以改善。",
"editorHighlightTip": "程式碼高亮功能可能影響效能,可選擇關閉。",
"emulator": "模擬器",
"encode": "編碼",
"envVars": "環境變數",
@@ -73,7 +73,7 @@
"force": "強制",
"fullScreen": "全螢幕模式",
"fullScreenJitter": "全螢幕模式抖動",
"fullScreenJitterHelp": "防止烙印",
"fullScreenJitterHelp": "防止螢幕烙印",
"fullScreenTip": "當設備旋轉為橫向時,是否開啟全螢幕模式?此選項僅適用於伺服器分頁。",
"goBackQ": "返回?",
"goto": "前往",
@@ -81,17 +81,17 @@
"highlight": "程式碼標記",
"homeWidgetUrlConfig": "桌面小工具連結配置",
"host": "主機",
"httpFailedWithCode": "請求失敗, 狀態碼: {code}",
"httpFailedWithCode": "請求失敗狀態碼{code}",
"ignoreCert": "忽略憑證",
"image": "映像檔",
"imagesList": "映像檔列表",
"init": "初始化",
"inner": "內建",
"install": "安裝",
"installDockerWithUrl": "請先 https://docs.docker.com/engine/install docker",
"installDockerWithUrl": "請先前往 https://docs.docker.com/engine/install 安裝 Docker",
"invalid": "無效",
"jumpServer": "跳板伺服器",
"keepForeground": "請保持App處於前端!",
"keepForeground": "請App 保持在前景執行",
"keepStatusWhenErr": "保留上次的伺服器狀態",
"keepStatusWhenErrTip": "僅在執行腳本出錯時",
"keyAuth": "金鑰認證",
@@ -104,7 +104,7 @@
"manual": "手動",
"max": "最大",
"maxRetryCount": "伺服器嘗試重連次數",
"maxRetryCountEqual0": "無限重試",
"maxRetryCountEqual0": "無限重試",
"min": "最小",
"mission": "任務",
"more": "更多",
@@ -125,7 +125,7 @@
"onlyOneLine": "僅顯示為一行(可捲動)",
"onlyWhenCoreBiggerThan8": "僅當核心數大於 8 時生效",
"openLastPath": "打開上次的路徑",
"openLastPathTip": "不同的伺服器會有不同的記錄,且記錄的是退出時的路徑",
"openLastPathTip": "將為每台伺服器紀錄其最後存取路徑",
"parseContainerStatsTip": "Docker 解析消耗狀態較為緩慢",
"percentOfSize": "{size} 的 {percent}%",
"permission": "權限",
@@ -142,7 +142,7 @@
"prune": "修剪",
"pushToken": "消息推送 Token",
"pveIgnoreCertTip": "不建議啟用,請注意安全風險!如果您使用的是 PVE 的預設憑證,則需要啟用此選項。",
"pveLoginFailed": "登失敗。無法使用伺服器配置中的使用者名稱/密碼 Linux PAM 方式登錄。",
"pveLoginFailed": "登失敗。無法使用伺服器設定中的使用者名稱密碼透過 Linux PAM 方式認證。",
"pveVersionLow": "此功能目前處於測試階段,僅在 PVE 8+ 上進行過測試。請謹慎使用。",
"read": "讀取",
"reboot": "重開",
@@ -169,7 +169,7 @@
"sftpDlPrepare": "準備連線至伺服器...",
"sftpEditorTip": "如果為空, 使用App內建的檔案編輯器。如果有值, 則使用遠端伺服器的編輯器, 例如 `vim`(建議根據 `EDITOR` 自動獲取)。",
"sftpRmrDirSummary": "在 SFTP 中使用 `rm -r` 來刪除檔案夾",
"sftpSSHConnected": "SFTP 已連線...",
"sftpSSHConnected": "SFTP 已連線",
"sftpShowFoldersFirst": "資料夾顯示在前",
"showDistLogo": "顯示發行版 Logo",
"shutdown": "關機",
@@ -179,7 +179,7 @@
"specifyDev": "指定裝置",
"specifyDevTip": "例如網路流量統計預設是所有裝置,你可以在這裡指定特定的裝置。",
"speed": "速度",
"spentTime": "耗時: {time}",
"spentTime": "耗時{time}",
"sshTermHelp": "在終端機可捲動時,橫向拖動可以選中文字。點擊鍵盤按鈕可以開啟/關閉鍵盤。檔案圖示會打開目前路徑 SFTP。剪貼簿按鈕會在有選中文字時複製內容在未選中並且剪貼簿有內容時貼上內容到終端機。程式碼圖示會貼上程式碼片段到終端機並執行。",
"sshTip": "該功能目前處於測試階段。\n\n請在 {url} 回饋問題,或者加入我們開發。",
"sshVirtualKeyAutoOff": "虛擬按鍵自動切換",
@@ -213,7 +213,7 @@
"unknown": "未知",
"unkownConvertMode": "未知轉換模式",
"update": "更新",
"updateIntervalEqual0": "設定為 0,伺服器狀態不會自動更新。\n且不能計算CPU使用情況。",
"updateIntervalEqual0": "設定為 0 將不自動刷新伺服器狀態,\n也無法計算 CPU 使用。",
"updateServerStatusInterval": "伺服器狀態更新間隔",
"upload": "上傳",
"upsideDown": "上下交換",
@@ -233,7 +233,7 @@
"watchNotPaired": "沒有已配對的 Apple Watch",
"webdavSettingEmpty": "WebDav 設定項爲空",
"whenOpenApp": "當打開 App 時",
"wolTip": "在配置 WOL(網絡喚醒)後,每次連線伺服器都會先發送一次 WOL 請求",
"wolTip": "設定 WOL 後,每次連線伺服器時將自動發送喚醒請求",
"write": "寫入",
"writeScriptFailTip": "寫入腳本失敗,可能是沒有權限/目錄不存在等。",
"writeScriptTip": "連線到伺服器後,將會在 `~/.config/server_box` \n | `/tmp/server_box` 中寫入一個腳本來監測系統狀態。你可以審查腳本內容。"

View File

@@ -78,7 +78,7 @@ final class _BackupPageState extends State<BackupPage> with AutomaticKeepAliveCl
ListTile(
title: Text(libL10n.backup),
trailing: const Icon(Icons.save),
onTap: () => BackupService.backup(context, FileBackupSource())
onTap: () => BackupService.backup(context, FileBackupSource()),
),
ListTile(
trailing: const Icon(Icons.restore),
@@ -264,7 +264,6 @@ final class _BackupPageState extends State<BackupPage> with AutomaticKeepAliveCl
).cardx;
}
Future<void> _onTapWebdavDl(BuildContext context) async {
webdavLoading.value = true;
try {
@@ -357,7 +356,6 @@ final class _BackupPageState extends State<BackupPage> with AutomaticKeepAliveCl
}
}
void _onBulkImportServers(BuildContext context) async {
final data = await context.showImportDialog(title: l10n.server, modelDef: Spix.example.toJson());
if (data == null) return;
@@ -394,11 +392,6 @@ final class _BackupPageState extends State<BackupPage> with AutomaticKeepAliveCl
}
}
@override
bool get wantKeepAlive => true;
}

View File

@@ -17,10 +17,7 @@ class HomePage extends StatefulWidget {
@override
State<HomePage> createState() => _HomePageState();
static const route = AppRouteNoArg(
page: HomePage.new,
path: '/',
);
static const route = AppRouteNoArg(page: HomePage.new, path: '/');
}
class _HomePageState extends State<HomePage>
@@ -181,11 +178,7 @@ class _HomePageState extends State<HomePage>
//_reqNotiPerm();
if (Stores.setting.autoCheckAppUpdate.fetch()) {
AppUpdateIface.doUpdate(
build: BuildData.build,
url: Urls.updateCfg,
context: context,
);
AppUpdateIface.doUpdate(build: BuildData.build, url: Urls.updateCfg, context: context);
}
MethodChans.updateHomeWidget();
await ServerProvider.refresh();
@@ -216,10 +209,7 @@ class _HomePageState extends State<HomePage>
void _goAuth() {
if (Stores.setting.useBioAuth.fetch()) {
if (LocalAuthPage.route.alreadyIn) return;
LocalAuthPage.route.go(
context,
args: LocalAuthPageArgs(onAuthSuccess: () => _shouldAuth = false),
);
LocalAuthPage.route.go(context, args: LocalAuthPageArgs(onAuthSuccess: () => _shouldAuth = false));
}
}
@@ -245,9 +235,7 @@ final class _AppBar extends StatelessWidget implements PreferredSizeWidget {
@override
Widget build(BuildContext context) {
return SizedBox(
height: preferredSize.height,
);
return SizedBox(height: preferredSize.height);
}
@override

View File

@@ -4,7 +4,6 @@ import 'package:server_box/core/extension/context/locale.dart';
import 'package:server_box/core/route.dart';
import 'package:server_box/view/page/ssh/page/page.dart';
class IPerfPage extends StatefulWidget {
final SpiRequiredArgs args;
@@ -13,10 +12,7 @@ class IPerfPage extends StatefulWidget {
@override
State<IPerfPage> createState() => _IPerfPageState();
static const route = AppRouteArg<void, SpiRequiredArgs>(
page: IPerfPage.new,
path: '/iperf',
);
static const route = AppRouteArg<void, SpiRequiredArgs>(page: IPerfPage.new, path: '/iperf');
}
class _IPerfPageState extends State<IPerfPage> {
@@ -33,9 +29,7 @@ class _IPerfPageState extends State<IPerfPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: CustomAppBar(
title: const Text('iperf'),
),
appBar: CustomAppBar(title: const Text('iperf')),
body: _buildBody(),
floatingActionButton: _buildFAB(),
);
@@ -63,12 +57,7 @@ class _IPerfPageState extends State<IPerfPage> {
return ListView(
padding: const EdgeInsets.symmetric(horizontal: 17),
children: [
Input(
controller: _hostCtrl,
label: l10n.host,
icon: Icons.computer,
suggestion: false,
),
Input(controller: _hostCtrl, label: l10n.host, icon: Icons.computer, suggestion: false),
Input(
controller: _portCtrl,
label: l10n.port,

View File

@@ -24,10 +24,7 @@ class PrivateKeyEditPage extends StatefulWidget {
@override
State<PrivateKeyEditPage> createState() => _PrivateKeyEditPageState();
static const route = AppRoute(
page: PrivateKeyEditPage.new,
path: '/private_key/edit',
);
static const route = AppRoute(page: PrivateKeyEditPage.new, path: '/private_key/edit');
}
class _PrivateKeyEditPageState extends State<PrivateKeyEditPage> {
@@ -82,11 +79,7 @@ class _PrivateKeyEditPageState extends State<PrivateKeyEditPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: _buildAppBar(),
body: _buildBody(),
floatingActionButton: _buildFAB(),
);
return Scaffold(appBar: _buildAppBar(), body: _buildBody(), floatingActionButton: _buildFAB());
}
CustomAppBar _buildAppBar() {
@@ -98,9 +91,7 @@ class _PrivateKeyEditPageState extends State<PrivateKeyEditPage> {
onPressed: () {
context.showRoundDialog(
title: libL10n.attention,
child: Text(libL10n.askContinue(
'${libL10n.delete} ${l10n.privateKey}(${pki.id})',
)),
child: Text(libL10n.askContinue('${libL10n.delete} ${l10n.privateKey}(${pki.id})')),
actions: Btn.ok(
onTap: () {
PrivateKeyProvider.delete(pki);
@@ -112,13 +103,10 @@ class _PrivateKeyEditPageState extends State<PrivateKeyEditPage> {
);
},
icon: const Icon(Icons.delete),
)
),
]
: null;
return CustomAppBar(
title: Text(libL10n.edit),
actions: actions,
);
return CustomAppBar(title: Text(libL10n.edit), actions: actions);
}
String _standardizeLineSeparators(String value) {
@@ -126,11 +114,7 @@ class _PrivateKeyEditPageState extends State<PrivateKeyEditPage> {
}
Widget _buildFAB() {
return FloatingActionButton(
tooltip: l10n.save,
onPressed: _onTapSave,
child: const Icon(Icons.save),
);
return FloatingActionButton(tooltip: l10n.save, onPressed: _onTapSave, child: const Icon(Icons.save));
}
Widget _buildBody() {
@@ -170,11 +154,7 @@ class _PrivateKeyEditPageState extends State<PrivateKeyEditPage> {
final size = (await file.stat()).size;
if (size > Miscs.privateKeyMaxSize) {
context.showSnackBar(
l10n.fileTooLarge(
path,
size.bytes2Str,
Miscs.privateKeyMaxSize.bytes2Str,
),
l10n.fileTooLarge(path, size.bytes2Str, Miscs.privateKeyMaxSize.bytes2Str),
);
return;
}
@@ -196,10 +176,7 @@ class _PrivateKeyEditPageState extends State<PrivateKeyEditPage> {
onSubmitted: (_) => _onTapSave(),
),
SizedBox(height: MediaQuery.of(context).size.height * 0.1),
ValBuilder(
listenable: _loading,
builder: (val) => val ?? UIs.placeholder,
),
ValBuilder(listenable: _loading, builder: (val) => val ?? UIs.placeholder),
],
);
}

View File

@@ -15,10 +15,7 @@ class PrivateKeysListPage extends StatefulWidget {
@override
State<PrivateKeysListPage> createState() => _PrivateKeyListState();
static const route = AppRouteNoArg(
page: PrivateKeysListPage.new,
path: '/private_key',
);
static const route = AppRouteNoArg(page: PrivateKeysListPage.new, path: '/private_key');
}
class _PrivateKeyListState extends State<PrivateKeysListPage> with AfterLayoutMixin {
@@ -34,26 +31,21 @@ class _PrivateKeyListState extends State<PrivateKeysListPage> with AfterLayoutMi
}
Widget _buildBody() {
return PrivateKeyProvider.pkis.listenVal(
(pkis) {
return PrivateKeyProvider.pkis.listenVal((pkis) {
if (pkis.isEmpty) {
return Center(child: Text(libL10n.empty));
}
final children = pkis.map(_buildKeyItem).toList();
return AutoMultiList(children: children);
},
);
});
}
Widget _buildKeyItem(PrivateKeyInfo item) {
return ListTile(
title: Text(item.id),
subtitle: Text(item.type ?? l10n.unknown, style: UIs.textGrey),
onTap: () => PrivateKeyEditPage.route.go(
context,
args: PrivateKeyEditPageArgs(pki: item),
),
onTap: () => PrivateKeyEditPage.route.go(context, args: PrivateKeyEditPageArgs(pki: item)),
trailing: const Icon(Icons.edit),
).cardx;
}
@@ -72,20 +64,16 @@ extension on _PrivateKeyListState {
if (home == null) return;
final idRsaFile = File(home.joinPath('.ssh/id_rsa'));
if (!idRsaFile.existsSync()) return;
final sysPk = PrivateKeyInfo(
id: 'system',
key: await idRsaFile.readAsString(),
);
final sysPk = PrivateKeyInfo(id: 'system', key: await idRsaFile.readAsString());
context.showRoundDialog(
title: libL10n.attention,
child: Text(l10n.addSystemPrivateKeyTip),
actions: Btn.ok(onTap: () {
actions: Btn.ok(
onTap: () {
context.pop();
PrivateKeyEditPage.route.go(
context,
args: PrivateKeyEditPageArgs(pki: sysPk),
);
}).toList,
PrivateKeyEditPage.route.go(context, args: PrivateKeyEditPageArgs(pki: sysPk));
},
).toList,
);
}
}

View File

@@ -18,10 +18,7 @@ class ProcessPage extends StatefulWidget {
@override
State<ProcessPage> createState() => _ProcessPageState();
static const route = AppRouteArg(
page: ProcessPage.new,
path: '/process',
);
static const route = AppRouteArg(page: ProcessPage.new, path: '/process');
}
class _ProcessPageState extends State<ProcessPage> {
@@ -49,8 +46,7 @@ class _ProcessPageState extends State<ProcessPage> {
void initState() {
super.initState();
_client = widget.args.spi.server?.value.client;
final duration =
Duration(seconds: Stores.setting.serverStatusUpdateInterval.fetch());
final duration = Duration(seconds: Stores.setting.serverStatusUpdateInterval.fetch());
_timer = Timer.periodic(duration, (_) => _refresh());
}
@@ -62,8 +58,10 @@ class _ProcessPageState extends State<ProcessPage> {
Future<void> _refresh() async {
if (mounted) {
final result =
await _client?.run(ShellFunc.process.exec(widget.args.spi.id)).string;
final systemType = widget.args.spi.server?.value.status.system;
final result = await _client
?.run(ShellFunc.process.exec(widget.args.spi.id, systemType: systemType))
.string;
if (result == null || result.isEmpty) {
context.showSnackBar(libL10n.empty);
return;
@@ -72,8 +70,7 @@ class _ProcessPageState extends State<ProcessPage> {
// If there are any [Proc]'s data is not complete,
// the option to sort by cpu/mem will not be available.
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);
_sortModes.removeWhere((e) => e == ProcSortMode.mem);
@@ -97,25 +94,20 @@ class _ProcessPageState extends State<ProcessPage> {
},
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) {
actions.add(IconButton(
actions.add(
IconButton(
icon: const Icon(Icons.error),
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))],
),
],
),
));
);
}
Widget child;
if (_result.procs.isEmpty) {
@@ -144,32 +136,26 @@ class _ProcessPageState extends State<ProcessPage> {
return CardX(
key: ValueKey(proc.pid),
child: ListTile(
leading: SizedBox(
width: _media.size.width / 6,
child: leading,
),
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),
onTap: () => _lastFocusId = proc.pid,
onLongPress: () {
context.showRoundDialog(
title: libL10n.attention,
child: Text(libL10n.askContinue(
'${l10n.stop} ${l10n.process}(${proc.pid})',
)),
actions: Btn.ok(onTap: () async {
child: Text(libL10n.askContinue('${l10n.stop} ${l10n.process}(${proc.pid})')),
actions: Btn.ok(
onTap: () async {
context.pop();
await context.showLoadingDialog(fn: () async {
await context.showLoadingDialog(
fn: () async {
await _client?.run('kill ${proc.pid}');
await _refresh();
});
}).toList,
},
);
},
).toList,
);
},
selected: _lastFocusId == proc.pid,
@@ -185,17 +171,9 @@ class _ProcessPageState extends State<ProcessPage> {
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'),
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'),
],
);
}

View File

@@ -18,18 +18,12 @@ final class PvePageArgs {
final class PvePage extends StatefulWidget {
final PvePageArgs args;
const PvePage({
super.key,
required this.args,
});
const PvePage({super.key, required this.args});
@override
State<PvePage> createState() => _PvePageState();
static const route = AppRouteArg<void, PvePageArgs>(
page: PvePage.new,
path: '/pve',
);
static const route = AppRouteArg<void, PvePageArgs>(page: PvePage.new, path: '/pve');
}
const _kHorziPadding = 11.0;
@@ -87,9 +81,7 @@ final class _PvePageState extends State<PvePage> {
_timer?.cancel();
return Padding(
padding: const EdgeInsets.all(13),
child: Center(
child: Text(val),
),
child: Center(child: Text(val)),
);
}
return ValBuilder(
@@ -110,10 +102,7 @@ final class _PvePageState extends State<PvePage> {
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];
@@ -135,10 +124,7 @@ final class _PvePageState extends State<PvePage> {
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,
),
),
@@ -183,18 +169,11 @@ final class _PvePageState extends State<PvePage> {
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: [
@@ -202,18 +181,11 @@ final class _PvePageState extends State<PvePage> {
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;
@@ -232,14 +204,8 @@ final class _PvePageState extends State<PvePage> {
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
const SizedBox(width: 15),
Text(
_wrapNodeName(item),
style: UIs.text13Bold,
),
Text(
' / ${item.summary}',
style: UIs.text12Grey,
),
Text(_wrapNodeName(item), style: UIs.text13Bold),
Text(' / ${item.summary}', style: UIs.text12Grey),
const Spacer(),
_buildCtrlBtns(item),
UIs.width13,
@@ -266,34 +232,23 @@ final class _PvePageState extends State<PvePage> {
'${l10n.write}:\n${item.diskwrite.bytes2Str}',
style: UIs.text11Grey,
textAlign: TextAlign.center,
)
),
],
),
Column(
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),
],
),
],
),
const SizedBox(height: 21)
const SizedBox(height: 21),
];
return Column(
mainAxisSize: MainAxisSize.min,
children: children,
).cardx;
return Column(mainAxisSize: MainAxisSize.min, children: children).cardx;
}
Widget _buildLxc(PveLxc item) {
@@ -309,14 +264,8 @@ final class _PvePageState extends State<PvePage> {
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
const SizedBox(width: 15),
Text(
_wrapNodeName(item),
style: UIs.text13Bold,
),
Text(
' / ${item.summary}',
style: UIs.text12Grey,
),
Text(_wrapNodeName(item), style: UIs.text13Bold),
Text(' / ${item.summary}', style: UIs.text12Grey),
const Spacer(),
_buildCtrlBtns(item),
UIs.width13,
@@ -343,34 +292,23 @@ final class _PvePageState extends State<PvePage> {
'${l10n.write}:\n${item.diskwrite.bytes2Str}',
style: UIs.text11Grey,
textAlign: TextAlign.center,
)
),
],
),
Column(
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),
],
),
],
),
const SizedBox(height: 21)
const SizedBox(height: 21),
];
return Column(
mainAxisSize: MainAxisSize.min,
children: children,
).cardx;
return Column(mainAxisSize: MainAxisSize.min, children: children).cardx;
}
Widget _buildStorage(PveStorage item) {
@@ -396,10 +334,7 @@ final class _PvePageState extends State<PvePage> {
}
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) {
@@ -407,22 +342,26 @@ final class _PvePageState extends State<PvePage> {
if (!item.available) {
return Btn.icon(
icon: const Icon(Icons.play_arrow, color: Colors.grey),
onTap: () => _onCtrl(_pve.start, l10n.start, item));
onTap: () => _onCtrl(_pve.start, l10n.start, item),
);
}
return Row(
children: [
Btn.icon(
icon: const Icon(Icons.stop, color: Colors.grey, size: 20),
padding: pad,
onTap: () => _onCtrl(_pve.stop, l10n.stop, item)),
onTap: () => _onCtrl(_pve.stop, l10n.stop, item),
),
Btn.icon(
icon: const Icon(Icons.refresh, color: Colors.grey, size: 20),
padding: pad,
onTap: () => _onCtrl(_pve.reboot, l10n.reboot, item)),
onTap: () => _onCtrl(_pve.reboot, l10n.reboot, item),
),
Btn.icon(
icon: const Icon(Icons.power_off, color: Colors.grey, size: 20),
padding: pad,
onTap: () => _onCtrl(_pve.shutdown, l10n.shutdown, item)),
onTap: () => _onCtrl(_pve.shutdown, l10n.shutdown, item),
),
],
);
}
@@ -437,9 +376,7 @@ extension on _PvePageState {
);
if (sure != true) return;
final (suc, err) = await context.showLoadingDialog(
fn: () => func(item.node, item.id),
);
final (suc, err) = await context.showLoadingDialog(fn: () => func(item.node, item.id));
if (suc == true) {
context.showSnackBar(libL10n.success);
} else {

View File

@@ -9,6 +9,7 @@ import 'package:server_box/core/route.dart';
import 'package:server_box/data/model/server/custom.dart';
import 'package:server_box/data/model/server/server.dart';
import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/model/server/system.dart';
import 'package:server_box/data/model/server/wol_cfg.dart';
import 'package:server_box/data/provider/private_key.dart';
import 'package:server_box/data/provider/server.dart';
@@ -59,6 +60,7 @@ class _ServerEditPageState extends State<ServerEditPage> with AfterLayoutMixin {
final _env = <String, String>{}.vn;
final _customCmds = <String, String>{}.vn;
final _tags = <String>{}.vn;
final _systemType = ValueNotifier<SystemType?>(null);
@override
void dispose() {
@@ -91,6 +93,7 @@ class _ServerEditPageState extends State<ServerEditPage> with AfterLayoutMixin {
_env.dispose();
_customCmds.dispose();
_tags.dispose();
_systemType.dispose();
}
@override
@@ -174,6 +177,7 @@ class _ServerEditPageState extends State<ServerEditPage> with AfterLayoutMixin {
),
),
_buildAuth(),
_buildSystemType(),
_buildJumpServer(),
_buildMore(),
];
@@ -331,6 +335,26 @@ class _ServerEditPageState extends State<ServerEditPage> with AfterLayoutMixin {
);
}
Widget _buildSystemType() {
return _systemType.listenVal((val) {
return ListTile(
leading: Icon(MingCute.laptop_2_line),
title: Text(l10n.system),
trailing: PopupMenu<SystemType?>(
initialValue: val,
items: [
PopupMenuItem(value: null, child: Text(libL10n.auto)),
PopupMenuItem(value: SystemType.linux, child: Text('Linux')),
PopupMenuItem(value: SystemType.bsd, child: Text('BSD')),
PopupMenuItem(value: SystemType.windows, child: Text('Windows')),
],
onSelected: (value) => _systemType.value = value,
child: Text(val?.name ?? libL10n.auto, style: TextStyle(color: val == null ? Colors.grey : null)),
),
).cardx;
});
}
Widget _buildAltUrl() {
return Input(
controller: _altUrlController,
@@ -614,6 +638,7 @@ extension on _ServerEditPageState {
wolCfg: wol,
envs: _env.value.isEmpty ? null : _env.value,
id: widget.args?.spi.id ?? ShortId.generate(),
customSystemType: _systemType.value,
);
if (this.spi == null) {
@@ -668,5 +693,7 @@ extension on _ServerEditPageState {
_netDevCtrl.text = spi.custom?.netDev ?? '';
_scriptDirCtrl.text = spi.custom?.scriptDir ?? '';
_systemType.value = spi.customSystemType;
}
}

View File

@@ -7,21 +7,9 @@ class _CardStatus {
final bool? diskIO;
final NetViewType? net;
const _CardStatus({
this.flip = false,
this.diskIO,
this.net,
});
const _CardStatus({this.flip = false, this.diskIO, this.net});
_CardStatus copyWith({
bool? flip,
bool? diskIO,
NetViewType? net,
}) {
return _CardStatus(
flip: flip ?? this.flip,
diskIO: diskIO ?? this.diskIO,
net: net ?? this.net,
);
_CardStatus copyWith({bool? flip, bool? diskIO, NetViewType? net}) {
return _CardStatus(flip: flip ?? this.flip, diskIO: diskIO ?? this.diskIO, net: net ?? this.net);
}
}

View File

@@ -319,8 +319,7 @@ class _ServerPageState extends State<ServerPage> with AutomaticKeepAliveClientMi
],
),
UIs.height13,
if (Stores.setting.moveServerFuncs.fetch())
SizedBox(height: 27, child: ServerFuncBtns(spi: spi)),
if (Stores.setting.moveServerFuncs.fetch()) SizedBox(height: 27, child: ServerFuncBtns(spi: spi)),
],
);
},

View File

@@ -5,11 +5,7 @@ final class _TopBar extends StatelessWidget implements PreferredSizeWidget {
final void Function(String) onTagChanged;
final String initTag;
const _TopBar({
required this.initTag,
required this.onTagChanged,
required this.tags,
});
const _TopBar({required this.initTag, required this.onTagChanged, required this.tags});
@override
Widget build(BuildContext context) {
@@ -31,15 +27,9 @@ final class _TopBar extends StatelessWidget implements PreferredSizeWidget {
padding: EdgeInsets.symmetric(horizontal: 7, vertical: 3),
child: Row(
children: [
Text(
BuildData.name,
style: TextStyle(fontSize: 19),
),
Text(BuildData.name, style: TextStyle(fontSize: 19)),
SizedBox(width: 5),
Icon(
Icons.settings,
size: 17,
),
Icon(Icons.settings, size: 17),
],
),
),

View File

@@ -49,7 +49,11 @@ extension _Operation on _ServerPageState {
await context.showRoundDialog(title: libL10n.attention, child: Text(l10n.suspendTip));
Stores.setting.showSuspendTip.put(false);
}
srv.client?.execWithPwd(ShellFunc.suspend.exec(srv.spi.id), context: context, id: srv.id);
srv.client?.execWithPwd(
ShellFunc.suspend.exec(srv.spi.id, systemType: srv.status.system),
context: context,
id: srv.id,
);
},
typ: l10n.suspend,
name: srv.spi.name,
@@ -58,7 +62,11 @@ extension _Operation on _ServerPageState {
void _onTapShutdown(Server srv) {
_askFor(
func: () => srv.client?.execWithPwd(ShellFunc.shutdown.exec(srv.spi.id), context: context, id: srv.id),
func: () => srv.client?.execWithPwd(
ShellFunc.shutdown.exec(srv.spi.id, systemType: srv.status.system),
context: context,
id: srv.id,
),
typ: l10n.shutdown,
name: srv.spi.name,
);
@@ -66,7 +74,11 @@ extension _Operation on _ServerPageState {
void _onTapReboot(Server srv) {
_askFor(
func: () => srv.client?.execWithPwd(ShellFunc.reboot.exec(srv.spi.id), context: context, id: srv.id),
func: () => srv.client?.execWithPwd(
ShellFunc.reboot.exec(srv.spi.id, systemType: srv.status.system),
context: context,
id: srv.id,
),
typ: l10n.reboot,
name: srv.spi.name,
);

View File

@@ -7,8 +7,7 @@ final class _AppAboutPage extends StatefulWidget {
State<_AppAboutPage> createState() => _AppAboutPageState();
}
final class _AppAboutPageState extends State<_AppAboutPage>
with AutomaticKeepAliveClientMixin {
final class _AppAboutPageState extends State<_AppAboutPage> with AutomaticKeepAliveClientMixin {
@override
Widget build(BuildContext context) {
super.build(context);
@@ -16,15 +15,8 @@ final class _AppAboutPageState extends State<_AppAboutPage>
padding: const EdgeInsets.all(13),
children: [
UIs.height13,
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 47, maxWidth: 47),
child: UIs.appIcon,
),
const Text(
'${BuildData.name}\nv${BuildData.build}',
textAlign: TextAlign.center,
style: UIs.text15,
),
ConstrainedBox(constraints: const BoxConstraints(maxHeight: 47, maxWidth: 47), child: UIs.appIcon),
const Text('${BuildData.name}\nv${BuildData.build}', textAlign: TextAlign.center, style: UIs.text15),
UIs.height13,
SizedBox(
height: 77,
@@ -52,7 +44,8 @@ final class _AppAboutPageState extends State<_AppAboutPage>
),
UIs.height13,
SimpleMarkdown(
data: '''
data:
'''
#### Contributors
${GithubIds.contributors.map((e) => '[$e](${e.url})').join(' ')}

View File

@@ -10,10 +10,7 @@ class ServerDetailOrderPage extends StatefulWidget {
@override
State<ServerDetailOrderPage> createState() => _ServerDetailOrderPageState();
static const route = AppRouteNoArg(
page: ServerDetailOrderPage.new,
path: '/settings/order/server_detail',
);
static const route = AppRouteNoArg(page: ServerDetailOrderPage.new, path: '/settings/order/server_detail');
}
class _ServerDetailOrderPageState extends State<ServerDetailOrderPage> {
@@ -31,8 +28,7 @@ class _ServerDetailOrderPageState extends State<ServerDetailOrderPage> {
return ValBuilder(
listenable: prop.listenable(),
builder: (keys) {
final disabled =
ServerDetailCards.names.where((e) => !keys.contains(e)).toList();
final disabled = ServerDetailCards.names.where((e) => !keys.contains(e)).toList();
final allKeys = [...keys, ...disabled];
return ReorderableListView.builder(
padding: const EdgeInsets.all(7),

View File

@@ -10,10 +10,7 @@ class ServerFuncBtnsOrderPage extends StatefulWidget {
@override
State<ServerFuncBtnsOrderPage> createState() => _ServerDetailOrderPageState();
static const route = AppRouteNoArg(
page: ServerFuncBtnsOrderPage.new,
path: '/setting/seq/srv_func',
);
static const route = AppRouteNoArg(page: ServerFuncBtnsOrderPage.new, path: '/setting/seq/srv_func');
}
class _ServerDetailOrderPageState extends State<ServerFuncBtnsOrderPage> {
@@ -67,12 +64,7 @@ class _ServerDetailOrderPageState extends State<ServerFuncBtnsOrderPage> {
);
}
Widget _buildCheckBox(
List<int> keys,
int key,
int idx,
bool value,
) {
Widget _buildCheckBox(List<int> keys, int key, int idx, bool value) {
return Checkbox(
value: value,
onChanged: (val) {

View File

@@ -12,10 +12,7 @@ class ServerOrderPage extends StatefulWidget {
@override
State<ServerOrderPage> createState() => _ServerOrderPageState();
static const route = AppRouteNoArg(
page: ServerOrderPage.new,
path: '/settings/order/server',
);
static const route = AppRouteNoArg(page: ServerOrderPage.new, path: '/settings/order/server');
}
class _ServerOrderPageState extends State<ServerOrderPage> {
@@ -36,10 +33,7 @@ class _ServerOrderPageState extends State<ServerOrderPage> {
final double scale = lerpDouble(1, 1.02, animValue)!;
return Transform.scale(
scale: scale,
child: Card(
elevation: elevation,
child: child,
),
child: Card(elevation: elevation, child: child),
);
},
child: _buildCardTile(index),
@@ -56,11 +50,7 @@ class _ServerOrderPageState extends State<ServerOrderPage> {
footer: const SizedBox(height: 77),
onReorder: (oldIndex, newIndex) {
setState(() {
orders.value.move(
oldIndex,
newIndex,
property: Stores.setting.serverOrder,
);
orders.value.move(oldIndex, newIndex, property: Stores.setting.serverOrder);
});
},
padding: const EdgeInsets.all(8),
@@ -78,9 +68,7 @@ class _ServerOrderPageState extends State<ServerOrderPage> {
index: index,
child: Padding(
padding: const EdgeInsets.only(bottom: 8),
child: CardX(
child: _buildCardTile(index),
),
child: CardX(child: _buildCardTile(index)),
),
);
}
@@ -93,20 +81,14 @@ class _ServerOrderPageState extends State<ServerOrderPage> {
}
return ListTile(
title: Text(
spi.name,
style: const TextStyle(fontWeight: FontWeight.w500),
),
title: Text(spi.name, style: const TextStyle(fontWeight: FontWeight.w500)),
subtitle: Text(spi.oldId, style: UIs.textGrey),
leading: CircleAvatar(
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.onPrimary,
child: Text(spi.name[0]),
),
trailing: ReorderableDragStartListener(
index: index,
child: const Icon(Icons.drag_handle),
),
trailing: ReorderableDragStartListener(index: index, child: const Icon(Icons.drag_handle)),
);
}
}

View File

@@ -19,10 +19,7 @@ class SnippetEditPage extends StatefulWidget {
@override
State<SnippetEditPage> createState() => _SnippetEditPageState();
static const route = AppRoute(
page: SnippetEditPage.new,
path: '/snippets/edit',
);
static const route = AppRoute(page: SnippetEditPage.new, path: '/snippets/edit');
}
class _SnippetEditPageState extends State<SnippetEditPage> with AfterLayoutMixin {
@@ -47,10 +44,7 @@ class _SnippetEditPageState extends State<SnippetEditPage> with AfterLayoutMixin
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: CustomAppBar(
title: Text(libL10n.edit),
actions: _buildAppBarActions(),
),
appBar: CustomAppBar(title: Text(libL10n.edit), actions: _buildAppBarActions()),
body: _buildBody(),
floatingActionButton: _buildFAB(),
);
@@ -64,9 +58,7 @@ class _SnippetEditPageState extends State<SnippetEditPage> with AfterLayoutMixin
onPressed: () {
context.showRoundDialog(
title: libL10n.attention,
child: Text(libL10n.askContinue(
'${libL10n.delete} ${l10n.snippet}(${snippet.name})',
)),
child: Text(libL10n.askContinue('${libL10n.delete} ${l10n.snippet}(${snippet.name})')),
actions: Btn.ok(
onTap: () {
SnippetProvider.del(snippet);
@@ -79,7 +71,7 @@ class _SnippetEditPageState extends State<SnippetEditPage> with AfterLayoutMixin
},
tooltip: libL10n.delete,
icon: const Icon(Icons.delete),
)
),
];
}
@@ -168,12 +160,7 @@ class _SnippetEditPageState extends State<SnippetEditPage> with AfterLayoutMixin
trailing: const Icon(Icons.keyboard_arrow_right),
subtitle: subtitle == null
? null
: Text(
subtitle,
maxLines: 1,
style: UIs.textGrey,
overflow: TextOverflow.ellipsis,
),
: Text(subtitle, maxLines: 1, style: UIs.textGrey, overflow: TextOverflow.ellipsis),
onTap: () async {
vals.removeWhere((e) => !ServerProvider.serverOrder.value.contains(e));
final serverIds = await context.showPickDialog(
@@ -198,7 +185,8 @@ class _SnippetEditPageState extends State<SnippetEditPage> with AfterLayoutMixin
child: Padding(
padding: const EdgeInsets.all(13),
child: SimpleMarkdown(
data: '''
data:
'''
📌 ${l10n.supportFmtArgs}\n
${SnippetX.fmtArgs.keys.map((e) => '`$e`').join(', ')}\n
@@ -207,11 +195,7 @@ ${libL10n.example}:
- `\${ctrl+c}` (Control + C)
- `\${ctrl+b}d` (Tmux Detach)
''',
styleSheet: MarkdownStyleSheet(
codeblockDecoration: const BoxDecoration(
color: Colors.transparent,
),
),
styleSheet: MarkdownStyleSheet(codeblockDecoration: const BoxDecoration(color: Colors.transparent)),
),
),
);

View File

@@ -11,10 +11,7 @@ class SnippetListPage extends StatefulWidget {
@override
State<SnippetListPage> createState() => _SnippetListPageState();
static const route = AppRouteNoArg(
page: SnippetListPage.new,
path: '/snippets',
);
static const route = AppRouteNoArg(page: SnippetListPage.new, path: '/snippets');
}
class _SnippetListPageState extends State<SnippetListPage> with AutomaticKeepAliveClientMixin {
@@ -38,8 +35,7 @@ class _SnippetListPageState extends State<SnippetListPage> with AutomaticKeepAli
Widget _buildBody() {
// final isMobile = ResponsiveBreakpoints.of(context).isMobile;
return SnippetProvider.snippets.listenVal(
(snippets) {
return SnippetProvider.snippets.listenVal((snippets) {
return _tag.listenVal((tag) {
final child = _buildScaffold(snippets, tag);
// if (isMobile) {
@@ -54,8 +50,7 @@ class _SnippetListPageState extends State<SnippetListPage> with AutomaticKeepAli
// leftBuilder: (_, __) => child,
// );
});
},
);
});
}
Widget _buildScaffold(List<Snippet> snippets, String tag) {
@@ -104,11 +99,7 @@ class _SnippetListPageState extends State<SnippetListPage> with AutomaticKeepAli
Widget _buildSnippetItem(Snippet snippet) {
return ListTile(
contentPadding: const EdgeInsets.only(left: 23, right: 17),
title: Text(
snippet.name,
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
title: Text(snippet.name, overflow: TextOverflow.ellipsis, maxLines: 1),
subtitle: Text(
snippet.note ?? snippet.script,
overflow: TextOverflow.ellipsis,
@@ -119,10 +110,7 @@ class _SnippetListPageState extends State<SnippetListPage> with AutomaticKeepAli
onTap: () {
// final isMobile = ResponsiveBreakpoints.of(context).isMobile;
// if (isMobile) {
SnippetEditPage.route.go(
context,
args: SnippetEditPageArgs(snippet: snippet),
);
SnippetEditPage.route.go(context, args: SnippetEditPageArgs(snippet: snippet));
// } else {
// _splitViewCtrl.replace(SnippetEditPage(
// args: SnippetEditPageArgs(snippet: snippet),

View File

@@ -8,10 +8,7 @@ class SnippetResultPage extends StatelessWidget {
const SnippetResultPage({super.key, required this.args});
static const route = AppRouteArg(
page: SnippetResultPage.new,
path: '/snippets/result',
);
static const route = AppRouteArg(page: SnippetResultPage.new, path: '/snippets/result');
@override
Widget build(BuildContext context) {
@@ -37,10 +34,7 @@ class SnippetResultPage extends StatelessWidget {
SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 17),
scrollDirection: Axis.horizontal,
child: Text(
item.result,
textAlign: TextAlign.start,
),
child: Text(item.result, textAlign: TextAlign.start),
),
],
),

View File

@@ -88,10 +88,7 @@ extension _VirtKey on SSHPageState {
while (initPath == null) {
// Check if we've exceeded timeout
if (DateTime.now().difference(startTime) > timeout) {
contextSafe?.showRoundDialog(
title: libL10n.error,
child: Text(libL10n.empty),
);
contextSafe?.showRoundDialog(title: libL10n.error, child: Text(libL10n.empty));
return;
}
@@ -119,10 +116,7 @@ extension _VirtKey on SSHPageState {
}
if (!initPath.startsWith('/')) {
context.showRoundDialog(
title: libL10n.error,
child: Text('${l10n.remotePath}: $initPath'),
);
context.showRoundDialog(title: libL10n.error, child: Text('${l10n.remotePath}: $initPath'));
return;
}
@@ -138,10 +132,7 @@ extension _VirtKey on SSHPageState {
if (text != null) {
_terminal.textInput(text);
} else {
context.showRoundDialog(
title: libL10n.error,
child: Text(libL10n.empty),
);
context.showRoundDialog(title: libL10n.error, child: Text(libL10n.empty));
}
});
}

View File

@@ -16,19 +16,14 @@ class SSHTabPage extends StatefulWidget {
@override
State<SSHTabPage> createState() => _SSHTabPageState();
static const route = AppRouteNoArg(
page: SSHTabPage.new,
path: '/ssh',
);
static const route = AppRouteNoArg(page: SSHTabPage.new, path: '/ssh');
}
typedef _TabMap = Map<String, ({Widget page, FocusNode? focus})>;
class _SSHTabPageState extends State<SSHTabPage>
with TickerProviderStateMixin, AutomaticKeepAliveClientMixin {
late final _TabMap _tabMap = {
libL10n.add: (page: _AddPage(onTapInitCard: _onTapInitCard), focus: null),
};
late final _TabMap _tabMap = {libL10n.add: (page: _AddPage(onTapInitCard: _onTapInitCard), focus: null)};
final _pageCtrl = PageController();
final _fabVN = 0.vn;
final _tabRN = RNode();
@@ -48,12 +43,7 @@ class _SSHTabPageState extends State<SSHTabPage>
appBar: PreferredSizeListenBuilder(
listenable: _tabRN,
builder: () {
return _TabBar(
idxVN: _fabVN,
map: _tabMap,
onTap: _onTapTab,
onClose: _onTapClose,
);
return _TabBar(idxVN: _fabVN, map: _tabMap, onTap: _onTapTab, onClose: _onTapClose);
},
),
body: _buildBody(),
@@ -159,12 +149,7 @@ extension on _SSHTabPageState {
}
final class _TabBar extends StatelessWidget implements PreferredSizeWidget {
const _TabBar({
required this.idxVN,
required this.map,
required this.onTap,
required this.onClose,
});
const _TabBar({required this.idxVN, required this.map, required this.onTap, required this.onClose});
final ValueListenable<int> idxVN;
final _TabMap map;
@@ -188,10 +173,7 @@ final class _TabBar extends StatelessWidget implements PreferredSizeWidget {
itemBuilder: (_, idx) => _buildItem(idx),
separatorBuilder: (_, _) => Padding(
padding: const EdgeInsets.symmetric(vertical: 17),
child: Container(
color: const Color.fromARGB(61, 158, 158, 158),
width: 3,
),
child: Container(color: const Color.fromARGB(61, 158, 158, 158), width: 3),
),
);
},
@@ -242,10 +224,7 @@ final class _TabBar extends StatelessWidget implements PreferredSizeWidget {
width: selected ? kWideWidth : kNarrowWidth,
duration: Durations.medium3,
curve: Curves.fastEaseInToSlowEaseOut,
child: OverflowBox(
maxWidth: selected ? kWideWidth : null,
child: btn,
),
child: OverflowBox(maxWidth: selected ? kWideWidth : null, child: btn),
);
}
@@ -280,9 +259,7 @@ class _AddPage extends StatelessWidget {
return ServerProvider.serverOrder.listenVal((order) {
if (order.isEmpty) {
return Center(
child: Text(libL10n.empty, textAlign: TextAlign.center),
);
return Center(child: Text(libL10n.empty, textAlign: TextAlign.center));
}
// Custom grid
@@ -316,7 +293,7 @@ class _AddPage extends StatelessWidget {
overflow: TextOverflow.ellipsis,
),
),
const Icon(Icons.chevron_right)
const Icon(Icons.chevron_right),
],
),
),

View File

@@ -16,10 +16,7 @@ import 'package:server_box/view/page/storage/sftp_mission.dart';
final class LocalFilePageArgs {
final bool? isPickFile;
final String? initDir;
const LocalFilePageArgs({
this.isPickFile,
this.initDir,
});
const LocalFilePageArgs({this.isPickFile, this.initDir});
}
class LocalFilePage extends StatefulWidget {
@@ -27,10 +24,7 @@ class LocalFilePage extends StatefulWidget {
const LocalFilePage({super.key, this.args});
static const route = AppRoute<String, LocalFilePageArgs>(
page: LocalFilePage.new,
path: '/files/local',
);
static const route = AppRoute<String, LocalFilePageArgs>(page: LocalFilePage.new, path: '/files/local');
@override
State<LocalFilePage> createState() => _LocalFilePageState();
@@ -98,9 +92,7 @@ class _LocalFilePageState extends State<LocalFilePage> with AutomaticKeepAliveCl
Future<List<(FileSystemEntity, FileStat)>> getEntities() async {
final files = await Directory(_path.path).list().toList();
final sorted = _sortType.value.sort(files);
final stats = await Future.wait(
sorted.map((e) async => (e, await e.stat())),
);
final stats = await Future.wait(sorted.map((e) async => (e, await e.stat())));
return stats;
}
@@ -133,12 +125,7 @@ class _LocalFilePageState extends State<LocalFilePage> with AutomaticKeepAliveCl
final stat = item.$2;
final isDir = stat.type == FileSystemEntityType.directory;
return _buildItem(
file: file,
fileName: fileName,
stat: stat,
isDir: isDir,
);
return _buildItem(file: file, fileName: fileName, stat: stat, isDir: isDir);
},
);
},
@@ -156,10 +143,7 @@ class _LocalFilePageState extends State<LocalFilePage> with AutomaticKeepAliveCl
leading: isDir ? const Icon(Icons.folder_open) : const Icon(Icons.insert_drive_file),
title: Text(fileName),
subtitle: isDir ? null : Text(stat.size.bytes2Str, style: UIs.textGrey),
trailing: Text(
stat.modified.ymdhms(),
style: UIs.textGrey,
),
trailing: Text(stat.modified.ymdhms(), style: UIs.textGrey),
onLongPress: () {
if (isDir) {
_showDirActionDialog(file);
@@ -187,8 +171,7 @@ class _LocalFilePageState extends State<LocalFilePage> with AutomaticKeepAliveCl
}
Widget _buildSortBtn() {
return _sortType.listenVal(
(value) {
return _sortType.listenVal((value) {
return PopupMenuButton<_SortType>(
icon: const Icon(Icons.sort),
itemBuilder: (_) => _SortType.values.map((e) => e.menuItem).toList(),
@@ -196,8 +179,7 @@ class _LocalFilePageState extends State<LocalFilePage> with AutomaticKeepAliveCl
_sortType.value = value;
},
);
},
);
});
}
@override
@@ -238,10 +220,12 @@ extension _Actions on _LocalFilePageState {
title: libL10n.file,
child: Text(fileName),
actions: [
Btn.ok(onTap: () {
Btn.ok(
onTap: () {
context.pop();
context.pop(file.path);
}),
},
),
],
);
return;
@@ -382,21 +366,13 @@ extension _OnTapFile on _LocalFilePageState {
);
if (spi == null) return;
final args = SftpPageArgs(
spi: spi,
isSelect: true,
);
final args = SftpPageArgs(spi: spi, isSelect: true);
final remotePath = await SftpPage.route.go(context, args);
if (remotePath == null) {
return;
}
SftpProvider.add(SftpReq(
spi,
'$remotePath/$fileName',
file.absolute.path,
SftpReqType.upload,
));
SftpProvider.add(SftpReq(spi, '$remotePath/$fileName', file.absolute.path, SftpReqType.upload));
context.showSnackBar(l10n.added2List);
}
}
@@ -404,8 +380,7 @@ extension _OnTapFile on _LocalFilePageState {
enum _SortType {
name,
size,
time,
;
time;
List<FileSystemEntity> sort(List<FileSystemEntity> files) {
switch (this) {
@@ -437,13 +412,7 @@ enum _SortType {
PopupMenuItem<_SortType> get menuItem {
return PopupMenuItem(
value: this,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Icon(icon),
Text(i18n),
],
),
child: Row(mainAxisAlignment: MainAxisAlignment.spaceAround, children: [Icon(icon), Text(i18n)]),
);
}
}

View File

@@ -11,19 +11,14 @@ class SftpMissionPage extends StatefulWidget {
@override
State<SftpMissionPage> createState() => _SftpMissionPageState();
static const route = AppRouteNoArg(
page: SftpMissionPage.new,
path: '/sftp/mission',
);
static const route = AppRouteNoArg(page: SftpMissionPage.new, path: '/sftp/mission');
}
class _SftpMissionPageState extends State<SftpMissionPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: CustomAppBar(
title: Text(l10n.mission, style: UIs.text18),
),
appBar: CustomAppBar(title: Text(l10n.mission, style: UIs.text18)),
body: _buildBody(),
);
}
@@ -50,10 +45,7 @@ class _SftpMissionPageState extends State<SftpMissionPage> {
status: status,
subtitle: libL10n.error,
trailing: IconButton(
onPressed: () => context.showRoundDialog(
title: libL10n.error,
child: Text(err.toString()),
),
onPressed: () => context.showRoundDialog(title: libL10n.error, child: Text(err.toString())),
icon: const Icon(Icons.error),
),
);
@@ -109,9 +101,7 @@ class _SftpMissionPageState extends State<SftpMissionPage> {
Widget _buildFinished(SftpReqStatus status) {
final time = status.spentTime.toString();
final str = l10n.spentTime(
time == 'null' ? l10n.unknown : (time.substring(0, time.length - 7)),
);
final str = l10n.spentTime(time == 'null' ? l10n.unknown : (time.substring(0, time.length - 7)));
final btns = Row(
mainAxisSize: MainAxisSize.min,
@@ -120,41 +110,26 @@ class _SftpMissionPageState extends State<SftpMissionPage> {
onPressed: () {
final idx = status.req.localPath.lastIndexOf(Pfs.seperator);
final dir = status.req.localPath.substring(0, idx);
LocalFilePage.route.go(
context,
args: LocalFilePageArgs(initDir: dir),
);
LocalFilePage.route.go(context, args: LocalFilePageArgs(initDir: dir));
},
icon: const Icon(Icons.file_open),
),
IconButton(
onPressed: () => Pfs.sharePaths(paths: [status.req.localPath]),
icon: const Icon(Icons.open_in_new),
)
),
],
);
return _wrapInCard(
status: status,
subtitle: str,
trailing: btns,
);
return _wrapInCard(status: status, subtitle: str, trailing: btns);
}
Widget _wrapInCard({
required SftpReqStatus status,
String? subtitle,
Widget? trailing,
}) {
Widget _wrapInCard({required SftpReqStatus status, String? subtitle, Widget? trailing}) {
final time = DateTime.fromMicrosecondsSinceEpoch(status.id);
return CardX(
child: ListTile(
leading: Text(time.hourMinute),
title: Text(
status.fileName,
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
title: Text(status.fileName, overflow: TextOverflow.ellipsis, maxLines: 1),
subtitle: subtitle == null ? null : Text(subtitle, style: UIs.textGrey),
trailing: trailing,
),
@@ -165,9 +140,7 @@ class _SftpMissionPageState extends State<SftpMissionPage> {
return IconButton(
onPressed: () => context.showRoundDialog(
title: libL10n.attention,
child: Text(libL10n.askContinue(
'${libL10n.delete} ${l10n.mission}($name)',
)),
child: Text(libL10n.askContinue('${libL10n.delete} ${l10n.mission}($name)')),
actions: Btn.ok(
onTap: () {
SftpProvider.cancel(id);

View File

@@ -9,15 +9,9 @@ import 'package:server_box/view/page/ssh/page/page.dart';
final class SystemdPage extends StatefulWidget {
final SpiRequiredArgs args;
const SystemdPage({
super.key,
required this.args,
});
const SystemdPage({super.key, required this.args});
static const route = AppRouteArg<void, SpiRequiredArgs>(
page: SystemdPage.new,
path: '/systemd',
);
static const route = AppRouteArg<void, SpiRequiredArgs>(page: SystemdPage.new, path: '/systemd');
@override
State<SystemdPage> createState() => _SystemdPageState();
@@ -37,9 +31,7 @@ final class _SystemdPageState extends State<SystemdPage> {
return Scaffold(
appBar: CustomAppBar(
title: const Text('Systemd'),
actions: isDesktop
? [Btn.icon(icon: const Icon(Icons.refresh), onTap: _pro.getUnits)]
: null,
actions: isDesktop ? [Btn.icon(icon: const Icon(Icons.refresh), onTap: _pro.getUnits)] : null,
),
body: RefreshIndicator(onRefresh: _pro.getUnits, child: _buildBody()),
);
@@ -54,9 +46,7 @@ final class _SystemdPageState extends State<SystemdPage> {
duration: Durations.medium1,
curve: Curves.fastEaseInToSlowEaseOut,
height: isBusy ? SizedLoading.medium.size : 0,
child: isBusy
? SizedLoading.medium
: const SizedBox.shrink(),
child: isBusy ? SizedLoading.medium : const SizedBox.shrink(),
),
),
),
@@ -66,35 +56,24 @@ final class _SystemdPageState extends State<SystemdPage> {
}
Widget _buildUnitList(VNode<List<SystemdUnit>> units) {
return units.listenVal(
(units) {
return units.listenVal((units) {
if (units.isEmpty) {
return SliverToBoxAdapter(
child:
CenterGreyTitle(libL10n.empty).paddingSymmetric(horizontal: 13),
);
return SliverToBoxAdapter(child: CenterGreyTitle(libL10n.empty).paddingSymmetric(horizontal: 13));
}
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
delegate: SliverChildBuilderDelegate((context, index) {
final unit = units[index];
return ListTile(
leading: _buildScopeTag(unit.scope),
title: unit.description != null
? TipText(unit.name, unit.description!)
: Text(unit.name),
subtitle: Wrap(children: [
_buildStateTag(unit.state),
_buildTypeTag(unit.type),
]).paddingOnly(top: 7),
title: unit.description != null ? TipText(unit.name, unit.description!) : Text(unit.name),
subtitle: Wrap(
children: [_buildStateTag(unit.state), _buildTypeTag(unit.type)],
).paddingOnly(top: 7),
trailing: _buildUnitFuncs(unit),
).cardx.paddingSymmetric(horizontal: 13);
},
childCount: units.length,
),
);
},
}, childCount: units.length),
);
});
}
Widget _buildUnitFuncs(SystemdUnit unit) {
@@ -128,11 +107,7 @@ final class _SystemdPageState extends State<SystemdPage> {
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Icon(func.icon, size: 19),
const SizedBox(width: 10),
Text(func.name.capitalize),
],
children: [Icon(func.icon, size: 19), const SizedBox(width: 10), Text(func.name.capitalize)],
),
);
}
@@ -155,8 +130,7 @@ final class _SystemdPageState extends State<SystemdPage> {
color: color?.withValues(alpha: 0.7) ?? UIs.halfAlpha,
borderRadius: BorderRadius.circular(5),
),
child: Text(tag, style: UIs.text11)
.paddingSymmetric(horizontal: 5, vertical: 1),
child: Text(tag, style: UIs.text11).paddingSymmetric(horizontal: 5, vertical: 1),
).paddingOnly(right: noPad ? 0 : 5);
}
}

View File

@@ -6,17 +6,12 @@ class OmitStartText extends StatelessWidget {
final TextStyle? style;
final TextOverflow? overflow;
const OmitStartText(
this.text, {
super.key,
this.maxLines,
this.style,
this.overflow,
});
const OmitStartText(this.text, {super.key, this.maxLines, this.style, this.overflow});
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (context, size) {
return LayoutBuilder(
builder: (context, size) {
bool exceeded = false;
int len = 0;
for (; !exceeded && len < text.length; len++) {
@@ -27,11 +22,7 @@ class OmitStartText extends StatelessWidget {
);
// Use a textpainter to determine if it will exceed max lines
final tp = TextPainter(
maxLines: maxLines ?? 1,
textDirection: TextDirection.ltr,
text: span,
);
final tp = TextPainter(maxLines: maxLines ?? 1, textDirection: TextDirection.ltr, text: span);
// trigger it to layout
tp.layout(maxWidth: size.maxWidth);
@@ -47,6 +38,7 @@ class OmitStartText extends StatelessWidget {
maxLines: maxLines ?? 1,
style: style,
);
});
},
);
}
}

View File

@@ -5,18 +5,13 @@ import 'package:flutter/material.dart';
final class PercentCircle extends StatelessWidget {
final double percent;
const PercentCircle({
super.key,
required this.percent,
});
const PercentCircle({super.key, required this.percent});
@override
Widget build(BuildContext context) {
final percent = switch (this.percent) {
0 => 0.01,
100 => 99.9,
// NaN
final val when val.isNaN => 0.01,
<= 0.01 => 0.01,
>= 99.9 => 99.9,
_ => this.percent,
};
return Stack(

View File

@@ -6,11 +6,7 @@ final class UnixPermOp {
final bool w;
final bool x;
const UnixPermOp({
required this.r,
required this.w,
required this.x,
});
const UnixPermOp({required this.r, required this.w, required this.x});
UnixPermOp copyWith({bool? r, bool? w, bool? x}) {
return UnixPermOp(r: r ?? this.r, w: w ?? this.w, x: x ?? this.x);
@@ -24,8 +20,7 @@ final class UnixPermOp {
enum UnixPermScope {
user,
group,
other,
;
other;
String get title {
return switch (this) {
@@ -150,9 +145,6 @@ final class _UnixPermEditorState extends State<UnixPermEditor> {
}
Widget _buildSwitch(bool value, void Function(bool) onChanged) {
return Switch(
value: value,
onChanged: onChanged,
);
return Switch(value: value, onChanged: onChanged);
}
}

View File

@@ -96,9 +96,12 @@ include(flutter/generated_plugins.cmake)
# By default, "installing" just makes a relocatable bundle in the build
# directory.
set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle")
if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
endif()
# if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
# set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
# endif()
# Always set the install prefix to the build bundle directory, even if
# CMAKE_INSTALL_PREFIX was set to something else before.
set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
# Start with a clean build bundle directory every time.
install(CODE "

View File

@@ -15,7 +15,7 @@ fa1215b4be74 Up 12 hours firefly
const images = [
'rustdesk/rustdesk-server:latest',
'rustdesk/rustdesk-server:latest',
'uusec/firefly:latest'
'uusec/firefly:latest',
];
const states = ['Up 2 hours', 'Up 41 minutes', 'Up 12 hours'];
for (var idx = 1; idx < lines.length; idx++) {

View File

@@ -198,16 +198,16 @@ const _nestedJsonLsblkOutput = '''
''';
const _raws = [
// '''
// Filesystem 1K-blocks Used Available Use% Mounted on
// udev 864088 0 864088 0% /dev
// tmpfs 176724 688 176036 1% /run
// /dev/vda3 40910528 18067948 20951380 47% /
// tmpfs 883612 0 883612 0% /dev/shm
// tmpfs 5120 0 5120 0% /run/lock
// /dev/vda2 192559 11807 180752 7% /boot/efi
// tmpfs 176720 104 176616 1% /run/user/1000
// ''',
// '''
// Filesystem 1K-blocks Used Available Use% Mounted on
// udev 864088 0 864088 0% /dev
// tmpfs 176724 688 176036 1% /run
// /dev/vda3 40910528 18067948 20951380 47% /
// tmpfs 883612 0 883612 0% /dev/shm
// tmpfs 5120 0 5120 0% /run/lock
// /dev/vda2 192559 11807 180752 7% /boot/efi
// tmpfs 176720 104 176616 1% /run/user/1000
// ''',
'''
Filesystem 1K-blocks Used Available Use% Mounted on
udev 16181648 0 16181648 0% /dev

View File

@@ -113,15 +113,12 @@ void main() {
SensorAdaptor.virtual,
SensorAdaptor.pci,
]);
expect(
sensors.map((e) => e.summary),
[
expect(sensors.map((e) => e.summary), [
'+56.0°C (high = +105.0°C, crit = +105.0°C)',
'+27.8°C (crit = +119.0°C)',
'+56.0°C',
'+45.9°C (low = -273.1°C, high = +83.8°C)',
],
);
]);
});
test('parse sensors2', () {
@@ -138,14 +135,11 @@ void main() {
SensorAdaptor.pci,
SensorAdaptor.pci,
]);
expect(
sensors.map((e) => e.summary),
[
expect(sensors.map((e) => e.summary), [
'1.26 V',
'1.19 V (min = +0.00 V, max = +1.74 V)',
'+45.9°C (low = -273.1°C, high = +69.8°C)',
'+44.9°C',
],
);
]);
});
}

87
test/uptime_test.dart Normal file
View File

@@ -0,0 +1,87 @@
import 'package:flutter_test/flutter_test.dart';
void main() {
group('Linux uptime parsing tests', () {
test('should parse uptime with days and hours:minutes', () {
const raw = '19:39:15 up 61 days, 18:16, 1 user, load average: 0.00, 0.00, 0.00';
final result = _testParseUpTime(raw);
expect(result, '61 days, 18:16');
});
test('should parse uptime with single day and hours:minutes', () {
const raw = '19:39:15 up 1 day, 2:34, 1 user, load average: 0.00, 0.00, 0.00';
final result = _testParseUpTime(raw);
expect(result, '1 day, 2:34');
});
test('should parse uptime with only hours:minutes', () {
const raw = '19:39:15 up 2:34, 1 user, load average: 0.00, 0.00, 0.00';
final result = _testParseUpTime(raw);
expect(result, '2:34');
});
test('should parse uptime with only minutes', () {
const raw = '19:39:15 up 34 min, 1 user, load average: 0.00, 0.00, 0.00';
final result = _testParseUpTime(raw);
expect(result, '34 min');
});
test('should parse uptime with days only (no time part)', () {
const raw = '19:39:15 up 5 days, 1 user, load average: 0.00, 0.00, 0.00';
final result = _testParseUpTime(raw);
expect(result, '5 days');
});
test('should return null for invalid format', () {
const raw = 'invalid uptime format';
final result = _testParseUpTime(raw);
expect(result, null);
});
test('should handle edge case with empty string', () {
const raw = '';
final result = _testParseUpTime(raw);
expect(result, null);
});
});
}
// Helper function to test the private _parseUpTime function
String? _testParseUpTime(String raw) {
final splitedUp = raw.split('up ');
if (splitedUp.length == 2) {
final uptimePart = splitedUp[1];
final splitedComma = uptimePart.split(', ');
if (splitedComma.isEmpty) return null;
// Handle different uptime formats
final firstPart = splitedComma[0].trim();
// Case 1: "61 days" or "1 day" - need to get the time part from next segment
if (firstPart.contains('day')) {
if (splitedComma.length >= 2) {
final timePart = splitedComma[1].trim();
// Check if it's in HH:MM format
if (timePart.contains(':') && !timePart.contains('user') && !timePart.contains('load')) {
return '$firstPart, $timePart';
}
}
return firstPart;
}
// Case 2: "2:34" (hours:minutes) - already in good format
if (firstPart.contains(':') && !firstPart.contains('user') && !firstPart.contains('load')) {
return firstPart;
}
// Case 3: "34 min" - already in good format
if (firstPart.contains('min')) {
return firstPart;
}
// Fallback: return first part
return firstPart;
}
return null;
}

473
test/windows_test.dart Normal file
View File

@@ -0,0 +1,473 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:server_box/data/model/app/shell_func.dart';
import 'package:server_box/data/model/server/server_status_update_req.dart';
import 'package:server_box/data/model/server/system.dart';
import 'package:server_box/data/res/status.dart';
void main() {
group('Windows System Tests', () {
test('should verify Windows segments length matches command types', () {
final systemType = SystemType.windows;
final expectedLength = WindowsStatusCmdType.values.length;
expect(systemType.segmentsLen, equals(expectedLength));
expect(systemType.isSegmentsLenMatch(expectedLength), isTrue);
});
test('should generate Windows PowerShell script correctly', () {
final script = ShellFunc.allScript({'custom_cmd': 'echo "test"'}, systemType: SystemType.windows);
expect(script, contains('PowerShell script for ServerBox'));
expect(script, contains('function SbStatus'));
expect(script, contains('function SbProcess'));
expect(script, contains('function SbShutdown'));
expect(script, contains('function SbReboot'));
expect(script, contains('function SbSuspend'));
expect(script, contains('switch (\$args[0])'));
expect(script, contains('"-s" { SbStatus }'));
expect(script, contains('echo "test"'));
});
test('should handle Windows system parsing with real data', () async {
final segments = _windowsStatusSegments;
final serverStatus = InitStatus.status;
final req = ServerStatusUpdateReq(
system: SystemType.windows,
ss: serverStatus,
segments: segments,
customCmds: {},
);
final result = await getStatus(req);
// Verify system information was parsed
expect(result.more[StatusCmdType.sys], equals('Microsoft Windows 11 Pro for Workstations'));
expect(result.more[StatusCmdType.host], equals('LKH6'));
// Verify CPU information
expect(result.cpu.now, isNotEmpty);
expect(result.cpu.brand.keys.first, contains('12th Gen Intel(R) Core(TM) i5-12490F'));
// Verify memory information
expect(result.mem, isNotNull);
expect(result.mem.total, equals(66943944));
expect(result.mem.free, equals(58912812));
// Verify disk information
expect(result.disk, isNotEmpty);
final cDrive = result.disk.firstWhere((disk) => disk.path == 'C:');
expect(cDrive.fsTyp, equals('NTFS'));
expect(cDrive.size, equals(BigInt.parse('999271952384') ~/ BigInt.from(1024)));
expect(cDrive.avail, equals(BigInt.parse('386084032512') ~/ BigInt.from(1024)));
// Verify TCP connections
expect(result.tcp, isNotNull);
expect(result.tcp.active, equals(2));
});
test('should parse Windows CPU data correctly', () async {
const cpuJson = '''
{
"Name": "12th Gen Intel(R) Core(TM) i5-12490F",
"LoadPercentage": 42
}
''';
final segments = ['__windows', '1754151483', '', '', cpuJson];
final serverStatus = InitStatus.status;
final req = ServerStatusUpdateReq(
system: SystemType.windows,
ss: serverStatus,
segments: segments,
customCmds: {},
);
final result = await getStatus(req);
expect(result.cpu.now, hasLength(1));
expect(result.cpu.now.first.user, equals(42));
expect(result.cpu.now.first.idle, equals(58));
});
test('should parse Windows memory data correctly', () async {
const memoryJson = '''
{
"TotalVisibleMemorySize": 66943944,
"FreePhysicalMemory": 58912812
}
''';
final segments = ['__windows', '1754151483', '', '', '', '', '', '', memoryJson];
final serverStatus = InitStatus.status;
final req = ServerStatusUpdateReq(
system: SystemType.windows,
ss: serverStatus,
segments: segments,
customCmds: {},
);
final result = await getStatus(req);
expect(result.mem, isNotNull);
expect(result.mem.total, equals(66943944));
expect(result.mem.free, equals(58912812));
expect(result.mem.avail, equals(58912812));
});
test('should parse Windows disk data correctly', () async {
const diskJson = '''
{
"DeviceID": "C:",
"Size": 999271952384,
"FreeSpace": 386084032512,
"FileSystem": "NTFS"
}
''';
final segments = ['__windows', '1754151483', '', '', '', '', '', diskJson];
final serverStatus = InitStatus.status;
final req = ServerStatusUpdateReq(
system: SystemType.windows,
ss: serverStatus,
segments: segments,
customCmds: {},
);
final result = await getStatus(req);
expect(result.disk, hasLength(1));
final disk = result.disk.first;
expect(disk.path, equals('C:'));
expect(disk.mount, equals('C:'));
expect(disk.fsTyp, equals('NTFS'));
expect(disk.size, equals(BigInt.parse('999271952384') ~/ BigInt.from(1024)));
expect(disk.avail, equals(BigInt.parse('386084032512') ~/ BigInt.from(1024)));
expect(disk.usedPercent, equals(61));
});
test('should parse Windows battery data correctly', () async {
const batteryJson = '''
{
"EstimatedChargeRemaining": 85,
"BatteryStatus": 6
}
''';
// Create segments with enough elements to reach battery position
final segments = List.filled(WindowsStatusCmdType.values.length, '');
segments[0] = '__windows';
segments[WindowsStatusCmdType.battery.index] = batteryJson;
final serverStatus = InitStatus.status;
final req = ServerStatusUpdateReq(
system: SystemType.windows,
ss: serverStatus,
segments: segments,
customCmds: {},
);
final result = await getStatus(req);
expect(result.batteries, hasLength(1));
final battery = result.batteries.first;
expect(battery.name, equals('Battery'));
expect(battery.percent, equals(85));
expect(battery.status.name, equals('charging'));
});
test('should handle Windows uptime parsing correctly', () async {
// Test new format with date line + uptime days
const uptimeNewFormat = 'Friday, July 25, 2025 2:26:42 PM\n2';
final segments = List.filled(WindowsStatusCmdType.values.length, '');
segments[0] = '__windows';
segments[WindowsStatusCmdType.uptime.index] = uptimeNewFormat;
final serverStatus = InitStatus.status;
final req = ServerStatusUpdateReq(
system: SystemType.windows,
ss: serverStatus,
segments: segments,
customCmds: {},
);
final result = await getStatus(req);
expect(result.more[StatusCmdType.uptime], isNotNull);
});
test('should handle Windows uptime parsing with old format', () async {
const uptimeDateTime = 'Friday, July 25, 2025 2:26:42 PM';
final segments = List.filled(WindowsStatusCmdType.values.length, '');
segments[0] = '__windows';
segments[WindowsStatusCmdType.uptime.index] = uptimeDateTime;
final serverStatus = InitStatus.status;
final req = ServerStatusUpdateReq(
system: SystemType.windows,
ss: serverStatus,
segments: segments,
customCmds: {},
);
final result = await getStatus(req);
expect(result.more[StatusCmdType.uptime], isNotNull);
});
test('should handle Windows script path generation', () {
const serverId = 'test-server';
final scriptPath = ShellFunc.getScriptPath(serverId, systemType: SystemType.windows);
expect(scriptPath, contains('.ps1'));
expect(scriptPath, contains('\\'));
final installCmd = ShellFunc.getInstallShellCmd(serverId, systemType: SystemType.windows);
expect(installCmd, contains('New-Item'));
expect(installCmd, contains('Set-Content'));
// No longer contains 'powershell' prefix as commands now run in PowerShell session
});
test('should execute Windows commands correctly', () {
const serverId = 'test-server';
final statusCmd = ShellFunc.status.exec(serverId, systemType: SystemType.windows);
expect(statusCmd, contains('powershell'));
expect(statusCmd, contains('-ExecutionPolicy Bypass'));
expect(statusCmd, contains('-s'));
final processCmd = ShellFunc.process.exec(serverId, systemType: SystemType.windows);
expect(processCmd, contains('powershell'));
expect(processCmd, contains('-p'));
});
test('should handle GPU detection on Windows', () async {
const nvidiaNotFound = 'NVIDIA driver not found';
const amdNotFound = 'AMD driver not found';
final segments = List.filled(WindowsStatusCmdType.values.length, '');
segments[0] = '__windows';
segments[WindowsStatusCmdType.nvidia.index] = nvidiaNotFound;
segments[WindowsStatusCmdType.amd.index] = amdNotFound;
final serverStatus = InitStatus.status;
final req = ServerStatusUpdateReq(
system: SystemType.windows,
ss: serverStatus,
segments: segments,
customCmds: {},
);
final result = await getStatus(req);
// Should not throw errors even when GPU drivers are not found
expect(result.nvidia, anyOf(isNull, isEmpty));
expect(result.amd, anyOf(isNull, isEmpty));
});
test('should handle Windows error conditions gracefully', () async {
// Test with malformed JSON and error messages
final segments = [
'__windows',
'1754151483',
'Network adapter error',
'Microsoft Windows 11 Pro for Workstations',
'invalid json {',
'uptime error',
'connection error',
'disk error',
'memory error',
'temp error',
'LKH6',
'diskio error',
'battery error',
'NVIDIA driver not found',
'AMD driver not found',
'sensor error',
'smart error',
'12th Gen Intel(R) Core(TM) i5-12490F',
];
final serverStatus = InitStatus.status;
final req = ServerStatusUpdateReq(
system: SystemType.windows,
ss: serverStatus,
segments: segments,
customCmds: {},
);
// Should not throw exceptions
expect(() async => await getStatus(req), returnsNormally);
final result = await getStatus(req);
expect(result.more[StatusCmdType.sys], equals('Microsoft Windows 11 Pro for Workstations'));
expect(result.more[StatusCmdType.host], equals('LKH6'));
});
test('should handle Windows temperature error output gracefully', () async {
// Test with actual error output from win_raw.txt
final segments = [
'__windows',
'1754151483',
'', // network
'Microsoft Windows 11 Pro for Workstations', // system
'''
{
"Name": "12th Gen Intel(R) Core(TM) i5-12490F",
"LoadPercentage": 42
}
''', // cpu
'Friday, July 25, 2025 2:26:42 PM', // uptime
'2', // connections
'''
{
"DeviceID": "C:",
"Size": 999271952384,
"FreeSpace": 386084032512,
"FileSystem": "NTFS"
}
''', // disk
'''
{
"TotalVisibleMemorySize": 66943944,
"FreePhysicalMemory": 58912812
}
''', // memory
'''
The string is missing the terminator: ".
+ CategoryInfo : ParserError: (:) [], ParentContainsErrorRecordException
+ FullyQualifiedErrorId : TerminatorExpectedAtEndOfString
''', // temp (error output)
'LKH6', // host
'', // diskio
'', // battery
'NVIDIA driver not found', // nvidia
'AMD driver not found', // amd
'', // sensors
'''
{
"DeviceId": "0",
"Temperature": 41,
"TemperatureMax": 70,
"Wear": 0,
"PowerOnHours": null
}
''', // smart
'12th Gen Intel(R) Core(TM) i5-12490F', // cpu brand
];
final serverStatus = InitStatus.status;
final req = ServerStatusUpdateReq(
system: SystemType.windows,
ss: serverStatus,
segments: segments,
customCmds: {},
);
// Should not throw exceptions even with error output in temperature values
expect(() async => await getStatus(req), returnsNormally);
final result = await getStatus(req);
expect(result.more[StatusCmdType.sys], equals('Microsoft Windows 11 Pro for Workstations'));
expect(result.more[StatusCmdType.host], equals('LKH6'));
// Temperature should be empty since we got error output
expect(result.temps.isEmpty, isTrue);
});
});
}
// Sample Windows status segments based on real PowerShell output
final _windowsStatusSegments = [
'__windows', // System type marker
'1754151483', // Unix timestamp
'', // Network data (empty for now)
'Microsoft Windows 11 Pro for Workstations', // System name
'''
{
"Name": "12th Gen Intel(R) Core(TM) i5-12490F",
"LoadPercentage": 42
}
''', // CPU data
'Friday, July 25, 2025 2:26:42 PM', // Uptime (boot time)
'2', // Connection count
'''
{
"DeviceID": "C:",
"Size": 999271952384,
"FreeSpace": 386084032512,
"FileSystem": "NTFS"
}
''', // Disk data
'''
{
"TotalVisibleMemorySize": 66943944,
"FreePhysicalMemory": 58912812
}
''', // Memory data
'', // Temperature (combined command - empty due to OpenHardwareMonitor error)
'LKH6', // Hostname
'', // Disk I/O (empty for now)
'', // Battery data (empty)
'NVIDIA driver not found', // NVIDIA GPU
'AMD driver not found', // AMD GPU
'', // Sensors (empty due to OpenHardwareMonitor error)
'''
{
"CimClass": {
"CimSuperClassName": "MSFT_StorageObject",
"CimSuperClass": {
"CimSuperClassName": null,
"CimSuperClass": null,
"CimClassProperties": "ObjectId PassThroughClass PassThroughIds PassThroughNamespace PassThroughServer UniqueId",
"CimClassQualifiers": "Abstract = True locale = 1033",
"CimClassMethods": "",
"CimSystemProperties": "Microsoft.Management.Infrastructure.CimSystemProperties"
},
"CimClassProperties": [
"ObjectId",
"PassThroughClass",
"PassThroughIds",
"PassThroughNamespace",
"PassThroughServer",
"UniqueId",
"DeviceId",
"FlushLatencyMax",
"LoadUnloadCycleCount",
"LoadUnloadCycleCountMax",
"ManufactureDate",
"PowerOnHours",
"ReadErrorsCorrected",
"ReadErrorsTotal",
"ReadErrorsUncorrected",
"ReadLatencyMax",
"StartStopCycleCount",
"StartStopCycleCountMax",
"Temperature",
"TemperatureMax",
"Wear",
"WriteErrorsCorrected",
"WriteErrorsTotal",
"WriteErrorsUncorrected",
"WriteLatencyMax"
]
},
"Temperature": 46,
"TemperatureMax": 70,
"Wear": 0,
"ReadLatencyMax": 1930,
"WriteLatencyMax": 1903,
"FlushLatencyMax": 262
}
''', // Disk SMART data
'12th Gen Intel(R) Core(TM) i5-12490F', // CPU brand
];