295 lines
9.1 KiB
Dart
295 lines
9.1 KiB
Dart
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'),
|
|
};
|
|
}
|