From 7edef87a4f65350fc1b231abdba96cb6defb4994 Mon Sep 17 00:00:00 2001 From: lollipopkit Date: Tue, 19 Mar 2024 01:26:53 -0600 Subject: [PATCH] opt.: pve dashboard (#307) --- lib/data/model/server/pve.dart | 100 +++++++++- lib/data/provider/pve.dart | 108 +++++------ lib/l10n/app_de.arb | 2 + lib/l10n/app_en.arb | 2 + lib/l10n/app_es.arb | 2 + lib/l10n/app_fr.arb | 2 + lib/l10n/app_id.arb | 2 + lib/l10n/app_ja.arb | 2 + lib/l10n/app_pt.arb | 2 + lib/l10n/app_ru.arb | 2 + lib/l10n/app_zh.arb | 2 + lib/l10n/app_zh_tw.arb | 2 + lib/view/page/pve.dart | 325 ++++++++++++++++----------------- lib/view/widget/row.dart | 25 +++ 14 files changed, 338 insertions(+), 240 deletions(-) create mode 100644 lib/view/widget/row.dart diff --git a/lib/data/model/server/pve.dart b/lib/data/model/server/pve.dart index 917a5638..a78327be 100644 --- a/lib/data/model/server/pve.dart +++ b/lib/data/model/server/pve.dart @@ -1,6 +1,7 @@ import 'package:toolbox/core/extension/context/locale.dart'; import 'package:toolbox/core/extension/duration.dart'; import 'package:toolbox/core/extension/numx.dart'; +import 'package:toolbox/core/extension/order.dart'; enum PveResType { lxc, @@ -61,8 +62,9 @@ sealed class PveResIface { abstract interface class PveCtrlIface { String get node; String get id; - bool get isRunning; + bool get available; String get summary; + String get name; } final class PveLxc extends PveResIface implements PveCtrlIface { @@ -73,6 +75,7 @@ final class PveLxc extends PveResIface implements PveCtrlIface { final int vmid; @override final String node; + @override final String name; @override final String status; @@ -131,11 +134,11 @@ final class PveLxc extends PveResIface implements PveCtrlIface { } @override - bool get isRunning => status == 'running'; + bool get available => status == 'running'; @override String get summary { - if (isRunning) { + if (available) { return uptime.secondsToDuration().toStr; } return l10n.stopped; @@ -150,6 +153,7 @@ final class PveQemu extends PveResIface implements PveCtrlIface { final int vmid; @override final String node; + @override final String name; @override final String status; @@ -208,11 +212,11 @@ final class PveQemu extends PveResIface implements PveCtrlIface { } @override - bool get isRunning => status == 'running'; + bool get available => status == 'running'; @override String get summary { - if (isRunning) { + if (available) { return uptime.secondsToDuration().toStr; } return l10n.stopped; @@ -269,12 +273,13 @@ final class PveNode extends PveResIface { } } -final class PveStorage extends PveResIface { +final class PveStorage extends PveResIface implements PveCtrlIface { @override final String id; @override final PveResType type; final String storage; + @override final String node; @override final String status; @@ -311,14 +316,29 @@ final class PveStorage extends PveResIface { maxdisk: json['maxdisk'], ); } + + @override + bool get available => status == 'available'; + + @override + String get name => storage; + + @override + String get summary { + if (available) { + return '${l10n.used}: ${disk.bytes2Str} / ${l10n.total}: ${maxdisk.bytes2Str}'; + } + return l10n.notAvailable; + } } -final class PveSdn extends PveResIface { +final class PveSdn extends PveResIface implements PveCtrlIface { @override final String id; @override final PveResType type; final String sdn; + @override final String node; @override final String status; @@ -340,6 +360,15 @@ final class PveSdn extends PveResIface { status: json['status'], ); } + + @override + bool get available => status == 'ok'; + + @override + String get name => sdn; + + @override + String get summary => available ? status : l10n.notAvailable; } final class PveRes { @@ -357,6 +386,8 @@ final class PveRes { required this.sdns, }); + bool get onlyOneNode => nodes.length == 1; + int get length => qemus.length + lxcs.length + nodes.length + storages.length + sdns.length; @@ -379,4 +410,59 @@ final class PveRes { index -= storages.length; return sdns[index]; } + + static Future parse((List list, PveRes? old) val) async { + final (list, old) = val; + final items = list.map((e) => PveResIface.fromJson(e)).toList(); + final Order qemus = []; + final Order lxcs = []; + final Order nodes = []; + final Order storages = []; + final Order sdns = []; + for (final item in items) { + switch (item.type) { + case PveResType.lxc: + lxcs.add(item as PveLxc); + break; + case PveResType.qemu: + qemus.add(item as PveQemu); + break; + case PveResType.node: + nodes.add(item as PveNode); + break; + case PveResType.storage: + storages.add(item as PveStorage); + break; + case PveResType.sdn: + sdns.add(item as PveSdn); + break; + } + } + + if (old != null) { + qemus.reorder( + order: old.qemus.map((e) => e.id).toList(), + finder: (e, s) => e.id == s); + lxcs.reorder( + order: old.lxcs.map((e) => e.id).toList(), + finder: (e, s) => e.id == s); + nodes.reorder( + order: old.nodes.map((e) => e.id).toList(), + finder: (e, s) => e.id == s); + storages.reorder( + order: old.storages.map((e) => e.id).toList(), + finder: (e, s) => e.id == s); + sdns.reorder( + order: old.sdns.map((e) => e.id).toList(), + finder: (e, s) => e.id == s); + } + + return PveRes( + qemus: qemus, + lxcs: lxcs, + nodes: nodes, + storages: storages, + sdns: sdns, + ); + } } diff --git a/lib/data/provider/pve.dart b/lib/data/provider/pve.dart index 6659b3cd..3764585d 100644 --- a/lib/data/provider/pve.dart +++ b/lib/data/provider/pve.dart @@ -1,21 +1,20 @@ import 'dart:async'; +import 'package:computer/computer.dart'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; -import 'package:toolbox/core/extension/order.dart'; import 'package:toolbox/data/model/server/pve.dart'; import 'package:toolbox/data/model/server/server_private_info.dart'; +import 'package:toolbox/data/res/logger.dart'; + +typedef PveCtrlFunc = Future Function(String node, String id); final class PveProvider extends ChangeNotifier { final ServerPrivateInfo spi; late final String addr; //late final SSHClient _client; - final data = ValueNotifier(null); - - PveProvider({ - required this.spi, - }) { + PveProvider({required this.spi}) { // final client = _spi.server?.client; // if (client == null) { // throw Exception('Server client is null'); @@ -27,19 +26,31 @@ final class PveProvider extends ChangeNotifier { return; } this.addr = addr; - _init().then((_) => connected.complete()); + _init(); } final err = ValueNotifier(null); final connected = Completer(); final session = Dio(); + final data = ValueNotifier(null); + bool get onlyOneNode => data.value?.nodes.length == 1; + String? release; + bool isBusy = false; // int _localPort = 0; // String get addr => 'http://127.0.0.1:$_localPort'; Future _init() async { - //await _forward(); - await _login(); + try { + //await _forward(); + await _login(); + await _release; + } catch (e) { + Loggers.app.warning('PVE init failed', e); + err.value = e.toString(); + } finally { + connected.complete(); + } } // Future _forward() async { @@ -75,65 +86,30 @@ final class PveProvider extends ChangeNotifier { session.options.headers['Cookie'] = 'PVEAuthCookie=$ticket'; } - Future list() async { + /// Returns true if the PVE version is 8.0 or later + Future get _release async { + final resp = await session.get('$addr/api2/extjs/version'); + final version = resp.data['data']['release'] as String?; + if (version != null) { + release = version; + } + } + + Future list() async { await connected.future; - final resp = await session.get('$addr/api2/json/cluster/resources'); - final list = resp.data['data'] as List; - final items = list.map((e) => PveResIface.fromJson(e)).toList(); - - final Order qemus = []; - final Order lxcs = []; - final Order nodes = []; - final Order storages = []; - final Order sdns = []; - for (final item in items) { - switch (item.type) { - case PveResType.lxc: - lxcs.add(item as PveLxc); - break; - case PveResType.qemu: - qemus.add(item as PveQemu); - break; - case PveResType.node: - nodes.add(item as PveNode); - break; - case PveResType.storage: - storages.add(item as PveStorage); - break; - case PveResType.sdn: - sdns.add(item as PveSdn); - break; - } + if (isBusy) return; + isBusy = true; + try { + final resp = await session.get('$addr/api2/json/cluster/resources'); + final res = resp.data['data'] as List; + final result = await Computer.shared.start(PveRes.parse, (res, data.value)); + data.value = result; + } catch (e) { + Loggers.app.warning('PVE list failed', e); + err.value = e.toString(); + } finally { + isBusy = false; } - - final old = data.value; - if (old != null) { - qemus.reorder( - order: old.qemus.map((e) => e.id).toList(), - finder: (e, s) => e.id == s); - lxcs.reorder( - order: old.lxcs.map((e) => e.id).toList(), - finder: (e, s) => e.id == s); - nodes.reorder( - order: old.nodes.map((e) => e.id).toList(), - finder: (e, s) => e.id == s); - storages.reorder( - order: old.storages.map((e) => e.id).toList(), - finder: (e, s) => e.id == s); - sdns.reorder( - order: old.sdns.map((e) => e.id).toList(), - finder: (e, s) => e.id == s); - } - - final res = PveRes( - qemus: qemus, - lxcs: lxcs, - nodes: nodes, - storages: storages, - sdns: sdns, - ); - data.value = res; - return res; } Future reboot(String node, String id) async { diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 5e734a05..440cb10b 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -167,6 +167,7 @@ "noTask": "Nicht fragen", "noUpdateAvailable": "Kein Update verfügbar", "node": "Knoten", + "notAvailable": "Nicht verfügbar", "notSelected": "Nicht ausgewählt", "note": "Hinweis", "nullToken": "Null token", @@ -197,6 +198,7 @@ "privateKey": "Private Key", "process": "Prozess", "pushToken": "Push Token", + "pveVersionLow": "Diese Funktion befindet sich derzeit in der Testphase und wurde nur auf PVE 8+ getestet. Bitte verwenden Sie sie mit Vorsicht.", "pwd": "Passwort", "read": "Lesen", "reboot": "Neustart", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index de365bdc..3b7690d9 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -167,6 +167,7 @@ "noTask": "No task", "noUpdateAvailable": "No update available", "node": "Node", + "notAvailable": "Unavailable", "notSelected": "Not selected", "note": "Note", "nullToken": "Null token", @@ -197,6 +198,7 @@ "privateKey": "Private Key", "process": "Process", "pushToken": "Push token", + "pveVersionLow": "This feature is currently in the testing phase and has only been tested on PVE 8+. Please use it with caution.", "pwd": "Password", "read": "Read", "reboot": "Reboot", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 1591df53..48808f55 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -167,6 +167,7 @@ "noTask": "Sin tareas", "noUpdateAvailable": "No hay actualizaciones disponibles", "node": "Nodo", + "notAvailable": "No disponible", "notSelected": "No seleccionado", "note": "Nota", "nullToken": "Token nulo", @@ -197,6 +198,7 @@ "privateKey": "Llave privada", "process": "Proceso", "pushToken": "Token de notificaciones", + "pveVersionLow": "Esta función está actualmente en fase de prueba y solo se ha probado en PVE 8+. Úsela con precaución.", "pwd": "Contraseña", "read": "Leer", "reboot": "Reiniciar", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 99c4ce75..80f5b1c3 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -167,6 +167,7 @@ "noTask": "Aucune tâche", "noUpdateAvailable": "Aucune mise à jour disponible", "node": "Noeud", + "notAvailable": "Non disponible", "notSelected": "Non sélectionné", "note": "Note", "nullToken": "Jeton nul", @@ -197,6 +198,7 @@ "privateKey": "Clé privée", "process": "Processus", "pushToken": "Jeton d'identification", + "pveVersionLow": "Cette fonctionnalité est actuellement en phase de test et n'a été testée que sur PVE 8+. Veuillez l'utiliser avec prudence.", "pwd": "Mot de passe", "read": "Lire", "reboot": "Redémarrer", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 5885e0c4..4dd942bd 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -167,6 +167,7 @@ "noTask": "Tidak bertanya", "noUpdateAvailable": "Tidak ada pembaruan yang tersedia", "node": "Node", + "notAvailable": "Tidak tersedia", "notSelected": "Tidak terpilih", "note": "Catatan", "nullToken": "Token NULL", @@ -197,6 +198,7 @@ "privateKey": "Kunci Pribadi", "process": "Proses", "pushToken": "Dorong token", + "pveVersionLow": "Fitur ini saat ini sedang dalam tahap pengujian dan hanya diuji pada PVE 8+. Gunakan dengan hati-hati.", "pwd": "Kata sandi", "read": "Baca", "reboot": "Reboot", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 2cb3a237..596056e6 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -167,6 +167,7 @@ "noTask": "タスクがありません", "noUpdateAvailable": "利用可能な更新はありません", "node": "ノード", + "notAvailable": "利用不可", "notSelected": "選択されていません", "note": "メモ", "nullToken": "トークンなし", @@ -197,6 +198,7 @@ "privateKey": "プライベートキー", "process": "プロセス", "pushToken": "プッシュトークン", + "pveVersionLow": "この機能は現在テスト段階にあり、PVE 8+でのみテストされています。ご利用の際は慎重に。", "pwd": "パスワード", "read": "読み取り", "reboot": "再起動", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 5b01a663..64dc691e 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -167,6 +167,7 @@ "noTask": "Sem tarefas", "noUpdateAvailable": "Sem atualizações disponíveis", "node": "Nó", + "notAvailable": "Indisponível", "notSelected": "Não selecionado", "note": "Nota", "nullToken": "Token nulo", @@ -197,6 +198,7 @@ "privateKey": "Chave privada", "process": "Processo", "pushToken": "Token de notificação push", + "pveVersionLow": "Esta funcionalidade está atualmente em fase de teste e foi testada apenas no PVE 8+. Por favor, use com cautela.", "pwd": "Senha", "read": "Leitura", "reboot": "Reiniciar", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 701f26f6..8a330081 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -167,6 +167,7 @@ "noTask": "нет задач", "noUpdateAvailable": "нет доступных обновлений", "node": "Узел", + "notAvailable": "Недоступно", "notSelected": "не выбрано", "note": "заметка", "nullToken": "нет токена", @@ -197,6 +198,7 @@ "privateKey": "приватный ключ", "process": "процесс", "pushToken": "токен уведомлений", + "pveVersionLow": "Эта функция в настоящее время находится на стадии тестирования и была протестирована только на PVE 8+. Используйте ее с осторожностью.", "pwd": "пароль", "read": "чтение", "reboot": "перезагрузка", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 5eac51e4..c0da4d77 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -167,6 +167,7 @@ "noTask": "没有任务", "noUpdateAvailable": "没有可用更新", "node": "节点", + "notAvailable": "不可用", "notSelected": "未选择", "note": "备注", "nullToken": "无Token", @@ -197,6 +198,7 @@ "privateKey": "私钥", "process": "进程", "pushToken": "消息推送 Token", + "pveVersionLow": "当前该功能处于测试阶段,仅在PVE 8+上测试过,请谨慎使用", "pwd": "密码", "read": "读", "reboot": "重启", diff --git a/lib/l10n/app_zh_tw.arb b/lib/l10n/app_zh_tw.arb index 58ebfb3a..3d7dee96 100644 --- a/lib/l10n/app_zh_tw.arb +++ b/lib/l10n/app_zh_tw.arb @@ -167,6 +167,7 @@ "noTask": "沒有任務", "noUpdateAvailable": "沒有可用更新", "node": "節點", + "notAvailable": "不可用", "notSelected": "未選擇", "note": "備註", "nullToken": "無Token", @@ -197,6 +198,7 @@ "privateKey": "私鑰", "process": "進程", "pushToken": "消息推送 Token", + "pveVersionLow": "此功能目前處於測試階段,僅在PVE 8+上進行過測試。請謹慎使用。", "pwd": "密碼", "read": "读", "reboot": "重启", diff --git a/lib/view/page/pve.dart b/lib/view/page/pve.dart index 66d789c3..e7cd3872 100644 --- a/lib/view/page/pve.dart +++ b/lib/view/page/pve.dart @@ -17,6 +17,7 @@ import 'package:toolbox/view/widget/appbar.dart'; import 'package:toolbox/view/widget/icon_btn.dart'; import 'package:toolbox/view/widget/kv_row.dart'; import 'package:toolbox/view/widget/percent_circle.dart'; +import 'package:toolbox/view/widget/row.dart'; import 'package:toolbox/view/widget/two_line_text.dart'; final class PvePage extends StatefulWidget { @@ -48,6 +49,7 @@ final class _PvePageState extends State { void initState() { super.initState(); _initRefreshTimer(); + _afterInit(); } @override @@ -61,23 +63,46 @@ final class _PvePageState extends State { return Scaffold( appBar: CustomAppBar( title: TwoLineText(up: 'PVE', down: widget.spi.name), + actions: [ + ValueListenableBuilder( + valueListenable: _pve.err, + builder: (_, val, __) => val == null + ? UIs.placeholder + : IconBtn( + icon: Icons.refresh, + onTap: () { + _pve.err.value = null; + _pve.list(); + _initRefreshTimer(); + }, + ), + ), + ], ), body: ValueListenableBuilder( - valueListenable: _pve.data, + valueListenable: _pve.err, builder: (_, val, __) { - return _buildBody(val); + if (val != null) { + _timer?.cancel(); + return Padding( + padding: const EdgeInsets.all(13), + child: Center( + child: Text(val), + ), + ); + } + return ValueListenableBuilder( + valueListenable: _pve.data, + builder: (_, val, __) { + return _buildBody(val); + }, + ); }, ), ); } Widget _buildBody(PveRes? data) { - if (_pve.err.value != null) { - return Center( - child: Text('Failed to connect to PVE: ${_pve.err.value}'), - ); - } - if (data == null) { return UIs.centerLoading; } @@ -113,6 +138,7 @@ final class _PvePageState extends State { fontWeight: FontWeight.bold, color: Colors.grey, ), + textAlign: TextAlign.start, ), ), ); @@ -193,9 +219,9 @@ final class _PvePageState extends State { } Widget _buildQemu(PveQemu item) { - if (!item.isRunning) { + if (!item.available) { return ListTile( - title: Text(item.name, style: UIs.text13Bold), + title: Text(_wrapNodeName(item), style: UIs.text13Bold), trailing: _buildCtrlBtns(item), ).card; } @@ -209,7 +235,7 @@ final class _PvePageState extends State { text: TextSpan( children: [ TextSpan( - text: item.name, + text: _wrapNodeName(item), style: UIs.text13Bold, ), TextSpan( @@ -225,49 +251,46 @@ final class _PvePageState extends State { ], ), UIs.height7, - Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, + AvgWidthRow( + width: _media.size.width, + padding: _kHorziPadding * 2 + 26, children: [ - _wrap(PercentCircle(percent: (item.cpu / item.maxcpu) * 100), 4), - _wrap(PercentCircle(percent: (item.mem / item.maxmem) * 100), 4), - _wrap( - Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - '${l10n.read}:\n${item.diskread.bytes2Str}', - style: UIs.text11Grey, - textAlign: TextAlign.center, - ), - const SizedBox(height: 3), - Text( - '${l10n.write}:\n${item.diskwrite.bytes2Str}', - style: UIs.text11Grey, - textAlign: TextAlign.center, - ) - ], + PercentCircle(percent: (item.cpu / item.maxcpu) * 100), + PercentCircle(percent: (item.mem / item.maxmem) * 100), + Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '${l10n.read}:\n${item.diskread.bytes2Str}', + style: UIs.text11Grey, + textAlign: TextAlign.center, ), - 4), - _wrap( - Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - '↓:\n${item.netin.bytes2Str}', - style: UIs.text11Grey, - textAlign: TextAlign.center, - ), - const SizedBox(height: 3), - Text( - '↑:\n${item.netout.bytes2Str}', - style: UIs.text11Grey, - textAlign: TextAlign.center, - ) - ], + const SizedBox(height: 3), + Text( + '${l10n.write}:\n${item.diskwrite.bytes2Str}', + style: UIs.text11Grey, + textAlign: TextAlign.center, + ) + ], + ), + Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '↓:\n${item.netin.bytes2Str}', + style: UIs.text11Grey, + textAlign: TextAlign.center, ), - 4), + const SizedBox(height: 3), + Text( + '↑:\n${item.netout.bytes2Str}', + style: UIs.text11Grey, + textAlign: TextAlign.center, + ) + ], + ), ], ), const SizedBox(height: 21) @@ -279,9 +302,9 @@ final class _PvePageState extends State { } Widget _buildLxc(PveLxc item) { - if (!item.isRunning) { + if (!item.available) { return ListTile( - title: Text(item.name, style: UIs.text13Bold), + title: Text(_wrapNodeName(item), style: UIs.text13Bold), trailing: _buildCtrlBtns(item), ).card; } @@ -295,7 +318,7 @@ final class _PvePageState extends State { text: TextSpan( children: [ TextSpan( - text: item.name, + text: _wrapNodeName(item), style: UIs.text13Bold, ), TextSpan( @@ -311,49 +334,46 @@ final class _PvePageState extends State { ], ), UIs.height7, - Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, + AvgWidthRow( + width: _media.size.width, + padding: _kHorziPadding * 2 + 26, children: [ - _wrap(PercentCircle(percent: (item.cpu / item.maxcpu) * 100), 4), - _wrap(PercentCircle(percent: (item.mem / item.maxmem) * 100), 4), - _wrap( - Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - '${l10n.read}:\n${item.diskread.bytes2Str}', - style: UIs.text11Grey, - textAlign: TextAlign.center, - ), - const SizedBox(height: 3), - Text( - '${l10n.write}:\n${item.diskwrite.bytes2Str}', - style: UIs.text11Grey, - textAlign: TextAlign.center, - ) - ], + PercentCircle(percent: (item.cpu / item.maxcpu) * 100), + PercentCircle(percent: (item.mem / item.maxmem) * 100), + Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '${l10n.read}:\n${item.diskread.bytes2Str}', + style: UIs.text11Grey, + textAlign: TextAlign.center, ), - 4), - _wrap( - Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - '↓:\n${item.netin.bytes2Str}', - style: UIs.text11Grey, - textAlign: TextAlign.center, - ), - const SizedBox(height: 3), - Text( - '↑:\n${item.netout.bytes2Str}', - style: UIs.text11Grey, - textAlign: TextAlign.center, - ) - ], + const SizedBox(height: 3), + Text( + '${l10n.write}:\n${item.diskwrite.bytes2Str}', + style: UIs.text11Grey, + textAlign: TextAlign.center, + ) + ], + ), + Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '↓:\n${item.netin.bytes2Str}', + style: UIs.text11Grey, + textAlign: TextAlign.center, ), - 4), + const SizedBox(height: 3), + Text( + '↑:\n${item.netout.bytes2Str}', + style: UIs.text11Grey, + textAlign: TextAlign.center, + ) + ], + ), ], ), const SizedBox(height: 21) @@ -369,12 +389,13 @@ final class _PvePageState extends State { padding: const EdgeInsets.all(13), child: Column( mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ - Text(item.storage, style: UIs.text13Bold), + Text(_wrapNodeName(item), style: UIs.text13Bold), const Spacer(), - Text(item.status, style: UIs.text12Grey), + Text(item.summary, style: UIs.text11Grey), ], ), UIs.height7, @@ -387,90 +408,41 @@ final class _PvePageState extends State { Widget _buildSdn(PveSdn item) { return ListTile( - title: Text(item.sdn), - trailing: Text(item.status), + title: Text(_wrapNodeName(item)), + trailing: Text(item.summary), ).card; } Widget _buildCtrlBtns(PveCtrlIface item) { - if (!item.isRunning) { + if (!item.available) { return IconBtn( - icon: Icons.play_arrow, - color: Colors.grey, - onTap: () async { - bool? suc; - await context.showLoadingDialog(fn: () async { - suc = await _pve.start(item.node, item.id); - }); - if (suc == true) { - context.showSnackBar(l10n.success); - } else { - context.showSnackBar(l10n.failed); - } - }, - ); + icon: Icons.play_arrow, + color: Colors.grey, + onTap: () => _onCtrl(_pve.start, l10n.start, item)); } return Row( children: [ IconBtn( - icon: Icons.stop, - color: Colors.grey, - onTap: () async { - final sure = await _ask(l10n.stop, item.id); - if (!sure) return; - bool? suc; - await context.showLoadingDialog(fn: () async { - suc = await _pve.stop(item.node, item.id); - }); - if (suc == true) { - context.showSnackBar(l10n.success); - } else { - context.showSnackBar(l10n.failed); - } - }, - ), + icon: Icons.stop, + color: Colors.grey, + onTap: () => _onCtrl(_pve.stop, l10n.stop, item)), IconBtn( - icon: Icons.refresh, - color: Colors.grey, - onTap: () async { - final sure = await _ask(l10n.reboot, item.id); - if (!sure) return; - bool? suc; - await context.showLoadingDialog(fn: () async { - suc = await _pve.reboot(item.node, item.id); - }); - if (suc == true) { - context.showSnackBar(l10n.success); - } else { - context.showSnackBar(l10n.failed); - } - }, - ), + icon: Icons.refresh, + color: Colors.grey, + onTap: () => _onCtrl(_pve.reboot, l10n.reboot, item)), IconBtn( icon: Icons.power_off, color: Colors.grey, - onTap: () async { - final sure = await _ask(l10n.shutdown, item.id); - if (!sure) return; - bool? suc; - await context.showLoadingDialog(fn: () async { - suc = await _pve.shutdown(item.node, item.id); - }); - if (suc == true) { - context.showSnackBar(l10n.success); - } else { - context.showSnackBar(l10n.failed); - } - }, + onTap: () => _onCtrl(_pve.shutdown, l10n.shutdown, item), ), ], ); } - Future _ask(String action, String id) async { - final sure = await context.showRoundDialog( + void _onCtrl(PveCtrlFunc func, String action, PveCtrlIface item) async { + final sure = await context.showRoundDialog( title: Text(l10n.attention), - child: Text(l10n.askContinue('$action $id')), + child: Text(l10n.askContinue('$action ${item.id}')), actions: [ TextButton( onPressed: () => context.pop(true), @@ -478,14 +450,24 @@ final class _PvePageState extends State { ), ], ); - return sure == true; + if (sure != true) return; + bool? suc; + await context.showLoadingDialog(fn: () async { + suc = await func(item.node, item.id); + }); + if (suc == true) { + context.showSnackBar(l10n.success); + } else { + context.showSnackBar(l10n.failed); + } } - Widget _wrap(Widget child, int count) { - return SizedBox( - width: (_media.size.width - 2 * _kHorziPadding - 26) / count, - child: child, - ); + /// Add PveNode if [PveProvider.onlyOneNode] is false + String _wrapNodeName(PveCtrlIface item) { + if (_pve.onlyOneNode) { + return item.name; + } + return '${item.node} / ${item.name}'; } void _initRefreshTimer() { @@ -497,4 +479,13 @@ final class _PvePageState extends State { } }); } + + void _afterInit() async { + await _pve.connected.future; + if (_pve.release != null && _pve.release!.compareTo('8.0') < 0) { + if (mounted) { + context.showSnackBar(l10n.pveVersionLow); + } + } + } } diff --git a/lib/view/widget/row.dart b/lib/view/widget/row.dart new file mode 100644 index 00000000..028a5a0d --- /dev/null +++ b/lib/view/widget/row.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; + +final class AvgWidthRow extends StatelessWidget { + final List children; + final double? width; + final double padding; + + const AvgWidthRow({ + super.key, + required this.children, + this.width, + this.padding = 0, + }); + + @override + Widget build(BuildContext context) { + final width = + ((this.width ?? MediaQuery.of(context).size.width) - padding) / + children.length; + return Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: children.map((e) => SizedBox(width: width, child: e)).toList(), + ); + } +}