Add app source and tests
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
56
lib/Peach/about/external/data/Docker.dart
vendored
Normal file
56
lib/Peach/about/external/data/Docker.dart
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
class DockerStatus {
|
||||
final bool installed;
|
||||
final bool running;
|
||||
final String version;
|
||||
DockerStatus(this.installed, this.running, this.version);
|
||||
}
|
||||
|
||||
class DockerContainer {
|
||||
final String id;
|
||||
final String name;
|
||||
final String image;
|
||||
final String state; // running | exited | created | paused | restarting | dead
|
||||
final String status; // e.g. "Up 2 hours" / "Exited (0) 3 min ago"
|
||||
final String ports;
|
||||
|
||||
DockerContainer({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.image,
|
||||
required this.state,
|
||||
required this.status,
|
||||
required this.ports,
|
||||
});
|
||||
|
||||
bool get up => state.toLowerCase() == 'running';
|
||||
}
|
||||
|
||||
/// Everything needed to `docker run` a new container.
|
||||
class DockerRunSpec {
|
||||
String image;
|
||||
String name;
|
||||
String ports; // "host:container" per line
|
||||
String env; // "KEY=VALUE" per line
|
||||
String volumes; // "host:container" per line
|
||||
String restart; // no | on-failure | always | unless-stopped
|
||||
bool detached;
|
||||
String command;
|
||||
|
||||
DockerRunSpec({
|
||||
this.image = '',
|
||||
this.name = '',
|
||||
this.ports = '',
|
||||
this.env = '',
|
||||
this.volumes = '',
|
||||
this.restart = 'unless-stopped',
|
||||
this.detached = true,
|
||||
this.command = '',
|
||||
});
|
||||
|
||||
List<String> _lines(String s) =>
|
||||
s.split('\n').map((e) => e.trim()).where((e) => e.isNotEmpty).toList();
|
||||
|
||||
List<String> get portList => _lines(ports);
|
||||
List<String> get envList => _lines(env);
|
||||
List<String> get volumeList => _lines(volumes);
|
||||
}
|
||||
151
lib/Peach/about/external/data/EnvCheck.dart
vendored
Normal file
151
lib/Peach/about/external/data/EnvCheck.dart
vendored
Normal file
@@ -0,0 +1,151 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A tool/runtime PeachDeployments can detect on a server and install if
|
||||
/// missing. [detect] prints a version line to stdout when present, nothing
|
||||
/// otherwise (guarded by `command -v`). [pkg] maps a package manager to the
|
||||
/// package(s) to install. [unit] is a systemd unit to `enable --now` after.
|
||||
class EnvTool {
|
||||
final String key;
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final String detect;
|
||||
final Map<String, String> pkg;
|
||||
final String? unit;
|
||||
|
||||
const EnvTool(
|
||||
this.key,
|
||||
this.label,
|
||||
this.icon,
|
||||
this.detect,
|
||||
this.pkg, {
|
||||
this.unit,
|
||||
});
|
||||
|
||||
String packagesFor(String pm) => pkg[pm] ?? key;
|
||||
}
|
||||
|
||||
const List<EnvTool> kEnvTools = [
|
||||
EnvTool(
|
||||
'java',
|
||||
'Java (JDK)',
|
||||
Icons.coffee,
|
||||
'command -v java >/dev/null 2>&1 && java -version 2>&1 | head -n1',
|
||||
{
|
||||
'apt': 'openjdk-17-jdk',
|
||||
'dnf': 'java-17-openjdk-devel',
|
||||
'yum': 'java-17-openjdk-devel',
|
||||
'apk': 'openjdk17',
|
||||
'zypper': 'java-17-openjdk-devel',
|
||||
'pacman': 'jdk-openjdk',
|
||||
},
|
||||
),
|
||||
EnvTool(
|
||||
'mariadb',
|
||||
'MariaDB / MySQL',
|
||||
Icons.storage,
|
||||
'(command -v mariadb >/dev/null 2>&1 && mariadb --version 2>&1 | head -n1) || '
|
||||
'(command -v mysql >/dev/null 2>&1 && mysql --version 2>&1 | head -n1)',
|
||||
{
|
||||
'apt': 'mariadb-server',
|
||||
'dnf': 'mariadb-server',
|
||||
'yum': 'mariadb-server',
|
||||
'apk': 'mariadb mariadb-client',
|
||||
'zypper': 'mariadb',
|
||||
'pacman': 'mariadb',
|
||||
},
|
||||
unit: 'mariadb',
|
||||
),
|
||||
EnvTool(
|
||||
'postgres',
|
||||
'PostgreSQL',
|
||||
Icons.dataset,
|
||||
'command -v psql >/dev/null 2>&1 && psql --version 2>&1 | head -n1',
|
||||
{
|
||||
'apt': 'postgresql',
|
||||
'dnf': 'postgresql-server',
|
||||
'yum': 'postgresql-server',
|
||||
'apk': 'postgresql',
|
||||
'zypper': 'postgresql-server',
|
||||
'pacman': 'postgresql',
|
||||
},
|
||||
unit: 'postgresql',
|
||||
),
|
||||
EnvTool(
|
||||
'nginx',
|
||||
'nginx',
|
||||
Icons.dns,
|
||||
'command -v nginx >/dev/null 2>&1 && nginx -v 2>&1 | head -n1',
|
||||
{
|
||||
'apt': 'nginx',
|
||||
'dnf': 'nginx',
|
||||
'yum': 'nginx',
|
||||
'apk': 'nginx',
|
||||
'zypper': 'nginx',
|
||||
'pacman': 'nginx',
|
||||
},
|
||||
unit: 'nginx',
|
||||
),
|
||||
EnvTool(
|
||||
'docker',
|
||||
'Docker',
|
||||
Icons.inventory_2,
|
||||
'command -v docker >/dev/null 2>&1 && docker --version 2>&1 | head -n1',
|
||||
{
|
||||
'apt': 'docker.io',
|
||||
'dnf': 'docker',
|
||||
'yum': 'docker',
|
||||
'apk': 'docker',
|
||||
'zypper': 'docker',
|
||||
'pacman': 'docker',
|
||||
},
|
||||
unit: 'docker',
|
||||
),
|
||||
EnvTool(
|
||||
'node',
|
||||
'Node.js',
|
||||
Icons.javascript,
|
||||
'command -v node >/dev/null 2>&1 && node --version 2>&1 | head -n1',
|
||||
{
|
||||
'apt': 'nodejs npm',
|
||||
'dnf': 'nodejs',
|
||||
'yum': 'nodejs',
|
||||
'apk': 'nodejs npm',
|
||||
'zypper': 'nodejs',
|
||||
'pacman': 'nodejs npm',
|
||||
},
|
||||
),
|
||||
EnvTool(
|
||||
'git',
|
||||
'Git',
|
||||
Icons.merge_type,
|
||||
'command -v git >/dev/null 2>&1 && git --version 2>&1 | head -n1',
|
||||
{
|
||||
'apt': 'git',
|
||||
'dnf': 'git',
|
||||
'yum': 'git',
|
||||
'apk': 'git',
|
||||
'zypper': 'git',
|
||||
'pacman': 'git',
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
/// Builds the install command for a detected package manager.
|
||||
String installCommandFor(String pm, String packages) {
|
||||
switch (pm) {
|
||||
case 'apt':
|
||||
return 'apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y $packages';
|
||||
case 'dnf':
|
||||
return 'dnf install -y $packages';
|
||||
case 'yum':
|
||||
return 'yum install -y $packages';
|
||||
case 'apk':
|
||||
return 'apk add --no-cache $packages';
|
||||
case 'zypper':
|
||||
return 'zypper --non-interactive install $packages';
|
||||
case 'pacman':
|
||||
return 'pacman -Sy --noconfirm $packages';
|
||||
default:
|
||||
return 'echo "Unsupported package manager"; false';
|
||||
}
|
||||
}
|
||||
10
lib/Peach/about/external/data/Health.dart
vendored
Normal file
10
lib/Peach/about/external/data/Health.dart
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
enum HealthStatus { healthy, unhealthy, unknown, disabled, checking }
|
||||
|
||||
class HealthResult {
|
||||
final HealthStatus status;
|
||||
final String detail;
|
||||
final DateTime time;
|
||||
HealthResult(this.status, this.detail) : time = DateTime.now();
|
||||
|
||||
static HealthResult checking() => HealthResult(HealthStatus.checking, '…');
|
||||
}
|
||||
170
lib/Peach/about/external/data/Kafka.dart
vendored
Normal file
170
lib/Peach/about/external/data/Kafka.dart
vendored
Normal file
@@ -0,0 +1,170 @@
|
||||
// Models for the Kafka monitor. All data is scraped from the standard Kafka
|
||||
// CLI tools (`kafka-topics`, `kafka-consumer-groups`, `kafka-get-offsets`,
|
||||
// `kafka-broker-api-versions`) run over SSH.
|
||||
|
||||
/// Result of locating the Kafka tooling on a server.
|
||||
class KafkaStatus {
|
||||
final bool detected;
|
||||
final String binDir; // e.g. /opt/kafka/bin
|
||||
final String suffix; // ".sh" for Apache tarballs, "" for Confluent packages
|
||||
final bool java; // a JVM is on PATH
|
||||
final String version; // best-effort version string
|
||||
final String detail; // human note (where it was found / why not)
|
||||
|
||||
KafkaStatus({
|
||||
required this.detected,
|
||||
this.binDir = '',
|
||||
this.suffix = '.sh',
|
||||
this.java = false,
|
||||
this.version = '',
|
||||
this.detail = '',
|
||||
});
|
||||
}
|
||||
|
||||
class KafkaPartition {
|
||||
final int id;
|
||||
final int leader;
|
||||
final List<int> replicas;
|
||||
final List<int> isr;
|
||||
int earliest;
|
||||
int latest; // high-watermark (log-end offset)
|
||||
|
||||
KafkaPartition({
|
||||
required this.id,
|
||||
required this.leader,
|
||||
required this.replicas,
|
||||
required this.isr,
|
||||
this.earliest = 0,
|
||||
this.latest = 0,
|
||||
});
|
||||
|
||||
/// Messages currently retained on this partition.
|
||||
int get messages => (latest - earliest).clamp(0, 1 << 62);
|
||||
|
||||
/// Replica set smaller than in-sync means the partition is healthy but the
|
||||
/// ISR shrinking below the replica count means it is under-replicated.
|
||||
bool get underReplicated => isr.length < replicas.length;
|
||||
|
||||
bool get offline => leader < 0;
|
||||
}
|
||||
|
||||
class KafkaTopic {
|
||||
final String name;
|
||||
final int replicationFactor;
|
||||
final String configs;
|
||||
final List<KafkaPartition> partitions;
|
||||
|
||||
KafkaTopic({
|
||||
required this.name,
|
||||
required this.replicationFactor,
|
||||
required this.partitions,
|
||||
this.configs = '',
|
||||
});
|
||||
|
||||
bool get internal => name.startsWith('_') || name.startsWith('__');
|
||||
int get partitionCount => partitions.length;
|
||||
|
||||
/// Total high-watermark across partitions — the number of records ever
|
||||
/// produced (ignoring compaction), a good proxy for "messages sent".
|
||||
int get highWatermark => partitions.fold(0, (s, p) => s + p.latest);
|
||||
|
||||
/// Messages currently retained on the topic (latest − earliest).
|
||||
int get retained => partitions.fold(0, (s, p) => s + p.messages);
|
||||
|
||||
int get underReplicatedCount =>
|
||||
partitions.where((p) => p.underReplicated).length;
|
||||
int get offlineCount => partitions.where((p) => p.offline).length;
|
||||
bool get healthy => underReplicatedCount == 0 && offlineCount == 0;
|
||||
}
|
||||
|
||||
/// One row of `kafka-consumer-groups --describe` (per topic/partition).
|
||||
class KafkaGroupAssignment {
|
||||
final String topic;
|
||||
final int partition;
|
||||
final int currentOffset; // committed / processed
|
||||
final int logEndOffset; // produced
|
||||
final int lag;
|
||||
final String consumerId;
|
||||
final String host;
|
||||
final String clientId;
|
||||
|
||||
KafkaGroupAssignment({
|
||||
required this.topic,
|
||||
required this.partition,
|
||||
required this.currentOffset,
|
||||
required this.logEndOffset,
|
||||
required this.lag,
|
||||
this.consumerId = '',
|
||||
this.host = '',
|
||||
this.clientId = '',
|
||||
});
|
||||
}
|
||||
|
||||
class KafkaConsumerGroup {
|
||||
final String groupId;
|
||||
String state; // Stable | Empty | Dead | PreparingRebalance | ...
|
||||
int members;
|
||||
final String coordinator;
|
||||
final String assignmentStrategy;
|
||||
final List<KafkaGroupAssignment> assignments;
|
||||
|
||||
KafkaConsumerGroup({
|
||||
required this.groupId,
|
||||
this.state = '',
|
||||
this.members = 0,
|
||||
this.coordinator = '',
|
||||
this.assignmentStrategy = '',
|
||||
List<KafkaGroupAssignment>? assignments,
|
||||
}) : assignments = assignments ?? [];
|
||||
|
||||
int get totalLag =>
|
||||
assignments.fold(0, (s, a) => s + (a.lag < 0 ? 0 : a.lag));
|
||||
int get processed => assignments.fold(0, (s, a) => s + a.currentOffset);
|
||||
Set<String> get topics => assignments.map((a) => a.topic).toSet();
|
||||
int get liveMembers => assignments
|
||||
.map((a) => a.consumerId)
|
||||
.where((c) => c.isNotEmpty && c != '-')
|
||||
.toSet()
|
||||
.length;
|
||||
|
||||
bool get active => state.toLowerCase() == 'stable';
|
||||
bool get idle => state.toLowerCase() == 'empty';
|
||||
}
|
||||
|
||||
class KafkaBroker {
|
||||
final int id;
|
||||
final String host;
|
||||
final int port;
|
||||
final String rack;
|
||||
bool isController;
|
||||
|
||||
KafkaBroker({
|
||||
required this.id,
|
||||
required this.host,
|
||||
required this.port,
|
||||
this.rack = '',
|
||||
this.isController = false,
|
||||
});
|
||||
|
||||
String get endpoint => '$host:$port';
|
||||
}
|
||||
|
||||
/// Cluster-level snapshot: brokers + health counts.
|
||||
class KafkaCluster {
|
||||
final List<KafkaBroker> brokers;
|
||||
final String clusterId;
|
||||
final int controllerId;
|
||||
final List<String> underReplicated; // "topic-partition" strings
|
||||
final List<String> unavailable;
|
||||
|
||||
KafkaCluster({
|
||||
required this.brokers,
|
||||
this.clusterId = '',
|
||||
this.controllerId = -1,
|
||||
List<String>? underReplicated,
|
||||
List<String>? unavailable,
|
||||
}) : underReplicated = underReplicated ?? [],
|
||||
unavailable = unavailable ?? [];
|
||||
|
||||
bool get healthy => underReplicated.isEmpty && unavailable.isEmpty;
|
||||
}
|
||||
53
lib/Peach/about/external/data/PeachError.dart
vendored
Normal file
53
lib/Peach/about/external/data/PeachError.dart
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
import '../../internal/application/Severity.dart';
|
||||
|
||||
/// The single error shape every failure is normalised into before it reaches
|
||||
/// the user. SSH, build and archive failures are all mapped onto this by
|
||||
/// `ParentViewModel.handleError`, so the error overlay only ever renders one
|
||||
/// kind of object.
|
||||
class PeachError {
|
||||
final double code;
|
||||
final String message;
|
||||
final String helper;
|
||||
final String title;
|
||||
final String severity;
|
||||
|
||||
PeachError({
|
||||
required this.code,
|
||||
required this.message,
|
||||
required this.helper,
|
||||
required this.title,
|
||||
required this.severity,
|
||||
});
|
||||
|
||||
factory PeachError.fromJsonMap(Map<String, dynamic> json) => PeachError(
|
||||
code: (json['code'] as num?)?.toDouble() ?? 0,
|
||||
message: json['message'] as String? ?? '',
|
||||
helper: json['helper'] as String? ?? '',
|
||||
title: json['title'] as String? ?? 'Peach Error',
|
||||
severity: json['severity'] as String? ?? Severity.message.name,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'code': code,
|
||||
'message': message,
|
||||
'helper': helper,
|
||||
'title': title,
|
||||
'severity': severity,
|
||||
};
|
||||
}
|
||||
|
||||
/// Best-effort decode of an error payload into a [PeachError]. Anything that
|
||||
/// isn't a recognisable map is surfaced as a generic error rather than thrown,
|
||||
/// so the overlay always has something to show.
|
||||
PeachError getPeachError(dynamic data) {
|
||||
if (data is Map<String, dynamic>) return PeachError.fromJsonMap(data);
|
||||
return PeachError(
|
||||
code: 9000.01,
|
||||
message: data?.toString() ?? 'An unknown error occurred.',
|
||||
helper:
|
||||
'Retry the operation, and if the problem persists check the '
|
||||
'server and your connection details.',
|
||||
title: 'Peach Error',
|
||||
severity: Severity.message.name,
|
||||
);
|
||||
}
|
||||
24
lib/Peach/about/external/data/Redis.dart
vendored
Normal file
24
lib/Peach/about/external/data/Redis.dart
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
/// A single Redis key's type, TTL and (formatted) value.
|
||||
class RedisEntry {
|
||||
final String key;
|
||||
final String type; // string | list | set | hash | zset | stream | none
|
||||
final String ttl; // seconds, -1 (no expiry), -2 (missing)
|
||||
final String value;
|
||||
RedisEntry({
|
||||
required this.key,
|
||||
required this.type,
|
||||
required this.ttl,
|
||||
required this.value,
|
||||
});
|
||||
|
||||
String get ttlLabel {
|
||||
final n = int.tryParse(ttl.trim());
|
||||
if (n == null) return ttl;
|
||||
if (n == -1) return 'no expiry';
|
||||
if (n == -2) return 'missing';
|
||||
if (n < 60) return '${n}s';
|
||||
if (n < 3600) return '${(n / 60).floor()}m ${n % 60}s';
|
||||
if (n < 86400) return '${(n / 3600).floor()}h';
|
||||
return '${(n / 86400).floor()}d';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user