opt.: pve dashboard (#307)

This commit is contained in:
lollipopkit
2024-03-19 01:26:53 -06:00
parent 48fdf4cc84
commit 7edef87a4f
14 changed files with 338 additions and 240 deletions

View File

@@ -1,6 +1,7 @@
import 'package:toolbox/core/extension/context/locale.dart'; import 'package:toolbox/core/extension/context/locale.dart';
import 'package:toolbox/core/extension/duration.dart'; import 'package:toolbox/core/extension/duration.dart';
import 'package:toolbox/core/extension/numx.dart'; import 'package:toolbox/core/extension/numx.dart';
import 'package:toolbox/core/extension/order.dart';
enum PveResType { enum PveResType {
lxc, lxc,
@@ -61,8 +62,9 @@ sealed class PveResIface {
abstract interface class PveCtrlIface { abstract interface class PveCtrlIface {
String get node; String get node;
String get id; String get id;
bool get isRunning; bool get available;
String get summary; String get summary;
String get name;
} }
final class PveLxc extends PveResIface implements PveCtrlIface { final class PveLxc extends PveResIface implements PveCtrlIface {
@@ -73,6 +75,7 @@ final class PveLxc extends PveResIface implements PveCtrlIface {
final int vmid; final int vmid;
@override @override
final String node; final String node;
@override
final String name; final String name;
@override @override
final String status; final String status;
@@ -131,11 +134,11 @@ final class PveLxc extends PveResIface implements PveCtrlIface {
} }
@override @override
bool get isRunning => status == 'running'; bool get available => status == 'running';
@override @override
String get summary { String get summary {
if (isRunning) { if (available) {
return uptime.secondsToDuration().toStr; return uptime.secondsToDuration().toStr;
} }
return l10n.stopped; return l10n.stopped;
@@ -150,6 +153,7 @@ final class PveQemu extends PveResIface implements PveCtrlIface {
final int vmid; final int vmid;
@override @override
final String node; final String node;
@override
final String name; final String name;
@override @override
final String status; final String status;
@@ -208,11 +212,11 @@ final class PveQemu extends PveResIface implements PveCtrlIface {
} }
@override @override
bool get isRunning => status == 'running'; bool get available => status == 'running';
@override @override
String get summary { String get summary {
if (isRunning) { if (available) {
return uptime.secondsToDuration().toStr; return uptime.secondsToDuration().toStr;
} }
return l10n.stopped; 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 @override
final String id; final String id;
@override @override
final PveResType type; final PveResType type;
final String storage; final String storage;
@override
final String node; final String node;
@override @override
final String status; final String status;
@@ -311,14 +316,29 @@ final class PveStorage extends PveResIface {
maxdisk: json['maxdisk'], 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 @override
final String id; final String id;
@override @override
final PveResType type; final PveResType type;
final String sdn; final String sdn;
@override
final String node; final String node;
@override @override
final String status; final String status;
@@ -340,6 +360,15 @@ final class PveSdn extends PveResIface {
status: json['status'], 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 { final class PveRes {
@@ -357,6 +386,8 @@ final class PveRes {
required this.sdns, required this.sdns,
}); });
bool get onlyOneNode => nodes.length == 1;
int get length => int get length =>
qemus.length + lxcs.length + nodes.length + storages.length + sdns.length; qemus.length + lxcs.length + nodes.length + storages.length + sdns.length;
@@ -379,4 +410,59 @@ final class PveRes {
index -= storages.length; index -= storages.length;
return sdns[index]; return sdns[index];
} }
static Future<PveRes> parse((List list, PveRes? old) val) async {
final (list, old) = val;
final items = list.map((e) => PveResIface.fromJson(e)).toList();
final Order<PveQemu> qemus = [];
final Order<PveLxc> lxcs = [];
final Order<PveNode> nodes = [];
final Order<PveStorage> storages = [];
final Order<PveSdn> 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,
);
}
} }

View File

@@ -1,21 +1,20 @@
import 'dart:async'; import 'dart:async';
import 'package:computer/computer.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/material.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/pve.dart';
import 'package:toolbox/data/model/server/server_private_info.dart'; import 'package:toolbox/data/model/server/server_private_info.dart';
import 'package:toolbox/data/res/logger.dart';
typedef PveCtrlFunc = Future<bool> Function(String node, String id);
final class PveProvider extends ChangeNotifier { final class PveProvider extends ChangeNotifier {
final ServerPrivateInfo spi; final ServerPrivateInfo spi;
late final String addr; late final String addr;
//late final SSHClient _client; //late final SSHClient _client;
final data = ValueNotifier<PveRes?>(null); PveProvider({required this.spi}) {
PveProvider({
required this.spi,
}) {
// final client = _spi.server?.client; // final client = _spi.server?.client;
// if (client == null) { // if (client == null) {
// throw Exception('Server client is null'); // throw Exception('Server client is null');
@@ -27,19 +26,31 @@ final class PveProvider extends ChangeNotifier {
return; return;
} }
this.addr = addr; this.addr = addr;
_init().then((_) => connected.complete()); _init();
} }
final err = ValueNotifier<String?>(null); final err = ValueNotifier<String?>(null);
final connected = Completer<void>(); final connected = Completer<void>();
final session = Dio(); final session = Dio();
final data = ValueNotifier<PveRes?>(null);
bool get onlyOneNode => data.value?.nodes.length == 1;
String? release;
bool isBusy = false;
// int _localPort = 0; // int _localPort = 0;
// String get addr => 'http://127.0.0.1:$_localPort'; // String get addr => 'http://127.0.0.1:$_localPort';
Future<void> _init() async { Future<void> _init() async {
//await _forward(); try {
await _login(); //await _forward();
await _login();
await _release;
} catch (e) {
Loggers.app.warning('PVE init failed', e);
err.value = e.toString();
} finally {
connected.complete();
}
} }
// Future<void> _forward() async { // Future<void> _forward() async {
@@ -75,65 +86,30 @@ final class PveProvider extends ChangeNotifier {
session.options.headers['Cookie'] = 'PVEAuthCookie=$ticket'; session.options.headers['Cookie'] = 'PVEAuthCookie=$ticket';
} }
Future<PveRes> list() async { /// Returns true if the PVE version is 8.0 or later
Future<void> 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<void> list() async {
await connected.future; await connected.future;
final resp = await session.get('$addr/api2/json/cluster/resources'); if (isBusy) return;
final list = resp.data['data'] as List; isBusy = true;
final items = list.map((e) => PveResIface.fromJson(e)).toList(); try {
final resp = await session.get('$addr/api2/json/cluster/resources');
final Order<PveQemu> qemus = []; final res = resp.data['data'] as List;
final Order<PveLxc> lxcs = []; final result = await Computer.shared.start(PveRes.parse, (res, data.value));
final Order<PveNode> nodes = []; data.value = result;
final Order<PveStorage> storages = []; } catch (e) {
final Order<PveSdn> sdns = []; Loggers.app.warning('PVE list failed', e);
for (final item in items) { err.value = e.toString();
switch (item.type) { } finally {
case PveResType.lxc: isBusy = false;
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;
}
} }
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<bool> reboot(String node, String id) async { Future<bool> reboot(String node, String id) async {

View File

@@ -167,6 +167,7 @@
"noTask": "Nicht fragen", "noTask": "Nicht fragen",
"noUpdateAvailable": "Kein Update verfügbar", "noUpdateAvailable": "Kein Update verfügbar",
"node": "Knoten", "node": "Knoten",
"notAvailable": "Nicht verfügbar",
"notSelected": "Nicht ausgewählt", "notSelected": "Nicht ausgewählt",
"note": "Hinweis", "note": "Hinweis",
"nullToken": "Null token", "nullToken": "Null token",
@@ -197,6 +198,7 @@
"privateKey": "Private Key", "privateKey": "Private Key",
"process": "Prozess", "process": "Prozess",
"pushToken": "Push Token", "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", "pwd": "Passwort",
"read": "Lesen", "read": "Lesen",
"reboot": "Neustart", "reboot": "Neustart",

View File

@@ -167,6 +167,7 @@
"noTask": "No task", "noTask": "No task",
"noUpdateAvailable": "No update available", "noUpdateAvailable": "No update available",
"node": "Node", "node": "Node",
"notAvailable": "Unavailable",
"notSelected": "Not selected", "notSelected": "Not selected",
"note": "Note", "note": "Note",
"nullToken": "Null token", "nullToken": "Null token",
@@ -197,6 +198,7 @@
"privateKey": "Private Key", "privateKey": "Private Key",
"process": "Process", "process": "Process",
"pushToken": "Push token", "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", "pwd": "Password",
"read": "Read", "read": "Read",
"reboot": "Reboot", "reboot": "Reboot",

View File

@@ -167,6 +167,7 @@
"noTask": "Sin tareas", "noTask": "Sin tareas",
"noUpdateAvailable": "No hay actualizaciones disponibles", "noUpdateAvailable": "No hay actualizaciones disponibles",
"node": "Nodo", "node": "Nodo",
"notAvailable": "No disponible",
"notSelected": "No seleccionado", "notSelected": "No seleccionado",
"note": "Nota", "note": "Nota",
"nullToken": "Token nulo", "nullToken": "Token nulo",
@@ -197,6 +198,7 @@
"privateKey": "Llave privada", "privateKey": "Llave privada",
"process": "Proceso", "process": "Proceso",
"pushToken": "Token de notificaciones", "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", "pwd": "Contraseña",
"read": "Leer", "read": "Leer",
"reboot": "Reiniciar", "reboot": "Reiniciar",

View File

@@ -167,6 +167,7 @@
"noTask": "Aucune tâche", "noTask": "Aucune tâche",
"noUpdateAvailable": "Aucune mise à jour disponible", "noUpdateAvailable": "Aucune mise à jour disponible",
"node": "Noeud", "node": "Noeud",
"notAvailable": "Non disponible",
"notSelected": "Non sélectionné", "notSelected": "Non sélectionné",
"note": "Note", "note": "Note",
"nullToken": "Jeton nul", "nullToken": "Jeton nul",
@@ -197,6 +198,7 @@
"privateKey": "Clé privée", "privateKey": "Clé privée",
"process": "Processus", "process": "Processus",
"pushToken": "Jeton d'identification", "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", "pwd": "Mot de passe",
"read": "Lire", "read": "Lire",
"reboot": "Redémarrer", "reboot": "Redémarrer",

View File

@@ -167,6 +167,7 @@
"noTask": "Tidak bertanya", "noTask": "Tidak bertanya",
"noUpdateAvailable": "Tidak ada pembaruan yang tersedia", "noUpdateAvailable": "Tidak ada pembaruan yang tersedia",
"node": "Node", "node": "Node",
"notAvailable": "Tidak tersedia",
"notSelected": "Tidak terpilih", "notSelected": "Tidak terpilih",
"note": "Catatan", "note": "Catatan",
"nullToken": "Token NULL", "nullToken": "Token NULL",
@@ -197,6 +198,7 @@
"privateKey": "Kunci Pribadi", "privateKey": "Kunci Pribadi",
"process": "Proses", "process": "Proses",
"pushToken": "Dorong token", "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", "pwd": "Kata sandi",
"read": "Baca", "read": "Baca",
"reboot": "Reboot", "reboot": "Reboot",

View File

@@ -167,6 +167,7 @@
"noTask": "タスクがありません", "noTask": "タスクがありません",
"noUpdateAvailable": "利用可能な更新はありません", "noUpdateAvailable": "利用可能な更新はありません",
"node": "ノード", "node": "ノード",
"notAvailable": "利用不可",
"notSelected": "選択されていません", "notSelected": "選択されていません",
"note": "メモ", "note": "メモ",
"nullToken": "トークンなし", "nullToken": "トークンなし",
@@ -197,6 +198,7 @@
"privateKey": "プライベートキー", "privateKey": "プライベートキー",
"process": "プロセス", "process": "プロセス",
"pushToken": "プッシュトークン", "pushToken": "プッシュトークン",
"pveVersionLow": "この機能は現在テスト段階にあり、PVE 8+でのみテストされています。ご利用の際は慎重に。",
"pwd": "パスワード", "pwd": "パスワード",
"read": "読み取り", "read": "読み取り",
"reboot": "再起動", "reboot": "再起動",

View File

@@ -167,6 +167,7 @@
"noTask": "Sem tarefas", "noTask": "Sem tarefas",
"noUpdateAvailable": "Sem atualizações disponíveis", "noUpdateAvailable": "Sem atualizações disponíveis",
"node": "Nó", "node": "Nó",
"notAvailable": "Indisponível",
"notSelected": "Não selecionado", "notSelected": "Não selecionado",
"note": "Nota", "note": "Nota",
"nullToken": "Token nulo", "nullToken": "Token nulo",
@@ -197,6 +198,7 @@
"privateKey": "Chave privada", "privateKey": "Chave privada",
"process": "Processo", "process": "Processo",
"pushToken": "Token de notificação push", "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", "pwd": "Senha",
"read": "Leitura", "read": "Leitura",
"reboot": "Reiniciar", "reboot": "Reiniciar",

View File

@@ -167,6 +167,7 @@
"noTask": "нет задач", "noTask": "нет задач",
"noUpdateAvailable": "нет доступных обновлений", "noUpdateAvailable": "нет доступных обновлений",
"node": "Узел", "node": "Узел",
"notAvailable": "Недоступно",
"notSelected": "не выбрано", "notSelected": "не выбрано",
"note": "заметка", "note": "заметка",
"nullToken": "нет токена", "nullToken": "нет токена",
@@ -197,6 +198,7 @@
"privateKey": "приватный ключ", "privateKey": "приватный ключ",
"process": "процесс", "process": "процесс",
"pushToken": "токен уведомлений", "pushToken": "токен уведомлений",
"pveVersionLow": "Эта функция в настоящее время находится на стадии тестирования и была протестирована только на PVE 8+. Используйте ее с осторожностью.",
"pwd": "пароль", "pwd": "пароль",
"read": "чтение", "read": "чтение",
"reboot": "перезагрузка", "reboot": "перезагрузка",

View File

@@ -167,6 +167,7 @@
"noTask": "没有任务", "noTask": "没有任务",
"noUpdateAvailable": "没有可用更新", "noUpdateAvailable": "没有可用更新",
"node": "节点", "node": "节点",
"notAvailable": "不可用",
"notSelected": "未选择", "notSelected": "未选择",
"note": "备注", "note": "备注",
"nullToken": "无Token", "nullToken": "无Token",
@@ -197,6 +198,7 @@
"privateKey": "私钥", "privateKey": "私钥",
"process": "进程", "process": "进程",
"pushToken": "消息推送 Token", "pushToken": "消息推送 Token",
"pveVersionLow": "当前该功能处于测试阶段仅在PVE 8+上测试过,请谨慎使用",
"pwd": "密码", "pwd": "密码",
"read": "读", "read": "读",
"reboot": "重启", "reboot": "重启",

View File

@@ -167,6 +167,7 @@
"noTask": "沒有任務", "noTask": "沒有任務",
"noUpdateAvailable": "沒有可用更新", "noUpdateAvailable": "沒有可用更新",
"node": "節點", "node": "節點",
"notAvailable": "不可用",
"notSelected": "未選擇", "notSelected": "未選擇",
"note": "備註", "note": "備註",
"nullToken": "無Token", "nullToken": "無Token",
@@ -197,6 +198,7 @@
"privateKey": "私鑰", "privateKey": "私鑰",
"process": "進程", "process": "進程",
"pushToken": "消息推送 Token", "pushToken": "消息推送 Token",
"pveVersionLow": "此功能目前處於測試階段僅在PVE 8+上進行過測試。請謹慎使用。",
"pwd": "密碼", "pwd": "密碼",
"read": "读", "read": "读",
"reboot": "重启", "reboot": "重启",

View File

@@ -17,6 +17,7 @@ import 'package:toolbox/view/widget/appbar.dart';
import 'package:toolbox/view/widget/icon_btn.dart'; import 'package:toolbox/view/widget/icon_btn.dart';
import 'package:toolbox/view/widget/kv_row.dart'; import 'package:toolbox/view/widget/kv_row.dart';
import 'package:toolbox/view/widget/percent_circle.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'; import 'package:toolbox/view/widget/two_line_text.dart';
final class PvePage extends StatefulWidget { final class PvePage extends StatefulWidget {
@@ -48,6 +49,7 @@ final class _PvePageState extends State<PvePage> {
void initState() { void initState() {
super.initState(); super.initState();
_initRefreshTimer(); _initRefreshTimer();
_afterInit();
} }
@override @override
@@ -61,23 +63,46 @@ final class _PvePageState extends State<PvePage> {
return Scaffold( return Scaffold(
appBar: CustomAppBar( appBar: CustomAppBar(
title: TwoLineText(up: 'PVE', down: widget.spi.name), 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( body: ValueListenableBuilder(
valueListenable: _pve.data, valueListenable: _pve.err,
builder: (_, val, __) { 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) { Widget _buildBody(PveRes? data) {
if (_pve.err.value != null) {
return Center(
child: Text('Failed to connect to PVE: ${_pve.err.value}'),
);
}
if (data == null) { if (data == null) {
return UIs.centerLoading; return UIs.centerLoading;
} }
@@ -113,6 +138,7 @@ final class _PvePageState extends State<PvePage> {
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Colors.grey, color: Colors.grey,
), ),
textAlign: TextAlign.start,
), ),
), ),
); );
@@ -193,9 +219,9 @@ final class _PvePageState extends State<PvePage> {
} }
Widget _buildQemu(PveQemu item) { Widget _buildQemu(PveQemu item) {
if (!item.isRunning) { if (!item.available) {
return ListTile( return ListTile(
title: Text(item.name, style: UIs.text13Bold), title: Text(_wrapNodeName(item), style: UIs.text13Bold),
trailing: _buildCtrlBtns(item), trailing: _buildCtrlBtns(item),
).card; ).card;
} }
@@ -209,7 +235,7 @@ final class _PvePageState extends State<PvePage> {
text: TextSpan( text: TextSpan(
children: [ children: [
TextSpan( TextSpan(
text: item.name, text: _wrapNodeName(item),
style: UIs.text13Bold, style: UIs.text13Bold,
), ),
TextSpan( TextSpan(
@@ -225,49 +251,46 @@ final class _PvePageState extends State<PvePage> {
], ],
), ),
UIs.height7, UIs.height7,
Row( AvgWidthRow(
mainAxisAlignment: MainAxisAlignment.spaceAround, width: _media.size.width,
padding: _kHorziPadding * 2 + 26,
children: [ children: [
_wrap(PercentCircle(percent: (item.cpu / item.maxcpu) * 100), 4), PercentCircle(percent: (item.cpu / item.maxcpu) * 100),
_wrap(PercentCircle(percent: (item.mem / item.maxmem) * 100), 4), PercentCircle(percent: (item.mem / item.maxmem) * 100),
_wrap( Column(
Column( mainAxisSize: MainAxisSize.min,
mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center, children: [
children: [ Text(
Text( '${l10n.read}:\n${item.diskread.bytes2Str}',
'${l10n.read}:\n${item.diskread.bytes2Str}', style: UIs.text11Grey,
style: UIs.text11Grey, textAlign: TextAlign.center,
textAlign: TextAlign.center,
),
const SizedBox(height: 3),
Text(
'${l10n.write}:\n${item.diskwrite.bytes2Str}',
style: UIs.text11Grey,
textAlign: TextAlign.center,
)
],
), ),
4), const SizedBox(height: 3),
_wrap( Text(
Column( '${l10n.write}:\n${item.diskwrite.bytes2Str}',
mainAxisSize: MainAxisSize.min, style: UIs.text11Grey,
mainAxisAlignment: MainAxisAlignment.center, textAlign: TextAlign.center,
children: [ )
Text( ],
'↓:\n${item.netin.bytes2Str}', ),
style: UIs.text11Grey, Column(
textAlign: TextAlign.center, mainAxisSize: MainAxisSize.min,
), mainAxisAlignment: MainAxisAlignment.center,
const SizedBox(height: 3), children: [
Text( Text(
':\n${item.netout.bytes2Str}', ':\n${item.netin.bytes2Str}',
style: UIs.text11Grey, style: UIs.text11Grey,
textAlign: TextAlign.center, textAlign: TextAlign.center,
)
],
), ),
4), const SizedBox(height: 3),
Text(
'↑:\n${item.netout.bytes2Str}',
style: UIs.text11Grey,
textAlign: TextAlign.center,
)
],
),
], ],
), ),
const SizedBox(height: 21) const SizedBox(height: 21)
@@ -279,9 +302,9 @@ final class _PvePageState extends State<PvePage> {
} }
Widget _buildLxc(PveLxc item) { Widget _buildLxc(PveLxc item) {
if (!item.isRunning) { if (!item.available) {
return ListTile( return ListTile(
title: Text(item.name, style: UIs.text13Bold), title: Text(_wrapNodeName(item), style: UIs.text13Bold),
trailing: _buildCtrlBtns(item), trailing: _buildCtrlBtns(item),
).card; ).card;
} }
@@ -295,7 +318,7 @@ final class _PvePageState extends State<PvePage> {
text: TextSpan( text: TextSpan(
children: [ children: [
TextSpan( TextSpan(
text: item.name, text: _wrapNodeName(item),
style: UIs.text13Bold, style: UIs.text13Bold,
), ),
TextSpan( TextSpan(
@@ -311,49 +334,46 @@ final class _PvePageState extends State<PvePage> {
], ],
), ),
UIs.height7, UIs.height7,
Row( AvgWidthRow(
mainAxisAlignment: MainAxisAlignment.spaceAround, width: _media.size.width,
padding: _kHorziPadding * 2 + 26,
children: [ children: [
_wrap(PercentCircle(percent: (item.cpu / item.maxcpu) * 100), 4), PercentCircle(percent: (item.cpu / item.maxcpu) * 100),
_wrap(PercentCircle(percent: (item.mem / item.maxmem) * 100), 4), PercentCircle(percent: (item.mem / item.maxmem) * 100),
_wrap( Column(
Column( mainAxisSize: MainAxisSize.min,
mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center, children: [
children: [ Text(
Text( '${l10n.read}:\n${item.diskread.bytes2Str}',
'${l10n.read}:\n${item.diskread.bytes2Str}', style: UIs.text11Grey,
style: UIs.text11Grey, textAlign: TextAlign.center,
textAlign: TextAlign.center,
),
const SizedBox(height: 3),
Text(
'${l10n.write}:\n${item.diskwrite.bytes2Str}',
style: UIs.text11Grey,
textAlign: TextAlign.center,
)
],
), ),
4), const SizedBox(height: 3),
_wrap( Text(
Column( '${l10n.write}:\n${item.diskwrite.bytes2Str}',
mainAxisSize: MainAxisSize.min, style: UIs.text11Grey,
mainAxisAlignment: MainAxisAlignment.center, textAlign: TextAlign.center,
children: [ )
Text( ],
'↓:\n${item.netin.bytes2Str}', ),
style: UIs.text11Grey, Column(
textAlign: TextAlign.center, mainAxisSize: MainAxisSize.min,
), mainAxisAlignment: MainAxisAlignment.center,
const SizedBox(height: 3), children: [
Text( Text(
':\n${item.netout.bytes2Str}', ':\n${item.netin.bytes2Str}',
style: UIs.text11Grey, style: UIs.text11Grey,
textAlign: TextAlign.center, textAlign: TextAlign.center,
)
],
), ),
4), const SizedBox(height: 3),
Text(
'↑:\n${item.netout.bytes2Str}',
style: UIs.text11Grey,
textAlign: TextAlign.center,
)
],
),
], ],
), ),
const SizedBox(height: 21) const SizedBox(height: 21)
@@ -369,12 +389,13 @@ final class _PvePageState extends State<PvePage> {
padding: const EdgeInsets.all(13), padding: const EdgeInsets.all(13),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Row(
children: [ children: [
Text(item.storage, style: UIs.text13Bold), Text(_wrapNodeName(item), style: UIs.text13Bold),
const Spacer(), const Spacer(),
Text(item.status, style: UIs.text12Grey), Text(item.summary, style: UIs.text11Grey),
], ],
), ),
UIs.height7, UIs.height7,
@@ -387,90 +408,41 @@ final class _PvePageState extends State<PvePage> {
Widget _buildSdn(PveSdn item) { Widget _buildSdn(PveSdn item) {
return ListTile( return ListTile(
title: Text(item.sdn), title: Text(_wrapNodeName(item)),
trailing: Text(item.status), trailing: Text(item.summary),
).card; ).card;
} }
Widget _buildCtrlBtns(PveCtrlIface item) { Widget _buildCtrlBtns(PveCtrlIface item) {
if (!item.isRunning) { if (!item.available) {
return IconBtn( return IconBtn(
icon: Icons.play_arrow, icon: Icons.play_arrow,
color: Colors.grey, color: Colors.grey,
onTap: () async { onTap: () => _onCtrl(_pve.start, l10n.start, item));
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);
}
},
);
} }
return Row( return Row(
children: [ children: [
IconBtn( IconBtn(
icon: Icons.stop, icon: Icons.stop,
color: Colors.grey, color: Colors.grey,
onTap: () async { onTap: () => _onCtrl(_pve.stop, l10n.stop, item)),
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);
}
},
),
IconBtn( IconBtn(
icon: Icons.refresh, icon: Icons.refresh,
color: Colors.grey, color: Colors.grey,
onTap: () async { onTap: () => _onCtrl(_pve.reboot, l10n.reboot, item)),
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);
}
},
),
IconBtn( IconBtn(
icon: Icons.power_off, icon: Icons.power_off,
color: Colors.grey, color: Colors.grey,
onTap: () async { onTap: () => _onCtrl(_pve.shutdown, l10n.shutdown, item),
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);
}
},
), ),
], ],
); );
} }
Future<bool> _ask(String action, String id) async { void _onCtrl(PveCtrlFunc func, String action, PveCtrlIface item) async {
final sure = await context.showRoundDialog( final sure = await context.showRoundDialog<bool>(
title: Text(l10n.attention), title: Text(l10n.attention),
child: Text(l10n.askContinue('$action $id')), child: Text(l10n.askContinue('$action ${item.id}')),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => context.pop(true), onPressed: () => context.pop(true),
@@ -478,14 +450,24 @@ final class _PvePageState extends State<PvePage> {
), ),
], ],
); );
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) { /// Add PveNode if [PveProvider.onlyOneNode] is false
return SizedBox( String _wrapNodeName(PveCtrlIface item) {
width: (_media.size.width - 2 * _kHorziPadding - 26) / count, if (_pve.onlyOneNode) {
child: child, return item.name;
); }
return '${item.node} / ${item.name}';
} }
void _initRefreshTimer() { void _initRefreshTimer() {
@@ -497,4 +479,13 @@ final class _PvePageState extends State<PvePage> {
} }
}); });
} }
void _afterInit() async {
await _pve.connected.future;
if (_pve.release != null && _pve.release!.compareTo('8.0') < 0) {
if (mounted) {
context.showSnackBar(l10n.pveVersionLow);
}
}
}
} }

25
lib/view/widget/row.dart Normal file
View File

@@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
final class AvgWidthRow extends StatelessWidget {
final List<Widget> 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(),
);
}
}