[status, message, details]] */ private array $results = []; public function handle(): int { $only = $this->option('only') ? strtolower((string) $this->option('only')) : null; $checks = [ 'mysql' => fn () => $this->checkMysql(), 'redis' => fn () => $this->checkRedis(), 'cache' => fn () => $this->checkCache(), 'meilisearch' => fn () => $this->checkMeilisearch(), 'qdrant' => fn () => $this->checkQdrant(), 'reverb' => fn () => $this->checkReverb(), 'vision' => fn () => $this->checkVision(), 'horizon' => fn () => $this->checkHorizon(), 'webserver' => fn () => $this->checkWebserver(), 'phpfpm' => fn () => $this->checkPhpFpm(), 'paths' => fn () => $this->checkWritablePaths(), 'ram' => fn () => $this->checkRam(), 'disk' => fn () => $this->checkDisk(), 'load' => fn () => $this->checkLoad(), 's3' => fn () => $this->checkS3(), 'failed_jobs' => fn () => $this->checkFailedJobs(), 'queue_backlog' => fn () => $this->checkQueueBacklog(), 'ssl' => fn () => $this->checkSsl(), 'scheduler' => fn () => $this->checkScheduler(), 'log_errors' => fn () => $this->checkLogErrors(), 'app' => fn () => $this->checkApp(), ]; if ($only !== null) { if (! array_key_exists($only, $checks)) { $this->error("Unknown check '{$only}'. Available: " . implode(', ', array_keys($checks))); return self::FAILURE; } $checks = [$only => $checks[$only]]; } foreach ($checks as $name => $check) { $check(); } if ($this->option('json')) { $this->line(json_encode($this->results, JSON_PRETTY_PRINT)); return $this->hasFailures() ? self::FAILURE : self::SUCCESS; } $this->renderTable(); $failed = $this->countByStatus('fail'); $warned = $this->countByStatus('warn'); $this->newLine(); if ($failed > 0) { $this->error("❌ {$failed} check(s) FAILED" . ($warned > 0 ? ", {$warned} warning(s)" : '') . '.'); return self::FAILURE; } if ($warned > 0) { $this->warn("⚠️ All checks passed with {$warned} warning(s)."); return self::SUCCESS; } $this->info('✅ All checks passed.'); return self::SUCCESS; } // ── Individual checks ────────────────────────────────────────────────────── private function checkMysql(): void { try { DB::select('SELECT 1'); $db = config('database.connections.' . config('database.default') . '.database'); $artworkCount = DB::table('artworks')->whereNull('deleted_at')->count(); $this->pass('mysql', "Connected to `{$db}`. Artworks in DB: {$artworkCount}.", ['artwork_count' => $artworkCount]); } catch (Throwable $e) { $this->failCheck('mysql', 'Connection failed: ' . $e->getMessage()); } } private function checkRedis(): void { try { $pong = Redis::ping(); // ping returns "+PONG\r\n" string or true depending on driver $ok = $pong === true || str_contains((string) $pong, 'PONG'); if ($ok) { $info = Redis::info('server'); $version = $info['redis_version'] ?? ($info['Server']['redis_version'] ?? 'unknown'); $this->pass('redis', "Reachable. Redis version: {$version}.", ['version' => $version]); } else { $this->failCheck('redis', 'Unexpected ping response: ' . var_export($pong, true)); } } catch (Throwable $e) { $this->failCheck('redis', 'Connection failed: ' . $e->getMessage()); } } private function checkCache(): void { try { $key = '_healthcheck_' . uniqid('', true); $value = 'ok_' . time(); Cache::put($key, $value, 10); $got = Cache::get($key); Cache::forget($key); $driver = config('cache.default'); if ($got === $value) { $this->pass('cache', "Driver `{$driver}` read/write OK.", ['driver' => $driver]); } else { $this->failCheck('cache', "Driver `{$driver}`: wrote '{$value}' but read back " . var_export($got, true)); } } catch (Throwable $e) { $this->failCheck('cache', 'Cache test failed: ' . $e->getMessage()); } } private function checkMeilisearch(): void { try { /** @var MeilisearchClient $client */ $client = app(MeilisearchClient::class); $health = $client->health(); if (($health['status'] ?? '') !== 'available') { $this->failCheck('meilisearch', 'Meilisearch reports unhealthy status: ' . json_encode($health)); return; } $version = $client->version()['pkgVersion'] ?? 'unknown'; // ── List ALL indexes ────────────────────────────────────────────── $allIndexes = $client->getIndexes(); $indexObjects = $allIndexes->getResults(); $indexSummaries = []; // DB counts for coverage checks $dbPublicArtworks = DB::table('artworks') ->whereNull('deleted_at') ->where('is_public', 1) ->where('is_approved', 1) ->count(); $artworkIndexName = (new Artwork())->searchableAs(); $artworkIndexOk = false; $warnings = []; foreach ($indexObjects as $idx) { $idxName = $idx->getUid(); $stats = $idx->stats(); $docCount = (int) ($stats['numberOfDocuments'] ?? 0); $indexing = (bool) ($stats['isIndexing'] ?? false); $suffix = $indexing ? ' [indexing]' : ''; // Coverage check for artworks index if ($idxName === $artworkIndexName) { if ($docCount === 0) { $warnings[] = "❌ `{$idxName}`: EMPTY (DB has {$dbPublicArtworks} public+approved). Run: php artisan scout:import \"App\\\\Models\\\\Artwork\""; } elseif ($dbPublicArtworks > 0 && $docCount < (int) ($dbPublicArtworks * 0.5)) { $warnings[] = "⚠️ `{$idxName}`: {$docCount} docs < 50% of DB {$dbPublicArtworks}. Run: php artisan artworks:search-rebuild"; } else { $artworkIndexOk = true; } } $indexSummaries[] = "`{$idxName}`: {$docCount} docs{$suffix}"; } // Check for pending tasks try { $pending = $client->getTasks(['statuses' => 'enqueued,processing'])->getTotal(); if ($pending > 0) { $warnings[] = "{$pending} task(s) still pending in Meilisearch queue"; } } catch (Throwable) { // non-fatal } $indexList = implode(' | ', $indexSummaries) ?: 'none'; $message = "v{$version}. Indexes: {$indexList}."; if (! empty($warnings)) { $this->result('meilisearch', 'warn', $message . ' — ' . implode('; ', $warnings), [ 'meili_version' => $version, 'indexes' => $indexSummaries, 'warnings' => $warnings, ]); } else { $this->pass('meilisearch', $message, [ 'meili_version' => $version, 'indexes' => $indexSummaries, 'db_public_artworks' => $dbPublicArtworks, ]); } } catch (Throwable $e) { $this->failCheck('meilisearch', 'Unreachable or error: ' . $e->getMessage()); } } private function checkQdrant(): void { $enabled = (bool) config('vision.vector_gateway.enabled', true); $baseUrl = rtrim((string) config('vision.vector_gateway.base_url', ''), '/'); $apiKey = (string) config('vision.vector_gateway.api_key', ''); $collection = (string) config('vision.vector_gateway.collection', 'images'); $endpoint = (string) config('vision.vector_gateway.collections_endpoint', '/vectors/collections'); if (! $enabled) { $this->warn_check('qdrant', 'VISION_VECTOR_GATEWAY_ENABLED=false — similarity search disabled, skipping.'); return; } if (empty($baseUrl)) { $this->warn_check('qdrant', 'VISION_VECTOR_GATEWAY_URL not configured — skipping.'); return; } try { // 1. Hit the gateway health endpoint $healthUrl = $baseUrl . '/health'; $health = $this->httpGetWithAuth($healthUrl, $apiKey, 5); if (($health['code'] ?? 0) === 0) { $this->failCheck('qdrant', "Vector gateway unreachable at {$baseUrl}/health"); return; } if ($health['code'] >= 400) { $this->failCheck('qdrant', "Vector gateway /health returned HTTP {$health['code']} at {$baseUrl}"); return; } // 2. List collections to verify the configured collection exists $collectionsUrl = $baseUrl . $endpoint; $resp = $this->httpGetWithAuth($collectionsUrl, $apiKey, 8); $code = $resp['code'] ?? 0; if ($code === 0) { $this->failCheck('qdrant', "Cannot reach {$collectionsUrl}"); return; } if ($code >= 400) { $this->failCheck('qdrant', "Collections endpoint returned HTTP {$code} at {$collectionsUrl}"); return; } $body = json_decode($resp['body'] ?? '', true); // Gateway returns { "collections": [...] } or flat array $collections = []; if (isset($body['collections']) && is_array($body['collections'])) { $collections = $body['collections']; } elseif (is_array($body)) { $collections = $body; } // Collection names may be strings or objects with a 'name' key $collectionNames = array_map( fn ($c) => is_string($c) ? $c : ($c['name'] ?? (string) json_encode($c)), $collections ); $countLabel = count($collectionNames) . ' collection(s): ' . implode(', ', $collectionNames); if (! in_array($collection, $collectionNames, true)) { $this->failCheck('qdrant', "Configured collection `{$collection}` NOT FOUND. Available: {$countLabel}. Re-run: php artisan artworks:vector-index-backfill"); return; } // 3. Try a test search to confirm similarity actually works using the // same URL-based request shape as VectorGatewayClient. $searchEndpoint = rtrim($baseUrl, '/') . (string) config('vision.vector_gateway.search_endpoint', '/vectors/search'); $probeArtwork = Artwork::query() ->select(['id', 'hash', 'thumb_ext']) ->whereNull('deleted_at') ->where('is_public', 1) ->where('is_approved', 1) ->whereNotNull('hash') ->orderByDesc('published_at') ->first(); if (! $probeArtwork) { $this->warn_check('qdrant', "Gateway reachable and collection `{$collection}` found, but there is no eligible artwork available for a similarity smoke probe.", [ 'base_url' => $baseUrl, 'collection' => $collection, 'collections' => $collectionNames, ]); return; } /** @var ArtworkVisionImageUrl $imageUrlBuilder */ $imageUrlBuilder = app(ArtworkVisionImageUrl::class); $probeImageUrl = $imageUrlBuilder->fromArtwork($probeArtwork); if ($probeImageUrl === null || $probeImageUrl === '') { $this->warn_check('qdrant', "Gateway reachable and collection `{$collection}` found, but the probe artwork #{$probeArtwork->id} has no usable image URL.", [ 'probe_artwork_id' => (int) $probeArtwork->id, 'collection' => $collection, ]); return; } $payload = json_encode(['url' => $probeImageUrl, 'limit' => 1]); $ch = curl_init($searchEndpoint); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 10, CURLOPT_CONNECTTIMEOUT => 3, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload, CURLOPT_HTTPHEADER => array_filter([ 'Content-Type: application/json', 'Accept: application/json', $apiKey ? "X-API-Key: {$apiKey}" : '', ]), ]); $searchBody = curl_exec($ch); $searchCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); $searchOk = $searchCode >= 200 && $searchCode < 300; $searchResult = json_decode((string) $searchBody, true); // Count indexed vectors $vectorCount = null; if ($searchOk && isset($searchResult['total'])) { $vectorCount = (int) $searchResult['total']; } $details = [ 'base_url' => $baseUrl, 'collection' => $collection, 'collections' => $collectionNames, 'probe_artwork_id'=> (int) $probeArtwork->id, 'probe_image_url' => $probeImageUrl, 'search_http' => $searchCode, 'vector_count' => $vectorCount, ]; if (! $searchOk) { $this->warn_check('qdrant', "Gateway reachable, collection `{$collection}` found, but search probe returned HTTP {$searchCode}. Check vector gateway logs.", $details); return; } $vcLabel = $vectorCount !== null ? ", ~{$vectorCount} vectors indexed" : ''; $this->pass('qdrant', "Gateway OK (HTTP {$health['code']}). Collection `{$collection}` present ({$countLabel}). Similarity search probe: HTTP {$searchCode}{$vcLabel}.", $details); } catch (Throwable $e) { $this->failCheck('qdrant', 'Check failed: ' . $e->getMessage()); } } private function checkReverb(): void { // REVERB_HOST / REVERB_PORT = the public-facing hostname clients connect to. // REVERB_SERVER_HOST / REVERB_SERVER_PORT = internal bind address for the Reverb process. // We probe the public-facing host — that's what actually matters for users. $host = config('broadcasting.connections.reverb.options.host') ?: env('REVERB_HOST', ''); $port = (int) (config('broadcasting.connections.reverb.options.port') ?: env('REVERB_PORT', 443)); $scheme = config('broadcasting.connections.reverb.options.scheme') ?: env('REVERB_SCHEME', 'https'); if (empty($host)) { $this->warn_check('reverb', 'REVERB_HOST not configured — skipping.'); return; } // Reverb exposes an HTTP health endpoint at /apps/{appId} // We do a plain TCP connect as the minimal check; a refused connection means down. $timeout = 5; try { $errno = 0; $errstr = ''; $proto = $scheme === 'https' ? 'ssl' : 'tcp'; $fp = @fsockopen("{$proto}://{$host}", $port, $errno, $errstr, $timeout); if ($fp === false) { $this->failCheck('reverb', "Cannot connect to {$host}:{$port} ({$scheme}) — {$errstr} [{$errno}]"); return; } fclose($fp); // TCP succeeded — Reverb is alive. Attempt an HTTP probe for extra info, // but don't fail/warn on it: Reverb is a WebSocket server and may not // respond to plain HTTP GET requests with a meaningful status code. $url = "{$scheme}://{$host}:{$port}/"; $response = $this->httpGet($url, 3); $code = $response['code'] ?? 0; $codeStr = $code > 0 ? " (HTTP {$code})" : ''; $this->pass('reverb', "Reachable at {$host}:{$port}{$codeStr}.", ['host' => $host, 'port' => $port, 'http_code' => $code]); } catch (Throwable $e) { $this->failCheck('reverb', 'Check failed: ' . $e->getMessage()); } } private function checkVision(): void { $services = [ 'CLIP / Gateway' => rtrim((string) config('vision.gateway.base_url', ''), '/') . '/health', 'Vector Gateway' => rtrim((string) config('vision.vector_gateway.base_url', ''), '/') . '/health', ]; $allPassed = true; $messages = []; foreach ($services as $label => $url) { if ($url === '/health' || $url === '') { $messages[] = "{$label}: not configured"; continue; } $response = $this->httpGet($url, 5); $code = $response['code'] ?? 0; if ($code >= 200 && $code < 300) { $messages[] = "{$label}: OK (HTTP {$code})"; } elseif ($code === 0) { $allPassed = false; $messages[] = "{$label}: UNREACHABLE ({$url})"; } else { $allPassed = false; $messages[] = "{$label}: HTTP {$code} ({$url})"; } } $summary = implode(' | ', $messages); if ($allPassed) { $this->pass('vision', $summary); } else { $this->warn_check('vision', $summary); } } private function checkWebserver(): void { // ── 1. systemctl (Linux) ───────────────────────────────────────────── if (function_exists('exec') && PHP_OS_FAMILY !== 'Windows') { foreach (['nginx', 'apache2', 'httpd'] as $svc) { $out = []; $rc = -1; @exec("systemctl is-active {$svc} 2>/dev/null", $out, $rc); if ($rc === 0) { $pid = ''; @exec("systemctl show -p MainPID {$svc} 2>/dev/null", $pidOut, $pidRc); if ($pidRc === 0 && preg_match('/MainPID=(\d+)/', implode('', $pidOut), $m)) { $pid = " (PID {$m[1]})"; } $this->pass('webserver', "Service `{$svc}` is active via systemctl{$pid}.", ['service' => $svc]); return; } } } // ── 2. pgrep fallback ──────────────────────────────────────────────── if (function_exists('exec') && PHP_OS_FAMILY !== 'Windows') { foreach (['nginx', 'apache2', 'httpd'] as $proc) { $pids = []; $rc = -1; @exec("pgrep -x {$proc} 2>/dev/null", $pids, $rc); if ($rc === 0 && count($pids) > 0) { $this->pass('webserver', "Process `{$proc}` found via pgrep (PIDs: " . implode(', ', array_slice($pids, 0, 5)) . ").", ['process' => $proc]); return; } } } // ── 3. TCP probe — identify the actual server from the Server header ── foreach ([80, 443] as $port) { $scheme = $port === 443 ? 'ssl' : 'tcp'; $fp = @fsockopen("{$scheme}://127.0.0.1", $port, $errno, $errstr, 3); if ($fp === false) { continue; } fclose($fp); $probeUrl = ($port === 443 ? 'https' : 'http') . '://127.0.0.1/'; $resp = $this->httpGet($probeUrl, 5); $rawServer = (string) ($resp['server'] ?? ''); $server = strtolower($rawServer); if ($rawServer === '') { $this->pass('webserver', "Port {$port} open (HTTP {$resp['code']}). Server header not exposed."); } elseif (str_contains($server, 'nginx')) { $this->pass('webserver', "nginx running (HTTP {$resp['code']}) — Server: {$rawServer}.", ['server' => $rawServer, 'port' => $port]); } elseif (str_contains($server, 'apache')) { $this->pass('webserver', "Apache running (HTTP {$resp['code']}) — Server: {$rawServer}.", ['server' => $rawServer, 'port' => $port]); } else { $this->pass('webserver', "Port {$port} open (HTTP {$resp['code']}) — Server: {$rawServer}.", ['server' => $rawServer, 'port' => $port]); } return; } $this->failCheck('webserver', 'No web server detected via systemctl/pgrep, and ports 80/443 are closed. Start nginx: sudo systemctl start nginx'); } private function checkPhpFpm(): void { if (PHP_OS_FAMILY === 'Windows') { $this->warn_check('phpfpm', 'PHP-FPM is not applicable on this Windows setup. Nginx/mod_fcgid or similar is expected locally.'); return; } // ── 1. systemctl ───────────────────────────────────────────────────── if (function_exists('exec') && PHP_OS_FAMILY !== 'Windows') { // Discover all php*-fpm service names via systemctl list-units $units = []; @exec("systemctl list-units --type=service --state=active --no-pager --plain 2>/dev/null | grep php", $units, $rc); foreach ($units as $line) { if (preg_match('/^(php[\d.\-]+fpm\.service)/i', trim($line), $m)) { $svcFull = $m[1]; $this->pass('phpfpm', "Service `{$svcFull}` is active.", ['service' => $svcFull]); return; } } // Fallback: check common names directly $candidates = ['php8.4-fpm', 'php8.3-fpm', 'php8.2-fpm', 'php8.1-fpm', 'php8.0-fpm', 'php-fpm']; foreach ($candidates as $svc) { $out = []; $rc = -1; @exec("systemctl is-active {$svc} 2>/dev/null", $out, $rc); if ($rc === 0) { $this->pass('phpfpm', "Service `{$svc}` is active via systemctl.", ['service' => $svc]); return; } } // pgrep fallback $pids = []; $rc = -1; @exec("pgrep -a php-fpm 2>/dev/null", $pids, $rc); if ($rc === 0 && count($pids) > 0) { $first = trim($pids[0]); $this->pass('phpfpm', "PHP-FPM master process found via pgrep: {$first}", ['processes' => array_slice($pids, 0, 3)]); return; } } // ── 2. Check common Unix socket paths ──────────────────────────────── $sockets = [ '/run/php/php8.4-fpm.sock', '/run/php/php8.3-fpm.sock', '/run/php/php8.2-fpm.sock', '/run/php/php8.1-fpm.sock', '/run/php/php-fpm.sock', '/var/run/php/php-fpm.sock', ]; foreach ($sockets as $sock) { if (file_exists($sock) && filetype($sock) === 'socket') { $this->pass('phpfpm', "PHP-FPM socket found: {$sock}", ['socket' => $sock]); return; } } $this->warn_check('phpfpm', "Could not detect PHP-FPM via systemctl, pgrep, or known sockets. Verify manually: systemctl status php*-fpm"); } private function checkWritablePaths(): void { $paths = [ 'storage/logs' => storage_path('logs'), 'storage/framework/cache' => storage_path('framework/cache'), 'storage/framework/sessions'=> storage_path('framework/sessions'), 'storage/framework/views' => storage_path('framework/views'), 'storage/app' => storage_path('app'), 'storage/app/public' => storage_path('app/public'), 'bootstrap/cache' => base_path('bootstrap/cache'), ]; // Also check configured upload local paths (only when they point to local disk, not S3) $artworksDisk = (string) config('uploads.artworks.disk', 's3'); if ($artworksDisk !== 's3') { $storageRoot = (string) config('uploads.storage_root', ''); if (! empty($storageRoot)) { $paths['uploads/storage_root'] = $storageRoot; } } $originalsRoot = (string) config('uploads.local_originals_root', ''); if (! empty($originalsRoot)) { $paths['uploads/originals'] = $originalsRoot; } $failed = []; $missing = []; $ok = []; foreach ($paths as $label => $path) { if (! file_exists($path)) { $missing[] = "{$label} ({$path}) — directory does not exist"; continue; } if (! is_writable($path)) { $failed[] = "{$label} ({$path}) — NOT writable"; continue; } // Confirm we can actually create and delete a temp file $probe = $path . '/.healthcheck_' . getmypid(); if (@file_put_contents($probe, '1') === false) { $failed[] = "{$label} ({$path}) — write test failed"; } else { @unlink($probe); $ok[] = $label; } } $summary = count($ok) . '/' . count($paths) . ' paths writable'; if (! empty($failed) || ! empty($missing)) { $issues = array_merge($failed, $missing); $this->failCheck('paths', "{$summary}. Issues: " . implode('; ', $issues), [ 'ok' => $ok, 'failed' => $failed, 'missing' => $missing, ]); } else { $this->pass('paths', "{$summary}. Checked: " . implode(', ', $ok), ['ok' => $ok]); } } private function checkHorizon(): void { try { $queueDefault = (string) config('queue.default', 'sync'); $scoutQueueConnection = (string) config('scout.queue.connection', $queueDefault); if ($queueDefault !== 'redis' && $scoutQueueConnection !== 'redis') { $this->warn_check('horizon', "Horizon skipped: queue.default={$queueDefault}, scout.queue.connection={$scoutQueueConnection}. Redis-backed Horizon is not required in this environment.", [ 'queue_default' => $queueDefault, 'scout_queue_connection' => $scoutQueueConnection, ]); return; } // Horizon stores its status in Redis under the horizon:master-supervisor key prefix. // A simpler cross-version check: look for any horizon-related Redis key. $status = Cache::store('redis')->get('horizon:status'); if ($status === null) { // Try reading directly from Redis $status = Redis::get('horizon:status'); } if ($status === null) { if (function_exists('exec') && PHP_OS_FAMILY !== 'Windows') { $processes = []; $rc = -1; @exec('pgrep -a -f "artisan horizon|php.*horizon" 2>/dev/null', $processes, $rc); if ($rc === 0 && $processes !== []) { $this->pass('horizon', 'Horizon process detected via pgrep even though no Redis status key was found.', [ 'processes' => array_slice($processes, 0, 3), ]); return; } } $this->warn_check('horizon', 'No Horizon status key in Redis and no Horizon process detected — Horizon may not be running or has never started.'); return; } $status = is_string($status) ? strtolower(trim($status)) : strtolower((string) $status); if ($status === 'running') { $this->pass('horizon', "Horizon status: running."); } elseif ($status === 'paused') { $this->warn_check('horizon', "Horizon is PAUSED. Resume with: php artisan horizon:continue"); } else { $this->failCheck('horizon', "Horizon status: {$status}. Start with: php artisan horizon"); } } catch (Throwable $e) { $this->warn_check('horizon', 'Could not read Horizon status: ' . $e->getMessage()); } } private function checkRam(): void { if (PHP_OS_FAMILY === 'Windows') { $this->warn_check('ram', 'RAM check not supported on Windows. Run on Linux server for real metrics.'); return; } try { $meminfo = @file_get_contents('/proc/meminfo'); if ($meminfo === false || $meminfo === '') { $this->warn_check('ram', '/proc/meminfo not available — cannot read RAM stats.'); return; } preg_match('/MemTotal:\s+(\d+)/', $meminfo, $total); preg_match('/MemAvailable:\s+(\d+)/', $meminfo, $available); $totalKb = (int) ($total[1] ?? 0); $availableKb = (int) ($available[1] ?? 0); if ($totalKb === 0) { $this->warn_check('ram', 'Could not parse MemTotal from /proc/meminfo.'); return; } $usedKb = $totalKb - $availableKb; $usedPct = round(($usedKb / $totalKb) * 100, 1); $totalMb = round($totalKb / 1024); $availableMb = round($availableKb / 1024); $usedMb = round($usedKb / 1024); $details = [ 'total_mb' => $totalMb, 'used_mb' => $usedMb, 'available_mb' => $availableMb, 'used_pct' => $usedPct, ]; $msg = "Total: {$totalMb} MB | Used: {$usedMb} MB ({$usedPct}%) | Available: {$availableMb} MB."; if ($usedPct >= 95) { $this->failCheck('ram', "RAM critical — {$usedPct}% used. {$msg}", $details); } elseif ($usedPct >= 85) { $this->warn_check('ram', "RAM high — {$usedPct}% used. {$msg}", $details); } else { $this->pass('ram', $msg, $details); } } catch (Throwable $e) { $this->warn_check('ram', 'RAM check failed: ' . $e->getMessage()); } } private function checkDisk(): void { $paths = [ 'app root' => base_path(), 'storage' => storage_path(), ]; $warnThreshold = 85; $failThreshold = 95; $rows = []; $worst = 'pass'; foreach ($paths as $label => $path) { if (! file_exists($path)) { continue; } $total = @disk_total_space($path); $free = @disk_free_space($path); if ($total === false || $total === 0.0 || $free === false) { $rows[] = "{$label}: unable to read"; continue; } $used = $total - $free; $usedPct = round(($used / $total) * 100, 1); $totalGb = round($total / (1024 ** 3), 1); $freeGb = round($free / (1024 ** 3), 1); $rows[] = "{$label}: {$freeGb} GB free / {$totalGb} GB total ({$usedPct}% used)"; if ($usedPct >= $failThreshold && $worst !== 'fail') { $worst = 'fail'; } elseif ($usedPct >= $warnThreshold && $worst === 'pass') { $worst = 'warn'; } } $summary = implode(' | ', $rows); $this->result('disk', $worst, $summary, ['paths' => $rows]); } private function checkLoad(): void { if (PHP_OS_FAMILY === 'Windows') { $this->warn_check('load', 'Load average check not supported on Windows. Run on Linux server for real metrics.'); return; } try { // sys_getloadavg() returns 1/5/15-min averages $load = sys_getloadavg(); if (! is_array($load) || count($load) < 3) { $this->warn_check('load', 'sys_getloadavg() returned unexpected result.'); return; } [$l1, $l5, $l15] = $load; $l1 = round((float) $l1, 2); $l5 = round((float) $l5, 2); $l15 = round((float) $l15, 2); // Determine CPU count for relative comparison $cpuCount = 1; if (function_exists('exec')) { $cpuOut = []; @exec('nproc 2>/dev/null', $cpuOut, $cpuRc); if ($cpuRc === 0 && isset($cpuOut[0]) && (int) $cpuOut[0] > 0) { $cpuCount = (int) $cpuOut[0]; } } $relLoad = round($l1 / $cpuCount * 100, 1); $details = [ 'load_1' => $l1, 'load_5' => $l5, 'load_15' => $l15, 'cpu_count' => $cpuCount, 'load_pct' => $relLoad, ]; $msg = "1 min: {$l1} | 5 min: {$l5} | 15 min: {$l15} | CPUs: {$cpuCount} ({$relLoad}% relative load)."; if ($relLoad >= 200) { $this->failCheck('load', "System overloaded — {$relLoad}% of CPU capacity. {$msg}", $details); } elseif ($relLoad >= 100) { $this->warn_check('load', "Load exceeds CPU count — {$relLoad}% of capacity. {$msg}", $details); } else { $this->pass('load', $msg, $details); } } catch (Throwable $e) { $this->warn_check('load', 'Load check failed: ' . $e->getMessage()); } } private function checkS3(): void { try { $key = config('filesystems.disks.s3.key'); $bucket = config('filesystems.disks.s3.bucket'); $endpoint = config('filesystems.disks.s3.endpoint'); if (empty($key) || empty($bucket)) { $this->warn_check('s3', 'S3/Contabo not configured — AWS_ACCESS_KEY_ID or AWS_BUCKET env vars missing.'); return; } // Force throw=true so suppressed errors surface as exceptions. $disk = Storage::build([ ...config('filesystems.disks.s3'), 'throw' => true, 'report' => false, ]); $probeKey = '_health_probe_' . time() . '.txt'; $payload = 'healthcheck:' . now()->toIso8601String(); // Write $disk->put($probeKey, $payload); // Read back $readBack = $disk->get($probeKey); // Delete (best-effort) try { $disk->delete($probeKey); } catch (Throwable) {} if ($readBack !== $payload) { $this->failCheck('s3', "S3/Contabo probe file read-back mismatch (bucket: {$bucket}, endpoint: {$endpoint}).", ['bucket' => $bucket, 'endpoint' => $endpoint]); return; } $this->pass('s3', "S3/Contabo write+read+delete OK (bucket: {$bucket}, endpoint: {$endpoint}).", ['bucket' => $bucket, 'endpoint' => $endpoint]); } catch (Throwable $e) { $this->failCheck('s3', 'S3/Contabo check failed: ' . $e->getMessage(), ['endpoint' => config('filesystems.disks.s3.endpoint', '')]); } } private function checkFailedJobs(): void { try { $count = DB::table('failed_jobs')->count(); if ($count === 0) { $this->pass('failed_jobs', 'No failed jobs.'); } else { $this->warn_check('failed_jobs', "{$count} failed job(s) in the `failed_jobs` table. Run `php artisan queue:flush` to clear after investigation.", ['count' => $count]); } } catch (Throwable $e) { $this->warn_check('failed_jobs', 'Could not query failed_jobs table: ' . $e->getMessage()); } } private function checkQueueBacklog(): void { try { $count = DB::table('jobs')->count(); $details = ['count' => $count]; $msg = "{$count} job(s) pending in the `jobs` table."; if ($count >= 5000) { $this->failCheck('queue_backlog', "Queue backlog critical — {$msg}", $details); } elseif ($count >= 500) { $this->warn_check('queue_backlog', "Queue backlog high — {$msg}", $details); } else { $this->pass('queue_backlog', $msg, $details); } } catch (Throwable $e) { $this->warn_check('queue_backlog', 'Could not query jobs table: ' . $e->getMessage()); } } private function checkSsl(): void { $appUrl = rtrim((string) config('app.url', ''), '/'); if (! str_starts_with($appUrl, 'https://')) { $this->warn_check('ssl', "APP_URL (`{$appUrl}`) is not HTTPS — SSL check skipped."); return; } $host = parse_url($appUrl, PHP_URL_HOST); if (empty($host)) { $this->warn_check('ssl', 'Could not parse host from APP_URL.'); return; } try { $context = stream_context_create(['ssl' => [ 'capture_peer_cert' => true, 'verify_peer' => true, 'verify_peer_name' => true, 'SNI_enabled' => true, ]]); $fp = @stream_socket_client( "ssl://{$host}:443", $errno, $errstr, 10, STREAM_CLIENT_CONNECT, $context ); if ($fp === false) { $this->failCheck('ssl', "SSL connection to {$host}:443 failed — {$errstr} [{$errno}]."); return; } $params = stream_context_get_params($fp); fclose($fp); $cert = $params['options']['ssl']['peer_certificate'] ?? null; if ($cert === null) { $this->warn_check('ssl', "Connected to {$host}:443 but could not capture certificate."); return; } $certInfo = openssl_x509_parse($cert); $validTo = (int) ($certInfo['validTo_time_t'] ?? 0); if ($validTo === 0) { $this->warn_check('ssl', "Could not parse certificate expiry for {$host}."); return; } $daysLeft = (int) ceil(($validTo - time()) / 86400); $expiry = date('Y-m-d', $validTo); $details = ['host' => $host, 'expires' => $expiry, 'days_left' => $daysLeft]; if ($daysLeft <= 0) { $this->failCheck('ssl', "SSL certificate for {$host} has EXPIRED ({$expiry}).", $details); } elseif ($daysLeft <= 7) { $this->failCheck('ssl', "SSL certificate for {$host} expires in {$daysLeft} day(s) ({$expiry}) — renew immediately!", $details); } elseif ($daysLeft <= 30) { $this->warn_check('ssl', "SSL certificate for {$host} expires in {$daysLeft} day(s) ({$expiry}).", $details); } else { $this->pass('ssl', "SSL certificate for {$host} is valid for {$daysLeft} more day(s) (expires {$expiry}).", $details); } } catch (Throwable $e) { $this->failCheck('ssl', "SSL check for {$host} failed: " . $e->getMessage()); } } private function checkScheduler(): void { // The scheduler tick key is written by the scheduled health:tick command. // If Redis is not the cache driver, we can't check it. if (config('cache.default') !== 'redis' && config('queue.default') !== 'redis') { $this->warn_check('scheduler', 'Scheduler check requires Redis cache or queue — skipping in this environment.'); return; } try { $raw = Redis::get('health:scheduler_last_tick'); if ($raw === null || $raw === false) { $this->warn_check('scheduler', 'No scheduler tick recorded yet. Ensure cron `* * * * * php artisan schedule:run` is configured and has run at least once. (Key: health:scheduler_last_tick)'); return; } $lastTick = (int) $raw; $age = time() - $lastTick; $details = ['last_tick' => date('Y-m-d H:i:s', $lastTick), 'age_seconds' => $age]; if ($age > 300) { $this->failCheck('scheduler', "Scheduler last ran " . gmdate('H:i:s', $age) . " ago — cron may have stopped.", $details); } elseif ($age > 120) { $this->warn_check('scheduler', "Scheduler last ran {$age}s ago (expected ≤ 60s).", $details); } else { $this->pass('scheduler', "Scheduler is ticking — last run {$age}s ago ({$details['last_tick']}).", $details); } } catch (Throwable $e) { $this->warn_check('scheduler', 'Scheduler check failed: ' . $e->getMessage()); } } private function checkLogErrors(): void { $logFile = storage_path('logs/laravel.log'); if (! file_exists($logFile)) { $this->warn_check('log_errors', 'laravel.log does not exist yet.'); return; } try { // Read the last ~50 KB so we cover roughly 100+ log lines without loading the whole file. $handle = @fopen($logFile, 'r'); if ($handle === false) { $this->warn_check('log_errors', 'Could not open laravel.log for reading.'); return; } fseek($handle, max(0, filesize($logFile) - 51200)); // last ~50 KB $tail = fread($handle, 51200); fclose($handle); // Split into lines and take the last 100 $lines = array_slice(explode("\n", $tail), -100); $errorCount = 0; $criticalCount = 0; $lastError = ''; foreach ($lines as $line) { if (preg_match('/\.(ERROR|CRITICAL|ALERT|EMERGENCY)/', $line)) { $errorCount++; if (preg_match('/\.CRITICAL|\.ALERT|\.EMERGENCY/', $line)) { $criticalCount++; } if ($lastError === '') { $lastError = substr(trim($line), 0, 200); } } } $details = ['errors' => $errorCount, 'critical' => $criticalCount, 'last_error' => $lastError]; $suffix = $lastError !== '' ? " Last: {$lastError}" : ''; if ($criticalCount > 0) { $this->warn_check('log_errors', "{$criticalCount} CRITICAL/ALERT/EMERGENCY and {$errorCount} total error line(s) in last 100 log lines.{$suffix}", $details); } elseif ($errorCount > 0) { $this->warn_check('log_errors', "{$errorCount} ERROR line(s) in last 100 log lines.{$suffix}", $details); } else { $this->pass('log_errors', 'No ERROR/CRITICAL entries in last 100 log lines.', $details); } } catch (Throwable $e) { $this->warn_check('log_errors', 'Log check failed: ' . $e->getMessage()); } } private function checkApp(): void { $appUrl = rtrim((string) config('app.url', ''), '/'); if (empty($appUrl) || str_contains($appUrl, '.test') || str_contains($appUrl, 'localhost')) { $this->warn_check('app', "APP_URL is `{$appUrl}` — looks like a local/dev URL, skipping HTTP probe."); return; } // Probe the app homepage $response = $this->httpGet($appUrl . '/', 10); $code = $response['code'] ?? 0; if ($code === 200) { $ttfb = $response['ttfb'] ?? 0; $this->pass('app', "Homepage responded HTTP 200. TTFB: {$ttfb}ms.", ['url' => $appUrl, 'ttfb_ms' => $ttfb]); } elseif ($code > 0) { $this->warn_check('app', "Homepage returned HTTP {$code}. URL: {$appUrl}", ['url' => $appUrl, 'http_code' => $code]); } else { $this->failCheck('app', "Homepage unreachable. URL: {$appUrl}"); } } // ── Helpers ──────────────────────────────────────────────────────────────── private function httpGet(string $url, int $timeout = 5): array { $start = microtime(true); try { $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => $timeout, CURLOPT_CONNECTTIMEOUT => 3, CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 3, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_USERAGENT => 'SkinbaseHealthCheck/1.0', CURLOPT_HTTPHEADER => ['Accept: application/json, text/html'], CURLOPT_HEADERFUNCTION => function ($ch, $header) use (&$responseHeaders) { $responseHeaders[] = $header; return strlen($header); }, ]); $responseHeaders = []; $body = curl_exec($ch); $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); $ttfb = (int) round((microtime(true) - $start) * 1000); // Extract Server header for Apache detection $serverHeader = ''; foreach ($responseHeaders as $h) { if (stripos($h, 'Server:') === 0) { $serverHeader = trim(substr($h, 7)); break; } } return ['code' => $code, 'body' => $body ?: '', 'ttfb' => $ttfb, 'server' => $serverHeader]; } catch (Throwable) { return ['code' => 0, 'body' => '', 'ttfb' => 0, 'server' => '']; } } /** * httpGet variant that sends the vector gateway auth header. */ private function httpGetWithAuth(string $url, string $apiKey, int $timeout = 5): array { $start = microtime(true); try { $headers = ['Accept: application/json']; if (! empty($apiKey)) { $headers[] = "X-API-Key: {$apiKey}"; } $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => $timeout, CURLOPT_CONNECTTIMEOUT => 3, CURLOPT_FOLLOWLOCATION => false, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_USERAGENT => 'SkinbaseHealthCheck/1.0', CURLOPT_HTTPHEADER => $headers, ]); $body = curl_exec($ch); $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); $ttfb = (int) round((microtime(true) - $start) * 1000); return ['code' => $code, 'body' => $body ?: '', 'ttfb' => $ttfb]; } catch (Throwable) { return ['code' => 0, 'body' => '', 'ttfb' => 0]; } } private function pass(string $name, string $message, array $details = []): void { $this->result($name, 'pass', $message, $details); } private function failCheck(string $name, string $message, array $details = []): void { $this->result($name, 'fail', $message, $details); } private function warn_check(string $name, string $message, array $details = []): void { $this->result($name, 'warn', $message, $details); } private function result(string $name, string $status, string $message, array $details = []): void { $this->results[$name] = [ 'status' => $status, 'message' => $message, 'details' => $details, ]; } private function renderTable(): void { $this->newLine(); $this->line(' SERVICE STATUS MESSAGE'); $this->line(' ' . str_repeat('─', 90)); foreach ($this->results as $name => $r) { [$icon, $color] = match ($r['status']) { 'pass' => ['✅', 'green'], 'warn' => ['⚠️ ', 'yellow'], default => ['❌', 'red'], }; $label = str_pad(strtoupper($name), 15); $status = str_pad(strtoupper($r['status']), 7); $message = $r['message']; $this->line(" {$icon} {$label} {$status} {$message}"); } $this->line(' ' . str_repeat('─', 90)); } private function hasFailures(): bool { return $this->countByStatus('fail') > 0; } private function countByStatus(string $status): int { return count(array_filter($this->results, fn ($r) => $r['status'] === $status)); } }