Skip to content

Commit 38f4ef6

Browse files
committed
Add Memcached metrics, close #50
1 parent 8ee7e20 commit 38f4ef6

File tree

11 files changed

+519
-25
lines changed

11 files changed

+519
-25
lines changed

assets/css/styles.css

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/*! tailwindcss v4.1.13 | MIT License | https://tailwindcss.com */
1+
/*! tailwindcss v4.1.14 | MIT License | https://tailwindcss.com */
22
@layer properties;
33
@layer theme, base, components, utilities;
44
@layer theme {
@@ -195,6 +195,9 @@
195195
.col-span-8 {
196196
grid-column: span 8 / span 8;
197197
}
198+
.col-span-10 {
199+
grid-column: span 10 / span 10;
200+
}
198201
.m-1 {
199202
margin: calc(0.25rem * 1);
200203
}
@@ -250,6 +253,9 @@
250253
.h-4 {
251254
height: calc(0.25rem * 4);
252255
}
256+
.h-90 {
257+
height: calc(0.25rem * 90);
258+
}
253259
.w-4 {
254260
width: calc(0.25rem * 4);
255261
}
@@ -289,6 +295,9 @@
289295
.cursor-pointer {
290296
cursor: pointer;
291297
}
298+
.resize {
299+
resize: both;
300+
}
292301
.appearance-none {
293302
appearance: none;
294303
}
@@ -836,6 +845,11 @@
836845
background-color: oklch(80.8% 0.114 19.571);
837846
}
838847
}
848+
.md\:col-span-5 {
849+
@media (width >= 48rem) {
850+
grid-column: span 5 / span 5;
851+
}
852+
}
839853
.md\:me-4 {
840854
@media (width >= 48rem) {
841855
margin-inline-end: calc(0.25rem * 4);

assets/js/echarts.min.js

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

config.dist.php

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,15 @@
1717
*
1818
* You can comment out (or delete) any dashboard.
1919
*/
20-
'dashboards' => [
20+
'dashboards' => [
2121
RobiNN\Pca\Dashboards\Server\ServerDashboard::class,
2222
RobiNN\Pca\Dashboards\Redis\RedisDashboard::class,
2323
RobiNN\Pca\Dashboards\Memcached\MemcachedDashboard::class,
2424
RobiNN\Pca\Dashboards\OPCache\OPCacheDashboard::class,
2525
RobiNN\Pca\Dashboards\APCu\APCuDashboard::class,
2626
RobiNN\Pca\Dashboards\Realpath\RealpathDashboard::class,
2727
],
28-
'redis' => [
28+
'redis' => [
2929
[
3030
'name' => 'Localhost', // The server name (optional).
3131
'host' => '127.0.0.1', // Optional when a path or nodes is specified.
@@ -52,7 +52,7 @@
5252
//'separator' => ':', // Separator for tree view (optional)
5353
],
5454
],
55-
'memcached' => [
55+
'memcached' => [
5656
[
5757
'name' => 'Localhost', // The server name, optional.
5858
'host' => '127.0.0.1', // Optional when a path is specified.
@@ -63,7 +63,7 @@
6363
],
6464
//'apcu-separator' => ':', // Separator for tree view (optional)
6565
// Example of authentication with http auth.
66-
/*'auth' => static function (): void {
66+
/*'auth' => static function (): void {
6767
$username = 'admin';
6868
$password = 'pass';
6969
@@ -88,7 +88,7 @@
8888
}
8989
},*/
9090
// Decoding / Encoding functions
91-
'converters' => [
91+
'converters' => [
9292
'gzcompress' => [
9393
'view' => static fn (string $value): ?string => @gzuncompress($value) !== false ? gzuncompress($value) : null,
9494
'save' => static fn (string $value): string => gzcompress($value),
@@ -116,7 +116,7 @@
116116
],*/
117117
],
118118
// Formatting functions, it runs after decoding
119-
'formatters' => [
119+
'formatters' => [
120120
'unserialize' => static function (string $value): ?string {
121121
$unserialized_value = @unserialize($value, ['allowed_classes' => false]);
122122
if ($unserialized_value !== false && is_array($unserialized_value)) {
@@ -131,10 +131,13 @@
131131
},
132132
],
133133
// Customizations
134-
//'timezone' => 'Europe/Bratislava', // Leave empty (or commented out) to get it automatically obtained.
135-
'time-format' => 'd. m. Y H:i:s',
136-
'decimal-sep' => ',',
137-
'thousands-sep' => ' ',
138-
'list-view' => 'table', // table/tree - default key list view
139-
'panelrefresh' => 30, // In seconds, refresh interval for panels - default 30
134+
//'timezone' => 'Europe/Bratislava', // Leave empty (or commented out) to get it automatically obtained.
135+
'time-format' => 'd. m. Y H:i:s',
136+
'decimal-sep' => ',',
137+
'thousands-sep' => ' ',
138+
'list-view' => 'table', // table/tree - default key list view
139+
'panelrefresh' => 30, // In seconds, refresh interval for panels - default 30
140+
'metricsrefresh' => 60, // In seconds, refresh interval for metrics - default 60
141+
'metricstab' => 1440, // Default tab in metrics, 60 - Last hour, 1440 - Last day, 10080 - Last week, 43200 - Last month - default 1440
142+
'hash' => 'pca', // Any random string to secure metrics DB file
140143
];

src/Admin.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
use RobiNN\Pca\Dashboards\DashboardInterface;
1212

1313
class Admin {
14-
public const VERSION = '2.2.4';
14+
public const VERSION = '2.3.0';
1515

1616
/**
1717
* @var array<string, DashboardInterface>

src/Dashboards/Memcached/MemcachedDashboard.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,10 @@ public function ajax(): string {
9393
return Helpers::getPanelsJson($this->getPanelsData(Http::get('tab') === 'commands_stats'));
9494
}
9595

96+
if (isset($_GET['metrics'])) {
97+
return (new MemcachedMetrics($this->memcached))->collectAndRespond();
98+
}
99+
96100
if (isset($_GET['deleteall'])) {
97101
return $this->deleteAllKeys();
98102
}
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
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\Memcached;
10+
11+
use JsonException;
12+
use PDO;
13+
use RobiNN\Pca\Config;
14+
use RobiNN\Pca\Http;
15+
16+
class MemcachedMetrics {
17+
private PDO $pdo;
18+
19+
private const RATE_COMMANDS = ['get', 'set', 'delete', 'incr', 'decr', 'cas', 'touch', 'flush'];
20+
private const HIT_RATE_COMMANDS = ['get', 'delete', 'incr', 'decr', 'cas', 'touch'];
21+
22+
public function __construct(private readonly PHPMem $memcached) {
23+
$hash = md5(Config::get('hash', 'pca')); // This isn't really safe, but it's better than nothing
24+
$db = __DIR__.'/../../../tmp/memcached_metrics'.$hash.'.db';
25+
26+
$this->pdo = new PDO('sqlite:'.$db);
27+
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
28+
29+
$schema = <<<SQL
30+
CREATE TABLE IF NOT EXISTS metrics (
31+
id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp INTEGER NOT NULL,
32+
memory_used INTEGER, memory_limit INTEGER, stored_items INTEGER, connections INTEGER,
33+
new_connection_rate REAL, eviction_rate REAL, expired_rate REAL,
34+
hit_rate_overall REAL, hit_rate_get REAL, hit_rate_delete REAL, hit_rate_incr REAL, hit_rate_decr REAL, hit_rate_cas REAL, hit_rate_touch REAL,
35+
request_rate_overall REAL, request_rate_get REAL, request_rate_set REAL, request_rate_delete REAL, request_rate_incr REAL, request_rate_decr REAL, request_rate_cas REAL, request_rate_touch REAL, request_rate_flush REAL,
36+
traffic_received_rate REAL, traffic_sent_rate REAL,
37+
cumulative_total_connections INTEGER, cumulative_evictions INTEGER, cumulative_expired_unfetched INTEGER,
38+
cumulative_bytes_read INTEGER, cumulative_bytes_written INTEGER,
39+
cumulative_cmd_get INTEGER, cumulative_cmd_set INTEGER, cumulative_cmd_delete INTEGER, cumulative_cmd_incr INTEGER, cumulative_cmd_decr INTEGER, cumulative_cmd_cas INTEGER, cumulative_cmd_touch INTEGER, cumulative_cmd_flush INTEGER
40+
)
41+
SQL;
42+
43+
$this->pdo->exec($schema);
44+
}
45+
46+
/**
47+
* @throws MemcachedException
48+
*/
49+
public function collectAndRespond(): string {
50+
$stats = $this->memcached->getServerStats();
51+
52+
if (empty($stats)) {
53+
throw new MemcachedException('Failed to retrieve Memcached stats.');
54+
}
55+
56+
$metrics = $this->calculateMetrics($stats);
57+
$this->insertMetrics($metrics);
58+
$recent_data = $this->fetchRecentMetrics();
59+
$formatted_data = $this->formatDataForResponse($recent_data);
60+
61+
header('Content-Type: application/json');
62+
header('Cache-Control: no-cache, must-revalidate');
63+
64+
try {
65+
return json_encode($formatted_data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
66+
} catch (JsonException $e) {
67+
return $e->getMessage();
68+
}
69+
}
70+
71+
/**
72+
* @param array<string, mixed> $stats Raw stats from Memcached.
73+
*
74+
* @return array<string, mixed>
75+
*/
76+
private function calculateMetrics(array $stats): array {
77+
/** @var array<string, mixed>|false $last_point */
78+
$last_point = $this->pdo->query('SELECT * FROM metrics ORDER BY id DESC LIMIT 1')->fetch(PDO::FETCH_ASSOC);
79+
80+
$time_diff = ($last_point && isset($last_point['timestamp'])) ? time() - (int) $last_point['timestamp'] : 60;
81+
$time_diff = max($time_diff, 1);
82+
83+
$calculate_rate = static function (int|float $current_val, ?int $last_val) use ($time_diff): float {
84+
if ($last_val === null || $current_val < $last_val) {
85+
return 0.0;
86+
}
87+
88+
return round(($current_val - $last_val) / $time_diff, 2);
89+
};
90+
91+
$calculate_hit_rate = static function (array $stats, string $cmd): float {
92+
$hits = $stats[$cmd.'_hits'] ?? 0;
93+
$misses = $stats[$cmd.'_misses'] ?? 0;
94+
$total = $hits + $misses;
95+
96+
return $total > 0 ? round(($hits / $total) * 100, 2) : 0.0;
97+
};
98+
99+
$command_rates = [];
100+
101+
foreach (self::RATE_COMMANDS as $cmd) {
102+
$command_rates['request_rate_'.$cmd] = $calculate_rate(
103+
$stats['cmd_'.$cmd] ?? 0,
104+
$last_point['cumulative_cmd_'.$cmd] ?? null
105+
);
106+
}
107+
108+
$command_rates['request_rate_overall'] = array_sum($command_rates);
109+
110+
$hit_rates = [];
111+
$total_hits = $total_misses = 0;
112+
113+
foreach (self::HIT_RATE_COMMANDS as $cmd) {
114+
$hit_rates['hit_rate_'.$cmd] = $calculate_hit_rate($stats, $cmd);
115+
$total_hits += $stats[$cmd.'_hits'] ?? 0;
116+
$total_misses += $stats[$cmd.'_misses'] ?? 0;
117+
}
118+
119+
$hit_rates['hit_rate_overall'] = ($total_hits + $total_misses > 0) ? round(($total_hits / ($total_hits + $total_misses)) * 100, 2) : 0.0;
120+
121+
$metrics_to_insert = array_merge($hit_rates, $command_rates, [
122+
'timestamp' => time(),
123+
'memory_used' => $stats['bytes'] ?? 0,
124+
'memory_limit' => $stats['limit_maxbytes'] ?? 0,
125+
'stored_items' => $stats['curr_items'] ?? 0,
126+
'connections' => $stats['curr_connections'] ?? 0,
127+
'new_connection_rate' => $calculate_rate($stats['total_connections'] ?? 0, $last_point['cumulative_total_connections'] ?? null),
128+
'eviction_rate' => $calculate_rate($stats['evictions'] ?? 0, $last_point['cumulative_evictions'] ?? null),
129+
'expired_rate' => $calculate_rate($stats['expired_unfetched'] ?? 0, $last_point['cumulative_expired_unfetched'] ?? null),
130+
'traffic_received_rate' => $calculate_rate($stats['bytes_read'] ?? 0, $last_point['cumulative_bytes_read'] ?? null),
131+
'traffic_sent_rate' => $calculate_rate($stats['bytes_written'] ?? 0, $last_point['cumulative_bytes_written'] ?? null),
132+
'cumulative_total_connections' => $stats['total_connections'] ?? 0,
133+
'cumulative_evictions' => $stats['evictions'] ?? 0,
134+
'cumulative_expired_unfetched' => $stats['expired_unfetched'] ?? 0,
135+
'cumulative_bytes_read' => $stats['bytes_read'] ?? 0,
136+
'cumulative_bytes_written' => $stats['bytes_written'] ?? 0,
137+
]);
138+
139+
foreach (self::RATE_COMMANDS as $cmd) {
140+
$metrics_to_insert['cumulative_cmd_'.$cmd] = $stats['cmd_'.$cmd] ?? 0;
141+
}
142+
143+
return $metrics_to_insert;
144+
}
145+
146+
/**
147+
* @param array<string, mixed> $metrics
148+
*/
149+
private function insertMetrics(array $metrics): void {
150+
$columns = implode(', ', array_keys($metrics));
151+
$placeholders = rtrim(str_repeat('?, ', count($metrics)), ', ');
152+
$sql = "INSERT INTO metrics ($columns) VALUES ($placeholders)";
153+
154+
$stmt = $this->pdo->prepare($sql);
155+
$stmt->execute(array_values($metrics));
156+
}
157+
158+
/**
159+
* @return array<int, array<string, mixed>>
160+
*/
161+
private function fetchRecentMetrics(): array {
162+
$max_data_points_to_return = Http::get('points', Config::get('metricstab', 1440));
163+
164+
$stmt = $this->pdo->prepare('SELECT * FROM metrics ORDER BY id DESC LIMIT :limit');
165+
$stmt->bindValue(':limit', $max_data_points_to_return, PDO::PARAM_INT);
166+
$stmt->execute();
167+
$results = $stmt->fetchAll(PDO::FETCH_ASSOC);
168+
169+
return array_reverse($results);
170+
}
171+
172+
/**
173+
* @param array<int, array<string, mixed>> $db_rows
174+
*
175+
* @return array<int, array<string, mixed>>
176+
*/
177+
private function formatDataForResponse(array $db_rows): array {
178+
$formatted_results = [];
179+
180+
foreach ($db_rows as $row) {
181+
$formatted_results[] = [
182+
'timestamp' => date('Y-m-d H:i:s', (int) $row['timestamp']),
183+
'unix_timestamp' => (int) $row['timestamp'],
184+
'hit_rates' => [
185+
'overall' => $row['hit_rate_overall'], 'get' => $row['hit_rate_get'], 'delete' => $row['hit_rate_delete'],
186+
'incr' => $row['hit_rate_incr'], 'decr' => $row['hit_rate_decr'], 'cas' => $row['hit_rate_cas'], 'touch' => $row['hit_rate_touch'],
187+
],
188+
'request_rates' => [
189+
'overall' => $row['request_rate_overall'], 'get' => $row['request_rate_get'], 'set' => $row['request_rate_set'],
190+
'delete' => $row['request_rate_delete'], 'incr' => $row['request_rate_incr'], 'decr' => $row['request_rate_decr'],
191+
'cas' => $row['request_rate_cas'], 'touch' => $row['request_rate_touch'], 'flush' => $row['request_rate_flush'],
192+
],
193+
'traffic' => [
194+
'received_rate' => $row['traffic_received_rate'],
195+
'sent_rate' => $row['traffic_sent_rate'],
196+
],
197+
'memory_used' => $row['memory_used'],
198+
'memory_limit' => $row['memory_limit'],
199+
'stored_items' => $row['stored_items'],
200+
'eviction_rate' => $row['eviction_rate'],
201+
'expired_rate' => $row['expired_rate'],
202+
'connections' => $row['connections'],
203+
'new_connection_rate' => $row['new_connection_rate'],
204+
];
205+
}
206+
207+
return $formatted_results;
208+
}
209+
}

src/Dashboards/Memcached/MemcachedTrait.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -407,7 +407,7 @@ private function commandsStats(): string {
407407
$commands = ['error' => $e->getMessage()];
408408
}
409409

410-
return $this->template->render('dashboards/memcached', ['commands' => $commands]);
410+
return $this->template->render('dashboards/memcached/memcached', ['commands' => $commands]);
411411
}
412412

413413
/**
@@ -438,7 +438,7 @@ private function slabs(): string {
438438
return Helpers::formatFields($fields, $slab);
439439
}, $slabs_stats['slabs']);
440440

441-
return $this->template->render('dashboards/memcached', [
441+
return $this->template->render('dashboards/memcached/memcached', [
442442
'slabs' => $slabs,
443443
'meta' => $slabs_stats['meta'],
444444
]);
@@ -486,7 +486,7 @@ private function items(): string {
486486
return Helpers::formatFields($fields, $item);
487487
}, $stats);
488488

489-
return $this->template->render('dashboards/memcached', ['items' => $items]);
489+
return $this->template->render('dashboards/memcached/memcached', ['items' => $items]);
490490
}
491491

492492
/**
@@ -524,7 +524,7 @@ private function mainDashboard(): string {
524524

525525
$paginator = new Paginator($this->template, $keys);
526526

527-
return $this->template->render('dashboards/memcached', [
527+
return $this->template->render('dashboards/memcached/memcached', [
528528
'keys' => $paginator->getPaginated(),
529529
'all_keys' => $this->memcached->getServerStats()['curr_items'],
530530
'paginator' => $paginator->render(),

0 commit comments

Comments
 (0)