Files
PeachDeployments/lib/Peach/see/deploy/single/ViewDeploy.dart
alvocool 38881d1fe1 Add app source and tests
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 10:15:30 +03:00

405 lines
12 KiB
Dart

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