import 'dart:async'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import '../core/app_controller.dart'; import '../l10n/strings.dart'; import '../services/metrics_format.dart'; import '../services/monitor.dart'; import '../services/ssh_service.dart'; import 'adaptive.dart'; import 'common.dart'; import 'theme.dart'; class MonitorPage extends StatefulWidget { final AppController controller; const MonitorPage(this.controller, {super.key}); @override State createState() => _MonitorPageState(); } class _MonitorPageState extends State { final adapter = LinuxMonitor(); Map metrics = {}; Timer? timer; bool busy = false; String? error; DateTime? updated; @override void initState() { super.initState(); widget.controller.addListener(schedule); schedule(); } void schedule() { timer?.cancel(); if (!widget.controller.foreground || !widget.controller.ssh.connected) { adapter.cancel(); return; } refresh(); timer = Timer.periodic( Duration(seconds: widget.controller.monitorSeconds), (_) => refresh(), ); } Future refresh() async { if (busy || !widget.controller.foreground || !widget.controller.ssh.connected) { return; } setState(() => busy = true); try { final result = await adapter.sample(widget.controller.ssh.client); if (mounted && widget.controller.foreground) { setState(() { metrics = result; error = null; updated = DateTime.now(); }); } } catch (e) { if (mounted) setState(() => error = failureCode(e)); } finally { if (mounted) setState(() => busy = false); } } @override void dispose() { timer?.cancel(); adapter.cancel(); widget.controller.removeListener(schedule); super.dispose(); } @override Widget build(BuildContext context) { if (!widget.controller.ssh.connected) { return const PageMessage( 'selectServer', icon: Icons.monitor_heart_outlined, ); } final locale = Localizations.localeOf(context).toString(); final l = AppLocalizations.of(context)!; String decimal(double v, int digits) => NumberFormat.decimalPatternDigits( locale: locale, decimalDigits: digits, ).format(v); String percent(double v) => '${decimal(v, 1)} %'; String bytes(int b) => formatBytes(b, decimal); final v = MetricValues.parse(metrics); final handshake = widget.controller.ssh.handshakeMs; String uptime() { final u = splitUptime(v.uptimeSeconds!); return [ if (u.days > 0) l.uptimeDays(u.days), if (u.days > 0 || u.hours > 0) l.uptimeHours(u.hours), l.uptimeMinutes(u.minutes), ].join(' '); } return LayoutBuilder( builder: (context, constraints) => ListView( padding: readablePadding(constraints, maxWidth: 1200), children: [ Wrap( spacing: 12, runSpacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ // Never shown as disabled: a press while a sample is running // is ignored by refresh() instead of starting a second one. FilledButton.tonalIcon( onPressed: refresh, icon: busy ? const SizedBox.square( dimension: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.refresh), label: Text(context.tr('refresh')), ), Text( [ l.autoRefresh('${widget.controller.monitorSeconds}'), if (updated != null) l.updatedAt(DateFormat.Hms(locale).format(updated!)), ].join(' · '), style: _muted(context), ), ], ), const SizedBox(height: 12), if (error != null) Card( color: Theme.of(context).colorScheme.errorContainer, child: ListTile( leading: const Icon(Icons.error_outline), title: Text(context.tr(error!)), ), ), AdaptiveGrid( minTileWidth: 280, children: [ _Metric( icon: Icons.memory, color: Brand.blue, label: context.tr('cpu'), value: v.cpu == null ? metrics['cpu'] : percent(v.cpu!), gauge: v.cpu, ), _Metric( icon: Icons.developer_board, color: Brand.violet, label: context.tr('ram'), value: v.ramPercent == null ? metrics['ram'] : percent(v.ramPercent!), detail: v.ramUsedBytes == null ? null : l.usedOf(bytes(v.ramUsedBytes!), bytes(v.ramTotalBytes!)), gauge: v.ramPercent, ), _Metric( icon: Icons.storage, color: Brand.green, label: context.tr('disk'), value: v.diskPercent == null ? metrics['disk'] : percent(v.diskPercent!), detail: v.diskUsedBytes == null ? null : l.usedOf( bytes(v.diskUsedBytes!), bytes(v.diskTotalBytes!), ), gauge: v.diskPercent, ), _Metric( icon: Icons.av_timer, color: Brand.blue, label: context.tr('load'), value: v.load == null ? metrics['load'] : v.load!.map((x) => decimal(x, 2)).join(' · '), detail: v.load == null ? null : context.tr('loadNote'), ), _Metric( icon: Icons.schedule, color: Brand.green, label: context.tr('uptime'), value: v.uptimeSeconds == null ? metrics['uptime'] : uptime(), ), _Metric( icon: Icons.dns_outlined, color: Brand.violet, label: context.tr('release'), value: metrics['release'], detail: metrics['system'], ), _Metric( icon: Icons.speed, color: Brand.blue, label: context.tr('sshLatency'), value: metrics['rtt'], detail: [ context.tr('commandRtt'), if (handshake != null) '${context.tr('handshake')}: $handshake ms', ].join('\n'), ), ], ), const SizedBox(height: 12), Text(context.tr('backgroundNote'), style: _muted(context)), ], ), ); } } TextStyle _muted(BuildContext context) => TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12, ); /// A desktop-style metric card: tinted icon tile, label, value, detail and /// an optional percentage gauge. class _Metric extends StatelessWidget { final IconData icon; final Color color; final String label; final String? value, detail; final double? gauge; const _Metric({ required this.icon, required this.color, required this.label, required this.value, this.detail, this.gauge, }); @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; final level = gauge == null ? null : (gauge! / 100).clamp(0.0, 1.0); return Card( child: Padding( padding: const EdgeInsets.all(16), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( width: 42, height: 42, decoration: BoxDecoration( color: color.withValues(alpha: .12), borderRadius: BorderRadius.circular(12), ), child: Icon(icon, color: color), ), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: _muted(context)), const SizedBox(height: 4), SelectableText( value ?? '—', style: Theme.of(context).textTheme.titleLarge?.copyWith( fontFeatures: const [FontFeature.tabularFigures()], ), ), if (level != null) ...[ const SizedBox(height: 8), ClipRRect( borderRadius: BorderRadius.circular(3), child: LinearProgressIndicator( value: level, minHeight: 5, color: level >= .9 ? scheme.error : level >= .75 ? Brand.amber : color, ), ), ], if (detail != null) ...[ const SizedBox(height: 6), Text(detail!, style: _muted(context)), ], ], ), ), ], ), ), ); } }