Add app source and tests

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
alvocool
2026-10-11 10:15:30 +03:00
parent 7e246255c2
commit 38881d1fe1
135 changed files with 21057 additions and 0 deletions

View 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();
}

View 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();
}

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

View 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");
}
}
}