Add app source and tests
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
56
lib/Peach/about/external/data/Docker.dart
vendored
Normal file
56
lib/Peach/about/external/data/Docker.dart
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
class DockerStatus {
|
||||
final bool installed;
|
||||
final bool running;
|
||||
final String version;
|
||||
DockerStatus(this.installed, this.running, this.version);
|
||||
}
|
||||
|
||||
class DockerContainer {
|
||||
final String id;
|
||||
final String name;
|
||||
final String image;
|
||||
final String state; // running | exited | created | paused | restarting | dead
|
||||
final String status; // e.g. "Up 2 hours" / "Exited (0) 3 min ago"
|
||||
final String ports;
|
||||
|
||||
DockerContainer({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.image,
|
||||
required this.state,
|
||||
required this.status,
|
||||
required this.ports,
|
||||
});
|
||||
|
||||
bool get up => state.toLowerCase() == 'running';
|
||||
}
|
||||
|
||||
/// Everything needed to `docker run` a new container.
|
||||
class DockerRunSpec {
|
||||
String image;
|
||||
String name;
|
||||
String ports; // "host:container" per line
|
||||
String env; // "KEY=VALUE" per line
|
||||
String volumes; // "host:container" per line
|
||||
String restart; // no | on-failure | always | unless-stopped
|
||||
bool detached;
|
||||
String command;
|
||||
|
||||
DockerRunSpec({
|
||||
this.image = '',
|
||||
this.name = '',
|
||||
this.ports = '',
|
||||
this.env = '',
|
||||
this.volumes = '',
|
||||
this.restart = 'unless-stopped',
|
||||
this.detached = true,
|
||||
this.command = '',
|
||||
});
|
||||
|
||||
List<String> _lines(String s) =>
|
||||
s.split('\n').map((e) => e.trim()).where((e) => e.isNotEmpty).toList();
|
||||
|
||||
List<String> get portList => _lines(ports);
|
||||
List<String> get envList => _lines(env);
|
||||
List<String> get volumeList => _lines(volumes);
|
||||
}
|
||||
151
lib/Peach/about/external/data/EnvCheck.dart
vendored
Normal file
151
lib/Peach/about/external/data/EnvCheck.dart
vendored
Normal file
@@ -0,0 +1,151 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A tool/runtime PeachDeployments can detect on a server and install if
|
||||
/// missing. [detect] prints a version line to stdout when present, nothing
|
||||
/// otherwise (guarded by `command -v`). [pkg] maps a package manager to the
|
||||
/// package(s) to install. [unit] is a systemd unit to `enable --now` after.
|
||||
class EnvTool {
|
||||
final String key;
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final String detect;
|
||||
final Map<String, String> pkg;
|
||||
final String? unit;
|
||||
|
||||
const EnvTool(
|
||||
this.key,
|
||||
this.label,
|
||||
this.icon,
|
||||
this.detect,
|
||||
this.pkg, {
|
||||
this.unit,
|
||||
});
|
||||
|
||||
String packagesFor(String pm) => pkg[pm] ?? key;
|
||||
}
|
||||
|
||||
const List<EnvTool> kEnvTools = [
|
||||
EnvTool(
|
||||
'java',
|
||||
'Java (JDK)',
|
||||
Icons.coffee,
|
||||
'command -v java >/dev/null 2>&1 && java -version 2>&1 | head -n1',
|
||||
{
|
||||
'apt': 'openjdk-17-jdk',
|
||||
'dnf': 'java-17-openjdk-devel',
|
||||
'yum': 'java-17-openjdk-devel',
|
||||
'apk': 'openjdk17',
|
||||
'zypper': 'java-17-openjdk-devel',
|
||||
'pacman': 'jdk-openjdk',
|
||||
},
|
||||
),
|
||||
EnvTool(
|
||||
'mariadb',
|
||||
'MariaDB / MySQL',
|
||||
Icons.storage,
|
||||
'(command -v mariadb >/dev/null 2>&1 && mariadb --version 2>&1 | head -n1) || '
|
||||
'(command -v mysql >/dev/null 2>&1 && mysql --version 2>&1 | head -n1)',
|
||||
{
|
||||
'apt': 'mariadb-server',
|
||||
'dnf': 'mariadb-server',
|
||||
'yum': 'mariadb-server',
|
||||
'apk': 'mariadb mariadb-client',
|
||||
'zypper': 'mariadb',
|
||||
'pacman': 'mariadb',
|
||||
},
|
||||
unit: 'mariadb',
|
||||
),
|
||||
EnvTool(
|
||||
'postgres',
|
||||
'PostgreSQL',
|
||||
Icons.dataset,
|
||||
'command -v psql >/dev/null 2>&1 && psql --version 2>&1 | head -n1',
|
||||
{
|
||||
'apt': 'postgresql',
|
||||
'dnf': 'postgresql-server',
|
||||
'yum': 'postgresql-server',
|
||||
'apk': 'postgresql',
|
||||
'zypper': 'postgresql-server',
|
||||
'pacman': 'postgresql',
|
||||
},
|
||||
unit: 'postgresql',
|
||||
),
|
||||
EnvTool(
|
||||
'nginx',
|
||||
'nginx',
|
||||
Icons.dns,
|
||||
'command -v nginx >/dev/null 2>&1 && nginx -v 2>&1 | head -n1',
|
||||
{
|
||||
'apt': 'nginx',
|
||||
'dnf': 'nginx',
|
||||
'yum': 'nginx',
|
||||
'apk': 'nginx',
|
||||
'zypper': 'nginx',
|
||||
'pacman': 'nginx',
|
||||
},
|
||||
unit: 'nginx',
|
||||
),
|
||||
EnvTool(
|
||||
'docker',
|
||||
'Docker',
|
||||
Icons.inventory_2,
|
||||
'command -v docker >/dev/null 2>&1 && docker --version 2>&1 | head -n1',
|
||||
{
|
||||
'apt': 'docker.io',
|
||||
'dnf': 'docker',
|
||||
'yum': 'docker',
|
||||
'apk': 'docker',
|
||||
'zypper': 'docker',
|
||||
'pacman': 'docker',
|
||||
},
|
||||
unit: 'docker',
|
||||
),
|
||||
EnvTool(
|
||||
'node',
|
||||
'Node.js',
|
||||
Icons.javascript,
|
||||
'command -v node >/dev/null 2>&1 && node --version 2>&1 | head -n1',
|
||||
{
|
||||
'apt': 'nodejs npm',
|
||||
'dnf': 'nodejs',
|
||||
'yum': 'nodejs',
|
||||
'apk': 'nodejs npm',
|
||||
'zypper': 'nodejs',
|
||||
'pacman': 'nodejs npm',
|
||||
},
|
||||
),
|
||||
EnvTool(
|
||||
'git',
|
||||
'Git',
|
||||
Icons.merge_type,
|
||||
'command -v git >/dev/null 2>&1 && git --version 2>&1 | head -n1',
|
||||
{
|
||||
'apt': 'git',
|
||||
'dnf': 'git',
|
||||
'yum': 'git',
|
||||
'apk': 'git',
|
||||
'zypper': 'git',
|
||||
'pacman': 'git',
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
/// Builds the install command for a detected package manager.
|
||||
String installCommandFor(String pm, String packages) {
|
||||
switch (pm) {
|
||||
case 'apt':
|
||||
return 'apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y $packages';
|
||||
case 'dnf':
|
||||
return 'dnf install -y $packages';
|
||||
case 'yum':
|
||||
return 'yum install -y $packages';
|
||||
case 'apk':
|
||||
return 'apk add --no-cache $packages';
|
||||
case 'zypper':
|
||||
return 'zypper --non-interactive install $packages';
|
||||
case 'pacman':
|
||||
return 'pacman -Sy --noconfirm $packages';
|
||||
default:
|
||||
return 'echo "Unsupported package manager"; false';
|
||||
}
|
||||
}
|
||||
10
lib/Peach/about/external/data/Health.dart
vendored
Normal file
10
lib/Peach/about/external/data/Health.dart
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
enum HealthStatus { healthy, unhealthy, unknown, disabled, checking }
|
||||
|
||||
class HealthResult {
|
||||
final HealthStatus status;
|
||||
final String detail;
|
||||
final DateTime time;
|
||||
HealthResult(this.status, this.detail) : time = DateTime.now();
|
||||
|
||||
static HealthResult checking() => HealthResult(HealthStatus.checking, '…');
|
||||
}
|
||||
170
lib/Peach/about/external/data/Kafka.dart
vendored
Normal file
170
lib/Peach/about/external/data/Kafka.dart
vendored
Normal file
@@ -0,0 +1,170 @@
|
||||
// Models for the Kafka monitor. All data is scraped from the standard Kafka
|
||||
// CLI tools (`kafka-topics`, `kafka-consumer-groups`, `kafka-get-offsets`,
|
||||
// `kafka-broker-api-versions`) run over SSH.
|
||||
|
||||
/// Result of locating the Kafka tooling on a server.
|
||||
class KafkaStatus {
|
||||
final bool detected;
|
||||
final String binDir; // e.g. /opt/kafka/bin
|
||||
final String suffix; // ".sh" for Apache tarballs, "" for Confluent packages
|
||||
final bool java; // a JVM is on PATH
|
||||
final String version; // best-effort version string
|
||||
final String detail; // human note (where it was found / why not)
|
||||
|
||||
KafkaStatus({
|
||||
required this.detected,
|
||||
this.binDir = '',
|
||||
this.suffix = '.sh',
|
||||
this.java = false,
|
||||
this.version = '',
|
||||
this.detail = '',
|
||||
});
|
||||
}
|
||||
|
||||
class KafkaPartition {
|
||||
final int id;
|
||||
final int leader;
|
||||
final List<int> replicas;
|
||||
final List<int> isr;
|
||||
int earliest;
|
||||
int latest; // high-watermark (log-end offset)
|
||||
|
||||
KafkaPartition({
|
||||
required this.id,
|
||||
required this.leader,
|
||||
required this.replicas,
|
||||
required this.isr,
|
||||
this.earliest = 0,
|
||||
this.latest = 0,
|
||||
});
|
||||
|
||||
/// Messages currently retained on this partition.
|
||||
int get messages => (latest - earliest).clamp(0, 1 << 62);
|
||||
|
||||
/// Replica set smaller than in-sync means the partition is healthy but the
|
||||
/// ISR shrinking below the replica count means it is under-replicated.
|
||||
bool get underReplicated => isr.length < replicas.length;
|
||||
|
||||
bool get offline => leader < 0;
|
||||
}
|
||||
|
||||
class KafkaTopic {
|
||||
final String name;
|
||||
final int replicationFactor;
|
||||
final String configs;
|
||||
final List<KafkaPartition> partitions;
|
||||
|
||||
KafkaTopic({
|
||||
required this.name,
|
||||
required this.replicationFactor,
|
||||
required this.partitions,
|
||||
this.configs = '',
|
||||
});
|
||||
|
||||
bool get internal => name.startsWith('_') || name.startsWith('__');
|
||||
int get partitionCount => partitions.length;
|
||||
|
||||
/// Total high-watermark across partitions — the number of records ever
|
||||
/// produced (ignoring compaction), a good proxy for "messages sent".
|
||||
int get highWatermark => partitions.fold(0, (s, p) => s + p.latest);
|
||||
|
||||
/// Messages currently retained on the topic (latest − earliest).
|
||||
int get retained => partitions.fold(0, (s, p) => s + p.messages);
|
||||
|
||||
int get underReplicatedCount =>
|
||||
partitions.where((p) => p.underReplicated).length;
|
||||
int get offlineCount => partitions.where((p) => p.offline).length;
|
||||
bool get healthy => underReplicatedCount == 0 && offlineCount == 0;
|
||||
}
|
||||
|
||||
/// One row of `kafka-consumer-groups --describe` (per topic/partition).
|
||||
class KafkaGroupAssignment {
|
||||
final String topic;
|
||||
final int partition;
|
||||
final int currentOffset; // committed / processed
|
||||
final int logEndOffset; // produced
|
||||
final int lag;
|
||||
final String consumerId;
|
||||
final String host;
|
||||
final String clientId;
|
||||
|
||||
KafkaGroupAssignment({
|
||||
required this.topic,
|
||||
required this.partition,
|
||||
required this.currentOffset,
|
||||
required this.logEndOffset,
|
||||
required this.lag,
|
||||
this.consumerId = '',
|
||||
this.host = '',
|
||||
this.clientId = '',
|
||||
});
|
||||
}
|
||||
|
||||
class KafkaConsumerGroup {
|
||||
final String groupId;
|
||||
String state; // Stable | Empty | Dead | PreparingRebalance | ...
|
||||
int members;
|
||||
final String coordinator;
|
||||
final String assignmentStrategy;
|
||||
final List<KafkaGroupAssignment> assignments;
|
||||
|
||||
KafkaConsumerGroup({
|
||||
required this.groupId,
|
||||
this.state = '',
|
||||
this.members = 0,
|
||||
this.coordinator = '',
|
||||
this.assignmentStrategy = '',
|
||||
List<KafkaGroupAssignment>? assignments,
|
||||
}) : assignments = assignments ?? [];
|
||||
|
||||
int get totalLag =>
|
||||
assignments.fold(0, (s, a) => s + (a.lag < 0 ? 0 : a.lag));
|
||||
int get processed => assignments.fold(0, (s, a) => s + a.currentOffset);
|
||||
Set<String> get topics => assignments.map((a) => a.topic).toSet();
|
||||
int get liveMembers => assignments
|
||||
.map((a) => a.consumerId)
|
||||
.where((c) => c.isNotEmpty && c != '-')
|
||||
.toSet()
|
||||
.length;
|
||||
|
||||
bool get active => state.toLowerCase() == 'stable';
|
||||
bool get idle => state.toLowerCase() == 'empty';
|
||||
}
|
||||
|
||||
class KafkaBroker {
|
||||
final int id;
|
||||
final String host;
|
||||
final int port;
|
||||
final String rack;
|
||||
bool isController;
|
||||
|
||||
KafkaBroker({
|
||||
required this.id,
|
||||
required this.host,
|
||||
required this.port,
|
||||
this.rack = '',
|
||||
this.isController = false,
|
||||
});
|
||||
|
||||
String get endpoint => '$host:$port';
|
||||
}
|
||||
|
||||
/// Cluster-level snapshot: brokers + health counts.
|
||||
class KafkaCluster {
|
||||
final List<KafkaBroker> brokers;
|
||||
final String clusterId;
|
||||
final int controllerId;
|
||||
final List<String> underReplicated; // "topic-partition" strings
|
||||
final List<String> unavailable;
|
||||
|
||||
KafkaCluster({
|
||||
required this.brokers,
|
||||
this.clusterId = '',
|
||||
this.controllerId = -1,
|
||||
List<String>? underReplicated,
|
||||
List<String>? unavailable,
|
||||
}) : underReplicated = underReplicated ?? [],
|
||||
unavailable = unavailable ?? [];
|
||||
|
||||
bool get healthy => underReplicated.isEmpty && unavailable.isEmpty;
|
||||
}
|
||||
53
lib/Peach/about/external/data/PeachError.dart
vendored
Normal file
53
lib/Peach/about/external/data/PeachError.dart
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
import '../../internal/application/Severity.dart';
|
||||
|
||||
/// The single error shape every failure is normalised into before it reaches
|
||||
/// the user. SSH, build and archive failures are all mapped onto this by
|
||||
/// `ParentViewModel.handleError`, so the error overlay only ever renders one
|
||||
/// kind of object.
|
||||
class PeachError {
|
||||
final double code;
|
||||
final String message;
|
||||
final String helper;
|
||||
final String title;
|
||||
final String severity;
|
||||
|
||||
PeachError({
|
||||
required this.code,
|
||||
required this.message,
|
||||
required this.helper,
|
||||
required this.title,
|
||||
required this.severity,
|
||||
});
|
||||
|
||||
factory PeachError.fromJsonMap(Map<String, dynamic> json) => PeachError(
|
||||
code: (json['code'] as num?)?.toDouble() ?? 0,
|
||||
message: json['message'] as String? ?? '',
|
||||
helper: json['helper'] as String? ?? '',
|
||||
title: json['title'] as String? ?? 'Peach Error',
|
||||
severity: json['severity'] as String? ?? Severity.message.name,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'code': code,
|
||||
'message': message,
|
||||
'helper': helper,
|
||||
'title': title,
|
||||
'severity': severity,
|
||||
};
|
||||
}
|
||||
|
||||
/// Best-effort decode of an error payload into a [PeachError]. Anything that
|
||||
/// isn't a recognisable map is surfaced as a generic error rather than thrown,
|
||||
/// so the overlay always has something to show.
|
||||
PeachError getPeachError(dynamic data) {
|
||||
if (data is Map<String, dynamic>) return PeachError.fromJsonMap(data);
|
||||
return PeachError(
|
||||
code: 9000.01,
|
||||
message: data?.toString() ?? 'An unknown error occurred.',
|
||||
helper:
|
||||
'Retry the operation, and if the problem persists check the '
|
||||
'server and your connection details.',
|
||||
title: 'Peach Error',
|
||||
severity: Severity.message.name,
|
||||
);
|
||||
}
|
||||
24
lib/Peach/about/external/data/Redis.dart
vendored
Normal file
24
lib/Peach/about/external/data/Redis.dart
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
/// A single Redis key's type, TTL and (formatted) value.
|
||||
class RedisEntry {
|
||||
final String key;
|
||||
final String type; // string | list | set | hash | zset | stream | none
|
||||
final String ttl; // seconds, -1 (no expiry), -2 (missing)
|
||||
final String value;
|
||||
RedisEntry({
|
||||
required this.key,
|
||||
required this.type,
|
||||
required this.ttl,
|
||||
required this.value,
|
||||
});
|
||||
|
||||
String get ttlLabel {
|
||||
final n = int.tryParse(ttl.trim());
|
||||
if (n == null) return ttl;
|
||||
if (n == -1) return 'no expiry';
|
||||
if (n == -2) return 'missing';
|
||||
if (n < 60) return '${n}s';
|
||||
if (n < 3600) return '${(n / 60).floor()}m ${n % 60}s';
|
||||
if (n < 86400) return '${(n / 3600).floor()}h';
|
||||
return '${(n / 86400).floor()}d';
|
||||
}
|
||||
}
|
||||
125
lib/Peach/about/external/initial/SystemdSpec.dart
vendored
Normal file
125
lib/Peach/about/external/initial/SystemdSpec.dart
vendored
Normal file
@@ -0,0 +1,125 @@
|
||||
/// systemd service [Type].
|
||||
enum SystemdType { simple, exec, forking, oneshot, notify }
|
||||
|
||||
/// systemd Restart= policy.
|
||||
enum RestartPolicy { onFailure, always, no }
|
||||
|
||||
extension RestartPolicyX on RestartPolicy {
|
||||
String get value => switch (this) {
|
||||
RestartPolicy.onFailure => 'on-failure',
|
||||
RestartPolicy.always => 'always',
|
||||
RestartPolicy.no => 'no',
|
||||
};
|
||||
String get label => switch (this) {
|
||||
RestartPolicy.onFailure => 'On failure',
|
||||
RestartPolicy.always => 'Always',
|
||||
RestartPolicy.no => 'Never',
|
||||
};
|
||||
}
|
||||
|
||||
/// Everything needed to generate and register a `.service` unit file.
|
||||
class SystemdSpec {
|
||||
String unitName; // e.g. "orders" -> orders.service
|
||||
String description;
|
||||
SystemdType type;
|
||||
String user;
|
||||
String group;
|
||||
String workingDirectory;
|
||||
String execStart;
|
||||
RestartPolicy restart;
|
||||
int restartSec;
|
||||
|
||||
/// Raw "KEY=VALUE" lines.
|
||||
String environmentLines;
|
||||
|
||||
/// Extra `After=`/`Wants=` targets, e.g. mariadb.service.
|
||||
List<String> afterUnits;
|
||||
|
||||
/// Add `SuccessExitStatus=143` (SIGTERM) — recommended for JVM apps.
|
||||
bool javaSuccessExit;
|
||||
|
||||
bool enableOnBoot;
|
||||
bool startNow;
|
||||
|
||||
SystemdSpec({
|
||||
this.unitName = '',
|
||||
this.description = '',
|
||||
this.type = SystemdType.simple,
|
||||
this.user = 'root',
|
||||
this.group = '',
|
||||
this.workingDirectory = '',
|
||||
this.execStart = '',
|
||||
this.restart = RestartPolicy.onFailure,
|
||||
this.restartSec = 5,
|
||||
this.environmentLines = '',
|
||||
List<String>? afterUnits,
|
||||
this.javaSuccessExit = false,
|
||||
this.enableOnBoot = true,
|
||||
this.startNow = true,
|
||||
}) : afterUnits = afterUnits ?? ['network.target'];
|
||||
|
||||
/// Sanitized unit name (no spaces / .service suffix).
|
||||
String get safeName {
|
||||
var n = unitName.trim();
|
||||
if (n.toLowerCase().endsWith('.service')) {
|
||||
n = n.substring(0, n.length - '.service'.length);
|
||||
}
|
||||
return n.replaceAll(RegExp(r'[^A-Za-z0-9_.@-]'), '-');
|
||||
}
|
||||
|
||||
List<String> get envList => environmentLines
|
||||
.split('\n')
|
||||
.map((e) => e.trim())
|
||||
.where((e) => e.isNotEmpty && e.contains('='))
|
||||
.toList();
|
||||
|
||||
/// Renders the unit file contents.
|
||||
String render() {
|
||||
final b = StringBuffer();
|
||||
b.writeln('# Generated by PeachDeployments');
|
||||
b.writeln('[Unit]');
|
||||
b.writeln(
|
||||
'Description=${description.trim().isEmpty ? safeName : description.trim()}',
|
||||
);
|
||||
final afters = afterUnits.where((a) => a.trim().isNotEmpty).toList();
|
||||
if (afters.isNotEmpty) {
|
||||
b.writeln('After=${afters.join(' ')}');
|
||||
// Wants only the non-core targets (don't hard-require network.target).
|
||||
final wants = afters.where((a) => a != 'network.target').toList();
|
||||
if (wants.isNotEmpty) b.writeln('Wants=${wants.join(' ')}');
|
||||
}
|
||||
b.writeln();
|
||||
b.writeln('[Service]');
|
||||
b.writeln('Type=${type.name}');
|
||||
if (user.trim().isNotEmpty) b.writeln('User=${user.trim()}');
|
||||
if (group.trim().isNotEmpty) b.writeln('Group=${group.trim()}');
|
||||
if (workingDirectory.trim().isNotEmpty) {
|
||||
b.writeln('WorkingDirectory=${workingDirectory.trim()}');
|
||||
}
|
||||
for (final e in envList) {
|
||||
b.writeln('Environment="$e"');
|
||||
}
|
||||
b.writeln('ExecStart=${execStart.trim()}');
|
||||
b.writeln('Restart=${restart.value}');
|
||||
if (restart != RestartPolicy.no) b.writeln('RestartSec=$restartSec');
|
||||
if (javaSuccessExit) b.writeln('SuccessExitStatus=143');
|
||||
b.writeln();
|
||||
b.writeln('[Install]');
|
||||
b.writeln('WantedBy=multi-user.target');
|
||||
return b.toString();
|
||||
}
|
||||
|
||||
/// Human-readable list of the commands the installer will run.
|
||||
List<String> plannedCommands({required bool sudo}) {
|
||||
final p = sudo ? 'sudo ' : '';
|
||||
final target = '/etc/systemd/system/$safeName.service';
|
||||
return [
|
||||
'write $target',
|
||||
'${p}chmod 644 $target',
|
||||
'${p}systemctl daemon-reload',
|
||||
if (enableOnBoot) '${p}systemctl enable $safeName',
|
||||
if (startNow) '${p}systemctl restart $safeName',
|
||||
'${p}systemctl status $safeName',
|
||||
];
|
||||
}
|
||||
}
|
||||
65
lib/Peach/about/internal/application/DeploymentRecord.dart
Normal file
65
lib/Peach/about/internal/application/DeploymentRecord.dart
Normal file
@@ -0,0 +1,65 @@
|
||||
enum DeployStatus { success, failure, reverted }
|
||||
|
||||
extension DeployStatusX on DeployStatus {
|
||||
String get label => switch (this) {
|
||||
DeployStatus.success => 'Success',
|
||||
DeployStatus.failure => 'Failure',
|
||||
DeployStatus.reverted => 'Reverted',
|
||||
};
|
||||
}
|
||||
|
||||
/// One historical deployment attempt, tracked for auditing.
|
||||
class DeploymentRecord {
|
||||
final String id;
|
||||
final String serverId;
|
||||
final String serverName;
|
||||
final String serviceId;
|
||||
final String serviceLabel;
|
||||
final String jarName;
|
||||
final String remotePath;
|
||||
final DateTime time;
|
||||
final DeployStatus status;
|
||||
final String message;
|
||||
|
||||
DeploymentRecord({
|
||||
required this.id,
|
||||
required this.serverId,
|
||||
required this.serverName,
|
||||
required this.serviceId,
|
||||
required this.serviceLabel,
|
||||
required this.jarName,
|
||||
required this.remotePath,
|
||||
required this.time,
|
||||
required this.status,
|
||||
required this.message,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'serverId': serverId,
|
||||
'serverName': serverName,
|
||||
'serviceId': serviceId,
|
||||
'serviceLabel': serviceLabel,
|
||||
'jarName': jarName,
|
||||
'remotePath': remotePath,
|
||||
'time': time.toIso8601String(),
|
||||
'status': status.name,
|
||||
'message': message,
|
||||
};
|
||||
|
||||
factory DeploymentRecord.fromJson(Map<String, dynamic> j) => DeploymentRecord(
|
||||
id: j['id'] as String,
|
||||
serverId: (j['serverId'] ?? '') as String,
|
||||
serverName: (j['serverName'] ?? '') as String,
|
||||
serviceId: (j['serviceId'] ?? '') as String,
|
||||
serviceLabel: (j['serviceLabel'] ?? '') as String,
|
||||
jarName: (j['jarName'] ?? '') as String,
|
||||
remotePath: (j['remotePath'] ?? '') as String,
|
||||
time: DateTime.tryParse((j['time'] ?? '') as String) ?? DateTime(2000),
|
||||
status: DeployStatus.values.firstWhere(
|
||||
(s) => s.name == j['status'],
|
||||
orElse: () => DeployStatus.failure,
|
||||
),
|
||||
message: (j['message'] ?? '') as String,
|
||||
);
|
||||
}
|
||||
3
lib/Peach/about/internal/application/NavigatorType.dart
Normal file
3
lib/Peach/about/internal/application/NavigatorType.dart
Normal file
@@ -0,0 +1,3 @@
|
||||
/// How a destination replaces (or stacks on) the current screen. Every
|
||||
/// transition in the app goes through `PeachNavigation` with one of these.
|
||||
enum NavigatorType { justOpen, openFully, replaceCurrent, makeNewMain }
|
||||
@@ -0,0 +1,2 @@
|
||||
/// Severity of an in-app notification sheet (`showApplicationNotification`).
|
||||
enum NotificationType { success, info, warning, error }
|
||||
8
lib/Peach/about/internal/application/Pair.dart
Normal file
8
lib/Peach/about/internal/application/Pair.dart
Normal file
@@ -0,0 +1,8 @@
|
||||
/// A generic two-slot carrier, used where a call needs to hand back two values
|
||||
/// without earning a named model (e.g. a resolved command plus its headers).
|
||||
class Pair<K, V> {
|
||||
final K key;
|
||||
final V value;
|
||||
|
||||
const Pair(this.key, this.value);
|
||||
}
|
||||
85
lib/Peach/about/internal/application/ServerProfile.dart
Normal file
85
lib/Peach/about/internal/application/ServerProfile.dart
Normal file
@@ -0,0 +1,85 @@
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'ServiceConfig.dart';
|
||||
|
||||
/// A unique server the user connects to. Holds the connection details plus a
|
||||
/// list of deployable [ServiceConfig]s that live on it.
|
||||
///
|
||||
/// The [password] is NOT serialized into the plain config JSON — it is held in
|
||||
/// memory and persisted separately via encrypted secure storage.
|
||||
class ServerProfile {
|
||||
final String id;
|
||||
String name; // friendly label, e.g. "prod-1"
|
||||
String host;
|
||||
int port;
|
||||
String username;
|
||||
String password; // runtime only; persisted via SecretStore
|
||||
bool useSudo;
|
||||
List<ServiceConfig> services;
|
||||
|
||||
/// Saved deployment order: service ids in the order the user arranged them
|
||||
/// for batch build & deploy. Ids not present are appended in natural order.
|
||||
List<String> deployOrder;
|
||||
|
||||
ServerProfile({
|
||||
String? id,
|
||||
this.name = 'New server',
|
||||
this.host = '',
|
||||
this.port = 22,
|
||||
this.username = '',
|
||||
this.password = '',
|
||||
this.useSudo = true,
|
||||
List<ServiceConfig>? services,
|
||||
List<String>? deployOrder,
|
||||
}) : id = id ?? const Uuid().v4(),
|
||||
services = services ?? [],
|
||||
deployOrder = deployOrder ?? [];
|
||||
|
||||
/// Services arranged by the saved [deployOrder], with any not-listed ones
|
||||
/// appended in their natural order.
|
||||
List<ServiceConfig> get orderedServices {
|
||||
final byId = {for (final s in services) s.id: s};
|
||||
final result = <ServiceConfig>[];
|
||||
for (final id in deployOrder) {
|
||||
final s = byId.remove(id);
|
||||
if (s != null) result.add(s);
|
||||
}
|
||||
// Preserve original order for the remaining (new/unordered) services.
|
||||
for (final s in services) {
|
||||
if (byId.containsKey(s.id)) result.add(s);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
ServerProfile copy() {
|
||||
final c = ServerProfile.fromJson(toJson());
|
||||
c.password = password;
|
||||
return c;
|
||||
}
|
||||
|
||||
/// JSON WITHOUT the password (that goes to secure storage).
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'host': host,
|
||||
'port': port,
|
||||
'username': username,
|
||||
'useSudo': useSudo,
|
||||
'services': services.map((s) => s.toJson()).toList(),
|
||||
'deployOrder': deployOrder,
|
||||
};
|
||||
|
||||
factory ServerProfile.fromJson(Map<String, dynamic> j) => ServerProfile(
|
||||
id: j['id'] as String?,
|
||||
name: (j['name'] ?? 'server') as String,
|
||||
host: (j['host'] ?? '') as String,
|
||||
port: (j['port'] ?? 22) as int,
|
||||
username: (j['username'] ?? '') as String,
|
||||
useSudo: (j['useSudo'] ?? true) as bool,
|
||||
services: ((j['services'] ?? []) as List<dynamic>)
|
||||
.map((e) => ServiceConfig.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
deployOrder: ((j['deployOrder'] ?? []) as List<dynamic>)
|
||||
.map((e) => e.toString())
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
183
lib/Peach/about/internal/application/ServiceConfig.dart
Normal file
183
lib/Peach/about/internal/application/ServiceConfig.dart
Normal file
@@ -0,0 +1,183 @@
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// What kind of thing this service deploys.
|
||||
enum ServiceKind { jarService, webApp, generic }
|
||||
|
||||
/// Front-end framework for a [ServiceKind.webApp].
|
||||
enum WebFramework { angular, react, flutterWeb, staticFiles }
|
||||
|
||||
/// How a service's health is probed.
|
||||
enum HealthCheckType { auto, http, tcp, command, none }
|
||||
|
||||
extension ServiceKindX on ServiceKind {
|
||||
String get label => switch (this) {
|
||||
ServiceKind.webApp => 'Web app (nginx)',
|
||||
ServiceKind.generic => 'Service (any binary)',
|
||||
ServiceKind.jarService => 'Jar service',
|
||||
};
|
||||
}
|
||||
|
||||
extension HealthCheckTypeX on HealthCheckType {
|
||||
String get label => switch (this) {
|
||||
HealthCheckType.auto => 'systemd (auto)',
|
||||
HealthCheckType.http => 'HTTP request',
|
||||
HealthCheckType.tcp => 'TCP port',
|
||||
HealthCheckType.command => 'Command exit 0',
|
||||
HealthCheckType.none => 'Disabled',
|
||||
};
|
||||
}
|
||||
|
||||
extension WebFrameworkX on WebFramework {
|
||||
String get label => switch (this) {
|
||||
WebFramework.angular => 'Angular',
|
||||
WebFramework.react => 'React',
|
||||
WebFramework.flutterWeb => 'Flutter web',
|
||||
WebFramework.staticFiles => 'Static files',
|
||||
};
|
||||
|
||||
/// Default build command (run through a shell). Blank = no build.
|
||||
String get defaultBuild => switch (this) {
|
||||
WebFramework.angular => 'npm install && npm run build',
|
||||
WebFramework.react => 'npm install && npm run build',
|
||||
WebFramework.flutterWeb => 'flutter build web --release',
|
||||
WebFramework.staticFiles => '',
|
||||
};
|
||||
}
|
||||
|
||||
/// A single deployable service that lives on a [ServerProfile].
|
||||
/// One server can host many of these — jar services and/or web apps.
|
||||
class ServiceConfig {
|
||||
final String id;
|
||||
String name; // friendly label, e.g. "orders-api"
|
||||
|
||||
ServiceKind kind;
|
||||
WebFramework framework;
|
||||
|
||||
/// Absolute target directory on the server.
|
||||
/// - jar service: where the jar lives, e.g. /opt/orders
|
||||
/// - web app: the nginx web root, e.g. /var/www/orders
|
||||
String remoteDir;
|
||||
|
||||
/// The filename the uploaded jar should be saved as (jar services only).
|
||||
String remoteJarName;
|
||||
|
||||
/// The systemd unit to (re)start for a jar service, e.g. "orders".
|
||||
String serviceName;
|
||||
|
||||
/// The nginx unit to reload for a web app (web only). Default "nginx".
|
||||
String nginxService;
|
||||
|
||||
/// Optional custom log command (WITHOUT sudo — the deployer adds it).
|
||||
String logCommand;
|
||||
|
||||
/// Absolute path to the local source project. When set, PeachDeployments can build
|
||||
/// from source before deploying.
|
||||
String localProjectPath;
|
||||
|
||||
/// Optional custom build command. Blank auto-detects.
|
||||
String buildCommand;
|
||||
|
||||
/// jar services: glob/path for the built jar (blank auto-detects).
|
||||
String jarPattern;
|
||||
|
||||
/// web apps: build output directory relative to the project (blank
|
||||
/// auto-detects, e.g. dist/, build/, build/web).
|
||||
String outputSubdir;
|
||||
|
||||
/// How to probe this service's health.
|
||||
HealthCheckType healthType;
|
||||
|
||||
/// Health target: a URL (http), port number (tcp), or shell command.
|
||||
String healthTarget;
|
||||
|
||||
ServiceConfig({
|
||||
String? id,
|
||||
this.name = 'New service',
|
||||
this.kind = ServiceKind.jarService,
|
||||
this.framework = WebFramework.react,
|
||||
this.remoteDir = '/opt/app',
|
||||
this.remoteJarName = '',
|
||||
this.serviceName = '',
|
||||
this.nginxService = 'nginx',
|
||||
this.logCommand = '',
|
||||
this.localProjectPath = '',
|
||||
this.buildCommand = '',
|
||||
this.jarPattern = '',
|
||||
this.outputSubdir = '',
|
||||
this.healthType = HealthCheckType.auto,
|
||||
this.healthTarget = '',
|
||||
}) : id = id ?? const Uuid().v4();
|
||||
|
||||
bool get isWeb => kind == ServiceKind.webApp;
|
||||
bool get isGeneric => kind == ServiceKind.generic;
|
||||
bool get canBuild => localProjectPath.trim().isNotEmpty;
|
||||
|
||||
String get effectiveLogCommand {
|
||||
if (logCommand.trim().isNotEmpty) return logCommand.trim();
|
||||
if (isWeb) {
|
||||
final unit = nginxService.trim().isEmpty ? 'nginx' : nginxService.trim();
|
||||
return 'journalctl -u $unit -n 200 --no-pager';
|
||||
}
|
||||
return 'journalctl -u ${serviceName.isEmpty ? 'app' : serviceName} '
|
||||
'-n 200 --no-pager';
|
||||
}
|
||||
|
||||
/// Normalized remote directory (no trailing slash).
|
||||
String get remoteRoot => remoteDir.endsWith('/')
|
||||
? remoteDir.substring(0, remoteDir.length - 1)
|
||||
: remoteDir;
|
||||
|
||||
String remotePath(String jarLabelFallback) {
|
||||
final name = remoteJarName.trim().isNotEmpty
|
||||
? remoteJarName.trim()
|
||||
: jarLabelFallback;
|
||||
return '$remoteRoot/$name';
|
||||
}
|
||||
|
||||
ServiceConfig copy() => ServiceConfig.fromJson(toJson());
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'kind': kind.name,
|
||||
'framework': framework.name,
|
||||
'remoteDir': remoteDir,
|
||||
'remoteJarName': remoteJarName,
|
||||
'serviceName': serviceName,
|
||||
'nginxService': nginxService,
|
||||
'logCommand': logCommand,
|
||||
'localProjectPath': localProjectPath,
|
||||
'buildCommand': buildCommand,
|
||||
'jarPattern': jarPattern,
|
||||
'outputSubdir': outputSubdir,
|
||||
'healthType': healthType.name,
|
||||
'healthTarget': healthTarget,
|
||||
};
|
||||
|
||||
factory ServiceConfig.fromJson(Map<String, dynamic> j) => ServiceConfig(
|
||||
id: j['id'] as String?,
|
||||
name: (j['name'] ?? 'service') as String,
|
||||
kind: ServiceKind.values.firstWhere(
|
||||
(k) => k.name == j['kind'],
|
||||
orElse: () => ServiceKind.jarService,
|
||||
),
|
||||
framework: WebFramework.values.firstWhere(
|
||||
(f) => f.name == j['framework'],
|
||||
orElse: () => WebFramework.react,
|
||||
),
|
||||
remoteDir: (j['remoteDir'] ?? '/opt/app') as String,
|
||||
remoteJarName: (j['remoteJarName'] ?? '') as String,
|
||||
serviceName: (j['serviceName'] ?? '') as String,
|
||||
nginxService: (j['nginxService'] ?? 'nginx') as String,
|
||||
logCommand: (j['logCommand'] ?? '') as String,
|
||||
localProjectPath: (j['localProjectPath'] ?? '') as String,
|
||||
buildCommand: (j['buildCommand'] ?? '') as String,
|
||||
jarPattern: (j['jarPattern'] ?? '') as String,
|
||||
outputSubdir: (j['outputSubdir'] ?? '') as String,
|
||||
healthType: HealthCheckType.values.firstWhere(
|
||||
(h) => h.name == j['healthType'],
|
||||
orElse: () => HealthCheckType.auto,
|
||||
),
|
||||
healthTarget: (j['healthTarget'] ?? '') as String,
|
||||
);
|
||||
}
|
||||
2
lib/Peach/about/internal/application/Severity.dart
Normal file
2
lib/Peach/about/internal/application/Severity.dart
Normal file
@@ -0,0 +1,2 @@
|
||||
/// How loudly a [PeachError] should be presented.
|
||||
enum Severity { message, warning, critical }
|
||||
104
lib/Peach/about/internal/application/StepTracker.dart
Normal file
104
lib/Peach/about/internal/application/StepTracker.dart
Normal file
@@ -0,0 +1,104 @@
|
||||
import 'dart:async';
|
||||
|
||||
enum StepState { pending, active, done, failed, skipped }
|
||||
|
||||
class DeployStep {
|
||||
final String key;
|
||||
final String label;
|
||||
StepState state;
|
||||
double? progress; // 0..1 within this step, when meaningful
|
||||
|
||||
DeployStep(
|
||||
this.key,
|
||||
this.label, {
|
||||
this.state = StepState.pending,
|
||||
this.progress,
|
||||
});
|
||||
}
|
||||
|
||||
/// Tracks an ordered list of steps for a build or deploy operation and streams
|
||||
/// updates so the UI can render a live stepper with per-step progress.
|
||||
class StepTracker {
|
||||
final _controller = StreamController<List<DeployStep>>.broadcast();
|
||||
Stream<List<DeployStep>> get stream => _controller.stream;
|
||||
final List<DeployStep> steps = [];
|
||||
|
||||
void reset(List<(String, String)> defs) {
|
||||
steps
|
||||
..clear()
|
||||
..addAll(defs.map((d) => DeployStep(d.$1, d.$2)));
|
||||
_emit();
|
||||
}
|
||||
|
||||
DeployStep? _find(String key) {
|
||||
for (final s in steps) {
|
||||
if (s.key == key) return s;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void active(String key) {
|
||||
final s = _find(key);
|
||||
if (s == null) return;
|
||||
// mark earlier active/pending steps done as we move forward
|
||||
for (final step in steps) {
|
||||
if (step == s) break;
|
||||
if (step.state == StepState.active) step.state = StepState.done;
|
||||
}
|
||||
s.state = StepState.active;
|
||||
s.progress = null;
|
||||
_emit();
|
||||
}
|
||||
|
||||
void progress(String key, double? p) {
|
||||
final s = _find(key);
|
||||
if (s == null) return;
|
||||
s.progress = p;
|
||||
_emit();
|
||||
}
|
||||
|
||||
void done(String key) => _set(key, StepState.done);
|
||||
void fail(String key) => _set(key, StepState.failed);
|
||||
void skip(String key) => _set(key, StepState.skipped);
|
||||
|
||||
void _set(String key, StepState state) {
|
||||
final s = _find(key);
|
||||
if (s == null) return;
|
||||
s.state = state;
|
||||
s.progress = null;
|
||||
_emit();
|
||||
}
|
||||
|
||||
/// Marks the currently-active step done (used before moving on).
|
||||
void completeActive() {
|
||||
for (final s in steps) {
|
||||
if (s.state == StepState.active) s.state = StepState.done;
|
||||
}
|
||||
_emit();
|
||||
}
|
||||
|
||||
/// Any still-pending/active steps become failed (on an error).
|
||||
void failRemaining() {
|
||||
for (final s in steps) {
|
||||
if (s.state == StepState.active) {
|
||||
s.state = StepState.failed;
|
||||
} else if (s.state == StepState.pending) {
|
||||
s.state = StepState.skipped;
|
||||
}
|
||||
}
|
||||
_emit();
|
||||
}
|
||||
|
||||
void clear() {
|
||||
steps.clear();
|
||||
_emit();
|
||||
}
|
||||
|
||||
void _emit() {
|
||||
if (!_controller.isClosed) {
|
||||
_controller.add(List.unmodifiable(steps));
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() => _controller.close();
|
||||
}
|
||||
7
lib/Peach/about/internal/application/TextType.dart
Normal file
7
lib/Peach/about/internal/application/TextType.dart
Normal file
@@ -0,0 +1,7 @@
|
||||
/// The three font roles every piece of display copy is drawn with. Resolved to
|
||||
/// an actual family by `getTextType` in designs/text/Text.dart.
|
||||
///
|
||||
/// Capitalised deliberately: these read as type names at every call site
|
||||
/// (`text('Peach', 20, TextType.Bold)`), matching the reference architecture.
|
||||
// ignore_for_file: constant_identifier_names
|
||||
enum TextType { Bold, Light, Regular }
|
||||
372
lib/Peach/about/internal/file/BuildService.dart
Normal file
372
lib/Peach/about/internal/file/BuildService.dart
Normal file
@@ -0,0 +1,372 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import '../application/ServiceConfig.dart';
|
||||
import '../../../comms/Comms.dart' show LogLine, LogLevel;
|
||||
import '../application/StepTracker.dart';
|
||||
|
||||
class BuildResult {
|
||||
final bool ok;
|
||||
final String message;
|
||||
final File? jar; // jar services
|
||||
final Directory? dir; // web apps (static output directory)
|
||||
BuildResult(this.ok, this.message, {this.jar, this.dir});
|
||||
}
|
||||
|
||||
/// Builds a jar from a local Maven/Gradle project (desktop only) and locates
|
||||
/// the produced artifact so it can be deployed.
|
||||
class BuildService {
|
||||
final _logController = StreamController<LogLine>.broadcast();
|
||||
Stream<LogLine> get logs => _logController.stream;
|
||||
final steps = StepTracker();
|
||||
Stream<List<DeployStep>> get stepStream => steps.stream;
|
||||
Process? _proc;
|
||||
|
||||
void _log(LogLevel level, String text) {
|
||||
for (final line in const LineSplitter().convert(text)) {
|
||||
if (line.trim().isEmpty && level == LogLevel.remote) continue;
|
||||
_logController.add(LogLine(level, line));
|
||||
}
|
||||
}
|
||||
|
||||
/// True on desktop, where local builds can run.
|
||||
static bool get supported =>
|
||||
Platform.isWindows || Platform.isMacOS || Platform.isLinux;
|
||||
|
||||
Future<BuildResult> build(ServiceConfig svc) async {
|
||||
steps.reset([
|
||||
('detect', 'Detect project'),
|
||||
('compile', 'Compile'),
|
||||
('locate', 'Locate jar'),
|
||||
]);
|
||||
if (!supported) {
|
||||
steps.failRemaining();
|
||||
return BuildResult(false, 'Local builds are only available on desktop.');
|
||||
}
|
||||
final dir = svc.localProjectPath.trim();
|
||||
if (dir.isEmpty) {
|
||||
steps.failRemaining();
|
||||
return BuildResult(false, 'No project path set.');
|
||||
}
|
||||
if (!Directory(dir).existsSync()) {
|
||||
steps.failRemaining();
|
||||
return BuildResult(false, 'Project path does not exist: $dir');
|
||||
}
|
||||
|
||||
steps.active('detect');
|
||||
final (exec, args, tool) = _resolveCommand(svc, dir);
|
||||
if (exec == null) {
|
||||
steps.failRemaining();
|
||||
return BuildResult(
|
||||
false,
|
||||
'Could not detect a Maven (pom.xml) or Gradle (build.gradle) '
|
||||
'project in $dir. Set a custom build command in the service.',
|
||||
);
|
||||
}
|
||||
steps.done('detect');
|
||||
|
||||
steps.active('compile');
|
||||
_log(LogLevel.step, 'Building $tool project in $dir …');
|
||||
_log(LogLevel.info, '\$ $exec ${args.join(' ')}');
|
||||
try {
|
||||
final proc = await Process.start(
|
||||
exec,
|
||||
args,
|
||||
workingDirectory: dir,
|
||||
runInShell: true,
|
||||
);
|
||||
_proc = proc;
|
||||
proc.stdout
|
||||
.transform(utf8.decoder)
|
||||
.listen((d) => _log(LogLevel.remote, d.trimRight()));
|
||||
proc.stderr
|
||||
.transform(utf8.decoder)
|
||||
.listen((d) => _log(LogLevel.remote, d.trimRight()));
|
||||
final code = await proc.exitCode;
|
||||
_proc = null;
|
||||
if (code != 0) {
|
||||
steps.failRemaining();
|
||||
return BuildResult(false, 'Build failed (exit code $code).');
|
||||
}
|
||||
_log(LogLevel.success, 'Build finished ✓');
|
||||
steps.done('compile');
|
||||
} catch (e) {
|
||||
_proc = null;
|
||||
steps.failRemaining();
|
||||
return BuildResult(false, 'Could not run build: $e');
|
||||
}
|
||||
|
||||
// Locate the produced jar.
|
||||
steps.active('locate');
|
||||
_log(LogLevel.step, 'Locating built jar …');
|
||||
final jar = _findJar(svc, dir, tool);
|
||||
if (jar == null) {
|
||||
steps.failRemaining();
|
||||
return BuildResult(
|
||||
false,
|
||||
'Build succeeded but no jar was found. Set a jar pattern.',
|
||||
);
|
||||
}
|
||||
steps.done('locate');
|
||||
_log(LogLevel.success, 'Found jar: ${jar.path}');
|
||||
return BuildResult(true, 'Built ${_basename(jar.path)}', jar: jar);
|
||||
}
|
||||
|
||||
/// Builds a web app (Angular/React/Flutter web) and returns the directory of
|
||||
/// static files to publish. For [WebFramework.staticFiles] no build runs.
|
||||
Future<BuildResult> buildWeb(ServiceConfig svc) async {
|
||||
steps.reset([
|
||||
('compile', 'Build ${svc.framework.label}'),
|
||||
('locate', 'Locate output'),
|
||||
]);
|
||||
if (!supported) {
|
||||
steps.failRemaining();
|
||||
return BuildResult(false, 'Local builds are only available on desktop.');
|
||||
}
|
||||
final dir = svc.localProjectPath.trim();
|
||||
if (dir.isEmpty) {
|
||||
steps.failRemaining();
|
||||
return BuildResult(false, 'No project path set.');
|
||||
}
|
||||
if (!Directory(dir).existsSync()) {
|
||||
steps.failRemaining();
|
||||
return BuildResult(false, 'Project path does not exist: $dir');
|
||||
}
|
||||
|
||||
final command = svc.buildCommand.trim().isNotEmpty
|
||||
? svc.buildCommand.trim()
|
||||
: svc.framework.defaultBuild;
|
||||
|
||||
steps.active('compile');
|
||||
if (command.isNotEmpty) {
|
||||
_log(LogLevel.step, 'Building ${svc.framework.label} in $dir …');
|
||||
_log(LogLevel.info, '\$ $command');
|
||||
final (exec, args) = Platform.isWindows
|
||||
? ('cmd', ['/c', command])
|
||||
: ('/bin/sh', ['-c', command]);
|
||||
try {
|
||||
final proc = await Process.start(
|
||||
exec,
|
||||
args,
|
||||
workingDirectory: dir,
|
||||
runInShell: true,
|
||||
);
|
||||
_proc = proc;
|
||||
proc.stdout
|
||||
.transform(utf8.decoder)
|
||||
.listen((d) => _log(LogLevel.remote, d.trimRight()));
|
||||
proc.stderr
|
||||
.transform(utf8.decoder)
|
||||
.listen((d) => _log(LogLevel.remote, d.trimRight()));
|
||||
final code = await proc.exitCode;
|
||||
_proc = null;
|
||||
if (code != 0) {
|
||||
steps.failRemaining();
|
||||
return BuildResult(false, 'Build failed (exit $code).');
|
||||
}
|
||||
_log(LogLevel.success, 'Build finished ✓');
|
||||
} catch (e) {
|
||||
_proc = null;
|
||||
steps.failRemaining();
|
||||
return BuildResult(false, 'Could not run build: $e');
|
||||
}
|
||||
} else {
|
||||
_log(LogLevel.info, 'Static files — no build step.');
|
||||
}
|
||||
steps.done('compile');
|
||||
|
||||
steps.active('locate');
|
||||
_log(LogLevel.step, 'Locating web output …');
|
||||
final out = _findWebOutput(svc, dir);
|
||||
if (out == null) {
|
||||
steps.failRemaining();
|
||||
return BuildResult(
|
||||
false,
|
||||
'Could not find the built site (an index.html). '
|
||||
'Set the output directory in the service.',
|
||||
);
|
||||
}
|
||||
steps.done('locate');
|
||||
_log(LogLevel.success, 'Publishing from: ${out.path}');
|
||||
return BuildResult(true, 'Built ${svc.framework.label}', dir: out);
|
||||
}
|
||||
|
||||
Directory? _findWebOutput(ServiceConfig svc, String dir) {
|
||||
final sub = svc.outputSubdir.trim();
|
||||
if (sub.isNotEmpty) {
|
||||
final d = Directory(
|
||||
sub.startsWith('/') || sub.contains(':') ? sub : '$dir/$sub',
|
||||
);
|
||||
if (!d.existsSync()) return null;
|
||||
// If the pointed-at folder holds index.html use it; otherwise drill into
|
||||
// it (e.g. user set `dist` but Angular nests dist/<proj>/browser).
|
||||
return _withIndex(d) ?? _findDistWithIndex(d.path) ?? d;
|
||||
}
|
||||
switch (svc.framework) {
|
||||
case WebFramework.flutterWeb:
|
||||
return _withIndex(Directory('$dir/build/web'));
|
||||
case WebFramework.angular:
|
||||
return _findDistWithIndex('$dir/dist');
|
||||
case WebFramework.react:
|
||||
return _withIndex(Directory('$dir/build')) ??
|
||||
_withIndex(Directory('$dir/dist'));
|
||||
case WebFramework.staticFiles:
|
||||
return _withIndex(Directory(dir)) ?? Directory(dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns [d] if it (or a nested `browser/` dir) contains index.html.
|
||||
Directory? _withIndex(Directory d) {
|
||||
if (!d.existsSync()) return null;
|
||||
if (File('${d.path}/index.html').existsSync()) return d;
|
||||
final browser = Directory('${d.path}/browser'); // Angular 17+
|
||||
if (File('${browser.path}/index.html').existsSync()) return browser;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Angular puts output under `dist/<project>/` — find the one with index.html.
|
||||
Directory? _findDistWithIndex(String distPath) {
|
||||
final dist = Directory(distPath);
|
||||
if (!dist.existsSync()) return null;
|
||||
final direct = _withIndex(dist);
|
||||
if (direct != null) return direct;
|
||||
for (final entry in dist.listSync().whereType<Directory>()) {
|
||||
final found = _withIndex(entry);
|
||||
if (found != null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void cancel() {
|
||||
_proc?.kill();
|
||||
_proc = null;
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
cancel();
|
||||
_logController.close();
|
||||
steps.dispose();
|
||||
}
|
||||
|
||||
// ---- command resolution ---------------------------------------------------
|
||||
|
||||
(String?, List<String>, String) _resolveCommand(
|
||||
ServiceConfig svc,
|
||||
String dir,
|
||||
) {
|
||||
final custom = svc.buildCommand.trim();
|
||||
if (custom.isNotEmpty) {
|
||||
if (Platform.isWindows) {
|
||||
return ('cmd', ['/c', custom], 'custom');
|
||||
}
|
||||
return ('/bin/sh', ['-c', custom], 'custom');
|
||||
}
|
||||
|
||||
final hasPom = File('$dir/pom.xml').existsSync();
|
||||
final hasGradle =
|
||||
File('$dir/build.gradle').existsSync() ||
|
||||
File('$dir/build.gradle.kts').existsSync();
|
||||
|
||||
if (hasPom) {
|
||||
final wrapper = _wrapper(dir, 'mvnw', 'mvnw.cmd');
|
||||
final exec = wrapper ?? 'mvn';
|
||||
return (exec, ['-B', '-DskipTests', 'package'], 'Maven');
|
||||
}
|
||||
if (hasGradle) {
|
||||
final wrapper = _wrapper(dir, 'gradlew', 'gradlew.bat');
|
||||
final exec = wrapper ?? 'gradle';
|
||||
return (exec, ['clean', 'build', '-x', 'test'], 'Gradle');
|
||||
}
|
||||
return (null, <String>[], '');
|
||||
}
|
||||
|
||||
String? _wrapper(String dir, String unixName, String winName) {
|
||||
if (Platform.isWindows) {
|
||||
final w = File('$dir/$winName');
|
||||
if (w.existsSync()) return w.path;
|
||||
} else {
|
||||
final w = File('$dir/$unixName');
|
||||
if (w.existsSync()) return w.path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---- jar location ---------------------------------------------------------
|
||||
|
||||
File? _findJar(ServiceConfig svc, String dir, String tool) {
|
||||
final pattern = svc.jarPattern.trim();
|
||||
if (pattern.isNotEmpty) {
|
||||
final full = pattern.startsWith('/') || pattern.contains(':')
|
||||
? pattern
|
||||
: '$dir/$pattern';
|
||||
if (!full.contains('*')) {
|
||||
final f = File(full);
|
||||
return f.existsSync() ? f : null;
|
||||
}
|
||||
final slash = full.lastIndexOf('/');
|
||||
final searchDir = slash >= 0 ? full.substring(0, slash) : dir;
|
||||
final glob = slash >= 0 ? full.substring(slash + 1) : full;
|
||||
return _pickJar(searchDir, glob: glob);
|
||||
}
|
||||
|
||||
// Auto: search the tool's default output dir. For a custom command the
|
||||
// tool is unknown, so try both common locations.
|
||||
final candidates = switch (tool) {
|
||||
'Gradle' => ['$dir/build/libs'],
|
||||
'Maven' => ['$dir/target'],
|
||||
_ => ['$dir/build/libs', '$dir/target'],
|
||||
};
|
||||
for (final c in candidates) {
|
||||
final jar = _pickJar(c);
|
||||
if (jar != null) return jar;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
File? _pickJar(String searchDir, {String glob = '*.jar'}) {
|
||||
final d = Directory(searchDir);
|
||||
if (!d.existsSync()) return null;
|
||||
final re = _globToRegExp(glob);
|
||||
var jars = d
|
||||
.listSync()
|
||||
.whereType<File>()
|
||||
.where((f) => f.path.toLowerCase().endsWith('.jar'))
|
||||
.where((f) => re.hasMatch(_basename(f.path)))
|
||||
.toList();
|
||||
if (jars.isEmpty) return null;
|
||||
|
||||
bool isArtifact(File f) {
|
||||
final n = _basename(f.path).toLowerCase();
|
||||
return !n.contains('-sources') &&
|
||||
!n.contains('-javadoc') &&
|
||||
!n.startsWith('original-') &&
|
||||
!n.endsWith('-plain.jar');
|
||||
}
|
||||
|
||||
final preferred = jars.where(isArtifact).toList();
|
||||
final pool = preferred.isNotEmpty ? preferred : jars;
|
||||
pool.sort((a, b) => b.statSync().modified.compareTo(a.statSync().modified));
|
||||
return pool.first;
|
||||
}
|
||||
|
||||
RegExp _globToRegExp(String glob) {
|
||||
final buf = StringBuffer('^');
|
||||
for (final ch in glob.split('')) {
|
||||
if (ch == '*') {
|
||||
buf.write('.*');
|
||||
} else if ('.[]{}()+-^\$|?\\'.contains(ch)) {
|
||||
buf.write('\\$ch');
|
||||
} else {
|
||||
buf.write(ch);
|
||||
}
|
||||
}
|
||||
buf.write(r'$');
|
||||
return RegExp(buf.toString(), caseSensitive: false);
|
||||
}
|
||||
|
||||
static String _basename(String path) {
|
||||
final i = path.replaceAll('\\', '/').lastIndexOf('/');
|
||||
return i >= 0 ? path.substring(i + 1) : path;
|
||||
}
|
||||
}
|
||||
38
lib/Peach/about/internal/file/ConnectFileStorage.dart
Normal file
38
lib/Peach/about/internal/file/ConnectFileStorage.dart
Normal file
@@ -0,0 +1,38 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../../../comms/Comms.dart' show LogLine;
|
||||
import '../application/ServiceConfig.dart';
|
||||
import '../application/StepTracker.dart';
|
||||
import 'BuildService.dart';
|
||||
|
||||
/// Local-side file work: building the artifact to ship, reading it into
|
||||
/// memory, and packing a web build into a single archive. Kept behind an
|
||||
/// interface so a ViewModel reaches it through the same [DataManager] as
|
||||
/// everything else.
|
||||
abstract class ConnectFileStorage {
|
||||
/// True on desktop, where local builds can run.
|
||||
bool get buildSupported;
|
||||
|
||||
/// Build log lines and step progress, for the console and step list.
|
||||
Stream<LogLine> get buildLogs;
|
||||
Stream<List<DeployStep>> get buildStepStream;
|
||||
|
||||
/// Compiles [service]'s project and locates the produced jar.
|
||||
Future<BuildResult> build(ServiceConfig service);
|
||||
|
||||
/// Compiles [service]'s web project and locates its static output directory.
|
||||
Future<BuildResult> buildWeb(ServiceConfig service);
|
||||
|
||||
/// Cancels a build in flight.
|
||||
void cancelBuild();
|
||||
|
||||
/// Reads a local artifact into memory, ready to upload.
|
||||
Future<Uint8List> readArtifact(String path);
|
||||
|
||||
/// True when [path] exists on this machine.
|
||||
Future<bool> artifactExists(String path);
|
||||
|
||||
/// Packs a directory of static files into gzipped tar bytes, returning the
|
||||
/// archive and the number of files it contains. Runs off the UI isolate.
|
||||
Future<(Uint8List, int)> packageWeb(String directoryPath);
|
||||
}
|
||||
47
lib/Peach/about/internal/file/FileStorage.dart
Normal file
47
lib/Peach/about/internal/file/FileStorage.dart
Normal file
@@ -0,0 +1,47 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../../comms/Comms.dart' show LogLine;
|
||||
import '../application/ServiceConfig.dart';
|
||||
import '../application/StepTracker.dart';
|
||||
import 'BuildService.dart';
|
||||
import 'ConnectFileStorage.dart';
|
||||
import 'WebArchive.dart';
|
||||
|
||||
/// The one concrete [ConnectFileStorage]. Owns the [BuildService] and delegates
|
||||
/// archiving to [WebArchive]; holds no state of its own beyond the builder.
|
||||
class FileStorage implements ConnectFileStorage {
|
||||
final BuildService _builder = BuildService();
|
||||
|
||||
@override
|
||||
bool get buildSupported => BuildService.supported;
|
||||
|
||||
@override
|
||||
Stream<LogLine> get buildLogs => _builder.logs;
|
||||
|
||||
@override
|
||||
Stream<List<DeployStep>> get buildStepStream => _builder.stepStream;
|
||||
|
||||
@override
|
||||
Future<BuildResult> build(ServiceConfig service) => _builder.build(service);
|
||||
|
||||
@override
|
||||
Future<BuildResult> buildWeb(ServiceConfig service) =>
|
||||
_builder.buildWeb(service);
|
||||
|
||||
@override
|
||||
void cancelBuild() => _builder.cancel();
|
||||
|
||||
@override
|
||||
Future<Uint8List> readArtifact(String path) => File(path).readAsBytes();
|
||||
|
||||
@override
|
||||
Future<bool> artifactExists(String path) => File(path).exists();
|
||||
|
||||
@override
|
||||
Future<(Uint8List, int)> packageWeb(String directoryPath) =>
|
||||
// Packaging a large static tree blocks long enough to drop frames, so it
|
||||
// runs on a background isolate.
|
||||
compute(packageWebDir, directoryPath);
|
||||
}
|
||||
34
lib/Peach/about/internal/file/WebArchive.dart
Normal file
34
lib/Peach/about/internal/file/WebArchive.dart
Normal file
@@ -0,0 +1,34 @@
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:archive/archive_io.dart';
|
||||
|
||||
/// Packs a directory of static files into a gzip-compressed tar archive
|
||||
/// (contents at the archive root) for a single-shot upload + remote extract.
|
||||
class WebArchive {
|
||||
/// Returns the .tar.gz bytes of [dir]'s contents and the number of files.
|
||||
static (Uint8List, int) tarGz(Directory dir) {
|
||||
final archive = createArchiveFromDirectory(dir, includeDirName: false);
|
||||
// CRITICAL: on Windows the entry names use backslashes; a POSIX `tar` on
|
||||
// the server would then create single files literally named
|
||||
// `assets\main.js` instead of the `assets/main.js` tree — breaking any
|
||||
// app with subdirectories (Angular/React assets, etc.). Force forward
|
||||
// slashes so the archive is valid on Linux.
|
||||
for (final f in archive.files) {
|
||||
f.name = f.name.replaceAll('\\', '/');
|
||||
}
|
||||
final tar = TarEncoder().encode(archive);
|
||||
final gz = GZipEncoder().encode(tar);
|
||||
final count = archive.files.where((f) => f.isFile).length;
|
||||
// Release the file handles opened while reading the directory so the
|
||||
// source tree isn't left locked (notably on Windows).
|
||||
for (final f in archive.files) {
|
||||
f.closeSync();
|
||||
}
|
||||
return (Uint8List.fromList(gz), count);
|
||||
}
|
||||
}
|
||||
|
||||
/// Top-level entry so packaging can run off the UI isolate via `compute`.
|
||||
(Uint8List, int) packageWebDir(String path) =>
|
||||
WebArchive.tarGz(Directory(path));
|
||||
Reference in New Issue
Block a user