Files
flutter_opencode_client/lib/view/page/pve.dart
GT610 09431a0b08 fix(pve): Fix connection issues and add more error handlings (#1081)
* feat(PVE): Added display of PVE connection loading steps

Added a detailed display of loading steps during the PVE connection process, including stages such as establishing an SSH tunnel, authentication, and data retrieval

Also optimized the sorting of PVE storage content and the logic for handling connection errors

* feat(pve): Added error handling and prompts for PVE two-factor authentication

Added error handling for PVE servers when two-factor authentication is enabled, along with relevant error types and localized prompts

* feat(PVE): Added support for PVE passwords during key-based authentication

- Added the `pvePwd` field to the `ServerCustom` model
- Added a PVE password input field to the edit page (displayed only during key-based authentication)
- Updated multilingual files to support PVE-related loading states and password prompts
- Optimized PVE connection logic to support password verification during key-based authentication
2026-03-22 16:25:48 +08:00

441 lines
14 KiB
Dart

import 'dart:async';
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:server_box/core/extension/context/locale.dart';
import 'package:server_box/data/model/app/error.dart';
import 'package:server_box/data/model/server/pve.dart';
import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/provider/pve.dart';
import 'package:server_box/data/res/store.dart';
import 'package:server_box/view/widget/percent_circle.dart';
final class PvePageArgs {
final Spi spi;
const PvePageArgs({required this.spi});
}
final class PvePage extends ConsumerStatefulWidget {
final PvePageArgs args;
const PvePage({super.key, required this.args});
@override
ConsumerState<PvePage> createState() => _PvePageState();
static const route = AppRouteArg<void, PvePageArgs>(page: PvePage.new, path: '/pve');
}
const _kHorziPadding = 11.0;
final class _PvePageState extends ConsumerState<PvePage> {
late MediaQueryData _media;
Timer? _timer;
late final _provider = pveProvider(widget.args.spi);
late final _notifier = ref.read(_provider.notifier);
@override
void dispose() {
super.dispose();
_timer?.cancel();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_media = MediaQuery.of(context);
}
@override
void initState() {
super.initState();
_initRefreshTimer();
_afterInit();
}
@override
Widget build(BuildContext context) {
final pveState = ref.watch(_provider);
// If there is an error, stop the timer
if (pveState.error != null) {
_timer?.cancel();
}
return Scaffold(
appBar: CustomAppBar(
title: TwoLineText(up: 'PVE', down: widget.args.spi.name),
actions: [
pveState.error == null
? UIs.placeholder
: Btn.icon(
icon: const Icon(Icons.refresh),
onTap: () {
_notifier.reconnect();
_initRefreshTimer();
},
),
],
),
body: pveState.error != null
? _buildError(pveState.error!)
: _buildBody(pveState.data, pveState.loadingStep),
);
}
Widget _buildError(PveErr error) {
return Padding(
padding: const EdgeInsets.all(13),
child: Center(child: Text(error.toString())),
);
}
Widget _buildBody(PveRes? data, PveLoadingStep loadingStep) {
if (data == null) {
return _buildLoading(loadingStep);
}
PveResType? lastType;
return ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: _kHorziPadding, vertical: 7),
itemCount: data.length * 2,
itemBuilder: (context, index) {
final item = data[index ~/ 2];
if (index % 2 == 0) {
final type = switch (item) {
final PveNode _ => PveResType.node,
final PveQemu _ => PveResType.qemu,
final PveLxc _ => PveResType.lxc,
final PveStorage _ => PveResType.storage,
final PveSdn _ => PveResType.sdn,
};
if (type == lastType) {
return UIs.placeholder;
}
lastType = type;
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),
textAlign: TextAlign.start,
),
),
);
}
return switch (item) {
final PveNode _ => _buildNode(item),
final PveQemu _ => _buildQemu(item),
final PveLxc _ => _buildLxc(item),
final PveStorage _ => _buildStorage(item),
final PveSdn _ => _buildSdn(item),
};
},
);
}
Widget _buildLoading(PveLoadingStep step) {
final String message = switch (step) {
PveLoadingStep.forwarding => l10n.pveLoadingForwarding,
PveLoadingStep.loggingIn => l10n.pveLoadingLogin,
PveLoadingStep.fetchingData => l10n.pveLoadingData,
_ => l10n.pveLoadingConnect,
};
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 17),
Text(message, style: UIs.text13Grey),
],
),
);
}
Widget _buildNode(PveNode item) {
final valueAnim = AlwaysStoppedAnimation(UIs.primaryColor);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 13, horizontal: 13),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Text(item.node, style: UIs.text15Bold),
const Spacer(),
Text(item.topRight, style: UIs.text12Grey),
],
),
UIs.height13,
// Row(
// mainAxisAlignment: MainAxisAlignment.spaceAround,
// children: [
// _wrap(PercentCircle(percent: item.cpu / item.maxcpu), 3),
// _wrap(PercentCircle(percent: item.mem / item.maxmem), 3),
// ],
// ),
Row(
children: [
const Icon(Icons.memory, size: 13, color: Colors.grey),
UIs.width7,
const Text('CPU', style: UIs.text12Grey),
const Spacer(),
Text('${(item.cpu * 100).toStringAsFixed(1)} %', style: UIs.text12Grey),
],
),
const SizedBox(height: 3),
LinearProgressIndicator(value: item.cpu / item.maxcpu, minHeight: 7, valueColor: valueAnim),
UIs.height7,
Row(
children: [
const Icon(Icons.view_agenda, size: 13, color: Colors.grey),
UIs.width7,
const Text('RAM', style: UIs.text12Grey),
const Spacer(),
Text('${item.mem.bytes2Str} / ${item.maxmem.bytes2Str}', style: UIs.text12Grey),
],
),
const SizedBox(height: 3),
LinearProgressIndicator(value: item.mem / item.maxmem, minHeight: 7, valueColor: valueAnim),
],
),
).cardx;
}
Widget _buildQemu(PveQemu item) {
if (!item.available) {
return ListTile(
title: Text(_wrapNodeName(item), style: UIs.text13Bold),
trailing: _buildCtrlBtns(item),
).cardx;
}
final children = <Widget>[
const SizedBox(height: 5),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
const SizedBox(width: 15),
Text(_wrapNodeName(item), style: UIs.text13Bold),
Text(' / ${item.summary}', style: UIs.text12Grey),
const Spacer(),
_buildCtrlBtns(item),
UIs.width13,
],
),
UIs.height7,
AvgSize(
totalSize: _media.size.width,
padding: _kHorziPadding * 2 + 26,
children: [
PercentCircle(percent: (item.cpu / item.maxcpu) * 100),
PercentCircle(percent: (item.mem / item.maxmem) * 100),
Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'${l10n.read}:\n${item.diskread.bytes2Str}',
style: UIs.text11Grey,
textAlign: TextAlign.center,
),
const SizedBox(height: 3),
Text(
'${l10n.write}:\n${item.diskwrite.bytes2Str}',
style: UIs.text11Grey,
textAlign: TextAlign.center,
),
],
),
Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('↓:\n${item.netin.bytes2Str}', style: UIs.text11Grey, textAlign: TextAlign.center),
const SizedBox(height: 3),
Text('↑:\n${item.netout.bytes2Str}', style: UIs.text11Grey, textAlign: TextAlign.center),
],
),
],
),
const SizedBox(height: 21),
];
return Column(mainAxisSize: MainAxisSize.min, children: children).cardx;
}
Widget _buildLxc(PveLxc item) {
if (!item.available) {
return ListTile(
title: Text(_wrapNodeName(item), style: UIs.text13Bold),
trailing: _buildCtrlBtns(item),
).cardx;
}
final children = <Widget>[
const SizedBox(height: 5),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
const SizedBox(width: 15),
Text(_wrapNodeName(item), style: UIs.text13Bold),
Text(' / ${item.summary}', style: UIs.text12Grey),
const Spacer(),
_buildCtrlBtns(item),
UIs.width13,
],
),
UIs.height7,
AvgSize(
totalSize: _media.size.width,
padding: _kHorziPadding * 2 + 26,
children: [
PercentCircle(percent: (item.cpu / item.maxcpu) * 100),
PercentCircle(percent: (item.mem / item.maxmem) * 100),
Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'${l10n.read}:\n${item.diskread.bytes2Str}',
style: UIs.text11Grey,
textAlign: TextAlign.center,
),
const SizedBox(height: 3),
Text(
'${l10n.write}:\n${item.diskwrite.bytes2Str}',
style: UIs.text11Grey,
textAlign: TextAlign.center,
),
],
),
Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('↓:\n${item.netin.bytes2Str}', style: UIs.text11Grey, textAlign: TextAlign.center),
const SizedBox(height: 3),
Text('↑:\n${item.netout.bytes2Str}', style: UIs.text11Grey, textAlign: TextAlign.center),
],
),
],
),
const SizedBox(height: 21),
];
return Column(mainAxisSize: MainAxisSize.min, children: children).cardx;
}
Widget _buildStorage(PveStorage item) {
return Padding(
padding: const EdgeInsets.all(13),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(_wrapNodeName(item), style: UIs.text13Bold),
const Spacer(),
Text(item.summary, style: UIs.text11Grey),
],
),
UIs.height7,
KvRow(k: libL10n.content, v: item.content),
KvRow(k: l10n.plugInType, v: item.plugintype),
],
),
).cardx;
}
Widget _buildSdn(PveSdn item) {
return ListTile(title: Text(_wrapNodeName(item)), trailing: Text(item.summary)).cardx;
}
Widget _buildCtrlBtns(PveCtrlIface item) {
const pad = EdgeInsets.symmetric(horizontal: 7, vertical: 5);
if (!item.available) {
return Btn.icon(
icon: const Icon(Icons.play_arrow, color: Colors.grey),
onTap: () => _onCtrl(libL10n.start, item, () => _notifier.start(item.node, item.id)),
);
}
return Row(
children: [
Btn.icon(
icon: const Icon(Icons.stop, color: Colors.grey, size: 20),
padding: pad,
onTap: () => _onCtrl(libL10n.stop, item, () => _notifier.stop(item.node, item.id)),
),
Btn.icon(
icon: const Icon(Icons.refresh, color: Colors.grey, size: 20),
padding: pad,
onTap: () => _onCtrl(libL10n.reboot, item, () => _notifier.reboot(item.node, item.id)),
),
Btn.icon(
icon: const Icon(Icons.power_off, color: Colors.grey, size: 20),
padding: pad,
onTap: () => _onCtrl(libL10n.shutdown, item, () => _notifier.shutdown(item.node, item.id)),
),
],
);
}
}
extension on _PvePageState {
void _onCtrl(String action, PveCtrlIface item, Future<bool> Function() func) async {
final sure = await context.showRoundDialog<bool>(
title: libL10n.attention,
child: Text(libL10n.askContinue('$action ${item.id}')),
actions: Btnx.okReds,
);
if (sure != true) return;
final (suc, err) = await context.showLoadingDialog(fn: func);
if (suc == true) {
context.showSnackBar(libL10n.success);
} else {
context.showSnackBar(err?.toString() ?? libL10n.fail);
}
}
/// Add PveNode if only one node exists
String _wrapNodeName(PveCtrlIface item) {
final pveState = ref.read(_provider);
if (pveState.data?.onlyOneNode ?? false) {
return item.name;
}
return '${item.node} / ${item.name}';
}
void _initRefreshTimer() {
_timer?.cancel();
_timer = Timer.periodic(Duration(seconds: Stores.setting.serverStatusUpdateInterval.fetch()), (_) {
if (mounted) {
_notifier.list();
}
});
}
void _afterInit() async {
// Wait for the PVE state to be connected
while (mounted) {
final pveState = ref.read(_provider);
if (pveState.isConnected) {
if (pveState.release != null && pveState.release!.compareTo('8.0') < 0) {
if (mounted) {
context.showSnackBar(l10n.pveVersionLow);
}
}
break;
}
if (pveState.error != null) {
break; // Skip if there is an error
}
await Future.delayed(const Duration(milliseconds: 100));
}
}
}