diff --git a/.gitignore b/.gitignore index 3165a290c..931e9e72d 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ main /bin /build .claude/worktrees/ +.worktrees/ diff --git a/.superpowers/sdd/2026-08-12-failure-analysis-workspace/final-fix-report.md b/.superpowers/sdd/2026-08-12-failure-analysis-workspace/final-fix-report.md new file mode 100644 index 000000000..12034d20b --- /dev/null +++ b/.superpowers/sdd/2026-08-12-failure-analysis-workspace/final-fix-report.md @@ -0,0 +1,128 @@ +# Failure Analysis Workspace Final Fix Report + +Date: 2026-08-12 + +## Scope and commit + +- Scope: the complete final-review fix wave for the Failure Analysis Workspace. +- Commit: `fix(ui): complete failure analysis final review` (this report is included in that single commit; the final hash is reported in the handoff). +- Production APIs, endpoints, refresh behavior, and visual design were unchanged. + +## Findings and TDD evidence + +### IMPORTANT: unmatched display-relevant analysis + +Root cause: `appendEntry` returned when `ClusterDetails.ClusterName` was absent from the `clusters-info` index, but the model did not record that discarded actionable or structural entry. The adapter consequently rendered a healthy empty or incomplete state. + +RED command: + +```text +node --test go/http/testdata/clusters_analysis_state_test.js +``` + +Test-only run outcome: 10 passed, 3 failed. Relevant failures were: + +- `incident model tracks an unmatched structural entry`: expected `unmatchedEntryCount === 1`, received `undefined`. +- `document adapter renders unavailable state when an actionable analysis has no matching cluster`: expected `Analysis unavailable`, received `0 active incidents across 0 clusters`. + +GREEN implementation: + +- `buildClustersAnalysisModel` increments `unmatchedEntryCount` only when an actionable or structural entry reaches `appendEntry` and lacks its cluster. +- The adapter renders the unavailable state before topology URL adjustment or incident summary rendering whenever that count is nonzero. +- Separate regressions cover an actionable `DeadMaster` with `clusters=[]` at the adapter boundary and a structural-only unmatched entry at the model boundary. + +GREEN outcome: the focused JavaScript suite passed 13/13. + +### MINOR 1: accurate test title and explicit actionable derivation + +The mixed model test was renamed from claiming actionable and downtimed derivation to the behavior it actually asserts: blocked and structural entries. A focused test now asserts the complete literal actionable entry model. + +Because actionable derivation was already correct, the new characterization test was mutation-checked rather than represented as a naturally failing baseline. + +Mutation RED command: + +```text +node --test --test-name-pattern='incident model derives an actionable entry' go/http/testdata/clusters_analysis_state_test.js +``` + +Outcome after temporarily changing the production actionable status label: 0 passed, 1 failed, with literal `statusLabel` mismatch (`Action required` versus `Requires attention`). The mutation was reverted. + +Restored GREEN command: the same command passed 1/1. + +### MINOR 2: deterministic analysis-entry ordering + +Root cause: entries were appended in replication-analysis API order and only clusters were sorted. + +RED command: + +```text +node --test go/http/testdata/clusters_analysis_state_test.js +``` + +Test-only run outcome: `incident model sorts entries by state, instance, and analysis` failed with the reversed API order intact. + +GREEN implementation: each cluster's entries are sorted by state precedence (`blocked`, `actionable`, `warning`, `downtimed`), then instance, then analysis, before cluster state derivation. The regression uses reversed mixed-state input and a hand-written literal expected order, including an analysis tie-break for the same instance. + +GREEN outcome: the focused JavaScript suite passed 13/13. + +### MINOR 3: complete workspace CSS selector scoping + +Root cause: the stylesheet guard rejected only newline-prefixed `.popover` and `.container` strings and did not validate arbitrary rule selectors. + +RED command: + +```text +go test ./go/http -run 'TestClustersAnalysisWorkspaceStylesAreScoped|TestUnscopedWorkspaceCSSSelectorsRejectsArbitraryGlobalRule' -count=1 +``` + +Test-only run outcome: build failed because the new all-selector validator did not exist. + +GREEN implementation: + +- `TestClustersAnalysisWorkspaceStylesAreScoped` now runs all selectors returned by the existing recursive `workspaceCSSSelectors` parser through the existing workspace-ID selector validator. +- A focused real-parser regression includes `.unexpected-global` inside a media rule and asserts that it is rejected; no mock is used. + +GREEN outcome: the focused Go test command passed. + +## Files changed + +- `resources/public/js/clusters-analysis.js` +- `go/http/testdata/clusters_analysis_state_test.js` +- `go/http/static_assets_test.go` +- `.superpowers/sdd/2026-08-12-failure-analysis-workspace/final-fix-report.md` + +## Full verification + +Command: + +```text +node --test go/http/testdata/*.js && \ +node --check resources/public/js/clusters-analysis.js && \ +gofmt -w go/http/static_assets_test.go && \ +go test ./go/http -count=1 && \ +bash tests/functional/test-smoke.sh && \ +git diff --check +``` + +Outcome: exit 0. + +- Node behavior tests: 20 passed, 0 failed. +- JavaScript syntax check: passed. +- Go HTTP package: passed. +- Functional smoke: 32 passed, 0 failed, 0 skipped. +- Formatting: `gofmt` applied to the changed Go test. +- Diff whitespace check: passed. +- Existing healthy lab was used; no containers were recreated or restarted. + +## Self-review + +- Confirmed only display-relevant actionable and structural entries contribute to the unmatched count; non-interesting non-structural analysis remains ignored as before. +- Confirmed any unmatched count forces unavailable rendering, preventing a partial incident list as well as a false healthy empty state. +- Confirmed sorting is independent of API order and uses explicit state precedence followed by lexical instance and analysis keys. +- Confirmed the CSS guard recursively checks selectors inside media rules and reports every unscoped selector. +- Confirmed no production changes were made outside the JavaScript model/adapter and no CSS was altered. +- Confirmed the final diff contains no unrelated workspace changes. + +## Concerns + +None. The lab remained healthy throughout verification. diff --git a/.superpowers/sdd/2026-08-12-live-failover-audit-ui/final-fix-report.md b/.superpowers/sdd/2026-08-12-live-failover-audit-ui/final-fix-report.md new file mode 100644 index 000000000..f5430cadd --- /dev/null +++ b/.superpowers/sdd/2026-08-12-live-failover-audit-ui/final-fix-report.md @@ -0,0 +1,94 @@ +# Final safety-fix report + +Date: 2026-08-12 (Asia/Bangkok) + +## Outcome + +All three Important safety findings are addressed in one focused change set. +No live failover was run and no MySQL container was started, stopped, or +recreated during this correction. + +## TDD evidence + +Initial RED command: + +```text +go test ./go/http -run 'Test(AuditFailoverHarnessSafetyContracts|SmokeEndsOnlyMaintenanceCreatedByItsBeginCall|MaintenanceBegunResponseReturnsCreatedMaintenanceKey)$' -count=1 +``` + +It failed at compile time with: + +```text +go/http/api_test.go:60:14: undefined: maintenanceBegunResponse +``` + +After the minimal handler response helper was introduced, the same command +failed on the shell regressions: missing `deadline=$((SECONDS + 90))`, curl +max-time two, deadline loop, cleanup early-return, and keyed end-maintenance; +it also detected `start mysql2 mysql3` and instance-based maintenance cleanup. + +A focused boundary RED then failed because the deadline loop did not budget its +final curl against the remaining seconds. That test named the missing +`remaining=$((deadline - SECONDS))` and reduced curl argument. + +Final focused GREEN: + +```text +ok github.com/proxysql/orchestrator/go/http +``` + +## Implemented contracts + +1. Recovery polling is bounded by an actual 90-second wall-clock deadline. + Every curl has a two-second maximum, reduced to the remaining deadline + budget when necessary, and success/failure output uses actual elapsed time. +2. `restore_lab` is a true no-op while `MYSQL1_STOPPED=false`. Once mysql1 was + stopped, cleanup starts mysql1 only. It never starts mysql2/mysql3; replica + repair uses `docker compose exec` and therefore operates only on replicas + that are already running. +3. BeginMaintenance success preserves the historical `Details.Hostname` and + `Details.Port` fields and adds its new maintenance ID as + `Details.MaintenanceKey`, while preserving `Code: OK` and the existing + Message. Smoke validates the direct response's status, code, exact instance + message, instance details, and positive integer key, then calls only + `/api/end-maintenance/$MAINTENANCE_KEY`. Failed or unrelated responses cause + no cleanup call. + +## Additive API compatibility correction + +The initial safety correction represented `Details` as the maintenance-key +number, which regressed the successful BeginMaintenance response contract for +clients that read `Details.Hostname` and `Details.Port`. A focused TDD test +against that implementation failed with: + +```text +json: cannot unmarshal number into Go struct field .Details of type struct { Hostname string; Port int; MaintenanceKey int64 } +``` + +The response now embeds the original `inst.InstanceKey` fields in its details +object and exposes `MaintenanceKey` additively. Failure responses were not +changed. + +## Verification + +- `go test ./go/http -count=1`: pass. +- Node UI state tests: 23/23 pass across four files. +- `bash -n tests/functional/test-audit-ui-failover.sh tests/functional/test-smoke.sh`: pass. +- `bash tests/functional/test-smoke.sh`: 35 passed, 0 failed, 0 skipped; + begin returned `Details.MaintenanceKey` 1 alongside `Hostname`/`Port`, and + cleanup ended that exact key. +- `git diff --check`: pass. +- Live failover: intentionally not run. + +Only Orchestrator was recreated for smoke; a before/after comparison confirmed +all three MySQL container IDs were unchanged. An initial run failed at the +readiness gate because the mounted binary was Darwin rather than Linux; no +maintenance began. Rebuilding with the existing Linux/arm64 Go image resolved +the environment mismatch, after which smoke passed. + +## Concerns + +None for the three corrected findings. Recreating Orchestrator resets the +functional SQLite audit database by design, so historical live failover rows +from the earlier review are no longer resident; their captured evidence remains +in `final-report.md`. No new failover was run. diff --git a/.superpowers/sdd/2026-08-12-live-failover-audit-ui/final-report.md b/.superpowers/sdd/2026-08-12-live-failover-audit-ui/final-report.md new file mode 100644 index 000000000..488ea6c46 --- /dev/null +++ b/.superpowers/sdd/2026-08-12-live-failover-audit-ui/final-report.md @@ -0,0 +1,155 @@ +# Final verification: populated audit history + +## Scope and commits + +This handoff verifies the recovered, restored functional lab and the populated +audit-history UI evidence produced by this work. + +Commits created before this handoff: + +- `5556dbe9 test(ui): persist audit history in functional lab` +- `52bbe45f test(ui): verify functional audit persistence` +- `e722ba7c test(ui): exercise populated audit history` + +Initial report commit: `d3c81f67 docs(ui): record populated audit verification`. + +No production UI correction was required after browser review. + +## Recovery and audit evidence + +The controlled failure produced successful `DeadMaster` recovery records for +`mysql1:3306`; the recorded successor was `mysql2:3306`. Two recovery records +are present, each records `IsSuccessful: true`, `AnalysisEntry.Analysis: +DeadMaster`, and the successor `mysql2:3306` (the most recent is ID 2). + +Fresh API counts from 2026-08-12 14:54 ICT: + +| Endpoint | Records | +| --- | ---: | +| `/api/audit/0` | 20 | +| `/api/audit-failure-detection/0` | 2 | +| `/api/audit-recovery/0` | 2 | + +Both detection records and both recovery records represent `DeadMaster` for +`mysql1:3306`; the recovery records are successful with `mysql2:3306` as +successor. + +## Restored topology and identity + +Fresh container inspection retained the IDs captured by the recovery harness: + +| Service | Container ID | State | Role / replication | +| --- | --- | --- | --- | +| mysql1 | `76e92eb4a8be` | healthy | `read_only=0` | +| mysql2 | `ca05b9577b38` | healthy | source `mysql1`; IO `Yes`; SQL `Yes` | +| mysql3 | `cf2ffd96825e` | healthy | source `mysql1`; IO `Yes`; SQL `Yes` | + +This matches the pre-restoration identity record: no MySQL container was +recreated. `SHOW REPLICA STATUS\\G` for mysql2 and mysql3 also reported zero +last IO and SQL errors and zero seconds behind source. + +## Automated verification + +All prescribed commands were run fresh and exited zero: + +| Command / suite | Result | +| --- | --- | +| `go test ./go/http -count=1` | 1 package passed; fresh JSON run counted 77 passing Go tests | +| `for file in go/http/testdata/*_test.js; do node --test "$file" \|\| exit 1; done` | 4 Node test files; 23/23 tests passed | +| `node --check resources/public/js/*.js` | 30/30 JavaScript files parsed successfully | +| `bash tests/functional/test-smoke.sh` | 35 passed, 0 failed, 0 skipped | +| `git diff --check` | no whitespace errors | + +The smoke run rediscovered all three instances and passed its audit-persistence, +web/API, health, metrics, and ProxySQL checks. + +## Commit hygiene + +After the initial report commit `d3c81f67`, `git status --short` produced no +output. The tracked worktree was clean; this report was the only file staged +and committed for that handoff. + +## Browser evidence + +Task 3 inspected the populated application at the default desktop viewport and +again at 390x844. At both sizes: + +- `/web/audit` displayed its populated rows and correct pager states. +- `/web/audit-failure-detection` displayed two `DeadMaster` detections; the + expanded detection showed the two replicas, changelog, processing node, and + its recovery link. +- `/web/audit-recovery` displayed two `DeadMaster` recoveries and working UID + detail links. +- `/web/audit-recovery/id/2` displayed failed `mysql1:3306`, successor + `mysql2:3306`, timing and acknowledgement data, affected replicas, and all + 26 recovery steps. Its related-detection link also rendered the corresponding + detail. + +At 390px, the table/detail shells scrolled internally without document-level +horizontal overflow; empty and unavailable states stayed hidden while populated +content was shown. Browser console inspection found **0 errors and 0 warnings** +at both viewport sizes. + +## Safety and unresolved concerns + +The final state has the original mysql1 writer and two healthy replicas sourced +from mysql1. The recovery workflow restored this topology without recreating +containers, deleting volumes, or discarding SQLite history. + +Unresolved concerns: **none**. Docker Compose emitted its pre-existing +obsolete-top-level-`version` notice and the MySQL client emitted its standard +password-on-command-line warning during the earlier live verification. + +## Final safety corrections (2026-08-12) + +Three Important review findings were corrected without running another live +failover: + +- The recovery poll now uses a `SECONDS + 90` wall-clock deadline, limits each + curl to at most two seconds (and to the remaining deadline budget near the + boundary), and reports actual elapsed seconds. +- `restore_lab` returns immediately unless this harness stopped mysql1. During + restoration it starts only mysql1; mysql2/mysql3 are never started, and + replication repair is attempted only through `exec` against their existing + running containers. +- successful `begin-maintenance` responses retain the existing Code, Message, + `Details.Hostname`, and `Details.Port` fields while adding the created key as + `Details.MaintenanceKey`. The smoke test accepts only the direct successful + response for mysql2, extracts its positive integer key, and ends maintenance + only through `/api/end-maintenance/{key}`. + +Strict RED evidence was captured before each correction. The handler contract +first failed to build with `undefined: maintenanceBegunResponse`. After the +minimal API response change exposed the shell regressions, the focused test +reported all missing deadline/no-op/keyed-cleanup contracts and detected both +unsafe instance cleanup branches. A second deadline-boundary RED reported the +missing remaining-budget calculation before that behavior was added. + +A final scoped review found that the first key-returning response had replaced +the historical instance details with a number. The additive compatibility test +failed against that version because numeric Details could not decode into +`Hostname`, `Port`, and `MaintenanceKey`. The corrected response preserves the +two historical fields and adds the key; the smoke consumer now verifies all +three before cleanup. Failure responses remain unchanged. + +Fresh GREEN verification: + +| Command | Result | +| --- | --- | +| focused three-regression `go test` | pass | +| `go test ./go/http -count=1` | pass | +| four `go/http/testdata/*_test.js` files | 23/23 pass | +| `bash -n` on both changed functional scripts | pass | +| `bash tests/functional/test-smoke.sh` | 35 passed, 0 failed, 0 skipped | +| `git diff --check` | pass | + +The test binary was rebuilt for the lab's Linux/arm64 platform and only the +Orchestrator service was recreated. The smoke test received +`Details.MaintenanceKey` 1 alongside the historical instance fields, ended +exactly that key, and passed 35/35 checks. MySQL container ID comparison before +and after had no diff. The first smoke attempt failed safely at readiness +because a host Darwin binary had been mounted into the Linux container; no +maintenance call occurred. Rebuilding in the existing `golang:1.25.7` Linux +image corrected that environment mismatch. No live failover was run. +command-line-password warning during topology inspection; neither is an +application/browser-console warning or a verification failure. diff --git a/.superpowers/sdd/2026-08-18-consolidated-ui-integration/browser-qa.md b/.superpowers/sdd/2026-08-18-consolidated-ui-integration/browser-qa.md new file mode 100644 index 000000000..9cf8b6bc1 --- /dev/null +++ b/.superpowers/sdd/2026-08-18-consolidated-ui-integration/browser-qa.md @@ -0,0 +1,66 @@ +# Consolidated UI browser acceptance — 2026-08-19 + +The rebuilt Linux/arm64 Orchestrator service was tested in the Codex in-app Browser against the three-node functional lab. Every route was explicitly reloaded after the rebuild. Browser warning/error counts are new entries observed during each route check. + +| Route | Viewport | State | Navigation | Interaction | Body overflow | Console | +|---|---:|---|---|---|---|---| +| `/web/clusters` | 1440×900 | 1 cluster | Expanded | Cluster link visible | none (1440/1440) | 0 | +| `/web/cluster/mysql1:3306` | 1440×900 | 3 cards, 3 nodes, 2 links | Expanded | Collapse/expand, View, Details, dismiss | none (1440/1440) | 0 | +| `/web/clusters-analysis` | 1440×900 | ErrantGTIDStructureWarning | Expanded | Incident visible | none (1440/1440) | 0 | +| `/web/discover` | 1440×900 | Form ready | Expanded | Inputs usable | none (1440/1440) | 0 | +| `/web/search/mysql1` | 1440×900 | Populated | Expanded | Results visible | none (1440/1440) | 0 | +| `/web/search/no-such-instance` | 1440×900 | Empty | Expanded | Empty state visible | none (1440/1440) | 0 | +| `/web/audit` | 1440×900 | 20 rows | Expanded | Next `/1`, Previous `/0` | none (1440/1440) | 0 | +| `/web/audit-failure-detection` | 1440×900 | Empty | Expanded | Empty state visible | none (1440/1440) | 0 | +| `/web/audit-recovery` | 1440×900 | Empty | Expanded | Empty state visible | none (1440/1440) | 0 | +| `/web/audit-recovery/id/999999` | 1440×900 | Empty detail | Expanded | Not-found state visible | none (1440/1440) | 0 | +| `/web/status` | 1440×900 | Populated | Expanded | Status panels visible | none (1440/1440) | 0 | +| `/web/about` | 1440×900 | Populated | Expanded | ProxySQL links/content visible | none (1440/1440) | 0 | +| `/web/faq` | 1440×900 | Populated | Expanded | Documentation content visible | none (1440/1440) | 0 | +| `/web/agents` | 1440×900 | Disabled | Expanded | Disabled state visible | none (1440/1440) | 0 | +| `/web/seeds` | 1440×900 | Disabled | Expanded | Disabled state visible | none (1440/1440) | 0 | +| `/web/clusters` | 794×900 | 1 cluster | Collapsed; toggler visible | Toggler and Home open once | none (794/794) | 0 | +| `/web/cluster/mysql1:3306` | 794×900 | 3 cards, 3 nodes, 2 links | Collapsed | Collapse/expand, View, Details | none (794/794) | 0 | +| `/web/clusters-analysis` | 794×900 | ErrantGTIDStructureWarning | Collapsed | Incident visible | none (794/794) | 0 | +| `/web/discover` | 794×900 | Form ready | Collapsed | Inputs usable | none (794/794) | 0 | +| `/web/search/mysql1` | 794×900 | Populated | Collapsed | Results visible | none (794/794) | 0 | +| `/web/search/no-such-instance` | 794×900 | Empty | Collapsed | Empty state visible | none (794/794) | 0 | +| `/web/audit` | 794×900 | 20 rows | Collapsed | Next `/1`, Previous `/0` | none (794/794) | 0 | +| `/web/audit-failure-detection` | 794×900 | Empty | Collapsed | Empty state visible | none (794/794) | 0 | +| `/web/audit-recovery` | 794×900 | Empty | Collapsed | Empty state visible | none (794/794) | 0 | +| `/web/audit-recovery/id/999999` | 794×900 | Empty detail | Collapsed | Not-found state visible | none (794/794) | 0 | +| `/web/status` | 794×900 | Populated | Collapsed | Status panels visible | none (794/794) | 0 | +| `/web/about` | 794×900 | Populated | Collapsed | Links/content visible | none (794/794) | 0 | +| `/web/faq` | 794×900 | Populated | Collapsed | Documentation content visible | none (794/794) | 0 | +| `/web/agents` | 794×900 | Disabled | Collapsed | Disabled state visible | none (794/794) | 0 | +| `/web/seeds` | 794×900 | Disabled | Collapsed | Disabled state visible | none (794/794) | 0 | +| `/web/clusters` | 390×844 | 1 cluster | Collapsed; toggler visible | Toggler and Home open once | none (390/390) | 0 | +| `/web/cluster/mysql1:3306` | 390×844 | 3 cards, 3 nodes, 2 links | Collapsed | Collapse/expand, View, Details, canvas scroll | none (390/390) | 0 | +| `/web/clusters-analysis` | 390×844 | ErrantGTIDStructureWarning | Collapsed | Incident visible | none (390/390) | 0 | +| `/web/discover` | 390×844 | Form ready | Collapsed | Inputs usable | none (390/390) | 0 | +| `/web/search/mysql1` | 390×844 | Populated | Collapsed | Results visible | none (390/390) | 0 | +| `/web/search/no-such-instance` | 390×844 | Empty | Collapsed | Empty state visible | none (390/390) | 0 | +| `/web/audit` | 390×844 | 20 rows | Collapsed | Next `/1`, Previous `/0` | none (390/390) | 0 | +| `/web/audit-failure-detection` | 390×844 | Empty | Collapsed | Empty state visible | none (390/390) | 0 | +| `/web/audit-recovery` | 390×844 | Empty | Collapsed | Empty state visible | none (390/390) | 0 | +| `/web/audit-recovery/id/999999` | 390×844 | Empty detail | Collapsed | Not-found state visible | none (390/390) | 0 | +| `/web/status` | 390×844 | Populated | Collapsed | Status panels visible | none (390/390) | 0 | +| `/web/about` | 390×844 | Populated | Collapsed | Links/content visible | none (390/390) | 0 | +| `/web/faq` | 390×844 | Populated | Collapsed | Documentation content visible | none (390/390) | 0 | +| `/web/agents` | 390×844 | Disabled | Collapsed | Disabled state visible | none (390/390) | 0 | +| `/web/seeds` | 390×844 | Disabled | Collapsed | Disabled state visible | none (390/390) | 0 | + +## Interaction evidence + +- Topology began at 3 semantic cards / 3 D3 nodes / 2 links. Collapsing the primary produced 1 node / 0 links; expanding restored 3 / 2. +- One View activation opened exactly one menu. One Details activation opened exactly one `#node_modal` and one backdrop; dismissal removed both. +- The Details activation left the card's `left` and `top` coordinates unchanged, proving the interactive click did not initiate drag. +- At 390px, `#cluster_canvas` measured 342px client width and 960px scroll width with `overflow-x: auto`; real browser scrolling moved `scrollLeft` from 0 to 450 while the body remained 390px wide. +- At responsive widths, the navbar, Home, Audit, and Problems menus each opened exactly once; Problems remained inside the collapsed navigation below 992px. +- The viewport override was reset after testing. The deliverable tab was left at `/web/clusters` in the natural 1280×720 environment with no overflow or console entries. + +## Lab safety evidence + +- MySQL container IDs were unchanged before and after the Orchestrator-only rebuild: mysql1 `76e92eb4a8be3381c6fbe047dc5b2ac08038a0ec386404859142ad6b59a04ae8`, mysql2 `ca05b9577b38739f475a6799252d7c513fcc8858d6af7830812af94e8f40b41d`, mysql3 `cf2ffd96825ec9fa135f047c5546cc4214d83c7b573e44d2eadf09a60629a1ad`. +- Roles remained mysql1 writable primary and mysql2/mysql3 read-only replicas with IO/SQL replication running. +- ProxySQL remained running under container ID prefix `da43b`; no dependency lifecycle operation and no failover occurred. diff --git a/.superpowers/sdd/2026-08-18-consolidated-ui-integration/final-fix-1-report.md b/.superpowers/sdd/2026-08-18-consolidated-ui-integration/final-fix-1-report.md new file mode 100644 index 000000000..d5b384921 --- /dev/null +++ b/.superpowers/sdd/2026-08-18-consolidated-ui-integration/final-fix-1-report.md @@ -0,0 +1,66 @@ +# Final review fix round 1 — Dynamic Bootstrap controls + +Status: COMPLETE + +## Finding and root cause + +`bootstrap-legacy-bridge.js` normalizes legacy Bootstrap attributes only during `init` and `DOMContentLoaded`. The recovery dropdown in `cluster.js` and the two alert dismiss buttons in `orchestrator.js` are appended after those passes, so Bootstrap 5's data API could not activate the late controls from their legacy-only attributes. + +## RED evidence + +Added `TestDynamicBootstrapControlsEmitNativeAttributes` in `go/http/static_assets_test.go`. Before the implementation change: + +- `go test ./go/http -run '^TestDynamicBootstrapControlsEmitNativeAttributes$' -count=1` failed because the recovery dropdown lacked `data-bs-toggle="dropdown"`. +- The same test found zero of the two required alert emitters with `data-bs-dismiss="alert"`. + +## Implementation + +- The dynamic recovery button now emits both `data-toggle="dropdown"` and `data-bs-toggle="dropdown"`. +- `addAlert` and `addModalAlert` now emit both `data-dismiss="alert"` and `data-bs-dismiss="alert"`. +- `bootstrap-legacy-bridge.js` was not changed; no delegated bridge listener was added and its idempotence remains unchanged. +- Recovery `aria-expanded="true"` behavior remains unchanged. + +## Automated GREEN evidence + +- Focused regression: passed. +- `node --check resources/public/js/cluster.js`: passed. +- `node --check resources/public/js/orchestrator.js`: passed. +- All six `go/http/testdata/*_test.js` files: 26 tests passed, 0 failed. +- `go test ./go/http -count=1`: passed. +- `go test ./... -count=1`: all packages passed. +- Functional smoke: 50 passed, 0 failed, 0 skipped. +- `git diff --check`: passed before final verification. + +## Browser evidence + +After rebuilding the Linux/arm64 binary and recreating only Orchestrator with `--no-deps`, the Codex in-app Browser explicitly reloaded `/web/cluster/mysql1:3306`. + +A safe `?orchestrator-msg=dynamic-bootstrap-fix` query created one alert after page initialization. The single dismiss button had both `data-dismiss="alert"` and `data-bs-dismiss="alert"`. One real click removed the sole alert and dismiss button; both counts changed from one to zero. There were no warning or error console entries before or after the click, and no duplicate Bootstrap action/error was observed. + +The safe lab exposed no actionable recovery state and therefore rendered zero recovery dropdowns. Recovery dropdown activation remains static-contract-only; no failover was induced and no MySQL instance was stopped. + +## Lab safety evidence + +MySQL container IDs were unchanged before and after the Orchestrator-only rebuild: + +- mysql1: `76e92eb4a8be3381c6fbe047dc5b2ac08038a0ec386404859142ad6b59a04ae8` +- mysql2: `ca05b9577b38739f475a6799252d7c513fcc8858d6af7830812af94e8f40b41d` +- mysql3: `cf2ffd96825ec9fa135f047c5546cc4214d83c7b573e44d2eadf09a60629a1ad` + +ProxySQL remained running under unchanged container ID `da43b809c43d9ff755589194c3d154d40f28c6f4a9965b848849c9e0f7157a15`. mysql1 remained writable; mysql2/mysql3 remained read-only replicas of mysql1 with both IO and SQL threads running. Only Orchestrator changed container ID, from `bff2c0db72949309bfdb9454a879ae95e09782c79c190418ce9721107fbcb465` to `90780062e111efa000cf4cccc16b7919f5b1b16d0ef9fbaedcd8495ede44abbb`. + +## Concerns + +- Live recovery-dropdown interaction was intentionally not exercised because the safe lab had no actionable recovery state. The focused static regression proves the late emitter includes both legacy and native Bootstrap attributes. + +## Review fix round 1/5 — Scoped regression + +The initial regression scanned complete JavaScript files, so an identical attribute pair elsewhere in a file could satisfy the assertion after the intended emitter regressed. The test now extracts one exact named function declaration through the next function at the same indentation scope, rejecting missing or duplicate declarations and missing scope boundaries. + +Assertions are independent per emitter: + +- `onAnalysisEntry` must contain the `recover_dropdown_` hook and exactly one paired dropdown attribute. +- `addAlert` must contain exactly one paired alert-dismiss attribute. +- `addModalAlert` must contain exactly one paired alert-dismiss attribute. + +RED mutation evidence: after temporarily removing `data-bs-dismiss="alert"` only from `addAlert`, the focused test failed specifically with `addAlert native alert dismiss emitters = 0, want 1`. The production line was immediately restored, leaving this review commit test-only. Focused GREEN, full `go/http`, all Node UI tests, and diff/show checks passed; no Docker or browser work was repeated for this test-only review fix. diff --git a/.superpowers/sdd/2026-08-18-consolidated-ui-integration/final-fix-2a-report.md b/.superpowers/sdd/2026-08-18-consolidated-ui-integration/final-fix-2a-report.md new file mode 100644 index 000000000..d3854df77 --- /dev/null +++ b/.superpowers/sdd/2026-08-18-consolidated-ui-integration/final-fix-2a-report.md @@ -0,0 +1,48 @@ +# Final review fix round 2A — Malformed failure-analysis payloads + +Status: COMPLETE (scoped verification green; full Node run has concurrent audit-domain failures) + +## Finding and root cause + +`hasValidClustersAnalysisResponses` checked only the three response envelopes. A partial HTTP-200 response could therefore reach `buildClustersAnalysisModel`, which dereferenced `FailedInstanceKey`, `ClusterDetails`, and `AnalyzedInstanceKey`, or invoked `.forEach` on a truthy non-array `StructureAnalysis`. The exception escaped the jQuery `.done` callback before the existing unavailable renderer could hide the loading state, leaving loading or stale content visible. + +## RED evidence + +Four adapter-level behavior regressions were added to `go/http/testdata/clusters_analysis_state_test.js` for: + +- a blocked recovery missing `FailedInstanceKey`; +- an analysis entry missing `ClusterDetails`; +- an analysis entry missing `AnalyzedInstanceKey`; +- a string-valued `StructureAnalysis`. + +Each regression requires the adapter to hide loading, render `Analysis unavailable`, and avoid healthy, empty, incident-list, or loading markup. Before the production change, the focused command reported 13 passed and 4 failed. Each failure was the expected uncaught `TypeError` at the corresponding production dereference or iteration site. + +## Implementation + +The existing response validator now validates all display-relevant record and scalar shapes before model construction: + +- cluster identity, alias, and instance count; +- analysis code, instance key, cluster details, replica count, downtime state, and structural-analysis values; +- blocked-recovery analysis and failed-instance key. + +`StructureAnalysis` accepts an array of strings and also JSON `null`, because Go's nil `[]AnalysisCode` serializes as `null` and the existing model intentionally treats that as an empty list. Any other value, or a missing field, is rejected. Malformed successful responses now follow the existing unavailable-state return path. Model API, state precedence, unmatched actionable detection, sorting, topology paths, and escaping were not changed. + +## GREEN and verification evidence + +- `node --test go/http/testdata/clusters_analysis_state_test.js`: 17 passed, 0 failed. +- `node --check resources/public/js/clusters-analysis.js`: exit 0. +- `go test ./go/http`: passed. +- Scoped `git diff --check -- resources/public/js/clusters-analysis.js go/http/testdata/clusters_analysis_state_test.js`: passed. +- Full `node --test go/http/testdata/*.js`: all 17 clusters-analysis tests passed; the combined run reported 32 passed and 4 failures, all in concurrently added `audit_ui_safety_test.js` against concurrent audit-recovery/failure work outside this fix's scope. + +## Files changed by fix 2A + +- `resources/public/js/clusters-analysis.js` +- `go/http/testdata/clusters_analysis_state_test.js` +- `.superpowers/sdd/2026-08-18-consolidated-ui-integration/final-fix-2a-report.md` + +No files were staged or committed. Concurrent audit-domain changes were left untouched. + +## Concerns + +- The shared worktree's full Node UI suite is not globally green while concurrent audit safety work is incomplete. The failures are outside this change and do not involve failure-analysis code. diff --git a/.superpowers/sdd/2026-08-18-consolidated-ui-integration/final-fix-2b-report.md b/.superpowers/sdd/2026-08-18-consolidated-ui-integration/final-fix-2b-report.md new file mode 100644 index 000000000..ae59a6bbb --- /dev/null +++ b/.superpowers/sdd/2026-08-18-consolidated-ui-integration/final-fix-2b-report.md @@ -0,0 +1,104 @@ +# Final fix 2B report — audit route mode and DOM safety + +## Scope + +Owned changes: + +- `resources/public/js/audit-recovery.js` +- `resources/public/js/audit-failure-detection.js` +- `go/http/testdata/audit_ui_safety_test.js` +- this report + +Concurrent changes in `resources/public/js/clusters-analysis.js` and +`go/http/testdata/clusters_analysis_state_test.js` were not edited, staged, or +otherwise managed by this work. + +## Root cause + +1. Recovery list/detail mode was inferred from response cardinality with + `auditEntries.length == 1`. A normal list or filtered route that happened to + return one recovery therefore hid its pager/table and rendered the detail + workspace. Route intent is supplied independently by `recoveryId()` and + `recoveryUid()` and must be the only detail-mode signal. +2. Both audit scripts mixed API strings into HTML fragments before passing the + result to jQuery. Stored host, cluster, analysis, changelog, error, + acknowledgement, user, and comment values could therefore become parsed DOM. + The same values were appended to API and web paths without segment encoding. + Failure-detection expansion also interpolated an API ID into a selector. +3. Failure-detection pagination initialized `baseWebUri` through `appUrl()` and + then passed the complete prefixed URI through `appUrl()` again in both click + handlers. With a `/proxy` deployment prefix, navigation therefore targeted + `/proxy/proxy/web/...`; the working audit and recovery handlers assign the + already-prefixed base plus page directly. + +## Fix + +- `resolveRecoveryViewState` now selects `unavailable`, `empty`, `detail`, or + `list` explicitly. A non-empty response becomes detail only on an ID or UID + route; a one-row list remains a list with its table and pager. +- API text is rendered through jQuery-created elements using `.text()` and safe + `.attr()` calls. Only fixed application markup is created as markup. +- Route segments for recovery and failure-detection API calls, detail links, + search links, cluster links, related-record links, and pager bases use + `encodeURIComponent`. +- Recovery acknowledgement and metadata renderers and failure-detection metadata + renderers are small browser-compatible helpers with CommonJS exports for real + behavior tests. +- Failure-detection row expansion compares data attributes rather than building + a selector from an API value. +- `failureDetectionPagerUrl` appends the page to the already-prefixed base URI; + previous and next handlers no longer apply the application prefix twice. + +## TDD evidence + +Initial RED: + +```text +node --test go/http/testdata/audit_ui_safety_test.js +tests 6; pass 0; fail 6 +``` + +All six assertions failed because the route-state and safe DOM builders did not +exist. A second RED added direct request/pager path coverage and failed 1 of 7 +tests because the segment helpers were not exported. + +An adjacent pager RED then passed the existing seven tests and failed one new +assertion because `failureDetectionPagerUrl` did not exist. The fixture used the +prefixed base `/proxy/web/audit-failure-detection/` and required previous/next +targets containing exactly one `/proxy` prefix. + +Focused GREEN: + +```text +node --test go/http/testdata/audit_ui_safety_test.js +tests 8; pass 8; fail 0 +``` + +The hostile fixture contains an `` payload plus quotes, apostrophe, +ampersand, slashes, spaces, and question marks. Tests assert inert escaped text, +encoded URL segments, one-row list mode, ID/UID detail mode, and exclusive empty +and unavailable states. + +## Verification + +Fresh verification completed at HEAD `7e68960817c5f5f28429da409908cf8f00fce037`: + +```text +node --check resources/public/js/audit-recovery.js PASS +node --check resources/public/js/audit-failure-detection.js PASS +for file in go/http/testdata/*_test.js; do node --test "$file"; done PASS (38 tests) +go test ./go/http -count=1 PASS +git diff --check PASS +``` + +No Docker, browser, git-index, commit, or GitHub operation was performed, per +the task boundary. + +## Concerns + +- Verification is automated only because this independent fix explicitly + prohibited Docker and browser work. The helpers exercise actual rendering + construction with a hostile DOM serializer, while existing live acceptance + remains the integration-level UI evidence. +- Recovery steps are rendered by the separate shared script with `.text()`; + this change only encodes the UID before handing it to that script. diff --git a/docs/superpowers/plans/2026-08-11-restorative-topology-ui.md b/docs/superpowers/plans/2026-08-11-restorative-topology-ui.md new file mode 100644 index 000000000..6d8426b10 --- /dev/null +++ b/docs/superpowers/plans/2026-08-11-restorative-topology-ui.md @@ -0,0 +1,136 @@ +# Restorative Topology UI Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Restore the cluster-detail page to a legible, calm topology workspace that evokes the historical Orchestrator UI while retaining the current API and topology behavior. + +**Architecture:** Keep the D3 v3 topology renderer and its absolute-positioned instance cards intact for the first release. Add a cluster-scoped workspace shell and semantic card hooks around the existing renderer, with all presentation rules isolated in a new stylesheet. The global Bootstrap 5 layout remains unchanged. + +**Tech Stack:** Go HTML templates, Go `httptest` template tests, jQuery, D3 v3, existing functional Docker lab, CSS. + +## Global Constraints + +- Do not change recovery, failover, drag-and-drop, API, or discovery semantics. +- Scope new visual rules below `#cluster_workspace`; do not extend the global Bootstrap compatibility layer. +- Preserve current `data-command`, instance modal, and D3 card positioning hooks. +- Make state understandable with text and icons as well as color. +- Keep the first release desktop-first, with a narrow-screen fallback that remains usable. +- Do not add or commit `.superpowers/` visual-companion artifacts. + +--- + +## File Map + +| File | Responsibility | +| --- | --- | +| `resources/templates/cluster.tmpl` | Add the cluster workspace shell, toolbar, canvas, and semantic navigation landmarks. | +| `resources/public/css/cluster-workspace.css` | Contain the restorative topology layout, card states, menu, and responsive rules. | +| `resources/public/js/orchestrator.js` | Render stable semantic card regions and a compact action trigger. | +| `resources/public/js/cluster.js` | Delegate the new action trigger without disturbing existing node controls. | +| `resources/public/js/cluster-tree.js` | Keep the D3 canvas sized to the workspace viewport. | +| `go/http/render_test.go` | Verify cluster template markup and stylesheet inclusion. | +| `go/http/static_assets_test.go` | Verify the new static asset and card interaction hooks are shipped. | +| `tests/functional/test-smoke.sh` | Assert the live lab serves the cluster workspace shell. | + +## Task 1: Establish a rendering contract + +**Files:** `go/http/render_test.go`, `go/http/static_assets_test.go` + +- [ ] Add `TestRenderClusterWorkspace` using `templates/cluster` and the existing sample template data. +- [ ] Assert the response has `id="cluster_workspace"`, `id="cluster_canvas"`, and `/css/cluster-workspace.css`. +- [ ] Add static source assertions for the new CSS file and the semantic card/action hook names. +- [ ] Run the focused Go tests: + +```bash +go test ./go/http -run 'TestRenderClusterWorkspace|TestStatic' +``` + +Expected: the focused test suite passes before markup behavior is changed. + +- [ ] Commit the contract test first. + +```bash +git add go/http/render_test.go go/http/static_assets_test.go +git commit -m "test(ui): define cluster workspace rendering contract" +``` + +## Task 2: Add the scoped cluster workspace shell + +**Files:** `resources/templates/cluster.tmpl`, `resources/public/css/cluster-workspace.css` + +- [ ] Include the stylesheet through the cluster page’s template block. +- [ ] Wrap the existing sidebar and topology area in `#cluster_workspace` without renaming legacy IDs. +- [ ] Add landmarks for a compact cluster header, command rail, topology canvas, and an accessible live status region. +- [ ] Move no controls: existing `data-command` links must remain present and operational. +- [ ] Implement the base palette: a restrained dark application chrome, light canvas, and a narrow low-noise rail. +- [ ] Run `go test ./go/http -run TestRenderClusterWorkspace` and inspect the rendered page source with curl. +- [ ] Commit the shell and CSS foundation. + +```bash +git add resources/templates/cluster.tmpl resources/public/css/cluster-workspace.css +git commit -m "feat(ui): add restorative cluster workspace shell" +``` + +## Task 3: Render semantic node cards + +**Files:** `resources/public/js/orchestrator.js`, `resources/public/css/cluster-workspace.css`, `resources/public/js/cluster.js` + +- [ ] Refactor `renderInstanceElement` to emit named card regions for identity, role, health, replication, and actions. +- [ ] Preserve each existing status calculation, warning text, and the instance element’s current dimensions/positioning contract. +- [ ] Replace icon-only card affordances with a concise, labelled details/action trigger while retaining the modal entry point. +- [ ] Give warning and fatal states explicit label treatment in addition to their color treatment. +- [ ] Update delegated click handling so the new trigger opens the same node modal and ordinary card dragging is not intercepted. +- [ ] Add CSS for quiet normal cards, a clear primary badge, replica state, warning/fatal emphasis, and a visible selected state. +- [ ] Run the focused Go static-asset tests and `git diff --check`. +- [ ] Commit semantic card rendering. + +```bash +git add resources/public/js/orchestrator.js resources/public/js/cluster.js resources/public/css/cluster-workspace.css go/http/static_assets_test.go +git commit -m "feat(ui): restore semantic topology node cards" +``` + +## Task 4: Fit the topology renderer into its workspace + +**Files:** `resources/public/js/cluster-tree.js`, `resources/public/css/cluster-workspace.css` + +- [ ] Measure the D3 viewport from `#cluster_canvas` while retaining a safe fallback to `#cluster_container`. +- [ ] Keep the existing tree geometry, line drawing, pan/zoom behavior, and `repositionIntanceDiv` integration. +- [ ] Add responsive canvas and card rules for a narrow browser window; retain horizontal exploration rather than crushing nodes. +- [ ] Check syntax by loading the page in the running lab and checking the browser console manually. +- [ ] Commit the renderer integration. + +```bash +git add resources/public/js/cluster-tree.js resources/public/css/cluster-workspace.css +git commit -m "feat(ui): fit topology graph to workspace canvas" +``` + +## Task 5: Verify with the three-node Docker lab + +**Files:** `tests/functional/test-smoke.sh` + +- [ ] Extend the existing smoke script with a request to `/web/cluster/mysql1:3306`. +- [ ] Assert the returned HTML includes the workspace shell and its stylesheet; retain the existing API checks. +- [ ] Run the local functional lab and then: + +```bash +bash tests/functional/test-smoke.sh +curl -fsS http://localhost:3099/web/cluster/mysql1:3306 | rg 'cluster_workspace|cluster-workspace.css' +``` + +Expected: smoke tests pass and both workspace identifiers are present. + +- [ ] Perform manual visual checks in the user’s open browser: 3-node tree, primary/replica distinction, warning state, modal details, and narrow viewport fallback. +- [ ] Commit the smoke coverage. + +```bash +git add tests/functional/test-smoke.sh +git commit -m "test(ui): smoke-test cluster workspace" +``` + +## Task 6: Final verification and handoff + +- [ ] Run `go test ./go/http`. +- [ ] Run `git diff --check` and inspect `git status --short` to confirm only intended source files are tracked. +- [ ] Run the functional smoke script against the Docker lab. +- [ ] Review the rendered cluster page in the local browser at desktop and narrow widths. +- [ ] Summarize preserved behavior, visual changes, and any intentionally deferred renderer modernization. diff --git a/docs/superpowers/plans/2026-08-12-cluster-flow-shell.md b/docs/superpowers/plans/2026-08-12-cluster-flow-shell.md new file mode 100644 index 000000000..902969d5a --- /dev/null +++ b/docs/superpowers/plans/2026-08-12-cluster-flow-shell.md @@ -0,0 +1,97 @@ +# Cluster Flow Shell Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `/web/clusters` a useful operational landing page and make its transition into cluster topology visually coherent. + +**Architecture:** Retain current routes, APIs, and JavaScript data flow. Add a page-scoped clusters landing stylesheet and template landmarks, then reuse the compact shell vocabulary on cluster detail by moving existing display controls into a labelled inline View menu. + +**Tech Stack:** Go templates/tests, existing jQuery/D3 v3, CSS, functional Docker lab. + +## Global Constraints + +- Preserve routes, APIs, recovery/failover, drag/drop, D3 positioning, and node modal behavior. +- Preserve existing cluster command values while changing their presentation. +- Keep page-specific CSS scoped; do not extend global Bootstrap compatibility shims. +- The `/` redirect remains `/web/clusters`; the landing page must stand on its own. + +--- + +## File Map + +| File | Responsibility | +| --- | --- | +| `resources/templates/clusters.tmpl` | Landing-page shell and operational list landmarks. | +| `resources/public/css/clusters-workspace.css` | Scoped landing-page layout and responsive styles. | +| `resources/templates/cluster.tmpl` | Compact identity row and inline View menu. | +| `resources/public/css/cluster-workspace.css` | Detail-shell refinement without topology changes. | +| `resources/public/js/cluster.js` | Existing command delegation adapted to the inline menu only if required. | +| `go/http/render_test.go` | Template contracts for landing and retained detail controls. | +| `tests/functional/test-smoke.sh` | Landing, redirect, and detail-page HTTP smoke checks. | + +## Task 1: Protect landing and detail page contracts + +**Files:** `go/http/render_test.go` + +- [ ] Add a failing rendered `templates/clusters` test asserting `id="clusters_workspace"`, `id="clusters_list"`, and `/css/clusters-workspace.css`. +- [ ] Add a detail-template assertion for an inline `View` control while retaining all `data-command` values. +- [ ] Run `go test ./go/http -run 'TestRenderClustersWorkspace|TestRenderClusterWorkspace'` and confirm it is red before markup. +- [ ] Implement no production code in this task; commit the contract. + +```bash +git add go/http/render_test.go +git commit -m "test(ui): define cluster flow shell contracts" +``` + +## Task 2: Build the clusters operational landing page + +**Files:** `resources/templates/clusters.tmpl`, `resources/public/css/clusters-workspace.css` + +- [ ] Load the new stylesheet and wrap existing cluster results in `#clusters_workspace` and `#clusters_list` without replacing existing JavaScript IDs/classes. +- [ ] Add a compact landing header with title, known-cluster count region, and a labelled discovery action. +- [ ] Style cluster rows/cards for name, primary, members, health/problems, and explicit open action; use the data already rendered by `clusters.js`. +- [ ] Keep all CSS below `#clusters_workspace` and add a narrow-screen single-column fallback. +- [ ] Run `go test ./go/http -run TestRenderClustersWorkspace` and `node --check resources/public/js/clusters.js`. +- [ ] Commit the landing page. + +```bash +git add resources/templates/clusters.tmpl resources/public/css/clusters-workspace.css go/http/render_test.go +git commit -m "feat(ui): add operational clusters landing page" +``` + +## Task 3: Flatten cluster-detail chrome + +**Files:** `resources/templates/cluster.tmpl`, `resources/public/css/cluster-workspace.css`, `resources/public/js/cluster.js` + +- [ ] Replace the visually dominant rail treatment with a compact inline View menu beside the existing identity/status row. +- [ ] Move no `data-command` elements semantically: preserve their values, delegated handlers, keyboard behavior, and default-navigation guard. +- [ ] Keep topology immediately below the compact header; do not alter D3 sizing, graph geometry, cards, or modal behavior. +- [ ] Add CSS for the menu’s open/focus state and narrow-screen wrapping, all scoped under `#cluster_workspace`. +- [ ] Run `go test ./go/http`, `node --check resources/public/js/cluster.js`, and `git diff --check`. +- [ ] Commit the detail-shell refinement. + +```bash +git add resources/templates/cluster.tmpl resources/public/css/cluster-workspace.css resources/public/js/cluster.js go/http/render_test.go +git commit -m "feat(ui): simplify cluster topology chrome" +``` + +## Task 4: Verify the redirected user flow + +**Files:** `tests/functional/test-smoke.sh` + +- [ ] Extend smoke coverage for `/` redirecting to `/web/clusters`, the landing shell, and `/web/cluster/mysql1:3306` retaining its topology shell. +- [ ] Rebuild/recreate only the Orchestrator lab container from this worktree, preserving the MySQL topology. +- [ ] Run `bash tests/functional/test-smoke.sh` and assert `curl -fsSI http://localhost:3099/` returns the cluster landing redirect. +- [ ] Manually inspect landing → cluster detail, menu controls, node Details, and narrow viewport in the user browser. +- [ ] Commit smoke coverage. + +```bash +git add tests/functional/test-smoke.sh +git commit -m "test(ui): verify cluster landing flow" +``` + +## Task 5: Final verification + +- [ ] Run `go test ./go/http`, `git diff --check`, and the functional smoke script. +- [ ] Confirm `git status --short` contains no tracked scratch files. +- [ ] Review the complete landing-to-topology route in the Docker lab and summarize preserved operational behavior. diff --git a/docs/superpowers/plans/2026-08-12-failure-analysis-workspace.md b/docs/superpowers/plans/2026-08-12-failure-analysis-workspace.md new file mode 100644 index 000000000..33b0a350c --- /dev/null +++ b/docs/superpowers/plans/2026-08-12-failure-analysis-workspace.md @@ -0,0 +1,625 @@ +# Failure Analysis Workspace Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace `/web/clusters-analysis` legacy popovers with a responsive, semantic incident workspace consistent with the restored cluster dashboard. + +**Architecture:** Keep the existing three API endpoints and refresh policy. Split the browser code into pure data-model and markup functions, with a thin document-ready adapter that loads data, renders one of loading/content/empty/error states, and preserves blocked-recovery alerts. A dedicated stylesheet is scoped under `#clusters_analysis_workspace` so the rest of Orchestrator remains untouched. + +**Tech Stack:** Go HTML templates and `httptest`, browser JavaScript with jQuery, Node's built-in test runner and `vm`, scoped CSS, Docker Compose live lab. + +## Global Constraints + +- Do not change failure-detection logic, recovery decisions, API response shapes, polling intervals, or other audit pages. +- Preserve `/api/clusters-info`, `/api/replication-analysis`, and `/api/blocked-recoveries` as the data sources. +- Preserve authorized-user refresh behavior and blocked-recovery audit links. +- Default aliases equal to the cluster name must not be displayed twice and must use the canonical cluster-name topology route. +- No page-level horizontal scrolling is permitted at narrow widths. +- State must be conveyed by text in addition to color. +- Dynamic API strings must be HTML-escaped before insertion into markup. + +--- + +### Task 1: Semantic Failure Analysis Shell + +**Files:** +- Modify: `go/http/render_test.go` +- Modify: `resources/templates/clusters_analysis.tmpl` +- Create: `resources/public/css/clusters-analysis-workspace.css` + +**Interfaces:** +- Produces: `#clusters_analysis_workspace`, `#clusters_analysis_summary`, `#clusters_analysis_list`, `#clusters_analysis_loading`, and `.clusters-analysis-dashboard-link` for later renderer and browser checks. +- Consumes: existing template fields `.prefix` and `.removeTextFromHostnameDisplay`. + +- [ ] **Step 1: Write the failing shell test** + +Add this focused test to `go/http/render_test.go`: + +```go +func TestRenderClustersAnalysisWorkspace(t *testing.T) { + chdirToRepoRoot(t) + clearContentTemplateCache() + + rec := httptest.NewRecorder() + renderHTML(rec, http.StatusOK, "templates/clusters_analysis", sampleTemplateData()) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + for _, expected := range []string{ + `id="clusters_analysis_workspace"`, + `aria-labelledby="clusters_analysis_title"`, + `id="clusters_analysis_summary"`, + `role="status"`, + `id="clusters_analysis_loading"`, + `id="clusters_analysis_list"`, + `href="/web/clusters"`, + `/css/clusters-analysis-workspace.css`, + } { + if !strings.Contains(body, expected) { + t.Errorf("expected failure analysis workspace contract %q", expected) + } + } +} +``` + +- [ ] **Step 2: Run the shell test and verify RED** + +Run: `go test ./go/http -run TestRenderClustersAnalysisWorkspace -count=1` + +Expected: FAIL because `clusters_analysis.tmpl` has only the legacy `#clusters_analysis` container. + +- [ ] **Step 3: Implement the semantic template shell** + +Replace the legacy container in `resources/templates/clusters_analysis.tmpl` with this structure, retaining the hostname helper and script dependencies below it: + +```html + + +
+
+
+

Recovery operations

+

Failure analysis

+

Loading active incidents…

+
+ Cluster dashboard +
+
+ +
Loading active incidents…
+
+
+
+``` + +Create `clusters-analysis-workspace.css` with an initial root rule only: + +```css +#clusters_analysis_workspace { + --analysis-accent: #2f6b9e; + --analysis-background: #f4f3ef; + --analysis-border: #d5d9dc; + --analysis-chrome: #202b36; + --analysis-muted: #687582; + --analysis-panel: #ffffff; + --analysis-text: #263442; +} +``` + +- [ ] **Step 4: Run the shell and complete HTTP tests** + +Run: `gofmt -w go/http/render_test.go && go test ./go/http -count=1` + +Expected: PASS. + +- [ ] **Step 5: Commit the shell** + +```bash +git add go/http/render_test.go resources/templates/clusters_analysis.tmpl resources/public/css/clusters-analysis-workspace.css +git commit -m "feat(ui): add failure analysis workspace shell" +``` + +--- + +### Task 2: Pure Incident Display Model + +**Files:** +- Modify: `go/http/testdata/clusters_analysis_state_test.js` +- Modify: `resources/public/js/clusters-analysis.js` + +**Interfaces:** +- Produces: `buildClustersAnalysisModel(clusters, replicationAnalysis, blockedRecoveries, interestingAnalysisMap) -> {clusters: AnalysisCluster[], incidentCount: number}`. +- Produces each `AnalysisCluster` with `{clusterName, displayName, alias, topologyPath, countInstances, allDowntimed, state, entries}`. +- Produces each entry with `{analysis, instance, state, statusLabel, impactLabel, replicaCount, downtimeEndTimestamp}`. +- Consumes: `clusterAnalysisTopologyPath(cluster, compact)` from the same file. + +- [ ] **Step 1: Add failing behavior tests for model derivation** + +Extend `go/http/testdata/clusters_analysis_state_test.js` with literal fixtures and expectations: + +```js +test("incident model derives actionable, blocked, downtimed, and structural entries", function() { + const clusters = [{ClusterName: "mysql1:3306", ClusterAlias: "mysql1:3306", CountInstances: 3}]; + const replicationAnalysis = {Details: [{ + Analysis: "DeadMaster", + AnalyzedInstanceKey: {Hostname: "mysql1", Port: 3306}, + ClusterDetails: {ClusterName: "mysql1:3306"}, + CountReplicas: 2, + IsDowntimed: false, + StructureAnalysis: ["ErrantGTIDStructureWarning"], + }]}; + const blocked = [{ + FailedInstanceKey: {Hostname: "mysql1", Port: 3306}, + Analysis: "DeadMaster", + }]; + + const model = sandbox.buildClustersAnalysisModel( + clusters, + replicationAnalysis, + blocked, + {DeadMaster: true} + ); + + assert.equal(model.incidentCount, 2); + assert.equal(model.clusters.length, 1); + assert.equal(model.clusters[0].topologyPath, "/web/cluster/mysql1:3306?compact=true"); + assert.equal(model.clusters[0].state, "blocked"); + assert.deepEqual(JSON.parse(JSON.stringify(model.clusters[0].entries)), [ + { + analysis: "DeadMaster", + instance: "mysql1:3306", + state: "blocked", + statusLabel: "Recovery blocked", + impactLabel: "Affected replicas", + replicaCount: 2, + downtimeEndTimestamp: "", + }, + { + analysis: "ErrantGTIDStructureWarning", + instance: "mysql1:3306", + state: "warning", + statusLabel: "Structural warning", + impactLabel: "Participating replicas", + replicaCount: 2, + downtimeEndTimestamp: "", + }, + ]); +}); + +test("incident model reports a downtimed analysis without mutating API input", function() { + const entry = { + Analysis: "DeadMaster", + AnalyzedInstanceKey: {Hostname: "mysql1", Port: 3306}, + ClusterDetails: {ClusterName: "mysql1:3306"}, + CountReplicas: 2, + IsDowntimed: true, + DowntimeEndTimestamp: "2026-08-12 04:00:00", + StructureAnalysis: [], + }; + const model = sandbox.buildClustersAnalysisModel( + [{ClusterName: "mysql1:3306", ClusterAlias: "production", CountInstances: 3}], + {Details: [entry]}, + [], + {DeadMaster: true} + ); + + assert.equal(model.clusters[0].alias, "production"); + assert.equal(model.clusters[0].state, "downtimed"); + assert.equal(model.clusters[0].entries[0].statusLabel, "Downtimed"); + assert.equal(model.clusters[0].entries[0].downtimeEndTimestamp, "2026-08-12 04:00:00"); + assert.equal(entry.IsStructureAnalysis, undefined); + assert.equal(entry.Analysis, "DeadMaster"); +}); +``` + +- [ ] **Step 2: Run the model tests and verify RED** + +Run: `node --test go/http/testdata/clusters_analysis_state_test.js` + +Expected: FAIL with `buildClustersAnalysisModel is not a function`. + +- [ ] **Step 3: Implement the pure model builder** + +Add top-level helpers before `$(document).ready(...)` in `clusters-analysis.js`: + +```js +function clustersAnalysisBlockedKey(hostname, port, analysis) { + return hostname + ":" + port + ":" + analysis; +} + +function buildClustersAnalysisModel(clusters, replicationAnalysis, blockedRecoveries, interestingAnalysisMap) { + var blocked = {}; + (blockedRecoveries || []).forEach(function(recovery) { + var key = clustersAnalysisBlockedKey( + recovery.FailedInstanceKey.Hostname, + recovery.FailedInstanceKey.Port, + recovery.Analysis + ); + blocked[key] = true; + }); + + var byName = {}; + (clusters || []).forEach(function(cluster) { + byName[cluster.ClusterName] = { + clusterName: cluster.ClusterName, + displayName: cluster.ClusterName, + alias: cluster.ClusterAlias && cluster.ClusterAlias != cluster.ClusterName ? cluster.ClusterAlias : "", + topologyPath: clusterAnalysisTopologyPath(cluster, true), + countInstances: cluster.CountInstances, + allDowntimed: true, + state: "downtimed", + entries: [], + }; + }); + + function appendEntry(apiEntry, analysis, structural) { + var cluster = byName[apiEntry.ClusterDetails.ClusterName]; + if (!cluster) { + return; + } + var isBlocked = !!blocked[clustersAnalysisBlockedKey( + apiEntry.AnalyzedInstanceKey.Hostname, + apiEntry.AnalyzedInstanceKey.Port, + analysis + )]; + var state = structural ? "warning" : (isBlocked ? "blocked" : (apiEntry.IsDowntimed ? "downtimed" : "actionable")); + var labels = { + actionable: "Requires attention", + blocked: "Recovery blocked", + downtimed: "Downtimed", + warning: "Structural warning", + }; + cluster.entries.push({ + analysis: analysis, + instance: apiEntry.AnalyzedInstanceKey.Hostname + ":" + apiEntry.AnalyzedInstanceKey.Port, + state: state, + statusLabel: labels[state], + impactLabel: structural ? "Participating replicas" : "Affected replicas", + replicaCount: apiEntry.CountReplicas, + downtimeEndTimestamp: apiEntry.IsDowntimed ? (apiEntry.DowntimeEndTimestamp || "") : "", + }); + if (!apiEntry.IsDowntimed) { + cluster.allDowntimed = false; + } + } + + ((replicationAnalysis && replicationAnalysis.Details) || []).forEach(function(entry) { + if (Object.prototype.hasOwnProperty.call(interestingAnalysisMap, entry.Analysis)) { + appendEntry(entry, entry.Analysis, false); + } + (entry.StructureAnalysis || []).forEach(function(analysis) { + appendEntry(entry, analysis, true); + }); + }); + + var precedence = {blocked: 4, actionable: 3, warning: 2, downtimed: 1}; + var affected = Object.keys(byName).map(function(name) { + var cluster = byName[name]; + cluster.entries.forEach(function(entry) { + if (precedence[entry.state] > precedence[cluster.state]) { + cluster.state = entry.state; + } + }); + return cluster; + }).filter(function(cluster) { + return cluster.entries.length > 0; + }); + + affected.sort(function(a, b) { + if (a.allDowntimed != b.allDowntimed) { + return a.allDowntimed ? 1 : -1; + } + return (b.countInstances - a.countInstances) || a.clusterName.localeCompare(b.clusterName); + }); + + return { + clusters: affected, + incidentCount: affected.reduce(function(total, cluster) { return total + cluster.entries.length; }, 0), + }; +} +``` + +After model construction, apply `removeTextFromHostnameDisplay()` to a copied `displayName` only; do not alter `clusterName` or URLs. + +- [ ] **Step 4: Run model tests and all JavaScript behavior tests** + +Run: `node --test go/http/testdata/*.js` + +Expected: PASS with the new model tests and existing route/policy tests. + +- [ ] **Step 5: Commit the incident model** + +```bash +git add go/http/testdata/clusters_analysis_state_test.js resources/public/js/clusters-analysis.js +git commit -m "feat(ui): derive failure analysis incident model" +``` + +--- + +### Task 3: Semantic Incident, Empty, and Error Rendering + +**Files:** +- Modify: `go/http/testdata/clusters_analysis_state_test.js` +- Modify: `resources/public/js/clusters-analysis.js` +- Modify: `resources/templates/clusters_analysis.tmpl` + +**Interfaces:** +- Consumes: the `AnalysisCluster` model from Task 2. +- Produces: `renderClustersAnalysisMarkup(model) -> string`. +- Produces: `renderClustersAnalysisEmptyState() -> string` and `renderClustersAnalysisUnavailableState() -> string`. +- Produces: `escapeClustersAnalysisHTML(value) -> string` for all API-derived text and attributes. +- The document adapter writes markup only to `#clusters_analysis_list`, hides `#clusters_analysis_loading`, and updates `#clusters_analysis_summary`. + +- [ ] **Step 1: Add failing renderer tests** + +Add these tests to `clusters_analysis_state_test.js`: + +```js +test("incident markup is semantic, escaped, and contains one clear topology action", function() { + const html = sandbox.renderClustersAnalysisMarkup({incidentCount: 1, clusters: [{ + clusterName: "mysql1:3306", + displayName: "", + alias: "", + topologyPath: "/web/cluster/mysql1:3306?compact=true", + countInstances: 3, + allDowntimed: false, + state: "actionable", + entries: [{ + analysis: "DeadMaster", + instance: "mysql1:3306", + state: "actionable", + statusLabel: "Requires attention", + impactLabel: "Affected replicas", + replicaCount: 2, + downtimeEndTimestamp: "", + }], + }]}); + + assert.match(html, /]+data-cluster-name="mysql1:3306"/); + assert.match(html, /<mysql1>/); + assert.doesNotMatch(html, //); + assert.match(html, /DeadMaster/); + assert.match(html, /Affected replicas/); + assert.match(html, /href="\/web\/cluster\/mysql1:3306\?compact=true"/); + assert.equal((html.match(/Open topology/g) || []).length, 1); + assert.doesNotMatch(html, /popover|popover-title|popover-content/); +}); + +test("empty and unavailable states cannot be confused", function() { + const empty = sandbox.renderClustersAnalysisEmptyState(); + const unavailable = sandbox.renderClustersAnalysisUnavailableState(); + + assert.match(empty, /No active failover incidents/); + assert.doesNotMatch(empty, /DeadMaster|interestingAnalysis/); + assert.match(unavailable, /Failure analysis is temporarily unavailable/); + assert.match(unavailable, /Reload page/); +}); +``` + +- [ ] **Step 2: Run renderer tests and verify RED** + +Run: `node --test go/http/testdata/clusters_analysis_state_test.js` + +Expected: FAIL because the three render functions do not exist. + +- [ ] **Step 3: Implement escaped semantic markup** + +Implement `escapeClustersAnalysisHTML` using replacements for `&`, `<`, `>`, `"`, and `'`. Implement the three pure render functions with these class hooks: + +```html +
+
...
+
    +
  • ...
  • +
+ +
+``` + +Every API-derived value and URL must pass through `escapeClustersAnalysisHTML`. Each state must include visible copy: `Requires attention`, `Recovery blocked`, `Downtimed`, or `Structural warning`. + +- [ ] **Step 4: Replace the legacy document adapter** + +In the document-ready block: + +- call `buildClustersAnalysisModel(...)` once all three required API calls succeed; +- hide `#clusters_analysis_loading`; +- update summary to `N active incident(s) across M cluster(s)`; +- insert `renderClustersAnalysisMarkup(model)` or `renderClustersAnalysisEmptyState()`; +- on any required request failure, hide the loader, set summary to `Analysis unavailable`, and insert `renderClustersAnalysisUnavailableState()`; +- keep the separate blocked-recovery alerts and authorized refresh timer; +- remove all `popover`, `popover-title`, `popover-content`, `.popover()`, and `.show()` rendering code. + +Use the existing jQuery Deferred failure callbacks; do not change endpoints or polling. + +- [ ] **Step 5: Run behavior, syntax, and HTTP tests** + +Run: + +```bash +node --test go/http/testdata/*.js +node --check resources/public/js/clusters-analysis.js +go test ./go/http -count=1 +``` + +Expected: all commands PASS. + +- [ ] **Step 6: Commit the semantic renderer** + +```bash +git add go/http/testdata/clusters_analysis_state_test.js resources/public/js/clusters-analysis.js resources/templates/clusters_analysis.tmpl +git commit -m "feat(ui): render semantic failure incidents" +``` + +--- + +### Task 4: Restorative Responsive Styling + +**Files:** +- Modify: `resources/public/css/clusters-analysis-workspace.css` +- Modify: `go/http/static_assets_test.go` + +**Interfaces:** +- Consumes: Task 1 shell hooks and Task 3 semantic incident hooks. +- Produces: desktop three-column incident rows and narrow single-column cards without body overflow. + +- [ ] **Step 1: Add the failing CSS scope contract** + +Add a test to `go/http/static_assets_test.go` that reads `clusters-analysis-workspace.css`, requires selectors for all semantic hooks, and rejects unscoped rule starts: + +```go +func TestClustersAnalysisWorkspaceStylesAreScoped(t *testing.T) { + chdirToRepoRoot(t) + source, err := os.ReadFile(filepath.Join("resources", "public", "css", "clusters-analysis-workspace.css")) + if err != nil { + t.Fatal(err) + } + css := string(source) + for _, selector := range []string{ + "#clusters_analysis_workspace .clusters-analysis-header", + "#clusters_analysis_workspace .analysis-cluster", + "#clusters_analysis_workspace .analysis-entry", + "#clusters_analysis_workspace .analysis-cluster-impact", + "#clusters_analysis_workspace .clusters-analysis-empty", + "#clusters_analysis_workspace .clusters-analysis-unavailable", + } { + if !strings.Contains(css, selector) { + t.Errorf("missing scoped selector %q", selector) + } + } + if strings.Contains(css, "\n.popover") || strings.Contains(css, "\n.container") { + t.Fatal("failure analysis stylesheet leaks legacy global selectors") + } +} +``` + +- [ ] **Step 2: Run the CSS contract and verify RED** + +Run: `go test ./go/http -run TestClustersAnalysisWorkspaceStylesAreScoped -count=1` + +Expected: FAIL on missing semantic selectors. + +- [ ] **Step 3: Implement the complete scoped stylesheet** + +Build on the Task 1 variables and implement: + +- full-bleed warm workspace with `min-height: calc(100vh - 56px)`; +- 88px charcoal flex header matching `clusters-workspace.css`; +- centered content at `max-width: 1180px`; +- desktop row grid `minmax(220px, .9fr) minmax(360px, 1.6fr) minmax(180px, .7fr)`; +- white 5px-radius rows with 1px borders and a 4px state strip; +- compact analysis-entry list with readable status pills and impact numbers; +- blue `Open topology` action and visible `:focus` outlines; +- muted, centered empty/unavailable panels; +- state colors: actionable `#b94a48`, blocked `#8e3b46`, warning `#b17817`, downtimed `#687582`; +- `@media (max-width: 760px)` hiding column labels and making rows one column; +- `overflow-wrap: anywhere` on identity and instance text; +- no fixed content widths or negative horizontal offsets inside rows. + +- [ ] **Step 4: Run CSS, HTTP, and diff checks** + +Run: + +```bash +go test ./go/http -count=1 +git diff --check +``` + +Expected: PASS. + +- [ ] **Step 5: Commit responsive styling** + +```bash +git add resources/public/css/clusters-analysis-workspace.css go/http/static_assets_test.go +git commit -m "feat(ui): style failure analysis workspace" +``` + +--- + +### Task 5: Live Lab and Browser Verification + +**Files:** +- Modify: `tests/functional/test-smoke.sh` +- Modify if browser evidence identifies a defect: only files introduced or modified in Tasks 1-4 + +**Interfaces:** +- Consumes: completed workspace route and static assets. +- Produces: repeatable smoke coverage and verified desktop/narrow behavior in the three-MySQL lab. + +- [ ] **Step 1: Add failing live smoke coverage** + +Under the Web UI section of `tests/functional/test-smoke.sh`, add: + +```bash +test_endpoint "Failure analysis workspace" "$ORC_URL/web/clusters-analysis" "200" +test_body_contains "Failure analysis shell" "$ORC_URL/web/clusters-analysis" 'id="clusters_analysis_workspace"' +test_body_contains "Failure analysis stylesheet" "$ORC_URL/web/clusters-analysis" 'clusters-analysis-workspace\.css' +``` + +- [ ] **Step 2: Run smoke against the pre-restart container and verify RED** + +Run: `bash tests/functional/test-smoke.sh` + +Expected: at least the new shell or stylesheet assertion fails if the running container still serves the previous worktree state. + +- [ ] **Step 3: Rebuild and restart only Orchestrator** + +Build the current branch's binary, then recreate the service: + +```bash +go build -o bin/orchestrator ./go/cmd/orchestrator +docker compose -f tests/functional/docker-compose.yml up -d --force-recreate orchestrator +``` + +Do not recreate mysql1, mysql2, or mysql3. + +- [ ] **Step 4: Run complete automated verification** + +Run: + +```bash +gofmt -w go/http/render_test.go go/http/static_assets_test.go +node --check resources/public/js/clusters-analysis.js +node --test go/http/testdata/*.js +go test ./go/http -count=1 +bash tests/functional/test-smoke.sh +git diff --check +``` + +Expected: all commands PASS, with the smoke total increased by three. + +- [ ] **Step 5: Verify all rendered top-navigation destinations** + +Fetch `/web/clusters`, extract every unique internal `/web/` href, request each destination, and confirm each returns HTTP 200. Specifically verify `/web/clusters-analysis` and every topology URL rendered by the Failure analysis model. + +- [ ] **Step 6: Perform browser QA on the live lab** + +At `http://localhost:3099/web/clusters-analysis` verify: + +- header, summary, incident rows or empty state render with no legacy popover styling; +- cluster identity, analysis, affected/participating replicas, and state copy are legible; +- Cluster dashboard and Open topology links navigate successfully; +- no browser console errors or warnings originate from the page; +- keyboard focus is visible on both primary links; +- at a viewport no wider than 480px, rows stack, controls remain visible, and `document.documentElement.scrollWidth === document.documentElement.clientWidth`; +- temporarily render `renderClustersAnalysisUnavailableState()` into `#clusters_analysis_list` through the claimed local tab, verify its copy and layout, then reload the page to restore live state; +- temporarily render `renderClustersAnalysisEmptyState()` the same way, verify its copy and layout, then reload again. + +If browser QA finds a defect, add a focused failing automated test before changing production code, implement the minimal correction, and rerun Steps 4-6. + +- [ ] **Step 7: Commit verification coverage and any tested corrections** + +```bash +git add tests/functional/test-smoke.sh +git commit -m "test(ui): verify failure analysis workspace" +``` + +- [ ] **Step 8: Confirm a clean handoff** + +Run: `git status --short && git log -5 --oneline` + +Expected: clean tracked worktree and the five implementation commits visible. diff --git a/docs/superpowers/plans/2026-08-12-live-failover-audit-ui.md b/docs/superpowers/plans/2026-08-12-live-failover-audit-ui.md new file mode 100644 index 000000000..3a3a7691f --- /dev/null +++ b/docs/superpowers/plans/2026-08-12-live-failover-audit-ui.md @@ -0,0 +1,308 @@ +# Live Failover and Audit UI Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Generate a real MySQL `DeadMaster` recovery, restore the original three-node topology, and verify Orchestrator's populated audit, failure-detection, and recovery UI states. + +**Architecture:** Enable audit persistence in the functional SQLite backend, then use a dedicated shell harness built from the repository's existing functional-test helpers. The harness owns the complete stop/recovery/restore lifecycle through a cleanup trap, while browser inspection validates the real APIs and triggers focused TDD fixes only when an observable populated-state defect is found. + +**Tech Stack:** Bash, Docker Compose, MySQL 8.4, ProxySQL, Orchestrator HTTP API, Go template/static tests, Node `node:test`, in-app Browser. + +## Global Constraints + +- Preserve all MySQL volumes and container identities. +- Never run `docker compose down`, volume removal, or unscoped `docker compose up`. +- Recreate only Orchestrator and always use `--no-deps`. +- Stop and restart only the resolved current primary container. +- Use bounded waits and restore the original topology from a cleanup trap on success or failure. +- Final topology is `mysql1` writable primary with `mysql2` and `mysql3` running replicas. +- Keep generated Orchestrator SQLite history available for browser inspection. +- Production fixes require a focused failing test observed before implementation. + +--- + +### Task 1: Enable functional audit persistence + +**Files:** +- Modify: `tests/functional/orchestrator-test.conf.json` +- Test: `tests/functional/test-smoke.sh` + +**Interfaces:** +- Consumes: the existing SQLite functional backend at `/tmp/orchestrator-test.sqlite3`. +- Produces: persisted `/api/audit/0` entries for topology and recovery operations in the current Orchestrator session. + +- [ ] **Step 1: Add the functional configuration value** + +Add this top-level JSON property next to `BackendDB`: + +```json +"AuditToBackendDB": true, +``` + +- [ ] **Step 2: Validate the configuration and recreate only Orchestrator** + +Run: + +```bash +python3 -m json.tool tests/functional/orchestrator-test.conf.json >/dev/null +docker ps --filter 'name=functional-mysql' --format '{{.ID}} {{.Names}}' >/tmp/orchestrator-audit-mysql-before +docker compose -f tests/functional/docker-compose.yml up -d --no-deps --force-recreate orchestrator +``` + +Expected: valid JSON; only `functional-orchestrator-1` is recreated. + +- [ ] **Step 3: Wait for readiness and generate a baseline audit entry** + +Run: + +```bash +for attempt in $(seq 1 60); do + curl -fsS http://localhost:3099/api/clusters >/dev/null && break + sleep 1 +done +curl -fsS http://localhost:3099/api/discover/mysql1/3306 >/dev/null +``` + +Expected: Orchestrator becomes reachable and discovery succeeds. + +- [ ] **Step 4: Verify MySQL identities did not change** + +Run: + +```bash +docker ps --filter 'name=functional-mysql' --format '{{.ID}} {{.Names}}' >/tmp/orchestrator-audit-mysql-after +diff -u /tmp/orchestrator-audit-mysql-before /tmp/orchestrator-audit-mysql-after +``` + +Expected: no diff. + +- [ ] **Step 5: Commit** + +```bash +git add tests/functional/orchestrator-test.conf.json +git commit -m "test(ui): persist audit history in functional lab" +``` + +--- + +### Task 2: Add and run the controlled failover harness + +**Files:** +- Create: `tests/functional/test-audit-ui-failover.sh` +- Reuse: `tests/functional/lib.sh` + +**Interfaces:** +- Consumes: `wait_for_orchestrator`, `discover_topology`, `mysql_read_only`, `mysql_stop_replica_sql`, `mysql_reset_replica_all_sql`, `mysql_change_source_sql`, `mysql_start_replica_sql`, and `proxysql_servers` from `tests/functional/lib.sh`. +- Produces: a restored three-node topology plus non-empty `/api/audit/0`, `/api/audit-failure-detection/0`, and `/api/audit-recovery/0` responses. + +- [ ] **Step 1: Create the safety-gated harness** + +The script must: + +```bash +#!/bin/bash +set -uo pipefail +cd "$(dirname "$0")/../.." +source tests/functional/lib.sh + +COMPOSE="docker compose -f tests/functional/docker-compose.yml" +BEFORE_IDS="$(mktemp)" +AFTER_IDS="$(mktemp)" +MYSQL1_STOPPED=false + +restore_lab() { + if [ "$MYSQL1_STOPPED" = true ]; then + $COMPOSE start mysql1 >/dev/null + fi + for attempt in $(seq 1 60); do + $COMPOSE exec -T mysql1 mysqladmin ping -h localhost -uroot -ptestpass >/dev/null 2>&1 && break + sleep 1 + done + + local stop_sql reset_sql change_sql start_sql + stop_sql=$(mysql_stop_replica_sql) + reset_sql=$(mysql_reset_replica_all_sql) + change_sql=$(mysql_change_source_sql mysql1 3306 repl repl_pass) + start_sql=$(mysql_start_replica_sql) + + $COMPOSE exec -T mysql1 mysql -uroot -ptestpass \ + -e "$stop_sql $reset_sql SET GLOBAL read_only=0;" >/dev/null 2>&1 || true + for replica in mysql2 mysql3; do + $COMPOSE exec -T "$replica" mysql -uroot -ptestpass \ + -e "$stop_sql $change_sql $start_sql SET GLOBAL read_only=1;" >/dev/null 2>&1 || true + done + $COMPOSE exec -T proxysql mysql -h127.0.0.1 -P6032 -uradmin -pradmin \ + -e "DELETE FROM mysql_servers WHERE hostgroup_id IN (10,20); INSERT INTO mysql_servers (hostgroup_id,hostname,port) VALUES (10,'mysql1',3306),(20,'mysql2',3306),(20,'mysql3',3306); LOAD MYSQL SERVERS TO RUNTIME; SAVE MYSQL SERVERS TO DISK;" >/dev/null 2>&1 || true + curl -fsS "$ORC_URL/api/discover/mysql1/3306" >/dev/null || true + curl -fsS "$ORC_URL/api/discover/mysql2/3306" >/dev/null || true + curl -fsS "$ORC_URL/api/discover/mysql3/3306" >/dev/null || true +} + +trap 'restore_lab' EXIT +``` + +After the cleanup definition, the script must: + +1. record MySQL container IDs; +2. call `wait_for_orchestrator` and `discover_topology mysql1`; +3. require `mysql1` read-only `0`, `mysql2` read-only `1`, and ProxySQL writer `mysql1`; +4. stop `mysql1` and set `MYSQL1_STOPPED=true`; +5. poll `/api/v2/recoveries` for at most 90 seconds until a successful `DeadMaster` recovery has a non-empty successor; +6. call `restore_lab` explicitly and set `MYSQL1_STOPPED=false`; +7. poll until `mysql1` is writable and both replicas report `mysql1` as their source; +8. require all three audit APIs to return non-empty JSON arrays; +9. compare the final MySQL IDs with the initial IDs; +10. exit non-zero on any failed contract. + +- [ ] **Step 2: Validate shell syntax** + +Run: + +```bash +bash -n tests/functional/test-audit-ui-failover.sh +``` + +Expected: exit 0. + +- [ ] **Step 3: Run the harness** + +Run: + +```bash +bash tests/functional/test-audit-ui-failover.sh +``` + +Expected: successful `DeadMaster` recovery, original topology restored, audit APIs populated, MySQL IDs unchanged. + +- [ ] **Step 4: Capture API evidence** + +Run: + +```bash +curl -fsS http://localhost:3099/api/audit/0 | python3 -m json.tool >/tmp/orchestrator-audit.json +curl -fsS http://localhost:3099/api/audit-failure-detection/0 | python3 -m json.tool >/tmp/orchestrator-detections.json +curl -fsS http://localhost:3099/api/audit-recovery/0 | python3 -m json.tool >/tmp/orchestrator-recoveries.json +``` + +Expected: each file contains at least one record; the detection/recovery files contain `DeadMaster`. + +- [ ] **Step 5: Commit** + +```bash +git add tests/functional/test-audit-ui-failover.sh +git commit -m "test(ui): exercise populated audit history" +``` + +--- + +### Task 3: Browser-audit populated history states + +**Files:** +- Inspect: `resources/templates/audit.tmpl` +- Inspect: `resources/templates/audit_failure_detection.tmpl` +- Inspect: `resources/templates/audit_recovery.tmpl` +- Inspect: `resources/public/js/audit.js` +- Inspect: `resources/public/js/audit-failure-detection.js` +- Inspect: `resources/public/js/audit-recovery.js` +- Modify only if a reproduced browser defect requires it. + +**Interfaces:** +- Consumes: populated live APIs from Task 2. +- Produces: verified populated rows, expandable detection context, recovery detail, related-history links, and narrow-screen behavior. + +- [ ] **Step 1: Audit desktop routes** + +Navigate through: + +```text +http://localhost:3099/web/audit +http://localhost:3099/web/audit-failure-detection +http://localhost:3099/web/audit-recovery +``` + +Verify visible rows, mutually exclusive empty/error states, working pager state, no console errors, and no document-level horizontal overflow. + +- [ ] **Step 2: Exercise linked detail behavior** + +Click the `DeadMaster` detection, its related recovery link, and the recovery's related detection link. Verify the recovery summary includes failed instance, successor, start/end times, acknowledgement state, and recovery steps. + +- [ ] **Step 3: Audit at 390px** + +Set the Browser viewport to 390 by 844. Repeat all three history routes and the recovery detail route. Verify tables scroll inside their shells while `document.documentElement.scrollWidth === window.innerWidth`. + +- [ ] **Step 4: If a defect appears, perform one focused TDD cycle** + +For each defect, add a behavior test to the closest existing file: + +```text +go/http/render_test.go +go/http/static_assets_test.go +go/http/testdata/_test.js +``` + +Run the focused test and observe the expected failure. Implement the minimal template, JavaScript, or scoped CSS change. Rerun the focused test and the affected browser interaction before continuing. Commit each independently reviewable correction as: + +```bash +git commit -m "fix(ui): " +``` + +- [ ] **Step 5: Reset the Browser viewport** + +Return the Browser viewport to its default size and leave the populated recovery detail page open for review. + +--- + +### Task 4: Final verification and evidence report + +**Files:** +- Create: `.superpowers/sdd/2026-08-12-live-failover-audit-ui/final-report.md` + +**Interfaces:** +- Consumes: restored lab, generated history, and any Task 3 fixes. +- Produces: one evidence-backed handoff recording automated, browser, and safety results. + +- [ ] **Step 1: Run the complete automated verification** + +```bash +go test ./go/http -count=1 +for file in go/http/testdata/*_test.js; do node --test "$file" || exit 1; done +for file in resources/public/js/*.js; do node --check "$file" || exit 1; done +bash tests/functional/test-smoke.sh +git diff --check +``` + +Expected: every command exits 0; smoke reports zero failures. + +- [ ] **Step 2: Verify restored topology and container identity** + +```bash +docker ps --filter 'name=functional-mysql' --format '{{.ID}} {{.Names}} {{.Status}}' +docker compose -f tests/functional/docker-compose.yml exec -T mysql1 mysql -uroot -ptestpass -Nse 'SELECT @@read_only' +docker compose -f tests/functional/docker-compose.yml exec -T mysql2 mysql -uroot -ptestpass -e 'SHOW REPLICA STATUS\G' +docker compose -f tests/functional/docker-compose.yml exec -T mysql3 mysql -uroot -ptestpass -e 'SHOW REPLICA STATUS\G' +``` + +Expected: all three healthy; mysql1 writable; mysql2/mysql3 source `mysql1` with IO and SQL threads running. + +- [ ] **Step 3: Write the report** + +Record: + +- commits created; +- successful recovery analysis and successor; +- final topology and unchanged container IDs; +- audit API record counts; +- desktop and narrow browser route results; +- console errors/warnings; +- exact automated test counts; +- unresolved concerns, or explicitly `none`. + +- [ ] **Step 4: Commit the report and any remaining tracked changes** + +```bash +git add .superpowers/sdd/2026-08-12-live-failover-audit-ui/final-report.md +git commit -m "docs(ui): record populated audit verification" +git status --short +``` + +Expected: report committed and tracked worktree clean. diff --git a/docs/superpowers/plans/2026-08-18-consolidated-ui-integration.md b/docs/superpowers/plans/2026-08-18-consolidated-ui-integration.md new file mode 100644 index 000000000..50bf11e41 --- /dev/null +++ b/docs/superpowers/plans/2026-08-18-consolidated-ui-integration.md @@ -0,0 +1,1011 @@ +# Consolidated UI Integration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Consolidate the sound parts of UI PRs 125 and 126 into PR 122 so Orchestrator has one coherent, locally served Bootstrap 5 interface with stable legacy interactions, responsive navigation, current icons, and a behavior-compatible D3 v7 topology graph. + +**Architecture:** PR 122 remains the only implementation branch. Shared asset versioning is injected before content and layout rendering, Bootstrap compatibility lives in one idempotent bridge, and page behavior stays in existing scripts. PR 125 changes are reapplied in focused commits; PR 126's Bootstrap 3 rollback is not merged. + +**Tech Stack:** Go 1.25.7, `html/template`, Chi HTTP routing, jQuery, Bootstrap 5.3, Bootstrap Icons, D3 v7, Node's built-in test runner, Docker Compose, and the in-app Browser. + +**Spec:** `docs/superpowers/specs/2026-08-18-consolidated-ui-integration-design.md` + +## Global Constraints + +- Work only on `codex/ui-restorative-topology-worktree`, the head branch of PR 122. +- Do not merge or cherry-pick PR 125 or PR 126 wholesale. +- Keep PR 122's semantic workspaces, API contracts, authorization, polling, recovery behavior, and MySQL topology unchanged. +- Keep `/bootstrap5` as the canonical Bootstrap bundle; do not delete the legacy `/bootstrap` directory in this work. +- Use locally served Bootstrap Icons; do not introduce a CDN dependency. +- A delegated interaction must execute once per user action. +- D3 v3 may be removed only after D3 v7 automated and browser parity checks pass. +- Browser QA must cover desktop, 794-pixel, and 390-by-844 viewports with an explicit reload after every build or asset change. +- Do not run a live failover or intentionally change MySQL roles during this plan. +- Do not close PR 125 or PR 126 automatically. + +## File Structure + +| Responsibility | Files | +|---|---| +| Shared asset version and cache policy | `go/http/render.go`, `go/http/render_test.go`, `go/app/http.go`, `go/app/http_test.go` | +| Local icon assets | `resources/public/bootstrap-icons/font/bootstrap-icons.min.css`, `resources/public/bootstrap-icons/font/fonts/bootstrap-icons.woff`, `resources/public/bootstrap-icons/font/fonts/bootstrap-icons.woff2` | +| Bootstrap compatibility adapter | `resources/public/js/bootstrap-legacy-bridge.js`, `go/http/testdata/bootstrap_legacy_bridge_test.js` | +| Global shell and icon presentation | `resources/templates/layout.tmpl`, `resources/public/css/orchestrator.css`, `resources/public/js/orchestrator.js`, `resources/public/js/cluster.js`, `resources/public/js/clusters.js`, `resources/public/js/audit-recovery.js`, `resources/public/js/cluster-pools.js`, `resources/templates/cluster.tmpl`, `resources/templates/clusters.tmpl` | +| Legacy page structure and workspace rhythm | `resources/templates/agent.tmpl`, `resources/templates/audit.tmpl`, `resources/templates/audit_failure_detection.tmpl`, `resources/templates/audit_recovery.tmpl`, `resources/public/css/legacy-workspace.css`, `resources/public/css/cluster-workspace.css`, `resources/public/css/clusters-workspace.css`, `resources/public/css/clusters-analysis-workspace.css` | +| D3 v7 topology adapter | `resources/public/js/d3.v7.min.js`, `resources/public/js/cluster-tree-layout.js`, `resources/public/js/cluster-tree.js`, `go/http/testdata/cluster_tree_layout_test.js`, `resources/templates/cluster.tmpl` | +| Static and live integration contracts | `go/http/static_assets_test.go`, `go/http/render_test.go`, `tests/functional/test-smoke.sh` | + +--- + +### Task 1: Unified Asset Versioning and Local Icon Delivery + +**Files:** +- Modify: `go/http/render.go:20-130` +- Modify: `go/http/render_test.go` +- Modify: `go/app/http.go:108-125` +- Modify: `go/app/http_test.go` +- Modify: `resources/templates/agents.tmpl` +- Modify: `resources/templates/audit.tmpl` +- Modify: `resources/templates/audit_failure_detection.tmpl` +- Modify: `resources/templates/audit_recovery.tmpl` +- Modify: `resources/templates/cluster.tmpl` +- Modify: `resources/templates/clusters.tmpl` +- Modify: `resources/templates/clusters_analysis.tmpl` +- Modify: `resources/templates/layout.tmpl` +- Modify: `resources/templates/seeds.tmpl` +- Modify: `resources/templates/status.tmpl` +- Create: `resources/public/bootstrap-icons/font/bootstrap-icons.min.css` +- Create: `resources/public/bootstrap-icons/font/fonts/bootstrap-icons.woff` +- Create: `resources/public/bootstrap-icons/font/fonts/bootstrap-icons.woff2` +- Test: `go/http/render_test.go` +- Test: `go/http/static_assets_test.go` +- Test: `go/app/http_test.go` + +**Interfaces:** +- Produces: `computeAssetVersion() string`, stable for the process lifetime. +- Produces: `injectAssetVersion(data interface{}, version string) interface{}`, which adds `assetVersion` only to a non-nil `map[string]interface{}` that does not already contain it. +- Produces: `revalidateStaticAssets(next nethttp.Handler) nethttp.Handler`, which sets `Cache-Control: no-cache, must-revalidate` before serving an asset. +- Produces: template field `{{.assetVersion}}`, available to both content and layout templates. +- Produces: local Bootstrap Icons paths under `/bootstrap-icons/font/`. + +- [ ] **Step 1: Add failing render and static-asset tests** + +Add a render test that temporarily sets the package asset token, renders `templates/clusters_analysis`, and requires the same token in one layout asset and one content asset: + +```go +func setAssetVersionForTest(t *testing.T, version string) { + t.Helper() + old := assetVersion + assetVersion = version + t.Cleanup(func() { assetVersion = old }) +} + +func TestRenderHTMLSharesAssetVersionWithContentAndLayout(t *testing.T) { + chdirToRepoRoot(t) + clearContentTemplateCache() + setAssetVersionForTest(t, "asset-test-42") + + rec := httptest.NewRecorder() + renderHTML(rec, http.StatusOK, "templates/clusters_analysis", sampleTemplateData()) + body := rec.Body.String() + for _, want := range []string{ + `/css/orchestrator.css?v=asset-test-42`, + `/js/clusters-analysis.js?v=asset-test-42`, + } { + if !strings.Contains(body, want) { + t.Errorf("rendered page missing shared asset version %q", want) + } + } +} +``` + +Call `setAssetVersionForTest(t, "asset-test-42")` at the start of `TestRenderHTMLUsesLocalBootstrapAssets` before rendering the template. + +Extend `TestRenderHTMLUsesLocalBootstrapAssets` to require: + +```go +for _, want := range []string{ + `/bootstrap5/css/bootstrap.min.css?v=asset-test-42`, + `/bootstrap5/js/bootstrap.bundle.min.js?v=asset-test-42`, + `/bootstrap-icons/font/bootstrap-icons.min.css?v=asset-test-42`, +} { + if !strings.Contains(body, want) { + t.Errorf("rendered layout missing versioned local asset %q", want) + } +} +``` + +Add `TestBootstrapIconAssetsAreVendored` to `static_assets_test.go`, checking that the CSS references both `bootstrap-icons.woff` and `bootstrap-icons.woff2` and that all three files exist. + +- [ ] **Step 2: Add a failing cache-policy test** + +Extract the planned middleware name in a test before implementing it: + +```go +func TestRevalidateStaticAssets(t *testing.T) { + next := nethttp.HandlerFunc(func(w nethttp.ResponseWriter, _ *nethttp.Request) { + w.WriteHeader(nethttp.StatusNoContent) + }) + rec := httptest.NewRecorder() + revalidateStaticAssets(next).ServeHTTP(rec, httptest.NewRequest(nethttp.MethodGet, "/css/orchestrator.css", nil)) + if got := rec.Header().Get("Cache-Control"); got != "no-cache, must-revalidate" { + t.Fatalf("Cache-Control = %q", got) + } +} +``` + +- [ ] **Step 3: Run the focused tests and record RED** + +Run: + +```bash +go test ./go/http -run 'Test(RenderHTMLSharesAssetVersionWithContentAndLayout|RenderHTMLUsesLocalBootstrapAssets|BootstrapIconAssetsAreVendored)$' -count=1 +go test ./go/app -run '^TestRevalidateStaticAssets$' -count=1 +``` + +Expected: failures for the undefined asset helpers, absent icon files, and dated content asset URLs. + +- [ ] **Step 4: Implement the shared version before content rendering** + +Add these functions to `render.go` and call `injectAssetVersion(data, assetVersion)` before `content.Execute`: + +```go +var assetVersion = computeAssetVersion() + +func computeAssetVersion() string { + if exe, err := os.Executable(); err == nil { + if info, err := os.Stat(exe); err == nil { + return strconv.FormatInt(info.ModTime().UnixNano(), 10) + } + } + return strconv.Itoa(os.Getpid()) +} + +func injectAssetVersion(data interface{}, version string) interface{} { + if values, ok := data.(map[string]interface{}); ok && values != nil { + if _, exists := values["assetVersion"]; !exists { + values["assetVersion"] = version + } + } + return data +} +``` + +Do not place the injection after `content.Execute`; content and layout must see the same map value. + +- [ ] **Step 5: Implement static revalidation** + +Add the named wrapper and use it for both prefixed and unprefixed file-server registrations: + +```go +func revalidateStaticAssets(next nethttp.Handler) nethttp.Handler { + return nethttp.HandlerFunc(func(w nethttp.ResponseWriter, r *nethttp.Request) { + w.Header().Set("Cache-Control", "no-cache, must-revalidate") + next.ServeHTTP(w, r) + }) +} +``` + +- [ ] **Step 6: Import only PR 125's icon assets and version every first-party stylesheet/script URL** + +Import the three new vendor files from `origin/pr-125` without importing its Bootstrap replacement or application code: + +```bash +git archive origin/pr-125 \ + resources/public/bootstrap-icons/font/bootstrap-icons.min.css \ + resources/public/bootstrap-icons/font/fonts/bootstrap-icons.woff \ + resources/public/bootstrap-icons/font/fonts/bootstrap-icons.woff2 | tar -x +``` + +Update template `` and ` + + +``` + +- [ ] **Step 6: Run GREEN verification** + +Run: + +```bash +node --test go/http/testdata/bootstrap_legacy_bridge_test.js +node --check resources/public/js/bootstrap-legacy-bridge.js +go test ./go/http -run 'Test(RenderHTMLUsesLocalBootstrapAssets|AllContentTemplatesRenderWithLayout)$' -count=1 +git diff --check +``` + +Expected: all commands exit 0 and the Node report shows zero failed tests. + +- [ ] **Step 7: Commit** + +```bash +git add resources/public/js/bootstrap-legacy-bridge.js go/http/testdata/bootstrap_legacy_bridge_test.js resources/templates/layout.tmpl go/http/render_test.go go/http/static_assets_test.go +git commit -m "fix(ui): centralize Bootstrap compatibility" +``` + +--- + +### Task 3: Responsive Global Shell, Icons, and Workspace Consistency + +**Files:** +- Modify: `resources/templates/layout.tmpl` +- Modify: `resources/templates/cluster.tmpl` +- Modify: `resources/templates/clusters.tmpl` +- Modify: `resources/templates/agent.tmpl` +- Modify: `resources/templates/audit.tmpl` +- Modify: `resources/templates/audit_failure_detection.tmpl` +- Modify: `resources/templates/audit_recovery.tmpl` +- Modify: `resources/public/css/orchestrator.css` +- Modify: `resources/public/css/legacy-workspace.css` +- Modify: `resources/public/css/cluster-workspace.css` +- Modify: `resources/public/css/clusters-workspace.css` +- Modify: `resources/public/css/clusters-analysis-workspace.css` +- Modify: `resources/public/js/orchestrator.js` +- Modify: `resources/public/js/cluster.js` +- Modify: `resources/public/js/clusters.js` +- Modify: `resources/public/js/audit-recovery.js` +- Modify: `resources/public/js/cluster-pools.js` +- Test: `go/http/render_test.go` +- Test: `go/http/static_assets_test.go` +- Test: existing `go/http/testdata/*_test.js` + +**Interfaces:** +- Consumes: local Bootstrap Icons and bridge from Tasks 1-2. +- Produces: `navbar-expand-lg` shell with labelled search and grouped status controls. +- Produces: shared CSS variables `--orc-ink`, `--orc-muted`, `--orc-line`, `--orc-surface`, `--orc-background`, `--orc-accent`, and `--orc-primary` on `:root`. +- Preserves: all existing `data-btn`, `data-command`, element IDs, authorization checks, and delegated command handlers. + +- [ ] **Step 1: Add failing shell, icon, and structure contracts** + +Implement the shell contract in `render_test.go`: + +```go +func TestResponsiveShellUsesBootstrapIcons(t *testing.T) { + chdirToRepoRoot(t) + setAssetVersionForTest(t, "asset-test-42") + rec := httptest.NewRecorder() + renderHTML(rec, http.StatusOK, "templates/clusters", sampleTemplateData()) + body := rec.Body.String() + for _, want := range []string{ + `navbar-expand-lg`, + `aria-label="Search instances"`, + `aria-label="Submit search"`, + `class="bi bi-search"`, + `id="nav_operational_status"`, + } { + if !strings.Contains(body, want) { + t.Errorf("responsive shell missing %q", want) + } + } +} + +func TestAgentDetailUsesBootstrapGrid(t *testing.T) { + chdirToRepoRoot(t) + source, err := os.ReadFile("resources/templates/agent.tmpl") + if err != nil { + t.Fatal(err) + } + body := string(source) + if !strings.Contains(body, `class="row g-3"`) || strings.Count(body, `class="col-md-6"`) != 2 { + t.Fatal("agent Info and Snapshots must share one two-column Bootstrap row") + } +} +``` + +Add `TestActiveUIUsesBootstrapIcons` to `static_assets_test.go`. Read these exact files and reject the literal `glyphicon`: `layout.tmpl`, `cluster.tmpl`, `clusters.tmpl`, `orchestrator.js`, `cluster.js`, `clusters.js`, `audit-recovery.js`, and `cluster-pools.js`. Do not scan `bootbox.min.js`, vendored Bootstrap 3 files, or archived fixtures. Preserve the existing route-registration test from PR 119. + +- [ ] **Step 2: Run RED tests** + +Run: + +```bash +go test ./go/http -run 'Test(RenderedNavigationUsesCurrentProjectDestinations|LayoutWebLinksHaveRegisteredRoutes|ResponsiveShellUsesBootstrapIcons|AgentDetailUsesBootstrapGrid)$' -count=1 +``` + +Expected: failures for the `md` breakpoint, missing icon classes, status group, and agent row. + +- [ ] **Step 3: Implement the global shell** + +Change the nav to `navbar-expand-lg`. Give the search input `aria-label="Search instances"`, give its button `aria-label="Submit search"`, and wrap context, recovery, read-only, user, refresh, and problem controls in `#nav_operational_status`. Keep Problems visible outside the collapsed menu only when there is room; inside collapsed navigation it must remain keyboard reachable. + +Use this icon mapping for active templates and generated markup: + +| Legacy meaning | Bootstrap Icon | +|---|---| +| search | `bi-search` | +| settings/context | `bi-gear` | +| refresh/repeat | `bi-arrow-clockwise` | +| pause | `bi-pause-fill` | +| read-only | `bi-eye` | +| writable | `bi-pencil` | +| warning/problem | `bi-exclamation-triangle-fill` | +| healthy/success | `bi-check-circle-fill` | +| error/remove | `bi-x-circle-fill` | +| maintenance | `bi-wrench-adjustable` | +| topology/GTID | `bi-diagram-3` | +| start/stop replication | `bi-play-fill` / `bi-stop-fill` | + +Retain text labels for modal actions and add `aria-hidden="true"` to decorative `` elements. + +- [ ] **Step 4: Remove duplicate modal activation and preserve drag exclusion** + +In `orchestrator.js`, keep the single title/details delegated handler that opens `#node_modal`. Remove the redundant unhealthy-node heading handler. Its drag exclusion must continue to treat buttons, links, inputs, and `[data-node-details]` as interactive descendants. + +- [ ] **Step 5: Repair agent and audit structures** + +Wrap the two agent columns in one `.row g-3`, use `.col-md-6`, and correct the invalid nested table closing tags without changing data hooks. Keep the existing text pagination labels and `aria-label` values in all three audit templates; decorative arrows receive `aria-hidden="true"` spans. + +- [ ] **Step 6: Unify workspace tokens and responsive rhythm** + +Define the seven `--orc-*` variables in `orchestrator.css`, then make the four workspace styles consume them for page background, ink, muted text, borders, surface, accent, and primary actions. Align workspace maximum width to `1180px`, header radius to `0.75rem`, and panel border to `1px solid var(--orc-line)`. Preserve each workspace's semantic state colors and topology canvas dimensions. + +At widths below `992px`, the nav collapses and workspaces retain at least `14px` horizontal padding. At widths below `620px`, hero/header content stacks and tables scroll inside their `.legacy-table-shell`; `body` must not acquire horizontal overflow. + +- [ ] **Step 7: Run focused GREEN verification** + +Run: + +```bash +node --check resources/public/js/orchestrator.js +node --check resources/public/js/cluster.js +node --check resources/public/js/clusters.js +for file in go/http/testdata/*_test.js; do node --test "$file" || exit 1; done +go test ./go/http -run 'Test(RenderedNavigationUsesCurrentProjectDestinations|LayoutWebLinksHaveRegisteredRoutes|ResponsiveShellUsesBootstrapIcons|AgentDetailUsesBootstrapGrid|AllContentTemplatesRenderWithLayout)$' -count=1 +git diff --check +``` + +Expected: all commands exit 0. + +- [ ] **Step 8: Browser checkpoint at the actual 794-pixel width** + +Rebuild and restart only the Orchestrator container, reload the in-app browser, and verify `/web/clusters`, `/web/cluster/mysql1:3306`, `/web/clusters-analysis`, `/web/audit`, and `/web/status`. Confirm the nav is collapsed, all five pages have readable full-width headers, there is no body-level horizontal overflow, dropdowns open once, and browser error/warning logs are empty. + +- [ ] **Step 9: Commit** + +```bash +git add \ + resources/templates/layout.tmpl \ + resources/templates/agent.tmpl \ + resources/templates/audit.tmpl \ + resources/templates/audit_failure_detection.tmpl \ + resources/templates/audit_recovery.tmpl \ + resources/templates/cluster.tmpl \ + resources/templates/clusters.tmpl \ + resources/public/css/orchestrator.css \ + resources/public/css/legacy-workspace.css \ + resources/public/css/cluster-workspace.css \ + resources/public/css/clusters-workspace.css \ + resources/public/css/clusters-analysis-workspace.css \ + resources/public/js/orchestrator.js \ + resources/public/js/cluster.js \ + resources/public/js/clusters.js \ + resources/public/js/audit-recovery.js \ + resources/public/js/cluster-pools.js \ + go/http/render_test.go \ + go/http/static_assets_test.go +git commit -m "feat(ui): unify responsive workspace chrome" +``` + +--- + +### Task 4: Behavior-Compatible D3 v7 Topology + +**Files:** +- Create: `resources/public/js/d3.v7.min.js` +- Create: `resources/public/js/cluster-tree-layout.js` +- Create: `go/http/testdata/cluster_tree_layout_test.js` +- Modify: `resources/public/js/cluster-tree.js` +- Modify: `resources/templates/cluster.tmpl` +- Modify: `go/http/static_assets_test.go` +- Delete after parity passes: `resources/public/js/d3.v3.min.js` + +**Interfaces:** +- Produces: `window.OrchestratorTreeLayout.layout(d3, root, treeLayout, horizontalSpacing) -> {nodes, links}`. +- `nodes` is `Array` of original topology objects with current `x` and normalized `y` coordinates. +- `links` is `Array<{source: object, target: object}>` referring to original topology objects. +- Preserves: `x0` and `y0` transition origins on each original node. + +- [ ] **Step 1: Write the pure layout RED test** + +Load vendored D3 v7 and the planned adapter in a `vm` context. Use a root with two children, then remove one child so the remaining node must receive a new vertical coordinate: + +```js +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const vm = require('node:vm'); + +test('D3 v7 layout recalculates coordinates without replacing transition origins', () => { + const context = {globalThis: null, window: null}; + context.globalThis = context; + context.window = context; + vm.createContext(context); + vm.runInContext(fs.readFileSync(path.join(__dirname, '../../../resources/public/js/d3.v7.min.js'), 'utf8'), context); + vm.runInContext(fs.readFileSync(path.join(__dirname, '../../../resources/public/js/cluster-tree-layout.js'), 'utf8'), context); + + const replica1 = {id: 'replica1', virtualDepth: 1, children: [], x0: 123, y0: 320}; + const replica2 = {id: 'replica2', virtualDepth: 1, children: []}; + const root = {id: 'primary', virtualDepth: 0, children: [replica1, replica2]}; + const tree = context.d3.tree().size([600, 640]); + const first = context.OrchestratorTreeLayout.layout(context.d3, root, tree, 320); + const firstByID = Object.fromEntries(first.nodes.map(node => [node.id, node])); + const firstReplicaX = firstByID.replica1.x; + + root.children = [replica1]; + const second = context.OrchestratorTreeLayout.layout(context.d3, root, tree, 320); + const secondByID = Object.fromEntries(second.nodes.map(node => [node.id, node])); + + assert.equal(first.nodes.length, 3); + assert.equal(first.links.length, 2); + assert.equal(firstByID.replica1.y, 320); + assert.notEqual(secondByID.replica1.x, firstReplicaX); + assert.equal(secondByID.replica1.x0, 123); + assert.equal(secondByID.replica1.y0, 320); +}); +``` + +Add this source guard to `static_assets_test.go`: + +```go +func TestTopologyUsesD3V7WithoutCoordinateRestore(t *testing.T) { + chdirToRepoRoot(t) + treeSource, err := os.ReadFile("resources/public/js/cluster-tree.js") + if err != nil { + t.Fatal(err) + } + for _, forbidden := range []string{"prevX", "prevY", "d3.layout.tree", "d3.svg.diagonal"} { + if strings.Contains(string(treeSource), forbidden) { + t.Errorf("cluster-tree.js retains legacy topology expression %q", forbidden) + } + } + templateSource, err := os.ReadFile("resources/templates/cluster.tmpl") + if err != nil { + t.Fatal(err) + } + for _, required := range []string{"d3.v7.min.js?v={{.assetVersion}}", "cluster-tree-layout.js?v={{.assetVersion}}"} { + if !strings.Contains(string(templateSource), required) { + t.Errorf("cluster template missing %q", required) + } + } +} +``` + +- [ ] **Step 2: Run RED tests** + +Run: + +```bash +node --test go/http/testdata/cluster_tree_layout_test.js +go test ./go/http -run '^TestTopologyUsesD3V7WithoutCoordinateRestore$' -count=1 +``` + +Expected: missing D3 v7/adapter and legacy API guard failures. + +- [ ] **Step 3: Import D3 v7 and implement the pure adapter** + +Import only `resources/public/js/d3.v7.min.js` from `origin/pr-125`: + +```bash +git archive origin/pr-125 resources/public/js/d3.v7.min.js | tar -x +``` + +Implement the adapter as a global module: + +```js +(function(root) { + function layout(d3, topologyRoot, treeLayout, horizontalSpacing) { + var hierarchyRoot = d3.hierarchy(topologyRoot, function(node) { return node.children; }); + treeLayout(hierarchyRoot); + hierarchyRoot.each(function(hierarchyNode) { + var node = hierarchyNode.data; + node.x = hierarchyNode.x; + node.y = node.isAnchor + ? node.virtualDepth * horizontalSpacing - horizontalSpacing / 2 + : node.virtualDepth * horizontalSpacing; + }); + return { + nodes: hierarchyRoot.descendants().map(function(item) { return item.data; }).reverse(), + links: hierarchyRoot.links().map(function(link) { + return {source: link.source.data, target: link.target.data}; + }) + }; + } + root.OrchestratorTreeLayout = {layout: layout}; +})(typeof window === 'undefined' ? globalThis : window); +``` + +Do not save and restore `x` or `y`. The rendering update stores current positions into `x0` and `y0` only after nodes and links have moved. + +- [ ] **Step 4: Port `cluster-tree.js` to D3 v7** + +Use `d3.tree`, `d3.hierarchy`, `d3.linkHorizontal`, and `nodeEnter.merge(node)`. Update click handlers to `(event, datum)`. Preserve PR 122's viewport fallback and ensure SVG width is `Math.max(viewport.width() - margins, topologyWidth)`, so deep topologies remain horizontally scrollable rather than clipped. + +Load scripts in `cluster.tmpl` in this order: + +```html + + + +``` + +- [ ] **Step 5: Run automated GREEN verification** + +Run: + +```bash +node --test go/http/testdata/cluster_tree_layout_test.js +node --check resources/public/js/cluster-tree-layout.js +node --check resources/public/js/cluster-tree.js +go test ./go/http -run 'Test(TopologyUsesD3V7WithoutCoordinateRestore|RenderClusterWorkspace|RenderClusterWorkspacePreservesLegacyHooks)$' -count=1 +git diff --check +``` + +Expected: all commands exit 0. + +- [ ] **Step 6: Run the D3 browser parity gate** + +Rebuild and restart only Orchestrator, reload `/web/cluster/mysql1:3306`, and verify: + +1. three semantic instance cards and two links render; +2. clicking a node circle collapses and expands its descendants and existing nodes move to recalculated coordinates; +3. clicking Details opens exactly one modal; +4. clicking interactive card controls does not begin a drag; +5. the View dropdown works; +6. the page has no console errors or body-level horizontal overflow at 794 and 390 pixels. + +Only after all six checks pass, remove `d3.v3.min.js` and add a static test proving no template references it. + +- [ ] **Step 7: Commit** + +```bash +git add resources/public/js/d3.v7.min.js resources/public/js/cluster-tree-layout.js resources/public/js/cluster-tree.js resources/templates/cluster.tmpl go/http/testdata/cluster_tree_layout_test.js go/http/static_assets_test.go +git rm resources/public/js/d3.v3.min.js +git commit -m "feat(ui): migrate topology rendering to D3 v7" +``` + +--- + +### Task 5: Full Lab and Browser Acceptance Matrix + +**Files:** +- Modify: `tests/functional/test-smoke.sh` +- Modify: `go/http/static_assets_test.go` +- Create: `.superpowers/sdd/2026-08-18-consolidated-ui-integration/browser-qa.md` + +**Interfaces:** +- Consumes: final rendered shell and topology assets from Tasks 1-4. +- Produces: smoke assertions for versioned local assets, compatibility bridge, Bootstrap Icons, D3 v7, and every global-navigation route. +- Produces: browser evidence table with route, viewport, state, interaction, overflow, and console results. + +- [ ] **Step 1: Add RED smoke/static contracts** + +Add these exact smoke calls after topology discovery: + +```bash +test_body_contains "Clusters workspace" "$ORC_URL/web/clusters" 'id="clusters_workspace"' +test_body_contains "Topology workspace" "$ORC_URL/web/cluster/mysql1:3306" 'id="cluster_workspace"' +test_body_contains "Failure analysis workspace" "$ORC_URL/web/clusters-analysis" 'id="clusters_analysis_workspace"' +test_body_contains "Discover workspace" "$ORC_URL/web/discover" 'id="discover_workspace"' +test_body_contains "Audit workspace" "$ORC_URL/web/audit" 'id="audit"' +test_body_contains "Failure detection workspace" "$ORC_URL/web/audit-failure-detection" 'Failure detections' +test_body_contains "Recovery workspace" "$ORC_URL/web/audit-recovery" 'Recoveries' +test_body_contains "Status workspace" "$ORC_URL/web/status" 'id="status_workspace"' +test_body_contains "About workspace" "$ORC_URL/web/about" 'id="about_workspace"' +test_endpoint "Agents route" "$ORC_URL/web/agents" "200" +test_endpoint "Seeds route" "$ORC_URL/web/seeds" "200" +test_body_contains "D3 v7" "$ORC_URL/web/cluster/mysql1:3306" 'd3\.v7\.min\.js\?v=' +test_body_contains "Topology layout adapter" "$ORC_URL/web/cluster/mysql1:3306" 'cluster-tree-layout\.js\?v=' +test_body_contains "Bootstrap bridge" "$ORC_URL/web/clusters" 'bootstrap-legacy-bridge\.js\?v=' +test_body_contains "Bootstrap Icons" "$ORC_URL/web/clusters" 'bootstrap-icons\.min\.css\?v=' +``` + +Add this static contract to `static_assets_test.go`: + +```go +func TestConsolidatedUIAssetContracts(t *testing.T) { + chdirToRepoRoot(t) + clusterTemplate, err := os.ReadFile("resources/templates/cluster.tmpl") + if err != nil { + t.Fatal(err) + } + layoutTemplate, err := os.ReadFile("resources/templates/layout.tmpl") + if err != nil { + t.Fatal(err) + } + combined := string(clusterTemplate) + string(layoutTemplate) + for _, required := range []string{ + "d3.v7.min.js?v={{.assetVersion}}", + "cluster-tree-layout.js?v={{.assetVersion}}", + "bootstrap-legacy-bridge.js?v={{.assetVersion}}", + "bootstrap-icons.min.css?v={{.assetVersion}}", + } { + if !strings.Contains(combined, required) { + t.Errorf("consolidated UI is missing %q", required) + } + } + for _, forbidden := range []string{"cdn.jsdelivr.net", "github.com/openark/orchestrator", "d3.v3.min.js"} { + if strings.Contains(combined, forbidden) { + t.Errorf("consolidated UI retains forbidden reference %q", forbidden) + } + } +} +``` + +The earlier render test proves that content and layout use one shared `?v=` token; this contract proves the final asset set and rejects remote or obsolete references. + +- [ ] **Step 2: Run RED smoke contracts against the pre-restart server** + +Run: + +```bash +go test ./go/http -run '^TestConsolidatedUIAssetContracts$' -count=1 +bash tests/functional/test-smoke.sh +``` + +Expected: the new asset assertions fail until the rebuilt Orchestrator server is mounted. + +- [ ] **Step 3: Rebuild and recreate only Orchestrator safely** + +Record MySQL container IDs, rebuild the Linux/arm64 binary, recreate only Orchestrator, and reseed discovery: + +```bash +MYSQL_IDS_BEFORE=$(docker inspect -f '{{.Id}}' functional-mysql1-1 functional-mysql2-1 functional-mysql3-1) +docker run --rm --platform linux/arm64 \ + -v "$PWD:/work" -w /work golang:1.25.7 \ + go build -o bin/orchestrator ./go/cmd/orchestrator +docker compose -f tests/functional/docker-compose.yml up -d --no-deps --force-recreate orchestrator +source tests/functional/lib.sh +wait_for_orchestrator +discover_topology mysql1 +MYSQL_IDS_AFTER=$(docker inspect -f '{{.Id}}' functional-mysql1-1 functional-mysql2-1 functional-mysql3-1) +test "$MYSQL_IDS_BEFORE" = "$MYSQL_IDS_AFTER" +``` + +Do not recreate or start MySQL or ProxySQL dependencies. + +- [ ] **Step 4: Run the full automated suite** + +Run: + +```bash +gofmt -s -l go/ +docker run --rm -v "$PWD:/app" -w /app golangci/golangci-lint:v2.11.4 golangci-lint run +go test ./... -count=1 +for file in go/http/testdata/*_test.js; do node --test "$file" || exit 1; done +for file in tests/functional/*.sh; do bash -n "$file" || exit 1; done +docker compose -f tests/functional/docker-compose.yml -f tests/functional/docker-compose.mariadb.yml config --quiet +bash tests/functional/test-smoke.sh +git diff --check +``` + +Expected: `gofmt` prints nothing; every other command exits 0; MySQL container IDs match the pre-rebuild record. + +- [ ] **Step 5: Execute the desktop and responsive browser matrix** + +Use the in-app Browser and record each route at 1440-by-900, the natural 794-pixel application width, and 390-by-844. For every route, record: + +```markdown +| Route | Viewport | State | Navigation | Interaction | Body overflow | Console | +|---|---:|---|---|---|---|---| +``` + +Check populated and empty states where the lab supports both. Audit and topology tables may scroll inside their own shells; `document.body.scrollWidth` must not exceed `innerWidth`. Reset the viewport override after the matrix and leave `/web/clusters` open as the deliverable tab. + +- [ ] **Step 6: Commit verification contracts and evidence** + +```bash +git add tests/functional/test-smoke.sh go/http/static_assets_test.go .superpowers/sdd/2026-08-18-consolidated-ui-integration/browser-qa.md +git commit -m "test(ui): verify consolidated browser workspaces" +``` + +--- + +### Task 6: Publish PR 122 and Record PR 125/126 Disposition + +**External targets:** +- Update: PR 122 description on GitHub +- Comment: PR 125 and PR 126 +- Repository source remains unchanged in this task + +**Interfaces:** +- Consumes: green automated and browser evidence from Task 5. +- Produces: pushed PR 122 head, updated PR 122 validation summary, and evidence-based comments on PRs 125 and 126. + +- [ ] **Step 1: Review the complete integration diff** + +Run: + +```bash +git status -sb +git diff --check origin/master...HEAD +git diff --stat origin/master...HEAD +git log --oneline origin/master..HEAD +``` + +Confirm no PR 126 Bootstrap 3 layout, IE8 shim, Openark link, or unrelated backend behavior entered the branch. + +- [ ] **Step 2: Run final verification immediately before publishing** + +Run: + +```bash +go test ./... -count=1 +for file in go/http/testdata/*_test.js; do node --test "$file" || exit 1; done +bash tests/functional/test-smoke.sh +git show --check --oneline HEAD +git status --short +``` + +Expected: all tests exit 0, the smoke summary has zero failures, and the tracked worktree is clean. + +- [ ] **Step 3: Push PR 122** + +```bash +git push origin codex/ui-restorative-topology-worktree +``` + +- [ ] **Step 4: Update PR 122's description** + +Add a “Related UI PRs” section stating that PR 125's local icons, cache policy, compatibility behavior, and D3 v7 work were reapplied with the listed fixes; PR 126's local-asset intent was satisfied without adopting its Bootstrap 3/IE8 rollback. Add the final automated counts and browser matrix summary. + +- [ ] **Step 5: Comment on PR 125 without closing it** + +Post this evidence-based disposition: + +```markdown +PR 122 now incorporates this PR's local Bootstrap Icons, shared asset cache-busting, Bootstrap 5 compatibility behavior, and D3 v7 topology migration. The integration also fixes the asset-version render order, duplicate modal activation, tree-coordinate restoration, agent grid structure, and icon-only accessibility findings. The full Go, Node, functional-smoke, and desktop/794px/390px browser results are recorded in PR 122 and its committed browser QA report. This PR remains open for the maintainers to disposition; it was not merged wholesale because it independently overlaps PR 122 in seventeen UI files. +``` + +- [ ] **Step 6: Comment on PR 126 without closing it** + +Post: + +```markdown +PR 122 now satisfies the reliable local-asset and working-navigation goals of this PR while retaining the approved Bootstrap 5 interface and current ProxySQL documentation/repository links. The Bootstrap 3/IE8 and Openark rollback in this patch was therefore not incorporated. This PR remains open for the maintainers to disposition. +``` + +- [ ] **Step 7: Check GitHub state** + +Run: + +```bash +gh pr view 122 --repo ProxySQL/orchestrator --json mergeable,mergeStateStatus,isDraft,headRefOid,statusCheckRollup,url +gh pr view 125 --repo ProxySQL/orchestrator --json state,url,comments +gh pr view 126 --repo ProxySQL/orchestrator --json state,url,comments +``` + +Report PR 122's mergeability and exact CI state. Do not mark the draft ready or close PR 125/126 without a separate maintainer instruction. diff --git a/docs/superpowers/specs/2026-08-11-cluster-flow-shell-design.md b/docs/superpowers/specs/2026-08-11-cluster-flow-shell-design.md new file mode 100644 index 000000000..e53657712 --- /dev/null +++ b/docs/superpowers/specs/2026-08-11-cluster-flow-shell-design.md @@ -0,0 +1,41 @@ +# Cluster Flow Shell Design + +## Goal + +Make the default Orchestrator experience coherent from its landing route through cluster exploration. `/web/clusters` becomes a concise operational landing page; cluster detail uses the same compact frame and gives topology priority. + +## Scope + +- Redesign `/web/clusters` as the primary cluster landing page. +- Flatten the cluster-detail header into a single compact identity row. +- Replace the visually dominant left command rail with a small inline View/control menu beside cluster identity. +- Keep the current semantic topology cards and D3 renderer. + +## Information Architecture + +`global navigation → cluster landing list → selected cluster identity → topology canvas` + +The global navigation stays a compact dark bar. The landing page shows one row/card per cluster with name, alias, primary, member count, health/problem summary, and an explicit open action. Selecting a cluster opens its existing detail route. + +The cluster page starts with an identity row: cluster name, primary, member/health summary, and a compact View menu. The topology canvas follows immediately. The command controls retain their existing data-command values and behavior but no longer define the page’s visual hierarchy. + +## Visual Direction + +- Use the dark bar only for global application context. +- Use a warm white/light-gray content surface for landing and topology work. +- Prefer dense, legible operational text over dashboard decoration. +- Reserve warning/error color and emphasis for real topology state. +- Keep controls labelled, keyboard reachable, and grouped by purpose. + +## Constraints + +- Preserve routes, APIs, recovery/failover, drag/drop, D3 positioning, and node modal behavior. +- Preserve existing cluster commands while changing their placement/presentation. +- Keep page-specific CSS scoped; do not extend global Bootstrap compatibility shims. +- The `/` redirect remains `/web/clusters`; the redesigned landing must therefore stand on its own. + +## Verification + +- Template tests protect the landing-page landmarks and retained command hooks. +- Functional smoke checks confirm `/`, `/web/clusters`, and a live cluster detail route return the intended page shells. +- Manual lab check confirms the landing page is readable, the selected cluster opens, view controls work, and topology remains interactable. diff --git a/docs/superpowers/specs/2026-08-11-restorative-topology-ui-design.md b/docs/superpowers/specs/2026-08-11-restorative-topology-ui-design.md new file mode 100644 index 000000000..041749347 --- /dev/null +++ b/docs/superpowers/specs/2026-08-11-restorative-topology-ui-design.md @@ -0,0 +1,70 @@ +# Restorative Topology UI Design + +## Goal + +Restore a coherent, operator-first cluster-detail experience without changing Orchestrator's topology APIs, recovery behavior, or existing operational semantics. The result should recover the clarity of the historical UI: a topology canvas is the focal point, and each instance is readable at a glance. + +## Scope + +The first release applies only to the cluster-detail workspace. It covers the shared chrome needed by that page, the cluster template, topology presentation, node cards, and cluster-scoped styling. + +It does not redesign non-cluster pages, change API response formats, replace recovery logic, remove drag/drop capabilities, or merge unrelated pending pull requests. + +## Design Direction + +The interface uses a compact dark application bar, a narrow preference rail, and a light topology canvas. This preserves the historical screen's information hierarchy while replacing its inconsistent Bootstrap-3/Bootstrap-5 hybrid appearance. + +The canvas displays the cluster name and a concise health summary above the topology. Replication links are visually quiet. Instance cards, rather than the framework chrome, carry operational meaning. + +## Node Cards + +Each visible instance card always shows: + +- Hostname and port +- Role: primary or replica +- Reachability/health state +- Replication lag +- Version +- Writable or read-only state + +Healthy cards are intentionally quiet. Warning, stale, and fatal cards have progressively stronger state treatment that remains understandable without relying on color alone. Existing actions remain available through one consistent overflow menu and the existing details/modal flow. Recovery and failover remain deliberate, confirmed operations. + +The initial release preserves the current interaction contracts, including existing action endpoints and drag/drop behavior. It changes presentation and action discoverability, not operational authority. + +## Implementation Architecture + +The existing cluster API and topology model remain the source of truth. The client continues to fetch and render the same instance data. + +The implementation creates a cluster-page visual boundary: + +1. Update the cluster page's markup and scripts to use a small, page-specific component structure. +2. Replace Glyphicon-dependent controls in that page with maintained, locally served icons or accessible text labels. +3. Add cluster-scoped CSS for the application bar, preference rail, canvas, links, stateful node cards, and responsive behavior. +4. Preserve the current D3 tree geometry for the first release. A later renderer replacement can target that boundary without changing the topology API or the rest of the application. + +No new global CSS compatibility shim is introduced. Any Bootstrap compatibility work remains isolated to existing legacy screens so the cluster view does not deepen the current hybrid dependency state. + +## State and Failure Handling + +While topology data is loading, the canvas shows a restrained loading state. If topology data is unavailable or stale, the page retains its structural layout and presents an explicit, non-destructive warning. Action controls remain unavailable until their target instance and current state have loaded. + +Node health, lag, and maintenance conditions use existing backend values. The UI does not infer recovery safety or alter failover eligibility. + +## Responsive Behavior + +Desktop retains the horizontal topology canvas. At narrow widths, the application bar condenses, the preference rail becomes a compact control group, and node cards stack by replication depth without truncating hostname, role, or status information. + +## Verification + +The Docker Compose lab provides three instances: `mysql1` as primary and `mysql2`/`mysql3` as replicas. Verification includes: + +- Cluster page renders against the real three-node topology. +- Primary/replica role, lag, read-only state, and links are legible. +- Existing node details and allowed actions remain reachable. +- Failure and stale-state visual treatment renders without hiding topology context. +- Desktop and narrow viewport layouts do not overlap or clip node content. +- Existing HTTP/template and relevant API tests continue to pass. + +## Deferred Work + +A full D3 renderer replacement, dashboard/list-page redesign, audit-page redesign, and broader dependency cleanup are separate follow-on projects. They must not be bundled into this restoration. diff --git a/docs/superpowers/specs/2026-08-12-failure-analysis-workspace-design.md b/docs/superpowers/specs/2026-08-12-failure-analysis-workspace-design.md new file mode 100644 index 000000000..bb30432bd --- /dev/null +++ b/docs/superpowers/specs/2026-08-12-failure-analysis-workspace-design.md @@ -0,0 +1,105 @@ +# Failure Analysis Workspace Design + +## Goal + +Replace the legacy floating-popover presentation on `/web/clusters-analysis` with a clear operational incident workspace that matches the restored Clusters and topology pages. Preserve the existing analysis APIs, recovery semantics, navigation destinations, authorization rules, and refresh behavior. + +## Scope + +This change covers only the Failure analysis page: + +- page shell and responsive layout; +- semantic rendering of clusters and analysis entries; +- severity, downtime, and blocked-recovery presentation; +- topology navigation; +- loading, empty, and unavailable states; +- focused automated and live-lab verification. + +It does not change failure-detection logic, recovery decisions, API response shapes, polling intervals, or other audit pages. + +## Visual Direction + +Use the same restrained operational style as the restored Clusters page: charcoal page header, warm neutral background, white bordered rows, compact typography, and blue primary actions. Severity color is reserved for state indicators rather than large decorative surfaces. + +The page consists of: + +1. A compact header containing the eyebrow “Recovery operations,” the title “Failure analysis,” a live incident-count summary, and a link back to the cluster dashboard. +2. A narrow summary strip identifying the row columns: cluster, active analysis, and impact/action. +3. One stacked row per affected cluster. Each row contains cluster identity, instance count, analysis entries, impact counts, and an “Open topology” action. +4. A calm empty-state panel when no actionable analysis exists. + +Rows remain single-column cards at narrow widths. No page-level horizontal scrolling is permitted. + +## Components and Responsibilities + +### Template shell + +`clusters_analysis.tmpl` owns the stable semantic structure: the workspace section, labelled header, live summary, list container, loading status, and scoped stylesheet/script references. JavaScript fills only the dynamic incident list and status text. + +### Analysis state preparation + +`clusters-analysis.js` keeps the existing API sequence and association logic, but separates data preparation from DOM rendering. It will derive a display model for each affected cluster containing: + +- canonical topology URL; +- display alias and cluster name; +- total instance count; +- sorted analysis entries; +- blocked and downtime flags; +- affected or participating replica counts; +- a page-level incident total. + +Default aliases equal to the cluster name are not displayed twice and use the canonical cluster-name route. + +### Incident rendering + +Each cluster becomes one semantic article with a stable cluster-name data hook. Analysis entries are rendered as a list rather than nested `
` fragments. Every entry exposes: + +- the analysis code as the primary label; +- the analyzed instance; +- a readable “Downtimed” or “Recovery blocked” state when applicable; +- the relevant replica-impact count. + +Existing links to prior blocking recoveries remain available in the global alert area. + +### Scoped styles + +A dedicated `clusters-analysis-workspace.css` styles only `#clusters_analysis_workspace`. It must not alter legacy popovers used elsewhere. Layout and state selectors use semantic workspace classes and `data-analysis-state` attributes, not Bootstrap popover internals. + +## Data Flow and States + +On document ready, the page shows its loading state and requests clusters, replication analysis, and blocked recoveries using the existing endpoints. Once all required data is available, JavaScript prepares the display model, updates the live incident count, and renders either affected-cluster rows or the empty state. Authorized users retain the existing refresh timer. + +If an API request fails, the loader is removed and the workspace displays a concise unavailable-state message with a reload action. Partial data must not be presented as a healthy empty state. + +The empty state says that no incidents currently require failover attention and briefly explains that the page reports actionable failure analysis. It does not print the entire internal `interestingAnalysis` list. + +## Accessibility and Interaction + +- The workspace title labels the main section. +- The incident count uses `role="status"` and polite live updates. +- Cluster rows are semantic articles with descriptive headings. +- State is conveyed by text and iconography in addition to color. +- Links have visible focus treatment and descriptive labels. +- Loading, empty, and unavailable states are readable without JavaScript-created popovers. + +## Testing + +Implementation follows red-green TDD. Tests cover: + +- rendered template shell and stylesheet contract; +- canonical topology routes for default and distinct aliases; +- incident-model derivation for normal, blocked, downtimed, and structural analysis; +- empty and unavailable rendering states; +- absence of legacy popover markup in the page renderer; +- JavaScript syntax and the complete Go HTTP test suite; +- the live Docker smoke suite and direct HTTP checks for the page and assets; +- browser verification of populated and empty layouts, topology navigation, focusable actions, console errors, and a narrow viewport. + +## Acceptance Criteria + +- Failure analysis visually belongs to the same product as the restored Clusters and topology pages. +- A user can identify the affected cluster, failing instance, analysis type, impact, and blocked/downtimed state at a glance. +- Every displayed topology action reaches a valid route. +- The page has intentional loading, empty, and error states. +- Existing backend analysis and recovery behavior is unchanged. +- The layout is usable without body-level horizontal overflow on narrow screens. diff --git a/docs/superpowers/specs/2026-08-12-live-failover-audit-ui-design.md b/docs/superpowers/specs/2026-08-12-live-failover-audit-ui-design.md new file mode 100644 index 000000000..66d2961be --- /dev/null +++ b/docs/superpowers/specs/2026-08-12-live-failover-audit-ui-design.md @@ -0,0 +1,77 @@ +# Live Failover and Audit UI Verification Design + +## Objective + +Exercise Orchestrator's history UI with real operational data from the existing three-node MySQL Docker lab. The pass must populate and inspect general audit operations, failure detections, recovery summaries, and a recovery detail page without recreating or deleting the MySQL containers or their volumes. + +## Chosen approach + +Use one controlled hard-primary failure because it produces the complete state chain needed by the UI: + +1. enable backend audit persistence in the functional Orchestrator configuration; +2. recreate only the Orchestrator container with `--no-deps`; +3. rediscover the healthy `mysql1` primary with `mysql2` and `mysql3` replicas; +4. stop only `mysql1`; +5. wait for `DeadMaster` detection and a successful automated recovery; +6. restart `mysql1` and restore the original lab topology and ProxySQL hostgroups; +7. retain the Orchestrator SQLite session so its audit and recovery history remains available for browser testing. + +This is preferable to a synthetic fixture because it validates the browser against the real API payloads. It is preferable to a forced logical failover because a logical failover does not cover the actual failure-detection history. + +## Safety boundaries + +- Record container IDs, health, topology roles, and ProxySQL writer state before mutation. +- Never run a broad `docker compose up`, `down`, volume removal, or dependency recreation. +- Recreate only Orchestrator and always use `--no-deps`. +- Stop and restart only the resolved current primary container. +- Preserve all MySQL volumes and container identities. +- Use bounded waits for detection, recovery, restart, and topology restoration. +- If recovery fails, restart the stopped primary immediately, capture diagnostics, and restore the original topology before further investigation. +- Confirm the original three MySQL container IDs and healthy state after restoration. + +## UI states to verify + +### Audit operations + +- At least one table row renders with timestamp, operation type, instance, and message. +- Pagination controls remain disabled/enabled according to available pages. +- Instance links use working registered routes. +- Long messages and identifiers do not create document-level horizontal overflow. + +### Failure detections + +- A `DeadMaster` detection row renders with failed instance, affected replicas, cluster, and detection time. +- Expanding the detection reveals recorded context and a working related-recovery link. +- API-derived values are displayed as text rather than executable markup. + +### Recoveries + +- A successful recovery row renders with failed instance and promoted successor. +- Opening the recovery renders the summary, acknowledgement state, related detection, and recovery steps. +- Empty, populated, and unavailable states remain mutually exclusive. + +### Existing topology pages + +- Cluster dashboard and topology reflect the promoted primary during recovery. +- After cleanup, the lab returns to `mysql1` as writable primary and `mysql2`/`mysql3` as running replicas. + +## Remediation policy + +Browser defects discovered with populated data will be reproduced by a focused failing test before production changes. Fixes will preserve existing API contracts and routes, keep CSS scoped below the history workspace, escape API-derived content, and retain narrow-screen table scrolling without document overflow. + +## Verification + +- Focused regression tests for every discovered defect, including a demonstrated red-to-green cycle. +- Full `go test ./go/http -count=1`. +- All Node UI behavior tests and JavaScript syntax checks. +- Functional smoke suite after topology restoration. +- Browser audit at desktop and 390px widths for the three populated history pages and recovery detail. +- Browser console audit for errors and warnings. +- Pre/post MySQL container-ID comparison and final role/replication checks. + +## Out of scope + +- PostgreSQL failover UI verification. +- Agent-enabled UI verification. +- Redesigning the topology workspace or global navigation. +- Acknowledging or deleting the generated recovery record solely to make the page look cleaner. diff --git a/docs/superpowers/specs/2026-08-18-consolidated-ui-integration-design.md b/docs/superpowers/specs/2026-08-18-consolidated-ui-integration-design.md new file mode 100644 index 000000000..000bdb0b1 --- /dev/null +++ b/docs/superpowers/specs/2026-08-18-consolidated-ui-integration-design.md @@ -0,0 +1,109 @@ +# Consolidated UI Integration Design + +## Goal + +Consolidate the useful work from UI PRs 122, 125, and 126 into one coherent Orchestrator interface that is visually consistent, operationally safe, responsive, and maintainable. PR 122 remains the integration branch because it already supplies the semantic workspaces and live-tested operational flows. PRs 125 and 126 are treated as source material, not merged wholesale. + +## Current Evidence + +The Docker lab was started with three MySQL instances and the PR 122 UI was inspected in the in-app browser at the normal 794-pixel application width. The Clusters, topology, failure-analysis, Audit, and Status pages render populated data without console errors. The strongest parts are the semantic topology cards, restrained operational color palette, explicit loading and empty states, and consistent workspace panels. + +The remaining visible weaknesses are concentrated in the global shell and legacy compatibility layer: navigation becomes crowded before it collapses, Glyphicon-dependent controls are inconsistent, typography and spacing vary between legacy and redesigned pages, and interaction compatibility is spread between templates and application JavaScript. + +PR 125 provides useful Bootstrap Icons, local Bootstrap 5 assets, cache-busting, and a D3 v7 port, but it is an independent one-commit rewrite that conflicts with PR 122 in seventeen UI files. Its current review also identifies an asset-version ordering bug, duplicate modal behavior, a tree-coordinate regression, and accessibility details. PR 126 restores a Bootstrap 3/IE8 shell and obsolete Openark destinations, so its rollback is not compatible with the chosen direction. + +## Integration Policy + +1. PR 122 is the only implementation base and remains the final integration PR. +2. Valuable PR 125 changes are reapplied deliberately in small, reviewable commits with focused regression tests. The PR 125 commit is not merged or cherry-picked wholesale. +3. PR 126 is not merged. Its valid intent—local, reliable framework assets and working legacy interactions—is already covered by the Bootstrap 5 compatibility design below. +4. PRs 125 and 126 remain open during implementation. After verification, each receives a concise record of what was incorporated or rejected. Closing or superseding either PR remains a maintainer decision. + +## Visual System + +The final interface keeps PR 122's charcoal chrome, warm neutral page background, white operational panels, blue primary actions, orange product accent, and state colors reserved for health and severity. + +The global header becomes the single source of visual navigation behavior: + +- the ProxySQL Orchestrator brand remains left aligned and links to the current repository; +- Home, Clusters, and Audit remain the primary navigation groups; +- search is a compact labelled control rather than an isolated narrow field; +- recovery, read-only, refresh, user, and problem indicators remain grouped at the right; +- navigation collapses at the large breakpoint so the current 794-pixel browser width receives a usable menu rather than a crowded desktop row; +- every icon-only action has an accessible name, visible focus state, and a text tooltip where useful. + +Bootstrap Icons replace Glyphicon presentation in the active Bootstrap 5 shell and redesigned pages. Text labels remain for important operational actions, so meaning never depends on icon shape or color alone. + +The semantic PR 122 workspaces remain intact. This integration standardizes their header heights, content widths, vertical rhythm, panel borders, table density, buttons, and responsive behavior without reverting them to generic Bootstrap examples. + +## Asset and Compatibility Architecture + +PR 122's existing local `/bootstrap5` assets remain the canonical Bootstrap bundle. This pass adds the local Bootstrap Icons font but does not delete the old `/bootstrap` directory; removing unused vendor assets is separate cleanup and is not required to make the interface correct. + +A single global asset version is injected into render data before either the content template or layout template executes. All first-party CSS and JavaScript references use that token. This replaces scattered date-based query strings and prevents a layout from receiving a value that content templates cannot see. + +Bootstrap 5 compatibility behavior is moved out of the layout template into one small `bootstrap-legacy-bridge.js` module. It owns only: + +- normalization of required legacy `data-toggle`, `data-target`, and `data-dismiss` attributes; +- the limited jQuery wrappers still used by Orchestrator for modals, dropdowns, popovers, tooltips, and alerts; +- idempotent initialization so DOM-ready processing cannot register handlers twice. + +Application behavior remains in the existing page scripts. The bridge must not own topology, recovery, audit, or modal business logic. + +## D3 Topology Migration + +PR 125's D3 v7 migration is incorporated as a behavior-preserving modernization, not a graph redesign. The port must retain: + +- the topology hierarchy and link geometry; +- collapse and expand behavior; +- node dragging and move-equivalent actions; +- stable transition origins through `x0` and `y0`; +- recalculated `x` and `y` positions after every hierarchy update; +- PR 122's semantic node-card markup and responsive canvas sizing. + +The old D3 v3 asset is removed only after automated graph-contract tests and browser collapse, expand, drag-exclusion, and modal checks pass against D3 v7. If parity cannot be demonstrated within this work, the icon, shell, and cache fixes remain independently shippable and the D3 migration stays in PR 125. + +## Page and Interaction Scope + +Browser review covers every route exposed by the global navigation plus the populated topology route: + +- Clusters and cluster topology; +- Failure analysis; +- Discover and Search; +- Audit operations, failure detections, recoveries, and recovery detail; +- Status, About, FAQ, Agents, and Seeds; +- node details, View menu, problem dropdown, navigation dropdowns, modal dismissal, audit pagination, and topology collapse/expand. + +Backend discovery, recovery decisions, authorization, polling intervals, and API response contracts are unchanged. The lab may seed Orchestrator discovery metadata, but UI validation must not deliberately change MySQL roles or run a failover unless a separate test explicitly requires it. + +## Data Flow and Error Handling + +The server renders a layout and content template with the same asset version and provider-aware template data. The browser loads local framework CSS, icon CSS, the Bootstrap bundle, the compatibility bridge, shared application scripts, and finally page-specific assets in a deterministic order. + +Page scripts retain the existing APIs and intentional loading, populated, empty, and unavailable states. A failed API request must become a visible unavailable state rather than a healthy empty state. Missing icons or optional visual assets must not block navigation or operational actions. Duplicate initialization must not produce duplicate requests, modal openings, or command execution. + +## Testing and Browser Verification + +Implementation follows red-green TDD. Focused tests cover: + +- asset-version availability in both layout and content templates; +- one-time compatibility-bridge initialization and delegated handlers; +- registered navigation destinations and current repository/documentation links; +- accessible icon-only controls and responsive navigation hooks; +- modal, dropdown, and problem-panel behavior; +- D3 v7 hierarchy coordinates, collapse/expand state, and semantic card preservation; +- absence of external Bootstrap CDN dependencies; +- absence of obsolete Openark links in the rendered shell. + +The complete Go, JavaScript, shell, and Docker smoke suites run after focused tests. Browser verification uses populated lab data at desktop width, the current 794-pixel application width, and 390-by-844 mobile width. Each route is checked for readable hierarchy, body-level overflow, console errors, working navigation, focusable controls, and stable populated or empty states. Code changes are followed by an explicit browser reload before visual judgment. + +## Acceptance Criteria + +- PR 122 presents one visually coherent product across all primary routes. +- The header is uncluttered and fully usable at desktop, 794-pixel, and mobile widths. +- Locally served Bootstrap 5 and Bootstrap Icons load without external CDN dependencies. +- Legacy modal, dropdown, popover, alert, and dismissal interactions work once per action. +- The topology graph retains PR 122's semantic cards and passes D3 v7 behavior parity, or the D3 migration is explicitly left out rather than partially merged. +- No API contract, authorization rule, recovery behavior, or MySQL topology is changed by the UI consolidation. +- Automated suites and browser QA pass before PR 122 is marked ready. +- PRs 125 and 126 receive an evidence-based disposition without being closed automatically. diff --git a/go/app/http.go b/go/app/http.go index d99393d26..1daad4b84 100644 --- a/go/app/http.go +++ b/go/app/http.go @@ -44,6 +44,13 @@ var sslPEMPassword []byte var agentSSLPEMPassword []byte var discoveryMetrics *collection.Collection +func revalidateStaticAssets(next nethttp.Handler) nethttp.Handler { + return nethttp.HandlerFunc(func(w nethttp.ResponseWriter, r *nethttp.Request) { + w.Header().Set("Cache-Control", "no-cache, must-revalidate") + next.ServeHTTP(w, r) + }) +} + // Http starts serving func Http(continuousDiscovery bool) { promptForSSLPasswords() @@ -106,7 +113,7 @@ func standardHttp(continuousDiscovery bool) { // Static file serving prefix := config.Config.URLPrefix - fileServer := nethttp.FileServer(nethttp.Dir("resources/public")) + fileServer := revalidateStaticAssets(nethttp.FileServer(nethttp.Dir("resources/public"))) if prefix != "" { router.Handle(prefix+"/*", nethttp.StripPrefix(prefix, fileServer)) } else { diff --git a/go/app/http_test.go b/go/app/http_test.go new file mode 100644 index 000000000..e044b5f61 --- /dev/null +++ b/go/app/http_test.go @@ -0,0 +1,34 @@ +/* + Copyright 2014 Outbrain Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package app + +import ( + nethttp "net/http" + "net/http/httptest" + "testing" +) + +func TestRevalidateStaticAssets(t *testing.T) { + next := nethttp.HandlerFunc(func(w nethttp.ResponseWriter, _ *nethttp.Request) { + w.WriteHeader(nethttp.StatusNoContent) + }) + rec := httptest.NewRecorder() + revalidateStaticAssets(next).ServeHTTP(rec, httptest.NewRequest(nethttp.MethodGet, "/css/orchestrator.css", nil)) + if got := rec.Header().Get("Cache-Control"); got != "no-cache, must-revalidate" { + t.Fatalf("Cache-Control = %q", got) + } +} diff --git a/go/http/api.go b/go/http/api.go index cd2ea45d7..95de5f858 100644 --- a/go/http/api.go +++ b/go/http/api.go @@ -402,6 +402,16 @@ func (this *HttpAPI) Resolve(w http.ResponseWriter, r *http.Request) { } // BeginMaintenance begins maintenance mode for given instance +type maintenanceBegunDetails struct { + inst.InstanceKey + MaintenanceKey int64 +} + +func maintenanceBegunResponse(instanceKey inst.InstanceKey, maintenanceKey int64) *APIResponse { + details := maintenanceBegunDetails{InstanceKey: instanceKey, MaintenanceKey: maintenanceKey} + return &APIResponse{Code: OK, Message: fmt.Sprintf("Maintenance begun: %+v", instanceKey), Details: details} +} + func (this *HttpAPI) BeginMaintenance(w http.ResponseWriter, r *http.Request) { if !isAuthorizedForAction(r) { Respond(w, &APIResponse{Code: ERROR, Message: "Unauthorized"}) @@ -419,7 +429,7 @@ func (this *HttpAPI) BeginMaintenance(w http.ResponseWriter, r *http.Request) { return } - Respond(w, &APIResponse{Code: OK, Message: fmt.Sprintf("Maintenance begun: %+v", instanceKey), Details: instanceKey}) + Respond(w, maintenanceBegunResponse(instanceKey, key)) } // EndMaintenance terminates maintenance mode diff --git a/go/http/api_test.go b/go/http/api_test.go index 8ff630b29..f607d2eb3 100644 --- a/go/http/api_test.go +++ b/go/http/api_test.go @@ -1,6 +1,7 @@ package http import ( + "encoding/json" "strings" "testing" @@ -9,6 +10,7 @@ import ( "github.com/proxysql/golib/log" test "github.com/proxysql/golib/tests" "github.com/proxysql/orchestrator/go/config" + "github.com/proxysql/orchestrator/go/inst" ) func init() { @@ -53,3 +55,39 @@ func TestKnownPaths(t *testing.T) { test.S(t).ExpectTrue(pathsMap[synonym]) } } + +func TestMaintenanceBegunResponsePreservesInstanceDetailsAndAddsCreatedMaintenanceKey(t *testing.T) { + response := maintenanceBegunResponse(inst.InstanceKey{Hostname: "mysql2", Port: 3306}, 42) + + encoded, err := json.Marshal(response) + if err != nil { + t.Fatal(err) + } + var decoded struct { + Code string + Message string + Details struct { + Hostname string + Port int + MaintenanceKey int64 + } + } + if err := json.Unmarshal(encoded, &decoded); err != nil { + t.Fatal(err) + } + if decoded.Code != "OK" { + t.Fatalf("expected OK code, got %q", decoded.Code) + } + if decoded.Message != "Maintenance begun: mysql2:3306" { + t.Fatalf("expected existing message to be preserved, got %q", decoded.Message) + } + if decoded.Details.Hostname != "mysql2" { + t.Fatalf("expected existing Details.Hostname mysql2, got %q", decoded.Details.Hostname) + } + if decoded.Details.Port != 3306 { + t.Fatalf("expected existing Details.Port 3306, got %d", decoded.Details.Port) + } + if decoded.Details.MaintenanceKey != 42 { + t.Fatalf("expected created maintenance key 42 in Details.MaintenanceKey, got %d", decoded.Details.MaintenanceKey) + } +} diff --git a/go/http/render.go b/go/http/render.go index 42ede0be5..26a261241 100644 --- a/go/http/render.go +++ b/go/http/render.go @@ -23,6 +23,7 @@ import ( "net/http" "os" "path/filepath" + "strconv" "sync" "github.com/proxysql/golib/log" @@ -52,6 +53,26 @@ var ( const templateDir = "resources" const layoutFile = "templates/layout" +var assetVersion = computeAssetVersion() + +func computeAssetVersion() string { + if exe, err := os.Executable(); err == nil { + if info, err := os.Stat(exe); err == nil { + return strconv.FormatInt(info.ModTime().UnixNano(), 10) + } + } + return strconv.Itoa(os.Getpid()) +} + +func injectAssetVersion(data interface{}, version string) interface{} { + if values, ok := data.(map[string]interface{}); ok && values != nil { + if _, exists := values["assetVersion"]; !exists { + values["assetVersion"] = version + } + } + return data +} + func loadLayoutSource() { layoutPath := filepath.Join(templateDir, layoutFile+".tmpl") b, err := os.ReadFile(layoutPath) @@ -94,6 +115,7 @@ func renderHTML(w http.ResponseWriter, status int, name string, data interface{} http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } + data = injectAssetVersion(data, assetVersion) var contentBuf bytes.Buffer if err := content.Execute(&contentBuf, data); err != nil { diff --git a/go/http/render_test.go b/go/http/render_test.go index ba03fe760..af4e41292 100644 --- a/go/http/render_test.go +++ b/go/http/render_test.go @@ -27,6 +27,8 @@ import ( "testing" "github.com/go-chi/chi/v5" + + "github.com/proxysql/orchestrator/go/config" ) // chdirToRepoRoot finds the repository root (directory containing resources/templates) @@ -60,6 +62,13 @@ func clearContentTemplateCache() { contentTemplateCache.Unlock() } +func setAssetVersionForTest(t *testing.T, version string) { + t.Helper() + old := assetVersion + assetVersion = version + t.Cleanup(func() { assetVersion = old }) +} + // contentTemplateNames returns every content template under resources/templates // (everything except layout). Discovered from disk so new templates are covered. func contentTemplateNames(t *testing.T) []string { @@ -106,6 +115,185 @@ func sampleTemplateData() map[string]interface{} { "recoveryUid": "", "pseudoGTIDModeEnabled": false, "contextMenuVisible": false, + "providerName": "MySQL", + "defaultInstancePort": 3306, + } +} + +func TestRenderedNavigationUsesCurrentProjectDestinations(t *testing.T) { + chdirToRepoRoot(t) + clearContentTemplateCache() + + rec := httptest.NewRecorder() + renderHTML(rec, http.StatusOK, "templates/about", sampleTemplateData()) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + for _, expected := range []string{ + `href="https://github.com/ProxySQL/orchestrator"`, + `href="https://proxysql.github.io/orchestrator/"`, + `href="https://github.com/ProxySQL/orchestrator/blob/master/docs/faq.md"`, + `>Documentation`, + `>Operations`, + `>Failure detections`, + `>Recoveries`, + } { + if !strings.Contains(body, expected) { + t.Errorf("rendered navigation is missing %q", expected) + } + } + if strings.Contains(strings.ToLower(body), "github.com/openark/orchestrator") { + t.Fatal("rendered navigation still links to the archived openark repository") + } + if strings.Contains(body, `href="/web/agents"`) || strings.Contains(body, `href="/web/seeds"`) { + t.Fatal("agent-only navigation must be hidden while the agent HTTP service is disabled") + } +} + +func TestRenderedNavigationShowsAgentToolsWhenEnabled(t *testing.T) { + chdirToRepoRoot(t) + clearContentTemplateCache() + + data := sampleTemplateData() + data["agentsHttpActive"] = true + rec := httptest.NewRecorder() + renderHTML(rec, http.StatusOK, "templates/about", data) + body := rec.Body.String() + for _, expected := range []string{`href="/web/agents"`, `href="/web/seeds"`} { + if !strings.Contains(body, expected) { + t.Errorf("agent-enabled navigation is missing %q", expected) + } + } +} + +func TestRenderAboutDescribesCurrentProject(t *testing.T) { + chdirToRepoRoot(t) + clearContentTemplateCache() + + rec := httptest.NewRecorder() + renderHTML(rec, http.StatusOK, "templates/about", sampleTemplateData()) + body := rec.Body.String() + for _, expected := range []string{ + `id="about_workspace"`, + `MySQL 9.7`, + `PostgreSQL 12`, + `ProxySQL/orchestrator`, + `Apache License 2.0`, + `/css/legacy-workspace.css`, + } { + if !strings.Contains(body, expected) { + t.Errorf("modern About page is missing %q", expected) + } + } +} + +func TestRenderOperationalPagesHaveIntentionalStates(t *testing.T) { + chdirToRepoRoot(t) + clearContentTemplateCache() + + tests := []struct { + template string + hook string + message string + }{ + {template: "templates/status", hook: `id="status_workspace"`, message: "Loading node status"}, + {template: "templates/audit", hook: `id="audit_empty"`, message: "No audit operations recorded"}, + {template: "templates/audit_failure_detection", hook: `id="audit_empty"`, message: "No failure detections recorded"}, + {template: "templates/audit_recovery", hook: `id="audit_empty"`, message: "No recoveries recorded"}, + {template: "templates/agents", hook: `id="agents_disabled"`, message: "Agent HTTP service is disabled"}, + {template: "templates/seeds", hook: `id="seeds_disabled"`, message: "Agent HTTP service is disabled"}, + } + for _, tt := range tests { + t.Run(tt.template, func(t *testing.T) { + rec := httptest.NewRecorder() + renderHTML(rec, http.StatusOK, tt.template, sampleTemplateData()) + body := rec.Body.String() + for _, expected := range []string{tt.hook, tt.message, `/css/legacy-workspace.css`} { + if !strings.Contains(body, expected) { + t.Errorf("rendered page is missing %q", expected) + } + } + }) + } +} + +func TestRenderStatusWorkspaceHasStructuredHealthTable(t *testing.T) { + chdirToRepoRoot(t) + clearContentTemplateCache() + + rec := httptest.NewRecorder() + renderHTML(rec, http.StatusOK, "templates/status", sampleTemplateData()) + body := rec.Body.String() + for _, expected := range []string{ + `id="status_summary"`, + ``, + ``, + `Node`, + `Hostname`, + `id="status_actions"`, + } { + if !strings.Contains(body, expected) { + t.Errorf("structured Status workspace is missing %q", expected) + } + } +} + +func TestDiscoverUsesConfiguredProviderAndPort(t *testing.T) { + chdirToRepoRoot(t) + clearContentTemplateCache() + + oldProvider, oldPort := config.Config.ProviderType, config.Config.DefaultInstancePort + config.Config.ProviderType, config.Config.DefaultInstancePort = "postgresql", 5432 + t.Cleanup(func() { + config.Config.ProviderType, config.Config.DefaultInstancePort = oldProvider, oldPort + }) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/web/discover", nil) + Web.Discover(rec, req) + body := rec.Body.String() + for _, expected := range []string{ + `id="discover_workspace"`, + `Enter a PostgreSQL hostname and port`, + `value="5432"`, + } { + if !strings.Contains(body, expected) { + t.Errorf("provider-aware Discover page is missing %q", expected) + } + } +} + +func TestAgentDetailRoutesExplainWhenAgentHTTPIsDisabled(t *testing.T) { + chdirToRepoRoot(t) + clearContentTemplateCache() + + oldServeAgents := config.Config.ServeAgentsHttp + config.Config.ServeAgentsHttp = false + t.Cleanup(func() { config.Config.ServeAgentsHttp = oldServeAgents }) + + tests := []struct { + name string + path string + handler http.HandlerFunc + disabledID string + legacyJS string + }{ + {name: "agent detail", path: "/web/agent/mysql1", handler: Web.Agent, disabledID: `id="agents_disabled"`, legacyJS: `/js/agent.js`}, + {name: "seed detail", path: "/web/seed-details/1", handler: Web.AgentSeedDetails, disabledID: `id="seeds_disabled"`, legacyJS: `/js/seed.js`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rec := httptest.NewRecorder() + tt.handler(rec, httptest.NewRequest(http.MethodGet, tt.path, nil)) + body := rec.Body.String() + if !strings.Contains(body, tt.disabledID) || !strings.Contains(body, "Agent HTTP service is disabled") { + t.Fatalf("disabled agent route did not explain its state: %s", truncate(body, 600)) + } + if strings.Contains(body, tt.legacyJS) { + t.Errorf("disabled agent route still loads %q", tt.legacyJS) + } + }) } } @@ -137,6 +325,282 @@ func TestRenderHTMLYield(t *testing.T) { } } +func TestRenderHTMLUsesLocalBootstrapAssets(t *testing.T) { + chdirToRepoRoot(t) + clearContentTemplateCache() + setAssetVersionForTest(t, "asset-test-42") + + rec := httptest.NewRecorder() + renderHTML(rec, http.StatusOK, "templates/clusters", sampleTemplateData()) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + for _, expected := range []string{ + `/bootstrap5/css/bootstrap.min.css?v=asset-test-42`, + `/bootstrap5/js/bootstrap.bundle.min.js?v=asset-test-42`, + `/js/bootstrap-legacy-bridge.js?v=asset-test-42`, + `/bootstrap-icons/font/bootstrap-icons.min.css?v=asset-test-42`, + } { + if !strings.Contains(body, expected) { + t.Fatalf("expected locally served Bootstrap asset %q, body snippet: %s", expected, truncate(body, 500)) + } + } + if strings.Contains(body, "cdn.jsdelivr.net/npm/bootstrap") { + t.Fatal("rendered layout still depends on the external Bootstrap CDN") + } + bundle := strings.Index(body, `/bootstrap5/js/bootstrap.bundle.min.js?v=asset-test-42`) + bridge := strings.Index(body, `/js/bootstrap-legacy-bridge.js?v=asset-test-42`) + if bundle < 0 || bridge < bundle { + t.Fatal("Bootstrap bundle must load before the compatibility bridge") + } + if strings.Contains(body, "Bootstrap 5 compatibility: map legacy") { + t.Fatal("layout still embeds the compatibility bridge inline") + } +} + +func TestRenderHTMLSharesAssetVersionWithContentAndLayout(t *testing.T) { + chdirToRepoRoot(t) + clearContentTemplateCache() + setAssetVersionForTest(t, "asset-test-42") + + rec := httptest.NewRecorder() + renderHTML(rec, http.StatusOK, "templates/clusters_analysis", sampleTemplateData()) + body := rec.Body.String() + for _, want := range []string{ + `/css/orchestrator.css?v=asset-test-42`, + `/js/clusters-analysis.js?v=asset-test-42`, + } { + if !strings.Contains(body, want) { + t.Errorf("rendered page missing shared asset version %q", want) + } + } +} + +func TestRenderedNavigationUsesRegisteredClusterRoutes(t *testing.T) { + chdirToRepoRoot(t) + clearContentTemplateCache() + + rec := httptest.NewRecorder() + renderHTML(rec, http.StatusOK, "templates/clusters", sampleTemplateData()) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + for _, expected := range []string{ + `href="/web/clusters"`, + `href="/web/clusters-analysis"`, + } { + if !strings.Contains(body, expected) { + t.Errorf("rendered navigation is missing registered route %q", expected) + } + } + for _, broken := range []string{ + `href="/web/clusters/"`, + `href="/web/clusters-analysis/"`, + } { + if strings.Contains(body, broken) { + t.Errorf("rendered navigation links to unregistered route %q", broken) + } + } +} + +func TestResponsiveShellUsesBootstrapIcons(t *testing.T) { + chdirToRepoRoot(t) + setAssetVersionForTest(t, "asset-test-42") + rec := httptest.NewRecorder() + renderHTML(rec, http.StatusOK, "templates/clusters", sampleTemplateData()) + body := rec.Body.String() + for _, want := range []string{ + `navbar-expand-lg`, + `aria-label="Search instances"`, + `aria-label="Submit search"`, + `class="bi bi-search"`, + `id="nav_operational_status"`, + } { + if !strings.Contains(body, want) { + t.Errorf("responsive shell missing %q", want) + } + } +} + +func TestAgentDetailUsesBootstrapGrid(t *testing.T) { + chdirToRepoRoot(t) + source, err := os.ReadFile("resources/templates/agent.tmpl") + if err != nil { + t.Fatal(err) + } + body := string(source) + if !strings.Contains(body, `class="row g-3"`) || strings.Count(body, `class="col-md-6"`) != 2 { + t.Fatal("agent Info and Snapshots must share one two-column Bootstrap row") + } +} + +func TestRenderClustersWorkspace(t *testing.T) { + chdirToRepoRoot(t) + clearContentTemplateCache() + + rec := httptest.NewRecorder() + renderHTML(rec, http.StatusOK, "templates/clusters", sampleTemplateData()) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + for _, expected := range []string{ + `id="clusters_workspace"`, + `aria-labelledby="clusters_workspace_title"`, + `id="clusters_workspace_title"`, + `id="clusters_known_count"`, + `role="status"`, + `href="/web/discover"`, + `>Discover instance`, + `id="clusters_list"`, + `id="clusters"`, + `/css/clusters-workspace.css`, + } { + if !strings.Contains(body, expected) { + t.Fatalf("expected clusters workspace contract %q, body snippet: %s", expected, truncate(body, 500)) + } + } +} + +func TestRenderClustersAnalysisWorkspace(t *testing.T) { + chdirToRepoRoot(t) + clearContentTemplateCache() + + rec := httptest.NewRecorder() + renderHTML(rec, http.StatusOK, "templates/clusters_analysis", sampleTemplateData()) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + for _, expected := range []string{ + `id="clusters_analysis_workspace"`, + `aria-labelledby="clusters_analysis_title"`, + `id="clusters_analysis_summary"`, + `role="status"`, + `id="clusters_analysis_loading"`, + `id="clusters_analysis_list"`, + `href="/web/clusters"`, + `/css/clusters-analysis-workspace.css`, + } { + if !strings.Contains(body, expected) { + t.Errorf("expected failure analysis workspace contract %q", expected) + } + } +} + +func TestRenderClustersAnalysisWorkspaceUsesCurrentAssetRevision(t *testing.T) { + chdirToRepoRoot(t) + clearContentTemplateCache() + setAssetVersionForTest(t, "asset-test-42") + + rec := httptest.NewRecorder() + renderHTML(rec, http.StatusOK, "templates/clusters_analysis", sampleTemplateData()) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + for _, expected := range []string{ + `href="/css/clusters-analysis-workspace.css?v=asset-test-42"`, + `src="/js/clusters-analysis.js?v=asset-test-42"`, + } { + if !strings.Contains(body, expected) { + t.Errorf("expected current failure analysis asset revision %q", expected) + } + } +} + +func TestRenderClusterWorkspace(t *testing.T) { + chdirToRepoRoot(t) + clearContentTemplateCache() + + rec := httptest.NewRecorder() + renderHTML(rec, http.StatusOK, "templates/cluster", sampleTemplateData()) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + for _, expected := range []string{ + `id="cluster_workspace"`, + `id="cluster_canvas"`, + `/css/cluster-workspace.css`, + } { + if !strings.Contains(body, expected) { + t.Fatalf("expected cluster workspace contract %q, body snippet: %s", expected, truncate(body, 500)) + } + } +} + +func TestRenderClusterWorkspacePreservesLegacyHooks(t *testing.T) { + chdirToRepoRoot(t) + clearContentTemplateCache() + + rec := httptest.NewRecorder() + renderHTML(rec, http.StatusOK, "templates/cluster", sampleTemplateData()) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + body := rec.Body.String() + for _, expected := range []string{ + `id="cluster_sidebar"`, + `id="cluster_container"`, + `id="node_modal"`, + `id="cluster_view_toggle"`, + `data-bs-toggle="dropdown"`, + `aria-controls="cluster_view_menu"`, + `>View`, + `id="cluster_view_menu"`, + } { + if !strings.Contains(body, expected) { + t.Fatalf("expected preserved cluster hook %q, body snippet: %s", expected, truncate(body, 500)) + } + } + + headerEnd := strings.Index(body, ``) + viewMenu := strings.Index(body, `id="cluster_sidebar"`) + if headerEnd == -1 || viewMenu == -1 || viewMenu > headerEnd { + t.Fatal("expected cluster View menu to remain inline with the cluster header") + } + infoCommand := strings.Index(body, `data-command="info"`) + if infoCommand == -1 { + t.Fatal("expected rendered cluster information command") + } + infoCommandEnd := strings.Index(body[infoCommand:], ``) + if infoCommandEnd == -1 { + t.Fatal("expected rendered cluster information command") + } + infoMarkup := body[infoCommand : infoCommand+infoCommandEnd] + if count := strings.Count(infoMarkup, ` 0 { + t.Errorf("failure analysis stylesheet selectors must be scoped below #clusters_analysis_workspace: %q", unscoped) + } + + intermediateBreakpointFound := false + mediaPattern := regexp.MustCompile(`@media\s*\(max-width:\s*(\d+)px\)\s*\{`) + columnPattern := regexp.MustCompile(`(?s)` + regexp.QuoteMeta("#clusters_analysis_workspace .clusters-analysis-columns") + `\s*\{([^}]*)\}`) + clusterPattern := regexp.MustCompile(`(?s)` + regexp.QuoteMeta("#clusters_analysis_workspace .analysis-cluster") + `\s*\{([^}]*)\}`) + flexibleGrid := "grid-template-columns: minmax(0, .9fr) minmax(0, 1.6fr) minmax(0, .7fr);" + for _, match := range mediaPattern.FindAllStringSubmatchIndex(css, -1) { + breakpoint, err := strconv.Atoi(css[match[2]:match[3]]) + if err != nil || breakpoint <= 760 { + continue + } + openBrace := match[1] - 1 + closeBrace := matchingCSSBrace(css, openBrace) + if closeBrace < 0 { + continue + } + mediaCSS := css[openBrace+1 : closeBrace] + columns := columnPattern.FindStringSubmatch(mediaCSS) + cluster := clusterPattern.FindStringSubmatch(mediaCSS) + if columns != nil && cluster != nil && + strings.Contains(columns[1], flexibleGrid) && strings.Contains(cluster[1], flexibleGrid) { + intermediateBreakpointFound = true + break + } + } + if !intermediateBreakpointFound { + t.Error("failure analysis stylesheet needs an intermediate breakpoint above 760px with flexible label and row grids") + } + + braceDepth := 0 + for _, character := range css { + switch character { + case '{': + braceDepth++ + case '}': + braceDepth-- + } + if braceDepth < 0 { + break + } + } + if braceDepth != 0 { + t.Error("failure analysis stylesheet has unbalanced braces") + } +} + +func TestUnscopedWorkspaceCSSSelectorsRejectsArbitraryGlobalRule(t *testing.T) { + css := ` +#clusters_analysis_workspace .analysis-cluster { display: grid; } +@media (max-width: 760px) { + #clusters_analysis_workspace .analysis-cluster { display: block; } + .unexpected-global { display: none; } +}` + + got := unscopedWorkspaceCSSSelectors(css, "#clusters_analysis_workspace") + want := []string{".unexpected-global"} + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Fatalf("unscopedWorkspaceCSSSelectors() = %q, want %q", got, want) + } +} + +func TestClusterTopologyRendererUsesWorkspaceCanvasViewport(t *testing.T) { + chdirToRepoRoot(t) + + source, err := os.ReadFile(filepath.Join("resources", "public", "js", "cluster-tree.js")) + if err != nil { + t.Fatal(err) + } + for _, snippet := range []string{ + `var viewport = $("#cluster_canvas");`, + `viewport = $("#cluster_container");`, + `var svgWidth = Math.max(viewport.width() - margin.right - margin.left, topologyWidth);`, + `var topologyWidth = (maxDepth + 1) * horizontalSpacing;`, + `var svgHeight = viewport.height() - margin.top - margin.bottom;`, + } { + if !strings.Contains(string(source), snippet) { + t.Errorf("cluster-tree.js must size the topology from the workspace viewport: missing %q", snippet) + } + } + + css, err := os.ReadFile(filepath.Join("resources", "public", "css", "cluster-workspace.css")) + if err != nil { + t.Fatal(err) + } + for _, snippet := range []string{ + `#cluster_workspace #cluster_canvas`, + `overflow: auto;`, + `#cluster_workspace #cluster_wrapper`, + `min-width: 960px;`, + `#cluster_workspace .instance.instance-diagram`, + } { + if !strings.Contains(string(css), snippet) { + t.Errorf("cluster workspace must retain explorable topology cards on narrow screens: missing %q", snippet) + } + } +} + +func TestClusterWorkspaceCommandsPreventAnchorNavigation(t *testing.T) { + chdirToRepoRoot(t) + + source, err := os.ReadFile(filepath.Join("resources", "public", "js", "cluster.js")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(source), `$("#cluster_sidebar").on("click", "a[data-command]", function(event) { + event.preventDefault(); + });`) { + t.Fatal("cluster workspace command rail does not prevent href default navigation") + } +} + +func TestClusterNodeCardDragCancelKeepsSemanticTextDraggable(t *testing.T) { + chdirToRepoRoot(t) + + source, err := os.ReadFile(filepath.Join("resources", "public", "js", "cluster.js")) + if err != nil { + t.Fatal(err) + } + match := regexp.MustCompile(`cancel:\s*"([^"]+)"`).FindStringSubmatch(string(source)) + if match == nil { + t.Fatal("cluster instance draggable does not define a cancel selector") + } + cancelled := make(map[string]bool) + for _, selector := range strings.Split(match[1], ",") { + cancelled[strings.TrimSpace(selector)] = true + } + if cancelled["span"] { + t.Fatal("semantic card text spans must remain draggable") + } + for _, selector := range []string{"button", "a", ".instance-glyphs", ".instance-trailer"} { + if !cancelled[selector] { + t.Errorf("cluster instance draggable must cancel actual control %q", selector) + } + } +} + +func TestNonClusterDetailsTriggerOpensNodeModal(t *testing.T) { + chdirToRepoRoot(t) + + source, err := os.ReadFile(filepath.Join("resources", "public", "js", "orchestrator.js")) + if err != nil { + t.Fatal(err) + } + want := regexp.MustCompile(`(?s)if \(renderType != "cluster"\) \{\s*popoverElement\.find\("\[data-node-details\]"\)\.click\(function\(e\) \{\s*e\.preventDefault\(\);\s*e\.stopPropagation\(\);\s*openNodeModal\(instance\);`) + if !want.Match(source) { + t.Fatal("non-cluster details trigger does not open the existing node modal") + } +} + +func TestOpenNodeModalExplicitlyShowsBootstrapModal(t *testing.T) { + chdirToRepoRoot(t) + + source, err := os.ReadFile(filepath.Join("resources", "public", "js", "orchestrator.js")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(source), `$('#node_modal').modal('show');`) { + t.Fatal("openNodeModal must explicitly show the Bootstrap modal") + } +} + +func TestClusterProblemsPlacementTracksNavbarBreakpoint(t *testing.T) { + chdirToRepoRoot(t) + + source, err := os.ReadFile(filepath.Join("resources", "public", "js", "cluster.js")) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + `window.matchMedia("(min-width: 992px)")`, + `appendTo(".cluster-workspace-header")`, + `appendTo('[data-nav-page="problems"]')`, + `.off("resize.clusterProblems")`, + `.on("resize.clusterProblems", placeClusterProblems)`, + } { + if !strings.Contains(string(source), want) { + t.Errorf("cluster problems placement must contain %q", want) + } + } +} + +func TestIconOnlyActionsHaveAccessibleNames(t *testing.T) { + chdirToRepoRoot(t) + + clustersSource, err := os.ReadFile(filepath.Join("resources", "public", "js", "clusters.js")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(clustersSource), `aria-label="Open compact topology"`) { + t.Error("compact topology link must have an accessible name") + } + + auditSource, err := os.ReadFile(filepath.Join("resources", "public", "js", "audit-recovery.js")) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + ` + + + -
-
-
-
+
+
+
+
+
+
+
+ + @@ -120,15 +133,6 @@
-
- -
{{yield}} @@ -158,7 +162,7 @@
@@ -169,7 +173,7 @@ @@ -179,23 +183,23 @@