Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
224 changes: 201 additions & 23 deletions app/Console/Server/ServerLogsCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,21 @@

#[AsCommand(
name: 'server:logs',
description: 'View server logs (system and detected services)'
description: 'View server logs (system, PHP-FPM, sites, and detected services)'
)]
class ServerLogsCommand extends BaseCommand
{
use PlaybooksTrait;
use ServersTrait;

/**
* @var array<string, mixed>|null
* @var array{
* options: array<string|int, string>,
* services: list<string>,
* phpVersions: list<string>,
* sites: list<string>,
* hasDocker: bool
* }|null
*/
private ?array $processedServices = null;

Expand All @@ -37,7 +43,7 @@ protected function configure(): void

$this->addOption('server', null, InputOption::VALUE_REQUIRED, 'Server name');
$this->addOption('lines', 'n', InputOption::VALUE_REQUIRED, 'Number of lines to retrieve');
$this->addOption('service', 's', InputOption::VALUE_REQUIRED, 'Service name (all|system|detected service name)');
$this->addOption('service', 's', InputOption::VALUE_REQUIRED, 'Service name (all|system|php-fpm|site|detected service name)');
}

// ---- Execution
Expand Down Expand Up @@ -121,19 +127,24 @@ protected function execute(InputInterface $input, OutputInterface $output): int
// ----

/**
* Process detected services and build options for user selection.
*
* Consolidates Docker-related processes and caches results for reuse.
* Process detected services, PHP versions, and sites to build options.
*
* @param array<string, mixed> $info Server information from server-info playbook
* @return array<string, mixed>
* @return array{
* options: array<string|int, string>,
* services: list<string>,
* phpVersions: list<string>,
* sites: list<string>,
* hasDocker: bool
* }
*/
protected function getProcessedServices(array $info): array
{
if ($this->processedServices !== null) {
return $this->processedServices;
}

// 1. Detected listening services
/** @var array<int, string> $ports */
$ports = $info['ports'] ?? [];
$detected = array_unique(array_values($ports));
Expand All @@ -142,7 +153,7 @@ protected function getProcessedServices(array $info): array
$hasDocker = false;

foreach ($detected as $service) {
$lower = strtolower($service);
$lower = strtolower((string) $service);

if ($lower === 'unknown') {
continue;
Expand All @@ -153,25 +164,66 @@ protected function getProcessedServices(array $info): array
continue;
}

$services[] = $service;
$services[] = (string) $service;
}

// 2. PHP Versions (PHP-FPM)
$phpVersions = [];
if (isset($info['php']) && is_array($info['php']) && isset($info['php']['versions']) && is_array($info['php']['versions'])) {
foreach ($info['php']['versions'] as $versionData) {
$version = null;
if (is_array($versionData) && isset($versionData['version'])) {
/** @var string|int|float $rawVersion */
$rawVersion = $versionData['version'];
$version = (string) $rawVersion;
} elseif (is_string($versionData) || is_numeric($versionData)) {
$version = (string) $versionData;
}

if ($version !== null) {
$phpVersions[] = $version;
}
}
}

// 3. Sites
/** @var list<string> $sites */
$sites = [];
if (isset($info['sites_config']) && is_array($info['sites_config'])) {
$sites = array_map(strval(...), array_keys($info['sites_config']));
}

// Build options
$options = [
'all' => 'All services',
'all' => 'All logs (System, Services, PHP, Sites)',
'system' => 'System logs',
];

// Detected services (Caddy, SSH, etc.)
foreach ($services as $service) {
$options[strtolower($service)] = $service;
}

// PHP-FPM services
foreach ($phpVersions as $version) {
$serviceName = "php{$version}-fpm";
$options[$serviceName] = "PHP {$version} FPM";
}

// Sites
foreach ($sites as $site) {
$options[$site] = "Site: {$site}";
}

if ($hasDocker) {
$options['docker'] = 'docker';
$options['docker'] = 'Docker';
}

return $this->processedServices = [
'options' => $options,
'services' => $services,
'phpVersions' => $phpVersions,
'sites' => $sites,
'hasDocker' => $hasDocker,
];
}
Expand All @@ -183,25 +235,78 @@ protected function getProcessedServices(array $info): array
*/
protected function displayServiceLogs(ServerDTO $server, string $service, int $lines, array $info): void
{
if ($service === 'all') {
$processed = $this->getProcessedServices($info);
$processed = $this->getProcessedServices($info);
/** @var array<int, string> $detectedServices */
$detectedServices = $processed['services'];
/** @var array<int, string> $phpVersions */
$phpVersions = $processed['phpVersions'];
/** @var array<int, string> $sites */
$sites = $processed['sites'];
/** @var bool $hasDocker */
$hasDocker = $processed['hasDocker'];

if ($service === 'all') {
// 1. System Logs
$this->retrieveServiceLogs($server, 'System', '', $lines);

/** @var array<int, string> $services */
$services = $processed['services'];
foreach ($services as $serviceName) {
// 2. Detected Services (Caddy, SSH, etc.)
foreach ($detectedServices as $serviceName) {
$lower = strtolower($serviceName);
if (str_starts_with($lower, 'php') && str_ends_with($lower, '-fpm')) {
// Prefer file-based PHP-FPM logs handled below
continue;
}

$this->retrieveServiceLogs($server, $serviceName, $serviceName, $lines);
}

/** @var bool $hasDocker */
$hasDocker = $processed['hasDocker'];
// 3. PHP-FPM Logs
foreach ($phpVersions as $version) {
$this->retrieveFileLogs(
$server,
"PHP {$version} FPM",
"/var/log/php{$version}-fpm.log",
$lines
);
}

// 4. Site Logs
foreach ($sites as $site) {
$this->retrieveFileLogs(
$server,
"Site: {$site}",
"/var/log/caddy/{$site}-access.log",
$lines
);
}

// 5. Docker
if ($hasDocker) {
$this->retrieveServiceLogs($server, 'docker', 'docker', $lines);
$this->retrieveServiceLogs($server, 'Docker', 'docker', $lines);
}

} elseif ($service === 'system') {
$this->retrieveServiceLogs($server, 'System', '', $lines);
} elseif (in_array($service, $sites, true)) {
// Specific Site Log
$this->retrieveFileLogs(
$server,
"Site: {$service}",
"/var/log/caddy/{$service}-access.log",
$lines
);
} elseif (str_starts_with($service, 'php') && str_ends_with($service, '-fpm')) {
// Specific PHP-FPM Log
// Service name format: php8.3-fpm
// Check if it's a file log or system service (usually both, but we prefer file for PHP-FPM)
$this->retrieveFileLogs(
$server,
$service,
"/var/log/{$service}.log",
$lines
);
} else {
// Generic Service (journalctl)
$this->retrieveServiceLogs($server, $service, $service, $lines);
}
}
Expand Down Expand Up @@ -236,7 +341,7 @@ protected function retrieveServiceLogs(ServerDTO $server, string $service, strin

if ($result['exit_code'] !== 0 && !$serviceNotFound) {
$this->nay("Failed to retrieve {$service} logs");
$this->io->writeln($output);
$this->io->writeln($this->highlightErrors($output));
$this->io->writeln('');

return;
Expand All @@ -249,14 +354,36 @@ protected function retrieveServiceLogs(ServerDTO $server, string $service, strin
return;
}

$this->io->writeln($output);
$this->io->writeln($this->highlightErrors($output));
} catch (\RuntimeException $e) {
$this->nay($e->getMessage());
}

$this->io->writeln('');
}

/**
* Retrieve logs from a specific file.
*/
protected function retrieveFileLogs(ServerDTO $server, string $title, string $filepath, int $lines): void
{
$this->io->writeln([
"<fg=cyan>{$title} Logs</>",
"<fg=gray>File: {$filepath}</>",
'',
]);

$content = $this->readLogFile($server, $filepath, $lines);

if ($content !== null) {
$this->io->writeln($this->highlightErrors($content));
} else {
$this->io->writeln('<fg=yellow>No logs found or file does not exist.</>');
}

$this->io->writeln('');
}

/**
* Try to find and display traditional log files in /var/log/.
*/
Expand All @@ -282,7 +409,7 @@ protected function tryTraditionalLogs(ServerDTO $server, string $service, int $l
$this->io->writeln([
"<fg=bright-black>From {$logFile}:</>",
'',
$logContent,
$this->highlightErrors($logContent),
]);

return;
Expand All @@ -301,7 +428,8 @@ protected function tryTraditionalLogs(ServerDTO $server, string $service, int $l
protected function readLogFile(ServerDTO $server, string $logFile, int $lines): ?string
{
try {
$result = $this->ssh->executeCommand($server, "tail -n {$lines} {$logFile} 2>/dev/null");
$safeLogFile = escapeshellarg($logFile);
$result = $this->ssh->executeCommand($server, "tail -n {$lines} {$safeLogFile} 2>/dev/null");

if ($result['exit_code'] === 0 && trim($result['output']) !== '') {
return trim($result['output']);
Expand All @@ -313,6 +441,56 @@ protected function readLogFile(ServerDTO $server, string $logFile, int $lines):
}
}

/**
* Highlight error keywords in log content.
*/
protected function highlightErrors(string $content): string
{
// 1. Text keywords (substring match)
$textKeywords = [
'error',
'exception',
'fail',
'failed',
'fatal',
'panic',
];

// 2. Numeric status codes (regex word boundary match)
// Matches 500, 502, 503, 504 as distinct words
$statusPattern = '/\b(500|502|503|504)\b/';

$lines = explode("\n", $content);
$processedLines = [];

foreach ($lines as $line) {
$lowerLine = strtolower($line);
$hasError = false;

// Check text keywords
foreach ($textKeywords as $keyword) {
if (str_contains($lowerLine, $keyword)) {
$hasError = true;
break;
}
}

// Check numeric status codes if no text error found yet
if (!$hasError && preg_match($statusPattern, $line)) {
$hasError = true;
}

if ($hasError) {
// Highlight the entire line in red
$processedLines[] = "<fg=red>{$line}</>";
} else {
$processedLines[] = $line;
}
}

return implode("\n", $processedLines);
}

/**
* Get common systemd service name patterns for a process.
*
Expand Down
Loading