diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Monitoring/EventLogsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Monitoring/EventLogsTests.cs
new file mode 100644
index 00000000..c1df6f76
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Monitoring/EventLogsTests.cs
@@ -0,0 +1,110 @@
+using Microsoft.Playwright;
+using Xunit;
+using ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;
+
+namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Monitoring;
+
+///
+/// End-to-end render + control-gating tests for the Site Event Logs page
+/// (/monitoring/event-logs).
+///
+///
+/// Why this is a render + controls + tolerant-query suite (no row assertions):
+/// Site event logs live in the SITE's local SQLite store and are fetched on demand via
+/// an Akka Ask round-trip to a live site — there is no central table, and no CLI
+/// or DB seed path. A clean site may legitimately have zero logged events, so these
+/// facts must never assert on specific event rows. Instead they verify the two things
+/// that are deterministic regardless of site state:
+///
+/// - The Search button is gated on site selection (disabled until a
+/// site is chosen, then enabled).
+/// - Clicking Search resolves the query — the results table renders
+/// either a data row or the empty-state row.
+///
+/// The row-expand affordance is exercised only when real data rows happen to exist.
+///
+///
+///
+/// Gated on via Skip.IfNot: when the cluster is
+/// unreachable the facts report as Skipped (not Failed), matching the suite idiom.
+///
+///
+[Collection("Playwright")]
+public class EventLogsTests
+{
+ private const string EventLogsUrl = "/monitoring/event-logs";
+
+ private readonly PlaywrightFixture _fixture;
+
+ public EventLogsTests(PlaywrightFixture fixture)
+ {
+ _fixture = fixture;
+ }
+
+ ///
+ /// The Search button must be disabled on page load (no site selected) and become
+ /// enabled once a site is selected from the site dropdown. Deterministic regardless
+ /// of whether the site has any logged events.
+ ///
+ [SkippableFact]
+ public async Task EventLogs_SearchGatedOnSiteSelection()
+ {
+ Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
+
+ var page = await _fixture.NewAuthenticatedPageAsync();
+ await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{EventLogsUrl}");
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+
+ await Assertions.Expect(page.Locator("h4:has-text('Site Event Logs')")).ToBeVisibleAsync();
+
+ // The site selector is populated from the cluster's known sites; the live cluster
+ // exposes site-a. Each option's value is the site's SiteIdentifier.
+ await Assertions.Expect(page.Locator("#filter-site option[value='site-a']")).ToHaveCountAsync(1);
+
+ // Search is gated: disabled until a site is selected.
+ var search = page.Locator("button.btn.btn-primary.btn-sm:has-text('Search')");
+ await Assertions.Expect(search).ToBeDisabledAsync();
+
+ await page.Locator("#filter-site").SelectOptionAsync(new SelectOptionValue { Value = "site-a" });
+ await Assertions.Expect(search).ToBeEnabledAsync(new() { Timeout = 5_000 });
+ }
+
+ ///
+ /// After selecting a site and clicking Search, the query must resolve: the results
+ /// table renders either a data row or the empty-state row (both match
+ /// table tbody tr). The row-expand affordance is exercised only when real data
+ /// rows exist — a site may have zero logged events, and event logs are not seedable
+ /// from central, so the empty-state render is itself the assertion in that case.
+ ///
+ [SkippableFact]
+ public async Task EventLogs_Search_RendersTableOrEmptyState()
+ {
+ Skip.IfNot(await ClusterAvailability.IsAvailableAsync(), ClusterAvailability.SkipReason);
+
+ var page = await _fixture.NewAuthenticatedPageAsync();
+ await page.GotoAsync($"{PlaywrightFixture.BaseUrl}{EventLogsUrl}");
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+
+ await page.Locator("#filter-site").SelectOptionAsync(new SelectOptionValue { Value = "site-a" });
+ await page.Locator("button.btn.btn-primary.btn-sm:has-text('Search')").ClickAsync();
+
+ // The query resolved once the results table renders. The empty-state is itself a
+ //
| No events found. |
, so a data row OR the empty-state
+ // row both surface under table tbody tr. Generous timeout: the fetch is an Akka
+ // Ask round-trip to a live site.
+ var settled = page.Locator("table tbody tr, td:has-text('No events found.')");
+ await Assertions.Expect(settled.First).ToBeVisibleAsync(new() { Timeout = 15_000 });
+
+ // Exercise the row-expand ONLY IF real data rows exist. A clean site may have zero
+ // logged events, and event logs are not seedable from central — so when no expand
+ // toggle is present, the empty-state render above is the assertion (tolerated).
+ var expandBtns = page.Locator("button[aria-label='View full message']");
+ if (await expandBtns.CountAsync() > 0)
+ {
+ await expandBtns.First.ClickAsync();
+ await Assertions.Expect(page.Locator("button[aria-label='Hide full message']").First)
+ .ToBeVisibleAsync(new() { Timeout = 5_000 });
+ }
+ // else: no events logged on this site — the empty-state render is the assertion.
+ }
+}