Phase 1 of the design at
docs/plans/2026-05-12-derive-on-compose-design.md.
Additive schema only — no behavior changes. Existing data and code
paths continue to work; subsequent phases will start writing the
new fields.
Template gains:
IsDerived true when this row was auto-created to back
a composition slot
OwnerCompositionId back-ref to the owning TemplateComposition
(plain int, not an EF nav property — managed
by TemplateService for cascade-delete)
TemplateAttribute / TemplateScript each gain:
IsInherited row copied from base and not yet overridden;
changes to the base flow downward
LockedInDerived on a base, blocks derived from overriding;
enforced at the service layer in later phases
EF Core migration AddDerivedTemplateFields adds four columns:
Templates.IsDerived bit NOT NULL DEFAULT 0
Templates.OwnerCompositionId int NULL
TemplateAttributes.IsInherited bit NOT NULL DEFAULT 0
TemplateAttributes.LockedInDerived bit NOT NULL DEFAULT 0
TemplateScripts.IsInherited bit NOT NULL DEFAULT 0
TemplateScripts.LockedInDerived bit NOT NULL DEFAULT 0
Existing rows get the defaults. Tests across SiteRuntime / TemplateEngine
/ CentralUI suites stay green (129 / 199 / 159).
Next: phase 2 — wire AddCompositionAsync to derive on compose for
new compositions. Old data still flows the direct-reference path
until phase 3's migration script.
Aveva-style composition: composing $Sensor into $Pump creates a
derived template Pump.TempSensor that inherits from $Sensor and can
override values, override script bodies, add new fields, with
LockedInDerived on the base preventing specific overrides.
Schema sketch: Template gains IsDerived + OwnerCompositionId;
TemplateAttribute/Script gain IsInherited + LockedInDerived.
TemplateComposition.ComposedTemplateId pivots to point at the
derived template (the base is reachable via derived.ParentTemplateId).
Phased rollout (9 phases), starting from additive schema, then
flow change for new compositions, then EF Core migration of
existing data, then resolution, lock semantics, tree UI, derived
template edit UI, base template lock-toggle UI, editor metadata
simplification (multi-parent picker becomes mostly obsolete —
derived templates always have a single owner).
Open questions captured at the end for review before phase 1.
Two caveats from the script-scope rollout addressed:
1. ITemplateEngineRepository.GetTemplatesComposingAsync — a scoped
query that returns only the templates referencing a given template
via Compositions, eager-loaded with their Attributes / Scripts /
Compositions. Replaces the GetAllTemplatesAsync + filter pattern
in TemplateEdit so the Monaco metadata fetch doesn't pull the
entire template catalog to find one parent.
2. Multi-parent picker. The previous implementation suppressed Parent
assistance entirely when more than one template composes the open
one. Now TemplateEdit collects every parent into _editorParents
and renders a small `select` above the script editor when there
are >1, letting the user choose which parent's metadata drives
Parent.Attributes / Parent.CallScript completion + diagnostics.
Single-parent templates skip the picker (no UI change). Zero
parents (root template) hide the picker and surface no Parent
assistance.
Browser-verified on the Sensor Module template (composed by both Pump
and Variable Speed Motor): picker shows both options, switching
updates the editor's parent metadata immediately via the existing
GetContext callback.
Test counts unchanged (159 / 199); the new repo method is exercised
end-to-end by the parent-picker browser path.
Phases 3+4 of the script-scope rollout. Wires the runtime accessors
landed in efba01d through to Monaco completion, diagnostics, and
hover.
New analyzer surface in ScriptAnalysisService:
String-literal completion contexts (added to TryStringLiteralCompletions):
Attributes["..."] -> SelfAttributes
Children["..."] -> composition names
Children["X"].Attributes["..."] -> child template's attributes
Children["X"].CallScript("...") -> child template's scripts
Parent.Attributes["..."] -> parent template's attributes
Parent.CallScript("...") -> parent template's scripts
Diagnostics:
SCADA006 Attribute "Typo" is not declared on {this template,
child composition 'X', the parent}. (warning)
SCADA007 Composition "Unknown" is not declared on this template.
(warning)
CallShared / CallScript snippet-expansion now routes through the
child / parent shape catalogs when invoked on Children["X"] /
Parent — picking a child script accepts `Sample", ${1:count})`.
Contract additions:
- AttributeShape (Name, Type) record
- CompositionContext (Name, Attributes, Scripts) record
- SelfAttributes / Children / Parent fields on DiagnoseRequest,
CompletionsRequest, HoverRequest, SignatureHelpRequest
ScriptHost (analyzer-side globals) gains stub AttributeBag /
ChildrenBag / CompositionBag types so Roslyn doesn't emit CS0103 on
Attributes / Children / Parent. The stubs are never invoked — only
their signatures are read by the analyzer's compilation pass.
MonacoEditor.razor exposes SelfAttributes / Children / Parent
parameters; GetContext returns them; monaco-init.js forwards all
three on completion / hover / signature-help / diagnostics requests.
TemplateEdit fetches each composition's resolved child template
shape via GetTemplateWithChildrenAsync, and queries GetAllTemplatesAsync
for any single parent that composes the open template. Multi-parent
or no-parent → Parent is suppressed.
11 new xUnit tests on the new completion / diagnostic paths. Total:
149 -> 159.
Browser-verified via curl:
- Children["..."] suggests composition names
- Attributes["..."] suggests attributes with type detail
- Attributes["Typo"] squiggles SCADA006
- Children["Unknown"] squiggles SCADA007
- No spurious CS0103 on the new accessors
Hover, signature help, and inlay hints for the new accessors keep
working because they reuse the same dispatch logic.
Phases 1+2 of the design at
docs/plans/2026-05-12-script-scope-access-design.md.
Adds ergonomic scope-aware accessors to compiled scripts. A script
on a composed TempSensor reads its own attribute via
Attributes["Temperature"]; reaches up to the parent via
Parent.Attributes["SpeedRPM"]; invokes a child script via
Children["TempSensor"].CallScript("Sample"). All resolve to the
existing flat Instance.GetAttribute / SetAttribute / CallScript
delegates by prepending the script's canonical path prefix.
Runtime types (SiteRuntime.Scripts.ScopeAccessors):
AttributeAccessor sync indexer + GetAsync / SetAsync
CompositionAccessor Attributes + CallScript
ChildrenAccessor Children["name"] => CompositionAccessor
ScriptGlobals gains Scope, Attributes, Children, Parent properties.
Sync indexer blocks on the Instance Actor Ask; explicit GetAsync /
SetAsync are also available for callers that want to await.
Plumbing:
- Commons.Types.Scripts.ScriptScope record (SelfPath / ParentPath).
- ResolvedScript.Scope (defaults to ScriptScope.Root for back-compat).
- FlatteningService emits new ScriptScope(prefix, "") for each
composed script so a script defined on TempSensor composed under
a parent gets SelfPath = "TempSensor".
- ScriptActor reads the Scope from its ResolvedScript and forwards
it through ScriptExecutionActor into ScriptGlobals on each call.
RevisionHashService not touched: the per-script canonical name
already encodes the composition path, so any structural change
already flips the hash.
10 new unit tests on the path arithmetic. Site/Template engine
suites stay green (129 + 199).
Editor surface (Phase 3: metadata fetch, Phase 4: completion +
SCADA006 / SCADA007 diagnostics) follows in the next commits.
Self / Children / Parent accessors with sync-indexer + async-method
shape. Flattening pipeline emits ScriptScope per resolved script;
ScriptCompilationService seeds the accessors at execution time with
no new actor messages or lookup paths.
Phased: (1) runtime accessors + Scope on ResolvedScript, (2)
flattening + deploy round-trip, (3) editor metadata fetch for child
+ parent shapes, (4) Monaco completion / hover / diagnostics
(SCADA006 unknown attribute, SCADA007 unknown composition).
Out of scope: per-template Roslyn-generated typed accessors,
locking-aware writes (covered by lock-enforcement pass), and
sibling-of-sibling chained navigation.
Three more editor features rolled in:
1. Roslyn Format command.
New POST /api/script-analysis/format runs Formatter.Format() from
Microsoft.CodeAnalysis.CSharp.Workspaces on the parsed script
tree. monaco-init.js registers a DocumentFormattingEditProvider
so Ctrl/Cmd-Shift-F and the toolbar "Format" button both work.
2. Inlay hints with parameter names.
New POST /api/script-analysis/inlay-hints walks CallShared /
CallScript invocations and emits InlayHint records positioned at
each argument with the matching parameter's name (e.g. "name:").
Ghost text appears via Monaco's InlayHintsProvider.
3. SCADA005 argument-type diagnostic.
Literal type vs. declared parameter type check on every
CallShared/CallScript argument. Float accepts Integer literals;
Object/List accept anything; null only matches reference-ish
types. Legacy lowercase types ("string" etc) from the DB are
normalized to the canonical set before comparison so existing
data doesn't false-negative. Non-literal args (variables,
expressions) are skipped — out of scope for a cheap pass.
4. Parameters["name"] hover.
Hover endpoint now also resolves Parameters["X"] element-access
keys against the form's DeclaredParameterShapes and returns
"parameter `name: String`"-style markdown. MonacoEditor surfaces
the new DeclaredParameterShapes parameter; ScriptParameterNames
gets a ParseShapes companion.
5. Problems panel.
Bootstrap card under the editor listing every marker with
severity badge, line number, message, and SCADA / CS code. Click
a row to scroll the editor to that line and focus. JS now
invokes OnMarkersChanged on the .NET side whenever
setModelMarkers fires, so the panel stays in sync with the
editor.
6. Editor toolbar.
Small top-right strip on each editor with Format / Wrap /
Minimap / Theme toggles. New MonacoBlazor.format,
setEditorOption, and revealLine JS APIs back the buttons and the
problems-panel scroll-to-line.
Contracts:
- FormatRequest / FormatResponse
- InlayHintsRequest / InlayHintsResponse / InlayHint
- HoverRequest.DeclaredParameters
- MonacoEditor.DeclaredParameterShapes parameter
- MonacoEditor.MarkersChanged callback
- ScadaContext.DeclaredParameterShapes
10 new xUnit tests covering format, inlay hints, SCADA005 (string-
expects-integer, integer-expects-string, float-accepts-integer,
object-accepts-anything, non-literal-skipped), and Parameters key
hover. Total: 139 -> 149.
Microsoft.CodeAnalysis.CSharp.Workspaces 4.13.0 added to pull in
Formatter and AdhocWorkspace.
Browser-verified: typing `CallShared("Greet", 42)` now shows the
"name:" inlay hint and a SCADA005 squiggle on `42`; Parameters["typo"]
shows SCADA003 as before; the toolbar buttons all work.
Now that the form holds parameter + return shapes for declared
parameters, sibling scripts (template Scripts tab), and shared
scripts (via SharedScriptCatalog), the editor leverages them four
ways:
1. Snippet expansion on accept.
Picking a CallShared or CallScript completion inserts the full
call template with tabstops, e.g. `Greet", ${1:name})`. The JS
provider extends the completion range over Monaco's auto-closed
`")` so the snippet replaces the closing pair cleanly. Items
carry insertTextRules=4 (InsertAsSnippet) and a command to
immediately trigger parameter hints after acceptance.
2. Hover info.
Hovering the script name token inside CallShared("X") or
CallScript("Y") shows a markdown tooltip with the call signature
and return type. New endpoint POST /api/script-analysis/hover.
3. Signature help.
Inside CallShared(...) / CallScript(...) Monaco shows the
parameter strip with the active parameter highlighted. The
service walks up from the cursor to the nearest enclosing
InvocationExpression and resolves which argument index the
cursor is on. New endpoint POST /api/script-analysis/signature-help.
4. Argument-count diagnostic (SCADA004) and unknown-Parameters-key
diagnostic (SCADA003). The Diagnose pipeline now consults the
declared parameters and sibling/shared shapes to flag:
- Parameters["typo"] when "typo" isn't on the form (warn)
- CallScript("Calc", 1) when Calc declares 2 required args (err)
- CallShared("Greet", 1, 2, 3) when Greet declares 1 arg (err)
Optional parameters relax the required-count bound.
Contract changes:
- ScriptShape / ParameterShape records
- ISharedScriptCatalog.GetShapesAsync (replaces GetNamesAsync)
- new HoverRequest/Response, SignatureHelpRequest/Response
- CompletionsRequest.SiblingScripts: string[] -> ScriptShape[]
- DiagnoseRequest gains DeclaredParameters + SiblingScripts
- CompletionItem gains InsertTextRules (Monaco snippet rule)
Form wiring:
- TemplateEdit passes ScriptShapeParser.Parse(...) per sibling
- MonacoEditor surfaces SiblingScripts: IReadOnlyList<ScriptShape>
- GetContext returns shapes to JS on each completion/hover/sig
request
10 new ScriptAnalysisServiceTests covering all four features plus
optional-parameter edge cases. Existing tests updated for the
contract changes. Total: 113 -> 139.
Browser-verified via direct curl + Monaco marker readback:
- SCADA003 squiggle on Parameters["typo"]
- Snippet item Greet", ${1:name}) with insertTextRules=4
- Hover markdown shape signature
- Signature help parameter strip
Two pre-flagged follow-ups from the Monaco integration:
1. IMemoryCache for diagnostics keyed by SHA256 of the script body.
Same-code Diagnose() now short-circuits the Roslyn compile and
forbidden-API walk. SizeLimit 200 entries with 5-minute sliding
expiration. Completions aren't cached — position + form context
vary too much for a useful hit rate.
2. Forbidden-API analyzer now resolves identifiers through the
SemanticModel instead of matching names. A user identifier
named File / Thread / Process / etc. no longer false-positives
— only references that resolve to a NamedTypeSymbol whose
containing namespace is on the banned list are flagged. The
diagnostic message now names the offending namespace, e.g.
"Type 'File' from forbidden namespace 'System.IO' is not
allowed in scripts."
Refactor: extracted ISharedScriptCatalog so ScriptAnalysisService
can be unit-tested without standing up SharedScriptService's EF
chain. Concrete SharedScriptCatalog wraps the existing service.
16 new xUnit tests in ScriptAnalysisServiceTests:
- Empty / clean / missing-semicolon paths
- SCADA001 on each banned using namespace (theory)
- SCADA002 on real File.ReadAllText through System.IO
- No-false-positive checks for user-defined File / Thread locals
- Cache returns the same response instance on repeat
- Different code → different cache entries
- String-literal completions for Parameters / CallScript / CallShared
- General completion at file scope returns ScriptHost members
Total CentralUI test count: 113 -> 129.
Wave 3 of the Monaco/Roslyn integration. Adds the four extensions
agreed in the design Q&A:
1. Parameters["..."] keys — when the cursor is inside a string
literal that's the index of a Parameters[] element-access,
completions return the parameter names declared in the form's
ParameterListEditor.
2. CallShared("...") names — when the cursor is inside a string
literal argument to a CallShared(...) invocation, completions
return the names of all shared scripts (resolved server-side
via SharedScriptService).
3. CallScript("...") names — same shape, but uses sibling-script
names passed from the form (TemplateEdit's _scripts list).
4. Forbidden-API diagnostic — squiggles uses of the documented
script trust model bans: System.IO / Diagnostics / Reflection /
Net / Threading.Thread namespaces, plus the named types File,
Directory, Process, Thread, Socket, etc. New diagnostic codes
SCADA001 (using directive) and SCADA002 (type identifier).
ScriptAnalysisService gains a SharedScriptService dependency
(scoped, hence the analyzer is now scoped too); CompletionsRequest
carries DeclaredParameters and SiblingScripts; Complete is now async.
MonacoEditor.razor exposes DeclaredParameters / SiblingScripts
parameters plus a [JSInvokable] GetContext() so the JS side asks
for the latest form state on every completion request. The
provider in monaco-init.js looks up the owning editor from the
internal editors map and forwards the context.
ScriptParameterNames helper parses the ParameterListEditor JSON
into a name list — used by SharedScriptForm, ApiMethodForm, and
TemplateEdit's Add-Script form to populate the Monaco context.
Smoke-verified via direct fetch + Monaco trigger:
- var x = Parameters[" → popup: "name" (declared parameter)
- var y = CallShared(" → popup: GetWeather, Greet
- using System.IO; → SCADA001 squiggle
- Process.Start(...) → SCADA002 squiggle
- File.ReadAllText(...) → SCADA002 squiggle
Also fixed: ScriptAnalysisService scoped (was singleton, broke DI
because SharedScriptService is scoped); JS normalizes Pascal-case
context keys from Blazor's record serialization to camel-case for
the request body.
Adds Microsoft.CodeAnalysis.CSharp.Scripting (4.13.0). Scripts are
compiled as C# script fragments against a ScriptHost globals type
that mirrors what the runtime exposes (Parameters bag, CallShared,
CallScript) — Roslyn reads the signatures so those identifiers are
in scope for analysis without executing anything.
ScriptAnalysisService:
- Diagnose(code): Compilation.GetDiagnostics() projected to
Monaco-shaped DiagnosticMarker records (severity 8/4/2/1).
- Complete(code, line, col): dot-member lookup via SemanticModel
when the token at position is part of a MemberAccessExpression;
falls back to LookupSymbols at position for the general case.
Two endpoints exposed by the existing CentralUI endpoint pipeline,
both behind RequireDesign policy:
POST /api/script-analysis/diagnostics
POST /api/script-analysis/completions
monaco-init.js registers a csharp CompletionItemProvider with dot/
paren/quote trigger chars, plus a 500 ms debounced diagnostics pass
on every keystroke that pushes markers via setModelMarkers. Initial
pass fires on editor create so existing scripts surface errors right
away. Auth uses the existing cookie via credentials: same-origin.
Smoke-verified:
- Typing `DateTimeOffset.UtcNow` (no semicolon) shows the missing
semicolon squiggle in real time.
- Ctrl-Space at file scope returns the full type universe
(AccessViolationException, Action, Akka, AppDomain, ...).
Wave 2 of three. SCADA-specific extensions (declared param keys,
shared/sibling script names, forbidden-API diagnostic) follow.
Vendors Monaco 0.55.1 min/vs/ (~15 MB) at
wwwroot/lib/monaco/vs/. No CDN dependency; works on air-gapped
deployments. Loaded lazily on first script-edit via the AMD loader.
wwwroot/js/monaco-init.js exposes window.MonacoBlazor with
createEditor / setValue / getValue / setMarkers / dispose. Handles
loader bootstrap, DotNet round-trip on content change, and marker
sets for later diagnostic wiring.
Components/Shared/MonacoEditor.razor is a Blazor wrapper with
Value / ValueChanged / Language / Height / ReadOnly parameters and
IAsyncDisposable teardown. Bidirectional binding tracks
_lastSentValue to avoid push/pull loops.
Replaces the plain textareas in SharedScriptForm, TemplateEdit's
Add-Script form, and ApiMethodForm. Default height 320px ≈ the
previous rows=10. Build / tests / dialog flow unaffected.
Wave 1 of three. Roslyn-backed completions and SCADA-specific
extensions follow in subsequent commits.
Editors now set a _normalized flag when ParseFromJson coalesces a
legacy type name (lowercase "string", "Int32", "Double", etc.) to the
canonical set. When flagged, render a small alert-info inline:
"Some parameter types were normalized... Save to persist the
canonical form." The flag clears on any user edit so the notice
doesn't linger after Emit overwrites the JSON.
31 new bUnit tests in tests/.../Shared/:
- ParameterListEditorTests: null/empty rendering, row count per
JSON entry, legacy type normalization across .NET names +
lowercase, the normalized notice trigger, add/remove emission,
List/non-List item-type column visibility, required-flag round
trip, invalid JSON + non-array error paths.
- ReturnTypeEditorTests: null vs simple vs List shape, legacy type
normalization, change-type / clear-type emission, invalid JSON
+ non-object error paths.
Total CentralUI test count: 82 -> 113.
Two new shared components in Components/Shared:
- ParameterListEditor: table of rows (name + type + item type + required + remove)
- ReturnTypeEditor: single type (+ item type when List)
Both round-trip the same JSON shape already stored on the entity:
parameters: [{"name":"x","type":"String","required":true},...]
return: {"type":"List","itemType":"Integer"} | null
Type set follows the Inbound API validator (Boolean, Integer, Float,
String, Object, List). Legacy values normalize on read — Int32 / int64
/ Double / Decimal / lowercase string / etc all coalesce to the new
set so existing rows render correctly. Re-saving persists the
normalized form.
Applied to:
- SharedScriptForm
- TemplateEdit Add Script form (also surfaces ParameterDefinitions
+ ReturnDefinition which the entity supported but the form was
never wiring through)
- ApiMethodForm
Graceful degradation: invalid JSON is shown with a "Start fresh"
escape hatch instead of crashing the form.
Replaces hardcoded sidebar / nav-link hex colors with Bootstrap CSS
custom properties (var(--bs-dark), var(--bs-primary), var(--bs-gray-*),
var(--bs-white)). Visual parity preserved; rebrand/dark-mode work
later can override the variables without touching this file.
Only the reconnect overlay rgba(0,0,0,0.5) is left as a literal —
Bootstrap doesn't ship a backdrop-overlay token.
Eliminates the per-page <ConfirmDialog @ref="_confirmDialog"
ConfirmButtonClass="btn-danger" /> boilerplate. Pages now inject
IDialogService and call ConfirmAsync(title, message, danger: true)
programmatically.
New scoped service holds a single active dialog (throws on nested
calls), with a global DialogHost mounted once in MainLayout that
renders the modal markup, owns body scroll-lock via Bootstrap's
modal-open class, traps focus on the modal element, and handles
Escape-to-cancel.
Same service also exposes PromptAsync, used to replace the bespoke
NewFolderDialog. Both ConfirmDialog and NewFolderDialog components
are deleted — their callers (~13 pages across Admin/Design/Deployment
/Monitoring) now go through the service.
DiffDialog stays as-is — different use case (before/after content).
bUnit tests in TopologyPageTests, DataConnectionsPageTests, and
TemplatesPageTests register IDialogService in their service
collection.
Also: a top-of-file Razor comment on Sites.razor pointing future
implementers at it as the reference list-page pattern.
Dashboard: user-info card demoted; 4 KPI cards (Sites, Data
connections, Templates, API keys) sourced from existing repositories;
3 Quick-action link cards (Health, Audit Log, Templates). Inline
max-width style replaced with Bootstrap utilities.
Health: KPI row condensed to Online / Offline / Sites with active
errors (Total Sites and Total Script Errors dropped). Per-site cards
re-laid out 2-column with each subsection (Data Connections,
Instances & Queues, Errors & Parked Messages) inside Bootstrap
collapse panels collapsed by default. Online / Offline / Primary /
Standby badges paired with shape glyphs (o / * / triangle) plus
aria-label.
EventLogs: filter row wrapped in a Bootstrap collapse toggled by
"Filter options (n active)"; per-row View toggle reveals the full
message in a collapse row; "Keyword" relabeled "Message contains";
all filter inputs gain id+label-for+aria-label; severity badges paired
with a leading glyph; explicit "End of results" terminator on
Load more.
ParkedMessages: Message ID rendered as <code>{first 12}...</code>
plus a clipboard button; per-row View toggle reveals full error;
action buttons get aria-label="{Retry|Discard} message {id}";
in-flight spinner inside the active button.
AuditLog: pagination Next-disabled now uses
_page * _pageSize >= _totalCount via HasMore helper (fixes the
exactly-page-size edge case). Clear filters button added. Entity ID
rendered as code + clipboard button. View/Hide buttons gain
aria-label referencing the entry id. State JSON larger than 1 KB
renders a "View in modal" button instead of the inline overflow.
New shared DiffDialog mirroring ConfirmDialog's API
(ShowAsync(title, before, after)) so live-data pages stop
hand-rolling Bootstrap modal markup.
Topology: <h4> in flex header, aria-labels on Expand/Collapse/Refresh
and the inline rename input, Live-updates toggle (suppresses the 15s
timer when off), instance/area counts moved into a summary alert
above the tree, Stale badge paired with bi-exclamation-triangle icon
+ aria-label, hand-rolled Diff modal replaced with <DiffDialog @ref>.
Deployments: pause/resume auto-refresh button replaces the static
"Auto-refresh: 10s" text; summary cards switch to
col-lg-3 col-md-6 col-12; InProgress spinner gets role="status" +
aria-label; failed rows pick up a bi-x-circle icon next to the
Status badge; Deployment ID + Revision folded into one
{id}@{revision[..8]} cell; inline Error column collapses behind a
per-row "View error" toggle; bare empty-state text upgraded to the
centered muted block.
DebugView: status-strip card at the top showing instance / connection
state / last snapshot timestamp plus a "Start fresh" button when the
page auto-reconnected from localStorage. Per-table filter input,
scroll-lock toggle, Clear button, and a 200-row queue-style cap.
<tbody> elements gain aria-live="polite" aria-atomic="false" for
screen-reader announcements. Quality and Alarm-State badges get
aria-labels; timestamps display HH:mm:ss with full ms in a hover
tooltip. Auto-reconnect surfaces a toast with autoDismissMs: 8000.
Templates: <h4> in flex header, Expand/Collapse moved into a Bulk
actions dropdown, hover-visible kebab on tree nodes with aria-labels.
TreeView CSS gets a .tv-kebab opacity-on-hover utility.
TemplateCreate: form-control (not -sm) for primary inputs; accessible
Back button.
TemplateEdit: Properties card vertical-stacked with Save at the
bottom-right and Parent rendered as readonly plaintext. Add-member
forms (Attributes, Alarms, Scripts, Compositions) reflowed from
horizontal row g-2 align-items-end into cards with stacked col-12
inputs (Scripts gets rows=10). Lock/Unlock badges show full words.
Per-row Delete moved into a kebab dropdown. Tab nav gains
role="tablist" / role="tab" / aria-selected / aria-controls and panels
get role="tabpanel". Validation entries get consistent strong-and-
muted styling.
SharedScripts: migrated from table to card grid (col-lg-6) matching
Sites; cards show code preview + param/return badges + Edit + kebab.
Search filter, empty state CTA, @key.
SharedScriptForm: small ?-icon tooltips next to Parameters and Return
Definition labels.
ExternalSystems: SMTP split out to its own page; remaining tabs (
External Systems, DB Connections, Notification Lists, API Methods,
API Keys) unified as card grids with per-tab search + empty-state CTA.
Tab nav gets full ARIA instrumentation. Header gains a link to the
new SMTP page.
New page SmtpConfiguration.razor at /design/smtp: vertical-stacked
form using the existing Credentials field on the entity.
ExternalSystemForm: AuthConfig placeholder updates based on the
selected AuthType (None / ApiKey / BasicAuth).
DbConnectionForm: form-text below Connection String noting that the
value is stored in plain text and is admin-only.
ApiMethodForm: Script textarea rows=10; JSON example placeholders
for Params and Returns.
NotificationListForm: form-control sizing on Name/email inputs;
thead.table-dark -> table-light on the recipients table.
LdapMappings: flex header, search filter, per-row Edit + kebab Delete,
@key, dropped Site-Scope-Rules cell in favor of a {n rule(s)} badge.
LdapMappingForm: two stacked cards (Mapping then Site Scope Rules);
scope rules render as removable chips with an inline "Add scope rule"
form; create-mode disables the scope card with an explainer; role
select gets form-text help.
DataConnections: <h4> in flex header, Bulk actions dropdown holding
Expand/Collapse, hover-visible kebab on tree nodes mirroring the
right-click context menu, aria-labels, "No connections match the
filter." inline empty state.
DataConnectionForm: Site rendered as readonly plaintext + lock-after-
creation note in edit mode; parallel Primary endpoint / Backup endpoint
headings; "Optional" badge on Backup when null; form-text on
FailoverRetryCount.
ApiKeys: search filter, Status column dropped (state now lives in the
kebab menu label "Disable"/"Enable"), Edit + kebab actions, @key,
aria-labels.
ApiKeyForm: nested card removed; fixed-text Back header; real
clipboard copy via IJSRuntime + toast confirmation.
Test selector fix in DataConnectionFormTests for the new Site
readonly-plaintext rendering.
ConfirmDialog locks body scroll via IJSRuntime + Bootstrap's
modal-open class on show, restores on hide. Escape key now closes
the dialog; default ConfirmButtonClass flipped from btn-danger to
btn-primary so non-destructive confirms aren't red. Destructive
callsites (Delete, Discard) get explicit ConfirmButtonClass="btn-danger".
ToastNotification adds aria-live="polite" + aria-atomic="true" on the
container and an optional autoDismissMs parameter on every Show* method.
LoadingSpinner text-muted -> text-secondary for contrast.
DataTable gains a clear (x) button on the search input and applies
disabled / aria-disabled directly to the pagination buttons.
NewFolderDialog splits backdrop and modal markup to match ConfirmDialog.
NavMenu wraps the nav list in an overflow-y scroll container so the
username/sign-out footer stays anchored, and section headers convert
from <li> to <div role="presentation">.
MainLayout adds a hamburger toggle for <lg viewports; sidebar collapses
via Bootstrap collapse data attributes.
App.razor extracts inline <style> block to a shared site.css; adds a
left-border accent on the active nav link; switches the reconnect
modal to modal-dialog-centered.
Login uses d-flex / min-vh-100 centering. NotAuthorizedView gets the
same centered layout plus the ScadaLink brand heading.
Sites.razor: only the new ConfirmButtonClass="btn-danger" follow-up.
Audit of every page in CentralUI against the Sites.razor card-grid
pattern, the no-third-party-UI-libs constraint, and accessibility
basics. Findings + per-page severity + suggested implementation
order live in docs/plans/. Implementation follows in subsequent commits.
The dense table buried high-signal fields (name, identifier, connections)
under four 80-character Akka/gRPC URLs truncated mid-string. Replace with
a 2-column responsive card grid; cluster-node addresses now live in a
collapsed disclosure with copy-to-clipboard. Adds client-side filter,
empty/no-match states, kebab menu for less-frequent actions, and
@key=site.Id to keep Bootstrap collapse state from leaking across cards
when the filter changes.
Three new sections inserted into <OpcUaEndpointEditor>:
1. Authentication (between the existing Connection row and Timing)
- 'Enable Authentication' button when Config.UserIdentity is null
- TokenType select (Anonymous / UsernamePassword / X509Certificate)
- Conditional Username + Password inputs for UsernamePassword
- Conditional Certificate path + Certificate password for X509Certificate
- 'Remove Authentication' button
2. Advanced subscription (after the existing Subscription row)
- Subscription display name (text)
- Subscription priority (number 0-255)
- Timestamps to return (Source / Server / Both select)
- Discard oldest (checkbox)
3. Deadband filter (after Advanced subscription)
- 'Enable Deadband' button when Config.Deadband is null
- Type select (Absolute / Percent), Value number input
- 'Remove Deadband' button
EnableAuthentication and EnableDeadband helpers complement EnableHeartbeat.
All new fields use the existing RenderFieldError helper for validator errors.
82/82 CentralUI tests pass (the 10 new editor tests drove the design).
New deployment-wide options bound from the "OpcUa" section of appsettings.json:
- ApplicationName (default "ScadaLink-DCL")
- TrustedIssuerStorePath / TrustedPeerStorePath / RejectedCertificateStorePath
Empty paths fall back to Path.GetTempPath()/ScadaLink/pki/* so dev runs work
without explicit config — same defaults the hardcoded values previously used.
Wiring:
- ServiceCollectionExtensions binds OpcUaGlobalOptions to the OpcUa section.
- DataConnectionFactory takes IOptions<OpcUaGlobalOptions> and constructs
RealOpcUaClientFactory with the snapshot.
- RealOpcUaClient(globalOptions) replaces the hardcoded ApplicationName and
the three CertificateTrustList store paths in ApplicationConfiguration.
- Parameterless ctors on factory and client preserved for the existing test
suite (32/32 DCL tests still green).
OpcUaConnectionOptions record gains DiscardOldest, SubscriptionPriority,
SubscriptionDisplayName, TimestampsToReturn, plus OpcUaDeadbandOptions and
OpcUaUserIdentityOptions nullable sub-records.
OpcUaDataConnection.ConnectAsync copies all new fields from the typed
OpcUaEndpointConfig (including the Deadband and UserIdentity sub-objects)
into the OpcUaConnectionOptions record.
RealOpcUaClient:
- BuildUserIdentity translates TokenType into Opc.Ua.UserIdentity:
Anonymous → null, UsernamePassword → new UserIdentity(name, utf8(pass)),
X509Certificate → new UserIdentity(X509CertificateLoader.LoadPkcs12FromFile(...)).
- Subscription uses opts.SubscriptionDisplayName and opts.SubscriptionPriority.
- MonitoredItem.DiscardOldest is opts.DiscardOldest (was hardcoded true).
- BuildDataChangeFilter materializes a DataChangeFilter when Deadband is set.
- ReadAsync uses MapTimestampsToReturn for opts.TimestampsToReturn (was hardcoded Source).
X509CertificateLoader replaces obsolete X509Certificate2(string,string) ctor
(SYSLIB0057 on .NET 10). UserIdentity(string,byte[]) ctor used because the
(string,string) overload was removed in OPC Foundation 1.5.378.106.
Adds 11 new tests covering:
- Roundtrip of DiscardOldest/SubscriptionPriority/SubscriptionDisplayName/TimestampsToReturn
- Roundtrip of UserIdentity sub-object across all three TokenTypes
- Roundtrip of Deadband sub-object
- ToFlatDict/FromFlatDict for UserIdentity.* and Deadband.* dotted keys
- Validator rules: empty SubscriptionDisplayName, UsernamePassword w/o Username,
X509 w/o CertificatePath, Deadband Value <= 0, prefix propagation
Build passes; tests fail because serializer/validator have not been extended yet
(TDD red phase). Task B2 will implement the changes to drive them green.
Captures the design decisions from the brainstorming session:
- OpcUaEndpointConfig POCO + validator + serializer in Commons
- Single source of truth: both UI and site runtime consume the model
- Typed nested JSON storage (camelCase), legacy flat-dict fallback
- Shared <OpcUaEndpointEditor> Blazor component used twice
- Custom protocol removed from dropdown; Protocol field hidden
- Validation timing on Save only; per-field red text via ValidationEntry
Brings the Data Connections admin page up to the same UX standard as the
Topology page:
- Search box with dim non-matches (opacity 0.4, shape preserved)
- Toolbar: + Connection (disabled until a site is selected), Refresh,
Expand, Collapse
- Site context menu gains "Add Connection here" that navigates with
?siteId= so the form preselects + locks the Site field
- Form gains "Primary Endpoint" / "Backup Endpoint" h6 subsection
headers matching the SiteForm convention; Failover Retry Count moved
inside the Backup subsection
- URL renamed: /admin/connections (primary) + /admin/data-connections
(legacy secondary @page). Same dual-route treatment on the form
- Nav label: "Data Connections" -> "Connections"
- Adds DataConnectionsPageTests bUnit suite (6 tests)