feat: discover local ssh server (#921)
This commit is contained in:
218
lib/view/page/server/discovery/discovery.dart
Normal file
218
lib/view/page/server/discovery/discovery.dart
Normal file
@@ -0,0 +1,218 @@
|
||||
import 'package:fl_lib/fl_lib.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:icons_plus/icons_plus.dart';
|
||||
import 'package:server_box/core/extension/context/locale.dart';
|
||||
import 'package:server_box/core/service/ssh_discovery.dart';
|
||||
import 'package:server_box/data/model/server/discovery_result.dart';
|
||||
|
||||
part 'widget.dart';
|
||||
|
||||
class SshDiscoveryPage extends ConsumerStatefulWidget {
|
||||
const SshDiscoveryPage({super.key});
|
||||
|
||||
static const route = AppRouteNoArg<List<SshDiscoveryResult>>(
|
||||
page: SshDiscoveryPage.new,
|
||||
path: '/servers/discovery',
|
||||
);
|
||||
|
||||
@override
|
||||
ConsumerState<SshDiscoveryPage> createState() => _SshDiscoveryPageState();
|
||||
}
|
||||
|
||||
class _SshDiscoveryPageState extends ConsumerState<SshDiscoveryPage> {
|
||||
final _config = ValueNotifier(const SshDiscoveryConfig());
|
||||
final _discoveryResults = ValueNotifier<List<SshDiscoveryResult>>([]);
|
||||
final _isDiscovering = ValueNotifier(false);
|
||||
final _discoveryReport = ValueNotifier<SshDiscoveryReport?>(null);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_config.dispose();
|
||||
_discoveryResults.dispose();
|
||||
_isDiscovering.dispose();
|
||||
_discoveryReport.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: CustomAppBar(
|
||||
title: Text(l10n.discoverSshServers),
|
||||
actions: [IconButton(icon: const Icon(Icons.settings), onPressed: _showSettings)],
|
||||
),
|
||||
body: _buildBody(),
|
||||
floatingActionButton: _isDiscovering.listenVal((discovering) {
|
||||
if (discovering) return UIs.placeholder;
|
||||
return _buildFAB();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
return Column(
|
||||
children: [
|
||||
_buildSummary(),
|
||||
Expanded(child: _buildResultsList()),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSummary() {
|
||||
return _discoveryReport.listenVal((report) {
|
||||
if (report == null) {
|
||||
return UIs.placeholder;
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(l10n.discoverySummary, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
UIs.height7,
|
||||
Text('${libL10n.found}: ${report.count} ${l10n.servers}'),
|
||||
Text('${libL10n.duration}: ${report.durationMs}ms'),
|
||||
Text(
|
||||
'${l10n.finishedAt}: ${DateTime.parse(report.generatedAt).toLocal().toString().substring(0, 16)}',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildResultsList() {
|
||||
return _discoveryResults.listenVal((results) {
|
||||
if (results.isEmpty) {
|
||||
return _isDiscovering.listenVal((discovering) {
|
||||
if (discovering) {
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [CircularProgressIndicator(), UIs.height13, Text('Discovering SSH servers...')],
|
||||
),
|
||||
);
|
||||
}
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(BoxIcons.bx_search, size: 64, color: UIs.textGrey.color),
|
||||
UIs.height13,
|
||||
Text(l10n.tapToStartDiscovery, style: UIs.textGrey),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: results.length,
|
||||
itemBuilder: (context, index) {
|
||||
final result = results[index];
|
||||
return _buildResultTile(result, index);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildResultTile(SshDiscoveryResult result, int index) {
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
result.isSelected ? Icons.check_circle : Icons.circle_outlined,
|
||||
color: result.isSelected ? Colors.green : null,
|
||||
),
|
||||
title: Text(result.ip),
|
||||
subtitle: result.banner != null
|
||||
? Text(result.banner!, style: const TextStyle(fontSize: 12))
|
||||
: Text('Port ${result.port}', style: UIs.textGrey),
|
||||
trailing: const Icon(BoxIcons.bx_server),
|
||||
onTap: () {
|
||||
final updated = result.copyWith(isSelected: !result.isSelected);
|
||||
final newResults = List<SshDiscoveryResult>.from(_discoveryResults.value);
|
||||
newResults[index] = updated;
|
||||
_discoveryResults.value = newResults;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFAB() {
|
||||
return _discoveryResults.listenVal((results) {
|
||||
final selectedResults = results.where((r) => r.isSelected).toList();
|
||||
return AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
switchInCurve: Curves.easeInOut,
|
||||
switchOutCurve: Curves.easeInOut,
|
||||
transitionBuilder: (child, animation) {
|
||||
return SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(0, 0.3),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(parent: animation, curve: Curves.easeInOutBack)),
|
||||
child: FadeTransition(opacity: animation, child: child),
|
||||
);
|
||||
},
|
||||
layoutBuilder: (currentChild, previousChildren) {
|
||||
return Stack(
|
||||
alignment: Alignment.centerRight,
|
||||
children: <Widget>[...previousChildren, if (currentChild != null) currentChild],
|
||||
);
|
||||
},
|
||||
child: selectedResults.isNotEmpty
|
||||
? FloatingActionButton.extended(
|
||||
key: const ValueKey('import'),
|
||||
heroTag: 'import_fab',
|
||||
onPressed: () => _importSelected(),
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text('${libL10n.import} (${selectedResults.length})'),
|
||||
)
|
||||
: FloatingActionButton.extended(
|
||||
key: const ValueKey('discovery'),
|
||||
heroTag: 'discovery_fab',
|
||||
onPressed: _startDiscovery,
|
||||
icon: const Icon(BoxIcons.bx_search),
|
||||
label: Text(libL10n.search),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _startDiscovery() async {
|
||||
_isDiscovering.value = true;
|
||||
_discoveryResults.value = [];
|
||||
_discoveryReport.value = null;
|
||||
|
||||
try {
|
||||
final report = await SshDiscoveryService.discover(_config.value);
|
||||
_discoveryReport.value = report;
|
||||
_discoveryResults.value = report.items;
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
context.showSnackBar('${l10n.discoveryFailed}: $e');
|
||||
}
|
||||
} finally {
|
||||
_isDiscovering.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
void _showSettings() {
|
||||
context.showRoundDialog(
|
||||
child: _DiscoverySettingsDialog(config: _config.value, onChanged: (config) => _config.value = config),
|
||||
actions: Btnx.oks,
|
||||
);
|
||||
}
|
||||
|
||||
void _importSelected() {
|
||||
final selected = _discoveryResults.value.where((r) => r.isSelected).toList();
|
||||
if (selected.isEmpty) return;
|
||||
|
||||
context.pop(selected);
|
||||
}
|
||||
}
|
||||
78
lib/view/page/server/discovery/widget.dart
Normal file
78
lib/view/page/server/discovery/widget.dart
Normal file
@@ -0,0 +1,78 @@
|
||||
part of 'discovery.dart';
|
||||
|
||||
class _DiscoverySettingsDialog extends StatefulWidget {
|
||||
final SshDiscoveryConfig config;
|
||||
final ValueChanged<SshDiscoveryConfig> onChanged;
|
||||
|
||||
const _DiscoverySettingsDialog({
|
||||
required this.config,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_DiscoverySettingsDialog> createState() => _DiscoverySettingsDialogState();
|
||||
}
|
||||
|
||||
class _DiscoverySettingsDialogState extends State<_DiscoverySettingsDialog> {
|
||||
late final _timeoutController = TextEditingController(text: widget.config.timeoutMs.toString());
|
||||
late final _concurrencyController = TextEditingController(text: widget.config.maxConcurrency.toString());
|
||||
late bool _enableMdns = widget.config.enableMdns;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timeoutController.dispose();
|
||||
_concurrencyController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
l10n.discoverySettings,
|
||||
style: const TextStyle(fontSize: 18),
|
||||
),
|
||||
UIs.height13,
|
||||
Input(
|
||||
controller: _timeoutController,
|
||||
type: TextInputType.number,
|
||||
label: '${libL10n.timeout} (ms)',
|
||||
onChanged: (v) {
|
||||
final t = int.tryParse(v) ?? 700;
|
||||
if (t > 0) {
|
||||
widget.onChanged(widget.config.copyWith(timeoutMs: t));
|
||||
}
|
||||
},
|
||||
hint: '700',
|
||||
),
|
||||
UIs.height7,
|
||||
Input(
|
||||
controller: _concurrencyController,
|
||||
type: TextInputType.number,
|
||||
label: l10n.maxConcurrency,
|
||||
hint: '128',
|
||||
onChanged: (v) {
|
||||
final c = int.tryParse(v) ?? 128;
|
||||
if (c > 0) {
|
||||
widget.onChanged(widget.config.copyWith(maxConcurrency: c));
|
||||
}
|
||||
},
|
||||
),
|
||||
UIs.height7,
|
||||
SwitchListTile(
|
||||
title: Text(l10n.enableMdns),
|
||||
subtitle: Text(l10n.enableMdnsDesc),
|
||||
value: _enableMdns,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_enableMdns = value;
|
||||
});
|
||||
widget.onChanged(widget.config.copyWith(enableMdns: value));
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,100 @@
|
||||
part of 'edit.dart';
|
||||
|
||||
extension _Actions on _ServerEditPageState {
|
||||
Future<void> _onTapSSHDiscovery() async {
|
||||
try {
|
||||
final result = await SshDiscoveryPage.route.go(context);
|
||||
|
||||
if (result != null && result.isNotEmpty) {
|
||||
await _processDiscoveredServers(result);
|
||||
}
|
||||
} catch (e, s) {
|
||||
context.showErrDialog(e, s);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _processDiscoveredServers(List<SshDiscoveryResult> discoveredServers) async {
|
||||
if (discoveredServers.length == 1) {
|
||||
// Single server - populate the current form
|
||||
final server = discoveredServers.first;
|
||||
_ipController.text = server.ip;
|
||||
_portController.text = server.port.toString();
|
||||
if (_nameController.text.isEmpty) {
|
||||
_nameController.text = server.ip;
|
||||
}
|
||||
context.showSnackBar('${libL10n.found} 1 ${l10n.server}');
|
||||
} else {
|
||||
// Multiple servers - show import dialog
|
||||
final shouldImport = await context.showRoundDialog<bool>(
|
||||
title: libL10n.import,
|
||||
child: Text(libL10n.askContinue('${libL10n.found} ${discoveredServers.length} ${l10n.servers}')),
|
||||
actions: Btnx.cancelOk,
|
||||
);
|
||||
|
||||
if (shouldImport == true) {
|
||||
// Prompt user to configure default values before importing
|
||||
final defaultUsername = 'root';
|
||||
final defaultKeyId = _keyIdx.value?.toString() ?? '';
|
||||
final usernameController = TextEditingController(text: defaultUsername);
|
||||
final keyIdController = TextEditingController(text: defaultKeyId);
|
||||
|
||||
final shouldProceed = await context.showRoundDialog<bool>(
|
||||
title: libL10n.import,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('${libL10n.found} ${discoveredServers.length} ${l10n.servers}.'),
|
||||
const SizedBox(height: 8),
|
||||
Text(libL10n.setting),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: usernameController,
|
||||
decoration: InputDecoration(labelText: libL10n.user),
|
||||
),
|
||||
TextField(
|
||||
controller: keyIdController,
|
||||
decoration: InputDecoration(labelText: l10n.privateKey),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: Btnx.cancelOk,
|
||||
);
|
||||
|
||||
if (shouldProceed == true) {
|
||||
final username = usernameController.text.isNotEmpty ? usernameController.text : defaultUsername;
|
||||
final keyId = keyIdController.text.isNotEmpty ? keyIdController.text : null;
|
||||
final servers = discoveredServers.map((result) => Spi(
|
||||
name: result.ip,
|
||||
ip: result.ip,
|
||||
port: result.port,
|
||||
user: username,
|
||||
keyId: keyId,
|
||||
pwd: _passwordController.text.isEmpty ? null : _passwordController.text,
|
||||
)).toList();
|
||||
|
||||
await _batchImportServers(servers);
|
||||
}
|
||||
|
||||
usernameController.dispose();
|
||||
keyIdController.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _batchImportServers(List<Spi> servers) async {
|
||||
final store = Stores.server;
|
||||
int imported = 0;
|
||||
for (final server in servers) {
|
||||
try {
|
||||
store.put(server);
|
||||
imported++;
|
||||
} catch (e) {
|
||||
dprint('Failed to import server ${server.name}: $e');
|
||||
}
|
||||
}
|
||||
context.showSnackBar('${libL10n.success}: $imported ${l10n.servers}');
|
||||
if (mounted) Navigator.of(context).pop(true);
|
||||
}
|
||||
void _onTapSSHImport() async {
|
||||
try {
|
||||
final servers = await SSHConfig.parseConfig();
|
||||
|
||||
@@ -13,6 +13,7 @@ import 'package:server_box/core/utils/server_dedup.dart';
|
||||
import 'package:server_box/core/utils/ssh_config.dart';
|
||||
import 'package:server_box/data/model/app/scripts/cmd_types.dart';
|
||||
import 'package:server_box/data/model/server/custom.dart';
|
||||
import 'package:server_box/data/model/server/discovery_result.dart';
|
||||
import 'package:server_box/data/model/server/server_private_info.dart';
|
||||
import 'package:server_box/data/model/server/system.dart';
|
||||
import 'package:server_box/data/model/server/wol_cfg.dart';
|
||||
@@ -21,6 +22,7 @@ import 'package:server_box/data/provider/server/all.dart';
|
||||
import 'package:server_box/data/res/store.dart';
|
||||
import 'package:server_box/data/store/server.dart';
|
||||
import 'package:server_box/view/page/private_key/edit.dart';
|
||||
import 'package:server_box/view/page/server/discovery/discovery.dart';
|
||||
|
||||
part 'actions.dart';
|
||||
part 'widget.dart';
|
||||
@@ -30,17 +32,13 @@ class ServerEditPage extends ConsumerStatefulWidget {
|
||||
|
||||
const ServerEditPage({super.key, this.args});
|
||||
|
||||
static const route = AppRoute<bool, SpiRequiredArgs>(
|
||||
page: ServerEditPage.new,
|
||||
path: '/servers/edit',
|
||||
);
|
||||
static const route = AppRoute<bool, SpiRequiredArgs>(page: ServerEditPage.new, path: '/servers/edit');
|
||||
|
||||
@override
|
||||
ConsumerState<ServerEditPage> createState() => _ServerEditPageState();
|
||||
}
|
||||
|
||||
class _ServerEditPageState extends ConsumerState<ServerEditPage>
|
||||
with AfterLayoutMixin {
|
||||
class _ServerEditPageState extends ConsumerState<ServerEditPage> with AfterLayoutMixin {
|
||||
late final spi = widget.args?.spi;
|
||||
final _nameController = TextEditingController();
|
||||
final _ipController = TextEditingController();
|
||||
@@ -137,11 +135,12 @@ class _ServerEditPageState extends ConsumerState<ServerEditPage>
|
||||
_buildWriteScriptTip(),
|
||||
if (isMobile) _buildQrScan(),
|
||||
if (isDesktop) _buildSSHImport(),
|
||||
_buildSSHDiscovery(),
|
||||
];
|
||||
final children = [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: topItems.joinWith(UIs.width13).toList(),
|
||||
SizedBox(
|
||||
height: 50,
|
||||
child: ListView(scrollDirection: Axis.horizontal, children: topItems.joinWith(UIs.width13).toList()),
|
||||
),
|
||||
Input(
|
||||
autoFocus: true,
|
||||
@@ -186,10 +185,7 @@ class _ServerEditPageState extends ConsumerState<ServerEditPage>
|
||||
hint: 'root',
|
||||
suggestion: false,
|
||||
),
|
||||
TagTile(
|
||||
tags: _tags,
|
||||
allTags: ref.watch(serversNotifierProvider).tags,
|
||||
).cardx,
|
||||
TagTile(tags: _tags, allTags: ref.watch(serversNotifierProvider).tags).cardx,
|
||||
ListTile(
|
||||
title: Text(l10n.autoConnect),
|
||||
trailing: _autoConnect.listenVal(
|
||||
|
||||
@@ -439,6 +439,16 @@ extension _Widgets on _ServerEditPageState {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSSHDiscovery() {
|
||||
return Btn.tile(
|
||||
text: l10n.discoverSshServers,
|
||||
icon: const Icon(BoxIcons.bx_search, color: Colors.grey),
|
||||
onTap: _onTapSSHDiscovery,
|
||||
textStyle: UIs.textGrey,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDelBtn() {
|
||||
return IconButton(
|
||||
onPressed: () {
|
||||
|
||||
Reference in New Issue
Block a user