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

@@ -12,14 +12,16 @@ use App\Framework\Analytics\AnalyticsCategory;
use App\Framework\Analytics\AnalyticsCollector;
use App\Framework\Analytics\Storage\AnalyticsStorage;
use App\Framework\Attributes\Route;
use App\Framework\Auth\Auth;
use App\Framework\Http\Method;
use App\Framework\Http\Status;
use App\Framework\Meta\MetaData;
use App\Framework\Router\AdminRoutes;
use App\Framework\Router\Result\JsonResult;
use App\Framework\Router\Result\ViewResult;
use App\Framework\Admin\Attributes\AdminPage;
use App\Framework\Admin\Attributes\AdminSection;
#[AdminSection(name: 'Analytics', icon: 'chart-bar', order: 4, description: 'Analytics and reporting')]
final class AnalyticsController
{
public function __construct(
@@ -32,7 +34,7 @@ final class AnalyticsController
) {
}
#[Auth]
#[AdminPage(title: 'Analytics Dashboard', icon: 'chart-bar', section: 'Analytics', order: 10)]
#[Route(path: '/admin/analytics', method: Method::GET, name: AdminRoutes::ANALYTICS_DASHBOARD)]
public function dashboard(): ViewResult
{

View File

@@ -7,13 +7,15 @@ namespace App\Application\Admin\Content;
use App\Application\Admin\Service\AdminLayoutProcessor;
use App\Domain\Media\ImageRepository;
use App\Domain\Media\ImageSlotRepository;
use App\Framework\Admin\Attributes\AdminPage;
use App\Framework\Admin\Attributes\AdminSection;
use App\Framework\Attributes\Route;
use App\Framework\Auth\Auth;
use App\Framework\DateTime\Clock;
use App\Framework\Http\Method;
use App\Framework\Meta\MetaData;
use App\Framework\Router\Result\ViewResult;
#[AdminSection(name: 'Content', icon: 'photo', order: 3, description: 'Content management')]
final readonly class ImageManagerController
{
public function __construct(
@@ -24,8 +26,8 @@ final readonly class ImageManagerController
) {
}
#[AdminPage(title: 'Images', icon: 'images', section: 'Content', order: 10)]
#[Route(path: '/admin/content/images', method: Method::GET, name: 'admin.content.images')]
// #[Auth(strategy: 'ip', allowedIps: ['127.0.0.1', '::1'])]
public function show(): ViewResult
{
$slots = $this->slotRepository->findAllWithImages();

View File

@@ -7,14 +7,16 @@ namespace App\Application\Admin\Content;
use App\Application\Admin\Service\AdminLayoutProcessor;
use App\Domain\Media\ImageSlot;
use App\Domain\Media\ImageSlotRepository;
use App\Framework\Admin\Attributes\AdminPage;
use App\Framework\Admin\Attributes\AdminSection;
use App\Framework\Attributes\Route;
use App\Framework\Auth\Auth;
use App\Framework\DateTime\Clock;
use App\Framework\Http\Method;
use App\Framework\Http\Request;
use App\Framework\Meta\MetaData;
use App\Framework\Router\Result\ViewResult;
#[AdminSection(name: 'Content', icon: 'photo', order: 3, description: 'Content management')]
final readonly class ImageSlotsController
{
public function __construct(
@@ -24,7 +26,7 @@ final readonly class ImageSlotsController
) {
}
#[Auth]
#[AdminPage(title: 'Image Slots', icon: 'image', section: 'Content', order: 20)]
#[Route('/admin/content/image-slots', name: 'admin.content.image-slots')]
public function show(): ViewResult
{
@@ -41,7 +43,6 @@ final readonly class ImageSlotsController
return new ViewResult('imageslots', new MetaData('Image Slots', 'Image Slots Management'), $finalData);
}
#[Auth]
#[Route('/admin/content/image-slots/{slotName}', method: Method::POST)]
public function update(string $slotName): ViewResult
{
@@ -59,7 +60,6 @@ final readonly class ImageSlotsController
return new ViewResult('imageslot', new MetaData('Edit Image Slot', 'Image Slot Management'), $finalData);
}
#[Auth]
#[Route('/admin/imageslots/create', method: Method::POST)]
public function create(Request $request): void
{
@@ -72,7 +72,6 @@ final readonly class ImageSlotsController
// TODO: Return proper response or redirect
}
#[Auth]
#[Route('/admin/imageslots/edit/{id}', method: Method::PUT)]
public function edit(Request $request, string $id): void
{

View File

@@ -4,8 +4,8 @@ declare(strict_types=1);
namespace App\Application\Admin;
use App\Framework\Admin\Attributes\AdminPage;
use App\Framework\Attributes\Route;
use App\Framework\Auth\Auth;
use App\Framework\Core\VersionInfo;
use App\Framework\DateTime\Clock;
use App\Framework\Http\Method;
@@ -25,7 +25,7 @@ final readonly class Dashboard
) {
}
#[Auth]
#[AdminPage(title: 'Dashboard', icon: 'dashboard', section: null, order: 0)]
#[Route(path: '/admin', method: Method::GET, name: AdminRoutes::DASHBOARD)]
public function show(): ViewResult
{

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Application\Admin\Database;
use App\Framework\Admin\Attributes\AdminPage;
use App\Framework\Admin\Attributes\AdminSection;
use App\Application\Admin\Service\AdminLayoutProcessor;
use App\Framework\Admin\AdminPageRenderer;
use App\Framework\Attributes\Route;
use App\Framework\Database\Browser\Registry\DatabaseRegistry;
use App\Framework\Database\Browser\Registry\TableRegistry;
use App\Framework\Meta\MetaData;
use App\Framework\Pagination\PaginationService;
use App\Framework\Pagination\ValueObjects\PaginationRequest;
use App\Framework\Router\Result\ViewResult;
use App\Framework\View\Table\Generators\DatabaseTableGenerator;
#[AdminSection(name: 'Database', icon: 'database', order: 30)]
final readonly class DatabaseBrowserController
{
public function __construct(
private DatabaseRegistry $databaseRegistry,
private TableRegistry $tableRegistry,
private AdminPageRenderer $pageRenderer,
private AdminLayoutProcessor $layoutProcessor,
private PaginationService $paginationService,
private DatabaseTableGenerator $tableGenerator,
) {
}
#[AdminPage(title: 'Database Browser', icon: 'database', section: 'Database', order: 10)]
#[Route('/admin/database', name: 'admin.database.browser')]
public function index(): ViewResult
{
$database = $this->databaseRegistry->getCurrentDatabase();
$tables = $this->tableRegistry->getAllTables();
// Convert to array for pagination
$tablesArray = array_map(fn ($table) => $table->toArray(), $tables);
// Paginate tables
$paginationRequest = PaginationRequest::first(limit: 50, sortField: 'name');
$paginator = $this->paginationService->forArray($tablesArray);
$paginationResponse = $paginator->paginate($paginationRequest);
// Generate table
$tableData = array_map(
fn ($table) => [
'name' => $table['name'],
'row_count' => $table['row_count'],
'size_mb' => $table['size_mb'],
'engine' => $table['engine'],
'collation' => $table['collation'],
],
$paginationResponse->data
);
$table = $this->tableGenerator->generate($tableData);
$data = [
'title' => 'Database Browser',
'database' => $database->toArray(),
'table' => $table->render(),
'table_count' => count($tables),
'pagination' => [
'current_page' => $paginationResponse->meta->currentPage,
'total_pages' => $paginationResponse->meta->totalPages,
'total_items' => $paginationResponse->meta->totalCount,
'items_per_page' => $paginationResponse->request->limit,
],
];
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
return new ViewResult(
template: 'database/browser',
metaData: new MetaData('Database Browser', 'Admin - Database Browser'),
data: $finalData
);
}
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Application\Admin\Database;
use App\Framework\Admin\Attributes\AdminPage;
use App\Application\Admin\Service\AdminLayoutProcessor;
use App\Framework\Attributes\Route;
use App\Framework\Database\Browser\Registry\TableRegistry;
use App\Framework\Meta\MetaData;
use App\Framework\Router\Result\ViewResult;
use App\Framework\View\Table\Generators\DatabaseMetadataTableGenerator;
final readonly class TableBrowserController
{
public function __construct(
private TableRegistry $tableRegistry,
private AdminLayoutProcessor $layoutProcessor,
private DatabaseMetadataTableGenerator $metadataTableGenerator,
) {
}
#[AdminPage(title: 'Table Details', icon: 'table', section: 'Database', order: 20, hidden: true)]
#[Route('/admin/database/table/{table}', name: 'admin.database.table')]
public function show(string $table): ViewResult
{
$tableSchema = $this->tableRegistry->getTableSchema($table);
if ($tableSchema === null) {
$data = [
'title' => 'Table Not Found',
'error' => "Table '{$table}' not found",
];
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
return new ViewResult(
template: 'database/table-detail',
metaData: new MetaData('Table Not Found', 'Admin - Table Not Found'),
data: $finalData
);
}
// Generate tables for columns, indexes, and foreign keys
$columnsTable = $this->metadataTableGenerator->generateColumns($tableSchema->columns);
$indexesTable = $this->metadataTableGenerator->generateIndexes($tableSchema->indexes);
$foreignKeysTable = $this->metadataTableGenerator->generateForeignKeys($tableSchema->foreignKeys);
$data = [
'title' => "Table: {$tableSchema->name}",
'table' => $tableSchema->toArray(),
'columns_table' => $columnsTable->render(),
'indexes_table' => $indexesTable->render(),
'foreign_keys_table' => $foreignKeysTable->render(),
'has_indexes' => !empty($tableSchema->indexes),
'has_foreign_keys' => !empty($tableSchema->foreignKeys),
];
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
return new ViewResult(
template: 'database/table-detail',
metaData: new MetaData("Table: {$tableSchema->name}", "Admin - Table: {$tableSchema->name}"),
data: $finalData
);
}
}

View File

@@ -4,12 +4,15 @@ declare(strict_types=1);
namespace App\Application\Admin;
use App\Framework\Admin\Attributes\AdminPage;
use App\Framework\Admin\Attributes\AdminSection;
use App\Framework\Attributes\Route;
use App\Framework\Deployment\Pipeline\Services\PipelineHistoryService;
use App\Framework\Deployment\Pipeline\ValueObjects\DeploymentEnvironment;
use App\Framework\Deployment\Pipeline\ValueObjects\PipelineStatus;
use App\Framework\Http\Method;
use App\Framework\Meta\MetaData;
use App\Framework\Router\AdminRoutes;
use App\Framework\Router\Result\ViewResult;
use App\Framework\Core\ValueObjects\Duration;
@@ -22,13 +25,15 @@ use App\Framework\Core\ValueObjects\Duration;
* - Environment-specific metrics
* - Recent deployments overview
*/
#[AdminSection(name: 'Infrastructure', icon: 'database', order: 2, description: 'Infrastructure monitoring and management')]
final readonly class DeploymentDashboardController
{
public function __construct(
private PipelineHistoryService $historyService
) {}
#[Route(path: '/admin/deployment/dashboard', method: Method::GET)]
#[AdminPage(title: 'Deployment Dashboard', icon: 'server', section: 'Infrastructure', order: 60)]
#[Route(path: '/admin/deployment/dashboard', method: Method::GET, name: AdminRoutes::DEPLOYMENT_DASHBOARD)]
public function dashboard(): ViewResult
{
// Get overall statistics

View File

@@ -20,9 +20,13 @@ use App\Framework\Meta\MetaData;
use App\Framework\Router\Result\JsonResult;
use App\Framework\Router\Result\ViewResult;
use App\Framework\Admin\Attributes\AdminPage;
use App\Framework\Admin\Attributes\AdminSection;
/**
* Design System Documentation Controller
*/
#[AdminSection(name: 'Development', icon: 'code', order: 5, description: 'Development tools and utilities')]
final readonly class DesignSystemController
{
public function __construct(
@@ -34,6 +38,7 @@ final readonly class DesignSystemController
) {
}
#[AdminPage(title: 'Design System', icon: 'tools', section: 'Development', order: 20)]
#[Route(path: '/admin/dev/design-system', method: Method::GET, name: 'admin.dev.design-system')]
public function dashboard(HttpRequest $request): ViewResult
{

View File

@@ -5,12 +5,14 @@ declare(strict_types=1);
namespace App\Application\Admin\Development;
use App\Application\Admin\Service\AdminLayoutProcessor;
use App\Framework\Admin\Attributes\AdminPage;
use App\Framework\Admin\Attributes\AdminSection;
use App\Framework\Attributes\Route;
use App\Framework\Auth\Auth;
use App\Framework\Discovery\Results\DiscoveryRegistry;
use App\Framework\Meta\MetaData;
use App\Framework\Router\Result\ViewResult;
#[AdminSection(name: 'Development', icon: 'code', order: 5, description: 'Development tools and utilities')]
final readonly class RoutesController
{
public function __construct(
@@ -19,7 +21,7 @@ final readonly class RoutesController
) {
}
#[Auth]
#[AdminPage(title: 'Routes', icon: 'list', section: 'Development', order: 10)]
#[Route('/admin/dev/routes', name: 'admin.dev.routes')]
public function show(): ViewResult
{

View File

@@ -21,7 +21,11 @@ use App\Framework\View\Components\FormInput;
use App\Framework\View\Components\FormRadio;
use App\Framework\View\Components\FormSelect;
use App\Framework\View\Components\FormTextarea;
use App\Framework\Admin\Attributes\AdminPage;
use App\Framework\Admin\Attributes\AdminSection;
use App\Framework\Router\AdminRoutes;
#[AdminSection(name: 'Development', icon: 'code', order: 5, description: 'Development tools and utilities')]
final readonly class StyleguideController
{
public function __construct(
@@ -30,7 +34,8 @@ final readonly class StyleguideController
) {
}
#[Route(path: '/admin/dev/styleguide', method: Method::GET)]
#[AdminPage(title: 'Styleguide', icon: 'tools', section: 'Development', order: 40)]
#[Route(path: '/admin/dev/styleguide', method: Method::GET, name: AdminRoutes::DEV_STYLEGUIDE)]
public function showStyleguide(): ViewResult
{
$metaData = new MetaData(

View File

@@ -31,9 +31,13 @@ use App\Framework\Waf\Rules\RuleEngine;
use App\Framework\Waf\ThreatAssessmentService;
use App\Framework\Waf\WafEngine;
use App\Framework\Admin\Attributes\AdminPage;
use App\Framework\Admin\Attributes\AdminSection;
/**
* Test controller for WAF functionality
*/
#[AdminSection(name: 'Development', icon: 'code', order: 5, description: 'Development tools and utilities')]
final class WafTestController
{
private ?WafEngine $wafEngine = null;
@@ -46,6 +50,7 @@ final class WafTestController
) {
}
#[AdminPage(title: 'WAF Test', icon: 'bug', section: 'Development', order: 30)]
#[Route(path: '/admin/dev/waf-test', method: Method::GET, name: 'admin.dev.waf-test')]
public function showTestPage(): ViewResult
{

View File

@@ -6,12 +6,14 @@ namespace App\Application\Admin;
use App\Domain\Media\Image;
use App\Domain\Media\ImageRepository;
use App\Framework\Admin\Attributes\AdminPage;
use App\Framework\Admin\Attributes\AdminSection;
use App\Framework\Attributes\Route;
use App\Framework\Auth\Auth;
#[AdminSection(name: 'Content', icon: 'photo', order: 3, description: 'Content management')]
final readonly class Images
{
#[Auth]
#[AdminPage(title: 'Images (Debug)', icon: 'images', section: 'Content', order: 60, hidden: true)]
#[Route('/admin/images')]
public function showAll(ImageRepository $imageRepository): never
{

View File

@@ -5,8 +5,9 @@ declare(strict_types=1);
namespace App\Application\Admin\Infrastructure;
use App\Application\Admin\Service\AdminLayoutProcessor;
use App\Framework\Admin\Attributes\AdminPage;
use App\Framework\Admin\Attributes\AdminSection;
use App\Framework\Attributes\Route;
use App\Framework\Auth\Auth;
use App\Framework\Cache\Metrics\CacheMetricsInterface;
use App\Framework\DateTime\Clock;
use App\Framework\Http\Method;
@@ -15,6 +16,7 @@ use App\Framework\Router\AdminRoutes;
use App\Framework\Router\Result\JsonResult;
use App\Framework\Router\Result\ViewResult;
#[AdminSection(name: 'Infrastructure', icon: 'database', order: 2, description: 'Infrastructure monitoring and management')]
final readonly class CacheMetricsController
{
public function __construct(
@@ -24,7 +26,7 @@ final readonly class CacheMetricsController
) {
}
#[Auth]
#[AdminPage(title: 'Cache Metrics', icon: 'cache', section: 'Infrastructure', order: 20)]
#[Route('/admin/infrastructure/cache', Method::GET, name: AdminRoutes::INFRASTRUCTURE_CACHE)]
public function showDashboard(): ViewResult
{

View File

@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace App\Application\Admin\Infrastructure;
use App\Application\Admin\Service\AdminLayoutProcessor;
use App\Framework\Admin\Attributes\AdminPage;
use App\Framework\Admin\Attributes\AdminSection;
use App\Framework\Attributes\Route;
use App\Framework\DateTime\Clock;
use App\Framework\Http\Method;
use App\Framework\Meta\MetaData;
use App\Framework\Process\Services\DockerService;
use App\Framework\Process\ValueObjects\Docker\DockerContainer;
use App\Framework\Router\AdminRoutes;
use App\Framework\Router\Result\JsonResult;
use App\Framework\Router\Result\ViewResult;
#[AdminSection(name: 'Infrastructure', icon: 'database', order: 2, description: 'Infrastructure monitoring and management')]
final readonly class DockerController
{
public function __construct(
private DockerService $dockerService,
private AdminLayoutProcessor $layoutProcessor,
private Clock $clock,
) {
}
#[AdminPage(title: 'Docker', icon: 'docker', section: 'Infrastructure', order: 80)]
#[Route(path: '/admin/infrastructure/docker', method: Method::GET, name: AdminRoutes::INFRASTRUCTURE_DOCKER)]
public function dashboard(): ViewResult
{
$containers = $this->dockerService->listContainers(all: true);
$dockerVersion = $this->dockerService->getVersion();
$isRunning = $this->dockerService->isRunning();
// Get stats for running containers
$containerStats = [];
foreach ($containers as $container) {
if ($container->isRunning) {
$stats = $this->dockerService->getContainerStats($container->id);
if ($stats !== null) {
$containerStats[$container->id] = $stats->toArray();
}
}
}
// Prepare container data
$containerData = [];
foreach ($containers as $container) {
$stats = $containerStats[$container->id] ?? null;
$containerData[] = [
'id' => $container->id,
'name' => $container->name,
'image' => $container->image,
'status' => $container->status,
'is_running' => $container->isRunning,
'stats' => $stats,
];
}
$data = [
'title' => 'Docker Dashboard',
'docker_version' => $dockerVersion,
'is_running' => $isRunning,
'containers' => $containerData,
'total_containers' => count($containers),
'running_containers' => count(array_filter($containers, fn($c) => $c->isRunning)),
'stopped_containers' => count(array_filter($containers, fn($c) => !$c->isRunning)),
'current_year' => $this->clock->now()->format('Y'),
];
$finalData = $this->layoutProcessor->processLayoutFromArray($data);
return new ViewResult(
template: 'docker-dashboard',
metaData: new MetaData('Docker Dashboard', 'Docker container management and monitoring'),
data: $finalData
);
}
#[Route(path: '/admin/infrastructure/docker/api/containers', method: Method::GET)]
public function apiContainers(): JsonResult
{
$containers = $this->dockerService->listContainers(all: true);
$containerData = array_map(fn($c) => $c->toArray(), $containers);
return new JsonResult([
'success' => true,
'data' => $containerData,
]);
}
#[Route(path: '/admin/infrastructure/docker/api/containers/{containerId}/stats', method: Method::GET)]
public function apiContainerStats(string $containerId): JsonResult
{
$stats = $this->dockerService->getContainerStats($containerId);
if ($stats === null) {
return new JsonResult([
'success' => false,
'error' => 'Container not found or not running',
], 404);
}
return new JsonResult([
'success' => true,
'data' => $stats->toArray(),
]);
}
#[Route(path: '/admin/infrastructure/docker/api/containers/{containerId}/logs', method: Method::GET)]
public function apiContainerLogs(string $containerId): JsonResult
{
$lines = 100;
$logs = $this->dockerService->getContainerLogs($containerId, $lines);
if ($logs === null) {
return new JsonResult([
'success' => false,
'error' => 'Failed to retrieve logs',
], 404);
}
return new JsonResult([
'success' => true,
'data' => [
'container_id' => $containerId,
'logs' => $logs,
'lines' => $lines,
],
]);
}
#[Route(path: '/admin/infrastructure/docker/api/containers/{containerId}/start', method: Method::POST)]
public function apiStartContainer(string $containerId): JsonResult
{
$success = $this->dockerService->startContainer($containerId);
return new JsonResult([
'success' => $success,
'message' => $success ? 'Container started' : 'Failed to start container',
]);
}
#[Route(path: '/admin/infrastructure/docker/api/containers/{containerId}/stop', method: Method::POST)]
public function apiStopContainer(string $containerId): JsonResult
{
$success = $this->dockerService->stopContainer($containerId);
return new JsonResult([
'success' => $success,
'message' => $success ? 'Container stopped' : 'Failed to stop container',
]);
}
#[Route(path: '/admin/infrastructure/docker/api/containers/{containerId}/restart', method: Method::POST)]
public function apiRestartContainer(string $containerId): JsonResult
{
$success = $this->dockerService->restartContainer($containerId);
return new JsonResult([
'success' => $success,
'message' => $success ? 'Container restarted' : 'Failed to restart container',
]);
}
}

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Application\Admin\Infrastructure;
use App\Application\Admin\Service\AdminLayoutProcessor;
use App\Framework\Admin\Attributes\AdminPage;
use App\Framework\Admin\Attributes\AdminSection;
use App\Framework\Attributes\Route;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\DateTime\Clock;
@@ -15,10 +17,12 @@ use App\Framework\Http\SseStream;
use App\Framework\Http\Status;
use App\Framework\Logging\LogViewer;
use App\Framework\Meta\MetaData;
use App\Framework\Router\AdminRoutes;
use App\Framework\Router\Result\JsonResult;
use App\Framework\Router\Result\SseResult;
use App\Framework\Router\Result\ViewResult;
#[AdminSection(name: 'Infrastructure', icon: 'database', order: 2, description: 'Infrastructure monitoring and management')]
final readonly class LogViewerController
{
public function __construct(
@@ -29,7 +33,8 @@ final readonly class LogViewerController
) {
}
#[Route('/admin/infrastructure/logs', Method::GET)]
#[AdminPage(title: 'Log Viewer', icon: 'logs', section: 'Infrastructure', order: 40)]
#[Route('/admin/infrastructure/logs', Method::GET, name: AdminRoutes::INFRASTRUCTURE_LOGS)]
public function showLogViewer(): ViewResult
{
$metaData = new MetaData(

View File

@@ -5,14 +5,16 @@ declare(strict_types=1);
namespace App\Application\Admin\Infrastructure;
use App\Application\Admin\Service\AdminLayoutProcessor;
use App\Framework\Admin\Attributes\AdminPage;
use App\Framework\Admin\Attributes\AdminSection;
use App\Framework\Attributes\Route;
use App\Framework\Auth\Auth;
use App\Framework\DateTime\Clock;
use App\Framework\Http\Method;
use App\Framework\Meta\MetaData;
use App\Framework\Redis\Services\RedisMonitoringService;
use App\Framework\Router\Result\ViewResult;
#[AdminSection(name: 'Infrastructure', icon: 'database', order: 2, description: 'Infrastructure monitoring and management')]
final readonly class RedisController
{
public function __construct(
@@ -22,7 +24,7 @@ final readonly class RedisController
) {
}
#[Auth]
#[AdminPage(title: 'Redis', icon: 'redis', section: 'Infrastructure', order: 10)]
#[Route(path: '/admin/infrastructure/redis', method: Method::GET, name: 'admin.infrastructure.redis')]
public function show(): ViewResult
{

View File

@@ -5,14 +5,16 @@ declare(strict_types=1);
namespace App\Application\Admin\Infrastructure;
use App\Application\Admin\Service\AdminLayoutProcessor;
use App\Framework\Admin\Attributes\AdminPage;
use App\Framework\Admin\Attributes\AdminSection;
use App\Framework\Attributes\Route;
use App\Framework\Auth\Auth;
use App\Framework\DateTime\Clock;
use App\Framework\DI\DefaultContainer;
use App\Framework\Http\Method;
use App\Framework\Meta\MetaData;
use App\Framework\Router\Result\ViewResult;
#[AdminSection(name: 'Infrastructure', icon: 'database', order: 2, description: 'Infrastructure monitoring and management')]
final readonly class ServicesController
{
public function __construct(
@@ -22,7 +24,7 @@ final readonly class ServicesController
) {
}
#[Auth]
#[AdminPage(title: 'Services', icon: 'server', section: 'Infrastructure', order: 30)]
#[Route(path: '/admin/infrastructure/services', method: Method::GET, name: 'admin.infrastructure.services')]
public function show(): ViewResult
{

View File

@@ -22,6 +22,9 @@ use App\Framework\Queue\Services\JobMetricsManagerInterface;
use App\Framework\Queue\Services\WorkerRegistry;
use App\Framework\Router\Result\ViewResult;
use App\Framework\Scheduler\Services\SchedulerService;
use App\Framework\Admin\Attributes\AdminPage;
use App\Framework\Admin\Attributes\AdminSection;
use App\Framework\Router\AdminRoutes;
/**
* Job Dashboard Controller
@@ -34,6 +37,7 @@ use App\Framework\Scheduler\Services\SchedulerService;
*
* Uses composable LiveComponents for modular, reusable UI.
*/
#[AdminSection(name: 'Infrastructure', icon: 'database', order: 2, description: 'Infrastructure monitoring and management')]
final readonly class JobDashboardController
{
public function __construct(
@@ -44,7 +48,8 @@ final readonly class JobDashboardController
private SchedulerService $scheduler
) {}
#[Route(path: '/admin/jobs/dashboard', method: Method::GET)]
#[AdminPage(title: 'Job Dashboard', icon: 'terminal', section: 'Infrastructure', order: 70)]
#[Route(path: '/admin/jobs/dashboard', method: Method::GET, name: AdminRoutes::JOBS_DASHBOARD)]
public function dashboard(): ViewResult
{
// Create composable LiveComponents

View File

@@ -6,7 +6,6 @@ namespace App\Application\Admin\MachineLearning;
use App\Application\Admin\Service\AdminLayoutProcessor;
use App\Framework\Attributes\Route;
use App\Framework\Auth\Auth;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Http\HttpRequest;
use App\Framework\Http\Method;
@@ -15,7 +14,10 @@ use App\Framework\MachineLearning\ModelManagement\ModelRegistry;
use App\Framework\Meta\MetaData;
use App\Framework\Router\AdminRoutes;
use App\Framework\Router\Result\ViewResult;
use App\Framework\Admin\Attributes\AdminPage;
use App\Framework\Admin\Attributes\AdminSection;
#[AdminSection(name: 'Machine Learning', icon: 'brain', order: 7, description: 'Machine Learning model management')]
final readonly class MLDashboardAdminController
{
public function __construct(
@@ -24,7 +26,7 @@ final readonly class MLDashboardAdminController
private AdminLayoutProcessor $layoutProcessor
) {}
#[Auth]
#[AdminPage(title: 'ML Dashboard', icon: 'brain', section: 'Machine Learning', order: 10)]
#[Route(path: '/admin/ml/dashboard', method: Method::GET, name: AdminRoutes::ML_DASHBOARD)]
public function dashboard(HttpRequest $request): ViewResult
{

View File

@@ -5,8 +5,9 @@ declare(strict_types=1);
namespace App\Application\Admin;
use App\Application\Admin\Service\AdminLayoutProcessor;
use App\Framework\Admin\Attributes\AdminPage;
use App\Framework\Admin\Attributes\AdminSection;
use App\Framework\Attributes\Route;
use App\Framework\Auth\Auth;
use App\Framework\Database\Migration\MigrationLoader;
use App\Framework\Database\Migration\MigrationRunner;
use App\Framework\Http\Method;
@@ -17,6 +18,7 @@ use App\Framework\View\Table\Table;
use App\Framework\View\Table\TableBuilder;
use App\Framework\View\Table\ValueObjects\ColumnDefinition;
#[AdminSection(name: 'Infrastructure', icon: 'database', order: 2, description: 'Infrastructure monitoring and management')]
final readonly class MigrationStatus
{
public function __construct(
@@ -26,7 +28,7 @@ final readonly class MigrationStatus
) {
}
#[Auth]
#[AdminPage(title: 'Migrations', icon: 'database', section: 'Infrastructure', order: 50)]
#[Route(path: '/admin/infrastructure/migrations', method: Method::GET, name: AdminRoutes::MIGRATIONS)]
public function show(): ViewResult
{

View File

@@ -13,12 +13,16 @@ use App\Framework\Http\Responses\JsonResponse;
use App\Framework\Notification\Storage\NotificationRepository;
use App\Framework\Notification\ValueObjects\NotificationId;
use App\Framework\Meta\MetaData;
use App\Framework\Admin\Attributes\AdminPage;
use App\Framework\Admin\Attributes\AdminSection;
use App\Framework\Router\AdminRoutes;
/**
* Admin Notifications Controller
*
* Displays and manages system notifications for administrators
*/
#[AdminSection(name: 'Notifications', icon: 'bell', order: 6, description: 'System notifications')]
final readonly class NotificationsAdminController
{
public function __construct(
@@ -28,7 +32,8 @@ final readonly class NotificationsAdminController
/**
* Display notifications list
*/
#[Route(path: '/admin/notifications', method: Method::GET)]
#[AdminPage(title: 'Notifications', icon: 'bell', section: 'Notifications', order: 10)]
#[Route(path: '/admin/notifications', method: Method::GET, name: AdminRoutes::NOTIFICATIONS)]
public function index(HttpRequest $request): ViewResult
{
// For now, use 'admin' as recipient ID

View File

@@ -24,12 +24,15 @@ use App\Framework\Http\Method;
use App\Framework\Router\Result\JsonResult;
use App\Framework\Router\Result\Redirect;
use App\Framework\Router\Result\ViewResult;
use App\Framework\Admin\Attributes\AdminPage;
use App\Framework\Admin\Attributes\AdminSection;
/**
* Pre-Save Campaign Admin Controller
*
* Admin interface for managing pre-save campaigns
*/
#[AdminSection(name: 'Content', icon: 'photo', order: 3, description: 'Content management')]
#[AdminResource(
name: 'presave-campaigns',
singularName: 'Pre-Save Campaign',
@@ -52,6 +55,7 @@ final readonly class PreSaveCampaignAdminController
/**
* List all campaigns
*/
#[AdminPage(title: 'Pre-Save Campaigns', icon: 'music', section: 'Content', order: 40)]
#[Route('/admin/presave-campaigns', Method::GET)]
public function index(): ViewResult
{
@@ -121,6 +125,7 @@ final readonly class PreSaveCampaignAdminController
/**
* Show campaign statistics
*/
#[AdminPage(title: 'Pre-Save Campaign', icon: 'music', section: 'Content', order: 40, hidden: true)]
#[Route('/admin/presave-campaigns/{id}', Method::GET)]
public function show(int $id): ViewResult
{

View File

@@ -8,7 +8,6 @@ use App\Domain\PreSave\PreSaveCampaign;
use App\Domain\PreSave\PreSaveCampaignRepository;
use App\Domain\PreSave\PreSaveRegistrationRepository;
use App\Domain\PreSave\ValueObjects\CampaignStatus;
use App\Framework\Attributes\Auth;
use App\Framework\Attributes\Route;
use App\Framework\Core\Method;
use App\Framework\Exception\ErrorCode;
@@ -17,11 +16,13 @@ use App\Framework\Http\HttpRequest;
use App\Framework\Http\JsonResult;
use App\Framework\Http\Redirect;
use App\Framework\Http\ViewResult;
use App\Framework\Admin\Attributes\AdminPage;
use App\Framework\Admin\Attributes\AdminSection;
/**
* Admin Controller for Pre-Save Campaign Management
* Admin Controller for Pre-Save Campaign Management (Legacy)
*/
#[Auth(strategy: 'ip', allowedIps: ['127.0.0.1', '::1'])]
#[AdminSection(name: 'Content', icon: 'photo', order: 3, description: 'Content management')]
final readonly class PreSaveCampaignController
{
public function __construct(
@@ -33,6 +34,7 @@ final readonly class PreSaveCampaignController
/**
* Show all campaigns overview
*/
#[AdminPage(title: 'Pre-Save Campaigns (Legacy)', icon: 'music', section: 'Content', order: 70, hidden: true)]
#[Route(path: '/admin/presave/campaigns', method: Method::GET)]
public function index(HttpRequest $request): ViewResult
{

View File

@@ -0,0 +1,272 @@
<?php
declare(strict_types=1);
namespace App\Application\Admin\Registry;
use App\Application\Admin\Service\AdminPageDiscovery;
use App\Application\Admin\ValueObjects\AdminPageMetadata;
use App\Application\Admin\ValueObjects\AdminSectionMetadata;
use App\Application\Admin\ValueObjects\NavigationItem;
use App\Application\Admin\ValueObjects\NavigationMenu;
use App\Application\Admin\ValueObjects\NavigationSection;
use App\Framework\Icon\Icon;
use App\Framework\Icon\IconRegistry;
use App\Framework\Router\UrlGenerator;
/**
* AdminNavigationRegistry
*
* Central registry for admin navigation structure.
* Groups pages by section and provides navigation menu generation.
*/
final class AdminNavigationRegistry
{
/** @var array<string, AdminPageMetadata> */
private array $pages = [];
/** @var array<string, AdminSectionMetadata> */
private array $sections = [];
private bool $initialized = false;
public function __construct(
private readonly AdminPageDiscovery $pageDiscovery,
private readonly UrlGenerator $urlGenerator,
private readonly IconRegistry $iconRegistry
) {
}
/**
* Initialize registry by discovering pages
*/
public function initialize(): void
{
if ($this->initialized) {
return;
}
$pages = $this->pageDiscovery->discoverPages();
$sectionMetadata = $this->pageDiscovery->discoverSections();
// Build pages index
foreach ($pages as $page) {
$this->pages[$page->path] = $page;
}
// Build sections index
foreach ($sectionMetadata as $sectionName => $metadata) {
$this->sections[$sectionName] = new AdminSectionMetadata(
name: $metadata['name'],
icon: $metadata['icon'] ?? null,
order: $metadata['order'] ?? 0,
description: $metadata['description'] ?? null
);
}
$this->initialized = true;
}
/**
* Get navigation menu grouped by sections
*/
public function getNavigationMenu(string $currentPath): NavigationMenu
{
$this->initialize();
// Group pages by section
$pagesBySection = [];
foreach ($this->pages as $page) {
$sectionName = $page->section ?? 'Other';
if (! isset($pagesBySection[$sectionName])) {
$pagesBySection[$sectionName] = [];
}
$pagesBySection[$sectionName][] = $page;
}
// Sort pages within each section by order
foreach ($pagesBySection as $sectionName => $pages) {
usort($pages, fn (AdminPageMetadata $a, AdminPageMetadata $b) => $a->order <=> $b->order);
$pagesBySection[$sectionName] = $pages;
}
// Build navigation sections
$navigationSections = [];
foreach ($pagesBySection as $sectionName => $pages) {
$items = [];
foreach ($pages as $page) {
$url = $this->getUrlForPage($page);
$icon = $page->getIcon();
$items[] = new NavigationItem(
name: $page->title,
url: $url,
icon: $icon,
isActive: $this->isPageActive($page, $currentPath)
);
}
// Get section metadata or create default
$sectionMetadata = $this->sections[$sectionName] ?? new AdminSectionMetadata(
name: $sectionName,
icon: $this->getDefaultSectionIcon($sectionName),
order: $this->getDefaultSectionOrder($sectionName)
);
$navigationSections[] = new NavigationSection(
name: $sectionMetadata->name,
items: $items,
icon: $sectionMetadata->icon
);
}
// Sort sections by order
usort($navigationSections, function (NavigationSection $a, NavigationSection $b) {
$orderA = $this->sections[$a->name]?->order ?? $this->getDefaultSectionOrder($a->name);
$orderB = $this->sections[$b->name]?->order ?? $this->getDefaultSectionOrder($b->name);
return $orderA <=> $orderB;
});
return new NavigationMenu($navigationSections);
}
/**
* Get breadcrumbs for current path
*
* @return array<array{name: string, url: string|null}>
*/
public function getBreadcrumbs(string $currentPath): array
{
$this->initialize();
$adminDashboardUrl = '/admin';
try {
$adminDashboardUrl = $this->urlGenerator->route('admin.dashboard');
} catch (\Exception) {
// Fallback to default
}
$breadcrumbs = [
['name' => 'Admin', 'url' => $adminDashboardUrl],
];
// Find page matching current path
$currentPage = null;
foreach ($this->pages as $page) {
if ($page->path === $currentPath || str_starts_with($currentPath, $page->path)) {
$currentPage = $page;
break;
}
}
if ($currentPage === null) {
return $breadcrumbs;
}
// Add section if available
if ($currentPage->section !== null && $currentPage->section !== 'Other') {
$breadcrumbs[] = ['name' => $currentPage->section, 'url' => null];
}
// Add current page
$breadcrumbs[] = [
'name' => $currentPage->title,
'url' => $this->getUrlForPage($currentPage),
];
return $breadcrumbs;
}
/**
* Get all pages
*
* @return array<AdminPageMetadata>
*/
public function getAllPages(): array
{
$this->initialize();
return array_values($this->pages);
}
/**
* Get pages by section
*
* @return array<string, array<AdminPageMetadata>>
*/
public function getPagesBySection(): array
{
$this->initialize();
$pagesBySection = [];
foreach ($this->pages as $page) {
$sectionName = $page->section ?? 'Other';
if (! isset($pagesBySection[$sectionName])) {
$pagesBySection[$sectionName] = [];
}
$pagesBySection[$sectionName][] = $page;
}
return $pagesBySection;
}
/**
* Get URL for a page
*/
private function getUrlForPage(AdminPageMetadata $page): string
{
// Try to use route name if available
if (! empty($page->routeName)) {
try {
return $this->urlGenerator->route($page->routeName);
} catch (\Exception) {
// Fallback to path
}
}
return $page->path;
}
/**
* Check if page is active
*/
private function isPageActive(AdminPageMetadata $page, string $currentPath): bool
{
return $page->path === $currentPath || str_starts_with($currentPath, $page->path . '/');
}
/**
* Get default icon for section
*/
private function getDefaultSectionIcon(string $sectionName): Icon|string|null
{
$iconName = match (strtolower($sectionName)) {
'system' => 'server',
'infrastructure' => 'database',
'content' => 'photo',
'analytics' => 'chart-bar',
'development' => 'code',
'notifications' => 'bell',
'machinelearning' => 'brain',
default => 'file',
};
return $this->iconRegistry->get($iconName) ?? Icon::font($iconName);
}
/**
* Get default order for section
*/
private function getDefaultSectionOrder(string $sectionName): int
{
return match (strtolower($sectionName)) {
'system' => 1,
'infrastructure' => 2,
'content' => 3,
'analytics' => 4,
'development' => 5,
'notifications' => 6,
'machinelearning' => 7,
default => 99,
};
}
}

View File

@@ -4,135 +4,57 @@ declare(strict_types=1);
namespace App\Application\Admin\Service;
use App\Application\Admin\Registry\AdminNavigationRegistry;
use App\Framework\Http\HttpRequest;
use App\Framework\Icon\IconRegistry;
use App\Framework\Router\UrlGenerator;
final readonly class AdminNavigationService
{
public function __construct(
private UrlGenerator $urlGenerator
private AdminNavigationRegistry $navigationRegistry,
private UrlGenerator $urlGenerator,
private HttpRequest $request,
private IconRegistry $iconRegistry
) {
}
/**
* Get navigation menu as array (backward compatible format)
*
* @return array<string, array{icon: string, items: array<string, string>}>
*/
public function getNavigationMenu(): array
{
error_log("AdminNavigationService: Starting navigation menu generation");
$menuDefinition = [
'System' => [
'icon' => 'server',
'routes' => [
'Dashboard' => 'admin.dashboard',
'Performance' => 'admin.system.performance',
'Environment' => 'admin.system.environment',
'Health Check' => 'admin.system.health',
'PHP Info' => 'admin.system.phpinfo',
],
],
'Infrastructure' => [
'icon' => 'database',
'routes' => [
'Redis' => 'admin.infrastructure.redis',
'Cache Metrics' => 'admin.infrastructure.cache',
'Services' => 'admin.infrastructure.services',
],
'static' => [
'Logs' => '/admin/infrastructure/logs', // TODO: Add named route
],
],
'Development' => [
'icon' => 'code',
'routes' => [
'Routes' => 'admin.dev.routes',
'Design System' => 'admin.dev.design-system',
'WAF Testing' => 'admin.dev.waf-test',
],
'static' => [
'Style Guide' => '/admin/dev/styleguide', // TODO: Add named route
],
],
'Analytics' => [
'icon' => 'chart-bar',
'routes' => [
'Dashboard' => 'admin.analytics.dashboard',
],
],
'Content' => [
'icon' => 'photo',
'routes' => [
'Image Manager' => 'admin.content.images',
'Image Slots' => 'admin.content.image-slots',
],
],
];
$currentPath = $this->request->getPath();
$navigationMenu = $this->navigationRegistry->getNavigationMenu($currentPath);
// Convert NavigationMenu to legacy array format for backward compatibility
$menu = [];
foreach ($menuDefinition as $sectionName => $sectionData) {
error_log("AdminNavigationService: Processing section '{$sectionName}'");
foreach ($navigationMenu->sections as $section) {
$items = [];
// Process routes
if (isset($sectionData['routes'])) {
foreach ($sectionData['routes'] as $itemName => $routeName) {
try {
$items[$itemName] = $this->urlGenerator->route($routeName);
error_log("AdminNavigationService: Successfully generated route '{$routeName}' for '{$itemName}'");
} catch (\Exception $e) {
error_log("AdminNavigationService: Failed to generate route '{$routeName}' for '{$itemName}': " . $e->getMessage());
// Generate fallback URL from route name - better mapping
$fallbackUrl = match ($routeName) {
'admin.dashboard' => '/admin',
'admin.system.health' => '/admin/system/health',
'admin.system.phpinfo' => '/admin/system/phpinfo',
'admin.system.performance' => '/admin/system/performance',
'admin.system.environment' => '/admin/system/environment',
'admin.infrastructure.redis' => '/admin/infrastructure/redis',
'admin.infrastructure.cache' => '/admin/infrastructure/cache',
'admin.infrastructure.services' => '/admin/infrastructure/services',
'admin.dev.routes' => '/admin/dev/routes',
'admin.dev.design-system' => '/admin/dev/design-system',
'admin.dev.waf-test' => '/admin/dev/waf-test',
'admin.analytics.dashboard' => '/admin/analytics/dashboard',
'admin.content.images' => '/admin/content/images',
'admin.content.image-slots' => '/admin/content/image-slots',
default => '/' . str_replace('.', '/', $routeName)
};
$items[$itemName] = $fallbackUrl;
error_log("AdminNavigationService: Using fallback URL '{$fallbackUrl}' for '{$itemName}'");
}
}
foreach ($section->items as $item) {
$items[$item->name] = $item->url;
}
// Add static routes
if (isset($sectionData['static'])) {
foreach ($sectionData['static'] as $itemName => $url) {
$items[$itemName] = $url;
error_log("AdminNavigationService: Added static route '{$url}' for '{$itemName}'");
}
}
$menu[$sectionName] = [
'icon' => $sectionData['icon'],
$icon = $section->getIcon();
$menu[$section->name] = [
'icon' => $icon?->toString() ?? 'file',
'items' => $items,
];
}
error_log("AdminNavigationService: Successfully completed navigation menu generation");
return $menu;
}
public function getCurrentSection(string $currentPath): ?string
{
$menu = $this->getNavigationMenu();
$navigationMenu = $this->navigationRegistry->getNavigationMenu($currentPath);
foreach ($menu as $section => $data) {
foreach ($data['items'] as $name => $url) {
if (str_starts_with($currentPath, $url)) {
return $section;
foreach ($navigationMenu->sections as $section) {
foreach ($section->items as $item) {
if (str_starts_with($currentPath, $item->url)) {
return $section->name;
}
}
}
@@ -140,37 +62,24 @@ final readonly class AdminNavigationService
return null;
}
/**
* Get breadcrumbs for current path
*
* @return array<array{name: string, url: string|null}>
*/
public function getBreadcrumbs(string $currentPath): array
{
$menu = $this->getNavigationMenu();
$breadcrumbs = [
['name' => 'Admin', 'url' => $this->urlGenerator->route('admin.dashboard')],
];
foreach ($menu as $section => $data) {
foreach ($data['items'] as $name => $url) {
if ($currentPath === $url || str_starts_with($currentPath, $url)) {
if ($url !== $this->urlGenerator->route('admin.dashboard')) {
$breadcrumbs[] = ['name' => $section, 'url' => null];
$breadcrumbs[] = ['name' => $name, 'url' => $url];
}
return $breadcrumbs;
}
}
}
return $breadcrumbs;
return $this->navigationRegistry->getBreadcrumbs($currentPath);
}
public function getActiveMenuItem(string $currentPath): ?string
{
$menu = $this->getNavigationMenu();
$navigationMenu = $this->navigationRegistry->getNavigationMenu($currentPath);
foreach ($menu as $section => $data) {
foreach ($data['items'] as $name => $url) {
if ($currentPath === $url || str_starts_with($currentPath, $url)) {
return $name;
foreach ($navigationMenu->sections as $section) {
foreach ($section->items as $item) {
if ($currentPath === $item->url || str_starts_with($currentPath, $item->url)) {
return $item->name;
}
}
}

View File

@@ -0,0 +1,356 @@
<?php
declare(strict_types=1);
namespace App\Application\Admin\Service;
use App\Application\Admin\ValueObjects\AdminPageMetadata;
use App\Framework\Admin\Attributes\AdminPage;
use App\Framework\Admin\Attributes\AdminSection;
use App\Framework\Attributes\Route;
use App\Framework\Auth\Auth;
use App\Framework\Discovery\Results\DiscoveryRegistry;
use App\Framework\Discovery\ValueObjects\DiscoveredAttribute;
use App\Framework\Icon\Icon;
use App\Framework\Icon\IconRegistry;
use App\Framework\Http\Method;
/**
* AdminPageDiscovery Service
*
* Automatically discovers admin pages from Route attributes and optional AdminPage attributes.
* Filters routes starting with /admin/ and extracts metadata for navigation.
*/
final readonly class AdminPageDiscovery
{
public function __construct(
private DiscoveryRegistry $discoveryRegistry,
private IconRegistry $iconRegistry
) {
}
/**
* Discover all admin pages from routes
*
* @return array<AdminPageMetadata>
*/
public function discoverPages(): array
{
$routeAttributes = $this->discoveryRegistry->attributes->get(Route::class);
$adminPages = [];
foreach ($routeAttributes as $routeAttribute) {
$path = $this->extractPath($routeAttribute);
// Only process admin routes
if (! str_starts_with($path, '/admin/')) {
continue;
}
// Only process GET routes for navigation (exclude API routes)
$method = $this->extractMethod($routeAttribute);
if ($method !== Method::GET || str_starts_with($path, '/admin/api/')) {
continue;
}
// Extract metadata
$pageMetadata = $this->buildPageMetadata($routeAttribute, $path);
if ($pageMetadata !== null && ! $pageMetadata->hidden) {
$adminPages[] = $pageMetadata;
}
}
return $adminPages;
}
/**
* Extract section metadata from controller class
*
* @return array<string, mixed>
*/
public function discoverSections(): array
{
$sections = [];
$routeAttributes = $this->discoveryRegistry->attributes->get(Route::class);
// Get all unique controller classes with admin routes
$controllerClasses = [];
foreach ($routeAttributes as $routeAttribute) {
$path = $this->extractPath($routeAttribute);
if (str_starts_with($path, '/admin/')) {
$controllerClass = $routeAttribute->className->getFullyQualified();
if (! isset($controllerClasses[$controllerClass])) {
$controllerClasses[$controllerClass] = $routeAttribute;
}
}
}
// Check for AdminSection attributes on controllers
foreach ($controllerClasses as $controllerClass => $routeAttribute) {
$sectionMetadata = $this->extractSectionMetadata($controllerClass);
if ($sectionMetadata !== null) {
$sectionName = $sectionMetadata['name'];
if (! isset($sections[$sectionName])) {
$sections[$sectionName] = $sectionMetadata;
}
}
}
return $sections;
}
/**
* Build page metadata from route attribute
*/
private function buildPageMetadata(DiscoveredAttribute $routeAttribute, string $path): ?AdminPageMetadata
{
$controllerClass = $routeAttribute->className->getFullyQualified();
$methodName = $routeAttribute->methodName?->toString() ?? '';
$routeName = $this->extractRouteName($routeAttribute);
// Extract AdminPage attribute if present
$adminPageAttribute = $this->extractAdminPageAttribute($routeAttribute);
// Generate default title from method/controller name
$title = $adminPageAttribute?->title ?? $this->generateTitle($methodName, $controllerClass, $path);
// Generate default section from namespace
$section = $adminPageAttribute?->section ?? $this->extractSectionFromNamespace($controllerClass);
// Generate default icon
$icon = $adminPageAttribute?->getIcon() ?? $this->generateDefaultIcon($section);
return new AdminPageMetadata(
path: $path,
title: $title,
routeName: $routeName,
controllerClass: $controllerClass,
methodName: $methodName,
icon: $icon,
section: $section,
order: $adminPageAttribute?->order ?? 0,
hidden: $adminPageAttribute?->hidden ?? false,
description: $adminPageAttribute?->description
);
}
/**
* Extract AdminPage attribute from route attribute
*/
private function extractAdminPageAttribute(DiscoveredAttribute $routeAttribute): ?AdminPage
{
// Check if AdminPage attribute exists on the same method
$controllerClass = $routeAttribute->className->getFullyQualified();
$methodName = $routeAttribute->methodName?->toString();
if ($methodName === null) {
return null;
}
try {
$reflection = new \ReflectionMethod($controllerClass, $methodName);
$attributes = $reflection->getAttributes(AdminPage::class);
if (! empty($attributes)) {
return $attributes[0]->newInstance();
}
} catch (\ReflectionException) {
// Ignore reflection errors
}
return null;
}
/**
* Extract AdminSection attribute from controller class
*
* @return array<string, mixed>|null
*/
private function extractSectionMetadata(string $controllerClass): ?array
{
try {
$reflection = new \ReflectionClass($controllerClass);
$attributes = $reflection->getAttributes(AdminSection::class);
if (! empty($attributes)) {
$sectionAttribute = $attributes[0]->newInstance();
$icon = $sectionAttribute->getIcon();
return [
'name' => $sectionAttribute->name ?? $this->extractSectionFromNamespace($controllerClass),
'icon' => $icon?->toString(),
'order' => $sectionAttribute->order,
'description' => $sectionAttribute->description,
];
}
} catch (\ReflectionException) {
// Ignore reflection errors
}
return null;
}
/**
* Extract section name from namespace
*/
private function extractSectionFromNamespace(string $controllerClass): string
{
// Extract namespace segment after Application\Admin
// e.g., App\Application\Admin\System\HealthController -> System
if (preg_match('/App\\\Application\\\Admin\\\\([^\\\\]+)/', $controllerClass, $matches)) {
return $matches[1];
}
// Fallback: use last namespace segment
$parts = explode('\\', $controllerClass);
$className = end($parts);
// Remove "Controller" suffix if present
if (str_ends_with($className, 'Controller')) {
$className = substr($className, 0, -10);
}
return $className;
}
/**
* Generate title from method/controller name
*/
private function generateTitle(string $methodName, string $controllerClass, string $path): string
{
// Try to extract from route name first
$routeName = $this->extractRouteNameFromPath($path);
if ($routeName !== null) {
return $this->formatTitle($routeName);
}
// Use method name (e.g., "show" -> "Show", "index" -> "Index")
if ($methodName !== '') {
return $this->formatTitle($methodName);
}
// Extract from controller class name
$parts = explode('\\', $controllerClass);
$className = end($parts);
// Remove "Controller" suffix
if (str_ends_with($className, 'Controller')) {
$className = substr($className, 0, -10);
}
return $this->formatTitle($className);
}
/**
* Format string as title (e.g., "health_check" -> "Health Check")
*/
private function formatTitle(string $text): string
{
// Replace underscores and dashes with spaces
$text = str_replace(['_', '-'], ' ', $text);
// Capitalize words
return ucwords(strtolower($text));
}
/**
* Generate default icon based on section
*/
private function generateDefaultIcon(?string $section): ?Icon
{
if ($section === null) {
return null;
}
$iconName = match (strtolower($section)) {
'system' => 'server',
'infrastructure' => 'database',
'content' => 'photo',
'analytics' => 'chart-bar',
'development' => 'code',
'notifications' => 'bell',
'machinelearning' => 'brain',
default => 'file',
};
return $this->iconRegistry->get($iconName) ?? Icon::font($iconName);
}
/**
* Extract path from route attribute
*/
private function extractPath(DiscoveredAttribute $routeAttribute): string
{
$path = $routeAttribute->additionalData['path'] ?? '';
if (empty($path) && isset($routeAttribute->arguments['path'])) {
$path = $routeAttribute->arguments['path'];
if (is_object($path) && method_exists($path, 'toString')) {
$path = $path->toString();
}
}
return (string) $path;
}
/**
* Extract HTTP method from route attribute
*/
private function extractMethod(DiscoveredAttribute $routeAttribute): Method
{
$method = $routeAttribute->additionalData['method'] ?? null;
if ($method === null && isset($routeAttribute->arguments['method'])) {
$method = $routeAttribute->arguments['method'];
}
if ($method instanceof Method) {
return $method;
}
if (is_string($method)) {
return Method::from(strtoupper($method));
}
// Default to GET
return Method::GET;
}
/**
* Extract route name from route attribute
*/
private function extractRouteName(DiscoveredAttribute $routeAttribute): string
{
$name = $routeAttribute->additionalData['name'] ?? '';
if (empty($name) && isset($routeAttribute->arguments['name'])) {
$name = $routeAttribute->arguments['name'];
if (is_object($name) && method_exists($name, 'value')) {
$name = $name->value;
}
}
return (string) $name;
}
/**
* Extract route name from path as fallback
*/
private function extractRouteNameFromPath(string $path): ?string
{
// Remove /admin prefix
$path = str_replace('/admin/', '', $path);
// Remove leading slash
$path = ltrim($path, '/');
if (empty($path)) {
return null;
}
// Use last segment as name
$parts = explode('/', $path);
return end($parts);
}
}

View File

@@ -4,11 +4,13 @@ declare(strict_types=1);
namespace App\Application\Admin;
use App\Framework\Admin\Attributes\AdminPage;
use App\Framework\Admin\Attributes\AdminSection;
use App\Framework\Attributes\Route;
use App\Framework\Auth\Auth;
use App\Framework\Cache\Cache;
use App\Framework\Discovery\Results\DiscoveryRegistry;
#[AdminSection(name: 'Development', icon: 'code', order: 5, description: 'Development tools and utilities')]
final readonly class ShowDiscovery
{
public function __construct(
@@ -16,7 +18,7 @@ final readonly class ShowDiscovery
) {
}
#[Auth]
#[AdminPage(title: 'Discovery (Debug)', icon: 'code', section: 'Development', order: 50, hidden: true)]
#[Route('/admin/discovery')]
public function show(
#Cache $cache

View File

@@ -7,6 +7,8 @@ namespace App\Application\Admin;
use App\Application\Admin\Service\AdminLayoutProcessor;
use App\Application\LiveComponents\ImageGallery\ImageGalleryComponent;
use App\Domain\Media\ImageRepository;
use App\Framework\Admin\Attributes\AdminPage;
use App\Framework\Admin\Attributes\AdminSection;
use App\Framework\Attributes\Route;
use App\Framework\Http\HttpRequest;
use App\Framework\Http\Method;
@@ -15,6 +17,7 @@ use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\Meta\MetaData;
use App\Framework\Router\Result\ViewResult;
#[AdminSection(name: 'Content', icon: 'photo', order: 3, description: 'Content management')]
final readonly class ShowImageManager
{
public function __construct(
@@ -24,6 +27,7 @@ final readonly class ShowImageManager
) {
}
#[AdminPage(title: 'Image Manager (Legacy)', icon: 'images', section: 'Content', order: 50, hidden: true)]
#[Route(path: '/admin/images', method: Method::GET)]
public function __invoke(HttpRequest $request): ViewResult
{

View File

@@ -10,7 +10,6 @@ use App\Domain\Media\ImageRepository;
use App\Domain\Media\ImageResizer;
use App\Domain\Media\ImageVariantRepository;
use App\Framework\Attributes\Route;
use App\Framework\Auth\Auth;
use App\Framework\Core\PathProvider;
use App\Framework\Http\Method;
use App\Framework\Http\Request;
@@ -33,7 +32,6 @@ final readonly class ShowImageUpload
) {
}
#[Auth]
#[Route('/upload')]
public function __invoke(): ViewResult
{
@@ -61,7 +59,6 @@ final readonly class ShowImageUpload
return new ViewResult('upload-form', $metaData, $finalData);
}
#[Auth]
#[Route('/upload', Method::POST)]
public function upload(Request $request, Ulid $ulid, ImageRepository $imageRepository, ImageVariantRepository $imageVariantRepository): ViewResult
{

View File

@@ -5,11 +5,13 @@ declare(strict_types=1);
namespace App\Application\Admin;
use App\Application\Admin\Service\AdminLayoutProcessor;
use App\Framework\Admin\Attributes\AdminPage;
use App\Framework\Admin\Attributes\AdminSection;
use App\Framework\Attributes\Route;
use App\Framework\Auth\Auth;
use App\Framework\Meta\MetaData;
use App\Framework\Router\Result\ViewResult;
#[AdminSection(name: 'Development', icon: 'code', order: 5, description: 'Development tools and utilities')]
final readonly class ShowUploadTest
{
public function __construct(
@@ -17,7 +19,7 @@ final readonly class ShowUploadTest
) {
}
#[Auth]
#[AdminPage(title: 'Upload Test', icon: 'file-upload', section: 'Development', order: 60, hidden: true)]
#[Route('/admin/test/upload')]
public function __invoke(): ViewResult
{

View File

@@ -5,8 +5,9 @@ declare(strict_types=1);
namespace App\Application\Admin\System;
use App\Application\Admin\Service\AdminLayoutProcessor;
use App\Framework\Admin\Attributes\AdminPage;
use App\Framework\Admin\Attributes\AdminSection;
use App\Framework\Attributes\Route;
use App\Framework\Auth\Auth;
use App\Framework\Config\Environment;
use App\Framework\DateTime\Clock;
use App\Framework\DI\DefaultContainer;
@@ -14,6 +15,7 @@ use App\Framework\Http\Method;
use App\Framework\Meta\MetaData;
use App\Framework\Router\Result\ViewResult;
#[AdminSection(name: 'System', icon: 'server', order: 1, description: 'System monitoring and configuration')]
final readonly class EnvironmentController
{
public function __construct(
@@ -23,7 +25,7 @@ final readonly class EnvironmentController
) {
}
#[Auth]
#[AdminPage(title: 'Environment Variables', icon: 'settings', section: 'System', order: 4)]
#[Route(path: '/admin/system/environment', method: Method::GET, name: 'admin.system.environment')]
public function show(): ViewResult
{

View File

@@ -5,8 +5,9 @@ declare(strict_types=1);
namespace App\Application\Admin\System;
use App\Application\Admin\Service\AdminLayoutProcessor;
use App\Framework\Admin\Attributes\AdminPage;
use App\Framework\Admin\Attributes\AdminSection;
use App\Framework\Attributes\Route;
use App\Framework\Auth\Auth;
use App\Framework\DateTime\Clock;
use App\Framework\Health\HealthCheckCategory;
use App\Framework\Health\HealthCheckManager;
@@ -16,6 +17,7 @@ use App\Framework\Router\Result\JsonResult;
use App\Framework\Router\Result\ViewResult;
use App\Framework\View\Table\Generators\HealthCheckTableGenerator;
#[AdminSection(name: 'System', icon: 'server', order: 1, description: 'System monitoring and configuration')]
final readonly class HealthController
{
public function __construct(
@@ -26,7 +28,7 @@ final readonly class HealthController
) {
}
#[Auth]
#[AdminPage(title: 'System Health', icon: 'health', section: 'System', order: 2)]
#[Route(path: '/admin/system/health', method: Method::GET, name: 'admin.system.health')]
public function showDashboard(): ViewResult
{

View File

@@ -5,8 +5,9 @@ declare(strict_types=1);
namespace App\Application\Admin\System;
use App\Application\Admin\Service\AdminLayoutProcessor;
use App\Framework\Admin\Attributes\AdminPage;
use App\Framework\Admin\Attributes\AdminSection;
use App\Framework\Attributes\Route;
use App\Framework\Auth\Auth;
use App\Framework\Core\ValueObjects\Byte;
use App\Framework\Core\ValueObjects\Percentage;
use App\Framework\DateTime\Clock;
@@ -17,6 +18,7 @@ use App\Framework\Performance\MemoryMonitor;
use App\Framework\Router\Result\JsonResult;
use App\Framework\Router\Result\ViewResult;
#[AdminSection(name: 'System', icon: 'server', order: 1, description: 'System monitoring and configuration')]
final readonly class PerformanceController
{
public function __construct(
@@ -26,7 +28,7 @@ final readonly class PerformanceController
) {
}
#[Auth]
#[AdminPage(title: 'Performance', icon: 'performance', section: 'System', order: 3)]
#[Route(path: '/admin/system/performance', method: Method::GET, name: 'admin.system.performance')]
public function show(): ViewResult
{
@@ -80,7 +82,6 @@ final readonly class PerformanceController
);
}
#[Auth]
#[Route(path: '/admin/system/performance/api/realtime', method: Method::GET)]
public function getRealtimeMetrics(Request $request): JsonResult
{

View File

@@ -6,13 +6,15 @@ namespace App\Application\Admin\System;
use App\Application\Admin\Service\AdminLayoutProcessor;
use App\Application\Admin\System\Service\PhpInfoService;
use App\Framework\Admin\Attributes\AdminPage;
use App\Framework\Admin\Attributes\AdminSection;
use App\Framework\Attributes\Route;
use App\Framework\Auth\Auth;
use App\Framework\DateTime\Clock;
use App\Framework\Http\Method;
use App\Framework\Meta\MetaData;
use App\Framework\Router\Result\ViewResult;
#[AdminSection(name: 'System', icon: 'server', order: 1, description: 'System monitoring and configuration')]
final readonly class PhpInfoController
{
public function __construct(
@@ -22,7 +24,7 @@ final readonly class PhpInfoController
) {
}
#[Auth]
#[AdminPage(title: 'PHP Information', icon: 'code', section: 'System', order: 5)]
#[Route(path: '/admin/system/phpinfo', method: Method::GET, name: 'admin.system.phpinfo')]
public function show(): ViewResult
{

View File

@@ -4,8 +4,9 @@ declare(strict_types=1);
namespace App\Application\Admin\System;
use App\Framework\Admin\Attributes\AdminPage;
use App\Framework\Admin\Attributes\AdminSection;
use App\Framework\Attributes\Route;
use App\Framework\Auth\Auth;
use App\Framework\Config\TypedConfiguration;
use App\Framework\Core\VersionInfo;
use App\Framework\DateTime\Clock;
@@ -14,8 +15,10 @@ use App\Framework\Http\Method;
use App\Framework\Http\Session\SessionManager;
use App\Framework\Meta\MetaData;
use App\Framework\Performance\MemoryMonitor;
use App\Framework\Router\AdminRoutes;
use App\Framework\Router\Result\ViewResult;
#[AdminSection(name: 'System', icon: 'server', order: 1, description: 'System monitoring and configuration')]
final readonly class SystemDashboardController
{
public function __construct(
@@ -27,8 +30,8 @@ final readonly class SystemDashboardController
) {
}
#[Auth]
#[Route(path: '/admin/system', method: Method::GET)]
#[AdminPage(title: 'System Dashboard', icon: 'dashboard', section: 'System', order: 1)]
#[Route(path: '/admin/system', method: Method::GET, name: AdminRoutes::SYSTEM_DASHBOARD)]
public function show(): ViewResult
{
/** @var array<string, mixed> $stats */

View File

@@ -18,6 +18,8 @@ use App\Framework\Http\Method;
use App\Framework\Router\Result\JsonResult;
use App\Framework\Router\Result\Redirect;
use App\Framework\Router\Result\ViewResult;
use App\Framework\Admin\Attributes\AdminPage;
use App\Framework\Admin\Attributes\AdminSection;
/**
* Example Admin Controller demonstrating the Admin Framework
@@ -28,6 +30,7 @@ use App\Framework\Router\Result\ViewResult;
* - AdminPageRenderer for consistent rendering
* - AdminApiHandler for auto-generated CRUD API
*/
#[AdminSection(name: 'Content', icon: 'photo', order: 3, description: 'Content management')]
#[AdminResource(
name: 'users',
singularName: 'User',
@@ -50,6 +53,7 @@ final readonly class UserAdminController
/**
* List all users with table, search, and pagination
*/
#[AdminPage(title: 'Users', icon: 'users', section: 'Content', order: 30)]
#[Route('/admin/users', Method::GET)]
public function index(): ViewResult
{

View File

@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace App\Application\Admin\ValueObjects;
use App\Framework\Icon\Icon;
final readonly class AdminPageMetadata
{
public function __construct(
public string $path,
public string $title,
public string $routeName,
public string $controllerClass,
public string $methodName,
public Icon|string|null $icon = null,
public ?string $section = null,
public int $order = 0,
public bool $hidden = false,
public ?string $description = null
) {
}
/**
* Get icon as Icon object
*/
public function getIcon(): ?Icon
{
if ($this->icon === null) {
return null;
}
if ($this->icon instanceof Icon) {
return $this->icon;
}
return Icon::fromString($this->icon);
}
public static function fromArray(array $data): self
{
return new self(
path: $data['path'],
title: $data['title'],
routeName: $data['route_name'] ?? '',
controllerClass: $data['controller_class'],
methodName: $data['method_name'],
icon: $data['icon'] ?? null, // Can be string or Icon
section: $data['section'] ?? null,
order: $data['order'] ?? 0,
hidden: $data['hidden'] ?? false,
description: $data['description'] ?? null
);
}
public function toArray(): array
{
return [
'path' => $this->path,
'title' => $this->title,
'route_name' => $this->routeName,
'controller_class' => $this->controllerClass,
'method_name' => $this->methodName,
'icon' => $this->icon instanceof Icon ? $this->icon->toString() : $this->icon,
'section' => $this->section,
'order' => $this->order,
'hidden' => $this->hidden,
'description' => $this->description,
];
}
public function withTitle(string $title): self
{
return new self(
path: $this->path,
title: $title,
routeName: $this->routeName,
controllerClass: $this->controllerClass,
methodName: $this->methodName,
icon: $this->icon,
section: $this->section,
order: $this->order,
hidden: $this->hidden,
description: $this->description
);
}
public function withSection(?string $section): self
{
return new self(
path: $this->path,
title: $this->title,
routeName: $this->routeName,
controllerClass: $this->controllerClass,
methodName: $this->methodName,
icon: $this->icon,
section: $section,
order: $this->order,
hidden: $this->hidden,
description: $this->description
);
}
public function withOrder(int $order): self
{
return new self(
path: $this->path,
title: $this->title,
routeName: $this->routeName,
controllerClass: $this->controllerClass,
methodName: $this->methodName,
icon: $this->icon,
section: $this->section,
order: $order,
hidden: $this->hidden,
description: $this->description
);
}
public function withHidden(bool $hidden): self
{
return new self(
path: $this->path,
title: $this->title,
routeName: $this->routeName,
controllerClass: $this->controllerClass,
methodName: $this->methodName,
icon: $this->icon,
section: $this->section,
order: $this->order,
hidden: $hidden,
description: $this->description
);
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Application\Admin\ValueObjects;
use App\Framework\Icon\Icon;
final readonly class AdminSectionMetadata
{
public function __construct(
public string $name,
public Icon|string|null $icon = null,
public int $order = 0,
public ?string $description = null
) {
}
/**
* Get icon as Icon object
*/
public function getIcon(): ?Icon
{
if ($this->icon === null) {
return null;
}
if ($this->icon instanceof Icon) {
return $this->icon;
}
return Icon::fromString($this->icon);
}
public static function fromArray(array $data): self
{
return new self(
name: $data['name'],
icon: $data['icon'] ?? null, // Can be string or Icon
order: $data['order'] ?? 0,
description: $data['description'] ?? null
);
}
public function toArray(): array
{
return [
'name' => $this->name,
'icon' => $this->icon instanceof Icon ? $this->icon->toString() : $this->icon,
'order' => $this->order,
'description' => $this->description,
];
}
public function withIcon(Icon|string|null $icon): self
{
return new self(
name: $this->name,
icon: $icon,
order: $this->order,
description: $this->description
);
}
public function withOrder(int $order): self
{
return new self(
name: $this->name,
icon: $this->icon,
order: $order,
description: $this->description
);
}
}

View File

@@ -4,345 +4,276 @@ declare(strict_types=1);
namespace App\Application\Admin\ValueObjects;
use App\Framework\Design\ValueObjects\CssColor;
use App\Framework\Design\ValueObjects\DesignToken;
use App\Framework\Design\ValueObjects\ColorFormat;
use App\Framework\Design\ValueObjects\ColorPalette;
use App\Framework\Design\ValueObjects\ColorVariation;
use App\Framework\Design\ValueObjects\CssColor;
use App\Framework\Design\ValueObjects\TokenRegistry;
use App\Framework\Design\ValueObjects\Tokens\ColorToken;
use App\Framework\Design\ValueObjects\Tokens\SpacingToken;
use App\Framework\Design\ValueObjects\Tokens\TokenInterface;
use App\Framework\Design\ValueObjects\Tokens\TypographyToken;
/**
* Admin Token Registry
*
* Central registry for all admin design tokens.
* Uses framework's DesignToken system for compatibility.
* Uses ColorVariation for organized color management.
*/
final readonly class AdminTokenRegistry
final readonly class AdminTokenRegistry extends TokenRegistry
{
/**
* @var array<string, DesignToken>
*/
private array $tokens;
public function __construct()
protected function initializeTokens(): array
{
$this->tokens = $this->initializeTokens();
$colorPalette = $this->createColorPalette();
$tokens = $colorPalette->getAllTokens('admin');
// Add spacing tokens
$tokens = array_merge($tokens, $this->createSpacingTokens());
// Add typography tokens
$tokens = array_merge($tokens, $this->createTypographyTokens());
return $tokens;
}
/**
* Get token by name
* Create color palette with variations
*/
public function get(string $name): ?DesignToken
private function createColorPalette(): ColorPalette
{
return $this->tokens[$name] ?? null;
}
/**
* Get all tokens
* @return array<string, DesignToken>
*/
public function all(): array
{
return $this->tokens;
}
/**
* Get tokens by category
* @return array<string, DesignToken>
*/
public function byCategory(string $category): array
{
return array_filter(
$this->tokens,
fn(DesignToken $token) => str_starts_with($token->name, $category . '-')
);
}
/**
* Get all color tokens
* @return array<string, DesignToken>
*/
public function colors(): array
{
return array_filter(
$this->tokens,
fn(DesignToken $token) => $token->value instanceof CssColor
);
}
/**
* Get all spacing tokens
* @return array<string, DesignToken>
*/
public function spacing(): array
{
return $this->byCategory('spacing');
}
/**
* Generate CSS custom properties
*/
public function toCss(): string
{
$css = ":root {\n";
foreach ($this->tokens as $token) {
$css .= " " . $token->toCssCustomProperty() . "\n";
}
$css .= "}\n";
return $css;
}
/**
* Generate dark mode CSS overrides
*/
public function toDarkModeCss(): string
{
$css = "@media (prefers-color-scheme: dark) {\n";
$css .= " :root {\n";
foreach ($this->colors() as $token) {
$darkColor = $this->getDarkModeColor($token);
if ($darkColor) {
$css .= " --{$token->name}: {$darkColor->toString()};\n";
}
}
$css .= " }\n";
$css .= "}\n";
return $css;
}
/**
* Initialize all admin design tokens
* @return array<string, DesignToken>
*/
private function initializeTokens(): array
{
return [
return new ColorPalette([
// Background Colors
'admin-bg-primary' => DesignToken::color(
'admin-bg-primary',
new CssColor('oklch(98% 0.01 280)', ColorFormat::OKLCH),
'Primary background color'
new ColorVariation(
name: 'bg-primary',
light: new CssColor('oklch(98% 0.01 280)', ColorFormat::OKLCH),
dark: new CssColor('oklch(20% 0.02 280)', ColorFormat::OKLCH),
description: 'Primary background color'
),
'admin-bg-secondary' => DesignToken::color(
'admin-bg-secondary',
new CssColor('oklch(95% 0.01 280)', ColorFormat::OKLCH),
'Secondary background color'
new ColorVariation(
name: 'bg-secondary',
light: new CssColor('oklch(95% 0.01 280)', ColorFormat::OKLCH),
dark: new CssColor('oklch(23% 0.02 280)', ColorFormat::OKLCH),
description: 'Secondary background color'
),
'admin-bg-tertiary' => DesignToken::color(
'admin-bg-tertiary',
new CssColor('oklch(92% 0.01 280)', ColorFormat::OKLCH),
'Tertiary background color'
new ColorVariation(
name: 'bg-tertiary',
light: new CssColor('oklch(92% 0.01 280)', ColorFormat::OKLCH),
dark: new CssColor('oklch(26% 0.02 280)', ColorFormat::OKLCH),
description: 'Tertiary background color'
),
// Sidebar Colors
'admin-sidebar-bg' => DesignToken::color(
'admin-sidebar-bg',
new CssColor('oklch(25% 0.02 280)', ColorFormat::OKLCH),
'Sidebar background color'
new ColorVariation(
name: 'sidebar-bg',
light: new CssColor('oklch(25% 0.02 280)', ColorFormat::OKLCH),
dark: new CssColor('oklch(15% 0.02 280)', ColorFormat::OKLCH),
description: 'Sidebar background color'
),
'admin-sidebar-text' => DesignToken::color(
'admin-sidebar-text',
new CssColor('oklch(90% 0.01 280)', ColorFormat::OKLCH),
'Sidebar text color'
new ColorVariation(
name: 'sidebar-text',
light: new CssColor('oklch(90% 0.01 280)', ColorFormat::OKLCH),
dark: new CssColor('oklch(75% 0.02 280)', ColorFormat::OKLCH),
description: 'Sidebar text color'
),
'admin-sidebar-text-hover' => DesignToken::color(
'admin-sidebar-text-hover',
new CssColor('oklch(100% 0 0)', ColorFormat::OKLCH),
'Sidebar text hover color'
new ColorVariation(
name: 'sidebar-text-hover',
light: new CssColor('oklch(100% 0 0)', ColorFormat::OKLCH),
dark: new CssColor('oklch(95% 0.01 280)', ColorFormat::OKLCH),
description: 'Sidebar text hover color'
),
'admin-sidebar-active' => DesignToken::color(
'admin-sidebar-active',
new CssColor('oklch(45% 0.15 280)', ColorFormat::OKLCH),
'Sidebar active item color'
new ColorVariation(
name: 'sidebar-active',
light: new CssColor('oklch(45% 0.15 280)', ColorFormat::OKLCH),
dark: new CssColor('oklch(35% 0.2 280)', ColorFormat::OKLCH),
description: 'Sidebar active item color'
),
'admin-sidebar-border' => DesignToken::color(
'admin-sidebar-border',
new CssColor('oklch(30% 0.02 280)', ColorFormat::OKLCH),
'Sidebar border color'
new ColorVariation(
name: 'sidebar-border',
light: new CssColor('oklch(30% 0.02 280)', ColorFormat::OKLCH),
dark: new CssColor('oklch(25% 0.02 280)', ColorFormat::OKLCH),
description: 'Sidebar border color'
),
// Header Colors
'admin-header-bg' => DesignToken::color(
'admin-header-bg',
new CssColor('oklch(100% 0 0)', ColorFormat::OKLCH),
'Header background color'
new ColorVariation(
name: 'header-bg',
light: new CssColor('oklch(100% 0 0)', ColorFormat::OKLCH),
dark: new CssColor('oklch(18% 0.02 280)', ColorFormat::OKLCH),
description: 'Header background color'
),
'admin-header-border' => DesignToken::color(
'admin-header-border',
new CssColor('oklch(85% 0.01 280)', ColorFormat::OKLCH),
'Header border color'
new ColorVariation(
name: 'header-border',
light: new CssColor('oklch(85% 0.01 280)', ColorFormat::OKLCH),
dark: new CssColor('oklch(30% 0.02 280)', ColorFormat::OKLCH),
description: 'Header border color'
),
'admin-header-text' => DesignToken::color(
'admin-header-text',
new CssColor('oklch(20% 0.02 280)', ColorFormat::OKLCH),
'Header text color'
new ColorVariation(
name: 'header-text',
light: new CssColor('oklch(20% 0.02 280)', ColorFormat::OKLCH),
dark: new CssColor('oklch(90% 0.01 280)', ColorFormat::OKLCH),
description: 'Header text color'
),
// Content Colors
'admin-content-bg' => DesignToken::color(
'admin-content-bg',
new CssColor('oklch(100% 0 0)', ColorFormat::OKLCH),
'Content background color'
new ColorVariation(
name: 'content-bg',
light: new CssColor('oklch(100% 0 0)', ColorFormat::OKLCH),
dark: new CssColor('oklch(20% 0.02 280)', ColorFormat::OKLCH),
description: 'Content background color'
),
'admin-content-text' => DesignToken::color(
'admin-content-text',
new CssColor('oklch(20% 0.02 280)', ColorFormat::OKLCH),
'Content text color'
new ColorVariation(
name: 'content-text',
light: new CssColor('oklch(20% 0.02 280)', ColorFormat::OKLCH),
dark: new CssColor('oklch(90% 0.01 280)', ColorFormat::OKLCH),
description: 'Content text color'
),
// Interactive Colors
'admin-link-color' => DesignToken::color(
'admin-link-color',
new CssColor('oklch(55% 0.2 260)', ColorFormat::OKLCH),
'Link color'
new ColorVariation(
name: 'link-color',
light: new CssColor('oklch(55% 0.2 260)', ColorFormat::OKLCH),
dark: new CssColor('oklch(70% 0.2 260)', ColorFormat::OKLCH),
description: 'Link color'
),
'admin-link-hover' => DesignToken::color(
'admin-link-hover',
new CssColor('oklch(45% 0.25 260)', ColorFormat::OKLCH),
'Link hover color'
new ColorVariation(
name: 'link-hover',
light: new CssColor('oklch(45% 0.25 260)', ColorFormat::OKLCH),
dark: new CssColor('oklch(80% 0.22 260)', ColorFormat::OKLCH),
description: 'Link hover color'
),
'admin-link-active' => DesignToken::color(
'admin-link-active',
new CssColor('oklch(35% 0.3 260)', ColorFormat::OKLCH),
'Link active color'
new ColorVariation(
name: 'link-active',
light: new CssColor('oklch(35% 0.3 260)', ColorFormat::OKLCH),
dark: new CssColor('oklch(85% 0.25 260)', ColorFormat::OKLCH),
description: 'Link active color'
),
// Accent Colors
'admin-accent-primary' => DesignToken::color(
'admin-accent-primary',
new CssColor('oklch(60% 0.2 280)', ColorFormat::OKLCH),
'Primary accent color'
new ColorVariation(
name: 'accent-primary',
light: new CssColor('oklch(60% 0.2 280)', ColorFormat::OKLCH),
dark: new CssColor('oklch(65% 0.22 280)', ColorFormat::OKLCH),
description: 'Primary accent color'
),
'admin-accent-success' => DesignToken::color(
'admin-accent-success',
new CssColor('oklch(65% 0.2 145)', ColorFormat::OKLCH),
'Success accent color'
new ColorVariation(
name: 'accent-success',
light: new CssColor('oklch(65% 0.2 145)', ColorFormat::OKLCH),
dark: new CssColor('oklch(70% 0.22 145)', ColorFormat::OKLCH),
description: 'Success accent color'
),
'admin-accent-warning' => DesignToken::color(
'admin-accent-warning',
new CssColor('oklch(70% 0.2 85)', ColorFormat::OKLCH),
'Warning accent color'
new ColorVariation(
name: 'accent-warning',
light: new CssColor('oklch(70% 0.2 85)', ColorFormat::OKLCH),
dark: new CssColor('oklch(75% 0.22 85)', ColorFormat::OKLCH),
description: 'Warning accent color'
),
'admin-accent-error' => DesignToken::color(
'admin-accent-error',
new CssColor('oklch(60% 0.25 25)', ColorFormat::OKLCH),
'Error accent color'
new ColorVariation(
name: 'accent-error',
light: new CssColor('oklch(60% 0.25 25)', ColorFormat::OKLCH),
dark: new CssColor('oklch(65% 0.27 25)', ColorFormat::OKLCH),
description: 'Error accent color'
),
'admin-accent-info' => DesignToken::color(
'admin-accent-info',
new CssColor('oklch(65% 0.2 240)', ColorFormat::OKLCH),
'Info accent color'
new ColorVariation(
name: 'accent-info',
light: new CssColor('oklch(65% 0.2 240)', ColorFormat::OKLCH),
dark: new CssColor('oklch(70% 0.22 240)', ColorFormat::OKLCH),
description: 'Info accent color'
),
// Border Colors
'admin-border-light' => DesignToken::color(
'admin-border-light',
new CssColor('oklch(90% 0.01 280)', ColorFormat::OKLCH),
'Light border color'
new ColorVariation(
name: 'border-light',
light: new CssColor('oklch(90% 0.01 280)', ColorFormat::OKLCH),
dark: new CssColor('oklch(30% 0.02 280)', ColorFormat::OKLCH),
description: 'Light border color'
),
'admin-border-medium' => DesignToken::color(
'admin-border-medium',
new CssColor('oklch(80% 0.02 280)', ColorFormat::OKLCH),
'Medium border color'
new ColorVariation(
name: 'border-medium',
light: new CssColor('oklch(80% 0.02 280)', ColorFormat::OKLCH),
dark: new CssColor('oklch(35% 0.02 280)', ColorFormat::OKLCH),
description: 'Medium border color'
),
'admin-border-dark' => DesignToken::color(
'admin-border-dark',
new CssColor('oklch(70% 0.02 280)', ColorFormat::OKLCH),
'Dark border color'
new ColorVariation(
name: 'border-dark',
light: new CssColor('oklch(70% 0.02 280)', ColorFormat::OKLCH),
dark: new CssColor('oklch(40% 0.02 280)', ColorFormat::OKLCH),
description: 'Dark border color'
),
// Focus/Hover States
'admin-focus-ring' => DesignToken::color(
'admin-focus-ring',
new CssColor('oklch(55% 0.2 260)', ColorFormat::OKLCH),
'Focus ring color'
new ColorVariation(
name: 'focus-ring',
light: new CssColor('oklch(55% 0.2 260)', ColorFormat::OKLCH),
dark: new CssColor('oklch(70% 0.2 260)', ColorFormat::OKLCH),
description: 'Focus ring color'
),
'admin-hover-overlay' => DesignToken::color(
'admin-hover-overlay',
new CssColor('oklch(0% 0 0 / 0.05)', ColorFormat::OKLCH),
'Hover overlay color'
new ColorVariation(
name: 'hover-overlay',
light: new CssColor('oklch(0% 0 0 / 0.05)', ColorFormat::OKLCH),
dark: new CssColor('oklch(100% 0 0 / 0.05)', ColorFormat::OKLCH),
description: 'Hover overlay color'
),
], 'admin');
}
// Spacing Tokens
'spacing-xs' => DesignToken::spacing('spacing-xs', '0.25rem', 'Extra small spacing'),
'spacing-sm' => DesignToken::spacing('spacing-sm', '0.5rem', 'Small spacing'),
'spacing-md' => DesignToken::spacing('spacing-md', '1rem', 'Medium spacing'),
'spacing-lg' => DesignToken::spacing('spacing-lg', '1.5rem', 'Large spacing'),
'spacing-xl' => DesignToken::spacing('spacing-xl', '2rem', 'Extra large spacing'),
'spacing-2xl' => DesignToken::spacing('spacing-2xl', '3rem', 'Double extra large spacing'),
/**
* Create spacing tokens
* @return array<string, TokenInterface>
*/
private function createSpacingTokens(): array
{
return [
'admin-spacing-xs' => new SpacingToken('admin-spacing-xs', '0.25rem', 'Extra small spacing'),
'admin-spacing-sm' => new SpacingToken('admin-spacing-sm', '0.5rem', 'Small spacing'),
'admin-spacing-md' => new SpacingToken('admin-spacing-md', '1rem', 'Medium spacing'),
'admin-spacing-lg' => new SpacingToken('admin-spacing-lg', '1.5rem', 'Large spacing'),
'admin-spacing-xl' => new SpacingToken('admin-spacing-xl', '2rem', 'Extra large spacing'),
'admin-spacing-2xl' => new SpacingToken('admin-spacing-2xl', '3rem', 'Double extra large spacing'),
// Layout Spacing
'spacing-sidebar' => DesignToken::spacing('spacing-sidebar', '250px', 'Sidebar width'),
'spacing-sidebar-wide' => DesignToken::spacing('spacing-sidebar-wide', '280px', 'Sidebar width on wide screens'),
'spacing-header' => DesignToken::spacing('spacing-header', '4rem', 'Header height'),
'spacing-content-padding' => DesignToken::spacing('spacing-content-padding', '2rem', 'Content padding'),
'spacing-content-max-width' => DesignToken::spacing('spacing-content-max-width', '1400px', 'Content maximum width'),
// Typography Tokens
'font-size-xs' => DesignToken::typography('font-size-xs', '0.75rem', 'Extra small font size'),
'font-size-sm' => DesignToken::typography('font-size-sm', '0.875rem', 'Small font size'),
'font-size-base' => DesignToken::typography('font-size-base', '1rem', 'Base font size'),
'font-size-lg' => DesignToken::typography('font-size-lg', '1.125rem', 'Large font size'),
'font-size-xl' => DesignToken::typography('font-size-xl', '1.25rem', 'Extra large font size'),
'font-size-2xl' => DesignToken::typography('font-size-2xl', '1.5rem', 'Double extra large font size'),
'font-size-3xl' => DesignToken::typography('font-size-3xl', '1.875rem', 'Triple extra large font size'),
'admin-spacing-sidebar' => new SpacingToken('admin-spacing-sidebar', '250px', 'Sidebar width'),
'admin-spacing-sidebar-wide' => new SpacingToken('admin-spacing-sidebar-wide', '280px', 'Sidebar width on wide screens'),
'admin-spacing-header' => new SpacingToken('admin-spacing-header', '4rem', 'Header height'),
'admin-spacing-content-padding' => new SpacingToken('admin-spacing-content-padding', '2rem', 'Content padding'),
'admin-spacing-content-max-width' => new SpacingToken('admin-spacing-content-max-width', '1400px', 'Content maximum width'),
];
}
/**
* Get dark mode variant of a color
* Create typography tokens
* @return array<string, TokenInterface>
*/
private function getDarkModeColor(DesignToken $token): ?CssColor
private function createTypographyTokens(): array
{
if (!($token->value instanceof CssColor)) {
return null;
}
// Dark mode color mappings
$darkModeMap = [
// Backgrounds
'admin-bg-primary' => 'oklch(20% 0.02 280)',
'admin-bg-secondary' => 'oklch(23% 0.02 280)',
'admin-bg-tertiary' => 'oklch(26% 0.02 280)',
// Sidebar
'admin-sidebar-bg' => 'oklch(15% 0.02 280)',
'admin-sidebar-text' => 'oklch(75% 0.02 280)',
'admin-sidebar-text-hover' => 'oklch(95% 0.01 280)',
'admin-sidebar-active' => 'oklch(35% 0.2 280)',
'admin-sidebar-border' => 'oklch(25% 0.02 280)',
// Header
'admin-header-bg' => 'oklch(18% 0.02 280)',
'admin-header-border' => 'oklch(30% 0.02 280)',
'admin-header-text' => 'oklch(90% 0.01 280)',
// Content
'admin-content-bg' => 'oklch(20% 0.02 280)',
'admin-content-text' => 'oklch(90% 0.01 280)',
// Interactive
'admin-link-color' => 'oklch(70% 0.2 260)',
'admin-link-hover' => 'oklch(80% 0.22 260)',
'admin-link-active' => 'oklch(85% 0.25 260)',
// Borders
'admin-border-light' => 'oklch(30% 0.02 280)',
'admin-border-medium' => 'oklch(35% 0.02 280)',
'admin-border-dark' => 'oklch(40% 0.02 280)',
// Focus
'admin-focus-ring' => 'oklch(70% 0.2 260)',
'admin-hover-overlay' => 'oklch(100% 0 0 / 0.05)',
return [
'admin-font-size-xs' => new TypographyToken('admin-font-size-xs', '0.75rem', 'Extra small font size'),
'admin-font-size-sm' => new TypographyToken('admin-font-size-sm', '0.875rem', 'Small font size'),
'admin-font-size-base' => new TypographyToken('admin-font-size-base', '1rem', 'Base font size'),
'admin-font-size-lg' => new TypographyToken('admin-font-size-lg', '1.125rem', 'Large font size'),
'admin-font-size-xl' => new TypographyToken('admin-font-size-xl', '1.25rem', 'Extra large font size'),
'admin-font-size-2xl' => new TypographyToken('admin-font-size-2xl', '1.5rem', 'Double extra large font size'),
'admin-font-size-3xl' => new TypographyToken('admin-font-size-3xl', '1.875rem', 'Triple extra large font size'),
];
}
if (isset($darkModeMap[$token->name])) {
return new CssColor($darkModeMap[$token->name], ColorFormat::OKLCH);
}
protected function getLayerName(): string
{
return 'admin-settings';
}
return null;
/**
* Generate CSS custom properties (admin-specific layer)
*/
public function toCss(string $layerName = 'admin-settings', bool $includeHdr = true): string
{
return parent::toCss($layerName, $includeHdr);
}
/**
* Generate dark mode CSS overrides (deprecated - use toCss() which includes dark mode)
*/
public function toDarkModeCss(string $layerName = 'admin-settings'): string
{
return parent::toDarkModeCss($layerName);
}
}

View File

@@ -4,22 +4,40 @@ declare(strict_types=1);
namespace App\Application\Admin\ValueObjects;
use App\Framework\Icon\Icon;
final readonly class NavigationItem
{
public function __construct(
public string $name,
public string $url,
public ?string $icon = null,
public Icon|string|null $icon = null,
public bool $isActive = false
) {
}
/**
* Get icon as Icon object
*/
public function getIcon(): ?Icon
{
if ($this->icon === null) {
return null;
}
if ($this->icon instanceof Icon) {
return $this->icon;
}
return Icon::fromString($this->icon);
}
public static function fromArray(array $data): self
{
return new self(
name: $data['name'] ?? '',
url: $data['url'] ?? '',
icon: $data['icon'] ?? null,
icon: $data['icon'] ?? null, // Can be string or Icon
isActive: $data['is_active'] ?? false
);
}
@@ -29,7 +47,7 @@ final readonly class NavigationItem
return [
'name' => $this->name,
'url' => $this->url,
'icon' => $this->icon,
'icon' => $this->icon instanceof Icon ? $this->icon->toString() : $this->icon,
'is_active' => $this->isActive,
];
}

View File

@@ -24,7 +24,7 @@ final readonly class NavigationSection
return new self(
name: $data['section'] ?? $data['name'] ?? '',
items: $items,
icon: $data['icon'] ?? null
icon: $data['icon'] ?? null // Can be string or Icon
);
}
@@ -37,7 +37,7 @@ final readonly class NavigationSection
fn (NavigationItem $item) => $item->toArray(),
$this->items
),
'icon' => $this->icon,
'icon' => $this->icon instanceof Icon ? $this->icon->toString() : $this->icon,
];
}

View File

@@ -2,16 +2,14 @@
<div class="admin-page">
<div class="page-header">
<h1>{{ title }}</h1>
<if condition="{{ subtitle }}">
<p class="subtitle">{{ subtitle }}</p>
</if>
<h1>{{$title}}</h1>
<p class="subtitle" if="{{$subtitle}}">{{$subtitle}}</p>
</div>
<div class="form-container">
<div class="card">
<div class="card-body">
{{ form }}
{{$form}}
</div>
</div>
</div>

View File

@@ -2,34 +2,26 @@
<div class="admin-page">
<div class="page-header">
<h1>{{ title }}</h1>
<h1>{{$title}}</h1>
<div class="page-actions">
<if condition="{{ actions }}">
<for items="{{ actions }}" value="action">
<a href="{{ action.url }}" class="btn btn-primary">
<if condition="{{ action.icon }}">
<i class="icon-{{ action.icon }}"></i>
</if>
{{ action.label }}
</a>
</for>
</if>
<a href="{{$action['url']}}" class="btn btn-primary" foreach="$actions as $action">
<i class="icon-{{$action['icon']}}" if="{{$action['icon']}}"></i>
{{$action['label']}}
</a>
</div>
</div>
<if condition="{{ searchable }}">
<div class="table-controls">
<input type="text"
class="form-control search-input"
data-table-search="{{ resource }}"
placeholder="Search...">
</div>
</if>
<div class="table-controls" if="{{$searchable}}">
<input type="text"
class="form-control search-input"
data-table-search="{{$resource}}"
placeholder="Search...">
</div>
<div class="table-container">
{{ table }}
{{$table}}
</div>
<div class="pagination-container" data-table-pagination="{{ resource }}"></div>
<div class="pagination-container" data-table-pagination="{{$resource}}"></div>
</div>

View File

@@ -1,11 +1,11 @@
<!doctype html>
<html lang="de" data-theme="{theme ?? 'auto'}">
<html lang="de" data-theme="{{ $theme ?? 'auto' }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>{title} | Admin Panel</title>
<meta name="description" content="{description}">
<title>{{ $title }} | Admin Panel</title>
<meta name="description" content="{{ $description }}">
<meta property="og:type" content="website">
<!-- Theme Meta -->
@@ -27,14 +27,14 @@
<!-- Admin Layout Grid -->
<div class="admin-layout">
<!-- Sidebar Navigation -->
<x-admin-sidebar currentPath="{current_path}" />
<x-admin-sidebar currentPath="{{ $current_path }}" navigation-menu='{{ json_encode($navigation_menu) }}' />
<!-- Header with Search & User Menu -->
<x-admin-header pageTitle="{page_title}" />
<x-admin-header pageTitle="{{ $page_title }}" />
<!-- Main Content Area -->
<main class="admin-content" id="main-content" role="main">
{content}
{{ $content }}
</main>
</div>

View File

@@ -5,31 +5,30 @@
<div class="stats-grid">
<div class="stat-card">
<h3>Page Views</h3>
<p><strong>Today:</strong> {{ today_page_views }}</p>
<p><strong>This Week:</strong> {{ week_page_views }}</p>
<p><strong>This Month:</strong> {{ month_page_views }}</p>
<p><strong>Today:</strong> {{$today_page_views}}</p>
<p><strong>This Week:</strong> {{$week_page_views}}</p>
<p><strong>This Month:</strong> {{$month_page_views}}</p>
</div>
<div class="stat-card">
<h3>Unique Visitors</h3>
<p><strong>Today:</strong> {{ today_visitors }}</p>
<p><strong>This Week:</strong> {{ week_visitors }}</p>
<p><strong>This Month:</strong> {{ month_visitors }}</p>
<p><strong>Today:</strong> {{$today_visitors}}</p>
<p><strong>This Week:</strong> {{$week_visitors}}</p>
<p><strong>This Month:</strong> {{$month_visitors}}</p>
</div>
<div class="stat-card">
<h3>Performance</h3>
<p><strong>Avg. Load Time:</strong> {{ avg_load_time }}</p>
<p><strong>Bounce Rate:</strong> {{ bounce_rate }}</p>
<p><strong>Session Duration:</strong> {{ avg_session_duration }}</p>
<p><strong>Avg. Load Time:</strong> {{$avg_load_time}}</p>
<p><strong>Bounce Rate:</strong> {{$bounce_rate}}</p>
<p><strong>Session Duration:</strong> {{$avg_session_duration}}</p>
</div>
</div>
<div class="stats-grid">
<div class="stat-card full-width">
<h3>Top Pages</h3>
<if condition="{{ top_pages }}">
<table>
<table if="{{$top_pages}}">
<thead>
<tr>
<th>Page</th>
@@ -39,44 +38,37 @@
</tr>
</thead>
<tbody>
<for items="{{ top_pages }}" value="page">
<tr>
<td>{{ page.path }}</td>
<td>{{ page.views }}</td>
<td>{{ page.unique_visitors }}</td>
<td>{{ page.avg_time }}</td>
<tr foreach="$top_pages as $page">
<td>{{$page['path']}}</td>
<td>{{$page['views']}}</td>
<td>{{$page['unique_visitors']}}</td>
<td>{{$page['avg_time']}}</td>
</tr>
</for>
</tbody>
</table>
<else>
<p>No analytics data available yet.</p>
<p>The analytics system will start collecting data once visitors start using your website.</p>
</if>
<div if="{{!$top_pages}}">
<p>No analytics data available yet.</p>
<p>The analytics system will start collecting data once visitors start using your website.</p>
</div>
</div>
</div>
<div class="stats-grid">
<div class="stat-card">
<h3>Traffic Sources</h3>
<if condition="{{ traffic_sources }}">
<ul style="list-style: none; padding: 0;">
<for items="{{ traffic_sources }}" key="source" value="percentage">
<li style="margin: 8px 0;">
<strong>{{ source }}:</strong> {{ percentage }}%
<ul style="list-style: none; padding: 0;" if="{{$traffic_sources}}">
<li style="margin: 8px 0;" foreach="$traffic_sources as $source => $percentage">
<strong>{{$source}}:</strong> {{$percentage}}%
</li>
</for>
</ul>
<else>
<p>No traffic source data available.</p>
</if>
<p if="{{!$traffic_sources}}">No traffic source data available.</p>
</div>
<div class="stat-card">
<h3>System Status</h3>
<p><strong>Analytics Status:</strong> <span style="color: var(--success);">Active</span></p>
<p><strong>Data Collection:</strong> <span style="color: var(--success);">Running</span></p>
<p><strong>Last Update:</strong> {{ last_update }}</p>
<p><strong>Last Update:</strong> {{$last_update}}</p>
</div>
</div>
</div>

View File

@@ -7,31 +7,31 @@
<!-- Cache Overview Cards -->
<div class="admin-grid admin-grid--4col">
<div class="admin-card metric-card">
<div class="metric-card__value">{{ hit_rate }}%</div>
<div class="metric-card__value">{{$hit_rate}}%</div>
<div class="metric-card__label">Hit Rate</div>
<div class="metric-card__change metric-card__change--{{ hit_rate >= 80 ? 'positive' : 'negative' }}">
{{ hit_rate >= 80 ? 'Excellent' : (hit_rate >= 60 ? 'Good' : 'Needs Improvement') }}
<div class="metric-card__change metric-card__change--{{$hit_rate >= 80 ? 'positive' : 'negative'}}">
{{$hit_rate >= 80 ? 'Excellent' : ($hit_rate >= 60 ? 'Good' : 'Needs Improvement')}}
</div>
</div>
<div class="admin-card metric-card">
<div class="metric-card__value">{{ total_operations }}</div>
<div class="metric-card__value">{{$total_operations}}</div>
<div class="metric-card__label">Total Operations</div>
<div class="metric-card__change">Since startup</div>
</div>
<div class="admin-card metric-card">
<div class="metric-card__value">{{ avg_latency_ms }}ms</div>
<div class="metric-card__value">{{$avg_latency_ms}}ms</div>
<div class="metric-card__label">Average Latency</div>
<div class="metric-card__change metric-card__change--{{ avg_latency_ms <= 5 ? 'positive' : 'negative' }}">
{{ avg_latency_ms <= 5 ? 'Fast' : 'Slow' }}
<div class="metric-card__change metric-card__change--{{$avg_latency_ms <= 5 ? 'positive' : 'negative'}}">
{{$avg_latency_ms <= 5 ? 'Fast' : 'Slow'}}
</div>
</div>
<div class="admin-card metric-card">
<div class="metric-card__value">{{ total_size_mb }}MB</div>
<div class="metric-card__value">{{$total_size_mb}}MB</div>
<div class="metric-card__label">Total Cache Size</div>
<div class="metric-card__change">{{ active_drivers }} active drivers</div>
<div class="metric-card__change">{{$active_drivers}} active drivers</div>
</div>
</div>
@@ -39,53 +39,47 @@
<div class="admin-card">
<div class="admin-card__header">
<h3 class="admin-card__title">Cache Health Status</h3>
<span class="admin-table__status admin-table__status--{{ health_status === 'healthy' ? 'success' : (health_status === 'warning' ? 'warning' : 'error') }}">
<span class="status-indicator status-indicator--{{ health_status === 'healthy' ? 'success' : (health_status === 'warning' ? 'warning' : 'error') }}"></span>
{{ health_status|upper }}
<span class="admin-table__status admin-table__status--{{$health_status === 'healthy' ? 'success' : ($health_status === 'warning' ? 'warning' : 'error')}}">
<span class="status-indicator status-indicator--{{$health_status === 'healthy' ? 'success' : ($health_status === 'warning' ? 'warning' : 'error')}}"></span>
{{strtoupper($health_status)}}
</span>
</div>
<div class="admin-card__content">
<p>Cache system efficiency rating: <strong>{{ efficiency_rating }}</strong></p>
<if condition="{{ recommendations_count > 0 }}">
<p class="text-warning">{{ recommendations_count }} recommendations available for optimization.</p>
</if>
<p>Cache system efficiency rating: <strong>{{$efficiency_rating}}</strong></p>
<p class="text-warning" if="{{$recommendations_count > 0}}">{{$recommendations_count}} recommendations available for optimization.</p>
</div>
</div>
<!-- Driver Statistics -->
<if condition="{{ active_drivers > 0 }}">
<div class="admin-card">
<div class="admin-card__header">
<h3 class="admin-card__title">Driver Statistics</h3>
</div>
<div class="admin-card__content">
<div class="admin-table-wrapper">
<table class="admin-table">
<thead>
<tr>
<th>Driver</th>
<th>Hit Rate</th>
<th>Operations</th>
<th>Avg Latency</th>
<th>Size (MB)</th>
</tr>
</thead>
<tbody>
<for items="{{ driver_stats }}" key="driver" value="stats">
<tr>
<td class="font-mono">{{ driver }}</td>
<td>{{ stats.hit_rate }}%</td>
<td>{{ stats.operations }}</td>
<td>{{ stats.avg_latency }}ms</td>
<td>{{ stats.size }}MB</td>
</tr>
</for>
</tbody>
</table>
</div>
<div class="admin-card" if="{{$active_drivers > 0}}">
<div class="admin-card__header">
<h3 class="admin-card__title">Driver Statistics</h3>
</div>
<div class="admin-card__content">
<div class="admin-table-wrapper">
<table class="admin-table">
<thead>
<tr>
<th>Driver</th>
<th>Hit Rate</th>
<th>Operations</th>
<th>Avg Latency</th>
<th>Size (MB)</th>
</tr>
</thead>
<tbody>
<tr foreach="$driver_stats as $driver => $stats">
<td class="font-mono">{{$driver}}</td>
<td>{{$stats['hit_rate']}}%</td>
<td>{{$stats['operations']}}</td>
<td>{{$stats['avg_latency']}}ms</td>
<td>{{$stats['size']}}MB</td>
</tr>
</tbody>
</table>
</div>
</div>
</if>
</div>
<!-- Real-time Metrics (JavaScript will update these) -->
<div class="admin-card">

View File

@@ -13,30 +13,21 @@
</li>
<!-- LinkCollection Items -->
<if condition="breadcrumbs && breadcrumbs.count() > 0">
<for items="breadcrumbs" as="link" index="index">
<!-- Separator -->
<li class="admin-breadcrumbs__separator" aria-hidden="true">
<svg class="admin-breadcrumbs__separator-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</li>
<li class="admin-breadcrumbs__separator" aria-hidden="true" foreach="$breadcrumbs as $link">
<svg class="admin-breadcrumbs__separator-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</li>
<!-- Breadcrumb Item -->
<li class="admin-breadcrumbs__item">
<!-- Check if current page (AccessibleLink with aria-current) -->
<if condition="link.hasAttribute('aria-current')">
<span class="admin-breadcrumbs__current" aria-current="page">
{link.text}
</span>
<else>
<a href="{link.href}" class="admin-breadcrumbs__link">
{link.text}
</a>
</else>
</if>
</li>
</for>
</if>
<!-- Breadcrumb Item -->
<li class="admin-breadcrumbs__item" foreach="$breadcrumbs as $link">
<!-- Check if current page (AccessibleLink with aria-current) -->
<span class="admin-breadcrumbs__current" aria-current="page" if="{{$link->hasAttribute('aria-current')}}">
{{$link->text}}
</span>
<a href="{{$link->href}}" class="admin-breadcrumbs__link" if="{{!$link->hasAttribute('aria-current')}}">
{{$link->text}}
</a>
</li>
</ol>
</nav>

View File

@@ -15,7 +15,7 @@
<div class="admin-nav__section">
<ul class="admin-nav__list" role="list">
<li class="admin-nav__item">
<a href="/admin" class="admin-nav__link" aria-current="{current_path === '/admin' ? 'page' : null}">
<a href="/admin" class="admin-nav__link" aria-current="{{$current_path === '/admin' ? 'page' : null}}">
<svg class="admin-nav__icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<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"/>
</svg>
@@ -30,7 +30,7 @@
<h2 class="admin-nav__section-title">Content</h2>
<ul class="admin-nav__list" role="list">
<li class="admin-nav__item">
<a href="/admin/pages" class="admin-nav__link" aria-current="{current_path === '/admin/pages' ? 'page' : null}">
<a href="/admin/pages" class="admin-nav__link" aria-current="{{$current_path === '/admin/pages' ? 'page' : null}}">
<svg class="admin-nav__icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<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"/>
</svg>
@@ -38,7 +38,7 @@
</a>
</li>
<li class="admin-nav__item">
<a href="/admin/media" class="admin-nav__link" aria-current="{current_path === '/admin/media' ? 'page' : null}">
<a href="/admin/media" class="admin-nav__link" aria-current="{{$current_path === '/admin/media' ? 'page' : null}}">
<svg class="admin-nav__icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<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"/>
</svg>
@@ -53,7 +53,7 @@
<h2 class="admin-nav__section-title">System</h2>
<ul class="admin-nav__list" role="list">
<li class="admin-nav__item">
<a href="/admin/users" class="admin-nav__link" aria-current="{current_path === '/admin/users' ? 'page' : null}">
<a href="/admin/users" class="admin-nav__link" aria-current="{{$current_path === '/admin/users' ? 'page' : null}}">
<svg class="admin-nav__icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/>
</svg>
@@ -61,7 +61,7 @@
</a>
</li>
<li class="admin-nav__item">
<a href="/admin/settings" class="admin-nav__link" aria-current="{current_path === '/admin/settings' ? 'page' : null}">
<a href="/admin/settings" class="admin-nav__link" aria-current="{{$current_path === '/admin/settings' ? 'page' : null}}">
<svg class="admin-nav__icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
@@ -77,15 +77,15 @@
<div class="admin-sidebar__footer">
<a href="/admin/profile" class="admin-sidebar__user">
<img
src="{user.avatar ?? '/assets/default-avatar.png'}"
alt="{user.name}"
src="{{$user['avatar'] ?? '/assets/default-avatar.png'}}"
alt="{{$user['name']}}"
class="admin-sidebar__avatar"
width="32"
height="32"
/>
<div class="admin-sidebar__user-info">
<span class="admin-sidebar__user-name">{user.name}</span>
<span class="admin-sidebar__user-role">{user.role}</span>
<span class="admin-sidebar__user-name">{{$user['name']}}</span>
<span class="admin-sidebar__user-role">{{$user['role']}}</span>
</div>
</a>
</div>

View File

@@ -25,11 +25,11 @@
</div>
<div class="admin-stat-list__item">
<span class="admin-stat-list__label">Uptime</span>
<span class="admin-stat-list__value">{{ uptime_formatted }}</span>
<span class="admin-stat-list__value">{{$uptime_formatted}}</span>
</div>
<div class="admin-stat-list__item">
<span class="admin-stat-list__label">Version</span>
<span class="admin-stat-list__value">{{ $framework_version }}</span>
<span class="admin-stat-list__value">{{$framework_version}}</span>
</div>
</div>
</div>
@@ -43,15 +43,15 @@
<div class="admin-stat-list">
<div class="admin-stat-list__item">
<span class="admin-stat-list__label">Memory Usage</span>
<span class="admin-stat-list__value">{{ memory_usage_formatted }}</span>
<span class="admin-stat-list__value">{{$memory_usage_formatted}}</span>
</div>
<div class="admin-stat-list__item">
<span class="admin-stat-list__label">Peak Memory</span>
<span class="admin-stat-list__value">{{ peak_memory_formatted }}</span>
<span class="admin-stat-list__value">{{$peak_memory_formatted}}</span>
</div>
<div class="admin-stat-list__item">
<span class="admin-stat-list__label">Load Average</span>
<span class="admin-stat-list__value">{{ load_average }}</span>
<span class="admin-stat-list__value">{{$load_average}}</span>
</div>
</div>
</div>
@@ -69,11 +69,11 @@
</div>
<div class="admin-stat-list__item">
<span class="admin-stat-list__label">Pool Size</span>
<span class="admin-stat-list__value">{{ db_pool_size }}</span>
<span class="admin-stat-list__value">{{$db_pool_size}}</span>
</div>
<div class="admin-stat-list__item">
<span class="admin-stat-list__label">Active Connections</span>
<span class="admin-stat-list__value">{{ db_active_connections }}</span>
<span class="admin-stat-list__value">{{$db_active_connections}}</span>
</div>
</div>
</div>
@@ -90,11 +90,11 @@
<div class="admin-stat-list">
<div class="admin-stat-list__item">
<span class="admin-stat-list__label">Hit Rate</span>
<span class="admin-stat-list__value">{{ cache_hit_rate }}</span>
<span class="admin-stat-list__value">{{$cache_hit_rate}}</span>
</div>
<div class="admin-stat-list__item">
<span class="admin-stat-list__label">Total Operations</span>
<span class="admin-stat-list__value">{{ cache_total_operations }}</span>
<span class="admin-stat-list__value">{{$cache_total_operations}}</span>
</div>
<div class="admin-stat-list__item">
<span class="admin-stat-list__label">Status</span>
@@ -112,15 +112,15 @@
<div class="admin-stat-list">
<div class="admin-stat-list__item">
<span class="admin-stat-list__label">Requests Today</span>
<span class="admin-stat-list__value">{{ requests_today }}</span>
<span class="admin-stat-list__value">{{$requests_today}}</span>
</div>
<div class="admin-stat-list__item">
<span class="admin-stat-list__label">Errors</span>
<span class="admin-stat-list__value">{{ errors_today }}</span>
<span class="admin-stat-list__value">{{$errors_today}}</span>
</div>
<div class="admin-stat-list__item">
<span class="admin-stat-list__label">Last Deployment</span>
<span class="admin-stat-list__value">{{ last_deployment }}</span>
<span class="admin-stat-list__value">{{$last_deployment}}</span>
</div>
</div>
</div>
@@ -132,9 +132,9 @@
</div>
<div class="admin-card__content">
<div class="admin-cluster">
<x-a href="{{ clear_cache_url }}" class="admin-btn admin-btn--primary">Clear Cache</x-a>
<x-a href="{{ logs_url }}" class="admin-btn admin-btn--secondary">View Logs</x-a>
<x-a href="{{ migrations_url }}" class="admin-btn admin-btn--accent">Migrations</x-a>
<x-a href="{{$clear_cache_url}}" class="admin-btn admin-btn--primary">Clear Cache</x-a>
<x-a href="{{$logs_url}}" class="admin-btn admin-btn--secondary">View Logs</x-a>
<x-a href="{{$migrations_url}}" class="admin-btn admin-btn--accent">Migrations</x-a>
</div>
</div>
</div>

View File

@@ -0,0 +1,33 @@
<layout name="admin" />
<div class="admin-page">
<div class="page-header">
<h1>{{ $title }}</h1>
</div>
<div class="stats-grid">
<div class="stat-card">
<h3>Database Information</h3>
<p><strong>Name:</strong> {{$database['name']}}</p>
<p if="{{$database['charset']}}"><strong>Charset:</strong> {{$database['charset']}}</p>
<p if="{{$database['collation']}}"><strong>Collation:</strong> {{$database['collation']}}</p>
<p><strong>Total Tables:</strong> {{$table_count}}</p>
<p if="{{$database['size_mb']}}"><strong>Total Size:</strong> {{number_format($database['size_mb'], 2)}} MB</p>
</div>
</div>
<div class="admin-card">
<h2>Tables</h2>
<div class="table-container">
{{$table}}
</div>
<div class="pagination-info" if="{{$pagination}}">
<p>
Showing page {{$pagination['current_page']}} of {{$pagination['total_pages']}}
({{$pagination['total_items']}} total tables)
</p>
</div>
</div>
</div>

View File

@@ -0,0 +1,49 @@
<layout name="admin" />
<div class="admin-page">
<div class="page-header">
<h1>{{ $title }}</h1>
<div class="page-actions">
<a href="/admin/database" class="btn btn-secondary">Back to Database</a>
</div>
</div>
<div class="alert alert-error" if="{{$error}}">
{{$error}}
</div>
<div if="{{$table}}">
<div class="stats-grid">
<div class="stat-card">
<h3>Table Information</h3>
<p><strong>Name:</strong> {{$table['name']}}</p>
<p if="{{$table['row_count']}}"><strong>Rows:</strong> {{number_format($table['row_count'])}}</p>
<p if="{{$table['size_mb']}}"><strong>Size:</strong> {{number_format($table['size_mb'], 2)}} MB</p>
<p if="{{$table['engine']}}"><strong>Engine:</strong> {{$table['engine']}}</p>
<p if="{{$table['collation']}}"><strong>Collation:</strong> {{$table['collation']}}</p>
</div>
</div>
<div class="admin-card">
<h2>Columns</h2>
<div class="table-container">
{{$columns_table}}
</div>
</div>
<div class="admin-card" if="{{$has_indexes}}">
<h2>Indexes</h2>
<div class="table-container">
{{$indexes_table}}
</div>
</div>
<div class="admin-card" if="{{$has_foreign_keys}}">
<h2>Foreign Keys</h2>
<div class="table-container">
{{$foreign_keys_table}}
</div>
</div>
</div>
</div>

View File

@@ -19,7 +19,7 @@
</div>
<div class="admin-card__content">
<div class="admin-stat-big">
<span class="admin-stat-big__value">{{ $totalDeployments }}</span>
<span class="admin-stat-big__value">{{$totalDeployments}}</span>
<span class="admin-stat-big__label">All Time</span>
</div>
</div>
@@ -31,7 +31,7 @@
</div>
<div class="admin-card__content">
<div class="admin-stat-big">
<span class="admin-stat-big__value">{{ $successRate }}%</span>
<span class="admin-stat-big__value">{{$successRate}}%</span>
<span class="admin-stat-big__label">Success</span>
</div>
</div>
@@ -43,7 +43,7 @@
</div>
<div class="admin-card__content">
<div class="admin-stat-big">
<span class="admin-stat-big__value">{{ $failedDeployments }}</span>
<span class="admin-stat-big__value">{{$failedDeployments}}</span>
<span class="admin-stat-big__label">Failures</span>
</div>
</div>
@@ -55,7 +55,7 @@
</div>
<div class="admin-card__content">
<div class="admin-stat-big">
<span class="admin-stat-big__value">{{ $averageDurationFormatted }}</span>
<span class="admin-stat-big__value">{{$averageDurationFormatted}}</span>
<span class="admin-stat-big__label">Avg Time</span>
</div>
</div>
@@ -72,19 +72,19 @@
<div class="admin-stat-list">
<div class="admin-stat-list__item">
<span class="admin-stat-list__label">Total Deployments</span>
<span class="admin-stat-list__value">{{ $productionStats['total_deployments'] }}</span>
<span class="admin-stat-list__value">{{$productionStats['total_deployments']}}</span>
</div>
<div class="admin-stat-list__item">
<span class="admin-stat-list__label">Success Rate</span>
<span class="admin-stat-list__value">{{ $productionStats['success_rate'] }}%</span>
<span class="admin-stat-list__value">{{$productionStats['success_rate']}}%</span>
</div>
<div class="admin-stat-list__item">
<span class="admin-stat-list__label">Failed</span>
<span class="admin-stat-list__value">{{ $productionStats['failed'] }}</span>
<span class="admin-stat-list__value">{{$productionStats['failed']}}</span>
</div>
<div class="admin-stat-list__item">
<span class="admin-stat-list__label">Rolled Back</span>
<span class="admin-stat-list__value">{{ $productionStats['rolled_back'] }}</span>
<span class="admin-stat-list__value">{{$productionStats['rolled_back']}}</span>
</div>
</div>
</div>
@@ -98,19 +98,19 @@
<div class="admin-stat-list">
<div class="admin-stat-list__item">
<span class="admin-stat-list__label">Total Deployments</span>
<span class="admin-stat-list__value">{{ $stagingStats['total_deployments'] }}</span>
<span class="admin-stat-list__value">{{$stagingStats['total_deployments']}}</span>
</div>
<div class="admin-stat-list__item">
<span class="admin-stat-list__label">Success Rate</span>
<span class="admin-stat-list__value">{{ $stagingStats['success_rate'] }}%</span>
<span class="admin-stat-list__value">{{$stagingStats['success_rate']}}%</span>
</div>
<div class="admin-stat-list__item">
<span class="admin-stat-list__label">Failed</span>
<span class="admin-stat-list__value">{{ $stagingStats['failed'] }}</span>
<span class="admin-stat-list__value">{{$stagingStats['failed']}}</span>
</div>
<div class="admin-stat-list__item">
<span class="admin-stat-list__label">Rolled Back</span>
<span class="admin-stat-list__value">{{ $stagingStats['rolled_back'] }}</span>
<span class="admin-stat-list__value">{{$stagingStats['rolled_back']}}</span>
</div>
</div>
</div>
@@ -123,7 +123,7 @@
<h3 class="admin-card__title">Recent Deployments</h3>
</div>
<div class="admin-card__content">
<div if="count($recentDeployments) > 0">
<div if="{{count($recentDeployments) > 0}}">
<table class="admin-table">
<thead>
<tr>
@@ -138,31 +138,29 @@
</tr>
</thead>
<tbody>
<for items="recentDeployments" as="deployment">
<tr>
<td><code>{{ $deployment['pipelineId'] }}</code></td>
<td>
<span class="admin-badge admin-badge--neutral">
{{ $deployment['environment'] }}
</span>
</td>
<td>
<span class="admin-badge {{ $deployment['statusBadgeClass'] }}">
{{ $deployment['status'] }}
</span>
<span if="$deployment['wasRolledBack']" class="admin-badge admin-badge--warning">Rolled Back</span>
</td>
<td>{{ $deployment['durationFormatted'] }}</td>
<td>{{ $deployment['stageCount'] }}</td>
<td>{{ $deployment['successRate'] }}%</td>
<td>{{ $deployment['startedAt'] }}</td>
<td>{{ $deployment['completedAt'] }}</td>
</tr>
</for>
<tr foreach="$recentDeployments as $deployment">
<td><code>{{$deployment['pipelineId']}}</code></td>
<td>
<span class="admin-badge admin-badge--neutral">
{{$deployment['environment']}}
</span>
</td>
<td>
<span class="admin-badge {{$deployment['statusBadgeClass']}}">
{{$deployment['status']}}
</span>
<span if="{{$deployment['wasRolledBack']}}" class="admin-badge admin-badge--warning">Rolled Back</span>
</td>
<td>{{$deployment['durationFormatted']}}</td>
<td>{{$deployment['stageCount']}}</td>
<td>{{$deployment['successRate']}}%</td>
<td>{{$deployment['startedAt']}}</td>
<td>{{$deployment['completedAt']}}</td>
</tr>
</tbody>
</table>
</div>
<div if="count($recentDeployments) === 0">
<div if="{{count($recentDeployments) === 0}}">
<div class="admin-empty-state">
<p class="admin-empty-state__text">No deployments found</p>
</div>
@@ -171,7 +169,7 @@
</div>
<!-- Failed Deployments Section -->
<div if="count($failedDeployments) > 0" class="admin-card" style="margin-top: var(--admin-spacing-xl);">
<div class="admin-card" style="margin-top: var(--admin-spacing-xl);" if="{{count($failedDeployments) > 0}}">
<div class="admin-card__header">
<h3 class="admin-card__title">Recent Failed Deployments</h3>
</div>
@@ -188,30 +186,28 @@
</tr>
</thead>
<tbody>
<for items="failedDeployments" as="deployment">
<tr>
<td><code>{{ $deployment['pipelineId'] }}</code></td>
<td>
<span class="admin-badge admin-badge--neutral">
{{ $deployment['environment'] }}
</span>
</td>
<td>
<span if="$deployment['failedStage'] !== null" class="admin-badge admin-badge--danger">
{{ $deployment['failedStage'] }}
</span>
<span if="$deployment['failedStage'] === null" class="admin-badge admin-badge--neutral">N/A</span>
</td>
<td>
<span if="$deployment['error'] !== null" class="admin-text-truncate" title="{{ $deployment['error'] }}">
{{ $deployment['error'] }}
</span>
<span if="$deployment['error'] === null" class="admin-text-muted">No error message</span>
</td>
<td>{{ $deployment['durationFormatted'] }}</td>
<td>{{ $deployment['startedAt'] }}</td>
</tr>
</for>
<tr foreach="$failedDeployments as $deployment">
<td><code>{{$deployment['pipelineId']}}</code></td>
<td>
<span class="admin-badge admin-badge--neutral">
{{$deployment['environment']}}
</span>
</td>
<td>
<span if="{{$deployment['failedStage'] !== null}}" class="admin-badge admin-badge--danger">
{{$deployment['failedStage']}}
</span>
<span if="{{$deployment['failedStage'] === null}}" class="admin-badge admin-badge--neutral">N/A</span>
</td>
<td>
<span if="{{$deployment['error'] !== null}}" class="admin-text-truncate" title="{{$deployment['error']}}">
{{$deployment['error']}}
</span>
<span if="{{$deployment['error'] === null}}" class="admin-text-muted">No error message</span>
</td>
<td>{{$deployment['durationFormatted']}}</td>
<td>{{$deployment['startedAt']}}</td>
</tr>
</tbody>
</table>
</div>

View File

@@ -0,0 +1,11 @@
<layout name="admin" />
<div class="admin-page">
<div class="page-header">
<h1>{{$title}}</h1>
</div>
<!-- LiveComponent for real-time Docker container monitoring -->
<x-docker-containers id="docker-dashboard" />

View File

@@ -1,7 +1,7 @@
<layout name="admin" />
<div class="section">
<h2>Umgebungsvariablen</h2>
<h2>{{$title}}</h2>
<div class="admin-tools">
<input type="text" id="envFilter" placeholder="Variablen filtern..." style="padding: 8px 12px; border: 1px solid var(--gray-300); border-radius: 6px; width: 300px;">

View File

@@ -1,84 +1,12 @@
<layout name="admin" />
<!-- Cache invalidation: 2025-01-20 16:11 -->
<div class="section">
<h2>{{ title }}</h2>
<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);">
{{ overall_status }}
</div>
<div class="metric-card__label">Overall Status</div>
</div>
<div class="metric-card">
<div class="metric-card__value">{{ total_checks }}</div>
<div class="metric-card__label">Total Checks</div>
</div>
<div class="metric-card">
<div class="metric-card__value" style="color: var(--success);">{{ healthy_checks }}</div>
<div class="metric-card__label">Healthy</div>
</div>
<div class="metric-card">
<div class="metric-card__value" style="color: var(--warning);">{{ warning_checks }}</div>
<div class="metric-card__label">Warnings</div>
</div>
<div class="metric-card">
<div class="metric-card__value" style="color: var(--error);">{{ failed_checks }}</div>
<div class="metric-card__label">Failed</div>
</div>
</div>
</div>
</div>
<div class="admin-card" style="margin-top: var(--space-lg);">
<div class="admin-card__header">
<h3 class="admin-card__title">Health Check Details</h3>
<button class="admin-button admin-button--small" onclick="refreshHealthChecks()" style="margin-left: auto;">
🔄 Refresh
</button>
</div>
<table-data source="health_check_table" container-class="admin-card" />
</div>
<h2>{{$title}}</h2>
<!-- LiveComponent for real-time health status -->
<x-health-status id="health-dashboard" />
</div>
<script>
function toggleDetails(id) {
const element = document.getElementById(id);
if (element) {
element.style.display = element.style.display === 'none' ? 'table-row' : 'none';
}
}
function refreshHealthChecks() {
window.location.reload();
}
// Auto-refresh health status every 60 seconds
setInterval(function() {
window.location.reload();
}, 60000);
</script>
<style>
.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 {
color: var(--muted);
font-size: 0.875rem;

View File

@@ -3,8 +3,8 @@
<div class="section">
<div class="page-header-actions">
<div>
<h2>{{ title }}</h2>
<p>{{ subtitle }}</p>
<h2>{{$title}}</h2>
<p>{{$subtitle}}</p>
</div>
</div>
@@ -68,24 +68,22 @@
</p>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 20px;">
<for var="slot" in="slots">
<div class="stat-card slot-item" data-slot-id="{{ slot.id }}">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<h4>{{ slot.slotName }}</h4>
<p style="color: var(--gray-600); margin: 0;">ID: {{ slot.id }}</p>
</div>
<div class="slot-image-container" style="width: 100px; height: 100px;">
<div style="border: 2px dashed var(--gray-300); display: flex; align-items: center; justify-content: center; height: 100%; border-radius: 4px; cursor: pointer;"
ondrop="handleDrop(event, '{{ slot.id }}')"
ondragover="handleDragOver(event)"
ondragleave="handleDragLeave(event)">
<span style="color: var(--gray-500); font-size: 12px; text-align: center;">Drop image here</span>
</div>
<div class="stat-card slot-item" data-slot-id="{{$slot->id}}" foreach="$slots as $slot">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<h4>{{$slot->slotName}}</h4>
<p style="color: var(--gray-600); margin: 0;">ID: {{$slot->id}}</p>
</div>
<div class="slot-image-container" style="width: 100px; height: 100px;">
<div style="border: 2px dashed var(--gray-300); display: flex; align-items: center; justify-content: center; height: 100%; border-radius: 4px; cursor: pointer;"
ondrop="handleDrop(event, '{{$slot->id}}')"
ondragover="handleDragOver(event)"
ondragleave="handleDragLeave(event)">
<span style="color: var(--gray-500); font-size: 12px; text-align: center;">Drop image here</span>
</div>
</div>
</div>
</for>
</div>
</div>
</div>
</div>

View File

@@ -1,10 +1,10 @@
<layout src="admin-main"/>
<form action='/admin/imageslots/edit/{{ id }}' method='post'>
<form action='/admin/imageslots/edit/{{$id}}' method='post'>
<input type='hidden' name='_method' value='PUT'/>
<label>Slot Name:
<input type='text' name='slotName' value='{{ slotName }}'/>
<input type='text' name='slotName' value='{{$slotName}}'/>
</label>
<input type='submit' value='Update'/>

View File

@@ -1,47 +1,39 @@
<layout name="admin" />
<div class="section">
<h2>{{ title }}</h2>
<h2>{{$title}}</h2>
<div class="admin-card">
<div class="admin-card__header">
<h3 class="admin-card__title">Existing Image Slots</h3>
</div>
<div class="admin-card__content">
<if condition="slots">
<div class="admin-table-wrapper">
<table class="admin-table">
<thead>
<tr>
<th>Slot Name</th>
<th>Current Image</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<for var="slot" in="slots">
<tr>
<td>{{ slot.slotName }}</td>
<td>
<if condition="slot.image">
{{ slot.image.filename }}
<else/>
<span style="color: var(--muted);">No image assigned</span>
</if>
</td>
<td class="admin-table__actions">
<form action="/admin/content/image-slots/{{ slot.slotName }}" method="post" style="display: inline;">
<button type="submit" class="admin-table__action">Edit</button>
</form>
</td>
</tr>
</for>
</tbody>
</table>
</div>
<else/>
<p style="color: var(--muted); text-align: center; padding: var(--space-lg);">No image slots created yet.</p>
</if>
<div class="admin-table-wrapper" if="{{$slots}}">
<table class="admin-table">
<thead>
<tr>
<th>Slot Name</th>
<th>Current Image</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr foreach="$slots as $slot">
<td>{{$slot->slotName}}</td>
<td>
<span if="{{$slot->image}}">{{$slot->image->filename}}</span>
<span style="color: var(--muted);" if="{{!$slot->image}}">No image assigned</span>
</td>
<td class="admin-table__actions">
<form action="/admin/content/image-slots/{{$slot->slotName}}" method="post" style="display: inline;">
<button type="submit" class="admin-table__action">Edit</button>
</form>
</td>
</tr>
</tbody>
</table>
</div>
<p style="color: var(--muted); text-align: center; padding: var(--space-lg);" if="{{!$slots}}">No image slots created yet.</p>
</div>
</div>

View File

@@ -1,7 +1,7 @@
<layout name="admin" />
<div class="section">
<h2>📄 {{ title }}</h2>
<h2>📄 {{$title}}</h2>
<div class="admin-card">
<div class="admin-card__header">

View File

@@ -1,29 +1,24 @@
<layout name="admin" />
<div class="section">
<h2>{{ title }}</h2>
<h2>{{$title}}</h2>
<div class="stats-grid">
<div class="stat-card">
<h3>Migration Status</h3>
<p><strong>Total Migrations:</strong> {{ total_migrations }}</p>
<p><strong>Applied:</strong> <span style="color: var(--success);">{{ applied_count }}</span></p>
<p><strong>Pending:</strong> <span style="color: var(--warning);">{{ pending_count }}</span></p>
<p><strong>Total Migrations:</strong> {{$total_migrations}}</p>
<p><strong>Applied:</strong> <span style="color: var(--success);">{{$applied_count}}</span></p>
<p><strong>Pending:</strong> <span style="color: var(--warning);">{{$pending_count}}</span></p>
</div>
<div class="stat-card">
<h3>Database State</h3>
<p><strong>Pending Count:</strong> {{ pending_count }}</p>
<p><strong>Pending Count:</strong> {{$pending_count}}</p>
<p><strong>Status:</strong>
<if condition="{{ has_pending }}">
<span style="color: var(--warning);">⚠️ {{ pending_count }} Migration(s) pending</span>
<else/>
<span style="color: var(--success);"> Database is up to date</span>
</if>
<span style="color: var(--warning);" if="{{$has_pending}}">⚠️ {{$pending_count}} Migration(s) pending</span>
<span style="color: var(--success);" if="{{!$has_pending}}"> Database is up to date</span>
</p>
<if condition="{{ has_pending }}">
<p><strong>Action Required:</strong> <span style="color: var(--error);">Run migrations to update database</span></p>
</if>
<p if="{{$has_pending}}"><strong>Action Required:</strong> <span style="color: var(--error);">Run migrations to update database</span></p>
</div>
</div>

View File

@@ -33,20 +33,20 @@
<div class="admin-stat-item">
<span class="admin-stat-item__label">Overall Status</span>
<span class="admin-stat-item__value">
<span class="admin-badge admin-badge--{{ $overall_badge }}">{{ $overall_status }}</span>
<span class="admin-badge admin-badge--{{$overall_badge}}">{{$overall_status}}</span>
</span>
</div>
<div class="admin-stat-item">
<span class="admin-stat-item__label">Health Percentage</span>
<span class="admin-stat-item__value">{{ $health_percentage }}%</span>
<span class="admin-stat-item__value">{{$health_percentage}}%</span>
</div>
<div class="admin-stat-item">
<span class="admin-stat-item__label">Average Accuracy</span>
<span class="admin-stat-item__value">{{ $average_accuracy }}%</span>
<span class="admin-stat-item__value">{{$average_accuracy}}%</span>
</div>
<div class="admin-stat-item">
<span class="admin-stat-item__label">Time Window</span>
<span class="admin-stat-item__value">{{ $time_window_hours }} hours</span>
<span class="admin-stat-item__value">{{$time_window_hours}} hours</span>
</div>
</div>
</div>
@@ -61,24 +61,24 @@
<div class="admin-stat-list">
<div class="admin-stat-item">
<span class="admin-stat-item__label">Total Models</span>
<span class="admin-stat-item__value">{{ $total_models }}</span>
<span class="admin-stat-item__value">{{$total_models}}</span>
</div>
<div class="admin-stat-item">
<span class="admin-stat-item__label">Healthy</span>
<span class="admin-stat-item__value">
<span class="admin-badge admin-badge--success">{{ $healthy_models }}</span>
<span class="admin-badge admin-badge--success">{{$healthy_models}}</span>
</span>
</div>
<div class="admin-stat-item">
<span class="admin-stat-item__label">Degraded</span>
<span class="admin-stat-item__value">
<span class="admin-badge admin-badge--warning">{{ $degraded_models }}</span>
<span class="admin-badge admin-badge--warning">{{$degraded_models}}</span>
</span>
</div>
<div class="admin-stat-item">
<span class="admin-stat-item__label">Critical</span>
<span class="admin-stat-item__value">
<span class="admin-badge admin-badge--danger">{{ $critical_models }}</span>
<span class="admin-badge admin-badge--danger">{{$critical_models}}</span>
</span>
</div>
</div>
@@ -94,19 +94,19 @@
<div class="admin-stat-list">
<div class="admin-stat-item">
<span class="admin-stat-item__label">Total Predictions</span>
<span class="admin-stat-item__value">{{ $total_predictions }}</span>
<span class="admin-stat-item__value">{{$total_predictions}}</span>
</div>
<div class="admin-stat-item">
<span class="admin-stat-item__label">Supervised Models</span>
<span class="admin-stat-item__value">{{ $supervised_count }}</span>
<span class="admin-stat-item__value">{{$supervised_count}}</span>
</div>
<div class="admin-stat-item">
<span class="admin-stat-item__label">Unsupervised Models</span>
<span class="admin-stat-item__value">{{ $unsupervised_count }}</span>
<span class="admin-stat-item__value">{{$unsupervised_count}}</span>
</div>
<div class="admin-stat-item">
<span class="admin-stat-item__label">Reinforcement Models</span>
<span class="admin-stat-item__value">{{ $reinforcement_count }}</span>
<span class="admin-stat-item__value">{{$reinforcement_count}}</span>
</div>
</div>
</div>
@@ -114,11 +114,11 @@
</div>
<!-- Degradation Alerts Section -->
<div class="admin-card" if="{{ $has_alerts }}">
<div class="admin-card" if="{{$has_alerts}}">
<div class="admin-card__header">
<h3 class="admin-card__title">
Degradation Alerts
<span class="admin-badge admin-badge--danger">{{ $alert_count }}</span>
<span class="admin-badge admin-badge--danger">{{$alert_count}}</span>
</h3>
</div>
<div class="admin-card__content">
@@ -137,23 +137,23 @@
<tbody>
<tr foreach="$alerts as $alert">
<td>
<strong>{{ $alert['model_name'] }}</strong>
<strong>{{$alert['model_name']}}</strong>
</td>
<td>
<code>{{ $alert['version'] }}</code>
<code>{{$alert['version']}}</code>
</td>
<td>
<span class="admin-badge admin-badge--{{ $alert['severity_badge'] }}">
{{ $alert['current_accuracy'] }}%
<span class="admin-badge admin-badge--{{$alert['severity_badge']}}">
{{$alert['current_accuracy']}}%
</span>
</td>
<td>{{ $alert['threshold'] }}%</td>
<td>{{$alert['threshold']}}%</td>
<td>
<span class="admin-badge admin-badge--{{ $alert['severity_badge'] }}">
{{ $alert['severity'] }}
<span class="admin-badge admin-badge--{{$alert['severity_badge']}}">
{{$alert['severity']}}
</span>
</td>
<td>{{ $alert['recommendation'] }}</td>
<td>{{$alert['recommendation']}}</td>
</tr>
</tbody>
</table>
@@ -187,38 +187,38 @@
<tbody>
<tr foreach="$models as $model">
<td>
<strong>{{ $model['model_name'] }}</strong>
<strong>{{$model['model_name']}}</strong>
</td>
<td>
<code>{{ $model['version'] }}</code>
<code>{{$model['version']}}</code>
</td>
<td>
<span class="admin-badge admin-badge--info">
{{ $model['type'] }}
{{$model['type']}}
</span>
</td>
<td>{{ $model['accuracy'] }}%</td>
<td>{{$model['accuracy']}}%</td>
<td>
<span if="!{{ $model['precision'] }}">-</span>
<span if="{{ $model['precision'] }}">{{ $model['precision'] }}%</span>
<span if="{{!$model['precision']}}">-</span>
<span if="{{$model['precision']}}">{{$model['precision']}}%</span>
</td>
<td>
<span if="!{{ $model['recall'] }}">-</span>
<span if="{{ $model['recall'] }}">{{ $model['recall'] }}%</span>
<span if="{{!$model['recall']}}">-</span>
<span if="{{$model['recall']}}">{{$model['recall']}}%</span>
</td>
<td>
<span if="!{{ $model['f1_score'] }}">-</span>
<span if="{{ $model['f1_score'] }}">{{ $model['f1_score'] }}%</span>
<span if="{{!$model['f1_score']}}">-</span>
<span if="{{$model['f1_score']}}">{{$model['f1_score']}}%</span>
</td>
<td>{{ $model['total_predictions'] }}</td>
<td>{{$model['total_predictions']}}</td>
<td>
<span if="!{{ $model['average_confidence'] }}">-</span>
<span if="{{ $model['average_confidence'] }}">{{ $model['average_confidence'] }}%</span>
<span if="{{!$model['average_confidence']}}">-</span>
<span if="{{$model['average_confidence']}}">{{$model['average_confidence']}}%</span>
</td>
<td>{{ $model['threshold'] }}</td>
<td>{{$model['threshold']}}</td>
<td>
<span class="admin-badge admin-badge--{{ $model['status_badge'] }}">
{{ $model['status'] }}
<span class="admin-badge admin-badge--{{$model['status_badge']}}">
{{$model['status']}}
</span>
</td>
</tr>
@@ -229,39 +229,39 @@
</div>
<!-- Confusion Matrices Section -->
<div class="admin-card" if="{{ $has_confusion_matrices }}">
<div class="admin-card" if="{{$has_confusion_matrices}}">
<div class="admin-card__header">
<h3 class="admin-card__title">Classification Performance (Confusion Matrices)</h3>
</div>
<div class="admin-card__content">
<div class="admin-grid admin-grid--2-col">
<div foreach="$confusion_matrices as $matrix" class="confusion-matrix-card">
<h4 class="confusion-matrix-card__title">{{ $matrix['model_name'] }} v{{ $matrix['version'] }}</h4>
<h4 class="confusion-matrix-card__title">{{$matrix['model_name']}} v{{$matrix['version']}}</h4>
<div class="confusion-matrix">
<div class="confusion-matrix__grid">
<!-- True Positive -->
<div class="confusion-matrix__cell confusion-matrix__cell--tp">
<div class="confusion-matrix__cell-label">True Positive</div>
<div class="confusion-matrix__cell-value">{{ $matrix['true_positives'] }}</div>
<div class="confusion-matrix__cell-value">{{$matrix['true_positives']}}</div>
</div>
<!-- False Positive -->
<div class="confusion-matrix__cell confusion-matrix__cell--fp">
<div class="confusion-matrix__cell-label">False Positive</div>
<div class="confusion-matrix__cell-value">{{ $matrix['false_positives'] }}</div>
<div class="confusion-matrix__cell-value">{{$matrix['false_positives']}}</div>
</div>
<!-- False Negative -->
<div class="confusion-matrix__cell confusion-matrix__cell--fn">
<div class="confusion-matrix__cell-label">False Negative</div>
<div class="confusion-matrix__cell-value">{{ $matrix['false_negatives'] }}</div>
<div class="confusion-matrix__cell-value">{{$matrix['false_negatives']}}</div>
</div>
<!-- True Negative -->
<div class="confusion-matrix__cell confusion-matrix__cell--tn">
<div class="confusion-matrix__cell-label">True Negative</div>
<div class="confusion-matrix__cell-value">{{ $matrix['true_negatives'] }}</div>
<div class="confusion-matrix__cell-value">{{$matrix['true_negatives']}}</div>
</div>
</div>
@@ -269,16 +269,16 @@
<div class="admin-stat-item">
<span class="admin-stat-item__label">False Positive Rate</span>
<span class="admin-stat-item__value">
<span class="admin-badge admin-badge--{{ $matrix['fp_rate_badge'] }}">
{{ $matrix['fp_rate_percent'] }}%
<span class="admin-badge admin-badge--{{$matrix['fp_rate_badge']}}">
{{$matrix['fp_rate_percent']}}%
</span>
</span>
</div>
<div class="admin-stat-item">
<span class="admin-stat-item__label">False Negative Rate</span>
<span class="admin-stat-item__value">
<span class="admin-badge admin-badge--{{ $matrix['fn_rate_badge'] }}">
{{ $matrix['fn_rate_percent'] }}%
<span class="admin-badge admin-badge--{{$matrix['fn_rate_badge']}}">
{{$matrix['fn_rate_percent']}}%
</span>
</span>
</div>
@@ -290,7 +290,7 @@
</div>
<!-- Model Registry Summary -->
<div class="admin-card" if="{{ $has_registry_summary }}">
<div class="admin-card" if="{{$has_registry_summary}}">
<div class="admin-card__header">
<h3 class="admin-card__title">Model Registry Summary</h3>
</div>
@@ -298,18 +298,18 @@
<div class="admin-grid admin-grid--3-col">
<div class="admin-stat-item">
<span class="admin-stat-item__label">Total Versions</span>
<span class="admin-stat-item__value">{{ $registry_total_versions }}</span>
<span class="admin-stat-item__value">{{$registry_total_versions}}</span>
</div>
<div class="admin-stat-item">
<span class="admin-stat-item__label">Production Models</span>
<span class="admin-stat-item__value">
<span class="admin-badge admin-badge--success">{{ $registry_production_count }}</span>
<span class="admin-badge admin-badge--success">{{$registry_production_count}}</span>
</span>
</div>
<div class="admin-stat-item">
<span class="admin-stat-item__label">Development Models</span>
<span class="admin-stat-item__value">
<span class="admin-badge admin-badge--info">{{ $registry_development_count }}</span>
<span class="admin-badge admin-badge--info">{{$registry_development_count}}</span>
</span>
</div>
</div>
@@ -327,15 +327,15 @@
</thead>
<tbody>
<tr foreach="$registry_models as $regModel">
<td><strong>{{ $regModel['model_name'] }}</strong></td>
<td>{{ $regModel['version_count'] }}</td>
<td><strong>{{$regModel['model_name']}}</strong></td>
<td>{{$regModel['version_count']}}</td>
<td>
<span class="admin-badge admin-badge--info">{{ $regModel['type'] }}</span>
<span class="admin-badge admin-badge--info">{{$regModel['type']}}</span>
</td>
<td><code>{{ $regModel['latest_version'] }}</code></td>
<td><code>{{$regModel['latest_version']}}</code></td>
<td>
<span class="admin-badge admin-badge--{{ $regModel['environment'] === 'production' ? 'success' : 'info' }}">
{{ $regModel['environment'] }}
<span class="admin-badge admin-badge--{{$regModel['environment'] === 'production' ? 'success' : 'info'}}">
{{$regModel['environment']}}
</span>
</td>
</tr>
@@ -355,13 +355,13 @@
<div class="admin-stat-item">
<span class="admin-stat-item__label">Dashboard Data</span>
<span class="admin-stat-item__value">
<code>GET {{ $api_dashboard_url }}</code>
<code>GET {{$api_dashboard_url}}</code>
</span>
</div>
<div class="admin-stat-item">
<span class="admin-stat-item__label">Health Check</span>
<span class="admin-stat-item__value">
<code>GET {{ $api_health_url }}</code>
<code>GET {{$api_health_url}}</code>
</span>
</div>
<div class="admin-stat-item">

View File

@@ -2,197 +2,7 @@
<div class="section">
<h2>Performance Übersicht</h2>
<div class="stats-grid">
<div class="stat-card">
<h3>💾 Speicher <span id="realtime-indicator" class="indicator"></span></h3>
<p><strong>Aktuell:</strong> <span id="current-memory">{{ performance.currentMemoryUsage }}</span></p>
<p><strong>Peak:</strong> <span id="peak-memory">{{ performance.peakMemoryUsage }}</span></p>
<p><strong>Limit:</strong> {{ performance.memoryLimit }}</p>
<p><strong>Auslastung:</strong> <span id="memory-percentage">{{ performance.memoryUsagePercentage }}</span>%</p>
<div class="progress-bar">
<div class="progress-fill" id="memory-progress" style="width: {{ performance.memoryUsagePercentage }}%"></div>
</div>
</div>
<div class="stat-card">
<h3>System</h3>
<p><strong>Ausführungszeit:</strong> {{ performance.executionTime }}</p>
<p><strong>Geladene Dateien:</strong> {{ performance.includedFiles }}</p>
<p><strong>OPCache:</strong> {{ performance.opcacheEnabled }}</p>
</div>
<if condition="{{ performance.opcacheMemoryUsage }}">
<div class="stat-card">
<h3>OPCache</h3>
<p><strong>Speicher:</strong> {{ performance.opcacheMemoryUsage }}</p>
<p><strong>Cache Hits:</strong> {{ performance.opcacheCacheHits }}</p>
<p><strong>Miss Rate:</strong> {{ performance.opcacheMissRate }}</p>
</div>
</if>
</div>
<!-- Real-time Controls -->
<div class="admin-card" style="margin-top: var(--space-lg);">
<div class="admin-card__header">
<h3 class="admin-card__title">⚙️ Real-time Monitoring</h3>
</div>
<div class="admin-card__content">
<label style="display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="realtime-toggle">
<span>Enable Real-time Updates</span>
<span id="realtime-status" class="status-indicator"></span>
</label>
<p style="margin-top: 10px; font-size: 0.9em; color: var(--text-muted);">
<span id="last-update">Last Updated: {{ timestamp }}</span>
</p>
</div>
</div>
</div>
<style>
.progress-bar {
height: 8px;
background: var(--bg-muted);
border-radius: 4px;
overflow: hidden;
margin: 8px 0;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--success), var(--success-dark));
transition: width 0.3s ease;
}
.indicator {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-muted);
margin-left: 8px;
}
.indicator.active {
background: var(--success);
animation: pulse 1s infinite;
}
.status-indicator {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--text-muted);
display: inline-block;
}
.status-indicator.active {
background: var(--success);
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: var(--space-lg);
margin-bottom: var(--space-lg);
}
.stat-card {
background: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: var(--space-lg);
}
.stat-card h3 {
margin: 0 0 var(--space-md) 0;
color: var(--text-primary);
display: flex;
align-items: center;
}
.stat-card p {
margin: var(--space-sm) 0;
color: var(--text-secondary);
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
const toggle = document.getElementById('realtime-toggle');
const statusIndicator = document.getElementById('realtime-status');
const realtimeIndicator = document.getElementById('realtime-indicator');
const lastUpdate = document.getElementById('last-update');
let updateInterval = null;
toggle?.addEventListener('change', function() {
if (this.checked) {
startRealtimeUpdates();
} else {
stopRealtimeUpdates();
}
});
function startRealtimeUpdates() {
statusIndicator?.classList.add('active');
realtimeIndicator?.classList.add('active');
updateInterval = setInterval(async () => {
try {
const response = await fetch('/admin/system/performance/api/realtime');
const data = await response.json();
updateMetrics(data);
} catch (error) {
console.error('Failed to fetch realtime metrics:', error);
stopRealtimeUpdates();
toggle.checked = false;
}
}, 3000); // Update every 3 seconds
}
function stopRealtimeUpdates() {
statusIndicator?.classList.remove('active');
realtimeIndicator?.classList.remove('active');
if (updateInterval) {
clearInterval(updateInterval);
updateInterval = null;
}
}
function updateMetrics(data) {
// Update memory metrics
const currentMemory = document.getElementById('current-memory');
if (currentMemory && data.memory?.current) {
currentMemory.textContent = data.memory.current;
}
const peakMemory = document.getElementById('peak-memory');
if (peakMemory && data.memory?.peak) {
peakMemory.textContent = data.memory.peak;
}
const memoryPercentage = document.getElementById('memory-percentage');
const memoryProgress = document.getElementById('memory-progress');
if (data.memory?.usage_percentage !== undefined) {
if (memoryPercentage) {
memoryPercentage.textContent = data.memory.usage_percentage.toFixed(1);
}
if (memoryProgress) {
memoryProgress.style.width = data.memory.usage_percentage + '%';
}
}
// Update timestamp
if (lastUpdate && data.timestamp) {
lastUpdate.textContent = 'Last Updated: ' + data.timestamp;
}
}
});
</script>
<!-- LiveComponent for real-time performance metrics -->
<x-performance-metrics id="performance-dashboard" />
</div>

View File

@@ -1,7 +1,7 @@
<layout name="admin" />
<div class="section">
<h2>{{ title }}</h2>
<h2>{{$title}}</h2>
<div class="admin-card">
<div class="admin-card__header">
@@ -24,17 +24,15 @@
<div class="admin-card__content">
<div style="margin-bottom: var(--space-md);">
<div class="metric-card" style="display: inline-block; margin-right: var(--space-md);">
<div class="metric-card__value">{{ extensions_count }}</div>
<div class="metric-card__value">{{$extensions_count}}</div>
<div class="metric-card__label">Total Extensions</div>
</div>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: var(--space-sm);">
<for var="extension" in="extensions_list">
<div style="padding: var(--space-sm); background: var(--bg-alt); border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: 0.875rem;">
{{ extension }}
</div>
</for>
<div style="padding: var(--space-sm); background: var(--bg-alt); border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: 0.875rem;" foreach="$extensions_list as $extension">
{{$extension}}
</div>
</div>
</div>
</div>

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Edit Campaign - {campaign.title}</title>
<title>Edit Campaign - {{$campaign->title}}</title>
<link rel="stylesheet" href="/css/styles.css">
</head>
<body>
@@ -11,10 +11,10 @@
<div class="container">
<div class="page-header">
<h1>Edit Campaign</h1>
<a href="/admin/presave/campaigns/{campaign.id}" class="btn btn-secondary">Back to Details</a>
<a href="/admin/presave/campaigns/{{$campaign->id}}" class="btn btn-secondary">Back to Details</a>
</div>
<form action="/admin/presave/campaigns/{campaign.id}" method="POST" class="campaign-form">
<form action="/admin/presave/campaigns/{{$campaign->id}}" method="POST" class="campaign-form">
<csrf-token />
<input type="hidden" name="_method" value="PUT">
@@ -23,23 +23,23 @@
<div class="form-group">
<label for="title">Campaign Title *</label>
<input type="text" id="title" name="title" value="{campaign.title}" required class="form-control">
<input type="text" id="title" name="title" value="{{$campaign->title}}" required class="form-control">
</div>
<div class="form-group">
<label for="artist_name">Artist Name *</label>
<input type="text" id="artist_name" name="artist_name" value="{campaign.artistName}" required class="form-control">
<input type="text" id="artist_name" name="artist_name" value="{{$campaign->artistName}}" required class="form-control">
</div>
<div class="form-group">
<label for="cover_image_url">Cover Image URL *</label>
<input type="url" id="cover_image_url" name="cover_image_url" value="{campaign.coverImageUrl}" required class="form-control">
<input type="url" id="cover_image_url" name="cover_image_url" value="{{$campaign->coverImageUrl}}" required class="form-control">
<small>Direct URL to album/single cover image</small>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" name="description" rows="4" class="form-control">{campaign.description}</textarea>
<textarea id="description" name="description" rows="4" class="form-control">{{$campaign->description}}</textarea>
</div>
</div>
@@ -48,7 +48,7 @@
<div class="form-group">
<label for="release_date">Release Date *</label>
<input type="datetime-local" id="release_date" name="release_date" value="{campaign.releaseDate|datetime_input}" required class="form-control">
<input type="datetime-local" id="release_date" name="release_date" value="{{$campaign->releaseDate}}" required class="form-control">
</div>
</div>
@@ -58,23 +58,23 @@
<div class="form-group">
<label for="spotify_url">Spotify URL</label>
<input type="url" id="spotify_url" name="spotify_url" value="{campaign.trackUrls.spotify}" class="form-control" placeholder="https://open.spotify.com/album/...">
<input type="url" id="spotify_url" name="spotify_url" value="{{$campaign->trackUrls['spotify'] ?? ''}}" class="form-control" placeholder="https://open.spotify.com/album/...">
</div>
<div class="form-group">
<label for="apple_music_url">Apple Music URL</label>
<input type="url" id="apple_music_url" name="apple_music_url" value="{campaign.trackUrls.apple_music}" class="form-control" placeholder="https://music.apple.com/album/...">
<input type="url" id="apple_music_url" name="apple_music_url" value="{{$campaign->trackUrls['apple_music'] ?? ''}}" class="form-control" placeholder="https://music.apple.com/album/...">
</div>
<div class="form-group">
<label for="tidal_url">Tidal URL</label>
<input type="url" id="tidal_url" name="tidal_url" value="{campaign.trackUrls.tidal}" class="form-control" placeholder="https://tidal.com/browse/album/...">
<input type="url" id="tidal_url" name="tidal_url" value="{{$campaign->trackUrls['tidal'] ?? ''}}" class="form-control" placeholder="https://tidal.com/browse/album/...">
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Update Campaign</button>
<a href="/admin/presave/campaigns/{campaign.id}" class="btn btn-secondary">Cancel</a>
<a href="/admin/presave/campaigns/{{$campaign->id}}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
@@ -82,7 +82,7 @@
<script>
// Convert timestamp to datetime-local format
const releaseDate = new Date({campaign.releaseDate} * 1000);
const releaseDate = new Date({{$campaign->releaseDate}} * 1000);
document.getElementById('release_date').value = releaseDate.toISOString().slice(0, 16);
</script>
</body>

View File

@@ -14,73 +14,61 @@
<a href="/admin/presave/campaigns/create" class="btn btn-primary">New Campaign</a>
</div>
<if condition="{stats}">
<div class="stats-grid">
<div class="stat-card">
<h3>Total Campaigns</h3>
<p class="stat-value">{stats.total}</p>
</div>
<div class="stat-card">
<h3>Active</h3>
<p class="stat-value">{stats.active}</p>
</div>
<div class="stat-card">
<h3>Total Registrations</h3>
<p class="stat-value">{stats.total_registrations}</p>
</div>
<div class="stat-card">
<h3>Completed</h3>
<p class="stat-value">{stats.completed}</p>
</div>
<div class="stats-grid" if="{{$stats}}">
<div class="stat-card">
<h3>Total Campaigns</h3>
<p class="stat-value">{{$stats['total']}}</p>
</div>
</if>
<div class="stat-card">
<h3>Active</h3>
<p class="stat-value">{{$stats['active']}}</p>
</div>
<div class="stat-card">
<h3>Total Registrations</h3>
<p class="stat-value">{{$stats['total_registrations']}}</p>
</div>
<div class="stat-card">
<h3>Completed</h3>
<p class="stat-value">{{$stats['completed']}}</p>
</div>
</div>
<if condition="{campaigns}">
<div class="campaigns-list">
<for items="{campaigns}" as="campaign">
<div class="campaign-card">
<div class="campaign-header">
<img src="{campaign.coverImageUrl}" alt="{campaign.title}" class="campaign-cover">
<div class="campaign-info">
<h3>{campaign.title}</h3>
<p class="artist">{campaign.artistName}</p>
<p class="release-date">Release: {campaign.releaseDate|date}</p>
</div>
<div class="campaign-status">
<span class="badge badge-{campaign.status.value}">{campaign.status.value}</span>
</div>
</div>
<div class="campaign-actions">
<a href="/admin/presave/campaigns/{campaign.id}" class="btn btn-sm">View</a>
<a href="/admin/presave/campaigns/{campaign.id}/edit" class="btn btn-sm">Edit</a>
<if condition="{campaign.status.value === 'draft'}">
<form action="/admin/presave/campaigns/{campaign.id}/activate" method="POST" style="display: inline;">
<csrf-token />
<button type="submit" class="btn btn-sm btn-success">Activate</button>
</form>
</if>
<if condition="{campaign.status.value === 'active'}">
<form action="/admin/presave/campaigns/{campaign.id}/pause" method="POST" style="display: inline;">
<csrf-token />
<button type="submit" class="btn btn-sm btn-warning">Pause</button>
</form>
</if>
<button class="btn btn-sm btn-danger" onclick="deleteCampaign({campaign.id})">Delete</button>
</div>
<div class="campaigns-list" if="{{$campaigns}}">
<div class="campaign-card" foreach="$campaigns as $campaign">
<div class="campaign-header">
<img src="{{$campaign->coverImageUrl}}" alt="{{$campaign->title}}" class="campaign-cover">
<div class="campaign-info">
<h3>{{$campaign->title}}</h3>
<p class="artist">{{$campaign->artistName}}</p>
<p class="release-date">Release: {{$campaign->releaseDate}}</p>
</div>
</for>
</div>
</if>
<div class="campaign-status">
<span class="badge badge-{{$campaign->status->value}}">{{$campaign->status->value}}</span>
</div>
</div>
<div class="campaign-actions">
<a href="/admin/presave/campaigns/{{$campaign->id}}" class="btn btn-sm">View</a>
<a href="/admin/presave/campaigns/{{$campaign->id}}/edit" class="btn btn-sm">Edit</a>
<if condition="{!campaigns || campaigns.length === 0}">
<div class="empty-state">
<p>No campaigns yet.</p>
<a href="/admin/presave/campaigns/create" class="btn btn-primary">Create your first campaign</a>
<form action="/admin/presave/campaigns/{{$campaign->id}}/activate" method="POST" style="display: inline;" if="{{$campaign->status->value === 'draft'}}">
<csrf-token />
<button type="submit" class="btn btn-sm btn-success">Activate</button>
</form>
<form action="/admin/presave/campaigns/{{$campaign->id}}/pause" method="POST" style="display: inline;" if="{{$campaign->status->value === 'active'}}">
<csrf-token />
<button type="submit" class="btn btn-sm btn-warning">Pause</button>
</form>
<button class="btn btn-sm btn-danger" onclick="deleteCampaign({{$campaign->id}})">Delete</button>
</div>
</div>
</if>
</div>
<div class="empty-state" if="{{!$campaigns || count($campaigns) === 0}}">
<p>No campaigns yet.</p>
<a href="/admin/presave/campaigns/create" class="btn btn-primary">Create your first campaign</a>
</div>
</div>
</layout>

View File

@@ -3,48 +3,44 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{campaign.title} - Campaign Details</title>
<title>{{$campaign->title}} - Campaign Details</title>
<link rel="stylesheet" href="/css/styles.css">
</head>
<body>
<layout name="admin">
<div class="container">
<div class="page-header">
<h1>{campaign.title}</h1>
<h1>{{$campaign->title}}</h1>
<div class="header-actions">
<a href="/admin/presave/campaigns/{campaign.id}/edit" class="btn btn-primary">Edit</a>
<a href="/admin/presave/campaigns/{{$campaign->id}}/edit" class="btn btn-primary">Edit</a>
<a href="/admin/presave/campaigns" class="btn btn-secondary">Back to List</a>
</div>
</div>
<div class="campaign-details">
<div class="campaign-cover-large">
<img src="{campaign.coverImageUrl}" alt="{campaign.title}">
<img src="{{$campaign->coverImageUrl}}" alt="{{$campaign->title}}">
</div>
<div class="campaign-info-section">
<h2>Campaign Information</h2>
<dl class="info-list">
<dt>Artist</dt>
<dd>{campaign.artistName}</dd>
<dd>{{$campaign->artistName}}</dd>
<dt>Status</dt>
<dd><span class="badge badge-{campaign.status.value}">{campaign.status.value}</span></dd>
<dd><span class="badge badge-{{$campaign->status->value}}">{{$campaign->status->value}}</span></dd>
<dt>Release Date</dt>
<dd>{campaign.releaseDate|date}</dd>
<dd>{{$campaign->releaseDate}}</dd>
<if condition="{campaign.description}">
<dt>Description</dt>
<dd>{campaign.description}</dd>
</if>
<dt if="{{$campaign->description}}">Description</dt>
<dd if="{{$campaign->description}}">{{$campaign->description}}</dd>
<dt>Available Platforms</dt>
<dd>
<div class="platform-links">
<for items="{campaign.trackUrls}" as="platform=>url">
<a href="{url}" target="_blank" class="platform-badge">{platform}</a>
</for>
<a href="{{$url}}" target="_blank" class="platform-badge" foreach="$campaign->trackUrls as $platform => $url">{{$platform}}</a>
</div>
</dd>
</dl>
@@ -55,19 +51,19 @@
<div class="stats-grid">
<div class="stat-card">
<h3>Total Registrations</h3>
<p class="stat-value">{stats.total_registrations}</p>
<p class="stat-value">{{$stats['total_registrations']}}</p>
</div>
<div class="stat-card">
<h3>Pending</h3>
<p class="stat-value">{stats.pending}</p>
<p class="stat-value">{{$stats['pending']}}</p>
</div>
<div class="stat-card">
<h3>Completed</h3>
<p class="stat-value">{stats.completed}</p>
<p class="stat-value">{{$stats['completed']}}</p>
</div>
<div class="stat-card">
<h3>Failed</h3>
<p class="stat-value">{stats.failed}</p>
<p class="stat-value">{{$stats['failed']}}</p>
</div>
</div>
@@ -75,15 +71,15 @@
<div class="platform-stats">
<div class="platform-stat">
<span class="platform-name">Spotify</span>
<span class="platform-count">{stats.by_platform.spotify}</span>
<span class="platform-count">{{$stats['by_platform']['spotify']}}</span>
</div>
<div class="platform-stat">
<span class="platform-name">Apple Music</span>
<span class="platform-count">{stats.by_platform.apple_music}</span>
<span class="platform-count">{{$stats['by_platform']['apple_music']}}</span>
</div>
<div class="platform-stat">
<span class="platform-name">Tidal</span>
<span class="platform-count">{stats.by_platform.tidal}</span>
<span class="platform-count">{{$stats['by_platform']['tidal']}}</span>
</div>
</div>
</div>
@@ -91,83 +87,61 @@
<div class="campaign-actions-section">
<h2>Campaign Actions</h2>
<div class="action-buttons">
<if condition="{campaign.status.value === 'draft'}">
<form action="/admin/presave/campaigns/{campaign.id}/activate" method="POST">
<csrf-token />
<button type="submit" class="btn btn-success">Activate Campaign</button>
</form>
</if>
<form action="/admin/presave/campaigns/{{$campaign->id}}/activate" method="POST" if="{{$campaign->status->value === 'draft'}}">
<csrf-token />
<button type="submit" class="btn btn-success">Activate Campaign</button>
</form>
<if condition="{campaign.status.value === 'active'}">
<form action="/admin/presave/campaigns/{campaign.id}/pause" method="POST">
<csrf-token />
<button type="submit" class="btn btn-warning">Pause Campaign</button>
</form>
</if>
<form action="/admin/presave/campaigns/{{$campaign->id}}/pause" method="POST" if="{{$campaign->status->value === 'active'}}">
<csrf-token />
<button type="submit" class="btn btn-warning">Pause Campaign</button>
</form>
<if condition="{campaign.status.value === 'paused'}">
<form action="/admin/presave/campaigns/{campaign.id}/activate" method="POST">
<csrf-token />
<button type="submit" class="btn btn-success">Resume Campaign</button>
</form>
</if>
<form action="/admin/presave/campaigns/{{$campaign->id}}/activate" method="POST" if="{{$campaign->status->value === 'paused'}}">
<csrf-token />
<button type="submit" class="btn btn-success">Resume Campaign</button>
</form>
<if condition="{campaign.status.value !== 'completed'}">
<form action="/admin/presave/campaigns/{campaign.id}/complete" method="POST">
<csrf-token />
<button type="submit" class="btn btn-primary">Mark as Completed</button>
</form>
</if>
<form action="/admin/presave/campaigns/{{$campaign->id}}/complete" method="POST" if="{{$campaign->status->value !== 'completed'}}">
<csrf-token />
<button type="submit" class="btn btn-primary">Mark as Completed</button>
</form>
</div>
</div>
<div class="registrations-section">
<h2>Registrations</h2>
<if condition="{registrations && registrations.length > 0}">
<table class="data-table">
<thead>
<tr>
<th>User ID</th>
<th>Platform</th>
<th>Status</th>
<th>Registered</th>
<th>Processed</th>
<th>Error</th>
</tr>
</thead>
<tbody>
<for items="{registrations}" as="registration">
<tr>
<td>{registration.userId}</td>
<td>{registration.platform.value}</td>
<td><span class="badge badge-{registration.status.value}">{registration.status.value}</span></td>
<td>{registration.registeredAt|datetime}</td>
<td>
<if condition="{registration.processedAt}">
{registration.processedAt|datetime}
</if>
<if condition="{!registration.processedAt}">
-
</if>
</td>
<td>
<if condition="{registration.errorMessage}">
<span class="error-message" title="{registration.errorMessage}">
{registration.errorMessage|truncate:50}
</span>
</if>
<if condition="{!registration.errorMessage}">
-
</if>
</td>
</tr>
</for>
</tbody>
</table>
</if>
<if condition="{!registrations || registrations.length === 0}">
<p class="text-muted">No registrations yet.</p>
</if>
<table class="data-table" if="{{$registrations && count($registrations) > 0}}">
<thead>
<tr>
<th>User ID</th>
<th>Platform</th>
<th>Status</th>
<th>Registered</th>
<th>Processed</th>
<th>Error</th>
</tr>
</thead>
<tbody>
<tr foreach="$registrations as $registration">
<td>{{$registration->userId}}</td>
<td>{{$registration->platform->value}}</td>
<td><span class="badge badge-{{$registration->status->value}}">{{$registration->status->value}}</span></td>
<td>{{$registration->registeredAt}}</td>
<td>
<span if="{{$registration->processedAt}}">{{$registration->processedAt}}</span>
<span if="{{!$registration->processedAt}}">-</span>
</td>
<td>
<span class="error-message" title="{{$registration->errorMessage}}" if="{{$registration->errorMessage}}">
{{substr($registration->errorMessage, 0, 50)}}
</span>
<span if="{{!$registration->errorMessage}}">-</span>
</td>
</tr>
</tbody>
</table>
<p class="text-muted" if="{{!$registrations || count($registrations) === 0}}">No registrations yet.</p>
</div>
</div>
</div>

View File

@@ -1,24 +1,24 @@
<layout name="admin" />
<div class="section">
<h2>{{ title }}</h2>
<h2>{{$title}}</h2>
<!-- Connection Status -->
<div class="stats-grid">
<div class="stat-card">
<h3>Connection Status</h3>
<p><strong>Status:</strong> {{ redis.status }}</p>
<p><strong>Connected:</strong> {{ redis.is_connected }}</p>
<p><strong>Status:</strong> {{$redis['status']}}</p>
<p><strong>Connected:</strong> {{$redis['is_connected']}}</p>
</div>
</div>
<!-- TODO: Conditional sections will work once ForProcessor/IfProcessor are fixed -->
<div class="stat-card">
<h3>Debug Information</h3>
<p><strong>Has Basic Info:</strong> {{ redis.has_basic_info }}</p>
<p><strong>Has Error:</strong> {{ redis.has_error }}</p>
<p><strong>Has Databases:</strong> {{ redis.has_databases }}</p>
<p><strong>Has Cache Patterns:</strong> {{ redis.has_cache_patterns }}</p>
<p><strong>Has Basic Info:</strong> {{$redis['has_basic_info']}}</p>
<p><strong>Has Error:</strong> {{$redis['has_error']}}</p>
<p><strong>Has Databases:</strong> {{$redis['has_databases']}}</p>
<p><strong>Has Cache Patterns:</strong> {{$redis['has_cache_patterns']}}</p>
</div>
</div>

View File

@@ -5,23 +5,23 @@
<div class="stats-grid">
<div class="stat-card">
<h3>Overview</h3>
<p><strong>Total Routes:</strong> {{ total_routes }}</p>
<p><strong>Admin Routes:</strong> {{ admin_routes_count }}</p>
<p><strong>API Routes:</strong> {{ api_routes_count }}</p>
<p><strong>Total Routes:</strong> {{$total_routes}}</p>
<p><strong>Admin Routes:</strong> {{$admin_routes_count}}</p>
<p><strong>API Routes:</strong> {{$api_routes_count}}</p>
</div>
<div class="stat-card">
<h3>HTTP Methods</h3>
<p><strong>GET:</strong> {{ get_routes_count }}</p>
<p><strong>POST:</strong> {{ post_routes_count }}</p>
<p><strong>PUT/PATCH:</strong> {{ put_routes_count }}</p>
<p><strong>DELETE:</strong> {{ delete_routes_count }}</p>
<p><strong>GET:</strong> {{$get_routes_count}}</p>
<p><strong>POST:</strong> {{$post_routes_count}}</p>
<p><strong>PUT/PATCH:</strong> {{$put_routes_count}}</p>
<p><strong>DELETE:</strong> {{$delete_routes_count}}</p>
</div>
<div class="stat-card">
<h3>Authentication</h3>
<p><strong>Protected Routes:</strong> {{ protected_routes_count }}</p>
<p><strong>Public Routes:</strong> {{ public_routes_count }}</p>
<p><strong>Protected Routes:</strong> {{$protected_routes_count}}</p>
<p><strong>Public Routes:</strong> {{$public_routes_count}}</p>
</div>
</div>
@@ -32,8 +32,7 @@
</div>
</div>
<if condition="{{ middlewares }}">
<div class="stats-grid">
<div class="stats-grid" if="{{$middlewares}}">
<div class="stat-card full-width">
<h3>Middleware Usage</h3>
<table>
@@ -45,16 +44,13 @@
</tr>
</thead>
<tbody>
<for items="{{ middlewares }}" key="middleware" value="data">
<tr>
<td>{{ middleware }}</td>
<td>{{ data.count }}</td>
<td style="font-size: 12px;">{{ data.routes }}</td>
<tr foreach="$middlewares as $middleware => $data">
<td>{{$middleware}}</td>
<td>{{$data['count']}}</td>
<td style="font-size: 12px;">{{$data['routes']}}</td>
</tr>
</for>
</tbody>
</table>
</div>
</div>
</if>
</div>

View File

@@ -6,19 +6,15 @@
<div style="margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center;">
<input type="text" id="serviceFilter" placeholder="Dienste filtern..."
style="padding: 8px 12px; border: 1px solid var(--gray-300); border-radius: 6px; width: 300px;">
<span class="services-count" style="color: var(--gray-600);">{{ servicesCount }} Dienste insgesamt</span>
<span class="services-count" style="color: var(--gray-600);">{{$servicesCount}} Dienste insgesamt</span>
</div>
<div class="stats-grid" id="serviceList">
<for var="service" in="services">
<div class="stat-card service-item">
<h3>{{ service.name }}</h3>
<p><strong>Kategorie:</strong> {{ service.category }}</p>
<if condition="{{ service.subCategory }}">
<p><strong>Unterkategorie:</strong> {{ service.subCategory }}</p>
</if>
</div>
</for>
<div class="stat-card service-item" foreach="$services as $service">
<h3>{{$service['name']}}</h3>
<p><strong>Kategorie:</strong> {{$service['category']}}</p>
<p if="{{$service['subCategory']}}"><strong>Unterkategorie:</strong> {{$service['subCategory']}}</p>
</div>
</div>
</div>
@@ -36,6 +32,6 @@ document.getElementById('serviceFilter').addEventListener('input', function() {
});
document.querySelector('.services-count').textContent =
visibleCount + ' von {{ servicesCount }} Diensten';
visibleCount + ' von {{$servicesCount}} Diensten';
});
</script>

View File

@@ -1,25 +1,19 @@
<layout name="admin" />
<div class="section">
<h2>{{ title }}</h2>
<h2>{{$title}}</h2>
<if condition="{{ success }}">
<div class="alert alert-success">
<strong>Erfolg!</strong> {{ description }}
</div>
<else>
<if condition="{{ error }}">
<div class="alert alert-danger">
<strong>Fehler:</strong> {{ description }}
</div>
<else>
<p>{{ description }}</p>
</if>
</if>
<div class="alert alert-success" if="{{$success}}">
<strong>Erfolg!</strong> {{$description}}
</div>
<div class="alert alert-danger" if="{{$error}}">
<strong>Fehler:</strong> {{$description}}
</div>
<p if="{{!$success && !$error}}">{{$description}}</p>
<div class="stat-card">
<h3>Bild hochladen</h3>
{{formHtml}}
{{$formHtml}}
</div>
</div>

View File

@@ -1,8 +1,8 @@
<layout name="admin" />
<div class="section">
<h2>{{ title }}</h2>
<p>{{ description }}</p>
<h2>{{$title}}</h2>
<p>{{$description}}</p>
<div class="stat-card">
<h3>JavaScript Upload Test</h3>

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Application\Asset\Api\Requests;
use App\Framework\Validation\Attributes\MaxLength;
use App\Framework\Validation\Attributes\MinLength;
final class UploadAssetRequest
{
public ?string $bucket = null;
/**
* @var string[]|null
*/
public ?array $tags = null;
/**
* @var array<string, mixed>|null
*/
public ?array $meta = null;
}

View File

@@ -0,0 +1,302 @@
<?php
declare(strict_types=1);
namespace App\Application\Asset\Api\V1;
use App\Application\Asset\Api\Requests\UploadAssetRequest;
use App\Domain\Asset\Exceptions\AssetNotFoundException;
use App\Domain\Asset\Services\AssetService;
use App\Domain\Asset\ValueObjects\AssetId;
use App\Domain\Asset\ValueObjects\AssetMetadata;
use App\Domain\Asset\ValueObjects\VariantName;
use App\Framework\Attributes\Route;
use App\Framework\Exception\FrameworkException;
use App\Framework\Http\Headers;
use App\Framework\Http\HttpResponse;
use App\Framework\Http\Method;
use App\Framework\Http\Request;
use App\Framework\Http\Status;
use App\Framework\Serializer\Json\JsonSerializer;
use App\Framework\Serializer\Json\JsonSerializerConfig;
use App\Domain\Asset\Storage\AssetStorageInterface;
use App\Framework\Storage\ValueObjects\BucketName;
final readonly class AssetsController
{
public function __construct(
private AssetService $assetService,
private AssetStorageInterface $storage,
private JsonSerializer $jsonSerializer
) {
}
#[Route(path: '/api/v1/assets/upload', method: Method::POST)]
public function upload(Request $request, UploadAssetRequest $uploadRequest): HttpResponse
{
try {
// Get file from request (multipart/form-data)
$files = $request->files;
if (empty($files) || ! isset($files['file'])) {
return $this->errorResponse('File is required', Status::BAD_REQUEST);
}
$file = $files['file'];
if (! isset($file['tmp_name']) || ! is_uploaded_file($file['tmp_name'])) {
return $this->errorResponse('Invalid file upload', Status::BAD_REQUEST);
}
$content = file_get_contents($file['tmp_name']);
if ($content === false) {
return $this->errorResponse('Could not read file', Status::BAD_REQUEST);
}
// Determine MIME type
$mimeType = $file['type'] ?? mime_content_type($file['tmp_name']) ?? 'application/octet-stream';
$mime = \App\Framework\Http\MimeType::tryFrom($mimeType)
?? \App\Framework\Http\CustomMimeType::fromString($mimeType);
// Determine bucket
$bucket = $uploadRequest->bucket !== null
? BucketName::fromString($uploadRequest->bucket)
: BucketName::fromString('media');
// Parse metadata
$meta = null;
if ($uploadRequest->meta !== null) {
$meta = AssetMetadata::fromArray($uploadRequest->meta);
}
// Parse tags
$tags = $uploadRequest->tags ?? [];
// Upload asset
$asset = $this->assetService->upload(
content: $content,
bucket: $bucket,
mime: $mime,
meta: $meta,
tags: $tags
);
$data = $this->serializeAsset($asset);
return new HttpResponse(
status: Status::CREATED,
headers: new Headers(['Content-Type' => 'application/json']),
body: $this->jsonSerializer->serializeWithConfig($data, JsonSerializerConfig::pretty())
);
} catch (FrameworkException $e) {
return $this->errorResponse($e->getMessage(), Status::BAD_REQUEST);
} catch (\Throwable $e) {
return $this->errorResponse('Upload failed: ' . $e->getMessage(), Status::INTERNAL_SERVER_ERROR);
}
}
#[Route(path: '/api/v1/assets/{id}', method: Method::GET)]
public function show(string $id): HttpResponse
{
try {
$asset = $this->assetService->findById(AssetId::fromString($id));
$variants = $this->assetService->getVariants($asset->id);
$tags = $this->assetService->getTags($asset->id);
$data = $this->serializeAsset($asset, $variants, $tags);
return new HttpResponse(
status: Status::OK,
headers: new Headers(['Content-Type' => 'application/json']),
body: $this->jsonSerializer->serializeWithConfig($data, JsonSerializerConfig::pretty())
);
} catch (AssetNotFoundException $e) {
return $this->errorResponse($e->getMessage(), Status::NOT_FOUND);
} catch (\InvalidArgumentException $e) {
return $this->errorResponse($e->getMessage(), Status::BAD_REQUEST);
}
}
#[Route(path: '/api/v1/assets/{id}/variants', method: Method::GET)]
public function variants(string $id): HttpResponse
{
try {
$asset = $this->assetService->findById(AssetId::fromString($id));
$variants = $this->assetService->getVariants($asset->id);
$data = array_map(fn ($variant) => $this->serializeVariant($variant), $variants);
return new HttpResponse(
status: Status::OK,
headers: new Headers(['Content-Type' => 'application/json']),
body: $this->jsonSerializer->serializeWithConfig($data, JsonSerializerConfig::pretty())
);
} catch (AssetNotFoundException $e) {
return $this->errorResponse($e->getMessage(), Status::NOT_FOUND);
}
}
#[Route(path: '/api/v1/assets/{id}/variants/{variant}', method: Method::GET)]
public function variant(string $id, string $variant): HttpResponse
{
try {
$asset = $this->assetService->findById(AssetId::fromString($id));
$variantEntity = $this->assetService->getVariant($asset->id, VariantName::fromString($variant));
if ($variantEntity === null) {
return $this->errorResponse('Variant not found', Status::NOT_FOUND);
}
$data = $this->serializeVariant($variantEntity);
return new HttpResponse(
status: Status::OK,
headers: new Headers(['Content-Type' => 'application/json']),
body: $this->jsonSerializer->serializeWithConfig($data, JsonSerializerConfig::pretty())
);
} catch (AssetNotFoundException $e) {
return $this->errorResponse($e->getMessage(), Status::NOT_FOUND);
} catch (\InvalidArgumentException $e) {
return $this->errorResponse($e->getMessage(), Status::BAD_REQUEST);
}
}
#[Route(path: '/api/v1/assets/{id}/url', method: Method::GET)]
public function url(string $id): HttpResponse
{
try {
$asset = $this->assetService->findById(AssetId::fromString($id));
$url = $this->storage->getUrl($asset);
return new HttpResponse(
status: Status::OK,
headers: new Headers(['Content-Type' => 'application/json']),
body: $this->jsonSerializer->serializeWithConfig(['url' => $url], JsonSerializerConfig::pretty())
);
} catch (AssetNotFoundException $e) {
return $this->errorResponse($e->getMessage(), Status::NOT_FOUND);
}
}
#[Route(path: '/api/v1/assets/{id}/signed-url', method: Method::GET)]
public function signedUrl(Request $request, string $id): HttpResponse
{
try {
$asset = $this->assetService->findById(AssetId::fromString($id));
$expires = (int) ($request->query['expires'] ?? 3600);
$url = $this->storage->getSignedUrl($asset, $expires);
return new HttpResponse(
status: Status::OK,
headers: new Headers(['Content-Type' => 'application/json']),
body: $this->jsonSerializer->serializeWithConfig(['url' => $url], JsonSerializerConfig::pretty())
);
} catch (AssetNotFoundException $e) {
return $this->errorResponse($e->getMessage(), Status::NOT_FOUND);
}
}
#[Route(path: '/api/v1/assets/{id}', method: Method::DELETE)]
public function delete(string $id): HttpResponse
{
try {
$this->assetService->delete(AssetId::fromString($id));
return new HttpResponse(Status::NO_CONTENT);
} catch (AssetNotFoundException $e) {
return $this->errorResponse($e->getMessage(), Status::NOT_FOUND);
}
}
#[Route(path: '/api/v1/assets', method: Method::GET)]
public function index(Request $request): HttpResponse
{
try {
$bucket = $request->query['bucket'] ?? null;
$tag = $request->query['tag'] ?? null;
$assets = [];
if ($tag !== null) {
$assets = $this->assetService->findByTag($tag);
} elseif ($bucket !== null) {
$assets = $this->assetService->findByBucket(BucketName::fromString($bucket));
}
$data = array_map(fn ($asset) => $this->serializeAsset($asset), $assets);
return new HttpResponse(
status: Status::OK,
headers: new Headers(['Content-Type' => 'application/json']),
body: $this->jsonSerializer->serializeWithConfig($data, JsonSerializerConfig::pretty())
);
} catch (\InvalidArgumentException $e) {
return $this->errorResponse($e->getMessage(), Status::BAD_REQUEST);
}
}
private function serializeAsset(
\App\Domain\Asset\Entities\Asset $asset,
array $variants = [],
array $tags = []
): array {
$variantData = array_map(fn ($v) => [
'variant' => $v->variant->toString(),
'url' => $this->storage->getUrl($this->createAssetFromVariant($v)),
], $variants);
$tagData = array_map(fn ($t) => $t->tag, $tags);
return [
'id' => $asset->id->toString(),
'bucket' => $asset->bucket->toString(),
'key' => $asset->key->toString(),
'mime' => $asset->mime->value,
'bytes' => $asset->bytes->bytes->toBytes(),
'sha256' => $asset->sha256->toString(),
'meta' => $asset->meta->toArray(),
'url' => $this->storage->getUrl($asset),
'variants' => $variantData,
'tags' => $tagData,
'created_at' => $asset->createdAt->toIso8601(),
];
}
private function serializeVariant(\App\Domain\Asset\Entities\AssetVariant $variant): array
{
$tempAsset = $this->createAssetFromVariant($variant);
return [
'asset_id' => $variant->assetId->toString(),
'variant' => $variant->variant->toString(),
'bucket' => $variant->bucket->toString(),
'key' => $variant->key->toString(),
'mime' => $variant->mime->value,
'bytes' => $variant->bytes->bytes->toBytes(),
'meta' => $variant->meta->toArray(),
'url' => $this->storage->getUrl($tempAsset),
];
}
private function createAssetFromVariant(\App\Domain\Asset\Entities\AssetVariant $variant): \App\Domain\Asset\Entities\Asset
{
return new \App\Domain\Asset\Entities\Asset(
id: $variant->assetId,
bucket: $variant->bucket,
key: $variant->key,
mime: $variant->mime,
bytes: $variant->bytes,
sha256: \App\Framework\Core\ValueObjects\Hash::fromString('0000000000000000000000000000000000000000000000000000000000000000', \App\Framework\Core\ValueObjects\HashAlgorithm::SHA256),
meta: $variant->meta,
createdAt: \App\Framework\Core\ValueObjects\Timestamp::now()
);
}
private function errorResponse(string $message, Status $status): HttpResponse
{
return new HttpResponse(
status: $status,
headers: new Headers(['Content-Type' => 'application/json']),
body: $this->jsonSerializer->serializeWithConfig(['error' => $message], JsonSerializerConfig::pretty())
);
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Application\Cms\Api\Requests;
use App\Framework\Validation\Attributes\MaxLength;
use App\Framework\Validation\Attributes\Required;
final class CreateContentRequest
{
#[Required]
#[MaxLength(255)]
public ?string $content_type_id = null;
#[MaxLength(255)]
public ?string $slug = null;
#[Required]
#[MaxLength(255)]
public ?string $title = null;
#[Required]
public ?array $blocks = null;
public ?array $meta_data = null;
public ?string $author_id = null;
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Application\Cms\Api\Requests;
use App\Framework\Validation\Attributes\MaxLength;
use App\Framework\Validation\Attributes\MinLength;
use App\Framework\Validation\Attributes\Pattern;
use App\Framework\Validation\Attributes\Required;
final class CreateContentTypeRequest
{
#[Required]
#[MinLength(1)]
#[MaxLength(255)]
public ?string $name = null;
#[Required]
#[MinLength(1)]
#[MaxLength(255)]
#[Pattern('^[a-z0-9_-]+$')]
public ?string $slug = null;
#[MaxLength(1000)]
public ?string $description = null;
public bool $is_system = false;
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Application\Cms\Api\Requests;
use App\Framework\Validation\Attributes\MaxLength;
final class UpdateContentRequest
{
#[MaxLength(255)]
public ?string $title = null;
#[MaxLength(255)]
public ?string $slug = null;
public ?array $blocks = null;
public ?array $meta_data = null;
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Application\Cms\Api\Requests;
use App\Framework\Validation\Attributes\MaxLength;
use App\Framework\Validation\Attributes\MinLength;
use App\Framework\Validation\Attributes\Pattern;
final class UpdateContentTypeRequest
{
#[MinLength(1)]
#[MaxLength(255)]
public ?string $name = null;
#[MinLength(1)]
#[MaxLength(255)]
#[Pattern('^[a-z0-9_-]+$')]
public ?string $slug = null;
#[MaxLength(1000)]
public ?string $description = null;
}

View File

@@ -0,0 +1,193 @@
<?php
declare(strict_types=1);
namespace App\Application\Cms\Api\V1;
use App\Application\Cms\Api\Requests\CreateContentTypeRequest;
use App\Application\Cms\Api\Requests\UpdateContentTypeRequest;
use App\Domain\Cms\Exceptions\CannotDeleteSystemContentTypeException;
use App\Domain\Cms\Exceptions\ContentTypeNotFoundException;
use App\Domain\Cms\Exceptions\DuplicateContentTypeSlugException;
use App\Domain\Cms\Services\ContentTypeService;
use App\Domain\Cms\ValueObjects\ContentTypeId;
use App\Framework\Attributes\Route;
use App\Framework\Exception\FrameworkException;
use App\Framework\Http\Headers;
use App\Framework\Http\HttpResponse;
use App\Framework\Http\Method;
use App\Framework\Http\Request;
use App\Framework\Http\Status;
use App\Framework\Serializer\Json\JsonSerializer;
use App\Framework\Serializer\Json\JsonSerializerConfig;
final readonly class ContentTypesController
{
public function __construct(
private ContentTypeService $contentTypeService,
private JsonSerializer $jsonSerializer
) {
}
#[Route(path: '/api/v1/cms/content-types', method: Method::GET)]
public function index(Request $request): HttpResponse
{
$contentTypes = $this->contentTypeService->findAll();
$data = array_map(fn ($contentType) => $this->serializeContentType($contentType), $contentTypes);
return new HttpResponse(
status: Status::OK,
headers: new Headers(['Content-Type' => 'application/json']),
body: $this->jsonSerializer->serializeWithConfig($data, JsonSerializerConfig::pretty())
);
}
#[Route(path: '/api/v1/cms/content-types/{id}', method: Method::GET)]
public function show(Request $request): HttpResponse
{
try {
$id = ContentTypeId::fromString($request->queryParams['id'] ?? '');
$contentType = $this->contentTypeService->findById($id);
$data = $this->serializeContentType($contentType);
return new HttpResponse(
status: Status::OK,
headers: new Headers(['Content-Type' => 'application/json']),
body: $this->jsonSerializer->serializeWithConfig($data, JsonSerializerConfig::pretty())
);
} catch (ContentTypeNotFoundException $e) {
return $this->errorResponse($e->getMessage(), Status::NOT_FOUND);
} catch (\InvalidArgumentException $e) {
return $this->errorResponse($e->getMessage(), Status::BAD_REQUEST);
}
}
#[Route(path: '/api/v1/cms/content-types', method: Method::POST)]
public function create(Request $request, CreateContentTypeRequest $createRequest): HttpResponse
{
try {
$requestData = $this->jsonSerializer->deserialize($request->body);
if (! is_array($requestData)) {
return $this->errorResponse('Invalid request body', Status::BAD_REQUEST);
}
// Map request data to CreateContentTypeRequest
$createRequest->name = $requestData['name'] ?? null;
$createRequest->slug = $requestData['slug'] ?? null;
$createRequest->description = $requestData['description'] ?? null;
$createRequest->is_system = $requestData['is_system'] ?? false;
// Validate required fields
if ($createRequest->name === null || $createRequest->slug === null) {
return $this->errorResponse('name and slug are required', Status::BAD_REQUEST);
}
$contentType = $this->contentTypeService->create(
name: $createRequest->name,
slug: $createRequest->slug,
description: $createRequest->description,
isSystem: $createRequest->is_system
);
$data = $this->serializeContentType($contentType);
return new HttpResponse(
status: Status::CREATED,
headers: new Headers(['Content-Type' => 'application/json']),
body: $this->jsonSerializer->serializeWithConfig($data, JsonSerializerConfig::pretty())
);
} catch (DuplicateContentTypeSlugException $e) {
return $this->errorResponse($e->getMessage(), Status::CONFLICT);
} catch (FrameworkException $e) {
return $this->errorResponse($e->getMessage(), Status::BAD_REQUEST);
} catch (\InvalidArgumentException $e) {
return $this->errorResponse($e->getMessage(), Status::BAD_REQUEST);
}
}
#[Route(path: '/api/v1/cms/content-types/{id}', method: Method::PUT)]
public function update(Request $request, UpdateContentTypeRequest $updateRequest): HttpResponse
{
try {
$id = ContentTypeId::fromString($request->queryParams['id'] ?? '');
$requestData = $this->jsonSerializer->deserialize($request->body);
if (! is_array($requestData)) {
return $this->errorResponse('Invalid request body', Status::BAD_REQUEST);
}
// Map request data to UpdateContentTypeRequest
$updateRequest->name = $requestData['name'] ?? null;
$updateRequest->slug = $requestData['slug'] ?? null;
$updateRequest->description = $requestData['description'] ?? null;
$contentType = $this->contentTypeService->update(
id: $id,
name: $updateRequest->name,
slug: $updateRequest->slug,
description: $updateRequest->description
);
$data = $this->serializeContentType($contentType);
return new HttpResponse(
status: Status::OK,
headers: new Headers(['Content-Type' => 'application/json']),
body: $this->jsonSerializer->serializeWithConfig($data, JsonSerializerConfig::pretty())
);
} catch (ContentTypeNotFoundException $e) {
return $this->errorResponse($e->getMessage(), Status::NOT_FOUND);
} catch (DuplicateContentTypeSlugException $e) {
return $this->errorResponse($e->getMessage(), Status::CONFLICT);
} catch (FrameworkException $e) {
return $this->errorResponse($e->getMessage(), Status::BAD_REQUEST);
} catch (\InvalidArgumentException $e) {
return $this->errorResponse($e->getMessage(), Status::BAD_REQUEST);
}
}
#[Route(path: '/api/v1/cms/content-types/{id}', method: Method::DELETE)]
public function delete(Request $request): HttpResponse
{
try {
$id = ContentTypeId::fromString($request->queryParams['id'] ?? '');
$this->contentTypeService->delete($id);
return new HttpResponse(
status: Status::NO_CONTENT,
headers: new Headers(['Content-Type' => 'application/json'])
);
} catch (ContentTypeNotFoundException $e) {
return $this->errorResponse($e->getMessage(), Status::NOT_FOUND);
} catch (CannotDeleteSystemContentTypeException $e) {
return $this->errorResponse($e->getMessage(), Status::BAD_REQUEST);
} catch (\InvalidArgumentException $e) {
return $this->errorResponse($e->getMessage(), Status::BAD_REQUEST);
}
}
/**
* @return array<string, mixed>
*/
private function serializeContentType(\App\Domain\Cms\Entities\ContentType $contentType): array
{
return [
'id' => $contentType->id->toString(),
'name' => $contentType->name,
'slug' => $contentType->slug,
'description' => $contentType->description,
'is_system' => $contentType->isSystem,
'created_at' => $contentType->createdAt->format('Y-m-d\TH:i:s\Z'),
'updated_at' => $contentType->updatedAt->format('Y-m-d\TH:i:s\Z'),
];
}
private function errorResponse(string $message, Status $status): HttpResponse
{
return new HttpResponse(
status: $status,
headers: new Headers(['Content-Type' => 'application/json']),
body: $this->jsonSerializer->serializeWithConfig(['error' => $message], JsonSerializerConfig::pretty())
);
}
}

View File

@@ -0,0 +1,260 @@
<?php
declare(strict_types=1);
namespace App\Application\Cms\Api\V1;
use App\Application\Cms\Api\Requests\CreateContentRequest;
use App\Application\Cms\Api\Requests\UpdateContentRequest;
use App\Domain\Cms\Enums\ContentStatus;
use App\Domain\Cms\Services\ContentService;
use App\Domain\Cms\ValueObjects\BlockData;
use App\Domain\Cms\ValueObjects\ContentBlocks;
use App\Domain\Cms\ValueObjects\ContentId;
use App\Domain\Cms\ValueObjects\ContentSlug;
use App\Domain\Cms\ValueObjects\ContentTypeId;
use App\Domain\Cms\ValueObjects\Locale;
use App\Framework\Attributes\Route;
use App\Framework\Exception\FrameworkException;
use App\Framework\Http\Headers;
use App\Framework\Http\HttpResponse;
use App\Framework\Http\Method;
use App\Framework\Http\Request;
use App\Framework\Http\Status;
use App\Framework\Serializer\Json\JsonSerializer;
use App\Framework\Serializer\Json\JsonSerializerConfig;
final readonly class ContentsController
{
public function __construct(
private ContentService $contentService,
private JsonSerializer $jsonSerializer
) {
}
#[Route(path: '/api/v1/cms/contents', method: Method::GET)]
public function index(Request $request): HttpResponse
{
$contentTypeId = $request->queryParams['content_type_id'] ?? null;
$status = $request->queryParams['status'] ?? null;
$contents = [];
if ($contentTypeId !== null) {
$typeId = ContentTypeId::fromString($contentTypeId);
$contentStatus = $status !== null ? ContentStatus::from($status) : null;
$contents = $this->contentService->findByType($typeId, $contentStatus);
} else {
$contents = $status === ContentStatus::PUBLISHED->value
? $this->contentService->findPublished()
: [];
}
$data = array_map(fn ($content) => $this->serializeContent($content), $contents);
return new HttpResponse(
status: Status::OK,
headers: new Headers(['Content-Type' => 'application/json']),
body: $this->jsonSerializer->serializeWithConfig($data, JsonSerializerConfig::pretty())
);
}
#[Route(path: '/api/v1/cms/contents/{id}', method: Method::GET)]
public function show(Request $request): HttpResponse
{
try {
$id = ContentId::fromString($request->queryParams['id'] ?? '');
$content = $this->contentService->findById($id);
$data = $this->serializeContent($content);
return new HttpResponse(
status: Status::OK,
headers: new Headers(['Content-Type' => 'application/json']),
body: $this->jsonSerializer->serializeWithConfig($data, JsonSerializerConfig::pretty())
);
} catch (FrameworkException $e) {
return $this->errorResponse($e->getMessage(), Status::NOT_FOUND);
}
}
#[Route(path: '/api/v1/cms/contents', method: Method::POST)]
public function create(Request $request, CreateContentRequest $createRequest): HttpResponse
{
try {
$requestData = $this->jsonSerializer->deserialize($request->body);
if (! is_array($requestData)) {
return $this->errorResponse('Invalid request body', Status::BAD_REQUEST);
}
// Map request data to CreateContentRequest
$createRequest->content_type_id = $requestData['content_type_id'] ?? null;
$createRequest->title = $requestData['title'] ?? null;
$createRequest->slug = $requestData['slug'] ?? null;
$createRequest->blocks = $requestData['blocks'] ?? null;
$createRequest->meta_data = $requestData['meta_data'] ?? null;
$createRequest->author_id = $requestData['author_id'] ?? null;
// Validate required fields
if ($createRequest->content_type_id === null || $createRequest->title === null || $createRequest->blocks === null) {
return $this->errorResponse('content_type_id, title, and blocks are required', Status::BAD_REQUEST);
}
$contentTypeId = ContentTypeId::fromString($createRequest->content_type_id);
$blocks = ContentBlocks::fromArray($createRequest->blocks);
$slug = $createRequest->slug !== null ? ContentSlug::fromString($createRequest->slug) : null;
$metaData = $createRequest->meta_data !== null ? BlockData::fromArray($createRequest->meta_data) : null;
$defaultLocale = isset($requestData['default_locale'])
? Locale::fromString($requestData['default_locale'])
: Locale::english();
$content = $this->contentService->create(
contentTypeId: $contentTypeId,
title: $createRequest->title,
blocks: $blocks,
defaultLocale: $defaultLocale,
slug: $slug,
authorId: $createRequest->author_id,
metaData: $metaData
);
$data = $this->serializeContent($content);
return new HttpResponse(
status: Status::CREATED,
headers: new Headers(['Content-Type' => 'application/json']),
body: $this->jsonSerializer->serializeWithConfig($data, JsonSerializerConfig::pretty())
);
} catch (FrameworkException $e) {
return $this->errorResponse($e->getMessage(), Status::BAD_REQUEST);
}
}
#[Route(path: '/api/v1/cms/contents/{id}', method: Method::PUT)]
public function update(Request $request, UpdateContentRequest $updateRequest): HttpResponse
{
try {
$id = ContentId::fromString($request->queryParams['id'] ?? '');
$requestData = $this->jsonSerializer->deserialize($request->body);
if (! is_array($requestData)) {
return $this->errorResponse('Invalid request body', Status::BAD_REQUEST);
}
// Map request data
$updateRequest->title = $requestData['title'] ?? null;
$updateRequest->slug = $requestData['slug'] ?? null;
$updateRequest->blocks = $requestData['blocks'] ?? null;
$updateRequest->meta_data = $requestData['meta_data'] ?? null;
$content = $this->contentService->findById($id);
if ($updateRequest->title !== null) {
$content = $this->contentService->updateTitle($id, $updateRequest->title);
}
if ($updateRequest->slug !== null) {
$content = $this->contentService->updateSlug($id, ContentSlug::fromString($updateRequest->slug));
}
if ($updateRequest->blocks !== null) {
$content = $this->contentService->updateBlocks($id, ContentBlocks::fromArray($updateRequest->blocks));
}
if ($updateRequest->meta_data !== null) {
$content = $this->contentService->updateMetaData($id, BlockData::fromArray($updateRequest->meta_data));
}
$data = $this->serializeContent($content);
return new HttpResponse(
status: Status::OK,
headers: new Headers(['Content-Type' => 'application/json']),
body: $this->jsonSerializer->serializeWithConfig($data, JsonSerializerConfig::pretty())
);
} catch (FrameworkException $e) {
return $this->errorResponse($e->getMessage(), Status::BAD_REQUEST);
}
}
#[Route(path: '/api/v1/cms/contents/{id}', method: Method::DELETE)]
public function delete(Request $request): HttpResponse
{
try {
$id = ContentId::fromString($request->queryParams['id'] ?? '');
$this->contentService->delete($id);
return new HttpResponse(
status: Status::NO_CONTENT,
headers: new Headers(['Content-Type' => 'application/json']),
body: ''
);
} catch (FrameworkException $e) {
return $this->errorResponse($e->getMessage(), Status::NOT_FOUND);
}
}
#[Route(path: '/api/v1/cms/contents/{id}/publish', method: Method::POST)]
public function publish(Request $request): HttpResponse
{
try {
$id = ContentId::fromString($request->queryParams['id'] ?? '');
$content = $this->contentService->publish($id);
$data = $this->serializeContent($content);
return new HttpResponse(
status: Status::OK,
headers: new Headers(['Content-Type' => 'application/json']),
body: $this->jsonSerializer->serializeWithConfig($data, JsonSerializerConfig::pretty())
);
} catch (FrameworkException $e) {
return $this->errorResponse($e->getMessage(), Status::BAD_REQUEST);
}
}
#[Route(path: '/api/v1/cms/contents/{id}/unpublish', method: Method::POST)]
public function unpublish(Request $request): HttpResponse
{
try {
$id = ContentId::fromString($request->queryParams['id'] ?? '');
$content = $this->contentService->unpublish($id);
$data = $this->serializeContent($content);
return new HttpResponse(
status: Status::OK,
headers: new Headers(['Content-Type' => 'application/json']),
body: $this->jsonSerializer->serializeWithConfig($data, JsonSerializerConfig::pretty())
);
} catch (FrameworkException $e) {
return $this->errorResponse($e->getMessage(), Status::BAD_REQUEST);
}
}
private function serializeContent($content): array
{
return [
'id' => $content->id->toString(),
'content_type_id' => $content->contentTypeId->toString(),
'slug' => $content->slug->toString(),
'title' => $content->title,
'blocks' => $content->blocks->toArray(),
'meta_data' => $content->metaData?->toArray(),
'status' => $content->status->value,
'author_id' => $content->authorId,
'default_locale' => $content->defaultLocale->toString(),
'published_at' => $content->publishedAt?->format('Y-m-d\TH:i:s\Z'),
'created_at' => $content->createdAt->format('Y-m-d\TH:i:s\Z'),
'updated_at' => $content->updatedAt->format('Y-m-d\TH:i:s\Z'),
];
}
private function errorResponse(string $message, Status $status): HttpResponse
{
return new HttpResponse(
status: $status,
headers: new Headers(['Content-Type' => 'application/json']),
body: $this->jsonSerializer->serialize([
'error' => $message,
])
);
}
}

View File

@@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
namespace App\Application\Console\Commands\Design;
use App\Application\Admin\ValueObjects\AdminTokenRegistry;
use App\Framework\Design\Repositories\DatabaseTokenRepository;
use App\Framework\Design\ValueObjects\FrameworkTokenRegistry;
use App\Framework\Design\ValueObjects\HybridTokenRegistry;
use App\Framework\Design\ValueObjects\TokenRegistry;
use App\Framework\Console\CommandGroup;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\ExitCode;
use App\Framework\Filesystem\ValueObjects\FilePath;
#[CommandGroup(
name: 'Design',
description: 'Design system and token management commands',
icon: '🎨',
priority: 70
)]
final readonly class GenerateTokensCommand
{
public function __construct(
private AdminTokenRegistry $adminTokenRegistry,
private FrameworkTokenRegistry $frameworkTokenRegistry,
private ?DatabaseTokenRepository $dbRepository = null
) {
}
#[ConsoleCommand('design:generate-tokens', 'Generate CSS file from PHP design tokens')]
public function generateTokens(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$output->writeLine('🎨 Generating Design Tokens CSS...', ConsoleColor::BRIGHT_CYAN);
$output->newLine();
// Parse options
$scope = $input->getOption('scope') ?? 'admin'; // 'framework' or 'admin'
$format = $input->getOption('format') ?? 'itcss'; // 'itcss' or 'cube'
$outputPathOption = $input->getOption('output');
$useDb = $input->getOption('use-db') === true || $input->getOption('use-db') === 'true';
// Select base token registry based on scope
$baseRegistry = match($scope) {
'framework' => $this->frameworkTokenRegistry,
'admin' => $this->adminTokenRegistry,
default => throw new \InvalidArgumentException("Invalid scope: {$scope}. Use 'framework' or 'admin'.")
};
// Use hybrid registry if DB is enabled and repository is available
$tokenRegistry = ($useDb && $this->dbRepository !== null)
? new HybridTokenRegistry($baseRegistry, $this->dbRepository)
: $baseRegistry;
// Determine output path
$outputPath = $this->determineOutputPath($scope, $format, $outputPathOption);
// Ensure directory exists
$directoryPath = $outputPath->getDirectory()->toString();
if (! is_dir($directoryPath)) {
if (! mkdir($directoryPath, 0755, true) && ! is_dir($directoryPath)) {
$output->writeLine(
sprintf('❌ Failed to create directory: %s', $directoryPath),
ConsoleColor::RED
);
return ExitCode::IO_ERROR;
}
}
// Generate CSS content
$css = $this->generateCssContent($tokenRegistry, $scope, $format);
// Write file
$bytesWritten = file_put_contents($outputPath->toString(), $css);
if ($bytesWritten === false) {
$output->writeLine(
sprintf('❌ Failed to write file: %s', $outputPath->toString()),
ConsoleColor::RED
);
return ExitCode::IO_ERROR;
}
// Calculate statistics
$tokenCount = count($tokenRegistry->all());
$fileSize = $bytesWritten;
$fileSizeFormatted = $this->formatBytes($fileSize);
// Success message
$output->writeLine('✅ Design tokens generated successfully!', ConsoleColor::GREEN);
$output->newLine();
$output->writeLine('📊 Summary:', ConsoleColor::BRIGHT_CYAN);
$output->writeLine(sprintf(' • Scope: %s', $scope));
$output->writeLine(sprintf(' • Format: %s', strtoupper($format)));
$output->writeLine(sprintf(' • Database: %s', $useDb && $this->dbRepository !== null ? 'Yes' : 'No'));
$output->writeLine(sprintf(' • Output file: %s', $outputPath->toString()));
$output->writeLine(sprintf(' • Tokens generated: %d', $tokenCount));
$output->writeLine(sprintf(' • File size: %s', $fileSizeFormatted));
$output->newLine();
$output->writeLine(
'💡 This file is auto-generated. Do not edit manually.',
ConsoleColor::YELLOW
);
return ExitCode::SUCCESS;
}
/**
* Determine output path based on scope and format
*/
private function determineOutputPath(string $scope, string $format, ?string $customPath): FilePath
{
if ($customPath) {
return FilePath::create($customPath);
}
// Default paths based on scope and format
return match([$scope, $format]) {
['framework', 'itcss'] => FilePath::create('resources/css/settings/_generated-tokens.css'),
['framework', 'cube'] => FilePath::create('resources/css/settings/_generated-tokens.css'),
['admin', 'itcss'] => FilePath::create('resources/css/admin/01-settings/_generated-tokens.css'),
['admin', 'cube'] => FilePath::create('resources/css/admin/settings/_generated-tokens.css'),
default => FilePath::create('resources/css/admin/01-settings/_generated-tokens.css'),
};
}
/**
* Generate complete CSS content with header
*/
private function generateCssContent(TokenRegistry $tokenRegistry, string $scope, string $format): string
{
$timestamp = date('Y-m-d H:i:s');
$sourceClass = $scope === 'framework'
? 'FrameworkTokenRegistry'
: 'AdminTokenRegistry';
$sourcePath = $scope === 'framework'
? 'src/Framework/Design/ValueObjects/FrameworkTokenRegistry.php'
: 'src/Application/Admin/ValueObjects/AdminTokenRegistry.php';
$header = <<<'CSS'
/**
* Generated Design Tokens - DO NOT EDIT MANUALLY
*
* This file is automatically generated from {SOURCE_CLASS}.
* To modify tokens, edit: {SOURCE_PATH}
*
* Generated: {TIMESTAMP}
* Source: {SOURCE_CLASS}
* Scope: {SCOPE}
* Architecture: {ARCHITECTURE}
*/
CSS;
$header = str_replace('{TIMESTAMP}', $timestamp, $header);
$header = str_replace('{SOURCE_CLASS}', $sourceClass, $header);
$header = str_replace('{SOURCE_PATH}', $sourcePath, $header);
$header = str_replace('{SCOPE}', $scope, $header);
$header = str_replace('{ARCHITECTURE}', strtoupper($format), $header);
$layerName = $scope === 'framework' ? 'framework-settings' : 'admin-settings';
$css = $tokenRegistry->toCss($layerName, includeHdr: true);
return $header . $css;
}
/**
* Format bytes to human-readable format
*/
private function formatBytes(int $bytes): string
{
$units = ['B', 'KB', 'MB', 'GB'];
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
$bytes /= (1 << (10 * $pow));
return round($bytes, 2) . ' ' . $units[$pow];
}
}

View File

@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace App\Application\Console\Commands\Design;
use App\Application\Admin\ValueObjects\AdminTokenRegistry;
use App\Framework\Design\Linters\TokenLinter;
use App\Framework\Design\ValueObjects\FrameworkTokenRegistry;
use App\Framework\Design\ValueObjects\TokenRegistry;
use App\Framework\Console\CommandGroup;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\ExitCode;
#[CommandGroup(
name: 'Design',
description: 'Design system and token management commands',
icon: '🎨',
priority: 70
)]
final readonly class LintTokensCommand
{
public function __construct(
private AdminTokenRegistry $adminTokenRegistry,
private FrameworkTokenRegistry $frameworkTokenRegistry,
private TokenLinter $linter
) {
}
#[ConsoleCommand('design:lint-tokens', 'Lint design tokens for code quality')]
public function lintTokens(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$output->writeLine('🔍 Linting Design Tokens...', ConsoleColor::BRIGHT_CYAN);
$output->newLine();
$scope = $input->getOption('scope') ?? 'all'; // 'framework', 'admin', or 'all'
$usedTokens = $input->getOption('used-tokens') ? explode(',', $input->getOption('used-tokens')) : [];
$results = [];
if ($scope === 'all' || $scope === 'framework') {
$output->writeLine('Linting framework tokens...', ConsoleColor::CYAN);
$results['framework'] = $this->linter->lintRegistry($this->frameworkTokenRegistry, $usedTokens);
}
if ($scope === 'all' || $scope === 'admin') {
$output->writeLine('Linting admin tokens...', ConsoleColor::CYAN);
$results['admin'] = $this->linter->lintRegistry($this->adminTokenRegistry, $usedTokens);
}
$output->newLine();
// Display results
$hasIssues = false;
foreach ($results as $scopeName => $result) {
$output->writeLine("📊 {$scopeName} tokens:", ConsoleColor::BRIGHT_CYAN);
if (! $result->hasIssues()) {
$output->writeLine(' ✅ No linting issues found!', ConsoleColor::GREEN);
} else {
$hasIssues = true;
$issuesBySeverity = $result->getIssuesBySeverity();
foreach (['error', 'warning', 'info'] as $severity) {
if (! isset($issuesBySeverity[$severity])) {
continue;
}
$color = match($severity) {
'error' => ConsoleColor::RED,
'warning' => ConsoleColor::YELLOW,
default => ConsoleColor::CYAN
};
$icon = match($severity) {
'error' => '❌',
'warning' => '⚠️',
default => ''
};
$issueCount = count($issuesBySeverity[$severity]);
$output->writeLine(" {$icon} {$severity} ({$issueCount}):", $color);
foreach ($issuesBySeverity[$severity] as $issue) {
$output->writeLine("{$issue->toString()}", $color);
}
}
}
$output->newLine();
}
// Summary
$output->writeLine('📋 Summary:', ConsoleColor::BRIGHT_CYAN);
foreach ($results as $scopeName => $result) {
$output->writeLine("{$scopeName}: {$result->getSummary()}");
}
$output->newLine();
if ($hasIssues) {
$output->writeLine('⚠️ Linting found issues!', ConsoleColor::YELLOW);
return ExitCode::VALIDATION_ERROR;
}
$output->writeLine('✅ Linting passed!', ConsoleColor::GREEN);
return ExitCode::SUCCESS;
}
}

View File

@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Application\Console\Commands\Design;
use App\Application\Admin\ValueObjects\AdminTokenRegistry;
use App\Framework\Design\Validators\TokenValidator;
use App\Framework\Design\ValueObjects\FrameworkTokenRegistry;
use App\Framework\Design\ValueObjects\TokenRegistry;
use App\Framework\Console\CommandGroup;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\ExitCode;
#[CommandGroup(
name: 'Design',
description: 'Design system and token management commands',
icon: '🎨',
priority: 70
)]
final readonly class ValidateTokensCommand
{
public function __construct(
private AdminTokenRegistry $adminTokenRegistry,
private FrameworkTokenRegistry $frameworkTokenRegistry,
private TokenValidator $validator
) {
}
#[ConsoleCommand('design:validate-tokens', 'Validate design tokens for correctness')]
public function validateTokens(ConsoleInput $input, ConsoleOutputInterface $output): ExitCode
{
$output->writeLine('🔍 Validating Design Tokens...', ConsoleColor::BRIGHT_CYAN);
$output->newLine();
$scope = $input->getOption('scope') ?? 'all'; // 'framework', 'admin', or 'all'
$results = [];
if ($scope === 'all' || $scope === 'framework') {
$output->writeLine('Validating framework tokens...', ConsoleColor::CYAN);
$results['framework'] = $this->validator->validateRegistry($this->frameworkTokenRegistry);
}
if ($scope === 'all' || $scope === 'admin') {
$output->writeLine('Validating admin tokens...', ConsoleColor::CYAN);
$results['admin'] = $this->validator->validateRegistry($this->adminTokenRegistry);
}
$output->newLine();
// Display results
$hasErrors = false;
foreach ($results as $scopeName => $result) {
$output->writeLine("📊 {$scopeName} tokens:", ConsoleColor::BRIGHT_CYAN);
if (empty($result->errors) && empty($result->warnings)) {
$output->writeLine(' ✅ All tokens are valid!', ConsoleColor::GREEN);
} else {
if (! empty($result->errors)) {
$hasErrors = true;
$errorCount = count($result->errors);
$output->writeLine(" ❌ Errors ({$errorCount}):", ConsoleColor::RED);
foreach ($result->errors as $error) {
$output->writeLine("{$error->toString()}", ConsoleColor::RED);
}
}
if (! empty($result->warnings)) {
$warningCount = count($result->warnings);
$output->writeLine(" ⚠️ Warnings ({$warningCount}):", ConsoleColor::YELLOW);
foreach ($result->warnings as $warning) {
$output->writeLine("{$warning->toString()}", ConsoleColor::YELLOW);
}
}
}
$output->newLine();
}
// Summary
$output->writeLine('📋 Summary:', ConsoleColor::BRIGHT_CYAN);
foreach ($results as $scopeName => $result) {
$output->writeLine("{$scopeName}: {$result->getSummary()}");
}
$output->newLine();
if ($hasErrors) {
$output->writeLine('❌ Validation failed!', ConsoleColor::RED);
return ExitCode::VALIDATION_ERROR;
}
$output->writeLine('✅ Validation passed!', ConsoleColor::GREEN);
return ExitCode::SUCCESS;
}
}

View File

@@ -6,6 +6,7 @@ namespace App\Application\Contact;
use App\Framework\Attributes\Route;
use App\Framework\CommandBus\CommandBus;
use App\Framework\Display\Formatters\AutoFormatter;
use App\Framework\Http\Method;
use App\Framework\Meta\MetaData;
use App\Framework\Meta\StaticPageMetaResolver;
@@ -16,12 +17,14 @@ use App\Framework\Router\ActionResult;
use App\Framework\Router\Result\Redirect;
use App\Framework\Router\Result\ViewResult;
use App\Framework\Router\WebRoutes;
use App\Framework\SyntaxHighlighter\SyntaxHighlighter;
final readonly class ShowContact
{
#[Route(path: '/kontakt', name: WebRoutes::CONTACT)]
public function __invoke(): ViewResult
{
throw new \Exception((string)time());
return new ViewResult(
'contact',
new StaticPageMetaResolver(

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Application\LiveComponents\Dashboard;
use App\Framework\LiveComponents\Attributes\Action;
use App\Framework\LiveComponents\Attributes\LiveComponent;
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
use App\Framework\LiveComponents\Contracts\Pollable;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\LiveComponents\ValueObjects\ComponentRenderData;
use App\Framework\Process\Services\DockerService;
/**
* Docker Containers LiveComponent
*
* Displays real-time Docker container status and metrics:
* - Docker daemon status
* - Container list with status
* - Container statistics (CPU, memory)
*
* Polls every 5 seconds for real-time monitoring.
*/
#[LiveComponent('docker-containers')]
final readonly class DockerContainersComponent implements LiveComponentContract, Pollable
{
public function __construct(
public ComponentId $id,
public DockerContainersState $state,
private DockerService $dockerService
) {}
public function getRenderData(): ComponentRenderData
{
// Initial poll if state is empty (lastUpdated is empty string)
$state = $this->state->lastUpdated === ''
? $this->poll()
: $this->state;
return new ComponentRenderData(
templatePath: 'livecomponent-docker-containers',
data: [
// LiveComponent integration
'componentId' => $this->id->toString(),
'stateJson' => json_encode($state->toArray()),
'pollInterval' => $this->getPollInterval(),
// Component data
'isRunning' => $state->isRunning,
'dockerVersion' => $state->dockerVersion,
'totalContainers' => $state->totalContainers,
'runningContainers' => $state->runningContainers,
'stoppedContainers' => $state->stoppedContainers,
'containers' => $state->containers,
'lastUpdated' => $state->lastUpdated,
]
);
}
#[Action]
public function poll(): DockerContainersState
{
$containers = $this->dockerService->listContainers(all: true);
$dockerVersion = $this->dockerService->getVersion();
$isRunning = $this->dockerService->isRunning();
// Get stats for running containers
$containerStats = [];
foreach ($containers as $container) {
if ($container->isRunning) {
$stats = $this->dockerService->getContainerStats($container->id);
if ($stats !== null) {
$containerStats[$container->id] = $stats->toArray();
}
}
}
// Prepare container data
$containerData = [];
foreach ($containers as $container) {
$stats = $containerStats[$container->id] ?? null;
$containerData[] = [
'id' => $container->id,
'name' => $container->name,
'image' => $container->image,
'status' => $container->status,
'is_running' => $container->isRunning,
'stats' => $stats,
];
}
return $this->state->withContainers(
isRunning: $isRunning,
dockerVersion: $dockerVersion,
containers: $containerData
);
}
public function getPollInterval(): int
{
return 5000; // Poll every 5 seconds for real-time updates
}
}

View File

@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace App\Application\LiveComponents\Dashboard;
use App\Application\LiveComponents\LiveComponentState;
/**
* Type-safe state for DockerContainersComponent
*
* Provides real-time Docker container status and metrics.
* Immutable value object - transformations return new instances.
*/
final readonly class DockerContainersState implements LiveComponentState
{
/**
* @param array<int, array{id: string, name: string, image: string, status: string, is_running: bool, stats: array<string, mixed>|null}> $containers
*/
public function __construct(
public bool $isRunning = false,
public string $dockerVersion = '',
public int $totalContainers = 0,
public int $runningContainers = 0,
public int $stoppedContainers = 0,
public array $containers = [],
public string $lastUpdated = ''
) {}
/**
* Create from array data (from client or storage)
*/
public static function fromArray(array $data): self
{
return new self(
isRunning: (bool) ($data['isRunning'] ?? $data['is_running'] ?? false),
dockerVersion: (string) ($data['dockerVersion'] ?? $data['docker_version'] ?? ''),
totalContainers: (int) ($data['totalContainers'] ?? $data['total_containers'] ?? 0),
runningContainers: (int) ($data['runningContainers'] ?? $data['running_containers'] ?? 0),
stoppedContainers: (int) ($data['stoppedContainers'] ?? $data['stopped_containers'] ?? 0),
containers: (array) ($data['containers'] ?? []),
lastUpdated: (string) ($data['lastUpdated'] ?? $data['last_updated'] ?? '')
);
}
/**
* Create empty state with defaults
*/
public static function empty(): self
{
return new self();
}
/**
* Update with fresh Docker container data
*
* @param array<int, array{id: string, name: string, image: string, status: string, is_running: bool, stats: array<string, mixed>|null}> $containers
*/
public function withContainers(
bool $isRunning,
string $dockerVersion,
array $containers
): self {
$runningCount = count(array_filter($containers, fn($c) => $c['is_running'] ?? false));
$stoppedCount = count($containers) - $runningCount;
return new self(
isRunning: $isRunning,
dockerVersion: $dockerVersion,
totalContainers: count($containers),
runningContainers: $runningCount,
stoppedContainers: $stoppedCount,
containers: $containers,
lastUpdated: date('H:i:s')
);
}
/**
* Update timestamp
*/
public function withLastUpdated(string $timestamp): self
{
return new self(
isRunning: $this->isRunning,
dockerVersion: $this->dockerVersion,
totalContainers: $this->totalContainers,
runningContainers: $this->runningContainers,
stoppedContainers: $this->stoppedContainers,
containers: $this->containers,
lastUpdated: $timestamp
);
}
/**
* Convert to array for template rendering
*/
public function toArray(): array
{
return [
'isRunning' => $this->isRunning,
'dockerVersion' => $this->dockerVersion,
'totalContainers' => $this->totalContainers,
'runningContainers' => $this->runningContainers,
'stoppedContainers' => $this->stoppedContainers,
'containers' => $this->containers,
'lastUpdated' => $this->lastUpdated,
];
}
}

View File

@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Application\LiveComponents\Dashboard;
use App\Framework\Health\HealthCheckManager;
use App\Framework\LiveComponents\Attributes\Action;
use App\Framework\LiveComponents\Attributes\LiveComponent;
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
use App\Framework\LiveComponents\Contracts\Pollable;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\LiveComponents\ValueObjects\ComponentRenderData;
use App\Framework\View\Table\Generators\HealthCheckTableGenerator;
/**
* Health Status LiveComponent
*
* Displays real-time system health status:
* - Overall health status
* - Individual health check results
* - Health check statistics
*
* Polls every 5 seconds for real-time monitoring.
*/
#[LiveComponent('health-status')]
final readonly class HealthStatusComponent implements LiveComponentContract, Pollable
{
public function __construct(
public ComponentId $id,
public HealthStatusState $state,
private HealthCheckManager $healthManager,
private HealthCheckTableGenerator $tableGenerator
) {}
public function getRenderData(): ComponentRenderData
{
// Initial poll if state is empty (lastUpdated is empty string)
$state = $this->state->lastUpdated === ''
? $this->poll()
: $this->state;
// Generate table for health checks
$healthCheckTable = $this->tableGenerator->generate($state->healthChecks);
return new ComponentRenderData(
templatePath: 'livecomponent-health-status',
data: [
// LiveComponent integration
'componentId' => $this->id->toString(),
'stateJson' => json_encode($state->toArray()),
'pollInterval' => $this->getPollInterval(),
// Component data
'overallStatus' => $state->overallStatus,
'totalChecks' => $state->totalChecks,
'healthyChecks' => $state->healthyChecks,
'warningChecks' => $state->warningChecks,
'failedChecks' => $state->failedChecks,
'healthChecks' => $state->healthChecks,
'healthCheckTable' => $healthCheckTable,
'lastUpdated' => $state->lastUpdated,
]
);
}
#[Action]
public function poll(): HealthStatusState
{
// Run all health checks
$healthReport = $this->healthManager->runAllChecks();
// Convert HealthReport to array format for template
$healthChecks = [];
foreach ($healthReport->results as $componentName => $result) {
$statusText = match ($result->status->value) {
'healthy' => 'healthy',
'warning' => 'warning',
'unhealthy' => 'unhealthy',
default => 'unknown',
};
$statusClass = match ($result->status->value) {
'healthy' => 'success',
'warning' => 'warning',
'unhealthy' => 'danger',
default => 'secondary',
};
$healthChecks[] = [
'componentName' => $componentName,
'status' => $result->status->value,
'statusText' => $statusText,
'statusClass' => $statusClass,
'message' => $result->message->toString(),
'responseTime' => $result->responseTime?->toString(),
];
}
return $this->state->withHealthChecks(
overallStatus: $healthReport->overallStatus->value,
healthChecks: $healthChecks
);
}
public function getPollInterval(): int
{
return 5000; // Poll every 5 seconds for real-time updates
}
}

View File

@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace App\Application\LiveComponents\Dashboard;
use App\Application\LiveComponents\LiveComponentState;
/**
* Type-safe state for HealthStatusComponent
*
* Provides real-time health check status and metrics.
* Immutable value object - transformations return new instances.
*/
final readonly class HealthStatusState implements LiveComponentState
{
/**
* @param array<int, array{componentName: string, status: string, statusText: string, statusClass: string, message: string, responseTime: string|null}> $healthChecks
*/
public function __construct(
public string $overallStatus = 'HEALTHY',
public int $totalChecks = 0,
public int $healthyChecks = 0,
public int $warningChecks = 0,
public int $failedChecks = 0,
public array $healthChecks = [],
public string $lastUpdated = ''
) {}
/**
* Create from array data (from client or storage)
*/
public static function fromArray(array $data): self
{
return new self(
overallStatus: (string) ($data['overallStatus'] ?? $data['overall_status'] ?? 'HEALTHY'),
totalChecks: (int) ($data['totalChecks'] ?? $data['total_checks'] ?? 0),
healthyChecks: (int) ($data['healthyChecks'] ?? $data['healthy_checks'] ?? 0),
warningChecks: (int) ($data['warningChecks'] ?? $data['warning_checks'] ?? 0),
failedChecks: (int) ($data['failedChecks'] ?? $data['failed_checks'] ?? 0),
healthChecks: (array) ($data['healthChecks'] ?? $data['health_checks'] ?? []),
lastUpdated: (string) ($data['lastUpdated'] ?? $data['last_updated'] ?? '')
);
}
/**
* Create empty state with defaults
*/
public static function empty(): self
{
return new self();
}
/**
* Update with fresh health check data
*
* @param array<int, array{componentName: string, status: string, statusText: string, statusClass: string, message: string, responseTime: string|null}> $healthChecks
*/
public function withHealthChecks(
string $overallStatus,
array $healthChecks
): self {
$healthyCount = 0;
$warningCount = 0;
$failedCount = 0;
foreach ($healthChecks as $check) {
$status = $check['status'] ?? $check['statusText'] ?? 'unknown';
if ($status === 'healthy' || $status === 'HEALTHY' || ($check['statusClass'] ?? '') === 'success') {
$healthyCount++;
} elseif ($status === 'warning' || $status === 'WARNING' || ($check['statusClass'] ?? '') === 'warning') {
$warningCount++;
} else {
$failedCount++;
}
}
return new self(
overallStatus: $overallStatus,
totalChecks: count($healthChecks),
healthyChecks: $healthyCount,
warningChecks: $warningCount,
failedChecks: $failedCount,
healthChecks: $healthChecks,
lastUpdated: date('H:i:s')
);
}
/**
* Update timestamp
*/
public function withLastUpdated(string $timestamp): self
{
return new self(
overallStatus: $this->overallStatus,
totalChecks: $this->totalChecks,
healthyChecks: $this->healthyChecks,
warningChecks: $this->warningChecks,
failedChecks: $this->failedChecks,
healthChecks: $this->healthChecks,
lastUpdated: $timestamp
);
}
/**
* Convert to array for template rendering
*/
public function toArray(): array
{
return [
'overallStatus' => $this->overallStatus,
'totalChecks' => $this->totalChecks,
'healthyChecks' => $this->healthyChecks,
'warningChecks' => $this->warningChecks,
'failedChecks' => $this->failedChecks,
'healthChecks' => $this->healthChecks,
'lastUpdated' => $this->lastUpdated,
];
}
}

View File

@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace App\Application\LiveComponents\Dashboard;
use App\Framework\Core\ValueObjects\Byte;
use App\Framework\Core\ValueObjects\Percentage;
use App\Framework\DateTime\Clock;
use App\Framework\LiveComponents\Attributes\Action;
use App\Framework\LiveComponents\Attributes\LiveComponent;
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
use App\Framework\LiveComponents\Contracts\Pollable;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\LiveComponents\ValueObjects\ComponentRenderData;
use App\Framework\Performance\MemoryMonitor;
/**
* Performance Metrics LiveComponent
*
* Displays real-time performance metrics:
* - Memory usage (current, peak, limit, percentage)
* - Load average
* - Execution time
* - OPCache statistics
*
* Polls every 3 seconds for real-time monitoring.
*/
#[LiveComponent('performance-metrics')]
final readonly class PerformanceMetricsComponent implements LiveComponentContract, Pollable
{
public function __construct(
public ComponentId $id,
public PerformanceMetricsState $state,
private MemoryMonitor $memoryMonitor,
private Clock $clock
) {}
public function getRenderData(): ComponentRenderData
{
// Initial poll if state is empty (lastUpdated is empty string)
$state = $this->state->lastUpdated === ''
? $this->poll()
: $this->state;
return new ComponentRenderData(
templatePath: 'livecomponent-performance-metrics',
data: [
// LiveComponent integration
'componentId' => $this->id->toString(),
'stateJson' => json_encode($state->toArray()),
'pollInterval' => $this->getPollInterval(),
// Component data
'currentMemory' => $state->currentMemory,
'peakMemory' => $state->peakMemory,
'memoryLimit' => $state->memoryLimit,
'memoryUsagePercentage' => $state->memoryUsagePercentage,
'loadAverage' => $state->loadAverage,
'executionTime' => $state->executionTime,
'includedFiles' => $state->includedFiles,
'opcacheEnabled' => $state->opcacheEnabled,
'opcacheMemoryUsage' => $state->opcacheMemoryUsage,
'opcacheCacheHits' => $state->opcacheCacheHits,
'opcacheMissRate' => $state->opcacheMissRate,
'lastUpdated' => $state->lastUpdated,
]
);
}
#[Action]
public function poll(): PerformanceMetricsState
{
// Get memory metrics
$currentMemory = $this->memoryMonitor->getCurrentMemory();
$peakMemory = $this->memoryMonitor->getPeakMemory();
$memoryLimit = $this->memoryMonitor->getMemoryLimit();
$usagePercentage = $this->memoryMonitor->getMemoryUsagePercentage();
// Get load average
$loadAverage = function_exists('sys_getloadavg') ? sys_getloadavg() : ['N/A', 'N/A', 'N/A'];
// Calculate execution time
$executionTime = number_format(
microtime(true) - ($_SERVER['REQUEST_TIME_FLOAT'] ?? microtime(true)),
4
) . ' Sekunden';
// Get included files count
$includedFiles = count(get_included_files());
// Check OPCache status
$opcacheEnabled = function_exists('opcache_get_status');
$opcacheMemoryUsage = null;
$opcacheCacheHits = null;
$opcacheMissRate = null;
if ($opcacheEnabled) {
try {
$opcacheStatus = opcache_get_status(false);
if ($opcacheStatus !== false) {
$memoryUsed = Byte::fromBytes($opcacheStatus['memory_usage']['used_memory']);
$opcacheMemoryUsage = $memoryUsed->toHumanReadable();
$opcacheCacheHits = number_format($opcacheStatus['opcache_statistics']['hits']);
$hits = $opcacheStatus['opcache_statistics']['hits'];
$misses = $opcacheStatus['opcache_statistics']['misses'];
$total = $hits + $misses;
if ($total > 0) {
$missRate = Percentage::from(($misses / $total) * 100);
$opcacheMissRate = $missRate->format(2) . '%';
}
}
} catch (\Throwable) {
// Ignore errors
}
}
return $this->state->withMetrics(
currentMemory: $currentMemory->toHumanReadable(),
peakMemory: $peakMemory->toHumanReadable(),
memoryLimit: $memoryLimit->toHumanReadable(),
memoryUsagePercentage: (float) $usagePercentage->format(2),
loadAverage: $loadAverage,
executionTime: $executionTime,
includedFiles: $includedFiles,
opcacheEnabled: $opcacheEnabled,
opcacheMemoryUsage: $opcacheMemoryUsage,
opcacheCacheHits: $opcacheCacheHits,
opcacheMissRate: $opcacheMissRate
);
}
public function getPollInterval(): int
{
return 3000; // Poll every 3 seconds for real-time updates
}
}

View File

@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
namespace App\Application\LiveComponents\Dashboard;
use App\Application\LiveComponents\LiveComponentState;
/**
* Type-safe state for PerformanceMetricsComponent
*
* Provides real-time performance metrics:
* - Memory usage (current, peak, limit, percentage)
* - Load average
* - Execution time
* - OPCache statistics
*
* Immutable value object - transformations return new instances.
*/
final readonly class PerformanceMetricsState implements LiveComponentState
{
public function __construct(
public string $currentMemory = '',
public string $peakMemory = '',
public string $memoryLimit = '',
public float $memoryUsagePercentage = 0.0,
public array $loadAverage = [],
public string $executionTime = '',
public int $includedFiles = 0,
public bool $opcacheEnabled = false,
public ?string $opcacheMemoryUsage = null,
public ?string $opcacheCacheHits = null,
public ?string $opcacheMissRate = null,
public string $lastUpdated = ''
) {}
/**
* Create from array data (from client or storage)
*/
public static function fromArray(array $data): self
{
return new self(
currentMemory: (string) ($data['currentMemory'] ?? $data['current_memory'] ?? ''),
peakMemory: (string) ($data['peakMemory'] ?? $data['peak_memory'] ?? ''),
memoryLimit: (string) ($data['memoryLimit'] ?? $data['memory_limit'] ?? ''),
memoryUsagePercentage: (float) ($data['memoryUsagePercentage'] ?? $data['memory_usage_percentage'] ?? 0.0),
loadAverage: (array) ($data['loadAverage'] ?? $data['load_average'] ?? []),
executionTime: (string) ($data['executionTime'] ?? $data['execution_time'] ?? ''),
includedFiles: (int) ($data['includedFiles'] ?? $data['included_files'] ?? 0),
opcacheEnabled: (bool) ($data['opcacheEnabled'] ?? $data['opcache_enabled'] ?? false),
opcacheMemoryUsage: isset($data['opcacheMemoryUsage']) ? (string) $data['opcacheMemoryUsage'] : (isset($data['opcache_memory_usage']) ? (string) $data['opcache_memory_usage'] : null),
opcacheCacheHits: isset($data['opcacheCacheHits']) ? (string) $data['opcacheCacheHits'] : (isset($data['opcache_cache_hits']) ? (string) $data['opcache_cache_hits'] : null),
opcacheMissRate: isset($data['opcacheMissRate']) ? (string) $data['opcacheMissRate'] : (isset($data['opcache_miss_rate']) ? (string) $data['opcache_miss_rate'] : null),
lastUpdated: (string) ($data['lastUpdated'] ?? $data['last_updated'] ?? '')
);
}
/**
* Create empty state with defaults
*/
public static function empty(): self
{
return new self();
}
/**
* Update with fresh performance metrics
*/
public function withMetrics(
string $currentMemory,
string $peakMemory,
string $memoryLimit,
float $memoryUsagePercentage,
array $loadAverage,
string $executionTime,
int $includedFiles,
bool $opcacheEnabled,
?string $opcacheMemoryUsage = null,
?string $opcacheCacheHits = null,
?string $opcacheMissRate = null
): self {
return new self(
currentMemory: $currentMemory,
peakMemory: $peakMemory,
memoryLimit: $memoryLimit,
memoryUsagePercentage: $memoryUsagePercentage,
loadAverage: $loadAverage,
executionTime: $executionTime,
includedFiles: $includedFiles,
opcacheEnabled: $opcacheEnabled,
opcacheMemoryUsage: $opcacheMemoryUsage,
opcacheCacheHits: $opcacheCacheHits,
opcacheMissRate: $opcacheMissRate,
lastUpdated: date('H:i:s')
);
}
/**
* Update timestamp
*/
public function withLastUpdated(string $timestamp): self
{
return new self(
currentMemory: $this->currentMemory,
peakMemory: $this->peakMemory,
memoryLimit: $this->memoryLimit,
memoryUsagePercentage: $this->memoryUsagePercentage,
loadAverage: $this->loadAverage,
executionTime: $this->executionTime,
includedFiles: $this->includedFiles,
opcacheEnabled: $this->opcacheEnabled,
opcacheMemoryUsage: $this->opcacheMemoryUsage,
opcacheCacheHits: $this->opcacheCacheHits,
opcacheMissRate: $this->opcacheMissRate,
lastUpdated: $timestamp
);
}
/**
* Convert to array for template rendering
*/
public function toArray(): array
{
return [
'currentMemory' => $this->currentMemory,
'peakMemory' => $this->peakMemory,
'memoryLimit' => $this->memoryLimit,
'memoryUsagePercentage' => $this->memoryUsagePercentage,
'loadAverage' => $this->loadAverage,
'executionTime' => $this->executionTime,
'includedFiles' => $this->includedFiles,
'opcacheEnabled' => $this->opcacheEnabled,
'opcacheMemoryUsage' => $this->opcacheMemoryUsage,
'opcacheCacheHits' => $this->opcacheCacheHits,
'opcacheMissRate' => $this->opcacheMissRate,
'lastUpdated' => $this->lastUpdated,
];
}
}

View File

@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace App\Application\LiveComponents\Notification;
use App\Framework\LiveComponents\Attributes\Action;
use App\Framework\LiveComponents\Attributes\LiveComponent;
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
use App\Framework\LiveComponents\ValueObjects\ComponentData;
use App\Framework\LiveComponents\ValueObjects\ComponentId;
use App\Framework\LiveComponents\ValueObjects\ComponentRenderData;
/**
* NotificationComponent - Toast notifications for LiveComponents
*
* Features:
* - Multiple notification types (info, success, warning, error)
* - Configurable positions (top-right, top-left, bottom-right, bottom-left)
* - Auto-dismiss with configurable duration
* - Action buttons
* - Icons support
*/
#[LiveComponent('notification')]
final readonly class NotificationComponent implements LiveComponentContract
{
public ComponentId $id;
public NotificationState $state;
public function __construct(
ComponentId $id,
?ComponentData $initialData = null,
string $message = '',
string $type = 'info',
string $position = 'top-right',
int $duration = 5000,
bool $isVisible = false,
?string $actionText = null,
?string $actionUrl = null,
?string $icon = null
) {
$this->id = $id;
// If initialData is provided (from state update), use it
if ($initialData !== null) {
$this->state = NotificationState::fromComponentData($initialData);
return;
}
// Otherwise, initialize with provided parameters
$this->state = new NotificationState(
message: $message,
type: $type,
position: $position,
duration: $duration,
isVisible: $isVisible,
actionText: $actionText,
actionUrl: $actionUrl,
icon: $icon
);
}
public function getRenderData(): ComponentRenderData
{
return new ComponentRenderData(
templatePath: 'livecomponent-notification',
data: [
'componentId' => $this->id->toString(),
'stateJson' => json_encode($this->state->toArray()),
'message' => $this->state->message,
'type' => $this->state->type,
'position' => $this->state->position,
'duration' => $this->state->duration,
'is_visible' => $this->state->isVisible,
'action_text' => $this->state->actionText,
'action_url' => $this->state->actionUrl,
'icon' => $this->state->icon,
'type_class' => $this->state->getTypeClass(),
'position_class' => $this->state->getPositionClass(),
'has_action' => $this->state->hasAction(),
]
);
}
/**
* Show notification action
*
* @return NotificationState New state
*/
#[Action]
public function showNotification(string $message, string $type = 'info'): NotificationState
{
return $this->state->withMessage($message)->show();
}
/**
* Hide notification action
*
* @return NotificationState New state
*/
#[Action]
public function hide(): NotificationState
{
return $this->state->hide();
}
/**
* Handle action button click
*
* @return NotificationState New state
*/
#[Action]
public function handleAction(string $actionUrl): NotificationState
{
// Hide notification after action
return $this->state->hide();
}
}

View File

@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace App\Application\LiveComponents\Notification;
use App\Application\LiveComponents\LiveComponentState;
use App\Framework\LiveComponents\ValueObjects\ComponentData;
/**
* Type-safe state for NotificationComponent
*
* Provides compile-time type safety for notification functionality.
* All properties are readonly and immutable - transformations return new instances.
*/
final readonly class NotificationState implements LiveComponentState
{
public function __construct(
public string $message = '',
public string $type = 'info', // info, success, warning, error
public string $position = 'top-right', // top-right, top-left, bottom-right, bottom-left
public int $duration = 5000, // milliseconds, 0 = persistent
public bool $isVisible = false,
public ?string $actionText = null,
public ?string $actionUrl = null,
public ?string $icon = null
) {
}
/**
* Create from ComponentData (Framework → Domain conversion)
*/
public static function fromArray(array $data): self
{
return new self(
message: (string) ($data['message'] ?? ''),
type: (string) ($data['type'] ?? 'info'),
position: (string) ($data['position'] ?? 'top-right'),
duration: (int) ($data['duration'] ?? 5000),
isVisible: (bool) ($data['is_visible'] ?? false),
actionText: isset($data['action_text']) ? (string) $data['action_text'] : null,
actionUrl: isset($data['action_url']) ? (string) $data['action_url'] : null,
icon: isset($data['icon']) ? (string) $data['icon'] : null
);
}
/**
* Create empty state with defaults
*/
public static function empty(): self
{
return new self();
}
/**
* Convert to ComponentData (Domain → Framework conversion)
*/
public function toArray(): array
{
return [
'message' => $this->message,
'type' => $this->type,
'position' => $this->position,
'duration' => $this->duration,
'is_visible' => $this->isVisible,
'action_text' => $this->actionText,
'action_url' => $this->actionUrl,
'icon' => $this->icon,
];
}
/**
* Create notification with message
*/
public function withMessage(string $message): self
{
return new self(
message: $message,
type: $this->type,
position: $this->position,
duration: $this->duration,
isVisible: true,
actionText: $this->actionText,
actionUrl: $this->actionUrl,
icon: $this->icon
);
}
/**
* Show notification
*/
public function show(): self
{
return new self(
message: $this->message,
type: $this->type,
position: $this->position,
duration: $this->duration,
isVisible: true,
actionText: $this->actionText,
actionUrl: $this->actionUrl,
icon: $this->icon
);
}
/**
* Hide notification
*/
public function hide(): self
{
return new self(
message: $this->message,
type: $this->type,
position: $this->position,
duration: $this->duration,
isVisible: false,
actionText: $this->actionText,
actionUrl: $this->actionUrl,
icon: $this->icon
);
}
/**
* Get CSS class for notification type
*/
public function getTypeClass(): string
{
return "notification--{$this->type}";
}
/**
* Get CSS class for position
*/
public function getPositionClass(): string
{
return "notification--{$this->position}";
}
/**
* Check if notification has action
*/
public function hasAction(): bool
{
return $this->actionText !== null && $this->actionUrl !== null;
}
}

View File

@@ -17,7 +17,6 @@ final readonly class ShowHome
#[Route(path: '/', method: Method::GET, name: WebRoutes::HOME)]
public function home(HomeRequest $request, string $test = 'hallo'): ViewResult
{
throw new \Exception('test');
// Production deployment trigger - scp from /workspace/repo
$model = new HomeViewModel('Hallo Welt!');

View File

@@ -17,14 +17,8 @@ final readonly class ShowImpressum
#[Route(path: '/impressum', name: WebRoutes::IMPRESSUM)]
public function impressum(): ViewResult
{
// Media cleanup logic moved to App\Application\Media\MediaCleanupService
// This eliminates the 600ms+ performance issue caused by:
// - RecursiveDirectoryIterator over all files
// - Database queries for every file
// - File system operations during web request
return new ViewResult(
'impressum',
'test',
new StaticPageMetaResolver(
'Impressum',
'Hallo Welt!',