Enable Discovery debug logging for production troubleshooting
- Add DISCOVERY_LOG_LEVEL=debug - Add DISCOVERY_SHOW_PROGRESS=true - Temporary changes for debugging InitializerProcessor fixes on production
This commit is contained in:
253
src/Application/Admin/CacheMetricsController.php
Normal file
253
src/Application/Admin/CacheMetricsController.php
Normal file
@@ -0,0 +1,253 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Admin;
|
||||
|
||||
use App\Framework\Attributes\Route;
|
||||
use App\Framework\Cache\Metrics\CacheMetricsInterface;
|
||||
use App\Framework\DateTime\Clock;
|
||||
use App\Framework\Http\Method;
|
||||
use App\Framework\Meta\MetaData;
|
||||
use App\Framework\Router\Result\JsonResult;
|
||||
use App\Framework\Router\Result\ViewResult;
|
||||
|
||||
final readonly class CacheMetricsController
|
||||
{
|
||||
public function __construct(
|
||||
private CacheMetricsInterface $cacheMetrics,
|
||||
private Clock $clock
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/cache', Method::GET)]
|
||||
public function showDashboard(): ViewResult
|
||||
{
|
||||
$metaData = new MetaData(
|
||||
title: 'Cache Metriken Dashboard | Admin',
|
||||
description: 'Real-time Cache Performance Monitoring und Analytics Dashboard'
|
||||
);
|
||||
|
||||
// Get initial cache stats for template
|
||||
$stats = $this->cacheMetrics->getStats();
|
||||
|
||||
return new ViewResult('cache-metrics', $metaData, [
|
||||
'pageClass' => 'cache-metrics-page',
|
||||
'initialStats' => $stats,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/admin/cache/metrics', Method::GET)]
|
||||
public function getMetrics(): JsonResult
|
||||
{
|
||||
$stats = $this->cacheMetrics->getStats();
|
||||
|
||||
// Add debugging info about what's causing cache activity
|
||||
$topKeys = $this->cacheMetrics->getTopKeys(10);
|
||||
$frameworkKeys = array_filter(
|
||||
array_keys($topKeys),
|
||||
fn ($key) =>
|
||||
str_contains($key, 'discovery') ||
|
||||
str_contains($key, 'route') ||
|
||||
str_contains($key, 'template') ||
|
||||
str_contains($key, 'attribute')
|
||||
);
|
||||
|
||||
return new JsonResult([
|
||||
'status' => 'success',
|
||||
'data' => [
|
||||
'cache_stats' => $stats->toArray(),
|
||||
'recommendations' => $stats->getRecommendations(),
|
||||
'performance_summary' => [
|
||||
'efficiency_rating' => $stats->getEfficiencyRating(),
|
||||
'total_operations' => $stats->getTotalOperations(),
|
||||
'ops_per_second' => $stats->calculateOpsPerSecond(),
|
||||
],
|
||||
'real_time' => [
|
||||
'current_hit_rate' => $this->cacheMetrics->getHitRate(),
|
||||
'timestamp' => $this->clock->time(),
|
||||
'framework_cache_activity' => count($frameworkKeys),
|
||||
'sampling_note' => 'Only ~10% of operations are recorded (sampling)',
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/admin/cache/metrics/driver/{driver}', Method::GET)]
|
||||
public function getDriverMetrics(string $driver): JsonResult
|
||||
{
|
||||
$driverStats = $this->cacheMetrics->getStatsForDriver($driver);
|
||||
|
||||
if (empty($driverStats)) {
|
||||
return new JsonResult([
|
||||
'status' => 'error',
|
||||
'message' => "Driver '{$driver}' not found or has no statistics",
|
||||
], \App\Framework\Http\Status::NOT_FOUND);
|
||||
}
|
||||
|
||||
return new JsonResult([
|
||||
'status' => 'success',
|
||||
'data' => [
|
||||
'driver' => $driver,
|
||||
'stats' => $driverStats,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/admin/cache/metrics/top-keys', Method::GET)]
|
||||
public function getTopKeys(): JsonResult
|
||||
{
|
||||
$topKeys = $this->cacheMetrics->getTopKeys(20);
|
||||
$heaviestKeys = $this->cacheMetrics->getHeaviestKeys(20);
|
||||
|
||||
return new JsonResult([
|
||||
'status' => 'success',
|
||||
'data' => [
|
||||
'most_accessed' => $topKeys,
|
||||
'heaviest_keys' => $heaviestKeys,
|
||||
'analysis' => $this->analyzeKeyPatterns($topKeys, $heaviestKeys),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/admin/cache/metrics/reset', Method::POST)]
|
||||
public function resetMetrics(): JsonResult
|
||||
{
|
||||
$this->cacheMetrics->reset();
|
||||
|
||||
return new JsonResult([
|
||||
'status' => 'success',
|
||||
'message' => 'Cache metrics have been reset',
|
||||
'timestamp' => $this->clock->time(),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/admin/cache/reset', Method::GET)]
|
||||
public function resetMetricsGet(): JsonResult
|
||||
{
|
||||
// Allow GET for easier testing (normally should be POST)
|
||||
$this->cacheMetrics->reset();
|
||||
|
||||
return new JsonResult([
|
||||
'status' => 'success',
|
||||
'message' => 'Cache metrics have been reset via GET',
|
||||
'timestamp' => $this->clock->time(),
|
||||
'note' => 'You can now visit /admin/cache/debug to generate test data',
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/admin/cache/debug', Method::GET)]
|
||||
public function debugTemplates(): JsonResult
|
||||
{
|
||||
// Generate test operations to show variety
|
||||
|
||||
// Generate mixed test operations to show variety
|
||||
$this->cacheMetrics->recordMiss('test', 'cold_cache_miss_1', 0.012);
|
||||
$this->cacheMetrics->recordMiss('test', 'cold_cache_miss_2', 0.015);
|
||||
$this->cacheMetrics->recordSet('test', 'new_item_1', 2048, 3600, 0.004);
|
||||
$this->cacheMetrics->recordSet('test', 'new_item_2', 4096, 1800, 0.006);
|
||||
$this->cacheMetrics->recordHit('test', 'warm_cache_hit_1', 1024, 0.001);
|
||||
$this->cacheMetrics->recordHit('test', 'warm_cache_hit_2', 2048, 0.002);
|
||||
$this->cacheMetrics->recordDelete('test', 'expired_item', 0.003);
|
||||
|
||||
$stats = $this->cacheMetrics->getStats();
|
||||
|
||||
return new JsonResult([
|
||||
'message' => 'Test cache operations generated',
|
||||
'stats' => $stats->toArray(),
|
||||
'hit_rate' => $this->cacheMetrics->getHitRate(),
|
||||
'top_keys' => $this->cacheMetrics->getTopKeys(5),
|
||||
'heaviest_keys' => $this->cacheMetrics->getHeaviestKeys(5),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/admin/cache/metrics/summary', Method::GET)]
|
||||
public function getSummary(): JsonResult
|
||||
{
|
||||
$stats = $this->cacheMetrics->getStats();
|
||||
|
||||
return new JsonResult([
|
||||
'status' => 'success',
|
||||
'data' => [
|
||||
'hit_rate' => round($stats->hitRate * 100, 2),
|
||||
'total_operations' => $stats->getTotalOperations(),
|
||||
'efficiency' => $stats->getEfficiencyRating(),
|
||||
'avg_latency_ms' => round($stats->avgLatency * 1000, 2),
|
||||
'total_size_mb' => round($stats->totalSize / 1024 / 1024, 2),
|
||||
'active_drivers' => count($stats->driverStats),
|
||||
'recommendations_count' => count($stats->getRecommendations()),
|
||||
'health_status' => $this->getHealthStatus($stats),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $topKeys
|
||||
* @param array<string, int> $heaviestKeys
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function analyzeKeyPatterns(array $topKeys, array $heaviestKeys): array
|
||||
{
|
||||
$analysis = [
|
||||
'patterns' => [],
|
||||
'insights' => [],
|
||||
];
|
||||
|
||||
// Find common prefixes in top keys
|
||||
$prefixes = [];
|
||||
foreach (array_keys($topKeys) as $key) {
|
||||
$parts = explode(':', $key, 2);
|
||||
if (count($parts) > 1) {
|
||||
$prefix = $parts[0];
|
||||
$prefixes[$prefix] = ($prefixes[$prefix] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($prefixes)) {
|
||||
arsort($prefixes);
|
||||
$analysis['patterns']['common_prefixes'] = array_slice($prefixes, 0, 5, true);
|
||||
}
|
||||
|
||||
// Generate insights
|
||||
if ($stats = $this->cacheMetrics->getStats()) {
|
||||
if ($stats->hitRate < 0.7) {
|
||||
$analysis['insights'][] = 'Low hit rate suggests cache keys may be too specific or TTL too short';
|
||||
}
|
||||
|
||||
if (! empty($heaviestKeys)) {
|
||||
$maxSize = 0;
|
||||
foreach ($heaviestKeys as $keyData) {
|
||||
if (isset($keyData['size']) && is_numeric($keyData['size'])) {
|
||||
$maxSize = max($maxSize, (int)$keyData['size']);
|
||||
}
|
||||
}
|
||||
if ($maxSize > 1024 * 1024) { // 1MB
|
||||
$analysis['insights'][] = 'Large cache values detected - consider data compression or normalization';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $analysis;
|
||||
}
|
||||
|
||||
private function getHealthStatus($stats): string
|
||||
{
|
||||
$issues = 0;
|
||||
|
||||
if ($stats->hitRate < 0.7) {
|
||||
$issues++;
|
||||
}
|
||||
if ($stats->avgLatency > 0.01) {
|
||||
$issues++;
|
||||
}
|
||||
if ($stats->totalSize > 100 * 1024 * 1024) {
|
||||
$issues++;
|
||||
} // 100MB
|
||||
|
||||
return match (true) {
|
||||
$issues === 0 => 'healthy',
|
||||
$issues === 1 => 'warning',
|
||||
default => 'critical'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,43 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Admin;
|
||||
|
||||
use App\Framework\Attributes\Route;
|
||||
use App\Framework\Auth\Auth;
|
||||
use App\Framework\Config\Environment;
|
||||
use App\Framework\Config\TypedConfiguration;
|
||||
use App\Framework\Core\VersionInfo;
|
||||
use App\Framework\DateTime\Clock;
|
||||
use App\Framework\DI\DefaultContainer;
|
||||
use App\Framework\Http\HttpResponse;
|
||||
use App\Framework\Http\Method;
|
||||
use App\Framework\Http\Response;
|
||||
use App\Framework\Meta\MetaData;
|
||||
use App\Framework\Meta\OpenGraphTypeWebsite;
|
||||
use App\Framework\Performance\MemoryUsageTracker;
|
||||
use App\Framework\Router\Result\ViewResult;
|
||||
use App\Framework\Http\Session\SessionManager;
|
||||
use App\Framework\Http\Status;
|
||||
use Dom\HTMLDocument;
|
||||
use App\Framework\Meta\MetaData;
|
||||
use App\Framework\Performance\MemoryMonitor;
|
||||
use App\Framework\Redis\RedisConnectionPool;
|
||||
use App\Framework\Router\Result\ViewResult;
|
||||
|
||||
final readonly class Dashboard
|
||||
{
|
||||
public function __construct(
|
||||
private DefaultContainer $container,
|
||||
private VersionInfo $versionInfo,
|
||||
private MemoryUsageTracker $memoryTracker,
|
||||
private TypedConfiguration $config,
|
||||
) {}
|
||||
private MemoryMonitor $memoryMonitor,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Auth]
|
||||
#[Route(path: '/admin', method: Method::GET)]
|
||||
public function show(): ViewResult
|
||||
{
|
||||
/** @var array<string, mixed> $stats */
|
||||
$stats = [
|
||||
'frameworkVersion' => $this->versionInfo->getVersion(),
|
||||
'phpVersion' => PHP_VERSION,
|
||||
'memoryUsage' => $this->formatBytes(memory_get_usage(true)),
|
||||
'peakMemoryUsage' => $this->formatBytes(memory_get_peak_usage(true)),
|
||||
'memoryUsage' => $this->memoryMonitor->getCurrentMemory()->toHumanReadable(),
|
||||
'peakMemoryUsage' => $this->memoryMonitor->getPeakMemory()->toHumanReadable(),
|
||||
'serverInfo' => $_SERVER['SERVER_SOFTWARE'] ?? 'Unknown',
|
||||
'serverTime' => date('Y-m-d H:i:s'),
|
||||
'serverTime' => $this->clock->now()->format('Y-m-d H:i:s'),
|
||||
'timezone' => date_default_timezone_get(),
|
||||
'operatingSystem' => PHP_OS,
|
||||
'loadedExtensions' => $this->getLoadedExtensions(),
|
||||
@@ -51,9 +57,10 @@ final readonly class Dashboard
|
||||
return new ViewResult(
|
||||
template: 'dashboard',
|
||||
metaData: new MetaData('Admin Dashboard'),
|
||||
/** @var array<string, mixed> */
|
||||
data: [
|
||||
'title' => 'Admin Dashboard',
|
||||
'stats' => $stats
|
||||
'stats' => $stats,
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -66,7 +73,7 @@ final readonly class Dashboard
|
||||
$routes = $routeRegistry->getRoutes();
|
||||
|
||||
// Sort routes by path for better readability
|
||||
usort($routes, function($a, $b) {
|
||||
usort($routes, function ($a, $b) {
|
||||
return strcmp($a->path, $b->path);
|
||||
});
|
||||
|
||||
@@ -75,7 +82,7 @@ final readonly class Dashboard
|
||||
metaData: new MetaData('', ''),
|
||||
data: [
|
||||
'title' => 'Routen-Übersicht',
|
||||
'routes' => $routes
|
||||
'routes' => $routes,
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -84,14 +91,44 @@ final readonly class Dashboard
|
||||
#[Route(path: '/admin/services', method: Method::GET)]
|
||||
public function services(): ViewResult
|
||||
{
|
||||
$services = $this->container->getServiceIds();
|
||||
sort($services);
|
||||
$registeredServices = $this->container->getRegisteredServices();
|
||||
|
||||
// The registered services are returned as a numeric array with class names as values
|
||||
$serviceNames = array_filter($registeredServices, 'is_string');
|
||||
$serviceNames = array_unique($serviceNames); // Remove duplicates
|
||||
sort($serviceNames);
|
||||
|
||||
// Prepare service data with categorization
|
||||
$serviceData = [];
|
||||
foreach ($serviceNames as $serviceName) {
|
||||
// Ensure $serviceName is a string before exploding
|
||||
if (is_string($serviceName) && strpos($serviceName, '\\') !== false) {
|
||||
$parts = explode('\\', $serviceName);
|
||||
$category = $parts[1] ?? 'Unknown';
|
||||
$subCategory = $parts[2] ?? '';
|
||||
|
||||
$serviceData[] = [
|
||||
'name' => $serviceName,
|
||||
'category' => $category,
|
||||
'subCategory' => $subCategory,
|
||||
];
|
||||
} else {
|
||||
// Handle non-namespaced services
|
||||
$serviceData[] = [
|
||||
'name' => (string)$serviceName,
|
||||
'category' => 'Other',
|
||||
'subCategory' => '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return new ViewResult(
|
||||
template: 'admin/services',
|
||||
template: 'services',
|
||||
metaData: new MetaData('', ''),
|
||||
data: [
|
||||
'title' => 'Registrierte Dienste',
|
||||
'services' => $services
|
||||
'services' => $serviceData,
|
||||
'servicesCount' => count($serviceData),
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -100,27 +137,32 @@ final readonly class Dashboard
|
||||
#[Route(path: '/admin/environment', method: Method::GET)]
|
||||
public function environment(): ViewResult
|
||||
{
|
||||
$environment = $this->container->get(Environment::class);
|
||||
/** @var array<int, array<string, string>> $env */
|
||||
$env = [];
|
||||
foreach ($_ENV as $key => $value) {
|
||||
foreach ($environment->all() as $key => $value) {
|
||||
// Maskiere sensible Daten
|
||||
if (str_contains(strtolower($key), 'password') ||
|
||||
str_contains(strtolower($key), 'secret') ||
|
||||
str_contains(strtolower($key), 'key')) {
|
||||
$value = '********';
|
||||
}
|
||||
$env[$key] = $value;
|
||||
$env[] = [
|
||||
'key' => $key,
|
||||
'value' => is_array($value) ? json_encode($value) : (string)$value,
|
||||
];
|
||||
}
|
||||
|
||||
ksort($env);
|
||||
|
||||
dd($env);
|
||||
// Sort by key
|
||||
usort($env, fn ($a, $b) => strcmp($a['key'], $b['key']));
|
||||
|
||||
return new ViewResult(
|
||||
template: 'admin/environment',
|
||||
template: 'environment',
|
||||
metaData: new MetaData('', ''),
|
||||
data: [
|
||||
'title' => 'Umgebungsvariablen',
|
||||
'env' => $env
|
||||
'env' => $env,
|
||||
'current_year' => $this->clock->now()->format('Y'),
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -128,10 +170,10 @@ final readonly class Dashboard
|
||||
#[Auth]
|
||||
#[Route('/admin/phpinfo')]
|
||||
#[Route(path: '/admin/phpinfo/{mode}', method: Method::GET)]
|
||||
public function phpInfo(int $mode = 1): Response
|
||||
public function phpInfo(string $mode = '1'): Response
|
||||
{
|
||||
ob_start();
|
||||
phpinfo($mode);
|
||||
phpinfo((int)$mode);
|
||||
$phpinfo = ob_get_clean();
|
||||
|
||||
// Extraktion des <body> Inhalts, um nur den relevanten Teil anzuzeigen
|
||||
@@ -182,15 +224,17 @@ final readonly class Dashboard
|
||||
#[Route(path: '/admin/performance', method: Method::GET)]
|
||||
public function performance(): ViewResult
|
||||
{
|
||||
/** @var array<string, mixed> $performanceData */
|
||||
$performanceData = [
|
||||
'currentMemoryUsage' => $this->formatBytes(memory_get_usage(true)),
|
||||
'peakMemoryUsage' => $this->formatBytes(memory_get_peak_usage(true)),
|
||||
'memoryLimit' => $this->formatBytes($this->getMemoryLimitInBytes()),
|
||||
'memoryUsagePercentage' => round((memory_get_usage(true) / $this->getMemoryLimitInBytes()) * 100, 2),
|
||||
'currentMemoryUsage' => $this->memoryMonitor->getCurrentMemory()->toHumanReadable(),
|
||||
'peakMemoryUsage' => $this->memoryMonitor->getPeakMemory()->toHumanReadable(),
|
||||
'memoryLimit' => $this->memoryMonitor->getMemoryLimit()->toHumanReadable(),
|
||||
'memoryUsagePercentage' => $this->memoryMonitor->getMemoryUsagePercentage()->format(2),
|
||||
'loadAverage' => function_exists('sys_getloadavg') ? sys_getloadavg() : ['N/A', 'N/A', 'N/A'],
|
||||
'opcacheEnabled' => function_exists('opcache_get_status') ? 'Ja' : 'Nein',
|
||||
'executionTime' => number_format(microtime(true) - $_SERVER['REQUEST_TIME_FLOAT'], 4) . ' Sekunden',
|
||||
'includedFiles' => count(get_included_files()),
|
||||
'files' => get_included_files(),
|
||||
];
|
||||
|
||||
if (function_exists('opcache_get_status')) {
|
||||
@@ -210,9 +254,11 @@ final readonly class Dashboard
|
||||
|
||||
return new ViewResult(
|
||||
template: 'performance',
|
||||
metaData: new MetaData('Performance-Daten', 'Performance-Daten'),
|
||||
data: [
|
||||
'title' => 'Performance-Daten',
|
||||
'performance' => $performanceData
|
||||
'performance' => $performanceData,
|
||||
'current_year' => $this->clock->now()->format('Y'),
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -221,10 +267,11 @@ final readonly class Dashboard
|
||||
#[Route(path: '/admin/redis', method: Method::GET)]
|
||||
public function redisInfo(): ViewResult
|
||||
{
|
||||
/** @var array<string, mixed> $redisInfo */
|
||||
$redisInfo = [];
|
||||
|
||||
try {
|
||||
$redis = $this->container->get('Predis\Client');
|
||||
$redis = $this->container->get(RedisConnectionPool::class)->getConnection()->getClient();
|
||||
$info = $redis->info();
|
||||
$redisInfo['status'] = 'Verbunden';
|
||||
$redisInfo['version'] = $info['redis_version'];
|
||||
@@ -236,16 +283,20 @@ final readonly class Dashboard
|
||||
|
||||
// Einige Schlüssel auflisten (begrenzt auf 50)
|
||||
$keys = $redis->keys('*');
|
||||
$redisInfo['key_sample'] = array_slice($keys, 0, 50);
|
||||
/** @var array<int, string> $keySample */
|
||||
$keySample = array_slice($keys, 0, 50);
|
||||
$redisInfo['key_sample'] = $keySample;
|
||||
} catch (\Throwable $e) {
|
||||
$redisInfo['status'] = 'Fehler: ' . $e->getMessage();
|
||||
}
|
||||
|
||||
return new ViewResult(
|
||||
template: 'admin/redis',
|
||||
template: 'redis',
|
||||
metaData: new MetaData('Redis Information', 'Redis Information'),
|
||||
data: [
|
||||
'title' => 'Redis Information',
|
||||
'redis' => $redisInfo
|
||||
'redis' => $redisInfo,
|
||||
'current_year' => $this->clock->now()->format('Y'),
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -272,25 +323,32 @@ final readonly class Dashboard
|
||||
}
|
||||
|
||||
$value = (int) $memoryLimit;
|
||||
$unit = strtolower($memoryLimit[strlen($memoryLimit)-1]);
|
||||
$unit = strtolower($memoryLimit[strlen($memoryLimit) - 1]);
|
||||
|
||||
switch($unit) {
|
||||
switch ($unit) {
|
||||
case 'g':
|
||||
$value *= 1024;
|
||||
// no break
|
||||
case 'm':
|
||||
$value *= 1024;
|
||||
// no break
|
||||
case 'k':
|
||||
$value *= 1024;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function getLoadedExtensions(): array
|
||||
{
|
||||
$extensions = get_loaded_extensions();
|
||||
sort($extensions);
|
||||
|
||||
return $extensions;
|
||||
}
|
||||
|
||||
@@ -299,6 +357,7 @@ final readonly class Dashboard
|
||||
try {
|
||||
if ($this->container->has(SessionManager::class)) {
|
||||
$sessionManager = $this->container->get(SessionManager::class);
|
||||
|
||||
// Diese Methode müsste implementiert werden
|
||||
return $sessionManager->getActiveSessionCount();
|
||||
}
|
||||
|
||||
186
src/Application/Admin/HealthController.php
Normal file
186
src/Application/Admin/HealthController.php
Normal file
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Admin;
|
||||
|
||||
use App\Framework\Attributes\Route;
|
||||
use App\Framework\Health\HealthCheckCategory;
|
||||
use App\Framework\Health\HealthCheckManager;
|
||||
use App\Framework\Http\Method;
|
||||
use App\Framework\Meta\MetaData;
|
||||
use App\Framework\Router\Result\JsonResult;
|
||||
use App\Framework\Router\Result\ViewResult;
|
||||
|
||||
final readonly class HealthController
|
||||
{
|
||||
public function __construct(
|
||||
private HealthCheckManager $healthManager
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/health', Method::GET)]
|
||||
public function showDashboard(): ViewResult
|
||||
{
|
||||
$metaData = new MetaData(
|
||||
title: 'System Health Dashboard',
|
||||
description: 'Real-time system health monitoring and diagnostics'
|
||||
);
|
||||
|
||||
return new ViewResult('health-dashboard', $metaData, [
|
||||
'pageClass' => 'health-dashboard-page',
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/admin/health-optimized', Method::GET)]
|
||||
public function showOptimizedDashboard(): ViewResult
|
||||
{
|
||||
// Get initial health data for view processors
|
||||
$healthReport = $this->healthManager->runAllChecks();
|
||||
|
||||
$metaData = new MetaData(
|
||||
title: 'System Health Dashboard',
|
||||
description: 'Real-time system health monitoring'
|
||||
);
|
||||
|
||||
// Prepare data for view processors
|
||||
$healthChecks = [];
|
||||
foreach ($healthReport->results as $componentName => $result) {
|
||||
$details = [];
|
||||
if (! empty($result->details)) {
|
||||
foreach ($result->details as $key => $value) {
|
||||
$details[] = ['key' => $key, 'value' => is_scalar($value) ? (string)$value : json_encode($value)];
|
||||
}
|
||||
}
|
||||
|
||||
$healthChecks[] = [
|
||||
'componentName' => $componentName,
|
||||
'status' => $result->status->value,
|
||||
'output' => $result->message,
|
||||
'details' => $details,
|
||||
'time' => $result->responseTime ? round($result->responseTime * 1000, 2) . 'ms' : null,
|
||||
];
|
||||
}
|
||||
|
||||
return new ViewResult('health-dashboard-optimized', $metaData, [
|
||||
'overall_status' => $healthReport->overallStatus->value,
|
||||
'health_checks' => $healthChecks,
|
||||
'is_loading' => false,
|
||||
'error_message' => null,
|
||||
'subtitle' => 'Real-time System Health Monitoring',
|
||||
'pageClass' => 'health-dashboard-page',
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/admin/health/api/status', Method::GET)]
|
||||
/**
|
||||
* @return JsonResult<array{status: string, data: array<string, mixed>}>
|
||||
*/
|
||||
public function getSystemStatus(): JsonResult
|
||||
{
|
||||
$report = $this->healthManager->runAllChecks();
|
||||
|
||||
return new JsonResult([
|
||||
'status' => 'success',
|
||||
'data' => $report->toArray(),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/admin/health/api/check/{name}', Method::GET)]
|
||||
/**
|
||||
* @return JsonResult<array{status: string, data?: array{check_name: string, result: array<string, mixed>}, message?: string, available_checks?: array<int, string>}>
|
||||
*/
|
||||
public function runSingleCheck(string $name): JsonResult
|
||||
{
|
||||
$result = $this->healthManager->runCheck($name);
|
||||
|
||||
if ($result === null) {
|
||||
return new JsonResult([
|
||||
'status' => 'error',
|
||||
'message' => "Health check '{$name}' not found",
|
||||
'available_checks' => $this->healthManager->getRegisteredChecks(),
|
||||
], \App\Framework\Http\Status::NOT_FOUND);
|
||||
}
|
||||
|
||||
return new JsonResult([
|
||||
'status' => 'success',
|
||||
'data' => [
|
||||
'check_name' => $name,
|
||||
'result' => $result->toArray(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/admin/health/api/category/{category}', Method::GET)]
|
||||
/**
|
||||
* @return JsonResult<array{status: string, data?: array{category: string, category_display: string, category_icon: string, report: array<string, mixed>}, message?: string, available_categories?: array<int, string>}>
|
||||
*/
|
||||
public function getChecksByCategory(string $category): JsonResult
|
||||
{
|
||||
try {
|
||||
$categoryEnum = HealthCheckCategory::from($category);
|
||||
$report = $this->healthManager->runChecksByCategory($categoryEnum);
|
||||
|
||||
return new JsonResult([
|
||||
'status' => 'success',
|
||||
'data' => [
|
||||
'category' => $category,
|
||||
'category_display' => $categoryEnum->getDisplayName(),
|
||||
'category_icon' => $categoryEnum->getIcon(),
|
||||
'report' => $report->toArray(),
|
||||
],
|
||||
]);
|
||||
|
||||
} catch (\ValueError $e) {
|
||||
return new JsonResult([
|
||||
'status' => 'error',
|
||||
'message' => "Invalid category '{$category}'",
|
||||
'available_categories' => array_column(HealthCheckCategory::cases(), 'value'),
|
||||
], \App\Framework\Http\Status::BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
#[Route('/admin/health/api/summary', Method::GET)]
|
||||
/**
|
||||
* @return JsonResult<array{status: string, data: array{overall_status: string, overall_icon: string, overall_color: string, summary_text: string, quick_stats: array<string, int>, categories: array<string, mixed>, timestamp: int}}>
|
||||
*/
|
||||
public function getHealthSummary(): JsonResult
|
||||
{
|
||||
$report = $this->healthManager->runAllChecks();
|
||||
$checksByCategory = $this->healthManager->getChecksByCategory();
|
||||
|
||||
return new JsonResult([
|
||||
'status' => 'success',
|
||||
'data' => [
|
||||
'overall_status' => $report->overallStatus->value,
|
||||
'overall_icon' => $report->overallStatus->getIcon(),
|
||||
'overall_color' => $report->overallStatus->getColor(),
|
||||
'summary_text' => $report->getSummary(),
|
||||
'quick_stats' => [
|
||||
'total_checks' => count($report->results),
|
||||
'healthy' => count($report->getHealthyChecks()),
|
||||
'warnings' => count($report->getWarningChecks()),
|
||||
'failed' => count($report->getFailedChecks()),
|
||||
'categories' => count($checksByCategory),
|
||||
],
|
||||
'categories' => $checksByCategory,
|
||||
'timestamp' => time(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/admin/health/api/run-all', Method::POST)]
|
||||
/**
|
||||
* @return JsonResult<array{status: string, message: string, data: array<string, mixed>}>
|
||||
*/
|
||||
public function runAllChecks(): JsonResult
|
||||
{
|
||||
$report = $this->healthManager->runAllChecks();
|
||||
|
||||
return new JsonResult([
|
||||
'status' => 'success',
|
||||
'message' => 'All health checks completed',
|
||||
'data' => $report->toArray(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Admin;
|
||||
@@ -12,21 +13,27 @@ final readonly class Images
|
||||
{
|
||||
#[Auth]
|
||||
#[Route('/admin/images')]
|
||||
public function showAll(ImageRepository $imageRepository)
|
||||
public function showAll(ImageRepository $imageRepository): never
|
||||
{
|
||||
$images = $imageRepository->findAll();
|
||||
|
||||
echo "<div style='background-color: #222; display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); grid-gap: 16px; padding: 16px;'>";
|
||||
|
||||
/** @var Image $image */
|
||||
foreach($images as $image) {
|
||||
foreach ($images as $image) {
|
||||
echo sprintf("<a href='/images/%s'>", $image->filename);
|
||||
|
||||
echo "<img src='/images/" . $image->filename . "' style='width: 400px; aspect-ratio: 1; object-fit: cover; border-radius: 16px;'/>";
|
||||
|
||||
echo "</a>";
|
||||
|
||||
var_dump($image->variants[0]->filename ?? 'no variant');
|
||||
// Check if variants are loaded to avoid uninitialized property error
|
||||
try {
|
||||
$variantInfo = $image->variants[0]->filename ?? 'no variant';
|
||||
echo "<small>Variant: " . htmlspecialchars($variantInfo) . "</small>";
|
||||
} catch (Error $e) {
|
||||
echo "<small>Variants not loaded yet</small>";
|
||||
}
|
||||
}
|
||||
|
||||
echo "</div>";
|
||||
|
||||
211
src/Application/Admin/LogViewerController.php
Normal file
211
src/Application/Admin/LogViewerController.php
Normal file
@@ -0,0 +1,211 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Admin;
|
||||
|
||||
use App\Framework\Attributes\Route;
|
||||
use App\Framework\Core\ValueObjects\Duration;
|
||||
use App\Framework\DateTime\Timer;
|
||||
use App\Framework\Http\Method;
|
||||
use App\Framework\Http\SseStream;
|
||||
use App\Framework\Http\Status;
|
||||
use App\Framework\Logging\LogViewer;
|
||||
use App\Framework\Meta\MetaData;
|
||||
use App\Framework\Router\Result\JsonResult;
|
||||
use App\Framework\Router\Result\SseResult;
|
||||
use App\Framework\Router\Result\ViewResult;
|
||||
|
||||
final readonly class LogViewerController
|
||||
{
|
||||
public function __construct(
|
||||
private LogViewer $logViewer,
|
||||
private Timer $timer
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/logs', Method::GET)]
|
||||
public function showLogViewer(): ViewResult
|
||||
{
|
||||
$metaData = new MetaData(
|
||||
title: 'Log Viewer | Admin',
|
||||
description: 'Real-time log viewing and analysis'
|
||||
);
|
||||
|
||||
return new ViewResult('log-viewer', $metaData, [
|
||||
'pageClass' => 'log-viewer-page',
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/admin/logs/api/list', Method::GET)]
|
||||
/**
|
||||
* @return JsonResult<array{status: string, data: array{logs: array<string, mixed>, total_logs: int}}>
|
||||
*/
|
||||
public function getAvailableLogs(): JsonResult
|
||||
{
|
||||
$logs = $this->logViewer->getAvailableLogs();
|
||||
|
||||
return new JsonResult([
|
||||
'status' => 'success',
|
||||
'data' => [
|
||||
'logs' => $logs,
|
||||
'total_logs' => count($logs),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/admin/logs/api/read/{logName}', Method::GET)]
|
||||
/**
|
||||
* @return JsonResult<array{status: string, data?: array<string, mixed>, message?: string, available_logs?: array<int, string>}>
|
||||
*/
|
||||
public function readLog(string $logName): JsonResult
|
||||
{
|
||||
$limit = (int) ($_GET['limit'] ?? 100);
|
||||
$level = $_GET['level'] ?? null;
|
||||
$search = $_GET['search'] ?? null;
|
||||
|
||||
try {
|
||||
$logData = $this->logViewer->readLog($logName, $limit, $level, $search);
|
||||
|
||||
return new JsonResult([
|
||||
'status' => 'success',
|
||||
'data' => $logData,
|
||||
]);
|
||||
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return new JsonResult([
|
||||
'status' => 'error',
|
||||
'message' => $e->getMessage(),
|
||||
'available_logs' => array_keys($this->logViewer->getAvailableLogs()),
|
||||
], Status::NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
#[Route('/admin/logs/api/tail/{logName}', Method::GET)]
|
||||
/**
|
||||
* @return JsonResult<array{status: string, data?: array<string, mixed>, message?: string}>
|
||||
*/
|
||||
public function tailLog(string $logName): JsonResult
|
||||
{
|
||||
$lines = (int) ($_GET['lines'] ?? 50);
|
||||
|
||||
try {
|
||||
$logData = $this->logViewer->tailLog($logName, $lines);
|
||||
|
||||
return new JsonResult([
|
||||
'status' => 'success',
|
||||
'data' => $logData,
|
||||
]);
|
||||
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return new JsonResult([
|
||||
'status' => 'error',
|
||||
'message' => $e->getMessage(),
|
||||
], Status::NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
#[Route('/admin/logs/api/search', Method::GET)]
|
||||
/**
|
||||
* @return JsonResult<array{status: string, data?: array<string, mixed>, message?: string}>
|
||||
*/
|
||||
public function searchLogs(): JsonResult
|
||||
{
|
||||
$query = $_GET['query'] ?? '';
|
||||
$level = $_GET['level'] ?? null;
|
||||
$logs = isset($_GET['logs']) ? explode(',', $_GET['logs']) : null;
|
||||
|
||||
if (empty($query)) {
|
||||
return new JsonResult([
|
||||
'status' => 'error',
|
||||
'message' => 'Query parameter is required',
|
||||
], Status::BAD_REQUEST);
|
||||
}
|
||||
|
||||
$results = $this->logViewer->searchLogs($query, $logs, $level);
|
||||
|
||||
return new JsonResult([
|
||||
'status' => 'success',
|
||||
'data' => $results,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/admin/logs/api/levels', Method::GET)]
|
||||
/**
|
||||
* @return JsonResult<array{status: string, data: array{levels: array<string, array{color: string, icon: string}>}}>
|
||||
*/
|
||||
public function getLogLevels(): JsonResult
|
||||
{
|
||||
return new JsonResult([
|
||||
'status' => 'success',
|
||||
'data' => [
|
||||
'levels' => [
|
||||
'DEBUG' => ['color' => '#6c757d', 'icon' => '🔍'],
|
||||
'INFO' => ['color' => '#17a2b8', 'icon' => 'ℹ️'],
|
||||
'WARNING' => ['color' => '#ffc107', 'icon' => '⚠️'],
|
||||
'ERROR' => ['color' => '#dc3545', 'icon' => '❌'],
|
||||
'CRITICAL' => ['color' => '#800020', 'icon' => '🚨'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/admin/logs/api/stream/{logName}', Method::GET)]
|
||||
public function streamLog(string $logName): SseResult
|
||||
{
|
||||
$level = $_GET['level'] ?? null;
|
||||
$search = $_GET['search'] ?? null;
|
||||
$batchSize = (int) ($_GET['batch_size'] ?? 10);
|
||||
|
||||
try {
|
||||
static $executed = false;
|
||||
|
||||
return new SseResult(
|
||||
callback: function (SseStream $stream) use ($logName, $level, $search, $batchSize, &$executed) {
|
||||
// Verhindere mehrfache Ausführung
|
||||
if ($executed || ! $stream->isActive()) {
|
||||
return;
|
||||
}
|
||||
$executed = true;
|
||||
|
||||
foreach ($this->logViewer->streamLog($logName, $level, $search, $batchSize) as $batch) {
|
||||
// Prüfe Verbindung vor jedem Batch
|
||||
if (! $stream->isConnectionActive() || ! $stream->isActive()) {
|
||||
break;
|
||||
}
|
||||
|
||||
$stream->sendJson([
|
||||
'status' => 'success',
|
||||
'batch' => $batch['batch'],
|
||||
'batch_number' => $batch['batch_number'],
|
||||
'entries_in_batch' => $batch['entries_in_batch'],
|
||||
'total_processed' => $batch['total_processed'],
|
||||
'is_final' => $batch['is_final'] ?? false,
|
||||
], 'log_batch');
|
||||
|
||||
// Kleine Pause für bessere Performance
|
||||
$this->timer->sleep(Duration::fromMilliseconds(50));
|
||||
}
|
||||
|
||||
// Sende finales Event
|
||||
if ($stream->isConnectionActive() && $stream->isActive()) {
|
||||
$stream->sendJson(['status' => 'complete'], 'stream_complete');
|
||||
}
|
||||
|
||||
// Stream beenden
|
||||
$stream->close('Log streaming completed');
|
||||
}, // 1 Minute Maximum
|
||||
maxDuration: 60, // Heartbeat alle 10 Sekunden
|
||||
heartbeatInterval: 10
|
||||
);
|
||||
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return new SseResult(callback: function (SseStream $stream) use ($e) {
|
||||
$stream->sendJson([
|
||||
'status' => 'error',
|
||||
'message' => $e->getMessage(),
|
||||
], 'error');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Admin;
|
||||
|
||||
use App\Framework\View\Template;
|
||||
@@ -8,12 +10,13 @@ use App\Framework\View\Template;
|
||||
class RoutesViewModel
|
||||
{
|
||||
public string $name = 'Michael';
|
||||
|
||||
public string $title = 'Routes';
|
||||
|
||||
public function __construct(
|
||||
/** @var array<string, mixed> */
|
||||
public array $routes = []
|
||||
)
|
||||
{
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,37 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Admin;
|
||||
|
||||
use App\Framework\Attributes\Route;
|
||||
use App\Framework\Auth\Auth;
|
||||
use App\Framework\Cache\Cache;
|
||||
use App\Framework\Discovery\Results\DiscoveryResults;
|
||||
use App\Framework\View\TemplateDiscoveryVisitor;
|
||||
use App\Framework\Discovery\Results\DiscoveryRegistry;
|
||||
|
||||
class ShowDiscovery
|
||||
{
|
||||
public function __construct(
|
||||
private DiscoveryResults $results,
|
||||
)
|
||||
{
|
||||
private DiscoveryRegistry $results,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Auth]
|
||||
#[Route('/admin/discovery')]
|
||||
public function show(
|
||||
#Cache $cache
|
||||
)
|
||||
{
|
||||
$attributes = $this->results->getAllAttributeResults();
|
||||
): void {
|
||||
$attributeTypes = $this->results->attributes()->getAllTypes();
|
||||
|
||||
foreach ($attributes as $name => $attribute) {
|
||||
echo "Attribute: $name <br/>";
|
||||
foreach ($attributeTypes as $attributeType) {
|
||||
echo "Attribute: $attributeType <br/>";
|
||||
echo "<ul>";
|
||||
foreach ($attribute as $result) {
|
||||
echo "<li>" . $result['class'] . '::'.($result['method'] ?? '').'()</li>';
|
||||
};
|
||||
$attributeMappings = $this->results->attributes()->get($attributeType);
|
||||
foreach ($attributeMappings as $attributeMapping) {
|
||||
$className = $attributeMapping->class->getFullyQualified();
|
||||
$methodName = $attributeMapping->method?->toString() ?? '';
|
||||
echo "<li>" . $className . '::' . $methodName . '()</li>';
|
||||
}
|
||||
echo "</ul>";
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
39
src/Application/Admin/ShowImageManager.php
Normal file
39
src/Application/Admin/ShowImageManager.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Admin;
|
||||
|
||||
use App\Domain\Media\ImageRepository;
|
||||
use App\Domain\Media\ImageSlotRepository;
|
||||
use App\Framework\Attributes\Route;
|
||||
use App\Framework\Auth\Auth;
|
||||
use App\Framework\Http\Method;
|
||||
use App\Framework\Meta\MetaData;
|
||||
use App\Framework\Router\Result\ViewResult;
|
||||
|
||||
final readonly class ShowImageManager
|
||||
{
|
||||
public function __construct(
|
||||
private ImageSlotRepository $slotRepository,
|
||||
private ImageRepository $imageRepository
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route(path: '/admin/image-manager', method: Method::GET)]
|
||||
// #[Auth(strategy: 'ip', allowedIps: ['127.0.0.1', '::1'])]
|
||||
public function show(): ViewResult
|
||||
{
|
||||
$slots = $this->slotRepository->findAllWithImages();
|
||||
$images = $this->imageRepository->findAll(100, 0);
|
||||
|
||||
return new ViewResult(
|
||||
'image-manager',
|
||||
new MetaData('Image Manager'),
|
||||
[
|
||||
'slots' => $slots,
|
||||
'images' => $images,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Admin;
|
||||
|
||||
use App\Domain\Media\ImageSlot;
|
||||
@@ -15,20 +17,21 @@ class ShowImageSlots
|
||||
{
|
||||
public function __construct(
|
||||
private ImageSlotRepository $imageSlotRepository,
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
#[Auth]
|
||||
#[Route('/admin/imageslots')]
|
||||
public function show()
|
||||
public function show(): ViewResult
|
||||
{
|
||||
$slots = $this->imageSlotRepository->getSlots();
|
||||
|
||||
/** @var ImageSlot $slot */
|
||||
foreach($slots as $slot) {
|
||||
foreach ($slots as $slot) {
|
||||
#echo $slot->slotName . '<br/>';
|
||||
|
||||
if($slot->image !== null) {
|
||||
# echo $slot->image->filename . '<br/>';
|
||||
if (isset($slot->image)) {
|
||||
echo $slot->image->filename . '<br/>';
|
||||
}
|
||||
|
||||
$slotName = $slot->slotName;
|
||||
@@ -59,7 +62,7 @@ class ShowImageSlots
|
||||
|
||||
#[Auth]
|
||||
#[Route('/admin/imageslots/create', method: Method::POST)]
|
||||
public function create(Request $request)
|
||||
public function create(Request $request): void
|
||||
{
|
||||
$name = $request->parsedBody->get('slotName');
|
||||
|
||||
@@ -68,11 +71,12 @@ class ShowImageSlots
|
||||
$this->imageSlotRepository->save($slot);
|
||||
|
||||
debug($name);
|
||||
// TODO: Return proper response or redirect
|
||||
}
|
||||
|
||||
#[Auth]
|
||||
#[Route('/admin/imageslots/edit/{id}', method: Method::PUT)]
|
||||
public function edit(Request $request, string $id)
|
||||
public function edit(Request $request, string $id): void
|
||||
{
|
||||
$name = $request->parsedBody->get('slotName');
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Admin;
|
||||
|
||||
use App\Domain\Media\Image;
|
||||
@@ -10,30 +12,23 @@ use App\Domain\Media\SaveImageFile;
|
||||
use App\Framework\Attributes\Route;
|
||||
use App\Framework\Auth\Auth;
|
||||
use App\Framework\Core\PathProvider;
|
||||
use App\Framework\Database\Transaction;
|
||||
use App\Framework\DateTime\SystemClock;
|
||||
use App\Framework\Http\Method;
|
||||
use App\Framework\Http\Request;
|
||||
use App\Framework\Http\UploadedFile;
|
||||
use App\Framework\Ulid\StringConverter;
|
||||
use App\Framework\Ulid\Ulid;
|
||||
use Media\Services\ImageService;
|
||||
use function imagecopyresampled;
|
||||
use function imagecreatefromjpeg;
|
||||
use function imagecreatetruecolor;
|
||||
use function imagedestroy;
|
||||
use function imagejpeg;
|
||||
|
||||
class ShowImageUpload
|
||||
{
|
||||
public function __construct(
|
||||
private PathProvider $pathProvider,
|
||||
private StringConverter $stringConverter,
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
#[Auth]
|
||||
#[Route('/upload')]
|
||||
public function __invoke()
|
||||
public function __invoke(): void
|
||||
{
|
||||
$html = <<<HTML
|
||||
<form action="/upload" method="post" enctype="multipart/form-data">
|
||||
@@ -47,9 +42,10 @@ class ShowImageUpload
|
||||
echo $html;
|
||||
die();
|
||||
}
|
||||
|
||||
#[Auth]
|
||||
#[Route('/upload', Method::POST)]
|
||||
public function upload(Request $request, Ulid $ulid, ImageRepository $imageRepository, ImageVariantRepository $imageVariantRepository,)
|
||||
public function upload(Request $request, Ulid $ulid, ImageRepository $imageRepository, ImageVariantRepository $imageVariantRepository): void
|
||||
{
|
||||
try {
|
||||
/** @var UploadedFile $file */
|
||||
@@ -76,11 +72,13 @@ class ShowImageUpload
|
||||
echo "<h2>Bild bereits vorhanden</h2>";
|
||||
echo "<p>Dieses Bild wurde bereits hochgeladen.</p>";
|
||||
echo "<p>Bild-ID: " . htmlspecialchars($existingImage->ulid) . "</p>";
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$idStr = str_pad((string)$id, 9, '0', STR_PAD_LEFT);
|
||||
$filePathPattern = sprintf('%s/%s/%s',
|
||||
$filePathPattern = sprintf(
|
||||
'%s/%s/%s',
|
||||
substr($idStr, 0, 3),
|
||||
substr($idStr, 3, 3),
|
||||
substr($idStr, 6, 3),
|
||||
@@ -125,7 +123,8 @@ class ShowImageUpload
|
||||
#mkdir($path, 0755, true);
|
||||
$variant = new ImageResizer()($image, 50, 50);
|
||||
|
||||
$imageVariantRepository->save($variant);;
|
||||
$imageVariantRepository->save($variant);
|
||||
;
|
||||
|
||||
$href = "/images/".$variant->filename;
|
||||
echo "<a href='$href'>$href</a>";
|
||||
|
||||
@@ -1,29 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Admin;
|
||||
|
||||
use App\Framework\Attributes\Route;
|
||||
use App\Framework\Auth\Auth;
|
||||
use App\Framework\Core\PathProvider;
|
||||
use App\Framework\Core\RouteCache;
|
||||
use App\Framework\Discovery\Results\DiscoveryResults;
|
||||
use App\Framework\Discovery\Results\DiscoveryRegistry;
|
||||
use App\Framework\Meta\MetaData;
|
||||
use App\Framework\Router\Result\ViewResult;
|
||||
|
||||
final readonly class ShowRoutes
|
||||
{
|
||||
public function __construct(
|
||||
private PathProvider $pathProvider,
|
||||
private DiscoveryResults $processedResults
|
||||
){}
|
||||
private DiscoveryRegistry $processedResults
|
||||
) {
|
||||
}
|
||||
|
||||
#[Auth]
|
||||
#[Route('/admin/routes')]
|
||||
public function show(): ViewResult
|
||||
{
|
||||
$routes = $this->processedResults->get(Route::class);
|
||||
// Get routes from the AttributeRegistry (Routes are stored as Route::class attributes)
|
||||
$routeMappings = $this->processedResults->attributes->get(Route::class);
|
||||
|
||||
sort($routes);
|
||||
// Convert DiscoveredAttribute objects to display format
|
||||
$routes = [];
|
||||
foreach ($routeMappings as $discoveredAttribute) {
|
||||
$routeData = $discoveredAttribute->additionalData;
|
||||
$routes[] = [
|
||||
'path' => $routeData['path'] ?? '',
|
||||
'method' => $routeData['method'] ?? '',
|
||||
'controller' => $discoveredAttribute->className->getFullyQualified(),
|
||||
'handler' => $discoveredAttribute->methodName?->toString() ?? '',
|
||||
'file' => $discoveredAttribute->filePath?->toString() ?? '',
|
||||
'middleware' => $routeData['middleware'] ?? [],
|
||||
'name' => $routeData['name'] ?? '',
|
||||
'signature' => $routeData['signature'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
// Sort routes by path for a better overview
|
||||
usort($routes, fn ($a, $b) => strcmp($a['path'], $b['path']));
|
||||
|
||||
/*
|
||||
|
||||
@@ -45,12 +63,14 @@ final readonly class ShowRoutes
|
||||
|
||||
echo str_repeat('-', 50) . "<br/></div>";*/
|
||||
|
||||
return new ViewResult('routes',
|
||||
return new ViewResult(
|
||||
'routes',
|
||||
metaData: new MetaData('Routes', 'Routes'),
|
||||
data: [
|
||||
'name' => 'Michael',
|
||||
'title' => 'Routes',
|
||||
],
|
||||
model: new RoutesViewModel($routes));
|
||||
model: new RoutesViewModel($routes)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
184
src/Application/Admin/StyleguideController.php
Normal file
184
src/Application/Admin/StyleguideController.php
Normal file
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Application\Admin;
|
||||
|
||||
use App\Framework\Attributes\Route;
|
||||
use App\Framework\Http\Method;
|
||||
use App\Framework\Meta\MetaData;
|
||||
use App\Framework\Router\Result\ViewResult;
|
||||
|
||||
final class StyleguideController
|
||||
{
|
||||
#[Route(path: '/admin/styleguide', method: Method::GET)]
|
||||
public function showStyleguide(): ViewResult
|
||||
{
|
||||
$metaData = new MetaData(
|
||||
title: 'Admin Styleguide',
|
||||
description: 'Design System Components for Admin Interface'
|
||||
);
|
||||
|
||||
return new ViewResult(
|
||||
template: 'styleguide',
|
||||
metaData: $metaData,
|
||||
data: [
|
||||
'components' => $this->getComponentExamples(),
|
||||
'colorTokens' => $this->getColorTokens(),
|
||||
'spacingTokens' => $this->getSpacingTokens(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<string, string>>
|
||||
*/
|
||||
private function getComponentExamples(): array
|
||||
{
|
||||
return [
|
||||
'buttons' => [
|
||||
'primary' => '<button class="admin-button">Primary Button</button>',
|
||||
'secondary' => '<button class="admin-button admin-button--secondary">Secondary Button</button>',
|
||||
'success' => '<button class="admin-button admin-button--success">Success Button</button>',
|
||||
'warning' => '<button class="admin-button admin-button--warning">Warning Button</button>',
|
||||
'error' => '<button class="admin-button admin-button--error">Error Button</button>',
|
||||
'small' => '<button class="admin-button admin-button--small">Small Button</button>',
|
||||
'large' => '<button class="admin-button admin-button--large">Large Button</button>',
|
||||
],
|
||||
'cards' => [
|
||||
'basic' => '
|
||||
<div class="admin-card">
|
||||
<div class="admin-card__header">
|
||||
<h3 class="admin-card__title">Basic Card</h3>
|
||||
</div>
|
||||
<div class="admin-card__content">
|
||||
This is a basic admin card with header and content.
|
||||
</div>
|
||||
</div>
|
||||
',
|
||||
'status_success' => '
|
||||
<div class="admin-card status-card status-card--success">
|
||||
<div class="admin-card__header">
|
||||
<h3 class="admin-card__title">✅ Success Card</h3>
|
||||
<span class="admin-table__status admin-table__status--success">
|
||||
<span class="status-indicator status-indicator--success"></span>
|
||||
PASS
|
||||
</span>
|
||||
</div>
|
||||
<div class="admin-card__content">
|
||||
This is a success status card.
|
||||
</div>
|
||||
</div>
|
||||
',
|
||||
'metric' => '
|
||||
<div class="admin-card metric-card">
|
||||
<div class="metric-card__value">1,234</div>
|
||||
<div class="metric-card__label">Total Users</div>
|
||||
<div class="metric-card__change metric-card__change--positive">+12% this month</div>
|
||||
</div>
|
||||
',
|
||||
],
|
||||
'forms' => [
|
||||
'input' => '<input type="text" class="admin-input" placeholder="Enter text here">',
|
||||
'select' => '
|
||||
<select class="admin-input admin-select">
|
||||
<option>Choose option</option>
|
||||
<option>Option 1</option>
|
||||
<option>Option 2</option>
|
||||
</select>
|
||||
',
|
||||
'search' => '
|
||||
<div class="admin-search">
|
||||
<input type="search" class="admin-input admin-search__input" placeholder="Search...">
|
||||
</div>
|
||||
',
|
||||
],
|
||||
'tables' => [
|
||||
'basic' => '
|
||||
<div class="admin-table-wrapper">
|
||||
<div class="admin-table-wrapper__header">
|
||||
<h3 class="admin-table-wrapper__title">Sample Data</h3>
|
||||
</div>
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Status</th>
|
||||
<th>Value</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Sample Item 1</td>
|
||||
<td>
|
||||
<span class="admin-table__status admin-table__status--success">
|
||||
<span class="status-indicator status-indicator--success"></span>
|
||||
ACTIVE
|
||||
</span>
|
||||
</td>
|
||||
<td>$123.45</td>
|
||||
<td class="admin-table__actions">
|
||||
<a href="#" class="admin-table__action">Edit</a>
|
||||
<a href="#" class="admin-table__action admin-table__action--danger">Delete</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Sample Item 2</td>
|
||||
<td>
|
||||
<span class="admin-table__status admin-table__status--warning">
|
||||
<span class="status-indicator status-indicator--warning"></span>
|
||||
PENDING
|
||||
</span>
|
||||
</td>
|
||||
<td>$67.89</td>
|
||||
<td class="admin-table__actions">
|
||||
<a href="#" class="admin-table__action">Edit</a>
|
||||
<a href="#" class="admin-table__action admin-table__action--danger">Delete</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<string, string>>
|
||||
*/
|
||||
private function getColorTokens(): array
|
||||
{
|
||||
return [
|
||||
'primary' => [
|
||||
'accent' => 'var(--accent)',
|
||||
'bg' => 'var(--bg)',
|
||||
'bg-alt' => 'var(--bg-alt)',
|
||||
'text' => 'var(--text)',
|
||||
'muted' => 'var(--muted)',
|
||||
'border' => 'var(--border)',
|
||||
],
|
||||
'semantic' => [
|
||||
'success' => 'var(--success)',
|
||||
'warning' => 'var(--warning)',
|
||||
'error' => 'var(--error)',
|
||||
'info' => 'var(--info-base)',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function getSpacingTokens(): array
|
||||
{
|
||||
return [
|
||||
'space-sm' => 'var(--space-sm)',
|
||||
'space-md' => 'var(--space-md)',
|
||||
'space-lg' => 'var(--space-lg)',
|
||||
'radius-md' => 'var(--radius-md)',
|
||||
'radius-lg' => 'var(--radius-lg)',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
<?php
|
||||
/** @var \App\Framework\Core\ViewModel $this */
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>System Health Dashboard - Admin</title>
|
||||
<!-- CSS wird automatisch über AssetInjector geladen -->
|
||||
</head>
|
||||
<body class="admin-page">
|
||||
<!-- Admin Header -->
|
||||
<header class="admin-header">
|
||||
<div class="admin-header__info">
|
||||
<h1 class="admin-header__title">🏥 System Health Dashboard</h1>
|
||||
<p class="admin-header__subtitle">Real-time System Health Monitoring</p>
|
||||
</div>
|
||||
<div class="admin-header__actions">
|
||||
<button class="admin-button admin-button--secondary admin-button--small" id="refreshBtn">
|
||||
🔄 Refresh All
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Admin Main Content -->
|
||||
<main class="admin-main">
|
||||
<!-- Overall Status Card -->
|
||||
<div class="admin-card status-card status-card--info" id="overallStatus">
|
||||
<div class="admin-card__content" style="text-align: center;">
|
||||
<div style="font-size: 4rem; margin-bottom: 1rem;" id="statusIcon">⏳</div>
|
||||
<div class="metric-card__value" id="statusText" style="font-size: 1.5rem; margin-bottom: 0.5rem;">Loading...</div>
|
||||
<div class="admin-card__subtitle" id="statusDescription">Checking system health...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Health Checks Grid -->
|
||||
<div class="admin-cards admin-cards--3-col" id="healthGrid">
|
||||
<!-- Health cards will be populated here -->
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="admin-card">
|
||||
<div class="admin-card__header">
|
||||
<h3 class="admin-card__title">Quick Actions</h3>
|
||||
</div>
|
||||
<div class="admin-card__content">
|
||||
<div style="display: flex; gap: var(--space-md); flex-wrap: wrap;">
|
||||
<button class="admin-button" id="runAllBtn">🔍 Run All Checks</button>
|
||||
<button class="admin-button admin-button--secondary" id="exportBtn">📊 Export Report</button>
|
||||
<button class="admin-button admin-button--secondary" onclick="window.location.href='../../../../logs'">📄 View Logs</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
class HealthDashboard {
|
||||
constructor() {
|
||||
this.refreshButton = document.getElementById('refreshBtn');
|
||||
this.runAllButton = document.getElementById('runAllBtn');
|
||||
this.exportButton = document.getElementById('exportBtn');
|
||||
this.setupEventListeners();
|
||||
this.loadHealthStatus();
|
||||
|
||||
// Auto-refresh every 30 seconds
|
||||
setInterval(() => this.loadHealthStatus(), 30000);
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
this.refreshButton.addEventListener('click', () => {
|
||||
this.loadHealthStatus();
|
||||
});
|
||||
|
||||
this.runAllButton.addEventListener('click', () => {
|
||||
this.runAllChecks();
|
||||
});
|
||||
|
||||
this.exportButton.addEventListener('click', () => {
|
||||
this.exportReport();
|
||||
});
|
||||
}
|
||||
|
||||
async loadHealthStatus() {
|
||||
try {
|
||||
this.setLoading(true);
|
||||
|
||||
const response = await fetch('/admin/health/api/status');
|
||||
if (!response.ok) throw new Error('Network error');
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
this.updateOverallStatus(data.data);
|
||||
this.updateHealthCards(data.data.checks);
|
||||
} else {
|
||||
throw new Error(data.message || 'Unknown error occurred');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Health check failed:', error);
|
||||
this.showError(error.message);
|
||||
} finally {
|
||||
this.setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
updateOverallStatus(data) {
|
||||
const statusIcon = document.getElementById('statusIcon');
|
||||
const statusText = document.getElementById('statusText');
|
||||
const statusDescription = document.getElementById('statusDescription');
|
||||
const overallCard = document.getElementById('overallStatus');
|
||||
|
||||
// Remove all status classes
|
||||
overallCard.classList.remove('status-card--success', 'status-card--warning', 'status-card--error', 'status-card--info');
|
||||
|
||||
if (data.overall_status === 'healthy') {
|
||||
statusIcon.textContent = '✅';
|
||||
statusText.textContent = 'System Healthy';
|
||||
statusDescription.textContent = 'All systems are operating normally';
|
||||
overallCard.classList.add('status-card--success');
|
||||
} else if (data.overall_status === 'warning') {
|
||||
statusIcon.textContent = '⚠️';
|
||||
statusText.textContent = 'System Warning';
|
||||
statusDescription.textContent = 'Some issues detected that need attention';
|
||||
overallCard.classList.add('status-card--warning');
|
||||
} else if (data.overall_status === 'critical') {
|
||||
statusIcon.textContent = '❌';
|
||||
statusText.textContent = 'System Critical';
|
||||
statusDescription.textContent = 'Critical issues detected requiring immediate attention';
|
||||
overallCard.classList.add('status-card--error');
|
||||
} else {
|
||||
statusIcon.textContent = '❓';
|
||||
statusText.textContent = 'Status Unknown';
|
||||
statusDescription.textContent = 'Unable to determine system status';
|
||||
overallCard.classList.add('status-card--info');
|
||||
}
|
||||
}
|
||||
|
||||
updateHealthCards(checks) {
|
||||
const healthGrid = document.getElementById('healthGrid');
|
||||
healthGrid.innerHTML = '';
|
||||
|
||||
checks.forEach(check => {
|
||||
const card = this.createHealthCard(check);
|
||||
healthGrid.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
createHealthCard(check) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'admin-card status-card';
|
||||
|
||||
// Add status-specific class
|
||||
if (check.status === 'pass') {
|
||||
card.classList.add('status-card--success');
|
||||
} else if (check.status === 'warn') {
|
||||
card.classList.add('status-card--warning');
|
||||
} else if (check.status === 'fail') {
|
||||
card.classList.add('status-card--error');
|
||||
} else {
|
||||
card.classList.add('status-card--info');
|
||||
}
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="admin-card__header">
|
||||
<h3 class="admin-card__title">${this.getCheckIcon(check.status)} ${check.componentName}</h3>
|
||||
<span class="admin-table__status admin-table__status--${this.getStatusVariant(check.status)}">
|
||||
<span class="status-indicator status-indicator--${this.getStatusVariant(check.status)}"></span>
|
||||
${check.status.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div class="admin-card__content">
|
||||
${check.output ? `<p style="margin-bottom: var(--space-sm);">${check.output}</p>` : ''}
|
||||
${check.details ? this.renderCheckDetails(check.details) : ''}
|
||||
${check.time ? `<p class="admin-table__timestamp">Last checked: ${new Date(check.time).toLocaleString()}</p>` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
getCheckIcon(status) {
|
||||
switch(status) {
|
||||
case 'pass': return '✅';
|
||||
case 'warn': return '⚠️';
|
||||
case 'fail': return '❌';
|
||||
default: return '❓';
|
||||
}
|
||||
}
|
||||
|
||||
getStatusVariant(status) {
|
||||
switch(status) {
|
||||
case 'pass': return 'success';
|
||||
case 'warn': return 'warning';
|
||||
case 'fail': return 'error';
|
||||
default: return 'info';
|
||||
}
|
||||
}
|
||||
|
||||
renderCheckDetails(details) {
|
||||
if (typeof details === 'object') {
|
||||
return Object.entries(details).map(([key, value]) =>
|
||||
`<div style="display: flex; justify-content: space-between; margin-bottom: 0.25rem;">
|
||||
<span style="color: var(--muted); font-size: 0.875rem;">${key}:</span>
|
||||
<span style="font-weight: 500; font-size: 0.875rem;">${value}</span>
|
||||
</div>`
|
||||
).join('');
|
||||
}
|
||||
return `<p>${details}</p>`;
|
||||
}
|
||||
|
||||
async runAllChecks() {
|
||||
try {
|
||||
this.runAllButton.disabled = true;
|
||||
this.runAllButton.textContent = '🔄 Running...';
|
||||
|
||||
const response = await fetch('/admin/health/api/run-all', {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to run checks');
|
||||
|
||||
// Reload status after running checks
|
||||
await this.loadHealthStatus();
|
||||
|
||||
this.showSuccess('All health checks completed successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to run checks:', error);
|
||||
this.showError('Failed to run health checks: ' + error.message);
|
||||
} finally {
|
||||
this.runAllButton.disabled = false;
|
||||
this.runAllButton.textContent = '🔍 Run All Checks';
|
||||
}
|
||||
}
|
||||
|
||||
async exportReport() {
|
||||
try {
|
||||
this.exportButton.disabled = true;
|
||||
this.exportButton.textContent = '📤 Exporting...';
|
||||
|
||||
const response = await fetch('/admin/health/api/export');
|
||||
if (!response.ok) throw new Error('Export failed');
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `health-report-${new Date().toISOString().split('T')[0]}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
|
||||
this.showSuccess('Health report exported successfully');
|
||||
} catch (error) {
|
||||
console.error('Export failed:', error);
|
||||
this.showError('Failed to export report: ' + error.message);
|
||||
} finally {
|
||||
this.exportButton.disabled = false;
|
||||
this.exportButton.textContent = '📊 Export Report';
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(loading) {
|
||||
if (loading) {
|
||||
document.getElementById('statusIcon').textContent = '⏳';
|
||||
document.getElementById('statusText').textContent = 'Loading...';
|
||||
document.getElementById('statusDescription').textContent = 'Checking system health...';
|
||||
}
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
// Create temporary error notification
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'admin-card status-card status-card--error';
|
||||
errorDiv.style.cssText = 'position: fixed; top: 20px; right: 20px; z-index: 1000; min-width: 300px;';
|
||||
errorDiv.innerHTML = `
|
||||
<div class="admin-card__content">
|
||||
<strong>Error:</strong> ${message}
|
||||
<button onclick="this.parentElement.parentElement.remove()" style="float: right; background: none; border: none; color: var(--error); cursor: pointer;">×</button>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(errorDiv);
|
||||
|
||||
// Auto-remove after 5 seconds
|
||||
setTimeout(() => {
|
||||
if (errorDiv.parentElement) {
|
||||
errorDiv.parentElement.removeChild(errorDiv);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
showSuccess(message) {
|
||||
// Create temporary success notification
|
||||
const successDiv = document.createElement('div');
|
||||
successDiv.className = 'admin-card status-card status-card--success';
|
||||
successDiv.style.cssText = 'position: fixed; top: 20px; right: 20px; z-index: 1000; min-width: 300px;';
|
||||
successDiv.innerHTML = `
|
||||
<div class="admin-card__content">
|
||||
<strong>Success:</strong> ${message}
|
||||
<button onclick="this.parentElement.parentElement.remove()" style="float: right; background: none; border: none; color: var(--success); cursor: pointer;">×</button>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(successDiv);
|
||||
|
||||
// Auto-remove after 3 seconds
|
||||
setTimeout(() => {
|
||||
if (successDiv.parentElement) {
|
||||
successDiv.parentElement.removeChild(successDiv);
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize dashboard when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
new HealthDashboard();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
496
src/Application/Admin/templates/health-dashboard.view.php
Normal file
496
src/Application/Admin/templates/health-dashboard.view.php
Normal file
@@ -0,0 +1,496 @@
|
||||
<?php
|
||||
/** @var \App\Framework\Core\ViewModel $this */
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>System Health Dashboard - Admin</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #f5f5f7;
|
||||
color: #1d1d1f;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e5e5e7;
|
||||
padding: 1rem 2rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #1d1d1f;
|
||||
}
|
||||
|
||||
.header .subtitle {
|
||||
color: #86868b;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.dashboard {
|
||||
padding: 2rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.overall-status {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.07);
|
||||
border: 1px solid #e5e5e7;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.status-description {
|
||||
color: #86868b;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.health-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.health-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.07);
|
||||
border: 1px solid #e5e5e7;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.health-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 15px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
font-size: 2rem;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.status-healthy {
|
||||
background: #d1f2db;
|
||||
color: #0f5132;
|
||||
}
|
||||
|
||||
.status-warning {
|
||||
background: #fff3cd;
|
||||
color: #664d03;
|
||||
}
|
||||
|
||||
.status-unhealthy {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.check-details {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #f2f2f7;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
color: #86868b;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.actions {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.07);
|
||||
border: 1px solid #e5e5e7;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
background: #007aff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.action-button:hover {
|
||||
background: #0051d0;
|
||||
}
|
||||
|
||||
.action-button.secondary {
|
||||
background: #f2f2f7;
|
||||
color: #1d1d1f;
|
||||
}
|
||||
|
||||
.action-button.secondary:hover {
|
||||
background: #e5e5e7;
|
||||
}
|
||||
|
||||
.loading {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: #f8d7da;
|
||||
border: 1px solid #f5c6cb;
|
||||
color: #721c24;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header {
|
||||
padding: 1rem;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.dashboard {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.health-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div>
|
||||
<h1>🏥 System Health Dashboard</h1>
|
||||
<p class="subtitle">Real-time System Health Monitoring</p>
|
||||
</div>
|
||||
<div>
|
||||
<button class="action-button" id="refreshBtn">
|
||||
🔄 Refresh All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard" id="dashboard">
|
||||
<!-- Overall Status -->
|
||||
<div class="overall-status" id="overallStatus">
|
||||
<div class="status-icon" id="statusIcon">⏳</div>
|
||||
<div class="status-text" id="statusText">Loading...</div>
|
||||
<div class="status-description" id="statusDescription">Checking system health...</div>
|
||||
</div>
|
||||
|
||||
<!-- Health Checks Grid -->
|
||||
<div class="health-grid" id="healthGrid">
|
||||
<!-- Health cards will be populated here -->
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="actions">
|
||||
<h3>Quick Actions</h3>
|
||||
<button class="action-button" id="runAllBtn">🔍 Run All Checks</button>
|
||||
<button class="action-button secondary" id="exportBtn">📊 Export Report</button>
|
||||
<button class="action-button secondary" onclick="window.location.href='../../../../logs'">📄 View Logs</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
class HealthDashboard {
|
||||
constructor() {
|
||||
this.refreshButton = document.getElementById('refreshBtn');
|
||||
this.runAllButton = document.getElementById('runAllBtn');
|
||||
this.exportButton = document.getElementById('exportBtn');
|
||||
this.setupEventListeners();
|
||||
this.loadHealthStatus();
|
||||
|
||||
// Auto-refresh every 30 seconds
|
||||
setInterval(() => this.loadHealthStatus(), 30000);
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
this.refreshButton.addEventListener('click', () => {
|
||||
this.loadHealthStatus();
|
||||
});
|
||||
|
||||
this.runAllButton.addEventListener('click', () => {
|
||||
this.runAllChecks();
|
||||
});
|
||||
|
||||
this.exportButton.addEventListener('click', () => {
|
||||
this.exportReport();
|
||||
});
|
||||
}
|
||||
|
||||
async loadHealthStatus() {
|
||||
try {
|
||||
this.setLoading(true);
|
||||
|
||||
const response = await fetch('/admin/health/api/status');
|
||||
if (!response.ok) throw new Error('Network error');
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
this.updateOverallStatus(data.data);
|
||||
this.updateHealthCards(data.data.checks);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading health status:', error);
|
||||
this.showError('Failed to load health status');
|
||||
} finally {
|
||||
this.setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async runAllChecks() {
|
||||
try {
|
||||
this.setLoading(true);
|
||||
|
||||
const response = await fetch('/admin/health/api/run-all', {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Network error');
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
this.updateOverallStatus(data.data);
|
||||
this.updateHealthCards(data.data.checks);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error running health checks:', error);
|
||||
this.showError('Failed to run health checks');
|
||||
} finally {
|
||||
this.setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
updateOverallStatus(data) {
|
||||
const statusIcon = document.getElementById('statusIcon');
|
||||
const statusText = document.getElementById('statusText');
|
||||
const statusDescription = document.getElementById('statusDescription');
|
||||
|
||||
statusIcon.textContent = data.overall_icon;
|
||||
statusIcon.style.color = data.overall_color;
|
||||
statusText.textContent = data.overall_status.charAt(0).toUpperCase() + data.overall_status.slice(1);
|
||||
statusDescription.textContent = `${data.summary.healthy_count} healthy, ${data.summary.warning_count} warnings, ${data.summary.unhealthy_count} failed`;
|
||||
}
|
||||
|
||||
updateHealthCards(checks) {
|
||||
const grid = document.getElementById('healthGrid');
|
||||
grid.innerHTML = '';
|
||||
|
||||
checks.forEach(check => {
|
||||
const card = this.createHealthCard(check);
|
||||
grid.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
createHealthCard(check) {
|
||||
const result = check.result;
|
||||
const card = document.createElement('div');
|
||||
card.className = 'health-card';
|
||||
|
||||
const statusClass = this.getStatusClass(result.status);
|
||||
const responseTime = result.response_time_ms ? `${result.response_time_ms}ms` : 'N/A';
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="card-header">
|
||||
<div class="card-icon">${this.getCategoryIcon(check.name)}</div>
|
||||
<div class="card-title">${check.name}</div>
|
||||
</div>
|
||||
|
||||
<div class="status-badge ${statusClass}">
|
||||
${result.status.charAt(0).toUpperCase() + result.status.slice(1)}
|
||||
</div>
|
||||
|
||||
<p>${result.message}</p>
|
||||
|
||||
<div class="check-details">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Response Time:</span>
|
||||
<span class="detail-value">${responseTime}</span>
|
||||
</div>
|
||||
${this.renderDetails(result.details)}
|
||||
</div>
|
||||
`;
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
renderDetails(details) {
|
||||
if (!details || Object.keys(details).length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return Object.entries(details).map(([key, value]) => `
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">${this.formatKey(key)}:</span>
|
||||
<span class="detail-value">${this.formatValue(value)}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
getStatusClass(status) {
|
||||
switch (status) {
|
||||
case 'healthy': return 'status-healthy';
|
||||
case 'warning': return 'status-warning';
|
||||
case 'unhealthy': return 'status-unhealthy';
|
||||
default: return 'status-unhealthy';
|
||||
}
|
||||
}
|
||||
|
||||
getCategoryIcon(name) {
|
||||
const icons = {
|
||||
'Database Connection': '🗄️',
|
||||
'Cache System': '⚡',
|
||||
'Disk Space': '💾',
|
||||
'System Resources': '🖥️'
|
||||
};
|
||||
return icons[name] || '🔧';
|
||||
}
|
||||
|
||||
formatKey(key) {
|
||||
return key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
||||
}
|
||||
|
||||
formatValue(value) {
|
||||
if (typeof value === 'number') {
|
||||
return value.toLocaleString();
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.join(', ');
|
||||
}
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
// Handle objects that might be arrays in disguise
|
||||
if (Object.prototype.toString.call(value) === '[object Array]') {
|
||||
return value.join(', ');
|
||||
}
|
||||
// Check if it's an object with numeric keys (PHP array)
|
||||
const keys = Object.keys(value);
|
||||
if (keys.length > 0 && keys.every(key => !isNaN(key))) {
|
||||
return Object.values(value).join(', ');
|
||||
}
|
||||
// For other objects, try to stringify
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch (e) {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
setLoading(loading) {
|
||||
const dashboard = document.getElementById('dashboard');
|
||||
if (loading) {
|
||||
dashboard.classList.add('loading');
|
||||
this.refreshButton.textContent = '⏳ Loading...';
|
||||
} else {
|
||||
dashboard.classList.remove('loading');
|
||||
this.refreshButton.textContent = '🔄 Refresh All';
|
||||
}
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
const grid = document.getElementById('healthGrid');
|
||||
grid.innerHTML = `
|
||||
<div class="error-message">
|
||||
<strong>Error:</strong> ${message}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async exportReport() {
|
||||
try {
|
||||
const response = await fetch('/admin/health/api/status');
|
||||
const data = await response.json();
|
||||
|
||||
const report = JSON.stringify(data, null, 2);
|
||||
const blob = new Blob([report], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `health-report-${new Date().toISOString().slice(0, 19)}.json`;
|
||||
a.click();
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('Export failed:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize dashboard when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
new HealthDashboard();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
747
src/Application/Admin/templates/log-viewer.view.php
Normal file
747
src/Application/Admin/templates/log-viewer.view.php
Normal file
@@ -0,0 +1,747 @@
|
||||
<?php
|
||||
/** @var \App\Framework\Core\ViewModel $this */
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Log Viewer - Admin</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #f5f5f7;
|
||||
color: #1d1d1f;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e5e5e7;
|
||||
padding: 1rem 2rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #1d1d1f;
|
||||
}
|
||||
|
||||
.header .subtitle {
|
||||
color: #86868b;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
height: calc(100vh - 80px);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 300px;
|
||||
background: #fff;
|
||||
border-right: 1px solid #e5e5e7;
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.controls {
|
||||
background: #fff;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.log-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.log-item {
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 0.5rem;
|
||||
border: 1px solid #f2f2f7;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.log-item:hover {
|
||||
background: #f2f2f7;
|
||||
border-color: #007aff;
|
||||
}
|
||||
|
||||
.log-item.active {
|
||||
background: #007aff;
|
||||
color: white;
|
||||
border-color: #007aff;
|
||||
}
|
||||
|
||||
.log-name {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.log-info {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.log-content {
|
||||
background: #1e1e1e;
|
||||
color: #f8f8f2;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
margin-bottom: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.log-entry.level-debug {
|
||||
border-left-color: #6c757d;
|
||||
background: rgba(108, 117, 125, 0.1);
|
||||
}
|
||||
|
||||
.log-entry.level-info {
|
||||
border-left-color: #17a2b8;
|
||||
background: rgba(23, 162, 184, 0.1);
|
||||
}
|
||||
|
||||
.log-entry.level-warning {
|
||||
border-left-color: #ffc107;
|
||||
background: rgba(255, 193, 7, 0.1);
|
||||
}
|
||||
|
||||
.log-entry.level-error {
|
||||
border-left-color: #dc3545;
|
||||
background: rgba(220, 53, 69, 0.1);
|
||||
}
|
||||
|
||||
.log-entry.level-critical {
|
||||
border-left-color: #800020;
|
||||
background: rgba(128, 0, 32, 0.2);
|
||||
}
|
||||
|
||||
.log-timestamp {
|
||||
color: #a6a6a6;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.log-level {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
|
||||
.log-level.debug { background: #6c757d; color: white; }
|
||||
.log-level.info { background: #17a2b8; color: white; }
|
||||
.log-level.warning { background: #ffc107; color: black; }
|
||||
.log-level.error { background: #dc3545; color: white; }
|
||||
.log-level.critical { background: #800020; color: white; }
|
||||
|
||||
.log-message {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.log-context {
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: #a6a6a6;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #e5e5e7;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #007aff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #0051d0;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #f2f2f7;
|
||||
color: #1d1d1f;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #e5e5e7;
|
||||
}
|
||||
|
||||
.stream-indicator {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #4CAF50;
|
||||
border-radius: 50%;
|
||||
margin-right: 0.5rem;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
.stream-status {
|
||||
background: linear-gradient(135deg, #2a2a2a, #333);
|
||||
border: 1px solid #4CAF50;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
color: #86868b;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
color: #86868b;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.log-content {
|
||||
height: 400px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div>
|
||||
<h1>📄 Log Viewer</h1>
|
||||
<p class="subtitle">Real-time Log Analysis and Monitoring</p>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-secondary" onclick="window.location.href='/admin/health'">
|
||||
🏥 Health Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<div class="sidebar">
|
||||
<h3>Available Logs</h3>
|
||||
<ul class="log-list" id="logList">
|
||||
<li class="loading">Loading logs...</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="main-content">
|
||||
<div class="controls">
|
||||
<select class="form-control" id="levelFilter">
|
||||
<option value="">All Levels</option>
|
||||
<option value="DEBUG">Debug</option>
|
||||
<option value="INFO">Info</option>
|
||||
<option value="WARNING">Warning</option>
|
||||
<option value="ERROR">Error</option>
|
||||
<option value="CRITICAL">Critical</option>
|
||||
</select>
|
||||
|
||||
<input type="text" class="form-control" id="searchInput" placeholder="Search logs...">
|
||||
|
||||
<input type="number" class="form-control" id="limitInput" value="100" min="10" max="1000" placeholder="Limit">
|
||||
|
||||
<button class="btn btn-primary" id="refreshBtn">🔄 Refresh</button>
|
||||
<button class="btn btn-secondary" id="tailBtn">📡 Tail</button>
|
||||
<button class="btn btn-secondary" id="streamBtn">📺 Stream Live</button>
|
||||
<button class="btn btn-secondary" id="searchBtn">🔍 Search All</button>
|
||||
</div>
|
||||
|
||||
<div class="log-content" id="logContent">
|
||||
<div class="empty-state">
|
||||
<h3>Select a log file to view</h3>
|
||||
<p>Choose a log file from the sidebar to start viewing logs</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
class LogViewer {
|
||||
constructor() {
|
||||
this.currentLog = null;
|
||||
this.refreshInterval = null;
|
||||
this.streamEventSource = null;
|
||||
this.isStreaming = false;
|
||||
this.streamEntries = [];
|
||||
this.setupEventListeners();
|
||||
this.loadAvailableLogs();
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
document.getElementById('refreshBtn').addEventListener('click', () => {
|
||||
if (this.currentLog) {
|
||||
this.loadLogContent(this.currentLog);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('tailBtn').addEventListener('click', () => {
|
||||
if (this.currentLog) {
|
||||
this.tailLog(this.currentLog);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('streamBtn').addEventListener('click', () => {
|
||||
if (this.currentLog) {
|
||||
this.toggleStream();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('searchBtn').addEventListener('click', () => {
|
||||
this.searchAllLogs();
|
||||
});
|
||||
|
||||
document.getElementById('searchInput').addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (this.currentLog) {
|
||||
this.loadLogContent(this.currentLog);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('levelFilter').addEventListener('change', () => {
|
||||
if (this.currentLog) {
|
||||
this.loadLogContent(this.currentLog);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async loadAvailableLogs() {
|
||||
try {
|
||||
const response = await fetch('/admin/logs/api/list');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
this.renderLogList(data.data.logs);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading logs:', error);
|
||||
document.getElementById('logList').innerHTML =
|
||||
'<li class="log-item">Error loading logs</li>';
|
||||
}
|
||||
}
|
||||
|
||||
renderLogList(logs) {
|
||||
const logList = document.getElementById('logList');
|
||||
logList.innerHTML = '';
|
||||
|
||||
Object.values(logs).forEach(log => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'log-item';
|
||||
li.innerHTML = `
|
||||
<div class="log-name">${log.name}</div>
|
||||
<div class="log-info">
|
||||
${log.size_human} • ${log.modified_human}
|
||||
${!log.readable ? ' • ⚠️ Not readable' : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (log.readable) {
|
||||
li.addEventListener('click', () => {
|
||||
this.selectLog(log.name, li);
|
||||
});
|
||||
} else {
|
||||
li.style.opacity = '0.5';
|
||||
li.style.cursor = 'not-allowed';
|
||||
}
|
||||
|
||||
logList.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
selectLog(logName, element) {
|
||||
// Stop any active stream
|
||||
if (this.isStreaming) {
|
||||
this.stopStream();
|
||||
}
|
||||
|
||||
// Update active state
|
||||
document.querySelectorAll('.log-item').forEach(item => {
|
||||
item.classList.remove('active');
|
||||
});
|
||||
element.classList.add('active');
|
||||
|
||||
this.currentLog = logName;
|
||||
this.loadLogContent(logName);
|
||||
}
|
||||
|
||||
async loadLogContent(logName) {
|
||||
const logContent = document.getElementById('logContent');
|
||||
logContent.innerHTML = '<div class="loading">Loading log content...</div>';
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
const level = document.getElementById('levelFilter').value;
|
||||
const search = document.getElementById('searchInput').value;
|
||||
const limit = document.getElementById('limitInput').value;
|
||||
|
||||
if (level) params.set('level', level);
|
||||
if (search) params.set('search', search);
|
||||
if (limit) params.set('limit', limit);
|
||||
|
||||
const response = await fetch(`/admin/logs/api/read/${logName}?${params}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
this.renderLogEntries(data.data.entries);
|
||||
} else {
|
||||
logContent.innerHTML = `<div class="empty-state">Error: ${data.message}</div>`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading log content:', error);
|
||||
logContent.innerHTML = '<div class="empty-state">Error loading log content</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async tailLog(logName) {
|
||||
try {
|
||||
const lines = document.getElementById('limitInput').value || 50;
|
||||
const response = await fetch(`/admin/logs/api/tail/${logName}?lines=${lines}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
this.renderLogEntries(data.data.entries);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error tailing log:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async searchAllLogs() {
|
||||
const search = document.getElementById('searchInput').value;
|
||||
if (!search) {
|
||||
alert('Please enter a search query');
|
||||
return;
|
||||
}
|
||||
|
||||
const logContent = document.getElementById('logContent');
|
||||
logContent.innerHTML = '<div class="loading">Searching all logs...</div>';
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set('query', search);
|
||||
|
||||
const level = document.getElementById('levelFilter').value;
|
||||
if (level) params.set('level', level);
|
||||
|
||||
const response = await fetch(`/admin/logs/api/search?${params}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
this.renderSearchResults(data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error searching logs:', error);
|
||||
logContent.innerHTML = '<div class="empty-state">Error searching logs</div>';
|
||||
}
|
||||
}
|
||||
|
||||
toggleStream() {
|
||||
if (this.isStreaming) {
|
||||
this.stopStream();
|
||||
} else {
|
||||
this.startStream();
|
||||
}
|
||||
}
|
||||
|
||||
startStream() {
|
||||
if (!this.currentLog || this.isStreaming) return;
|
||||
|
||||
const streamBtn = document.getElementById('streamBtn');
|
||||
streamBtn.textContent = '⏹️ Stop Stream';
|
||||
streamBtn.classList.remove('btn-secondary');
|
||||
streamBtn.classList.add('btn-primary');
|
||||
|
||||
this.isStreaming = true;
|
||||
this.streamEntries = [];
|
||||
|
||||
const logContent = document.getElementById('logContent');
|
||||
logContent.innerHTML = `
|
||||
<div class="stream-status" style="padding: 1rem; border-radius: 4px; margin-bottom: 1rem; color: #4CAF50;">
|
||||
<span class="stream-indicator"></span><strong>Live Streaming:</strong> ${this.currentLog}
|
||||
<span style="float: right;" id="streamStats">0 entries</span>
|
||||
</div>
|
||||
<div id="streamContent"></div>
|
||||
`;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
const level = document.getElementById('levelFilter').value;
|
||||
const search = document.getElementById('searchInput').value;
|
||||
const batchSize = 10;
|
||||
|
||||
if (level) params.set('level', level);
|
||||
if (search) params.set('search', search);
|
||||
params.set('batch_size', batchSize);
|
||||
|
||||
const url = `/admin/logs/api/stream/${this.currentLog}?${params}`;
|
||||
this.streamEventSource = new EventSource(url);
|
||||
|
||||
this.streamEventSource.addEventListener('log_batch', (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.status === 'success') {
|
||||
this.handleStreamBatch(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing stream data:', error);
|
||||
}
|
||||
});
|
||||
|
||||
this.streamEventSource.addEventListener('stream_complete', (event) => {
|
||||
console.log('Stream completed');
|
||||
this.updateStreamStatus('Stream completed');
|
||||
});
|
||||
|
||||
this.streamEventSource.addEventListener('error', (event) => {
|
||||
console.error('Stream error:', event);
|
||||
this.stopStream();
|
||||
this.updateStreamStatus('Stream error occurred');
|
||||
});
|
||||
|
||||
this.streamEventSource.onerror = (error) => {
|
||||
console.error('EventSource error:', error);
|
||||
this.stopStream();
|
||||
};
|
||||
}
|
||||
|
||||
stopStream() {
|
||||
if (!this.isStreaming) return;
|
||||
|
||||
const streamBtn = document.getElementById('streamBtn');
|
||||
streamBtn.textContent = '📺 Stream Live';
|
||||
streamBtn.classList.remove('btn-primary');
|
||||
streamBtn.classList.add('btn-secondary');
|
||||
|
||||
this.isStreaming = false;
|
||||
|
||||
if (this.streamEventSource) {
|
||||
this.streamEventSource.close();
|
||||
this.streamEventSource = null;
|
||||
}
|
||||
|
||||
this.updateStreamStatus('Stream stopped');
|
||||
}
|
||||
|
||||
handleStreamBatch(data) {
|
||||
const streamContent = document.getElementById('streamContent');
|
||||
const streamStats = document.getElementById('streamStats');
|
||||
|
||||
// Füge neue Einträge hinzu
|
||||
data.batch.forEach(entry => {
|
||||
this.streamEntries.push(entry);
|
||||
});
|
||||
|
||||
// Update Statistics
|
||||
if (streamStats) {
|
||||
streamStats.textContent = `${this.streamEntries.length} entries (Batch ${data.batch_number})`;
|
||||
}
|
||||
|
||||
// Render neue Einträge
|
||||
const newEntriesHtml = data.batch.map(entry => {
|
||||
const levelClass = `level-${entry.level.toLowerCase()}`;
|
||||
return `
|
||||
<div class="log-entry ${levelClass}">
|
||||
<div>
|
||||
<span class="log-timestamp">${entry.timestamp}</span>
|
||||
<span class="log-level ${entry.level.toLowerCase()}">${entry.level}</span>
|
||||
</div>
|
||||
<div class="log-message">${this.escapeHtml(entry.message)}</div>
|
||||
${entry.context ? `<div class="log-context">${this.escapeHtml(entry.context)}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
if (streamContent) {
|
||||
streamContent.innerHTML += newEntriesHtml;
|
||||
|
||||
// Auto-scroll to bottom
|
||||
const logContent = document.getElementById('logContent');
|
||||
logContent.scrollTop = logContent.scrollHeight;
|
||||
}
|
||||
|
||||
// Begrenze die Anzahl der angezeigten Einträge (Performance)
|
||||
if (this.streamEntries.length > 500) {
|
||||
this.streamEntries = this.streamEntries.slice(-400); // Keep last 400
|
||||
this.reRenderStreamEntries();
|
||||
}
|
||||
}
|
||||
|
||||
reRenderStreamEntries() {
|
||||
const streamContent = document.getElementById('streamContent');
|
||||
if (streamContent) {
|
||||
streamContent.innerHTML = this.streamEntries.map(entry => {
|
||||
const levelClass = `level-${entry.level.toLowerCase()}`;
|
||||
return `
|
||||
<div class="log-entry ${levelClass}">
|
||||
<div>
|
||||
<span class="log-timestamp">${entry.timestamp}</span>
|
||||
<span class="log-level ${entry.level.toLowerCase()}">${entry.level}</span>
|
||||
</div>
|
||||
<div class="log-message">${this.escapeHtml(entry.message)}</div>
|
||||
${entry.context ? `<div class="log-context">${this.escapeHtml(entry.context)}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
|
||||
updateStreamStatus(message) {
|
||||
const streamStats = document.getElementById('streamStats');
|
||||
if (streamStats) {
|
||||
streamStats.textContent = message;
|
||||
}
|
||||
}
|
||||
|
||||
renderLogEntries(entries) {
|
||||
const logContent = document.getElementById('logContent');
|
||||
|
||||
if (entries.length === 0) {
|
||||
logContent.innerHTML = '<div class="empty-state">No log entries found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
logContent.innerHTML = entries.map(entry => {
|
||||
const levelClass = `level-${entry.level.toLowerCase()}`;
|
||||
return `
|
||||
<div class="log-entry ${levelClass}">
|
||||
<div>
|
||||
<span class="log-timestamp">${entry.timestamp}</span>
|
||||
<span class="log-level ${entry.level.toLowerCase()}">${entry.level}</span>
|
||||
</div>
|
||||
<div class="log-message">${this.escapeHtml(entry.message)}</div>
|
||||
${entry.context ? `<div class="log-context">${this.escapeHtml(entry.context)}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Scroll to bottom
|
||||
logContent.scrollTop = logContent.scrollHeight;
|
||||
}
|
||||
|
||||
renderSearchResults(searchData) {
|
||||
const logContent = document.getElementById('logContent');
|
||||
|
||||
if (searchData.total_matches === 0) {
|
||||
logContent.innerHTML = '<div class="empty-state">No matches found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = `
|
||||
<div style="padding: 1rem; background: #2a2a2a; border-radius: 4px; margin-bottom: 1rem;">
|
||||
<strong>Search Results:</strong> ${searchData.total_matches} matches for "${searchData.query}"
|
||||
</div>
|
||||
`;
|
||||
|
||||
Object.entries(searchData.results).forEach(([logName, logData]) => {
|
||||
if (logData.entries) {
|
||||
html += `
|
||||
<div style="padding: 0.5rem; background: #333; margin-bottom: 1rem; border-radius: 4px;">
|
||||
<strong>${logName}</strong> (${logData.total_entries} matches)
|
||||
</div>
|
||||
`;
|
||||
|
||||
logData.entries.forEach(entry => {
|
||||
const levelClass = `level-${entry.level.toLowerCase()}`;
|
||||
html += `
|
||||
<div class="log-entry ${levelClass}">
|
||||
<div>
|
||||
<span class="log-timestamp">${entry.timestamp}</span>
|
||||
<span class="log-level ${entry.level.toLowerCase()}">${entry.level}</span>
|
||||
</div>
|
||||
<div class="log-message">${this.escapeHtml(entry.message)}</div>
|
||||
${entry.context ? `<div class="log-context">${this.escapeHtml(entry.context)}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
logContent.innerHTML = html;
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize log viewer when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
new LogViewer();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,11 +1,46 @@
|
||||
<layout src="admin-main"/>
|
||||
|
||||
<div>Routes Page</div>
|
||||
<div class="admin-content">
|
||||
<h1>{{ title }}</h1>
|
||||
|
||||
<div class="admin-tools">
|
||||
<input type="text" id="routeFilter" placeholder="Routen filtern..." class="search-input">
|
||||
</div>
|
||||
|
||||
<p>{{ name }}</p>
|
||||
<table class="admin-table" id="routesTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Pfad</th>
|
||||
<th>Methode</th>
|
||||
<th>Controller</th>
|
||||
<th>Handler</th>
|
||||
<th>Name</th>
|
||||
<th>Middleware</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<for var="route" in="routes">
|
||||
<tr>
|
||||
<td>{{ route.path }}</td>
|
||||
<td><span class="method-badge method-{{ route.method | lower }}">{{ route.method }}</span></td>
|
||||
<td>{{ route.controller }}</td>
|
||||
<td>{{ route.handler }}</td>
|
||||
<td>{{ route.name | default('-') }}</td>
|
||||
<td>{{ route.middleware | join(', ') | default('-') }}</td>
|
||||
</tr>
|
||||
</for>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<ul>
|
||||
<for var="route" in="routes">
|
||||
<li><a href="{{ route.path }}">{{ route.path }} </a> <i> ({{ route.class }}) {{ route.attributes.0 }} </i> </li>
|
||||
</for>
|
||||
</ul>
|
||||
<script>
|
||||
document.getElementById('routeFilter').addEventListener('input', function() {
|
||||
const filterValue = this.value.toLowerCase();
|
||||
const rows = document.querySelectorAll('#routesTable tbody tr');
|
||||
|
||||
rows.forEach(row => {
|
||||
const text = row.textContent.toLowerCase();
|
||||
row.style.display = text.includes(filterValue) ? '' : 'none';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
<div class="admin-section">
|
||||
<h2>PHP Erweiterungen</h2>
|
||||
<div class="extensions-list">
|
||||
<?php foreach($stats['loadedExtensions'] as $extension): ?>
|
||||
<?php foreach ($stats['loadedExtensions'] as $extension): ?>
|
||||
<span class="extension-badge"><?= $extension ?></span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= $title ?></title>
|
||||
<title>{{ title }}</title>
|
||||
<link rel="stylesheet" href="/css/admin.css">
|
||||
</head>
|
||||
<body class="admin-page">
|
||||
@@ -42,18 +42,18 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach($env as $key => $value): ?>
|
||||
<tr>
|
||||
<td><?= $key ?></td>
|
||||
<td><?= is_array($value) ? json_encode($value) : $value ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<for var="envVar" in="env">
|
||||
<tr>
|
||||
<td>{{ envVar.key }}</td>
|
||||
<td>{{ envVar.value }}</td>
|
||||
</tr>
|
||||
</for>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="admin-footer">
|
||||
<p>© <?= date('Y') ?> Framework Admin</p>
|
||||
<p>© {{ current_year }} Framework Admin</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
250
src/Application/Admin/views/admin/image-manager.view.php
Normal file
250
src/Application/Admin/views/admin/image-manager.view.php
Normal file
@@ -0,0 +1,250 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Image Manager</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="container-fluid">
|
||||
<h1>Image Manager</h1>
|
||||
|
||||
<div class="row">
|
||||
<!-- Image Slots Section -->
|
||||
<div class="col-md-6">
|
||||
<h2>Image Slots</h2>
|
||||
<div id="image-slots" class="list-group">
|
||||
<for var="slot" in="slots">
|
||||
<div class="list-group-item slot-item" data-slot-id="{{ slot.id }}">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h5>{{ slot.slotName }}</h5>
|
||||
<small class="text-muted">ID: {{ slot.id }}</small>
|
||||
</div>
|
||||
<div class="slot-image-container" style="width: 100px; height: 100px;">
|
||||
<div class="border border-dashed d-flex align-items-center justify-content-center h-100"
|
||||
ondrop="handleDrop(event, '{{ slot.id }}')"
|
||||
ondragover="handleDragOver(event)"
|
||||
ondragleave="handleDragLeave(event)">
|
||||
<span class="text-muted">Drop image here or click to select</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</for>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Available Images Section -->
|
||||
<div class="col-md-6">
|
||||
<h2>Available Images</h2>
|
||||
|
||||
<!-- Search Bar -->
|
||||
<div class="mb-3">
|
||||
<input type="text"
|
||||
id="image-search"
|
||||
class="form-control"
|
||||
placeholder="Search images..."
|
||||
onkeyup="searchImages()">
|
||||
</div>
|
||||
|
||||
<!-- Images Grid -->
|
||||
<div id="images-grid" class="row g-2">
|
||||
<for var="image" in="images">
|
||||
<div class="col-md-4 image-item"
|
||||
data-filename="{{ image.originalFilename }}"
|
||||
data-alt="{{ image.altText }}">
|
||||
<div class="card">
|
||||
<img src="/media/images/{{ image.path }}"
|
||||
alt="{{ image.altText }}"
|
||||
class="card-img-top"
|
||||
style="height: 150px; object-fit: cover; cursor: move;"
|
||||
draggable="true"
|
||||
ondragstart="handleDragStart(event, '{{ image.ulid }}')"
|
||||
onclick="selectImage('{{ image.ulid }}')">
|
||||
<div class="card-body p-2">
|
||||
<small class="text-truncate d-block">
|
||||
{{ image.originalFilename }}
|
||||
</small>
|
||||
<small class="text-muted">
|
||||
{{ image.width }}x{{ image.height }} • {{ image.fileSize }}KB
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</for>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image Selection Modal -->
|
||||
<div class="modal fade" id="imageSelectModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Select Image for <span id="modal-slot-name"></span></h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="modal-images" class="row g-2">
|
||||
<!-- Images will be loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Current dragging image
|
||||
let draggedImageUlid = null;
|
||||
let selectedSlotId = null;
|
||||
|
||||
// Handle drag start
|
||||
function handleDragStart(event, imageUlid) {
|
||||
draggedImageUlid = imageUlid;
|
||||
event.dataTransfer.effectAllowed = 'copy';
|
||||
}
|
||||
|
||||
// Handle drag over
|
||||
function handleDragOver(event) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.classList.add('bg-light');
|
||||
}
|
||||
|
||||
// Handle drag leave
|
||||
function handleDragLeave(event) {
|
||||
event.currentTarget.classList.remove('bg-light');
|
||||
}
|
||||
|
||||
// Handle drop
|
||||
async function handleDrop(event, slotId) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.classList.remove('bg-light');
|
||||
|
||||
if (draggedImageUlid) {
|
||||
await assignImageToSlot(slotId, draggedImageUlid);
|
||||
}
|
||||
}
|
||||
|
||||
// Assign image to slot
|
||||
async function assignImageToSlot(slotId, imageUlid) {
|
||||
try {
|
||||
const response = await fetch(`/api/image-slots/${slotId}/image`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ image_ulid: imageUlid })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
location.reload(); // Simple reload for now
|
||||
} else {
|
||||
alert('Failed to assign image');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
alert('Error assigning image');
|
||||
}
|
||||
}
|
||||
|
||||
// Remove image from slot
|
||||
async function removeImage(slotId) {
|
||||
if (!confirm('Remove image from this slot?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/image-slots/${slotId}/image`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Failed to remove image');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
alert('Error removing image');
|
||||
}
|
||||
}
|
||||
|
||||
// Select image (click handler)
|
||||
function selectImage(imageUlid) {
|
||||
// Find which slot was clicked if any
|
||||
const clickedSlot = document.querySelector('.slot-item.selecting');
|
||||
if (clickedSlot) {
|
||||
const slotId = clickedSlot.dataset.slotId;
|
||||
assignImageToSlot(slotId, imageUlid);
|
||||
clickedSlot.classList.remove('selecting');
|
||||
}
|
||||
}
|
||||
|
||||
// Search images
|
||||
function searchImages() {
|
||||
const searchTerm = document.getElementById('image-search').value.toLowerCase();
|
||||
const imageItems = document.querySelectorAll('.image-item');
|
||||
|
||||
imageItems.forEach(item => {
|
||||
const filename = item.dataset.filename.toLowerCase();
|
||||
const alt = item.dataset.alt.toLowerCase();
|
||||
|
||||
if (filename.includes(searchTerm) || alt.includes(searchTerm)) {
|
||||
item.style.display = '';
|
||||
} else {
|
||||
item.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add click handler to slots for selection mode
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('.slot-item').forEach(slot => {
|
||||
const container = slot.querySelector('.slot-image-container');
|
||||
if (container && !container.querySelector('img')) {
|
||||
container.style.cursor = 'pointer';
|
||||
container.addEventListener('click', function() {
|
||||
// Remove previous selection
|
||||
document.querySelectorAll('.slot-item').forEach(s => s.classList.remove('selecting'));
|
||||
// Mark as selecting
|
||||
slot.classList.add('selecting');
|
||||
// Highlight available images
|
||||
document.getElementById('images-grid').classList.add('selecting-mode');
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add some CSS
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.slot-item.selecting {
|
||||
border: 2px solid #0d6efd;
|
||||
background-color: #e7f1ff;
|
||||
}
|
||||
|
||||
.selecting-mode .card {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.selecting-mode .card:hover {
|
||||
border-color: #0d6efd;
|
||||
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25);
|
||||
}
|
||||
|
||||
.border-dashed {
|
||||
border-style: dashed !important;
|
||||
}
|
||||
|
||||
.object-fit-cover {
|
||||
object-fit: cover;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
</script>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= $title ?></title>
|
||||
<title>{{ title }}</title>
|
||||
<link rel="stylesheet" href="/css/admin.css">
|
||||
</head>
|
||||
<body class="admin-page">
|
||||
@@ -25,68 +25,68 @@
|
||||
<div class="dashboard-stats">
|
||||
<div class="stat-box">
|
||||
<h3>Aktueller Speicherverbrauch</h3>
|
||||
<div class="stat-value"><?= $performance['currentMemoryUsage'] ?></div>
|
||||
<div class="stat-value">{{ performance.currentMemoryUsage }}</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-box">
|
||||
<h3>Maximaler Speicherverbrauch</h3>
|
||||
<div class="stat-value"><?= $performance['peakMemoryUsage'] ?></div>
|
||||
<div class="stat-value">{{ performance.peakMemoryUsage }}</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-box">
|
||||
<h3>Speicherlimit</h3>
|
||||
<div class="stat-value"><?= $performance['memoryLimit'] ?></div>
|
||||
<div class="stat-value">{{ performance.memoryLimit }}</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-box">
|
||||
<h3>Speicherauslastung</h3>
|
||||
<div class="stat-value">
|
||||
<div class="progress-bar">
|
||||
<div class="progress" style="width: <?= $performance['memoryUsagePercentage'] ?>%"></div>
|
||||
<div class="progress" style="width: {{ performance.memoryUsagePercentage }}%"></div>
|
||||
</div>
|
||||
<div class="progress-value"><?= $performance['memoryUsagePercentage'] ?>%</div>
|
||||
<div class="progress-value">{{ performance.memoryUsagePercentage }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-box">
|
||||
<h3>Systemlast (1/5/15 min)</h3>
|
||||
<div class="stat-value">
|
||||
<?= $performance['loadAverage'][0] ?> /
|
||||
<?= $performance['loadAverage'][1] ?> /
|
||||
<?= $performance['loadAverage'][2] ?>
|
||||
{{ performance.loadAverage.0 }} /
|
||||
{{ performance.loadAverage.1 }} /
|
||||
{{ performance.loadAverage.2 }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-box">
|
||||
<h3>OPCache aktiviert</h3>
|
||||
<div class="stat-value"><?= $performance['opcacheEnabled'] ?></div>
|
||||
<div class="stat-value">{{ performance.opcacheEnabled }}</div>
|
||||
</div>
|
||||
|
||||
<?php if (isset($performance['opcacheMemoryUsage'])): ?>
|
||||
<div class="stat-box">
|
||||
<h3>OPCache Speicherverbrauch</h3>
|
||||
<div class="stat-value"><?= $performance['opcacheMemoryUsage'] ?></div>
|
||||
</div>
|
||||
<div if="performance.opcacheMemoryUsage">
|
||||
<div class="stat-box">
|
||||
<h3>OPCache Speicherverbrauch</h3>
|
||||
<div class="stat-value">{{ performance.opcacheMemoryUsage }}</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-box">
|
||||
<h3>OPCache Cache Hits</h3>
|
||||
<div class="stat-value"><?= $performance['opcacheCacheHits'] ?></div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<h3>OPCache Cache Hits</h3>
|
||||
<div class="stat-value">{{ performance.opcacheCacheHits }}</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-box">
|
||||
<h3>OPCache Miss Rate</h3>
|
||||
<div class="stat-value"><?= $performance['opcacheMissRate'] ?></div>
|
||||
<div class="stat-box">
|
||||
<h3>OPCache Miss Rate</h3>
|
||||
<div class="stat-value">{{ performance.opcacheMissRate }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="stat-box">
|
||||
<h3>Ausführungszeit</h3>
|
||||
<div class="stat-value"><?= $performance['executionTime'] ?></div>
|
||||
<div class="stat-value">{{ performance.executionTime }}</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-box">
|
||||
<h3>Geladene Dateien</h3>
|
||||
<div class="stat-value"><?= $performance['includedFiles'] ?></div>
|
||||
<div class="stat-value">{{ performance.includedFiles }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -97,17 +97,17 @@
|
||||
</div>
|
||||
|
||||
<div class="file-list" id="fileList">
|
||||
<?php foreach(get_included_files() as $file): ?>
|
||||
<div class="file-item">
|
||||
<?= $file ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<for var="file" in="performance.files">
|
||||
<div class="file-item">
|
||||
{{ file }}
|
||||
</div>
|
||||
</for>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-footer">
|
||||
<p>© <?= date('Y') ?> Framework Admin</p>
|
||||
<p>© {{ current_year }} Framework Admin</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= $title ?></title>
|
||||
<title>{{ title }}</title>
|
||||
<link rel="stylesheet" href="/css/admin.css">
|
||||
</head>
|
||||
<body class="admin-page">
|
||||
@@ -22,72 +22,61 @@
|
||||
</div>
|
||||
|
||||
<div class="admin-content">
|
||||
<?php if (isset($redis['status']) && $redis['status'] === 'Verbunden'): ?>
|
||||
<div class="dashboard-stats">
|
||||
<div class="stat-box">
|
||||
<h3>Status</h3>
|
||||
<div class="stat-value status-connected"><?= $redis['status'] ?></div>
|
||||
</div>
|
||||
|
||||
<div class="stat-box">
|
||||
<h3>Version</h3>
|
||||
<div class="stat-value"><?= $redis['version'] ?></div>
|
||||
</div>
|
||||
|
||||
<div class="stat-box">
|
||||
<h3>Uptime</h3>
|
||||
<div class="stat-value"><?= $redis['uptime'] ?></div>
|
||||
</div>
|
||||
|
||||
<div class="stat-box">
|
||||
<h3>Speicherverbrauch</h3>
|
||||
<div class="stat-value"><?= $redis['memory'] ?></div>
|
||||
</div>
|
||||
|
||||
<div class="stat-box">
|
||||
<h3>Max. Speicherverbrauch</h3>
|
||||
<div class="stat-value"><?= $redis['peak_memory'] ?></div>
|
||||
</div>
|
||||
|
||||
<div class="stat-box">
|
||||
<h3>Verbundene Clients</h3>
|
||||
<div class="stat-value"><?= $redis['clients'] ?></div>
|
||||
</div>
|
||||
|
||||
<div class="stat-box">
|
||||
<h3>Anzahl Schlüssel</h3>
|
||||
<div class="stat-value"><?= $redis['keys'] ?></div>
|
||||
</div>
|
||||
<div class="dashboard-stats">
|
||||
<div class="stat-box">
|
||||
<h3>Status</h3>
|
||||
<div class="stat-value status-connected">{{ redis.status }}</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-section">
|
||||
<h2>Schlüssel (max. 50 angezeigt)</h2>
|
||||
<div class="admin-tools">
|
||||
<input type="text" id="keyFilter" placeholder="Schlüssel filtern..." class="search-input">
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<h3>Version</h3>
|
||||
<div class="stat-value">{{ redis.version }}</div>
|
||||
</div>
|
||||
|
||||
<div class="key-list" id="keyList">
|
||||
<?php if (empty($redis['key_sample'])): ?>
|
||||
<div class="empty-message">Keine Schlüssel vorhanden</div>
|
||||
<?php else: ?>
|
||||
<?php foreach($redis['key_sample'] as $key): ?>
|
||||
<div class="key-item">
|
||||
<?= $key ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<h3>Uptime</h3>
|
||||
<div class="stat-value">{{ redis.uptime }}</div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="error-message">
|
||||
<h2>Redis-Verbindung fehlgeschlagen</h2>
|
||||
<p><?= $redis['status'] ?? 'Unbekannter Fehler' ?></p>
|
||||
|
||||
<div class="stat-box">
|
||||
<h3>Speicherverbrauch</h3>
|
||||
<div class="stat-value">{{ redis.memory }}</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="stat-box">
|
||||
<h3>Max. Speicherverbrauch</h3>
|
||||
<div class="stat-value">{{ redis.peak_memory }}</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-box">
|
||||
<h3>Verbundene Clients</h3>
|
||||
<div class="stat-value">{{ redis.clients }}</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-box">
|
||||
<h3>Anzahl Schlüssel</h3>
|
||||
<div class="stat-value">{{ redis.keys }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-section">
|
||||
<h2>Schlüssel (max. 50 angezeigt)</h2>
|
||||
<div class="admin-tools">
|
||||
<input type="text" id="keyFilter" placeholder="Schlüssel filtern..." class="search-input">
|
||||
</div>
|
||||
|
||||
<div class="key-list" id="keyList">
|
||||
<for var="key" in="redis.key_sample">
|
||||
<div class="key-item">
|
||||
{{ key }}
|
||||
</div>
|
||||
</for>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-footer">
|
||||
<p>© <?= date('Y') ?> Framework Admin</p>
|
||||
<p>© {{ current_year }} Framework Admin</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach($routes as $route): ?>
|
||||
<?php foreach ($routes as $route): ?>
|
||||
<tr>
|
||||
<td><?= $route->path ?></td>
|
||||
<td><?= $route->method ?></td>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= $title ?></title>
|
||||
<title>{{ title }}</title>
|
||||
<link rel="stylesheet" href="/css/admin.css">
|
||||
</head>
|
||||
<body class="admin-page">
|
||||
@@ -24,31 +24,26 @@
|
||||
<div class="admin-content">
|
||||
<div class="admin-tools">
|
||||
<input type="text" id="serviceFilter" placeholder="Dienste filtern..." class="search-input">
|
||||
<span class="services-count"><?= count($services) ?> Dienste insgesamt</span>
|
||||
<span class="services-count">{{ servicesCount }} Dienste insgesamt</span>
|
||||
</div>
|
||||
|
||||
<div class="service-list" id="serviceList">
|
||||
<?php foreach($services as $service): ?>
|
||||
<for var="service" in="services">
|
||||
<div class="service-item">
|
||||
<div class="service-name"><?= $service ?></div>
|
||||
<?php
|
||||
$parts = explode('\\', $service);
|
||||
$category = $parts[1] ?? 'Unknown';
|
||||
$subCategory = $parts[2] ?? '';
|
||||
?>
|
||||
<div class="service-name">{{ service.name }}</div>
|
||||
<div class="service-category">
|
||||
<span class="category-badge"><?= $category ?></span>
|
||||
<?php if ($subCategory): ?>
|
||||
<span class="subcategory-badge"><?= $subCategory ?></span>
|
||||
<?php endif; ?>
|
||||
<span class="category-badge">{{ service.category }}</span>
|
||||
<if condition="service.subCategory">
|
||||
<span class="subcategory-badge">{{ service.subCategory }}</span>
|
||||
</if>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</for>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-footer">
|
||||
<p>© <?= date('Y') ?> Framework Admin</p>
|
||||
<p>© {{ date('Y') }} Framework Admin</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@@ -65,7 +60,7 @@
|
||||
});
|
||||
|
||||
document.querySelector('.services-count').textContent =
|
||||
visibleCount + ' von ' + <?= count($services) ?> + ' Diensten';
|
||||
visibleCount + ' von ' + {{ servicesCount }} + ' Diensten';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
Reference in New Issue
Block a user