Reject malformed --date values with a clear error before building any shell command, and wrap every interpolated value (log paths, filter expression, line count) in escapeshellarg() so filter options and date values can no longer break out of the tail/grep pipeline. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
288 lines
9.8 KiB
PHP
288 lines
9.8 KiB
PHP
<?php
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Support\Facades\File;
|
|
|
|
class ViewScheduledLogs extends Command
|
|
{
|
|
protected $signature = 'logs:scheduled
|
|
{--lines=50 : Number of lines to display}
|
|
{--follow : Follow the log file (tail -f)}
|
|
{--date= : Specific date (Y-m-d format, defaults to today)}
|
|
{--task-name= : Filter by task name (partial match)}
|
|
{--task-id= : Filter by task ID}
|
|
{--backup-name= : Filter by backup name (partial match)}
|
|
{--backup-id= : Filter by backup ID}
|
|
{--errors : View error logs only}
|
|
{--all : View both normal and error logs}
|
|
{--hourly : Filter hourly jobs}
|
|
{--daily : Filter daily jobs}
|
|
{--weekly : Filter weekly jobs}
|
|
{--monthly : Filter monthly jobs}
|
|
{--frequency= : Filter by specific cron expression}';
|
|
|
|
protected $description = 'View scheduled backups and tasks logs with optional filtering';
|
|
|
|
public function handle()
|
|
{
|
|
$date = $this->option('date') ?: now()->format('Y-m-d');
|
|
if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
|
$this->error('Invalid date format. Use Y-m-d (e.g. 2025-01-31).');
|
|
|
|
return self::INVALID;
|
|
}
|
|
$logPaths = $this->getLogPaths($date);
|
|
|
|
if (empty($logPaths)) {
|
|
$this->showAvailableLogFiles($date);
|
|
|
|
return;
|
|
}
|
|
|
|
$lines = $this->option('lines');
|
|
$follow = $this->option('follow');
|
|
|
|
// Build grep filters
|
|
$filters = $this->buildFilters();
|
|
$filterDescription = $this->getFilterDescription();
|
|
$logTypeDescription = $this->getLogTypeDescription();
|
|
|
|
if ($follow) {
|
|
$this->info("Following {$logTypeDescription} logs for {$date}{$filterDescription} (Press Ctrl+C to stop)...");
|
|
$this->line('');
|
|
|
|
if (count($logPaths) === 1) {
|
|
$logPath = escapeshellarg($logPaths[0]);
|
|
if ($filters) {
|
|
$escapedFilters = escapeshellarg($filters);
|
|
passthru("tail -f {$logPath} | grep -E {$escapedFilters}");
|
|
} else {
|
|
passthru("tail -f {$logPath}");
|
|
}
|
|
} else {
|
|
// Multiple files - use multitail or tail with process substitution
|
|
$logPathsStr = implode(' ', array_map('escapeshellarg', $logPaths));
|
|
if ($filters) {
|
|
$escapedFilters = escapeshellarg($filters);
|
|
passthru("tail -f {$logPathsStr} | grep -E {$escapedFilters}");
|
|
} else {
|
|
passthru("tail -f {$logPathsStr}");
|
|
}
|
|
}
|
|
} else {
|
|
$this->info("Showing last {$lines} lines of {$logTypeDescription} logs for {$date}{$filterDescription}:");
|
|
$this->line('');
|
|
|
|
$escapedLines = escapeshellarg((string) $lines);
|
|
if (count($logPaths) === 1) {
|
|
$logPath = escapeshellarg($logPaths[0]);
|
|
if ($filters) {
|
|
$escapedFilters = escapeshellarg($filters);
|
|
passthru("tail -n {$escapedLines} {$logPath} | grep -E {$escapedFilters}");
|
|
} else {
|
|
passthru("tail -n {$escapedLines} {$logPath}");
|
|
}
|
|
} else {
|
|
// Multiple files - concatenate and sort by timestamp
|
|
$logPathsStr = implode(' ', array_map('escapeshellarg', $logPaths));
|
|
if ($filters) {
|
|
$escapedFilters = escapeshellarg($filters);
|
|
passthru("tail -n {$escapedLines} {$logPathsStr} | sort | grep -E {$escapedFilters}");
|
|
} else {
|
|
passthru("tail -n {$escapedLines} {$logPathsStr} | sort");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private function getLogPaths(string $date): array
|
|
{
|
|
$paths = [];
|
|
|
|
if ($this->option('errors')) {
|
|
// Error logs only
|
|
$errorPath = storage_path("logs/scheduled-errors-{$date}.log");
|
|
if (File::exists($errorPath)) {
|
|
$paths[] = $errorPath;
|
|
}
|
|
} elseif ($this->option('all')) {
|
|
// Both normal and error logs
|
|
$normalPath = storage_path("logs/scheduled-{$date}.log");
|
|
$errorPath = storage_path("logs/scheduled-errors-{$date}.log");
|
|
|
|
if (File::exists($normalPath)) {
|
|
$paths[] = $normalPath;
|
|
}
|
|
if (File::exists($errorPath)) {
|
|
$paths[] = $errorPath;
|
|
}
|
|
} else {
|
|
// Normal logs only (default)
|
|
$normalPath = storage_path("logs/scheduled-{$date}.log");
|
|
if (File::exists($normalPath)) {
|
|
$paths[] = $normalPath;
|
|
}
|
|
}
|
|
|
|
return $paths;
|
|
}
|
|
|
|
private function showAvailableLogFiles(string $date): void
|
|
{
|
|
$logType = $this->getLogTypeDescription();
|
|
$this->warn("No {$logType} logs found for date {$date}");
|
|
|
|
// Show available log files
|
|
$normalFiles = File::glob(storage_path('logs/scheduled-*.log'));
|
|
$errorFiles = File::glob(storage_path('logs/scheduled-errors-*.log'));
|
|
|
|
if (! empty($normalFiles) || ! empty($errorFiles)) {
|
|
$this->info('Available scheduled log files:');
|
|
|
|
if (! empty($normalFiles)) {
|
|
$this->line(' Normal logs:');
|
|
foreach ($normalFiles as $file) {
|
|
$basename = basename($file);
|
|
$this->line(" - {$basename}");
|
|
}
|
|
}
|
|
|
|
if (! empty($errorFiles)) {
|
|
$this->line(' Error logs:');
|
|
foreach ($errorFiles as $file) {
|
|
$basename = basename($file);
|
|
$this->line(" - {$basename}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private function getLogTypeDescription(): string
|
|
{
|
|
if ($this->option('errors')) {
|
|
return 'error';
|
|
} elseif ($this->option('all')) {
|
|
return 'all';
|
|
} else {
|
|
return 'normal';
|
|
}
|
|
}
|
|
|
|
private function buildFilters(): ?string
|
|
{
|
|
$filters = [];
|
|
|
|
if ($taskName = $this->option('task-name')) {
|
|
$filters[] = '"task_name":"[^"]*'.preg_quote($taskName, '/').'[^"]*"';
|
|
}
|
|
|
|
if ($taskId = $this->option('task-id')) {
|
|
$filters[] = '"task_id":'.preg_quote($taskId, '/');
|
|
}
|
|
|
|
if ($backupName = $this->option('backup-name')) {
|
|
$filters[] = '"backup_name":"[^"]*'.preg_quote($backupName, '/').'[^"]*"';
|
|
}
|
|
|
|
if ($backupId = $this->option('backup-id')) {
|
|
$filters[] = '"backup_id":'.preg_quote($backupId, '/');
|
|
}
|
|
|
|
// Frequency filters
|
|
if ($this->option('hourly')) {
|
|
$filters[] = $this->getFrequencyPattern('hourly');
|
|
}
|
|
|
|
if ($this->option('daily')) {
|
|
$filters[] = $this->getFrequencyPattern('daily');
|
|
}
|
|
|
|
if ($this->option('weekly')) {
|
|
$filters[] = $this->getFrequencyPattern('weekly');
|
|
}
|
|
|
|
if ($this->option('monthly')) {
|
|
$filters[] = $this->getFrequencyPattern('monthly');
|
|
}
|
|
|
|
if ($frequency = $this->option('frequency')) {
|
|
$filters[] = '"frequency":"'.preg_quote($frequency, '/').'"';
|
|
}
|
|
|
|
return empty($filters) ? null : implode('|', $filters);
|
|
}
|
|
|
|
private function getFrequencyPattern(string $type): string
|
|
{
|
|
$patterns = [
|
|
'hourly' => [
|
|
'0 \* \* \* \*', // 0 * * * *
|
|
'@hourly', // @hourly
|
|
],
|
|
'daily' => [
|
|
'0 0 \* \* \*', // 0 0 * * *
|
|
'@daily', // @daily
|
|
'@midnight', // @midnight
|
|
],
|
|
'weekly' => [
|
|
'0 0 \* \* [0-6]', // 0 0 * * 0-6 (any day of week)
|
|
'@weekly', // @weekly
|
|
],
|
|
'monthly' => [
|
|
'0 0 1 \* \*', // 0 0 1 * * (first of month)
|
|
'@monthly', // @monthly
|
|
],
|
|
];
|
|
|
|
$typePatterns = $patterns[$type] ?? [];
|
|
|
|
// For grep, we need to match the frequency field in JSON
|
|
return '"frequency":"('.implode('|', $typePatterns).')"';
|
|
}
|
|
|
|
private function getFilterDescription(): string
|
|
{
|
|
$descriptions = [];
|
|
|
|
if ($taskName = $this->option('task-name')) {
|
|
$descriptions[] = "task name: {$taskName}";
|
|
}
|
|
|
|
if ($taskId = $this->option('task-id')) {
|
|
$descriptions[] = "task ID: {$taskId}";
|
|
}
|
|
|
|
if ($backupName = $this->option('backup-name')) {
|
|
$descriptions[] = "backup name: {$backupName}";
|
|
}
|
|
|
|
if ($backupId = $this->option('backup-id')) {
|
|
$descriptions[] = "backup ID: {$backupId}";
|
|
}
|
|
|
|
// Frequency filters
|
|
if ($this->option('hourly')) {
|
|
$descriptions[] = 'hourly jobs';
|
|
}
|
|
|
|
if ($this->option('daily')) {
|
|
$descriptions[] = 'daily jobs';
|
|
}
|
|
|
|
if ($this->option('weekly')) {
|
|
$descriptions[] = 'weekly jobs';
|
|
}
|
|
|
|
if ($this->option('monthly')) {
|
|
$descriptions[] = 'monthly jobs';
|
|
}
|
|
|
|
if ($frequency = $this->option('frequency')) {
|
|
$descriptions[] = "frequency: {$frequency}";
|
|
}
|
|
|
|
return empty($descriptions) ? '' : ' (filtered by '.implode(', ', $descriptions).')';
|
|
}
|
|
}
|