From 26264ecdea7d5a2607eb21d0fbcde1165bc5d913 Mon Sep 17 00:00:00 2001 From: lollipopkit Date: Mon, 18 Mar 2024 18:34:25 +0800 Subject: [PATCH] new: pve (#307) --- lib/core/extension/duration.dart | 20 + lib/core/extension/numx.dart | 5 + lib/core/extension/order.dart | 11 +- lib/core/extension/widget.dart | 7 + lib/core/route.dart | 5 + lib/data/model/app/menu/server_func.dart | 4 + lib/data/model/app/menu/server_func.g.dart | 5 + lib/data/model/server/custom.dart | 36 ++ lib/data/model/server/custom.g.dart | 44 +++ lib/data/model/server/pve.dart | 351 ++++++++++++++++++ .../model/server/server_private_info.dart | 37 +- .../model/server/server_private_info.g.dart | 7 +- lib/data/provider/pve.dart | 112 ++++++ lib/l10n/app_de.arb | 6 + lib/l10n/app_en.arb | 6 + lib/l10n/app_es.arb | 6 + lib/l10n/app_fr.arb | 6 + lib/l10n/app_id.arb | 6 + lib/l10n/app_ja.arb | 6 + lib/l10n/app_pt.arb | 6 + lib/l10n/app_ru.arb | 6 + lib/l10n/app_zh.arb | 6 + lib/l10n/app_zh_tw.arb | 6 + lib/main.dart | 2 + lib/view/page/backup.dart | 28 +- lib/view/page/pve.dart | 197 ++++++++++ lib/view/page/server/edit.dart | 24 +- lib/view/page/server/tab.dart | 32 +- lib/view/widget/percent_circle.dart | 41 ++ lib/view/widget/server_func_btns.dart | 6 + test/pve_test.dart | 136 +++++++ 31 files changed, 1114 insertions(+), 56 deletions(-) create mode 100644 lib/core/extension/duration.dart create mode 100644 lib/core/extension/widget.dart create mode 100644 lib/data/model/server/custom.dart create mode 100644 lib/data/model/server/custom.g.dart create mode 100644 lib/data/model/server/pve.dart create mode 100644 lib/data/provider/pve.dart create mode 100644 lib/view/page/pve.dart create mode 100644 lib/view/widget/percent_circle.dart create mode 100644 test/pve_test.dart diff --git a/lib/core/extension/duration.dart b/lib/core/extension/duration.dart new file mode 100644 index 000000000..fcfc27874 --- /dev/null +++ b/lib/core/extension/duration.dart @@ -0,0 +1,20 @@ +import 'package:toolbox/core/extension/context/locale.dart'; + +extension DurationX on Duration { + String get toStr { + final days = inDays; + if (days > 0) { + return '$days ${l10n.day}'; + } + final hours = inHours % 24; + if (hours > 0) { + return '$hours ${l10n.hour}'; + } + final minutes = inMinutes % 60; + if (minutes > 0) { + return '$minutes ${l10n.minute}'; + } + final seconds = inSeconds % 60; + return '$seconds ${l10n.second}'; + } +} diff --git a/lib/core/extension/numx.dart b/lib/core/extension/numx.dart index 8bf6744e7..2fe0e538b 100644 --- a/lib/core/extension/numx.dart +++ b/lib/core/extension/numx.dart @@ -33,3 +33,8 @@ extension BigIntX on BigInt { String get kb2Str => (this * BigInt.from(1024)).bytes2Str; } + +extension IntX on int { + Duration secondsToDuration() => Duration(seconds: this); + DateTime get tsToDateTime => DateTime.fromMillisecondsSinceEpoch(this * 1000); +} diff --git a/lib/core/extension/order.dart b/lib/core/extension/order.dart index c2ffef8bf..f57d576f2 100644 --- a/lib/core/extension/order.dart +++ b/lib/core/extension/order.dart @@ -1,3 +1,4 @@ +import 'package:toolbox/core/extension/listx.dart'; import 'package:toolbox/core/persistant_store.dart'; typedef Order = List; @@ -52,7 +53,7 @@ extension OrderX on Order { move(index, newIndex, property: property, onMove: onMove); } - /// order: ['d', 'b', 'e']\ + /// order: ['d', 'b', 'e'] /// this: ['a', 'b', 'c', 'd']\ /// result: ['d', 'b', 'a', 'c']\ /// return: ['e'] @@ -64,11 +65,11 @@ extension OrderX on Order { final missed = []; final surplus = []; for (final id in order.toSet()) { - try { - final item = firstWhere((e) => finder(e, id)); - newOrder.add(item); - } catch (e) { + final item = firstWhereOrNull((element) => finder(element, id)); + if (item == null) { surplus.add(id); + } else { + newOrder.add(item); } } for (final item in this) { diff --git a/lib/core/extension/widget.dart b/lib/core/extension/widget.dart new file mode 100644 index 000000000..5580d5900 --- /dev/null +++ b/lib/core/extension/widget.dart @@ -0,0 +1,7 @@ +import 'package:flutter/material.dart'; + +extension WidgetX on Widget { + Widget get card { + return Card(child: this); + } +} diff --git a/lib/core/route.dart b/lib/core/route.dart index 336e73556..299ce989c 100644 --- a/lib/core/route.dart +++ b/lib/core/route.dart @@ -9,6 +9,7 @@ import 'package:toolbox/view/page/iperf.dart'; import 'package:toolbox/view/page/ping.dart'; import 'package:toolbox/view/page/private_key/edit.dart'; import 'package:toolbox/view/page/private_key/list.dart'; +import 'package:toolbox/view/page/pve.dart'; import 'package:toolbox/view/page/server/detail.dart'; import 'package:toolbox/view/page/setting/platform/android.dart'; import 'package:toolbox/view/page/setting/platform/ios.dart'; @@ -227,4 +228,8 @@ class AppRoute { static AppRoute serverFuncBtnsOrder({Key? key}) { return AppRoute(ServerFuncBtnsOrderPage(key: key), 'server_func_btns_seq'); } + + static AppRoute pve({Key? key, required ServerPrivateInfo spi}) { + return AppRoute(PvePage(key: key, spi: spi), 'pve'); + } } diff --git a/lib/data/model/app/menu/server_func.dart b/lib/data/model/app/menu/server_func.dart index ae514703c..1901a51cf 100644 --- a/lib/data/model/app/menu/server_func.dart +++ b/lib/data/model/app/menu/server_func.dart @@ -20,6 +20,8 @@ enum ServerFuncBtn { snippet, @HiveField(6) iperf, + @HiveField(7) + pve, ; IconData get icon => switch (this) { @@ -30,6 +32,7 @@ enum ServerFuncBtn { process => Icons.list_alt_outlined, terminal => Icons.terminal, iperf => Icons.speed, + pve => Icons.computer, }; String get toStr => switch (this) { @@ -40,6 +43,7 @@ enum ServerFuncBtn { process => l10n.process, terminal => l10n.terminal, iperf => 'iperf', + pve => 'PVE', }; int toJson() => index; diff --git a/lib/data/model/app/menu/server_func.g.dart b/lib/data/model/app/menu/server_func.g.dart index cf83cef38..107700dac 100644 --- a/lib/data/model/app/menu/server_func.g.dart +++ b/lib/data/model/app/menu/server_func.g.dart @@ -27,6 +27,8 @@ class ServerFuncBtnAdapter extends TypeAdapter { return ServerFuncBtn.snippet; case 6: return ServerFuncBtn.iperf; + case 7: + return ServerFuncBtn.pve; default: return ServerFuncBtn.terminal; } @@ -56,6 +58,9 @@ class ServerFuncBtnAdapter extends TypeAdapter { case ServerFuncBtn.iperf: writer.writeByte(6); break; + case ServerFuncBtn.pve: + writer.writeByte(7); + break; } } diff --git a/lib/data/model/server/custom.dart b/lib/data/model/server/custom.dart new file mode 100644 index 000000000..2dae75e55 --- /dev/null +++ b/lib/data/model/server/custom.dart @@ -0,0 +1,36 @@ +import 'package:hive_flutter/adapters.dart'; + +part 'custom.g.dart'; + +@HiveType(typeId: 7) +final class ServerCustom { + @HiveField(0) + final String? temperature; + @HiveField(1) + final String? pveAddr; + + const ServerCustom({ + this.temperature, + this.pveAddr, + }); + + static ServerCustom fromJson(Map json) { + final temperature = json["temperature"] as String?; + final pveAddr = json["pveAddr"] as String?; + return ServerCustom( + temperature: temperature, + pveAddr: pveAddr, + ); + } + + Map toJson() { + final json = {}; + if (temperature != null) { + json["temperature"] = temperature; + } + if (pveAddr != null) { + json["pveAddr"] = pveAddr; + } + return json; + } +} diff --git a/lib/data/model/server/custom.g.dart b/lib/data/model/server/custom.g.dart new file mode 100644 index 000000000..cc8668725 --- /dev/null +++ b/lib/data/model/server/custom.g.dart @@ -0,0 +1,44 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'custom.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class ServerCustomAdapter extends TypeAdapter { + @override + final int typeId = 7; + + @override + ServerCustom read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return ServerCustom( + temperature: fields[0] as String?, + pveAddr: fields[1] as String?, + ); + } + + @override + void write(BinaryWriter writer, ServerCustom obj) { + writer + ..writeByte(2) + ..writeByte(0) + ..write(obj.temperature) + ..writeByte(1) + ..write(obj.pveAddr); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ServerCustomAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/data/model/server/pve.dart b/lib/data/model/server/pve.dart new file mode 100644 index 000000000..e673312b3 --- /dev/null +++ b/lib/data/model/server/pve.dart @@ -0,0 +1,351 @@ +import 'package:toolbox/core/extension/context/locale.dart'; +import 'package:toolbox/core/extension/duration.dart'; +import 'package:toolbox/core/extension/numx.dart'; + +enum PveResType { + lxc, + qemu, + node, + storage, + sdn, + ; + + static PveResType fromString(String type) { + switch (type) { + case 'lxc': + return PveResType.lxc; + case 'qemu': + return PveResType.qemu; + case 'node': + return PveResType.node; + case 'storage': + return PveResType.storage; + case 'sdn': + return PveResType.sdn; + default: + throw Exception('Unknown PveResType: $type'); + } + } + + String get toStr => switch (this) { + PveResType.node => l10n.node, + PveResType.qemu => 'QEMU', + PveResType.lxc => 'LXC', + PveResType.storage => l10n.storage, + PveResType.sdn => 'SDN', + }; +} + +sealed class PveResIface { + String get id; + String get status; + PveResType get type; + + static PveResIface fromJson(Map json) { + final type = PveResType.fromString(json['type']); + switch (type) { + case PveResType.lxc: + return PveLxc.fromJson(json); + case PveResType.qemu: + return PveQemu.fromJson(json); + case PveResType.node: + return PveNode.fromJson(json); + case PveResType.storage: + return PveStorage.fromJson(json); + case PveResType.sdn: + return PveSdn.fromJson(json); + } + } +} + +final class PveLxc extends PveResIface { + @override + final String id; + @override + final PveResType type; + final int vmid; + final String node; + final String name; + @override + final String status; + final int uptime; + final int mem; + final int maxmem; + final double cpu; + final int maxcpu; + final int disk; + final int maxdisk; + final int diskread; + final int diskwrite; + final int netin; + final int netout; + + PveLxc({ + required this.id, + required this.type, + required this.vmid, + required this.node, + required this.name, + required this.status, + required this.uptime, + required this.mem, + required this.maxmem, + required this.cpu, + required this.maxcpu, + required this.disk, + required this.maxdisk, + required this.diskread, + required this.diskwrite, + required this.netin, + required this.netout, + }); + + static PveLxc fromJson(Map json) { + return PveLxc( + id: json['id'], + type: PveResType.lxc, + vmid: json['vmid'], + node: json['node'], + name: json['name'], + status: json['status'], + uptime: json['uptime'], + mem: json['mem'], + maxmem: json['maxmem'], + cpu: (json['cpu'] as num).toDouble(), + maxcpu: json['maxcpu'], + disk: json['disk'], + maxdisk: json['maxdisk'], + diskread: json['diskread'], + diskwrite: json['diskwrite'], + netin: json['netin'], + netout: json['netout'], + ); + } +} + +final class PveQemu extends PveResIface { + @override + final String id; + @override + final PveResType type; + final int vmid; + final String node; + final String name; + @override + final String status; + final int uptime; + final int mem; + final int maxmem; + final double cpu; + final int maxcpu; + final int disk; + final int maxdisk; + final int diskread; + final int diskwrite; + final int netin; + final int netout; + + PveQemu({ + required this.id, + required this.type, + required this.vmid, + required this.node, + required this.name, + required this.status, + required this.uptime, + required this.mem, + required this.maxmem, + required this.cpu, + required this.maxcpu, + required this.disk, + required this.maxdisk, + required this.diskread, + required this.diskwrite, + required this.netin, + required this.netout, + }); + + static PveQemu fromJson(Map json) { + return PveQemu( + id: json['id'], + type: PveResType.qemu, + vmid: json['vmid'], + node: json['node'], + name: json['name'], + status: json['status'], + uptime: json['uptime'], + mem: json['mem'], + maxmem: json['maxmem'], + cpu: (json['cpu'] as num).toDouble(), + maxcpu: json['maxcpu'], + disk: json['disk'], + maxdisk: json['maxdisk'], + diskread: json['diskread'], + diskwrite: json['diskwrite'], + netin: json['netin'], + netout: json['netout'], + ); + } + + bool get isRunning => status == 'running'; + + String get topRight { + if (!isRunning) { + return uptime.secondsToDuration().toStr; + } + return l10n.stopped; + } +} + +final class PveNode extends PveResIface { + @override + final String id; + @override + final PveResType type; + final String node; + @override + final String status; + final int uptime; + final int mem; + final int maxmem; + final double cpu; + final int maxcpu; + + PveNode({ + required this.id, + required this.type, + required this.node, + required this.status, + required this.uptime, + required this.mem, + required this.maxmem, + required this.cpu, + required this.maxcpu, + }); + + static PveNode fromJson(Map json) { + return PveNode( + id: json['id'], + type: PveResType.node, + node: json['node'], + status: json['status'], + uptime: json['uptime'], + mem: json['mem'], + maxmem: json['maxmem'], + cpu: (json['cpu'] as num).toDouble(), + maxcpu: json['maxcpu'], + ); + } +} + +final class PveStorage extends PveResIface { + @override + final String id; + @override + final PveResType type; + final String storage; + final String node; + @override + final String status; + final String plugintype; + final String content; + final int shared; + final int disk; + final int maxdisk; + + PveStorage({ + required this.id, + required this.type, + required this.storage, + required this.node, + required this.status, + required this.plugintype, + required this.content, + required this.shared, + required this.disk, + required this.maxdisk, + }); + + static PveStorage fromJson(Map json) { + return PveStorage( + id: json['id'], + type: PveResType.storage, + storage: json['storage'], + node: json['node'], + status: json['status'], + plugintype: json['plugintype'], + content: json['content'], + shared: json['shared'], + disk: json['disk'], + maxdisk: json['maxdisk'], + ); + } +} + +final class PveSdn extends PveResIface { + @override + final String id; + @override + final PveResType type; + final String sdn; + final String node; + @override + final String status; + + PveSdn({ + required this.id, + required this.type, + required this.sdn, + required this.node, + required this.status, + }); + + static PveSdn fromJson(Map json) { + return PveSdn( + id: json['id'], + type: PveResType.sdn, + sdn: json['sdn'], + node: json['node'], + status: json['status'], + ); + } +} + +final class PveRes { + final List nodes; + final List qemus; + final List lxcs; + final List storages; + final List sdns; + + const PveRes({ + required this.nodes, + required this.qemus, + required this.lxcs, + required this.storages, + required this.sdns, + }); + + int get length => + qemus.length + lxcs.length + nodes.length + storages.length + sdns.length; + + PveResIface operator [](int index) { + if (index < nodes.length) { + return nodes[index]; + } + index -= nodes.length; + if (index < qemus.length) { + return qemus[index]; + } + index -= qemus.length; + if (index < lxcs.length) { + return lxcs[index]; + } + index -= lxcs.length; + if (index < storages.length) { + return storages[index]; + } + index -= storages.length; + return sdns[index]; + } +} diff --git a/lib/data/model/server/server_private_info.dart b/lib/data/model/server/server_private_info.dart index d5cc5a0f7..f4d224610 100644 --- a/lib/data/model/server/server_private_info.dart +++ b/lib/data/model/server/server_private_info.dart @@ -1,4 +1,5 @@ import 'package:hive_flutter/hive_flutter.dart'; +import 'package:toolbox/data/model/server/custom.dart'; import 'package:toolbox/data/model/server/server.dart'; import 'package:toolbox/data/res/provider.dart'; @@ -6,6 +7,7 @@ import '../app/error.dart'; part 'server_private_info.g.dart'; +/// In former version, it's called `ServerPrivateInfo`. @HiveType(typeId: 3) class ServerPrivateInfo { @HiveField(0) @@ -33,6 +35,9 @@ class ServerPrivateInfo { @HiveField(9) final String? jumpId; + @HiveField(10) + final ServerCustom? custom; + final String id; const ServerPrivateInfo({ @@ -46,6 +51,7 @@ class ServerPrivateInfo { this.alterUrl, this.autoConnect, this.jumpId, + this.custom, }) : id = '$user@$ip:$port'; static ServerPrivateInfo fromJson(Map json) { @@ -59,6 +65,9 @@ class ServerPrivateInfo { final alterUrl = json["alterUrl"] as String?; final autoConnect = json["autoConnect"] as bool?; final jumpId = json["jumpId"] as String?; + final custom = json["customCmd"] == null + ? null + : ServerCustom.fromJson(json["custom"].cast()); return ServerPrivateInfo( name: name, @@ -71,6 +80,7 @@ class ServerPrivateInfo { alterUrl: alterUrl, autoConnect: autoConnect, jumpId: jumpId, + custom: custom, ); } @@ -80,12 +90,27 @@ class ServerPrivateInfo { data["ip"] = ip; data["port"] = port; data["user"] = user; - data["authorization"] = pwd; - data["pubKeyId"] = keyId; - data["tags"] = tags; - data["alterUrl"] = alterUrl; - data["autoConnect"] = autoConnect; - data["jumpId"] = jumpId; + if (pwd != null) { + data["authorization"] = pwd; + } + if (keyId != null) { + data["pubKeyId"] = keyId; + } + if (tags != null) { + data["tags"] = tags; + } + if (alterUrl != null) { + data["alterUrl"] = alterUrl; + } + if (autoConnect != null) { + data["autoConnect"] = autoConnect; + } + if (jumpId != null) { + data["jumpId"] = jumpId; + } + if (custom != null) { + data["custom"] = custom?.toJson(); + } return data; } diff --git a/lib/data/model/server/server_private_info.g.dart b/lib/data/model/server/server_private_info.g.dart index 9abae0bcb..2ab99e6d8 100644 --- a/lib/data/model/server/server_private_info.g.dart +++ b/lib/data/model/server/server_private_info.g.dart @@ -27,13 +27,14 @@ class ServerPrivateInfoAdapter extends TypeAdapter { alterUrl: fields[7] as String?, autoConnect: fields[8] as bool?, jumpId: fields[9] as String?, + custom: fields[10] as ServerCustom?, ); } @override void write(BinaryWriter writer, ServerPrivateInfo obj) { writer - ..writeByte(10) + ..writeByte(11) ..writeByte(0) ..write(obj.name) ..writeByte(1) @@ -53,7 +54,9 @@ class ServerPrivateInfoAdapter extends TypeAdapter { ..writeByte(8) ..write(obj.autoConnect) ..writeByte(9) - ..write(obj.jumpId); + ..write(obj.jumpId) + ..writeByte(10) + ..write(obj.custom); } @override diff --git a/lib/data/provider/pve.dart b/lib/data/provider/pve.dart new file mode 100644 index 000000000..56058fc1a --- /dev/null +++ b/lib/data/provider/pve.dart @@ -0,0 +1,112 @@ +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:toolbox/data/model/server/pve.dart'; +import 'package:toolbox/data/model/server/server_private_info.dart'; + +final class PveProvider extends ChangeNotifier { + final ServerPrivateInfo spi; + late final String addr; + //late final SSHClient _client; + + PveProvider({ + required this.spi, + }) { + // final client = _spi.server?.client; + // if (client == null) { + // throw Exception('Server client is null'); + // } + // _client = client; + final addr = spi.custom?.pveAddr; + if (addr == null) { + err.value = 'PVE address is null'; + return; + } + this.addr = addr; + _init().then((_) => connected.complete()); + } + + final err = ValueNotifier(null); + final connected = Completer(); + final session = Dio(); + + // int _localPort = 0; + // String get addr => 'http://127.0.0.1:$_localPort'; + + Future _init() async { + //await _forward(); + await _login(); + } + + // Future _forward() async { + // var retries = 0; + // while (retries < 3) { + // try { + // _localPort = Random().nextInt(1000) + 37000; + // print('Forwarding local port $_localPort'); + // final serverSocket = await ServerSocket.bind('localhost', _localPort); + // final forward = await _client.forwardLocal('127.0.0.1', 8006); + // serverSocket.listen((socket) { + // forward.stream.cast>().pipe(socket); + // socket.pipe(forward.sink); + // }); + // return; + // } on SocketException { + // retries++; + // } + // } + // throw Exception('Failed to bind local port'); + // } + + Future _login() async { + final resp = await session.post('$addr/api2/extjs/access/ticket', data: { + 'username': spi.user, + 'password': spi.pwd, + 'realm': 'pam', + 'new-format': '1' + }); + final ticket = resp.data['data']['ticket']; + session.options.headers['CSRFPreventionToken'] = + resp.data['data']['CSRFPreventionToken']; + session.options.headers['Cookie'] = 'PVEAuthCookie=$ticket'; + } + + 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 qemus = []; + final lxcs = []; + final nodes = []; + final storages = []; + final 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; + } + } + return PveRes( + qemus: qemus, + lxcs: lxcs, + nodes: nodes, + storages: storages, + sdns: sdns, + ); + } +} diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index e80cb3097..2ecd4622c 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -7,6 +7,7 @@ "addPrivateKey": "Private key hinzufügen", "addSystemPrivateKeyTip": "Derzeit haben Sie keinen privaten Schlüssel, fügen Sie den Schlüssel hinzu, der mit dem System geliefert wird (~/.ssh/id_rsa)?", "added2List": "Zur Aufgabenliste hinzugefügt", + "addr": "Adresse", "all": "Alle", "alreadyLastDir": "Bereits im letzten Verzeichnis.", "alterUrl": "Url ändern", @@ -54,6 +55,7 @@ "createFolder": "Ordner erstellen", "cursorType": "Cursor-Typ", "dark": "Dunkel", + "day": "Tag", "debug": "Debug", "decode": "Decode", "decompress": "Dekomprimieren", @@ -109,6 +111,7 @@ "highlight": "Code highlight", "homeWidgetUrlConfig": "Home-Widget-Link konfigurieren", "host": "Host", + "hour": "Stunde", "httpFailedWithCode": "Anfrage fehlgeschlagen, Statuscode: {code}", "icloudSynced": "iCloud wird synchronisiert und einige Einstellungen erfordern möglicherweise einen Neustart der App, um wirksam zu werden.", "image": "Image", @@ -143,6 +146,7 @@ "maxRetryCount": "Anzahl an Verbindungsversuchen", "maxRetryCountEqual0": "Unbegrenzte Verbindungsversuche zum Server", "min": "min", + "minute": "Minute", "mission": "Mission", "more": "Mehr", "moveOutServerFuncBtnsHelp": "Ein: kann unter jeder Karte auf der Registerkarte \"Server\" angezeigt werden. Aus: kann oben auf der Seite \"Serverdetails\" angezeigt werden.", @@ -161,6 +165,7 @@ "noServerAvailable": "Kein Server verfügbar.", "noTask": "Nicht fragen", "noUpdateAvailable": "Kein Update verfügbar", + "node": "Knoten", "notSelected": "Nicht ausgewählt", "note": "Hinweis", "nullToken": "Null token", @@ -237,6 +242,7 @@ "stats": "Statistik", "stop": "Stop", "stopped": "Ausgelaufen", + "storage": "Speicher", "success": "Erfolgreich", "supportFmtArgs": "Die folgenden Formatierungsparameter werden unterstützt:", "suspend": "Suspend", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 55b87ade0..a2eb06f62 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -7,6 +7,7 @@ "addPrivateKey": "Add private key", "addSystemPrivateKeyTip": "Currently don't have any private key, do you add the one that comes with the system (~/.ssh/id_rsa)?", "added2List": "Added to task list", + "addr": "Address", "all": "All", "alreadyLastDir": "Already in last directory.", "alterUrl": "Alter url", @@ -54,6 +55,7 @@ "createFolder": "Create folder", "cursorType": "Cursor type", "dark": "Dark", + "day": "Day", "debug": "Debug", "decode": "Decode", "decompress": "Decompress", @@ -109,6 +111,7 @@ "highlight": "Code highlight", "homeWidgetUrlConfig": "Config home widget url", "host": "Host", + "hour": "Hour", "httpFailedWithCode": "request failed, status code: {code}", "icloudSynced": "iCloud wird synchronisiert und einige Einstellungen erfordern möglicherweise einen Neustart der App, um wirksam zu werden.", "image": "Image", @@ -143,6 +146,7 @@ "maxRetryCount": "Number of server reconnection", "maxRetryCountEqual0": "Will retry again and again.", "min": "min", + "minute": "Minute", "mission": "Mission", "more": "More", "moveOutServerFuncBtnsHelp": "On: can be displayed below each card on the Server Tab page. Off: can be displayed at the top of the Server Details page.", @@ -161,6 +165,7 @@ "noServerAvailable": "No server available.", "noTask": "No task", "noUpdateAvailable": "No update available", + "node": "Node", "notSelected": "Not selected", "note": "Note", "nullToken": "Null token", @@ -237,6 +242,7 @@ "stats": "Stats", "stop": "Stop", "stopped": "Stopped", + "storage": "Storage", "success": "Success", "supportFmtArgs": "The following formatting parameters are supported:", "suspend": "Suspend", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 43f6ef173..c7c9d2f12 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -7,6 +7,7 @@ "addPrivateKey": "Agregar una llave privada", "addSystemPrivateKeyTip": "Actualmente no hay ninguna llave privada, ¿quieres agregar la que viene por defecto en el sistema (~/.ssh/id_rsa)?", "added2List": "Añadido a la lista de tareas", + "addr": "Dirección", "all": "Todos", "alreadyLastDir": "Ya estás en el directorio superior", "alterUrl": "URL alternativa", @@ -54,6 +55,7 @@ "createFolder": "Crear carpeta", "cursorType": "Tipo de cursor", "dark": "Oscuro", + "day": "Día", "debug": "Depurar", "decode": "Decodificar", "decompress": "Descomprimir", @@ -109,6 +111,7 @@ "highlight": "Resaltar código", "homeWidgetUrlConfig": "Configuración de URL del widget de inicio", "host": "Anfitrión", + "hour": "Hora", "httpFailedWithCode": "Fallo en la solicitud, código de estado: {code}", "icloudSynced": "iCloud sincronizado, algunos ajustes pueden requerir reiniciar para tomar efecto.", "image": "Imagen", @@ -143,6 +146,7 @@ "maxRetryCount": "Número máximo de reintentos de conexión al servidor", "maxRetryCountEqual0": "Reintentará infinitamente", "min": "Mínimo", + "minute": "Minuto", "mission": "Misión", "more": "Más", "moveOutServerFuncBtnsHelp": "Activado: se mostrará debajo de cada tarjeta en la página de servidores. Desactivado: se mostrará en la parte superior de los detalles del servidor.", @@ -161,6 +165,7 @@ "noServerAvailable": "No hay servidores disponibles.", "noTask": "Sin tareas", "noUpdateAvailable": "No hay actualizaciones disponibles", + "node": "Nodo", "notSelected": "No seleccionado", "note": "Nota", "nullToken": "Token nulo", @@ -237,6 +242,7 @@ "stats": "Estadísticas", "stop": "Detener", "stopped": "Detenido", + "storage": "Almacenamiento", "success": "Éxito", "supportFmtArgs": "Soporta los siguientes argumentos de formato:", "suspend": "Suspender", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 6babd5244..f9b47897d 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -7,6 +7,7 @@ "addPrivateKey": "Ajouter une clé privée", "addSystemPrivateKeyTip": "Actuellement, vous n'avez aucune clé privée. Voulez-vous ajouter celle qui accompagne le système (~/.ssh/id_rsa)?", "added2List": "Ajouté à la liste des tâches", + "addr": "Adresse", "all": "Tous", "alreadyLastDir": "Déjà dans le dernier répertoire.", "alterUrl": "Modifier l'URL", @@ -54,6 +55,7 @@ "createFolder": "Créer un dossier", "cursorType": "Type de curseur", "dark": "Sombre", + "day": "Jour", "debug": "Déboguer", "decode": "Décoder", "decompress": "Décompresser", @@ -109,6 +111,7 @@ "highlight": "Coloration syntaxique", "homeWidgetUrlConfig": "Configurer l'URL du widget d'accueil", "host": "Hôte", + "hour": "Heure", "httpFailedWithCode": "requête échouée, code d'état : {code}", "icloudSynced": "iCloud est synchronisé et certaines options peuvent nécessiter un redémarrage de l'application pour être effectives.", "image": "Image", @@ -143,6 +146,7 @@ "maxRetryCount": "Nombre de reconnexions du serveur", "maxRetryCountEqual0": "Réessayera encore et encore.", "min": "min", + "minute": "Minute", "mission": "Mission", "more": "Plus", "moveOutServerFuncBtnsHelp": "Activé : peut être affiché sous chaque carte sur la page de l'onglet Serveur. Désactivé : peut être affiché en haut de la page Détails du serveur.", @@ -161,6 +165,7 @@ "noServerAvailable": "Aucun serveur disponible.", "noTask": "Aucune tâche", "noUpdateAvailable": "Aucune mise à jour disponible", + "node": "Noeud", "notSelected": "Non sélectionné", "note": "Note", "nullToken": "Jeton nul", @@ -237,6 +242,7 @@ "stats": "Statistiques", "stop": "Arrêter", "stopped": "interrompue", + "storage": "Stockage", "success": "Succès", "supportFmtArgs": "Les paramètres de formatage suivants sont pris en charge:", "suspend": "Suspendre", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index d5b17185e..43e819faf 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -7,6 +7,7 @@ "addPrivateKey": "Tambahkan kunci pribadi", "addSystemPrivateKeyTip": "Saat ini tidak memiliki kunci privat, apakah Anda menambahkan kunci yang disertakan dengan sistem (~/.ssh/id_rsa)?", "added2List": "Ditambahkan ke Daftar Tugas", + "addr": "Alamat", "all": "Semua", "alreadyLastDir": "Sudah di direktori terakhir.", "alterUrl": "Alter url", @@ -54,6 +55,7 @@ "createFolder": "Membuat folder", "cursorType": "Jenis kursor", "dark": "Gelap", + "day": "Hari", "debug": "Debug", "decode": "Membaca sandi", "decompress": "Dekompresi", @@ -109,6 +111,7 @@ "highlight": "Sorotan kode", "homeWidgetUrlConfig": "Konfigurasi URL Widget Rumah", "host": "Host", + "hour": "Jam", "httpFailedWithCode": "Permintaan gagal, kode status: {code}", "icloudSynced": "iCloud disinkronkan dan beberapa pengaturan mungkin memerlukan pengaktifan ulang aplikasi agar dapat diterapkan.", "image": "Gambar", @@ -143,6 +146,7 @@ "maxRetryCount": "Jumlah penyambungan kembali server", "maxRetryCountEqual0": "Akan mencoba lagi lagi dan lagi.", "min": "Min", + "minute": "Menit", "mission": "Misi", "more": "Lebih Banyak", "moveOutServerFuncBtnsHelp": "Aktif: dapat ditampilkan di bawah setiap kartu pada halaman Tab Server. Nonaktif: dapat ditampilkan di bagian atas halaman Rincian Server.", @@ -161,6 +165,7 @@ "noServerAvailable": "Tidak ada server yang tersedia.", "noTask": "Tidak bertanya", "noUpdateAvailable": "Tidak ada pembaruan yang tersedia", + "node": "Node", "notSelected": "Tidak terpilih", "note": "Catatan", "nullToken": "Token NULL", @@ -237,6 +242,7 @@ "stats": "Statistik", "stop": "Berhenti", "stopped": "dihentikan", + "storage": "Penyimpanan", "success": "Kesuksesan", "supportFmtArgs": "Parameter pemformatan berikut ini didukung:", "suspend": "Suspend", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 9c841a64a..4d972f039 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -7,6 +7,7 @@ "addPrivateKey": "プライベートキーを追加", "addSystemPrivateKeyTip": "現在プライベートキーがありません。システムのデフォルト(~/.ssh/id_rsa)を追加しますか?", "added2List": "タスクリストに追加されました", + "addr": "住所", "all": "すべて", "alreadyLastDir": "すでに最上位のディレクトリです", "alterUrl": "代替リンク", @@ -54,6 +55,7 @@ "createFolder": "フォルダーを作成", "cursorType": "カーソルタイプ", "dark": "ダーク", + "day": "日", "debug": "デバッグ", "decode": "デコード", "decompress": "解凍", @@ -109,6 +111,7 @@ "highlight": "コードハイライト", "homeWidgetUrlConfig": "ホームウィジェットURL設定", "host": "ホスト", + "hour": "時間", "httpFailedWithCode": "リクエスト失敗、ステータスコード: {code}", "icloudSynced": "iCloudが同期されました。一部の設定はアプリを再起動する必要があります。", "image": "イメージ", @@ -143,6 +146,7 @@ "maxRetryCount": "サーバーの再接続試行回数", "maxRetryCountEqual0": "無限に再試行します", "min": "最小", + "minute": "分", "mission": "ミッション", "more": "もっと", "moveOutServerFuncBtnsHelp": "有効にする:サーバータブの各カードの下に表示されます。無効にする:サーバーの詳細ページの上部に表示されます。", @@ -161,6 +165,7 @@ "noServerAvailable": "使用可能なサーバーがありません。", "noTask": "タスクがありません", "noUpdateAvailable": "利用可能な更新はありません", + "node": "ノード", "notSelected": "選択されていません", "note": "メモ", "nullToken": "トークンなし", @@ -237,6 +242,7 @@ "stats": "統計", "stop": "停止", "stopped": "停止しました", + "storage": "ストレージ", "success": "成功", "supportFmtArgs": "以下のフォーマット引数がサポートされています:", "suspend": "中断", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 1b9734450..11c9dd6c9 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -7,6 +7,7 @@ "addPrivateKey": "Adicionar uma chave privada", "addSystemPrivateKeyTip": "Atualmente, não há nenhuma chave privada. Gostaria de adicionar a chave do sistema (~/.ssh/id_rsa)?", "added2List": "Adicionado à lista de tarefas", + "addr": "Endereço", "all": "Todos", "alreadyLastDir": "Já é o diretório mais alto", "alterUrl": "URL alternativa", @@ -54,6 +55,7 @@ "createFolder": "Criar pasta", "cursorType": "Tipo de cursor", "dark": "Escuro", + "day": "Dia", "debug": "Debugar", "decode": "Decodificar", "decompress": "Descomprimir", @@ -109,6 +111,7 @@ "highlight": "Destaque de código", "homeWidgetUrlConfig": "Configuração de URL do widget da tela inicial", "host": "Host", + "hour": "Hora", "httpFailedWithCode": "Falha na solicitação, código de status: {code}", "icloudSynced": "iCloud sincronizado, algumas configurações podem precisar de reinicialização do app para serem aplicadas.", "image": "Imagem", @@ -143,6 +146,7 @@ "maxRetryCount": "Número de tentativas de reconexão com o servidor", "maxRetryCountEqual0": "Irá tentar indefinidamente", "min": "Mínimo", + "minute": "Minuto", "mission": "Missão", "more": "Mais", "moveOutServerFuncBtnsHelp": "Ativado: Mostra abaixo de cada cartão na aba do servidor. Desativado: Mostra no topo da página de detalhes do servidor.", @@ -161,6 +165,7 @@ "noServerAvailable": "Nenhum servidor disponível.", "noTask": "Sem tarefas", "noUpdateAvailable": "Sem atualizações disponíveis", + "node": "Nó", "notSelected": "Não selecionado", "note": "Nota", "nullToken": "Token nulo", @@ -237,6 +242,7 @@ "stats": "Estatísticas", "stop": "Parar", "stopped": "Parado", + "storage": "Armazenamento", "success": "Sucesso", "supportFmtArgs": "Suporta os seguintes argumentos formatados:", "suspend": "Suspender", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 34d7b2179..a5feb3d7e 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -7,6 +7,7 @@ "addPrivateKey": "добавить приватный ключ", "addSystemPrivateKeyTip": "В данный момент приватные ключи отсутствуют. Добавить системный приватный ключ (~/.ssh/id_rsa)?", "added2List": "добавлено в список задач", + "addr": "Адрес", "all": "все", "alreadyLastDir": "Уже в корневом каталоге", "alterUrl": "альтернативная ссылка", @@ -54,6 +55,7 @@ "createFolder": "создать папку", "cursorType": "Тип курсора", "dark": "темная", + "day": "День", "debug": "отладка", "decode": "декодировать", "decompress": "разархивировать", @@ -109,6 +111,7 @@ "highlight": "подсветка кода", "homeWidgetUrlConfig": "конфигурация URL виджета домашнего экрана", "host": "хост", + "hour": "Час", "httpFailedWithCode": "Ошибка запроса, код: {code}", "icloudSynced": "Синхронизация с iCloud выполнена, некоторые настройки могут потребовать перезапуска приложения для вступления в силу.", "image": "образ", @@ -143,6 +146,7 @@ "maxRetryCount": "максимальное количество попыток переподключения к серверу", "maxRetryCountEqual0": "будет бесконечно пытаться переподключиться", "min": "минимум", + "minute": "Минута", "mission": "задача", "more": "больше", "moveOutServerFuncBtnsHelp": "Включено: кнопки функций сервера отображаются под каждой карточкой на вкладке сервера. Выключено: отображается в верхней части страницы деталей сервера.", @@ -161,6 +165,7 @@ "noServerAvailable": "Нет доступных серверов.", "noTask": "нет задач", "noUpdateAvailable": "нет доступных обновлений", + "node": "Узел", "notSelected": "не выбрано", "note": "заметка", "nullToken": "нет токена", @@ -237,6 +242,7 @@ "stats": "статистика", "stop": "остановить", "stopped": "остановлено", + "storage": "Хранение", "success": "успех", "supportFmtArgs": "Поддерживаются следующие форматы аргументов:", "suspend": "приостановить", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 6a8809812..0181ee492 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -7,6 +7,7 @@ "addPrivateKey": "添加一个私钥", "addSystemPrivateKeyTip": "当前没有任何私钥,是否添加系统自带的(~/.ssh/id_rsa)?", "added2List": "已添加至任务列表", + "addr": "地址", "all": "所有", "alreadyLastDir": "已经是最上层目录了", "alterUrl": "备选链接", @@ -54,6 +55,7 @@ "createFolder": "创建文件夹", "cursorType": "光标类型", "dark": "暗", + "day": "天", "debug": "调试", "decode": "解码", "decompress": "解压缩", @@ -109,6 +111,7 @@ "highlight": "代码高亮", "homeWidgetUrlConfig": "桌面部件链接配置", "host": "主机", + "hour": "小时", "httpFailedWithCode": "请求失败, 状态码: {code}", "icloudSynced": "iCloud已同步,某些设置可能需要重启才能生效。", "image": "镜像", @@ -143,6 +146,7 @@ "maxRetryCount": "服务器尝试重连次数", "maxRetryCountEqual0": "会无限重试", "min": "最小", + "minute": "分钟", "mission": "任务", "more": "更多", "moveOutServerFuncBtnsHelp": "开启:可以在服务器 Tab 页的每个卡片下方显示。关闭:在服务器详情页顶部显示。", @@ -161,6 +165,7 @@ "noServerAvailable": "没有可用的服务器。", "noTask": "没有任务", "noUpdateAvailable": "没有可用更新", + "node": "节点", "notSelected": "未选择", "note": "备注", "nullToken": "无Token", @@ -237,6 +242,7 @@ "stats": "统计", "stop": "停止", "stopped": "已停止", + "storage": "存储", "success": "成功", "supportFmtArgs": "支持以下格式化参数:", "suspend": "挂起", diff --git a/lib/l10n/app_zh_tw.arb b/lib/l10n/app_zh_tw.arb index e3bb85411..7626d5540 100644 --- a/lib/l10n/app_zh_tw.arb +++ b/lib/l10n/app_zh_tw.arb @@ -7,6 +7,7 @@ "addPrivateKey": "新增一個私鑰", "addSystemPrivateKeyTip": "當前沒有任何私鑰,是否添加系統自帶的(~/.ssh/id_rsa)?", "added2List": "已添加至任務列表", + "addr": "地址", "all": "所有", "alreadyLastDir": "已經是最上層目錄了", "alterUrl": "備選鏈接", @@ -54,6 +55,7 @@ "createFolder": "創建文件夾", "cursorType": "光標類型", "dark": "暗", + "day": "日", "debug": "調試", "decode": "解碼", "decompress": "解壓縮", @@ -109,6 +111,7 @@ "highlight": "代碼高亮", "homeWidgetUrlConfig": "桌面部件鏈接配置", "host": "主機", + "hour": "小時", "httpFailedWithCode": "請求失敗, 狀態碼: {code}", "icloudSynced": "iCloud已同步,某些設置可能需要重啟才能生效。", "image": "鏡像", @@ -143,6 +146,7 @@ "maxRetryCount": "服務器嘗試重連次數", "maxRetryCountEqual0": "會無限重試", "min": "最小", + "minute": "分鐘", "mission": "任務", "more": "更多", "moveOutServerFuncBtnsHelp": "開啟:可以在服務器 Tab 頁的每個卡片下方顯示。關閉:在服務器詳情頁頂部顯示。", @@ -161,6 +165,7 @@ "noServerAvailable": "沒有可用的服務器。", "noTask": "沒有任務", "noUpdateAvailable": "沒有可用更新", + "node": "節點", "notSelected": "未選擇", "note": "備註", "nullToken": "無Token", @@ -237,6 +242,7 @@ "stats": "統計", "stop": "停止", "stopped": "已停止", + "storage": "存儲", "success": "成功", "supportFmtArgs": "支援以下格式化參數:", "suspend": "挂起", diff --git a/lib/main.dart b/lib/main.dart index 5cbff32c1..16474fedb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,6 +15,7 @@ import 'package:toolbox/core/utils/platform/base.dart'; import 'package:toolbox/core/utils/sync/webdav.dart'; import 'package:toolbox/core/utils/ui.dart'; import 'package:toolbox/data/model/app/menu/server_func.dart'; +import 'package:toolbox/data/model/server/custom.dart'; import 'package:toolbox/data/res/logger.dart'; import 'package:toolbox/data/res/provider.dart'; import 'package:toolbox/data/res/store.dart'; @@ -118,6 +119,7 @@ Future _initDb() async { Hive.registerAdapter(VirtKeyAdapter()); // 4 Hive.registerAdapter(NetViewTypeAdapter()); // 5 Hive.registerAdapter(ServerFuncBtnAdapter()); // 6 + Hive.registerAdapter(ServerCustomAdapter()); // 7 } void _setupLogger() { diff --git a/lib/view/page/backup.dart b/lib/view/page/backup.dart index 84e648d27..3d8d0c35d 100644 --- a/lib/view/page/backup.dart +++ b/lib/view/page/backup.dart @@ -223,22 +223,22 @@ class BackupPage extends StatelessWidget { Widget _buildBulkImportServers(BuildContext context) { return CardX( - child: ListTile( - title: Text(l10n.bulkImportServers), - subtitle: MarkdownBody( - data: l10n.bulkImportServersTip(Urls.appWiki), - styleSheet: MarkdownStyleSheet( + child: ListTile( + title: Text(l10n.bulkImportServers), + subtitle: MarkdownBody( + data: l10n.bulkImportServersTip(Urls.appWiki), + styleSheet: MarkdownStyleSheet( p: UIs.textGrey, - a: TextStyle( - color: primaryColor, - )), - onTapLink: (text, href, title) { - if (href != null) openUrl(href); - }, + a: TextStyle(color: primaryColor), + ), + onTapLink: (text, href, title) { + if (href != null) openUrl(href); + }, + ), + leading: const Icon(Icons.import_export), + onTap: () => _onBulkImportServers(context), ), - trailing: const Icon(Icons.import_export), - onTap: () => _onBulkImportServers(context), - )); + ); } Future _onTapFileRestore(BuildContext context) async { diff --git a/lib/view/page/pve.dart b/lib/view/page/pve.dart new file mode 100644 index 000000000..ed902c008 --- /dev/null +++ b/lib/view/page/pve.dart @@ -0,0 +1,197 @@ +import 'package:flutter/material.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/ui.dart'; +import 'package:toolbox/view/widget/appbar.dart'; +import 'package:toolbox/view/widget/future_widget.dart'; +import 'package:toolbox/view/widget/percent_circle.dart'; + +final class PvePage extends StatefulWidget { + final ServerPrivateInfo spi; + + const PvePage({ + super.key, + required this.spi, + }); + + @override + _PvePageState createState() => _PvePageState(); +} + +const _kHorziPadding = 11.0; + +final class _PvePageState extends State { + late final pve = PveProvider(spi: widget.spi); + late MediaQueryData _media; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _media = MediaQuery.of(context); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: const CustomAppBar( + title: Text('PVE'), + ), + body: _buildBody(), + ); + } + + Widget _buildBody() { + if (pve.err.value != null) { + return Center( + 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; + return ListView.separated( + padding: const EdgeInsets.symmetric( + horizontal: _kHorziPadding, + vertical: 7, + ), + itemCount: data.length + 1, + separatorBuilder: (context, index) { + final type = switch (data[index]) { + final PveNode _ => PveResType.node, + final PveQemu _ => PveResType.qemu, + final PveLxc _ => PveResType.lxc, + final PveStorage _ => PveResType.storage, + final PveSdn _ => PveResType.sdn, + }; + if (type == lastType) { + return UIs.placeholder; + } + return Padding( + 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; + 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); + } + }, + ); + }, + ); + } + + Widget _buildQemu(PveQemu item) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + title: Text(item.name), + trailing: Text(item.topRight), + ), + 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( + children: [ + Text( + item.netin.bytes2Str, + style: const TextStyle(fontSize: 10, color: Colors.grey), + textAlign: TextAlign.center, + ), + const SizedBox(height: 3), + Text( + item.netout.bytes2Str, + style: const TextStyle(fontSize: 10, color: Colors.grey), + textAlign: TextAlign.center, + ) + ], + ), + 4), + ], + ), + if (item.isRunning) 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), + ).card; + } + + Widget _buildStorage(PveStorage item) { + return ListTile( + title: Text(item.storage), + trailing: Text(item.status), + ).card; + } + + Widget _buildSdn(PveSdn item) { + return ListTile( + title: Text(item.sdn), + trailing: Text(item.status), + ).card; + } + + Widget _wrap(Widget child, int count) { + return SizedBox( + height: (_media.size.width - 2 * _kHorziPadding) / count, + child: child, + ); + } +} diff --git a/lib/view/page/server/edit.dart b/lib/view/page/server/edit.dart index 1a4215788..bb12595a0 100644 --- a/lib/view/page/server/edit.dart +++ b/lib/view/page/server/edit.dart @@ -5,7 +5,9 @@ import 'package:toolbox/core/extension/context/dialog.dart'; import 'package:toolbox/core/extension/context/locale.dart'; import 'package:toolbox/core/extension/context/snackbar.dart'; import 'package:toolbox/data/model/app/shell_func.dart'; +import 'package:toolbox/data/model/server/custom.dart'; import 'package:toolbox/data/res/provider.dart'; +import 'package:toolbox/view/widget/expand_tile.dart'; import '../../../core/route.dart'; import '../../../data/model/server/private_key_info.dart'; @@ -33,6 +35,8 @@ class _ServerEditPageState extends State { final _portController = TextEditingController(); final _usernameController = TextEditingController(); final _passwordController = TextEditingController(); + final _pveAddrCtrl = TextEditingController(); + final _nameFocus = FocusNode(); final _ipFocus = FocusNode(); final _alterUrlFocus = FocusNode(); @@ -71,6 +75,7 @@ class _ServerEditPageState extends State { _altUrlController.text = spi.alterUrl ?? ''; _autoConnect.value = spi.autoConnect ?? true; _jumpServer.value = spi.jumpId; + _pveAddrCtrl.text = spi.custom?.pveAddr ?? ''; } } @@ -221,8 +226,6 @@ class _ServerEditPageState extends State { allTags: [...Pros.server.tags.value], onRenameTag: Pros.server.renameTag, ), - _buildAuth(), - //_buildJumpServer(), ListTile( title: Text(l10n.autoConnect), trailing: ListenableBuilder( @@ -235,6 +238,9 @@ class _ServerEditPageState extends State { ), ), ), + _buildAuth(), + //_buildJumpServer(), + _buildPVE(), ]; return SingleChildScrollView( padding: const EdgeInsets.fromLTRB(17, 17, 17, 47), @@ -329,6 +335,17 @@ class _ServerEditPageState extends State { ); } + Widget _buildPVE() { + return ExpandTile(title: const Text('PVE'), children: [ + Input( + controller: _pveAddrCtrl, + type: TextInputType.url, + label: l10n.addr, + hint: 'https://example.com:8006', + ), + ]); + } + Widget _buildFAB() { return FloatingActionButton( heroTag: 'server', @@ -428,6 +445,8 @@ class _ServerEditPageState extends State { if (_portController.text.isEmpty) { _portController.text = '22'; } + final pveAddr = _pveAddrCtrl.text.isEmpty ? null : _pveAddrCtrl.text; + final custom = pveAddr == null ? null : ServerCustom(pveAddr: pveAddr); final spi = ServerPrivateInfo( name: _nameController.text.isEmpty @@ -444,6 +463,7 @@ class _ServerEditPageState extends State { alterUrl: _altUrlController.text.isEmpty ? null : _altUrlController.text, autoConnect: _autoConnect.value, jumpId: _jumpServer.value, + custom: custom, ); if (widget.spi == null) { diff --git a/lib/view/page/server/tab.dart b/lib/view/page/server/tab.dart index 1e2ee43a9..d563f3b6b 100644 --- a/lib/view/page/server/tab.dart +++ b/lib/view/page/server/tab.dart @@ -1,5 +1,4 @@ import 'package:after_layout/after_layout.dart'; -import 'package:circle_chart/circle_chart.dart'; import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; import 'package:provider/provider.dart'; @@ -16,13 +15,13 @@ import 'package:toolbox/data/model/server/sensors.dart'; import 'package:toolbox/data/model/server/try_limiter.dart'; import 'package:toolbox/data/res/provider.dart'; import 'package:toolbox/data/res/store.dart'; +import 'package:toolbox/view/widget/percent_circle.dart'; import '../../../core/route.dart'; import '../../../data/model/app/net_view.dart'; import '../../../data/model/server/server.dart'; import '../../../data/model/server/server_private_info.dart'; import '../../../data/provider/server.dart'; -import '../../../data/res/color.dart'; import '../../../data/res/ui.dart'; import '../../widget/cardx.dart'; import '../../widget/server_func_btns.dart'; @@ -312,9 +311,11 @@ class _ServerPageState extends State Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - _wrapWithSizedbox(_buildPercentCircle(ss.cpu.usedPercent()), true), + _wrapWithSizedbox(PercentCircle(percent: ss.cpu.usedPercent()), true), _wrapWithSizedbox( - _buildPercentCircle(ss.mem.usedPercent * 100), true), + PercentCircle(percent: ss.mem.usedPercent * 100), + true, + ), _wrapWithSizedbox(_buildNet(ss, spi.id)), _wrapWithSizedbox(_buildDisk(ss, spi.id)), ], @@ -509,29 +510,6 @@ class _ServerPageState extends State ); } - Widget _buildPercentCircle(double percent) { - if (percent <= 0) percent = 0.01; - if (percent >= 100) percent = 99.9; - return Stack( - alignment: Alignment.center, - children: [ - CircleChart( - progressColor: primaryColor, - progressNumber: percent, - maxNumber: 100, - width: 57, - height: 57, - animationDuration: const Duration(milliseconds: 777), - ), - Text( - '${percent.toStringAsFixed(1)}%', - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 12.7), - ), - ], - ); - } - @override bool get wantKeepAlive => true; diff --git a/lib/view/widget/percent_circle.dart b/lib/view/widget/percent_circle.dart new file mode 100644 index 000000000..f403ac713 --- /dev/null +++ b/lib/view/widget/percent_circle.dart @@ -0,0 +1,41 @@ +import 'package:circle_chart/circle_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:toolbox/data/res/color.dart'; + +final class PercentCircle extends StatelessWidget { + final double percent; + + const PercentCircle({ + super.key, + required this.percent, + }); + + @override + Widget build(BuildContext context) { + final percent = switch (this.percent) { + 0 => 0.01, + 100 => 99.9, + // NaN + final val when val.isNaN => 0.01, + _ => this.percent, + }; + return Stack( + alignment: Alignment.center, + children: [ + CircleChart( + progressColor: primaryColor, + progressNumber: percent, + maxNumber: 100, + width: 57, + height: 57, + animationDuration: const Duration(milliseconds: 777), + ), + Text( + '${percent.toStringAsFixed(1)}%', + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 12.7), + ), + ], + ); + } +} diff --git a/lib/view/widget/server_func_btns.dart b/lib/view/widget/server_func_btns.dart index 7864eb8d6..00da60bcc 100644 --- a/lib/view/widget/server_func_btns.dart +++ b/lib/view/widget/server_func_btns.dart @@ -163,6 +163,12 @@ void _onTapMoreBtns( check: () => _checkClient(context, spi.id), ); break; + case ServerFuncBtn.pve: + AppRoute.pve(spi: spi).checkGo( + context: context, + check: () => _checkClient(context, spi.id), + ); + break; } } diff --git a/test/pve_test.dart b/test/pve_test.dart new file mode 100644 index 000000000..11fbf7fb4 --- /dev/null +++ b/test/pve_test.dart @@ -0,0 +1,136 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:toolbox/data/model/server/pve.dart'; + +const _raw = ''' +{ + "data": [ + { + "maxmem": 12884901888, + "type": "lxc", + "cpu": 0.0544631947461575, + "netin": 65412250538, + "template": 0, + "diskread": 324033204224, + "maxcpu": 8, + "disk": 29767077888, + "diskwrite": 707866570752, + "node": "pve", + "vmid": 100, + "mem": 5389254656, + "status": "running", + "netout": 66898114418, + "uptime": 1204757, + "id": "lxc/100", + "maxdisk": 134145380352, + "name": "Jellyfin" + }, + { + "vmid": 101, + "node": "pve", + "uptime": 0, + "netout": 0, + "status": "stopped", + "mem": 0, + "id": "qemu/101", + "name": "ubuntu", + "maxdisk": 137438953472, + "maxmem": 6442450944, + "cpu": 0, + "netin": 0, + "type": "qemu", + "disk": 0, + "diskread": 0, + "template": 0, + "maxcpu": 8, + "diskwrite": 0 + }, + { + "maxcpu": 4, + "template": 0, + "diskread": 23287297536, + "disk": 0, + "diskwrite": 39555984896, + "maxmem": 4294967296, + "type": "qemu", + "netin": 2190678599, + "cpu": 0.0516426831961466, + "id": "qemu/102", + "maxdisk": 0, + "name": "win", + "node": "pve", + "vmid": 102, + "mem": 1791827968, + "status": "running", + "netout": 213292068, + "uptime": 1013075 + }, + { + "maxcpu": 12, + "id": "node/pve", + "disk": 358415503360, + "maxdisk": 998011547648, + "cgroup-mode": 2, + "node": "pve", + "maxmem": 29287632896, + "type": "node", + "status": "online", + "mem": 11522887680, + "cpu": 0.0451634094268353, + "level": "", + "uptime": 1204771 + }, + { + "id": "storage/pve/DSM", + "disk": 1250082226176, + "maxdisk": 9909187887104, + "storage": "DSM", + "node": "pve", + "status": "available", + "type": "storage", + "plugintype": "cifs", + "content": "snippets,backup,images,rootdir,vztmpl,iso", + "shared": 1 + }, + { + "type": "storage", + "status": "available", + "plugintype": "dir", + "content": "iso,vztmpl,images,rootdir,backup,snippets", + "shared": 0, + "node": "pve", + "maxdisk": 1967847137280, + "storage": "hard", + "id": "storage/pve/hard", + "disk": 620950544384 + }, + { + "maxdisk": 998011547648, + "storage": "local", + "disk": 358415503360, + "id": "storage/pve/local", + "status": "available", + "type": "storage", + "plugintype": "dir", + "content": "backup,snippets,rootdir,images,vztmpl,iso", + "shared": 0, + "node": "pve" + }, + { + "id": "sdn/pve/localnetwork", + "node": "pve", + "sdn": "localnetwork", + "status": "ok", + "type": "sdn" + } + ] +}'''; + +void main() { + test('parse pve', () { + final list = json.decode(_raw)['data'] as List; + final pveItems = list.map((e) => PveResIface.fromJson(e)).toList(); + expect(pveItems.length, 8); + }); +}