mirror of
https://devops.lemonos.cn/lawson/FendxPHP.git
synced 2026-06-15 23:12:49 +08:00
feat(database): 添加用户角色权限系统及相关监控功能
- 创建用户表(users)包含基本信息和认证字段 - 创建角色表(roles)用于权限控制 - 创建权限表(permissions)定义系统权限 - 创建用户角色关联表(user_roles)建立用户与角色关系 - 创建角色权限关联表(role_permissions)建立角色与权限关系 - 创建迁移记录表(migrations)追踪数据库变更 - 添加AdminController提供管理员面板功能 - 实现系统监控、配置管理、缓存清理等功能 - 添加AOP切面编程支持的各种通知类型 - 实现告警管理AlertManager支持多渠道告警 - 添加文档注解接口规范
This commit is contained in:
24
fendx-framework/fendx-monitor/composer.json
Normal file
24
fendx-framework/fendx-monitor/composer.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "fendx/monitor",
|
||||
"description": "FendxPHP Monitor Module - 性能监控、指标收集、系统监控",
|
||||
"type": "library",
|
||||
"license": "MIT",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Lawson",
|
||||
"email": "lawson@fendx.cn"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": ">=8.1",
|
||||
"fendx/common": "^1.0",
|
||||
"fendx/core": "^1.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Fendx\\Monitor\\": "src/"
|
||||
}
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true
|
||||
}
|
||||
408
fendx-framework/fendx-monitor/src/Alert/AlertManager.php
Normal file
408
fendx-framework/fendx-monitor/src/Alert/AlertManager.php
Normal file
@@ -0,0 +1,408 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\Monitor\Alert;
|
||||
|
||||
use Fendx\Log\Logger;
|
||||
|
||||
final class AlertManager
|
||||
{
|
||||
private static array $alerts = [];
|
||||
private static array $config = [];
|
||||
private static array $channels = [];
|
||||
private static bool $enabled = true;
|
||||
|
||||
public static function initialize(array $config): void
|
||||
{
|
||||
self::$config = array_merge([
|
||||
'enabled' => true,
|
||||
'max_alerts' => 500,
|
||||
'retention_period' => 7200,
|
||||
'channels' => ['log'],
|
||||
'thresholds' => [
|
||||
'error_rate' => 0.05,
|
||||
'memory_usage' => 0.9,
|
||||
'disk_usage' => 0.95,
|
||||
'response_time' => 2.0,
|
||||
'critical_errors' => 5
|
||||
],
|
||||
'cooldown' => [
|
||||
'error_rate' => 300,
|
||||
'memory_usage' => 600,
|
||||
'disk_usage' => 600,
|
||||
'response_time' => 300,
|
||||
'critical_errors' => 1800
|
||||
]
|
||||
], $config);
|
||||
|
||||
self::$enabled = self::$config['enabled'];
|
||||
self::initializeChannels();
|
||||
}
|
||||
|
||||
public static function triggerAlert(string $type, string $message, array $context = [], string $severity = 'warning'): void
|
||||
{
|
||||
if (!self::$enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
$alert = [
|
||||
'id' => uniqid('alert_', true),
|
||||
'type' => $type,
|
||||
'message' => $message,
|
||||
'context' => $context,
|
||||
'severity' => $severity,
|
||||
'timestamp' => microtime(true),
|
||||
'datetime' => date('Y-m-d H:i:s'),
|
||||
'status' => 'active',
|
||||
'acknowledged' => false,
|
||||
'acknowledged_by' => null,
|
||||
'acknowledged_at' => null,
|
||||
'resolved' => false,
|
||||
'resolved_at' => null
|
||||
];
|
||||
|
||||
self::$alerts[] = $alert;
|
||||
self::cleanupOldAlerts();
|
||||
|
||||
// 发送告警到各个渠道
|
||||
self::sendToChannels($alert);
|
||||
|
||||
// 记录告警
|
||||
Logger::warning("Alert triggered: {$type} - {$message}", $alert);
|
||||
}
|
||||
|
||||
public static function checkErrorRate(float $errorRate, int $totalErrors, int $timeWindow): void
|
||||
{
|
||||
if ($errorRate > self::$config['thresholds']['error_rate']) {
|
||||
self::triggerAlert('error_rate',
|
||||
"High error rate detected: " . round($errorRate * 100, 2) . "%",
|
||||
[
|
||||
'error_rate' => $errorRate,
|
||||
'total_errors' => $totalErrors,
|
||||
'time_window' => $timeWindow,
|
||||
'threshold' => self::$config['thresholds']['error_rate']
|
||||
],
|
||||
$errorRate > 0.1 ? 'critical' : 'warning'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static function checkMemoryUsage(float $usagePercent, int $usedBytes, int $totalBytes): void
|
||||
{
|
||||
if ($usagePercent > self::$config['thresholds']['memory_usage']) {
|
||||
self::triggerAlert('memory_usage',
|
||||
"High memory usage: " . round($usagePercent * 100, 2) . "%",
|
||||
[
|
||||
'usage_percent' => $usagePercent,
|
||||
'used_bytes' => $usedBytes,
|
||||
'total_bytes' => $totalBytes,
|
||||
'threshold' => self::$config['thresholds']['memory_usage']
|
||||
],
|
||||
$usagePercent > 0.95 ? 'critical' : 'warning'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static function checkDiskUsage(string $path, float $usagePercent, int $usedBytes, int $totalBytes): void
|
||||
{
|
||||
if ($usagePercent > self::$config['thresholds']['disk_usage']) {
|
||||
self::triggerAlert('disk_usage',
|
||||
"Low disk space on {$path}: " . round($usagePercent * 100, 2) . "% used",
|
||||
[
|
||||
'path' => $path,
|
||||
'usage_percent' => $usagePercent,
|
||||
'used_bytes' => $usedBytes,
|
||||
'total_bytes' => $totalBytes,
|
||||
'threshold' => self::$config['thresholds']['disk_usage']
|
||||
],
|
||||
$usagePercent > 0.98 ? 'critical' : 'warning'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static function checkResponseTime(string $endpoint, float $avgTime, float $threshold = null): void
|
||||
{
|
||||
$threshold = $threshold ?? self::$config['thresholds']['response_time'];
|
||||
|
||||
if ($avgTime > $threshold) {
|
||||
self::triggerAlert('response_time',
|
||||
"Slow response time for {$endpoint}: " . round($avgTime, 3) . "s",
|
||||
[
|
||||
'endpoint' => $endpoint,
|
||||
'avg_time' => $avgTime,
|
||||
'threshold' => $threshold
|
||||
],
|
||||
$avgTime > $threshold * 2 ? 'critical' : 'warning'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static function checkCriticalErrors(int $criticalCount, int $timeWindow): void
|
||||
{
|
||||
if ($criticalCount >= self::$config['thresholds']['critical_errors']) {
|
||||
self::triggerAlert('critical_errors',
|
||||
"Multiple critical errors detected: {$criticalCount} in {$timeWindow}s",
|
||||
[
|
||||
'critical_count' => $criticalCount,
|
||||
'time_window' => $timeWindow,
|
||||
'threshold' => self::$config['thresholds']['critical_errors']
|
||||
],
|
||||
'critical'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static function checkServiceDown(string $service, array $context = []): void
|
||||
{
|
||||
self::triggerAlert('service_down',
|
||||
"Service unavailable: {$service}",
|
||||
array_merge($context, ['service' => $service]),
|
||||
'critical'
|
||||
);
|
||||
}
|
||||
|
||||
public static function acknowledgeAlert(string $alertId, string $acknowledgedBy): bool
|
||||
{
|
||||
foreach (self::$alerts as &$alert) {
|
||||
if ($alert['id'] === $alertId && $alert['status'] === 'active') {
|
||||
$alert['acknowledged'] = true;
|
||||
$alert['acknowledged_by'] = $acknowledgedBy;
|
||||
$alert['acknowledged_at'] = microtime(true);
|
||||
|
||||
Logger::info("Alert acknowledged: {$alertId} by {$acknowledgedBy}");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function resolveAlert(string $alertId): bool
|
||||
{
|
||||
foreach (self::$alerts as &$alert) {
|
||||
if ($alert['id'] === $alertId && $alert['status'] === 'active') {
|
||||
$alert['status'] = 'resolved';
|
||||
$alert['resolved_at'] = microtime(true);
|
||||
|
||||
Logger::info("Alert resolved: {$alertId}");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function getActiveAlerts(): array
|
||||
{
|
||||
return array_filter(self::$alerts, fn($alert) => $alert['status'] === 'active');
|
||||
}
|
||||
|
||||
public static function getAlerts(array $filters = []): array
|
||||
{
|
||||
$alerts = self::$alerts;
|
||||
|
||||
// 应用过滤器
|
||||
if (!empty($filters)) {
|
||||
$alerts = array_filter($alerts, function($alert) use ($filters) {
|
||||
foreach ($filters as $key => $value) {
|
||||
if (isset($alert[$key]) && $alert[$key] !== $value) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// 按时间倒序排列
|
||||
usort($alerts, function($a, $b) {
|
||||
return $b['timestamp'] <=> $a['timestamp'];
|
||||
});
|
||||
|
||||
return array_values($alerts);
|
||||
}
|
||||
|
||||
public static function getAlertStatistics(): array
|
||||
{
|
||||
$stats = [
|
||||
'total_alerts' => count(self::$alerts),
|
||||
'active_alerts' => 0,
|
||||
'acknowledged_alerts' => 0,
|
||||
'resolved_alerts' => 0,
|
||||
'by_type' => [],
|
||||
'by_severity' => [],
|
||||
'by_hour' => [],
|
||||
'recent_alerts' => 0
|
||||
];
|
||||
|
||||
$now = time();
|
||||
$oneHourAgo = $now - 3600;
|
||||
|
||||
foreach (self::$alerts as $alert) {
|
||||
// 状态统计
|
||||
if ($alert['status'] === 'active') {
|
||||
$stats['active_alerts']++;
|
||||
}
|
||||
if ($alert['acknowledged']) {
|
||||
$stats['acknowledged_alerts']++;
|
||||
}
|
||||
if ($alert['resolved']) {
|
||||
$stats['resolved_alerts']++;
|
||||
}
|
||||
|
||||
// 按类型统计
|
||||
$type = $alert['type'];
|
||||
$stats['by_type'][$type] = ($stats['by_type'][$type] ?? 0) + 1;
|
||||
|
||||
// 按严重程度统计
|
||||
$severity = $alert['severity'];
|
||||
$stats['by_severity'][$severity] = ($stats['by_severity'][$severity] ?? 0) + 1;
|
||||
|
||||
// 按小时统计
|
||||
$hour = date('Y-m-d H:00', (int)$alert['timestamp']);
|
||||
$stats['by_hour'][$hour] = ($stats['by_hour'][$hour] ?? 0) + 1;
|
||||
|
||||
// 最近告警
|
||||
if ($alert['timestamp'] > $oneHourAgo) {
|
||||
$stats['recent_alerts']++;
|
||||
}
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
public static function clearAlerts(): void
|
||||
{
|
||||
self::$alerts = [];
|
||||
}
|
||||
|
||||
public static function clearResolvedAlerts(): void
|
||||
{
|
||||
self::$alerts = array_filter(self::$alerts, fn($alert) => !$alert['resolved']);
|
||||
}
|
||||
|
||||
public static function addChannel(string $name, callable $handler): void
|
||||
{
|
||||
self::$channels[$name] = $handler;
|
||||
}
|
||||
|
||||
public static function removeChannel(string $name): void
|
||||
{
|
||||
unset(self::$channels[$name]);
|
||||
}
|
||||
|
||||
private static function initializeChannels(): void
|
||||
{
|
||||
// 默认日志渠道
|
||||
self::$channels['log'] = function($alert) {
|
||||
$level = match ($alert['severity']) {
|
||||
'critical' => 'critical',
|
||||
'warning' => 'warning',
|
||||
default => 'info'
|
||||
};
|
||||
|
||||
Logger::$level("ALERT [{$alert['type']}]: {$alert['message']}", $alert);
|
||||
};
|
||||
|
||||
// 邮件渠道(如果配置了)
|
||||
if (isset(self::$config['email']) && self::$config['email']['enabled']) {
|
||||
self::$channels['email'] = [self::class, 'sendEmailAlert'];
|
||||
}
|
||||
|
||||
// 钉钉渠道(如果配置了)
|
||||
if (isset(self::$config['dingtalk']) && self::$config['dingtalk']['enabled']) {
|
||||
self::$channels['dingtalk'] = [self::class, 'sendDingTalkAlert'];
|
||||
}
|
||||
|
||||
// Slack渠道(如果配置了)
|
||||
if (isset(self::$config['slack']) && self::$config['slack']['enabled']) {
|
||||
self::$channels['slack'] = [self::class, 'sendSlackAlert'];
|
||||
}
|
||||
}
|
||||
|
||||
private static function sendToChannels(array $alert): void
|
||||
{
|
||||
foreach (self::$config['channels'] as $channelName) {
|
||||
if (isset(self::$channels[$channelName])) {
|
||||
try {
|
||||
self::$channels[$channelName]($alert);
|
||||
} catch (\Throwable $e) {
|
||||
Logger::error("Failed to send alert to channel {$channelName}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static function sendEmailAlert(array $alert): void
|
||||
{
|
||||
$config = self::$config['email'];
|
||||
|
||||
$to = $config['to'] ?? [];
|
||||
$subject = "[Fendx Alert] {$alert['type']} - {$alert['severity']}";
|
||||
$message = self::formatAlertMessage($alert, 'email');
|
||||
|
||||
// 这里应该使用实际的邮件发送库
|
||||
Logger::info("Email alert would be sent to: " . implode(', ', $to));
|
||||
}
|
||||
|
||||
private static function sendDingTalkAlert(array $alert): void
|
||||
{
|
||||
$config = self::$config['dingtalk'];
|
||||
$webhook = $config['webhook'] ?? '';
|
||||
|
||||
if (empty($webhook)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$message = self::formatAlertMessage($alert, 'dingtalk');
|
||||
|
||||
// 这里应该使用实际的HTTP客户端发送到钉钉
|
||||
Logger::info("DingTalk alert would be sent: {$message}");
|
||||
}
|
||||
|
||||
private static function sendSlackAlert(array $alert): void
|
||||
{
|
||||
$config = self::$config['slack'];
|
||||
$webhook = $config['webhook'] ?? '';
|
||||
|
||||
if (empty($webhook)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$message = self::formatAlertMessage($alert, 'slack');
|
||||
|
||||
// 这里应该使用实际的HTTP客户端发送到Slack
|
||||
Logger::info("Slack alert would be sent: {$message}");
|
||||
}
|
||||
|
||||
private static function formatAlertMessage(array $alert, string $format): string
|
||||
{
|
||||
$timestamp = date('Y-m-d H:i:s', (int)$alert['timestamp']);
|
||||
$severity = strtoupper($alert['severity']);
|
||||
|
||||
return match ($format) {
|
||||
'email' => "
|
||||
<h2>[{$severity}] {$alert['type']}</h2>
|
||||
<p><strong>Message:</strong> {$alert['message']}</p>
|
||||
<p><strong>Time:</strong> {$timestamp}</p>
|
||||
<p><strong>Context:</strong> <pre>" . json_encode($alert['context'], JSON_PRETTY_PRINT) . "</pre></p>
|
||||
",
|
||||
'dingtalk' => "【{$severity}】{$alert['type']}\n{$alert['message']}\n时间: {$timestamp}",
|
||||
'slack' => "*[{$severity}] {$alert['type']}*\n{$alert['message']}\nTime: {$timestamp}",
|
||||
default => "[{$severity}] {$alert['type']}: {$alert['message']} at {$timestamp}"
|
||||
};
|
||||
}
|
||||
|
||||
private static function cleanupOldAlerts(): void
|
||||
{
|
||||
if (count(self::$alerts) <= self::$config['max_alerts']) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 按时间排序,保留最新的告警
|
||||
usort(self::$alerts, function($a, $b) {
|
||||
return $a['timestamp'] <=> $b['timestamp'];
|
||||
});
|
||||
|
||||
self::$alerts = array_slice(self::$alerts, -self::$config['max_alerts']);
|
||||
}
|
||||
}
|
||||
661
fendx-framework/fendx-monitor/src/Analyzer/LogAnalyzer.php
Normal file
661
fendx-framework/fendx-monitor/src/Analyzer/LogAnalyzer.php
Normal file
@@ -0,0 +1,661 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\Monitor\Analyzer;
|
||||
|
||||
use Fendx\Log\Logger;
|
||||
|
||||
final class LogAnalyzer
|
||||
{
|
||||
private static array $config = [];
|
||||
private static bool $enabled = true;
|
||||
private static array $logIndex = [];
|
||||
private static array $patterns = [];
|
||||
|
||||
public static function initialize(array $config): void
|
||||
{
|
||||
self::$config = array_merge([
|
||||
'enabled' => true,
|
||||
'log_paths' => [dirname(__DIR__, 4) . '/runtime/logs'],
|
||||
'max_file_size' => 50 * 1024 * 1024, // 50MB
|
||||
'index_cache_ttl' => 300, // 5分钟
|
||||
'search_limit' => 1000,
|
||||
'real_time' => true,
|
||||
'patterns' => [
|
||||
'error' => '/\b(ERROR|FATAL|CRITICAL)\b/i',
|
||||
'warning' => '/\b(WARNING|WARN)\b/i',
|
||||
'exception' => '/\b(Exception|Throwable)\b/i',
|
||||
'sql' => '/\b(SELECT|INSERT|UPDATE|DELETE|CREATE|DROP|ALTER)\b/i',
|
||||
'slow_query' => '/slow.*query|query.*slow/i',
|
||||
'memory' => '/memory|Memory/i',
|
||||
'performance' => '/performance|slow|timeout/i',
|
||||
'security' => '/security|auth|login|logout|unauthorized/i'
|
||||
]
|
||||
], $config);
|
||||
|
||||
self::$enabled = self::$config['enabled'];
|
||||
self::$patterns = self::$config['patterns'];
|
||||
|
||||
if (self::$enabled) {
|
||||
self::buildLogIndex();
|
||||
}
|
||||
}
|
||||
|
||||
public static function search(array $criteria = []): array
|
||||
{
|
||||
if (!self::$enabled) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$results = [];
|
||||
$limit = $criteria['limit'] ?? self::$config['search_limit'];
|
||||
$offset = $criteria['offset'] ?? 0;
|
||||
|
||||
// 构建搜索条件
|
||||
$filters = self::buildFilters($criteria);
|
||||
$sortOrder = $criteria['sort'] ?? 'desc';
|
||||
$sortBy = $criteria['sort_by'] ?? 'timestamp';
|
||||
|
||||
// 搜索日志文件
|
||||
foreach (self::$config['log_paths'] as $logPath) {
|
||||
if (!is_dir($logPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$files = glob($logPath . '/*.log');
|
||||
foreach ($files as $file) {
|
||||
if (filesize($file) > self::$config['max_file_size']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fileResults = self::searchFile($file, $filters, $sortBy, $sortOrder, $limit);
|
||||
$results = array_merge($results, $fileResults);
|
||||
|
||||
if (count($results) >= $limit + $offset) {
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 应用排序和分页
|
||||
$results = self::sortResults($results, $sortBy, $sortOrder);
|
||||
$results = array_slice($results, $offset, $limit);
|
||||
|
||||
return [
|
||||
'logs' => $results,
|
||||
'total' => count($results),
|
||||
'criteria' => $criteria,
|
||||
'search_time' => microtime(true)
|
||||
];
|
||||
}
|
||||
|
||||
public static function aggregate(array $criteria = []): array
|
||||
{
|
||||
if (!self::$enabled) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$timeRange = $criteria['time_range'] ?? '1h';
|
||||
$groupBy = $criteria['group_by'] ?? 'level';
|
||||
$filters = self::buildFilters($criteria);
|
||||
|
||||
$aggregation = [
|
||||
'time_range' => $timeRange,
|
||||
'group_by' => $groupBy,
|
||||
'total_logs' => 0,
|
||||
'groups' => [],
|
||||
'timeline' => [],
|
||||
'top_errors' => [],
|
||||
'patterns' => []
|
||||
];
|
||||
|
||||
// 解析时间范围
|
||||
$timeRangeSeconds = self::parseTimeRange($timeRange);
|
||||
$startTime = time() - $timeRangeSeconds;
|
||||
|
||||
// 统计日志
|
||||
foreach (self::$config['log_paths'] as $logPath) {
|
||||
if (!is_dir($logPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$files = glob($logPath . '/*.log');
|
||||
foreach ($files as $file) {
|
||||
$fileStats = self::analyzeFile($file, $filters, $startTime, $groupBy);
|
||||
$aggregation['total_logs'] += $fileStats['total_logs'];
|
||||
$aggregation['groups'] = array_merge_recursive($aggregation['groups'], $fileStats['groups']);
|
||||
$aggregation['timeline'] = array_merge($aggregation['timeline'], $fileStats['timeline']);
|
||||
$aggregation['top_errors'] = array_merge($aggregation['top_errors'], $fileStats['top_errors']);
|
||||
$aggregation['patterns'] = array_merge_recursive($aggregation['patterns'], $fileStats['patterns']);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理聚合结果
|
||||
$aggregation['timeline'] = self::processTimeline($aggregation['timeline'], $timeRange);
|
||||
$aggregation['top_errors'] = self::getTopItems($aggregation['top_errors'], 10);
|
||||
$aggregation['patterns'] = self::processPatterns($aggregation['patterns']);
|
||||
|
||||
return $aggregation;
|
||||
}
|
||||
|
||||
public static function getLogFiles(): array
|
||||
{
|
||||
if (!self::$enabled) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$files = [];
|
||||
|
||||
foreach (self::$config['log_paths'] as $logPath) {
|
||||
if (!is_dir($logPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$logFiles = glob($logPath . '/*.log');
|
||||
foreach ($logFiles as $file) {
|
||||
$stat = stat($file);
|
||||
$files[] = [
|
||||
'name' => basename($file),
|
||||
'path' => $file,
|
||||
'size' => $stat['size'],
|
||||
'size_formatted' => self::formatBytes($stat['size']),
|
||||
'modified' => $stat['mtime'],
|
||||
'modified_formatted' => date('Y-m-d H:i:s', $stat['mtime']),
|
||||
'lines' => self::countLines($file)
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 按修改时间排序
|
||||
usort($files, function($a, $b) {
|
||||
return $b['modified'] <=> $a['modified'];
|
||||
});
|
||||
|
||||
return $logs;
|
||||
}
|
||||
|
||||
public static function getLogContent(string $file, int $lines = 100, int $offset = 0): array
|
||||
{
|
||||
if (!self::$enabled || !file_exists($file)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$content = [];
|
||||
$handle = fopen($file, 'r');
|
||||
$currentLine = 0;
|
||||
$lineCount = 0;
|
||||
|
||||
if ($handle) {
|
||||
while (($line = fgets($handle)) !== false && $lineCount < $lines + $offset) {
|
||||
$currentLine++;
|
||||
|
||||
if ($currentLine > $offset) {
|
||||
$parsed = self::parseLogLine($line);
|
||||
if ($parsed) {
|
||||
$content[] = $parsed;
|
||||
$lineCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
fclose($handle);
|
||||
}
|
||||
|
||||
return [
|
||||
'content' => $content,
|
||||
'total_lines' => self::countLines($file),
|
||||
'offset' => $offset,
|
||||
'limit' => $lines,
|
||||
'file' => $file
|
||||
];
|
||||
}
|
||||
|
||||
public static function exportLogs(array $criteria = [], string $format = 'json'): string
|
||||
{
|
||||
$results = self::search($criteria);
|
||||
|
||||
return match ($format) {
|
||||
'json' => json_encode($results, JSON_PRETTY_PRINT),
|
||||
'csv' => self::exportToCsv($results['logs']),
|
||||
'txt' => self::exportToText($results['logs']),
|
||||
default => json_encode($results)
|
||||
};
|
||||
}
|
||||
|
||||
public static function getRealTimeLogs(int $tail = 100): array
|
||||
{
|
||||
if (!self::$enabled || !self::$config['real_time']) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$logs = [];
|
||||
|
||||
foreach (self::$config['log_paths'] as $logPath) {
|
||||
if (!is_dir($logPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$files = glob($logPath . '/*.log');
|
||||
foreach ($files as $file) {
|
||||
$fileLogs = self::tailFile($file, $tail);
|
||||
$logs = array_merge($logs, $fileLogs);
|
||||
}
|
||||
}
|
||||
|
||||
// 按时间排序并限制数量
|
||||
usort($logs, function($a, $b) {
|
||||
return $b['timestamp'] <=> $a['timestamp'];
|
||||
});
|
||||
|
||||
return array_slice($logs, 0, $tail);
|
||||
}
|
||||
|
||||
private static function buildLogIndex(): void
|
||||
{
|
||||
self::$logIndex = [];
|
||||
|
||||
foreach (self::$config['log_paths'] as $logPath) {
|
||||
if (!is_dir($logPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$files = glob($logPath . '/*.log');
|
||||
foreach ($files as $file) {
|
||||
self::$logIndex[] = [
|
||||
'file' => $file,
|
||||
'size' => filesize($file),
|
||||
'modified' => filemtime($file)
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static function buildFilters(array $criteria): array
|
||||
{
|
||||
$filters = [];
|
||||
|
||||
if (isset($criteria['level'])) {
|
||||
$filters['level'] = $criteria['level'];
|
||||
}
|
||||
|
||||
if (isset($criteria['message'])) {
|
||||
$filters['message'] = $criteria['message'];
|
||||
}
|
||||
|
||||
if (isset($criteria['trace_id'])) {
|
||||
$filters['trace_id'] = $criteria['trace_id'];
|
||||
}
|
||||
|
||||
if (isset($criteria['start_time'])) {
|
||||
$filters['start_time'] = is_string($criteria['start_time'])
|
||||
? strtotime($criteria['start_time'])
|
||||
: $criteria['start_time'];
|
||||
}
|
||||
|
||||
if (isset($criteria['end_time'])) {
|
||||
$filters['end_time'] = is_string($criteria['end_time'])
|
||||
? strtotime($criteria['end_time'])
|
||||
: $criteria['end_time'];
|
||||
}
|
||||
|
||||
if (isset($criteria['pattern'])) {
|
||||
$filters['pattern'] = $criteria['pattern'];
|
||||
}
|
||||
|
||||
return $filters;
|
||||
}
|
||||
|
||||
private static function searchFile(string $file, array $filters, string $sortBy, string $sortOrder, int $limit): array
|
||||
{
|
||||
$results = [];
|
||||
$handle = fopen($file, 'r');
|
||||
|
||||
if (!$handle) {
|
||||
return $results;
|
||||
}
|
||||
|
||||
while (($line = fgets($handle)) !== false) {
|
||||
$parsed = self::parseLogLine($line);
|
||||
if (!$parsed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (self::matchesFilters($parsed, $filters)) {
|
||||
$results[] = $parsed;
|
||||
|
||||
if (count($results) >= $limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
return $results;
|
||||
}
|
||||
|
||||
private static function parseLogLine(string $line): ?array
|
||||
{
|
||||
// 基础日志格式解析
|
||||
$patterns = [
|
||||
// 标准格式: [2024-01-01 12:00:00] LEVEL.MESSAGE
|
||||
'/^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\] (\w+)\.(.+)$/',
|
||||
// JSON格式
|
||||
'/^({.*})$/',
|
||||
// 简单格式: 2024-01-01 12:00:00 LEVEL MESSAGE
|
||||
'/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) (\w+) (.+)$/'
|
||||
];
|
||||
|
||||
foreach ($patterns as $pattern) {
|
||||
if (preg_match($pattern, trim($line), $matches)) {
|
||||
if (count($matches) === 2 && json_decode($matches[1])) {
|
||||
// JSON格式
|
||||
$data = json_decode($matches[1], true);
|
||||
return array_merge($data, [
|
||||
'raw' => $line,
|
||||
'file' => $file ?? ''
|
||||
]);
|
||||
} else {
|
||||
// 标准格式
|
||||
return [
|
||||
'timestamp' => strtotime($matches[1]),
|
||||
'datetime' => $matches[1],
|
||||
'level' => strtoupper($matches[2]),
|
||||
'message' => $matches[3],
|
||||
'raw' => $line,
|
||||
'file' => $file ?? ''
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function matchesFilters(array $log, array $filters): bool
|
||||
{
|
||||
foreach ($filters as $key => $value) {
|
||||
switch ($key) {
|
||||
case 'level':
|
||||
if (strtoupper($log['level'] ?? '') !== strtoupper($value)) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'message':
|
||||
if (stripos($log['message'] ?? '', $value) === false) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'trace_id':
|
||||
if (stripos($log['raw'] ?? '', $value) === false) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'start_time':
|
||||
if (($log['timestamp'] ?? 0) < $value) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'end_time':
|
||||
if (($log['timestamp'] ?? 0) > $value) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'pattern':
|
||||
if (!preg_match($value, $log['raw'] ?? '')) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static function sortResults(array $results, string $sortBy, string $sortOrder): array
|
||||
{
|
||||
usort($results, function($a, $b) use ($sortBy, $sortOrder) {
|
||||
$comparison = 0;
|
||||
|
||||
switch ($sortBy) {
|
||||
case 'timestamp':
|
||||
$comparison = ($a['timestamp'] ?? 0) <=> ($b['timestamp'] ?? 0);
|
||||
break;
|
||||
case 'level':
|
||||
$levelOrder = ['DEBUG' => 0, 'INFO' => 1, 'WARNING' => 2, 'ERROR' => 3, 'CRITICAL' => 4];
|
||||
$comparison = ($levelOrder[$a['level'] ?? 'INFO'] ?? 1) <=> ($levelOrder[$b['level'] ?? 'INFO'] ?? 1);
|
||||
break;
|
||||
case 'message':
|
||||
$comparison = strcmp($a['message'] ?? '', $b['message'] ?? '');
|
||||
break;
|
||||
}
|
||||
|
||||
return $sortOrder === 'desc' ? -$comparison : $comparison;
|
||||
});
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
private static function analyzeFile(string $file, array $filters, int $startTime, string $groupBy): array
|
||||
{
|
||||
$stats = [
|
||||
'total_logs' => 0,
|
||||
'groups' => [],
|
||||
'timeline' => [],
|
||||
'top_errors' => [],
|
||||
'patterns' => []
|
||||
];
|
||||
|
||||
$handle = fopen($file, 'r');
|
||||
if (!$handle) {
|
||||
return $stats;
|
||||
}
|
||||
|
||||
while (($line = fgets($handle)) !== false) {
|
||||
$parsed = self::parseLogLine($line);
|
||||
if (!$parsed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (($parsed['timestamp'] ?? 0) < $startTime) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!self::matchesFilters($parsed, $filters)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$stats['total_logs']++;
|
||||
|
||||
// 按组统计
|
||||
$groupKey = self::getGroupKey($parsed, $groupBy);
|
||||
if (!isset($stats['groups'][$groupKey])) {
|
||||
$stats['groups'][$groupKey] = 0;
|
||||
}
|
||||
$stats['groups'][$groupKey]++;
|
||||
|
||||
// 时间线统计
|
||||
$timeKey = date('Y-m-d H:00', $parsed['timestamp']);
|
||||
if (!isset($stats['timeline'][$timeKey])) {
|
||||
$stats['timeline'][$timeKey] = 0;
|
||||
}
|
||||
$stats['timeline'][$timeKey]++;
|
||||
|
||||
// 错误统计
|
||||
if (in_array($parsed['level'] ?? '', ['ERROR', 'CRITICAL'])) {
|
||||
$errorMsg = substr($parsed['message'] ?? '', 0, 100);
|
||||
if (!isset($stats['top_errors'][$errorMsg])) {
|
||||
$stats['top_errors'][$errorMsg] = 0;
|
||||
}
|
||||
$stats['top_errors'][$errorMsg]++;
|
||||
}
|
||||
|
||||
// 模式匹配
|
||||
foreach (self::$patterns as $patternName => $pattern) {
|
||||
if (preg_match($pattern, $parsed['raw'] ?? '')) {
|
||||
if (!isset($stats['patterns'][$patternName])) {
|
||||
$stats['patterns'][$patternName] = 0;
|
||||
}
|
||||
$stats['patterns'][$patternName]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
return $stats;
|
||||
}
|
||||
|
||||
private static function getGroupKey(array $log, string $groupBy): string
|
||||
{
|
||||
return match ($groupBy) {
|
||||
'level' => $log['level'] ?? 'UNKNOWN',
|
||||
'hour' => date('Y-m-d H:00', $log['timestamp'] ?? time()),
|
||||
'date' => date('Y-m-d', $log['timestamp'] ?? time()),
|
||||
default => 'default'
|
||||
};
|
||||
}
|
||||
|
||||
private static function parseTimeRange(string $range): int
|
||||
{
|
||||
return match ($range) {
|
||||
'5m' => 5 * 60,
|
||||
'15m' => 15 * 60,
|
||||
'30m' => 30 * 60,
|
||||
'1h' => 60 * 60,
|
||||
'6h' => 6 * 60 * 60,
|
||||
'12h' => 12 * 60 * 60,
|
||||
'24h' => 24 * 60 * 60,
|
||||
'7d' => 7 * 24 * 60 * 60,
|
||||
'30d' => 30 * 24 * 60 * 60,
|
||||
default => 60 * 60
|
||||
};
|
||||
}
|
||||
|
||||
private static function processTimeline(array $timeline, string $timeRange): array
|
||||
{
|
||||
$interval = match ($timeRange) {
|
||||
'5m', '15m', '30m' => '5m',
|
||||
'1h' => '10m',
|
||||
'6h', '12h' => '30m',
|
||||
'24h' => '1h',
|
||||
'7d' => '6h',
|
||||
'30d' => '1d',
|
||||
default => '1h'
|
||||
};
|
||||
|
||||
ksort($timeline);
|
||||
return $timeline;
|
||||
}
|
||||
|
||||
private static function getTopItems(array $items, int $limit): array
|
||||
{
|
||||
arsort($items);
|
||||
return array_slice($items, 0, $limit, true);
|
||||
}
|
||||
|
||||
private static function processPatterns(array $patterns): array
|
||||
{
|
||||
arsort($patterns);
|
||||
return $patterns;
|
||||
}
|
||||
|
||||
private static function countLines(string $file): int
|
||||
{
|
||||
$lines = 0;
|
||||
$handle = fopen($file, 'r');
|
||||
|
||||
if ($handle) {
|
||||
while (!feof($handle)) {
|
||||
fgets($handle);
|
||||
$lines++;
|
||||
}
|
||||
fclose($handle);
|
||||
}
|
||||
|
||||
return $lines;
|
||||
}
|
||||
|
||||
private static function tailFile(string $file, int $lines): array
|
||||
{
|
||||
$result = [];
|
||||
$handle = fopen($file, 'r');
|
||||
|
||||
if (!$handle) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// 移动到文件末尾
|
||||
fseek($handle, 0, SEEK_END);
|
||||
$pos = ftell($handle);
|
||||
$lineCount = 0;
|
||||
|
||||
// 向前读取
|
||||
while ($pos > 0 && $lineCount < $lines) {
|
||||
$pos--;
|
||||
fseek($handle, $pos);
|
||||
$char = fgetc($handle);
|
||||
|
||||
if ($char === "\n") {
|
||||
$lineCount++;
|
||||
if ($lineCount <= $lines) {
|
||||
$currentPos = ftell($handle);
|
||||
$line = fgets($handle);
|
||||
$parsed = self::parseLogLine($line);
|
||||
if ($parsed) {
|
||||
$result[] = $parsed;
|
||||
}
|
||||
fseek($handle, $currentPos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
return array_reverse($result);
|
||||
}
|
||||
|
||||
private static function formatBytes(int $bytes): string
|
||||
{
|
||||
$units = ['B', 'KB', 'MB', 'GB'];
|
||||
$bytes = max($bytes, 0);
|
||||
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
|
||||
$pow = min($pow, count($units) - 1);
|
||||
$bytes /= (1 << (10 * $pow));
|
||||
return round($bytes, 2) . ' ' . $units[$pow];
|
||||
}
|
||||
|
||||
private static function exportToCsv(array $logs): string
|
||||
{
|
||||
$csv = "Timestamp,Level,Message,File\n";
|
||||
|
||||
foreach ($logs as $log) {
|
||||
$csv .= sprintf(
|
||||
"%s,%s,%s,%s\n",
|
||||
$log['datetime'] ?? '',
|
||||
$log['level'] ?? '',
|
||||
str_replace(["\n", "\r", ","], [" ", " ", ";"], $log['message'] ?? ''),
|
||||
basename($log['file'] ?? '')
|
||||
);
|
||||
}
|
||||
|
||||
return $csv;
|
||||
}
|
||||
|
||||
private static function exportToText(array $logs): string
|
||||
{
|
||||
$text = "";
|
||||
|
||||
foreach ($logs as $log) {
|
||||
$text .= sprintf(
|
||||
"[%s] %s.%s\n",
|
||||
$log['datetime'] ?? '',
|
||||
$log['level'] ?? '',
|
||||
$log['message'] ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
return $text;
|
||||
}
|
||||
}
|
||||
371
fendx-framework/fendx-monitor/src/Auth/PermissionInterceptor.php
Normal file
371
fendx-framework/fendx-monitor/src/Auth/PermissionInterceptor.php
Normal file
@@ -0,0 +1,371 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\Monitor\Auth;
|
||||
|
||||
use Fendx\Web\Interceptor\Interceptor;
|
||||
use Fendx\Web\Request\Request;
|
||||
|
||||
class PermissionInterceptor implements Interceptor
|
||||
{
|
||||
private array $routePermissions = [
|
||||
'/monitor/health' => ['monitor.view'],
|
||||
'/monitor/health/*' => ['monitor.view'],
|
||||
'/monitor/metrics' => ['metrics.view'],
|
||||
'/monitor/alerts' => ['alerts.view'],
|
||||
'/monitor/alerts/*' => ['alerts.view'],
|
||||
'/monitor/errors' => ['errors.view'],
|
||||
'/monitor/logs/search' => ['logs.search'],
|
||||
'/monitor/logs/export' => ['logs.export'],
|
||||
'/monitor/logs/content' => ['logs.view'],
|
||||
'/monitor/logs/realtime' => ['logs.view'],
|
||||
'/monitor/logs/charts/*' => ['logs.view'],
|
||||
|
||||
'/admin/dashboard' => ['dashboard.view'],
|
||||
'/admin/system/info' => ['system.view'],
|
||||
'/admin/system/status' => ['system.view'],
|
||||
'/admin/config' => ['config.view'],
|
||||
'/admin/config' => ['config.edit'], // POST
|
||||
'/admin/cache/clear' => ['system.cache_clear'],
|
||||
'/admin/logs/clear' => ['logs.clear'],
|
||||
'/admin/users' => ['users.view'],
|
||||
'/admin/users/*/ban' => ['users.ban'],
|
||||
'/admin/users/*/unban' => ['users.ban'],
|
||||
'/admin/permissions' => ['users.view'],
|
||||
'/admin/audit' => ['audit.view']
|
||||
];
|
||||
|
||||
private array $publicRoutes = [
|
||||
'/monitor/health', // 健康检查通常是公开的
|
||||
'/monitor/metrics/prometheus' // Prometheus监控指标
|
||||
];
|
||||
|
||||
public function preHandle(Request $request): mixed
|
||||
{
|
||||
$path = $request->path();
|
||||
$method = $request->method();
|
||||
|
||||
// 检查是否为公开路由
|
||||
if ($this->isPublicRoute($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 获取所需权限
|
||||
$requiredPermissions = $this->getRequiredPermissions($path, $method);
|
||||
|
||||
if (empty($requiredPermissions)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 检查用户是否已登录
|
||||
$userId = $this->getCurrentUserId();
|
||||
if (!$userId) {
|
||||
return $this->createUnauthorizedResponse();
|
||||
}
|
||||
|
||||
// 检查权限
|
||||
if (!PermissionManager::hasAnyPermission($requiredPermissions, $userId)) {
|
||||
return $this->createForbiddenResponse($requiredPermissions);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function postHandle(Request $request, mixed $result): mixed
|
||||
{
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function afterCompletion(Request $request, ?\Throwable $exception): void
|
||||
{
|
||||
// 记录敏感操作的审计日志
|
||||
$this->logSensitiveOperation($request);
|
||||
}
|
||||
|
||||
private function isPublicRoute(string $path): bool
|
||||
{
|
||||
foreach ($this->publicRoutes as $publicRoute) {
|
||||
if ($this->matchRoute($path, $publicRoute)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private function getRequiredPermissions(string $path, string $method): array
|
||||
{
|
||||
$permissions = [];
|
||||
|
||||
// 精确匹配
|
||||
if (isset($this->routePermissions[$path])) {
|
||||
$permissions = $this->routePermissions[$path];
|
||||
}
|
||||
|
||||
// 通配符匹配
|
||||
foreach ($this->routePermissions as $route => $perms) {
|
||||
if ($this->matchRoute($path, $route)) {
|
||||
$permissions = array_merge($permissions, $perms);
|
||||
}
|
||||
}
|
||||
|
||||
// 特殊处理:POST请求通常需要编辑权限
|
||||
if ($method === 'POST') {
|
||||
$permissions = array_map(function($perm) {
|
||||
return str_replace('.view', '.edit', $perm);
|
||||
}, $permissions);
|
||||
}
|
||||
|
||||
return array_unique($permissions);
|
||||
}
|
||||
|
||||
private function matchRoute(string $path, string $pattern): bool
|
||||
{
|
||||
// 精确匹配
|
||||
if ($path === $pattern) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 通配符匹配
|
||||
if (str_contains($pattern, '*')) {
|
||||
$regex = str_replace('*', '.*', preg_quote($pattern, '/'));
|
||||
return preg_match('/^' . $regex . '$/', $path);
|
||||
}
|
||||
|
||||
// 参数匹配
|
||||
if (str_contains($pattern, '/')) {
|
||||
$patternParts = explode('/', $pattern);
|
||||
$pathParts = explode('/', $path);
|
||||
|
||||
if (count($patternParts) !== count($pathParts)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($patternParts as $i => $part) {
|
||||
if ($part !== '*' && $part !== $pathParts[$i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function getCurrentUserId(): ?int
|
||||
{
|
||||
// 从session获取用户ID
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
return $_SESSION['user_id'] ?? null;
|
||||
}
|
||||
|
||||
private function createUnauthorizedResponse(): array
|
||||
{
|
||||
http_response_code(401);
|
||||
return [
|
||||
'code' => 401,
|
||||
'message' => 'Unauthorized - Please login first',
|
||||
'data' => null
|
||||
];
|
||||
}
|
||||
|
||||
private function createForbiddenResponse(array $requiredPermissions): array
|
||||
{
|
||||
http_response_code(403);
|
||||
return [
|
||||
'code' => 403,
|
||||
'message' => 'Forbidden - Insufficient permissions',
|
||||
'data' => [
|
||||
'required_permissions' => $requiredPermissions,
|
||||
'user_permissions' => PermissionManager::getUserPermissions()
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
private function logSensitiveOperation(Request $request): void
|
||||
{
|
||||
$path = $request->path();
|
||||
$method = $request->method();
|
||||
|
||||
// 定义敏感操作
|
||||
$sensitiveOperations = [
|
||||
'POST:/admin/config',
|
||||
'POST:/admin/cache/clear',
|
||||
'POST:/admin/logs/clear',
|
||||
'POST:/admin/users/*/ban',
|
||||
'POST:/admin/users/*/unban',
|
||||
'POST:/monitor/alerts/*/acknowledge',
|
||||
'POST:/monitor/alerts/*/resolve'
|
||||
];
|
||||
|
||||
$operationKey = $method . ':' . $path;
|
||||
|
||||
if (in_array($operationKey, $sensitiveOperations) ||
|
||||
$this->matchesSensitivePattern($operationKey)) {
|
||||
|
||||
$this->writeAuditLog($request, $operationKey);
|
||||
}
|
||||
}
|
||||
|
||||
private function matchesSensitivePattern(string $operationKey): bool
|
||||
{
|
||||
$patterns = [
|
||||
'POST:/admin/users/*',
|
||||
'POST:/monitor/alerts/*',
|
||||
'POST:/monitor/logs/clear'
|
||||
];
|
||||
|
||||
foreach ($patterns as $pattern) {
|
||||
if ($this->matchOperation($operationKey, $pattern)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function matchOperation(string $operation, string $pattern): bool
|
||||
{
|
||||
if (str_contains($pattern, '*')) {
|
||||
$regex = str_replace('*', '.*', preg_quote($pattern, '/'));
|
||||
return preg_match('/^' . $regex . '$/', $operation);
|
||||
}
|
||||
return $operation === $pattern;
|
||||
}
|
||||
|
||||
private function writeAuditLog(Request $request, string $operation): void
|
||||
{
|
||||
$userId = $this->getCurrentUserId();
|
||||
if (!$userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$log = [
|
||||
'user_id' => $userId,
|
||||
'operation' => $operation,
|
||||
'path' => $request->path(),
|
||||
'method' => $request->method(),
|
||||
'ip' => $request->ip(),
|
||||
'user_agent' => $request->header('User-Agent') ?? 'unknown',
|
||||
'request_data' => $this->sanitizeRequestData($request->all()),
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
|
||||
// 这里应该保存到审计日志表
|
||||
error_log('Audit log: ' . json_encode($log));
|
||||
}
|
||||
|
||||
private function sanitizeRequestData(array $data): array
|
||||
{
|
||||
$sensitiveKeys = ['password', 'token', 'secret', 'key', 'auth'];
|
||||
|
||||
foreach ($data as $key => $value) {
|
||||
foreach ($sensitiveKeys as $sensitive) {
|
||||
if (stripos($key, $sensitive) !== false) {
|
||||
$data[$key] = '[REDACTED]';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
$data[$key] = $this->sanitizeRequestData($value);
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function addRoutePermission(string $route, array $permissions): void
|
||||
{
|
||||
$this->routePermissions[$route] = $permissions;
|
||||
}
|
||||
|
||||
public function removeRoutePermission(string $route): void
|
||||
{
|
||||
unset($this->routePermissions[$route]);
|
||||
}
|
||||
|
||||
public function addPublicRoute(string $route): void
|
||||
{
|
||||
$this->publicRoutes[] = $route;
|
||||
}
|
||||
|
||||
public function removePublicRoute(string $route): void
|
||||
{
|
||||
$key = array_search($route, $this->publicRoutes);
|
||||
if ($key !== false) {
|
||||
unset($this->publicRoutes[$key]);
|
||||
$this->publicRoutes = array_values($this->publicRoutes);
|
||||
}
|
||||
}
|
||||
|
||||
public function getRoutePermissions(): array
|
||||
{
|
||||
return $this->routePermissions;
|
||||
}
|
||||
|
||||
public function getPublicRoutes(): array
|
||||
{
|
||||
return $this->publicRoutes;
|
||||
}
|
||||
|
||||
public function checkRoutePermission(string $path, string $method, ?int $userId = null): bool
|
||||
{
|
||||
$requiredPermissions = $this->getRequiredPermissions($path, $method);
|
||||
|
||||
if (empty($requiredPermissions)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!$userId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return PermissionManager::hasAnyPermission($requiredPermissions, $userId);
|
||||
}
|
||||
|
||||
public function getUserAccessibleRoutes(?int $userId = null): array
|
||||
{
|
||||
$accessibleRoutes = [];
|
||||
|
||||
foreach ($this->routePermissions as $route => $permissions) {
|
||||
if ($this->isPublicRoute($route)) {
|
||||
$accessibleRoutes[] = $route;
|
||||
} elseif ($userId && PermissionManager::hasAnyPermission($permissions, $userId)) {
|
||||
$accessibleRoutes[] = $route;
|
||||
}
|
||||
}
|
||||
|
||||
return $accessibleRoutes;
|
||||
}
|
||||
|
||||
public function exportRoutePermissions(): string
|
||||
{
|
||||
return json_encode([
|
||||
'route_permissions' => $this->routePermissions,
|
||||
'public_routes' => $this->publicRoutes,
|
||||
'export_time' => microtime(true)
|
||||
], JSON_PRETTY_PRINT);
|
||||
}
|
||||
|
||||
public function importRoutePermissions(string $data): bool
|
||||
{
|
||||
try {
|
||||
$import = json_decode($data, true);
|
||||
|
||||
if (!isset($import['route_permissions']) || !isset($import['public_routes'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->routePermissions = $import['route_permissions'];
|
||||
$this->publicRoutes = $import['public_routes'];
|
||||
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
517
fendx-framework/fendx-monitor/src/Auth/PermissionManager.php
Normal file
517
fendx-framework/fendx-monitor/src/Auth/PermissionManager.php
Normal file
@@ -0,0 +1,517 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\Monitor\Auth;
|
||||
|
||||
final class PermissionManager
|
||||
{
|
||||
private static array $permissions = [
|
||||
// 仪表盘权限
|
||||
'dashboard.view' => '查看仪表盘',
|
||||
|
||||
// 监控权限
|
||||
'monitor.view' => '查看监控数据',
|
||||
'monitor.manage' => '管理监控设置',
|
||||
|
||||
// 健康检查权限
|
||||
'health.view' => '查看健康检查',
|
||||
'health.manage' => '管理健康检查',
|
||||
|
||||
// 日志权限
|
||||
'logs.view' => '查看日志',
|
||||
'logs.search' => '搜索日志',
|
||||
'logs.export' => '导出日志',
|
||||
'logs.clear' => '清理日志',
|
||||
|
||||
// 错误追踪权限
|
||||
'errors.view' => '查看错误',
|
||||
'errors.manage' => '管理错误设置',
|
||||
|
||||
// 告警权限
|
||||
'alerts.view' => '查看告警',
|
||||
'alerts.acknowledge' => '确认告警',
|
||||
'alerts.resolve' => '解决告警',
|
||||
'alerts.manage' => '管理告警设置',
|
||||
|
||||
// 指标权限
|
||||
'metrics.view' => '查看指标',
|
||||
'metrics.export' => '导出指标',
|
||||
'metrics.manage' => '管理指标设置',
|
||||
|
||||
// 用户管理权限
|
||||
'users.view' => '查看用户',
|
||||
'users.manage' => '管理用户',
|
||||
'users.ban' => '封禁用户',
|
||||
|
||||
// 配置权限
|
||||
'config.view' => '查看配置',
|
||||
'config.edit' => '编辑配置',
|
||||
|
||||
// 系统权限
|
||||
'system.view' => '查看系统信息',
|
||||
'system.cache_clear' => '清理缓存',
|
||||
'system.restart' => '重启服务',
|
||||
|
||||
// 审计权限
|
||||
'audit.view' => '查看审计日志'
|
||||
];
|
||||
|
||||
private static array $roles = [
|
||||
'super_admin' => [
|
||||
'name' => '超级管理员',
|
||||
'permissions' => ['*'] // 所有权限
|
||||
],
|
||||
'admin' => [
|
||||
'name' => '管理员',
|
||||
'permissions' => [
|
||||
'dashboard.view',
|
||||
'monitor.view', 'monitor.manage',
|
||||
'health.view', 'health.manage',
|
||||
'logs.view', 'logs.search', 'logs.export', 'logs.clear',
|
||||
'errors.view', 'errors.manage',
|
||||
'alerts.view', 'alerts.acknowledge', 'alerts.resolve', 'alerts.manage',
|
||||
'metrics.view', 'metrics.export', 'metrics.manage',
|
||||
'users.view', 'users.manage', 'users.ban',
|
||||
'config.view', 'config.edit',
|
||||
'system.view', 'system.cache_clear',
|
||||
'audit.view'
|
||||
]
|
||||
],
|
||||
'operator' => [
|
||||
'name' => '运维人员',
|
||||
'permissions' => [
|
||||
'dashboard.view',
|
||||
'monitor.view',
|
||||
'health.view',
|
||||
'logs.view', 'logs.search',
|
||||
'errors.view',
|
||||
'alerts.view', 'alerts.acknowledge', 'alerts.resolve',
|
||||
'metrics.view',
|
||||
'config.view',
|
||||
'system.view',
|
||||
'audit.view'
|
||||
]
|
||||
],
|
||||
'developer' => [
|
||||
'name' => '开发人员',
|
||||
'permissions' => [
|
||||
'dashboard.view',
|
||||
'monitor.view',
|
||||
'health.view',
|
||||
'logs.view', 'logs.search', 'logs.export',
|
||||
'errors.view',
|
||||
'alerts.view',
|
||||
'metrics.view', 'metrics.export',
|
||||
'config.view',
|
||||
'system.view'
|
||||
]
|
||||
],
|
||||
'viewer' => [
|
||||
'name' => '只读用户',
|
||||
'permissions' => [
|
||||
'dashboard.view',
|
||||
'monitor.view',
|
||||
'health.view',
|
||||
'logs.view',
|
||||
'errors.view',
|
||||
'alerts.view',
|
||||
'metrics.view',
|
||||
'config.view',
|
||||
'system.view'
|
||||
]
|
||||
]
|
||||
];
|
||||
|
||||
private static array $userPermissions = [];
|
||||
private static bool $initialized = false;
|
||||
|
||||
public static function initialize(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 从数据库或配置文件加载用户权限
|
||||
self::loadUserPermissions();
|
||||
self::$initialized = true;
|
||||
}
|
||||
|
||||
public static function hasPermission(string $permission, ?int $userId = null): bool
|
||||
{
|
||||
self::initialize();
|
||||
|
||||
$userId = $userId ?? self::getCurrentUserId();
|
||||
if (!$userId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查用户权限
|
||||
$userPerms = self::$userPermissions[$userId] ?? [];
|
||||
|
||||
// 超级权限
|
||||
if (in_array('*', $userPerms)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 直接权限
|
||||
if (in_array($permission, $userPerms)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 通配符权限
|
||||
foreach ($userPerms as $perm) {
|
||||
if (str_ends_with($perm, '*')) {
|
||||
$prefix = substr($perm, 0, -1);
|
||||
if (str_starts_with($permission, $prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function hasAnyPermission(array $permissions, ?int $userId = null): bool
|
||||
{
|
||||
foreach ($permissions as $permission) {
|
||||
if (self::hasPermission($permission, $userId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function hasAllPermissions(array $permissions, ?int $userId = null): bool
|
||||
{
|
||||
foreach ($permissions as $permission) {
|
||||
if (!self::hasPermission($permission, $userId)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function getUserPermissions(?int $userId = null): array
|
||||
{
|
||||
self::initialize();
|
||||
|
||||
$userId = $userId ?? self::getCurrentUserId();
|
||||
if (!$userId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return self::$userPermissions[$userId] ?? [];
|
||||
}
|
||||
|
||||
public static function setUserPermissions(int $userId, array $permissions): void
|
||||
{
|
||||
self::$userPermissions[$userId] = $permissions;
|
||||
self::saveUserPermissions($userId, $permissions);
|
||||
}
|
||||
|
||||
public static function addUserPermission(int $userId, string $permission): void
|
||||
{
|
||||
$permissions = self::getUserPermissions($userId);
|
||||
if (!in_array($permission, $permissions)) {
|
||||
$permissions[] = $permission;
|
||||
self::setUserPermissions($userId, $permissions);
|
||||
}
|
||||
}
|
||||
|
||||
public static function removeUserPermission(int $userId, string $permission): void
|
||||
{
|
||||
$permissions = self::getUserPermissions($userId);
|
||||
$key = array_search($permission, $permissions);
|
||||
if ($key !== false) {
|
||||
unset($permissions[$key]);
|
||||
$permissions = array_values($permissions);
|
||||
self::setUserPermissions($userId, $permissions);
|
||||
}
|
||||
}
|
||||
|
||||
public static function assignRole(int $userId, string $role): bool
|
||||
{
|
||||
if (!isset(self::$roles[$role])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$permissions = self::$roles[$role]['permissions'];
|
||||
self::setUserPermissions($userId, $permissions);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function getUserRole(?int $userId = null): ?string
|
||||
{
|
||||
$userPerms = self::getUserPermissions($userId);
|
||||
|
||||
foreach (self::$roles as $role => $config) {
|
||||
if ($config['permissions'] === $userPerms) {
|
||||
return $role;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function getAllPermissions(): array
|
||||
{
|
||||
return self::$permissions;
|
||||
}
|
||||
|
||||
public static function getAllRoles(): array
|
||||
{
|
||||
return self::$roles;
|
||||
}
|
||||
|
||||
public static function getPermissionName(string $permission): string
|
||||
{
|
||||
return self::$permissions[$permission] ?? $permission;
|
||||
}
|
||||
|
||||
public static function getRoleName(string $role): string
|
||||
{
|
||||
return self::$roles[$role]['name'] ?? $role;
|
||||
}
|
||||
|
||||
public static function getRolePermissions(string $role): array
|
||||
{
|
||||
return self::$roles[$role]['permissions'] ?? [];
|
||||
}
|
||||
|
||||
public static function createRole(string $role, string $name, array $permissions): bool
|
||||
{
|
||||
if (isset(self::$roles[$role])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
self::$roles[$role] = [
|
||||
'name' => $name,
|
||||
'permissions' => $permissions
|
||||
];
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function updateRole(string $role, string $name, array $permissions): bool
|
||||
{
|
||||
if (!isset(self::$roles[$role])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
self::$roles[$role] = [
|
||||
'name' => $name,
|
||||
'permissions' => $permissions
|
||||
];
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function deleteRole(string $role): bool
|
||||
{
|
||||
if (!isset(self::$roles[$role]) || $role === 'super_admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
unset(self::$roles[$role]);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function checkPermission(string $permission): bool
|
||||
{
|
||||
if (!self::hasPermission($permission)) {
|
||||
throw new \RuntimeException("Permission denied: {$permission}");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function requirePermission(string $permission): void
|
||||
{
|
||||
if (!self::hasPermission($permission)) {
|
||||
http_response_code(403);
|
||||
echo json_encode([
|
||||
'code' => 403,
|
||||
'message' => 'Permission denied',
|
||||
'data' => ['required_permission' => $permission]
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
public static function requireAnyPermission(array $permissions): void
|
||||
{
|
||||
if (!self::hasAnyPermission($permissions)) {
|
||||
http_response_code(403);
|
||||
echo json_encode([
|
||||
'code' => 403,
|
||||
'message' => 'Permission denied',
|
||||
'data' => ['required_permissions' => $permissions]
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
public static function requireAllPermissions(array $permissions): void
|
||||
{
|
||||
if (!self::hasAllPermissions($permissions)) {
|
||||
http_response_code(403);
|
||||
echo json_encode([
|
||||
'code' => 403,
|
||||
'message' => 'Permission denied',
|
||||
'data' => ['required_permissions' => $permissions]
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
public static function filterByPermission(array $data, string $permissionField = 'permission'): array
|
||||
{
|
||||
return array_filter($data, function($item) use ($permissionField) {
|
||||
$permission = $item[$permissionField] ?? '';
|
||||
return self::hasPermission($permission);
|
||||
});
|
||||
}
|
||||
|
||||
public static function getPermissionTree(): array
|
||||
{
|
||||
$tree = [];
|
||||
|
||||
foreach (self::$permissions as $permission => $name) {
|
||||
$parts = explode('.', $permission);
|
||||
$current = &$tree;
|
||||
|
||||
foreach ($parts as $part) {
|
||||
if (!isset($current[$part])) {
|
||||
$current[$part] = [];
|
||||
}
|
||||
$current = &$current[$part];
|
||||
}
|
||||
|
||||
$current['_name'] = $name;
|
||||
$current['_permission'] = $permission;
|
||||
}
|
||||
|
||||
return $tree;
|
||||
}
|
||||
|
||||
private static function getCurrentUserId(): ?int
|
||||
{
|
||||
// 从session获取用户ID
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
return $_SESSION['user_id'] ?? null;
|
||||
}
|
||||
|
||||
private static function loadUserPermissions(): void
|
||||
{
|
||||
// 这里应该从数据库加载用户权限
|
||||
// 暂时使用模拟数据
|
||||
|
||||
// 模拟数据:用户ID为1的是超级管理员
|
||||
self::$userPermissions[1] = ['*'];
|
||||
|
||||
// 模拟数据:用户ID为2的是管理员
|
||||
self::$userPermissions[2] = self::$roles['admin']['permissions'];
|
||||
|
||||
// 模拟数据:用户ID为3的是运维人员
|
||||
self::$userPermissions[3] = self::$roles['operator']['permissions'];
|
||||
}
|
||||
|
||||
private static function saveUserPermissions(int $userId, array $permissions): void
|
||||
{
|
||||
// 这里应该保存到数据库
|
||||
// 暂时只保存在内存中
|
||||
|
||||
// 记录审计日志
|
||||
self::logPermissionChange($userId, $permissions);
|
||||
}
|
||||
|
||||
private static function logPermissionChange(int $userId, array $permissions): void
|
||||
{
|
||||
$log = [
|
||||
'user_id' => self::getCurrentUserId(),
|
||||
'target_user_id' => $userId,
|
||||
'action' => 'permission_change',
|
||||
'permissions' => $permissions,
|
||||
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
|
||||
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown',
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
|
||||
// 这里应该保存到审计日志表
|
||||
error_log('Permission change: ' . json_encode($log));
|
||||
}
|
||||
|
||||
public static function getAuditLog(array $filters = []): array
|
||||
{
|
||||
// 这里应该从数据库获取审计日志
|
||||
// 暂时返回模拟数据
|
||||
|
||||
return [
|
||||
[
|
||||
'id' => 1,
|
||||
'user_id' => 1,
|
||||
'target_user_id' => 2,
|
||||
'action' => 'permission_change',
|
||||
'details' => ['permissions' => ['dashboard.view', 'monitor.view']],
|
||||
'ip' => '127.0.0.1',
|
||||
'user_agent' => 'Mozilla/5.0...',
|
||||
'timestamp' => microtime(true)
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public static function validatePermission(string $permission): bool
|
||||
{
|
||||
return isset(self::$permissions[$permission]);
|
||||
}
|
||||
|
||||
public static function validateRole(string $role): bool
|
||||
{
|
||||
return isset(self::$roles[$role]);
|
||||
}
|
||||
|
||||
public static function getPermissionsByCategory(): array
|
||||
{
|
||||
$categories = [];
|
||||
|
||||
foreach (self::$permissions as $permission => $name) {
|
||||
$category = explode('.', $permission)[0];
|
||||
if (!isset($categories[$category])) {
|
||||
$categories[$category] = [];
|
||||
}
|
||||
$categories[$category][$permission] = $name;
|
||||
}
|
||||
|
||||
return $categories;
|
||||
}
|
||||
|
||||
public static function exportPermissions(): string
|
||||
{
|
||||
return json_encode([
|
||||
'permissions' => self::$permissions,
|
||||
'roles' => self::$roles,
|
||||
'export_time' => microtime(true)
|
||||
], JSON_PRETTY_PRINT);
|
||||
}
|
||||
|
||||
public static function importPermissions(string $data): bool
|
||||
{
|
||||
try {
|
||||
$import = json_decode($data, true);
|
||||
|
||||
if (!isset($import['permissions']) || !isset($import['roles'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
self::$permissions = $import['permissions'];
|
||||
self::$roles = $import['roles'];
|
||||
|
||||
return true;
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
347
fendx-framework/fendx-monitor/src/Collector/MetricsCollector.php
Normal file
347
fendx-framework/fendx-monitor/src/Collector/MetricsCollector.php
Normal file
@@ -0,0 +1,347 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\Monitor\Collector;
|
||||
|
||||
use Fendx\Core\Context\Context;
|
||||
|
||||
final class MetricsCollector
|
||||
{
|
||||
private static array $metrics = [];
|
||||
private static array $timers = [];
|
||||
private static array $counters = [];
|
||||
private static array $gauges = [];
|
||||
private static array $histograms = [];
|
||||
|
||||
public static function startTimer(string $name): void
|
||||
{
|
||||
self::$timers[$name] = [
|
||||
'start_time' => microtime(true),
|
||||
'start_memory' => memory_get_usage(true),
|
||||
'trace_id' => Context::getTraceId()
|
||||
];
|
||||
}
|
||||
|
||||
public static function endTimer(string $name): float
|
||||
{
|
||||
if (!isset(self::$timers[$name])) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$timer = self::$timers[$name];
|
||||
$duration = microtime(true) - $timer['start_time'];
|
||||
$memoryUsed = memory_get_usage(true) - $timer['start_memory'];
|
||||
|
||||
self::recordMetric($name, [
|
||||
'type' => 'timer',
|
||||
'duration' => $duration,
|
||||
'memory_used' => $memoryUsed,
|
||||
'trace_id' => $timer['trace_id'],
|
||||
'timestamp' => microtime(true)
|
||||
]);
|
||||
|
||||
unset(self::$timers[$name]);
|
||||
return $duration;
|
||||
}
|
||||
|
||||
public static function incrementCounter(string $name, float $value = 1.0, array $tags = []): void
|
||||
{
|
||||
$key = self::buildKey($name, $tags);
|
||||
|
||||
if (!isset(self::$counters[$key])) {
|
||||
self::$counters[$key] = 0.0;
|
||||
}
|
||||
|
||||
self::$counters[$key] += $value;
|
||||
|
||||
self::recordMetric($name, [
|
||||
'type' => 'counter',
|
||||
'value' => self::$counters[$key],
|
||||
'increment' => $value,
|
||||
'tags' => $tags,
|
||||
'trace_id' => Context::getTraceId(),
|
||||
'timestamp' => microtime(true)
|
||||
]);
|
||||
}
|
||||
|
||||
public static function setGauge(string $name, float $value, array $tags = []): void
|
||||
{
|
||||
$key = self::buildKey($name, $tags);
|
||||
self::$gauges[$key] = $value;
|
||||
|
||||
self::recordMetric($name, [
|
||||
'type' => 'gauge',
|
||||
'value' => $value,
|
||||
'tags' => $tags,
|
||||
'trace_id' => Context::getTraceId(),
|
||||
'timestamp' => microtime(true)
|
||||
]);
|
||||
}
|
||||
|
||||
public static function recordHistogram(string $name, float $value, array $tags = []): void
|
||||
{
|
||||
$key = self::buildKey($name, $tags);
|
||||
|
||||
if (!isset(self::$histograms[$key])) {
|
||||
self::$histograms[$key] = [];
|
||||
}
|
||||
|
||||
self::$histograms[$key][] = $value;
|
||||
|
||||
self::recordMetric($name, [
|
||||
'type' => 'histogram',
|
||||
'value' => $value,
|
||||
'count' => count(self::$histograms[$key]),
|
||||
'sum' => array_sum(self::$histograms[$key]),
|
||||
'tags' => $tags,
|
||||
'trace_id' => Context::getTraceId(),
|
||||
'timestamp' => microtime(true)
|
||||
]);
|
||||
}
|
||||
|
||||
public static function recordHttpRequest(string $method, string $path, int $statusCode, float $duration): void
|
||||
{
|
||||
self::recordHistogram('http_request_duration', $duration, [
|
||||
'method' => $method,
|
||||
'path' => $path,
|
||||
'status' => (string)$statusCode
|
||||
]);
|
||||
|
||||
self::incrementCounter('http_requests_total', 1.0, [
|
||||
'method' => $method,
|
||||
'path' => $path,
|
||||
'status' => (string)$statusCode
|
||||
]);
|
||||
}
|
||||
|
||||
public static function recordDatabaseQuery(string $query, float $duration, bool $success = true): void
|
||||
{
|
||||
$queryType = self::extractQueryType($query);
|
||||
|
||||
self::recordHistogram('db_query_duration', $duration, [
|
||||
'query_type' => $queryType,
|
||||
'success' => $success ? 'true' : 'false'
|
||||
]);
|
||||
|
||||
self::incrementCounter('db_queries_total', 1.0, [
|
||||
'query_type' => $queryType,
|
||||
'success' => $success ? 'true' : 'false'
|
||||
]);
|
||||
}
|
||||
|
||||
public static function recordCacheOperation(string $operation, string $key, bool $hit): void
|
||||
{
|
||||
self::incrementCounter('cache_operations_total', 1.0, [
|
||||
'operation' => $operation,
|
||||
'hit' => $hit ? 'true' : 'false'
|
||||
]);
|
||||
}
|
||||
|
||||
public static function recordError(string $type, string $message, array $context = []): void
|
||||
{
|
||||
self::incrementCounter('errors_total', 1.0, [
|
||||
'type' => $type
|
||||
]);
|
||||
|
||||
self::recordMetric('error', [
|
||||
'type' => 'error',
|
||||
'error_type' => $type,
|
||||
'message' => $message,
|
||||
'context' => $context,
|
||||
'trace_id' => Context::getTraceId(),
|
||||
'timestamp' => microtime(true)
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getMetrics(): array
|
||||
{
|
||||
return self::$metrics;
|
||||
}
|
||||
|
||||
public static function getCounters(): array
|
||||
{
|
||||
return self::$counters;
|
||||
}
|
||||
|
||||
public static function getGauges(): array
|
||||
{
|
||||
return self::$gauges;
|
||||
}
|
||||
|
||||
public static function getHistograms(): array
|
||||
{
|
||||
$result = [];
|
||||
|
||||
foreach (self::$histograms as $key => $values) {
|
||||
if (empty($values)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
sort($values);
|
||||
$count = count($values);
|
||||
$sum = array_sum($values);
|
||||
|
||||
$result[$key] = [
|
||||
'count' => $count,
|
||||
'sum' => $sum,
|
||||
'min' => $values[0],
|
||||
'max' => $values[$count - 1],
|
||||
'mean' => $sum / $count,
|
||||
'p50' => $values[(int)($count * 0.5)],
|
||||
'p95' => $values[(int)($count * 0.95)],
|
||||
'p99' => $values[(int)($count * 0.99)]
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public static function getSystemMetrics(): array
|
||||
{
|
||||
return [
|
||||
'memory_usage' => memory_get_usage(true),
|
||||
'memory_peak' => memory_get_peak_usage(true),
|
||||
'memory_limit' => self::getMemoryLimit(),
|
||||
'cpu_usage' => self::getCpuUsage(),
|
||||
'load_average' => sys_getloadavg(),
|
||||
'uptime' => self::getUptime(),
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
}
|
||||
|
||||
public static function clear(): void
|
||||
{
|
||||
self::$metrics = [];
|
||||
self::$timers = [];
|
||||
self::$counters = [];
|
||||
self::$gauges = [];
|
||||
self::$histograms = [];
|
||||
}
|
||||
|
||||
private static function recordMetric(string $name, array $data): void
|
||||
{
|
||||
if (!isset(self::$metrics[$name])) {
|
||||
self::$metrics[$name] = [];
|
||||
}
|
||||
|
||||
self::$metrics[$name][] = $data;
|
||||
|
||||
// 限制每个指标最多保存1000条记录
|
||||
if (count(self::$metrics[$name]) > 1000) {
|
||||
self::$metrics[$name] = array_slice(self::$metrics[$name], -1000);
|
||||
}
|
||||
}
|
||||
|
||||
private static function buildKey(string $name, array $tags): string
|
||||
{
|
||||
if (empty($tags)) {
|
||||
return $name;
|
||||
}
|
||||
|
||||
ksort($tags);
|
||||
$tagPairs = [];
|
||||
|
||||
foreach ($tags as $key => $value) {
|
||||
$tagPairs[] = $key . ':' . $value;
|
||||
}
|
||||
|
||||
return $name . '{' . implode(',', $tagPairs) . '}';
|
||||
}
|
||||
|
||||
private static function extractQueryType(string $query): string
|
||||
{
|
||||
$query = trim(strtoupper($query));
|
||||
|
||||
if (str_starts_with($query, 'SELECT')) {
|
||||
return 'SELECT';
|
||||
} elseif (str_starts_with($query, 'INSERT')) {
|
||||
return 'INSERT';
|
||||
} elseif (str_starts_with($query, 'UPDATE')) {
|
||||
return 'UPDATE';
|
||||
} elseif (str_starts_with($query, 'DELETE')) {
|
||||
return 'DELETE';
|
||||
} elseif (str_starts_with($query, 'CREATE')) {
|
||||
return 'CREATE';
|
||||
} elseif (str_starts_with($query, 'DROP')) {
|
||||
return 'DROP';
|
||||
} elseif (str_starts_with($query, 'ALTER')) {
|
||||
return 'ALTER';
|
||||
}
|
||||
|
||||
return 'OTHER';
|
||||
}
|
||||
|
||||
private static function getMemoryLimit(): int
|
||||
{
|
||||
$limit = ini_get('memory_limit');
|
||||
|
||||
if ($limit === '-1') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return self::parseMemoryLimit($limit);
|
||||
}
|
||||
|
||||
private static function parseMemoryLimit(string $limit): int
|
||||
{
|
||||
$unit = strtoupper(substr($limit, -1));
|
||||
$value = (int)substr($limit, 0, -1);
|
||||
|
||||
return match ($unit) {
|
||||
'G' => $value * 1024 * 1024 * 1024,
|
||||
'M' => $value * 1024 * 1024,
|
||||
'K' => $value * 1024,
|
||||
default => (int)$limit
|
||||
};
|
||||
}
|
||||
|
||||
private static function getCpuUsage(): float
|
||||
{
|
||||
if (function_exists('sys_getloadavg')) {
|
||||
$load = sys_getloadavg();
|
||||
return $load[0] ?? 0.0;
|
||||
}
|
||||
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
private static function getUptime(): int
|
||||
{
|
||||
if (function_exists('shell_exec')) {
|
||||
$uptime = shell_exec('cat /proc/uptime | cut -d" " -f1');
|
||||
if ($uptime) {
|
||||
return (int)$uptime;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
public static function exportPrometheus(): string
|
||||
{
|
||||
$output = [];
|
||||
|
||||
// 导出计数器
|
||||
foreach (self::$counters as $key => $value) {
|
||||
$output[] = "# TYPE " . str_replace(['{', '}', ':', ','], '_', $key) . " counter";
|
||||
$output[] = str_replace(['{', '}', ':', ','], '_', $key) . " " . $value;
|
||||
}
|
||||
|
||||
// 导出仪表盘
|
||||
foreach (self::$gauges as $key => $value) {
|
||||
$output[] = "# TYPE " . str_replace(['{', '}', ':', ','], '_', $key) . " gauge";
|
||||
$output[] = str_replace(['{', '}', ':', ','], '_', $key) . " " . $value;
|
||||
}
|
||||
|
||||
// 导出直方图
|
||||
foreach (self::getHistograms() as $key => $stats) {
|
||||
$cleanKey = str_replace(['{', '}', ':', ','], '_', $key);
|
||||
$output[] = "# TYPE " . $cleanKey . " histogram";
|
||||
$output[] = $cleanKey . "_count " . $stats['count'];
|
||||
$output[] = $cleanKey . "_sum " . $stats['sum'];
|
||||
$output[] = $cleanKey . "_bucket{le=\"+Inf\"} " . $stats['count'];
|
||||
}
|
||||
|
||||
return implode("\n", $output);
|
||||
}
|
||||
}
|
||||
531
fendx-framework/fendx-monitor/src/Health/HealthChecker.php
Normal file
531
fendx-framework/fendx-monitor/src/Health/HealthChecker.php
Normal file
@@ -0,0 +1,531 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\Monitor\Health;
|
||||
|
||||
use Fendx\Db\DB;
|
||||
use Fendx\Cache\Cache;
|
||||
use Fendx\Log\Logger;
|
||||
use Fendx\File\FileManager;
|
||||
|
||||
final class HealthChecker
|
||||
{
|
||||
private array $checks = [];
|
||||
private array $config = [];
|
||||
|
||||
public function __construct(array $config = [])
|
||||
{
|
||||
$this->config = array_merge([
|
||||
'timeout' => 5.0,
|
||||
'retries' => 3,
|
||||
'enabled_checks' => [
|
||||
'database',
|
||||
'cache',
|
||||
'filesystem',
|
||||
'memory',
|
||||
'disk',
|
||||
'external_services'
|
||||
]
|
||||
], $config);
|
||||
|
||||
$this->initializeChecks();
|
||||
}
|
||||
|
||||
public function check(): array
|
||||
{
|
||||
$results = [];
|
||||
$overallStatus = 'healthy';
|
||||
$startTime = microtime(true);
|
||||
|
||||
foreach ($this->checks as $name => $checker) {
|
||||
if (!in_array($name, $this->config['enabled_checks'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$checkStart = microtime(true);
|
||||
|
||||
try {
|
||||
$result = $this->executeCheck($checker);
|
||||
$result['duration'] = microtime(true) - $checkStart;
|
||||
$results[$name] = $result;
|
||||
|
||||
if ($result['status'] === 'critical') {
|
||||
$overallStatus = 'critical';
|
||||
} elseif ($result['status'] === 'warning' && $overallStatus !== 'critical') {
|
||||
$overallStatus = 'warning';
|
||||
}
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$results[$name] = [
|
||||
'status' => 'critical',
|
||||
'message' => 'Health check failed: ' . $e->getMessage(),
|
||||
'duration' => microtime(true) - $checkStart,
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
$overallStatus = 'critical';
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => $overallStatus,
|
||||
'timestamp' => microtime(true),
|
||||
'duration' => microtime(true) - $startTime,
|
||||
'checks' => $results,
|
||||
'summary' => $this->generateSummary($results)
|
||||
];
|
||||
}
|
||||
|
||||
public function checkIndividual(string $name): array
|
||||
{
|
||||
if (!isset($this->checks[$name])) {
|
||||
throw new \InvalidArgumentException("Health check '$name' not found");
|
||||
}
|
||||
|
||||
$checkStart = microtime(true);
|
||||
|
||||
try {
|
||||
$result = $this->executeCheck($this->checks[$name]);
|
||||
$result['duration'] = microtime(true) - $checkStart;
|
||||
return $result;
|
||||
} catch (\Throwable $e) {
|
||||
return [
|
||||
'status' => 'critical',
|
||||
'message' => 'Health check failed: ' . $e->getMessage(),
|
||||
'duration' => microtime(true) - $checkStart,
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
public function addCheck(string $name, callable $checker): void
|
||||
{
|
||||
$this->checks[$name] = $checker;
|
||||
}
|
||||
|
||||
public function removeCheck(string $name): void
|
||||
{
|
||||
unset($this->checks[$name]);
|
||||
}
|
||||
|
||||
public function getAvailableChecks(): array
|
||||
{
|
||||
return array_keys($this->checks);
|
||||
}
|
||||
|
||||
private function initializeChecks(): void
|
||||
{
|
||||
$this->checks['database'] = [$this, 'checkDatabase'];
|
||||
$this->checks['cache'] = [$this, 'checkCache'];
|
||||
$this->checks['filesystem'] = [$this, 'checkFilesystem'];
|
||||
$this->checks['memory'] = [$this, 'checkMemory'];
|
||||
$this->checks['disk'] = [$this, 'checkDisk'];
|
||||
$this->checks['external_services'] = [$this, 'checkExternalServices'];
|
||||
}
|
||||
|
||||
private function executeCheck(callable $checker): array
|
||||
{
|
||||
$result = $checker();
|
||||
|
||||
if (!isset($result['status'])) {
|
||||
throw new \RuntimeException('Health check must return a status');
|
||||
}
|
||||
|
||||
if (!in_array($result['status'], ['healthy', 'warning', 'critical'])) {
|
||||
throw new \RuntimeException('Invalid health check status: ' . $result['status']);
|
||||
}
|
||||
|
||||
$result['timestamp'] = microtime(true);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function checkDatabase(): array
|
||||
{
|
||||
try {
|
||||
$start = microtime(true);
|
||||
$pdo = DB::pdo();
|
||||
$pdo->query('SELECT 1')->fetch();
|
||||
$duration = microtime(true) - $start;
|
||||
|
||||
// 获取数据库连接信息
|
||||
$status = [
|
||||
'connected' => true,
|
||||
'response_time' => $duration,
|
||||
'version' => $pdo->getAttribute(\PDO::ATTR_SERVER_VERSION) ?? 'unknown',
|
||||
'driver' => $pdo->getAttribute(\PDO::ATTR_DRIVER_NAME)
|
||||
];
|
||||
|
||||
// 检查连接池状态(如果支持)
|
||||
try {
|
||||
$status['active_connections'] = $this->getActiveConnections($pdo);
|
||||
} catch (\Throwable $e) {
|
||||
$status['active_connections'] = 'unknown';
|
||||
}
|
||||
|
||||
// 检查数据库大小
|
||||
try {
|
||||
$status['database_size'] = $this->getDatabaseSize($pdo);
|
||||
} catch (\Throwable $e) {
|
||||
$status['database_size'] = 'unknown';
|
||||
}
|
||||
|
||||
$status['status'] = $duration < 1.0 ? 'healthy' : 'warning';
|
||||
$status['message'] = $duration < 1.0 ? 'Database connection OK' : 'Database response slow';
|
||||
|
||||
return $status;
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
return [
|
||||
'status' => 'critical',
|
||||
'message' => 'Database connection failed: ' . $e->getMessage(),
|
||||
'connected' => false,
|
||||
'error' => $e->getMessage()
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private function checkCache(): array
|
||||
{
|
||||
try {
|
||||
$start = microtime(true);
|
||||
|
||||
// 测试写入
|
||||
$testKey = 'health_check_' . time();
|
||||
Cache::set($testKey, 'ok', 10);
|
||||
|
||||
// 测试读取
|
||||
$value = Cache::get($testKey);
|
||||
|
||||
// 清理测试数据
|
||||
Cache::delete($testKey);
|
||||
|
||||
$duration = microtime(true) - $start;
|
||||
|
||||
$status = [
|
||||
'connected' => true,
|
||||
'response_time' => $duration,
|
||||
'read_write_test' => $value === 'ok',
|
||||
'type' => 'redis' // 可以根据实际配置动态获取
|
||||
];
|
||||
|
||||
// 获取Redis信息(如果可用)
|
||||
try {
|
||||
$redis = new \Redis();
|
||||
$redis->connect('127.0.0.1', 6379);
|
||||
$info = $redis->info();
|
||||
$status['memory_usage'] = $info['used_memory_human'] ?? 'unknown';
|
||||
$status['connected_clients'] = $info['connected_clients'] ?? 'unknown';
|
||||
$status['uptime'] = $info['uptime_in_seconds'] ?? 'unknown';
|
||||
} catch (\Throwable $e) {
|
||||
// Redis信息获取失败不影响基本健康检查
|
||||
}
|
||||
|
||||
$status['status'] = ($value === 'ok' && $duration < 0.5) ? 'healthy' : 'warning';
|
||||
$status['message'] = ($value === 'ok' && $duration < 0.5) ? 'Cache service OK' : 'Cache service slow';
|
||||
|
||||
return $status;
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
return [
|
||||
'status' => 'critical',
|
||||
'message' => 'Cache connection failed: ' . $e->getMessage(),
|
||||
'connected' => false,
|
||||
'error' => $e->getMessage()
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private function checkFilesystem(): array
|
||||
{
|
||||
try {
|
||||
$paths = [
|
||||
'runtime' => dirname(__DIR__, 4) . '/runtime',
|
||||
'storage' => dirname(__DIR__, 4) . '/runtime/storage',
|
||||
'logs' => dirname(__DIR__, 4) . '/runtime/logs',
|
||||
'cache' => dirname(__DIR__, 4) . '/runtime/cache'
|
||||
];
|
||||
|
||||
$results = [];
|
||||
$overallStatus = 'healthy';
|
||||
|
||||
foreach ($paths as $name => $path) {
|
||||
$check = $this->checkPathAccess($path);
|
||||
$results[$name] = $check;
|
||||
|
||||
if ($check['status'] === 'critical') {
|
||||
$overallStatus = 'critical';
|
||||
} elseif ($check['status'] === 'warning' && $overallStatus !== 'critical') {
|
||||
$overallStatus = 'warning';
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => $overallStatus,
|
||||
'message' => $overallStatus === 'healthy' ? 'Filesystem OK' : 'Filesystem issues detected',
|
||||
'paths' => $results
|
||||
];
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
return [
|
||||
'status' => 'critical',
|
||||
'message' => 'Filesystem check failed: ' . $e->getMessage(),
|
||||
'error' => $e->getMessage()
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private function checkMemory(): array
|
||||
{
|
||||
$memoryUsage = memory_get_usage(true);
|
||||
$memoryLimit = $this->parseMemoryLimit(ini_get('memory_limit'));
|
||||
$memoryPeak = memory_get_peak_usage(true);
|
||||
|
||||
$usagePercent = $memoryLimit > 0 ? ($memoryUsage / $memoryLimit) : 0;
|
||||
|
||||
$status = [
|
||||
'current_usage' => $memoryUsage,
|
||||
'peak_usage' => $memoryPeak,
|
||||
'limit' => $memoryLimit,
|
||||
'usage_percent' => round($usagePercent * 100, 2),
|
||||
'formatted' => [
|
||||
'current' => $this->formatBytes($memoryUsage),
|
||||
'peak' => $this->formatBytes($memoryPeak),
|
||||
'limit' => $memoryLimit === -1 ? 'unlimited' : $this->formatBytes($memoryLimit)
|
||||
]
|
||||
];
|
||||
|
||||
$threshold = $this->config['memory_threshold'] ?? 0.8;
|
||||
|
||||
if ($usagePercent > 0.95) {
|
||||
$status['status'] = 'critical';
|
||||
$status['message'] = 'Memory usage critically high';
|
||||
} elseif ($usagePercent > $threshold) {
|
||||
$status['status'] = 'warning';
|
||||
$status['message'] = 'Memory usage high';
|
||||
} else {
|
||||
$status['status'] = 'healthy';
|
||||
$status['message'] = 'Memory usage OK';
|
||||
}
|
||||
|
||||
return $status;
|
||||
}
|
||||
|
||||
private function checkDisk(): array
|
||||
{
|
||||
$paths = [
|
||||
'runtime' => dirname(__DIR__, 4) . '/runtime',
|
||||
'root' => dirname(__DIR__, 4)
|
||||
];
|
||||
|
||||
$results = [];
|
||||
$overallStatus = 'healthy';
|
||||
|
||||
foreach ($paths as $name => $path) {
|
||||
if (!file_exists($path)) {
|
||||
$results[$name] = [
|
||||
'status' => 'warning',
|
||||
'message' => 'Path does not exist',
|
||||
'path' => $path
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
$freeSpace = disk_free_space($path);
|
||||
$totalSpace = disk_total_space($path);
|
||||
$usedSpace = $totalSpace - $freeSpace;
|
||||
$usagePercent = ($usedSpace / $totalSpace) * 100;
|
||||
|
||||
$check = [
|
||||
'path' => $path,
|
||||
'total_space' => $totalSpace,
|
||||
'used_space' => $usedSpace,
|
||||
'free_space' => $freeSpace,
|
||||
'usage_percent' => round($usagePercent, 2),
|
||||
'formatted' => [
|
||||
'total' => $this->formatBytes($totalSpace),
|
||||
'used' => $this->formatBytes($usedSpace),
|
||||
'free' => $this->formatBytes($freeSpace)
|
||||
]
|
||||
];
|
||||
|
||||
$threshold = $this->config['disk_threshold'] ?? 0.9;
|
||||
|
||||
if ($usagePercent > 0.95) {
|
||||
$check['status'] = 'critical';
|
||||
$check['message'] = 'Disk space critically low';
|
||||
} elseif ($usagePercent > $threshold) {
|
||||
$check['status'] = 'warning';
|
||||
$check['message'] = 'Disk space low';
|
||||
} else {
|
||||
$check['status'] = 'healthy';
|
||||
$check['message'] = 'Disk space OK';
|
||||
}
|
||||
|
||||
$results[$name] = $check;
|
||||
|
||||
if ($check['status'] === 'critical') {
|
||||
$overallStatus = 'critical';
|
||||
} elseif ($check['status'] === 'warning' && $overallStatus !== 'critical') {
|
||||
$overallStatus = 'warning';
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => $overallStatus,
|
||||
'message' => $overallStatus === 'healthy' ? 'Disk space OK' : 'Disk space issues detected',
|
||||
'paths' => $results
|
||||
];
|
||||
}
|
||||
|
||||
private function checkExternalServices(): array
|
||||
{
|
||||
// 这里可以检查外部服务,如API网关、第三方服务等
|
||||
// 示例:检查Google DNS
|
||||
try {
|
||||
$start = microtime(true);
|
||||
$socket = @fsockopen('8.8.8.8', 53, $errno, $errstr, $this->config['timeout']);
|
||||
$duration = microtime(true) - $start;
|
||||
|
||||
if ($socket) {
|
||||
fclose($socket);
|
||||
return [
|
||||
'status' => $duration < 1.0 ? 'healthy' : 'warning',
|
||||
'message' => 'External connectivity OK',
|
||||
'response_time' => $duration,
|
||||
'service' => 'DNS (8.8.8.8:53)'
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
'status' => 'critical',
|
||||
'message' => 'External connectivity failed',
|
||||
'error' => $errstr,
|
||||
'service' => 'DNS (8.8.8.8:53)'
|
||||
];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return [
|
||||
'status' => 'critical',
|
||||
'message' => 'External service check failed',
|
||||
'error' => $e->getMessage()
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private function checkPathAccess(string $path): array
|
||||
{
|
||||
if (!file_exists($path)) {
|
||||
return [
|
||||
'status' => 'critical',
|
||||
'message' => 'Path does not exist',
|
||||
'path' => $path,
|
||||
'readable' => false,
|
||||
'writable' => false
|
||||
];
|
||||
}
|
||||
|
||||
$readable = is_readable($path);
|
||||
$writable = is_writable($path);
|
||||
|
||||
if (!$readable || !$writable) {
|
||||
return [
|
||||
'status' => 'critical',
|
||||
'message' => 'Path not accessible',
|
||||
'path' => $path,
|
||||
'readable' => $readable,
|
||||
'writable' => $writable
|
||||
];
|
||||
}
|
||||
|
||||
// 测试写入权限
|
||||
$testFile = $path . '/health_test_' . uniqid();
|
||||
$writeTest = file_put_contents($testFile, 'test');
|
||||
|
||||
if ($writeTest === false) {
|
||||
return [
|
||||
'status' => 'critical',
|
||||
'message' => 'Cannot write to path',
|
||||
'path' => $path,
|
||||
'readable' => $readable,
|
||||
'writable' => false
|
||||
];
|
||||
}
|
||||
|
||||
unlink($testFile);
|
||||
|
||||
return [
|
||||
'status' => 'healthy',
|
||||
'message' => 'Path accessible',
|
||||
'path' => $path,
|
||||
'readable' => true,
|
||||
'writable' => true
|
||||
];
|
||||
}
|
||||
|
||||
private function generateSummary(array $results): array
|
||||
{
|
||||
$summary = [
|
||||
'total_checks' => count($results),
|
||||
'healthy' => 0,
|
||||
'warning' => 0,
|
||||
'critical' => 0
|
||||
];
|
||||
|
||||
foreach ($results as $result) {
|
||||
$summary[$result['status']]++;
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
private function parseMemoryLimit(string $limit): int
|
||||
{
|
||||
if ($limit === '-1') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
$unit = strtoupper(substr($limit, -1));
|
||||
$value = (int)substr($limit, 0, -1);
|
||||
|
||||
return match ($unit) {
|
||||
'G' => $value * 1024 * 1024 * 1024,
|
||||
'M' => $value * 1024 * 1024,
|
||||
'K' => $value * 1024,
|
||||
default => (int)$limit
|
||||
};
|
||||
}
|
||||
|
||||
private function formatBytes(int $bytes): string
|
||||
{
|
||||
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
$bytes = max($bytes, 0);
|
||||
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
|
||||
$pow = min($pow, count($units) - 1);
|
||||
|
||||
$bytes /= (1 << (10 * $pow));
|
||||
|
||||
return round($bytes, 2) . ' ' . $units[$pow];
|
||||
}
|
||||
|
||||
private function getActiveConnections(\PDO $pdo): int
|
||||
{
|
||||
// MySQL specific
|
||||
try {
|
||||
$stmt = $pdo->query('SHOW STATUS LIKE "Threads_connected"');
|
||||
$result = $stmt->fetch(\PDO::FETCH_ASSOC);
|
||||
return (int)($result['Value'] ?? 0);
|
||||
} catch (\Throwable $e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private function getDatabaseSize(\PDO $pdo): string
|
||||
{
|
||||
try {
|
||||
$stmt = $pdo->query('SELECT ROUND(SUM(data_length + index_length) / 1024 / 1024, 1) AS "size" FROM information_schema.tables');
|
||||
$result = $stmt->fetch(\PDO::FETCH_ASSOC);
|
||||
return ($result['size'] ?? 0) . ' MB';
|
||||
} catch (\Throwable $e) {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\Monitor\Interceptor;
|
||||
|
||||
use Fendx\Web\Interceptor\Interceptor;
|
||||
use Fendx\Web\Request\Request;
|
||||
use Fendx\Monitor\Service\MonitorService;
|
||||
|
||||
final class MonitorInterceptor implements Interceptor
|
||||
{
|
||||
private string $requestId;
|
||||
|
||||
public function before(Request $request): bool
|
||||
{
|
||||
if (!MonitorService::isEnabled()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 记录请求开始
|
||||
$this->requestId = MonitorService::recordRequestStart();
|
||||
|
||||
// 记录请求计数
|
||||
\Fendx\Monitor\Collector\MetricsCollector::incrementCounter('requests_in_progress');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function after(Request $request, mixed $result): mixed
|
||||
{
|
||||
if (!MonitorService::isEnabled() || !isset($this->requestId)) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// 记录请求结束
|
||||
MonitorService::recordRequestEnd(
|
||||
$this->requestId,
|
||||
$request->method(),
|
||||
$request->path(),
|
||||
$this->extractStatusCode($result)
|
||||
);
|
||||
|
||||
// 减少进行中的请求计数
|
||||
\Fendx\Monitor\Collector\MetricsCollector::incrementCounter('requests_in_progress', -1);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function afterCompletion(Request $request, ?\Throwable $exception): void
|
||||
{
|
||||
if (!MonitorService::isEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果有异常,记录错误
|
||||
if ($exception) {
|
||||
MonitorService::recordException($exception, [
|
||||
'request_method' => $request->method(),
|
||||
'request_path' => $request->path(),
|
||||
'request_id' => $this->requestId ?? 'unknown'
|
||||
]);
|
||||
}
|
||||
|
||||
// 确保进行中的请求计数正确
|
||||
if (isset($this->requestId)) {
|
||||
\Fendx\Monitor\Collector\MetricsCollector::incrementCounter('requests_in_progress', -1);
|
||||
}
|
||||
}
|
||||
|
||||
private function extractStatusCode(mixed $result): int
|
||||
{
|
||||
// 如果结果是Response对象,提取状态码
|
||||
if (is_object($result) && method_exists($result, 'getStatusCode')) {
|
||||
return $result->getStatusCode();
|
||||
}
|
||||
|
||||
// 如果是数组且包含code字段
|
||||
if (is_array($result) && isset($result['code'])) {
|
||||
$code = $result['code'];
|
||||
// 将业务代码转换为HTTP状态码
|
||||
return match (true) {
|
||||
$code >= 200 && $code < 300 => 200,
|
||||
$code === 401 => 401,
|
||||
$code === 403 => 403,
|
||||
$code === 404 => 404,
|
||||
$code === 422 => 422,
|
||||
$code >= 500 => 500,
|
||||
default => 200
|
||||
};
|
||||
}
|
||||
|
||||
return 200; // 默认成功
|
||||
}
|
||||
}
|
||||
507
fendx-framework/fendx-monitor/src/Service/MonitorService.php
Normal file
507
fendx-framework/fendx-monitor/src/Service/MonitorService.php
Normal file
@@ -0,0 +1,507 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\Monitor\Service;
|
||||
|
||||
use Fendx\Monitor\Collector\MetricsCollector;
|
||||
use Fendx\Monitor\Health\HealthChecker;
|
||||
use Fendx\Monitor\Tracker\ErrorTracker;
|
||||
use Fendx\Monitor\Alert\AlertManager;
|
||||
use Fendx\Monitor\Analyzer\LogAnalyzer;
|
||||
use Fendx\Monitor\Visualizer\LogVisualizer;
|
||||
use Fendx\Db\DB;
|
||||
use Fendx\Cache\Cache;
|
||||
use Fendx\Log\Logger;
|
||||
|
||||
final class MonitorService
|
||||
{
|
||||
private static bool $enabled = true;
|
||||
private static array $config = [];
|
||||
private static ?HealthChecker $healthChecker = null;
|
||||
|
||||
public static function initialize(array $config): void
|
||||
{
|
||||
self::$config = array_merge([
|
||||
'enabled' => true,
|
||||
'sample_rate' => 1.0,
|
||||
'retention_period' => 3600,
|
||||
'export_interval' => 60,
|
||||
'alert_thresholds' => [
|
||||
'memory_usage' => 0.8,
|
||||
'cpu_usage' => 0.8,
|
||||
'response_time' => 1.0,
|
||||
'error_rate' => 0.05
|
||||
]
|
||||
], $config);
|
||||
|
||||
self::$enabled = self::$config['enabled'];
|
||||
|
||||
// 初始化健康检查器
|
||||
if (self::$enabled) {
|
||||
self::$healthChecker = new HealthChecker([
|
||||
'timeout' => $config['health_timeout'] ?? 5.0,
|
||||
'memory_threshold' => self::$config['alert_thresholds']['memory_usage'],
|
||||
'disk_threshold' => $config['disk_threshold'] ?? 0.9,
|
||||
'enabled_checks' => $config['enabled_checks'] ?? [
|
||||
'database', 'cache', 'filesystem', 'memory', 'disk'
|
||||
]
|
||||
]);
|
||||
|
||||
// 初始化错误追踪器
|
||||
ErrorTracker::initialize($config['error_tracking'] ?? []);
|
||||
|
||||
// 初始化告警管理器
|
||||
AlertManager::initialize($config['alerts'] ?? []);
|
||||
|
||||
// 初始化日志分析器
|
||||
LogAnalyzer::initialize($config['log_analysis'] ?? []);
|
||||
|
||||
// 初始化日志可视化器
|
||||
LogVisualizer::initialize($config['log_visualization'] ?? []);
|
||||
|
||||
self::startSystemMonitoring();
|
||||
}
|
||||
}
|
||||
|
||||
public static function isEnabled(): bool
|
||||
{
|
||||
return self::$enabled;
|
||||
}
|
||||
|
||||
public static function recordRequestStart(): string
|
||||
{
|
||||
if (!self::$enabled) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$requestId = uniqid('req_', true);
|
||||
MetricsCollector::startTimer('request_' . $requestId);
|
||||
|
||||
return $requestId;
|
||||
}
|
||||
|
||||
public static function recordRequestEnd(string $requestId, string $method, string $path, int $statusCode): void
|
||||
{
|
||||
if (!self::$enabled || !$requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$duration = MetricsCollector::endTimer('request_' . $requestId);
|
||||
MetricsCollector::recordHttpRequest($method, $path, $statusCode, $duration);
|
||||
|
||||
// 检查性能阈值
|
||||
self::checkPerformanceThresholds($duration, $statusCode);
|
||||
}
|
||||
|
||||
public static function recordDatabaseQuery(string $query, float $duration, bool $success = true): void
|
||||
{
|
||||
if (!self::$enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
MetricsCollector::recordDatabaseQuery($query, $duration, $success);
|
||||
|
||||
// 记录慢查询
|
||||
if ($duration > 1.0) {
|
||||
Logger::warning('Slow query detected', [
|
||||
'query' => $query,
|
||||
'duration' => $duration,
|
||||
'trace_id' => \Fendx\Core\Context\Context::getTraceId()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public static function recordCacheOperation(string $operation, string $key, bool $hit): void
|
||||
{
|
||||
if (!self::$enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
MetricsCollector::recordCacheOperation($operation, $key, $hit);
|
||||
}
|
||||
|
||||
public static function recordError(string $type, string $message, array $context = []): void
|
||||
{
|
||||
if (!self::$enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
MetricsCollector::recordError($type, $message, $context);
|
||||
ErrorTracker::trackError($type, $message, $context);
|
||||
|
||||
// 检查错误率
|
||||
self::checkErrorRate();
|
||||
}
|
||||
|
||||
public static function recordException(\Throwable $exception, array $context = []): void
|
||||
{
|
||||
if (!self::$enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
$type = get_class($exception);
|
||||
$message = $exception->getMessage();
|
||||
$exceptionContext = array_merge($context, [
|
||||
'file' => $exception->getFile(),
|
||||
'line' => $exception->getLine(),
|
||||
'trace' => $exception->getTraceAsString(),
|
||||
'code' => $exception->getCode()
|
||||
]);
|
||||
|
||||
MetricsCollector::recordError($type, $message, $exceptionContext);
|
||||
ErrorTracker::trackException($exception, $context);
|
||||
|
||||
// 检查错误率
|
||||
self::checkErrorRate();
|
||||
}
|
||||
|
||||
public static function getMetricsSummary(): array
|
||||
{
|
||||
if (!self::$enabled) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'system' => MetricsCollector::getSystemMetrics(),
|
||||
'counters' => MetricsCollector::getCounters(),
|
||||
'gauges' => MetricsCollector::getGauges(),
|
||||
'histograms' => MetricsCollector::getHistograms(),
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
}
|
||||
|
||||
public static function getHealthStatus(): array
|
||||
{
|
||||
if (!self::$enabled || !self::$healthChecker) {
|
||||
return [
|
||||
'status' => 'unknown',
|
||||
'checks' => [],
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
}
|
||||
|
||||
return self::$healthChecker->check();
|
||||
}
|
||||
|
||||
public static function checkIndividualHealth(string $component): array
|
||||
{
|
||||
if (!self::$enabled || !self::$healthChecker) {
|
||||
return [
|
||||
'status' => 'unknown',
|
||||
'message' => 'Health checking disabled',
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
}
|
||||
|
||||
return self::$healthChecker->checkIndividual($component);
|
||||
}
|
||||
|
||||
public static function getAvailableHealthChecks(): array
|
||||
{
|
||||
if (!self::$healthChecker) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return self::$healthChecker->getAvailableChecks();
|
||||
}
|
||||
|
||||
public static function getErrors(array $filters = []): array
|
||||
{
|
||||
return ErrorTracker::getErrors($filters);
|
||||
}
|
||||
|
||||
public static function getErrorStatistics(): array
|
||||
{
|
||||
return ErrorTracker::getErrorStatistics();
|
||||
}
|
||||
|
||||
public static function getErrorTrends(int $hours = 24): array
|
||||
{
|
||||
return ErrorTracker::getErrorTrends($hours);
|
||||
}
|
||||
|
||||
public static function getAlerts(array $filters = []): array
|
||||
{
|
||||
return AlertManager::getAlerts($filters);
|
||||
}
|
||||
|
||||
public static function getActiveAlerts(): array
|
||||
{
|
||||
return AlertManager::getActiveAlerts();
|
||||
}
|
||||
|
||||
public static function getAlertStatistics(): array
|
||||
{
|
||||
return AlertManager::getAlertStatistics();
|
||||
}
|
||||
|
||||
public static function acknowledgeAlert(string $alertId, string $acknowledgedBy): bool
|
||||
{
|
||||
return AlertManager::acknowledgeAlert($alertId, $acknowledgedBy);
|
||||
}
|
||||
|
||||
public static function resolveAlert(string $alertId): bool
|
||||
{
|
||||
return AlertManager::resolveAlert($alertId);
|
||||
}
|
||||
|
||||
public static function searchLogs(array $criteria = []): array
|
||||
{
|
||||
return LogAnalyzer::search($criteria);
|
||||
}
|
||||
|
||||
public static function aggregateLogs(array $criteria = []): array
|
||||
{
|
||||
return LogAnalyzer::aggregate($criteria);
|
||||
}
|
||||
|
||||
public static function getLogFiles(): array
|
||||
{
|
||||
return LogAnalyzer::getLogFiles();
|
||||
}
|
||||
|
||||
public static function getLogContent(string $file, int $lines = 100, int $offset = 0): array
|
||||
{
|
||||
return LogAnalyzer::getLogContent($file, $lines, $offset);
|
||||
}
|
||||
|
||||
public static function exportLogs(array $criteria = [], string $format = 'json'): string
|
||||
{
|
||||
return LogAnalyzer::exportLogs($criteria, $format);
|
||||
}
|
||||
|
||||
public static function getRealTimeLogs(int $tail = 100): array
|
||||
{
|
||||
return LogAnalyzer::getRealTimeLogs($tail);
|
||||
}
|
||||
|
||||
public static function generateLogChart(string $chartType, array $data, string $title = ''): string
|
||||
{
|
||||
return match ($chartType) {
|
||||
'timeline' => LogVisualizer::generateTimelineChart($data),
|
||||
'pie' => LogVisualizer::generatePieChart($data, $title),
|
||||
'bar' => LogVisualizer::generateBarChart($data, $title),
|
||||
'heatmap' => LogVisualizer::generateHeatmap($data, $title),
|
||||
'level_distribution' => LogVisualizer::generateLogLevelDistribution($data),
|
||||
'error_trend' => LogVisualizer::generateErrorTrendChart($data),
|
||||
'top_errors' => LogVisualizer::generateTopErrorsChart($data),
|
||||
default => LogVisualizer::generateTimelineChart($data)
|
||||
};
|
||||
}
|
||||
|
||||
public static function exportMetrics(string $format = 'json'): string
|
||||
{
|
||||
if (!self::$enabled) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$metrics = self::getMetricsSummary();
|
||||
|
||||
return match ($format) {
|
||||
'prometheus' => MetricsCollector::exportPrometheus(),
|
||||
'json' => json_encode($metrics, JSON_PRETTY_PRINT),
|
||||
default => json_encode($metrics)
|
||||
};
|
||||
}
|
||||
|
||||
public static function clearMetrics(): void
|
||||
{
|
||||
MetricsCollector::clear();
|
||||
}
|
||||
|
||||
private static function startSystemMonitoring(): void
|
||||
{
|
||||
// 记录基础系统指标
|
||||
MetricsCollector::setGauge('system_memory_usage', memory_get_usage(true));
|
||||
MetricsCollector::setGauge('system_memory_peak', memory_get_peak_usage(true));
|
||||
|
||||
if (function_exists('sys_getloadavg')) {
|
||||
$load = sys_getloadavg();
|
||||
MetricsCollector::setGauge('system_load_1m', $load[0]);
|
||||
MetricsCollector::setGauge('system_load_5m', $load[1] ?? 0);
|
||||
MetricsCollector::setGauge('system_load_15m', $load[2] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
private static function checkPerformanceThresholds(float $duration, int $statusCode): void
|
||||
{
|
||||
// 检查响应时间
|
||||
if ($duration > self::$config['alert_thresholds']['response_time']) {
|
||||
Logger::warning('Slow response detected', [
|
||||
'duration' => $duration,
|
||||
'status_code' => $statusCode,
|
||||
'threshold' => self::$config['alert_thresholds']['response_time']
|
||||
]);
|
||||
}
|
||||
|
||||
// 检查错误状态码
|
||||
if ($statusCode >= 500) {
|
||||
MetricsCollector::recordError('http_error', "HTTP {$statusCode}", [
|
||||
'duration' => $duration
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private static function checkErrorRate(): void
|
||||
{
|
||||
$metrics = MetricsCollector::getMetrics();
|
||||
|
||||
if (!isset($metrics['errors_total'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$recentErrors = 0;
|
||||
$totalRequests = 0;
|
||||
$now = microtime(true);
|
||||
$window = 300; // 5分钟窗口
|
||||
|
||||
// 计算最近5分钟的错误率
|
||||
foreach ($metrics['errors_total'] as $error) {
|
||||
if ($now - $error['timestamp'] < $window) {
|
||||
$recentErrors++;
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($metrics['http_requests_total'])) {
|
||||
foreach ($metrics['http_requests_total'] as $request) {
|
||||
if ($now - $request['timestamp'] < $window) {
|
||||
$totalRequests++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($totalRequests > 0) {
|
||||
$errorRate = $recentErrors / $totalRequests;
|
||||
|
||||
if ($errorRate > self::$config['alert_thresholds']['error_rate']) {
|
||||
Logger::error('High error rate detected', [
|
||||
'error_rate' => round($errorRate * 100, 2) . '%',
|
||||
'errors' => $recentErrors,
|
||||
'total_requests' => $totalRequests,
|
||||
'window' => $window . 's'
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static function checkDatabaseHealth(): array
|
||||
{
|
||||
try {
|
||||
$start = microtime(true);
|
||||
$pdo = DB::pdo();
|
||||
$pdo->query('SELECT 1')->fetch();
|
||||
$duration = microtime(true) - $start;
|
||||
|
||||
return [
|
||||
'status' => $duration < 1.0 ? 'healthy' : 'warning',
|
||||
'response_time' => $duration,
|
||||
'connected' => true
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
'status' => 'critical',
|
||||
'error' => $e->getMessage(),
|
||||
'connected' => false
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private static function checkCacheHealth(): array
|
||||
{
|
||||
try {
|
||||
$start = microtime(true);
|
||||
Cache::set('health_check', 'ok', 10);
|
||||
$value = Cache::get('health_check');
|
||||
$duration = microtime(true) - $start;
|
||||
|
||||
return [
|
||||
'status' => ($value === 'ok' && $duration < 0.5) ? 'healthy' : 'warning',
|
||||
'response_time' => $duration,
|
||||
'connected' => true
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
return [
|
||||
'status' => 'critical',
|
||||
'error' => $e->getMessage(),
|
||||
'connected' => false
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private static function checkErrorRateHealth(): array
|
||||
{
|
||||
$metrics = MetricsCollector::getMetrics();
|
||||
|
||||
if (!isset($metrics['errors_total']) || !isset($metrics['http_requests_total'])) {
|
||||
return [
|
||||
'status' => 'healthy',
|
||||
'rate' => 0,
|
||||
'errors' => 0,
|
||||
'requests' => 0
|
||||
];
|
||||
}
|
||||
|
||||
$recentErrors = 0;
|
||||
$totalRequests = 0;
|
||||
$now = microtime(true);
|
||||
$window = 300; // 5分钟窗口
|
||||
|
||||
foreach ($metrics['errors_total'] as $error) {
|
||||
if ($now - $error['timestamp'] < $window) {
|
||||
$recentErrors++;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($metrics['http_requests_total'] as $request) {
|
||||
if ($now - $request['timestamp'] < $window) {
|
||||
$totalRequests++;
|
||||
}
|
||||
}
|
||||
|
||||
$errorRate = $totalRequests > 0 ? $recentErrors / $totalRequests : 0;
|
||||
$threshold = self::$config['alert_thresholds']['error_rate'];
|
||||
|
||||
return [
|
||||
'status' => $errorRate < $threshold ? 'healthy' : ($errorRate < $threshold * 2 ? 'warning' : 'critical'),
|
||||
'rate' => round($errorRate * 100, 2) . '%',
|
||||
'errors' => $recentErrors,
|
||||
'requests' => $totalRequests,
|
||||
'threshold' => round($threshold * 100, 2) . '%'
|
||||
];
|
||||
}
|
||||
|
||||
public static function getAlerts(): array
|
||||
{
|
||||
if (!self::$enabled) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$alerts = [];
|
||||
$health = self::getHealthStatus();
|
||||
|
||||
foreach ($health['checks'] as $component => $check) {
|
||||
if ($check['status'] === 'warning' || $check['status'] === 'critical') {
|
||||
$alerts[] = [
|
||||
'component' => $component,
|
||||
'severity' => $check['status'],
|
||||
'message' => self::generateAlertMessage($component, $check),
|
||||
'timestamp' => microtime(true)
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $alerts;
|
||||
}
|
||||
|
||||
private static function generateAlertMessage(string $component, array $check): string
|
||||
{
|
||||
return match ($component) {
|
||||
'memory' => "High memory usage: {$check['ratio']}",
|
||||
'cpu' => "High CPU usage: {$check['usage']}",
|
||||
'database' => "Database issue: " . ($check['error'] ?? 'Slow response'),
|
||||
'cache' => "Cache issue: " . ($check['error'] ?? 'Slow response'),
|
||||
'error_rate' => "High error rate: {$check['rate']} (threshold: {$check['threshold']})",
|
||||
default => "Component {$component} status: {$check['status']}"
|
||||
};
|
||||
}
|
||||
}
|
||||
462
fendx-framework/fendx-monitor/src/Tracker/ErrorTracker.php
Normal file
462
fendx-framework/fendx-monitor/src/Tracker/ErrorTracker.php
Normal file
@@ -0,0 +1,462 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\Monitor\Tracker;
|
||||
|
||||
use Fendx\Core\Context\Context;
|
||||
use Fendx\Log\Logger;
|
||||
|
||||
final class ErrorTracker
|
||||
{
|
||||
private static array $errors = [];
|
||||
private static array $config = [];
|
||||
private static bool $enabled = true;
|
||||
|
||||
public static function initialize(array $config): void
|
||||
{
|
||||
self::$config = array_merge([
|
||||
'enabled' => true,
|
||||
'max_errors' => 1000,
|
||||
'retention_period' => 3600,
|
||||
'notify_threshold' => 10,
|
||||
'group_similar' => true,
|
||||
'track_stack_trace' => true,
|
||||
'track_request_info' => true
|
||||
], $config);
|
||||
|
||||
self::$enabled = self::$config['enabled'];
|
||||
|
||||
// 注册错误和异常处理器
|
||||
if (self::$enabled) {
|
||||
set_error_handler([self::class, 'handleError']);
|
||||
set_exception_handler([self::class, 'handleException']);
|
||||
register_shutdown_function([self::class, 'handleShutdown']);
|
||||
}
|
||||
}
|
||||
|
||||
public static function trackError(string $type, string $message, array $context = [], ?\Throwable $previous = null): void
|
||||
{
|
||||
if (!self::$enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
$error = [
|
||||
'id' => uniqid('error_', true),
|
||||
'type' => $type,
|
||||
'message' => $message,
|
||||
'context' => $context,
|
||||
'trace_id' => Context::getTraceId(),
|
||||
'timestamp' => microtime(true),
|
||||
'datetime' => date('Y-m-d H:i:s'),
|
||||
'file' => $context['file'] ?? null,
|
||||
'line' => $context['line'] ?? null,
|
||||
'severity' => self::determineSeverity($type, $context),
|
||||
'group_key' => self::generateGroupKey($type, $message, $context)
|
||||
];
|
||||
|
||||
// 添加堆栈跟踪
|
||||
if (self::$config['track_stack_trace'] && isset($context['trace'])) {
|
||||
$error['stack_trace'] = $context['trace'];
|
||||
} elseif ($previous) {
|
||||
$error['stack_trace'] = $previous->getTraceAsString();
|
||||
}
|
||||
|
||||
// 添加请求信息
|
||||
if (self::$config['track_request_info']) {
|
||||
$error['request'] = self::captureRequestInfo();
|
||||
}
|
||||
|
||||
// 添加服务器信息
|
||||
$error['server'] = self::captureServerInfo();
|
||||
|
||||
self::$errors[] = $error;
|
||||
self::cleanupOldErrors();
|
||||
|
||||
// 记录到日志
|
||||
Logger::error("Error tracked: {$type} - {$message}", $error);
|
||||
|
||||
// 检查是否需要发送告警
|
||||
self::checkAlertThreshold($error);
|
||||
}
|
||||
|
||||
public static function trackException(\Throwable $exception, array $context = []): void
|
||||
{
|
||||
if (!self::$enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
$errorContext = array_merge($context, [
|
||||
'file' => $exception->getFile(),
|
||||
'line' => $exception->getLine(),
|
||||
'trace' => $exception->getTraceAsString(),
|
||||
'exception_class' => get_class($exception),
|
||||
'code' => $exception->getCode()
|
||||
]);
|
||||
|
||||
self::trackError(
|
||||
'exception',
|
||||
$exception->getMessage(),
|
||||
$errorContext,
|
||||
$exception
|
||||
);
|
||||
}
|
||||
|
||||
public static function handleError(int $severity, string $message, string $file = '', int $line = 0, array $context = []): bool
|
||||
{
|
||||
if (!(error_reporting() & $severity)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$errorTypes = [
|
||||
E_ERROR => 'ERROR',
|
||||
E_WARNING => 'WARNING',
|
||||
E_PARSE => 'PARSE',
|
||||
E_NOTICE => 'NOTICE',
|
||||
E_CORE_ERROR => 'CORE_ERROR',
|
||||
E_CORE_WARNING => 'CORE_WARNING',
|
||||
E_COMPILE_ERROR => 'COMPILE_ERROR',
|
||||
E_COMPILE_WARNING => 'COMPILE_WARNING',
|
||||
E_USER_ERROR => 'USER_ERROR',
|
||||
E_USER_WARNING => 'USER_WARNING',
|
||||
E_USER_NOTICE => 'USER_NOTICE',
|
||||
E_STRICT => 'STRICT',
|
||||
E_RECOVERABLE_ERROR => 'RECOVERABLE_ERROR',
|
||||
E_DEPRECATED => 'DEPRECATED',
|
||||
E_USER_DEPRECATED => 'USER_DEPRECATED'
|
||||
];
|
||||
|
||||
$type = $errorTypes[$severity] ?? 'UNKNOWN';
|
||||
|
||||
self::trackError($type, $message, [
|
||||
'file' => $file,
|
||||
'line' => $line,
|
||||
'severity_code' => $severity,
|
||||
'context' => $context
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function handleException(\Throwable $exception): void
|
||||
{
|
||||
self::trackException($exception);
|
||||
}
|
||||
|
||||
public static function handleShutdown(): void
|
||||
{
|
||||
$error = error_get_last();
|
||||
|
||||
if ($error && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
|
||||
self::trackError(
|
||||
'FATAL_ERROR',
|
||||
$error['message'],
|
||||
[
|
||||
'file' => $error['file'],
|
||||
'line' => $error['line'],
|
||||
'type' => $error['type']
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static function getErrors(array $filters = []): array
|
||||
{
|
||||
$errors = self::$errors;
|
||||
|
||||
// 应用过滤器
|
||||
if (!empty($filters)) {
|
||||
$errors = array_filter($errors, function($error) use ($filters) {
|
||||
foreach ($filters as $key => $value) {
|
||||
if (isset($error[$key]) && $error[$key] !== $value) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// 按时间倒序排列
|
||||
usort($errors, function($a, $b) {
|
||||
return $b['timestamp'] <=> $a['timestamp'];
|
||||
});
|
||||
|
||||
return array_values($errors);
|
||||
}
|
||||
|
||||
public static function getErrorById(string $id): ?array
|
||||
{
|
||||
foreach (self::$errors as $error) {
|
||||
if ($error['id'] === $id) {
|
||||
return $error;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function getErrorStatistics(): array
|
||||
{
|
||||
$stats = [
|
||||
'total_errors' => count(self::$errors),
|
||||
'by_type' => [],
|
||||
'by_severity' => [],
|
||||
'by_hour' => [],
|
||||
'recent_errors' => 0,
|
||||
'critical_errors' => 0,
|
||||
'most_common' => []
|
||||
];
|
||||
|
||||
$now = time();
|
||||
$oneHourAgo = $now - 3600;
|
||||
|
||||
foreach (self::$errors as $error) {
|
||||
// 按类型统计
|
||||
$type = $error['type'];
|
||||
$stats['by_type'][$type] = ($stats['by_type'][$type] ?? 0) + 1;
|
||||
|
||||
// 按严重程度统计
|
||||
$severity = $error['severity'];
|
||||
$stats['by_severity'][$severity] = ($stats['by_severity'][$severity] ?? 0) + 1;
|
||||
|
||||
// 按小时统计
|
||||
$hour = date('Y-m-d H:00', (int)$error['timestamp']);
|
||||
$stats['by_hour'][$hour] = ($stats['by_hour'][$hour] ?? 0) + 1;
|
||||
|
||||
// 最近错误
|
||||
if ($error['timestamp'] > $oneHourAgo) {
|
||||
$stats['recent_errors']++;
|
||||
}
|
||||
|
||||
// 严重错误
|
||||
if ($severity === 'critical') {
|
||||
$stats['critical_errors']++;
|
||||
}
|
||||
|
||||
// 最常见错误(按分组)
|
||||
$groupKey = $error['group_key'];
|
||||
if (!isset($stats['most_common'][$groupKey])) {
|
||||
$stats['most_common'][$groupKey] = [
|
||||
'group_key' => $groupKey,
|
||||
'type' => $type,
|
||||
'message' => $error['message'],
|
||||
'count' => 0,
|
||||
'last_occurred' => $error['timestamp']
|
||||
];
|
||||
}
|
||||
$stats['most_common'][$groupKey]['count']++;
|
||||
$stats['most_common'][$groupKey]['last_occurred'] = max(
|
||||
$stats['most_common'][$groupKey]['last_occurred'],
|
||||
$error['timestamp']
|
||||
);
|
||||
}
|
||||
|
||||
// 按出现次数排序最常见错误
|
||||
usort($stats['most_common'], function($a, $b) {
|
||||
return $b['count'] <=> $a['count'];
|
||||
});
|
||||
|
||||
$stats['most_common'] = array_slice($stats['most_common'], 0, 10);
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
public static function getErrorTrends(int $hours = 24): array
|
||||
{
|
||||
$trends = [];
|
||||
$now = time();
|
||||
$interval = 3600; // 1小时间隔
|
||||
|
||||
for ($i = $hours - 1; $i >= 0; $i--) {
|
||||
$hourStart = $now - ($i + 1) * $interval;
|
||||
$hourEnd = $now - $i * $interval;
|
||||
$hourKey = date('Y-m-d H:00', $hourStart);
|
||||
|
||||
$hourErrors = array_filter(self::$errors, function($error) use ($hourStart, $hourEnd) {
|
||||
return $error['timestamp'] >= $hourStart && $error['timestamp'] < $hourEnd;
|
||||
});
|
||||
|
||||
$trends[] = [
|
||||
'hour' => $hourKey,
|
||||
'total' => count($hourErrors),
|
||||
'critical' => count(array_filter($hourErrors, fn($e) => $e['severity'] === 'critical')),
|
||||
'by_type' => array_count_values(array_column($hourErrors, 'type'))
|
||||
];
|
||||
}
|
||||
|
||||
return $trends;
|
||||
}
|
||||
|
||||
public static function clearErrors(): void
|
||||
{
|
||||
self::$errors = [];
|
||||
}
|
||||
|
||||
public static function clearErrorsByType(string $type): void
|
||||
{
|
||||
self::$errors = array_filter(self::$errors, fn($error) => $error['type'] !== $type);
|
||||
}
|
||||
|
||||
public static function clearOldErrors(int $olderThanSeconds = null): void
|
||||
{
|
||||
$cutoff = $olderThanSeconds ?? self::$config['retention_period'];
|
||||
$cutoffTime = time() - $cutoff;
|
||||
|
||||
self::$errors = array_filter(self::$errors, fn($error) => $error['timestamp'] > $cutoffTime);
|
||||
}
|
||||
|
||||
public static function exportErrors(string $format = 'json'): string
|
||||
{
|
||||
$data = [
|
||||
'errors' => self::$errors,
|
||||
'statistics' => self::getErrorStatistics(),
|
||||
'trends' => self::getErrorTrends(),
|
||||
'export_time' => microtime(true)
|
||||
];
|
||||
|
||||
return match ($format) {
|
||||
'json' => json_encode($data, JSON_PRETTY_PRINT),
|
||||
'csv' => self::exportToCsv(),
|
||||
default => json_encode($data)
|
||||
};
|
||||
}
|
||||
|
||||
private static function determineSeverity(string $type, array $context): string
|
||||
{
|
||||
$criticalTypes = ['ERROR', 'FATAL_ERROR', 'CORE_ERROR', 'COMPILE_ERROR'];
|
||||
$warningTypes = ['WARNING', 'CORE_WARNING', 'COMPILE_WARNING', 'USER_ERROR'];
|
||||
|
||||
if (in_array($type, $criticalTypes)) {
|
||||
return 'critical';
|
||||
} elseif (in_array($type, $warningTypes)) {
|
||||
return 'warning';
|
||||
} elseif (isset($context['exception_class']) && str_ends_with($context['exception_class'], 'Exception')) {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
return 'info';
|
||||
}
|
||||
|
||||
private static function generateGroupKey(string $type, string $message, array $context): string
|
||||
{
|
||||
if (!self::$config['group_similar']) {
|
||||
return uniqid('group_', true);
|
||||
}
|
||||
|
||||
// 标准化消息(移除时间戳、ID等动态内容)
|
||||
$normalizedMessage = preg_replace('/\d+/', 'N', $message);
|
||||
$normalizedMessage = preg_replace('/[a-f0-9]{8,}/', 'ID', $normalizedMessage);
|
||||
|
||||
// 包含文件位置(如果有)
|
||||
$location = '';
|
||||
if (isset($context['file'])) {
|
||||
$location = basename($context['file']);
|
||||
if (isset($context['line'])) {
|
||||
$location .= ':' . $context['line'];
|
||||
}
|
||||
}
|
||||
|
||||
return md5($type . '|' . $normalizedMessage . '|' . $location);
|
||||
}
|
||||
|
||||
private static function captureRequestInfo(): array
|
||||
{
|
||||
$request = [
|
||||
'method' => $_SERVER['REQUEST_METHOD'] ?? 'CLI',
|
||||
'uri' => $_SERVER['REQUEST_URI'] ?? '',
|
||||
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
|
||||
'ip' => $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1',
|
||||
'referer' => $_SERVER['HTTP_REFERER'] ?? '',
|
||||
'headers' => [],
|
||||
'get' => [],
|
||||
'post' => []
|
||||
];
|
||||
|
||||
// 捕获请求头(排除敏感信息)
|
||||
foreach ($_SERVER as $key => $value) {
|
||||
if (str_starts_with($key, 'HTTP_') && !str_contains($key, 'AUTH') && !str_contains($key, 'COOKIE')) {
|
||||
$request['headers'][str_replace('HTTP_', '', $key)] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
// 捕获GET参数(排除敏感信息)
|
||||
foreach ($_GET as $key => $value) {
|
||||
if (!in_array(strtolower($key), ['password', 'token', 'secret', 'key'])) {
|
||||
$request['get'][$key] = is_scalar($value) ? $value : '[complex]';
|
||||
}
|
||||
}
|
||||
|
||||
// 捕获POST参数(排除敏感信息)
|
||||
foreach ($_POST as $key => $value) {
|
||||
if (!in_array(strtolower($key), ['password', 'token', 'secret', 'key'])) {
|
||||
$request['post'][$key] = is_scalar($value) ? $value : '[complex]';
|
||||
}
|
||||
}
|
||||
|
||||
return $request;
|
||||
}
|
||||
|
||||
private static function captureServerInfo(): array
|
||||
{
|
||||
return [
|
||||
'php_version' => PHP_VERSION,
|
||||
'os' => PHP_OS,
|
||||
'memory_usage' => memory_get_usage(true),
|
||||
'memory_peak' => memory_get_peak_usage(true),
|
||||
'load_average' => function_exists('sys_getloadavg') ? sys_getloadavg() : null,
|
||||
'process_id' => getmypid(),
|
||||
'hostname' => gethostname() ?? 'unknown'
|
||||
];
|
||||
}
|
||||
|
||||
private static function cleanupOldErrors(): void
|
||||
{
|
||||
if (count(self::$errors) <= self::$config['max_errors']) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 按时间排序,保留最新的错误
|
||||
usort(self::$errors, function($a, $b) {
|
||||
return $a['timestamp'] <=> $b['timestamp'];
|
||||
});
|
||||
|
||||
self::$errors = array_slice(self::$errors, -self::$config['max_errors']);
|
||||
}
|
||||
|
||||
private static function checkAlertThreshold(array $error): void
|
||||
{
|
||||
// 检查相同类型错误的频率
|
||||
$recentSimilar = array_filter(self::$errors, function($e) use ($error) {
|
||||
return $e['group_key'] === $error['group_key'] &&
|
||||
(time() - $e['timestamp']) < 300; // 5分钟内
|
||||
});
|
||||
|
||||
if (count($recentSimilar) >= self::$config['notify_threshold']) {
|
||||
Logger::critical('High error frequency detected', [
|
||||
'error_type' => $error['type'],
|
||||
'message' => $error['message'],
|
||||
'count' => count($recentSimilar),
|
||||
'time_window' => '5 minutes'
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private static function exportToCsv(): string
|
||||
{
|
||||
$csv = "ID,Type,Message,File,Line,Severity,Timestamp,TraceId\n";
|
||||
|
||||
foreach (self::$errors as $error) {
|
||||
$csv .= sprintf(
|
||||
"%s,%s,%s,%s,%s,%s,%s,%s\n",
|
||||
$error['id'],
|
||||
$error['type'],
|
||||
str_replace(["\n", "\r", ","], [" ", " ", ";"], $error['message']),
|
||||
$error['file'] ?? '',
|
||||
$error['line'] ?? '',
|
||||
$error['severity'],
|
||||
date('Y-m-d H:i:s', (int)$error['timestamp']),
|
||||
$error['trace_id']
|
||||
);
|
||||
}
|
||||
|
||||
return $csv;
|
||||
}
|
||||
}
|
||||
379
fendx-framework/fendx-monitor/src/Visualizer/LogVisualizer.php
Normal file
379
fendx-framework/fendx-monitor/src/Visualizer/LogVisualizer.php
Normal file
@@ -0,0 +1,379 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\Monitor\Visualizer;
|
||||
|
||||
final class LogVisualizer
|
||||
{
|
||||
private static array $config = [];
|
||||
private static array $colors = [
|
||||
'DEBUG' => '#6c757d',
|
||||
'INFO' => '#17a2b8',
|
||||
'WARNING' => '#ffc107',
|
||||
'ERROR' => '#fd7e14',
|
||||
'CRITICAL' => '#dc3545'
|
||||
];
|
||||
|
||||
public static function initialize(array $config): void
|
||||
{
|
||||
self::$config = array_merge([
|
||||
'chart_width' => 800,
|
||||
'chart_height' => 400,
|
||||
'max_data_points' => 100,
|
||||
'theme' => 'light'
|
||||
], $config);
|
||||
}
|
||||
|
||||
public static function generateTimelineChart(array $timelineData): string
|
||||
{
|
||||
$width = self::$config['chart_width'];
|
||||
$height = self::$config['chart_height'];
|
||||
$maxPoints = self::$config['max_data_points'];
|
||||
|
||||
// 准备数据
|
||||
$labels = array_keys($timelineData);
|
||||
$values = array_values($timelineData);
|
||||
|
||||
if (count($labels) > $maxPoints) {
|
||||
$labels = array_slice($labels, -$maxPoints);
|
||||
$values = array_slice($values, -$maxPoints);
|
||||
}
|
||||
|
||||
$maxValue = max($values) ?: 1;
|
||||
$chartHeight = $height - 60; // 留出空间给标签
|
||||
$chartWidth = $width - 80; // 留出空间给Y轴标签
|
||||
|
||||
$svg = '<svg width="' . $width . '" height="' . $height . '" xmlns="http://www.w3.org/2000/svg">';
|
||||
$svg .= '<style>';
|
||||
$svg .= '.chart-line { fill: none; stroke: #007bff; stroke-width: 2; }';
|
||||
$svg .= '.chart-point { fill: #007bff; }';
|
||||
$svg .= '.chart-grid { stroke: #e9ecef; stroke-width: 1; }';
|
||||
$svg .= '.chart-text { font-family: Arial, sans-serif; font-size: 12px; fill: #495057; }';
|
||||
$svg .= '.chart-label { font-family: Arial, sans-serif; font-size: 10px; fill: #6c757d; }';
|
||||
$svg .= '</style>';
|
||||
|
||||
// 绘制网格
|
||||
for ($i = 0; $i <= 5; $i++) {
|
||||
$y = 30 + ($chartHeight / 5) * $i;
|
||||
$svg .= '<line x1="60" y1="' . $y . '" x2="' . ($width - 20) . '" y2="' . $y . '" class="chart-grid"/>';
|
||||
|
||||
$value = round($maxValue * (5 - $i) / 5);
|
||||
$svg .= '<text x="50" y="' . ($y + 4) . '" text-anchor="end" class="chart-text">' . $value . '</text>';
|
||||
}
|
||||
|
||||
// 绘制数据线
|
||||
$points = [];
|
||||
foreach ($values as $index => $value) {
|
||||
$x = 60 + ($chartWidth / (count($values) - 1)) * $index;
|
||||
$y = 30 + $chartHeight - ($value / $maxValue) * $chartHeight;
|
||||
$points[] = $x . ',' . $y;
|
||||
|
||||
// 绘制数据点
|
||||
$svg .= '<circle cx="' . $x . '" cy="' . $y . '" r="3" class="chart-point"/>';
|
||||
}
|
||||
|
||||
if (!empty($points)) {
|
||||
$svg .= '<polyline points="' . implode(' ', $points) . '" class="chart-line"/>';
|
||||
}
|
||||
|
||||
// 绘制X轴标签
|
||||
$labelStep = max(1, floor(count($labels) / 10));
|
||||
foreach ($labels as $index => $label) {
|
||||
if ($index % $labelStep === 0 || $index === count($labels) - 1) {
|
||||
$x = 60 + ($chartWidth / (count($labels) - 1)) * $index;
|
||||
$svg .= '<text x="' . $x . '" y="' . ($height - 10) . '" text-anchor="middle" class="chart-label">' . substr($label, -5) . '</text>';
|
||||
}
|
||||
}
|
||||
|
||||
$svg .= '</svg>';
|
||||
return $svg;
|
||||
}
|
||||
|
||||
public static function generatePieChart(array $data, string $title = ''): string
|
||||
{
|
||||
$width = self::$config['chart_width'];
|
||||
$height = self::$config['chart_height'];
|
||||
$centerX = $width / 2;
|
||||
$centerY = $height / 2 - 20;
|
||||
$radius = min($width, $height) / 3;
|
||||
|
||||
$total = array_sum($data);
|
||||
if ($total === 0) {
|
||||
return '<svg width="' . $width . '" height="' . $height . '"><text x="' . $centerX . '" y="' . $centerY . '" text-anchor="middle" class="chart-text">No Data</text></svg>';
|
||||
}
|
||||
|
||||
$svg = '<svg width="' . $width . '" height="' . $height . '" xmlns="http://www.w3.org/2000/svg">';
|
||||
$svg .= '<style>';
|
||||
$svg .= '.chart-text { font-family: Arial, sans-serif; font-size: 12px; fill: #495057; }';
|
||||
$svg .= '.chart-title { font-family: Arial, sans-serif; font-size: 14px; font-weight: bold; fill: #212529; }';
|
||||
$svg .= '.chart-legend { font-family: Arial, sans-serif; font-size: 11px; fill: #6c757d; }';
|
||||
$svg .= '</style>';
|
||||
|
||||
if ($title) {
|
||||
$svg .= '<text x="' . $centerX . '" y="20" text-anchor="middle" class="chart-title">' . $title . '</text>';
|
||||
}
|
||||
|
||||
$colors = self::generateColors(count($data));
|
||||
$startAngle = -90; // 从顶部开始
|
||||
$legendY = $height - 60;
|
||||
|
||||
foreach ($data as $label => $value) {
|
||||
$percentage = ($value / $total) * 100;
|
||||
$endAngle = $startAngle + ($percentage * 3.6);
|
||||
|
||||
$startRad = deg2rad($startAngle);
|
||||
$endRad = deg2rad($endAngle);
|
||||
|
||||
$x1 = $centerX + $radius * cos($startRad);
|
||||
$y1 = $centerY + $radius * sin($startRad);
|
||||
$x2 = $centerX + $radius * cos($endRad);
|
||||
$y2 = $centerY + $radius * sin($endRad);
|
||||
|
||||
$largeArc = $percentage > 50 ? 1 : 0;
|
||||
|
||||
$path = "M {$centerX} {$centerY} L {$x1} {$y1} A {$radius} {$radius} 0 {$largeArc} 1 {$x2} {$y2} Z";
|
||||
$color = array_shift($colors);
|
||||
|
||||
$svg .= '<path d="' . $path . '" fill="' . $color . '" stroke="white" stroke-width="2"/>';
|
||||
|
||||
// 添加标签
|
||||
if ($percentage > 5) {
|
||||
$labelAngle = deg2rad($startAngle + ($percentage * 1.8));
|
||||
$labelX = $centerX + ($radius * 0.7) * cos($labelAngle);
|
||||
$labelY = $centerY + ($radius * 0.7) * sin($labelAngle);
|
||||
|
||||
$svg .= '<text x="' . $labelX . '" y="' . $labelY . '" text-anchor="middle" class="chart-text" fill="white">' . round($percentage, 1) . '%</text>';
|
||||
}
|
||||
|
||||
// 添加图例
|
||||
$svg .= '<rect x="20" y="' . $legendY . '" width="12" height="12" fill="' . $color . '"/>';
|
||||
$svg .= '<text x="38" y="' . ($legendY + 10) . '" class="chart-legend">' . $label . ' (' . round($percentage, 1) . '%)</text>';
|
||||
|
||||
$legendY += 18;
|
||||
$startAngle = $endAngle;
|
||||
}
|
||||
|
||||
$svg .= '</svg>';
|
||||
return $svg;
|
||||
}
|
||||
|
||||
public static function generateBarChart(array $data, string $title = ''): string
|
||||
{
|
||||
$width = self::$config['chart_width'];
|
||||
$height = self::$config['chart_height'];
|
||||
$margin = 60;
|
||||
$chartWidth = $width - 2 * $margin;
|
||||
$chartHeight = $height - 2 * $margin;
|
||||
|
||||
$labels = array_keys($data);
|
||||
$values = array_values($data);
|
||||
$maxValue = max($values) ?: 1;
|
||||
$barWidth = $chartWidth / count($labels) * 0.8;
|
||||
$barSpacing = $chartWidth / count($labels) * 0.2;
|
||||
|
||||
$svg = '<svg width="' . $width . '" height="' . $height . '" xmlns="http://www.w3.org/2000/svg">';
|
||||
$svg .= '<style>';
|
||||
$svg .= '.chart-bar { fill: #007bff; }';
|
||||
$svg .= '.chart-bar:hover { fill: #0056b3; }';
|
||||
$svg .= '.chart-grid { stroke: #e9ecef; stroke-width: 1; }';
|
||||
$svg .= '.chart-text { font-family: Arial, sans-serif; font-size: 12px; fill: #495057; }';
|
||||
$svg .= '.chart-label { font-family: Arial, sans-serif; font-size: 10px; fill: #6c757d; }';
|
||||
$svg .= '.chart-title { font-family: Arial, sans-serif; font-size: 14px; font-weight: bold; fill: #212529; }';
|
||||
$svg .= '</style>';
|
||||
|
||||
if ($title) {
|
||||
$svg .= '<text x="' . ($width / 2) . '" y="20" text-anchor="middle" class="chart-title">' . $title . '</text>';
|
||||
}
|
||||
|
||||
// 绘制网格和Y轴标签
|
||||
for ($i = 0; $i <= 5; $i++) {
|
||||
$y = $margin + ($chartHeight / 5) * $i;
|
||||
$svg .= '<line x1="' . $margin . '" y1="' . $y . '" x2="' . ($width - $margin) . '" y2="' . $y . '" class="chart-grid"/>';
|
||||
|
||||
$value = round($maxValue * (5 - $i) / 5);
|
||||
$svg .= '<text x="' . ($margin - 10) . '" y="' . ($y + 4) . '" text-anchor="end" class="chart-text">' . $value . '</text>';
|
||||
}
|
||||
|
||||
// 绘制柱状图
|
||||
foreach ($values as $index => $value) {
|
||||
$x = $margin + ($barWidth + $barSpacing) * $index + $barSpacing / 2;
|
||||
$barHeight = ($value / $maxValue) * $chartHeight;
|
||||
$y = $margin + $chartHeight - $barHeight;
|
||||
|
||||
$svg .= '<rect x="' . $x . '" y="' . $y . '" width="' . $barWidth . '" height="' . $barHeight . '" class="chart-bar"/>';
|
||||
|
||||
// 数值标签
|
||||
$svg .= '<text x="' . ($x + $barWidth / 2) . '" y="' . ($y - 5) . '" text-anchor="middle" class="chart-text">' . $value . '</text>';
|
||||
|
||||
// X轴标签
|
||||
$label = $labels[$index];
|
||||
if (strlen($label) > 10) {
|
||||
$label = substr($label, 0, 10) . '...';
|
||||
}
|
||||
$svg .= '<text x="' . ($x + $barWidth / 2) . '" y="' . ($height - $margin + 20) . '" text-anchor="middle" class="chart-label">' . $label . '</text>';
|
||||
}
|
||||
|
||||
$svg .= '</svg>';
|
||||
return $svg;
|
||||
}
|
||||
|
||||
public static function generateHeatmap(array $data, string $title = ''): string
|
||||
{
|
||||
$width = self::$config['chart_width'];
|
||||
$height = self::$config['chart_height'];
|
||||
$cellSize = 20;
|
||||
$margin = 50;
|
||||
|
||||
$rows = count($data);
|
||||
$cols = $rows > 0 ? count(reset($data)) : 0;
|
||||
|
||||
if ($rows === 0 || $cols === 0) {
|
||||
return '<svg width="' . $width . '" height="' . $height . '"><text x="' . ($width / 2) . '" y="' . ($height / 2) . '" text-anchor="middle" class="chart-text">No Data</text></svg>';
|
||||
}
|
||||
|
||||
$svg = '<svg width="' . $width . '" height="' . $height . '" xmlns="http://www.w3.org/2000/svg">';
|
||||
$svg .= '<style>';
|
||||
$svg .= '.heatmap-cell { stroke: white; stroke-width: 1; }';
|
||||
$svg .= '.chart-text { font-family: Arial, sans-serif; font-size: 10px; fill: #495057; }';
|
||||
$svg .= '.chart-title { font-family: Arial, sans-serif; font-size: 14px; font-weight: bold; fill: #212529; }';
|
||||
$svg .= '</style>';
|
||||
|
||||
if ($title) {
|
||||
$svg .= '<text x="' . ($width / 2) . '" y="20" text-anchor="middle" class="chart-title">' . $title . '</text>';
|
||||
}
|
||||
|
||||
// 找出最大值用于颜色映射
|
||||
$maxValue = 0;
|
||||
foreach ($data as $row) {
|
||||
foreach ($row as $value) {
|
||||
$maxValue = max($maxValue, $value);
|
||||
}
|
||||
}
|
||||
|
||||
// 绘制热力图
|
||||
foreach ($data as $rowIndex => $row) {
|
||||
foreach ($row as $colIndex => $value) {
|
||||
$x = $margin + $colIndex * $cellSize;
|
||||
$y = $margin + $rowIndex * $cellSize;
|
||||
|
||||
$color = self::getHeatmapColor($value, $maxValue);
|
||||
$svg .= '<rect x="' . $x . '" y="' . $y . '" width="' . $cellSize . '" height="' . $cellSize . '" fill="' . $color . '" class="heatmap-cell"/>';
|
||||
|
||||
// 添加数值
|
||||
if ($value > 0) {
|
||||
$textColor = $value > $maxValue / 2 ? 'white' : 'black';
|
||||
$svg .= '<text x="' . ($x + $cellSize / 2) . '" y="' . ($y + $cellSize / 2 + 3) . '" text-anchor="middle" class="chart-text" fill="' . $textColor . '">' . $value . '</text>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加颜色图例
|
||||
$legendX = $margin + $cols * $cellSize + 20;
|
||||
$legendHeight = 100;
|
||||
|
||||
for ($i = 0; $i <= $legendHeight; $i++) {
|
||||
$value = ($maxValue * $i) / $legendHeight;
|
||||
$color = self::getHeatmapColor($value, $maxValue);
|
||||
$y = $margin + $legendHeight - $i;
|
||||
|
||||
$svg .= '<rect x="' . $legendX . '" y="' . $y . '" width="15" height="1" fill="' . $color . '"/>';
|
||||
}
|
||||
|
||||
$svg .= '<text x="' . ($legendX + 20) . '" y="' . $margin . '" class="chart-text">' . $maxValue . '</text>';
|
||||
$svg .= '<text x="' . ($legendX + 20) . '" y="' . ($margin + $legendHeight) . '" class="chart-text">0</text>';
|
||||
|
||||
$svg .= '</svg>';
|
||||
return $svg;
|
||||
}
|
||||
|
||||
public static function generateLogLevelDistribution(array $levelData): string
|
||||
{
|
||||
// 为日志级别分配特定颜色
|
||||
$coloredData = [];
|
||||
foreach ($levelData as $level => $count) {
|
||||
$coloredData[$level] = [
|
||||
'count' => $count,
|
||||
'color' => self::$colors[$level] ?? '#6c757d'
|
||||
];
|
||||
}
|
||||
|
||||
return self::generatePieChart(array_column($coloredData, 'count'), 'Log Level Distribution');
|
||||
}
|
||||
|
||||
public static function generateErrorTrendChart(array $errorData): string
|
||||
{
|
||||
return self::generateTimelineChart($errorData);
|
||||
}
|
||||
|
||||
public static function generateTopErrorsChart(array $topErrors): string
|
||||
{
|
||||
// 限制显示前10个错误
|
||||
$errors = array_slice($topErrors, 0, 10, true);
|
||||
|
||||
// 截断长错误消息
|
||||
$labels = [];
|
||||
foreach (array_keys($errors) as $error) {
|
||||
$labels[] = strlen($error) > 30 ? substr($error, 0, 30) . '...' : $error;
|
||||
}
|
||||
|
||||
$data = array_combine($labels, array_values($errors));
|
||||
|
||||
return self::generateBarChart($data, 'Top Errors');
|
||||
}
|
||||
|
||||
private static function generateColors(int $count): array
|
||||
{
|
||||
$colors = [
|
||||
'#007bff', '#28a745', '#dc3545', '#ffc107', '#17a2b8',
|
||||
'#6610f2', '#e83e8c', '#fd7e14', '#20c997', '#6f42c1',
|
||||
'#343a40', '#6c757d', '#f8f9fa', '#007bff', '#28a745'
|
||||
];
|
||||
|
||||
$result = [];
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$result[] = $colors[$i % count($colors)];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private static function getHeatmapColor(float $value, float $maxValue): string
|
||||
{
|
||||
if ($maxValue === 0) {
|
||||
return '#f8f9fa';
|
||||
}
|
||||
|
||||
$ratio = $value / $maxValue;
|
||||
|
||||
if ($ratio < 0.2) {
|
||||
return '#e8f5e8'; // 浅绿
|
||||
} elseif ($ratio < 0.4) {
|
||||
return '#a8d8a8'; // 中绿
|
||||
} elseif ($ratio < 0.6) {
|
||||
return '#ffd700'; // 黄色
|
||||
} elseif ($ratio < 0.8) {
|
||||
return '#ff8c00'; // 橙色
|
||||
} else {
|
||||
return '#ff4444'; // 红色
|
||||
}
|
||||
}
|
||||
|
||||
public static function exportChart(string $svg, string $filename, string $format = 'svg'): string
|
||||
{
|
||||
$filepath = sys_get_temp_dir() . '/' . $filename . '.' . $format;
|
||||
|
||||
switch ($format) {
|
||||
case 'svg':
|
||||
file_put_contents($filepath, $svg);
|
||||
break;
|
||||
case 'png':
|
||||
// 这里需要使用SVG到PNG的转换库
|
||||
// 暂时返回SVG内容
|
||||
return $svg;
|
||||
default:
|
||||
file_put_contents($filepath, $svg);
|
||||
}
|
||||
|
||||
return $filepath;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user