405 lines
12 KiB
Dart
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);
|
|
}
|