From 1c0c1382ad2c553dbada6e2ee1192d07febe43e0 Mon Sep 17 00:00:00 2001 From: Daniel Badura Date: Wed, 2 Sep 2026 12:57:27 +0200 Subject: [PATCH] Add dashboard --- config/routes.yaml | 4 +- docs/dashboard.md | 43 +++++ docs/project.json | 1 + docs/store.md | 6 +- public/build/app.css | 81 ++++++++ src/Controller/DashboardController.php | 66 +++++++ src/Controller/DefaultController.php | 2 +- ...chlevelEventSourcingDashboardExtension.php | 11 ++ templates/dashboard/index.html.twig | 174 ++++++++++++++++++ templates/layout.html.twig | 1 + tests/Fixture/Order.php | 11 ++ tests/Fixture/OrderPaid.php | 9 + tests/Fixture/OrderPlaced.php | 9 + tests/Fixture/Profile.php | 11 ++ tests/Fixture/ProfileCreated.php | 9 + .../Controller/DashboardControllerTest.php | 130 +++++++++++++ .../PatchlevelEventSourcingBundleTest.php | 3 + 17 files changed, 565 insertions(+), 6 deletions(-) create mode 100644 docs/dashboard.md create mode 100644 src/Controller/DashboardController.php create mode 100644 templates/dashboard/index.html.twig create mode 100644 tests/Fixture/Order.php create mode 100644 tests/Fixture/OrderPaid.php create mode 100644 tests/Fixture/OrderPlaced.php create mode 100644 tests/Fixture/Profile.php create mode 100644 tests/Fixture/ProfileCreated.php create mode 100644 tests/Unit/Controller/DashboardControllerTest.php diff --git a/config/routes.yaml b/config/routes.yaml index 7f07c8c..6842252 100644 --- a/config/routes.yaml +++ b/config/routes.yaml @@ -2,9 +2,9 @@ patchlevel_event_sourcing_dashboard_default_index: path: / controller: Patchlevel\EventSourcingDashboardBundle\Controller\DefaultController::indexAction -patchlevel_event_sourcing_dashboard_default_dashboard: +patchlevel_event_sourcing_dashboard_dashboard_index: path: /dashboard - controller: Patchlevel\EventSourcingDashboardBundle\Controller\DefaultController::dashboardAction + controller: Patchlevel\EventSourcingDashboardBundle\Controller\DashboardController::indexAction patchlevel_event_sourcing_dashboard_store_show: path: /store diff --git a/docs/dashboard.md b/docs/dashboard.md new file mode 100644 index 0000000..b9d5155 --- /dev/null +++ b/docs/dashboard.md @@ -0,0 +1,43 @@ +# Dashboard + +The dashboard view is the landing page of the bundle. It gives you an overview of your event +sourcing setup at a glance, before you drill down into the store, a single aggregate or your +subscriptions. The index route of the bundle redirects here. + +## Overview + +Four cards summarize the current state of your system: + +* **Events in store**: how many messages your event store holds in total +* **Aggregates**: how many aggregate roots are registered +* **Registered events**: how many event classes are registered +* **Subscriptions**: how many subscriptions the subscription engine knows about + +Each card links to the view where you can explore that number in detail. + +## Subscription health + +Below the cards you see how your subscriptions are distributed across the statuses of the +subscription engine, for example `active`, `booting`, `paused` or `error`. Statuses without any +subscription are hidden, so in a healthy system you only see `active`. + +Every status is a link into the [subscriptions](subscriptions.md) view, pre-filtered to that +status. This makes the dashboard a good starting point when something goes wrong: a red `error` +or `failed` pill takes you straight to the subscriptions that need attention. + +:::tip +If you only ever want to look at one thing, look at this row. Everything else on the page is +informational, but a non-empty `error` or `failed` count means events are not being processed. +::: + +## Latest events + +The last recorded events are listed at the bottom, newest first, with the same columns as the +[store](store.md) view. From here you can jump into the inspection of the aggregate or stream +that recorded an event, or open the full store listing. + +## Learn more + +* [How to browse and filter the event store](store.md) +* [How to inspect a single aggregate](inspection.md) +* [How to manage subscriptions](subscriptions.md) diff --git a/docs/project.json b/docs/project.json index 7a2c80d..bedcbcc 100644 --- a/docs/project.json +++ b/docs/project.json @@ -5,6 +5,7 @@ { "title": "Dashboard", "subEntries": [ + { "title": "Dashboard", "file": "dashboard.md" }, { "title": "Store", "file": "store.md" }, { "title": "Inspection", "file": "inspection.md" }, { "title": "Events", "file": "events.md" }, diff --git a/docs/store.md b/docs/store.md index e12d369..0eba7f5 100644 --- a/docs/store.md +++ b/docs/store.md @@ -1,8 +1,8 @@ # Store -The store view is the landing page of the dashboard. It lists the messages in your event store in -reverse order, newest first, so you can see what your application recorded most recently. The index -route of the bundle redirects here. +The store view lists the messages in your event store in reverse order, newest first, so you can +see what your application recorded most recently. It is reachable from the sidebar and from the +[dashboard](dashboard.md) overview. ![The store view](screenshot1.png) diff --git a/public/build/app.css b/public/build/app.css index 3083d96..4d2f114 100644 --- a/public/build/app.css +++ b/public/build/app.css @@ -843,6 +843,9 @@ input:where([type='file']):focus { .mt-3 { margin-top: 0.75rem; } +.mt-4 { + margin-top: 1rem; +} .mt-5 { margin-top: 1.25rem; } @@ -873,6 +876,9 @@ input:where([type='file']):focus { .flow-root { display: flow-root; } +.grid { + display: grid; +} .hidden { display: none; } @@ -993,9 +999,15 @@ input:where([type='file']):focus { .cursor-help { cursor: help; } +.grid-cols-1 { + grid-template-columns: repeat(1, minmax(0, 1fr)); +} .flex-col { flex-direction: column; } +.flex-wrap { + flex-wrap: wrap; +} .place-content-between { place-content: space-between; } @@ -1023,6 +1035,9 @@ input:where([type='file']):focus { .gap-2 { gap: 0.5rem; } +.gap-3 { + gap: 0.75rem; +} .gap-4 { gap: 1rem; } @@ -1030,6 +1045,10 @@ input:where([type='file']):focus { -moz-column-gap: 0.375rem; column-gap: 0.375rem; } +.gap-x-2 { + -moz-column-gap: 0.5rem; + column-gap: 0.5rem; +} .gap-x-3 { -moz-column-gap: 0.75rem; column-gap: 0.75rem; @@ -1163,6 +1182,10 @@ input:where([type='file']):focus { --tw-bg-opacity: 1; background-color: rgb(220 252 231 / var(--tw-bg-opacity, 1)); } +.bg-green-50 { + --tw-bg-opacity: 1; + background-color: rgb(240 253 244 / var(--tw-bg-opacity, 1)); +} .bg-indigo-600 { --tw-bg-opacity: 1; background-color: rgb(79 70 229 / var(--tw-bg-opacity, 1)); @@ -1186,6 +1209,10 @@ input:where([type='file']):focus { .bg-white\/10 { background-color: rgb(255 255 255 / 0.1); } +.bg-yellow-50 { + --tw-bg-opacity: 1; + background-color: rgb(254 252 232 / var(--tw-bg-opacity, 1)); +} .bg-opacity-75 { --tw-bg-opacity: 0.75; } @@ -1210,6 +1237,9 @@ input:where([type='file']):focus { .p-2\.5 { padding: 0.625rem; } +.p-3 { + padding: 0.75rem; +} .p-4 { padding: 1rem; } @@ -1326,6 +1356,10 @@ input:where([type='file']):focus { font-size: 1.5rem; line-height: 2rem; } +.text-base { + font-size: 1rem; + line-height: 1.5rem; +} .text-sm { font-size: 0.875rem; line-height: 1.25rem; @@ -1355,6 +1389,13 @@ input:where([type='file']):focus { .font-semibold { font-weight: 600; } +.capitalize { + text-transform: capitalize; +} +.tabular-nums { + --tw-numeric-spacing: tabular-nums; + font-variant-numeric: var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction); +} .leading-6 { line-height: 1.5rem; } @@ -1369,6 +1410,10 @@ input:where([type='file']):focus { --tw-text-opacity: 1; color: rgb(107 114 128 / var(--tw-text-opacity, 1)); } +.text-gray-600 { + --tw-text-opacity: 1; + color: rgb(75 85 99 / var(--tw-text-opacity, 1)); +} .text-gray-700 { --tw-text-opacity: 1; color: rgb(55 65 81 / var(--tw-text-opacity, 1)); @@ -1381,6 +1426,10 @@ input:where([type='file']):focus { --tw-text-opacity: 1; color: rgb(22 163 74 / var(--tw-text-opacity, 1)); } +.text-green-700 { + --tw-text-opacity: 1; + color: rgb(21 128 61 / var(--tw-text-opacity, 1)); +} .text-indigo-200 { --tw-text-opacity: 1; color: rgb(199 210 254 / var(--tw-text-opacity, 1)); @@ -1393,6 +1442,10 @@ input:where([type='file']):focus { --tw-text-opacity: 1; color: rgb(248 113 113 / var(--tw-text-opacity, 1)); } +.text-red-700 { + --tw-text-opacity: 1; + color: rgb(185 28 28 / var(--tw-text-opacity, 1)); +} .text-red-800 { --tw-text-opacity: 1; color: rgb(153 27 27 / var(--tw-text-opacity, 1)); @@ -1405,6 +1458,10 @@ input:where([type='file']):focus { --tw-text-opacity: 1; color: rgb(234 179 8 / var(--tw-text-opacity, 1)); } +.text-yellow-800 { + --tw-text-opacity: 1; + color: rgb(133 77 14 / var(--tw-text-opacity, 1)); +} .opacity-0 { opacity: 0; } @@ -1451,13 +1508,25 @@ input:where([type='file']):focus { --tw-ring-opacity: 1; --tw-ring-color: rgb(209 213 219 / var(--tw-ring-opacity, 1)); } +.ring-gray-500\/10 { + --tw-ring-color: rgb(107 114 128 / 0.1); +} .ring-gray-900\/5 { --tw-ring-color: rgb(17 24 39 / 0.05); } +.ring-green-600\/20 { + --tw-ring-color: rgb(22 163 74 / 0.2); +} +.ring-red-600\/20 { + --tw-ring-color: rgb(220 38 38 / 0.2); +} .ring-white { --tw-ring-opacity: 1; --tw-ring-color: rgb(255 255 255 / var(--tw-ring-opacity, 1)); } +.ring-yellow-600\/20 { + --tw-ring-color: rgb(202 138 4 / 0.2); +} .ring-opacity-5 { --tw-ring-opacity: 0.05; } @@ -1526,6 +1595,10 @@ input:where([type='file']):focus { --tw-text-opacity: 1; color: rgb(17 24 39 / var(--tw-text-opacity, 1)); } +.hover\:text-indigo-500:hover { + --tw-text-opacity: 1; + color: rgb(99 102 241 / var(--tw-text-opacity, 1)); +} .hover\:text-white:hover { --tw-text-opacity: 1; color: rgb(255 255 255 / var(--tw-text-opacity, 1)); @@ -1633,6 +1706,10 @@ input:where([type='file']):focus { transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); } + .sm\:grid-cols-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .sm\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } @@ -1735,6 +1812,10 @@ input:where([type='file']):focus { width: 18rem; } + .lg\:grid-cols-4 { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + .lg\:flex-col { flex-direction: column; } diff --git a/src/Controller/DashboardController.php b/src/Controller/DashboardController.php new file mode 100644 index 0000000..3b4e591 --- /dev/null +++ b/src/Controller/DashboardController.php @@ -0,0 +1,66 @@ +engine->subscriptions(); + + return new Response( + $this->twig->render('@PatchlevelEventSourcingDashboard/dashboard/index.html.twig', [ + 'messageCount' => $this->store->count(), + 'aggregateCount' => count($this->aggregateRootRegistry->aggregateNames()), + 'eventCount' => count($this->eventRegistry->eventNames()), + 'subscriptionCount' => count($subscriptions), + 'subscriptionStatuses' => $this->countByStatus($subscriptions), + 'messages' => $this->store->load(null, self::LATEST_MESSAGE_LIMIT, 0, true), + ]), + ); + } + + /** + * @param list $subscriptions + * + * @return array + */ + private function countByStatus(array $subscriptions): array + { + $counts = []; + + foreach (Status::cases() as $status) { + $counts[$status->value] = 0; + } + + foreach ($subscriptions as $subscription) { + $counts[$subscription->status()->value]++; + } + + return $counts; + } +} diff --git a/src/Controller/DefaultController.php b/src/Controller/DefaultController.php index e3dbab4..8796721 100644 --- a/src/Controller/DefaultController.php +++ b/src/Controller/DefaultController.php @@ -18,7 +18,7 @@ public function __construct( public function indexAction(): Response { return new RedirectResponse( - $this->router->generate('patchlevel_event_sourcing_dashboard_store_show'), + $this->router->generate('patchlevel_event_sourcing_dashboard_dashboard_index'), ); } } diff --git a/src/DependencyInjection/PatchlevelEventSourcingDashboardExtension.php b/src/DependencyInjection/PatchlevelEventSourcingDashboardExtension.php index 9bb92a6..eb923ec 100644 --- a/src/DependencyInjection/PatchlevelEventSourcingDashboardExtension.php +++ b/src/DependencyInjection/PatchlevelEventSourcingDashboardExtension.php @@ -13,6 +13,7 @@ use Patchlevel\EventSourcing\Snapshot\SnapshotStore; use Patchlevel\EventSourcing\Store\Store; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; +use Patchlevel\EventSourcingDashboardBundle\Controller\DashboardController; use Patchlevel\EventSourcingDashboardBundle\Controller\DefaultController; use Patchlevel\EventSourcingDashboardBundle\Controller\EventController; use Patchlevel\EventSourcingDashboardBundle\Controller\InspectionController; @@ -61,6 +62,16 @@ public function load(array $configs, ContainerBuilder $container): void ]) ->addTag('controller.service_arguments'); + $container->register(DashboardController::class) + ->setArguments([ + new Reference('twig'), + new Reference(Store::class), + new Reference(AggregateRootRegistry::class), + new Reference(EventRegistry::class), + new Reference(SubscriptionEngine::class), + ]) + ->addTag('controller.service_arguments'); + $container->register(StoreController::class) ->setArguments([ new Reference('twig'), diff --git a/templates/dashboard/index.html.twig b/templates/dashboard/index.html.twig new file mode 100644 index 0000000..e3175af --- /dev/null +++ b/templates/dashboard/index.html.twig @@ -0,0 +1,174 @@ +{% extends '@PatchlevelEventSourcingDashboard/layout.html.twig' %} + +{% block content %} +
+ + +
+ Dashboard +
+ + +
+ {{ _self.stat_card( + "Events in store", + messageCount, + "circle-stack", + path('patchlevel_event_sourcing_dashboard_store_show') + ) }} + {{ _self.stat_card( + "Aggregates", + aggregateCount, + "identification", + path('patchlevel_event_sourcing_dashboard_inspection_index') + ) }} + {{ _self.stat_card( + "Registered events", + eventCount, + "calendar", + path('patchlevel_event_sourcing_dashboard_event_index') + ) }} + {{ _self.stat_card( + "Subscriptions", + subscriptionCount, + "video-camera", + path('patchlevel_event_sourcing_dashboard_subscription_show') + ) }} +
+ + +
+
+ Subscription health +
+ +
+ {% for status, count in subscriptionStatuses %} + + {{ status }} + {{ count }} + + {% endfor %} +
+
+ + +
+
+
+ Latest events +
+ + View all + +
+ +
+
+
+ + + + {% if eventsourcing_uses_stream_store() %} + + {% else %} + + + {% endif %} + + + + + + + {% for message in messages %} + + {% if eventsourcing_uses_stream_store() %} + + {% else %} + + + {% endif %} + + + + + + {% else %} + + + + {% endfor %} + +
+ Stream + + Aggregate + ID + + Playhead + + Event + + Recorded On +
+ {{ eventsourcing_stream_name(message) }} + + + {{ eventsourcing_aggregate_name(message) }} + + + {{ eventsourcing_short_id(eventsourcing_aggregate_id(message)) }} + + {{ eventsourcing_playhead(message) }} + {{ eventsourcing_event_name(message) }} + + {{ eventsourcing_recorded_on(message) ? eventsourcing_recorded_on(message)|date('Y-m-d H:i:s') : '-' }}
No events found
+
+
+
+
+
+{% endblock %} + +{% macro stat_card(label, value, icon, url) %} + +
+ {{ heroicon(icon, 'h-6 w-6 text-white') }} +
+
+
{{ label }}
+
{{ value }}
+
+
+{% endmacro %} + +{% macro status_classes(status, count) %} + {%- if count == 0 -%} + bg-white text-gray-500 ring-gray-300 + {%- elseif status in ['error', 'failed'] -%} + bg-red-50 text-red-700 ring-red-600/20 + {%- elseif status in ['paused', 'detached'] -%} + bg-yellow-50 text-yellow-800 ring-yellow-600/20 + {%- elseif status == 'active' -%} + bg-green-50 text-green-700 ring-green-600/20 + {%- else -%} + bg-gray-50 text-gray-600 ring-gray-500/10 + {%- endif -%} +{% endmacro %} diff --git a/templates/layout.html.twig b/templates/layout.html.twig index 4cae62d..88bce41 100644 --- a/templates/layout.html.twig +++ b/templates/layout.html.twig @@ -10,6 +10,7 @@
    • + {{ _self.nav_point("Dashboard", "home", "patchlevel_event_sourcing_dashboard_dashboard_index") }} {{ _self.nav_point("Store", "circle-stack", "patchlevel_event_sourcing_dashboard_store_show") }} {{ _self.nav_point("Inspection", "identification", "patchlevel_event_sourcing_dashboard_inspection_index") }} {{ _self.nav_point("Subscriptions", "video-camera", "patchlevel_event_sourcing_dashboard_subscription_show") }} diff --git a/tests/Fixture/Order.php b/tests/Fixture/Order.php new file mode 100644 index 0000000..0362c9a --- /dev/null +++ b/tests/Fixture/Order.php @@ -0,0 +1,11 @@ +createMock(Store::class); + $store->method('count')->willReturn(42); + $store->expects($this->once()) + ->method('load') + ->with(null, 10, 0, true) + ->willReturn($stream); + + $engine = $this->createMock(SubscriptionEngine::class); + $engine->method('subscriptions')->willReturn($subscriptions); + + $twig = $this->createMock(Environment::class); + $twig->expects($this->once()) + ->method('render') + ->with( + '@PatchlevelEventSourcingDashboard/dashboard/index.html.twig', + [ + 'messageCount' => 42, + 'aggregateCount' => 2, + 'eventCount' => 3, + 'subscriptionCount' => 4, + 'subscriptionStatuses' => [ + 'new' => 0, + 'booting' => 0, + 'active' => 2, + 'paused' => 1, + 'finished' => 0, + 'detached' => 0, + 'error' => 1, + 'failed' => 0, + ], + 'messages' => $stream, + ], + ) + ->willReturn(''); + + $controller = new DashboardController( + $twig, + $store, + new AggregateRootRegistry(['profile' => Profile::class, 'order' => Order::class]), + new EventRegistry([ + 'profile.created' => ProfileCreated::class, + 'order.placed' => OrderPlaced::class, + 'order.paid' => OrderPaid::class, + ]), + $engine, + ); + + $response = $controller->indexAction(); + + self::assertSame(200, $response->getStatusCode()); + self::assertSame('', $response->getContent()); + } + + public function testEveryStatusIsReportedWhenNothingIsRegistered(): void + { + $store = $this->createMock(Store::class); + $store->method('count')->willReturn(0); + $store->method('load')->willReturn(new ArrayStream([])); + + $engine = $this->createMock(SubscriptionEngine::class); + $engine->method('subscriptions')->willReturn([]); + + $expectedStatuses = []; + foreach (Status::cases() as $status) { + $expectedStatuses[$status->value] = 0; + } + + $twig = $this->createMock(Environment::class); + $twig->expects($this->once()) + ->method('render') + ->with( + self::anything(), + self::callback(static function (array $context) use ($expectedStatuses): bool { + self::assertSame(0, $context['messageCount']); + self::assertSame(0, $context['aggregateCount']); + self::assertSame(0, $context['eventCount']); + self::assertSame(0, $context['subscriptionCount']); + self::assertSame($expectedStatuses, $context['subscriptionStatuses']); + + return true; + }), + ) + ->willReturn(''); + + $controller = new DashboardController( + $twig, + $store, + new AggregateRootRegistry([]), + new EventRegistry([]), + $engine, + ); + + $controller->indexAction(); + } +} diff --git a/tests/Unit/PatchlevelEventSourcingBundleTest.php b/tests/Unit/PatchlevelEventSourcingBundleTest.php index ece8309..f1e608f 100644 --- a/tests/Unit/PatchlevelEventSourcingBundleTest.php +++ b/tests/Unit/PatchlevelEventSourcingBundleTest.php @@ -5,6 +5,7 @@ namespace Patchlevel\EventSourcingDashboardBundle\Tests\Unit; use Patchlevel\EventSourcing\Metadata\Message\MessageHeaderRegistry; +use Patchlevel\EventSourcingDashboardBundle\Controller\DashboardController; use Patchlevel\EventSourcingDashboardBundle\Controller\DefaultController; use Patchlevel\EventSourcingDashboardBundle\Controller\EventController; use Patchlevel\EventSourcingDashboardBundle\Controller\InspectionController; @@ -32,6 +33,7 @@ public function testEmptyConfig(): void $container->compile(); + self::assertFalse($container->has(DashboardController::class)); self::assertFalse($container->has(DefaultController::class)); self::assertFalse($container->has(EventController::class)); self::assertFalse($container->has(InspectionController::class)); @@ -51,6 +53,7 @@ public function testEnabled(): void ] ); + self::assertTrue($container->has(DashboardController::class)); self::assertTrue($container->has(DefaultController::class)); self::assertTrue($container->has(EventController::class)); self::assertTrue($container->has(InspectionController::class));