From 9dafdc4f67162afb5d0d438eb5b12c002383543d Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 9 Oct 2026 13:43:34 +0300 Subject: [PATCH 1/3] feat(stats): sparklines for connections and transactions or queries per second (#1165) The Overview polled the server every few seconds but showed only the current numbers. Each poll now also feeds an in-memory history (ten minutes of samples, kept while the Overview is open). Counters become rates per second from their change. PostgreSQL shows active connections and transactions per second; MySQL shows connected threads and queries per second. The sparklines come from the numbers each poll already fetched, so no query is added. --- lib/core/stats/metric_history.dart | 43 ++++++++++ lib/features/mysql/mysql_stats_view.dart | 31 ++++++- .../postgresql/postgres_stats_view.dart | 36 ++++++++- lib/shared/widgets/querya_sparkline.dart | 81 +++++++++++++++++++ test/core/stats/metric_history_test.dart | 42 ++++++++++ 5 files changed, 229 insertions(+), 4 deletions(-) create mode 100644 lib/core/stats/metric_history.dart create mode 100644 lib/shared/widgets/querya_sparkline.dart create mode 100644 test/core/stats/metric_history_test.dart diff --git a/lib/core/stats/metric_history.dart b/lib/core/stats/metric_history.dart new file mode 100644 index 00000000..c75847d2 --- /dev/null +++ b/lib/core/stats/metric_history.dart @@ -0,0 +1,43 @@ +/// One sample of a metric: when it was taken and its value. +class MetricSample { + const MetricSample(this.at, this.value); + + final DateTime at; + final double value; +} + +/// The last samples of named metrics, kept while a dashboard is open. Counters +/// (cumulative totals such as transactions committed) become rates per second +/// from their change between two samples. +class MetricHistory { + MetricHistory({this.capacity = 120}); + + /// Samples kept per metric: 120 at the 5 s poll is ten minutes. + final int capacity; + + final Map> _series = {}; + final Map _lastCounter = {}; + + void record(String name, double value, DateTime at) { + final list = _series.putIfAbsent(name, () => []); + list.add(MetricSample(at, value)); + if (list.length > capacity) list.removeRange(0, list.length - capacity); + } + + /// Records the rate of a cumulative [total] since the previous sample. The + /// first sample gives no rate; a counter that went down (a server restart) + /// gives none either. + void recordCounter(String name, double total, DateTime at) { + final last = _lastCounter[name]; + _lastCounter[name] = MetricSample(at, total); + if (last == null) return; + final seconds = + at.difference(last.at).inMicroseconds / Duration.microsecondsPerSecond; + final delta = total - last.value; + if (seconds <= 0 || delta < 0) return; + record(name, delta / seconds, at); + } + + List series(String name) => + List.unmodifiable(_series[name] ?? const []); +} diff --git a/lib/features/mysql/mysql_stats_view.dart b/lib/features/mysql/mysql_stats_view.dart index 3ec4afa6..e51a96e7 100644 --- a/lib/features/mysql/mysql_stats_view.dart +++ b/lib/features/mysql/mysql_stats_view.dart @@ -3,9 +3,11 @@ import 'dart:math' as math; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/motion/ticker_gated_polling.dart'; +import 'package:querya_desktop/core/stats/metric_history.dart'; import 'package:querya_desktop/core/util/deep_collection_equals.dart'; import 'package:querya_desktop/core/database/mysql_service.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/shared/widgets/querya_sparkline.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; @@ -30,6 +32,9 @@ class MysqlStatsView extends material.StatefulWidget { class _MysqlStatsViewState extends material.State { MysqlLease? _lease; Map? _stats; + + /// Samples of the last poll results: threads and queries per second. + final _history = MetricHistory(); bool _loading = true; String? _error; Timer? _timer; @@ -120,14 +125,28 @@ class _MysqlStatsViewState extends material.State { } } + /// Records the poll's numbers. No query is added: the poll already fetched them. + void _recordHistory(Map stats) { + final now = DateTime.now(); + double num(String key) => + double.tryParse((stats['status'] as Map?)?[key] ?? '') ?? + 0; + _history + ..record('threads_connected', num('Threads_connected'), now) + ..record('threads_running', num('Threads_running'), now); + _history.recordCounter('queries_per_second', num('Questions'), now); + } + Future _onPollTick() async { final conn = _lease?.connection; if (conn == null || !conn.isConnected) return; try { final stats = await conn.serverStats(); if (!mounted) return; - if (!replaceIfChanged(_stats, stats, (v) => _stats = v)) return; - setState(() => _stats = stats); + _recordHistory(stats); + // The sparklines move with every sample, so rebuild even when the numbers + // did not change. + setState(() => replaceIfChanged(_stats, stats, (v) => _stats = v)); } catch (_) {} } @@ -472,6 +491,14 @@ class _MysqlStatsViewState extends material.State { context, 'Max used', _status(stats, 'Max_used_connections')), _metricRow( context, 'Max allowed', _variable(stats, 'max_connections')), + QueryaSparkline( + samples: _history.series('threads_connected'), + color: context.workbench.accent, + ), + QueryaSparkline( + samples: _history.series('queries_per_second'), + color: context.workbench.accent, + ), ], stretch: true, ), diff --git a/lib/features/postgresql/postgres_stats_view.dart b/lib/features/postgresql/postgres_stats_view.dart index bb721ea4..bf6a1357 100644 --- a/lib/features/postgresql/postgres_stats_view.dart +++ b/lib/features/postgresql/postgres_stats_view.dart @@ -3,10 +3,12 @@ import 'dart:math' as math; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/motion/ticker_gated_polling.dart'; +import 'package:querya_desktop/core/stats/metric_history.dart'; import 'package:querya_desktop/core/util/deep_collection_equals.dart'; import 'package:querya_desktop/core/database/postgres_connection.dart'; import 'package:querya_desktop/core/database/postgres_service.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/shared/widgets/querya_sparkline.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; @@ -32,6 +34,9 @@ class _PostgresStatsViewState extends material.State { PostgresConnection? get _connection => _lease?.connection; Map? _stats; + + /// Samples of the last poll results: connections and transactions per second. + final _history = MetricHistory(); bool _loading = true; String? _error; Timer? _timer; @@ -103,6 +108,21 @@ class _PostgresStatsViewState extends material.State { } } + /// Records the poll's numbers. No query is added: the poll already fetched them. + void _recordHistory(Map stats) { + final now = DateTime.now(); + _history + ..record('connections_active', + ((stats['connections_active'] ?? 0) as num).toDouble(), now) + ..record('connections_total', + ((stats['connections_total'] ?? 0) as num).toDouble(), now); + var commits = 0; + for (final db in stats['databases'] as List? ?? const []) { + commits += ((db as Map)['xact_commit'] as num? ?? 0).toInt(); + } + _history.recordCounter('transactions_per_second', commits.toDouble(), now); + } + Future _fetch() async { final c = _connection; if (c == null || !c.isConnected) { @@ -116,6 +136,7 @@ class _PostgresStatsViewState extends material.State { try { final stats = await c.serverStats(); if (!mounted) return; + _recordHistory(stats); if (!replaceIfChanged(_stats, stats, (v) => _stats = v)) { if (_loading) { setState(() { @@ -143,8 +164,10 @@ class _PostgresStatsViewState extends material.State { try { final stats = await c.serverStats(); if (!mounted) return; - if (!replaceIfChanged(_stats, stats, (v) => _stats = v)) return; - setState(() => _stats = stats); + _recordHistory(stats); + // The sparklines move with every sample, so rebuild even when the numbers + // did not change. + setState(() => replaceIfChanged(_stats, stats, (v) => _stats = v)); } catch (_) {} } @@ -452,6 +475,15 @@ class _PostgresStatsViewState extends material.State { _row(context, 'Active', '$active'), _row(context, 'Idle', '$idle'), _row(context, 'Max connections', maxConn), + const Gap(8), + QueryaSparkline( + samples: _history.series('connections_active'), + color: context.workbench.accent, + ), + QueryaSparkline( + samples: _history.series('transactions_per_second'), + color: context.workbench.accent, + ), ], ), minHeight: _gridCardMinHeight, diff --git a/lib/shared/widgets/querya_sparkline.dart b/lib/shared/widgets/querya_sparkline.dart new file mode 100644 index 00000000..06638425 --- /dev/null +++ b/lib/shared/widgets/querya_sparkline.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/stats/metric_history.dart'; + +/// A small line of the last samples of a metric. The tooltip names the latest +/// value and when it was taken. Draws nothing until there are two samples. +class QueryaSparkline extends material.StatelessWidget { + const QueryaSparkline({ + super.key, + required this.samples, + required this.color, + this.width = 180, + this.height = 32, + }); + + final List samples; + final material.Color color; + final double width; + final double height; + + @override + material.Widget build(material.BuildContext context) { + final line = material.CustomPaint( + size: material.Size(width, height), + painter: _SparklinePainter(samples, color), + ); + if (samples.isEmpty) return line; + final last = samples.last; + final time = last.at.toLocal(); + final hh = time.hour.toString().padLeft(2, '0'); + final mm = time.minute.toString().padLeft(2, '0'); + final ss = time.second.toString().padLeft(2, '0'); + final value = last.value >= 100 + ? last.value.round().toString() + : last.value.toStringAsFixed(1); + return material.Tooltip(message: '$value at $hh:$mm:$ss', child: line); + } +} + +class _SparklinePainter extends material.CustomPainter { + _SparklinePainter(this.samples, this.color); + + final List samples; + final material.Color color; + + @override + void paint(material.Canvas canvas, material.Size size) { + if (samples.length < 2) return; + var lo = samples.first.value; + var hi = samples.first.value; + for (final s in samples) { + if (s.value < lo) lo = s.value; + if (s.value > hi) hi = s.value; + } + // A flat series sits in the middle instead of dividing by zero. + final span = hi == lo ? 1.0 : hi - lo; + final path = material.Path(); + for (var i = 0; i < samples.length; i++) { + final x = size.width * i / (samples.length - 1); + final y = hi == lo + ? size.height / 2 + : size.height - (samples[i].value - lo) / span * size.height; + if (i == 0) { + path.moveTo(x, y); + } else { + path.lineTo(x, y); + } + } + canvas.drawPath( + path, + material.Paint() + ..color = color + ..style = material.PaintingStyle.stroke + ..strokeWidth = 1.5 + ..strokeJoin = material.StrokeJoin.round, + ); + } + + @override + bool shouldRepaint(_SparklinePainter old) => + old.samples != samples || old.color != color; +} diff --git a/test/core/stats/metric_history_test.dart b/test/core/stats/metric_history_test.dart new file mode 100644 index 00000000..59034484 --- /dev/null +++ b/test/core/stats/metric_history_test.dart @@ -0,0 +1,42 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/stats/metric_history.dart'; + +void main() { + group('MetricHistory (#1165)', () { + test('keeps only the newest samples up to the capacity', () { + final h = MetricHistory(capacity: 3); + final t = DateTime.utc(2026, 1, 1); + for (var i = 0; i < 5; i++) { + h.record('active', i.toDouble(), t.add(Duration(seconds: i))); + } + expect(h.series('active').map((s) => s.value), [2, 3, 4]); + }); + + test('a counter gives a rate per second from its change', () { + final h = MetricHistory(); + final t = DateTime.utc(2026, 1, 1); + h.recordCounter('tps', 1000, t); + expect(h.series('tps'), isEmpty, reason: 'the first sample has no rate'); + h.recordCounter('tps', 1100, t.add(const Duration(seconds: 5))); + expect(h.series('tps').single.value, 20); + }); + + test('a counter that went down (a restart) gives no rate', () { + final h = MetricHistory(); + final t = DateTime.utc(2026, 1, 1); + h.recordCounter('tps', 1000, t); + h.recordCounter('tps', 10, t.add(const Duration(seconds: 5))); + expect(h.series('tps'), isEmpty); + h.recordCounter('tps', 60, t.add(const Duration(seconds: 10))); + expect(h.series('tps').single.value, 10); + }); + + test('series are read-only and unknown metrics are empty', () { + final h = MetricHistory(); + expect(h.series('nothing'), isEmpty); + h.record('a', 1, DateTime.utc(2026)); + expect(() => h.series('a').add(const MetricSample(DateTime.utc(2026), 2)), + throwsUnsupportedError); + }); + }); +} From 126c2b66a34f3f25d9c86a02299a490b8ec0ab08 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 9 Oct 2026 13:43:46 +0300 Subject: [PATCH 2/3] fix(stats): MySQL status samples are doubles (#1165) --- lib/features/mysql/mysql_stats_view.dart | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/features/mysql/mysql_stats_view.dart b/lib/features/mysql/mysql_stats_view.dart index e51a96e7..327e1bc3 100644 --- a/lib/features/mysql/mysql_stats_view.dart +++ b/lib/features/mysql/mysql_stats_view.dart @@ -128,13 +128,14 @@ class _MysqlStatsViewState extends material.State { /// Records the poll's numbers. No query is added: the poll already fetched them. void _recordHistory(Map stats) { final now = DateTime.now(); - double num(String key) => + double statusValue(String key) => double.tryParse((stats['status'] as Map?)?[key] ?? '') ?? - 0; + 0.0; _history - ..record('threads_connected', num('Threads_connected'), now) - ..record('threads_running', num('Threads_running'), now); - _history.recordCounter('queries_per_second', num('Questions'), now); + ..record('threads_connected', statusValue('Threads_connected'), now) + ..record('threads_running', statusValue('Threads_running'), now); + _history.recordCounter( + 'queries_per_second', statusValue('Questions'), now); } Future _onPollTick() async { From 5213ea5d631d2e282f15dd2001ce69192f25a3b3 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 9 Oct 2026 13:47:01 +0300 Subject: [PATCH 3/3] test(stats): drop const on a DateTime-based sample (#1165) --- test/core/stats/metric_history_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/core/stats/metric_history_test.dart b/test/core/stats/metric_history_test.dart index 59034484..b7c28c6c 100644 --- a/test/core/stats/metric_history_test.dart +++ b/test/core/stats/metric_history_test.dart @@ -35,7 +35,7 @@ void main() { final h = MetricHistory(); expect(h.series('nothing'), isEmpty); h.record('a', 1, DateTime.utc(2026)); - expect(() => h.series('a').add(const MetricSample(DateTime.utc(2026), 2)), + expect(() => h.series('a').add(MetricSample(DateTime.utc(2026), 2)), throwsUnsupportedError); }); });