fix: Gitea Traefik routing and connection pool optimization
Some checks failed
🚀 Build & Deploy Image / Determine Build Necessity (push) Failing after 10m14s
🚀 Build & Deploy Image / Build Runtime Base Image (push) Has been skipped
🚀 Build & Deploy Image / Build Docker Image (push) Has been skipped
🚀 Build & Deploy Image / Run Tests & Quality Checks (push) Has been skipped
🚀 Build & Deploy Image / Auto-deploy to Staging (push) Has been skipped
🚀 Build & Deploy Image / Auto-deploy to Production (push) Has been skipped
Security Vulnerability Scan / Check for Dependency Changes (push) Failing after 11m25s
Security Vulnerability Scan / Composer Security Audit (push) Has been cancelled
Some checks failed
🚀 Build & Deploy Image / Determine Build Necessity (push) Failing after 10m14s
🚀 Build & Deploy Image / Build Runtime Base Image (push) Has been skipped
🚀 Build & Deploy Image / Build Docker Image (push) Has been skipped
🚀 Build & Deploy Image / Run Tests & Quality Checks (push) Has been skipped
🚀 Build & Deploy Image / Auto-deploy to Staging (push) Has been skipped
🚀 Build & Deploy Image / Auto-deploy to Production (push) Has been skipped
Security Vulnerability Scan / Check for Dependency Changes (push) Failing after 11m25s
Security Vulnerability Scan / Composer Security Audit (push) Has been cancelled
- Remove middleware reference from Gitea Traefik labels (caused routing issues) - Optimize Gitea connection pool settings (MAX_IDLE_CONNS=30, authentication_timeout=180s) - Add explicit service reference in Traefik labels - Fix intermittent 504 timeouts by improving PostgreSQL connection handling Fixes Gitea unreachability via git.michaelschiemer.de
This commit is contained in:
@@ -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
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
84
src/Application/Admin/Database/DatabaseBrowserController.php
Normal file
84
src/Application/Admin/Database/DatabaseBrowserController.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
69
src/Application/Admin/Database/TableBrowserController.php
Normal file
69
src/Application/Admin/Database/TableBrowserController.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
169
src/Application/Admin/Infrastructure/DockerController.php
Normal file
169
src/Application/Admin/Infrastructure/DockerController.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
272
src/Application/Admin/Registry/AdminNavigationRegistry.php
Normal file
272
src/Application/Admin/Registry/AdminNavigationRegistry.php
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
356
src/Application/Admin/Service/AdminPageDiscovery.php
Normal file
356
src/Application/Admin/Service/AdminPageDiscovery.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
137
src/Application/Admin/ValueObjects/AdminPageMetadata.php
Normal file
137
src/Application/Admin/ValueObjects/AdminPageMetadata.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
75
src/Application/Admin/ValueObjects/AdminSectionMetadata.php
Normal file
75
src/Application/Admin/ValueObjects/AdminSectionMetadata.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
33
src/Application/Admin/templates/database/browser.view.php
Normal file
33
src/Application/Admin/templates/database/browser.view.php
Normal 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
11
src/Application/Admin/templates/docker-dashboard.view.php
Normal file
11
src/Application/Admin/templates/docker-dashboard.view.php
Normal 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" />
|
||||
|
||||
|
||||
@@ -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;">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'/>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
24
src/Application/Asset/Api/Requests/UploadAssetRequest.php
Normal file
24
src/Application/Asset/Api/Requests/UploadAssetRequest.php
Normal 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;
|
||||
}
|
||||
|
||||
302
src/Application/Asset/Api/V1/AssetsController.php
Normal file
302
src/Application/Asset/Api/V1/AssetsController.php
Normal 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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
30
src/Application/Cms/Api/Requests/CreateContentRequest.php
Normal file
30
src/Application/Cms/Api/Requests/CreateContentRequest.php
Normal 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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
21
src/Application/Cms/Api/Requests/UpdateContentRequest.php
Normal file
21
src/Application/Cms/Api/Requests/UpdateContentRequest.php
Normal 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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
193
src/Application/Cms/Api/V1/ContentTypesController.php
Normal file
193
src/Application/Cms/Api/V1/ContentTypesController.php
Normal 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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
260
src/Application/Cms/Api/V1/ContentsController.php
Normal file
260
src/Application/Cms/Api/V1/ContentsController.php
Normal 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,
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
112
src/Application/Console/Commands/Design/LintTokensCommand.php
Normal file
112
src/Application/Console/Commands/Design/LintTokensCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
121
src/Application/LiveComponents/Dashboard/HealthStatusState.php
Normal file
121
src/Application/LiveComponents/Dashboard/HealthStatusState.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!');
|
||||
|
||||
@@ -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!',
|
||||
|
||||
Reference in New Issue
Block a user