diff --git a/app/Console/Commands/RedisMonitor.php b/app/Console/Commands/RedisMonitor.php new file mode 100644 index 000000000..cf1023493 --- /dev/null +++ b/app/Console/Commands/RedisMonitor.php @@ -0,0 +1,146 @@ +option('connection'); + $refresh = (int) $this->option('refresh'); + $once = $this->option('once'); + + try { + $service = new RedisMonitorService($connection, $refresh); + + if ($once) { + $stats = $service->getStats(); + + if (!$stats['connected']) { + $this->error("Cannot connect to Redis at {$stats['host']}:{$stats['port']}"); + return Command::FAILURE; + } + + $this->displayStats($stats); + return Command::SUCCESS; + } + + // Start the continuous monitor + $service->monitor(); + + return Command::SUCCESS; + + } catch (\Exception $e) { + $this->error('Redis Monitor failed: ' . $e->getMessage()); + return Command::FAILURE; + } + } + + /** + * Display stats in table format (for --once mode). + */ + protected function displayStats(array $stats): void + { + $this->newLine(); + $this->line("🔴 Redis Monitor - {$stats['host']}:{$stats['port']} (v{$stats['version']})"); + $this->newLine(); + + $this->line('📊 Server Status'); + $this->table( + ['Metric', 'Value'], + [ + ['Status', '● ONLINE'], + ['Role', $stats['role']], + ['Uptime', "{$stats['uptime_days']} days"], + ] + ); + + $this->newLine(); + $this->line('💾 Memory Usage'); + $this->table( + ['Metric', 'Value'], + [ + ['Used Memory', $this->formatBytes($stats['used_memory'])], + ['Peak Memory', $this->formatBytes($stats['used_memory_peak'])], + ['RSS Memory', $this->formatBytes($stats['used_memory_rss'])], + ['Fragmentation Ratio', $stats['mem_fragmentation_ratio']], + ] + ); + + $this->newLine(); + $this->line('👥 Clients & Connections'); + $this->table( + ['Metric', 'Value'], + [ + ['Connected Clients', number_format($stats['connected_clients'])], + ['Blocked Clients', number_format($stats['blocked_clients'])], + ['Total Connections', number_format($stats['total_connections'])], + ] + ); + + $this->newLine(); + $this->line('⚡ Performance Metrics'); + $this->table( + ['Metric', 'Value'], + [ + ['Commands Processed', number_format($stats['total_commands'])], + ['Operations/sec', number_format($stats['ops_per_sec']) . ' ops/s'], + ['Network Input', $stats['input_kbps'] . ' KB/s'], + ['Network Output', $stats['output_kbps'] . ' KB/s'], + ] + ); + + $this->newLine(); + $this->line('🔑 Keyspace Statistics'); + $this->table( + ['Metric', 'Value'], + [ + ['Total Keys (db0)', number_format($stats['db_keys'])], + ['Keyspace Hits', number_format($stats['keyspace_hits'])], + ['Keyspace Misses', number_format($stats['keyspace_misses'])], + ['Hit Rate', $stats['hit_rate'] . '%'], + ['Expired Keys', number_format($stats['expired_keys'])], + ['Evicted Keys', number_format($stats['evicted_keys'])], + ] + ); + } + + /** + * 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"; + } +} + diff --git a/app/Services/RedisMonitorService.php b/app/Services/RedisMonitorService.php new file mode 100644 index 000000000..4f214b841 --- /dev/null +++ b/app/Services/RedisMonitorService.php @@ -0,0 +1,524 @@ +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. + * + * @return array|null + */ + 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. + * + * @return array + */ + 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. + * + * @param array $info + * @return array + */ + 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. + * + * @param array $info + */ + 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"; + } +} + diff --git a/app/Services/Tmux/TmuxLayoutBuilder.php b/app/Services/Tmux/TmuxLayoutBuilder.php index 8fca797b2..52eff1a53 100644 --- a/app/Services/Tmux/TmuxLayoutBuilder.php +++ b/app/Services/Tmux/TmuxLayoutBuilder.php @@ -233,25 +233,34 @@ class TmuxLayoutBuilder } // redis monitoring - if ((int) Settings::settingValue('redis') === 1 && $this->commandExists('redis-cli')) { - $redisHost = config('database.redis.default.host', '127.0.0.1'); - $redisPort = config('database.redis.default.port', 6379); + if ((int) Settings::settingValue('redis') === 1) { $redisArgs = Settings::settingValue('redis_args') ?? ''; - $refreshInterval = 2; + $refreshInterval = 30; $this->paneManager->createWindow($windowIndex, 'redis'); // Check if custom args provided for simple redis-cli output - if (! empty($redisArgs) && $redisArgs !== 'NULL') { + if (! empty($redisArgs) && $redisArgs !== 'NULL' && $this->commandExists('redis-cli')) { + $redisHost = config('database.redis.default.host', '127.0.0.1'); + $redisPort = config('database.redis.default.port', 6379); $this->paneManager->respawnPane("{$windowIndex}.0", "watch -n{$refreshInterval} -c 'redis-cli -h {$redisHost} -p {$redisPort} {$redisArgs}'"); } else { - // Use modern visual monitoring script - $monitorScript = base_path('misc/redis-monitor.sh'); - if (file_exists($monitorScript)) { - $this->paneManager->respawnPane("{$windowIndex}.0", "bash {$monitorScript} {$redisHost} {$redisPort} {$refreshInterval}"); + // Use PHP-based Redis monitor service + $redisHost = config('database.redis.default.host', '127.0.0.1'); + $redisPort = config('database.redis.default.port', 6379); + $artisan = base_path('artisan'); + + // Determine how to connect to Redis + $connectionInfo = $this->resolveRedisConnection($redisHost, (int) $redisPort); + + if ($connectionInfo['use_sail']) { + // Use sail to run inside Docker container + $sail = base_path('sail'); + $this->paneManager->respawnPane("{$windowIndex}.0", "{$sail} artisan redis:monitor --refresh={$refreshInterval}"); } else { - // Fallback to basic redis-cli info with color - $this->paneManager->respawnPane("{$windowIndex}.0", "watch -n{$refreshInterval} -c 'redis-cli -h {$redisHost} -p {$redisPort} info'"); + // Run directly, potentially with host override + $envPrefix = $connectionInfo['override_host'] ? "REDIS_HOST={$connectionInfo['host']} " : ''; + $this->paneManager->respawnPane("{$windowIndex}.0", "{$envPrefix}php {$artisan} redis:monitor --refresh={$refreshInterval}"); } } $windowIndex++; @@ -274,4 +283,39 @@ class TmuxLayoutBuilder return $result->successful() && str_contains($result->output(), $command); } + + /** + * Resolve how to connect to Redis from the host + * + * Returns an array with: + * - 'use_sail' => bool - whether to use sail to run inside Docker + * - 'override_host' => bool - whether to override REDIS_HOST env var + * - 'host' => string - the host to use (only relevant if override_host is true) + */ + protected function resolveRedisConnection(string $host, int $port): array + { + // If host is already an IP or localhost, use it directly + if (filter_var($host, FILTER_VALIDATE_IP) || $host === 'localhost') { + return ['use_sail' => false, 'override_host' => false, 'host' => $host]; + } + + // Try to resolve the hostname + $resolved = gethostbyname($host); + if ($resolved !== $host) { + // Hostname resolves - use it directly + return ['use_sail' => false, 'override_host' => false, 'host' => $host]; + } + + // Hostname doesn't resolve (Docker internal hostname) + // Check if Redis is accessible on 127.0.0.1 (Docker port forwarding) + $socket = @fsockopen('127.0.0.1', $port, $errno, $errstr, 1); + if ($socket !== false) { + fclose($socket); + // Redis accessible on localhost - override host to 127.0.0.1 + return ['use_sail' => false, 'override_host' => true, 'host' => '127.0.0.1']; + } + + // Redis not accessible on localhost - need to use sail + return ['use_sail' => true, 'override_host' => false, 'host' => $host]; + } } diff --git a/misc/redis-monitor.sh b/misc/redis-monitor.sh deleted file mode 100644 index 8512cea1a..000000000 --- a/misc/redis-monitor.sh +++ /dev/null @@ -1,266 +0,0 @@ -#!/bin/bash -# -# Redis Monitor - Modern visual monitoring for Redis server -# Usage: redis-monitor.sh [host] [port] [refresh_interval] [password] -# redis-monitor.sh 127.0.0.1 6379 30 mypassword -# -# Or set REDIS_PASSWORD environment variable: -# export REDIS_PASSWORD=mypassword -# redis-monitor.sh -# - -# Configuration -REDIS_HOST="${1:-127.0.0.1}" -REDIS_PORT="${2:-6379}" -REFRESH="${3:-30}" -REDIS_PASSWORD="${4:-${REDIS_PASSWORD}}" # Accept as arg or env var - -# Colors -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -MAGENTA='\033[0;35m' -CYAN='\033[0;36m' -WHITE='\033[1;37m' -GRAY='\033[0;90m' -BOLD='\033[1m' -DIM='\033[2m' -NC='\033[0m' # No Color - -# Box drawing characters -H_LINE="─" -V_LINE="│" -TL_CORNER="┌" -TR_CORNER="┐" -BL_CORNER="└" -BR_CORNER="┘" -T_DOWN="┬" -T_UP="┴" -T_RIGHT="├" -T_LEFT="┤" -CROSS="┼" - -# Get terminal width -TERM_WIDTH=$(tput cols 2>/dev/null || echo 80) -BOX_WIDTH=$((TERM_WIDTH - 2)) - -# Clear to end of line escape sequence -CLEAR_EOL='\033[K' - -# Functions -print_header() { - local title="$1" - local padding=$(( (BOX_WIDTH - ${#title} - 2) / 2 )) - echo -e "${CYAN}${TL_CORNER}$(printf '%*s' "$BOX_WIDTH" | tr ' ' "$H_LINE")${TR_CORNER}${NC}${CLEAR_EOL}" - echo -e "${CYAN}${V_LINE}${NC}$(printf '%*s' "$padding")${BOLD}${WHITE}$title${NC}$(printf '%*s' "$((BOX_WIDTH - padding - ${#title}))")${CYAN}${V_LINE}${NC}${CLEAR_EOL}" - echo -e "${CYAN}${T_RIGHT}$(printf '%*s' "$BOX_WIDTH" | tr ' ' "$H_LINE")${T_LEFT}${NC}${CLEAR_EOL}" -} - -print_section() { - local title="$1" - echo -e "${CYAN}${T_RIGHT}${H_LINE}${H_LINE}${NC} ${YELLOW}${BOLD}$title${NC} ${CYAN}$(printf '%*s' "$((BOX_WIDTH - ${#title} - 5))" | tr ' ' "$H_LINE")${T_LEFT}${NC}${CLEAR_EOL}" -} - -print_row() { - local label="$1" - local value="$2" - local color="${3:-$WHITE}" - local label_width=25 - local value_width=$((BOX_WIDTH - label_width - 3)) - printf "${CYAN}${V_LINE}${NC} ${DIM}%-${label_width}s${NC} ${color}%-${value_width}s${NC}${CYAN}${V_LINE}${NC}${CLEAR_EOL}\n" "$label" "$value" -} - -print_meter() { - local label="$1" - local current="$2" - local max="$3" - local label_width=25 - local meter_width=30 - local percentage=0 - - if [ "$max" -gt 0 ] 2>/dev/null; then - percentage=$((current * 100 / max)) - fi - - local filled=$((percentage * meter_width / 100)) - local empty=$((meter_width - filled)) - - local color=$GREEN - if [ "$percentage" -gt 80 ]; then - color=$RED - elif [ "$percentage" -gt 60 ]; then - color=$YELLOW - fi - - local meter="${color}" - for ((i=0; i$/,&/;ta' -} - -get_redis_info() { - if [ -n "$REDIS_PASSWORD" ]; then - redis-cli -h "$REDIS_HOST" -p "$REDIS_PORT" -a "$REDIS_PASSWORD" --no-auth-warning info 2>/dev/null - else - redis-cli -h "$REDIS_HOST" -p "$REDIS_PORT" info 2>/dev/null - fi -} - -# Terminal control for flicker-free updates -hide_cursor() { printf '\033[?25l'; } -show_cursor() { printf '\033[?25h'; } -move_to_top() { printf '\033[H'; } -clear_screen() { printf '\033[2J\033[H'; } - -# Cleanup on exit -cleanup() { - show_cursor - printf '\033[?1049l' # Exit alternate screen buffer - exit 0 -} -trap cleanup EXIT INT TERM - -# Enter alternate screen buffer and hide cursor -printf '\033[?1049h' -hide_cursor -clear_screen - -# Main loop -while true; do - # Move cursor to top-left instead of clearing (prevents flicker) - move_to_top - - # Get Redis info - INFO=$(get_redis_info) - - if [ -z "$INFO" ]; then - clear_screen - print_header "⚠ REDIS MONITOR - CONNECTION FAILED" - print_row "Status" "Cannot connect to Redis" "$RED" - print_row "Host" "$REDIS_HOST:$REDIS_PORT" "$YELLOW" - print_footer - echo -e "${DIM}Press Ctrl+C to exit | Refresh: ${REFRESH}s | Retrying...${NC}" - sleep "$REFRESH" - continue - fi - - # Parse info - REDIS_VERSION=$(echo "$INFO" | grep "^redis_version:" | cut -d: -f2 | tr -d '\r') - UPTIME_DAYS=$(echo "$INFO" | grep "^uptime_in_days:" | cut -d: -f2 | tr -d '\r') - UPTIME_SECONDS=$(echo "$INFO" | grep "^uptime_in_seconds:" | cut -d: -f2 | tr -d '\r') - CONNECTED_CLIENTS=$(echo "$INFO" | grep "^connected_clients:" | cut -d: -f2 | tr -d '\r') - BLOCKED_CLIENTS=$(echo "$INFO" | grep "^blocked_clients:" | cut -d: -f2 | tr -d '\r') - USED_MEMORY=$(echo "$INFO" | grep "^used_memory:" | cut -d: -f2 | tr -d '\r') - USED_MEMORY_PEAK=$(echo "$INFO" | grep "^used_memory_peak:" | cut -d: -f2 | tr -d '\r') - USED_MEMORY_RSS=$(echo "$INFO" | grep "^used_memory_rss:" | cut -d: -f2 | tr -d '\r') - MAXMEMORY=$(echo "$INFO" | grep "^maxmemory:" | cut -d: -f2 | tr -d '\r') - TOTAL_CONNECTIONS=$(echo "$INFO" | grep "^total_connections_received:" | cut -d: -f2 | tr -d '\r') - TOTAL_COMMANDS=$(echo "$INFO" | grep "^total_commands_processed:" | cut -d: -f2 | tr -d '\r') - OPS_PER_SEC=$(echo "$INFO" | grep "^instantaneous_ops_per_sec:" | cut -d: -f2 | tr -d '\r') - INPUT_KBPS=$(echo "$INFO" | grep "^instantaneous_input_kbps:" | cut -d: -f2 | tr -d '\r') - OUTPUT_KBPS=$(echo "$INFO" | grep "^instantaneous_output_kbps:" | cut -d: -f2 | tr -d '\r') - KEYSPACE_HITS=$(echo "$INFO" | grep "^keyspace_hits:" | cut -d: -f2 | tr -d '\r') - KEYSPACE_MISSES=$(echo "$INFO" | grep "^keyspace_misses:" | cut -d: -f2 | tr -d '\r') - EXPIRED_KEYS=$(echo "$INFO" | grep "^expired_keys:" | cut -d: -f2 | tr -d '\r') - EVICTED_KEYS=$(echo "$INFO" | grep "^evicted_keys:" | cut -d: -f2 | tr -d '\r') - DB_KEYS=$(echo "$INFO" | grep "^db0:" | sed 's/.*keys=\([0-9]*\).*/\1/' | tr -d '\r') - ROLE=$(echo "$INFO" | grep "^role:" | cut -d: -f2 | tr -d '\r') - CONNECTED_SLAVES=$(echo "$INFO" | grep "^connected_slaves:" | cut -d: -f2 | tr -d '\r') - MEM_FRAG_RATIO=$(echo "$INFO" | grep "^mem_fragmentation_ratio:" | cut -d: -f2 | tr -d '\r') - RDB_CHANGES=$(echo "$INFO" | grep "^rdb_changes_since_last_save:" | cut -d: -f2 | tr -d '\r') - RDB_LAST_SAVE=$(echo "$INFO" | grep "^rdb_last_save_time:" | cut -d: -f2 | tr -d '\r') - - # Calculate hit rate - HIT_RATE=0 - if [ -n "$KEYSPACE_HITS" ] && [ -n "$KEYSPACE_MISSES" ]; then - TOTAL_OPS=$((KEYSPACE_HITS + KEYSPACE_MISSES)) - if [ "$TOTAL_OPS" -gt 0 ]; then - HIT_RATE=$((KEYSPACE_HITS * 100 / TOTAL_OPS)) - fi - fi - - # Current time - CURRENT_TIME=$(date "+%Y-%m-%d %H:%M:%S") - - # Print dashboard - print_header "🔴 REDIS MONITOR v${REDIS_VERSION:-unknown} | ${REDIS_HOST}:${REDIS_PORT}" - - print_section "Server Status" - print_row "Status" "● ONLINE" "$GREEN" - print_row "Role" "${ROLE:-standalone}" "$MAGENTA" - print_row "Uptime" "${UPTIME_DAYS:-0} days ($(format_number ${UPTIME_SECONDS:-0}) seconds)" "$WHITE" - print_row "Last Updated" "$CURRENT_TIME" "$GRAY" - - print_section "Memory Usage" - USED_MEM_FMT=$(format_bytes ${USED_MEMORY:-0}) - PEAK_MEM_FMT=$(format_bytes ${USED_MEMORY_PEAK:-0}) - RSS_MEM_FMT=$(format_bytes ${USED_MEMORY_RSS:-0}) - print_row "Used Memory" "$USED_MEM_FMT" "$CYAN" - print_row "Peak Memory" "$PEAK_MEM_FMT" "$YELLOW" - print_row "RSS Memory" "$RSS_MEM_FMT" "$WHITE" - print_row "Fragmentation Ratio" "${MEM_FRAG_RATIO:-N/A}" "$WHITE" - - if [ -n "$MAXMEMORY" ] && [ "$MAXMEMORY" -gt 0 ]; then - print_meter "Memory Usage" "${USED_MEMORY:-0}" "$MAXMEMORY" - fi - - print_section "Clients & Connections" - print_row "Connected Clients" "$(format_number ${CONNECTED_CLIENTS:-0})" "$GREEN" - print_row "Blocked Clients" "$(format_number ${BLOCKED_CLIENTS:-0})" "$YELLOW" - print_row "Total Connections" "$(format_number ${TOTAL_CONNECTIONS:-0})" "$WHITE" - [ -n "$CONNECTED_SLAVES" ] && print_row "Connected Slaves" "$CONNECTED_SLAVES" "$MAGENTA" - - print_section "Performance Metrics" - print_row "Commands Processed" "$(format_number ${TOTAL_COMMANDS:-0})" "$WHITE" - print_row "Operations/sec" "$(format_number ${OPS_PER_SEC:-0}) ops/s" "$GREEN" - print_row "Network Input" "${INPUT_KBPS:-0} KB/s" "$CYAN" - print_row "Network Output" "${OUTPUT_KBPS:-0} KB/s" "$CYAN" - - print_section "Keyspace Statistics" - print_row "Total Keys (db0)" "$(format_number ${DB_KEYS:-0})" "$WHITE" - print_row "Keyspace Hits" "$(format_number ${KEYSPACE_HITS:-0})" "$GREEN" - print_row "Keyspace Misses" "$(format_number ${KEYSPACE_MISSES:-0})" "$RED" - print_row "Hit Rate" "${HIT_RATE}%" "$CYAN" - print_row "Expired Keys" "$(format_number ${EXPIRED_KEYS:-0})" "$YELLOW" - print_row "Evicted Keys" "$(format_number ${EVICTED_KEYS:-0})" "$RED" - - print_section "Persistence" - print_row "Changes Since Save" "$(format_number ${RDB_CHANGES:-0})" "$WHITE" - if [ -n "$RDB_LAST_SAVE" ] && [ "$RDB_LAST_SAVE" -gt 0 ]; then - LAST_SAVE_FMT=$(date -d "@$RDB_LAST_SAVE" "+%Y-%m-%d %H:%M:%S" 2>/dev/null || echo "N/A") - print_row "Last Save" "$LAST_SAVE_FMT" "$GRAY" - fi - - print_footer - echo -e "${DIM}Press Ctrl+C to exit | Refresh: ${REFRESH}s${NC}${CLEAR_EOL}" - # Clear any remaining lines below (in case previous frame had more content) - printf '\033[J' - - sleep "$REFRESH" -done -