1253 lines
51 KiB
PHP
1253 lines
51 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Models\Artwork;
|
|
use App\Services\Vision\ArtworkVisionImageUrl;
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Redis;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Meilisearch\Client as MeilisearchClient;
|
|
use Throwable;
|
|
|
|
/**
|
|
* Comprehensive service health check.
|
|
*
|
|
* Usage:
|
|
* php artisan health:check # all checks
|
|
* php artisan health:check --only=meili # single service
|
|
* php artisan health:check --json # machine-readable JSON output
|
|
*/
|
|
class HealthCheckCommand extends Command
|
|
{
|
|
protected $signature = 'health:check
|
|
{--only= : Run only a named check (mysql|redis|cache|meilisearch|qdrant|reverb|vision|horizon|webserver|phpfpm|paths|ram|disk|load|s3|failed_jobs|queue_backlog|ssl|scheduler|log_errors|app)}
|
|
{--json : Output results as JSON}';
|
|
|
|
protected $description = 'Check health of all critical services (MySQL, Redis, Cache, Meilisearch, Qdrant, Reverb, Vision, Horizon, Nginx, PHP-FPM, writable paths, RAM, disk, load, S3/Contabo, failed jobs, queue backlog, SSL, scheduler, log errors, App).';
|
|
|
|
/** Collected results: [name => [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(' <fg=white;options=bold>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} <fg={$color}>{$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));
|
|
}
|
|
}
|