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,15 @@
import 'package:flutter/cupertino.dart';
import '../../../about/internal/application/ServerProfile.dart';
import 'BatchDeployState.dart';
/// Batch "build all & deploy": pick which services to include, choose a mode,
/// and run them one after another with live per-service status.
class BatchDeploy extends StatefulWidget {
final ServerProfile server;
const BatchDeploy({super.key, required this.server});
@override
State<BatchDeploy> createState() => BatchDeployState();
}

View File

@@ -0,0 +1,549 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:stacked/stacked.dart';
import '../../../about/internal/application/ServerProfile.dart';
import '../../../about/internal/application/ServiceConfig.dart';
import '../../../about/internal/application/StepTracker.dart';
import '../../../about/internal/application/TextType.dart';
import '../../../comms/Comms.dart' show LogLine;
import '../../../designs/LogConsole.dart';
import '../../../designs/StepList.dart';
import '../../../designs/text/Text.dart';
import '../../../utils/Colors.dart';
import 'BatchDeploy.dart';
import 'ConnectBatchDeploy.dart';
import 'ViewBatchDeploy.dart';
enum BatchMode { buildDeploy, buildOnly }
class BatchDeployState extends State<BatchDeploy>
implements ConnectBatchDeploy {
ViewBatchDeploy? _model;
final _subscriptions = <StreamSubscription>[];
final _lines = <LogLine>[];
List<DeployStep> _steps = [];
final Set<String> _selected = {};
final Map<String, BatchState> _status = {};
final Map<String, String> _detail = {};
late List<ServiceConfig> _ordered;
BatchMode _mode = BatchMode.buildDeploy;
bool _running = false;
ServerProfile get _server => widget.server;
/// A service can join a batch only if Peach knows how to build it.
bool _eligible(ServiceConfig s) => s.canBuild;
int get _eligibleCount => _ordered.where(_eligible).length;
int get _doneCount =>
_status.values.where((s) => s == BatchState.done).length;
int get _failCount =>
_status.values.where((s) => s == BatchState.failed).length;
@override
void initState() {
super.initState();
_ordered = _server.orderedServices;
for (final service in _ordered) {
if (_eligible(service)) _selected.add(service.id);
}
}
@override
void dispose() {
for (final s in _subscriptions) {
s.cancel();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
return ViewModelBuilder<ViewBatchDeploy>.reactive(
viewModelBuilder: () => ViewBatchDeploy(context, this),
onViewModelReady: _onReady,
builder: (context, viewModel, child) => Scaffold(
appBar: AppBar(
title: text(
'Build & deploy · ${_server.name}',
20,
TextType.Bold,
color: context.jp.text,
),
),
body: Column(
children: [
_header(),
Expanded(
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
children: [
_reorderHint(),
ReorderableListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
buildDefaultDragHandles: false,
onReorderItem: _onReorder,
itemCount: _ordered.length,
itemBuilder: (context, i) => Padding(
key: ValueKey(_ordered[i].id),
padding: const EdgeInsets.only(bottom: 10),
child: _serviceTile(_ordered[i], i),
),
),
if (_steps.isNotEmpty) ...[
const SizedBox(height: 14),
StepList(steps: _steps),
],
if (_lines.isNotEmpty) ...[
const SizedBox(height: 14),
LogConsole(
logs: _lines,
height: 300,
onClear: _onClearLogs,
onCopy: _onCopyLogs,
),
],
],
),
),
_bottomBar(),
],
),
),
);
}
void _onReady(ViewBatchDeploy 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 {
final model = _model;
if (model == null) return;
_subscriptions.addAll([
model.logs.listen(_onLogLine),
model.buildLogs.listen(_onLogLine),
model.steps.listen(_onSteps),
model.buildSteps.listen(_onSteps),
]);
}
void _onLogLine(LogLine line) {
if (!mounted) return;
setState(() => _lines.add(line));
}
void _onSteps(List<DeployStep> steps) {
if (!mounted) return;
setState(() => _steps = steps);
}
// ── named handlers ─────────────────────────────────────────────────────────
/// [newIndex] already accounts for the removed item — `onReorderItem` does
/// that adjustment for us, unlike the older `onReorder`.
void _onReorder(int oldIndex, int newIndex) {
setState(() {
final moved = _ordered.removeAt(oldIndex);
_ordered.insert(newIndex, moved);
});
_model?.saveDeployOrder(_server, _ordered.map((s) => s.id).toList());
}
void _onRun() {
final batch = _ordered.where((s) => _selected.contains(s.id)).toList();
if (batch.isEmpty) {
_notify('Select at least one service.');
return;
}
setState(() {
_running = true;
_lines.clear();
});
_model?.runBatch(
_server,
batch,
deployAfterBuild: _mode == BatchMode.buildDeploy,
);
}
void _onStop() => _model?.requestCancel();
void _onSelectMode(BatchMode mode) => setState(() => _mode = mode);
void _onToggleService(ServiceConfig service, bool? selected) {
setState(() {
if (selected == true) {
_selected.add(service.id);
} else {
_selected.remove(service.id);
}
});
}
void _onToggleAll() {
final eligible = _ordered.where(_eligible).map((s) => s.id).toSet();
setState(() {
if (_selected.length >= eligible.length) {
_selected.clear();
} else {
_selected
..clear()
..addAll(eligible);
}
});
}
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)));
}
// ── ConnectBatchDeploy ─────────────────────────────────────────────────────
@override
void onBatchStarted(List<String> serviceIds) {
if (!mounted) return;
setState(() {
for (final id in serviceIds) {
_status[id] = BatchState.queued;
_detail.remove(id);
}
});
}
@override
void onServiceState(String serviceId, BatchState state, {String? detail}) {
if (!mounted) return;
setState(() {
_status[serviceId] = state;
if (detail != null) _detail[serviceId] = detail;
});
}
@override
void onBatchFinished() {
if (!mounted) return;
setState(() => _running = false);
}
// ── screen-only widgets ────────────────────────────────────────────────────
Widget _header() {
final jp = context.jp;
final allSelected =
_selected.length >= _eligibleCount && _eligibleCount > 0;
return Container(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
decoration: BoxDecoration(
color: jp.surface,
border: Border(bottom: BorderSide(color: jp.border)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
text('Mode', 13, TextType.Bold, color: jp.textDim),
const Spacer(),
if (_running || _doneCount + _failCount > 0)
text(
'${_doneCount + _failCount}/${_selected.length} · '
'$_doneCount ok · $_failCount failed',
12,
TextType.Regular,
color: jp.textFaint,
),
],
),
const SizedBox(height: 8),
Row(
children: [
_modeChip(
BatchMode.buildDeploy,
'Build & deploy',
Icons.rocket_launch,
),
const SizedBox(width: 8),
_modeChip(BatchMode.buildOnly, 'Build only', Icons.build),
const Spacer(),
TextButton.icon(
onPressed: _running ? null : _onToggleAll,
icon: Icon(
allSelected ? Icons.remove_done : Icons.done_all,
size: 16,
),
label: text(
allSelected ? 'Clear' : 'Select all',
13,
TextType.Bold,
color: colorPrimary,
),
),
],
),
],
),
);
}
Widget _reorderHint() => Padding(
padding: const EdgeInsets.only(left: 4, bottom: 8, top: 2),
child: Row(
children: [
Icon(Icons.drag_indicator, size: 15, color: context.jp.textFaint),
const SizedBox(width: 4),
text(
'Drag to set deployment order — saved automatically',
12,
TextType.Regular,
color: context.jp.textFaint,
),
],
),
);
Widget _modeChip(BatchMode mode, String label, IconData icon) {
final jp = context.jp;
final active = _mode == mode;
return GestureDetector(
onTap: _running ? null : () => _onSelectMode(mode),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: active ? PeachTheme.seed.withValues(alpha: 0.14) : jp.surface2,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: active ? PeachTheme.seed : jp.border),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 15, color: active ? PeachTheme.seed : jp.textDim),
const SizedBox(width: 6),
text(
label,
12.5,
TextType.Bold,
color: active ? PeachTheme.seed : jp.textDim,
),
],
),
),
);
}
Widget _serviceTile(ServiceConfig service, int index) {
final jp = context.jp;
final eligible = _eligible(service);
final selected = _selected.contains(service.id);
final state = _status[service.id];
return Container(
padding: const EdgeInsets.fromLTRB(8, 12, 12, 12),
decoration: PeachTheme.panel(context, radius: 14),
child: Row(
children: [
Container(
width: 24,
height: 24,
alignment: Alignment.center,
decoration: BoxDecoration(
color: PeachTheme.seed.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(7),
),
child: text(
'${index + 1}',
12,
TextType.Bold,
color: PeachTheme.seed,
),
),
const SizedBox(width: 4),
if (eligible)
Checkbox(
value: selected,
onChanged: _running ? null : (v) => _onToggleService(service, v),
)
else
const SizedBox(width: 40, child: Icon(Icons.block, size: 18)),
Icon(
service.isWeb
? Icons.language
: (service.isGeneric ? Icons.terminal : Icons.settings_suggest),
size: 18,
color: jp.textDim,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
text(
service.name,
14,
TextType.Bold,
color: jp.text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
text(
eligible
? (_detail[service.id] ??
'${service.kind.label} · builds from source')
: 'Add a source path to include',
11.5,
TextType.Regular,
color: jp.textFaint,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 8),
_statusPill(state),
if (!_running) ...[
const SizedBox(width: 4),
ReorderableDragStartListener(
index: index,
child: Icon(Icons.drag_handle, size: 20, color: jp.textFaint),
),
],
],
),
);
}
Widget _statusPill(BatchState? state) {
if (state == null) return const SizedBox.shrink();
final jp = context.jp;
final (color, label, spinning) = switch (state) {
BatchState.queued => (jp.textFaint, 'QUEUED', false),
BatchState.building => (PeachTheme.warn, 'BUILDING', true),
BatchState.deploying => (PeachTheme.accent, 'DEPLOYING', true),
BatchState.done => (PeachTheme.seed, 'DONE', false),
BatchState.failed => (PeachTheme.danger, 'FAILED', false),
BatchState.skipped => (jp.textFaint, 'SKIPPED', false),
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.16),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: color.withValues(alpha: 0.4)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (spinning)
SizedBox(
width: 11,
height: 11,
child: CircularProgressIndicator(strokeWidth: 2, color: color),
)
else
Icon(
state == BatchState.done
? Icons.check
: state == BatchState.failed
? Icons.close
: Icons.circle,
size: 11,
color: color,
),
const SizedBox(width: 5),
text(label, 10, TextType.Bold, color: color),
],
),
);
}
Widget _bottomBar() {
return SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
child: Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: _running || _selected.isEmpty ? null : _onRun,
icon: _running
? SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colorWhite,
),
)
: Icon(
_mode == BatchMode.buildOnly
? Icons.build
: Icons.rocket_launch,
),
label: text(
_running
? 'Running…'
: (_mode == BatchMode.buildOnly
? 'Build ${_selected.length} selected'
: 'Build & deploy ${_selected.length} selected'),
14,
TextType.Bold,
color: colorWhite,
),
),
),
if (_running) ...[
const SizedBox(width: 10),
OutlinedButton.icon(
onPressed: _onStop,
icon: const Icon(Icons.cancel, size: 18),
label: text(
'Stop',
13,
TextType.Bold,
color: PeachTheme.danger,
),
style: OutlinedButton.styleFrom(
foregroundColor: PeachTheme.danger,
side: BorderSide(
color: PeachTheme.danger.withValues(alpha: 0.6),
),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
),
),
],
],
),
),
);
}
}

View File

@@ -0,0 +1,13 @@
/// Where one service has got to in the batch.
enum BatchState { queued, building, deploying, done, failed, skipped }
abstract class ConnectBatchDeploy {
/// The run started; every selected service is queued.
void onBatchStarted(List<String> serviceIds);
/// One service moved to a new state, optionally with a detail line.
void onServiceState(String serviceId, BatchState state, {String? detail});
/// Every service in the batch has been attempted.
void onBatchFinished();
}

View File

@@ -0,0 +1,180 @@
import 'dart:io';
import 'package:uuid/uuid.dart';
import '../../../about/internal/application/DeploymentRecord.dart';
import '../../../about/internal/application/ServerProfile.dart';
import '../../../about/internal/application/ServiceConfig.dart';
import '../../../about/internal/application/StepTracker.dart';
import '../../../comms/Comms.dart' show DeployResult, LogLine;
import '../../../utils/CommonUtils.dart';
import '../../parent/ParentViewModel.dart';
import 'ConnectBatchDeploy.dart';
class ViewBatchDeploy extends ParentViewModel {
ConnectBatchDeploy connection;
ViewBatchDeploy(super.context, this.connection);
bool _cancelRequested = false;
Stream<LogLine> get logs => getDataManager().logs;
Stream<LogLine> get buildLogs => getDataManager().buildLogs;
Stream<List<DeployStep>> get steps => getDataManager().stepStream;
Stream<List<DeployStep>> get buildSteps => getDataManager().buildStepStream;
/// Builds (and optionally deploys) each service in [batch], in order. One
/// service failing doesn't stop the rest — the run reports per-service.
void runBatch(
ServerProfile server,
List<ServiceConfig> batch, {
required bool deployAfterBuild,
}) async {
if (deployAfterBuild &&
!await canReach(
server,
() => runBatch(server, batch, deployAfterBuild: deployAfterBuild),
)) {
return;
}
_cancelRequested = false;
connection.onBatchStarted(batch.map((s) => s.id).toList());
for (final service in batch) {
if (_cancelRequested) {
connection.onServiceState(service.id, BatchState.skipped);
continue;
}
await _runOne(server, service, deployAfterBuild);
}
connection.onBatchFinished();
}
Future<void> _runOne(
ServerProfile server,
ServiceConfig service,
bool deployAfterBuild,
) async {
connection.onServiceState(service.id, BatchState.building);
final built = service.isWeb
? await getDataManager().buildWeb(service)
: await getDataManager().build(service);
if (!built.ok) {
connection.onServiceState(
service.id,
BatchState.failed,
detail: built.message,
);
return;
}
if (!deployAfterBuild) {
connection.onServiceState(service.id, BatchState.done, detail: 'Built');
return;
}
connection.onServiceState(service.id, BatchState.deploying);
DeployResult result;
String artifact;
try {
if (service.isWeb) {
final directory = built.dir!.path;
if (!File('$directory/index.html').existsSync()) {
connection.onServiceState(
service.id,
BatchState.failed,
detail: 'No index.html in build output.',
);
return;
}
final (bytes, count) = await getDataManager().packageWeb(directory);
artifact = service.framework.label;
result = await getDataManager().deployWeb(
server: server,
service: service,
archiveBytes: bytes,
fileCount: count,
);
} else {
final jar = built.jar!;
artifact = baseName(jar.path);
result = await getDataManager().deploy(
server: server,
service: service,
jarBytes: await jar.readAsBytes(),
jarLabel: artifact,
);
}
} catch (error) {
connection.onServiceState(
service.id,
BatchState.failed,
detail: 'Error: $error',
);
return;
}
await _record(server, service, result, artifact);
connection.onServiceState(
service.id,
result.ok ? BatchState.done : BatchState.failed,
detail: result.message,
);
}
/// Stops after the service currently running finishes.
void requestCancel() {
_cancelRequested = true;
getDataManager().requestCancel();
getDataManager().cancelBuild();
}
/// Remembers the order services should be deployed in next time.
void saveDeployOrder(ServerProfile server, List<String> ids) async {
try {
final servers = await getDataManager().getServers();
final index = servers.indexWhere((s) => s.id == server.id);
if (index < 0) return;
servers[index].deployOrder = ids;
await getDataManager().setServers(servers);
} catch (error) {
handleError(error, () => saveDeployOrder(server, ids), () {}, "Retry");
}
}
Future<void> _record(
ServerProfile server,
ServiceConfig service,
DeployResult result,
String artifact,
) async {
try {
await getDataManager().addDeployment(
DeploymentRecord(
id: const Uuid().v4(),
serverId: server.id,
serverName: server.name,
serviceId: service.id,
serviceLabel: service.name,
jarName: artifact,
remotePath: service.isWeb
? service.remoteRoot
: service.remotePath(artifact),
time: DateTime.now(),
status: result.ok ? DeployStatus.success : DeployStatus.failure,
message: result.message,
),
);
} catch (_) {
// A missing history row must not change what the batch reports.
}
}
}