perf(adminui): /scripts lists a projection and loads source per dialog (R04 P8)

The list loaded every script entity including SourceCode to render names and
hashes. ScriptListQueries projects {ScriptId, Name, Language, SourceHash} for
the list and fetches one script's source when its Source dialog opens, with a
generation guard against a superseded open.

Refs #745
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 17:30:50 -04:00
parent 8e69118200
commit 10b83d69c5
3 changed files with 138 additions and 11 deletions
@@ -3,7 +3,7 @@
@rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptList
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
<PageErrorBoundary>
@@ -89,13 +89,17 @@ else
<button type="button" class="btn-close" aria-label="Close" @onclick="CloseSource"></button>
</div>
<div class="modal-body">
@if (src is not null)
@if (src is null || (_sourceLoaded && _sourceCode is null))
{
<pre class="mono small mb-0" style="background:var(--zebra-bg);padding:1rem;border-radius:4px;overflow:auto">@src.SourceCode</pre>
<p class="text-muted mb-0">This script is no longer loaded.</p>
}
else if (!_sourceLoaded)
{
<p class="text-muted mb-0">Loading source…</p>
}
else
{
<p class="text-muted mb-0">This script is no longer loaded.</p>
<pre class="mono small mb-0" style="background:var(--zebra-bg);padding:1rem;border-radius:4px;overflow:auto">@_sourceCode</pre>
}
</div>
<div class="modal-footer">
@@ -108,30 +112,53 @@ else
</PageErrorBoundary>
@code {
private List<Script>? _rows;
// A projection without SourceCode (R04 P8); the source loads per dialog open.
private List<ScriptListRow>? _rows;
// Only the id of the script whose source dialog is open — never the record itself, so a reload
// that drops or replaces the row cannot leave a stale object on screen.
private string? _sourceScriptId;
// The open dialog's source, fetched on open. _sourceGeneration discards a superseded fetch (the
// operator opened another row, or closed the dialog, while the first one was in flight).
private string? _sourceCode;
private bool _sourceLoaded;
private int _sourceGeneration;
protected override async Task OnInitializedAsync()
{
await using var db = await DbFactory.CreateDbContextAsync();
_rows = await db.Scripts.AsNoTracking()
.OrderBy(s => s.Name)
.ToListAsync();
_rows = await ScriptListQueries.LoadRowsAsync(db);
}
/// <summary>Re-resolves the open dialog's script from the current list on every render. Drives
/// the dialog's CONTENT only — never its existence (see the comment at the markup): a script
/// that is no longer loaded renders a notice with the frame and Close still mounted.</summary>
private Script? FindSourceScript()
private ScriptListRow? FindSourceScript()
=> _sourceScriptId is null
? null
: _rows?.FirstOrDefault(s => s.ScriptId == _sourceScriptId);
// One dialog at a time: opening another row replaces the id.
private void ShowSource(string scriptId) => _sourceScriptId = scriptId;
private async Task ShowSource(string scriptId)
{
var generation = ++_sourceGeneration;
_sourceScriptId = scriptId;
_sourceCode = null;
_sourceLoaded = false;
private void CloseSource() => _sourceScriptId = null;
await using var db = await DbFactory.CreateDbContextAsync();
var source = await ScriptListQueries.LoadSourceAsync(db, scriptId);
if (generation != _sourceGeneration) return;
_sourceCode = source;
_sourceLoaded = true;
}
private void CloseSource()
{
_sourceGeneration++;
_sourceScriptId = null;
_sourceCode = null;
_sourceLoaded = false;
}
}
@@ -0,0 +1,41 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptList;
/// <summary>One row of the <c>/scripts</c> list: everything the list renders, and nothing else.</summary>
/// <param name="ScriptId">The script's logical id.</param>
/// <param name="Name">The authored display name.</param>
/// <param name="Language">The script language (e.g. <c>CSharp</c>).</param>
/// <param name="SourceHash">The source hash shown abbreviated on the row.</param>
public sealed record ScriptListRow(string ScriptId, string Name, string Language, string SourceHash);
/// <summary>
/// Queries behind the <c>/scripts</c> page. The list is a projection that leaves out
/// <c>SourceCode</c> (R04 P8): loading every script's full source to render names and hashes made
/// the page cost proportional to the total size of every script in the fleet. The source is fetched
/// on demand, for one script, when its "Source" dialog opens.
/// </summary>
public static class ScriptListQueries
{
/// <summary>Loads the list rows, ordered by name, without any script's source.</summary>
/// <param name="db">The config DB context.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The list rows.</returns>
public static Task<List<ScriptListRow>> LoadRowsAsync(OtOpcUaConfigDbContext db, CancellationToken ct = default) =>
db.Scripts.AsNoTracking()
.OrderBy(s => s.Name)
.Select(s => new ScriptListRow(s.ScriptId, s.Name, s.Language, s.SourceHash))
.ToListAsync(ct);
/// <summary>Loads one script's source, or null when the script no longer exists.</summary>
/// <param name="db">The config DB context.</param>
/// <param name="scriptId">The script's logical id.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The source code, or null.</returns>
public static Task<string?> LoadSourceAsync(OtOpcUaConfigDbContext db, string scriptId, CancellationToken ct = default) =>
db.Scripts.AsNoTracking()
.Where(s => s.ScriptId == scriptId)
.Select(s => (string?)s.SourceCode)
.FirstOrDefaultAsync(ct);
}
@@ -0,0 +1,59 @@
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptList;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptList;
/// <summary>R04 P8: the <c>/scripts</c> list is a projection without source; source loads per dialog.</summary>
[Trait("Category", "Unit")]
public sealed class ScriptListQueriesTests
{
private static OtOpcUaConfigDbContext NewDb(string name) =>
new(new DbContextOptionsBuilder<OtOpcUaConfigDbContext>().UseInMemoryDatabase(name).Options);
private static async Task<string> SeedAsync()
{
var name = Guid.NewGuid().ToString("N");
await using var db = NewDb(name);
db.Scripts.AddRange(
new Script { ScriptId = "s-b", Name = "Beta", SourceCode = "return 2;", SourceHash = "hb" },
new Script { ScriptId = "s-a", Name = "Alpha", SourceCode = "return 1;", SourceHash = "ha", Language = "CSharp" });
await db.SaveChangesAsync();
return name;
}
[Fact]
public async Task LoadRowsAsync_returns_name_ordered_rows_without_source()
{
var name = await SeedAsync();
await using var db = NewDb(name);
var rows = await ScriptListQueries.LoadRowsAsync(db);
rows.Select(r => r.ScriptId).ShouldBe(["s-a", "s-b"]);
rows[0].ShouldBe(new ScriptListRow("s-a", "Alpha", "CSharp", "ha"));
// The row type has no source member at all — the list cannot load it by accident.
typeof(ScriptListRow).GetProperty("SourceCode").ShouldBeNull();
}
[Fact]
public async Task LoadSourceAsync_returns_one_scripts_source_or_null_when_gone()
{
var name = await SeedAsync();
await using var db = NewDb(name);
(await ScriptListQueries.LoadSourceAsync(db, "s-b")).ShouldBe("return 2;");
(await ScriptListQueries.LoadSourceAsync(db, "missing")).ShouldBeNull();
}
[Fact]
public void Scripts_page_uses_the_projection()
{
var src = AdminUiSource.Read("Components", "Pages", "Scripts.razor");
src.ShouldContain("ScriptListQueries.LoadRowsAsync(");
src.ShouldNotContain("List<Script>");
}
}