Add app source and tests
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
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 }
|
||||
Reference in New Issue
Block a user