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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,7 +15,7 @@
<div class="admin-nav__section">
<ul class="admin-nav__list" role="list">
<li class="admin-nav__item">
<a href="/admin" class="admin-nav__link" aria-current="{current_path === '/admin' ? 'page' : null}">
<a href="/admin" class="admin-nav__link" aria-current="{{$current_path === '/admin' ? 'page' : null}}">
<svg class="admin-nav__icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
</svg>
@@ -30,7 +30,7 @@
<h2 class="admin-nav__section-title">Content</h2>
<ul class="admin-nav__list" role="list">
<li class="admin-nav__item">
<a href="/admin/pages" class="admin-nav__link" aria-current="{current_path === '/admin/pages' ? 'page' : null}">
<a href="/admin/pages" class="admin-nav__link" aria-current="{{$current_path === '/admin/pages' ? 'page' : null}}">
<svg class="admin-nav__icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
@@ -38,7 +38,7 @@
</a>
</li>
<li class="admin-nav__item">
<a href="/admin/media" class="admin-nav__link" aria-current="{current_path === '/admin/media' ? 'page' : null}">
<a href="/admin/media" class="admin-nav__link" aria-current="{{$current_path === '/admin/media' ? 'page' : null}}">
<svg class="admin-nav__icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
@@ -53,7 +53,7 @@
<h2 class="admin-nav__section-title">System</h2>
<ul class="admin-nav__list" role="list">
<li class="admin-nav__item">
<a href="/admin/users" class="admin-nav__link" aria-current="{current_path === '/admin/users' ? 'page' : null}">
<a href="/admin/users" class="admin-nav__link" aria-current="{{$current_path === '/admin/users' ? 'page' : null}}">
<svg class="admin-nav__icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/>
</svg>
@@ -61,7 +61,7 @@
</a>
</li>
<li class="admin-nav__item">
<a href="/admin/settings" class="admin-nav__link" aria-current="{current_path === '/admin/settings' ? 'page' : null}">
<a href="/admin/settings" class="admin-nav__link" aria-current="{{$current_path === '/admin/settings' ? 'page' : null}}">
<svg class="admin-nav__icon" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
@@ -77,15 +77,15 @@
<div class="admin-sidebar__footer">
<a href="/admin/profile" class="admin-sidebar__user">
<img
src="{user.avatar ?? '/assets/default-avatar.png'}"
alt="{user.name}"
src="{{$user['avatar'] ?? '/assets/default-avatar.png'}}"
alt="{{$user['name']}}"
class="admin-sidebar__avatar"
width="32"
height="32"
/>
<div class="admin-sidebar__user-info">
<span class="admin-sidebar__user-name">{user.name}</span>
<span class="admin-sidebar__user-role">{user.role}</span>
<span class="admin-sidebar__user-name">{{$user['name']}}</span>
<span class="admin-sidebar__user-role">{{$user['role']}}</span>
</div>
</a>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,84 +1,12 @@
<layout name="admin" />
<!-- Cache invalidation: 2025-01-20 16:11 -->
<div class="section">
<h2>{{ title }}</h2>
<div class="admin-card">
<div class="admin-card__header">
<h3 class="admin-card__title">System Overview</h3>
</div>
<div class="admin-card__content">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: var(--space-md); margin-bottom: var(--space-lg);">
<div class="metric-card">
<div class="metric-card__value" style="color: var(--success);">
{{ overall_status }}
</div>
<div class="metric-card__label">Overall Status</div>
</div>
<div class="metric-card">
<div class="metric-card__value">{{ total_checks }}</div>
<div class="metric-card__label">Total Checks</div>
</div>
<div class="metric-card">
<div class="metric-card__value" style="color: var(--success);">{{ healthy_checks }}</div>
<div class="metric-card__label">Healthy</div>
</div>
<div class="metric-card">
<div class="metric-card__value" style="color: var(--warning);">{{ warning_checks }}</div>
<div class="metric-card__label">Warnings</div>
</div>
<div class="metric-card">
<div class="metric-card__value" style="color: var(--error);">{{ failed_checks }}</div>
<div class="metric-card__label">Failed</div>
</div>
</div>
</div>
</div>
<div class="admin-card" style="margin-top: var(--space-lg);">
<div class="admin-card__header">
<h3 class="admin-card__title">Health Check Details</h3>
<button class="admin-button admin-button--small" onclick="refreshHealthChecks()" style="margin-left: auto;">
🔄 Refresh
</button>
</div>
<table-data source="health_check_table" container-class="admin-card" />
</div>
<h2>{{$title}}</h2>
<!-- LiveComponent for real-time health status -->
<x-health-status id="health-dashboard" />
</div>
<script>
function toggleDetails(id) {
const element = document.getElementById(id);
if (element) {
element.style.display = element.style.display === 'none' ? 'table-row' : 'none';
}
}
function refreshHealthChecks() {
window.location.reload();
}
// Auto-refresh health status every 60 seconds
setInterval(function() {
window.location.reload();
}, 60000);
</script>
<style>
.metric-card {
text-align: center;
padding: var(--space-md);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg-alt);
}
.metric-card__value {
font-size: 2rem;
font-weight: 700;
margin-bottom: var(--space-sm);
}
.metric-card__label {
color: var(--muted);
font-size: 0.875rem;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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