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
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:
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,6 @@ final readonly class TemplatePathResolver
|
||||
}
|
||||
}
|
||||
|
||||
throw new TemplateNotFoundException($template, $controllerClass ?? '');
|
||||
throw TemplateNotFoundException::forTemplate($template);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
129
src/Framework/View/Table/Generators/DatabaseTableGenerator.php
Normal file
129
src/Framework/View/Table/Generators/DatabaseTableGenerator.php
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user