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 */ 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"; } }