Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions lib/core/stats/metric_history.dart
Original file line number Diff line number Diff line change
@@ -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<String, List<MetricSample>> _series = {};
final Map<String, MetricSample> _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<MetricSample> series(String name) =>
List.unmodifiable(_series[name] ?? const <MetricSample>[]);
}
32 changes: 30 additions & 2 deletions lib/features/mysql/mysql_stats_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -30,6 +32,9 @@ class MysqlStatsView extends material.StatefulWidget {
class _MysqlStatsViewState extends material.State<MysqlStatsView> {
MysqlLease? _lease;
Map<String, dynamic>? _stats;

/// Samples of the last poll results: threads and queries per second.
final _history = MetricHistory();
bool _loading = true;
String? _error;
Timer? _timer;
Expand Down Expand Up @@ -120,14 +125,29 @@ class _MysqlStatsViewState extends material.State<MysqlStatsView> {
}
}

/// Records the poll's numbers. No query is added: the poll already fetched them.
void _recordHistory(Map<String, dynamic> stats) {
final now = DateTime.now();
double statusValue(String key) =>
double.tryParse((stats['status'] as Map<String, String>?)?[key] ?? '') ??
0.0;
_history
..record('threads_connected', statusValue('Threads_connected'), now)
..record('threads_running', statusValue('Threads_running'), now);
_history.recordCounter(
'queries_per_second', statusValue('Questions'), now);
}

Future<void> _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 (_) {}
}

Expand Down Expand Up @@ -472,6 +492,14 @@ class _MysqlStatsViewState extends material.State<MysqlStatsView> {
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,
),
Expand Down
36 changes: 34 additions & 2 deletions lib/features/postgresql/postgres_stats_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -32,6 +34,9 @@ class _PostgresStatsViewState extends material.State<PostgresStatsView> {
PostgresConnection? get _connection => _lease?.connection;

Map<String, dynamic>? _stats;

/// Samples of the last poll results: connections and transactions per second.
final _history = MetricHistory();
bool _loading = true;
String? _error;
Timer? _timer;
Expand Down Expand Up @@ -103,6 +108,21 @@ class _PostgresStatsViewState extends material.State<PostgresStatsView> {
}
}

/// Records the poll's numbers. No query is added: the poll already fetched them.
void _recordHistory(Map<String, dynamic> 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<void> _fetch() async {
final c = _connection;
if (c == null || !c.isConnected) {
Expand All @@ -116,6 +136,7 @@ class _PostgresStatsViewState extends material.State<PostgresStatsView> {
try {
final stats = await c.serverStats();
if (!mounted) return;
_recordHistory(stats);
if (!replaceIfChanged(_stats, stats, (v) => _stats = v)) {
if (_loading) {
setState(() {
Expand Down Expand Up @@ -143,8 +164,10 @@ class _PostgresStatsViewState extends material.State<PostgresStatsView> {
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 (_) {}
}

Expand Down Expand Up @@ -452,6 +475,15 @@ class _PostgresStatsViewState extends material.State<PostgresStatsView> {
_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,
Expand Down
81 changes: 81 additions & 0 deletions lib/shared/widgets/querya_sparkline.dart
Original file line number Diff line number Diff line change
@@ -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<MetricSample> 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<MetricSample> 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;
}
42 changes: 42 additions & 0 deletions test/core/stats/metric_history_test.dart
Original file line number Diff line number Diff line change
@@ -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(MetricSample(DateTime.utc(2026), 2)),
throwsUnsupportedError);
});
});
}
Loading