Add app source and tests
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
404
lib/Peach/see/deploy/single/ViewDeploy.dart
Normal file
404
lib/Peach/see/deploy/single/ViewDeploy.dart
Normal file
@@ -0,0 +1,404 @@
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:desktop_drop/desktop_drop.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
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, TransferProgress;
|
||||
import '../../../utils/CommonUtils.dart';
|
||||
import '../../parent/ParentViewModel.dart';
|
||||
import 'ConnectDeploy.dart';
|
||||
|
||||
class ViewDeploy extends ParentViewModel {
|
||||
ConnectDeploy connection;
|
||||
|
||||
ViewDeploy(super.context, this.connection);
|
||||
|
||||
/// Both the SSH session and the local build write to the same log stream on
|
||||
/// screen, so the console reads as one continuous story.
|
||||
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;
|
||||
|
||||
Stream<TransferProgress?> get transferProgress =>
|
||||
getDataManager().transferProgress;
|
||||
|
||||
bool get isConnected => getDataManager().isConnected;
|
||||
|
||||
bool get canRevert => getDataManager().canRevert;
|
||||
|
||||
bool get buildSupported => getDataManager().buildSupported;
|
||||
|
||||
// ── staging ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Reads where nginx actually serves from, so a mismatched web root can be
|
||||
/// flagged before the user waits through a deploy that won't show up.
|
||||
void loadNginxRoots(ServerProfile server) async {
|
||||
try {
|
||||
final roots = await getDataManager().nginxRoots(server);
|
||||
connection.onNginxRootsLoaded(roots);
|
||||
} catch (_) {
|
||||
connection.onNginxRootsLoaded(const []);
|
||||
}
|
||||
}
|
||||
|
||||
/// Picks a jar (or any file, for a generic service).
|
||||
void pickArtifact(bool anyFile) async {
|
||||
try {
|
||||
final picked = anyFile
|
||||
? await FilePicker.pickFiles(withData: true)
|
||||
: await FilePicker.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['jar'],
|
||||
withData: true,
|
||||
);
|
||||
if (picked == null || picked.files.isEmpty) return;
|
||||
final file = picked.files.first;
|
||||
final bytes = file.bytes ?? await File(file.path!).readAsBytes();
|
||||
connection.onJarStaged(bytes, file.name, 'Dropped file');
|
||||
} catch (error) {
|
||||
handleError(error, () => pickArtifact(anyFile), () {}, "Retry");
|
||||
}
|
||||
}
|
||||
|
||||
/// Picks the folder holding a built site.
|
||||
void pickWebFolder() async {
|
||||
try {
|
||||
final directory = await FilePicker.getDirectoryPath(
|
||||
dialogTitle: 'Select the built site folder (contains index.html)',
|
||||
);
|
||||
if (directory == null) return;
|
||||
connection.onWebStaged(directory, 'Selected folder');
|
||||
} catch (error) {
|
||||
handleError(error, pickWebFolder, () {}, "Retry");
|
||||
}
|
||||
}
|
||||
|
||||
/// Accepts a drag-and-drop payload, rejecting anything the service can't use.
|
||||
void stageDropped(DropDoneDetails details, bool isWeb, bool isGeneric) async {
|
||||
if (details.files.isEmpty) return;
|
||||
try {
|
||||
if (isWeb) {
|
||||
final entity = details.files.first;
|
||||
if (FileSystemEntity.isDirectorySync(entity.path)) {
|
||||
connection.onWebStaged(entity.path, 'Dropped folder');
|
||||
} else {
|
||||
connection.onBuildFailed('Drop the built site folder here.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isGeneric) {
|
||||
final file = details.files.first;
|
||||
if (FileSystemEntity.isDirectorySync(file.path)) {
|
||||
connection.onBuildFailed('Drop a single file, not a folder.');
|
||||
return;
|
||||
}
|
||||
connection.onJarStaged(
|
||||
await file.readAsBytes(),
|
||||
file.name,
|
||||
'Dropped file',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final jar = details.files.firstWhere(
|
||||
(f) => f.name.toLowerCase().endsWith('.jar'),
|
||||
orElse: () => details.files.first,
|
||||
);
|
||||
if (!jar.name.toLowerCase().endsWith('.jar')) {
|
||||
connection.onBuildFailed('That is not a .jar file.');
|
||||
return;
|
||||
}
|
||||
connection.onJarStaged(await jar.readAsBytes(), jar.name, 'Dropped file');
|
||||
} catch (error) {
|
||||
handleError(error, () {}, () {}, "Okay");
|
||||
}
|
||||
}
|
||||
|
||||
/// Picks the source project folder and remembers it on the service.
|
||||
void pickProjectDirectory(ServerProfile server, ServiceConfig service) async {
|
||||
try {
|
||||
final directory = await FilePicker.getDirectoryPath(
|
||||
dialogTitle: 'Select the service project folder',
|
||||
);
|
||||
if (directory == null) return;
|
||||
await saveProjectPath(server, service, directory);
|
||||
} catch (error) {
|
||||
handleError(
|
||||
error,
|
||||
() => pickProjectDirectory(server, service),
|
||||
() {},
|
||||
"Retry",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists the project path so the next deploy doesn't ask again.
|
||||
Future<void> saveProjectPath(
|
||||
ServerProfile server,
|
||||
ServiceConfig service,
|
||||
String path,
|
||||
) async {
|
||||
if (service.localProjectPath == path) return;
|
||||
service.localProjectPath = path;
|
||||
final servers = await getDataManager().getServers();
|
||||
final serverIndex = servers.indexWhere((s) => s.id == server.id);
|
||||
if (serverIndex < 0) return;
|
||||
final services = servers[serverIndex].services;
|
||||
final serviceIndex = services.indexWhere((s) => s.id == service.id);
|
||||
if (serviceIndex < 0) return;
|
||||
services[serviceIndex] = service;
|
||||
await getDataManager().setServers(servers);
|
||||
}
|
||||
|
||||
// ── build ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Compiles the project and stages whatever it produces.
|
||||
void buildFromSource(ServiceConfig service, bool isWeb) async {
|
||||
try {
|
||||
final result = isWeb
|
||||
? await getDataManager().buildWeb(service)
|
||||
: await getDataManager().build(service);
|
||||
|
||||
if (!result.ok) {
|
||||
connection.onBuildFailed(result.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isWeb) {
|
||||
connection.onWebStaged(result.dir!.path, 'Built from source');
|
||||
} else {
|
||||
final jar = result.jar!;
|
||||
connection.onJarStaged(
|
||||
await jar.readAsBytes(),
|
||||
_basename(jar.path),
|
||||
'Built from source',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
connection.onBuildFailed(error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
// ── deploy ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Ships a staged jar/binary, then records the outcome in the history.
|
||||
void deployJar(
|
||||
ServerProfile server,
|
||||
ServiceConfig service,
|
||||
Uint8List bytes,
|
||||
String label,
|
||||
) async {
|
||||
if (!await canReach(
|
||||
server,
|
||||
() => deployJar(server, service, bytes, label),
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
DeployResult result;
|
||||
try {
|
||||
result = await getDataManager().deploy(
|
||||
server: server,
|
||||
service: service,
|
||||
jarBytes: bytes,
|
||||
jarLabel: label,
|
||||
);
|
||||
} catch (error) {
|
||||
result = DeployResult(false, canRevert, 'Error: $error');
|
||||
}
|
||||
await _record(
|
||||
server,
|
||||
service,
|
||||
result,
|
||||
label,
|
||||
service.remotePath(label.isEmpty ? '<jar>' : label),
|
||||
);
|
||||
connection.onDeployFinished(result);
|
||||
}
|
||||
|
||||
/// Packages the built site, then publishes it to nginx.
|
||||
void deployWeb(
|
||||
ServerProfile server,
|
||||
ServiceConfig service,
|
||||
String directory,
|
||||
) async {
|
||||
if (!await canReach(server, () => deployWeb(server, service, directory))) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The archive is extracted at the web root, so index.html has to be at the
|
||||
// folder's top level — for Angular that's dist/<project>/browser, which is
|
||||
// an easy directory to miss.
|
||||
if (!File('$directory/index.html').existsSync()) {
|
||||
connection.onDeployFinished(
|
||||
DeployResult(
|
||||
false,
|
||||
false,
|
||||
'No index.html in "$directory". Point at the folder that CONTAINS '
|
||||
'index.html (for Angular that\'s dist/<project>/browser).',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
DeployResult result;
|
||||
try {
|
||||
connection.onPackagingStarted();
|
||||
final (bytes, count) = await getDataManager().packageWeb(directory);
|
||||
if (count == 0) {
|
||||
connection.onDeployFinished(
|
||||
DeployResult(
|
||||
false,
|
||||
false,
|
||||
'That folder is empty — nothing to deploy.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
result = await getDataManager().deployWeb(
|
||||
server: server,
|
||||
service: service,
|
||||
archiveBytes: bytes,
|
||||
fileCount: count,
|
||||
);
|
||||
} catch (error) {
|
||||
result = DeployResult(false, canRevert, 'Error: $error');
|
||||
}
|
||||
await _record(
|
||||
server,
|
||||
service,
|
||||
result,
|
||||
_basename(directory),
|
||||
service.remoteRoot,
|
||||
);
|
||||
connection.onDeployFinished(result);
|
||||
}
|
||||
|
||||
/// Restores the backup the last deploy left behind.
|
||||
void revert(ServerProfile server, ServiceConfig service) async {
|
||||
try {
|
||||
final result = await getDataManager().revert();
|
||||
if (result.ok) {
|
||||
await _writeRecord(
|
||||
server,
|
||||
service,
|
||||
DeployStatus.reverted,
|
||||
'(previous)',
|
||||
result.message,
|
||||
service.remoteRoot,
|
||||
);
|
||||
}
|
||||
connection.onRevertFinished(result);
|
||||
} catch (error) {
|
||||
connection.onRevertFinished(DeployResult(false, false, '$error'));
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops the backup, committing to the version just deployed.
|
||||
void discardBackup() async {
|
||||
try {
|
||||
await getDataManager().discardBackup();
|
||||
connection.onBackupDiscarded();
|
||||
} catch (error) {
|
||||
handleError(error, discardBackup, () {}, "Retry");
|
||||
}
|
||||
}
|
||||
|
||||
/// Aborts whichever half of the pipeline is running.
|
||||
void cancel(bool building) {
|
||||
if (building) {
|
||||
getDataManager().cancelBuild();
|
||||
} else {
|
||||
getDataManager().requestCancel();
|
||||
}
|
||||
}
|
||||
|
||||
void disconnect() {
|
||||
getDataManager().disconnect();
|
||||
connection.onFollowChanged(false);
|
||||
}
|
||||
|
||||
void toggleFollow(
|
||||
ServerProfile server,
|
||||
ServiceConfig service,
|
||||
bool currentlyFollowing,
|
||||
) async {
|
||||
if (currentlyFollowing) {
|
||||
getDataManager().stopLogs();
|
||||
connection.onFollowChanged(false);
|
||||
return;
|
||||
}
|
||||
connection.onFollowChanged(true);
|
||||
try {
|
||||
await getDataManager().followLogs(server, service);
|
||||
} catch (error) {
|
||||
connection.onFollowChanged(false);
|
||||
handleError(
|
||||
error,
|
||||
() => toggleFollow(server, service, false),
|
||||
() {},
|
||||
"Retry",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── history ────────────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _record(
|
||||
ServerProfile server,
|
||||
ServiceConfig service,
|
||||
DeployResult result,
|
||||
String artifact,
|
||||
String target,
|
||||
) {
|
||||
return _writeRecord(
|
||||
server,
|
||||
service,
|
||||
result.ok ? DeployStatus.success : DeployStatus.failure,
|
||||
artifact,
|
||||
result.message,
|
||||
target,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _writeRecord(
|
||||
ServerProfile server,
|
||||
ServiceConfig service,
|
||||
DeployStatus status,
|
||||
String artifact,
|
||||
String message,
|
||||
String target,
|
||||
) 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: target,
|
||||
time: DateTime.now(),
|
||||
status: status,
|
||||
message: message,
|
||||
),
|
||||
);
|
||||
} catch (_) {
|
||||
// Losing a history row must never mask the deploy's own outcome, which
|
||||
// the caller is about to report.
|
||||
}
|
||||
}
|
||||
|
||||
String _basename(String path) => baseName(path);
|
||||
}
|
||||
Reference in New Issue
Block a user