e497a93d创建于 2025年8月25日历史提交
<?php

/**
 * @package    Grav\Common\Scheduler
 * 
 * @copyright  Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.
 * @license    MIT License; see LICENSE file for details.
 */

namespace Grav\Common\Scheduler;

use DateTime;
use RocketTheme\Toolbox\File\JsonFile;

/**
 * Job History Manager
 * 
 * Provides comprehensive job execution history, logging, and analytics
 * 
 * @package Grav\Common\Scheduler
 */
class JobHistory
{
    /** @var string */
    protected $historyPath;
    
    /** @var int */
    protected $retentionDays = 30;
    
    /** @var int */
    protected $maxOutputLength = 5000;
    
    /**
     * Constructor
     * 
     * @param string $historyPath
     * @param int $retentionDays
     */
    public function __construct(string $historyPath, int $retentionDays = 30)
    {
        $this->historyPath = $historyPath;
        $this->retentionDays = $retentionDays;
        
        // Ensure history directory exists
        if (!is_dir($this->historyPath)) {
            mkdir($this->historyPath, 0755, true);
        }
    }
    
    /**
     * Log job execution
     * 
     * @param Job $job
     * @param array $metadata Additional metadata to store
     * @return string Log entry ID
     */
    public function logExecution(Job $job, array $metadata = []): string
    {
        $entryId = uniqid($job->getId() . '_', true);
        $timestamp = new DateTime();
        
        $entry = [
            'id' => $entryId,
            'job_id' => $job->getId(),
            'command' => is_string($job->getCommand()) ? $job->getCommand() : 'Closure',
            'arguments' => method_exists($job, 'getRawArguments') ? $job->getRawArguments() : $job->getArguments(),
            'executed_at' => $timestamp->format('c'),
            'timestamp' => $timestamp->getTimestamp(),
            'success' => $job->isSuccessful(),
            'output' => $this->captureOutput($job),
            'execution_time' => method_exists($job, 'getExecutionTime') ? $job->getExecutionTime() : null,
            'retry_count' => method_exists($job, 'getRetryCount') ? $job->getRetryCount() : 0,
            'priority' => method_exists($job, 'getPriority') ? $job->getPriority() : 'normal',
            'tags' => method_exists($job, 'getTags') ? $job->getTags() : [],
            'metadata' => array_merge(
                method_exists($job, 'getMetadata') ? $job->getMetadata() : [],
                $metadata
            ),
        ];
        
        // Store in daily file
        $this->storeEntry($entry);
        
        // Also store in job-specific history
        $this->storeJobHistory($job->getId(), $entry);
        
        return $entryId;
    }
    
    /**
     * Capture job output with length limit
     * 
     * @param Job $job
     * @return array
     */
    protected function captureOutput(Job $job): array
    {
        $output = $job->getOutput();
        $truncated = false;
        
        if (strlen($output) > $this->maxOutputLength) {
            $output = substr($output, 0, $this->maxOutputLength);
            $truncated = true;
        }
        
        return [
            'content' => $output,
            'truncated' => $truncated,
            'length' => strlen($job->getOutput()),
        ];
    }
    
    /**
     * Store entry in daily log file
     * 
     * @param array $entry
     * @return void
     */
    protected function storeEntry(array $entry): void
    {
        $date = date('Y-m-d');
        $filename = $this->historyPath . '/' . $date . '.json';
        
        $jsonFile = JsonFile::instance($filename);
        $entries = $jsonFile->content() ?: [];
        $entries[] = $entry;
        $jsonFile->save($entries);
    }
    
    /**
     * Store job-specific history
     * 
     * @param string $jobId
     * @param array $entry
     * @return void
     */
    protected function storeJobHistory(string $jobId, array $entry): void
    {
        $jobDir = $this->historyPath . '/jobs';
        if (!is_dir($jobDir)) {
            mkdir($jobDir, 0755, true);
        }
        
        $filename = $jobDir . '/' . $jobId . '.json';
        $jsonFile = JsonFile::instance($filename);
        $history = $jsonFile->content() ?: [];
        
        // Keep only last 100 executions per job
        $history[] = $entry;
        if (count($history) > 100) {
            $history = array_slice($history, -100);
        }
        
        $jsonFile->save($history);
    }
    
    /**
     * Get job history
     * 
     * @param string $jobId
     * @param int $limit
     * @return array
     */
    public function getJobHistory(string $jobId, int $limit = 50): array
    {
        $filename = $this->historyPath . '/jobs/' . $jobId . '.json';
        if (!file_exists($filename)) {
            return [];
        }
        
        $jsonFile = JsonFile::instance($filename);
        $history = $jsonFile->content() ?: [];
        
        // Return most recent first
        $history = array_reverse($history);
        
        if ($limit > 0) {
            $history = array_slice($history, 0, $limit);
        }
        
        return $history;
    }
    
    /**
     * Get history for a date range
     * 
     * @param DateTime $startDate
     * @param DateTime $endDate
     * @param string|null $jobId Filter by job ID
     * @return array
     */
    public function getHistoryRange(DateTime $startDate, DateTime $endDate, ?string $jobId = null): array
    {
        $history = [];
        $current = clone $startDate;
        
        while ($current <= $endDate) {
            $filename = $this->historyPath . '/' . $current->format('Y-m-d') . '.json';
            if (file_exists($filename)) {
                $jsonFile = JsonFile::instance($filename);
                $entries = $jsonFile->content() ?: [];
                
                foreach ($entries as $entry) {
                    if ($jobId === null || $entry['job_id'] === $jobId) {
                        $history[] = $entry;
                    }
                }
            }
            
            $current->modify('+1 day');
        }
        
        return $history;
    }
    
    /**
     * Get job statistics
     * 
     * @param string $jobId
     * @param int $days Number of days to analyze
     * @return array
     */
    public function getJobStatistics(string $jobId, int $days = 7): array
    {
        $startDate = new DateTime("-{$days} days");
        $endDate = new DateTime('now');
        
        $history = $this->getHistoryRange($startDate, $endDate, $jobId);
        
        if (empty($history)) {
            return [
                'total_runs' => 0,
                'successful_runs' => 0,
                'failed_runs' => 0,
                'success_rate' => 0,
                'average_execution_time' => 0,
                'last_run' => null,
                'last_success' => null,
                'last_failure' => null,
            ];
        }
        
        $totalRuns = count($history);
        $successfulRuns = 0;
        $executionTimes = [];
        $lastRun = null;
        $lastSuccess = null;
        $lastFailure = null;
        
        foreach ($history as $entry) {
            if ($entry['success']) {
                $successfulRuns++;
                if (!$lastSuccess || $entry['timestamp'] > $lastSuccess['timestamp']) {
                    $lastSuccess = $entry;
                }
            } else {
                if (!$lastFailure || $entry['timestamp'] > $lastFailure['timestamp']) {
                    $lastFailure = $entry;
                }
            }
            
            if (!$lastRun || $entry['timestamp'] > $lastRun['timestamp']) {
                $lastRun = $entry;
            }
            
            if (isset($entry['execution_time']) && $entry['execution_time'] > 0) {
                $executionTimes[] = $entry['execution_time'];
            }
        }
        
        return [
            'total_runs' => $totalRuns,
            'successful_runs' => $successfulRuns,
            'failed_runs' => $totalRuns - $successfulRuns,
            'success_rate' => $totalRuns > 0 ? round(($successfulRuns / $totalRuns) * 100, 2) : 0,
            'average_execution_time' => !empty($executionTimes) ? round(array_sum($executionTimes) / count($executionTimes), 3) : 0,
            'last_run' => $lastRun,
            'last_success' => $lastSuccess,
            'last_failure' => $lastFailure,
        ];
    }
    
    /**
     * Get global statistics
     * 
     * @param int $days
     * @return array
     */
    public function getGlobalStatistics(int $days = 7): array
    {
        $startDate = new DateTime("-{$days} days");
        $endDate = new DateTime('now');
        
        $history = $this->getHistoryRange($startDate, $endDate);
        
        $jobStats = [];
        foreach ($history as $entry) {
            $jobId = $entry['job_id'];
            if (!isset($jobStats[$jobId])) {
                $jobStats[$jobId] = [
                    'runs' => 0,
                    'success' => 0,
                    'failed' => 0,
                ];
            }
            
            $jobStats[$jobId]['runs']++;
            if ($entry['success']) {
                $jobStats[$jobId]['success']++;
            } else {
                $jobStats[$jobId]['failed']++;
            }
        }
        
        return [
            'total_executions' => count($history),
            'unique_jobs' => count($jobStats),
            'job_statistics' => $jobStats,
            'period_days' => $days,
            'from_date' => $startDate->format('Y-m-d'),
            'to_date' => $endDate->format('Y-m-d'),
        ];
    }
    
    /**
     * Search history
     * 
     * @param array $criteria
     * @return array
     */
    public function searchHistory(array $criteria): array
    {
        $results = [];
        
        // Determine date range
        $startDate = isset($criteria['start_date']) ? new DateTime($criteria['start_date']) : new DateTime('-7 days');
        $endDate = isset($criteria['end_date']) ? new DateTime($criteria['end_date']) : new DateTime('now');
        
        $history = $this->getHistoryRange($startDate, $endDate, $criteria['job_id'] ?? null);
        
        foreach ($history as $entry) {
            $match = true;
            
            // Filter by success status
            if (isset($criteria['success']) && $entry['success'] !== $criteria['success']) {
                $match = false;
            }
            
            // Filter by output content
            if (isset($criteria['output_contains']) && 
                stripos($entry['output']['content'], $criteria['output_contains']) === false) {
                $match = false;
            }
            
            // Filter by tags
            if (isset($criteria['tags']) && is_array($criteria['tags'])) {
                $entryTags = $entry['tags'] ?? [];
                if (empty(array_intersect($criteria['tags'], $entryTags))) {
                    $match = false;
                }
            }
            
            if ($match) {
                $results[] = $entry;
            }
        }
        
        // Sort results
        if (isset($criteria['sort_by'])) {
            usort($results, function($a, $b) use ($criteria) {
                $field = $criteria['sort_by'];
                $order = $criteria['sort_order'] ?? 'desc';
                
                $aVal = $a[$field] ?? 0;
                $bVal = $b[$field] ?? 0;
                
                if ($order === 'asc') {
                    return $aVal <=> $bVal;
                } else {
                    return $bVal <=> $aVal;
                }
            });
        }
        
        // Limit results
        if (isset($criteria['limit'])) {
            $results = array_slice($results, 0, $criteria['limit']);
        }
        
        return $results;
    }
    
    /**
     * Clean old history files
     * 
     * @return int Number of files deleted
     */
    public function cleanOldHistory(): int
    {
        $deleted = 0;
        $cutoffDate = new DateTime("-{$this->retentionDays} days");
        
        $files = glob($this->historyPath . '/*.json');
        foreach ($files as $file) {
            $filename = basename($file, '.json');
            // Check if filename is a date
            if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $filename)) {
                $fileDate = new DateTime($filename);
                if ($fileDate < $cutoffDate) {
                    unlink($file);
                    $deleted++;
                }
            }
        }
        
        return $deleted;
    }
    
    /**
     * Export history to CSV
     * 
     * @param array $history
     * @param string $filename
     * @return bool
     */
    public function exportToCsv(array $history, string $filename): bool
    {
        $handle = fopen($filename, 'w');
        if (!$handle) {
            return false;
        }
        
        // Write headers
        fputcsv($handle, [
            'Job ID',
            'Executed At',
            'Success',
            'Execution Time',
            'Output Length',
            'Retry Count',
            'Priority',
            'Tags',
        ]);
        
        // Write data
        foreach ($history as $entry) {
            fputcsv($handle, [
                $entry['job_id'],
                $entry['executed_at'],
                $entry['success'] ? 'Yes' : 'No',
                $entry['execution_time'] ?? '',
                $entry['output']['length'] ?? 0,
                $entry['retry_count'] ?? 0,
                $entry['priority'] ?? 'normal',
                implode(', ', $entry['tags'] ?? []),
            ]);
        }
        
        fclose($handle);
        return true;
    }
}