Skip to content

Commit 4559a3c

Browse files
authored
feat: Dashboard MVP (#231)
1 parent a865d4e commit 4559a3c

28 files changed

Lines changed: 3573 additions & 21 deletions

.github/workflows/docker-release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,4 +78,4 @@ jobs:
7878
username: ${{ secrets.DOCKERHUB_USERNAME }}
7979
password: ${{ secrets.DOCKERHUB_TOKEN }}
8080
repository: ${{ env.IMAGE_NAME }}
81-
readme-filepath: ./docker/DOCKER.md
81+
readme-filepath: ./docker/README.md

README.md

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,8 @@ settings. Subscribers can override these by specifying their own `connectionStri
318318
| Setting | Description |
319319
|------------------------------------------------|----------------------------------------------------------------------------------------------------|
320320
| `dangerousAcceptAnyServerCertificateValidator` | Set to `true` to accept any server certificate. Useful when testing with self-signed certificates. |
321+
| `dashboardEnabled` | Enable the web-based dashboard UI for viewing event history and diagnostics. Default: `true`. |
322+
| `dashboardPort` | Optional dedicated port for the dashboard. If not set, dashboard is served on each topic's port. |
321323

322324
#### Subscription Validation
323325

@@ -719,6 +721,67 @@ await client.PublishEventsWithHttpMessagesAsync(
719721
events: new List<EventGridEvent> { <your event> });
720722
```
721723

724+
## Dashboard
725+
726+
The simulator includes an optional web-based dashboard for monitoring events and debugging issues.
727+
728+
### Configuration
729+
730+
The dashboard is enabled by default. To disable it, add `dashboardEnabled: false` to your `appsettings.json`:
731+
732+
```json
733+
{
734+
"dashboardEnabled": false,
735+
"topics": [
736+
...
737+
]
738+
}
739+
```
740+
741+
You can optionally configure a dedicated port for the dashboard:
742+
743+
```json
744+
{
745+
"dashboardPort": 5000,
746+
"topics": [
747+
...
748+
]
749+
}
750+
```
751+
752+
### Accessing the Dashboard
753+
754+
The dashboard is available at `/dashboard` on any topic port. For example, if you have a topic configured on port 60101:
755+
756+
```
757+
https://localhost:60101/dashboard
758+
```
759+
760+
If you've configured a dedicated `dashboardPort`, use that port instead.
761+
762+
### Features
763+
764+
- **Event History**: View the last 100 events received by the simulator
765+
- **Delivery Status**: Track delivery attempts to each subscriber (Delivered, Failed, Pending, Retrying)
766+
- **Rejected Events**: View events that failed validation with error details and the raw request body
767+
- **Topic Filtering**: Filter events by topic name
768+
- **Auto-refresh**: Dashboard automatically refreshes every 2 seconds (can be toggled off)
769+
- **Clear History**: Reset all event history and statistics
770+
771+
### Dashboard Statistics
772+
773+
The dashboard displays real-time statistics:
774+
775+
| Stat | Description |
776+
|----------------|--------------------------------------------------|
777+
| Total Received | Total events received since startup |
778+
| In History | Events currently in the history buffer (max 100) |
779+
| Delivered | Successful deliveries to subscribers |
780+
| Failed | Failed delivery attempts |
781+
| Pending | Deliveries in progress or awaiting retry |
782+
| Rejected | Events that failed validation (400 errors) |
783+
| Active Topics | Number of configured topics |
784+
722785
## Notes
723786

724787
### HTTPs
@@ -830,4 +893,3 @@ Some features that could be added if there was a need for them:
830893

831894
- Certificate configuration in `appsettings.json`.
832895
- Subscriber token auth.
833-
- Web-based console for admin stats etc.

docker/appsettings.docker.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
{
2+
"dashboardEnabled": true,
23
"Serilog": {
34
"Using": [
45
"Serilog.Sinks.Console",

docker/docker-compose-up.ps1

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,5 @@ $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
55
docker-compose -f "$scriptDir/docker-compose.yml" up `
66
--build `
77
--force-recreate `
8-
--no-cache `
98
--remove-orphans `
109
--detach
Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
using AzureEventGridSimulator.Domain.Entities;
2+
using AzureEventGridSimulator.Domain.Entities.Dashboard;
3+
using AzureEventGridSimulator.Domain.Services.Dashboard;
4+
using AzureEventGridSimulator.Infrastructure.Settings;
5+
using AzureEventGridSimulator.Infrastructure.Settings.Subscribers;
6+
using NSubstitute;
7+
using Shouldly;
8+
using Xunit;
9+
10+
namespace AzureEventGridSimulator.Tests.Domain.Services.Dashboard;
11+
12+
[Trait("Category", "unit")]
13+
public class EventHistoryServiceTests
14+
{
15+
private readonly ILogger<EventHistoryService> _logger;
16+
private readonly EventHistoryService _service;
17+
private readonly SimulatorSettings _settings;
18+
private readonly EventHistoryStore _store;
19+
20+
public EventHistoryServiceTests()
21+
{
22+
_store = new EventHistoryStore();
23+
_settings = new SimulatorSettings
24+
{
25+
Topics =
26+
[
27+
new TopicSettings
28+
{
29+
Name = "topic-1",
30+
Port = 60101,
31+
Key = "key1",
32+
},
33+
new TopicSettings
34+
{
35+
Name = "topic-2",
36+
Port = 60102,
37+
Key = "key2",
38+
Disabled = true,
39+
},
40+
],
41+
};
42+
_logger = Substitute.For<ILogger<EventHistoryService>>();
43+
_service = new EventHistoryService(_store, _settings, _logger);
44+
}
45+
46+
[Fact]
47+
public void RecordEventReceived_AddsEventToStore()
48+
{
49+
var evt = CreateTestEvent("event-1");
50+
var topic = new TopicSettings
51+
{
52+
Name = "test-topic",
53+
Port = 60101,
54+
Key = "key",
55+
};
56+
57+
_service.RecordEventReceived(evt, topic, EventSchema.EventGridSchema);
58+
59+
_store.Count.ShouldBe(1);
60+
var recorded = _store.Get("event-1");
61+
recorded.ShouldNotBeNull();
62+
recorded.TopicName.ShouldBe("test-topic");
63+
recorded.TopicPort.ShouldBe(60101);
64+
}
65+
66+
[Fact]
67+
public void RecordDeliveryQueued_AddsDeliveryToEvent()
68+
{
69+
var evt = CreateTestEvent("event-1");
70+
var topic = new TopicSettings
71+
{
72+
Name = "test-topic",
73+
Port = 60101,
74+
Key = "key",
75+
};
76+
_service.RecordEventReceived(evt, topic, EventSchema.EventGridSchema);
77+
78+
var subscriber = new HttpSubscriberSettings
79+
{
80+
Name = "http-subscriber",
81+
Endpoint = "https://example.com/webhook",
82+
};
83+
84+
_service.RecordDeliveryQueued("event-1", subscriber);
85+
86+
var recorded = _store.Get("event-1");
87+
recorded.ShouldNotBeNull();
88+
var deliveries = recorded.GetDeliveries();
89+
deliveries.Count.ShouldBe(1);
90+
deliveries[0].SubscriberName.ShouldBe("http-subscriber");
91+
deliveries[0].Status.ShouldBe(DeliveryStatus.Pending);
92+
}
93+
94+
[Fact]
95+
public void RecordDeliveryAttempt_UpdatesDeliveryStatus()
96+
{
97+
// Setup
98+
var evt = CreateTestEvent("event-1");
99+
var topic = new TopicSettings
100+
{
101+
Name = "test-topic",
102+
Port = 60101,
103+
Key = "key",
104+
};
105+
_service.RecordEventReceived(evt, topic, EventSchema.EventGridSchema);
106+
107+
var subscriber = new HttpSubscriberSettings
108+
{
109+
Name = "http-subscriber",
110+
Endpoint = "https://example.com/webhook",
111+
};
112+
_service.RecordDeliveryQueued("event-1", subscriber);
113+
114+
// Act
115+
var attempt = new DeliveryAttempt
116+
{
117+
AttemptNumber = 1,
118+
AttemptTime = DateTime.UtcNow,
119+
Outcome = DeliveryOutcome.Success,
120+
HttpStatusCode = 200,
121+
};
122+
_service.RecordDeliveryAttempt("event-1", "http-subscriber", attempt);
123+
124+
// Assert
125+
var recorded = _store.Get("event-1");
126+
recorded.ShouldNotBeNull();
127+
var deliveries = recorded.GetDeliveries();
128+
deliveries[0].Status.ShouldBe(DeliveryStatus.Delivered);
129+
deliveries[0].Attempts.Count.ShouldBe(1);
130+
deliveries[0].Attempts[0].HttpStatusCode.ShouldBe(200);
131+
}
132+
133+
[Fact]
134+
public void RecordDeliveryAttempt_NonExistingEvent_DoesNotThrow()
135+
{
136+
var attempt = new DeliveryAttempt
137+
{
138+
AttemptNumber = 1,
139+
AttemptTime = DateTime.UtcNow,
140+
Outcome = DeliveryOutcome.Success,
141+
};
142+
143+
Should.NotThrow(() =>
144+
_service.RecordDeliveryAttempt("non-existing", "subscriber", attempt)
145+
);
146+
}
147+
148+
[Fact]
149+
public void RecordDeliveryCompleted_UpdatesStatusAndCompletedTime()
150+
{
151+
// Setup
152+
var evt = CreateTestEvent("event-1");
153+
var topic = new TopicSettings
154+
{
155+
Name = "test-topic",
156+
Port = 60101,
157+
Key = "key",
158+
};
159+
_service.RecordEventReceived(evt, topic, EventSchema.EventGridSchema);
160+
161+
var subscriber = new HttpSubscriberSettings
162+
{
163+
Name = "http-subscriber",
164+
Endpoint = "https://example.com/webhook",
165+
};
166+
_service.RecordDeliveryQueued("event-1", subscriber);
167+
168+
// Act
169+
var completedAt = DateTimeOffset.UtcNow;
170+
_service.RecordDeliveryCompleted(
171+
"event-1",
172+
"http-subscriber",
173+
DeliveryStatus.DeadLettered,
174+
completedAt
175+
);
176+
177+
// Assert
178+
var recorded = _store.Get("event-1");
179+
recorded.ShouldNotBeNull();
180+
var deliveries = recorded.GetDeliveries();
181+
deliveries[0].Status.ShouldBe(DeliveryStatus.DeadLettered);
182+
deliveries[0].CompletedAt.ShouldBe(completedAt);
183+
}
184+
185+
[Fact]
186+
public void GetRecentEvents_ReturnsAllEvents()
187+
{
188+
var evt1 = CreateTestEvent("event-1");
189+
var evt2 = CreateTestEvent("event-2");
190+
var topic = new TopicSettings
191+
{
192+
Name = "test-topic",
193+
Port = 60101,
194+
Key = "key",
195+
};
196+
197+
_service.RecordEventReceived(evt1, topic, EventSchema.EventGridSchema);
198+
_service.RecordEventReceived(evt2, topic, EventSchema.EventGridSchema);
199+
200+
var recent = _service.GetRecentEvents();
201+
202+
recent.Count.ShouldBe(2);
203+
}
204+
205+
[Fact]
206+
public void GetRecentEvents_WithTopicFilter_ReturnsFilteredEvents()
207+
{
208+
var evt1 = CreateTestEvent("event-1");
209+
var evt2 = CreateTestEvent("event-2");
210+
var topic1 = new TopicSettings
211+
{
212+
Name = "topic-a",
213+
Port = 60101,
214+
Key = "key",
215+
};
216+
var topic2 = new TopicSettings
217+
{
218+
Name = "topic-b",
219+
Port = 60102,
220+
Key = "key",
221+
};
222+
223+
_service.RecordEventReceived(evt1, topic1, EventSchema.EventGridSchema);
224+
_service.RecordEventReceived(evt2, topic2, EventSchema.EventGridSchema);
225+
226+
var filtered = _service.GetRecentEvents("topic-a");
227+
228+
filtered.Count.ShouldBe(1);
229+
filtered[0].TopicName.ShouldBe("topic-a");
230+
}
231+
232+
[Fact]
233+
public void GetEvent_ExistingEvent_ReturnsEvent()
234+
{
235+
var evt = CreateTestEvent("event-1");
236+
var topic = new TopicSettings
237+
{
238+
Name = "test-topic",
239+
Port = 60101,
240+
Key = "key",
241+
};
242+
_service.RecordEventReceived(evt, topic, EventSchema.EventGridSchema);
243+
244+
var result = _service.GetEvent("event-1");
245+
246+
result.ShouldNotBeNull();
247+
result.Id.ShouldBe("event-1");
248+
}
249+
250+
[Fact]
251+
public void GetEvent_NonExistingEvent_ReturnsNull()
252+
{
253+
var result = _service.GetEvent("non-existing");
254+
255+
result.ShouldBeNull();
256+
}
257+
258+
[Fact]
259+
public void GetStats_ReturnsStatsWithActiveTopicsCount()
260+
{
261+
var evt = CreateTestEvent("event-1");
262+
var topic = new TopicSettings
263+
{
264+
Name = "test-topic",
265+
Port = 60101,
266+
Key = "key",
267+
};
268+
_service.RecordEventReceived(evt, topic, EventSchema.EventGridSchema);
269+
270+
var stats = _service.GetStats();
271+
272+
stats.TotalEventsReceived.ShouldBe(1);
273+
stats.EventsInHistory.ShouldBe(1);
274+
// One topic is enabled, one is disabled
275+
stats.TopicsActive.ShouldBe(1);
276+
}
277+
278+
private static SimulatorEvent CreateTestEvent(string id)
279+
{
280+
return SimulatorEvent.FromEventGridEvent(
281+
new EventGridEvent
282+
{
283+
Id = id,
284+
Subject = "/test/subject",
285+
EventType = "Test.EventType",
286+
EventTime = DateTime.UtcNow.ToString("o"),
287+
DataVersion = "1.0",
288+
Data = new { Property = "Value" },
289+
}
290+
);
291+
}
292+
}

0 commit comments

Comments
 (0)