Add app source and tests

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

View File

@@ -0,0 +1,56 @@
class DockerStatus {
final bool installed;
final bool running;
final String version;
DockerStatus(this.installed, this.running, this.version);
}
class DockerContainer {
final String id;
final String name;
final String image;
final String state; // running | exited | created | paused | restarting | dead
final String status; // e.g. "Up 2 hours" / "Exited (0) 3 min ago"
final String ports;
DockerContainer({
required this.id,
required this.name,
required this.image,
required this.state,
required this.status,
required this.ports,
});
bool get up => state.toLowerCase() == 'running';
}
/// Everything needed to `docker run` a new container.
class DockerRunSpec {
String image;
String name;
String ports; // "host:container" per line
String env; // "KEY=VALUE" per line
String volumes; // "host:container" per line
String restart; // no | on-failure | always | unless-stopped
bool detached;
String command;
DockerRunSpec({
this.image = '',
this.name = '',
this.ports = '',
this.env = '',
this.volumes = '',
this.restart = 'unless-stopped',
this.detached = true,
this.command = '',
});
List<String> _lines(String s) =>
s.split('\n').map((e) => e.trim()).where((e) => e.isNotEmpty).toList();
List<String> get portList => _lines(ports);
List<String> get envList => _lines(env);
List<String> get volumeList => _lines(volumes);
}

View File

@@ -0,0 +1,151 @@
import 'package:flutter/material.dart';
/// A tool/runtime PeachDeployments can detect on a server and install if
/// missing. [detect] prints a version line to stdout when present, nothing
/// otherwise (guarded by `command -v`). [pkg] maps a package manager to the
/// package(s) to install. [unit] is a systemd unit to `enable --now` after.
class EnvTool {
final String key;
final String label;
final IconData icon;
final String detect;
final Map<String, String> pkg;
final String? unit;
const EnvTool(
this.key,
this.label,
this.icon,
this.detect,
this.pkg, {
this.unit,
});
String packagesFor(String pm) => pkg[pm] ?? key;
}
const List<EnvTool> kEnvTools = [
EnvTool(
'java',
'Java (JDK)',
Icons.coffee,
'command -v java >/dev/null 2>&1 && java -version 2>&1 | head -n1',
{
'apt': 'openjdk-17-jdk',
'dnf': 'java-17-openjdk-devel',
'yum': 'java-17-openjdk-devel',
'apk': 'openjdk17',
'zypper': 'java-17-openjdk-devel',
'pacman': 'jdk-openjdk',
},
),
EnvTool(
'mariadb',
'MariaDB / MySQL',
Icons.storage,
'(command -v mariadb >/dev/null 2>&1 && mariadb --version 2>&1 | head -n1) || '
'(command -v mysql >/dev/null 2>&1 && mysql --version 2>&1 | head -n1)',
{
'apt': 'mariadb-server',
'dnf': 'mariadb-server',
'yum': 'mariadb-server',
'apk': 'mariadb mariadb-client',
'zypper': 'mariadb',
'pacman': 'mariadb',
},
unit: 'mariadb',
),
EnvTool(
'postgres',
'PostgreSQL',
Icons.dataset,
'command -v psql >/dev/null 2>&1 && psql --version 2>&1 | head -n1',
{
'apt': 'postgresql',
'dnf': 'postgresql-server',
'yum': 'postgresql-server',
'apk': 'postgresql',
'zypper': 'postgresql-server',
'pacman': 'postgresql',
},
unit: 'postgresql',
),
EnvTool(
'nginx',
'nginx',
Icons.dns,
'command -v nginx >/dev/null 2>&1 && nginx -v 2>&1 | head -n1',
{
'apt': 'nginx',
'dnf': 'nginx',
'yum': 'nginx',
'apk': 'nginx',
'zypper': 'nginx',
'pacman': 'nginx',
},
unit: 'nginx',
),
EnvTool(
'docker',
'Docker',
Icons.inventory_2,
'command -v docker >/dev/null 2>&1 && docker --version 2>&1 | head -n1',
{
'apt': 'docker.io',
'dnf': 'docker',
'yum': 'docker',
'apk': 'docker',
'zypper': 'docker',
'pacman': 'docker',
},
unit: 'docker',
),
EnvTool(
'node',
'Node.js',
Icons.javascript,
'command -v node >/dev/null 2>&1 && node --version 2>&1 | head -n1',
{
'apt': 'nodejs npm',
'dnf': 'nodejs',
'yum': 'nodejs',
'apk': 'nodejs npm',
'zypper': 'nodejs',
'pacman': 'nodejs npm',
},
),
EnvTool(
'git',
'Git',
Icons.merge_type,
'command -v git >/dev/null 2>&1 && git --version 2>&1 | head -n1',
{
'apt': 'git',
'dnf': 'git',
'yum': 'git',
'apk': 'git',
'zypper': 'git',
'pacman': 'git',
},
),
];
/// Builds the install command for a detected package manager.
String installCommandFor(String pm, String packages) {
switch (pm) {
case 'apt':
return 'apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y $packages';
case 'dnf':
return 'dnf install -y $packages';
case 'yum':
return 'yum install -y $packages';
case 'apk':
return 'apk add --no-cache $packages';
case 'zypper':
return 'zypper --non-interactive install $packages';
case 'pacman':
return 'pacman -Sy --noconfirm $packages';
default:
return 'echo "Unsupported package manager"; false';
}
}

View File

@@ -0,0 +1,10 @@
enum HealthStatus { healthy, unhealthy, unknown, disabled, checking }
class HealthResult {
final HealthStatus status;
final String detail;
final DateTime time;
HealthResult(this.status, this.detail) : time = DateTime.now();
static HealthResult checking() => HealthResult(HealthStatus.checking, '…');
}

170
lib/Peach/about/external/data/Kafka.dart vendored Normal file
View File

@@ -0,0 +1,170 @@
// Models for the Kafka monitor. All data is scraped from the standard Kafka
// CLI tools (`kafka-topics`, `kafka-consumer-groups`, `kafka-get-offsets`,
// `kafka-broker-api-versions`) run over SSH.
/// Result of locating the Kafka tooling on a server.
class KafkaStatus {
final bool detected;
final String binDir; // e.g. /opt/kafka/bin
final String suffix; // ".sh" for Apache tarballs, "" for Confluent packages
final bool java; // a JVM is on PATH
final String version; // best-effort version string
final String detail; // human note (where it was found / why not)
KafkaStatus({
required this.detected,
this.binDir = '',
this.suffix = '.sh',
this.java = false,
this.version = '',
this.detail = '',
});
}
class KafkaPartition {
final int id;
final int leader;
final List<int> replicas;
final List<int> isr;
int earliest;
int latest; // high-watermark (log-end offset)
KafkaPartition({
required this.id,
required this.leader,
required this.replicas,
required this.isr,
this.earliest = 0,
this.latest = 0,
});
/// Messages currently retained on this partition.
int get messages => (latest - earliest).clamp(0, 1 << 62);
/// Replica set smaller than in-sync means the partition is healthy but the
/// ISR shrinking below the replica count means it is under-replicated.
bool get underReplicated => isr.length < replicas.length;
bool get offline => leader < 0;
}
class KafkaTopic {
final String name;
final int replicationFactor;
final String configs;
final List<KafkaPartition> partitions;
KafkaTopic({
required this.name,
required this.replicationFactor,
required this.partitions,
this.configs = '',
});
bool get internal => name.startsWith('_') || name.startsWith('__');
int get partitionCount => partitions.length;
/// Total high-watermark across partitions — the number of records ever
/// produced (ignoring compaction), a good proxy for "messages sent".
int get highWatermark => partitions.fold(0, (s, p) => s + p.latest);
/// Messages currently retained on the topic (latest − earliest).
int get retained => partitions.fold(0, (s, p) => s + p.messages);
int get underReplicatedCount =>
partitions.where((p) => p.underReplicated).length;
int get offlineCount => partitions.where((p) => p.offline).length;
bool get healthy => underReplicatedCount == 0 && offlineCount == 0;
}
/// One row of `kafka-consumer-groups --describe` (per topic/partition).
class KafkaGroupAssignment {
final String topic;
final int partition;
final int currentOffset; // committed / processed
final int logEndOffset; // produced
final int lag;
final String consumerId;
final String host;
final String clientId;
KafkaGroupAssignment({
required this.topic,
required this.partition,
required this.currentOffset,
required this.logEndOffset,
required this.lag,
this.consumerId = '',
this.host = '',
this.clientId = '',
});
}
class KafkaConsumerGroup {
final String groupId;
String state; // Stable | Empty | Dead | PreparingRebalance | ...
int members;
final String coordinator;
final String assignmentStrategy;
final List<KafkaGroupAssignment> assignments;
KafkaConsumerGroup({
required this.groupId,
this.state = '',
this.members = 0,
this.coordinator = '',
this.assignmentStrategy = '',
List<KafkaGroupAssignment>? assignments,
}) : assignments = assignments ?? [];
int get totalLag =>
assignments.fold(0, (s, a) => s + (a.lag < 0 ? 0 : a.lag));
int get processed => assignments.fold(0, (s, a) => s + a.currentOffset);
Set<String> get topics => assignments.map((a) => a.topic).toSet();
int get liveMembers => assignments
.map((a) => a.consumerId)
.where((c) => c.isNotEmpty && c != '-')
.toSet()
.length;
bool get active => state.toLowerCase() == 'stable';
bool get idle => state.toLowerCase() == 'empty';
}
class KafkaBroker {
final int id;
final String host;
final int port;
final String rack;
bool isController;
KafkaBroker({
required this.id,
required this.host,
required this.port,
this.rack = '',
this.isController = false,
});
String get endpoint => '$host:$port';
}
/// Cluster-level snapshot: brokers + health counts.
class KafkaCluster {
final List<KafkaBroker> brokers;
final String clusterId;
final int controllerId;
final List<String> underReplicated; // "topic-partition" strings
final List<String> unavailable;
KafkaCluster({
required this.brokers,
this.clusterId = '',
this.controllerId = -1,
List<String>? underReplicated,
List<String>? unavailable,
}) : underReplicated = underReplicated ?? [],
unavailable = unavailable ?? [];
bool get healthy => underReplicated.isEmpty && unavailable.isEmpty;
}

View File

@@ -0,0 +1,53 @@
import '../../internal/application/Severity.dart';
/// The single error shape every failure is normalised into before it reaches
/// the user. SSH, build and archive failures are all mapped onto this by
/// `ParentViewModel.handleError`, so the error overlay only ever renders one
/// kind of object.
class PeachError {
final double code;
final String message;
final String helper;
final String title;
final String severity;
PeachError({
required this.code,
required this.message,
required this.helper,
required this.title,
required this.severity,
});
factory PeachError.fromJsonMap(Map<String, dynamic> json) => PeachError(
code: (json['code'] as num?)?.toDouble() ?? 0,
message: json['message'] as String? ?? '',
helper: json['helper'] as String? ?? '',
title: json['title'] as String? ?? 'Peach Error',
severity: json['severity'] as String? ?? Severity.message.name,
);
Map<String, dynamic> toJson() => {
'code': code,
'message': message,
'helper': helper,
'title': title,
'severity': severity,
};
}
/// Best-effort decode of an error payload into a [PeachError]. Anything that
/// isn't a recognisable map is surfaced as a generic error rather than thrown,
/// so the overlay always has something to show.
PeachError getPeachError(dynamic data) {
if (data is Map<String, dynamic>) return PeachError.fromJsonMap(data);
return PeachError(
code: 9000.01,
message: data?.toString() ?? 'An unknown error occurred.',
helper:
'Retry the operation, and if the problem persists check the '
'server and your connection details.',
title: 'Peach Error',
severity: Severity.message.name,
);
}

View File

@@ -0,0 +1,24 @@
/// A single Redis key's type, TTL and (formatted) value.
class RedisEntry {
final String key;
final String type; // string | list | set | hash | zset | stream | none
final String ttl; // seconds, -1 (no expiry), -2 (missing)
final String value;
RedisEntry({
required this.key,
required this.type,
required this.ttl,
required this.value,
});
String get ttlLabel {
final n = int.tryParse(ttl.trim());
if (n == null) return ttl;
if (n == -1) return 'no expiry';
if (n == -2) return 'missing';
if (n < 60) return '${n}s';
if (n < 3600) return '${(n / 60).floor()}m ${n % 60}s';
if (n < 86400) return '${(n / 3600).floor()}h';
return '${(n / 86400).floor()}d';
}
}

View File

@@ -0,0 +1,125 @@
/// systemd service [Type].
enum SystemdType { simple, exec, forking, oneshot, notify }
/// systemd Restart= policy.
enum RestartPolicy { onFailure, always, no }
extension RestartPolicyX on RestartPolicy {
String get value => switch (this) {
RestartPolicy.onFailure => 'on-failure',
RestartPolicy.always => 'always',
RestartPolicy.no => 'no',
};
String get label => switch (this) {
RestartPolicy.onFailure => 'On failure',
RestartPolicy.always => 'Always',
RestartPolicy.no => 'Never',
};
}
/// Everything needed to generate and register a `.service` unit file.
class SystemdSpec {
String unitName; // e.g. "orders" -> orders.service
String description;
SystemdType type;
String user;
String group;
String workingDirectory;
String execStart;
RestartPolicy restart;
int restartSec;
/// Raw "KEY=VALUE" lines.
String environmentLines;
/// Extra `After=`/`Wants=` targets, e.g. mariadb.service.
List<String> afterUnits;
/// Add `SuccessExitStatus=143` (SIGTERM) — recommended for JVM apps.
bool javaSuccessExit;
bool enableOnBoot;
bool startNow;
SystemdSpec({
this.unitName = '',
this.description = '',
this.type = SystemdType.simple,
this.user = 'root',
this.group = '',
this.workingDirectory = '',
this.execStart = '',
this.restart = RestartPolicy.onFailure,
this.restartSec = 5,
this.environmentLines = '',
List<String>? afterUnits,
this.javaSuccessExit = false,
this.enableOnBoot = true,
this.startNow = true,
}) : afterUnits = afterUnits ?? ['network.target'];
/// Sanitized unit name (no spaces / .service suffix).
String get safeName {
var n = unitName.trim();
if (n.toLowerCase().endsWith('.service')) {
n = n.substring(0, n.length - '.service'.length);
}
return n.replaceAll(RegExp(r'[^A-Za-z0-9_.@-]'), '-');
}
List<String> get envList => environmentLines
.split('\n')
.map((e) => e.trim())
.where((e) => e.isNotEmpty && e.contains('='))
.toList();
/// Renders the unit file contents.
String render() {
final b = StringBuffer();
b.writeln('# Generated by PeachDeployments');
b.writeln('[Unit]');
b.writeln(
'Description=${description.trim().isEmpty ? safeName : description.trim()}',
);
final afters = afterUnits.where((a) => a.trim().isNotEmpty).toList();
if (afters.isNotEmpty) {
b.writeln('After=${afters.join(' ')}');
// Wants only the non-core targets (don't hard-require network.target).
final wants = afters.where((a) => a != 'network.target').toList();
if (wants.isNotEmpty) b.writeln('Wants=${wants.join(' ')}');
}
b.writeln();
b.writeln('[Service]');
b.writeln('Type=${type.name}');
if (user.trim().isNotEmpty) b.writeln('User=${user.trim()}');
if (group.trim().isNotEmpty) b.writeln('Group=${group.trim()}');
if (workingDirectory.trim().isNotEmpty) {
b.writeln('WorkingDirectory=${workingDirectory.trim()}');
}
for (final e in envList) {
b.writeln('Environment="$e"');
}
b.writeln('ExecStart=${execStart.trim()}');
b.writeln('Restart=${restart.value}');
if (restart != RestartPolicy.no) b.writeln('RestartSec=$restartSec');
if (javaSuccessExit) b.writeln('SuccessExitStatus=143');
b.writeln();
b.writeln('[Install]');
b.writeln('WantedBy=multi-user.target');
return b.toString();
}
/// Human-readable list of the commands the installer will run.
List<String> plannedCommands({required bool sudo}) {
final p = sudo ? 'sudo ' : '';
final target = '/etc/systemd/system/$safeName.service';
return [
'write $target',
'${p}chmod 644 $target',
'${p}systemctl daemon-reload',
if (enableOnBoot) '${p}systemctl enable $safeName',
if (startNow) '${p}systemctl restart $safeName',
'${p}systemctl status $safeName',
];
}
}