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