Skip to content

Commit f18c138

Browse files
committed
Experimental Redis Cluster support
1 parent 756530d commit f18c138

File tree

12 files changed

+309
-39
lines changed

12 files changed

+309
-39
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ Or you can even use JSON (e.g. Redis SSL option).
2828
Redis:
2929

3030
- `PCA_REDIS_0_NAME` The server name (optional).
31-
- `PCA_REDIS_0_HOST` Optional when a path is specified.
31+
- `PCA_REDIS_0_HOST` Optional when a path or nodes is specified.
32+
- `PCA_REDIS_0_NODES` List of cluster nodes. You can set value as JSON `["127.0.0.1:7000","127.0.0.1:7001","127.0.0.1:7002"]`.
3233
- `PCA_REDIS_0_PORT` Optional when the default port is used.
3334
- `PCA_REDIS_0_SCHEME` Connection scheme (optional). If you need a TLS connection, set it to `tls`.
3435
- `PCA_REDIS_0_SSL` [SSL options](https://www.php.net/manual/en/context.ssl.php) for TLS. Requires Redis >= 6.0 (optional). You can set value as JSON `{"cafile":"private.pem","verify_peer":true}`.

config.dist.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,13 @@
2828
'redis' => [
2929
[
3030
'name' => 'Localhost', // The server name (optional).
31-
'host' => '127.0.0.1', // Optional when a path is specified.
31+
'host' => '127.0.0.1', // Optional when a path or nodes is specified.
32+
/*'nodes' => [
33+
// List of cluster nodes.
34+
'127.0.0.1:7000',
35+
'127.0.0.1:7001',
36+
'127.0.0.1:7002',
37+
],*/
3238
'port' => 6379, // Optional when the default port is used.
3339
//'scheme' => 'tls', // Connection scheme (optional).
3440
/*'ssl' => [

phpstan.neon

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,5 @@ parameters:
44
paths:
55
- src
66
- tests
7+
excludePaths:
8+
- 'src/Dashboards/Redis/Compatibility/Cluster/RedisCluster.php' # phpstan doesn't have updated stubs for RedisCluster

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.1.3';
14+
public const VERSION = '2.2.0';
1515

1616
/**
1717
* @var array<string, DashboardInterface>
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
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\Compatibility\Cluster;
10+
11+
use Redis;
12+
use RedisClusterException;
13+
use RobiNN\Pca\Dashboards\DashboardException;
14+
use RobiNN\Pca\Dashboards\Redis\Compatibility\RedisCompatibilityInterface;
15+
use RobiNN\Pca\Dashboards\Redis\Compatibility\RedisJson;
16+
use RobiNN\Pca\Dashboards\Redis\Compatibility\RedisModules;
17+
18+
class RedisCluster extends \RedisCluster implements RedisCompatibilityInterface {
19+
use RedisJson;
20+
use RedisModules;
21+
22+
/**
23+
* @var array<int|string, string>
24+
*/
25+
public array $data_types = [
26+
Redis::REDIS_NOT_FOUND => 'other',
27+
Redis::REDIS_STRING => 'string',
28+
Redis::REDIS_SET => 'set',
29+
Redis::REDIS_LIST => 'list',
30+
Redis::REDIS_ZSET => 'zset',
31+
Redis::REDIS_HASH => 'hash',
32+
Redis::REDIS_STREAM => 'stream',
33+
'ReJSON-RL' => 'rejson',
34+
];
35+
36+
/**
37+
* @param array<string, mixed> $server
38+
*
39+
* @throws DashboardException
40+
*/
41+
public function __construct(array $server) {
42+
$auth = null;
43+
44+
if (isset($server['password'])) {
45+
$auth = isset($server['username']) ? [$server['username'], $server['password']] : $server['password'];
46+
}
47+
48+
try {
49+
parent::__construct($server['name'] ?? 'default', $server['nodes'], 3, 0, false, $auth);
50+
} catch (RedisClusterException $e) {
51+
throw new DashboardException($e->getMessage().' ['.implode(',', $server['nodes']).']');
52+
}
53+
}
54+
55+
public function getType(string|int $type): string {
56+
return $this->data_types[$type] ?? 'unknown';
57+
}
58+
59+
60+
public function getKeyType(string $key): string {
61+
$type = $this->type($key);
62+
63+
if ($type === Redis::REDIS_NOT_FOUND) {
64+
$this->setOption(Redis::OPT_REPLY_LITERAL, true);
65+
$type = $this->rawCommand($key, 'TYPE', $key);
66+
}
67+
68+
return $this->getType($type);
69+
}
70+
71+
/**
72+
* @return array<int|string, mixed>
73+
*/
74+
public function getInfo(?string $option = null): array {
75+
static $info = [];
76+
77+
$options = ['SERVER', 'CLIENTS', 'MEMORY', 'PERSISTENCE', 'STATS', 'REPLICATION', 'CPU', 'CLUSTER', 'KEYSPACE'];
78+
$nodes = $this->_masters();
79+
80+
foreach ($options as $option_name) {
81+
$combined = [];
82+
83+
foreach ($nodes as $node) {
84+
$node_info = $this->info($node, $option_name);
85+
86+
foreach ($node_info as $key => $value) {
87+
$combined[$key][] = $value;
88+
}
89+
}
90+
91+
foreach ($combined as $key => $values) {
92+
$unique = array_unique($values);
93+
$combined[$key] = count($unique) === 1 ? $unique[0] : $unique;
94+
}
95+
96+
$info[strtolower($option_name)] = $combined;
97+
}
98+
99+
return $option !== null ? ($info[$option] ?? []) : $info;
100+
}
101+
102+
/**
103+
* @return array<int, string>
104+
*/
105+
public function scanKeys(string $pattern, int $count): array {
106+
$keys = [];
107+
$nodes = $this->_masters();
108+
109+
foreach ($nodes as $node) {
110+
$iterator = null;
111+
112+
while (false !== ($scan = $this->scan($iterator, $node, $pattern, $count))) {
113+
foreach ($scan as $key) {
114+
$keys[] = $key;
115+
116+
if (count($keys) >= $count) {
117+
return $keys;
118+
}
119+
}
120+
}
121+
}
122+
123+
return $keys;
124+
}
125+
126+
public function listRem(string $key, string $value, int $count): int {
127+
return $this->lRem($key, $value, $count);
128+
}
129+
130+
/**
131+
* @param array<string, string> $messages
132+
*/
133+
public function streamAdd(string $key, string $id, array $messages): string {
134+
return $this->xadd($key, $id, $messages);
135+
}
136+
137+
/**
138+
* @param array<int, string> $keys
139+
*
140+
* @return array<string, mixed>
141+
*
142+
* @throws RedisClusterException
143+
*/
144+
public function pipelineKeys(array $keys): array {
145+
$data = [];
146+
147+
foreach ($keys as $key) {
148+
$ttl = $this->ttl($key);
149+
$type = $this->type($key);
150+
$size = $this->rawCommand($key, 'MEMORY', 'USAGE', $key);
151+
$scard = $this->rawCommand($key, 'SCARD', $key);
152+
$llen = $this->rawCommand($key, 'LLEN', $key);
153+
$zcard = $this->rawCommand($key, 'ZCARD', $key);
154+
$hlen = $this->rawCommand($key, 'HLEN', $key);
155+
$xlen = $this->rawCommand($key, 'XLEN', $key);
156+
157+
$results = [$ttl, $type, $size, $scard, $llen, $zcard, $hlen, $xlen];
158+
159+
$type = $this->getType($results[1]);
160+
161+
$count = match ($type) {
162+
'set' => $results[3] ?? null,
163+
'list' => $results[4] ?? null,
164+
'zset' => $results[5] ?? null,
165+
'hash' => $results[6] ?? null,
166+
'stream' => $results[7] ?? null,
167+
default => null,
168+
};
169+
170+
$data[$key] = [
171+
'ttl' => $results[0],
172+
'type' => $type,
173+
'size' => $results[2] ?? 0,
174+
'count' => is_numeric($count) ? (int) $count : null,
175+
];
176+
}
177+
178+
return $data;
179+
}
180+
181+
public function size(string $key): int {
182+
$size = $this->rawCommand($key, 'MEMORY', 'USAGE', $key);
183+
184+
return is_int($size) ? $size : 0;
185+
}
186+
187+
public function flushAllClusterDBs(): bool {
188+
$nodes = $this->_masters();
189+
190+
foreach ($nodes as $node) {
191+
$this->flushDB($node);
192+
}
193+
194+
return true;
195+
}
196+
197+
public function clusterDbSize(): int {
198+
$nodes = $this->_masters();
199+
$total = 0;
200+
201+
foreach ($nodes as $node) {
202+
$total += $this->dbSize($node);
203+
}
204+
205+
return $total;
206+
}
207+
}

src/Dashboards/Redis/Compatibility/RedisModules.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ public function getModules(): array {
2323
return [];
2424
}
2525

26-
if (count($list) === 0) {
26+
if (!is_array($list) || count($list) === 0) {
2727
return [];
2828
}
2929

src/Dashboards/Redis/RedisDashboard.php

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,12 @@ class RedisDashboard implements DashboardInterface {
2727

2828
private int $current_server;
2929

30-
public Compatibility\Redis|Compatibility\Predis $redis;
30+
public Compatibility\Redis|Compatibility\Predis|Compatibility\Cluster\RedisCluster $redis;
3131

3232
public string $client = '';
3333

34+
public bool $is_cluster = false;
35+
3436
public function __construct(private readonly Template $template, ?string $client = null) {
3537
$this->client = $client ?? (extension_loaded('redis') ? 'redis' : 'predis');
3638
$this->servers = Config::get('redis', []);
@@ -70,20 +72,26 @@ public function dashboardInfo(): array {
7072
/**
7173
* Connect to the server.
7274
*
73-
* @param array<string, int|string> $server
75+
* @param array<string, mixed> $server
7476
*
7577
* @throws DashboardException
7678
*/
77-
public function connect(array $server): Compatibility\Redis|Compatibility\Predis {
79+
public function connect(array $server): Compatibility\Redis|Compatibility\Predis|Compatibility\Cluster\RedisCluster {
7880
$server['database'] = Http::get('db', $server['database'] ?? 0);
7981

80-
if (isset($server['authfile'])) {
82+
if (!empty($server['authfile'])) {
8183
$server['password'] = trim(file_get_contents($server['authfile']));
8284
}
8385

86+
$this->is_cluster = !empty($server['nodes']) && is_array($server['nodes']);
87+
8488
if ($this->client === 'redis') {
85-
$redis = new Compatibility\Redis($server);
89+
$redis = $this->is_cluster ? new Compatibility\Cluster\RedisCluster($server) : new Compatibility\Redis($server);
8690
} elseif ($this->client === 'predis') {
91+
if ($this->is_cluster) {
92+
throw new DashboardException('There is currently no support for clusters with Predis.');
93+
}
94+
8795
$redis = new Compatibility\Predis($server);
8896
} else {
8997
throw new DashboardException('Redis extension or Predis is not installed.');

src/Dashboards/Redis/RedisTrait.php

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,18 @@ private function panels(): string {
3434

3535
$count_of_all_keys = 0;
3636

37-
foreach ($info['keyspace'] as $value) {
38-
[$keys] = explode(',', $value);
39-
[, $key_count] = explode('=', $keys);
40-
$count_of_all_keys += (int) $key_count;
37+
if (isset($info['keyspace'])) {
38+
foreach ($info['keyspace'] as $entries) {
39+
if (!is_array($entries)) {
40+
$entries = [$entries];
41+
}
42+
43+
foreach ($entries as $entry) {
44+
[$keysPart] = explode(',', $entry);
45+
[, $keyCount] = explode('=', $keysPart);
46+
$count_of_all_keys += (int) $keyCount;
47+
}
48+
}
4149
}
4250

4351
$used_memory = (int) $info['memory']['used_memory'];
@@ -58,6 +66,10 @@ private function panels(): string {
5866
$redis_mode = isset($info['server']['redis_mode']) ? ', '.$info['server']['redis_mode'].' mode' : '';
5967
$maxclients = isset($info['clients']['maxclients']) ? ' / '.Format::number((int) $info['clients']['maxclients']) : '';
6068

69+
if (!$this->is_cluster) {
70+
$role = ['Role', $info['replication']['role'].', connected slaves '.$info['replication']['connected_slaves']];
71+
}
72+
6173
$panels = [
6274
[
6375
'title' => $title ?? null,
@@ -66,7 +78,7 @@ private function panels(): string {
6678
'Version' => $info['server']['redis_version'].$redis_mode,
6779
'Cluster' => $info['cluster']['cluster_enabled'] ? 'Enabled' : 'Disabled',
6880
'Uptime' => Format::seconds((int) $info['server']['uptime_in_seconds']),
69-
'Role' => $info['replication']['role'].', connected slaves '.$info['replication']['connected_slaves'],
81+
$role ?? null,
7082
'Keys' => Format::number($count_of_all_keys).' (all databases)',
7183
['Hits / Misses', Format::number($hits).' / '.Format::number($misses).' ('.$hit_rate.'%)', $hit_rate, 'higher'],
7284
],
@@ -103,7 +115,7 @@ private function panels(): string {
103115
* @throws Exception
104116
*/
105117
private function deleteAllKeys(): string {
106-
if ($this->redis->flushDB()) {
118+
if ($this->is_cluster ? $this->redis->flushAllClusterDBs() : $this->redis->flushAll()) {
107119
return Helpers::alert($this->template, 'All keys from the current database have been removed.', 'success');
108120
}
109121

@@ -469,6 +481,10 @@ private function getDatabases(): array {
469481
}
470482

471483
private function dbSelect(): string {
484+
if ($this->is_cluster) {
485+
return '';
486+
}
487+
472488
try {
473489
$databases = $this->template->render('components/select', [
474490
'id' => 'db_select',
@@ -483,6 +499,11 @@ private function dbSelect(): string {
483499
}
484500

485501
private function slowlog(): string {
502+
if ($this->is_cluster) {
503+
return $this->template->render('components/tabs', ['links' => ['keys' => 'Keys', 'slowlog' => 'Slow Log',],]).
504+
'Unsupported in cluster.';
505+
}
506+
486507
if (isset($_GET['resetlog'])) {
487508
$this->redis->rawCommand('SLOWLOG', 'RESET');
488509
Http::redirect(['tab']);
@@ -538,7 +559,7 @@ function (string $key, string $value, int $ttl): bool {
538559

539560
return $this->template->render('dashboards/redis/redis', [
540561
'keys' => $paginator->getPaginated(),
541-
'all_keys' => $this->redis->dbSize(),
562+
'all_keys' => $this->is_cluster ? $this->redis->clusterDbSize() : $this->redis->dbSize(),
542563
'paginator' => $paginator->render(),
543564
'view_key' => Http::queryString(['s'], ['view' => 'key', 'key' => '__key__']),
544565
]);

templates/layout.twig

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@
3939
<div class="flex items-center justify-center gap-4">
4040
{% for link, item in nav %}
4141
{% set link_bg = item.colors ? ' style="--link-bg:' ~ item.colors.500 ~ ';--link-bg-hover:' ~ item.colors.700 ~ ';--link-active:' ~ item.colors.300 ~ ';--link-active-dark:' ~ item.colors.900 ~ ';"' : '' %}
42-
<a class="sblink{{ current == link ? ' active' : '' }} flex size-9 items-center justify-center rounded-sm text-white" {{ link_bg|raw }} href="?dashboard={{ link }}" title="{{ item.title }}">
42+
{% set server = get('server') and get('dashboard') == link ? '&server=' ~ get('server') : '' %}
43+
<a class="sblink{{ current == link ? ' active' : '' }} flex size-9 items-center justify-center rounded-sm text-white" {{ link_bg|raw }} href="?dashboard={{ link ~ server }}" title="{{ item.title }}">
4344
{{ svg(item.icon ?? ('dashboards/' ~ item.key), 16) }}
4445
</a>
4546
{% endfor %}

0 commit comments

Comments
 (0)