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:
2025-08-11 20:13:26 +02:00
parent 59fd3dd3b1
commit 55a330b223
3683 changed files with 2956207 additions and 16948 deletions

View 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'
};
}
}

View File

@@ -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();
}

View 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(),
]);
}
}

View File

@@ -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>";

View 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');
});
}
}
}

View File

@@ -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 = []
)
{
) {
}

View File

@@ -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>";
};
}
}
}

View 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,
]
);
}
}

View File

@@ -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');

View File

@@ -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>";

View File

@@ -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)
);
}
}

View 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)',
];
}
}

View File

@@ -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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>&copy; <?= date('Y') ?> Framework Admin</p>
<p>&copy; {{ current_year }} Framework Admin</p>
</div>
<script>

View 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>

View File

@@ -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>&copy; <?= date('Y') ?> Framework Admin</p>
<p>&copy; {{ current_year }} Framework Admin</p>
</div>
<script>

View File

@@ -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>&copy; <?= date('Y') ?> Framework Admin</p>
<p>&copy; {{ current_year }} Framework Admin</p>
</div>
<script>

View File

@@ -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>

View File

@@ -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>&copy; <?= date('Y') ?> Framework Admin</p>
<p>&copy; {{ 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>