mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-29 01:08:56 +00:00
541 lines
18 KiB
PHP
541 lines
18 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use Illuminate\Support\Facades\Redis;
|
|
|
|
/**
|
|
* Service for monitoring Redis server status and performance metrics.
|
|
*
|
|
* Provides a modern visual dashboard displaying Redis statistics including
|
|
* memory usage, client connections, performance metrics, and keyspace data.
|
|
*/
|
|
class RedisMonitorService
|
|
{
|
|
// ANSI color codes
|
|
private const RED = "\033[0;31m";
|
|
|
|
private const GREEN = "\033[0;32m";
|
|
|
|
private const YELLOW = "\033[1;33m";
|
|
|
|
private const BLUE = "\033[0;34m";
|
|
|
|
private const MAGENTA = "\033[0;35m";
|
|
|
|
private const CYAN = "\033[0;36m";
|
|
|
|
private const WHITE = "\033[1;37m";
|
|
|
|
private const GRAY = "\033[0;90m";
|
|
|
|
private const BOLD = "\033[1m";
|
|
|
|
private const DIM = "\033[2m";
|
|
|
|
private const NC = "\033[0m"; // No Color
|
|
|
|
// Box drawing characters
|
|
private const H_LINE = '─';
|
|
|
|
private const V_LINE = '│';
|
|
|
|
private const TL_CORNER = '┌';
|
|
|
|
private const TR_CORNER = '┐';
|
|
|
|
private const BL_CORNER = '└';
|
|
|
|
private const BR_CORNER = '┘';
|
|
|
|
private const T_RIGHT = '├';
|
|
|
|
private const T_LEFT = '┤';
|
|
|
|
private const CLEAR_EOL = "\033[K";
|
|
|
|
protected string $host;
|
|
|
|
protected int $port;
|
|
|
|
protected int $refreshInterval;
|
|
|
|
protected ?string $connection;
|
|
|
|
protected int $termWidth;
|
|
|
|
protected int $boxWidth;
|
|
|
|
protected bool $running = true;
|
|
|
|
/**
|
|
* Create a new RedisMonitorService instance.
|
|
*
|
|
* @param string|null $connection Redis connection name (from config/database.php)
|
|
* @param int $refreshInterval Refresh interval in seconds
|
|
*/
|
|
public function __construct(?string $connection = 'default', int $refreshInterval = 30)
|
|
{
|
|
$this->connection = $connection;
|
|
$this->refreshInterval = $refreshInterval;
|
|
|
|
// Get connection config
|
|
$config = config("database.redis.{$connection}", config('database.redis.default'));
|
|
$this->host = $config['host'] ?? '127.0.0.1';
|
|
$this->port = (int) ($config['port'] ?? 6379);
|
|
|
|
// Get terminal width
|
|
$this->termWidth = $this->getTerminalWidth();
|
|
$this->boxWidth = min($this->termWidth - 2, 100); // Cap at 100 for readability
|
|
}
|
|
|
|
/**
|
|
* Get terminal width.
|
|
*/
|
|
protected function getTerminalWidth(): int
|
|
{
|
|
$width = 80;
|
|
if (function_exists('exec')) {
|
|
$output = [];
|
|
@exec('tput cols 2>/dev/null', $output);
|
|
if (! empty($output[0]) && is_numeric($output[0])) {
|
|
$width = (int) $output[0];
|
|
}
|
|
}
|
|
|
|
return max($width, 60);
|
|
}
|
|
|
|
/**
|
|
* Get Redis INFO data.
|
|
*/
|
|
public function getRedisInfo(): ?array
|
|
{
|
|
try {
|
|
return Redis::connection($this->connection)->info();
|
|
} catch (\Exception $e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start the monitor loop.
|
|
*
|
|
* @param callable|null $shouldContinue Optional callback to control loop continuation
|
|
*/
|
|
public function monitor(?callable $shouldContinue = null): void
|
|
{
|
|
// Disable output buffering for immediate display
|
|
while (ob_get_level()) {
|
|
ob_end_flush();
|
|
}
|
|
|
|
// Hide cursor
|
|
$this->hideCursor();
|
|
|
|
// Setup cleanup handler
|
|
$cleanup = function () {
|
|
$this->showCursor();
|
|
echo self::NC; // Reset colors
|
|
};
|
|
|
|
// Register shutdown handler
|
|
register_shutdown_function($cleanup);
|
|
|
|
// Handle signals if pcntl is available
|
|
if (function_exists('pcntl_async_signals')) {
|
|
pcntl_async_signals(true);
|
|
}
|
|
|
|
if (function_exists('pcntl_signal')) {
|
|
pcntl_signal(SIGINT, function () use ($cleanup) {
|
|
$this->running = false;
|
|
$cleanup();
|
|
exit(0);
|
|
});
|
|
pcntl_signal(SIGTERM, function () use ($cleanup) {
|
|
$this->running = false;
|
|
$cleanup();
|
|
exit(0);
|
|
});
|
|
}
|
|
|
|
// Initial clear
|
|
$this->clearScreen();
|
|
|
|
while ($this->running) {
|
|
// Check if we should continue
|
|
if ($shouldContinue !== null && ! $shouldContinue()) {
|
|
break;
|
|
}
|
|
|
|
// Process signals if available
|
|
if (function_exists('pcntl_signal_dispatch')) {
|
|
pcntl_signal_dispatch();
|
|
}
|
|
|
|
// Move cursor to top-left (prevents flicker, better than clearing)
|
|
$this->moveToTop();
|
|
|
|
// Get Redis info
|
|
$info = $this->getRedisInfo();
|
|
|
|
if ($info === null) {
|
|
$this->displayConnectionError();
|
|
} else {
|
|
$this->displayDashboard($info);
|
|
}
|
|
|
|
// Flush output immediately
|
|
if (function_exists('flush')) {
|
|
flush();
|
|
}
|
|
|
|
// Sleep in small increments to be responsive to signals
|
|
$sleepTime = $this->refreshInterval;
|
|
while ($sleepTime > 0 && $this->running) {
|
|
sleep(1);
|
|
$sleepTime--;
|
|
|
|
// Process signals during sleep
|
|
if (function_exists('pcntl_signal_dispatch')) {
|
|
pcntl_signal_dispatch();
|
|
}
|
|
}
|
|
}
|
|
|
|
$cleanup();
|
|
}
|
|
|
|
/**
|
|
* Get a single snapshot of Redis stats without entering monitor mode.
|
|
*/
|
|
public function getStats(): array
|
|
{
|
|
$info = $this->getRedisInfo();
|
|
|
|
if ($info === null) {
|
|
return [
|
|
'connected' => false,
|
|
'host' => $this->host,
|
|
'port' => $this->port,
|
|
];
|
|
}
|
|
|
|
return $this->parseRedisInfo($info);
|
|
}
|
|
|
|
/**
|
|
* Parse Redis INFO into structured data.
|
|
*/
|
|
protected function parseRedisInfo(array $info): array
|
|
{
|
|
// Flatten nested arrays if present
|
|
$flatInfo = [];
|
|
foreach ($info as $key => $value) {
|
|
if (is_array($value)) {
|
|
$flatInfo = array_merge($flatInfo, $value);
|
|
} else {
|
|
$flatInfo[$key] = $value;
|
|
}
|
|
}
|
|
|
|
// Calculate hit rate
|
|
$hits = (int) ($flatInfo['keyspace_hits'] ?? 0);
|
|
$misses = (int) ($flatInfo['keyspace_misses'] ?? 0);
|
|
$totalOps = $hits + $misses;
|
|
$hitRate = $totalOps > 0 ? round(($hits * 100) / $totalOps, 2) : 0;
|
|
|
|
// Parse db keys from all databases
|
|
$dbKeys = 0;
|
|
for ($i = 0; $i <= 15; $i++) {
|
|
$dbKey = "db{$i}";
|
|
if (isset($flatInfo[$dbKey])) {
|
|
$dbValue = $flatInfo[$dbKey];
|
|
if (is_array($dbValue)) {
|
|
// phpredis returns array with 'keys' key
|
|
$dbKeys += (int) ($dbValue['keys'] ?? 0);
|
|
} elseif (is_string($dbValue)) {
|
|
// String format: "keys=123,expires=0,avg_ttl=0"
|
|
if (preg_match('/keys=(\d+)/', $dbValue, $matches)) {
|
|
$dbKeys += (int) $matches[1];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return [
|
|
'connected' => true,
|
|
'host' => $this->host,
|
|
'port' => $this->port,
|
|
'version' => $flatInfo['redis_version'] ?? 'unknown',
|
|
'uptime_days' => (int) ($flatInfo['uptime_in_days'] ?? 0),
|
|
'uptime_seconds' => (int) ($flatInfo['uptime_in_seconds'] ?? 0),
|
|
'role' => $flatInfo['role'] ?? 'standalone',
|
|
'connected_clients' => (int) ($flatInfo['connected_clients'] ?? 0),
|
|
'blocked_clients' => (int) ($flatInfo['blocked_clients'] ?? 0),
|
|
'total_connections' => (int) ($flatInfo['total_connections_received'] ?? 0),
|
|
'connected_slaves' => (int) ($flatInfo['connected_slaves'] ?? 0),
|
|
'used_memory' => (int) ($flatInfo['used_memory'] ?? 0),
|
|
'used_memory_peak' => (int) ($flatInfo['used_memory_peak'] ?? 0),
|
|
'used_memory_rss' => (int) ($flatInfo['used_memory_rss'] ?? 0),
|
|
'maxmemory' => (int) ($flatInfo['maxmemory'] ?? 0),
|
|
'mem_fragmentation_ratio' => $flatInfo['mem_fragmentation_ratio'] ?? 'N/A',
|
|
'total_commands' => (int) ($flatInfo['total_commands_processed'] ?? 0),
|
|
'ops_per_sec' => (int) ($flatInfo['instantaneous_ops_per_sec'] ?? 0),
|
|
'input_kbps' => (float) ($flatInfo['instantaneous_input_kbps'] ?? 0),
|
|
'output_kbps' => (float) ($flatInfo['instantaneous_output_kbps'] ?? 0),
|
|
'db_keys' => $dbKeys,
|
|
'keyspace_hits' => $hits,
|
|
'keyspace_misses' => $misses,
|
|
'hit_rate' => $hitRate,
|
|
'expired_keys' => (int) ($flatInfo['expired_keys'] ?? 0),
|
|
'evicted_keys' => (int) ($flatInfo['evicted_keys'] ?? 0),
|
|
'rdb_changes' => (int) ($flatInfo['rdb_changes_since_last_save'] ?? 0),
|
|
'rdb_last_save' => (int) ($flatInfo['rdb_last_save_time'] ?? 0),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Display the Redis monitor dashboard.
|
|
*/
|
|
protected function displayDashboard(array $info): void
|
|
{
|
|
$stats = $this->parseRedisInfo($info);
|
|
$currentTime = now()->format('Y-m-d H:i:s');
|
|
|
|
// Build the entire output as a single string for atomic write
|
|
$buffer = '';
|
|
|
|
// Header
|
|
$buffer .= $this->printHeader("🔴 REDIS MONITOR v{$stats['version']} | {$this->host}:{$this->port}");
|
|
|
|
// Server Status
|
|
$buffer .= $this->printSection('Server Status');
|
|
$buffer .= $this->printRow('Status', '● ONLINE', self::GREEN);
|
|
$buffer .= $this->printRow('Role', $stats['role'], self::MAGENTA);
|
|
$buffer .= $this->printRow('Uptime', "{$stats['uptime_days']} days (".$this->formatNumber($stats['uptime_seconds']).' seconds)', self::WHITE);
|
|
$buffer .= $this->printRow('Last Updated', $currentTime, self::GRAY);
|
|
|
|
// Memory Usage
|
|
$buffer .= $this->printSection('Memory Usage');
|
|
$buffer .= $this->printRow('Used Memory', $this->formatBytes($stats['used_memory']), self::CYAN);
|
|
$buffer .= $this->printRow('Peak Memory', $this->formatBytes($stats['used_memory_peak']), self::YELLOW);
|
|
$buffer .= $this->printRow('RSS Memory', $this->formatBytes($stats['used_memory_rss']), self::WHITE);
|
|
$buffer .= $this->printRow('Fragmentation Ratio', (string) $stats['mem_fragmentation_ratio'], self::WHITE);
|
|
|
|
if ($stats['maxmemory'] > 0) {
|
|
$buffer .= $this->printMeter('Memory Usage', $stats['used_memory'], $stats['maxmemory']);
|
|
}
|
|
|
|
// Clients & Connections
|
|
$buffer .= $this->printSection('Clients & Connections');
|
|
$buffer .= $this->printRow('Connected Clients', $this->formatNumber($stats['connected_clients']), self::GREEN);
|
|
$buffer .= $this->printRow('Blocked Clients', $this->formatNumber($stats['blocked_clients']), self::YELLOW);
|
|
$buffer .= $this->printRow('Total Connections', $this->formatNumber($stats['total_connections']), self::WHITE);
|
|
if ($stats['connected_slaves'] > 0) {
|
|
$buffer .= $this->printRow('Connected Slaves', (string) $stats['connected_slaves'], self::MAGENTA);
|
|
}
|
|
|
|
// Performance Metrics
|
|
$buffer .= $this->printSection('Performance Metrics');
|
|
$buffer .= $this->printRow('Commands Processed', $this->formatNumber($stats['total_commands']), self::WHITE);
|
|
$buffer .= $this->printRow('Operations/sec', $this->formatNumber($stats['ops_per_sec']).' ops/s', self::GREEN);
|
|
$buffer .= $this->printRow('Network Input', sprintf('%.2f KB/s', $stats['input_kbps']), self::CYAN);
|
|
$buffer .= $this->printRow('Network Output', sprintf('%.2f KB/s', $stats['output_kbps']), self::CYAN);
|
|
|
|
// Keyspace Statistics
|
|
$buffer .= $this->printSection('Keyspace Statistics');
|
|
$buffer .= $this->printRow('Total Keys', $this->formatNumber($stats['db_keys']), self::WHITE);
|
|
$buffer .= $this->printRow('Keyspace Hits', $this->formatNumber($stats['keyspace_hits']), self::GREEN);
|
|
$buffer .= $this->printRow('Keyspace Misses', $this->formatNumber($stats['keyspace_misses']), self::RED);
|
|
$buffer .= $this->printRow('Hit Rate', $stats['hit_rate'].'%', self::CYAN);
|
|
$buffer .= $this->printRow('Expired Keys', $this->formatNumber($stats['expired_keys']), self::YELLOW);
|
|
$buffer .= $this->printRow('Evicted Keys', $this->formatNumber($stats['evicted_keys']), self::RED);
|
|
|
|
// Persistence
|
|
$buffer .= $this->printSection('Persistence');
|
|
$buffer .= $this->printRow('Changes Since Save', $this->formatNumber($stats['rdb_changes']), self::WHITE);
|
|
if ($stats['rdb_last_save'] > 0) {
|
|
$lastSave = date('Y-m-d H:i:s', $stats['rdb_last_save']);
|
|
$buffer .= $this->printRow('Last Save', $lastSave, self::GRAY);
|
|
}
|
|
|
|
$buffer .= $this->printFooter();
|
|
$buffer .= self::DIM." Press Ctrl+C to exit | Refresh: {$this->refreshInterval}s".self::NC.self::CLEAR_EOL."\n";
|
|
|
|
// Clear any remaining content below
|
|
$buffer .= "\033[J";
|
|
|
|
// Output the entire buffer at once
|
|
echo $buffer;
|
|
}
|
|
|
|
/**
|
|
* Display connection error screen.
|
|
*/
|
|
protected function displayConnectionError(): void
|
|
{
|
|
$buffer = '';
|
|
$buffer .= $this->printHeader('⚠ REDIS MONITOR - CONNECTION FAILED');
|
|
$buffer .= $this->printRow('Status', '● OFFLINE - Cannot connect to Redis', self::RED);
|
|
$buffer .= $this->printRow('Host', "{$this->host}:{$this->port}", self::YELLOW);
|
|
$buffer .= $this->printRow('Retrying in', "{$this->refreshInterval} seconds...", self::GRAY);
|
|
$buffer .= $this->printFooter();
|
|
$buffer .= self::DIM." Press Ctrl+C to exit | Refresh: {$this->refreshInterval}s".self::NC.self::CLEAR_EOL."\n";
|
|
$buffer .= "\033[J";
|
|
|
|
echo $buffer;
|
|
}
|
|
|
|
/**
|
|
* Print dashboard header.
|
|
*/
|
|
protected function printHeader(string $title): string
|
|
{
|
|
// Strip emoji for length calculation (emoji can be multi-byte)
|
|
$titleLen = mb_strlen(preg_replace('/[\x{1F300}-\x{1F9FF}]/u', ' ', $title));
|
|
$padding = max(0, (int) (($this->boxWidth - $titleLen - 2) / 2));
|
|
$line = str_repeat(self::H_LINE, $this->boxWidth);
|
|
|
|
$buffer = self::CYAN.self::TL_CORNER.$line.self::TR_CORNER.self::NC.self::CLEAR_EOL."\n";
|
|
$buffer .= self::CYAN.self::V_LINE.self::NC;
|
|
$buffer .= str_repeat(' ', $padding);
|
|
$buffer .= self::BOLD.self::WHITE.$title.self::NC;
|
|
$buffer .= str_repeat(' ', max(0, $this->boxWidth - $padding - $titleLen));
|
|
$buffer .= self::CYAN.self::V_LINE.self::NC.self::CLEAR_EOL."\n";
|
|
$buffer .= self::CYAN.self::T_RIGHT.$line.self::T_LEFT.self::NC.self::CLEAR_EOL."\n";
|
|
|
|
return $buffer;
|
|
}
|
|
|
|
/**
|
|
* Print section header.
|
|
*/
|
|
protected function printSection(string $title): string
|
|
{
|
|
$remainingWidth = $this->boxWidth - mb_strlen($title) - 5;
|
|
$line = str_repeat(self::H_LINE, max(0, $remainingWidth));
|
|
|
|
return self::CYAN.self::T_RIGHT.self::H_LINE.self::H_LINE.self::NC.' '
|
|
.self::YELLOW.self::BOLD.$title.self::NC.' '
|
|
.self::CYAN.$line.self::T_LEFT.self::NC.self::CLEAR_EOL."\n";
|
|
}
|
|
|
|
/**
|
|
* Print a data row.
|
|
*/
|
|
protected function printRow(string $label, string $value, string $color = self::WHITE): string
|
|
{
|
|
$labelWidth = 25;
|
|
$valueWidth = $this->boxWidth - $labelWidth - 3;
|
|
|
|
$label = str_pad($label, $labelWidth);
|
|
$value = str_pad(mb_substr($value, 0, $valueWidth), $valueWidth);
|
|
|
|
return self::CYAN.self::V_LINE.self::NC.' '
|
|
.self::DIM.$label.self::NC.' '
|
|
.$color.$value.self::NC
|
|
.self::CYAN.self::V_LINE.self::NC.self::CLEAR_EOL."\n";
|
|
}
|
|
|
|
/**
|
|
* Print a progress meter.
|
|
*/
|
|
protected function printMeter(string $label, int $current, int $max): string
|
|
{
|
|
$labelWidth = 25;
|
|
$meterWidth = min(30, $this->boxWidth - $labelWidth - 10);
|
|
|
|
$percentage = $max > 0 ? (int) (($current * 100) / $max) : 0;
|
|
$filled = (int) (($percentage * $meterWidth) / 100);
|
|
$empty = $meterWidth - $filled;
|
|
|
|
// Choose color based on percentage
|
|
$color = self::GREEN;
|
|
if ($percentage > 80) {
|
|
$color = self::RED;
|
|
} elseif ($percentage > 60) {
|
|
$color = self::YELLOW;
|
|
}
|
|
|
|
$meter = $color.str_repeat('█', $filled).self::GRAY.str_repeat('░', $empty).self::NC;
|
|
|
|
$label = str_pad($label, $labelWidth);
|
|
$percentStr = sprintf('%3d%%', $percentage);
|
|
$remainingSpace = $this->boxWidth - $labelWidth - $meterWidth - strlen($percentStr) - 4;
|
|
|
|
return self::CYAN.self::V_LINE.self::NC.' '
|
|
.self::DIM.$label.self::NC.' '
|
|
.$meter.' '.self::WHITE.$percentStr.self::NC
|
|
.str_repeat(' ', max(0, $remainingSpace))
|
|
.self::CYAN.self::V_LINE.self::NC.self::CLEAR_EOL."\n";
|
|
}
|
|
|
|
/**
|
|
* Print dashboard footer.
|
|
*/
|
|
protected function printFooter(): string
|
|
{
|
|
$line = str_repeat(self::H_LINE, $this->boxWidth);
|
|
|
|
return self::CYAN.self::BL_CORNER.$line.self::BR_CORNER.self::NC.self::CLEAR_EOL."\n";
|
|
}
|
|
|
|
/**
|
|
* Format bytes into human-readable string.
|
|
*/
|
|
protected function formatBytes(int $bytes): string
|
|
{
|
|
if ($bytes >= 1073741824) {
|
|
return sprintf('%.2f GB', $bytes / 1073741824);
|
|
} elseif ($bytes >= 1048576) {
|
|
return sprintf('%.2f MB', $bytes / 1048576);
|
|
} elseif ($bytes >= 1024) {
|
|
return sprintf('%.2f KB', $bytes / 1024);
|
|
}
|
|
|
|
return $bytes.' B';
|
|
}
|
|
|
|
/**
|
|
* Format number with thousands separators.
|
|
*/
|
|
protected function formatNumber(int $number): string
|
|
{
|
|
return number_format($number);
|
|
}
|
|
|
|
/**
|
|
* Hide terminal cursor.
|
|
*/
|
|
protected function hideCursor(): void
|
|
{
|
|
echo "\033[?25l";
|
|
}
|
|
|
|
/**
|
|
* Show terminal cursor.
|
|
*/
|
|
protected function showCursor(): void
|
|
{
|
|
echo "\033[?25h";
|
|
}
|
|
|
|
/**
|
|
* Move cursor to top-left.
|
|
*/
|
|
protected function moveToTop(): void
|
|
{
|
|
echo "\033[H";
|
|
}
|
|
|
|
/**
|
|
* Clear the screen.
|
|
*/
|
|
protected function clearScreen(): void
|
|
{
|
|
echo "\033[2J\033[H";
|
|
}
|
|
}
|