mirror of
https://github.com/lollipopkit/flutter_server_box.git
synced 2025-12-17 07:14:28 +01:00
1023 lines
31 KiB
Dart
1023 lines
31 KiB
Dart
import 'package:extended_image/extended_image.dart';
|
|
import 'package:fl_chart/fl_chart.dart';
|
|
import 'package:fl_lib/fl_lib.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_markdown/flutter_markdown.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:icons_plus/icons_plus.dart';
|
|
import 'package:server_box/core/extension/context/locale.dart';
|
|
import 'package:server_box/core/extension/server.dart';
|
|
import 'package:server_box/core/route.dart';
|
|
import 'package:server_box/data/model/app/scripts/cmd_types.dart';
|
|
import 'package:server_box/data/model/app/server_detail_card.dart';
|
|
import 'package:server_box/data/model/server/amd.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/net_speed.dart';
|
|
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' as server_model;
|
|
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/provider/server/single.dart';
|
|
import 'package:server_box/data/res/store.dart';
|
|
import 'package:server_box/view/page/pve.dart';
|
|
import 'package:server_box/view/page/server/edit/edit.dart';
|
|
import 'package:server_box/view/widget/server_func_btns.dart';
|
|
|
|
part 'misc.dart';
|
|
|
|
class ServerDetailPage extends ConsumerStatefulWidget {
|
|
final SpiRequiredArgs args;
|
|
const ServerDetailPage({super.key, required this.args});
|
|
|
|
@override
|
|
ConsumerState<ServerDetailPage> createState() => _ServerDetailPageState();
|
|
|
|
static const route = AppRouteArg(page: ServerDetailPage.new, path: '/servers/detail');
|
|
}
|
|
|
|
class _ServerDetailPageState extends ConsumerState<ServerDetailPage> with SingleTickerProviderStateMixin {
|
|
late final _cardBuildMap = Map.fromIterables(ServerDetailCards.names, [
|
|
_buildAbout,
|
|
_buildCPUView,
|
|
_buildMemView,
|
|
_buildSwapView,
|
|
_buildGpuView,
|
|
_buildDiskView,
|
|
_buildDiskSmart,
|
|
_buildNetView,
|
|
_buildSensors,
|
|
_buildTemperature,
|
|
_buildBatteries,
|
|
_buildPve,
|
|
_buildCustomCmd,
|
|
]);
|
|
|
|
late Size _size;
|
|
final List<String> _cardsOrder = [];
|
|
|
|
final _settings = Stores.setting;
|
|
final _netSortType = ValueNotifier(_NetSortType.device);
|
|
late final _collapse = _settings.collapseUIDefault.fetch();
|
|
late final _textFactor = TextScaler.linear(_settings.textFactor.fetch());
|
|
|
|
@override
|
|
void dispose() {
|
|
super.dispose();
|
|
_netSortType.dispose();
|
|
}
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
_size = MediaQuery.sizeOf(context);
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
final order = _settings.detailCardOrder.fetch();
|
|
order.removeWhere((e) => !ServerDetailCards.names.contains(e));
|
|
_cardsOrder.addAll(order);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final serverState = ref.watch(serverNotifierProvider(widget.args.spi.id));
|
|
if (serverState.client == null) {
|
|
return Scaffold(
|
|
appBar: CustomAppBar(),
|
|
body: Center(child: Text(libL10n.empty)),
|
|
);
|
|
}
|
|
return _buildMainPage(serverState);
|
|
}
|
|
|
|
Widget _buildMainPage(ServerState si) {
|
|
final buildFuncs = !Stores.setting.moveServerFuncs.fetch();
|
|
final logo = _buildLogo(si);
|
|
final children = <Widget>[if (logo != null) logo, if (buildFuncs) ServerFuncBtns(spi: si.spi)];
|
|
for (final card in _cardsOrder) {
|
|
final child = _cardBuildMap[card]?.call(si);
|
|
if (child != null) {
|
|
children.add(child);
|
|
}
|
|
}
|
|
|
|
return Scaffold(
|
|
appBar: _buildAppBar(si),
|
|
body: SafeArea(child: AutoMultiList(children: children)),
|
|
);
|
|
}
|
|
|
|
CustomAppBar _buildAppBar(ServerState si) {
|
|
return CustomAppBar(
|
|
title: Text(
|
|
si.spi.name,
|
|
style: TextStyle(fontSize: 20, color: context.isDark ? Colors.white : Colors.black),
|
|
),
|
|
actions: [
|
|
QrShareBtn(data: si.spi.toJsonString(), tip: si.spi.name, tip2: '${l10n.server} ~ ServerBox'),
|
|
IconButton(
|
|
icon: const Icon(Icons.edit),
|
|
onPressed: () async {
|
|
final delete = await ServerEditPage.route.go(context, args: SpiRequiredArgs(si.spi));
|
|
if (delete == true) {
|
|
context.pop();
|
|
}
|
|
},
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget? _buildLogo(ServerState si) {
|
|
final logoUrl = si.getLogoUrl(context);
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 13),
|
|
child: LayoutBuilder(
|
|
builder: (_, cons) {
|
|
if (logoUrl == null) {
|
|
return UIs.placeholder;
|
|
}
|
|
return ExtendedImage.network(
|
|
logoUrl,
|
|
cache: true,
|
|
height: cons.maxWidth * 0.3,
|
|
width: cons.maxWidth,
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget? _buildAbout(ServerState si) {
|
|
final ss = si.status;
|
|
return ExpandTile(
|
|
key: ValueKey(ss.more.hashCode), // Use hashCode to avoid perf issue
|
|
leading: const Icon(MingCute.information_fill, size: 20),
|
|
initiallyExpanded: _getInitExpand(ss.more.entries.length),
|
|
title: Text(libL10n.about),
|
|
childrenPadding: const EdgeInsets.symmetric(horizontal: 17, vertical: 11),
|
|
children: ss.more.entries
|
|
.map(
|
|
(e) => Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(e.key.i18n, style: UIs.text13, overflow: TextOverflow.ellipsis),
|
|
Text(e.value, style: UIs.text13Grey, overflow: TextOverflow.ellipsis),
|
|
],
|
|
),
|
|
),
|
|
)
|
|
.toList(),
|
|
).cardx;
|
|
}
|
|
|
|
Widget? _buildCPUView(ServerState si) {
|
|
final ss = si.status;
|
|
final percent = ss.cpu.usedPercent(coreIdx: 0).toInt();
|
|
final details = [
|
|
_buildDetailPercent(ss.cpu.user, 'user'),
|
|
UIs.width13,
|
|
_buildDetailPercent(ss.cpu.idle, 'idle'),
|
|
];
|
|
if (ss.system == SystemType.linux) {
|
|
details.addAll([
|
|
UIs.width13,
|
|
_buildDetailPercent(ss.cpu.sys, 'sys'),
|
|
UIs.width13,
|
|
_buildDetailPercent(ss.cpu.iowait, 'io'),
|
|
]);
|
|
}
|
|
|
|
final List<Widget> children = Stores.setting.cpuViewAsProgress.fetch()
|
|
? _buildCPUProgress(ss.cpu)
|
|
: [_buildCPUChart(ss)];
|
|
|
|
if (ss.cpu.brand.isNotEmpty) {
|
|
children.add(
|
|
Column(children: ss.cpu.brand.entries.map(_buildCpuModelItem).toList()).paddingOnly(top: 13),
|
|
);
|
|
}
|
|
|
|
return ExpandTile(
|
|
title: Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: _buildAnimatedText(ValueKey(percent), '$percent%', UIs.text27),
|
|
),
|
|
childrenPadding: const EdgeInsets.symmetric(vertical: 13),
|
|
initiallyExpanded: _getInitExpand(1),
|
|
trailing: Row(mainAxisSize: MainAxisSize.min, children: details),
|
|
children: children,
|
|
).cardx;
|
|
}
|
|
|
|
Widget _buildCpuModelItem(MapEntry<String, int> e) {
|
|
final name = e.key
|
|
.replaceFirst('Intel(R)', '')
|
|
.replaceFirst('AMD', '')
|
|
.replaceFirst('with Radeon Graphics', '');
|
|
final child = Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
LayoutBuilder(
|
|
builder: (_, cons) {
|
|
return ConstrainedBox(
|
|
constraints: BoxConstraints(maxWidth: cons.maxWidth * .7),
|
|
child: Text(name, style: UIs.text13, overflow: TextOverflow.ellipsis, maxLines: 1),
|
|
);
|
|
},
|
|
),
|
|
Text('x ${e.value}', style: UIs.text13Grey, overflow: TextOverflow.clip),
|
|
],
|
|
);
|
|
return child.paddingSymmetric(horizontal: 17);
|
|
}
|
|
|
|
Widget _buildDetailPercent(double percent, String timeType) {
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
Text('${percent.toStringAsFixed(1)}%', style: UIs.text12, textScaler: _textFactor),
|
|
Text(timeType, style: UIs.text12Grey, textScaler: _textFactor),
|
|
],
|
|
);
|
|
}
|
|
|
|
List<Widget> _buildCPUProgress(Cpus cs) {
|
|
const kMaxColumn = 2;
|
|
const kRowThreshold = 4;
|
|
const kCoresCountThreshold = kMaxColumn * kRowThreshold;
|
|
final children = <Widget>[];
|
|
final displayCpuIndexSetting = Stores.setting.displayCpuIndex.fetch();
|
|
|
|
if (cs.coresCount > kCoresCountThreshold) {
|
|
final numCoresToDisplay = cs.coresCount - 1;
|
|
final numRows = (numCoresToDisplay + kMaxColumn - 1) ~/ kMaxColumn;
|
|
|
|
for (var i = 0; i < numRows; i++) {
|
|
final rowChildren = <Widget>[];
|
|
for (var j = 0; j < kMaxColumn; j++) {
|
|
final coreListIndex = i * kMaxColumn + j;
|
|
if (coreListIndex >= numCoresToDisplay) break;
|
|
|
|
final coreNumberOneBased = coreListIndex + 1;
|
|
|
|
if (displayCpuIndexSetting) {
|
|
rowChildren.add(Text('$coreNumberOneBased', style: UIs.text13Grey));
|
|
}
|
|
rowChildren.add(
|
|
Expanded(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 3),
|
|
child: _buildProgress(cs.usedPercent(coreIdx: coreNumberOneBased)),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
if (rowChildren.isNotEmpty) {
|
|
children.add(
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 17),
|
|
child: Row(children: rowChildren.joinWith(UIs.width7).toList()),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
for (var i = 1; i < cs.coresCount; i++) {
|
|
children.add(
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 3, horizontal: 17),
|
|
child: _buildProgress(cs.usedPercent(coreIdx: i)),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
return children;
|
|
}
|
|
|
|
Widget _buildCPUChart(server_model.ServerStatus ss) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 17, vertical: 13),
|
|
child: LayoutBuilder(
|
|
builder: (_, cons) {
|
|
return SizedBox(
|
|
height: 137,
|
|
width: cons.maxWidth,
|
|
child: _buildLineChart(
|
|
ss.cpu.spots,
|
|
//ss.cpu.rangeX,
|
|
tooltipPrefix: 'CPU',
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildProgress(double percent) {
|
|
if (percent > 100) percent = 100;
|
|
final percentWithinOne = percent / 100;
|
|
return LinearProgressIndicator(
|
|
value: percentWithinOne,
|
|
minHeight: 7,
|
|
backgroundColor: UIs.halfAlpha,
|
|
color: UIs.primaryColor,
|
|
);
|
|
}
|
|
|
|
Widget? _buildMemView(ServerState si) {
|
|
final ss = si.status;
|
|
final free = ss.mem.free / ss.mem.total * 100;
|
|
final avail = ss.mem.availPercent * 100;
|
|
final used = ss.mem.usedPercent * 100;
|
|
final usedStr = used.toStringAsFixed(0);
|
|
|
|
final percentW = Row(
|
|
children: [
|
|
_buildAnimatedText(ValueKey(usedStr), '$usedStr%', UIs.text27),
|
|
UIs.width7,
|
|
Text('of ${(ss.mem.total * 1024).bytes2Str}', style: UIs.text13Grey),
|
|
],
|
|
);
|
|
|
|
return Padding(
|
|
padding: UIs.roundRectCardPadding,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
percentW,
|
|
Row(
|
|
children: [
|
|
_buildDetailPercent(free, 'free'),
|
|
UIs.width13,
|
|
_buildDetailPercent(avail, 'avail'),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
UIs.height13,
|
|
_buildProgress(used),
|
|
],
|
|
),
|
|
).cardx;
|
|
}
|
|
|
|
Widget? _buildSwapView(ServerState si) {
|
|
final ss = si.status;
|
|
if (ss.swap.total == 0) return null;
|
|
|
|
final used = ss.swap.usedPercent * 100;
|
|
final cached = ss.swap.cached / ss.swap.total * 100;
|
|
|
|
final percentW = Row(
|
|
children: [
|
|
Text('${used.toStringAsFixed(0)}%', style: UIs.text27),
|
|
UIs.width7,
|
|
Text('of ${(ss.swap.total * 1024).bytes2Str} ', style: UIs.text13Grey),
|
|
],
|
|
);
|
|
|
|
return Padding(
|
|
padding: UIs.roundRectCardPadding,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [percentW, _buildDetailPercent(cached, 'cached')],
|
|
),
|
|
UIs.height13,
|
|
_buildProgress(used),
|
|
],
|
|
),
|
|
).cardx;
|
|
}
|
|
|
|
Widget? _buildGpuView(ServerState si) {
|
|
final ss = si.status;
|
|
final hasNvidia = ss.nvidia != null && ss.nvidia!.isNotEmpty;
|
|
final hasAmd = ss.amd != null && ss.amd!.isNotEmpty;
|
|
|
|
if (!hasNvidia && !hasAmd) return null;
|
|
|
|
final children = <Widget>[];
|
|
|
|
// Add NVIDIA GPUs
|
|
if (hasNvidia) {
|
|
children.addAll(ss.nvidia!.map((e) => _buildNvidiaGpuItem(e)));
|
|
}
|
|
|
|
// Add AMD GPUs
|
|
if (hasAmd) {
|
|
children.addAll(ss.amd!.map((e) => _buildAmdGpuItem(e)));
|
|
}
|
|
|
|
return ExpandTile(
|
|
title: const Text('GPU'),
|
|
leading: const Icon(Icons.memory, size: 17),
|
|
initiallyExpanded: _getInitExpand(children.length, 3),
|
|
children: children,
|
|
).cardx;
|
|
}
|
|
|
|
Widget _buildNvidiaGpuItem(NvidiaSmiItem item) {
|
|
final mem = item.memory;
|
|
return ListTile(
|
|
title: Text(item.name, style: UIs.text13),
|
|
leading: Text(
|
|
'${item.percent}%\n${item.temp} °C',
|
|
style: UIs.text12Grey,
|
|
textScaler: _textFactor,
|
|
textAlign: TextAlign.center,
|
|
),
|
|
subtitle: Text(
|
|
'${item.power} - FAN ${item.fanSpeed}%\n${mem.used} / ${mem.total} ${mem.unit}',
|
|
style: UIs.text12Grey,
|
|
textScaler: _textFactor,
|
|
),
|
|
contentPadding: const EdgeInsets.only(left: 17, right: 17),
|
|
trailing: Row(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
IconButton(
|
|
onPressed: () => _onTapNvidiaGpuItem(item),
|
|
icon: const Icon(Icons.info_outline, size: 17),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildAmdGpuItem(AmdSmiItem item) {
|
|
final mem = item.memory;
|
|
return ListTile(
|
|
title: Text('${item.name} (AMD)', style: UIs.text13),
|
|
leading: Text(
|
|
'${item.utilization}%\n${item.temp} °C',
|
|
style: UIs.text12Grey,
|
|
textScaler: _textFactor,
|
|
textAlign: TextAlign.center,
|
|
),
|
|
subtitle: Text(
|
|
'${item.power} - FAN ${item.fanSpeed} RPM\n${item.clockSpeed} MHz\n${mem.used} / ${mem.total} ${mem.unit}',
|
|
style: UIs.text12Grey,
|
|
textScaler: _textFactor,
|
|
),
|
|
contentPadding: const EdgeInsets.only(left: 17, right: 17),
|
|
trailing: Row(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
IconButton(onPressed: () => _onTapAmdGpuItem(item), icon: const Icon(Icons.info_outline, size: 17)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildGpuProcessItem(NvidiaSmiMemProcess process) {
|
|
return ListTile(
|
|
title: Text(
|
|
process.name,
|
|
style: UIs.text12,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
textScaler: _textFactor,
|
|
),
|
|
subtitle: Text(
|
|
'PID: ${process.pid} - ${process.memory} MiB',
|
|
style: UIs.text12Grey,
|
|
textScaler: _textFactor,
|
|
),
|
|
trailing: InkWell(
|
|
onTap: () => _onTapGpuProcessItem(process),
|
|
child: const Icon(Icons.info_outline, size: 17),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildAmdGpuProcessItem(AmdSmiMemProcess process) {
|
|
return ListTile(
|
|
title: Text(
|
|
process.name,
|
|
style: UIs.text12,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
textScaler: _textFactor,
|
|
),
|
|
subtitle: Text(
|
|
'PID: ${process.pid} - ${process.memory} MiB',
|
|
style: UIs.text12Grey,
|
|
textScaler: _textFactor,
|
|
),
|
|
trailing: InkWell(
|
|
onTap: () => _onTapAmdGpuProcessItem(process),
|
|
child: const Icon(Icons.info_outline, size: 17),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget? _buildDiskView(ServerState si) {
|
|
final ss = si.status;
|
|
final children = <Widget>[];
|
|
|
|
// Create widgets for each top-level disk
|
|
for (int idx = 0; idx < ss.disk.length; idx++) {
|
|
final disk = ss.disk[idx];
|
|
children.add(_buildDiskItemWithHierarchy(disk, ss, 0));
|
|
}
|
|
|
|
if (children.isEmpty) return null;
|
|
|
|
return ExpandTile(
|
|
title: Text(l10n.disk),
|
|
childrenPadding: const EdgeInsets.only(bottom: 7),
|
|
leading: Icon(ServerDetailCards.disk.icon, size: 17),
|
|
initiallyExpanded: _getInitExpand(children.length),
|
|
children: children,
|
|
).cardx;
|
|
}
|
|
|
|
Widget _buildDiskItemWithHierarchy(Disk disk, server_model.ServerStatus ss, int depth) {
|
|
// Create a list to hold this disk and its children
|
|
final items = <Widget>[];
|
|
|
|
// Add the current disk
|
|
items.add(_buildDiskItem(disk, ss, depth));
|
|
|
|
// Recursively add child disks with increased indentation
|
|
if (disk.children.isNotEmpty) {
|
|
for (final childDisk in disk.children) {
|
|
items.add(_buildDiskItemWithHierarchy(childDisk, ss, depth + 1));
|
|
}
|
|
}
|
|
|
|
return Column(children: items);
|
|
}
|
|
|
|
Widget _buildDiskItem(Disk disk, server_model.ServerStatus ss, int depth) {
|
|
final (read, write) = ss.diskIO.getSpeed(disk.path);
|
|
final text = () {
|
|
final use = '${l10n.used} ${disk.used.kb2Str} / ${disk.size.kb2Str}';
|
|
if (read == null || write == null) return use;
|
|
return '$use\n${l10n.read} $read | ${l10n.write} $write';
|
|
}();
|
|
|
|
return Padding(
|
|
padding: EdgeInsets.only(
|
|
left: 17.0 + (depth * 15.0), // Indent based on depth
|
|
right: 17.0,
|
|
top: 5.0,
|
|
bottom: 5.0,
|
|
),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
disk.mount.isEmpty ? disk.path : '${disk.path} (${disk.mount})',
|
|
style: UIs.text12,
|
|
textScaler: _textFactor,
|
|
),
|
|
Text(text, style: UIs.text12Grey, textScaler: _textFactor),
|
|
],
|
|
),
|
|
),
|
|
if (disk.size > BigInt.zero)
|
|
SizedBox(
|
|
height: 41,
|
|
width: 41,
|
|
child: Stack(
|
|
alignment: Alignment.center,
|
|
children: [
|
|
CircularProgressIndicator(
|
|
value: disk.usedPercent / 100,
|
|
strokeWidth: 5,
|
|
backgroundColor: UIs.halfAlpha,
|
|
color: UIs.primaryColor,
|
|
),
|
|
Text('${disk.usedPercent}%', style: UIs.text12Grey),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget? _buildDiskSmart(ServerState 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 healthStatus = _getDiskHealthStatus(smart);
|
|
|
|
return ListTile(
|
|
dense: true,
|
|
leading: healthStatus.icon,
|
|
title: Text(smart.device, style: UIs.text13, textScaler: _textFactor),
|
|
trailing: Text(
|
|
healthStatus.text,
|
|
style: UIs.text13.copyWith(fontWeight: FontWeight.bold),
|
|
textScaler: _textFactor,
|
|
),
|
|
subtitle: _buildDiskSmartDetails(smart),
|
|
onTap: () => _onTapDiskSmartItem(smart),
|
|
);
|
|
}
|
|
|
|
({String text, Color color, Widget icon}) _getDiskHealthStatus(DiskSmart smart) {
|
|
if (smart.healthy == null) {
|
|
return (
|
|
text: libL10n.unknown,
|
|
color: Colors.orange,
|
|
icon: const Icon(Icons.help_outline, color: Colors.orange, size: 18),
|
|
);
|
|
} else if (smart.healthy!) {
|
|
return (
|
|
text: 'PASS',
|
|
color: Colors.green,
|
|
icon: const Icon(Icons.check_circle, color: Colors.green, size: 18),
|
|
);
|
|
} else {
|
|
return (text: 'FAIL', color: Colors.red, icon: const Icon(Icons.error, color: Colors.red, size: 18));
|
|
}
|
|
}
|
|
|
|
Widget? _buildDiskSmartDetails(DiskSmart smart) {
|
|
final details = <String>[];
|
|
|
|
if (smart.model != null) {
|
|
details.add(smart.model!);
|
|
}
|
|
|
|
if (smart.temperature != null) {
|
|
details.add('${smart.temperature!.toStringAsFixed(1)}°C');
|
|
}
|
|
|
|
if (smart.powerOnHours != null) {
|
|
final hours = smart.powerOnHours!;
|
|
details.add('$hours ${libL10n.hour}');
|
|
}
|
|
|
|
if (smart.ssdLifeLeft != null) {
|
|
details.add('Life left: ${smart.ssdLifeLeft}%');
|
|
}
|
|
|
|
if (details.isEmpty) return null;
|
|
|
|
return Text(
|
|
details.join(' | '),
|
|
style: UIs.text12Grey,
|
|
textScaler: _textFactor,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
);
|
|
}
|
|
|
|
void _onTapDiskSmartItem(DiskSmart smart) {
|
|
final details = <String>[];
|
|
|
|
if (smart.model != null) details.add('Model: ${smart.model}');
|
|
if (smart.serial != null) details.add('Serial: ${smart.serial}');
|
|
if (smart.temperature != null) {
|
|
details.add('Temperature: ${smart.temperature!.toStringAsFixed(1)}°C');
|
|
}
|
|
|
|
if (smart.powerOnHours != null) {
|
|
details.add('Power On: ${smart.powerOnHours} ${libL10n.hour}');
|
|
}
|
|
if (smart.powerCycleCount != null) {
|
|
details.add('Power Cycle: ${smart.powerCycleCount}');
|
|
}
|
|
|
|
if (smart.ssdLifeLeft != null) {
|
|
details.add('Life Left: ${smart.ssdLifeLeft}%');
|
|
}
|
|
if (smart.lifetimeWritesGiB != null) {
|
|
details.add('Lifetime Write: ${smart.lifetimeWritesGiB} GiB');
|
|
}
|
|
if (smart.lifetimeReadsGiB != null) {
|
|
details.add('Lifetime Read: ${smart.lifetimeReadsGiB} GiB');
|
|
}
|
|
if (smart.averageEraseCount != null) {
|
|
details.add('Avg. Erase: ${smart.averageEraseCount}');
|
|
}
|
|
if (smart.unsafeShutdownCount != null) {
|
|
details.add('Unsafe Shutdown: ${smart.unsafeShutdownCount}');
|
|
}
|
|
|
|
final criticalAttrs = [
|
|
'Reallocated_Sector_Ct',
|
|
'Current_Pending_Sector',
|
|
'Offline_Uncorrectable',
|
|
'UDMA_CRC_Error_Count',
|
|
];
|
|
|
|
for (final attrName in criticalAttrs) {
|
|
final attr = smart.getAttribute(attrName);
|
|
if (attr != null && attr.rawValue != null) {
|
|
final value = attr.rawValue.toString();
|
|
details.add('${attrName.replaceAll('_', ' ')}: $value');
|
|
}
|
|
}
|
|
|
|
if (details.isEmpty) {
|
|
return;
|
|
}
|
|
|
|
final markdown = details.join('\n\n- ');
|
|
context.showRoundDialog(
|
|
title: smart.device,
|
|
child: MarkdownBody(
|
|
data: '- $markdown',
|
|
selectable: true,
|
|
styleSheet: MarkdownStyleSheet.fromTheme(
|
|
Theme.of(context),
|
|
).copyWith(p: UIs.text13Grey, h2: UIs.text15),
|
|
),
|
|
actions: Btnx.oks,
|
|
);
|
|
}
|
|
|
|
Widget? _buildNetView(ServerState si) {
|
|
final ss = si.status;
|
|
final ns = ss.netSpeed;
|
|
final children = <Widget>[];
|
|
final devices = ns.devices;
|
|
if (devices.isEmpty) return null;
|
|
|
|
devices.sort(_netSortType.value.getSortFunc(ns));
|
|
children.addAll(devices.map((e) => _buildNetSpeedItem(ns, e)));
|
|
|
|
return ExpandTile(
|
|
leading: Icon(ServerDetailCards.net.icon, size: 17),
|
|
title: Row(
|
|
children: [
|
|
Text(l10n.net),
|
|
UIs.width13,
|
|
_netSortType.listenVal(
|
|
(val) => InkWell(
|
|
onTap: () => _netSortType.value = val.next,
|
|
child: AnimatedSwitcher(
|
|
duration: const Duration(milliseconds: 377),
|
|
transitionBuilder: (child, animation) => FadeTransition(opacity: animation, child: child),
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.sort, size: 17),
|
|
UIs.width7,
|
|
Text(val.name, style: UIs.text13Grey),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
childrenPadding: const EdgeInsets.only(bottom: 11),
|
|
initiallyExpanded: _getInitExpand(children.length),
|
|
children: children,
|
|
).cardx;
|
|
}
|
|
|
|
Widget _buildNetSpeedItem(NetSpeed ns, String device) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 7, horizontal: 17),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
device,
|
|
style: UIs.text12,
|
|
textScaler: _textFactor,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.fade,
|
|
textAlign: TextAlign.left,
|
|
),
|
|
Text(
|
|
'${ns.sizeIn(device: device)} | ${ns.sizeOut(device: device)}',
|
|
style: UIs.text12Grey,
|
|
textScaler: _textFactor,
|
|
),
|
|
],
|
|
),
|
|
SizedBox(
|
|
width: 170,
|
|
child: Text(
|
|
'${ns.speedOut(device: device)} ↑\n${ns.speedIn(device: device)} ↓',
|
|
textAlign: TextAlign.end,
|
|
style: UIs.text13Grey,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget? _buildTemperature(ServerState si) {
|
|
final ss = si.status;
|
|
if (ss.temps.isEmpty) return null;
|
|
|
|
return CardX(
|
|
child: ExpandTile(
|
|
title: Text(l10n.temperature),
|
|
leading: const Icon(Icons.ac_unit, size: 20),
|
|
initiallyExpanded: _getInitExpand(ss.temps.devices.length),
|
|
childrenPadding: const EdgeInsets.only(bottom: 7),
|
|
children: ss.temps.devices.map((key) => _buildTemperatureItem(key, ss.temps.get(key))).toList(),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildTemperatureItem(String key, double? val) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(left: 3, right: 17, top: 5, bottom: 5),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Btn.text(
|
|
text: key,
|
|
textStyle: UIs.text15,
|
|
onTap: () => _onTapTemperatureItem(key),
|
|
).paddingSymmetric(horizontal: 5),
|
|
Text('${val?.toStringAsFixed(1)}°C', style: UIs.text13Grey),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget? _buildBatteries(ServerState si) {
|
|
final ss = si.status;
|
|
if (ss.batteries.isEmpty) return null;
|
|
|
|
return CardX(
|
|
child: ExpandTile(
|
|
title: Text(l10n.battery),
|
|
leading: const Icon(Icons.battery_charging_full, size: 17),
|
|
childrenPadding: const EdgeInsets.only(bottom: 7),
|
|
initiallyExpanded: _getInitExpand(ss.batteries.length, 2),
|
|
children: ss.batteries.map(_buildBatteryItem).toList(),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildBatteryItem(Battery battery) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 17, vertical: 5),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text('${battery.name}', style: UIs.text15),
|
|
Text('${battery.status.name} - ${battery.cycle}', style: UIs.text13Grey),
|
|
],
|
|
),
|
|
Text('${battery.percent?.toStringAsFixed(0)}%', style: UIs.text13Grey),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget? _buildSensors(ServerState si) {
|
|
final ss = si.status;
|
|
if (ss.sensors.isEmpty) return UIs.placeholder;
|
|
return CardX(
|
|
child: ExpandTile(
|
|
title: Text(l10n.sensors),
|
|
leading: const Icon(Icons.thermostat, size: 17),
|
|
childrenPadding: const EdgeInsets.only(bottom: 7),
|
|
initiallyExpanded: _getInitExpand(ss.sensors.length, 2),
|
|
children: ss.sensors.map(_buildSensorItem).toList(),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildSensorItem(SensorItem si) {
|
|
if (si.summary == null) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 17, vertical: 7),
|
|
child: Text(si.device),
|
|
);
|
|
}
|
|
|
|
final itemW = Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Text(si.device, style: UIs.text15),
|
|
UIs.width7,
|
|
Text('(${si.adapter.raw})', style: UIs.text13Grey),
|
|
],
|
|
),
|
|
Text(si.summary ?? '', style: UIs.text13Grey),
|
|
],
|
|
).expanded();
|
|
|
|
return InkWell(
|
|
onTap: () => _onTapSensorItem(si),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 17, vertical: 7),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
itemW,
|
|
UIs.width7,
|
|
const Icon(Icons.keyboard_arrow_right, color: Colors.grey),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget? _buildPve(ServerState si) {
|
|
final addr = si.spi.custom?.pveAddr;
|
|
if (addr == null || addr.isEmpty) return null;
|
|
return CardX(
|
|
child: ListTile(
|
|
title: const Text('PVE'),
|
|
leading: const Icon(FontAwesome.server_solid, size: 17),
|
|
trailing: const Icon(Icons.chevron_right),
|
|
onTap: () => PvePage.route.go(context, PvePageArgs(spi: si.spi)),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget? _buildCustomCmd(ServerState si) {
|
|
final ss = si.status;
|
|
if (ss.customCmds.isEmpty) return null;
|
|
return CardX(
|
|
child: ExpandTile(
|
|
leading: const Icon(MingCute.command_line, size: 17),
|
|
title: Text(l10n.customCmd),
|
|
initiallyExpanded: _getInitExpand(ss.customCmds.length),
|
|
children: ss.customCmds.entries.map(_buildCustomCmdItem).toList(),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildCustomCmdItem(MapEntry<String, String> cmd) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 17, vertical: 7),
|
|
child: KvRow(
|
|
k: cmd.key,
|
|
v: cmd.value,
|
|
vBuilder: () {
|
|
if (!cmd.value.contains('\n')) return null;
|
|
return GestureDetector(
|
|
onTap: () => _onTapCustomItem(cmd),
|
|
child: const Icon(Icons.info_outline, size: 17, color: Colors.grey),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildAnimatedText(Key key, String text, TextStyle style) {
|
|
return AnimatedSwitcher(
|
|
duration: const Duration(milliseconds: 277),
|
|
child: Text(key: key, text, style: style, textScaler: _textFactor),
|
|
transitionBuilder: (child, animation) => FadeTransition(opacity: animation, child: child),
|
|
);
|
|
}
|
|
}
|