Skip to content

Commit 68befc1

Browse files
committed
Redis metrics
1 parent a48262a commit 68befc1

File tree

4 files changed

+258
-0
lines changed

4 files changed

+258
-0
lines changed

src/Dashboards/Redis/RedisDashboard.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,10 @@ public function ajax(): string {
104104
return Helpers::getPanelsJson($this->getPanelsData());
105105
}
106106

107+
if (isset($_GET['metrics'])) {
108+
return (new RedisMetrics($this->redis, $this->template, $this->servers, $this->current_server))->collectAndRespond();
109+
}
110+
107111
if (isset($_GET['deleteall'])) {
108112
return $this->deleteAllKeys();
109113
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
<?php
2+
/**
3+
* This file is part of the phpCacheAdmin.
4+
* Copyright (c) Róbert Kelčák (https://kelcak.com/)
5+
*/
6+
7+
declare(strict_types=1);
8+
9+
namespace RobiNN\Pca\Dashboards\Redis;
10+
11+
use JsonException;
12+
use PDO;
13+
use RobiNN\Pca\Config;
14+
use RobiNN\Pca\Dashboards\Redis\Compatibility\Cluster\PredisCluster;
15+
use RobiNN\Pca\Dashboards\Redis\Compatibility\Cluster\RedisCluster;
16+
use RobiNN\Pca\Dashboards\Redis\Compatibility\Predis;
17+
use RobiNN\Pca\Dashboards\Redis\Compatibility\Redis;
18+
use RobiNN\Pca\Helpers;
19+
use RobiNN\Pca\Http;
20+
use RobiNN\Pca\Template;
21+
22+
readonly class RedisMetrics {
23+
private PDO $pdo;
24+
25+
/**
26+
* @param array<int, array<string, int|string>> $servers
27+
*/
28+
public function __construct(
29+
private Redis|Predis|RedisCluster|PredisCluster $redis,
30+
private Template $template,
31+
array $servers,
32+
int $selected,
33+
) {
34+
$server_name = Helpers::getServerTitle($servers[$selected]);
35+
$hash = md5($server_name.Config::get('hash', 'pca'));
36+
$db = __DIR__.'/../../../tmp/redis_metrics_'.$hash.'.db';
37+
38+
$this->pdo = new PDO('sqlite:'.$db);
39+
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
40+
41+
$schema = <<<SQL
42+
CREATE TABLE IF NOT EXISTS metrics (
43+
id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp INTEGER NOT NULL,
44+
commands_per_second INTEGER, hit_rate REAL, memory_used INTEGER, memory_peak INTEGER,
45+
fragmentation_ratio REAL, connections INTEGER
46+
)
47+
SQL;
48+
49+
$this->pdo->exec($schema);
50+
}
51+
52+
public function collectAndRespond(): string {
53+
$info = $this->redis->getInfo(null, [
54+
'used_memory',
55+
'used_memory_peak',
56+
'mem_fragmentation_ratio',
57+
'keyspace_hits',
58+
'keyspace_misses',
59+
'connected_clients',
60+
'instantaneous_ops_per_sec',
61+
]);
62+
63+
$metrics = $this->calculateMetrics($info);
64+
$this->insertMetrics($metrics);
65+
$recent_data = $this->fetchRecentMetrics();
66+
$formatted_data = $this->formatDataForResponse($recent_data);
67+
68+
header('Content-Type: application/json');
69+
header('Cache-Control: no-cache, must-revalidate');
70+
71+
try {
72+
return json_encode($formatted_data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
73+
} catch (JsonException $e) {
74+
return Helpers::alert($this->template, $e->getMessage(), 'error');
75+
}
76+
}
77+
78+
/**
79+
* @param array<string, mixed> $info
80+
*
81+
* @return array<string, int|float>
82+
*/
83+
private function calculateMetrics(array $info): array {
84+
$keyspace_hits = $info['stats']['keyspace_hits'] ?? 0;
85+
$keyspace_misses = $info['stats']['keyspace_misses'] ?? 0;
86+
$total_commands = $keyspace_hits + $keyspace_misses;
87+
88+
return [
89+
'timestamp' => time(),
90+
'commands_per_second' => $info['stats']['instantaneous_ops_per_sec'] ?? 0,
91+
'hit_rate' => $total_commands > 0 ? round(($keyspace_hits / $total_commands) * 100, 2) : 0.0,
92+
'memory_used' => $info['memory']['used_memory'] ?? 0,
93+
'memory_peak' => $info['memory']['used_memory_peak'] ?? 0,
94+
'fragmentation_ratio' => $info['memory']['mem_fragmentation_ratio'] ?? 0,
95+
'connections' => $info['clients']['connected_clients'] ?? 0,
96+
];
97+
}
98+
99+
/**
100+
* @param array<string, mixed> $metrics
101+
*/
102+
private function insertMetrics(array $metrics): void {
103+
$columns = implode(', ', array_keys($metrics));
104+
$placeholders = rtrim(str_repeat('?, ', count($metrics)), ', ');
105+
$sql = sprintf('INSERT INTO metrics (%s) VALUES (%s)', $columns, $placeholders);
106+
107+
$stmt = $this->pdo->prepare($sql);
108+
$stmt->execute(array_values($metrics));
109+
}
110+
111+
/**
112+
* @return array<int, array<string, mixed>>
113+
*/
114+
private function fetchRecentMetrics(): array {
115+
$max_data_points_to_return = Http::post('points', Config::get('metricstab', 1440));
116+
117+
$stmt = $this->pdo->prepare('SELECT * FROM metrics ORDER BY id DESC LIMIT :limit');
118+
$stmt->bindValue(':limit', $max_data_points_to_return, PDO::PARAM_INT);
119+
$stmt->execute();
120+
121+
$results = $stmt->fetchAll(PDO::FETCH_ASSOC);
122+
123+
return array_reverse($results);
124+
}
125+
126+
/**
127+
* @param array<int, array<string, mixed>> $db_rows
128+
*
129+
* @return array<int, array<string, mixed>>
130+
*/
131+
private function formatDataForResponse(array $db_rows): array {
132+
$formatted_results = [];
133+
134+
foreach ($db_rows as $row) {
135+
$formatted_results[] = [
136+
'timestamp' => date('Y-m-d H:i:s', (int) $row['timestamp']),
137+
'unix_timestamp' => (int) $row['timestamp'],
138+
'commands_per_second' => $row['commands_per_second'],
139+
'hit_rate' => $row['hit_rate'],
140+
'memory' => [
141+
'used' => $row['memory_used'],
142+
'peak' => $row['memory_peak'],
143+
'fragmentation' => $row['fragmentation_ratio'],
144+
],
145+
'connections' => $row['connections'],
146+
];
147+
}
148+
149+
return $formatted_results;
150+
}
151+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
{{ include('components/tabs.twig', {
2+
tabs: true,
3+
selected: config('metricstab', 1440),
4+
links: {
5+
60: 'Last hour',
6+
1440: 'Last day',
7+
10080: 'Last week',
8+
43200: 'Last month',
9+
},
10+
}) }}
11+
12+
<div class="md:grid md:grid-cols-4 gap-4">
13+
<div class="p-4 mb-2 rounded-sm bg-white border border-gray-200 dark:bg-gray-800 dark:border-gray-700 col-span-2">
14+
<div id="memory_chart" class="h-90"></div>
15+
</div>
16+
<div class="p-4 mb-2 rounded-sm bg-white border border-gray-200 dark:bg-gray-800 dark:border-gray-700 col-span-2">
17+
<div id="fragmentation_chart" class="h-90"></div>
18+
</div>
19+
<div class="p-4 mb-2 rounded-sm bg-white border border-gray-200 dark:bg-gray-800 dark:border-gray-700 col-span-2">
20+
<div id="commands_chart" class="h-90"></div>
21+
</div>
22+
<div class="p-4 mb-2 rounded-sm bg-white border border-gray-200 dark:bg-gray-800 dark:border-gray-700 col-span-2">
23+
<div id="hit_rate_chart" class="h-90"></div>
24+
</div>
25+
<div class="p-4 mb-2 rounded-sm bg-white border border-gray-200 dark:bg-gray-800 dark:border-gray-700 col-span-2">
26+
<div id="connections_chart" class="h-90"></div>
27+
</div>
28+
</div>
29+
30+
<script src="assets/js/echarts.min.js"></script>
31+
<script>
32+
document.addEventListener('DOMContentLoaded', () => {
33+
const initial_theme = document.documentElement.classList.contains('dark') ? 'dark' : null;
34+
35+
const chart_config = {
36+
memory: echarts.init(document.getElementById('memory_chart'), initial_theme, {renderer: 'svg'}),
37+
fragmentation: echarts.init(document.getElementById('fragmentation_chart'), initial_theme, {renderer: 'svg'}),
38+
commands: echarts.init(document.getElementById('commands_chart'), initial_theme, {renderer: 'svg'}),
39+
hit_rate: echarts.init(document.getElementById('hit_rate_chart'), initial_theme, {renderer: 'svg'}),
40+
connections: echarts.init(document.getElementById('connections_chart'), initial_theme, {renderer: 'svg'}),
41+
};
42+
43+
const render_charts = (data) => {
44+
if (!data || data.length === 0) return;
45+
const timestamps = data.map(p => p.timestamp.split(' ')[1]);
46+
47+
chart(chart_config.memory, {
48+
title: 'Memory Usage',
49+
legend: ['Used', 'Peak'],
50+
yAxis: {type: 'value', name: 'Memory', axisLabel: {formatter: '{value} MB'}},
51+
series: [
52+
{name: 'Used', type: 'line', data: data.map(p => (p.memory.used / 1048576).toFixed(2)), areaStyle: {}},
53+
{name: 'Peak', type: 'line', data: data.map(p => (p.memory.peak / 1048576).toFixed(2))},
54+
],
55+
}, timestamps);
56+
57+
chart(chart_config.fragmentation, {
58+
title: 'Fragmentation Ratio',
59+
legend: ['Ratio'],
60+
yAxis: {type: 'value', name: 'Ratio'},
61+
series: [
62+
{name: 'Ratio', type: 'line', data: data.map(p => p.memory.fragmentation), areaStyle: {}},
63+
],
64+
}, timestamps);
65+
66+
chart(chart_config.commands, {
67+
title: 'Commands per second',
68+
legend: ['Commands'],
69+
yAxis: {type: 'value', name: 'ops/sec'},
70+
series: [
71+
{name: 'Commands', type: 'line', data: data.map(p => p.commands_per_second), areaStyle: {}},
72+
],
73+
}, timestamps);
74+
75+
chart(chart_config.hit_rate, {
76+
title: 'Hit Rate',
77+
tooltip: {valueFormatter: v => v.toFixed(2) + '%'},
78+
legend: ['Hit Rate'],
79+
yAxis: {type: 'value', min: 0, max: 100, axisLabel: {formatter: '{value}%'}},
80+
series: [
81+
{name: 'Hit Rate', type: 'line', data: data.map(p => p.hit_rate), areaStyle: {}},
82+
],
83+
}, timestamps);
84+
85+
chart(chart_config.connections, {
86+
title: 'Connections',
87+
legend: ['Connections'],
88+
yAxis: {type: 'value', name: 'Clients'},
89+
series: [
90+
{name: 'Connections', type: 'line', data: data.map(p => p.connections), areaStyle: {}},
91+
],
92+
}, timestamps);
93+
94+
};
95+
96+
init_metrics(render_charts, chart_config);
97+
});
98+
</script>

templates/dashboards/redis/redis.twig

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
links: {
33
'keys': 'Keys',
44
'slowlog': 'Slow Log',
5+
'metrics': 'Metrics',
56
},
67
}) }}
78

@@ -91,3 +92,7 @@
9192
</div>
9293
</div>
9394
{% endif %}
95+
96+
{% if get('tab') == 'metrics' %}
97+
{{ include('dashboards/redis/metrics.twig') }}
98+
{% endif %}

0 commit comments

Comments
 (0)