fix: Gitea Traefik routing and connection pool optimization
Some checks failed
🚀 Build & Deploy Image / Determine Build Necessity (push) Failing after 10m14s
🚀 Build & Deploy Image / Build Runtime Base Image (push) Has been skipped
🚀 Build & Deploy Image / Build Docker Image (push) Has been skipped
🚀 Build & Deploy Image / Run Tests & Quality Checks (push) Has been skipped
🚀 Build & Deploy Image / Auto-deploy to Staging (push) Has been skipped
🚀 Build & Deploy Image / Auto-deploy to Production (push) Has been skipped
Security Vulnerability Scan / Check for Dependency Changes (push) Failing after 11m25s
Security Vulnerability Scan / Composer Security Audit (push) Has been cancelled

- Remove middleware reference from Gitea Traefik labels (caused routing issues)
- Optimize Gitea connection pool settings (MAX_IDLE_CONNS=30, authentication_timeout=180s)
- Add explicit service reference in Traefik labels
- Fix intermittent 504 timeouts by improving PostgreSQL connection handling

Fixes Gitea unreachability via git.michaelschiemer.de
This commit is contained in:
2025-11-09 14:46:15 +01:00
parent 85c369e846
commit 36ef2a1e2c
1366 changed files with 104925 additions and 28719 deletions

View File

@@ -4,6 +4,9 @@ declare(strict_types=1);
namespace App\Framework\View\Components;
use App\Application\Admin\ValueObjects\NavigationMenu;
use App\Application\Admin\ValueObjects\NavigationSection;
use App\Application\Admin\ValueObjects\NavigationItem;
use App\Framework\View\Attributes\ComponentName;
use App\Framework\View\Contracts\StaticComponent;
use App\Framework\View\Dom\ElementNode;
@@ -16,6 +19,7 @@ final readonly class AdminSidebar implements StaticComponent
private string $title;
private string $logoUrl;
private string $currentPath;
private ?NavigationMenu $navigationMenu;
public function __construct(
string $content = '',
@@ -24,7 +28,10 @@ final readonly class AdminSidebar implements StaticComponent
// Extract attributes with defaults
$this->title = $attributes['title'] ?? 'Admin Panel';
$this->logoUrl = $attributes['logo-url'] ?? '/admin';
$this->currentPath = $attributes['current-path'] ?? '/admin';
$this->currentPath = $attributes['current-path'] ?? $attributes['currentPath'] ?? '/admin';
// Parse navigation menu from attributes
$this->navigationMenu = $this->parseNavigationMenu($attributes);
}
public function getRootNode(): Node
@@ -42,6 +49,15 @@ final readonly class AdminSidebar implements StaticComponent
}
private function buildSidebarContent(): string
{
$header = $this->buildHeader();
$navigation = $this->buildNavigation();
$footer = $this->buildFooter();
return $header . $navigation . $footer;
}
private function buildHeader(): string
{
return <<<HTML
<div class="admin-sidebar__header">
@@ -49,10 +65,128 @@ final readonly class AdminSidebar implements StaticComponent
<span class="admin-sidebar__title">{$this->title}</span>
</a>
</div>
HTML;
}
private function buildNavigation(): string
{
if ($this->navigationMenu !== null) {
return $this->buildDynamicNavigation();
}
return $this->buildFallbackNavigation();
}
private function buildDynamicNavigation(): string
{
$html = '<nav class="admin-nav" aria-label="Primary">';
foreach ($this->navigationMenu->sections as $section) {
$html .= $this->renderSection($section);
}
$html .= '</nav>';
return $html;
}
private function renderSection(NavigationSection $section): string
{
$html = '<div class="admin-nav__section">';
if ($section->name !== '') {
$html .= '<h2 class="admin-nav__section-title">' . htmlspecialchars($section->name) . '</h2>';
}
$html .= '<ul class="admin-nav__list" role="list">';
foreach ($section->items as $item) {
$html .= $this->renderItem($item);
}
$html .= '</ul></div>';
return $html;
}
private function renderItem(NavigationItem $item): string
{
$activeState = $item->isActive($this->currentPath)
? 'aria-current="page"'
: '';
$iconHtml = $this->renderIcon($item->icon);
return <<<HTML
<li class="admin-nav__item">
<a href="{$this->escape($item->url)}" class="admin-nav__link" {$activeState}>
{$iconHtml}
<span>{$this->escape($item->name)}</span>
</a>
</li>
HTML;
}
private function renderIcon(?string $icon): string
{
if ($icon === null || $icon === '') {
return '';
}
// Try to match icon name to SVG or use emoji fallback
$svgIcon = $this->getSvgIcon($icon);
if ($svgIcon !== null) {
return '<svg class="admin-nav__icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">' . $svgIcon . '</svg>';
}
// Fallback to emoji or text
$emojiIcon = $this->getEmojiIcon($icon);
return '<span class="admin-nav__icon">' . $emojiIcon . '</span>';
}
private function getSvgIcon(string $iconName): ?string
{
// Common icon mappings
$icons = [
'dashboard' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>',
'server' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"/>',
'database' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4"/>',
'photo' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>',
'chart-bar' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>',
'code' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/>',
'bell' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/>',
'brain' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/>',
'file' => '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>',
];
return $icons[strtolower($iconName)] ?? null;
}
private function getEmojiIcon(string $iconName): string
{
$emoji = [
'dashboard' => '📊',
'server' => '🖥️',
'database' => '💾',
'photo' => '🖼️',
'chart-bar' => '📈',
'code' => '💻',
'bell' => '🔔',
'brain' => '🧠',
'file' => '📄',
];
return $emoji[strtolower($iconName)] ?? '📄';
}
private function buildFallbackNavigation(): string
{
// Fallback to old hardcoded navigation for backward compatibility
return <<<HTML
<nav class="admin-nav">
<div class="admin-nav__section">
<h3 class="admin-nav__section-title">Dashboard</h3>
<h2 class="admin-nav__section-title">Dashboard</h2>
<ul class="admin-nav__list" role="list">
<li class="admin-nav__item">
<a href="/admin" class="admin-nav__link" {$this->getActiveState('/admin')}>
@@ -62,50 +196,13 @@ final readonly class AdminSidebar implements StaticComponent
</li>
</ul>
</div>
<div class="admin-nav__section">
<h3 class="admin-nav__section-title">System</h3>
<ul class="admin-nav__list" role="list">
<li class="admin-nav__item">
<a href="/admin/infrastructure/cache" class="admin-nav__link" {$this->getActiveState('/admin/infrastructure/cache')}>
<span class="admin-nav__icon">💾</span>
<span>Cache</span>
</a>
</li>
<li class="admin-nav__item">
<a href="/admin/infrastructure/logs" class="admin-nav__link" {$this->getActiveState('/admin/infrastructure/logs')}>
<span class="admin-nav__icon">📝</span>
<span>Logs</span>
</a>
</li>
<li class="admin-nav__item">
<a href="/admin/infrastructure/migrations" class="admin-nav__link" {$this->getActiveState('/admin/infrastructure/migrations')}>
<span class="admin-nav__icon">🔄</span>
<span>Migrations</span>
</a>
</li>
</ul>
</div>
<div class="admin-nav__section">
<h3 class="admin-nav__section-title">Content</h3>
<ul class="admin-nav__list" role="list">
<li class="admin-nav__item">
<a href="/admin/images" class="admin-nav__link" {$this->getActiveState('/admin/images')}>
<span class="admin-nav__icon">🖼️</span>
<span>Images</span>
</a>
</li>
<li class="admin-nav__item">
<a href="/admin/users" class="admin-nav__link" {$this->getActiveState('/admin/users')}>
<span class="admin-nav__icon">👥</span>
<span>Users</span>
</a>
</li>
</ul>
</div>
</nav>
HTML;
}
private function buildFooter(): string
{
return <<<HTML
<div class="admin-sidebar__footer">
<div class="admin-sidebar__user">
<div class="admin-sidebar__avatar" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: white; font-weight: 600; font-size: 0.875rem;">A</div>
@@ -122,4 +219,51 @@ final readonly class AdminSidebar implements StaticComponent
{
return $path === $this->currentPath ? 'aria-current="page"' : '';
}
/**
* Parse navigation menu from attributes
*/
private function parseNavigationMenu(array $attributes): ?NavigationMenu
{
// Try different attribute names
$menuData = $attributes['navigation-menu']
?? $attributes['navigation_menu']
?? $attributes['navigationMenu']
?? null;
if ($menuData === null) {
return null;
}
// Handle array directly
if (is_array($menuData)) {
try {
return NavigationMenu::fromArray($menuData);
} catch (\Exception) {
return null;
}
}
// Handle JSON string
if (is_string($menuData)) {
$decoded = json_decode($menuData, true);
if (is_array($decoded)) {
try {
return NavigationMenu::fromArray($decoded);
} catch (\Exception) {
return null;
}
}
}
return null;
}
/**
* Escape HTML special characters
*/
private function escape(string $string): string
{
return htmlspecialchars($string, ENT_QUOTES, 'UTF-8');
}
}

View File

@@ -261,15 +261,32 @@ final class XComponentTransformer implements NodeVisitor, AstTransformer
/**
* Extract attributes as strings (for StaticComponents)
* Also handles JSON strings for complex data types
*
* @return array<string, string>
* @return array<string, string|array|mixed>
*/
private function extractAttributesForStaticComponent(ElementNode $element): array
{
$attributes = [];
foreach ($element->getAttributes() as $attr) {
$attributes[$attr->name] = $attr->value;
$value = $attr->value;
// Try to parse JSON strings (for arrays/objects passed as attributes)
// This allows templates to pass complex data like: navigation-menu='{"sections":[...]}'
if (is_string($value) && (str_starts_with($value, '[') || str_starts_with($value, '{'))) {
try {
$decoded = json_decode($value, true, 512, JSON_THROW_ON_ERROR);
if (is_array($decoded)) {
$attributes[$attr->name] = $decoded;
continue;
}
} catch (\JsonException) {
// Not valid JSON, use as string
}
}
$attributes[$attr->name] = $value;
}
return $attributes;

View File

@@ -4,8 +4,6 @@ declare(strict_types=1);
namespace App\Framework\View\Exception;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Exception\FrameworkException;
/**
@@ -16,18 +14,9 @@ final class TemplateNotFound extends FrameworkException
{
public static function forTemplate(string $template, ?string $searchPath = null, ?\Throwable $previous = null): self
{
$context = ExceptionContext::forOperation('template_loading', 'TemplateLoader')
->withData([
'template' => $template,
'search_path' => $searchPath,
'timestamp' => microtime(true),
]);
return self::fromContext(
"Das Template '$template' konnte nicht geladen werden.",
$context,
ErrorCode::TPL_TEMPLATE_NOT_FOUND,
$previous
return new self(
template: $template,
previous: $previous
);
}
@@ -37,13 +26,9 @@ final class TemplateNotFound extends FrameworkException
*/
public function __construct(string $template, ?\Throwable $previous = null, int $code = 0, array $context = [])
{
$exceptionContext = ExceptionContext::forOperation('template_loading', 'TemplateLoader')
->withData(['template' => $template] + $context);
parent::__construct(
message: "Das Template '$template' konnte nicht geladen werden.",
context: $exceptionContext,
errorCode: ErrorCode::TPL_TEMPLATE_NOT_FOUND,
code: $code,
previous: $previous
);
}

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Framework\View\Exceptions;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Exception\FrameworkException;
/**
@@ -15,78 +14,36 @@ final class TemplateCacheException extends FrameworkException
{
public static function cacheWriteFailed(string $cacheKey, string $cachePath, ?\Throwable $previous = null): self
{
$context = ExceptionContext::forOperation('template_cache_write', 'CacheManager')
->withData([
'cache_key' => $cacheKey,
'cache_path' => $cachePath,
'operation' => 'write',
])
->withMetadata([
'recovery_strategy' => 'fallback_to_uncached',
'cache_type' => 'template',
]);
return self::fromContext(
return new self(
"Template cache write failed for key '$cacheKey'.",
$context,
ErrorCode::TPL_CACHE_FAILED,
(int) ErrorCode::TPL_CACHE_FAILED->value,
$previous
);
}
public static function cacheReadFailed(string $cacheKey, string $cachePath, ?\Throwable $previous = null): self
{
$context = ExceptionContext::forOperation('template_cache_read', 'CacheManager')
->withData([
'cache_key' => $cacheKey,
'cache_path' => $cachePath,
'operation' => 'read',
])
->withMetadata([
'recovery_strategy' => 'regenerate_cache',
'cache_type' => 'template',
]);
return self::fromContext(
return new self(
"Template cache read failed for key '$cacheKey'.",
$context,
ErrorCode::TPL_CACHE_FAILED,
(int) ErrorCode::TPL_CACHE_FAILED->value,
$previous
);
}
public static function cacheInvalidationFailed(string $cacheKey, ?\Throwable $previous = null): self
{
$context = ExceptionContext::forOperation('template_cache_invalidation', 'CacheManager')
->withData([
'cache_key' => $cacheKey,
'operation' => 'invalidate',
]);
return self::fromContext(
return new self(
"Template cache invalidation failed for key '$cacheKey'.",
$context,
ErrorCode::TPL_CACHE_FAILED,
(int) ErrorCode::TPL_CACHE_FAILED->value,
$previous
);
}
public static function invalidCacheFormat(string $cacheKey, string $expectedFormat, string $actualFormat, ?\Throwable $previous = null): self
{
$context = ExceptionContext::forOperation('template_cache_validation', 'CacheManager')
->withData([
'cache_key' => $cacheKey,
'expected_format' => $expectedFormat,
'actual_format' => $actualFormat,
])
->withMetadata([
'recovery_strategy' => 'regenerate_cache',
]);
return self::fromContext(
return new self(
"Invalid template cache format for key '$cacheKey'. Expected '$expectedFormat', got '$actualFormat'.",
$context,
ErrorCode::TPL_CACHE_FAILED,
(int) ErrorCode::TPL_CACHE_FAILED->value,
$previous
);
}

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Framework\View\Exceptions;
use App\Framework\Exception\Core\TemplateErrorCode;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Exception\FrameworkException;
/**
@@ -15,41 +14,20 @@ final class TemplateNotFoundException extends FrameworkException
{
public static function forTemplate(string $template, ?string $searchPath = null, ?array $searchPaths = null, ?\Throwable $previous = null): self
{
$context = ExceptionContext::forOperation('template_resolution', 'TemplateEngine')
->withData([
'template' => $template,
'search_path' => $searchPath,
'search_paths' => $searchPaths ?? [],
'timestamp' => microtime(true),
])
->withMetadata([
'suggestions' => self::generateSuggestions($template, $searchPaths ?? []),
'template_type' => self::detectTemplateType($template),
]);
$pathInfo = $searchPath ? " (searched in: $searchPath)" : '';
return self::fromContext(
return new self(
"Template \"$template\" nicht gefunden $pathInfo.",
$context,
TemplateErrorCode::TEMPLATE_NOT_FOUND,
(int) TemplateErrorCode::TEMPLATE_NOT_FOUND->value,
$previous
);
}
public static function withFallback(string $template, string $fallbackTemplate, ?\Throwable $previous = null): self
{
$context = ExceptionContext::forOperation('template_fallback', 'TemplateEngine')
->withData([
'template' => $template,
'fallback_template' => $fallbackTemplate,
'recovery_strategy' => 'fallback_template',
]);
return self::fromContext(
return new self(
"Template \"$template\" und Fallback \"$fallbackTemplate\" nicht gefunden.",
$context,
TemplateErrorCode::TEMPLATE_NOT_FOUND,
(int) TemplateErrorCode::TEMPLATE_NOT_FOUND->value,
$previous
);
}
@@ -59,21 +37,13 @@ final class TemplateNotFoundException extends FrameworkException
* @deprecated Use forTemplate() factory method instead
*/
public function __construct(
string $template,
string $file,
string $message,
int $code = 0,
?\Throwable $previous = null
) {
$context = ExceptionContext::forOperation('template_render', 'view_engine')
->withData([
'template' => $template,
'file' => $file,
]);
parent::__construct(
message: "Template \"$template\" nicht gefunden ($file).",
context: $context,
errorCode: TemplateErrorCode::TEMPLATE_NOT_FOUND,
message: $message,
code: $code,
previous: $previous
);
}

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Framework\View\Exceptions;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Exception\FrameworkException;
/**
@@ -15,117 +14,54 @@ final class TemplateProcessorException extends FrameworkException
{
public static function processorFailed(string $processorClass, string $template, string $operation, ?\Throwable $previous = null): self
{
$context = ExceptionContext::forOperation('template_processing', $processorClass)
->withData([
'processor' => $processorClass,
'template' => $template,
'operation' => $operation,
])
->withMetadata([
'recovery_strategy' => 'skip_processor_or_fallback',
'processor_type' => self::getProcessorType($processorClass),
]);
return self::fromContext(
return new self(
"Template processor '$processorClass' failed for template '$template' during '$operation'.",
$context,
ErrorCode::TPL_PROCESSOR_FAILED,
(int) ErrorCode::TPL_PROCESSOR_FAILED->value,
$previous
);
}
public static function assetNotFound(string $assetPath, string $manifestPath, ?\Throwable $previous = null): self
{
$context = ExceptionContext::forOperation('asset_injection', 'AssetInjector')
->withData([
'asset_path' => $assetPath,
'manifest_path' => $manifestPath,
'error_type' => 'asset_not_found',
])
->withMetadata([
'recovery_strategy' => 'use_development_fallback_or_skip',
]);
return self::fromContext(
return new self(
"Asset '$assetPath' not found in manifest '$manifestPath'.",
$context,
ErrorCode::TPL_ASSET_NOT_FOUND,
(int) ErrorCode::TPL_ASSET_NOT_FOUND->value,
$previous
);
}
public static function manifestNotFound(string $manifestPath, ?\Throwable $previous = null): self
{
$context = ExceptionContext::forOperation('manifest_loading', 'AssetInjector')
->withData([
'manifest_path' => $manifestPath,
'error_type' => 'manifest_not_found',
])
->withMetadata([
'recovery_strategy' => 'use_development_mode_or_disable_assets',
]);
return self::fromContext(
return new self(
"Vite manifest not found: $manifestPath",
$context,
ErrorCode::TPL_ASSET_NOT_FOUND,
(int) ErrorCode::TPL_ASSET_NOT_FOUND->value,
$previous
);
}
public static function contentLoadingFailed(string $path, string $reason, ?\Throwable $previous = null): self
{
$context = ExceptionContext::forOperation('content_loading', 'TemplateContentLoader')
->withData([
'path' => $path,
'reason' => $reason,
'file_exists' => file_exists($path),
'is_readable' => is_readable($path),
])
->withMetadata([
'recovery_strategy' => 'check_permissions_or_fallback',
]);
return self::fromContext(
return new self(
"Template content loading failed for '$path': $reason",
$context,
ErrorCode::TPL_CONTENT_LOADING_FAILED,
(int) ErrorCode::TPL_CONTENT_LOADING_FAILED->value,
$previous
);
}
public static function invalidProcessorConfiguration(string $processorClass, string $issue, ?\Throwable $previous = null): self
{
$context = ExceptionContext::forOperation('processor_configuration', 'TemplateEngine')
->withData([
'processor' => $processorClass,
'configuration_issue' => $issue,
]);
return self::fromContext(
return new self(
"Invalid processor configuration for '$processorClass': $issue",
$context,
ErrorCode::TPL_PROCESSOR_FAILED,
(int) ErrorCode::TPL_PROCESSOR_FAILED->value,
$previous
);
}
public static function processorChainFailed(array $processorChain, string $failedProcessor, ?\Throwable $previous = null): self
{
$context = ExceptionContext::forOperation('processor_chain', 'TemplateEngine')
->withData([
'processor_chain' => $processorChain,
'failed_processor' => $failedProcessor,
'chain_position' => array_search($failedProcessor, $processorChain),
])
->withMetadata([
'recovery_strategy' => 'skip_failed_processor_and_continue',
]);
return self::fromContext(
return new self(
"Template processor chain failed at '$failedProcessor'.",
$context,
ErrorCode::TPL_PROCESSOR_FAILED,
(int) ErrorCode::TPL_PROCESSOR_FAILED->value,
$previous
);
}

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Framework\View\Exceptions;
use App\Framework\Exception\ErrorCode;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Exception\FrameworkException;
/**
@@ -15,96 +14,47 @@ final class TemplateRenderingException extends FrameworkException
{
public static function renderingFailed(string $template, string $stage, ?\Throwable $previous = null): self
{
$context = ExceptionContext::forOperation('template_rendering', 'TemplateProcessor')
->withData([
'template' => $template,
'stage' => $stage,
'error_type' => 'rendering_failed',
])
->withMetadata([
'recovery_strategy' => 'fallback_template_or_error_page',
'stage_description' => self::getStageDescription($stage),
]);
return self::fromContext(
return new self(
"Template rendering failed for '$template' at stage '$stage'.",
$context,
ErrorCode::TPL_RENDERING_FAILED,
(int) ErrorCode::TPL_RENDERING_FAILED->value,
$previous
);
}
public static function invalidRendererOutput(string $template, string $expectedType, string $actualType, ?\Throwable $previous = null): self
{
$context = ExceptionContext::forOperation('template_output_validation', 'TemplateProcessor')
->withData([
'template' => $template,
'expected_type' => $expectedType,
'actual_type' => $actualType,
]);
return self::fromContext(
return new self(
"Template renderer must return '$expectedType', got '$actualType' for template '$template'.",
$context,
ErrorCode::TPL_RENDERING_FAILED,
(int) ErrorCode::TPL_RENDERING_FAILED->value,
$previous
);
}
public static function variableNotFound(string $template, string $variable, array $availableVariables = [], ?\Throwable $previous = null): self
{
$context = ExceptionContext::forOperation('template_variable_resolution', 'TemplateProcessor')
->withData([
'template' => $template,
'missing_variable' => $variable,
'available_variables' => array_keys($availableVariables),
])
->withMetadata([
'suggestions' => self::findSimilarVariables($variable, array_keys($availableVariables)),
]);
return self::fromContext(
return new self(
"Template variable '$variable' not found in template '$template'.",
$context,
ErrorCode::TPL_VARIABLE_NOT_FOUND,
(int) ErrorCode::TPL_VARIABLE_NOT_FOUND->value,
$previous
);
}
public static function syntaxError(string $template, string $error, int $line = 0, ?\Throwable $previous = null): self
{
$context = ExceptionContext::forOperation('template_parsing', 'TemplateProcessor')
->withData([
'template' => $template,
'syntax_error' => $error,
'line' => $line,
])
->withMetadata([
'error_category' => 'syntax',
]);
$lineInfo = $line > 0 ? " at line $line" : '';
return self::fromContext(
return new self(
"Template syntax error in '$template'$lineInfo: $error",
$context,
ErrorCode::TPL_SYNTAX_ERROR,
(int) ErrorCode::TPL_SYNTAX_ERROR->value,
$previous
);
}
public static function compilationFailed(string $template, string $compiler, ?\Throwable $previous = null): self
{
$context = ExceptionContext::forOperation('template_compilation', $compiler)
->withData([
'template' => $template,
'compiler' => $compiler,
]);
return self::fromContext(
return new self(
"Template compilation failed for '$template' using compiler '$compiler'.",
$context,
ErrorCode::TPL_COMPILATION_FAILED,
(int) ErrorCode::TPL_COMPILATION_FAILED->value,
$previous
);
}

View File

@@ -21,6 +21,6 @@ final readonly class TemplatePathResolver
}
}
throw new TemplateNotFoundException($template, $controllerClass ?? '');
throw TemplateNotFoundException::forTemplate($template);
}
}

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Framework\View\Processors\Exceptions;
use App\Framework\Exception\ExceptionContext;
use App\Framework\Exception\FrameworkException;
final class ViteManifestNotFoundException extends FrameworkException
@@ -16,8 +15,6 @@ final class ViteManifestNotFoundException extends FrameworkException
) {
parent::__construct(
message: "Vite manifest not found: $manifestPath",
context: ExceptionContext::forOperation('vite_manifest_load', 'View')
->withData(['manifestPath' => $manifestPath]),
code: $code,
previous: $previous
);

View File

@@ -26,6 +26,9 @@ final class PhpVariableProcessor implements StringProcessor
// Array-Funktionen
'count', 'implode', 'array_keys', 'array_values',
// JSON-Funktionen (für Component-Attribute)
'json_encode',
// Custom Template-Funktionen
'format_date', 'format_currency', 'format_filesize',
@@ -325,8 +328,8 @@ final class PhpVariableProcessor implements StringProcessor
continue;
}
// Variablen: $name
if (preg_match('/^\$([a-zA-Z_][a-zA-Z0-9_]*)$/', $part, $matches)) {
// Variablen: $name oder name (ohne $)
if (preg_match('/^\$?([a-zA-Z_][a-zA-Z0-9_]*)$/', $part, $matches)) {
$varName = $matches[1];
if (array_key_exists($varName, $data)) {
$args[] = $data[$varName];

View File

@@ -0,0 +1,251 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Table\Generators;
use App\Framework\Database\Browser\ValueObjects\ColumnMetadata;
use App\Framework\Database\Browser\ValueObjects\ForeignKeyMetadata;
use App\Framework\Database\Browser\ValueObjects\IndexMetadata;
use App\Framework\View\Table\CellFormatter;
use App\Framework\View\Table\Table;
use App\Framework\View\Table\TableColumn;
use App\Framework\View\Table\TableGenerator;
use App\Framework\View\Table\TableOptions;
use App\Framework\View\Table\TableRow;
final readonly class DatabaseMetadataTableGenerator implements TableGenerator
{
/**
* Generate table for columns
*/
public function generateColumns(array $columns): Table
{
$tableColumns = [
TableColumn::text('name', 'Column', 'column-name'),
TableColumn::text('type', 'Type', 'column-type'),
new TableColumn(
key: 'nullable',
header: 'Nullable',
cssClass: 'column-nullable',
formatter: $this->createBooleanFormatter()
),
TableColumn::text('default', 'Default', 'column-default'),
new TableColumn(
key: 'key',
header: 'Key',
cssClass: 'column-key',
formatter: $this->createKeyFormatter()
),
TableColumn::text('extra', 'Extra', 'column-extra'),
];
$rows = [];
foreach ($columns as $column) {
if ($column instanceof ColumnMetadata) {
$rowData = [
'name' => $column->name,
'type' => $column->type,
'nullable' => $column->nullable,
'default' => $column->default,
'key' => $column->key,
'extra' => $column->extra,
];
} elseif (is_array($column)) {
$rowData = $column;
} else {
continue;
}
$rows[] = TableRow::fromData($rowData, $tableColumns, 'column-row');
}
$options = new TableOptions(
striped: true,
bordered: true,
hover: true,
responsive: true,
emptyMessage: 'No columns found'
);
return new Table(
columns: $tableColumns,
rows: $rows,
cssClass: 'admin-table column-table',
options: $options,
id: 'columnsTable'
);
}
/**
* Generate table for indexes
*/
public function generateIndexes(array $indexes): Table
{
$tableColumns = [
TableColumn::text('name', 'Index Name', 'index-name'),
TableColumn::text('columns', 'Columns', 'index-columns'),
new TableColumn(
key: 'unique',
header: 'Unique',
cssClass: 'index-unique',
formatter: $this->createBooleanFormatter()
),
TableColumn::text('type', 'Type', 'index-type'),
];
$rows = [];
foreach ($indexes as $index) {
if ($index instanceof IndexMetadata) {
$rowData = [
'name' => $index->name,
'columns' => implode(', ', $index->columns),
'unique' => $index->unique,
'type' => $index->type,
];
} elseif (is_array($index)) {
$rowData = $index;
} else {
continue;
}
$rows[] = TableRow::fromData($rowData, $tableColumns, 'index-row');
}
$options = new TableOptions(
striped: true,
bordered: true,
hover: true,
responsive: true,
emptyMessage: 'No indexes found'
);
return new Table(
columns: $tableColumns,
rows: $rows,
cssClass: 'admin-table index-table',
options: $options,
id: 'indexesTable'
);
}
/**
* Generate table for foreign keys
*/
public function generateForeignKeys(array $foreignKeys): Table
{
$tableColumns = [
TableColumn::text('name', 'Constraint', 'fk-name'),
TableColumn::text('column', 'Column', 'fk-column'),
TableColumn::text('referenced_table', 'Referenced Table', 'fk-referenced-table'),
TableColumn::text('referenced_column', 'Referenced Column', 'fk-referenced-column'),
];
$rows = [];
foreach ($foreignKeys as $fk) {
if ($fk instanceof ForeignKeyMetadata) {
$rowData = [
'name' => $fk->name,
'column' => $fk->column,
'referenced_table' => $fk->referencedTable,
'referenced_column' => $fk->referencedColumn,
];
} elseif (is_array($fk)) {
$rowData = $fk;
} else {
continue;
}
$rows[] = TableRow::fromData($rowData, $tableColumns, 'fk-row');
}
$options = new TableOptions(
striped: true,
bordered: true,
hover: true,
responsive: true,
emptyMessage: 'No foreign keys found'
);
return new Table(
columns: $tableColumns,
rows: $rows,
cssClass: 'admin-table foreign-key-table',
options: $options,
id: 'foreignKeysTable'
);
}
public function generate(object|array $data, ?TableOptions $options = null): Table
{
// Default: try to generate columns table
if (is_array($data) && !empty($data)) {
$firstItem = reset($data);
if ($firstItem instanceof ColumnMetadata) {
return $this->generateColumns($data);
}
if ($firstItem instanceof IndexMetadata) {
return $this->generateIndexes($data);
}
if ($firstItem instanceof ForeignKeyMetadata) {
return $this->generateForeignKeys($data);
}
}
throw new \InvalidArgumentException('DatabaseMetadataTableGenerator: Unsupported data type');
}
public function supports(object|array $data): bool
{
if (!is_array($data) || empty($data)) {
return false;
}
$firstItem = reset($data);
return $firstItem instanceof ColumnMetadata
|| $firstItem instanceof IndexMetadata
|| $firstItem instanceof ForeignKeyMetadata;
}
private function createBooleanFormatter(): CellFormatter
{
return new class () implements CellFormatter {
public function format(mixed $value): string
{
$bool = (bool) $value;
if ($bool) {
return '<span class="badge badge--success">Yes</span>';
}
return '<span class="badge badge--secondary">No</span>';
}
};
}
private function createKeyFormatter(): CellFormatter
{
return new class () implements CellFormatter {
public function format(mixed $value): string
{
if ($value === null || $value === '') {
return '<em class="text-muted">-</em>';
}
$key = strtoupper((string) $value);
$badgeClass = match ($key) {
'PRI' => 'badge--primary',
'UNI' => 'badge--info',
'MUL' => 'badge--warning',
default => 'badge--secondary'
};
return sprintf(
'<span class="badge %s">%s</span>',
$badgeClass,
htmlspecialchars($key, ENT_QUOTES)
);
}
};
}
}

View File

@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Table\Generators;
use App\Framework\Database\Browser\ValueObjects\TableMetadata;
use App\Framework\View\Table\CellFormatter;
use App\Framework\View\Table\Table;
use App\Framework\View\Table\TableColumn;
use App\Framework\View\Table\TableGenerator;
use App\Framework\View\Table\TableOptions;
use App\Framework\View\Table\TableRow;
final readonly class DatabaseTableGenerator implements TableGenerator
{
public function generate(object|array $data, ?TableOptions $options = null): Table
{
if (!is_array($data)) {
throw new \InvalidArgumentException('DatabaseTableGenerator expects array of TableMetadata');
}
$columns = [
TableColumn::text('name', 'Table Name', 'table-name'),
new TableColumn(
key: 'row_count',
header: 'Rows',
cssClass: 'table-rows',
formatter: $this->createNumberFormatter()
),
new TableColumn(
key: 'size_mb',
header: 'Size (MB)',
cssClass: 'table-size',
formatter: $this->createSizeFormatter()
),
TableColumn::text('engine', 'Engine', 'table-engine'),
TableColumn::text('collation', 'Collation', 'table-collation'),
];
$rows = [];
foreach ($data as $table) {
if ($table instanceof TableMetadata) {
$rowData = [
'name' => $table->name,
'row_count' => $table->rowCount,
'size_mb' => $table->sizeMb,
'engine' => $table->engine,
'collation' => $table->collation,
];
} elseif (is_array($table)) {
$rowData = $table;
} else {
continue;
}
$rows[] = TableRow::fromData($rowData, $columns, 'table-row');
}
$tableOptions = $options ?? new TableOptions(
striped: true,
bordered: true,
hover: true,
responsive: true,
emptyMessage: 'No tables found'
);
return new Table(
columns: $columns,
rows: $rows,
cssClass: 'admin-table database-table',
options: $tableOptions,
id: 'databaseTablesTable'
);
}
public function supports(object|array $data): bool
{
if (!is_array($data) || empty($data)) {
return false;
}
$firstItem = reset($data);
if ($firstItem instanceof TableMetadata) {
return true;
}
if (is_array($firstItem)) {
// Check if it has table structure
return isset($firstItem['name']) || isset($firstItem['table_name']);
}
return false;
}
private function createNumberFormatter(): CellFormatter
{
return new class () implements CellFormatter {
public function format(mixed $value): string
{
if ($value === null) {
return '<em class="text-muted">N/A</em>';
}
return number_format((int) $value, 0, '.', ',');
}
};
}
private function createSizeFormatter(): CellFormatter
{
return new class () implements CellFormatter {
public function format(mixed $value): string
{
if ($value === null) {
return '<em class="text-muted">N/A</em>';
}
$size = (float) $value;
if ($size < 0.01) {
return '< 0.01';
}
return number_format($size, 2);
}
};
}
}

View File

@@ -5,8 +5,8 @@ declare(strict_types=1);
namespace App\Framework\View;
use App\Framework\Core\AttributeMapper;
use App\Framework\Reflection\WrappedReflectionClass;
use App\Framework\Reflection\WrappedReflectionMethod;
use App\Framework\ReflectionLegacy\WrappedReflectionClass;
use App\Framework\ReflectionLegacy\WrappedReflectionMethod;
final class TemplateMapper implements AttributeMapper
{

View File

@@ -0,0 +1,194 @@
<div class="docker-containers-component">
<!-- Docker Status -->
<div class="admin-card" if="{{$isRunning}}">
<div class="admin-card__header">
<h3 class="admin-card__title">Docker Status</h3>
</div>
<div class="admin-card__content">
<div class="admin-stat-list">
<div class="admin-stat-list__item">
<span class="admin-stat-list__label">Version</span>
<span class="admin-stat-list__value">{{$dockerVersion}}</span>
</div>
<div class="admin-stat-list__item">
<span class="admin-stat-list__label">Status</span>
<span class="admin-badge admin-badge--success">Running</span>
</div>
<div class="admin-stat-list__item">
<span class="admin-stat-list__label">Total Containers</span>
<span class="admin-stat-list__value">{{$totalContainers}}</span>
</div>
<div class="admin-stat-list__item">
<span class="admin-stat-list__label">Running</span>
<span class="admin-stat-list__value">{{$runningContainers}}</span>
</div>
<div class="admin-stat-list__item">
<span class="admin-stat-list__label">Stopped</span>
<span class="admin-stat-list__value">{{$stoppedContainers}}</span>
</div>
</div>
</div>
</div>
<div class="admin-card" if="{{!$isRunning}}">
<div class="admin-card__content">
<div class="alert alert-error">
<strong>Docker is not running</strong>
<p>Docker daemon is not accessible. Please check if Docker is installed and running.</p>
</div>
</div>
</div>
<!-- Containers Table -->
<div class="admin-card" style="margin-top: var(--space-lg);">
<div class="admin-card__header">
<h3 class="admin-card__title">Containers</h3>
</div>
<div class="admin-card__content">
<table class="admin-table" id="docker-containers-table">
<thead>
<tr>
<th>Name</th>
<th>Image</th>
<th>Status</th>
<th>CPU</th>
<th>Memory</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr foreach="$containers as $container">
<td>{{$container['name']}}</td>
<td>{{$container['image']}}</td>
<td>
<span class="admin-badge admin-badge--success" if="{{$container['is_running']}}">Running</span>
<span class="admin-badge admin-badge--secondary" if="{{!$container['is_running']}}">Stopped</span>
</td>
<td>
<span if="{{$container['stats']}}">{{$container['stats']['cpu_percent']}}%</span>
<span if="{{!$container['stats']}}">-</span>
</td>
<td>
<span if="{{$container['stats']}}">{{$container['stats']['memory_usage']}} / {{$container['stats']['memory_limit']}}</span>
<span if="{{!$container['stats']}}">-</span>
</td>
<td>
<div class="btn-group">
<button class="btn btn-sm btn-secondary" onclick="stopContainer('{{$container['id']}}')" if="{{$container['is_running']}}">Stop</button>
<button class="btn btn-sm btn-secondary" onclick="restartContainer('{{$container['id']}}')" if="{{$container['is_running']}}">Restart</button>
<button class="btn btn-sm btn-primary" onclick="startContainer('{{$container['id']}}')" if="{{!$container['is_running']}}">Start</button>
<button class="btn btn-sm btn-secondary" onclick="showLogs('{{$container['id']}}')">Logs</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="component-footer">
<span class="component-footer__text">Last updated: {{$lastUpdated}}</span>
<span class="component-footer__badge">Updates every 5s</span>
</div>
</div>
<script>
function startContainer(containerId) {
fetch(`/admin/infrastructure/docker/api/containers/${containerId}/start`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
window.location.reload();
} else {
alert('Failed to start container: ' + (data.message || 'Unknown error'));
}
})
.catch(error => {
alert('Error: ' + error.message);
});
}
function stopContainer(containerId) {
if (!confirm('Are you sure you want to stop this container?')) {
return;
}
fetch(`/admin/infrastructure/docker/api/containers/${containerId}/stop`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
window.location.reload();
} else {
alert('Failed to stop container: ' + (data.message || 'Unknown error'));
}
})
.catch(error => {
alert('Error: ' + error.message);
});
}
function restartContainer(containerId) {
if (!confirm('Are you sure you want to restart this container?')) {
return;
}
fetch(`/admin/infrastructure/docker/api/containers/${containerId}/restart`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
window.location.reload();
} else {
alert('Failed to restart container: ' + (data.message || 'Unknown error'));
}
})
.catch(error => {
alert('Error: ' + error.message);
});
}
function showLogs(containerId) {
window.open(`/admin/infrastructure/docker/api/containers/${containerId}/logs`, '_blank');
}
</script>
<style>
.docker-containers-component {
padding: 0;
}
.component-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: #f8f9fa;
border-radius: 6px;
font-size: 0.75rem;
color: #666;
margin-top: var(--space-md);
}
.component-footer__badge {
background: #007bff;
color: white;
padding: 0.25rem 0.5rem;
border-radius: 12px;
font-size: 0.7rem;
}
</style>

View File

@@ -0,0 +1,95 @@
<div class="health-status-component">
<!-- Overall Status -->
<div class="admin-card">
<div class="admin-card__header">
<h3 class="admin-card__title">System Overview</h3>
</div>
<div class="admin-card__content">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: var(--space-md); margin-bottom: var(--space-lg);">
<div class="metric-card">
<div class="metric-card__value" style="color: var(--success);">
{{$overallStatus}}
</div>
<div class="metric-card__label">Overall Status</div>
</div>
<div class="metric-card">
<div class="metric-card__value">{{$totalChecks}}</div>
<div class="metric-card__label">Total Checks</div>
</div>
<div class="metric-card">
<div class="metric-card__value" style="color: var(--success);">{{$healthyChecks}}</div>
<div class="metric-card__label">Healthy</div>
</div>
<div class="metric-card">
<div class="metric-card__value" style="color: var(--warning);">{{$warningChecks}}</div>
<div class="metric-card__label">Warnings</div>
</div>
<div class="metric-card">
<div class="metric-card__value" style="color: var(--error);">{{$failedChecks}}</div>
<div class="metric-card__label">Failed</div>
</div>
</div>
</div>
</div>
<!-- Health Check Details -->
<div class="admin-card" style="margin-top: var(--space-lg);">
<div class="admin-card__header">
<h3 class="admin-card__title">Health Check Details</h3>
</div>
<table-data source="{{$healthCheckTable}}" container-class="admin-card" />
</div>
<div class="component-footer">
<span class="component-footer__text">Last updated: {{$lastUpdated}}</span>
<span class="component-footer__badge">Updates every 5s</span>
</div>
</div>
<style>
.health-status-component {
padding: 0;
}
.metric-card {
text-align: center;
padding: var(--space-md);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg-alt);
}
.metric-card__value {
font-size: 2rem;
font-weight: 700;
margin-bottom: var(--space-sm);
}
.metric-card__label {
font-size: 0.875rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.component-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: #f8f9fa;
border-radius: 6px;
font-size: 0.75rem;
color: #666;
margin-top: var(--space-md);
}
.component-footer__badge {
background: #007bff;
color: white;
padding: 0.25rem 0.5rem;
border-radius: 12px;
font-size: 0.7rem;
}
</style>

View File

@@ -0,0 +1,22 @@
<div
data-live-component="{{componentId}}"
data-state='{{stateJson}}'
class="livecomponent-notification {{type_class}} {{position_class}}"
style="display: {{is_visible ? 'block' : 'none'}};"
role="alert"
aria-live="polite"
>
<div class="notification-content">
<if condition="icon">
<span class="notification-icon">{{icon}}</span>
</if>
<span class="notification-message">{{message}}</span>
<if condition="has_action">
<a href="{{action_url}}" class="notification-action" data-live-action="handleAction" data-param-action-url="{{action_url}}">
{{action_text}}
</a>
</if>
<button class="notification-close" data-live-action="hide" aria-label="Close">×</button>
</div>
</div>

View File

@@ -0,0 +1,169 @@
<div class="performance-metrics-component">
<div class="stats-grid">
<!-- Memory Card -->
<div class="stat-card stat-card--primary">
<div class="stat-card__icon">💾</div>
<div class="stat-card__content">
<div class="stat-card__value">{{$currentMemory}}</div>
<div class="stat-card__label">Current Memory</div>
<div class="stat-card__subvalue">{{$peakMemory}} peak</div>
</div>
<div class="stat-card__progress">
<div class="progress-bar">
<div class="progress-fill" style="width: {{$memoryUsagePercentage}}%"></div>
</div>
<div class="stat-card__percentage">{{$memoryUsagePercentage}}%</div>
</div>
</div>
<!-- System Card -->
<div class="stat-card">
<div class="stat-card__icon">⚙️</div>
<div class="stat-card__content">
<div class="stat-card__value">{{$executionTime}}</div>
<div class="stat-card__label">Execution Time</div>
<div class="stat-card__subvalue">{{$includedFiles}} files loaded</div>
</div>
</div>
<!-- Load Average Card -->
<div class="stat-card" if="{{!empty($loadAverage)}}">
<div class="stat-card__icon">📊</div>
<div class="stat-card__content">
<div class="stat-card__value">
{{$loadAverage[0]}} / {{$loadAverage[1]}} / {{$loadAverage[2]}}
</div>
<div class="stat-card__label">Load Average (1m / 5m / 15m)</div>
</div>
</div>
<!-- OPCache Card -->
<div class="stat-card stat-card--info" if="{{$opcacheEnabled}}">
<div class="stat-card__icon">🚀</div>
<div class="stat-card__content">
<div class="stat-card__value" if="{{$opcacheMemoryUsage}}">{{$opcacheMemoryUsage}}</div>
<div class="stat-card__label">OPCache Memory</div>
<div class="stat-card__subvalue" if="{{$opcacheCacheHits}}">{{$opcacheCacheHits}} hits</div>
<div class="stat-card__subvalue" if="{{$opcacheMissRate}}">Miss: {{$opcacheMissRate}}</div>
</div>
</div>
</div>
<div class="component-footer">
<span class="component-footer__text">Last updated: {{$lastUpdated}}</span>
<span class="component-footer__badge">Updates every 3s</span>
</div>
</div>
<style>
.performance-metrics-component {
padding: 0;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.stat-card {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #e0e0e0;
transition: all 0.2s;
}
.stat-card:hover {
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
transform: translateY(-2px);
}
.stat-card--primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
}
.stat-card--info {
background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%);
border: none;
}
.stat-card__icon {
font-size: 2rem;
opacity: 0.9;
}
.stat-card__content {
flex: 1;
}
.stat-card__value {
font-size: 1.25rem;
font-weight: bold;
line-height: 1.2;
}
.stat-card__label {
font-size: 0.75rem;
opacity: 0.8;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 0.25rem;
}
.stat-card__subvalue {
font-size: 0.85rem;
opacity: 0.7;
margin-top: 0.25rem;
}
.stat-card__progress {
margin-top: 0.5rem;
}
.progress-bar {
height: 6px;
background: rgba(255, 255, 255, 0.3);
border-radius: 3px;
overflow: hidden;
margin-bottom: 0.25rem;
}
.progress-fill {
height: 100%;
background: rgba(255, 255, 255, 0.9);
transition: width 0.3s ease;
}
.stat-card__percentage {
font-size: 0.7rem;
opacity: 0.8;
text-align: right;
}
.component-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: #f8f9fa;
border-radius: 6px;
font-size: 0.75rem;
color: #666;
}
.component-footer__badge {
background: #007bff;
color: white;
padding: 0.25rem 0.5rem;
border-radius: 12px;
font-size: 0.7rem;
}
</style>