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