diff --git a/src/ScadaLink.CentralUI/Components/Pages/Admin/ApiKeyForm.razor b/src/ScadaLink.CentralUI/Components/Pages/Admin/ApiKeyForm.razor
index b47872d..e6bd069 100644
--- a/src/ScadaLink.CentralUI/Components/Pages/Admin/ApiKeyForm.razor
+++ b/src/ScadaLink.CentralUI/Components/Pages/Admin/ApiKeyForm.razor
@@ -27,6 +27,17 @@
@:Add API Key
}
+ @* Bundle D (#23 M7-T12) drill-in: deep-link into the central Audit Log
+ pre-filtered to this API key's inbound calls. Inbound audit rows record
+ the key Name as Actor and live on the ApiInbound channel. *@
+ @if (IsEditMode && !string.IsNullOrWhiteSpace(_formName))
+ {
+
+ Recent audit activity
+
+ }
diff --git a/src/ScadaLink.CentralUI/Components/Pages/Admin/SiteForm.razor b/src/ScadaLink.CentralUI/Components/Pages/Admin/SiteForm.razor
index 3c80438..7755334 100644
--- a/src/ScadaLink.CentralUI/Components/Pages/Admin/SiteForm.razor
+++ b/src/ScadaLink.CentralUI/Components/Pages/Admin/SiteForm.razor
@@ -20,7 +20,20 @@
-
@(IsEditMode ? "Edit Site" : "Add Site")
+
+
@(IsEditMode ? "Edit Site" : "Add Site")
+ @* Bundle D (#23 M7-T12) drill-in: deep-link into the central Audit
+ Log pre-filtered to this site's events. AuditEvent.SourceSiteId
+ stores the SiteIdentifier (string), so we pass that through. *@
+ @if (IsEditMode && !string.IsNullOrWhiteSpace(_formIdentifier))
+ {
+
+ Recent audit activity
+
+ }
+
Configure Instance
+ @* Bundle D (#23 M7-T12) drill-in: deep-link into the central Audit Log
+ pre-filtered to this instance. Instance is UI-only on the filter bar
+ (AuditEvent has no Instance column), so we use the ?instance= UI-text
+ seam — the filter bar's Instance free-text input is pre-populated. *@
+ @if (_instance != null)
+ {
+
+ Recent audit activity
+
+ }
+ @* Bundle D (#23 M7-T12) drill-in: deep-link into the central Audit Log
+ pre-filtered to this external system's outbound API events. Audit rows
+ record the target by external-system name, so we filter on Target. *@
+ @if (Id.HasValue && !string.IsNullOrWhiteSpace(_name))
+ {
+
+ Recent audit activity
+
+ }
+
@if (_loading)
{
diff --git a/tests/ScadaLink.CentralUI.Tests/Admin/ApiKeyFormAuditDrillinTests.cs b/tests/ScadaLink.CentralUI.Tests/Admin/ApiKeyFormAuditDrillinTests.cs
new file mode 100644
index 0000000..5eb5a04
--- /dev/null
+++ b/tests/ScadaLink.CentralUI.Tests/Admin/ApiKeyFormAuditDrillinTests.cs
@@ -0,0 +1,72 @@
+using System.Security.Claims;
+using Bunit;
+using Microsoft.AspNetCore.Components;
+using Microsoft.AspNetCore.Components.Authorization;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.JSInterop;
+using NSubstitute;
+using ScadaLink.Commons.Entities.InboundApi;
+using ScadaLink.Commons.Interfaces.Repositories;
+using ScadaLink.Security;
+using ApiKeyForm = ScadaLink.CentralUI.Components.Pages.Admin.ApiKeyForm;
+
+namespace ScadaLink.CentralUI.Tests.Admin;
+
+///
+/// Bundle D drill-in test (#23 M7-T12) for the API Keys edit page. The chip
+/// routes operators into the central Audit Log pre-filtered by Actor = ApiKey.Name
+/// AND Channel = ApiInbound (no other channel uses the key name as actor, but
+/// the explicit channel scope keeps deep links tight). Create mode suppresses
+/// the link — there's no API key to drill into yet.
+///
+public class ApiKeyFormAuditDrillinTests : BunitContext
+{
+ private readonly IInboundApiRepository _repo = Substitute.For();
+
+ public ApiKeyFormAuditDrillinTests()
+ {
+ Services.AddSingleton(_repo);
+
+ var claims = new[]
+ {
+ new Claim("Username", "admin"),
+ new Claim(JwtTokenService.RoleClaimType, "Admin"),
+ };
+ var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
+ Services.AddSingleton(new TestAuthStateProvider(user));
+ Services.AddAuthorizationCore();
+ AuthorizationPolicies.AddScadaLinkAuthorization(Services);
+ }
+
+ [Fact]
+ public void EditPage_HasRecentAuditActivityLink_WithActorAndApiInboundChannel()
+ {
+ var key = ApiKey.FromHash("Orders-Integration", "k-hash");
+ key.Id = 11;
+ _repo.GetApiKeyByIdAsync(11, Arg.Any()).Returns(key);
+ _repo.GetAllApiMethodsAsync(Arg.Any())
+ .Returns(Task.FromResult>(new List()));
+
+ var cut = Render(p => p.Add(c => c.Id, 11));
+
+ cut.WaitForAssertion(() =>
+ {
+ var link = cut.Find("a[data-test=\"audit-link\"]");
+ Assert.Equal(
+ "/audit/log?actor=Orders-Integration&channel=ApiInbound",
+ link.GetAttribute("href"));
+ Assert.Contains("Recent audit activity", link.TextContent);
+ });
+ }
+
+ [Fact]
+ public void CreatePage_HasNoRecentAuditActivityLink()
+ {
+ var cut = Render();
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.Empty(cut.FindAll("a[data-test=\"audit-link\"]"));
+ });
+ }
+}
diff --git a/tests/ScadaLink.CentralUI.Tests/Admin/SiteFormAuditDrillinTests.cs b/tests/ScadaLink.CentralUI.Tests/Admin/SiteFormAuditDrillinTests.cs
new file mode 100644
index 0000000..265c30e
--- /dev/null
+++ b/tests/ScadaLink.CentralUI.Tests/Admin/SiteFormAuditDrillinTests.cs
@@ -0,0 +1,73 @@
+using System.Security.Claims;
+using Bunit;
+using Microsoft.AspNetCore.Components;
+using Microsoft.AspNetCore.Components.Authorization;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using NSubstitute;
+using ScadaLink.Commons.Entities.Sites;
+using ScadaLink.Commons.Interfaces.Repositories;
+using ScadaLink.Communication;
+using ScadaLink.Security;
+using SiteForm = ScadaLink.CentralUI.Components.Pages.Admin.SiteForm;
+
+namespace ScadaLink.CentralUI.Tests.Admin;
+
+///
+/// Bundle D drill-in test (#23 M7-T12) for the Site edit page. The chip
+/// routes operators into the central Audit Log pre-filtered by SourceSiteId =
+/// Site.SiteIdentifier (the same string the audit pipeline stamps onto every
+/// site-sourced row). Create mode suppresses the link — there's no site yet.
+///
+public class SiteFormAuditDrillinTests : BunitContext
+{
+ private readonly ISiteRepository _siteRepo = Substitute.For();
+ private readonly CommunicationService _comms;
+
+ public SiteFormAuditDrillinTests()
+ {
+ _comms = new CommunicationService(
+ Options.Create(new CommunicationOptions()),
+ NullLogger.Instance);
+ Services.AddSingleton(_siteRepo);
+ Services.AddSingleton(_comms);
+
+ var claims = new[]
+ {
+ new Claim("Username", "admin"),
+ new Claim(JwtTokenService.RoleClaimType, "Admin"),
+ };
+ var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
+ Services.AddSingleton(new TestAuthStateProvider(user));
+ Services.AddAuthorizationCore();
+ AuthorizationPolicies.AddScadaLinkAuthorization(Services);
+ }
+
+ [Fact]
+ public void EditPage_HasRecentAuditActivityLink_WithSiteEqualToSiteIdentifier()
+ {
+ _siteRepo.GetSiteByIdAsync(3, Arg.Any())
+ .Returns(new Site("Plant A", "plant-a") { Id = 3 });
+
+ var cut = Render(p => p.Add(c => c.Id, 3));
+
+ cut.WaitForAssertion(() =>
+ {
+ var link = cut.Find("a[data-test=\"audit-link\"]");
+ Assert.Equal("/audit/log?site=plant-a", link.GetAttribute("href"));
+ Assert.Contains("Recent audit activity", link.TextContent);
+ });
+ }
+
+ [Fact]
+ public void CreatePage_HasNoRecentAuditActivityLink()
+ {
+ var cut = Render();
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.Empty(cut.FindAll("a[data-test=\"audit-link\"]"));
+ });
+ }
+}
diff --git a/tests/ScadaLink.CentralUI.Tests/Deployment/InstanceConfigureAuditDrillinTests.cs b/tests/ScadaLink.CentralUI.Tests/Deployment/InstanceConfigureAuditDrillinTests.cs
new file mode 100644
index 0000000..25a2657
--- /dev/null
+++ b/tests/ScadaLink.CentralUI.Tests/Deployment/InstanceConfigureAuditDrillinTests.cs
@@ -0,0 +1,100 @@
+using System.Security.Claims;
+using Bunit;
+using Microsoft.AspNetCore.Components;
+using Microsoft.AspNetCore.Components.Authorization;
+using Microsoft.Extensions.DependencyInjection;
+using NSubstitute;
+using ScadaLink.CentralUI.Auth;
+using ScadaLink.Commons.Entities.Instances;
+using ScadaLink.Commons.Entities.Sites;
+using ScadaLink.Commons.Entities.Templates;
+using ScadaLink.Commons.Interfaces.Repositories;
+using ScadaLink.Commons.Interfaces.Services;
+using ScadaLink.DeploymentManager;
+using ScadaLink.Security;
+using ScadaLink.TemplateEngine.Services;
+using InstanceConfigurePage = ScadaLink.CentralUI.Components.Pages.Deployment.InstanceConfigure;
+
+namespace ScadaLink.CentralUI.Tests.Deployment;
+
+///
+/// Bundle D drill-in test (#23 M7-T12) for the Instance Configure page. The
+/// chip routes operators into the central Audit Log pre-filtered by
+/// ?instance={Instance.UniqueName}. Instance is UI-only on the filter
+/// bar (the repository filter contract has no instance column), so the page
+/// uses the UI-text seam — the Audit Log's filter bar pre-populates its
+/// Instance free-text input from this query string.
+///
+public class InstanceConfigureAuditDrillinTests : BunitContext
+{
+ private readonly ITemplateEngineRepository _templateRepo =
+ Substitute.For();
+ private readonly ISiteRepository _siteRepo = Substitute.For();
+
+ public InstanceConfigureAuditDrillinTests()
+ {
+ // Loose JS interop because shared components on the page render
+ // localStorage / clipboard touches that we don't care about here.
+ JSInterop.Mode = JSRuntimeMode.Loose;
+
+ Services.AddSingleton(_templateRepo);
+ Services.AddSingleton(_siteRepo);
+
+ Services.AddSingleton(new InstanceService(_templateRepo, Substitute.For()));
+ Services.AddSingleton(Substitute.For());
+
+ // Auth: a system-wide Deployment user so SiteScope grants everything.
+ var claims = new[]
+ {
+ new Claim("Username", "deployer"),
+ new Claim(JwtTokenService.RoleClaimType, "Deployment"),
+ };
+ var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
+ var authProvider = new TestAuthStateProvider(user);
+ Services.AddSingleton(authProvider);
+ Services.AddSingleton(new SiteScopeService(authProvider));
+ Services.AddAuthorizationCore();
+ AuthorizationPolicies.AddScadaLinkAuthorization(Services);
+ }
+
+ [Fact]
+ public void Page_HasRecentAuditActivityLink_WithInstanceUniqueName()
+ {
+ var instance = new Instance("Pump-Station-007")
+ {
+ Id = 42,
+ TemplateId = 1,
+ SiteId = 1,
+ State = ScadaLink.Commons.Types.Enums.InstanceState.NotDeployed,
+ };
+
+ _templateRepo.GetInstanceByIdAsync(42, Arg.Any()).Returns(instance);
+ _templateRepo.GetTemplateByIdAsync(1, Arg.Any())
+ .Returns(new Template("Pump") { Id = 1 });
+ _siteRepo.GetAllSitesAsync(Arg.Any())
+ .Returns(new List { new("Plant A", "plant-a") { Id = 1 } });
+ _templateRepo.GetAreasBySiteIdAsync(1, Arg.Any())
+ .Returns(new List());
+ _templateRepo.GetAttributesByTemplateIdAsync(1, Arg.Any())
+ .Returns(new List());
+ _siteRepo.GetDataConnectionsBySiteIdAsync(1, Arg.Any())
+ .Returns(new List());
+ _templateRepo.GetBindingsByInstanceIdAsync(42, Arg.Any())
+ .Returns(new List());
+ _templateRepo.GetOverridesByInstanceIdAsync(42, Arg.Any())
+ .Returns(new List());
+ _templateRepo.GetAlarmsByTemplateIdAsync(1, Arg.Any())
+ .Returns(new List());
+ _templateRepo.GetAlarmOverridesByInstanceIdAsync(42, Arg.Any())
+ .Returns(new List());
+
+ var cut = Render(p => p.Add(c => c.Id, 42));
+
+ cut.WaitForAssertion(() =>
+ {
+ var link = cut.Find("a[data-test=\"audit-link\"]");
+ Assert.Equal("/audit/log?instance=Pump-Station-007", link.GetAttribute("href"));
+ Assert.Contains("Recent audit activity", link.TextContent);
+ });
+ }
+}
diff --git a/tests/ScadaLink.CentralUI.Tests/Design/ExternalSystemFormAuditDrillinTests.cs b/tests/ScadaLink.CentralUI.Tests/Design/ExternalSystemFormAuditDrillinTests.cs
new file mode 100644
index 0000000..cf685ef
--- /dev/null
+++ b/tests/ScadaLink.CentralUI.Tests/Design/ExternalSystemFormAuditDrillinTests.cs
@@ -0,0 +1,70 @@
+using System.Security.Claims;
+using Bunit;
+using Microsoft.AspNetCore.Components;
+using Microsoft.AspNetCore.Components.Authorization;
+using Microsoft.Extensions.DependencyInjection;
+using NSubstitute;
+using ScadaLink.Commons.Entities.ExternalSystems;
+using ScadaLink.Commons.Interfaces.Repositories;
+using ScadaLink.Security;
+using ExternalSystemForm = ScadaLink.CentralUI.Components.Pages.Design.ExternalSystemForm;
+
+namespace ScadaLink.CentralUI.Tests.Design;
+
+///
+/// Bundle D drill-in test (#23 M7-T12) for the External Systems edit page.
+/// The page-header chip routes operators into the central Audit Log
+/// pre-filtered by Target = external-system name. Create mode has nothing
+/// to drill into yet, so the link is suppressed.
+///
+public class ExternalSystemFormAuditDrillinTests : BunitContext
+{
+ private readonly IExternalSystemRepository _repo = Substitute.For();
+
+ public ExternalSystemFormAuditDrillinTests()
+ {
+ Services.AddSingleton(_repo);
+
+ var claims = new[]
+ {
+ new Claim("Username", "tester"),
+ new Claim(JwtTokenService.RoleClaimType, "Design"),
+ };
+ var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
+ Services.AddSingleton(new TestAuthStateProvider(user));
+ Services.AddAuthorizationCore();
+ AuthorizationPolicies.AddScadaLinkAuthorization(Services);
+ }
+
+ [Fact]
+ public void EditPage_HasRecentAuditActivityLink_WithTargetEqualToSystemName()
+ {
+ _repo.GetExternalSystemByIdAsync(7, Arg.Any())
+ .Returns(new ExternalSystemDefinition("ERP-Alpha", "https://erp.example.test", "ApiKey")
+ {
+ Id = 7,
+ });
+
+ var cut = Render(p => p.Add(c => c.Id, 7));
+
+ cut.WaitForAssertion(() =>
+ {
+ var link = cut.Find("a[data-test=\"audit-link\"]");
+ Assert.Equal("/audit/log?target=ERP-Alpha", link.GetAttribute("href"));
+ Assert.Contains("Recent audit activity", link.TextContent);
+ });
+ }
+
+ [Fact]
+ public void CreatePage_HasNoRecentAuditActivityLink()
+ {
+ // Create mode (Id is null) — there's no real external system to drill into,
+ // so the link must not render.
+ var cut = Render();
+
+ cut.WaitForAssertion(() =>
+ {
+ Assert.Empty(cut.FindAll("a[data-test=\"audit-link\"]"));
+ });
+ }
+}