This commit is contained in:
lollipopkit
2024-03-18 18:34:25 +08:00
parent 8bfb0eb9e0
commit 26264ecdea
31 changed files with 1114 additions and 56 deletions

View File

@@ -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}';
}
}

View File

@@ -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);
}

View File

@@ -1,3 +1,4 @@
import 'package:toolbox/core/extension/listx.dart';
import 'package:toolbox/core/persistant_store.dart';
typedef Order<T> = List<T>;
@@ -52,7 +53,7 @@ extension OrderX<T> on Order<T> {
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<T> on Order<T> {
final missed = <T>[];
final surplus = <String>[];
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) {

View File

@@ -0,0 +1,7 @@
import 'package:flutter/material.dart';
extension WidgetX on Widget {
Widget get card {
return Card(child: this);
}
}

View File

@@ -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');
}
}

View File

@@ -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;

View File

@@ -27,6 +27,8 @@ class ServerFuncBtnAdapter extends TypeAdapter<ServerFuncBtn> {
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<ServerFuncBtn> {
case ServerFuncBtn.iperf:
writer.writeByte(6);
break;
case ServerFuncBtn.pve:
writer.writeByte(7);
break;
}
}

View File

@@ -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<String, dynamic> json) {
final temperature = json["temperature"] as String?;
final pveAddr = json["pveAddr"] as String?;
return ServerCustom(
temperature: temperature,
pveAddr: pveAddr,
);
}
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (temperature != null) {
json["temperature"] = temperature;
}
if (pveAddr != null) {
json["pveAddr"] = pveAddr;
}
return json;
}
}

View File

@@ -0,0 +1,44 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'custom.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class ServerCustomAdapter extends TypeAdapter<ServerCustom> {
@override
final int typeId = 7;
@override
ServerCustom read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
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;
}

View File

@@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> json) {
return PveSdn(
id: json['id'],
type: PveResType.sdn,
sdn: json['sdn'],
node: json['node'],
status: json['status'],
);
}
}
final class PveRes {
final List<PveNode> nodes;
final List<PveQemu> qemus;
final List<PveLxc> lxcs;
final List<PveStorage> storages;
final List<PveSdn> 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];
}
}

View File

@@ -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<String, dynamic> 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<String, dynamic>());
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;
}

View File

@@ -27,13 +27,14 @@ class ServerPrivateInfoAdapter extends TypeAdapter<ServerPrivateInfo> {
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<ServerPrivateInfo> {
..writeByte(8)
..write(obj.autoConnect)
..writeByte(9)
..write(obj.jumpId);
..write(obj.jumpId)
..writeByte(10)
..write(obj.custom);
}
@override

112
lib/data/provider/pve.dart Normal file
View File

@@ -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<String?>(null);
final connected = Completer<void>();
final session = Dio();
// int _localPort = 0;
// String get addr => 'http://127.0.0.1:$_localPort';
Future<void> _init() async {
//await _forward();
await _login();
}
// Future<void> _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<List<int>>().pipe(socket);
// socket.pipe(forward.sink);
// });
// return;
// } on SocketException {
// retries++;
// }
// }
// throw Exception('Failed to bind local port');
// }
Future<void> _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<PveRes> 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 = <PveQemu>[];
final lxcs = <PveLxc>[];
final nodes = <PveNode>[];
final storages = <PveStorage>[];
final sdns = <PveSdn>[];
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,
);
}
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "中断",

View File

@@ -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",

View File

@@ -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": "приостановить",

View File

@@ -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": "挂起",

View File

@@ -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": "挂起",

View File

@@ -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<void> _initDb() async {
Hive.registerAdapter(VirtKeyAdapter()); // 4
Hive.registerAdapter(NetViewTypeAdapter()); // 5
Hive.registerAdapter(ServerFuncBtnAdapter()); // 6
Hive.registerAdapter(ServerCustomAdapter()); // 7
}
void _setupLogger() {

View File

@@ -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<void> _onTapFileRestore(BuildContext context) async {

197
lib/view/page/pve.dart Normal file
View File

@@ -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<PvePage> {
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,
);
}
}

View File

@@ -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<ServerEditPage> {
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<ServerEditPage> {
_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<ServerEditPage> {
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<ServerEditPage> {
),
),
),
_buildAuth(),
//_buildJumpServer(),
_buildPVE(),
];
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(17, 17, 17, 47),
@@ -329,6 +335,17 @@ class _ServerEditPageState extends State<ServerEditPage> {
);
}
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<ServerEditPage> {
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<ServerEditPage> {
alterUrl: _altUrlController.text.isEmpty ? null : _altUrlController.text,
autoConnect: _autoConnect.value,
jumpId: _jumpServer.value,
custom: custom,
);
if (widget.spi == null) {

View File

@@ -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<ServerPage>
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<ServerPage>
);
}
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;

View File

@@ -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),
),
],
);
}
}

View File

@@ -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;
}
}

136
test/pve_test.dart Normal file
View File

@@ -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);
});
}