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