Add app source and tests
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
10
lib/Peach/see/server/detail/ConnectServerDetail.dart
Normal file
10
lib/Peach/see/server/detail/ConnectServerDetail.dart
Normal file
@@ -0,0 +1,10 @@
|
||||
import '../../../about/internal/application/DeploymentRecord.dart';
|
||||
import '../../../about/internal/application/ServerProfile.dart';
|
||||
|
||||
abstract class ConnectServerDetail {
|
||||
/// The stored copy of this server, re-read after any change.
|
||||
void onServerLoaded(ServerProfile server, List<DeploymentRecord> history);
|
||||
|
||||
/// The server (and its stored password) was removed.
|
||||
void onServerDeleted();
|
||||
}
|
||||
20
lib/Peach/see/server/detail/ServerDetail.dart
Normal file
20
lib/Peach/see/server/detail/ServerDetail.dart
Normal file
@@ -0,0 +1,20 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
|
||||
import '../../../about/internal/application/DeploymentRecord.dart';
|
||||
import '../../../about/internal/application/ServerProfile.dart';
|
||||
import 'ServerDetailState.dart';
|
||||
|
||||
/// A single server: its connection, its tools, and the services on it.
|
||||
class ServerDetail extends StatefulWidget {
|
||||
final ServerProfile server;
|
||||
final List<DeploymentRecord> history;
|
||||
|
||||
const ServerDetail({
|
||||
super.key,
|
||||
required this.server,
|
||||
this.history = const [],
|
||||
});
|
||||
|
||||
@override
|
||||
State<ServerDetail> createState() => ServerDetailState();
|
||||
}
|
||||
591
lib/Peach/see/server/detail/ServerDetailState.dart
Normal file
591
lib/Peach/see/server/detail/ServerDetailState.dart
Normal file
@@ -0,0 +1,591 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stacked/stacked.dart';
|
||||
|
||||
import '../../../about/internal/application/DeploymentRecord.dart';
|
||||
import '../../../about/internal/application/NavigatorType.dart';
|
||||
import '../../../about/internal/application/NotificationType.dart';
|
||||
import '../../../about/internal/application/ServerProfile.dart';
|
||||
import '../../../about/internal/application/ServiceConfig.dart';
|
||||
import '../../../about/internal/application/TextType.dart';
|
||||
import '../../../configs/Navigator.dart';
|
||||
import '../../../designs/text/Text.dart';
|
||||
import '../../../utils/Colors.dart';
|
||||
import '../../configuration/Configuration.dart';
|
||||
import '../../deploy/batch/BatchDeploy.dart';
|
||||
import '../../deploy/single/Deploy.dart';
|
||||
import '../../docker/manager/DockerManager.dart';
|
||||
import '../../files/Files.dart';
|
||||
import '../../health/Health.dart';
|
||||
import '../../kafka/Kafka.dart';
|
||||
import '../../logs/Logs.dart';
|
||||
import '../../redis/Redis.dart';
|
||||
import '../../service/ServiceEditor.dart';
|
||||
import '../../systemd/Systemd.dart';
|
||||
import '../editor/ServerEditor.dart';
|
||||
import 'ConnectServerDetail.dart';
|
||||
import 'ServerDetail.dart';
|
||||
import 'ViewServerDetail.dart';
|
||||
|
||||
class ServerDetailState extends State<ServerDetail>
|
||||
implements ConnectServerDetail {
|
||||
ViewServerDetail? _model;
|
||||
|
||||
late ServerProfile _server;
|
||||
List<DeploymentRecord> _history = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_server = widget.server.copy();
|
||||
_history = widget.history;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewModelBuilder<ViewServerDetail>.reactive(
|
||||
viewModelBuilder: () => ViewServerDetail(context, this),
|
||||
onViewModelReady: _onReady,
|
||||
builder: (context, viewModel, child) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: text(_server.name, 20, TextType.Bold, color: context.jp.text),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Edit server',
|
||||
onPressed: _onEditServer,
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Delete server',
|
||||
onPressed: _onDeleteServer,
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: _onAddService,
|
||||
icon: const Icon(Icons.add),
|
||||
label: text('Add service', 14, TextType.Bold, color: colorWhite),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 96),
|
||||
children: [
|
||||
_connectionCard(),
|
||||
const SizedBox(height: 18),
|
||||
_toolsSection(),
|
||||
const SizedBox(height: 20),
|
||||
_sectionHeader(),
|
||||
const SizedBox(height: 10),
|
||||
if (_server.services.isEmpty)
|
||||
_noServices()
|
||||
else
|
||||
LayoutBuilder(
|
||||
builder: (context, c) {
|
||||
const gap = 12.0;
|
||||
final cols = c.maxWidth >= 760 ? 2 : 1;
|
||||
final w = (c.maxWidth - gap * (cols - 1)) / cols;
|
||||
return Wrap(
|
||||
spacing: gap,
|
||||
runSpacing: gap,
|
||||
children: [
|
||||
for (final svc in _server.services)
|
||||
SizedBox(width: w, child: _serviceCard(svc)),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onReady(ViewServerDetail model) {
|
||||
_model = model;
|
||||
// onViewModelReady fires mid-build; a synchronous setState from the
|
||||
// first load would dirty this State while it is still being built.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _initiate();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _initiate() async {
|
||||
_model?.loadServer(_server.id);
|
||||
}
|
||||
|
||||
// ── named handlers: server ─────────────────────────────────────────────────
|
||||
|
||||
Future<void> _onEditServer() async {
|
||||
final edited = await PeachNavigation().navigateToPageWithData(
|
||||
ServerEditor(server: _server),
|
||||
context,
|
||||
);
|
||||
if (edited is! ServerProfile) return;
|
||||
// Preserve the services list on the edited connection.
|
||||
edited.services = _server.services;
|
||||
_model?.saveServer(edited);
|
||||
}
|
||||
|
||||
void _onDeleteServer() {
|
||||
_model?.showApplicationNotification(
|
||||
NotificationType.warning,
|
||||
'Delete "${_server.name}"?',
|
||||
'Removes the server, its services and its stored password.',
|
||||
true,
|
||||
true,
|
||||
null,
|
||||
action: 'Delete server',
|
||||
positiveAction: _onConfirmDeleteServer,
|
||||
);
|
||||
}
|
||||
|
||||
void _onConfirmDeleteServer() {
|
||||
Navigator.pop(context);
|
||||
_model?.deleteServer(_server);
|
||||
}
|
||||
|
||||
// ── named handlers: services ───────────────────────────────────────────────
|
||||
|
||||
Future<void> _onAddService() async {
|
||||
final created = await PeachNavigation().navigateToPageWithData(
|
||||
const ServiceEditor(),
|
||||
context,
|
||||
);
|
||||
if (created is! ServiceConfig) return;
|
||||
_model?.addService(_server, created);
|
||||
}
|
||||
|
||||
Future<void> _onEditService(ServiceConfig svc) async {
|
||||
final edited = await PeachNavigation().navigateToPageWithData(
|
||||
ServiceEditor(service: svc),
|
||||
context,
|
||||
);
|
||||
if (edited is! ServiceConfig) return;
|
||||
_model?.updateService(_server, edited);
|
||||
}
|
||||
|
||||
void _onDeleteService(ServiceConfig svc) =>
|
||||
_model?.removeService(_server, svc);
|
||||
|
||||
Future<void> _onDeploy(ServiceConfig svc) async {
|
||||
await PeachNavigation().navigateToPageWithData(
|
||||
Deploy(server: _server, service: svc),
|
||||
context,
|
||||
);
|
||||
_model?.loadServer(_server.id);
|
||||
}
|
||||
|
||||
void _onViewLogs(ServiceConfig svc) {
|
||||
PeachNavigation().navigateToPage(
|
||||
NavigatorType.justOpen,
|
||||
Logs(server: _server, service: svc),
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onRegisterUnit(ServiceConfig? svc) async {
|
||||
await PeachNavigation().navigateToPageWithData(
|
||||
Systemd(server: _server, service: svc),
|
||||
context,
|
||||
);
|
||||
_model?.loadServer(_server.id);
|
||||
}
|
||||
|
||||
// ── named handlers: tools ──────────────────────────────────────────────────
|
||||
|
||||
Future<void> _onOpenBatch() async {
|
||||
await PeachNavigation().navigateToPageWithData(
|
||||
BatchDeploy(server: _server),
|
||||
context,
|
||||
);
|
||||
_model?.loadServer(_server.id);
|
||||
}
|
||||
|
||||
void _onOpenConfiguration() => _open(Configuration(server: _server));
|
||||
|
||||
void _onOpenHealth() => _open(Health(servers: [_server]));
|
||||
|
||||
void _onOpenFiles() => _open(Files(server: _server));
|
||||
|
||||
void _onOpenDocker() => _open(DockerManager(server: _server));
|
||||
|
||||
void _onOpenRedis() => _open(Redis(server: _server));
|
||||
|
||||
void _onOpenKafka() => _open(Kafka(server: _server));
|
||||
|
||||
void _onNewUnit() => _onRegisterUnit(null);
|
||||
|
||||
void _open(Widget destination) => PeachNavigation().navigateToPage(
|
||||
NavigatorType.justOpen,
|
||||
destination,
|
||||
context,
|
||||
);
|
||||
|
||||
// ── ConnectServerDetail ────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
void onServerLoaded(ServerProfile server, List<DeploymentRecord> history) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_server = server;
|
||||
_history = history;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onServerDeleted() {
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context, true);
|
||||
}
|
||||
|
||||
// ── screen-only widgets ────────────────────────────────────────────────────
|
||||
|
||||
Widget _connectionCard() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: PeachTheme.panel(context, radius: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_line(
|
||||
Icons.dns,
|
||||
'${_server.username}@${_server.host}:${_server.port}',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_line(
|
||||
_server.useSudo ? Icons.shield : Icons.shield_outlined,
|
||||
_server.useSudo ? 'sudo enabled' : 'no sudo',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_line(Icons.lock, 'password stored encrypted'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _line(IconData icon, String label) => Row(
|
||||
children: [
|
||||
Icon(icon, size: 15, color: PeachTheme.seed.withValues(alpha: 0.9)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: text(label, 13, TextType.Regular, color: context.jp.textDim),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _toolsSection() {
|
||||
return LayoutBuilder(
|
||||
builder: (context, c) {
|
||||
const gap = 12.0;
|
||||
final cols = c.maxWidth >= 520 ? 4 : 2;
|
||||
final w = (c.maxWidth - gap * (cols - 1)) / cols;
|
||||
return Wrap(
|
||||
spacing: gap,
|
||||
runSpacing: gap,
|
||||
children: [
|
||||
_tool(
|
||||
w,
|
||||
Icons.rocket_launch,
|
||||
'Build & deploy all',
|
||||
'Batch build + deploy',
|
||||
PeachTheme.seed,
|
||||
_onOpenBatch,
|
||||
),
|
||||
_tool(
|
||||
w,
|
||||
Icons.fact_check_outlined,
|
||||
'Configuration',
|
||||
'Environment & setup',
|
||||
PeachTheme.warn,
|
||||
_onOpenConfiguration,
|
||||
),
|
||||
_tool(
|
||||
w,
|
||||
Icons.monitor_heart,
|
||||
'Health',
|
||||
'Service status',
|
||||
PeachTheme.danger,
|
||||
_onOpenHealth,
|
||||
),
|
||||
_tool(
|
||||
w,
|
||||
Icons.folder_open,
|
||||
'Files',
|
||||
'Browse / transfer',
|
||||
PeachTheme.accent,
|
||||
_onOpenFiles,
|
||||
),
|
||||
_tool(
|
||||
w,
|
||||
Icons.inventory_2,
|
||||
'Docker',
|
||||
'Containers & images',
|
||||
PeachTheme.accent,
|
||||
_onOpenDocker,
|
||||
),
|
||||
_tool(
|
||||
w,
|
||||
Icons.data_object,
|
||||
'Redis',
|
||||
'Browse keys / values',
|
||||
PeachTheme.warn,
|
||||
_onOpenRedis,
|
||||
),
|
||||
_tool(
|
||||
w,
|
||||
Icons.hub,
|
||||
'Kafka',
|
||||
'Topics · groups · lag',
|
||||
PeachTheme.violet,
|
||||
_onOpenKafka,
|
||||
),
|
||||
_tool(
|
||||
w,
|
||||
Icons.miscellaneous_services,
|
||||
'New unit',
|
||||
'systemd wizard',
|
||||
PeachTheme.violet,
|
||||
_onNewUnit,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tool(
|
||||
double w,
|
||||
IconData icon,
|
||||
String title,
|
||||
String sub,
|
||||
Color color,
|
||||
VoidCallback onTap,
|
||||
) {
|
||||
final jp = context.jp;
|
||||
return SizedBox(
|
||||
width: w,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: PeachTheme.panel(context, radius: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
border: Border.all(color: color.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Icon(icon, color: color, size: 20),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
text(
|
||||
title,
|
||||
14,
|
||||
TextType.Bold,
|
||||
color: jp.text,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 1),
|
||||
text(
|
||||
sub,
|
||||
11.5,
|
||||
TextType.Regular,
|
||||
color: jp.textFaint,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sectionHeader() => Row(
|
||||
children: [
|
||||
text(
|
||||
'SERVICES',
|
||||
12,
|
||||
TextType.Bold,
|
||||
color: context.jp.textDim,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
text(
|
||||
'${_server.services.length}',
|
||||
12,
|
||||
TextType.Bold,
|
||||
color: PeachTheme.seed,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _serviceCard(ServiceConfig svc) {
|
||||
final jp = context.jp;
|
||||
final count = _history.where((h) => h.serviceId == svc.id).length;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: PeachTheme.panel(context, radius: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
decoration: BoxDecoration(
|
||||
color: (svc.isWeb ? PeachTheme.accent : PeachTheme.seed)
|
||||
.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
),
|
||||
child: Icon(
|
||||
svc.isWeb
|
||||
? Icons.language
|
||||
: (svc.isGeneric
|
||||
? Icons.terminal
|
||||
: Icons.settings_suggest),
|
||||
color: svc.isWeb ? PeachTheme.accent : PeachTheme.seed,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
text(
|
||||
svc.name,
|
||||
15,
|
||||
TextType.Regular,
|
||||
color: jp.text,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
text(
|
||||
svc.isWeb
|
||||
? svc.remoteRoot
|
||||
: svc.remotePath(
|
||||
svc.remoteJarName.isEmpty
|
||||
? '<jar>'
|
||||
: svc.remoteJarName,
|
||||
),
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: jp.textDim,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!svc.isWeb)
|
||||
IconButton(
|
||||
onPressed: () => _onRegisterUnit(svc),
|
||||
icon: const Icon(
|
||||
Icons.settings_applications_outlined,
|
||||
size: 20,
|
||||
),
|
||||
tooltip: 'Register systemd unit',
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => _onEditService(svc),
|
||||
icon: const Icon(Icons.edit_outlined, size: 20),
|
||||
tooltip: 'Edit',
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => _onDeleteService(svc),
|
||||
icon: const Icon(Icons.delete_outline, size: 20),
|
||||
tooltip: 'Delete',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
if (svc.isWeb)
|
||||
_tag('◆ ${svc.framework.label}')
|
||||
else if (svc.serviceName.isNotEmpty)
|
||||
_tag('unit: ${svc.serviceName}'),
|
||||
if (svc.canBuild) _tag('⚙ builds from source'),
|
||||
if (count > 0) _tag('$count deploys'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _onViewLogs(svc),
|
||||
icon: const Icon(Icons.article_outlined, size: 18),
|
||||
label: text('Logs', 13, TextType.Bold, color: jp.textDim),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: jp.textDim,
|
||||
side: BorderSide(color: jp.border),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton.icon(
|
||||
onPressed: () => _onDeploy(svc),
|
||||
icon: const Icon(Icons.rocket_launch, size: 18),
|
||||
label: text('Deploy', 13, TextType.Bold, color: colorWhite),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tag(String label) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: context.jp.surface2,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: text(label, 11, TextType.Regular, color: context.jp.textDim),
|
||||
);
|
||||
|
||||
Widget _noServices() => Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 30),
|
||||
alignment: Alignment.center,
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.settings_suggest,
|
||||
size: 40,
|
||||
color: PeachTheme.seed.withValues(alpha: 0.5),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
text('No services yet', 14, TextType.Bold, color: context.jp.text),
|
||||
const SizedBox(height: 4),
|
||||
text(
|
||||
'Add a service to deploy a jar to this server.',
|
||||
13,
|
||||
TextType.Regular,
|
||||
color: context.jp.textDim,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
87
lib/Peach/see/server/detail/ViewServerDetail.dart
Normal file
87
lib/Peach/see/server/detail/ViewServerDetail.dart
Normal file
@@ -0,0 +1,87 @@
|
||||
import '../../../about/internal/application/ServerProfile.dart';
|
||||
import '../../../about/internal/application/ServiceConfig.dart';
|
||||
import '../../parent/ParentViewModel.dart';
|
||||
import 'ConnectServerDetail.dart';
|
||||
|
||||
class ViewServerDetail extends ParentViewModel {
|
||||
ConnectServerDetail connection;
|
||||
|
||||
ViewServerDetail(super.context, this.connection);
|
||||
|
||||
/// Re-reads this server from storage along with the deployment history, so
|
||||
/// the screen always reflects what was actually persisted.
|
||||
void loadServer(String serverId) async {
|
||||
try {
|
||||
final servers = await getDataManager().getServers();
|
||||
final history = await getDataManager().getDeployments();
|
||||
final stored = servers.where((s) => s.id == serverId).firstOrNull;
|
||||
if (stored == null) {
|
||||
connection.onServerDeleted();
|
||||
return;
|
||||
}
|
||||
connection.onServerLoaded(stored, history);
|
||||
} catch (error) {
|
||||
handleError(error, () => loadServer(serverId), () {}, "Retry");
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists [server] — used after editing the connection, or after adding,
|
||||
/// editing or removing one of its services.
|
||||
void saveServer(ServerProfile server) async {
|
||||
try {
|
||||
final servers = await getDataManager().getServers();
|
||||
final index = servers.indexWhere((s) => s.id == server.id);
|
||||
if (index >= 0) {
|
||||
servers[index] = server;
|
||||
} else {
|
||||
servers.add(server);
|
||||
}
|
||||
await getDataManager().setServers(servers);
|
||||
loadServer(server.id);
|
||||
} catch (error) {
|
||||
handleError(error, () => saveServer(server), () {}, "Retry");
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds [service] to [server] and persists.
|
||||
void addService(ServerProfile server, ServiceConfig service) {
|
||||
server.services.add(service);
|
||||
saveServer(server);
|
||||
}
|
||||
|
||||
/// Replaces the stored copy of [service] on [server].
|
||||
void updateService(ServerProfile server, ServiceConfig service) {
|
||||
final index = server.services.indexWhere((s) => s.id == service.id);
|
||||
if (index >= 0) {
|
||||
server.services[index] = service;
|
||||
} else {
|
||||
server.services.add(service);
|
||||
}
|
||||
saveServer(server);
|
||||
}
|
||||
|
||||
void removeService(ServerProfile server, ServiceConfig service) {
|
||||
server.services.removeWhere((s) => s.id == service.id);
|
||||
saveServer(server);
|
||||
}
|
||||
|
||||
/// Saves the order services are deployed in during a batch run.
|
||||
void saveDeployOrder(ServerProfile server, List<String> ids) {
|
||||
server.deployOrder = ids;
|
||||
saveServer(server);
|
||||
}
|
||||
|
||||
void deleteServer(ServerProfile server) async {
|
||||
showLoading("Removing ${server.name} …");
|
||||
try {
|
||||
final servers = await getDataManager().getServers();
|
||||
servers.removeWhere((s) => s.id == server.id);
|
||||
await getDataManager().setServers(servers);
|
||||
await getDataManager().deleteServerSecret(server.id);
|
||||
closeLoading();
|
||||
connection.onServerDeleted();
|
||||
} catch (error) {
|
||||
handleError(error, () => deleteServer(server), () {}, "Retry");
|
||||
}
|
||||
}
|
||||
}
|
||||
6
lib/Peach/see/server/editor/ConnectServerEditor.dart
Normal file
6
lib/Peach/see/server/editor/ConnectServerEditor.dart
Normal file
@@ -0,0 +1,6 @@
|
||||
import '../../../about/internal/application/ServerProfile.dart';
|
||||
|
||||
abstract class ConnectServerEditor {
|
||||
/// The connection details were verified against the live server.
|
||||
void onConnectionVerified(ServerProfile server);
|
||||
}
|
||||
16
lib/Peach/see/server/editor/ServerEditor.dart
Normal file
16
lib/Peach/see/server/editor/ServerEditor.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
|
||||
import '../../../about/internal/application/ServerProfile.dart';
|
||||
import 'ServerEditorState.dart';
|
||||
|
||||
/// Edits a server's connection details only. Services are managed on the
|
||||
/// server detail screen.
|
||||
class ServerEditor extends StatefulWidget {
|
||||
/// null = register a new server.
|
||||
final ServerProfile? server;
|
||||
|
||||
const ServerEditor({super.key, this.server});
|
||||
|
||||
@override
|
||||
State<ServerEditor> createState() => ServerEditorState();
|
||||
}
|
||||
186
lib/Peach/see/server/editor/ServerEditorState.dart
Normal file
186
lib/Peach/see/server/editor/ServerEditorState.dart
Normal file
@@ -0,0 +1,186 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stacked/stacked.dart';
|
||||
|
||||
import '../../../about/internal/application/ServerProfile.dart';
|
||||
import '../../../about/internal/application/TextType.dart';
|
||||
import '../../../designs/input/InputFields.dart';
|
||||
import '../../../designs/text/Text.dart';
|
||||
import '../../../utils/Colors.dart';
|
||||
import '../../../utils/Validators.dart';
|
||||
import 'ConnectServerEditor.dart';
|
||||
import 'ServerEditor.dart';
|
||||
import 'ViewServerEditor.dart';
|
||||
|
||||
class ServerEditorState extends State<ServerEditor>
|
||||
implements ConnectServerEditor {
|
||||
ViewServerEditor? _model;
|
||||
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late final ServerProfile _server;
|
||||
bool _showPassword = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_server = widget.server?.copy() ?? ServerProfile();
|
||||
}
|
||||
|
||||
bool get _isNew => widget.server == null;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewModelBuilder<ViewServerEditor>.reactive(
|
||||
viewModelBuilder: () => ViewServerEditor(context, this),
|
||||
onViewModelReady: _onReady,
|
||||
builder: (context, viewModel, child) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: text(
|
||||
_isNew ? 'Register server' : 'Edit server',
|
||||
20,
|
||||
TextType.Bold,
|
||||
color: context.jp.text,
|
||||
),
|
||||
actions: [
|
||||
TextButton.icon(
|
||||
onPressed: _onTestConnection,
|
||||
icon: const Icon(Icons.wifi_tethering, size: 18),
|
||||
label: text('Test', 13, TextType.Bold, color: colorPrimary),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 12, left: 8),
|
||||
child: FilledButton.icon(
|
||||
onPressed: _onSave,
|
||||
icon: const Icon(Icons.check, size: 18),
|
||||
label: text('Save', 14, TextType.Bold, color: colorWhite),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Form(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 40),
|
||||
children: [
|
||||
inputSection(context, 'Identity'),
|
||||
inputField(
|
||||
label: 'Server name',
|
||||
hint: 'prod-1',
|
||||
initial: _server.name,
|
||||
onSaved: _onSaveName,
|
||||
validator: requiredField,
|
||||
),
|
||||
inputSection(context, 'Connection'),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: inputField(
|
||||
label: 'Host / IP',
|
||||
hint: '10.0.0.5 or example.com',
|
||||
initial: _server.host,
|
||||
onSaved: _onSaveHost,
|
||||
validator: validHost,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: inputField(
|
||||
label: 'Port',
|
||||
initial: _server.port.toString(),
|
||||
keyboardType: TextInputType.number,
|
||||
onSaved: _onSavePort,
|
||||
validator: validPort,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
inputField(
|
||||
label: 'Username',
|
||||
hint: 'root / ubuntu',
|
||||
initial: _server.username,
|
||||
onSaved: _onSaveUsername,
|
||||
validator: requiredField,
|
||||
),
|
||||
inputField(
|
||||
label: 'Password',
|
||||
initial: _server.password,
|
||||
obscure: !_showPassword,
|
||||
onSaved: _onSavePassword,
|
||||
suffix: IconButton(
|
||||
icon: Icon(
|
||||
_showPassword ? Icons.visibility_off : Icons.visibility,
|
||||
),
|
||||
onPressed: _onTogglePassword,
|
||||
),
|
||||
),
|
||||
inputHint(
|
||||
context,
|
||||
Icons.lock,
|
||||
'Stored encrypted in your OS keystore '
|
||||
'(Keychain / Keystore / DPAPI / libsecret).',
|
||||
),
|
||||
inputSwitch(
|
||||
context: context,
|
||||
title: 'Use sudo for privileged commands',
|
||||
subtitle:
|
||||
'Sends the password to sudo -S for systemctl / mkdir / rm.',
|
||||
value: _server.useSudo,
|
||||
onChanged: _onToggleSudo,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onReady(ViewServerEditor model) {
|
||||
_model = model;
|
||||
// onViewModelReady fires mid-build; a synchronous setState from the
|
||||
// first load would dirty this State while it is still being built.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _initiate();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _initiate() async {}
|
||||
|
||||
// ── named handlers ─────────────────────────────────────────────────────────
|
||||
|
||||
void _onSaveName(String v) => _server.name = v;
|
||||
|
||||
void _onSaveHost(String v) => _server.host = v;
|
||||
|
||||
void _onSavePort(String v) => _server.port = int.tryParse(v) ?? 22;
|
||||
|
||||
void _onSaveUsername(String v) => _server.username = v;
|
||||
|
||||
void _onSavePassword(String v) => _server.password = v;
|
||||
|
||||
void _onTogglePassword() => setState(() => _showPassword = !_showPassword);
|
||||
|
||||
void _onToggleSudo(bool v) => setState(() => _server.useSudo = v);
|
||||
|
||||
/// Commits the form and hands the profile back to whoever opened the editor.
|
||||
void _onSave() {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
_formKey.currentState!.save();
|
||||
Navigator.of(context).pop(_server);
|
||||
}
|
||||
|
||||
/// Verifies the details as typed, without leaving the editor.
|
||||
void _onTestConnection() {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
_formKey.currentState!.save();
|
||||
_model?.testConnection(_server);
|
||||
}
|
||||
|
||||
// ── ConnectServerEditor ────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
void onConnectionVerified(ServerProfile server) {
|
||||
// The success sheet raised by the ViewModel is the whole outcome; the form
|
||||
// keeps its values so the user can save straight away.
|
||||
}
|
||||
}
|
||||
32
lib/Peach/see/server/editor/ViewServerEditor.dart
Normal file
32
lib/Peach/see/server/editor/ViewServerEditor.dart
Normal file
@@ -0,0 +1,32 @@
|
||||
import '../../../about/internal/application/NotificationType.dart';
|
||||
import '../../../about/internal/application/ServerProfile.dart';
|
||||
import '../../parent/ParentViewModel.dart';
|
||||
import 'ConnectServerEditor.dart';
|
||||
|
||||
class ViewServerEditor extends ParentViewModel {
|
||||
ConnectServerEditor connection;
|
||||
|
||||
ViewServerEditor(super.context, this.connection);
|
||||
|
||||
/// Opens an SSH session with the details as entered, so the user finds out
|
||||
/// now — not at first deploy — whether they work.
|
||||
void testConnection(ServerProfile server) async {
|
||||
showLoading("Connecting to ${server.host} …");
|
||||
try {
|
||||
await getDataManager().connect(server);
|
||||
closeLoading();
|
||||
connection.onConnectionVerified(server);
|
||||
showApplicationNotification(
|
||||
NotificationType.success,
|
||||
"Connection verified",
|
||||
"Peach signed in to ${server.username}@${server.host}:${server.port} "
|
||||
"successfully.",
|
||||
true,
|
||||
true,
|
||||
null,
|
||||
);
|
||||
} catch (error) {
|
||||
handleError(error, () => testConnection(server), () {}, "Retry");
|
||||
}
|
||||
}
|
||||
}
|
||||
12
lib/Peach/see/server/setup/ConnectServerSetup.dart
Normal file
12
lib/Peach/see/server/setup/ConnectServerSetup.dart
Normal file
@@ -0,0 +1,12 @@
|
||||
import '../../../comms/Comms.dart' show DeployResult;
|
||||
|
||||
abstract class ConnectServerSetup {
|
||||
/// The server answered: its package manager and what's already installed.
|
||||
void onServerProbed(String packageManager, Map<String, String> versions);
|
||||
|
||||
/// The server couldn't be reached or probed.
|
||||
void onProbeFailed(String message);
|
||||
|
||||
/// The install finished; [versions] carries the re-probed tools.
|
||||
void onInstallFinished(DeployResult result, Map<String, String> versions);
|
||||
}
|
||||
15
lib/Peach/see/server/setup/ServerSetup.dart
Normal file
15
lib/Peach/see/server/setup/ServerSetup.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
|
||||
import '../../../about/internal/application/ServerProfile.dart';
|
||||
import 'ServerSetupState.dart';
|
||||
|
||||
/// Guided onboarding after registering a new server: connect → show what the
|
||||
/// environment already has → ask what else to set up → install it.
|
||||
class ServerSetup extends StatefulWidget {
|
||||
final ServerProfile server;
|
||||
|
||||
const ServerSetup({super.key, required this.server});
|
||||
|
||||
@override
|
||||
State<ServerSetup> createState() => ServerSetupState();
|
||||
}
|
||||
652
lib/Peach/see/server/setup/ServerSetupState.dart
Normal file
652
lib/Peach/see/server/setup/ServerSetupState.dart
Normal file
@@ -0,0 +1,652 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:stacked/stacked.dart';
|
||||
|
||||
import '../../../about/external/data/EnvCheck.dart';
|
||||
import '../../../about/internal/application/ServerProfile.dart';
|
||||
import '../../../about/internal/application/StepTracker.dart';
|
||||
import '../../../about/internal/application/TextType.dart';
|
||||
import '../../../comms/Comms.dart' show DeployResult, LogLine;
|
||||
import '../../../designs/LogConsole.dart';
|
||||
import '../../../designs/StepList.dart';
|
||||
import '../../../designs/text/Text.dart';
|
||||
import '../../../utils/Colors.dart';
|
||||
import 'ConnectServerSetup.dart';
|
||||
import 'ServerSetup.dart';
|
||||
import 'ViewServerSetup.dart';
|
||||
|
||||
class ServerSetupState extends State<ServerSetup>
|
||||
implements ConnectServerSetup {
|
||||
static const _labels = ['Connect', 'Choose', 'Install', 'Done'];
|
||||
|
||||
ViewServerSetup? _model;
|
||||
|
||||
StreamSubscription<LogLine>? _logSub;
|
||||
StreamSubscription<List<DeployStep>>? _stepSub;
|
||||
|
||||
final _lines = <LogLine>[];
|
||||
List<DeployStep> _steps = [];
|
||||
final Map<String, String> _versions = {};
|
||||
final Set<String> _selected = {};
|
||||
|
||||
int _step = 0; // 0 connect · 1 choose · 2 install · 3 done
|
||||
bool _connecting = true;
|
||||
bool _connected = false;
|
||||
String? _connectError;
|
||||
String _packageManager = 'unknown';
|
||||
bool _installing = false;
|
||||
DeployResult? _result;
|
||||
|
||||
ServerProfile get _server => widget.server;
|
||||
|
||||
bool _installed(String key) => (_versions[key] ?? '').trim().isNotEmpty;
|
||||
|
||||
List<EnvTool> get _toInstall =>
|
||||
kEnvTools.where((t) => _selected.contains(t.key)).toList();
|
||||
|
||||
int get _presentCount => kEnvTools.where((t) => _installed(t.key)).length;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_logSub?.cancel();
|
||||
_stepSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ViewModelBuilder<ViewServerSetup>.reactive(
|
||||
viewModelBuilder: () => ViewServerSetup(context, this),
|
||||
onViewModelReady: _onReady,
|
||||
builder: (context, viewModel, child) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: text(
|
||||
'Set up ${_server.name}',
|
||||
20,
|
||||
TextType.Bold,
|
||||
color: context.jp.text,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _onSkip,
|
||||
child: text('Skip', 13, TextType.Bold, color: colorPrimary),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
_progressBar(),
|
||||
Expanded(
|
||||
child: switch (_step) {
|
||||
0 => _connectStep(),
|
||||
1 => _chooseStep(viewModel),
|
||||
2 => _installStep(viewModel),
|
||||
_ => _doneStep(),
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onReady(ViewServerSetup model) {
|
||||
_model = model;
|
||||
// onViewModelReady fires mid-build; a synchronous setState from the
|
||||
// first load would dirty this State while it is still being built.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _initiate();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _initiate() async {
|
||||
_logSub = _model?.logs.listen(_onLogLine);
|
||||
_stepSub = _model?.steps.listen(_onSteps);
|
||||
_model?.probeServer(_server);
|
||||
}
|
||||
|
||||
void _onLogLine(LogLine line) {
|
||||
if (!mounted) return;
|
||||
setState(() => _lines.add(line));
|
||||
}
|
||||
|
||||
void _onSteps(List<DeployStep> steps) {
|
||||
if (!mounted) return;
|
||||
setState(() => _steps = steps);
|
||||
}
|
||||
|
||||
// ── named handlers ─────────────────────────────────────────────────────────
|
||||
|
||||
void _onSkip() => Navigator.of(context).pop();
|
||||
|
||||
void _onFinish() => Navigator.of(context).pop(true);
|
||||
|
||||
void _onRetryConnect() {
|
||||
setState(() {
|
||||
_connecting = true;
|
||||
_connectError = null;
|
||||
});
|
||||
_model?.probeServer(_server);
|
||||
}
|
||||
|
||||
void _onGoToChoose() => setState(() => _step = 1);
|
||||
|
||||
void _onGoToConnect() => setState(() => _step = 0);
|
||||
|
||||
void _onGoToInstall() => setState(() => _step = 2);
|
||||
|
||||
void _onToggleTool(EnvTool tool, bool? selected) {
|
||||
setState(() {
|
||||
if (selected == true) {
|
||||
_selected.add(tool.key);
|
||||
} else {
|
||||
_selected.remove(tool.key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _onInstall(ViewServerSetup model) {
|
||||
setState(() {
|
||||
_installing = true;
|
||||
_lines.clear();
|
||||
_result = null;
|
||||
});
|
||||
model.installEnvironment(
|
||||
_server,
|
||||
_toInstall,
|
||||
model.buildInstallCommand(_toInstall, _packageManager),
|
||||
);
|
||||
}
|
||||
|
||||
void _onClearLogs() => setState(() => _lines.clear());
|
||||
|
||||
void _onCopyLogs() {
|
||||
Clipboard.setData(
|
||||
ClipboardData(text: _lines.map((l) => l.text).join('\n')),
|
||||
);
|
||||
_notify('Logs copied.');
|
||||
}
|
||||
|
||||
void _notify(String message) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
|
||||
// ── ConnectServerSetup ─────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
void onServerProbed(String packageManager, Map<String, String> versions) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_packageManager = packageManager;
|
||||
_versions
|
||||
..clear()
|
||||
..addAll(versions);
|
||||
_connected = true;
|
||||
_connecting = false;
|
||||
_connectError = null;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onProbeFailed(String message) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_connected = false;
|
||||
_connecting = false;
|
||||
_connectError = message;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onInstallFinished(DeployResult result, Map<String, String> versions) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_installing = false;
|
||||
_result = result;
|
||||
_versions.addAll(versions);
|
||||
if (result.ok) _step = 3;
|
||||
});
|
||||
}
|
||||
|
||||
// ── screen-only widgets ────────────────────────────────────────────────────
|
||||
|
||||
Widget _progressBar() {
|
||||
final jp = context.jp;
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
|
||||
decoration: BoxDecoration(
|
||||
color: jp.surface,
|
||||
border: Border(bottom: BorderSide(color: jp.border)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
for (var i = 0; i < _labels.length; i++) ...[
|
||||
_dot(i, _labels[i]),
|
||||
if (i < _labels.length - 1)
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 2,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 6),
|
||||
color: i < _step ? PeachTheme.seed : jp.border,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _dot(int index, String label) {
|
||||
final jp = context.jp;
|
||||
final done = index < _step;
|
||||
final active = index == _step;
|
||||
final color = done || active ? PeachTheme.seed : jp.textFaint;
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 26,
|
||||
height: 26,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: active ? 0.18 : (done ? 0.14 : 0.0)),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: color.withValues(alpha: 0.6)),
|
||||
),
|
||||
child: done
|
||||
? const Icon(Icons.check, size: 14, color: PeachTheme.seed)
|
||||
: text('${index + 1}', 12, TextType.Bold, color: color),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
text(
|
||||
label,
|
||||
12.5,
|
||||
active ? TextType.Bold : TextType.Regular,
|
||||
color: active ? jp.text : jp.textFaint,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _connectStep() {
|
||||
final jp = context.jp;
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: PeachTheme.panel(context, glowColor: PeachTheme.seed),
|
||||
child: Column(
|
||||
children: [
|
||||
if (_connecting) ...[
|
||||
const SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: CircularProgressIndicator(strokeWidth: 3),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
text(
|
||||
'Connecting to ${_server.host} …',
|
||||
14,
|
||||
TextType.Regular,
|
||||
color: jp.textDim,
|
||||
),
|
||||
] else if (_connected) ...[
|
||||
const Icon(
|
||||
Icons.check_circle,
|
||||
color: PeachTheme.seed,
|
||||
size: 44,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
text('Connected', 18, TextType.Bold, color: jp.text),
|
||||
const SizedBox(height: 6),
|
||||
text(
|
||||
'Package manager: $_packageManager · '
|
||||
'$_presentCount/${kEnvTools.length} tools present',
|
||||
14,
|
||||
TextType.Regular,
|
||||
color: jp.textDim,
|
||||
align: TextAlign.center,
|
||||
),
|
||||
] else ...[
|
||||
const Icon(Icons.error, color: PeachTheme.danger, size: 44),
|
||||
const SizedBox(height: 12),
|
||||
text(
|
||||
'Could not connect',
|
||||
14,
|
||||
TextType.Bold,
|
||||
color: PeachTheme.danger,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
text(
|
||||
_connectError ?? '',
|
||||
12,
|
||||
TextType.Regular,
|
||||
color: jp.textDim,
|
||||
align: TextAlign.center,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
children: [
|
||||
if (!_connected && !_connecting) ...[
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _onRetryConnect,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: text('Retry', 14, TextType.Bold, color: jp.textDim),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
],
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: _connected ? _onGoToChoose : null,
|
||||
icon: const Icon(Icons.arrow_forward),
|
||||
label: text(
|
||||
'Choose environment',
|
||||
14,
|
||||
TextType.Bold,
|
||||
color: colorWhite,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _chooseStep(ViewServerSetup model) {
|
||||
final jp = context.jp;
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
children: [
|
||||
text(
|
||||
'What should this server run?',
|
||||
18,
|
||||
TextType.Bold,
|
||||
color: jp.text,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
text(
|
||||
'Tick anything missing you want Peach to install '
|
||||
'(via $_packageManager). Installed tools are already ready.',
|
||||
13,
|
||||
TextType.Regular,
|
||||
color: jp.textDim,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
for (final tool in kEnvTools) _toolTile(tool),
|
||||
],
|
||||
),
|
||||
),
|
||||
_bottomBar(
|
||||
back: _onGoToConnect,
|
||||
nextLabel: _selected.isEmpty
|
||||
? 'Nothing to install'
|
||||
: 'Review & install',
|
||||
onNext: _selected.isEmpty ? null : _onGoToInstall,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _toolTile(EnvTool tool) {
|
||||
final jp = context.jp;
|
||||
final installed = _installed(tool.key);
|
||||
final selected = _selected.contains(tool.key);
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: PeachTheme.panel(context, radius: 14),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: (installed ? PeachTheme.seed : jp.textFaint).withValues(
|
||||
alpha: 0.14,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
),
|
||||
child: Icon(
|
||||
tool.icon,
|
||||
color: installed ? PeachTheme.seed : jp.textDim,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
text(tool.label, 14, TextType.Bold, color: jp.text),
|
||||
text(
|
||||
installed
|
||||
? (_versions[tool.key] ?? 'installed')
|
||||
: 'not installed',
|
||||
11.5,
|
||||
TextType.Regular,
|
||||
color: installed ? jp.textDim : jp.textFaint,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (installed)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(right: 6),
|
||||
child: Icon(Icons.check_circle, color: PeachTheme.seed),
|
||||
)
|
||||
else
|
||||
Checkbox(value: selected, onChanged: (v) => _onToggleTool(tool, v)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _installStep(ViewServerSetup model) {
|
||||
final jp = context.jp;
|
||||
final command = model.buildInstallCommand(_toInstall, _packageManager);
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
children: [
|
||||
text('Review', 18, TextType.Bold, color: jp.text),
|
||||
const SizedBox(height: 4),
|
||||
text(
|
||||
'These will be installed${_server.useSudo ? ' via sudo' : ''}:',
|
||||
13,
|
||||
TextType.Regular,
|
||||
color: jp.textDim,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: _toInstall
|
||||
.map(
|
||||
(t) => Chip(
|
||||
avatar: Icon(t.icon, size: 16),
|
||||
label: Text(t.label),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: jp.consoleBg,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: ConsoleColors.frame),
|
||||
),
|
||||
child: SelectableText(
|
||||
command,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12.5,
|
||||
color: ConsoleColors.body,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_steps.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
StepList(steps: _steps),
|
||||
],
|
||||
if (_result != null && !_result!.ok) ...[
|
||||
const SizedBox(height: 12),
|
||||
_banner(_result!),
|
||||
],
|
||||
if (_lines.isNotEmpty) ...[
|
||||
const SizedBox(height: 14),
|
||||
LogConsole(
|
||||
logs: _lines,
|
||||
height: 240,
|
||||
onClear: _onClearLogs,
|
||||
onCopy: _onCopyLogs,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
_bottomBar(
|
||||
back: _installing ? null : _onGoToChoose,
|
||||
nextLabel: _installing ? 'Installing…' : 'Install now',
|
||||
onNext: _installing ? null : () => _onInstall(model),
|
||||
nextIcon: Icons.download,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _doneStep() {
|
||||
final jp = context.jp;
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: PeachTheme.panel(context, glowColor: PeachTheme.seed),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
gradient: PeachTheme.brandGradient,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: PeachTheme.glow(
|
||||
PeachTheme.seed,
|
||||
opacity: 0.5,
|
||||
blur: 28,
|
||||
),
|
||||
),
|
||||
child: Icon(Icons.check, color: colorWhite, size: 38),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
text(
|
||||
'${_server.name} is ready',
|
||||
20,
|
||||
TextType.Bold,
|
||||
color: jp.text,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
text(
|
||||
'$_presentCount/${kEnvTools.length} tools installed. '
|
||||
'Add a service to start deploying.',
|
||||
14,
|
||||
TextType.Regular,
|
||||
color: jp.textDim,
|
||||
align: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
FilledButton.icon(
|
||||
onPressed: _onFinish,
|
||||
icon: const Icon(Icons.check),
|
||||
label: text('Finish', 14, TextType.Bold, color: colorWhite),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _bottomBar({
|
||||
VoidCallback? back,
|
||||
required String nextLabel,
|
||||
VoidCallback? onNext,
|
||||
IconData nextIcon = Icons.arrow_forward,
|
||||
}) {
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 14),
|
||||
child: Row(
|
||||
children: [
|
||||
OutlinedButton(
|
||||
onPressed: back,
|
||||
child: text('Back', 13, TextType.Bold, color: context.jp.textDim),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: onNext,
|
||||
icon: _installing
|
||||
? SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colorWhite,
|
||||
),
|
||||
)
|
||||
: Icon(nextIcon),
|
||||
label: text(nextLabel, 14, TextType.Bold, color: colorWhite),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _banner(DeployResult result) {
|
||||
final color = result.ok ? PeachTheme.seed : PeachTheme.danger;
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(14, 12, 12, 12),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.10),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: color.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(result.ok ? Icons.check_circle : Icons.error, color: color),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: text(result.message, 14, TextType.Regular, color: color),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
76
lib/Peach/see/server/setup/ViewServerSetup.dart
Normal file
76
lib/Peach/see/server/setup/ViewServerSetup.dart
Normal file
@@ -0,0 +1,76 @@
|
||||
import '../../../about/external/data/EnvCheck.dart';
|
||||
import '../../../about/internal/application/ServerProfile.dart';
|
||||
import '../../../about/internal/application/StepTracker.dart';
|
||||
import '../../../comms/Comms.dart' show LogLine;
|
||||
import '../../parent/ParentViewModel.dart';
|
||||
import 'ConnectServerSetup.dart';
|
||||
|
||||
class ViewServerSetup extends ParentViewModel {
|
||||
ConnectServerSetup connection;
|
||||
|
||||
ViewServerSetup(super.context, this.connection);
|
||||
|
||||
Stream<LogLine> get logs => getDataManager().logs;
|
||||
|
||||
Stream<List<DeployStep>> get steps => getDataManager().stepStream;
|
||||
|
||||
/// Step 1: prove the credentials work and read what's already there. The
|
||||
/// failure is reported inline rather than as an overlay — on this screen a
|
||||
/// failed connection is a step of the wizard, not an interruption.
|
||||
void probeServer(ServerProfile server) async {
|
||||
try {
|
||||
final packageManager = await getDataManager().detectPackageManager(
|
||||
server,
|
||||
);
|
||||
final versions = await getDataManager().probeEnvironment(
|
||||
server,
|
||||
kEnvTools.map((t) => '${t.key} ${t.detect}').toList(),
|
||||
);
|
||||
connection.onServerProbed(packageManager, versions);
|
||||
} catch (error) {
|
||||
connection.onProbeFailed(error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Step 3: install everything the user ticked in one package-manager call,
|
||||
/// enable the units that need it, then re-probe those tools.
|
||||
void installEnvironment(
|
||||
ServerProfile server,
|
||||
List<EnvTool> tools,
|
||||
String command,
|
||||
) async {
|
||||
try {
|
||||
final result = await getDataManager().installEnvironment(
|
||||
server: server,
|
||||
command: command,
|
||||
units: tools.where((t) => t.unit != null).map((t) => t.unit!).toList(),
|
||||
);
|
||||
|
||||
var versions = <String, String>{};
|
||||
try {
|
||||
versions = await getDataManager().probeEnvironment(
|
||||
server,
|
||||
tools.map((t) => '${t.key} ${t.detect}').toList(),
|
||||
);
|
||||
} catch (_) {
|
||||
// The install result is what matters; a failed re-probe just leaves
|
||||
// the tiles showing their previous state.
|
||||
}
|
||||
|
||||
connection.onInstallFinished(result, versions);
|
||||
} catch (error) {
|
||||
handleError(
|
||||
error,
|
||||
() => installEnvironment(server, tools, command),
|
||||
() {},
|
||||
"Retry",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The single command that installs every selected tool.
|
||||
String buildInstallCommand(List<EnvTool> tools, String packageManager) {
|
||||
final packages = tools.map((t) => t.packagesFor(packageManager)).join(' ');
|
||||
return installCommandFor(packageManager, packages);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user