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

@@ -121,6 +121,15 @@ final class PveLxc extends PveResIface {
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 {
@@ -190,7 +199,7 @@ final class PveQemu extends PveResIface {
bool get isRunning => status == 'running';
String get topRight {
if (!isRunning) {
if (isRunning) {
return uptime.secondsToDuration().toStr;
}
return l10n.stopped;
@@ -236,6 +245,15 @@ final class PveNode extends PveResIface {
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 {

View File

@@ -2,6 +2,7 @@ import 'dart:async';
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';
@@ -10,6 +11,8 @@ final class PveProvider extends ChangeNotifier {
late final String addr;
//late final SSHClient _client;
final data = ValueNotifier<PveRes?>(null);
PveProvider({
required this.spi,
}) {
@@ -77,11 +80,12 @@ final class PveProvider extends ChangeNotifier {
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 qemus = <PveQemu>[];
final lxcs = <PveLxc>[];
final nodes = <PveNode>[];
final storages = <PveStorage>[];
final sdns = <PveSdn>[];
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:
@@ -101,12 +105,34 @@ final class PveProvider extends ChangeNotifier {
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,
lxcs: lxcs,
nodes: nodes,
storages: storages,
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 text15 = TextStyle(fontSize: 15);
static const text15Bold = TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
);
static const text18 = TextStyle(fontSize: 18);
static const text27 = TextStyle(fontSize: 27);
static const textGrey = TextStyle(color: Colors.grey);
@@ -39,6 +43,7 @@ abstract final class UIs {
/// SizedBox
static const placeholder = SizedBox();
static const height7 = SizedBox(height: 7);
static const height13 = SizedBox(height: 13);
static const height77 = SizedBox(height: 77);
static const width13 = SizedBox(width: 13);

View File

@@ -48,6 +48,7 @@
"containerName": "Container Name",
"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",
"content": "Inhalt",
"convert": "Konvertieren",
"copy": "Kopieren",
"copyPath": "Pfad kopieren",
@@ -185,6 +186,7 @@
"pingNoServer": "Kein Server zum Anpingen.\nBitte füge einen Server hinzu.",
"pkg": "Pkg",
"platformNotSupportUpdate": "Die aktuelle Plattform unterstützt keine In-App-Updates.\nBitte kompiliere vom Quellcode und installiere sie.",
"plugInType": "Einfügetyp",
"plzEnterHost": "Bitte Host eingeben.",
"plzSelectKey": "Wähle einen Key.",
"port": "Port",

View File

@@ -48,6 +48,7 @@
"containerName": "Container name",
"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.",
"content": "Content",
"convert": "Convert",
"copy": "Copy",
"copyPath": "Copy path",
@@ -185,6 +186,7 @@
"pingNoServer": "No server to ping.\nPlease add a server in server tab.",
"pkg": "Pkg",
"platformNotSupportUpdate": "Current platform does not support in app update.\nPlease build from source and install it.",
"plugInType": "Insertion Type",
"plzEnterHost": "Please enter host.",
"plzSelectKey": "Please select a key.",
"port": "Port",

View File

@@ -48,6 +48,7 @@
"containerName": "Nombre 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",
"content": "Contenido",
"convert": "Convertir",
"copy": "Copiar",
"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",
"pkg": "Gestión de paquetes",
"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",
"plzSelectKey": "Por favor, selecciona una llave privada",
"port": "Puerto",

View File

@@ -48,6 +48,7 @@
"containerName": "Nom 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.",
"content": "Contenu",
"convert": "Convertir",
"copy": "Copier",
"copyPath": "Copier le chemin",
@@ -185,6 +186,7 @@
"pingNoServer": "Aucun serveur pour ping.\nVeuillez ajouter un serveur dans l'onglet serveur.",
"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.",
"plugInType": "Type d'insertion",
"plzEnterHost": "Veuillez saisir l'hôte.",
"plzSelectKey": "Veuillez sélectionner une clé.",
"port": "Port",

View File

@@ -48,6 +48,7 @@
"containerName": "Nama kontainer",
"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.",
"content": "Konten",
"convert": "Mengubah",
"copy": "Menyalin",
"copyPath": "Path Copy",
@@ -185,6 +186,7 @@
"pingNoServer": "Tidak ada server untuk melakukan ping.\nHarap tambahkan server di tab Server.",
"pkg": "Pkg",
"platformNotSupportUpdate": "Platform saat ini tidak mendukung pembaruan aplikasi.\nSilakan bangun dari sumber dan instal.",
"plugInType": "Jenis Penyisipan",
"plzEnterHost": "Harap masukkan host.",
"plzSelectKey": "Pilih kunci.",
"port": "Port",

View File

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

View File

@@ -48,6 +48,7 @@
"containerName": "Nome 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",
"content": "Conteúdo",
"convert": "Converter",
"copy": "Copiar",
"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",
"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",
"plugInType": "Tipo de Inserção",
"plzEnterHost": "Por favor, insira o host",
"plzSelectKey": "Por favor, selecione uma chave privada",
"port": "Porta",

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,19 @@
import 'dart:async';
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/widget.dart';
import 'package:toolbox/data/model/server/pve.dart';
import 'package:toolbox/data/model/server/server_private_info.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/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/two_line_text.dart';
final class PvePage extends StatefulWidget {
final ServerPrivateInfo spi;
@@ -24,8 +30,9 @@ final class PvePage extends StatefulWidget {
const _kHorziPadding = 11.0;
final class _PvePageState extends State<PvePage> {
late final pve = PveProvider(spi: widget.spi);
late final _pve = PveProvider(spi: widget.spi);
late MediaQueryData _media;
Timer? _timer;
@override
void didChangeDependencies() {
@@ -33,46 +40,55 @@ final class _PvePageState extends State<PvePage> {
_media = MediaQuery.of(context);
}
@override
void initState() {
super.initState();
_initRefreshTimer();
}
@override
void dispose() {
super.dispose();
_timer?.cancel();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: const CustomAppBar(
title: Text('PVE'),
appBar: CustomAppBar(
title: TwoLineText(up: 'PVE', down: widget.spi.name),
),
body: ValueListenableBuilder(
valueListenable: _pve.data,
builder: (_, val, __) {
return _buildBody(val);
},
),
body: _buildBody(),
);
}
Widget _buildBody() {
if (pve.err.value != null) {
Widget _buildBody(PveRes? data) {
if (_pve.err.value != null) {
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'),
);
return UIs.centerLoading;
}
PveResType? lastType;
return ListView.separated(
return ListView.builder(
padding: const EdgeInsets.symmetric(
horizontal: _kHorziPadding,
vertical: 7,
),
itemCount: data.length + 1,
separatorBuilder: (context, index) {
final type = switch (data[index]) {
itemCount: data.length * 2,
itemBuilder: (context, index) {
final item = data[index ~/ 2];
if (index % 2 == 0) {
final type = switch (item) {
final PveNode _ => PveResType.node,
final PveQemu _ => PveResType.qemu,
final PveLxc _ => PveResType.lxc,
@@ -82,6 +98,7 @@ final class _PvePageState extends State<PvePage> {
if (type == lastType) {
return UIs.placeholder;
}
lastType = type;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 7),
child: Align(
@@ -95,59 +112,135 @@ final class _PvePageState extends State<PvePage> {
),
),
);
},
itemBuilder: (context, index) {
if (index == 0) return UIs.placeholder;
final item = data[index - 1];
switch (item) {
case final PveNode item:
lastType = PveResType.node;
return _buildNode(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);
}
return switch (item) {
final PveNode _ => _buildNode(item),
final PveQemu _ => _buildQemu(item),
final PveLxc _ => _buildLxc(item),
final PveStorage _ => _buildStorage(item),
final PveSdn _ => _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) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
title: Text(item.name),
trailing: Text(item.topRight),
UIs.height13,
Row(
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(PercentCircle(percent: (item.disk / item.maxdisk) * 100), 4),
_wrap(
Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
item.netin.bytes2Str,
style: const TextStyle(fontSize: 10, color: Colors.grey),
'${l10n.read}:\n${item.diskread.bytes2Str}',
style: UIs.text11Grey,
textAlign: TextAlign.center,
),
const SizedBox(height: 3),
Text(
item.netout.bytes2Str,
style: const TextStyle(fontSize: 10, color: Colors.grey),
'${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,
)
],
@@ -155,29 +248,94 @@ final class _PvePageState extends State<PvePage> {
4),
],
),
if (item.isRunning) UIs.height13,
UIs.height13,
],
).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),
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,
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 _buildStorage(PveStorage item) {
return ListTile(
title: Text(item.storage),
trailing: Text(item.status),
return Padding(
padding: const EdgeInsets.all(13),
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;
}
@@ -194,4 +352,14 @@ final class _PvePageState extends State<PvePage> {
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),
],
),
),
);
}
}