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
@@ -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);
}