592 lines
18 KiB
Dart
592 lines
18 KiB
Dart
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,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|