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;

View File

@@ -258,6 +258,14 @@ packages:
url: "https://github.com/lollipopkit/circle_chart"
source: git
version: "0.0.3"
cli_config:
dependency: transitive
description:
name: cli_config
sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec
url: "https://pub.dev"
source: hosted
version: "0.2.0"
clock:
dependency: transitive
description:
@@ -299,6 +307,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.2"
coverage:
dependency: transitive
description:
name: coverage
sha256: "4b8701e48a58f7712492c9b1f7ba0bb9d525644dd66d023b62e1fc8cdb560c8a"
url: "https://pub.dev"
source: hosted
version: "1.14.0"
cross_file:
dependency: transitive
description:
@@ -939,6 +955,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.0"
node_preamble:
dependency: transitive
description:
name: node_preamble
sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
package_config:
dependency: transitive
description:
@@ -1340,6 +1364,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.2"
shelf_packages_handler:
dependency: transitive
description:
name: shelf_packages_handler
sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
shelf_static:
dependency: transitive
description:
name: shelf_static
sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3
url: "https://pub.dev"
source: hosted
version: "1.1.3"
shelf_web_socket:
dependency: transitive
description:
@@ -1369,6 +1409,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.5"
source_map_stack_trace:
dependency: transitive
description:
name: source_map_stack_trace
sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b
url: "https://pub.dev"
source: hosted
version: "2.1.2"
source_maps:
dependency: transitive
description:
name: source_maps
sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812"
url: "https://pub.dev"
source: hosted
version: "0.10.13"
source_span:
dependency: transitive
description:
@@ -1433,6 +1489,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test:
dependency: "direct dev"
description:
name: test
sha256: "301b213cd241ca982e9ba50266bd3f5bd1ea33f1455554c5abb85d1be0e2d87e"
url: "https://pub.dev"
source: hosted
version: "1.25.15"
test_api:
dependency: transitive
description:
@@ -1441,6 +1505,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.4"
test_core:
dependency: transitive
description:
name: test_core
sha256: "84d17c3486c8dfdbe5e12a50c8ae176d15e2a771b96909a9442b40173649ccaa"
url: "https://pub.dev"
source: hosted
version: "0.6.8"
timing:
dependency: transitive
description:
@@ -1658,6 +1730,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.2"
webkit_inspection_protocol:
dependency: transitive
description:
name: webkit_inspection_protocol
sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
win32:
dependency: transitive
description:

View File

@@ -88,6 +88,7 @@ dev_dependencies:
json_serializable: ^6.8.0
freezed: ^2.5.7
riverpod_generator: ^2.6.3
test: ^1.24.0
flutter_test:
sdk: flutter
fl_build:

550
test/disk_smart_test.dart Normal file
View File

@@ -0,0 +1,550 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:server_box/data/model/server/disk_smart.dart';
const _raw = '''
{
"json_format_version": [
1,
0
],
"smartctl": {
"version": [
7,
4
],
"pre_release": false,
"svn_revision": "5530",
"platform_info": "x86_64-linux-6.6.58-rt45-intel-ese-standard-lts-rt",
"build_info": "(local build)",
"argv": [
"smartctl",
"-A",
"-j",
"/dev/sda"
],
"drive_database_version": {
"string": "7.3/5528"
},
"exit_status": 0
},
"local_time": {
"time_t": 1749074092,
"asctime": "Thu Jun 5 05:54:52 2025 CST"
},
"device": {
"name": "/dev/sda",
"info_name": "/dev/sda [SAT]",
"type": "sat",
"protocol": "ATA"
},
"ata_smart_attributes": {
"revision": 16,
"table": [
{
"id": 9,
"name": "Power_On_Hours",
"value": 100,
"worst": 100,
"thresh": 0,
"when_failed": "",
"flags": {
"value": 18,
"string": "-O--C- ",
"prefailure": false,
"updated_online": true,
"performance": false,
"error_rate": false,
"event_count": true,
"auto_keep": false
},
"raw": {
"value": 17472,
"string": "17472"
}
},
{
"id": 12,
"name": "Power_Cycle_Count",
"value": 100,
"worst": 100,
"thresh": 0,
"when_failed": "",
"flags": {
"value": 18,
"string": "-O--C- ",
"prefailure": false,
"updated_online": true,
"performance": false,
"error_rate": false,
"event_count": true,
"auto_keep": false
},
"raw": {
"value": 1948,
"string": "1948"
}
},
{
"id": 167,
"name": "Write_Protect_Mode",
"value": 100,
"worst": 100,
"thresh": 0,
"when_failed": "",
"flags": {
"value": 34,
"string": "-O---K ",
"prefailure": false,
"updated_online": true,
"performance": false,
"error_rate": false,
"event_count": false,
"auto_keep": true
},
"raw": {
"value": 0,
"string": "0"
}
},
{
"id": 168,
"name": "SATA_Phy_Error_Count",
"value": 100,
"worst": 100,
"thresh": 0,
"when_failed": "",
"flags": {
"value": 18,
"string": "-O--C- ",
"prefailure": false,
"updated_online": true,
"performance": false,
"error_rate": false,
"event_count": true,
"auto_keep": false
},
"raw": {
"value": 0,
"string": "0"
}
},
{
"id": 172,
"name": "Erase_Fail_Count",
"value": 100,
"worst": 100,
"thresh": 0,
"when_failed": "",
"flags": {
"value": 50,
"string": "-O--CK ",
"prefailure": false,
"updated_online": true,
"performance": false,
"error_rate": false,
"event_count": true,
"auto_keep": true
},
"raw": {
"value": 0,
"string": "0"
}
},
{
"id": 173,
"name": "MaxAvgErase_Ct",
"value": 100,
"worst": 100,
"thresh": 0,
"when_failed": "",
"flags": {
"value": 0,
"string": "------ ",
"prefailure": false,
"updated_online": false,
"performance": false,
"error_rate": false,
"event_count": false,
"auto_keep": false
},
"raw": {
"value": 8257696,
"string": "160 (Average 126)"
}
},
{
"id": 181,
"name": "Program_Fail_Count",
"value": 100,
"worst": 100,
"thresh": 0,
"when_failed": "",
"flags": {
"value": 18,
"string": "-O--C- ",
"prefailure": false,
"updated_online": true,
"performance": false,
"error_rate": false,
"event_count": true,
"auto_keep": false
},
"raw": {
"value": 0,
"string": "0"
}
},
{
"id": 187,
"name": "Reported_Uncorrect",
"value": 100,
"worst": 100,
"thresh": 0,
"when_failed": "",
"flags": {
"value": 50,
"string": "-O--CK ",
"prefailure": false,
"updated_online": true,
"performance": false,
"error_rate": false,
"event_count": true,
"auto_keep": true
},
"raw": {
"value": 0,
"string": "0"
}
},
{
"id": 192,
"name": "Unsafe_Shutdown_Count",
"value": 100,
"worst": 100,
"thresh": 0,
"when_failed": "",
"flags": {
"value": 18,
"string": "-O--C- ",
"prefailure": false,
"updated_online": true,
"performance": false,
"error_rate": false,
"event_count": true,
"auto_keep": false
},
"raw": {
"value": 141,
"string": "141"
}
},
{
"id": 194,
"name": "Temperature_Celsius",
"value": 65,
"worst": 39,
"thresh": 0,
"when_failed": "",
"flags": {
"value": 35,
"string": "PO---K ",
"prefailure": true,
"updated_online": true,
"performance": false,
"error_rate": false,
"event_count": false,
"auto_keep": true
},
"raw": {
"value": 261993922595,
"string": "35 (Min/Max 14/61)"
}
},
{
"id": 196,
"name": "Reallocated_Event_Count",
"value": 100,
"worst": 100,
"thresh": 0,
"when_failed": "",
"flags": {
"value": 0,
"string": "------ ",
"prefailure": false,
"updated_online": false,
"performance": false,
"error_rate": false,
"event_count": false,
"auto_keep": false
},
"raw": {
"value": 0,
"string": "0"
}
},
{
"id": 218,
"name": "CRC_Error_Count",
"value": 100,
"worst": 100,
"thresh": 0,
"when_failed": "",
"flags": {
"value": 0,
"string": "------ ",
"prefailure": false,
"updated_online": false,
"performance": false,
"error_rate": false,
"event_count": false,
"auto_keep": false
},
"raw": {
"value": 0,
"string": "0"
}
},
{
"id": 231,
"name": "SSD_Life_Left",
"value": 100,
"worst": 100,
"thresh": 0,
"when_failed": "",
"flags": {
"value": 19,
"string": "PO--C- ",
"prefailure": true,
"updated_online": true,
"performance": false,
"error_rate": false,
"event_count": true,
"auto_keep": false
},
"raw": {
"value": 93,
"string": "93"
}
},
{
"id": 233,
"name": "Flash_Writes_GiB",
"value": 100,
"worst": 100,
"thresh": 0,
"when_failed": "",
"flags": {
"value": 19,
"string": "PO--C- ",
"prefailure": true,
"updated_online": true,
"performance": false,
"error_rate": false,
"event_count": true,
"auto_keep": false
},
"raw": {
"value": 17618,
"string": "17618"
}
},
{
"id": 241,
"name": "Lifetime_Writes_GiB",
"value": 100,
"worst": 100,
"thresh": 0,
"when_failed": "",
"flags": {
"value": 18,
"string": "-O--C- ",
"prefailure": false,
"updated_online": true,
"performance": false,
"error_rate": false,
"event_count": true,
"auto_keep": false
},
"raw": {
"value": 11520,
"string": "11520"
}
},
{
"id": 242,
"name": "Lifetime_Reads_GiB",
"value": 100,
"worst": 100,
"thresh": 0,
"when_failed": "",
"flags": {
"value": 18,
"string": "-O--C- ",
"prefailure": false,
"updated_online": true,
"performance": false,
"error_rate": false,
"event_count": true,
"auto_keep": false
},
"raw": {
"value": 12361,
"string": "12361"
}
},
{
"id": 244,
"name": "Average_Erase_Count",
"value": 100,
"worst": 100,
"thresh": 0,
"when_failed": "",
"flags": {
"value": 0,
"string": "------ ",
"prefailure": false,
"updated_online": false,
"performance": false,
"error_rate": false,
"event_count": false,
"auto_keep": false
},
"raw": {
"value": 126,
"string": "126"
}
},
{
"id": 245,
"name": "Max_Erase_Count",
"value": 100,
"worst": 100,
"thresh": 0,
"when_failed": "",
"flags": {
"value": 0,
"string": "------ ",
"prefailure": false,
"updated_online": false,
"performance": false,
"error_rate": false,
"event_count": false,
"auto_keep": false
},
"raw": {
"value": 160,
"string": "160"
}
},
{
"id": 246,
"name": "Total_Erase_Count",
"value": 100,
"worst": 100,
"thresh": 0,
"when_failed": "",
"flags": {
"value": 0,
"string": "------ ",
"prefailure": false,
"updated_online": false,
"performance": false,
"error_rate": false,
"event_count": false,
"auto_keep": false
},
"raw": {
"value": 2749648,
"string": "2749648"
}
}
]
},
"power_on_time": {
"hours": 17472
},
"power_cycle_count": 1948,
"temperature": {
"current": 35
}
}''';
void main() {
group('DiskSmart', () {
late DiskSmart diskSmart;
setUp(() {
final parsedResults = DiskSmart.parse(_raw);
expect(parsedResults.length, 1, reason: 'Should parse one disk entry');
diskSmart = parsedResults.first;
});
test('parses basic device info correctly', () {
expect(diskSmart.device, '/dev/sda');
expect(diskSmart.temperature, 35);
expect(diskSmart.powerOnHours, 17472);
expect(diskSmart.powerCycleCount, 1948);
});
test('has correct SMART attributes', () {
expect(diskSmart.smartAttributes.length, isNot(0));
final tempAttr = diskSmart.getAttribute('Temperature_Celsius');
expect(tempAttr, isNotNull);
expect(tempAttr?.value, 65);
expect(tempAttr?.worst, 39);
expect(tempAttr?.rawString, '35 (Min/Max 14/61)');
final powerOnAttr = diskSmart.getAttribute('Power_On_Hours');
expect(powerOnAttr?.rawValue, 17472);
// Test non-existent attribute
expect(diskSmart.getAttribute('NonExistent'), isNull);
});
test('extracts attribute flags correctly', () {
final tempAttr = diskSmart.getAttribute('Temperature_Celsius');
expect(tempAttr?.flags.prefailure, isTrue);
expect(tempAttr?.flags.updatedOnline, isTrue);
expect(tempAttr?.flags.performance, isFalse);
final lifeLeftAttr = diskSmart.getAttribute('SSD_Life_Left');
expect(lifeLeftAttr?.flags.prefailure, isTrue);
expect(lifeLeftAttr?.flags.eventCount, isTrue);
});
test('calculates SSD health metrics correctly', () {
expect(diskSmart.ssdLifeLeft, 93);
expect(diskSmart.lifetimeWritesGiB, 11520);
expect(diskSmart.lifetimeReadsGiB, 12361);
expect(diskSmart.unsafeShutdownCount, 141);
expect(diskSmart.averageEraseCount, 126);
expect(diskSmart.maxEraseCount, 160);
});
test('toMap() converts all important data', () {
final map = diskSmart.toJson();
expect(map['device'], '/dev/sda');
expect(map['temperature'], 35);
expect(map['powerOnHours'], 17472);
expect(map['powerCycleCount'], 1948);
expect(map['smartAttributes'], isA<Map>());
});
});
group('DiskSmart parsing edge cases', () {
test('handles empty input', () {
final results = DiskSmart.parse('');
expect(results, isEmpty);
});
test('handles malformed JSON gracefully', () {
final results = DiskSmart.parse('{not valid json}');
expect(results, isEmpty);
});
test('handles multiple disk data', () {
final results = DiskSmart.parse('$_raw\n\n$_raw');
expect(results.length, 2);
});
});
}