This commit is contained in:
lollipopkit
2023-01-28 00:17:36 +08:00
parent 913ce312de
commit 9608c9139c
9 changed files with 325 additions and 181 deletions

View File

@@ -14,13 +14,16 @@
</p>
<p align="center">
A Flutter project which provide charts to display server status and tools to manage server.<br>Especially thanks to <a href="https://github.com/TerminalStudio/dartssh2">dartssh2</a>.
A Flutter project which provide charts to display server status and tools to manage server.
<br>
Especially thanks to <a href="https://github.com/TerminalStudio/dartssh2">dartssh2</a> & <a href="https://github.com/TerminalStudio/xterm.dart">xterm.dart</a>.
</p>
## 🔖 Milestone
## 🔖 Feature
- [x] Status chart view
- [x] Snippet ~~market~~, Ping, SFTP, Docker, Apt/Yum and etc.
- [x] SSH terminal
- [x] `Docker Manage`, `Pkg Manage`, `SFTP`, `Snippet` ~~market~~, `Ping` and etc.
- [x] i18n (English, Chinese)
- [x] Desktop support

View File

@@ -24,11 +24,12 @@ class DropdownBtnItem {
}
class ServerTabMenuItems {
static const List<DropdownBtnItem> firstItems = [sftp, pkg, docker];
static const List<DropdownBtnItem> firstItems = [sftp, snippet, pkg, docker];
static const List<DropdownBtnItem> secondItems = [edit];
static const sftp =
DropdownBtnItem(text: 'SFTP', icon: Icons.insert_drive_file);
static const snippet = DropdownBtnItem(text: 'Snippet', icon: Icons.code);
static const pkg =
DropdownBtnItem(text: 'Pkg', icon: Icons.system_security_update);
static const docker =

View File

@@ -2,9 +2,9 @@
class BuildData {
static const String name = "ServerBox";
static const int build = 183;
static const int build = 186;
static const String engine =
"Flutter 3.7.0 • channel stable • https://github.com/flutter/flutter.git\nFramework • revision b06b8b2710 (4 days ago) • 2023-01-23 16:55:55 -0800\nEngine • revision b24591ed32\nTools • Dart 2.19.0 • DevTools 2.20.1\n";
static const String buildAt = "2023-01-27 21:38:08.181334";
static const int modifications = 19;
static const String buildAt = "2023-01-28 00:10:21.021365";
static const int modifications = 8;
}

View File

@@ -21,6 +21,7 @@ import 'package:toolbox/view/page/server/detail.dart';
import 'package:toolbox/view/page/server/edit.dart';
import 'package:toolbox/view/page/sftp/view.dart';
import 'package:toolbox/view/page/snippet/edit.dart';
import 'package:toolbox/view/page/ssh.dart';
import 'package:toolbox/view/widget/picker.dart';
import 'package:toolbox/view/widget/round_rect_card.dart';
@@ -160,8 +161,8 @@ class _ServerPageState extends State<ServerPage>
context, _s.error, Text(ss.failedInfo ?? ''), []),
child: Text(_s.clickSee, style: style))
: Text(topRightStr, style: style, textScaleFactor: 1.0),
const SizedBox(width: 7),
_buildSnippetBtn(spi),
const SizedBox(width: 9),
_buildSSHBtn(spi),
_buildMoreBtn(spi),
],
)
@@ -196,60 +197,13 @@ class _ServerPageState extends State<ServerPage>
);
}
Widget _buildSnippetBtn(ServerPrivateInfo spi) {
Widget _buildSSHBtn(ServerPrivateInfo spi) {
return GestureDetector(
child: const Icon(Icons.play_arrow),
onTap: () {
final provider = locator<SnippetProvider>();
if (provider.snippets.isEmpty) {
showRoundDialog(
context,
_s.attention,
Text(_s.noSavedSnippet),
[
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(_s.ok),
),
TextButton(
onPressed: () =>
AppRoute(const SnippetEditPage(), 'edit snippet')
.go(context),
child: Text(_s.addOne),
)
],
);
return;
}
var snippet = provider.snippets.first;
showRoundDialog(
context,
_s.choose,
buildPicker(provider.snippets.map((e) => Text(e.name)).toList(),
(idx) => snippet = provider.snippets[idx]),
[
TextButton(
onPressed: () async {
Navigator.of(context).pop();
final result =
await locator<ServerProvider>().runSnippet(spi.id, snippet);
showRoundDialog(
context,
_s.result,
Text(result ?? _s.error, style: textSize13),
[
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(_s.ok))
],
);
},
child: Text(_s.run),
)
],
);
},
child: const Icon(
Icons.terminal,
size: 21,
),
onTap: () => AppRoute(SSHPage(spi: spi), 'ssh page').go(context),
);
}
@@ -271,14 +225,16 @@ class _ServerPageState extends State<ServerPage>
),
],
onSelected: (value) {
final item = value as DropdownBtnItem;
switch (item) {
switch (value as DropdownBtnItem) {
case ServerTabMenuItems.pkg:
AppRoute(PkgManagePage(spi), 'pkg manage').go(context);
break;
case ServerTabMenuItems.sftp:
AppRoute(SFTPPage(spi), 'SFTP').go(context);
break;
case ServerTabMenuItems.snippet:
_showSnippetDialog(spi.id);
break;
case ServerTabMenuItems.edit:
AppRoute(ServerEditPage(spi: spi), 'Edit server info').go(context);
break;
@@ -393,6 +349,57 @@ class _ServerPageState extends State<ServerPage>
);
}
void _showSnippetDialog(String id) {
final provider = locator<SnippetProvider>();
if (provider.snippets.isEmpty) {
showRoundDialog(
context,
_s.attention,
Text(_s.noSavedSnippet),
[
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(_s.ok),
),
TextButton(
onPressed: () =>
AppRoute(const SnippetEditPage(), 'edit snippet').go(context),
child: Text(_s.addOne),
)
],
);
return;
}
var snippet = provider.snippets.first;
showRoundDialog(
context,
_s.choose,
buildPicker(provider.snippets.map((e) => Text(e.name)).toList(),
(idx) => snippet = provider.snippets[idx]),
[
TextButton(
onPressed: () async {
Navigator.of(context).pop();
final result =
await locator<ServerProvider>().runSnippet(id, snippet);
showRoundDialog(
context,
_s.result,
Text(result ?? _s.error, style: textSize13),
[
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(_s.ok))
],
);
},
child: Text(_s.run),
)
],
);
}
@override
bool get wantKeepAlive => true;

View File

@@ -1,31 +1,22 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:toolbox/core/route.dart';
import 'package:toolbox/core/utils.dart';
import 'package:toolbox/data/model/server/server_private_info.dart';
import 'package:toolbox/data/model/server/snippet.dart';
import 'package:toolbox/data/provider/server.dart';
import 'package:toolbox/data/provider/snippet.dart';
import 'package:toolbox/data/res/color.dart';
import 'package:toolbox/data/res/font_style.dart';
import 'package:toolbox/data/res/padding.dart';
import 'package:toolbox/generated/l10n.dart';
import 'package:toolbox/locator.dart';
import 'package:toolbox/view/page/snippet/edit.dart';
import 'package:toolbox/view/widget/picker.dart';
import 'package:toolbox/view/widget/round_rect_card.dart';
class SnippetListPage extends StatefulWidget {
const SnippetListPage({Key? key, this.spi}) : super(key: key);
final ServerPrivateInfo? spi;
const SnippetListPage({Key? key}) : super(key: key);
@override
_SnippetListPageState createState() => _SnippetListPageState();
}
class _SnippetListPageState extends State<SnippetListPage> {
late ServerPrivateInfo _selectedSpi;
final _textStyle = TextStyle(color: primaryColor);
late S _s;
@@ -54,116 +45,45 @@ class _SnippetListPageState extends State<SnippetListPage> {
Widget _buildBody() {
return Consumer<SnippetProvider>(
builder: (_, key, __) {
return key.snippets.isNotEmpty
? ListView.builder(
padding: const EdgeInsets.all(13),
itemCount: key.snippets.length,
itemExtent: 57,
itemBuilder: (context, idx) {
return RoundRectCard(
Padding(
padding: roundRectCardPadding,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
key.snippets[idx].name,
textAlign: TextAlign.center,
),
Row(
children: [
TextButton(
onPressed: () => AppRoute(
SnippetEditPage(
snippet: key.snippets[idx]),
'snippet edit page')
.go(context),
child: Text(
_s.edit,
style: _textStyle,
),
),
TextButton(
onPressed: () {
final snippet = key.snippets[idx];
if (widget.spi == null) {
_showRunDialog(snippet);
return;
}
run(context, snippet);
},
child: Text(
_s.run,
style: _textStyle,
),
)
],
)
],
if (key.snippets.isEmpty) {
return Center(
child: Text(_s.noSavedSnippet),
);
}
return ListView.builder(
padding: const EdgeInsets.all(13),
itemCount: key.snippets.length,
itemExtent: 57,
itemBuilder: (context, idx) {
return RoundRectCard(
Padding(
padding: roundRectCardPadding,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
key.snippets[idx].name,
textAlign: TextAlign.center,
),
TextButton(
onPressed: () => AppRoute(
SnippetEditPage(snippet: key.snippets[idx]),
'snippet edit page')
.go(context),
child: Text(
_s.edit,
style: _textStyle,
),
),
);
},
)
: Center(
child: Text(_s.noSavedSnippet),
);
],
),
),
);
},
);
},
);
}
void _showRunDialog(Snippet snippet) {
showRoundDialog(
context,
_s.chooseDestination,
Consumer<ServerProvider>(
builder: (_, provider, __) {
if (provider.servers.isEmpty) {
return Text(_s.noServerAvailable);
}
_selectedSpi = provider.servers.first.info;
return buildPicker(
provider.servers
.map((e) => Text(
e.info.name,
textAlign: TextAlign.center,
))
.toList(),
(idx) => _selectedSpi = provider.servers[idx].info);
},
),
[
TextButton(
onPressed: () async {
Navigator.of(context).pop();
run(context, snippet);
},
child: Text(_s.run),
),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(_s.cancel),
),
],
);
}
Future<void> run(BuildContext context, Snippet snippet) async {
final id = (widget.spi ?? _selectedSpi).id;
final result = await locator<ServerProvider>().runSnippet(id, snippet);
if (result != null) {
showRoundDialog(
context,
_s.result,
Text(result, style: const TextStyle(fontSize: 13)),
[
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(_s.close),
)
],
);
}
}
}

100
lib/view/page/ssh.dart Normal file
View File

@@ -0,0 +1,100 @@
import 'dart:convert';
import 'package:dartssh2/dartssh2.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:xterm/xterm.dart';
import '../../data/model/server/server_private_info.dart';
import '../../data/provider/server.dart';
import '../../locator.dart';
import '../widget/virtual_keyboard.dart';
class SSHPage extends StatefulWidget {
final ServerPrivateInfo spi;
const SSHPage({Key? key, required this.spi}) : super(key: key);
@override
_SSHPageState createState() => _SSHPageState();
}
class _SSHPageState extends State<SSHPage> {
late final terminal = Terminal(inputHandler: keyboard);
final keyboard = VirtualKeyboard(defaultInputHandler);
var title = '';
@override
void initState() {
super.initState();
initTerminal();
}
Future<void> initTerminal() async {
terminal.write('Connecting...\r\n');
final client = locator<ServerProvider>()
.servers
.where((e) => e.info.id == widget.spi.id)
.first
.client;
if (client == null) {
terminal.write('Failed to connect\r\n');
return;
}
terminal.write('Connected\r\n');
final session = await client.shell(
pty: SSHPtyConfig(
width: terminal.viewWidth,
height: terminal.viewHeight,
),
);
terminal.buffer.clear();
terminal.buffer.setCursor(0, 0);
terminal.onTitleChange = (title) {
setState(() => this.title = title);
};
terminal.onResize = (width, height, pixelWidth, pixelHeight) {
session.resizeTerminal(width, height, pixelWidth, pixelHeight);
};
terminal.onOutput = (data) {
session.write(utf8.encode(data) as Uint8List);
};
session.stdout
.cast<List<int>>()
.transform(const Utf8Decoder())
.listen(terminal.write);
session.stderr
.cast<List<int>>()
.transform(const Utf8Decoder())
.listen(terminal.write);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(title),
backgroundColor:
Theme.of(context).appBarTheme.backgroundColor?.withOpacity(0.5),
),
body: Column(
children: [
Expanded(
child: TerminalView(terminal, keyboardType: TextInputType.none),
),
VirtualKeyboardView(keyboard),
],
),
);
}
}

View File

@@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
import 'package:xterm/xterm.dart';
class VirtualKeyboardView extends StatelessWidget {
const VirtualKeyboardView(this.keyboard, {super.key});
final VirtualKeyboard keyboard;
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: keyboard,
builder: (context, child) => ToggleButtons(
isSelected: [keyboard.ctrl, keyboard.alt, keyboard.shift],
onPressed: (index) {
switch (index) {
case 0:
keyboard.ctrl = !keyboard.ctrl;
break;
case 1:
keyboard.alt = !keyboard.alt;
break;
case 2:
keyboard.shift = !keyboard.shift;
break;
}
},
children: const [Text('Ctrl'), Text('Alt'), Text('Shift')],
),
);
}
}
class VirtualKeyboard extends TerminalInputHandler with ChangeNotifier {
final TerminalInputHandler _inputHandler;
VirtualKeyboard(this._inputHandler);
bool _ctrl = false;
bool get ctrl => _ctrl;
set ctrl(bool value) {
if (_ctrl != value) {
_ctrl = value;
notifyListeners();
}
}
bool _shift = false;
bool get shift => _shift;
set shift(bool value) {
if (_shift != value) {
_shift = value;
notifyListeners();
}
}
bool _alt = false;
bool get alt => _alt;
set alt(bool value) {
if (_alt != value) {
_alt = value;
notifyListeners();
}
}
@override
String? call(TerminalKeyboardEvent event) {
return _inputHandler.call(event.copyWith(
ctrl: event.ctrl || _ctrl,
shift: event.shift || _shift,
alt: event.alt || _alt,
));
}
}

View File

@@ -250,6 +250,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.0"
equatable:
dependency: transitive
description:
name: equatable
sha256: c2b87cb7756efdf69892005af546c56c0b5037f54d2a88269b4f347a505e3ca2
url: "https://pub.dev"
source: hosted
version: "2.0.5"
extended_image:
dependency: "direct main"
description:
@@ -638,6 +646,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.0"
platform_info:
dependency: transitive
description:
name: platform_info
sha256: "012e73712166cf0b56d3eb95c0d33491f56b428c169eca385f036448474147e4"
url: "https://pub.dev"
source: hosted
version: "3.2.0"
plugin_platform_interface:
dependency: transitive
description:
@@ -694,6 +710,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.2.1"
quiver:
dependency: transitive
description:
name: quiver
sha256: b1c1ac5ce6688d77f65f3375a9abb9319b3cb32486bdc7a1e0fdf004d7ba4e47
url: "https://pub.dev"
source: hosted
version: "3.2.1"
r_upgrade:
dependency: "direct main"
description:
@@ -963,6 +987,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.2.2"
xterm:
dependency: "direct main"
description:
name: xterm
sha256: f65619cb24d03507812e346ddb8386cad9e16a01a481a8f5c8a2eba55b4edada
url: "https://pub.dev"
source: hosted
version: "3.4.1"
yaml:
dependency: transitive
description:

View File

@@ -53,6 +53,7 @@ dependencies:
share_plus: ^6.3.0
intl: ^0.17.0
share_plus_web: ^3.1.0
xterm: ^3.4.1
dev_dependencies:
flutter_native_splash: ^2.1.6