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