feat: disk smart info (#773)

This commit is contained in:
lollipopkit🏳️‍⚧️
2025-06-05 07:31:45 +08:00
committed by GitHub
parent 741a6442e0
commit 176cb7da03
41 changed files with 2225 additions and 157 deletions

View File

@@ -11,13 +11,13 @@ enum ServerDetailCards {
swap(Icons.swap_horiz),
gpu(Bootstrap.gpu_card),
disk(Bootstrap.device_hdd_fill),
smart(Icons.health_and_safety, sinceBuild: 1174),
net(ZondIcons.network),
sensor(MingCute.dashboard_4_line),
temp(FontAwesome.temperature_empty_solid),
battery(Icons.battery_full),
pve(BoxIcons.bxs_dashboard, sinceBuild: 818),
custom(Icons.code, sinceBuild: 825),
;
custom(Icons.code, sinceBuild: 825);
final int? sinceBuild;
@@ -31,19 +31,20 @@ enum ServerDetailCards {
static final names = values.map((e) => e.name).toList();
String get toStr => switch (this) {
about => libL10n.about,
cpu => 'CPU',
mem => 'RAM',
swap => 'Swap',
gpu => 'GPU',
disk => l10n.disk,
net => l10n.net,
sensor => l10n.sensors,
temp => l10n.temperature,
battery => l10n.battery,
pve => 'PVE',
custom => l10n.cmd,
};
about => libL10n.about,
cpu => 'CPU',
mem => 'RAM',
swap => 'Swap',
gpu => 'GPU',
disk => l10n.disk,
smart => l10n.diskHealth,
net => l10n.net,
sensor => l10n.sensors,
temp => l10n.temperature,
battery => l10n.battery,
pve => 'PVE',
custom => l10n.cmd,
};
/// If:
/// Version 1 => user set [about], default is [about, cpu]

View File

@@ -9,8 +9,7 @@ enum ShellFunc {
process,
shutdown,
reboot,
suspend,
;
suspend;
static const seperator = 'SrvBoxSep';
@@ -29,7 +28,9 @@ enum ShellFunc {
/// Default is [scriptDirTmp]/[scriptFile], if this path is not accessible,
/// it will be changed to [scriptDirHome]/[scriptFile].
static String getScriptDir(String id) {
final customScriptDir = ServerProvider.pick(id: id)?.value.spi.custom?.scriptDir;
final customScriptDir = ServerProvider.pick(
id: id,
)?.value.spi.custom?.scriptDir;
if (customScriptDir != null) return customScriptDir;
return _scriptDirMap.putIfAbsent(id, () {
return scriptDirTmp;
@@ -37,10 +38,10 @@ enum ShellFunc {
}
static void switchScriptDir(String id) => switch (_scriptDirMap[id]) {
scriptDirTmp => _scriptDirMap[id] = scriptDirHome,
scriptDirHome => _scriptDirMap[id] = scriptDirTmp,
_ => _scriptDirMap[id] = scriptDirHome,
};
scriptDirTmp => _scriptDirMap[id] = scriptDirHome,
scriptDirHome => _scriptDirMap[id] = scriptDirTmp,
_ => _scriptDirMap[id] = scriptDirHome,
};
static String getScriptPath(String id) {
return '${getScriptDir(id)}/$scriptFile';
@@ -57,13 +58,13 @@ chmod 755 $scriptPath
}
String get flag => switch (this) {
ShellFunc.process => 'p',
ShellFunc.shutdown => 'sd',
ShellFunc.reboot => 'r',
ShellFunc.suspend => 'sp',
ShellFunc.status => 's',
// ShellFunc.docker=> 'd',
};
ShellFunc.process => 'p',
ShellFunc.shutdown => 'sd',
ShellFunc.reboot => 'r',
ShellFunc.suspend => 'sp',
ShellFunc.status => 's',
// ShellFunc.docker=> 'd',
};
String exec(String id) => 'sh ${getScriptPath(id)} -$flag';
@@ -94,14 +95,14 @@ if [ "\$macSign" = "" ] && [ "\$bsdSign" = "" ]; then
else
\t${BSDStatusCmdType.values.map((e) => e.cmd).join(cmdDivider)}
fi''';
// case ShellFunc.docker:
// return '''
// result=\$(docker version 2>&1 | grep "permission denied")
// if [ "\$result" != "" ]; then
// \t${_dockerCmds.join(_cmdDivider)}
// else
// \t${_dockerCmds.map((e) => "sudo -S $e").join(_cmdDivider)}
// fi''';
// case ShellFunc.docker:
// return '''
// result=\$(docker version 2>&1 | grep "permission denied")
// if [ "\$result" != "" ]; then
// \t${_dockerCmds.join(_cmdDivider)}
// else
// \t${_dockerCmds.map((e) => "sudo -S $e").join(_cmdDivider)}
// fi''';
case ShellFunc.process:
return '''
if [ "\$macSign" = "" ] && [ "\$bsdSign" = "" ]; then
@@ -162,7 +163,9 @@ exec 2>/dev/null
// Write each func
for (final func in values) {
final customCmdsStr = () {
if (func == ShellFunc.status && customCmds != null && customCmds.isNotEmpty) {
if (func == ShellFunc.status &&
customCmds != null &&
customCmds.isNotEmpty) {
return '$cmdDivider\n\t${customCmds.values.join(cmdDivider)}';
}
return '';
@@ -209,17 +212,21 @@ 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'),
host._('cat /etc/hostname'),
diskio._('cat /proc/diskstats'),
battery._('for f in /sys/class/power_supply/*/uevent; do cat "\$f"; echo; done'),
battery._(
'for f in /sys/class/power_supply/*/uevent; do cat "\$f"; echo; done',
),
nvidia._('nvidia-smi -q -x'),
sensors._('sensors'),
cpuBrand._('cat /proc/cpuinfo | grep "model name"'),
;
diskSmart._('for d in \$(lsblk -dn -o KNAME); do smartctl -j /dev/\$d; echo; done'),
cpuBrand._('cat /proc/cpuinfo | grep "model name"');
final String cmd;
@@ -238,8 +245,7 @@ enum BSDStatusCmdType {
mem._('top -l 1 | grep PhysMem'),
//temp,
host._('hostname'),
cpuBrand._('sysctl -n machdep.cpu.brand_string'),
;
cpuBrand._('sysctl -n machdep.cpu.brand_string');
final String cmd;
@@ -248,10 +254,10 @@ enum BSDStatusCmdType {
extension StatusCmdTypeX on StatusCmdType {
String get i18n => switch (this) {
StatusCmdType.sys => l10n.system,
StatusCmdType.host => l10n.host,
StatusCmdType.uptime => l10n.uptime,
StatusCmdType.battery => l10n.battery,
final val => val.name,
};
StatusCmdType.sys => l10n.system,
StatusCmdType.host => l10n.host,
StatusCmdType.uptime => l10n.uptime,
StatusCmdType.battery => l10n.battery,
final val => val.name,
};
}

View File

@@ -0,0 +1,204 @@
import 'dart:convert';
import 'package:fl_lib/fl_lib.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'disk_smart.freezed.dart';
part 'disk_smart.g.dart';
@freezed
class DiskSmart with _$DiskSmart {
const DiskSmart._();
const factory DiskSmart({
required String device,
bool? healthy,
double? temperature,
String? model,
String? serial,
int? powerOnHours,
int? powerCycleCount,
required Map<String, dynamic> rawData,
required Map<String, SmartAttribute> smartAttributes,
}) = _DiskSmart;
factory DiskSmart.fromJson(Map<String, dynamic> json) => _$DiskSmartFromJson(json);
static List<DiskSmart> parse(String raw) {
final results = <DiskSmart>[];
final jsonBlocks = raw.split('\n\n').where((s) => s.trim().isNotEmpty);
for (final jsonStr in jsonBlocks) {
try {
final data = json.decode(jsonStr.trim()) as Map<String, dynamic>;
// Basic
final device = data['device']?['name']?.toString() ?? '';
final healthy = data['smart_status']?['passed'] as bool?;
// Model and Serial
final model =
data['model_name']?.toString() ??
data['model_family']?.toString() ??
data['device']?['model_name']?.toString();
final serial = data['serial_number']?.toString() ?? data['device']?['serial_number']?.toString();
// SMART Attrs
final smartAttributes = _parseSmartAttributes(data);
final temperature = _extractTemperature(data, smartAttributes);
final powerOnHours =
data['power_on_time']?['hours'] as int? ?? smartAttributes['Power_On_Hours']?.rawValue as int?;
final powerCycleCount =
data['power_cycle_count'] as int? ?? smartAttributes['Power_Cycle_Count']?.rawValue as int?;
results.add(
DiskSmart(
device: device,
healthy: healthy,
temperature: temperature,
model: model,
serial: serial,
powerOnHours: powerOnHours,
powerCycleCount: powerCycleCount,
rawData: data,
smartAttributes: smartAttributes,
),
);
} catch (e, s) {
Loggers.app.warning('DiskSmart parse', e, s);
}
}
return results;
}
static Map<String, SmartAttribute> _parseSmartAttributes(Map<String, dynamic> data) {
final attributes = <String, SmartAttribute>{};
final attrTable = data['ata_smart_attributes']?['table'] as List?;
if (attrTable == null) return attributes;
for (final attr in attrTable) {
if (attr is Map<String, dynamic>) {
final name = attr['name']?.toString();
if (name != null) {
attributes[name] = SmartAttribute(
id: attr['id'] as int?,
name: name,
value: attr['value'] as int?,
worst: attr['worst'] as int?,
thresh: attr['thresh'] as int?,
whenFailed: attr['when_failed']?.toString(),
rawValue: attr['raw']?['value'],
rawString: attr['raw']?['string']?.toString(),
flags: SmartAttributeFlags.fromMap(attr['flags'] as Map<String, dynamic>? ?? {}),
);
}
}
}
return attributes;
}
static final _tempReg = RegExp(r'^(\d+(?:\.\d+)?)');
/// Extract temperature from the data
static double? _extractTemperature(Map<String, dynamic> data, Map<String, SmartAttribute> attrs) {
// Directly
final directTemp = data['temperature']?['current'];
if (directTemp is num) return directTemp.toDouble();
// SMART attribute
final tempAttr = attrs['Temperature_Celsius'];
if (tempAttr != null) {
// "35 (Min/Max 14/61)"
final rawString = tempAttr.rawString;
if (rawString != null) {
final match = _tempReg.firstMatch(rawString);
if (match != null) {
return double.tryParse(match.group(1)!);
}
}
// Simple numeric value
if (tempAttr.rawValue is num && tempAttr.rawValue! < 150) {
return tempAttr.rawValue!.toDouble();
}
}
return null;
}
/// Get the specific SMART attribute by name
SmartAttribute? getAttribute(String name) => smartAttributes[name];
int? get ssdLifeLeft => smartAttributes['SSD_Life_Left']?.rawValue as int?;
int? get lifetimeWritesGiB => smartAttributes['Lifetime_Writes_GiB']?.rawValue as int?;
int? get lifetimeReadsGiB => smartAttributes['Lifetime_Reads_GiB']?.rawValue as int?;
int? get unsafeShutdownCount => smartAttributes['Unsafe_Shutdown_Count']?.rawValue as int?;
int? get averageEraseCount => smartAttributes['Average_Erase_Count']?.rawValue as int?;
int? get maxEraseCount => smartAttributes['Max_Erase_Count']?.rawValue as int?;
@override
String toString() => 'DiskSmart($device)';
}
@freezed
class SmartAttribute with _$SmartAttribute {
const SmartAttribute._();
const factory SmartAttribute({
int? id,
required String name,
int? value,
int? worst,
int? thresh,
String? whenFailed,
dynamic rawValue,
String? rawString,
required SmartAttributeFlags flags,
}) = _SmartAttribute;
factory SmartAttribute.fromJson(Map<String, dynamic> json) => _$SmartAttributeFromJson(json);
@override
String toString() {
return 'SmartAttribute(id: $id, name: $name)';
}
}
@freezed
class SmartAttributeFlags with _$SmartAttributeFlags {
const SmartAttributeFlags._();
const factory SmartAttributeFlags({
int? value,
String? string,
@Default(false) bool prefailure,
@Default(false) bool updatedOnline,
@Default(false) bool performance,
@Default(false) bool errorRate,
@Default(false) bool eventCount,
@Default(false) bool autoKeep,
}) = _SmartAttributeFlags;
factory SmartAttributeFlags.fromJson(Map<String, dynamic> json) => _$SmartAttributeFlagsFromJson(json);
factory SmartAttributeFlags.fromMap(Map<String, dynamic> map) {
return SmartAttributeFlags(
value: map['value'] as int?,
string: map['string']?.toString(),
prefailure: map['prefailure'] == true,
updatedOnline: map['updated_online'] == true,
performance: map['performance'] == true,
errorRate: map['error_rate'] == true,
eventCount: map['event_count'] == true,
autoKeep: map['auto_keep'] == true,
);
}
@override
String toString() {
return 'SmartAttributeFlags(value: $value, string: $string)';
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,91 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'disk_smart.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$DiskSmartImpl _$$DiskSmartImplFromJson(Map<String, dynamic> json) =>
_$DiskSmartImpl(
device: json['device'] as String,
healthy: json['healthy'] as bool?,
temperature: (json['temperature'] as num?)?.toDouble(),
model: json['model'] as String?,
serial: json['serial'] as String?,
powerOnHours: (json['powerOnHours'] as num?)?.toInt(),
powerCycleCount: (json['powerCycleCount'] as num?)?.toInt(),
rawData: json['rawData'] as Map<String, dynamic>,
smartAttributes: (json['smartAttributes'] as Map<String, dynamic>).map(
(k, e) =>
MapEntry(k, SmartAttribute.fromJson(e as Map<String, dynamic>)),
),
);
Map<String, dynamic> _$$DiskSmartImplToJson(_$DiskSmartImpl instance) =>
<String, dynamic>{
'device': instance.device,
'healthy': instance.healthy,
'temperature': instance.temperature,
'model': instance.model,
'serial': instance.serial,
'powerOnHours': instance.powerOnHours,
'powerCycleCount': instance.powerCycleCount,
'rawData': instance.rawData,
'smartAttributes': instance.smartAttributes,
};
_$SmartAttributeImpl _$$SmartAttributeImplFromJson(Map<String, dynamic> json) =>
_$SmartAttributeImpl(
id: (json['id'] as num?)?.toInt(),
name: json['name'] as String,
value: (json['value'] as num?)?.toInt(),
worst: (json['worst'] as num?)?.toInt(),
thresh: (json['thresh'] as num?)?.toInt(),
whenFailed: json['whenFailed'] as String?,
rawValue: json['rawValue'],
rawString: json['rawString'] as String?,
flags: SmartAttributeFlags.fromJson(
json['flags'] as Map<String, dynamic>,
),
);
Map<String, dynamic> _$$SmartAttributeImplToJson(
_$SmartAttributeImpl instance,
) => <String, dynamic>{
'id': instance.id,
'name': instance.name,
'value': instance.value,
'worst': instance.worst,
'thresh': instance.thresh,
'whenFailed': instance.whenFailed,
'rawValue': instance.rawValue,
'rawString': instance.rawString,
'flags': instance.flags,
};
_$SmartAttributeFlagsImpl _$$SmartAttributeFlagsImplFromJson(
Map<String, dynamic> json,
) => _$SmartAttributeFlagsImpl(
value: (json['value'] as num?)?.toInt(),
string: json['string'] as String?,
prefailure: json['prefailure'] as bool? ?? false,
updatedOnline: json['updatedOnline'] as bool? ?? false,
performance: json['performance'] as bool? ?? false,
errorRate: json['errorRate'] as bool? ?? false,
eventCount: json['eventCount'] as bool? ?? false,
autoKeep: json['autoKeep'] as bool? ?? false,
);
Map<String, dynamic> _$$SmartAttributeFlagsImplToJson(
_$SmartAttributeFlagsImpl instance,
) => <String, dynamic>{
'value': instance.value,
'string': instance.string,
'prefailure': instance.prefailure,
'updatedOnline': instance.updatedOnline,
'performance': instance.performance,
'errorRate': instance.errorRate,
'eventCount': instance.eventCount,
'autoKeep': instance.autoKeep,
};

View File

@@ -5,6 +5,7 @@ import 'package:server_box/data/model/server/battery.dart';
import 'package:server_box/data/model/server/conn.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/disk_smart.dart';
import 'package:server_box/data/model/server/memory.dart';
import 'package:server_box/data/model/server/net_speed.dart';
import 'package:server_box/data/model/server/nvdia.dart';
@@ -19,12 +20,7 @@ class Server {
SSHClient? client;
ServerConn conn;
Server(
this.spi,
this.status,
this.conn, {
this.client,
});
Server(this.spi, this.status, this.conn, {this.client});
bool get needGenClient => conn < ServerConn.connecting;
@@ -44,6 +40,7 @@ class ServerStatus {
SystemType system;
Err? err;
DiskIO diskIO;
List<DiskSmart> diskSmart;
List<NvidiaSmiItem>? nvidia;
final List<Battery> batteries = [];
final Map<StatusCmdType, String> more = {};
@@ -61,6 +58,7 @@ class ServerStatus {
required this.temps,
required this.system,
required this.diskIO,
this.diskSmart = const [],
this.err,
this.nvidia,
this.diskUsage,

View File

@@ -85,7 +85,9 @@ extension Spix on Spi {
VNode<Server>? get jumpServer => ServerProvider.pick(id: jumpId);
bool shouldReconnect(Spi old) {
return id != old.id ||
return user != old.user ||
ip != old.ip ||
port != old.port ||
pwd != old.pwd ||
keyId != old.keyId ||
alterUrl != old.alterUrl ||

View File

@@ -4,6 +4,7 @@ import 'package:server_box/data/model/server/battery.dart';
import 'package:server_box/data/model/server/conn.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/disk_smart.dart';
import 'package:server_box/data/model/server/memory.dart';
import 'package:server_box/data/model/server/net_speed.dart';
import 'package:server_box/data/model/server/nvdia.dart';
@@ -37,7 +38,8 @@ 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 {
@@ -48,9 +50,7 @@ Future<ServerStatus> _getLinuxStatus(ServerStatusUpdateReq req) async {
}
try {
final sys = _parseSysVer(
StatusCmdType.sys.find(segments),
);
final sys = _parseSysVer(StatusCmdType.sys.find(segments));
if (sys != null) {
req.ss.more[StatusCmdType.sys] = sys;
}
@@ -130,6 +130,13 @@ Future<ServerStatus> _getLinuxStatus(ServerStatusUpdateReq req) async {
Loggers.app.warning(e, s);
}
try {
final smarts = DiskSmart.parse(StatusCmdType.diskSmart.find(segments));
req.ss.diskSmart = smarts;
} catch (e, s) {
Loggers.app.warning(e, s);
}
try {
req.ss.nvidia = NvidiaSmi.fromXml(StatusCmdType.nvidia.find(segments));
} catch (e, s) {

View File

@@ -45,21 +45,20 @@ class ServerProvider extends Provider {
for (int idx = 0; idx < spis.length; idx++) {
final spi = spis[idx];
final originServer = oldServers[spi.id];
final newServer = genServer(spi);
/// #258
/// If not [shouldReconnect], then keep the old state.
if (originServer != null && !originServer.value.spi.shouldReconnect(spi)) {
newServer.conn = originServer.value.conn;
originServer.value.spi = spi;
servers[spi.id] = originServer;
} else {
final newServer = genServer(spi);
servers[spi.id] = newServer.vn;
}
servers[spi.id] = newServer.vn;
}
final serverOrder_ = Stores.setting.serverOrder.fetch();
if (serverOrder_.isNotEmpty) {
spis.reorder(
order: serverOrder_,
finder: (n, id) => n.id == id,
);
spis.reorder(order: serverOrder_, finder: (n, id) => n.id == id);
serverOrder.value.addAll(spis.map((e) => e.id));
} else {
serverOrder.value.addAll(servers.keys);
@@ -104,31 +103,30 @@ class ServerProvider extends Provider {
/// if [spi] is specificed then only refresh this server
/// [onlyFailed] only refresh failed servers
static Future<void> refresh({
Spi? spi,
bool onlyFailed = false,
}) async {
static Future<void> refresh({Spi? spi, bool onlyFailed = false}) async {
if (spi != null) {
_manualDisconnectedIds.remove(spi.id);
await _getData(spi);
return;
}
await Future.wait(servers.values.map((val) async {
final s = val.value;
if (onlyFailed) {
if (s.conn != ServerConn.failed) return;
TryLimiter.reset(s.spi.id);
}
await Future.wait(
servers.values.map((val) async {
final s = val.value;
if (onlyFailed) {
if (s.conn != ServerConn.failed) return;
TryLimiter.reset(s.spi.id);
}
if (_manualDisconnectedIds.contains(s.spi.id)) return;
if (_manualDisconnectedIds.contains(s.spi.id)) return;
if (s.conn == ServerConn.disconnected && !s.spi.autoConnect) {
return;
}
if (s.conn == ServerConn.disconnected && !s.spi.autoConnect) {
return;
}
return await _getData(s.spi);
}));
return await _getData(s.spi);
}),
);
}
static Future<void> startAutoRefresh() async {
@@ -307,14 +305,11 @@ class ServerProvider extends Provider {
_setServerState(s, ServerConn.connected);
try {
final (_, writeScriptResult) = await sv.client!.exec(
(session) async {
final scriptRaw = ShellFunc.allScript(spi.custom?.cmds).uint8List;
session.stdin.add(scriptRaw);
session.stdin.close();
},
entry: ShellFunc.getInstallShellCmd(spi.id),
);
final (_, writeScriptResult) = await sv.client!.exec((session) async {
final scriptRaw = ShellFunc.allScript(spi.custom?.cmds).uint8List;
session.stdin.add(scriptRaw);
session.stdin.close();
}, entry: ShellFunc.getInstallShellCmd(spi.id));
if (writeScriptResult.isNotEmpty) {
ShellFunc.switchScriptDir(spi.id);
throw writeScriptResult;
@@ -366,10 +361,7 @@ class ServerProvider extends Provider {
}
}
TryLimiter.inc(sid);
sv.status.err = SSHErr(
type: SSHErrType.segements,
message: 'Seperate segments failed, raw:\n$raw',
);
sv.status.err = SSHErr(type: SSHErrType.segements, message: 'Seperate segments failed, raw:\n$raw');
_setServerState(s, ServerConn.failed);
return;
}
@@ -408,17 +400,10 @@ class ServerProvider extends Provider {
system: systemType,
customCmds: spi.custom?.cmds ?? {},
);
sv.status = await Computer.shared.start(
getStatus,
req,
taskName: 'StatusUpdateReq<${sv.id}>',
);
sv.status = await Computer.shared.start(getStatus, req, taskName: 'StatusUpdateReq<${sv.id}>');
} catch (e, trace) {
TryLimiter.inc(sid);
sv.status.err = SSHErr(
type: SSHErrType.getStatus,
message: 'Parse failed: $e\n\n$raw',
);
sv.status.err = SSHErr(type: SSHErrType.getStatus, message: 'Parse failed: $e\n\n$raw');
_setServerState(s, ServerConn.failed);
Loggers.app.warning('Server status', e, trace);
return;

View File

@@ -17,6 +17,11 @@ abstract final class GithubIds {
'dccif',
'mikropsoft',
'CakesTwix',
'dsvf',
'fei1025',
'MasedMSD',
'GitGitro',
'Shin-suechtig',
};
static const participants = <GhId>{
@@ -99,6 +104,20 @@ abstract final class GithubIds {
'88484396',
'honggeigei',
'likecreep',
'axlrose',
'immortal521',
'PRO-2684',
'Xiaobao-Yang',
'Mrhs121',
'Fudiautobi',
'papaj-na-wrotkach',
'kid1412621',
'smanx',
'xuanyue1024',
'RuofengX',
'rhwong',
'AstroEngineeer',
'mochasweet',
};
}

View File

@@ -8,56 +8,33 @@ import 'package:server_box/data/model/server/system.dart';
import 'package:server_box/data/model/server/temp.dart';
abstract final class InitStatus {
static SingleCpuCore get _initOneTimeCpuStatus => SingleCpuCore(
'cpu',
0,
0,
0,
0,
0,
0,
0,
);
static Cpus get cpus => Cpus(
[_initOneTimeCpuStatus],
[_initOneTimeCpuStatus],
);
static NetSpeedPart get _initNetSpeedPart => NetSpeedPart(
'',
BigInt.zero,
BigInt.zero,
0,
);
static NetSpeed get netSpeed => NetSpeed(
[_initNetSpeedPart],
[_initNetSpeedPart],
);
static SingleCpuCore get _initOneTimeCpuStatus =>
SingleCpuCore('cpu', 0, 0, 0, 0, 0, 0, 0);
static Cpus get cpus =>
Cpus([_initOneTimeCpuStatus], [_initOneTimeCpuStatus]);
static NetSpeedPart get _initNetSpeedPart =>
NetSpeedPart('', BigInt.zero, BigInt.zero, 0);
static NetSpeed get netSpeed =>
NetSpeed([_initNetSpeedPart], [_initNetSpeedPart]);
static ServerStatus get status => ServerStatus(
cpu: cpus,
mem: const Memory(
total: 1,
free: 1,
avail: 1,
),
disk: [
Disk(
path: '/',
mount: '/',
usedPercent: 0,
used: BigInt.zero,
size: BigInt.one,
avail: BigInt.zero,
)
],
tcp: const Conn(maxConn: 0, active: 0, passive: 0, fail: 0),
netSpeed: netSpeed,
swap: const Swap(
total: 0,
free: 0,
cached: 0,
),
system: SystemType.linux,
temps: Temperatures(),
diskIO: DiskIO([], []),
);
cpu: cpus,
mem: const Memory(total: 1, free: 1, avail: 1),
disk: [
Disk(
path: '/',
mount: '/',
usedPercent: 0,
used: BigInt.zero,
size: BigInt.one,
avail: BigInt.zero,
),
],
tcp: const Conn(maxConn: 0, active: 0, passive: 0, fail: 0),
netSpeed: netSpeed,
swap: const Swap(total: 0, free: 0, cached: 0),
system: SystemType.linux,
temps: Temperatures(),
diskIO: DiskIO([], []),
diskSmart: const [],
);
}

View File

@@ -335,6 +335,12 @@ abstract class AppLocalizations {
/// **'Disk'**
String get disk;
/// No description provided for @diskHealth.
///
/// In en, this message translates to:
/// **'Disk Health'**
String get diskHealth;
/// No description provided for @diskIgnorePath.
///
/// In en, this message translates to:

View File

@@ -128,6 +128,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get disk => 'Festplatte';
@override
String get diskHealth => 'Festplattengesundheit';
@override
String get diskIgnorePath => 'Pfad für Datenträger ignorieren';

View File

@@ -127,6 +127,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get disk => 'Disk';
@override
String get diskHealth => 'Disk Health';
@override
String get diskIgnorePath => 'Ignore path for disk';

View File

@@ -128,6 +128,9 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get disk => 'Disco';
@override
String get diskHealth => 'Salud del disco';
@override
String get diskIgnorePath => 'Rutas de disco ignoradas';

View File

@@ -128,6 +128,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get disk => 'Disque';
@override
String get diskHealth => 'Santé du disque';
@override
String get diskIgnorePath => 'Chemin à ignorer pour le disque';

View File

@@ -127,6 +127,9 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get disk => 'Disk';
@override
String get diskHealth => 'Kesehatan disk';
@override
String get diskIgnorePath => 'Abaikan jalan untuk disk';

View File

@@ -120,6 +120,9 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get disk => 'ディスク';
@override
String get diskHealth => 'ディスクの健康状態';
@override
String get diskIgnorePath => '無視されたディスクパス';

View File

@@ -127,6 +127,9 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get disk => 'Schijf';
@override
String get diskHealth => 'Schijfgezondheid';
@override
String get diskIgnorePath => 'Pad negeren voor schijf';

View File

@@ -127,6 +127,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get disk => 'Disco';
@override
String get diskHealth => 'Saúde do disco';
@override
String get diskIgnorePath => 'Caminhos de disco ignorados';

View File

@@ -127,6 +127,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get disk => 'Диск';
@override
String get diskHealth => 'Состояние диска';
@override
String get diskIgnorePath => 'Игнорировать путь к диску';

View File

@@ -126,6 +126,9 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get disk => 'Disk';
@override
String get diskHealth => 'Disk sağlığı';
@override
String get diskIgnorePath => 'Disk için yok sayılan yol';

View File

@@ -128,6 +128,9 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get disk => 'Диск';
@override
String get diskHealth => 'Стан диска';
@override
String get diskIgnorePath => 'Ігнорувати шлях для диска';

View File

@@ -119,6 +119,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get disk => '磁盘';
@override
String get diskHealth => '磁盘健康';
@override
String get diskIgnorePath => '忽略的磁盘路径';
@@ -844,6 +847,9 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
@override
String get disk => '磁碟';
@override
String get diskHealth => '磁碟健康';
@override
String get diskIgnorePath => '忽略的磁碟路徑';

View File

@@ -36,6 +36,7 @@
"dirEmpty": "Stelle sicher, dass der Ordner leer ist.",
"disconnected": "Disconnected",
"disk": "Festplatte",
"diskHealth": "Festplattengesundheit",
"diskIgnorePath": "Pfad für Datenträger ignorieren",
"displayCpuIndex": "Zeigen Sie den CPU-Index an",
"dl2Local": "Datei \"{fileName}\" herunterladen?",

View File

@@ -36,6 +36,7 @@
"dirEmpty": "Make sure the folder is empty.",
"disconnected": "Disconnected",
"disk": "Disk",
"diskHealth": "Disk Health",
"diskIgnorePath": "Ignore path for disk",
"displayCpuIndex": "Display CPU index",
"dl2Local": "Download {fileName} to local?",

View File

@@ -36,6 +36,7 @@
"dirEmpty": "Asegúrate de que el directorio esté vacío",
"disconnected": "Desconectado",
"disk": "Disco",
"diskHealth": "Salud del disco",
"diskIgnorePath": "Rutas de disco ignoradas",
"displayCpuIndex": "Muestre el índice de CPU",
"dl2Local": "¿Descargar {fileName} a local?",

View File

@@ -36,6 +36,7 @@
"dirEmpty": "Assurez-vous que le répertoire est vide.",
"disconnected": "Déconnecté",
"disk": "Disque",
"diskHealth": "Santé du disque",
"diskIgnorePath": "Chemin à ignorer pour le disque",
"displayCpuIndex": "Afficher l'index CPU",
"dl2Local": "Télécharger {fileName} localement ?",

View File

@@ -36,6 +36,7 @@
"dirEmpty": "Pastikan dir kosong.",
"disconnected": "Terputus",
"disk": "Disk",
"diskHealth": "Kesehatan disk",
"diskIgnorePath": "Abaikan jalan untuk disk",
"displayCpuIndex": "Tampilkan indeks CPU",
"dl2Local": "Unduh {fileName} ke lokal?",

View File

@@ -36,6 +36,7 @@
"dirEmpty": "フォルダーが空であることを確認してください",
"disconnected": "接続が切断されました",
"disk": "ディスク",
"diskHealth": "ディスクの健康状態",
"diskIgnorePath": "無視されたディスクパス",
"displayCpuIndex": "CPUインデックスを表示する",
"dl2Local": "{fileName}をローカルにダウンロードしますか?",

View File

@@ -36,6 +36,7 @@
"dirEmpty": "Zorg ervoor dat de map leeg is.",
"disconnected": "Verbroken",
"disk": "Schijf",
"diskHealth": "Schijfgezondheid",
"diskIgnorePath": "Pad negeren voor schijf",
"displayCpuIndex": "Toon de CPU-index",
"dl2Local": "Download {fileName} naar lokaal?",

View File

@@ -36,6 +36,7 @@
"dirEmpty": "Certifique-se de que a pasta está vazia",
"disconnected": "Desconectado",
"disk": "Disco",
"diskHealth": "Saúde do disco",
"diskIgnorePath": "Caminhos de disco ignorados",
"displayCpuIndex": "Exiba o índice de CPU",
"dl2Local": "Baixar {fileName} para o local?",

View File

@@ -36,6 +36,7 @@
"dirEmpty": "Пожалуйста, убедитесь, что папка пуста",
"disconnected": "Отключено",
"disk": "Диск",
"diskHealth": "Состояние диска",
"diskIgnorePath": "Игнорировать путь к диску",
"displayCpuIndex": "Отобразить индекс ЦП",
"dl2Local": "Загрузить {fileName} на локальный диск?",

View File

@@ -36,6 +36,7 @@
"dirEmpty": "Klasörün boş olduğundan emin olun.",
"disconnected": "Bağlantı kesildi",
"disk": "Disk",
"diskHealth": "Disk sağlığı",
"diskIgnorePath": "Disk için yok sayılan yol",
"displayCpuIndex": "CPU indeksini göster",
"dl2Local": "{fileName} dosyasını yerel cihaza indir?",

View File

@@ -36,6 +36,7 @@
"dirEmpty": "Переконайтеся, що директорія пуста.",
"disconnected": "Відключено",
"disk": "Диск",
"diskHealth": "Стан диска",
"diskIgnorePath": "Ігнорувати шлях для диска",
"displayCpuIndex": "Відобразити індекс ЦП",
"dl2Local": "Завантажити {fileName} на локальний комп'ютер?",

View File

@@ -36,6 +36,7 @@
"dirEmpty": "请确保文件夹为空",
"disconnected": "连接断开",
"disk": "磁盘",
"diskHealth": "磁盘健康",
"diskIgnorePath": "忽略的磁盘路径",
"displayCpuIndex": "显示 CPU 索引",
"dl2Local": "下载 {fileName} 到本地?",

View File

@@ -36,6 +36,7 @@
"dirEmpty": "請確保資料夾為空",
"disconnected": "連接斷開",
"disk": "磁碟",
"diskHealth": "磁碟健康",
"diskIgnorePath": "忽略的磁碟路徑",
"displayCpuIndex": "顯示 CPU 索引",
"dl2Local": "下載 {fileName} 到本地?",

View File

@@ -11,6 +11,7 @@ import 'package:server_box/data/model/app/shell_func.dart';
import 'package:server_box/data/model/server/battery.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/disk_smart.dart';
import 'package:server_box/data/model/server/dist.dart';
import 'package:server_box/data/model/server/net_speed.dart';
import 'package:server_box/data/model/server/nvdia.dart';
@@ -43,6 +44,7 @@ class _ServerDetailPageState extends State<ServerDetailPage> with SingleTickerPr
_buildSwapView,
_buildGpuView,
_buildDiskView,
_buildDiskSmart,
_buildNetView,
_buildSensors,
_buildTemperature,
@@ -147,7 +149,7 @@ class _ServerDetailPageState extends State<ServerDetailPage> with SingleTickerPr
return ExtendedImage.network(
logoUrl,
cache: true,
height: cons.maxHeight * 0.2,
height: cons.maxWidth * 0.3,
width: cons.maxWidth,
);
},
@@ -563,6 +565,55 @@ class _ServerDetailPageState extends State<ServerDetailPage> with SingleTickerPr
);
}
Widget? _buildDiskSmart(Server si) {
final smarts = si.status.diskSmart;
if (smarts.isEmpty) return null;
return CardX(
child: ExpandTile(
title: Text(l10n.diskHealth),
leading: Icon(ServerDetailCards.smart.icon, size: 17),
childrenPadding: const EdgeInsets.only(bottom: 7),
initiallyExpanded: _getInitExpand(smarts.length),
children: smarts.map(_buildDiskSmartItem).toList(),
),
);
}
Widget _buildDiskSmartItem(DiskSmart smart) {
final isPass = smart.healthy ?? false;
final statusText = isPass ? 'PASS' : 'FAIL';
final statusColor = isPass ? Colors.green : Colors.red;
final statusIcon = isPass
? Icon(Icons.check_circle, color: Colors.green, size: 18)
: Icon(Icons.error, color: Colors.red, size: 18);
return ListTile(
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 0),
leading: statusIcon,
title: Text(smart.device, style: UIs.text13, textScaler: _textFactor),
trailing: Text(
statusText,
style: UIs.text13.copyWith(color: statusColor, fontWeight: FontWeight.bold),
textScaler: _textFactor,
),
subtitle: _buildDiskSmartDetails(smart),
);
}
Widget? _buildDiskSmartDetails(DiskSmart smart) {
final details = <String>[];
if (smart.model != null) details.add(smart.model!);
if (smart.serial != null) details.add('S/N: ${smart.serial}');
if (smart.temperature != null) details.add('${smart.temperature!.toStringAsFixed(1)}°C');
if (smart.powerOnHours != null) details.add('${smart.powerOnHours} hours');
if (details.isEmpty) return null;
return Text(details.join(' | '), style: UIs.text12, textScaler: _textFactor);
}
Widget? _buildNetView(Server si) {
final ss = si.status;
final ns = ss.netSpeed;