Commit Graph

1906 Commits

Author SHA1 Message Date
Joseph Doherty f81cbabd2d test(core): pin batch-after-per-tag ordering; relocate batch tests beside the engine suite 2026-08-15 12:21:42 -04:00
Joseph Doherty cd4ce305a2 fix(modbus): batch event honors deadband — raise from the driver-filtered buffer 2026-08-15 12:17:54 -04:00
Joseph Doherty e04ff2488c feat(runtime): DriverInstanceActor forwards batched data changes 2026-08-15 12:16:49 -04:00
Joseph Doherty 04e6a4690c feat(runtime): OpcUaPublishActor batched value updates 2026-08-15 12:16:22 -04:00
Joseph Doherty ba55a5b48e feat(drivers): Modbus + S7 implement IBatchSubscribable via PollGroupEngine batch callback 2026-08-15 12:14:43 -04:00
Joseph Doherty 536fa29224 feat(drivers): AbCip + FOCAS implement IBatchSubscribable 2026-08-15 12:13:51 -04:00
Joseph Doherty 8d7350ea73 feat(opcuaserver): node manager batched WriteValues under one lock 2026-08-15 12:13:46 -04:00
Joseph Doherty adebad711f feat(core): PollGroupEngine optional per-cycle batch callback 2026-08-15 12:11:12 -04:00
Joseph Doherty bb3ccf264b feat(commons): batched WriteValues on the address-space sink 2026-08-15 12:09:27 -04:00
Joseph Doherty 1320f3fa08 perf(runtime): raise opcua pinned dispatcher throughput 1 -> 100 2026-08-15 12:07:05 -04:00
Joseph Doherty b857df8a12 feat(core): add IBatchSubscribable batch data-change contract 2026-08-15 12:06:53 -04:00
Joseph Doherty a1a258d85d chore(deps): bump ZB.MOM.WW.LocalDb 0.1.3 -> 0.2.1, correct the MaxBatchSize rationale
Closes the pin skew against ScadaBridge (which already pins 0.2.1). Pure pin
bump — no code change was required:

- `RegisterReplicated(string)` still delegates to `baselineExistingRows: false`,
  so `LocalDbSetup.OnReady` is byte-identical in behaviour.
- Everything 0.2.x added is additive (`DeregisterReplicated`, `MaxBatchBytes`,
  the `baselineExistingRows` overload); none of it is called here.
- The wire protocol is unchanged (0.1.x peers still sync) and the on-disk
  bookkeeping schema v1 -> v2 upgrades in place on open, so no rig, volume or
  operator action is needed.

The one thing that DID need reconciling is documentation. 0.2.0 made batching
byte-budgeted: `MaxBatchBytes` (2 MB default, under the 4 MB gRPC cap) now
bounds the wire message, and `MaxBatchSize` is demoted to a secondary row cap
plus the DB read-page size. The rig's `MaxBatchSize = 16` was chosen as a
hand-computed byte proxy ("row-count-only against gRPC's 4 MB cap"), and that
rationale is now wrong everywhere it appeared — appsettings, Configuration.md,
the pair-replication runbook, and both docker-dev site-a services.

The VALUE stays 16, for a different and still-real reason: OplogStore /
SnapshotStreamer materialise a whole read page in memory *before* the
byte-budget split runs, so 16 x ~171 KB artifact chunk rows bounds that
transient allocation at ~2.7 MB. Documented as such, with `MaxBatchBytes`
added to both config tables.

CLAUDE.md records the pin and two deliberate non-adoptions: this repo does not
take ScadaBridge's conditional-registration + boot-time `DeregisterReplicated`
self-heal (OnReady registers all four tables unconditionally, and
LocalDbSetupTests pins that set), and consequently does not use
`baselineExistingRows` either — so enabling replication on a long-running
default-OFF node converges on subsequent writes, not on rows already in the
file. Both are behaviour changes, out of scope for a pin bump.

Verification: solution builds clean; full suite run (57 projects). All 66
LocalDb tests in Host.IntegrationTests green. One unrelated failure,
RoslynVirtualTagEvaluatorTests.Evaluate_racing_ClearCompiledScripts_never_
fails_with_disposed — a Roslyn evaluator disposal race with no LocalDb in its
path, whose own comment documents that its "at most one clear per evaluation"
assumption depends on thread-pool pressure. It passed 3/3 re-runs in isolation
and fails only under full-solution parallel load; pre-existing test-design
fragility, not a regression from this bump.
2026-08-15 03:33:53 -04:00
Joseph Doherty 259d9621ba feat(adminui): gate the Secrets rail link on the page's own policy
v2-ci / build (push) Successful in 4m44s
v2-ci / unit-tests (push) Failing after 17m18s
The /admin/secrets link was already in the side rail, but ungated — every
authenticated user saw it, and a Designer/Operator clicking it was bounced by
the page's own [Authorize(Policy = SecretsAuthorization.ManagePolicy)]. Hide
the advertised dead end.

Gated on that same policy constant rather than Roles="Administrator". The two
are equivalent today (the RCL's AdminRole is "Administrator", read off the same
ClaimTypes.Role claim FleetAdmin uses), so this is not about reach — it is that
the rail cannot drift if the RCL ever changes its policy.

Live-gated on docker-dev, both directions, because the positive half alone
proves nothing here: the link rendered for an admin BEFORE this change too, so
"admin still sees it" is satisfied equally by a working gate and by an inert
one. Negative control — gate swapped to Roles="NoSuchRoleFalsifyProbe", rebuilt,
item went to 0 while /role-grants and /apikeys stayed at 1 (the item is hidden,
not the rail broken). Restored build hashed to the same image ID as the
pre-falsification one, proving the restore was byte-exact. This also rules out
AuthorizeView being inert inside ThemeShell's <Nav> slot under this host's
static router, which would have silently hidden the link from everyone.

SecretsNavLinkTests covers it in the AdminUI's actual idiom — source scan +
metadata cross-check, NOT bUnit (the bunit PackageVersion pin has zero
PackageReferences repo-wide). The load-bearing assertion is index ordering
(AuthorizeView open < NavRailItem < close): a scan that merely finds both tags
in the file passes when they are not nested, which is the ungated state.

Scope: Secrets only. /role-grants and /apikeys are FleetAdmin (Administrator-
only) too and stay visible to everyone, so the rail is now inconsistent —
deliberate, as hiding those is a UX call for the whole rail rather than
something to smuggle into this change. Noted in the markup.

Family-wide task from the scadaproj index session (ScadaBridge ee193cd2,
ignitionoee 3627bb8, HistorianGateway 58b6418, mxgw 87d575d).
2026-08-13 09:59:31 -04:00
Joseph Doherty c51c59987b fix(galaxy): publish an unrepresentable value as BadTypeMismatch, not a good null
v2-ci / build (push) Successful in 6m23s
v2-ci / unit-tests (push) Failing after 20m2s
Reverses the conservative call from the previous commit at the user's direction,
and they were right: "surface it on a counter first" left the wire lying. An
MXAccess event can carry Good quality while its payload is a kind MxValueDecoder
has no mapping for, so the old behaviour published null-under-Good — which a
client cannot tell apart from a genuine null reading. A value the gateway DID
send would be consumed as a value it did not, with the only evidence in a log
nobody is tailing. Bad data is the honest wire representation of lost data.

BadTypeMismatch is an existing StatusCodeMap constant, deliberately reused rather
than written inline: StatusCodeParityTests finds status constants by reflecting
over Good/Uncertain/Bad-prefixed uint fields and checking them against the pinned
SDK, so an inline literal would be invisible to that guard (Gitea #497, where 16
wrong constants had accumulated across 6 drivers exactly this way).

Logging is now once per DISTINCT value kind rather than once per pump. The set is
bounded by the MxValue oneof enum so a broken producer still cannot flood, but a
second unrepresentable kind appearing later gets its own line — warn-once-per-pump
would have silently swallowed it, which is the same class of bug as the null this
commit fixes.

The test's fixture matters more than its assertion: the undecodable event carries
Quality = 192 (OPC DA Good), so a decoder that gave up silently would publish Good
and fail. Paired with a positive control — a decodable value at the same quality
must still publish Good — so it cannot be satisfied by a pump that marks
everything Bad. Falsified by reverting the status to Good: the test reds, and only
that test.

Verified: 331 Galaxy tests green, Core.Abstractions 298 (the parity guard),
solution builds 0 errors. Docs corrected in the two places that recorded the old
decision as deliberate.
2026-08-12 03:03:51 -04:00
Joseph Doherty 4769044f88 feat(galaxy): handle the replay-gap sentinel and sparse-array values
v2-ci / build (push) Successful in 4m39s
v2-ci / unit-tests (push) Failing after 19m35s
Closes the two follow-ups left open by the MxGateway 0.2.0 bump. Reading the
gateway's own contract first changed what each of them is, and one of my earlier
claims was simply wrong -- both corrections are recorded below.

Replay gap
----------
A sentinel MxEvent carries family = UNSPECIFIED, so it fell through EventPump's
family switch into the default drop: we silently ignored a documented MUST ("you
missed events -- discard local state and re-snapshot"). It is now intercepted and
answered by re-issuing SubscribeBulk for every tracked subscription, since
MXAccess delivers an initial value per item on subscribe -- re-subscribing IS the
re-snapshot. It does not restart the event pump (the stream carrying the sentinel
is healthy, and the handler runs on that stream's own read loop, so disposing the
pump from there would deadlock on itself), it does not block the read loop, and
re-entry is gated so a burst collapses to one pass instead of a subscribe storm
per sentinel.

Handled on the READ loop, not in the dispatch loop, because everything past that
point is subject to newest-dropped channel pressure -- and "you missed events" is
the one signal whose loss is unrecoverable. Pinned by a test that fills the
channel and still expects the sentinel.

⚠️ This cannot fire in the shipped configuration, and I did not pretend otherwise
or paper over it by enabling resume. The gateway emits the sentinel only for a
stream opened with after_worker_sequence > 0; GatewayGalaxySubscriber passes 0,
which the gateway treats as "fresh stream, not a resume -- plain attach, no
replay, no sentinel". Enabling resume is a larger and genuinely delicate change:
the sequence is only meaningful within ONE gateway session, our reconnect
recreates the session, and replaying a remembered sequence against a fresh
session sets the server's live watermark ABOVE every event it is about to send --
silencing the stream instead of recovering it. That deserves its own change with
a live gate, which I cannot run from here.

Sparse arrays -- correcting the last commit message
---------------------------------------------------
I wrote that the bump "makes handling it possible" on the decode path. That is
wrong: MxSparseArray is documented write-only, the gateway expands it before
forwarding, and "the worker never receives or produces it". So the decode path
is unreachable by contract and the write path has no use for it either -- it is a
wire compaction for a WHOLE-array write, not a partial update (unmentioned
indices are RESET, deliberately, because the gateway cannot read-modify-write
without racing the provider). It is therefore the wrong primitive for an OPC UA
IndexRange write, which expects untouched elements preserved -- and WriteRequest
carries no IndexRange in the first place.

It is decoded anyway, as contract defence: the alternative if a future gateway
ever emits one is a silent null published under Good quality. Fill semantics
mirror the gateway's own SparseArrayExpander exactly (epoch for Time, empty
string, Integer widening to long[] when any element is int64) so a value that
round-trips through a write reads back as the same CLR array.

Also: the decoder is now total by contract, since it runs inside the dispatch
loop where an escaping exception kills dispatch for the life of the pump. A
malformed payload degrades instead of throwing.

A real defect found in the same code
------------------------------------
A stream the GATEWAY completes -- a closed or evicted session -- ends the RPC
without an error, so nothing threw, the supervisor was never notified, and the
pump exited silently. GetHealth() kept reporting Healthy, which also means the
#527 comms-loss watchdog could never trip, because it requires a non-Healthy
state. The driver went permanently deaf with no signal anywhere. This one is
live, unlike the two above. Now reported as a fault; the falsification control is
the existing CleanShutdown_DoesNotInvokeOnStreamFault, since our own disposal
also ends the stream and must NOT be reported.

Value kinds we cannot represent are now counted and warned once rather than
published as an indistinguishable null. The status is deliberately NOT downgraded
to Bad: nothing can reach that branch today, and changing what an OPC UA client
sees for a live shape is not a change to make without a gate. The counter exists
so the next person has evidence instead of a guess.

Verified: 330 Galaxy tests green (12 new), plus Galaxy.Browser 21, Runtime 558,
Core 351; full solution builds 0 errors with the warning census unchanged. Every
new test was falsified first -- disabling the gap interception reds 3 of 4 (the
throwing-callback test passes vacuously when the callback never runs, by
construction), disabling sparse decode plus the representability signal reds all
7 decoder tests, and both were restored and re-verified green.

Docs: the Galaxy.Performance invariant was stated as received = dispatched +
dropped + in-flight, which this breaks -- a sentinel is received but never enters
the channel -- so it now reads + replay_gaps. Galaxy.md gains the three
operational notes; DriverLifecycle.md records that "reaches a non-Healthy state"
is an obligation a driver can silently fail to meet, which is the general shape
of the clean-end bug.
2026-08-12 02:56:17 -04:00
Joseph Doherty c8b5c997b2 fix(logging): #561 was a measurement artifact — measure sink growth through an open handle
v2-ci / build (push) Successful in 4m34s
v2-ci / unit-tests (push) Failing after 16m39s
Gitea #561 ("production writes a 0-byte log") is closed as not-a-defect. The
production log on wonder-app-vd03 listed as 0 bytes with LastWriteTime ==
CreationTime; opening it showed 4,658,893 bytes of correct output, written
continuously since service start.

On Windows the directory entry's size and last-write time are not refreshed while
the writing process holds the file open, so Get-ChildItem, dir and FileInfo.Length
all report size-at-open for the life of the handle. A live, healthy log is
indistinguishable from a dead sink by metadata alone — which is what made the
false reading survive six weeks of investigation.

That same trap was in the 29a202f5 instrumentation: CheckFileSinkGrowth measured
with FileInfo.Length, so it would have reported "self-check FAILED: the sink did
not grow" at every boot on a perfectly healthy Windows service — and a
self-check's false positive reads as confirmation of the very bug it was added to
find. MeasureFamilyBytes now reads Length through an opened FileStream:
FileShare.ReadWrite to open it at all while the sink holds its write handle, and
FileShare.Delete so the probe never blocks a roll or retention delete.

Both instruments are kept. A sink that cannot write its file is still a real and
otherwise-invisible failure — a sibling repo reproduced it via handle contention,
where RollingFileSink logs the lock-out to SelfLog and falls through to _001. Only
the two doc claims that are now false are corrected: the class summary's
"production ran that way for six weeks" and the warning text's "#561 failure mode".

Tests: A_working_file_sink_passes already measured while Serilog held the file
open — precisely the broken case — and passed throughout, because it only ever ran
on macOS, where the length is honest. That platform blindness is now recorded in
the class summary, alongside two tests pinning the sharing contract the fix
depends on.
2026-08-12 02:09:23 -04:00
Joseph Doherty b037cc7ac4 chore(secrets): adopt ZB.MOM.WW.Secrets 0.6.2 — fail-closed store-path validation
v2-ci / build (push) Successful in 4m42s
v2-ci / unit-tests (push) Failing after 15m19s
Four-minor jump 0.2.3 → 0.6.2. The release line exists because a deploy
renaming the app directory took the MxGateway credential store with it
(2026-08-09, two silent days): Secrets:SqlitePath must now be absolute AND
outside the content root, rejected at boot rather than relocated. 0.6.2 over
0.6.1 is message-only (the rejection text now teaches the mounted-volume
case and forecloses the override hunt) — the live rig evidence below was
gathered at 0.6.1 and the validator behavior is identical.

Adoption changes beyond the pins:

- appsettings.json drops the relative "otopcua-secrets.db" (now boot-fatal);
  dev gets the library's absolute per-user default, deployments supply their
  own (wonder already pins an absolute out-of-root path in the service env).
- Program.cs's pre-host ${secret:} expander now calls the FOUR-argument
  AddZbSecrets with builder.Environment.ContentRootPath. This call site is
  the actual fix, not the pin: the throwaway container has no
  IHostEnvironment, and without the explicit root the under-content-root
  rule is skipped at exactly the moment the migrator creates the store file
  (the 0.6.0 defect). Do NOT simplify back to the 3-argument overload — it
  compiles and silently reopens the gap. Pinned by a Program.cs source-scan
  test AND a runtime falsification control that RUNS the migration and
  demonstrably creates the store at the rejected path under the 3-arg form.
- docker-dev: Secrets__SqlitePath moves /app/data → /data via a SECOND mount
  of the same per-node volume (same file at the volume root, so seeded
  stores carry over) because /app is the container's content root and the
  new rule correctly rejects it. LocalDb stays on /app/data (secrets-only
  rule).
- SecretsStorePathValidationTests: positive-control probes, artifact-first
  (relative and in-content-root paths must FAIL leaving no file; outside-
  root passes; the no-explicit-root control proves the file assertions can
  fail) — a clean boot is equally consistent with the validator never being
  wired.

Live-verified on the six-node docker-dev rig (at 0.6.1): all nodes boot
under the new validation with stores at /data; an AdminUI-written secret
converged into BOTH central nodes' independent stores; site-a held only its
own pair's secrets (Phase 6 pair-local isolation intact). Replicator wire
behavior unchanged 0.3.0–0.6.x per CHANGELOG; registration-graph pins and
the full suite pass.
2026-08-11 09:06:43 -04:00
Joseph Doherty 8ab520ef57 fix(scripts): gate the source dialog's existence on the held id, not the resolve
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.
2026-08-11 06:06:49 -04:00
Joseph Doherty 29a202f5ee fix(logging): #561 — SelfLog to file+stderr and a startup file-sink growth self-check
v2-ci / build (push) Successful in 6m38s
v2-ci / unit-tests (push) Failing after 17m30s
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.
2026-08-11 05:52:54 -04:00
Joseph Doherty 01c2c8a1b7 chore(theme): bump ZB.MOM.WW.Theme 0.3.1 → 0.4.0 — kit ships the .btn sizing block (local copy deleted); drop inert Accent param (kit-default value, inline --accent would beat a future dark override) 2026-08-11 02:56:14 -04:00
Joseph Doherty 7f7798cd8c merge: ui(density) Task 3 — EquipmentPage panel idiom + bounded RawPath 2026-08-10 18:01:39 -04:00
Joseph Doherty ac419cf2b7 merge: ui(density) Task 2 — Scripts inline expander → source dialog + stacked identity 2026-08-10 18:01:39 -04:00
Joseph Doherty 564f859215 merge: ui(density) Task 1 — truncation sweep (Abbreviate + title on unbounded text) 2026-08-10 18:01:39 -04:00
Joseph Doherty 88aaf1d947 ui(density): truncation sweep — Abbreviate + title on unbounded backend/free text (§1d Task 1) 2026-08-10 18:01:07 -04:00
Joseph Doherty 4cbd03bd21 ui(equipment): adopt panel idiom on tab bodies + bound RawPath column (§1d Task 3) 2026-08-10 18:00:20 -04:00
Joseph Doherty ac85a9acb5 ui(scripts): inline source expander → dialog + stacked row identity (§1d Task 2) 2026-08-10 17:59:11 -04:00
Joseph Doherty 34d60c4125 ui(hosts): declutter — per-cluster panels, stacked driver identity, abbreviated Last error, Controls in a dialog
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.
2026-08-10 17:34:37 -04:00
Joseph Doherty ef55ff3977 merge: AdminUI sweep — Shared/Drivers+Acls (DriverFormShell lever, CollectionEditor, tint swaps)
v2-ci / build (push) Successful in 4m56s
v2-ci / unit-tests (push) Failing after 15m23s
2026-08-10 16:33:24 -04:00
Joseph Doherty 47bf7a3186 merge: AdminUI sweep — Shared/Raw (modal feet, rebirth confirm pair, tree-twist) 2026-08-10 16:33:24 -04:00
Joseph Doherty 5b7fdb8911 merge: AdminUI sweep — UNS pages (modal feet, UnsTree action buttons, tree-twist) 2026-08-10 16:33:24 -04:00
Joseph Doherty 5c3b8cd8d1 merge: AdminUI sweep — ops pages (btn-groups, Security:Ldap key fix, prose deletions) 2026-08-10 16:33:24 -04:00
Joseph Doherty 9c04c0dcee merge: AdminUI sweep — Clusters pages (btn-groups, notice-alert, stale-prose deletions) 2026-08-10 16:33:24 -04:00
Joseph Doherty ec652f6bde ui(adminui): group driver/acl shared button pairs, adopt notice-alert + tree-twist
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.
2026-08-10 16:32:47 -04:00
Joseph Doherty 3d6d28e5a2 style(adminui): btn-groups + prose trim on top-level ops pages
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.
2026-08-10 16:32:29 -04:00
Joseph Doherty 30345c2941 style(adminui): group UNS page + modal buttons into btn-groups
Task 3 of the AdminUI UI sweep. Behavior-preserving markup only:

- GlobalUns: Expand/Collapse pair -> btn-group btn-group-sm (Import stays
  separate); delete-confirm footer Cancel+Delete -> btn-group.
- Area/Line/ScriptedAlarm/VirtualTag/AddReference/ImportEquipment modal
  footers: Cancel + primary -> btn-group.
- EquipmentPage: the three text-end action cells (Save name/Remove,
  Edit/Delete x2) -> btn-group btn-group-sm, me-1 spacers dropped.
- UnsTree: per-node actions become real small buttons (outline-secondary,
  outline-danger for destructive) and adjacent runs of >=2 group into
  btn-group btn-group-sm; the genuine navigation links (cluster settings,
  equipment Open) stay links. Expander/leaf placeholder adopt .tree-twist
  in place of the inline width:18px.
2026-08-10 16:31:55 -04:00
Joseph Doherty d2ad58be0e style(adminui): group Raw modal button pairs, drop py-0 hack, use tree-twist
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.
2026-08-10 16:31:44 -04:00
Joseph Doherty 697a773340 fix(adminui): link the dead scoped-CSS bundle; add btn/notice-alert/tree-twist rules
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.
2026-08-10 16:31:42 -04:00
Joseph Doherty 42924b6fb0 ui(adminui): clusters pages — button groups, notice-alert tint, prose trim
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
2026-08-10 16:31:25 -04:00
Joseph Doherty acea65eb96 merge: MQTT browser+contracts review findings resolved (Driver.Mqtt.Browser-002..-005, Driver.Mqtt.Contracts-004..-007) 2026-08-10 08:23:16 -04:00
Joseph Doherty 93f70504bd fix(mqtt-contracts): strict retainSeed, real bounds instead of dead [Range]s, DateTime edges, closed topic formatting
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.
2026-08-10 08:18:30 -04:00
Joseph Doherty b8acabfa5f merge: SQL driver health review findings resolved (Driver.Sql-004/-005/-006/-008/-010)
# Conflicts:
#	docs/drivers/Sql.md
2026-08-10 08:11:40 -04:00
Joseph Doherty ead8a97511 merge: MTConnect review findings resolved (Driver.MTConnect-004 write-back authoring, -005/-006/-007)
# Conflicts:
#	code-reviews/Driver.MTConnect/findings.md
2026-08-10 08:11:03 -04:00
Joseph Doherty 38dee82e29 merge: SQL contracts review findings resolved (Driver.Sql.Contracts-001 scalar keyValue, -002 fleet-wide numeric-enum rejection) 2026-08-10 08:10:36 -04:00
Joseph Doherty 55f4354117 merge: Calculation review findings resolved (Driver.Calculation-005 pump split, Driver.Calculation.Contracts-001 single-parse Inspect) 2026-08-10 08:09:21 -04:00
Joseph Doherty 298ad04fba feat(adminui): author MTConnect write-back on the driver form (Driver.MTConnect-004)
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.
2026-08-10 08:07:36 -04:00
Joseph Doherty 678aa3aa53 fix(sql): read keyValue with the same scalar read as whereValue (Driver.Sql.Contracts-001)
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.
2026-08-10 08:07:15 -04:00
Joseph Doherty 9d4760fd2f docs(driver.sql): topByTimestamp is only defined when the ordering column is unique (Driver.Sql-008) 2026-08-10 08:06:27 -04:00
Joseph Doherty d7568ac342 merge: SQL dialect+chunking review findings resolved (Driver.Sql-002, Driver.Sql.Dialect-001/-002/-003) 2026-08-10 08:06:13 -04:00
Joseph Doherty 300abde4bb merge: SQL browser/probe review findings resolved (Driver.Sql-009, Driver.Sql.Browser-001/-002/-003) 2026-08-10 08:06:02 -04:00
Joseph Doherty 336e79ea9a fix(driver.sql): compare-and-swap the health field so a concurrent transition cannot be lost (Driver.Sql-010) 2026-08-10 08:05:23 -04:00