ui: Central UI density/consistency sweep + Theme 0.4.1
Applies the family-wide admin-UI cleanup playbook to the Central UI so the Blazor surfaces stop diverging from the shared kit: buttons are grouped rather than individually sized, long cell values are contained instead of widening tables, and hard-coded colours give way to theme tokens. The headline fix is that MainLayout passed Accent="#2f5fd0" to ThemeShell, which the kit emits as an inline style on the shell root. Being a descendant of <html>, it beat the [data-bs-theme="dark"] override for the entire app, so the dark accent had never rendered. Declaring --accent in site.css :root instead lets both schemes resolve; light is unchanged because the value already matched the kit's light default. Theme pins to 0.4.1, which upstreams the local .btn sizing block verbatim, so that block is deleted here rather than duplicated. Verified byte-identical before removal; the repo now declares no --bs-btn-* anywhere. NOT purely cosmetic, contrary to the sweep's stated scope: four detail-modal surfaces (NotificationReport, ConfigurationAuditLog, ParkedMessages, SiteCallsReport) were additionally refactored from holding the selected record to holding its id and re-resolving from the current page each render, with the resolve doubling as the visibility gate. A background refresh that drops the row now closes the modal instead of showing a stale snapshot. This is a behaviour change and is called out rather than buried: a full-suite run turned up one intermittent CentralUI failure, CloseButton_DismissesModal, whose stack (GetRequiredEventBindingEntry during DispatchEventAsync) indicates the handler was disposed between render and click — a window the previous field-held record made structurally impossible. Treat the modal lifecycle here as unreviewed. Build 0/0; suite green apart from that one intermittent failure.
This commit is contained in:
@@ -103,7 +103,7 @@
|
||||
<PackageVersion Include="ZB.MOM.WW.Auth.ApiKeys" Version="0.1.5" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.5" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Audit" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Theme" Version="0.3.1" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Theme" Version="0.4.1" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Secrets" Version="0.5.1" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Secrets.Abstractions" Version="0.5.1" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Secrets.Ui" Version="0.5.1" />
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
# Central UI cleanup sweep (2026-08-11)
|
||||
|
||||
Applies the family admin-UI cleanup playbook (`../scadaproj/admin_ui_cleanup.md`) to the
|
||||
ScadaBridge Central UI. That playbook was distilled from two completed rounds — the ignitionoee
|
||||
router and the OtOpcUa AdminUI — both on the same ZB.MOM.WW.Theme 0.3.1 + Bootstrap 5.3 base.
|
||||
Same *classes* of defect, different instances per app; the discovery phase below was run before
|
||||
anything was changed.
|
||||
|
||||
Surface: `src/ZB.MOM.WW.ScadaBridge.CentralUI/` (99 `.razor` files, 43 routable pages) plus
|
||||
`src/ZB.MOM.WW.ScadaBridge.Host/Components/App.razor`.
|
||||
|
||||
## 0. Where ScadaBridge diverges from the playbook's premises
|
||||
|
||||
Recorded first because four of the playbook's assumptions are wrong for this repo, and acting on
|
||||
them unverified would have produced damage rather than cleanup.
|
||||
|
||||
| Playbook premise | ScadaBridge reality |
|
||||
|---|---|
|
||||
| "Theme 0.3.1 is light-only — don't chase dark mode." | **Inverted.** This app implements dark mode itself: `site.css` carries a `[data-bs-theme="dark"]` token override block and `App.razor` has a pre-paint inline script reading `localStorage['sb-theme']`. Hard-coded colours and phantom tokens are therefore *worse* here, not moot. |
|
||||
| "Check whether the scoped-CSS bundle is linked — OtOpcUa's was dead." | **CLEAN**, verified on disk rather than assumed. The RCL bundle carries all 8 scope ids and the Host bundle's entire content is the `@import` of it; `Program.cs` calls `MapStaticAssets()`. Adding a manual `<link>` would double-apply. |
|
||||
| "Neither app has bUnit; the final gate is a live browser pass." | **Wrong for this repo** — `tests/…CentralUI.Tests` references bUnit, and several tests render components. Page-level tests are still source-scan guards that grep `.razor` text. |
|
||||
| "The theme's `panel`/`panel-head` shell." | Not used here at all (`grep -c panel-head` = 0). The idiom is Bootstrap `card`/`card-header`/`card-body`. |
|
||||
|
||||
## 1. Discovery findings
|
||||
|
||||
### 1a. Foundation / CSS audit
|
||||
|
||||
- **Phantom `var(--…)` tokens: CLEAN, zero.** All 25 token usages resolve. The two that look
|
||||
undefined (`--audit-col-width`, `--tv-depth`) are set at runtime from
|
||||
`AuditResultsGrid.razor.cs` / `audit-grid.js` and `TreeView.razor`. No `var(--x, fallback)`
|
||||
masks a phantom.
|
||||
- **Ghost CSS classes: 26 distinct / 39 occurrences, but only ONE loses intent.**
|
||||
`form-label-sm` (×6, `SecuredWrites.razor:44,54,65,76,80,89`) — Bootstrap ships `.form-label`
|
||||
and `.col-form-label-sm` but not `.form-label-sm`, so the intended smaller label silently
|
||||
no-opped next to `form-select-sm`/`form-control-sm` controls. The other 25 are inert naming
|
||||
hooks whose geometry lives in an inline `style=` on the same element or is driven by JS interop.
|
||||
- **Button sizing.** Theme 0.3.1 ships no `.btn` rule, so Bootstrap's hard-coded
|
||||
`--bs-btn-font-size: 1rem` applied against the kit's 0.9rem body. 316 `.btn` elements: 244
|
||||
explicit `btn-sm`/`btn-lg`, 22 sized by an enclosing `btn-group-sm`, **50 full-size** —
|
||||
concentrated in the Transport wizard feet (18) and the shared form-shell Save/Cancel feet (15).
|
||||
13 further buttons carry `py-0 px-*` micro-sizing hacks, a symptom of the same gap.
|
||||
- **Hard-coded colours — one HIGH.** `MainLayout.razor:6` passed `Accent="#2f5fd0"` to
|
||||
`ThemeShell`. The kit emits that parameter as `style="--accent: …"` on the shell root, which is a
|
||||
descendant of `<html>` and therefore **beat the `[data-bs-theme="dark"]` override for the entire
|
||||
app — the dark accent was dead**, affecting the brand mark, the active rail-link border and
|
||||
`.rail-btn`. And it was a no-op in light, because `#2f5fd0` is already theme.css's own light
|
||||
default. Also MEDIUM: `AlarmTriggerEditor.razor:281-283` baked Bootstrap light subtle tints into
|
||||
a deadband-preview SVG, rendering as bright pastel bars on the dark page.
|
||||
- The playbook's repeated inline `width:18px` expander hack is essentially **absent** — the four
|
||||
named tree components size their expanders by class. One hit only, `Dialogs/TreeRow.razor:10`.
|
||||
|
||||
### 1b. Button inventory
|
||||
|
||||
**Four of the prior apps' defect classes simply do not exist here:** zero `·` action
|
||||
separators, zero `<a href="#">` actions, zero inline `style=` button sizing hacks, zero
|
||||
navigation-interceptor "must be a button" comments. And `btn-group` is not zero — 6 correct groups
|
||||
already exist and served as the exemplars.
|
||||
|
||||
Real inventory: ~45 grouping candidates (15 dialog feet, 15 form feet, 2 filter bars, 3 pagers,
|
||||
9 toolbars, ~14 row-action cells); **4** genuine link→action conversions; 4 `btn-link` Back buttons
|
||||
against 7 siblings already using `btn-outline-secondary btn-sm` (three idioms for one affordance);
|
||||
~26 inter-button spacers to drop (and 12 lookalikes that must be kept); exactly **one** inline
|
||||
arm→confirm flow (`TransportExport.razor:499-518`), restyle-only.
|
||||
|
||||
Biggest levers: `DialogHost.razor` (1 render site, 23 `ConfirmAsync` call sites),
|
||||
`MonacoEditor.razor` (6 files), `SchemaBuilder.razor` (4 files / 7 instances).
|
||||
|
||||
### 1c. Prose inventory
|
||||
|
||||
The UI was already lean: ~12 unconditionally-rendered explainer blocks across 98 files, **zero**
|
||||
rendered milestone labels, **zero** dead links, **zero** wrong config keys, **zero** wrong
|
||||
numerics. 10 DELETE, 4 RELOCATE-then-delete, ~120 KEEP.
|
||||
|
||||
The real defects were in `docs/`, not on the pages — and per the playbook, wrong facts outrank
|
||||
style:
|
||||
|
||||
- `Component-Transport.md` claimed a `NotificationList` pulls in its `SmtpConfiguration`. It does
|
||||
not — `DependencyResolver` only walks `ExportSelection.SmtpConfigurationIds`. The *UI banner* was
|
||||
the correct statement and the doc was wrong.
|
||||
- `Component-Transport.md` listed **API keys as exportable** in five places. They are deliberately
|
||||
not transportable (see the comment on `ExportSelection`), and the shipped **SMS configs** group
|
||||
was missing from the same lists.
|
||||
- `Component-Transport.md` described a "6-Step" import wizard; the UI renders 5 pills, with Map a
|
||||
sub-section inside Diff.
|
||||
- `Component-CentralUI.md` (×4) and `Component-ClusterInfrastructure.md` still said "via
|
||||
ClusterClient" — removed in the gRPC migration's Phase 4.
|
||||
- `Component-Commons.md` omitted `Expression` from `AlarmTriggerType`.
|
||||
- The HiLo **partial-merge vs whole-replace** override rule existed only in a June plan doc, never
|
||||
in `docs/requirements/`.
|
||||
|
||||
Plus one vestigial UI field: `SiteForm.razor` collects a per-site **Akka Address** that has had no
|
||||
runtime consumer since gRPC Phase 4 — only writers remain; every central→site dial resolves
|
||||
`GrpcNodeAAddress`/`GrpcNodeBAddress`.
|
||||
|
||||
#### Out-of-scope doc drift found while verifying the above (NOT fixed here)
|
||||
|
||||
Chasing the UI prose's citations surfaced a wider ClusterClient de-drift job that this sweep
|
||||
deliberately did **not** take on — it is a docs initiative, not a UI cleanup, and silently
|
||||
expanding into it would have made this change unreviewable. Recorded so it does not get lost:
|
||||
|
||||
- `docs/requirements/HighLevelReqs.md:49,51,52,405,523` still describes
|
||||
**ClusterClient/ClusterClientReceptionist as the live command/control transport**, including
|
||||
"Central creates a ClusterClient per site using both Akka addresses as contact points". All of
|
||||
that went in the migration's Phase 4.
|
||||
- `docs/requirements/Component-ManagementService.md` **contradicts itself**: `:27` says the
|
||||
ManagementActor is "**not** advertised via ClusterClientReceptionist", while `:16`, `:21`, and
|
||||
the whole `:48` "ClusterClientReceptionist Registration" section say it registers and that
|
||||
failover routes through ClusterClient. `:272` and `:278` repeat the claim.
|
||||
- `docs/requirements/Component-NotificationOutbox.md:31` labels the site→central store-and-forward
|
||||
edge "(ClusterClient)" in its diagram.
|
||||
- `docs/requirements/Component-Commons.md:292` — a namespace-tree comment reading
|
||||
"HTTP/ClusterClient management commands".
|
||||
|
||||
`Component-Host.md` is already correct (it has an explicit REQ-HOST-6a "no
|
||||
ClusterClientReceptionist" section) and is the model for what the others should say.
|
||||
|
||||
### 1d. Density / layout
|
||||
|
||||
The scan that matters most, and the one that fires hardest here.
|
||||
|
||||
- **Unbounded free-text columns — ~30 cells.** Worst: `SecuredWrites.razor:218`
|
||||
(`@row.ExecutionError`, raw device exception text, last of 11 columns, six `text-nowrap`
|
||||
neighbours), `TransportImport.razor:265` (`@item.FieldDiffJson`, externally-produced JSON in the
|
||||
only `<pre>` in the codebase without `pre-wrap`, inside a `colspan="6"` row), and
|
||||
`ConnectionCertificates.razor:63-64` (two adjacent remote X.509 DNs, bare, columns 1–2 of 7).
|
||||
Notable pattern: several rows had already bounded their `LastError` correctly but left a sibling
|
||||
column bare.
|
||||
- **Sections running together — 8 pages.** Worst: `Health.razor:234-434` (five `<h6>` groups in one
|
||||
card body per site) and `TemplateEdit.razor:436` (four heading+table groups in one card body,
|
||||
and that card is emitted *above* the page's own `<h4>` title).
|
||||
- **Identity slam — ~10.** Worst: `Health.razor:208` (name + machine id fused in one `<strong>`),
|
||||
`NotificationLists.razor:77` (recipient name + contact in one chip, uncapped count, one cell).
|
||||
- **Raw slices.** No unguarded ones in markup. Three unguarded on toast paths
|
||||
(`Topology.razor:905,963,964`) plus a parser bug at `ParameterValueForm.razor:382`.
|
||||
- **Detail surfaces holding the row OBJECT rather than its id — 5.** Latent today (none of those
|
||||
pages is on a timer) but wrong, and mandatory to fix before any lands on a timer page.
|
||||
|
||||
Reference pages already doing it right: `Admin/Sites.razor` (stacked identity),
|
||||
`Monitoring/ParkedMessages.razor` (line-clamped error + drawer).
|
||||
|
||||
### 1e. Tree tables — verdict: **REJECT**
|
||||
|
||||
Do not port the router's generic `TreeTable`/`TreeVisibility`. Zero `rowspan`, zero
|
||||
indent-by-padding table rows, zero repeated path-prefix columns, zero faked group-header rows. The
|
||||
only hierarchy-ish shape (dotted composed member names in `InstanceConfigure.razor`) is one column,
|
||||
flat for non-composed instances, carries per-row edit controls, and already has a proper tree
|
||||
presentation in `DebugView.razor`. The six nav/picker trees (`TreeView`, `TemplateFolderTree`,
|
||||
`NodeBrowserDialog`+`TreeRow`, `ExecutionTree`, `SchemaBuilder`) are the "different animal" the
|
||||
playbook says to leave alone. An unused component would be an orphan.
|
||||
|
||||
## 2. Changes made
|
||||
|
||||
### Foundation (landed first — page batches reference it by name)
|
||||
|
||||
`src/ZB.MOM.WW.ScadaBridge.CentralUI/wwwroot/css/site.css` (already loaded *after* `<ThemeHead />`,
|
||||
so cascade order needed no change):
|
||||
|
||||
- The `.btn` / `.btn-sm` **Bootstrap CSS-variable override block**, verbatim from the playbook, with
|
||||
the upstreaming header comment. Normalizes every unsized button app-wide without touching call
|
||||
sites. Deliberately overrides the *variables* only — `btn-group` seam and radius machinery
|
||||
depends on the box properties.
|
||||
- `--accent` moved into `:root` here, and `Accent="#2f5fd0"` **removed** from
|
||||
`MainLayout.razor`'s `ThemeShell`. As a `:root` declaration it loses to the dark block (equal
|
||||
specificity, later in the same file) and wins over theme.css (equal specificity, earlier sheet).
|
||||
Light mode is byte-identical because the value already matched theme.css's own light default.
|
||||
- `.form-label-sm` — the one ghost class that lost intent, given the rule it always implied.
|
||||
- Table-containment utilities the page batches use: `.cell-clip` (+ `-sm` / `-lg` width
|
||||
modifiers), `.cell-clamp-2`, `.detail-pre`. Modelled on the existing `.parked-error-clamp`
|
||||
pattern. **House rule: every clip/clamp is paired with a `title` so the full value stays
|
||||
reachable.**
|
||||
- `AlarmTriggerEditor.razor` deadband SVG fills → `var(--bad-bg)` / `var(--ok-bg)`.
|
||||
|
||||
Docs (landed before the page deletions, so no deletion raced its relocation):
|
||||
`Component-Transport.md` (API keys not transportable + the recovery hint, SMS configs added, the
|
||||
SMTP dependency-edge claim corrected, 6-Step → 5-Step, manifest samples and CLI synopsis),
|
||||
`Component-CentralUI.md` (ClusterClient → gRPC command channel ×4, Akka address marked legacy),
|
||||
`Component-ClusterInfrastructure.md` (ClusterClient → gRPC), `Component-Commons.md`
|
||||
(`AlarmTriggerType.Expression`), `Component-TemplateEngine.md` (the HiLo partial-merge rule).
|
||||
|
||||
### Page batches
|
||||
|
||||
Seven batches on disjoint file sets, each briefed with its own `file:line` inventory and the
|
||||
standing instruction to *verify by reading before editing and refuse items the markup contradicts*.
|
||||
Behavior preserved byte-for-byte throughout: `@onclick`, `disabled`, bindings, `AuthorizeView`
|
||||
gates, `data-test`/`data-testid` hooks, and the arm→confirm two-step.
|
||||
|
||||
| Batch | Files | Highlights |
|
||||
|---|---|---|
|
||||
| 1 — Transport + Ops | SecuredWrites, TransportImport/Export, ConnectionCertificates, TestBindingsDialog | The two worst unbounded cells bounded; 5 prose deletions + the 2 relocated banners; the app's only inline arm→confirm restyled (two-step preserved) |
|
||||
| 2 — Monitoring | Health, EventLogs, ParkedMessages, AlarmSummary | Health per-site rework (see refusal below); identity slam split; EventLogs expander re-keyed off the loop index onto the event GUID; ParkedMessages drawer converted from row-object to row-id + re-resolve |
|
||||
| 3 — Admin + form shells | 6 Admin pages, both endpoint editors, 4 Design forms | SiteForm/DataConnectionForm/ApiMethodForm split into one card per group; the 7-group OpcUaEndpointEditor given `fieldset`/`legend`; Back-link idiom unified; Akka address relabelled legacy |
|
||||
| 4 — Shared + dialogs | DialogHost, MonacoEditor, SchemaBuilder + 14 dialogs | `DialogHost` footer grouped — one edit reaching **23 confirm call sites**; 4 Monaco toolbar links → a real button group across 6 files; 12 dialog feet grouped |
|
||||
| 5 — Reports/Audit/Notifications | SiteCalls, 6 Notifications pages, 2 Audit pages, AuditResultsGrid/FilterBar, Dashboard | Row-action groups (anchor + buttons, destructive red); recipient chip slam capped at 5 + "N more"; **three** object-holding detail surfaces converted to id + re-resolve |
|
||||
| 6 — Deployment + Design | Deployments, InstanceCreate, Topology, TemplateCreate, SchemaLibrary, SharedScriptForm, Templates, DataConnections, AuditLogPage | Deployment identity split; **three unguarded `[..8]` revision-hash slices fixed** (a real latent `ArgumentOutOfRangeException` on the deploy path) |
|
||||
| 7 — The two guard-pinned giants | InstanceConfigure, TemplateEdit | TemplateEdit's inherited-members card moved *below* the page title (ordering bug) and split into four carded groups; all **67** pinned substrings re-verified present, both negative assertions still absent |
|
||||
|
||||
### Refusals worth recording (the briefs were wrong, the agents were right)
|
||||
|
||||
The playbook's instruction to *verify by reading before editing and refuse what the markup contradicts*
|
||||
earned its place four times:
|
||||
|
||||
1. **Health per-site groups (batch 2) — brief premise false.** The brief said "five headings stack flush
|
||||
in one card body" with "metadata paragraphs between heading and table". Neither was true: the headings
|
||||
sit in four `col-md-6` grid columns, three already behind collapse toggles, and no such paragraph
|
||||
exists. Following it literally would have nested two cards inside each of two collapse regions with the
|
||||
toggles floating outside any boundary. The agent restructured to one card per grid column, promoting
|
||||
each existing collapse toggle into a real `card-header`.
|
||||
2. **`ApiMethodForm` "Real I/O" strings (batch 3) — brief would have broken the suite.** The brief said to
|
||||
preserve `"Real I/O"` there. `TestRunWarningTests` asserts the opposite: `ApiMethodForm` must
|
||||
**not** contain it (the Inbound API sandbox has no side-effect surface). Verified against the test.
|
||||
3. **`AuditResultsGrid` Target column (batch 5) — `.cell-clip` was the wrong tool.** `white-space: nowrap`
|
||||
+ `max-width` raises a cell's min-content width, which would stop a *resized* column shrinking below the
|
||||
text width and break drag-to-resize at exactly the narrow widths the feature exists for. Fixed with a
|
||||
character cap instead, mirroring the grid's own `TruncateError`.
|
||||
4. **`dc-kebab` restyle (batch 6) — contextual, not accidental.** The page `<style>` block drives a
|
||||
hover-reveal with `!important` padding and an explicit colour override that `btn-outline-secondary`
|
||||
would fight. It is also the only kebab of 12 living in a hover-reveal tree row.
|
||||
|
||||
Two smaller ones: `DataConnections`' toolbar has just one plain button once the dropdown is excluded, so a
|
||||
`btn-group` there would be a no-op wrapper (no change made); and `NodeBrowserDialog`'s search label was
|
||||
reverted by its batch on discovering two tests selected it by tag — completed afterwards at the
|
||||
orchestration level, where the tests could be updated in the same change.
|
||||
|
||||
**One brief error I introduced and all three affected agents caught independently:** I specified
|
||||
`class="mono"` for stacked machine ids. `.mono` *does* exist (theme.css:117, with `tabular-nums`) — the
|
||||
agents' stated reason was wrong — but the app uses Bootstrap's `font-monospace` everywhere and has zero
|
||||
`mono` usages, so their substitution is the correct outcome for consistency.
|
||||
|
||||
## 3. Verification
|
||||
|
||||
`dotnet build ZB.MOM.WW.ScadaBridge.slnx` → **0 warnings, 0 errors.**
|
||||
|
||||
`dotnet test ZB.MOM.WW.ScadaBridge.slnx` → **8,750 passing**, including
|
||||
**`CentralUI.Tests` 990/990** (the bUnit + source-scan guard suites that pin this UI).
|
||||
|
||||
Two non-issues, both confirmed rather than assumed:
|
||||
|
||||
- **`SiteRuntime.Tests` 1 failure / `Host.Tests` 1 failure** — different tests on different runs, and both
|
||||
projects pass in isolation on re-run (540/540, 473/473). The sweep touched zero files in either project.
|
||||
Flakes.
|
||||
- **`CentralUI.PlaywrightTests` 159 failures — environmental and pre-existing.** `docker/docker-compose.yml`
|
||||
sets `ScadaBridge__Security__Auth__DisableLogin: "true"` on both central nodes (the SEC-36 GLAuth
|
||||
rotation workaround, uncommitted and not part of this change). Every test expecting a login flow fails
|
||||
regardless of this sweep; the visible errors are login-throttle lockouts and a 429.
|
||||
|
||||
### Test selectors updated (markup changed underneath them)
|
||||
|
||||
Folding per-member `btn-sm` into the group class — which the playbook mandates — moves the size class off
|
||||
the button and invalidates locators pinning it. Also the two node-picker labels became buttons. Updated,
|
||||
each with a comment naming this sweep:
|
||||
|
||||
- `TransportExportPageTests.cs` ×2 — assertions on `data-testid`s carried by deleted prose banners. The
|
||||
neighbouring `Assert.Empty(...group-api-keys)` and `Assert.NotNull(...group-sites)` survive and carry the
|
||||
real intent.
|
||||
- `TemplateCrudTests.cs` ×4, `SiteCrudTests.cs` ×4, `LdapMappingCrudTests.cs` ×5, `M9SurfaceTests.cs` ×1 —
|
||||
`button.btn-success.btn-sm` / `btn-outline-danger.btn-sm` → size class dropped.
|
||||
- `NodeBrowserDialogSelectionTests.cs` ×2, `NodeBrowserDialogSearchTests.cs` ×1 — anchor → `button.btn-link`.
|
||||
|
||||
### Merged-tree verification greps
|
||||
|
||||
- `btn-group` count **6 → 43**; **0** groups left with a redundant member `btn-sm` (5 were normalized at merge).
|
||||
- **35 `.cell-clip`/`.cell-clamp-2` usages, 35 carrying a `title`** — the house rule held across all seven batches.
|
||||
- `javascript:void(0)` — **0 remaining** in live markup (2 mentions survive inside comments explaining the removal).
|
||||
- Every pinned guard-test substring present; both negative assertions still absent.
|
||||
|
||||
## 4. Live browser gate
|
||||
|
||||
Rig redeployed via `docker/deploy.sh` — **all 8 nodes, both central instances**, so Traefik round-robin
|
||||
cannot serve a stale one. Both `/health/ready` 200; node-a active.
|
||||
|
||||
| # | Check | Result |
|
||||
|---|---|---|
|
||||
| 1 | Computed-style probes | **PASS** — body 14.4px vs `.btn-sm` 12.48px (0.78rem override live); `.form-label-sm` 12.8px (was a silent no-op); `.cell-clip` block/352px/nowrap/hidden; `.detail-pre` pre-wrap + 300px; scoped bundle **and** site.css both in `document.styleSheets` |
|
||||
| 1a | `.cell-clamp-2` genuinely clamping | **PASS** — computed `display` serializes as `flow-root`, which looked like a silent failure; measured instead: 40px clamped vs 500px unclamped. Working |
|
||||
| 1b | Dark accent (the headline fix) | **PASS** — brand mark `rgb(47,95,208)` light → `rgb(77,127,232)` dark; `--accent-deep` now flips with it (`#1e3f99` → `#7aa0ef`) instead of desyncing. The inline `--accent` on the shell root is gone |
|
||||
| 2 | Every page leads with data | **PASS** — Dashboard leads with KPI tiles; Export Bundle leads with the step indicator + artifact tree; all 10 deleted prose blocks absent |
|
||||
| 3 | Seamed `btn-group`s, destructive red | **PASS** — measured seam gap −1px (overlapping borders); Site Calls row group renders `View audit history │ Retry │ Discard` as one control with Discard red |
|
||||
| 4 | Arm→confirm, then **Cancel** | **PASS** — trigger is now an outline-danger button (was a bare red text link); arming flips it solid and reveals the guarded warning + seamed `Yes, export without encryption │ Cancel`; Cancel disarms, stays on Encrypt, **no export performed** |
|
||||
| 4a | `DialogHost` grouped footer (23 call sites) | **PASS** — Discard on a parked notification opens `Cancel │ Delete` seamed, danger labelling intact, backdrop covering; Cancel closed cleanly with all 31 parked rows intact and **no destructive write** |
|
||||
| 5 | Scoped-bundle resurrections | **N/A** — the bundle was already correctly linked here (discovery 1a-2); nothing to resurrect |
|
||||
| 6 | Legends / hints / empty states still render | **PASS** — ParkedMessages empty state ("Nothing has failed enough to give up on at this site"), the Encrypt-step secret-fields banner, and SiteForm's legacy-address `form-text` all render |
|
||||
| 7 | **Density with realistic faulted data** | **PASS** — exercised against a genuinely unhealthy rig: 31 parked notifications with live error text, 47 stuck site calls, 1 parked call. No horizontal scroll on any page; zero elements overflowing the viewport |
|
||||
| 7a | Clip actually engages under pressure | **PASS** — injecting a 150-char OPC UA node id into a clipped cell truncates it (1084px content in a 352px box) while the table grows only 75px and the viewport never overflows. Uncontained, that value would have pushed the actions column off-screen |
|
||||
| 7b | Sections read as separate cards | **PASS** — Health's three KPI groups and its four per-site groups now have real card boundaries; SiteForm renders as `Edit Site` / `Node A` / `Node B` |
|
||||
| 7c | Identity stacking | **PASS** — Health shows `Test Plant A` with `site-a` in mono beneath (was fused into one `<strong>`) |
|
||||
|
||||
**Not exercised live:** the ParkedMessages drawer re-key — site-a has no parked store-and-forward rows
|
||||
(the 31 parked are central-side notifications). It is covered by the build and by CentralUI.Tests; the
|
||||
equivalent re-key on NotificationReport was exercised through the confirm path above.
|
||||
|
||||
**Rig note:** `docker/docker-compose.yml` carries an uncommitted `DisableLogin: "true"` from the earlier
|
||||
EWS live-gate session (SEC-36). It predates this sweep and was left untouched.
|
||||
@@ -24,7 +24,7 @@ Central cluster only. Sites have no user interface.
|
||||
|
||||
## Real-Time Updates
|
||||
|
||||
- **Debug view**: Real-time display of attribute values and alarm states via **gRPC streaming**. When the user opens a debug view, a `DebugStreamBridgeActor` on the central side opens a gRPC server-streaming subscription to the site's `SiteStreamGrpcServer` for the selected instance, then requests an initial `DebugViewSnapshot` via ClusterClient. Ongoing `AttributeValueChanged` and `AlarmStateChanged` events flow via the gRPC stream (not through ClusterClient) to the bridge actor, which delivers them to the Blazor component via callbacks that call `InvokeAsync(StateHasChanged)` to push UI updates through the built-in SignalR circuit.
|
||||
- **Debug view**: Real-time display of attribute values and alarm states via **gRPC streaming**. When the user opens a debug view, a `DebugStreamBridgeActor` on the central side opens a gRPC server-streaming subscription to the site's `SiteStreamGrpcServer` for the selected instance, then requests an initial `DebugViewSnapshot` over the central→site gRPC command channel (`SiteCommandService`, `QueryReply.DebugViewSnapshot`). Ongoing `AttributeValueChanged` and `AlarmStateChanged` events flow via the gRPC data stream to the bridge actor, which delivers them to the Blazor component via callbacks that call `InvokeAsync(StateHasChanged)` to push UI updates through the built-in SignalR circuit.
|
||||
- **Health dashboard**: Site status, connection health, error rates, and buffer depths update via a **10-second auto-refresh timer**. Since health reports arrive from sites every 30 seconds, a 10s poll interval catches updates within one reporting cycle without unnecessary overhead.
|
||||
- **Deployment status**: Pending/in-progress/success/failed transitions **push to the UI immediately** via SignalR (built into Blazor Server). No polling required for deployment tracking.
|
||||
|
||||
@@ -81,7 +81,7 @@ Central cluster only. Sites have no user interface.
|
||||
- Configure SMTP settings.
|
||||
|
||||
### Site & Data Connection Management (Admin Role)
|
||||
- Create, edit, and delete site definitions, including Akka node addresses (NodeA/NodeB) and gRPC node addresses (GrpcNodeA/GrpcNodeB).
|
||||
- Create, edit, and delete site definitions, including gRPC node addresses (GrpcNodeA/GrpcNodeB). The legacy Akka node addresses (NodeA/NodeB) are still stored and editable but have had **no runtime consumer** since the ClusterClient→gRPC migration's Phase 4 — every central→site dial resolves the gRPC pair. The form labels them as legacy so an operator does not mistake them for a live setting.
|
||||
- Define data connections and assign them to sites (name, protocol type, connection details).
|
||||
- **Data connection form**: "Primary Endpoint Configuration" (required JSON text area) and optional "Backup Endpoint Configuration" (collapsible section, hidden by default, revealed via "Add Backup Endpoint" button; "Remove Backup" button when editing an existing backup). "Failover Retry Count" numeric input (default 3, min 1, max 20) is visible only when a backup endpoint is configured.
|
||||
- **Verify endpoint** (OPC UA): the OPC UA endpoint editor (in the data connection form) carries a **"Verify endpoint"** button that asks the target site to probe the configured endpoint — a temporary, short-lived connect against the live (or edited-but-unsaved) config. The result reports success or a typed failure kind (e.g. unreachable, untrusted certificate, server error). When the failure is an **untrusted server certificate**, the probe captures the cert (Subject / Issuer / Thumbprint / validity / DER) and the editor shows a detail panel with a **"Trust certificate"** button. The probe itself **never trusts** the cert — trusting is an explicit, Admin-gated action (see Server certificate management). After a Trust, Verify re-runs automatically and should then succeed.
|
||||
@@ -150,8 +150,8 @@ Central cluster only. Sites have no user interface.
|
||||
### Debug View (Deployment Role)
|
||||
- Select a deployed instance and open a live debug view.
|
||||
- Real-time streaming of all attribute values (with quality and timestamp) and alarm states for that instance.
|
||||
- The `DebugStreamService` creates a `DebugStreamBridgeActor` on the central side. The bridge actor opens a **gRPC server-streaming subscription** to the site's `SiteStreamGrpcServer` for the selected instance, then requests an initial `DebugViewSnapshot` via ClusterClient.
|
||||
- Ongoing events (`AttributeValueChanged`, `AlarmStateChanged`) flow via the gRPC stream directly to the bridge actor — they do not pass through ClusterClient.
|
||||
- The `DebugStreamService` creates a `DebugStreamBridgeActor` on the central side. The bridge actor opens a **gRPC server-streaming subscription** to the site's `SiteStreamGrpcServer` for the selected instance, then requests an initial `DebugViewSnapshot` over the central→site gRPC command channel (`SiteCommandService`).
|
||||
- Ongoing events (`AttributeValueChanged`, `AlarmStateChanged`) flow via the gRPC data stream directly to the bridge actor — they do not travel on the command channel.
|
||||
- Events are delivered to the Blazor component via callbacks, which call `InvokeAsync(StateHasChanged)` to push UI updates through the built-in SignalR circuit.
|
||||
- A pulsing "Live" indicator replaces the static "Connected" badge when streaming is active.
|
||||
- Subscribe-on-demand — stream starts when opened, stops when closed.
|
||||
@@ -175,7 +175,7 @@ Displays all attribute values for the instance in the collapsible tree. Each lea
|
||||
|
||||
The Alarms tab is the **only** runtime surface for native OPC UA Alarms & Conditions and MxAccess Gateway alarms (no dedicated operator/alarm-summary page). **All configured alarms are shown with current status, even when quiet/Normal** — no alarm is hidden simply because it has not fired.
|
||||
|
||||
Both enriched `AlarmStateChanged` events (live, via the gRPC stream) and the initial `DebugViewSnapshot` (via ClusterClient) carry the unified alarm shape, so all alarms appear on the first paint and update in place. Native alarms are a **read-only mirror** — the source system owns the alarm lifecycle (ack / shelve / suppress); the Debug View never offers ack-back or any command action.
|
||||
Both enriched `AlarmStateChanged` events (live, via the gRPC stream) and the initial `DebugViewSnapshot` (via the gRPC command channel) carry the unified alarm shape, so all alarms appear on the first paint and update in place. Native alarms are a **read-only mirror** — the source system owns the alarm lifecycle (ack / shelve / suppress); the Debug View never offers ack-back or any command action.
|
||||
|
||||
**Native source binding nodes** — a configured native alarm source binding is itself a tree node, placed by its canonical name in the hierarchy. Its live mirrored conditions nest as child rows beneath it. A quiet binding (no currently active conditions) renders a "no active conditions" placeholder row — it is never hidden, so the operator can see every configured binding regardless of alarm state. This requires the backend to emit a placeholder `AlarmStateChanged` with `IsConfiguredPlaceholder = true` for each idle binding (see Component-SiteRuntime.md — Instance Actor Wiring). The `NativeSourceCanonicalName` field on `AlarmStateChanged` events identifies which binding node a live condition belongs to.
|
||||
|
||||
|
||||
@@ -177,7 +177,7 @@ After the Leave the node's `ActorSystem` terminates, the `WhenTerminated` watchd
|
||||
|
||||
#### Site-pair failover
|
||||
|
||||
The same control appears on each **site** card. Central and each site are separate Akka clusters, so central cannot act on a site's membership — it *asks*, over the existing ClusterClient command/control channel:
|
||||
The same control appears on each **site** card. Central and each site are separate Akka clusters, so central cannot act on a site's membership — it *asks*, over the central→site gRPC command channel (`SiteCommandService`):
|
||||
|
||||
1. `CommunicationService.TriggerSiteFailoverAsync` sends a `TriggerSiteFailover` inside a `SiteEnvelope`.
|
||||
2. The site's `SiteCommunicationActor` (registered per node, so contact rotation reaches whichever answers) resolves the target from cluster state and issues the graceful `Leave` locally.
|
||||
|
||||
@@ -33,7 +33,7 @@ Commons must define shared primitive and utility types used across multiple comp
|
||||
- **`DeploymentStatus` enum**: Pending, InProgress, Success, Failed.
|
||||
- **`AlarmState` enum**: Active, Normal.
|
||||
- **`AlarmLevel` enum**: None, Low, LowLow, High, HighHigh. Severity level for an active alarm; always `None` for binary trigger types, set by `HiLo` triggers.
|
||||
- **`AlarmTriggerType` enum**: ValueMatch, RangeViolation, RateOfChange, HiLo.
|
||||
- **`AlarmTriggerType` enum**: ValueMatch, RangeViolation, RateOfChange, HiLo, Expression. `Expression` is a read-only boolean C# expression re-evaluated on attribute updates; the trigger fires when it evaluates to `true`.
|
||||
- **`AlarmKind` enum**: Computed, NativeOpcUa, NativeMxAccess. Discriminates how an alarm's state is produced — evaluated at the site by an `AlarmActor` from attribute triggers (`Computed`) vs. mirrored read-only from a native source (OPC UA Alarms & Conditions / MxAccess Gateway).
|
||||
- **`AlarmShelveState` enum**: Unshelved, OneShotShelved, TimedShelved, PermanentShelved. OPC UA Part 9 shelving sub-state of an alarm condition; mirrored read-only from the source. Computed alarms are always `Unshelved`.
|
||||
- **`AlarmTransitionKind` enum**: Snapshot, SnapshotComplete, Raise, Acknowledge, Clear, Retrigger, StateChange. Classifies a `NativeAlarmTransition`; `Snapshot`/`SnapshotComplete` carry the initial active-condition replay produced on every (re)subscribe so consumers can re-seed state.
|
||||
|
||||
@@ -110,7 +110,7 @@ breadcrumb.
|
||||
Override and lock rules apply per entity type at the following granularity:
|
||||
|
||||
- **Attributes**: Value and Description are overridable. Data Type is fixed by the defining level. `DataSourceReference` on a template attribute defines the **default** physical address for that attribute. Instances may override per attribute via `InstanceConnectionBinding.DataSourceReferenceOverride`; the override replaces the template default at flattening time. When the override is null (the default), the template value is used. Lock applies to the entire attribute (when locked, no fields can be overridden).
|
||||
- **Alarms**: Priority Level and Trigger Definition (thresholds/ranges/rates) are instance-overridable. Description and On-Trigger Script reference are **not** instance-overridable — `InstanceAlarmOverride` carries only `TriggerConfigurationOverride` and `PriorityLevelOverride`, so an instance cannot re-point an alarm's on-trigger script or reword its description (those are template-level authoring decisions). Name and Trigger Type (Value Match vs. Range vs. Rate of Change) are fixed. Lock applies to the entire alarm. (Recorded decision, arch-review 05: this narrower granularity is the implemented behavior; adding Description/OnTriggerScript override columns is future feature work, not spec debt.)
|
||||
- **Alarms**: Priority Level and Trigger Definition (thresholds/ranges/rates) are instance-overridable. Description and On-Trigger Script reference are **not** instance-overridable — `InstanceAlarmOverride` carries only `TriggerConfigurationOverride` and `PriorityLevelOverride`, so an instance cannot re-point an alarm's on-trigger script or reword its description (those are template-level authoring decisions). Name and Trigger Type (Value Match vs. Range vs. Rate of Change) are fixed. Lock applies to the entire alarm. `TriggerConfigurationOverride` is a **partial merge for `HiLo`** triggers — setpoints left unset in the override keep their inherited values — and a **whole-config replacement for every other trigger type**. (Recorded decision, arch-review 05: this narrower granularity is the implemented behavior; adding Description/OnTriggerScript override columns is future feature work, not spec debt.)
|
||||
- **Native alarm sources**: An instance overrides a non-locked source via `InstanceNativeAlarmSourceOverride`, keyed by `SourceCanonicalName`. `ConnectionNameOverride`, `SourceReferenceOverride`, and `ConditionFilterOverride` are individually overridable — each is applied only when non-null; a null field **keeps the inherited value**. Name is fixed. Lock applies to the entire source.
|
||||
- **Scripts**: C# source code, Trigger configuration, minimum time between runs, and parameter/return definitions are overridable. Name is fixed. Lock applies to the entire script.
|
||||
- **Composed module members**: A composing template or child template can override non-locked members inside a composed module using the canonical path-qualified name.
|
||||
|
||||
@@ -17,7 +17,8 @@ As of M8 (T18), Transport is no longer limited to central-only configuration: it
|
||||
## Responsibilities
|
||||
|
||||
- Define and own the `.scadabundle` file format (ZIP container, `manifest.json`, `content.json` / `content.enc`).
|
||||
- Resolve artifact dependencies at export time: base templates, shared scripts, external systems, template folders, notification lists, SMTP configs, API keys, API methods.
|
||||
- Resolve artifact dependencies at export time: base templates, shared scripts, external systems, template folders, notification lists, SMTP configs, SMS configs, API methods.
|
||||
- **Inbound API keys are deliberately NOT transportable.** They live in each environment's own secret store (per-env pepper, secret shown once) and cannot be exported — see the comment on `ExportSelection`. Only API *methods* travel. After importing into a destination environment, an operator must re-create the keys there and re-grant their method scopes via the admin UI or CLI.
|
||||
- Move **site-scoped configuration** (T18): `Site` definitions, site-scoped `DataConnection`s (protocol connections — distinct from External-System `DatabaseConnection`s), and `Instance`s along with their `InstanceAttributeOverride` / `InstanceAlarmOverride` / `InstanceNativeAlarmSourceOverride` / `InstanceConnectionBinding` children and `Area` membership (carried by name).
|
||||
- Reconcile cross-environment site identifiers and connection names through the **name-mapping subsystem** (`BundleNameMap`): auto-match by identifier/name, operator override via the import-wizard Map step or CLI flags, and per-conflict create-or-bind resolution (see "Name Mapping").
|
||||
- Compute a **per-line (Myers) diff** for code fields on Modified artifacts (T20) via the pure `LineDiffer`, embedding a size-capped structured line diff in each `ArtifactDiff`. Two independent caps guard the diff: a 400-line **output** cap on the emitted hunk list, and — as of arch-review 05 (Task 22) — a `MaxInputLines` **input** cap (4000 combined lines) that short-circuits the O((N+M)²) Myers trace to a summary-only result (`Truncated = true`, add/remove totals only) so a bloated or crafted bundle cannot OOM the active central node from the import preview.
|
||||
@@ -72,7 +73,7 @@ zip-bomb guard, and the exporter never emits per-script files.
|
||||
"summary": {
|
||||
"templates": 12, "templateFolders": 3, "sharedScripts": 4,
|
||||
"externalSystems": 2, "dbConnections": 1,
|
||||
"notificationLists": 1, "smtpConfigs": 0, "apiKeys": 2, "apiMethods": 5,
|
||||
"notificationLists": 1, "smtpConfigs": 0, "smsConfigs": 0, "apiMethods": 5,
|
||||
"sites": 2, "dataConnections": 3, "instances": 8
|
||||
},
|
||||
"contents": [
|
||||
@@ -141,7 +142,7 @@ The component is central-hosted. It is registered in `ZB.MOM.WW.ScadaBridge.Host
|
||||
|
||||
### UI — 4-Step Wizard (Design nav group)
|
||||
|
||||
**Step 1 — Select artifacts.** Templates are rendered as a tree matching the existing Templates page (the `TemplateFolderTree.razor` shared component, used in its new checkbox-selection mode). Tri-state checkboxes on folders (`☑` all, `☐` none, `▣` partial). Search filters the tree in place. Other artifact groups (shared scripts, external systems, notification lists, SMTP configs, API keys, API methods) are flat checkbox lists.
|
||||
**Step 1 — Select artifacts.** Templates are rendered as a tree matching the existing Templates page (the `TemplateFolderTree.razor` shared component, used in its new checkbox-selection mode). Tri-state checkboxes on folders (`☑` all, `☐` none, `▣` partial). Search filters the tree in place. Other artifact groups (shared scripts, external systems, notification lists, SMTP configs, SMS configs, API methods) are flat checkbox lists. API keys are not offered — they are not transportable (see Scope).
|
||||
|
||||
A **Sites & Instances** section (T18) adds a flat list of sites, each row expandable to its instances; selecting a site or individual instances pulls them (and their site-scoped `DataConnection`s) into the bundle. The wizard distinguishes operator-seeded selections from artifacts auto-included by dependency resolution (e.g., an instance's site and bound connections).
|
||||
|
||||
@@ -151,7 +152,7 @@ A **Sites & Instances** section (T18) adds a flat list of sites, each row expand
|
||||
- `Template` references `SharedScript` (by name) → include the script.
|
||||
- `Template` references `ExternalSystem` → include the definition and its methods.
|
||||
- `ApiMethod` references `SharedScript` → include the script.
|
||||
- `NotificationList` references `SmtpConfiguration` → include the SMTP config.
|
||||
- `NotificationList` does **not** pull in an `SmtpConfiguration`. SMTP (and SMS) configurations are environment-specific and are only ever included when the operator selects them explicitly — the resolver walks `ExportSelection.SmtpConfigurationIds` alone.
|
||||
- Any folder containing a selected template is included so the structure is reproducible on import.
|
||||
|
||||
The user can toggle "include all dependencies" off (with a warning that the bundle may produce an invalid import).
|
||||
@@ -213,7 +214,9 @@ Authorization: `RequireDesign` on both the Razor page and `IBundleExporter.Expor
|
||||
|
||||
## Import Flow
|
||||
|
||||
### UI — 6-Step Wizard (Admin nav group)
|
||||
### UI — 5-Step Wizard (Admin nav group)
|
||||
|
||||
Upload → Passphrase → Diff → Confirm → Result. The **Map** step is not a sixth pill: name mapping is a section rendered inside the Diff step.
|
||||
|
||||
**Step 1 — Upload.** Drag-and-drop or browse. On selection, the manifest is parsed and displayed (source env, exporter, timestamp, content count, SHA-256, encrypted yes/no). The manifest hash is validated against the `content` blob.
|
||||
|
||||
@@ -371,7 +374,7 @@ Three commands surface the same Transport operations as the Central UI wizards,
|
||||
```bash
|
||||
scadabridge bundle export --output FILE --passphrase X [--all | --templates A,B ...] \
|
||||
[--shared-scripts ...] [--external-systems ...] [--db-connections ...] \
|
||||
[--notification-lists ...] [--smtp-configs ...] [--api-keys ...] \
|
||||
[--notification-lists ...] [--smtp-configs ...] [--sms-configs ...] \
|
||||
[--api-methods ...] [--sites A,B ...] [--instances X,Y ...] \
|
||||
[--include-dependencies] [--source-environment NAME]
|
||||
|
||||
@@ -446,7 +449,7 @@ The `manifest.json` file is always present in the ZIP root and is never encrypte
|
||||
"dbConnections": 1,
|
||||
"notificationLists": 1,
|
||||
"smtpConfigs": 0,
|
||||
"apiKeys": 2,
|
||||
"smsConfigs": 0,
|
||||
"apiMethods": 5,
|
||||
"sites": 2,
|
||||
"dataConnections": 3,
|
||||
|
||||
@@ -162,11 +162,13 @@
|
||||
</div>
|
||||
|
||||
<div class="col-auto ms-auto">
|
||||
<button class="btn btn-outline-secondary btn-sm me-1"
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button class="btn btn-outline-secondary"
|
||||
@onclick="ClearFilters" data-test="filter-clear">Clear</button>
|
||||
<button class="btn btn-primary btn-sm"
|
||||
<button class="btn btn-primary"
|
||||
@onclick="Apply" data-test="filter-apply">Apply</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
<span data-test="status-badge-@row.EventId" class="badge @StatusBadgeClass(row.Status)">@row.Status</span>
|
||||
break;
|
||||
case "Target":
|
||||
<span class="small">@(row.Target ?? "—")</span>
|
||||
<span class="small" title="@row.Target">@TruncateTarget(row.Target)</span>
|
||||
break;
|
||||
case "Actor":
|
||||
<span class="small">@(row.Actor ?? "—")</span>
|
||||
|
||||
@@ -488,6 +488,34 @@ public partial class AuditResultsGrid : IAsyncDisposable
|
||||
_ => "badge bg-secondary",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Caps the rendered Target text, with the full value kept on the cell's
|
||||
/// <c>title</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Target is free text this app does not control (external-system URLs, SQL
|
||||
/// statements, method names) and its column is UNBOUNDED until the operator
|
||||
/// resizes it: the scoped stylesheet clips <c>.audit-grid-td</c> only when the
|
||||
/// inline <c>--audit-col-width</c> custom property is present, and
|
||||
/// <see cref="ColumnWidthStyle"/> emits nothing for a column with no persisted
|
||||
/// width — so on a fresh session one long target sets the table's width.
|
||||
/// The cap is applied to the STRING rather than by pinning a CSS width, because
|
||||
/// a <c>max-width</c>/<c>nowrap</c> clip would raise the cell's min-content
|
||||
/// width and stop a resized column from shrinking — i.e. it would break
|
||||
/// drag-to-resize at exactly the widths it is used at. Truncating characters
|
||||
/// leaves the cell's min-content word-based, so resize keeps working unchanged.
|
||||
/// Mirrors <see cref="TruncateError"/>, this grid's existing idiom.
|
||||
/// </remarks>
|
||||
private static string TruncateTarget(string? target)
|
||||
{
|
||||
if (string.IsNullOrEmpty(target))
|
||||
{
|
||||
return "—";
|
||||
}
|
||||
const int max = 60;
|
||||
return target.Length <= max ? target : string.Concat(target.AsSpan(0, max), "…");
|
||||
}
|
||||
|
||||
private static string TruncateError(string? message)
|
||||
{
|
||||
if (string.IsNullOrEmpty(message))
|
||||
|
||||
@@ -47,17 +47,27 @@
|
||||
@foreach (var match in _searchResults)
|
||||
{
|
||||
var node = match.Node;
|
||||
@* Name (+ type badge) on the first line, the full
|
||||
address-space path clipped beneath it — an OPC UA
|
||||
path is long enough to push the badge off-screen
|
||||
when everything sits inline. *@
|
||||
<li data-test="node-search-result" class="py-1">
|
||||
<a href="javascript:void(0)"
|
||||
class="@(node.NodeId == _selectedNodeId ? "fw-bold text-primary" : "")"
|
||||
<div>
|
||||
@* A button, not an href="javascript:void(0)" anchor:
|
||||
this selects a node rather than navigating, and the
|
||||
pseudo-href was a CSP/a11y smell. Matches the tree
|
||||
label in TreeRow.razor. *@
|
||||
<button type="button"
|
||||
class="btn btn-link p-0 text-start align-baseline @(node.NodeId == _selectedNodeId ? "fw-bold text-primary" : "")"
|
||||
@onclick="() => SelectBrowseNode(node)">
|
||||
@node.DisplayName
|
||||
</a>
|
||||
<small class="text-muted ms-1">@match.Path</small>
|
||||
</button>
|
||||
@if (!string.IsNullOrEmpty(node.DataType))
|
||||
{
|
||||
<span class="badge bg-body-secondary text-body-secondary border ms-1">@node.DataType</span>
|
||||
}
|
||||
</div>
|
||||
<small class="text-muted cell-clip cell-clip-lg" title="@match.Path">@match.Path</small>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@@ -104,12 +114,14 @@
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<span class="me-auto text-muted">Selected: <code>@(_selectedNodeId ?? "(none)")</code></span>
|
||||
<button class="btn btn-secondary" @onclick="Cancel">Cancel</button>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button class="btn btn-outline-secondary" @onclick="Cancel">Cancel</button>
|
||||
<button class="btn btn-primary" @onclick="Confirm" disabled="@string.IsNullOrWhiteSpace(_selectedNodeId)">Select</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
|
||||
@@ -59,7 +59,14 @@
|
||||
<tr>
|
||||
<td class="small">@row.AttributeName</td>
|
||||
<td class="small text-muted">@row.ConnectionName</td>
|
||||
<td class="small font-monospace text-break">@row.EffectiveTagPath</td>
|
||||
@* Tag path, live tag value and adapter error are all
|
||||
unbounded (OPC UA node ids, string-typed PLC tags,
|
||||
remote exception text). Clip/clamp them so one wide
|
||||
row can't stretch the dialog's table; the title
|
||||
attribute keeps the full text reachable. *@
|
||||
<td class="small font-monospace">
|
||||
<span class="cell-clip" title="@row.EffectiveTagPath">@row.EffectiveTagPath</span>
|
||||
</td>
|
||||
<td class="small font-monospace">
|
||||
@if (_loading)
|
||||
{
|
||||
@@ -71,12 +78,13 @@
|
||||
}
|
||||
else if (outcome.Success)
|
||||
{
|
||||
@FormatValue(outcome.Value)
|
||||
var formatted = FormatValue(outcome.Value);
|
||||
<span class="cell-clip" title="@formatted">@formatted</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">—</span>
|
||||
<div class="text-danger small">error: @outcome.ErrorMessage</div>
|
||||
<div class="text-danger small cell-clamp-2" title="@outcome.ErrorMessage">error: @outcome.ErrorMessage</div>
|
||||
}
|
||||
</td>
|
||||
<td class="small">
|
||||
|
||||
@@ -13,16 +13,26 @@
|
||||
|
||||
@if (Node.NodeClass == BrowseNodeClass.Variable)
|
||||
{
|
||||
<a href="javascript:void(0)"
|
||||
class="@(Node.NodeId == SelectedNodeId ? "fw-bold text-primary" : "")"
|
||||
@* Display name (+ type badge) on the first line, the node id clipped
|
||||
beneath it: an OPC UA node id runs 60–120 characters and inline it
|
||||
pushed the badge out of view on every deep node. The picker label
|
||||
stays visually a link — it is a selection affordance, not an action —
|
||||
but is a real <button> so there is no javascript: URL to reason about. *@
|
||||
<span class="d-inline-block align-top">
|
||||
<span class="d-block">
|
||||
<button type="button"
|
||||
class="btn btn-link p-0 text-start @(Node.NodeId == SelectedNodeId ? "fw-bold text-primary" : "")"
|
||||
@onclick="() => OnSelect.InvokeAsync(Node)"
|
||||
@ondblclick="() => OnSelect.InvokeAsync(Node)">
|
||||
@Node.DisplayName <small class="text-muted">(@Node.NodeId)</small>
|
||||
</a>
|
||||
@Node.DisplayName
|
||||
</button>
|
||||
@if (!string.IsNullOrEmpty(Node.DataType))
|
||||
{
|
||||
<span class="badge bg-body-secondary text-body-secondary border ms-1" data-test="node-type">@Node.DataType</span>
|
||||
}
|
||||
</span>
|
||||
<span class="font-monospace small text-muted cell-clip cell-clip-lg" title="@Node.NodeId">@Node.NodeId</span>
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -44,7 +54,7 @@
|
||||
@if (!string.IsNullOrEmpty(Node.ContinuationToken))
|
||||
{
|
||||
<li>
|
||||
<button class="btn btn-sm btn-link p-0" data-test="node-load-more"
|
||||
<button class="btn btn-sm btn-outline-secondary" data-test="node-load-more"
|
||||
@onclick="() => OnLoadMore.InvokeAsync(Node)" disabled="@Node.Loading">
|
||||
@(Node.Loading ? "Loading…" : "Load more")
|
||||
</button>
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
<div class="mxgateway-endpoint-editor">
|
||||
<h6 class="text-muted border-bottom pb-1">@Title</h6>
|
||||
|
||||
@* Groups get a real boundary (fieldset + legend) rather than an unstyled muted
|
||||
caption. Fieldsets — not nested cards — because this editor is embedded inside
|
||||
DataConnectionForm's card; same idiom as TransportExport. *@
|
||||
<fieldset class="mb-3">
|
||||
<legend class="h6">Connection</legend>
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-md-7">
|
||||
<label class="form-label small">Gateway endpoint</label>
|
||||
@@ -41,8 +46,10 @@
|
||||
@RenderFieldError("ReadTimeoutMs")
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="text-muted small mt-2 mb-1">Transport security</div>
|
||||
<fieldset class="mb-0">
|
||||
<legend class="h6">Transport security</legend>
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-md-2 d-flex align-items-end">
|
||||
<div class="form-check">
|
||||
@@ -69,6 +76,7 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
@@ -82,8 +90,10 @@
|
||||
var match = Errors?.Errors.FirstOrDefault(e =>
|
||||
e.EntityName != null
|
||||
&& (e.EntityName == field || e.EntityName.EndsWith("." + field)));
|
||||
// Validator text can carry raw, unbounded detail — clamp it and keep the full
|
||||
// message reachable via the title so it cannot reflow the surrounding row.
|
||||
return match is null
|
||||
? null
|
||||
: @<div class="text-danger small">@match.Message</div>;
|
||||
: @<div class="text-danger small cell-clamp-2" title="@match.Message">@match.Message</div>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,11 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Each settings group gets a real boundary (fieldset + legend) rather than an
|
||||
unstyled muted caption. Fieldsets — not nested cards — because this editor is
|
||||
embedded inside DataConnectionForm's card; same idiom as TransportExport. *@
|
||||
<fieldset class="mb-3">
|
||||
<legend class="h6">Connection</legend>
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-md-7">
|
||||
<label class="form-label small">Endpoint URL</label>
|
||||
@@ -71,9 +76,15 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-danger small ms-2" data-test="verify-failure">
|
||||
@result.FailureKind: @result.Error
|
||||
</span>
|
||||
@* The failure text is a raw driver exception message. Give it its own
|
||||
block below the button (rather than inline beside it) and clamp it,
|
||||
so a long message cannot reflow the whole button row. Full text
|
||||
stays reachable via the title. *@
|
||||
<div class="mt-1">
|
||||
<span class="text-danger small cell-clamp-2"
|
||||
data-test="verify-failure"
|
||||
title="@result.FailureKind: @result.Error">@result.FailureKind: @result.Error</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (result.FailureKind == VerifyFailureKind.UntrustedCertificate
|
||||
@@ -81,13 +92,15 @@
|
||||
{
|
||||
<div class="border rounded bg-body-secondary p-2 mt-2 small" data-test="verify-cert-panel">
|
||||
<div class="text-muted mb-1">Untrusted server certificate</div>
|
||||
@* Server-supplied X.509 DNs / thumbprints: clip so a long DN cannot
|
||||
stretch the panel; the full value stays in the title. *@
|
||||
<dl class="row mb-1 small">
|
||||
<dt class="col-sm-3">Subject</dt>
|
||||
<dd class="col-sm-9"><code>@cert.Subject</code></dd>
|
||||
<dd class="col-sm-9"><code class="cell-clip" title="@cert.Subject">@cert.Subject</code></dd>
|
||||
<dt class="col-sm-3">Issuer</dt>
|
||||
<dd class="col-sm-9"><code>@cert.Issuer</code></dd>
|
||||
<dd class="col-sm-9"><code class="cell-clip" title="@cert.Issuer">@cert.Issuer</code></dd>
|
||||
<dt class="col-sm-3">Thumbprint</dt>
|
||||
<dd class="col-sm-9"><code>@cert.Thumbprint</code></dd>
|
||||
<dd class="col-sm-9"><code class="cell-clip" title="@cert.Thumbprint">@cert.Thumbprint</code></dd>
|
||||
<dt class="col-sm-3">Not before</dt>
|
||||
<dd class="col-sm-9">@cert.NotBeforeUtc.ToString("u")</dd>
|
||||
<dt class="col-sm-3">Not after</dt>
|
||||
@@ -123,7 +136,10 @@
|
||||
|
||||
@if (_trustError is { } trustError)
|
||||
{
|
||||
<div class="text-danger small mt-1" data-test="trust-cert-error">@trustError</div>
|
||||
@* Raw exception text from the trust call — clamped, full text in title. *@
|
||||
<div class="text-danger small mt-1 cell-clamp-2"
|
||||
data-test="trust-cert-error"
|
||||
title="@trustError">@trustError</div>
|
||||
}
|
||||
@if (_trustSucceeded)
|
||||
{
|
||||
@@ -135,8 +151,10 @@
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="text-muted small mt-2 mb-1">Authentication</div>
|
||||
<fieldset class="mb-3">
|
||||
<legend class="h6">Authentication</legend>
|
||||
@if (Config.UserIdentity is null)
|
||||
{
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm mb-2"
|
||||
@@ -190,8 +208,10 @@
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</fieldset>
|
||||
|
||||
<div class="text-muted small mt-2 mb-1">Timing</div>
|
||||
<fieldset class="mb-3">
|
||||
<legend class="h6">Timing</legend>
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Session timeout (ms)</label>
|
||||
@@ -206,8 +226,10 @@
|
||||
@RenderFieldError("OperationTimeoutMs")
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="text-muted small mt-2 mb-1">Subscription</div>
|
||||
<fieldset class="mb-3">
|
||||
<legend class="h6">Subscription</legend>
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Publishing interval (ms)</label>
|
||||
@@ -246,8 +268,10 @@
|
||||
@RenderFieldError("MaxNotificationsPerPublish")
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="text-muted small mt-2 mb-1">Advanced subscription</div>
|
||||
<fieldset class="mb-3">
|
||||
<legend class="h6">Advanced subscription</legend>
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Subscription display name</label>
|
||||
@@ -278,8 +302,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="text-muted small mt-2 mb-1">Deadband filter</div>
|
||||
<fieldset class="mb-3">
|
||||
<legend class="h6">Deadband filter</legend>
|
||||
@if (Config.Deadband is null)
|
||||
{
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm mb-2"
|
||||
@@ -309,8 +335,10 @@
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</fieldset>
|
||||
|
||||
<div class="text-muted small mt-2 mb-1">Heartbeat</div>
|
||||
<fieldset class="mb-0">
|
||||
<legend class="h6">Heartbeat</legend>
|
||||
@if (Config.Heartbeat is null)
|
||||
{
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm mb-2"
|
||||
@@ -340,6 +368,7 @@
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
|
||||
@@ -2,8 +2,13 @@
|
||||
|
||||
@* The side-rail chassis (brand bar + responsive hamburger) is the shared
|
||||
ZB.MOM.WW.Theme ThemeShell. NavMenu fills the rail's <Nav> slot with the
|
||||
policy-gated nav groups; the session/sign-out block fills <RailFooter>. *@
|
||||
<ThemeShell Product="ScadaBridge" Accent="#2f5fd0">
|
||||
policy-gated nav groups; the session/sign-out block fills <RailFooter>.
|
||||
|
||||
No Accent="…" here on purpose: the kit emits that parameter as an inline
|
||||
style="--accent: …" on the shell root, which outranks the dark-mode token
|
||||
override on <html> and silently killed the dark accent app-wide. The accent
|
||||
is declared as a :root token in site.css instead. *@
|
||||
<ThemeShell Product="ScadaBridge">
|
||||
<Nav>
|
||||
<NavMenu />
|
||||
</Nav>
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
<div class="small text-muted mt-1">Key ID: <code>@_newlyCreatedKeyId</code></div>
|
||||
<div class="d-flex align-items-center mt-2">
|
||||
<code class="me-2" data-test="created-token">@_newlyCreatedToken</code>
|
||||
<button class="btn btn-outline-secondary btn-sm py-0 px-1" @onclick="CopyKeyToClipboard">Copy</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="CopyKeyToClipboard">Copy</button>
|
||||
</div>
|
||||
<small class="text-muted d-block mt-1">Save this token now — it will not be shown again.</small>
|
||||
</div>
|
||||
@@ -105,8 +105,10 @@
|
||||
<div class="text-danger small mt-2">@_formError</div>
|
||||
}
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-success btn-sm me-1" @onclick="SaveKey">Save</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">Cancel</button>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button class="btn btn-success" @onclick="SaveKey">Save</button>
|
||||
<button class="btn btn-outline-secondary" @onclick="GoBack">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -55,19 +55,24 @@
|
||||
<tr @key="key.KeyId">
|
||||
<td><code>@TruncateKeyId(key.KeyId)</code></td>
|
||||
<td>
|
||||
@key.Name
|
||||
@* Operator-supplied name: clip it so a long value cannot push the
|
||||
actions column off-screen. .cell-clip is display:block, hence the
|
||||
inner span; the badge stays outside it so status is never clipped. *@
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="cell-clip" title="@key.Name">@key.Name</span>
|
||||
@if (!key.Enabled)
|
||||
{
|
||||
<span class="badge bg-secondary ms-1">Disabled</span>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
<td>@key.Methods.Count</td>
|
||||
<td>
|
||||
<div class="d-flex gap-1">
|
||||
<button class="btn btn-outline-primary btn-sm py-0 px-2"
|
||||
<button class="btn btn-outline-primary btn-sm"
|
||||
@onclick='() => NavigationManager.NavigateTo($"/admin/api-keys/{key.KeyId}/edit")'>Edit</button>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-outline-secondary btn-sm py-0 px-2"
|
||||
<button class="btn btn-outline-secondary btn-sm"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-label="@($"More actions for {key.Name}")">⋮</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
|
||||
@@ -44,8 +44,10 @@
|
||||
<div class="text-danger small mt-2">@_formError</div>
|
||||
}
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-success btn-sm me-1" @onclick="SaveMapping">Save</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">Cancel</button>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button class="btn btn-success" @onclick="SaveMapping">Save</button>
|
||||
<button class="btn btn-outline-secondary" @onclick="GoBack">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -55,7 +55,9 @@
|
||||
var ruleCount = rules?.Count ?? 0;
|
||||
<tr @key="mapping.Id">
|
||||
<td>@mapping.Id</td>
|
||||
<td>@mapping.LdapGroupName</td>
|
||||
@* Externally sourced DN (CN=…,OU=…,DC=…): clip so it cannot set the
|
||||
table's width. .cell-clip is display:block, hence the span. *@
|
||||
<td><span class="cell-clip" title="@mapping.LdapGroupName">@mapping.LdapGroupName</span></td>
|
||||
<td><span class="badge bg-secondary">@mapping.Role</span></td>
|
||||
<td>
|
||||
@if (ruleCount > 0)
|
||||
@@ -69,10 +71,10 @@
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex gap-1">
|
||||
<button class="btn btn-outline-primary btn-sm py-0 px-2"
|
||||
<button class="btn btn-outline-primary btn-sm"
|
||||
@onclick='() => NavigationManager.NavigateTo($"/admin/ldap-mappings/{mapping.Id}/edit")'>Edit</button>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-outline-secondary btn-sm py-0 px-2"
|
||||
<button class="btn btn-outline-secondary btn-sm"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-label="@($"More actions for {mapping.LdapGroupName}")">⋮</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
|
||||
@@ -19,9 +19,8 @@
|
||||
<ToastNotification @ref="_toast" />
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<h6 class="card-title">@(IsEditMode ? "Edit Site" : "Add Site")</h6>
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span class="fw-semibold">@(IsEditMode ? "Edit Site" : "Add Site")</span>
|
||||
@* Drill-in: deep-link into the central Audit
|
||||
Log pre-filtered to this site's events. AuditEvent.SourceSiteId
|
||||
stores the SiteIdentifier (string), so we pass that through. *@
|
||||
@@ -34,6 +33,7 @@
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Identifier</label>
|
||||
<input type="text" class="form-control form-control-sm" @bind="_formIdentifier"
|
||||
@@ -43,43 +43,65 @@
|
||||
<label class="form-label small">Name</label>
|
||||
<input type="text" class="form-control form-control-sm" @bind="_formName" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="mb-0">
|
||||
<label class="form-label small">Description</label>
|
||||
<input type="text" class="form-control form-control-sm" @bind="_formDescription" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h6 class="text-muted border-bottom pb-1">Node A</h6>
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<span class="fw-semibold">Node A</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Akka Address</label>
|
||||
<label class="form-label small">Akka Address (legacy — unused)</label>
|
||||
<input type="text" class="form-control form-control-sm" @bind="_formNodeAAddress"
|
||||
placeholder="akka.tcp://scadabridge@host:port/user/site-communication" />
|
||||
<div class="form-text">
|
||||
Retained for historical configuration only — nothing reads it at runtime.
|
||||
Central dials the gRPC address below.
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
</div>
|
||||
<div class="mb-0">
|
||||
<label class="form-label small">gRPC Address</label>
|
||||
<input type="text" class="form-control form-control-sm" @bind="_formGrpcNodeAAddress"
|
||||
placeholder="http://host:8083" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h6 class="text-muted border-bottom pb-1">Node B</h6>
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<span class="fw-semibold">Node B</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Akka Address</label>
|
||||
<label class="form-label small">Akka Address (legacy — unused)</label>
|
||||
<input type="text" class="form-control form-control-sm" @bind="_formNodeBAddress"
|
||||
placeholder="akka.tcp://scadabridge@host:port/user/site-communication" />
|
||||
<div class="form-text">
|
||||
Retained for historical configuration only — nothing reads it at runtime.
|
||||
Central dials the gRPC address below.
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
</div>
|
||||
<div class="mb-0">
|
||||
<label class="form-label small">gRPC Address</label>
|
||||
<input type="text" class="form-control form-control-sm" @bind="_formGrpcNodeBAddress"
|
||||
placeholder="http://host:8083" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_formError != null)
|
||||
{
|
||||
<div class="text-danger small mt-2">@_formError</div>
|
||||
}
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-success btn-sm me-1" @onclick="SaveSite">Save</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">Cancel</button>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button class="btn btn-success" @onclick="SaveSite">Save</button>
|
||||
<button class="btn btn-outline-secondary" @onclick="GoBack">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -146,8 +146,10 @@
|
||||
Cluster nodes (Akka, gRPC)
|
||||
</button>
|
||||
<div class="collapse mt-2" id="@collapseId">
|
||||
@ClusterRow("Node A", site.NodeAAddress)
|
||||
@ClusterRow("Node B", site.NodeBAddress)
|
||||
@* Akka node addresses are legacy: no runtime consumer since the
|
||||
ClusterClient→gRPC migration. Central dials the gRPC pair below. *@
|
||||
@ClusterRow("Node A (legacy)", site.NodeAAddress)
|
||||
@ClusterRow("Node B (legacy)", site.NodeBAddress)
|
||||
@ClusterRow("gRPC A", site.GrpcNodeAAddress)
|
||||
@ClusterRow("gRPC B", site.GrpcNodeBAddress)
|
||||
</div>
|
||||
|
||||
+48
-13
@@ -63,16 +63,18 @@
|
||||
To="_filterTo" ToChanged="(v => _filterTo = v)"
|
||||
IdPrefix="audit-filter" />
|
||||
</div>
|
||||
<div class="col-md-2 d-flex gap-1">
|
||||
<button class="btn btn-primary btn-sm" @onclick="Search" disabled="@_searching">
|
||||
<div class="col-md-2">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button class="btn btn-primary" @onclick="Search" disabled="@_searching">
|
||||
@if (_searching) { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
|
||||
Search
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="ClearFilters" disabled="@_searching">
|
||||
<button class="btn btn-outline-secondary" @onclick="ClearFilters" disabled="@_searching">
|
||||
Clear filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_errorMessage != null)
|
||||
{
|
||||
@@ -107,7 +109,7 @@
|
||||
var isLarge = hasState && entry.AfterStateJson!.Length > 1024;
|
||||
<tr>
|
||||
<td class="small"><TimestampDisplay Value="@entry.Timestamp" /></td>
|
||||
<td class="small">@entry.User</td>
|
||||
<td class="small"><span class="cell-clip cell-clip-sm" title="@entry.User">@entry.User</span></td>
|
||||
<td><span class="badge @GetActionBadge(entry.Action)">@entry.Action</span></td>
|
||||
<td class="small">@entry.EntityType</td>
|
||||
<td class="small">
|
||||
@@ -124,13 +126,24 @@
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="small">@entry.EntityName</td>
|
||||
<td class="small">
|
||||
@* Entity names are operator/bundle supplied — guarded and
|
||||
bounded exactly like the Entity ID neighbour above. *@
|
||||
@if (!string.IsNullOrEmpty(entry.EntityName))
|
||||
{
|
||||
<span class="cell-clip" title="@entry.EntityName">@entry.EntityName</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (hasState)
|
||||
{
|
||||
if (isLarge)
|
||||
{
|
||||
<button class="btn btn-outline-info btn-sm py-0 px-1"
|
||||
<button class="btn btn-outline-info btn-sm"
|
||||
@onclick="() => ShowStateModal(entry)"
|
||||
aria-label="Open state details in modal for audit entry @entry.Id">
|
||||
View in modal
|
||||
@@ -138,7 +151,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<button class="btn btn-outline-info btn-sm py-0 px-1"
|
||||
<button class="btn btn-outline-info btn-sm"
|
||||
@onclick="() => ToggleStateView(entry.Id)"
|
||||
aria-label="Toggle state details for audit entry @entry.Id">
|
||||
@(_expandedEntryId == entry.Id ? "Hide" : "View")
|
||||
@@ -166,7 +179,12 @@
|
||||
<OffsetPager Page="_page" PageChanged="OnPageChanged" HasNextPage="HasMore" TotalCount="_totalCount" PageSize="_pageSize" />
|
||||
}
|
||||
|
||||
@if (_modalEntry != null)
|
||||
@* The state modal holds only the entry's id and re-resolves the row from the
|
||||
page currently on screen on every render (ModalEntry()). A refetch that
|
||||
replaces the entry list therefore never leaves this surface rendering a
|
||||
stale record, and an entry that has left the page closes the modal instead
|
||||
of stranding it. *@
|
||||
@if (ModalEntry() is { } modalEntry)
|
||||
{
|
||||
<div class="modal-backdrop fade show"></div>
|
||||
<div class="modal fade show d-block" tabindex="-1" role="dialog">
|
||||
@@ -174,12 +192,12 @@
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
Audit entry @_modalEntry.Id — @_modalEntry.EntityType state
|
||||
Audit entry @modalEntry.Id — @modalEntry.EntityType state
|
||||
</h5>
|
||||
<button type="button" class="btn-close" @onclick="CloseStateModal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<pre class="bg-body-secondary p-2 rounded small mb-0">@FormatJson(_modalEntry.AfterStateJson!)</pre>
|
||||
<pre class="bg-body-secondary p-2 rounded small mb-0">@FormatJson(modalEntry.AfterStateJson!)</pre>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="CloseStateModal">Close</button>
|
||||
@@ -217,12 +235,29 @@
|
||||
private bool _searching;
|
||||
private string? _errorMessage;
|
||||
private int? _expandedEntryId;
|
||||
private AuditLogEntry? _modalEntry;
|
||||
|
||||
// State modal. Only the entry's IDENTIFIER is held — never the AuditLogEntry
|
||||
// record itself — so the modal cannot outlive the row it was opened for.
|
||||
// ModalEntry() re-resolves it from the page currently on screen on every
|
||||
// render; an entry that has left the page resolves to null and the modal
|
||||
// renders nothing (it self-closes) rather than showing a stale snapshot.
|
||||
private int? _modalEntryId;
|
||||
|
||||
private ToastNotification _toast = default!;
|
||||
|
||||
private bool HasMore => _page * _pageSize < _totalCount;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the entry the state modal is open for from the page currently on
|
||||
/// screen. Returns null when no entry is selected, when the list has not
|
||||
/// loaded, or when the selected entry is no longer in the page — the modal's
|
||||
/// visibility gate.
|
||||
/// </summary>
|
||||
private AuditLogEntry? ModalEntry() =>
|
||||
_modalEntryId is { } id
|
||||
? _entries?.FirstOrDefault(e => e.Id == id)
|
||||
: null;
|
||||
|
||||
// Tracks the BundleImportId we last fetched against so a re-render with the
|
||||
// same query param doesn't re-run the query on every parameter set.
|
||||
private Guid? _lastFetchedBundleImportId;
|
||||
@@ -327,12 +362,12 @@
|
||||
|
||||
private void ShowStateModal(AuditLogEntry entry)
|
||||
{
|
||||
_modalEntry = entry;
|
||||
_modalEntryId = entry.Id;
|
||||
}
|
||||
|
||||
private void CloseStateModal()
|
||||
{
|
||||
_modalEntry = null;
|
||||
_modalEntryId = null;
|
||||
}
|
||||
|
||||
private async Task CopyAsync(string text)
|
||||
|
||||
@@ -18,13 +18,7 @@
|
||||
just the Audit Log + Configuration Audit Log pages). *@
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<h1 class="h4 mb-1">Execution Chain</h1>
|
||||
<p class="text-muted small mb-3">
|
||||
The full chain of script / inbound-request executions linked by
|
||||
<span class="font-monospace">ParentExecutionId</span>, rooted at the
|
||||
topmost ancestor. Select an execution to open the Audit Log filtered to
|
||||
its rows.
|
||||
</p>
|
||||
<h1 class="h4 mb-3">Execution Chain</h1>
|
||||
|
||||
@if (_executionId is null)
|
||||
{
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
</div>
|
||||
<p class="text-muted">Central management console for the ScadaBridge SCADA system.</p>
|
||||
|
||||
@* KPI row *@
|
||||
<div class="row g-3 mb-4">
|
||||
|
||||
+4
-2
@@ -42,8 +42,10 @@
|
||||
@if (!string.IsNullOrEmpty(ErrorMessage)) { <div class="text-danger small mt-1">@ErrorMessage</div> }
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="Close">Cancel</button>
|
||||
<button class="btn btn-primary btn-sm" @onclick="Submit">Create</button>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button class="btn btn-outline-secondary" @onclick="Close">Cancel</button>
|
||||
<button class="btn btn-primary" @onclick="Submit">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,12 +14,12 @@
|
||||
<div class="container-fluid mt-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">Deployment Status</h4>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="ToggleAutoRefresh"
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="Deployment list actions">
|
||||
<button class="btn btn-outline-secondary" @onclick="ToggleAutoRefresh"
|
||||
aria-label="@(_autoRefresh ? "Pause auto-refresh" : "Resume auto-refresh")">
|
||||
@(_autoRefresh ? "⏸ Pause updates" : "▶ Resume updates")
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="LoadDataAsync" aria-label="Refresh deployments">Refresh</button>
|
||||
<button class="btn btn-outline-secondary" @onclick="LoadDataAsync" aria-label="Refresh deployments">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -99,7 +99,14 @@
|
||||
var revShort = record.RevisionHash?[..Math.Min(8, record.RevisionHash?.Length ?? 0)];
|
||||
<tr id="@rowId" class="@GetRowClass(record.Status)">
|
||||
<td>
|
||||
<code class="small">@idShort@(string.IsNullOrEmpty(revShort) ? "" : $"@{revShort}")</code>
|
||||
@* Deployment id and revision hash are two distinct
|
||||
identifiers — kept on separate lines so neither
|
||||
reads as part of the other. *@
|
||||
<code class="small" title="@record.DeploymentId">@idShort</code>
|
||||
@if (!string.IsNullOrEmpty(revShort))
|
||||
{
|
||||
<div class="font-monospace small text-muted" title="@record.RevisionHash">@revShort</div>
|
||||
}
|
||||
</td>
|
||||
<td>@GetInstanceName(record.InstanceId)</td>
|
||||
<td>
|
||||
@@ -134,7 +141,7 @@
|
||||
<td class="small text-end">
|
||||
@if (isFailed && !string.IsNullOrEmpty(record.ErrorMessage))
|
||||
{
|
||||
<button class="btn btn-link btn-sm p-0" type="button"
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button"
|
||||
@onclick="() => ToggleErrorExpansion(record.DeploymentId)"
|
||||
aria-expanded="@(IsErrorExpanded(record.DeploymentId) ? "true" : "false")"
|
||||
aria-controls="@errorCollapseId">
|
||||
@@ -149,7 +156,7 @@
|
||||
<td colspan="7">
|
||||
<div class="small">
|
||||
<strong>Error:</strong>
|
||||
<pre class="mb-0 mt-1 small" style="white-space: pre-wrap; word-break: break-word;">@record.ErrorMessage</pre>
|
||||
<pre class="detail-pre mb-0 mt-1 small">@record.ErrorMessage</pre>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
+52
-25
@@ -123,7 +123,12 @@
|
||||
var isBrowsable = IsBrowsable(connId);
|
||||
<tr>
|
||||
<td class="small">@attr.Name</td>
|
||||
<td class="small text-muted font-monospace">@attr.DataSourceReference</td>
|
||||
@* Tag paths are OPC UA node ids (nsu=…;s=…) — unbounded
|
||||
text that would otherwise set the table's width.
|
||||
.cell-clip is display:block, hence the span. *@
|
||||
<td class="small text-muted font-monospace">
|
||||
<span class="cell-clip" title="@attr.DataSourceReference">@attr.DataSourceReference</span>
|
||||
</td>
|
||||
<td>
|
||||
<select class="form-select form-select-sm"
|
||||
value="@connId"
|
||||
@@ -156,20 +161,22 @@
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="p-2 d-flex gap-2">
|
||||
<button class="btn btn-success btn-sm" @onclick="SaveBindings" disabled="@_saving">Save Bindings</button>
|
||||
<div class="p-2">
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="Binding actions">
|
||||
<button class="btn btn-success" @onclick="SaveBindings" disabled="@_saving">Save Bindings</button>
|
||||
@* Test Bindings: one-shot live read of every bound attribute
|
||||
whose row has a connection picked AND an effective tag
|
||||
path. Disabled when no testable rows. Protocol-agnostic —
|
||||
any connection whose adapter implements ReadBatchAsync
|
||||
(OPC UA and MxGateway today) round-trips through
|
||||
ReadTagValuesCommand. *@
|
||||
<button class="btn btn-outline-primary btn-sm"
|
||||
<button class="btn btn-outline-primary"
|
||||
@onclick="OpenTestBindings"
|
||||
disabled="@(!HasTestableBindings())">
|
||||
Test Bindings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -252,7 +259,11 @@
|
||||
</span>
|
||||
}
|
||||
</td>
|
||||
<td class="small text-muted">@(attr.Value ?? "—")</td>
|
||||
@* Template values are unbounded — canonical JSON for a
|
||||
List attribute. Clip, full value on the title. *@
|
||||
<td class="small text-muted">
|
||||
<span class="cell-clip" title="@attr.Value">@(attr.Value ?? "—")</span>
|
||||
</td>
|
||||
<td>
|
||||
@if (attr.DataType == DataType.List)
|
||||
{
|
||||
@@ -265,7 +276,10 @@
|
||||
ShowElementType="false" />
|
||||
@if (_overrideErrors.TryGetValue(attr.Name, out var listErr))
|
||||
{
|
||||
<div class="alert alert-danger small mt-2 mb-0"
|
||||
@* Bounded: this alert lives inside a table cell,
|
||||
so an unbounded message would widen the column. *@
|
||||
<div class="alert alert-danger small mt-2 mb-0 text-break"
|
||||
style="max-width: 20rem;"
|
||||
data-test="override-list-error">@listErr</div>
|
||||
}
|
||||
@if (HasOverrideRow(attr.Name))
|
||||
@@ -298,10 +312,6 @@
|
||||
<div class="card mb-3">
|
||||
<div class="card-header py-2">
|
||||
<strong>Alarm Overrides</strong>
|
||||
<small class="text-muted ms-2">
|
||||
Click <em>Edit</em> to override an alarm's trigger configuration or priority.
|
||||
HiLo overrides merge into the inherited setpoints; other trigger types replace the whole config.
|
||||
</small>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
@if (_overridableAlarms.Count == 0)
|
||||
@@ -323,6 +333,9 @@
|
||||
<tbody>
|
||||
@foreach (var alarm in _overridableAlarms)
|
||||
{
|
||||
// Comma-joined list of overridden JSON keys — unbounded key
|
||||
// count, so it is clipped with the full text on the title.
|
||||
var overrideSummary = HasOverride(alarm.Name) ? OverrideSummary(alarm.Name) : null;
|
||||
<tr data-test="alarm-override-row-@alarm.Name">
|
||||
<td class="small">@alarm.Name</td>
|
||||
<td>
|
||||
@@ -335,8 +348,12 @@
|
||||
<td class="small">
|
||||
@if (HasOverride(alarm.Name))
|
||||
{
|
||||
@* .cell-clip is display:block, so the badge and the
|
||||
clipped summary share a flex line. *@
|
||||
<span class="d-flex align-items-center">
|
||||
<span class="badge bg-warning text-dark me-1" data-test="alarm-override-badge" title="Override is set">●</span>
|
||||
<span class="text-muted">@OverrideSummary(alarm.Name)</span>
|
||||
<span class="text-muted cell-clip cell-clip-sm" title="@overrideSummary">@overrideSummary</span>
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -344,17 +361,19 @@
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-outline-primary btn-sm me-1"
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="@($"Override actions for {alarm.Name}")">
|
||||
<button class="btn btn-outline-primary"
|
||||
data-test="alarm-edit-btn"
|
||||
@onclick="() => BeginEditOverride(alarm)"
|
||||
disabled="@_saving">Edit</button>
|
||||
@if (HasOverride(alarm.Name))
|
||||
{
|
||||
<button class="btn btn-outline-danger btn-sm"
|
||||
<button class="btn btn-outline-danger"
|
||||
data-test="alarm-clear-btn"
|
||||
@onclick="() => ClearAlarmOverride(alarm.Name)"
|
||||
disabled="@_saving">Clear</button>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@@ -371,10 +390,12 @@
|
||||
<div class="modal-dialog modal-dialog-scrollable modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h6 class="modal-title">
|
||||
Edit override: @_editingAlarm.Name
|
||||
<span class="badge bg-secondary-subtle text-secondary-emphasis border ms-1">@_editingAlarm.TriggerType</span>
|
||||
</h6>
|
||||
@* Name and trigger-type badge separated so the heading is a
|
||||
single identifier rather than an identifier run. *@
|
||||
<div>
|
||||
<h6 class="modal-title mb-1">Edit override: @_editingAlarm.Name</h6>
|
||||
<span class="badge bg-secondary-subtle text-secondary-emphasis border">@_editingAlarm.TriggerType</span>
|
||||
</div>
|
||||
<button type="button" class="btn-close" aria-label="Close" @onclick="CancelEditOverride"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@@ -419,9 +440,9 @@
|
||||
disabled="@_saving">Clear Override</button>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-outline-secondary btn-sm" data-test="alarm-cancel-override" @onclick="CancelEditOverride">Cancel</button>
|
||||
<button class="btn btn-success btn-sm" data-test="alarm-save-override" @onclick="SaveOverrideFromModal" disabled="@_saving">Save Override</button>
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="Override edit actions">
|
||||
<button class="btn btn-outline-secondary" data-test="alarm-cancel-override" @onclick="CancelEditOverride">Cancel</button>
|
||||
<button class="btn btn-success" data-test="alarm-save-override" @onclick="SaveOverrideFromModal" disabled="@_saving">Save Override</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -434,8 +455,9 @@
|
||||
<div class="card-header py-2 d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<strong>Native Alarm Source Overrides</strong>
|
||||
@* Kept: the blank-means-inherited rule is the only in-context
|
||||
decoder for the three override inputs below. *@
|
||||
<small class="text-muted ms-2">
|
||||
Retarget an inherited native alarm source binding for this instance.
|
||||
Leave a field blank to keep the inherited value.
|
||||
</small>
|
||||
</div>
|
||||
@@ -512,9 +534,12 @@
|
||||
<span class="badge bg-warning text-dark ms-1" title="Override is set">●</span>
|
||||
}
|
||||
</td>
|
||||
<td class="small text-muted font-monospace text-truncate" style="max-width: 200px;"
|
||||
@* Two identifiers — connection then source reference —
|
||||
stacked rather than slammed together with a slash. *@
|
||||
<td class="small" style="max-width: 200px;"
|
||||
title="@($"{src.ConnectionName} / {src.SourceReference}")">
|
||||
@src.ConnectionName / @src.SourceReference
|
||||
<div class="text-truncate">@src.ConnectionName</div>
|
||||
<div class="text-muted font-monospace text-truncate">@src.SourceReference</div>
|
||||
</td>
|
||||
<td>
|
||||
<select class="form-select form-select-sm"
|
||||
@@ -540,13 +565,15 @@
|
||||
@onchange="e => _nasFilterEdit[src.Name] = string.IsNullOrWhiteSpace((string?)e.Value) ? null : ((string?)e.Value)!.Trim()" />
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-success btn-sm me-1"
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="@($"Override actions for {src.Name}")">
|
||||
<button class="btn btn-success"
|
||||
@onclick="() => SaveNativeOverride(src.Name)" disabled="@_saving">Save</button>
|
||||
@if (HasNativeOverride(src.Name))
|
||||
{
|
||||
<button class="btn btn-outline-danger btn-sm"
|
||||
<button class="btn btn-outline-danger"
|
||||
@onclick="() => ClearNativeOverride(src.Name)" disabled="@_saving">Clear</button>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
+3
-3
@@ -65,9 +65,9 @@
|
||||
{
|
||||
<div class="text-danger small mt-2">@_formError</div>
|
||||
}
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-success btn-sm me-1" @onclick="CreateInstance">Create</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">Cancel</button>
|
||||
<div class="btn-group btn-group-sm mt-3" role="group" aria-label="Create instance actions">
|
||||
<button class="btn btn-success" @onclick="CreateInstance">Create</button>
|
||||
<button class="btn btn-outline-secondary" @onclick="GoBack">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+4
-2
@@ -17,8 +17,10 @@
|
||||
@if (!string.IsNullOrEmpty(ErrorMessage)) { <div class="text-danger small mt-1">@ErrorMessage</div> }
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="Close">Cancel</button>
|
||||
<button class="btn btn-primary btn-sm" @onclick="Submit">Move</button>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button class="btn btn-outline-secondary" @onclick="Close">Cancel</button>
|
||||
<button class="btn btn-primary" @onclick="Submit">Move</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+4
-2
@@ -17,8 +17,10 @@
|
||||
@if (!string.IsNullOrEmpty(ErrorMessage)) { <div class="text-danger small mt-1">@ErrorMessage</div> }
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="Close">Cancel</button>
|
||||
<button class="btn btn-primary btn-sm" @onclick="Submit">Move</button>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button class="btn btn-outline-secondary" @onclick="Close">Cancel</button>
|
||||
<button class="btn btn-primary" @onclick="Submit">Move</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
<input type="text" class="form-control form-control-sm" style="max-width: 320px;"
|
||||
placeholder="Search sites, areas, instances..."
|
||||
@bind="_searchText" @bind:event="oninput" @bind:after="OnSearchChanged" />
|
||||
<div class="btn-group btn-group-sm">
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="Topology actions">
|
||||
<button class="btn btn-outline-secondary" @onclick="OpenCreateAreaDialogRoot">+ Area</button>
|
||||
<button class="btn btn-outline-secondary"
|
||||
@onclick='() => NavigationManager.NavigateTo("/deployment/instances/create")'>+ Instance</button>
|
||||
@@ -902,7 +902,12 @@
|
||||
var result = await DeploymentService.DeployInstanceAsync(inst.Id, user);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
_toast.ShowSuccess($"Instance '{inst.UniqueName}' deployed (revision {result.Value.RevisionHash?[..8]}).");
|
||||
// Revision hashes are normally 64 hex chars, but the slice must
|
||||
// not assume it — a shorter hash would throw out of the toast
|
||||
// path and abort the post-deploy refresh.
|
||||
var deployedRev = result.Value.RevisionHash;
|
||||
var deployedRevShort = deployedRev is { Length: > 8 } ? deployedRev[..8] : deployedRev;
|
||||
_toast.ShowSuccess($"Instance '{inst.UniqueName}' deployed (revision {deployedRevShort}).");
|
||||
await LoadDataAsync();
|
||||
}
|
||||
else
|
||||
@@ -959,9 +964,13 @@
|
||||
builder.CloseElement();
|
||||
builder.OpenElement(5, "span");
|
||||
builder.AddAttribute(6, "class", "text-muted small ms-2");
|
||||
// Guarded slices: a hash shorter than 8 chars would throw
|
||||
// ArgumentOutOfRangeException out of the diff render.
|
||||
var deployedHash = diffResult.DeployedRevisionHash;
|
||||
var currentHash = diffResult.CurrentRevisionHash;
|
||||
builder.AddContent(7,
|
||||
$"Deployed: {diffResult.DeployedRevisionHash[..8]} | " +
|
||||
$"Current: {diffResult.CurrentRevisionHash[..8]} | " +
|
||||
$"Deployed: {(deployedHash.Length > 8 ? deployedHash[..8] : deployedHash)} | " +
|
||||
$"Current: {(currentHash.Length > 8 ? currentHash[..8] : currentHash)} | " +
|
||||
$"Deployed at: {diffResult.DeployedAt.LocalDateTime:yyyy-MM-dd HH:mm}");
|
||||
builder.CloseElement();
|
||||
builder.CloseElement();
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<button class="btn btn-link text-decoration-none ps-0 mb-2" @onclick="GoBack">← Back</button>
|
||||
<button class="btn btn-outline-secondary btn-sm mb-2" @onclick="GoBack">← Back</button>
|
||||
|
||||
<h4 class="mb-3">@(Id.HasValue ? "Edit API Method" : "Add API Method")</h4>
|
||||
|
||||
@@ -25,16 +25,24 @@
|
||||
{
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
@* The body used to stack six unrelated groups with nothing but a label
|
||||
between them. Fieldset + legend gives each stack a real boundary —
|
||||
same idiom as TransportExport. *@
|
||||
<fieldset class="mb-4">
|
||||
<legend class="h6">Method</legend>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Name</label>
|
||||
<input type="text" class="form-control" @bind="_name" disabled="@Id.HasValue" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="mb-0">
|
||||
<label class="form-label">Timeout (seconds)</label>
|
||||
<input type="number" class="form-control" @bind="_timeoutSeconds" min="1" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Approved API Keys</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="mb-4">
|
||||
<legend class="h6">Approved API Keys</legend>
|
||||
<div class="mb-0">
|
||||
@if (_allKeys.Count == 0)
|
||||
{
|
||||
<div class="form-text">
|
||||
@@ -67,20 +75,27 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="mb-4">
|
||||
<legend class="h6">Contract</legend>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Parameters</label>
|
||||
<SchemaBuilder Mode="object"
|
||||
Value="@_params"
|
||||
ValueChanged="@(v => _params = v)" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="mb-0">
|
||||
<label class="form-label">Return value</label>
|
||||
<SchemaBuilder Mode="value"
|
||||
Value="@_returns"
|
||||
ValueChanged="@(v => _returns = v)" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Script</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="mb-4">
|
||||
<legend class="h6">Script</legend>
|
||||
<div class="mb-0">
|
||||
<MonacoEditor @ref="_editor" Value="@_script" ValueChanged="@(v => _script = v)"
|
||||
Language="csharp" Height="320px"
|
||||
ScriptKind="ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis.ScriptKind.InboundApi"
|
||||
@@ -89,13 +104,14 @@
|
||||
MarkersChanged="@(m => { _markers = m; StateHasChanged(); })" />
|
||||
<ProblemsPanel Markers="@_markers" OnNavigate="@(m => _editor?.RevealLineAsync(m.StartLineNumber, m.StartColumn) ?? Task.CompletedTask)" />
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@if (_formError != null)
|
||||
{
|
||||
<div class="text-danger small mb-2">@_formError</div>
|
||||
}
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<div class="btn-group" role="group">
|
||||
<button class="btn btn-success" @onclick="Save">Save</button>
|
||||
<button class="btn btn-outline-primary" @onclick="ToggleTestRunPanel">
|
||||
@(_showTestRun ? "Hide Test Run" : "Test Run")
|
||||
@@ -111,13 +127,15 @@
|
||||
<div class="card-header py-2">
|
||||
<span class="fw-semibold">Test Run</span>
|
||||
</div>
|
||||
<div class="alert alert-warning py-1 mb-0 small rounded-0 border-0 border-bottom">
|
||||
<div class="card-body">
|
||||
@* Lives inside the body, not between header and body, where it rendered
|
||||
as a third band. Wording unchanged — pinned by TestRunWarningTests. *@
|
||||
<div class="alert alert-warning py-1 mb-3 small">
|
||||
<strong>Heads up:</strong>
|
||||
runs the script as typed (unsaved edits included) against the supplied
|
||||
<code>Parameters</code>. <code>Route</code> calls throw — cross-site
|
||||
routing needs a deployed site reachable over the cluster transport.
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">Parameter values</label>
|
||||
<ParameterValueForm ParameterDefinitions="@_params"
|
||||
|
||||
+6
-2
@@ -60,8 +60,12 @@
|
||||
@foreach (var cert in _certs)
|
||||
{
|
||||
<tr data-test="cert-row">
|
||||
<td class="small"><code>@cert.Subject</code></td>
|
||||
<td class="small"><code>@cert.Issuer</code></td>
|
||||
@* Subject/Issuer are X.509 DNs read off a remote OPC UA server —
|
||||
routinely 100+ chars, and side by side they used to push Remove
|
||||
off-screen. Clip both (title carries the full DN). The thumbprint
|
||||
is fixed-length SHA-1, so it needs no containment. *@
|
||||
<td class="small"><code class="cell-clip" title="@cert.Subject">@cert.Subject</code></td>
|
||||
<td class="small"><code class="cell-clip" title="@cert.Issuer">@cert.Issuer</code></td>
|
||||
<td class="small"><code>@cert.Thumbprint</code></td>
|
||||
<td class="small">@cert.NotBeforeUtc.ToString("u")</td>
|
||||
<td class="small">@cert.NotAfterUtc.ToString("u")</td>
|
||||
|
||||
+27
-10
@@ -27,6 +27,9 @@
|
||||
else
|
||||
{
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<span class="fw-semibold">Connection</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Site</label>
|
||||
@@ -86,8 +89,16 @@
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h6 class="text-muted mt-3">Primary endpoint</h6>
|
||||
@* Each endpoint editor is a heavyweight sub-form, so it gets its own card
|
||||
rather than sharing one body separated only by a muted <h6>. *@
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<span class="fw-semibold">Primary endpoint</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (_protocol == "MxGateway")
|
||||
{
|
||||
<MxGatewayEndpointEditor Title="Primary Endpoint"
|
||||
@@ -106,17 +117,21 @@
|
||||
Protocol="@_protocol"
|
||||
Errors="_primaryErrors" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h6 class="text-muted mt-3">
|
||||
Backup endpoint
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex align-items-center">
|
||||
<span class="fw-semibold">Backup endpoint</span>
|
||||
@if (!_showBackup)
|
||||
{
|
||||
<span class="badge bg-body-secondary text-body-secondary border ms-2">Optional</span>
|
||||
}
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (!_showBackup)
|
||||
{
|
||||
<div class="mb-3">
|
||||
<div class="mb-0">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm"
|
||||
@onclick="EnableBackup">Add Backup Endpoint</button>
|
||||
</div>
|
||||
@@ -147,20 +162,22 @@
|
||||
min="1" max="20" @bind="_formFailoverRetryCount" />
|
||||
<div class="form-text">Retries before failing over to backup endpoint.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="mb-0">
|
||||
<button type="button" class="btn btn-outline-danger btn-sm"
|
||||
@onclick="RemoveBackup">Remove Backup</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_formError != null)
|
||||
{
|
||||
<div class="text-danger small mt-2">@_formError</div>
|
||||
}
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-success btn-sm me-1" @onclick="SaveConnection">Save</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">Cancel</button>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button class="btn btn-success" @onclick="SaveConnection">Save</button>
|
||||
<button class="btn btn-outline-secondary" @onclick="GoBack">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<button class="btn btn-link text-decoration-none ps-0 mb-2" @onclick="GoBack">← Back</button>
|
||||
<button class="btn btn-outline-secondary btn-sm mb-2" @onclick="GoBack">← Back</button>
|
||||
|
||||
<h4 class="mb-3">@(Id.HasValue ? "Edit Database Connection" : "Add Database Connection")</h4>
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
<div class="text-danger small mb-2">@_formError</div>
|
||||
}
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<div class="btn-group" role="group">
|
||||
<button class="btn btn-success" @onclick="Save">Save</button>
|
||||
<button class="btn btn-outline-secondary" @onclick="GoBack">Cancel</button>
|
||||
</div>
|
||||
|
||||
+2
-2
@@ -8,7 +8,7 @@
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
<button class="btn btn-link text-decoration-none ps-0 mb-2" @onclick="GoBack">← Back</button>
|
||||
<button class="btn btn-outline-secondary btn-sm mb-2" @onclick="GoBack">← Back</button>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0">@(Id.HasValue ? "Edit External System" : "Add External System")</h4>
|
||||
@@ -73,7 +73,7 @@
|
||||
<div class="text-danger small mb-2">@_formError</div>
|
||||
}
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<div class="btn-group" role="group">
|
||||
<button class="btn btn-success" @onclick="Save">Save</button>
|
||||
<button class="btn btn-outline-secondary" @onclick="GoBack">Cancel</button>
|
||||
</div>
|
||||
|
||||
@@ -52,9 +52,9 @@
|
||||
<div class="text-danger small mt-2">@_formError</div>
|
||||
}
|
||||
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-success btn-sm me-1" @onclick="SaveSchema" disabled="@_busy">Save</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="CancelEdit" disabled="@_busy">Cancel</button>
|
||||
<div class="btn-group btn-group-sm mt-3" role="group" aria-label="Schema editor actions">
|
||||
<button class="btn btn-success" @onclick="SaveSchema" disabled="@_busy">Save</button>
|
||||
<button class="btn btn-outline-secondary" @onclick="CancelEdit" disabled="@_busy">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -90,16 +90,23 @@
|
||||
{
|
||||
<tr @key="s.Id">
|
||||
<td class="fw-semibold">@s.Name</td>
|
||||
<td>@(string.IsNullOrWhiteSpace(s.Scope) ? "—" : s.Scope)</td>
|
||||
@* Scope is operator free-text — clip it so a long value
|
||||
can't push the Reference/Actions columns off-screen.
|
||||
The title keeps the full value reachable. *@
|
||||
<td>
|
||||
<span class="cell-clip cell-clip-sm" title="@s.Scope">@(string.IsNullOrWhiteSpace(s.Scope) ? "—" : s.Scope)</span>
|
||||
</td>
|
||||
<td><code>lib:@s.Name</code></td>
|
||||
<td class="text-end">
|
||||
@* Row actions are disabled while the editor is open so the
|
||||
row under edit (and its siblings) can't be deleted out from
|
||||
under the form, and while a delete is in flight (_busy). *@
|
||||
<button class="btn btn-outline-primary btn-sm me-1"
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="@($"Actions for {s.Name}")">
|
||||
<button class="btn btn-outline-primary"
|
||||
@onclick="() => BeginEdit(s)" disabled="@(_editing || _busy)">Edit</button>
|
||||
<button class="btn btn-outline-danger btn-sm"
|
||||
<button class="btn btn-outline-danger"
|
||||
@onclick="() => DeleteSchema(s)" disabled="@(_editing || _busy)">Delete</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
@@ -87,13 +87,13 @@
|
||||
{
|
||||
<div class="@(_syntaxCheckPassed ? "text-success" : "text-danger") small mt-1">@_syntaxCheckResult</div>
|
||||
}
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-success btn-sm me-1" @onclick="SaveScript">Save</button>
|
||||
<button class="btn btn-outline-info btn-sm me-1" @onclick="CheckCompilation">Check Syntax</button>
|
||||
<button class="btn btn-outline-primary btn-sm me-1" @onclick="ToggleTestRunPanel">
|
||||
<div class="btn-group btn-group-sm mt-3" role="group" aria-label="Shared script actions">
|
||||
<button class="btn btn-success" @onclick="SaveScript">Save</button>
|
||||
<button class="btn btn-outline-info" @onclick="CheckCompilation">Check Syntax</button>
|
||||
<button class="btn btn-outline-primary" @onclick="ToggleTestRunPanel">
|
||||
@(_showTestRun ? "Hide Test Run" : "Test Run")
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="GoBack">Cancel</button>
|
||||
<button class="btn btn-outline-secondary" @onclick="GoBack">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -104,13 +104,15 @@
|
||||
<div class="card-header py-2 d-flex justify-content-between align-items-center">
|
||||
<span class="fw-semibold">Test Run <span class="badge bg-warning text-dark ms-1">Real I/O</span></span>
|
||||
</div>
|
||||
<div class="alert alert-warning py-1 mb-0 small rounded-0 border-0 border-bottom">
|
||||
<div class="card-body">
|
||||
@* Lives inside the body rather than between header and body,
|
||||
where it rendered as a third card band. *@
|
||||
<div class="alert alert-warning py-1 mb-3 small">
|
||||
<strong>Heads up:</strong>
|
||||
<code>External</code>, <code>Database</code>, and <code>Notify</code> calls fire for real against central's configured systems — real HTTP, real SQL, real emails. Side effects are permanent.
|
||||
<code>CallShared</code> executes the named shared script (saved version) in the same sandbox.
|
||||
<code>Attributes</code> and <code>CallScript</code> still throw.
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">Parameter values</label>
|
||||
<ParameterValueForm ParameterDefinitions="@_formParameters"
|
||||
|
||||
+4
-2
@@ -48,8 +48,10 @@
|
||||
</div>
|
||||
|
||||
<div class="modal-footer px-0 pb-0 mt-3">
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="() => Context.Cancel()" disabled="@_busy">Cancel</button>
|
||||
<button class="btn btn-success btn-sm" @onclick="Submit" disabled="@_busy">@(Editing ? "Save" : "Add")</button>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button class="btn btn-outline-secondary" @onclick="() => Context.Cancel()" disabled="@_busy">Cancel</button>
|
||||
<button class="btn btn-success" @onclick="Submit" disabled="@_busy">@(Editing ? "Save" : "Add")</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
|
||||
+4
-2
@@ -66,8 +66,10 @@
|
||||
</div>
|
||||
|
||||
<div class="modal-footer px-0 pb-0 mt-3">
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="() => Context.Cancel()" disabled="@_busy">Cancel</button>
|
||||
<button class="btn btn-success btn-sm" @onclick="Submit" disabled="@_busy">@(Editing ? "Save" : "Add")</button>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button class="btn btn-outline-secondary" @onclick="() => Context.Cancel()" disabled="@_busy">Cancel</button>
|
||||
<button class="btn btn-success" @onclick="Submit" disabled="@_busy">@(Editing ? "Save" : "Add")</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
|
||||
@@ -48,8 +48,8 @@
|
||||
{
|
||||
<div class="text-danger small mt-2">@_formError</div>
|
||||
}
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-success me-1" @onclick="CreateTemplate">Create</button>
|
||||
<div class="btn-group mt-3" role="group" aria-label="Create template actions">
|
||||
<button class="btn btn-success" @onclick="CreateTemplate">Create</button>
|
||||
<button class="btn btn-outline-secondary" @onclick="GoBack">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -262,22 +262,15 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@* M9: the FULL transitively-resolved inherited member set — the whole
|
||||
chain (grandparent + further ancestors) plus base members added after
|
||||
this template was created, which the immediate-base tables below cannot
|
||||
show. Read-only preview; each row carries its origin template + lock
|
||||
state, and alarms surface the merged effective trigger configuration. *@
|
||||
@if (_resolved != null)
|
||||
{
|
||||
@RenderInheritedSet()
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
@* Identity block: the template name leads, with the qualified path /
|
||||
inheritance relation stacked beneath it rather than run together on
|
||||
one heading line. *@
|
||||
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||
<div>
|
||||
<h4 class="d-inline mb-0">@_selectedTemplate.Name</h4>
|
||||
<h4 class="mb-0">@_selectedTemplate.Name</h4>
|
||||
@if (_selectedTemplate.ParentTemplateId.HasValue && !_selectedTemplate.IsDerived)
|
||||
{
|
||||
<span class="text-muted ms-2">inherits @(_templates.FirstOrDefault(t => t.Id == _selectedTemplate.ParentTemplateId)?.Name)</span>
|
||||
<div class="text-muted small">inherits @(_templates.FirstOrDefault(t => t.Id == _selectedTemplate.ParentTemplateId)?.Name)</div>
|
||||
}
|
||||
@if (_selectedTemplate.IsDerived)
|
||||
{
|
||||
@@ -286,18 +279,30 @@
|
||||
<div class="text-muted small font-monospace">@QualifiedTemplateName(_selectedTemplate)</div>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-outline-info btn-sm me-1" @onclick="RunValidation" disabled="@_validating">
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="Template actions">
|
||||
<button class="btn btn-outline-info" @onclick="RunValidation" disabled="@_validating">
|
||||
@if (_validating)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-1"></span>
|
||||
}
|
||||
Validate
|
||||
</button>
|
||||
<button class="btn btn-outline-danger btn-sm" @onclick="DeleteTemplate">Delete</button>
|
||||
<button class="btn btn-outline-danger" @onclick="DeleteTemplate">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* M9: the FULL transitively-resolved inherited member set — the whole
|
||||
chain (grandparent + further ancestors) plus base members added after
|
||||
this template was created, which the immediate-base tables below cannot
|
||||
show. Read-only preview; each row carries its origin template + lock
|
||||
state, and alarms surface the merged effective trigger configuration.
|
||||
Rendered BELOW the identity block so the template's own name leads the
|
||||
page. *@
|
||||
@if (_resolved != null)
|
||||
{
|
||||
@RenderInheritedSet()
|
||||
}
|
||||
|
||||
@* Validation results *@
|
||||
@if (_validationResult != null)
|
||||
{
|
||||
@@ -469,8 +474,14 @@
|
||||
{
|
||||
@if (members.Count > 0)
|
||||
{
|
||||
<h6 class="mt-2 mb-2">@title <span class="badge bg-secondary">@members.Count</span></h6>
|
||||
<table class="table table-sm table-striped mb-3">
|
||||
@* Each member kind gets its own card so four heading+table groups do
|
||||
not run together in one undivided body. *@
|
||||
<div class="card mb-3">
|
||||
<div class="card-header py-2">
|
||||
<span class="fw-semibold">@title</span>
|
||||
<span class="badge bg-secondary ms-1">@members.Count</span>
|
||||
</div>
|
||||
<table class="table table-sm table-striped mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
@@ -488,7 +499,11 @@
|
||||
{
|
||||
<tr>
|
||||
<td>@m.Name</td>
|
||||
<td class="small">@(m.EffectiveValue ?? "—")</td>
|
||||
@* Effective values are author-supplied and unbounded —
|
||||
bound them the same way the trigger-config sibling is. *@
|
||||
<td class="small">
|
||||
<span class="cell-clip" title="@m.EffectiveValue">@(m.EffectiveValue ?? "—")</span>
|
||||
</td>
|
||||
@if (showTrigger)
|
||||
{
|
||||
<td class="small text-muted font-monospace text-truncate"
|
||||
@@ -499,10 +514,11 @@
|
||||
<td>
|
||||
@if (m.IsInherited)
|
||||
{
|
||||
<span class="badge bg-secondary"
|
||||
title="@($"Inherited from {m.OriginTemplateName}")">
|
||||
Inherited from @m.OriginTemplateName
|
||||
</span>
|
||||
@* Short static badge + the (unbounded) origin
|
||||
template name as separate clipped text. *@
|
||||
<span class="badge bg-secondary">Inherited</span>
|
||||
<span class="cell-clip cell-clip-sm small text-muted"
|
||||
title="@($"Inherited from {m.OriginTemplateName}")">from @m.OriginTemplateName</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -527,6 +543,7 @@
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
};
|
||||
|
||||
@@ -681,8 +698,16 @@
|
||||
<tr>
|
||||
<td>@attr.Name</td>
|
||||
<td><span class="badge bg-light text-dark">@attr.DataType</span></td>
|
||||
<td class="small">@(effectiveValue ?? "—")</td>
|
||||
<td class="small text-muted">@(effectiveDataSource ?? "—")</td>
|
||||
@* Attribute default values and — especially — OPC UA /
|
||||
MxAccess node paths are unbounded author-supplied text;
|
||||
clip them so they cannot push the actions column
|
||||
off-screen, with the full value on the title. *@
|
||||
<td class="small">
|
||||
<span class="cell-clip cell-clip-sm" title="@effectiveValue">@(effectiveValue ?? "—")</span>
|
||||
</td>
|
||||
<td class="small text-muted">
|
||||
<span class="cell-clip font-monospace" title="@effectiveDataSource">@(effectiveDataSource ?? "—")</span>
|
||||
</td>
|
||||
<td>
|
||||
@if (attr.IsLocked)
|
||||
{
|
||||
@@ -726,7 +751,7 @@
|
||||
}
|
||||
<td>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-outline-secondary btn-sm py-0 px-1"
|
||||
<button class="btn btn-outline-secondary btn-sm"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
aria-label="@($"More actions for {attr.Name}")">⋮</button>
|
||||
@@ -890,7 +915,10 @@
|
||||
<td>@alarm.Name</td>
|
||||
<td><span class="badge bg-light text-dark">@alarm.TriggerType</span></td>
|
||||
<td>@alarm.PriorityLevel</td>
|
||||
<td class="small text-muted text-truncate" style="max-width: 200px;">@(alarm.TriggerConfiguration ?? "—")</td>
|
||||
@* Already width-bounded — the title makes the clipped
|
||||
config readable instead of merely hidden. *@
|
||||
<td class="small text-muted text-truncate" style="max-width: 200px;"
|
||||
title="@alarm.TriggerConfiguration">@(alarm.TriggerConfiguration ?? "—")</td>
|
||||
<td>
|
||||
@if (alarm.IsLocked)
|
||||
{
|
||||
@@ -903,7 +931,7 @@
|
||||
</td>
|
||||
<td>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-outline-secondary btn-sm py-0 px-1"
|
||||
<button class="btn btn-outline-secondary btn-sm"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
aria-label="@($"More actions for {alarm.Name}")">⋮</button>
|
||||
@@ -932,10 +960,6 @@
|
||||
<h5 class="mb-0">Native Alarm Sources</h5>
|
||||
<button class="btn btn-primary btn-sm" @onclick="OpenAddNativeSourceDialog">Add Source</button>
|
||||
</div>
|
||||
<p class="text-muted small">
|
||||
Read-only mirror of alarms from an OPC UA Alarms & Conditions server or the
|
||||
MxAccess Gateway. Discovered at runtime and shown live in the Debug View — no ack-back.
|
||||
</p>
|
||||
|
||||
@if (_nativeSources.Count == 0)
|
||||
{
|
||||
@@ -961,7 +985,11 @@
|
||||
<td>@src.Name</td>
|
||||
<td><span class="badge bg-light text-dark">@src.ConnectionName</span></td>
|
||||
<td class="small font-monospace text-truncate" style="max-width: 220px;" title="@src.SourceReference">@src.SourceReference</td>
|
||||
<td class="small text-muted">@(string.IsNullOrEmpty(src.ConditionFilter) ? "—" : src.ConditionFilter)</td>
|
||||
@* Condition filters are free-text expressions — clip
|
||||
with the full value on the title. *@
|
||||
<td class="small text-muted">
|
||||
<span class="cell-clip" title="@src.ConditionFilter">@(string.IsNullOrEmpty(src.ConditionFilter) ? "—" : src.ConditionFilter)</span>
|
||||
</td>
|
||||
<td>
|
||||
@if (src.IsLocked)
|
||||
{
|
||||
@@ -974,7 +1002,7 @@
|
||||
</td>
|
||||
<td>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-outline-secondary btn-sm py-0 px-1"
|
||||
<button class="btn btn-outline-secondary btn-sm"
|
||||
data-bs-toggle="dropdown" aria-expanded="false"
|
||||
aria-label="@($"More actions for {src.Name}")">⋮</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
@@ -1175,7 +1203,7 @@
|
||||
}
|
||||
<td>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-outline-secondary btn-sm py-0 px-1"
|
||||
<button class="btn btn-outline-secondary btn-sm"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
aria-label="@($"More actions for {script.Name}")">⋮</button>
|
||||
|
||||
+4
-2
@@ -58,8 +58,10 @@
|
||||
</div>
|
||||
|
||||
<div class="modal-footer px-0 pb-0 mt-3">
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="() => Context.Cancel()" disabled="@_busy">Cancel</button>
|
||||
<button class="btn btn-success btn-sm" @onclick="Submit" disabled="@_busy">@(Editing ? "Save" : "Add")</button>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button class="btn btn-outline-secondary" @onclick="() => Context.Cancel()" disabled="@_busy">Cancel</button>
|
||||
<button class="btn btn-success" @onclick="Submit" disabled="@_busy">@(Editing ? "Save" : "Add")</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
|
||||
+4
-2
@@ -264,8 +264,10 @@
|
||||
<button class="btn btn-outline-primary btn-sm me-auto" @onclick="ToggleTestRunPanel">
|
||||
@(_showTestRun ? "Hide Test Run" : "Test Run")
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="Cancel" disabled="@_busy">Cancel</button>
|
||||
<button class="btn btn-success btn-sm" @onclick="Submit" disabled="@_busy">@(Editing ? "Save" : "Add")</button>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button class="btn btn-outline-secondary" @onclick="Cancel" disabled="@_busy">Cancel</button>
|
||||
<button class="btn btn-success" @onclick="Submit" disabled="@_busy">@(Editing ? "Save" : "Add")</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
|
||||
@@ -44,14 +44,18 @@
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<button class="btn btn-outline-secondary btn-sm"
|
||||
@* The Bulk actions dropdown keeps its own .dropdown wrapper, so
|
||||
only the two plain create buttons are grouped. *@
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="Create actions">
|
||||
<button class="btn btn-outline-secondary"
|
||||
title="New folder at root"
|
||||
@onclick="() => OpenNewFolderDialog(null)">+ Folder</button>
|
||||
<button class="btn btn-primary btn-sm"
|
||||
<button class="btn btn-primary"
|
||||
title="New template at root"
|
||||
@onclick='() => NavigationManager.NavigateTo("/design/templates/create")'>+ Template</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3" style="max-width: 320px;">
|
||||
<div class="input-group input-group-sm">
|
||||
|
||||
@@ -125,12 +125,9 @@
|
||||
|
||||
<fieldset class="mb-4" data-testid="group-notification-lists">
|
||||
<legend class="h6">Notification Lists</legend>
|
||||
@* The "a notification list does not pull in its SMTP config" rule now lives in
|
||||
Component-Transport.md §Export Flow (dependency-edge list), not on the page. *@
|
||||
@RenderCheckboxList(_notificationLists, n => n.Id, n => n.Name, _selectedNotificationLists)
|
||||
<div class="alert alert-info small mt-2 mb-0 py-2" role="alert" data-testid="smtp-hint">
|
||||
Selecting a notification list does <strong>not</strong> automatically include its
|
||||
SMTP configuration. SMTP configurations are environment-specific and must be
|
||||
selected separately if you want them in the bundle.
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="mb-4" data-testid="group-smtp-configs">
|
||||
@@ -147,24 +144,15 @@
|
||||
|
||||
<fieldset class="mb-4" data-testid="group-api-methods">
|
||||
<legend class="h6">API Methods</legend>
|
||||
@* Methods only — inbound API keys are never transported. Recorded (with the
|
||||
re-create-and-re-grant recovery hint) in Component-Transport.md §Responsibilities. *@
|
||||
@RenderCheckboxList(_apiMethods, m => m.Id, m => m.Name, _selectedApiMethods)
|
||||
<div class="alert alert-info small mt-2 mb-0 py-2" role="alert" data-testid="api-keys-not-transported">
|
||||
<strong>API keys are not part of config transport.</strong> Inbound API keys
|
||||
live in each environment's own secret store and cannot be exported. After
|
||||
importing, re-create the keys on the destination and re-grant their method
|
||||
scopes via the admin UI/CLI.
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="mb-4" data-testid="group-sites">
|
||||
<legend class="h6">Sites & Instances</legend>
|
||||
@* Site-vs-instance selection semantics: Component-Transport.md §UI Export Wizard. *@
|
||||
@RenderSitesList()
|
||||
<div class="alert alert-info small mt-2 mb-0 py-2" role="alert" data-testid="sites-hint">
|
||||
Selecting a <strong>site</strong> includes its data connections and all of its
|
||||
instances. Expand a site to pick individual <strong>instances</strong> instead.
|
||||
Each instance pulls in its template (and that template's dependency closure)
|
||||
when <em>Include all dependencies</em> is on.
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="d-flex justify-content-end gap-2 mt-4">
|
||||
@@ -319,13 +307,8 @@
|
||||
var autoInstances = AutoIncluded(_resolved.Instances, seedInstanceIds, i => i.Id);
|
||||
|
||||
<div>
|
||||
<p class="text-body-secondary">
|
||||
The resolver walked your selection's dependency graph and produced the closure
|
||||
below. Items under <em>Auto-included</em> were pulled in because the items you
|
||||
ticked reference them; unticking
|
||||
<em>Include all dependencies</em> exports the seed alone.
|
||||
</p>
|
||||
|
||||
@* No lede here — the "Selected by you" / "Auto-included (dependencies)" column
|
||||
headings plus the labelled toggle below already say what the resolver did. *@
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="include-deps"
|
||||
checked="@_includeDependencies"
|
||||
@@ -496,10 +479,12 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
@* Arms the confirm block below — it mutates page state, so it is a button,
|
||||
not a javascript:void(0) anchor. Still a two-step: arm, then confirm. *@
|
||||
<p class="small">
|
||||
<a href="javascript:void(0)" class="link-danger" @onclick="OpenUnencryptedConfirm">
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" @onclick="OpenUnencryptedConfirm">
|
||||
Export without encryption…
|
||||
</a>
|
||||
</button>
|
||||
</p>
|
||||
|
||||
@if (_showUnencryptedConfirm)
|
||||
@@ -509,11 +494,11 @@
|
||||
in plaintext. Anyone with the file can read external-system credentials, SMTP
|
||||
passwords, SMS auth tokens, and database connection strings. The audit log will record this as
|
||||
<code>UnencryptedBundleExport</code>.
|
||||
<div class="mt-2 d-flex gap-2">
|
||||
<button class="btn btn-sm btn-danger" @onclick="ConfirmUnencryptedExport">
|
||||
<div class="mt-2 btn-group btn-group-sm" role="group">
|
||||
<button class="btn btn-danger" @onclick="ConfirmUnencryptedExport">
|
||||
Yes, export without encryption
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="CancelUnencryptedConfirm">
|
||||
<button class="btn btn-outline-secondary" @onclick="CancelUnencryptedConfirm">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -83,12 +83,8 @@
|
||||
private RenderFragment RenderStepUpload() => __builder =>
|
||||
{
|
||||
<div>
|
||||
<p class="text-body-secondary">
|
||||
Select a <code>.scadabundle</code> file produced by an exporter on this
|
||||
or another cluster. The bundle's manifest will be validated immediately;
|
||||
encrypted bundles will prompt for a passphrase on the next step.
|
||||
</p>
|
||||
|
||||
@* No lede — the step pills above and the InputFile's own accept filter +
|
||||
size hint already say what belongs here. *@
|
||||
<div class="mb-3">
|
||||
<label for="bundle-input" class="form-label">Bundle file</label>
|
||||
<InputFile id="bundle-input" OnChange="OnFileSelectedAsync"
|
||||
@@ -208,16 +204,14 @@
|
||||
<div>
|
||||
@RenderMapSection();
|
||||
|
||||
<p class="text-body-secondary">
|
||||
Review each artifact in the bundle and choose how it should be applied
|
||||
to this environment. Identical items are skipped automatically; new
|
||||
items default to Add; modified items require an explicit choice.
|
||||
</p>
|
||||
|
||||
@* Per-kind resolution defaults are documented in Component-Transport.md
|
||||
§Diff classification; the Status badge shows each row's kind in place. *@
|
||||
<div class="mb-3 d-flex flex-wrap gap-2 align-items-center" data-testid="bulk-actions">
|
||||
<span class="small text-body-secondary">Apply to all modified:</span>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => BulkSet(ResolutionAction.Skip)">Skip</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => BulkSet(ResolutionAction.Overwrite)">Overwrite</button>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button class="btn btn-outline-secondary" @onclick="() => BulkSet(ResolutionAction.Skip)">Skip</button>
|
||||
<button class="btn btn-outline-secondary" @onclick="() => BulkSet(ResolutionAction.Overwrite)">Overwrite</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive" style="max-height: 480px; overflow-y: auto;">
|
||||
@@ -239,7 +233,9 @@
|
||||
var current = _resolutions[key];
|
||||
<tr data-testid="diff-row">
|
||||
<td><span class="badge bg-secondary">@item.EntityType</span></td>
|
||||
<td>@item.Name</td>
|
||||
@* Bundle-supplied (external) name — clip so it can't set the
|
||||
table's width. .cell-clip is display:block, hence the span. *@
|
||||
<td><span class="cell-clip" title="@item.Name">@item.Name</span></td>
|
||||
<td>@RenderKindBadge(item)</td>
|
||||
<td>@(item.ExistingVersion?.ToString() ?? "—")</td>
|
||||
<td>@(item.IncomingVersion?.ToString() ?? "—")</td>
|
||||
@@ -261,7 +257,10 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<pre class="small mb-0"><code>@item.FieldDiffJson</code></pre>
|
||||
@* Raw JSON from an externally produced bundle —
|
||||
wrap + cap the height rather than forcing the
|
||||
already-scroll-boxed table to scroll sideways. *@
|
||||
<pre class="small mb-0 detail-pre"><code>@item.FieldDiffJson</code></pre>
|
||||
}
|
||||
</details>
|
||||
</td>
|
||||
@@ -321,12 +320,10 @@
|
||||
}
|
||||
|
||||
<div class="card mb-4" data-testid="map-section">
|
||||
@* The Source identifier / Source name / Map to target headings and the
|
||||
"Create new" option below carry the explanation this header used to. *@
|
||||
<div class="card-header bg-body-tertiary">
|
||||
<strong>Resolve site & connection references</strong>
|
||||
<span class="small text-body-secondary ms-2">
|
||||
This bundle references sites/connections from its source environment.
|
||||
Map each to an existing target, or create a new one.
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (hasSiteMappings)
|
||||
@@ -346,7 +343,7 @@
|
||||
var chosen = _siteChoices.TryGetValue(rsm.SourceSiteIdentifier, out var c) ? c : CreateNewValue;
|
||||
<tr data-testid="map-site-row">
|
||||
<td><code>@rsm.SourceSiteIdentifier</code></td>
|
||||
<td>@rsm.SourceSiteName</td>
|
||||
<td><span class="cell-clip" title="@rsm.SourceSiteName">@rsm.SourceSiteName</span></td>
|
||||
<td>
|
||||
<select class="form-select form-select-sm"
|
||||
style="max-width: 22rem;"
|
||||
@@ -396,7 +393,7 @@
|
||||
var key = (rcm.SourceSiteIdentifier, rcm.SourceConnectionName);
|
||||
var chosenConn = _connectionChoices.TryGetValue(key, out var cc) ? cc : CreateNewValue;
|
||||
<tr data-testid="map-conn-row">
|
||||
<td>@rcm.SourceConnectionName</td>
|
||||
<td><span class="cell-clip" title="@rcm.SourceConnectionName">@rcm.SourceConnectionName</span></td>
|
||||
<td>
|
||||
<select class="form-select form-select-sm"
|
||||
style="max-width: 22rem;"
|
||||
@@ -563,7 +560,8 @@
|
||||
<dd class="col-sm-9"><code>@_result.BundleImportId</code></dd>
|
||||
</dl>
|
||||
|
||||
<div class="d-flex gap-3">
|
||||
@* Two follow-on drill-ins for the same result — anchors are valid group members. *@
|
||||
<div class="btn-group" role="group">
|
||||
<a class="btn btn-outline-primary" href="/deployment/deployments">
|
||||
View on Deployments →
|
||||
</a>
|
||||
|
||||
@@ -100,8 +100,13 @@
|
||||
|
||||
@if (_notReporting.Count > 0)
|
||||
{
|
||||
<div class="text-muted small mb-2" data-test="alarm-summary-not-reporting">
|
||||
Not reporting (@_notReporting.Count): @string.Join(", ", _notReporting)
|
||||
@* The instance list is unbounded — a site with many silent instances would
|
||||
otherwise push a wall of names across the page. Clamp to two lines and
|
||||
keep the full list reachable via the title. *@
|
||||
var notReportingList = string.Join(", ", _notReporting);
|
||||
<div class="text-muted small mb-2 cell-clamp-2" data-test="alarm-summary-not-reporting"
|
||||
title="@notReportingList">
|
||||
Not reporting (@_notReporting.Count): @notReportingList
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
@@ -111,18 +111,17 @@
|
||||
{
|
||||
<tr><td colspan="7" class="text-muted text-center">No events found.</td></tr>
|
||||
}
|
||||
@for (int i = 0; i < _entries.Count; i++)
|
||||
@foreach (var entry in _entries)
|
||||
{
|
||||
var idx = i;
|
||||
var entry = _entries[idx];
|
||||
var entryId = entry.Id;
|
||||
var rowClass = entry.Severity == "Error" ? "table-danger"
|
||||
: entry.Severity == "Warning" ? "table-warning"
|
||||
: "";
|
||||
var expanded = _expandedRows.Contains(idx);
|
||||
var expanded = _expandedRows.Contains(entryId);
|
||||
<tr class="@rowClass">
|
||||
<td>
|
||||
<button class="btn btn-link btn-sm p-0"
|
||||
@onclick="() => ToggleRow(idx)"
|
||||
<button class="btn btn-sm btn-outline-secondary"
|
||||
@onclick="() => ToggleRow(entryId)"
|
||||
aria-label="@(expanded ? "Hide full message" : "View full message")">
|
||||
@(expanded ? "Hide" : "View")
|
||||
</button>
|
||||
@@ -135,14 +134,16 @@
|
||||
</span>
|
||||
</td>
|
||||
<td class="small">@(entry.InstanceId ?? "—")</td>
|
||||
<td class="small">@entry.Source</td>
|
||||
<td class="small text-truncate" style="max-width: 380px;">@entry.Message</td>
|
||||
<td class="small">
|
||||
<span class="cell-clip cell-clip-sm" title="@entry.Source">@entry.Source</span>
|
||||
</td>
|
||||
<td class="small text-truncate" style="max-width: 380px;" title="@entry.Message">@entry.Message</td>
|
||||
</tr>
|
||||
@if (expanded)
|
||||
{
|
||||
<tr class="@rowClass">
|
||||
<td colspan="7">
|
||||
<pre class="small mb-0">@entry.Message</pre>
|
||||
<pre class="small mb-0 detail-pre">@entry.Message</pre>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@@ -185,7 +186,13 @@
|
||||
private bool _searching;
|
||||
private string? _errorMessage;
|
||||
private ToastNotification _toast = default!;
|
||||
private readonly HashSet<int> _expandedRows = new();
|
||||
|
||||
// Keyed by EventLogEntry.Id — the site-minted GUID string — NOT by the row's
|
||||
// position in _entries. LoadMore() appends a page without clearing this set, so
|
||||
// index keys only survived because appends happen to keep earlier indices stable;
|
||||
// any change to that (re-sort, in-place refresh, prepend) would have silently
|
||||
// moved every expansion onto the wrong row. The id is stable by construction.
|
||||
private readonly HashSet<string> _expandedRows = new();
|
||||
|
||||
private int ActiveFilterCount
|
||||
{
|
||||
@@ -225,11 +232,11 @@
|
||||
|
||||
private async Task LoadMore() => await FetchPage();
|
||||
|
||||
private void ToggleRow(int idx)
|
||||
private void ToggleRow(string entryId)
|
||||
{
|
||||
if (!_expandedRows.Add(idx))
|
||||
if (!_expandedRows.Add(entryId))
|
||||
{
|
||||
_expandedRows.Remove(idx);
|
||||
_expandedRows.Remove(entryId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Headline KPI groups. Each is a self-contained panel, so each gets its own card
|
||||
boundary — the same treatment the Site Health Trends group below already uses,
|
||||
including tiles-that-are-cards nested in a card body (Trends does exactly that
|
||||
with KpiTrendChart). The two component-rendered groups own their heading and
|
||||
"View details" link internally, so the Notification Outbox group keeps its
|
||||
heading inside the card body too rather than promoting it to a card-header:
|
||||
uniformity across the panels beats matching the Trends card, whose card-header
|
||||
exists to carry the site selector and window toggle. card-body pb-0 lets each
|
||||
tile row's own mb-3 supply the bottom padding instead of doubling it. *@
|
||||
|
||||
@* Notification Outbox headline KPIs — a central concern, shown regardless of site reports *@
|
||||
<div class="card mb-3">
|
||||
<div class="card-body pb-0">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h6 class="text-muted mb-0">Notification Outbox</h6>
|
||||
<a class="small" href="/notifications/kpis">View details →</a>
|
||||
@@ -64,20 +76,30 @@
|
||||
{
|
||||
<div class="text-muted small mb-3">Notification Outbox KPIs unavailable: @_outboxKpiError</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Site Call Audit (#22) Task 7 — three KPI tiles for the Site Call channel
|
||||
(buffered / stuck / parked). Refreshed alongside the site states. *@
|
||||
<div class="card mb-3">
|
||||
<div class="card-body pb-0">
|
||||
<SiteCallKpiTiles Snapshot="@_siteCallKpi"
|
||||
IsAvailable="@_siteCallKpiAvailable"
|
||||
ErrorMessage="@_siteCallKpiError"
|
||||
PerNodeSnapshots="@_siteCallNodeKpis"
|
||||
PerNodeAvailable="@_siteCallNodeKpiAvailable" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Three KPI tiles for the Audit channel
|
||||
(volume / error rate / backlog). Refreshed alongside the site states. *@
|
||||
<div class="card mb-3">
|
||||
<div class="card-body pb-0">
|
||||
<AuditKpiTiles Snapshot="@_auditKpi"
|
||||
IsAvailable="@_auditKpiAvailable"
|
||||
ErrorMessage="@_auditKpiError" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Site Health Trends (M6) — per-site Site Health KPI history. Loads on a
|
||||
separate path from the 10s tile-refresh timer so a trend-query fault can
|
||||
@@ -160,7 +182,14 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
@* Overview cards *@
|
||||
@* Overview cards — the fourth headline tile row, so it takes the same card
|
||||
boundary as the three above it. It was also the only tile row on the page
|
||||
with no heading at all; it now carries one for the same reason. *@
|
||||
<div class="card mb-3">
|
||||
<div class="card-body pb-0">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h6 class="text-muted mb-0">Sites</h6>
|
||||
</div>
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-lg-4 col-md-6 col-12">
|
||||
<div class="card border-success h-100">
|
||||
@@ -187,6 +216,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Per-site detail cards — central cluster pinned to the top, then sites alphabetically *@
|
||||
@foreach (var (siteId, state) in _siteStates.OrderBy(s => s.Key == CentralHealthReportLoop.CentralSiteId ? 0 : 1).ThenBy(s => s.Key))
|
||||
@@ -196,26 +227,36 @@
|
||||
var detailsCollapseId = $"site-details-{siteId}";
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center py-2">
|
||||
@* Display name and machine id are two different things and are read
|
||||
for two different reasons, so they get two lines: chips + name on
|
||||
the first, the site identifier on its own beneath it. Reference
|
||||
shape: Pages/Admin/Sites.razor (card title + monospace identifier). *@
|
||||
<div>
|
||||
<div class="d-flex align-items-center flex-wrap gap-2">
|
||||
@if (state.IsOnline)
|
||||
{
|
||||
<span class="badge bg-success me-2" aria-label="State: Online">@OnlineGlyph Online</span>
|
||||
<span class="badge bg-success" aria-label="State: Online">@OnlineGlyph Online</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-danger me-2" aria-label="State: Offline">@OfflineGlyph Offline</span>
|
||||
<span class="badge bg-danger" aria-label="State: Offline">@OfflineGlyph Offline</span>
|
||||
}
|
||||
<strong class="fs-5">@siteName@(isCentral ? "" : $" ({siteId})")</strong>
|
||||
<strong class="fs-5">@siteName</strong>
|
||||
@if (state.IsOnline && state.IsMetricsStale)
|
||||
{
|
||||
<span class="badge bg-warning text-dark ms-2"
|
||||
<span class="badge bg-warning text-dark"
|
||||
title="Site is heartbeating but has sent no health report for over @StaleTimeoutDisplay — the metrics pipeline may be dead.">
|
||||
Metrics stale
|
||||
</span>
|
||||
}
|
||||
@if (!state.IsOnline && state.LastStatusChangeAt is { } changedAt)
|
||||
{
|
||||
<small class="text-muted ms-2">offline since @changedAt.ToString("u")</small>
|
||||
<small class="text-muted">offline since @changedAt.ToString("u")</small>
|
||||
}
|
||||
</div>
|
||||
@if (!isCentral)
|
||||
{
|
||||
<div class="small text-muted font-monospace">@siteId</div>
|
||||
}
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
@@ -236,9 +277,20 @@
|
||||
{
|
||||
var report = state.LatestReport;
|
||||
<div class="row g-3">
|
||||
@* Each logical group below sits in its own card, with the group's
|
||||
head promoted to a real card-header, instead of h6 rules stacked
|
||||
inside one undifferentiated body. Columns 3 and 4 keep their
|
||||
existing single collapse region (and its id) spanning both of
|
||||
their sub-sections: the disclosure toggle IS the card-header and
|
||||
the two sub-heads live inside the one card body. *@
|
||||
|
||||
@* Column 1: Nodes *@
|
||||
<div class="col-md-6">
|
||||
<h6 class="text-muted mb-2 border-bottom pb-1">Nodes</h6>
|
||||
<div class="card h-100">
|
||||
<div class="card-header py-2">
|
||||
<h6 class="text-muted mb-0">Nodes (@NodeRowCount(state))</h6>
|
||||
</div>
|
||||
<div class="card-body p-2">
|
||||
<table class="table table-sm table-borderless mb-0">
|
||||
<tbody>
|
||||
@if (report.ClusterNodes is { Count: > 0 })
|
||||
@@ -286,16 +338,22 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Column 2: Data Connections (collapsible) *@
|
||||
<div class="col-md-6">
|
||||
<button class="btn btn-link btn-sm p-0 text-decoration-none mb-2"
|
||||
<div class="card h-100">
|
||||
<div class="card-header py-2">
|
||||
<button class="btn btn-link btn-sm p-0 text-decoration-none"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="@($"#{detailsCollapseId}-conns")"
|
||||
aria-expanded="false">
|
||||
Data Connections (@report.DataConnectionStatuses.Count)
|
||||
</button>
|
||||
</div>
|
||||
<div class="collapse" id="@($"{detailsCollapseId}-conns")">
|
||||
<div class="card-body p-2">
|
||||
@if (report.DataConnectionStatuses.Count == 0)
|
||||
{
|
||||
<span class="text-muted small">None</span>
|
||||
@@ -306,10 +364,14 @@
|
||||
{
|
||||
var endpoint = report.DataConnectionEndpoints?.GetValueOrDefault(connName);
|
||||
var quality = report.DataConnectionTagQuality?.GetValueOrDefault(connName);
|
||||
var endpointText = endpoint ?? health.ToString();
|
||||
<div class="mb-2">
|
||||
<div class="d-flex justify-content-between">
|
||||
<strong class="small">@connName</strong>
|
||||
<span class="small">@(endpoint ?? health.ToString())</span>
|
||||
@* Both halves are site-supplied strings — a long connection
|
||||
name or endpoint URL would otherwise wrap this flex row
|
||||
into several lines. Clip both, full value on the title. *@
|
||||
<div class="d-flex justify-content-between gap-2">
|
||||
<strong class="small cell-clip cell-clip-sm" title="@connName">@connName</strong>
|
||||
<span class="small cell-clip cell-clip-sm text-end" title="@endpointText">@endpointText</span>
|
||||
</div>
|
||||
@if (quality != null)
|
||||
{
|
||||
@@ -335,16 +397,22 @@
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Column 3: Instances + Store-and-Forward (collapsible) *@
|
||||
<div class="col-md-6">
|
||||
<button class="btn btn-link btn-sm p-0 text-decoration-none mb-2"
|
||||
<div class="card h-100">
|
||||
<div class="card-header py-2">
|
||||
<button class="btn btn-link btn-sm p-0 text-decoration-none"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="@($"#{detailsCollapseId}-queues")"
|
||||
aria-expanded="false">
|
||||
Instances & Queues
|
||||
</button>
|
||||
</div>
|
||||
<div class="collapse" id="@($"{detailsCollapseId}-queues")">
|
||||
<div class="card-body p-2">
|
||||
<h6 class="text-muted mb-2 border-bottom pb-1">Instances</h6>
|
||||
<table class="table table-sm table-borderless mb-0">
|
||||
<tbody>
|
||||
@@ -363,7 +431,9 @@
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h6 class="text-muted mb-2 mt-3 border-bottom pb-1">Store-and-Forward Buffers</h6>
|
||||
<h6 class="text-muted mb-2 mt-3 border-bottom pb-1">
|
||||
Store-and-Forward Buffers (@report.StoreAndForwardBufferDepths.Count)
|
||||
</h6>
|
||||
@if (report.StoreAndForwardBufferDepths.Count == 0)
|
||||
{
|
||||
<span class="text-muted small">Empty</span>
|
||||
@@ -380,16 +450,22 @@
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Column 4: Error Counts + Parked Messages (collapsible) *@
|
||||
<div class="col-md-6">
|
||||
<button class="btn btn-link btn-sm p-0 text-decoration-none mb-2"
|
||||
<div class="card h-100">
|
||||
<div class="card-header py-2">
|
||||
<button class="btn btn-link btn-sm p-0 text-decoration-none"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="@($"#{detailsCollapseId}-errors")"
|
||||
aria-expanded="false">
|
||||
Errors & Parked Messages
|
||||
</button>
|
||||
</div>
|
||||
<div class="collapse" id="@($"{detailsCollapseId}-errors")">
|
||||
<div class="card-body p-2">
|
||||
<h6 class="text-muted mb-2 border-bottom pb-1">Error Counts</h6>
|
||||
<table class="table table-sm table-borderless mb-0">
|
||||
<tbody>
|
||||
@@ -426,6 +502,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -458,6 +536,13 @@
|
||||
? nodes.Count(n => n.IsOnline)
|
||||
: (state.IsOnline ? 1 : 0);
|
||||
|
||||
// Rows the Nodes group actually renders: the reported cluster-node list when the
|
||||
// site sends one, otherwise the single synthesized fallback row. Shown in that
|
||||
// group's card-header so the count is legible without reading the table — the same
|
||||
// metadata-in-the-header treatment the Data Connections group already had inline.
|
||||
private static int NodeRowCount(SiteHealthState state) =>
|
||||
state.LatestReport?.ClusterNodes is { Count: > 0 } nodes ? nodes.Count : 1;
|
||||
|
||||
private static string FormatDuration(TimeSpan span) =>
|
||||
span.TotalMinutes >= 1 && span == TimeSpan.FromMinutes(Math.Round(span.TotalMinutes))
|
||||
? $"{span.TotalMinutes:0} minute{(span.TotalMinutes == 1 ? "" : "s")}"
|
||||
|
||||
+52
-29
@@ -115,16 +115,20 @@
|
||||
{
|
||||
<div class="alert alert-secondary py-2 d-flex align-items-center mb-3">
|
||||
<strong class="me-3">@_selectedIds.Count selected</strong>
|
||||
<button class="btn btn-outline-success btn-sm me-2"
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="Bulk actions">
|
||||
<button class="btn btn-outline-success"
|
||||
@onclick="BulkRetry" disabled="@_bulkInProgress">
|
||||
@if (_bulkInProgress && _bulkAction == "Retry") { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
|
||||
Retry selected
|
||||
</button>
|
||||
<button class="btn btn-outline-danger btn-sm me-2"
|
||||
<button class="btn btn-outline-danger"
|
||||
@onclick="BulkDiscard" disabled="@_bulkInProgress">
|
||||
@if (_bulkInProgress && _bulkAction == "Discard") { <span class="spinner-border spinner-border-sm me-1" role="status"></span> }
|
||||
Discard selected
|
||||
</button>
|
||||
</div>
|
||||
@* The dismiss control is not a bulk action — it stays outside the group so
|
||||
it keeps its ms-auto right-alignment and its btn-close styling. *@
|
||||
<button type="button" class="btn-close ms-auto"
|
||||
aria-label="Clear selection" @onclick="ClearSelection"></button>
|
||||
</div>
|
||||
@@ -236,10 +240,10 @@
|
||||
<span class="text-muted small">
|
||||
Page @_pageNumber of @((_totalCount + _pageSize - 1) / _pageSize) · @_totalCount total
|
||||
</span>
|
||||
<div>
|
||||
<button class="btn btn-outline-secondary btn-sm me-1"
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="Pagination">
|
||||
<button class="btn btn-outline-secondary"
|
||||
@onclick="PrevPage" disabled="@(_pageNumber <= 1)">Previous</button>
|
||||
<button class="btn btn-outline-secondary btn-sm"
|
||||
<button class="btn btn-outline-secondary"
|
||||
@onclick="NextPage" disabled="@(_messages.Count < _pageSize)">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -247,15 +251,19 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (_drawerMessage != null)
|
||||
@* Re-resolved from the CURRENT page every render (see DrawerMessage). A row that
|
||||
has been retried, discarded or paged away simply resolves to null and the drawer
|
||||
renders nothing — it can never keep showing a row that no longer exists. *@
|
||||
@{ var drawer = DrawerMessage; }
|
||||
@if (drawer != null)
|
||||
{
|
||||
<div class="offcanvas-backdrop fade show" @onclick="CloseDrawer"></div>
|
||||
<div class="offcanvas offcanvas-end show parked-drawer" tabindex="-1" style="visibility: visible;">
|
||||
<div class="offcanvas-header border-bottom">
|
||||
<div>
|
||||
<div class="text-muted small text-uppercase">Parked message</div>
|
||||
<h5 class="offcanvas-title mb-0">@_drawerMessage.TargetSystem</h5>
|
||||
<div class="small text-muted">@_drawerMessage.MethodName</div>
|
||||
<h5 class="offcanvas-title mb-0">@drawer.TargetSystem</h5>
|
||||
<div class="small text-muted">@drawer.MethodName</div>
|
||||
</div>
|
||||
<button type="button" class="btn-close" aria-label="Close" @onclick="CloseDrawer"></button>
|
||||
</div>
|
||||
@@ -263,17 +271,17 @@
|
||||
<dl class="row mb-3">
|
||||
<dt class="col-4 text-muted fw-normal">Message ID</dt>
|
||||
<dd class="col-8 d-flex align-items-center gap-2">
|
||||
<code class="text-truncate" style="min-width: 0;">@_drawerMessage.MessageId</code>
|
||||
<code class="text-truncate" style="min-width: 0;">@drawer.MessageId</code>
|
||||
<button class="btn btn-link btn-sm p-0" title="Copy message ID"
|
||||
@onclick="() => CopyAsync(_drawerMessage.MessageId)">📋</button>
|
||||
@onclick="() => CopyAsync(drawer.MessageId)">📋</button>
|
||||
</dd>
|
||||
<dt class="col-4 text-muted fw-normal">Category</dt>
|
||||
<dd class="col-8">@CategoryLabel(_drawerMessage.Category)</dd>
|
||||
<dd class="col-8">@CategoryLabel(drawer.Category)</dd>
|
||||
<dt class="col-4 text-muted fw-normal">Origin instance</dt>
|
||||
<dd class="col-8">
|
||||
@if (!string.IsNullOrEmpty(_drawerMessage.OriginInstance))
|
||||
@if (!string.IsNullOrEmpty(drawer.OriginInstance))
|
||||
{
|
||||
<code>@_drawerMessage.OriginInstance</code>
|
||||
<code>@drawer.OriginInstance</code>
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -281,21 +289,21 @@
|
||||
}
|
||||
</dd>
|
||||
<dt class="col-4 text-muted fw-normal">Attempts</dt>
|
||||
<dd class="col-8 font-monospace">@_drawerMessage.AttemptCount / @_drawerMessage.MaxAttempts</dd>
|
||||
<dd class="col-8 font-monospace">@drawer.AttemptCount / @drawer.MaxAttempts</dd>
|
||||
<dt class="col-4 text-muted fw-normal">Originally enqueued</dt>
|
||||
<dd class="col-8">
|
||||
@Relative(_drawerMessage.OriginalTimestamp)
|
||||
<span class="text-muted">· @AbsoluteUtc(_drawerMessage.OriginalTimestamp)</span>
|
||||
@Relative(drawer.OriginalTimestamp)
|
||||
<span class="text-muted">· @AbsoluteUtc(drawer.OriginalTimestamp)</span>
|
||||
</dd>
|
||||
<dt class="col-4 text-muted fw-normal">Last attempt</dt>
|
||||
<dd class="col-8">
|
||||
@Relative(_drawerMessage.LastAttemptTimestamp)
|
||||
<span class="text-muted">· @AbsoluteUtc(_drawerMessage.LastAttemptTimestamp)</span>
|
||||
@Relative(drawer.LastAttemptTimestamp)
|
||||
<span class="text-muted">· @AbsoluteUtc(drawer.LastAttemptTimestamp)</span>
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
<div class="text-muted text-uppercase small fw-semibold mb-1">Error</div>
|
||||
<pre class="bg-body-secondary border rounded p-2 small mb-0 parked-error-pre">@_drawerMessage.ErrorMessage</pre>
|
||||
<pre class="bg-body-secondary border rounded p-2 small mb-0 parked-error-pre">@drawer.ErrorMessage</pre>
|
||||
</div>
|
||||
<div class="border-top p-3 d-flex gap-2">
|
||||
<button class="btn btn-outline-success btn-sm flex-grow-1"
|
||||
@@ -355,8 +363,21 @@
|
||||
private bool _actionInProgress;
|
||||
private string? _activeAction;
|
||||
|
||||
// Drawer
|
||||
private ParkedMessageEntry? _drawerMessage;
|
||||
// Drawer — holds the row's ID, never the row object. FetchPage() replaces
|
||||
// _messages wholesale after every Retry/Discard and on every page change, so a
|
||||
// captured ParkedMessageEntry would outlive its row and keep rendering values
|
||||
// (attempt count, last-attempt time, error) that no longer exist server-side;
|
||||
// PrevPage/NextPage do not clear the drawer, so it could even sit open over a
|
||||
// page that never contained it. Mirrors ExecutionTreePage's _modalExecutionId.
|
||||
private string? _drawerMessageId;
|
||||
|
||||
// Re-resolves the open drawer's row from the CURRENT page on every render.
|
||||
// A row that has been retried, discarded or paged away resolves to null, which
|
||||
// renders the drawer away — the drawer self-closes rather than going stale.
|
||||
private ParkedMessageEntry? DrawerMessage =>
|
||||
_drawerMessageId is null
|
||||
? null
|
||||
: _messages?.FirstOrDefault(m => m.MessageId == _drawerMessageId);
|
||||
|
||||
private ToastNotification _toast = default!;
|
||||
|
||||
@@ -384,7 +405,7 @@
|
||||
{
|
||||
_messages = null;
|
||||
_selectedIds.Clear();
|
||||
_drawerMessage = null;
|
||||
_drawerMessageId = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,7 +413,7 @@
|
||||
{
|
||||
_pageNumber = 1;
|
||||
_selectedIds.Clear();
|
||||
_drawerMessage = null;
|
||||
_drawerMessageId = null;
|
||||
await FetchPage();
|
||||
}
|
||||
|
||||
@@ -551,21 +572,23 @@
|
||||
|
||||
// ── Drawer ──
|
||||
|
||||
private void OpenDrawer(ParkedMessageEntry msg) => _drawerMessage = msg;
|
||||
private void CloseDrawer() => _drawerMessage = null;
|
||||
private void OpenDrawer(ParkedMessageEntry msg) => _drawerMessageId = msg.MessageId;
|
||||
private void CloseDrawer() => _drawerMessageId = null;
|
||||
|
||||
private async Task RetryFromDrawer()
|
||||
{
|
||||
if (_drawerMessage == null) return;
|
||||
var msg = _drawerMessage;
|
||||
// Snapshot the resolved row before acting — RetrySingle awaits FetchPage(),
|
||||
// after which DrawerMessage no longer resolves.
|
||||
var msg = DrawerMessage;
|
||||
if (msg == null) return;
|
||||
await RetrySingle(msg);
|
||||
CloseDrawer();
|
||||
}
|
||||
|
||||
private async Task DiscardFromDrawer()
|
||||
{
|
||||
if (_drawerMessage == null) return;
|
||||
var msg = _drawerMessage;
|
||||
var msg = DrawerMessage;
|
||||
if (msg == null) return;
|
||||
var ok = await DiscardSingle(msg);
|
||||
if (ok) CloseDrawer();
|
||||
}
|
||||
|
||||
+4
-3
@@ -107,11 +107,12 @@
|
||||
{
|
||||
@foreach (var r in _recipients)
|
||||
{
|
||||
var contact = _type == NotificationType.Sms ? r.PhoneNumber : r.EmailAddress;
|
||||
<tr>
|
||||
<td>@r.Name</td>
|
||||
<td>@(_type == NotificationType.Sms ? r.PhoneNumber : r.EmailAddress)</td>
|
||||
<td><span class="cell-clip cell-clip-sm" title="@r.Name">@r.Name</span></td>
|
||||
<td><span class="cell-clip" title="@contact">@contact</span></td>
|
||||
<td>
|
||||
<button class="btn btn-outline-danger btn-sm py-0 px-1" @onclick="() => DeleteRecipient(r)">Delete</button>
|
||||
<button class="btn btn-outline-danger btn-sm" @onclick="() => DeleteRecipient(r)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
+38
-6
@@ -57,7 +57,7 @@
|
||||
var recipients = _recipients.GetValueOrDefault(list.Id)
|
||||
?? (IReadOnlyList<NotificationRecipient>)Array.Empty<NotificationRecipient>();
|
||||
<tr @key="list.Id">
|
||||
<td>@list.Name</td>
|
||||
<td><span class="cell-clip" title="@list.Name">@list.Name</span></td>
|
||||
<td>@list.Type</td>
|
||||
<td>
|
||||
@if (recipients.Count == 0)
|
||||
@@ -66,27 +66,42 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var r in recipients)
|
||||
@foreach (var r in recipients.Take(MaxVisibleRecipients))
|
||||
{
|
||||
@* Type-aware contact: SMS recipients carry a PhoneNumber and a null
|
||||
EmailAddress, so an email-shaped badge would render "Name <>". Fall
|
||||
back to whichever contact field is populated. *@
|
||||
back to whichever contact field is populated. The two identifiers
|
||||
are separated inside the chip — name, then contact in a lighter
|
||||
weight — rather than fused into one "Name <contact>" run. *@
|
||||
var contact = list.Type == NotificationType.Sms
|
||||
? r.PhoneNumber
|
||||
: r.EmailAddress;
|
||||
<span class="badge bg-secondary-subtle text-secondary-emphasis me-1 mb-1">@r.Name <@contact></span>
|
||||
<span class="badge bg-secondary-subtle text-secondary-emphasis me-1 mb-1"
|
||||
title="@r.Name — @contact">@r.Name<span class="fw-normal opacity-75 ms-1">@contact</span></span>
|
||||
}
|
||||
@if (recipients.Count > MaxVisibleRecipients)
|
||||
{
|
||||
@* Long lists roll up rather than filling the cell with chips.
|
||||
The remainder stays readable on the tooltip, and the full set
|
||||
is always available on the list's Edit page. *@
|
||||
<span class="badge bg-body-secondary text-body-secondary border"
|
||||
title="@RemainingRecipientsTitle(list, recipients)">
|
||||
+@(recipients.Count - MaxVisibleRecipients) more
|
||||
</span>
|
||||
}
|
||||
}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<button class="btn btn-outline-primary btn-sm me-1"
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button class="btn btn-outline-primary"
|
||||
@onclick='() => NavigationManager.NavigateTo($"/notifications/lists/{list.Id}/edit")'>
|
||||
Edit
|
||||
</button>
|
||||
<button class="btn btn-outline-danger btn-sm"
|
||||
<button class="btn btn-outline-danger"
|
||||
@onclick="() => DeleteList(list)">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@@ -97,6 +112,13 @@
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// Recipient chips rendered inline before the "+N more" roll-up. A list may
|
||||
/// hold dozens of recipients and the cell is uncapped, so the grid would
|
||||
/// otherwise render one chip per recipient into a single row.
|
||||
/// </summary>
|
||||
private const int MaxVisibleRecipients = 5;
|
||||
|
||||
private bool _loading = true;
|
||||
private string? _errorMessage;
|
||||
private List<NotificationList> _lists = new();
|
||||
@@ -125,6 +147,16 @@
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tooltip for the "+N more" roll-up chip: the recipients that were not given
|
||||
/// a chip of their own, using the same type-aware contact field as the chips.
|
||||
/// </summary>
|
||||
private static string RemainingRecipientsTitle(
|
||||
NotificationList list, IReadOnlyList<NotificationRecipient> recipients) =>
|
||||
string.Join(", ", recipients
|
||||
.Skip(MaxVisibleRecipients)
|
||||
.Select(r => $"{r.Name} — {(list.Type == NotificationType.Sms ? r.PhoneNumber : r.EmailAddress)}"));
|
||||
|
||||
private async Task DeleteList(NotificationList list)
|
||||
{
|
||||
if (!await Dialog.ConfirmAsync("Delete", $"Delete notification list '{list.Name}'?", danger: true))
|
||||
|
||||
+39
-13
@@ -153,7 +153,9 @@
|
||||
<td>@n.Type</td>
|
||||
<td>@n.ListName</td>
|
||||
<td>
|
||||
@n.Subject
|
||||
@* Subject is script-authored free text, so it is bounded
|
||||
the same way the Last error line below it is. *@
|
||||
<span class="cell-clip" title="@n.Subject">@n.Subject</span>
|
||||
@if (!string.IsNullOrEmpty(n.LastError))
|
||||
{
|
||||
<div class="small text-danger text-truncate" style="max-width: 320px;"
|
||||
@@ -176,22 +178,24 @@
|
||||
@* NotificationId is the audit
|
||||
CorrelationId, so the link deep-links into the central Audit
|
||||
Log pre-filtered to this notification's lifecycle events. *@
|
||||
<a class="btn btn-outline-secondary btn-sm me-1"
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<a class="btn btn-outline-secondary"
|
||||
href="/audit/log?correlationId=@n.NotificationId"
|
||||
data-test="audit-link-@n.NotificationId">
|
||||
View audit history
|
||||
</a>
|
||||
@if (n.Status == "Parked")
|
||||
{
|
||||
<button class="btn btn-outline-success btn-sm me-1"
|
||||
<button class="btn btn-outline-success"
|
||||
@onclick="() => RetryNotification(n)" disabled="@_actionInProgress">
|
||||
Retry
|
||||
</button>
|
||||
<button class="btn btn-outline-danger btn-sm"
|
||||
<button class="btn btn-outline-danger"
|
||||
@onclick="() => DiscardNotification(n)" disabled="@_actionInProgress">
|
||||
Discard
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@@ -209,10 +213,14 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
@* ── Row detail modal ── *@
|
||||
@if (_detailNotification != null)
|
||||
@* ── Row detail modal ──
|
||||
The modal holds only the row's NotificationId and re-resolves the summary from
|
||||
the page currently on screen on every render (DetailRow()). A refresh that
|
||||
replaces the row list therefore never leaves this surface rendering a stale
|
||||
record, and a row that has left the page closes the modal instead of stranding
|
||||
it. *@
|
||||
@if (DetailRow() is { } d)
|
||||
{
|
||||
var d = _detailNotification;
|
||||
<div class="modal show d-block sb-modal-backdrop" tabindex="-1"
|
||||
@onclick="CloseDetail">
|
||||
<div class="modal-dialog modal-dialog-scrollable modal-lg" @onclick:stopPropagation="true">
|
||||
@@ -334,14 +342,16 @@
|
||||
<div class="modal-footer">
|
||||
@if (d.Status == "Parked")
|
||||
{
|
||||
<button class="btn btn-outline-success btn-sm"
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button class="btn btn-outline-success"
|
||||
@onclick="() => RetryFromDetail(d)" disabled="@_actionInProgress">
|
||||
Retry
|
||||
</button>
|
||||
<button class="btn btn-outline-danger btn-sm"
|
||||
<button class="btn btn-outline-danger"
|
||||
@onclick="() => DiscardFromDetail(d)" disabled="@_actionInProgress">
|
||||
Discard
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="CloseDetail">Close</button>
|
||||
</div>
|
||||
@@ -370,12 +380,28 @@
|
||||
private string? _listError;
|
||||
private bool _actionInProgress;
|
||||
|
||||
// Row detail modal
|
||||
private NotificationSummary? _detailNotification;
|
||||
// Row detail modal. Only the row's IDENTIFIER is held — never the
|
||||
// NotificationSummary record itself — so the modal cannot outlive the row it
|
||||
// was opened for. DetailRow() re-resolves it from the page currently on screen
|
||||
// on every render; a row that has left the page resolves to null and the modal
|
||||
// renders nothing (it self-closes) rather than showing a stale snapshot.
|
||||
private string? _detailNotificationId;
|
||||
private NotificationDetail? _detail;
|
||||
private bool _detailLoading;
|
||||
private string? _detailError;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the row the detail modal is open for from the page currently on
|
||||
/// screen. Returns null when no row is selected, when the list has not loaded,
|
||||
/// or when the selected row is no longer in the page — the modal's visibility
|
||||
/// gate, so a refresh that drops the row closes the surface instead of leaving
|
||||
/// it stranded on a stale record.
|
||||
/// </summary>
|
||||
private NotificationSummary? DetailRow() =>
|
||||
_detailNotificationId is { } id
|
||||
? _notifications?.FirstOrDefault(n => n.NotificationId == id)
|
||||
: null;
|
||||
|
||||
// Filters
|
||||
private string _statusFilter = string.Empty;
|
||||
private string _typeFilter = string.Empty;
|
||||
@@ -544,7 +570,7 @@
|
||||
{
|
||||
// The summary fields render immediately; Body + recipients fill in once the
|
||||
// full-detail fetch completes.
|
||||
_detailNotification = n;
|
||||
_detailNotificationId = n.NotificationId;
|
||||
_detail = null;
|
||||
_detailError = null;
|
||||
_detailLoading = true;
|
||||
@@ -572,7 +598,7 @@
|
||||
|
||||
private void CloseDetail()
|
||||
{
|
||||
_detailNotification = null;
|
||||
_detailNotificationId = null;
|
||||
_detail = null;
|
||||
_detailError = null;
|
||||
_detailLoading = false;
|
||||
|
||||
+8
-2
@@ -53,7 +53,11 @@
|
||||
<div class="col-md-4 text-muted">Messaging Service SID</div>
|
||||
<div class="col-md-8">@(string.IsNullOrWhiteSpace(sms.MessagingServiceSid) ? "(not set)" : sms.MessagingServiceSid)</div>
|
||||
<div class="col-md-4 text-muted">API Base URL</div>
|
||||
<div class="col-md-8">@(string.IsNullOrWhiteSpace(sms.ApiBaseUrl) ? "(provider default)" : sms.ApiBaseUrl)</div>
|
||||
@* An override base URL is an operator-supplied URL with no
|
||||
break opportunities — clip it so it cannot widen the card. *@
|
||||
<div class="col-md-8">
|
||||
<span class="cell-clip cell-clip-lg" title="@sms.ApiBaseUrl">@(string.IsNullOrWhiteSpace(sms.ApiBaseUrl) ? "(provider default)" : sms.ApiBaseUrl)</span>
|
||||
</div>
|
||||
<div class="col-md-4 text-muted">Auth Token</div>
|
||||
<div class="col-md-8">@(string.IsNullOrWhiteSpace(sms.AuthToken) ? "(not set)" : "(stored)")</div>
|
||||
<div class="col-md-4 text-muted">Connection Timeout</div>
|
||||
@@ -122,12 +126,14 @@
|
||||
<div class="col-12"><div class="text-danger small">@_formError</div></div>
|
||||
}
|
||||
<div class="col-12 text-end">
|
||||
<button class="btn btn-outline-secondary me-1" @onclick="CancelForm">Cancel</button>
|
||||
<div class="btn-group" role="group">
|
||||
<button class="btn btn-outline-secondary" @onclick="CancelForm">Cancel</button>
|
||||
<button class="btn btn-success" @onclick="Save">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (_smsConfigs.Count == 0)
|
||||
{
|
||||
|
||||
+24
-7
@@ -38,7 +38,10 @@
|
||||
{
|
||||
<div class="card mb-3" @key="smtp.Id">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong>@smtp.Host</strong>
|
||||
@* Under the Ews transport Host is a full EWS endpoint URL, not a
|
||||
host name — clip the card title so it cannot push the Edit
|
||||
button off the header. *@
|
||||
<strong class="cell-clip cell-clip-lg" title="@smtp.Host">@smtp.Host</strong>
|
||||
@if (_editingSmtp?.Id != smtp.Id || !_showForm)
|
||||
{
|
||||
<button class="btn btn-outline-primary btn-sm" @onclick="() => StartEdit(smtp)">Edit</button>
|
||||
@@ -51,12 +54,16 @@
|
||||
@if (IsEws(smtp.Transport))
|
||||
{
|
||||
<div class="col-md-4 text-muted">EWS URL</div>
|
||||
<div class="col-md-8">@smtp.Host</div>
|
||||
<div class="col-md-8">
|
||||
<span class="cell-clip cell-clip-lg" title="@smtp.Host">@smtp.Host</span>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="col-md-4 text-muted">Host</div>
|
||||
<div class="col-md-8">@smtp.Host:@smtp.Port</div>
|
||||
<div class="col-md-8">
|
||||
<span class="cell-clip cell-clip-lg" title="@($"{smtp.Host}:{smtp.Port}")">@smtp.Host:@smtp.Port</span>
|
||||
</div>
|
||||
}
|
||||
<div class="col-md-4 text-muted">Auth Type</div>
|
||||
<div class="col-md-8"><span class="badge bg-secondary">@smtp.AuthType</span></div>
|
||||
@@ -66,15 +73,23 @@
|
||||
<div class="col-md-8">@(string.IsNullOrWhiteSpace(smtp.TlsMode) ? "(not set)" : smtp.TlsMode)</div>
|
||||
}
|
||||
<div class="col-md-4 text-muted">From Address</div>
|
||||
<div class="col-md-8">@smtp.FromAddress</div>
|
||||
<div class="col-md-8">
|
||||
<span class="cell-clip" title="@smtp.FromAddress">@smtp.FromAddress</span>
|
||||
</div>
|
||||
<div class="col-md-4 text-muted">Credentials</div>
|
||||
<div class="col-md-8">@(string.IsNullOrWhiteSpace(smtp.Credentials) ? "(not set)" : "(stored)")</div>
|
||||
@if (string.Equals(smtp.AuthType, "OAuth2", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
@* Authority/Scope are operator-supplied URLs/URIs with no
|
||||
break opportunities — clipped for the same reason. *@
|
||||
<div class="col-md-4 text-muted">OAuth2 Authority</div>
|
||||
<div class="col-md-8">@(string.IsNullOrWhiteSpace(smtp.OAuth2Authority) ? "(M365 default)" : smtp.OAuth2Authority)</div>
|
||||
<div class="col-md-8">
|
||||
<span class="cell-clip cell-clip-lg" title="@smtp.OAuth2Authority">@(string.IsNullOrWhiteSpace(smtp.OAuth2Authority) ? "(M365 default)" : smtp.OAuth2Authority)</span>
|
||||
</div>
|
||||
<div class="col-md-4 text-muted">OAuth2 Scope</div>
|
||||
<div class="col-md-8">@(string.IsNullOrWhiteSpace(smtp.OAuth2Scope) ? "(M365 default)" : smtp.OAuth2Scope)</div>
|
||||
<div class="col-md-8">
|
||||
<span class="cell-clip cell-clip-lg" title="@smtp.OAuth2Scope">@(string.IsNullOrWhiteSpace(smtp.OAuth2Scope) ? "(M365 default)" : smtp.OAuth2Scope)</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -152,12 +167,14 @@
|
||||
<div class="col-12"><div class="text-danger small">@_formError</div></div>
|
||||
}
|
||||
<div class="col-12 text-end">
|
||||
<button class="btn btn-outline-secondary me-1" @onclick="CancelForm">Cancel</button>
|
||||
<div class="btn-group" role="group">
|
||||
<button class="btn btn-outline-secondary" @onclick="CancelForm">Cancel</button>
|
||||
<button class="btn btn-success" @onclick="Save">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (_smtpConfigs.Count == 0)
|
||||
{
|
||||
|
||||
+19
-9
@@ -30,7 +30,6 @@
|
||||
|
||||
<div class="d-flex align-items-baseline flex-wrap mb-3">
|
||||
<h4 class="mb-0 me-3">Secured Writes</h4>
|
||||
<span class="text-muted small">Two-person MxGateway writes — operator submits, a different verifier approves.</span>
|
||||
</div>
|
||||
|
||||
@* ── Operator region: submit form ───────────────────────────────────── *@
|
||||
@@ -142,8 +141,11 @@
|
||||
<tr @key="row.Id" data-test="secured-write-pending-row">
|
||||
<td>@row.SiteId</td>
|
||||
<td>@row.ConnectionName</td>
|
||||
<td><code>@row.TagPath</code></td>
|
||||
<td>@row.ValueJson</td>
|
||||
@* Tag path and value are device/operator supplied and unbounded.
|
||||
.cell-clip is display:block, so it goes on an inner element —
|
||||
putting it on the <td> would drop the cell out of the row. *@
|
||||
<td><code class="cell-clip" title="@row.TagPath">@row.TagPath</code></td>
|
||||
<td><span class="cell-clip" title="@row.ValueJson">@row.ValueJson</span></td>
|
||||
<td>@row.ValueType</td>
|
||||
<td>@row.OperatorUser</td>
|
||||
<td class="text-nowrap">@row.SubmittedAtUtc.ToString("u")</td>
|
||||
@@ -152,18 +154,20 @@
|
||||
{
|
||||
<span class="text-muted small me-2" data-test="secured-write-own-note">your submission</span>
|
||||
}
|
||||
<button class="btn btn-outline-success btn-sm me-1"
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button class="btn btn-outline-success"
|
||||
@onclick="() => Approve(row)"
|
||||
disabled="@(isOwn || _actionInProgress)"
|
||||
data-test="secured-write-approve">
|
||||
Approve
|
||||
</button>
|
||||
<button class="btn btn-outline-danger btn-sm"
|
||||
<button class="btn btn-outline-danger"
|
||||
@onclick="() => Reject(row)"
|
||||
disabled="@(isOwn || _actionInProgress)"
|
||||
data-test="secured-write-reject">
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@@ -207,15 +211,21 @@
|
||||
<tr @key="row.Id" data-test="secured-write-history-row">
|
||||
<td>@row.SiteId</td>
|
||||
<td>@row.ConnectionName</td>
|
||||
<td><code>@row.TagPath</code></td>
|
||||
<td>@row.ValueJson</td>
|
||||
@* 11 columns here, and the three timestamps to the right are
|
||||
text-nowrap — so the free-text cells clip narrow (-sm). *@
|
||||
<td><code class="cell-clip cell-clip-sm" title="@row.TagPath">@row.TagPath</code></td>
|
||||
<td><span class="cell-clip cell-clip-sm" title="@row.ValueJson">@row.ValueJson</span></td>
|
||||
<td><span class="badge @StatusBadgeClass(row.Status)">@row.Status</span></td>
|
||||
<td>@row.OperatorUser</td>
|
||||
<td>@(row.VerifierUser ?? "—")</td>
|
||||
<td class="text-nowrap">@row.SubmittedAtUtc.ToString("u")</td>
|
||||
<td class="text-nowrap">@(row.DecidedAtUtc?.ToString("u") ?? "—")</td>
|
||||
<td class="text-nowrap">@(row.ExecutedAtUtc?.ToString("u") ?? "—")</td>
|
||||
<td class="text-danger small">@(row.ExecutionError ?? "")</td>
|
||||
@* Raw device/MxGateway exception text in the last column — clamp to
|
||||
two lines so one bad message can't set the whole table's width. *@
|
||||
<td class="text-danger small">
|
||||
<span class="cell-clamp-2" title="@row.ExecutionError">@(row.ExecutionError ?? "")</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
@@ -229,7 +239,7 @@
|
||||
Showing @(_historyPage * HistoryPageSize + 1)–@Math.Min((_historyPage + 1) * HistoryPageSize, _historyTotalCount)
|
||||
of @_historyTotalCount
|
||||
</span>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button class="btn btn-outline-secondary" @onclick="HistoryPrev"
|
||||
disabled="@(_historyPage == 0)" data-test="secured-write-history-prev">Prev</button>
|
||||
<button class="btn btn-outline-secondary" @onclick="HistoryNext"
|
||||
|
||||
+20
-9
@@ -156,7 +156,10 @@
|
||||
<td><span class="small">@SiteName(c.SourceSite)</span></td>
|
||||
<td><span class="small">@(c.SourceNode ?? "—")</span></td>
|
||||
<td>@c.Channel</td>
|
||||
<td>@c.Target</td>
|
||||
@* Target is an external-system URL/method the site supplies —
|
||||
free text this page does not control, so it is bounded the
|
||||
same way the Last error cell below is. *@
|
||||
<td><span class="cell-clip" title="@c.Target">@c.Target</span></td>
|
||||
<td>
|
||||
<span class="badge @StatusBadgeClass(c.Status)">@c.Status</span>
|
||||
@if (c.IsStuck)
|
||||
@@ -182,7 +185,8 @@
|
||||
@* The TrackedOperationId is the audit CorrelationId, so the
|
||||
link deep-links into the central Audit Log pre-filtered to
|
||||
this cached call's lifecycle events. *@
|
||||
<a class="btn btn-outline-secondary btn-sm me-1"
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<a class="btn btn-outline-secondary"
|
||||
href="/audit/log?correlationId=@c.TrackedOperationId"
|
||||
data-test="audit-link-@c.TrackedOperationId">
|
||||
View audit history
|
||||
@@ -192,15 +196,16 @@
|
||||
actionable from central. *@
|
||||
@if (c.Status == "Parked")
|
||||
{
|
||||
<button class="btn btn-outline-success btn-sm me-1"
|
||||
<button class="btn btn-outline-success"
|
||||
@onclick="() => RetrySiteCall(c)" disabled="@_actionInProgress">
|
||||
Retry
|
||||
</button>
|
||||
<button class="btn btn-outline-danger btn-sm"
|
||||
<button class="btn btn-outline-danger"
|
||||
@onclick="() => DiscardSiteCall(c)" disabled="@_actionInProgress">
|
||||
Discard
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@@ -284,10 +289,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── Row detail modal ── *@
|
||||
@if (_detailSiteCall != null)
|
||||
@* ── Row detail modal ──
|
||||
The modal holds only the row's TrackedOperationId and re-resolves the summary
|
||||
from the page currently on screen on every render (DetailRow()). A refresh that
|
||||
replaces the row list therefore never leaves this surface rendering a stale
|
||||
record, and a row that has left the page closes the modal instead of stranding
|
||||
it. *@
|
||||
@if (DetailRow() is { } d)
|
||||
{
|
||||
var d = _detailSiteCall;
|
||||
<div class="modal show d-block sb-modal-backdrop" tabindex="-1"
|
||||
@onclick="CloseDetail">
|
||||
<div class="modal-dialog modal-dialog-scrollable modal-lg" @onclick:stopPropagation="true">
|
||||
@@ -372,14 +381,16 @@
|
||||
<div class="modal-footer">
|
||||
@if (d.Status == "Parked")
|
||||
{
|
||||
<button class="btn btn-outline-success btn-sm"
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button class="btn btn-outline-success"
|
||||
@onclick="() => RetryFromDetail(d)" disabled="@_actionInProgress">
|
||||
Retry
|
||||
</button>
|
||||
<button class="btn btn-outline-danger btn-sm"
|
||||
<button class="btn btn-outline-danger"
|
||||
@onclick="() => DiscardFromDetail(d)" disabled="@_actionInProgress">
|
||||
Discard
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="CloseDetail">Close</button>
|
||||
</div>
|
||||
|
||||
+20
-4
@@ -82,8 +82,12 @@ public partial class SiteCallsReport
|
||||
private (DateTime? AfterCreatedAtUtc, Guid? AfterId) _currentCursor = (null, null);
|
||||
private (DateTime? AfterCreatedAtUtc, Guid? AfterId)? _nextCursor;
|
||||
|
||||
// Row detail modal
|
||||
private SiteCallSummary? _detailSiteCall;
|
||||
// Row detail modal. Only the row's IDENTIFIER is held — never the
|
||||
// SiteCallSummary record itself — so the modal cannot outlive the row it was
|
||||
// opened for. DetailRow() re-resolves it from the page currently on screen on
|
||||
// every render; a row that has left the page resolves to null and the modal
|
||||
// renders nothing (it self-closes) rather than showing a stale snapshot.
|
||||
private Guid? _detailSiteCallId;
|
||||
private SiteCallDetail? _detail;
|
||||
private bool _detailLoading;
|
||||
private string? _detailError;
|
||||
@@ -123,6 +127,18 @@ public partial class SiteCallsReport
|
||||
|
||||
private bool HasNextPage => _nextCursor is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the row the detail modal is open for from the page currently on
|
||||
/// screen. Returns null when no row is selected, when the list has not loaded,
|
||||
/// or when the selected row is no longer in the page — the modal's visibility
|
||||
/// gate, so a refresh that drops the row closes the surface instead of leaving
|
||||
/// it stranded on a stale record.
|
||||
/// </summary>
|
||||
private SiteCallSummary? DetailRow() =>
|
||||
_detailSiteCallId is { } id
|
||||
? _siteCalls?.FirstOrDefault(c => c.TrackedOperationId == id)
|
||||
: null;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
@@ -401,7 +417,7 @@ public partial class SiteCallsReport
|
||||
{
|
||||
// The summary fields render immediately from the grid row; the full detail
|
||||
// (HttpStatus, all timestamps, LastError) fills in once the fetch completes.
|
||||
_detailSiteCall = c;
|
||||
_detailSiteCallId = c.TrackedOperationId;
|
||||
_detail = null;
|
||||
_detailError = null;
|
||||
_detailLoading = true;
|
||||
@@ -429,7 +445,7 @@ public partial class SiteCallsReport
|
||||
|
||||
private void CloseDetail()
|
||||
{
|
||||
_detailSiteCall = null;
|
||||
_detailSiteCallId = null;
|
||||
_detail = null;
|
||||
_detailError = null;
|
||||
_detailLoading = false;
|
||||
|
||||
@@ -278,9 +278,12 @@
|
||||
<div class="mt-3" aria-hidden="true">
|
||||
<svg viewBox="0 0 200 12" preserveAspectRatio="none"
|
||||
style="width:100%; height:10px; border-radius:5px; overflow:hidden;">
|
||||
<rect x="0" y="0" width="20" height="12" fill="#f8d7da" />
|
||||
<rect x="20" y="0" width="160" height="12" fill="#d1e7dd" />
|
||||
<rect x="180" y="0" width="20" height="12" fill="#f8d7da" />
|
||||
@* Token-driven fills, not literal hex: this preview renders on a
|
||||
#1b1d21 surface under [data-bs-theme="dark"], where Bootstrap's
|
||||
light subtle tints read as bright pastel bars. *@
|
||||
<rect x="0" y="0" width="20" height="12" fill="var(--bad-bg)" />
|
||||
<rect x="20" y="0" width="160" height="12" fill="var(--ok-bg)" />
|
||||
<rect x="180" y="0" width="20" height="12" fill="var(--bad-bg)" />
|
||||
</svg>
|
||||
<div class="d-flex justify-content-between small text-muted mt-1">
|
||||
<span>alarm</span>
|
||||
|
||||
@@ -25,10 +25,12 @@
|
||||
</div>
|
||||
|
||||
<div class="modal-footer px-0 pb-0 mt-3">
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="() => Context.Cancel()">Cancel</button>
|
||||
<button class="btn btn-primary btn-sm" @onclick="Submit"
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button class="btn btn-outline-secondary" @onclick="() => Context.Cancel()">Cancel</button>
|
||||
<button class="btn btn-primary" @onclick="Submit"
|
||||
disabled="@(_parentTemplateId == 0 || string.IsNullOrWhiteSpace(_slotName))">Compose</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <summary>Host-supplied context used to close (with the chosen parent + slot) or cancel.</summary>
|
||||
|
||||
@@ -47,13 +47,15 @@
|
||||
@if (state.Kind != DialogKind.Custom)
|
||||
{
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="Cancel">Cancel</button>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="Cancel">Cancel</button>
|
||||
<button type="button"
|
||||
class="btn @(state.Danger ? "btn-danger" : "btn-primary") btn-sm"
|
||||
class="btn @(state.Danger ? "btn-danger" : "btn-primary")"
|
||||
@onclick="Confirm">
|
||||
@ConfirmLabel(state)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,16 +5,20 @@
|
||||
|
||||
@if (ShowToolbar)
|
||||
{
|
||||
<div class="d-flex justify-content-end align-items-center gap-3 mb-1 small text-muted">
|
||||
<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none" @onclick="FormatAsync"
|
||||
@* Four editor-state toggles: actions on the editor, not navigation — so they
|
||||
are outline buttons in one segmented group, not link buttons. *@
|
||||
<div class="d-flex justify-content-end align-items-center mb-1">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="FormatAsync"
|
||||
title="Format document (Ctrl/Cmd+Shift+F)">Format</button>
|
||||
<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none" @onclick="ToggleWrap"
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="ToggleWrap"
|
||||
title="Word wrap">@(_wrap ? "Wrap on" : "Wrap off")</button>
|
||||
<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none" @onclick="ToggleMinimap"
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="ToggleMinimap"
|
||||
title="Toggle minimap">@(_minimap ? "Minimap on" : "Minimap off")</button>
|
||||
<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none" @onclick="ToggleTheme"
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="ToggleTheme"
|
||||
title="Toggle theme">@(_dark ? "Dark" : "Light")</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div @ref="_hostRef" class="monaco-editor-host"
|
||||
|
||||
+4
-2
@@ -31,10 +31,12 @@ else
|
||||
}
|
||||
|
||||
<div class="modal-footer px-0 pb-0 mt-3">
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="() => Context.Cancel()" disabled="@_busy">Cancel</button>
|
||||
<button class="btn btn-primary btn-sm" @onclick="Submit"
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button class="btn btn-outline-secondary" @onclick="() => Context.Cancel()" disabled="@_busy">Cancel</button>
|
||||
<button class="btn btn-primary" @onclick="Submit"
|
||||
disabled="@(_busy || !SiteOptions.Any())">Move</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <summary>Host-supplied context: Close(true) on a successful move, Cancel on dismiss.</summary>
|
||||
|
||||
@@ -15,8 +15,10 @@
|
||||
</select>
|
||||
|
||||
<div class="modal-footer px-0 pb-0 mt-3">
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="() => Context.Cancel()">Cancel</button>
|
||||
<button class="btn btn-primary btn-sm" @onclick="Submit">Move</button>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button class="btn btn-outline-secondary" @onclick="() => Context.Cancel()">Cancel</button>
|
||||
<button class="btn btn-primary" @onclick="Submit">Move</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
|
||||
@@ -13,8 +13,10 @@
|
||||
</select>
|
||||
|
||||
<div class="modal-footer px-0 pb-0 mt-3">
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="() => Context.Cancel()">Cancel</button>
|
||||
<button class="btn btn-primary btn-sm" @onclick="Submit">Move</button>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button class="btn btn-outline-secondary" @onclick="() => Context.Cancel()">Cancel</button>
|
||||
<button class="btn btn-primary" @onclick="Submit">Move</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
|
||||
@@ -9,10 +9,12 @@
|
||||
<input class="form-control form-control-sm" @bind="_name" @bind:event="oninput" />
|
||||
|
||||
<div class="modal-footer px-0 pb-0 mt-3">
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="() => Context.Cancel()">Cancel</button>
|
||||
<button class="btn btn-primary btn-sm" @onclick="Submit"
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button class="btn btn-outline-secondary" @onclick="() => Context.Cancel()">Cancel</button>
|
||||
<button class="btn btn-primary" @onclick="Submit"
|
||||
disabled="@string.IsNullOrWhiteSpace(_name)">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <summary>Host-supplied context used to close (with the trimmed name) or cancel.</summary>
|
||||
|
||||
@@ -135,7 +135,7 @@ else
|
||||
</div>
|
||||
|
||||
<button type="button"
|
||||
class="btn btn-link btn-sm text-danger p-0 ms-auto"
|
||||
class="btn btn-sm btn-outline-danger ms-auto"
|
||||
title="Remove" aria-label="Remove field"
|
||||
@onclick="() => RemoveProperty(parent, prop)">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
|
||||
@@ -6,9 +6,70 @@
|
||||
|
||||
/* ── App-level tokens (light defaults) ─────────────────────────────────────
|
||||
--sb-backdrop: semi-transparent overlay used by DialogHost and ad-hoc
|
||||
modal backdrops throughout the app. */
|
||||
modal backdrops throughout the app.
|
||||
--accent: ScadaBridge's product accent. Declared HERE rather than as
|
||||
ThemeShell's Accent="…" parameter: that parameter emits an inline
|
||||
style="--accent: …" on the shell root, which is a descendant of <html> and
|
||||
therefore beat the [data-bs-theme="dark"] override below for the whole app
|
||||
— the dark accent was dead. As a :root declaration it loses to the dark
|
||||
block (equal specificity, later in this file) and wins over theme.css
|
||||
(equal specificity, earlier sheet), so both schemes resolve correctly.
|
||||
The value matches theme.css's own light default, so light mode is
|
||||
unchanged. */
|
||||
:root {
|
||||
--sb-backdrop: rgba(0, 0, 0, 0.4);
|
||||
--accent: #2f5fd0;
|
||||
}
|
||||
|
||||
/* Button sizing lives in ZB.MOM.WW.Theme's layout.css as of 0.4.0 (the local
|
||||
copy that used to sit here was upstreamed verbatim), so there is deliberately
|
||||
no `.btn` rule in this sheet. Do not reintroduce one — and if you ever need
|
||||
to, override the CSS *variables* only, never the box properties, because
|
||||
.btn-group's seam and border-radius machinery is built on them. */
|
||||
|
||||
/* Bootstrap ships .form-label and .col-form-label-sm but no .form-label-sm,
|
||||
so the small-label intent next to a form-select-sm/form-control-sm was a
|
||||
silent no-op wherever it was used. Give it the rule it always implied. */
|
||||
.form-label-sm {
|
||||
font-size: .8rem;
|
||||
margin-bottom: .2rem;
|
||||
}
|
||||
|
||||
/* ── Table cell containment ─────────────────────────────────────────────────
|
||||
Text this app does not control — remote exception messages, X.509 DNs, OPC UA
|
||||
node ids, serialized payloads, bundle-supplied names — must never be allowed
|
||||
to set a table's width. A single long value otherwise pushes the actions
|
||||
column off-screen exactly when an operator most needs it. Apply .cell-clip
|
||||
(optionally with a width modifier) to the cell or an inner block, and ALWAYS
|
||||
pair it with a title="…" so the full value stays reachable; where a fuller
|
||||
view exists, keep it in the row's detail drawer/dialog too.
|
||||
Modelled on the existing .parked-error-clamp pattern in ParkedMessages. */
|
||||
.cell-clip {
|
||||
display: block;
|
||||
max-width: 22rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cell-clip-sm { max-width: 11rem; }
|
||||
.cell-clip-lg { max-width: 34rem; }
|
||||
|
||||
/* Two-line clamp for message-shaped text that deserves more than one line. */
|
||||
.cell-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
max-width: 34rem;
|
||||
}
|
||||
|
||||
/* For <pre>/<code> detail blocks: wrap instead of forcing horizontal scroll. */
|
||||
.detail-pre {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Reusable backdrop class — applied by DialogHost and standalone modal
|
||||
|
||||
@@ -43,7 +43,7 @@ public class LdapMappingCrudTests
|
||||
// violation. The LdapMappingForm.razor uses Blazor @bind, not <form>,
|
||||
// so the "Role" label's parent div.mb-2 is the tightest stable scope.
|
||||
await page.Locator("div.mb-2:has(label:has-text('Role')) .form-select.form-select-sm").SelectOptionAsync("Designer");
|
||||
await page.ClickAsync("button.btn.btn-success.btn-sm:has-text('Save')");
|
||||
await page.ClickAsync("button.btn.btn-success:has-text('Save')");
|
||||
|
||||
// Wait for Blazor enhanced navigation back to the list page.
|
||||
await PlaywrightFixture.WaitForPathAsync(page, "/admin/ldap-mappings", excludePath: "/create");
|
||||
@@ -62,7 +62,7 @@ public class LdapMappingCrudTests
|
||||
|
||||
// Change the role to Viewer and save.
|
||||
await page.Locator("div.mb-2:has(label:has-text('Role')) .form-select.form-select-sm").SelectOptionAsync("Viewer");
|
||||
await page.ClickAsync("button.btn.btn-success.btn-sm:has-text('Save')");
|
||||
await page.ClickAsync("button.btn.btn-success:has-text('Save')");
|
||||
|
||||
await PlaywrightFixture.WaitForPathAsync(page, "/admin/ldap-mappings", excludePath: "/edit");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
@@ -148,7 +148,7 @@ public class LdapMappingCrudTests
|
||||
// FIRST in SaveMapping) is unambiguously the one that fires.
|
||||
await page.Locator("div.mb-2:has(label:has-text('Role')) .form-select.form-select-sm")
|
||||
.SelectOptionAsync("Designer");
|
||||
await page.ClickAsync("button.btn.btn-success.btn-sm:has-text('Save')");
|
||||
await page.ClickAsync("button.btn.btn-success:has-text('Save')");
|
||||
|
||||
await Assertions.Expect(page.Locator("div.text-danger.small.mt-2"))
|
||||
.ToHaveTextAsync("LDAP Group Name is required.");
|
||||
@@ -162,7 +162,7 @@ public class LdapMappingCrudTests
|
||||
.FillAsync(group);
|
||||
await page.Locator("div.mb-2:has(label:has-text('Role')) .form-select.form-select-sm")
|
||||
.SelectOptionAsync(new[] { "" });
|
||||
await page.ClickAsync("button.btn.btn-success.btn-sm:has-text('Save')");
|
||||
await page.ClickAsync("button.btn.btn-success:has-text('Save')");
|
||||
|
||||
await Assertions.Expect(page.Locator("div.text-danger.small.mt-2"))
|
||||
.ToHaveTextAsync("Role is required.");
|
||||
@@ -200,7 +200,7 @@ public class LdapMappingCrudTests
|
||||
.FillAsync(group);
|
||||
await page.Locator("div.mb-2:has(label:has-text('Role')) .form-select.form-select-sm")
|
||||
.SelectOptionAsync("Viewer");
|
||||
await page.ClickAsync("button.btn.btn-success.btn-sm:has-text('Save')");
|
||||
await page.ClickAsync("button.btn.btn-success:has-text('Save')");
|
||||
|
||||
// The catch block prefixes the raw DB message with "Save failed:" — assert
|
||||
// only on that stable prefix, never the provider-specific tail.
|
||||
|
||||
@@ -117,7 +117,7 @@ public class M9SurfaceTests
|
||||
|
||||
// ── CREATE ────────────────────────────────────────────────────────────────────
|
||||
await nameInput.FillAsync(name);
|
||||
await page.ClickAsync("button.btn.btn-success.btn-sm:has-text('Save')");
|
||||
await page.ClickAsync("button.btn.btn-success:has-text('Save')");
|
||||
|
||||
// Success toast fires and the row appears in the table with its lib: reference.
|
||||
await Assertions.Expect(page.Locator(".toast", new() { HasText = "created" }))
|
||||
|
||||
@@ -79,8 +79,10 @@ public class TemplateCrudTests : IClassFixture<DeploymentFixture>
|
||||
// The select is label-anchored: <label>Data Type</label> + <select class="form-select">.
|
||||
await modal.Locator("div.col-12:has(label:has-text('Data Type')) select.form-select").SelectOptionAsync("Double");
|
||||
|
||||
// Click the footer Add button (btn-success btn-sm, text "Add").
|
||||
await modal.Locator(".modal-footer button.btn-success.btn-sm:has-text('Add')").ClickAsync();
|
||||
// Click the footer Add button (btn-success, text "Add"). The dialog feet were
|
||||
// wrapped in a btn-group btn-group-sm by the 2026-08-11 UI sweep, so the size
|
||||
// class now lives on the group rather than on the button itself.
|
||||
await modal.Locator(".modal-footer button.btn-success:has-text('Add')").ClickAsync();
|
||||
|
||||
// The modal should dismiss and the attribute table should show "Val".
|
||||
await Assertions.Expect(modal).ToHaveCountAsync(0, new() { Timeout = 10_000 });
|
||||
@@ -89,7 +91,7 @@ public class TemplateCrudTests : IClassFixture<DeploymentFixture>
|
||||
|
||||
// ── DELETE ────────────────────────────────────────────────────────────────
|
||||
// Click the header Delete button (btn-outline-danger btn-sm, text "Delete").
|
||||
await page.ClickAsync("button.btn.btn-outline-danger.btn-sm:has-text('Delete')");
|
||||
await page.ClickAsync("button.btn.btn-outline-danger:has-text('Delete')");
|
||||
|
||||
// The global DialogHost renders a confirm dialog.
|
||||
// The danger confirm button carries class "btn-danger" and text "Delete".
|
||||
@@ -258,8 +260,9 @@ public class TemplateCrudTests : IClassFixture<DeploymentFixture>
|
||||
var valueInput = modal.Locator("div.col-12:has(label:has-text('Value')) input.form-control");
|
||||
await valueInput.FillAsync("42.5");
|
||||
|
||||
// Footer button text is "Save" when editing (vs "Add" when adding).
|
||||
await modal.Locator(".modal-footer button.btn-success.btn-sm:has-text('Save')").ClickAsync();
|
||||
// Footer button text is "Save" when editing (vs "Add" when adding). Size class
|
||||
// sits on the enclosing btn-group-sm since the 2026-08-11 UI sweep.
|
||||
await modal.Locator(".modal-footer button.btn-success:has-text('Save')").ClickAsync();
|
||||
|
||||
// SaveAttribute persists then reloads, dismissing the modal.
|
||||
await Assertions.Expect(modal).ToHaveCountAsync(0, new() { Timeout = 10_000 });
|
||||
@@ -306,7 +309,7 @@ public class TemplateCrudTests : IClassFixture<DeploymentFixture>
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
// Header Delete button (btn-outline-danger btn-sm).
|
||||
await page.ClickAsync("button.btn.btn-outline-danger.btn-sm:has-text('Delete')");
|
||||
await page.ClickAsync("button.btn.btn-outline-danger:has-text('Delete')");
|
||||
|
||||
// Confirm via the global DialogHost modal's danger button (labelled
|
||||
// "Delete"). This modal HAS the `fade` class — distinct from the
|
||||
|
||||
@@ -130,7 +130,7 @@ public class SiteCrudTests
|
||||
await grpcInputs.Nth(0).FillAsync("http://zz:8083");
|
||||
await grpcInputs.Nth(1).FillAsync("http://zz:8083");
|
||||
|
||||
await page.ClickAsync("button.btn.btn-success.btn-sm:has-text('Save')");
|
||||
await page.ClickAsync("button.btn.btn-success:has-text('Save')");
|
||||
|
||||
// Wait for Blazor enhanced navigation back to the list page.
|
||||
await PlaywrightFixture.WaitForPathAsync(page, "/admin/sites", excludePath: "/create");
|
||||
@@ -149,7 +149,7 @@ public class SiteCrudTests
|
||||
|
||||
// Update the description and save.
|
||||
await page.Locator("label:has-text('Description') + input.form-control.form-control-sm").FillAsync("e2e-edited");
|
||||
await page.ClickAsync("button.btn.btn-success.btn-sm:has-text('Save')");
|
||||
await page.ClickAsync("button.btn.btn-success:has-text('Save')");
|
||||
|
||||
await PlaywrightFixture.WaitForPathAsync(page, "/admin/sites", excludePath: "/edit");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
@@ -222,7 +222,7 @@ public class SiteCrudTests
|
||||
await page.Locator("label:has-text('Identifier') + input.form-control.form-control-sm").FillAsync("site-a");
|
||||
await page.Locator("label:has-text('Name') + input.form-control.form-control-sm").FillAsync(distinctName);
|
||||
|
||||
await page.ClickAsync("button.btn.btn-success.btn-sm:has-text('Save')");
|
||||
await page.ClickAsync("button.btn.btn-success:has-text('Save')");
|
||||
|
||||
// The inline error surface must report the failed save and we must stay on /create.
|
||||
// The full message is a raw DbUpdateException — assert only the "Save failed" prefix.
|
||||
@@ -260,7 +260,7 @@ public class SiteCrudTests
|
||||
await page.Locator("label:has-text('Identifier') + input.form-control.form-control-sm").FillAsync(ident);
|
||||
await page.Locator("label:has-text('Name') + input.form-control.form-control-sm").FillAsync(name);
|
||||
|
||||
await page.ClickAsync("button.btn.btn-success.btn-sm:has-text('Save')");
|
||||
await page.ClickAsync("button.btn.btn-success:has-text('Save')");
|
||||
await PlaywrightFixture.WaitForPathAsync(page, "/admin/sites", excludePath: "/create");
|
||||
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||
|
||||
|
||||
+1
-1
@@ -100,7 +100,7 @@ public class NodeBrowserDialogSearchTests : BunitContext
|
||||
// Click the first result's link — this drives the SAME selection
|
||||
// mechanism the tree uses, so the footer Select button confirms and
|
||||
// OnSelected fires.
|
||||
cut.FindAll("[data-test=node-search-result] a")[0].Click();
|
||||
cut.FindAll("[data-test=node-search-result] button.btn-link")[0].Click();
|
||||
cut.Find(".modal-footer .btn-primary").Click();
|
||||
|
||||
Assert.Equal("ns=2;s=Pump1.Speed", _capturedSelection());
|
||||
|
||||
+5
-3
@@ -46,7 +46,7 @@ public class NodeBrowserDialogSelectionTests : BunitContext
|
||||
|
||||
cut.Find("[data-test=node-search-input]").Input("Pump1");
|
||||
cut.Find("[data-test=node-search-button]").Click();
|
||||
cut.FindAll("[data-test=node-search-result] a")[0].Click();
|
||||
cut.FindAll("[data-test=node-search-result] button.btn-link")[0].Click();
|
||||
cut.Find(".modal-footer .btn-primary").Click();
|
||||
|
||||
Assert.NotNull(captured);
|
||||
@@ -90,8 +90,10 @@ public class NodeBrowserDialogSelectionTests : BunitContext
|
||||
cut.InvokeAsync(() => cut.Instance.ShowAsync("plant-a", "PLC-MX", null));
|
||||
cut.Render();
|
||||
|
||||
// Click the tree node's select link (tree path, not search).
|
||||
cut.Find(".node-browser-tree a").Click();
|
||||
// Click the tree node's select control (tree path, not search). It is a
|
||||
// <button class="btn btn-link"> since the 2026-08-11 UI sweep replaced the
|
||||
// href="javascript:void(0)" anchor; both click handlers were preserved.
|
||||
cut.Find(".node-browser-tree button.btn-link").Click();
|
||||
cut.Find(".modal-footer .btn-primary").Click();
|
||||
|
||||
Assert.NotNull(captured);
|
||||
|
||||
+3
-4
@@ -175,10 +175,10 @@ public class TransportExportPageTests : BunitContext
|
||||
Assert.NotNull(cut.Find($"[data-testid='{groupId}']"));
|
||||
}
|
||||
|
||||
// The API-keys selection group is gone, replaced by an info note explaining
|
||||
// that keys must be re-created per environment.
|
||||
// The API-keys selection group is gone. The explanatory note that used to sit
|
||||
// beside it moved into docs/requirements/Component-Transport.md (UI cleanup
|
||||
// sweep 2026-08-11) — the absence of the group is the assertion that matters.
|
||||
Assert.Empty(cut.FindAll("[data-testid='group-api-keys']"));
|
||||
Assert.NotNull(cut.Find("[data-testid='api-keys-not-transported']"));
|
||||
|
||||
// Sanity: each artifact shows its label.
|
||||
Assert.Contains("Helpers", cut.Markup);
|
||||
@@ -354,7 +354,6 @@ public class TransportExportPageTests : BunitContext
|
||||
|
||||
// The Sites & Instances group is present alongside the existing artifact groups.
|
||||
Assert.NotNull(cut.Find("[data-testid='group-sites']"));
|
||||
Assert.NotNull(cut.Find("[data-testid='sites-hint']"));
|
||||
Assert.Contains("North Plant", cut.Markup);
|
||||
Assert.Contains("north", cut.Markup);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user