Merge feat/dashboard-ui-cleanup-sweep: admin-UI cleanup pass + ZB.MOM.WW.Theme 0.4.1
The Theme bump is separable from the sweep and lands as its own commit: 0.4.0 upstreamed the button-sizing block the sweep had installed locally, and this app's local .btn rule was trimmed rather than deleted because it also carried border-radius/font-weight/white-space that the kit does not ship.
This commit is contained in:
@@ -0,0 +1,352 @@
|
|||||||
|
# Dashboard UI cleanup sweep (2026-08-11)
|
||||||
|
|
||||||
|
Runs the family admin-UI cleanup playbook (`../scadaproj/admin_ui_cleanup.md`) against the
|
||||||
|
MXAccess Gateway Blazor dashboard. Third app in the family after the ignitionoee router and the
|
||||||
|
OtOpcUa AdminUI.
|
||||||
|
|
||||||
|
## 0. Platform correction — this app is *not* Bootstrap-free
|
||||||
|
|
||||||
|
The umbrella index describes mxaccessgw as the family's Bootstrap-free app. That is wrong, and
|
||||||
|
every Bootstrap-dependent recipe in the playbook applies here unchanged. What actually ships:
|
||||||
|
|
||||||
|
| Layer | Evidence |
|
||||||
|
|---|---|
|
||||||
|
| Bootstrap 5.3.3, self-hosted | `libman.json:5`; `wwwroot/lib/bootstrap/css/bootstrap.min.css` |
|
||||||
|
| Linked first in the head | `Dashboard/Components/App.razor:7` |
|
||||||
|
| `ZB.MOM.WW.Theme` 0.3.1 at discovery, **0.4.0 after §6** | `ZB.MOM.WW.MxGateway.Server.csproj` `<PackageReference>`; `<ThemeHead />` at `App.razor:8` |
|
||||||
|
| App stylesheet, loaded last | `App.razor:9` → `wwwroot/css/site.css` |
|
||||||
|
| Bootstrap JS bundle | `App.razor:14` |
|
||||||
|
|
||||||
|
What CLAUDE.md actually forbids is Blazor **component libraries** (MudBlazor, Radzen, FluentUI) —
|
||||||
|
not Bootstrap's CSS/JS. No Bootstrap was introduced by this sweep.
|
||||||
|
|
||||||
|
Playbook §2 foundation item 1 (*app stylesheet after `<ThemeHead />` so it wins the cascade*) was
|
||||||
|
therefore **already satisfied** before the sweep.
|
||||||
|
|
||||||
|
## 1. Discovery
|
||||||
|
|
||||||
|
### 1a. Foundation / CSS audit
|
||||||
|
|
||||||
|
**Shipped-vs-used class matrix.** Every class in `Dashboard/Components/**/*.razor` checked against
|
||||||
|
theme 0.3.1 `staticwebassets/css/{theme,layout}.css`, `bootstrap.min.css`, and `site.css`. Three
|
||||||
|
classes are defined nowhere:
|
||||||
|
|
||||||
|
| Ghost class | Site | Consequence | Verdict |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `tree-load-status` | `Shared/BrowseTreeNodeView.razor:42`, `:49` | **Real visual defect.** The row's indent spacer is `<span class="tree-toggle tree-toggle-empty">`; `.tree-toggle` sets `flex:none;width:1.1rem`, which only takes effect on a flex item. `.tree-row`/`.tree-attr` are flex; this container is an undefined block, so the span stays inline and `width` is ignored — "⌛ Loading…" and "Failed to load: …" render flush left, out of alignment with every sibling row. | **Define it** |
|
||||||
|
| `browse-stale-banner` | `Pages/BrowsePage.razor:78` | The banner carries `@onclick="ClearStaleBanner"` with no pointer affordance and no styling of its own — a click-to-dismiss control that does not look clickable. | **Define it** |
|
||||||
|
| `tree-node` | `Shared/BrowseTreeNodeView.razor:15` | Structural wrapper only; nothing needs to style it. A no-op, but a deliberate one. | **Leave** |
|
||||||
|
|
||||||
|
(Reported as ghosts by the raw extractor but false positives: `h-100` at `Shared/MetricCard.razor:1`
|
||||||
|
— Bootstrap-defined, mangled by the extractor's handling of the inline `@(...)` class expression.)
|
||||||
|
|
||||||
|
**Scoped-CSS bundle.** N/A — the project contains **zero** `*.razor.css` files, so no
|
||||||
|
`*.bundle.scp.css` is emitted and nothing is missing from the head. (This was OtOpcUa's finding; it
|
||||||
|
does not exist here.)
|
||||||
|
|
||||||
|
**Phantom CSS custom properties.** Zero. `Dashboard/Components/` contains no `var(--…)` at all —
|
||||||
|
tokens are used only from `site.css`, and every one of them (`--ink`, `--ink-soft`, `--ink-faint`,
|
||||||
|
`--card`, `--rule`, `--rule-strong`, `--mono`, `--accent`, `--accent-deep`, `--ok`, `--ok-bg`,
|
||||||
|
`--bad`, `--bad-bg`, `--warn`, `--warn-bg`, `--idle`, `--idle-bg`) resolves against theme 0.3.1.
|
||||||
|
This app has the OtOpcUa-clean result, not the router's.
|
||||||
|
|
||||||
|
**Button sizing — the one real foundation defect.** Theme 0.3.1 ships no `.btn` rule (confirmed:
|
||||||
|
`.btn` appears in `layout.css` only inside a comment). But `site.css:187` does, and it sets
|
||||||
|
`font-size` **directly** rather than through Bootstrap's variable:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.btn { border-radius: 5px; font-size: 0.82rem; font-weight: 500; white-space: nowrap; }
|
||||||
|
```
|
||||||
|
|
||||||
|
Bootstrap renders `.btn { font-size: var(--bs-btn-font-size) }`, and `.btn-sm` /
|
||||||
|
`.btn-group-sm > .btn` size themselves purely by *redefining that variable*
|
||||||
|
(`.btn-sm{--bs-btn-font-size:0.875rem}`). A literal `font-size` on `.btn` at equal specificity,
|
||||||
|
loaded later, wins over the variable-driven declaration for **every** button — so `btn-sm` and
|
||||||
|
`btn-group-sm` are font-size no-ops app-wide and small buttons differ from full-size ones by
|
||||||
|
padding alone. This is the same class of defect the other two apps hit from the opposite
|
||||||
|
direction (no `.btn` rule at all), and it takes the same fix.
|
||||||
|
|
||||||
|
**Dark scheme.** Theme 0.3.1 is light-only; `site.css` makes no `prefers-color-scheme` /
|
||||||
|
`data-bs-theme` claim, and its header comment ("Layers over theme.css … every colour resolves to a
|
||||||
|
theme.css token") is accurate. Nothing to correct.
|
||||||
|
|
||||||
|
### 1b. Button inventory
|
||||||
|
|
||||||
|
`grep -rn "btn-group"` returns **three** — this app already uses the convention where it matters:
|
||||||
|
|
||||||
|
| Site | Members | State |
|
||||||
|
|---|---|---|
|
||||||
|
| `Pages/ApiKeysPage.razor:189` | Rotate / Revoke, or Delete | Correct — `btn-group btn-group-sm`, no per-button `btn-sm`, `@if` inside the group |
|
||||||
|
| `Pages/SessionsPage.razor:90` | Close / Kill | Correct |
|
||||||
|
| `Pages/SessionDetailsPage.razor:34` | Close session / Kill worker | Correct |
|
||||||
|
|
||||||
|
Adjacent related buttons **not** yet grouped (both are feet, the playbook's named case):
|
||||||
|
|
||||||
|
| Site | Members | Fix |
|
||||||
|
|---|---|---|
|
||||||
|
| `Shared/ConfirmDialog.razor:17,22` | Cancel + `@ConfirmButtonClass` confirm, in a `modal-footer` | Wrap in `btn-group` |
|
||||||
|
| `Pages/ApiKeysPage.razor:136,137` | Save (`type="submit"`) + Cancel, in the create-key card body | Wrap in `btn-group btn-group-sm`; fold the two `btn-sm` and drop the `me-1` spacer |
|
||||||
|
|
||||||
|
Refused / not candidates:
|
||||||
|
|
||||||
|
- `Pages/WorkersPage.razor:74` — a lone Kill button. Nothing to group.
|
||||||
|
- `Pages/ApiKeysPage.razor:22` — lone page-head "Create API Key".
|
||||||
|
- `Pages/BrowsePage.razor:109` (`Clear all`) and `:167` (`Remove`) — lone buttons.
|
||||||
|
- `Shared/BrowseTreeNodeView.razor:19` `.tree-toggle` — a bare expander, deliberately unstyled as a
|
||||||
|
button; `MainLayout.razor:32` Sign Out / `:36` Sign In are the theme's `rail-btn`, one per
|
||||||
|
auth branch and mutually exclusive.
|
||||||
|
|
||||||
|
**Row actions styled as links**: none. Every action in the app is already a real `<button>`; the
|
||||||
|
only `<a>` in `Dashboard/Components/` is `MainLayout.razor:36`, which navigates. `btn-link`: zero
|
||||||
|
uses. `·` action separators: zero — the `·` occurrences (`BrowsePage.razor:54,102,106,287`,
|
||||||
|
`BrowseTreeNodeView.razor:71`, `AlarmsPage.razor:220`) all separate *metadata facts* or serve as a
|
||||||
|
bullet glyph, never actions. Inline `width:` sizing hacks: zero. `py-0`: zero.
|
||||||
|
|
||||||
|
**Arm→confirm flows** (restyle-only; the two-step must survive):
|
||||||
|
|
||||||
|
| Page | Flow |
|
||||||
|
|---|---|
|
||||||
|
| `SessionsPage` | Close / Kill → `ConfirmDialog` → `ConfirmPendingAsync` |
|
||||||
|
| `SessionDetailsPage` | Close session / Kill worker → `ConfirmDialog` |
|
||||||
|
| `WorkersPage` | Kill → `ConfirmDialog` |
|
||||||
|
| `ApiKeysPage` | Rotate / Revoke / Delete → `ConfirmDialog`; Create → modal form |
|
||||||
|
|
||||||
|
**Size mismatches within a group**: none.
|
||||||
|
|
||||||
|
### 1c. Prose inventory (DELETE / KEEP / RELOCATE)
|
||||||
|
|
||||||
|
| Site | Text | Verdict |
|
||||||
|
|---|---|---|
|
||||||
|
| `Pages/GalaxyPage.razor:134-138` | "Browse data is served by the `galaxy_repository.v1.GalaxyRepository` gRPC service. Clients call `DiscoverHierarchy` for the full tree and `GetLastDeployTime` to detect redeployments." | **DELETE** — unconditional client-API documentation on an operator page. Every fact is already in `docs/GalaxyRepository.md` (service name at :44, `GetLastDeployTime` at :49, `DiscoverHierarchy` at :50). Plain delete, no relocation needed. |
|
||||||
|
| `Pages/AlarmsPage.razor:151-154` | "Cleared alarms are not retained — this list reflects only alarms currently Active or ActiveAcked, refreshed every 3 seconds." | KEEP — decodes what the list contains and does not contain; the operator cannot infer "cleared alarms are absent" from the data. |
|
||||||
|
| `Pages/AlarmsPage.razor:26-30` | Alarms-disabled banner citing `MxGateway:Alarms:Enabled` | KEEP — conditional state banner. **Config key verified against the options class**: `GatewayOptions.Alarms` → `AlarmsOptions.Enabled`, present in `appsettings.json`. |
|
||||||
|
| `Pages/BrowsePage.razor:33-35`, `:41`, `:120-124` | Empty states | KEEP |
|
||||||
|
| `Pages/BrowsePage.razor:70`, `:90` | "Showing the first N matches — refine the filter." / "Double-click a tag, or right-click for the menu." | KEEP — truncation notice, and the only decoder of two interactions that have no visible affordance. |
|
||||||
|
| `Pages/SessionDetailsPage.razor:115-118` | "Waiting for events. The dashboard mirrors the session's gRPC event stream — events appear here only while a gRPC client is also consuming this session's events." | KEEP — explains an empty state that otherwise reads as a bug. |
|
||||||
|
| `Pages/GalaxyPage.razor:33-38` | Unknown-status empty state | KEEP |
|
||||||
|
|
||||||
|
**Stale-claim hunt** (wrong facts outrank style): none found. No milestone labels ("F8/F9 pending",
|
||||||
|
"Batch 2"), no dead repo hyperlinks, no superseded architecture claims. Every config key cited in
|
||||||
|
markup resolves — `MxGateway:Alarms:Enabled` (above) is the only one. Also checked and **cleared**:
|
||||||
|
`SettingsPage.razor:32` renders `Ldap.ServiceAccountPassword`, but
|
||||||
|
`Configuration/GatewayConfigurationProvider.cs:30` substitutes `RedactedValue` before the snapshot
|
||||||
|
is built, so no credential reaches the page.
|
||||||
|
|
||||||
|
### 1d. Density / layout scan (per page, not per file)
|
||||||
|
|
||||||
|
Structural mitigations already present app-wide: `site.css:137` caps `.dashboard-table td` at
|
||||||
|
`max-width: 26rem` with `overflow-wrap: break-word`, so the OtOpcUa `/hosts` failure mode (one long
|
||||||
|
exception widens the table until the actions column scrolls off) **cannot happen here**. Every table
|
||||||
|
is also inside `.table-responsive`. `panel-head` appears zero times, so the "sections running
|
||||||
|
together" tell does not fire — each group is its own `section.dashboard-section` card already.
|
||||||
|
|
||||||
|
Residual finding — **unbounded free-text columns**. The 26rem cap converts the horizontal blowout
|
||||||
|
into vertical blowout: a multi-line exception makes one row several times taller than its
|
||||||
|
neighbours and pushes the rest of the table off-screen.
|
||||||
|
|
||||||
|
| Site | Column | Bound to |
|
||||||
|
|---|---|---|
|
||||||
|
| `Shared/FaultList.razor:28` | Message | `@fault.Message` — worker/COM fault text, uncontrolled |
|
||||||
|
| `Pages/SessionsPage.razor:86` | Fault | `session.LastFault` |
|
||||||
|
| `Pages/WorkersPage.razor:70` | Fault | `worker.LastFault` |
|
||||||
|
| `Shared/BrowseTreeNodeView.razor:51` | (tree row) | `@Node.LoadError` — **worst case**: `.tree-attr`/`.tree-row` siblings are `white-space: nowrap` inside a fixed-height scroller, so a long browse error stretches the left pane horizontally |
|
||||||
|
| `Pages/DashboardHome.razor:47` | Galaxy panel | `Snapshot.Galaxy.LastError` on the compact overview |
|
||||||
|
|
||||||
|
Deliberately **not** truncated (the full text must stay reachable — playbook rule):
|
||||||
|
`SessionDetailsPage.razor:84` "Last fault" and `GalaxyPage.razor:47` "Last Error" are both
|
||||||
|
full-width rows on the drill-down page each summary links to.
|
||||||
|
|
||||||
|
Not firing: **identity slam** — `SessionsPage.razor:75-81` puts a worker pid next to a status chip,
|
||||||
|
which the playbook explicitly permits ("chips stay with the name line"); no cell renders two
|
||||||
|
identifiers. **Inline full-width expander rows** — zero `colspan` detail rows in the app.
|
||||||
|
|
||||||
|
### 1e. Tree tables
|
||||||
|
|
||||||
|
**No adoption.** `BrowsePage` + `BrowseTreeNodeView` is a lazy-loading nav/picker tree with a
|
||||||
|
context menu and no columns — the rubric's explicit "different animal, leave it alone". No table in
|
||||||
|
the app flattens hierarchy through a path-prefix column, hand-rolled `rowspan`, indent-by-padding,
|
||||||
|
or faked group-header rows; `Sessions`/`Workers`/`Events`/`Alarms`/`Galaxy` are flat fact lists and
|
||||||
|
`Top Templates` is rank-ordered (a tree would destroy load-bearing ordering). Porting the router's
|
||||||
|
`TreeTable` here would create an orphan.
|
||||||
|
|
||||||
|
### Guard tests
|
||||||
|
|
||||||
|
Checked for source-scan tests pinning dashboard markup: none. The only `.razor` reference in the
|
||||||
|
test project is a prose comment (`Gateway/GatewayApplicationTests.cs:116`). Nothing to re-pin.
|
||||||
|
|
||||||
|
## 2. Changes
|
||||||
|
|
||||||
|
### Foundation
|
||||||
|
|
||||||
|
1. **`wwwroot/css/site.css` — button sizing via Bootstrap CSS variables.** Replace the literal
|
||||||
|
`font-size` on `.btn` with `--bs-btn-*` overrides and add the `btn-sm` / `btn-group-sm` block, so
|
||||||
|
the small-button distinction works again. Box properties are *not* redefined wholesale —
|
||||||
|
`border-radius: 5px` stays literal because the three existing `btn-group`s already render seamed
|
||||||
|
under it (`.btn-group > .btn:not(:first-child)` outranks `.btn`). Carries the header comment the
|
||||||
|
other two apps carry: delete the local copy when a `ZB.MOM.WW.Theme` release ships a `.btn` rule.
|
||||||
|
**That release shipped the same day — see §6.**
|
||||||
|
2. **`site.css` — define `.tree-load-status`** as a flex row matching `.tree-row`, restoring the
|
||||||
|
indent alignment of the tree's loading/error rows.
|
||||||
|
3. **`site.css` — define `.browse-stale-banner`** with `cursor: pointer` and tightened padding, so
|
||||||
|
the click-to-dismiss banner reads as clickable. No behavior change.
|
||||||
|
4. **`Dashboard/Components/DashboardDisplay.cs` — add `Abbreviate(string?, int)`.** Safe helper
|
||||||
|
(length-checked, never a raw `[..n]` slice — the OtOpcUa #504 failure), `-` for null/whitespace,
|
||||||
|
ellipsis on truncation.
|
||||||
|
|
||||||
|
No prose relocation task: the single DELETE is already docs-covered.
|
||||||
|
|
||||||
|
### Page batches
|
||||||
|
|
||||||
|
**Batch A — free-text truncation** (`Shared/FaultList.razor`, `Pages/SessionsPage.razor`,
|
||||||
|
`Pages/WorkersPage.razor`, `Shared/BrowseTreeNodeView.razor`, `Pages/DashboardHome.razor`):
|
||||||
|
`Abbreviate` + full text on `title`, at the five sites in 1d.
|
||||||
|
|
||||||
|
**Batch B — button feet** (`Shared/ConfirmDialog.razor`, `Pages/ApiKeysPage.razor`): the two
|
||||||
|
`btn-group` wraps from 1b. `@onclick`, `disabled`, `type="submit"`, and both arm→confirm flows
|
||||||
|
unchanged.
|
||||||
|
|
||||||
|
**Batch C — prose** (`Pages/GalaxyPage.razor`): delete `:134-138`.
|
||||||
|
|
||||||
|
## 3. Verification
|
||||||
|
|
||||||
|
### Build + tests
|
||||||
|
|
||||||
|
- [x] `dotnet build src/ZB.MOM.WW.MxGateway.NonWindows.slnx` — 0 warnings, 0 errors
|
||||||
|
(`TreatWarningsAsErrors=true`).
|
||||||
|
- [x] `dotnet test src/ZB.MOM.WW.MxGateway.Tests` — 879/879 passed, the macOS baseline.
|
||||||
|
|
||||||
|
### Post-merge greps
|
||||||
|
|
||||||
|
- [x] Zero phantom `var(--…)` usages (there were none to begin with).
|
||||||
|
- [x] Zero ghost classes remaining except the deliberate `tree-node`.
|
||||||
|
- [x] `btn-group` count 3 → 5, matching the 1b inventory.
|
||||||
|
- [x] Zero `·` action separators, zero inline `width:` hacks (none existed).
|
||||||
|
- [x] Scoped-bundle link: N/A, no `*.razor.css`.
|
||||||
|
|
||||||
|
### Live browser gate
|
||||||
|
|
||||||
|
Recorded in §4 below.
|
||||||
|
|
||||||
|
## 4. Live browser gate — results
|
||||||
|
|
||||||
|
Run against a local `dotnet run` of the gateway on macOS (`http://localhost:5120`, the launch
|
||||||
|
profile's port), not windev — the sweep is CSS/markup-only and redeploying the shared NSSM service
|
||||||
|
was not warranted. Rig configuration (env overrides only, no committed config touched):
|
||||||
|
`Dashboard:DisableLogin=true` (to reach the Admin-only surfaces), `Ldap:Enabled=false`,
|
||||||
|
`Authentication:Mode=Disabled`, SQLite + Galaxy snapshot paths under the session scratchpad,
|
||||||
|
`ApiKeyPepper` set locally. The rig was stopped and its throwaway auth DB deleted afterwards.
|
||||||
|
|
||||||
|
**Realistic erroring data**: the Galaxy SQL Server (`localhost`, `ZB`) does not exist on macOS, so
|
||||||
|
every refresh fails with a genuine 250-character `Microsoft.Data.SqlClient` SSPI exception, and the
|
||||||
|
alarm monitor's auto-opened session fails on the missing x86 worker — both real, uncontrolled
|
||||||
|
error text of exactly the kind §1d is about.
|
||||||
|
|
||||||
|
| # | Check | Result | Evidence |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | Computed-style probes | **PASS** | body `14.4px`; `.btn` `13.6px` (0.85rem); `.btn-sm` `12.48px` (0.78rem) — the small/full distinction is live, where before the fix both computed to `13.12px`. `btn-group-sm` members: both `12.48px`, first member's right radius `0px`, last member's `5px` → **seams intact**, confirming the literal `border-radius` does not break group machinery. `.tree-load-status` → `display: flex`; `.browse-stale-banner` → `cursor: pointer`. Stylesheet order: bootstrap → theme → layout → **site.css last**. The theme's own `.rail-toggle btn-sm` also picks up `12.48px`, so the block reaches theme-owned buttons, not just app markup. |
|
||||||
|
| 2 | Every page leads with data, no unconditional doc block above the fold | **PASS** | All 9 routes' first blocks are `dashboard-page-header` → `metric-grid` / `dashboard-section` / state `alert`. `/galaxy` now ends at the Sync Info table — the deleted client-API paragraph is gone. |
|
||||||
|
| 3 | Seamed `btn-group`s; destructive members red | **PASS** | `/apikeys` row actions render Rotate + Revoke as one seamed group with Revoke in `btn-outline-danger` red. The `ConfirmDialog` foot renders Cancel + a solid red Revoke seamed together. |
|
||||||
|
| 4 | Arm→confirm: arm → confirm UI appears → **Cancel** → verify disarm | **PASS** | Revoke → "Revoke API key?" dialog → Cancel. Post-cancel probe: `dialogOpen:false`, `backdrops:0`, key status still `Active`, row action back to `btn-outline-danger` outline. No write completed. Save/Cancel in the create-key modal also verified: Cancel closes without creating; Save creates (validation message surfaced correctly on the first attempt with a missing display name). |
|
||||||
|
| 5 | Anything the scoped bundle resurrected | **N/A** | No `*.razor.css` in the project — see 1a. |
|
||||||
|
| 6 | KEEP-list legends / hints / empty states still render | **PASS** | `/browse`: both empty states plus the "Right-click a tag … Add to subscription panel" hint. `/alarms`: the "Cleared alarms are not retained …" legend under the table. `/galaxy`: Object Categories / Top Templates empty states. |
|
||||||
|
| 7 | Density with realistic data; sections as separate cards; no horizontal scroll | **PASS** | `/` renders the SQL error truncated to one line with `…`; `/galaxy` renders the same error in full (the drill-down) — the intended split is visible side by side. `/apikeys` with a 3-entry `read_subtrees` constraint wraps inside the column with no table overflow. `documentElement.scrollWidth == clientWidth` on every page walked. Each group is its own card. |
|
||||||
|
|
||||||
|
**Not exercisable on this rig** (recorded, not claimed as passing): the `Sessions` / `Workers` /
|
||||||
|
`Recent Faults` truncation call sites and the `BrowseTreeNodeView` load-error row need a *registered*
|
||||||
|
session, which requires the x86 worker — on macOS the launch fails before the session is ever
|
||||||
|
registered, so those tables stay empty. Their shared helper and the container CSS were both verified
|
||||||
|
live by other means (the `/` Galaxy error for `Abbreviate`, the computed-style probe for
|
||||||
|
`.tree-load-status`). Re-check them on windev the next time that service is redeployed.
|
||||||
|
|
||||||
|
**Side note surfaced, not acted on**: `SettingsPage` renders `LDAP service password` as
|
||||||
|
`[redacted]` in the browser — confirming `GatewayConfigurationProvider`'s substitution end to end.
|
||||||
|
|
||||||
|
## 5. Follow-up gate: `/admin/secrets` delete modal (scadaproj#2)
|
||||||
|
|
||||||
|
Not part of the sweep. Run because the same stale "mxgw is Bootstrap-free" claim corrected in §0 was
|
||||||
|
the stated reason this app was **excused** from the 2026-07-19 family-wide `/admin/secrets` modal
|
||||||
|
sweep: `ConfirmDeleteModal` shipped a bare `class="modal"`, which Bootstrap 5's `.modal{display:none}`
|
||||||
|
made permanently invisible on every Bootstrap host. The exemption's reasoning was false, so mxgw was
|
||||||
|
in scope and its delete modal had never been live-gated. `8f7ee49` bumped `ZB.MOM.WW.Secrets.Ui` to
|
||||||
|
`0.2.3`, which is supposed to carry the fix — but "supposed to" is what the original claim was.
|
||||||
|
|
||||||
|
**Static check** — decoding the UTF-16 literals out of `ZB.MOM.WW.Secrets.Ui.dll` 0.2.3 shows the
|
||||||
|
component now inlines its own `<style>` under a private namespace: `.zb-secrets-modal` with
|
||||||
|
`display: flex; position: fixed; inset: 0; z-index: 1081`, over a `.zb-secrets-modal-backdrop` at
|
||||||
|
`z-index: 1080`. No bare `modal` class.
|
||||||
|
|
||||||
|
**Live check** — local rig, scratch secrets store (`Secrets:SqlitePath` + `ZB_SECRETS_MASTER_KEY`
|
||||||
|
under the session scratchpad), throwaway secret `uisweep/throwaway`, delete armed but **never
|
||||||
|
confirmed**:
|
||||||
|
|
||||||
|
| Probe | Result |
|
||||||
|
|---|---|
|
||||||
|
| Modal root classes | `zb-secrets-modal` — and `document.querySelector('.modal')` is `null`, so Bootstrap's `.modal{display:none}` has nothing to bite |
|
||||||
|
| Modal computed style | `display: flex`, `position: fixed`, `z-index: 1081`, `visibility: visible`, `opacity: 1`, box `1600×827` |
|
||||||
|
| Backdrop | `display: block`, `position: fixed`, `z-index: 1080`, `rgba(15,18,20,.45)`, covers the full viewport |
|
||||||
|
| Confirm button hit-testable | `elementFromPoint` at its centre returns the button itself — not covered by the backdrop |
|
||||||
|
| Cancel → disarm | modal and backdrop both removed; secret still listed; **no delete performed** |
|
||||||
|
|
||||||
|
**PASS.** The `0.2.3` fix holds on this Bootstrap host. The rig was stopped and both throwaway
|
||||||
|
stores (secrets + auth) deleted.
|
||||||
|
</content>
|
||||||
|
|
||||||
|
## 6. Follow-up: `ZB.MOM.WW.Theme` 0.3.1 → 0.4.0 (dependency bump, not UI cleanup)
|
||||||
|
|
||||||
|
0.4.0 upstreams the button-sizing block this sweep installed locally, written against the finding in
|
||||||
|
§1a — the kit sizes **only** through `--bs-btn-*` variables and carries a comment in `layout.css`
|
||||||
|
saying why, so nobody simplifies it back into literals.
|
||||||
|
|
||||||
|
- `ZB.MOM.WW.MxGateway.Server.csproj`: `0.3.1` → `0.4.0`. No `Directory.Packages.props` in this repo;
|
||||||
|
the version lives on the `PackageReference`.
|
||||||
|
- `site.css`: the four `--bs-btn-*` overrides and the whole `.btn-group-sm > .btn, .btn-sm` rule
|
||||||
|
deleted — `layout.css` now supplies them and `<ThemeHead />` emits it before `site.css`.
|
||||||
|
|
||||||
|
**Trimmed, not deleted wholesale.** The local `.btn` rule also carried `border-radius: 5px`,
|
||||||
|
`font-weight: 500`, `white-space: nowrap`, which predate the sweep and which 0.4.0 does **not**
|
||||||
|
ship — it upstreamed sizing only. Removing the block entirely would have silently dropped three
|
||||||
|
app-specific declarations. What remains is `.btn { border-radius: 5px; font-weight: 500;
|
||||||
|
white-space: nowrap; }` — shape, not size. `border-radius` deliberately stays a literal rather than
|
||||||
|
`--bs-btn-border-radius`, because `.btn-sm` redefines that variable and small buttons would shrink
|
||||||
|
to the Bootstrap small radius.
|
||||||
|
|
||||||
|
**Verification**
|
||||||
|
|
||||||
|
- Build: 0 warnings, 0 errors. Suite: 879/879.
|
||||||
|
- Computed-style probe re-run on 0.4.0 with the local block gone: `.btn` **13.6px**, `.btn-sm`
|
||||||
|
**12.48px** — identical to the pre-bump numbers, so the kit rule reaches. `btn-group-sm` members
|
||||||
|
both 12.48px with the seam intact (first member right radius `0px`, last `5px`). Retained
|
||||||
|
declarations confirmed live on both sizes: radius `5px`, weight `500`, `nowrap`.
|
||||||
|
- Cascade confirmed by enumerating `document.styleSheets`: `--bs-btn-font-size` is now declared in
|
||||||
|
exactly two sheets — `bootstrap.min.css` (`1rem` / `0.875rem`) and `layout.css`
|
||||||
|
(`.85rem` / `.78rem`). `site.css` no longer declares it, so the duplicate is gone.
|
||||||
|
|
||||||
|
**Tree-hygiene note.** The first suite run after the bump failed
|
||||||
|
`GatewayTreeHygieneTests.SourceTree_ContainsNoSqliteDatabaseFiles` — unrelated to the bump. The §4
|
||||||
|
rig's *first* start used the default **relative** `Secrets:SqlitePath` (`mxgateway-secrets.db`),
|
||||||
|
which resolved against the server project directory and left a DB in the source tree; only the §5
|
||||||
|
run redirected it to the scratchpad. The file was untracked, was deleted, and the suite went green.
|
||||||
|
The §4/§5 cleanup notes were therefore incomplete as originally written — the scratchpad copies were
|
||||||
|
removed but this one was missed. The hygiene test is what caught it, which is what it exists for.
|
||||||
|
|
||||||
|
## 7. Follow-up: `ZB.MOM.WW.Theme` 0.4.0 → 0.4.1 (pin-only, no rendering change here)
|
||||||
|
|
||||||
|
0.4.1 was published the same day to fix `.rail-btn-block`, a modifier shipped broken in 0.4.0
|
||||||
|
(`display: block; width: auto` fills for an `<a>` but shrink-wraps a `<button>`; now
|
||||||
|
`width: calc(100% - 1.2rem)` with `box-sizing: border-box`, accounting for `.rail-btn`'s side
|
||||||
|
margins). Bumped for family-pin alignment.
|
||||||
|
|
||||||
|
**Why nothing needed re-verifying.** Confirmed rather than assumed:
|
||||||
|
|
||||||
|
- `diff` of the two restored packages: `theme.css` byte-identical; `layout.css` differs **only**
|
||||||
|
inside the `.rail-btn-block` rule and its comment. The `.btn` sizing block is unchanged, so the
|
||||||
|
§6 measurements (`.btn` 13.6px, `.btn-sm` 12.48px) still hold and the probe was not re-run.
|
||||||
|
- This app does not use `rail-btn-block`. It does carry the exact element pair the bug turned on —
|
||||||
|
`MainLayout.razor:32` is a form-submit `<button class="rail-btn">` (Sign Out) and
|
||||||
|
`MainLayout.razor:36` an `<a class="rail-btn">` (Sign In) — but base `.rail-btn` is
|
||||||
|
`display: inline-block`, which shrink-wraps both element types identically. The asymmetry only
|
||||||
|
appears once the block modifier is applied, so this dashboard was never affected.
|
||||||
|
|
||||||
|
**Verification.** Build 0 warnings / 0 errors; suite **879/879**; `staticwebassets.build.json`
|
||||||
|
resolves `zb.mom.ww.theme/0.4.1`. No stale-HTTP-cache clear was needed — restore picked 0.4.1
|
||||||
|
directly.
|
||||||
@@ -36,6 +36,32 @@ public static class DashboardDisplay
|
|||||||
return string.IsNullOrWhiteSpace(value) ? "-" : value;
|
return string.IsNullOrWhiteSpace(value) ? "-" : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Formats a nullable text value for display, shortened to a maximum length.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// For table cells bound to text the gateway does not control — fault messages, COM
|
||||||
|
/// exception text, SQL errors. One multi-line exception otherwise makes a single row
|
||||||
|
/// several times taller than its neighbours. Call sites keep the full text reachable
|
||||||
|
/// on the element's <c>title</c> and on the row's detail page.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="value">The text to format.</param>
|
||||||
|
/// <param name="maxLength">Maximum characters to render before the ellipsis.</param>
|
||||||
|
/// <returns>Formatted text, ellipsized when longer than <paramref name="maxLength"/>, or "-" if null or empty.</returns>
|
||||||
|
public static string Abbreviate(string? value, int maxLength = 80)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Length-checked, never a bare range slice: a value shorter than maxLength
|
||||||
|
// would throw and take the whole page render down with it.
|
||||||
|
return value.Length <= maxLength
|
||||||
|
? value
|
||||||
|
: string.Concat(value.AsSpan(0, maxLength).TrimEnd(), "…");
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Formats a long count value for display with thousands separator.
|
/// Formats a long count value for display with thousands separator.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -133,8 +133,10 @@ else
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<button type="submit" class="btn btn-success btn-sm me-1" disabled="@IsBusy">Save</button>
|
<div class="btn-group btn-group-sm" role="group" aria-label="Create API key actions">
|
||||||
<button type="button" class="btn btn-outline-secondary btn-sm" disabled="@IsBusy" @onclick="CloseCreateDialog">Cancel</button>
|
<button type="submit" class="btn btn-success" disabled="@IsBusy">Save</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary" disabled="@IsBusy" @onclick="CloseCreateDialog">Cancel</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ else
|
|||||||
</div>
|
</div>
|
||||||
@if (!string.IsNullOrWhiteSpace(Snapshot.Galaxy.LastError))
|
@if (!string.IsNullOrWhiteSpace(Snapshot.Galaxy.LastError))
|
||||||
{
|
{
|
||||||
<div class="empty-state mt-2">@Snapshot.Galaxy.LastError</div>
|
@* Overview stays compact; the Galaxy page renders the error in full. *@
|
||||||
|
<div class="empty-state mt-2" title="@Snapshot.Galaxy.LastError">@DashboardDisplay.Abbreviate(Snapshot.Galaxy.LastError, 160)</div>
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -131,11 +131,6 @@ else
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-secondary small mt-2">
|
|
||||||
Browse data is served by the <code>galaxy_repository.v1.GalaxyRepository</code> gRPC
|
|
||||||
service. Clients call <code>DiscoverHierarchy</code> for the full tree and
|
|
||||||
<code>GetLastDeployTime</code> to detect redeployments.
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -83,7 +83,8 @@ else
|
|||||||
<td>@DashboardDisplay.DateTime(session.OpenedAt)</td>
|
<td>@DashboardDisplay.DateTime(session.OpenedAt)</td>
|
||||||
<td>@DashboardDisplay.DateTime(session.LastClientActivityAt)</td>
|
<td>@DashboardDisplay.DateTime(session.LastClientActivityAt)</td>
|
||||||
<td>@DashboardDisplay.DateTime(session.LastWorkerHeartbeatAt)</td>
|
<td>@DashboardDisplay.DateTime(session.LastWorkerHeartbeatAt)</td>
|
||||||
<td>@DashboardDisplay.Text(session.LastFault)</td>
|
@* Full text stays reachable on the tooltip and on the session detail page. *@
|
||||||
|
<td title="@session.LastFault">@DashboardDisplay.Abbreviate(session.LastFault)</td>
|
||||||
@if (CanManage)
|
@if (CanManage)
|
||||||
{
|
{
|
||||||
<td>
|
<td>
|
||||||
|
|||||||
@@ -67,7 +67,8 @@ else
|
|||||||
<td><StatusBadge Text="@worker.State.ToString()" /></td>
|
<td><StatusBadge Text="@worker.State.ToString()" /></td>
|
||||||
<td><NavLink href="@($"sessions/{Uri.EscapeDataString(worker.SessionId)}")"><code>@worker.SessionId</code></NavLink></td>
|
<td><NavLink href="@($"sessions/{Uri.EscapeDataString(worker.SessionId)}")"><code>@worker.SessionId</code></NavLink></td>
|
||||||
<td>@DashboardDisplay.DateTime(worker.LastHeartbeatAt)</td>
|
<td>@DashboardDisplay.DateTime(worker.LastHeartbeatAt)</td>
|
||||||
<td>@DashboardDisplay.Text(worker.LastFault)</td>
|
@* Full text stays reachable on the tooltip and on the session detail page. *@
|
||||||
|
<td title="@worker.LastFault">@DashboardDisplay.Abbreviate(worker.LastFault)</td>
|
||||||
@if (CanManage)
|
@if (CanManage)
|
||||||
{
|
{
|
||||||
<td>
|
<td>
|
||||||
|
|||||||
@@ -46,9 +46,11 @@
|
|||||||
}
|
}
|
||||||
else if (Node.LoadState == BrowseLoadState.Error)
|
else if (Node.LoadState == BrowseLoadState.Error)
|
||||||
{
|
{
|
||||||
<div class="tree-load-status text-danger">
|
@* Abbreviated: sibling tree rows are nowrap inside a fixed-height
|
||||||
|
scroller, so a full COM/SQL error would stretch the whole pane. *@
|
||||||
|
<div class="tree-load-status text-danger" title="@Node.LoadError">
|
||||||
<span class="tree-toggle tree-toggle-empty"></span>
|
<span class="tree-toggle tree-toggle-empty"></span>
|
||||||
<span>Failed to load: @Node.LoadError</span>
|
<span>Failed to load: @DashboardDisplay.Abbreviate(Node.LoadError, 60)</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,16 +14,18 @@
|
|||||||
<p class="mb-0">@Message</p>
|
<p class="mb-0">@Message</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-outline-secondary"
|
<div class="btn-group" role="group" aria-label="Confirm or cancel">
|
||||||
disabled="@IsBusy"
|
<button type="button" class="btn btn-outline-secondary"
|
||||||
@onclick="OnCancel">
|
disabled="@IsBusy"
|
||||||
Cancel
|
@onclick="OnCancel">
|
||||||
</button>
|
Cancel
|
||||||
<button type="button" class="btn @ConfirmButtonClass"
|
</button>
|
||||||
disabled="@IsBusy"
|
<button type="button" class="btn @ConfirmButtonClass"
|
||||||
@onclick="OnConfirm">
|
disabled="@IsBusy"
|
||||||
@ConfirmLabel
|
@onclick="OnConfirm">
|
||||||
</button>
|
@ConfirmLabel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ else
|
|||||||
<td><code>@DashboardDisplay.Text(fault.SessionId)</code></td>
|
<td><code>@DashboardDisplay.Text(fault.SessionId)</code></td>
|
||||||
<td>@(fault.WorkerProcessId?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "-")</td>
|
<td>@(fault.WorkerProcessId?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "-")</td>
|
||||||
<td><StatusBadge Text="@fault.State" /></td>
|
<td><StatusBadge Text="@fault.State" /></td>
|
||||||
<td>@fault.Message</td>
|
<td title="@fault.Message">@DashboardDisplay.Abbreviate(fault.Message)</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<PackageReference Include="ZB.MOM.WW.Auth.ApiKeys" Version="0.1.5" />
|
<PackageReference Include="ZB.MOM.WW.Auth.ApiKeys" Version="0.1.5" />
|
||||||
<PackageReference Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.5" />
|
<PackageReference Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.5" />
|
||||||
<PackageReference Include="ZB.MOM.WW.Audit" Version="0.1.0" />
|
<PackageReference Include="ZB.MOM.WW.Audit" Version="0.1.0" />
|
||||||
<PackageReference Include="ZB.MOM.WW.Theme" Version="0.3.1" />
|
<PackageReference Include="ZB.MOM.WW.Theme" Version="0.4.1" />
|
||||||
<PackageReference Include="ZB.MOM.WW.Configuration" Version="0.1.0" />
|
<PackageReference Include="ZB.MOM.WW.Configuration" Version="0.1.0" />
|
||||||
<PackageReference Include="ZB.MOM.WW.Health" Version="0.2.0" />
|
<PackageReference Include="ZB.MOM.WW.Health" Version="0.2.0" />
|
||||||
<PackageReference Include="ZB.MOM.WW.Telemetry" Version="0.1.0" />
|
<PackageReference Include="ZB.MOM.WW.Telemetry" Version="0.1.0" />
|
||||||
|
|||||||
@@ -183,8 +183,19 @@ code {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Buttons ─────────────────────────────────────────────────────────────────
|
/* ── Buttons ─────────────────────────────────────────────────────────────────
|
||||||
Flatten Bootstrap buttons onto the single accent + hairline palette. */
|
Flatten Bootstrap buttons onto the single accent + hairline palette.
|
||||||
.btn { border-radius: 5px; font-size: 0.82rem; font-weight: 500; white-space: nowrap; }
|
|
||||||
|
Button *sizing* is not here: ZB.MOM.WW.Theme 0.4.0 upstreamed it into
|
||||||
|
layout.css as --bs-btn-* variable overrides, and ThemeHead emits that sheet
|
||||||
|
ahead of this one, so it applies without a local copy. Do not reintroduce a
|
||||||
|
local font-size on .btn — a literal at equal specificity beats Bootstrap's
|
||||||
|
`font-size: var(--bs-btn-font-size)` and flattens .btn-sm into a
|
||||||
|
padding-only difference across the whole app.
|
||||||
|
|
||||||
|
What remains is shape, not size, and is app-specific. border-radius stays a
|
||||||
|
literal rather than --bs-btn-border-radius so .btn-sm cannot shrink it; the
|
||||||
|
.btn-group seam rules outrank .btn, so groups still render seamed. */
|
||||||
|
.btn { border-radius: 5px; font-weight: 500; white-space: nowrap; }
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
@@ -290,6 +301,14 @@ code {
|
|||||||
.browse-panel { margin-top: 0; }
|
.browse-panel { margin-top: 0; }
|
||||||
|
|
||||||
.browse-search { margin-bottom: 0.6rem; }
|
.browse-search { margin-bottom: 0.6rem; }
|
||||||
|
|
||||||
|
/* Click-to-dismiss "Galaxy redeployed" notice above the tree. The whole banner
|
||||||
|
is the dismiss target, so it has to read as one. */
|
||||||
|
.browse-stale-banner {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.4rem 0.7rem;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
.browse-search-note {
|
.browse-search-note {
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
font-size: 0.74rem;
|
font-size: 0.74rem;
|
||||||
@@ -334,6 +353,22 @@ code {
|
|||||||
}
|
}
|
||||||
.tree-toggle-empty { cursor: default; }
|
.tree-toggle-empty { cursor: default; }
|
||||||
|
|
||||||
|
/* Loading / failed-to-load rows sit among .tree-row siblings and carry the same
|
||||||
|
leading .tree-toggle-empty spacer. The spacer only takes its width as a flex
|
||||||
|
item, so this container has to be flex or the row loses its indent. */
|
||||||
|
.tree-load-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.12rem 0.5rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
.tree-load-status > span:not(.tree-toggle) {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.tree-label {
|
.tree-label {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user