new: pve dashboard (#307)

This commit is contained in:
lollipopkit
2024-03-18 23:11:30 -06:00
parent 26264ecdea
commit 2597f99571
15 changed files with 390 additions and 114 deletions

View File

@@ -28,12 +28,12 @@ enum PveResType {
} }
String get toStr => switch (this) { String get toStr => switch (this) {
PveResType.node => l10n.node, PveResType.node => l10n.node,
PveResType.qemu => 'QEMU', PveResType.qemu => 'QEMU',
PveResType.lxc => 'LXC', PveResType.lxc => 'LXC',
PveResType.storage => l10n.storage, PveResType.storage => l10n.storage,
PveResType.sdn => 'SDN', PveResType.sdn => 'SDN',
}; };
} }
sealed class PveResIface { sealed class PveResIface {
@@ -121,6 +121,15 @@ final class PveLxc extends PveResIface {
netout: json['netout'], netout: json['netout'],
); );
} }
bool get isRunning => status == 'running';
String get topRight {
if (isRunning) {
return uptime.secondsToDuration().toStr;
}
return l10n.stopped;
}
} }
final class PveQemu extends PveResIface { final class PveQemu extends PveResIface {
@@ -190,7 +199,7 @@ final class PveQemu extends PveResIface {
bool get isRunning => status == 'running'; bool get isRunning => status == 'running';
String get topRight { String get topRight {
if (!isRunning) { if (isRunning) {
return uptime.secondsToDuration().toStr; return uptime.secondsToDuration().toStr;
} }
return l10n.stopped; return l10n.stopped;
@@ -236,6 +245,15 @@ final class PveNode extends PveResIface {
maxcpu: json['maxcpu'], maxcpu: json['maxcpu'],
); );
} }
bool get isRunning => status == 'online';
String get topRight {
if (isRunning) {
return uptime.secondsToDuration().toStr;
}
return l10n.stopped;
}
} }
final class PveStorage extends PveResIface { final class PveStorage extends PveResIface {

View File

@@ -2,6 +2,7 @@ import 'dart:async';
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';
@@ -10,6 +11,8 @@ final class PveProvider extends ChangeNotifier {
late final String addr; late final String addr;
//late final SSHClient _client; //late final SSHClient _client;
final data = ValueNotifier<PveRes?>(null);
PveProvider({ PveProvider({
required this.spi, required this.spi,
}) { }) {
@@ -77,11 +80,12 @@ final class PveProvider extends ChangeNotifier {
final resp = await session.get('$addr/api2/json/cluster/resources'); final resp = await session.get('$addr/api2/json/cluster/resources');
final list = resp.data['data'] as List; final list = resp.data['data'] as List;
final items = list.map((e) => PveResIface.fromJson(e)).toList(); final items = list.map((e) => PveResIface.fromJson(e)).toList();
final qemus = <PveQemu>[];
final lxcs = <PveLxc>[]; final Order<PveQemu> qemus = [];
final nodes = <PveNode>[]; final Order<PveLxc> lxcs = [];
final storages = <PveStorage>[]; final Order<PveNode> nodes = [];
final sdns = <PveSdn>[]; final Order<PveStorage> storages = [];
final Order<PveSdn> sdns = [];
for (final item in items) { for (final item in items) {
switch (item.type) { switch (item.type) {
case PveResType.lxc: case PveResType.lxc:
@@ -101,12 +105,34 @@ final class PveProvider extends ChangeNotifier {
break; break;
} }
} }
return PveRes(
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, qemus: qemus,
lxcs: lxcs, lxcs: lxcs,
nodes: nodes, nodes: nodes,
storages: storages, storages: storages,
sdns: sdns, sdns: sdns,
); );
data.value = res;
return res;
} }
} }

View File

@@ -22,6 +22,10 @@ abstract final class UIs {
); );
static const text13Grey = TextStyle(color: Colors.grey, fontSize: 13); static const text13Grey = TextStyle(color: Colors.grey, fontSize: 13);
static const text15 = TextStyle(fontSize: 15); static const text15 = TextStyle(fontSize: 15);
static const text15Bold = TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
);
static const text18 = TextStyle(fontSize: 18); static const text18 = TextStyle(fontSize: 18);
static const text27 = TextStyle(fontSize: 27); static const text27 = TextStyle(fontSize: 27);
static const textGrey = TextStyle(color: Colors.grey); static const textGrey = TextStyle(color: Colors.grey);
@@ -39,6 +43,7 @@ abstract final class UIs {
/// SizedBox /// SizedBox
static const placeholder = SizedBox(); static const placeholder = SizedBox();
static const height7 = SizedBox(height: 7);
static const height13 = SizedBox(height: 13); static const height13 = SizedBox(height: 13);
static const height77 = SizedBox(height: 77); static const height77 = SizedBox(height: 77);
static const width13 = SizedBox(width: 13); static const width13 = SizedBox(width: 13);

View File

@@ -48,6 +48,7 @@
"containerName": "Container Name", "containerName": "Container Name",
"containerStatus": "Container Status", "containerStatus": "Container Status",
"containerTrySudoTip": "Zum Beispiel: In der App ist der Benutzer auf aaa eingestellt, aber Docker ist unter dem Root-Benutzer installiert. In diesem Fall müssen Sie diese Option aktivieren", "containerTrySudoTip": "Zum Beispiel: In der App ist der Benutzer auf aaa eingestellt, aber Docker ist unter dem Root-Benutzer installiert. In diesem Fall müssen Sie diese Option aktivieren",
"content": "Inhalt",
"convert": "Konvertieren", "convert": "Konvertieren",
"copy": "Kopieren", "copy": "Kopieren",
"copyPath": "Pfad kopieren", "copyPath": "Pfad kopieren",
@@ -185,6 +186,7 @@
"pingNoServer": "Kein Server zum Anpingen.\nBitte füge einen Server hinzu.", "pingNoServer": "Kein Server zum Anpingen.\nBitte füge einen Server hinzu.",
"pkg": "Pkg", "pkg": "Pkg",
"platformNotSupportUpdate": "Die aktuelle Plattform unterstützt keine In-App-Updates.\nBitte kompiliere vom Quellcode und installiere sie.", "platformNotSupportUpdate": "Die aktuelle Plattform unterstützt keine In-App-Updates.\nBitte kompiliere vom Quellcode und installiere sie.",
"plugInType": "Einfügetyp",
"plzEnterHost": "Bitte Host eingeben.", "plzEnterHost": "Bitte Host eingeben.",
"plzSelectKey": "Wähle einen Key.", "plzSelectKey": "Wähle einen Key.",
"port": "Port", "port": "Port",

View File

@@ -48,6 +48,7 @@
"containerName": "Container name", "containerName": "Container name",
"containerStatus": "Container status", "containerStatus": "Container status",
"containerTrySudoTip": "For example: In the app, the user is set to aaa, but Docker is installed under the root user. In this case, you need to enable this option.", "containerTrySudoTip": "For example: In the app, the user is set to aaa, but Docker is installed under the root user. In this case, you need to enable this option.",
"content": "Content",
"convert": "Convert", "convert": "Convert",
"copy": "Copy", "copy": "Copy",
"copyPath": "Copy path", "copyPath": "Copy path",
@@ -185,6 +186,7 @@
"pingNoServer": "No server to ping.\nPlease add a server in server tab.", "pingNoServer": "No server to ping.\nPlease add a server in server tab.",
"pkg": "Pkg", "pkg": "Pkg",
"platformNotSupportUpdate": "Current platform does not support in app update.\nPlease build from source and install it.", "platformNotSupportUpdate": "Current platform does not support in app update.\nPlease build from source and install it.",
"plugInType": "Insertion Type",
"plzEnterHost": "Please enter host.", "plzEnterHost": "Please enter host.",
"plzSelectKey": "Please select a key.", "plzSelectKey": "Please select a key.",
"port": "Port", "port": "Port",

View File

@@ -48,6 +48,7 @@
"containerName": "Nombre del contenedor", "containerName": "Nombre del contenedor",
"containerStatus": "Estado del contenedor", "containerStatus": "Estado del contenedor",
"containerTrySudoTip": "Por ejemplo: si configuras el usuario dentro de la app como aaa, pero Docker está instalado bajo el usuario root, entonces necesitarás habilitar esta opción", "containerTrySudoTip": "Por ejemplo: si configuras el usuario dentro de la app como aaa, pero Docker está instalado bajo el usuario root, entonces necesitarás habilitar esta opción",
"content": "Contenido",
"convert": "Convertir", "convert": "Convertir",
"copy": "Copiar", "copy": "Copiar",
"copyPath": "Copiar ruta", "copyPath": "Copiar ruta",
@@ -185,6 +186,7 @@
"pingNoServer": "No hay servidores disponibles para hacer Ping\nPor favor, añade un servidor en la pestaña de servidores y vuelve a intentarlo", "pingNoServer": "No hay servidores disponibles para hacer Ping\nPor favor, añade un servidor en la pestaña de servidores y vuelve a intentarlo",
"pkg": "Gestión de paquetes", "pkg": "Gestión de paquetes",
"platformNotSupportUpdate": "La plataforma actual no soporta actualizaciones, por favor instala manualmente la última versión del código fuente", "platformNotSupportUpdate": "La plataforma actual no soporta actualizaciones, por favor instala manualmente la última versión del código fuente",
"plugInType": "Tipo de inserción",
"plzEnterHost": "Por favor, introduce el host", "plzEnterHost": "Por favor, introduce el host",
"plzSelectKey": "Por favor, selecciona una llave privada", "plzSelectKey": "Por favor, selecciona una llave privada",
"port": "Puerto", "port": "Puerto",

View File

@@ -48,6 +48,7 @@
"containerName": "Nom du conteneur", "containerName": "Nom du conteneur",
"containerStatus": "Statut du conteneur", "containerStatus": "Statut du conteneur",
"containerTrySudoTip": "Par exemple : dans l'application, l'utilisateur est défini comme aaa, mais Docker est installé en tant qu'utilisateur root. Dans ce cas, vous devez activer cette option.", "containerTrySudoTip": "Par exemple : dans l'application, l'utilisateur est défini comme aaa, mais Docker est installé en tant qu'utilisateur root. Dans ce cas, vous devez activer cette option.",
"content": "Contenu",
"convert": "Convertir", "convert": "Convertir",
"copy": "Copier", "copy": "Copier",
"copyPath": "Copier le chemin", "copyPath": "Copier le chemin",
@@ -185,6 +186,7 @@
"pingNoServer": "Aucun serveur pour ping.\nVeuillez ajouter un serveur dans l'onglet serveur.", "pingNoServer": "Aucun serveur pour ping.\nVeuillez ajouter un serveur dans l'onglet serveur.",
"pkg": "Pkg", "pkg": "Pkg",
"platformNotSupportUpdate": "La plateforme actuelle ne prend pas en charge la mise à jour dans l'application. \nVeuillez le compiler à partir de la source et l'installer.", "platformNotSupportUpdate": "La plateforme actuelle ne prend pas en charge la mise à jour dans l'application. \nVeuillez le compiler à partir de la source et l'installer.",
"plugInType": "Type d'insertion",
"plzEnterHost": "Veuillez saisir l'hôte.", "plzEnterHost": "Veuillez saisir l'hôte.",
"plzSelectKey": "Veuillez sélectionner une clé.", "plzSelectKey": "Veuillez sélectionner une clé.",
"port": "Port", "port": "Port",

View File

@@ -48,6 +48,7 @@
"containerName": "Nama kontainer", "containerName": "Nama kontainer",
"containerStatus": "Status wadah", "containerStatus": "Status wadah",
"containerTrySudoTip": "Contohnya: Di dalam aplikasi, pengguna diatur sebagai aaa, tetapi Docker diinstal di bawah pengguna root. Dalam kasus ini, Anda perlu mengaktifkan opsi ini.", "containerTrySudoTip": "Contohnya: Di dalam aplikasi, pengguna diatur sebagai aaa, tetapi Docker diinstal di bawah pengguna root. Dalam kasus ini, Anda perlu mengaktifkan opsi ini.",
"content": "Konten",
"convert": "Mengubah", "convert": "Mengubah",
"copy": "Menyalin", "copy": "Menyalin",
"copyPath": "Path Copy", "copyPath": "Path Copy",
@@ -185,6 +186,7 @@
"pingNoServer": "Tidak ada server untuk melakukan ping.\nHarap tambahkan server di tab Server.", "pingNoServer": "Tidak ada server untuk melakukan ping.\nHarap tambahkan server di tab Server.",
"pkg": "Pkg", "pkg": "Pkg",
"platformNotSupportUpdate": "Platform saat ini tidak mendukung pembaruan aplikasi.\nSilakan bangun dari sumber dan instal.", "platformNotSupportUpdate": "Platform saat ini tidak mendukung pembaruan aplikasi.\nSilakan bangun dari sumber dan instal.",
"plugInType": "Jenis Penyisipan",
"plzEnterHost": "Harap masukkan host.", "plzEnterHost": "Harap masukkan host.",
"plzSelectKey": "Pilih kunci.", "plzSelectKey": "Pilih kunci.",
"port": "Port", "port": "Port",

View File

@@ -48,6 +48,7 @@
"containerName": "コンテナ名", "containerName": "コンテナ名",
"containerStatus": "コンテナの状態", "containerStatus": "コンテナの状態",
"containerTrySudoTip": "例アプリ内でユーザーをaaaに設定しているが、Dockerがrootユーザーでインストールされている場合、このオプションを有効にする必要があります", "containerTrySudoTip": "例アプリ内でユーザーをaaaに設定しているが、Dockerがrootユーザーでインストールされている場合、このオプションを有効にする必要があります",
"content": "コンテンツ",
"convert": "変換", "convert": "変換",
"copy": "コピー", "copy": "コピー",
"copyPath": "パスをコピー", "copyPath": "パスをコピー",
@@ -185,6 +186,7 @@
"pingNoServer": "Pingに使用するサーバーがありません\nサーバータブでサーバーを追加してから再試行してください", "pingNoServer": "Pingに使用するサーバーがありません\nサーバータブでサーバーを追加してから再試行してください",
"pkg": "パッケージ管理", "pkg": "パッケージ管理",
"platformNotSupportUpdate": "現在のプラットフォームは更新をサポートしていません。最新のソースコードをコンパイルして手動でインストールしてください", "platformNotSupportUpdate": "現在のプラットフォームは更新をサポートしていません。最新のソースコードをコンパイルして手動でインストールしてください",
"plugInType": "挿入タイプ",
"plzEnterHost": "ホストを入力してください", "plzEnterHost": "ホストを入力してください",
"plzSelectKey": "プライベートキーを選択してください", "plzSelectKey": "プライベートキーを選択してください",
"port": "ポート", "port": "ポート",

View File

@@ -48,6 +48,7 @@
"containerName": "Nome do contêiner", "containerName": "Nome do contêiner",
"containerStatus": "Estado do contêiner", "containerStatus": "Estado do contêiner",
"containerTrySudoTip": "Por exemplo: se o usuário for definido como aaa dentro do app, mas o Docker estiver instalado sob o usuário root, esta opção precisará ser ativada", "containerTrySudoTip": "Por exemplo: se o usuário for definido como aaa dentro do app, mas o Docker estiver instalado sob o usuário root, esta opção precisará ser ativada",
"content": "Conteúdo",
"convert": "Converter", "convert": "Converter",
"copy": "Copiar", "copy": "Copiar",
"copyPath": "Copiar caminho", "copyPath": "Copiar caminho",
@@ -185,6 +186,7 @@
"pingNoServer": "Nenhum servidor disponível para Ping\nPor favor, adicione um servidor na aba de servidores e tente novamente", "pingNoServer": "Nenhum servidor disponível para Ping\nPor favor, adicione um servidor na aba de servidores e tente novamente",
"pkg": "Gerenciamento de pacotes", "pkg": "Gerenciamento de pacotes",
"platformNotSupportUpdate": "Atualização não suportada na plataforma atual, por favor, instale manualmente a versão mais recente do código-fonte", "platformNotSupportUpdate": "Atualização não suportada na plataforma atual, por favor, instale manualmente a versão mais recente do código-fonte",
"plugInType": "Tipo de Inserção",
"plzEnterHost": "Por favor, insira o host", "plzEnterHost": "Por favor, insira o host",
"plzSelectKey": "Por favor, selecione uma chave privada", "plzSelectKey": "Por favor, selecione uma chave privada",
"port": "Porta", "port": "Porta",

View File

@@ -48,6 +48,7 @@
"containerName": "имя контейнера", "containerName": "имя контейнера",
"containerStatus": "статус контейнера", "containerStatus": "статус контейнера",
"containerTrySudoTip": "Например: если пользователь в приложении установлен как aaa, но Docker установлен под пользователем root, тогда нужно включить эту опцию", "containerTrySudoTip": "Например: если пользователь в приложении установлен как aaa, но Docker установлен под пользователем root, тогда нужно включить эту опцию",
"content": "Содержимое",
"convert": "конвертировать", "convert": "конвертировать",
"copy": "копировать", "copy": "копировать",
"copyPath": "копировать путь", "copyPath": "копировать путь",
@@ -185,6 +186,7 @@
"pingNoServer": "Нет доступных серверов для Ping\nПожалуйста, добавьте серверы на вкладке серверов и попробуйте снова", "pingNoServer": "Нет доступных серверов для Ping\nПожалуйста, добавьте серверы на вкладке серверов и попробуйте снова",
"pkg": "менеджер пакетов", "pkg": "менеджер пакетов",
"platformNotSupportUpdate": "Текущая платформа не поддерживает обновления, пожалуйста, вручную установите последнюю версию из исходного кода", "platformNotSupportUpdate": "Текущая платформа не поддерживает обновления, пожалуйста, вручную установите последнюю версию из исходного кода",
"plugInType": "Тип вставки",
"plzEnterHost": "Пожалуйста, введите хост", "plzEnterHost": "Пожалуйста, введите хост",
"plzSelectKey": "Пожалуйста, выберите ключ", "plzSelectKey": "Пожалуйста, выберите ключ",
"port": "порт", "port": "порт",

View File

@@ -48,6 +48,7 @@
"containerName": "容器名", "containerName": "容器名",
"containerStatus": "容器状态", "containerStatus": "容器状态",
"containerTrySudoTip": "例如在应用内将用户设置为aaa但是Docker安装在root用户下这时就需要启用此选项", "containerTrySudoTip": "例如在应用内将用户设置为aaa但是Docker安装在root用户下这时就需要启用此选项",
"content": "内容",
"convert": "转换", "convert": "转换",
"copy": "复制", "copy": "复制",
"copyPath": "复制路径", "copyPath": "复制路径",
@@ -185,6 +186,7 @@
"pingNoServer": "没有服务器可用于Ping\n请在服务器tab添加服务器后再试", "pingNoServer": "没有服务器可用于Ping\n请在服务器tab添加服务器后再试",
"pkg": "包管理", "pkg": "包管理",
"platformNotSupportUpdate": "当前平台不支持更新,请编译最新源码后手动安装", "platformNotSupportUpdate": "当前平台不支持更新,请编译最新源码后手动安装",
"plugInType": "插入类型",
"plzEnterHost": "请输入主机", "plzEnterHost": "请输入主机",
"plzSelectKey": "请选择私钥", "plzSelectKey": "请选择私钥",
"port": "端口", "port": "端口",

View File

@@ -48,6 +48,7 @@
"containerName": "容器名稱", "containerName": "容器名稱",
"containerStatus": "容器狀態", "containerStatus": "容器狀態",
"containerTrySudoTip": "例如App内设置用户为aaa但是Docker安装在root用户这时就需要开启此选项", "containerTrySudoTip": "例如App内设置用户为aaa但是Docker安装在root用户这时就需要开启此选项",
"content": "內容",
"convert": "轉換", "convert": "轉換",
"copy": "複製", "copy": "複製",
"copyPath": "複製路徑", "copyPath": "複製路徑",
@@ -185,6 +186,7 @@
"pingNoServer": "沒有服務器可用於Ping\n請在服務器tab新增服務器後再試", "pingNoServer": "沒有服務器可用於Ping\n請在服務器tab新增服務器後再試",
"pkg": "包管理", "pkg": "包管理",
"platformNotSupportUpdate": "當前平台不支持更新,請編譯最新源碼後手動安裝", "platformNotSupportUpdate": "當前平台不支持更新,請編譯最新源碼後手動安裝",
"plugInType": "插入類型",
"plzEnterHost": "請輸入主機", "plzEnterHost": "請輸入主機",
"plzSelectKey": "請選擇私鑰", "plzSelectKey": "請選擇私鑰",
"port": "端口", "port": "端口",

View File

@@ -1,13 +1,19 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:toolbox/core/extension/context/locale.dart';
import 'package:toolbox/core/extension/numx.dart'; import 'package:toolbox/core/extension/numx.dart';
import 'package:toolbox/core/extension/widget.dart'; import 'package:toolbox/core/extension/widget.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/provider/pve.dart'; import 'package:toolbox/data/provider/pve.dart';
import 'package:toolbox/data/res/color.dart';
import 'package:toolbox/data/res/store.dart';
import 'package:toolbox/data/res/ui.dart'; import 'package:toolbox/data/res/ui.dart';
import 'package:toolbox/view/widget/appbar.dart'; import 'package:toolbox/view/widget/appbar.dart';
import 'package:toolbox/view/widget/future_widget.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/two_line_text.dart';
final class PvePage extends StatefulWidget { final class PvePage extends StatefulWidget {
final ServerPrivateInfo spi; final ServerPrivateInfo spi;
@@ -24,8 +30,9 @@ final class PvePage extends StatefulWidget {
const _kHorziPadding = 11.0; const _kHorziPadding = 11.0;
final class _PvePageState extends State<PvePage> { final class _PvePageState extends State<PvePage> {
late final pve = PveProvider(spi: widget.spi); late final _pve = PveProvider(spi: widget.spi);
late MediaQueryData _media; late MediaQueryData _media;
Timer? _timer;
@override @override
void didChangeDependencies() { void didChangeDependencies() {
@@ -33,121 +40,272 @@ final class _PvePageState extends State<PvePage> {
_media = MediaQuery.of(context); _media = MediaQuery.of(context);
} }
@override
void initState() {
super.initState();
_initRefreshTimer();
}
@override
void dispose() {
super.dispose();
_timer?.cancel();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: const CustomAppBar( appBar: CustomAppBar(
title: Text('PVE'), title: TwoLineText(up: 'PVE', down: widget.spi.name),
),
body: ValueListenableBuilder(
valueListenable: _pve.data,
builder: (_, val, __) {
return _buildBody(val);
},
), ),
body: _buildBody(),
); );
} }
Widget _buildBody() { Widget _buildBody(PveRes? data) {
if (pve.err.value != null) { if (_pve.err.value != null) {
return Center( return Center(
child: Text('Failed to connect to PVE: ${pve.err.value}'), child: Text('Failed to connect to PVE: ${_pve.err.value}'),
); );
} }
return FutureWidget(
future: pve.list(),
error: (e, _) {
return Center(
child: Text('Failed to list PVE: $e'),
);
},
loading: UIs.centerLoading,
success: (data) {
if (data == null) {
return const Center(
child: Text('No PVE Resource found'),
);
}
PveResType? lastType; if (data == null) {
return ListView.separated( return UIs.centerLoading;
padding: const EdgeInsets.symmetric( }
horizontal: _kHorziPadding,
vertical: 7, PveResType? lastType;
), return ListView.builder(
itemCount: data.length + 1, padding: const EdgeInsets.symmetric(
separatorBuilder: (context, index) { horizontal: _kHorziPadding,
final type = switch (data[index]) { vertical: 7,
final PveNode _ => PveResType.node, ),
final PveQemu _ => PveResType.qemu, itemCount: data.length * 2,
final PveLxc _ => PveResType.lxc, itemBuilder: (context, index) {
final PveStorage _ => PveResType.storage, final item = data[index ~/ 2];
final PveSdn _ => PveResType.sdn, if (index % 2 == 0) {
}; final type = switch (item) {
if (type == lastType) { final PveNode _ => PveResType.node,
return UIs.placeholder; final PveQemu _ => PveResType.qemu,
} final PveLxc _ => PveResType.lxc,
return Padding( final PveStorage _ => PveResType.storage,
padding: const EdgeInsets.symmetric(vertical: 7), final PveSdn _ => PveResType.sdn,
child: Align( };
alignment: Alignment.center, if (type == lastType) {
child: Text( return UIs.placeholder;
type.toStr, }
style: const TextStyle( lastType = type;
fontWeight: FontWeight.bold, return Padding(
color: Colors.grey, padding: const EdgeInsets.symmetric(vertical: 7),
), child: Align(
alignment: Alignment.center,
child: Text(
type.toStr,
style: const TextStyle(
fontWeight: FontWeight.bold,
color: Colors.grey,
), ),
), ),
); ),
}, );
itemBuilder: (context, index) { }
if (index == 0) return UIs.placeholder; return switch (item) {
final item = data[index - 1]; final PveNode _ => _buildNode(item),
switch (item) { final PveQemu _ => _buildQemu(item),
case final PveNode item: final PveLxc _ => _buildLxc(item),
lastType = PveResType.node; final PveStorage _ => _buildStorage(item),
return _buildNode(item); final PveSdn _ => _buildSdn(item),
case final PveQemu item: };
lastType = PveResType.qemu;
return _buildQemu(item);
case final PveLxc item:
lastType = PveResType.lxc;
return _buildLxc(item);
case final PveStorage item:
lastType = PveResType.storage;
return _buildStorage(item);
case final PveSdn item:
lastType = PveResType.sdn;
return _buildSdn(item);
}
},
);
}, },
); );
} }
Widget _buildNode(PveNode item) {
final valueAnim = AlwaysStoppedAnimation(primaryColor);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 13, horizontal: 13),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Text(item.node, style: UIs.text15Bold),
const Spacer(),
Text(item.topRight, style: UIs.text12Grey),
],
),
UIs.height13,
// Row(
// mainAxisAlignment: MainAxisAlignment.spaceAround,
// children: [
// _wrap(PercentCircle(percent: item.cpu / item.maxcpu), 3),
// _wrap(PercentCircle(percent: item.mem / item.maxmem), 3),
// ],
// ),
Row(
children: [
const Icon(Icons.memory, size: 13, color: Colors.grey),
UIs.width7,
const Text('CPU', style: UIs.text12Grey),
const Spacer(),
Text(
'${(item.cpu * 100).toStringAsFixed(1)} %',
style: UIs.text12Grey,
),
],
),
const SizedBox(height: 3),
LinearProgressIndicator(
value: item.cpu / item.maxcpu,
minHeight: 7,
valueColor: valueAnim,
),
UIs.height7,
Row(
children: [
const Icon(Icons.view_agenda, size: 13, color: Colors.grey),
UIs.width7,
const Text('RAM', style: UIs.text12Grey),
const Spacer(),
Text(
'${item.mem.bytes2Str} / ${item.maxmem.bytes2Str}',
style: UIs.text12Grey,
),
],
),
const SizedBox(height: 3),
LinearProgressIndicator(
value: item.mem / item.maxmem,
minHeight: 7,
valueColor: valueAnim,
),
],
),
).card;
}
Widget _buildQemu(PveQemu item) { Widget _buildQemu(PveQemu item) {
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
ListTile( UIs.height13,
title: Text(item.name), Row(
trailing: Text(item.topRight), children: [
UIs.width13,
Text(item.name, style: UIs.text13Bold),
const Spacer(),
Text(item.topRight, style: UIs.text12Grey),
UIs.width13,
],
), ),
if (item.isRunning) Row( if (item.isRunning)
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
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,
)
],
),
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,
)
],
),
4),
],
),
UIs.height13,
],
).card;
}
Widget _buildLxc(PveLxc item) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
UIs.height13,
Row(
children: [
UIs.width13,
Text(item.name, style: UIs.text13Bold),
const Spacer(),
Text(item.topRight, style: UIs.text12Grey),
UIs.width13,
],
),
UIs.height7,
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround, mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [ children: [
_wrap(PercentCircle(percent: (item.cpu / item.maxcpu) * 100), 4), _wrap(PercentCircle(percent: (item.cpu / item.maxcpu) * 100), 4),
_wrap(PercentCircle(percent: (item.mem / item.maxmem) * 100), 4), _wrap(PercentCircle(percent: (item.mem / item.maxmem) * 100), 4),
_wrap(PercentCircle(percent: (item.disk / item.maxdisk) * 100), 4),
_wrap( _wrap(
Column( Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Text( Text(
item.netin.bytes2Str, '${l10n.read}:\n${item.diskread.bytes2Str}',
style: const TextStyle(fontSize: 10, color: Colors.grey), style: UIs.text11Grey,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 3), const SizedBox(height: 3),
Text( Text(
item.netout.bytes2Str, '${l10n.write}:\n${item.diskwrite.bytes2Str}',
style: const TextStyle(fontSize: 10, color: Colors.grey), 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, textAlign: TextAlign.center,
) )
], ],
@@ -155,29 +313,29 @@ final class _PvePageState extends State<PvePage> {
4), 4),
], ],
), ),
if (item.isRunning) UIs.height13, UIs.height13,
], ],
).card; ).card;
} }
Widget _buildLxc(PveLxc item) {
return ListTile(
title: Text(item.name),
trailing: Text(item.status),
).card;
}
Widget _buildNode(PveNode item) {
return ListTile(
title: Text(item.node),
trailing: Text(item.status),
).card;
}
Widget _buildStorage(PveStorage item) { Widget _buildStorage(PveStorage item) {
return ListTile( return Padding(
title: Text(item.storage), padding: const EdgeInsets.all(13),
trailing: Text(item.status), child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Text(item.storage, style: UIs.text13Bold),
const Spacer(),
Text(item.status, style: UIs.text12Grey),
],
),
UIs.height7,
KvRow(k: l10n.content, v: item.content),
KvRow(k: l10n.plugInType, v: item.plugintype),
],
),
).card; ).card;
} }
@@ -194,4 +352,14 @@ final class _PvePageState extends State<PvePage> {
child: child, child: child,
); );
} }
void _initRefreshTimer() {
_timer = Timer.periodic(
Duration(seconds: Stores.setting.serverStatusUpdateInterval.fetch()),
(_) {
if (mounted) {
_pve.list();
}
});
}
} }

View File

@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
import 'package:toolbox/data/res/ui.dart';
final class KvRow extends StatelessWidget {
final String k;
final String v;
final void Function()? onTap;
const KvRow({
super.key,
required this.k,
required this.v,
this.onTap,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(k, style: UIs.text12),
UIs.width7,
Text(
v,
style: UIs.text11Grey,
overflow: TextOverflow.ellipsis,
),
if (onTap != null) UIs.width7,
if (onTap != null) const Icon(Icons.keyboard_arrow_right, size: 16),
],
),
),
);
}
}