diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor
index 05a1208c..dddd2f07 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor
@@ -90,6 +90,20 @@
+ @if (_operationIdFilter is { } operationId)
+ {
+ @* Read-only pill for the ?correlationId= deep link (Flow Debugger /
+ Audit Log). Its × drops just this filter and re-queries; the
+ Clear button drops it along with everything else. *@
+
+ Operation @ShortId(operationId)
+
+
+ }
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor.cs
index 72d34fdc..27dc2c44 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor.cs
@@ -102,6 +102,11 @@ public partial class SiteCallsReport
private DateTime? _fromFilter;
private DateTime? _toFilter;
+ // Exact-match on one operation, seeded only from the ?correlationId= deep
+ // link (Flow Debugger / Audit Log). Rendered as a read-only pill rather than
+ // an editable control — there is no sensible way to type one in.
+ private Guid? _operationIdFilter;
+
// ── Trends ──────────────────────────────────────────────────────────────
// Collapsible KPI-history charts for the SiteCallAudit / Global series. The
// section is collapsed on init and the series load lazily on first expand
@@ -162,14 +167,26 @@ public partial class SiteCallsReport
ApplyQueryStringFilters();
await RefreshAll();
+
+ // The ?correlationId= deep link lands on the operation itself: when the
+ // filtered first page is exactly that one row, open its detail so the
+ // operator is not left to click the row they navigated for.
+ if (_operationIdFilter is { } op
+ && _siteCalls is { Count: 1 }
+ && _siteCalls[0].TrackedOperationId == op)
+ {
+ await ShowDetail(_siteCalls[0]);
+ }
}
///
/// Pre-apply the Health-dashboard KPI-tile drill-in filters from the URL query
/// string. ?status=<status> seeds when it
/// matches a known status (case-insensitive); ?stuck=true seeds
- /// . Lax parsing — an absent, blank, or unrecognised value
- /// is silently dropped, leaving the filter empty (the no-param behaviour).
+ /// ; ?correlationId=<guid> seeds
+ /// (the Flow Debugger / Audit Log deep link onto
+ /// one operation). Lax parsing — an absent, blank, or unrecognised value is
+ /// silently dropped, leaving the filter empty (the no-param behaviour).
///
private void ApplyQueryStringFilters()
{
@@ -200,6 +217,12 @@ public partial class SiteCallsReport
{
_stuckOnly = stuck;
}
+
+ if (query.TryGetValue("correlationId", out var correlationValues)
+ && Guid.TryParse(correlationValues.ToString(), out var operationId))
+ {
+ _operationIdFilter = operationId;
+ }
}
/// Re-fetch the current page (Refresh button, and after a relay action).
@@ -262,7 +285,8 @@ public partial class SiteCallsReport
AfterCreatedAtUtc: cursor.AfterCreatedAtUtc,
AfterId: cursor.AfterId,
PageSize: PageSize,
- SourceNodeFilter: NullIfEmpty(_nodeFilter));
+ SourceNodeFilter: NullIfEmpty(_nodeFilter),
+ TrackedOperationIdFilter: _operationIdFilter);
var response = await CommunicationService.QuerySiteCallsAsync(request);
if (response.Success)
@@ -476,9 +500,18 @@ public partial class SiteCallsReport
_stuckOnly = false;
_fromFilter = null;
_toFilter = null;
+ _operationIdFilter = null;
+ }
+
+ /// The Operation pill's own × — drop the deep-link filter and re-query.
+ private async Task ClearOperationFilter()
+ {
+ _operationIdFilter = null;
+ await Search();
}
private bool HasActiveFilters =>
+ _operationIdFilter != null ||
!string.IsNullOrEmpty(_statusFilter) ||
!string.IsNullOrEmpty(_channelFilter) ||
!string.IsNullOrEmpty(_siteFilter) ||
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/QueryStringDrillInTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/QueryStringDrillInTests.cs
index 1c8cc84b..f777c95e 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/QueryStringDrillInTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/QueryStringDrillInTests.cs
@@ -151,6 +151,51 @@ public sealed class QueryStringDrillInTests
Assert.Empty(cut.FindAll("[data-test='site-calls-filter-operation']"));
}
+ // -----------------------------------------------------------------
+ // SiteCallsReport — ?correlationId= (Flow Debugger / Audit Log deep link, D2)
+ // -----------------------------------------------------------------
+
+ [Fact]
+ public void SiteCallsReport_CorrelationIdQuery_SeedsTheOperationFilter()
+ {
+ // A well-formed GUID rides on the first query as the exact-match
+ // TrackedOperationIdFilter and renders the read-only Operation pill.
+ var op = Guid.Parse("44444444-4444-4444-4444-444444444444");
+ using var ctx = new SiteCallsReportFixture();
+ var nav = (BunitNavigationManager)ctx.Services.GetRequiredService();
+ nav.NavigateTo($"/site-calls/report?correlationId={op}");
+
+ var cut = ctx.Render();
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.Single(ctx.QueryRequests);
+ Assert.Equal(op, ctx.QueryRequests[0].TrackedOperationIdFilter);
+ var pill = cut.Find("[data-test='site-calls-filter-operation']");
+ Assert.Contains(op.ToString("N")[..12], pill.TextContent);
+ });
+ }
+
+ [Fact]
+ public void SiteCallsReport_CorrelationIdQuery_Malformed_IsIgnored()
+ {
+ // Lax parsing, like ?status= — a value that is not a GUID leaves the
+ // filter null and renders no pill, so a hand-crafted bad URL loads the
+ // page unfiltered instead of breaking it.
+ using var ctx = new SiteCallsReportFixture();
+ var nav = (BunitNavigationManager)ctx.Services.GetRequiredService();
+ nav.NavigateTo("/site-calls/report?correlationId=nope");
+
+ var cut = ctx.Render();
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.Single(ctx.QueryRequests);
+ Assert.Null(ctx.QueryRequests[0].TrackedOperationIdFilter);
+ });
+ Assert.Empty(cut.FindAll("[data-test='site-calls-filter-operation']"));
+ }
+
// -----------------------------------------------------------------
// NotificationReport — ?correlationId= (Flow Debugger follow-ups, D2)
// -----------------------------------------------------------------
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs
index 71477834..04565490 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs
@@ -65,6 +65,16 @@ public class SiteCallsReportPageTests : BunitContext
private readonly List _queryRequests = new();
private readonly List _retryRequests = new();
private readonly List _discardRequests = new();
+ private readonly List _detailRequests = new();
+
+ // Scripted detail reply — the ?correlationId= auto-open path fetches the full
+ // row for the modal body. Overridable per test.
+ private SiteCallDetailResponse _detailReply = new(
+ "q", true, null,
+ new SiteCallDetail(ParkedId, "plant-a", "ApiOutbound", "ERP.GetOrder", "Parked",
+ RetryCount: 3, LastError: "HTTP 503 from ERP", HttpStatus: 503,
+ CreatedAtUtc: DateTime.UtcNow.AddMinutes(-30), UpdatedAtUtc: DateTime.UtcNow.AddMinutes(-5),
+ TerminalAtUtc: null, IngestedAtUtc: DateTime.UtcNow.AddMinutes(-5)));
// Scripted relay responses — overridable per test.
private RetrySiteCallResponse _retryReply =
@@ -506,6 +516,105 @@ public class SiteCallsReportPageTests : BunitContext
});
}
+ // ─────────────────────────────────────────────────────────────────────────
+ // Flow Debugger / Audit Log deep link: ?correlationId=
+ // seeds a read-only "Operation" pill filter, rides on the query as
+ // TrackedOperationIdFilter, and auto-opens the detail modal when exactly
+ // one matching row comes back (follow-ups plan D2 / Task 4).
+ // ─────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void SiteCallsReport_CorrelationIdQuery_SeedsTheOperationFilterAndAutoOpensDetail()
+ {
+ // The scripted query returns exactly the one row the deep link names.
+ _queryReply = _queryReply with
+ {
+ SiteCalls = _queryReply.SiteCalls.Where(c => c.TrackedOperationId == ParkedId).ToList(),
+ };
+
+ var nav = (BunitNavigationManager)Services.GetRequiredService();
+ nav.NavigateTo($"/site-calls/report?correlationId={ParkedId}");
+
+ var cut = Render();
+
+ cut.WaitForAssertion(() =>
+ {
+ // The first query already carries the operation filter.
+ Assert.Single(_queryRequests);
+ Assert.Equal(ParkedId, _queryRequests[0].TrackedOperationIdFilter);
+
+ // The filter card shows the read-only pill with the short id.
+ var pill = cut.Find("[data-test='site-calls-filter-operation']");
+ Assert.Contains("Operation", pill.TextContent);
+ Assert.Contains(ParkedId.ToString("N")[..12], pill.TextContent);
+
+ // Exactly one matching row → the detail modal opened on it.
+ var title = cut.Find(".modal .modal-title");
+ Assert.Contains("Site Call Detail", title.TextContent);
+ Assert.Contains(ParkedId.ToString("N")[..12], title.TextContent);
+ Assert.Single(_detailRequests);
+ Assert.Equal(ParkedId, _detailRequests[0].TrackedOperationId);
+ });
+ }
+
+ [Fact]
+ public void SiteCallsReport_CorrelationIdQuery_NoMatchingRow_DoesNotOpenDetail()
+ {
+ // The default scripted reply carries two rows, neither of which is the
+ // requested id — the filter still rides on the query, but nothing
+ // auto-opens.
+ var other = Guid.Parse("33333333-3333-3333-3333-333333333333");
+ var nav = (BunitNavigationManager)Services.GetRequiredService();
+ nav.NavigateTo($"/site-calls/report?correlationId={other}");
+
+ var cut = Render();
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.Single(_queryRequests);
+ Assert.Equal(other, _queryRequests[0].TrackedOperationIdFilter);
+ Assert.NotNull(cut.Find("[data-test='site-calls-filter-operation']"));
+ });
+ Assert.Empty(cut.FindAll(".modal"));
+ Assert.Empty(_detailRequests);
+ }
+
+ [Fact]
+ public void ClearFilters_DropsTheOperationFilter()
+ {
+ var nav = (BunitNavigationManager)Services.GetRequiredService();
+ nav.NavigateTo($"/site-calls/report?correlationId={ParkedId}");
+
+ var cut = Render();
+ cut.WaitForAssertion(() => Assert.NotNull(cut.Find("[data-test='site-calls-filter-operation']")));
+
+ // The pill counts as an active filter, so Clear is enabled.
+ var clear = cut.FindAll("button").Single(b => b.TextContent.Trim() == "Clear");
+ Assert.False(clear.HasAttribute("disabled"));
+ clear.Click();
+
+ cut.WaitForAssertion(() => Assert.Empty(cut.FindAll("[data-test='site-calls-filter-operation']")));
+ }
+
+ [Fact]
+ public void OperationPill_ClearButton_DropsTheFilterAndRequeries()
+ {
+ var nav = (BunitNavigationManager)Services.GetRequiredService();
+ nav.NavigateTo($"/site-calls/report?correlationId={ParkedId}");
+
+ var cut = Render();
+ cut.WaitForAssertion(() => Assert.Single(_queryRequests));
+
+ cut.Find("[data-test='site-calls-filter-operation-clear']").Click();
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.Equal(2, _queryRequests.Count);
+ Assert.Null(_queryRequests[1].TrackedOperationIdFilter);
+ Assert.Empty(cut.FindAll("[data-test='site-calls-filter-operation']"));
+ });
+ }
+
[Fact]
public void SiteScoping_ScopedDeploymentUser_HidesOutOfScopeRows()
{
@@ -673,6 +782,11 @@ public class SiteCallsReportPageTests : BunitContext
test._discardRequests.Add(r);
Sender.Tell(test._discardReply);
});
+ Receive(r =>
+ {
+ test._detailRequests.Add(r);
+ Sender.Tell(test._detailReply);
+ });
}
}