The modal's existence was a function of _rows (FindSourceScript() as the
visibility gate), so any render where the row is momentarily unresolvable —
a future reload nulling or replacing the list — unmounts the subtree and
disposes the Close handler's binding id mid-click (GetRequiredEventBindingEntry).
Latent here (_rows is written once, in OnInitializedAsync) but the identical
shape bit ScadaBridge live the moment a page refresh landed, and it presents
as an unattributable intermittent once one does.
ScadaBridge's shape, ported by the family sweep: intent (_sourceScriptId)
gates existence, the resolve drives only content; an unresolvable script
renders a notice with the frame and Close still mounted, and closing remains
the operator's action rather than a side effect of list state.
Production ran six weeks with a healthy process and a 0-byte log because
Serilog isolates per-sink failures: a file sink whose every write fails is
invisible from anywhere but SelfLog, which was not enabled. Two instruments:
- LogPipelineDiagnostics.EnableSelfLog (before the first logger builds):
SelfLog to logs/serilog-selflog.txt (rotated at 5 MB) + stderr — the only
channel that carries the exception the sink swallows.
- CheckFileSinkGrowth (after the startup banner): verifies each configured,
unbuffered file sink's rolled-file family physically grew across a marker
event; a silent sink is reported at boot via SelfLog/stderr and the
console sink instead of discovered months later. Growth-across-marker,
not existence — a previous run's bytes must not mask a silent sink (the
false pass an existence check would have given on wonder-app-vd03).
Never throws: both instruments swallow their own failures — a diagnostics
channel that can fail startup is worse than the defect it watches for.
The load-bearing test is the failure case (a sink that writes nothing IS
reported); a check that can only pass is indistinguishable from no check.
The page ran members, the driver-instances heading, and every cluster group
together inside one panel; driver name and id slammed inline; one long driver
exception blew the table out sideways (Controls scrolled out of reach); and the
inline #546 expander row dumped a full-width panel into the table.
- One panel per cluster group, node chips in the panel head.
- Driver id renders on its own muted mono line under the name.
- Last error abbreviated to 60 chars (DisplayText.Abbreviate — #504 rule),
full text on the tooltip and in the Controls dialog.
- DriverStatusPanel moved from an inline expander row to a modal (one at a
time); the dialog re-resolves its row from the current groups each render so
the 5 s rebuild can't strand it, and the Enabled-in-key recycle discipline
carries over unchanged. HostsPanelWiringTests re-pinned to the dialog markup.
Live-verified on docker-dev: separated cards, in-viewport table, dialog opens
with live breaker/status data and grouped Reconnect|Restart, closes cleanly.
Sweep task 5 (Shared/Drivers + Acls):
- btn-group the Save/Cancel pair in DriverFormShell (all 13 driver forms),
CollectionEditor foot + per-row Edit/Delete, DriverConfigModal /
DeviceModal / DriverTagPicker / AclScopePickerModal feet,
DriverStatusPanel Reconnect+Restart and its inline restart confirm,
and the Refresh+Close browse toolbars on the FOCAS/AbCip/TwinCAT pickers.
- Replace the no-op inline border-color:var(--alert) tint with .notice-alert
in DriverFormShell, DriverConfigModal, DeviceModal, MqttDriverForm,
MTConnectDriverForm, MqttDeviceForm.
- Replace inline style="width:18px" expander sizing with .tree-twist in
DriverBrowseTree and AclScopeTree.
Markup only — every @onclick, binding, disabled and arm->confirm flow is
unchanged.
Group related action buttons into btn-group/btn-group-sm on Alerts, Certificates,
ApiKeys and ScriptEdit; promote the RoleGrants Delete from btn-link to
btn-outline-danger. Replace the two inline style="border-color:var(--alert)"
no-ops with the .notice-alert class, and the undefined --surface-2 token in
Scripts with the theme's shipped --zebra-bg.
Delete unconditional documentation prose now covered by docs/ (Redundancy,
Telemetry, AlarmTracking, security, Reservations, AlarmHistorian, ScriptEditor),
keeping state banners and live captions. Correct the LDAP config key on
RoleGrants to Security:Ldap:GroupToRole (LdapOptions.SectionName) and drop the
same wrong key from Account with its deleted block; reword the stale
"Engine emit (F8/F9) is pending" script-log empty state.
UI sweep task 4 (Shared/Raw). Markup-only; no behavior change.
- Wrap the Cancel+primary modal-footer pairs in a Bootstrap btn-group across
RawNameDialog, RawDriverTypeDialog, RawConfirmDialog, RawTagModal,
RawManualTagEntryModal, RawCsvImportModal, RawBrowseModal and
RawCsvExportModal (the last mixes a <button> with the download <a>, which
is valid btn-group membership; the download attribute is unchanged).
- RawBrowseModal: the rebirth confirm pair (Publish rebirth request + Cancel)
becomes a btn-group btn-group-sm in place of the d-flex gap-2 wrapper.
- RawCsvImportModal: drop the py-0 padding hack from the per-row remove
button; .btn-sm sizing covers it.
- RawTree: replace the expander's inline style="width:18px" (and the matching
leaf placeholder) with the shared .tree-twist class.
The CSV import all-or-nothing commit gating and RawTree's tree logic are
untouched.
Foundation task of the AdminUI UI sweep. CSS/link/doc only — no behavior change
beyond restoring styles that were built but never served.
- App.razor: link the RCL's scoped-CSS bundle
(_content/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.bundle.scp.css).
It is produced on every compile and served by the Host, but nothing referenced
it, so ContextMenu.razor.css was dead at runtime and context menus rendered
in-flow with no position:fixed.
- site.css: three additions.
* .btn / .btn-sm sizing as Bootstrap CSS-VARIABLE overrides (never box
properties — .btn-group depends on Bootstrap's own .btn rules for its seams
and end-cap radii). Theme 0.3.1 ships no .btn rule, so Bootstrap's 1rem
applied against the theme's 0.9rem body. Upstreaming candidate, same as the
ignitionoee router's app.css.
* .notice-alert — red variant of the kit's amber .notice, on the --bad/
--bad-bg/--bad-border tokens. The 13 existing call sites hack this with
style="border-color:var(--alert)", and no --alert token exists anywhere, so
the tint silently no-ops today. Call-site migration is a separate task.
* .tree-twist — shared 18px expander column for the tree components, replacing
repeated inline style="width:18px". Rows are d-flex, so flex-basis is what
actually holds the column. Adoption is a separate task.
- ContextMenu.razor.css: correct the header comment — the kit is light-only
(no dark block in Theme 0.3.1, site.css, or this file). No rule changes.
- docs/Telemetry.md: record that driver-health is keyed per driver instance
across the cluster, not per Akka member (the wire record carries no per-member
identity). The fact previously lived only in Hosts.razor page copy.
- docs/Configuration.md: new audit-log subsection recording that EventId gives
cross-restart dedup, enforced by the filtered unique index
UX_ConfigAuditLog_EventId, so a retried apply inserts no duplicate row;
CorrelationId joins a row to its originating workflow. Previously only in the
historical v2 rebuild plan and ClusterAudit page copy.
Task 1 of the AdminUI UI sweep. Behavior-preserving: only wrappers, classes
and prose change — every @onclick, disabled, href and arm->confirm flow is
untouched.
Buttons -> Bootstrap btn-group:
- ClusterOverview page-head Edit cluster + Deployments pair
- Edit-form feet (Save+Cancel grouped; ms-auto Delete stays outside):
ClusterEdit, NodeEdit, NewCluster, AclEdit
- ClusterRedundancy confirm-failover Confirm+Cancel (ms-2 dropped)
- AclEdit permission-bundle presets: btn-link + middot separators become an
outline-secondary btn-group
Inline style="border-color:var(--alert)" (a no-op — that token is defined
nowhere) replaced by the .notice-alert class: NodeEdit, AclEdit, ClusterEdit.
Prose (unconditional documentation deleted; state banners/legends kept):
- ClusterRedundancy: removed the stale RedundancyStateActor "on each admin
node" block (cluster-scoped singleton on driver nodes since mesh Phase 6)
which also hyperlinked an unserved repo path; trimmed the mesh-split
explanation to its data-provenance first sentence
- ClusterAcls: removed the Security:Acl:Enabled mode walkthrough and the
grant/principal explanation (both in docs/security.md); kept the
unresolved-chip decoder and trimmed the empty state to one sentence
- AclEdit: removed its near-duplicate of that walkthrough
- ClusterAudit: dropped the stale (F3) label and the EventId/CorrelationId
dedup sentence; kept the page-size + Details-JSON caption
- ClusterNamespaces / NamespaceEdit: retired-in-v3 placeholders reworded to
past tense — Batches 2/3 shipped, /raw and /uns are live
Browser-002/-003/-004 fixed and -005 resolved as a documented constants change
(eddf5f57); Contracts-004/-005/-006/-007 fixed (93f70504). Both module headers
drop to 0 open findings. Each resolution records the disposition, the RED
evidence, and the deviations from the recommendation where one was taken.
Driver.Mqtt.Contracts-004/-005/-006/-007.
-004: retainSeed was the last behavioural key read leniently, so "retainSeed":"false"
— what a CSV import produces — silently became the default TRUE, the INVERSE of the
authored intent. Both mappers now read it through TryReadBoolStrict (the qos
absent/valid/present-but-invalid split) and DescribeRejection names it. The authoring
side needed no hand-mirrored rule: MqttTagConfigModel.Validate already consults this
same parse through TagConfigParseGate, so the save is blocked with the inspector's
wording — pinned by a new AdminUI parity theory pair.
-005: the seven [Range] attributes on MqttDriverOptions/MqttPlainOptions never ran —
the options are bound by JsonSerializer, and nothing in the four MQTT projects calls
Validator.TryValidateObject. Two of them CONTRADICTED the real authority (KeepAlive's
[Range(1,…)] forbade the zero Normalize admits as legal MQTT; MaxPayloadBytes' forbade
the non-positive value its own remarks document as falling back to 1 MiB), so wiring
them up would have broken deliberate behaviour. Deleted, with each property naming
where its bound actually lives. The one escape they papered over is closed: an
out-of-range defaultQos rode into the SUBSCRIBE verbatim, and MqttSubscriptionManager's
constructor now clamps it exactly as the AdminUI driver form already persists.
-006: the DateTime write arm cast a negative long to ulong, so 1969-12-31 published a
timestamp ~584 million years in the future; it is now refused as ValueNotRepresentable.
An Unspecified kind was handed to ToUniversalTime(), which reads it as LOCAL — the same
authored instant encoded differently on the two nodes of a redundant pair; it is now
read as UTC.
-007: Format/FormatState enforced only null/empty, and nothing at all on deviceId, so
the type was not round-trip-closed. They now apply the parser's IsValidSegment (plus
'/', which only the format side can see). MqttWriteEncoder's own guards stay — TryEncode
returns an error rather than throwing, and that contract does not change.
Resolutions record the red-first evidence and the two decisions a later reader would
otherwise re-litigate: why the write block is emitted in both states, and why -007's
race budget (8 racers x 3000 rounds) is part of the fix rather than incidental.
Open findings 5 -> 1. The remaining one is Driver.MTConnect-003 (High, browse-committed
MTConnect tags coerced to String by RawBrowseCommitMapper), which was not in this batch.
001 aligned keyValue onto the same scalar read as whereValue (678aa3aa); 002 fixed the
numeric-enum-spelling accept in Core.Abstractions.TagConfigJson.ClassifyEnumText,
fleet-wide (8d4ca548).
IWritable shipped behind write.enabled and docs/drivers/MTConnect.md told the operator to
set it, but MTConnectDriverForm — the only editor DriverFormMaps maps for this type, with
no raw-JSON escape hatch and no driver-instance API — exposed no write section, so the
documented enablement path was a hand edit of DriverInstance.DriverConfig in the config DB.
- Write-back section: switch + form text stating it is observation injection, that the
Agent's AllowPut is an independent second gate, and that it does not by itself make a
node writable over OPC UA (Tag.AccessLevel still composes CurrentWrite).
- Write added to the mirrored MTConnectConfigDto and to the form model, written in BOTH
states — an omitted block would leave a previously-authored true standing, so the switch
could never turn writing off again.
- The false 'MTConnect is read-only' header comment is gone; the doc names the new section.
No bUnit here, so the model/serialization seam is unit-tested both sides: the AdminUI test
pins the emitted bytes, and MTConnectFactoryTests parses that same document and asserts
MTConnectDriverOptions.Write.Enabled. Live page verification remains a rig step.
The first form compared the publish's managed thread id with the caller's, which flaked
(1 in 6): the evaluation pump may reuse the pooled thread the test released while awaiting
the drain, so an id match proves nothing. It now sets a ThreadLocal flag for exactly the
OnDependencyValue window, so 'published inline' is answered on the stack rather than by
thread identity. Re-falsified by making SchedulePump run the pump synchronously (red
immediately, reverted); 5 consecutive clean suite runs after.
The two value-bearing fields were read asymmetrically and nothing said why.
whereValue went through ReadScalar (string/number/boolean — the design doc's own
wide-row example authors it as the number 7); keyValue went through the string-only
ReadString, so a numerically-authored key refused the tag outright. Nothing downstream
justified the split: both are BOUND as DbParameters and both are matched back as text —
SqlPollReader.IndexByKey stringifies the key cell precisely so an authored key can face
an INTEGER key column.
The refusal was also mis-explained. Inspect branched on ReadString(root, "keyValue") is
null, so a numerically-authored key drew "Sql KeyValue tag has no 'keyValue'" — telling
the operator the field is missing when it is right there in the blob, the #549 shape of
an authoring surface disagreeing with the runtime about WHY.
keyValue now uses ReadScalar; Inspect mirrors it with HasScalar and says what a usable
key is; SqlTagConfigModel loads it with the shared TagConfigJson.GetScalarText (which
also replaces SqlRowSelectorModel's private copy), so the editor is not left stricter
than the parser. An object/array/null key is still refused. docs/drivers/Sql.md updated.
RED first: 4 failing in Driver.Sql.Tests.
-003 is a documented Won't Fix: the mitigating control is the ConfigEditor page
policy on the only two surfaces that reach a browse session (/raw and /uns),
there is no HTTP endpoint for browse, and PageAuthorizationGuardTests pins both
pages to that exact policy. Recorded in docs/security.md under Control-Plane
Authorization and pointed at from SqlDriverBrowser's class summary.
Dialect header 3 -> 0 open. The Driver.Sql header count is left alone for the
orchestrator to reconcile — sibling agents are editing that file in parallel.
Both modules go to 0 open findings. -005 records the concurrency guarantee the fix rests
on (at most one evaluation at a time: single pump + _evalGate, lock order _evalGate ->
_lock), the RED evidence, why the dependency snapshot is taken at trigger time, why the
timer path was left on its own scheduler, and the unbounded-queue trade. -001 records that
the equivalence is exact (Inspect's pre-checks already establish TryReadTagConfig's) and
that its new pin was verified against the pre-refactor code.