Add app source and tests

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
alvocool
2026-10-11 10:15:30 +03:00
parent 7e246255c2
commit 38881d1fe1
135 changed files with 21057 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
name: internal_memory
description: Adds a persisted field to local storage — injects the getter/setter
pair into ConnectInternalMemory, InternalMemory and AppDataManager in one go,
with JSON handling for objects and lists.
version: 0.1.0+1
environment:
mason: ^0.1.1
vars:
function_name:
type: string
description: The field name in camelCase
prompt: "🖋️ What is the field name? (e.g. activeServer)"
request_object:
type: string
description: The stored type — String, a model, or List<Model>
prompt: "📦 What type is stored? (e.g. ServerProfile / List<ServerProfile> / String)"
secure:
type: boolean
description: Store in the OS keystore instead of shared_preferences
default: false
prompt: "🔐 Is this a secret (password/token)?"

View File

@@ -0,0 +1,293 @@
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:mason/mason.dart';
/// Adds one persisted field to local storage by injecting the same
/// getter/setter pair into all three files that have to agree:
///
/// ConnectInternalMemory → the contract
/// InternalMemory → the implementation (+ its storage key)
/// AppDataManager → the delegation
///
/// Doing this by hand drifts: it's easy to add the pair to two of the three and
/// only find out at compile time, and the secure-key constant should never be
/// hand-picked.
Future<void> run(HookContext context) async {
final logger = context.logger;
final functionName = (context.vars['function_name'] ?? '') as String;
final requestObject = (context.vars['request_object'] ?? '') as String;
final secure = (context.vars['secure'] ?? false) as bool;
if (functionName.isEmpty || requestObject.isEmpty) {
logger.err('Both a field name and a type are required.');
return;
}
final pName = functionName.pascalCase;
final keyConst = '_${functionName.camelCase}Key';
final keyValue = secure
? _secureKeyValue(functionName)
: 'peach.${functionName.snakeCase}';
final isList = requestObject.startsWith('List<');
final isString = requestObject.toLowerCase() == 'string';
final baseType = isList
? requestObject.substring(5, requestObject.length - 1)
: requestObject;
const memoryDir = 'lib/Peach/memory';
const connectPath = '$memoryDir/ConnectInternalMemory.dart';
const memoryPath = '$memoryDir/InternalMemory.dart';
const managerPath = 'lib/Peach/informatics/AppDataManager.dart';
// Model types need importing into all three files; String doesn't.
String? modelImportFor(String fromFile) =>
isString ? null : _relativeImport(baseType, fromFile);
final duplicate = 'get$pName()';
// ── 1. the contract ────────────────────────────────────────────────────────
final contract = '''
Future<$requestObject> get$pName();
Future set$pName($requestObject data);''';
_inject(
connectPath,
contract,
duplicate: duplicate,
importLine: modelImportFor(connectPath),
logger: logger,
);
// ── 2. the implementation ──────────────────────────────────────────────────
final read = secure
? "await SecretStore.read($keyConst)"
: "(await SharedPreferences.getInstance()).getString($keyConst)";
final write = secure
? (String v) => "await SecretStore.write($keyConst, $v);"
: (String v) =>
"await (await SharedPreferences.getInstance()).setString($keyConst, $v);";
String getBody;
String setBody;
if (isString) {
getBody = ' return $read ?? \'\';';
setBody = ' ${write('data')}';
} else if (isList) {
getBody = '''
final stored = $read;
if (stored == null || stored.isEmpty) return [];
return (jsonDecode(stored) as List)
.map((item) => $baseType.fromJson(item as Map<String, dynamic>))
.toList();''';
setBody = '''
final encoded = jsonEncode(data.map((item) => item.toJson()).toList());
${write('encoded')}''';
} else {
getBody = '''
final stored = $read;
if (stored == null || stored.isEmpty) return null;
return $baseType.fromJson(jsonDecode(stored) as Map<String, dynamic>);''';
setBody = '''
${write('jsonEncode(data.toJson())')}''';
}
// A single object can legitimately be absent, so its getter is nullable.
final returnType = (isString || isList) ? requestObject : '$requestObject?';
final implementation = '''
@override
Future<$returnType> get$pName() async {
$getBody
}
@override
Future set$pName($requestObject data) async {
$setBody
}''';
_injectImplementation(
memoryPath,
keyLine: " static const $keyConst = '$keyValue';",
methods: implementation,
duplicate: duplicate,
importLine: modelImportFor(memoryPath),
needsConvert: !isString,
needsSecretStore: secure,
needsPrefs: !secure,
logger: logger,
);
// Keep the contract's nullability in step with the implementation.
if (!isString && !isList) {
_replaceInFile(
connectPath,
'Future<$requestObject> get$pName();',
'Future<$requestObject?> get$pName();',
);
}
// ── 3. the delegation ──────────────────────────────────────────────────────
final delegation = '''
@override
Future<$returnType> get$pName() => memory.get$pName();
@override
Future set$pName($requestObject data) => memory.set$pName(data);''';
_inject(
managerPath,
delegation,
duplicate: duplicate,
importLine: modelImportFor(managerPath),
logger: logger,
);
try {
await Process.run('dart', ['format', 'lib/Peach/'], runInShell: true);
} catch (_) {
// Formatting is a nicety; a missing dart on PATH shouldn't fail the run.
}
logger.success(
'✨ $pName wired through ConnectInternalMemory, InternalMemory and '
'AppDataManager.');
}
/// A random-suffixed key, so a stored secret isn't sitting under a guessable
/// name in the OS keystore.
String _secureKeyValue(String field) {
final random = Random.secure();
final bytes = List<int>.generate(12, (_) => random.nextInt(256));
final suffix =
base64Url.encode(bytes).replaceAll('=', '').substring(0, 16);
return 'peach.${field.snakeCase}.$suffix';
}
/// Finds [className]'s file and builds an import relative to [fromFile] — the
/// project uses relative imports inside lib/Peach, not package: ones.
String? _relativeImport(String className, String fromFile) {
final dir = Directory('lib');
if (!dir.existsSync()) return null;
for (final entity in dir.listSync(recursive: true)) {
if (entity is! File || !entity.path.endsWith('.dart')) continue;
final content = entity.readAsStringSync();
// Built from raw parts: in a normal string `\s` would collapse to a plain
// `s`, quietly turning this into a pattern that never matches.
final declaration =
RegExp(r'(class|enum)\s+' + RegExp.escape(className) + r'\b');
if (!declaration.hasMatch(content)) continue;
final target = entity.path.replaceAll(r'\', '/');
final from = fromFile.replaceAll(r'\', '/');
return "import '${_relativePath(from, target)}';";
}
return null;
}
String _relativePath(String fromFile, String toFile) {
final fromParts = fromFile.split('/')..removeLast();
final toParts = toFile.split('/');
var shared = 0;
while (shared < fromParts.length &&
shared < toParts.length - 1 &&
fromParts[shared] == toParts[shared]) {
shared++;
}
final up = List.filled(fromParts.length - shared, '..');
final down = toParts.sublist(shared);
return [...up, ...down].join('/');
}
/// Appends [content] just before the class's closing brace.
void _inject(
String path,
String content, {
required String duplicate,
required Logger logger,
String? importLine,
}) {
final file = File(path);
if (!file.existsSync()) {
logger.err('Missing $path — is this being run from the project root?');
return;
}
var source = file.readAsStringSync();
if (source.contains(duplicate)) {
logger.info('$path already has $duplicate — skipped.');
return;
}
source = _addImport(source, importLine);
final close = source.lastIndexOf('}');
if (close == -1) return;
file.writeAsStringSync(
'${source.substring(0, close).trimRight()}\n\n$content\n${source.substring(close)}');
}
/// Like [_inject], but also puts the storage key at the top of the class and
/// pulls in whatever the generated body needs.
void _injectImplementation(
String path, {
required String keyLine,
required String methods,
required String duplicate,
required bool needsConvert,
required bool needsSecretStore,
required bool needsPrefs,
required Logger logger,
String? importLine,
}) {
final file = File(path);
if (!file.existsSync()) {
logger.err('Missing $path — is this being run from the project root?');
return;
}
var source = file.readAsStringSync();
if (source.contains(duplicate)) {
logger.info('$path already has $duplicate — skipped.');
return;
}
source = _addImport(source, importLine);
if (needsConvert) source = _addImport(source, "import 'dart:convert';");
if (needsPrefs) {
source = _addImport(
source, "import 'package:shared_preferences/shared_preferences.dart';");
}
if (needsSecretStore) {
source = _addImport(source, "import 'SecretStore.dart';");
}
final classOpen = source.indexOf('{', source.indexOf('class InternalMemory'));
if (classOpen != -1) {
source =
'${source.substring(0, classOpen + 1)}\n$keyLine\n${source.substring(classOpen + 1)}';
}
final close = source.lastIndexOf('}');
if (close == -1) return;
file.writeAsStringSync(
'${source.substring(0, close).trimRight()}\n\n$methods\n${source.substring(close)}');
}
String _addImport(String source, String? importLine) {
if (importLine == null || source.contains(importLine)) return source;
return '$importLine\n$source';
}
void _replaceInFile(String path, String from, String to) {
final file = File(path);
if (!file.existsSync()) return;
final source = file.readAsStringSync();
if (!source.contains(from)) return;
file.writeAsStringSync(source.replaceFirst(from, to));
}

View File

@@ -0,0 +1,7 @@
name: internal_memory_hooks
environment:
sdk: ">=3.0.0 <4.0.0"
dependencies:
mason: ^0.1.0-dev

View File

@@ -0,0 +1,5 @@
/// The callbacks [View{{screen}}] uses to hand results back to the screen, so
/// the ViewModel never touches widgets.
abstract class Connect{{screen}} {
// void onSomethingLoaded(SomeModel data);
}

View File

@@ -0,0 +1,24 @@
import '../parent/ParentViewModel.dart';
import 'Connect{{screen}}.dart';
class View{{screen}} extends ParentViewModel {
Connect{{screen}} connection;
View{{screen}}(super.context, this.connection);
// Every server-bound call follows this shape: guard with canReach, show the
// loading overlay, hand the result back through `connection`, and route any
// failure to handleError.
//
// void loadSomething(ServerProfile server) async {
// if (!await canReach(server, () => loadSomething(server))) return;
// showLoading('Loading…');
// try {
// final result = await getDataManager().someCall(server);
// closeLoading();
// connection.onSomethingLoaded(result);
// } catch (error) {
// handleError(error, () => loadSomething(server), () {}, 'Retry');
// }
// }
}

View File

@@ -0,0 +1,10 @@
import 'package:flutter/cupertino.dart';
import '{{screen}}State.dart';
class {{screen}} extends StatefulWidget {
const {{screen}}({super.key});
@override
State<{{screen}}> createState() => {{screen}}State();
}

View File

@@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import 'package:stacked/stacked.dart';
import '../../designs/Responsive.dart';
import 'Connect{{screen}}.dart';
import 'View{{screen}}.dart';
import '{{screen}}.dart';
class {{screen}}State extends State<{{screen}}> implements Connect{{screen}} {
View{{screen}}? _model;
@override
Widget build(BuildContext context) {
return ViewModelBuilder<View{{screen}}>.reactive(
viewModelBuilder: () => View{{screen}}(context, this),
onViewModelReady: _onReady,
builder: (context, viewModel, child) => Scaffold(
body: LayoutBuilder(
builder: (context, constraints) => Responsive(
mobile: _mobileView(constraints),
tablet: _tabletView(constraints),
desktop: _desktopView(constraints),
),
),
),
);
}
void _onReady(View{{screen}} model) {
_model = model;
// onViewModelReady fires mid-build; a synchronous setState from the
// first load would dirty this State while it is still being built.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _initiate();
});
}
/// The single entry point for whatever this screen loads on open. Kick off
/// every initial load from here — never inline in `onViewModelReady`.
Future<void> _initiate() async {
assert(_model != null, 'View{{screen}} should be ready before _initiate');
// _model?.loadSomething();
}
// ── named handlers ─────────────────────────────────────────────────────────
// ── Connect{{screen}} ──────────────────────────────────────────────────────
// ── layouts ────────────────────────────────────────────────────────────────
Widget _mobileView(BoxConstraints constraints) {
return const SizedBox();
}
Widget _tabletView(BoxConstraints constraints) {
return const SizedBox();
}
Widget _desktopView(BoxConstraints constraints) {
return const SizedBox();
}
}

View File

@@ -0,0 +1,21 @@
name: mvvc_template
description: Generates a Peach screen as the mandatory four files — the
StatefulWidget shell, its State (UI + Connect implementation), the ViewModel
(logic, talks to the DataManager) and the Connect interface between them.
version: 0.1.0+1
environment:
mason: ^0.1.1
vars:
project:
type: string
description: The top-level folder under lib/ (also the package name)
default: Peach
prompt: What is your project name?
screen:
type: string
description: The screen name in PascalCase, e.g. ServerDetail
default: NewScreen
prompt: What is your screen name?

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

View 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';
}
}

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

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

View 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';
}
}

View 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',
];
}
}

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

View 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 }

View File

@@ -0,0 +1,2 @@
/// Severity of an in-app notification sheet (`showApplicationNotification`).
enum NotificationType { success, info, warning, error }

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

View 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(),
);
}

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

View File

@@ -0,0 +1,2 @@
/// How loudly a [PeachError] should be presented.
enum Severity { message, warning, critical }

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

View 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 }

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

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

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

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

2221
lib/Peach/comms/Comms.dart Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,223 @@
import 'dart:typed_data';
import '../about/external/data/Docker.dart';
import '../about/external/data/Health.dart';
import '../about/external/data/Kafka.dart';
import '../about/external/data/Redis.dart';
import '../about/internal/application/ServerProfile.dart';
import '../about/internal/application/ServiceConfig.dart';
import '../about/internal/application/StepTracker.dart';
import 'Comms.dart';
/// The transport contract: everything Peach can ask a remote server to do.
/// In the Autoreceptive reference this is the HTTP surface; here the wire is
/// SSH, but the role is identical — one abstract description of every remote
/// call, implemented by exactly one class ([SshService]) and reached only
/// through the [DataManager].
abstract class ConnectComms {
// ── session ───────────────────────────────────────────────────────────────
bool get isConnected;
bool get canRevert;
Stream<LogLine> get logs;
Stream<TransferProgress?> get transferProgress;
Stream<List<DeployStep>> get stepStream;
Future<void> connect(ServerProfile s);
void requestCancel();
void disconnect();
void dispose();
// ── deployment ────────────────────────────────────────────────────────────
Future<DeployResult> deploy({
required ServerProfile server,
required ServiceConfig service,
required Uint8List jarBytes,
required String jarLabel,
});
Future<DeployResult> deployWeb({
required ServerProfile server,
required ServiceConfig service,
required Uint8List archiveBytes,
required int fileCount,
});
Future<DeployResult> restartOnly(ServerProfile server, ServiceConfig service);
Future<DeployResult> revert();
Future<void> discardBackup();
// ── provisioning ──────────────────────────────────────────────────────────
Future<DeployResult> installSystemdUnit({
required ServerProfile server,
required String unitName,
required String unitContent,
bool enable = true,
bool startNow = true,
});
Future<String> detectPackageManager(ServerProfile server);
Future<Map<String, String>> probeEnvironment(
ServerProfile server,
List<String> detectByKey,
);
Future<DeployResult> installEnvironment({
required ServerProfile server,
required String command,
required List<String> units,
});
Future<DeployResult> installPackages({
required ServerProfile server,
required String command,
String? enableUnit,
});
// ── service & health ──────────────────────────────────────────────────────
Future<HealthResult> checkHealth(ServerProfile server, ServiceConfig service);
Future<String> serviceStatus(ServerProfile server, ServiceConfig service);
Future<DeployResult> serviceControl(
ServerProfile server,
ServiceConfig service,
String action,
);
Future<List<String>> nginxRoots(ServerProfile server);
// ── logs ──────────────────────────────────────────────────────────────────
Future<void> fetchLogs(
ServerProfile server,
ServiceConfig service, {
DateTime? since,
DateTime? until,
});
Future<void> followLogs(ServerProfile server, ServiceConfig service);
void stopLogs();
// ── redis ─────────────────────────────────────────────────────────────────
Future<(bool, String)> redisPing(
ServerProfile server, {
String host,
int port,
String pass,
int db,
});
Future<String> redisConfigPassword(ServerProfile server);
Future<int> redisDbSize(
ServerProfile server, {
String host,
int port,
String pass,
int db,
});
Future<(int, Map<int, int>)> redisDatabases(
ServerProfile server, {
String host,
int port,
String pass,
});
Future<List<(String, String)>> redisKeysTyped(
ServerProfile server, {
String pattern,
String filterType,
int limit,
String host,
int port,
String pass,
int db,
});
Future<List<String>> redisKeys(
ServerProfile server, {
String pattern,
int limit,
String host,
int port,
String pass,
int db,
});
Future<RedisEntry> redisGet(
ServerProfile server,
String key, {
String host,
int port,
String pass,
int db,
});
// ── docker ────────────────────────────────────────────────────────────────
Future<DockerStatus> dockerStatus(ServerProfile server);
Future<List<DockerContainer>> dockerPs(ServerProfile server, {bool all});
Future<String> dockerAction(ServerProfile server, String id, String action);
Future<String> dockerLogs(ServerProfile server, String id, {int tail});
Future<String> dockerTest(ServerProfile server);
Future<(bool, String)> dockerRun(ServerProfile server, DockerRunSpec spec);
// ── kafka ─────────────────────────────────────────────────────────────────
Future<KafkaStatus> kafkaDetect(ServerProfile server);
Future<String> kafkaTest(
ServerProfile server, {
required String binDir,
required String suffix,
required String bootstrap,
});
Future<List<KafkaTopic>> kafkaTopics(
ServerProfile server, {
required String binDir,
required String suffix,
required String bootstrap,
bool includeInternal,
});
Future<List<KafkaConsumerGroup>> kafkaGroups(
ServerProfile server, {
required String binDir,
required String suffix,
required String bootstrap,
});
Future<KafkaCluster> kafkaCluster(
ServerProfile server, {
required String binDir,
required String suffix,
required String bootstrap,
});
// ── remote filesystem ─────────────────────────────────────────────────────
Future<List<RemoteEntry>> listDir(ServerProfile server, String path);
Future<String> resolveHome(ServerProfile server);
Future<void> uploadFile(
ServerProfile server,
Uint8List bytes,
String remotePath,
);
Future<Uint8List> downloadFile(ServerProfile server, String remotePath);
Future<void> deleteRemote(
ServerProfile server,
String remotePath,
bool isDir,
);
}

View File

@@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import 'package:page_transition/page_transition.dart';
import '../about/internal/application/NavigatorType.dart';
/// Every screen transition in the app funnels through here. Destinations are
/// widget instances, not route names — there is no route table. Keeping one
/// choke point means a guard (session, device, connectivity) can be applied to
/// all routing by editing a single method.
class PeachNavigation {
void navigateToPage(NavigatorType type, dynamic path, BuildContext context) {
_runSec(context, type, path);
}
Future _runSec(BuildContext context, NavigatorType type, dynamic path) async {
switch (type) {
case NavigatorType.openFully:
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (BuildContext context) => path),
);
break;
case NavigatorType.justOpen:
Navigator.push(
context,
PageTransition(
type: PageTransitionType.size,
alignment: Alignment.center,
opaque: true,
child: path,
),
);
break;
case NavigatorType.replaceCurrent:
Navigator.pushReplacement(
context,
PageTransition(
type: PageTransitionType.scale,
alignment: Alignment.center,
opaque: true,
curve: Curves.ease,
duration: const Duration(microseconds: 9000),
child: path,
),
);
break;
case NavigatorType.makeNewMain:
Navigator.pushAndRemoveUntil(
context,
PageTransition(
type: PageTransitionType.fade,
alignment: Alignment.center,
opaque: true,
child: path,
),
ModalRoute.withName('/'),
);
break;
}
}
/// Opens [path] and returns whatever it pops with — the reload-on-return
/// channel: a child calls `Navigator.pop(context, true)` and the parent
/// refreshes on a truthy result.
Future<dynamic> navigateToPageWithData(
dynamic path,
BuildContext context,
) async {
return await Navigator.push(
context,
MaterialPageRoute(builder: (context) => path),
);
}
}

View File

@@ -0,0 +1,23 @@
import 'package:flutter/material.dart';
/// App-wide theme mode. `main.dart` listens to this and rebuilds MaterialApp;
/// any screen can set it. Persisting the choice is the ViewModel's job — this
/// only carries the live value.
class ThemeController {
ThemeController._();
static final ValueNotifier<ThemeMode> mode = ValueNotifier<ThemeMode>(
ThemeMode.dark,
);
/// The stored string ('dark' | 'light' | 'system') for the current mode.
static String get name => mode.value.name;
static void apply(String stored) {
mode.value = switch (stored) {
'light' => ThemeMode.light,
'system' => ThemeMode.system,
_ => ThemeMode.dark,
};
}
}

View File

@@ -0,0 +1,224 @@
import 'package:flutter/material.dart';
import '../about/internal/application/TextType.dart';
import '../about/internal/application/DeploymentRecord.dart';
import '../utils/Colors.dart';
import 'text/Text.dart';
enum _Range { week, month, year }
class _Bucket {
final String label;
final int count;
_Bucket(this.label, this.count);
}
/// "Compute Activity" style card: a bar chart of deployment counts over time,
/// with the peak column highlighted, and a Week / Month / Year toggle.
class ActivityChart extends StatefulWidget {
final List<DeploymentRecord> history;
const ActivityChart({super.key, required this.history});
@override
State<ActivityChart> createState() => _ActivityChartState();
}
class _ActivityChartState extends State<ActivityChart> {
_Range _range = _Range.week;
List<_Bucket> _buckets() {
final now = DateTime.now();
final h = widget.history;
int between(DateTime a, DateTime b) =>
h.where((r) => !r.time.isBefore(a) && r.time.isBefore(b)).length;
const wd = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const mo = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
switch (_range) {
case _Range.week:
final today = DateTime(now.year, now.month, now.day);
return List.generate(7, (i) {
final d = today.subtract(Duration(days: 6 - i));
return _Bucket(
wd[d.weekday - 1],
between(d, d.add(const Duration(days: 1))),
);
});
case _Range.month:
// last 6 weeks
final startOfWeek = DateTime(
now.year,
now.month,
now.day,
).subtract(Duration(days: now.weekday - 1));
return List.generate(6, (i) {
final s = startOfWeek.subtract(Duration(days: (5 - i) * 7));
final e = s.add(const Duration(days: 7));
return _Bucket('${s.month}/${s.day}', between(s, e));
});
case _Range.year:
// last 6 months
return List.generate(6, (i) {
final m = DateTime(now.year, now.month - (5 - i), 1);
final next = DateTime(m.year, m.month + 1, 1);
return _Bucket(mo[m.month - 1], between(m, next));
});
}
}
@override
Widget build(BuildContext context) {
final jp = context.jp;
final buckets = _buckets();
final max = buckets.fold(0, (m, b) => b.count > m ? b.count : m);
final peakIdx = buckets.indexWhere((b) => b.count == max && max > 0);
final total = buckets.fold(0, (n, b) => n + b.count);
return Container(
padding: const EdgeInsets.all(18),
decoration: PeachTheme.panel(context),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
text(
'Deployment activity',
16,
TextType.Bold,
color: jp.text,
weight: FontWeight.w800,
),
const SizedBox(height: 2),
text(
'$total deploys this period',
12.5,
TextType.Regular,
color: jp.textDim,
weight: FontWeight.w400,
),
],
),
),
_toggle(jp),
],
),
const SizedBox(height: 20),
SizedBox(
height: 190,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (var i = 0; i < buckets.length; i++)
Expanded(child: _bar(jp, buckets[i], max, i == peakIdx)),
],
),
),
],
),
);
}
Widget _bar(JpColors jp, _Bucket b, int max, bool peak) {
final frac = max == 0 ? 0.0 : b.count / max;
final h = 12 + frac * 130;
return Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
text(
'${b.count}',
12,
TextType.Regular,
color: peak ? PeachTheme.seed : jp.textDim,
weight: FontWeight.w800,
),
const SizedBox(height: 6),
Container(
width: 26,
height: h,
decoration: BoxDecoration(
gradient: peak ? PeachTheme.brandGradient : null,
color: peak ? null : jp.surface2,
borderRadius: BorderRadius.circular(9),
boxShadow: peak
? PeachTheme.glow(PeachTheme.seed, opacity: 0.35, blur: 16)
: null,
),
),
const SizedBox(height: 8),
text(
b.label,
11,
TextType.Regular,
color: peak ? jp.text : jp.textFaint,
maxLines: 1,
overflow: TextOverflow.clip,
weight: peak ? FontWeight.w700 : FontWeight.w500,
),
],
);
}
Widget _toggle(JpColors jp) {
Widget seg(_Range r, String label) {
final active = _range == r;
return GestureDetector(
onTap: () => setState(() => _range = r),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7),
decoration: BoxDecoration(
color: active ? jp.surface : Colors.transparent,
borderRadius: BorderRadius.circular(9),
boxShadow: active
? [
BoxShadow(
color: Colors.black.withValues(alpha: 0.08),
blurRadius: 6,
offset: const Offset(0, 2),
),
]
: null,
),
child: text(
label,
12.5,
TextType.Regular,
color: active ? jp.text : jp.textDim,
weight: active ? FontWeight.w700 : FontWeight.w500,
),
),
);
}
return Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: jp.surface2,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
seg(_Range.week, 'Week'),
seg(_Range.month, 'Month'),
seg(_Range.year, 'Year'),
],
),
);
}
}

View File

@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import '../utils/Colors.dart';
/// App-wide ambient backdrop: a solid base with soft, blurred brand-tinted
/// glows sitting behind the (transparent) scaffolds. Deliberately restrained —
/// the warm vermilion anchors the brand, a cool counter-tone adds depth.
class Backdrop extends StatelessWidget {
final Widget child;
const Backdrop({super.key, required this.child});
@override
Widget build(BuildContext context) {
final jp = context.jp;
final dark = Theme.of(context).brightness == Brightness.dark;
return DecoratedBox(
decoration: BoxDecoration(color: jp.bg),
child: Stack(
children: [
// Warm brand wash, upper-left.
Positioned(
top: -220,
left: -180,
child: _glow(PeachTheme.seed, dark ? 0.10 : 0.045, 560),
),
// Cool counter-tone, lower-right, for depth.
Positioned(
bottom: -240,
right: -190,
child: _glow(PeachTheme.violet, dark ? 0.10 : 0.04, 560),
),
// Faint high accent to lift the top of the canvas.
Positioned(
top: -320,
right: 40,
child: _glow(PeachTheme.accent, dark ? 0.05 : 0.02, 420),
),
Positioned.fill(child: child),
],
),
);
}
Widget _glow(Color c, double opacity, double size) => IgnorePointer(
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
c.withValues(alpha: opacity),
c.withValues(alpha: 0),
],
),
),
),
);
}

View File

@@ -0,0 +1,152 @@
import 'package:flutter/material.dart';
import '../about/internal/application/TextType.dart';
import '../about/internal/application/ServerProfile.dart';
import '../about/internal/application/DeploymentRecord.dart';
import '../utils/Colors.dart';
import 'text/Text.dart';
/// Warm gradient "insights" card that surfaces computed tips from the data.
class InsightsCard extends StatelessWidget {
final List<ServerProfile> servers;
final List<DeploymentRecord> history;
final VoidCallback onViewHistory;
const InsightsCard({
super.key,
required this.servers,
required this.history,
required this.onViewHistory,
});
List<(IconData, String)> _tips() {
final tips = <(IconData, String)>[];
final failed = history
.where((h) => h.status == DeployStatus.failure)
.length;
final totalServices = servers.fold(0, (n, s) => n + s.services.length);
if (history.isEmpty) {
tips.add((
Icons.rocket_launch,
'Add a service and run your first deploy to unlock insights.',
));
}
if (failed > 0) {
tips.add((
Icons.warning_amber,
'$failed failed ${failed == 1 ? 'deploy' : 'deploys'} — open the service logs to investigate.',
));
}
// Busiest server
if (history.isNotEmpty) {
final counts = <String, int>{};
for (final h in history) {
counts[h.serverName] = (counts[h.serverName] ?? 0) + 1;
}
final busiest = counts.entries.reduce(
(a, b) => a.value >= b.value ? a : b,
);
tips.add((
Icons.trending_up,
'${busiest.key} is your busiest server (${busiest.value} deploys).',
));
}
final emptyServers = servers.where((s) => s.services.isEmpty).length;
if (emptyServers > 0) {
tips.add((
Icons.dns,
'$emptyServers ${emptyServers == 1 ? 'server has' : 'servers have'} no services yet — add one to start deploying.',
));
}
if (totalServices > 0) {
tips.add((
Icons.health_and_safety,
'Open Health to check the live status of your $totalServices ${totalServices == 1 ? 'service' : 'services'}.',
));
}
return tips.take(3).toList();
}
@override
Widget build(BuildContext context) {
const ink = PeachTheme.ink;
final tips = _tips();
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
gradient: PeachTheme.peachGradient,
borderRadius: BorderRadius.circular(20),
boxShadow: PeachTheme.glow(PeachTheme.seed, opacity: 0.18, blur: 30),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.auto_awesome, size: 18, color: ink),
const SizedBox(width: 8),
text(
'Insights',
16,
TextType.Bold,
color: ink,
weight: FontWeight.w800,
),
],
),
const SizedBox(height: 14),
for (final (icon, tip) in tips) ...[
_tip(icon, tip),
const SizedBox(height: 8),
],
const SizedBox(height: 4),
Align(
alignment: Alignment.centerRight,
child: FilledButton.icon(
onPressed: onViewHistory,
icon: const Icon(Icons.timeline, size: 16),
label: const Text('View history'),
style: FilledButton.styleFrom(
backgroundColor: ink,
foregroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
),
),
],
),
);
}
Widget _tip(IconData icon, String tip) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 11),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.55),
borderRadius: BorderRadius.circular(12),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 16, color: PeachTheme.ink.withValues(alpha: 0.7)),
const SizedBox(width: 10),
Expanded(
child: text(
tip,
12.5,
TextType.Regular,
color: PeachTheme.ink.withValues(alpha: 0.85),
height: 1.35,
weight: FontWeight.w600,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,249 @@
import 'package:flutter/material.dart';
import '../about/internal/application/TextType.dart';
import '../comms/Comms.dart' show LogLine, LogLevel;
import '../utils/Colors.dart';
import 'text/Text.dart';
/// Reusable terminal-style log console. It stays dark in both light and dark
/// app themes (like a code block), with colored, prefixed lines and auto-scroll.
class LogConsole extends StatefulWidget {
final List<LogLine> logs;
final double? height;
final bool live;
final VoidCallback? onClear;
final VoidCallback? onCopy;
/// When the caller is showing a filtered subset, the size of the unfiltered
/// backlog — rendered as "shown / total" in the header.
final int? totalCount;
/// Replaces the "Output will appear here." placeholder.
final String? emptyMessage;
const LogConsole({
super.key,
required this.logs,
this.height = 300,
this.live = false,
this.onClear,
this.onCopy,
this.totalCount,
this.emptyMessage,
});
@override
State<LogConsole> createState() => _LogConsoleState();
}
class _LogConsoleState extends State<LogConsole> {
final _scroll = ScrollController();
int _last = 0;
@override
void didUpdateWidget(LogConsole old) {
super.didUpdateWidget(old);
if (widget.logs.length != _last) {
_last = widget.logs.length;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scroll.hasClients) {
_scroll.animateTo(
_scroll.position.maxScrollExtent,
duration: const Duration(milliseconds: 120),
curve: Curves.easeOut,
);
}
});
}
}
@override
void dispose() {
_scroll.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// A null height means the caller has already constrained us (e.g. inside
// an Expanded), so we fill whatever we're given.
return Container(
height: widget.height,
decoration: BoxDecoration(
color: context.jp.consoleBg,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: ConsoleColors.frame),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: const BoxDecoration(
border: Border(bottom: BorderSide(color: ConsoleColors.frame)),
),
child: Row(
children: [
const Icon(Icons.terminal, size: 16, color: PeachTheme.seed),
const SizedBox(width: 8),
text(
'LOGS',
12,
TextType.Regular,
color: ConsoleColors.header,
letterSpacing: 1.5,
weight: FontWeight.w700,
),
if (widget.live) ...[
const SizedBox(width: 10),
const _LivePill(),
],
const SizedBox(width: 10),
text(
widget.totalCount == null
? '${widget.logs.length}'
: '${widget.logs.length} / ${widget.totalCount}',
11,
TextType.Regular,
color: ConsoleColors.muted,
weight: FontWeight.w400,
),
const Spacer(),
if (widget.onCopy != null)
IconButton(
tooltip: 'Copy',
iconSize: 16,
color: ConsoleColors.header,
onPressed: widget.logs.isEmpty ? null : widget.onCopy,
icon: const Icon(Icons.copy_all),
),
if (widget.onClear != null)
_ClearButton(
enabled: widget.logs.isNotEmpty,
onTap: widget.onClear!,
),
],
),
),
Expanded(
child: widget.logs.isEmpty
? Center(
child: text(
widget.emptyMessage ?? 'Output will appear here.',
14,
TextType.Regular,
color: ConsoleColors.disabled,
weight: FontWeight.w400,
),
)
: ListView.builder(
controller: _scroll,
padding: const EdgeInsets.all(12),
itemCount: widget.logs.length,
itemBuilder: (_, i) => _row(widget.logs[i]),
),
),
],
),
);
}
Widget _row(LogLine l) {
final (color, prefix) = switch (l.level) {
LogLevel.error => (ConsoleColors.error, '✖ '),
LogLevel.warn => (ConsoleColors.warn, '! '),
LogLevel.success => (ConsoleColors.success, '✓ '),
LogLevel.step => (ConsoleColors.step, '➤ '),
LogLevel.info => (ConsoleColors.info, '· '),
LogLevel.remote => (ConsoleColors.body, ' '),
};
return Padding(
padding: const EdgeInsets.symmetric(vertical: 1),
child: SelectableText(
'$prefix${l.text}',
style: TextStyle(
fontFamily: 'monospace',
fontSize: 12.5,
height: 1.35,
color: color,
),
),
);
}
}
/// Labeled "Clear" control: empties the on-screen backlog. New lines keep
/// streaming in afterwards.
class _ClearButton extends StatelessWidget {
final bool enabled;
final VoidCallback onTap;
const _ClearButton({required this.enabled, required this.onTap});
@override
Widget build(BuildContext context) {
return Tooltip(
message: 'Clear the log backlog — new lines keep streaming',
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: enabled ? onTap : null,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(color: ConsoleColors.frameRaised),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.delete_sweep,
size: 14,
color: enabled ? ConsoleColors.header : ConsoleColors.disabled,
),
const SizedBox(width: 5),
text(
'Clear',
11.5,
TextType.Regular,
color: enabled ? ConsoleColors.header : ConsoleColors.disabled,
weight: FontWeight.w700,
),
],
),
),
),
);
}
}
class _LivePill extends StatelessWidget {
const _LivePill();
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: ConsoleColors.live.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.fiber_manual_record,
size: 9,
color: ConsoleColors.live,
),
const SizedBox(width: 4),
text(
'LIVE',
10,
TextType.Regular,
color: ConsoleColors.live,
weight: FontWeight.w700,
),
],
),
);
}
}

View File

@@ -0,0 +1,193 @@
import 'package:flutter/material.dart';
import '../about/internal/application/TextType.dart';
import '../about/internal/application/DeploymentRecord.dart';
import '../utils/Colors.dart';
import 'text/Text.dart';
import '../utils/CommonUtils.dart';
/// Deployment metrics: success-rate ring plus stat tiles.
class MetricsPanel extends StatelessWidget {
final List<DeploymentRecord> history;
const MetricsPanel({super.key, required this.history});
@override
Widget build(BuildContext context) {
final jp = context.jp;
final total = history.length;
final success = history
.where((h) => h.status == DeployStatus.success)
.length;
final failed = history
.where((h) => h.status == DeployStatus.failure)
.length;
final reverted = history
.where((h) => h.status == DeployStatus.reverted)
.length;
final attempts = success + failed;
final rate = attempts == 0 ? 0.0 : success / attempts;
final last = history.isNotEmpty ? history.first : null;
return Container(
padding: const EdgeInsets.all(16),
decoration: PeachTheme.panel(context, glowColor: PeachTheme.seed),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
text(
'METRICS',
12,
TextType.Regular,
color: jp.textDim,
letterSpacing: 1.2,
weight: FontWeight.w700,
),
const SizedBox(height: 14),
Row(
children: [
_rateRing(jp, rate, attempts),
const SizedBox(width: 18),
Expanded(
child: Wrap(
spacing: 10,
runSpacing: 10,
children: [
_tile(jp, 'Total', '$total', jp.text),
_tile(jp, 'Success', '$success', PeachTheme.seed),
_tile(jp, 'Failed', '$failed', PeachTheme.danger),
_tile(jp, 'Reverted', '$reverted', PeachTheme.warn),
],
),
),
],
),
if (last != null) ...[
const SizedBox(height: 14),
Divider(height: 1, color: jp.border),
const SizedBox(height: 12),
Row(
children: [
Icon(Icons.schedule, size: 14, color: jp.textFaint),
const SizedBox(width: 6),
Expanded(
child: text(
'Last: ${last.serverName} · ${last.serviceLabel} · ${timeAgo(last.time)}',
12.5,
TextType.Regular,
color: jp.textDim,
overflow: TextOverflow.ellipsis,
weight: FontWeight.w400,
),
),
_statusDot(last.status),
],
),
],
],
),
);
}
Widget _rateRing(JpColors jp, double rate, int attempts) {
final pct = (rate * 100).round();
final color = rate >= 0.8
? PeachTheme.seed
: rate >= 0.5
? PeachTheme.warn
: PeachTheme.danger;
return SizedBox(
width: 84,
height: 84,
child: Stack(
alignment: Alignment.center,
children: [
SizedBox(
width: 84,
height: 84,
child: CircularProgressIndicator(
value: attempts == 0 ? 0 : rate,
strokeWidth: 8,
backgroundColor: jp.surface2,
valueColor: AlwaysStoppedAnimation(color),
strokeCap: StrokeCap.round,
),
),
Column(
mainAxisSize: MainAxisSize.min,
children: [
text(
attempts == 0 ? '—' : '$pct%',
20,
TextType.Regular,
color: color,
weight: FontWeight.w800,
),
text(
'SUCCESS',
8,
TextType.Regular,
color: jp.textFaint,
letterSpacing: 1,
weight: FontWeight.w700,
),
],
),
],
),
);
}
Widget _tile(JpColors jp, String label, String value, Color color) {
return Container(
width: 92,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: jp.bg.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: jp.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
text(
value,
20,
TextType.Regular,
color: color,
weight: FontWeight.w800,
),
text(
label.toUpperCase(),
9,
TextType.Regular,
color: jp.textFaint,
letterSpacing: 0.8,
weight: FontWeight.w700,
),
],
),
);
}
Widget _statusDot(DeployStatus s) {
final color = switch (s) {
DeployStatus.success => PeachTheme.seed,
DeployStatus.failure => PeachTheme.danger,
DeployStatus.reverted => PeachTheme.warn,
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.16),
borderRadius: BorderRadius.circular(20),
),
child: text(
s.label,
10,
TextType.Regular,
color: color,
weight: FontWeight.w700,
),
);
}
}

View File

@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../about/internal/application/TextType.dart';
import '../utils/Colors.dart';
import 'text/Text.dart';
/// A scrollable, copyable console sheet for one-shot command output — docker
/// logs, a kafka describe, a redis value. Raised from a screen rather than
/// pushed, because the output is a peek, not a destination.
void showOutputSheet(BuildContext context, String title, String output) {
final jp = context.jp;
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: jp.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (sheetContext) => SizedBox(
height: MediaQuery.of(sheetContext).size.height * 0.7,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(child: text(title, 16, TextType.Bold, color: jp.text)),
IconButton(
tooltip: 'Copy',
onPressed: () {
Clipboard.setData(ClipboardData(text: output));
ScaffoldMessenger.of(
sheetContext,
).showSnackBar(const SnackBar(content: Text('Copied.')));
},
icon: const Icon(Icons.copy_all, size: 18),
),
],
),
const SizedBox(height: 8),
Expanded(
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: jp.consoleBg,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: ConsoleColors.frame),
),
child: SingleChildScrollView(
child: SelectableText(
output,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12.5,
height: 1.4,
color: ConsoleColors.body,
),
),
),
),
),
],
),
),
),
);
}

View File

@@ -0,0 +1,110 @@
import 'package:flutter/material.dart';
import '../about/internal/application/TextType.dart';
import '../about/internal/application/DeploymentRecord.dart';
import '../utils/Colors.dart';
import 'text/Text.dart';
import '../utils/CommonUtils.dart';
/// A compact "recently deployed" list of the latest deployments.
class RecentDeploys extends StatelessWidget {
final List<DeploymentRecord> history;
final VoidCallback onViewAll;
final int max;
const RecentDeploys({
super.key,
required this.history,
required this.onViewAll,
this.max = 5,
});
@override
Widget build(BuildContext context) {
final jp = context.jp;
final items = history.take(max).toList();
return Container(
decoration: PeachTheme.panel(context),
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 8, 8),
child: Row(
children: [
const Icon(Icons.bolt, size: 16, color: PeachTheme.seed),
const SizedBox(width: 6),
text(
'RECENTLY DEPLOYED',
12,
TextType.Regular,
color: jp.textDim,
letterSpacing: 1.2,
weight: FontWeight.w700,
),
const Spacer(),
TextButton(onPressed: onViewAll, child: const Text('View all')),
],
),
),
for (var i = 0; i < items.length; i++) ...[
if (i > 0)
Divider(height: 1, indent: 16, endIndent: 16, color: jp.border),
_row(jp, items[i]),
],
],
),
);
}
Widget _row(JpColors jp, DeploymentRecord r) {
final (color, icon) = switch (r.status) {
DeployStatus.success => (PeachTheme.seed, Icons.check_circle),
DeployStatus.failure => (PeachTheme.danger, Icons.error),
DeployStatus.reverted => (PeachTheme.warn, Icons.undo),
};
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
children: [
Icon(icon, size: 18, color: color),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
text(
'${r.serverName} · ${r.serviceLabel}',
13.5,
TextType.Regular,
color: jp.text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
weight: FontWeight.w600,
),
const SizedBox(height: 1),
Text(
r.jarName.isEmpty ? r.remotePath : r.jarName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11.5,
fontFamily: 'monospace',
color: jp.textFaint,
),
),
],
),
),
const SizedBox(width: 8),
text(
timeAgo(r.time),
11.5,
TextType.Regular,
color: jp.textFaint,
weight: FontWeight.w400,
),
],
),
);
}
}

View File

@@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
import '../about/internal/application/TextType.dart';
import 'text/Text.dart';
/// Picks one of three layouts for the current width. Screens are built as
/// `_mobileView` / `_tabletView` / `_desktopView` methods on their State and
/// handed to this widget, rather than as one layout littered with width checks.
class Responsive extends StatelessWidget {
final Widget mobile;
final Widget tablet;
final Widget desktop;
const Responsive({
super.key,
required this.desktop,
required this.mobile,
required this.tablet,
});
/// mobile < 650
static bool isMobile(BuildContext context) =>
MediaQuery.sizeOf(context).width < 650;
/// tablet >= 650
static bool isTablet(BuildContext context) =>
MediaQuery.sizeOf(context).width >= 650;
/// desktop >= 1100
static bool isDesktop(BuildContext context) =>
MediaQuery.sizeOf(context).width >= 1100;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
if (isDesktop(context)) {
return desktop;
} else if (isTablet(context)) {
return tablet;
} else if (isMobile(context)) {
return mobile;
} else {
return SizedBox(
width: double.infinity,
height: double.infinity,
child: Column(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [text("NOT SUPPORTED", 12, TextType.Bold)],
),
);
}
},
);
}
}

View File

@@ -0,0 +1,99 @@
import 'package:flutter/material.dart';
import '../about/internal/application/TextType.dart';
import '../utils/Colors.dart';
import 'text/Text.dart';
/// A horizontal metric card: icon chip on the left, big value + label on the
/// right, with an optional trailing accent (e.g. a mini ring or delta).
class StatCard extends StatelessWidget {
final IconData icon;
final String label;
final String value;
final Color color;
final String? sub;
final Widget? trailing;
const StatCard({
super.key,
required this.icon,
required this.label,
required this.value,
required this.color,
this.sub,
this.trailing,
});
@override
Widget build(BuildContext context) {
final jp = context.jp;
return Container(
padding: const EdgeInsets.all(16),
decoration: PeachTheme.panel(context, glowColor: color, radius: 18),
child: Row(
children: [
Container(
width: 46,
height: 46,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
color.withValues(alpha: 0.32),
color.withValues(alpha: 0.14),
],
),
borderRadius: BorderRadius.circular(13),
border: Border.all(color: color.withValues(alpha: 0.40)),
boxShadow: PeachTheme.glow(color, opacity: 0.22, blur: 16),
),
child: Icon(icon, color: color, size: 22),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
text(
value,
25,
TextType.Bold,
color: jp.text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
letterSpacing: -0.6,
height: 1.05,
weight: FontWeight.w800,
),
const SizedBox(height: 2),
text(
label.toUpperCase(),
10.5,
TextType.Regular,
color: jp.textFaint,
maxLines: 1,
overflow: TextOverflow.ellipsis,
letterSpacing: 0.8,
weight: FontWeight.w700,
),
if (sub != null)
text(
sub!,
11,
TextType.Regular,
weight: FontWeight.w400,
color: jp.textDim,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
if (trailing != null) ...[const SizedBox(width: 8), trailing!],
],
),
);
}
}

View File

@@ -0,0 +1,184 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../about/internal/application/TextType.dart';
import '../utils/Colors.dart';
import 'text/Text.dart';
/// Job-statistics style card: a donut gauge with the total in the centre, plus
/// labelled progress bars underneath.
class StatsDonut extends StatelessWidget {
final int success;
final int failed;
final int reverted;
const StatsDonut({
super.key,
required this.success,
required this.failed,
required this.reverted,
});
int get _total => success + failed + reverted;
@override
Widget build(BuildContext context) {
final jp = context.jp;
final total = _total;
double pct(int n) => total == 0 ? 0 : n / total;
return Container(
padding: const EdgeInsets.all(18),
decoration: PeachTheme.panel(context),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
text(
'Deploy statistics',
16,
TextType.Bold,
color: jp.text,
weight: FontWeight.w800,
),
const Spacer(),
Container(
width: 30,
height: 30,
decoration: BoxDecoration(
color: jp.surface2,
borderRadius: BorderRadius.circular(9),
),
child: Icon(Icons.open_in_full, size: 14, color: jp.textDim),
),
],
),
const SizedBox(height: 14),
Center(
child: SizedBox(
width: 176,
height: 176,
child: CustomPaint(
painter: _DonutPainter(
track: jp.surface2,
segments: [
(pct(success), PeachTheme.seed),
(pct(failed), PeachTheme.danger),
(pct(reverted), PeachTheme.slate),
],
),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
text(
'$total',
38,
TextType.Bold,
color: jp.text,
height: 1,
weight: FontWeight.w800,
),
const SizedBox(height: 2),
text(
'Total deploys',
12,
TextType.Regular,
color: jp.textFaint,
weight: FontWeight.w400,
),
],
),
),
),
),
),
const SizedBox(height: 18),
_bar(jp, 'Successful', success, pct(success), PeachTheme.seed),
const SizedBox(height: 14),
_bar(jp, 'Failed', failed, pct(failed), PeachTheme.danger),
const SizedBox(height: 14),
_bar(jp, 'Reverted', reverted, pct(reverted), PeachTheme.slate),
],
),
);
}
Widget _bar(JpColors jp, String label, int n, double frac, Color color) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
text(
label,
13,
TextType.Regular,
color: jp.textDim,
weight: FontWeight.w600,
),
const Spacer(),
text(
'${(frac * 100).round()}%',
13,
TextType.Regular,
color: jp.text,
weight: FontWeight.w800,
),
],
),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Stack(
children: [
Container(height: 8, color: jp.surface2),
FractionallySizedBox(
widthFactor: frac.clamp(0.0, 1.0),
child: Container(height: 8, color: color),
),
],
),
),
],
);
}
}
class _DonutPainter extends CustomPainter {
final List<(double, Color)> segments;
final Color track;
_DonutPainter({required this.segments, required this.track});
@override
void paint(Canvas canvas, Size size) {
const stroke = 18.0;
final rect =
Offset(stroke / 2, stroke / 2) &
Size(size.width - stroke, size.height - stroke);
final trackPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = stroke
..color = track;
canvas.drawArc(rect, 0, 2 * math.pi, false, trackPaint);
const gap = 0.06;
var start = -math.pi / 2;
for (final (frac, color) in segments) {
if (frac <= 0) continue;
final full = frac * 2 * math.pi;
final sweep = (full - gap).clamp(0.02, 2 * math.pi);
final p = Paint()
..style = PaintingStyle.stroke
..strokeWidth = stroke
..strokeCap = StrokeCap.round
..color = color;
canvas.drawArc(rect, start + gap / 2, sweep, false, p);
start += full;
}
}
@override
bool shouldRepaint(_DonutPainter old) =>
old.segments != segments || old.track != track;
}

View File

@@ -0,0 +1,161 @@
import 'package:flutter/material.dart' hide StepState;
import '../about/internal/application/StepTracker.dart';
import '../about/internal/application/TextType.dart';
import '../utils/Colors.dart';
import 'text/Text.dart';
/// Renders an ordered list of deploy/build steps with live state and, for the
/// active step, an inline progress bar.
class StepList extends StatelessWidget {
final List<DeployStep> steps;
const StepList({super.key, required this.steps});
@override
Widget build(BuildContext context) {
final jp = context.jp;
final doneCount = steps.where((s) => s.state == StepState.done).length;
return Container(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
decoration: PeachTheme.panel(context, glowColor: PeachTheme.seed),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
text(
'PROGRESS',
12,
TextType.Regular,
color: jp.textDim,
letterSpacing: 1.2,
weight: FontWeight.w700,
),
const Spacer(),
text(
'$doneCount / ${steps.length}',
12,
TextType.Regular,
color: PeachTheme.seed,
weight: FontWeight.w700,
),
],
),
const SizedBox(height: 10),
for (var i = 0; i < steps.length; i++)
_StepRow(step: steps[i], isLast: i == steps.length - 1, jp: jp),
],
),
);
}
}
class _StepRow extends StatelessWidget {
final DeployStep step;
final bool isLast;
final JpColors jp;
const _StepRow({required this.step, required this.isLast, required this.jp});
@override
Widget build(BuildContext context) {
final active = step.state == StepState.active;
final (color, icon) = switch (step.state) {
StepState.done => (PeachTheme.seed, Icons.check),
StepState.active => (PeachTheme.seed, Icons.autorenew),
StepState.failed => (PeachTheme.danger, Icons.close),
StepState.skipped => (jp.textFaint, Icons.remove),
StepState.pending => (jp.textFaint, Icons.circle_outlined),
};
final pct = step.progress == null
? null
: (step.progress! * 100).clamp(0, 100).toStringAsFixed(0);
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Column(
children: [
_dot(color, icon, active),
if (!isLast)
Expanded(
child: Container(
width: 2,
margin: const EdgeInsets.symmetric(vertical: 2),
color: step.state == StepState.done
? PeachTheme.seed.withValues(alpha: 0.4)
: jp.border,
),
),
],
),
const SizedBox(width: 12),
Expanded(
child: Padding(
padding: EdgeInsets.only(bottom: isLast ? 0 : 14, top: 2),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: text(
step.label,
13.5,
TextType.Regular,
color: step.state == StepState.pending
? jp.textFaint
: jp.text,
weight: active ? FontWeight.w700 : FontWeight.w500,
),
),
if (pct != null && active)
text(
'$pct%',
12,
TextType.Regular,
color: PeachTheme.seed,
weight: FontWeight.w700,
),
],
),
if (active && step.progress != null) ...[
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: step.progress,
minHeight: 5,
backgroundColor: jp.surface2,
),
),
],
],
),
),
),
],
),
);
}
Widget _dot(Color color, IconData icon, bool active) {
return Container(
width: 26,
height: 26,
decoration: BoxDecoration(
color: color.withValues(alpha: active ? 0.18 : 0.12),
shape: BoxShape.circle,
border: Border.all(color: color.withValues(alpha: 0.6)),
),
child: active
? const Padding(
padding: EdgeInsets.all(5),
child: CircularProgressIndicator(
strokeWidth: 2,
color: PeachTheme.seed,
),
)
: Icon(icon, size: 15, color: color),
);
}
}

View File

@@ -0,0 +1,165 @@
import 'package:flutter/material.dart';
import '../about/internal/application/TextType.dart';
import '../comms/Comms.dart' show TransferProgress;
import '../utils/Colors.dart';
import 'text/Text.dart';
/// A descriptive, non-flat transfer panel: big percentage, a gradient progress
/// bar, and live stats (transferred, speed, ETA).
class TransferCard extends StatelessWidget {
final TransferProgress progress;
const TransferCard({super.key, required this.progress});
@override
Widget build(BuildContext context) {
final jp = context.jp;
final pct = progress.fraction == null
? null
: (progress.fraction! * 100).clamp(0, 100);
final verb = progress.uploading ? 'Uploading' : 'Downloading';
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: jp.surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: PeachTheme.seed.withValues(alpha: 0.5)),
boxShadow: [
...PeachTheme.softShadow(Theme.of(context).brightness),
...PeachTheme.glow(PeachTheme.seed, opacity: 0.18, blur: 30),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Icon(
progress.uploading ? Icons.cloud_upload : Icons.cloud_download,
color: PeachTheme.seed,
size: 22,
),
const SizedBox(width: 8),
text(
verb,
15,
TextType.Regular,
color: jp.text,
weight: FontWeight.w700,
),
const Spacer(),
text(
pct == null ? '—' : '${pct.toStringAsFixed(0)}%',
26,
TextType.Regular,
color: PeachTheme.seed,
height: 1,
weight: FontWeight.w800,
),
],
),
const SizedBox(height: 12),
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Stack(
children: [
Container(height: 10, color: jp.surface2),
FractionallySizedBox(
widthFactor: (progress.fraction ?? 0).clamp(0.0, 1.0),
child: Container(
height: 10,
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [PeachTheme.seed, PeachTheme.accent],
),
),
),
),
],
),
),
const SizedBox(height: 14),
Row(
children: [
_stat(
jp,
Icons.data_usage,
'Transferred',
'${_bytes(progress.sent)} / ${_bytes(progress.total)}',
),
_sep(jp),
_stat(
jp,
Icons.speed,
'Speed',
'${_bytes(progress.bytesPerSec.round())}/s',
),
_sep(jp),
_stat(jp, Icons.timer_outlined, 'ETA', _eta(progress.eta)),
],
),
],
),
);
}
Widget _sep(JpColors jp) => Container(width: 1, height: 30, color: jp.border);
Widget _stat(JpColors jp, IconData icon, String label, String value) {
return Expanded(
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 12, color: jp.textFaint),
const SizedBox(width: 4),
text(
label.toUpperCase(),
9,
TextType.Regular,
color: jp.textFaint,
letterSpacing: 0.8,
weight: FontWeight.w700,
),
],
),
const SizedBox(height: 3),
Text(
value,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12.5,
fontFamily: 'monospace',
fontWeight: FontWeight.w600,
color: jp.text,
),
),
],
),
);
}
static String _bytes(int b) {
if (b < 1024) return '$b B';
if (b < 1024 * 1024) return '${(b / 1024).toStringAsFixed(0)} KB';
if (b < 1024 * 1024 * 1024) {
return '${(b / (1024 * 1024)).toStringAsFixed(1)} MB';
}
return '${(b / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
}
static String _eta(Duration? d) {
if (d == null) return '—';
final s = d.inSeconds;
if (s <= 0) return '0s';
if (s < 60) return '${s}s';
final m = s ~/ 60;
final rem = s % 60;
if (m < 60) return '${m}m ${rem}s';
final h = m ~/ 60;
return '${h}h ${m % 60}m';
}
}

View File

@@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
import '../../about/internal/application/TextType.dart';
import '../../utils/Colors.dart';
import '../text/Text.dart';
/// The standard labelled text field. Every form in the app uses this rather
/// than building a bare [TextFormField], so labels, spacing and validation
/// messages stay identical everywhere.
Widget inputField({
required String label,
required String initial,
required void Function(String) onSaved,
String? hint,
bool required = false,
bool obscure = false,
TextInputType? keyboardType,
Widget? suffix,
String? Function(String?)? validator,
void Function(String)? onChanged,
TextEditingController? controller,
int? maxLines = 1,
bool enabled = true,
}) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: TextFormField(
initialValue: controller == null ? initial : null,
controller: controller,
obscureText: obscure,
keyboardType: keyboardType,
maxLines: obscure ? 1 : maxLines,
enabled: enabled,
onChanged: onChanged,
decoration: InputDecoration(
labelText: label,
hintText: hint,
suffixIcon: suffix,
),
validator:
validator ??
(required
? (v) => (v == null || v.trim().isEmpty) ? 'Required' : null
: null),
onSaved: (v) => onSaved(v ?? ''),
),
);
}
/// A section heading inside a form.
Widget inputSection(BuildContext context, String title) => Padding(
padding: const EdgeInsets.only(top: 20, bottom: 10),
child: text(
title.toUpperCase(),
12,
TextType.Bold,
color: context.jp.textDim,
letterSpacing: 1.2,
),
);
/// A one-line explanatory note under a field.
Widget inputHint(BuildContext context, IconData icon, String message) =>
Padding(
padding: const EdgeInsets.only(left: 4, top: 2, bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 13, color: context.jp.textFaint),
const SizedBox(width: 6),
Expanded(
child: text(
message,
12,
TextType.Regular,
color: context.jp.textFaint,
),
),
],
),
);
/// A labelled switch row, matching the field spacing.
Widget inputSwitch({
required BuildContext context,
required String title,
required String subtitle,
required bool value,
required void Function(bool) onChanged,
}) => SwitchListTile(
contentPadding: EdgeInsets.zero,
value: value,
onChanged: onChanged,
title: text(title, 14, TextType.Regular, color: context.jp.text),
subtitle: text(subtitle, 12, TextType.Light, color: context.jp.textDim),
);

View File

@@ -0,0 +1,281 @@
import 'package:flutter/material.dart';
import '../../about/internal/application/TextType.dart';
import '../../utils/Colors.dart';
import '../text/Text.dart';
class NavItemData {
final IconData icon;
final String label;
final int? badge;
final VoidCallback onTap;
final bool selected;
const NavItemData({
required this.icon,
required this.label,
required this.onTap,
this.badge,
this.selected = false,
});
}
/// Left navigation rail / sidebar. [expanded] shows labels; otherwise a
/// compact icon rail.
class SideNav extends StatelessWidget {
final bool expanded;
final List<NavItemData> items;
final ThemeMode themeMode;
final Future<void> Function() onCycleTheme;
final VoidCallback onNewServer;
const SideNav({
super.key,
required this.expanded,
required this.items,
required this.themeMode,
required this.onCycleTheme,
required this.onNewServer,
});
(IconData, String) get _themeIcon => switch (themeMode) {
ThemeMode.dark => (Icons.dark_mode, 'Dark'),
ThemeMode.light => (Icons.light_mode, 'Light'),
ThemeMode.system => (Icons.brightness_auto, 'System'),
};
@override
Widget build(BuildContext context) {
final jp = context.jp;
final (themeI, themeLabel) = _themeIcon;
return Container(
width: expanded ? 236 : 78,
decoration: BoxDecoration(
color: jp.surface,
border: Border(right: BorderSide(color: jp.border)),
),
child: SafeArea(
right: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: EdgeInsets.fromLTRB(expanded ? 20 : 0, 20, 0, 20),
child: Row(
mainAxisAlignment: expanded
? MainAxisAlignment.start
: MainAxisAlignment.center,
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
gradient: PeachTheme.brandGradient,
borderRadius: BorderRadius.circular(11),
boxShadow: PeachTheme.glow(
PeachTheme.seed,
opacity: 0.5,
blur: 18,
),
),
child: const Icon(
Icons.rocket_launch,
color: Colors.white,
size: 20,
),
),
if (expanded) ...[
const SizedBox(width: 12),
text(
'Peach',
20,
TextType.Bold,
color: jp.text,
weight: FontWeight.w800,
),
],
],
),
),
Expanded(
child: ListView(
padding: EdgeInsets.symmetric(horizontal: expanded ? 12 : 10),
children: [for (final it in items) _navItem(context, it)],
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: expanded ? 12 : 10),
child: Column(
children: [
_footerButton(
context,
themeI,
themeLabel,
(_) => onCycleTheme(),
),
const SizedBox(height: 6),
_newServerButton(context),
const SizedBox(height: 14),
],
),
),
],
),
),
);
}
Widget _navItem(BuildContext context, NavItemData it) {
final jp = context.jp;
final inner = Container(
padding: EdgeInsets.symmetric(
horizontal: expanded ? 14 : 0,
vertical: 12,
),
decoration: BoxDecoration(
color: it.selected
? PeachTheme.seed.withValues(alpha: 0.13)
: Colors.transparent,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: expanded
? MainAxisAlignment.start
: MainAxisAlignment.center,
children: [
Icon(
it.icon,
size: 20,
color: it.selected ? PeachTheme.seed : jp.textDim,
),
if (expanded) ...[
const SizedBox(width: 12),
Expanded(
child: text(
it.label,
14,
TextType.Regular,
color: it.selected ? jp.text : jp.textDim,
overflow: TextOverflow.ellipsis,
weight: it.selected ? FontWeight.w700 : FontWeight.w600,
),
),
if (it.badge != null && it.badge! > 0)
Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 1),
decoration: BoxDecoration(
color: jp.surface2,
borderRadius: BorderRadius.circular(20),
),
child: text(
'${it.badge}',
11,
TextType.Regular,
color: jp.textDim,
weight: FontWeight.w700,
),
),
],
],
),
);
// A premium left accent bar marks the selected item.
final content = Container(
margin: const EdgeInsets.only(bottom: 6),
child: Row(
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 160),
width: 3,
height: it.selected ? 22 : 0,
margin: EdgeInsets.only(right: expanded ? 8 : 0),
decoration: BoxDecoration(
color: PeachTheme.seed,
borderRadius: BorderRadius.circular(3),
),
),
Expanded(child: inner),
],
),
);
return InkWell(
borderRadius: BorderRadius.circular(12),
onTap: it.onTap,
child: expanded ? content : Tooltip(message: it.label, child: content),
);
}
Widget _footerButton(
BuildContext context,
IconData icon,
String label,
void Function(BuildContext) onTap,
) {
final jp = context.jp;
final w = InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => onTap(context),
child: Container(
padding: EdgeInsets.symmetric(
horizontal: expanded ? 14 : 0,
vertical: 12,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: jp.border),
),
child: Row(
mainAxisAlignment: expanded
? MainAxisAlignment.start
: MainAxisAlignment.center,
children: [
Icon(icon, size: 19, color: jp.textDim),
if (expanded) ...[
const SizedBox(width: 12),
text(
'Theme: $label',
13,
TextType.Regular,
color: jp.textDim,
weight: FontWeight.w400,
),
],
],
),
),
);
return expanded ? w : Tooltip(message: 'Theme: $label', child: w);
}
Widget _newServerButton(BuildContext context) {
if (!expanded) {
return Tooltip(
message: 'New server',
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: onNewServer,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
gradient: PeachTheme.brandGradient,
borderRadius: BorderRadius.circular(12),
boxShadow: PeachTheme.glow(
PeachTheme.seed,
opacity: 0.5,
blur: 16,
),
),
child: const Icon(Icons.add, color: Colors.white),
),
),
);
}
return SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: onNewServer,
icon: const Icon(Icons.add),
label: const Text('New server'),
),
);
}
}

View File

@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import '../../about/internal/application/TextType.dart';
import '../../utils/Colors.dart';
/// Resolves a [TextType] role to the font family that carries it. Display copy
/// (Bold) uses Sora; body and secondary copy use Urbanist and PlusJakartaSans.
String getTextType(TextType type) {
switch (type) {
case TextType.Bold:
return 'Sora';
case TextType.Regular:
return 'Urbanist';
case TextType.Light:
return 'PlusJakartaSans';
}
}
/// The only way display copy is drawn. Never build a raw `Text`/`TextStyle`
/// for user-facing strings — go through here so family, weight and colour stay
/// consistent across every screen.
///
/// [color] defaults to the theme's primary text colour when a [BuildContext]
/// is supplied, so a call site rarely needs to name a colour at all.
Widget text(
String text,
double textSize,
TextType type, {
BuildContext? context,
Color? color,
TextAlign? align,
int? maxLines,
TextOverflow? overflow,
double? letterSpacing,
double? height,
double? wordSpacing,
TextDecoration? decoration,
FontWeight? weight,
}) {
return Text(
text,
textAlign: align ?? TextAlign.left,
maxLines: maxLines,
overflow: overflow,
style: TextStyle(
decoration: decoration ?? TextDecoration.none,
color: color ?? (context != null ? context.jp.text : colorPrimaryDark),
fontSize: textSize,
fontFamily: getTextType(type),
letterSpacing: letterSpacing,
height: height,
wordSpacing: wordSpacing,
fontWeight: weight ?? _weightFor(type),
),
);
}
FontWeight _weightFor(TextType type) {
switch (type) {
case TextType.Bold:
return FontWeight.w700;
case TextType.Regular:
return FontWeight.w500;
case TextType.Light:
return FontWeight.w400;
}
}

View File

@@ -0,0 +1,440 @@
import 'dart:typed_data';
import '../about/external/data/Docker.dart';
import '../about/external/data/Health.dart';
import '../about/external/data/Kafka.dart';
import '../about/external/data/Redis.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 '../about/internal/file/BuildService.dart';
import '../about/internal/file/ConnectFileStorage.dart';
import '../comms/Comms.dart';
import '../comms/ConnectComms.dart';
import '../memory/ConnectInternalMemory.dart';
import 'DataManager.dart';
/// The single concrete [DataManager], constructed once in `ParentViewModel` and
/// shared by every screen. It holds no logic — it only forwards each call to
/// whichever of the three collaborators owns it.
class AppDataManager implements DataManager {
final ConnectInternalMemory memory;
final ConnectComms comms;
final ConnectFileStorage files;
AppDataManager(this.memory, this.comms, this.files);
// ── memory ────────────────────────────────────────────────────────────────
@override
Future<List<ServerProfile>> getServers() => memory.getServers();
@override
Future setServers(List<ServerProfile> servers) => memory.setServers(servers);
@override
Future deleteServerSecret(String serverId) =>
memory.deleteServerSecret(serverId);
@override
Future<List<DeploymentRecord>> getDeployments() => memory.getDeployments();
@override
Future<List<DeploymentRecord>> addDeployment(DeploymentRecord record) =>
memory.addDeployment(record);
@override
Future clearDeployments() => memory.clearDeployments();
@override
Future<String> getThemeMode() => memory.getThemeMode();
@override
Future setThemeMode(String mode) => memory.setThemeMode(mode);
// ── comms: session ────────────────────────────────────────────────────────
@override
bool get isConnected => comms.isConnected;
@override
bool get canRevert => comms.canRevert;
@override
Stream<LogLine> get logs => comms.logs;
@override
Stream<TransferProgress?> get transferProgress => comms.transferProgress;
@override
Stream<List<DeployStep>> get stepStream => comms.stepStream;
@override
Future<void> connect(ServerProfile s) => comms.connect(s);
@override
void requestCancel() => comms.requestCancel();
@override
void disconnect() => comms.disconnect();
@override
void dispose() => comms.dispose();
// ── comms: deployment ─────────────────────────────────────────────────────
@override
Future<DeployResult> deploy({
required ServerProfile server,
required ServiceConfig service,
required Uint8List jarBytes,
required String jarLabel,
}) => comms.deploy(
server: server,
service: service,
jarBytes: jarBytes,
jarLabel: jarLabel,
);
@override
Future<DeployResult> deployWeb({
required ServerProfile server,
required ServiceConfig service,
required Uint8List archiveBytes,
required int fileCount,
}) => comms.deployWeb(
server: server,
service: service,
archiveBytes: archiveBytes,
fileCount: fileCount,
);
@override
Future<DeployResult> restartOnly(
ServerProfile server,
ServiceConfig service,
) => comms.restartOnly(server, service);
@override
Future<DeployResult> revert() => comms.revert();
@override
Future<void> discardBackup() => comms.discardBackup();
// ── comms: provisioning ───────────────────────────────────────────────────
@override
Future<DeployResult> installSystemdUnit({
required ServerProfile server,
required String unitName,
required String unitContent,
bool enable = true,
bool startNow = true,
}) => comms.installSystemdUnit(
server: server,
unitName: unitName,
unitContent: unitContent,
enable: enable,
startNow: startNow,
);
@override
Future<String> detectPackageManager(ServerProfile server) =>
comms.detectPackageManager(server);
@override
Future<Map<String, String>> probeEnvironment(
ServerProfile server,
List<String> detectByKey,
) => comms.probeEnvironment(server, detectByKey);
@override
Future<DeployResult> installEnvironment({
required ServerProfile server,
required String command,
required List<String> units,
}) =>
comms.installEnvironment(server: server, command: command, units: units);
@override
Future<DeployResult> installPackages({
required ServerProfile server,
required String command,
String? enableUnit,
}) => comms.installPackages(
server: server,
command: command,
enableUnit: enableUnit,
);
// ── comms: service & health ───────────────────────────────────────────────
@override
Future<HealthResult> checkHealth(
ServerProfile server,
ServiceConfig service,
) => comms.checkHealth(server, service);
@override
Future<String> serviceStatus(ServerProfile server, ServiceConfig service) =>
comms.serviceStatus(server, service);
@override
Future<DeployResult> serviceControl(
ServerProfile server,
ServiceConfig service,
String action,
) => comms.serviceControl(server, service, action);
@override
Future<List<String>> nginxRoots(ServerProfile server) =>
comms.nginxRoots(server);
// ── comms: logs ───────────────────────────────────────────────────────────
@override
Future<void> fetchLogs(
ServerProfile server,
ServiceConfig service, {
DateTime? since,
DateTime? until,
}) => comms.fetchLogs(server, service, since: since, until: until);
@override
Future<void> followLogs(ServerProfile server, ServiceConfig service) =>
comms.followLogs(server, service);
@override
void stopLogs() => comms.stopLogs();
// ── comms: redis ──────────────────────────────────────────────────────────
@override
Future<(bool, String)> redisPing(
ServerProfile server, {
String host = '127.0.0.1',
int port = 6379,
String pass = '',
int db = 0,
}) => comms.redisPing(server, host: host, port: port, pass: pass, db: db);
@override
Future<String> redisConfigPassword(ServerProfile server) =>
comms.redisConfigPassword(server);
@override
Future<int> redisDbSize(
ServerProfile server, {
String host = '127.0.0.1',
int port = 6379,
String pass = '',
int db = 0,
}) => comms.redisDbSize(server, host: host, port: port, pass: pass, db: db);
@override
Future<(int, Map<int, int>)> redisDatabases(
ServerProfile server, {
String host = '127.0.0.1',
int port = 6379,
String pass = '',
}) => comms.redisDatabases(server, host: host, port: port, pass: pass);
@override
Future<List<(String, String)>> redisKeysTyped(
ServerProfile server, {
String pattern = '*',
String filterType = '',
int limit = 50000,
String host = '127.0.0.1',
int port = 6379,
String pass = '',
int db = 0,
}) => comms.redisKeysTyped(
server,
pattern: pattern,
filterType: filterType,
limit: limit,
host: host,
port: port,
pass: pass,
db: db,
);
@override
Future<List<String>> redisKeys(
ServerProfile server, {
String pattern = '*',
int limit = 100000,
String host = '127.0.0.1',
int port = 6379,
String pass = '',
int db = 0,
}) => comms.redisKeys(
server,
pattern: pattern,
limit: limit,
host: host,
port: port,
pass: pass,
db: db,
);
@override
Future<RedisEntry> redisGet(
ServerProfile server,
String key, {
String host = '127.0.0.1',
int port = 6379,
String pass = '',
int db = 0,
}) => comms.redisGet(server, key, host: host, port: port, pass: pass, db: db);
// ── comms: docker ─────────────────────────────────────────────────────────
@override
Future<DockerStatus> dockerStatus(ServerProfile server) =>
comms.dockerStatus(server);
@override
Future<List<DockerContainer>> dockerPs(
ServerProfile server, {
bool all = true,
}) => comms.dockerPs(server, all: all);
@override
Future<String> dockerAction(ServerProfile server, String id, String action) =>
comms.dockerAction(server, id, action);
@override
Future<String> dockerLogs(
ServerProfile server,
String id, {
int tail = 300,
}) => comms.dockerLogs(server, id, tail: tail);
@override
Future<String> dockerTest(ServerProfile server) => comms.dockerTest(server);
@override
Future<(bool, String)> dockerRun(ServerProfile server, DockerRunSpec spec) =>
comms.dockerRun(server, spec);
// ── comms: kafka ──────────────────────────────────────────────────────────
@override
Future<KafkaStatus> kafkaDetect(ServerProfile server) =>
comms.kafkaDetect(server);
@override
Future<String> kafkaTest(
ServerProfile server, {
required String binDir,
required String suffix,
required String bootstrap,
}) => comms.kafkaTest(
server,
binDir: binDir,
suffix: suffix,
bootstrap: bootstrap,
);
@override
Future<List<KafkaTopic>> kafkaTopics(
ServerProfile server, {
required String binDir,
required String suffix,
required String bootstrap,
bool includeInternal = false,
}) => comms.kafkaTopics(
server,
binDir: binDir,
suffix: suffix,
bootstrap: bootstrap,
includeInternal: includeInternal,
);
@override
Future<List<KafkaConsumerGroup>> kafkaGroups(
ServerProfile server, {
required String binDir,
required String suffix,
required String bootstrap,
}) => comms.kafkaGroups(
server,
binDir: binDir,
suffix: suffix,
bootstrap: bootstrap,
);
@override
Future<KafkaCluster> kafkaCluster(
ServerProfile server, {
required String binDir,
required String suffix,
required String bootstrap,
}) => comms.kafkaCluster(
server,
binDir: binDir,
suffix: suffix,
bootstrap: bootstrap,
);
// ── comms: remote filesystem ──────────────────────────────────────────────
@override
Future<List<RemoteEntry>> listDir(ServerProfile server, String path) =>
comms.listDir(server, path);
@override
Future<String> resolveHome(ServerProfile server) => comms.resolveHome(server);
@override
Future<void> uploadFile(
ServerProfile server,
Uint8List bytes,
String remotePath,
) => comms.uploadFile(server, bytes, remotePath);
@override
Future<Uint8List> downloadFile(ServerProfile server, String remotePath) =>
comms.downloadFile(server, remotePath);
@override
Future<void> deleteRemote(
ServerProfile server,
String remotePath,
bool isDir,
) => comms.deleteRemote(server, remotePath, isDir);
// ── files ─────────────────────────────────────────────────────────────────
@override
bool get buildSupported => files.buildSupported;
@override
Stream<LogLine> get buildLogs => files.buildLogs;
@override
Stream<List<DeployStep>> get buildStepStream => files.buildStepStream;
@override
Future<BuildResult> build(ServiceConfig service) => files.build(service);
@override
Future<BuildResult> buildWeb(ServiceConfig service) =>
files.buildWeb(service);
@override
void cancelBuild() => files.cancelBuild();
@override
Future<Uint8List> readArtifact(String path) => files.readArtifact(path);
@override
Future<bool> artifactExists(String path) => files.artifactExists(path);
@override
Future<(Uint8List, int)> packageWeb(String directoryPath) =>
files.packageWeb(directoryPath);
}

View File

@@ -0,0 +1,10 @@
import '../about/internal/file/ConnectFileStorage.dart';
import '../comms/ConnectComms.dart';
import '../memory/ConnectInternalMemory.dart';
/// The single gateway every ViewModel talks to. It is the union of the three
/// capability contracts — remote (SSH), local persistence, and local files — so
/// a screen never reaches for a service directly and no screen needs to know
/// which of the three a given call lands on.
abstract class DataManager
implements ConnectInternalMemory, ConnectComms, ConnectFileStorage {}

View File

@@ -0,0 +1,52 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../about/internal/application/ServerProfile.dart';
import 'SecretStore.dart';
/// Persists server profiles (with their nested services) locally. Non-secret
/// data goes to shared_preferences; passwords go to encrypted [SecretStore].
class ConfigStore {
static const _key = 'peach.servers.v2';
/// The key used before the app was renamed. Read once and migrated forward,
/// so an existing install keeps its servers.
static const _legacyKey = 'jarpilot.servers.v2';
final _secrets = SecretStore();
Future<List<ServerProfile>> load() async {
final prefs = await SharedPreferences.getInstance();
var raw = prefs.getString(_key);
if (raw == null || raw.isEmpty) {
raw = prefs.getString(_legacyKey);
if (raw != null && raw.isNotEmpty) {
await prefs.setString(_key, raw);
await prefs.remove(_legacyKey);
}
}
if (raw == null || raw.isEmpty) return [];
final list = jsonDecode(raw) as List<dynamic>;
final servers = list
.map((e) => ServerProfile.fromJson(e as Map<String, dynamic>))
.toList();
// Hydrate passwords from encrypted storage.
for (final s in servers) {
s.password = await _secrets.readPassword(s.id) ?? '';
}
return servers;
}
Future<void> save(List<ServerProfile> servers) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
_key,
jsonEncode(servers.map((s) => s.toJson()).toList()),
);
for (final s in servers) {
await _secrets.writePassword(s.id, s.password);
}
}
Future<void> deleteSecret(String serverId) =>
_secrets.deletePassword(serverId);
}

View File

@@ -0,0 +1,24 @@
import '../about/internal/application/DeploymentRecord.dart';
import '../about/internal/application/ServerProfile.dart';
/// Everything Peach persists on this device. Server profiles and their nested
/// services live here; passwords are held separately in the OS keystore and
/// hydrated onto the profiles on read, so a caller never handles the two
/// stores independently.
abstract class ConnectInternalMemory {
Future<List<ServerProfile>> getServers();
Future setServers(List<ServerProfile> servers);
Future deleteServerSecret(String serverId);
Future<List<DeploymentRecord>> getDeployments();
Future<List<DeploymentRecord>> addDeployment(DeploymentRecord record);
Future clearDeployments();
Future<String> getThemeMode();
Future setThemeMode(String mode);
}

View File

@@ -0,0 +1,49 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../about/internal/application/DeploymentRecord.dart';
/// Persists the deployment history so successes/failures can be tracked.
class DeploymentStore {
static const _key = 'peach.deployments.v1';
/// The key used before the app was renamed; migrated forward on first read.
static const _legacyKey = 'jarpilot.deployments.v1';
static const _max = 300;
Future<List<DeploymentRecord>> load() async {
final prefs = await SharedPreferences.getInstance();
var raw = prefs.getString(_key);
if (raw == null || raw.isEmpty) {
raw = prefs.getString(_legacyKey);
if (raw != null && raw.isNotEmpty) {
await prefs.setString(_key, raw);
await prefs.remove(_legacyKey);
}
}
if (raw == null || raw.isEmpty) return [];
final list = jsonDecode(raw) as List<dynamic>;
final records = list
.map((e) => DeploymentRecord.fromJson(e as Map<String, dynamic>))
.toList();
records.sort((a, b) => b.time.compareTo(a.time));
return records;
}
Future<List<DeploymentRecord>> add(DeploymentRecord record) async {
final records = await load();
records.insert(0, record);
if (records.length > _max) records.removeRange(_max, records.length);
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
_key,
jsonEncode(records.map((r) => r.toJson()).toList()),
);
return records;
}
Future<void> clear() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_key);
}
}

View File

@@ -0,0 +1,49 @@
import 'package:shared_preferences/shared_preferences.dart';
import '../about/internal/application/DeploymentRecord.dart';
import '../about/internal/application/ServerProfile.dart';
import 'ConfigStore.dart';
import 'ConnectInternalMemory.dart';
import 'DeploymentStore.dart';
/// The one concrete [ConnectInternalMemory]. Non-secret data goes to
/// shared_preferences via the two stores; passwords go to the OS keystore via
/// [SecretStore] — reads hydrate them back onto the profiles so callers only
/// ever see whole [ServerProfile]s.
class InternalMemory implements ConnectInternalMemory {
static const _themeKey = 'peach.thememode';
final ConfigStore _config = ConfigStore();
final DeploymentStore _deployments = DeploymentStore();
@override
Future<List<ServerProfile>> getServers() => _config.load();
@override
Future setServers(List<ServerProfile> servers) => _config.save(servers);
@override
Future deleteServerSecret(String serverId) => _config.deleteSecret(serverId);
@override
Future<List<DeploymentRecord>> getDeployments() => _deployments.load();
@override
Future<List<DeploymentRecord>> addDeployment(DeploymentRecord record) =>
_deployments.add(record);
@override
Future clearDeployments() => _deployments.clear();
@override
Future<String> getThemeMode() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_themeKey) ?? 'dark';
}
@override
Future setThemeMode(String mode) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_themeKey, mode);
}
}

View File

@@ -0,0 +1,45 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// Encrypted storage backed by the OS keystore: Keychain (macOS/iOS),
/// Keystore/EncryptedSharedPreferences (Android), DPAPI (Windows) and
/// libsecret (Linux). Server passwords live here; so does anything else the
/// `internal_memory` brick generates with `secure: true`.
class SecretStore {
static const _storage = FlutterSecureStorage(aOptions: AndroidOptions());
static String _passwordKey(String serverId) => 'peach.pw.$serverId';
/// The key this app used before it was renamed. Reads fall back to it once
/// and migrate the value forward, so an existing install doesn't silently
/// lose every stored password on upgrade.
static String _legacyPasswordKey(String serverId) => 'jarpilot.pw.$serverId';
Future<String?> readPassword(String serverId) async {
final current = await _storage.read(key: _passwordKey(serverId));
if (current != null) return current;
final legacy = await _storage.read(key: _legacyPasswordKey(serverId));
if (legacy == null) return null;
await _storage.write(key: _passwordKey(serverId), value: legacy);
await _storage.delete(key: _legacyPasswordKey(serverId));
return legacy;
}
Future<void> writePassword(String serverId, String password) =>
_storage.write(key: _passwordKey(serverId), value: password);
Future<void> deletePassword(String serverId) async {
await _storage.delete(key: _passwordKey(serverId));
await _storage.delete(key: _legacyPasswordKey(serverId));
}
// ── generic access ─────────────────────────────────────────────────────────
// Used by fields the `internal_memory` brick generates as secure.
static Future<String?> read(String key) => _storage.read(key: key);
static Future<void> write(String key, String value) =>
_storage.write(key: key, value: value);
static Future<void> delete(String key) => _storage.delete(key: key);
}

View File

@@ -0,0 +1,16 @@
import 'package:flutter/cupertino.dart';
import '../../about/internal/application/ServerProfile.dart';
import 'ConfigurationState.dart';
/// Probes the server's environment (Java, MariaDB, Postgres, nginx, Docker,
/// Node, Git), shows what's present, and guides installing what's missing
/// using the auto-detected package manager — preview, then confirm.
class Configuration extends StatefulWidget {
final ServerProfile server;
const Configuration({super.key, required this.server});
@override
State<Configuration> createState() => ConfigurationState();
}

View File

@@ -0,0 +1,406 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:stacked/stacked.dart';
import '../../about/external/data/EnvCheck.dart';
import '../../about/internal/application/NotificationType.dart';
import '../../about/internal/application/ServerProfile.dart';
import '../../about/internal/application/StepTracker.dart';
import '../../about/internal/application/TextType.dart';
import '../../comms/Comms.dart' show LogLine;
import '../../designs/LogConsole.dart';
import '../../designs/StepList.dart';
import '../../designs/text/Text.dart';
import '../../utils/Colors.dart';
import 'Configuration.dart';
import 'ConnectConfiguration.dart';
import 'ViewConfiguration.dart';
class ConfigurationState extends State<Configuration>
implements ConnectConfiguration {
ViewConfiguration? _model;
StreamSubscription<LogLine>? _logSub;
StreamSubscription<List<DeployStep>>? _stepSub;
final _lines = <LogLine>[];
List<DeployStep> _steps = [];
final Map<String, String> _versions = {};
String _packageManager = 'unknown';
bool _loading = true;
String? _error;
String? _busyKey;
ServerProfile get _server => widget.server;
bool _installed(String key) => (_versions[key] ?? '').trim().isNotEmpty;
@override
void dispose() {
_logSub?.cancel();
_stepSub?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ViewModelBuilder<ViewConfiguration>.reactive(
viewModelBuilder: () => ViewConfiguration(context, this),
onViewModelReady: _onReady,
builder: (context, viewModel, child) => Scaffold(
appBar: AppBar(
title: text(
'${_server.name} · configuration',
20,
TextType.Bold,
color: context.jp.text,
),
actions: [
IconButton(
tooltip: 'Re-scan',
onPressed: _loading || _busyKey != null ? null : _onRescan,
icon: const Icon(Icons.refresh),
),
const SizedBox(width: 4),
],
),
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 40),
children: [
_summaryCard(),
if (_error != null) ...[const SizedBox(height: 12), _errorCard()],
const SizedBox(height: 16),
text(
'ENVIRONMENT',
12,
TextType.Bold,
color: context.jp.textDim,
letterSpacing: 1.2,
),
const SizedBox(height: 10),
LayoutBuilder(
builder: (context, c) {
const gap = 12.0;
final cols = c.maxWidth >= 720 ? 2 : 1;
final w = (c.maxWidth - gap * (cols - 1)) / cols;
return Wrap(
spacing: gap,
runSpacing: gap,
children: [
for (final tool in kEnvTools)
SizedBox(width: w, child: _toolCard(tool)),
],
);
},
),
if (_steps.isNotEmpty) ...[
const SizedBox(height: 22),
StepList(steps: _steps),
],
if (_lines.isNotEmpty) ...[
const SizedBox(height: 14),
LogConsole(
logs: _lines,
height: 260,
onClear: _onClearLogs,
onCopy: _onCopyLogs,
),
],
],
),
),
);
}
void _onReady(ViewConfiguration model) {
_model = model;
// onViewModelReady fires mid-build; a synchronous setState from the
// first load would dirty this State while it is still being built.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _initiate();
});
}
Future<void> _initiate() async {
_logSub = _model?.logs.listen(_onLogLine);
_stepSub = _model?.steps.listen(_onSteps);
_model?.probeEnvironment(_server);
}
void _onLogLine(LogLine line) {
if (!mounted) return;
setState(() => _lines.add(line));
}
void _onSteps(List<DeployStep> steps) {
if (!mounted) return;
setState(() => _steps = steps);
}
// ── named handlers ─────────────────────────────────────────────────────────
void _onRescan() {
setState(() {
_loading = true;
_error = null;
});
_model?.probeEnvironment(_server);
}
void _onInstall(EnvTool tool) =>
_model?.prepareInstall(tool, _packageManager);
void _onClearLogs() => setState(() => _lines.clear());
void _onCopyLogs() {
Clipboard.setData(
ClipboardData(text: _lines.map((l) => l.text).join('\n')),
);
_notify('Logs copied.');
}
void _notify(String message) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
// ── ConnectConfiguration ───────────────────────────────────────────────────
@override
void onEnvironmentProbed(
String packageManager,
Map<String, String> versions,
) {
if (!mounted) return;
setState(() {
_packageManager = packageManager;
_versions
..clear()
..addAll(versions);
_loading = false;
_error = null;
});
}
@override
void onProbeFailed(String message) {
if (!mounted) return;
setState(() {
_loading = false;
_error = message;
});
}
@override
void onInstallCommandReady(EnvTool tool, String command) {
final sudoNote = _server.useSudo ? ' (via sudo)' : '';
final unitNote = tool.unit == null
? ''
: '\n\nThen: systemctl enable --now ${tool.unit}';
_model?.showApplicationNotification(
NotificationType.info,
'Install ${tool.label}?',
'Peach will run$sudoNote:\n\n$command$unitNote',
true,
true,
null,
action: 'Install',
positiveAction: () => _onConfirmInstall(tool, command),
);
}
void _onConfirmInstall(EnvTool tool, String command) {
Navigator.pop(context);
setState(() {
_busyKey = tool.key;
_lines.clear();
});
_model?.installTool(_server, tool, command);
}
@override
void onToolInstalled(EnvTool tool, String version) {
if (!mounted) return;
setState(() {
_versions[tool.key] = version;
_busyKey = null;
});
}
// ── screen-only widgets ────────────────────────────────────────────────────
Widget _summaryCard() {
final jp = context.jp;
final present = kEnvTools.where((t) => _installed(t.key)).length;
return Container(
padding: const EdgeInsets.all(16),
decoration: PeachTheme.panel(context, glowColor: PeachTheme.seed),
child: Row(
children: [
Container(
width: 46,
height: 46,
decoration: BoxDecoration(
color: PeachTheme.seed.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(13),
border: Border.all(
color: PeachTheme.seed.withValues(alpha: 0.35),
),
),
child: const Icon(Icons.fact_check, color: PeachTheme.seed),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
text(
_loading
? 'Scanning environment…'
: '$present / ${kEnvTools.length} tools ready',
16,
TextType.Bold,
color: jp.text,
),
const SizedBox(height: 2),
text(
'${_server.username}@${_server.host}',
12,
TextType.Regular,
color: jp.textDim,
),
],
),
),
_packageManagerChip(),
],
),
);
}
Widget _packageManagerChip() {
final jp = context.jp;
final known = _packageManager != 'unknown';
final color = known ? PeachTheme.accent : jp.textFaint;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: color.withValues(alpha: 0.4)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.inventory_2, size: 13, color: color),
const SizedBox(width: 5),
text(
_loading ? '…' : _packageManager,
11.5,
TextType.Bold,
color: color,
),
],
),
);
}
Widget _errorCard() => Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: PeachTheme.danger.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: PeachTheme.danger.withValues(alpha: 0.5)),
),
child: Row(
children: [
const Icon(Icons.error_outline, color: PeachTheme.danger),
const SizedBox(width: 10),
Expanded(
child: text(_error!, 14, TextType.Regular, color: PeachTheme.danger),
),
],
),
);
Widget _toolCard(EnvTool tool) {
final jp = context.jp;
final installed = _installed(tool.key);
final version = _versions[tool.key]?.trim() ?? '';
final busy = _busyKey == tool.key;
final color = installed ? PeachTheme.seed : jp.textFaint;
return Container(
padding: const EdgeInsets.all(14),
decoration: PeachTheme.panel(context, radius: 16),
child: Row(
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(11),
border: Border.all(color: color.withValues(alpha: 0.3)),
),
child: Icon(tool.icon, color: color, size: 20),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
text(tool.label, 15, TextType.Bold, color: jp.text),
const SizedBox(height: 2),
text(
_loading
? 'checking…'
: (installed
? (version.isEmpty ? 'installed' : version)
: 'Not installed'),
12,
TextType.Regular,
color: installed ? jp.textDim : jp.textFaint,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 10),
if (_loading)
const SizedBox()
else if (installed)
const Icon(Icons.check_circle, color: PeachTheme.seed)
else if (busy)
const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2),
)
else
OutlinedButton.icon(
onPressed: _busyKey != null ? null : () => _onInstall(tool),
icon: const Icon(Icons.download, size: 18),
label: text('Install', 13, TextType.Bold, color: PeachTheme.seed),
style: OutlinedButton.styleFrom(
foregroundColor: PeachTheme.seed,
side: BorderSide(color: PeachTheme.seed.withValues(alpha: 0.5)),
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 10,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,16 @@
import '../../about/external/data/EnvCheck.dart';
abstract class ConnectConfiguration {
/// The environment scan finished: the detected package manager and the
/// version string found for each tool (empty when absent).
void onEnvironmentProbed(String packageManager, Map<String, String> versions);
/// The scan failed.
void onProbeFailed(String message);
/// The exact command that would install [tool], for the user to confirm.
void onInstallCommandReady(EnvTool tool, String command);
/// [tool] finished installing; [version] is its freshly re-probed version.
void onToolInstalled(EnvTool tool, String version);
}

View File

@@ -0,0 +1,79 @@
import '../../about/external/data/EnvCheck.dart';
import '../../about/internal/application/ServerProfile.dart';
import '../../about/internal/application/StepTracker.dart';
import '../../comms/Comms.dart' show LogLine;
import '../parent/ParentViewModel.dart';
import 'ConnectConfiguration.dart';
class ViewConfiguration extends ParentViewModel {
ConnectConfiguration connection;
ViewConfiguration(super.context, this.connection);
Stream<LogLine> get logs => getDataManager().logs;
Stream<List<DeployStep>> get steps => getDataManager().stepStream;
/// Detects the package manager, then probes for every tool at once.
void probeEnvironment(ServerProfile server) async {
if (!await canReach(server, () => probeEnvironment(server))) return;
try {
final packageManager = await getDataManager().detectPackageManager(
server,
);
final versions = await getDataManager().probeEnvironment(
server,
kEnvTools.map((t) => '${t.key} ${t.detect}').toList(),
);
connection.onEnvironmentProbed(packageManager, versions);
} catch (error) {
connection.onProbeFailed(error.toString());
}
}
/// Builds the install command for [tool] under [packageManager] and hands it
/// back for confirmation — nothing runs on the server until the user agrees.
void prepareInstall(EnvTool tool, String packageManager) {
if (packageManager == 'unknown') {
connection.onProbeFailed(
'Unknown package manager — cannot auto-install.',
);
return;
}
connection.onInstallCommandReady(
tool,
installCommandFor(packageManager, tool.packagesFor(packageManager)),
);
}
/// Installs [tool], enables its unit where it has one, then re-probes just
/// that tool so the card updates without a full re-scan.
void installTool(ServerProfile server, EnvTool tool, String command) async {
try {
await getDataManager().installPackages(
server: server,
command: command,
enableUnit: tool.unit,
);
var version = '';
try {
final probed = await getDataManager().probeEnvironment(server, [
'${tool.key} ${tool.detect}',
]);
version = probed[tool.key] ?? '';
} catch (_) {
// The install itself succeeded; a failed re-probe just leaves the
// version blank until the next full scan.
}
connection.onToolInstalled(tool, version);
} catch (error) {
connection.onToolInstalled(tool, '');
handleError(
error,
() => installTool(server, tool, command),
() {},
"Retry",
);
}
}
}

View File

@@ -0,0 +1,15 @@
import 'package:flutter/cupertino.dart';
import '../../../about/internal/application/ServerProfile.dart';
import 'BatchDeployState.dart';
/// Batch "build all & deploy": pick which services to include, choose a mode,
/// and run them one after another with live per-service status.
class BatchDeploy extends StatefulWidget {
final ServerProfile server;
const BatchDeploy({super.key, required this.server});
@override
State<BatchDeploy> createState() => BatchDeployState();
}

View File

@@ -0,0 +1,549 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:stacked/stacked.dart';
import '../../../about/internal/application/ServerProfile.dart';
import '../../../about/internal/application/ServiceConfig.dart';
import '../../../about/internal/application/StepTracker.dart';
import '../../../about/internal/application/TextType.dart';
import '../../../comms/Comms.dart' show LogLine;
import '../../../designs/LogConsole.dart';
import '../../../designs/StepList.dart';
import '../../../designs/text/Text.dart';
import '../../../utils/Colors.dart';
import 'BatchDeploy.dart';
import 'ConnectBatchDeploy.dart';
import 'ViewBatchDeploy.dart';
enum BatchMode { buildDeploy, buildOnly }
class BatchDeployState extends State<BatchDeploy>
implements ConnectBatchDeploy {
ViewBatchDeploy? _model;
final _subscriptions = <StreamSubscription>[];
final _lines = <LogLine>[];
List<DeployStep> _steps = [];
final Set<String> _selected = {};
final Map<String, BatchState> _status = {};
final Map<String, String> _detail = {};
late List<ServiceConfig> _ordered;
BatchMode _mode = BatchMode.buildDeploy;
bool _running = false;
ServerProfile get _server => widget.server;
/// A service can join a batch only if Peach knows how to build it.
bool _eligible(ServiceConfig s) => s.canBuild;
int get _eligibleCount => _ordered.where(_eligible).length;
int get _doneCount =>
_status.values.where((s) => s == BatchState.done).length;
int get _failCount =>
_status.values.where((s) => s == BatchState.failed).length;
@override
void initState() {
super.initState();
_ordered = _server.orderedServices;
for (final service in _ordered) {
if (_eligible(service)) _selected.add(service.id);
}
}
@override
void dispose() {
for (final s in _subscriptions) {
s.cancel();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
return ViewModelBuilder<ViewBatchDeploy>.reactive(
viewModelBuilder: () => ViewBatchDeploy(context, this),
onViewModelReady: _onReady,
builder: (context, viewModel, child) => Scaffold(
appBar: AppBar(
title: text(
'Build & deploy · ${_server.name}',
20,
TextType.Bold,
color: context.jp.text,
),
),
body: Column(
children: [
_header(),
Expanded(
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
children: [
_reorderHint(),
ReorderableListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
buildDefaultDragHandles: false,
onReorderItem: _onReorder,
itemCount: _ordered.length,
itemBuilder: (context, i) => Padding(
key: ValueKey(_ordered[i].id),
padding: const EdgeInsets.only(bottom: 10),
child: _serviceTile(_ordered[i], i),
),
),
if (_steps.isNotEmpty) ...[
const SizedBox(height: 14),
StepList(steps: _steps),
],
if (_lines.isNotEmpty) ...[
const SizedBox(height: 14),
LogConsole(
logs: _lines,
height: 300,
onClear: _onClearLogs,
onCopy: _onCopyLogs,
),
],
],
),
),
_bottomBar(),
],
),
),
);
}
void _onReady(ViewBatchDeploy model) {
_model = model;
// onViewModelReady fires mid-build; a synchronous setState from the
// first load would dirty this State while it is still being built.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _initiate();
});
}
Future<void> _initiate() async {
final model = _model;
if (model == null) return;
_subscriptions.addAll([
model.logs.listen(_onLogLine),
model.buildLogs.listen(_onLogLine),
model.steps.listen(_onSteps),
model.buildSteps.listen(_onSteps),
]);
}
void _onLogLine(LogLine line) {
if (!mounted) return;
setState(() => _lines.add(line));
}
void _onSteps(List<DeployStep> steps) {
if (!mounted) return;
setState(() => _steps = steps);
}
// ── named handlers ─────────────────────────────────────────────────────────
/// [newIndex] already accounts for the removed item — `onReorderItem` does
/// that adjustment for us, unlike the older `onReorder`.
void _onReorder(int oldIndex, int newIndex) {
setState(() {
final moved = _ordered.removeAt(oldIndex);
_ordered.insert(newIndex, moved);
});
_model?.saveDeployOrder(_server, _ordered.map((s) => s.id).toList());
}
void _onRun() {
final batch = _ordered.where((s) => _selected.contains(s.id)).toList();
if (batch.isEmpty) {
_notify('Select at least one service.');
return;
}
setState(() {
_running = true;
_lines.clear();
});
_model?.runBatch(
_server,
batch,
deployAfterBuild: _mode == BatchMode.buildDeploy,
);
}
void _onStop() => _model?.requestCancel();
void _onSelectMode(BatchMode mode) => setState(() => _mode = mode);
void _onToggleService(ServiceConfig service, bool? selected) {
setState(() {
if (selected == true) {
_selected.add(service.id);
} else {
_selected.remove(service.id);
}
});
}
void _onToggleAll() {
final eligible = _ordered.where(_eligible).map((s) => s.id).toSet();
setState(() {
if (_selected.length >= eligible.length) {
_selected.clear();
} else {
_selected
..clear()
..addAll(eligible);
}
});
}
void _onClearLogs() => setState(() => _lines.clear());
void _onCopyLogs() {
Clipboard.setData(
ClipboardData(text: _lines.map((l) => l.text).join('\n')),
);
_notify('Logs copied.');
}
void _notify(String message) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
// ── ConnectBatchDeploy ─────────────────────────────────────────────────────
@override
void onBatchStarted(List<String> serviceIds) {
if (!mounted) return;
setState(() {
for (final id in serviceIds) {
_status[id] = BatchState.queued;
_detail.remove(id);
}
});
}
@override
void onServiceState(String serviceId, BatchState state, {String? detail}) {
if (!mounted) return;
setState(() {
_status[serviceId] = state;
if (detail != null) _detail[serviceId] = detail;
});
}
@override
void onBatchFinished() {
if (!mounted) return;
setState(() => _running = false);
}
// ── screen-only widgets ────────────────────────────────────────────────────
Widget _header() {
final jp = context.jp;
final allSelected =
_selected.length >= _eligibleCount && _eligibleCount > 0;
return Container(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
decoration: BoxDecoration(
color: jp.surface,
border: Border(bottom: BorderSide(color: jp.border)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
text('Mode', 13, TextType.Bold, color: jp.textDim),
const Spacer(),
if (_running || _doneCount + _failCount > 0)
text(
'${_doneCount + _failCount}/${_selected.length} · '
'$_doneCount ok · $_failCount failed',
12,
TextType.Regular,
color: jp.textFaint,
),
],
),
const SizedBox(height: 8),
Row(
children: [
_modeChip(
BatchMode.buildDeploy,
'Build & deploy',
Icons.rocket_launch,
),
const SizedBox(width: 8),
_modeChip(BatchMode.buildOnly, 'Build only', Icons.build),
const Spacer(),
TextButton.icon(
onPressed: _running ? null : _onToggleAll,
icon: Icon(
allSelected ? Icons.remove_done : Icons.done_all,
size: 16,
),
label: text(
allSelected ? 'Clear' : 'Select all',
13,
TextType.Bold,
color: colorPrimary,
),
),
],
),
],
),
);
}
Widget _reorderHint() => Padding(
padding: const EdgeInsets.only(left: 4, bottom: 8, top: 2),
child: Row(
children: [
Icon(Icons.drag_indicator, size: 15, color: context.jp.textFaint),
const SizedBox(width: 4),
text(
'Drag to set deployment order — saved automatically',
12,
TextType.Regular,
color: context.jp.textFaint,
),
],
),
);
Widget _modeChip(BatchMode mode, String label, IconData icon) {
final jp = context.jp;
final active = _mode == mode;
return GestureDetector(
onTap: _running ? null : () => _onSelectMode(mode),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: active ? PeachTheme.seed.withValues(alpha: 0.14) : jp.surface2,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: active ? PeachTheme.seed : jp.border),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 15, color: active ? PeachTheme.seed : jp.textDim),
const SizedBox(width: 6),
text(
label,
12.5,
TextType.Bold,
color: active ? PeachTheme.seed : jp.textDim,
),
],
),
),
);
}
Widget _serviceTile(ServiceConfig service, int index) {
final jp = context.jp;
final eligible = _eligible(service);
final selected = _selected.contains(service.id);
final state = _status[service.id];
return Container(
padding: const EdgeInsets.fromLTRB(8, 12, 12, 12),
decoration: PeachTheme.panel(context, radius: 14),
child: Row(
children: [
Container(
width: 24,
height: 24,
alignment: Alignment.center,
decoration: BoxDecoration(
color: PeachTheme.seed.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(7),
),
child: text(
'${index + 1}',
12,
TextType.Bold,
color: PeachTheme.seed,
),
),
const SizedBox(width: 4),
if (eligible)
Checkbox(
value: selected,
onChanged: _running ? null : (v) => _onToggleService(service, v),
)
else
const SizedBox(width: 40, child: Icon(Icons.block, size: 18)),
Icon(
service.isWeb
? Icons.language
: (service.isGeneric ? Icons.terminal : Icons.settings_suggest),
size: 18,
color: jp.textDim,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
text(
service.name,
14,
TextType.Bold,
color: jp.text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
text(
eligible
? (_detail[service.id] ??
'${service.kind.label} · builds from source')
: 'Add a source path to include',
11.5,
TextType.Regular,
color: jp.textFaint,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 8),
_statusPill(state),
if (!_running) ...[
const SizedBox(width: 4),
ReorderableDragStartListener(
index: index,
child: Icon(Icons.drag_handle, size: 20, color: jp.textFaint),
),
],
],
),
);
}
Widget _statusPill(BatchState? state) {
if (state == null) return const SizedBox.shrink();
final jp = context.jp;
final (color, label, spinning) = switch (state) {
BatchState.queued => (jp.textFaint, 'QUEUED', false),
BatchState.building => (PeachTheme.warn, 'BUILDING', true),
BatchState.deploying => (PeachTheme.accent, 'DEPLOYING', true),
BatchState.done => (PeachTheme.seed, 'DONE', false),
BatchState.failed => (PeachTheme.danger, 'FAILED', false),
BatchState.skipped => (jp.textFaint, 'SKIPPED', false),
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.16),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: color.withValues(alpha: 0.4)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (spinning)
SizedBox(
width: 11,
height: 11,
child: CircularProgressIndicator(strokeWidth: 2, color: color),
)
else
Icon(
state == BatchState.done
? Icons.check
: state == BatchState.failed
? Icons.close
: Icons.circle,
size: 11,
color: color,
),
const SizedBox(width: 5),
text(label, 10, TextType.Bold, color: color),
],
),
);
}
Widget _bottomBar() {
return SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
child: Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: _running || _selected.isEmpty ? null : _onRun,
icon: _running
? SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colorWhite,
),
)
: Icon(
_mode == BatchMode.buildOnly
? Icons.build
: Icons.rocket_launch,
),
label: text(
_running
? 'Running…'
: (_mode == BatchMode.buildOnly
? 'Build ${_selected.length} selected'
: 'Build & deploy ${_selected.length} selected'),
14,
TextType.Bold,
color: colorWhite,
),
),
),
if (_running) ...[
const SizedBox(width: 10),
OutlinedButton.icon(
onPressed: _onStop,
icon: const Icon(Icons.cancel, size: 18),
label: text(
'Stop',
13,
TextType.Bold,
color: PeachTheme.danger,
),
style: OutlinedButton.styleFrom(
foregroundColor: PeachTheme.danger,
side: BorderSide(
color: PeachTheme.danger.withValues(alpha: 0.6),
),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
),
),
],
],
),
),
);
}
}

View File

@@ -0,0 +1,13 @@
/// Where one service has got to in the batch.
enum BatchState { queued, building, deploying, done, failed, skipped }
abstract class ConnectBatchDeploy {
/// The run started; every selected service is queued.
void onBatchStarted(List<String> serviceIds);
/// One service moved to a new state, optionally with a detail line.
void onServiceState(String serviceId, BatchState state, {String? detail});
/// Every service in the batch has been attempted.
void onBatchFinished();
}

View File

@@ -0,0 +1,180 @@
import 'dart:io';
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;
import '../../../utils/CommonUtils.dart';
import '../../parent/ParentViewModel.dart';
import 'ConnectBatchDeploy.dart';
class ViewBatchDeploy extends ParentViewModel {
ConnectBatchDeploy connection;
ViewBatchDeploy(super.context, this.connection);
bool _cancelRequested = false;
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;
/// Builds (and optionally deploys) each service in [batch], in order. One
/// service failing doesn't stop the rest — the run reports per-service.
void runBatch(
ServerProfile server,
List<ServiceConfig> batch, {
required bool deployAfterBuild,
}) async {
if (deployAfterBuild &&
!await canReach(
server,
() => runBatch(server, batch, deployAfterBuild: deployAfterBuild),
)) {
return;
}
_cancelRequested = false;
connection.onBatchStarted(batch.map((s) => s.id).toList());
for (final service in batch) {
if (_cancelRequested) {
connection.onServiceState(service.id, BatchState.skipped);
continue;
}
await _runOne(server, service, deployAfterBuild);
}
connection.onBatchFinished();
}
Future<void> _runOne(
ServerProfile server,
ServiceConfig service,
bool deployAfterBuild,
) async {
connection.onServiceState(service.id, BatchState.building);
final built = service.isWeb
? await getDataManager().buildWeb(service)
: await getDataManager().build(service);
if (!built.ok) {
connection.onServiceState(
service.id,
BatchState.failed,
detail: built.message,
);
return;
}
if (!deployAfterBuild) {
connection.onServiceState(service.id, BatchState.done, detail: 'Built');
return;
}
connection.onServiceState(service.id, BatchState.deploying);
DeployResult result;
String artifact;
try {
if (service.isWeb) {
final directory = built.dir!.path;
if (!File('$directory/index.html').existsSync()) {
connection.onServiceState(
service.id,
BatchState.failed,
detail: 'No index.html in build output.',
);
return;
}
final (bytes, count) = await getDataManager().packageWeb(directory);
artifact = service.framework.label;
result = await getDataManager().deployWeb(
server: server,
service: service,
archiveBytes: bytes,
fileCount: count,
);
} else {
final jar = built.jar!;
artifact = baseName(jar.path);
result = await getDataManager().deploy(
server: server,
service: service,
jarBytes: await jar.readAsBytes(),
jarLabel: artifact,
);
}
} catch (error) {
connection.onServiceState(
service.id,
BatchState.failed,
detail: 'Error: $error',
);
return;
}
await _record(server, service, result, artifact);
connection.onServiceState(
service.id,
result.ok ? BatchState.done : BatchState.failed,
detail: result.message,
);
}
/// Stops after the service currently running finishes.
void requestCancel() {
_cancelRequested = true;
getDataManager().requestCancel();
getDataManager().cancelBuild();
}
/// Remembers the order services should be deployed in next time.
void saveDeployOrder(ServerProfile server, List<String> ids) async {
try {
final servers = await getDataManager().getServers();
final index = servers.indexWhere((s) => s.id == server.id);
if (index < 0) return;
servers[index].deployOrder = ids;
await getDataManager().setServers(servers);
} catch (error) {
handleError(error, () => saveDeployOrder(server, ids), () {}, "Retry");
}
}
Future<void> _record(
ServerProfile server,
ServiceConfig service,
DeployResult result,
String artifact,
) 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: service.isWeb
? service.remoteRoot
: service.remotePath(artifact),
time: DateTime.now(),
status: result.ok ? DeployStatus.success : DeployStatus.failure,
message: result.message,
),
);
} catch (_) {
// A missing history row must not change what the batch reports.
}
}
}

View File

@@ -0,0 +1,33 @@
import 'dart:typed_data';
import '../../../comms/Comms.dart' show DeployResult;
abstract class ConnectDeploy {
/// nginx's configured roots, used to warn when a web service deploys
/// somewhere nginx isn't serving.
void onNginxRootsLoaded(List<String> roots);
/// A jar / binary is staged and ready to ship.
void onJarStaged(Uint8List bytes, String name, String source);
/// A built site directory is staged and ready to ship.
void onWebStaged(String directory, String source);
/// A local build finished; [message] explains a failure.
void onBuildFailed(String message);
/// Packaging a web build began (it can take a while on big sites).
void onPackagingStarted();
/// The deploy finished, successfully or not.
void onDeployFinished(DeployResult result);
/// A revert finished.
void onRevertFinished(DeployResult result);
/// The kept-backup was discarded.
void onBackupDiscarded();
/// Live tail started or stopped.
void onFollowChanged(bool following);
}

View File

@@ -0,0 +1,17 @@
import 'package:flutter/cupertino.dart';
import '../../../about/internal/application/ServerProfile.dart';
import '../../../about/internal/application/ServiceConfig.dart';
import 'DeployState.dart';
/// Stage an artifact — dropped, picked, or built from source — then ship it to
/// the server, with live steps, transfer stats and a revert path.
class Deploy extends StatefulWidget {
final ServerProfile server;
final ServiceConfig service;
const Deploy({super.key, required this.server, required this.service});
@override
State<Deploy> createState() => DeployState();
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,22 @@
import '../../../about/external/data/Docker.dart';
abstract class ConnectDockerManager {
/// Docker's state on the server, plus its containers when it's running.
void onDockerLoaded(DockerStatus status, List<DockerContainer> containers);
/// A container action (start/stop/restart/rm) finished.
void onActionFinished(String output);
/// Output to show in a sheet — container logs, or a test run.
void onOutputReady(String title, String output);
/// The install command was resolved for this distro; the screen confirms it
/// with the user before anything runs.
void onInstallCommandReady(String command);
/// The Docker install finished; the caller should re-check status.
void onInstallFinished();
/// Something failed with a message worth surfacing inline.
void onDockerFailed(String message);
}

View File

@@ -0,0 +1,15 @@
import 'package:flutter/cupertino.dart';
import '../../../about/internal/application/ServerProfile.dart';
import 'DockerManagerState.dart';
/// See, start/stop/restart, inspect logs and remove Docker containers — and
/// install Docker if it's missing.
class DockerManager extends StatefulWidget {
final ServerProfile server;
const DockerManager({super.key, required this.server});
@override
State<DockerManager> createState() => DockerManagerState();
}

View File

@@ -0,0 +1,495 @@
import 'package:flutter/material.dart';
import 'package:stacked/stacked.dart';
import '../../../about/external/data/Docker.dart';
import '../../../about/internal/application/NotificationType.dart';
import '../../../about/internal/application/ServerProfile.dart';
import '../../../about/internal/application/TextType.dart';
import '../../../configs/Navigator.dart';
import '../../../designs/OutputSheet.dart';
import '../../../designs/text/Text.dart';
import '../../../utils/Colors.dart';
import '../run/DockerRun.dart';
import 'ConnectDockerManager.dart';
import 'DockerManager.dart';
import 'ViewDockerManager.dart';
class DockerManagerState extends State<DockerManager>
implements ConnectDockerManager {
ViewDockerManager? _model;
DockerStatus? _status;
List<DockerContainer> _containers = [];
bool _loading = true;
bool _installing = false;
String? _busyId;
ServerProfile get _server => widget.server;
bool get _ready => _status != null && _status!.installed && _status!.running;
@override
Widget build(BuildContext context) {
return ViewModelBuilder<ViewDockerManager>.reactive(
viewModelBuilder: () => ViewDockerManager(context, this),
onViewModelReady: _onReady,
builder: (context, viewModel, child) => Scaffold(
appBar: AppBar(
title: text(
'Docker · ${_server.name}',
20,
TextType.Bold,
color: context.jp.text,
),
actions: [
if (_ready)
IconButton(
tooltip: 'Test (hello-world)',
onPressed: _onTest,
icon: const Icon(Icons.verified),
),
IconButton(
tooltip: 'Refresh',
onPressed: _loading ? null : _onRefresh,
icon: const Icon(Icons.refresh),
),
const SizedBox(width: 4),
],
),
floatingActionButton: _ready
? FloatingActionButton.extended(
onPressed: _onOpenRun,
icon: const Icon(Icons.add),
label: text(
'Run container',
14,
TextType.Bold,
color: colorWhite,
),
)
: null,
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 96),
children: [
_statusCard(),
if (_ready) ...[
const SizedBox(height: 16),
_sectionHeader(),
const SizedBox(height: 10),
if (_loading)
const Center(
child: Padding(
padding: EdgeInsets.all(24),
child: CircularProgressIndicator(),
),
)
else if (_containers.isEmpty)
_empty()
else
LayoutBuilder(
builder: (context, c) {
const gap = 12.0;
final cols = c.maxWidth >= 720 ? 2 : 1;
final w = (c.maxWidth - gap * (cols - 1)) / cols;
return Wrap(
spacing: gap,
runSpacing: gap,
children: [
for (final container in _containers)
SizedBox(width: w, child: _containerCard(container)),
],
);
},
),
],
],
),
),
);
}
void _onReady(ViewDockerManager model) {
_model = model;
// onViewModelReady fires mid-build; a synchronous setState from the
// first load would dirty this State while it is still being built.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _initiate();
});
}
Future<void> _initiate() async {
_model?.loadDocker(_server);
}
// ── named handlers ─────────────────────────────────────────────────────────
void _onRefresh() {
setState(() => _loading = true);
_model?.loadDocker(_server);
}
void _onTest() => _model?.runTest(_server);
void _onInstall() => _model?.prepareInstall(_server);
Future<void> _onOpenRun() async {
await PeachNavigation().navigateToPageWithData(
DockerRun(server: _server),
context,
);
_onRefresh();
}
void _onStart(DockerContainer c) => _action(c, 'start');
void _onStop(DockerContainer c) => _action(c, 'stop');
void _onRestart(DockerContainer c) => _action(c, 'restart');
void _onRemove(DockerContainer c) {
_model?.showApplicationNotification(
NotificationType.warning,
'Remove "${c.name}"?',
'This force-removes the container (docker rm -f).',
true,
true,
null,
action: 'Remove',
positiveAction: () => _onConfirmRemove(c),
);
}
void _onConfirmRemove(DockerContainer c) {
Navigator.pop(context);
_action(c, 'rm');
}
void _action(DockerContainer c, String action) {
setState(() => _busyId = c.id);
_model?.runAction(_server, c.id, action);
}
void _onShowLogs(DockerContainer c) {
setState(() => _busyId = c.id);
_model?.loadContainerLogs(_server, c.id, c.name);
}
void _notify(String message) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
// ── ConnectDockerManager ───────────────────────────────────────────────────
@override
void onDockerLoaded(DockerStatus status, List<DockerContainer> containers) {
if (!mounted) return;
setState(() {
_status = status;
_containers = containers;
_loading = false;
_busyId = null;
});
}
@override
void onActionFinished(String output) {
if (!mounted) return;
setState(() => _busyId = null);
if (output.toLowerCase().contains('error')) _notify(output);
_onRefresh();
}
@override
void onOutputReady(String title, String output) {
if (!mounted) return;
setState(() => _busyId = null);
showOutputSheet(context, title, output);
}
@override
void onInstallCommandReady(String command) {
_model?.showApplicationNotification(
NotificationType.info,
'Install Docker?',
'Runs:\n$command\nthen: systemctl enable --now docker',
true,
true,
null,
action: 'Install',
positiveAction: () => _onConfirmInstall(command),
);
}
void _onConfirmInstall(String command) {
Navigator.pop(context);
setState(() => _installing = true);
_model?.installDocker(_server, command);
}
@override
void onInstallFinished() {
if (!mounted) return;
setState(() => _installing = false);
_onRefresh();
}
@override
void onDockerFailed(String message) {
if (!mounted) return;
setState(() {
_loading = false;
_busyId = null;
});
_notify(message);
}
// ── screen-only widgets ────────────────────────────────────────────────────
Widget _sectionHeader() => Row(
children: [
text(
'CONTAINERS',
12,
TextType.Bold,
color: context.jp.textDim,
letterSpacing: 1.2,
),
const SizedBox(width: 8),
text('${_containers.length}', 12, TextType.Bold, color: PeachTheme.seed),
],
);
Widget _statusCard() {
final jp = context.jp;
final installed = _status?.installed ?? false;
final running = _status?.running ?? false;
final (color, label) = !installed
? (jp.textFaint, 'NOT INSTALLED')
: running
? (PeachTheme.seed, 'RUNNING')
: (PeachTheme.warn, 'STOPPED');
return Container(
padding: const EdgeInsets.all(16),
decoration: PeachTheme.panel(context, glowColor: PeachTheme.accent),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 46,
height: 46,
decoration: BoxDecoration(
color: PeachTheme.accent.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(13),
),
child: const Icon(Icons.inventory_2, color: PeachTheme.accent),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
text(
_loading ? 'Checking Docker…' : 'Docker',
16,
TextType.Bold,
color: jp.text,
),
text(
installed ? (_status?.version ?? '') : 'Not installed',
12,
TextType.Regular,
color: jp.textDim,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 5,
),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.16),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: color.withValues(alpha: 0.4)),
),
child: text(label, 10.5, TextType.Bold, color: color),
),
],
),
if (!installed) ...[
const SizedBox(height: 14),
FilledButton.icon(
onPressed: _installing ? null : _onInstall,
icon: _installing
? SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colorWhite,
),
)
: const Icon(Icons.download),
label: text(
_installing ? 'Installing…' : 'Install Docker',
14,
TextType.Bold,
color: colorWhite,
),
),
] else if (!running) ...[
const SizedBox(height: 12),
text(
'The Docker daemon is not running. Start it with '
'systemctl start docker (Configuration → install also enables it).',
12.5,
TextType.Regular,
color: jp.textDim,
),
],
],
),
);
}
Widget _empty() {
final jp = context.jp;
return Container(
padding: const EdgeInsets.symmetric(vertical: 40),
alignment: Alignment.center,
decoration: PeachTheme.panel(context),
child: Column(
children: [
Icon(Icons.inbox, size: 40, color: jp.textFaint),
const SizedBox(height: 10),
text('No containers yet', 14, TextType.Regular, color: jp.textDim),
const SizedBox(height: 4),
text(
'Tap "Run container" to start one.',
12,
TextType.Light,
color: jp.textFaint,
),
],
),
);
}
Widget _containerCard(DockerContainer c) {
final jp = context.jp;
final busy = _busyId == c.id;
final (color, label) = switch (c.state.toLowerCase()) {
'running' => (PeachTheme.seed, 'UP'),
'exited' => (jp.textFaint, 'EXITED'),
'paused' => (PeachTheme.warn, 'PAUSED'),
'created' => (PeachTheme.accent, 'CREATED'),
'restarting' => (PeachTheme.warn, 'RESTARTING'),
_ => (PeachTheme.danger, c.state.toUpperCase()),
};
return Container(
padding: const EdgeInsets.all(14),
decoration: PeachTheme.panel(context, radius: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.widgets,
size: 18,
color: c.up ? PeachTheme.seed : jp.textFaint,
),
const SizedBox(width: 8),
Expanded(
child: text(
c.name,
14,
TextType.Bold,
color: jp.text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.16),
borderRadius: BorderRadius.circular(20),
),
child: text(label, 10, TextType.Bold, color: color),
),
],
),
const SizedBox(height: 6),
text(
c.image,
12,
TextType.Regular,
color: jp.textDim,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
text(
c.status,
11.5,
TextType.Light,
color: jp.textFaint,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (c.ports.trim().isNotEmpty)
text(
c.ports,
11.5,
TextType.Light,
color: jp.textFaint,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
Row(
children: [
if (busy)
const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
else ...[
if (c.up) ...[
_act(Icons.restart_alt, 'Restart', () => _onRestart(c)),
_act(Icons.stop, 'Stop', () => _onStop(c)),
] else
_act(Icons.play_arrow, 'Start', () => _onStart(c)),
_act(Icons.article_outlined, 'Logs', () => _onShowLogs(c)),
_act(
Icons.delete_outline,
'Remove',
() => _onRemove(c),
color: PeachTheme.danger,
),
],
],
),
],
),
);
}
Widget _act(IconData icon, String tip, VoidCallback onTap, {Color? color}) =>
IconButton(
tooltip: tip,
iconSize: 20,
color: color ?? context.jp.textDim,
onPressed: onTap,
icon: Icon(icon),
);
}

View File

@@ -0,0 +1,117 @@
import '../../../about/external/data/Docker.dart';
import '../../../about/external/data/EnvCheck.dart';
import '../../../about/internal/application/ServerProfile.dart';
import '../../parent/ParentViewModel.dart';
import 'ConnectDockerManager.dart';
class ViewDockerManager extends ParentViewModel {
ConnectDockerManager connection;
ViewDockerManager(super.context, this.connection);
/// Reads Docker's state and, when the daemon is up, its containers.
void loadDocker(ServerProfile server) async {
if (!await canReach(server, () => loadDocker(server))) return;
try {
final status = await getDataManager().dockerStatus(server);
var containers = <DockerContainer>[];
if (status.installed && status.running) {
containers = await getDataManager().dockerPs(server, all: true);
}
connection.onDockerLoaded(status, containers);
} catch (error) {
connection.onDockerFailed(error.toString());
}
}
/// start | stop | restart | rm on one container.
void runAction(
ServerProfile server,
String containerId,
String action,
) async {
try {
final output = await getDataManager().dockerAction(
server,
containerId,
action,
);
connection.onActionFinished(output);
} catch (error) {
handleError(
error,
() => runAction(server, containerId, action),
() {},
"Retry",
);
}
}
void loadContainerLogs(
ServerProfile server,
String containerId,
String name,
) async {
try {
final output = await getDataManager().dockerLogs(server, containerId);
connection.onOutputReady(
'$name · logs',
output.isEmpty ? '(no output)' : output,
);
} catch (error) {
handleError(
error,
() => loadContainerLogs(server, containerId, name),
() {},
"Retry",
);
}
}
/// Runs `hello-world` to prove the daemon can pull and execute an image.
void runTest(ServerProfile server) async {
showLoading("Running hello-world …");
try {
final output = await getDataManager().dockerTest(server);
closeLoading();
connection.onOutputReady('docker run --rm hello-world', output);
} catch (error) {
handleError(error, () => runTest(server), () {}, "Retry");
}
}
/// Resolves the right package-manager command for this distro, then hands
/// it back so the screen can confirm before anything is installed.
void prepareInstall(ServerProfile server) async {
if (!await canReach(server, () => prepareInstall(server))) return;
try {
final manager = await getDataManager().detectPackageManager(server);
if (manager == 'unknown') {
connection.onDockerFailed(
'Unknown package manager — install Docker manually.',
);
return;
}
final tool = kEnvTools.firstWhere((t) => t.key == 'docker');
connection.onInstallCommandReady(
installCommandFor(manager, tool.packagesFor(manager)),
);
} catch (error) {
handleError(error, () => prepareInstall(server), () {}, "Retry");
}
}
void installDocker(ServerProfile server, String command) async {
try {
await getDataManager().installPackages(
server: server,
command: command,
enableUnit: 'docker',
);
connection.onInstallFinished();
} catch (error) {
connection.onInstallFinished();
handleError(error, () => installDocker(server, command), () {}, "Retry");
}
}
}

View File

@@ -0,0 +1,5 @@
abstract class ConnectDockerRun {
/// The run finished: [started] is whether the container came up, and
/// [output] is the container id on success or the failure text.
void onRunFinished(bool started, String output);
}

View File

@@ -0,0 +1,14 @@
import 'package:flutter/cupertino.dart';
import '../../../about/internal/application/ServerProfile.dart';
import 'DockerRunState.dart';
/// Form to `docker run` a new container.
class DockerRun extends StatefulWidget {
final ServerProfile server;
const DockerRun({super.key, required this.server});
@override
State<DockerRun> createState() => DockerRunState();
}

View File

@@ -0,0 +1,313 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:stacked/stacked.dart';
import '../../../about/external/data/Docker.dart';
import '../../../about/internal/application/ServerProfile.dart';
import '../../../about/internal/application/TextType.dart';
import '../../../comms/Comms.dart' show LogLine;
import '../../../designs/LogConsole.dart';
import '../../../designs/text/Text.dart';
import '../../../utils/Colors.dart';
import 'ConnectDockerRun.dart';
import 'DockerRun.dart';
import 'ViewDockerRun.dart';
class DockerRunState extends State<DockerRun> implements ConnectDockerRun {
ViewDockerRun? _model;
StreamSubscription<LogLine>? _logSub;
final _lines = <LogLine>[];
final _image = TextEditingController();
final _name = TextEditingController();
final _ports = TextEditingController();
final _env = TextEditingController();
final _volumes = TextEditingController();
final _command = TextEditingController();
String _restart = 'unless-stopped';
bool _detached = true;
bool _running = false;
(bool, String)? _result;
ServerProfile get _server => widget.server;
@override
void dispose() {
_logSub?.cancel();
for (final c in [_image, _name, _ports, _env, _volumes, _command]) {
c.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
return ViewModelBuilder<ViewDockerRun>.reactive(
viewModelBuilder: () => ViewDockerRun(context, this),
onViewModelReady: _onReady,
builder: (context, viewModel, child) => Scaffold(
appBar: AppBar(
title: text(
'Run container',
20,
TextType.Bold,
color: context.jp.text,
),
),
body: ListView(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 40),
children: [
_field(
'Image',
_image,
hint: 'nginx:latest · redis:7 · postgres:16',
),
_field('Container name (optional)', _name, hint: 'my-nginx'),
_field(
'Ports (host:container, one per line)',
_ports,
hint: '8080:80\n443:443',
lines: 2,
mono: true,
),
_field(
'Environment (KEY=VALUE, one per line)',
_env,
hint: 'POSTGRES_PASSWORD=secret',
lines: 2,
mono: true,
),
_field(
'Volumes (host:container, one per line)',
_volumes,
hint: '/data/pg:/var/lib/postgresql/data',
lines: 2,
mono: true,
),
Row(
children: [
Expanded(
child: DropdownButtonFormField<String>(
initialValue: _restart,
decoration: const InputDecoration(
labelText: 'Restart policy',
),
items: const [
DropdownMenuItem(value: 'no', child: Text('no')),
DropdownMenuItem(
value: 'on-failure',
child: Text('on-failure'),
),
DropdownMenuItem(value: 'always', child: Text('always')),
DropdownMenuItem(
value: 'unless-stopped',
child: Text('unless-stopped'),
),
],
onChanged: _onRestartChanged,
),
),
const SizedBox(width: 12),
Expanded(
child: SwitchListTile(
contentPadding: EdgeInsets.zero,
dense: true,
value: _detached,
onChanged: _onDetachedChanged,
title: text(
'Detached (-d)',
14,
TextType.Regular,
color: context.jp.text,
),
),
),
],
),
_field(
'Command / args (optional)',
_command,
hint: 'redis-server --appendonly yes',
mono: true,
),
const SizedBox(height: 8),
FilledButton.icon(
onPressed: _running ? null : _onRun,
icon: _running
? SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colorWhite,
),
)
: const Icon(Icons.play_arrow),
label: text(
_running ? 'Starting…' : 'Run container',
14,
TextType.Bold,
color: colorWhite,
),
),
if (_result != null) ...[
const SizedBox(height: 14),
_banner(_result!),
],
if (_lines.isNotEmpty) ...[
const SizedBox(height: 14),
LogConsole(
logs: _lines,
height: 220,
onClear: _onClearLogs,
onCopy: _onCopyLogs,
),
],
],
),
),
);
}
void _onReady(ViewDockerRun model) {
_model = model;
// onViewModelReady fires mid-build; a synchronous setState from the
// first load would dirty this State while it is still being built.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _initiate();
});
}
Future<void> _initiate() async {
_logSub = _model?.logs.listen(_onLogLine);
}
void _onLogLine(LogLine line) {
if (!mounted) return;
setState(() => _lines.add(line));
}
DockerRunSpec _spec() => DockerRunSpec(
image: _image.text,
name: _name.text,
ports: _ports.text,
env: _env.text,
volumes: _volumes.text,
restart: _restart,
detached: _detached,
command: _command.text,
);
// ── named handlers ─────────────────────────────────────────────────────────
void _onRun() {
if (_image.text.trim().isEmpty) {
_notify('Enter an image (e.g. nginx:latest).');
return;
}
setState(() {
_running = true;
_lines.clear();
_result = null;
});
_model?.runContainer(_server, _spec());
}
void _onRestartChanged(String? v) => setState(() => _restart = v ?? 'no');
void _onDetachedChanged(bool v) => setState(() => _detached = v);
void _onClearLogs() => setState(() => _lines.clear());
void _onCopyLogs() {
Clipboard.setData(
ClipboardData(text: _lines.map((l) => l.text).join('\n')),
);
_notify('Logs copied.');
}
void _onViewContainers() => Navigator.pop(context, true);
void _notify(String message) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
// ── ConnectDockerRun ───────────────────────────────────────────────────────
@override
void onRunFinished(bool started, String output) {
if (!mounted) return;
setState(() {
_running = false;
_result = (started, output);
});
}
// ── screen-only widgets ────────────────────────────────────────────────────
Widget _banner((bool, String) result) {
final (ok, output) = result;
final color = ok ? PeachTheme.seed : PeachTheme.danger;
return Container(
padding: const EdgeInsets.fromLTRB(14, 12, 12, 12),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color.withValues(alpha: 0.5)),
),
child: Row(
children: [
Icon(ok ? Icons.check_circle : Icons.error, color: color),
const SizedBox(width: 10),
Expanded(
child: text(
ok ? 'Container started ✓ (${_shortId(output)})' : output,
14,
TextType.Regular,
color: color,
),
),
if (ok)
FilledButton(
onPressed: _onViewContainers,
child: text('View', 13, TextType.Bold, color: colorWhite),
),
],
),
);
}
/// `docker run -d` echoes the full container id; the short form is what the
/// CLI shows everywhere else.
String _shortId(String output) {
final id = output.trim().split('\n').last.trim();
return id.length <= 12 ? id : '${id.substring(0, 12)}…';
}
Widget _field(
String label,
TextEditingController controller, {
String? hint,
int lines = 1,
bool mono = false,
}) {
return Padding(
padding: const EdgeInsets.only(bottom: 14),
child: TextField(
controller: controller,
maxLines: lines,
style: mono
? const TextStyle(fontFamily: 'monospace', fontSize: 13)
: null,
decoration: InputDecoration(labelText: label, hintText: hint),
),
);
}
}

View File

@@ -0,0 +1,25 @@
import '../../../about/external/data/Docker.dart';
import '../../../about/internal/application/ServerProfile.dart';
import '../../../comms/Comms.dart' show LogLine;
import '../../parent/ParentViewModel.dart';
import 'ConnectDockerRun.dart';
class ViewDockerRun extends ParentViewModel {
ConnectDockerRun connection;
ViewDockerRun(super.context, this.connection);
/// The command's output as it runs, shown in the console.
Stream<LogLine> get logs => getDataManager().logs;
void runContainer(ServerProfile server, DockerRunSpec spec) async {
if (!await canReach(server, () => runContainer(server, spec))) return;
try {
final (started, output) = await getDataManager().dockerRun(server, spec);
connection.onRunFinished(started, output);
} catch (error) {
connection.onRunFinished(false, error.toString());
handleError(error, () => runContainer(server, spec), () {}, "Retry");
}
}
}

View File

@@ -0,0 +1,18 @@
import '../../comms/Comms.dart' show RemoteEntry;
abstract class ConnectFiles {
/// The user's home directory, resolved on open — the starting location.
void onHomeResolved(String path);
/// The contents of the directory currently being shown.
void onDirectoryLoaded(List<RemoteEntry> entries);
/// The listing failed; [message] explains why.
void onDirectoryFailed(String message);
/// A transfer finished (or failed) with a message to surface.
void onTransferFinished(String message);
/// A remote entry was deleted.
void onEntryDeleted();
}

View File

@@ -0,0 +1,15 @@
import 'package:flutter/cupertino.dart';
import '../../about/internal/application/ServerProfile.dart';
import 'FilesState.dart';
/// Browse the server over SFTP: navigate directories, upload local files into
/// the current directory, and download remote files to local disk.
class Files extends StatefulWidget {
final ServerProfile server;
const Files({super.key, required this.server});
@override
State<Files> createState() => FilesState();
}

View File

@@ -0,0 +1,357 @@
import 'dart:async';
import 'package:desktop_drop/desktop_drop.dart';
import 'package:flutter/material.dart';
import 'package:stacked/stacked.dart';
import '../../about/internal/application/NotificationType.dart';
import '../../about/internal/application/ServerProfile.dart';
import '../../about/internal/application/TextType.dart';
import '../../comms/Comms.dart' show RemoteEntry, TransferProgress;
import '../../designs/text/Text.dart';
import '../../utils/Colors.dart';
import '../../utils/CommonUtils.dart';
import 'ConnectFiles.dart';
import 'Files.dart';
import 'ViewFiles.dart';
class FilesState extends State<Files> implements ConnectFiles {
ViewFiles? _model;
StreamSubscription<TransferProgress?>? _progressSub;
String _path = '.';
List<RemoteEntry> _entries = [];
bool _loading = true;
bool _busy = false;
bool _dragging = false;
String? _error;
double? _progress;
ServerProfile get _server => widget.server;
@override
void dispose() {
_progressSub?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ViewModelBuilder<ViewFiles>.reactive(
viewModelBuilder: () => ViewFiles(context, this),
onViewModelReady: _onReady,
builder: (context, viewModel, child) => Scaffold(
appBar: AppBar(
title: text(
'${_server.name} · files',
20,
TextType.Bold,
color: context.jp.text,
),
actions: [
IconButton(
tooltip: 'Refresh',
onPressed: _busy ? null : _onRefresh,
icon: const Icon(Icons.refresh),
),
const SizedBox(width: 4),
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: _busy ? null : _onUploadPicked,
icon: const Icon(Icons.upload),
label: text('Upload', 14, TextType.Bold, color: colorWhite),
),
body: DropTarget(
onDragEntered: _onDragEntered,
onDragExited: _onDragExited,
onDragDone: _onDragDone,
child: Column(
children: [
_pathBar(),
if (_progress != null)
LinearProgressIndicator(
value: _progress == 0 ? null : _progress,
minHeight: 3,
),
Expanded(child: _body()),
],
),
),
),
);
}
void _onReady(ViewFiles model) {
_model = model;
// onViewModelReady fires mid-build; a synchronous setState from the
// first load would dirty this State while it is still being built.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _initiate();
});
}
Future<void> _initiate() async {
_progressSub = _model?.transferProgress.listen(_onProgress);
_model?.resolveHome(_server);
}
void _onProgress(TransferProgress? p) {
if (!mounted) return;
setState(() => _progress = p?.fraction);
}
// ── named handlers ─────────────────────────────────────────────────────────
void _onRefresh() {
setState(() {
_loading = true;
_error = null;
});
_model?.listDirectory(_server, _path);
}
void _onEnter(RemoteEntry e) {
if (!e.isDir) return;
setState(() => _path = joinRemote(_path, e.name));
_onRefresh();
}
void _onUp() {
setState(() => _path = parentRemote(_path));
_onRefresh();
}
void _onUploadPicked() {
setState(() {
_busy = true;
_progress = 0;
});
_model?.uploadPicked(_server, _path, joinRemote);
}
void _onDragEntered(DropEventDetails _) => setState(() => _dragging = true);
void _onDragExited(DropEventDetails _) => setState(() => _dragging = false);
Future<void> _onDragDone(DropDoneDetails details) async {
setState(() {
_dragging = false;
_busy = true;
_progress = 0;
});
for (final file in details.files) {
final bytes = await file.readAsBytes();
_model?.uploadBytes(
_server,
joinRemote(_path, file.name),
bytes,
file.name,
);
}
}
void _onDownload(RemoteEntry e) {
setState(() {
_busy = true;
_progress = 0;
});
_model?.downloadFile(_server, joinRemote(_path, e.name), e.name);
}
void _onDelete(RemoteEntry e) {
_model?.showApplicationNotification(
NotificationType.warning,
'Delete "${e.name}"?',
e.isDir
? 'Removes the directory (it must be empty).'
: 'Permanently deletes this file on the server.',
true,
true,
null,
action: 'Delete',
positiveAction: () => _onConfirmDelete(e),
);
}
void _onConfirmDelete(RemoteEntry e) {
Navigator.pop(context);
_model?.deleteEntry(_server, joinRemote(_path, e.name), e.isDir);
}
void _onRowMenu(String value, RemoteEntry e) {
if (value == 'download') _onDownload(e);
if (value == 'delete') _onDelete(e);
}
void _notify(String message) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
// ── ConnectFiles ───────────────────────────────────────────────────────────
@override
void onHomeResolved(String path) {
if (!mounted) return;
setState(() => _path = path);
_model?.listDirectory(_server, path);
}
@override
void onDirectoryLoaded(List<RemoteEntry> entries) {
if (!mounted) return;
setState(() {
_entries = entries;
_loading = false;
_error = null;
});
}
@override
void onDirectoryFailed(String message) {
if (!mounted) return;
setState(() {
_loading = false;
_error = message;
});
}
@override
void onTransferFinished(String message) {
if (!mounted) return;
setState(() {
_busy = false;
_progress = null;
});
_notify(message);
_onRefresh();
}
@override
void onEntryDeleted() {
_onRefresh();
}
// ── screen-only widgets ────────────────────────────────────────────────────
Widget _pathBar() {
final jp = context.jp;
return Container(
color: _dragging ? PeachTheme.seed.withValues(alpha: 0.12) : jp.surface,
padding: const EdgeInsets.fromLTRB(8, 8, 12, 8),
child: Row(
children: [
IconButton(
tooltip: 'Up',
onPressed: _busy || _path == '/' ? null : _onUp,
icon: const Icon(Icons.arrow_upward),
),
Expanded(
child: text(
_dragging ? 'Drop files to upload here' : _path,
13,
_dragging ? TextType.Bold : TextType.Regular,
color: _dragging ? PeachTheme.seed : jp.textDim,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
Widget _body() {
if (_loading) return const Center(child: CircularProgressIndicator());
if (_error != null) return _errorView();
if (_entries.isEmpty) {
return Center(
child: text(
'Empty directory',
14,
TextType.Regular,
color: context.jp.textFaint,
),
);
}
return ListView.separated(
itemCount: _entries.length,
separatorBuilder: (_, i) => Divider(height: 1, color: context.jp.border),
itemBuilder: (_, i) => _row(_entries[i]),
);
}
Widget _errorView() => Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, color: colorNegative, size: 40),
const SizedBox(height: 12),
text(
_error!,
14,
TextType.Regular,
color: colorNegative,
align: TextAlign.center,
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _onRefresh,
icon: const Icon(Icons.refresh),
label: text('Retry', 14, TextType.Bold, color: colorWhite),
),
],
),
),
);
Widget _row(RemoteEntry e) {
final jp = context.jp;
return ListTile(
leading: Icon(
e.isDir ? Icons.folder : Icons.insert_drive_file_outlined,
color: e.isDir ? PeachTheme.accent : jp.textDim,
),
title: text(
e.name,
14,
TextType.Regular,
color: jp.text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: e.isDir
? null
: text(formatBytes(e.size), 12, TextType.Light, color: jp.textFaint),
onTap: e.isDir ? () => _onEnter(e) : null,
trailing: PopupMenuButton<String>(
onSelected: (v) => _onRowMenu(v, e),
itemBuilder: (_) => [
if (!e.isDir)
const PopupMenuItem(
value: 'download',
child: ListTile(
leading: Icon(Icons.download),
title: Text('Download'),
contentPadding: EdgeInsets.zero,
),
),
const PopupMenuItem(
value: 'delete',
child: ListTile(
leading: Icon(Icons.delete_outline),
title: Text('Delete'),
contentPadding: EdgeInsets.zero,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,118 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import '../../about/internal/application/ServerProfile.dart';
import '../../comms/Comms.dart' show TransferProgress;
import '../parent/ParentViewModel.dart';
import 'ConnectFiles.dart';
class ViewFiles extends ParentViewModel {
ConnectFiles connection;
ViewFiles(super.context, this.connection);
/// Upload/download progress, subscribed to by the screen so the bar can
/// track a transfer as it runs.
Stream<TransferProgress?> get transferProgress =>
getDataManager().transferProgress;
/// Resolves the login directory so the browser opens somewhere useful.
void resolveHome(ServerProfile server) async {
if (!await canReach(server, () => resolveHome(server))) return;
try {
final home = await getDataManager().resolveHome(server);
connection.onHomeResolved(home);
} catch (error) {
connection.onDirectoryFailed(error.toString());
}
}
void listDirectory(ServerProfile server, String path) async {
try {
final entries = await getDataManager().listDir(server, path);
connection.onDirectoryLoaded(entries);
} catch (error) {
connection.onDirectoryFailed(error.toString());
}
}
/// Uploads bytes already in memory (a drag-and-drop payload).
void uploadBytes(
ServerProfile server,
String remotePath,
Uint8List bytes,
String label,
) async {
if (!await canReach(
server,
() => uploadBytes(server, remotePath, bytes, label),
)) {
return;
}
try {
await getDataManager().uploadFile(server, bytes, remotePath);
connection.onTransferFinished('Uploaded $label');
} catch (error) {
connection.onTransferFinished('Upload failed: $error');
}
}
/// Prompts for a local file, then uploads it into [directory].
void uploadPicked(
ServerProfile server,
String directory,
String Function(String, String) join,
) async {
try {
final picked = await FilePicker.pickFiles(withData: true);
if (picked == null || picked.files.isEmpty) return;
final file = picked.files.first;
final bytes = file.bytes ?? await File(file.path!).readAsBytes();
uploadBytes(server, join(directory, file.name), bytes, file.name);
} catch (error) {
handleError(
error,
() => uploadPicked(server, directory, join),
() {},
"Retry",
);
}
}
/// Downloads [remotePath] and writes it wherever the user chooses.
void downloadFile(
ServerProfile server,
String remotePath,
String fileName,
) async {
if (!await canReach(
server,
() => downloadFile(server, remotePath, fileName),
)) {
return;
}
try {
final bytes = await getDataManager().downloadFile(server, remotePath);
final saved = await FilePicker.saveFile(fileName: fileName, bytes: bytes);
connection.onTransferFinished(
saved == null ? 'Download cancelled' : 'Saved to $saved',
);
} catch (error) {
connection.onTransferFinished('Download failed: $error');
}
}
void deleteEntry(ServerProfile server, String remotePath, bool isDir) async {
if (!await canReach(server, () => deleteEntry(server, remotePath, isDir))) {
return;
}
try {
await getDataManager().deleteRemote(server, remotePath, isDir);
connection.onEntryDeleted();
} catch (error) {
connection.onTransferFinished('Delete failed: $error');
}
}
}

View File

@@ -0,0 +1,13 @@
import '../../about/external/data/Health.dart';
abstract class ConnectHealth {
/// Every known service was marked as pending a check.
void onSweepStarted();
/// One service's check came back. Results arrive as they complete rather
/// than in a single batch, so the dashboard fills in progressively.
void onServiceChecked(String serviceId, HealthResult result);
/// All services in this sweep have been checked.
void onSweepFinished();
}

View File

@@ -0,0 +1,14 @@
import 'package:flutter/cupertino.dart';
import '../../about/internal/application/ServerProfile.dart';
import 'HealthState.dart';
/// Live health dashboard across all servers and their services.
class Health extends StatefulWidget {
final List<ServerProfile> servers;
const Health({super.key, required this.servers});
@override
State<Health> createState() => HealthState();
}

View File

@@ -0,0 +1,294 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:stacked/stacked.dart';
import '../../about/external/data/Health.dart';
import '../../about/internal/application/ServerProfile.dart';
import '../../about/internal/application/ServiceConfig.dart';
import '../../about/internal/application/TextType.dart';
import '../../designs/StatCard.dart';
import '../../designs/text/Text.dart';
import '../../utils/Colors.dart';
import 'ConnectHealth.dart';
import 'Health.dart';
import 'ViewHealth.dart';
class HealthState extends State<Health> implements ConnectHealth {
ViewHealth? _model;
final Map<String, HealthResult> _results = {};
bool _running = false;
bool _auto = false;
Timer? _timer;
List<ServerProfile> get _servers => widget.servers;
int get _totalServices => _servers.fold(0, (n, s) => n + s.services.length);
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ViewModelBuilder<ViewHealth>.reactive(
viewModelBuilder: () => ViewHealth(context, this),
onViewModelReady: _onReady,
builder: (context, viewModel, child) => Scaffold(
appBar: AppBar(
title: text('Health', 20, TextType.Bold, color: context.jp.text),
actions: [
Row(
children: [
text('Auto', 13, TextType.Regular, color: context.jp.textDim),
Switch(value: _auto, onChanged: _onToggleAuto),
],
),
IconButton(
tooltip: 'Run checks',
onPressed: _running ? null : _onRunChecks,
icon: _running
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh),
),
const SizedBox(width: 4),
],
),
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 40),
children: [
_statRow(),
const SizedBox(height: 20),
if (_totalServices == 0)
_empty()
else
for (final server in _servers)
if (server.services.isNotEmpty) _serverGroup(server),
],
),
),
);
}
void _onReady(ViewHealth model) {
_model = model;
// onViewModelReady fires mid-build; a synchronous setState from the
// first load would dirty this State while it is still being built.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _initiate();
});
}
Future<void> _initiate() async {
_model?.runSweep(_servers);
}
int _count(HealthStatus s) =>
_results.values.where((r) => r.status == s).length;
// ── named handlers ─────────────────────────────────────────────────────────
void _onRunChecks() => _model?.runSweep(_servers);
void _onToggleAuto(bool v) {
setState(() => _auto = v);
_timer?.cancel();
if (v) {
_timer = Timer.periodic(
const Duration(seconds: 30),
(_) => _model?.runSweep(_servers),
);
}
}
// ── ConnectHealth ──────────────────────────────────────────────────────────
@override
void onSweepStarted() {
if (!mounted) return;
setState(() {
_running = true;
for (final server in _servers) {
for (final service in server.services) {
_results[service.id] = HealthResult.checking();
}
}
});
}
@override
void onServiceChecked(String serviceId, HealthResult result) {
if (!mounted) return;
setState(() => _results[serviceId] = result);
}
@override
void onSweepFinished() {
if (!mounted) return;
setState(() => _running = false);
}
// ── screen-only widgets ────────────────────────────────────────────────────
Widget _statRow() {
return LayoutBuilder(
builder: (context, c) {
const gap = 12.0;
final cols = c.maxWidth >= 640 ? 3 : 1;
final w = (c.maxWidth - gap * (cols - 1)) / cols;
final cards = [
StatCard(
icon: Icons.favorite,
label: 'Healthy',
value: '${_count(HealthStatus.healthy)}',
color: PeachTheme.seed,
),
StatCard(
icon: Icons.heart_broken,
label: 'Unhealthy',
value: '${_count(HealthStatus.unhealthy)}',
color: PeachTheme.danger,
),
StatCard(
icon: Icons.dns,
label: 'Services',
value: '$_totalServices',
color: PeachTheme.accent,
),
];
return Wrap(
spacing: gap,
runSpacing: gap,
children: [for (final card in cards) SizedBox(width: w, child: card)],
);
},
);
}
Widget _empty() => Container(
padding: const EdgeInsets.symmetric(vertical: 40),
alignment: Alignment.center,
decoration: PeachTheme.panel(context),
child: text(
'No services to monitor yet.',
14,
TextType.Regular,
color: context.jp.textDim,
),
);
Widget _serverGroup(ServerProfile server) {
final jp = context.jp;
return Padding(
padding: const EdgeInsets.only(bottom: 18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.dns, size: 15, color: PeachTheme.seed),
const SizedBox(width: 8),
text(server.name, 15, TextType.Bold, color: jp.text),
const SizedBox(width: 8),
text(server.host, 12, TextType.Regular, color: jp.textFaint),
],
),
const SizedBox(height: 10),
Container(
decoration: PeachTheme.panel(context, radius: 16),
child: Column(
children: [
for (var i = 0; i < server.services.length; i++) ...[
if (i > 0) Divider(height: 1, color: jp.border),
_serviceRow(server.services[i]),
],
],
),
),
],
),
);
}
Widget _serviceRow(ServiceConfig svc) {
final jp = context.jp;
final result = _results[svc.id];
final (color, icon, label) = _style(result?.status ?? HealthStatus.unknown);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Row(
children: [
Icon(
svc.isWeb ? Icons.language : Icons.settings_suggest,
size: 18,
color: jp.textDim,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
text(
svc.name,
14,
TextType.Regular,
color: jp.text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
text(
'${svc.healthType.label}'
'${result != null ? ' · ${result.detail}' : ''}',
11.5,
TextType.Light,
color: jp.textFaint,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 10),
if (result?.status == HealthStatus.checking)
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
else
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.16),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: color.withValues(alpha: 0.45)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 12, color: color),
const SizedBox(width: 5),
text(label, 11, TextType.Bold, color: color),
],
),
),
],
),
);
}
(Color, IconData, String) _style(HealthStatus s) => switch (s) {
HealthStatus.healthy => (PeachTheme.seed, Icons.check_circle, 'HEALTHY'),
HealthStatus.unhealthy => (PeachTheme.danger, Icons.error, 'DOWN'),
HealthStatus.disabled => (context.jp.textFaint, Icons.remove_circle, 'OFF'),
HealthStatus.checking => (PeachTheme.accent, Icons.sync, 'CHECK'),
HealthStatus.unknown => (PeachTheme.warn, Icons.help, 'UNKNOWN'),
};
}

View File

@@ -0,0 +1,48 @@
import '../../about/external/data/Health.dart';
import '../../about/internal/application/ServerProfile.dart';
import '../parent/ParentViewModel.dart';
import 'ConnectHealth.dart';
class ViewHealth extends ParentViewModel {
ConnectHealth connection;
ViewHealth(super.context, this.connection);
bool _running = false;
bool get isRunning => _running;
/// Checks every service on every server, reporting each result as it lands.
/// A server that can't be reached marks its own services unhealthy and the
/// sweep moves on, so one dead box doesn't stall the whole dashboard.
void runSweep(List<ServerProfile> servers) async {
if (_running) return;
_running = true;
notifyListeners();
connection.onSweepStarted();
for (final server in servers) {
for (final service in server.services) {
try {
final result = await getDataManager().checkHealth(server, service);
connection.onServiceChecked(service.id, result);
} catch (error) {
connection.onServiceChecked(
service.id,
HealthResult(HealthStatus.unhealthy, _describe(error)),
);
}
}
}
getDataManager().disconnect();
_running = false;
notifyListeners();
connection.onSweepFinished();
}
String _describe(Object error) {
final text = error.toString();
return text.length > 80 ? '${text.substring(0, 77)}…' : text;
}
}

View File

@@ -0,0 +1,7 @@
import '../../about/internal/application/DeploymentRecord.dart';
abstract class ConnectHistory {
void onHistoryLoaded(List<DeploymentRecord> history);
void onHistoryCleared();
}

View File

@@ -0,0 +1,15 @@
import 'package:flutter/cupertino.dart';
import '../../about/internal/application/DeploymentRecord.dart';
import 'HistoryState.dart';
class History extends StatefulWidget {
/// Seeded by the caller so the timeline paints immediately; the ViewModel
/// re-reads it on open.
final List<DeploymentRecord> history;
const History({super.key, this.history = const []});
@override
State<History> createState() => HistoryState();
}

View File

@@ -0,0 +1,337 @@
import 'package:flutter/material.dart';
import 'package:stacked/stacked.dart';
import '../../about/internal/application/DeploymentRecord.dart';
import '../../about/internal/application/NotificationType.dart';
import '../../about/internal/application/TextType.dart';
import '../../designs/text/Text.dart';
import '../../utils/Colors.dart';
import '../../utils/CommonUtils.dart';
import 'ConnectHistory.dart';
import 'History.dart';
import 'ViewHistory.dart';
class HistoryState extends State<History> implements ConnectHistory {
ViewHistory? _model;
List<DeploymentRecord> _history = [];
@override
void initState() {
super.initState();
_history = widget.history;
}
@override
Widget build(BuildContext context) {
return ViewModelBuilder<ViewHistory>.reactive(
viewModelBuilder: () => ViewHistory(context, this),
onViewModelReady: _onReady,
builder: (context, viewModel, child) => Scaffold(
appBar: AppBar(
title: text(
'Deployment history',
20,
TextType.Bold,
color: context.jp.text,
),
actions: [
if (_history.isNotEmpty)
IconButton(
tooltip: 'Clear history',
onPressed: _onClear,
icon: const Icon(Icons.delete_sweep),
),
const SizedBox(width: 8),
],
),
body: _history.isEmpty ? _empty() : _timeline(),
),
);
}
void _onReady(ViewHistory model) {
_model = model;
// onViewModelReady fires mid-build; a synchronous setState from the
// first load would dirty this State while it is still being built.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _initiate();
});
}
Future<void> _initiate() async {
_model?.loadHistory();
}
// ── derived state ──────────────────────────────────────────────────────────
/// Records (already newest-first) grouped by calendar day.
List<MapEntry<DateTime, List<DeploymentRecord>>> get _groups {
final map = <DateTime, List<DeploymentRecord>>{};
for (final r in _history) {
final day = DateTime(r.time.year, r.time.month, r.time.day);
map.putIfAbsent(day, () => []).add(r);
}
return map.entries.toList()..sort((a, b) => b.key.compareTo(a.key));
}
String _dayLabel(DateTime d) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final diff = today.difference(d).inDays;
if (diff == 0) return 'Today';
if (diff == 1) return 'Yesterday';
if (diff < 7) return '$diff days ago';
String two(int n) => n.toString().padLeft(2, '0');
return '${d.year}-${two(d.month)}-${two(d.day)}';
}
// ── named handlers ─────────────────────────────────────────────────────────
void _onClear() {
_model?.showApplicationNotification(
NotificationType.warning,
'Clear history?',
'This deletes all tracked deployment records. It cannot be undone.',
true,
true,
null,
action: 'Clear history',
positiveAction: _onConfirmClear,
);
}
void _onConfirmClear() {
Navigator.pop(context);
_model?.clearHistory();
}
// ── ConnectHistory ─────────────────────────────────────────────────────────
@override
void onHistoryLoaded(List<DeploymentRecord> history) {
if (!mounted) return;
setState(() => _history = history);
}
@override
void onHistoryCleared() {
if (!mounted) return;
setState(() => _history = []);
}
// ── screen-only widgets ────────────────────────────────────────────────────
Widget _empty() => Center(
child: text(
'No deployments recorded yet.',
14,
TextType.Regular,
color: context.jp.textDim,
),
);
Widget _timeline() {
final groups = _groups;
return ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
itemCount: groups.length,
itemBuilder: (context, i) => _dayGroup(
_dayLabel(groups[i].key),
groups[i].value,
i == groups.length - 1,
),
);
}
Widget _dayGroup(String label, List<DeploymentRecord> records, bool isLast) {
final jp = context.jp;
final ok = records.where((r) => r.status == DeployStatus.success).length;
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Timeline rail: dot + connecting line.
Column(
children: [
Container(
width: 14,
height: 14,
margin: const EdgeInsets.only(top: 4),
decoration: BoxDecoration(
color: PeachTheme.seed,
shape: BoxShape.circle,
boxShadow: PeachTheme.glow(
PeachTheme.seed,
opacity: 0.5,
blur: 10,
),
),
),
Expanded(
child: Container(
width: 2,
margin: const EdgeInsets.symmetric(vertical: 4),
color: isLast ? Colors.transparent : jp.border,
),
),
],
),
const SizedBox(width: 16),
Expanded(
child: Padding(
padding: EdgeInsets.only(bottom: isLast ? 0 : 26),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
text(label, 16, TextType.Bold, color: jp.text),
const SizedBox(width: 8),
text(
'${records.length} deploys · $ok ok',
12,
TextType.Regular,
color: jp.textFaint,
),
],
),
const SizedBox(height: 12),
LayoutBuilder(
builder: (context, c) {
const gap = 12.0;
final cols = (c.maxWidth / 300).floor().clamp(1, 4);
final w = (c.maxWidth - gap * (cols - 1)) / cols;
return Wrap(
spacing: gap,
runSpacing: gap,
children: [
for (final r in records)
SizedBox(width: w, child: _timelineTile(r)),
],
);
},
),
],
),
),
),
],
),
);
}
Widget _timelineTile(DeploymentRecord record) {
final jp = context.jp;
final (color, icon) = switch (record.status) {
DeployStatus.success => (PeachTheme.seed, Icons.check_circle),
DeployStatus.failure => (PeachTheme.danger, Icons.error),
DeployStatus.reverted => (PeachTheme.warn, Icons.undo),
};
String two(int n) => n.toString().padLeft(2, '0');
final clock = '${two(record.time.hour)}:${two(record.time.minute)}';
return Container(
padding: const EdgeInsets.all(14),
decoration: PeachTheme.panel(context, radius: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: color.withValues(alpha: 0.35)),
),
child: Icon(icon, size: 18, color: color),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
text(clock, 15, TextType.Bold, color: jp.text),
text(
timeAgo(record.time),
11,
TextType.Regular,
color: jp.textFaint,
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.16),
borderRadius: BorderRadius.circular(20),
),
child: text(
record.status.label,
10.5,
TextType.Bold,
color: color,
),
),
],
),
const SizedBox(height: 12),
text(
'${record.serverName} · ${record.serviceLabel}',
14,
TextType.Bold,
color: jp.text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 3),
_metaRow(
Icons.inventory_2,
record.jarName.isEmpty ? '—' : record.jarName,
12,
jp.textDim,
),
const SizedBox(height: 3),
_metaRow(
Icons.folder_outlined,
record.remotePath,
11.5,
jp.textFaint,
),
if (record.message.isNotEmpty) ...[
const SizedBox(height: 8),
text(
record.message,
11.5,
TextType.Regular,
color: jp.textDim,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
),
);
}
Widget _metaRow(IconData icon, String label, double size, Color color) => Row(
children: [
Icon(icon, size: 12, color: context.jp.textFaint),
const SizedBox(width: 5),
Expanded(
child: text(
label,
size,
TextType.Regular,
color: color,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
);
}

View File

@@ -0,0 +1,28 @@
import '../parent/ParentViewModel.dart';
import 'ConnectHistory.dart';
class ViewHistory extends ParentViewModel {
ConnectHistory connection;
ViewHistory(super.context, this.connection);
void loadHistory() async {
try {
final history = await getDataManager().getDeployments();
connection.onHistoryLoaded(history);
} catch (error) {
handleError(error, loadHistory, () {}, "Retry");
}
}
void clearHistory() async {
showLoading("Clearing history …");
try {
await getDataManager().clearDeployments();
closeLoading();
connection.onHistoryCleared();
} catch (error) {
handleError(error, clearHistory, () {}, "Retry");
}
}
}

View File

@@ -0,0 +1,16 @@
import '../../about/internal/application/DeploymentRecord.dart';
import '../../about/internal/application/ServerProfile.dart';
abstract class ConnectHome {
/// The dashboard's data, freshly read from storage.
void onDashboardLoaded(
List<ServerProfile> servers,
List<DeploymentRecord> history,
);
/// A server was saved; [server] is the stored version.
void onServerSaved(ServerProfile server);
/// The theme preference changed to [mode].
void onThemeChanged(String mode);
}

View File

@@ -0,0 +1,17 @@
import 'package:flutter/cupertino.dart';
import '../../about/internal/application/DeploymentRecord.dart';
import '../../about/internal/application/ServerProfile.dart';
import 'HomeState.dart';
class Home extends StatefulWidget {
/// Seeded by the splash so the dashboard paints populated on first frame;
/// every later change is reloaded through the ViewModel.
final List<ServerProfile> servers;
final List<DeploymentRecord> history;
const Home({super.key, this.servers = const [], this.history = const []});
@override
State<Home> createState() => HomeState();
}

View File

@@ -0,0 +1,723 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:stacked/stacked.dart';
import '../../about/internal/application/DeploymentRecord.dart';
import '../../about/internal/application/NavigatorType.dart';
import '../../about/internal/application/ServerProfile.dart';
import '../../about/internal/application/ServiceConfig.dart';
import '../../about/internal/application/TextType.dart';
import '../../configs/Navigator.dart';
import '../../configs/ThemeController.dart';
import '../../designs/ActivityChart.dart';
import '../../designs/InsightsCard.dart';
import '../../designs/RecentDeploys.dart';
import '../../designs/Responsive.dart';
import '../../designs/StatCard.dart';
import '../../designs/StatsDonut.dart';
import '../../designs/navigation/SideNav.dart';
import '../../designs/text/Text.dart';
import '../../utils/Colors.dart';
import '../health/Health.dart';
import '../history/History.dart';
import '../server/detail/ServerDetail.dart';
import '../server/editor/ServerEditor.dart';
import '../server/setup/ServerSetup.dart';
import 'ConnectHome.dart';
import 'Home.dart';
import 'ViewHome.dart';
enum HomeView { list, grid }
class HomeState extends State<Home> implements ConnectHome {
static const _viewKey = 'peach.homeview';
ViewHome? _model;
List<ServerProfile> _servers = [];
List<DeploymentRecord> _history = [];
HomeView _view = HomeView.grid;
@override
void initState() {
super.initState();
_servers = widget.servers;
_history = widget.history;
}
@override
Widget build(BuildContext context) {
return ViewModelBuilder<ViewHome>.reactive(
viewModelBuilder: () => ViewHome(context, this),
onViewModelReady: _onReady,
builder: (context, viewModel, child) => LayoutBuilder(
// The rail only earns its space on a real desktop width, so the
// breakpoint here is wider than Responsive's tablet cut-off.
builder: (context, constraints) => Responsive(
mobile: _mobileView(),
tablet: constraints.maxWidth >= 900
? _desktopView(constraints.maxWidth)
: _tabletView(),
desktop: _desktopView(constraints.maxWidth),
),
),
);
}
void _onReady(ViewHome model) {
_model = model;
// onViewModelReady fires mid-build; a synchronous setState from the
// first load would dirty this State while it is still being built.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _initiate();
});
}
Future<void> _initiate() async {
await _loadView();
_model?.loadDashboard();
}
// ── derived state ──────────────────────────────────────────────────────────
int get _success =>
_history.where((h) => h.status == DeployStatus.success).length;
int get _failed =>
_history.where((h) => h.status == DeployStatus.failure).length;
int get _reverted =>
_history.where((h) => h.status == DeployStatus.reverted).length;
int get _rate {
final attempted = _success + _failed;
return attempted == 0 ? 0 : ((_success / attempted) * 100).round();
}
int get _totalServices => _servers.fold(0, (n, s) => n + s.services.length);
// ── view preference ────────────────────────────────────────────────────────
Future<void> _loadView() async {
final prefs = await SharedPreferences.getInstance();
final stored = prefs.getString(_viewKey);
if (stored == HomeView.list.name && mounted) {
setState(() => _view = HomeView.list);
}
}
Future<void> _setView(HomeView v) async {
setState(() => _view = v);
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_viewKey, v.name);
}
void _onGridView() => _setView(HomeView.grid);
void _onListView() => _setView(HomeView.list);
// ── named handlers ─────────────────────────────────────────────────────────
void _onCycleTheme() => _model?.cycleTheme(ThemeController.name);
Future<void> _onNewServer() async {
final created = await PeachNavigation().navigateToPageWithData(
const ServerEditor(),
context,
);
if (created is! ServerProfile) return;
_model?.saveServer(created);
if (!mounted) return;
// Guided environment setup for the freshly-registered server.
await PeachNavigation().navigateToPageWithData(
ServerSetup(server: created),
context,
);
}
Future<void> _onOpenServer(ServerProfile server) async {
await PeachNavigation().navigateToPageWithData(
ServerDetail(server: server, history: _history),
context,
);
// The detail screen can add, edit or remove services and record deploys.
_model?.loadDashboard();
}
Future<void> _onOpenHistory() async {
await PeachNavigation().navigateToPageWithData(
History(history: _history),
context,
);
_model?.loadDashboard();
}
void _onOpenHealth() {
PeachNavigation().navigateToPage(
NavigatorType.justOpen,
Health(servers: _servers),
context,
);
}
// ── ConnectHome ────────────────────────────────────────────────────────────
@override
void onDashboardLoaded(
List<ServerProfile> servers,
List<DeploymentRecord> history,
) {
if (!mounted) return;
setState(() {
_servers = servers;
_history = history;
});
}
@override
void onServerSaved(ServerProfile server) {
// The dashboard reload that follows carries the new value; nothing extra
// to do here beyond keeping the interface honest.
}
@override
void onThemeChanged(String mode) {
ThemeController.apply(mode);
}
// ── layouts ────────────────────────────────────────────────────────────────
Widget _desktopView(double width) {
return Scaffold(
body: Row(
children: [
SideNav(
expanded: width >= 1140,
themeMode: ThemeController.mode.value,
onCycleTheme: () async => _onCycleTheme(),
onNewServer: _onNewServer,
items: [
NavItemData(
icon: Icons.grid_view_rounded,
label: 'Dashboard',
selected: true,
onTap: _onDashboardTap,
),
NavItemData(
icon: Icons.monitor_heart,
label: 'Health',
badge: _totalServices,
onTap: _onOpenHealth,
),
NavItemData(
icon: Icons.history,
label: 'History',
badge: _history.length,
onTap: _onOpenHistory,
),
],
),
Expanded(child: _mainArea()),
],
),
);
}
/// Tablet sits between the two: the dashboard body without the side rail.
Widget _tabletView() => Scaffold(body: _mainArea());
Widget _mobileView() {
final jp = context.jp;
final (themeIcon, themeTip) = switch (ThemeController.mode.value) {
ThemeMode.dark => (Icons.dark_mode, 'Dark theme'),
ThemeMode.light => (Icons.light_mode, 'Light theme'),
ThemeMode.system => (Icons.brightness_auto, 'System theme'),
};
return Scaffold(
appBar: AppBar(
titleSpacing: 20,
title: Row(
children: [
Icon(Icons.rocket_launch, color: colorPrimary),
const SizedBox(width: 10),
text('Peach', 17, TextType.Bold, color: jp.text),
],
),
actions: [
IconButton(
tooltip: themeTip,
onPressed: _onCycleTheme,
icon: Icon(themeIcon),
),
_viewToggle(jp),
const SizedBox(width: 4),
IconButton(
tooltip: 'Health',
onPressed: _onOpenHealth,
icon: const Icon(Icons.monitor_heart),
),
IconButton(
tooltip: 'History',
onPressed: _onOpenHistory,
icon: const Icon(Icons.history),
),
const SizedBox(width: 8),
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: _onNewServer,
icon: const Icon(Icons.add),
label: text('New server', 14, TextType.Bold, color: colorWhite),
),
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 96),
children: [
_statRow(),
const SizedBox(height: 16),
_bento(),
const SizedBox(height: 24),
_sectionHeader('SERVERS', _servers.length),
const SizedBox(height: 12),
if (_servers.isEmpty) _emptyServers() else _serversWrap(),
],
),
);
}
void _onDashboardTap() {}
// ── screen-only widgets ────────────────────────────────────────────────────
Widget _mainArea() {
final jp = context.jp;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(28, 22, 28, 8),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
text('Dashboard', 26, TextType.Bold, color: jp.text),
const SizedBox(height: 2),
text(
'${_servers.length} servers · ${_history.length} deployments',
14,
TextType.Regular,
color: jp.textDim,
),
],
),
),
_viewToggle(jp),
],
),
),
),
Expanded(
child: ListView(
padding: const EdgeInsets.fromLTRB(28, 8, 28, 32),
children: [
_statRow(),
const SizedBox(height: 16),
_bento(),
const SizedBox(height: 24),
_sectionHeader('SERVERS', _servers.length),
const SizedBox(height: 12),
if (_servers.isEmpty) _emptyServers() else _serversWrap(),
],
),
),
],
);
}
Widget _bento() {
final activity = ActivityChart(history: _history);
final donut = StatsDonut(
success: _success,
failed: _failed,
reverted: _reverted,
);
final insights = InsightsCard(
servers: _servers,
history: _history,
onViewHistory: _onOpenHistory,
);
final recent = RecentDeploys(
history: _history,
onViewAll: _onOpenHistory,
max: 5,
);
return LayoutBuilder(
builder: (context, c) {
if (c.maxWidth <= 760) {
return Column(
children: [
activity,
const SizedBox(height: 16),
donut,
const SizedBox(height: 16),
insights,
if (_history.isNotEmpty) ...[const SizedBox(height: 16), recent],
],
);
}
return Column(
children: [
IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(flex: 5, child: activity),
const SizedBox(width: 16),
Expanded(flex: 3, child: donut),
],
),
),
const SizedBox(height: 16),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(flex: 5, child: insights),
const SizedBox(width: 16),
Expanded(
flex: 3,
child: _history.isEmpty ? const SizedBox() : recent,
),
],
),
],
);
},
);
}
Widget _statRow() {
return LayoutBuilder(
builder: (context, c) {
const gap = 14.0;
final cols = c.maxWidth >= 900
? 4
: c.maxWidth >= 560
? 2
: 1;
final w = (c.maxWidth - gap * (cols - 1)) / cols;
final cards = [
StatCard(
icon: Icons.rocket_launch,
label: 'Deployments',
value: '${_history.length}',
color: PeachTheme.accent,
),
StatCard(
icon: Icons.verified,
label: 'Success rate',
value: _history.isEmpty ? '—' : '$_rate%',
color: PeachTheme.seed,
),
StatCard(
icon: Icons.error_outline,
label: 'Failed',
value: '$_failed',
color: PeachTheme.danger,
),
StatCard(
icon: Icons.dns,
label: 'Servers',
value: '${_servers.length}',
color: PeachTheme.violet,
),
];
return Wrap(
spacing: gap,
runSpacing: gap,
children: [for (final card in cards) SizedBox(width: w, child: card)],
);
},
);
}
Widget _serversWrap() {
if (_view == HomeView.list) {
return Column(
children: [
for (final s in _servers)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _serverListCard(s),
),
],
);
}
return LayoutBuilder(
builder: (context, c) {
const gap = 14.0;
final cols = (c.maxWidth / 300).floor().clamp(1, 4);
final w = (c.maxWidth - gap * (cols - 1)) / cols;
return Wrap(
spacing: gap,
runSpacing: gap,
children: [
for (final s in _servers)
SizedBox(width: w, child: _serverGridCard(s)),
],
);
},
);
}
int _deployCount(ServerProfile s) =>
_history.where((h) => h.serverId == s.id).length;
Widget _serverListCard(ServerProfile server) {
final jp = context.jp;
final count = _deployCount(server);
return Card(
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: () => _onOpenServer(server),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
_iconTile(46, 13),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
text(
server.name,
16,
TextType.Bold,
color: jp.text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 3),
text(
'${server.username}@${server.host}:${server.port}',
13,
TextType.Regular,
color: jp.textDim,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 10),
_kindChips(server),
const SizedBox(width: 14),
if (count > 0)
text(
'$count deploys',
12,
TextType.Regular,
color: jp.textFaint,
),
const SizedBox(width: 8),
Icon(Icons.chevron_right, color: jp.textFaint),
],
),
),
),
);
}
Widget _serverGridCard(ServerProfile server) {
final jp = context.jp;
final count = _deployCount(server);
return Card(
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: () => _onOpenServer(server),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
_iconTile(42, 12),
const Spacer(),
if (count > 0)
text('$count', 13, TextType.Bold, color: jp.textFaint),
],
),
const SizedBox(height: 14),
text(
server.name,
16,
TextType.Bold,
color: jp.text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 3),
text(
'${server.username}@${server.host}',
12,
TextType.Regular,
color: jp.textDim,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 14),
_kindChips(server),
],
),
),
),
);
}
Widget _iconTile(double size, double radius) => Container(
width: size,
height: size,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
PeachTheme.seed.withValues(alpha: 0.28),
PeachTheme.seed.withValues(alpha: 0.10),
],
),
borderRadius: BorderRadius.circular(radius),
border: Border.all(color: PeachTheme.seed.withValues(alpha: 0.35)),
),
child: const Icon(Icons.dns, color: PeachTheme.seed),
);
Widget _kindChips(ServerProfile s) {
final jp = context.jp;
var jar = 0, web = 0;
for (final svc in s.services) {
if (svc.kind == ServiceKind.webApp) {
web++;
} else {
jar++;
}
}
Widget chip(IconData icon, int n, Color color) => Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 12, color: color),
const SizedBox(width: 4),
text('$n', 11, TextType.Bold, color: color),
],
),
);
if (jar == 0 && web == 0) {
return text('no services', 11, TextType.Regular, color: jp.textFaint);
}
return Wrap(
spacing: 6,
children: [
if (jar > 0) chip(Icons.settings_suggest, jar, PeachTheme.seed),
if (web > 0) chip(Icons.language, web, PeachTheme.accent),
],
);
}
Widget _sectionHeader(String title, int count) {
return Row(
children: [
text(
title,
12,
TextType.Bold,
color: context.jp.textDim,
letterSpacing: 1.2,
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 1),
decoration: BoxDecoration(
color: PeachTheme.seed.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(20),
),
child: text('$count', 11, TextType.Bold, color: PeachTheme.seed),
),
],
);
}
Widget _viewToggle(JpColors jp) {
Widget btn(HomeView v, IconData icon, String tip, VoidCallback onTap) {
final active = _view == v;
return IconButton(
tooltip: tip,
onPressed: onTap,
icon: Icon(icon, color: active ? PeachTheme.seed : jp.textFaint),
);
}
return Container(
decoration: BoxDecoration(
color: jp.surface2,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: jp.border),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
btn(HomeView.grid, Icons.grid_view, 'Grid view', _onGridView),
btn(
HomeView.list,
Icons.view_agenda_outlined,
'List view',
_onListView,
),
],
),
);
}
Widget _emptyServers() {
final jp = context.jp;
return Container(
padding: const EdgeInsets.symmetric(vertical: 48),
decoration: PeachTheme.panel(context),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.dns,
size: 56,
color: PeachTheme.seed.withValues(alpha: 0.6),
),
const SizedBox(height: 16),
text('No servers yet', 20, TextType.Bold, color: jp.text),
const SizedBox(height: 6),
text(
'Register a server, then add its services.',
14,
TextType.Regular,
color: jp.textDim,
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: _onNewServer,
icon: const Icon(Icons.add),
label: text('New server', 14, TextType.Bold, color: colorWhite),
),
],
),
);
}
}

View File

@@ -0,0 +1,87 @@
import '../../about/internal/application/DeploymentRecord.dart';
import '../../about/internal/application/ServerProfile.dart';
import '../parent/ParentViewModel.dart';
import 'ConnectHome.dart';
class ViewHome extends ParentViewModel {
ConnectHome connection;
ViewHome(super.context, this.connection);
/// Re-reads servers and history. Called on open and whenever a child screen
/// pops back having changed something.
void loadDashboard() async {
try {
final servers = await getDataManager().getServers();
final history = await getDataManager().getDeployments();
connection.onDashboardLoaded(servers, history);
} catch (error) {
handleError(error, loadDashboard, () {}, "Retry");
}
}
/// Inserts or updates [server] in storage, then refreshes the dashboard.
void saveServer(ServerProfile server) async {
try {
final servers = await getDataManager().getServers();
final index = servers.indexWhere((s) => s.id == server.id);
if (index >= 0) {
servers[index] = server;
} else {
servers.add(server);
}
await getDataManager().setServers(servers);
connection.onServerSaved(server);
loadDashboard();
} catch (error) {
handleError(error, () => saveServer(server), () {}, "Retry");
}
}
/// Removes [server] and its stored password.
void deleteServer(ServerProfile server) async {
try {
final servers = await getDataManager().getServers();
servers.removeWhere((s) => s.id == server.id);
await getDataManager().setServers(servers);
await getDataManager().deleteServerSecret(server.id);
loadDashboard();
} catch (error) {
handleError(error, () => deleteServer(server), () {}, "Retry");
}
}
/// Appends a deployment outcome to the history.
void recordDeployment(DeploymentRecord record) async {
try {
await getDataManager().addDeployment(record);
loadDashboard();
} catch (error) {
handleError(error, () => recordDeployment(record), () {}, "Retry");
}
}
void clearHistory() async {
try {
await getDataManager().clearDeployments();
loadDashboard();
} catch (error) {
handleError(error, clearHistory, () {}, "Retry");
}
}
/// Advances the theme dark → light → system → dark and persists the choice.
void cycleTheme(String current) async {
try {
final next = switch (current) {
'dark' => 'light',
'light' => 'system',
_ => 'dark',
};
await getDataManager().setThemeMode(next);
connection.onThemeChanged(next);
} catch (error) {
handleError(error, () => cycleTheme(current), () {}, "Retry");
}
}
}

View File

@@ -0,0 +1,36 @@
import '../../about/external/data/Kafka.dart';
/// One scan's worth of cluster state.
class KafkaScan {
final List<KafkaTopic> topics;
final List<KafkaConsumerGroup> groups;
final KafkaCluster cluster;
const KafkaScan({
required this.topics,
required this.groups,
required this.cluster,
});
/// Nothing came back at all — usually an unreachable bootstrap server rather
/// than a genuinely empty cluster.
bool get isEmpty =>
topics.isEmpty && groups.isEmpty && cluster.brokers.isEmpty;
}
abstract class ConnectKafka {
/// Whether the Kafka CLI tools were found, and where.
void onKafkaDetected(KafkaStatus status);
/// Detection itself failed (the server couldn't be probed).
void onDetectFailed(String message);
/// Topics, groups and cluster health, freshly read.
void onScanned(KafkaScan scan);
/// The scan failed.
void onScanFailed(String message);
/// Output of the broker probe, to show in a sheet.
void onProbeOutput(String title, String output);
}

View File

@@ -0,0 +1,16 @@
import 'package:flutter/cupertino.dart';
import '../../about/internal/application/ServerProfile.dart';
import 'KafkaState.dart';
/// Live Kafka monitoring over SSH: topics with message offsets, consumer
/// groups with lag, and cluster/broker health. Everything comes from the
/// standard Kafka CLI tools run on the server.
class Kafka extends StatefulWidget {
final ServerProfile server;
const Kafka({super.key, required this.server});
@override
State<Kafka> createState() => KafkaState();
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,94 @@
import '../../about/internal/application/ServerProfile.dart';
import '../parent/ParentViewModel.dart';
import 'ConnectKafka.dart';
class ViewKafka extends ParentViewModel {
ConnectKafka connection;
ViewKafka(super.context, this.connection);
/// Looks for the Kafka CLI tools on PATH and in the usual install dirs.
void detectKafka(ServerProfile server) async {
if (!await canReach(server, () => detectKafka(server))) return;
try {
final status = await getDataManager().kafkaDetect(server);
connection.onKafkaDetected(status);
} catch (error) {
connection.onDetectFailed(error.toString());
}
}
/// Reads topics, consumer groups and cluster health in one pass. They're
/// three separate CLI invocations, so this is where they're sequenced.
void scan(
ServerProfile server, {
required String binDir,
required String suffix,
required String bootstrap,
required bool includeInternal,
}) async {
try {
final topics = await getDataManager().kafkaTopics(
server,
binDir: binDir,
suffix: suffix,
bootstrap: bootstrap,
includeInternal: includeInternal,
);
final groups = await getDataManager().kafkaGroups(
server,
binDir: binDir,
suffix: suffix,
bootstrap: bootstrap,
);
final cluster = await getDataManager().kafkaCluster(
server,
binDir: binDir,
suffix: suffix,
bootstrap: bootstrap,
);
connection.onScanned(
KafkaScan(topics: topics, groups: groups, cluster: cluster),
);
} catch (error) {
connection.onScanFailed(error.toString());
}
}
/// Runs `kafka-broker-api-versions` so the user can see exactly what the
/// broker says when a scan comes back empty.
void probeBroker(
ServerProfile server, {
required String binDir,
required String suffix,
required String bootstrap,
}) async {
showLoading("Probing $bootstrap …");
try {
final output = await getDataManager().kafkaTest(
server,
binDir: binDir,
suffix: suffix,
bootstrap: bootstrap,
);
closeLoading();
connection.onProbeOutput(
'kafka-broker-api-versions · $bootstrap',
output.isEmpty ? '(no output — broker unreachable?)' : output,
);
} catch (error) {
handleError(
error,
() => probeBroker(
server,
binDir: binDir,
suffix: suffix,
bootstrap: bootstrap,
),
() {},
"Retry",
);
}
}
}

View File

@@ -0,0 +1,16 @@
abstract class ConnectLogs {
/// A fetch finished (the lines themselves arrive on the log stream).
void onLogsFetched();
/// The unit's current systemd status, e.g. `active (running)` or `no-unit`.
void onStatusLoaded(String status);
/// A start / stop / restart finished, with the message to surface.
void onControlFinished(String message);
/// Live tail started or stopped.
void onFollowChanged(bool following);
/// The filtered log text was written to [path], or cancelled when null.
void onLogsSaved(String? path);
}

View File

@@ -0,0 +1,17 @@
import 'package:flutter/cupertino.dart';
import '../../about/internal/application/ServerProfile.dart';
import '../../about/internal/application/ServiceConfig.dart';
import 'LogsState.dart';
/// Read-only viewer plus lightweight service control: fetch, filter and
/// download logs, live-tail them, and start / stop / restart the unit.
class Logs extends StatefulWidget {
final ServerProfile server;
final ServiceConfig service;
const Logs({super.key, required this.server, required this.service});
@override
State<Logs> createState() => LogsState();
}

View File

@@ -0,0 +1,449 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:stacked/stacked.dart';
import '../../about/internal/application/ServerProfile.dart';
import '../../about/internal/application/ServiceConfig.dart';
import '../../about/internal/application/TextType.dart';
import '../../comms/Comms.dart' show LogLine;
import '../../designs/LogConsole.dart';
import '../../designs/text/Text.dart';
import '../../utils/Colors.dart';
import 'ConnectLogs.dart';
import 'Logs.dart';
import 'ViewLogs.dart';
class LogsState extends State<Logs> implements ConnectLogs {
ViewLogs? _model;
StreamSubscription<LogLine>? _logSub;
final _lines = <LogLine>[];
final _searchController = TextEditingController();
bool _loading = true;
bool _following = false;
bool _busy = false;
String _status = 'unknown';
String _query = '';
DateTimeRange? _range;
ServerProfile get _server => widget.server;
ServiceConfig get _service => widget.service;
List<LogLine> get _filtered {
if (_query.trim().isEmpty) return _lines;
final q = _query.toLowerCase();
return _lines.where((l) => l.text.toLowerCase().contains(q)).toList();
}
@override
void dispose() {
_logSub?.cancel();
_model?.stopLogs();
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ViewModelBuilder<ViewLogs>.reactive(
viewModelBuilder: () => ViewLogs(context, this),
onViewModelReady: _onReady,
builder: (context, viewModel, child) => Scaffold(
appBar: AppBar(
title: text(
'${_service.name} · logs',
20,
TextType.Bold,
color: context.jp.text,
),
actions: [
IconButton(
tooltip: _range == null ? 'Filter by date' : 'Change date range',
onPressed: _busy ? null : _onPickRange,
icon: Icon(
Icons.date_range,
color: _range != null ? PeachTheme.seed : null,
),
),
IconButton(
tooltip: 'Download logs',
onPressed: _onDownload,
icon: const Icon(Icons.download),
),
IconButton(
tooltip: 'Refresh',
onPressed: _loading || _busy ? null : _onRefresh,
icon: const Icon(Icons.refresh),
),
const SizedBox(width: 4),
],
),
body: Column(
children: [
_header(),
_controlBar(),
_searchBar(),
if (_loading || _busy) const LinearProgressIndicator(minHeight: 3),
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 0),
child: LogConsole(
logs: _filtered,
height: null,
live: _following,
totalCount: _query.isEmpty ? null : _lines.length,
emptyMessage: _loading
? 'Loading…'
: (_query.isNotEmpty ? 'No matches.' : 'No log output.'),
onClear: _onClearBacklog,
onCopy: _onCopy,
),
),
),
_bottomBar(),
],
),
),
);
}
void _onReady(ViewLogs model) {
_model = model;
// onViewModelReady fires mid-build; a synchronous setState from the
// first load would dirty this State while it is still being built.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _initiate();
});
}
Future<void> _initiate() async {
_logSub = _model?.logs.listen(_onLogLine);
_model?.fetchLogs(_server, _service);
_model?.loadStatus(_server, _service);
}
void _onLogLine(LogLine line) {
if (!mounted) return;
setState(() => _lines.add(line));
}
// ── status helpers ─────────────────────────────────────────────────────────
bool get _isActive =>
_status.contains('active') && !_status.contains('inactive');
bool get _hasUnit => _status != 'no-unit';
(Color, IconData) get _statusStyle {
if (_status.contains('failed')) return (PeachTheme.danger, Icons.error);
if (_isActive) return (PeachTheme.seed, Icons.play_circle);
if (_status.contains('activating')) {
return (PeachTheme.warn, Icons.hourglass_top);
}
return (context.jp.textFaint, Icons.stop_circle);
}
String _unitLabel() {
if (_service.isWeb) {
final unit = _service.nginxService.isEmpty
? 'nginx'
: _service.nginxService;
return 'nginx: $unit';
}
return _service.serviceName.isEmpty
? '(no unit)'
: 'unit: ${_service.serviceName}';
}
// ── named handlers ─────────────────────────────────────────────────────────
void _onRefresh() {
setState(() {
_loading = true;
_following = false;
_lines.clear();
});
_model?.fetchLogs(
_server,
_service,
since: _range?.start,
until: _range?.end,
);
}
void _onStart() => _control('start');
void _onStop() => _control('stop');
void _onRestart() => _control('restart');
void _control(String action) {
setState(() {
_busy = true;
_following = false;
});
_model?.control(_server, _service, action);
}
Future<void> _onPickRange() async {
final now = DateTime.now();
final picked = await showDateRangePicker(
context: context,
firstDate: DateTime(now.year - 3),
lastDate: now,
initialDateRange: _range,
);
if (picked == null) return;
setState(
() => _range = DateTimeRange(
start: DateTime(
picked.start.year,
picked.start.month,
picked.start.day,
),
end: DateTime(
picked.end.year,
picked.end.month,
picked.end.day,
23,
59,
59,
),
),
);
_onRefresh();
}
void _onClearRange() {
setState(() => _range = null);
_onRefresh();
}
void _onDownload() {
final lines = _filtered.map((l) => l.text).toList();
if (lines.isEmpty) {
_notify('Nothing to download.');
return;
}
_model?.saveLogs(_service.name, lines);
}
void _onToggleFollow() => _model?.toggleFollow(_server, _service, _following);
void _onClearBacklog() => setState(() => _lines.clear());
void _onCopy() {
Clipboard.setData(
ClipboardData(text: _filtered.map((l) => l.text).join('\n')),
);
_notify('Logs copied.');
}
void _onSearchChanged(String v) => setState(() => _query = v);
void _onClearSearch() {
_searchController.clear();
setState(() => _query = '');
}
void _notify(String message) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
// ── ConnectLogs ────────────────────────────────────────────────────────────
@override
void onLogsFetched() {
if (!mounted) return;
setState(() => _loading = false);
}
@override
void onStatusLoaded(String status) {
if (!mounted) return;
setState(() => _status = status);
}
@override
void onControlFinished(String message) {
if (!mounted) return;
setState(() => _busy = false);
_notify(message);
}
@override
void onFollowChanged(bool following) {
if (!mounted) return;
setState(() => _following = following);
}
@override
void onLogsSaved(String? path) {
_notify(path == null ? 'Download cancelled' : 'Saved to $path');
}
// ── screen-only widgets ────────────────────────────────────────────────────
Widget _header() {
final jp = context.jp;
final (color, icon) = _statusStyle;
return Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(16, 10, 16, 10),
decoration: BoxDecoration(
color: jp.surface,
border: Border(bottom: BorderSide(color: jp.border)),
),
child: Row(
children: [
const Icon(Icons.dns, size: 15, color: PeachTheme.seed),
const SizedBox(width: 6),
Expanded(
child: text(
'${_server.username}@${_server.host} · ${_unitLabel()}',
12.5,
TextType.Regular,
color: jp.textDim,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.16),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: color.withValues(alpha: 0.5)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 13, color: color),
const SizedBox(width: 5),
text(
_status.toUpperCase(),
10.5,
TextType.Bold,
color: color,
letterSpacing: 0.4,
),
],
),
),
],
),
);
}
Widget _controlBar() {
if (!_hasUnit) return const SizedBox.shrink();
return Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(12, 10, 12, 4),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
// Contextual: Start only when stopped, Stop only when running.
if (!_isActive)
_ctlButton(Icons.play_arrow, 'Start', PeachTheme.seed, _onStart),
if (_isActive)
_ctlButton(Icons.stop, 'Stop', PeachTheme.danger, _onStop),
_ctlButton(
Icons.restart_alt,
_service.isWeb ? 'Reload' : 'Restart',
PeachTheme.accent,
_onRestart,
),
if (_range != null) _rangeChip(),
],
),
);
}
Widget _ctlButton(
IconData icon,
String label,
Color color,
VoidCallback onTap,
) {
return OutlinedButton.icon(
onPressed: _busy ? null : onTap,
icon: Icon(icon, size: 18),
label: text(label, 13, TextType.Bold, color: color),
style: OutlinedButton.styleFrom(
foregroundColor: color,
side: BorderSide(color: color.withValues(alpha: 0.5)),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
);
}
Widget _rangeChip() {
String d(DateTime t) => '${t.month}/${t.day}';
return InputChip(
avatar: const Icon(Icons.date_range, size: 16),
label: Text('${d(_range!.start)} – ${d(_range!.end)}'),
onDeleted: _onClearRange,
onPressed: _onPickRange,
);
}
Widget _searchBar() {
return Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 8),
child: TextField(
controller: _searchController,
onChanged: _onSearchChanged,
decoration: InputDecoration(
hintText: 'Search logs…',
prefixIcon: const Icon(Icons.search, size: 20),
suffixIcon: _query.isEmpty
? null
: IconButton(
icon: const Icon(Icons.close, size: 18),
onPressed: _onClearSearch,
),
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 12),
),
),
);
}
Widget _bottomBar() {
return SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 6, 12, 12),
child: SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _busy ? null : _onToggleFollow,
icon: Icon(_following ? Icons.stop_circle : Icons.stream),
label: text(
_following ? 'Stop live tail' : 'Live tail',
14,
TextType.Bold,
color: colorWhite,
),
style: _following
? FilledButton.styleFrom(
backgroundColor: PeachTheme.danger,
foregroundColor: colorWhite,
)
: null,
),
),
),
);
}
}

View File

@@ -0,0 +1,147 @@
import 'dart:convert';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/services.dart';
import '../../about/internal/application/ServerProfile.dart';
import '../../about/internal/application/ServiceConfig.dart';
import '../../comms/Comms.dart' show LogLine;
import '../parent/ParentViewModel.dart';
import 'ConnectLogs.dart';
class ViewLogs extends ParentViewModel {
ConnectLogs connection;
ViewLogs(super.context, this.connection);
/// Log lines as they arrive from the server. The screen subscribes to this
/// directly — a stream of remote output has no request/response shape to
/// hand back through the Connect interface one call at a time.
Stream<LogLine> get logs => getDataManager().logs;
/// Pulls the journal for [service], optionally bounded by a date range.
void fetchLogs(
ServerProfile server,
ServiceConfig service, {
DateTime? since,
DateTime? until,
}) async {
if (!await canReach(
server,
() => fetchLogs(server, service, since: since, until: until),
)) {
return;
}
getDataManager().stopLogs();
try {
await getDataManager().fetchLogs(
server,
service,
since: since,
until: until,
);
connection.onLogsFetched();
} catch (error) {
connection.onLogsFetched();
handleError(
error,
() => fetchLogs(server, service, since: since, until: until),
() {},
"Retry",
);
}
}
/// Reads the unit's systemd state. Failures are reported as `unknown`
/// rather than raised — the status pill is ambient, not an operation the
/// user asked for.
void loadStatus(ServerProfile server, ServiceConfig service) async {
try {
final status = await getDataManager().serviceStatus(server, service);
connection.onStatusLoaded(status);
} catch (_) {
connection.onStatusLoaded('unknown');
}
}
/// start | stop | restart on the service's unit.
void control(
ServerProfile server,
ServiceConfig service,
String action,
) async {
if (!await canReach(server, () => control(server, service, action))) return;
getDataManager().stopLogs();
try {
final result = await getDataManager().serviceControl(
server,
service,
action,
);
connection.onControlFinished(result.message);
loadStatus(server, service);
} catch (error) {
handleError(
error,
() => control(server, service, action),
() {},
"Retry",
);
}
}
/// Starts or stops the live tail.
void toggleFollow(
ServerProfile server,
ServiceConfig service,
bool currentlyFollowing,
) async {
if (currentlyFollowing) {
getDataManager().stopLogs();
connection.onFollowChanged(false);
return;
}
if (!await canReach(
server,
() => toggleFollow(server, service, currentlyFollowing),
)) {
return;
}
connection.onFollowChanged(true);
try {
await getDataManager().followLogs(server, service);
} catch (error) {
connection.onFollowChanged(false);
handleError(
error,
() => toggleFollow(server, service, false),
() {},
"Retry",
);
}
}
/// Writes [lines] to a file the user picks.
void saveLogs(String serviceName, List<String> lines) async {
try {
final body = lines.join('\n');
final stamp = DateTime.now()
.toIso8601String()
.replaceAll(':', '-')
.split('.')
.first;
final safeName = serviceName.replaceAll(RegExp(r'[^A-Za-z0-9_-]'), '_');
final path = await FilePicker.saveFile(
dialogTitle: 'Save logs',
fileName: '$safeName-logs-$stamp.txt',
bytes: Uint8List.fromList(utf8.encode(body)),
);
connection.onLogsSaved(path);
} catch (error) {
handleError(error, () => saveLogs(serviceName, lines), () {}, "Retry");
}
}
/// Stops any tail still running when the screen goes away.
void stopLogs() => getDataManager().stopLogs();
}

View File

@@ -0,0 +1,722 @@
import 'dart:async';
import 'dart:io';
import 'package:dartssh2/dartssh2.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../about/external/data/PeachError.dart';
import '../../about/internal/application/NotificationType.dart';
import '../../about/internal/application/ServerProfile.dart';
import '../../about/internal/application/Severity.dart';
import '../../about/internal/application/TextType.dart';
import '../../about/internal/file/FileStorage.dart';
import '../../comms/Comms.dart';
import '../../designs/text/Text.dart';
import '../../informatics/AppDataManager.dart';
import '../../informatics/DataManager.dart';
import '../../memory/InternalMemory.dart';
import '../../utils/Colors.dart';
/// The base every ViewModel extends. It owns the three cross-cutting overlays
/// (loading, unreachable-server, error), the single [DataManager] instance, and
/// the one error decision tree — so no screen has to reimplement any of them.
///
/// The Autoreceptive original guards calls with a *connectivity* check, which
/// suits an app talking to one public API. Peach talks to whichever box the
/// user configured — often on a LAN or VPN where the device has no internet at
/// all — so the equivalent guard here is [canReach]: an actual SSH handshake
/// with the target server.
class ParentViewModel extends ChangeNotifier {
OverlayEntry? loadingEntry;
OverlayEntry? networkEntry;
OverlayEntry? errorEntry;
late DataManager dataManager;
BuildContext context;
OverlayState? overlayState;
ParentViewModel(this.context) {
overlayState = Overlay.of(context);
dataManager = AppDataManager(InternalMemory(), SshService(), FileStorage());
}
DataManager getDataManager() {
return dataManager;
}
// ── Loading overlay ────────────────────────────────────────────────────────
void showLoading(String loadingText) async {
if (loadingEntry != null) return;
_hideKeyboard();
final jp = context.jp;
loadingEntry = OverlayEntry(
builder: (context) {
return Scaffold(
backgroundColor: jp.bg.withValues(alpha: 0.86),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 64,
height: 64,
child: CircularProgressIndicator(
strokeWidth: 5,
color: colorPrimary,
backgroundColor: jp.surface2,
),
),
const SizedBox(height: 24),
text(
loadingText,
18,
TextType.Bold,
color: jp.text,
align: TextAlign.center,
),
const SizedBox(height: 8),
text(
'Working on the server — keep this window open.',
13,
TextType.Regular,
color: jp.textDim,
align: TextAlign.center,
),
],
),
),
);
},
);
overlayState?.insert(loadingEntry!);
notifyListeners();
}
void closeLoading() {
if (loadingEntry != null) {
loadingEntry?.remove();
loadingEntry = null;
notifyListeners();
}
}
// ── Unreachable-server overlay ─────────────────────────────────────────────
/// Shown when the target server can't be reached at all. [actions] is the
/// retry closure that re-runs whatever the user was doing.
void noNetwork(Function() actions) async {
if (networkEntry != null) return;
_hideKeyboard();
final jp = context.jp;
networkEntry = OverlayEntry(
builder: (context) {
Widget hintRow(IconData icon, String label) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13),
child: Row(
children: [
Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: colorPrimary.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, size: 16, color: colorPrimary),
),
const SizedBox(width: 12),
Expanded(
child: text(label, 13, TextType.Regular, color: jp.textDim),
),
],
),
);
return Scaffold(
backgroundColor: jp.bg,
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(28),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 116,
height: 116,
decoration: BoxDecoration(
color: colorPrimary.withValues(alpha: 0.08),
shape: BoxShape.circle,
border: Border.all(
color: colorPrimary.withValues(alpha: 0.20),
width: 2,
),
),
child: Icon(
Icons.cloud_off_rounded,
size: 44,
color: colorPrimary,
),
),
const SizedBox(height: 28),
text(
"Server unreachable",
22,
TextType.Bold,
color: jp.text,
align: TextAlign.center,
),
const SizedBox(height: 10),
text(
"Peach couldn't open an SSH session to this server.",
14,
TextType.Regular,
color: jp.textDim,
align: TextAlign.center,
),
const SizedBox(height: 24),
Container(
decoration: BoxDecoration(
color: jp.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: jp.border),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
hintRow(
Icons.dns_outlined,
'Check the host and port are correct',
),
Divider(height: 1, color: jp.border),
hintRow(
Icons.vpn_key_outlined,
'Confirm the username and password still work',
),
Divider(height: 1, color: jp.border),
hintRow(
Icons.lan_outlined,
'Make sure you are on the right network or VPN',
),
],
),
),
const SizedBox(height: 28),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: actions,
icon: const Icon(Icons.refresh_rounded, size: 18),
label: text(
'Try again',
14,
TextType.Bold,
color: colorWhite,
),
),
),
],
),
),
),
),
);
},
);
overlayState?.insert(networkEntry!);
notifyListeners();
}
void closeNetwork() {
if (networkEntry != null) {
networkEntry?.remove();
networkEntry = null;
notifyListeners();
}
}
/// Guard every server-bound call with this: it opens (or reuses) an SSH
/// session to [server] and, on failure, raises the unreachable overlay wired
/// to [actions] and returns false so the caller can bail out early.
Future<bool> canReach(ServerProfile server, Function() actions) async {
closeNetwork();
try {
await getDataManager().connect(server);
return true;
} catch (_) {
closeLoading();
noNetwork(actions);
return false;
}
}
// ── Error overlay ──────────────────────────────────────────────────────────
void showError(
Function() actions,
Function() closeActions,
PeachError error,
String buttonText,
) async {
if (errorEntry != null) return;
_hideKeyboard();
final jp = context.jp;
final bool critical = error.severity == Severity.critical.name;
errorEntry = OverlayEntry(
builder: (context) {
return Scaffold(
backgroundColor: jp.bg,
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(28),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Container(
width: 96,
height: 96,
decoration: BoxDecoration(
color: colorNegative.withValues(alpha: 0.10),
shape: BoxShape.circle,
border: Border.all(
color: colorNegative.withValues(alpha: 0.22),
width: 2,
),
),
child: Icon(
critical
? Icons.gpp_maybe_rounded
: Icons.error_outline_rounded,
size: 40,
color: colorNegative,
),
),
),
const SizedBox(height: 24),
text(
error.title,
22,
TextType.Bold,
color: jp.text,
align: TextAlign.center,
),
const SizedBox(height: 10),
text(
error.message,
14,
TextType.Regular,
color: jp.textDim,
align: TextAlign.center,
height: 1.5,
),
if (error.helper.isNotEmpty) ...[
const SizedBox(height: 18),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: jp.surface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: jp.border),
),
child: text(
error.helper,
13,
TextType.Regular,
color: jp.textDim,
height: 1.55,
),
),
],
const SizedBox(height: 12),
Center(
child: text(
'Code ${error.code}',
11,
TextType.Light,
color: jp.textFaint,
),
),
const SizedBox(height: 24),
FilledButton(
onPressed: () {
dismissError();
actions();
},
child: text(
buttonText,
14,
TextType.Bold,
color: colorWhite,
),
),
const SizedBox(height: 8),
TextButton(
onPressed: () {
dismissError();
closeActions();
},
child: text(
'Dismiss',
13,
TextType.Regular,
color: jp.textDim,
),
),
],
),
),
),
),
);
},
);
overlayState?.insert(errorEntry!);
notifyListeners();
}
void dismissError() {
if (errorEntry != null) {
errorEntry?.remove();
errorEntry = null;
notifyListeners();
}
}
/// The standard `catch` handler — every ViewModel routes failures here so the
/// same class of problem always reads the same way to the user. Closes the
/// loading overlay first, then maps the failure onto a [PeachError].
void handleError(
Object? error,
Function() actions,
Function() closeAction,
String buttonName,
) {
closeLoading();
if (error is SSHAuthAbortError || error is SSHAuthFailError) {
showError(
actions,
closeAction,
PeachError(
code: 4010.01,
message: "The server rejected these credentials.",
helper:
"Check the username and password on this server profile. If "
"the account uses a key or 2FA, SSH password auth may be "
"disabled on the host.",
title: "Authentication failed",
severity: Severity.critical.name,
),
buttonName,
);
} else if (error is SocketException || error is SSHStateError) {
showError(
actions,
closeAction,
PeachError(
code: 5000.01,
message: "Peach couldn't reach the server.",
helper:
"Confirm the host and port, that the machine is powered on, "
"and that you're on the network or VPN that can see it.",
title: "Connection failed",
severity: Severity.message.name,
),
buttonName,
);
} else if (error is TimeoutException) {
showError(
actions,
closeAction,
PeachError(
code: 5000.02,
message: "The server took too long to respond.",
helper:
"The connection is slow or the command is still running. "
"Retry, or check the server's load.",
title: "Timed out",
severity: Severity.message.name,
),
buttonName,
);
} else if (error is SSHError) {
showError(
actions,
closeAction,
PeachError(
code: 5100.00,
message: "The SSH session failed while running this operation.",
helper: error.toString(),
title: "SSH error",
severity: Severity.message.name,
),
buttonName,
);
} else if (error is FileSystemException) {
showError(
actions,
closeAction,
PeachError(
code: 6100.01,
message: "A local file couldn't be read or written.",
helper:
"Check the artifact path still exists and that Peach has "
"permission to read it.",
title: "File error",
severity: Severity.message.name,
),
buttonName,
);
} else if (error is Exception) {
showError(
actions,
closeAction,
PeachError(
code: 6000.01,
message: "An error occurred while processing your request.",
helper: error.toString(),
title: "Peach Error",
severity: Severity.message.name,
),
buttonName,
);
} else {
showError(
actions,
closeAction,
PeachError(
code: 8700.02,
message: "An error occurred while processing your request.",
helper:
"Relaunch Peach and try again. If the problem persists, "
"check the server logs for what happened.",
title: "Peach Error",
severity: Severity.message.name,
),
buttonName,
);
}
}
// ── In-app notification sheet ──────────────────────────────────────────────
/// A modal bottom sheet for confirmations and outcomes. [positiveAction]
/// must call `Navigator.pop(context)` first to dismiss the sheet before it
/// navigates anywhere.
Future<void> showApplicationNotification(
NotificationType type,
String title,
String description,
bool enableDrag,
bool barrierDismiss,
VoidCallback? closeAction, {
String? action,
VoidCallback? positiveAction,
}) async {
final jp = context.jp;
Color actionColor;
IconData actionIconData;
switch (type) {
case NotificationType.success:
actionIconData = Icons.check_circle_rounded;
actionColor = colorPositive;
break;
case NotificationType.info:
actionIconData = Icons.info_rounded;
actionColor = colorAccent;
break;
case NotificationType.warning:
actionIconData = Icons.warning_amber_rounded;
actionColor = colorWarmYellow;
break;
case NotificationType.error:
actionIconData = Icons.error_rounded;
actionColor = colorNegative;
break;
}
final bool hasAction = action != null && positiveAction != null;
await showModalBottomSheet(
context: context,
barrierColor: colorPrimaryDark.withValues(alpha: 0.6),
backgroundColor: Colors.transparent,
elevation: 0,
enableDrag: enableDrag,
isDismissible: barrierDismiss,
isScrollControlled: true,
builder: (BuildContext context) {
return Container(
decoration: BoxDecoration(
color: jp.surface,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(28),
topRight: Radius.circular(28),
),
border: Border.all(color: jp.border),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 36,
height: 4,
margin: const EdgeInsets.only(top: 12, bottom: 8),
decoration: BoxDecoration(
color: jp.border,
borderRadius: BorderRadius.circular(999),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 28),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: actionColor.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: actionColor.withValues(alpha: 0.2),
),
),
child: Icon(
actionIconData,
size: 28,
color: actionColor,
),
),
IconButton(
onPressed: () {
Navigator.pop(context);
closeAction?.call();
},
icon: Icon(
Icons.close_rounded,
size: 18,
color: jp.textDim,
),
),
],
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 5,
),
decoration: BoxDecoration(
color: actionColor.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: actionColor.withValues(alpha: 0.2),
),
),
child: text(
type.name.toUpperCase(),
10,
TextType.Bold,
color: actionColor,
letterSpacing: 0.8,
),
),
const SizedBox(height: 12),
text(title, 20, TextType.Bold, color: jp.text),
const SizedBox(height: 10),
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: actionColor.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: actionColor.withValues(alpha: 0.15),
),
),
child: text(
description,
13,
TextType.Regular,
color: jp.textDim,
height: 1.6,
),
),
const SizedBox(height: 24),
if (hasAction) ...[
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: positiveAction,
style: FilledButton.styleFrom(
backgroundColor: actionColor,
),
icon: Icon(actionIconData, size: 16),
label: text(
action,
14,
TextType.Bold,
color: colorWhite,
),
),
),
const SizedBox(height: 12),
],
Center(
child: TextButton(
onPressed: () {
Navigator.pop(context);
closeAction?.call();
},
child: text(
hasAction ? "Cancel" : "Got it, dismiss",
12,
TextType.Regular,
color: jp.textDim,
),
),
),
],
),
),
],
),
);
},
);
}
void _hideKeyboard() {
SystemChannels.textInput.invokeMethod('TextInput.hide');
}
@override
void dispose() {
closeLoading();
closeNetwork();
dismissError();
super.dispose();
}
}

View File

@@ -0,0 +1,36 @@
import '../../about/external/data/Redis.dart';
/// One scan's worth of results — the key list plus the context needed to
/// label it (per-database counts, DBSIZE, and per-key types when filtering).
class RedisScan {
final int databaseCount;
final Map<int, int> databaseCounts;
final int total;
final List<String> keys;
final Map<String, String> keyTypes;
const RedisScan({
required this.databaseCount,
required this.databaseCounts,
required this.total,
required this.keys,
required this.keyTypes,
});
}
abstract class ConnectRedis {
/// A scan completed.
void onScanned(RedisScan scan);
/// The scan couldn't run; [message] is already phrased for the user.
void onScanFailed(String message);
/// A password was read out of the server's redis.conf.
void onPasswordDetected(String password);
/// No `requirepass` was present in redis.conf.
void onPasswordMissing();
/// One key's value was read.
void onValueLoaded(RedisEntry entry);
}

Some files were not shown because too many files have changed in this diff Show More