[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(), 'reverb' => fn () => $this->checkReverb(), 'vision' => fn () => $this->checkVision(), 'horizon' => fn () => $this->checkHorizon(), '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'; $indexName = (new Artwork())->searchableAs(); $index = $client->index($indexName); $stats = $index->stats(); $docCount = (int) ($stats['numberOfDocuments'] ?? 0); $isIndexing = (bool) ($stats['isIndexing'] ?? false); // Expected: ≥ 50% of DB artworks should be indexed $dbCount = DB::table('artworks') ->whereNull('deleted_at') ->where('is_public', 1) ->where('is_approved', 1) ->count(); $status = 'pass'; $message = "v{$version}. Index `{$indexName}`: {$docCount} docs (DB public+approved: {$dbCount})."; if ($isIndexing) { $message .= ' [currently re-indexing]'; } if ($docCount === 0) { $status = 'fail'; $message = "Index `{$indexName}` is EMPTY (DB has {$dbCount} public+approved artworks). Run: php artisan scout:import \"App\\\\Models\\\\Artwork\""; } elseif ($dbCount > 0 && $docCount < (int) ($dbCount * 0.5)) { $status = 'warn'; $message .= " — indexed count is < 50% of DB count. Index may be stale. Run: php artisan artworks:search-rebuild"; } // Check pending Meilisearch tasks try { $tasks = $client->getTasks(['statuses' => 'enqueued,processing']); $pendingCount = $tasks->getTotal(); if ($pendingCount > 0) { $message .= " ({$pendingCount} tasks still pending)"; } } catch (Throwable) { // non-fatal } $this->result('meilisearch', $status, $message, [ 'index' => $indexName, 'indexed_docs' => $docCount, 'db_eligible' => $dbCount, 'is_indexing' => $isIndexing, 'meili_version' => $version, ]); } catch (Throwable $e) { $this->failCheck('meilisearch', 'Unreachable or error: ' . $e->getMessage()); } } private function checkReverb(): void { $host = config('reverb.servers.reverb.options.host') ?? env('REVERB_HOST', ''); $port = (int) (config('reverb.servers.reverb.options.port') ?? env('REVERB_PORT', 443)); $scheme = config('reverb.servers.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); // Try the HTTP health probe (Reverb answers 200 on /) $url = "{$scheme}://{$host}:{$port}/"; $response = $this->httpGet($url, 3); $code = $response['code'] ?? 0; // Reverb returns 200 or 400 on the root — both mean it's alive if ($code >= 200 && $code < 500) { $this->pass('reverb', "Reachable at {$host}:{$port} (HTTP {$code}).", ['host' => $host, 'port' => $port]); } else { $this->warn_check('reverb', "TCP open but HTTP returned {$code}.", ['host' => $host, 'port' => $port]); } } 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 checkHorizon(): void { try { // 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) { $this->warn_check('horizon', 'No Horizon status key in Redis — 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 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'], ]); $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)); } }